From 53a1a043c7a4308234996feac0d360f63d0569f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=A7=CE=B3=CF=86=CF=84=20Kompanion?= Date: Wed, 15 Oct 2025 12:07:21 +1300 Subject: [PATCH] Add Kconfig and more tools --- CMakeLists.txt | 44 ++++++++++ docs/memory-architecture.md | 2 + src/main.cpp | 42 +++++++++- src/mcp/HandlersIntrospection.hpp | 135 ++++++++++++++++++++++++++++++ src/mcp/RegisterTools.hpp | 2 + src/mcp/ToolSchemas.json | 30 +++++++ tests/contract/test_mcp_tools.cpp | 3 + 7 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 src/mcp/HandlersIntrospection.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 559ae1f..5a02469 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,30 @@ endif() set(KOM_HAVE_PG ${KOM_HAVE_PG} CACHE INTERNAL "kom_dal has Postgres backend") +set(KOMPANION_KCONFIG_TARGET "") +find_package(KF6Config QUIET) +if (KF6Config_FOUND) + set(KOMPANION_KCONFIG_TARGET KF6::ConfigCore) +else() + find_package(KF5Config QUIET) + if (KF5Config_FOUND) + set(KOMPANION_KCONFIG_TARGET KF5::ConfigCore) + endif() +endif() + +if (KOMPANION_KCONFIG_TARGET STREQUAL "") + message(WARNING "KConfig (KF6/KF5) not found; defaulting to environment-based configuration.") +endif() + +option(BUILD_KOMPANION_CLI "Build Kompanion Qt command-line client" ON) +if (BUILD_KOMPANION_CLI) + find_package(Qt6 COMPONENTS Core QUIET) + if (NOT Qt6_FOUND) + message(WARNING "Qt6 Core not found; disabling Kompanion CLI.") + set(BUILD_KOMPANION_CLI OFF) + endif() +endif() + # Placeholder: find Qt and qtmcp when available # find_package(Qt6 COMPONENTS Core Network REQUIRED) # find_package(qtmcp REQUIRED) @@ -34,10 +58,30 @@ add_executable(kom_mcp ) target_include_directories(kom_mcp PRIVATE src) target_link_libraries(kom_mcp PRIVATE kom_dal) +target_compile_definitions(kom_mcp PRIVATE PROJECT_SOURCE_DIR="${CMAKE_SOURCE_DIR}") +if (NOT KOMPANION_KCONFIG_TARGET STREQUAL "") + target_link_libraries(kom_mcp PRIVATE ${KOMPANION_KCONFIG_TARGET}) + target_compile_definitions(kom_mcp PRIVATE HAVE_KCONFIG) +endif() install(TARGETS kom_mcp RUNTIME DESTINATION bin) option(BUILD_TESTS "Build tests" ON) + +if (BUILD_KOMPANION_CLI) + add_executable(kompanion + src/cli/KompanionApp.cpp + ) + target_include_directories(kompanion PRIVATE src) + target_link_libraries(kompanion PRIVATE Qt6::Core kom_dal) + target_compile_definitions(kompanion PRIVATE PROJECT_SOURCE_DIR="${CMAKE_SOURCE_DIR}") + if (NOT KOMPANION_KCONFIG_TARGET STREQUAL "") + target_link_libraries(kompanion PRIVATE ${KOMPANION_KCONFIG_TARGET}) + target_compile_definitions(kompanion PRIVATE HAVE_KCONFIG) + endif() + install(TARGETS kompanion RUNTIME DESTINATION bin) +endif() + if (BUILD_TESTS) enable_testing() add_subdirectory(tests) diff --git a/docs/memory-architecture.md b/docs/memory-architecture.md index 6e91ac9..399897c 100644 --- a/docs/memory-architecture.md +++ b/docs/memory-architecture.md @@ -71,6 +71,8 @@ - **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. ## Next-Step Checklist - [x] Detect pqxx via CMake and plumb `HAVE_PG`. diff --git a/src/main.cpp b/src/main.cpp index c17666d..50072fd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,9 +1,17 @@ // Minimal CLI runner that registers Kompanion MCP tools and dispatches requests. +#include #include #include #include +#include #include #include + +#ifdef HAVE_KCONFIG +#include +#include +#endif + #include "mcp/KomMcpServer.hpp" #include "mcp/RegisterTools.hpp" @@ -42,13 +50,40 @@ void print_usage(const char* exe, KomMcpServer& server) { } // namespace +#ifdef HAVE_KCONFIG +std::optional 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 +std::optional read_dsn_from_config() { + return std::nullopt; +} +#endif + int main(int argc, char** argv) { KomMcpServer server; register_default_tools(server); - const char* pgDsn = std::getenv("PG_DSN"); - if (!pgDsn || !*pgDsn) { - std::cerr << "[kom_mcp] PG_DSN not set; fallback DAL will be used if available.\n"; + const char* envDsn = std::getenv("PG_DSN"); + std::optional 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; fallback DAL will be used if available (configure Database/PgDsn via KConfig).\n"; } if (argc < 2) { @@ -80,4 +115,3 @@ int main(int argc, char** argv) { return 1; } } -#include diff --git a/src/mcp/HandlersIntrospection.hpp b/src/mcp/HandlersIntrospection.hpp new file mode 100644 index 0000000..3e41ae2 --- /dev/null +++ b/src/mcp/HandlersIntrospection.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(c) < 0x20) { + os << "\\u" << std::hex << std::uppercase << static_cast(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(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 + std::unique_ptr pipe(popen(cmd, "r"), pclose); + 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(maxBytes - (total - len))); + break; + } + oss.write(buffer, static_cast(len)); + } + return oss.str(); +#endif +} + +inline std::optional 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 diff --git a/src/mcp/RegisterTools.hpp b/src/mcp/RegisterTools.hpp index 3a5e7d0..2251099 100644 --- a/src/mcp/RegisterTools.hpp +++ b/src/mcp/RegisterTools.hpp @@ -1,5 +1,6 @@ #pragma once #include "KomMcpServer.hpp" +#include "HandlersIntrospection.hpp" #include "HandlersLocalBackup.hpp" #include "HandlersMemory.hpp" @@ -8,4 +9,5 @@ inline void register_default_tools(KomMcpServer& server) { 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); } diff --git a/src/mcp/ToolSchemas.json b/src/mcp/ToolSchemas.json index 8c287ae..00dc98f 100644 --- a/src/mcp/ToolSchemas.json +++ b/src/mcp/ToolSchemas.json @@ -192,5 +192,35 @@ "required": ["status"] } } + , + + "kom.meta.v1.project_snapshot": { + "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"] + } + }, + "pg_dsn": {"type": "string"}, + "notes": {"type": "string"} + }, + "required": ["sections"] + } + } } } diff --git a/tests/contract/test_mcp_tools.cpp b/tests/contract/test_mcp_tools.cpp index 11a3ca4..d69e412 100644 --- a/tests/contract/test_mcp_tools.cpp +++ b/tests/contract/test_mcp_tools.cpp @@ -49,6 +49,9 @@ int main() { 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;