open-memex 0.6.2 → 0.7.0-alpha.11

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 (68) hide show
  1. package/AGENTS.md +25 -7
  2. package/CHANGELOG.md +103 -1
  3. package/README.md +54 -16
  4. package/README.zh-CN.md +30 -15
  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 +180 -79
  11. package/dist/export.js +12 -2
  12. package/dist/index.js +20 -112
  13. package/dist/init.js +103 -11
  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/opencode-v2.js +43 -0
  18. package/dist/paths.js +34 -2
  19. package/dist/plugin-core.js +138 -0
  20. package/dist/retrieve/display.js +161 -0
  21. package/dist/retrieve/inject.js +1 -1
  22. package/dist/retrieve/search.js +44 -5
  23. package/dist/review.js +9 -3
  24. package/dist/store/db.js +11 -9
  25. package/dist/store/lifecycle.js +20 -4
  26. package/dist/store/markdown.js +53 -7
  27. package/dist/store/migrate.js +14 -3
  28. package/dist/store/sync.js +5 -3
  29. package/dist/store/v2migrate.js +2 -2
  30. package/dist/submit.js +13 -5
  31. package/dist/tools/memory.js +28 -0
  32. package/dist/tools/ops.js +127 -16
  33. package/docs/CURATOR.md +7 -3
  34. package/docs/V2-DESIGN.md +335 -32
  35. package/docs/memory-visibility-design.md +130 -0
  36. package/package.json +2 -2
  37. package/scripts/build-stamp.mjs +63 -0
  38. package/scripts/smoke-mcp.ts +47 -1
  39. package/scripts/smoke-pure.ts +237 -3
  40. package/scripts/test-full.ts +362 -5
  41. package/skills/open-memex/SKILL.md +53 -1
  42. package/src/capture/keywords.ts +56 -6
  43. package/src/cli.ts +282 -39
  44. package/src/config.ts +104 -17
  45. package/src/distill-agents.ts +19 -0
  46. package/src/doctor.ts +181 -71
  47. package/src/export.ts +14 -2
  48. package/src/index.ts +25 -112
  49. package/src/init.ts +108 -11
  50. package/src/inventory-html.ts +191 -0
  51. package/src/inventory.ts +242 -0
  52. package/src/mcp.ts +21 -5
  53. package/src/opencode-v2.ts +119 -0
  54. package/src/paths.ts +35 -1
  55. package/src/plugin-core.ts +152 -0
  56. package/src/retrieve/display.ts +174 -0
  57. package/src/retrieve/inject.ts +1 -1
  58. package/src/retrieve/search.ts +63 -6
  59. package/src/review.ts +9 -2
  60. package/src/store/db.ts +11 -9
  61. package/src/store/lifecycle.ts +24 -3
  62. package/src/store/markdown.ts +60 -7
  63. package/src/store/migrate.ts +15 -2
  64. package/src/store/sync.ts +6 -3
  65. package/src/store/v2migrate.ts +2 -1
  66. package/src/submit.ts +12 -4
  67. package/src/tools/memory.ts +28 -0
  68. package/src/tools/ops.ts +147 -17
package/src/cli.ts CHANGED
@@ -9,22 +9,76 @@ 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,
16
17
  ulid,
17
18
  msToRfc3339,
19
+ normalizeAliases,
18
20
  type Frontmatter,
19
21
  } from "./store/markdown.ts";
20
- import { loadConfig } from "./config.ts";
21
- import { paths, projectRoot } from "./paths.ts";
22
+ import { loadConfig, configSource } from "./config.ts";
23
+ import { paths, projectRoot, isInRepoMemoryFile } from "./paths.ts";
22
24
  import { redact } from "./redact.ts";
23
25
  import { resolveMcpCommand } from "./init.ts";
24
26
  import fs from "node:fs";
25
27
  import path from "node:path";
28
+ import { execFileSync } from "node:child_process";
26
29
  import { fileURLToPath } from "node:url";
27
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
+
28
82
  /** Per-command help, printed by `open-memex <command> --help`.
29
83
  AI assistants discover the CLI through --help, so every command needs one. */
30
84
  const COMMAND_HELP: Record<string, string> = {
@@ -35,14 +89,20 @@ Usage: open-memex where
35
89
  Example:
36
90
  open-memex where`,
37
91
 
38
- list: `List memories in a scope, newest first.
92
+ list: `List memories in a scope, newest first, as a numbered inventory.
39
93
 
40
- Usage: open-memex list [--scope project|personal] [--type T] [--limit N]
94
+ Usage: open-memex list [--scope project|personal|both] [--type T] [--limit N] [--include active|all]
41
95
 
42
96
  Flags:
43
- --scope project (default) or personal
44
- --type filter by memory type
45
- --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.
46
106
 
47
107
  Examples:
48
108
  open-memex list
@@ -64,19 +124,26 @@ Example:
64
124
 
65
125
  add: `Save a fact, preference, decision, or note to local memory.
66
126
 
67
- Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
127
+ Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"]
68
128
 
69
129
  Flags:
70
- --scope project (default) or personal (personal never leaves this machine)
71
- --type memory type (default: fact)
72
- --tag comma-separated tags
130
+ --scope project (default) or personal (personal never leaves this machine)
131
+ --type memory type (default: fact)
132
+ --tag comma-separated tags
133
+ --aliases alternate phrasings of this fact (up to 4), indexed with the
134
+ memory so differently-worded searches still match; separate
135
+ with ; (commas also accepted — use ; when an alias itself
136
+ contains one). Stored only when capture aliases are enabled
137
+ (init default)
73
138
 
74
139
  Example:
75
140
  open-memex add "We deploy on Fridays" --scope project --tag process`,
76
141
 
77
142
  supersede: `Replace a memory with a newer version. The old one is kept as history.
78
143
 
79
- Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
144
+ Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2] [--aliases "a;b"]
145
+
146
+ Without --aliases, the old memory's aliases carry over.
80
147
 
81
148
  Example:
82
149
  open-memex supersede 01ABC "We deploy on Thursdays now"`,
@@ -85,15 +152,22 @@ Example:
85
152
 
86
153
  Usage: open-memex status <id> active|deprecated|retracted|archived
87
154
 
155
+ Retraction is one-way: a retracted memory cannot be set back to active
156
+ (it was withdrawn on purpose). Save the content as a new memory instead.
157
+
88
158
  Example:
89
159
  open-memex status 01ABC deprecated`,
90
160
 
91
- 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.
92
165
 
93
- Usage: open-memex forget <id>
166
+ Usage: open-memex forget <id> [--soft]
94
167
 
95
168
  Example:
96
- open-memex forget 01ABC`,
169
+ open-memex forget 01ABC
170
+ open-memex forget 01ABC --soft`,
97
171
 
98
172
  propose: `Copy personal memories into the project outbox as review drafts.
99
173
  The personal originals stay put. Nothing enters git at this step.
@@ -145,6 +219,22 @@ Usage: open-memex sync-status
145
219
  Example:
146
220
  open-memex sync-status`,
147
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
+
148
238
  pull: `Pull shared project memories from the git remote: fetch + fast-forward
149
239
  only. Never auto-merges — a diverged branch fails with a clear message and is
150
240
  left for you to resolve by hand. On success the local index re-syncs.
@@ -359,16 +449,17 @@ function usage(exitCode = 1): never {
359
449
 
360
450
  Usage:
361
451
  open-memex where
362
- 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]
363
453
  open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
364
454
  open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
365
455
  open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
366
456
  open-memex status <id> active|deprecated|retracted|archived
367
- open-memex forget <id>
457
+ open-memex forget <id> [--soft]
368
458
  open-memex propose <id...> --to project [--local-approve]
369
459
  open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
370
460
  open-memex resolve [id-or-path]
371
461
  open-memex sync-status
462
+ open-memex inventory [--format text|json|html] [--scope project|personal|both] [--out <path>] [--allow-personal]
372
463
  open-memex pull
373
464
  open-memex push
374
465
  open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
@@ -795,12 +886,13 @@ async function main() {
795
886
  process.exit(1);
796
887
  }
797
888
  const cfg = loadConfig();
798
- const { detectKeywords } = await import("./capture/keywords.ts");
889
+ const { scanKeywords, MIN_CAPTURE_CHARS, MAX_CAPTURE_CHARS } = await import(
890
+ "./capture/keywords.ts"
891
+ );
799
892
  const text = rest.slice(1).join(" ");
800
- const hits = detectKeywords(text, cfg);
893
+ const { hits, dropped } = scanKeywords(text, cfg);
801
894
  if (hits.length === 0) {
802
- console.log("no keyword triggers — nothing would be captured.");
803
- return;
895
+ console.log("no keyword triggers - nothing would be captured.");
804
896
  }
805
897
  for (const h of hits) {
806
898
  const r = redact(h.content, cfg.redactPatterns);
@@ -811,6 +903,15 @@ async function main() {
811
903
  ` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`,
812
904
  );
813
905
  }
906
+ // D67: a trigger that matched but produced a fragment is reported, not
907
+ // silently dropped - otherwise "记住:这个" just looks broken.
908
+ for (const d of dropped) {
909
+ console.log(
910
+ d.reason === "too-short"
911
+ ? `! 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>".`
912
+ : `! matched but NOT captured: body is ${d.content.length} chars (ceiling: ${MAX_CAPTURE_CHARS}) - save it with memory_add instead.`,
913
+ );
914
+ }
814
915
  return;
815
916
  }
816
917
 
@@ -832,6 +933,7 @@ async function main() {
832
933
  if (cmd === "where") {
833
934
  const p = paths();
834
935
  console.log(`root: ${p.root}`);
936
+ console.log(`config: ${configSource() ?? "(built-in defaults)"}`);
835
937
  console.log(`memories: ${p.memories}`);
836
938
  console.log(`index: ${p.indexDb}`);
837
939
  console.log(`project: ${project.key}`);
@@ -850,20 +952,44 @@ async function main() {
850
952
 
851
953
  if (cmd === "list") {
852
954
  const flags = parseFlags(rest);
853
- const s = resolveCliScope(flags, project);
854
- syncScope(s.key, "cli");
855
- const hits = list(s.key, {
856
- type: flags.type,
857
- limit: flags.limit ? Number(flags.limit) : undefined,
858
- });
859
- if (hits.length === 0) {
860
- console.log(`(no memories in ${s.key})`);
861
- return;
862
- }
863
- for (const h of hits) {
864
- const dep = h.status === "deprecated" ? " [deprecated]" : "";
865
- const rev = hitStateLabel(h);
866
- console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
955
+ // D66: --scope both, on par with the memory_list tool — prints each
956
+ // scope's newest under its own header.
957
+ const scopes =
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;
965
+ for (const s of scopes) {
966
+ syncScope(s.key, "cli");
967
+ const hits = list(s.key, {
968
+ type: flags.type,
969
+ limit: flags.limit ? Number(flags.limit) : undefined,
970
+ include,
971
+ });
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
+ }
980
+ if (hits.length === 0) {
981
+ console.log(
982
+ scopes.length > 1
983
+ ? "(nothing remembered in this scope yet)"
984
+ : `(no memories in ${s.key})`,
985
+ );
986
+ continue;
987
+ }
988
+ for (const h of hits) {
989
+ console.log(formatInventoryLine(seq++, h, PERSONAL_SCOPE.key));
990
+ }
991
+ const note = truncationNote(hits.length, total);
992
+ if (note) console.log(note);
867
993
  }
868
994
  return;
869
995
  }
@@ -943,6 +1069,20 @@ async function main() {
943
1069
  .map((t) => t.trim())
944
1070
  .filter(Boolean)
945
1071
  : [],
1072
+ // D61: --aliases "phrase one; phrase two" (comma also accepted; use
1073
+ // ; when an alias itself contains a comma). A bare --aliases with no
1074
+ // value makes parseFlags yield "true" — treat that as absent. Stored
1075
+ // only when the install enables capture aliases, redacted like content.
1076
+ ...(typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
1077
+ ? {
1078
+ aliases: normalizeAliases(
1079
+ flags.aliases
1080
+ .split(/[;,]/)
1081
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
1082
+ .filter(Boolean),
1083
+ ),
1084
+ }
1085
+ : {}),
946
1086
  source: "cli",
947
1087
  created_at: rfc,
948
1088
  updated_at: rfc,
@@ -985,6 +1125,15 @@ async function main() {
985
1125
  tags: flags.tag
986
1126
  ? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
987
1127
  : undefined,
1128
+ aliases:
1129
+ typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
1130
+ ? normalizeAliases(
1131
+ flags.aliases
1132
+ .split(/[;,]/)
1133
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
1134
+ .filter(Boolean),
1135
+ )
1136
+ : undefined,
988
1137
  source: "cli",
989
1138
  });
990
1139
  upsertFromFile(oldMf);
@@ -1020,6 +1169,29 @@ async function main() {
1020
1169
  if (cmd === "forget") {
1021
1170
  const id = rest[0];
1022
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
+ }
1023
1195
  const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
1024
1196
  | { file_path: string }
1025
1197
  | undefined;
@@ -1028,11 +1200,17 @@ async function main() {
1028
1200
  process.exit(1);
1029
1201
  }
1030
1202
  // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
1203
+ // Only ENOENT = success; anything else means the file survives and
1204
+ // would be re-indexed — fail loudly (P0 review, 2026-10-03).
1031
1205
  if (row.file_path) {
1032
1206
  try {
1033
1207
  fs.unlinkSync(row.file_path);
1034
- } catch {
1035
- /* already gone */
1208
+ } catch (e) {
1209
+ const code = (e as NodeJS.ErrnoException).code;
1210
+ if (code !== "ENOENT") {
1211
+ console.error(`could not delete ${row.file_path} (${code ?? "error"}) — memory not forgotten`);
1212
+ process.exit(1);
1213
+ }
1036
1214
  }
1037
1215
  }
1038
1216
  deleteFromIndex(id);
@@ -1147,11 +1325,73 @@ function positionalArgs(argv: string[]): string[] {
1147
1325
  return;
1148
1326
  }
1149
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
+
1150
1390
  if (cmd === "sync-status") {
1151
1391
  syncScope(project.key, "cli");
1152
1392
  syncScope(PERSONAL_SCOPE.key, "cli");
1153
1393
  try {
1154
- console.log(formatSyncStatus(getSyncStatus()));
1394
+ console.log(formatSyncStatus(getSyncStatus(), { cap: Number.POSITIVE_INFINITY }));
1155
1395
  } catch (e) {
1156
1396
  console.error(`sync-status failed: ${(e as Error).message}`);
1157
1397
  process.exit(2);
@@ -1250,6 +1490,9 @@ function positionalArgs(argv: string[]): string[] {
1250
1490
  console.log(
1251
1491
  `${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`,
1252
1492
  );
1493
+ for (const rp of r.rejectedPaths) {
1494
+ console.log(` rejected: ${rp} (path outside the bundle)`);
1495
+ }
1253
1496
  for (const c of r.skippedConflict) {
1254
1497
  console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
1255
1498
  }
package/src/config.ts CHANGED
@@ -7,6 +7,14 @@ export interface MyOMemoryConfig {
7
7
  maxProfileItems: number;
8
8
  injectOnFirstTurn: boolean;
9
9
  keywordCaptureEnabled: boolean;
10
+ /**
11
+ * D61: capture-time aliasing (retrieval layer 3). When on, agents are
12
+ * asked to attach 2–4 aliases (alternate phrasings, other-language
13
+ * equivalents) to each memory they save; ops stores them and the index
14
+ * searches them, so differently-worded questions still hit. Asked once
15
+ * at init, default on; off = aliases passed by agents are dropped.
16
+ */
17
+ captureAliases: boolean;
10
18
  /** Patterns whose capture group 1 becomes the memory body; saved to the current scope. */
11
19
  keywordPatterns: string[];
12
20
  /** Same shape, but hits are forced into the personal scope (e.g. "remember for me"). */
@@ -34,26 +42,41 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
34
42
  maxProfileItems: 5,
35
43
  injectOnFirstTurn: true,
36
44
  keywordCaptureEnabled: true,
45
+ captureAliases: true,
37
46
  keywordPatterns: [
38
- "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
39
- "^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
47
+ "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that|\\s+to)?[:,]?\\s+(.+)$",
48
+ "^\\s*(?:please\\s+)?note(?:\\s+that)?[:,]?\\s+(.+)$",
49
+ // D67: "don't forget the wifi password" is narration addressed to the
50
+ // agent, not a statement to the store. An explicit marker - "that" or a
51
+ // colon - makes it an instruction ("don't forget that ..." / "don't
52
+ // forget: ..."). Mirrors 别忘了 below; see D67.
53
+ "^\\s*(?:please\\s+)?don'?t\\s+forget(?:\\s+that\\s+|[:,]\\s+)(.+)$",
40
54
  "^\\s*TIL[:,]?\\s+(.+)$",
41
55
  "^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
42
56
  // Chinese equivalents
43
- "^\\s*记住(?!(个人))[::,,]?\\s*(.+)$",
57
+ "^\\s*(?:请)?记住(?!(个人))[::,,]?\\s*(.+)$",
44
58
  "^\\s*(?:请)?(?:记一下|记录一下)[::,,]?\\s*(.+)$",
45
- "^\\s*别忘了[::,,]?\\s*(.+)$",
59
+ // D67: separator required. "别忘了带伞" is narration ("don't forget your
60
+ // umbrella"); "别忘了:周五不发布" is an instruction to the store. Same
61
+ // rule as the English "don't forget" above.
62
+ "^\\s*别忘了[::,,]\\s*(.+)$",
46
63
  // First-person plural: team/project context, NOT personal
47
64
  "^\\s*我们认为[::,,]?\\s*(.+)$",
48
65
  "^\\s*我们决定[::,,]?\\s*(.+)$",
49
- "^\\s*帮我们记(?:住|一下)?[::,,]?\\s*(.+)$",
66
+ // D67: the boundary applies to the BARE verb only. 记 + (住|录) + 一下 is a
67
+ // complete verb phrase, so the noun may follow directly ("帮我记住这个配置",
68
+ // "记一下这个约定"); only a bare 记 needs a separator, otherwise 帮我记得…
69
+ // would capture mid-word garbage. See D67.
70
+ "^\\s*(?:请)?帮我们记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
50
71
  ],
51
72
  keywordPersonalPatterns: [
52
73
  "^\\s*remember\\s+for\\s+me(?:\\s+that)?[:,]?\\s+(.+)$",
53
- "^\\s*记住(个人)[::,,]?\\s*(.+)$",
74
+ "^\\s*(?:请)?记住(个人)[::,,]?\\s*(.+)$",
54
75
  // First-person singular: personal scope ("我" → 个人, "我们" → 项目)
55
- "^\\s*记住我(?!们)[::,,]?\\s*(.+)$",
56
- "^\\s*替我记(?:住|一下)?[::,,]?\\s*(.+)$",
76
+ "^\\s*(?:请)?记住我(?!们)[::,,]?\\s*(.+)$",
77
+ "^\\s*(?:请)?替我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
78
+ "^\\s*(?:请)?帮我记(?:(?:住|录)(?!得|着)(?:一下)?|一下|(?=[::,,\\s]|$))[::,,]?\\s*(.+)$",
79
+ "^\\s*help\\s+me\\s+remember(?:\\s+that)?[:,]?\\s+(.+)$",
57
80
  "^\\s*我觉得[::,,]?\\s*(.+)$",
58
81
  "^\\s*我喜欢[::,,]?\\s*(.+)$",
59
82
  ],
@@ -65,19 +88,68 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
65
88
  sync: { autoPull: false },
66
89
  };
67
90
 
68
- function stripJsonComments(raw: string): string {
69
- // Line comments
70
- let out = raw.replace(/^\s*\/\/.*$/gm, "");
71
- // Block comments (non-greedy)
72
- out = out.replace(/\/\*[\s\S]*?\*\//g, "");
73
- // Trailing commas before closing brackets
74
- out = out.replace(/,(\s*[}\]])/g, "$1");
91
+ /**
92
+ * Strip JSONC comments and trailing commas with a string-aware scanner.
93
+ * The previous regex version was not string-aware: a config value like
94
+ * "src/**\/secrets" had its `/**…*\/` eaten as a block comment, silently
95
+ * corrupting the user's redact pattern (P0 review, 2026-10-03). Shared
96
+ * with doctor.ts — one parser, not two.
97
+ */
98
+ export function stripJsonComments(raw: string): string {
99
+ let out = "";
100
+ let i = 0;
101
+ let inStr = false;
102
+ let esc = false;
103
+ while (i < raw.length) {
104
+ const c = raw[i]!;
105
+ const n = raw[i + 1];
106
+ if (inStr) {
107
+ out += c;
108
+ if (esc) esc = false;
109
+ else if (c === "\\") esc = true;
110
+ else if (c === '"') inStr = false;
111
+ i++;
112
+ continue;
113
+ }
114
+ if (c === '"') {
115
+ inStr = true;
116
+ out += c;
117
+ i++;
118
+ continue;
119
+ }
120
+ if (c === "/" && n === "/") {
121
+ while (i < raw.length && raw[i] !== "\n") i++;
122
+ continue;
123
+ }
124
+ if (c === "/" && n === "*") {
125
+ i += 2;
126
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
127
+ i += 2;
128
+ continue;
129
+ }
130
+ if (c === ",") {
131
+ // Trailing comma: drop it when the next significant char closes a
132
+ // container. Whitespace/comments between are tolerated.
133
+ let j = i + 1;
134
+ while (j < raw.length && /\s/.test(raw[j]!)) j++;
135
+ if (raw[j] === "}" || raw[j] === "]") {
136
+ i++;
137
+ continue;
138
+ }
139
+ }
140
+ out += c;
141
+ i++;
142
+ }
75
143
  return out;
76
144
  }
77
145
 
78
- /** Where `config set` / interactive `init` persist. Respects MY_O_MEMORY_CONFIG. */
146
+ /**
147
+ * Where `config set` / interactive `init` persist. Respects OPEN_MEMEX_CONFIG
148
+ * (D62); MY_O_MEMORY_CONFIG is the pre-rename fallback, still honored.
149
+ */
79
150
  export function configFilePath(): string {
80
151
  return (
152
+ process.env.OPEN_MEMEX_CONFIG ??
81
153
  process.env.MY_O_MEMORY_CONFIG ??
82
154
  path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc")
83
155
  );
@@ -110,6 +182,7 @@ export const SETTABLE_KEYS: Record<string, (v: unknown) => unknown> = {
110
182
  maxProfileItems: toNonNegInt,
111
183
  injectOnFirstTurn: toBool,
112
184
  keywordCaptureEnabled: toBool,
185
+ captureAliases: toBool,
113
186
  memoryDir: toRelativeDir,
114
187
  "sync.autoPull": toBool,
115
188
  logLevel: (v) => {
@@ -146,6 +219,7 @@ export function saveConfig(patch: Record<string, unknown>): string {
146
219
  /** Path of the config file in effect, or null when using built-in defaults. */
147
220
  export function configSource(): string | null {
148
221
  const candidates = [
222
+ process.env.OPEN_MEMEX_CONFIG,
149
223
  process.env.MY_O_MEMORY_CONFIG,
150
224
  path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
151
225
  path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
@@ -162,7 +236,20 @@ export function loadConfig(): MyOMemoryConfig {
162
236
  try {
163
237
  const raw = fs.readFileSync(p, "utf8");
164
238
  const parsed = JSON.parse(stripJsonComments(raw)) as Partial<MyOMemoryConfig>;
165
- return { ...DEFAULT_CONFIG, ...parsed };
239
+ const merged = { ...DEFAULT_CONFIG, ...parsed };
240
+ // The config file is hand-editable, so the load path must enforce
241
+ // the same invariants `config set` does (P0 review, 2026-10-03):
242
+ // an unvalidated memoryDir like "../../shared" would put
243
+ // committed project memories outside the repo.
244
+ try {
245
+ merged.memoryDir = toRelativeDir(merged.memoryDir);
246
+ } catch {
247
+ console.error(
248
+ `[open-memex] invalid memoryDir "${merged.memoryDir}" in ${p} — using default "${DEFAULT_CONFIG.memoryDir}"`,
249
+ );
250
+ merged.memoryDir = DEFAULT_CONFIG.memoryDir;
251
+ }
252
+ return merged;
166
253
  } catch (err) {
167
254
  console.error(`[open-memex] failed to parse ${p}:`, err);
168
255
  }