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.
- package/AGENTS.md +8 -3
- package/CHANGELOG.md +40 -0
- package/README.md +17 -3
- package/README.zh-CN.md +12 -3
- package/dist/.build-stamp +1 -1
- package/dist/capture/handoff.js +59 -0
- package/dist/cli.js +188 -19
- package/dist/distill-agents.js +4 -1
- package/dist/index.js +14 -6
- package/dist/inventory-html.js +150 -0
- package/dist/inventory.js +176 -0
- package/dist/mcp.js +9 -0
- package/dist/opencode-v2.js +8 -3
- package/dist/paths.js +18 -0
- package/dist/plugin-core.js +48 -3
- package/dist/retrieve/display.js +161 -0
- package/dist/retrieve/search.js +33 -3
- package/dist/scope.js +17 -0
- package/dist/store/lifecycle.js +42 -0
- package/dist/tools/ops.js +92 -22
- package/docs/TEST-PLAN.md +1 -1
- package/docs/V2-DESIGN.md +117 -2
- package/docs/memory-visibility-design.md +130 -0
- package/package.json +2 -2
- package/scripts/build-stamp.mjs +62 -62
- package/scripts/smoke-mcp.ts +47 -1
- package/scripts/smoke-pure.ts +85 -1
- package/scripts/test-full.ts +192 -5
- package/skills/open-memex/SKILL.md +29 -0
- package/src/capture/handoff.ts +74 -0
- package/src/cli.ts +202 -19
- package/src/distill-agents.ts +7 -0
- package/src/index.ts +13 -5
- package/src/inventory-html.ts +191 -0
- package/src/inventory.ts +242 -0
- package/src/mcp.ts +9 -0
- package/src/opencode-v2.ts +7 -2
- package/src/paths.ts +19 -0
- package/src/plugin-core.ts +62 -3
- package/src/retrieve/display.ts +174 -0
- package/src/retrieve/search.ts +49 -4
- package/src/scope.ts +18 -0
- package/src/store/lifecycle.ts +52 -0
- package/src/tools/ops.ts +99 -27
package/scripts/test-full.ts
CHANGED
|
@@ -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.
|
|
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("<script>alert(1)</script>") && !page.includes("<script>alert(1)</script>"));
|
|
353
|
+
ok("html page folds the hidden history", page.includes("Replaced & 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
|
-
// ----------
|
|
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
|
-
// ----------
|
|
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
|
-
// ----------
|
|
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
|
-
// ----------
|
|
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
|
|
45
|
-
--type
|
|
46
|
-
--limit
|
|
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" ? [
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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");
|