@modusensus/dsh-mneme 0.8.15 → 0.9.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 (100) hide show
  1. package/README.md +21 -3
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +4 -2
  4. package/docs/CONFIGURATION.md +16 -3
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/DISTILL.md +162 -0
  7. package/docs/STORAGE.md +11 -2
  8. package/docs/WORKFLOWS.md +299 -0
  9. package/lib/api-standalone.js +22 -39
  10. package/lib/api.js +294 -10
  11. package/lib/candidates.js +454 -0
  12. package/lib/capabilities.js +819 -0
  13. package/lib/client.js +2767 -337
  14. package/lib/config.js +99 -6
  15. package/lib/continuity.js +35 -6
  16. package/lib/dream/decisions.js +37 -1
  17. package/lib/dream/sleep.js +10 -2
  18. package/lib/dream.js +7 -1
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/hot-memory.js +62 -0
  21. package/lib/index.js +72 -18
  22. package/lib/inject.js +33 -64
  23. package/lib/maintenance.js +25 -5
  24. package/lib/pipeline.js +534 -0
  25. package/lib/recall-stats.js +22 -7
  26. package/lib/reclaim-http.js +150 -0
  27. package/lib/safe-slice.js +18 -0
  28. package/lib/scope.js +16 -4
  29. package/lib/serve.js +1 -1
  30. package/lib/service.js +83 -17
  31. package/lib/session-usage.js +350 -0
  32. package/lib/settings.js +19 -2
  33. package/lib/store.js +251 -10
  34. package/lib/summarize.js +239 -60
  35. package/lib/tools.js +11 -2
  36. package/package.json +1 -1
  37. package/scripts/publish-gate.mjs +197 -0
  38. package/src/api-standalone.js +22 -39
  39. package/src/api.js +294 -10
  40. package/src/candidates.js +454 -0
  41. package/src/capabilities.js +819 -0
  42. package/src/config.js +99 -6
  43. package/src/continuity.js +35 -6
  44. package/src/dream/decisions.js +37 -1
  45. package/src/dream/sleep.js +10 -2
  46. package/src/dream.js +7 -1
  47. package/src/entities/extractor.js +4 -0
  48. package/src/hot-memory.js +62 -0
  49. package/src/index.js +72 -18
  50. package/src/inject.js +33 -64
  51. package/src/maintenance.js +25 -5
  52. package/src/pipeline.js +534 -0
  53. package/src/recall-stats.js +22 -7
  54. package/src/reclaim-http.js +150 -0
  55. package/src/safe-slice.js +18 -0
  56. package/src/scope.js +16 -4
  57. package/src/serve.js +1 -1
  58. package/src/service.js +83 -17
  59. package/src/session-usage.js +350 -0
  60. package/src/settings.js +19 -2
  61. package/src/store.js +251 -10
  62. package/src/summarize.js +239 -60
  63. package/src/tools.js +11 -2
  64. package/test/api-routes-gaps.test.js +681 -0
  65. package/test/api.test.js +171 -8
  66. package/test/candidates.test.js +345 -0
  67. package/test/capabilities.test.js +577 -0
  68. package/test/client.test.js +715 -4
  69. package/test/continuity.test.js +84 -0
  70. package/test/contrast.test.js +314 -0
  71. package/test/dream-merge-guardrail.test.js +219 -0
  72. package/test/embed-ready-poll.test.js +94 -0
  73. package/test/entities.test.js +13 -2
  74. package/test/failure-query.test.js +147 -0
  75. package/test/failure-retention.test.js +105 -0
  76. package/test/graph-api.test.js +36 -2
  77. package/test/hot-memory.test.js +67 -1
  78. package/test/inject-recall.test.js +68 -1
  79. package/test/inject.test.js +23 -0
  80. package/test/llm-audit.test.js +190 -1
  81. package/test/maintenance.test.js +112 -2
  82. package/test/mint-source.test.js +202 -0
  83. package/test/pipeline.test.js +505 -0
  84. package/test/placement.test.js +1 -1
  85. package/test/pre-inject-gate.test.js +10 -0
  86. package/test/publish-gate.test.js +272 -0
  87. package/test/recall-retention.test.js +128 -0
  88. package/test/recall-stats.test.js +89 -4
  89. package/test/reclaim-http.test.js +209 -0
  90. package/test/release-workflow.test.js +77 -0
  91. package/test/scope-distill-stamp.test.js +135 -0
  92. package/test/serve.test.js +1 -1
  93. package/test/service.test.js +7 -2
  94. package/test/session-usage.test.js +308 -0
  95. package/test/settings.test.js +8 -0
  96. package/test/store-write-path-columns.test.js +191 -0
  97. package/test/store.test.js +108 -0
  98. package/test/summarize.test.js +350 -0
  99. package/test/supersede-state-tracking.test.js +4 -4
  100. package/test/tools.test.js +16 -1
@@ -600,9 +600,12 @@ test("explorer chrome aligns with the host design system", () => {
600
600
  /\.mneme-vtab\.mneme-active::after/.test(clientSource),
601
601
  "active sub-tabs use the host underline treatment"
602
602
  );
603
+ // #178:这里原本直接断言宿主品牌蓝。宿主蓝在浅色画布上只有 4.23:1,对 13px
604
+ // 正文不足 AA,所以面板改用自己的语义色层 --mneme-accent(同一条宿主 token
605
+ // 往 label-primary 方向混 15%),换肤仍跟随宿主。意图不变:激活态是品牌蓝。
603
606
  assert.ok(
604
- clientSource.includes(".mneme-vtab.mneme-active{color:var(--dsw-alias-state-business-primary)}"),
605
- "active sub-tab text must turn the host brand blue"
607
+ clientSource.includes(".mneme-vtab.mneme-active{color:var(--mneme-accent)}"),
608
+ "active sub-tab text must turn the host brand blue (via the panel accent layer)"
606
609
  );
607
610
  assert.equal(
608
611
  /\.mneme-xmain\{[^}]*border:1px/.test(clientSource),
@@ -664,6 +667,48 @@ test("heat badges render from the /list projection and self-hide when off", () =
664
667
  );
665
668
  });
666
669
 
670
+ // Token 去向三屏(#412):前端只拿 by_day 的稀疏行渲染两种时间尺度,三个坑
671
+ // 都在这一层,且都是「不报错但图错了」的静默失效,所以锁形状而非锁行为:
672
+ // ① 柱状图必须补连续 7 天——by_day 是 GROUP BY 的稀疏结果,直接映射会缺列;
673
+ // ② 热力图的 56 格必须来自独立的 days=56 请求——复用 days=7 的响应会让 56 格
674
+ // 里 48 格恒 0(图看着正常,其实只有一周有数据);
675
+ // ③ 开合不能挂在 details 上——details 的 onClick 会连折叠区子卡片的点击一起
676
+ // 吃掉(preventDefault 拦掉冒泡的激活事件),必须挂 summary。
677
+ test("token flow: 7-day bars pad to a continuous week, heatmap uses its own 56-day window", () => {
678
+ // ① 连续 7 天:右端对齐今天 UTC,缺日填 0
679
+ assert.ok(
680
+ /Array\.from\(\{ length: 7 \}/.test(clientSource),
681
+ "the 7-day bars must be generated as a fixed 7-slot array, not mapped straight off by_day"
682
+ );
683
+ assert.ok(
684
+ /rawDayMap\.get\(key\) \?\? 0/.test(clientSource),
685
+ "missing days must fill 0 so the x-axis stays a continuous week"
686
+ );
687
+ // stats 为 null(未到)与 {}(已知为空)必须分开:前者不画柱,后者补零
688
+ assert.ok(
689
+ /const rawDays = stats && Array\.isArray\(stats\.by_day\) \? stats\.by_day : null;/.test(clientSource),
690
+ "an unloaded response must stay null, else the loading state paints 7 zero bars"
691
+ );
692
+ // ② 热力图独立 56 天窗口
693
+ assert.ok(
694
+ clientSource.includes('llm-audit/stats?days=56'),
695
+ "the heatmap must issue its own days=56 request"
696
+ );
697
+ assert.ok(
698
+ /heatStats && Array\.isArray\(heatStats\.by_day\)/.test(clientSource),
699
+ "the heatmap must read heatStats.by_day, not stats.by_day"
700
+ );
701
+ // ③ 开合挂 summary,不挂 details
702
+ assert.ok(
703
+ !/className: "mneme-legacy",\s*\n\s*open: legacyOpen,\s*\n\s*onClick/.test(clientSource),
704
+ "the click handler must not sit on <details>: it swallows clicks from child cards"
705
+ );
706
+ assert.ok(
707
+ /h\("summary", \{ onClick: \(e\) => \{ e\.preventDefault\(\); setLegacyOpen\(!legacyOpen\); \} \}/.test(clientSource),
708
+ "the toggle must be bound on <summary>, the native disclosure handle"
709
+ );
710
+ });
711
+
667
712
  // 巩固/睡眠模型路由 UI:下拉数据来自宿主侧已注册适配器(/llm-providers,
668
713
  // 云端插件侧端点),「测试连通性」走 POST /test-model 真实最小调用。旧后端
669
714
  // 端点 404 时必须回退纯文本输入——前端自门控,不挡旧版本。
@@ -794,8 +839,9 @@ test("stale route values are marked and explained at the row level (issue #191)"
794
839
  "the en stale hint must cover provider-absence too, not only the model case"
795
840
  );
796
841
  // 4. issue #215:三条路由各自透传自己的档位键——routeSelects 按路由行
797
- // 传入键名、从生效配置取值;sleep 无回退重试,档位被拒时测试是事前唯一
798
- // 的暴露口。
842
+ // 传入键名、从生效配置取值。(曾在此断言「sleep 无回退重试,测试是事前
843
+ // 唯一的暴露口」:该前提自 939fbfc 起就不成立——sleep/梦/蒸馏都走
844
+ // withEffortFallback,档位被拒会丢字段重试,测试从不是唯一暴露口。)
799
845
  assert.ok(
800
846
  /const routeSelects = \(providerKey, modelKey, testState, setTestState, effortKey\) => \{/.test(clientSource)
801
847
  && /const effort = effortKey \? \(eff\[effortKey\] \|\| ""\) : ""/.test(clientSource),
@@ -815,6 +861,31 @@ test("stale route values are marked and explained at the row level (issue #191)"
815
861
  );
816
862
  });
817
863
 
864
+ // 探针降级的如实呈现(issue #215 后续):后端档位被拒时不再 502,而是丢字段
865
+ // 重试成功 + effortDropped:true。面板必须把「连通了、但不是按你配的档位」讲出来
866
+ // ——静默成功和静默失败一样有害:前者让人以为档位生效,后者把人赶去换 Provider。
867
+ test("panel surfaces a downgraded connectivity test instead of a silent success or a red failure", () => {
868
+ // 1. 只有后端明确置位才算降级(旧后端无字段 → 不误报)
869
+ assert.ok(
870
+ /dropped: j\.effortDropped === true/.test(clientSource),
871
+ "the panel must read effortDropped strictly (=== true) so an older backend never renders a phantom downgrade"
872
+ );
873
+ // 2. 降级文案只在成功行追加,失败行不得出现(失败就是失败,别混进降级措辞)
874
+ assert.ok(
875
+ /\(testState\.ok && testState\.dropped \? " · " \+ t\("memory\.features\.modelTestDowngraded"\) : ""\)/.test(clientSource),
876
+ "the downgrade note must be appended only on the success line"
877
+ );
878
+ // 3. 双语词表各一条(按行首的 "key": "…" 计数,与上面的全键计数锁同口径;
879
+ // 用 split 会把渲染处的 t("…") 一起数进来,那是 3 次不是 2 次)
880
+ const tableRows = clientSource.match(/^\s*"memory\.features\.modelTestDowngraded"\s*:\s*"/gm) || [];
881
+ assert.equal(tableRows.length, 2, `memory.features.modelTestDowngraded must be declared exactly twice in the tables (zh + en), got ${tableRows.length}`);
882
+ // 4. 那条陈旧前提不得复活:面板不得再声称 sleep/梦 侧无回退重试
883
+ assert.ok(
884
+ !/无回退重试|无回退|no fallback retry/.test(clientSource),
885
+ "the panel must not re-assert the stale 'no fallback retry' claim that withEffortFallback already covers"
886
+ );
887
+ });
888
+
818
889
  // 帮助与反馈入口(v0.8):设置页底部三个反馈链接——GitHub 新建 issue 预填
819
890
  // (环境信息)、邮件反馈、浏览已知问题。纯前端链接零后端成本;插件版本从
820
891
  // /info 拉取(version 只读,不铺任何 token/凭据)。公开链接不得带个人邮箱。
@@ -941,6 +1012,41 @@ test("scope isolation ships panel switches and provenance surfaces (A4)", () =>
941
1012
  assert.ok(clientSource.includes('t("memory.explorer.detail.occurred")'), "drawer must render occurred row");
942
1013
  });
943
1014
 
1015
+ // Issue #427:设置面板把折叠线从「冷门」挪到「新手之外」。锁四件事:
1016
+ // ①首屏层 = 常用组(3 总开关)+ 作用域组——scope 留在首屏是因为「它记的东西
1017
+ // 会不会被别的会话看到」是新用户的必答题,藏进折叠等于没答;②其余组整组收进
1018
+ // 「全部设置」(第一层折叠),「高级」降为它内部的第二层;③每条开关挂
1019
+ // 「默认 / 已改」徽标,信号取自 overrides(键不在其中 = 仍是 config schema
1020
+ // 默认值),面板自判、不加后端字段;④分组与折叠键中英成对。验收口径:首屏
1021
+ // 可点项 = 3 总开关 + autoInject 的 2 个子开关 + scope 2 开关 + 「全部设置」
1022
+ // 按钮 = 8(≤ 8),由 ①② 的组成正则共同钉死,不另数 DOM。
1023
+ test("settings card folds everything beyond the first screen behind All settings (issue #427)", () => {
1024
+ // ① 首屏层组成:两组都要在,且 basic 的成员是从 core/dream 移走的。
1025
+ assert.ok(/const FEATURE_FIRST_SCREEN = \["group\.basic", "group\.scope"\];/.test(clientSource),
1026
+ "the first screen must be exactly the basic and scope groups");
1027
+ assert.ok(/key: "group\.basic", items: \["autoInject", "autoSummarize", "autoDream"\]/.test(clientSource),
1028
+ "group.basic must carry exactly the three master switches");
1029
+ assert.ok(!/key: "group\.core", items: \["autoInject/.test(clientSource) && !/key: "group\.dream", items: \["autoDream/.test(clientSource),
1030
+ "basic members must be MOVED out of their old groups (render-once), not filtered at render time");
1031
+ // ② 两层折叠的接线:restGroups 只在 showAll 展开后渲染;「高级」按钮出现在
1032
+ // 「全部设置」之后(同一渲染分支内),showAdv 展开的条目走第二层。
1033
+ assert.ok(/showAll && h\(react\.Fragment, null,\s*restGroups\.map\(renderGroup\)/.test(clientSource),
1034
+ "the remaining groups must render only behind the All settings fold");
1035
+ const allBtn = clientSource.indexOf('t("memory.features.allToggle")');
1036
+ const advBtn = clientSource.indexOf('t("memory.features.advancedToggle")');
1037
+ assert.ok(allBtn >= 0 && advBtn > allBtn, "the advanced fold must be nested after All settings");
1038
+ assert.equal(clientSource.match(/const FEATURE_ADVANCED_BOOLS = \[([^\]]*)\];/)[1].split(",").filter((s) => s.trim()).length, 7,
1039
+ "the advanced second tier must hold exactly the 7 rarer switches the label promises");
1040
+ // ③ 徽标:两类各一套中英文案;判定读 overrides 本地判,不引入后端字段。
1041
+ for (const key of ["memory.features.tagDefault", "memory.features.tagChanged",
1042
+ "memory.features.group.basic", "memory.features.allToggle", "memory.features.advancedToggle"]) {
1043
+ const occurrences = clientSource.split("\"" + key + "\"").length - 1;
1044
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
1045
+ }
1046
+ assert.ok(clientSource.includes("Object.prototype.hasOwnProperty.call(ovr, k)"),
1047
+ "the default/changed badge must be decided from overrides, not a new backend field");
1048
+ });
1049
+
944
1050
  // v0.8.0 冲突集中处理:状态页的待确认冲突队列(reason + 双方内容对比 +
945
1051
  // 保留 A/B/仅标记动作),resolveConflictPending 的第一个前端出口。双语齐备。
946
1052
  test("conflict queue ships a central review surface on the status tab", () => {
@@ -1217,6 +1323,313 @@ test("mcp card: mount snippet is copyable and token-keyed to the external API",
1217
1323
  assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
1218
1324
  }
1219
1325
  });
1326
+ // --- #400 冲突裁决窗可读性(报障原话:窗口太小又不能放大,完全对比不了内容,只好
1327
+ // 两个都删或胡乱选一个)---
1328
+ // 锁四件事:展开切换存在且默认收起(折叠态仍走 140 字预览,既有布局不回归);
1329
+ // 展开态渲染未截断全文并去掉 diff 的行数钳制;判读依据行(类型/重要度/字数/
1330
+ // 新旧 + 单侧复制)在位;窄容器降级单列。缺任何一条,报障就复发。
1331
+ test("conflict queue: expandable full text with decision metadata", () => {
1332
+ assert.ok(clientSource.includes('"memory.status.conflictQueue.expand"'), "collapsed state must offer 展开全文");
1333
+ assert.ok(clientSource.includes('"memory.status.conflictQueue.collapse"'), "expanded state must offer 收起全文");
1334
+ assert.ok(clientSource.includes('"aria-expanded": open'), "the toggle must expose aria-expanded");
1335
+ assert.ok(/open \? \(s\.content \|\| ""\) : \(s\.content \|\| ""\)\.slice\(0, 140\)/.test(clientSource),
1336
+ "collapsed keeps the 140-char preview while expanded must render untruncated content");
1337
+ assert.ok(clientSource.includes("mneme-conflict-diff--full"), "expanded diff must drop the 4-line clamp");
1338
+ // 展开态高度上限必须与视口无关:宿主可能把面板放进零高视口(隐藏 webview /
1339
+ // 后台页),那时 vh 会算成 0px,「展开全文」直接看不见字——肉眼验收实测踩过。
1340
+ assert.ok(clientSource.includes("max-height:28em"), "expanded height cap must be viewport-independent");
1341
+ assert.equal(/\.mneme-conflict-(diff--full|text)\{[^}]*\d+vh/.test(clientSource), false,
1342
+ "conflict expand rules must not use vh");
1343
+ assert.ok(clientSource.includes('"memory.status.conflictQueue.chars"'), "each side must show its length");
1344
+ assert.ok(clientSource.includes('"memory.status.conflictQueue.updated"'), "each side must show which one is newer");
1345
+ assert.ok(clientSource.includes("copySide"), "each side must be copyable for external comparison");
1346
+ assert.ok(clientSource.includes("@container (max-width:560px){.mneme-conflict-pair{grid-template-columns:1fr}}"),
1347
+ "a narrow panel must fall back to one column instead of two unreadable halves");
1348
+ for (const key of [
1349
+ "memory.status.conflictQueue.expand",
1350
+ "memory.status.conflictQueue.collapse",
1351
+ "memory.status.conflictQueue.copySide",
1352
+ "memory.status.conflictQueue.copiedSide",
1353
+ "memory.status.conflictQueue.importance",
1354
+ "memory.status.conflictQueue.chars",
1355
+ "memory.status.conflictQueue.updated",
1356
+ "memory.status.conflictQueue.created"
1357
+ ]) {
1358
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
1359
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
1360
+ }
1361
+ });
1362
+
1363
+ // #386 第 5 条:面板手动新增记忆。这一块最容易悄悄退化——菜单项被删掉、弹层被
1364
+ // 从树里摘掉、或者表单又长回 scope/sensitivity 输入框(那会绕开详情抽屉的「放宽
1365
+ // 可见性」二次确认),面板表面上看不出坏,只是入口没了。所以既锁「入口存在」,
1366
+ // 也锁「入口真的挂在树上」。
1367
+ test("panel can create a memory by hand: menu entry wired to the dialog", () => {
1368
+ // 入口在「更多操作」菜单里,且菜单项真的调用 setCreateOpen(true)——只断言文案
1369
+ // 存在的话,把 onClick 摘掉测试照样绿。
1370
+ assert.ok(clientSource.includes('"memory.explorer.newMemory"'), "menu entry label must exist");
1371
+ assert.ok(/role: "menuitem", onClick: \(\) => \{ setCreateOpen\(true\); setMenuOpen\(false\); \}/.test(clientSource),
1372
+ "the menu item must actually open the create dialog");
1373
+ assert.ok(clientSource.includes("const [createOpen, setCreateOpen] = useState(false)"),
1374
+ "the dialog needs its own open state");
1375
+ // 弹层必须挂在树上并接上回调:onCreated 同时刷新列表与选中新行(并入既有条目时
1376
+ // 返回的 id 是被并入那一行,也该滚进视野)。
1377
+ assert.ok(/createOpen && h\(CreateDialog, \{/.test(clientSource), "the dialog must be mounted conditionally");
1378
+ assert.ok(/onCreated: \(d\) => \{ setReloadKey\(\(k\) => k \+ 1\); if \(d && d\.id\) setSelectedId\(d\.id\); \}/.test(clientSource),
1379
+ "creating must refresh the list and select the resulting row");
1380
+ // 打后端的路由是 /create,不是 standalone 的 /memories:面板与工具必须走同一条
1381
+ // saveWithDedupe 路径,否则「同标题并入」的提示在面板上会失灵。
1382
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/create"'),
1383
+ "the dialog must post to the panel create route");
1384
+ assert.ok(/d\.action === "merged" \? t\("memory\.explorer\.newMerged"\) : t\("memory\.explorer\.newCreated"\)/.test(clientSource),
1385
+ "merged and created are different facts and must be reported differently");
1386
+ // 弹层源码切片:下面「表单里有什么 / 没有什么」的断言一律在这段上做。在整包上做
1387
+ // 等于没锁——这些字段名在 bundle 里到处都是,`clientSource.includes("title")` 恒真。
1388
+ const dialogStart = clientSource.indexOf("function CreateDialog");
1389
+ const dialogEnd = clientSource.indexOf("function MemoryExplorer", dialogStart);
1390
+ assert.ok(dialogStart >= 0 && dialogEnd > dialogStart, "CreateDialog must be findable in the bundle");
1391
+ const dialogSource = clientSource.slice(dialogStart, dialogEnd);
1392
+ // 表单字段与 memory_save 工具对齐,且锁的是**真实请求体写法**:多一个字段(比如
1393
+ // scope/sensitivity)这段就匹配不上,少一个 trim 也匹配不上。锁裸字段名会恒真。
1394
+ assert.match(dialogSource,
1395
+ /JSON\.stringify\(\{\s*type,\s*title: title\.trim\(\),\s*content: content\.trim\(\),\s*importance,\s*tags: tags\.split\(","\)\.map\(\(s\) => s\.trim\(\)\)\.filter\(Boolean\)\s*\}\)/,
1396
+ "the create request body must carry exactly type/title/content/importance/tags");
1397
+ // 成功路径**不得**重置 busy:提示要停 900ms 才关窗,这期间按钮若重新可点,第二次
1398
+ // 同 type+title 会落进同一行并把正文再追加一遍(service 走 appendContent),一次
1399
+ // 误触就写坏一条记忆。所以全弹层只允许 catch 那条路重置 busy。
1400
+ assert.equal(dialogSource.split("setBusy(false)").length - 1, 1,
1401
+ "only the error path may reset busy; the success path must stay disabled until the dialog closes");
1402
+ assert.match(dialogSource, /\.catch\(\(\) => \{ setBusy\(false\); setErr\(t\("memory\.explorer\.newFailed"\)\); \}\)/,
1403
+ "the single busy reset must be the error path");
1404
+ // 按钮文案同理:成功后 busy 仍为 true,若只看 busy,写入早就完成了按钮还写着「保存
1405
+ // 中…」——既骗用户又和旁边那行「已新建」自相矛盾。msg 一置上就切回正常文案。
1406
+ assert.match(dialogSource, /busy && !msg \? t\("memory\.explorer\.newSaving"\) : t\("memory\.explorer\.newConfirm"\)/,
1407
+ "once the write succeeded the button must stop claiming it is still saving");
1408
+ assert.equal(/newScope|sensitivity/.test(dialogSource), false,
1409
+ "the create form must not offer scope/sensitivity: that path bypasses the widen-confirm guard");
1410
+ // document 不在类型下拉里:铸造口唯一(registerDocument,#230),后端也会 400。
1411
+ assert.equal(dialogSource.includes('"document"'), false,
1412
+ "document rows are minted only via registerDocument and must not be offered here");
1413
+ // 类型白名单与 src/tools.js 的 memory_save enum 逐字一致(缺一个就会有人点进去才被拒)。
1414
+ for (const k of ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]) {
1415
+ assert.ok(dialogSource.includes(`"${k}"`), `type option ${k} must be offered`);
1416
+ }
1417
+ // 前端空值拦截只是省一次往返,服务端校验才是权威——但两者都得在,否则空标题会
1418
+ // 一路发出去再弹一个 400 给用户看。
1419
+ assert.ok(dialogSource.includes('if (!title.trim()) { setErr(t("memory.explorer.newNoTitle")); return; }'),
1420
+ "empty title must be caught before the round trip");
1421
+ assert.ok(dialogSource.includes('if (!content.trim()) { setErr(t("memory.explorer.newNoContent")); return; }'),
1422
+ "empty body must be caught before the round trip");
1423
+ // 十四个新键必须中英成对(与上面那条全局锁同口径,这里列出名字便于定位)。
1424
+ for (const key of [
1425
+ "memory.explorer.newMemory",
1426
+ "memory.explorer.newTitle",
1427
+ "memory.explorer.newHint",
1428
+ "memory.explorer.newType",
1429
+ "memory.explorer.newTitlePlaceholder",
1430
+ "memory.explorer.newContentPlaceholder",
1431
+ "memory.explorer.newTagsPlaceholder",
1432
+ "memory.explorer.newConfirm",
1433
+ "memory.explorer.newSaving",
1434
+ "memory.explorer.newCreated",
1435
+ "memory.explorer.newMerged",
1436
+ "memory.explorer.newFailed",
1437
+ "memory.explorer.newNoTitle",
1438
+ "memory.explorer.newNoContent"
1439
+ ]) {
1440
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
1441
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
1442
+ }
1443
+ });
1444
+ // --- issue #386 第 4 条:存储回收卡(dry-run 预估 + 两步确认执行)---
1445
+ // 这是「不可逆的内容丢弃」在面板上的唯一入口,所以锁的不是「有没有这张卡」,而是三条
1446
+ // 不变量:执行必须两步、POST 必须逐字带确认门、VACUUM 失败不得渲染成干净的成功。
1447
+ // 所有断言都做在**卡片切片**上:路由串/dryRun/confirm 这些字面量在整包里到处都是,
1448
+ // 在 clientSource 上做 includes 恒真、等于没锁(#408 评审踩过的坑)。
1449
+ const maintCardSource = () => {
1450
+ const start = clientSource.indexOf("function MaintenanceCard");
1451
+ const end = clientSource.indexOf("function MirrorHealthCard", start);
1452
+ assert.ok(start >= 0 && end > start, "MaintenanceCard must be findable in the bundle (切片锚点变了?)");
1453
+ return clientSource.slice(start, end);
1454
+ };
1455
+
1456
+ test("maintenance card: i18n keys are bilingual and the section is really rendered", () => {
1457
+ for (const key of [
1458
+ "memory.status.sec.maintenance",
1459
+ "memory.status.maint",
1460
+ "memory.status.maint.freelist",
1461
+ "memory.status.maint.plannedInputs",
1462
+ "memory.status.maint.plannedEmbeddings",
1463
+ "memory.status.maint.window",
1464
+ "memory.status.maint.windowUnit",
1465
+ "memory.status.maint.windowLabel",
1466
+ "memory.status.maint.reestimate",
1467
+ "memory.status.maint.reestimating",
1468
+ "memory.status.maint.stale",
1469
+ "memory.status.maint.vacuum",
1470
+ "memory.status.maint.vacuumHint",
1471
+ "memory.status.maint.execute",
1472
+ "memory.status.maint.confirm",
1473
+ "memory.status.maint.running",
1474
+ "memory.status.maint.cancel",
1475
+ "memory.status.maint.confirmHint",
1476
+ "memory.status.maint.badWindow",
1477
+ "memory.status.maint.failed",
1478
+ "memory.status.maint.done",
1479
+ "memory.status.maint.size",
1480
+ "memory.status.maint.reclaimed",
1481
+ "memory.status.maint.clearedInputs",
1482
+ "memory.status.maint.clearedEmbeddings",
1483
+ "memory.status.maint.receipt",
1484
+ "memory.status.maint.receiptNone",
1485
+ "memory.status.maint.duration",
1486
+ "memory.status.maint.vacuumRan",
1487
+ "memory.status.maint.vacuumOff",
1488
+ "memory.status.maint.vacuumFailed",
1489
+ "memory.status.maint.vacuumFailedHint"
1490
+ ]) {
1491
+ // 数的是**字典行**(`"key": "` 顶格成行),不是裸字符串出现次数。裸数会把调用点
1492
+ // 一起算进来:`t("...maint.done")` 在卡片里本来就有一次,删掉一整行英文译文之后
1493
+ // 「字典 zh 行 + 调用点」照样凑够 2——锁恒真,就是这么漏的(本条第一版被 M1 变异
1494
+ // 抓出来了)。行首锚定后,少一侧译文就只剩 1。
1495
+ const entries = clientSource.match(new RegExp(`^\\s*"${key.replace(/\./g, "\\.")}"\\s*:\\s*"`, "gm"));
1496
+ const count = entries ? entries.length : 0;
1497
+ assert.equal(count, 2, `i18n key ${key} must appear as exactly one dictionary line per language (got ${count})`);
1498
+ }
1499
+ // 分区键只在字典里躺着不算加分区:必须真的渲染出「维护」这段扫读锚点。
1500
+ assert.ok(clientSource.includes('h("div", { className: "mneme-statushead" }, t("memory.status.sec.maintenance"))'),
1501
+ "the maintenance section head must render from the dictionary key");
1502
+ });
1503
+
1504
+ test("maintenance card: the reclaim route is the in-process one, not the standalone data plane", () => {
1505
+ const src = maintCardSource();
1506
+ // 走 /api/dsh-mneme/... 这条 in-process 路由:它才有 requireAuth + 与 CLI 同一条
1507
+ // reclaim 实现。写成 standalone 数据面的路径会绕开面板身份的鉴权口径。
1508
+ assert.equal(src.split('"/api/dsh-mneme/maintenance/reclaim"').length - 1, 1,
1509
+ "the card must name the in-process reclaim route exactly once (via its path constant)");
1510
+ // 预估走 GET 且把窗口作为查询参数带上;粘在按钮上——键入即发会让数字在半截输入时跳。
1511
+ assert.ok(src.includes("`${RECLAIM_PATH}?olderThanDays=${days}`"),
1512
+ "re-estimating must GET the route with the current window as a query parameter");
1513
+ assert.equal(src.split("onClick: estimate").length - 1, 1,
1514
+ "only the re-estimate button may trigger an estimate (no request per keystroke)");
1515
+ assert.ok(src.includes('onChange: (e) => setDays(e.target.value)'),
1516
+ "typing in the window field must only update local state");
1517
+ });
1518
+
1519
+ test("maintenance card: execution carries the verbatim dryRun:false + confirm:true gate", () => {
1520
+ const src = maintCardSource();
1521
+ // 逐字锁请求体:多一个字段、少一个空格、把 Number(days) 换成裸 days 都匹配不上。
1522
+ // 锁裸字段名(dryRun/confirm)会恒真——它们在这段源码里各自还有别的出现位置。
1523
+ assert.match(src,
1524
+ /JSON\.stringify\(\{ dryRun: false, confirm: true, olderThanDays: Number\(days\), vacuum \}\)/,
1525
+ "the executing body must be exactly dryRun:false + confirm:true + olderThanDays + vacuum");
1526
+ // 全卡只有一处能改库:确认门之外的任何一次 POST 都是「第一次点就真清了」。
1527
+ assert.equal(src.split('method: "POST"').length - 1, 1, "the card must issue exactly one POST");
1528
+ assert.equal(src.split("dryRun: false").length - 1, 1, "exactly one mutating body may exist");
1529
+ // 窗口非法时不得发请求:0 在服务端是「清掉全部快照」,畸形输入绝不能静默落进最坏解释。
1530
+ assert.ok(src.includes('if (!/^\\d+$/.test(days)) { setError(t("memory.status.maint.badWindow")); setArmed(false); return; }'),
1531
+ "a non-numeric window must be rejected locally before any round trip");
1532
+ // 上界同样在本地拦:/^\d+$/ 放得过 1e20 与 400 位数字(后者化成 Infinity),后端
1533
+ // 会回 400,但那时用户只看到一句「回收失败」,分不清是窗口不合法还是网络问题。
1534
+ assert.match(src, /const MAX_WINDOW_DAYS = 3650;/,
1535
+ "the card must carry the same upper bound as the backend");
1536
+ assert.ok(src.includes('if (Number(days) > MAX_WINDOW_DAYS) { setError(t("memory.status.maint.badWindow")); setArmed(false); return; }'),
1537
+ "an out-of-range window must be rejected locally, before any round trip");
1538
+ });
1539
+
1540
+ test("maintenance card: 'no receipt' does not claim a single cause", () => {
1541
+ // writeReceipt 返回 null 有两种成因:审计被关(config.llmAudit.enabled === false),
1542
+ // 或回执写库失败(saveLlmAudit 抛错,只 warn)。旧文案「无(审计未启用)」把第二种
1543
+ // 情况说成第一种——而这行出现在不可逆操作的结果区,用户会照它去对账。
1544
+ for (const wording of [
1545
+ '"memory.status.maint.receiptNone": "未记录(审计未启用或回执写入失败)"',
1546
+ '"memory.status.maint.receiptNone": "Not recorded (audit disabled or receipt write failed)"'
1547
+ ]) {
1548
+ assert.ok(clientSource.includes(wording),
1549
+ `the missing-receipt wording must name both causes: ${wording}`);
1550
+ }
1551
+ });
1552
+
1553
+ test("maintenance card: no window.confirm — the host webview cannot be relied on for it", () => {
1554
+ const src = maintCardSource();
1555
+ assert.equal(/window\.confirm/.test(src), false,
1556
+ "window.confirm is not reliably available in the host webview; the in-card confirm state is the gate");
1557
+ assert.equal(/(^|[^.\w])confirm\s*\(/.test(src), false,
1558
+ "no blocking confirm() call may exist in the card (the confirm state is a rendered branch)");
1559
+ assert.ok(src.includes("mneme-btndangerconfirm"),
1560
+ "the armed state must use the repo's existing danger-confirm styling");
1561
+ });
1562
+
1563
+ test("maintenance card: mounted in the status view under the maintenance section", () => {
1564
+ // 组件存在但没挂上树 = 用户永远看不到,正是这条 issue 报的缺口。
1565
+ const panelStart = clientSource.indexOf("function StatusPanel");
1566
+ const panelEnd = clientSource.indexOf("function ", clientSource.indexOf("h(WorkbenchSection", panelStart));
1567
+ assert.ok(panelStart >= 0 && panelEnd > panelStart, "StatusPanel must be findable in the bundle");
1568
+ const panel = clientSource.slice(panelStart, panelEnd);
1569
+ assert.ok(panel.includes("h(MaintenanceCard, { t })"),
1570
+ "the status panel must render the maintenance card");
1571
+ assert.ok(panel.includes('t("memory.status.sec.maintenance")'),
1572
+ "the card must sit under the maintenance section head, not loose in the grid");
1573
+ });
1574
+
1575
+ test("maintenance card: success state exists only behind the confirm gate", () => {
1576
+ const src = maintCardSource();
1577
+ // 第一次点只能「武装」,不得直连执行:默认态是红描边钮(mneme-btndanger),只有武装
1578
+ // 后的实心红确认钮(mneme-btndangerconfirm)才允许调用 execute。
1579
+ assert.match(src, /mneme-btndangerconfirm"[\s\S]{0,200}?onClick: execute/,
1580
+ "only the armed danger-confirm button may fire the mutating call");
1581
+ assert.match(src, /mneme-btndanger"[\s\S]{0,200}?onClick: \(\) => setArmed\(true\)/,
1582
+ "the first click must only arm the confirm state, never execute");
1583
+ assert.equal(src.split("onClick: execute").length - 1, 1,
1584
+ "the executing click must have exactly one entry point in the card");
1585
+ // 非 2xx 必须先 return,成功态才能写在它之后——否则失败也会渲染成成功。
1586
+ const guard = src.indexOf("if (!r.ok) {");
1587
+ const success = src.indexOf("setResult(r.body)");
1588
+ assert.ok(guard >= 0 && success > guard,
1589
+ "a non-2xx response must return before the success report is written");
1590
+ assert.equal(src.split("setResult(r.body)").length - 1, 1,
1591
+ "the success report must be written exactly once, from the confirmed call");
1592
+ // 窗口改过却没重新估算时,确认态里摆出来的会是上一次的数字——对不可逆动作来说这是最坏
1593
+ // 的一种错位,所以那种状态下执行钮必须是禁用的。
1594
+ assert.ok(src.includes("const stale = !report || String(report.olderThanDays) !== days;"),
1595
+ "an estimate that no longer matches the input must be detectable");
1596
+ assert.match(src, /disabled: running \|\| loading \|\| estimating \|\| stale/,
1597
+ "the run button must stay disabled while the estimate is stale");
1598
+ });
1599
+
1600
+ // VACUUM 失败 ≠ 成功(契约 §5 的硬要求)。清理的两个 UPDATE 各自提交且不可逆,VACUUM
1601
+ // 只负责把文件缩回去;把半成功渲染成成功,用户会以为白清了一次。这条锁住那个三元分支,
1602
+ // 防止未来被「简化」成只看 dryRun/receipt。
1603
+ test("maintenance card: a failed VACUUM is reported, never rendered as a clean success", () => {
1604
+ const src = maintCardSource();
1605
+ assert.ok(src.includes("const vacuumError = rv.error == null ? null : String(rv.error);"),
1606
+ "vacuum.error is string|null and must be read as the failure signal it is");
1607
+ assert.match(src, /vacuumError \? t\("memory\.status\.maint\.vacuumFailed"\) : t\("memory\.status\.maint\.done"\)/,
1608
+ "a non-null vacuum.error must replace the success headline");
1609
+ assert.ok(src.includes('t("memory.status.maint.vacuumFailedHint")'),
1610
+ "the failed state must state that the reclaim already happened, is irreversible, and that a rerun is idempotent");
1611
+ // receipt 也要露面:不可逆动作没有回执号,出事时无法对账。锁的是**取值表达式**而不是
1612
+ // 行标签——`t("...maint.receipt")` 只是左侧那行文案,把右侧渲染删空它照样在,锁标签
1613
+ // 等于没锁(M12 变异验证过)。
1614
+ assert.match(src, /result\.receipt == null \? t\("memory\.status\.maint\.receiptNone"\) : String\(result\.receipt\)/,
1615
+ "the receipt value must be rendered (id when present, an explicit 'not recorded' when absent)");
1616
+ });
1617
+
1618
+ test("maintenance card: VACUUM defaults to off and a failed run is reported verbatim", () => {
1619
+ const src = maintCardSource();
1620
+ // VACUUM 会短暂独占数据库(别的进程此时写入会 SQLITE_BUSY),它是**可选**动作,
1621
+ // 默认必须关着——默认开等于每次回收都替用户独占一次库。契约 §5 的硬要求。
1622
+ assert.ok(src.includes("const [vacuum, setVacuum] = useState(false);"),
1623
+ "VACUUM must default to off");
1624
+ // 非 2xx 必须把服务端给的 error 串如实带出来:只说「失败」,现场就分不清是 400 的
1625
+ // confirm-required 还是 500 的 internal。
1626
+ assert.ok(src.includes('setResultError(`${t("memory.status.maint.failed")} · ${(r.body && r.body.error) || `HTTP ${r.status}`}`);'),
1627
+ "a non-2xx response must surface the server's own error string, not a bare 'failed'");
1628
+ // 执行成功后必须复位确认态:否则下一次点击落在已武装的分支上,一步就把不可逆动作
1629
+ // 放出去,两步确认名存实亡。
1630
+ assert.match(src, /setResult\(r\.body\);\s*setArmed\(false\);/,
1631
+ "the confirm state must be reset on the success path");
1632
+ });
1220
1633
  // 面板 bundle 在本文件里只被当**文本**读(上面的断言全是正则/字符串包含),
1221
1634
  // 而仓库的 CI 里没有任何一步**解析**它:于是重复声明这类语法错误能一路绿灯进
1222
1635
  // 主干,后果却是整个面板加载失败(__ModuleLoader__ 拿到的模块一执行就抛
@@ -1228,3 +1641,301 @@ test("client bundle parses: 面板产物必须是合法 JS(无重复声明等
1228
1641
  const result = spawnSync(process.execPath, ["--check", join(root, "lib/client.js")], { encoding: "utf8" });
1229
1642
  assert.equal(result.status, 0, `lib/client.js 解析失败:\n${result.stderr || result.stdout}`);
1230
1643
  });
1644
+
1645
+ // #399 的两个留存键(distillRecords.enabled / retentionDays)此前只有后端
1646
+ // 白名单与 docs/CONFIGURATION.md 的「面板可启停」,面板里根本没有这一行——
1647
+ // 文档承诺了一个够不着的开关。补上 UI 之后,这条锁住四件容易被「简化」掉的事:
1648
+ // ① 两个键真的进了渲染清单(enabled 在组里、retentionDays 在数值清单里);
1649
+ // ② 数值项的草稿必须从 effective 初始化——漏掉这一句,行会永远显示空值,
1650
+ // 用户以为配置丢了(#191 的睡眠路由键踩过同一个坑,见 :738 那条注释);
1651
+ // ③ 天数行必须由父开关门控(关着不留存,改天数没有意义);
1652
+ // ④ 非整数在本地就被挡下并把草稿退回生效值——留着非法文本会让人以为改成功了。
1653
+ test("distill retention switches are reachable from the panel (#399)", () => {
1654
+ // 认「组里有没有」不要用 `"key"]` 这种子串——`eff["distillRecords.enabled"]`
1655
+ // 也长这样,把组里那一项删掉它照样命中(变异验证实测:删项后仍全绿)。
1656
+ // 必须把 core 组的 items 数组抠出来单独看。
1657
+ const coreGroup = clientSource.match(/key: "group\.core", items: \[([\s\S]*?)\]/);
1658
+ assert.ok(coreGroup, "the core group must be declared");
1659
+ assert.ok(
1660
+ coreGroup[1].includes('"distillRecords.enabled"'),
1661
+ "distillRecords.enabled must be an item of the core group, not merely referenced elsewhere"
1662
+ );
1663
+ assert.ok(
1664
+ /const FEATURE_NUMBERS = \["distillRecords\.retentionDays"\]/.test(clientSource),
1665
+ "retentionDays must be in the numeric key list"
1666
+ );
1667
+ assert.ok(
1668
+ /for \(const key of FEATURE_NUMBERS\) numDraft\[key\] = String\(e\[key\] \?\? ""\);/.test(clientSource)
1669
+ && /setNums\(numDraft\);/.test(clientSource),
1670
+ "the numeric draft must be seeded from effective on mount, or the row always renders empty"
1671
+ );
1672
+ assert.ok(
1673
+ /const distillSub = eff\["distillRecords\.enabled"\] && h\("div", \{ className: "mneme-featsub" \},\s*\n\s*numRow\("distillRecords\.retentionDays"\)/.test(clientSource),
1674
+ "the retention-days row must be gated on the parent switch and live in its sub-block"
1675
+ );
1676
+ // 定义了不等于接线了:把 distillSub 从组尾子块里摘掉,上面每条断言照样全绿
1677
+ // (变异验证实测:摘掉后仍全绿)。子块是在 items.map 之后按组尾追加的,所以必须确认
1678
+ // 它真的出现在那一个渲染点上。
1679
+ const coreTail = clientSource.match(/g\.key === "group\.core" && h\(react\.Fragment, null, ([^)]*)\)/);
1680
+ assert.ok(coreTail, "the core group must append its sub-blocks at the group tail");
1681
+ assert.ok(
1682
+ /\bdistillSub\b/.test(coreTail[1]),
1683
+ "distillSub must be rendered at the core group tail, not merely declared above"
1684
+ );
1685
+ assert.ok(
1686
+ /if \(!\/\^\\d\+\$\/\.test\(raw\)\) \{\s*\n\s*setNums\(\(c\) => \(\{ \.\.\.c, \[key\]: String\(eff\[key\] \?\? ""\) \}\)\);\s*\n\s*return;\s*\n\s*\}/.test(clientSource),
1687
+ "a non-integer draft must be rejected locally and reverted to the effective value"
1688
+ );
1689
+ for (const key of [
1690
+ "memory.features.distillRecords.enabled",
1691
+ "memory.features.distillRecords.enabled.hint",
1692
+ "memory.features.distillRecords.retentionDays",
1693
+ "memory.features.distillRecords.retentionDays.hint"
1694
+ ]) {
1695
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
1696
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
1697
+ }
1698
+ });
1699
+
1700
+ test("a rejected feature write is visible and reverts the draft", () => {
1701
+ // 锁的是「保存失败从不显示」:错误提示原先只有一个座位、且带 `!state` 条件
1702
+ // (首屏加载失败才渲染)——state 一到手,此后每次写入失败都静默,输入框里
1703
+ // 留着没保存的值。整数项把「越界」变成可达路径后,人能在面板上看到 99999
1704
+ // 却以为它生效了。
1705
+ // 认「座位存在」不能只看 setSaveError 出现几次(函数里调它、渲染处读它,
1706
+ // 两处都在就算命中),要把写入路径与渲染路径分别钉住。
1707
+ assert.ok(
1708
+ clientSource.includes('const [saveError, setSaveError] = useState("");'),
1709
+ "save failures need their own state slot"
1710
+ );
1711
+ // 渲染座位必须在主卡片里(描述行之后、运行时块之前),而不是被 !state 挡住。
1712
+ const descAt = clientSource.indexOf('h("div", { className: "mneme-set-desc" }, t("memory.features.desc"))');
1713
+ const seatAt = clientSource.indexOf('saveError && h("div", { className: "mneme-mainterr" }, saveError)');
1714
+ const provAt = clientSource.indexOf("h(RuntimeProvisionBlock, { t })");
1715
+ assert.ok(descAt > 0 && seatAt > descAt, "the save-error seat must live in the main card body");
1716
+ assert.ok(provAt > seatAt, "the seat must sit in the card header area, before the runtime block");
1717
+ const earlyReturn = clientSource.match(/if \(error && !state\) return[\s\S]{0,300}?error\)\);/);
1718
+ assert.ok(earlyReturn, "the load-failure early return must still be there");
1719
+ assert.ok(!earlyReturn[0].includes("saveError"), "the save-error seat must not ride the load-failure branch");
1720
+
1721
+ // 写入路径:服务端 400 的 body 带着键名与范围(api.js 那条 400 的设计),
1722
+ // 要读出来;失败要能被调用方看见,所以返回值必须是真假。
1723
+ const putBody = clientSource.match(/const put = async \(patch\) => \{([\s\S]*?)\r?\n \};/);
1724
+ assert.ok(putBody, "put must be declared");
1725
+ assert.ok(putBody[1].includes("const detail = await res.json()"), "the server error body must be read");
1726
+ assert.ok(putBody[1].includes('throw new Error(detail || ("HTTP " + res.status));'), "the body must win over the bare status");
1727
+ assert.ok(putBody[1].includes("return true;") && putBody[1].includes("return false;"), "put must report success");
1728
+ assert.ok(putBody[1].includes("setSaveError(`"), "a rejected write must set the visible error");
1729
+
1730
+ // 两个提交路径都要在同一处退回生效值:只报错不退,框里的值仍与在用的值分家。
1731
+ assert.ok(clientSource.includes("put({ [key]: raw }).then((ok) => {"), "a rejected string draft must be revertible");
1732
+ assert.ok(clientSource.includes("put({ [key]: Number(raw) }).then((ok) => {"), "a rejected number draft must be revertible");
1733
+ assert.ok((clientSource.split("if (!ok) setStrs(").length - 1) >= 1, "the string draft must revert on rejection");
1734
+ assert.ok((clientSource.split("if (!ok) setNums(").length - 1) >= 1, "the number draft must revert on rejection");
1735
+
1736
+ const occurrences = clientSource.split('"memory.features.saveFailed"').length - 1;
1737
+ assert.ok(occurrences >= 2, `i18n key memory.features.saveFailed must exist in both zh and en (got ${occurrences})`);
1738
+ });
1739
+
1740
+ // ── M.O.(Modus Operandi)面板的数据面 ────────────────────────────────────────
1741
+ // 第一版面板拿硬编码的 PIPELINE_MOCK 验证形状与交互(服务端管线当时还没落地)。
1742
+ // 接上真数据之后那份常量必须消失:留着它,面板随时可能退回展示一份并不存在的
1743
+ // 工作流——而这正是这一刀要修掉的东西。这里锁「没有」比锁「有什么」更要紧。
1744
+ test("M.O. panel carries no mock pipeline data", () => {
1745
+ assert.equal(
1746
+ clientSource.includes("PIPELINE_MOCK"),
1747
+ false,
1748
+ "the hardcoded PIPELINE_MOCK must be gone now that the panel reads the server"
1749
+ );
1750
+ });
1751
+
1752
+ // 四个座位——会话头图标、会话头浮层、右栏 tab、对话行结算卡——必须读同一份投影。
1753
+ // 分家的事故长这样:会话头说有 3 条候选、右栏说有 5 条,而两个数字都对、没人查得出来。
1754
+ // 所以缓存只许有一处(usePipeline),其余要么从它取,要么经 PipelineBrowser 取。
1755
+ test("M.O. seats share a single pipeline document", () => {
1756
+ assert.equal(
1757
+ (clientSource.match(/function usePipeline\(/g) || []).length,
1758
+ 1,
1759
+ "usePipeline must be defined exactly once — one cache, every seat"
1760
+ );
1761
+ const bodyOf = (name) => {
1762
+ const start = clientSource.indexOf(`function ${name}(`);
1763
+ assert.ok(start > -1, `${name} must exist`);
1764
+ const next = clientSource.slice(start + 1).indexOf("\n function ");
1765
+ return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length);
1766
+ };
1767
+ for (const seat of ["PipelineHeaderEntry", "TurnRunCard"]) {
1768
+ assert.ok(
1769
+ bodyOf(seat).includes("usePipeline(project, null)"),
1770
+ `${seat} must read the shared pipeline document`
1771
+ );
1772
+ }
1773
+ // 另两个座位不再自己调 hook:它们把 project 交给 PipelineBrowser,由它按 openId 决定
1774
+ // 读清单还是读某一条。hook 不能条件调用,所以这道分叉必须落在**组件之间**而不是
1775
+ // 一个组件里的 if/else——后者会在点开那一下炸成「Rendered more hooks than during
1776
+ // the previous render」,而那是运行期才看得见的错。
1777
+ assert.ok(
1778
+ bodyOf("PipelinePane").includes("h(PipelineBrowser, { t, project })"),
1779
+ "PipelinePane must delegate to PipelineBrowser"
1780
+ );
1781
+ });
1782
+
1783
+ // 清单与图**同屏**。这条锁的形状换过两次,两次的教训都写在这里,免得再翻回去:
1784
+ // 一进来就是图 —— 没有名字、没有落点,要的是一串有名字的条目;
1785
+ // 清单 ⟷ 图两屏来回切 —— 条目有了,但画布藏在一次点击后面、界面上没有任何提示,
1786
+ // 反馈连着两次说「看不到画布和节点」。
1787
+ // 所以锁的是:清单行渲染名字/作用域/规模,**且** 图与清单在同一个组件里同时出现。
1788
+ test("M.O. browser shows the named list and the canvas on one screen", () => {
1789
+ const bodyOf = (name) => {
1790
+ const start = clientSource.indexOf(`function ${name}(`);
1791
+ assert.ok(start > -1, `${name} must exist`);
1792
+ const next = clientSource.slice(start + 1).indexOf("\n function ");
1793
+ return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length);
1794
+ };
1795
+ const list = bodyOf("PipelineWorkflowList");
1796
+ assert.ok(list.includes("row.title || row.id"), "each row must show a name");
1797
+ assert.ok(list.includes("memory.actions.scope.project"), "each row must show its scope");
1798
+ assert.ok(list.includes("memory.actions.list.meta"), "each row must show its size");
1799
+ const browser = bodyOf("PipelineBrowser");
1800
+ assert.ok(browser.includes("PipelineWorkflowList"), "the named list must be on screen");
1801
+ assert.ok(
1802
+ browser.includes("h(PipelineView, { t, data: detailDoc })"),
1803
+ "the canvas must be on the SAME screen — not behind a click"
1804
+ );
1805
+ assert.ok(browser.includes("rows.length ? rows[0].id : null"), "the first workflow must be expanded by default");
1806
+ });
1807
+
1808
+ // 面板读的是服务端投影,不是本地算的:工作流本体住仓库、投影住服务端,
1809
+ // 面板只负责画。路由一旦改了名字、或者项目根忘了带,这条会先红。
1810
+ test("M.O. panel reads the server projection", () => {
1811
+ assert.match(
1812
+ clientSource,
1813
+ /"\/api\/dsh-mneme\/pipeline" \+/,
1814
+ "the panel must fetch the pipeline route"
1815
+ );
1816
+ // 项目根是**客户端从会话读出来带过去的**,不是服务端猜的:插件侧的 ctx 上没有 cwd
1817
+ // (只有 logger / llm / reflect / webServer / inject / commands),猜不出来。
1818
+ assert.ok(
1819
+ clientSource.includes('params.push("project=" + encodeURIComponent(project))'),
1820
+ "the panel must pass the session's project root to the route"
1821
+ );
1822
+ assert.ok(
1823
+ clientSource.includes("function sessionProject(ctx, sessionId)"),
1824
+ "the session cwd lookup must exist and stay named"
1825
+ );
1826
+ // 两条来源的先后:会话列表快照(byId[sessionId].cwd)是宿主给客户端的公开形状,
1827
+ // sessions.get() 是宿主侧写法、客户端不一定有。顺序反了不会报错,只会让面板静悄悄
1828
+ // 退回全局层——那跟「本项目还没有工作流」长得一模一样,是最难查的一类错。
1829
+ // 而且两条必须各兜各的异常:一条抛了不能把另一条也带走,否则同样表现成「项目未知」。
1830
+ const sessionProjectBody = clientSource.slice(
1831
+ clientSource.indexOf("function sessionProject("),
1832
+ clientSource.indexOf("function layoutDag(")
1833
+ );
1834
+ assert.ok(
1835
+ sessionProjectBody.indexOf("sessions.list.getSnapshot") <
1836
+ sessionProjectBody.indexOf("typeof sessions.get"),
1837
+ "the host's public shape (list snapshot) must be tried before the host-side accessor"
1838
+ );
1839
+ const betweenLookups = sessionProjectBody.slice(
1840
+ sessionProjectBody.indexOf("sessions.list.getSnapshot"),
1841
+ sessionProjectBody.indexOf("typeof sessions.get")
1842
+ );
1843
+ assert.ok(
1844
+ betweenLookups.includes("catch (err)"),
1845
+ "each lookup must carry its own catch — a throwing accessor must not take out the next one"
1846
+ );
1847
+ });
1848
+
1849
+ // 右栏 tab 的正文单独包了一层具名组件,而不是把 hook 直接塞进 slots.register 的回调里:
1850
+ // 那个回调是不是当组件渲染由宿主决定,赌错就是「Rendered more hooks than during the
1851
+ // previous render」这种只在运行时炸、测试看不见的错。
1852
+ test("right-pane tab renders a named component instead of a bare hook callback", () => {
1853
+ assert.match(clientSource, /function PipelinePane\(/, "PipelinePane must exist");
1854
+ assert.ok(
1855
+ clientSource.includes(
1856
+ "}, (props) => h(PipelinePane, { t, project: sessionProject(ctx, props && props.sessionId) }));"
1857
+ ),
1858
+ "the tab callback must delegate to the named PipelinePane component, with the session's project"
1859
+ );
1860
+ });
1861
+
1862
+ // 「仓库里还没有工作流」与「一条运行记录都没有」是两句不同的话,都得中英齐全——
1863
+ // i18n 掉一边,界面上就是一行 key 裸奔。作用域那三句尤其不能缺:清单行上「项目」
1864
+ // 与「全局」是唯一的来源提示,缺了它,人分不清手里这条是本项目的还是跟着插件来的。
1865
+ test("M.O. empty-state, list and scope copy exist in both languages", () => {
1866
+ for (const key of [
1867
+ "memory.actions.empty.title",
1868
+ "memory.actions.empty.body",
1869
+ "memory.actions.noRuns",
1870
+ "memory.actions.zoom.in",
1871
+ "memory.actions.zoom.out",
1872
+ "memory.actions.zoom.fit",
1873
+ "memory.actions.list.heading",
1874
+ "memory.actions.list.count",
1875
+ "memory.actions.list.meta",
1876
+ "memory.actions.scope.project",
1877
+ "memory.actions.scope.global",
1878
+ "memory.actions.scope.none",
1879
+ "memory.actions.broken"
1880
+ ]) {
1881
+ const hits = (clientSource.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) || []).length;
1882
+ assert.equal(hits, 2, `${key} must exist in both zh and en (got ${hits})`);
1883
+ }
1884
+ });
1885
+
1886
+ // 画布能放大缩小。锁的重点不是「有没有缩放」,而是滚轮那一条必须**自己挂非被动监听**:
1887
+ // React 的 onWheel 挂在 root 上且是 passive 的,preventDefault 会被浏览器忽略,
1888
+ // 于是 Ctrl+滚轮既缩放画布又缩放整页,谁也没拦住——这个错本地测不出来,只在真浏览器里响。
1889
+ test("M.O. canvas zooms: buttons plus a non-passive, modifier-gated wheel listener", () => {
1890
+ const bodyOf = (name) => {
1891
+ const start = clientSource.indexOf(`function ${name}(`);
1892
+ assert.ok(start > -1, `${name} must exist`);
1893
+ const next = clientSource.slice(start + 1).indexOf("\n function ");
1894
+ return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length);
1895
+ };
1896
+ const view = bodyOf("PipelineView");
1897
+ assert.ok(view.includes("PipelineZoomBar"), "the zoom control must be rendered");
1898
+ assert.ok(view.includes("{ passive: false }"), "the wheel listener must be non-passive, or preventDefault is ignored");
1899
+ assert.ok(view.includes("ev.ctrlKey") && view.includes("ev.metaKey"), "plain wheel must stay a scroll, not a zoom");
1900
+ assert.ok(view.includes('width: (zoom * 100) + "%"'), "zoom must change the layout width so the container can scroll to it");
1901
+ const bar = bodyOf("PipelineZoomBar");
1902
+ assert.ok(bar.includes("memory.actions.zoom.in"), "zoom in must be reachable by button, not only by wheel");
1903
+ assert.ok(bar.includes("memory.actions.zoom.out"), "zoom out must be reachable by button");
1904
+ });
1905
+
1906
+ // 「tab 的类型」与「tab 的正文」必须是两件互不相干的事,而且类型注册要幂等。
1907
+ // 为什么会栽(2026-10-16 真机上现形):宿主对重复的类型 id 会抛
1908
+ // (sidebarRight: tab type id "…" is already registered),而类型注册挂在宿主服务上、
1909
+ // 插件重挂(刷新 / 热重载 / 会话重建)时**留着**,正文座位却是挂插件 scope、随插件回收的。
1910
+ // 两件事原来同一个 try —— 第二次挂载时那一抛把正文一起带走,界面上只剩宿主的 fallback
1911
+ // 「这类内容还没有可用的查看方式」,错误只在浏览器控制台里,界面上零痕迹。
1912
+ test("M.O. tab type registration is idempotent and isolated from the pane body", () => {
1913
+ const typeAt = clientSource.indexOf("id: PIPELINE_ACTIONS_TAB_ID,");
1914
+ const bodyAt = clientSource.indexOf('ctx.slots.inject("sidebar.right.pane.tab"');
1915
+ assert.ok(typeAt > -1, "the tab type registration must exist");
1916
+ assert.ok(bodyAt > typeAt, "the pane body registration must exist, after the type");
1917
+ assert.ok(
1918
+ clientSource.includes("tabs.get(PIPELINE_ACTIONS_TAB_ID)"),
1919
+ "the type registration must be idempotent — a re-mount must not re-register the id"
1920
+ );
1921
+ assert.ok(
1922
+ clientSource.slice(typeAt, bodyAt).includes("catch (err)"),
1923
+ "the type registration must carry its own catch — its failure must not take out the pane body"
1924
+ );
1925
+ });
1926
+
1927
+ // 回归锁:M.O. 段里每个 .mneme-pipe-* 类名在样式表里只许有**一条**规则。
1928
+ // 为什么会栽在这里(2026-10-16,真机上才现形):新写的详情屏容器与既有的「节点详情」面板
1929
+ // 撞了同名 .mneme-pipe-detail —— 后写的 flex:1 赢下冲突,先写的 max-height:180px; overflow:auto
1930
+ // 却留着,于是详情屏被钉在 180px、里面的画布 flex:1 分到 0 像素,屏幕上只剩空白加一条滚动条。
1931
+ // 只查颜色的 contrast.test.js 抓不到这类撞车;它只有在屏幕上看才现形,所以要在这里钉住。
1932
+ test("M.O. class names do not collide in the stylesheet", () => {
1933
+ const declared = new Map();
1934
+ for (const m of clientSource.matchAll(/"\.mneme-pipe-([a-z0-9-]+)\{/g)) {
1935
+ declared.set(m[1], (declared.get(m[1]) || 0) + 1);
1936
+ }
1937
+ const dupes = [...declared].filter(([, n]) => n > 1).map(([k, n]) => `.mneme-pipe-${k}×${n}`);
1938
+ assert.deepEqual(dupes, [], `duplicate .mneme-pipe-* rule(s): ${dupes.join(", ")}`);
1939
+ // 扫描量也得钉:正则一旦匹配不到东西,上面那条会静默变绿(例如样式表被整块挪走)。
1940
+ assert.ok(declared.size >= 20, `the scan must actually see the pipeline stylesheet (saw ${declared.size})`);
1941
+ });