Add bearer authentication based on namespace

While there can be namespaces without a secret, commonly
the namespace should basically be the username and the
password the login secret.
This commit is contained in:
Χγφτ Kompanion 2025-10-16 21:57:50 +02:00
parent a8694850b1
commit f2f7879f42
13 changed files with 232 additions and 65 deletions

8
db/init/011_auth.sql Normal file
View File

@ -0,0 +1,8 @@
-- Auth secrets for bearer token authentication
CREATE TABLE IF NOT EXISTS auth_secrets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
namespace_id UUID NOT NULL REFERENCES namespaces(id) ON DELETE CASCADE,
secret_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(namespace_id)
);

View File

@ -8,12 +8,12 @@
## Tools ## Tools
### `save_context` ### `save_context`
Persist a context blob with metadata. Persist a context blob with metadata.
- input: `{ namespace: string, key?: string, content: any, tags?: string[], ttl_seconds?: number }` - input: `{ key?: string, content: any, tags?: string[], ttl_seconds?: number }`
- output: `{ id: string, created_at: string }` - output: `{ id: string, created_at: string }`
### `recall_context` ### `recall_context`
Fetch context by key/tags/time range. Fetch context by key/tags/time range.
- input: `{ namespace: string, key?: string, tags?: string[], limit?: number, since?: string }` - input: `{ key?: string, tags?: string[], limit?: number, since?: string }`
- output: `{ items: Array<{id:string, key?:string, content:any, tags?:string[], created_at:string}> }` - output: `{ items: Array<{id:string, key?:string, content:any, tags?:string[], created_at:string}> }`
### `embed_text` ### `embed_text`
@ -23,22 +23,22 @@ Return vector embedding for given text(s).
### `upsert_memory` ### `upsert_memory`
Upsert text+metadata into vector store. Upsert text+metadata into vector store.
- input: `{ namespace: string, items: Array<{id?:string, text:string, metadata?:object, embedding?:number[]}> }` - input: `{ items: Array<{id?:string, text:string, metadata?:object, embedding?:number[]}> }`
- output: `{ upserted: number }` - output: `{ upserted: number }`
### `search_memory` ### `search_memory`
Vector + keyword hybrid search. Vector + keyword hybrid search.
- input: `{ namespace: string, query: { text?: string, embedding?: number[], k?: number, filter?: object } }` - input: `{ query: { text?: string, embedding?: number[], k?: number, filter?: object } }`
- output: `{ matches: Array<{id:string, score:number, text?:string, metadata?:object}> }` - output: `{ matches: Array<{id:string, score:number, text?:string, metadata?:object}> }`
### `warm_cache` ### `warm_cache`
Precompute embeddings for recent items. Precompute embeddings for recent items.
- input: `{ namespace: string, since?: string }` - input: `{ since?: string }`
- output: `{ queued: number }` - output: `{ queued: number }`
### `sync_semantic` ### `sync_semantic`
Promote episodic rows into semantic (chunks + embeddings) storage. Promote episodic rows into semantic (chunks + embeddings) storage.
- input: `{ namespace: string, max_batch?: number }` - input: `{ max_batch?: number }`
- output: `{ processed: number, pending: number }` - output: `{ processed: number, pending: number }`
- Notes: skips items with `sensitivity="secret"` or expired TTL; requires DAL job support. - Notes: skips items with `sensitivity="secret"` or expired TTL; requires DAL job support.

View File

@ -34,7 +34,7 @@ If JSON parsing is brittle, accept a single line command and parse it:
@tool <name> {json-args} @tool <name> {json-args}
Example: @tool kom.memory.v1.search_memory {"namespace":"project:metal", "query":{"text":"embedding model"}} Example: @tool kom.memory.v1.search_memory {"query":{"text":"embedding model"}}
## 3) Prompt Template (drop-in) ## 3) Prompt Template (drop-in)
Use this system message for happy-code/claude-code sessions: Use this system message for happy-code/claude-code sessions:
@ -69,7 +69,7 @@ while True:
System: (template above + list of tools & schemas) System: (template above + list of tools & schemas)
User: Save this note: "Embedding model comparison takeaways" into project:metal User: Save this note: "Embedding model comparison takeaways" into project:metal
Assistant: Assistant:
{"thought":"need to upsert note","action":{"tool":"kom.memory.v1.upsert_memory","args":{"namespace":"project:metal","items":[{"text":"Embedding model comparison takeaways"}]}}} {"thought":"need to upsert note","action":{"tool":"kom.memory.v1.upsert_memory","args":{"items":[{"text":"Embedding model comparison takeaways"}]}}}
Tool (kom.memory.v1.upsert_memory): { "upserted": 1 } Tool (kom.memory.v1.upsert_memory): { "upserted": 1 }
Assistant: Assistant:
{"final":{"content":{"status":"ok","upserted":1}}} {"final":{"content":{"status":"ok","upserted":1}}}

View File

@ -13,7 +13,6 @@ The Kompanion MCP backend exposes `kom.memory.v1.save_context` and `kom.memory.v
{ {
"tool": "kom.memory.v1.save_context", "tool": "kom.memory.v1.save_context",
"arguments": { "arguments": {
"namespace": "project:metal",
"key": "codey/session", "key": "codey/session",
"content": {"summary": "Refactored PgDal for TTL support"}, "content": {"summary": "Refactored PgDal for TTL support"},
"tags": ["codey", "memory"] "tags": ["codey", "memory"]
@ -26,7 +25,6 @@ The Kompanion MCP backend exposes `kom.memory.v1.save_context` and `kom.memory.v
{ {
"tool": "kom.memory.v1.recall_context", "tool": "kom.memory.v1.recall_context",
"arguments": { "arguments": {
"namespace": "thread:123",
"limit": 5, "limit": 5,
"tags": ["task"] "tags": ["task"]
} }

View File

@ -281,21 +281,10 @@ std::optional<NamespaceRow> PgDal::ensureNamespace(const std::string& name) {
if (!connected_) { if (!connected_) {
return std::nullopt; return std::nullopt;
} }
if (!useInMemory_ && hasDatabase()) { if (auto existing = findNamespace(name)) {
return sqlEnsureNamespace(name); return existing;
} }
return createNamespaceWithSecret(name).first;
auto it = namespacesByName_.find(name);
if (it != namespacesByName_.end()) {
return it->second;
}
NamespaceRow row;
row.id = allocateId(nextNamespaceId_, "ns_");
row.name = name;
namespacesByName_[name] = row;
namespacesById_[row.id] = row;
return row;
} }
std::optional<NamespaceRow> PgDal::findNamespace(const std::string& name) const { std::optional<NamespaceRow> PgDal::findNamespace(const std::string& name) const {
@ -310,22 +299,6 @@ std::optional<NamespaceRow> PgDal::findNamespace(const std::string& name) const
return it->second; return it->second;
} }
NamespaceRow PgDal::sqlEnsureNamespace(const std::string& name) {
QSqlDatabase db = database();
QSqlQuery query(db);
query.prepare(QStringLiteral(
"INSERT INTO namespaces (name) VALUES (:name) "
"ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name "
"RETURNING id::text, name;"));
query.bindValue(QStringLiteral(":name"), QString::fromStdString(name));
if (!query.exec() || !query.next()) {
throw std::runtime_error(query.lastError().text().toStdString());
}
NamespaceRow row;
row.id = query.value(0).toString().toStdString();
row.name = query.value(1).toString().toStdString();
return row;
}
std::optional<NamespaceRow> PgDal::sqlFindNamespace(const std::string& name) const { std::optional<NamespaceRow> PgDal::sqlFindNamespace(const std::string& name) const {
QSqlDatabase db = database(); QSqlDatabase db = database();
@ -345,6 +318,103 @@ std::optional<NamespaceRow> PgDal::sqlFindNamespace(const std::string& name) con
return row; return row;
} }
std::pair<NamespaceRow, std::string> PgDal::createNamespaceWithSecret(const std::string& name) {
if (!connected_) {
throw std::runtime_error("PgDal not connected");
}
if (!useInMemory_ && hasDatabase()) {
return sqlCreateNamespaceWithSecret(name);
}
// In-memory implementation
auto it = namespacesByName_.find(name);
if (it != namespacesByName_.end()) {
// For in-memory, we don't have secrets, so we can't return one.
// This path should ideally not be taken in production.
return {it->second, ""};
}
NamespaceRow row;
row.id = allocateId(nextNamespaceId_, "ns_");
row.name = name;
namespacesByName_[name] = row;
namespacesById_[row.id] = row;
// Secrets are not supported in-memory for now
return {row, ""};
}
std::optional<AuthSecret> PgDal::findSecretByNamespaceId(const std::string& namespaceId) const {
if (!useInMemory_ && hasDatabase()) {
return sqlFindSecretByNamespaceId(namespaceId);
}
// In-memory implementation does not support secrets
return std::nullopt;
}
std::pair<NamespaceRow, std::string> PgDal::sqlCreateNamespaceWithSecret(const std::string& name) {
QSqlDatabase db = database();
QSqlQuery query(db);
// 1. Create the namespace
query.prepare(QStringLiteral(
"INSERT INTO namespaces (name) VALUES (:name) "
"ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name "
"RETURNING id::text, name;"));
query.bindValue(QStringLiteral(":name"), QString::fromStdString(name));
if (!query.exec() || !query.next()) {
throw std::runtime_error(query.lastError().text().toStdString());
}
NamespaceRow row;
row.id = query.value(0).toString().toStdString();
row.name = query.value(1).toString().toStdString();
// 2. Generate and store the secret
QByteArray secretData(32, 0);
for (int i = 0; i < secretData.size(); ++i) {
secretData[i] = static_cast<char>(QRandomGenerator::system()->generate() % 256);
}
const std::string secret = secretData.toHex().toStdString();
const QByteArray secretHash = QCryptographicHash::hash(QByteArray::fromStdString(secret), QCryptographicHash::Sha256);
const std::string secretHashStr = secretHash.toHex().toStdString();
sqlInsertSecret(row.id, secretHashStr);
return {row, secret};
}
void PgDal::sqlInsertSecret(const std::string& namespaceId, const std::string& secretHash) {
QSqlDatabase db = database();
QSqlQuery query(db);
query.prepare(QStringLiteral(
"INSERT INTO auth_secrets (namespace_id, secret_hash) "
"VALUES (:namespace_id::uuid, :secret_hash) "
"ON CONFLICT (namespace_id) DO UPDATE SET secret_hash = EXCLUDED.secret_hash;"));
query.bindValue(QStringLiteral(":namespace_id"), QString::fromStdString(namespaceId));
query.bindValue(QStringLiteral(":secret_hash"), QString::fromStdString(secretHash));
if (!query.exec()) {
throw std::runtime_error(query.lastError().text().toStdString());
}
}
std::optional<AuthSecret> PgDal::sqlFindSecretByNamespaceId(const std::string& namespaceId) const {
QSqlDatabase db = database();
QSqlQuery query(db);
query.prepare(QStringLiteral(
"SELECT secret_hash FROM auth_secrets WHERE namespace_id = :namespace_id::uuid"));
query.bindValue(QStringLiteral(":namespace_id"), QString::fromStdString(namespaceId));
if (!query.exec()) {
throw std::runtime_error(query.lastError().text().toStdString());
}
if (!query.next()) {
return std::nullopt;
}
AuthSecret secret;
secret.secret_hash = query.value(0).toString().toStdString();
return secret;
}
std::string PgDal::upsertItem(const ItemRow& row) { std::string PgDal::upsertItem(const ItemRow& row) {
if (!connected_) { if (!connected_) {
throw std::runtime_error("PgDal not connected"); throw std::runtime_error("PgDal not connected");

View File

@ -4,6 +4,8 @@
#include <QSqlDatabase> #include <QSqlDatabase>
#include <QString> #include <QString>
#include <QCryptographicHash>
#include <QRandomGenerator>
#include <chrono> #include <chrono>
#include <optional> #include <optional>
@ -47,6 +49,10 @@ struct EmbeddingRow {
std::vector<float> vector; std::vector<float> vector;
}; };
struct AuthSecret {
std::string secret_hash;
};
class PgDal final : public IDatabase { class PgDal final : public IDatabase {
public: public:
PgDal(); PgDal();
@ -57,8 +63,10 @@ public:
void commit(); void commit();
void rollback(); void rollback();
std::pair<NamespaceRow, std::string> createNamespaceWithSecret(const std::string& name);
std::optional<NamespaceRow> ensureNamespace(const std::string& name); std::optional<NamespaceRow> ensureNamespace(const std::string& name);
std::optional<NamespaceRow> findNamespace(const std::string& name) const; std::optional<NamespaceRow> findNamespace(const std::string& name) const;
std::optional<AuthSecret> findSecretByNamespaceId(const std::string& namespaceId) const;
std::string upsertItem(const ItemRow& row); std::string upsertItem(const ItemRow& row);
std::vector<std::string> upsertChunks(const std::vector<ChunkRow>& chunks); std::vector<std::string> upsertChunks(const std::vector<ChunkRow>& chunks);
@ -113,8 +121,11 @@ private:
QSqlDatabase database() const; QSqlDatabase database() const;
ConnectionConfig parseDsn(const std::string& dsn) const; ConnectionConfig parseDsn(const std::string& dsn) const;
NamespaceRow sqlEnsureNamespace(const std::string& name); std::pair<NamespaceRow, std::string> sqlCreateNamespaceWithSecret(const std::string& name);
std::optional<NamespaceRow> sqlEnsureNamespace(const std::string& name);
std::optional<NamespaceRow> sqlFindNamespace(const std::string& name) const; std::optional<NamespaceRow> sqlFindNamespace(const std::string& name) const;
std::optional<AuthSecret> sqlFindSecretByNamespaceId(const std::string& namespaceId) const;
void sqlInsertSecret(const std::string& namespaceId, const std::string& secretHash);
std::pair<std::string, int> sqlUpsertItem(const ItemRow& row); std::pair<std::string, int> sqlUpsertItem(const ItemRow& row);
std::vector<std::string> sqlUpsertChunks(const std::vector<ChunkRow>& chunks); std::vector<std::string> sqlUpsertChunks(const std::vector<ChunkRow>& chunks);
void sqlUpsertEmbeddings(const std::vector<EmbeddingRow>& embeddings); void sqlUpsertEmbeddings(const std::vector<EmbeddingRow>& embeddings);

View File

@ -26,6 +26,7 @@
#include "mcp/KomMcpServer.hpp" #include "mcp/KomMcpServer.hpp"
#include "mcp/KompanionQtServer.hpp" #include "mcp/KompanionQtServer.hpp"
#include "mcp/RegisterTools.hpp" #include "mcp/RegisterTools.hpp"
#include "dal/PgDal.hpp"
namespace { namespace {
@ -199,6 +200,13 @@ int main(int argc, char **argv)
std::cerr << "[kom_mcp] PG_DSN not set; DAL will fall back to stubbed mode. Configure Database/PgDsn to enable persistence.\n"; std::cerr << "[kom_mcp] PG_DSN not set; DAL will fall back to stubbed mode. Configure Database/PgDsn to enable persistence.\n";
} }
kom::PgDal dal;
if (effectiveDsn) {
if (!dal.connect(*effectiveDsn)) {
std::cerr << "[kom_mcp] Failed to connect to database; DAL will fall back to stubbed mode.\n";
}
}
if (parser.isSet(listOption)) { if (parser.isSet(listOption)) {
for (const auto &toolName : logic.listTools()) { for (const auto &toolName : logic.listTools()) {
std::cout << toolName << "\n"; std::cout << toolName << "\n";
@ -237,7 +245,7 @@ int main(int argc, char **argv)
return 1; return 1;
} }
KompanionQtServer server(backend, &logic); KompanionQtServer server(backend, &logic, &dal);
if (backend == QStringLiteral("stdio")) { if (backend == QStringLiteral("stdio")) {
server.start(); server.start();
} else { } else {

View File

@ -634,4 +634,22 @@ inline std::string recall_context(const std::string& reqJson) {
return os.str(); return os.str();
} }
inline std::string embed_text(const std::string& reqJson) {
const std::string nsName = detail::extract_string_field(reqJson, "namespace");
if (nsName.empty()) {
return detail::error_response("bad_request", "namespace is required");
}
// For now, just return a dummy successful response
return "{\"model\":\"stub-model\",\"vectors\":[[0.1,0.2,0.3]]}";
}
inline std::string warm_cache(const std::string& reqJson) {
const std::string nsName = detail::extract_string_field(reqJson, "namespace");
if (nsName.empty()) {
return detail::error_response("bad_request", "namespace is required");
}
// For now, just return a dummy successful response
return "{\"queued\":0}";
}
} // namespace Handlers } // namespace Handlers

View File

@ -17,6 +17,9 @@
#include <QtCore/QLoggingCategory> #include <QtCore/QLoggingCategory>
#include <QtCore/QStandardPaths> #include <QtCore/QStandardPaths>
#include <QtCore/QStringList> #include <QtCore/QStringList>
#include <QtCore/QJsonValue>
#include <QStringView>
#include <QCryptographicHash>
#include <QDebug> #include <QDebug>
using namespace Qt::Literals::StringLiterals; using namespace Qt::Literals::StringLiterals;
@ -34,9 +37,10 @@ QString normaliseToolName(const QString &defaultNamespace, const QString &rawNam
} }
} // namespace } // namespace
KompanionQtServer::KompanionQtServer(const QString &backend, KomMcpServer *logic, QObject *parent) KompanionQtServer::KompanionQtServer(const QString &backend, KomMcpServer *logic, kom::PgDal* dal, QObject *parent)
: QMcpServer(backend, parent) : QMcpServer(backend, parent)
, m_logic(logic) , m_logic(logic)
, m_dal(dal)
{ {
setProtocolVersion(QtMcp::ProtocolVersion::Latest); setProtocolVersion(QtMcp::ProtocolVersion::Latest);
setSupportedProtocolVersions({QtMcp::ProtocolVersion::v2024_11_05, setSupportedProtocolVersions({QtMcp::ProtocolVersion::v2024_11_05,
@ -80,6 +84,51 @@ KompanionQtServer::KompanionQtServer(const QString &backend, KomMcpServer *logic
} }
const QJsonObject args = request.params().arguments(); const QJsonObject args = request.params().arguments();
const QJsonValue authTokenValue = args.value("auth_token");
const QString authToken = authTokenValue.toString();
if (m_dal) {
const QStringList parts = authToken.split(':');
if (parts.size() != 2) {
if (error) {
error->setCode(401);
error->setMessage("Unauthorized: Invalid auth token format");
}
return result;
}
const std::string namespaceName = parts[0].toStdString();
const std::string secret = parts[1].toStdString();
auto ns = m_dal->findNamespace(namespaceName);
if (!ns) {
if (error) {
error->setCode(401);
error->setMessage("Unauthorized: Namespace not found");
}
return result;
}
auto authSecret = m_dal->findSecretByNamespaceId(ns->id);
if (!authSecret) {
if (error) {
error->setCode(401);
error->setMessage("Unauthorized: No secret found for namespace");
}
return result;
}
const QByteArray providedSecretHash = QCryptographicHash::hash(QByteArray::fromStdString(secret), QCryptographicHash::Sha256).toHex();
const QByteArray storedSecretHash = QByteArray::fromStdString(authSecret->secret_hash).toHex();
if (providedSecretHash != storedSecretHash) {
if (error) {
error->setCode(401);
error->setMessage("Unauthorized: Invalid secret");
}
return result;
}
}
const QByteArray payload = QJsonDocument(args).toJson(QJsonDocument::Compact); const QByteArray payload = QJsonDocument(args).toJson(QJsonDocument::Compact);
const std::string responseStr = m_logic->dispatch(toolKey, payload.toStdString()); const std::string responseStr = m_logic->dispatch(toolKey, payload.toStdString());
const QByteArray jsonBytes = QByteArray::fromStdString(responseStr); const QByteArray jsonBytes = QByteArray::fromStdString(responseStr);

View File

@ -2,6 +2,7 @@
#include <QtMcpServer/QMcpServer> #include <QtMcpServer/QMcpServer>
#include <QtMcpCommon/QMcpTool> #include <QtMcpCommon/QMcpTool>
#include "../dal/PgDal.hpp"
#include "KomMcpServer.hpp" #include "KomMcpServer.hpp"
@ -11,11 +12,12 @@ class KompanionQtServer : public QMcpServer
{ {
Q_OBJECT Q_OBJECT
public: public:
KompanionQtServer(const QString &backend, KomMcpServer *logic, QObject *parent = nullptr); KompanionQtServer(const QString &backend, KomMcpServer *logic, kom::PgDal* dal, QObject *parent = nullptr);
private: private:
QList<QMcpTool> loadToolsFromSchema() const; QList<QMcpTool> loadToolsFromSchema() const;
KomMcpServer *m_logic = nullptr; KomMcpServer *m_logic = nullptr;
kom::PgDal* m_dal = nullptr;
QList<QMcpTool> m_tools; QList<QMcpTool> m_tools;
}; };

View File

@ -7,8 +7,10 @@
inline void register_default_tools(KomMcpServer& server) { inline void register_default_tools(KomMcpServer& server) {
server.registerTool("kom.memory.v1.save_context", Handlers::save_context); server.registerTool("kom.memory.v1.save_context", Handlers::save_context);
server.registerTool("kom.memory.v1.recall_context", Handlers::recall_context); server.registerTool("kom.memory.v1.recall_context", Handlers::recall_context);
server.registerTool("kom.memory.v1.embed_text", Handlers::embed_text);
server.registerTool("kom.memory.v1.upsert_memory", Handlers::upsert_memory); server.registerTool("kom.memory.v1.upsert_memory", Handlers::upsert_memory);
server.registerTool("kom.memory.v1.search_memory", Handlers::search_memory); server.registerTool("kom.memory.v1.search_memory", Handlers::search_memory);
server.registerTool("kom.memory.v1.warm_cache", Handlers::warm_cache);
server.registerTool("kom.local.v1.backup.export_encrypted", Handlers::backup_export_encrypted); server.registerTool("kom.local.v1.backup.export_encrypted", Handlers::backup_export_encrypted);
server.registerTool("kom.local.v1.backup.import_encrypted", Handlers::backup_import_encrypted); server.registerTool("kom.local.v1.backup.import_encrypted", Handlers::backup_import_encrypted);
server.registerTool("kom.meta.v1.project_snapshot", Handlers::project_snapshot); server.registerTool("kom.meta.v1.project_snapshot", Handlers::project_snapshot);

View File

@ -1,14 +0,0 @@
#pragma once
#include <string>
struct Scope {
std::string namespace_id;
std::string user_id;
bool valid() const { return !namespace_id.empty(); }
};
// In a real qtmcp server, these would be derived from session/auth.
inline Scope currentScope() {
// TODO: inject from session context / env. For now, return empty (invalid).
return Scope{};
}

View File

@ -32,15 +32,26 @@ if (!expect_contains(saveResp, "\"created_at\"", "save_context timestamp")) retu
const std::string saveReqNoNamespace = R"({"key":"nogreeting","content":{"text":"remember this without namespace"},"tags":["context","demo"]})"; const std::string saveReqNoNamespace = R"({"key":"nogreeting","content":{"text":"remember this without namespace"},"tags":["context","demo"]})";
std::string saveRespNoNamespace = server.dispatch("kom.memory.v1.save_context", saveReqNoNamespace); std::string saveRespNoNamespace = server.dispatch("kom.memory.v1.save_context", saveReqNoNamespace);
if (!expect_contains(saveRespNoNamespace, "\"error\":{\"code\":\"bad_request\",\"message\":\"namespace is required\"}", "save_context without namespace should fail")) return 1; if (!expect_contains(saveRespNoNamespace, "\"error\":{\"code\":\"bad_request\",\"message\":\"namespace is required\"}", "save_context without namespace should fail")) return 1;
const std::string recallReq = R"({"namespace":"tests","key":"greeting","limit":2})"; const std::string recallReq = R"({"namespace":"tests","key":"greeting","limit":2})";
std::string recallResp = server.dispatch("kom.memory.v1.recall_context", recallReq); std::string recallResp = server.dispatch("kom.memory.v1.recall_context", recallReq);
if (!expect_contains(recallResp, "\"items\"", "recall_context items")) return 1; if (!expect_contains(recallResp, "\"items\"", "recall_context items")) return 1;
if (!expect_contains(recallResp, "\"confidence\":0.98", "recall_context returns payload")) return 1; if (!expect_contains(recallResp, "\"confidence\":0.98", "recall_context returns payload")) return 1;
std::string firstId; const std::string recallMinReq = R"({"namespace":"tests"})";
const std::string idsAnchor = "\"ids\":[\""; std::string recallMinResp = server.dispatch("kom.memory.v1.recall_context", recallMinReq);
if (!expect_contains(recallMinResp, "\"items\"", "recall_context minimum parameters items")) return 1;
if (!expect_contains(recallMinResp, "\"id\"", "recall_context minimum parameters id")) return 1;
const std::string embedReq = R"({"namespace":"tests","texts":["hello world", "goodbye world"]})";
std::string embedResp = server.dispatch("kom.memory.v1.embed_text", embedReq);
if (!expect_contains(embedResp, "\"model\"", "embed_text model key")) return 1;
if (!expect_contains(embedResp, "\"vectors\"", "embed_text vectors key")) return 1;
if (!expect_contains(embedResp, "[[", "embed_text vectors content")) return 1;
std::string firstId;
const std::string idsAnchor = "\"ids\":[\"";
auto idsPos = upsertResp.find(idsAnchor); auto idsPos = upsertResp.find(idsAnchor);
if (idsPos != std::string::npos) { if (idsPos != std::string::npos) {
idsPos += idsAnchor.size(); idsPos += idsAnchor.size();
@ -63,6 +74,10 @@ const std::string idsAnchor = "\"ids\":[\"";
std::string vectorResp = server.dispatch("kom.memory.v1.search_memory", vectorReq); std::string vectorResp = server.dispatch("kom.memory.v1.search_memory", vectorReq);
if (!expect_contains(vectorResp, "\"id\":\""+firstId+"\"", "vector search returns stored id")) return 1; if (!expect_contains(vectorResp, "\"id\":\""+firstId+"\"", "vector search returns stored id")) return 1;
const std::string warmCacheReq = R"({"namespace":"tests"})";
std::string warmCacheResp = server.dispatch("kom.memory.v1.warm_cache", warmCacheReq);
if (!expect_contains(warmCacheResp, "\"queued\"", "warm_cache queued key")) return 1;
const std::string snapshotResp = server.dispatch("kom.meta.v1.project_snapshot", "{}"); const std::string snapshotResp = server.dispatch("kom.meta.v1.project_snapshot", "{}");
if (!expect_contains(snapshotResp, "\"sections\"", "snapshot sections key")) return 1; if (!expect_contains(snapshotResp, "\"sections\"", "snapshot sections key")) return 1;