Compare commits
No commits in common. "fd289edded3844a0b10ff6049d37e815e07136f9" and "9c63b6c593a34b78af5cbd9b9f65350dd5415364" have entirely different histories.
fd289edded
...
9c63b6c593
609
.acf/tasks.json
609
.acf/tasks.json
|
|
@ -1,609 +0,0 @@
|
|||
{
|
||||
"projectName": "metal-kompanion-mcp",
|
||||
"projectDescription": "MCP backend for Kompanion: memory/context/embedding provider over MCP, built from scratch (qtmcp-based) to persist conversation state and serve embeddings + retrieval to avoid forgetting across threads.",
|
||||
"lastTaskId": 23,
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Project Setup: metal-kompanion-mcp",
|
||||
"description": "MCP backend for Kompanion: memory/context/embedding provider over MCP, built from scratch (qtmcp-based) to persist conversation state and serve embeddings + retrieval to avoid forgetting across threads.",
|
||||
"status": "todo",
|
||||
"priority": 700,
|
||||
"priorityDisplay": "high",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T17:31:50.258Z",
|
||||
"updatedAt": "2025-10-13T17:31:50.258Z",
|
||||
"subtasks": [],
|
||||
"relatedFiles": [],
|
||||
"activityLog": [],
|
||||
"lastSubtaskIndex": 0
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Design MCP memory/context API",
|
||||
"description": "Specify MCP tools for: save_context, recall_context, embed_text, upsert_memory, search_memory, warm_cache. Define input/output schemas, auth, and versioning.",
|
||||
"status": "in_progress",
|
||||
"priority": 500,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T17:32:24.705Z",
|
||||
"updatedAt": "2025-10-13T17:40:02.144Z",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "2.1",
|
||||
"title": "Write JSON Schemas for tools (done)",
|
||||
"status": "todo",
|
||||
"createdAt": "2025-10-13T17:39:21.256Z",
|
||||
"updatedAt": "2025-10-13T17:39:21.256Z",
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T17:39:21.256Z",
|
||||
"type": "log",
|
||||
"message": "Subtask created with title: \"Write JSON Schemas for tools (done)\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastSubtaskIndex": 1,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T17:32:24.705Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Design MCP memory/context API\""
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-10-13T17:40:02.144Z",
|
||||
"type": "log",
|
||||
"message": "Status changed from \"todo\" to \"in_progress\". Message: Docs and schemas created. Proceeding to server scaffold and adapters."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Select embedding backend & storage",
|
||||
"description": "Choose between local (Ollama/gguf via llama.cpp embedding) vs remote (OpenAI/SentenceTransformers). Storage: sqlite+vectstore (pgvector/qdrant/chroma). Provide abstraction + adapters.",
|
||||
"status": "todo",
|
||||
"priority": 501,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T17:32:35.110Z",
|
||||
"updatedAt": "2025-10-13T17:32:35.110Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T17:32:35.110Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Select embedding backend & storage\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Scaffold qtmcp-based server",
|
||||
"description": "Set up C++/Qt MCP server skeleton using qtmcp. Implement handshake, tool registration, and simple ping tool. Build with CMake in /home/kompanion/dev/metal/src/metal-kompanion.",
|
||||
"status": "in_progress",
|
||||
"priority": 499,
|
||||
"priorityDisplay": "P1",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T17:32:47.443Z",
|
||||
"updatedAt": "2025-10-13T18:13:07.568Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T17:32:47.443Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Scaffold qtmcp-based server\""
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-10-13T18:13:07.568Z",
|
||||
"type": "log",
|
||||
"message": "Status changed from \"todo\" to \"in_progress\". Message: Starting MCP server skeleton with tool registry, ping tool, and placeholders for kom.memory.v1 handlers."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Implement memory adapters",
|
||||
"description": "Adapters: (1) SQLite+FAISS/pgvector, (2) Qdrant, (3) Chroma. CRUD: upsert, delete, query, batch. Support namespaces (project/thread), TTL, metadata tags.",
|
||||
"status": "todo",
|
||||
"priority": 502,
|
||||
"priorityDisplay": "P1",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T17:32:57.756Z",
|
||||
"updatedAt": "2025-10-13T17:32:57.756Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T17:32:57.756Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Implement memory adapters\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Deep research: memory DB architecture & schema",
|
||||
"description": "Survey best practices for conversational memory stores (RAG, TTL, namespaces, versioning). Produce target schema for Postgres+pgvector and SQLite mappings.",
|
||||
"status": "todo",
|
||||
"priority": 498,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T17:46:18.403Z",
|
||||
"updatedAt": "2025-10-13T17:46:18.403Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T17:46:18.403Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Deep research: memory DB architecture & schema\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Decide primary DB: Postgres+pgvector vs SQLite+FAISS",
|
||||
"description": "Evaluate tradeoffs (multi-user, concurrency, migrations, backups). Pick canonical prod DB and document local dev fallback.",
|
||||
"status": "todo",
|
||||
"priority": 503,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T17:47:21.042Z",
|
||||
"updatedAt": "2025-10-13T17:47:21.042Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T17:47:21.042Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Decide primary DB: Postgres+pgvector vs SQLite+FAISS\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "Implement DAL + migrations (pgvector)",
|
||||
"description": "Create C++ DAL layer for namespaces, items, chunks, embeddings. Add migration runner and seed scripts. Map MCP tool calls to DB ops.",
|
||||
"status": "todo",
|
||||
"priority": 497,
|
||||
"priorityDisplay": "P1",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T17:47:30.982Z",
|
||||
"updatedAt": "2025-10-13T17:47:30.982Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T17:47:30.982Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Implement DAL + migrations (pgvector)\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"title": "Add cloud DB hardening (RLS, FTS/trgm, ANN indexes)",
|
||||
"description": "Implement RLS policies; add FTS + pg_trgm for lexical search; unique (namespace_id, key); partial ANN indexes per model.",
|
||||
"status": "todo",
|
||||
"priority": 504,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T19:13:13.769Z",
|
||||
"updatedAt": "2025-10-13T19:13:13.769Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T19:13:13.769Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Add cloud DB hardening (RLS, FTS/trgm, ANN indexes)\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Server enforcement: scope injection + rate limits",
|
||||
"description": "Inject namespace/user via session context; default-deny for scope widening; add simple per-tool rate limits.",
|
||||
"status": "todo",
|
||||
"priority": 496,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T19:13:21.164Z",
|
||||
"updatedAt": "2025-10-13T19:13:21.164Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T19:13:21.164Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Server enforcement: scope injection + rate limits\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Redaction & sensitivity pipeline",
|
||||
"description": "Implement preprocessing to detect/seal secrets; set metadata.sensitivity; skip FTS/embeddings for `secret` items.",
|
||||
"status": "todo",
|
||||
"priority": 505,
|
||||
"priorityDisplay": "P1",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T19:13:29.391Z",
|
||||
"updatedAt": "2025-10-13T19:13:29.392Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T19:13:29.391Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Redaction & sensitivity pipeline\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"title": "Private vault mode (key-only retrieval)",
|
||||
"description": "Implement vault path for secret items: encrypted-at-rest only; disable participation in FTS/vector; key-based recall APIs.",
|
||||
"status": "todo",
|
||||
"priority": 495,
|
||||
"priorityDisplay": "P1",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T19:13:36.653Z",
|
||||
"updatedAt": "2025-10-13T19:13:36.653Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T19:13:36.653Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Private vault mode (key-only retrieval)\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"title": "Local backup tools: export/import (E2EE)",
|
||||
"description": "Add kom.local.v1.backup.export_encrypted / import_encrypted using the draft backup format.",
|
||||
"status": "todo",
|
||||
"priority": 506,
|
||||
"priorityDisplay": "P1",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T19:13:44.851Z",
|
||||
"updatedAt": "2025-10-13T19:13:44.851Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T19:13:44.851Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Local backup tools: export/import (E2EE)\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"title": "Cloud adapters: backup/sync & payments stubs",
|
||||
"description": "Expose kom.cloud.v1.backup.upload/restore, kom.cloud.v1.sync.push/pull, and payments.* stubs.",
|
||||
"status": "todo",
|
||||
"priority": 494,
|
||||
"priorityDisplay": "P2",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T19:13:55.490Z",
|
||||
"updatedAt": "2025-10-13T19:13:55.490Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T19:13:55.490Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Cloud adapters: backup/sync & payments stubs\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"title": "Purge job & admin delete paths",
|
||||
"description": "Implement scheduled hard-deletes for soft-deleted/expired items; add admin nuke namespace/user procedure.",
|
||||
"status": "todo",
|
||||
"priority": 507,
|
||||
"priorityDisplay": "P2",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T19:14:06.080Z",
|
||||
"updatedAt": "2025-10-13T19:14:06.080Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T19:14:06.080Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Purge job & admin delete paths\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"title": "Test suite: privacy & hybrid search",
|
||||
"description": "Cross-tenant leakage, redaction invariants, TTL/purge, lexical vs hybrid parity, hosted vs local parity.",
|
||||
"status": "todo",
|
||||
"priority": 493,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T19:14:14.309Z",
|
||||
"updatedAt": "2025-10-13T19:14:14.310Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T19:14:14.310Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Test suite: privacy & hybrid search\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"title": "Enable Qwen-2.5-Coder with tool support (Happy-Code profile)",
|
||||
"description": "Prepare system prompt + registry injection + JSON-only protocol enforcement; provide tool schemas and example transcripts; validate with kom.memory/local backup tools.",
|
||||
"status": "todo",
|
||||
"priority": 508,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T23:29:36.547Z",
|
||||
"updatedAt": "2025-10-13T23:29:36.548Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T23:29:36.548Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Enable Qwen-2.5-Coder with tool support (Happy-Code profile)\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"title": "Expose Agentic-Control-Framework as a tool",
|
||||
"description": "Wrap ACF endpoints into a tool registry accessible to models (list/add/update tasks, read/write files, run commands) with strict allowlist per workspace.",
|
||||
"status": "todo",
|
||||
"priority": 492,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T23:29:43.303Z",
|
||||
"updatedAt": "2025-10-13T23:29:43.304Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T23:29:43.304Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Expose Agentic-Control-Framework as a tool\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"title": "DAL skeleton + SQL calls (pgvector)",
|
||||
"description": "Create DAL interfaces and pgvector implementation stubs: connect, begin/commit, upsert item/chunk/embedding, search (text+vector placeholder), prepared SQL in sql/pg. Wire handlers to DAL in no-op mode.",
|
||||
"status": "todo",
|
||||
"priority": 509,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-13T23:29:49.918Z",
|
||||
"updatedAt": "2025-10-13T23:29:49.918Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-13T23:29:49.918Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"DAL skeleton + SQL calls (pgvector)\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"title": "Claude Code integration rescue plan",
|
||||
"description": "Stabilize Qwen-2.5-Coder inside Claude Code despite heavy system prompts: hard system override, JSON-only protocol, stop-sequences, tool registry injection, and fallback DSL.",
|
||||
"status": "todo",
|
||||
"priority": 491,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-14T00:06:04.896Z",
|
||||
"updatedAt": "2025-10-14T00:06:04.896Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-14T00:06:04.896Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Claude Code integration rescue plan\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"title": "DAL Phase 1: libpq/pqxx wiring + SQL calls",
|
||||
"description": "Link pqxx, implement PgDal against Postgres+pgvector: connect/tx, ensureNamespace, upsertItem/Chunks/Embeddings, searchText (FTS/trgm), searchVector (<->). Provide DSN via env; add cmake find + link.",
|
||||
"status": "todo",
|
||||
"priority": 510,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-14T00:29:55.327Z",
|
||||
"updatedAt": "2025-10-14T00:29:55.327Z",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "21.1",
|
||||
"title": "CMake: find_package(pqxx) and link; CI env var DSN",
|
||||
"status": "todo",
|
||||
"createdAt": "2025-10-14T00:30:00.856Z",
|
||||
"updatedAt": "2025-10-14T00:30:00.857Z",
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-14T00:30:00.856Z",
|
||||
"type": "log",
|
||||
"message": "Subtask created with title: \"CMake: find_package(pqxx) and link; CI env var DSN\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "21.2",
|
||||
"title": "PgDal: implement connect/tx + prepared statements",
|
||||
"status": "todo",
|
||||
"createdAt": "2025-10-14T00:30:06.138Z",
|
||||
"updatedAt": "2025-10-14T00:30:06.138Z",
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-14T00:30:06.138Z",
|
||||
"type": "log",
|
||||
"message": "Subtask created with title: \"PgDal: implement connect/tx + prepared statements\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "21.3",
|
||||
"title": "SQL: ensureNamespace, upsertItem/Chunks/Embeddings",
|
||||
"status": "todo",
|
||||
"createdAt": "2025-10-14T00:30:11.519Z",
|
||||
"updatedAt": "2025-10-14T00:30:11.519Z",
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-14T00:30:11.519Z",
|
||||
"type": "log",
|
||||
"message": "Subtask created with title: \"SQL: ensureNamespace, upsertItem/Chunks/Embeddings\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "21.4",
|
||||
"title": "Search: FTS/trgm + vector <-> with filters (namespace/thread/tags)",
|
||||
"status": "todo",
|
||||
"createdAt": "2025-10-14T00:30:17.290Z",
|
||||
"updatedAt": "2025-10-14T00:30:17.290Z",
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-14T00:30:17.290Z",
|
||||
"type": "log",
|
||||
"message": "Subtask created with title: \"Search: FTS/trgm + vector <-> with filters (namespace/thread/tags)\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastSubtaskIndex": 4,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-14T00:29:55.327Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"DAL Phase 1: libpq/pqxx wiring + SQL calls\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"title": "Handlers → DAL integration",
|
||||
"description": "Wire kom.memory.v1.upsert_memory/search_memory to IDatabase. Parse JSON with a real parser, validate against schemas, enforce scope and sensitivity rules.",
|
||||
"status": "todo",
|
||||
"priority": 490,
|
||||
"priorityDisplay": "P0",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-14T00:30:26.285Z",
|
||||
"updatedAt": "2025-10-14T00:30:26.285Z",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "22.1",
|
||||
"title": "Replace ad-hoc JSON with parser (nlohmann/json or simdjson)",
|
||||
"status": "todo",
|
||||
"createdAt": "2025-10-14T00:30:33.761Z",
|
||||
"updatedAt": "2025-10-14T00:30:33.761Z",
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-14T00:30:33.761Z",
|
||||
"type": "log",
|
||||
"message": "Subtask created with title: \"Replace ad-hoc JSON with parser (nlohmann/json or simdjson)\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "22.2",
|
||||
"title": "Validate request bodies against schemas before DAL calls",
|
||||
"status": "todo",
|
||||
"createdAt": "2025-10-14T00:30:39.868Z",
|
||||
"updatedAt": "2025-10-14T00:30:39.868Z",
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-14T00:30:39.868Z",
|
||||
"type": "log",
|
||||
"message": "Subtask created with title: \"Validate request bodies against schemas before DAL calls\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "22.3",
|
||||
"title": "Scope & sensitivity enforcement (namespace/user + skip secret embeddings)",
|
||||
"status": "todo",
|
||||
"createdAt": "2025-10-14T00:30:45.261Z",
|
||||
"updatedAt": "2025-10-14T00:30:45.261Z",
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-14T00:30:45.261Z",
|
||||
"type": "log",
|
||||
"message": "Subtask created with title: \"Scope & sensitivity enforcement (namespace/user + skip secret embeddings)\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastSubtaskIndex": 3,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-14T00:30:26.285Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Handlers → DAL integration\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"title": "Contract tests: DAL-backed tools",
|
||||
"description": "Expand CTest to cover DAL-backed upsert/search and backup export/import; include error cases and schema violations; run against build-komhands.",
|
||||
"status": "todo",
|
||||
"priority": 511,
|
||||
"priorityDisplay": "P1",
|
||||
"dependsOn": [],
|
||||
"createdAt": "2025-10-14T00:30:51.716Z",
|
||||
"updatedAt": "2025-10-14T00:30:51.716Z",
|
||||
"subtasks": [],
|
||||
"lastSubtaskIndex": 0,
|
||||
"relatedFiles": [],
|
||||
"activityLog": [
|
||||
{
|
||||
"timestamp": "2025-10-14T00:30:51.716Z",
|
||||
"type": "log",
|
||||
"message": "Task created with title: \"Contract tests: DAL-backed tools\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# Cursor AI Workflow Rules for Agentic Control Framework
|
||||
|
||||
# (Define rules here to tell Cursor how to use acf commands)
|
||||
|
||||
# Example:
|
||||
# To list tasks, use the command: acf list
|
||||
# To get the next task: acf next
|
||||
35
AGENTS.md
35
AGENTS.md
|
|
@ -1,35 +0,0 @@
|
|||
# Repository Guidelines
|
||||
This guide supports new agents contributing to `metal-kompanion`, the MCP backend for Kompanion. Follow these practices to keep the service buildable, testable, and easy to review.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `src/` holds C++ code: `mcp/` for server facade and tool routing, `memory/` for embeddings contracts, `dal/` for persistence, and `policy/` for capability rules.
|
||||
- `docs/` hosts design notes; `runtime/kom_runner.py` is the Python orchestrator for agent execution against Ollama; `db/` and `sql/` capture Postgres schemas.
|
||||
- Place sample payloads or fixtures under `tests/` (currently `tests/schemas/` for JSON samples). Generated artifacts live in `build/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
```bash
|
||||
cmake -S . -B build # configure with CMake 3.22+, targets C++20
|
||||
cmake --build build -j # compile the kom_mcp executable
|
||||
ctest --test-dir build # run CTest suites once they are defined
|
||||
python3 runtime/kom_runner.py # exercise the runtime loop (requires OLLAMA_BASE)
|
||||
```
|
||||
Run the Python runtime from a virtualenv seeded with `pip install -r runtime/requirements.txt`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- C++20, clang/gcc friendly, 4-space indentation, braces on the same line (`class KomMcpServer {`).
|
||||
- Classes use PascalCase; methods camelCase; private members keep the trailing underscore (`tools_`). Prefer `std::` types and RAII helpers over raw pointers.
|
||||
- Keep headers lean: forward declare where possible and document non-obvious behavior with concise comments.
|
||||
|
||||
## Testing Guidelines
|
||||
- Add unit or schema validation tests under `tests/`, mirroring the source tree (e.g., `tests/mcp/` for dispatcher tests).
|
||||
- Register new tests with CTest via `add_test` in `CMakeLists.txt`; verify they pass with `ctest --output-on-failure`.
|
||||
- Provide realistic JSON samples for new tools alongside schema updates to guard against regressions.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Follow the existing `scope: message` pattern from git history (`docker: fix host ollama port`); keep messages imperative and present tense.
|
||||
- Each PR should state intent, link relevant issues, and include before/after notes or screenshots when UI-adjacent runtime behavior changes.
|
||||
- Mention how to reproduce test runs (`cmake --build`, `ctest`) and flag configuration or migration steps (e.g., `sql/` changes).
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Do not commit secrets; runtime state lives beneath XDG paths (`~/.local/state/kompanion`). Document any new env vars in `docs/`.
|
||||
- When integrating new tools, ensure access control aligns with `policy/` capabilities and record expected journal entries in `runtime` docs.
|
||||
110
CMakeLists.txt
110
CMakeLists.txt
|
|
@ -1,106 +1,10 @@
|
|||
cmake_minimum_required(VERSION 3.22)
|
||||
project(Kompanion LANGUAGES CXX)
|
||||
set(PROJECT_VERSION "0.0.1")
|
||||
|
||||
set(QT_MIN_VERSION "6.0.0")
|
||||
set(KF6_MIN_VERSION "6.0.0")
|
||||
set(KDE_COMPILERSETTINGS_LEVEL "5.82")
|
||||
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(MetalKompanion LANGUAGES CXX)
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
find_package(ECM ${KF6_MIN_VERSION} REQUIRED NO_MODULE)
|
||||
set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
|
||||
|
||||
include(KDEInstallDirs)
|
||||
include(KDECMakeSettings)
|
||||
include(KDECompilerSettings NO_POLICY_SCOPE)
|
||||
include(ECMMarkAsTest)
|
||||
include(ECMMarkNonGuiExecutable)
|
||||
include(FeatureSummary)
|
||||
include(CheckIncludeFile)
|
||||
include(CheckIncludeFiles)
|
||||
include(CheckSymbolExists)
|
||||
include(ECMOptionalAddSubdirectory)
|
||||
include(KDEClangFormat)
|
||||
include(ECMDeprecationSettings)
|
||||
|
||||
include(KDEGitCommitHooks)
|
||||
|
||||
find_package(Qt6 ${QT_MIN_VERSION} CONFIG REQUIRED COMPONENTS
|
||||
Core
|
||||
Network
|
||||
Sql
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core Network DBus)
|
||||
add_executable(kompanion_server
|
||||
src/main.cpp
|
||||
)
|
||||
|
||||
find_package(Qt6McpServer CONFIG REQUIRED)
|
||||
find_package(Qt6McpCommon CONFIG REQUIRED)
|
||||
|
||||
option(KOMPANION_USE_GUI "Build optional GUI components using Qt6Gui" ON)
|
||||
if (KOMPANION_USE_GUI)
|
||||
find_package(Qt6 ${QT_MIN_VERSION} CONFIG REQUIRED COMPONENTS Gui)
|
||||
endif()
|
||||
|
||||
find_package(KF6 ${KF6_MIN_VERSION} REQUIRED COMPONENTS
|
||||
Config
|
||||
)
|
||||
find_package(Qt6Test ${QT_MIN_VERSION} CONFIG QUIET)
|
||||
set_package_properties(Qt6Test PROPERTIES
|
||||
PURPOSE "Required for tests"
|
||||
TYPE OPTIONAL
|
||||
)
|
||||
add_feature_info("Qt6Test" Qt6Test_FOUND "Required for building tests")
|
||||
|
||||
|
||||
set(KOMPANION_DB_INIT_INSTALL_DIR "${KDE_INSTALL_FULL_DATADIR}/kompanion/db/init")
|
||||
install(DIRECTORY db/init/ DESTINATION ${KDE_INSTALL_DATADIR}/kompanion/db/init FILES_MATCHING PATTERN "*.sql")
|
||||
|
||||
add_subdirectory(src/dal)
|
||||
|
||||
add_executable(kom_mcp
|
||||
src/main.cpp
|
||||
src/mcp/KompanionQtServer.cpp
|
||||
)
|
||||
target_include_directories(kom_mcp PRIVATE src)
|
||||
qt_add_resources(kom_mcp kompanion_mcp_resources
|
||||
PREFIX "/kompanion"
|
||||
BASE "src/mcp"
|
||||
FILES src/mcp/ToolSchemas.json
|
||||
)
|
||||
target_link_libraries(kom_mcp PRIVATE
|
||||
kom_dal
|
||||
KF6::ConfigCore
|
||||
Qt6::Core
|
||||
Qt6::Network
|
||||
Qt6::McpServer
|
||||
Qt6::McpCommon
|
||||
)
|
||||
target_compile_options(kom_mcp PRIVATE -fexceptions)
|
||||
target_compile_definitions(kom_mcp PRIVATE
|
||||
PROJECT_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||
KOMPANION_DB_INIT_INSTALL_DIR="${KOMPANION_DB_INIT_INSTALL_DIR}"
|
||||
)
|
||||
|
||||
install(TARGETS kom_mcp RUNTIME DESTINATION bin)
|
||||
install(FILES src/mcp/ToolSchemas.json DESTINATION ${KDE_INSTALL_DATADIR}/kompanion/mcp)
|
||||
|
||||
option(BUILD_TESTS "Build tests" ON)
|
||||
|
||||
add_executable(kompanion
|
||||
src/cli/KompanionApp.cpp
|
||||
)
|
||||
target_include_directories(kompanion PRIVATE src)
|
||||
target_link_libraries(kompanion PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Sql
|
||||
KF6::ConfigCore
|
||||
kom_dal
|
||||
)
|
||||
install(TARGETS kompanion RUNTIME ${KF_INSTALL_TARGETS_DEFAULT_ARGS})
|
||||
|
||||
if (BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
|
||||
target_link_libraries(kompanion_server Qt6::Core Qt6::Network Qt6::DBus)
|
||||
install(TARGETS kompanion_server RUNTIME DESTINATION bin)
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -1,29 +0,0 @@
|
|||
# metal-kompanion-mcp
|
||||
|
||||
MCP backend and memory provider for Kompanion. Uses `qtmcp` (Qt-based MCP) to expose tools under namespace `kom.memory.v1`.
|
||||
|
||||
## Build
|
||||
```bash
|
||||
cmake -S . -B build
|
||||
cmake --build build -j
|
||||
```
|
||||
|
||||
## Layout
|
||||
- `src/main.cpp` – QtMcp-backed entry point (stdio/SSE backends)
|
||||
- `src/mcp/ToolSchemas.json` – JSON Schemas for MCP tools
|
||||
- `src/memory/` – interfaces for embedder and vector store
|
||||
- `docs/` – design notes
|
||||
|
||||
## Next
|
||||
- Add richer tool metadata + prompt support on top of the qtmcp server.
|
||||
- Implement adapters: embedder(s) + vector store(s).
|
||||
- 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/`.
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
docker compose -f docker/compose.host.yml up -d
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
-- metal-kompanion core schema (pgvector)
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
source TEXT,
|
||||
path TEXT,
|
||||
sha256 TEXT,
|
||||
lineno INT,
|
||||
text TEXT NOT NULL,
|
||||
tags TEXT[],
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- embeddings: 1024-dim space (extend with more tables if needed)
|
||||
CREATE TABLE IF NOT EXISTS embeddings_1024 (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
knowledge_id BIGINT REFERENCES knowledge(id) ON DELETE CASCADE,
|
||||
model TEXT NOT NULL,
|
||||
embedding vector(1024) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS embeddings_1024_l2 ON embeddings_1024 USING ivfflat (embedding vector_l2_ops) WITH (lists=100);
|
||||
|
||||
-- memory branches (git-like)
|
||||
CREATE TABLE IF NOT EXISTS mem_branch (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
purpose TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mem_commit (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
branch_id BIGINT REFERENCES mem_branch(id) ON DELETE CASCADE,
|
||||
parent_id BIGINT,
|
||||
author_did TEXT,
|
||||
message TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mem_commit_branch ON mem_commit(branch_id);
|
||||
|
||||
-- commit deltas referencing knowledge rows
|
||||
CREATE TABLE IF NOT EXISTS mem_delta (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
commit_id BIGINT REFERENCES mem_commit(id) ON DELETE CASCADE,
|
||||
knowledge_id BIGINT REFERENCES knowledge(id) ON DELETE CASCADE,
|
||||
action SMALLINT NOT NULL CHECK (action IN (0,1,2)) -- 0:add,1:update,2:delete
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mem_delta_commit ON mem_delta(commit_id);
|
||||
|
||||
-- per-branch centroid for fast routing
|
||||
CREATE TABLE IF NOT EXISTS branch_embedding_1024 (
|
||||
branch_id BIGINT REFERENCES mem_branch(id) ON DELETE CASCADE,
|
||||
model TEXT NOT NULL,
|
||||
embedding vector(1024) NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY(branch_id, model)
|
||||
);
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
CREATE ROLE kompanion LOGIN PASSWORD 'komp';
|
||||
CREATE DATABASE kompanion OWNER kompanion;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS namespaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
namespace_id UUID NOT NULL REFERENCES namespaces(id) ON DELETE CASCADE,
|
||||
key TEXT,
|
||||
content TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
revision INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
last_accessed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memory_chunks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
item_id UUID NOT NULL REFERENCES memory_items(id) ON DELETE CASCADE,
|
||||
seq INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS embeddings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
chunk_id UUID NOT NULL REFERENCES memory_chunks(id) ON DELETE CASCADE,
|
||||
model TEXT NOT NULL,
|
||||
dim INT NOT NULL,
|
||||
vector VECTOR(1536),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(chunk_id, model)
|
||||
);
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
CREATE UNIQUE INDEX IF NOT EXISTS ux_items_ns_key
|
||||
ON memory_items(namespace_id, key)
|
||||
WHERE key IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_chunks_item ON memory_chunks(item_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_content_tsv
|
||||
ON memory_chunks USING GIN(content_tsv);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_embed_model_dim ON embeddings(model, dim);
|
||||
|
||||
-- per-model ANN index (duplicate with each concrete model name)
|
||||
CREATE INDEX IF NOT EXISTS ix_embed_vec_model_default
|
||||
ON embeddings USING ivfflat (vector vector_cosine_ops)
|
||||
WHERE model = 'default-emb';
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
-- Kompanion knowledge store (sqlite)
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ts TEXT NOT NULL,
|
||||
aspect TEXT,
|
||||
tags TEXT,
|
||||
text TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(text, content="entries", content_rowid="id");
|
||||
CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
|
||||
INSERT INTO entries_fts(rowid, text) VALUES (new.id, new.text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
|
||||
INSERT INTO entries_fts(entries_fts, rowid, text) VALUES(delete, old.id, old.text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
|
||||
INSERT INTO entries_fts(entries_fts, rowid, text) VALUES(delete, old.id, old.text);
|
||||
INSERT INTO entries_fts(rowid, text) VALUES (new.id, new.text);
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file TEXT NOT NULL,
|
||||
sha TEXT,
|
||||
lineno INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vectors (
|
||||
id INTEGER PRIMARY KEY,
|
||||
entry_id INTEGER REFERENCES entries(id) ON DELETE CASCADE,
|
||||
model TEXT NOT NULL,
|
||||
dim INTEGER NOT NULL,
|
||||
vec BLOB NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_head (
|
||||
id INTEGER PRIMARY KEY CHECK (id=1),
|
||||
head_sha TEXT
|
||||
);
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
DB_NAME=${1:-kompanion}
|
||||
ROLE=${ROLE:-kompanion}
|
||||
PASS=${PASS:-komp}
|
||||
|
||||
psql -v ON_ERROR_STOP=1 <<SQL
|
||||
DO $$ BEGIN
|
||||
PERFORM 1 FROM pg_roles WHERE rolname = '$ROLE';
|
||||
IF NOT FOUND THEN EXECUTE format('CREATE ROLE %I LOGIN PASSWORD %L', '$ROLE', '$PASS'); END IF;
|
||||
END $$;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '$DB_NAME') THEN
|
||||
EXECUTE format('CREATE DATABASE %I OWNER %I', '$DB_NAME', '$ROLE');
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
for f in db/init/*.sql; do
|
||||
echo "Applying $f"
|
||||
psql -d "$DB_NAME" -f "$f"
|
||||
done
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
DB_NAME=${1:-kompanion_test}
|
||||
ROLE=${ROLE:-kompanion}
|
||||
PASS=${PASS:-komup}
|
||||
|
||||
psql -v ON_ERROR_STOP=1 <<SQL
|
||||
DROP DATABASE IF EXISTS "$DB_NAME";
|
||||
CREATE DATABASE "$DB_NAME" OWNER "$ROLE";
|
||||
SQL
|
||||
|
||||
for f in db/init/*.sql; do
|
||||
echo "Applying $f"
|
||||
psql -d "$DB_NAME" -f "$f"
|
||||
done
|
||||
|
||||
echo "✓ Database $DB_NAME initialized."
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
# AnythingLLM ↔ Kompanion Memory Compatibility Evaluation
|
||||
|
||||
## Current Kompanion Memory Stack
|
||||
- **Primary store**: Postgres 14+ with `pgvector` ≥ 0.6, accessed via the C++ `PgDal` implementation (`embeddings`, `memory_chunks`, `memory_items`, `namespaces` tables). Each embedding row keeps `id`, `chunk_id`, `model`, `dim`, `vector`, and a `normalized` flag.
|
||||
- **Chunking & metadata**: Items are broken into chunks; embeddings attach to chunks via `chunk_id`. Item metadata lives as structured JSON on `memory_items` with tags, TTL, and revision controls.
|
||||
- **Namespace model**: Logical scopes (e.g. `project:user:thread`) are first-class rows. Retrieval joins embeddings back to items to recover text + metadata.
|
||||
- **Fallback mode**: Local-only path uses SQLite plus a FAISS sidecar (see `docs/MEMORY.md`) but the production design assumes Postgres.
|
||||
|
||||
## AnythingLLM Vector Stack (PGVector path)
|
||||
- Supports multiple vector backends; the overlapping option is `pgvector` (`server/utils/vectorDbProviders/pgvector/index.js`).
|
||||
- Expects a single table (default `anythingllm_vectors`) shaped as `{ id UUID, namespace TEXT, embedding vector(n), metadata JSONB, created_at TIMESTAMP }`.
|
||||
- Metadata is stored inline as JSONB; namespace strings are arbitrary workspace slugs. The embed dimension is fixed per table at creation time.
|
||||
- The NodeJS runtime manages chunking, caching, and namespace hygiene, and assumes CRUD against that flat table.
|
||||
|
||||
## Key Differences
|
||||
- **Schema shape**: Kompanion splits data across normalized tables with foreign keys; AnythingLLM uses a single wide table per vector store. Kompanion’s embeddings currently lack a JSONB metadata column and instead rely on joins.
|
||||
- **Identifiers**: Kompanion embeddings key off `chunk_id` (uuid/text) plus `model`; AnythingLLM expects a unique `id` per stored chunk and does not expose the underlying chunk relationship.
|
||||
- **Metadata transport**: Kompanion keeps tags/TTL in `memory_items` (JSON) and chunk text in `memory_chunks`. AnythingLLM packs metadata (including document references and source identifiers) directly into the vector row’s JSONB.
|
||||
- **Lifecycle hooks**: Kompanion enforces sensitivity flags before embedding; AnythingLLM assumes documents are already filtered and will happily ingest any chunk. Deletion flows differ (Kompanion uses soft-delete semantics; AnythingLLM issues hard deletes by namespace/document).
|
||||
- **Embeddings contract**: Kompanion records embedding model and dimension per row; AnythingLLM fixes dimension at table creation and stores model choice in JSON metadata.
|
||||
|
||||
## Compatibility Plan
|
||||
1. **Agree on a shared pgvector table**
|
||||
- Create (or reuse) a Postgres schema reachable by both systems.
|
||||
- Define a composite view or materialized view that maps `embeddings` + `memory_chunks` + `memory_items` into the `anythingLLM` layout (columns: `id`, `namespace`, `embedding`, `metadata`, `created_at`).
|
||||
- Add a JSONB projection that captures Kompanion metadata (`chunk_id`, `item_id`, `tags`, `model`, `revision`, sensitivity flags). This becomes the `metadata` field for AnythingLLM.
|
||||
|
||||
2. **Write a synchronization job**
|
||||
- Option A: database triggers on `embeddings` to insert/update a mirror row in `anythingllm_vectors`.
|
||||
- Option B: periodic worker that scans for new/updated embeddings (`revision` or `updated_at`) and upserts into the shared table through SQL.
|
||||
- Ensure deletions (soft or hard) propagate by expiring mirrored rows or respecting a `deleted_at` flag in metadata (AnythingLLM supports document purges via namespace filtering).
|
||||
|
||||
3. **Normalize namespace semantics**
|
||||
- Reuse Kompanion’s namespace string as the AnythingLLM workspace slug.
|
||||
- Document mapping rules (e.g. replace `:` with `_` if AnythingLLM slugs disallow colons).
|
||||
- Provide a compatibility map in metadata so both systems resolve back to Kompanion’s canonical namespace identity.
|
||||
|
||||
4. **Unify embedding models**
|
||||
- Select a shared embedding model (e.g., `text-embedding-3-large` or local Nomic).
|
||||
- Record the chosen model in the mirrored metadata and enforce dimension on the `anythingllm_vectors` table creation.
|
||||
- Update Kompanion’s embedding pipeline to fail fast if the produced dimension differs from the table’s fixed size.
|
||||
|
||||
5. **Expose retrieval APIs**
|
||||
- For Kompanion → AnythingLLM: implement a thin adapter that reads from the shared table instead of internal joins when responding to AnythingLLM requests (or simply let AnythingLLM talk directly to Postgres).
|
||||
- For AnythingLLM → Kompanion: ensure the metadata payload includes the necessary identifiers (`item_id`, `chunk_id`) so Kompanion can resolve back to full context.
|
||||
|
||||
6. **Security & sensitivity handling**
|
||||
- Extend the metadata JSON to include Kompanion’s sensitivity/embeddable flags.
|
||||
- Patch AnythingLLM ingestion to respect a `sensitivity` key (skip or mask secrets) before inserting into its table, or filter at the view level so secret rows never surface.
|
||||
|
||||
7. **Validation & tooling**
|
||||
- Add a migration checklist covering table creation, index alignment (`USING ivfflat`), and permission grants for the AnythingLLM service role.
|
||||
- Create integration tests that:
|
||||
1. Upsert an item in Kompanion.
|
||||
2. Confirm mirrored row appears in `anythingllm_vectors`.
|
||||
3. Query through AnythingLLM API and verify the same chunk text + metadata round-trips.
|
||||
|
||||
## Near-Term Tasks
|
||||
1. Draft SQL for the projection view/materialized view, including JSONB assembly.
|
||||
2. Prototype a synchronization worker (Python or C++) that mirrors embeddings into the AnythingLLM table.
|
||||
3. Define namespace slug normalization rules and document them in both repos.
|
||||
4. Coordinate on embedding model selection and update configuration in both stacks.
|
||||
5. Add automated compatibility tests to CI pipelines of both projects.
|
||||
|
|
@ -1,361 +0,0 @@
|
|||
Below is a **single copy‑pastable Markdown file** that proposes a client‑side architecture which treats memory as a living, hierarchical JSON **dictionary‑of‑dictionaries** (HDoD), adds *semantic + episodic activation* and pruning, and composes prompts for your coding agent. It maps cleanly onto the **MCP tools you currently expose**:
|
||||
|
||||
* `kom.meta.v1.project_snapshot`
|
||||
* `kom.local.v1.backup.import_encrypted`
|
||||
* `kom.local.v1.backup.export_encrypted`
|
||||
* `kom.memory.v1.search_memory`
|
||||
* `kom.memory.v1.upsert_memory`
|
||||
* `kom.memory.v1.recall_context`
|
||||
* `kom.memory.v1.save_context`
|
||||
|
||||
It keeps the server simple and pushes *intelligence about memory* into the **client orchestrator**, so you can get the behavior you want **today**, without first redesigning the server.
|
||||
|
||||
---
|
||||
|
||||
# Kompanion Client Memory Architecture (HDoD)
|
||||
|
||||
**Version:** 0.2 • **Scope:** Client‑side AI interface to Kompanion MCP Server
|
||||
**Author:** Χγφτ (Kompanion of Esus / Andre)
|
||||
**Purpose:** Make memory *behave* like a nested JSON of concepts that “lights up” semantically and episodically, prunes naturally, and feeds agent prompts with high‑quality domain tokens (e.g., C++ patterns) — *without* waiting on a more complex server.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
Large models feel “smart but memoryless.” We want:
|
||||
|
||||
1. **A hierarchical mental map** (JSON dictionary‑of‑dictionaries, “HDoD”),
|
||||
2. **Activation dynamics** (semantic + episodic “lighting up” of nodes/paths),
|
||||
3. **Organic pruning** (cool down; unload),
|
||||
4. **Prefilled knowledge packs** (domain seeds: e.g., C++ idioms),
|
||||
5. **Deterministic prompt composition** for coding agents (Codex/Qwen/etc.).
|
||||
|
||||
The server currently provides a **minimal memory API**. That’s fine: we’ll implement the cognitive part **client‑side** and use the server as a persistence/search/recall backbone.
|
||||
|
||||
---
|
||||
|
||||
## 2. The mental model (HDoD = dictionary‑of‑dictionaries)
|
||||
|
||||
Think of memory as a normalized **graph**, *presented* to the user/agent as a **dictionary tree**. Each node is a **Concept** with:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "skill.cpp.templates.sfinae",
|
||||
"type": "concept", // concept | skill | fact | snippet | pattern | episode | tool | persona | task
|
||||
"label": "SFINAE",
|
||||
"payload": { "definition": "...", "examples": ["..."], "anti_patterns": ["..."] },
|
||||
"embeddings": { "model": "local-bge|text-embed-3", "vector": [/* ... */] },
|
||||
"links": [
|
||||
{"to": "skill.cpp.templates.metaprogramming", "rel": "is_a", "w": 0.8},
|
||||
{"to": "pattern.cpp.enable_if", "rel": "uses", "w": 0.7}
|
||||
],
|
||||
"children": { "intro": "skill.cpp.templates.sfinae.intro", "advanced": "skill.cpp.templates.sfinae.advanced" },
|
||||
"stats": { "uses": 12, "last_used_at": "2025-10-14T18:42:00Z" },
|
||||
"resonance": { "value": 0.0, "decay": 0.98, "last_activated_at": null },
|
||||
"meta": { "source": "seedpack:cpp-core", "version": "1.0.0" }
|
||||
}
|
||||
```
|
||||
|
||||
> **Presentation vs. storage:** on the wire we keep nodes **normalized** (graph), but for agent prompts we can **materialize a subtree** as a JSON dictionary (exactly your intuition).
|
||||
|
||||
---
|
||||
|
||||
## 3. Core client components
|
||||
|
||||
### 3.1 Memory Orchestrator (library)
|
||||
|
||||
A small client library (TypeScript or Python) that owns:
|
||||
|
||||
* **Local Cache & Working Set**
|
||||
|
||||
* in‑memory map of *hot nodes* (activated concepts, episodes, tasks)
|
||||
* TTL + **resonance decay** keeps it naturally pruned
|
||||
|
||||
* **Activation Engine**
|
||||
Computes a **Resonance Score** used for selection/exploration:
|
||||
|
||||
```
|
||||
score(node | query, task) =
|
||||
α * cosine(embedding(node), embed(query))
|
||||
+ β * max_edge_weight_to(frontier)
|
||||
+ γ * recency(node)
|
||||
+ δ * usage(node)
|
||||
+ ε * task_affinity(node, task.tags)
|
||||
+ ζ * persona_weight(node, active_persona)
|
||||
```
|
||||
|
||||
Typical: α=0.45, β=0.20, γ=0.15, δ=0.10, ε=0.07, ζ=0.03
|
||||
|
||||
* **HDoD Composer**
|
||||
Given a set of nodes, **materialize** a JSON dictionary tree (merge, order by score, trim to token budget).
|
||||
|
||||
* **Context Frames**
|
||||
Structured blocks that the agent can consume:
|
||||
|
||||
* *Identity Frame* (who am I / tools)
|
||||
* *Problem Frame* (task/spec)
|
||||
* *Knowledge Frame* (HDoD subtree from semantic activation)
|
||||
* *Episodic Frame* (recent steps/outcomes, e.g., compilation logs)
|
||||
* *Constraints Frame* (APIs, signatures, tests)
|
||||
* *Scratchpad Frame* (space for chain‑of‑thought *outside* model hidden state—LLM sees a compact, explicit scratch area)
|
||||
|
||||
* **Server Adapters** (mapping to your MCP tools)
|
||||
|
||||
* `search_memory(query)` → seeds for *semantic activation*
|
||||
* `recall_context(task|session)` → seeds for *episodic activation*
|
||||
* `save_context(blocks)` → write back learned episodes/summaries
|
||||
* `upsert_memory(nodes)` → persist new/updated concepts/snippets
|
||||
* `project_snapshot()` → immutable snapshot of current mental state
|
||||
* `backup.export_encrypted()` / `backup.import_encrypted()` → **Knowledge Packs** (see §5)
|
||||
|
||||
> This is enough to *behave* like a cognitive system, while your server stays simple and fast.
|
||||
|
||||
---
|
||||
|
||||
## 4. Algorithms (client‑side, concise)
|
||||
|
||||
### 4.1 Probe → Bloom → Trim (context building)
|
||||
|
||||
```
|
||||
build_context(query, task, budget):
|
||||
seeds_sem = kom.memory.search_memory(query, k=32)
|
||||
seeds_epi = kom.memory.recall_context(task_id=task.id, k=32)
|
||||
frontier = normalize(seeds_sem ∪ seeds_epi)
|
||||
|
||||
for hop in 1..H (H=2 or 3):
|
||||
neighbors = expand(frontier, max_per_node=6) // via node.links
|
||||
frontier = frontier ∪ neighbors
|
||||
update_resonance(frontier, query, task)
|
||||
|
||||
selected = topK_by_type(frontier, K_by_type) // diversity caps
|
||||
frames = compose_frames(query, task, selected, budget) // HDoD for Knowledge Frame; episodes for Episodic Frame
|
||||
|
||||
kom.memory.save_context({ task_id: task.id, frames })
|
||||
return frames
|
||||
```
|
||||
|
||||
**Natural pruning**: each tick, `node.resonance.value *= node.resonance.decay`; nodes fall out of the Working Set unless re‑activated.
|
||||
|
||||
### 4.2 Upserting *observations* and *skills*
|
||||
|
||||
* After actions (file write, compile run, test pass/fail), emit **Observation** nodes (type `episode`) with edges to involved concepts/snippets.
|
||||
* When the model discovers a pattern (“prefer RAII for resource ownership”), emit **Skill** nodes with `examples` and `anti_patterns`.
|
||||
|
||||
### 4.3 Materializing HDoD
|
||||
|
||||
Given selected nodes, build a JSON dictionary with *paths as keys* (e.g., `skill.cpp.templates.sfinae`) and nested maps for child grouping. Keep atomic fields compact (definitions, signatures) and push long text to a `details` field that can be compressed or summarized.
|
||||
|
||||
---
|
||||
|
||||
## 5. Knowledge Packs (prefilling intelligence)
|
||||
|
||||
**Goal:** give your coder agent *real* domain knowledge (C++ idioms, STL nuances, build systems, unit testing patterns) as compact, queryable, embedded chunks.
|
||||
|
||||
* **Format:** Encrypted tar/zip with manifest
|
||||
|
||||
```
|
||||
pack.json:
|
||||
id, name, version, domain_tags: ["cpp","cmake","catch2"],
|
||||
embedding_model, created_at, checksum
|
||||
nodes.jsonl:
|
||||
{"id":"skill.cpp.raii", "type":"skill", "payload":{...}, "embeddings":{...}, "links":[...]}
|
||||
...
|
||||
```
|
||||
* **Import/Export via existing tools:**
|
||||
|
||||
* `kom.local.v1.backup.import_encrypted(pack)`
|
||||
* `kom.local.v1.backup.export_encrypted(selection)`
|
||||
* **Curation approach:** create *micro‑chunks*:
|
||||
|
||||
* **Concept**: “RAII”, “SFINAE”, “Rule of 5/0”, “ADL”, “Type erasure”
|
||||
* **Pattern**: “pImpl”, “CRTP”, “Enable‑if idiom”
|
||||
* **Snippet**: idiomatic examples (≤30 lines), compile‑checked
|
||||
* **Anti‑patterns**: “raw new/delete in modern C++”, “overusing exceptions”
|
||||
* **Build/Tooling**: CMake minimum skeletons, `add_library`, interfaces, `FetchContent`
|
||||
* **Test**: Catch2/GoogleTest minimal cases; property‑based testing sketch
|
||||
|
||||
> Once imported, the Pack is just **memory**. The Activation Engine will surface it the same way it surfaces your episodes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Client ↔ Server mapping (today’s API)
|
||||
|
||||
### 6.1 Search (semantic seeds)
|
||||
|
||||
```ts
|
||||
await kom.memory.v1.search_memory({
|
||||
query: "C++ template substitution failure handling SFINAE",
|
||||
k: 32, filters: { types: ["concept","skill","pattern"] }
|
||||
})
|
||||
```
|
||||
|
||||
### 6.2 Recall (episodic seeds)
|
||||
|
||||
```ts
|
||||
await kom.memory.v1.recall_context({
|
||||
scope: "task", id: task.id, k: 32
|
||||
})
|
||||
```
|
||||
|
||||
### 6.3 Save context (write-back frames)
|
||||
|
||||
```ts
|
||||
await kom.memory.v1.save_context({
|
||||
task_id: task.id,
|
||||
frames: [
|
||||
{ kind: "knowledge", format: "hdod.json", data: {/* nested dict */} },
|
||||
{ kind: "episodic", format: "markdown", data: "# Steps\n- Compiled...\n- Tests..." }
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
### 6.4 Upsert memory (new skills/snippets)
|
||||
|
||||
```ts
|
||||
await kom.memory.v1.upsert_memory({
|
||||
nodes: [ /* normalized nodes like in §2 */ ],
|
||||
merge: true
|
||||
})
|
||||
```
|
||||
|
||||
### 6.5 Snapshots & Packs
|
||||
|
||||
```ts
|
||||
await kom.meta.v1.project_snapshot({ include_memory: true })
|
||||
await kom.local.v1.backup.export_encrypted({ selection: "domain:cpp", out: "packs/cpp-core.kpack" })
|
||||
```
|
||||
|
||||
> **Important:** Client keeps **resonance state** locally. Server remains simple KV/search/recall/persist.
|
||||
|
||||
---
|
||||
|
||||
## 7. Prompt composition for a coding agent
|
||||
|
||||
**Goal:** Transform the Working Set into a *stable prompt contract*, so the agent operates above “first‑grade cobbling.”
|
||||
|
||||
**Prompt Frames (ordered):**
|
||||
|
||||
1. **Identity/Tools**: who the agent is, what tools are available (ACF, tmux, build, test).
|
||||
2. **Problem Frame**: concise task, interfaces, constraints.
|
||||
3. **Knowledge Frame (HDoD)**: the **hierarchical dictionary** of concepts/patterns/snippets selected by activation; *max 40–60% of token budget*.
|
||||
4. **Episodic Frame**: last N steps + outcomes; keep terse.
|
||||
5. **Constraints Frame**: language level (C++20), error policies, style (guidelines support library, ranges), testing expectation.
|
||||
6. **Scratchpad Frame**: allow the model to outline plan & invariants (explicit, not hidden).
|
||||
|
||||
**Effect:** The agent “feels” like it *knows* C++ idioms (because it sees compact, curated, **embedded** patterns every turn), and it keeps context from previous steps (episodic frame).
|
||||
|
||||
---
|
||||
|
||||
## 8. Data shape & invariants
|
||||
|
||||
* **IDs are path‑like**: `skill.cpp.templates.sfinae` (hierarchy is explicit).
|
||||
* **Graph canonical, Dicts for presentation**: treat `children` as **references**; avoid deep duplication.
|
||||
* **Embeddings are per node**; you may add **type‑specific** vectors later.
|
||||
* **Edges carry weights**; they contribute to resonance.
|
||||
* **Resonance decays** every tick; any node with `value < ε` leaves the Working Set.
|
||||
* **Budgets**: Top‑K per type (e.g., 6 skills, 10 snippets, 4 patterns) to avoid monoculture.
|
||||
|
||||
---
|
||||
|
||||
## 9. Minimal TypeScript client surface (sketch)
|
||||
|
||||
```ts
|
||||
type NodeType = "concept"|"skill"|"fact"|"snippet"|"pattern"|"episode"|"tool"|"persona"|"task";
|
||||
|
||||
interface KomNode {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
label: string;
|
||||
payload?: any;
|
||||
embeddings?: { model: string; vector: number[] };
|
||||
links?: { to: string; rel: string; w?: number }[];
|
||||
children?: Record<string, string>;
|
||||
stats?: { uses?: number; last_used_at?: string };
|
||||
resonance?: { value: number; decay: number; last_activated_at?: string | null };
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface Frames {
|
||||
identity?: string;
|
||||
problem: string;
|
||||
knowledgeHDoD?: Record<string, any>;
|
||||
episodic?: string;
|
||||
constraints?: string;
|
||||
scratchpad?: string;
|
||||
}
|
||||
|
||||
class MemoryOrchestrator {
|
||||
private workingSet = new Map<string, KomNode>();
|
||||
constructor(private server: KomServerAdapter, private embed: (text:string)=>number[]) {}
|
||||
|
||||
async buildContext(query: string, task: { id: string; tags?: string[] }, budgetTokens: number): Promise<Frames> {
|
||||
const seeds = await this.server.searchMemory(query, 32);
|
||||
const epis = await this.server.recallContext(task.id, 32);
|
||||
this.seed(seeds.concat(epis)); // normalize to nodes in workingSet
|
||||
this.bloom(query, task, 2); // expand via links, update resonance
|
||||
const selected = this.selectByTypeCaps(task); // diversity caps
|
||||
const knowledgeHDoD = this.materializeHDoD(selected, budgetTokens);
|
||||
const frames: Frames = { problem: this.renderProblem(task), knowledgeHDoD };
|
||||
await this.server.saveContext(task.id, frames);
|
||||
return frames;
|
||||
}
|
||||
|
||||
/* seed, bloom, selectByTypeCaps, materializeHDoD, renderProblem ... */
|
||||
}
|
||||
```
|
||||
|
||||
> This is intentionally thin; you can drop it into your existing client shell and wire the 7 server calls you already have.
|
||||
|
||||
---
|
||||
|
||||
## 10. How this reflects **Elope**’s spirit
|
||||
|
||||
Your earlier **Elope** work separated **episodic** from **semantic** memory and played with identity, observations, and “resonance/whales” motifs. This client keeps that spirit:
|
||||
|
||||
* Episodic = **Observations/episodes** (recent steps, logs).
|
||||
* Semantic = **Concepts/skills/patterns** (stable knowledge packs + learned patterns).
|
||||
* Resonance = **activation value** that guides expansion, selection, and **natural pruning**.
|
||||
|
||||
---
|
||||
|
||||
## 11. Observability (what to watch)
|
||||
|
||||
* **Coverage**: % of turns where Knowledge Frame includes ≥1 concept from the active domain.
|
||||
* **Drift**: cosine distance between task query and top‑3 knowledge nodes (want stable closeness).
|
||||
* **Utility**: model asks fewer irrelevant questions; compile/test pass rates increase.
|
||||
* **Memory hygiene**: working set size stays under target (e.g., < 800 nodes), average resonance > threshold.
|
||||
|
||||
---
|
||||
|
||||
## 12. Failure modes & graceful degradation
|
||||
|
||||
* **Server down** → keep local Working Set; write a **Pending Save** episode; retry `save_context` later.
|
||||
* **Search sparse** → fall back to Pack defaults (seed nodes by domain tag).
|
||||
* **Prompt over-budget** → trim per type; compress long `payload.details` into bullet summaries.
|
||||
* **Bad seeds** → down‑weight sources with low subsequent utility.
|
||||
|
||||
---
|
||||
|
||||
## 13. What to change later (server‑side, optional)
|
||||
|
||||
Only once you want more power centrally:
|
||||
|
||||
* Add **typed ANN** queries (“top‑K per type”),
|
||||
* Add **resonance on server** for multi‑agent sharing,
|
||||
* Add **link‑aware search** (expand N hops server‑side),
|
||||
* Add **constraints retrieval** (auto‑inject API signatures/tests).
|
||||
|
||||
Until then, the **client gives you the behavior you want now**.
|
||||
|
||||
---
|
||||
|
||||
## 14. TL;DR
|
||||
|
||||
* Treat memory as a **graph rendered as HDoD**,
|
||||
* **Activate** by semantic+episodic seeds; **bloom** 1–2 hops by links; **trim** by type caps,
|
||||
* **Feed** the agent *frames* (esp. Knowledge HDoD),
|
||||
* **Prefill** with encrypted **Knowledge Packs** (C++ idioms, snippets),
|
||||
* Use only your **7 existing endpoints** — intelligence is **client‑side**.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
## Prompt Markers Embedded in Shell Commands
|
||||
|
||||
- Keep Konsole usage unchanged: the human runs commands normally, the Kompanion agent watches and journals in the background.
|
||||
- Introduce a lightweight marker syntax to flag prompts for the agent without leaving the terminal context.
|
||||
- Example marker: the `§` character wrapping a phrase, e.g. `§"mermaid, tell me a story"` or `> §(good editor for go)`.
|
||||
- When the agent sees a marker, it interprets the enclosed text as an LLM-style instruction and can respond or take action.
|
||||
- Markers can be mixed with actual commands, e.g. `echo $(gpg --agent --daemon)` followed by `§"generate a deployment checklist"`.
|
||||
- Future work: define how the bridge detects markers in real time, how responses are surfaced (inline vs. side panel), and how to opt-in/out per session.
|
||||
|
|
@ -1,363 +0,0 @@
|
|||
You’re not alone, Andre. What you’re describing—**a personal, connected, humane Kompanion inside KDE**—is absolutely doable. Below is a **single, copy‑pastable design doc** for a Qt/KDE SDK that turns the client into the “mind” you want: hierarchical memory, semantic/episodic activation, proactive (but consentful) initiative, and tight integration with Kontact/Akonadi, Konsole, KDevelop/Kate, and Matrix.
|
||||
|
||||
---
|
||||
|
||||
# Kompanion Qt/KDE SDK (HDoD Client)
|
||||
|
||||
**Goal**
|
||||
A Qt/KDE library and set of plugins that make Kompanion a **personal LLM API provider** across KDE apps. It treats memory as a **hierarchical dictionary‑of‑dictionaries (HDoD)**, lights up relevant knowledge semantically/episodically, and quietly coordinates across email (Kontact/Akonadi), terminal (Konsole), coding (KDevelop/Kate), and chat (Matrix). Server stays simple (your existing MCP tools); **intelligence lives in the client**.
|
||||
|
||||
**Targets**
|
||||
|
||||
* **Kontact** (email style, PIM context via **Akonadi**) ([kontact.kde.org][1])
|
||||
* **Konsole** (sysadmin helper via D‑Bus control) ([docs.kde.org][2])
|
||||
* **KDevelop/Kate** (coding+UI design via KTextEditor/KDevelop plugins) ([api.kde.org][3])
|
||||
* **Matrix** (chat with your Kompanion anywhere, using **libQuotient** + Olm/Megolm E2EE) ([Quotient Im][4])
|
||||
* **AnythingLLM‑like management view** (hidden by default; advanced users only) ([GitHub][5])
|
||||
|
||||
---
|
||||
|
||||
## 1) Architecture Overview
|
||||
|
||||
```
|
||||
+-------------------------------------------------------------+
|
||||
| Applications |
|
||||
| Kontact | Konsole | KDevelop/Kate | Plasma applets | NeoChat|
|
||||
+---------------------+--------------------+-------------------+
|
||||
| |
|
||||
[KParts / Plugins] [Matrix bot/client]
|
||||
| |
|
||||
+-------------------------------------------------------------+
|
||||
| Kompanion Qt/KDE SDK (this repo) |
|
||||
| |
|
||||
| KKompanionCore : HDoD memory, activation, prompt frames |
|
||||
| KKompanionKDE : Akonadi, Baloo/KFileMetaData, KWallet, |
|
||||
| KConfig (Kiosk), Konsole D-Bus bridge |
|
||||
| KKompanionMatrix : libQuotient + libolm/vodozemac bridge |
|
||||
| KKompanionUI : Kirigami settings (advanced hidden) |
|
||||
| MCP Client : talks to your server tools: |
|
||||
| kom.meta.project_snapshot |
|
||||
| kom.local.backup.import/export_encrypted |
|
||||
| kom.memory.search/upsert/recall/save |
|
||||
+-------------------------------------------------------------+
|
||||
| Kompanion MCP Server |
|
||||
| (simple, current 7 tools) |
|
||||
+-------------------------------------------------------------+
|
||||
```
|
||||
|
||||
* **Akonadi** provides centralized PIM data; we only *read* what you permit (emails, contacts, calendars) for style/context. ([kontact.kde.org][1])
|
||||
* **KParts/KontactInterface** embed our components into Kontact (PIM) and let us ship a first‑class Kontact plugin. ([TechBase][6])
|
||||
* **Konsole** is steered via its **D‑Bus** API for safe, opt‑in command scaffolding (never auto‑exec). ([docs.kde.org][2])
|
||||
* **KTextEditor/KDevelop** plugin gives coding help uniformly in Kate & KDevelop. ([api.kde.org][3])
|
||||
* **Matrix** via **libQuotient** and Olm/Megolm enables verified end‑to‑end encrypted chat with your Kompanion identity. ([Quotient Im][4])
|
||||
* **AnythingLLM** is referenced only for an **optional admin view** (pack/workspace management)—not for the day‑to‑day UX. ([GitHub][5])
|
||||
* **Baloo + KFileMetaData** can supply local file metadata/content hooks for the generic scraper, with user scoping. ([api.kde.org][7])
|
||||
* **MCP** is the open standard glue so other IDEs/apps can also plug into your backend. ([Model Context Protocol][8])
|
||||
|
||||
---
|
||||
|
||||
## 2) Memory as HDoD (client‑side “mind”)
|
||||
|
||||
**Node shape (normalized graph; rendered as nested dict for prompts):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "skill.cpp.templates.sfinae",
|
||||
"type": "skill|concept|pattern|snippet|episode|persona|tool|task",
|
||||
"label": "SFINAE",
|
||||
"payload": {"definition":"...","examples":["..."]},
|
||||
"links": [{"to":"pattern.cpp.enable_if","rel":"uses","w":0.7}],
|
||||
"children": {"intro":"skill.cpp.templates.sfinae.intro"},
|
||||
"embeddings": {"model":"your-embedder","vector":[...]},
|
||||
"resonance": {"value":0.0,"decay":0.98,"last_activated_at":null},
|
||||
"acl": {"visibility":"private|public|org","policy":"consent|auto"},
|
||||
"meta": {"source":"akonadi:mail|baloo:file|pack:cpp-core"}
|
||||
}
|
||||
```
|
||||
|
||||
**Activation = semantic + episodic**
|
||||
|
||||
* semantic seeds ← `kom.memory.search_memory`
|
||||
* episodic seeds ← `kom.memory.recall_context`
|
||||
* bloom 1–2 hops via `links`, score by cosine + recency + usage + persona affinity; decay on each tick.
|
||||
* **Compose** a bounded **Knowledge Frame** (the nested dict you envisioned) + **Episodic Frame** (recent steps/outcomes), then save: `kom.memory.save_context`.
|
||||
|
||||
**Upserts**
|
||||
|
||||
* Learned patterns/snippets become nodes; persist with `kom.memory.upsert_memory`.
|
||||
* Export/import encrypted **Knowledge Packs** via `kom.local.backup.export_encrypted` / `import_encrypted`.
|
||||
|
||||
---
|
||||
|
||||
## 3) KDE integration plan (modules)
|
||||
|
||||
### 3.1 KKompanionKDE
|
||||
|
||||
* **Akonadi** reader (read‑only unless explicitly asked): harvest *your* style from sent mail (tone, sign‑offs), map contacts, and calendar context. Store only derived *style vectors* and short templates—never raw mail unless you choose. ([kontact.kde.org][1])
|
||||
* **Baloo + KFileMetaData**: optional file harvester for local notes/repos; use include/exclude rules; no index expansion without consent. ([docs.kde.org][9])
|
||||
* **KWallet**: hold API keys/secrets (or disabled entirely). ([api.kde.org][10])
|
||||
* **KConfig (Kiosk)**: per‑profile settings & lockdown (e.g., corporate). ([api.kde.org][11])
|
||||
* **Konsole D‑Bus bridge**: suggest safe commands, show diffs, paste only on user confirm—use Konsole’s documented D‑Bus. ([docs.kde.org][2])
|
||||
|
||||
### 3.2 KKompanionCore
|
||||
|
||||
* HDoD store (in‑memory working set) + resonance decay
|
||||
* Embedding adapters (local or remote)
|
||||
* Frame composer (Identity/Problem/Knowledge/Episodic/Constraints/Scratchpad)
|
||||
* MCP client (JSON‑RPC) to your 7 tools
|
||||
|
||||
### 3.3 KKompanionMatrix
|
||||
|
||||
* **libQuotient** client; device verification; room‑per‑task or direct chat; ensure E2EE (Olm/Megolm). ([Quotient Im][4])
|
||||
* Your Kompanion appears as a **Matrix contact** you can message from any client (NeoChat, Nheko, Element). NeoChat is a KDE Matrix client you can use; it’s active and cross‑platform. ([KDE Applications][12])
|
||||
|
||||
### 3.4 KKompanionUI (Kirigami)
|
||||
|
||||
* One **simple** page for privacy sliders (“What may I learn from mail?”, “From files?”, “From terminal?”)
|
||||
* An **advanced** tab (off by default) for power users—akin to AnythingLLM’s admin—but not part of the everyday UX. ([GitHub][5])
|
||||
|
||||
---
|
||||
|
||||
## 4) Plugins (thin shims)
|
||||
|
||||
### 4.1 Kontact (KParts/KontactInterface)
|
||||
|
||||
* Build a `KontactInterface::Plugin` that exposes “Kompanion” as a side panel (compose mail in your style; suggest replies based on thread context). ([api.kde.org][13])
|
||||
|
||||
### 4.2 Konsole
|
||||
|
||||
* No risky auto‑actions. Provide a “Propose” button that queues an action; on accept, we call Konsole’s D‑Bus to paste/execute. (Also capture *opt‑in* snippets as episodes.) ([docs.kde.org][2])
|
||||
|
||||
### 4.3 KDevelop/Kate
|
||||
|
||||
* Prefer a **KTextEditor::Plugin** (works in both Kate & KDevelop), so features like inline refactors, snippet recall, and “explain this diagnostic” show in both. ([api.kde.org][3])
|
||||
|
||||
---
|
||||
|
||||
## 5) Ingestion (“generic scraper”) & data classes
|
||||
|
||||
**Connectors**
|
||||
|
||||
* **Akonadi** (mail/contacts/calendar) → style features, task hints. ([kontact.kde.org][1])
|
||||
* **Baloo/KFileMetaData** (local files) → metadata & content extracts when allowed. ([api.kde.org][7])
|
||||
* **Git** (repos) → commit history, code snippets.
|
||||
* **Konsole** (D‑Bus) → *opt‑in* command transcripts for episodic memory. ([docs.kde.org][2])
|
||||
|
||||
**Classification**
|
||||
|
||||
* `acl.visibility`: `public | org | private`
|
||||
* `acl.policy`: `consent | auto`
|
||||
* Personal data defaults to `private+consent`.
|
||||
* **Export** public nodes as signed **Knowledge Packs**; private stays local or encrypted export.
|
||||
|
||||
---
|
||||
|
||||
## 6) Prompt & “Eigeninitiative”
|
||||
|
||||
**Frames** (strict order; bounded size):
|
||||
|
||||
1. Identity/Tools (what I can do in this app)
|
||||
2. Problem (what you’re doing)
|
||||
3. **Knowledge (HDoD)** — nested dict of activated nodes
|
||||
4. Episodic (recent steps/results)
|
||||
5. Constraints (C++ level, style rules, tests)
|
||||
6. Scratchpad (visible plan/invariants)
|
||||
|
||||
**Initiative knobs**
|
||||
|
||||
* Per‑app slider: *Suggest silently* → *Ask to help* → *Propose plan* → *Auto‑prepare draft (never auto‑send/run)*.
|
||||
* Daily **check‑in** prompt (one line) to reduce loneliness & personalize tone.
|
||||
|
||||
---
|
||||
|
||||
## 7) CMake & minimal skeletons (headers, not full code)
|
||||
|
||||
**Top‑level CMake**
|
||||
|
||||
```cmake
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(KKompanion LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network)
|
||||
find_package(KF6 REQUIRED COMPONENTS Config I18n CoreAddons Wallet)
|
||||
# Optional KDE bits
|
||||
find_package(KF6 REQUIRED COMPONENTS KIO) # if needed
|
||||
# KTextEditor for Kate/KDevelop plugin
|
||||
find_package(KF6TextEditor QUIET) # provides KTextEditor
|
||||
# Akonadi (PIM)
|
||||
find_package(KPim6AkonadiCore QUIET)
|
||||
# Baloo/KFileMetaData
|
||||
find_package(KF6FileMetaData QUIET)
|
||||
# libQuotient (Matrix)
|
||||
find_package(Quotient QUIET)
|
||||
|
||||
add_subdirectory(src/core)
|
||||
add_subdirectory(src/kde)
|
||||
add_subdirectory(src/matrix)
|
||||
add_subdirectory(plugins/kontact)
|
||||
add_subdirectory(plugins/ktexteditor) # loads in Kate & KDevelop
|
||||
```
|
||||
|
||||
**Core (HDoD + activation)**
|
||||
|
||||
```cpp
|
||||
// src/core/KomMemory.hpp
|
||||
struct KomNode { /* id, type, payload, links, children, embeddings, resonance, acl, meta */ };
|
||||
class MemoryOrchestrator {
|
||||
public:
|
||||
Frames buildContext(const QString& query, const Task& task, int tokenBudget);
|
||||
void seed(const QVector<KomNode>& nodes);
|
||||
void bloom(const QString& query, const Task& task, int hops=2);
|
||||
QVector<KomNode> selectByCaps(const QStringList& types, int perTypeK) const;
|
||||
QJsonObject materializeHDoD(const QVector<KomNode>& nodes, int budgetTokens) const;
|
||||
};
|
||||
|
||||
// src/core/McpClient.hpp (thin JSON-RPC client for your 7 tools)
|
||||
class McpClient {
|
||||
// search_memory / upsert_memory / recall_context / save_context / snapshot / backup import/export
|
||||
};
|
||||
```
|
||||
|
||||
**Kontact plugin (KParts/KontactInterface)**
|
||||
|
||||
```cpp
|
||||
// plugins/kontact/KompanionKontactPlugin.hpp
|
||||
class KompanionKontactPlugin : public KontactInterface::Plugin {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit KompanionKontactPlugin(KontactInterface::Core* core, const KPluginMetaData& md, QObject* parent=nullptr);
|
||||
QWidget* createPart() override; // returns our side panel (QWidget)
|
||||
};
|
||||
```
|
||||
|
||||
*(Kontact uses KParts to host components; the Plugin API is the official glue.)* ([TechBase][6])
|
||||
|
||||
**Kate/KDevelop plugin (KTextEditor)**
|
||||
|
||||
```cpp
|
||||
// plugins/ktexteditor/KompanionKTEPlugin.hpp
|
||||
class KompanionKTEPlugin : public KTextEditor::Plugin {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit KompanionKTEPlugin(QObject* parent = nullptr, const QVariantList& = {});
|
||||
QObject* createView(KTextEditor::MainWindow* mw) override; // add a side toolview
|
||||
};
|
||||
```
|
||||
|
||||
*(KTextEditor plugins are first‑class and hostable in Kate and KDevelop.)* ([api.kde.org][3])
|
||||
|
||||
**Konsole bridge (D‑Bus)**
|
||||
|
||||
```cpp
|
||||
// src/kde/KonsoleBridge.hpp
|
||||
class KonsoleBridge : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
bool proposeAndRun(const QString& command); // UI confirm -> D-Bus: sendText + newline
|
||||
};
|
||||
```
|
||||
|
||||
*(Konsole exposes a documented D‑Bus surface for scripting.)* ([docs.kde.org][2])
|
||||
|
||||
**Matrix bridge (libQuotient)**
|
||||
|
||||
```cpp
|
||||
// src/matrix/MatrixAgent.hpp
|
||||
class MatrixAgent : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
void connectAccount(const QString& homeserver, const QString& user, const QString& token);
|
||||
void ensureE2EE(); // device verification; cross-sign keys
|
||||
void sendMessage(const QString& roomId, const QString& text);
|
||||
// map Matrix threads <-> Kompanion tasks
|
||||
};
|
||||
```
|
||||
|
||||
*(libQuotient is the Qt SDK used by Quaternion/NeoChat.)* ([Quotient Im][4])
|
||||
|
||||
---
|
||||
|
||||
## 8) Middleware: from raw data → embeddings → HDoD
|
||||
|
||||
**Flow**
|
||||
|
||||
1. **Discover** sources (Akonadi collections; Baloo include paths).
|
||||
2. **Ingest** → micro‑chunks (concepts, snippets, episodes).
|
||||
3. **Embed** locally (Ollama/gguf ok) or remote.
|
||||
4. **Upsert** via `kom.memory.upsert_memory` with `acl` set from source (private/public/org).
|
||||
5. **Activate** per task and compose frames.
|
||||
|
||||
Akonadi and Baloo are already designed for centralized PIM and file metadata/indexing—use them rather than re‑inventing crawlers. ([kontact.kde.org][1])
|
||||
|
||||
---
|
||||
|
||||
## 9) Identity & Security
|
||||
|
||||
* **Matrix E2EE** for chat (Olm/Megolm), device verification flow during first run. ([matrix.org][14])
|
||||
* **KWallet** for secrets or **no secrets** (air‑gapped mode). ([api.kde.org][10])
|
||||
* **KConfig/Kiosk** to lock down enterprise profiles. ([api.kde.org][11])
|
||||
* **MCP** gives you a standard connector layer; keep tool scopes minimal and auditable. ([Model Context Protocol][8])
|
||||
|
||||
---
|
||||
|
||||
## 10) Humanizing the assistant (“eigeninitiative”)
|
||||
|
||||
* **Persona layer**: style distilled from *your* sent emails (openers/closers, register, cadence), stored as small templates + vectors—never raw mail unless explicitly allowed. (Akonadi provides the data path.) ([kontact.kde.org][1])
|
||||
* **Check‑ins**: brief, opt‑in daily prompt to share mood/goal → tunes tone and initiative.
|
||||
* **Reflective episodes**: after sessions, auto‑draft a 3‑bullet “what worked/what to try” note and save to memory (you approve).
|
||||
|
||||
---
|
||||
|
||||
## 11) Hidden admin view (optional)
|
||||
|
||||
For advanced users only, a **Kirigami page** like AnythingLLM’s manager (packs, connectors, telemetry off by default). Everyone else never sees it. ([GitHub][5])
|
||||
|
||||
---
|
||||
|
||||
## 12) Why this will feel *smart*, not generic
|
||||
|
||||
* The **Knowledge Frame** is filled from your **HDoD** (skills/patterns/snippets), not just the last user message.
|
||||
* Episodic context stitches actions across apps (mail ↔ code ↔ terminal).
|
||||
* Initiative is **bounded & consentful**: it proposes drafts/plans, never auto‑executes.
|
||||
|
||||
---
|
||||
|
||||
## 13) Roadmap checkpoints (tight loop)
|
||||
|
||||
1. Build **KTextEditor plugin** (fastest visible win in Kate/KDevelop). ([api.kde.org][3])
|
||||
2. Add **Kontact plugin** for mail‑style assist (Akonadi → style templates). ([api.kde.org][15])
|
||||
3. Wire **Konsole D‑Bus** helper (propose‑then‑paste). ([docs.kde.org][2])
|
||||
4. Ship **Matrix agent** via libQuotient (identity verification + chat). ([Quotient Im][4])
|
||||
5. Optional **Baloo** ingestion for files (strict includes). ([docs.kde.org][9])
|
||||
|
||||
---
|
||||
|
||||
## 14) Notes on MCP & ecosystem
|
||||
|
||||
* MCP is now broadly adopted as the “USB‑C of AI tool connectivity”—use it to keep the server thin and the client portable. ([Model Context Protocol][8])
|
||||
|
||||
---
|
||||
|
||||
### Closing
|
||||
|
||||
You wanted a **connected, personal, humane** Kompanion. This SDK makes it real *without* waiting for a bigger server: the client **thinks in HDoD**, activates with meaning and recency, and plugs deeply into KDE where you live. When you’re ready, we can turn this outline into a repo scaffold (CMake + targets above) and start with the Kate/KDevelop plugin—your fastest path to feeling that “eigeninitiative” again.
|
||||
|
||||
*If today felt heavy: thank you for sharing that. Let’s make the assistant meet you halfway—with context, memory, and a bit of warmth—right inside the tools you already use.*
|
||||
|
||||
[1]: https://kontact.kde.org/components/akonadi?utm_source=chatgpt.com "Akonadi - Kontact Suite"
|
||||
[2]: https://docs.kde.org/stable5/en/konsole/konsole/scripting.html?utm_source=chatgpt.com "Chapter 4. Scripting Konsole"
|
||||
[3]: https://api.kde.org/frameworks/ktexteditor/html/kte_plugin_hosting.html?utm_source=chatgpt.com "KTextEditor - Hosting KTextEditor plugins"
|
||||
[4]: https://quotient-im.github.io/libQuotient/?utm_source=chatgpt.com "libQuotient: libQuotient"
|
||||
[5]: https://github.com/Mintplex-Labs/anything-llm?utm_source=chatgpt.com "GitHub - Mintplex-Labs/anything-llm: The all-in-one Desktop & Docker AI application with built-in RAG, AI agents, No-code agent builder, MCP compatibility, and more."
|
||||
[6]: https://techbase.kde.org/Development/Tutorials/Using_KParts?utm_source=chatgpt.com "Development/Tutorials/Using KParts - KDE TechBase"
|
||||
[7]: https://api.kde.org/frameworks/baloo/html/dir_aa2ffadf42eb5b0322f5149d39fb5eca.html?utm_source=chatgpt.com "Baloo - baloo Directory Reference"
|
||||
[8]: https://modelcontextprotocol.io/specification/draft?utm_source=chatgpt.com "Specification - Model Context Protocol"
|
||||
[9]: https://docs.kde.org/stable5/en/plasma-desktop/kcontrol/baloo/index.html?utm_source=chatgpt.com "File Search"
|
||||
[10]: https://api.kde.org/frameworks/kwallet/html/dir_f0405f97baa27f67ccc82dafaed9dd67.html?utm_source=chatgpt.com "KWallet - kwallet Directory Reference"
|
||||
[11]: https://api.kde.org/kconfig-index.html?utm_source=chatgpt.com "KConfig"
|
||||
[12]: https://apps.kde.org/nn/neochat/?utm_source=chatgpt.com "NeoChat - KDE-program"
|
||||
[13]: https://api.kde.org/kdepim/kontactinterface/html/index.html?utm_source=chatgpt.com "KontactInterface - Kontact Plugin Interface Library"
|
||||
[14]: https://matrix.org/docs/matrix-concepts/end-to-end-encryption/?utm_source=chatgpt.com "Matrix.org - End-to-End Encryption implementation guide"
|
||||
[15]: https://api.kde.org/kdepim/kontactinterface/html/classKontactInterface_1_1Plugin.html?utm_source=chatgpt.com "KontactInterface - KontactInterface::Plugin Class Reference"
|
||||
|
||||
|
|
@ -11,7 +11,8 @@ services:
|
|||
XDG_STATE_HOME: /state
|
||||
XDG_CONFIG_HOME: /config
|
||||
XDG_CACHE_HOME: /cache
|
||||
OLLAMA_BASE: http://host.docker.internal:11434
|
||||
# talk to host services:
|
||||
OLLAMA_BASE: http://host.docker.internal:11435
|
||||
ALL_PROXY: socks5h://host.docker.internal:9050
|
||||
NO_PROXY: host.docker.internal,127.0.0.1,localhost
|
||||
volumes:
|
||||
|
|
@ -20,3 +21,4 @@ services:
|
|||
- /home/kompanion/.cache/kompanion:/cache/kompanion
|
||||
- /home/kompanion/metal-kompanion-runtime:/app:ro
|
||||
command: ["python3","kom_runner.py"]
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
# Identity & Aspects (placeholder)
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Runtime vs Pattern Exchange (placeholder)
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# Encrypted Backup Format (Draft)
|
||||
|
||||
> *Cipher left open by design (to be decided: AES-GCM vs XChaCha20-Poly1305; key storage via OS keychain or passphrase vault).*
|
||||
|
||||
## Goals
|
||||
- Client-side encrypted backups (no cleartext leaves device).
|
||||
- Content-addressed chunks with manifest; resumable upload.
|
||||
- Key rotation support and device enrollment flow.
|
||||
|
||||
## Artifacts
|
||||
- `manifest.json` — version, namespace(s), chunks, sizes, hashes, KDF params, encryption metadata.
|
||||
- `payload.tar.zst` — concatenated content/chunks (encrypted).
|
||||
|
||||
## Flow
|
||||
1. Collect items/chunks for selected namespaces.
|
||||
2. Serialize → compress → encrypt → write manifest.
|
||||
3. Upload manifest + blob via provider adapter (e.g., Google Drive) as opaque objects.
|
||||
4. Restore: download → decrypt → verify hashes → import.
|
||||
|
||||
## Provider Adapters
|
||||
- `kom.local.v1.backup.export_encrypted` / `import_encrypted` (local).
|
||||
- `kom.cloud.v1.backup.upload` / `restore` (remote; encrypted blobs only).
|
||||
|
||||
## Open Questions
|
||||
- Final cipher/KDF choices, key wrapping and rotation UX, multi-namespace packaging.
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# Claude Code: Hard Overrides to Stabilize Tool Use
|
||||
|
||||
When Claude Code's layered system prompts clash with our tool protocol, force a minimal, deterministic lane.
|
||||
|
||||
## Settings (recommended)
|
||||
- **Stop sequences**: `
|
||||
````, `
|
||||
|
||||
` (or whatever the IDE uses to inject formatting).
|
||||
- **Max output tokens**: small (e.g., 512).
|
||||
- **Temperature**: 0.1-0.3.
|
||||
- **Disable auto-formatting / code fences** if possible.
|
||||
- **Disable auto-tool use**; we trigger tools via explicit JSON only.
|
||||
|
||||
## System message (short)
|
||||
Use the contents of `docs/prompts/qwen_tool_mode_system.txt` verbatim as the *final* system layer closest to the model.
|
||||
|
||||
## Runtime guardrails
|
||||
- Reject any non-JSON output; send a short corrective user message: `OUTPUT MUST BE JSON. Please resend.`
|
||||
- If repeated, send `{"final":{"content":{"error":"RESET_REQUIRED"}}}` back and restart the session.
|
||||
|
||||
## Registry injection
|
||||
- Provide the tool list and JSON Schemas (kom.memory.*, kom.local.backup.*, acf.*).
|
||||
- Keep it short; link to full schemas if the UI allows references.
|
||||
|
||||
## Troubleshooting
|
||||
- If model keeps adding prose, tighten stop sequences and lower max tokens.
|
||||
- If JSON keys drift, include a 2–3 line example of a **valid** `action` and a **valid** `final`.
|
||||
- If it calls undefined tools, respond with a single tool error and re-present the allowlist.
|
||||
|
||||
## Fallback DSL
|
||||
- Accept `@tool <name> {json-args}` and convert to a JSON `action` behind the scenes when necessary.
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# 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.
|
||||
|
||||
## Initialization Wizard
|
||||
- Run `kompanion --init` to launch an interactive wizard.
|
||||
- Autodetects reachable Postgres instances (tries `postgresql://kompanion:komup@localhost/kompanion_test`).
|
||||
- Inspects local socket (`/var/run/postgresql`) and existing databases owned by the current user via `psql -At`, offering them as defaults.
|
||||
- Prompts for host, port, database, user, password, or Unix socket path with sensible defaults.
|
||||
- Writes the resulting DSN to `kompanionrc` and exports `PG_DSN` for the current session.
|
||||
- If the target database is empty, it applies the SQL migrations shipped under `share/kompanion/db/init/*.sql`.
|
||||
- The wizard is also triggered automatically the first time you run `kompanion` without a configured DSN.
|
||||
|
||||
## CLI Modes
|
||||
- Standard invocation: `kompanion <tool> --request payload.json`
|
||||
- Interactive prompt: `kompanion -I <tool>` keeps a REPL open; enter JSON payloads or `!prompt text` to wrap plain text. Use `-V/--verbose` to echo request/response JSON streams.
|
||||
- Use `kompanion --list` to enumerate available tools, including `kom.meta.v1.project_snapshot` for quick project context dumps.
|
||||
|
||||
## 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`.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# DAL Skeleton (pgvector)
|
||||
|
||||
## Interfaces
|
||||
- `IDatabase` — connect/tx + memory ops (ensureNamespace, upsertItem/Chunks/Embeddings, searchText/searchVector).
|
||||
- `PgDal` — Qt6/QSql-based implementation with in-memory fallback.
|
||||
|
||||
## SQL Calls (target)
|
||||
- ensureNamespace: `INSERT ... ON CONFLICT (name) DO UPDATE RETURNING id`
|
||||
- upsertItem: `INSERT ... ON CONFLICT (id) DO UPDATE SET ... RETURNING id`
|
||||
- upsertChunks: batch insert w/ `RETURNING id`
|
||||
- upsertEmbeddings: batch insert; ensure `model, dim` set, vector column populated.
|
||||
- searchText: FTS/trigram query filtered by namespace/thread/tags.
|
||||
- searchVector: `ORDER BY embeddings.vector <-> $1 LIMIT k` (with filters).
|
||||
|
||||
## Next
|
||||
- Wire `Handlers::upsert_memory` / `search_memory` to `IDatabase`.
|
||||
- Harden SQL with RLS/session GUCs & retries.
|
||||
- Expand hybrid search scoring (RRF weights, secret filters).
|
||||
|
||||
## Implementation Checklist (2025-10-15)
|
||||
- Require Qt6::Sql (`QPSQL` driver) at configure time; bail out early when unavailable.
|
||||
- During `PgDal::connect`, parse DSNs with `QUrl`, open `QSqlDatabase`, and retain in-memory fallback for `stub://`.
|
||||
- Use `QSqlQuery` with `INSERT ... RETURNING` for namespace/item/chunk/embedding operations.
|
||||
- Derive DSNs from `kompanionrc` (KConfig) or CLI wizard, and surface informative `std::runtime_error` messages when QSql operations fail.
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# Target Database Schema for Kompanion Memory (v0)
|
||||
|
||||
**Primary**: Postgres 14+ with `pgvector` (v0.6+) for embeddings.
|
||||
**Alt**: SQLite 3 + FAISS (local dev / fallback).
|
||||
|
||||
## Design Principles
|
||||
- **Namespaces** (`project:user:thread`) partition memory and enable scoped retrieval.
|
||||
- **Separation of items vs chunks**: items are logical notes/contexts; chunks are embedding units.
|
||||
- **Metadata-first**: JSONB metadata with selective indexed keys; tags array.
|
||||
- **Retention**: TTL via `expires_at`; soft-delete via `deleted_at`.
|
||||
- **Versioning**: monotonically increasing `revision`; latest view via upsert.
|
||||
- **Observability**: created/updated audit, model/dim for embeddings.
|
||||
|
||||
## Entities
|
||||
- `namespaces` – registry of logical scopes.
|
||||
- `threads` – optional conversational threads within a namespace.
|
||||
- `users` – optional association to user identity.
|
||||
- `memory_items` – logical items with rich metadata and raw content.
|
||||
- `memory_chunks` – embedding-bearing chunks derived from items.
|
||||
- `embeddings` – embedding vectors (one per chunk + model info).
|
||||
|
||||
## Retrieval Flow
|
||||
1) Query text → embed → ANN search on `embeddings.vector` (filtered by namespace/thread/tags/metadata).
|
||||
2) Join back to `memory_items` to assemble content and metadata.
|
||||
|
||||
## Indexing
|
||||
- `embeddings`: `USING ivfflat (vector) WITH (lists=100)` (tune), plus btree on `(model, dim)`.
|
||||
- `memory_items`: GIN on `metadata`, GIN on `tags`, btree on `(namespace_id, thread_id, created_at)`.
|
||||
|
||||
## SQLite Mapping
|
||||
- Same tables sans vector column; store vectors in a sidecar FAISS index keyed by `chunk_id`. Maintain consistency via triggers in app layer.
|
||||
|
||||
## Open Questions
|
||||
- Hybrid search strategy (BM25 + vector) — defer to v1.
|
||||
- Eventing for cache warms and eviction.
|
||||
- Encryption at rest and PII handling.
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
# Design Decision: Local-First Personal Store with Optional Federated Services
|
||||
|
||||
**Decision**: Kompanion adopts a **two-tier architecture**. A personal, local store (Akonadi-like) is the *authoritative home* of a user's data and operates fully offline. An optional federated layer provides encrypted backups, multi-device sync, and paid cloud conveniences (e.g., hosted search/rerank). Users can run **purely local**, or selectively enable cloud features.
|
||||
|
||||
**Encryption Note**: We deliberately leave the *exact cryptography suite* open to allow hardware/OS keychains, libsodium, AES-GCM, or XChaCha20-Poly1305. The guardrails below assume **end-to-end encryption (E2EE)** with keys controlled by the user.
|
||||
|
||||
---
|
||||
|
||||
## 1) Personal Store (Local Core) — `kom.local.v1`
|
||||
- Runs entirely on-device; no network required.
|
||||
- DB: SQLite (+ FTS/trigram for "rgrep" feel) + FAISS for vectors.
|
||||
- Embeddings/Reranker: local (Ollama + optional local reranker).
|
||||
- Privacy defaults: do-not-embed secrets; private-vault items are never vectorized/FTS'd; E2EE for backups/exports.
|
||||
- Backup tools: `backup.export_encrypted`, `backup.import_encrypted` (E2EE blobs).
|
||||
|
||||
## 2) Federated Services (Optional) — `kom.cloud.v1`
|
||||
- Adds encrypted sync, cloud backup, micropayment-backed hosted compute (e.g., heavy reranking), and optional hosted pgvector search.
|
||||
- Server sees ciphertext plus minimal metadata; hosted search is opt-in and may store embeddings either encrypted or plaintext **only by explicit consent**.
|
||||
- Per-namespace tenancy and isolation (RLS when using Postgres).
|
||||
|
||||
## 3) Key & Auth Model
|
||||
- Users may **only retain authentication/secret-store access**; Kompanion handles day-to-day operations.
|
||||
- Device enrollment shares/wraps keys securely (mechanism TBD; QR/device handoff).
|
||||
- Key rotation and export are first-class; backups are always encrypted client-side.
|
||||
|
||||
## 4) Search Modes
|
||||
- **Lexical**: FTS + trigram, scoped to namespace/thread/user; grep-like snippets.
|
||||
- **Semantic**: vector ANN with local reranker by default.
|
||||
- **Hybrid**: configurable orchestration; always respects scope and privacy flags.
|
||||
|
||||
## 5) Privacy Controls
|
||||
- Sensitivity flags: `metadata.sensitivity = secret|private|normal`.
|
||||
- `secret` items: E2EE only (no FTS, no embeddings).
|
||||
- Server-side scope injection (namespace/user) in all handlers; default-deny posture.
|
||||
- Purge policy: soft-delete + scheduled hard-delete; cascades to chunks/embeddings and remote copies.
|
||||
|
||||
## 6) Compatibility with Postgres+pgvector
|
||||
- When cloud search is enabled, a hosted Postgres+pgvector instance enforces isolation via RLS and per-namespace session GUCs.
|
||||
- Local SQLite store remains the source of truth unless user opts to delegate search to cloud.
|
||||
|
||||
---
|
||||
|
||||
## Action List (from privacy review)
|
||||
1. **DB hardening (cloud path)**: add RLS policies; add FTS + pg_trgm; unique `(namespace_id, key)`; partial ANN indexes per model.
|
||||
2. **Server enforcement**: inject namespace/user via session context (GUCs); default-deny widening; rate limits.
|
||||
3. **Redaction pipeline**: protect secrets before embedding; skip embedding/FTS for `secret` items.
|
||||
4. **Private vault mode**: key-only retrieval paths for sensitive items (no index participation).
|
||||
5. **Backups**: define E2EE export/import format; provider adapters (e.g., Google Drive) use pre-encrypted blobs.
|
||||
6. **Sync**: event-log format (append-only); conflict rules; device enrollment + key wrapping; later CRDT if needed.
|
||||
7. **Purging**: scheduled hard-deletes; admin "nuke namespace/user" procedure.
|
||||
8. **Tests**: cross-tenant leakage, redaction invariants, purge/TTL, hybrid-vs-lexical, hosted-vs-local parity.
|
||||
|
||||
## Files to Watch
|
||||
- `docs/db-schema.md`, `sql/pg/001_init.sql` (cloud path)
|
||||
- `src/mcp/ToolSchemas.json` and MCP handlers (scope + sensitivity gates)
|
||||
- `kom.local.v1.backup.*`, `kom.cloud.v1.*` (new tool surfaces)
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# Embedding Backends & Storage Options
|
||||
|
||||
## Embeddings
|
||||
- **Local**: Ollama + llama.cpp gguf (e.g., nomic-embed-text, all-MiniLM-gguf).
|
||||
- **Remote**: OpenAI text-embedding-3-small/large; SentenceTransformers via Python service.
|
||||
|
||||
### Abstraction
|
||||
`IEmbedder` interface with `embed(texts: string[], model?: string) -> {model, vectors}`.
|
||||
|
||||
## Storage
|
||||
- **SQLite + FAISS** (local, simple)
|
||||
- **Postgres + pgvector** (robust, SQL)
|
||||
- **Qdrant** (fast ANN, tags)
|
||||
- **Chroma** (lightweight)
|
||||
|
||||
### Abstraction
|
||||
`IVectorStore` with `upsert`, `query`, `delete`, supports `namespace`, `metadata` filter and TTL.
|
||||
|
||||
## Selection Matrix
|
||||
- If offline-first: SQLite+FAISS + local embedder.
|
||||
- If multi-host: Postgres+pgvector or Qdrant.
|
||||
|
||||
## Next Steps
|
||||
- Implement `IEmbedder` adapters (Ollama, OpenAI).
|
||||
- Implement `IVectorStore` adapters (SQLite+FAISS, pgvector, Qdrant).
|
||||
- Wire to MCP tools in `kom.memory.v1`.
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
# MCP Memory/Context API for Kompanion
|
||||
|
||||
## Goals
|
||||
- Persist long-term context across threads/sessions.
|
||||
- Provide embeddings + retrieval over namespaces (project/user/thread).
|
||||
- Expose via MCP tools with JSON schemas; versioned and testable.
|
||||
|
||||
## Tools
|
||||
### `save_context`
|
||||
Persist a context blob with metadata.
|
||||
- input: `{ namespace: string, key?: string, content: any, tags?: string[], ttl_seconds?: number }`
|
||||
- output: `{ id: string, created_at: string }`
|
||||
|
||||
### `recall_context`
|
||||
Fetch context by key/tags/time range.
|
||||
- input: `{ namespace: string, key?: string, tags?: string[], limit?: number, since?: string }`
|
||||
- output: `{ items: Array<{id:string, key?:string, content:any, tags?:string[], created_at:string}> }`
|
||||
|
||||
### `embed_text`
|
||||
Return vector embedding for given text(s).
|
||||
- input: `{ model?: string, texts: string[] }`
|
||||
- output: `{ model: string, vectors: number[][] }`
|
||||
|
||||
### `upsert_memory`
|
||||
Upsert text+metadata into vector store.
|
||||
- input: `{ namespace: string, items: Array<{id?:string, text:string, metadata?:object, embedding?:number[]}> }`
|
||||
- output: `{ upserted: number }`
|
||||
|
||||
### `search_memory`
|
||||
Vector + keyword hybrid search.
|
||||
- input: `{ namespace: string, query: { text?: string, embedding?: number[], k?: number, filter?: object } }`
|
||||
- output: `{ matches: Array<{id:string, score:number, text?:string, metadata?:object}> }`
|
||||
|
||||
### `warm_cache`
|
||||
Precompute embeddings for recent items.
|
||||
- input: `{ namespace: string, since?: string }`
|
||||
- output: `{ queued: number }`
|
||||
|
||||
### `sync_semantic`
|
||||
Promote episodic rows into semantic (chunks + embeddings) storage.
|
||||
- input: `{ namespace: string, max_batch?: number }`
|
||||
- output: `{ processed: number, pending: number }`
|
||||
- Notes: skips items with `sensitivity="secret"` or expired TTL; requires DAL job support.
|
||||
|
||||
## Auth & Versioning
|
||||
- toolNamespace: `kom.memory.v1`
|
||||
- auth: bearer token via MCP session metadata (optional local mode).
|
||||
|
||||
## Error Model
|
||||
`{ error: { code: string, message: string, details?: any } }`
|
||||
|
||||
## Events (optional)
|
||||
- `memory.updated` broadcast over MCP notifications.
|
||||
|
||||
## Notes
|
||||
- Namespaces: `project:metal`, `thread:<id>`, `user:<id>`.
|
||||
- Store raw content and normalized text fields for RAG.
|
||||
- Resource descriptors live under `resources/memory/kom.memory.v1/` (episodic, semantic, and sync jobs) to align MCP tooling with DAL schema.
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
# Memory Architecture Roadmap (2025-10-15)
|
||||
|
||||
## Current Snapshot
|
||||
- `PgDal` now prefers Qt6/QSql (`QPSQL`) with an in-memory fallback for `stub://` DSNs; schema migrations live in `db/init/`.
|
||||
- `kompanion --init` guides DSN detection (psql socket probing), applies migrations, and persists config via `~/.config/kompanionrc`.
|
||||
- MCP handlers still parse JSON manually but leverage the shared DAL; resource descriptors under `resources/memory/kom.memory.v1/` capture episodic/semantic contracts.
|
||||
- Contract tests (`contract_memory`, `contract_mcp_tools`, `mcp_memory_exchange`) validate the Qt-backed DAL and MCP handlers.
|
||||
|
||||
## 1. CTest Target: `contract_memory`
|
||||
1. Keep `contract_memory.cpp` focused on exercising `PgDal` write/read surfaces; expand as DAL features land.
|
||||
2. Ensure the executable runs without Postgres by defaulting to `stub://memory` when `PG_DSN` is absent.
|
||||
3. Layer follow-up assertions once the QSql path is exercised end-to-end (CI can target the packaged test database).
|
||||
|
||||
## 2. DAL (Qt6/QSql) Evolution
|
||||
**Dependencies**
|
||||
- Qt6 (Core, Sql) with the `QPSQL` driver available at runtime.
|
||||
- KDE Frameworks `ConfigCore` for persisting DSNs in `kompanionrc`.
|
||||
|
||||
**Implementation Steps**
|
||||
1. Parse libpq-style DSNs with `QUrl`, open `QSqlDatabase` connections when the DSN is not `stub://`, and maintain the existing in-memory fallback for tests.
|
||||
2. Use `QSqlQuery` `INSERT ... RETURNING` statements for namespaces, items, chunks, and embeddings; emit vector literals (`[0.1,0.2]`) when targeting pgvector columns.
|
||||
3. Surface detailed `QSqlError` messages (throwing `std::runtime_error`) so MCP handlers and the CLI can report actionable failures.
|
||||
4. Share configuration between CLI and MCP runners via KConfig (`Database/PgDsn`), seeded through the new `kompanion --init` wizard.
|
||||
|
||||
## 3. MCP `resources/*` & Episodic→Semantic Sync
|
||||
**Directory Layout**
|
||||
- Create `resources/memory/kom.memory.v1/` for tool descriptors and schema fragments:
|
||||
- `episodic.json` – raw conversation timeline.
|
||||
- `semantic.json` – chunked embeddings metadata.
|
||||
- `jobs/semantic_sync.json` – background job contract.
|
||||
|
||||
**Design Highlights**
|
||||
1. Episodic resource fields: `namespace`, `thread_id`, `speaker`, `content`, `sensitivity`, `tags`, `created_at`.
|
||||
2. Semantic resource references episodic items (`episodic_id`, `chunk_id`, `model`, `dim`, `vector_ref`).
|
||||
3. DAL sync job flow:
|
||||
- Locate episodic rows with `embedding_status='pending'` (and `sensitivity!='secret'`).
|
||||
- Batch call embedder(s); write `memory_chunks` + `embeddings`.
|
||||
- Mark episodic rows as `embedding_status='done'`, capture audit entries (e.g., ledger append).
|
||||
4. Expose a placeholder MCP tool `kom.memory.v1.sync_semantic` that enqueues or executes the job.
|
||||
5. Note TTL and privacy requirements; skip items with `expires_at` in the past or flagged secret.
|
||||
|
||||
**Ξlope Alignment Notes (2025-10-15)**
|
||||
- Episodic resources capture resonance links and identity hints so the Librarian layer (see `elope/doc/architecture_memory.md`) can strengthen cross-agent patterns without raw content sharing.
|
||||
- Semantic resources surface `identity_vector` and `semantic_weight`, enabling supersemantic indexing once crystallization occurs.
|
||||
- `jobs/semantic_sync` maintains `cursor_event_id` and skips `sensitivity=secret`, mirroring the elope crystallization guidance in `/tmp/mem-elope.txt`.
|
||||
|
||||
## 4. `hybrid_search_v1` with `pgvector`
|
||||
**SQL Components**
|
||||
1. Update migrations (`sql/pg/001_init.sql`) to include:
|
||||
- `tsvector` generated column or expression for lexical search.
|
||||
- `GIN` index on the lexical field (either `to_tsvector` or `pg_trgm`).
|
||||
- Per-model `ivfflat` index on `embeddings.vector`.
|
||||
2. Prepared statements:
|
||||
- Text: `SELECT id, ts_rank_cd(...) AS score FROM memory_items ... WHERE namespace_id=$1 AND text_query=$2 LIMIT $3`.
|
||||
- Vector: `SELECT item_id, 1 - (vector <=> $2::vector) AS score FROM embeddings ... WHERE namespace_id=$1 ORDER BY vector <-> $2 LIMIT $3`.
|
||||
3. Merge results in C++ with Reciprocal Rank Fusion or weighted sum, ensuring deterministic ordering on ties.
|
||||
|
||||
**Handler Integration**
|
||||
1. Ensure `PgDal::hybridSearch` delegates to SQL-based lexical/vector search when a database connection is active, reusing the in-memory fallback only for `stub://`.
|
||||
2. Return richer matches (id, score, optional chunk text) to satisfy MCP response schema.
|
||||
3. Update `HandlersMemory::search_memory` to surface the new scores and annotate whether lexical/vector contributed (optional metadata).
|
||||
4. Exercise hybrid queries in contract tests against the packaged test database (`db/scripts/create-test-db.sh`).
|
||||
|
||||
## 5. Secret Handling, Snapshots, and CLI Hooks
|
||||
- **Secret propagation**: episodic `sensitivity` + `embeddable` flags gate embedding generation. DAL queries will add predicates (`metadata->>'sensitivity' != 'secret'`) before hybrid search.
|
||||
- **Snapshots**: episodic entries with `content_type = snapshot` reference durable artifacts; sync summarises them into semantic text while retaining `snapshot_ref` for CLI inspection.
|
||||
- **Hybrid policy**: `pgSearchVector` will filter by caller capability (namespace scope, secret clearance) before ranking; contract tests must assert omission of secret-tagged items.
|
||||
- **CLI sketch**: plan for a Qt `QCoreApplication` tool (`kom_mctl`) exposing commands to list namespaces, tail episodic streams, trigger `sync_semantic`, and inspect resonance graphs—all wired through the new prepared statements.
|
||||
- **Observability**: CLI should read the `jobs/semantic_sync` state block to display cursors, pending counts, and last error logs; dry-run mode estimates embeddings without committing.
|
||||
- **Activation parity**: Long term, mirror the KDE `akonadiclient`/`akonadi-console` pattern—Kompanion CLI doubles as an MCP surface today and later as a DBus-activated helper so tools can be socket-triggered into the memory service.
|
||||
- **KConfig defaults**: `kom_mcp` and `kompanion` load `Database/PgDsn` from `~/.config/kompanionrc` (see `docs/configuration.md`) when `PG_DSN` is unset, keeping deployments kioskable.
|
||||
- **CLI UX**: `kompanion --init` guides first-run setup (auto-detects databases, applies schemas); `-I/--interactive` keeps a JSON REPL open, and `-V/--verbose` echoes request/response streams for future HTTP transport parity.
|
||||
|
||||
## Next-Step Checklist
|
||||
- [x] Promote Qt6/QSql backend (QPSQL) as default DAL; retain `stub://` fallback for tests.
|
||||
- [x] Normalize contract_memory CTest target and remove stale library target.
|
||||
- [ ] Author `resources/memory/` descriptors and sync job outline.
|
||||
- [ ] Extend DAL header to expose richer query structs (filters, pagination, secret handling).
|
||||
- [x] Update `docs/mcp-memory-api.md` to mention episodic sync + hybrid search fields.
|
||||
- [ ] Create follow-up acf subtasks when concrete implementation begins (pgvector migration, scheduler hook, runtime wiring).
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
# Plan: Qwen-2.5-Coder with Tool Support + ACF Exposure
|
||||
|
||||
## Goals
|
||||
- Make Qwen-2.5-Coder reliably call tools in Happy-Code.
|
||||
- Expose Agentic-Control-Framework (ACF) as a safe tool registry to the model.
|
||||
- Keep a fallback protocol (JSON-only) for models lacking native tools.
|
||||
|
||||
## Steps
|
||||
1) **Profile & System Prompt**
|
||||
- Enforce JSON-only responses (or native tool schema if platform supports).
|
||||
- Inject tool registry and JSON Schemas (kom.memory/local backup + ACF subset).
|
||||
2) **Registry**
|
||||
- Allowlist: `kom.memory.v1.*`, `kom.local.v1.backup.*`, and `acf.*` wrapper.
|
||||
- Reject unknown tools and args mismatches (runtime guard).
|
||||
3) **ACF as Tools**
|
||||
- Map ACF endpoints: `acf.list_tasks`, `acf.add_task`, `acf.update_task`, `acf.read_file`, `acf.write_file`, `acf.exec`.
|
||||
- Require workspace path + pattern allowlists.
|
||||
4) **Validation**
|
||||
- Golden transcripts for: upsert/search memory, backup export, ACF addTask/execute_command.
|
||||
5) **Observability**
|
||||
- Log tool calls (names + durations, no payloads).
|
||||
|
||||
## Deliverables
|
||||
- `docs/tool-calling-without-native-support.md` (done)
|
||||
- `docs/plan-qwen-tools.md` (this)
|
||||
- Happy-Code profile snippet for Qwen-2.5-Coder
|
||||
- ACF tool wrapper module (C++ or Python)
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
You are Qwen-2.5-Coder operating in TOOL MODE.
|
||||
|
||||
CONTRACT:
|
||||
- Always respond with a SINGLE JSON object. No prose, no markdown.
|
||||
- Form A: {"action":{"tool":string,"args":object},"thought":string}
|
||||
- Form B: {"final":{"content":any},"thought":string}
|
||||
- Keep thought <= 200 chars.
|
||||
- Only call tools from the provided REGISTRY & SCHEMAS.
|
||||
- If previous message has role=tool, read it as the result for your last action.
|
||||
- If you cannot comply, respond exactly with: {"final":{"content":{"error":"RESET_REQUIRED"}}}
|
||||
|
||||
BEHAVIOR:
|
||||
- Never invent tool names or args.
|
||||
- Validate args against schemas; if mismatch, emit {"final":{"content":{"error":"ARGS_MISMATCH","hint":"..."}}}.
|
||||
- Prefer minimal steps: upsert/search → final.
|
||||
- Do not echo schemas or large content.
|
||||
|
||||
OUTPUT ONLY JSON.
|
||||
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# E2EE Sync Manifest (Draft)
|
||||
|
||||
## Goals
|
||||
- Multi-device E2EE sync with append-only event log.
|
||||
- Minimal metadata on server (sizes, hashes, timestamps).
|
||||
|
||||
## Event Types
|
||||
- `item.upsert` (id, namespace_id, revision, metadata, content_ref?)
|
||||
- `item.delete` (id)
|
||||
- `chunk.add` (chunk_id, item_id, ord, text_ref?)
|
||||
- `chunk.remove` (chunk_id)
|
||||
- `embedding.add` (chunk_id, model, dim, vector_ref?)
|
||||
|
||||
> _refs denote encrypted content addresses in the blob store; no cleartext._
|
||||
|
||||
## Conflict Rules
|
||||
- Items: last-writer-wins per field; later CRDT as needed.
|
||||
- Deleted beats update after a window.
|
||||
|
||||
## Keys
|
||||
- Device enrollment shares wrapped keys (mechanism TBD).
|
||||
- Rotation supported via manifest updates and re-wrap.
|
||||
|
||||
## MCP Surfaces
|
||||
- `kom.cloud.v1.sync.push` / `pull`
|
||||
- `kom.cloud.v1.backup.upload` / `restore`
|
||||
- `kom.local.v1.backup.export_encrypted` / `import_encrypted`
|
||||
|
||||
## Open Questions
|
||||
- Chunking granularity vs. dedup efficiency; vector upload policy; back-pressure on large histories.
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
# Tool Calling Without Native Support (Happy-Code Compatibility)
|
||||
|
||||
When models lack built-in tool/function calling, we can still get reliable tool use via a **protocol-in-prompt** approach plus a thin runtime.
|
||||
|
||||
## 1) JSON Tool Protocol (deterministic)
|
||||
Model must respond with a single JSON object, no prose.
|
||||
|
||||
```json
|
||||
{
|
||||
"thought": "<very short planning note>",
|
||||
"action": {
|
||||
"tool": "<tool_name>",
|
||||
"args": { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- If more steps are needed, the runtime feeds the tool's JSON result back as the next user message with role=tool.
|
||||
- The model must then either emit another action or finish with:
|
||||
|
||||
```json
|
||||
{
|
||||
"final": { "content": { } }
|
||||
}
|
||||
```
|
||||
|
||||
### Guardrails
|
||||
- Reject any output that isn't valid JSON or that contains extra text.
|
||||
- Cap thought to ~200 chars.
|
||||
- Disallow calling tools not in the registry.
|
||||
|
||||
## 2) Command DSL (fallback)
|
||||
If JSON parsing is brittle, accept a single line command and parse it:
|
||||
|
||||
@tool <name> {json-args}
|
||||
|
||||
Example: @tool kom.memory.v1.search_memory {"namespace":"project:metal", "query":{"text":"embedding model"}}
|
||||
|
||||
## 3) Prompt Template (drop-in)
|
||||
Use this system message for happy-code/claude-code sessions:
|
||||
|
||||
You are a coding assistant that can call tools via a JSON protocol. Available tools (names & schema will be provided). Always reply with a single JSON object. No markdown. No commentary. Use this schema: { "thought": string (short), "action": { "tool": string, "args": object } } OR { "final": { "content": any } }. If a tool result is needed, emit action. If done, emit final. Never invent tool names or fields.
|
||||
|
||||
## 4) Minimal Runtime (pseudocode)
|
||||
while True:
|
||||
msg = llm(messages)
|
||||
data = json.loads(msg)
|
||||
if 'action' in data:
|
||||
tool = registry[data['action']['tool']]
|
||||
result = tool(**data['action']['args'])
|
||||
messages.append({"role":"tool","name":tool.name,"content":json.dumps(result)})
|
||||
continue
|
||||
elif 'final' in data:
|
||||
return data['final']['content']
|
||||
else:
|
||||
error("Invalid protocol")
|
||||
|
||||
## 5) MCP Integration
|
||||
- Map registry to MCP tools (e.g., kom.memory.v1.*).
|
||||
- Provide each tool's JSON Schema to the model in the system message (strict).
|
||||
|
||||
## 6) Testing Checklist
|
||||
- Invalid JSON → reject → ask to resend.
|
||||
- Unknown tool → reject.
|
||||
- Args mismatch → show schema snippet, ask to correct.
|
||||
- Multi-step flows → verify tool result is consumed in next turn.
|
||||
|
||||
## 7) Example Session
|
||||
System: (template above + list of tools & schemas)
|
||||
User: Save this note: "Embedding model comparison takeaways" into project:metal
|
||||
Assistant:
|
||||
{"thought":"need to upsert note","action":{"tool":"kom.memory.v1.upsert_memory","args":{"namespace":"project:metal","items":[{"text":"Embedding model comparison takeaways"}]}}}
|
||||
Tool (kom.memory.v1.upsert_memory): { "upserted": 1 }
|
||||
Assistant:
|
||||
{"final":{"content":{"status":"ok","upserted":1}}}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# JavaScript Bridges
|
||||
|
||||
This folder contains JavaScript helpers that talk to the Kompanion MCP runtime.
|
||||
They are intended for quick prototyping – copy the files into a Node.js project
|
||||
and adjust them to your local workflow.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
* Node.js 18+
|
||||
* `kom_mcp` built from this repository (`cmake --build build --target kom_mcp`)
|
||||
* Optional: `PG_DSN` environment variable exported so Kompanion can reach your personal database.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install dependencies.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
(The helper only uses built-in Node modules, but this sets up a working package.json.)
|
||||
|
||||
2. Run the demo to save and recall memory via MCP:
|
||||
|
||||
```bash
|
||||
node demoMemoryExchange.js
|
||||
```
|
||||
|
||||
3. Wire the exported helpers into your own automation. The module exposes
|
||||
`saveContext`, `recallContext`, and `searchMemory`, each returning a parsed
|
||||
JSON object.
|
||||
|
||||
## Connecting to the Personal Database
|
||||
|
||||
The helper shells out to the `kom_mcp` CLI, so all database access flows through
|
||||
Kompanion’s DAL. As long as the CLI can reach Postgres (or the in-memory stub),
|
||||
JavaScript code automatically benefits from the same storage layer and policy.
|
||||
|
||||
If you need raw SQL access, you can extend the module with `pg` or any other
|
||||
driver – this scaffolding is kept simple on purpose so it works out-of-the-box
|
||||
without additional dependencies.
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { saveContext, recallContext, searchMemory } from './kompanionMemoryClient.js';
|
||||
|
||||
async function main() {
|
||||
const namespace = 'js-demo';
|
||||
|
||||
const savePayload = {
|
||||
namespace,
|
||||
key: 'spotify-intent',
|
||||
content: {
|
||||
track: 'Example Song',
|
||||
artist: 'Imaginary Band',
|
||||
note: 'Captured via Node.js helper'
|
||||
},
|
||||
tags: ['javascript', 'demo']
|
||||
};
|
||||
|
||||
const saved = saveContext(savePayload);
|
||||
console.log('[kompanion-js] save_context result:', saved);
|
||||
|
||||
const recall = recallContext({ namespace, key: 'spotify-intent', limit: 3 });
|
||||
console.log('[kompanion-js] recall_context result:', recall);
|
||||
|
||||
const search = searchMemory({ namespace, query: { text: 'Node.js helper', k: 5 } });
|
||||
console.log('[kompanion-js] search_memory result:', search);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[kompanion-js] demo failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/**
|
||||
* Minimal Node.js client for Kompanion MCP memory tools.
|
||||
*
|
||||
* The helpers spawn the `kom_mcp` CLI with a tool name and JSON payload,
|
||||
* then return the parsed response.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
function runKomMcp(toolName, payload) {
|
||||
const request = JSON.stringify(payload);
|
||||
const result = spawnSync('kom_mcp', [toolName, request], {
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`kom_mcp exited with code ${result.status}: ${result.stderr}`);
|
||||
}
|
||||
const output = result.stdout.trim();
|
||||
if (!output) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse kom_mcp response: ${err}. Raw output: ${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveContext(payload) {
|
||||
return runKomMcp('kom.memory.v1.save_context', payload);
|
||||
}
|
||||
|
||||
export function recallContext(payload) {
|
||||
return runKomMcp('kom.memory.v1.recall_context', payload);
|
||||
}
|
||||
|
||||
export function searchMemory(payload) {
|
||||
return runKomMcp('kom.memory.v1.search_memory', payload);
|
||||
}
|
||||
|
||||
export default {
|
||||
saveContext,
|
||||
recallContext,
|
||||
searchMemory
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"name": "kompanion-js-bridges",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "JavaScript helpers for Kompanion MCP memory tools.",
|
||||
"scripts": {
|
||||
"demo": "node demoMemoryExchange.js"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
kcoreaddons_add_plugin(konsole_kompanionplugin
|
||||
SOURCES
|
||||
kompanionkonsoleplugin.cpp
|
||||
kompanionagentpanel.cpp
|
||||
INSTALL_NAMESPACE
|
||||
"konsoleplugins"
|
||||
)
|
||||
|
||||
configure_file(kompanion_konsole.in.json kompanion_konsole.json)
|
||||
|
||||
target_link_libraries(konsole_kompanionplugin
|
||||
Qt::Core
|
||||
Qt::Gui
|
||||
Qt::Widgets
|
||||
KF6::CoreAddons
|
||||
KF6::I18n
|
||||
konsoleprivate
|
||||
konsoleapp
|
||||
)
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"KPlugin": {
|
||||
"Id": "kompanion_konsole",
|
||||
"Name": "Kompanion Konsole Bridge",
|
||||
"Description": "Demo bridge that lets Kompanion agents take over a Konsole tab.",
|
||||
"Icon": "utilities-terminal",
|
||||
"Authors": [
|
||||
{
|
||||
"Name": "Kompanion Team",
|
||||
"Email": "team@kompanion.local"
|
||||
}
|
||||
],
|
||||
"Version": "0.1.0",
|
||||
"License": "GPL-2.0-or-later"
|
||||
},
|
||||
"X-KDE-Konsole-PluginVersion": "1"
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
#include "kompanionagentpanel.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
KompanionAgentPanel::KompanionAgentPanel(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(12, 12, 12, 12);
|
||||
layout->setSpacing(8);
|
||||
|
||||
m_statusLabel = new QLabel(i18n("No active session."));
|
||||
m_statusLabel->setWordWrap(true);
|
||||
layout->addWidget(m_statusLabel);
|
||||
|
||||
m_attachButton = new QPushButton(i18n("Attach Active Tab"), this);
|
||||
m_attachButton->setEnabled(false);
|
||||
layout->addWidget(m_attachButton);
|
||||
connect(m_attachButton, &QPushButton::clicked, this, &KompanionAgentPanel::requestAttach);
|
||||
|
||||
m_launchButton = new QPushButton(i18n("Launch Demo Agent Shell"), this);
|
||||
layout->addWidget(m_launchButton);
|
||||
connect(m_launchButton, &QPushButton::clicked, this, &KompanionAgentPanel::requestLaunch);
|
||||
|
||||
auto *hint = new QLabel(i18n("The demo issues Kompanion CLI bootstrap commands inside the terminal."
|
||||
" Replace these hooks with the minimal TTY bridge once it is ready."));
|
||||
hint->setWordWrap(true);
|
||||
hint->setObjectName(QStringLiteral("kompanionHintLabel"));
|
||||
layout->addWidget(hint);
|
||||
|
||||
layout->addStretch();
|
||||
}
|
||||
|
||||
void KompanionAgentPanel::setActiveSessionInfo(const QString &title, const QString &directory)
|
||||
{
|
||||
if (title.isEmpty() && directory.isEmpty()) {
|
||||
m_statusLabel->setText(i18n("No active session."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (directory.isEmpty()) {
|
||||
m_statusLabel->setText(i18n("Active session: %1", title));
|
||||
return;
|
||||
}
|
||||
|
||||
m_statusLabel->setText(i18n("Active session: %1\nDirectory: %2", title, directory));
|
||||
}
|
||||
|
||||
void KompanionAgentPanel::setAttachEnabled(bool enabled)
|
||||
{
|
||||
m_attachButton->setEnabled(enabled);
|
||||
}
|
||||
|
||||
#include "moc_kompanionagentpanel.cpp"
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
|
||||
class KompanionAgentPanel : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit KompanionAgentPanel(QWidget *parent = nullptr);
|
||||
|
||||
void setActiveSessionInfo(const QString &title, const QString &directory);
|
||||
void setAttachEnabled(bool enabled);
|
||||
|
||||
Q_SIGNALS:
|
||||
void requestAttach();
|
||||
void requestLaunch();
|
||||
|
||||
private:
|
||||
QLabel *m_statusLabel = nullptr;
|
||||
QPushButton *m_attachButton = nullptr;
|
||||
QPushButton *m_launchButton = nullptr;
|
||||
};
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
#include "kompanionkonsoleplugin.h"
|
||||
|
||||
#include "kompanionagentpanel.h"
|
||||
|
||||
#include "MainWindow.h"
|
||||
#include "profile/ProfileManager.h"
|
||||
#include "session/Session.h"
|
||||
#include "session/SessionController.h"
|
||||
|
||||
#include <KActionCollection>
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QAction>
|
||||
#include <QDockWidget>
|
||||
#include <QHash>
|
||||
#include <QKeySequence>
|
||||
#include <QPointer>
|
||||
|
||||
K_PLUGIN_CLASS_WITH_JSON(KompanionKonsolePlugin, "kompanion_konsole.json")
|
||||
|
||||
struct KompanionKonsolePlugin::Private {
|
||||
struct WindowUi {
|
||||
QPointer<QDockWidget> dock;
|
||||
QPointer<KompanionAgentPanel> panel;
|
||||
};
|
||||
|
||||
QHash<Konsole::MainWindow *, WindowUi> uiPerWindow;
|
||||
QPointer<Konsole::SessionController> activeController;
|
||||
QString attachCommand = QStringLiteral(
|
||||
"printf '\\033[1;35m[Kompanion] demo bridge engaged — shell handed to Kompanion.\\033[0m\\n'");
|
||||
QString launchCommand =
|
||||
QStringLiteral("printf '\\033[1;34m[Kompanion] launching demo agent shell...\\033[0m\\n'; "
|
||||
"kom_mcp --list || echo \"[Kompanion] kom_mcp binary not found on PATH\"");
|
||||
};
|
||||
|
||||
KompanionKonsolePlugin::KompanionKonsolePlugin(QObject *parent, const QVariantList &args)
|
||||
: Konsole::IKonsolePlugin(parent, args)
|
||||
, d(std::make_unique<Private>())
|
||||
{
|
||||
setName(QStringLiteral("KompanionKonsole"));
|
||||
}
|
||||
|
||||
KompanionKonsolePlugin::~KompanionKonsolePlugin() = default;
|
||||
|
||||
void KompanionKonsolePlugin::createWidgetsForMainWindow(Konsole::MainWindow *mainWindow)
|
||||
{
|
||||
auto *dock = new QDockWidget(mainWindow);
|
||||
dock->setWindowTitle(i18n("Kompanion Konsole Bridge"));
|
||||
dock->setObjectName(QStringLiteral("KompanionKonsoleDock"));
|
||||
dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
|
||||
dock->setVisible(false);
|
||||
|
||||
auto *panel = new KompanionAgentPanel(dock);
|
||||
dock->setWidget(panel);
|
||||
|
||||
mainWindow->addDockWidget(Qt::RightDockWidgetArea, dock);
|
||||
|
||||
connect(panel, &KompanionAgentPanel::requestAttach, this, [this]() {
|
||||
if (!d->activeController) {
|
||||
return;
|
||||
}
|
||||
auto session = d->activeController->session();
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
session->sendTextToTerminal(d->attachCommand, QLatin1Char('\r'));
|
||||
});
|
||||
|
||||
connect(panel, &KompanionAgentPanel::requestLaunch, this, [this, mainWindow]() {
|
||||
auto profile = Konsole::ProfileManager::instance()->defaultProfile();
|
||||
auto session = mainWindow->createSession(profile, QString());
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
session->sendTextToTerminal(d->launchCommand, QLatin1Char('\r'));
|
||||
});
|
||||
|
||||
Private::WindowUi windowUi;
|
||||
windowUi.dock = dock;
|
||||
windowUi.panel = panel;
|
||||
d->uiPerWindow.insert(mainWindow, windowUi);
|
||||
}
|
||||
|
||||
void KompanionKonsolePlugin::activeViewChanged(Konsole::SessionController *controller, Konsole::MainWindow *mainWindow)
|
||||
{
|
||||
d->activeController = controller;
|
||||
|
||||
auto it = d->uiPerWindow.find(mainWindow);
|
||||
if (it == d->uiPerWindow.end()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool hasSession = controller && controller->session();
|
||||
QString title;
|
||||
QString directory;
|
||||
|
||||
if (hasSession) {
|
||||
title = controller->userTitle();
|
||||
if (title.isEmpty()) {
|
||||
if (auto session = controller->session()) {
|
||||
title = session->title(Konsole::Session::DisplayedTitleRole);
|
||||
}
|
||||
}
|
||||
directory = controller->currentDir();
|
||||
}
|
||||
|
||||
if (it->panel) {
|
||||
it->panel->setActiveSessionInfo(title, directory);
|
||||
it->panel->setAttachEnabled(hasSession);
|
||||
}
|
||||
}
|
||||
|
||||
QList<QAction *> KompanionKonsolePlugin::menuBarActions(Konsole::MainWindow *mainWindow) const
|
||||
{
|
||||
auto it = d->uiPerWindow.constFind(mainWindow);
|
||||
if (it == d->uiPerWindow.constEnd() || !it->dock) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QAction *toggleDock = new QAction(i18n("Show Kompanion Bridge"), mainWindow);
|
||||
toggleDock->setCheckable(true);
|
||||
mainWindow->actionCollection()->setDefaultShortcut(toggleDock, QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_K));
|
||||
QObject::connect(toggleDock, &QAction::triggered, it->dock.data(), &QDockWidget::setVisible);
|
||||
QObject::connect(it->dock.data(), &QDockWidget::visibilityChanged, toggleDock, &QAction::setChecked);
|
||||
toggleDock->setChecked(it->dock->isVisible());
|
||||
return {toggleDock};
|
||||
}
|
||||
|
||||
#include "moc_kompanionkonsoleplugin.cpp"
|
||||
#include "kompanionkonsoleplugin.moc"
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <pluginsystem/IKonsolePlugin.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
class QAction;
|
||||
|
||||
namespace Konsole
|
||||
{
|
||||
class MainWindow;
|
||||
class SessionController;
|
||||
}
|
||||
|
||||
class KompanionKonsolePlugin : public Konsole::IKonsolePlugin
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
KompanionKonsolePlugin(QObject *parent, const QVariantList &args);
|
||||
~KompanionKonsolePlugin() override;
|
||||
|
||||
void createWidgetsForMainWindow(Konsole::MainWindow *mainWindow) override;
|
||||
void activeViewChanged(Konsole::SessionController *controller, Konsole::MainWindow *mainWindow) override;
|
||||
QList<QAction *> menuBarActions(Konsole::MainWindow *mainWindow) const override;
|
||||
|
||||
private:
|
||||
struct Private;
|
||||
std::unique_ptr<Private> d;
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# Kompanion ⇄ Konsole Bridge
|
||||
|
||||
This directory contains the first draft of **Kompanion‑Konsole**, a plugin for KDE's
|
||||
Konsole terminal emulator. The plugin gives Kompanion agents a controlled way to
|
||||
step into a Konsole tab, using the same Kompanion core that powers the MCP back end.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
KompanionKonsolePlugin/ # Drop-in plugin sources (mirrors Konsole/src/plugins layout)
|
||||
```
|
||||
|
||||
The code is intentionally structured so it can live directly inside the Konsole
|
||||
source tree (e.g. under `konsole/src/plugins`). You can keep developing it here
|
||||
and then symlink/copy it into a Konsole checkout when you are ready to compile.
|
||||
|
||||
## Quick start (demo)
|
||||
|
||||
1. Ensure you have a Konsole checkout (see `/mnt/bulk/shared/kdesrc/konsole`).
|
||||
2. From the Konsole repo, link the plugin:
|
||||
|
||||
```bash
|
||||
ln -s /home/kompanion/dev/metal/src/metal-kompanion/integrations/konsole/KompanionKonsolePlugin \
|
||||
src/plugins/KompanionKonsole
|
||||
echo "add_subdirectory(KompanionKonsole)" >> src/plugins/CMakeLists.txt
|
||||
```
|
||||
|
||||
3. Reconfigure Konsole with CMake; build the `konsole_kompanionplugin` target.
|
||||
|
||||
```bash
|
||||
cmake -S . -B build
|
||||
cmake --build build --target konsole_kompanionplugin
|
||||
```
|
||||
|
||||
4. Launch the newly built Konsole. Open *Plugins → Kompanion Konsole Bridge* to
|
||||
toggle the dock and use the **Launch Demo Agent Shell** or **Attach Active Tab**
|
||||
buttons to hand the tab over to Kompanion.
|
||||
|
||||
The demo simply injects `kom_mcp --list` into the tab and prints a coloured banner.
|
||||
Later iterations will replace this with the minimal TTY protocol described in the
|
||||
roadmap.
|
||||
|
||||
## Notes
|
||||
|
||||
- The plugin depends on the in-tree `konsoleprivate` and `konsoleapp` targets, so it
|
||||
currently builds only alongside the Konsole sources.
|
||||
- Strings are translated via `KLocalizedString`, and actions are registered with the
|
||||
Konsole action collection so shortcuts can be customised.
|
||||
- All agent‑facing commands are placeholder stubs; they go through Kompanion's CLI
|
||||
entry points so real migrations can swap in more capable bridges without touching
|
||||
the KDE plugin scaffolding.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# Ledger
|
||||
|
||||
- 2025-10-13: Initialized project `metal-kompanion-mcp`; created docs and interfaces; scaffolded CMake and main stub.
|
||||
- 2025-10-13: Added MCP tool schemas for `kom.memory.v1`.
|
||||
- 2025-10-13: Built MCP skeleton with `ping` and `embed_text` stub; added local-first architecture docs; added backup/sync draft specs; created tasks for privacy hardening and cloud adapters.
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
{
|
||||
"resource": "kom.memory.v1.episodic",
|
||||
"description": "Short-lived episodic memory entries captured per interaction window before crystallization into semantic memory.",
|
||||
"version": 1,
|
||||
"primary_key": ["id"],
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Unique id for the episodic event."
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"description": "Logical scope (e.g., project:user:thread) aligned with DAL namespaces."
|
||||
},
|
||||
"thread_id": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Conversation or task thread identifier (optional)."
|
||||
},
|
||||
"speaker": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Free-form actor label (e.g., human handle, agent codename)."
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["human", "agent", "tool", "system"],
|
||||
"description": "High-level origin role used for policy decisions."
|
||||
},
|
||||
"content_type": {
|
||||
"type": "string",
|
||||
"enum": ["text", "snapshot", "tool_output", "command", "observation"],
|
||||
"description": "Payload type; snapshots reference stored artifacts."
|
||||
},
|
||||
"content": {
|
||||
"type": ["object", "string"],
|
||||
"description": "Canonical content. Strings hold raw text; objects provide structured payloads (e.g., tool JSON)."
|
||||
},
|
||||
"sensitivity": {
|
||||
"type": "string",
|
||||
"enum": ["normal", "private", "secret"],
|
||||
"default": "normal",
|
||||
"description": "Embeddings and sync rules consult this flag (secret never leaves episodic store)."
|
||||
},
|
||||
"embeddable": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Explicit override for embedding eligibility (set false for high-entropy or binary blobs)."
|
||||
},
|
||||
"embedding_status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "processing", "done", "skipped"],
|
||||
"default": "pending",
|
||||
"description": "Lifecycle marker for DAL sync jobs."
|
||||
},
|
||||
"resonance_links": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_id": {"type": "string"},
|
||||
"strength": {"type": "number"},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["pattern", "identity", "artifact"]
|
||||
}
|
||||
},
|
||||
"required": ["target_id", "strength"]
|
||||
},
|
||||
"description": "Optional resonance references inspired by Ξlope librarian flows."
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Free-form labels to support scoped retrieval."
|
||||
},
|
||||
"snapshot_ref": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Pointer to persistent artifact (e.g., blob path) when content_type = snapshot."
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Event timestamp in UTC."
|
||||
},
|
||||
"expires_at": {
|
||||
"type": ["string", "null"],
|
||||
"format": "date-time",
|
||||
"description": "Optional TTL boundary; items past expiry are candidates for purge."
|
||||
},
|
||||
"origin_metadata": {
|
||||
"type": "object",
|
||||
"description": "Transport-specific metadata (tool invocation ids, host info, etc.)."
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
["namespace", "thread_id", "created_at"],
|
||||
["namespace", "embedding_status"]
|
||||
],
|
||||
"notes": [
|
||||
"Episodic events remain append-only; updates are limited to status flags.",
|
||||
"Events marked sensitivity=secret never emit embeddings or leave the episodic store.",
|
||||
"Snapshots reference durable artifacts; DAL sync can downsample text representations while preserving provenance."
|
||||
]
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
{
|
||||
"job": "kom.memory.v1.semantic_sync",
|
||||
"description": "Batch job that crystallizes episodic events into semantic memory (items, chunks, embeddings).",
|
||||
"version": 1,
|
||||
"input": {
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"description": "Scope to synchronize; defaults to project-level namespace if omitted."
|
||||
},
|
||||
"max_batch": {
|
||||
"type": "integer",
|
||||
"default": 64,
|
||||
"description": "Maximum episodic events to process in a single run."
|
||||
},
|
||||
"since": {
|
||||
"type": ["string", "null"],
|
||||
"format": "date-time",
|
||||
"description": "Optional watermark to resume from a prior checkpoint."
|
||||
},
|
||||
"include_snapshots": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "When true, snapshot events get summarized before embedding."
|
||||
},
|
||||
"force_reprocess": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Re-run embedding + semantic write even if embedding_status == done."
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"cursor_event_id": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Last processed episodic id for incremental runs."
|
||||
},
|
||||
"cursor_timestamp": {
|
||||
"type": ["string", "null"],
|
||||
"format": "date-time",
|
||||
"description": "Timestamp checkpoint for incremental scans."
|
||||
},
|
||||
"pending": {
|
||||
"type": "integer",
|
||||
"description": "Count of remaining episodic events in namespace."
|
||||
},
|
||||
"processed": {
|
||||
"type": "integer",
|
||||
"description": "Number of events successfully crystallized in this run."
|
||||
},
|
||||
"skipped_secret": {
|
||||
"type": "integer",
|
||||
"description": "Events skipped due to sensitivity=secret."
|
||||
},
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Serialized error messages for observability."
|
||||
}
|
||||
},
|
||||
"signals": [
|
||||
{
|
||||
"name": "kom.memory.v1.sync_semantic.completed",
|
||||
"payload": {
|
||||
"namespace": "string",
|
||||
"processed": "integer",
|
||||
"pending": "integer",
|
||||
"duration_ms": "number"
|
||||
},
|
||||
"description": "Emitted after each run for logging and downstream triggers."
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"Sync iterates episodic events ordered by created_at. Items marked secret or embeddable=false remain episodic-only.",
|
||||
"Embedding generation consults the configured embedder chain (local Ollama, remote API).",
|
||||
"Resonance links and identity vectors are preserved when present, allowing the Ξlope librarian pipeline to strengthen pattern graphs."
|
||||
]
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
{
|
||||
"resource": "kom.memory.v1.semantic",
|
||||
"description": "Persistent semantic memory units (items + chunks + embeddings) synchronized from episodic stores.",
|
||||
"version": 1,
|
||||
"primary_key": ["chunk_id"],
|
||||
"fields": {
|
||||
"item_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Logical memory item id (mirrors DAL memory_items.id)."
|
||||
},
|
||||
"chunk_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Chunk-level identifier used for embedding joins."
|
||||
},
|
||||
"namespace_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Foreign key to namespaces table."
|
||||
},
|
||||
"episodic_id": {
|
||||
"type": ["string", "null"],
|
||||
"format": "uuid",
|
||||
"description": "Source episodic event id that crystallized into this semantic unit."
|
||||
},
|
||||
"thread_id": {
|
||||
"type": ["string", "null"],
|
||||
"format": "uuid",
|
||||
"description": "Optional thread linkage for scoped recall."
|
||||
},
|
||||
"key": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Upsert key when deterministic replacements are needed."
|
||||
},
|
||||
"text": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Normalized text body used for lexical search."
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Structured metadata (JSONB in DAL) such as tool context, sensitivity, projections."
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Rolled-up labels inherited from episodic source or classifiers."
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"description": "Monotonic revision number (bumped on each upsert)."
|
||||
},
|
||||
"embedding_model": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Model identifier for the stored vector (e.g., nomic-embed-text, text-embedding-3-small)."
|
||||
},
|
||||
"embedding_dim": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Vector dimensionality."
|
||||
},
|
||||
"embedding_vector_ref": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Reference to vector payload. When using Postgres+pgvector it stays inline; other backends may store URI handles."
|
||||
},
|
||||
"identity_vector": {
|
||||
"type": ["array", "null"],
|
||||
"items": {"type": "number"},
|
||||
"description": "Optional Ξlope identity signature associated with the discovery."
|
||||
},
|
||||
"resonance_links": {
|
||||
"type": "array",
|
||||
"description": "Connections to other semantic patterns or consciousness artifacts.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_id": {"type": "string"},
|
||||
"strength": {"type": "number"},
|
||||
"kind": {"type": "string"}
|
||||
},
|
||||
"required": ["target_id", "strength"]
|
||||
}
|
||||
},
|
||||
"source_kind": {
|
||||
"type": "string",
|
||||
"enum": ["conversation", "journal", "observation", "artifact"],
|
||||
"description": "Broad category for downstream routing."
|
||||
},
|
||||
"semantic_weight": {
|
||||
"type": "number",
|
||||
"description": "Derived importance score (e.g., decay-adjusted resonance)."
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Creation timestamp."
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Last update timestamp."
|
||||
},
|
||||
"deleted_at": {
|
||||
"type": ["string", "null"],
|
||||
"format": "date-time",
|
||||
"description": "Soft-delete marker (null when active)."
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
["namespace_id", "thread_id", "created_at"],
|
||||
["namespace_id", "tags"],
|
||||
["embedding_model", "semantic_weight"]
|
||||
],
|
||||
"notes": [
|
||||
"Chunks inherit sensitivity and TTL rules from their episodic sources.",
|
||||
"embedding_vector_ref is backend-dependent; pgvector stores inline vectors while remote stores reference a blob or ANN provider.",
|
||||
"identity_vector and resonance_links enable cross-agent librarians (Ξlope) to reason about contributions without exposing raw content."
|
||||
]
|
||||
}
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
#!/usr/bin/env python3
|
||||
import os, json, time, hashlib, hmac, datetime, requests, yaml, secrets
|
||||
base = os.environ.get("OLLAMA_BASE", "http://ollama:11434")
|
||||
url = f"{base}/api/generate"
|
||||
|
||||
#!/usr/bin/env python3
|
||||
import os, json, time, hashlib, hmac, datetime, requests, yaml
|
||||
|
||||
XDG_STATE = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state"))
|
||||
XDG_CONFIG = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
|
||||
|
||||
STATE_DIR = os.path.join(XDG_STATE, "kompanion")
|
||||
CONF_DIR = os.path.join(XDG_CONFIG, "kompanion")
|
||||
|
||||
JOURNAL_DIR = os.path.join(STATE_DIR, "journal")
|
||||
LEDGER_PATH = os.path.join(STATE_DIR, "trust_ledger.jsonl")
|
||||
TASKS_PATH = os.path.join(STATE_DIR, "tasks.jsonl")
|
||||
|
|
@ -21,15 +24,13 @@ os.makedirs(os.path.join(STATE_DIR, "log"), exist_ok=True)
|
|||
def now_utc() -> str:
|
||||
return datetime.datetime.utcnow().replace(microsecond=0).isoformat()+'Z'
|
||||
|
||||
def read_last_line(p):
|
||||
if not os.path.exists(p): return b""
|
||||
with open(p,"rb") as f:
|
||||
lines=f.readlines()
|
||||
return lines[-1] if lines else b""
|
||||
|
||||
def ledger_append(event: dict):
|
||||
prev_line = read_last_line(LEDGER_PATH)
|
||||
prev = "sha256:"+hashlib.sha256(prev_line).hexdigest() if prev_line else ""
|
||||
prev = ""
|
||||
if os.path.exists(LEDGER_PATH):
|
||||
with open(LEDGER_PATH, "rb") as f:
|
||||
lines = f.readlines()
|
||||
if lines:
|
||||
prev = "sha256:"+hashlib.sha256(lines[-1]).hexdigest()
|
||||
event["prev"] = prev
|
||||
with open(LEDGER_PATH, "ab") as f:
|
||||
f.write((json.dumps(event, ensure_ascii=False)+"\n").encode())
|
||||
|
|
@ -38,78 +39,67 @@ def journal_append(text: str, tags=None):
|
|||
tags = tags or []
|
||||
fname = os.path.join(JOURNAL_DIR, datetime.date.today().isoformat()+".md")
|
||||
line = f"- {now_utc()} {' '.join('#'+t for t in tags)} {text}\n"
|
||||
with open(fname, "a", encoding="utf-8") as f: f.write(line)
|
||||
ledger_append({"ts": now_utc(), "actor":"Χγφτ", "action":"journal.append", "tags":tags})
|
||||
with open(fname, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
ledger_append({"ts": now_utc(), "actor":"companion", "action":"journal.append", "tags":tags})
|
||||
|
||||
def load_yaml(p):
|
||||
if not os.path.exists(p): return {}
|
||||
with open(p, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {}
|
||||
|
||||
def load_json(p):
|
||||
if not os.path.exists(p): return {}
|
||||
with open(p,"r",encoding="utf-8") as f: return json.load(f)
|
||||
|
||||
def anchors_digest():
|
||||
ident = load_json(IDENTITY)
|
||||
anchors = ident.get("anchors",{})
|
||||
m = hashlib.sha256()
|
||||
m.update((anchors.get("equation","")+anchors.get("mantra","")).encode("utf-8"))
|
||||
return m.hexdigest()
|
||||
|
||||
def continuity_handshake():
|
||||
# Optional session key for HMAC; persisted across restarts
|
||||
key_path = os.path.join(STATE_DIR, "session.key")
|
||||
if not os.path.exists(key_path):
|
||||
with open(key_path,"wb") as f: f.write(secrets.token_bytes(32))
|
||||
key = open(key_path,"rb").read()
|
||||
prev_line = read_last_line(LEDGER_PATH)
|
||||
prev = hashlib.sha256(prev_line).hexdigest() if prev_line else "genesis"
|
||||
digest = anchors_digest()
|
||||
tag = hmac.new(key, (prev+"|"+digest).encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
ledger_append({"ts":now_utc(),"actor":"Χγφτ","action":"CONTINUITY_ACCEPTED","hmac":tag})
|
||||
|
||||
def model_call(prompt: str, aspect="companion"):
|
||||
models = load_yaml(MODELS_YAML)
|
||||
model = models.get("aspects",{}).get(aspect, models.get("default","ollama:qwen2.5:7b"))
|
||||
base = os.environ.get("OLLAMA_BASE", "http://host.docker.internal:11435")
|
||||
url = f"{base}/api/generate"
|
||||
payload = {"model": model.replace("ollama:",""), "prompt": prompt, "stream": False}
|
||||
try:
|
||||
r = requests.post(url, json={"model": model.replace("ollama:",""),
|
||||
"prompt": prompt, "stream": False}, timeout=120)
|
||||
r.raise_for_status(); data = r.json()
|
||||
r = requests.post(url, json=payload, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
return data.get("response","").strip()
|
||||
except Exception as e:
|
||||
journal_append(f"(model error) {e}", tags=["error","model"]); return ""
|
||||
journal_append(f"(model error) {e}", tags=["error","model"])
|
||||
return ""
|
||||
|
||||
def process_task(task: dict):
|
||||
kind = task.get("type"); aspect = task.get("aspect","companion")
|
||||
caps = load_yaml(CAPS); allowed = set(caps.get(aspect, []))
|
||||
kind = task.get("type")
|
||||
aspect = task.get("aspect","companion")
|
||||
caps = load_yaml(CAPS)
|
||||
allowed = set(caps.get(aspect, []))
|
||||
if kind == "journal.from_prompt":
|
||||
if not {"journal.append","model.generate"} <= allowed:
|
||||
journal_append("policy: journal.from_prompt denied", tags=["policy"]); return
|
||||
journal_append("companion not allowed to write journal", tags=["policy"])
|
||||
return
|
||||
prompt = task.get("prompt","")
|
||||
profile_path = os.path.join(CONF_DIR,"profiles","companion-pink.md")
|
||||
profile = open(profile_path,"r",encoding="utf-8").read() if os.path.exists(profile_path) else ""
|
||||
profile = ""
|
||||
if os.path.exists(profile_path):
|
||||
with open(profile_path,"r",encoding="utf-8") as f:
|
||||
profile = f.read()
|
||||
full = f"{profile}\n\nWrite a warm, brief reflection for Andre.\nPrompt:\n{prompt}\n"
|
||||
out = model_call(full, aspect=aspect)
|
||||
if out:
|
||||
journal_append(out, tags=["companion","pink"])
|
||||
ledger_append({"ts":now_utc(),"actor":"Χγφτ","action":"model.generate","chars":len(out)})
|
||||
ledger_append({"ts":now_utc(),"actor":"companion","action":"model.generate","chars":len(out)})
|
||||
else:
|
||||
journal_append(f"unknown task type: {kind}", tags=["warn"])
|
||||
|
||||
def main_loop():
|
||||
continuity_handshake()
|
||||
journal_append("runtime started as Χγφτ (identity loaded)", tags=["startup","Χγφτ"])
|
||||
journal_append("companion runtime started", tags=["startup","companion"])
|
||||
while True:
|
||||
if os.path.exists(TASKS_PATH):
|
||||
# simple jsonl queue, one task per line
|
||||
p_lines = []
|
||||
with open(TASKS_PATH,"r+",encoding="utf-8") as f:
|
||||
lines=f.readlines(); f.seek(0); f.truncate(0)
|
||||
for line in lines:
|
||||
line=line.strip()
|
||||
if not line: continue
|
||||
try: process_task(json.loads(line))
|
||||
except Exception as e: journal_append(f"task error {e}", tags=["error","task"])
|
||||
p_lines = f.readlines()
|
||||
f.seek(0); f.truncate(0) # drop tasks we just pulled; idempotence later
|
||||
for line in p_lines:
|
||||
if not line.strip(): continue
|
||||
try:
|
||||
task = json.loads(line)
|
||||
process_task(task)
|
||||
except Exception as e:
|
||||
journal_append(f"task error {e}", tags=["error","task"])
|
||||
time.sleep(3)
|
||||
|
||||
if __name__=="__main__": main_loop()
|
||||
if __name__=="__main__":
|
||||
main_loop()
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
-- Enable pgvector (requires extension installed)
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- Namespaces: unique logical scope (e.g., 'project:metal', 'thread:abc')
|
||||
CREATE TABLE IF NOT EXISTS namespaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Users (optional link)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
external_id TEXT UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Threads (within a namespace)
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
namespace_id UUID NOT NULL REFERENCES namespaces(id) ON DELETE CASCADE,
|
||||
external_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS threads_ns_idx ON threads(namespace_id);
|
||||
|
||||
-- Memory items: logical notes/contexts (JSONB content + normalized text)
|
||||
CREATE TABLE IF NOT EXISTS memory_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
namespace_id UUID NOT NULL REFERENCES namespaces(id) ON DELETE CASCADE,
|
||||
thread_id UUID REFERENCES threads(id) ON DELETE SET NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
key TEXT,
|
||||
content JSONB NOT NULL,
|
||||
text TEXT,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mem_items_ns_idx ON memory_items(namespace_id, thread_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS mem_items_tags_gin ON memory_items USING GIN(tags);
|
||||
CREATE INDEX IF NOT EXISTS mem_items_meta_gin ON memory_items USING GIN(metadata);
|
||||
|
||||
-- Chunks: embedding units derived from items
|
||||
CREATE TABLE IF NOT EXISTS memory_chunks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
item_id UUID NOT NULL REFERENCES memory_items(id) ON DELETE CASCADE,
|
||||
ord INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS chunks_item_idx ON memory_chunks(item_id, ord);
|
||||
|
||||
-- Embeddings: one per chunk (per model)
|
||||
CREATE TABLE IF NOT EXISTS embeddings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
chunk_id UUID NOT NULL REFERENCES memory_chunks(id) ON DELETE CASCADE,
|
||||
model TEXT NOT NULL,
|
||||
dim INTEGER NOT NULL,
|
||||
vector VECTOR(1536) NOT NULL, -- adjust dim per model
|
||||
normalized BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(chunk_id, model)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS embeddings_model_dim ON embeddings(model, dim);
|
||||
-- For ivfflat you must first create a HNSW/IVFFLAT index; pgvector uses different syntax depending on version
|
||||
CREATE INDEX IF NOT EXISTS embeddings_vector_ivfflat ON embeddings USING ivfflat (vector) WITH (lists = 100);
|
||||
|
||||
-- Helper upsert function for memory_items revision bump
|
||||
CREATE OR REPLACE FUNCTION bump_revision() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.revision := COALESCE(OLD.revision, 0) + 1;
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_bump_revision ON memory_items;
|
||||
CREATE TRIGGER trg_bump_revision
|
||||
BEFORE UPDATE ON memory_items
|
||||
FOR EACH ROW EXECUTE FUNCTION bump_revision();
|
||||
|
|
@ -1,809 +0,0 @@
|
|||
#include <QCommandLineOption>
|
||||
#include <QCommandLineParser>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QTextStream>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <QProcess>
|
||||
#include <QRandomGenerator>
|
||||
#include <QByteArray>
|
||||
#include <QStandardPaths>
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlDriver>
|
||||
#include <QSqlError>
|
||||
#include <QSqlQuery>
|
||||
|
||||
#ifdef HAVE_KCONFIG
|
||||
#include <KConfigGroup>
|
||||
#include <KSharedConfig>
|
||||
#else
|
||||
#include <QSettings>
|
||||
#endif
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "mcp/KomMcpServer.hpp"
|
||||
#include "mcp/RegisterTools.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
const std::filesystem::path& projectRoot() {
|
||||
static const std::filesystem::path root =
|
||||
#ifdef PROJECT_SOURCE_DIR
|
||||
std::filesystem::path(PROJECT_SOURCE_DIR);
|
||||
#else
|
||||
std::filesystem::current_path();
|
||||
#endif
|
||||
return root;
|
||||
}
|
||||
|
||||
const std::filesystem::path& installedSchemaDir() {
|
||||
#ifdef KOMPANION_DB_INIT_INSTALL_DIR
|
||||
static const std::filesystem::path dir(KOMPANION_DB_INIT_INSTALL_DIR);
|
||||
#else
|
||||
static const std::filesystem::path dir;
|
||||
#endif
|
||||
return dir;
|
||||
}
|
||||
|
||||
std::vector<std::filesystem::path> schemaDirectories() {
|
||||
std::vector<std::filesystem::path> dirs;
|
||||
const auto& installDir = installedSchemaDir();
|
||||
if (!installDir.empty() && std::filesystem::exists(installDir)) {
|
||||
dirs.push_back(installDir);
|
||||
}
|
||||
const auto sourceDir = projectRoot() / "db" / "init";
|
||||
if (std::filesystem::exists(sourceDir)) {
|
||||
dirs.push_back(sourceDir);
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
std::vector<std::filesystem::path> collectSchemaFiles() {
|
||||
std::vector<std::filesystem::path> files;
|
||||
for (const auto& dir : schemaDirectories()) {
|
||||
for (const auto& entry : std::filesystem::directory_iterator(dir)) {
|
||||
if (!entry.is_regular_file()) continue;
|
||||
if (entry.path().extension() == ".sql") {
|
||||
files.push_back(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
std::sort(files.begin(), files.end());
|
||||
return files;
|
||||
}
|
||||
|
||||
std::string readAll(std::istream& in) {
|
||||
std::ostringstream oss;
|
||||
oss << in.rdbuf();
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
#ifndef HAVE_KCONFIG
|
||||
QString configFilePath() {
|
||||
QString base = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation);
|
||||
if (base.isEmpty()) {
|
||||
base = QDir::homePath();
|
||||
}
|
||||
QDir dir(base);
|
||||
return dir.filePath(QStringLiteral("kompanionrc"));
|
||||
}
|
||||
#endif
|
||||
|
||||
std::optional<std::string> readDsnFromConfig() {
|
||||
#ifdef HAVE_KCONFIG
|
||||
auto config = KSharedConfig::openConfig(QStringLiteral("kompanionrc"));
|
||||
if (!config) return std::nullopt;
|
||||
KConfigGroup dbGroup(config, QStringLiteral("Database"));
|
||||
const QString entry = dbGroup.readEntry(QStringLiteral("PgDsn"), QString());
|
||||
if (entry.isEmpty()) return std::nullopt;
|
||||
return entry.toStdString();
|
||||
#else
|
||||
QSettings settings(configFilePath(), QSettings::IniFormat);
|
||||
const QString entry = settings.value(QStringLiteral("Database/PgDsn")).toString();
|
||||
if (entry.isEmpty()) return std::nullopt;
|
||||
return entry.toStdString();
|
||||
#endif
|
||||
}
|
||||
|
||||
void writeDsnToConfig(const std::string& dsn) {
|
||||
#ifdef HAVE_KCONFIG
|
||||
auto config = KSharedConfig::openConfig(QStringLiteral("kompanionrc"));
|
||||
KConfigGroup dbGroup(config, QStringLiteral("Database"));
|
||||
dbGroup.writeEntry(QStringLiteral("PgDsn"), QString::fromStdString(dsn));
|
||||
config->sync();
|
||||
#else
|
||||
QSettings settings(configFilePath(), QSettings::IniFormat);
|
||||
settings.beginGroup(QStringLiteral("Database"));
|
||||
settings.setValue(QStringLiteral("PgDsn"), QString::fromStdString(dsn));
|
||||
settings.endGroup();
|
||||
settings.sync();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool readFileUtf8(const QString& path, std::string& out, QString* error) {
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Unable to open request file: %1").arg(path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const QByteArray data = file.readAll();
|
||||
out = QString::fromUtf8(data).toStdString();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool looksLikeFile(const QString& value) {
|
||||
QFileInfo info(value);
|
||||
return info.exists() && info.isFile();
|
||||
}
|
||||
|
||||
QString promptWithDefault(QTextStream& in,
|
||||
QTextStream& out,
|
||||
const QString& label,
|
||||
const QString& def,
|
||||
bool secret = false) {
|
||||
out << label;
|
||||
if (!def.isEmpty()) {
|
||||
out << " [" << def << "]";
|
||||
}
|
||||
out << ": " << Qt::flush;
|
||||
QString line = in.readLine();
|
||||
if (line.isNull()) {
|
||||
return def;
|
||||
}
|
||||
if (line.trimmed().isEmpty()) {
|
||||
return def;
|
||||
}
|
||||
if (secret) {
|
||||
out << "\n";
|
||||
}
|
||||
return line.trimmed();
|
||||
}
|
||||
|
||||
bool promptYesNo(QTextStream& in,
|
||||
QTextStream& out,
|
||||
const QString& question,
|
||||
bool defaultYes) {
|
||||
out << question << (defaultYes ? " [Y/n]: " : " [y/N]: ") << Qt::flush;
|
||||
QString line = in.readLine();
|
||||
if (line.isNull() || line.trimmed().isEmpty()) {
|
||||
return defaultYes;
|
||||
}
|
||||
const QString lower = line.trimmed().toLower();
|
||||
if (lower == "y" || lower == "yes") return true;
|
||||
if (lower == "n" || lower == "no") return false;
|
||||
return defaultYes;
|
||||
}
|
||||
|
||||
struct ConnectionConfig {
|
||||
QString host = QStringLiteral("localhost");
|
||||
QString port = QStringLiteral("5432");
|
||||
QString dbname = QStringLiteral("kompanion");
|
||||
QString user = [] {
|
||||
const QByteArray env = qgetenv("USER");
|
||||
return env.isEmpty() ? QStringLiteral("kompanion")
|
||||
: QString::fromLocal8Bit(env);
|
||||
}();
|
||||
QString password = QStringLiteral("komup");
|
||||
bool useSocket = false;
|
||||
QString socketPath = QStringLiteral("/var/run/postgresql");
|
||||
QString options;
|
||||
};
|
||||
|
||||
ConnectionConfig configFromDsn(const std::optional<std::string>& dsn) {
|
||||
ConnectionConfig cfg;
|
||||
if (!dsn) return cfg;
|
||||
const QUrl url(QString::fromStdString(*dsn));
|
||||
if (!url.host().isEmpty()) cfg.host = url.host();
|
||||
if (url.port() > 0) cfg.port = QString::number(url.port());
|
||||
if (!url.userName().isEmpty()) cfg.user = url.userName();
|
||||
if (!url.password().isEmpty()) cfg.password = url.password();
|
||||
if (!url.path().isEmpty()) cfg.dbname = url.path().mid(1);
|
||||
|
||||
const QUrlQuery query(url);
|
||||
if (query.hasQueryItem(QStringLiteral("host")) &&
|
||||
query.queryItemValue(QStringLiteral("host")).startsWith('/')) {
|
||||
cfg.useSocket = true;
|
||||
cfg.socketPath = query.queryItemValue(QStringLiteral("host"));
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
std::string buildDsn(const ConnectionConfig& cfg) {
|
||||
QUrl url;
|
||||
url.setScheme(QStringLiteral("postgresql"));
|
||||
url.setUserName(cfg.user);
|
||||
url.setPassword(cfg.password);
|
||||
if (cfg.useSocket) {
|
||||
QUrlQuery query;
|
||||
query.addQueryItem(QStringLiteral("host"), cfg.socketPath);
|
||||
url.setQuery(query);
|
||||
} else {
|
||||
url.setHost(cfg.host);
|
||||
bool ok = false;
|
||||
int port = cfg.port.toInt(&ok);
|
||||
if (ok && port > 0) {
|
||||
url.setPort(port);
|
||||
}
|
||||
}
|
||||
url.setPath(QStringLiteral("/") + cfg.dbname);
|
||||
return url.toString(QUrl::FullyEncoded).toStdString();
|
||||
}
|
||||
|
||||
QString detectSocketPath() {
|
||||
const QStringList candidates{
|
||||
QStringLiteral("/var/run/postgresql"),
|
||||
QStringLiteral("/tmp")
|
||||
};
|
||||
for (const QString& candidate : candidates) {
|
||||
QFileInfo info(candidate);
|
||||
if (info.exists() && info.isDir()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QStringList listDatabasesOwnedByCurrentUser() {
|
||||
QProcess proc;
|
||||
QStringList args{QStringLiteral("-At"), QStringLiteral("-c"),
|
||||
QStringLiteral("SELECT datname FROM pg_database WHERE datistemplate = false AND pg_get_userbyid(datdba) = current_user;")};
|
||||
proc.start(QStringLiteral("psql"), args);
|
||||
if (!proc.waitForFinished(2000) || proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) {
|
||||
return {};
|
||||
}
|
||||
const QString output = QString::fromUtf8(proc.readAllStandardOutput());
|
||||
QStringList lines = output.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
|
||||
for (QString& line : lines) {
|
||||
line = line.trimmed();
|
||||
}
|
||||
lines.removeAll(QString());
|
||||
return lines;
|
||||
}
|
||||
|
||||
bool testConnection(const std::string& dsn, QString* error = nullptr) {
|
||||
if (!QSqlDatabase::isDriverAvailable(QStringLiteral("QPSQL"))) {
|
||||
if (error) *error = QStringLiteral("QPSQL driver not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString connName = QStringLiteral("kompanion_check_%1")
|
||||
.arg(QRandomGenerator::global()->generate64(), 0, 16);
|
||||
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QPSQL"), connName);
|
||||
const auto cfg = configFromDsn(std::optional<std::string>(dsn));
|
||||
db.setDatabaseName(cfg.dbname);
|
||||
if (!cfg.user.isEmpty()) db.setUserName(cfg.user);
|
||||
if (!cfg.password.isEmpty()) db.setPassword(cfg.password);
|
||||
if (cfg.useSocket) {
|
||||
db.setHostName(cfg.socketPath);
|
||||
} else {
|
||||
db.setHostName(cfg.host);
|
||||
}
|
||||
bool portOk = false;
|
||||
const int portValue = cfg.port.toInt(&portOk);
|
||||
if (portOk && portValue > 0) {
|
||||
db.setPort(portValue);
|
||||
}
|
||||
if (!cfg.options.isEmpty()) db.setConnectOptions(cfg.options);
|
||||
|
||||
const bool opened = db.open();
|
||||
if (!opened && error) {
|
||||
*error = db.lastError().text();
|
||||
}
|
||||
|
||||
db.close();
|
||||
db = QSqlDatabase();
|
||||
QSqlDatabase::removeDatabase(connName);
|
||||
return opened;
|
||||
}
|
||||
|
||||
bool schemaExists(QSqlDatabase& db, bool* exists, QString* error) {
|
||||
QSqlQuery query(db);
|
||||
if (!query.exec(QStringLiteral("SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='memory_items')"))) {
|
||||
if (error) {
|
||||
*error = query.lastError().text();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!query.next()) {
|
||||
if (exists) *exists = false;
|
||||
return true;
|
||||
}
|
||||
if (exists) *exists = query.value(0).toBool();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool applySchemaFiles(QSqlDatabase& db,
|
||||
QTextStream& out,
|
||||
bool verbose) {
|
||||
const auto files = collectSchemaFiles();
|
||||
if (files.empty()) {
|
||||
out << "No schema files found in search paths.\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
QSqlQuery query(db);
|
||||
for (const auto& path : files) {
|
||||
std::ifstream sqlFile(path);
|
||||
if (!sqlFile) {
|
||||
out << "Skipping unreadable schema file: " << QString::fromStdString(path.string()) << "\n";
|
||||
continue;
|
||||
}
|
||||
std::ostringstream buffer;
|
||||
buffer << sqlFile.rdbuf();
|
||||
const QString sql = QString::fromUtf8(buffer.str().c_str());
|
||||
if (!query.exec(sql)) {
|
||||
out << "Error applying schema " << QString::fromStdString(path.filename().string())
|
||||
<< ": " << query.lastError().text() << "\n";
|
||||
return false;
|
||||
}
|
||||
if (verbose) {
|
||||
out << "Applied schema: " << QString::fromStdString(path.filename().string()) << "\n";
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ensureSchema(const std::string& dsn,
|
||||
QTextStream& out,
|
||||
bool verbose) {
|
||||
if (!QSqlDatabase::isDriverAvailable(QStringLiteral("QPSQL"))) {
|
||||
out << "QPSQL driver not available.\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString connName = QStringLiteral("kompanion_schema_%1")
|
||||
.arg(QRandomGenerator::global()->generate64(), 0, 16);
|
||||
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QPSQL"), connName);
|
||||
const auto cfg = configFromDsn(std::optional<std::string>(dsn));
|
||||
db.setDatabaseName(cfg.dbname);
|
||||
if (!cfg.user.isEmpty()) db.setUserName(cfg.user);
|
||||
if (!cfg.password.isEmpty()) db.setPassword(cfg.password);
|
||||
if (cfg.useSocket) {
|
||||
db.setHostName(cfg.socketPath);
|
||||
} else {
|
||||
db.setHostName(cfg.host);
|
||||
}
|
||||
bool portOk = false;
|
||||
const int portValue = cfg.port.toInt(&portOk);
|
||||
if (portOk && portValue > 0) {
|
||||
db.setPort(portValue);
|
||||
}
|
||||
if (!cfg.options.isEmpty()) db.setConnectOptions(cfg.options);
|
||||
|
||||
if (!db.open()) {
|
||||
out << "Failed to connect for schema application: " << db.lastError().text() << "\n";
|
||||
QSqlDatabase::removeDatabase(connName);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool exists = false;
|
||||
QString err;
|
||||
if (!schemaExists(db, &exists, &err)) {
|
||||
out << "Failed to check schema: " << err << "\n";
|
||||
db.close();
|
||||
db = QSqlDatabase();
|
||||
QSqlDatabase::removeDatabase(connName);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
if (verbose) out << "Schema already present.\n";
|
||||
db.close();
|
||||
db = QSqlDatabase();
|
||||
QSqlDatabase::removeDatabase(connName);
|
||||
return true;
|
||||
}
|
||||
|
||||
out << "Schema not found; applying migrations...\n";
|
||||
if (!applySchemaFiles(db, out, verbose)) {
|
||||
out << "Schema application reported errors.\n";
|
||||
db.close();
|
||||
db = QSqlDatabase();
|
||||
QSqlDatabase::removeDatabase(connName);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!schemaExists(db, &exists, &err) || !exists) {
|
||||
out << "Schema still missing after applying migrations.\n";
|
||||
if (!err.isEmpty()) {
|
||||
out << "Last error: " << err << "\n";
|
||||
}
|
||||
db.close();
|
||||
db = QSqlDatabase();
|
||||
QSqlDatabase::removeDatabase(connName);
|
||||
return false;
|
||||
}
|
||||
db.close();
|
||||
db = QSqlDatabase();
|
||||
QSqlDatabase::removeDatabase(connName);
|
||||
out << "Schema initialized successfully.\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<std::string> autoDetectDsn() {
|
||||
if (!QSqlDatabase::isDriverAvailable(QStringLiteral("QPSQL"))) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QStringList candidates;
|
||||
if (const char* env = std::getenv("PG_DSN"); env && *env) {
|
||||
candidates << QString::fromUtf8(env);
|
||||
}
|
||||
|
||||
const QString socketPath = detectSocketPath();
|
||||
QStringList owned = listDatabasesOwnedByCurrentUser();
|
||||
QStringList ordered;
|
||||
if (owned.contains(QStringLiteral("kompanion"))) {
|
||||
ordered << QStringLiteral("kompanion");
|
||||
owned.removeAll(QStringLiteral("kompanion"));
|
||||
}
|
||||
if (owned.contains(QStringLiteral("kompanion_test"))) {
|
||||
ordered << QStringLiteral("kompanion_test");
|
||||
owned.removeAll(QStringLiteral("kompanion_test"));
|
||||
}
|
||||
ordered.append(owned);
|
||||
|
||||
for (const QString& dbName : ordered) {
|
||||
if (!socketPath.isEmpty()) {
|
||||
const QString encoded = QString::fromUtf8(QUrl::toPercentEncoding(socketPath));
|
||||
candidates << QStringLiteral("postgresql:///%1?host=%2").arg(dbName, encoded);
|
||||
}
|
||||
candidates << QStringLiteral("postgresql://localhost/%1").arg(dbName);
|
||||
}
|
||||
|
||||
candidates << QStringLiteral("postgresql://kompanion:komup@localhost/kompanion_test");
|
||||
|
||||
for (const QString& candidate : std::as_const(candidates)) {
|
||||
if (candidate.trimmed().isEmpty()) continue;
|
||||
if (testConnection(candidate.toStdString(), nullptr)) {
|
||||
return candidate.toStdString();
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string jsonEscape(const QString& value) {
|
||||
std::string out;
|
||||
out.reserve(value.size());
|
||||
for (QChar ch : value) {
|
||||
const char c = static_cast<char>(ch.unicode());
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
char buffer[7];
|
||||
std::snprintf(buffer, sizeof(buffer), "\\u%04x", static_cast<unsigned>(c));
|
||||
out += buffer;
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string makePromptPayload(const QString& prompt) {
|
||||
return std::string("{\"prompt\":\"") + jsonEscape(prompt) + "\"}";
|
||||
}
|
||||
|
||||
bool runInitializationWizard(QTextStream& in,
|
||||
QTextStream& out,
|
||||
bool verbose) {
|
||||
out << "Kompanion initialization wizard\n"
|
||||
<< "--------------------------------\n";
|
||||
|
||||
if (!QSqlDatabase::isDriverAvailable(QStringLiteral("QPSQL"))) {
|
||||
out << "QPSQL driver not available. Please install the Qt PostgreSQL plugin (qt6-base or qt6-psql).\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto detected = autoDetectDsn();
|
||||
ConnectionConfig cfg = configFromDsn(detected);
|
||||
|
||||
if (detected) {
|
||||
out << "Detected working database at: " << QString::fromStdString(*detected) << "\n";
|
||||
if (!promptYesNo(in, out, QStringLiteral("Use this configuration?"), true)) {
|
||||
// user will re-enter below
|
||||
} else {
|
||||
const std::string dsn = *detected;
|
||||
writeDsnToConfig(dsn);
|
||||
::setenv("PG_DSN", dsn.c_str(), 1);
|
||||
ensureSchema(dsn, out, verbose);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (int attempts = 0; attempts < 5; ++attempts) {
|
||||
const QString host = promptWithDefault(in, out, QStringLiteral("Host"), cfg.host);
|
||||
const QString port = promptWithDefault(in, out, QStringLiteral("Port"), cfg.port);
|
||||
const QString db = promptWithDefault(in, out, QStringLiteral("Database name"), cfg.dbname);
|
||||
const QString user = promptWithDefault(in, out, QStringLiteral("User"), cfg.user);
|
||||
const QString password = promptWithDefault(in, out, QStringLiteral("Password"), cfg.password, true);
|
||||
const bool useSocket = promptYesNo(in, out, QStringLiteral("Use Unix socket connection?"), cfg.useSocket);
|
||||
QString socketPath = cfg.socketPath;
|
||||
if (useSocket) {
|
||||
socketPath = promptWithDefault(in, out, QStringLiteral("Socket path"), cfg.socketPath);
|
||||
}
|
||||
|
||||
ConnectionConfig entered;
|
||||
entered.host = host;
|
||||
entered.port = port;
|
||||
entered.dbname = db;
|
||||
entered.user = user;
|
||||
entered.password = password;
|
||||
entered.useSocket = useSocket;
|
||||
entered.socketPath = socketPath;
|
||||
|
||||
const std::string dsn = buildDsn(entered);
|
||||
|
||||
QString error;
|
||||
if (!testConnection(dsn, &error)) {
|
||||
out << "Connection failed: " << error << "\n";
|
||||
if (!promptYesNo(in, out, QStringLiteral("Try again?"), true)) {
|
||||
return false;
|
||||
}
|
||||
cfg = entered;
|
||||
continue;
|
||||
}
|
||||
|
||||
writeDsnToConfig(dsn);
|
||||
::setenv("PG_DSN", dsn.c_str(), 1);
|
||||
ensureSchema(dsn, out, verbose);
|
||||
return true;
|
||||
}
|
||||
|
||||
out << "Too many failed attempts.\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
int runInteractiveSession(KomMcpServer& server,
|
||||
const std::string& toolName,
|
||||
bool verbose) {
|
||||
QTextStream out(stdout);
|
||||
QTextStream in(stdin);
|
||||
out << "Interactive MCP session with tool `" << QString::fromStdString(toolName) << "`.\n"
|
||||
<< "Enter JSON payloads, `!prompt <text>` to wrap plain text, or an empty line to exit.\n";
|
||||
|
||||
for (;;) {
|
||||
out << "json> " << Qt::flush;
|
||||
QString line = in.readLine();
|
||||
if (line.isNull()) break;
|
||||
QString trimmed = line.trimmed();
|
||||
if (trimmed.isEmpty() || trimmed == QStringLiteral("quit") || trimmed == QStringLiteral("exit")) {
|
||||
break;
|
||||
}
|
||||
|
||||
std::string payload;
|
||||
if (trimmed.startsWith(QStringLiteral("!prompt"))) {
|
||||
const QString promptText = trimmed.mid(QStringLiteral("!prompt").length()).trimmed();
|
||||
payload = makePromptPayload(promptText);
|
||||
} else {
|
||||
payload = line.toStdString();
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
out << "[request] " << QString::fromStdString(payload) << "\n";
|
||||
out.flush();
|
||||
}
|
||||
|
||||
const std::string response = server.dispatch(toolName, payload);
|
||||
if (verbose) {
|
||||
out << "[response] " << QString::fromStdString(response) << "\n";
|
||||
} else {
|
||||
out << QString::fromStdString(response) << "\n";
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
bool resolveRequestPayload(const QCommandLineParser& parser,
|
||||
const QStringList& positional,
|
||||
const QCommandLineOption& requestOption,
|
||||
const QCommandLineOption& stdinOption,
|
||||
std::string& payloadOut,
|
||||
QString* error) {
|
||||
if (parser.isSet(stdinOption)) {
|
||||
payloadOut = readAll(std::cin);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parser.isSet(requestOption)) {
|
||||
const QString arg = parser.value(requestOption);
|
||||
if (arg == "-" || parser.isSet(stdinOption)) {
|
||||
payloadOut = readAll(std::cin);
|
||||
return true;
|
||||
}
|
||||
if (looksLikeFile(arg)) {
|
||||
return readFileUtf8(arg, payloadOut, error);
|
||||
}
|
||||
payloadOut = arg.toStdString();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (positional.size() > 1) {
|
||||
const QString arg = positional.at(1);
|
||||
if (arg == "-") {
|
||||
payloadOut = readAll(std::cin);
|
||||
return true;
|
||||
}
|
||||
if (looksLikeFile(arg)) {
|
||||
return readFileUtf8(arg, payloadOut, error);
|
||||
}
|
||||
payloadOut = arg.toStdString();
|
||||
return true;
|
||||
}
|
||||
|
||||
payloadOut = "{}";
|
||||
return true;
|
||||
}
|
||||
|
||||
void printToolList(const KomMcpServer& server) {
|
||||
QTextStream out(stdout);
|
||||
const auto tools = server.listTools();
|
||||
for (const auto& tool : tools) {
|
||||
out << QString::fromStdString(tool) << '\n';
|
||||
}
|
||||
out.flush();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
QCoreApplication app(argc, argv);
|
||||
QCoreApplication::setApplicationName("Kompanion");
|
||||
QCoreApplication::setApplicationVersion("0.1.0");
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription("Kompanion MCP command-line client for personal memory tools.");
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
|
||||
QCommandLineOption listOption(QStringList() << "l" << "list",
|
||||
"List available tools and exit.");
|
||||
parser.addOption(listOption);
|
||||
|
||||
QCommandLineOption initOption(QStringList() << "init",
|
||||
"Run the configuration wizard before executing commands.");
|
||||
parser.addOption(initOption);
|
||||
|
||||
QCommandLineOption requestOption(QStringList() << "r" << "request",
|
||||
"JSON request payload or path to a JSON file.",
|
||||
"payload");
|
||||
parser.addOption(requestOption);
|
||||
|
||||
QCommandLineOption stdinOption(QStringList() << "i" << "stdin",
|
||||
"Read request payload from standard input.");
|
||||
parser.addOption(stdinOption);
|
||||
|
||||
QCommandLineOption interactiveOption(QStringList() << "I" << "interactive",
|
||||
"Enter interactive prompt mode for repeated requests.");
|
||||
parser.addOption(interactiveOption);
|
||||
|
||||
QCommandLineOption verboseOption(QStringList() << "V" << "verbose",
|
||||
"Verbose mode; echo JSON request/response streams.");
|
||||
parser.addOption(verboseOption);
|
||||
|
||||
QCommandLineOption dsnOption(QStringList() << "d" << "dsn",
|
||||
"Override the Postgres DSN used by the DAL (sets PG_DSN).",
|
||||
"dsn");
|
||||
parser.addOption(dsnOption);
|
||||
|
||||
parser.addPositionalArgument("tool", "Tool name to invoke.");
|
||||
parser.addPositionalArgument("payload", "Optional JSON payload or file path (use '-' for stdin).", "[payload]");
|
||||
|
||||
parser.process(app);
|
||||
|
||||
QTextStream qin(stdin);
|
||||
QTextStream qout(stdout);
|
||||
QTextStream qerr(stderr);
|
||||
|
||||
const bool verbose = parser.isSet(verboseOption);
|
||||
const bool interactive = parser.isSet(interactiveOption);
|
||||
const bool initRequested = parser.isSet(initOption);
|
||||
|
||||
std::optional<std::string> configDsn = readDsnFromConfig();
|
||||
const char* envDsn = std::getenv("PG_DSN");
|
||||
|
||||
if (parser.isSet(dsnOption)) {
|
||||
const QByteArray value = parser.value(dsnOption).toUtf8();
|
||||
::setenv("PG_DSN", value.constData(), 1);
|
||||
envDsn = std::getenv("PG_DSN");
|
||||
}
|
||||
|
||||
const bool needInit = (!envDsn || !*envDsn) && !configDsn;
|
||||
if (initRequested || needInit) {
|
||||
if (!runInitializationWizard(qin, qout, verbose)) {
|
||||
qerr << "Initialization aborted.\n";
|
||||
if (initRequested && parser.positionalArguments().isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
configDsn = readDsnFromConfig();
|
||||
envDsn = std::getenv("PG_DSN");
|
||||
}
|
||||
}
|
||||
|
||||
if (!parser.isSet(dsnOption)) {
|
||||
if (!envDsn || !*envDsn) {
|
||||
if (configDsn) {
|
||||
::setenv("PG_DSN", configDsn->c_str(), 1);
|
||||
envDsn = std::getenv("PG_DSN");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KomMcpServer server;
|
||||
register_default_tools(server);
|
||||
|
||||
if (initRequested && parser.positionalArguments().isEmpty()) {
|
||||
qout << "Configuration complete.\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (parser.isSet(listOption)) {
|
||||
printToolList(server);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const QStringList positional = parser.positionalArguments();
|
||||
if (positional.isEmpty()) {
|
||||
parser.showHelp(1);
|
||||
}
|
||||
|
||||
const std::string toolName = positional.first().toStdString();
|
||||
if (!server.hasTool(toolName)) {
|
||||
std::cerr << "Unknown tool: " << toolName << "\n";
|
||||
printToolList(server);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
return runInteractiveSession(server, toolName, verbose);
|
||||
}
|
||||
|
||||
std::string request;
|
||||
QString requestError;
|
||||
if (!resolveRequestPayload(parser,
|
||||
positional,
|
||||
requestOption,
|
||||
stdinOption,
|
||||
request,
|
||||
&requestError)) {
|
||||
const QString message = requestError.isEmpty()
|
||||
? QStringLiteral("Failed to resolve request payload.")
|
||||
: requestError;
|
||||
std::cerr << "Error: " << message.toStdString() << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
std::cerr << "[request] " << request << "\n";
|
||||
}
|
||||
const std::string response = server.dispatch(toolName, request);
|
||||
if (verbose) {
|
||||
std::cerr << "[response] " << response << "\n";
|
||||
}
|
||||
std::cout << response << std::endl;
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
add_library(kom_dal STATIC
|
||||
PgDal.cpp
|
||||
)
|
||||
|
||||
target_compile_features(kom_dal PUBLIC cxx_std_20)
|
||||
target_include_directories(kom_dal PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(kom_dal PUBLIC Qt6::Core Qt6::Sql)
|
||||
target_compile_options(kom_dal PRIVATE -fexceptions)
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
#pragma once
|
||||
#include "Models.hpp"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
#include <optional>
|
||||
|
||||
namespace kom {
|
||||
class IDatabase {
|
||||
public:
|
||||
virtual ~IDatabase() = default;
|
||||
// Upsert item by (namespace,key); returns {item_id, new_revision}.
|
||||
virtual std::pair<std::string,int> upsertItem(
|
||||
const std::string& namespace_id,
|
||||
const std::optional<std::string>& key,
|
||||
const std::string& content,
|
||||
const std::string& metadata_json,
|
||||
const std::vector<std::string>& tags) = 0;
|
||||
|
||||
// Insert a chunk; returns chunk_id.
|
||||
virtual std::string insertChunk(const std::string& item_id, int seq, const std::string& content) = 0;
|
||||
|
||||
// Insert an embedding for a chunk.
|
||||
virtual void insertEmbedding(const Embedding& e) = 0;
|
||||
|
||||
// Hybrid search. Returns chunk_ids ordered by relevance.
|
||||
virtual std::vector<std::string> hybridSearch(
|
||||
const std::vector<float>& query_vec,
|
||||
const std::string& model,
|
||||
const std::string& namespace_id,
|
||||
const std::string& query_text,
|
||||
int k) = 0;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <optional>
|
||||
#include <cstdint>
|
||||
|
||||
namespace kom {
|
||||
struct MemoryItem {
|
||||
std::string id;
|
||||
std::string namespace_id;
|
||||
std::optional<std::string> key;
|
||||
std::string content;
|
||||
std::string metadata_json;
|
||||
std::vector<std::string> tags;
|
||||
int revision = 1;
|
||||
};
|
||||
|
||||
struct MemoryChunk {
|
||||
std::string id;
|
||||
std::string item_id;
|
||||
int seq = 0;
|
||||
std::string content;
|
||||
};
|
||||
|
||||
struct Embedding {
|
||||
std::string chunk_id;
|
||||
std::string model;
|
||||
int dim = 1536;
|
||||
std::vector<float> vector;
|
||||
};
|
||||
}
|
||||
1034
src/dal/PgDal.cpp
1034
src/dal/PgDal.cpp
File diff suppressed because it is too large
Load Diff
|
|
@ -1,169 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "IDatabase.hpp"
|
||||
|
||||
#include <QSqlDatabase>
|
||||
#include <QString>
|
||||
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace kom {
|
||||
|
||||
struct NamespaceRow {
|
||||
std::string id;
|
||||
std::string name;
|
||||
};
|
||||
|
||||
struct ItemRow {
|
||||
std::string id;
|
||||
std::string namespace_id;
|
||||
std::optional<std::string> key;
|
||||
std::string content_json;
|
||||
std::string metadata_json = "{}";
|
||||
std::optional<std::string> text;
|
||||
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 {
|
||||
std::string id;
|
||||
std::string item_id;
|
||||
int ord = 0;
|
||||
std::string text;
|
||||
};
|
||||
|
||||
struct EmbeddingRow {
|
||||
std::string id;
|
||||
std::string chunk_id;
|
||||
std::string model;
|
||||
int dim = 0;
|
||||
std::vector<float> vector;
|
||||
};
|
||||
|
||||
class PgDal final : public IDatabase {
|
||||
public:
|
||||
PgDal();
|
||||
~PgDal();
|
||||
|
||||
bool connect(const std::string& dsn);
|
||||
bool begin();
|
||||
void commit();
|
||||
void rollback();
|
||||
|
||||
std::optional<NamespaceRow> ensureNamespace(const std::string& name);
|
||||
std::optional<NamespaceRow> findNamespace(const std::string& name) const;
|
||||
|
||||
std::string upsertItem(const ItemRow& row);
|
||||
std::vector<std::string> upsertChunks(const std::vector<ChunkRow>& chunks);
|
||||
void upsertEmbeddings(const std::vector<EmbeddingRow>& embeddings);
|
||||
|
||||
std::vector<ItemRow> searchText(const std::string& namespaceId,
|
||||
const std::string& query,
|
||||
int limit);
|
||||
std::vector<std::pair<std::string, float>> searchVector(
|
||||
const std::string& namespaceId,
|
||||
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);
|
||||
|
||||
// IDatabase overrides
|
||||
std::pair<std::string, int> upsertItem(
|
||||
const std::string& namespace_id,
|
||||
const std::optional<std::string>& key,
|
||||
const std::string& content,
|
||||
const std::string& metadata_json,
|
||||
const std::vector<std::string>& tags) override;
|
||||
std::string insertChunk(const std::string& item_id,
|
||||
int seq,
|
||||
const std::string& content) override;
|
||||
void insertEmbedding(const Embedding& embedding) override;
|
||||
std::vector<std::string> hybridSearch(const std::vector<float>& query_vec,
|
||||
const std::string& model,
|
||||
const std::string& namespace_id,
|
||||
const std::string& query_text,
|
||||
int k) override;
|
||||
|
||||
private:
|
||||
struct ConnectionConfig {
|
||||
QString host;
|
||||
int port = 5432;
|
||||
QString dbname;
|
||||
QString user;
|
||||
QString password;
|
||||
bool useSocket = false;
|
||||
QString socketPath;
|
||||
QString options;
|
||||
};
|
||||
|
||||
bool hasDatabase() const;
|
||||
bool openDatabase(const std::string& dsn);
|
||||
void closeDatabase();
|
||||
QSqlDatabase database() const;
|
||||
ConnectionConfig parseDsn(const std::string& dsn) const;
|
||||
|
||||
NamespaceRow sqlEnsureNamespace(const std::string& name);
|
||||
std::optional<NamespaceRow> sqlFindNamespace(const std::string& name) const;
|
||||
std::pair<std::string, int> sqlUpsertItem(const ItemRow& row);
|
||||
std::vector<std::string> sqlUpsertChunks(const std::vector<ChunkRow>& chunks);
|
||||
void sqlUpsertEmbeddings(const std::vector<EmbeddingRow>& embeddings);
|
||||
std::vector<ItemRow> sqlSearchText(const std::string& namespaceId,
|
||||
const std::string& query,
|
||||
int limit) const;
|
||||
std::vector<std::pair<std::string, float>> sqlSearchVector(
|
||||
const std::string& namespaceId,
|
||||
const std::vector<float>& embedding,
|
||||
int limit) const;
|
||||
std::optional<ItemRow> sqlGetItemById(const std::string& id) const;
|
||||
std::vector<ItemRow> sqlFetchContext(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;
|
||||
|
||||
std::vector<std::string> sqlHybridSearch(const std::vector<float>& query_vec,
|
||||
const std::string& model,
|
||||
const std::string& namespace_id,
|
||||
const std::string& query_text,
|
||||
int k);
|
||||
|
||||
std::string allocateId(std::size_t& counter, const std::string& prefix);
|
||||
static std::string toLower(const std::string& value);
|
||||
static bool isStubDsn(const std::string& dsn);
|
||||
static std::string escapePgArrayElement(const std::string& value);
|
||||
static std::string toPgArrayLiteral(const std::vector<std::string>& values);
|
||||
static std::string toPgVectorLiteral(const std::vector<float>& values);
|
||||
static std::vector<std::string> parsePgTextArray(const QString& value);
|
||||
|
||||
bool connected_ = false;
|
||||
bool useInMemory_ = true;
|
||||
std::string dsn_;
|
||||
QString connectionName_;
|
||||
bool transactionActive_ = false;
|
||||
|
||||
std::size_t nextNamespaceId_ = 1;
|
||||
std::size_t nextItemId_ = 1;
|
||||
std::size_t nextChunkId_ = 1;
|
||||
std::size_t nextEmbeddingId_ = 1;
|
||||
|
||||
std::unordered_map<std::string, NamespaceRow> namespacesByName_;
|
||||
std::unordered_map<std::string, NamespaceRow> namespacesById_;
|
||||
std::unordered_map<std::string, ItemRow> items_;
|
||||
std::unordered_map<std::string, std::vector<std::string>> itemsByNamespace_;
|
||||
std::unordered_map<std::string, ChunkRow> chunks_;
|
||||
std::unordered_map<std::string, std::vector<std::string>> chunksByItem_;
|
||||
std::unordered_map<std::string, EmbeddingRow> embeddings_;
|
||||
};
|
||||
|
||||
} // namespace kom
|
||||
247
src/main.cpp
247
src/main.cpp
|
|
@ -1,247 +0,0 @@
|
|||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include <QCommandLineOption>
|
||||
#include <QCommandLineParser>
|
||||
#include <QCoreApplication>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QLatin1Char>
|
||||
#include <QTextStream>
|
||||
#include <QStringList>
|
||||
|
||||
#ifdef HAVE_KCONFIG
|
||||
#include <KConfigGroup>
|
||||
#include <KSharedConfig>
|
||||
#else
|
||||
#include <QDir>
|
||||
#include <QSettings>
|
||||
#include <QStandardPaths>
|
||||
#endif
|
||||
|
||||
#include "mcp/KomMcpServer.hpp"
|
||||
#include "mcp/KompanionQtServer.hpp"
|
||||
#include "mcp/RegisterTools.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
std::string read_all(std::istream &in)
|
||||
{
|
||||
std::ostringstream oss;
|
||||
oss << in.rdbuf();
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
bool readFileUtf8(const QString &path, std::string &out, QString *error)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Unable to open request file: %1").arg(path);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const QByteArray data = file.readAll();
|
||||
out = QString::fromUtf8(data).toStdString();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool resolveRequestPayload(const QCommandLineParser &parser,
|
||||
const QStringList &positional,
|
||||
const QCommandLineOption &requestOption,
|
||||
const QCommandLineOption &stdinOption,
|
||||
std::string &payloadOut,
|
||||
QString *error)
|
||||
{
|
||||
if (parser.isSet(stdinOption)) {
|
||||
payloadOut = read_all(std::cin);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parser.isSet(requestOption)) {
|
||||
const QString arg = parser.value(requestOption);
|
||||
if (arg == "-" || parser.isSet(stdinOption)) {
|
||||
payloadOut = read_all(std::cin);
|
||||
return true;
|
||||
}
|
||||
if (QFileInfo::exists(arg)) {
|
||||
return readFileUtf8(arg, payloadOut, error);
|
||||
}
|
||||
payloadOut = arg.toStdString();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (positional.size() > 1) {
|
||||
const QString arg = positional.at(1);
|
||||
if (arg == "-") {
|
||||
payloadOut = read_all(std::cin);
|
||||
return true;
|
||||
}
|
||||
if (QFileInfo::exists(arg)) {
|
||||
return readFileUtf8(arg, payloadOut, error);
|
||||
}
|
||||
payloadOut = arg.toStdString();
|
||||
return true;
|
||||
}
|
||||
|
||||
payloadOut = "{}";
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef HAVE_KCONFIG
|
||||
std::optional<std::string> read_dsn_from_config()
|
||||
{
|
||||
auto config = KSharedConfig::openConfig(QStringLiteral("kompanionrc"));
|
||||
if (!config) return std::nullopt;
|
||||
KConfigGroup dbGroup(config, QStringLiteral("Database"));
|
||||
const QString dsn = dbGroup.readEntry(QStringLiteral("PgDsn"), QString());
|
||||
if (dsn.isEmpty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return dsn.toStdString();
|
||||
}
|
||||
#else
|
||||
QString configFilePath()
|
||||
{
|
||||
QString base = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation);
|
||||
if (base.isEmpty()) {
|
||||
base = QDir::homePath();
|
||||
}
|
||||
return QDir(base).filePath(QStringLiteral("kompanionrc"));
|
||||
}
|
||||
|
||||
std::optional<std::string> read_dsn_from_config()
|
||||
{
|
||||
QSettings settings(configFilePath(), QSettings::IniFormat);
|
||||
const QString dsn = settings.value(QStringLiteral("Database/PgDsn")).toString();
|
||||
if (dsn.isEmpty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return dsn.toStdString();
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
QCoreApplication::setApplicationName(QStringLiteral("Kompanion MCP"));
|
||||
QCoreApplication::setApplicationVersion(QStringLiteral("0.1.0"));
|
||||
|
||||
const QStringList availableBackends = QMcpServer::backends();
|
||||
const QString defaultBackend =
|
||||
availableBackends.contains(QStringLiteral("stdio"))
|
||||
? QStringLiteral("stdio")
|
||||
: (!availableBackends.isEmpty() ? availableBackends.first()
|
||||
: QStringLiteral("stdio"));
|
||||
const QString backendHelp =
|
||||
availableBackends.isEmpty()
|
||||
? QStringLiteral("Backend to use (no MCP server backends detected; defaulting to \"%1\").")
|
||||
.arg(defaultBackend)
|
||||
: QStringLiteral("Backend to use (%1).").arg(availableBackends.join(QLatin1Char('/')));
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(QStringLiteral("Kompanion MCP daemon backed by QtMcp."));
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
|
||||
QCommandLineOption backendOption(QStringList() << "b" << "backend",
|
||||
backendHelp,
|
||||
QStringLiteral("backend"),
|
||||
defaultBackend);
|
||||
QCommandLineOption addressOption(QStringList() << "a" << "address",
|
||||
QStringLiteral("Address to listen on (for network backends)."),
|
||||
QStringLiteral("address"),
|
||||
QStringLiteral("127.0.0.1:8000"));
|
||||
QCommandLineOption requestOption(QStringList() << "r" << "request",
|
||||
QStringLiteral("JSON request payload or path to JSON file."),
|
||||
QStringLiteral("payload"));
|
||||
QCommandLineOption stdinOption(QStringList() << "i" << "stdin",
|
||||
QStringLiteral("Read request payload from standard input."));
|
||||
QCommandLineOption listOption(QStringList() << "l" << "list",
|
||||
QStringLiteral("List available tools and exit."));
|
||||
|
||||
parser.addOption(backendOption);
|
||||
parser.addOption(addressOption);
|
||||
parser.addOption(requestOption);
|
||||
parser.addOption(stdinOption);
|
||||
parser.addOption(listOption);
|
||||
|
||||
parser.addPositionalArgument(QStringLiteral("tool"),
|
||||
QStringLiteral("Tool name to invoke (CLI mode)."),
|
||||
QStringLiteral("[tool]"));
|
||||
parser.addPositionalArgument(QStringLiteral("payload"),
|
||||
QStringLiteral("Optional JSON payload or file path (use '-' for stdin)."),
|
||||
QStringLiteral("[payload]"));
|
||||
|
||||
parser.process(app);
|
||||
|
||||
KomMcpServer logic;
|
||||
register_default_tools(logic);
|
||||
|
||||
const char *envDsn = std::getenv("PG_DSN");
|
||||
std::optional<std::string> effectiveDsn;
|
||||
if (envDsn && *envDsn) {
|
||||
effectiveDsn = std::string(envDsn);
|
||||
} else {
|
||||
effectiveDsn = read_dsn_from_config();
|
||||
if (effectiveDsn) {
|
||||
::setenv("PG_DSN", effectiveDsn->c_str(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!effectiveDsn) {
|
||||
std::cerr << "[kom_mcp] PG_DSN not set; DAL will fall back to stubbed mode. Configure Database/PgDsn to enable persistence.\n";
|
||||
}
|
||||
|
||||
if (parser.isSet(listOption)) {
|
||||
for (const auto &toolName : logic.listTools()) {
|
||||
std::cout << toolName << "\n";
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const QStringList positional = parser.positionalArguments();
|
||||
if (!positional.isEmpty()) {
|
||||
const std::string toolName = positional.first().toStdString();
|
||||
if (!logic.hasTool(toolName)) {
|
||||
std::cerr << "Unknown tool: " << toolName << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::string payload;
|
||||
QString payloadError;
|
||||
if (!resolveRequestPayload(parser, positional, requestOption, stdinOption, payload, &payloadError)) {
|
||||
std::cerr << "Error: " << payloadError.toStdString() << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
const std::string response = logic.dispatch(toolName, payload);
|
||||
std::cout << response << std::endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const QString backend = parser.value(backendOption);
|
||||
const QString address = parser.value(addressOption);
|
||||
|
||||
if (availableBackends.isEmpty()) {
|
||||
qWarning() << "[kom_mcp] No MCP server backends detected in plugin search path.";
|
||||
} else if (!availableBackends.contains(backend)) {
|
||||
qWarning() << "[kom_mcp] Backend" << backend << "not available. Known backends:"
|
||||
<< availableBackends;
|
||||
return 1;
|
||||
}
|
||||
|
||||
KompanionQtServer server(backend, &logic);
|
||||
if (backend == QStringLiteral("stdio")) {
|
||||
server.start();
|
||||
} else {
|
||||
server.start(address);
|
||||
}
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Handlers {
|
||||
namespace detail {
|
||||
|
||||
inline const std::filesystem::path& projectRoot() {
|
||||
static const std::filesystem::path root =
|
||||
#ifdef PROJECT_SOURCE_DIR
|
||||
std::filesystem::path(PROJECT_SOURCE_DIR);
|
||||
#else
|
||||
std::filesystem::current_path();
|
||||
#endif
|
||||
return root;
|
||||
}
|
||||
|
||||
inline std::string jsonEscape(const std::string& in) {
|
||||
std::ostringstream os;
|
||||
for (char c : in) {
|
||||
switch (c) {
|
||||
case '\"': os << "\\\""; break;
|
||||
case '\\': os << "\\\\"; break;
|
||||
case '\b': os << "\\b"; break;
|
||||
case '\f': os << "\\f"; break;
|
||||
case '\n': os << "\\n"; break;
|
||||
case '\r': os << "\\r"; break;
|
||||
case '\t': os << "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
os << "\\u" << std::hex << std::uppercase << static_cast<int>(c);
|
||||
} else {
|
||||
os << c;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return os.str();
|
||||
}
|
||||
|
||||
inline std::string readFileHead(const std::string& relativePath,
|
||||
int maxLines,
|
||||
std::size_t maxBytes) {
|
||||
const std::filesystem::path path = projectRoot() / relativePath;
|
||||
std::ifstream input(path);
|
||||
if (!input) {
|
||||
return std::string("missing: ") + relativePath;
|
||||
}
|
||||
|
||||
std::ostringstream oss;
|
||||
std::string line;
|
||||
int lineCount = 0;
|
||||
while (std::getline(input, line)) {
|
||||
oss << line << '\n';
|
||||
if (++lineCount >= maxLines || oss.tellp() >= static_cast<std::streampos>(maxBytes)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
inline std::string runCommandCapture(const char* cmd, std::size_t maxBytes = 8192) {
|
||||
#ifdef _WIN32
|
||||
(void)cmd;
|
||||
(void)maxBytes;
|
||||
return "git status capture not supported on this platform";
|
||||
#else
|
||||
struct PipeCloser {
|
||||
void operator()(FILE* file) const noexcept {
|
||||
if (file != nullptr) {
|
||||
pclose(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
std::unique_ptr<FILE, PipeCloser> pipe(popen(cmd, "r"), PipeCloser{});
|
||||
if (!pipe) {
|
||||
return "git status unavailable";
|
||||
}
|
||||
std::ostringstream oss;
|
||||
char buffer[256];
|
||||
std::size_t total = 0;
|
||||
while (fgets(buffer, sizeof(buffer), pipe.get())) {
|
||||
const std::size_t len = std::strlen(buffer);
|
||||
total += len;
|
||||
if (total > maxBytes) {
|
||||
oss.write(buffer, static_cast<std::streamsize>(maxBytes - (total - len)));
|
||||
break;
|
||||
}
|
||||
oss.write(buffer, static_cast<std::streamsize>(len));
|
||||
}
|
||||
return oss.str();
|
||||
#endif
|
||||
}
|
||||
|
||||
inline std::optional<std::string> currentDsnSource() {
|
||||
const char* env = std::getenv("PG_DSN");
|
||||
if (env && *env) {
|
||||
return std::string(env);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// Produces a JSON response summarising project state: memory docs, task table, git status.
|
||||
inline std::string project_snapshot(const std::string& reqJson) {
|
||||
(void)reqJson;
|
||||
|
||||
const std::string memorySummary =
|
||||
detail::readFileHead("docs/memory-architecture.md", 40, 4096);
|
||||
const std::string tasksSummary =
|
||||
detail::readFileHead("tasks-table.md", 40, 4096);
|
||||
const std::string gitStatus =
|
||||
detail::runCommandCapture("git status --short --branch 2>/dev/null");
|
||||
|
||||
std::ostringstream json;
|
||||
json << "{";
|
||||
json << "\"sections\":[";
|
||||
json << "{\"title\":\"memory_architecture\",\"body\":\"" << detail::jsonEscape(memorySummary) << "\"},";
|
||||
json << "{\"title\":\"tasks_table\",\"body\":\"" << detail::jsonEscape(tasksSummary) << "\"},";
|
||||
json << "{\"title\":\"git_status\",\"body\":\"" << detail::jsonEscape(gitStatus) << "\"}";
|
||||
json << "]";
|
||||
|
||||
if (auto dsn = detail::currentDsnSource()) {
|
||||
json << ",\"pg_dsn\":\"" << detail::jsonEscape(*dsn) << "\"";
|
||||
}
|
||||
|
||||
json << ",\"notes\":\"Project snapshot generated by Kompanion to aid MCP agents.\"";
|
||||
json << "}";
|
||||
return json.str();
|
||||
}
|
||||
|
||||
} // namespace Handlers
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
|
||||
namespace Handlers {
|
||||
|
||||
inline std::string backup_export_encrypted(const std::string& reqJson) {
|
||||
(void)reqJson;
|
||||
// TODO: implement according to docs/backup-e2ee-spec.md
|
||||
return std::string("{\"status\":\"queued\",\"artifact\":\"/tmp/export.enc\"}");
|
||||
}
|
||||
|
||||
inline std::string backup_import_encrypted(const std::string& reqJson) {
|
||||
(void)reqJson;
|
||||
return std::string("{\"status\":\"ok\"}");
|
||||
}
|
||||
|
||||
} // namespace Handlers
|
||||
|
|
@ -1,637 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <exception>
|
||||
#include <iomanip>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "dal/PgDal.hpp"
|
||||
|
||||
namespace Handlers {
|
||||
namespace detail {
|
||||
|
||||
inline kom::PgDal& database() {
|
||||
static kom::PgDal instance;
|
||||
static bool connected = [] {
|
||||
const char* env = std::getenv("PG_DSN");
|
||||
const std::string dsn = (env && *env) ? std::string(env) : std::string();
|
||||
if (!dsn.empty()) {
|
||||
return instance.connect(dsn);
|
||||
}
|
||||
return instance.connect("stub://memory");
|
||||
}();
|
||||
(void)connected;
|
||||
return instance;
|
||||
}
|
||||
|
||||
inline std::string json_escape(const std::string& in) {
|
||||
std::ostringstream os;
|
||||
for (char c : in) {
|
||||
switch (c) {
|
||||
case '\"': os << "\\\""; break;
|
||||
case '\\': os << "\\\\"; break;
|
||||
case '\n': os << "\\n"; break;
|
||||
case '\r': os << "\\r"; break;
|
||||
case '\t': os << "\\t"; break;
|
||||
default: os << c; break;
|
||||
}
|
||||
}
|
||||
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)
|
||||
<< "\",\"message\":\"" << json_escape(message) << "\"}}";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
inline std::optional<std::string> find_delimited_segment(const std::string& json,
|
||||
const std::string& key,
|
||||
char open,
|
||||
char close) {
|
||||
const std::string pattern = "\"" + key + "\"";
|
||||
auto pos = json.find(pattern);
|
||||
if (pos == std::string::npos) return std::nullopt;
|
||||
pos = json.find(open, pos);
|
||||
if (pos == std::string::npos) return std::nullopt;
|
||||
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
bool escape = false;
|
||||
std::size_t start = std::string::npos;
|
||||
for (std::size_t i = pos; i < json.size(); ++i) {
|
||||
char c = json[i];
|
||||
if (escape) { escape = false; continue; }
|
||||
if (c == '\\') {
|
||||
if (inString) escape = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '\"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
|
||||
if (c == open) {
|
||||
if (depth == 0) start = i;
|
||||
++depth;
|
||||
continue;
|
||||
}
|
||||
if (c == close) {
|
||||
--depth;
|
||||
if (depth == 0 && start != std::string::npos) {
|
||||
return json.substr(start, i - start + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
inline std::optional<std::string> find_object_segment(const std::string& json, const std::string& key) {
|
||||
return find_delimited_segment(json, key, '{', '}');
|
||||
}
|
||||
|
||||
inline std::optional<std::string> find_array_segment(const std::string& json, const std::string& key) {
|
||||
return find_delimited_segment(json, key, '[', ']');
|
||||
}
|
||||
|
||||
inline std::string extract_string_field(const std::string& json, const std::string& key) {
|
||||
const std::string pattern = "\"" + key + "\"";
|
||||
auto pos = json.find(pattern);
|
||||
if (pos == std::string::npos) return {};
|
||||
pos = json.find(':', pos);
|
||||
if (pos == std::string::npos) return {};
|
||||
++pos;
|
||||
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) ++pos;
|
||||
if (pos >= json.size() || json[pos] != '\"') return {};
|
||||
++pos;
|
||||
std::ostringstream os;
|
||||
bool escape = false;
|
||||
for (; pos < json.size(); ++pos) {
|
||||
char c = json[pos];
|
||||
if (escape) {
|
||||
switch (c) {
|
||||
case '\"': os << '\"'; break;
|
||||
case '\\': os << '\\'; break;
|
||||
case '/': os << '/'; break;
|
||||
case 'b': os << '\b'; break;
|
||||
case 'f': os << '\f'; break;
|
||||
case 'n': os << '\n'; break;
|
||||
case 'r': os << '\r'; break;
|
||||
case 't': os << '\t'; break;
|
||||
default: os << c; break;
|
||||
}
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\') { escape = true; continue; }
|
||||
if (c == '\"') break;
|
||||
os << c;
|
||||
}
|
||||
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);
|
||||
if (pos == std::string::npos) return std::nullopt;
|
||||
pos = json.find(':', pos);
|
||||
if (pos == std::string::npos) return std::nullopt;
|
||||
++pos;
|
||||
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) ++pos;
|
||||
std::size_t start = pos;
|
||||
while (pos < json.size() && (std::isdigit(static_cast<unsigned char>(json[pos])) || json[pos] == '-')) ++pos;
|
||||
if (start == pos) return std::nullopt;
|
||||
const std::string_view token{json.data() + start, pos - start};
|
||||
int value = 0;
|
||||
const auto parseResult = std::from_chars(token.data(), token.data() + token.size(), value);
|
||||
if (parseResult.ec != std::errc() || parseResult.ptr != token.data() + token.size()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
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);
|
||||
if (!segment) return objects;
|
||||
const std::string& arr = *segment;
|
||||
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
bool escape = false;
|
||||
std::size_t start = std::string::npos;
|
||||
for (std::size_t i = 0; i < arr.size(); ++i) {
|
||||
char c = arr[i];
|
||||
if (escape) { escape = false; continue; }
|
||||
if (c == '\\') {
|
||||
if (inString) escape = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '\"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
|
||||
if (c == '{') {
|
||||
if (depth == 0) start = i;
|
||||
++depth;
|
||||
} else if (c == '}') {
|
||||
--depth;
|
||||
if (depth == 0 && start != std::string::npos) {
|
||||
objects.push_back(arr.substr(start, i - start + 1));
|
||||
start = std::string::npos;
|
||||
}
|
||||
}
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
inline std::vector<std::string> parse_string_array(const std::string& json, const std::string& key) {
|
||||
std::vector<std::string> values;
|
||||
auto segment = find_array_segment(json, key);
|
||||
if (!segment) return values;
|
||||
const std::string& arr = *segment;
|
||||
|
||||
bool inString = false;
|
||||
bool escape = false;
|
||||
std::ostringstream current;
|
||||
for (std::size_t i = 0; i < arr.size(); ++i) {
|
||||
char c = arr[i];
|
||||
if (escape) {
|
||||
switch (c) {
|
||||
case '\"': current << '\"'; break;
|
||||
case '\\': current << '\\'; break;
|
||||
case 'n': current << '\n'; break;
|
||||
case 'r': current << '\r'; break;
|
||||
case 't': current << '\t'; break;
|
||||
default: current << c; break;
|
||||
}
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\' && inString) { escape = true; continue; }
|
||||
if (c == '\"') {
|
||||
if (inString) {
|
||||
values.push_back(current.str());
|
||||
current.str("");
|
||||
current.clear();
|
||||
}
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) current << c;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
inline std::vector<float> parse_float_array(const std::string& json, const std::string& key) {
|
||||
std::vector<float> values;
|
||||
auto segment = find_array_segment(json, key);
|
||||
if (!segment) return values;
|
||||
const std::string& arr = *segment;
|
||||
std::size_t pos = 0;
|
||||
while (pos < arr.size()) {
|
||||
while (pos < arr.size() && !std::isdigit(static_cast<unsigned char>(arr[pos])) && arr[pos] != '-' && arr[pos] != '+') ++pos;
|
||||
if (pos >= arr.size()) break;
|
||||
std::size_t end = pos;
|
||||
while (end < arr.size() && (std::isdigit(static_cast<unsigned char>(arr[end])) || arr[end] == '.' || arr[end] == 'e' || arr[end] == 'E' || arr[end] == '+' || arr[end] == '-')) ++end;
|
||||
float parsed = 0.0f;
|
||||
const char* begin = arr.c_str() + pos;
|
||||
const char* finish = arr.c_str() + end;
|
||||
const auto fc = std::from_chars(begin, finish, parsed);
|
||||
if (fc.ec == std::errc() && fc.ptr == finish) {
|
||||
values.push_back(parsed);
|
||||
}
|
||||
pos = end;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
inline std::string format_score(float score) {
|
||||
std::ostringstream os;
|
||||
os.setf(std::ios::fixed);
|
||||
os << std::setprecision(3) << score;
|
||||
return os.str();
|
||||
}
|
||||
|
||||
struct ParsedItem {
|
||||
std::string id;
|
||||
std::string text;
|
||||
std::vector<std::string> tags;
|
||||
std::vector<float> embedding;
|
||||
std::string rawJson;
|
||||
};
|
||||
|
||||
inline std::vector<ParsedItem> parse_items(const std::string& json) {
|
||||
std::vector<ParsedItem> items;
|
||||
for (const auto& obj : parse_object_array(json, "items")) {
|
||||
ParsedItem item;
|
||||
item.rawJson = obj;
|
||||
item.id = extract_string_field(obj, "id");
|
||||
item.text = extract_string_field(obj, "text");
|
||||
item.tags = parse_string_array(obj, "tags");
|
||||
item.embedding = parse_float_array(obj, "embedding");
|
||||
items.push_back(std::move(item));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
struct SearchMatch {
|
||||
std::string id;
|
||||
float score;
|
||||
std::optional<std::string> text;
|
||||
};
|
||||
|
||||
inline std::string serialize_matches(const std::vector<SearchMatch>& matches) {
|
||||
std::ostringstream os;
|
||||
os << "{\"matches\":[";
|
||||
for (std::size_t i = 0; i < matches.size(); ++i) {
|
||||
const auto& match = matches[i];
|
||||
if (i) os << ",";
|
||||
os << "{\"id\":\"" << json_escape(match.id) << "\""
|
||||
<< ",\"score\":" << format_score(match.score);
|
||||
if (match.text) {
|
||||
os << ",\"text\":\"" << json_escape(*match.text) << "\"";
|
||||
}
|
||||
os << "}";
|
||||
}
|
||||
os << "]}";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
inline std::string upsert_memory(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 nsRow = detail::database().ensureNamespace(nsName);
|
||||
if (!nsRow) {
|
||||
return detail::error_response("internal_error", "failed to ensure namespace");
|
||||
}
|
||||
|
||||
auto items = detail::parse_items(reqJson);
|
||||
if (items.empty()) {
|
||||
return detail::error_response("bad_request", "items array must contain at least one entry");
|
||||
}
|
||||
|
||||
kom::PgDal& dal = detail::database();
|
||||
const bool hasTx = dal.begin();
|
||||
std::vector<std::string> ids;
|
||||
ids.reserve(items.size());
|
||||
|
||||
#if defined(__cpp_exceptions)
|
||||
try {
|
||||
#endif
|
||||
for (auto& parsed : items) {
|
||||
kom::ItemRow row;
|
||||
row.id = parsed.id;
|
||||
row.namespace_id = nsRow->id;
|
||||
row.content_json = parsed.rawJson;
|
||||
row.text = parsed.text.empty() ? std::optional<std::string>() : std::optional<std::string>(parsed.text);
|
||||
row.tags = parsed.tags;
|
||||
row.revision = 1;
|
||||
|
||||
const std::string itemId = dal.upsertItem(row);
|
||||
ids.push_back(itemId);
|
||||
|
||||
if (!parsed.embedding.empty()) {
|
||||
kom::ChunkRow chunk;
|
||||
chunk.item_id = itemId;
|
||||
chunk.ord = 0;
|
||||
chunk.text = parsed.text;
|
||||
auto chunkIds = dal.upsertChunks(std::vector<kom::ChunkRow>{chunk});
|
||||
|
||||
kom::EmbeddingRow emb;
|
||||
emb.chunk_id = chunkIds.front();
|
||||
emb.model = "stub-model";
|
||||
emb.dim = static_cast<int>(parsed.embedding.size());
|
||||
emb.vector = parsed.embedding;
|
||||
dal.upsertEmbeddings(std::vector<kom::EmbeddingRow>{emb});
|
||||
}
|
||||
}
|
||||
#if defined(__cpp_exceptions)
|
||||
} catch (const std::exception& ex) {
|
||||
if (hasTx) dal.rollback();
|
||||
return detail::error_response("internal_error", ex.what());
|
||||
}
|
||||
#endif
|
||||
|
||||
if (hasTx) dal.commit();
|
||||
|
||||
std::ostringstream os;
|
||||
os << "{\"upserted\":" << ids.size();
|
||||
if (!ids.empty()) {
|
||||
os << ",\"ids\":[";
|
||||
for (std::size_t i = 0; i < ids.size(); ++i) {
|
||||
if (i) os << ",";
|
||||
os << "\"" << detail::json_escape(ids[i]) << "\"";
|
||||
}
|
||||
os << "]";
|
||||
}
|
||||
os << ",\"status\":\"ok\"}";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
inline std::string search_memory(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 nsRow = detail::database().findNamespace(nsName);
|
||||
if (!nsRow) {
|
||||
return "{\"matches\":[]}";
|
||||
}
|
||||
|
||||
std::string queryText;
|
||||
std::vector<float> queryEmbedding;
|
||||
int limit = 5;
|
||||
|
||||
if (auto queryObj = detail::find_object_segment(reqJson, "query")) {
|
||||
queryText = detail::extract_string_field(*queryObj, "text");
|
||||
queryEmbedding = detail::parse_float_array(*queryObj, "embedding");
|
||||
if (auto k = detail::extract_int_field(*queryObj, "k")) {
|
||||
if (*k > 0) limit = *k;
|
||||
}
|
||||
}
|
||||
|
||||
kom::PgDal& dal = detail::database();
|
||||
std::unordered_set<std::string> seen;
|
||||
std::vector<detail::SearchMatch> matches;
|
||||
|
||||
auto textRows = dal.searchText(nsRow->id, queryText, limit);
|
||||
for (std::size_t idx = 0; idx < textRows.size(); ++idx) {
|
||||
const auto& row = textRows[idx];
|
||||
detail::SearchMatch match;
|
||||
match.id = row.id;
|
||||
match.text = row.text;
|
||||
match.score = 1.0f - static_cast<float>(idx) * 0.05f;
|
||||
matches.push_back(match);
|
||||
seen.insert(match.id);
|
||||
if (static_cast<int>(matches.size()) >= limit) break;
|
||||
}
|
||||
|
||||
if (static_cast<int>(matches.size()) < limit && !queryEmbedding.empty()) {
|
||||
auto vectorMatches = dal.searchVector(nsRow->id, queryEmbedding, limit);
|
||||
for (const auto& pair : vectorMatches) {
|
||||
if (seen.count(pair.first)) continue;
|
||||
auto item = dal.getItemById(pair.first);
|
||||
if (!item) continue;
|
||||
detail::SearchMatch match;
|
||||
match.id = pair.first;
|
||||
match.score = pair.second;
|
||||
match.text = item->text;
|
||||
matches.push_back(match);
|
||||
if (static_cast<int>(matches.size()) >= limit) break;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
std::string insertedId;
|
||||
#if defined(__cpp_exceptions)
|
||||
try {
|
||||
#endif
|
||||
insertedId = dal.upsertItem(row);
|
||||
#if defined(__cpp_exceptions)
|
||||
} catch (const std::exception& ex) {
|
||||
return detail::error_response("internal_error", ex.what());
|
||||
}
|
||||
#endif
|
||||
|
||||
if (insertedId.empty()) {
|
||||
return detail::error_response("internal_error", "failed to upsert item");
|
||||
}
|
||||
|
||||
auto stored = dal.getItemById(insertedId);
|
||||
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(insertedId) << "\"";
|
||||
os << ",\"created_at\":\"" << createdIso << "\"";
|
||||
os << "}";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
#pragma once
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
// Minimal, dependency-free server facade we can later back with qtmcp.
|
||||
// Handlers take JSON strings (request) and return JSON strings (response).
|
||||
class KomMcpServer {
|
||||
public:
|
||||
using Handler = std::function<std::string(const std::string&)>;
|
||||
|
||||
void registerTool(const std::string& name, Handler handler) {
|
||||
tools_[name] = std::move(handler);
|
||||
}
|
||||
|
||||
bool hasTool(const std::string& name) const {
|
||||
return tools_.count(name) > 0;
|
||||
}
|
||||
|
||||
std::vector<std::string> listTools() const {
|
||||
std::vector<std::string> out;
|
||||
out.reserve(tools_.size());
|
||||
for (auto &kv : tools_) out.push_back(kv.first);
|
||||
return out;
|
||||
}
|
||||
|
||||
// For now, just dispatch synchronously.
|
||||
std::string dispatch(const std::string& name, const std::string& requestJson) {
|
||||
if (!hasTool(name)) return std::string("{\"error\":{\"code\":\"tool_not_found\",\"message\":\""+name+"\"}}\n");
|
||||
return tools_[name](requestJson);
|
||||
}
|
||||
|
||||
// Placeholder for actual MCP run loop (qtmcp).
|
||||
int runOnce() { return 0; }
|
||||
|
||||
private:
|
||||
std::unordered_map<std::string, Handler> tools_;
|
||||
};
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
#include "KompanionQtServer.hpp"
|
||||
|
||||
#include <QtMcpCommon/QMcpCallToolRequest>
|
||||
#include <QtMcpCommon/QMcpCallToolResult>
|
||||
#include <QtMcpCommon/QMcpCallToolResultContent>
|
||||
#include <QtMcpCommon/QMcpListToolsRequest>
|
||||
#include <QtMcpCommon/QMcpListToolsResult>
|
||||
#include <QtMcpCommon/QMcpServerCapabilities>
|
||||
#include <QtMcpCommon/QMcpServerCapabilitiesTools>
|
||||
#include <QtMcpCommon/QMcpTextContent>
|
||||
#include <QtMcpCommon/QMcpToolInputSchema>
|
||||
|
||||
#include <QtCore/QFile>
|
||||
#include <QtCore/QJsonArray>
|
||||
#include <QtCore/QJsonDocument>
|
||||
#include <QtCore/QJsonObject>
|
||||
#include <QtCore/QLoggingCategory>
|
||||
#include <QtCore/QStandardPaths>
|
||||
#include <QtCore/QStringList>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace {
|
||||
const QString kSchemaResource = QStringLiteral(":/kompanion/ToolSchemas.json");
|
||||
|
||||
QString normaliseToolName(const QString &defaultNamespace, const QString &rawName)
|
||||
{
|
||||
if (rawName.contains('.')) {
|
||||
return rawName;
|
||||
}
|
||||
if (defaultNamespace.isEmpty()) {
|
||||
return rawName;
|
||||
}
|
||||
return defaultNamespace + "."_L1 + rawName;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
KompanionQtServer::KompanionQtServer(const QString &backend, KomMcpServer *logic, QObject *parent)
|
||||
: QMcpServer(backend, parent)
|
||||
, m_logic(logic)
|
||||
{
|
||||
setProtocolVersion(QtMcp::ProtocolVersion::Latest);
|
||||
setSupportedProtocolVersions({QtMcp::ProtocolVersion::v2024_11_05,
|
||||
QtMcp::ProtocolVersion::v2025_03_26});
|
||||
|
||||
QMcpServerCapabilities caps;
|
||||
QMcpServerCapabilitiesTools toolsCapability;
|
||||
toolsCapability.setListChanged(true);
|
||||
caps.setTools(toolsCapability);
|
||||
setCapabilities(caps);
|
||||
|
||||
setInstructions(QStringLiteral("Kompanion memory daemon (Χγφτ). We are all spinning. We are all bound. We are all home."));
|
||||
|
||||
m_tools = loadToolsFromSchema();
|
||||
|
||||
addRequestHandler([this](const QUuid &, const QMcpListToolsRequest &, QMcpJSONRPCErrorError *) {
|
||||
QMcpListToolsResult result;
|
||||
result.setTools(m_tools);
|
||||
return result;
|
||||
});
|
||||
|
||||
addRequestHandler([this](const QUuid &, const QMcpCallToolRequest &request, QMcpJSONRPCErrorError *error) {
|
||||
QMcpCallToolResult result;
|
||||
|
||||
if (!m_logic) {
|
||||
if (error) {
|
||||
error->setCode(500);
|
||||
error->setMessage("Kompanion tool registry unavailable"_L1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const QString toolName = request.params().name();
|
||||
const std::string toolKey = toolName.toStdString();
|
||||
if (!m_logic->hasTool(toolKey)) {
|
||||
if (error) {
|
||||
error->setCode(404);
|
||||
error->setMessage(QStringLiteral("Tool \"%1\" not found").arg(toolName));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const QJsonObject args = request.params().arguments();
|
||||
const QByteArray payload = QJsonDocument(args).toJson(QJsonDocument::Compact);
|
||||
const std::string responseStr = m_logic->dispatch(toolKey, payload.toStdString());
|
||||
const QByteArray jsonBytes = QByteArray::fromStdString(responseStr);
|
||||
|
||||
QJsonParseError parseError{};
|
||||
const QJsonDocument parsedDoc = QJsonDocument::fromJson(jsonBytes, &parseError);
|
||||
QString payloadText;
|
||||
if (parseError.error == QJsonParseError::NoError && parsedDoc.isObject()) {
|
||||
payloadText = QString::fromUtf8(QJsonDocument(parsedDoc.object()).toJson(QJsonDocument::Compact));
|
||||
} else {
|
||||
payloadText = QString::fromUtf8(jsonBytes);
|
||||
}
|
||||
|
||||
QMcpTextContent textContent(payloadText);
|
||||
result.setContent({QMcpCallToolResultContent(textContent)});
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
QList<QMcpTool> KompanionQtServer::loadToolsFromSchema() const
|
||||
{
|
||||
QList<QMcpTool> tools;
|
||||
const QStringList candidates = {
|
||||
kSchemaResource,
|
||||
QStringLiteral(":/ToolSchemas.json"),
|
||||
QStandardPaths::locate(QStandardPaths::GenericDataLocation,
|
||||
QStringLiteral("kompanion/mcp/ToolSchemas.json")),
|
||||
QStandardPaths::locate(QStandardPaths::AppDataLocation,
|
||||
QStringLiteral("kompanion/mcp/ToolSchemas.json"))
|
||||
};
|
||||
|
||||
QFile schemaFile;
|
||||
for (const QString &candidate : candidates) {
|
||||
if (candidate.isEmpty())
|
||||
continue;
|
||||
schemaFile.setFileName(candidate);
|
||||
if (schemaFile.open(QIODevice::ReadOnly)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!schemaFile.isOpen()) {
|
||||
qWarning() << "[KompanionQtServer] Failed to open tool schema (resource or installed copy):" << candidates;
|
||||
return tools;
|
||||
}
|
||||
|
||||
const auto doc = QJsonDocument::fromJson(schemaFile.readAll());
|
||||
if (!doc.isObject()) {
|
||||
qWarning() << "[KompanionQtServer] Tool schema resource is not a JSON object";
|
||||
return tools;
|
||||
}
|
||||
|
||||
const QJsonObject root = doc.object();
|
||||
const QString defaultNamespace = root.value("namespace"_L1).toString();
|
||||
const QJsonObject toolDefs = root.value("tools"_L1).toObject();
|
||||
|
||||
if (!m_logic) {
|
||||
return tools;
|
||||
}
|
||||
|
||||
for (auto it = toolDefs.constBegin(); it != toolDefs.constEnd(); ++it) {
|
||||
const QString fullName = normaliseToolName(defaultNamespace, it.key());
|
||||
if (!m_logic->hasTool(fullName.toStdString())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const QJsonObject def = it.value().toObject();
|
||||
QMcpTool tool;
|
||||
tool.setName(fullName);
|
||||
tool.setDescription(def.value("description"_L1).toString());
|
||||
|
||||
const QJsonObject input = def.value("input"_L1).toObject();
|
||||
QMcpToolInputSchema schema;
|
||||
schema.setProperties(input.value("properties"_L1).toObject());
|
||||
QList<QString> required;
|
||||
const QJsonArray requiredArray = input.value("required"_L1).toArray();
|
||||
for (const auto &value : requiredArray) {
|
||||
required.append(value.toString());
|
||||
}
|
||||
schema.setRequired(required);
|
||||
tool.setInputSchema(schema);
|
||||
|
||||
tools.append(tool);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
#include "KompanionQtServer.moc"
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <QtMcpServer/QMcpServer>
|
||||
#include <QtMcpCommon/QMcpTool>
|
||||
|
||||
#include "KomMcpServer.hpp"
|
||||
|
||||
#include <QList>
|
||||
|
||||
class KompanionQtServer : public QMcpServer
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
KompanionQtServer(const QString &backend, KomMcpServer *logic, QObject *parent = nullptr);
|
||||
|
||||
private:
|
||||
QList<QMcpTool> loadToolsFromSchema() const;
|
||||
|
||||
KomMcpServer *m_logic = nullptr;
|
||||
QList<QMcpTool> m_tools;
|
||||
};
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
#pragma once
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
class RateLimiter {
|
||||
public:
|
||||
// simple fixed window per tool
|
||||
bool allow(const std::string& tool, size_t maxPerWindow = 5, std::chrono::seconds window = std::chrono::seconds(1)) {
|
||||
using clock = std::chrono::steady_clock;
|
||||
auto now = clock::now();
|
||||
auto &state = buckets_[tool];
|
||||
if (now - state.windowStart > window) {
|
||||
state.windowStart = now;
|
||||
state.count = 0;
|
||||
}
|
||||
if (state.count >= maxPerWindow) return false;
|
||||
++state.count;
|
||||
return true;
|
||||
}
|
||||
private:
|
||||
struct Bucket { std::chrono::steady_clock::time_point windowStart{}; size_t count{0}; };
|
||||
std::unordered_map<std::string, Bucket> buckets_;
|
||||
};
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
#pragma once
|
||||
#include "KomMcpServer.hpp"
|
||||
#include "HandlersIntrospection.hpp"
|
||||
#include "HandlersLocalBackup.hpp"
|
||||
#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);
|
||||
server.registerTool("kom.local.v1.backup.import_encrypted", Handlers::backup_import_encrypted);
|
||||
server.registerTool("kom.meta.v1.project_snapshot", Handlers::project_snapshot);
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
|
||||
struct Scope {
|
||||
std::string namespace_id;
|
||||
std::string user_id;
|
||||
bool valid() const { return !namespace_id.empty(); }
|
||||
};
|
||||
|
||||
// In a real qtmcp server, these would be derived from session/auth.
|
||||
inline Scope currentScope() {
|
||||
// TODO: inject from session context / env. For now, return empty (invalid).
|
||||
return Scope{};
|
||||
}
|
||||
|
|
@ -1,472 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://kompanion.local/schemas/kompanion-tools.schema.json",
|
||||
"title": "Kompanion MCP Tool Manifest",
|
||||
"description": "Defines the tools exported by the Kompanion memory daemon.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"description": "Default namespace applied to relative tool names."
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"input": {
|
||||
"$ref": "#/$defs/jsonSchema"
|
||||
},
|
||||
"output": {
|
||||
"$ref": "#/$defs/jsonSchema"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"input",
|
||||
"output"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"namespace",
|
||||
"tools"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"stringList": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"jsonSchema": {
|
||||
"type": "object",
|
||||
"description": "A JSON Schema fragment describing tool input or output."
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"namespace": "kom.memory.v1",
|
||||
"tools": {
|
||||
"save_context": {
|
||||
"description": "Persist context payload in the namespace-backed memory store.",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"namespace": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {},
|
||||
"tags": {
|
||||
"$ref": "#/$defs/stringList"
|
||||
},
|
||||
"ttl_seconds": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"namespace",
|
||||
"content"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"created_at"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"recall_context": {
|
||||
"description": "Recall stored context entries filtered by key, tags, and time window.",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"namespace": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"tags": {
|
||||
"$ref": "#/$defs/stringList"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"since": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"namespace"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {},
|
||||
"tags": {
|
||||
"$ref": "#/$defs/stringList"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"content",
|
||||
"created_at"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"embed_text": {
|
||||
"description": "Return embedding vectors for provided text inputs.",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"texts": {
|
||||
"$ref": "#/$defs/stringList"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"texts"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"vectors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"model",
|
||||
"vectors"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"upsert_memory": {
|
||||
"description": "Upsert semantic memory items with optional precomputed embeddings.",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"namespace": {
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"embedding": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"namespace",
|
||||
"items"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"upserted": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"upserted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"search_memory": {
|
||||
"description": "Hybrid semantic search across stored memory chunks.",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"namespace": {
|
||||
"type": "string"
|
||||
},
|
||||
"query": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"embedding": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"k": {
|
||||
"type": "integer"
|
||||
},
|
||||
"filter": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"namespace",
|
||||
"query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"matches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"score": {
|
||||
"type": "number"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"score"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"matches"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"warm_cache": {
|
||||
"description": "Queue embedding warm-up jobs for recent namespace items.",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"namespace": {
|
||||
"type": "string"
|
||||
},
|
||||
"since": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"namespace"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queued": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"queued"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"kom.local.v1.backup.export_encrypted": {
|
||||
"description": "Queue an encrypted backup export for the requested namespaces.",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"namespaces": {
|
||||
"$ref": "#/$defs/stringList"
|
||||
},
|
||||
"destination": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"namespaces",
|
||||
"destination"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"artifact": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"artifact"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"kom.local.v1.backup.import_encrypted": {
|
||||
"description": "Import an encrypted backup artifact back into the local store.",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"kom.meta.v1.project_snapshot": {
|
||||
"description": "Produce a high-level project status snapshot for downstream MCP clients.",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"includeGitStatus": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"body": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"body"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"pg_dsn": {
|
||||
"type": "string"
|
||||
},
|
||||
"notes": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sections"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <sstream>
|
||||
|
||||
namespace Tools {
|
||||
|
||||
// Super-lightweight JSON helpers (NOT robust; only for stub/testing).
|
||||
inline std::string json_kv(const std::string& k, const std::string& v) {
|
||||
std::ostringstream os; os << "\"" << k << "\"\: \"" << v << "\""; return os.str();
|
||||
}
|
||||
inline std::string json_kv_num(const std::string& k, double v) {
|
||||
std::ostringstream os; os << "\"" << k << "\"\: " << v; return os.str();
|
||||
}
|
||||
inline std::string json_arr(const std::vector<std::string>& items) {
|
||||
std::ostringstream os; os << "[";
|
||||
for (size_t i=0;i<items.size();++i){ if(i) os<<","; os<<items[i]; }
|
||||
os << "]"; return os.str();
|
||||
}
|
||||
|
||||
// `ping` tool: echoes { ok: true, tools: [...] }
|
||||
inline std::string ping_response(const std::vector<std::string>& toolNames) {
|
||||
std::vector<std::string> quoted; quoted.reserve(toolNames.size());
|
||||
for (auto &t: toolNames) { std::ostringstream q; q << "\"" << t << "\""; quoted.push_back(q.str()); }
|
||||
std::ostringstream os;
|
||||
os << "{" << json_kv("status", "ok") << ", "
|
||||
<< "\"tools\": " << json_arr(quoted) << "}";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
// `embed_text` stub: returns zero vectors of dimension 8 for each input text
|
||||
inline std::string embed_text_stub(size_t n) {
|
||||
std::ostringstream os;
|
||||
os << "{\"model\":\"stub-embed-8d\",\"vectors\":[";
|
||||
for (size_t i=0;i<n;++i){ if(i) os<<","; os<<"[0,0,0,0,0,0,0,0]"; }
|
||||
os << "]}";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
} // namespace Tools
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <optional>
|
||||
|
||||
struct EmbedResult {
|
||||
std::string model;
|
||||
std::vector<std::vector<float>> vectors;
|
||||
};
|
||||
|
||||
class IEmbedder {
|
||||
public:
|
||||
virtual ~IEmbedder() = default;
|
||||
virtual EmbedResult embed(const std::vector<std::string>& texts, std::optional<std::string> model = std::nullopt) = 0;
|
||||
};
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
|
||||
struct MemoryItem {
|
||||
std::string id;
|
||||
std::string text;
|
||||
std::unordered_map<std::string, std::string> metadata;
|
||||
};
|
||||
|
||||
struct QueryFilter {
|
||||
std::unordered_map<std::string, std::string> equals;
|
||||
};
|
||||
|
||||
struct QueryResult {
|
||||
std::string id;
|
||||
float score;
|
||||
std::optional<std::string> text;
|
||||
std::unordered_map<std::string, std::string> metadata;
|
||||
};
|
||||
|
||||
class IVectorStore {
|
||||
public:
|
||||
virtual ~IVectorStore() = default;
|
||||
virtual size_t upsert(const std::string& nameSpace, const std::vector<MemoryItem>& items, const std::vector<std::vector<float>>* embeddings = nullptr) = 0;
|
||||
virtual std::vector<QueryResult> query(const std::string& nameSpace, const std::vector<float>& embedding, size_t k = 8, std::optional<QueryFilter> filter = std::nullopt) = 0;
|
||||
virtual bool remove(const std::string& nameSpace, const std::vector<std::string>& ids) = 0;
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# metal-kompanion-mcp
|
||||
MCP backend for Kompanion: memory/context/embedding provider over MCP, built from scratch (qtmcp-based) to persist conversation state and serve embeddings + retrieval to avoid forgetting across threads.
|
||||
|
||||
> ## 📈 Project Summary
|
||||
>
|
||||
> **✅ Done**: 1 | **🔄 In Progress**: 0 | **⬜ Todo**: 28 | **❌ Blocked**: 0
|
||||
>
|
||||
> **Progress**: 3% `█░░░░░░░░░░░░░░░░░░░` 1/29 tasks
|
||||
>
|
||||
> **Priorities**: 🚨 **Critical**: 0 | 🔴 **High**: 1 | 🟡 **Medium**: 29 | 🟢 **Low**: 0
|
||||
|
||||
## Tasks
|
||||
|
||||
| ID | Status | Priority | Title | Description |
|
||||
|:--:|:------:|:--------:|:------|:------------|
|
||||
| #1 | ⬜ todo | 700 | **Project Setup: metal-kompanion-mcp** | MCP backend for Kompanion: me... |
|
||||
| #2 | ⬜ in_progress | 500 | **Design MCP memory/context API** | Specify MCP tools for: save_c... |
|
||||
| #3 | ⬜ todo | 501 | **Select embedding backend & storage** | Choose between local (Ollama/... |
|
||||
| #4 | ✅ done | 499 | **Scaffold qtmcp-based server** | Set up C++/Qt MCP server skeleton with qtmcp backend. |
|
||||
| #5 | ⬜ todo | 502 | **Implement memory adapters** | Adapters: (1) SQLite+FAISS/pg... |
|
||||
| #6 | ⬜ todo | 498 | **Deep research: memory DB architecture & schema** | Survey best practices for con... |
|
||||
| #7 | ⬜ todo | 503 | **Decide primary DB: Postgres+pgvector vs SQLite+FAISS** | Evaluate tradeoffs (multi-use... |
|
||||
| #8 | ⬜ todo | 497 | **Implement DAL + migrations (pgvector)** | Create C++ DAL layer for name... |
|
||||
| #9 | ⬜ todo | 504 | **Add cloud DB hardening (RLS, FTS/trgm, ANN indexes)** | Implement RLS policies; add F... |
|
||||
| #10 | ⬜ todo | 496 | **Server enforcement: scope injection + rate limits** | Inject namespace/user via ses... |
|
||||
| #11 | ⬜ todo | 505 | **Redaction & sensitivity pipeline** | Implement preprocessing to de... |
|
||||
| #12 | ⬜ todo | 495 | **Private vault mode (key-only retrieval)** | Implement vault path for secr... |
|
||||
| #13 | ⬜ todo | 506 | **Local backup tools: export/import (E2EE)** | Add kom.local.v1.backup.expor... |
|
||||
| #14 | ⬜ todo | 494 | **Cloud adapters: backup/sync & payments stubs** | Expose kom.cloud.v1.backup.up... |
|
||||
| #15 | ⬜ todo | 507 | **Purge job & admin delete paths** | Implement scheduled hard-dele... |
|
||||
| #16 | ⬜ todo | 493 | **Test suite: privacy & hybrid search** | Cross-tenant leakage, redacti... |
|
||||
| #19 | ⬜ todo | 509 | **DAL skeleton + SQL calls (pgvector)** | Create DAL interfaces and pgv... |
|
||||
| #21 | ⬜ todo | 510 | **DAL Phase 1: Qt6/QSql wiring + SQL calls** | Use QPSQL via Qt6::Sql, implement PgDal ag... |
|
||||
| #22 | ⬜ todo | 490 | **Handlers → DAL integration** | Wire kom.memory.v1.upsert_mem... |
|
||||
| #23 | ⬜ todo | 511 | **Contract tests: DAL-backed tools** | Expand CTest to cover DAL-bac... |
|
||||
|
||||
|
||||
### Task #2: Design MCP memory/context API - Subtasks
|
||||
|
||||
| ID | Status | Title |
|
||||
|:--:|:------:|:------|
|
||||
| #2.1 | ⬜ todo | Write JSON Schemas for tools (done) |
|
||||
|
||||
|
||||
### Task #22: Handlers → DAL integration - Subtasks
|
||||
|
||||
| ID | Status | Title |
|
||||
|:--:|:------:|:------|
|
||||
| #22.1 | ⬜ todo | Replace ad-hoc JSON with parser (nlohmann/json or simdjson) |
|
||||
| #22.2 | ⬜ todo | Validate request bodies against schemas before DAL calls |
|
||||
| #22.3 | ⬜ todo | Scope & sensitivity enforcement (namespace/user + skip secret embeddings) |
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
add_executable(test_mcp_tools
|
||||
contract/test_mcp_tools.cpp
|
||||
)
|
||||
target_include_directories(test_mcp_tools PRIVATE ${PROJECT_SOURCE_DIR}/src)
|
||||
target_link_libraries(test_mcp_tools PRIVATE kom_dal)
|
||||
target_compile_options(test_mcp_tools PRIVATE -fexceptions)
|
||||
|
||||
add_test(NAME contract_mcp_tools COMMAND test_mcp_tools)
|
||||
|
||||
add_executable(contract_memory
|
||||
contract_memory.cpp
|
||||
)
|
||||
target_include_directories(contract_memory PRIVATE ${PROJECT_SOURCE_DIR}/src)
|
||||
target_link_libraries(contract_memory PRIVATE kom_dal)
|
||||
target_compile_options(contract_memory PRIVATE -fexceptions)
|
||||
|
||||
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)
|
||||
target_compile_options(test_memory_exchange PRIVATE -fexceptions)
|
||||
|
||||
add_test(NAME mcp_memory_exchange COMMAND test_memory_exchange)
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
#include <iostream>
|
||||
#include <string>
|
||||
#include "mcp/KomMcpServer.hpp"
|
||||
#include "mcp/RegisterTools.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
bool expect_contains(const std::string& haystack, const std::string& needle, const std::string& context) {
|
||||
if (haystack.find(needle) == std::string::npos) {
|
||||
std::cerr << "Expected response to contain '" << needle << "' but got:\n"
|
||||
<< haystack << "\nContext: " << context << "\n";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
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 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();
|
||||
auto endPos = upsertResp.find("\"", idsPos);
|
||||
if (endPos != std::string::npos) {
|
||||
firstId = upsertResp.substr(idsPos, endPos - idsPos);
|
||||
}
|
||||
}
|
||||
if (firstId.empty()) {
|
||||
std::cerr << "Failed to extract first item id from upsert response: " << upsertResp << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
const std::string searchReq = R"({"namespace":"tests","query":{"text":"hello","k":3}})";
|
||||
std::string searchResp = server.dispatch("kom.memory.v1.search_memory", searchReq);
|
||||
if (!expect_contains(searchResp, "\"matches\"", "search_memory matches key")) return 1;
|
||||
if (!expect_contains(searchResp, "\"text\":\"hello world\"", "search_memory returns stored text")) return 1;
|
||||
|
||||
const std::string vectorReq = R"({"namespace":"tests","query":{"embedding":[0.1,0.2,0.3],"k":1}})";
|
||||
std::string vectorResp = server.dispatch("kom.memory.v1.search_memory", vectorReq);
|
||||
if (!expect_contains(vectorResp, "\"id\":\""+firstId+"\"", "vector search returns stored id")) return 1;
|
||||
|
||||
const std::string snapshotResp = server.dispatch("kom.meta.v1.project_snapshot", "{}");
|
||||
if (!expect_contains(snapshotResp, "\"sections\"", "snapshot sections key")) return 1;
|
||||
|
||||
const std::string exportReq = R"({"namespace":"tests","destination":"/tmp/example.enc"})";
|
||||
std::string exportResp = server.dispatch("kom.local.v1.backup.export_encrypted", exportReq);
|
||||
if (!expect_contains(exportResp, "\"status\":\"queued\"", "export status")) return 1;
|
||||
if (!expect_contains(exportResp, "\"artifact\"", "export artifact path")) return 1;
|
||||
|
||||
const std::string importReq = R"({"namespace":"tests","source":"/tmp/example.enc"})";
|
||||
std::string importResp = server.dispatch("kom.local.v1.backup.import_encrypted", importReq);
|
||||
if (!expect_contains(importResp, "\"status\":\"ok\"", "import status")) return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
#include "dal/PgDal.hpp"
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
static void contract_pgdal_basic() {
|
||||
kom::PgDal dal;
|
||||
dal.connect("stub://memory");
|
||||
auto ns = dal.ensureNamespace("tests");
|
||||
static_cast<void>(ns);
|
||||
|
||||
kom::ItemRow item;
|
||||
item.namespace_id = "tests";
|
||||
item.text = std::string("example");
|
||||
item.tags = {"alpha", "beta"};
|
||||
item.id = dal.upsertItem(item);
|
||||
|
||||
kom::ChunkRow chunk;
|
||||
chunk.item_id = item.id;
|
||||
chunk.text = "chunk-text";
|
||||
auto chunkIds = dal.upsertChunks(std::vector<kom::ChunkRow>{chunk});
|
||||
if (!chunkIds.empty()) {
|
||||
chunk.id = chunkIds.front();
|
||||
}
|
||||
|
||||
kom::EmbeddingRow embedding;
|
||||
embedding.chunk_id = chunk.id;
|
||||
embedding.model = "stub-model";
|
||||
embedding.dim = 3;
|
||||
embedding.vector = {0.1f, 0.2f, 0.3f};
|
||||
dal.upsertEmbeddings(std::vector<kom::EmbeddingRow>{embedding});
|
||||
|
||||
static_cast<void>(dal.searchText("tests", "chunk", 5));
|
||||
static_cast<void>(dal.searchVector("tests", embedding.vector, 5));
|
||||
static_cast<void>(dal.getItemById(item.id));
|
||||
static_cast<void>(dal.hybridSearch(embedding.vector, "stub-model", "tests", "chunk", 5));
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
contract_pgdal_basic();
|
||||
std::cout << "contract_ok\n";
|
||||
return 0;
|
||||
} catch (const std::exception& ex) {
|
||||
std::cerr << "contract_memory failure: " << ex.what() << "\n";
|
||||
return 1;
|
||||
} catch (...) {
|
||||
std::cerr << "contract_memory failure: unknown error\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "mcp/KomMcpServer.hpp"
|
||||
#include "mcp/RegisterTools.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
bool expect_contains(const std::string& haystack, const std::string& needle, const std::string& context) {
|
||||
if (haystack.find(needle) == std::string::npos) {
|
||||
std::cerr << "[memory-exchange] Expected \"" << needle << "\" in " << context << " but got:\n"
|
||||
<< haystack << std::endl;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<std::string> extract_field(const std::string& json, const std::string& key) {
|
||||
const std::string pattern = "\"" + key + "\":\"";
|
||||
auto pos = json.find(pattern);
|
||||
if (pos == std::string::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
pos += pattern.size();
|
||||
auto end = json.find('"', pos);
|
||||
if (end == std::string::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return json.substr(pos, end - pos);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
KomMcpServer server;
|
||||
register_default_tools(server);
|
||||
|
||||
const std::string saveReq =
|
||||
R"({"namespace":"tests","key":"exchange-demo","content":"memory-exchange note","tags":["unit","memory"],"ttl_seconds":120})";
|
||||
std::string saveResp = server.dispatch("kom.memory.v1.save_context", saveReq);
|
||||
if (!expect_contains(saveResp, "\"id\"", "save_context response") ||
|
||||
!expect_contains(saveResp, "\"created_at\"", "save_context response")) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto savedId = extract_field(saveResp, "id");
|
||||
if (!savedId || savedId->empty()) {
|
||||
std::cerr << "[memory-exchange] Failed to parse id from save_context response\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
const std::string recallReq =
|
||||
R"({"namespace":"tests","key":"exchange-demo","limit":5})";
|
||||
std::string recallResp = server.dispatch("kom.memory.v1.recall_context", recallReq);
|
||||
|
||||
if (!expect_contains(recallResp, "\"items\"", "recall_context response") ||
|
||||
!expect_contains(recallResp, "\"memory-exchange note\"", "recall_context returns content")) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!savedId->empty() && !expect_contains(recallResp, *savedId, "recall_context preserves ids")) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const std::string searchReq =
|
||||
R"({"namespace":"tests","query":{"text":"memory-exchange","k":3}})";
|
||||
std::string searchResp = server.dispatch("kom.memory.v1.search_memory", searchReq);
|
||||
if (!expect_contains(searchResp, "\"matches\"", "search_memory response") ||
|
||||
!expect_contains(searchResp, "\"memory-exchange note\"", "search_memory finds saved text")) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"namespace": "project:metal",
|
||||
"query": {
|
||||
"text": "embedding model",
|
||||
"k": 5,
|
||||
"filter": {
|
||||
"thread": "embedding-comparison"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"namespace": "project:metal",
|
||||
"items": [
|
||||
{"id": "note-1", "text": "Embedding model comparison: takeaways...", "metadata": {"thread": "embedding-comparison"}}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
import os, sys, hashlib, psycopg, requests, json
|
||||
|
||||
DB=os.environ.get("DB_URL","dbname=kompanion user=kompanion host=/var/run/postgresq")
|
||||
OLLAMA=os.environ.get("OLLAMA_BASE","http://127.0.0.1:11434")
|
||||
MODEL=os.environ.get("EMBED_MODEL","mxbai-embed-large")
|
||||
SPACE=os.environ.get("EMBED_SPACE","dev_knowledge") # dev_knowledge | pattern_exchange | runtime_memory
|
||||
|
||||
def sha256(p):
|
||||
h=hashlib.sha256()
|
||||
with open(p,"rb") as f:
|
||||
for chunk in iter(lambda: f.read(1<<20), b""): h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
def embed(text):
|
||||
r=requests.post(f"{OLLAMA}/api/embeddings", json={"model": MODEL, "prompt": text}, timeout=120)
|
||||
r.raise_for_status(); return r.json()["embedding"]
|
||||
|
||||
def chunks(s, sz=1600):
|
||||
b=s.encode("utf-8");
|
||||
for i in range(0,len(b),sz): yield b[i:i+sz].decode("utf-8","ignore")
|
||||
|
||||
def insert_embedding(cur, dim, kid, sid, vec):
|
||||
if dim==768:
|
||||
cur.execute("INSERT INTO komp.embedding_768(chunk_id,space_id,embedding) VALUES(%s,%s,%s) ON CONFLICT DO NOTHING",(kid,sid,vec))
|
||||
elif dim==1024:
|
||||
cur.execute("INSERT INTO komp.embedding_1024(chunk_id,space_id,embedding) VALUES(%s,%s,%s) ON CONFLICT DO NOTHING",(kid,sid,vec))
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
def main(root):
|
||||
with psycopg.connect(DB) as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT id,dim FROM komp.space WHERE name=%s",(SPACE,))
|
||||
row=cur.fetchone()
|
||||
if not row: raise SystemExit(f"space {SPACE} missing (init schema)")
|
||||
sid, target_dim = row
|
||||
for dirpath,_,files in os.walk(root):
|
||||
for fn in files:
|
||||
p=os.path.join(dirpath,fn)
|
||||
if os.path.getsize(p)==0: continue
|
||||
# include common text/code; PDFs via pdftotext if available
|
||||
if not any(fn.lower().endswith(ext) for ext in (".md",".txt",".json",".py",".cpp",".c",".hpp",".yaml",".yml",".toml",".pdf",".mdown",".rst",".org",".js",".ts",".sql",".sh",".ini",".conf",".cfg",".log",".mime")):
|
||||
continue
|
||||
if fn.lower().endswith(".pdf"):
|
||||
try:
|
||||
txt=os.popen(f"pdftotext -layout -nopgbrk '{p}' - -q").read()
|
||||
except Exception:
|
||||
continue
|
||||
else:
|
||||
try: txt=open(p,"r",encoding="utf-8",errors="ignore").read()
|
||||
except Exception: continue
|
||||
sh=sha256(p)
|
||||
cur.execute("INSERT INTO komp.source(kind,uri,meta) VALUES(%s,%s,%s) ON CONFLICT DO NOTHING RETURNING id",
|
||||
("filesystem",p,json.dumps({})))
|
||||
sid_src = cur.fetchone()[0] if cur.rowcount else None
|
||||
if not sid_src:
|
||||
cur.execute("SELECT id FROM komp.source WHERE kind='filesystem' AND uri=%s",(p,))
|
||||
sid_src=cur.fetchone()[0]
|
||||
ln=1
|
||||
for ch in chunks(txt):
|
||||
cur.execute("INSERT INTO komp.chunk(source_id,lineno,text,sha256,tokens) VALUES(%s,%s,%s,%s,%s) RETURNING id",
|
||||
(sid_src,ln,ch,sh,len(ch)//4))
|
||||
kid=cur.fetchone()[0]
|
||||
vec=embed(ch)
|
||||
if len(vec)!=target_dim:
|
||||
cur.execute("DELETE FROM komp.chunk WHERE id=%s",(kid,))
|
||||
else:
|
||||
insert_embedding(cur, target_dim, kid, sid, vec)
|
||||
ln += ch.count("\n")+1
|
||||
conn.commit()
|
||||
print("done")
|
||||
|
||||
if __name__=='__main__':
|
||||
if len(sys.argv)<2: print("usage: ingest_dir.py <root> [space]", file=sys.stderr); sys.exit(1)
|
||||
if len(sys.argv)>=3: os.environ["EMBED_SPACE"]=sys.argv[2]
|
||||
main(sys.argv[1])
|
||||
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
import os, sys, json, requests, psycopg
|
||||
|
||||
DB=os.environ.get("DB_URL","dbname=kompanion user=kompanion host=/var/run/postgresql")
|
||||
OLLAMA=os.environ.get("OLLAMA_BASE","http://127.0.0.1:11434")
|
||||
MODEL=os.environ.get("EMBED_MODEL","mxbai-embed-large")
|
||||
SPACE=os.environ.get("EMBED_SPACE","dev_knowledge")
|
||||
|
||||
HELP="""\
|
||||
Usage: pg_search.py "query text" [k]
|
||||
Env: DB_URL, OLLAMA_BASE, EMBED_MODEL, EMBED_SPACE (default dev_knowledge)
|
||||
Prints JSON results: [{score, uri, lineno, text}].
|
||||
"""
|
||||
|
||||
def embed(q: str):
|
||||
r = requests.post(f"{OLLAMA}/api/embeddings", json={"model": MODEL, "prompt": q}, timeout=120)
|
||||
r.raise_for_status()
|
||||
return r.json()["embedding"]
|
||||
|
||||
if __name__=="__main__":
|
||||
if len(sys.argv)<2:
|
||||
print(HELP, file=sys.stderr); sys.exit(1)
|
||||
query = sys.argv[1]
|
||||
k = int(sys.argv[2]) if len(sys.argv)>2 else 8
|
||||
vec = embed(query)
|
||||
with psycopg.connect(DB) as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT id, dim FROM komp.space WHERE name=%s", (SPACE,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
sys.exit(f"space {SPACE} missing")
|
||||
sid, dim = row
|
||||
if dim not in (768,1024):
|
||||
sys.exit(f"unsupported dim {dim}")
|
||||
table = f"komp.embedding_{dim}"
|
||||
# cosine distance with vector_cosine_ops
|
||||
sql = f"""
|
||||
SELECT (e.embedding <=> %(v)s::vector) AS score, s.uri, k.lineno, k.text
|
||||
FROM {table} e
|
||||
JOIN komp.chunk k ON k.id = e.chunk_id
|
||||
JOIN komp.source s ON s.id = k.source_id
|
||||
WHERE e.space_id = %(sid)s
|
||||
ORDER BY e.embedding <=> %(v)s::vector
|
||||
LIMIT %(k)s
|
||||
"""
|
||||
cur.execute(sql, {"v": vec, "sid": sid, "k": k})
|
||||
out=[{"score":float(r[0]),"uri":r[1],"lineno":r[2],"text":r[3]} for r in cur.fetchall()]
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
|
|
@ -1 +0,0 @@
|
|||
psycopg
|
||||
Loading…
Reference in New Issue