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
@@ -120,7 +120,53 @@ try {
120
120
  check("memory_search output masked", !searchText.includes("sk-test-FAKESECRET1234567890abcdef"));
121
121
 
122
122
  const list = await req("tools/call", { name: "memory_list", arguments: {} });
123
- check("memory_list works", (list.result?.content?.[0]?.text ?? "").includes(savedId));
123
+ const listText = list.result?.content?.[0]?.text ?? "";
124
+ check("memory_list works", listText.includes(savedId));
125
+ // D68: the list is the user's inventory — numbered, plain language,
126
+ // with provenance and age, not the raw developer format.
127
+ check("memory_list is numbered structured lines (D68)", /^\d+\. \[fact\] id=/m.test(listText), listText.slice(0, 120));
128
+ check("memory_list keeps raw provenance field (D68)", listText.includes("source=tool"), listText.slice(0, 160));
129
+ check("memory_list shows age token (D68)", /created=(now|\d+[mhd])/.test(listText), listText.slice(0, 160));
130
+ const listBoth = await req("tools/call", { name: "memory_list", arguments: { scope: "both" } });
131
+ const listBothText = listBoth.result?.content?.[0]?.text ?? "";
132
+ check("memory_list both scopes (D68)", listBothText.includes("About you"));
133
+ // D70: numbers run across the whole listing, so "delete #3" names one memory.
134
+ // Needs a personal entry too, or there is only one section to number.
135
+ const personalAdd = await req("tools/call", {
136
+ name: "memory_add",
137
+ arguments: { content: "mcp e2e probe: a personal note for the both-scopes listing", type: "fact", scope: "personal" },
138
+ });
139
+ check("personal memory added for the both-scopes probe", (personalAdd.result?.content?.[0]?.text ?? "").includes("id="), (personalAdd.result?.content?.[0]?.text ?? "").slice(0, 120));
140
+ const listBoth2 = await req("tools/call", { name: "memory_list", arguments: { scope: "both" } });
141
+ const listBothText2 = listBoth2.result?.content?.[0]?.text ?? "";
142
+ const bothNums = [...listBothText2.matchAll(/^(\d+)\. \[/gm)].map((m) => Number(m[1]));
143
+ check(
144
+ "memory_list numbers are unique across scopes (D70)",
145
+ bothNums.length > 1 && new Set(bothNums).size === bothNums.length && bothNums[0] === 1,
146
+ JSON.stringify(bothNums),
147
+ );
148
+
149
+ // D68: truncation is disclosed, never silent — one shown, total stated.
150
+ const add2 = await req("tools/call", {
151
+ name: "memory_add",
152
+ arguments: { content: "mcp e2e probe: second memory for truncation", type: "fact" },
153
+ });
154
+ const savedId2 = ((add2.result?.content?.[0]?.text ?? "").match(/id=([A-Za-z0-9_]+)/) ?? [])[1];
155
+ const listLim = await req("tools/call", { name: "memory_list", arguments: { limit: 1 } });
156
+ const listLimText = listLim.result?.content?.[0]?.text ?? "";
157
+ check("memory_list discloses truncation (D68)", /and \d+ more not shown/.test(listLimText), listLimText.slice(0, 200));
158
+
159
+ // D68: soft forget hides (retracted) — gone from the active list,
160
+ // visible in the audit view, hard forget still removes it everywhere.
161
+ const soft = await req("tools/call", { name: "memory_forget", arguments: { id: savedId2, soft: true } });
162
+ check("memory_forget soft works (D68)", (soft.result?.content?.[0]?.text ?? "").includes("Hidden"), (soft.result?.content?.[0]?.text ?? "").slice(0, 120));
163
+ const listAfterSoft = await req("tools/call", { name: "memory_list", arguments: {} });
164
+ check("hidden memory out of active list (D68)", !(listAfterSoft.result?.content?.[0]?.text ?? "").includes(savedId2));
165
+ const listAll = await req("tools/call", { name: "memory_list", arguments: { include: "all" } });
166
+ const listAllText = listAll.result?.content?.[0]?.text ?? "";
167
+ check("hidden memory visible in audit view (D68)", listAllText.includes(savedId2) && listAllText.includes("[retracted]"), listAllText.slice(0, 200));
168
+ const forget2 = await req("tools/call", { name: "memory_forget", arguments: { id: savedId2 } });
169
+ check("memory_forget still deletes a hidden memory (D68)", (forget2.result?.content?.[0]?.text ?? "").toLowerCase().includes("deleted"));
124
170
 
125
171
  const forget = await req("tools/call", { name: "memory_forget", arguments: { id: savedId } });
126
172
  check("memory_forget works", (forget.result?.content?.[0]?.text ?? "").includes("Deleted"));
@@ -1,15 +1,53 @@
1
1
  // Quick smoke test — runs the pure-logic modules (no bun:sqlite dependency).
2
2
  // Usage: node --experimental-strip-types scripts\smoke-pure.ts
3
- import { parse, serialize, ulid, normalizeFrontmatter, msToRfc3339, timeToMs, parseRawFrontmatter, type Frontmatter } from "../src/store/markdown.ts";
3
+ import { parse, serialize, ulid, normalizeFrontmatter, msToRfc3339, timeToMs, parseRawFrontmatter, normalizeAliases, type Frontmatter } from "../src/store/markdown.ts";
4
4
  import { planConversion, isV2File, migrateV2 } from "../src/store/v2migrate.ts";
5
5
  import { redact, findSecret } from "../src/redact.ts";
6
- import { detectKeywords } from "../src/capture/keywords.ts";
6
+ import { detectKeywords, scanKeywords } from "../src/capture/keywords.ts";
7
7
  import { DEFAULT_CONFIG } from "../src/config.ts";
8
- import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/scope.ts";
8
+ import { resolveProjectScope, resolveCwdScope, pickScopeRoot, PERSONAL_SCOPE } from "../src/scope.ts";
9
9
  import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
10
  import { toFtsQuery } from "../src/retrieve/query.ts";
11
11
  import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
12
12
  import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, mergeV2PluginEntry, removeV2PluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection, LEGACY_PACKAGE_NAME, shouldInstallSkill } from "../src/init.ts";
13
+ import { z } from "zod";
14
+ import { memoryAddArgs } from "../src/tools/ops.ts";
15
+ import { dataRootPath, homeOverride } from "../src/paths.ts";
16
+ import { configFilePath, loadConfig, stripJsonComments } from "../src/config.ts";
17
+
18
+ console.log("== jsonc parsing + config invariants (P0) ==");
19
+ {
20
+ // The pre-fix regex stripper ate "src/**/secrets" as a block comment,
21
+ // silently disabling the user's redact rule.
22
+ const doc = JSON.parse(stripJsonComments(`{
23
+ // a line comment with a "quote
24
+ "redactPatterns": ["src/**/secrets", "a /* not a comment */ b"],
25
+ /* a real block comment */
26
+ "memoryDir": ".ai/open-memex",
27
+ }`)) as { redactPatterns: string[] };
28
+ ok("redact pattern with ** survives comment stripping",
29
+ doc.redactPatterns[0] === "src/**/secrets" && doc.redactPatterns[1] === "a /* not a comment */ b",
30
+ JSON.stringify(doc.redactPatterns));
31
+ }
32
+ {
33
+ // loadConfig must enforce the memoryDir invariant on hand-edited files.
34
+ const saved = process.env.OPEN_MEMEX_CONFIG;
35
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "om-cfg-"));
36
+ const file = path.join(dir, "c.jsonc");
37
+ fs.writeFileSync(file, JSON.stringify({ memoryDir: "../../shared" }));
38
+ const errs: unknown[] = [];
39
+ const origErr = console.error;
40
+ console.error = (...a: unknown[]) => errs.push(a);
41
+ try {
42
+ process.env.OPEN_MEMEX_CONFIG = file;
43
+ ok("escaping memoryDir falls back to default", loadConfig().memoryDir === ".ai/open-memex");
44
+ } finally {
45
+ console.error = origErr;
46
+ if (saved === undefined) delete process.env.OPEN_MEMEX_CONFIG;
47
+ else process.env.OPEN_MEMEX_CONFIG = saved;
48
+ fs.rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ }
13
51
 
14
52
  let fails = 0;
15
53
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -57,6 +95,28 @@ ok("id survives", parsed?.fm.id === a);
57
95
  ok("tags survive", JSON.stringify(parsed?.fm.tags) === '["hello","world"]');
58
96
  ok("body survives", parsed?.body.trim() === body);
59
97
 
98
+ console.log("== capture aliases (D61) ==");
99
+ ok("normalize trims/dedupes/caps at 4",
100
+ JSON.stringify(normalizeAliases([" Time Off ", "time off", "", "节假日", "vacation", "PTO", "leave"])) === '["Time Off","节假日","vacation","PTO"]');
101
+ ok("normalize of undefined is empty", JSON.stringify(normalizeAliases(undefined)) === "[]");
102
+ const fmA: Frontmatter = { ...fm, id: ulid(), aliases: ["time off", "节假日", "vacation days"] };
103
+ const parsedA = parse(serialize(fmA, "body text"));
104
+ ok("aliases survive round-trip",
105
+ JSON.stringify(parsedA?.fm.aliases) === '["time off","节假日","vacation days"]');
106
+ ok("no aliases -> field omitted from file",
107
+ !/^aliases:/m.test(serialize(fm, "x")));
108
+ ok("empty aliases array -> field omitted",
109
+ !/^aliases:/m.test(serialize({ ...fm, aliases: [] }, "x")));
110
+ ok("parse caps hand-edited aliases at 4",
111
+ (normalizeFrontmatter({ id: "x", aliases: ["1", "2", "3", "4", "5"] } as never).aliases ?? []).length === 4);
112
+ {
113
+ // D61 review: the tool schema must never reject a sloppy alias list —
114
+ // normalization decides, not validation (bad aliases are noise).
115
+ const shape = z.object(memoryAddArgs);
116
+ const sloppy = shape.safeParse({ content: "x", aliases: ["a", "", "b", "c", "d", "e"] });
117
+ ok("tool schema accepts >4 / blank aliases (normalize drops them)", sloppy.success);
118
+ }
119
+
60
120
  console.log("== redact ==");
61
121
  const r1 = redact("api key is <private>sk-supersecretkey</private> ok", DEFAULT_CONFIG.redactPatterns);
62
122
  ok("private stripped", !r1.content.includes("sk-supersecretkey"));
@@ -199,6 +259,105 @@ ok("no false positive", hits3.length === 0);
199
259
  const hits4 = detectKeywords("Please note that FTS5 needs prefix matches", DEFAULT_CONFIG);
200
260
  ok("please-note matched", hits4.length === 1);
201
261
 
262
+ console.log("== keywords: capture gaps (D65) ==");
263
+ // "帮我记…" is the most natural Chinese phrasing and used to match nothing.
264
+ // Routes personal, mirroring 替我记 (the 我-rule), unlike 帮我们记 (project).
265
+ const g1 = detectKeywords("帮我记住:这个项目用 pnpm", DEFAULT_CONFIG);
266
+ ok("帮我记住 matched", g1.length === 1, JSON.stringify(g1));
267
+ ok("帮我记住 → personal", g1[0]?.personal === true);
268
+ ok("帮我记住 body clean", g1[0]?.content === "这个项目用 pnpm", g1[0]?.content);
269
+ // A leading 请 defeated every 记住-led pattern ("请记住我…", "请记住:…").
270
+ const g2 = detectKeywords("请记住我不喜欢周五发布", DEFAULT_CONFIG);
271
+ ok("请记住我 matched personal", g2.length === 1 && g2[0]?.personal === true, JSON.stringify(g2));
272
+ const g3 = detectKeywords("请记住:周五不发布", DEFAULT_CONFIG);
273
+ ok("请记住 matched project", g3.length === 1 && g3[0]?.personal === false, JSON.stringify(g3));
274
+ // English: "remember to …" captured a stray leading "to" in the body.
275
+ const g4 = detectKeywords("Remember to run the tests before merging", DEFAULT_CONFIG);
276
+ ok("remember-to body has no stray 'to'", g4.length === 1 && g4[0]?.content === "run the tests before merging", g4[0]?.content);
277
+ // …but the optional "to" must not eat a word that merely starts with it.
278
+ const g5 = detectKeywords("remember Toronto is where the office is", DEFAULT_CONFIG);
279
+ ok("remember Toronto… body intact", g5.length === 1 && g5[0]?.content === "Toronto is where the office is", g5[0]?.content);
280
+ // 帮我们记 still routes project (the 们-rule survives the new 帮我记 pattern).
281
+ const g6 = detectKeywords("帮我们记住:周五不发布", DEFAULT_CONFIG);
282
+ ok("帮我们记住 → project", g6.length === 1 && g6[0]?.personal === false, JSON.stringify(g6));
283
+
284
+ console.log("== keywords: review follow-ups (D66) ==");
285
+ // The new 帮我记 pattern initially shipped without the (?:请)? its siblings
286
+ // got in D65 — 请帮我记住 fell through again.
287
+ const f1 = detectKeywords("请帮我记住:这个项目用 pnpm", DEFAULT_CONFIG);
288
+ ok("请帮我记住 → personal", f1.length === 1 && f1[0]?.personal === true && f1[0]?.content === "这个项目用 pnpm", JSON.stringify(f1));
289
+ // 帮我记录一下 used to capture mid-word garbage ("录一下:…"). The verb
290
+ // phrase now includes 记录/记录一下 and must end at a separator boundary.
291
+ const f2 = detectKeywords("帮我记录一下:周五不发布", DEFAULT_CONFIG);
292
+ ok("帮我记录一下 body clean", f2.length === 1 && f2[0]?.content === "周五不发布", JSON.stringify(f2));
293
+ const f3 = detectKeywords("替我记录一下:护照在抽屉里", DEFAULT_CONFIG);
294
+ ok("替我记录一下 body clean", f3.length === 1 && f3[0]?.content === "护照在抽屉里", JSON.stringify(f3));
295
+ // The boundary guard: 帮我记得… is not a capture form and must not fire.
296
+ const f4 = detectKeywords("帮我记得带伞", DEFAULT_CONFIG);
297
+ ok("帮我记得 → no capture", f4.length === 0, JSON.stringify(f4));
298
+ // English personal phrasing on par with "remember for me".
299
+ const f5 = detectKeywords("Help me remember: the wifi is on the fridge note", DEFAULT_CONFIG);
300
+ ok("help me remember → personal", f5.length === 1 && f5[0]?.personal === true, JSON.stringify(f5));
301
+
302
+ console.log("== keywords: separator-less verb phrases (D67) ==");
303
+ // D66's boundary guard was one notch too strict: it demanded a separator even
304
+ // after a COMPLETE verb phrase, so the most natural form - "帮我记一下这个配置",
305
+ // with the noun straight after 记一下 - stopped matching. The boundary now
306
+ // guards the bare verb only (记 must be followed by 住/录/一下 or a separator),
307
+ // so 记得/记着 still cannot produce mid-word garbage.
308
+ const v1 = detectKeywords("帮我记一下这个配置", DEFAULT_CONFIG);
309
+ ok("帮我记一下这个配置 (no separator)", v1.length === 1 && v1[0]?.personal === true && v1[0]?.content === "这个配置", JSON.stringify(g1));
310
+ const v2 = detectKeywords("帮我记住这个配置", DEFAULT_CONFIG);
311
+ ok("帮我记住这个配置 (no separator)", v2.length === 1 && v2[0]?.content === "这个配置", JSON.stringify(g2));
312
+ const v3 = detectKeywords("替我记一下我住在杭州", DEFAULT_CONFIG);
313
+ ok("替我记一下我住在杭州 (no separator)", v3.length === 1 && v3[0]?.personal === true && v3[0]?.content === "我住在杭州", JSON.stringify(g3));
314
+ const v4 = detectKeywords("帮我们记一下这个约定", DEFAULT_CONFIG);
315
+ ok("帮我们记一下这个约定 → project", v4.length === 1 && v4[0]?.personal === false && v4[0]?.content === "这个约定", JSON.stringify(g4));
316
+ const v5 = detectKeywords("替我记着这个流程", DEFAULT_CONFIG);
317
+ ok("替我记着… → no capture (verb continuation)", v5.length === 0, JSON.stringify(v5));
318
+ const v6 = detectKeywords("帮我们记得每周同步", DEFAULT_CONFIG);
319
+ ok("帮我们记得… → no capture (verb continuation)", v6.length === 0, JSON.stringify(v6));
320
+ const v7 = detectKeywords("帮我记:这个项目用 pnpm", DEFAULT_CONFIG);
321
+ ok("帮我记:… (bare verb + separator)", v7.length === 1 && v7[0]?.content === "这个项目用 pnpm", JSON.stringify(v7));
322
+
323
+ console.log("== keywords: narration is not a trigger (D67) ==");
324
+ // 记得… / remind me… stay out on purpose (D67): they are imperatives aimed at
325
+ // the agent, and a false trigger writes memory nobody reviewed. The two forms
326
+ // already in the list follow the same rule - an explicit separator (or "that")
327
+ // marks a statement to the store.
328
+ const n1 = detectKeywords("别忘了带伞", DEFAULT_CONFIG);
329
+ ok("别忘了带伞 (narration) → no capture", n1.length === 0, JSON.stringify(n1));
330
+ const n2 = detectKeywords("别忘了:周五不发布", DEFAULT_CONFIG);
331
+ ok("别忘了:… (instruction) → project", n2.length === 1 && n2[0]?.personal === false && n2[0]?.content === "周五不发布", JSON.stringify(n2));
332
+ const n3 = detectKeywords("别忘了,每次发布前更新 changelog", DEFAULT_CONFIG);
333
+ ok("别忘了,… (ascii comma) → project", n3.length === 1 && n3[0]?.content === "每次发布前更新 changelog", JSON.stringify(n3));
334
+ const n4 = detectKeywords("Don't forget the wifi password", DEFAULT_CONFIG);
335
+ ok("don't forget <bare> (narration) → no capture", n4.length === 0, JSON.stringify(n4));
336
+ const n5 = detectKeywords("Don't forget that the wifi password is on the fridge", DEFAULT_CONFIG);
337
+ ok("don't forget that … → project", n5.length === 1 && n5[0]?.content === "the wifi password is on the fridge", JSON.stringify(n5));
338
+ const n6 = detectKeywords("don't forget: the wifi password", DEFAULT_CONFIG);
339
+ ok("don't forget: … → project", n6.length === 1 && n6[0]?.content === "the wifi password", JSON.stringify(n6));
340
+ // The 'note' family is unaffected - it was never the ambiguous form.
341
+ const n7 = detectKeywords("note the API is v2", DEFAULT_CONFIG);
342
+ ok("note <bare> still captures", n7.length === 1 && n7[0]?.content === "the API is v2", JSON.stringify(n7));
343
+ const n8 = detectKeywords("记得每次都要跑迁移脚本", DEFAULT_CONFIG);
344
+ ok("记得… → no capture", n8.length === 0, JSON.stringify(n8));
345
+ const n9 = detectKeywords("remind me to lock the door", DEFAULT_CONFIG);
346
+ ok("remind me to… → no capture", n9.length === 0, JSON.stringify(n9));
347
+
348
+ console.log("== keywords: the length floor reports instead of swallowing (D67) ==");
349
+ // D67 keeps the >=3-char floor (a trigger that yields 这个 is a match on the
350
+ // trigger, not a memory) but a rejection is no longer silent.
351
+ const d1 = scanKeywords("记住:这个", DEFAULT_CONFIG);
352
+ ok("短 body 不捕获", d1.hits.length === 0, JSON.stringify(d1));
353
+ ok("短 body 有说明 (too-short)", d1.dropped.length === 1 && d1.dropped[0]?.reason === "too-short" && d1.dropped[0]?.content === "这个", JSON.stringify(d1.dropped));
354
+ const d2 = scanKeywords("记住:这个配置", DEFAULT_CONFIG);
355
+ ok(">=3 字的照常捕获", d2.hits.length === 1 && d2.dropped.length === 0, JSON.stringify(d2));
356
+ // A rejected personal match must not leak into the project scope: "记住我:OK"
357
+ // must not be re-captured by the generic 记住 as "我:OK".
358
+ const d3 = scanKeywords("记住我:OK", DEFAULT_CONFIG);
359
+ ok("短 personal 不落到 project", d3.hits.length === 0 && d3.dropped.some((x) => x.reason === "too-short" && x.content === "OK"), JSON.stringify(d3));
360
+
202
361
  console.log("== scope ==");
203
362
  const s = resolveProjectScope(process.cwd());
204
363
  ok("kind=project", s.kind === "project");
@@ -220,6 +379,17 @@ ok("cwd scope deterministic", cwdOnly.key === cwdOnly2.key);
220
379
  // (This repo does have an origin after `git push`.)
221
380
  console.log(" cwd.key =", cwdOnly.key);
222
381
 
382
+ console.log("== scope (pickScopeRoot, D71) ==");
383
+ // OpenCode v1 reports the filesystem root as `worktree` for non-git folders;
384
+ // seeding a scope on it merges every non-git folder on the drive into one
385
+ // shared `project__workspace__…` bucket. A real worktree still wins.
386
+ const fsRoot = path.parse(process.cwd()).root;
387
+ ok("real worktree wins", pickScopeRoot(process.cwd(), "/elsewhere/opened") === process.cwd());
388
+ ok("fs-root worktree yields to directory", pickScopeRoot(fsRoot, process.cwd()) === process.cwd());
389
+ ok("missing worktree uses directory", pickScopeRoot(null, process.cwd()) === process.cwd());
390
+ ok("nothing but fs root: last resort", pickScopeRoot(fsRoot, null) === fsRoot);
391
+ ok("no candidates falls back to cwd", pickScopeRoot(null, null) === process.cwd());
392
+
223
393
  console.log("== cjk ==");
224
394
  ok("detects han", hasCjk("中文记忆"));
225
395
  ok("detects mixed", hasCjk("open中文"));
@@ -399,6 +569,38 @@ ok("failed backup leaves legacy dir for retry", fs.existsSync(legacyRoot2));
399
569
  delete process.env.MY_O_MEMORY_HOME;
400
570
  fs.rmSync(tmpHome, { recursive: true, force: true });
401
571
 
572
+ console.log("== env var names (D62) ==");
573
+ {
574
+ const saved = {
575
+ n: process.env.OPEN_MEMEX_HOME,
576
+ l: process.env.MY_O_MEMORY_HOME,
577
+ nc: process.env.OPEN_MEMEX_CONFIG,
578
+ lc: process.env.MY_O_MEMORY_CONFIG,
579
+ };
580
+ const restore = () => {
581
+ const set = (k: string, v: string | undefined) => (v === undefined ? delete process.env[k] : (process.env[k] = v));
582
+ set("OPEN_MEMEX_HOME", saved.n);
583
+ set("MY_O_MEMORY_HOME", saved.l);
584
+ set("OPEN_MEMEX_CONFIG", saved.nc);
585
+ set("MY_O_MEMORY_CONFIG", saved.lc);
586
+ };
587
+ try {
588
+ delete process.env.OPEN_MEMEX_HOME;
589
+ delete process.env.MY_O_MEMORY_HOME;
590
+ ok("no override -> homeOverride null", homeOverride() === null);
591
+ process.env.MY_O_MEMORY_HOME = "/tmp/legacy-home";
592
+ ok("legacy fallback honored", dataRootPath().endsWith("legacy-home") && homeOverride()?.via === "MY_O_MEMORY_HOME");
593
+ process.env.OPEN_MEMEX_HOME = "/tmp/new-home";
594
+ ok("new name wins over legacy", dataRootPath().endsWith("new-home") && homeOverride()?.via === "OPEN_MEMEX_HOME");
595
+ process.env.MY_O_MEMORY_CONFIG = "/tmp/legacy.jsonc";
596
+ ok("config legacy fallback", configFilePath().endsWith("legacy.jsonc"));
597
+ process.env.OPEN_MEMEX_CONFIG = "/tmp/new.jsonc";
598
+ ok("config new name wins", configFilePath().endsWith("new.jsonc"));
599
+ } finally {
600
+ restore();
601
+ }
602
+ }
603
+
402
604
  console.log("== lifecycle pure: contentHash / similarity ==");
403
605
  ok("hash deterministic + whitespace-insensitive", contentHash("hello world\n") === contentHash("hello world"));
404
606
  ok("hash differs on content", contentHash("hello world") !== contentHash("hello mars"));
@@ -561,5 +763,27 @@ ok("opencode per-project keeps skill fallback", shouldInstallSkill("opencode", f
561
763
  ok("vscode keeps skill", shouldInstallSkill("vscode", true) === true);
562
764
  ok("cursor keeps skill", shouldInstallSkill("cursor", false) === true);
563
765
 
766
+ // D68: display helpers — age tokens, structured lines, truncation notes.
767
+ {
768
+ const { ageToken, formatInventoryLine, truncationNote } = await import("../src/retrieve/display.ts");
769
+ const now = Date.UTC(2026, 9, 3, 12, 0, 0);
770
+ ok("ageToken now", ageToken(now - 30_000, now) === "now");
771
+ ok("ageToken days", ageToken(now - 3 * 86_400_000, now) === "3d");
772
+ ok("ageToken old falls back to a date", /^\d{4}-\d{2}-\d{2}$/.test(ageToken(now - 90 * 86_400_000, now)));
773
+ const line = formatInventoryLine(2, {
774
+ id: "01K6AB", type: "fact", snippet: "likes concise diffs", source: "user",
775
+ created_at: now - 3 * 86_400_000, updated_at: now - 3 * 86_400_000,
776
+ status: "active", review_state: "draft", scope_key: "personal",
777
+ }, "personal", now);
778
+ ok("inventory line is structured", line === "3. [fact] id=01K6AB created=3d source=user — likes concise diffs", line);
779
+ const proj = formatInventoryLine(0, {
780
+ id: "01K6CD", type: "decision", snippet: "ship on fridays", source: "keyword",
781
+ created_at: now - 2 * 86_400_000, updated_at: now - 3_600_000,
782
+ status: "active", review_state: "draft", scope_key: "project__x",
783
+ }, "personal", now);
784
+ ok("inventory line shows project state + late update", proj.includes("[draft]") && proj.includes("updated=1h"), proj);
785
+ ok("truncation note speaks only when cut", truncationNote(20, 54)?.includes("34 more") === true && truncationNote(20, 20) === null);
786
+ }
787
+
564
788
  console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
565
789
  process.exit(fails === 0 ? 0 : 1);