@modusensus/dsh-mneme 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +48 -19
  2. package/docs/CONFIGURATION.md +43 -4
  3. package/docs/DIRECTORIES.md +114 -0
  4. package/docs/DISTILL.md +64 -0
  5. package/docs/DSH-GO.md +97 -0
  6. package/docs/ENTITIES.md +5 -3
  7. package/docs/FORGET.md +77 -0
  8. package/docs/KB.md +110 -0
  9. package/docs/SLEEP.md +28 -3
  10. package/docs/STORAGE.md +23 -0
  11. package/lib/api-standalone.js +4 -1
  12. package/lib/api.js +581 -29
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3537 -1205
  16. package/lib/config.js +139 -1
  17. package/lib/continuity.js +7 -6
  18. package/lib/diary/auto.js +190 -0
  19. package/lib/diary/compose.js +440 -0
  20. package/lib/diary/dir.js +59 -0
  21. package/lib/diary/export.js +97 -0
  22. package/lib/diary/index.js +26 -0
  23. package/lib/diary/library.js +189 -0
  24. package/lib/diary/qq-inbox.js +142 -0
  25. package/lib/diary/rollover.js +61 -0
  26. package/lib/diary/session-provider.js +122 -0
  27. package/lib/dream/decisions.js +56 -5
  28. package/lib/dream/sleep.js +616 -15
  29. package/lib/dream.js +45 -13
  30. package/lib/dsh-go/host.js +162 -0
  31. package/lib/dsh-go/panel.js +2091 -0
  32. package/lib/dsh-go/text.js +342 -0
  33. package/lib/dsh-go/views/common.js +68 -0
  34. package/lib/dsh-go/views/entity.js +48 -0
  35. package/lib/dsh-go/views/memory.js +198 -0
  36. package/lib/dsh-go/views/root.js +30 -0
  37. package/lib/dsh-go/views/settings.js +398 -0
  38. package/lib/dsh-go/views/status.js +51 -0
  39. package/lib/embedding.js +42 -8
  40. package/lib/entities/extractor.js +36 -7
  41. package/lib/hot-memory.js +8 -9
  42. package/lib/index.js +92 -2
  43. package/lib/inject.js +189 -18
  44. package/lib/kb/index.js +309 -0
  45. package/lib/kb/routes.js +220 -0
  46. package/lib/kb/tools.js +148 -0
  47. package/lib/lang.js +87 -4
  48. package/lib/peak-hours.js +57 -2
  49. package/lib/semantic.js +347 -60
  50. package/lib/service.js +193 -66
  51. package/lib/settings.js +112 -14
  52. package/lib/store.js +347 -21
  53. package/lib/summarize.js +38 -11
  54. package/lib/tools.js +35 -4
  55. package/lib/user-settings.js +128 -32
  56. package/lib/vector-index.js +76 -6
  57. package/lib/wiki/cli-hint.js +90 -0
  58. package/lib/wiki/queries.js +116 -2
  59. package/lib/wiki/t1-jobs.js +232 -0
  60. package/lib/wiki/t1.js +7 -1
  61. package/package.json +25 -1
  62. package/src/api-standalone.js +4 -1
  63. package/src/api.js +581 -29
  64. package/src/attachment-text.js +122 -0
  65. package/src/choro-command.js +9 -2
  66. package/src/config.js +139 -1
  67. package/src/continuity.js +7 -6
  68. package/src/diary/auto.js +190 -0
  69. package/src/diary/compose.js +440 -0
  70. package/src/diary/dir.js +59 -0
  71. package/src/diary/export.js +97 -0
  72. package/src/diary/index.js +26 -0
  73. package/src/diary/library.js +189 -0
  74. package/src/diary/qq-inbox.js +142 -0
  75. package/src/diary/rollover.js +61 -0
  76. package/src/diary/session-provider.js +122 -0
  77. package/src/dream/decisions.js +56 -5
  78. package/src/dream/sleep.js +616 -15
  79. package/src/dream.js +45 -13
  80. package/src/dsh-go/host.js +162 -0
  81. package/src/dsh-go/panel.js +2091 -0
  82. package/src/dsh-go/text.js +342 -0
  83. package/src/dsh-go/views/common.js +68 -0
  84. package/src/dsh-go/views/entity.js +48 -0
  85. package/src/dsh-go/views/memory.js +198 -0
  86. package/src/dsh-go/views/root.js +30 -0
  87. package/src/dsh-go/views/settings.js +398 -0
  88. package/src/dsh-go/views/status.js +51 -0
  89. package/src/embedding.js +42 -8
  90. package/src/entities/extractor.js +36 -7
  91. package/src/hot-memory.js +8 -9
  92. package/src/index.js +92 -2
  93. package/src/inject.js +189 -18
  94. package/src/kb/index.js +309 -0
  95. package/src/kb/routes.js +220 -0
  96. package/src/kb/tools.js +148 -0
  97. package/src/lang.js +87 -4
  98. package/src/peak-hours.js +57 -2
  99. package/src/semantic.js +347 -60
  100. package/src/service.js +193 -66
  101. package/src/settings.js +112 -14
  102. package/src/store.js +347 -21
  103. package/src/summarize.js +38 -11
  104. package/src/tools.js +35 -4
  105. package/src/user-settings.js +128 -32
  106. package/src/vector-index.js +76 -6
  107. package/src/wiki/cli-hint.js +90 -0
  108. package/src/wiki/queries.js +116 -2
  109. package/src/wiki/t1-jobs.js +232 -0
  110. package/src/wiki/t1.js +7 -1
  111. package/test/api-routes-gaps.test.js +63 -0
  112. package/test/api.test.js +375 -4
  113. package/test/attachment-text.test.js +140 -0
  114. package/test/bm25-corpus-cache.test.js +145 -0
  115. package/test/capacity-warning.test.js +262 -0
  116. package/test/client.test.js +779 -154
  117. package/test/conflict-freeze.test.js +45 -1
  118. package/test/continuity.test.js +8 -1
  119. package/test/diary-library.test.js +187 -0
  120. package/test/diary.test.js +576 -0
  121. package/test/dream-maxtokens-fallback.test.js +3 -3
  122. package/test/dream.test.js +62 -1
  123. package/test/dsh-go-host.test.js +134 -0
  124. package/test/dsh-go-panel-actions.test.js +854 -0
  125. package/test/dsh-go-panel.test.js +251 -0
  126. package/test/embedding-ready.test.js +33 -3
  127. package/test/entities.test.js +73 -0
  128. package/test/forget-corrections.test.js +70 -0
  129. package/test/image-memory.test.js +171 -0
  130. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  131. package/test/kb-api-wiring.test.js +128 -0
  132. package/test/kb-collections.test.js +272 -0
  133. package/test/kb-routes.test.js +379 -0
  134. package/test/mint-source.test.js +26 -0
  135. package/test/peak-hours-observability.test.js +70 -0
  136. package/test/persona-api.test.js +3 -3
  137. package/test/persona-by-session.test.js +568 -0
  138. package/test/persona-settings.test.js +7 -7
  139. package/test/reflection-update-gate.test.js +129 -0
  140. package/test/reindex-backfill.test.js +334 -0
  141. package/test/reindex-scheduler.test.js +369 -0
  142. package/test/scope-candidates.test.js +249 -0
  143. package/test/sleep-action-set.test.js +14 -0
  144. package/test/sleep-heat.test.js +49 -0
  145. package/test/sleep.test.js +598 -0
  146. package/test/standalone-api.test.js +24 -0
  147. package/test/store.test.js +20 -0
  148. package/test/summarize.test.js +26 -0
  149. package/test/user-settings-segments.test.js +528 -0
  150. package/test/vector-index.test.js +120 -6
  151. package/test/wiki-api.test.js +355 -0
  152. package/test/wiki-cli-hint.test.js +118 -0
  153. package/test/wiki-queries.test.js +7 -0
  154. package/test/wiki-t1-jobs.test.js +125 -0
@@ -82,6 +82,50 @@ test("store: 不同对不去重", () => {
82
82
  store.close();
83
83
  });
84
84
 
85
+ // Issue #603 第 2 层:排序键从 created_at 换成相似度。原键在同一次 sleep 轮次里
86
+ // 全同(实测 60 条落在 60ms 内),队列在面板上等于随机——而「先看哪对」的唯一
87
+ // 客观信号就是相似度。这组用例锁三件事:相似度真的落库、排序真的按它、以及
88
+ // NULL(没记过)排在最后而不是被当成 0。
89
+ test("#603 store: similarity is persisted and read back", () => {
90
+ const store = openStore();
91
+ const row = store.saveConflictPending({ memory_a: "a", memory_b: "b", reason: "r", similarity: 0.87 });
92
+ assert.equal(row.similarity, 0.87, "fresh row carries the similarity it was parked with");
93
+ const [read] = store.listConflictPending();
94
+ assert.equal(read.similarity, 0.87, "listConflictPending reads the column back");
95
+ store.close();
96
+ });
97
+
98
+ test("#603 store: the queue is ordered by similarity, newest only as a tiebreak", () => {
99
+ const store = openStore();
100
+ // 写入顺序与相似度顺序相反:如果仍按 created_at 排,结果会是 low → high。
101
+ const low = store.saveConflictPending({ memory_a: "a", memory_b: "b", reason: "low", similarity: 0.61 });
102
+ const high = store.saveConflictPending({ memory_a: "c", memory_b: "d", reason: "high", similarity: 0.94 });
103
+ const mid = store.saveConflictPending({ memory_a: "e", memory_b: "f", reason: "mid", similarity: 0.78 });
104
+ const order = store.listConflictPending().map((r) => r.id);
105
+ assert.deepEqual(order, [high.id, mid.id, low.id], "most similar first, regardless of insertion order");
106
+ store.close();
107
+ });
108
+
109
+ test("#603 store: rows without a similarity sort last, they are not 'zero similar'", () => {
110
+ const store = openStore();
111
+ // 存量行(加列之前入队的)与 LLM 裁决路径(只有理由文本)都没有相似度。
112
+ const unknown = store.saveConflictPending({ memory_a: "a", memory_b: "b", reason: "no number" });
113
+ const known = store.saveConflictPending({ memory_a: "c", memory_b: "d", reason: "r", similarity: 0.7 });
114
+ assert.equal(unknown.similarity, undefined, "an unmeasured row must not report a number");
115
+ const order = store.listConflictPending().map((r) => r.id);
116
+ assert.deepEqual(order, [known.id, unknown.id], "unknown similarity goes after every measured row");
117
+ store.close();
118
+ });
119
+
120
+ test("#603 store: an out-of-range or non-numeric similarity is stored as unknown", () => {
121
+ const store = openStore();
122
+ for (const bad of [1.5, -0.2, Number.NaN, "0.9", null]) {
123
+ const row = store.saveConflictPending({ memory_a: `a${String(bad)}`, memory_b: `b${String(bad)}`, reason: "r", similarity: bad });
124
+ assert.equal(row.similarity, undefined, `similarity ${String(bad)} must not be stored as a number`);
125
+ }
126
+ store.close();
127
+ });
128
+
85
129
  test("store: resolveConflictPending 标 resolved + winner,默认列表不再返回", () => {
86
130
  const store = openStore();
87
131
  const pending = store.saveConflictPending({ memory_a: "a", memory_b: "b", reason: "r" });
@@ -294,7 +338,7 @@ test("dream: freeze=true 同一对跨 run 去重 —— 只保留一条 pending"
294
338
  const ctx2 = freezeCtx({ conflicts: [{ action: "conflict", winner: w.id, loser: l.id, reason: "日期矛盾" }] });
295
339
  const r2 = await dream.runDream(ctx2, service, { ...BASE_CONFIG, conflictFreezeEnabled: true });
296
340
  assert.equal(r1.frozen, 1);
297
- assert.equal(r2.frozen, 1, "每次 run 都检出并上报该对");
341
+ assert.equal(r2.frozen, 0, "第二次检出的是已在队列里的对,不算新停车(#603 第 1 层)");
298
342
  assert.equal(store.countConflictPending(), 1, "但队列只有一条 pending");
299
343
  store.close();
300
344
  });
@@ -267,8 +267,15 @@ test("#249 N3:队列满的丢弃必须留痕(§8 触发率口径)", async
267
267
  test("#249 N3:门控不调模型(静态锁)与挂载点的父开关在前", () => {
268
268
  // §11「门控不调 LLM」:本模块只 import 消息工厂。静态锁而不是运行时断言——真去调模型的
269
269
  // 回归不会从返回值露出来,只会让这一步变慢、变贵。
270
+ //
271
+ // #611 起多了 src/attachment-text.js:它同样**不调模型**(纯字符串函数、自己不
272
+ // import 任何东西),只是把图片块读成占位文本。所以锁的口径从「恰好一行 import」
273
+ // 改成「import 清单恰是这两项」——新增一行仍然转红,仍然要人来看一眼它是不是纯的。
270
274
  const src = readFileSync(new URL("../src/continuity.js", import.meta.url), "utf8");
271
- assert.deepEqual(src.match(/^import .*$/gm), ['import { createUserMessage } from "@deepseek-ai/dsh-llm";']);
275
+ assert.deepEqual(src.match(/^import .*$/gm), [
276
+ 'import { createUserMessage } from "@deepseek-ai/dsh-llm";',
277
+ 'import { contentToText } from "./attachment-text.js";'
278
+ ]);
272
279
  // 挂载点(`src/index.js`)是本功能唯一的生产入口:父开关必须在前、子项在后,否则「默认关」
273
280
  // 只剩 Config 里的默认值撑着,接线漏一次没人发现。
274
281
  const index = readFileSync(new URL("../src/index.js", import.meta.url), "utf8");
@@ -0,0 +1,187 @@
1
+ // 左侧「ta 的日记」面板读的是**跨桶**清单——它长在 `main` 座位上,宿主不给它
2
+ // sessionId,所以算不出「当前会话的项目根」。这套用例锁的就是那条路径:
3
+ // 谁被算成一个桶、谁被跳过、谁的名字从 frontmatter 来而不是从目录名猜。
4
+ //
5
+ // 反面教材是 #613 的双桶:同一个项目因为历史原因落了两个目录键
6
+ // (`QQ对话-4f061790` 与 `QQ---4f061790`),面板若按目录名认人就会把同一本日记
7
+ // 挂成两个人。所以这里有一条用例专门断言「名字取自 frontmatter 的 persona」。
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { listDiaryLibrary, readLibraryEntry, isBucketName } from "../src/diary/index.js";
14
+
15
+ /** 造一个临时 memoryDir,里面的 diaries/ 按给定的桶与日期铺开。 */
16
+ function fixture(buckets) {
17
+ const memoryDir = mkdtempSync(join(tmpdir(), "mneme-diary-lib-"));
18
+ const root = join(memoryDir, "diaries");
19
+ mkdirSync(root, { recursive: true });
20
+ for (const [bucket, files] of Object.entries(buckets)) {
21
+ const dir = join(root, bucket);
22
+ mkdirSync(dir, { recursive: true });
23
+ for (const [date, text] of Object.entries(files)) {
24
+ writeFileSync(join(dir, `${date}.md`), text, "utf8");
25
+ }
26
+ }
27
+ return { memoryDir, root };
28
+ }
29
+
30
+ function entry({ persona = "", project = "p", body = "正文" } = {}) {
31
+ const lines = ["---", "date: 2026-11-08"];
32
+ if (project) lines.push(`project: ${project}`);
33
+ if (persona) lines.push(`persona: ${persona}`);
34
+ lines.push("---", "", body, "");
35
+ return lines.join("\n");
36
+ }
37
+
38
+ test("按桶列出日记,名字取自 frontmatter 的 persona 而不是目录名", (t) => {
39
+ // 两个桶是同一个项目(#613 的双桶现场):目录名一个保留中文、一个被洗成 -。
40
+ // 面板要显示的是「amarre」与「ptc」,不是那两个目录键。
41
+ const { memoryDir } = fixture({
42
+ "QQ对话-4f061790": { "2026-11-07": entry({ persona: "settings (amarre)", project: "QQ对话-4f061790" }) },
43
+ "QQ---4f061790": { "2026-11-08": entry({ persona: "default (ptc)", project: "QQ---4f061790" }) }
44
+ });
45
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
46
+
47
+ const { buckets } = listDiaryLibrary({ memoryDir });
48
+ assert.equal(buckets.length, 2);
49
+ const byKey = Object.fromEntries(buckets.map((b) => [b.key, b]));
50
+ assert.equal(byKey["QQ对话-4f061790"].persona, "settings (amarre)");
51
+ assert.equal(byKey["QQ---4f061790"].persona, "default (ptc)");
52
+ // 桶键原样保留(面板要用它拼路由),没被改写。
53
+ assert.ok(byKey["QQ---4f061790"]);
54
+ });
55
+
56
+ test("新的在前;每桶带 latest / count / entries", (t) => {
57
+ const { memoryDir } = fixture({
58
+ a: {
59
+ "2026-11-05": entry({ persona: "default (x)" }),
60
+ "2026-11-08": entry({ persona: "default (x)" }),
61
+ "2026-11-06": entry({ persona: "default (x)" })
62
+ },
63
+ b: { "2026-11-01": entry({ persona: "default (y)" }) }
64
+ });
65
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
66
+
67
+ const { buckets } = listDiaryLibrary({ memoryDir });
68
+ assert.deepEqual(buckets.map((x) => x.key), ["a", "b"], "按 latest 倒序");
69
+ assert.equal(buckets[0].latest, "2026-11-08");
70
+ assert.equal(buckets[0].count, 3);
71
+ assert.deepEqual(buckets[0].entries.map((e) => e.date), ["2026-11-08", "2026-11-06", "2026-11-05"]);
72
+ assert.equal(buckets[1].latest, "2026-11-01");
73
+ });
74
+
75
+ test("空桶、回收站、非日记文件都不算一本", (t) => {
76
+ const { memoryDir, root } = fixture({
77
+ real: { "2026-11-08": entry({ persona: "default (x)" }) }
78
+ });
79
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
80
+ // 只有 runs.jsonl 的桶:文件被删光之后的样子。
81
+ mkdirSync(join(root, "hollow"), { recursive: true });
82
+ writeFileSync(join(root, "hollow", "runs.jsonl"), "{}\n", "utf8");
83
+ // 回收站:以点开头。
84
+ mkdirSync(join(root, ".deleted"), { recursive: true });
85
+ writeFileSync(join(root, ".deleted", "2026-11-08.md"), entry(), "utf8");
86
+ // 根目录下的散落文件不是桶。
87
+ writeFileSync(join(root, "stray.md"), "x", "utf8");
88
+
89
+ const { buckets } = listDiaryLibrary({ memoryDir });
90
+ assert.deepEqual(buckets.map((b) => b.key), ["real"],
91
+ "只有 runs.jsonl 的桶、.deleted 回收站、根下的散文件都不该出现在导航栏里");
92
+ });
93
+
94
+ test("读不到 diaries 根目录时返回空清单,不抛", () => {
95
+ const { buckets } = listDiaryLibrary({ memoryDir: join(tmpdir(), "mneme-no-such-dir-xyz") });
96
+ assert.deepEqual(buckets, []);
97
+ });
98
+
99
+ test("第一篇没 persona 时往下找:同一桶里先后住过两个人才是 #613 要拆的形状", (t) => {
100
+ const { memoryDir } = fixture({
101
+ shared: {
102
+ "2026-11-06": entry({ persona: "" }), // 手工写的,没有 frontmatter persona
103
+ "2026-11-07": entry({ persona: "settings (amarre)" })
104
+ }
105
+ });
106
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
107
+
108
+ const { buckets } = listDiaryLibrary({ memoryDir });
109
+ assert.equal(buckets[0].persona, "settings (amarre)", "跳过没 persona 的那篇,取第一篇带它的");
110
+ });
111
+
112
+ test("读一篇:桶名与日期都是路径成分,越界一律读不到", (t) => {
113
+ const { memoryDir, root } = fixture({ ok: { "2026-11-08": entry({ body: "今天很好" }) } });
114
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
115
+ // **诱饵**:root 外面放一个真能被读到的文件。只断言「返回 null」是不够的
116
+ // ——桶名那一层挡不住时,`join` 会真的读到它;没有这个诱饵,校验坏了用例也绿。
117
+ const leak = join(memoryDir, "leak");
118
+ mkdirSync(leak, { recursive: true });
119
+ writeFileSync(join(leak, "2026-11-08.md"), "根外面的东西", "utf8");
120
+ mkdirSync(join(root, "ok"), { recursive: true });
121
+
122
+ assert.match(readLibraryEntry({ memoryDir, bucket: "ok", date: "2026-11-08" }), /今天很好/);
123
+ // 这三种都是「不能让它们拼进路径」的形状;命中诱饵就说明校验漏了。
124
+ assert.equal(readLibraryEntry({ memoryDir, bucket: "..", date: "2026-11-08" }), null);
125
+ assert.equal(readLibraryEntry({ memoryDir, bucket: "../leak", date: "2026-11-08" }), null);
126
+ assert.equal(readLibraryEntry({ memoryDir, bucket: "ok/../../leak", date: "2026-11-08" }), null);
127
+ // 合法但不存在 → null(面板据此显示「读不出」而不是空框)。
128
+ assert.equal(readLibraryEntry({ memoryDir, bucket: "ok", date: "2026-01-01" }), null);
129
+ });
130
+
131
+ test("桶名判据挡的是路径语义,不是字符集", () => {
132
+ // 挡:分隔符、上跳、控制字符、首尾空白、`.` 与 `..`。
133
+ for (const bad of ["..", "../x", "a/b", "a\\b", "", ".", " a", "a ", "a\u0000b"]) {
134
+ assert.equal(isBucketName(bad), false, `不该接受桶名 ${JSON.stringify(bad)}`);
135
+ }
136
+ // 不挡:合法的单层目录名。**中文不在这道判据里**——历史上真有一个保留中文的
137
+ // 桶(`QQ对话-4f061790`),按字符集白名单会把它列出来却读不到。
138
+ for (const ok of ["QQ---4f061790", "amarre-c836db6a", "QQ对话-4f061790", "a b"]) {
139
+ assert.equal(isBucketName(ok), true, `该接受桶名 ${JSON.stringify(ok)}`);
140
+ }
141
+ });
142
+
143
+ test("历史桶名带中文也要能读(#613 的双桶就有一个是那样)", (t) => {
144
+ // 上一版 `projectKey` 只留 ASCII,但现场确实存在一个保留中文的桶
145
+ // (用户那次让另一个会话手工写的)。清单与读取必须用同一道判据,否则就是
146
+ // 「列得出来、点进去读不到」——用户看到的是点了没反应。
147
+ const { memoryDir } = fixture({ "QQ对话-4f061790": { "2026-11-07": entry({ body: "旧的那篇" }) } });
148
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
149
+
150
+ const { buckets } = listDiaryLibrary({ memoryDir });
151
+ assert.deepEqual(buckets.map((b) => b.key), ["QQ对话-4f061790"],
152
+ "带中文的桶名必须出现在清单里——它是真实存在的历史桶,跳过它等于把用户写过的日记藏起来");
153
+ assert.match(readLibraryEntry({ memoryDir, bucket: "QQ对话-4f061790", date: "2026-11-07" }), /旧的那篇/,
154
+ "清单里列得出来就必须读得到,不能只做一半");
155
+ });
156
+
157
+ test("清单带上桶的 projectRoot / agentId,面板靠它决定能不能写", (t) => {
158
+ // 桶名(目录名 + 8 位哈希)反推不回绝对路径,而「写今天」必须给 project。
159
+ // 所以 listDiaryLibrary 从 diaries/state.json 里取账本;账本里没有就留空,
160
+ // 面板据此禁用按钮——绝不猜路径,猜错就把日记写到别的项目去了。
161
+ const { memoryDir, root } = fixture({
162
+ "amarre-c836db6a": { "2026-11-08": entry({ persona: "default (ptc)" }) },
163
+ "lone-11111111": { "2026-11-08": entry({ persona: "default (ptc)" }) }
164
+ });
165
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
166
+ writeFileSync(join(root, "state.json"), JSON.stringify({
167
+ "amarre-c836db6a": { projectRoot: "D:\\amarre", lastDate: "2026-11-08", chatId: "c1", agentId: "ptc" }
168
+ }), "utf8");
169
+
170
+ const byKey = Object.fromEntries(listDiaryLibrary({ memoryDir }).buckets.map((b) => [b.key, b]));
171
+ assert.equal(byKey["amarre-c836db6a"].projectRoot, "D:\\amarre", "有账本的桶要带出绝对路径");
172
+ assert.equal(byKey["amarre-c836db6a"].agentId, "ptc");
173
+ assert.equal(byKey["lone-11111111"].projectRoot, "", "没账本就留空,绝不猜");
174
+ assert.equal(byKey["lone-11111111"].agentId, "");
175
+ });
176
+
177
+ test("state.json 坏掉时清单照常出,不因账本读不出就整页空白", (t) => {
178
+ // 账本只是附加信息。它坏了最坏是「不知道该写哪」,不该连日记都看不到。
179
+ const { memoryDir, root } = fixture({ "amarre-c836db6a": { "2026-11-08": entry({ persona: "default (ptc)" }) } });
180
+ t.after(() => rmSync(memoryDir, { recursive: true, force: true }));
181
+ writeFileSync(join(root, "state.json"), "{ not json", "utf8");
182
+
183
+ const { buckets } = listDiaryLibrary({ memoryDir });
184
+ assert.equal(buckets.length, 1);
185
+ assert.equal(buckets[0].projectRoot, "");
186
+ assert.equal(buckets[0].persona, "default (ptc)");
187
+ });