dc5dc94d79
Multi-channel retrieval sidecar over Universal Memory Protocol: - 3-channel RRF (UMP FTS5 + Qdrant vector + knowledge graph) - ACT-R re-ranking (Anderson 1983) with access tracking - Co-occurrence graph edges (Phase 6) for dense traversal - Memory lifecycle decay (Phase 4) with per-kind confidence - MCP shim routes recall through sidecar, falls back to canonical UMP Architecture: - src/server.js HTTP sidecar on port 4380 - src/graph.js 2592-node / 111-edge graph from UMP (or +cooccur: 13k+) - src/actr.js A_i = -d*ln(age) + beta*log1p(freq) + epsilon*conf - src/access_log.js per-URN counter + last_accessed_at - src/ump-recall-mcp.js MCP shim (recall via sidecar, others passthrough) Eval results (851-record UMP corpus): - 2ch RRF over baseline: +50pp recall@10 - 3ch RRF (+graph): +60pp, 12 unique wins - ACT-R re-rank: 4/20 #1 changes, 84% top-5 retention Tests: 76/76 passing across graph (27), actr (27), access_log (28), decay (20), mcp-shim (sidecar + fallback). Run with: npm test Inspired by AIAppsAPI/adaptive-recall but built from scratch against existing DNS2 infrastructure (UMP at :4317, Qdrant at :6333, Ollama at :11434). No paid SaaS, MIT-licensed.
209 lines
6.6 KiB
JavaScript
209 lines
6.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// test-mcp-shim.js — End-to-end smoke test for the ump-recall-mcp shim.
|
|
//
|
|
// Spawns the shim as a subprocess twice:
|
|
// Scenario A: sidecar reachable → expect recall._meta.source === "sidecar"
|
|
// Scenario B: sidecar unreachable → expect recall._meta.source === "ump-fallback"
|
|
//
|
|
// Sends JSON-RPC 2.0 over stdio (newline-delimited), one object per line.
|
|
// Asserts tools/list returns 7 tools and the recall hit has a non-empty urn.
|
|
|
|
import { spawn } from "node:child_process";
|
|
import process from "node:process";
|
|
import path from "node:path";
|
|
import url from "node:url";
|
|
|
|
const SHIM = path.resolve(
|
|
path.dirname(url.fileURLToPath(import.meta.url)),
|
|
"..",
|
|
"src",
|
|
"ump-recall-mcp.js",
|
|
);
|
|
const PROTO_VERSION = "2024-11-05";
|
|
|
|
const results = { A: null, B: null };
|
|
|
|
function startShim(env = {}) {
|
|
const child = spawn("node", [SHIM], {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
env: { ...process.env, ...env },
|
|
});
|
|
let buf = "";
|
|
const pending = new Map();
|
|
let nextId = 1;
|
|
|
|
child.stdout.on("data", (chunk) => {
|
|
buf += chunk.toString();
|
|
let nl;
|
|
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
const line = buf.slice(0, nl);
|
|
buf = buf.slice(nl + 1);
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const msg = JSON.parse(line);
|
|
if (msg.id != null && pending.has(msg.id)) {
|
|
const { resolve, reject } = pending.get(msg.id);
|
|
pending.delete(msg.id);
|
|
if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
else resolve(msg.result);
|
|
}
|
|
} catch {
|
|
// ignore non-JSON (e.g. progress noise)
|
|
}
|
|
}
|
|
});
|
|
|
|
function call(method, params) {
|
|
return new Promise((resolve, reject) => {
|
|
const id = nextId++;
|
|
pending.set(id, { resolve, reject });
|
|
const timer = setTimeout(() => {
|
|
if (pending.has(id)) {
|
|
pending.delete(id);
|
|
reject(new Error(`timeout on ${method}`));
|
|
}
|
|
}, 20000);
|
|
try {
|
|
child.stdin.write(
|
|
JSON.stringify({ jsonrpc: "2.0", id, method, params: params || {} }) + "\n",
|
|
);
|
|
} catch (e) {
|
|
clearTimeout(timer);
|
|
pending.delete(id);
|
|
reject(e);
|
|
}
|
|
// Resolve but we want to clear the timer too — wrap:
|
|
const orig = resolve;
|
|
// (no-op; promise resolves on the data handler which clears via closure)
|
|
}).then(
|
|
(v) => v,
|
|
(e) => { throw e; },
|
|
);
|
|
}
|
|
|
|
function kill() {
|
|
try { child.kill("SIGTERM"); } catch {}
|
|
setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 500);
|
|
}
|
|
|
|
return { child, call, kill };
|
|
}
|
|
|
|
function unwrapContent(result) {
|
|
// MCP tools/call returns { content: [{ type: "text", text: "<json string>" }] }
|
|
// (the SDK rejects type:"json"; we emit text+JSON.stringify on the shim side).
|
|
if (result && Array.isArray(result.content)) {
|
|
for (const c of result.content) {
|
|
if (c.type === "text" && c.text) {
|
|
try { return JSON.parse(c.text); } catch { return { text: c.text }; }
|
|
}
|
|
if (c.type === "json" && c.json) return c.json; // tolerate old shim shape
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function runScenario(name, env) {
|
|
const label = name === "A" ? "sidecar route" : "fallback route";
|
|
console.log(`\n=== Scenario ${name}: ${label} ===`);
|
|
const shim = startShim(env);
|
|
|
|
let pass = true;
|
|
const fails = [];
|
|
function check(cond, msg) {
|
|
if (!cond) { pass = false; fails.push(msg); console.log(` ✗ ${msg}`); }
|
|
else { console.log(` ✓ ${msg}`); }
|
|
}
|
|
|
|
try {
|
|
// 1. initialize
|
|
const initRes = await shim.call("initialize", {
|
|
protocolVersion: PROTO_VERSION,
|
|
capabilities: {},
|
|
clientInfo: { name: "test-mcp-shim", version: "0.0.1" },
|
|
});
|
|
check(
|
|
initRes && initRes.protocolVersion && initRes.serverInfo && initRes.serverInfo.name,
|
|
`initialize ok (server: ${initRes?.serverInfo?.name} v${initRes?.serverInfo?.version})`,
|
|
);
|
|
|
|
// 2. tools/list — assert 7 tools
|
|
const toolsRes = await shim.call("tools/list", {});
|
|
const tools = toolsRes?.tools || [];
|
|
check(Array.isArray(tools), "tools/list returns array");
|
|
check(tools.length === 7, `tools/list returns 7 tools (got ${tools.length})`);
|
|
const expected = ["recall", "remember", "get", "revise", "forget", "feedback", "capabilities"];
|
|
for (const t of expected) {
|
|
check(tools.some((x) => x.name === t), `tool present: ${t}`);
|
|
}
|
|
|
|
// 3. tools/call recall
|
|
const callRes = await shim.call("tools/call", {
|
|
name: "recall",
|
|
arguments: { query: "Triangles test", limit: 3 },
|
|
});
|
|
const payload = unwrapContent(callRes);
|
|
|
|
// 4. assertions
|
|
if (name === "A") {
|
|
check(
|
|
payload?._meta?.source === "sidecar",
|
|
`_meta.source === "sidecar" (got "${payload?._meta?.source}")`,
|
|
);
|
|
} else {
|
|
check(
|
|
payload?._meta?.source === "ump-fallback",
|
|
`_meta.source === "ump-fallback" (got "${payload?._meta?.source}")`,
|
|
);
|
|
}
|
|
|
|
// 5. at least 1 hit, first hit has non-empty urn
|
|
const hits = payload?.hits || payload?.results || payload?.memories || [];
|
|
check(Array.isArray(hits) && hits.length >= 1, `at least 1 hit (got ${hits.length})`);
|
|
if (hits.length >= 1) {
|
|
const first = hits[0];
|
|
// sidecar shape: { urn, ... } ; canonical UMP shape: { record: { id, ... } }
|
|
const urn = first.urn
|
|
|| first.id
|
|
|| first.memory_id
|
|
|| first.record?.id
|
|
|| first.record?.urn;
|
|
check(typeof urn === "string" && urn.length > 0, `first hit has non-empty urn (urn="${urn}")`);
|
|
}
|
|
} catch (e) {
|
|
pass = false;
|
|
fails.push(`exception: ${e.message}`);
|
|
console.log(` ✗ exception: ${e.message}`);
|
|
} finally {
|
|
shim.kill();
|
|
}
|
|
|
|
results[name] = { pass, fails };
|
|
}
|
|
|
|
(async () => {
|
|
// Scenario A: sidecar reachable on :4380
|
|
await runScenario("A", {
|
|
SIDECAR_URL: "http://127.0.0.1:4380",
|
|
UMP_DIR: "/root/.openclaw/agents/main/workspace/state/ump-local",
|
|
UMP_STORE: "json",
|
|
});
|
|
|
|
// Scenario B: sidecar unreachable, bad port
|
|
await runScenario("B", {
|
|
SIDECAR_URL: "http://127.0.0.1:9999",
|
|
FALLBACK_UMP_URL: "http://127.0.0.1:4317",
|
|
UMP_DIR: "/root/.openclaw/agents/main/workspace/state/ump-local",
|
|
UMP_STORE: "json",
|
|
});
|
|
|
|
console.log("\n=== Summary ===");
|
|
console.log(` Scenario A (sidecar route): ${results.A.pass ? "PASS" : "FAIL"}`);
|
|
if (!results.A.pass) for (const f of results.A.fails) console.log(` - ${f}`);
|
|
console.log(` Scenario B (fallback route): ${results.B.pass ? "PASS" : "FAIL"}`);
|
|
if (!results.B.pass) for (const f of results.B.fails) console.log(` - ${f}`);
|
|
|
|
const allPass = results.A.pass && results.B.pass;
|
|
process.exit(allPass ? 0 : 1);
|
|
})();
|