First prototype
This commit is contained in:
parent
53a1a043c7
commit
e881bc4998
11
README.md
11
README.md
|
|
@ -17,4 +17,13 @@ cmake --build build -j
|
||||||
## Next
|
## Next
|
||||||
- Add qtmcp dependency and implement server with tool registration.
|
- Add qtmcp dependency and implement server with tool registration.
|
||||||
- Implement adapters: embedder(s) + vector store(s).
|
- Implement adapters: embedder(s) + vector store(s).
|
||||||
- Wire `save_context`/`recall_context` to persistent store.
|
- Flesh out Postgres DAL paths (prepared statements + pgvector wiring).
|
||||||
|
|
||||||
|
## Memory Tools
|
||||||
|
- `kom.memory.v1.save_context` persists conversational or workspace state in a namespace.
|
||||||
|
- `kom.memory.v1.recall_context` retrieves stored context by key, tags, or time window.
|
||||||
|
- See `docs/using-memory-tools.md` for integration notes (Codey, Claude Code) and request samples.
|
||||||
|
|
||||||
|
## Integrations
|
||||||
|
- **Kompanion-Konsole** — demo plugin for KDE Konsole that lets agents hand terminals over to the Kompanion runtime. See `integrations/konsole/README.md`.
|
||||||
|
- **JavaScript helpers** — Node.js utilities that call the MCP memory tools from scripts or web extensions. See `integrations/js/`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Kompanion Configuration
|
||||||
|
|
||||||
|
Kompanion adheres to KDE’s KConfig conventions so deployments are kioskable and compatible with other desktop tooling.
|
||||||
|
|
||||||
|
## Configuration File
|
||||||
|
- Location: `${XDG_CONFIG_HOME:-~/.config}/kompanionrc`
|
||||||
|
- Group: `[Database]`
|
||||||
|
- Key: `PgDsn=postgresql://user:pass@host/dbname`
|
||||||
|
|
||||||
|
The CLI (`kompanion`) and MCP runner (`kom_mcp`) fall back to this entry when the `PG_DSN` environment variable is not set. If neither are present the in-memory DAL stub is used.
|
||||||
|
|
||||||
|
## Future HTTP Streaming
|
||||||
|
While current tooling focuses on stdio dispatch (for editor and agent integration), the roadmap includes an HTTP/2 or WebSocket streaming surface so MCP clients can maintain persistent conversations without leaving CLI compatibility behind. The same configuration keys will apply for both transports.
|
||||||
|
|
||||||
|
## Test Database
|
||||||
|
Bootstrap a local Postgres instance using the provided scripts:
|
||||||
|
```bash
|
||||||
|
ROLE=kompanion PASS=komup db/scripts/create-test-db.sh kompanion_test
|
||||||
|
```
|
||||||
|
This loads the schemas from `db/init/` and prepares the DSN you can reference in `kompanionrc`.
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Using Save/Recall Context Tools
|
||||||
|
|
||||||
|
The Kompanion MCP backend exposes `kom.memory.v1.save_context` and `kom.memory.v1.recall_context` so editor-embedded agents can persist and retrieve working memory.
|
||||||
|
|
||||||
|
## Registering the tools
|
||||||
|
1. Ensure the MCP client loads the server manifest (see `src/mcp/ToolSchemas.json`).
|
||||||
|
2. Add both tool names to the capability list you pass into the MCP handshake.
|
||||||
|
3. When the client calls `register_default_tools`, they become available to dispatch via `KomMcpServer::dispatch`.
|
||||||
|
|
||||||
|
## Client adapter hints
|
||||||
|
- **Codey (Google)** – map Codey’s `tool_invocation` hooks to MCP calls. Persist summary/state blobs after each completion:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "kom.memory.v1.save_context",
|
||||||
|
"arguments": {
|
||||||
|
"namespace": "project:metal",
|
||||||
|
"key": "codey/session",
|
||||||
|
"content": {"summary": "Refactored PgDal for TTL support"},
|
||||||
|
"tags": ["codey", "memory"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
On session start, call `kom.memory.v1.recall_context` with the namespace/key to warm the local context buffer.
|
||||||
|
- **Claude Code (Anthropic)** – use the `tool_use` event to flush conversational checkpoints:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "kom.memory.v1.recall_context",
|
||||||
|
"arguments": {
|
||||||
|
"namespace": "thread:123",
|
||||||
|
"limit": 5,
|
||||||
|
"tags": ["task"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Feed the returned snippets back into Claude’s prompt so follow-up completions have grounding data.
|
||||||
|
|
||||||
|
## Response fields
|
||||||
|
- `save_context` returns `{ "id": string, "created_at": ISO8601 }`.
|
||||||
|
- `recall_context` returns `{ "items": [{ "id", "key?", "content", "tags", "created_at" }] }`.
|
||||||
|
|
||||||
|
## Testing locally
|
||||||
|
```bash
|
||||||
|
cmake -S . -B build
|
||||||
|
cmake --build build
|
||||||
|
ctest --test-dir build --output-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
|
These commands build `kom_mcp` plus the test harness that exercises the new context tools.
|
||||||
|
|
@ -2,9 +2,13 @@
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <chrono>
|
||||||
|
#include <ctime>
|
||||||
|
#include <iomanip>
|
||||||
#include <numeric>
|
#include <numeric>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
#ifdef HAVE_PG
|
#ifdef HAVE_PG
|
||||||
#include <pqxx/array>
|
#include <pqxx/array>
|
||||||
|
|
@ -152,6 +156,7 @@ std::string PgDal::upsertItem(const ItemRow& row) {
|
||||||
return pgUpsertItem(row).first;
|
return pgUpsertItem(row).first;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
const auto now = std::chrono::system_clock::now();
|
||||||
ItemRow stored = row;
|
ItemRow stored = row;
|
||||||
if (stored.id.empty()) {
|
if (stored.id.empty()) {
|
||||||
stored.id = allocateId(nextItemId_, "item_");
|
stored.id = allocateId(nextItemId_, "item_");
|
||||||
|
|
@ -160,6 +165,21 @@ std::string PgDal::upsertItem(const ItemRow& row) {
|
||||||
auto existing = items_.find(stored.id);
|
auto existing = items_.find(stored.id);
|
||||||
if (existing != items_.end()) {
|
if (existing != items_.end()) {
|
||||||
stored.revision = existing->second.revision + 1;
|
stored.revision = existing->second.revision + 1;
|
||||||
|
if (stored.created_at == std::chrono::system_clock::time_point{}) {
|
||||||
|
stored.created_at = existing->second.created_at;
|
||||||
|
}
|
||||||
|
if (!stored.expires_at && existing->second.expires_at) {
|
||||||
|
stored.expires_at = existing->second.expires_at;
|
||||||
|
}
|
||||||
|
if (stored.metadata_json.empty()) {
|
||||||
|
stored.metadata_json = existing->second.metadata_json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stored.created_at == std::chrono::system_clock::time_point{}) {
|
||||||
|
stored.created_at = now;
|
||||||
|
}
|
||||||
|
if (!stored.metadata_json.empty()) {
|
||||||
|
// keep as provided
|
||||||
}
|
}
|
||||||
|
|
||||||
items_[stored.id] = stored;
|
items_[stored.id] = stored;
|
||||||
|
|
@ -240,10 +260,12 @@ std::vector<ItemRow> PgDal::searchText(const std::string& namespaceId,
|
||||||
if (bucketIt == itemsByNamespace_.end()) return results;
|
if (bucketIt == itemsByNamespace_.end()) return results;
|
||||||
|
|
||||||
const std::string loweredQuery = toLower(query);
|
const std::string loweredQuery = toLower(query);
|
||||||
|
const auto now = std::chrono::system_clock::now();
|
||||||
|
|
||||||
for (const auto& itemId : bucketIt->second) {
|
for (const auto& itemId : bucketIt->second) {
|
||||||
auto itemIt = items_.find(itemId);
|
auto itemIt = items_.find(itemId);
|
||||||
if (itemIt == items_.end()) continue;
|
if (itemIt == items_.end()) continue;
|
||||||
|
if (isExpired(itemIt->second, now)) continue;
|
||||||
|
|
||||||
if (!loweredQuery.empty()) {
|
if (!loweredQuery.empty()) {
|
||||||
const std::string loweredText = toLower(itemIt->second.text.value_or(std::string()));
|
const std::string loweredText = toLower(itemIt->second.text.value_or(std::string()));
|
||||||
|
|
@ -273,10 +295,14 @@ std::vector<std::pair<std::string, float>> PgDal::searchVector(
|
||||||
|
|
||||||
auto bucketIt = itemsByNamespace_.find(namespaceId);
|
auto bucketIt = itemsByNamespace_.find(namespaceId);
|
||||||
if (bucketIt == itemsByNamespace_.end()) return scores;
|
if (bucketIt == itemsByNamespace_.end()) return scores;
|
||||||
|
const auto now = std::chrono::system_clock::now();
|
||||||
|
|
||||||
for (const auto& itemId : bucketIt->second) {
|
for (const auto& itemId : bucketIt->second) {
|
||||||
auto chunkBucketIt = chunksByItem_.find(itemId);
|
auto chunkBucketIt = chunksByItem_.find(itemId);
|
||||||
if (chunkBucketIt == chunksByItem_.end()) continue;
|
if (chunkBucketIt == chunksByItem_.end()) continue;
|
||||||
|
auto itemIt = items_.find(itemId);
|
||||||
|
if (itemIt == items_.end()) continue;
|
||||||
|
if (isExpired(itemIt->second, now)) continue;
|
||||||
|
|
||||||
float bestScore = -1.0f;
|
float bestScore = -1.0f;
|
||||||
for (const auto& chunkId : chunkBucketIt->second) {
|
for (const auto& chunkId : chunkBucketIt->second) {
|
||||||
|
|
@ -323,6 +349,61 @@ std::optional<ItemRow> PgDal::getItemById(const std::string& id) const {
|
||||||
return it->second;
|
return it->second;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<ItemRow> PgDal::fetchContext(const std::string& namespaceId,
|
||||||
|
const std::optional<std::string>& key,
|
||||||
|
const std::vector<std::string>& tags,
|
||||||
|
const std::optional<std::string>& sinceIso,
|
||||||
|
int limit) const {
|
||||||
|
#ifdef HAVE_PG
|
||||||
|
if (connected_ && !useInMemory_) {
|
||||||
|
// TODO: implement direct Postgres query once DAL wiring is complete.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
std::vector<ItemRow> results;
|
||||||
|
if (!connected_ || limit == 0) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto bucketIt = itemsByNamespace_.find(namespaceId);
|
||||||
|
if (bucketIt == itemsByNamespace_.end()) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto now = std::chrono::system_clock::now();
|
||||||
|
const bool filterSince = sinceIso.has_value() && !sinceIso->empty();
|
||||||
|
|
||||||
|
for (auto it = bucketIt->second.rbegin(); it != bucketIt->second.rend(); ++it) {
|
||||||
|
auto itemIt = items_.find(*it);
|
||||||
|
if (itemIt == items_.end()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ItemRow& row = itemIt->second;
|
||||||
|
if (isExpired(row, now)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key && row.key != key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!tags.empty() && !includesAllTags(row.tags, tags)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (filterSince) {
|
||||||
|
const std::string createdIso = toIso8601(row.created_at);
|
||||||
|
if (!createdIso.empty() && createdIso < sinceIso.value()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push_back(row);
|
||||||
|
if (limit > 0 && static_cast<int>(results.size()) >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
std::pair<std::string, int> PgDal::upsertItem(
|
std::pair<std::string, int> PgDal::upsertItem(
|
||||||
const std::string& namespace_id,
|
const std::string& namespace_id,
|
||||||
const std::optional<std::string>& key,
|
const std::optional<std::string>& key,
|
||||||
|
|
@ -332,7 +413,8 @@ std::pair<std::string, int> PgDal::upsertItem(
|
||||||
ItemRow row;
|
ItemRow row;
|
||||||
row.namespace_id = namespace_id;
|
row.namespace_id = namespace_id;
|
||||||
row.key = key;
|
row.key = key;
|
||||||
row.content_json = metadata_json.empty() ? content : metadata_json;
|
row.content_json = content;
|
||||||
|
row.metadata_json = metadata_json;
|
||||||
if (!content.empty()) {
|
if (!content.empty()) {
|
||||||
row.text = content;
|
row.text = content;
|
||||||
}
|
}
|
||||||
|
|
@ -432,6 +514,44 @@ std::string PgDal::toLower(const std::string& value) {
|
||||||
return lowered;
|
return lowered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string PgDal::toIso8601(std::chrono::system_clock::time_point tp) {
|
||||||
|
if (tp == std::chrono::system_clock::time_point{}) {
|
||||||
|
return std::string();
|
||||||
|
}
|
||||||
|
auto tt = std::chrono::system_clock::to_time_t(tp);
|
||||||
|
std::tm tm{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
gmtime_s(&tm, &tt);
|
||||||
|
#else
|
||||||
|
gmtime_r(&tt, &tm);
|
||||||
|
#endif
|
||||||
|
std::ostringstream os;
|
||||||
|
os << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ");
|
||||||
|
return os.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PgDal::includesAllTags(const std::vector<std::string>& haystack,
|
||||||
|
const std::vector<std::string>& needles) {
|
||||||
|
if (needles.empty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::unordered_set<std::string> lookup(haystack.begin(), haystack.end());
|
||||||
|
for (const auto& tag : needles) {
|
||||||
|
if (!lookup.count(tag)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PgDal::isExpired(const ItemRow& row,
|
||||||
|
std::chrono::system_clock::time_point now) const {
|
||||||
|
if (!row.expires_at.has_value()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return now >= row.expires_at.value();
|
||||||
|
}
|
||||||
|
|
||||||
std::string PgDal::escapePgArrayElement(const std::string& value) {
|
std::string PgDal::escapePgArrayElement(const std::string& value) {
|
||||||
std::string escaped;
|
std::string escaped;
|
||||||
escaped.reserve(value.size());
|
escaped.reserve(value.size());
|
||||||
|
|
@ -597,6 +717,8 @@ ItemRow PgDal::mapItemRow(const pqxx::row& row) const {
|
||||||
}
|
}
|
||||||
item.tags = parseTextArrayField(row[5]);
|
item.tags = parseTextArrayField(row[5]);
|
||||||
item.revision = row[6].as<int>(1);
|
item.revision = row[6].as<int>(1);
|
||||||
|
item.metadata_json = "{}";
|
||||||
|
item.created_at = std::chrono::system_clock::now();
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
#include "IDatabase.hpp"
|
#include "IDatabase.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
@ -27,8 +28,11 @@ struct ItemRow {
|
||||||
std::optional<std::string> key;
|
std::optional<std::string> key;
|
||||||
std::string content_json;
|
std::string content_json;
|
||||||
std::optional<std::string> text;
|
std::optional<std::string> text;
|
||||||
|
std::string metadata_json;
|
||||||
std::vector<std::string> tags;
|
std::vector<std::string> tags;
|
||||||
int revision = 1;
|
int revision = 1;
|
||||||
|
std::chrono::system_clock::time_point created_at{};
|
||||||
|
std::optional<std::chrono::system_clock::time_point> expires_at;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ChunkRow {
|
struct ChunkRow {
|
||||||
|
|
@ -71,6 +75,11 @@ public:
|
||||||
const std::vector<float>& embedding,
|
const std::vector<float>& embedding,
|
||||||
int limit);
|
int limit);
|
||||||
std::optional<ItemRow> getItemById(const std::string& id) const;
|
std::optional<ItemRow> getItemById(const std::string& id) const;
|
||||||
|
std::vector<ItemRow> fetchContext(const std::string& namespaceId,
|
||||||
|
const std::optional<std::string>& key,
|
||||||
|
const std::vector<std::string>& tags,
|
||||||
|
const std::optional<std::string>& sinceIso,
|
||||||
|
int limit) const;
|
||||||
|
|
||||||
// IDatabase overrides (stubbed for now to operate on the in-memory backing store).
|
// IDatabase overrides (stubbed for now to operate on the in-memory backing store).
|
||||||
std::pair<std::string, int> upsertItem(
|
std::pair<std::string, int> upsertItem(
|
||||||
|
|
@ -92,6 +101,11 @@ public:
|
||||||
private:
|
private:
|
||||||
std::string allocateId(std::size_t& counter, const std::string& prefix);
|
std::string allocateId(std::size_t& counter, const std::string& prefix);
|
||||||
static std::string toLower(const std::string& value);
|
static std::string toLower(const std::string& value);
|
||||||
|
static std::string toIso8601(std::chrono::system_clock::time_point tp);
|
||||||
|
static bool includesAllTags(const std::vector<std::string>& haystack,
|
||||||
|
const std::vector<std::string>& needles);
|
||||||
|
bool isExpired(const ItemRow& row,
|
||||||
|
std::chrono::system_clock::time_point now) const;
|
||||||
void resetInMemoryStore();
|
void resetInMemoryStore();
|
||||||
static std::string toPgArrayLiteral(const std::vector<std::string>& values);
|
static std::string toPgArrayLiteral(const std::vector<std::string>& values);
|
||||||
static std::string escapePgArrayElement(const std::string& value);
|
static std::string escapePgArrayElement(const std::string& value);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <ctime>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
@ -44,6 +46,15 @@ inline std::string json_escape(const std::string& in) {
|
||||||
return os.str();
|
return os.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline std::string trim(const std::string& in) {
|
||||||
|
std::size_t start = 0;
|
||||||
|
while (start < in.size() && std::isspace(static_cast<unsigned char>(in[start]))) ++start;
|
||||||
|
if (start == in.size()) return std::string();
|
||||||
|
std::size_t end = in.size();
|
||||||
|
while (end > start && std::isspace(static_cast<unsigned char>(in[end - 1]))) --end;
|
||||||
|
return in.substr(start, end - start);
|
||||||
|
}
|
||||||
|
|
||||||
inline std::string error_response(const std::string& code, const std::string& message) {
|
inline std::string error_response(const std::string& code, const std::string& message) {
|
||||||
std::ostringstream os;
|
std::ostringstream os;
|
||||||
os << "{\"error\":{\"code\":\"" << json_escape(code)
|
os << "{\"error\":{\"code\":\"" << json_escape(code)
|
||||||
|
|
@ -137,6 +148,37 @@ inline std::string extract_string_field(const std::string& json, const std::stri
|
||||||
return os.str();
|
return os.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline std::optional<std::string> extract_json_value(const std::string& json, const std::string& key) {
|
||||||
|
const std::string pattern = "\"" + key + "\"";
|
||||||
|
auto keyPos = json.find(pattern);
|
||||||
|
if (keyPos == std::string::npos) return std::nullopt;
|
||||||
|
auto colonPos = json.find(':', keyPos + pattern.size());
|
||||||
|
if (colonPos == std::string::npos) return std::nullopt;
|
||||||
|
++colonPos;
|
||||||
|
while (colonPos < json.size() && std::isspace(static_cast<unsigned char>(json[colonPos]))) ++colonPos;
|
||||||
|
if (colonPos >= json.size()) return std::nullopt;
|
||||||
|
|
||||||
|
const char start = json[colonPos];
|
||||||
|
if (start == '{') {
|
||||||
|
return find_object_segment(json, key);
|
||||||
|
}
|
||||||
|
if (start == '[') {
|
||||||
|
return find_array_segment(json, key);
|
||||||
|
}
|
||||||
|
if (start == '"') {
|
||||||
|
auto decoded = extract_string_field(json, key);
|
||||||
|
std::ostringstream os;
|
||||||
|
os << '"' << json_escape(decoded) << '"';
|
||||||
|
return os.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t end = colonPos;
|
||||||
|
while (end < json.size() && json[end] != ',' && json[end] != '}' && json[end] != ']') ++end;
|
||||||
|
std::string token = trim(json.substr(colonPos, end - colonPos));
|
||||||
|
if (token.empty()) return std::nullopt;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
inline std::optional<int> extract_int_field(const std::string& json, const std::string& key) {
|
inline std::optional<int> extract_int_field(const std::string& json, const std::string& key) {
|
||||||
const std::string pattern = "\"" + key + "\"";
|
const std::string pattern = "\"" + key + "\"";
|
||||||
auto pos = json.find(pattern);
|
auto pos = json.find(pattern);
|
||||||
|
|
@ -155,6 +197,22 @@ inline std::optional<int> extract_int_field(const std::string& json, const std::
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline std::string iso8601_from_tp(std::chrono::system_clock::time_point tp) {
|
||||||
|
if (tp == std::chrono::system_clock::time_point{}) {
|
||||||
|
return std::string();
|
||||||
|
}
|
||||||
|
auto tt = std::chrono::system_clock::to_time_t(tp);
|
||||||
|
std::tm tm{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
gmtime_s(&tm, &tt);
|
||||||
|
#else
|
||||||
|
gmtime_r(&tt, &tm);
|
||||||
|
#endif
|
||||||
|
std::ostringstream os;
|
||||||
|
os << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ");
|
||||||
|
return os.str();
|
||||||
|
}
|
||||||
|
|
||||||
inline std::vector<std::string> parse_object_array(const std::string& json, const std::string& key) {
|
inline std::vector<std::string> parse_object_array(const std::string& json, const std::string& key) {
|
||||||
std::vector<std::string> objects;
|
std::vector<std::string> objects;
|
||||||
auto segment = find_array_segment(json, key);
|
auto segment = find_array_segment(json, key);
|
||||||
|
|
@ -431,4 +489,128 @@ inline std::string search_memory(const std::string& reqJson) {
|
||||||
return detail::serialize_matches(matches);
|
return detail::serialize_matches(matches);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline std::string save_context(const std::string& reqJson) {
|
||||||
|
const std::string nsName = detail::extract_string_field(reqJson, "namespace");
|
||||||
|
if (nsName.empty()) {
|
||||||
|
return detail::error_response("bad_request", "namespace is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
auto contentToken = detail::extract_json_value(reqJson, "content");
|
||||||
|
if (!contentToken) {
|
||||||
|
return detail::error_response("bad_request", "content is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
auto nsRow = detail::database().ensureNamespace(nsName);
|
||||||
|
if (!nsRow) {
|
||||||
|
return detail::error_response("internal_error", "failed to ensure namespace");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string key = detail::extract_string_field(reqJson, "key");
|
||||||
|
auto tags = detail::parse_string_array(reqJson, "tags");
|
||||||
|
auto ttlOpt = detail::extract_int_field(reqJson, "ttl_seconds");
|
||||||
|
|
||||||
|
kom::PgDal& dal = detail::database();
|
||||||
|
kom::ItemRow row;
|
||||||
|
row.namespace_id = nsRow->id;
|
||||||
|
if (!key.empty()) {
|
||||||
|
row.key = key;
|
||||||
|
}
|
||||||
|
row.tags = std::move(tags);
|
||||||
|
row.content_json = detail::trim(*contentToken);
|
||||||
|
row.metadata_json = "{}";
|
||||||
|
row.created_at = std::chrono::system_clock::now();
|
||||||
|
if (ttlOpt && *ttlOpt > 0) {
|
||||||
|
row.expires_at = row.created_at + std::chrono::seconds(*ttlOpt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentToken->empty() && (*contentToken)[0] == '"') {
|
||||||
|
auto textValue = detail::extract_string_field(reqJson, "content");
|
||||||
|
if (!textValue.empty()) {
|
||||||
|
row.text = textValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const std::string id = dal.upsertItem(row);
|
||||||
|
auto stored = dal.getItemById(id);
|
||||||
|
const auto createdTp = stored ? stored->created_at : row.created_at;
|
||||||
|
std::string createdIso = detail::iso8601_from_tp(createdTp);
|
||||||
|
if (createdIso.empty()) {
|
||||||
|
createdIso = detail::iso8601_from_tp(std::chrono::system_clock::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ostringstream os;
|
||||||
|
os << "{\"id\":\"" << detail::json_escape(id) << "\"";
|
||||||
|
os << ",\"created_at\":\"" << createdIso << "\"";
|
||||||
|
os << "}";
|
||||||
|
return os.str();
|
||||||
|
} catch (const std::exception& ex) {
|
||||||
|
return detail::error_response("internal_error", ex.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::string recall_context(const std::string& reqJson) {
|
||||||
|
const std::string nsName = detail::extract_string_field(reqJson, "namespace");
|
||||||
|
if (nsName.empty()) {
|
||||||
|
return detail::error_response("bad_request", "namespace is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string key = detail::extract_string_field(reqJson, "key");
|
||||||
|
auto tags = detail::parse_string_array(reqJson, "tags");
|
||||||
|
auto limitOpt = detail::extract_int_field(reqJson, "limit");
|
||||||
|
std::string since = detail::extract_string_field(reqJson, "since");
|
||||||
|
|
||||||
|
int limit = limitOpt.value_or(10);
|
||||||
|
if (limit <= 0) {
|
||||||
|
limit = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto nsRow = detail::database().findNamespace(nsName);
|
||||||
|
if (!nsRow) {
|
||||||
|
return std::string("{\"items\":[]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string> keyOpt;
|
||||||
|
if (!key.empty()) {
|
||||||
|
keyOpt = key;
|
||||||
|
}
|
||||||
|
std::optional<std::string> sinceOpt;
|
||||||
|
if (!since.empty()) {
|
||||||
|
sinceOpt = since;
|
||||||
|
}
|
||||||
|
|
||||||
|
kom::PgDal& dal = detail::database();
|
||||||
|
auto rows = dal.fetchContext(nsRow->id, keyOpt, tags, sinceOpt, limit);
|
||||||
|
|
||||||
|
std::ostringstream os;
|
||||||
|
os << "{\"items\":[";
|
||||||
|
for (std::size_t i = 0; i < rows.size(); ++i) {
|
||||||
|
const auto& row = rows[i];
|
||||||
|
if (i) os << ',';
|
||||||
|
os << "{\"id\":\"" << detail::json_escape(row.id) << "\"";
|
||||||
|
if (row.key) {
|
||||||
|
os << ",\"key\":\"" << detail::json_escape(*row.key) << "\"";
|
||||||
|
}
|
||||||
|
os << ",\"content\":";
|
||||||
|
if (!row.content_json.empty()) {
|
||||||
|
os << row.content_json;
|
||||||
|
} else {
|
||||||
|
os << "null";
|
||||||
|
}
|
||||||
|
os << ",\"tags\":[";
|
||||||
|
for (std::size_t t = 0; t < row.tags.size(); ++t) {
|
||||||
|
if (t) os << ',';
|
||||||
|
os << "\"" << detail::json_escape(row.tags[t]) << "\"";
|
||||||
|
}
|
||||||
|
os << "]";
|
||||||
|
const std::string createdIso = detail::iso8601_from_tp(row.created_at);
|
||||||
|
if (!createdIso.empty()) {
|
||||||
|
os << ",\"created_at\":\"" << createdIso << "\"";
|
||||||
|
}
|
||||||
|
os << "}";
|
||||||
|
}
|
||||||
|
os << "]}";
|
||||||
|
return os.str();
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace Handlers
|
} // namespace Handlers
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
#include "HandlersMemory.hpp"
|
#include "HandlersMemory.hpp"
|
||||||
|
|
||||||
inline void register_default_tools(KomMcpServer& server) {
|
inline void register_default_tools(KomMcpServer& server) {
|
||||||
|
server.registerTool("kom.memory.v1.save_context", Handlers::save_context);
|
||||||
|
server.registerTool("kom.memory.v1.recall_context", Handlers::recall_context);
|
||||||
server.registerTool("kom.memory.v1.upsert_memory", Handlers::upsert_memory);
|
server.registerTool("kom.memory.v1.upsert_memory", Handlers::upsert_memory);
|
||||||
server.registerTool("kom.memory.v1.search_memory", Handlers::search_memory);
|
server.registerTool("kom.memory.v1.search_memory", Handlers::search_memory);
|
||||||
server.registerTool("kom.local.v1.backup.export_encrypted", Handlers::backup_export_encrypted);
|
server.registerTool("kom.local.v1.backup.export_encrypted", Handlers::backup_export_encrypted);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"namespace": "kom.memory.v1",
|
"namespace": "kom.memory.v1",
|
||||||
"tools": {
|
"tools": {
|
||||||
"save_context": {
|
"save_context": {
|
||||||
|
"description": "Persist context payload in the namespace-backed memory store.",
|
||||||
"input": {
|
"input": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recall_context": {
|
"recall_context": {
|
||||||
|
"description": "Recall stored context entries filtered by key, tags, and time window.",
|
||||||
"input": {
|
"input": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -56,6 +58,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embed_text": {
|
"embed_text": {
|
||||||
|
"description": "Return embedding vectors for provided text inputs.",
|
||||||
"input": {
|
"input": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -74,6 +77,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"upsert_memory": {
|
"upsert_memory": {
|
||||||
|
"description": "Upsert semantic memory items with optional precomputed embeddings.",
|
||||||
"input": {
|
"input": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -103,6 +107,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"search_memory": {
|
"search_memory": {
|
||||||
|
"description": "Hybrid semantic search across stored memory chunks.",
|
||||||
"input": {
|
"input": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -140,6 +145,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"warm_cache": {
|
"warm_cache": {
|
||||||
|
"description": "Queue embedding warm-up jobs for recent namespace items.",
|
||||||
"input": {
|
"input": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -158,6 +164,7 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
"kom.local.v1.backup.export_encrypted": {
|
"kom.local.v1.backup.export_encrypted": {
|
||||||
|
"description": "Queue an encrypted backup export for the requested namespaces.",
|
||||||
"input": {
|
"input": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -177,6 +184,7 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
"kom.local.v1.backup.import_encrypted": {
|
"kom.local.v1.backup.import_encrypted": {
|
||||||
|
"description": "Import an encrypted backup artifact back into the local store.",
|
||||||
"input": {
|
"input": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -195,6 +203,7 @@
|
||||||
,
|
,
|
||||||
|
|
||||||
"kom.meta.v1.project_snapshot": {
|
"kom.meta.v1.project_snapshot": {
|
||||||
|
"description": "Produce a high-level project status snapshot for downstream MCP clients.",
|
||||||
"input": {
|
"input": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,11 @@ target_include_directories(contract_memory PRIVATE ${PROJECT_SOURCE_DIR}/src)
|
||||||
target_link_libraries(contract_memory PRIVATE kom_dal)
|
target_link_libraries(contract_memory PRIVATE kom_dal)
|
||||||
|
|
||||||
add_test(NAME contract_memory COMMAND contract_memory)
|
add_test(NAME contract_memory COMMAND contract_memory)
|
||||||
|
|
||||||
|
add_executable(test_memory_exchange
|
||||||
|
mcp/test_memory_exchange.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_memory_exchange PRIVATE ${PROJECT_SOURCE_DIR}/src)
|
||||||
|
target_link_libraries(test_memory_exchange PRIVATE kom_dal)
|
||||||
|
|
||||||
|
add_test(NAME mcp_memory_exchange COMMAND test_memory_exchange)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,16 @@ int main() {
|
||||||
if (!expect_contains(upsertResp, "\"upserted\":2", "upsert_memory count")) return 1;
|
if (!expect_contains(upsertResp, "\"upserted\":2", "upsert_memory count")) return 1;
|
||||||
if (!expect_contains(upsertResp, "\"status\":\"ok\"", "upsert_memory status")) return 1;
|
if (!expect_contains(upsertResp, "\"status\":\"ok\"", "upsert_memory status")) return 1;
|
||||||
|
|
||||||
|
const std::string saveReq = R"({"namespace":"tests","key":"greeting","content":{"text":"remember this","confidence":0.98},"tags":["context","demo"]})";
|
||||||
|
std::string saveResp = server.dispatch("kom.memory.v1.save_context", saveReq);
|
||||||
|
if (!expect_contains(saveResp, "\"id\"", "save_context id")) return 1;
|
||||||
|
if (!expect_contains(saveResp, "\"created_at\"", "save_context timestamp")) return 1;
|
||||||
|
|
||||||
|
const std::string recallReq = R"({"namespace":"tests","key":"greeting","limit":2})";
|
||||||
|
std::string recallResp = server.dispatch("kom.memory.v1.recall_context", recallReq);
|
||||||
|
if (!expect_contains(recallResp, "\"items\"", "recall_context items")) return 1;
|
||||||
|
if (!expect_contains(recallResp, "\"confidence\":0.98", "recall_context returns payload")) return 1;
|
||||||
|
|
||||||
std::string firstId;
|
std::string firstId;
|
||||||
const std::string idsAnchor = "\"ids\":[\"";
|
const std::string idsAnchor = "\"ids\":[\"";
|
||||||
auto idsPos = upsertResp.find(idsAnchor);
|
auto idsPos = upsertResp.find(idsAnchor);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue