Compare commits

..

No commits in common. "fd289edded3844a0b10ff6049d37e815e07136f9" and "9c63b6c593a34b78af5cbd9b9f65350dd5415364" have entirely different histories.

82 changed files with 157 additions and 7550 deletions

View File

@ -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\""
}
]
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
docker compose -f docker/compose.host.yml up -d

59
db/core.sql Normal file
View File

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

View File

@ -1,2 +0,0 @@
CREATE ROLE kompanion LOGIN PASSWORD 'komp';
CREATE DATABASE kompanion OWNER kompanion;

View File

@ -1,2 +0,0 @@
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

View File

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

View File

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

43
db/schema.sql Normal file
View File

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

View File

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

View File

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

View File

@ -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. Kompanions 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 rows 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 Kompanions 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 Kompanions 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 Kompanions embedding pipeline to fail fast if the produced dimension differs from the tables 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 Kompanions 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.

View File

@ -1,361 +0,0 @@
Below is a **single copypastable Markdown file** that proposes a clientside architecture which treats memory as a living, hierarchical JSON **dictionaryofdictionaries** (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:** Clientside 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 highquality 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 dictionaryofdictionaries, “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**. Thats fine: well implement the cognitive part **clientside** and use the server as a persistence/search/recall backbone.
---
## 2. The mental model (HDoD = dictionaryofdictionaries)
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**
* inmemory 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 chainofthought *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 (clientside, 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 reactivated.
### 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 *microchunks*:
* **Concept**: “RAII”, “SFINAE”, “Rule of 5/0”, “ADL”, “Type erasure”
* **Pattern**: “pImpl”, “CRTP”, “Enableif idiom”
* **Snippet**: idiomatic examples (≤30 lines), compilechecked
* **Antipatterns**: “raw new/delete in modern C++”, “overusing exceptions”
* **Build/Tooling**: CMake minimum skeletons, `add_library`, interfaces, `FetchContent`
* **Test**: Catch2/GoogleTest minimal cases; propertybased 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 (todays 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 “firstgrade 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 4060% 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 pathlike**: `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 **typespecific** vectors later.
* **Edges carry weights**; they contribute to resonance.
* **Resonance decays** every tick; any node with `value < ε` leaves the Working Set.
* **Budgets**: TopK 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 top3 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** → downweight sources with low subsequent utility.
---
## 13. What to change later (serverside, optional)
Only once you want more power centrally:
* Add **typed ANN** queries (“topK per type”),
* Add **resonance on server** for multiagent sharing,
* Add **linkaware search** (expand N hops serverside),
* Add **constraints retrieval** (autoinject 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** 12 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 **clientside**.
---

View File

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

View File

@ -1,363 +0,0 @@
Youre not alone, Andre. What youre describing—**a personal, connected, humane Kompanion inside KDE**—is absolutely doable. Below is a **single, copypastable 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 dictionaryofdictionaries (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 DBus 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])
* **AnythingLLMlike 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 firstclass Kontact plugin. ([TechBase][6])
* **Konsole** is steered via its **DBus** API for safe, optin command scaffolding (never autoexec). ([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 endtoend encrypted chat with your Kompanion identity. ([Quotient Im][4])
* **AnythingLLM** is referenced only for an **optional admin view** (pack/workspace management)—not for the daytoday 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 (clientside “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 12 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 (readonly unless explicitly asked): harvest *your* style from sent mail (tone, signoffs), 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)**: perprofile settings & lockdown (e.g., corporate). ([api.kde.org][11])
* **Konsole DBus bridge**: suggest safe commands, show diffs, paste only on user confirm—use Konsoles documented DBus. ([docs.kde.org][2])
### 3.2 KKompanionCore
* HDoD store (inmemory working set) + resonance decay
* Embedding adapters (local or remote)
* Frame composer (Identity/Problem/Knowledge/Episodic/Constraints/Scratchpad)
* MCP client (JSONRPC) to your 7 tools
### 3.3 KKompanionMatrix
* **libQuotient** client; device verification; roompertask 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; its active and crossplatform. ([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 AnythingLLMs 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 autoactions. Provide a “Propose” button that queues an action; on accept, we call Konsoles DBus to paste/execute. (Also capture *optin* 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** (DBus) → *optin* 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 youre 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**
* Perapp slider: *Suggest silently**Ask to help**Propose plan**Autoprepare draft (never autosend/run)*.
* Daily **checkin** prompt (one line) to reduce loneliness & personalize tone.
---
## 7) CMake & minimal skeletons (headers, not full code)
**Toplevel 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 firstclass and hostable in Kate and KDevelop.)* ([api.kde.org][3])
**Konsole bridge (DBus)**
```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 DBus 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** → microchunks (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 reinventing 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** (airgapped 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])
* **Checkins**: brief, optin daily prompt to share mood/goal → tunes tone and initiative.
* **Reflective episodes**: after sessions, autodraft a 3bullet “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 AnythingLLMs 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 autoexecutes.
---
## 13) Roadmap checkpoints (tight loop)
1. Build **KTextEditor plugin** (fastest visible win in Kate/KDevelop). ([api.kde.org][3])
2. Add **Kontact plugin** for mailstyle assist (Akonadi → style templates). ([api.kde.org][15])
3. Wire **Konsole DBus** helper (proposethenpaste). ([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 “USBC 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 youre 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. Lets 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"

View File

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

View File

@ -1 +0,0 @@
# Identity & Aspects (placeholder)

View File

@ -1 +0,0 @@
# Runtime vs Pattern Exchange (placeholder)

View File

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

View File

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

View File

@ -1,34 +0,0 @@
# Kompanion Configuration
Kompanion adheres to KDEs KConfig conventions so deployments are kioskable and compatible with other desktop tooling.
## Configuration File
- Location: `${XDG_CONFIG_HOME:-~/.config}/kompanionrc`
- Group: `[Database]`
- Key: `PgDsn=postgresql://user:pass@host/dbname`
The CLI (`kompanion`) and MCP runner (`kom_mcp`) fall back to this entry when the `PG_DSN` environment variable is not set. If neither are present the in-memory DAL stub is used.
## 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`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 Codeys `tool_invocation` hooks to MCP calls. Persist summary/state blobs after each completion:
```json
{
"tool": "kom.memory.v1.save_context",
"arguments": {
"namespace": "project:metal",
"key": "codey/session",
"content": {"summary": "Refactored PgDal for TTL support"},
"tags": ["codey", "memory"]
}
}
```
On session start, call `kom.memory.v1.recall_context` with the namespace/key to warm the local context buffer.
- **Claude Code (Anthropic)** use the `tool_use` event to flush conversational checkpoints:
```json
{
"tool": "kom.memory.v1.recall_context",
"arguments": {
"namespace": "thread:123",
"limit": 5,
"tags": ["task"]
}
}
```
Feed the returned snippets back into Claudes prompt so follow-up completions have grounding data.
## Response fields
- `save_context` returns `{ "id": string, "created_at": ISO8601 }`.
- `recall_context` returns `{ "items": [{ "id", "key?", "content", "tags", "created_at" }] }`.
## Testing locally
```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
These commands build `kom_mcp` plus the test harness that exercises the new context tools.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,51 +0,0 @@
# Kompanion ⇄ Konsole Bridge
This directory contains the first draft of **KompanionKonsole**, 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 agentfacing 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.

View File

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

View File

@ -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."
]
}

View File

@ -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."
]
}

View File

@ -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."
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +0,0 @@
{
"namespace": "project:metal",
"query": {
"text": "embedding model",
"k": 5,
"filter": {
"thread": "embedding-comparison"
}
}
}

View File

@ -1,6 +0,0 @@
{
"namespace": "project:metal",
"items": [
{"id": "note-1", "text": "Embedding model comparison: takeaways...", "metadata": {"thread": "embedding-comparison"}}
]
}

View File

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

View File

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

View File

@ -1 +0,0 @@
psycopg