From e881bc49984f910d5c83ab97b62ee4c513270e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=A7=CE=B3=CF=86=CF=84=20Kompanion?= Date: Wed, 15 Oct 2025 15:16:56 +1300 Subject: [PATCH] First prototype --- README.md | 11 +- docs/configuration.md | 20 ++++ docs/using-memory-tools.md | 48 ++++++++ src/dal/PgDal.cpp | 124 +++++++++++++++++++- src/dal/PgDal.hpp | 14 +++ src/mcp/HandlersMemory.hpp | 182 ++++++++++++++++++++++++++++++ src/mcp/RegisterTools.hpp | 2 + src/mcp/ToolSchemas.json | 9 ++ tests/CMakeLists.txt | 8 ++ tests/contract/test_mcp_tools.cpp | 22 +++- 10 files changed, 432 insertions(+), 8 deletions(-) create mode 100644 docs/configuration.md create mode 100644 docs/using-memory-tools.md diff --git a/README.md b/README.md index 0107a4e..8e9dc83 100644 --- a/README.md +++ b/README.md @@ -17,4 +17,13 @@ cmake --build build -j ## Next - Add qtmcp dependency and implement server with tool registration. - 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/`. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..7ac4f0f --- /dev/null +++ b/docs/configuration.md @@ -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`. diff --git a/docs/using-memory-tools.md b/docs/using-memory-tools.md new file mode 100644 index 0000000..12b3001 --- /dev/null +++ b/docs/using-memory-tools.md @@ -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. diff --git a/src/dal/PgDal.cpp b/src/dal/PgDal.cpp index e7a05a9..223803b 100644 --- a/src/dal/PgDal.cpp +++ b/src/dal/PgDal.cpp @@ -2,9 +2,13 @@ #include #include +#include +#include +#include #include #include #include +#include #ifdef HAVE_PG #include @@ -152,6 +156,7 @@ std::string PgDal::upsertItem(const ItemRow& row) { return pgUpsertItem(row).first; } #endif + const auto now = std::chrono::system_clock::now(); ItemRow stored = row; if (stored.id.empty()) { stored.id = allocateId(nextItemId_, "item_"); @@ -160,6 +165,21 @@ std::string PgDal::upsertItem(const ItemRow& row) { auto existing = items_.find(stored.id); if (existing != items_.end()) { 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; @@ -240,10 +260,12 @@ std::vector PgDal::searchText(const std::string& namespaceId, if (bucketIt == itemsByNamespace_.end()) return results; const std::string loweredQuery = toLower(query); + const auto now = std::chrono::system_clock::now(); for (const auto& itemId : bucketIt->second) { auto itemIt = items_.find(itemId); if (itemIt == items_.end()) continue; + if (isExpired(itemIt->second, now)) continue; if (!loweredQuery.empty()) { const std::string loweredText = toLower(itemIt->second.text.value_or(std::string())); @@ -273,10 +295,14 @@ std::vector> PgDal::searchVector( auto bucketIt = itemsByNamespace_.find(namespaceId); if (bucketIt == itemsByNamespace_.end()) return scores; + const auto now = std::chrono::system_clock::now(); for (const auto& itemId : bucketIt->second) { auto chunkBucketIt = chunksByItem_.find(itemId); 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; for (const auto& chunkId : chunkBucketIt->second) { @@ -323,6 +349,61 @@ std::optional PgDal::getItemById(const std::string& id) const { return it->second; } +std::vector PgDal::fetchContext(const std::string& namespaceId, + const std::optional& key, + const std::vector& tags, + const std::optional& sinceIso, + int limit) const { +#ifdef HAVE_PG + if (connected_ && !useInMemory_) { + // TODO: implement direct Postgres query once DAL wiring is complete. + return {}; + } +#endif + std::vector 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(results.size()) >= limit) { + break; + } + } + + return results; +} + std::pair PgDal::upsertItem( const std::string& namespace_id, const std::optional& key, @@ -332,7 +413,8 @@ std::pair PgDal::upsertItem( ItemRow row; row.namespace_id = namespace_id; row.key = key; - row.content_json = metadata_json.empty() ? content : metadata_json; + row.content_json = content; + row.metadata_json = metadata_json; if (!content.empty()) { row.text = content; } @@ -432,6 +514,44 @@ std::string PgDal::toLower(const std::string& value) { 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& haystack, + const std::vector& needles) { + if (needles.empty()) { + return true; + } + std::unordered_set 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 escaped; escaped.reserve(value.size()); @@ -597,6 +717,8 @@ ItemRow PgDal::mapItemRow(const pqxx::row& row) const { } item.tags = parseTextArrayField(row[5]); item.revision = row[6].as(1); + item.metadata_json = "{}"; + item.created_at = std::chrono::system_clock::now(); return item; } diff --git a/src/dal/PgDal.hpp b/src/dal/PgDal.hpp index b7afae8..28f731c 100644 --- a/src/dal/PgDal.hpp +++ b/src/dal/PgDal.hpp @@ -2,6 +2,7 @@ #include "IDatabase.hpp" +#include #include #include #include @@ -27,8 +28,11 @@ struct ItemRow { std::optional key; std::string content_json; std::optional text; + std::string metadata_json; std::vector tags; int revision = 1; + std::chrono::system_clock::time_point created_at{}; + std::optional expires_at; }; struct ChunkRow { @@ -71,6 +75,11 @@ public: const std::vector& embedding, int limit); std::optional getItemById(const std::string& id) const; + std::vector fetchContext(const std::string& namespaceId, + const std::optional& key, + const std::vector& tags, + const std::optional& sinceIso, + int limit) const; // IDatabase overrides (stubbed for now to operate on the in-memory backing store). std::pair upsertItem( @@ -92,6 +101,11 @@ public: private: std::string allocateId(std::size_t& counter, const std::string& prefix); 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& haystack, + const std::vector& needles); + bool isExpired(const ItemRow& row, + std::chrono::system_clock::time_point now) const; void resetInMemoryStore(); static std::string toPgArrayLiteral(const std::vector& values); static std::string escapePgArrayElement(const std::string& value); diff --git a/src/mcp/HandlersMemory.hpp b/src/mcp/HandlersMemory.hpp index 02e88bb..a56fa12 100644 --- a/src/mcp/HandlersMemory.hpp +++ b/src/mcp/HandlersMemory.hpp @@ -1,8 +1,10 @@ #pragma once #include +#include #include #include +#include #include #include #include @@ -44,6 +46,15 @@ inline std::string json_escape(const std::string& in) { return os.str(); } +inline std::string trim(const std::string& in) { + std::size_t start = 0; + while (start < in.size() && std::isspace(static_cast(in[start]))) ++start; + if (start == in.size()) return std::string(); + std::size_t end = in.size(); + while (end > start && std::isspace(static_cast(in[end - 1]))) --end; + return in.substr(start, end - start); +} + inline std::string error_response(const std::string& code, const std::string& message) { std::ostringstream os; 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(); } +inline std::optional 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(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 extract_int_field(const std::string& json, const std::string& key) { const std::string pattern = "\"" + key + "\""; auto pos = json.find(pattern); @@ -155,6 +197,22 @@ inline std::optional 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 parse_object_array(const std::string& json, const std::string& key) { std::vector objects; 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); } +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 keyOpt; + if (!key.empty()) { + keyOpt = key; + } + std::optional 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 diff --git a/src/mcp/RegisterTools.hpp b/src/mcp/RegisterTools.hpp index 2251099..9f6a57a 100644 --- a/src/mcp/RegisterTools.hpp +++ b/src/mcp/RegisterTools.hpp @@ -5,6 +5,8 @@ #include "HandlersMemory.hpp" 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.search_memory", Handlers::search_memory); server.registerTool("kom.local.v1.backup.export_encrypted", Handlers::backup_export_encrypted); diff --git a/src/mcp/ToolSchemas.json b/src/mcp/ToolSchemas.json index 00dc98f..1548039 100644 --- a/src/mcp/ToolSchemas.json +++ b/src/mcp/ToolSchemas.json @@ -2,6 +2,7 @@ "namespace": "kom.memory.v1", "tools": { "save_context": { + "description": "Persist context payload in the namespace-backed memory store.", "input": { "type": "object", "properties": { @@ -23,6 +24,7 @@ } }, "recall_context": { + "description": "Recall stored context entries filtered by key, tags, and time window.", "input": { "type": "object", "properties": { @@ -56,6 +58,7 @@ } }, "embed_text": { + "description": "Return embedding vectors for provided text inputs.", "input": { "type": "object", "properties": { @@ -74,6 +77,7 @@ } }, "upsert_memory": { + "description": "Upsert semantic memory items with optional precomputed embeddings.", "input": { "type": "object", "properties": { @@ -103,6 +107,7 @@ } }, "search_memory": { + "description": "Hybrid semantic search across stored memory chunks.", "input": { "type": "object", "properties": { @@ -140,6 +145,7 @@ } }, "warm_cache": { + "description": "Queue embedding warm-up jobs for recent namespace items.", "input": { "type": "object", "properties": { @@ -158,6 +164,7 @@ }, "kom.local.v1.backup.export_encrypted": { + "description": "Queue an encrypted backup export for the requested namespaces.", "input": { "type": "object", "properties": { @@ -177,6 +184,7 @@ }, "kom.local.v1.backup.import_encrypted": { + "description": "Import an encrypted backup artifact back into the local store.", "input": { "type": "object", "properties": { @@ -195,6 +203,7 @@ , "kom.meta.v1.project_snapshot": { + "description": "Produce a high-level project status snapshot for downstream MCP clients.", "input": { "type": "object", "properties": { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1320449..002c5aa 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,3 +13,11 @@ target_include_directories(contract_memory PRIVATE ${PROJECT_SOURCE_DIR}/src) target_link_libraries(contract_memory PRIVATE kom_dal) 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) diff --git a/tests/contract/test_mcp_tools.cpp b/tests/contract/test_mcp_tools.cpp index d69e412..3af9bcd 100644 --- a/tests/contract/test_mcp_tools.cpp +++ b/tests/contract/test_mcp_tools.cpp @@ -20,13 +20,23 @@ int main() { KomMcpServer server; register_default_tools(server); - const std::string upsertReq = R"({"namespace":"tests","items":[{"text":"hello world","tags":["greeting"],"embedding":[0.1,0.2,0.3]},{"text":"hola mundo","embedding":[0.05,0.1,0.15]}]})"; - std::string upsertResp = server.dispatch("kom.memory.v1.upsert_memory", upsertReq); - if (!expect_contains(upsertResp, "\"upserted\":2", "upsert_memory count")) return 1; - if (!expect_contains(upsertResp, "\"status\":\"ok\"", "upsert_memory status")) return 1; +const std::string upsertReq = R"({"namespace":"tests","items":[{"text":"hello world","tags":["greeting"],"embedding":[0.1,0.2,0.3]},{"text":"hola mundo","embedding":[0.05,0.1,0.15]}]})"; +std::string upsertResp = server.dispatch("kom.memory.v1.upsert_memory", upsertReq); +if (!expect_contains(upsertResp, "\"upserted\":2", "upsert_memory count")) return 1; +if (!expect_contains(upsertResp, "\"status\":\"ok\"", "upsert_memory status")) return 1; - std::string firstId; - const std::string idsAnchor = "\"ids\":[\""; +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; +const std::string idsAnchor = "\"ids\":[\""; auto idsPos = upsertResp.find(idsAnchor); if (idsPos != std::string::npos) { idsPos += idsAnchor.size();