diff --git a/doc/ideas.txt b/doc/ideas.txt new file mode 100644 index 0000000..230b41b --- /dev/null +++ b/doc/ideas.txt @@ -0,0 +1,8 @@ +## 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. diff --git a/integrations/js/README.md b/integrations/js/README.md new file mode 100644 index 0000000..14ec82d --- /dev/null +++ b/integrations/js/README.md @@ -0,0 +1,41 @@ +# JavaScript Bridges + +This folder contains JavaScript helpers that talk to the Kompanion MCP runtime. +They are intended for quick prototyping – copy the files into a Node.js project +and adjust them to your local workflow. + +## Prerequisites + +* Node.js 18+ +* `kom_mcp` built from this repository (`cmake --build build --target kom_mcp`) +* Optional: `PG_DSN` environment variable exported so Kompanion can reach your personal database. + +## Usage + +1. Install dependencies. + + ```bash + npm install + ``` + + (The helper only uses built-in Node modules, but this sets up a working package.json.) + +2. Run the demo to save and recall memory via MCP: + + ```bash + node demoMemoryExchange.js + ``` + +3. Wire the exported helpers into your own automation. The module exposes + `saveContext`, `recallContext`, and `searchMemory`, each returning a parsed + JSON object. + +## Connecting to the Personal Database + +The helper shells out to the `kom_mcp` CLI, so all database access flows through +Kompanion’s DAL. As long as the CLI can reach Postgres (or the in-memory stub), +JavaScript code automatically benefits from the same storage layer and policy. + +If you need raw SQL access, you can extend the module with `pg` or any other +driver – this scaffolding is kept simple on purpose so it works out-of-the-box +without additional dependencies. diff --git a/integrations/js/demoMemoryExchange.js b/integrations/js/demoMemoryExchange.js new file mode 100644 index 0000000..49ae39b --- /dev/null +++ b/integrations/js/demoMemoryExchange.js @@ -0,0 +1,30 @@ +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); +}); diff --git a/integrations/js/kompanionMemoryClient.js b/integrations/js/kompanionMemoryClient.js new file mode 100644 index 0000000..afa5488 --- /dev/null +++ b/integrations/js/kompanionMemoryClient.js @@ -0,0 +1,49 @@ +/** + * 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 +}; diff --git a/integrations/js/package.json b/integrations/js/package.json new file mode 100644 index 0000000..7953dc3 --- /dev/null +++ b/integrations/js/package.json @@ -0,0 +1,11 @@ +{ + "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": {} +} diff --git a/integrations/konsole/KompanionKonsolePlugin/CMakeLists.txt b/integrations/konsole/KompanionKonsolePlugin/CMakeLists.txt new file mode 100644 index 0000000..1be8c57 --- /dev/null +++ b/integrations/konsole/KompanionKonsolePlugin/CMakeLists.txt @@ -0,0 +1,19 @@ +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 +) diff --git a/integrations/konsole/KompanionKonsolePlugin/kompanion_konsole.in.json b/integrations/konsole/KompanionKonsolePlugin/kompanion_konsole.in.json new file mode 100644 index 0000000..5013262 --- /dev/null +++ b/integrations/konsole/KompanionKonsolePlugin/kompanion_konsole.in.json @@ -0,0 +1,17 @@ +{ + "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" +} diff --git a/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.cpp b/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.cpp new file mode 100644 index 0000000..dd2e91d --- /dev/null +++ b/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.cpp @@ -0,0 +1,58 @@ +#include "kompanionagentpanel.h" + +#include + +#include +#include +#include + +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" diff --git a/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.h b/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.h new file mode 100644 index 0000000..afaf8e6 --- /dev/null +++ b/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +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; +}; diff --git a/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.cpp b/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.cpp new file mode 100644 index 0000000..0b91e20 --- /dev/null +++ b/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.cpp @@ -0,0 +1,130 @@ +#include "kompanionkonsoleplugin.h" + +#include "kompanionagentpanel.h" + +#include "MainWindow.h" +#include "profile/ProfileManager.h" +#include "session/Session.h" +#include "session/SessionController.h" + +#include +#include + +#include +#include +#include +#include +#include + +K_PLUGIN_CLASS_WITH_JSON(KompanionKonsolePlugin, "kompanion_konsole.json") + +struct KompanionKonsolePlugin::Private { + struct WindowUi { + QPointer dock; + QPointer panel; + }; + + QHash uiPerWindow; + QPointer 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()) +{ + 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 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" diff --git a/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.h b/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.h new file mode 100644 index 0000000..21c4841 --- /dev/null +++ b/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include + +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 menuBarActions(Konsole::MainWindow *mainWindow) const override; + +private: + struct Private; + std::unique_ptr d; +}; diff --git a/integrations/konsole/README.md b/integrations/konsole/README.md new file mode 100644 index 0000000..9f93fa1 --- /dev/null +++ b/integrations/konsole/README.md @@ -0,0 +1,51 @@ +# Kompanion ⇄ Konsole Bridge + +This directory contains the first draft of **Kompanion‑Konsole**, a plugin for KDE's +Konsole terminal emulator. The plugin gives Kompanion agents a controlled way to +step into a Konsole tab, using the same Kompanion core that powers the MCP back end. + +## Layout + +``` +KompanionKonsolePlugin/ # Drop-in plugin sources (mirrors Konsole/src/plugins layout) +``` + +The code is intentionally structured so it can live directly inside the Konsole +source tree (e.g. under `konsole/src/plugins`). You can keep developing it here +and then symlink/copy it into a Konsole checkout when you are ready to compile. + +## Quick start (demo) + +1. Ensure you have a Konsole checkout (see `/mnt/bulk/shared/kdesrc/konsole`). +2. From the Konsole repo, link the plugin: + + ```bash + ln -s /home/kompanion/dev/metal/src/metal-kompanion/integrations/konsole/KompanionKonsolePlugin \ + src/plugins/KompanionKonsole + echo "add_subdirectory(KompanionKonsole)" >> src/plugins/CMakeLists.txt + ``` + +3. Reconfigure Konsole with CMake; build the `konsole_kompanionplugin` target. + + ```bash + cmake -S . -B build + cmake --build build --target konsole_kompanionplugin + ``` + +4. Launch the newly built Konsole. Open *Plugins → Kompanion Konsole Bridge* to + toggle the dock and use the **Launch Demo Agent Shell** or **Attach Active Tab** + buttons to hand the tab over to Kompanion. + +The demo simply injects `kom_mcp --list` into the tab and prints a coloured banner. +Later iterations will replace this with the minimal TTY protocol described in the +roadmap. + +## Notes + +- The plugin depends on the in-tree `konsoleprivate` and `konsoleapp` targets, so it + currently builds only alongside the Konsole sources. +- Strings are translated via `KLocalizedString`, and actions are registered with the + Konsole action collection so shortcuts can be customised. +- All agent‑facing commands are placeholder stubs; they go through Kompanion's CLI + entry points so real migrations can swap in more capable bridges without touching + the KDE plugin scaffolding. diff --git a/src/cli/KompanionApp.cpp b/src/cli/KompanionApp.cpp new file mode 100644 index 0000000..6a79650 --- /dev/null +++ b/src/cli/KompanionApp.cpp @@ -0,0 +1,773 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef HAVE_KCONFIG +#include +#include +#else +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 schemaDirectories() { + std::vector 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 collectSchemaFiles() { + std::vector 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 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 +} + +std::string readFileUtf8(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + throw std::runtime_error(QString("Unable to open request file: %1").arg(path).toStdString()); + } + const QByteArray data = file.readAll(); + return QString::fromUtf8(data).toStdString(); +} + +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& 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(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) { + QSqlQuery query(db); + if (!query.exec(QStringLiteral("SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='memory_items')"))) { + throw std::runtime_error(query.lastError().text().toStdString()); + } + if (!query.next()) return false; + return query.value(0).toBool(); +} + +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(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 success = false; + try { + if (schemaExists(db)) { + if (verbose) out << "Schema already present.\n"; + success = true; + } + if (!success) { + out << "Schema not found; applying migrations...\n"; + if (applySchemaFiles(db, out, verbose) && schemaExists(db)) { + out << "Schema initialized successfully.\n"; + success = true; + } else { + out << "Schema still missing after applying migrations.\n"; + success = false; + } + } + } catch (const std::exception& ex) { + out << "Failed to ensure schema: " << ex.what() << "\n"; + } + db.close(); + db = QSqlDatabase(); + QSqlDatabase::removeDatabase(connName); + return success; +} + +std::optional 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(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(c) < 0x20) { + char buffer[7]; + std::snprintf(buffer, sizeof(buffer), "\\u%04x", static_cast(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 ` 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(); + } + + try { + const std::string response = server.dispatch(toolName, payload); + if (verbose) { + out << "[response] " << QString::fromStdString(response) << "\n"; + } else { + out << QString::fromStdString(response) << "\n"; + } + } catch (const std::exception& ex) { + out << "Error: " << ex.what() << "\n"; + } + } + return 0; +} +std::string resolveRequestPayload(const QCommandLineParser& parser, + const QStringList& positional, + const QCommandLineOption& requestOption, + const QCommandLineOption& stdinOption) { + if (parser.isSet(stdinOption)) { + return readAll(std::cin); + } + + if (parser.isSet(requestOption)) { + const QString arg = parser.value(requestOption); + if (arg == "-" || parser.isSet(stdinOption)) { + return readAll(std::cin); + } + if (looksLikeFile(arg)) { + return readFileUtf8(arg); + } + return arg.toStdString(); + } + + if (positional.size() > 1) { + const QString arg = positional.at(1); + if (arg == "-") { + return readAll(std::cin); + } + if (looksLikeFile(arg)) { + return readFileUtf8(arg); + } + return arg.toStdString(); + } + + return "{}"; +} + +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 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; + try { + request = resolveRequestPayload(parser, positional, requestOption, stdinOption); + } catch (const std::exception& ex) { + std::cerr << "Error: " << ex.what() << "\n"; + return 1; + } + + try { + 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; + } catch (const std::exception& ex) { + std::cerr << "Error: " << ex.what() << "\n"; + } + return 1; +} diff --git a/tests/mcp/test_memory_exchange.cpp b/tests/mcp/test_memory_exchange.cpp new file mode 100644 index 0000000..4a560fb --- /dev/null +++ b/tests/mcp/test_memory_exchange.cpp @@ -0,0 +1,75 @@ +#include +#include +#include + +#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 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; +}