@modusensus/dsh-mneme 0.9.4 → 0.10.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 (126) hide show
  1. package/README.md +57 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +25 -2
  4. package/docs/DAEMON.md +13 -2
  5. package/docs/DISTILL.md +150 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +21 -7
  8. package/docs/STORAGE.md +2 -1
  9. package/lib/api-standalone.js +294 -8
  10. package/lib/api.js +217 -3
  11. package/lib/client.js +919 -39
  12. package/lib/config.js +33 -1
  13. package/lib/continuity.js +11 -3
  14. package/lib/document.js +15 -2
  15. package/lib/dream/decisions.js +176 -30
  16. package/lib/dream/sleep.js +530 -94
  17. package/lib/dream.js +174 -21
  18. package/lib/fence.js +24 -0
  19. package/lib/index.js +15 -6
  20. package/lib/inject.js +9 -4
  21. package/lib/lang.js +38 -16
  22. package/lib/memory-source.js +9 -0
  23. package/lib/organize.js +5 -1
  24. package/lib/pipeline-nudge-auto.js +3 -0
  25. package/lib/pipeline-nudge.js +41 -8
  26. package/lib/pipeline-runner.js +15 -0
  27. package/lib/pipeline.js +62 -1
  28. package/lib/pre-inject-gate.js +14 -0
  29. package/lib/profile-extract.js +4 -1
  30. package/lib/provenance-http.js +251 -0
  31. package/lib/serve.js +13 -0
  32. package/lib/service.js +97 -4
  33. package/lib/session-gate.js +91 -0
  34. package/lib/settings.js +13 -1
  35. package/lib/share-http.js +281 -0
  36. package/lib/share-import.js +234 -0
  37. package/lib/share.js +335 -0
  38. package/lib/store.js +797 -28
  39. package/lib/summarize.js +209 -23
  40. package/lib/tools.js +26 -6
  41. package/package.json +4 -2
  42. package/src/api-standalone.js +294 -8
  43. package/src/api.js +217 -3
  44. package/src/config.js +33 -1
  45. package/src/continuity.js +11 -3
  46. package/src/document.js +15 -2
  47. package/src/dream/decisions.js +176 -30
  48. package/src/dream/sleep.js +530 -94
  49. package/src/dream.js +174 -21
  50. package/src/fence.js +24 -0
  51. package/src/index.js +15 -6
  52. package/src/inject.js +9 -4
  53. package/src/lang.js +38 -16
  54. package/src/memory-source.js +9 -0
  55. package/src/organize.js +5 -1
  56. package/src/pipeline-nudge-auto.js +3 -0
  57. package/src/pipeline-nudge.js +41 -8
  58. package/src/pipeline-runner.js +15 -0
  59. package/src/pipeline.js +62 -1
  60. package/src/pre-inject-gate.js +14 -0
  61. package/src/profile-extract.js +4 -1
  62. package/src/provenance-http.js +251 -0
  63. package/src/serve.js +13 -0
  64. package/src/service.js +97 -4
  65. package/src/session-gate.js +91 -0
  66. package/src/settings.js +13 -1
  67. package/src/share-http.js +281 -0
  68. package/src/share-import.js +234 -0
  69. package/src/share.js +335 -0
  70. package/src/store.js +797 -28
  71. package/src/summarize.js +209 -23
  72. package/src/tools.js +26 -6
  73. package/test/api.test.js +523 -6
  74. package/test/audit.test.js +2 -2
  75. package/test/cli-receive.test.js +390 -0
  76. package/test/cli-share.test.js +437 -0
  77. package/test/client-share.test.js +225 -0
  78. package/test/client.test.js +354 -0
  79. package/test/config.test.js +8 -0
  80. package/test/conflict-reviewed.test.js +79 -0
  81. package/test/continuity.test.js +29 -1
  82. package/test/dedupe-candidate-window.test.js +180 -0
  83. package/test/dependency-overrides.test.js +78 -0
  84. package/test/differentiate-feedback.test.js +248 -0
  85. package/test/dream-candidate-hybrid.test.js +2 -2
  86. package/test/dream-decision-evidence.test.js +314 -0
  87. package/test/dream-fence.test.js +207 -0
  88. package/test/dream.test.js +87 -5
  89. package/test/helpers/dream-mock.js +6 -2
  90. package/test/inject-pools.test.js +2 -1
  91. package/test/llm-message-source.test.js +1 -1
  92. package/test/mint-source.test.js +84 -1
  93. package/test/pipeline-nudge-auto.test.js +26 -2
  94. package/test/pipeline-nudge.test.js +87 -0
  95. package/test/pipeline-r1r2.test.js +129 -0
  96. package/test/pipeline-runner.test.js +26 -0
  97. package/test/policy-epoch.test.js +2 -2
  98. package/test/prompt-stance.test.js +128 -0
  99. package/test/provenance-http.test.js +367 -0
  100. package/test/readme-claims.test.js +45 -0
  101. package/test/reasoning-effort.test.js +10 -10
  102. package/test/release-docs.test.js +7 -1
  103. package/test/scope-candidates.test.js +7 -4
  104. package/test/scope-state.test.js +130 -1
  105. package/test/security-doc.test.js +43 -4
  106. package/test/serve.test.js +62 -0
  107. package/test/session-gate.test.js +264 -0
  108. package/test/settings.test.js +4 -0
  109. package/test/share-foreign-rows.test.js +144 -0
  110. package/test/share-http.test.js +144 -0
  111. package/test/share-import.test.js +577 -0
  112. package/test/share-imported-guard.test.js +311 -0
  113. package/test/share-imported-identity.test.js +111 -0
  114. package/test/share-pack.test.js +511 -0
  115. package/test/share-receive-http.test.js +756 -0
  116. package/test/share-routes.test.js +337 -0
  117. package/test/share-store.test.js +133 -0
  118. package/test/share-validate.test.js +101 -0
  119. package/test/sleep-action-set.test.js +16 -11
  120. package/test/sleep-catchup.test.js +315 -0
  121. package/test/sleep-failure-backoff.test.js +156 -0
  122. package/test/sleep.test.js +293 -0
  123. package/test/standalone-api.test.js +574 -5
  124. package/test/store.test.js +220 -0
  125. package/test/summarize-parse-diagnostics.test.js +75 -0
  126. package/test/summarize.test.js +187 -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,357 @@ 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
+
2316
+ // #495:organize 与巩固共用 dream_runs,一次 agent 主动整理写两行。这张卡的标题是
2317
+ // 「最近巩固」,若面板继续拿 lastRun 取数,一次整理就把卡面变成整理回执——正是
2318
+ // issue 报的那个现象。锁三件事:① 卡面读 lastConsolidation;② 退回 lastRun 时必须
2319
+ // 先排除 organize(旧服务端没有新键,不能拿 organize 行冒充巩固);③ KPI 带那块
2320
+ // 「上一轮跳过什么」同样改读巩固轮,organize 的 skipped 不是巩固决策。
2321
+ test("dream status card reads the consolidation run, not whatever ran last (issue #495)", () => {
2322
+ const dreamCards = clientSource.slice(
2323
+ clientSource.indexOf("function DreamStatusCards("),
2324
+ clientSource.indexOf("function InjectStatusCard(")
2325
+ );
2326
+ assert.ok(dreamCards.includes("state.lastConsolidation"),
2327
+ "the card must consume lastConsolidation from /dream-status");
2328
+ assert.ok(dreamCards.includes('state.lastRun.run_type === "organize"'),
2329
+ "falling back to lastRun must still refuse an organize row — an old server may omit the new key");
2330
+ assert.ok(/const run = state\.lastConsolidation\s*\n?\s*\|\| \(state\.lastRun && !organizedOnly/.test(dreamCards)
2331
+ || dreamCards.includes("|| (state.lastRun && !organizedOnly"),
2332
+ "the fallback branch must skip the row it just identified as organize");
2333
+ // 脚注:最近一次后台运行确实是 organize 时点名它,而不是静默把它当巩固。
2334
+ assert.ok(dreamCards.includes("memory.status.dreamOrganizeOnly"),
2335
+ "an organize-only history must be named in a footnote, not shown as a consolidation");
2336
+ // ① 的取数还必须晚于本卡自己那次 fetch,不能只是把键名写进注释里。
2337
+ const fetchIdx = dreamCards.indexOf('apiFetch("/api/dsh-mneme/dream-status")');
2338
+ const useIdx = dreamCards.indexOf("state.lastConsolidation");
2339
+ assert.ok(fetchIdx >= 0 && useIdx > fetchIdx, "the key must be read from the /dream-status payload");
2340
+
2341
+ const tokenFlow = clientSource.slice(
2342
+ clientSource.indexOf("function TokenFlowSection("),
2343
+ clientSource.indexOf("function DreamStatusCards(")
2344
+ );
2345
+ assert.ok(tokenFlow.includes("d.lastConsolidation || d.lastRun"),
2346
+ "the skip-reason KPI belongs to consolidation; organize skipped buckets would read as blocked consolidation");
2347
+ });
2348
+ // 设置页目录(#518):用户要的是「左侧一个分类,直接跳到某一节」。这里锁五件事:
2349
+ // ① 目录由 tocGroups 一张表生成,五项分组标题全部走 groupHead(key) 渲染——两处各写
2350
+ // 一遍清单是这类索引最典型的腐烂方式,而腐烂后的目录会把人领到不存在的锚点;
2351
+ // ② 点一下必须真的滚(scrollIntoView),不是摆设;
2352
+ // ③ 文案双语齐备——只有中文键的目录在英文界面下会渲染出键名;④⑤ 两条是布局锁,见断言末尾。
2353
+ test("settings page ships a section index that jumps to each group (#518)", () => {
2354
+ // 目录与标题同源:tocGroups 的每一项都必须从 group.<key> 取文案。
2355
+ const tocBlock = clientSource.match(/const tocGroups = \[([\s\S]*?)\];/);
2356
+ assert.ok(tocBlock, "the settings view must declare tocGroups");
2357
+ const tocKeys = [...tocBlock[1].matchAll(/key: "([^"]+)"/g)].map((m) => m[1]);
2358
+ assert.deepEqual(tocKeys, ["personal", "engine", "search", "connect", "help"],
2359
+ "the index must cover every settings group, in render order");
2360
+ for (const key of tocKeys) {
2361
+ assert.ok(tocBlock[1].includes('t("memory.settings.group.' + key + '")'),
2362
+ 'tocGroups entry "' + key + '" must read its label from memory.settings.group.' + key);
2363
+ // 分组标题只有一条通路:正文里的五个标题都经过 groupHead(key),
2364
+ // 没有一处再手写 className 为 mneme-set-grouphead 的 div。
2365
+ const headCalls = clientSource.split('groupHead("' + key + '")').length - 1;
2366
+ assert.equal(headCalls, 1, 'group "' + key + '" must be rendered exactly once, via groupHead()');
2367
+ }
2368
+ const rawHeads = clientSource.split('className: "mneme-set-grouphead"').length - 1;
2369
+ assert.equal(rawHeads, 1, "no section may hand-write the group head; only groupHead() may (it owns the jump ref)");
2370
+
2371
+ // 跳转必须落到 ref 上并调用 scrollIntoView——目录不滚动等于没有目录。
2372
+ assert.match(clientSource, /tocRefs\.current\[key\] = node/,
2373
+ "every group head must register itself as a jump target");
2374
+ assert.match(clientSource, /node\.scrollIntoView\(\{ block: "start"/,
2375
+ "jumping must scroll the target section into view");
2376
+
2377
+ // 双语:三条新键都要在 zh 与 en 各出现一次(en 块在后,计数 >= 2)。
2378
+ for (const key of [
2379
+ "memory.settings.toc.label",
2380
+ "memory.settings.toc.aria",
2381
+ "memory.settings.toc.jump"
2382
+ ]) {
2383
+ const occurrences = clientSource.split('"' + key + '"').length - 1;
2384
+ assert.ok(occurrences >= 2, "i18n key " + key + " must exist in both zh and en (got " + occurrences + ")");
2385
+ }
2386
+ // 占位符是唯一的插值点:写成裸文案就等于把 "{name}" 直接显示给用户。
2387
+ assert.ok(clientSource.includes('"memory.settings.toc.jump": "跳到「{name}」"'),
2388
+ "the jump tooltip must keep its {name} placeholder");
2389
+
2390
+ // ④ 粘顶条会盖住跳转目标:只锁 scrollIntoView 存在,看不出标题停在目录背后——
2391
+ // 布局里唯一还能在源码上判的一条就是那个偏移,所以钉住它。
2392
+ const headRule = clientSource.match(/"\.mneme-set-grouphead\{([^"]*)\}/);
2393
+ assert.ok(headRule && /scroll-margin-top:\d+px/.test(headRule[1]),
2394
+ "the jump target must clear the sticky index via scroll-margin-top");
2395
+
2396
+ // ⑤ 窄屏是横向滚动,不是换行:换行会把粘顶条撑高,上面那个偏移跟着失准。
2397
+ const listRule = clientSource.match(/"\.mneme-set-toclist\{([^"]*)\}/);
2398
+ assert.ok(listRule && /overflow-x:auto/.test(listRule[1]) && !/flex-wrap:wrap/.test(listRule[1]),
2399
+ "the chip row must scroll sideways on narrow panels instead of wrapping under the sticky bar");
2400
+ });
2401
+
2402
+ // #514:用户以为插件「轻量」关掉了人格定义,实际是宿主 minimal 预设整体压制了注入。
2403
+ // 三道锁防复发:① 设置页那两个会被人开关的卡(运行模式 / 人格)都要能看到这条
2404
+ // 告示,且两处共用同一份取数与同一组文案,不许各自长一套;② 宿主的 minimal 与
2405
+ // 插件的 light 必须在文案里被明确区分,否则两个词在同一个页面里出现就还是混;
2406
+ // ③ 告示只在 suppressed 为真时出现——关掉注入的用户、preset 未知的宿主不该被吓。
2407
+ test("minimal preset notice reaches the settings page, not just the status tab (issue #514)", () => {
2408
+ for (const key of ["memory.status.minimalPresetTitle", "memory.status.minimalPresetBody", "memory.settings.mode.hostPresetHint"]) {
2409
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
2410
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
2411
+ }
2412
+ // ② 区分两个词:文案里必须同时点名宿主的极简/预设与插件的轻量档。
2413
+ assert.ok(clientSource.includes("极简模式(minimal 预设)"), "the zh copy must name the host's minimal preset explicitly");
2414
+ assert.ok(/轻量档不会关闭人格定义|Light never disables persona/.test(clientSource),
2415
+ "the copy must say that light mode does not disable persona — that is the exact misreading in #514");
2416
+
2417
+ // ① 两处消费:告示必须在运行模式卡与人格卡各出现一次,且都读同一个 minimalWarn。
2418
+ const uses = clientSource.split('t("memory.status.minimalPresetTitle")').length - 1;
2419
+ assert.ok(uses >= 2, `the notice must render in both the mode card and the persona card (got ${uses})`);
2420
+ assert.ok(clientSource.includes("const minimalWarn = !injectStatus.loading && injectStatus.suppressed;"),
2421
+ "the notice must be gated on the shared inject-status state, not on a second fetch of its own");
2422
+
2423
+ // 取数只有一份:useInjectStatus 之外不许再有人直接打 /inject-status。
2424
+ const fetches = clientSource.split('"/api/dsh-mneme/inject-status"').length - 1;
2425
+ assert.equal(fetches, 1, "inject-status must have exactly one reader; a second fetch lets the two surfaces drift");
2426
+
2427
+ // 3 未压制时零打扰:settings 里判的必须是 injectStatus.suppressed。
2428
+ assert.ok(!/minimalWarn\s*=\s*true/.test(clientSource), "the notice must never be hard-wired on");
2429
+ });
2430
+
2431
+ // --- 移动端适配(mobile-adapt 车道)---
2432
+ // 面板在 320–430px 容器里原本是「骨架级失衡」而不是「略挤」:记忆页左栏固定
2433
+ // 216px、卡片最小轨道 252+48=300px,要 ≥517px 才排得下;实体页 rail 224px +
2434
+ // 属性网格 200+48=248px,要 ≥473px。目标区间上沿只有 430,靠收窄数值永远追
2435
+ // 不上,所以适配必须换骨架(row → column)而不是继续动宽度。
2436
+ // 下面这组锁防三类回归:
2437
+ // ① 有人把新规则写成 @media——面板有两种形态(overlay 弹层与 better-sidebar
2438
+ // 工作台 tab),容器宽度与视口宽度并不相等,@media 只在 overlay 形态对,
2439
+ // tab 形态会整块失效;
2440
+ // ② 有人在双花括号块里写 color —— contrast.test.js 的规则提取器按单层花括号
2441
+ // 匹配,\`@container{...{...}}\` 整串不匹配会被跳过,等于绕过 #178 的 AA 主锁
2442
+ // (静默失效,比写错颜色更糟);\`background\` 同理,它是合成对比度的基底;
2443
+ // ③ 有人删掉 \`min(...,100%)\` 的最小轨道下限——只要容器比轨道窄,网格就会横向
2444
+ // 溢出,而外层 .mneme-xcontent 是 overflow:hidden,溢出部分直接被切掉。
2445
+ test("mobile: the narrow-container breakpoints reflow the two-column skeletons instead of shrinking numbers", () => {
2446
+ const cssStart = clientSource.indexOf("const css = [");
2447
+ const cssEnd = clientSource.indexOf('].join("\\n");', cssStart);
2448
+ assert.ok(cssStart > 0 && cssEnd > cssStart, "lib/client.js 里找不到 CSS 数组(切片锚点变了?)");
2449
+ const cssBlock = clientSource.slice(cssStart, cssEnd);
2450
+
2451
+ // 断点表只许三档:640(原收窄档,数值不动)、480(本轮主断点)、360(图标/内边距)。
2452
+ for (const max of ["480px", "360px"]) {
2453
+ assert.ok(
2454
+ cssBlock.includes("@container (max-width: " + max + "){"),
2455
+ "@container (max-width: " + max + ") 必须存在——" + max + " 是移动端骨架换向档,删了就等于整套适配没生效"
2456
+ );
2457
+ }
2458
+ // 480 是主断点:记忆页与实体页都要换向,且 rail/xside 满宽、限高、可滚。
2459
+ const c480 = /@container \(max-width: 480px\)\{([\s\S]*?)\}\*?\*?\s*",/.exec(cssBlock);
2460
+ assert.ok(c480, "480 档规则必须是一条完整的 CSS 字符串(单行,便于本测试切片)");
2461
+ const body = c480[1];
2462
+ for (const sel of [".mneme-xmain{flex-direction:column}", ".mneme-ent{flex-direction:column}"]) {
2463
+ assert.ok(body.includes(sel), "480 档必须让 " + sel + " —— 左右分栏在窄容器里只能改上下");
2464
+ }
2465
+ assert.ok(/\.mneme-xside\{[^}]*width:100%/.test(body), "记忆页左栏在窄容器里必须满宽");
2466
+ assert.ok(/\.mneme-xside\{[^}]*max-height:min\(/.test(body),
2467
+ "记忆页左栏必须限高可滚:6 段筛选集群在 320px 下自然高 260–320px,不限高会把主区挤成 0");
2468
+ assert.ok(/\.mneme-entrail\{[^}]*width:100%/.test(body), "实体页 rail 在窄容器里必须满宽");
2469
+ assert.ok(/\.mneme-entrail\{[^}]*max-height:min\(/.test(body), "实体页 rail 必须限高可滚");
2470
+ // 换向之后这两条 rail 是「限高」的,限高必须能真收缩:flex:none 在矮工作区里
2471
+ // 不肯让位——42vh 只是上限、不是需求,可 flex:none 让它按内容撑满,主区被
2472
+ // .mneme-xmain 的 overflow:hidden 直接裁成 0 高(CodeRabbit #542)。改成可收缩
2473
+ // 的 flex:0 1 auto 后,min-height:0 才允许它压到上限以下。
2474
+ for (const sel of [".mneme-xside", ".mneme-entrail"]) {
2475
+ assert.ok(new RegExp("\\" + sel + "\\{[^}]*flex:0 1 auto").test(body),
2476
+ sel + " 在窄容器里必须可收缩(flex:0 1 auto),flex:none 会在矮视口里把主区挤没");
2477
+ assert.ok(new RegExp("\\" + sel + "\\{[^}]*min-height:0").test(body),
2478
+ sel + " 必须带 min-height:0,否则 flex 项的内容高度下限会让收缩失效");
2479
+ }
2480
+ // overscroll-behavior 不继承,只能写在真正的滚动容器上:.mneme-entrail 的 base 规则
2481
+ // 是 overflow:hidden(lib/client.js:2042),滚动的是内层 .mneme-entrailbody
2482
+ // (lib/client.js:2046 的 overflow-y:auto)。写在 rail 上等于没写,触屏滚到底会
2483
+ // 串到外层(Copilot #542)。.mneme-xside 的 base 本身是滚动容器,写在它身上对。
2484
+ assert.ok(/\.mneme-entrailbody\{[^}]*overscroll-behavior:contain/.test(body),
2485
+ "滚动链的 overscroll-behavior 必须写在 .mneme-entrailbody(真滚动容器)上,写在 .mneme-entrail 上无效");
2486
+ assert.ok(!/\.mneme-entrail\{[^}]*overscroll-behavior/.test(body),
2487
+ ".mneme-entrail 是 overflow:hidden 的非滚动容器,overscroll-behavior 写在它身上是死代码");
2488
+ // 抽屉是覆盖层,窄容器里 52% 只剩 166px;满宽不丢关闭入口(✕ 在 .mneme-drawerbar)。
2489
+ assert.ok(body.includes(".mneme-drawer{width:100%}"), "详情抽屉在窄容器里必须满宽");
2490
+ // 网格轨道的 100% 下限不再锚定在本档:它的作用域是「所有宽度」,而 481–519px
2491
+ // (480 主断点之上、两栏又排不下的中间带)恰恰落回基础规则。锁在基础规则上,
2492
+ // 见本用例末尾的 topLevel 那组。本档只保留窄屏特有的 padding 与滚动链。
2493
+ // 固定宽弹层在 320px 会顶边:340px 的分享弹层比视口还宽。
2494
+ assert.ok(/\.mneme-sharedialog\{[^}]*calc\(100cqw - 32px\)/.test(body), "分享弹层宽度必须跟着容器收");
2495
+ assert.ok(/\.mneme-impdialog\{[^}]*calc\(100cqw - 32px\)/.test(body), "导入弹层宽度必须跟着容器收");
2496
+ // 页签的横向滚动必须住在 480 主断点,而不是 360 兜底档:4 个带图标的 tab 约 436px,
2497
+ // 而 480 档给 .mneme-xbar 的可用宽度是容器宽减 62px(左 10 + 右 52 让位给绝对定位
2498
+ // 的 ⋯),480px 时只剩 418px——整段 361–480px 都会把页签顶到 ⋯ 底下。写错档位的
2499
+ // 失败形态最难查:窄屏照样溢出,而只锁「360 档有这条规则」的测试全绿。
2500
+ assert.ok(/\.mneme-vtabs\{[^}]*min-width:0[^}]*overflow-x:auto/.test(body),
2501
+ "页签横向滚动属于 480 档:只在 360 档兜底等于把溢出留在主断点的整个区间");
2502
+ // 三处窄屏宽度都得带 box-sizing:border-box:全文件没有 *{box-sizing} 兜底,声明的
2503
+ // 那个数是内容宽,外宽还要再加 padding+border——impdialog +30px、menu +12px,320px
2504
+ // 视口下比屏幕还宽,会被 overlay 裁掉。CodeRabbit 只点了 sharedialog,另两处同病。
2505
+ for (const sel of ["menu", "impdialog", "sharedialog"]) {
2506
+ assert.ok(new RegExp("\\.mneme-" + sel + "\\{[^}]*box-sizing:border-box").test(body),
2507
+ ".mneme-" + sel + " 的窄屏宽度必须按外宽算(box-sizing:border-box),否则声明值加完 padding+border 就溢出容器");
2508
+ }
2509
+
2510
+ // .mneme-overlay 不在 .mneme-x 之内,容器查询够不到它——它只能走 @media,
2511
+ // 且必须保留基线声明(client.test.js 上有 width:min(1240px,calc(100vw - 88px)) 的锁)。
2512
+ assert.match(cssBlock, /@media \(max-width: 640px\)\{\.mneme-overlay\{/,
2513
+ ".mneme-overlay 是容器查询够不到的祖先,它的移动端外框必须用 @media 覆盖");
2514
+
2515
+ // ② 双花括号块内不得出现颜色声明(见文件头注释的失效机制)。
2516
+ const nested = [...cssBlock.matchAll(/@(?:container|media)[^{]*\{/g)];
2517
+ assert.ok(nested.length >= 4, "移动端适配应当新增 ≥4 条 @container/@media 规则(当前 " + nested.length + ")");
2518
+ for (const m of nested) {
2519
+ const at = m.index + m[0].length;
2520
+ let depth = 1, i = at;
2521
+ while (i < cssBlock.length && depth > 0) {
2522
+ if (cssBlock[i] === "{") depth++;
2523
+ else if (cssBlock[i] === "}") depth--;
2524
+ i++;
2525
+ }
2526
+ const inner = cssBlock.slice(at, i - 1);
2527
+ const hit = /(?:^|[;{])\s*(color|fill|stroke|background(?:-color)?)\s*:/.exec(inner);
2528
+ assert.equal(hit, null,
2529
+ "@container/@media 块内不得写颜色(" + (hit && hit[1]) + "):contrast.test.js 的单层花括号提取器会整串跳过该块," +
2530
+ "写进去的色值不会被 AA 主锁审计——这是静默绕过 #178,不是优化");
2531
+ }
2532
+
2533
+ // 触屏没有 hover:设置页删除钮必须常显(#457/#459 两次回滚都是因为连带改了 hover 配色,
2534
+ // 这里只许加 opacity:1,不许在同一条里再动 color)。
2535
+ assert.match(cssBlock, /@media \(hover:none\)\{\.mneme-set-del\{opacity:1/,
2536
+ "触屏设备上 .mneme-set-del 必须常显——它的 base 是 opacity:0,只靠 :hover 恢复,手机上等于永久不可见");
2537
+ // 断点之外还有一段中间带:481–519px 容器宽(480 主断点之上、两栏又排不下)。
2538
+ // 这一段落回基础规则,所以 min(…,100%) 的轨道下限必须写在基础规则上,只在断点里
2539
+ // 写等于给中间带留缺口。本组锁都作用在「剥掉嵌套块之后的顶层规则」上——否则
2540
+ // 480 档里的同名声明会让断言假绿。
2541
+ const topLevel = (() => {
2542
+ let out = "";
2543
+ let i = 0;
2544
+ while (i < cssBlock.length) {
2545
+ const at = cssBlock.indexOf("@", i);
2546
+ out += cssBlock.slice(i, at === -1 ? cssBlock.length : at);
2547
+ if (at === -1) break;
2548
+ const open = cssBlock.indexOf("{", at);
2549
+ if (open === -1) { out += cssBlock.slice(at); break; }
2550
+ let depth = 0, j = open;
2551
+ while (j < cssBlock.length) {
2552
+ if (cssBlock[j] === "{") depth++;
2553
+ else if (cssBlock[j] === "}") { depth--; if (depth === 0) { j++; break; } }
2554
+ j++;
2555
+ }
2556
+ i = j;
2557
+ }
2558
+ return out;
2559
+ })();
2560
+ assert.ok(topLevel.length > 10000, "顶层规则切片异常(" + topLevel.length + " 字符)——切片实现坏了,下面的锁会假绿");
2561
+ assert.match(topLevel, /\.mneme-cards\{[^}]*minmax\(min\(252px,100%\),1fr\)/,
2562
+ "卡片网格的 100% 轨道下限必须写在基础规则上:481–519px 是 480 主断点之外的中间带," +
2563
+ "只在断点里兜底等于让这一段横向溢出 2–11px(外层 overflow:hidden 直接切掉)");
2564
+ assert.match(topLevel, /\.mneme-attrgrid\{[^}]*minmax\(min\(200px,100%\),1fr\)/,
2565
+ "属性网格同理:481–519px 时实体页 detail 只剩 257–276px,裸 200px 下限会溢出");
2566
+ // 图谱的 touch-action 保持 none(这里是从 pan-y 回退后补的锁)。pan-y 那一版想解决
2567
+ // 「手指在图上竖划时页面滚不动」,但它同时把纵向手势交给浏览器做页面平移,而图谱的
2568
+ // 拖拽只挂了 mousedown/mousemove(全仓 onPointerDown/onTouchStart 命中 0)——触屏上
2569
+ // 纵向拖拽到底走哪条路,浏览器是否派发兼容鼠标事件,没有真机无法定论。宁可保留现状
2570
+ // 也不做无法复验的行为变更;真要两步都能用,得改成 Pointer Events,另开车道。
2571
+ assert.match(topLevel, /\.mneme-graphsvg\{[^}]*touch-action:none/,
2572
+ "图谱的 touch-action 必须写在基础规则上:481–519px 这段中间带落回基础规则," +
2573
+ "只在断点里声明等于给中间带留缺口");
2574
+ assert.doesNotMatch(topLevel, /\.mneme-graphsvg\{[^}]*touch-action:pan-y/,
2575
+ ".mneme-graphsvg 不得改回 touch-action:pan-y —— 纵向手势会被浏览器拿去滚页面," +
2576
+ "而图谱的拖拽只有鼠标事件实现,触屏拖拽行为无真机无法验证(见上方注释)");
2577
+ });
2578
+
2579
+ // Issue #512:面板详情抽屉要能说清「这条记忆是从哪来的」。
2580
+ //
2581
+ // 归因分两层:窗口级(哪一段原文蒸出来的)与行级(哪一轮铸造的)。这两层
2582
+ // 任何一层缺失都必须显示成「没有归因」,而不是让用户看到空白以为自己眼花了。
2583
+ test("memory drawer surfaces distillation provenance, both layers (issue #512)", () => {
2584
+ for (const key of [
2585
+ "memory.explorer.detail.provenance",
2586
+ "memory.explorer.detail.provenanceEmpty",
2587
+ "memory.explorer.detail.provenanceMint",
2588
+ "memory.explorer.detail.provenanceWindow",
2589
+ "memory.explorer.detail.provenanceNoRun",
2590
+ "memory.explorer.detail.provenanceRecords"
2591
+ ]) {
2592
+ // 只数字典定义处("key": 形态)——键在渲染里还会被 t("…") 再引一次,
2593
+ // 数全量出现次数会把两种用法混为一谈。
2594
+ const defined = clientSource.split(`"${key}":`).length - 1;
2595
+ assert.equal(defined, 2, `i18n key ${key} must be defined exactly once in zh and once in en (got ${defined})`);
2596
+ }
2597
+
2598
+ // 必须真的去读那一个端点,且是读不是算——归因的判据只有后端一处。
2599
+ assert.ok(clientSource.includes("/api/dsh-mneme/memory/provenance?id="),
2600
+ "the drawer must fetch the provenance endpoint");
2601
+ // 两层都要渲染:只渲染窗口会让铸造出来的 pattern 永远显示「没有来源」,
2602
+ // 而它们恰恰是归因最该说清的一类(没有会话区间可看)。
2603
+ // 断到 .map( 为止:只写 "provenance.mints" 会被空态判断里的 .length 满足,
2604
+ // 于是「渲染被整段删掉」这种回归照样能过(本用例的变异自证就是从这里翻车的)。
2605
+ assert.ok(clientSource.includes("provenance.mints.map("), "mint-level attribution (which run minted the row) must render");
2606
+ assert.ok(clientSource.includes("provenance.windows.map("), "window-level attribution (which transcript) must render");
2607
+ assert.ok(clientSource.includes("\"memory.explorer.detail.provenanceNoRun\""),
2608
+ "a legacy link without a run id must say so explicitly, not render an empty cell");
2609
+
2610
+ // 读失败要落回「没读到」而不是伪造一份空归因:两者在界面上是不同的话。
2611
+ assert.ok(clientSource.includes("/api/dsh-mneme/memory/provenance?id=") && clientSource.includes("setProvenance(null)"),
2612
+ "a failed read must clear the block instead of pretending there is no attribution");
2613
+ });
@@ -4,6 +4,14 @@ import { readFileSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Config } from "../src/config.js";
6
6
 
7
+ test("session incognito is opt-in, with a default-on subordinate selector", () => {
8
+ const cfg = Config({});
9
+ assert.equal(cfg.sessionGateEnabled, false);
10
+ assert.equal(cfg.sessionGateSubagents, true);
11
+ assert.deepEqual(cfg.sessionGateSessions, []);
12
+ assert.deepEqual(Config({ sessionGateSessions: ["s1", "s2"] }).sessionGateSessions, ["s1", "s2"]);
13
+ });
14
+
7
15
  // ---------------------------------------------------------------- rerank opt-in (item ⑥)
8
16
 
9
17
  test("rerank is opt-in: default config does not enable the local reranker", () => {