#!/usr/bin/env node // memory-mcp.mjs — an MCP server over the local-first agent memory, so any // session can SEARCH OUR PROMPT HISTORY across the fleet by name, instead of // remembering that `node memory/cli.mjs` exists on each Mac separately. // // The stores are local-first and encrypted per machine (AES-256-GCM under a key // that never leaves the Mac that wrote it), so there is no central index to // query. The fan-out inverts that: we ship the QUERY to each machine and let it // decrypt and search its own store, then merge the (redacted) hits here. No key // ever crosses the network, and a machine that's asleep simply drops out of the // results instead of failing the search. // // Machines come from the fleet registry first, with the older frame/puppet // registry layered on top for SSH/CDP overrides. A machine's name doubles as // its ssh host unless it sets `sshHost`/`ssh.alias`/`ssh`; set `repo` or // `repoPath` to override where the checkout lives. // // Hand-rolled JSON-RPC over stdio, matching the house style of // slab/bin/frame-mcp.mjs and artery/emacs-mcp.mjs — no SDK, node builtins only. import { execFile } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { homedir, hostname } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { httpPort, serveHttp, serveStdio } from "../toolchain/mcp/http-front.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const HOME = homedir(); const REPO = join(HERE, ".."); const FLEET_REGISTRIES = [ process.env.FLEET_MACHINES, join(HOME, "aesthetic-computer-vault", "machines.normalized.json"), join(HOME, "aesthetic-computer-vault", "machines.json"), join(REPO, "aesthetic-computer-vault", "machines.normalized.json"), join(REPO, "aesthetic-computer-vault", "machines.json"), ].filter(Boolean); const PUPPET_REGISTRY = process.env.SLAB_PUPPET_CONFIG || join(HOME, ".config", "slab", "puppet.json"); const LEDGER_LOCAL = join(HOME, ".config", "slab", "ledger", "local.json"); const DEFAULT_REPO = "~/aesthetic-computer"; const SELF = localHostName(); // e.g. neo/blueberry — what `local` actually is function readJson(path) { if (!path || !existsSync(path)) return null; try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } } function localHostName() { const ledger = readJson(LEDGER_LOCAL); if (ledger?.host) return ledger.host; return hostname().split(".")[0]; } function loadFleet() { for (const path of FLEET_REGISTRIES) { const data = readJson(path); const machines = data?.machines || data; if (machines && typeof machines === "object") return { path, machines }; } return { path: null, machines: {} }; } function loadPuppet() { const data = readJson(PUPPET_REGISTRY); return data?.machines || data || {}; } function sshHost(name, spec) { const ssh = spec.ssh; return ( spec.sshHost || (typeof ssh === "string" ? ssh : ssh?.alias || ssh?.host) || spec.host || spec.tailscale?.name || spec.hostname || name ); } function repoPath(spec) { return spec.repo || spec.repoPath || DEFAULT_REPO; } function memoryCandidate(name, spec, puppet) { if (name === SELF || spec.local || puppet[name]) return true; const caps = spec.capabilities || []; return ( caps.includes("macos-automation") || caps.includes("tailnet-api") || caps.includes("mail") || spec.designation === "agent-endpoint" ); } function normalizeMachine(name, spec = {}, puppet = {}) { return { ...spec, ...puppet, sourceName: name, local: spec.local || name === SELF || puppet.local === true, sshHost: sshHost(name, { ...spec, ...puppet }), repo: repoPath({ ...spec, ...puppet }), }; } // The fleet, as the canonical registry knows it. `local` is always present — // this Mac searches its own store in-process, no ssh. function machines() { const { machines: fleet } = loadFleet(); const puppet = loadPuppet(); const out = {}; for (const [name, spec] of Object.entries(fleet)) { if (name.startsWith("_")) continue; if (!memoryCandidate(name, spec, puppet)) continue; out[name] = normalizeMachine(name, spec, puppet[name]); } // Puppet-only machines are still valid; they may be work boxes not promoted // into the fleet registry yet. for (const [name, spec] of Object.entries(puppet)) { if (name.startsWith("_") || out[name]) continue; out[name] = normalizeMachine(name, {}, spec); } // This Mac already answers as `local`. If the fleet list also names it, drop // the named copy — otherwise its store gets searched twice and every hit // shows up doubled. delete out[SELF]; out.local = normalizeMachine("local", { local: true }, {}); return out; } function registryLabel() { const { path } = loadFleet(); return `${path || "(no fleet registry)"}; puppet overrides: ${PUPPET_REGISTRY}`; } function isLocal(name, spec) { return name === "local" || spec?.local === true; } // Run the memory CLI somewhere and get its --json envelope back. // // Remote commands go over stdin to `bash -s` rather than as an ssh argument: // the remote runs a login shell, and blueberry's is fish, which can't parse the // bash we'd otherwise send. The query is base64'd across the wire so no quoting // or shell metacharacter in a prompt ("$(", backticks, quotes — all common in // our prompts!) can reach the remote shell. function runCli(name, spec, args, { timeoutMs = 20000 } = {}) { const argv = ["memory/cli.mjs", ...args, "--json"]; if (isLocal(name, spec)) { return execFileAsync("node", [join(HERE, "cli.mjs"), ...args, "--json"], { cwd: REPO, timeoutMs, }); } const repo = spec.repo || DEFAULT_REPO; const host = spec.sshHost || name; const encoded = Buffer.from(JSON.stringify(argv), "utf8").toString("base64"); const script = [ `cd ${repo} || exit 3`, `printf %s '${encoded}' | base64 --decode > /tmp/.memory-mcp-argv.json`, `node -e 'const a=require("/tmp/.memory-mcp-argv.json");require("child_process").spawnSync(process.execPath,a,{stdio:"inherit"})'`, `rm -f /tmp/.memory-mcp-argv.json`, ].join("\n"); return execFileAsync( "ssh", [ "-o", "ControlMaster=auto", "-o", `ControlPath=${join(HOME, ".ssh", `cm-${host}`)}`, "-o", "ControlPersist=300", "-o", "BatchMode=yes", "-o", "ConnectTimeout=6", host, "bash -s", ], { input: script, timeoutMs }, ); } function execFileAsync(file, args, { cwd, input, timeoutMs } = {}) { return new Promise((resolve, reject) => { const child = execFile( file, args, { cwd, timeout: timeoutMs, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => { if (err && !stdout) return reject(new Error((stderr || err.message).trim())); resolve(stdout); }, ); if (input != null) { child.stdin.write(input); child.stdin.end(); } }); } // Ask every machine at once; an unreachable one contributes an error line, not // a failure. A search that silently dropped a sleeping Mac would be worse than // useless — you'd conclude the prompt was never written. // A login shell on a fleet Mac is not a clean pipe: neo's node version manager // greets every non-interactive command with "Using Node for alias lts-jod" on // STDOUT. So take the JSON envelope, not the whole stream — parse from the first // brace/bracket. If there's none, the remote said something that isn't a result // (usage text, a stack trace); surface its first line as the error. function parseEnvelope(stdout) { const at = stdout.search(/[[{]/); if (at < 0) throw new Error(stdout.trim().split("\n")[0] || "no output"); return JSON.parse(stdout.slice(at)); } async function fanOut(target, args) { const fleet = machines(); const names = target ? [target] : Object.keys(fleet); const unknown = names.filter((name) => !fleet[name]); if (unknown.length) throw new Error(`unknown machine: ${unknown.join(", ")} (have: ${Object.keys(fleet).join(", ")})`); const settled = await Promise.all( names.map(async (name) => { try { return { name, result: parseEnvelope(await runCli(name, fleet[name], args)) }; } catch (error) { return { name, error: String(error.message || error).split("\n")[0] }; } }), ); return settled; } function flag(name, value) { return value == null || value === false || value === "" ? [] : value === true ? [`--${name}`] : [`--${name}`, String(value)]; } async function toolSearch(a) { if (!a.query) throw new Error("`query` is required"); const args = [ "search", "--query", String(a.query), ...flag("limit", a.limit ?? 10), ...flag("since", a.since), ...flag("project", a.project), ...flag("role", a.role), ...flag("regex", a.regex), ]; const results = await fanOut(a.machine, args); const lines = []; const failures = []; const hits = []; for (const entry of results) { if (entry.error) { failures.push(` ${entry.name}: ${entry.error}`); continue; } // "[local]" is meaningless once a result is read back later — say which Mac. const label = entry.name === "local" ? SELF : entry.name; for (const hit of entry.result.hits || []) hits.push({ ...hit, machine: label }); } hits.sort((a, b) => new Date(b.when || 0) - new Date(a.when || 0)); lines.push(`${hits.length} match${hits.length === 1 ? "" : "es"} for "${a.query}" across ${results.length - failures.length} machine(s)`); if (failures.length) lines.push(`\nunreachable (their history is NOT in these results):\n${failures.join("\n")}`); lines.push(""); for (const hit of hits) { lines.push(`${hit.when} [${hit.machine}] ${hit.role || "?"} ${hit.session_id}#${hit.seq}`); lines.push(` ${hit.snippet}`); } if (!hits.length) lines.push("(nothing — try fewer words, or --regex, or widen --since)"); return [{ type: "text", text: lines.join("\n") }]; } async function toolSessions(a) { const args = ["list", ...flag("limit", a.limit ?? 15), ...flag("project", a.project)]; const results = await fanOut(a.machine, args); const lines = []; for (const entry of results) { if (entry.error) { lines.push(`[${entry.name}] unreachable: ${entry.error}`); continue; } lines.push(`[${entry.name}]`); for (const s of entry.result || []) { lines.push(` ${s.updated_at} ${s.session_id} seq=${s.last_seq} project=${s.project} "${s.title}"`); } } return [{ type: "text", text: lines.join("\n") || "(no sessions)" }]; } async function toolMachines() { const fleet = machines(); const lines = Object.entries(fleet).map(([name, spec]) => isLocal(name, spec) ? `local (= ${SELF}, this Mac — searched in-process, no ssh)` : `${name} ssh=${spec.sshHost || name} repo=${spec.repo || DEFAULT_REPO}`, ); lines.push(`\nregistry: ${registryLabel()} (fleet first; puppet supplies overrides and legacy machines)`); return [{ type: "text", text: lines.join("\n") }]; } const TOOLS = [ { name: "memory_search", description: "Search OUR PROMPT HISTORY — every prompt @jeffrey has typed to Claude/Codex — across the fleet (this Mac + neo + any other registered machine). Each machine decrypts and searches its own local store, so nothing is missed just because it was said on a different Mac. Use this to answer 'when did we decide X', 'what did I say about Y', 'have we hit this bug before'. Results are snippets with machine, session id, and timestamp; secrets are redacted. Unreachable machines are named explicitly so you know what ISN'T covered.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Text to find (case-insensitive substring, or a regex with regex:true)." }, machine: { type: "string", description: "Limit to one machine (see memory_machines). Omit to search the whole fleet." }, limit: { type: "number", description: "Max hits per machine (default 10)." }, since: { type: "string", description: "Only prompts at/after this time — ISO or YYYY-MM-DD." }, project: { type: "string", description: "Filter to one project." }, role: { type: "string", description: "Filter by role, e.g. 'user' for things @jeffrey typed." }, regex: { type: "boolean", description: "Treat query as a regular expression." }, }, required: ["query"], }, }, { name: "memory_sessions", description: "List recent agent sessions (title, project, last activity) per machine — the index behind memory_search. Use it to find a session id to search within, or to see what's been worked on lately and where.", inputSchema: { type: "object", properties: { machine: { type: "string", description: "One machine, or omit for the whole fleet." }, limit: { type: "number", description: "Sessions per machine (default 15)." }, project: { type: "string", description: "Filter to one project." }, }, }, }, { name: "memory_machines", description: "List the machines whose prompt history is searchable, and where each one's store lives. Machines come from the shared frame/puppet registry at ~/.config/slab/puppet.json.", inputSchema: { type: "object", properties: {} }, }, ]; async function callTool(name, args) { switch (name) { case "memory_search": return toolSearch(args || {}); case "memory_sessions": return toolSessions(args || {}); case "memory_machines": return toolMachines(); default: throw new Error(`Unknown tool: ${name}`); } } async function handleMessage(message) { const { id, method, params } = message; try { switch (method) { case "initialize": return { jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "memory-mcp", version: "1.0.0" }, }, }; case "initialized": case "notifications/initialized": return null; // notification — no response case "ping": return { jsonrpc: "2.0", id, result: {} }; case "tools/list": return { jsonrpc: "2.0", id, result: { tools: TOOLS } }; case "tools/call": { const content = await callTool(params?.name, params?.arguments); return { jsonrpc: "2.0", id, result: { content } }; } default: return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } }; } } catch (error) { // Tool failures come back as an error-flagged result (visible to the model) // rather than a protocol error, so the agent can read and react to them. if (method === "tools/call") { return { jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: String(error.message || error) }] } }; } return { jsonrpc: "2.0", id, error: { code: -32000, message: String(error.message || error) } }; } } const port = httpPort(process.argv, 7772); // 7769 is puppet's if (port) serveHttp({ handleMessage, port, banner: "🧠 memory-mcp shared daemon" }); else serveStdio({ handleMessage, banner: "🧠 memory-mcp server started (memory_search, memory_sessions, memory_machines)" });