UNPKG

open-memex

Version:

Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.

776 lines (739 loc) • 43.2 kB
// Full feature test — exercises every CLI command and every MCP tool. // Usage: node --experimental-strip-types scripts/test-full.ts // Isolated: uses temp HOME + MY_O_MEMORY_HOME + temp git repos. Touches nothing real. // // NOTE on flakiness: better-sqlite3 11.x intermittently crashes at process // exit on Node 24 (RemoveEnvironmentCleanupHook assertion, exit 134) — a // known pre-existing issue. The work itself always completes; only the exit // code/output flush is affected. Id-generating calls retry until an id is // parsed, and crash-prone commands retry up to 5 times. import { execFileSync, spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const REPO = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const CLI = path.join(REPO, "dist", "cli.js"); const MCP_TS = path.join(REPO, "src", "mcp.ts"); // Freshness guard: the CLI under test is the built dist/cli.js. A stale build // turns src fixes into phantom product failures, so refuse to run against one. // The check is a content fingerprint written by the build (scripts/build-stamp.mjs), // not an mtime comparison: `git checkout` rewrites src mtimes, so switching // branches can leave an older-branch dist looking newer than the branch's src. { try { execFileSync("node", [path.join(REPO, "scripts", "build-stamp.mjs"), "--check"], { cwd: REPO, stdio: ["ignore", "pipe", "pipe"], }); } catch (e: any) { console.error((e.stderr ?? "").toString().trim() || "dist/ is stale — run `npm run build` first"); process.exit(1); } } const T = fs.mkdtempSync(path.join(os.tmpdir(), "om-full-")); const TESTENV = { ...process.env, HOME: path.join(T, "home"), MY_O_MEMORY_HOME: path.join(T, "data"), }; fs.mkdirSync(TESTENV.HOME, { recursive: true }); let pass = 0, fail = 0; const failures: string[] = []; function ok(name: string, cond: boolean, info?: string) { if (cond) { pass++; console.log(` ok ${name}`); } else { fail++; failures.push(name); console.log(` FAIL ${name}${info ? " — " + info : ""}`); } } function isCrash(err: string): boolean { return /RemoveEnvironmentCleanupHook|Assertion failed/.test(err); } // Base runner: transparently retries the known better-sqlite3 exit crash // (work completes; only exit code/output flush is affected). function cliRaw(args: string[], cwd: string, env: NodeJS.ProcessEnv): { out: string; err: string; code: number } { let last = { out: "", err: "", code: 1 }; for (let i = 0; i < 5; i++) { try { const out = execFileSync("node", [CLI, ...args], { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); return { out, err: "", code: 0 }; } catch (e: any) { last = { out: e.stdout ?? "", err: e.stderr ?? "", code: e.status ?? 1 }; if (!isCrash(last.err)) break; // real error, not the flaky crash } } return last; } function cli(args: string[], cwd: string): { out: string; err: string; code: number } { return cliRaw(args, cwd, TESTENV); } function git(args: string[], cwd: string) { execFileSync("git", args, { cwd, env: TESTENV, stdio: "ignore" }); } function mkproj(name: string): string { const p = path.join(T, name); fs.mkdirSync(p, { recursive: true }); git(["init", "-q"], p); git(["config", "user.email", "test@example.com"], p); git(["config", "user.name", "Test"], p); fs.writeFileSync(path.join(p, "README.md"), "# test\n"); git(["add", "."], p); git(["commit", "-qm", "init"], p); return p; } const grabId = (out: string) => (out.match(/saved ([A-Z0-9]{26})/) || [])[1] ?? ""; const grabArrowId = (out: string) => (out.match(/→\s*([A-Z0-9]{26})/) || [])[1] ?? ""; function tarRead(tarfile: string, member: string): string { try { return execFileSync("tar", ["-xzOf", tarfile, member], { env: TESTENV, encoding: "utf8" }); } catch { return ""; } } function cliRetry(args: string[], cwd: string): { out: string; err: string; code: number } { return cli(args, cwd); // retry is built into cliRaw } // add is the id factory — the known better-sqlite3 exit crash can eat the // output, so retry until we actually get an id back. function addMem(args: string[], cwd: string): string { for (let i = 0; i < 5; i++) { const id = grabId(cli(["add", ...args], cwd).out); if (id) return id; } return ""; } function proposeMem(id: string, cwd: string): string { for (let i = 0; i < 5; i++) { const d = grabArrowId(cli(["propose", id, "--to", "project"], cwd).out); if (d) return d; } return ""; } // Separate data dir = separate machine (the real export/import scenario). const TESTENV2 = { ...TESTENV, MY_O_MEMORY_HOME: path.join(T, "data2") }; function cli2(args: string[], cwd: string): { out: string; err: string; code: number } { return cliRaw(args, cwd, TESTENV2); } console.log("== setup =="); const PROJ = mkproj("proj"); const REMOTE = path.join(T, "remote.git"); execFileSync("git", ["init", "-q", "--bare", REMOTE], { env: TESTENV }); git(["remote", "add", "origin", REMOTE], PROJ); ok("proj + bare remote ready", fs.existsSync(path.join(PROJ, ".git"))); // ---------- 1. where / scopes / config ---------- console.log("== where / scopes / config =="); let r = cli(["where"], PROJ); ok("where shows project scope", /project__/.test(r.out), r.out.slice(0, 120)); r = cli(["scopes"], PROJ); ok("scopes empty → graceful message", /no scopes with memories yet/.test(r.out), r.out.slice(0, 120)); r = cli(["config", "set", "sync.autoPull", "true"], PROJ); r = cli(["config"], PROJ); ok("config set/get dotted key", /autoPull/.test(r.out) && /true/.test(r.out), r.out.slice(0, 200)); cli(["config", "set", "sync.autoPull", "false"], PROJ); // ---------- 2. add / list / search ---------- console.log("== add / list / search =="); const idFact = addMem(["fulltest fact about deploys", "--type", "fact", "--tag", "t1"], PROJ); const idDecision = addMem(["fulltest decision to use ff merges", "--type", "decision", "--tag", "t2"], PROJ); const idPersonal = addMem(["fulltest personal pref", "--scope", "personal"], PROJ); ok("add returns ids", !!idFact && !!idDecision && !!idPersonal); r = cli(["list"], PROJ); ok("list default = project only", r.out.includes(idFact) && r.out.includes(idDecision) && !r.out.includes(idPersonal)); r = cli(["list", "--scope", "personal"], PROJ); ok("list --scope personal", r.out.includes(idPersonal) && !r.out.includes(idFact)); r = cli(["scopes"], PROJ); ok("scopes lists populated scopes", /personal/.test(r.out) && /project__/.test(r.out), r.out.slice(0, 160)); r = cli(["list", "--type", "decision"], PROJ); ok("list --type filter", r.out.includes(idDecision) && !r.out.includes(idFact)); r = cli(["search", "ff merges"], PROJ); ok("search finds decision", r.out.includes(idDecision), r.out.slice(0, 150)); // D61: capture-time aliases — a differently-worded query still hits. const idAlias = addMem(["fulltest holiday policy lives in the handbook", "--aliases", "time off;vacation days"], PROJ); r = cli(["search", "vacation"], PROJ); ok("search hits via alias-only term", r.out.includes(idAlias), r.out.slice(0, 150)); ok("alias-only hit explains itself (aka)", /aka:/i.test(r.out), r.out.slice(0, 150)); // D61 review: aliases go through the same redaction as content. (The // secret is assembled at runtime so no credential-shaped literal sits // in this file.) const fakePat = "ghp_" + "Ab3".repeat(12); const idSecretAlias = addMem(["fulltest alias redaction target", "--aliases", `${fakePat}; benign alias`], PROJ); r = cli(["search", "benign"], PROJ); ok("benign alias still searchable", r.out.includes(idSecretAlias), r.out.slice(0, 150)); { const dir = path.join(TESTENV.MY_O_MEMORY_HOME, "memories"); const stack = [dir]; let hit = ""; while (stack.length && !hit) { const d = stack.pop()!; for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) stack.push(p); else if (p.endsWith(`${idSecretAlias}.md`)) hit = fs.readFileSync(p, "utf8"); } } ok("secret-looking alias is masked in the stored file", !!hit && !hit.includes(fakePat), hit.slice(0, 200)); } // ---------- 3. supersede / status / forget ---------- console.log("== supersede / status / forget =="); r = cli(["supersede", idFact, "fulltest fact about deploys v2"], PROJ); const idV2 = grabArrowId(r.out); ok("supersede creates new version", !!idV2 && idV2 !== idFact, r.out.slice(0, 150)); const idDep = addMem(["fulltest to deprecate", "--type", "fact"], PROJ); r = cli(["status", idDep, "deprecated"], PROJ); ok("status deprecated", r.code === 0, (r.err || r.out).slice(0, 150)); r = cli(["status", idFact, "deprecated"], PROJ); ok("status on superseded chain-member is refused", r.code !== 0 && /chain-managed|supersede/i.test(r.err + r.out), (r.err || r.out).slice(0, 120)); r = cli(["forget", idDecision], PROJ); ok("forget deletes", r.code === 0 && /delet/i.test(r.out), r.out.slice(0, 120)); r = cli(["list"], PROJ); ok("forgotten id gone from list", !r.out.includes(idDecision)); // P0: forget must not report success when the file cannot be deleted — // the file is the source of truth and would be re-indexed on next sync. // (Simulated by putting a directory where the file belongs: unlink fails // with EISDIR even for root, unlike permission bits.) { const idF = addMem(["fulltest forget failure"], PROJ); const memRoot = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories"); let memFile = ""; const findFile = (d: string) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p2 = path.join(d, e.name); if (e.isDirectory()) findFile(p2); else if (e.name === idF + ".md") memFile = p2; } }; findFile(memRoot); ok("forget-failure setup found the file", !!memFile); const backup = fs.readFileSync(memFile, "utf8"); fs.rmSync(memFile); fs.mkdirSync(memFile); try { r = cli(["forget", idF], PROJ); ok("forget reports failure when unlink fails", r.code !== 0 && /could not delete/i.test(r.err + r.out), (r.err || r.out).slice(0, 150)); } finally { fs.rmSync(memFile, { recursive: true }); fs.writeFileSync(memFile, backup); } r = cli(["list"], PROJ); ok("memory survives failed forget", r.out.includes(idF), r.out.slice(0, 150)); r = cli(["forget", idF], PROJ); ok("forget succeeds once the file is deletable again", r.code === 0, (r.err || r.out).slice(0, 150)); } // ---------- 4. propose / submit / promote / sync-status ---------- console.log("== review workflow: propose / submit / promote =="); const idProp = addMem(["fulltest proposal candidate", "--scope", "personal"], PROJ); const idDraft = proposeMem(idProp, PROJ); ok("propose stages outbox draft", !!idDraft); r = cli(["sync-status"], PROJ); ok("sync-status shows outbox draft", /outbox/.test(r.out) && r.out.includes(idDraft)); r = cli(["submit", idDraft], PROJ); ok("submit commits locally", r.code === 0, (r.err || r.out).slice(0, 150)); const log = execFileSync("git", ["log", "--oneline", "-1"], { cwd: PROJ, env: TESTENV, encoding: "utf8" }); ok("submit created a git commit", /submit|mem/i.test(log), log.trim()); r = cli(["promote", idDraft, "--note", "fulltest approval"], PROJ); ok("promote → approved", r.code === 0, (r.err || r.out).slice(0, 150)); r = cli(["promote", idDraft, "--note", "fulltest publish"], PROJ); ok("promote → published", r.code === 0, (r.err || r.out).slice(0, 150)); const idRej = addMem(["fulltest reject candidate", "--scope", "personal"], PROJ); const idRejDraft = proposeMem(idRej, PROJ); r = cliRetry(["submit", idRejDraft], PROJ); ok("reject-path submit", r.code === 0, (r.err || r.out).slice(0, 120)); r = cli(["promote", idRejDraft, "--reject", "--note", "fulltest rejection"], PROJ); ok("promote --reject with note", r.code === 0, (r.err || r.out).slice(0, 150)); // ---------- 5. export / import ---------- console.log("== export / import =="); const expFile = path.join(T, "bundle.tar.gz"); r = cliRetry(["export", "-o", expFile], PROJ); ok("export creates bundle", r.code === 0 && fs.existsSync(expFile), (r.err || r.out).slice(0, 120)); const man = JSON.parse(tarRead(expFile, "manifest.json") || "{}"); ok("manifest format valid", man.format === "open-memex-export/1" && Array.isArray(man.memories) && man.memories.length > 0, JSON.stringify(man).slice(0, 120)); r = cliRetry(["export", "--scope", "personal", "--all", "-o", path.join(T, "p.tar.gz")], PROJ); const manP = JSON.parse(tarRead(path.join(T, "p.tar.gz"), "manifest.json") || "{}"); ok("export --all includes private", manP.include_private === true && manP.memories.length >= 2); const PROJ2 = mkproj("proj2"); function cli2Retry(args: string[], cwd: string): { out: string; err: string; code: number } { return cli2(args, cwd); // retry is built into cliRaw } r = cli2Retry(["import", expFile, "--dry-run"], PROJ2); ok("import --dry-run", /DRY RUN/.test(r.out), r.out.slice(0, 120)); r = cli2Retry(["import", expFile], PROJ2); ok("import real run", /imported [1-9]/.test(r.out), r.out.slice(0, 120)); r = cli2(["list"], PROJ2); ok("imported memories visible in target project", /fulltest/.test(r.out), r.out.slice(0, 160)); r = cli2Retry(["import", expFile], PROJ2); ok("import identical → skipped", /skipped [1-9]+ identical/.test(r.out), r.out.slice(0, 120)); // conflict: craft bundle with same id, different content const cid = man.memories[0].id; const cstage = path.join(T, "cstage"); fs.mkdirSync(path.join(cstage, "memories", "project"), { recursive: true }); fs.writeFileSync(path.join(cstage, "memories", "project", cid + ".md"), `---\nid: ${cid}\nscope: project\nscope_key: project__old__x\nvisibility: internal\ntype: fact\nstatus: active\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\n---\n\nCONFLICT CONTENT\n`); fs.writeFileSync(path.join(cstage, "manifest.json"), JSON.stringify({ format: "open-memex-export/1", exported_at: "2026-01-01T00:00:00Z", open_memex_version: "t", include_private: false, filters: {}, memories: [{ id: cid, scope: "project", file: `memories/project/${cid}.md` }] })); const cfile = path.join(T, "conflict.tar.gz"); execFileSync("tar", ["-czf", cfile, "-C", cstage, "manifest.json", "memories"], { env: TESTENV }); r = cli2Retry(["import", cfile], PROJ2); ok("import conflict reported, never overwritten", /conflict/.test(r.out) && /imported 0/.test(r.out), r.out.slice(0, 160)); // P0: a bundle manifest pointing outside the bundle must be rejected — // it otherwise imports arbitrary local .md files into the store. The // secret is planted next to the import staging dir (os.tmpdir()), which // is where "../" from a manifest entry lands. { const secretName = `outside-secret-${process.pid}.md`; const secretFile = path.join(os.tmpdir(), secretName); const secretId = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; fs.writeFileSync(secretFile, `---\nid: ${secretId}\nscope: personal\nscope_key: personal\nvisibility: private\ntype: fact\nstatus: active\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\n---\n\nfulltest traversal secret\n`); const tstage = path.join(T, "tstage"); fs.mkdirSync(tstage, { recursive: true }); fs.writeFileSync(path.join(tstage, "manifest.json"), JSON.stringify({ format: "open-memex-export/1", exported_at: "2026-01-01T00:00:00Z", open_memex_version: "t", include_private: true, filters: {}, memories: [{ id: secretId, scope: "personal", file: `../${secretName}` }] })); const tfile = path.join(T, "traversal.tar.gz"); execFileSync("tar", ["-czf", tfile, "-C", tstage, "manifest.json"], { env: TESTENV }); r = cli2Retry(["import", tfile], PROJ2); ok("import rejects path outside bundle", /imported 0/.test(r.out) && /rejected/.test(r.out), r.out.slice(0, 160)); r = cli2(["search", "traversal", "--scope", "personal"], PROJ2); ok("traversed file never landed in store", !/traversal secret/.test(r.out), r.out.slice(0, 160)); fs.rmSync(secretFile, { force: true }); } // ---------- 6. distill-agents ---------- console.log("== distill-agents =="); addMem(["fulltest distill decision: always fast-forward", "--type", "decision"], PROJ); addMem(["fulltest distill gotcha: sqlite crashes on exit", "--type", "gotcha"], PROJ); r = cliRetry(["distill-agents"], PROJ); ok("distill-agents proposes snippet", /## Learned/.test(r.out), r.out.slice(0, 120)); r = cliRetry(["distill-agents", "--type", "decision", "-o", path.join(T, "snip.md")], PROJ); ok("distill-agents -o writes file", fs.existsSync(path.join(T, "snip.md"))); // ---------- 7. inventory (D68) ---------- console.log("== inventory (D68) =="); addMem(["fulltest inventory entry with <script>alert(1)</script> tail", "--scope", "personal"], PROJ); r = cliRetry(["inventory"], PROJ); ok("inventory text lists the personal memory", r.out.includes("fulltest inventory entry")); ok("inventory text keeps structured provenance", /source=cli/.test(r.out)); const invJson = cliRetry(["inventory", "--format", "json"], PROJ); const invData = JSON.parse(invJson.out.slice(invJson.out.indexOf("{"))); ok("inventory json is the shared data layer", invData.format === "open-memex-inventory/2" && invData.total >= 1 && Array.isArray(invData.scopes), invData.format); const invFile = path.join(T, "inventory.json"); r = cliRetry(["inventory", "--format", "json", "--out", invFile], PROJ); ok("inventory --out writes the file", r.code === 0 && fs.existsSync(invFile), (r.err || r.out).slice(0, 150)); // D68/3-C v2: a personal-bearing report must not land in a git worktree. const inTree = path.join(PROJ, "inv.json"); r = cliRetry(["inventory", "--out", inTree], PROJ); ok("inventory refuses a worktree path without --allow-personal", r.code !== 0 && !fs.existsSync(inTree)); r = cliRetry(["inventory", "--out", inTree, "--allow-personal"], PROJ); ok("inventory allows the worktree path with --allow-personal", r.code === 0 && fs.existsSync(inTree)); fs.rmSync(inTree, { force: true }); // D68: soft forget hides; audit view still shows it; delete still works. const softId = addMem(["fulltest soft-hide candidate"], PROJ); r = cliRetry(["forget", softId, "--soft"], PROJ); ok("forget --soft hides the memory", r.code === 0 && /hidden/.test(r.out), r.out.slice(0, 120)); r = cliRetry(["list"], PROJ); ok("hidden memory out of the default list", !r.out.includes(softId)); r = cliRetry(["list", "--include", "all"], PROJ); ok("hidden memory visible with --include all", r.out.includes(softId) && r.out.includes("[retracted]"), r.out.slice(0, 160)); r = cliRetry(["forget", softId], PROJ); ok("hard forget still deletes a hidden memory", r.code === 0 && /deleted/.test(r.out)); // D68 follow-up: the HTML rendering of the same data layer. const htmlId = addMem(["fulltest html page entry"], PROJ); cliRetry(["forget", htmlId, "--soft"], PROJ); const htmlFile = path.join(T, "inventory-page.html"); r = cliRetry(["inventory", "--format", "html", "--out", htmlFile], PROJ); ok("inventory --format html writes the page", r.code === 0 && fs.existsSync(htmlFile), (r.err || r.out).slice(0, 150)); const page = fs.readFileSync(htmlFile, "utf8"); ok("html page shows current memories", page.includes("fulltest inventory entry")); ok("html page escapes memory content", page.includes("&lt;script&gt;alert(1)&lt;/script&gt;") && !page.includes("<script>alert(1)</script>")); ok("html page folds the hidden history", page.includes("Replaced &amp; hidden history") && page.includes(htmlId) && page.includes("retracted")); ok("html page enforces 'no network' with a CSP", /Content-Security-Policy/.test(page) && /default-src 'none'/.test(page)); r = cliRetry(["inventory", "--format", "html"], PROJ); ok("html defaults to the data dir", r.code === 0 && fs.existsSync(path.join(T, "data", "inventory.html")), (r.err || r.out).slice(0, 150)); const inTreePage = path.join(PROJ, "page.html"); r = cliRetry(["inventory", "--format", "html", "--out", inTreePage], PROJ); ok("html refuses a worktree path without --allow-personal", r.code !== 0 && !fs.existsSync(inTreePage)); // ---------- 8. pull / push ---------- console.log("== pull / push =="); r = cliRetry(["push"], PROJ); ok("push explicit", r.code === 0, (r.err || r.out).slice(0, 150)); const PROJ3 = path.join(T, "proj3"); execFileSync("git", ["clone", "-q", REMOTE, PROJ3], { env: TESTENV }); git(["config", "user.email", "test@example.com"], PROJ3); git(["config", "user.name", "Test"], PROJ3); const idRemote = addMem(["fulltest remote memory", "--type", "fact"], PROJ3); cliRetry(["submit", idRemote], PROJ3); cliRetry(["push"], PROJ3); r = cliRetry(["pull"], PROJ); ok("pull fast-forward", r.code === 0 && /up.to.date|fast-forward|pulled/i.test(r.out + r.err), (r.out + r.err).slice(0, 150)); // diverged: commit on both sides fs.writeFileSync(path.join(PROJ, "div1.txt"), "a"); git(["add", "."], PROJ); git(["commit", "-qm", "div1"], PROJ); fs.writeFileSync(path.join(PROJ3, "div2.txt"), "b"); git(["add", "."], PROJ3); git(["commit", "-qm", "div2"], PROJ3); cli(["push"], PROJ3); r = cli(["pull"], PROJ); ok("pull diverged → clear failure", r.code !== 0 && /diverg|behind|ahead/i.test(r.out + r.err), (r.out + r.err).slice(0, 160)); // ---------- 9. migrate / reindex ---------- console.log("== migrate / reindex =="); r = cli(["migrate", "--dry-run"], PROJ); ok("migrate bare → graceful no-op message", /no --from given/.test(r.out + r.err), (r.out + r.err).slice(0, 150)); const idMig = addMem(["fulltest migrate me", "--scope", "personal"], PROJ); r = cli(["migrate", "--from", "personal", "--to", "project", "--dry-run"], PROJ); ok("migrate personal→project dry-run", r.code === 0, (r.err || r.out).slice(0, 150)); const dbPath = path.join(TESTENV.MY_O_MEMORY_HOME as string, "index.db"); fs.rmSync(dbPath); const beforeReindex = Date.now(); cli(["reindex"], PROJ); // exit code unreliable (known sqlite exit crash); verify by artifact let dbOk = false; try { dbOk = fs.existsSync(dbPath) && fs.statSync(dbPath).mtimeMs >= beforeReindex - 1000; } catch { /* no */ } ok("reindex rebuilds", dbOk, `db exists: ${fs.existsSync(dbPath)}`); r = cli(["list", "--scope", "personal"], PROJ); ok("list works after reindex", r.out.includes(idMig) || r.out.includes(idPersonal)); // P0: a real migrate must re-scope the file, not just re-key it — a // personal memory migrated to the project key must become submittable // project memory (scope: project in its frontmatter). r = cli(["migrate", "--from", "personal", "--to", "project"], PROJ); ok("migrate personal→project runs", r.code === 0, (r.err || r.out).slice(0, 150)); { const memRoot = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories"); let moved = ""; const walk = (d: string) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (e.name === idMig + ".md") moved = fs.readFileSync(p, "utf8"); } }; walk(memRoot); ok("migrated file is now scope: project", /^scope: project$/m.test(moved), moved.slice(0, 200)); } // ---------- 10. capture / doctor / mcp --print-config / init ---------- console.log("== capture / doctor / mcp / init =="); r = cli(["capture", "--dry-run", "remember: we deploy on Fridays and use ff merges"], PROJ); ok("capture --dry-run", r.code === 0 && /deploy|friday/i.test(r.out), r.out.slice(0, 150)); for (const c of ["vscode", "cursor", "claude", "opencode", "visualstudio"]) { r = cli(["mcp", "--print-config", c], PROJ); ok(`mcp --print-config ${c}`, r.code === 0 && r.out.trim().length > 10, r.err.slice(0, 100)); } r = cli(["init", "--client", "vscode", "--yes"], PROJ); ok("init --client vscode --yes", r.code === 0 && fs.existsSync(path.join(PROJ, ".vscode", "mcp.json")), (r.err || r.out).slice(0, 150)); // P0: VS init must merge into an existing .mcp.json, not replace it — // the parsed doc used to be dropped, wiping other MCP servers. { const mcpFile = path.join(PROJ, ".mcp.json"); fs.writeFileSync(mcpFile, JSON.stringify({ servers: { "other-server": { type: "stdio", command: "other" } } })); r = cli(["init", "--client", "visualstudio", "--yes"], PROJ); const after = JSON.parse(fs.readFileSync(mcpFile, "utf8")) as { servers?: Record<string, unknown> }; ok("VS init preserves other servers in .mcp.json", r.code === 0 && !!after.servers?.["other-server"] && !!after.servers?.["open-memex"], JSON.stringify(after).slice(0, 160)); } r = cliRetry(["doctor"], PROJ); ok("doctor", r.code === 0 && /All checks passed/.test(r.out), (r.err || r.out).slice(0, 200)); // ---------- 11. MCP: all 11 tools + session-start instructions ---------- console.log("== MCP tools =="); { const child = spawn("node", ["--experimental-strip-types", MCP_TS], { cwd: PROJ, env: TESTENV, stdio: ["pipe", "pipe", "pipe"], }); let buf = ""; let id = 0; const pending = new Map<number, (v: any) => void>(); child.stdout.on("data", (d: Buffer) => { buf += d.toString(); let idx: number; while ((idx = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, idx).trim(); buf = buf.slice(idx + 1); if (!line) continue; try { const msg = JSON.parse(line); if (msg.id !== undefined && pending.has(msg.id)) { pending.get(msg.id)!(msg); pending.delete(msg.id); } } catch { /* ignore */ } } }); const req = (method: string, params: any) => new Promise<any>((resolve) => { const i = ++id; pending.set(i, resolve); child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: i, method, params }) + "\n"); setTimeout(() => { if (pending.has(i)) { pending.delete(i); resolve({ __timeout: true }); } }, 30000); }); const call = async (tool: string, args: any) => { const resp = await req("tools/call", { name: tool, arguments: args }); const text = (resp.result?.content || []).map((c: any) => c.text || "").join("\n"); return { resp, text }; }; const init = await req("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "1" } }); const instructions: string = init.result?.instructions || ""; ok("MCP initialize returns instructions", instructions.length > 200, instructions.slice(0, 100)); ok("instructions mention sync/memory_status", /memory_status|sync/i.test(instructions)); await req("notifications/initialized", {}); const tools = await req("tools/list", {}); const names: string[] = (tools.result?.tools || []).map((t: any) => t.name); const expected = ["memory_add","memory_forget","memory_list","memory_pr_status","memory_promote","memory_propose","memory_resolve","memory_search","memory_status","memory_submit","memory_supersede"]; ok("11 MCP tools listed", expected.every((n) => names.includes(n)), names.join(",")); let m = await call("memory_add", { content: "mcptest fact via MCP", type: "fact" }); const mid = (m.text.match(/([A-Z0-9]{26})/) || [])[1] || ""; ok("memory_add", !!mid, m.text.slice(0, 120)); m = await call("memory_search", { query: "mcptest fact" }); ok("memory_search", m.text.includes(mid), m.text.slice(0, 120)); m = await call("memory_list", { limit: 5 }); ok("memory_list", m.text.includes(mid) || /fact/.test(m.text), m.text.slice(0, 120)); m = await call("memory_status", {}); ok("memory_status", /project|outbox|sync/i.test(m.text), m.text.slice(0, 120)); m = await call("memory_supersede", { id: mid, content: "mcptest fact via MCP v2" }); const mid2 = (m.text.match(/with ([A-Z0-9]{26})/) || [])[1] || ""; ok("memory_supersede", !!mid2 && mid2 !== mid, m.text.slice(0, 120)); // propose → submit → promote via MCP const pid = ((await call("memory_add", { content: "mcptest proposal", scope: "personal" })).text.match(/([A-Z0-9]{26})/) || [])[1] || ""; m = await call("memory_propose", { ids: [pid] }); const pdraft = (m.text.match(/→\s*([A-Z0-9]{26})/) || m.text.match(/([A-Z0-9]{26})/) || [])[1] || ""; ok("memory_propose", !!pdraft && pdraft !== pid, m.text.slice(0, 120)); m = await call("memory_submit", { ids: [pdraft] }); ok("memory_submit", !/error/i.test(m.text) || /commit|submit/i.test(m.text), m.text.slice(0, 120)); m = await call("memory_promote", { id: pdraft, note: "mcptest approve" }); ok("memory_promote", !/__timeout/.test(JSON.stringify(m.resp)), m.text.slice(0, 120)); m = await call("memory_resolve", {}); ok("memory_resolve (list)", !/__timeout/.test(JSON.stringify(m.resp)), m.text.slice(0, 120)); m = await call("memory_pr_status", {}); ok("memory_pr_status (report)", !/__timeout/.test(JSON.stringify(m.resp)), m.text.slice(0, 120)); m = await call("memory_forget", { id: mid2 }); ok("memory_forget", /delet|forget/i.test(m.text), m.text.slice(0, 120)); child.kill(); } // ---------- 12. D64: P1 review-flow honesty fixes ---------- console.log("== D64: p1 fixes (propose visibility / supersede published / status door / plugin sync / status cap / doctor v1 / uninstall jsonc) =="); { r = cli(["where"], PROJ); const pkey = (/project:\s*(\S+)/.exec(r.out) || [])[1] ?? ""; // propose: the project copy must not inherit visibility: private, or // export would silently drop it from every team bundle. const seedId = addMem(["zzq p1 visible fact for the team", "--scope", "personal"], PROJ); const copyId = proposeMem(seedId, PROJ); const copyFile = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories", pkey, `${copyId}.md`); const copyText = fs.existsSync(copyFile) ? fs.readFileSync(copyFile, "utf8") : ""; ok("propose copy is internal, not private", /visibility: internal/.test(copyText), copyText.slice(0, 160)); // supersede a published memory: the replacement must re-enter review // at "proposed" (promotable), not land as a stranded repo draft. const did = addMem(["zzq p1 publish flow fact"], PROJ); cli(["submit", did], PROJ); cli(["promote", did], PROJ); r = cli(["promote", did], PROJ); const pubFile = path.join(PROJ, ".ai", "open-memex", `${did}.md`); ok("setup: promoted to published", r.code === 0 && /review_state: published/.test(fs.readFileSync(pubFile, "utf8")), (r.err || r.out).slice(0, 150)); r = cli(["supersede", did, "zzq p1 publish flow fact v2"], PROJ); const nid = grabArrowId(r.out); const newFile = nid ? path.join(PROJ, ".ai", "open-memex", `${nid}.md`) : ""; const newText = newFile && fs.existsSync(newFile) ? fs.readFileSync(newFile, "utf8") : ""; ok("supersede of published → proposed (not stranded draft)", /review_state: proposed/.test(newText), (r.out + newText).slice(0, 200)); r = cli(["promote", nid], PROJ); ok("promote advances the replacement", r.code === 0 && /approved/.test(r.out + (fs.existsSync(newFile) ? fs.readFileSync(newFile, "utf8") : "")), (r.err || r.out).slice(0, 150)); // status: retracted cannot silently return to active. const sid = addMem(["zzq p1 retract flow fact"], PROJ); r = cli(["status", sid, "retracted"], PROJ); ok("status → retracted", r.code === 0, (r.err || r.out).slice(0, 120)); r = cli(["status", sid, "active"], PROJ); ok("retracted → active is refused", r.code !== 0 && /retracted/.test(r.err), (r.out + r.err).slice(0, 160)); // plugin host: per-call sync — wipe the (rebuildable) index and the // opencode tools must still see memories written by other processes. const marker = `zzqp1sync${Date.now()}`; const pmid = addMem([`${marker} plugin freshness fact`, "--scope", "personal"], PROJ); fs.rmSync(path.join(TESTENV.MY_O_MEMORY_HOME as string, "index.db"), { force: true }); fs.rmSync(path.join(TESTENV.MY_O_MEMORY_HOME as string, "index.db-wal"), { force: true }); fs.rmSync(path.join(TESTENV.MY_O_MEMORY_HOME as string, "index.db-shm"), { force: true }); const probe = path.join(T, "p1-plugin-probe.ts"); fs.writeFileSync(probe, [ // file:// URLs, not raw paths — Windows rejects drive-letter specifiers. `import { makeTools } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "tools", "memory.ts")).href)};`, `import { PERSONAL_SCOPE } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "scope.ts")).href)};`, `import { loadConfig } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "config.ts")).href)};`, `const tools = makeTools(() => PERSONAL_SCOPE, loadConfig());`, `const r = await tools.memory_search.execute({ query: ${JSON.stringify(marker)} });`, `console.log(r.output);`, ].join("\n")); let probeOut = ""; try { probeOut = execFileSync("node", ["--experimental-strip-types", probe], { cwd: PROJ, env: TESTENV, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } catch (e: any) { probeOut = (e.stdout ?? "") + (e.stderr ?? ""); } ok("plugin tools sync before answering (index rebuilt)", probeOut.includes(pmid) || probeOut.includes(marker), probeOut.slice(0, 200)); // sync-status: long sections are capped with an "and N more" line. const outboxDir = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories", pkey); const template = fs.readFileSync(copyFile, "utf8"); for (let i = 0; i < 21; i++) { const cid = `01P1CAP${String(i).padStart(19, "0")}`; fs.writeFileSync(path.join(outboxDir, `${cid}.md`), template.replace(copyId, cid).replace("zzq p1 visible fact for the team", `zzq cap filler ${i}`)); } cli(["reindex"], PROJ); r = cli(["sync-status"], PROJ); // CLI shows the full list (the 20-line cap applies to agent tool // results, whose "… and N more" line points back here). const fillerShown = (r.out.match(/zzq cap filler/g) ?? []).length; ok("sync-status shows the full list", fillerShown === 21 && !/… and \d+ more/.test(r.out), `shown=${fillerShown}`); // ...and the agent-facing render (what memory_status returns) caps per section // and names the CLI, so a hidden entry is still reachable. const capProbe = path.join(T, "p1-cap-probe.ts"); fs.writeFileSync(capProbe, [ `import { getSyncStatus, formatSyncStatus } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "submit.ts")).href)};`, `console.log(formatSyncStatus(getSyncStatus()));`, ].join("\n")); let capOut = ""; try { capOut = execFileSync("node", ["--experimental-strip-types", capProbe], { cwd: PROJ, env: TESTENV, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } catch (e: any) { capOut = (e.stdout ?? "") + (e.stderr ?? ""); } const outboxSection = (capOut.split("outbox (appdata")[1] ?? "").split("repo .ai/open-memex")[0]; const capEntries = (outboxSection.match(/\[(draft|proposed|approved|published|rejected)\]/g) ?? []).length; const capFiller = (outboxSection.match(/zzq cap filler/g) ?? []).length; ok( "agent-facing sync-status caps and points at the CLI", capEntries === 20 && capFiller < 21 && /… and \d+ more \(full list: open-memex sync-status\)/.test(outboxSection), `entries=${capEntries} fillers=${capFiller} ${outboxSection.slice(0, 200)}`, ); for (let i = 0; i < 21; i++) fs.rmSync(path.join(outboxDir, `01P1CAP${String(i).padStart(19, "0")}.md`), { force: true }); // doctor: v1 memories left under memories/user/ must be flagged. const userDir = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories", "user"); fs.mkdirSync(userDir, { recursive: true }); fs.writeFileSync(path.join(userDir, "legacy-one.md"), "---\nid: legacy\n---\nold personal note\n"); r = cliRetry(["doctor"], PROJ); ok("doctor flags invisible v1 user memories", /v1 personal memories/.test(r.out), r.out.slice(0, 200)); fs.rmSync(userDir, { recursive: true, force: true }); cli(["reindex"], PROJ); // uninstall: a .jsonc-only global opencode config must be found. const home2 = path.join(T, "home-opencode"); const ocDir = path.join(home2, ".config", "opencode"); fs.mkdirSync(ocDir, { recursive: true }); const jsoncFile = path.join(ocDir, "opencode.jsonc"); fs.writeFileSync(jsoncFile, JSON.stringify({ plugin: ["file:///plugins/open-memex/src/index.ts"] }, null, 2)); // USERPROFILE too: os.homedir() ignores HOME on Windows. r = cliRaw(["uninstall", "--client", "opencode", "--global", "--yes"], PROJ, { ...TESTENV, HOME: home2, USERPROFILE: home2 }); const afterText = fs.readFileSync(jsoncFile, "utf8"); ok("uninstall finds and cleans .jsonc global config", r.code === 0 && !/open-memex/.test(afterText), (r.out + r.err).slice(0, 160) + " || " + afterText.slice(0, 120)); } // ---------- 12. D70: the visibility fixes from the 3-C review ---------- // Last section on purpose: nothing here may perturb the flows above it. // It writes only into a scratch project and into appdata fixtures (no // commits in PROJ, no extra CLI spawns beyond a handful) so the suite stays // order-independent and cheap — the shared PROJ git state is never dirtied. console.log("== D70: honest counts, stable numbers, one guard =="); { const fixture = (dir: string, id: string, body: string, scopeKey: string) => { const now = new Date().toISOString(); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( path.join(dir, `${id}.md`), `--- id: ${id} schema_version: 2 scope_key: ${scopeKey} scope: project visibility: internal project_name: d70 type: fact role: knowledge importance: normal status: active tags: [] source: cli created_at: "${now}" updated_at: "${now}" supersedes: null superseded_by: null review_state: draft proposed_by: null approved_by: null derived_from: null review_note: null review_history: [] --- ${body} `, "utf8", ); }; // The guard must not fail open: a --out whose parent directory does not // exist used to skip the worktree check (git -C on a missing dir throws) // and then die with a raw ENOENT. Outside a worktree the missing directory // is the only problem left, so it gets its own message. const missingDir = path.join(T, "no-such-dir", "report.json"); r = cliRetry(["inventory", "--out", missingDir], PROJ); ok( "inventory explains a missing parent directory", r.code !== 0 && /does not exist/.test(r.err) && !r.err.includes("ENOENT"), (r.err || r.out).slice(0, 160), ); // ...and inside a worktree the personal-content refusal wins over it. const missingInTree = path.join(PROJ, "no-such-dir", "report.json"); r = cliRetry(["inventory", "--out", missingInTree], PROJ); ok( "worktree refusal wins over a missing directory", r.code !== 0 && /git working tree/.test(r.err) && !fs.existsSync(missingInTree), (r.err || r.out).slice(0, 160), ); // `open-memex list` is the surface a sceptical user runs first: it must // not look complete when it isn't. Fixtures go straight into the outbox // (appdata, never the repo), so this needs no commits and no cleanup in git. const d70Key = (/project:\s*(\S+)/.exec(cli(["where"], PROJ).out) || [])[1] ?? ""; const outboxDir = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories", d70Key); const fillerIds: string[] = []; for (let i = 0; i < 22; i++) { const cid = `01P1D70${String(i).padStart(19, "0")}`; fillerIds.push(cid); fixture(outboxDir, cid, `fulltest d70 filler ${i} padding text`, d70Key); } cli(["reindex"], PROJ); r = cliRetry(["list", "--limit", "5"], PROJ); ok("cli list discloses truncation", /… and \d+ more not shown/.test(r.out), r.out.slice(-160)); ok( "cli list renders the structured inventory line", /^\d+\. \[[a-z]+\] id=\S+ created=\S+ source=/m.test(r.out), r.out.slice(0, 200), ); // Numbering runs across the whole listing, so a number names one entry. r = cliRetry(["list", "--scope", "both", "--limit", "5"], PROJ); const nums = [...r.out.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1])); ok( "numbers are unique and ascending across scopes", nums.length > 2 && new Set(nums).size === nums.length && nums[0] === 1 && nums.every((n, i) => i === 0 || n === nums[i - 1]! + 1), JSON.stringify(nums), ); // The text report numbers the same way. r = cliRetry(["inventory", "--scope", "project"], PROJ); const textNums = [...r.out.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1])); ok( "inventory text numbers continuously too", textNums.length > 2 && new Set(textNums).size === textNums.length && textNums.every((n, i) => i === 0 || n === textNums[i - 1]! + 1), JSON.stringify(textNums.slice(0, 8)), ); for (const id of fillerIds) fs.rmSync(path.join(outboxDir, `${id}.md`), { force: true }); cli(["reindex"], PROJ); // A retraction of an in-repo memory is a local working-tree edit until it // is committed - "hidden" must not read as "gone for the team". Runs in its // own project so PROJ's git state stays clean for the sections above. const d70Proj = mkproj("d70-proj"); const repoHideId = addMem(["fulltest d70 repo retraction"], d70Proj); cliRetry(["submit", repoHideId], d70Proj); r = cliRetry(["forget", repoHideId, "--soft"], d70Proj); ok( "soft hide of a repo memory warns about the commit", /commit and push/i.test(r.out) && /team still sees it/i.test(r.out), r.out.slice(0, 200), ); r = cliRetry(["forget", repoHideId], d70Proj); ok("hard forget of a hidden repo memory still deletes", r.code === 0 && /deleted/i.test(r.out), (r.err || r.out).slice(0, 120)); // An empty scope must still appear: "this project has nothing" is an answer. const emptyProj = mkproj("empty-proj"); r = cliRetry(["inventory", "--scope", "project"], emptyProj); ok( "inventory shows an empty scope as 0", /Project:.*\(0\)/.test(r.out) && /nothing remembered/i.test(r.out), r.out.slice(0, 200), ); // JSON: no absolute local paths, and the format id records the change. r = cliRetry(["inventory", "--format", "json"], PROJ); const invJson2 = JSON.parse(r.out.slice(r.out.indexOf("{"))); ok( "inventory json is /2 and carries no absolute file paths", invJson2.format === "open-memex-inventory/2" && !JSON.stringify(invJson2).includes(TESTENV.MY_O_MEMORY_HOME as string) && !/"file":/.test(JSON.stringify(invJson2)), `${invJson2.format} ${JSON.stringify(invJson2).slice(0, 120)}`, ); ok( "inventory json counts hidden without the entries", invJson2.hidden !== undefined && !("hiddenEntries" in invJson2), ); } console.log("\n================ SUMMARY ================"); console.log(`pass: ${pass}, fail: ${fail}`); if (failures.length) { console.log("failed tests:"); for (const f of failures) console.log(" - " + f); } process.exit(fail ? 1 : 0);