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
|
||||
- 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/`.
|
||||
|
|
|
|||
|
|
@ -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 <cctype>
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <numeric>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <unordered_set>
|
||||
|
||||
#ifdef HAVE_PG
|
||||
#include <pqxx/array>
|
||||
|
|
@ -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<ItemRow> 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<std::pair<std::string, float>> 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<ItemRow> PgDal::getItemById(const std::string& id) const {
|
|||
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(
|
||||
const std::string& namespace_id,
|
||||
const std::optional<std::string>& key,
|
||||
|
|
@ -332,7 +413,8 @@ std::pair<std::string, int> 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<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 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<int>(1);
|
||||
item.metadata_json = "{}";
|
||||
item.created_at = std::chrono::system_clock::now();
|
||||
return item;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include "IDatabase.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
|
@ -27,8 +28,11 @@ struct ItemRow {
|
|||
std::optional<std::string> key;
|
||||
std::string content_json;
|
||||
std::optional<std::string> text;
|
||||
std::string metadata_json;
|
||||
std::vector<std::string> tags;
|
||||
int revision = 1;
|
||||
std::chrono::system_clock::time_point created_at{};
|
||||
std::optional<std::chrono::system_clock::time_point> expires_at;
|
||||
};
|
||||
|
||||
struct ChunkRow {
|
||||
|
|
@ -71,6 +75,11 @@ public:
|
|||
const std::vector<float>& embedding,
|
||||
int limit);
|
||||
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).
|
||||
std::pair<std::string, int> 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<std::string>& haystack,
|
||||
const std::vector<std::string>& needles);
|
||||
bool isExpired(const ItemRow& row,
|
||||
std::chrono::system_clock::time_point now) const;
|
||||
void resetInMemoryStore();
|
||||
static std::string toPgArrayLiteral(const std::vector<std::string>& values);
|
||||
static std::string escapePgArrayElement(const std::string& value);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
|
|
@ -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<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) {
|
||||
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<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) {
|
||||
const std::string pattern = "\"" + key + "\"";
|
||||
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) {
|
||||
std::vector<std::string> 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<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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ int main() {
|
|||
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 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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue