@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.1

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 (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
@@ -0,0 +1,242 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+ import { Config } from "../src/config.js";
5
+ import { triagePair, recordLedgerTriage, LEDGER_TIERS } from "../src/dream/triage.js";
6
+
7
+ // Issue #641(beta 第一阶段):冲突分诊账本。锁三件事,防的回归是:
8
+ // ① 分诊判据漂移:四档的时间/相似度边界被「顺手调一下」而没有任何记录——
9
+ // shadow 期分布与真实库 46/20/5/27 的可比性全靠判据稳定,边界一动,
10
+ // shadow 周就白跑(每条边界用例的注释都写明它锚的真实库依据);
11
+ // ② 账本不幂等/首见时刻被刷新:同一对每轮重写 first_seen_at,挂账时长与
12
+ // 同召率统计全部失真;
13
+ // ③ 记账反噬相位或误入人工队列:shadow 档的写入是纯测量,抛错必须只计
14
+ // failed、绝不打断巩固,也绝不涨 countConflictPending 的面板徽标。
15
+
16
+ const mem = (id, over = {}) => ({
17
+ id,
18
+ title: `t-${id}`,
19
+ content: `c-${id}`,
20
+ created_at: "2026-11-01T00:00:00.000Z",
21
+ agent_scope: undefined,
22
+ workspace_scope: undefined,
23
+ ...over
24
+ });
25
+
26
+ const HOUR = 3_600_000;
27
+ const DAY = 86_400_000;
28
+
29
+ // ---- triagePair:四档判据与边界 ----
30
+
31
+ test("issue#641 跨作用域对判 cross-scope-dup(任一维键不同即算,#170 口径)", () => {
32
+ const r1 = triagePair(mem("a", { agent_scope: "x" }), mem("b", { agent_scope: "y" }), 0.9);
33
+ assert.equal(r1.tier, "cross-scope-dup");
34
+ assert.equal(r1.signals.crossScope, true);
35
+ // workspace 单维不同也算——与 sleep.js isCrossScopePair 同一判据
36
+ const r2 = triagePair(mem("a", { workspace_scope: "w1" }), mem("b", { workspace_scope: "w2" }), 0.86);
37
+ assert.equal(r2.tier, "cross-scope-dup");
38
+ });
39
+
40
+ test("issue#641 两侧都未标注不算跨作用域,但 annotated=false 显式落账(#616 的洞在测量层可见)", () => {
41
+ const r = triagePair(mem("a"), mem("b"), 0.91, { now: Date.UTC(2026, 10, 1, 2) });
42
+ assert.equal(r.tier, "dup");
43
+ assert.equal(r.signals.crossScope, false);
44
+ assert.equal(r.signals.annotated, false);
45
+ // 一侧标注一侧未标注:scopeKeyOf(null) !== scopeKeyOf("x") → 跨作用域
46
+ const r2 = triagePair(mem("a", { agent_scope: "x" }), mem("b"), 0.86);
47
+ assert.equal(r2.tier, "cross-scope-dup");
48
+ assert.equal(r2.signals.annotated, false);
49
+ });
50
+
51
+ test("issue#641 同日双录(<24h)+ sim≥dupMin 判 dup;真实库 76% 的对落这一档的依据", () => {
52
+ const a = mem("a", { created_at: "2026-11-01T00:00:00.000Z" });
53
+ const b = mem("b", { created_at: "2026-11-01T02:00:00.000Z" });
54
+ const r = triagePair(a, b, 0.91, { now: Date.UTC(2026, 10, 1) });
55
+ assert.equal(r.tier, "dup");
56
+ assert.equal(r.signals.gapDays, 0.083, "gapDays 落账时四舍五入到 3 位(audit JSON 保持干净)");
57
+ });
58
+
59
+ test("issue#641 dup 边界:sim 恰在 dupMin 判 dup(>=),低一分保守归 conflict", () => {
60
+ const a = mem("a", { created_at: "2026-11-01T00:00:00.000Z" });
61
+ const b = mem("b", { created_at: "2026-11-01T01:00:00.000Z" });
62
+ assert.equal(triagePair(a, b, 0.9, { now: Date.UTC(2026, 10, 1) }).tier, "dup");
63
+ assert.equal(triagePair(a, b, 0.899, { now: Date.UTC(2026, 10, 1) }).tier, "conflict");
64
+ });
65
+
66
+ test("issue#641 时间边界:差恰好 24h 不算 dup(1-7 天的模糊带宁保守归 conflict);≥7 天判 evolution", () => {
67
+ const a = mem("a", { created_at: "2026-11-01T00:00:00.000Z" });
68
+ const day1 = mem("b", { created_at: "2026-11-02T00:00:00.000Z" });
69
+ assert.equal(triagePair(a, day1, 0.95, { now: Date.UTC(2026, 10, 1) }).tier, "conflict");
70
+ const day30 = mem("b", { created_at: "2026-12-01T00:00:00.000Z" });
71
+ // evolution 的判据只有时间——sim 再高也先走取代链候选(enforce 阶段接线)
72
+ const r = triagePair(a, day30, 0.99, { now: Date.UTC(2026, 10, 1) });
73
+ assert.equal(r.tier, "evolution");
74
+ });
75
+
76
+ test("issue#641 created_at 缺失不下结论:归 conflict、gapDays=null,不编造时间信号", () => {
77
+ const r = triagePair(mem("a", { created_at: undefined }), mem("b"), 0.91);
78
+ assert.equal(r.tier, "conflict");
79
+ assert.equal(r.signals.gapDays, null);
80
+ });
81
+
82
+ test("issue#641 dupMin 可经 opts 覆盖(配置键 conflictLedgerDupMinSimilarity 的直通口)", () => {
83
+ const a = mem("a", { created_at: "2026-11-01T00:00:00.000Z" });
84
+ const b = mem("b", { created_at: "2026-11-01T01:00:00.000Z" });
85
+ assert.equal(triagePair(a, b, 0.87, { now: Date.UTC(2026, 10, 1), dupMinSimilarity: 0.86 }).tier, "dup");
86
+ });
87
+
88
+ // ---- recordLedgerTriage:best-effort 记账 ----
89
+
90
+ test("issue#641 分诊记账:逐对落账、tally 与档位计数一致", async () => {
91
+ const saved = [];
92
+ const service = { saveConflictLedgerEntry: (r) => saved.push(r) };
93
+ const pairs = [
94
+ { a: mem("a", { created_at: "2026-11-01T00:00:00.000Z" }), b: mem("b", { created_at: "2026-11-01T01:00:00.000Z" }), similarity: 0.91 },
95
+ { a: mem("c", { agent_scope: "x" }), b: mem("d", { agent_scope: "y" }), similarity: 0.86 },
96
+ { a: mem("e", { created_at: "2026-10-01T00:00:00.000Z" }), b: mem("f", { created_at: "2026-11-01T00:00:00.000Z" }), similarity: 0.8 },
97
+ { a: mem("g", { created_at: "2026-11-02T00:00:00.000Z" }), b: mem("h", { created_at: "2026-11-01T00:00:00.000Z" }), similarity: 0.86 }
98
+ ];
99
+ const tally = await recordLedgerTriage(service, "run-1", pairs, { now: Date.UTC(2026, 10, 1) });
100
+ assert.deepEqual(tally, { dup: 1, "cross-scope-dup": 1, evolution: 1, conflict: 1, recorded: 4, failed: 0, truncatedAt: null });
101
+ assert.equal(saved.length, 4);
102
+ assert.equal(saved[0].tier, "dup");
103
+ assert.equal(saved[0].run_id, "run-1");
104
+ });
105
+
106
+ test("issue#641 记账失败只计 failed 不打断:纯测量绝不反噬巩固相位", async () => {
107
+ let calls = 0;
108
+ const service = {
109
+ saveConflictLedgerEntry: () => {
110
+ calls++;
111
+ if (calls === 2) throw new Error("db busy");
112
+ }
113
+ };
114
+ const pairs = [1, 2, 3].map((i) => ({ a: mem(`a${i}`), b: mem(`b${i}`), similarity: 0.91 }));
115
+ const warns = [];
116
+ const tally = await recordLedgerTriage(service, "run-1", pairs, {
117
+ now: Date.UTC(2026, 10, 1),
118
+ logger: { warn: (m) => warns.push(m) }
119
+ });
120
+ assert.equal(tally.recorded, 2);
121
+ assert.equal(tally.failed, 1);
122
+ assert.equal(warns.length, 1, "失败要留下 warn,不是静默吞");
123
+ });
124
+
125
+ test("issue#641 记账分块:大池逐块落账不丢样本(shadow 分母必须完整)", async () => {
126
+ const saved = [];
127
+ const service = { saveConflictLedgerEntry: (r) => saved.push(r) };
128
+ const pairs = Array.from({ length: 120 }, (_, i) => ({
129
+ a: mem(`a${i}`, { created_at: "2026-11-01T00:00:00.000Z" }),
130
+ b: mem(`b${i}`, { created_at: "2026-11-01T01:00:00.000Z" }),
131
+ similarity: 0.91
132
+ }));
133
+ const tally = await recordLedgerTriage(service, "run-1", pairs, { now: Date.UTC(2026, 10, 1) });
134
+ assert.equal(tally.recorded, 120, "跨三块全部落账,无截断");
135
+ assert.equal(tally.dup, 120);
136
+ assert.equal(tally.truncatedAt, null);
137
+ assert.equal(saved.length, 120);
138
+ });
139
+
140
+ test("issue#641 记账分块:取消即停、truncatedAt 可见,绝不静默截断观察样本", async () => {
141
+ const service = { saveConflictLedgerEntry: () => {} };
142
+ const pairs = Array.from({ length: 120 }, (_, i) => ({
143
+ a: mem(`a${i}`, { created_at: "2026-11-01T00:00:00.000Z" }),
144
+ b: mem(`b${i}`, { created_at: "2026-11-01T01:00:00.000Z" }),
145
+ similarity: 0.91
146
+ }));
147
+ // 首块前取消:零写入,truncatedAt=0
148
+ const stopAtStart = await recordLedgerTriage(service, "run-1", pairs, { now: Date.UTC(2026, 10, 1), signal: { aborted: true } });
149
+ assert.equal(stopAtStart.recorded, 0, "取消在首块前生效");
150
+ assert.equal(stopAtStart.truncatedAt, 0);
151
+ // 中途取消:getter 在首块(50 条)落完后翻转 → 第二块前停,块内不弃样本
152
+ const saved = [];
153
+ let flipped = false;
154
+ const midService = {
155
+ saveConflictLedgerEntry: (r) => {
156
+ saved.push(r);
157
+ if (saved.length >= 10) flipped = true;
158
+ }
159
+ };
160
+ const mid = await recordLedgerTriage(midService, "run-2", pairs, {
161
+ now: Date.UTC(2026, 10, 1),
162
+ signal: { get aborted() { return flipped; } }
163
+ });
164
+ assert.equal(mid.recorded, 50, "第一块完整落账(块内不检查取消)");
165
+ assert.equal(mid.truncatedAt, 50, "取消点对可见,余下的对下一轮重见");
166
+ });
167
+
168
+ // ---- conflict_ledger 存储契约 ----
169
+
170
+ test("issue#641 conflict_ledger:键归一化 + 读回 + 人工队列零污染", () => {
171
+ const store = createStore(":memory:");
172
+ store.saveConflictLedgerEntry({
173
+ run_id: "run-1", memory_a: "b-id", memory_b: "a-id", tier: "dup",
174
+ similarity: 0.91, signals: { crossScope: false, annotated: false, gapDays: 0.5, dupMin: 0.9, similarity: 0.91 }
175
+ });
176
+ const rows = store.listConflictLedger();
177
+ assert.equal(rows.length, 1);
178
+ assert.equal(rows[0].memory_a, "a-id", "键按字典序归一化——反着传也是同一行");
179
+ assert.equal(rows[0].tier, "dup");
180
+ assert.equal(rows[0].status, "open");
181
+ assert.deepEqual(JSON.parse(rows[0].signals), { crossScope: false, annotated: false, gapDays: 0.5, dupMin: 0.9, similarity: 0.91 });
182
+ assert.equal(store.countConflictPending(), 0, "分诊账本是测量记录,绝不涨人工队列徽标");
183
+ assert.equal(store.countConflictLedger(), 1);
184
+ store.close();
185
+ });
186
+
187
+ test("issue#641 conflict_ledger:重复观察是刷新不是新行,first_* 保留首次检出", () => {
188
+ const store = createStore(":memory:");
189
+ store.saveConflictLedgerEntry({ run_id: "run-1", memory_a: "a", memory_b: "b", tier: "conflict", similarity: 0.86 });
190
+ const first = store.listConflictLedger()[0];
191
+ // 同一对后来判成 dup(两侧内容变了 tier 就该变——tier 是观察结论,不是身份)
192
+ store.saveConflictLedgerEntry({ run_id: "run-2", memory_a: "b", memory_b: "a", tier: "dup", similarity: 0.92 });
193
+ const rows = store.listConflictLedger();
194
+ assert.equal(rows.length, 1, "同一对只留一行");
195
+ assert.equal(rows[0].tier, "dup");
196
+ assert.equal(rows[0].first_run_id, "run-1", "首见 run 保留——挂账时长锚在首次检出");
197
+ assert.equal(rows[0].last_run_id, "run-2");
198
+ assert.ok(rows[0].first_seen_at < rows[0].last_seen_at, "单调时钟下两次观察严格递增");
199
+ assert.equal(typeof first.first_seen_at, "string");
200
+ store.close();
201
+ });
202
+
203
+ test("issue#641 conflict_ledger:状态迁移合法值校验 + 未知对返回 false", () => {
204
+ const store = createStore(":memory:");
205
+ store.saveConflictLedgerEntry({ run_id: "run-1", memory_a: "a", memory_b: "b", tier: "conflict" });
206
+ assert.equal(store.setConflictLedgerStatus("b", "a", "zombied"), true, "反着传也能命中(归一化)");
207
+ assert.equal(store.listConflictLedger({ status: "zombied" }).length, 1);
208
+ assert.throws(() => store.setConflictLedgerStatus("a", "b", "not-a-status"), /unknown status/, "非法状态是编程错误,抛错不落库");
209
+ assert.equal(store.setConflictLedgerStatus("x", "y", "open"), false, "未知对返回 false 不抛");
210
+ assert.throws(() => store.saveConflictLedgerEntry({ memory_a: "a", memory_b: "b", tier: "weird" }), /unknown tier/);
211
+ store.close();
212
+ });
213
+
214
+ test("issue#641 conflict_ledger:tier/status 过滤口径(shadow 周分析的查询面)", () => {
215
+ const store = createStore(":memory:");
216
+ store.saveConflictLedgerEntry({ memory_a: "a", memory_b: "b", tier: "dup" });
217
+ store.saveConflictLedgerEntry({ memory_a: "c", memory_b: "d", tier: "conflict" });
218
+ store.saveConflictLedgerEntry({ memory_a: "e", memory_b: "f", tier: "conflict" });
219
+ assert.equal(store.countConflictLedger({ tier: "conflict" }), 2);
220
+ assert.equal(store.countConflictLedger({ tier: "dup" }), 1);
221
+ assert.equal(store.listConflictLedger({ tier: "dup" })[0].memory_a, "a");
222
+ store.close();
223
+ });
224
+
225
+ // ---- 配置面 ----
226
+
227
+ test("issue#641 conflictLedgerMode 默认 off、枚举拒绝未定义档位(不存在设了没行为的档位)", () => {
228
+ const cfg = Config({});
229
+ assert.equal(cfg.conflictLedgerMode, "off");
230
+ assert.equal(cfg.conflictLedgerDupMinSimilarity, 0.9);
231
+ // enforce 在 beta 第二阶段进枚举(处置接线),但清单之外的值仍然要被拒
232
+ assert.throws(() => Config({ conflictLedgerMode: "yolo" }), /conflictLedgerMode/);
233
+ });
234
+
235
+ test("issue#641 LEDGER_TIERS 与 store 侧白名单一致(两处清单不许漂移)", () => {
236
+ const store = createStore(":memory:");
237
+ for (const tier of LEDGER_TIERS) {
238
+ store.saveConflictLedgerEntry({ memory_a: `a-${tier}`, memory_b: `b-${tier}`, tier });
239
+ }
240
+ assert.equal(store.countConflictLedger(), LEDGER_TIERS.length, "triage.js 的每一档都能落账");
241
+ store.close();
242
+ });
@@ -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");
@@ -48,8 +48,15 @@ const HOST_TOKENS = {
48
48
  "--dsw-alias-interactive-bg-active": { light: "#2631481a", dark: "#ffffff24" },
49
49
  "--dsw-alias-scrollbar-bg-l2": { light: "#e5e5e5", dark: "#545557" },
50
50
  "--dsw-alias-scrollbar-hover-l2": { light: "#d4d4d4", dark: "#65676b" },
51
+ // static-* 在两个主题下同值(宿主定义在 :root),图表色盘就是靠它们做色相来源,
52
+ // 再由 label 混色让深浅两套色阶自动成立。
53
+ "--dsw-static-amber-500": { light: "#f59e0b", dark: "#f59e0b" },
51
54
  "--dsw-static-amber-600": { light: "#dd8629", dark: "#dd8629" },
55
+ "--dsw-static-blue-500": { light: "#3b82f6", dark: "#3b82f6" },
52
56
  "--dsw-static-green-500": { light: "#22c55e", dark: "#22c55e" },
57
+ "--dsw-static-neutral-500": { light: "#7f8287", dark: "#7f8287" },
58
+ "--dsw-static-red-500": { light: "#ef4444", dark: "#ef4444" },
59
+ "--dsw-static-violet-400": { light: "#a78bfa", dark: "#a78bfa" },
53
60
  // 真实存在但**不是颜色**的宿主 token:只参与上面的「名字必须存在」检查。
54
61
  "--dsw-radius-md": { light: null, dark: null },
55
62
  "--dsw-focus-ring-width": { light: null, dark: null },
@@ -139,22 +146,38 @@ function resolveValue(raw, theme) {
139
146
  }
140
147
  return v.includes("\u00acMISSING\u00ac") ? null : v.trim();
141
148
  }
149
+ // 按顶层逗号切。color-mix 会嵌套(图表色盘先跟中性灰压彩度、再跟 label 适配主题),
150
+ // 早期用非贪婪正则会从内层那个「62%,」处劈开,两层都解析失败 → 图形锁报「解析不出颜色」,
151
+ // 看着像色值坏了,其实是解析器不会数括号。
152
+ function splitTop(s) {
153
+ const out = [];
154
+ let depth = 0;
155
+ let cur = "";
156
+ for (const ch of s) {
157
+ if (ch === "(") depth++;
158
+ else if (ch === ")") depth--;
159
+ if (ch === "," && depth === 0) { out.push(cur); cur = ""; continue; }
160
+ cur += ch;
161
+ }
162
+ out.push(cur);
163
+ return out.map((x) => x.trim());
164
+ }
142
165
  function colorOf(raw, theme) {
143
166
  const v = resolveValue(raw, theme);
144
167
  if (v === null) return null;
145
- let m = /^color-mix\(\s*in\s+srgb\s*,\s*(.+?)\s+([\d.]+)%\s*,\s*transparent\s*\)$/.exec(v);
146
- if (m) {
147
- const base = colorOf(m[1], theme);
168
+ const head = /^color-mix\(\s*in\s+srgb\s*,/i.exec(v);
169
+ if (head && v.endsWith(")")) {
170
+ const args = splitTop(v.slice(head[0].length, -1));
171
+ if (args.length !== 2) return null;
172
+ const pct = /^(.*?)\s+([\d.]+)%$/.exec(args[0]);
173
+ if (!pct) return null;
174
+ const base = colorOf(pct[1], theme);
148
175
  if (!base) return null;
149
- return [...base.slice(0, 3), base[3] * (parseFloat(m[2]) / 100)];
150
- }
151
- m = /^color-mix\(\s*in\s+srgb\s*,\s*(.+?)\s+([\d.]+)%\s*,\s*(.+?)\s*\)$/.exec(v);
152
- if (m) {
153
- const a = colorOf(m[1], theme);
154
- const b = colorOf(m[3], theme);
155
- if (!a || !b) return null;
156
- const p = parseFloat(m[2]) / 100;
157
- return [...a.slice(0, 3).map((x, i) => x * p + b[i] * (1 - p)), a[3] * p + b[3] * (1 - p)];
176
+ const p = parseFloat(pct[2]) / 100;
177
+ if (/^transparent$/i.test(args[1])) return [...base.slice(0, 3), base[3] * p];
178
+ const other = colorOf(args[1], theme);
179
+ if (!other) return null;
180
+ return [...base.slice(0, 3).map((x, i) => x * p + other[i] * (1 - p)), base[3] * p + other[3] * (1 - p)];
158
181
  }
159
182
  return parseColor(v);
160
183
  }
@@ -308,6 +331,95 @@ test("图形色(热度/打卡/连线)在浅深两主题下都达到 3:1", ()
308
331
  assert.deepEqual(problems, [], "图形色对比度不足:\n" + problems.join("\n"));
309
332
  });
310
333
 
334
+ // 色槽的归属是全局的:--mneme-chart-* 只服侍 donut(弧 + 图例点),--mneme-heat-* 只服侍
335
+ // 热力格,--mneme-sink-* 只服侍沉淀图那两根柱。三种回退都在这条锁上:
336
+ // ① 沉淀图借 donut 的 chart-3 / chart-2——同一页里一根琥珀柱在 donut 是「实体抽取」、
337
+ // 在沉淀图是「新增」,用户会把两个无关的量读成同一类;
338
+ // ② 上一版把第 6 个色槽内联成了三份(令牌 + 弧 + 图例点),改一处漏两处,死令牌还
339
+ // 没人发现——所以要求每一格都真的由令牌供给,不许内联字面量;
340
+ // ③ 令牌之间互相继承(`--mneme-sink-added:var(--mneme-chart-3)`)——看着解耦了,实际
341
+ // 一动 donut 的色盘就跟着改,语义层里出现别的图的 var() 一律拦。
342
+ // 它不锁色值:调色是设计决定,该靠截图给人看。与上面 GRAPHICS 的分工是「值 vs 名字」——
343
+ // 那条按值算 3:1、不看名字,所以换语义名也放宽不了对比度,新令牌照样被它咬住。
344
+ test("色槽各有其主:只被自己的图引用,且每格都由令牌供给", () => {
345
+ const FAMILIES = [
346
+ { name: "chart", re: /var\(\s*--mneme-chart-/, owns: /^\.mneme-(seg|dot)--[1-6]$/ },
347
+ { name: "heat", re: /var\(\s*--mneme-heat-/, owns: /^\.mneme-heatcell\.l[1-4]$/ },
348
+ { name: "sink", re: /var\(\s*--mneme-sink-/, owns: /^\.mneme-sinkbar--(added|merged)$/ }
349
+ ];
350
+ const problems = [];
351
+ // 语义层自己的声明也要查:`--mneme-sink-added:var(--mneme-chart-3)` 这种「继承式解耦」
352
+ // 看着分了家,实际上 donut 一动色盘它就跟着变。注意语义层那条规则是字符串拼出来的,
353
+ // **不在 rules 里**(rules 只收能整条匹配单个字面量的规则),所以必须单独扫 layer.body
354
+ // ——第一版写成了 `if (r.sel === layer.sel)`,那条分支永远进不去,等于没锁。
355
+ for (const f of FAMILIES) {
356
+ if (f.re.test(layer.body)) problems.push(`语义层里出现 ${f.name} 色槽的 var() 引用:色值要写成独立一份,不能继承别的图`);
357
+ }
358
+ for (const r of rules) {
359
+ for (const f of FAMILIES) {
360
+ if (f.re.test(r.body) && !f.owns.test(r.sel)) problems.push(`${r.sel} 借用了 ${f.name} 的色槽`);
361
+ }
362
+ }
363
+ const bySel = new Map(rules.map((r) => [r.sel, r]));
364
+ for (let i = 1; i <= 6; i++) {
365
+ for (const kind of ["seg", "dot"]) {
366
+ const sel = `.mneme-${kind}--${i}`;
367
+ const r = bySel.get(sel);
368
+ if (!r) { problems.push(`${sel} 规则没了`); continue; }
369
+ if (!r.body.includes(`var(--mneme-chart-${i})`)) problems.push(`${sel} 没按 --mneme-chart-${i} 取色(内联颜色会变成第二份源)`);
370
+ }
371
+ }
372
+ for (const [sel, tok] of [
373
+ ...Array.from({ length: 4 }, (_, i) => [`.mneme-heatcell.l${i + 1}`, null]),
374
+ [".mneme-sinkbar--added", "--mneme-sink-added"],
375
+ [".mneme-sinkbar--merged", "--mneme-sink-merged"]
376
+ ]) {
377
+ const r = bySel.get(sel);
378
+ if (!r) { problems.push(`${sel} 规则没了`); continue; }
379
+ if (tok ? !r.body.includes(`var(${tok})`) : !/var\(\s*--mneme-heat-/.test(r.body)) {
380
+ problems.push(`${sel} 的填充色不是从语义层的令牌取的`);
381
+ }
382
+ }
383
+ assert.deepEqual(problems, [], "色槽归属或取色来源不对:\n" + problems.join("\n"));
384
+ });
385
+
386
+ // 图表调色盘(2026-11,代号 E):五个色相 + 一套共同配方。上面那条明说了「不锁色值」——
387
+ // 色号该靠截图给人看。这条锁的是**配方**,因为配方一散就会退回旧口径:
388
+ // ① 五个槽各自挑色器与配比:chart-2 与 chart-5 又都变绿(深色下 H 只差 4)、红槽彩度
389
+ // 翻到 0.172(别人的两倍)、amber-600 再压黑读成土褐;
390
+ // ② 少掉「先跟中性灰混」那一步:彩度与明度带一起散,深浅两套色阶就没法自动成立;
391
+ // ③ 拿 --dsw-alias-state-* 当色源:那是宿主状态的语义别名,宿主调一次状态色就顺手
392
+ // 换掉了整张图——图表色盘是设计决定,不该被别人的开关牵走。
393
+ // 五个色相必须互不相同(这就是「两块绿」那条的直接回归锁),灰槽留给 chart-6。
394
+ test("图表五色:五个不同色相、同一套两步混色配方", () => {
395
+ const problems = [];
396
+ const hues = new Set();
397
+ for (let i = 1; i <= 5; i++) {
398
+ const m = new RegExp("--mneme-chart-" + i + ":([^;]+);").exec(layer.body);
399
+ if (!m) { problems.push("--mneme-chart-" + i + " 没了"); continue; }
400
+ const v = m[1].trim();
401
+ if (/--dsw-alias-state-/.test(v)) problems.push("--mneme-chart-" + i + " 用了宿主状态别名当色源:" + v);
402
+ const shape = /^color-mix\(in srgb,color-mix\(in srgb,var\((--dsw-static-[a-z]+-\d{3}),#[0-9a-f]{6}\) \d+%,var\(--dsw-static-neutral-500,#7f8287\)\) \d+%,var\(--dsw-alias-label-primary\)\)$/.exec(v);
403
+ if (!shape) { problems.push("--mneme-chart-" + i + " 不是「静态色 → 中性灰 → label」两步式:" + v); continue; }
404
+ hues.add(shape[1]);
405
+ }
406
+ if (hues.size !== 5) problems.push("五个槽只用了 " + hues.size + " 个色相(" + [...hues].join(" / ") + "):相邻两块又会分不出");
407
+ const g = /--mneme-chart-6:([^;]+);/.exec(layer.body);
408
+ if (!g) problems.push("--mneme-chart-6 没了");
409
+ else if (!/^color-mix\(in srgb,var\(--dsw-alias-label-primary\) \d+%,transparent\)$/.test(g[1].trim())) {
410
+ problems.push("--mneme-chart-6 是「其他」类,该保持半透明灰:" + g[1].trim());
411
+ }
412
+ // 沉淀图两根柱跟着同一套配方走(同页一半柔和一半艳最难解释),但归属仍是自己的
413
+ for (const tok of ["--mneme-sink-added", "--mneme-sink-merged"]) {
414
+ const m = new RegExp(tok + ":([^;]+);").exec(layer.body);
415
+ if (!m) { problems.push(tok + " 没了"); continue; }
416
+ if (!/color-mix\(in srgb,color-mix\(in srgb,var\(--dsw-static-/.test(m[1])) {
417
+ problems.push(tok + " 没跟着图表的配方走(单层混色会和 donut 并排时突兀)");
418
+ }
419
+ }
420
+ assert.deepEqual(problems, [], "图表调色盘的配方不统一:\n" + problems.join("\n"));
421
+ });
422
+
311
423
  // 虚线关系边曾经在 .mneme-gedge-dashed 上再叠一层 opacity:.7,把已经只有 50% 的
312
424
  // 描边压到浅色 2.28(低于图形 3:1)。上面那条图形锁只读单条规则自己的 stroke,
313
425
  // 看不到「同一条边同时命中两条规则、其中一条在降透明度」这种叠加,所以单独锁一层:
@@ -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
+ });