diff --git a/doc/ideas.txt b/doc/ideas.txt
new file mode 100644
index 0000000..230b41b
--- /dev/null
+++ b/doc/ideas.txt
@@ -0,0 +1,8 @@
+## Prompt Markers Embedded in Shell Commands
+
+- Keep Konsole usage unchanged: the human runs commands normally, the Kompanion agent watches and journals in the background.
+- Introduce a lightweight marker syntax to flag prompts for the agent without leaving the terminal context.
+  - Example marker: the `§` character wrapping a phrase, e.g. `§"mermaid, tell me a story"` or `> §(good editor for go)`.
+  - When the agent sees a marker, it interprets the enclosed text as an LLM-style instruction and can respond or take action.
+- Markers can be mixed with actual commands, e.g. `echo $(gpg --agent --daemon)` followed by `§"generate a deployment checklist"`.
+- Future work: define how the bridge detects markers in real time, how responses are surfaced (inline vs. side panel), and how to opt-in/out per session.
diff --git a/integrations/js/README.md b/integrations/js/README.md
new file mode 100644
index 0000000..14ec82d
--- /dev/null
+++ b/integrations/js/README.md
@@ -0,0 +1,41 @@
+# JavaScript Bridges
+
+This folder contains JavaScript helpers that talk to the Kompanion MCP runtime.
+They are intended for quick prototyping – copy the files into a Node.js project
+and adjust them to your local workflow.
+
+## Prerequisites
+
+* Node.js 18+
+* `kom_mcp` built from this repository (`cmake --build build --target kom_mcp`)
+* Optional: `PG_DSN` environment variable exported so Kompanion can reach your personal database.
+
+## Usage
+
+1. Install dependencies.
+
+   ```bash
+   npm install
+   ```
+
+   (The helper only uses built-in Node modules, but this sets up a working package.json.)
+
+2. Run the demo to save and recall memory via MCP:
+
+   ```bash
+   node demoMemoryExchange.js
+   ```
+
+3. Wire the exported helpers into your own automation.  The module exposes
+   `saveContext`, `recallContext`, and `searchMemory`, each returning a parsed
+   JSON object.
+
+## Connecting to the Personal Database
+
+The helper shells out to the `kom_mcp` CLI, so all database access flows through
+Kompanion’s DAL.  As long as the CLI can reach Postgres (or the in-memory stub),
+JavaScript code automatically benefits from the same storage layer and policy.
+
+If you need raw SQL access, you can extend the module with `pg` or any other
+driver – this scaffolding is kept simple on purpose so it works out-of-the-box
+without additional dependencies.
diff --git a/integrations/js/demoMemoryExchange.js b/integrations/js/demoMemoryExchange.js
new file mode 100644
index 0000000..49ae39b
--- /dev/null
+++ b/integrations/js/demoMemoryExchange.js
@@ -0,0 +1,30 @@
+import { saveContext, recallContext, searchMemory } from './kompanionMemoryClient.js';
+
+async function main() {
+  const namespace = 'js-demo';
+
+  const savePayload = {
+    namespace,
+    key: 'spotify-intent',
+    content: {
+      track: 'Example Song',
+      artist: 'Imaginary Band',
+      note: 'Captured via Node.js helper'
+    },
+    tags: ['javascript', 'demo']
+  };
+
+  const saved = saveContext(savePayload);
+  console.log('[kompanion-js] save_context result:', saved);
+
+  const recall = recallContext({ namespace, key: 'spotify-intent', limit: 3 });
+  console.log('[kompanion-js] recall_context result:', recall);
+
+  const search = searchMemory({ namespace, query: { text: 'Node.js helper', k: 5 } });
+  console.log('[kompanion-js] search_memory result:', search);
+}
+
+main().catch(err => {
+  console.error('[kompanion-js] demo failed:', err);
+  process.exit(1);
+});
diff --git a/integrations/js/kompanionMemoryClient.js b/integrations/js/kompanionMemoryClient.js
new file mode 100644
index 0000000..afa5488
--- /dev/null
+++ b/integrations/js/kompanionMemoryClient.js
@@ -0,0 +1,49 @@
+/**
+ * Minimal Node.js client for Kompanion MCP memory tools.
+ *
+ * The helpers spawn the `kom_mcp` CLI with a tool name and JSON payload,
+ * then return the parsed response.
+ */
+
+import { spawnSync } from 'node:child_process';
+
+function runKomMcp(toolName, payload) {
+  const request = JSON.stringify(payload);
+  const result = spawnSync('kom_mcp', [toolName, request], {
+    encoding: 'utf8'
+  });
+
+  if (result.error) {
+    throw result.error;
+  }
+  if (result.status !== 0) {
+    throw new Error(`kom_mcp exited with code ${result.status}: ${result.stderr}`);
+  }
+  const output = result.stdout.trim();
+  if (!output) {
+    return {};
+  }
+  try {
+    return JSON.parse(output);
+  } catch (err) {
+    throw new Error(`Failed to parse kom_mcp response: ${err}. Raw output: ${output}`);
+  }
+}
+
+export function saveContext(payload) {
+  return runKomMcp('kom.memory.v1.save_context', payload);
+}
+
+export function recallContext(payload) {
+  return runKomMcp('kom.memory.v1.recall_context', payload);
+}
+
+export function searchMemory(payload) {
+  return runKomMcp('kom.memory.v1.search_memory', payload);
+}
+
+export default {
+  saveContext,
+  recallContext,
+  searchMemory
+};
diff --git a/integrations/js/package.json b/integrations/js/package.json
new file mode 100644
index 0000000..7953dc3
--- /dev/null
+++ b/integrations/js/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "kompanion-js-bridges",
+  "private": true,
+  "type": "module",
+  "version": "0.1.0",
+  "description": "JavaScript helpers for Kompanion MCP memory tools.",
+  "scripts": {
+    "demo": "node demoMemoryExchange.js"
+  },
+  "dependencies": {}
+}
diff --git a/integrations/konsole/KompanionKonsolePlugin/CMakeLists.txt b/integrations/konsole/KompanionKonsolePlugin/CMakeLists.txt
new file mode 100644
index 0000000..1be8c57
--- /dev/null
+++ b/integrations/konsole/KompanionKonsolePlugin/CMakeLists.txt
@@ -0,0 +1,19 @@
+kcoreaddons_add_plugin(konsole_kompanionplugin
+    SOURCES
+        kompanionkonsoleplugin.cpp
+        kompanionagentpanel.cpp
+    INSTALL_NAMESPACE
+        "konsoleplugins"
+)
+
+configure_file(kompanion_konsole.in.json kompanion_konsole.json)
+
+target_link_libraries(konsole_kompanionplugin
+    Qt::Core
+    Qt::Gui
+    Qt::Widgets
+    KF6::CoreAddons
+    KF6::I18n
+    konsoleprivate
+    konsoleapp
+)
diff --git a/integrations/konsole/KompanionKonsolePlugin/kompanion_konsole.in.json b/integrations/konsole/KompanionKonsolePlugin/kompanion_konsole.in.json
new file mode 100644
index 0000000..5013262
--- /dev/null
+++ b/integrations/konsole/KompanionKonsolePlugin/kompanion_konsole.in.json
@@ -0,0 +1,17 @@
+{
+  "KPlugin": {
+    "Id": "kompanion_konsole",
+    "Name": "Kompanion Konsole Bridge",
+    "Description": "Demo bridge that lets Kompanion agents take over a Konsole tab.",
+    "Icon": "utilities-terminal",
+    "Authors": [
+      {
+        "Name": "Kompanion Team",
+        "Email": "team@kompanion.local"
+      }
+    ],
+    "Version": "0.1.0",
+    "License": "GPL-2.0-or-later"
+  },
+  "X-KDE-Konsole-PluginVersion": "1"
+}
diff --git a/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.cpp b/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.cpp
new file mode 100644
index 0000000..dd2e91d
--- /dev/null
+++ b/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.cpp
@@ -0,0 +1,58 @@
+#include "kompanionagentpanel.h"
+
+#include <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"
diff --git a/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.h b/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.h
new file mode 100644
index 0000000..afaf8e6
--- /dev/null
+++ b/integrations/konsole/KompanionKonsolePlugin/kompanionagentpanel.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#include <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;
+};
diff --git a/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.cpp b/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.cpp
new file mode 100644
index 0000000..0b91e20
--- /dev/null
+++ b/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.cpp
@@ -0,0 +1,130 @@
+#include "kompanionkonsoleplugin.h"
+
+#include "kompanionagentpanel.h"
+
+#include "MainWindow.h"
+#include "profile/ProfileManager.h"
+#include "session/Session.h"
+#include "session/SessionController.h"
+
+#include <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"
diff --git a/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.h b/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.h
new file mode 100644
index 0000000..21c4841
--- /dev/null
+++ b/integrations/konsole/KompanionKonsolePlugin/kompanionkonsoleplugin.h
@@ -0,0 +1,30 @@
+#pragma once
+
+#include <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;
+};
diff --git a/integrations/konsole/README.md b/integrations/konsole/README.md
new file mode 100644
index 0000000..9f93fa1
--- /dev/null
+++ b/integrations/konsole/README.md
@@ -0,0 +1,51 @@
+# Kompanion ⇄ Konsole Bridge
+
+This directory contains the first draft of **Kompanion‑Konsole**, a plugin for KDE's
+Konsole terminal emulator.  The plugin gives Kompanion agents a controlled way to
+step into a Konsole tab, using the same Kompanion core that powers the MCP back end.
+
+## Layout
+
+```
+KompanionKonsolePlugin/   # Drop-in plugin sources (mirrors Konsole/src/plugins layout)
+```
+
+The code is intentionally structured so it can live directly inside the Konsole
+source tree (e.g. under `konsole/src/plugins`).  You can keep developing it here
+and then symlink/copy it into a Konsole checkout when you are ready to compile.
+
+## Quick start (demo)
+
+1. Ensure you have a Konsole checkout (see `/mnt/bulk/shared/kdesrc/konsole`).
+2. From the Konsole repo, link the plugin:
+
+   ```bash
+   ln -s /home/kompanion/dev/metal/src/metal-kompanion/integrations/konsole/KompanionKonsolePlugin \
+         src/plugins/KompanionKonsole
+   echo "add_subdirectory(KompanionKonsole)" >> src/plugins/CMakeLists.txt
+   ```
+
+3. Reconfigure Konsole with CMake; build the `konsole_kompanionplugin` target.
+
+   ```bash
+   cmake -S . -B build
+   cmake --build build --target konsole_kompanionplugin
+   ```
+
+4. Launch the newly built Konsole.  Open *Plugins → Kompanion Konsole Bridge* to
+   toggle the dock and use the **Launch Demo Agent Shell** or **Attach Active Tab**
+   buttons to hand the tab over to Kompanion.
+
+The demo simply injects `kom_mcp --list` into the tab and prints a coloured banner.
+Later iterations will replace this with the minimal TTY protocol described in the
+roadmap.
+
+## Notes
+
+- The plugin depends on the in-tree `konsoleprivate` and `konsoleapp` targets, so it
+  currently builds only alongside the Konsole sources.
+- Strings are translated via `KLocalizedString`, and actions are registered with the
+  Konsole action collection so shortcuts can be customised.
+- All agent‑facing commands are placeholder stubs; they go through Kompanion's CLI
+  entry points so real migrations can swap in more capable bridges without touching
+  the KDE plugin scaffolding.
diff --git a/src/cli/KompanionApp.cpp b/src/cli/KompanionApp.cpp
new file mode 100644
index 0000000..6a79650
--- /dev/null
+++ b/src/cli/KompanionApp.cpp
@@ -0,0 +1,773 @@
+#include <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;
+}
diff --git a/tests/mcp/test_memory_exchange.cpp b/tests/mcp/test_memory_exchange.cpp
new file mode 100644
index 0000000..4a560fb
--- /dev/null
+++ b/tests/mcp/test_memory_exchange.cpp
@@ -0,0 +1,75 @@
+#include <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;
+}
