open-memex 0.7.0-alpha.1 → 0.7.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/AGENTS.md +23 -5
  2. package/CHANGELOG.md +88 -0
  3. package/README.md +46 -15
  4. package/README.zh-CN.md +27 -14
  5. package/dist/.build-stamp +1 -0
  6. package/dist/capture/keywords.js +41 -8
  7. package/dist/cli.js +259 -40
  8. package/dist/config.js +102 -18
  9. package/dist/distill-agents.js +9 -1
  10. package/dist/doctor.js +37 -47
  11. package/dist/export.js +12 -2
  12. package/dist/index.js +4 -2
  13. package/dist/init.js +23 -6
  14. package/dist/inventory-html.js +150 -0
  15. package/dist/inventory.js +176 -0
  16. package/dist/mcp.js +21 -5
  17. package/dist/paths.js +34 -2
  18. package/dist/retrieve/display.js +161 -0
  19. package/dist/retrieve/inject.js +1 -1
  20. package/dist/retrieve/search.js +44 -5
  21. package/dist/review.js +9 -3
  22. package/dist/scope.js +17 -0
  23. package/dist/store/db.js +11 -9
  24. package/dist/store/lifecycle.js +20 -4
  25. package/dist/store/markdown.js +53 -7
  26. package/dist/store/migrate.js +14 -3
  27. package/dist/store/sync.js +5 -3
  28. package/dist/store/v2migrate.js +2 -2
  29. package/dist/submit.js +13 -5
  30. package/dist/tools/memory.js +28 -0
  31. package/dist/tools/ops.js +127 -16
  32. package/docs/CURATOR.md +7 -3
  33. package/docs/V2-DESIGN.md +299 -32
  34. package/docs/memory-visibility-design.md +130 -0
  35. package/package.json +2 -2
  36. package/scripts/build-stamp.mjs +63 -0
  37. package/scripts/smoke-mcp.ts +47 -1
  38. package/scripts/smoke-pure.ts +227 -3
  39. package/scripts/test-full.ts +435 -5
  40. package/skills/open-memex/SKILL.md +53 -1
  41. package/src/capture/keywords.ts +56 -6
  42. package/src/cli.ts +282 -39
  43. package/src/config.ts +104 -17
  44. package/src/distill-agents.ts +19 -0
  45. package/src/doctor.ts +41 -42
  46. package/src/export.ts +14 -2
  47. package/src/index.ts +4 -2
  48. package/src/init.ts +25 -6
  49. package/src/inventory-html.ts +191 -0
  50. package/src/inventory.ts +242 -0
  51. package/src/mcp.ts +21 -5
  52. package/src/paths.ts +35 -1
  53. package/src/retrieve/display.ts +174 -0
  54. package/src/retrieve/inject.ts +1 -1
  55. package/src/retrieve/search.ts +63 -6
  56. package/src/review.ts +9 -2
  57. package/src/scope.ts +18 -0
  58. package/src/store/db.ts +11 -9
  59. package/src/store/lifecycle.ts +24 -3
  60. package/src/store/markdown.ts +60 -7
  61. package/src/store/migrate.ts +15 -2
  62. package/src/store/sync.ts +6 -3
  63. package/src/store/v2migrate.ts +2 -1
  64. package/src/submit.ts +12 -4
  65. package/src/tools/memory.ts +28 -0
  66. package/src/tools/ops.ts +147 -17
@@ -11,12 +11,29 @@ import { execFileSync, spawn } from "node:child_process";
11
11
  import fs from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
- import { fileURLToPath } from "node:url";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
15
 
16
16
  const REPO = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
17
17
  const CLI = path.join(REPO, "dist", "cli.js");
18
18
  const MCP_TS = path.join(REPO, "src", "mcp.ts");
19
19
 
20
+ // Freshness guard: the CLI under test is the built dist/cli.js. A stale build
21
+ // turns src fixes into phantom product failures, so refuse to run against one.
22
+ // The check is a content fingerprint written by the build (scripts/build-stamp.mjs),
23
+ // not an mtime comparison: `git checkout` rewrites src mtimes, so switching
24
+ // branches can leave an older-branch dist looking newer than the branch's src.
25
+ {
26
+ try {
27
+ execFileSync("node", [path.join(REPO, "scripts", "build-stamp.mjs"), "--check"], {
28
+ cwd: REPO,
29
+ stdio: ["ignore", "pipe", "pipe"],
30
+ });
31
+ } catch (e: any) {
32
+ console.error((e.stderr ?? "").toString().trim() || "dist/ is stale — run `npm run build` first");
33
+ process.exit(1);
34
+ }
35
+ }
36
+
20
37
  const T = fs.mkdtempSync(path.join(os.tmpdir(), "om-full-"));
21
38
  const TESTENV = {
22
39
  ...process.env,
@@ -133,6 +150,34 @@ ok("list --type filter", r.out.includes(idDecision) && !r.out.includes(idFact));
133
150
  r = cli(["search", "ff merges"], PROJ);
134
151
  ok("search finds decision", r.out.includes(idDecision), r.out.slice(0, 150));
135
152
 
153
+ // D61: capture-time aliases — a differently-worded query still hits.
154
+ const idAlias = addMem(["fulltest holiday policy lives in the handbook", "--aliases", "time off;vacation days"], PROJ);
155
+ r = cli(["search", "vacation"], PROJ);
156
+ ok("search hits via alias-only term", r.out.includes(idAlias), r.out.slice(0, 150));
157
+ ok("alias-only hit explains itself (aka)", /aka:/i.test(r.out), r.out.slice(0, 150));
158
+
159
+ // D61 review: aliases go through the same redaction as content. (The
160
+ // secret is assembled at runtime so no credential-shaped literal sits
161
+ // in this file.)
162
+ const fakePat = "ghp_" + "Ab3".repeat(12);
163
+ const idSecretAlias = addMem(["fulltest alias redaction target", "--aliases", `${fakePat}; benign alias`], PROJ);
164
+ r = cli(["search", "benign"], PROJ);
165
+ ok("benign alias still searchable", r.out.includes(idSecretAlias), r.out.slice(0, 150));
166
+ {
167
+ const dir = path.join(TESTENV.MY_O_MEMORY_HOME, "memories");
168
+ const stack = [dir];
169
+ let hit = "";
170
+ while (stack.length && !hit) {
171
+ const d = stack.pop()!;
172
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
173
+ const p = path.join(d, e.name);
174
+ if (e.isDirectory()) stack.push(p);
175
+ else if (p.endsWith(`${idSecretAlias}.md`)) hit = fs.readFileSync(p, "utf8");
176
+ }
177
+ }
178
+ ok("secret-looking alias is masked in the stored file", !!hit && !hit.includes(fakePat), hit.slice(0, 200));
179
+ }
180
+
136
181
  // ---------- 3. supersede / status / forget ----------
137
182
  console.log("== supersede / status / forget ==");
138
183
  r = cli(["supersede", idFact, "fulltest fact about deploys v2"], PROJ);
@@ -148,6 +193,40 @@ ok("forget deletes", r.code === 0 && /delet/i.test(r.out), r.out.slice(0, 120));
148
193
  r = cli(["list"], PROJ);
149
194
  ok("forgotten id gone from list", !r.out.includes(idDecision));
150
195
 
196
+ // P0: forget must not report success when the file cannot be deleted —
197
+ // the file is the source of truth and would be re-indexed on next sync.
198
+ // (Simulated by putting a directory where the file belongs: unlink fails
199
+ // with EISDIR even for root, unlike permission bits.)
200
+ {
201
+ const idF = addMem(["fulltest forget failure"], PROJ);
202
+ const memRoot = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories");
203
+ let memFile = "";
204
+ const findFile = (d: string) => {
205
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
206
+ const p2 = path.join(d, e.name);
207
+ if (e.isDirectory()) findFile(p2);
208
+ else if (e.name === idF + ".md") memFile = p2;
209
+ }
210
+ };
211
+ findFile(memRoot);
212
+ ok("forget-failure setup found the file", !!memFile);
213
+ const backup = fs.readFileSync(memFile, "utf8");
214
+ fs.rmSync(memFile);
215
+ fs.mkdirSync(memFile);
216
+ try {
217
+ r = cli(["forget", idF], PROJ);
218
+ ok("forget reports failure when unlink fails",
219
+ r.code !== 0 && /could not delete/i.test(r.err + r.out),
220
+ (r.err || r.out).slice(0, 150));
221
+ } finally {
222
+ fs.rmSync(memFile, { recursive: true });
223
+ fs.writeFileSync(memFile, backup);
224
+ }
225
+ r = cli(["list"], PROJ);
226
+ ok("memory survives failed forget", r.out.includes(idF), r.out.slice(0, 150));
227
+ r = cli(["forget", idF], PROJ);
228
+ ok("forget succeeds once the file is deletable again", r.code === 0, (r.err || r.out).slice(0, 150));
229
+ }
151
230
  // ---------- 4. propose / submit / promote / sync-status ----------
152
231
  console.log("== review workflow: propose / submit / promote ==");
153
232
  const idProp = addMem(["fulltest proposal candidate", "--scope", "personal"], PROJ);
@@ -203,6 +282,26 @@ const cfile = path.join(T, "conflict.tar.gz");
203
282
  execFileSync("tar", ["-czf", cfile, "-C", cstage, "manifest.json", "memories"], { env: TESTENV });
204
283
  r = cli2Retry(["import", cfile], PROJ2);
205
284
  ok("import conflict reported, never overwritten", /conflict/.test(r.out) && /imported 0/.test(r.out), r.out.slice(0, 160));
285
+ // P0: a bundle manifest pointing outside the bundle must be rejected —
286
+ // it otherwise imports arbitrary local .md files into the store. The
287
+ // secret is planted next to the import staging dir (os.tmpdir()), which
288
+ // is where "../" from a manifest entry lands.
289
+ {
290
+ const secretName = `outside-secret-${process.pid}.md`;
291
+ const secretFile = path.join(os.tmpdir(), secretName);
292
+ const secretId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
293
+ 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`);
294
+ const tstage = path.join(T, "tstage");
295
+ fs.mkdirSync(tstage, { recursive: true });
296
+ 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}` }] }));
297
+ const tfile = path.join(T, "traversal.tar.gz");
298
+ execFileSync("tar", ["-czf", tfile, "-C", tstage, "manifest.json"], { env: TESTENV });
299
+ r = cli2Retry(["import", tfile], PROJ2);
300
+ ok("import rejects path outside bundle", /imported 0/.test(r.out) && /rejected/.test(r.out), r.out.slice(0, 160));
301
+ r = cli2(["search", "traversal", "--scope", "personal"], PROJ2);
302
+ ok("traversed file never landed in store", !/traversal secret/.test(r.out), r.out.slice(0, 160));
303
+ fs.rmSync(secretFile, { force: true });
304
+ }
206
305
 
207
306
  // ---------- 6. distill-agents ----------
208
307
  console.log("== distill-agents ==");
@@ -213,7 +312,53 @@ ok("distill-agents proposes snippet", /## Learned/.test(r.out), r.out.slice(0, 1
213
312
  r = cliRetry(["distill-agents", "--type", "decision", "-o", path.join(T, "snip.md")], PROJ);
214
313
  ok("distill-agents -o writes file", fs.existsSync(path.join(T, "snip.md")));
215
314
 
216
- // ---------- 7. pull / push ----------
315
+ // ---------- 7. inventory (D68) ----------
316
+ console.log("== inventory (D68) ==");
317
+ addMem(["fulltest inventory entry with <script>alert(1)</script> tail", "--scope", "personal"], PROJ);
318
+ r = cliRetry(["inventory"], PROJ);
319
+ ok("inventory text lists the personal memory", r.out.includes("fulltest inventory entry"));
320
+ ok("inventory text keeps structured provenance", /source=cli/.test(r.out));
321
+ const invJson = cliRetry(["inventory", "--format", "json"], PROJ);
322
+ const invData = JSON.parse(invJson.out.slice(invJson.out.indexOf("{")));
323
+ ok("inventory json is the shared data layer", invData.format === "open-memex-inventory/2" && invData.total >= 1 && Array.isArray(invData.scopes), invData.format);
324
+ const invFile = path.join(T, "inventory.json");
325
+ r = cliRetry(["inventory", "--format", "json", "--out", invFile], PROJ);
326
+ ok("inventory --out writes the file", r.code === 0 && fs.existsSync(invFile), (r.err || r.out).slice(0, 150));
327
+ // D68/3-C v2: a personal-bearing report must not land in a git worktree.
328
+ const inTree = path.join(PROJ, "inv.json");
329
+ r = cliRetry(["inventory", "--out", inTree], PROJ);
330
+ ok("inventory refuses a worktree path without --allow-personal", r.code !== 0 && !fs.existsSync(inTree));
331
+ r = cliRetry(["inventory", "--out", inTree, "--allow-personal"], PROJ);
332
+ ok("inventory allows the worktree path with --allow-personal", r.code === 0 && fs.existsSync(inTree));
333
+ fs.rmSync(inTree, { force: true });
334
+ // D68: soft forget hides; audit view still shows it; delete still works.
335
+ const softId = addMem(["fulltest soft-hide candidate"], PROJ);
336
+ r = cliRetry(["forget", softId, "--soft"], PROJ);
337
+ ok("forget --soft hides the memory", r.code === 0 && /hidden/.test(r.out), r.out.slice(0, 120));
338
+ r = cliRetry(["list"], PROJ);
339
+ ok("hidden memory out of the default list", !r.out.includes(softId));
340
+ r = cliRetry(["list", "--include", "all"], PROJ);
341
+ ok("hidden memory visible with --include all", r.out.includes(softId) && r.out.includes("[retracted]"), r.out.slice(0, 160));
342
+ r = cliRetry(["forget", softId], PROJ);
343
+ ok("hard forget still deletes a hidden memory", r.code === 0 && /deleted/.test(r.out));
344
+ // D68 follow-up: the HTML rendering of the same data layer.
345
+ const htmlId = addMem(["fulltest html page entry"], PROJ);
346
+ cliRetry(["forget", htmlId, "--soft"], PROJ);
347
+ const htmlFile = path.join(T, "inventory-page.html");
348
+ r = cliRetry(["inventory", "--format", "html", "--out", htmlFile], PROJ);
349
+ ok("inventory --format html writes the page", r.code === 0 && fs.existsSync(htmlFile), (r.err || r.out).slice(0, 150));
350
+ const page = fs.readFileSync(htmlFile, "utf8");
351
+ ok("html page shows current memories", page.includes("fulltest inventory entry"));
352
+ ok("html page escapes memory content", page.includes("&lt;script&gt;alert(1)&lt;/script&gt;") && !page.includes("<script>alert(1)</script>"));
353
+ ok("html page folds the hidden history", page.includes("Replaced &amp; hidden history") && page.includes(htmlId) && page.includes("retracted"));
354
+ ok("html page enforces 'no network' with a CSP", /Content-Security-Policy/.test(page) && /default-src 'none'/.test(page));
355
+ r = cliRetry(["inventory", "--format", "html"], PROJ);
356
+ 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));
357
+ const inTreePage = path.join(PROJ, "page.html");
358
+ r = cliRetry(["inventory", "--format", "html", "--out", inTreePage], PROJ);
359
+ ok("html refuses a worktree path without --allow-personal", r.code !== 0 && !fs.existsSync(inTreePage));
360
+
361
+ // ---------- 8. pull / push ----------
217
362
  console.log("== pull / push ==");
218
363
  r = cliRetry(["push"], PROJ);
219
364
  ok("push explicit", r.code === 0, (r.err || r.out).slice(0, 150));
@@ -235,7 +380,7 @@ cli(["push"], PROJ3);
235
380
  r = cli(["pull"], PROJ);
236
381
  ok("pull diverged → clear failure", r.code !== 0 && /diverg|behind|ahead/i.test(r.out + r.err), (r.out + r.err).slice(0, 160));
237
382
 
238
- // ---------- 8. migrate / reindex ----------
383
+ // ---------- 9. migrate / reindex ----------
239
384
  console.log("== migrate / reindex ==");
240
385
  r = cli(["migrate", "--dry-run"], PROJ);
241
386
  ok("migrate bare → graceful no-op message", /no --from given/.test(r.out + r.err), (r.out + r.err).slice(0, 150));
@@ -251,8 +396,26 @@ try { dbOk = fs.existsSync(dbPath) && fs.statSync(dbPath).mtimeMs >= beforeReind
251
396
  ok("reindex rebuilds", dbOk, `db exists: ${fs.existsSync(dbPath)}`);
252
397
  r = cli(["list", "--scope", "personal"], PROJ);
253
398
  ok("list works after reindex", r.out.includes(idMig) || r.out.includes(idPersonal));
399
+ // P0: a real migrate must re-scope the file, not just re-key it — a
400
+ // personal memory migrated to the project key must become submittable
401
+ // project memory (scope: project in its frontmatter).
402
+ r = cli(["migrate", "--from", "personal", "--to", "project"], PROJ);
403
+ ok("migrate personal→project runs", r.code === 0, (r.err || r.out).slice(0, 150));
404
+ {
405
+ const memRoot = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories");
406
+ let moved = "";
407
+ const walk = (d: string) => {
408
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
409
+ const p = path.join(d, e.name);
410
+ if (e.isDirectory()) walk(p);
411
+ else if (e.name === idMig + ".md") moved = fs.readFileSync(p, "utf8");
412
+ }
413
+ };
414
+ walk(memRoot);
415
+ ok("migrated file is now scope: project", /^scope: project$/m.test(moved), moved.slice(0, 200));
416
+ }
254
417
 
255
- // ---------- 9. capture / doctor / mcp --print-config / init ----------
418
+ // ---------- 10. capture / doctor / mcp --print-config / init ----------
256
419
  console.log("== capture / doctor / mcp / init ==");
257
420
  r = cli(["capture", "--dry-run", "remember: we deploy on Fridays and use ff merges"], PROJ);
258
421
  ok("capture --dry-run", r.code === 0 && /deploy|friday/i.test(r.out), r.out.slice(0, 150));
@@ -262,10 +425,21 @@ for (const c of ["vscode", "cursor", "claude", "opencode", "visualstudio"]) {
262
425
  }
263
426
  r = cli(["init", "--client", "vscode", "--yes"], PROJ);
264
427
  ok("init --client vscode --yes", r.code === 0 && fs.existsSync(path.join(PROJ, ".vscode", "mcp.json")), (r.err || r.out).slice(0, 150));
428
+ // P0: VS init must merge into an existing .mcp.json, not replace it —
429
+ // the parsed doc used to be dropped, wiping other MCP servers.
430
+ {
431
+ const mcpFile = path.join(PROJ, ".mcp.json");
432
+ fs.writeFileSync(mcpFile, JSON.stringify({ servers: { "other-server": { type: "stdio", command: "other" } } }));
433
+ r = cli(["init", "--client", "visualstudio", "--yes"], PROJ);
434
+ const after = JSON.parse(fs.readFileSync(mcpFile, "utf8")) as { servers?: Record<string, unknown> };
435
+ ok("VS init preserves other servers in .mcp.json",
436
+ r.code === 0 && !!after.servers?.["other-server"] && !!after.servers?.["open-memex"],
437
+ JSON.stringify(after).slice(0, 160));
438
+ }
265
439
  r = cliRetry(["doctor"], PROJ);
266
440
  ok("doctor", r.code === 0 && /All checks passed/.test(r.out), (r.err || r.out).slice(0, 200));
267
441
 
268
- // ---------- 10. MCP: all 11 tools + session-start instructions ----------
442
+ // ---------- 11. MCP: all 11 tools + session-start instructions ----------
269
443
  console.log("== MCP tools ==");
270
444
  {
271
445
  const child = spawn("node", ["--experimental-strip-types", MCP_TS], {
@@ -339,6 +513,262 @@ console.log("== MCP tools ==");
339
513
  child.kill();
340
514
  }
341
515
 
516
+ // ---------- 12. D64: P1 review-flow honesty fixes ----------
517
+ console.log("== D64: p1 fixes (propose visibility / supersede published / status door / plugin sync / status cap / doctor v1 / uninstall jsonc) ==");
518
+ {
519
+ r = cli(["where"], PROJ);
520
+ const pkey = (/project:\s*(\S+)/.exec(r.out) || [])[1] ?? "";
521
+
522
+ // propose: the project copy must not inherit visibility: private, or
523
+ // export would silently drop it from every team bundle.
524
+ const seedId = addMem(["zzq p1 visible fact for the team", "--scope", "personal"], PROJ);
525
+ const copyId = proposeMem(seedId, PROJ);
526
+ const copyFile = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories", pkey, `${copyId}.md`);
527
+ const copyText = fs.existsSync(copyFile) ? fs.readFileSync(copyFile, "utf8") : "";
528
+ ok("propose copy is internal, not private", /visibility: internal/.test(copyText), copyText.slice(0, 160));
529
+
530
+ // supersede a published memory: the replacement must re-enter review
531
+ // at "proposed" (promotable), not land as a stranded repo draft.
532
+ const did = addMem(["zzq p1 publish flow fact"], PROJ);
533
+ cli(["submit", did], PROJ);
534
+ cli(["promote", did], PROJ);
535
+ r = cli(["promote", did], PROJ);
536
+ const pubFile = path.join(PROJ, ".ai", "open-memex", `${did}.md`);
537
+ ok("setup: promoted to published", r.code === 0 && /review_state: published/.test(fs.readFileSync(pubFile, "utf8")), (r.err || r.out).slice(0, 150));
538
+ r = cli(["supersede", did, "zzq p1 publish flow fact v2"], PROJ);
539
+ const nid = grabArrowId(r.out);
540
+ const newFile = nid ? path.join(PROJ, ".ai", "open-memex", `${nid}.md`) : "";
541
+ const newText = newFile && fs.existsSync(newFile) ? fs.readFileSync(newFile, "utf8") : "";
542
+ ok("supersede of published → proposed (not stranded draft)", /review_state: proposed/.test(newText), (r.out + newText).slice(0, 200));
543
+ r = cli(["promote", nid], PROJ);
544
+ 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));
545
+
546
+ // status: retracted cannot silently return to active.
547
+ const sid = addMem(["zzq p1 retract flow fact"], PROJ);
548
+ r = cli(["status", sid, "retracted"], PROJ);
549
+ ok("status → retracted", r.code === 0, (r.err || r.out).slice(0, 120));
550
+ r = cli(["status", sid, "active"], PROJ);
551
+ ok("retracted → active is refused", r.code !== 0 && /retracted/.test(r.err), (r.out + r.err).slice(0, 160));
552
+
553
+ // plugin host: per-call sync — wipe the (rebuildable) index and the
554
+ // opencode tools must still see memories written by other processes.
555
+ const marker = `zzqp1sync${Date.now()}`;
556
+ const pmid = addMem([`${marker} plugin freshness fact`, "--scope", "personal"], PROJ);
557
+ fs.rmSync(path.join(TESTENV.MY_O_MEMORY_HOME as string, "index.db"), { force: true });
558
+ fs.rmSync(path.join(TESTENV.MY_O_MEMORY_HOME as string, "index.db-wal"), { force: true });
559
+ fs.rmSync(path.join(TESTENV.MY_O_MEMORY_HOME as string, "index.db-shm"), { force: true });
560
+ const probe = path.join(T, "p1-plugin-probe.ts");
561
+ fs.writeFileSync(probe, [
562
+ // file:// URLs, not raw paths — Windows rejects drive-letter specifiers.
563
+ `import { makeTools } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "tools", "memory.ts")).href)};`,
564
+ `import { PERSONAL_SCOPE } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "scope.ts")).href)};`,
565
+ `import { loadConfig } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "config.ts")).href)};`,
566
+ `const tools = makeTools(() => PERSONAL_SCOPE, loadConfig());`,
567
+ `const r = await tools.memory_search.execute({ query: ${JSON.stringify(marker)} });`,
568
+ `console.log(r.output);`,
569
+ ].join("\n"));
570
+ let probeOut = "";
571
+ try {
572
+ probeOut = execFileSync("node", ["--experimental-strip-types", probe], { cwd: PROJ, env: TESTENV, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
573
+ } catch (e: any) { probeOut = (e.stdout ?? "") + (e.stderr ?? ""); }
574
+ ok("plugin tools sync before answering (index rebuilt)", probeOut.includes(pmid) || probeOut.includes(marker), probeOut.slice(0, 200));
575
+
576
+ // sync-status: long sections are capped with an "and N more" line.
577
+ const outboxDir = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories", pkey);
578
+ const template = fs.readFileSync(copyFile, "utf8");
579
+ for (let i = 0; i < 21; i++) {
580
+ const cid = `01P1CAP${String(i).padStart(19, "0")}`;
581
+ fs.writeFileSync(path.join(outboxDir, `${cid}.md`), template.replace(copyId, cid).replace("zzq p1 visible fact for the team", `zzq cap filler ${i}`));
582
+ }
583
+ cli(["reindex"], PROJ);
584
+ r = cli(["sync-status"], PROJ);
585
+ // CLI shows the full list (the 20-line cap applies to agent tool
586
+ // results, whose "… and N more" line points back here).
587
+ const fillerShown = (r.out.match(/zzq cap filler/g) ?? []).length;
588
+ ok("sync-status shows the full list", fillerShown === 21 && !/… and \d+ more/.test(r.out), `shown=${fillerShown}`);
589
+ // ...and the agent-facing render (what memory_status returns) caps per section
590
+ // and names the CLI, so a hidden entry is still reachable.
591
+ const capProbe = path.join(T, "p1-cap-probe.ts");
592
+ fs.writeFileSync(capProbe, [
593
+ `import { getSyncStatus, formatSyncStatus } from ${JSON.stringify(pathToFileURL(path.join(REPO, "src", "submit.ts")).href)};`,
594
+ `console.log(formatSyncStatus(getSyncStatus()));`,
595
+ ].join("\n"));
596
+ let capOut = "";
597
+ try {
598
+ capOut = execFileSync("node", ["--experimental-strip-types", capProbe], { cwd: PROJ, env: TESTENV, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
599
+ } catch (e: any) { capOut = (e.stdout ?? "") + (e.stderr ?? ""); }
600
+ const outboxSection = (capOut.split("outbox (appdata")[1] ?? "").split("repo .ai/open-memex")[0];
601
+ const capEntries = (outboxSection.match(/\[(draft|proposed|approved|published|rejected)\]/g) ?? []).length;
602
+ const capFiller = (outboxSection.match(/zzq cap filler/g) ?? []).length;
603
+ ok(
604
+ "agent-facing sync-status caps and points at the CLI",
605
+ capEntries === 20 && capFiller < 21 && /… and \d+ more \(full list: open-memex sync-status\)/.test(outboxSection),
606
+ `entries=${capEntries} fillers=${capFiller} ${outboxSection.slice(0, 200)}`,
607
+ );
608
+ for (let i = 0; i < 21; i++) fs.rmSync(path.join(outboxDir, `01P1CAP${String(i).padStart(19, "0")}.md`), { force: true });
609
+
610
+ // doctor: v1 memories left under memories/user/ must be flagged.
611
+ const userDir = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories", "user");
612
+ fs.mkdirSync(userDir, { recursive: true });
613
+ fs.writeFileSync(path.join(userDir, "legacy-one.md"), "---\nid: legacy\n---\nold personal note\n");
614
+ r = cliRetry(["doctor"], PROJ);
615
+ ok("doctor flags invisible v1 user memories", /v1 personal memories/.test(r.out), r.out.slice(0, 200));
616
+ fs.rmSync(userDir, { recursive: true, force: true });
617
+ cli(["reindex"], PROJ);
618
+
619
+ // uninstall: a .jsonc-only global opencode config must be found.
620
+ const home2 = path.join(T, "home-opencode");
621
+ const ocDir = path.join(home2, ".config", "opencode");
622
+ fs.mkdirSync(ocDir, { recursive: true });
623
+ const jsoncFile = path.join(ocDir, "opencode.jsonc");
624
+ fs.writeFileSync(jsoncFile, JSON.stringify({ plugin: ["file:///plugins/open-memex/src/index.ts"] }, null, 2));
625
+ // USERPROFILE too: os.homedir() ignores HOME on Windows.
626
+ r = cliRaw(["uninstall", "--client", "opencode", "--global", "--yes"], PROJ, { ...TESTENV, HOME: home2, USERPROFILE: home2 });
627
+ const afterText = fs.readFileSync(jsoncFile, "utf8");
628
+ 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));
629
+ }
630
+
631
+ // ---------- 12. D70: the visibility fixes from the 3-C review ----------
632
+ // Last section on purpose: nothing here may perturb the flows above it.
633
+ // It writes only into a scratch project and into appdata fixtures (no
634
+ // commits in PROJ, no extra CLI spawns beyond a handful) so the suite stays
635
+ // order-independent and cheap — the shared PROJ git state is never dirtied.
636
+ console.log("== D70: honest counts, stable numbers, one guard ==");
637
+ {
638
+ const fixture = (dir: string, id: string, body: string, scopeKey: string) => {
639
+ const now = new Date().toISOString();
640
+ fs.mkdirSync(dir, { recursive: true });
641
+ fs.writeFileSync(
642
+ path.join(dir, `${id}.md`),
643
+ `---
644
+ id: ${id}
645
+ schema_version: 2
646
+ scope_key: ${scopeKey}
647
+ scope: project
648
+ visibility: internal
649
+ project_name: d70
650
+ type: fact
651
+ role: knowledge
652
+ importance: normal
653
+ status: active
654
+ tags: []
655
+ source: cli
656
+ created_at: "${now}"
657
+ updated_at: "${now}"
658
+ supersedes: null
659
+ superseded_by: null
660
+ review_state: draft
661
+ proposed_by: null
662
+ approved_by: null
663
+ derived_from: null
664
+ review_note: null
665
+ review_history: []
666
+ ---
667
+
668
+ ${body}
669
+ `,
670
+ "utf8",
671
+ );
672
+ };
673
+
674
+ // The guard must not fail open: a --out whose parent directory does not
675
+ // exist used to skip the worktree check (git -C on a missing dir throws)
676
+ // and then die with a raw ENOENT. Outside a worktree the missing directory
677
+ // is the only problem left, so it gets its own message.
678
+ const missingDir = path.join(T, "no-such-dir", "report.json");
679
+ r = cliRetry(["inventory", "--out", missingDir], PROJ);
680
+ ok(
681
+ "inventory explains a missing parent directory",
682
+ r.code !== 0 && /does not exist/.test(r.err) && !r.err.includes("ENOENT"),
683
+ (r.err || r.out).slice(0, 160),
684
+ );
685
+ // ...and inside a worktree the personal-content refusal wins over it.
686
+ const missingInTree = path.join(PROJ, "no-such-dir", "report.json");
687
+ r = cliRetry(["inventory", "--out", missingInTree], PROJ);
688
+ ok(
689
+ "worktree refusal wins over a missing directory",
690
+ r.code !== 0 && /git working tree/.test(r.err) && !fs.existsSync(missingInTree),
691
+ (r.err || r.out).slice(0, 160),
692
+ );
693
+
694
+ // `open-memex list` is the surface a sceptical user runs first: it must
695
+ // not look complete when it isn't. Fixtures go straight into the outbox
696
+ // (appdata, never the repo), so this needs no commits and no cleanup in git.
697
+ const d70Key = (/project:\s*(\S+)/.exec(cli(["where"], PROJ).out) || [])[1] ?? "";
698
+ const outboxDir = path.join(TESTENV.MY_O_MEMORY_HOME as string, "memories", d70Key);
699
+ const fillerIds: string[] = [];
700
+ for (let i = 0; i < 22; i++) {
701
+ const cid = `01P1D70${String(i).padStart(19, "0")}`;
702
+ fillerIds.push(cid);
703
+ fixture(outboxDir, cid, `fulltest d70 filler ${i} padding text`, d70Key);
704
+ }
705
+ cli(["reindex"], PROJ);
706
+ r = cliRetry(["list", "--limit", "5"], PROJ);
707
+ ok("cli list discloses truncation", /… and \d+ more not shown/.test(r.out), r.out.slice(-160));
708
+ ok(
709
+ "cli list renders the structured inventory line",
710
+ /^\d+\. \[[a-z]+\] id=\S+ created=\S+ source=/m.test(r.out),
711
+ r.out.slice(0, 200),
712
+ );
713
+ // Numbering runs across the whole listing, so a number names one entry.
714
+ r = cliRetry(["list", "--scope", "both", "--limit", "5"], PROJ);
715
+ const nums = [...r.out.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
716
+ ok(
717
+ "numbers are unique and ascending across scopes",
718
+ nums.length > 2 && new Set(nums).size === nums.length && nums[0] === 1 && nums.every((n, i) => i === 0 || n === nums[i - 1]! + 1),
719
+ JSON.stringify(nums),
720
+ );
721
+ // The text report numbers the same way.
722
+ r = cliRetry(["inventory", "--scope", "project"], PROJ);
723
+ const textNums = [...r.out.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
724
+ ok(
725
+ "inventory text numbers continuously too",
726
+ textNums.length > 2 && new Set(textNums).size === textNums.length && textNums.every((n, i) => i === 0 || n === textNums[i - 1]! + 1),
727
+ JSON.stringify(textNums.slice(0, 8)),
728
+ );
729
+ for (const id of fillerIds) fs.rmSync(path.join(outboxDir, `${id}.md`), { force: true });
730
+ cli(["reindex"], PROJ);
731
+
732
+ // A retraction of an in-repo memory is a local working-tree edit until it
733
+ // is committed - "hidden" must not read as "gone for the team". Runs in its
734
+ // own project so PROJ's git state stays clean for the sections above.
735
+ const d70Proj = mkproj("d70-proj");
736
+ const repoHideId = addMem(["fulltest d70 repo retraction"], d70Proj);
737
+ cliRetry(["submit", repoHideId], d70Proj);
738
+ r = cliRetry(["forget", repoHideId, "--soft"], d70Proj);
739
+ ok(
740
+ "soft hide of a repo memory warns about the commit",
741
+ /commit and push/i.test(r.out) && /team still sees it/i.test(r.out),
742
+ r.out.slice(0, 200),
743
+ );
744
+ r = cliRetry(["forget", repoHideId], d70Proj);
745
+ 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));
746
+
747
+ // An empty scope must still appear: "this project has nothing" is an answer.
748
+ const emptyProj = mkproj("empty-proj");
749
+ r = cliRetry(["inventory", "--scope", "project"], emptyProj);
750
+ ok(
751
+ "inventory shows an empty scope as 0",
752
+ /Project:.*\(0\)/.test(r.out) && /nothing remembered/i.test(r.out),
753
+ r.out.slice(0, 200),
754
+ );
755
+
756
+ // JSON: no absolute local paths, and the format id records the change.
757
+ r = cliRetry(["inventory", "--format", "json"], PROJ);
758
+ const invJson2 = JSON.parse(r.out.slice(r.out.indexOf("{")));
759
+ ok(
760
+ "inventory json is /2 and carries no absolute file paths",
761
+ invJson2.format === "open-memex-inventory/2" &&
762
+ !JSON.stringify(invJson2).includes(TESTENV.MY_O_MEMORY_HOME as string) &&
763
+ !/"file":/.test(JSON.stringify(invJson2)),
764
+ `${invJson2.format} ${JSON.stringify(invJson2).slice(0, 120)}`,
765
+ );
766
+ ok(
767
+ "inventory json counts hidden without the entries",
768
+ invJson2.hidden !== undefined && !("hiddenEntries" in invJson2),
769
+ );
770
+ }
771
+
342
772
  console.log("\n================ SUMMARY ================");
343
773
  console.log(`pass: ${pass}, fail: ${fail}`);
344
774
  if (failures.length) { console.log("failed tests:"); for (const f of failures) console.log(" - " + f); }
@@ -25,6 +25,32 @@ open-memex add "I prefer concise diffs" --scope personal # applies everywhere
25
25
  Keep each memory one self-contained statement. Scope routing: facts about the
26
26
  user ("I"/"me") → `personal`; everything else → the current project.
27
27
 
28
+ Worth saving: decisions and their reasons, preferences, conventions, gotchas,
29
+ approaches tried and abandoned. Not worth saving: one-off task details or
30
+ anything re-derivable from the code.
31
+
32
+ Attach alternate phrasings when saving — synonyms, another way the question
33
+ might be worded, equivalents in the user's other language — so reworded
34
+ questions still find the memory:
35
+
36
+ ```sh
37
+ open-memex add "Public holidays are listed in the HR portal" --aliases "holidays;time off;节假日"
38
+ ```
39
+
40
+ (Aliases are stored only when the install has capture aliases enabled — the
41
+ init default.)
42
+
43
+ ## At task checkpoints — propose, don't just save
44
+
45
+ For conclusions you infer yourself (the user never stated them): when you
46
+ finish a task the user would describe in one sentence, propose 1–3 short
47
+ memories capturing the useful conclusion (what was learned or decided, how an
48
+ issue was resolved, what to avoid, where the authoritative doc lives — not the
49
+ raw transcript). Save nothing the user did not approve; on approval, save it —
50
+ with the `memory_add` tool pass `source: "inference"` so the capture is marked
51
+ as agent-proposed. Don't interrupt mid-task, and don't re-propose something
52
+ the user already declined.
53
+
28
54
  ## Recall
29
55
 
30
56
  ```sh
@@ -33,7 +59,33 @@ open-memex list # recent memories in the current project
33
59
  ```
34
60
 
35
61
  Search before asking the user about past decisions or preferences they may
36
- have told you before.
62
+ have told you before. Search well: break the question into its concepts and
63
+ try 2–3 phrasings per concept — synonyms, the user's other language, shorter
64
+ keyword forms — and check the other scope too, before concluding nothing is
65
+ stored.
66
+
67
+ ## Seeing and removing what is remembered
68
+
69
+ When the user asks "what do you remember about me?" (or wants to check or
70
+ clean up), show them the inventory:
71
+
72
+ ```sh
73
+ open-memex inventory # everything remembered, read-only
74
+ ```
75
+
76
+ In chat, present the memories conversationally — turn the fields into
77
+ plain words (who the words came from, when it was saved, project or
78
+ personal) instead of reading raw ids. If the user asks to remove one,
79
+ read the entry back in full and get a confirmation before deleting —
80
+ deletion is permanent, there is no undo. If they hesitate, offer hiding
81
+ instead (`--soft`): it disappears from lists and search, but the file
82
+ survives. Either way, if the memory lives in the repo, the change is only
83
+ yours until you commit and push it.
84
+
85
+ ```sh
86
+ open-memex forget <id> # delete (permanent)
87
+ open-memex forget <id> --soft # hide (retracted, one-way)
88
+ ```
37
89
 
38
90
  ## Project outbox → repo (the sync flow)
39
91
 
@@ -8,16 +8,42 @@ export interface KeywordHit {
8
8
  }
9
9
 
10
10
  /**
11
- * Scan user text for memory-capture keyword triggers.
11
+ * D67: capture-body length bounds. The lower bound rejects fragments — a
12
+ * trigger that yields "这个" or "OK" is a match on the trigger, not a memory,
13
+ * and a 2-char memory is noise in every future session's injection. The floor
14
+ * stays deliberately above 1: D67 considered lowering it and decided against
15
+ * it. What changed is that a rejection is no longer silent (see KeywordDrop).
16
+ */
17
+ export const MIN_CAPTURE_CHARS = 3;
18
+ export const MAX_CAPTURE_CHARS = 2000;
19
+
20
+ /** A pattern that matched but whose body was rejected before capture. */
21
+ export interface KeywordDrop {
22
+ content: string;
23
+ pattern: string;
24
+ personal: boolean;
25
+ reason: "too-short" | "too-long";
26
+ }
27
+
28
+ export interface KeywordScan {
29
+ hits: KeywordHit[];
30
+ dropped: KeywordDrop[];
31
+ }
32
+
33
+ /**
34
+ * Scan user text for memory-capture keyword triggers, reporting both what
35
+ * would be captured and what matched but was rejected (D67) — a dropped match
36
+ * that says nothing reads as a broken feature.
12
37
  * Each pattern must have a single capture group whose value becomes the memory body.
13
38
  * Personal patterns (cfg.keywordPersonalPatterns) force the personal scope.
14
39
  *
15
40
  * Personal patterns run first and claim their line: an explicit scope signal
16
41
  * (e.g. "记住我对花粉过敏") must not also fire the generic 记住 into the project scope.
17
42
  */
18
- export function detectKeywords(userText: string, cfg: MyOMemoryConfig): KeywordHit[] {
19
- if (!cfg.keywordCaptureEnabled) return [];
43
+ export function scanKeywords(userText: string, cfg: MyOMemoryConfig): KeywordScan {
20
44
  const hits: KeywordHit[] = [];
45
+ const dropped: KeywordDrop[] = [];
46
+ if (!cfg.keywordCaptureEnabled) return { hits, dropped };
21
47
  const lines = userText.split(/\r?\n/);
22
48
  const claimed = new Set<number>();
23
49
  const scan = (patterns: string[], personal: boolean, skipClaimed: boolean) => {
@@ -33,15 +59,39 @@ export function detectKeywords(userText: string, cfg: MyOMemoryConfig): KeywordH
33
59
  const m = lines[i].match(re);
34
60
  if (!m || !m[1]) continue;
35
61
  const content = m[1].trim().replace(/[.!?]$/, "").trim();
36
- if (content.length >= 3 && content.length <= 2000) {
62
+ if (personal) claimed.add(i);
63
+ if (content.length >= MIN_CAPTURE_CHARS && content.length <= MAX_CAPTURE_CHARS) {
37
64
  hits.push({ content, pattern: src, personal });
38
- if (personal) claimed.add(i);
39
65
  break; // one hit per pattern per message
40
66
  }
67
+ // Rejected: record it once per pattern. The line stays claimed for
68
+ // personal patterns even here - "记住我:OK" must not fall through to
69
+ // the generic 记住 and be saved as "我:OK" (D67).
70
+ if (!dropped.some((d) => d.pattern === src && d.content === content)) {
71
+ dropped.push({
72
+ content,
73
+ pattern: src,
74
+ personal,
75
+ reason: content.length < MIN_CAPTURE_CHARS ? "too-short" : "too-long",
76
+ });
77
+ }
78
+ break;
41
79
  }
42
80
  }
43
81
  };
44
82
  scan(cfg.keywordPersonalPatterns, true, false);
45
83
  scan(cfg.keywordPatterns, false, true);
46
- return hits;
84
+ if (cfg.logLevel === "debug" && dropped.length > 0) {
85
+ for (const d of dropped) {
86
+ console.error(
87
+ `[open-memex] keyword match not captured (${d.reason === "too-short" ? `body ${d.content.length} chars < ${MIN_CAPTURE_CHARS}` : `body ${d.content.length} chars > ${MAX_CAPTURE_CHARS}`}): "${d.content}"`,
88
+ );
89
+ }
90
+ }
91
+ return { hits, dropped };
92
+ }
93
+
94
+ /** Capture-only view of {@link scanKeywords} (drops are logged, not returned). */
95
+ export function detectKeywords(userText: string, cfg: MyOMemoryConfig): KeywordHit[] {
96
+ return scanKeywords(userText, cfg).hits;
47
97
  }