Add missing files

This commit is contained in:
Χγφτ Kompanion 2025-10-16 03:46:06 +13:00
parent 70848fda6e
commit db01fb0485
14 changed files with 1317 additions and 0 deletions

8
doc/ideas.txt Normal file
View File

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

41
integrations/js/README.md Normal file
View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,58 @@
#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

@ -0,0 +1,25 @@
#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

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

@ -0,0 +1,30 @@
#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

@ -0,0 +1,51 @@
# 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.

773
src/cli/KompanionApp.cpp Normal file
View File

@ -0,0 +1,773 @@
#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
}
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<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) {
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<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 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<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();
}
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<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;
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;
}

View File

@ -0,0 +1,75 @@
#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;
}