First prototype

This commit is contained in:
Χγφτ Kompanion 2025-10-15 15:16:56 +13:00
parent 53a1a043c7
commit e881bc4998
10 changed files with 432 additions and 8 deletions

View File

@ -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/`.

20
docs/configuration.md Normal file
View File

@ -0,0 +1,20 @@
# Kompanion Configuration
Kompanion adheres to KDEs 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`.

View File

@ -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 Codeys `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 Claudes 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.

View File

@ -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;
}

View File

@ -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);

View File

@ -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

View File

@ -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);

View File

@ -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": {

View File

@ -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)

View File

@ -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);