open-memex 0.7.0-alpha.8 → 0.7.0

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 (44) hide show
  1. package/AGENTS.md +8 -3
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +17 -3
  4. package/README.zh-CN.md +12 -3
  5. package/dist/.build-stamp +1 -1
  6. package/dist/capture/handoff.js +59 -0
  7. package/dist/cli.js +188 -19
  8. package/dist/distill-agents.js +4 -1
  9. package/dist/index.js +14 -6
  10. package/dist/inventory-html.js +150 -0
  11. package/dist/inventory.js +176 -0
  12. package/dist/mcp.js +9 -0
  13. package/dist/opencode-v2.js +8 -3
  14. package/dist/paths.js +18 -0
  15. package/dist/plugin-core.js +48 -3
  16. package/dist/retrieve/display.js +161 -0
  17. package/dist/retrieve/search.js +33 -3
  18. package/dist/scope.js +17 -0
  19. package/dist/store/lifecycle.js +42 -0
  20. package/dist/tools/ops.js +92 -22
  21. package/docs/TEST-PLAN.md +1 -1
  22. package/docs/V2-DESIGN.md +117 -2
  23. package/docs/memory-visibility-design.md +130 -0
  24. package/package.json +2 -2
  25. package/scripts/build-stamp.mjs +62 -62
  26. package/scripts/smoke-mcp.ts +47 -1
  27. package/scripts/smoke-pure.ts +85 -1
  28. package/scripts/test-full.ts +192 -5
  29. package/skills/open-memex/SKILL.md +29 -0
  30. package/src/capture/handoff.ts +74 -0
  31. package/src/cli.ts +202 -19
  32. package/src/distill-agents.ts +7 -0
  33. package/src/index.ts +13 -5
  34. package/src/inventory-html.ts +191 -0
  35. package/src/inventory.ts +242 -0
  36. package/src/mcp.ts +9 -0
  37. package/src/opencode-v2.ts +7 -2
  38. package/src/paths.ts +19 -0
  39. package/src/plugin-core.ts +62 -3
  40. package/src/retrieve/display.ts +174 -0
  41. package/src/retrieve/search.ts +49 -4
  42. package/src/scope.ts +18 -0
  43. package/src/store/lifecycle.ts +52 -0
  44. package/src/tools/ops.ts +99 -27
@@ -312,7 +312,53 @@ ok("distill-agents proposes snippet", /## Learned/.test(r.out), r.out.slice(0, 1
312
312
  r = cliRetry(["distill-agents", "--type", "decision", "-o", path.join(T, "snip.md")], PROJ);
313
313
  ok("distill-agents -o writes file", fs.existsSync(path.join(T, "snip.md")));
314
314
 
315
- // ---------- 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 ----------
316
362
  console.log("== pull / push ==");
317
363
  r = cliRetry(["push"], PROJ);
318
364
  ok("push explicit", r.code === 0, (r.err || r.out).slice(0, 150));
@@ -334,7 +380,7 @@ cli(["push"], PROJ3);
334
380
  r = cli(["pull"], PROJ);
335
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));
336
382
 
337
- // ---------- 8. migrate / reindex ----------
383
+ // ---------- 9. migrate / reindex ----------
338
384
  console.log("== migrate / reindex ==");
339
385
  r = cli(["migrate", "--dry-run"], PROJ);
340
386
  ok("migrate bare → graceful no-op message", /no --from given/.test(r.out + r.err), (r.out + r.err).slice(0, 150));
@@ -369,7 +415,7 @@ ok("migrate personal→project runs", r.code === 0, (r.err || r.out).slice(0, 15
369
415
  ok("migrated file is now scope: project", /^scope: project$/m.test(moved), moved.slice(0, 200));
370
416
  }
371
417
 
372
- // ---------- 9. capture / doctor / mcp --print-config / init ----------
418
+ // ---------- 10. capture / doctor / mcp --print-config / init ----------
373
419
  console.log("== capture / doctor / mcp / init ==");
374
420
  r = cli(["capture", "--dry-run", "remember: we deploy on Fridays and use ff merges"], PROJ);
375
421
  ok("capture --dry-run", r.code === 0 && /deploy|friday/i.test(r.out), r.out.slice(0, 150));
@@ -393,7 +439,7 @@ ok("init --client vscode --yes", r.code === 0 && fs.existsSync(path.join(PROJ, "
393
439
  r = cliRetry(["doctor"], PROJ);
394
440
  ok("doctor", r.code === 0 && /All checks passed/.test(r.out), (r.err || r.out).slice(0, 200));
395
441
 
396
- // ---------- 10. MCP: all 11 tools + session-start instructions ----------
442
+ // ---------- 11. MCP: all 11 tools + session-start instructions ----------
397
443
  console.log("== MCP tools ==");
398
444
  {
399
445
  const child = spawn("node", ["--experimental-strip-types", MCP_TS], {
@@ -467,7 +513,7 @@ console.log("== MCP tools ==");
467
513
  child.kill();
468
514
  }
469
515
 
470
- // ---------- 11. D64: P1 review-flow honesty fixes ----------
516
+ // ---------- 12. D64: P1 review-flow honesty fixes ----------
471
517
  console.log("== D64: p1 fixes (propose visibility / supersede published / status door / plugin sync / status cap / doctor v1 / uninstall jsonc) ==");
472
518
  {
473
519
  r = cli(["where"], PROJ);
@@ -582,6 +628,147 @@ console.log("== D64: p1 fixes (propose visibility / supersede published / status
582
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));
583
629
  }
584
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
+
585
772
  console.log("\n================ SUMMARY ================");
586
773
  console.log(`pass: ${pass}, fail: ${fail}`);
587
774
  if (failures.length) { console.log("failed tests:"); for (const f of failures) console.log(" - " + f); }
@@ -25,6 +25,12 @@ 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
+ One thing the user said is one memory. When their own wording triggered keyword
29
+ capture (`remember …`, `记住…`), that sentence is already stored — do not save it
30
+ again in any rewording. Save only what the stored text does not contain, as its
31
+ own statement; if your version replaces it, use `memory_supersede`. `memory_add`
32
+ refuses the echo and hands you the stored id.
33
+
28
34
  Worth saving: decisions and their reasons, preferences, conventions, gotchas,
29
35
  approaches tried and abandoned. Not worth saving: one-off task details or
30
36
  anything re-derivable from the code.
@@ -64,6 +70,29 @@ try 2–3 phrasings per concept — synonyms, the user's other language, shorter
64
70
  keyword forms — and check the other scope too, before concluding nothing is
65
71
  stored.
66
72
 
73
+ ## Seeing and removing what is remembered
74
+
75
+ When the user asks "what do you remember about me?" (or wants to check or
76
+ clean up), show them the inventory:
77
+
78
+ ```sh
79
+ open-memex inventory # everything remembered, read-only
80
+ ```
81
+
82
+ In chat, present the memories conversationally — turn the fields into
83
+ plain words (who the words came from, when it was saved, project or
84
+ personal) instead of reading raw ids. If the user asks to remove one,
85
+ read the entry back in full and get a confirmation before deleting —
86
+ deletion is permanent, there is no undo. If they hesitate, offer hiding
87
+ instead (`--soft`): it disappears from lists and search, but the file
88
+ survives. Either way, if the memory lives in the repo, the change is only
89
+ yours until you commit and push it.
90
+
91
+ ```sh
92
+ open-memex forget <id> # delete (permanent)
93
+ open-memex forget <id> --soft # hide (retracted, one-way)
94
+ ```
95
+
67
96
  ## Project outbox → repo (the sync flow)
68
97
 
69
98
  New project memories land in a local outbox (not in git). Review and publish:
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Turn hand-off between keyword capture and the agent (D73).
3
+ *
4
+ * The keyword hook and the agent are two writers for one user turn. The hook
5
+ * stores the user's sentence verbatim, with no agent in the loop; the agent
6
+ * then sees only the chat transcript and cannot tell the sentence is already
7
+ * stored, so it saves its own paraphrase — the same statement lands two or
8
+ * three times (observed 2026-10-04: one `remember …` sentence → one
9
+ * `source: keyword` file plus two `source: tool` files).
10
+ *
11
+ * Two mechanisms, in this order:
12
+ * (a) the hand-off note — this module's `formatHandoffBlock`, pushed into the
13
+ * turn's system context so the agent knows what is already stored;
14
+ * (b) the turn-echo guard — `TURN_ECHO_THRESHOLD` below, enforced in
15
+ * `addMemory` (src/tools/ops.ts) over what the hook just wrote.
16
+ *
17
+ * Pure module: no db, no fs, no config, so the threshold behavior is testable
18
+ * in scripts/smoke-pure.ts.
19
+ */
20
+
21
+ /**
22
+ * Overlap bar for refusing a re-save of a just-captured user statement.
23
+ *
24
+ * Deliberately high, and only ever applied to memories the *hook* wrote
25
+ * (`source: keyword` / `user`) inside `TURN_ECHO_WINDOW_MS`. Measured on the
26
+ * reported case and its near neighbours (token Jaccard, see
27
+ * `similarity` in src/store/lifecycle.ts): a wholesale re-save of the captured
28
+ * sentence scores 0.29–0.36, while a genuinely distinct fact the same
29
+ * sentence also contained scores 0.21 and below. Lexical overlap cannot
30
+ * separate an agent's gloss from a new fact — those two measured 0.211 and
31
+ * 0.214 — so the guard sits at the high-confidence end and everything under
32
+ * the bar is left to (a) and the agent's judgment. A refusal is always
33
+ * reported with the stored id, so the caller can supersede or rephrase.
34
+ */
35
+ export const TURN_ECHO_THRESHOLD = 0.28;
36
+
37
+ /** How long a hook capture counts as "this turn" (10 minutes). */
38
+ export const TURN_ECHO_WINDOW_MS = 10 * 60 * 1000;
39
+
40
+ /** One hook capture, in memory only — never persisted (like `injectedSessions`). */
41
+ export interface CaptureRecord {
42
+ id: string;
43
+ scopeKey: string;
44
+ content: string;
45
+ at: number;
46
+ /** The note has already been pushed for this capture. */
47
+ delivered?: boolean;
48
+ }
49
+
50
+ export function isTurnEcho(score: number): boolean {
51
+ return score >= TURN_ECHO_THRESHOLD;
52
+ }
53
+
54
+ /** Records still inside the window. */
55
+ export function freshCaptures(records: CaptureRecord[], now: number): CaptureRecord[] {
56
+ return records.filter((r) => now - r.at <= TURN_ECHO_WINDOW_MS);
57
+ }
58
+
59
+ function preview(content: string): string {
60
+ const one = content.replace(/\s+/g, " ").trim();
61
+ return one.length > 160 ? `${one.slice(0, 160)}…` : one;
62
+ }
63
+
64
+ /** The hand-off note (a). Empty string when there is nothing to hand off. */
65
+ export function formatHandoffBlock(records: CaptureRecord[]): string {
66
+ if (records.length === 0) return "";
67
+ const lines = records.map((r) => `- id=${r.id} (${r.scopeKey}) — "${preview(r.content)}"`);
68
+ return [
69
+ "[open-memex] The user's own wording in this turn was ALREADY stored verbatim, before you saw it. It is not unsaved.",
70
+ ...lines,
71
+ "Do not call memory_add to save that text again in any rewording — one statement is already one memory, and a rewording only adds a second copy that retrieval will return alongside the first.",
72
+ "Save a memory only for information those lines do NOT contain, as its own self-contained statement. If your version is meant to replace one of them, call memory_supersede with that id instead of memory_add.",
73
+ ].join("\n");
74
+ }
package/src/cli.ts CHANGED
@@ -9,7 +9,8 @@ import { findDuplicates, supersede, setStatus } from "./store/lifecycle.ts";
9
9
  import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.ts";
10
10
  import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.ts";
11
11
  import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.ts";
12
- import { search, list, hitStateLabel } from "./retrieve/search.ts";
12
+ import { search, list, countList, hitStateLabel } from "./retrieve/search.ts";
13
+ import { formatInventoryLine, truncationNote } from "./retrieve/display.ts";
13
14
  import {
14
15
  writeMemoryFile,
15
16
  readMemoryFile,
@@ -19,13 +20,65 @@ import {
19
20
  type Frontmatter,
20
21
  } from "./store/markdown.ts";
21
22
  import { loadConfig, configSource } from "./config.ts";
22
- import { paths, projectRoot } from "./paths.ts";
23
+ import { paths, projectRoot, isInRepoMemoryFile } from "./paths.ts";
23
24
  import { redact } from "./redact.ts";
24
25
  import { resolveMcpCommand } from "./init.ts";
25
26
  import fs from "node:fs";
26
27
  import path from "node:path";
28
+ import { execFileSync } from "node:child_process";
27
29
  import { fileURLToPath } from "node:url";
28
30
 
31
+ /**
32
+ * True when `target` sits inside a git working tree (D68 guard).
33
+ *
34
+ * D70: resolve the nearest EXISTING ancestor first. `git -C` on a
35
+ * directory that does not exist throws, and the old code turned that
36
+ * into "not in a worktree" — a fail-open guard on the one path where
37
+ * personal memories would ride a commit.
38
+ */
39
+ function isInsideWorkTree(target: string): boolean {
40
+ try {
41
+ let dir = path.dirname(path.resolve(target));
42
+ while (!fs.existsSync(dir)) {
43
+ const parent = path.dirname(dir);
44
+ if (parent === dir) break;
45
+ dir = parent;
46
+ }
47
+ const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
48
+ stdio: ["ignore", "pipe", "ignore"],
49
+ encoding: "utf8",
50
+ });
51
+ return out.trim() === "true";
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * D70: the one place that decides where a report may be written, for
59
+ * every `inventory` format. Returns an error message to print, or null
60
+ * when the path is safe. Kept single so the text/json/html paths cannot
61
+ * drift apart (they already had two copies, D68).
62
+ */
63
+ function inventoryWriteRefusal(
64
+ target: string,
65
+ includesPersonal: boolean,
66
+ allowPersonal: boolean,
67
+ ): string | null {
68
+ // Personal content must not ride a commit (the design's hardest rule).
69
+ if (includesPersonal && !allowPersonal && isInsideWorkTree(target)) {
70
+ return (
71
+ `inventory: ${target} is inside a git working tree and this report includes personal memories. ` +
72
+ `Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`
73
+ );
74
+ }
75
+ const parent = path.dirname(path.resolve(target));
76
+ if (!fs.existsSync(parent)) {
77
+ return `inventory: the directory ${parent} does not exist — create it first, or choose another --out path.`;
78
+ }
79
+ return null;
80
+ }
81
+
29
82
  /** Per-command help, printed by `open-memex <command> --help`.
30
83
  AI assistants discover the CLI through --help, so every command needs one. */
31
84
  const COMMAND_HELP: Record<string, string> = {
@@ -36,14 +89,20 @@ Usage: open-memex where
36
89
  Example:
37
90
  open-memex where`,
38
91
 
39
- list: `List memories in a scope, newest first.
92
+ list: `List memories in a scope, newest first, as a numbered inventory.
40
93
 
41
- Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N]
94
+ Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
42
95
 
43
96
  Flags:
44
- --scope project (default), personal, or both
45
- --type filter by memory type
46
- --limit max results
97
+ --scope project (default), personal, or both
98
+ --type filter by memory type
99
+ --limit max results (default 20, max 100)
100
+ --include active (default: what is currently remembered) or all (audit
101
+ view: superseded versions, retracted and archived included)
102
+
103
+ A cut list says how many entries it left out. Numbers are unique within one
104
+ listing, so "the third one" names exactly one memory - but they are only
105
+ valid for the listing you just ran.
47
106
 
48
107
  Examples:
49
108
  open-memex list
@@ -99,12 +158,16 @@ Retraction is one-way: a retracted memory cannot be set back to active
99
158
  Example:
100
159
  open-memex status 01ABC deprecated`,
101
160
 
102
- forget: `Delete a memory by id.
161
+ forget: `Delete a memory by id. With --soft, hide it instead: the memory
162
+ is retracted (out of lists and search, file kept). Retraction is one-way —
163
+ save the content as a new memory to bring it back. If the memory lives in
164
+ the repo, either action is only local until you commit and push it.
103
165
 
104
- Usage: open-memex forget <id>
166
+ Usage: open-memex forget <id> [--soft]
105
167
 
106
168
  Example:
107
- open-memex forget 01ABC`,
169
+ open-memex forget 01ABC
170
+ open-memex forget 01ABC --soft`,
108
171
 
109
172
  propose: `Copy personal memories into the project outbox as review drafts.
110
173
  The personal originals stay put. Nothing enters git at this step.
@@ -156,6 +219,22 @@ Usage: open-memex sync-status
156
219
  Example:
157
220
  open-memex sync-status`,
158
221
 
222
+ inventory: `Show everything remembered — personal + this project — in the
223
+ open: what each memory says, where it came from, and whether it has reached
224
+ the repo yet. Read-only; deleting stays a confirmed conversation with your
225
+ agent. Formats: text (default, diffable), json (for agents), html (a local
226
+ page for your browser; default writes <data dir>/inventory.html).
227
+
228
+ Usage: open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
229
+
230
+ Default scope: both. Without --out, text/json go to stdout. A report that
231
+ includes personal memories is refused inside a git working tree unless
232
+ --allow-personal is passed — personal content must not ride a commit.
233
+
234
+ Example:
235
+ open-memex inventory --format json --out ~/inventory.json
236
+ open-memex inventory --format html`,
237
+
159
238
  pull: `Pull shared project memories from the git remote: fetch + fast-forward
160
239
  only. Never auto-merges — a diverged branch fails with a clear message and is
161
240
  left for you to resolve by hand. On success the local index re-syncs.
@@ -370,16 +449,17 @@ function usage(exitCode = 1): never {
370
449
 
371
450
  Usage:
372
451
  open-memex where
373
- open-memex list [--scope project|personal] [--type T] [--limit N]
452
+ open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
374
453
  open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
375
454
  open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
376
455
  open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
377
456
  open-memex status <id> active|deprecated|retracted|archived
378
- open-memex forget <id>
457
+ open-memex forget <id> [--soft]
379
458
  open-memex propose <id...> --to project [--local-approve]
380
459
  open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
381
460
  open-memex resolve [id-or-path]
382
461
  open-memex sync-status
462
+ open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
383
463
  open-memex pull
384
464
  open-memex push
385
465
  open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
@@ -833,7 +913,7 @@ async function main() {
833
913
  );
834
914
  }
835
915
  return;
836
- }
916
+ }
837
917
 
838
918
  // `mcp` starts the stdio MCP server (11 tools; the opencode plugin exposes 5).
839
919
  // Branched before db() — runMcpServer() does its own init, and stdout must
@@ -875,23 +955,41 @@ async function main() {
875
955
  // D66: --scope both, on par with the memory_list tool — prints each
876
956
  // scope's newest under its own header.
877
957
  const scopes =
878
- flags.scope === "both" ? [project, PERSONAL_SCOPE] : [resolveCliScope(flags, project)];
958
+ flags.scope === "both" ? [PERSONAL_SCOPE, project] : [resolveCliScope(flags, project)];
959
+ const include = flags.include === "all" ? "all" : "active";
960
+ // D70: the CLI renders the SAME structured inventory the agent sees,
961
+ // numbering runs across the whole listing, and a cut list says how
962
+ // many entries it dropped — `open-memex list` is the surface a
963
+ // sceptical user runs first, so it may not look complete when it isn't.
964
+ let seq = 0;
879
965
  for (const s of scopes) {
880
966
  syncScope(s.key, "cli");
881
967
  const hits = list(s.key, {
882
968
  type: flags.type,
883
969
  limit: flags.limit ? Number(flags.limit) : undefined,
970
+ include,
884
971
  });
885
- if (scopes.length > 1) console.log(`## ${s.kind} (${s.key})`);
972
+ const total = countList(s.key, { type: flags.type, include });
973
+ if (scopes.length > 1) {
974
+ console.log(
975
+ s.key === PERSONAL_SCOPE.key
976
+ ? "## About you (personal — stays on this machine, applies everywhere)"
977
+ : `## This project (${s.projectName || s.key})`,
978
+ );
979
+ }
886
980
  if (hits.length === 0) {
887
- console.log(`(no memories in ${s.key})`);
981
+ console.log(
982
+ scopes.length > 1
983
+ ? "(nothing remembered in this scope yet)"
984
+ : `(no memories in ${s.key})`,
985
+ );
888
986
  continue;
889
987
  }
890
988
  for (const h of hits) {
891
- const dep = h.status === "deprecated" ? " [deprecated]" : "";
892
- const rev = hitStateLabel(h);
893
- console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
989
+ console.log(formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
894
990
  }
991
+ const note = truncationNote(hits.length, total);
992
+ if (note) console.log(note);
895
993
  }
896
994
  return;
897
995
  }
@@ -1071,6 +1169,29 @@ async function main() {
1071
1169
  if (cmd === "forget") {
1072
1170
  const id = rest[0];
1073
1171
  if (!id) usage();
1172
+ // D68: --soft hides (retracts) instead of deleting — same action as
1173
+ // the memory_forget tool's soft flag. One-way (D64).
1174
+ if (rest.includes("--soft")) {
1175
+ const target = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
1176
+ | { file_path: string }
1177
+ | undefined;
1178
+ try {
1179
+ const mf = setStatus(id, "retracted");
1180
+ upsertFromFile(mf);
1181
+ console.log(`hidden ${id} (retracted — out of lists and search; save it again as a new memory to bring it back)`);
1182
+ // D70: same honesty as the hard delete — a retraction of an in-repo
1183
+ // memory is a local working-tree edit until it is committed and pushed.
1184
+ if (target && isInRepoMemoryFile(target.file_path)) {
1185
+ console.log(
1186
+ `note: that memory lives in the repo (.ai/open-memex/) — the retraction is only local until you commit and push it; until then the team still sees it.`,
1187
+ );
1188
+ }
1189
+ } catch (e) {
1190
+ console.error(`hide failed: ${(e as Error).message}`);
1191
+ process.exit(1);
1192
+ }
1193
+ return;
1194
+ }
1074
1195
  const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
1075
1196
  | { file_path: string }
1076
1197
  | undefined;
@@ -1204,6 +1325,68 @@ function positionalArgs(argv: string[]): string[] {
1204
1325
  return;
1205
1326
  }
1206
1327
 
1328
+ if (cmd === "inventory") {
1329
+ // D68: refresh both scopes first so the report matches the markdown
1330
+ // on disk. Default scope: personal + this project.
1331
+ syncScope(project.key, "cli");
1332
+ syncScope(PERSONAL_SCOPE.key, "cli");
1333
+ const flags = parseFlags(rest);
1334
+ const format = flags.format ?? "text";
1335
+ if (format !== "text" && format !== "json" && format !== "html") {
1336
+ console.error(`inventory: unknown --format '${format}' (text|json|html)`);
1337
+ process.exit(2);
1338
+ }
1339
+ const scopeKeys =
1340
+ flags.scope === "personal"
1341
+ ? [PERSONAL_SCOPE.key]
1342
+ : flags.scope === "project"
1343
+ ? [project.key]
1344
+ : [PERSONAL_SCOPE.key, project.key];
1345
+ const outPath = flags["out"] ?? flags["output"];
1346
+ const includesPersonal = scopeKeys.includes(PERSONAL_SCOPE.key);
1347
+ const allowPersonal = flags["allow-personal"] === "true";
1348
+ try {
1349
+ const { loadInventory, renderInventoryText, renderInventoryJson } = await import("./inventory.ts");
1350
+ if (format === "html") {
1351
+ const { renderInventoryHtml, defaultHtmlOutPath } = await import("./inventory-html.ts");
1352
+ const target = outPath ?? defaultHtmlOutPath(paths().root);
1353
+ // D70: one guard for every format (this path had its own copy).
1354
+ const refusal = inventoryWriteRefusal(target, includesPersonal, allowPersonal);
1355
+ if (refusal) {
1356
+ console.error(refusal);
1357
+ process.exit(2);
1358
+ }
1359
+ // D70: only the audit surface needs the hidden rows themselves;
1360
+ // text/json report counts, so they must not pay to load them.
1361
+ const data = loadInventory(scopeKeys, { hidden: "entries" });
1362
+ fs.writeFileSync(target, renderInventoryHtml(data), "utf8");
1363
+ console.log(`inventory: ${data.total} memories → ${target}`);
1364
+ console.log(`open it in any browser; the report is read-only.`);
1365
+ return;
1366
+ }
1367
+ const data = loadInventory(scopeKeys);
1368
+ const rendered = format === "json" ? renderInventoryJson(data) : renderInventoryText(data);
1369
+ if (outPath) {
1370
+ // D68/3-C v2: the report contains personal content. Writing it into
1371
+ // a git working tree would ride the next commit off this machine —
1372
+ // refuse unless the user explicitly accepts that.
1373
+ const refusal = inventoryWriteRefusal(outPath, includesPersonal, allowPersonal);
1374
+ if (refusal) {
1375
+ console.error(refusal);
1376
+ process.exit(2);
1377
+ }
1378
+ fs.writeFileSync(outPath, rendered, "utf8");
1379
+ console.log(`inventory: ${data.total} memories → ${outPath}`);
1380
+ } else {
1381
+ process.stdout.write(rendered);
1382
+ }
1383
+ } catch (e) {
1384
+ console.error(`inventory failed: ${(e as Error).message}`);
1385
+ process.exit(2);
1386
+ }
1387
+ return;
1388
+ }
1389
+
1207
1390
  if (cmd === "sync-status") {
1208
1391
  syncScope(project.key, "cli");
1209
1392
  syncScope(PERSONAL_SCOPE.key, "cli");