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
package/dist/cli.js CHANGED
@@ -9,15 +9,62 @@ import { findDuplicates, supersede, setStatus } from "./store/lifecycle.js";
9
9
  import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.js";
10
10
  import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.js";
11
11
  import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.js";
12
- import { search, list, hitStateLabel } from "./retrieve/search.js";
13
- import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
14
- import { loadConfig } from "./config.js";
15
- import { paths, projectRoot } from "./paths.js";
12
+ import { search, list, countList, hitStateLabel } from "./retrieve/search.js";
13
+ import { formatInventoryLine, truncationNote } from "./retrieve/display.js";
14
+ import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, normalizeAliases, } from "./store/markdown.js";
15
+ import { loadConfig, configSource } from "./config.js";
16
+ import { paths, projectRoot, isInRepoMemoryFile } from "./paths.js";
16
17
  import { redact } from "./redact.js";
17
18
  import { resolveMcpCommand } from "./init.js";
18
19
  import fs from "node:fs";
19
20
  import path from "node:path";
21
+ import { execFileSync } from "node:child_process";
20
22
  import { fileURLToPath } from "node:url";
23
+ /**
24
+ * True when `target` sits inside a git working tree (D68 guard).
25
+ *
26
+ * D70: resolve the nearest EXISTING ancestor first. `git -C` on a
27
+ * directory that does not exist throws, and the old code turned that
28
+ * into "not in a worktree" — a fail-open guard on the one path where
29
+ * personal memories would ride a commit.
30
+ */
31
+ function isInsideWorkTree(target) {
32
+ try {
33
+ let dir = path.dirname(path.resolve(target));
34
+ while (!fs.existsSync(dir)) {
35
+ const parent = path.dirname(dir);
36
+ if (parent === dir)
37
+ break;
38
+ dir = parent;
39
+ }
40
+ const out = execFileSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
41
+ stdio: ["ignore", "pipe", "ignore"],
42
+ encoding: "utf8",
43
+ });
44
+ return out.trim() === "true";
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ /**
51
+ * D70: the one place that decides where a report may be written, for
52
+ * every `inventory` format. Returns an error message to print, or null
53
+ * when the path is safe. Kept single so the text/json/html paths cannot
54
+ * drift apart (they already had two copies, D68).
55
+ */
56
+ function inventoryWriteRefusal(target, includesPersonal, allowPersonal) {
57
+ // Personal content must not ride a commit (the design's hardest rule).
58
+ if (includesPersonal && !allowPersonal && isInsideWorkTree(target)) {
59
+ return (`inventory: ${target} is inside a git working tree and this report includes personal memories. ` +
60
+ `Refusing to write it there. Pass --allow-personal if you really want that, or write outside the repo.`);
61
+ }
62
+ const parent = path.dirname(path.resolve(target));
63
+ if (!fs.existsSync(parent)) {
64
+ return `inventory: the directory ${parent} does not exist — create it first, or choose another --out path.`;
65
+ }
66
+ return null;
67
+ }
21
68
  /** Per-command help, printed by `open-memex <command> --help`.
22
69
  AI assistants discover the CLI through --help, so every command needs one. */
23
70
  const COMMAND_HELP = {
@@ -27,14 +74,20 @@ Usage: open-memex where
27
74
 
28
75
  Example:
29
76
  open-memex where`,
30
- list: `List memories in a scope, newest first.
77
+ list: `List memories in a scope, newest first, as a numbered inventory.
31
78
 
32
- Usage: open-memex list [--scope project|personal] [--type T] [--limit N]
79
+ Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
33
80
 
34
81
  Flags:
35
- --scope project (default) or personal
36
- --type filter by memory type
37
- --limit max results
82
+ --scope project (default), personal, or both
83
+ --type filter by memory type
84
+ --limit max results (default 20, max 100)
85
+ --include active (default: what is currently remembered) or all (audit
86
+ view: superseded versions, retracted and archived included)
87
+
88
+ A cut list says how many entries it left out. Numbers are unique within one
89
+ listing, so "the third one" names exactly one memory - but they are only
90
+ valid for the listing you just ran.
38
91
 
39
92
  Examples:
40
93
  open-memex list
@@ -54,18 +107,25 @@ Example:
54
107
  open-memex search "deploy checklist" --scope both`,
55
108
  add: `Save a fact, preference, decision, or note to local memory.
56
109
 
57
- Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
110
+ Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"]
58
111
 
59
112
  Flags:
60
- --scope project (default) or personal (personal never leaves this machine)
61
- --type memory type (default: fact)
62
- --tag comma-separated tags
113
+ --scope project (default) or personal (personal never leaves this machine)
114
+ --type memory type (default: fact)
115
+ --tag comma-separated tags
116
+ --aliases alternate phrasings of this fact (up to 4), indexed with the
117
+ memory so differently-worded searches still match; separate
118
+ with ; (commas also accepted — use ; when an alias itself
119
+ contains one). Stored only when capture aliases are enabled
120
+ (init default)
63
121
 
64
122
  Example:
65
123
  open-memex add "We deploy on Fridays" --scope project --tag process`,
66
124
  supersede: `Replace a memory with a newer version. The old one is kept as history.
67
125
 
68
- Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
126
+ Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2] [--aliases "a;b"]
127
+
128
+ Without --aliases, the old memory's aliases carry over.
69
129
 
70
130
  Example:
71
131
  open-memex supersede 01ABC "We deploy on Thursdays now"`,
@@ -73,14 +133,21 @@ Example:
73
133
 
74
134
  Usage: open-memex status <id> active|deprecated|retracted|archived
75
135
 
136
+ Retraction is one-way: a retracted memory cannot be set back to active
137
+ (it was withdrawn on purpose). Save the content as a new memory instead.
138
+
76
139
  Example:
77
140
  open-memex status 01ABC deprecated`,
78
- forget: `Delete a memory by id.
141
+ forget: `Delete a memory by id. With --soft, hide it instead: the memory
142
+ is retracted (out of lists and search, file kept). Retraction is one-way —
143
+ save the content as a new memory to bring it back. If the memory lives in
144
+ the repo, either action is only local until you commit and push it.
79
145
 
80
- Usage: open-memex forget <id>
146
+ Usage: open-memex forget <id> [--soft]
81
147
 
82
148
  Example:
83
- open-memex forget 01ABC`,
149
+ open-memex forget 01ABC
150
+ open-memex forget 01ABC --soft`,
84
151
  propose: `Copy personal memories into the project outbox as review drafts.
85
152
  The personal originals stay put. Nothing enters git at this step.
86
153
 
@@ -127,6 +194,21 @@ Usage: open-memex sync-status
127
194
 
128
195
  Example:
129
196
  open-memex sync-status`,
197
+ inventory: `Show everything remembered — personal + this project — in the
198
+ open: what each memory says, where it came from, and whether it has reached
199
+ the repo yet. Read-only; deleting stays a confirmed conversation with your
200
+ agent. Formats: text (default, diffable), json (for agents), html (a local
201
+ page for your browser; default writes <data dir>/inventory.html).
202
+
203
+ Usage: open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
204
+
205
+ Default scope: both. Without --out, text/json go to stdout. A report that
206
+ includes personal memories is refused inside a git working tree unless
207
+ --allow-personal is passed — personal content must not ride a commit.
208
+
209
+ Example:
210
+ open-memex inventory --format json --out ~/inventory.json
211
+ open-memex inventory --format html`,
130
212
  pull: `Pull shared project memories from the git remote: fetch + fast-forward
131
213
  only. Never auto-merges — a diverged branch fails with a clear message and is
132
214
  left for you to resolve by hand. On success the local index re-syncs.
@@ -324,16 +406,17 @@ function usage(exitCode = 1) {
324
406
 
325
407
  Usage:
326
408
  open-memex where
327
- open-memex list [--scope project|personal] [--type T] [--limit N]
409
+ open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
328
410
  open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
329
411
  open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
330
412
  open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
331
413
  open-memex status <id> active|deprecated|retracted|archived
332
- open-memex forget <id>
414
+ open-memex forget <id> [--soft]
333
415
  open-memex propose <id...> --to project [--local-approve]
334
416
  open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
335
417
  open-memex resolve [id-or-path]
336
418
  open-memex sync-status
419
+ open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
337
420
  open-memex pull
338
421
  open-memex push
339
422
  open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
@@ -713,12 +796,11 @@ async function main() {
713
796
  process.exit(1);
714
797
  }
715
798
  const cfg = loadConfig();
716
- const { detectKeywords } = await import("./capture/keywords.js");
799
+ const { scanKeywords, MIN_CAPTURE_CHARS, MAX_CAPTURE_CHARS } = await import("./capture/keywords.js");
717
800
  const text = rest.slice(1).join(" ");
718
- const hits = detectKeywords(text, cfg);
801
+ const { hits, dropped } = scanKeywords(text, cfg);
719
802
  if (hits.length === 0) {
720
- console.log("no keyword triggers — nothing would be captured.");
721
- return;
803
+ console.log("no keyword triggers - nothing would be captured.");
722
804
  }
723
805
  for (const h of hits) {
724
806
  const r = redact(h.content, cfg.redactPatterns);
@@ -727,6 +809,13 @@ async function main() {
727
809
  console.log(` secret hit: ${r.hadSecret ? `yes (${r.matchedPattern}) — will be masked` : "no"}`);
728
810
  console.log(` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`);
729
811
  }
812
+ // D67: a trigger that matched but produced a fragment is reported, not
813
+ // silently dropped - otherwise "记住:这个" just looks broken.
814
+ for (const d of dropped) {
815
+ console.log(d.reason === "too-short"
816
+ ? `! matched but NOT captured: body "${d.content}" is ${d.content.length} chars (floor: ${MIN_CAPTURE_CHARS}). A trigger alone isn't a memory - write the fact: "记住:<fact>".`
817
+ : `! matched but NOT captured: body is ${d.content.length} chars (ceiling: ${MAX_CAPTURE_CHARS}) - save it with memory_add instead.`);
818
+ }
730
819
  return;
731
820
  }
732
821
  // `mcp` starts the stdio MCP server (11 tools; the opencode plugin exposes 5).
@@ -745,6 +834,7 @@ async function main() {
745
834
  if (cmd === "where") {
746
835
  const p = paths();
747
836
  console.log(`root: ${p.root}`);
837
+ console.log(`config: ${configSource() ?? "(built-in defaults)"}`);
748
838
  console.log(`memories: ${p.memories}`);
749
839
  console.log(`index: ${p.indexDb}`);
750
840
  console.log(`project: ${project.key}`);
@@ -759,20 +849,40 @@ async function main() {
759
849
  }
760
850
  if (cmd === "list") {
761
851
  const flags = parseFlags(rest);
762
- const s = resolveCliScope(flags, project);
763
- syncScope(s.key, "cli");
764
- const hits = list(s.key, {
765
- type: flags.type,
766
- limit: flags.limit ? Number(flags.limit) : undefined,
767
- });
768
- if (hits.length === 0) {
769
- console.log(`(no memories in ${s.key})`);
770
- return;
771
- }
772
- for (const h of hits) {
773
- const dep = h.status === "deprecated" ? " [deprecated]" : "";
774
- const rev = hitStateLabel(h);
775
- console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
852
+ // D66: --scope both, on par with the memory_list tool — prints each
853
+ // scope's newest under its own header.
854
+ const scopes = flags.scope === "both" ? [PERSONAL_SCOPE, project] : [resolveCliScope(flags, project)];
855
+ const include = flags.include === "all" ? "all" : "active";
856
+ // D70: the CLI renders the SAME structured inventory the agent sees,
857
+ // numbering runs across the whole listing, and a cut list says how
858
+ // many entries it dropped — `open-memex list` is the surface a
859
+ // sceptical user runs first, so it may not look complete when it isn't.
860
+ let seq = 0;
861
+ for (const s of scopes) {
862
+ syncScope(s.key, "cli");
863
+ const hits = list(s.key, {
864
+ type: flags.type,
865
+ limit: flags.limit ? Number(flags.limit) : undefined,
866
+ include,
867
+ });
868
+ const total = countList(s.key, { type: flags.type, include });
869
+ if (scopes.length > 1) {
870
+ console.log(s.key === PERSONAL_SCOPE.key
871
+ ? "## About you (personal — stays on this machine, applies everywhere)"
872
+ : `## This project (${s.projectName || s.key})`);
873
+ }
874
+ if (hits.length === 0) {
875
+ console.log(scopes.length > 1
876
+ ? "(nothing remembered in this scope yet)"
877
+ : `(no memories in ${s.key})`);
878
+ continue;
879
+ }
880
+ for (const h of hits) {
881
+ console.log(formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
882
+ }
883
+ const note = truncationNote(hits.length, total);
884
+ if (note)
885
+ console.log(note);
776
886
  }
777
887
  return;
778
888
  }
@@ -849,6 +959,18 @@ async function main() {
849
959
  .map((t) => t.trim())
850
960
  .filter(Boolean)
851
961
  : [],
962
+ // D61: --aliases "phrase one; phrase two" (comma also accepted; use
963
+ // ; when an alias itself contains a comma). A bare --aliases with no
964
+ // value makes parseFlags yield "true" — treat that as absent. Stored
965
+ // only when the install enables capture aliases, redacted like content.
966
+ ...(typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
967
+ ? {
968
+ aliases: normalizeAliases(flags.aliases
969
+ .split(/[;,]/)
970
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
971
+ .filter(Boolean)),
972
+ }
973
+ : {}),
852
974
  source: "cli",
853
975
  created_at: rfc,
854
976
  updated_at: rfc,
@@ -888,6 +1010,12 @@ async function main() {
888
1010
  tags: flags.tag
889
1011
  ? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
890
1012
  : undefined,
1013
+ aliases: typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
1014
+ ? normalizeAliases(flags.aliases
1015
+ .split(/[;,]/)
1016
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
1017
+ .filter(Boolean))
1018
+ : undefined,
891
1019
  source: "cli",
892
1020
  });
893
1021
  upsertFromFile(oldMf);
@@ -923,18 +1051,44 @@ async function main() {
923
1051
  const id = rest[0];
924
1052
  if (!id)
925
1053
  usage();
1054
+ // D68: --soft hides (retracts) instead of deleting — same action as
1055
+ // the memory_forget tool's soft flag. One-way (D64).
1056
+ if (rest.includes("--soft")) {
1057
+ const target = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id);
1058
+ try {
1059
+ const mf = setStatus(id, "retracted");
1060
+ upsertFromFile(mf);
1061
+ console.log(`hidden ${id} (retracted — out of lists and search; save it again as a new memory to bring it back)`);
1062
+ // D70: same honesty as the hard delete — a retraction of an in-repo
1063
+ // memory is a local working-tree edit until it is committed and pushed.
1064
+ if (target && isInRepoMemoryFile(target.file_path)) {
1065
+ console.log(`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.`);
1066
+ }
1067
+ }
1068
+ catch (e) {
1069
+ console.error(`hide failed: ${e.message}`);
1070
+ process.exit(1);
1071
+ }
1072
+ return;
1073
+ }
926
1074
  const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id);
927
1075
  if (!row) {
928
1076
  console.error("not found");
929
1077
  process.exit(1);
930
1078
  }
931
1079
  // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
1080
+ // Only ENOENT = success; anything else means the file survives and
1081
+ // would be re-indexed — fail loudly (P0 review, 2026-10-03).
932
1082
  if (row.file_path) {
933
1083
  try {
934
1084
  fs.unlinkSync(row.file_path);
935
1085
  }
936
- catch {
937
- /* already gone */
1086
+ catch (e) {
1087
+ const code = e.code;
1088
+ if (code !== "ENOENT") {
1089
+ console.error(`could not delete ${row.file_path} (${code ?? "error"}) — memory not forgotten`);
1090
+ process.exit(1);
1091
+ }
938
1092
  }
939
1093
  }
940
1094
  deleteFromIndex(id);
@@ -1054,11 +1208,73 @@ async function main() {
1054
1208
  }
1055
1209
  return;
1056
1210
  }
1211
+ if (cmd === "inventory") {
1212
+ // D68: refresh both scopes first so the report matches the markdown
1213
+ // on disk. Default scope: personal + this project.
1214
+ syncScope(project.key, "cli");
1215
+ syncScope(PERSONAL_SCOPE.key, "cli");
1216
+ const flags = parseFlags(rest);
1217
+ const format = flags.format ?? "text";
1218
+ if (format !== "text" && format !== "json" && format !== "html") {
1219
+ console.error(`inventory: unknown --format '${format}' (text|json|html)`);
1220
+ process.exit(2);
1221
+ }
1222
+ const scopeKeys = flags.scope === "personal"
1223
+ ? [PERSONAL_SCOPE.key]
1224
+ : flags.scope === "project"
1225
+ ? [project.key]
1226
+ : [PERSONAL_SCOPE.key, project.key];
1227
+ const outPath = flags["out"] ?? flags["output"];
1228
+ const includesPersonal = scopeKeys.includes(PERSONAL_SCOPE.key);
1229
+ const allowPersonal = flags["allow-personal"] === "true";
1230
+ try {
1231
+ const { loadInventory, renderInventoryText, renderInventoryJson } = await import("./inventory.js");
1232
+ if (format === "html") {
1233
+ const { renderInventoryHtml, defaultHtmlOutPath } = await import("./inventory-html.js");
1234
+ const target = outPath ?? defaultHtmlOutPath(paths().root);
1235
+ // D70: one guard for every format (this path had its own copy).
1236
+ const refusal = inventoryWriteRefusal(target, includesPersonal, allowPersonal);
1237
+ if (refusal) {
1238
+ console.error(refusal);
1239
+ process.exit(2);
1240
+ }
1241
+ // D70: only the audit surface needs the hidden rows themselves;
1242
+ // text/json report counts, so they must not pay to load them.
1243
+ const data = loadInventory(scopeKeys, { hidden: "entries" });
1244
+ fs.writeFileSync(target, renderInventoryHtml(data), "utf8");
1245
+ console.log(`inventory: ${data.total} memories → ${target}`);
1246
+ console.log(`open it in any browser; the report is read-only.`);
1247
+ return;
1248
+ }
1249
+ const data = loadInventory(scopeKeys);
1250
+ const rendered = format === "json" ? renderInventoryJson(data) : renderInventoryText(data);
1251
+ if (outPath) {
1252
+ // D68/3-C v2: the report contains personal content. Writing it into
1253
+ // a git working tree would ride the next commit off this machine —
1254
+ // refuse unless the user explicitly accepts that.
1255
+ const refusal = inventoryWriteRefusal(outPath, includesPersonal, allowPersonal);
1256
+ if (refusal) {
1257
+ console.error(refusal);
1258
+ process.exit(2);
1259
+ }
1260
+ fs.writeFileSync(outPath, rendered, "utf8");
1261
+ console.log(`inventory: ${data.total} memories → ${outPath}`);
1262
+ }
1263
+ else {
1264
+ process.stdout.write(rendered);
1265
+ }
1266
+ }
1267
+ catch (e) {
1268
+ console.error(`inventory failed: ${e.message}`);
1269
+ process.exit(2);
1270
+ }
1271
+ return;
1272
+ }
1057
1273
  if (cmd === "sync-status") {
1058
1274
  syncScope(project.key, "cli");
1059
1275
  syncScope(PERSONAL_SCOPE.key, "cli");
1060
1276
  try {
1061
- console.log(formatSyncStatus(getSyncStatus()));
1277
+ console.log(formatSyncStatus(getSyncStatus(), { cap: Number.POSITIVE_INFINITY }));
1062
1278
  }
1063
1279
  catch (e) {
1064
1280
  console.error(`sync-status failed: ${e.message}`);
@@ -1151,6 +1367,9 @@ async function main() {
1151
1367
  syncScope(PERSONAL_SCOPE.key, "cli");
1152
1368
  }
1153
1369
  console.log(`${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`);
1370
+ for (const rp of r.rejectedPaths) {
1371
+ console.log(` rejected: ${rp} (path outside the bundle)`);
1372
+ }
1154
1373
  for (const c of r.skippedConflict) {
1155
1374
  console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
1156
1375
  }
package/dist/config.js CHANGED
@@ -6,26 +6,41 @@ export const DEFAULT_CONFIG = {
6
6
  maxProfileItems: 5,
7
7
  injectOnFirstTurn: true,
8
8
  keywordCaptureEnabled: true,
9
+ captureAliases: true,
9
10
  keywordPatterns: [
10
- "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
11
- "^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
11
+ "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that|\\s+to)?[:,]?\\s+(.+)$",
12
+ "^\\s*(?:please\\s+)?note(?:\\s+that)?[:,]?\\s+(.+)$",
13
+ // D67: "don't forget the wifi password" is narration addressed to the
14
+ // agent, not a statement to the store. An explicit marker - "that" or a
15
+ // colon - makes it an instruction ("don't forget that ..." / "don't
16
+ // forget: ..."). Mirrors 别忘了 below; see D67.
17
+ "^\\s*(?:please\\s+)?don'?t\\s+forget(?:\\s+that\\s+|[:,]\\s+)(.+)$",
12
18
  "^\\s*TIL[:,]?\\s+(.+)$",
13
19
  "^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
14
20
  // Chinese equivalents
15
- "^\\s*记住(?!(个人))[::,,]?\\s*(.+)$",
21
+ "^\\s*(?:请)?记住(?!(个人))[::,,]?\\s*(.+)$",
16
22
  "^\\s*(?:请)?(?:记一下|记录一下)[::,,]?\\s*(.+)$",
17
- "^\\s*别忘了[::,,]?\\s*(.+)$",
23
+ // D67: separator required. "别忘了带伞" is narration ("don't forget your
24
+ // umbrella"); "别忘了:周五不发布" is an instruction to the store. Same
25
+ // rule as the English "don't forget" above.
26
+ "^\\s*别忘了[::,,]\\s*(.+)$",
18
27
  // First-person plural: team/project context, NOT personal
19
28
  "^\\s*我们认为[::,,]?\\s*(.+)$",
20
29
  "^\\s*我们决定[::,,]?\\s*(.+)$",
21
- "^\\s*帮我们记(?:住|一下)?[::,,]?\\s*(.+)$",
30
+ // D67: the boundary applies to the BARE verb only. 记 + (住|录) + 一下 is a
31
+ // complete verb phrase, so the noun may follow directly ("帮我记住这个配置",
32
+ // "记一下这个约定"); only a bare 记 needs a separator, otherwise 帮我记得…
33
+ // would capture mid-word garbage. See D67.
34
+ "^\\s*(?:请)?帮我们记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
22
35
  ],
23
36
  keywordPersonalPatterns: [
24
37
  "^\\s*remember\\s+for\\s+me(?:\\s+that)?[:,]?\\s+(.+)$",
25
- "^\\s*记住(个人)[::,,]?\\s*(.+)$",
38
+ "^\\s*(?:请)?记住(个人)[::,,]?\\s*(.+)$",
26
39
  // First-person singular: personal scope ("我" → 个人, "我们" → 项目)
27
- "^\\s*记住我(?!们)[::,,]?\\s*(.+)$",
28
- "^\\s*替我记(?:住|一下)?[::,,]?\\s*(.+)$",
40
+ "^\\s*(?:请)?记住我(?!们)[::,,]?\\s*(.+)$",
41
+ "^\\s*(?:请)?替我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
42
+ "^\\s*(?:请)?帮我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
43
+ "^\\s*help\\s+me\\s+remember(?:\\s+that)?[:,]?\\s+(.+)$",
29
44
  "^\\s*我觉得[::,,]?\\s*(.+)$",
30
45
  "^\\s*我喜欢[::,,]?\\s*(.+)$",
31
46
  ],
@@ -36,18 +51,73 @@ export const DEFAULT_CONFIG = {
36
51
  memoryDir: ".ai/open-memex",
37
52
  sync: { autoPull: false },
38
53
  };
39
- function stripJsonComments(raw) {
40
- // Line comments
41
- let out = raw.replace(/^\s*\/\/.*$/gm, "");
42
- // Block comments (non-greedy)
43
- out = out.replace(/\/\*[\s\S]*?\*\//g, "");
44
- // Trailing commas before closing brackets
45
- out = out.replace(/,(\s*[}\]])/g, "$1");
54
+ /**
55
+ * Strip JSONC comments and trailing commas with a string-aware scanner.
56
+ * The previous regex version was not string-aware: a config value like
57
+ * "src/**\/secrets" had its `/**…*\/` eaten as a block comment, silently
58
+ * corrupting the user's redact pattern (P0 review, 2026-10-03). Shared
59
+ * with doctor.ts — one parser, not two.
60
+ */
61
+ export function stripJsonComments(raw) {
62
+ let out = "";
63
+ let i = 0;
64
+ let inStr = false;
65
+ let esc = false;
66
+ while (i < raw.length) {
67
+ const c = raw[i];
68
+ const n = raw[i + 1];
69
+ if (inStr) {
70
+ out += c;
71
+ if (esc)
72
+ esc = false;
73
+ else if (c === "\\")
74
+ esc = true;
75
+ else if (c === '"')
76
+ inStr = false;
77
+ i++;
78
+ continue;
79
+ }
80
+ if (c === '"') {
81
+ inStr = true;
82
+ out += c;
83
+ i++;
84
+ continue;
85
+ }
86
+ if (c === "/" && n === "/") {
87
+ while (i < raw.length && raw[i] !== "\n")
88
+ i++;
89
+ continue;
90
+ }
91
+ if (c === "/" && n === "*") {
92
+ i += 2;
93
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/"))
94
+ i++;
95
+ i += 2;
96
+ continue;
97
+ }
98
+ if (c === ",") {
99
+ // Trailing comma: drop it when the next significant char closes a
100
+ // container. Whitespace/comments between are tolerated.
101
+ let j = i + 1;
102
+ while (j < raw.length && /\s/.test(raw[j]))
103
+ j++;
104
+ if (raw[j] === "}" || raw[j] === "]") {
105
+ i++;
106
+ continue;
107
+ }
108
+ }
109
+ out += c;
110
+ i++;
111
+ }
46
112
  return out;
47
113
  }
48
- /** Where `config set` / interactive `init` persist. Respects MY_O_MEMORY_CONFIG. */
114
+ /**
115
+ * Where `config set` / interactive `init` persist. Respects OPEN_MEMEX_CONFIG
116
+ * (D62); MY_O_MEMORY_CONFIG is the pre-rename fallback, still honored.
117
+ */
49
118
  export function configFilePath() {
50
- return (process.env.MY_O_MEMORY_CONFIG ??
119
+ return (process.env.OPEN_MEMEX_CONFIG ??
120
+ process.env.MY_O_MEMORY_CONFIG ??
51
121
  path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"));
52
122
  }
53
123
  function toBool(v) {
@@ -79,6 +149,7 @@ export const SETTABLE_KEYS = {
79
149
  maxProfileItems: toNonNegInt,
80
150
  injectOnFirstTurn: toBool,
81
151
  keywordCaptureEnabled: toBool,
152
+ captureAliases: toBool,
82
153
  memoryDir: toRelativeDir,
83
154
  "sync.autoPull": toBool,
84
155
  logLevel: (v) => {
@@ -109,6 +180,7 @@ export function saveConfig(patch) {
109
180
  /** Path of the config file in effect, or null when using built-in defaults. */
110
181
  export function configSource() {
111
182
  const candidates = [
183
+ process.env.OPEN_MEMEX_CONFIG,
112
184
  process.env.MY_O_MEMORY_CONFIG,
113
185
  path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
114
186
  path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
@@ -125,7 +197,19 @@ export function loadConfig() {
125
197
  try {
126
198
  const raw = fs.readFileSync(p, "utf8");
127
199
  const parsed = JSON.parse(stripJsonComments(raw));
128
- return { ...DEFAULT_CONFIG, ...parsed };
200
+ const merged = { ...DEFAULT_CONFIG, ...parsed };
201
+ // The config file is hand-editable, so the load path must enforce
202
+ // the same invariants `config set` does (P0 review, 2026-10-03):
203
+ // an unvalidated memoryDir like "../../shared" would put
204
+ // committed project memories outside the repo.
205
+ try {
206
+ merged.memoryDir = toRelativeDir(merged.memoryDir);
207
+ }
208
+ catch {
209
+ console.error(`[open-memex] invalid memoryDir "${merged.memoryDir}" in ${p} — using default "${DEFAULT_CONFIG.memoryDir}"`);
210
+ merged.memoryDir = DEFAULT_CONFIG.memoryDir;
211
+ }
212
+ return merged;
129
213
  }
130
214
  catch (err) {
131
215
  console.error(`[open-memex] failed to parse ${p}:`, err);
@@ -57,7 +57,15 @@ export function distillAgentsMarkdown(opts) {
57
57
  // (D53: the checkpoint mechanism is gone).
58
58
  lines.push(`### Memory hygiene (open-memex)`);
59
59
  lines.push(``);
60
- lines.push(`- When you finish a task the user would describe in one sentence, distill`, ` the session: propose 1–3 short memories capturing the useful`, ` conclusion — what was learned or decided, how an issue was resolved, what`, ` to avoid, where the authoritative doc lives — not the raw transcript.`, ` Save nothing without user approval.`, `- If the knowledge already lives in project docs, save a \`reference\` memory`, ` pointing at the doc instead of copying it.`);
60
+ lines.push(`- When you finish a task the user would describe in one sentence, distill`, ` the session: propose 1–3 short memories capturing the useful`, ` conclusion — what was learned or decided, how an issue was resolved, what`, ` to avoid, where the authoritative doc lives — not the raw transcript.`, ` Save nothing without user approval.`, ` Worth keeping: decisions and their reasons, preferences, conventions,`, ` gotchas, approaches tried and abandoned. Not worth keeping: one-off task`, ` details or anything re-derivable from the code.`, `- If the knowledge already lives in project docs, save a \`reference\` memory`, ` pointing at the doc instead of copying it.`, `- When saving via \`memory_add\`, attach 2–4 aliases (alternate phrasings,`, ` synonyms, equivalents in the user's other language) when the install has`, ` capture aliases enabled — they make reworded questions find the memory.`,
61
+ // D64: the skills runbook and the MCP handshake both teach marking
62
+ // inferred captures source: "inference"; this footer omitted it, so
63
+ // opencode agents saved distilled conclusions as if the user had
64
+ // stated them ("tool"). Keep the rubric one voice across surfaces.
65
+ `- Mark captures you infer (rather than the user stating) with`, ` \`source: "inference"\` — provenance matters more than polish.`,
66
+ // D68: same voice for the visibility surface — opencode agents reading
67
+ // this footer never see the MCP handshake either.
68
+ `- When the user asks what you remember, list it conversationally`, ` (\`memory_list\` with scope both, or \`open-memex inventory\`) — plain`, ` words, not raw ids. Before deleting an entry they point at, read it`, ` back in full and confirm; deletion is permanent. Offer hiding`, ` (\`memory_forget\` soft / \`forget --soft\`) when they hesitate.`);
61
69
  lines.push(``);
62
70
  return lines.join("\n");
63
71
  }