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