Add missing files
This commit is contained in:
parent
70848fda6e
commit
db01fb0485
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue