@modusensus/dsh-mneme 0.9.4 → 0.9.5

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 (80) hide show
  1. package/README.md +53 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +4 -2
  4. package/docs/DAEMON.md +9 -2
  5. package/docs/DISTILL.md +66 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +1 -1
  8. package/docs/STORAGE.md +1 -1
  9. package/lib/api-standalone.js +286 -8
  10. package/lib/api.js +195 -1
  11. package/lib/client.js +783 -30
  12. package/lib/document.js +15 -2
  13. package/lib/dream/decisions.js +88 -3
  14. package/lib/dream/sleep.js +63 -13
  15. package/lib/dream.js +65 -9
  16. package/lib/lang.js +23 -6
  17. package/lib/memory-source.js +9 -0
  18. package/lib/organize.js +5 -1
  19. package/lib/pipeline.js +62 -1
  20. package/lib/provenance-http.js +126 -0
  21. package/lib/serve.js +13 -0
  22. package/lib/service.js +67 -4
  23. package/lib/share-http.js +281 -0
  24. package/lib/share-import.js +234 -0
  25. package/lib/share.js +335 -0
  26. package/lib/store.js +272 -11
  27. package/lib/summarize.js +60 -11
  28. package/package.json +1 -1
  29. package/src/api-standalone.js +286 -8
  30. package/src/api.js +195 -1
  31. package/src/document.js +15 -2
  32. package/src/dream/decisions.js +88 -3
  33. package/src/dream/sleep.js +63 -13
  34. package/src/dream.js +65 -9
  35. package/src/lang.js +23 -6
  36. package/src/memory-source.js +9 -0
  37. package/src/organize.js +5 -1
  38. package/src/pipeline.js +62 -1
  39. package/src/provenance-http.js +126 -0
  40. package/src/serve.js +13 -0
  41. package/src/service.js +67 -4
  42. package/src/share-http.js +281 -0
  43. package/src/share-import.js +234 -0
  44. package/src/share.js +335 -0
  45. package/src/store.js +272 -11
  46. package/src/summarize.js +60 -11
  47. package/test/api.test.js +370 -2
  48. package/test/audit.test.js +2 -2
  49. package/test/cli-receive.test.js +390 -0
  50. package/test/cli-share.test.js +437 -0
  51. package/test/client-share.test.js +225 -0
  52. package/test/client.test.js +139 -0
  53. package/test/dedupe-candidate-window.test.js +180 -0
  54. package/test/dream-candidate-hybrid.test.js +2 -2
  55. package/test/dream-fence.test.js +164 -0
  56. package/test/dream.test.js +87 -5
  57. package/test/helpers/dream-mock.js +6 -2
  58. package/test/llm-message-source.test.js +1 -1
  59. package/test/mint-source.test.js +1 -1
  60. package/test/pipeline-r1r2.test.js +129 -0
  61. package/test/policy-epoch.test.js +2 -2
  62. package/test/provenance-http.test.js +184 -0
  63. package/test/readme-claims.test.js +45 -0
  64. package/test/reasoning-effort.test.js +10 -10
  65. package/test/scope-candidates.test.js +7 -4
  66. package/test/scope-state.test.js +130 -1
  67. package/test/serve.test.js +62 -0
  68. package/test/share-foreign-rows.test.js +144 -0
  69. package/test/share-http.test.js +144 -0
  70. package/test/share-import.test.js +577 -0
  71. package/test/share-imported-guard.test.js +311 -0
  72. package/test/share-imported-identity.test.js +111 -0
  73. package/test/share-pack.test.js +511 -0
  74. package/test/share-receive-http.test.js +756 -0
  75. package/test/share-routes.test.js +337 -0
  76. package/test/share-store.test.js +133 -0
  77. package/test/share-validate.test.js +101 -0
  78. package/test/standalone-api.test.js +524 -5
  79. package/test/summarize-parse-diagnostics.test.js +75 -0
  80. package/test/summarize.test.js +15 -2
@@ -0,0 +1,225 @@
1
+ // 车道 D 的面板静态锁。为什么是静态锁:lib/client.js 由宿主 __ModuleLoader__ 加载,
2
+ // 本仓库没有可跑的宿主壳,渲染测试要拖进一份 React 测试栈才够得着那几行真代码。
3
+ // 锁的是「形状」而不是「长得像」:每一条都对应一个**会伤人的回归形态**,
4
+ // 注释里写清它防的是哪一种(AGENTS.md 纪律 8 对测试同样适用)。
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { readFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const clientSource = readFileSync(join(here, "..", "lib/client.js"), "utf8");
13
+ const shareSource = readFileSync(join(here, "..", "src/share.js"), "utf8");
14
+
15
+ // 分享弹层最要紧的一条:**看见 report 之前不给下载**(spec §6 的支点)。
16
+ // 防的回归形态:有人为了「方便」把下载按钮改成常驻,或从别处(如列表工具栏)再开一条
17
+ // 直接落盘的口子——那一步停顿就没了,随手全库外发重新变成一次点击。
18
+ test("the share dialog gates the download on the report", () => {
19
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/share"'),
20
+ "面板必须发这一枪(不许另造 fetch 绕过 apiFetch 的 token 注入)");
21
+ assert.ok(clientSource.includes("const canDownload = !!rep && rep.included > 0;"),
22
+ "下载按钮的唯一门:有报告且有内容。included 为 0 时对面吃的是 empty-pack(400),不该给一个必败的文件");
23
+ assert.ok(clientSource.includes("canDownload && h(\"div\""),
24
+ "下载那一行必须挂在 canDownload 上,而不是常驻(按钮本身在这样一个 div 里)");
25
+ // 413 与 200 是两份形状:413 没有 memories,只有 report + 字节数 + hint。
26
+ assert.ok(clientSource.includes("if (res.status === 413)"),
27
+ "超水位要走 tooLarge 那一支,不能落到通用失败");
28
+ assert.ok(clientSource.includes("new Blob([JSON.stringify(pack)]"),
29
+ "落盘的必须是服务端给的那一份字节:重拼字段或 pretty-print 都会让「量过的包」与「发出去的包」不是同一坨");
30
+ });
31
+
32
+ // docs/SHARE.md「车道 D 三笔账」之一:included 只数实际发出的条数,够格进包的是
33
+ // included + deduped。防的回归:有人把「合格条数」的标签挪到 included 上,读数是假的。
34
+ test("the report separates what shipped from what qualified", () => {
35
+ assert.ok(clientSource.includes("report.included + report.deduped"),
36
+ "折叠掉的那几条必须加回去,两个数同屏");
37
+ assert.ok(clientSource.includes('t("memory.explorer.shareDeduped")'),
38
+ "deduped 那一行要真的有渲染点,不是只在字典里躺着");
39
+ });
40
+
41
+ // 跨文件锁:面板的六个跳过原因与八个可分享类型都只许是服务端那份的投影。
42
+ // 防的回归:share.js 加/改一个 reason 键或一类可分享类型,面板静默少一格
43
+ // ——她的报告上就有一类跳过是看不见的。
44
+ test("the panel's skip-reason and type sets mirror src/share.js", () => {
45
+ const serverReasons = [...shareSource.matchAll(/reason:\s*"([a-z_]+)"/g)].map((m) => m[1]);
46
+ const uniqueReasons = [...new Set(serverReasons)];
47
+ const panelReasons = /const SHARE_SKIP_REASONS = \[([^\]]*)\]/.exec(clientSource)[1]
48
+ .split(",").map((s) => s.trim().replace(/"/g, "")).filter(Boolean);
49
+ assert.deepEqual(panelReasons, uniqueReasons,
50
+ "跳过原因集必须与 share.js 的判序一致(顺序也一致:报告按判序展示)");
51
+
52
+ const serverTypes = /SHARE_ALLOWED_TYPES = new Set\(\[([^\]]*)\]/.exec(shareSource)[1]
53
+ .split(",").map((s) => s.trim().replace(/"/g, "")).filter(Boolean);
54
+ const panelTypes = /const SHARE_TYPE_OPTIONS = \[([^\]]*)\]/.exec(clientSource)[1]
55
+ .split(",").map((s) => s.trim().replace(/"/g, "")).filter(Boolean);
56
+ assert.deepEqual(panelTypes.slice().sort(), serverTypes.slice().sort(),
57
+ "类型下拉只能列真能进包的那几类");
58
+ });
59
+
60
+ // 别名的尺:服务端 importedTagFor 把整串(含 from-)截到 IMPORTED_TAG_MAX。
61
+ // 防的回归:服务端改这个常量而面板还拦着旧长度,她打的字在对面被静默吃掉。
62
+ test("the alias input matches the server's tag budget", () => {
63
+ const max = Number(/IMPORTED_TAG_MAX = (\d+)/.exec(shareSource)[1]);
64
+ assert.ok(clientSource.includes(`maxLength: ${max - "from-".length}`),
65
+ "输入框长度必须等于服务端留给别名的字符数");
66
+ });
67
+
68
+ // 文件名以服务端 Content-Disposition 为准(拼法只住在 src/share-http.js 的 shareFilename)。
69
+ // 防的回归:面板自己抄一份「日期 + pack_id 前 8 位」的拼法,两处日后分叉。
70
+ test("the pack filename comes from the response header, with a pack_id fallback", () => {
71
+ assert.ok(clientSource.includes('res.headers.get("content-disposition")'));
72
+ assert.ok(clientSource.includes("const dispositionName = (value) =>"));
73
+ // 回落那一格只许出现一次前缀串,且不带日期拼法。
74
+ assert.equal(clientSource.split("dsh-mneme-share-").length - 1, 1,
75
+ "面板只许有一份包名前缀串(回落用),不许再拼第二套日期命名");
76
+ assert.equal(/pack_id[^;\n]{0,48}slice\(\s*0\s*,\s*8\s*\)/.test(clientSource), false,
77
+ "不许在 pack_id 后面截 8 位——那是 src/share-http.js 的 shareFilename 唯一的拼法");
78
+ });
79
+
80
+ // docs/SHARE.md「车道 D 三笔账」之二:干跑与真导入的答复同形状,区别只有
81
+ // withDryRun 加的 dry_run 那一格。防的回归形态:界面按「我有没有发 confirm」推断写了
82
+ // 没有——同一件事写两处,改一处就出现「其实没写、却说写了」。
83
+ test("the receive dialog reads dry_run instead of inferring from its own button", () => {
84
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/receive"'),
85
+ "入库只许走这一枪(apiFetch 带 token 注入)");
86
+ assert.ok(clientSource.includes("post({ pack })"), "预览那一发不带 confirm——缺省即干跑");
87
+ assert.ok(clientSource.includes("post({ pack, confirm: true })"),
88
+ "真写的那一发必须带 confirm 这个真值,其他形状服务端都不认");
89
+ assert.ok(clientSource.includes('d.dry_run === false ? "memory.explorer.receiveDidCreate"'),
90
+ "「写进去了」这句只能由答复自己说——钉的是那个三元式本身,不是六个字面量都在");
91
+ assert.ok(clientSource.includes("result.dry_run !== false"),
92
+ "确认之后仍回干跑时要单独说「没有写库」,不许沉默,也不许挂在预览那一支上");
93
+ assert.ok(clientSource.includes('"memory.explorer.receiveStillDry"'),
94
+ "那一格要有自己的文案");
95
+ // 第二个旋钮一概不认:收侧只认 confirm(spec §5——reclaim 的 body 里就带 dryRun,
96
+ // 手熟的人会顺手发来,把它读成「要写」等于替她点头)。
97
+ // **切片而不是全文**:本文件里 reclaim/VACUUM 那一发合法地发 `dryRun: false, confirm: true`,
98
+ // 那是 reclaim 的形状不是 receive 的。全文反锁会一上来就红;谁把它「修」成全文锁,
99
+ // 就等于给 reclaim 那颗键定罪。所以按函数名切区段,并且**两个锚点都要验**:
100
+ // 结束锚点丢了的时候 indexOf 返回 -1,切片会一路长到文件尾而不是塌成空——
101
+ // 那时候这条锁看似更严,实际悄悄开始管整个文件(而 ReceiveDialog 之后今天没有 dryRun,
102
+ // 它会空过)。两头都钉住,锚点漂移只会红得明白。
103
+ const receiveStart = clientSource.indexOf("function ReceiveDialog(");
104
+ const receiveEnd = clientSource.indexOf("// #386 第 5 条", receiveStart);
105
+ assert.ok(receiveStart > 0 && receiveEnd > receiveStart,
106
+ "ReceiveDialog 的起止锚点都要在,且顺序对——否则这条锁在管的东西和它说的不是一件");
107
+ const receiveRegion = clientSource.slice(receiveStart, receiveEnd);
108
+ assert.ok(receiveRegion.length > 800, "ReceiveDialog 的区段短得不像话,先修锚再谈锁");
109
+ assert.equal(receiveRegion.includes("dryRun"), false,
110
+ "收侧不许发 dryRun——唯一的入口是 confirm");
111
+ });
112
+
113
+ // 三笔账之三:title_conflicts 是 advisory 读数,只对「本轮真正会新建的那些」判过,
114
+ // 重吃同一个包时恒空。防的回归:文案写成「现在没有冲突」。
115
+ test("the receive preview keeps the advisory reading honest", () => {
116
+ assert.ok(clientSource.includes("d.title_conflicts"), "撞名清单要显示");
117
+ assert.ok(clientSource.includes('"memory.explorer.receiveConflicts"'));
118
+ // 钉的是**文案**而不是注释:解释性的话必须能写「这不等于没有冲突」来提醒读者,
119
+ // 连注释一起 ban 等于把警告本身禁掉(这一轮就撞上了:新注释里的「没有撞名」先红的是这条锁)。
120
+ // 真正会伤人的是屏上那一句,所以只扫非注释行。
121
+ const copyLines = clientSource.split("\n").filter((line) => !/^\s*\/\//.test(line)).join("\n");
122
+ assert.equal(/现在没有冲突|没有撞名|无冲突|no conflicts|no title clashes/i.test(copyLines), false,
123
+ "不许出现「没有冲突」这类绝对断言(清单为空只说明本轮没有新的要报)");
124
+ // 先预览才允许确认:干跑是必经的一站,不是可选装饰。
125
+ assert.ok(clientSource.includes("disabled: busy || !preview"),
126
+ "确认按钮在拿到预览之前必须 disabled");
127
+ // advisory 读数的分母要说出来,否则「清单为空」与「什么也没比」在屏上长一个样。
128
+ // 注意它是**包内条数**而不是「比过了几条」,所以文案只许说「包内 N 条」。
129
+ assert.ok(clientSource.includes("d.conflicts_checked"),
130
+ "要把服务端给的包内条数显示出来");
131
+ assert.ok(clientSource.includes('t("memory.explorer.receivePackCount")'),
132
+ "那一行要有自己的渲染点,不是只在字典里躺着");
133
+ });
134
+
135
+ // D3 评审裁定补的锁:两种「按了没反应」的形态。
136
+ // a) 200 但正文读不出——曾经一路 return null,调用方把它读成「屏上已有提示」,
137
+ // 于是确认那一发悄悄没走完,而上一块的「将新建 N 条」还留在屏上;
138
+ // b) 预览还在飞的时候又能选一个文件——飞回来的数字说的是前一个包,确认写的却是后一个。
139
+ test("the receive dialog never goes silent, and never lets a superseded preview drive the write", () => {
140
+ assert.ok(clientSource.includes('if (!d || typeof d !== "object") {'),
141
+ "200 但没有可读正文也要 setErr,不许 return null 装成「已经处理过了」");
142
+ assert.ok(clientSource.includes("disabled: busy, onClick: () => fileRef.current?.click()"),
143
+ "预览/确认在飞的时候不许再选文件(那一块数字属于上一个包)");
144
+ assert.ok(clientSource.includes('typeof parsed !== "object"'),
145
+ "解得出 JSON 但不是对象,是另一种失败,得有它自己的一句话");
146
+ assert.ok(clientSource.includes('e.target.value = ""'),
147
+ "选完清空控件值:否则改过的同一个文件再选一次不发 change,面板拿着旧包预览");
148
+ });
149
+
150
+ // 外来区三件:筛选、来源徽标、整包退出。锁的是「每件事都挂在一个服务端真值上」,
151
+ // 而不是挂在一个界面自己造出来的判断上。
152
+ test("the foreign filter is a browse-state server filter and stays inert while searching", () => {
153
+ assert.ok(clientSource.includes('foreignOnly ? "&source=imported" : ""'),
154
+ "浏览态交给服务端筛:分页与 total 同口径,本地筛会切掉尾巴");
155
+ // **搜索态不做本地过滤**(本轮扫描改掉的裁定,不是漏做):/search 那张 DTO 的 source 是
156
+ // 召回渠道(keyword/vector/bm25,src/service.js:939),行的 provenance 被盖掉,「这条是不是
157
+ // 外来的」在这张 DTO 上压根读不出来。宁可让 chip 在搜索态明确失效并说清原因,也不要按一个
158
+ // 假信号筛——test/share-foreign-rows.test.js 的第三条用例锁着这个事实。
159
+ assert.ok(clientSource.includes("disabled: !!query.trim() && !foreignOnly"),
160
+ "有搜索词时不许**开启**外来筛选,但任何时候都许关掉——只给 disabled 会把她锁在一个她解不开的状态里");
161
+ assert.ok(clientSource.includes('"memory.explorer.filterForeignSearchHint"'),
162
+ "失效要有自己的文案,不能只靠 disabled 让人猜");
163
+ assert.equal(clientSource.includes('(!foreignOnly || m.source'), false,
164
+ "不许退回「搜索态本地按 source 筛」那条假信号");
165
+ // 服务端不吃 source 这件事,不许用「给 search 的 URL 也拼一个键」的方式绕过去:
166
+ // 那条路由压根不读它(src/api.js 的 search handler 里没有 source 这一档),拼上去
167
+ // 只会造出一个「看起来筛过了、其实没有」的形状。全文件只许有一处拼这个参数。
168
+ assert.equal(clientSource.split("&source=imported").length - 1, 1,
169
+ "只有浏览态那一条 URL 会拼 source=imported");
170
+ });
171
+
172
+ // 来源徽标与撤销都按服务端给的真值门控:徽标认 source + from- 标签,撤销认 pack_id。
173
+ test("the foreign badge and the withdraw action gate on server truth, not on guesswork", () => {
174
+ assert.ok(clientSource.includes('m.source === "imported"'),
175
+ "徽标先认 source,再认标签:人手工打的 from-* 标签不算外来行");
176
+ assert.ok(clientSource.includes('startsWith("from-")'),
177
+ "别名读数就是 importedTagFor 打的那条标签,面板不另找地方读别名");
178
+ assert.ok(clientSource.includes("memory.share_pack_id &&"),
179
+ "撤销按钮按包号自门控:/search 的 DTO 不带这一格,那种行按不了(已知代价,docs/SHARE.md 记着)");
180
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/receive/undo"'),
181
+ "整包退出只许走这一枪");
182
+ assert.ok(clientSource.includes("{ packId: memory.share_pack_id, confirm: true }"),
183
+ "删除那一发要带 confirm 真值,且 packId 只从行上取");
184
+ // 两段式的**第一段**也要有锁:只钉 confirm 那一发等于没钉——把三元式删掉、每次都带
185
+ // confirm:true,十把锁一把都不会红,而「整包物删」变成一次点击就发生。
186
+ assert.ok(clientSource.includes(": { packId: memory.share_pack_id })"),
187
+ "清点那一发必须是不带 confirm 的形状");
188
+ assert.ok(clientSource.includes("onClick: () => undoPack(false)"),
189
+ "第一颗键走的是干跑那一支");
190
+ assert.ok(clientSource.includes("d.dry_run === false"),
191
+ "撤成功的读数同样只由答复的 dry_run 说");
192
+ assert.ok(clientSource.includes("undoPreview.sample"),
193
+ "删除前的确认要带前缀样例(答复的 sample 最多 20 条,deleted 才是全量)");
194
+ // 撤是**物删**(docs/SHARE.md:归档行会占住 UNIQUE 位,撤了导不回来),文案不许写「归档」。
195
+ assert.equal(/memory\.explorer\.undoPack[A-Za-z]*"\s*:\s*"[^"]*(归档|archive)/i.test(clientSource), false,
196
+ "撤销的文案不许出现「归档」——它是物理删除");
197
+ // 删除那一发的成功判据**只能是答复的 dry_run**。`confirm && d.dry_run === false` 那种合取
198
+ // 读的是「我刚才点的是哪一颗」:确认回来一个 dry_run:true 时会把预览重画一遍当作没发生——
199
+ // 接收弹层那一侧修过同一个形状,撤销这条更硬的路不许退回去。
200
+ assert.ok(clientSource.includes("if (d.dry_run === false) onUndone(d.deleted ?? 0)"),
201
+ "撤销的成功判据只看答复");
202
+ assert.equal(clientSource.includes("confirm && d.dry_run === false"), false,
203
+ "不许再出现按按钮形状推断的那种合取");
204
+ assert.ok(clientSource.includes('t("memory.explorer.undoPackGone")'),
205
+ "包已不在本库时要说那一句,而不是给一颗「确认删除 0 条」的键");
206
+ // 同一形状只许有一份:抽屉的退出清单与接收弹层的撞名清单都是 {type,title} 两列。
207
+ assert.ok(clientSource.includes("TypeRowList({ t, rows: undoPreview.sample })"),
208
+ "清单形状走公共组件,不许第三份手写 .map");
209
+ assert.ok(clientSource.includes('foreignOnly && !!query.trim() && h("div"'),
210
+ "「搜索时不按」要有一行看得见的说明,不能只挂在 disabled 的 title 上");
211
+ });
212
+
213
+ // 外来判据是这条车道唯一一处「面板自己写的字面量」:lib/client.js 是独立 bundle,
214
+ // 不能 import src/memory-source.js,所以用静态镜像把它钉回服务端那一份。
215
+ // 服务端改了那个值而这里没跟,面板的徽标与筛选会安静地筛不到东西——正是最难发现的一种坏。
216
+ test("the panel's foreign-ness literal mirrors the server's single definition", () => {
217
+ const sourceModule = readFileSync(join(here, "..", "src/memory-source.js"), "utf8");
218
+ const imported = /IMPORTED_SOURCE = "([^"]+)"/.exec(sourceModule);
219
+ assert.ok(imported, "src/memory-source.js 的导出形状变了,先修这条锁的锚");
220
+ const value = imported[1];
221
+ assert.ok(clientSource.includes(`m.source === "${value}"`),
222
+ "徽标读的行来源必须与服务端定义的那个值同字面");
223
+ assert.ok(clientSource.includes(`&source=${value}`),
224
+ "浏览态那条 URL 拼的来源值必须是同一个");
225
+ });
@@ -2257,3 +2257,142 @@ test("profile extraction ships its button, distinct outcomes and correct save wi
2257
2257
  // 没配模型是一句可行动的话(先去选模型),不能和「模型没按格式回」共用一条文案。
2258
2258
  assert.ok(clientSource.includes('"no-route" ? "noroute"'), "no-route must map to its own panel outcome, not the generic failure");
2259
2259
  });
2260
+
2261
+ // Issue #521:degraded 轮的跳过明细必须能在面板上看见。此前 skipped 只落库,
2262
+ // 响应里没有聚合,status: degraded + applied: 3 从外面看完全是健康轮的样子——
2263
+ // 一整类动作 100% 被拦要自己写 SQL 才看得出,那个 bug 跑了一整天才被发现。
2264
+ test("dream status card surfaces normalized skip reasons, not just status (issue #521)", () => {
2265
+ // 三个新文案键 zh/en 各一份。
2266
+ for (const key of [
2267
+ "memory.status.skippedTitle",
2268
+ "memory.status.skippedHint",
2269
+ "memory.status.skippedMore"
2270
+ ]) {
2271
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
2272
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
2273
+ }
2274
+
2275
+ // 面板必须真的消费后端给的聚合字段,而不是自己再拼一份(归一化只有一份实现,
2276
+ // 住在 src/dream/decisions.js,与产生这些 error 的校验器同文件)。
2277
+ assert.ok(clientSource.includes("skippedReasons"), "the card must read the aggregated skippedReasons field");
2278
+
2279
+ // 归一化实现只许有一份,且必须在 decisions.js 里(与校验器同文件,措辞不会漂移)。
2280
+ const decisionsSource = readFileSync(new URL("../src/dream/decisions.js", import.meta.url), "utf8");
2281
+ assert.ok(decisionsSource.includes("export function normalizeSkipReason("), "normalization lives in decisions.js");
2282
+ assert.ok(decisionsSource.includes("export function summarizeSkipped("), "aggregation lives in decisions.js");
2283
+ assert.ok(!clientSource.includes("normalizeSkipReason("), "the panel must not re-implement normalization");
2284
+ // 序号前缀必须被剥掉:不剥的话 decision[0]/decision[1] 会被拆成 N 组(#521 实测 280 条拆成 20 组)。
2285
+ // #522 评审:一条决策可能攒下多条错误(用 "; " 拼接),所以剥前缀必须覆盖每一段,
2286
+ // 不是只剥行首那一个——先按 "; decision[N]:" 切段,逐段再剥。
2287
+ assert.ok(/replace\(\/\^decision\\\[\\d\+\\\]:/.test(decisionsSource), "the decision[N]: prefix is stripped");
2288
+ assert.ok(/split\(\/;\\s\*\(\?=decision/.test(decisionsSource), "multi-error strings are split before prefix stripping");
2289
+ });
2290
+
2291
+ // #522 评审:跳过明细原先只挂在 StatusPanel 折叠区里的 DreamStatusCards 上,而那个
2292
+ // 折叠区默认收起、收起时连子卡都不挂载——于是「一整类动作 100% 被拦」在首屏照样
2293
+ // 看不见,等于把 #521 的病换个地方犯。这条锁住:明细由 TokenFlowSection(首屏,
2294
+ // 就在 KPI 带下方)渲染,且只有一份实现(SkippedReasonsNotice)。
2295
+ test("skipped reasons are visible without expanding the collapsed detail section (#522)", () => {
2296
+ assert.ok(clientSource.includes("function SkippedReasonsNotice("),
2297
+ "the notice must be its own component so both call sites share one implementation");
2298
+ // 首屏:TokenFlowSection 的返回值里必须出现这个组件(KPI 带之后、tokenflow 之前)。
2299
+ const tokenFlow = clientSource.slice(
2300
+ clientSource.indexOf("function TokenFlowSection("),
2301
+ clientSource.indexOf("function DreamStatusCards(")
2302
+ );
2303
+ assert.ok(tokenFlow.includes("h(SkippedReasonsNotice,"),
2304
+ "the first screen must render the skip notice, not only the collapsed section");
2305
+ // 折叠区那份不再重复渲染:同一个数字在首屏与展开区各出现一次,读者会以为有两批跳过。
2306
+ const dreamCards = clientSource.slice(
2307
+ clientSource.indexOf("function DreamStatusCards("),
2308
+ clientSource.indexOf("function InjectStatusCard(")
2309
+ );
2310
+ assert.ok(!dreamCards.includes("h(SkippedReasonsNotice,"),
2311
+ "the collapsed section must not render the same notice a second time");
2312
+ assert.ok(!dreamCards.includes("skippedReasons"),
2313
+ "the collapsed card no longer needs its own copy of the skip buckets");
2314
+ });
2315
+ // 设置页目录(#518):用户要的是「左侧一个分类,直接跳到某一节」。这里锁五件事:
2316
+ // ① 目录由 tocGroups 一张表生成,五项分组标题全部走 groupHead(key) 渲染——两处各写
2317
+ // 一遍清单是这类索引最典型的腐烂方式,而腐烂后的目录会把人领到不存在的锚点;
2318
+ // ② 点一下必须真的滚(scrollIntoView),不是摆设;
2319
+ // ③ 文案双语齐备——只有中文键的目录在英文界面下会渲染出键名;④⑤ 两条是布局锁,见断言末尾。
2320
+ test("settings page ships a section index that jumps to each group (#518)", () => {
2321
+ // 目录与标题同源:tocGroups 的每一项都必须从 group.<key> 取文案。
2322
+ const tocBlock = clientSource.match(/const tocGroups = \[([\s\S]*?)\];/);
2323
+ assert.ok(tocBlock, "the settings view must declare tocGroups");
2324
+ const tocKeys = [...tocBlock[1].matchAll(/key: "([^"]+)"/g)].map((m) => m[1]);
2325
+ assert.deepEqual(tocKeys, ["personal", "engine", "search", "connect", "help"],
2326
+ "the index must cover every settings group, in render order");
2327
+ for (const key of tocKeys) {
2328
+ assert.ok(tocBlock[1].includes('t("memory.settings.group.' + key + '")'),
2329
+ 'tocGroups entry "' + key + '" must read its label from memory.settings.group.' + key);
2330
+ // 分组标题只有一条通路:正文里的五个标题都经过 groupHead(key),
2331
+ // 没有一处再手写 className 为 mneme-set-grouphead 的 div。
2332
+ const headCalls = clientSource.split('groupHead("' + key + '")').length - 1;
2333
+ assert.equal(headCalls, 1, 'group "' + key + '" must be rendered exactly once, via groupHead()');
2334
+ }
2335
+ const rawHeads = clientSource.split('className: "mneme-set-grouphead"').length - 1;
2336
+ assert.equal(rawHeads, 1, "no section may hand-write the group head; only groupHead() may (it owns the jump ref)");
2337
+
2338
+ // 跳转必须落到 ref 上并调用 scrollIntoView——目录不滚动等于没有目录。
2339
+ assert.match(clientSource, /tocRefs\.current\[key\] = node/,
2340
+ "every group head must register itself as a jump target");
2341
+ assert.match(clientSource, /node\.scrollIntoView\(\{ block: "start"/,
2342
+ "jumping must scroll the target section into view");
2343
+
2344
+ // 双语:三条新键都要在 zh 与 en 各出现一次(en 块在后,计数 >= 2)。
2345
+ for (const key of [
2346
+ "memory.settings.toc.label",
2347
+ "memory.settings.toc.aria",
2348
+ "memory.settings.toc.jump"
2349
+ ]) {
2350
+ const occurrences = clientSource.split('"' + key + '"').length - 1;
2351
+ assert.ok(occurrences >= 2, "i18n key " + key + " must exist in both zh and en (got " + occurrences + ")");
2352
+ }
2353
+ // 占位符是唯一的插值点:写成裸文案就等于把 "{name}" 直接显示给用户。
2354
+ assert.ok(clientSource.includes('"memory.settings.toc.jump": "跳到「{name}」"'),
2355
+ "the jump tooltip must keep its {name} placeholder");
2356
+
2357
+ // ④ 粘顶条会盖住跳转目标:只锁 scrollIntoView 存在,看不出标题停在目录背后——
2358
+ // 布局里唯一还能在源码上判的一条就是那个偏移,所以钉住它。
2359
+ const headRule = clientSource.match(/"\.mneme-set-grouphead\{([^"]*)\}/);
2360
+ assert.ok(headRule && /scroll-margin-top:\d+px/.test(headRule[1]),
2361
+ "the jump target must clear the sticky index via scroll-margin-top");
2362
+
2363
+ // ⑤ 窄屏是横向滚动,不是换行:换行会把粘顶条撑高,上面那个偏移跟着失准。
2364
+ const listRule = clientSource.match(/"\.mneme-set-toclist\{([^"]*)\}/);
2365
+ assert.ok(listRule && /overflow-x:auto/.test(listRule[1]) && !/flex-wrap:wrap/.test(listRule[1]),
2366
+ "the chip row must scroll sideways on narrow panels instead of wrapping under the sticky bar");
2367
+ });
2368
+
2369
+ // #514:用户以为插件「轻量」关掉了人格定义,实际是宿主 minimal 预设整体压制了注入。
2370
+ // 三道锁防复发:① 设置页那两个会被人开关的卡(运行模式 / 人格)都要能看到这条
2371
+ // 告示,且两处共用同一份取数与同一组文案,不许各自长一套;② 宿主的 minimal 与
2372
+ // 插件的 light 必须在文案里被明确区分,否则两个词在同一个页面里出现就还是混;
2373
+ // ③ 告示只在 suppressed 为真时出现——关掉注入的用户、preset 未知的宿主不该被吓。
2374
+ test("minimal preset notice reaches the settings page, not just the status tab (issue #514)", () => {
2375
+ for (const key of ["memory.status.minimalPresetTitle", "memory.status.minimalPresetBody", "memory.settings.mode.hostPresetHint"]) {
2376
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
2377
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
2378
+ }
2379
+ // ② 区分两个词:文案里必须同时点名宿主的极简/预设与插件的轻量档。
2380
+ assert.ok(clientSource.includes("极简模式(minimal 预设)"), "the zh copy must name the host's minimal preset explicitly");
2381
+ assert.ok(/轻量档不会关闭人格定义|Light never disables persona/.test(clientSource),
2382
+ "the copy must say that light mode does not disable persona — that is the exact misreading in #514");
2383
+
2384
+ // ① 两处消费:告示必须在运行模式卡与人格卡各出现一次,且都读同一个 minimalWarn。
2385
+ const uses = clientSource.split('t("memory.status.minimalPresetTitle")').length - 1;
2386
+ assert.ok(uses >= 2, `the notice must render in both the mode card and the persona card (got ${uses})`);
2387
+ assert.ok(clientSource.includes("const minimalWarn = !injectStatus.loading && injectStatus.suppressed;"),
2388
+ "the notice must be gated on the shared inject-status state, not on a second fetch of its own");
2389
+
2390
+ // 取数只有一份:useInjectStatus 之外不许再有人直接打 /inject-status。
2391
+ const fetches = clientSource.split('"/api/dsh-mneme/inject-status"').length - 1;
2392
+ assert.equal(fetches, 1, "inject-status must have exactly one reader; a second fetch lets the two surfaces drift");
2393
+
2394
+ // 3 未压制时零打扰:settings 里判的必须是 injectStatus.suppressed。
2395
+ assert.ok(!/minimalWarn\s*=\s*true/.test(clientSource), "the notice must never be hard-wired on");
2396
+ });
2397
+
2398
+
@@ -0,0 +1,180 @@
1
+ // Issue #505:saveWithDedupe 的去重候选窗口固定 100 条,库一变大就静默不再合并。
2
+ //
3
+ // 根因:候选集是 store.list({ type, limit: 100 }),order 默认 importance
4
+ // ⇒「该类型最重要的 100 行」,与「找出同标题的那一行」无关。某类型里 importance
5
+ // 高于待写行的行数一旦超过窗口,同标题行与待写行双双落在窗口外,find 恒不命中,
6
+ // 于是走创建分支:不报错、不告警,合并语义随库增长而失效。
7
+ //
8
+ // 这组用例刻意复刻报告里的实测形状(类型 1047 行、其中 importance=5 有 180 行),
9
+ // 但用更小的数字达成同样的窗口溢出,避免测试本身变慢。
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { DatabaseSync } from "node:sqlite";
16
+ import { createStore } from "../src/store.js";
17
+ import { createService } from "../src/service.js";
18
+
19
+ function freshService() {
20
+ const store = createStore(":memory:");
21
+ const service = createService({ store, mirror: null, config: {} });
22
+ return { store, service };
23
+ }
24
+
25
+ // 往某个类型里灌 n 条 importance=5 的行,把窗口挤满。
26
+ function crowdType(store, type, n) {
27
+ for (let i = 0; i < n; i++) {
28
+ store.save({
29
+ type,
30
+ title: `crowd-${type}-${i}`,
31
+ content: `padding row ${i}`,
32
+ importance: 5
33
+ });
34
+ }
35
+ }
36
+
37
+ test("#505: 同类型 importance 更高的行超过窗口后,同标题第二次写入仍然合并", () => {
38
+ const { store, service } = freshService();
39
+ // 窗口是 100。灌 120 条 importance=5,保证待写行(importance=3)与它的同标题
40
+ // 行都排不进前 100。
41
+ crowdType(store, "project", 120);
42
+
43
+ const payload = {
44
+ type: "project",
45
+ title: "dedupe-target",
46
+ content: "first content",
47
+ importance: 3
48
+ };
49
+ const first = service.saveWithDedupe(payload);
50
+ assert.equal(first.action, "created", "第一次写入应当是新建");
51
+
52
+ const second = service.saveWithDedupe({ ...payload, content: "second content" });
53
+ assert.equal(
54
+ second.action,
55
+ "merged",
56
+ "第二次同标题写入必须并入已有行;返回 created 说明候选窗口把同标题行挡在窗口外(#505 静默降级)"
57
+ );
58
+ assert.equal(second.memory.id, first.memory.id, "合并必须落在同一行");
59
+
60
+ const rows = store.list({ type: "project", limit: null })
61
+ .filter((m) => m.title.trim() === "dedupe-target");
62
+ assert.equal(rows.length, 1, "同标题只应存在一行");
63
+ });
64
+
65
+ test("#505: 定向候选不受 importance 分布影响(低 importance 的同标题行也能命中)", () => {
66
+ const { store, service } = freshService();
67
+ // 先写一条 importance=1 的目标行,再用大量 importance=5 的行把它挤出窗口。
68
+ const first = service.saveWithDedupe({
69
+ type: "pattern",
70
+ title: "low-importance-target",
71
+ content: "seed",
72
+ importance: 1
73
+ });
74
+ assert.equal(first.action, "created");
75
+
76
+ crowdType(store, "pattern", 120);
77
+
78
+ const second = service.saveWithDedupe({
79
+ type: "pattern",
80
+ title: "low-importance-target",
81
+ content: "merged later",
82
+ importance: 1
83
+ });
84
+ assert.equal(second.action, "merged", "低 importance 的同标题行同样必须能被找到");
85
+ assert.equal(second.memory.id, first.memory.id);
86
+ });
87
+
88
+ test("#505: 标题首尾空白不影响去重命中(入库不 trim,比较 trim)", () => {
89
+ const { store, service } = freshService();
90
+ crowdType(store, "constraint", 120);
91
+
92
+ const first = service.saveWithDedupe({
93
+ type: "constraint",
94
+ title: " spaced title ",
95
+ content: "seed",
96
+ importance: 3
97
+ });
98
+ assert.equal(first.action, "created");
99
+
100
+ // 同一个标题、首尾空白写法不同——按 trim 口径应当命中同一行。
101
+ const second = service.saveWithDedupe({
102
+ type: "constraint",
103
+ title: "spaced title",
104
+ content: "merged",
105
+ importance: 3
106
+ });
107
+ assert.equal(second.action, "merged", "trim 后同标题必须合并,否则存量带空白的标题会退回静默降级");
108
+ assert.equal(second.memory.id, first.memory.id);
109
+ });
110
+
111
+ test("#505: 定向候选不跨类型命中", () => {
112
+ const { store, service } = freshService();
113
+ crowdType(store, "project", 120);
114
+
115
+ const a = service.saveWithDedupe({
116
+ type: "project",
117
+ title: "same-title-different-type",
118
+ content: "in project",
119
+ importance: 3
120
+ });
121
+ const b = service.saveWithDedupe({
122
+ type: "preference",
123
+ title: "same-title-different-type",
124
+ content: "in preference",
125
+ importance: 3
126
+ });
127
+ assert.equal(a.action, "created");
128
+ assert.equal(b.action, "created", "不同 type 的同标题行不得合并");
129
+ assert.notEqual(a.memory.id, b.memory.id);
130
+ });
131
+
132
+ test("#505: 归档行不作为合并目标(口径与 list 默认一致)", () => {
133
+ const { store, service } = freshService();
134
+ crowdType(store, "decision", 120);
135
+
136
+ const first = service.saveWithDedupe({
137
+ type: "decision",
138
+ title: "archived-target",
139
+ content: "seed",
140
+ importance: 3
141
+ });
142
+ // 归档必须走 store.setArchived——store.update 的 SET 子句里没有 archived 列,
143
+ // 用 update({archived:true}) 根本不会归档,测试会假绿。
144
+ store.setArchived(first.memory.id, true);
145
+
146
+ const second = service.saveWithDedupe({
147
+ type: "decision",
148
+ title: "archived-target",
149
+ content: "after archive",
150
+ importance: 3
151
+ });
152
+ assert.equal(second.action, "created", "已归档行不该被当作活跃合并目标");
153
+ assert.notEqual(second.memory.id, first.memory.id);
154
+ });
155
+
156
+
157
+ // #505 把「取该类型前 100 条」换成「按 type + TRIM(title) 精确取」:窗口没了、静默不
158
+ // 合并也没了,但 SQL 形状变了——代价随最大类型的行数线性长(20000 行时 4.6ms 一次,
159
+ // 每存一条付一次),所以 store.js 里配了 (type, TRIM(title)) 表达式索引。这条用例不测
160
+ // 快慢(机器有关节,数字不可复现),测的是**那条索引真的被这条查询选中**:表达式写歪
161
+ // 一点(漏 TRIM、列序不对)优化器就静默退回扫整个类型,而本文件其余用例全绿。
162
+ test("#505: 定向候选那条 SQL 命中表达式索引,而不是扫完整个类型", () => {
163
+ const dir = mkdtempSync(join(tmpdir(), "mneme-dedupe-idx-"));
164
+ const file = join(dir, "m.db");
165
+ const store = createStore(file);
166
+ store.save({ type: "project", title: " spaced ", content: "x", importance: 3 });
167
+ store.close();
168
+ const db = new DatabaseSync(file);
169
+ try {
170
+ // WHERE 与 store.js 的 activeByTypeAndTitle 逐字同形,只把列表换成单列。
171
+ const plan = db.prepare(
172
+ "EXPLAIN QUERY PLAN SELECT id FROM memories WHERE type = ? AND TRIM(title) = ? AND forgotten = 0 AND archived = 0"
173
+ ).all("project", "spaced").map((r) => r.detail).join(" ");
174
+ assert.match(plan, /idx_memories_type_trimmed_title/,
175
+ `定向候选必须走 (type, TRIM(title)) 表达式索引,实得:${plan}`);
176
+ } finally {
177
+ db.close();
178
+ rmSync(dir, { recursive: true, force: true });
179
+ }
180
+ });
@@ -39,8 +39,8 @@ function seed(service, vectorIndex, title, vec) {
39
39
  }
40
40
 
41
41
  /** Capture the candidate titles the LLM was handed. */
42
- // 注意:不能用 helpers 的 mockCtx —— 它靠 userText.startsWith("id=") 区分阶段,而
43
- // semantic 可用时候选列表以「# 聚类 N」开头(语义预分组),会被误判成 summary。
42
+ // 注:helpers 的 mockCtx 过去靠 userText.startsWith("id=") 区分阶段,semantic 预分组把清单
43
+ // 头换成「# 聚类 N」就误判成 summary;#501/#493 把它的判据换成了清单形状(includes("| type=")),
44
44
  // 这里改用 system prompt 判定,与 listText 的形态解耦。
45
45
  function stubCtx(consolidationReply) {
46
46
  const seen = { titles: [] };