@modusensus/dsh-mneme 0.8.16 → 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 (73) hide show
  1. package/README.md +16 -3
  2. package/assets/icon.svg +2 -2
  3. package/docs/CONFIGURATION.md +2 -1
  4. package/docs/CONTINUITY.md +18 -3
  5. package/docs/WORKFLOWS.md +299 -0
  6. package/lib/api.js +124 -8
  7. package/lib/candidates.js +454 -0
  8. package/lib/capabilities.js +819 -0
  9. package/lib/client.js +1703 -182
  10. package/lib/config.js +31 -5
  11. package/lib/continuity.js +35 -6
  12. package/lib/dream/decisions.js +17 -1
  13. package/lib/dream/sleep.js +10 -2
  14. package/lib/dream.js +5 -1
  15. package/lib/entities/extractor.js +4 -0
  16. package/lib/index.js +49 -12
  17. package/lib/inject.js +6 -1
  18. package/lib/pipeline.js +534 -0
  19. package/lib/scope.js +16 -4
  20. package/lib/serve.js +1 -1
  21. package/lib/service.js +59 -18
  22. package/lib/session-usage.js +350 -0
  23. package/lib/settings.js +7 -1
  24. package/lib/store.js +55 -3
  25. package/lib/summarize.js +31 -0
  26. package/lib/tools.js +11 -2
  27. package/package.json +1 -1
  28. package/scripts/publish-gate.mjs +197 -0
  29. package/src/api.js +124 -8
  30. package/src/candidates.js +454 -0
  31. package/src/capabilities.js +819 -0
  32. package/src/config.js +31 -5
  33. package/src/continuity.js +35 -6
  34. package/src/dream/decisions.js +17 -1
  35. package/src/dream/sleep.js +10 -2
  36. package/src/dream.js +5 -1
  37. package/src/entities/extractor.js +4 -0
  38. package/src/index.js +49 -12
  39. package/src/inject.js +6 -1
  40. package/src/pipeline.js +534 -0
  41. package/src/scope.js +16 -4
  42. package/src/serve.js +1 -1
  43. package/src/service.js +59 -18
  44. package/src/session-usage.js +350 -0
  45. package/src/settings.js +7 -1
  46. package/src/store.js +55 -3
  47. package/src/summarize.js +31 -0
  48. package/src/tools.js +11 -2
  49. package/test/api.test.js +147 -7
  50. package/test/candidates.test.js +345 -0
  51. package/test/capabilities.test.js +577 -0
  52. package/test/client.test.js +361 -2
  53. package/test/continuity.test.js +84 -0
  54. package/test/embed-ready-poll.test.js +94 -0
  55. package/test/entities.test.js +13 -2
  56. package/test/failure-query.test.js +147 -0
  57. package/test/failure-retention.test.js +105 -0
  58. package/test/graph-api.test.js +36 -2
  59. package/test/inject-recall.test.js +32 -4
  60. package/test/llm-audit.test.js +92 -1
  61. package/test/mint-source.test.js +202 -0
  62. package/test/pipeline.test.js +505 -0
  63. package/test/placement.test.js +1 -1
  64. package/test/publish-gate.test.js +272 -0
  65. package/test/recall-retention.test.js +128 -0
  66. package/test/release-workflow.test.js +77 -0
  67. package/test/scope-distill-stamp.test.js +135 -0
  68. package/test/serve.test.js +1 -1
  69. package/test/session-usage.test.js +308 -0
  70. package/test/store-write-path-columns.test.js +191 -0
  71. package/test/summarize.test.js +176 -0
  72. package/test/supersede-state-tracking.test.js +4 -4
  73. package/test/tools.test.js +16 -1
package/lib/client.js CHANGED
@@ -74,15 +74,12 @@ window.__ModuleLoader__.load({
74
74
  // 这是记忆插件(issue 走维护者自开通道);
75
75
  // · 宿主耦合——宿主图标名会换代(#287:Outline20 → OutlineRegular/Medium),
76
76
  // 只认某一代就取到 undefined,h(undefined) 即 React #130 把整个 slot entry
77
- // 崩掉。自绘后这一整类问题消失,也不再需要「探测多代名字 + 缺名降级」。
78
- // 形状:单线螺旋(记忆痕迹 / 召回回路)。规格逐条对齐宿主图标,才不像外来户:
79
- // 16 栅格、fill:none、stroke:currentColor、描边 1(宿主 Regular 档)、圆头圆角。
80
- // 路径是 6 段三次贝塞尔拟合同一螺旋(拟合版与逐点采样版逐像素一致,已核验),
81
- // 所以源码里只有 200 字符,不是一百多个采样点。
82
- // 改形状只需换 MNEME_MARK_D,并同步 assets/icon.svg(插件列表那份,同一个 d)。
83
- const MNEME_MARK_D = "M4.43 12.31C2.19 10.17 2.27 6.81 4.18 4.84C6.10 2.88 9.06 2.99 10.74 4.68C12.43 6.37 12.28 8.92 10.82 10.33C9.35 11.74 7.22 11.55 6.08 10.32C4.95 9.08 5.18 7.35 6.18 6.50C6.57 6.17 7.03 5.99 7.48 5.97";
84
-
85
- /** mneme mark 的内联 SVG;currentColor + 描边 1,随主题与字号走。 */
77
+ // 崩掉。改用本地 Lucide 路径数据后,不再需要「探测多代名字 + 缺名降级」。
78
+ // Lucide Brain(项目现有 Lucide 1.42 图标数据,ISC);静态内嵌路径,避免插件运行时加载 ESM 包。
79
+ // 宿主图标名会随版本换代,因此插件标识仍由本地 helper 渲染;插件列表 SVG 与这里共用同一路径。
80
+ const MNEME_MARK_D = "M12 18V5M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5M17.997 5.125a4 4 0 0 1 2.526 5.77M18 18a4 4 0 0 0 2-7.464M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517M6 18a4 4 0 0 1-2-7.464M6.003 5.125a4 4 0 0 0-2.526 5.77";
81
+
82
+ /** 24 栅格 Lucide Brain 映射到宿主 16 栅格,颜色仍随主题走。 */
86
83
  const MnemeMark = ({ size = 16, className }) => h("svg", {
87
84
  width: size,
88
85
  height: size,
@@ -92,10 +89,10 @@ window.__ModuleLoader__.load({
92
89
  xmlns: "http://www.w3.org/2000/svg",
93
90
  "aria-hidden": "true",
94
91
  stroke: "currentColor",
95
- strokeWidth: 1,
92
+ strokeWidth: 2,
96
93
  strokeLinecap: "round",
97
94
  strokeLinejoin: "round"
98
- }, h("path", { d: MNEME_MARK_D }));
95
+ }, h("path", { d: MNEME_MARK_D, transform: "scale(0.666667)" }));
99
96
 
100
97
  /** 渲染 mneme mark:四个入口(浮层标题栏 / footer 回退 / 侧边栏 portal / better-sidebar tab)共用同一颗。 */
101
98
  const renderMnemeMark = (props) => h(MnemeMark, props);
@@ -425,30 +422,48 @@ window.__ModuleLoader__.load({
425
422
  "memory.tab.constraint": "工程约束",
426
423
  "memory.tab.document": "文档",
427
424
  "memory.features.title": "功能开关",
428
- "memory.features.desc": "控制插件在后台做哪些事。改动即时保存,重启 DSH 后生效。",
429
- "memory.features.group.core": "基础",
430
- "memory.features.group.enhance": "检索与增强",
431
- "memory.features.group.dream": "巩固与睡眠",
432
- "memory.features.group.scope": "多会话隔离",
425
+ "memory.features.desc": "控制插件在后台做哪些事。先看「常用」里的总开关就够了,其余项收在「全部设置」里。改动即时保存,重启 DSH 后生效。",
426
+ // Issue #427:分组原先按「实现」分(基础/检索与增强/巩固与睡眠/多会话隔离
427
+ // 是架构图的分区),而人真正会问的是四个问题:它会记住什么、它什么时候
428
+ // 写进去、它怎么用回来、不同会话会不会串。组名改成人话,术语留在 hint。
429
+ // group.basic 是首屏层:新用户不读文档就该答得出「会不会自动记」;
430
+ // 「别的会话能不能看到」由同在首屏的 scope 组回答。
431
+ "memory.features.group.basic": "常用(先动这几个)",
432
+ "memory.features.group.core": "它会记住什么",
433
+ "memory.features.group.enhance": "它什么时候写进去",
434
+ "memory.features.group.dream": "它怎么用回来",
435
+ "memory.features.group.scope": "不同会话之间",
433
436
  "memory.features.group.advanced": "高级",
434
- "memory.features.advancedToggle": "高级(注入策略 · 反思 · 实验项)",
437
+ // 「全部设置」= 第一层折叠(issue #427 建议 1),常显只留 basic + scope
438
+ // 两组;「高级」是它内部的第二层(进阶 7 项)。
439
+ "memory.features.allToggle": "全部设置",
440
+ "memory.features.advancedToggle": "更少用的 7 个开关",
435
441
  "memory.features.restartHint": "重启 DSH 后生效",
436
442
  "memory.features.loadFailed": "加载失败",
437
- "memory.features.autoInject": "自动注入",
438
- "memory.features.autoInject.hint": "每轮对话自动携带相关记忆",
439
- "memory.features.injectGuidanceEnabled": "能力说明",
440
- "memory.features.injectGuidanceEnabled.hint": "教 Agent 怎么用记忆:什么时候该查、什么时候该写、拿不准就不动",
441
- "memory.features.continuityRescueEnabled": "压缩边缘抢救",
442
- "memory.features.continuityRescueEnabled.hint": "长对话被压缩前,先存一份「正在做什么 / 下一步 / 未决问题」的快照,让关键上下文活过压缩",
443
- "memory.features.parentOff": "父开关关闭时不生效",
444
- "memory.features.autoSummarize": "自动总结",
445
- "memory.features.autoSummarize.hint": "对话结束自动提炼记忆条目",
446
- "memory.features.hotMemoryEnabled": "热记忆",
447
- "memory.features.hotMemoryEnabled.hint": "最近几轮对话原文随注入携带,不写入长期记忆",
448
- "memory.features.injectTimePrefix": "对话开始注入当前时间",
449
- "memory.features.injectTimePrefix.hint": "每次新会话的首轮注入一次日期与星期(默认关)",
450
- "memory.features.entityExtractionEnabled": "实体抽取",
451
- "memory.features.entityExtractionEnabled.hint": "从记忆中提取人物/项目/概念,图谱随之生长",
443
+ "memory.features.saveFailed": "保存失败",
444
+ // Issue #427 建议 2:条目名给「打开会怎样、关掉会怎样」,术语放末尾。
445
+ // 括号里的英文是设置项键名,末尾的「(BM25)」才是术语。
446
+ "memory.features.autoInject": "自动带上旧记忆(每轮都用)",
447
+ "memory.features.autoInject.hint": "开:每轮自动挑相关记忆塞给 Agent,它不用问就能想起;关:Agent 得自己调用查记忆的工具",
448
+ "memory.features.injectGuidanceEnabled": "顺带教 Agent 怎么用记忆",
449
+ "memory.features.injectGuidanceEnabled.hint": "开:在记忆工具的说明里写清什么时候该查、什么时候该写;关:只给工具不给用法",
450
+ "memory.features.continuityRescueEnabled": "长对话被压缩前先存一份快照",
451
+ "memory.features.continuityRescueEnabled.hint": "开:压缩前先存「正在做什么 / 下一步 / 未决问题」,关键上下文活过压缩;关:压缩可能把它压掉",
452
+ "memory.features.parentOff": "上一项关闭时不生效",
453
+ // 「默认 / 已改」徽标:overrides 里没有这个键 = 值来自 config schema 的
454
+ // 默认值,一条命令就能退回。徽标让这件事不必去读配置文件才成立。
455
+ "memory.features.tagDefault": "默认",
456
+ "memory.features.tagDefault.hint": "这一项用的是出厂默认值,没有被你改过",
457
+ "memory.features.tagChanged": "已改",
458
+ "memory.features.tagChanged.hint": "你改过这一项,它不再用默认值",
459
+ "memory.features.autoSummarize": "自动把对话记下来(自动注入之外)",
460
+ "memory.features.autoSummarize.hint": "开:对话结束自动提炼成长期记忆条目(autoSummarize);关:只有你手动存或 Agent 主动存才有记忆",
461
+ "memory.features.hotMemoryEnabled": "顺带带上最近几轮原话",
462
+ "memory.features.hotMemoryEnabled.hint": "开:注入时把最近几轮的对话原文一并带上,Agent 眼前就是刚才聊的内容;关:只带提炼后的长期记忆。这几轮原文不会写进长期记忆(热记忆,默认开)",
463
+ "memory.features.injectTimePrefix": "会话开头告诉它今天几号",
464
+ "memory.features.injectTimePrefix.hint": "开:每个新会话的第一轮注入一次日期与星期,「今天」「上周」这类说法更稳;关:注入里不带时间(时间前缀,默认关)",
465
+ "memory.features.entityExtractionEnabled": "顺手抽出人名、项目和概念",
466
+ "memory.features.entityExtractionEnabled.hint": "开:每条新记忆里抽出人物/项目/概念,关系图谱自己长起来,也能按「谁」找记忆;关:只存整条记忆,不建关系(实体抽取,默认关)",
452
467
  "memory.features.entityExtractionProvider": "实体抽取 Provider",
453
468
  "memory.features.entityExtractionModel": "实体抽取模型",
454
469
  "memory.features.entityExtractionReasoning": "实体抽取思考强度",
@@ -464,68 +479,140 @@ window.__ModuleLoader__.load({
464
479
  "memory.features.summarizeReasoningEffort.high": "高",
465
480
  "memory.features.summarizeReasoningEffort.hint": "思考型模型建议选「低」或「关闭思考」,避免推理占满输出导致总结失败",
466
481
  "memory.features.entityExtractionModelHint": "Provider / 模型留空 = 跟随主对话模型;思考强度选「跟随默认」= 服务商默认",
467
- "memory.features.codingRetrospect": "编码记忆蒸馏",
468
- "memory.features.codingRetrospect.hint": "回顾写代码的完整过程(含报错与工具调用),沉淀踩坑、约束和被否决的方案",
469
- "memory.features.rerankEnabled": "结果重排",
470
- "memory.features.rerankEnabled.hint": "本地重排模型对召回结果精排,更慢更准",
471
- "memory.features.heatEnabled": "热度衰减",
472
- "memory.features.heatEnabled.hint": "像遗忘曲线一样给记忆降温:越久没用到排得越靠后,不占注入名额",
473
- "memory.features.documentMemoryEnabled": "document 型记忆",
474
- "memory.features.documentMemoryEnabled.hint": "Agent 写的长文档只在记忆里放一张「摘要卡」:正文仍由 Agent 保管、按需读取,注入时只带摘要,不占上下文",
475
- "memory.features.resilientModelDownload": "模型下载断点续传",
476
- "memory.features.resilientModelDownload.hint": "模型下载中断后从断点续传并自动重试;关闭则回到一次性下载",
477
- "memory.features.searchSemanticDedup": "语义去重",
478
- "memory.features.searchSemanticDedup.hint": "搜索结果中意思相近的条目只保留一条",
479
- "memory.features.autoDream": "记忆巩固",
480
- "memory.features.autoDream.hint": "后台自动合并、沉淀碎片记忆",
481
- "memory.features.sleepModeEnabled": "睡眠模式",
482
- "memory.features.sleepModeEnabled.hint": "空闲时段做分层压缩与整理",
483
- "memory.features.hybridInject": "混合注入",
484
- "memory.features.hybridInject.hint": "关键词 + 向量双路召回后合并注入",
485
- "memory.features.selectiveInjectEnabled": "选择性注入",
486
- "memory.features.selectiveInjectEnabled.hint": "按相关性筛选,只注入值得携带的记忆",
487
- "memory.features.adaptiveThresholdEnabled": "自适应阈值",
488
- "memory.features.adaptiveThresholdEnabled.hint": "按召回质量动态调整相关性门槛",
489
- "memory.features.reflectionUpdateEnabled": "反思更新",
490
- "memory.features.reflectionUpdateEnabled.hint": "巩固时允许修正已有记忆",
482
+ "memory.features.codingRetrospect": "写代码的过程也记下来",
483
+ "memory.features.codingRetrospect.hint": "开:整段编码过程(含报错与工具调用)一并回顾,沉淀踩坑、约束和被否决的方案;关:只提炼对话内容(编码记忆蒸馏,默认关)",
484
+ "memory.features.rerankEnabled": "搜出来的结果再精排一遍",
485
+ "memory.features.rerankEnabled.hint": "开:本地重排模型给召回结果重新打分,更准但更慢;关:直接用检索时的排序(结果重排,默认关)",
486
+ "memory.features.heatEnabled": "越久没用越靠后(像会忘)",
487
+ "memory.features.heatEnabled.hint": "开:像遗忘曲线一样给记忆降温,长期没用过的往后排、不占注入名额;关:一直按相关度排(热度衰减,默认关)",
488
+ "memory.features.documentMemoryEnabled": "长文档只存一张摘要卡",
489
+ "memory.features.documentMemoryEnabled.hint": "开:Agent 写的长文档在记忆里只放「摘要卡」,正文仍由 Agent 保管、按需读取,注入只带摘要;关:整篇都往上下文里带(document 型记忆,默认关)",
490
+ // --- v0.9.0 M.O.(Modus Operandi · 手法,beta)---
491
+ // M.O. = Modus Operandi,拉丁语「一个人一贯的做事方式」;也是 org 词根
492
+ // modus(modusensus)。界面主显示 M.O.,设置项 hint 第一句给全名。
493
+ "memory.actions.beta": "beta",
494
+ "memory.actions.notice": "提炼结果是候选,可能有错、过时、会被推翻。",
495
+ "memory.actions.title": "M.O. · 手法",
496
+ "memory.actions.open": "打开 M.O.",
497
+ "memory.actions.viewFull": "完整视图",
498
+ "memory.actions.openPane": "在右栏打开完整视图",
499
+ "memory.actions.announce.open": "M.O. 面板已打开",
500
+ "memory.actions.announce.close": "M.O. 面板已关闭",
501
+ "memory.actions.close": "关闭",
502
+ "memory.actions.candidates": "候选 {n} 条",
503
+ "memory.actions.runs": "最近运行",
504
+ "memory.actions.runOf": "运行 {n}",
505
+ "memory.actions.run.ok": "成功",
506
+ "memory.actions.run.failed": "失败",
507
+ "memory.actions.run.refuted": "被推翻",
508
+ "memory.actions.state.candidate": "候选",
509
+ "memory.actions.state.trusted": "可信",
510
+ "memory.actions.state.stale": "过时",
511
+ "memory.actions.state.refuted": "被推翻",
512
+ "memory.actions.gate.has": "有闸门证据",
513
+ "memory.actions.gate.none": "还没闸门",
514
+ "memory.actions.gate.tag": "闸",
515
+ "memory.actions.who.main": "主",
516
+ "memory.actions.who.human": "人",
517
+ "memory.actions.who.ci": "CI",
518
+ "memory.actions.ov.done": "完成判据",
519
+ "memory.actions.ov.trigger": "何时触发",
520
+ "memory.actions.band.count": "{n} 步",
521
+ "memory.actions.legend.states": "状态",
522
+ "memory.actions.detail.situation": "情形",
523
+ "memory.actions.detail.action": "动作",
524
+ "memory.actions.detail.why": "为什么",
525
+ "memory.actions.detail.cf": "反事实",
526
+ "memory.actions.detail.noCf": "该节点还没有反事实记录",
527
+ "memory.actions.detail.source": "来源记忆",
528
+ "memory.actions.credit": "复用",
529
+ "memory.actions.creditValue": "{n} 次 · 上次 {d} 天前",
530
+ "memory.actions.dep": "依赖",
531
+ "memory.actions.zoom.in": "放大",
532
+ "memory.actions.zoom.out": "缩小",
533
+ "memory.actions.zoom.fit": "适应宽度",
534
+ "memory.actions.zoom.hint": "Ctrl/⌘ + 滚轮也能缩放",
535
+ "memory.actions.list.heading": "这个项目里的工作流",
536
+ "memory.actions.list.count": "工作流 {n} 条",
537
+ "memory.actions.list.meta": "{steps} 步 · {gates} 道闸门 · 反事实 {cf}/{steps}",
538
+ "memory.actions.scope.project": "项目 {name}",
539
+ "memory.actions.scope.global": "全局",
540
+ "memory.actions.scope.none": "项目未知",
541
+ "memory.actions.broken": "有文件读不出来:",
542
+ "memory.actions.empty.title": "这个项目里还没有工作流",
543
+ "memory.actions.empty.body": "工作流的本体住在项目里的 .mneme/pipelines/ 下——跟着仓库走,能被提交、能被 review、能被 CI 读。做法被复用第二次才算节点类型,第一次只是实例;同一路径被走通两次之后,它才会从会话里被提炼出来、落到那个目录里。",
544
+ "memory.actions.noRuns": "这一条还没有运行记录,所以每个节点都还是候选。",
545
+ "memory.actions.settle.title": "本次运行",
546
+ "memory.actions.settle.line": "「{title}」",
547
+ "memory.actions.settle.used": "本次复用 {n} 条做法(勾选为被用上)",
548
+ "memory.actions.settle.refutedWarn": "本次路径里有已被推翻的节点,结果仅供参考",
549
+ "memory.features.actionPipelineEnabled": "M.O.(beta)",
550
+ "memory.features.actionPipelineEnabled.hint": "⚠️ 开发中:目前只有「看」这一层能用,自己沉淀和触发都还没做,建议暂时保持关闭。Modus Operandi(手法):把真实会话里反复走通的「做法」连成一张只读图谱——会话头图标、右栏完整视图、每次运行的结算卡。重启 DSH 后生效",
551
+ "memory.features.resilientModelDownload": "模型下载断了能续上",
552
+ "memory.features.resilientModelDownload.hint": "开:下载中断后从断点续传并自动重试;关:中断就整份重来(断点续传,默认开)",
553
+ "memory.features.searchSemanticDedup": "搜到意思相同的只留一条",
554
+ "memory.features.searchSemanticDedup.hint": "开:检索结果里意思相近的条目合并成一条,少占名额;关:同义条目一起出现(语义去重,默认关)",
555
+ "memory.features.autoDream": "后台自动整理记忆(合并 · 去重)",
556
+ "memory.features.autoDream.hint": "开:后台自动合并、沉淀碎片记忆(autoDream);关:记忆只增不减,越用越散",
557
+ "memory.features.sleepModeEnabled": "你不在的时候它整理记忆",
558
+ "memory.features.sleepModeEnabled.hint": "开:空闲时段做分层压缩与整理,白天少打扰;关:只有你手动触发的整理(睡眠模式,默认关)",
559
+ // #427 建议 5:hybridInject / selectiveInjectEnabled / adaptiveThresholdEnabled
560
+ // 是同一件事「带什么回来」的三档,相邻摆放并在 hint 里点明,不再摊成
561
+ // 三个不相干的并列开关。
562
+ "memory.features.hybridInject": "关键词 + 意思 两路一起找",
563
+ "memory.features.hybridInject.hint": "开:两路检索各取一批再合并,召回更全;关:只用一路。「带什么回来」共三档(混合注入 / 选择性注入 / 自适应阈值),可只开其中一档(混合注入,默认开)",
564
+ "memory.features.selectiveInjectEnabled": "只带真正相关的记忆",
565
+ "memory.features.selectiveInjectEnabled.hint": "开:按相关性筛一遍再注入,上下文更干净;关:带出去的更多也更杂。三档之一(选择性注入,默认开)",
566
+ "memory.features.adaptiveThresholdEnabled": "按检索质量自动调松紧",
567
+ "memory.features.adaptiveThresholdEnabled.hint": "开:召回质量差时放宽门槛、好时收紧;关:门槛固定。三档之一(自适应阈值,默认开)",
568
+ "memory.features.reflectionUpdateEnabled": "整理时允许改已有记忆",
569
+ "memory.features.reflectionUpdateEnabled.hint": "开:巩固可以改写、合并已有条目,不是只增不改;关:旧记忆永不被自动改动(反思更新,默认开)",
491
570
  "memory.features.distill": "蒸馏与限流",
492
571
  "memory.features.distillRateLimitIntervalMs": "蒸馏放行间隔 (ms)",
493
572
  "memory.features.distillRateLimitRetries": "429 重试次数",
494
573
  "memory.features.distillRateLimitBaseDelayMs": "退避起始延迟 (ms)",
495
574
  "memory.features.distillMaxChars": "转录截断上限(字符)",
575
+ // #399 的两个留存键。此前只有后端白名单、面板够不着,而
576
+ // docs/CONFIGURATION.md 写的是「面板可启停」——补 UI 让文档成真。
577
+ // hint 必须写清两件事:这是**原始对话**落盘(隐私面),以及天数只管
578
+ // 启动期裁剪(reclaim 走另一条 --older-than,不读这个键)。
579
+ "memory.features.distillRecords.enabled": "留存蒸馏原文",
580
+ "memory.features.distillRecords.enabled.hint": "蒸馏时把窗口内的公开对话原文一并落盘,并记下每条记忆的来源区间;私密推理与工具结果不落。关 = 一行不写、连收集都不做",
581
+ "memory.features.distillRecords.retentionDays": "原文保留天数",
582
+ "memory.features.distillRecords.retentionDays.hint": "1–3650 天;只管启动期裁剪。想让 reclaim 也按这个天数回收,需显式传 --older-than 同一数值",
496
583
  "memory.features.codingBoostFactor": "编码记忆加权",
497
- "memory.features.memoryQualityFilter.enabled": "记忆质量过滤",
498
- "memory.features.memoryQualityFilter.enabled.hint": "低价值记忆自动归档,注入按质量降权",
499
- "memory.features.llmAudit.enabled": "后台调用审计",
500
- "memory.features.llmAudit.enabled.hint": "记录巩固/总结的后台模型调用与 token 消耗",
584
+ "memory.features.memoryQualityFilter.enabled": "没价值的记忆自动收起来",
585
+ "memory.features.memoryQualityFilter.enabled.hint": "开:低价值条目自动归档、注入时按质量降权;关:什么都留着,噪声也一起带(记忆质量过滤,默认开)",
586
+ "memory.features.llmAudit.enabled": "记下后台用了多少模型调用",
587
+ "memory.features.llmAudit.enabled.hint": "开:巩固/总结的后台调用与 token 消耗都有账可查;关:后台开销无从核对(调用审计,默认开)",
501
588
  // #254:写入准入两层。检测关时整条路径不碰;检测开、拦截关 = 仅告警 +
502
589
  // 留审计;两个都开才真拦。文案要写清「只留审计」与「真拦下」的区别,
503
590
  // 否则操作者会以为打开检测就已经在拦。
504
- "memory.features.writeAdmission.enabled": "写入准入判定",
505
- "memory.features.writeAdmission.enabled.hint": "写入前判空白/噪声,命中留审计(不拦)",
506
- "memory.features.writeAdmission.enforce": "写入准入拦截",
507
- "memory.features.writeAdmission.enforce.hint": "真拦下命中的写入;关闭时只留审计不拦",
508
- "memory.features.preInjectGate.enabled": "注入前判定",
509
- "memory.features.preInjectGate.enabled.hint": "注入前判意见/立场记忆,先观察分布(不拦)",
510
- "memory.features.preInjectGate.enforce": "注入前判定拦截",
511
- "memory.features.preInjectGate.enforce.hint": "判定命中的候选在注入时滤除;关闭时只留审计不拦",
591
+ "memory.features.writeAdmission.enabled": "写入前先判一下是不是废话(只记不拦)",
592
+ "memory.features.writeAdmission.enabled.hint": "开:空白/噪声写入判为命中并留一条审计记录,但仍存进去;关:完全不判。要真拦,见下一项(写入准入判定,默认关)",
593
+ "memory.features.writeAdmission.enforce": "真把没用的写入挡下来",
594
+ "memory.features.writeAdmission.enforce.hint": "开:命中直接拒绝写入;关:只留审计、照存不误。需先打开上面那项判定(写入准入拦截,默认关)",
595
+ "memory.features.preInjectGate.enabled": "注入前先筛掉「只是我的看法」的记忆(只记不拦)",
596
+ "memory.features.preInjectGate.enabled.hint": "开:注入前判出意见/立场类记忆并留审计,先观察分布;关:照常注入(注入前判定,默认关)",
597
+ "memory.features.preInjectGate.enforce": "注入时真滤掉判定不合适的",
598
+ "memory.features.preInjectGate.enforce.hint": "开:命中的候选在注入时丢掉;关:只留审计、照常注入。需先打开上面那项判定(注入前判定拦截,默认关)",
512
599
  // #164 A2:密钥/PII 判据自己的闸。文案要写清它管的是哪一批判据(与上面的
513
600
  // 空白/噪声分开)、它跑在「写入准入判定」里面(那一项关着就不跑),以及
514
601
  // 「开着也不拦」——拦是上面那项的事。
515
- "memory.features.sensitiveScanEnabled": "密钥/PII 扫描",
516
- "memory.features.sensitiveScanEnabled.hint": "写入前扫密钥与个人信息;需先打开写入准入判定才会执行,命中只留审计,要拦再打开写入准入拦截",
517
- "memory.features.bm25SearchEnabled": "BM25 关键词检索",
518
- "memory.features.bm25SearchEnabled.hint": "传统关键词打分检索,与向量召回互补",
519
- "memory.features.conflictFreezeEnabled": "冲突冻结",
520
- "memory.features.conflictFreezeEnabled.hint": "巩固发现互相矛盾的记忆时先冻结待确认",
521
- "memory.features.scopeEnabled": "作用域标注",
522
- "memory.features.scopeEnabled.hint": "新记忆自动记下来自哪个会话(Agent / 工作区),供隔离与检索过滤使用",
523
- "memory.features.strictScope": "严格隔离",
524
- "memory.features.strictScope.hint": "开启后,其他会话明确设为私有的记忆对你完全不可见;关闭时仍可见、只是排到后面。要做到彻底隔离,请用条目的「敏感度」字段",
525
- "memory.features.trustEpistemicWeighting": "可信度加权",
526
- "memory.features.trustEpistemicWeighting.hint": "按来源可信度调整召回排序(实验性)",
527
- "memory.features.reflectionFailureTracking": "反思失败追踪",
528
- "memory.features.reflectionFailureTracking.hint": "记录巩固失败的案例供后续改进(诊断用)",
602
+ "memory.features.sensitiveScanEnabled": "写入前扫一遍密钥和个人信息",
603
+ "memory.features.sensitiveScanEnabled.hint": "开:在写入准入判定里面扫密钥/个人信息,命中只留审计;要真拦,再打开「写入准入拦截」。判定关着时它不跑(密钥/PII 扫描,默认关)",
604
+ "memory.features.bm25SearchEnabled": "也用关键词方式找记忆",
605
+ "memory.features.bm25SearchEnabled.hint": "开:多一个字少一个字也搜得到,专名、报错码、路径更易命中,与「按意思找」互补;关:只按语义相近度(BM25 关键词检索,默认开)",
606
+ "memory.features.conflictFreezeEnabled": "互相矛盾的记忆先冻结",
607
+ "memory.features.conflictFreezeEnabled.hint": "开:整理发现矛盾时先冻结待你确认,不让两条同时生效;关:两边都照用(冲突冻结,默认关)",
608
+ "memory.features.scopeEnabled": "记住每条记忆来自哪个会话",
609
+ "memory.features.scopeEnabled.hint": "开:每条新记忆标上来自哪个 Agent / 工作区,别的会话能不能看到由它说了算;关:新记忆不带来源,所有会话共用一批记忆(作用域标注,默认关)",
610
+ "memory.features.strictScope": "别人设为私有的记忆我看不见",
611
+ "memory.features.strictScope.hint": "开:其他会话明确标私有的记忆对你彻底不可见;关:仍可见、只是排到后面。要彻底隔离请用条目的「敏感度」字段。需先打开上面那项标注(严格隔离,默认关)",
612
+ "memory.features.trustEpistemicWeighting": "按来源可信度调排序",
613
+ "memory.features.trustEpistemicWeighting.hint": "开:可信度高的来源排前面;关:一视同仁(可信度加权,实验性,默认关)",
614
+ "memory.features.reflectionFailureTracking": "整理失败时留下案例",
615
+ "memory.features.reflectionFailureTracking.hint": "开:把失败原因与案例记下来供诊断改进;关:失败不留记录(失败追踪,默认开)",
529
616
  "memory.features.embedRoute": "语义检索路线",
530
617
  "memory.features.embedProvider": "Embedding 提供方",
531
618
  "memory.features.embedProvider.openai": "OpenAI 兼容接口",
@@ -553,6 +640,7 @@ window.__ModuleLoader__.load({
553
640
  "memory.features.modelTestOk": "连通正常",
554
641
  "memory.features.modelTestFail": "测试失败",
555
642
  "memory.features.modelTestHint": "真实发起一次最小巩固调用,验证 Provider/模型连通与 effort 支持",
643
+ "memory.features.modelTestDowngraded": "档位不被该模型接受,已去掉档位连通(与真实巩固一致)",
556
644
  "memory.features.routeStaleMark": "(不在可用列表)",
557
645
  "memory.features.routeStaleHint": "该值不在当前可用列表(常见于切换过 Provider):请重新选择,或点「测试连通性」当场验证",
558
646
  "memory.explorer.viewCards": "卡片",
@@ -968,30 +1056,43 @@ window.__ModuleLoader__.load({
968
1056
  "memory.tab.constraint": "Constraints",
969
1057
  "memory.tab.document": "Documents",
970
1058
  "memory.features.title": "Features",
971
- "memory.features.desc": "Choose what the plugin does in the background. Changes are saved immediately and take effect after restarting DSH.",
972
- "memory.features.group.core": "Basics",
973
- "memory.features.group.enhance": "Search & enrichment",
974
- "memory.features.group.dream": "Consolidation & sleep",
975
- "memory.features.group.scope": "Multi-session isolation",
1059
+ "memory.features.desc": "Choose what the plugin does in the background. The Common switches at the top are all you need to start; everything else lives under All settings. Changes are saved immediately and take effect after restarting DSH.",
1060
+ // Issue #427: groups used to be implementation slices (the architecture
1061
+ // diagram's partitions). What a person actually asks is four questions —
1062
+ // what gets remembered, when it gets written, how it comes back, and
1063
+ // whether sessions bleed into each other. Terminology stays in the hints.
1064
+ // group.basic is the first-screen tier; the scope group sits there too so
1065
+ // "can other sessions see this?" is answerable without opening anything.
1066
+ "memory.features.group.basic": "Common (start here)",
1067
+ "memory.features.group.core": "What gets remembered",
1068
+ "memory.features.group.enhance": "When it gets written",
1069
+ "memory.features.group.dream": "How it comes back",
1070
+ "memory.features.group.scope": "Between sessions",
976
1071
  "memory.features.group.advanced": "Advanced",
977
- "memory.features.advancedToggle": "Advanced (injection · reflection · experimental)",
1072
+ "memory.features.allToggle": "All settings",
1073
+ "memory.features.advancedToggle": "7 rarer switches",
978
1074
  "memory.features.restartHint": "Takes effect after restarting DSH",
979
1075
  "memory.features.loadFailed": "Failed to load",
980
- "memory.features.autoInject": "Auto injection",
981
- "memory.features.autoInject.hint": "Carry relevant memories into every turn",
982
- "memory.features.injectGuidanceEnabled": "Capability guide",
983
- "memory.features.injectGuidanceEnabled.hint": "Teach the agent how to use memory: when to search, when to save, when to leave things alone",
984
- "memory.features.continuityRescueEnabled": "Compaction-edge rescue",
985
- "memory.features.continuityRescueEnabled.hint": "Before a long conversation gets compacted, store a snapshot of what's in progress / next steps / open questions so key context survives the compaction",
986
- "memory.features.parentOff": "Inactive while auto injection is off",
987
- "memory.features.autoSummarize": "Auto summarization",
988
- "memory.features.autoSummarize.hint": "Distill memory entries when a conversation ends",
989
- "memory.features.hotMemoryEnabled": "Hot memory",
990
- "memory.features.hotMemoryEnabled.hint": "Carry the last few turns verbatim; never written to long-term memory",
991
- "memory.features.injectTimePrefix": "Inject current time at conversation start",
992
- "memory.features.injectTimePrefix.hint": "Adds the date and weekday once at each new session's first turn (off by default)",
993
- "memory.features.entityExtractionEnabled": "Entity extraction",
994
- "memory.features.entityExtractionEnabled.hint": "Extract people / projects / concepts so the graph grows by itself",
1076
+ "memory.features.saveFailed": "Failed to save",
1077
+ "memory.features.autoInject": "Carry old memories into every turn",
1078
+ "memory.features.autoInject.hint": "On: relevant memories are handed to the agent every turn, so it remembers without being asked. Off: the agent has to call the search tool itself",
1079
+ "memory.features.injectGuidanceEnabled": "Also teach the agent how to use memory",
1080
+ "memory.features.injectGuidanceEnabled.hint": "On: the memory tools' description says when to search and when to write. Off: the tools ship without guidance",
1081
+ "memory.features.continuityRescueEnabled": "Snapshot before a long chat gets compacted",
1082
+ "memory.features.continuityRescueEnabled.hint": "On: a snapshot of what's in progress / next steps / open questions is stored before compaction, so key context survives it. Off: compaction can crush it",
1083
+ "memory.features.parentOff": "Inactive while the switch above is off",
1084
+ "memory.features.tagDefault": "default",
1085
+ "memory.features.tagDefault.hint": "This one is at its factory default — you have not changed it",
1086
+ "memory.features.tagChanged": "changed",
1087
+ "memory.features.tagChanged.hint": "You changed this one; it no longer uses the default",
1088
+ "memory.features.autoSummarize": "Turn conversations into memory automatically",
1089
+ "memory.features.autoSummarize.hint": "On: memory entries are distilled when a conversation ends (autoSummarize). Off: memory only exists if you save it, or the agent decides to",
1090
+ "memory.features.hotMemoryEnabled": "Carry the last few turns verbatim",
1091
+ "memory.features.hotMemoryEnabled.hint": "On: the recent turns ride along with each injection, so the agent sees what was just said. Off: only distilled memories travel. Those turns are never written to long-term memory (hot memory, on by default)",
1092
+ "memory.features.injectTimePrefix": "Tell it today's date at session start",
1093
+ "memory.features.injectTimePrefix.hint": "On: the first turn of each new session gets the date and weekday once, so \"today\" and \"last week\" land reliably. Off: no time in the injection (time prefix, off by default)",
1094
+ "memory.features.entityExtractionEnabled": "Also pull out people, projects and concepts",
1095
+ "memory.features.entityExtractionEnabled.hint": "On: each new memory yields people / projects / concepts, so the relation graph grows by itself and recall can key on who. Off: memories stay whole, no relations (entity extraction, off by default)",
995
1096
  "memory.features.entityExtractionProvider": "Entity extraction provider",
996
1097
  "memory.features.entityExtractionModel": "Entity extraction model",
997
1098
  "memory.features.entityExtractionReasoning": "Entity extraction reasoning",
@@ -1007,68 +1108,134 @@ window.__ModuleLoader__.load({
1007
1108
  "memory.features.summarizeReasoningEffort.high": "High",
1008
1109
  "memory.features.summarizeReasoningEffort.hint": "For thinking models, prefer Low or No reasoning so thinking cannot fill the output budget and break summaries",
1009
1110
  "memory.features.entityExtractionModelHint": "Provider / model empty = follow the main conversation model; reasoning \"Follow default\" = provider default",
1010
- "memory.features.codingRetrospect": "Coding retrospection",
1011
- "memory.features.codingRetrospect.hint": "Review the full coding session (tools and errors included) and distill pitfalls, constraints and rejected solutions",
1012
- "memory.features.rerankEnabled": "Reranking",
1013
- "memory.features.rerankEnabled.hint": "Rerank recalled results with a local model — slower, more precise",
1014
- "memory.features.heatEnabled": "Heat decay",
1015
- "memory.features.heatEnabled.hint": "Cool memories down like a forgetting curve: the longer unused, the lower they rank — they stop taking injection slots",
1016
- "memory.features.documentMemoryEnabled": "Document memory",
1017
- "memory.features.documentMemoryEnabled.hint": "Long agent-authored documents are stored as a \"summary card\" only: the full text stays agent-owned and is read on demand, so injection carries the summary without filling the context",
1018
- "memory.features.resilientModelDownload": "Resumable model downloads",
1019
- "memory.features.resilientModelDownload.hint": "Resume model downloads from where they stopped and retry automatically; off = one-shot downloads",
1020
- "memory.features.searchSemanticDedup": "Semantic dedup",
1021
- "memory.features.searchSemanticDedup.hint": "Keep one entry when search hits near-duplicates",
1022
- "memory.features.autoDream": "Memory consolidation",
1023
- "memory.features.autoDream.hint": "Merge and settle fragmented memories in the background",
1024
- "memory.features.sleepModeEnabled": "Sleep mode",
1025
- "memory.features.sleepModeEnabled.hint": "Layered compaction during idle periods",
1026
- "memory.features.hybridInject": "Hybrid injection",
1027
- "memory.features.hybridInject.hint": "Merge keyword + vector recall before injecting",
1028
- "memory.features.selectiveInjectEnabled": "Selective injection",
1029
- "memory.features.selectiveInjectEnabled.hint": "Only inject memories worth carrying, filtered by relevance",
1030
- "memory.features.adaptiveThresholdEnabled": "Adaptive threshold",
1031
- "memory.features.adaptiveThresholdEnabled.hint": "Adjust the relevance floor with recall quality",
1032
- "memory.features.reflectionUpdateEnabled": "Reflective update",
1033
- "memory.features.reflectionUpdateEnabled.hint": "Allow consolidation to revise existing memories",
1111
+ "memory.features.codingRetrospect": "Remember how the code was written too",
1112
+ "memory.features.codingRetrospect.hint": "On: the whole coding session (errors and tool calls included) is reviewed, yielding pitfalls, constraints and rejected solutions. Off: only conversation content is distilled (coding retrospection, off by default)",
1113
+ "memory.features.rerankEnabled": "Rerank search results once more",
1114
+ "memory.features.rerankEnabled.hint": "On: a local rerank model re-scores recalled candidates — more precise, slower. Off: the search-time order stands (reranking, off by default)",
1115
+ "memory.features.heatEnabled": "The longer unused, the lower it ranks",
1116
+ "memory.features.heatEnabled.hint": "On: memories cool down like a forgetting curve — long-unused ones rank later and stop taking injection slots. Off: relevance order always (heat decay, off by default)",
1117
+ "memory.features.documentMemoryEnabled": "Store long documents as a summary card",
1118
+ "memory.features.documentMemoryEnabled.hint": "On: long agent-authored documents live in memory as a \"summary card\"; the full text stays agent-owned and is read on demand. Off: the whole document travels into context (document memory, off by default)",
1119
+ // --- v0.9.0 M.O. / Modus Operandi (beta) ---
1120
+ "memory.actions.beta": "Beta",
1121
+ "memory.actions.notice": "Extracted steps are candidates: they may be wrong, outdated, or refuted.",
1122
+ "memory.actions.title": "Modus Operandi",
1123
+ "memory.actions.open": "Open Modus Operandi",
1124
+ "memory.actions.viewFull": "Full view",
1125
+ "memory.actions.openPane": "Open full view in sidebar",
1126
+ "memory.actions.announce.open": "Modus Operandi panel opened",
1127
+ "memory.actions.announce.close": "Modus Operandi panel closed",
1128
+ "memory.actions.close": "Close",
1129
+ "memory.actions.candidates": "{n} candidates",
1130
+ "memory.actions.runs": "Recent runs",
1131
+ "memory.actions.runOf": "Run {n}",
1132
+ "memory.actions.run.ok": "ok",
1133
+ "memory.actions.run.failed": "failed",
1134
+ "memory.actions.run.refuted": "refuted",
1135
+ "memory.actions.state.candidate": "candidate",
1136
+ "memory.actions.state.trusted": "trusted",
1137
+ "memory.actions.state.stale": "stale",
1138
+ "memory.actions.state.refuted": "refuted",
1139
+ "memory.actions.gate.has": "has counterfactual",
1140
+ "memory.actions.gate.none": "no gate yet",
1141
+ "memory.actions.gate.tag": "GATE",
1142
+ "memory.actions.who.main": "main",
1143
+ "memory.actions.who.human": "human",
1144
+ "memory.actions.who.ci": "CI",
1145
+ "memory.actions.ov.done": "Done when",
1146
+ "memory.actions.ov.trigger": "Triggered by",
1147
+ "memory.actions.band.count": "{n} steps",
1148
+ "memory.actions.legend.states": "States",
1149
+ "memory.actions.detail.situation": "Situation",
1150
+ "memory.actions.detail.action": "Action",
1151
+ "memory.actions.detail.why": "Why",
1152
+ "memory.actions.detail.cf": "Counterfactual",
1153
+ "memory.actions.detail.noCf": "No counterfactual recorded yet",
1154
+ "memory.actions.detail.source": "Source memories",
1155
+ "memory.actions.credit": "Reuse",
1156
+ "memory.actions.creditValue": "{n} uses · last {d}d ago",
1157
+ "memory.actions.dep": "depends on",
1158
+ "memory.actions.zoom.in": "Zoom in",
1159
+ "memory.actions.zoom.out": "Zoom out",
1160
+ "memory.actions.zoom.fit": "Fit width",
1161
+ "memory.actions.zoom.hint": "Ctrl/⌘ + wheel also zooms",
1162
+ "memory.actions.list.heading": "Workflows in this project",
1163
+ "memory.actions.list.count": "{n} workflow(s)",
1164
+ "memory.actions.list.meta": "{steps} steps · {gates} gates · counterfactual {cf}/{steps}",
1165
+ "memory.actions.scope.project": "project {name}",
1166
+ "memory.actions.scope.global": "global",
1167
+ "memory.actions.scope.none": "project unknown",
1168
+ "memory.actions.broken": "Some files could not be read:",
1169
+ "memory.actions.empty.title": "No workflows in this project yet",
1170
+ "memory.actions.empty.body": "A workflow lives in the project, under .mneme/pipelines/ — it travels with the repository: committable, reviewable, readable by CI. A practice becomes a node type on its second reuse; the first run is just an instance. Once the same path has been walked twice it gets distilled out of the session and lands in that directory.",
1171
+ "memory.actions.noRuns": "No runs recorded for this workflow yet, so every step is still a candidate.",
1172
+ "memory.actions.settle.title": "This run",
1173
+ "memory.actions.settle.line": "\"{title}\"",
1174
+ "memory.actions.settle.used": "Reused {n} steps this run (checked = actually used)",
1175
+ "memory.actions.settle.refutedWarn": "This path contains refuted steps; take the result with caution",
1176
+ "memory.features.actionPipelineEnabled": "M.O. (beta)",
1177
+ "memory.features.actionPipelineEnabled.hint": "⚠️ Under development: only the viewing layer works today — self-distillation and triggering are not built yet, so leave this off for now. Modus Operandi: the ways of working that actually got walked through in real sessions, drawn as a read-only graph — session-header icon, full sidebar view, and a per-run settlement card. Takes effect after restarting DSH",
1178
+ "memory.features.resilientModelDownload": "Model downloads resume after interruption",
1179
+ "memory.features.resilientModelDownload.hint": "On: downloads continue from where they stopped and retry automatically. Off: an interrupted download starts over (resumable downloads, on by default)",
1180
+ "memory.features.searchSemanticDedup": "Keep one entry when search hits near-duplicates",
1181
+ "memory.features.searchSemanticDedup.hint": "On: near-duplicate entries merge into one, saving injection slots. Off: duplicates all show up (semantic dedup, off by default)",
1182
+ "memory.features.autoDream": "Tidy memories in the background (merge · dedupe)",
1183
+ "memory.features.autoDream.hint": "On: fragmented memories are merged and settled in the background (autoDream). Off: memory only accumulates, so it gets messier the longer you use it",
1184
+ "memory.features.sleepModeEnabled": "It tidies memories while you are away",
1185
+ "memory.features.sleepModeEnabled.hint": "On: layered compaction and tidying happen during idle periods. Off: only consolidation you trigger manually (sleep mode, off by default)",
1186
+ // #427 suggestion 5: hybridInject / selectiveInjectEnabled /
1187
+ // adaptiveThresholdEnabled are three dials of one question — what comes
1188
+ // back. Adjacent placement plus a hint that names the other two.
1189
+ "memory.features.hybridInject": "Search by keywords and meaning together",
1190
+ "memory.features.hybridInject.hint": "On: both channels each fetch a batch and merge — broader recall. Off: one channel only. Three dials exist for \"what comes back\" (hybrid / selective / adaptive threshold); one is enough (hybrid injection, on by default)",
1191
+ "memory.features.selectiveInjectEnabled": "Only carry memories that truly matter",
1192
+ "memory.features.selectiveInjectEnabled.hint": "On: relevance is filtered before injecting — cleaner context. Off: more comes along, and noisier. One of the three dials (selective injection, on by default)",
1193
+ "memory.features.adaptiveThresholdEnabled": "Tighten or loosen by retrieval quality",
1194
+ "memory.features.adaptiveThresholdEnabled.hint": "On: a poor-recall day loosens the floor, a good one tightens it. Off: the floor is fixed. One of the three dials (adaptive threshold, on by default)",
1195
+ "memory.features.reflectionUpdateEnabled": "Let tidying revise existing memories",
1196
+ "memory.features.reflectionUpdateEnabled.hint": "On: consolidation may rewrite and merge existing entries, not only add. Off: old memories are never auto-edited (reflective update, on by default)",
1034
1197
  "memory.features.distill": "Distillation & rate limiting",
1035
1198
  "memory.features.distillRateLimitIntervalMs": "Distill spacing (ms)",
1036
1199
  "memory.features.distillRateLimitRetries": "429 retries",
1037
1200
  "memory.features.distillRateLimitBaseDelayMs": "Backoff base delay (ms)",
1038
1201
  "memory.features.distillMaxChars": "Transcript cap (chars)",
1202
+ "memory.features.distillRecords.enabled": "Keep distilled transcripts",
1203
+ "memory.features.distillRecords.enabled.hint": "Also store the public parts of the distilled window, with a provenance link per memory; private reasoning and tool output are never stored. Off writes nothing and skips collection entirely",
1204
+ "memory.features.distillRecords.retentionDays": "Transcript retention (days)",
1205
+ "memory.features.distillRecords.retentionDays.hint": "1–3650 days; startup pruning only. To make reclaim use the same window, pass the same value via --older-than",
1039
1206
  "memory.features.codingBoostFactor": "Coding boost factor",
1040
- "memory.features.memoryQualityFilter.enabled": "Memory quality filter",
1041
- "memory.features.memoryQualityFilter.enabled.hint": "Archive low-value memories automatically; injection downranks by quality",
1042
- "memory.features.llmAudit.enabled": "Background call audit",
1043
- "memory.features.llmAudit.enabled.hint": "Log background model calls and token usage from consolidation / summaries",
1207
+ "memory.features.memoryQualityFilter.enabled": "Tuck low-value memories away automatically",
1208
+ "memory.features.memoryQualityFilter.enabled.hint": "On: low-value entries are archived automatically and injection downranks by quality. Off: everything stays, noise included (memory quality filter, on by default)",
1209
+ "memory.features.llmAudit.enabled": "Keep tabs on background model usage",
1210
+ "memory.features.llmAudit.enabled.hint": "On: background consolidation / summary calls and token usage are all on record. Off: there is no way to check what the background spent (call audit, on by default)",
1044
1211
  // #254 — two layers: enabled runs the check (audit only), enforce actually
1045
1212
  // blocks. The hints must keep that difference explicit.
1046
- "memory.features.writeAdmission.enabled": "Write admission checks",
1047
- "memory.features.writeAdmission.enabled.hint": "Flag blank / noise writes before storing, with an audit row (never blocks)",
1048
- "memory.features.writeAdmission.enforce": "Write admission enforcement",
1049
- "memory.features.writeAdmission.enforce.hint": "Actually reject flagged writes; off keeps the audit row only",
1050
- "memory.features.preInjectGate.enabled": "Pre-inject gate",
1051
- "memory.features.preInjectGate.enabled.hint": "Judge opinion/stance memories before injection, audit-only (never blocks)",
1052
- "memory.features.preInjectGate.enforce": "Pre-inject gate enforcement",
1053
- "memory.features.preInjectGate.enforce.hint": "Actually drop flagged candidates at injection; off keeps the audit row only",
1213
+ "memory.features.writeAdmission.enabled": "Judge blank / noise writes before storing (audit only)",
1214
+ "memory.features.writeAdmission.enabled.hint": "On: blank / noise writes are flagged with an audit row but still stored. Off: nothing is judged. To actually block, see the next switch (write admission checks, off by default)",
1215
+ "memory.features.writeAdmission.enforce": "Really reject useless writes",
1216
+ "memory.features.writeAdmission.enforce.hint": "On: flagged writes are rejected outright. Off: audit row only, stored anyway. Needs the checks switch above (write admission enforcement, off by default)",
1217
+ "memory.features.preInjectGate.enabled": "Screen out \"just my opinion\" memories before injection (audit only)",
1218
+ "memory.features.preInjectGate.enabled.hint": "On: opinion / stance memories are judged before injection and audited, so you can watch the distribution first. Off: injected as usual (pre-inject gate, off by default)",
1219
+ "memory.features.preInjectGate.enforce": "Really drop flagged candidates at injection",
1220
+ "memory.features.preInjectGate.enforce.hint": "On: flagged candidates are dropped at injection time. Off: audit row only, injected anyway. Needs the gate switch above (pre-inject gate enforcement, off by default)",
1054
1221
  // #164 A2: its own gate for the secret / PII rules (separate from the
1055
1222
  // blank/noise pair above), and it runs inside the write admission checks
1056
1223
  // above, so with those off it never runs. Hits are audited only;
1057
1224
  // enforcement stays with the key above.
1058
- "memory.features.sensitiveScanEnabled": "Secret / PII scan",
1059
- "memory.features.sensitiveScanEnabled.hint": "Scan writes for credentials and personal data; needs write admission checks to run. Hits are audited only; enforcement needs the enforcement toggle too",
1060
- "memory.features.bm25SearchEnabled": "BM25 keyword search",
1061
- "memory.features.bm25SearchEnabled.hint": "Classic keyword scoring, complementary to vector recall",
1062
- "memory.features.conflictFreezeEnabled": "Conflict freezing",
1063
- "memory.features.conflictFreezeEnabled.hint": "Freeze contradictory memories for confirmation during consolidation",
1064
- "memory.features.scopeEnabled": "Scope tagging",
1065
- "memory.features.scopeEnabled.hint": "New memories record which session (agent / workspace) they came from, for isolation and filtered recall",
1066
- "memory.features.strictScope": "Strict isolation",
1067
- "memory.features.strictScope.hint": "When on, memories explicitly made private by other sessions become fully invisible; when off they stay visible but rank lower. For true isolation use a memory's sensitivity field",
1068
- "memory.features.trustEpistemicWeighting": "Credibility weighting",
1069
- "memory.features.trustEpistemicWeighting.hint": "Adjust recall ranking by source credibility (experimental)",
1070
- "memory.features.reflectionFailureTracking": "Reflection failure tracking",
1071
- "memory.features.reflectionFailureTracking.hint": "Record failed consolidation cases for later improvement (diagnostics)",
1225
+ "memory.features.sensitiveScanEnabled": "Scan writes for credentials and personal data",
1226
+ "memory.features.sensitiveScanEnabled.hint": "On: secret / PII rules run inside the write admission checks; hits are audited only — to block, also enable enforcement. Nothing runs while the checks switch is off (secret / PII scan, off by default)",
1227
+ "memory.features.bm25SearchEnabled": "Also search by exact keywords",
1228
+ "memory.features.bm25SearchEnabled.hint": "On: exact-term scoring joins in, so proper nouns, error codes and paths hit reliably — a complement to meaning-based search. Off: semantic similarity only (BM25 keyword search, on by default)",
1229
+ "memory.features.conflictFreezeEnabled": "Freeze contradictory memories first",
1230
+ "memory.features.conflictFreezeEnabled.hint": "On: contradictions found while tidying are frozen for your confirmation instead of both staying live. Off: both stay in use (conflict freezing, off by default)",
1231
+ "memory.features.scopeEnabled": "Record which session each memory came from",
1232
+ "memory.features.scopeEnabled.hint": "On: every new memory is tagged with the agent / workspace it came from — that tag decides whether other sessions see it. Off: new memories carry no origin and all sessions share one pool (scope tagging, off by default)",
1233
+ "memory.features.strictScope": "Memories others marked private stay invisible to me",
1234
+ "memory.features.strictScope.hint": "On: memories explicitly made private by other sessions are fully invisible. Off: they stay visible but rank lower. For true isolation use a memory's sensitivity field. Needs the tagging switch above (strict isolation, off by default)",
1235
+ "memory.features.trustEpistemicWeighting": "Rank by source credibility",
1236
+ "memory.features.trustEpistemicWeighting.hint": "On: more credible sources rank higher. Off: everything counts the same (credibility weighting, experimental, off by default)",
1237
+ "memory.features.reflectionFailureTracking": "Keep failed tidying cases on record",
1238
+ "memory.features.reflectionFailureTracking.hint": "On: failure causes and cases are stored for diagnosis and improvement. Off: failures leave no record (failure tracking, on by default)",
1072
1239
  "memory.features.embedRoute": "Semantic search route",
1073
1240
  "memory.features.embedProvider": "Embedding provider",
1074
1241
  "memory.features.embedProvider.openai": "OpenAI-compatible API",
@@ -1096,6 +1263,7 @@ window.__ModuleLoader__.load({
1096
1263
  "memory.features.modelTestOk": "Connected",
1097
1264
  "memory.features.modelTestFail": "Test failed",
1098
1265
  "memory.features.modelTestHint": "Fires one minimal consolidation call to verify provider/model connectivity and effort support",
1266
+ "memory.features.modelTestDowngraded": "Effort not accepted by this model; connected without it (same as a real consolidation)",
1099
1267
  "memory.features.routeStaleMark": " (not in list)",
1100
1268
  "memory.features.routeStaleHint": "This value is not in the currently available list (often a leftover from switching providers): reselect, or run a connectivity test to verify now",
1101
1269
  "memory.explorer.viewCards": "Cards",
@@ -1668,6 +1836,11 @@ window.__ModuleLoader__.load({
1668
1836
  ".mneme-badge--conflict{color:var(--mneme-danger);background:color-mix(in srgb,var(--mneme-danger) 10%,transparent);border:1px solid color-mix(in srgb,var(--mneme-danger) 30%,transparent)}",
1669
1837
  ".mneme-badge--archived{color:var(--mneme-fg-dim);background:var(--dsw-alias-interactive-bg-hover)}",
1670
1838
  ".mneme-badge--scope{color:var(--mneme-accent);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary) 30%,transparent)}",
1839
+ // 「默认 / 已改」徽标(#427):default 复用 --archived 的语义色组合
1840
+ // (fg-dim on interactive-bg-hover),changed 复用 --scope 的 accent 组
1841
+ // 合——两组配对都已过 contrast.test.js 的重算,不再发明新颜色。
1842
+ ".mneme-badge--default{color:var(--mneme-fg-dim);background:var(--dsw-alias-interactive-bg-hover)}",
1843
+ ".mneme-badge--changed{color:var(--mneme-accent);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary) 30%,transparent)}",
1671
1844
  // 冲突集中处理队列(v0.8.0):状态页的待确认冲突列表
1672
1845
  ".mneme-conflictq{display:flex;flex-direction:column;gap:10px}",
1673
1846
  ".mneme-conflictq-head{display:flex;align-items:center;gap:8px}",
@@ -1898,7 +2071,159 @@ window.__ModuleLoader__.load({
1898
2071
  ".mneme-maintwarnhead{font-size:13px;line-height:19px;font-weight:600;color:var(--mneme-danger)}",
1899
2072
  ".mneme-maintresult{margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}",
1900
2073
  ".mneme-maintresulthead{font-size:13px;line-height:19px;font-weight:600;color:var(--mneme-ok)}",
1901
- ".mneme-mainterr{margin-top:10px;font-size:12px;line-height:17px;color:var(--mneme-danger)}"
2074
+ ".mneme-mainterr{margin-top:10px;font-size:12px;line-height:17px;color:var(--mneme-danger)}",
2075
+ // --- v0.9.0 M.O.(beta)---------------------------------------------
2076
+ // 语义色层第二份:浮层面板(portal 到 body)、右栏 pane、退化 sheet、
2077
+ // 对话行结算卡都不在原四个渲染根之下。contrast.test.js 对原四根选择器
2078
+ // 做精确匹配锁,所以这里另起一条规则、取值逐字相同,且必须排在其后
2079
+ // (测试按首次出现抓取 --mneme-* 定义)。引用的宿主 token 全部在
2080
+ // test/contrast.test.js 的 HOST_TOKENS 快照里,不许新增名字。
2081
+ ".mneme-pipe,.mneme-pipeview,.mneme-pipe-settle,.mneme-pipe-root{" +
2082
+ "--mneme-fg-dim:color-mix(in srgb,var(--dsw-alias-label-primary) 60%,transparent);" +
2083
+ "--mneme-graphic-dim:color-mix(in srgb,var(--dsw-alias-label-primary) 50%,transparent);" +
2084
+ "--mneme-accent:color-mix(in srgb,var(--dsw-alias-state-business-primary) 85%,var(--dsw-alias-label-primary));" +
2085
+ "--mneme-danger:color-mix(in srgb,var(--dsw-alias-state-error-primary) 80%,var(--dsw-alias-label-primary));" +
2086
+ "--mneme-warn:color-mix(in srgb,var(--dsw-static-amber-600,#dd8629) 65%,var(--dsw-alias-label-primary));" +
2087
+ "--mneme-ok:color-mix(in srgb,var(--dsw-static-green-500,#22c55e) 65%,var(--dsw-alias-label-primary))" +
2088
+ "}",
2089
+ // 会话头 trigger:与 Agent Teams 同规格(28px 行高、12px 字号),
2090
+ // 页头窄容器下文字标签隐藏、降级纯图标。
2091
+ ".mneme-pipe-root{position:relative;display:inline-flex}",
2092
+ ".mneme-pipe-trigger{min-height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:0;border-radius:6px;align-items:center;gap:5px;padding:3px 7px;font-family:inherit;font-size:12px;display:inline-flex}",
2093
+ ".mneme-pipe-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}",
2094
+ ".mneme-pipe-trigger:focus-visible{color:var(--dsw-alias-label-primary);outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}",
2095
+ ".mneme-pipe-triggerLabel{white-space:nowrap}",
2096
+ "@container (width<=480px){.mneme-pipe-triggerLabel{display:none}}",
2097
+ ".mneme-pipe-dot{flex:none;min-width:14px;height:14px;border-radius:7px;padding:0 3px;background:color-mix(in srgb,var(--mneme-warn) 14%,transparent);color:var(--dsw-alias-label-primary);font-size:10px;line-height:14px;font-weight:600;font-variant-numeric:tabular-nums;text-align:center}",
2098
+ // 浮层面板:几何抄 Agent Teams(min(500px,100vw-32px) / 12px 圆角 /
2099
+ // fixed 定位由 useAnchoredPosition 给出)。阴影用中性 rgba:宿主的
2100
+ // elevation token 不在面板 token 快照白名单里,不能引用。
2101
+ ".mneme-pipe-panel{z-index:100;box-sizing:border-box;width:min(500px,100vw - 32px);max-height:min(680px,100vh - 32px);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;flex-direction:column;padding:0;display:flex;position:fixed;overflow:hidden;background:var(--dsw-alias-bg-layer-1);box-shadow:0 8px 28px rgba(0,0,0,0.28);color:var(--dsw-alias-label-primary)}",
2102
+ ".mneme-pipe-head{flex:none;display:flex;align-items:center;gap:8px;padding:10px 12px 8px 14px;border-bottom:1px solid var(--dsw-alias-border-l1)}",
2103
+ ".mneme-pipe-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);display:inline-flex;align-items:center;gap:6px;min-width:0}",
2104
+ ".mneme-pipe-beta{flex:none;font-size:11px;line-height:16px;font-weight:600;color:var(--mneme-warn);border:1px solid color-mix(in srgb,var(--mneme-warn) 55%,transparent);border-radius:6px;padding:0 6px}",
2105
+ // 内测免责:界面常驻一行,「候选」是可见状态词不是 tooltip。
2106
+ ".mneme-pipe-notice{flex:none;display:flex;gap:6px;align-items:flex-start;padding:7px 14px 0;font-size:12px;line-height:17px;color:var(--mneme-fg-dim)}",
2107
+ ".mneme-pipe-scope{flex:none;font-size:11px;line-height:16px;color:var(--mneme-fg-dim)}",
2108
+ // 运行档位:一次运行一个芯片;失败/被推翻的运行照常列出(留痕)。
2109
+ ".mneme-pipe-runs{flex:none;display:flex;gap:6px;flex-wrap:wrap;padding:6px 14px 2px}",
2110
+ ".mneme-pipe-run{border:1px solid var(--dsw-alias-border-l2);background:0 0;border-radius:8px;cursor:pointer;color:var(--mneme-fg-dim);font-family:inherit;font-size:12px;line-height:16px;padding:2px 8px;display:inline-flex;gap:6px;align-items:center}",
2111
+ ".mneme-pipe-run:hover{background:var(--dsw-alias-interactive-bg-hover)}",
2112
+ ".mneme-pipe-run:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}",
2113
+ ".mneme-pipe-run.mneme-on{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
2114
+ ".mneme-pipe-runDot{flex:none;width:8px;height:8px;border-radius:4px}",
2115
+ ".mneme-pipe-runDot--ok{background:var(--mneme-ok)}",
2116
+ ".mneme-pipe-runDot--failed{background:var(--mneme-warn)}",
2117
+ ".mneme-pipe-runDot--refuted{background:var(--mneme-danger)}",
2118
+ // 图:宽态 SVG 分层 DAG(trace viewer 读法),窄态整块换成竖排列表。
2119
+ // 高度下限:flex:1 的基准是 0,只要某一层祖先没有确定高度(浮层与居中 sheet 都是 max-height
2120
+ // 封顶),分到的就是 0 像素——画布「在 DOM 里、但看不见」。下限不修复成因,只保证最坏情况下
2121
+ // 还能看见图;这跟上面那条撞车是两件事,别把任何一条当成另一条的修复。
2122
+ ".mneme-pipe-graph{flex:1;min-height:220px;overflow:auto;padding:4px 8px 2px}",
2123
+ // 缩放条与缩放容器。画布本身是 width:100% 的 SVG,所以「缩放」= 改外层容器的百分比宽度:
2124
+ // 100% 就是适应栏宽,放大则容器比栏宽,滚动条自己出来——平移不必另写一套。
2125
+ ".mneme-pipe-zoombar{flex:none;display:flex;align-items:center;gap:6px;padding:5px 12px 0;font-size:12px;color:var(--mneme-fg-dim)}",
2126
+ ".mneme-pipe-zoombtn{border:1px solid var(--dsw-alias-border-l2);background:0 0;border-radius:6px;cursor:pointer;color:var(--mneme-fg-dim);font-family:inherit;font-size:12px;line-height:16px;padding:1px 7px}",
2127
+ ".mneme-pipe-zoombtn:hover{background:var(--dsw-alias-interactive-bg-hover)}",
2128
+ ".mneme-pipe-zoombtn:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}",
2129
+ ".mneme-pipe-zoomval{min-width:40px;text-align:right;font-variant-numeric:tabular-nums}",
2130
+ ".mneme-pipe-zoomwrap{min-width:0}",
2131
+ ".mneme-pipe-svg{display:block;width:100%;height:auto}",
2132
+ ".mneme-pipe-edge{stroke:var(--mneme-graphic-dim);fill:none;stroke-width:1.5}",
2133
+ ".mneme-pipe-edgeOn{stroke:var(--mneme-accent);fill:none;stroke-width:2.5}",
2134
+ ".mneme-pipe-edgeFail{stroke:var(--mneme-danger);fill:none;stroke-width:2;stroke-dasharray:5 3}",
2135
+ // 节点形状按 kind 区分(step 矩形 / gate 菱形 / branch 圆角 / artifact 胶囊),
2136
+ // 状态走描边+填充:refuted 是全篇核心信号,双份危险色最扎眼。
2137
+ ".mneme-pipe-node{fill:var(--dsw-alias-bg-layer-2);stroke:var(--mneme-graphic-dim);stroke-width:1.2}",
2138
+ ".mneme-pipe-node--trusted{stroke:var(--mneme-ok);stroke-width:1.6}",
2139
+ ".mneme-pipe-node--candidate{stroke:var(--mneme-graphic-dim)}",
2140
+ ".mneme-pipe-node--stale{stroke:var(--dsw-alias-border-l2);stroke-dasharray:3 2;fill:var(--dsw-alias-bg-base)}",
2141
+ ".mneme-pipe-node--refuted{stroke:var(--mneme-danger);stroke-width:2;fill:color-mix(in srgb,var(--mneme-danger) 10%,transparent)}",
2142
+ ".mneme-pipe-node--onpath{stroke:var(--dsw-alias-state-business-primary);stroke-width:2}",
2143
+ ".mneme-pipe-nodeT{fill:var(--dsw-alias-label-primary);font-size:11px;font-weight:600}",
2144
+ ".mneme-pipe-nodeS{fill:var(--mneme-fg-dim);font-size:9.5px}",
2145
+ ".mneme-pipe-nodeK{fill:var(--mneme-fg-dim);font-size:9px}",
2146
+ // 图例:四个状态 + 闸门有无(counterfactual null = 空心圈「还没闸门」)。
2147
+ ".mneme-pipe-legend{flex:none;display:flex;gap:10px;flex-wrap:wrap;align-items:center;padding:2px 14px 8px;font-size:11px;line-height:16px;color:var(--mneme-fg-dim)}",
2148
+ ".mneme-pipe-pill{flex:none;border-radius:6px;padding:0 6px;font-size:11px;line-height:18px;font-weight:600;border:1px solid transparent}",
2149
+ ".mneme-pipe-pill--candidate{color:var(--mneme-fg-dim);border-color:var(--dsw-alias-border-l2)}",
2150
+ ".mneme-pipe-pill--trusted{color:var(--mneme-ok);border-color:color-mix(in srgb,var(--mneme-ok) 45%,transparent)}",
2151
+ ".mneme-pipe-pill--stale{color:var(--mneme-fg-dim);border-color:var(--dsw-alias-border-l2);border-style:dashed}",
2152
+ ".mneme-pipe-pill--refuted{color:var(--mneme-danger);border-color:color-mix(in srgb,var(--mneme-danger) 55%,transparent);background:color-mix(in srgb,var(--mneme-danger) 10%,transparent)}",
2153
+ ".mneme-pipe-gate{flex:none;width:10px;height:10px;border-radius:5px;border:1.5px solid var(--mneme-graphic-dim)}",
2154
+ ".mneme-pipe-gate--has{border-color:var(--mneme-ok);background:var(--mneme-ok)}",
2155
+ // 阶段带(2026-10-16):段底用宿主 hover 层而不是自造色——对比度锁不认识
2156
+ // 「SVG 文字底下还有一层 rect」,所以段底必须淡到不参与文字对比度。
2157
+ ".mneme-pipe-bandbg{fill:var(--dsw-alias-interactive-bg-hover)}",
2158
+ ".mneme-pipe-bandline{stroke:var(--dsw-alias-border-l2);stroke-width:1}",
2159
+ ".mneme-pipe-bandT{fill:var(--dsw-alias-label-primary);font-size:12px;font-weight:600}",
2160
+ // 闸门节点与风险条:颜色只做冗余,信息本体在文本(闸 / R0-R3)里——
2161
+ // 不许靠单一色相区分(AGENTS.md 自查清单第 9 条)。
2162
+ ".mneme-pipe-node--gate{stroke:var(--mneme-warn);stroke-width:2}",
2163
+ ".mneme-pipe-gatetag{fill:var(--mneme-warn);font-size:9px;font-weight:600}",
2164
+ ".mneme-pipe-risk--r0{stroke:var(--mneme-graphic-dim)}",
2165
+ ".mneme-pipe-risk--r1{stroke:var(--mneme-accent)}",
2166
+ ".mneme-pipe-risk--r2{stroke:var(--mneme-warn)}",
2167
+ ".mneme-pipe-risk--r3{stroke:var(--mneme-danger)}",
2168
+ // 流程概览带:读投影里 pipeline.done 与 pipeline.trigger,回答「这条流程
2169
+ // 在干什么、何时起跑」。正文用 label-secondary(与节点详情屏同档)。
2170
+ ".mneme-pipe-overview{flex:none;padding:8px 14px 6px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary);display:flex;flex-direction:column;gap:3px}",
2171
+ // 节点详情(宽态点选展开 / 窄态 details 手风琴共用)。
2172
+ ".mneme-pipe-detail{flex:none;border-top:1px solid var(--dsw-alias-border-l1);padding:8px 14px 10px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);max-height:180px;overflow:auto}",
2173
+ ".mneme-pipe-detailTitle{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:2px}",
2174
+ ".mneme-pipe-dt{font-weight:600;color:var(--mneme-fg-dim)}",
2175
+ ".mneme-pipe-cf{margin-top:4px;padding:4px 8px;border-radius:8px;background:color-mix(in srgb,var(--mneme-ok) 10%,transparent);color:var(--dsw-alias-label-secondary)}",
2176
+ ".mneme-pipe-cf--none{background:0 0;border:1px dashed var(--dsw-alias-border-l2);color:var(--mneme-fg-dim)}",
2177
+ // 窄态(better-sidebar ~500px / 浮层内):节点竖排列表化,边收成
2178
+ // 左侧轨道线 + 「依赖」芯片——换布局,不是缩放。
2179
+ ".mneme-pipe-list{flex:1;min-height:180px;overflow:auto;padding:4px 12px 8px}",
2180
+ ".mneme-pipe-item{position:relative;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:8px 10px 8px 30px;margin:0 0 10px}",
2181
+ ".mneme-pipe-item::before{content:\"\";position:absolute;left:13px;top:-10px;height:10px;border-left:2px solid var(--mneme-graphic-dim)}",
2182
+ ".mneme-pipe-item:first-child::before{display:none}",
2183
+ ".mneme-pipe-itemDot{position:absolute;left:8px;top:12px;width:10px;height:10px;border-radius:5px;border:2px solid var(--mneme-graphic-dim);background:var(--dsw-alias-bg-layer-2)}",
2184
+ ".mneme-pipe-itemDot--trusted{border-color:var(--mneme-ok)}",
2185
+ ".mneme-pipe-itemDot--stale{border-style:dashed;border-color:var(--dsw-alias-border-l2)}",
2186
+ ".mneme-pipe-itemDot--refuted{border-color:var(--mneme-danger);background:var(--mneme-danger)}",
2187
+ ".mneme-pipe-itemDot--onpath{border-color:var(--dsw-alias-state-business-primary)}",
2188
+ ".mneme-pipe-itemName{font-size:13px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-primary)}",
2189
+ ".mneme-pipe-itemName--refuted{color:var(--mneme-danger)}",
2190
+ ".mneme-pipe-itemMeta{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:2px;font-size:11px;line-height:16px;color:var(--mneme-fg-dim)}",
2191
+ ".mneme-pipe-dep{font-size:11px;color:var(--mneme-fg-dim)}",
2192
+ ".mneme-pipe-item summary{cursor:pointer;list-style:none}",
2193
+ ".mneme-pipe-item summary::-webkit-details-marker{display:none}",
2194
+ ".mneme-pipe-itemBody{margin-top:6px;padding-top:6px;border-top:1px solid var(--dsw-alias-border-l1);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}",
2195
+ // 对话行结算卡(turnTail):死数据,跟这次运行绑死。
2196
+ ".mneme-pipe-settle{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:8px 10px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);margin:4px 0;max-width:620px}",
2197
+ ".mneme-pipe-settleHead{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:4px}",
2198
+ ".mneme-pipe-settleTitle{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);display:inline-flex;align-items:center;gap:6px}",
2199
+ ".mneme-pipe-steps{display:flex;gap:4px;flex-wrap:wrap;align-items:center;margin:2px 0}",
2200
+ ".mneme-pipe-stepChip{border:1px solid var(--dsw-alias-border-l2);border-radius:7px;padding:0 7px;font-size:11px;line-height:18px;color:var(--mneme-fg-dim);display:inline-flex;gap:4px;align-items:center}",
2201
+ ".mneme-pipe-stepChip--used{border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 55%,transparent);color:var(--dsw-alias-label-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
2202
+ ".mneme-pipe-stepArrow{color:var(--mneme-fg-dim);font-size:11px}",
2203
+ // 老宿主退化:居中 sheet(形态沿用记忆库 overlay 的背板 + 居中面板)。
2204
+ ".mneme-pipe-sheet{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:60;width:min(860px,calc(100vw - 48px));max-height:calc(100vh - 96px);border-radius:14px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(0,0,0,0.3)}",
2205
+ ".mneme-pipe-sheetbar{flex:none;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
2206
+ ".mneme-pipe-sheetbody{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column}",
2207
+ // 空态/加载/错误共用文案块。
2208
+ ".mneme-pipe-empty{flex:none;padding:18px 14px;text-align:center;font-size:13px;line-height:20px;color:var(--mneme-fg-dim)}",
2209
+ ".mneme-pipe-emptyTitle{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:4px}",
2210
+ // 工作流清单:**选择器**,不是「一屏」。它在上面占一条、图在下面占其余——
2211
+ // 第一版把它做成吃掉整屏、点条目才换屏,结果画布藏在一次点击后面且界面上没有任何
2212
+ // 提示,反馈连着两次说「看不到画布和节点」。上限用固定值不用百分比:百分比在
2213
+ // 高度不确定的祖先下会被当成 none,又变回吃掉整屏。
2214
+ ".mneme-pipe-wflist{flex:none;max-height:220px;min-height:0;overflow:auto;padding:8px 14px 10px;display:flex;flex-direction:column;gap:8px;border-bottom:1px solid var(--dsw-alias-border-l1)}",
2215
+ ".mneme-pipe-wfhead{flex:none;display:flex;align-items:baseline;justify-content:space-between;gap:8px;font-size:12px;color:var(--mneme-fg-dim)}",
2216
+ ".mneme-pipe-wf{display:block;width:100%;text-align:left;font-family:inherit;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:9px 11px;cursor:pointer;color:var(--dsw-alias-label-primary)}",
2217
+ ".mneme-pipe-wf:hover{background:var(--dsw-alias-interactive-bg-hover)}",
2218
+ ".mneme-pipe-wf:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}",
2219
+ // 当前展开的那条:只有颜色不同,图仍由下面的画布独占——选择器不该抢主语的位置。
2220
+ ".mneme-pipe-wf.mneme-on{border-color:var(--dsw-alias-state-business-primary)}",
2221
+ ".mneme-pipe-wfTop{display:flex;align-items:baseline;justify-content:space-between;gap:8px}",
2222
+ ".mneme-pipe-wfName{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);min-width:0}",
2223
+ ".mneme-pipe-wfScope{flex:none;font-size:11px;color:var(--mneme-fg-dim)}",
2224
+ ".mneme-pipe-wfMeta{display:block;margin-top:3px;font-size:12px;color:var(--mneme-fg-dim)}",
2225
+ ".mneme-pipe-wfId{display:block;margin-top:2px;font-size:11px;color:var(--mneme-fg-dim)}",
2226
+ ".mneme-pipe-broken{flex:none;font-size:12px;line-height:17px;color:var(--mneme-warn);padding:6px 14px 0}",
1902
2227
  ].join("\n");
1903
2228
  if (typeof document !== "undefined" && document.querySelector(`style[data-plugin-css="${CSS_TAG}"]`) === null) {
1904
2229
  const tag = document.createElement("style");
@@ -2454,10 +2779,24 @@ window.__ModuleLoader__.load({
2454
2779
  // 后端 src/config.js 的 INJECT_CHILD_FLAGS(运行时闸门),两侧漂移由
2455
2780
  // test/inject-parent-gate.test.js 钉住。
2456
2781
  const FEATURE_CHILDREN = { autoInject: ["injectGuidanceEnabled", "continuityRescueEnabled"] };
2782
+ // Issue #427 建议 1:折叠线挪到「新手之外」。group.basic 是首屏层,只列新
2783
+ // 用户敢动的总开关;group.scope 也在首屏——它回答新用户的第二个必答题
2784
+ // 「它记的东西会不会被别的会话看到」。其余组整组收进「全部设置」折叠
2785
+ // (showAll)。成员从原组里移走而不是渲染时过滤:同一项永远只渲染一次。
2786
+ // 首屏可点项 = 3 总开关 + autoInject 的 2 个子开关 + scope 2 开关 +
2787
+ // 「全部设置」按钮 = 8(验收线 ≤ 8;RuntimeProvisionBlock 配好即消失,
2788
+ // 常态首屏更少)。
2789
+ const FEATURE_FIRST_SCREEN = ["group.basic", "group.scope"];
2457
2790
  const FEATURE_GROUPS = [
2458
- { key: "group.core", items: ["autoInject", "autoSummarize", "hotMemoryEnabled", "injectTimePrefix", "memoryQualityFilter.enabled", "llmAudit.enabled", "writeAdmission.enabled", "writeAdmission.enforce", "preInjectGate.enabled", "preInjectGate.enforce", "sensitiveScanEnabled"] },
2459
- { key: "group.enhance", items: ["entityExtractionEnabled", "codingRetrospect", "rerankEnabled", "resilientModelDownload", "searchSemanticDedup", "bm25SearchEnabled", "heatEnabled", "documentMemoryEnabled"] },
2460
- { key: "group.dream", items: ["autoDream", "sleepModeEnabled"] },
2791
+ { key: "group.basic", items: ["autoInject", "autoSummarize", "autoDream"] },
2792
+ { key: "group.core", items: ["hotMemoryEnabled", "injectTimePrefix", "memoryQualityFilter.enabled", "llmAudit.enabled", "writeAdmission.enabled", "writeAdmission.enforce", "preInjectGate.enabled", "preInjectGate.enforce", "sensitiveScanEnabled",
2793
+ // #399 的留存总闸(见下方 distillSub:天数行是它的子块)。放在组末,
2794
+ // 子块才能紧跟在它后面展开——items.map 之后的子块是按组尾渲染的。
2795
+ "distillRecords.enabled"] },
2796
+ // v0.9.0 M.O.(beta):开关挂在 enhance 组。它不是行为开关而是
2797
+ // 座位闸门——关=一个座位都不注册(见 apply 内的 M.O. 块)。
2798
+ { key: "group.enhance", items: ["entityExtractionEnabled", "codingRetrospect", "rerankEnabled", "resilientModelDownload", "searchSemanticDedup", "bm25SearchEnabled", "heatEnabled", "documentMemoryEnabled", "actionPipelineEnabled"] },
2799
+ { key: "group.dream", items: ["sleepModeEnabled"] },
2461
2800
  // v0.8.0 A4(issue #17):作用域隔离组——标注总开关 + 严格硬过滤。
2462
2801
  { key: "group.scope", items: ["scopeEnabled", "strictScope"] }
2463
2802
  ];
@@ -2468,6 +2807,12 @@ window.__ModuleLoader__.load({
2468
2807
  // Issue #239 第 4 项:错峰时段串(巩固侧与蒸馏侧)。此前只有后端白名单、
2469
2808
  // 面板调不到——两个错峰键一个能调一个不能比都不给更让人困惑。
2470
2809
  "dreamPeakHours", "summarizePeakHours"];
2810
+ // 整数键(blur/Enter 提交,非法值不提交、退回生效值):
2811
+ // issue #399 的 distillRecords.retentionDays。此前它与 enabled 一样只有
2812
+ // 后端白名单、面板够不着——而 CONFIGURATION.md 明写「面板可启停」。
2813
+ // 其余整数键(429 调速器参数、distillMaxChars、codingBoostFactor)是有意
2814
+ // 不上 UI 的调优噪音,见上方注释,别顺手搬进来。
2815
+ const FEATURE_NUMBERS = ["distillRecords.retentionDays"];
2471
2816
  const EMBED_PROVIDERS = ["openai", "local", "ollama"];
2472
2817
  // 实体抽取思考强度(issue #109):与后端 FEATURE_FLAG_ENUMS 枚举对齐。
2473
2818
  const ENTITY_REASONING = ["none", "low", "medium", "high"];
@@ -2475,10 +2820,14 @@ window.__ModuleLoader__.load({
2475
2820
  // 蒸馏防推理烧预算)。后端枚举含 off,面板必须能选到,否则操作者用不上。
2476
2821
  const SUMMARIZE_REASONING = ["off", "none", "low", "medium", "high"];
2477
2822
 
2478
- function FeatureRow({ name, hint, on, disabled, onToggle, sub }) {
2823
+ function FeatureRow({ name, hint, on, disabled, onToggle, sub, tag }) {
2479
2824
  return h("div", { className: "mneme-featrow", style: sub ? { paddingLeft: 18, opacity: 0.86 } : undefined },
2480
2825
  h("div", { className: "mneme-featmain" },
2481
- h("div", { className: "mneme-featname" }, name),
2826
+ h("div", { className: "mneme-featname" }, name,
2827
+ // 「默认 / 已改」徽标(#427 建议 3):不用点开配置文件就知道哪
2828
+ // 些是出厂值、哪些被你改过;title 给完整解释(键盘/读屏可达)。
2829
+ tag && h("span", { className: "mneme-badge " + tag.kind, title: tag.title }, tag.text)
2830
+ ),
2482
2831
  h("div", { className: "mneme-feathint" }, hint)
2483
2832
  ),
2484
2833
  h("button", {
@@ -2496,10 +2845,18 @@ window.__ModuleLoader__.load({
2496
2845
  function FeaturesCard({ t }) {
2497
2846
  const [state, setState] = useState(null); // { overrides, effective }
2498
2847
  const [error, setError] = useState("");
2848
+ // 保存失败的提示要单独的座位:上面那个 error 只在首屏加载失败(state 还是
2849
+ // null)时渲染,state 一到手它就永不出现——于是每一次 put 失败都静默,
2850
+ // 草稿留在输入框里,人以为改成功了。
2851
+ const [saveError, setSaveError] = useState("");
2499
2852
  const [busy, setBusy] = useState(false);
2500
2853
  const [savedTick, setSavedTick] = useState(false);
2501
2854
  const [showAdv, setShowAdv] = useState(false);
2855
+ // Issue #427 建议 1:「全部设置」是第一层折叠(默认收起),「高级」是它
2856
+ // 内部的第二层。两个状态独立:收起全部设置时高级的展开与否被记住。
2857
+ const [showAll, setShowAll] = useState(false);
2502
2858
  const [strs, setStrs] = useState({}); // 字符串输入的本地草稿:key -> string
2859
+ const [nums, setNums] = useState({}); // 数值输入的本地草稿:key -> string(提交时才转整数)
2503
2860
  // 巩固/睡眠模型路由下拉的数据源:GET /llm-providers 探测(云端 v0.7.26+
2504
2861
  // 的插件侧端点)。null = 端点不可用(404/失败)→ 回退纯文本输入,不挡旧后端。
2505
2862
  const [routes, setRoutes] = useState(null);
@@ -2519,6 +2876,9 @@ window.__ModuleLoader__.load({
2519
2876
  const draft = {};
2520
2877
  for (const key of FEATURE_STRINGS) draft[key] = String(e[key] ?? "");
2521
2878
  setStrs(draft);
2879
+ const numDraft = {};
2880
+ for (const key of FEATURE_NUMBERS) numDraft[key] = String(e[key] ?? "");
2881
+ setNums(numDraft);
2522
2882
  })
2523
2883
  .catch(() => { if (!cancelled) setError(t("memory.features.loadFailed")); });
2524
2884
  return () => { cancelled = true; };
@@ -2536,6 +2896,7 @@ window.__ModuleLoader__.load({
2536
2896
 
2537
2897
  const eff = (state && state.effective) || {};
2538
2898
 
2899
+ // 返回值 = 这次写入有没有落库。调用方要据此决定草稿的去留。
2539
2900
  const put = async (patch) => {
2540
2901
  setBusy(true);
2541
2902
  try {
@@ -2544,13 +2905,25 @@ window.__ModuleLoader__.load({
2544
2905
  headers: { "Content-Type": "application/json" },
2545
2906
  body: JSON.stringify(patch)
2546
2907
  });
2547
- if (!res.ok) throw new Error("HTTP " + res.status);
2908
+ if (!res.ok) {
2909
+ // 服务端的校验失败把键名与范围写进 error(api.js 里那条 400 的注释
2910
+ // 就是这么设计的),读出来给人看。拿不到 body 才退回状态码——
2911
+ // 总之不能只剩一句「失败」让人猜是哪个开关、错在哪。
2912
+ const detail = await res.json()
2913
+ .then((j) => (j && j.error) || "")
2914
+ .catch(() => "");
2915
+ throw new Error(detail || ("HTTP " + res.status));
2916
+ }
2548
2917
  setState(await res.json());
2918
+ setSaveError("");
2549
2919
  announce(t("memory.features.restartHint"));
2550
2920
  setSavedTick(true);
2551
2921
  setTimeout(() => setSavedTick(false), 1800);
2552
- } catch {
2553
- setError(t("memory.features.loadFailed"));
2922
+ return true;
2923
+ } catch (err) {
2924
+ const why = (err && err.message) || String(err);
2925
+ setSaveError(`${t("memory.features.saveFailed")} — ${why}`);
2926
+ return false;
2554
2927
  } finally {
2555
2928
  setBusy(false);
2556
2929
  }
@@ -2558,15 +2931,45 @@ window.__ModuleLoader__.load({
2558
2931
 
2559
2932
  // 字符串项失焦/回车提交:trim 后与生效值相同则回填放弃,否则交给
2560
2933
  // 后端校验(ollamaBaseUrl 的协议、embedProvider 的枚举在后端把关)。
2934
+ // 后端拒了就退回生效值并出声——留着被拒的文本,人以为改成功了。
2561
2935
  const commitString = (key) => {
2562
2936
  const raw = (strs[key] ?? "").trim();
2563
2937
  if (raw === String(eff[key] ?? "")) {
2564
2938
  setStrs((c) => ({ ...c, [key]: String(eff[key] ?? "") }));
2565
2939
  return;
2566
2940
  }
2567
- put({ [key]: raw });
2941
+ put({ [key]: raw }).then((ok) => {
2942
+ if (!ok) setStrs((c) => ({ ...c, [key]: String(eff[key] ?? "") }));
2943
+ });
2568
2944
  };
2569
2945
 
2946
+ // 整数项失焦/回车提交。与字符串项同一形状,多三道闸:本地先挡非整数,
2947
+ // 挡不住的交后端(retentionDays 的范围 1–3650 由白名单把关),后端也拒了
2948
+ // 就把草稿退回生效值。前两道退回时也不出声——那是本地的即时反馈;第三道
2949
+ // 必须出声(put 会把原因写进 saveError),否则「框里的数字」与「在用的
2950
+ // 保留天数」会分家,而人看不出来。
2951
+ const commitNumber = (key) => {
2952
+ const raw = (nums[key] ?? "").trim();
2953
+ if (raw === String(eff[key] ?? "")) return;
2954
+ if (!/^\d+$/.test(raw)) {
2955
+ setNums((c) => ({ ...c, [key]: String(eff[key] ?? "") }));
2956
+ return;
2957
+ }
2958
+ put({ [key]: Number(raw) }).then((ok) => {
2959
+ if (!ok) setNums((c) => ({ ...c, [key]: String(eff[key] ?? "") }));
2960
+ });
2961
+ };
2962
+
2963
+ // 「默认 / 已改」的信号来源是 state.overrides:featureSnapshot 只把
2964
+ // settings.getFeatureFlags() 里真实落库的键放进 overrides,键不在其中
2965
+ // = 值仍来自 config schema 的默认值。面板自己判断,不加后端字段、
2966
+ // 不动 config.js(#427 的验收红线之一)。
2967
+ const ovr = (state && state.overrides) || {};
2968
+ const flagTag = (k) => ({
2969
+ kind: Object.prototype.hasOwnProperty.call(ovr, k) ? "mneme-badge--changed" : "mneme-badge--default",
2970
+ text: t(Object.prototype.hasOwnProperty.call(ovr, k) ? "memory.features.tagChanged" : "memory.features.tagDefault"),
2971
+ title: t(Object.prototype.hasOwnProperty.call(ovr, k) ? "memory.features.tagChanged.hint" : "memory.features.tagDefault.hint")
2972
+ });
2570
2973
  const boolRow = (k, opts = {}) => h(FeatureRow, {
2571
2974
  key: k,
2572
2975
  name: t(`memory.features.${k}`),
@@ -2575,6 +2978,7 @@ window.__ModuleLoader__.load({
2575
2978
  on: !!eff[k],
2576
2979
  disabled: busy,
2577
2980
  sub: !!opts.sub,
2981
+ tag: flagTag(k),
2578
2982
  onToggle: () => put({ [k]: !eff[k] })
2579
2983
  });
2580
2984
 
@@ -2592,9 +2996,11 @@ window.__ModuleLoader__.load({
2592
2996
  // 连通性测试:POST /test-model,空 provider/model = 按巩固路由解析
2593
2997
  // (agent 默认)。durationMs 优先后端值,缺失时客户端兜底计时;成功行
2594
2998
  // 附模型的实际回复(reply,后端截 100 字符)——「真的答了 ok」而非只报通。
2595
- // reasoningEffort 透传该路由配置的档位(issue #215):sleep 无回退重试,
2596
- // 档位被拒时真实 run 直接失败,测试是事前唯一的暴露口;'none'/未配置
2597
- // 省略字段,与后端 src/api.js 的同口径。
2999
+ // reasoningEffort 透传该路由配置的档位(issue #215),'none'/未配置省略
3000
+ // 字段,与后端 src/api.js 的同口径。**档位被拒不再是失败**:后端与真实
3001
+ // run 一样丢掉字段重试一次,回以 effortDropped:true,这里单独说明「连通
3002
+ // 了、但不是用你配的档位」——否则用户会照着红色失败去换 Provider,而
3003
+ // 后台其实一直在正常跑(这正是它此前给出误导性诊断的原因)。
2598
3004
  const runModelTest = async (provider, model, setTestState, reasoningEffort) => {
2599
3005
  setTestState({ running: true });
2600
3006
  const started = Date.now();
@@ -2620,6 +3026,8 @@ window.__ModuleLoader__.load({
2620
3026
  setTestState({
2621
3027
  ok: true,
2622
3028
  ms,
3029
+ // 只有后端明确置位才算降级(旧后端无此字段 → 不显示,不误报)。
3030
+ dropped: j.effortDropped === true,
2623
3031
  detail: [j.modelId, j.reply ? "「" + j.reply + "」" : ""].filter(Boolean).join(" · ")
2624
3032
  });
2625
3033
  } catch (err) {
@@ -2687,6 +3095,7 @@ window.__ModuleLoader__.load({
2687
3095
  (testState && !testState.running
2688
3096
  ? h("div", { className: "mneme-featsubhint" },
2689
3097
  (testState.ok ? "✓ " + t("memory.features.modelTestOk") : "✗ " + t("memory.features.modelTestFail"))
3098
+ + (testState.ok && testState.dropped ? " · " + t("memory.features.modelTestDowngraded") : "")
2690
3099
  + (typeof testState.ms === "number" ? " · " + (testState.ms / 1000).toFixed(1) + "s" : "")
2691
3100
  + (testState.detail ? " · " + testState.detail : ""))
2692
3101
  : h("div", { className: "mneme-featsubhint" }, t("memory.features.modelTestHint"))))
@@ -2703,6 +3112,22 @@ window.__ModuleLoader__.load({
2703
3112
  onKeyDown: (e) => { if (e.key === "Enter") e.target.blur(); }
2704
3113
  }));
2705
3114
 
3115
+ // type 刻意用 text + inputMode 而不是 number:number 输入框在部分内容被
3116
+ // 判非法时 e.target.value 会变成空串,草稿与用户所见脱节;这里要自己拿
3117
+ // 到原文才能挡下非整数并退回生效值。
3118
+ const numRow = (key) => h("div", { className: "mneme-featnum", key },
3119
+ h("span", { className: "mneme-featnumlabel" }, t(`memory.features.${key}`)),
3120
+ h("input", {
3121
+ className: "mneme-numinput",
3122
+ type: "text",
3123
+ inputMode: "numeric",
3124
+ value: nums[key] ?? "",
3125
+ "aria-label": t(`memory.features.${key}`),
3126
+ onChange: (e) => setNums((c) => ({ ...c, [key]: e.target.value })),
3127
+ onBlur: () => commitNumber(key),
3128
+ onKeyDown: (e) => { if (e.key === "Enter") e.target.blur(); }
3129
+ }));
3130
+
2706
3131
  // 语义检索路线:provider 下拉即时提交;local/ollama 选中时展开各自
2707
3132
  // 的连接字段,openai 的连接信息由「向量搜索」卡片持有,只给指引。
2708
3133
  const embedSub = h("div", { className: "mneme-featsub" },
@@ -2784,6 +3209,14 @@ window.__ModuleLoader__.load({
2784
3209
  h("div", { className: "mneme-featsubhint" }, t("memory.features.summarizePeakHours.hint"))
2785
3210
  );
2786
3211
 
3212
+ // #399 的留存开关紧跟在蒸馏总闸下:天数行只在留存开着时展开(关着一行
3213
+ // 不写,改天数没有意义)。放在这而不是挂在 flagRow 上,是因为
3214
+ // FEATURE_CHILDREN 只认布尔子项,而这里要的是一个整数输入行。
3215
+ const distillSub = eff["distillRecords.enabled"] && h("div", { className: "mneme-featsub" },
3216
+ numRow("distillRecords.retentionDays"),
3217
+ h("div", { className: "mneme-featsubhint" }, t("memory.features.distillRecords.retentionDays.hint"))
3218
+ );
3219
+
2787
3220
  if (error && !state) return h("section", { className: "mneme-set-card" },
2788
3221
  h("div", { className: "mneme-set-title" }, t("memory.features.title")),
2789
3222
  h("div", { className: "mneme-set-hint" }, error));
@@ -2794,29 +3227,58 @@ window.__ModuleLoader__.load({
2794
3227
  savedTick && h("span", { className: "mneme-saved", style: { marginLeft: 8, fontWeight: 400 } }, t("memory.features.restartHint"))
2795
3228
  ),
2796
3229
  h("div", { className: "mneme-set-desc" }, t("memory.features.desc")),
3230
+ // 见 saveError 的声明处:这条分支不能跟着加载失败那条走。
3231
+ saveError && h("div", { className: "mneme-mainterr" }, saveError),
2797
3232
  h(RuntimeProvisionBlock, { t }),
2798
3233
  state === null && !error
2799
3234
  ? h("div", { className: "mneme-set-hint" }, "…")
2800
- : h(react.Fragment, null,
2801
- FEATURE_GROUPS.map((g) => h(react.Fragment, { key: g.key },
3235
+ : (() => {
3236
+ // Issue #427:分组按「实现」分改不动需求本身,改的是「折叠线划在
3237
+ // 哪里」。renderGroup 是唯一的组渲染路径(含组尾子块),首屏层与
3238
+ // 「全部设置」共用它,保证 g.items.map(flagRow) 只写一次、子项
3239
+ // 只可能经 flagRow 渲染(test/inject-parent-gate.test.js 的门)。
3240
+ const renderGroup = (g) => h(react.Fragment, { key: g.key },
2802
3241
  h("div", { className: "mneme-featgroup" }, t(`memory.features.${g.key}`)),
2803
3242
  g.items.map(flagRow),
2804
- g.key === "group.core" && h(react.Fragment, null, summarizeSub),
3243
+ // 子块按组尾追加。distillSub 紧跟在 core 组最后一行
3244
+ // (distillRecords.enabled)之后,天数行才贴着它的总闸。
3245
+ g.key === "group.core" && h(react.Fragment, null, distillSub, summarizeSub),
2805
3246
  g.key === "group.enhance" && h(react.Fragment, null, embedSub, entitySub),
2806
3247
  g.key === "group.dream" && h(react.Fragment, null, dreamSub, sleepSub)
2807
- )),
2808
- h("div", { className: "mneme-featgroup" },
2809
- h("button", {
2810
- type: "button",
2811
- className: "mneme-footbtn",
2812
- "aria-expanded": String(showAdv),
2813
- onClick: () => setShowAdv(!showAdv)
2814
- },
2815
- h(Icon, { name: showAdv ? "chevronDown" : "chevronRight", size: 12 }),
2816
- t("memory.features.advancedToggle"))
2817
- ),
2818
- showAdv && FEATURE_ADVANCED_BOOLS.map((k) => boolRow(k))
2819
- )
3248
+ );
3249
+ const firstScreen = FEATURE_GROUPS.filter((g) => FEATURE_FIRST_SCREEN.includes(g.key));
3250
+ const restGroups = FEATURE_GROUPS.filter((g) => !FEATURE_FIRST_SCREEN.includes(g.key));
3251
+ return h(react.Fragment, null,
3252
+ firstScreen.map(renderGroup),
3253
+ // 「全部设置」= 第一层折叠(默认收起,#427 建议 1):里面的
3254
+ // 30 余项对新手是噪音,对新用户可见的只有上面的总开关层。
3255
+ h("div", { className: "mneme-featgroup" },
3256
+ h("button", {
3257
+ type: "button",
3258
+ className: "mneme-footbtn",
3259
+ "aria-expanded": String(showAll),
3260
+ onClick: () => setShowAll(!showAll)
3261
+ },
3262
+ h(Icon, { name: showAll ? "chevronDown" : "chevronRight", size: 12 }),
3263
+ t("memory.features.allToggle"))
3264
+ ),
3265
+ showAll && h(react.Fragment, null,
3266
+ restGroups.map(renderGroup),
3267
+ // 「高级」降为第二层,嵌在「全部设置」内部。
3268
+ h("div", { className: "mneme-featgroup" },
3269
+ h("button", {
3270
+ type: "button",
3271
+ className: "mneme-footbtn",
3272
+ "aria-expanded": String(showAdv),
3273
+ onClick: () => setShowAdv(!showAdv)
3274
+ },
3275
+ h(Icon, { name: showAdv ? "chevronDown" : "chevronRight", size: 12 }),
3276
+ t("memory.features.advancedToggle"))
3277
+ ),
3278
+ showAdv && FEATURE_ADVANCED_BOOLS.map((k) => boolRow(k))
3279
+ )
3280
+ );
3281
+ })()
2820
3282
  );
2821
3283
  }
2822
3284
 
@@ -6274,6 +6736,1045 @@ window.__ModuleLoader__.load({
6274
6736
  ), host);
6275
6737
  }
6276
6738
 
6739
+ // ---------------------------------------------------------------------
6740
+ // v0.9.0 M.O.(beta,只读)
6741
+ // 定性:trace viewer 不是编辑画布——节点=步骤、边=依赖、一次运行=一条
6742
+ // 被高亮走过的路径。本版数据全部是 §4 mneme.pipeline/v1 形状的假数据,
6743
+ // 服务端管线落地前先验证形态与交互。
6744
+ // ---------------------------------------------------------------------
6745
+ const PIPELINE_ACTIONS_TAB_ID = "dsh-mneme:actions";
6746
+ const PIPELINE_ACTIONS_TAB_KIND = "mneme-actions";
6747
+
6748
+ // 节点状态四值,refuted 是全篇核心信号(最扎眼)。
6749
+ const PIPELINE_STATES = ["candidate", "trusted", "stale", "refuted"];
6750
+
6751
+ // 数据面 = **项目里**的工作流(服务端 /api/dsh-mneme/pipeline 投影成 mneme.pipeline/v1)。
6752
+ // 项目从哪来:只有客户端拿得到会话的工作目录,所以这里解析出 cwd、当 ?project= 带上;
6753
+ // 服务端没有「当前项目」这个概念,也不许替谁猜一个。
6754
+ // 为什么四个座位共用缓存、且缓存**按项目 + 按工作流**分桶:四个座位读的必须是同一份
6755
+ // ——「会话头说有 3 条、右栏说有 5 条」这种分家没人查得出来;同理,同一个进程里开着
6756
+ // 两个不同项目的会话时,不分桶就会把 A 项目的清单端给 B 项目看,而画面看不出错。
6757
+ const pipelineDocs = new Map();
6758
+ const pipelinePendingByKey = new Map();
6759
+ function pipelineKey(project, id) { return (project || "") + "\u0000" + (id || ""); }
6760
+ function loadPipeline(project, id) {
6761
+ const key = pipelineKey(project, id);
6762
+ if (!pipelinePendingByKey.has(key)) {
6763
+ const params = [];
6764
+ if (project) params.push("project=" + encodeURIComponent(project));
6765
+ if (id) params.push("id=" + encodeURIComponent(id));
6766
+ const url = "/api/dsh-mneme/pipeline" + (params.length ? "?" + params.join("&") : "");
6767
+ pipelinePendingByKey.set(key, apiFetch(url)
6768
+ .then((res) => (res.ok ? res.json() : null))
6769
+ .catch(() => null)
6770
+ .then((doc) => { pipelineDocs.set(key, doc); return doc; }));
6771
+ }
6772
+ return pipelinePendingByKey.get(key);
6773
+ }
6774
+ // undefined = 还在读;null = 读不出来;对象 = 拿到了(其中 pipeline 为 null 表示
6775
+ // 这个项目里还没有工作流——那是空态,不是错误,别把两者画成同一张脸)。
6776
+ // id 为空 = 取清单(服务端会顺带给出第一条的完整投影,够结算卡用)。
6777
+ function usePipeline(project, id) {
6778
+ const key = pipelineKey(project, id);
6779
+ const [doc, setDoc] = useState(pipelineDocs.has(key) ? pipelineDocs.get(key) : undefined);
6780
+ useEffect(() => {
6781
+ let alive = true;
6782
+ loadPipeline(project, id).then((d) => { if (alive) setDoc(d); });
6783
+ return () => { alive = false; };
6784
+ }, [key]);
6785
+ return doc;
6786
+ }
6787
+ // 会话工作目录。两条来源依次试,各自兜自己的异常——一条抛了不能把另一条也带走;
6788
+ // 两处都试不到就**返回 null**(面板退到全局层,并如实标「项目未知」),绝不退回进程
6789
+ // 目录之类的东西——那会把「读不到」变成「读到别处」,而后者在界面上看不出来,是最难
6790
+ // 查的一类错。
6791
+ // 首选会话列表快照:byId[sessionId].cwd 是宿主给客户端的公开形状(dsh-context 用的
6792
+ // 就是这条);sessions.get() 是宿主侧写法,客户端不一定有,所以放第二位。
6793
+ function sessionProject(ctx, sessionId) {
6794
+ if (!sessionId || !ctx || typeof ctx.get !== "function") return null;
6795
+ const warn = (err) => console.warn("[dsh-mneme] session cwd lookup failed:", err);
6796
+ const pick = (row) => {
6797
+ if (!row) return null;
6798
+ const cwd = (row.header && row.header.cwd) || row.cwd;
6799
+ return typeof cwd === "string" && cwd ? cwd : null;
6800
+ };
6801
+ let sessions = null;
6802
+ try { sessions = ctx.get("sessions"); } catch (err) { warn(err); }
6803
+ if (sessions) {
6804
+ try {
6805
+ const snap = sessions.list && typeof sessions.list.getSnapshot === "function"
6806
+ ? sessions.list.getSnapshot()
6807
+ : null;
6808
+ const hit = pick(snap && snap.byId ? snap.byId[sessionId] : null);
6809
+ if (hit) return hit;
6810
+ } catch (err) { warn(err); }
6811
+ try {
6812
+ if (typeof sessions.get === "function") {
6813
+ const hit = pick(sessions.get(sessionId));
6814
+ if (hit) return hit;
6815
+ }
6816
+ } catch (err) { warn(err); }
6817
+ }
6818
+ return null;
6819
+ }
6820
+
6821
+ // DAG 分层布局:Kahn 最长路分层(层数 = 到该节点最长边数),
6822
+ // 防环——正常分层算完后如果还有节点没落位(说明有环),
6823
+ // 按输入顺序塞进最后一层,绝不死循环。节点数少,几十行够用。
6824
+ function layoutDag(nodes, edges) {
6825
+ const byId = {};
6826
+ for (const n of nodes) byId[n.id] = n;
6827
+ const indeg = {};
6828
+ const out = {};
6829
+ for (const n of nodes) { indeg[n.id] = 0; out[n.id] = []; }
6830
+ for (const e of edges) {
6831
+ if (!byId[e.from] || !byId[e.to] || e.from === e.to) continue;
6832
+ out[e.from].push(e.to);
6833
+ indeg[e.to] += 1;
6834
+ }
6835
+ const layer = {};
6836
+ const queue = nodes.filter((n) => indeg[n.id] === 0).map((n) => n.id);
6837
+ const remaining = {};
6838
+ for (const n of nodes) remaining[n.id] = indeg[n.id];
6839
+ while (queue.length) {
6840
+ const id = queue.shift();
6841
+ for (const to of out[id]) {
6842
+ layer[to] = Math.max(layer[to] || 0, (layer[id] || 0) + 1);
6843
+ remaining[to] -= 1;
6844
+ if (remaining[to] === 0) queue.push(to);
6845
+ }
6846
+ }
6847
+ const maxLayer = nodes.reduce((m, n) => Math.max(m, layer[n.id] || 0), 0);
6848
+ const leftovers = nodes.filter((n) => remaining[n.id] > 0);
6849
+ for (const n of leftovers) layer[n.id] = maxLayer + 1; // 环兜底
6850
+ const layers = [];
6851
+ for (const n of nodes) {
6852
+ const L = layer[n.id] || 0;
6853
+ (layers[L] = layers[L] || []).push(n.id);
6854
+ }
6855
+ return { layer, layers: layers.map((l) => l || []) };
6856
+ }
6857
+
6858
+ // 阶段带布局(2026-10-16):layoutDag 只按 DAG 深度分层、不读 phase,把 5 段
6859
+ // 19 步排成 18 层的单列长链(固有 354×1902px),第一屏既看不出「分几段」也
6860
+ // 读不出「谁在做」。这里按 step.phase 分组(保持作者书写顺序——phase 是人手
6861
+ // 写的分类词,不能按字母重排)、段内左到右每行两列换行:画布固有宽度落回
6862
+ // 354px(右栏 400px 不缩放就能读),高度从 1902 腰斩到 ~836。
6863
+ // 纯函数、不碰 DOM:探针 .scratch/mo-ux-probe-layout.mjs 会把它抠出来真跑,
6864
+ // 改签名或引用外部变量都会让那条可重跑核算失明。
6865
+ function layoutPipelineBands(nodes, edges, C) {
6866
+ const NW = (C && C.NW) || 150;
6867
+ const NH = (C && C.NH) || 54;
6868
+ const GX = (C && C.GX) || 30;
6869
+ const PAD = (C && C.PAD) || 12;
6870
+ const COLS = 2, ROW_GAP = 14, BAND_HEAD = 26, BAND_GAP = 18;
6871
+ const order = [];
6872
+ const byPhase = {};
6873
+ for (const n of nodes) {
6874
+ const ph = n.phase || "";
6875
+ if (!byPhase[ph]) { byPhase[ph] = []; order.push(ph); }
6876
+ byPhase[ph].push(n.id);
6877
+ }
6878
+ const pos = {};
6879
+ const bands = [];
6880
+ const bandRects = [];
6881
+ const W = PAD * 2 + COLS * NW + (COLS - 1) * GX;
6882
+ let y = PAD;
6883
+ for (const ph of order) {
6884
+ const ids = byPhase[ph];
6885
+ const rows = Math.ceil(ids.length / COLS);
6886
+ const top = y;
6887
+ y += BAND_HEAD;
6888
+ ids.forEach((id, i) => {
6889
+ const r = Math.floor(i / COLS), c = i % COLS;
6890
+ pos[id] = { x: PAD + c * (NW + GX), y: y + r * (NH + ROW_GAP) };
6891
+ });
6892
+ y += rows * NH + (rows - 1) * ROW_GAP;
6893
+ bands.push({ name: ph, count: ids.length, top, bottom: y });
6894
+ bandRects.push({ name: ph, count: ids.length, x: PAD - 6, y: top, w: W - 2 * (PAD - 6), h: y - top });
6895
+ y += BAND_GAP;
6896
+ }
6897
+ const H = y - BAND_GAP + PAD;
6898
+ return { pos, W, H, bands, bandRects };
6899
+ }
6900
+
6901
+ // 标题两行拆分:pipeClip 一刀截 11 字把「定版本号与发布范围」截成「…发布范…」。
6902
+ // 节点加高到 54px 后装得下两行——标本 19 个标题里 7 个超 11 字、最长 22 字,
6903
+ // 恰好两行放全;超长兜底仍留省略号。
6904
+ function pipeTitleLines(s, n) {
6905
+ const str = String(s || "");
6906
+ if (str.length <= n) return [str];
6907
+ const head = str.slice(0, n);
6908
+ const rest = str.slice(n);
6909
+ return [head, rest.length > n ? rest.slice(0, n - 1) + "…" : rest];
6910
+ }
6911
+
6912
+ function pipeFmtWhen(iso) {
6913
+ if (!iso) return "";
6914
+ const d = new Date(iso);
6915
+ if (isNaN(d.getTime())) return "";
6916
+ const p = (x) => String(x).padStart(2, "0");
6917
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
6918
+ }
6919
+ function pipeClip(s, n) {
6920
+ const str = String(s || "");
6921
+ return str.length > n ? str.slice(0, n - 1) + "…" : str;
6922
+ }
6923
+ function pipeDaysAgo(iso) {
6924
+ if (!iso) return null;
6925
+ const d = new Date(iso);
6926
+ if (isNaN(d.getTime())) return null;
6927
+ return Math.max(0, Math.floor((Date.now() - d.getTime()) / 86400000));
6928
+ }
6929
+
6930
+ // 运行档位:一次运行一个芯片;失败/被推翻的运行照常列出(留痕)。
6931
+ function PipelineRunChips(props) {
6932
+ const { t, runs, selectedRunId, onSelectRun } = props;
6933
+ return h("div", { className: "mneme-pipe-runs", role: "group", "aria-label": t("memory.actions.runs") },
6934
+ h("span", { className: "mneme-pipe-scope" }, t("memory.actions.runs")),
6935
+ (runs || []).map((r, i) => h("button", {
6936
+ key: r.id || i,
6937
+ type: "button",
6938
+ className: "mneme-pipe-run" + (r.id === selectedRunId ? " mneme-on" : ""),
6939
+ onClick: () => onSelectRun && onSelectRun(r.id),
6940
+ title: t("memory.actions.run." + (r.outcome || "ok"))
6941
+ },
6942
+ h("span", { className: "mneme-pipe-runDot mneme-pipe-runDot--" + (r.outcome || "ok"), "aria-hidden": "true" }),
6943
+ t("memory.actions.runOf").replace("{n}", String(i + 1)) + " · " + pipeFmtWhen(r.at)
6944
+ ))
6945
+ );
6946
+ }
6947
+
6948
+ // 宽态 DAG:按 phase 分阶段带的 SVG,一次运行 = 一条高亮路径(trace viewer 读法)。
6949
+ // 分组与几何来自 layoutPipelineBands(纯函数,探针真跑它),这里只管画。
6950
+ function PipelineGraphSvg(props) {
6951
+ const { t, data, run, selectedNodeId, onSelectNode } = props;
6952
+ const nodes = data.nodes || [];
6953
+ const edges = data.edges || [];
6954
+ const pathSet = {};
6955
+ const edgeState = {};
6956
+ if (run && Array.isArray(run.path)) {
6957
+ for (const id of run.path) pathSet[id] = true;
6958
+ for (let i = 0; i + 1 < run.path.length; i++) {
6959
+ const a = run.path[i];
6960
+ const b = run.path[i + 1];
6961
+ const isLast = i + 2 === run.path.length;
6962
+ edgeState[a + ">" + b] = run.outcome === "failed" && isLast ? "fail" : "on";
6963
+ }
6964
+ }
6965
+ const usedSet = {};
6966
+ if (run && Array.isArray(run.usedNodeIds)) {
6967
+ for (const id of run.usedNodeIds) usedSet[id] = true;
6968
+ }
6969
+ const NW = 150, NH = 54, GX = 30, GY = 66, PAD = 12;
6970
+ const laid = layoutPipelineBands(nodes, edges, { NW, NH, GX, GY, PAD });
6971
+ const pos = laid.pos;
6972
+ const W = laid.W;
6973
+ const Hh = laid.H;
6974
+ const KIND_RX = { step: 8, gate: 4, branch: 16, artifact: 21 };
6975
+ return h("svg", {
6976
+ className: "mneme-pipe-svg",
6977
+ viewBox: `0 0 ${W} ${Hh}`,
6978
+ width: W,
6979
+ height: Hh,
6980
+ role: "img",
6981
+ "aria-label": t("memory.actions.title")
6982
+ },
6983
+ // 阶段带:段底 + 段名与该段步数。分段信息画在节点之下、边之下——
6984
+ // 它是背景不是主角,但必须先上屏,第一屏才读得出「分几段」。
6985
+ laid.bandRects.map((b, i) => h("g", { key: "band" + i },
6986
+ h("rect", {
6987
+ className: "mneme-pipe-bandbg",
6988
+ x: b.x, y: b.y, width: b.w, height: b.h, rx: 10
6989
+ }),
6990
+ i > 0 ? h("line", {
6991
+ className: "mneme-pipe-bandline",
6992
+ x1: PAD, x2: W - PAD, y1: b.y - 9, y2: b.y - 9
6993
+ }) : null,
6994
+ h("text", {
6995
+ className: "mneme-pipe-bandT",
6996
+ x: PAD + 4, y: b.y + 17
6997
+ }, (b.name || "") + " · " + t("memory.actions.band.count").replace("{n}", String(b.count)))
6998
+ )),
6999
+ edges.map((e) => {
7000
+ const a = pos[e.from];
7001
+ const b = pos[e.to];
7002
+ if (!a || !b) return null;
7003
+ const st = edgeState[e.from + ">" + e.to];
7004
+ const cls = st === "fail" ? "mneme-pipe-edgeFail" : st === "on" ? "mneme-pipe-edgeOn" : "mneme-pipe-edge";
7005
+ // 同行相邻(蛇形读法):右缘出、左缘进,短水平贝塞尔;
7006
+ // 跨行/跨段仍走「底出顶进」的竖向贝塞尔。同行旧画法会把线甩出段外
7007
+ // (真机截图验出),横连读起来就是「往下一段走」。
7008
+ let d;
7009
+ if (b.y === a.y && b.x > a.x) {
7010
+ const ym = a.y + NH / 2;
7011
+ d = `M ${a.x + NW} ${ym} C ${a.x + NW + GX / 2} ${ym}, ${b.x - GX / 2} ${ym}, ${b.x} ${ym}`;
7012
+ } else {
7013
+ const x1 = a.x + NW / 2, y1 = a.y + NH, x2 = b.x + NW / 2, y2 = b.y;
7014
+ d = `M ${x1} ${y1} C ${x1} ${y1 + 26}, ${x2} ${y2 - 26}, ${x2} ${y2}`;
7015
+ }
7016
+ return h("path", {
7017
+ key: e.id || (e.from + ">" + e.to),
7018
+ className: cls,
7019
+ d
7020
+ }, e.label ? h("title", null, e.label) : null);
7021
+ }),
7022
+ nodes.map((n) => {
7023
+ const p = pos[n.id];
7024
+ if (!p) return null;
7025
+ const rx = KIND_RX[n.kind] != null ? KIND_RX[n.kind] : 8;
7026
+ const on = !!(run && pathSet[n.id]);
7027
+ const gate = n.kind === "gate";
7028
+ const cls = "mneme-pipe-node mneme-pipe-node--" + (n.state || "candidate")
7029
+ + (usedSet[n.id] ? " mneme-pipe-node--onpath" : "")
7030
+ + (gate ? " mneme-pipe-node--gate" : "");
7031
+ const lines = pipeTitleLines(n.title, 10);
7032
+ // meta 行三段:状态(四态)· who(主/人/CI)· risk(R0-R3)。
7033
+ // who/risk 都是投影自带字段,此前一个都没上屏;risk 缺失按 R0 兜底。
7034
+ const meta = [
7035
+ t("memory.actions.state." + (n.state || "candidate")),
7036
+ n.who ? t("memory.actions.who." + n.who) : null,
7037
+ n.risk || "R0"
7038
+ ].filter(Boolean).join(" · ");
7039
+ return h("g", {
7040
+ key: n.id,
7041
+ onClick: () => onSelectNode && onSelectNode(n.id),
7042
+ style: { cursor: "pointer" }
7043
+ },
7044
+ h("rect", {
7045
+ className: cls,
7046
+ x: p.x, y: p.y, width: NW, height: NH, rx
7047
+ }),
7048
+ // 风险色条:左缘一条竖线做冗余编码,信息本体在 meta 行的 R 文本里。
7049
+ h("line", {
7050
+ className: "mneme-pipe-risk--" + String(n.risk || "R0").toLowerCase(),
7051
+ x1: p.x + 3, y1: p.y + 8, x2: p.x + 3, y2: p.y + NH - 8,
7052
+ "stroke-width": 3, "stroke-linecap": "round"
7053
+ }),
7054
+ // 闸门角标:gate 步会「停下等批准」,与反事实圈同列放右上、圈在其上方——
7055
+ // 左上角压 risk 色条(真机截图验出的布局冲突)。
7056
+ gate ? h("text", {
7057
+ className: "mneme-pipe-gatetag",
7058
+ x: p.x + NW - 12, y: p.y + 24, textAnchor: "middle"
7059
+ }, t("memory.actions.gate.tag")) : null,
7060
+ // 反事实标记:空心圈 = 还没闸门(counterfactual 为 null),
7061
+ // 实心圈 = 有对照证据。做在右上角,一眼区分。
7062
+ h("circle", {
7063
+ cx: p.x + NW - 12, cy: p.y + 11, r: 4,
7064
+ fill: n.counterfactual ? "var(--mneme-ok)" : "none",
7065
+ stroke: n.counterfactual ? "var(--mneme-ok)" : "var(--mneme-graphic-dim)",
7066
+ "stroke-width": 1.5
7067
+ }, h("title", null, n.counterfactual ? t("memory.actions.gate.has") : t("memory.actions.gate.none"))),
7068
+ lines.length === 1
7069
+ ? h("text", {
7070
+ className: "mneme-pipe-nodeT",
7071
+ x: p.x + NW / 2, y: p.y + 24, textAnchor: "middle"
7072
+ }, lines[0])
7073
+ : [
7074
+ h("text", {
7075
+ key: "t1", className: "mneme-pipe-nodeT",
7076
+ x: p.x + NW / 2, y: p.y + 18, textAnchor: "middle"
7077
+ }, lines[0]),
7078
+ h("text", {
7079
+ key: "t2", className: "mneme-pipe-nodeT",
7080
+ x: p.x + NW / 2, y: p.y + 31, textAnchor: "middle"
7081
+ }, lines[1])
7082
+ ],
7083
+ h("text", {
7084
+ className: "mneme-pipe-nodeS",
7085
+ x: p.x + NW / 2, y: p.y + 46, textAnchor: "middle"
7086
+ }, meta),
7087
+ h("title", null, n.situation || n.title)
7088
+ );
7089
+ })
7090
+ );
7091
+ }
7092
+
7093
+ // 图例:四个状态一眼分清 + 闸门有无。「被推翻」最扎眼是全篇核心信号。
7094
+ function PipelineLegend(props) {
7095
+ const { t } = props;
7096
+ return h("div", { className: "mneme-pipe-legend" },
7097
+ t("memory.actions.legend.states"),
7098
+ PIPELINE_STATES.map((s) => h("span", { key: s, className: "mneme-pipe-pill mneme-pipe-pill--" + s }, t("memory.actions.state." + s))),
7099
+ h("span", { className: "mneme-pipe-gate", "aria-hidden": "true" }),
7100
+ t("memory.actions.gate.none"),
7101
+ h("span", { className: "mneme-pipe-gate mneme-pipe-gate--has", "aria-hidden": "true" }),
7102
+ t("memory.actions.gate.has")
7103
+ );
7104
+ }
7105
+
7106
+ // 节点详情:情形 / 动作 / 为什么 / 反事实(有无两种形态)/ 复用 / 来源。
7107
+ function PipelineNodeDetail(props) {
7108
+ const { t, node } = props;
7109
+ if (!node) return null;
7110
+ const credit = node.credit || {};
7111
+ const days = pipeDaysAgo(credit.lastUsedAt);
7112
+ const rows = [
7113
+ ["memory.actions.detail.situation", node.situation],
7114
+ ["memory.actions.detail.action", node.action],
7115
+ ["memory.actions.detail.why", node.why]
7116
+ ].filter(([, v]) => v);
7117
+ return h("div", { className: "mneme-pipe-detail", role: "region", "aria-label": node.title },
7118
+ h("div", { className: "mneme-pipe-detailTitle" },
7119
+ h("span", { className: "mneme-pipe-pill mneme-pipe-pill--" + (node.state || "candidate") }, t("memory.actions.state." + (node.state || "candidate"))),
7120
+ " " + node.title
7121
+ ),
7122
+ rows.map(([k, v]) => h("div", { key: k },
7123
+ h("span", { className: "mneme-pipe-dt" }, t(k) + ":"), v
7124
+ )),
7125
+ node.counterfactual
7126
+ ? h("div", { className: "mneme-pipe-cf" },
7127
+ h("span", { className: "mneme-pipe-dt" }, t("memory.actions.detail.cf") + ":"),
7128
+ node.counterfactual.contrast || "")
7129
+ : h("div", { className: "mneme-pipe-cf mneme-pipe-cf--none" }, t("memory.actions.detail.noCf")),
7130
+ h("div", null,
7131
+ h("span", { className: "mneme-pipe-dt" }, t("memory.actions.credit") + ":"),
7132
+ t("memory.actions.creditValue")
7133
+ .replace("{n}", String(credit.uses != null ? credit.uses : 0))
7134
+ .replace("{d}", days == null ? "—" : String(days))
7135
+ ),
7136
+ node.source && (node.source.memoryIds || []).length
7137
+ ? h("div", null,
7138
+ h("span", { className: "mneme-pipe-dt" }, t("memory.actions.detail.source") + ":"),
7139
+ (node.source.memoryIds || []).join("、"))
7140
+ : null
7141
+ );
7142
+ }
7143
+
7144
+ // 窄态(浮层 / better-sidebar ~500px):竖排列表——换布局,不是缩放。
7145
+ // 边收成左侧轨道线 + 「依赖」芯片;details 手风琴承载详情。
7146
+ function PipelineList(props) {
7147
+ const { t, data, run, selectedNodeId, onSelectNode } = props;
7148
+ const nodes = data.nodes || [];
7149
+ const edges = data.edges || [];
7150
+ const usedSet = {};
7151
+ if (run && Array.isArray(run.usedNodeIds)) for (const id of run.usedNodeIds) usedSet[id] = true;
7152
+ const onPath = {};
7153
+ if (run && Array.isArray(run.path)) for (const id of run.path) onPath[id] = true;
7154
+ const deps = {};
7155
+ for (const e of edges) (deps[e.to] = deps[e.to] || []).push(e.from);
7156
+ const { layers } = layoutDag(nodes, edges);
7157
+ const flat = [];
7158
+ layers.forEach((col) => col.forEach((id) => {
7159
+ const n = nodes.find((x) => x.id === id);
7160
+ if (n) flat.push(n);
7161
+ }));
7162
+ return h("div", { className: "mneme-pipe-list" },
7163
+ flat.map((n) => h("details", {
7164
+ key: n.id,
7165
+ className: "mneme-pipe-item",
7166
+ open: selectedNodeId === n.id || undefined,
7167
+ onToggle: (ev) => { if (ev.target && ev.target.open) onSelectNode && onSelectNode(n.id); }
7168
+ },
7169
+ h("summary", null,
7170
+ h("span", {
7171
+ className: "mneme-pipe-itemDot mneme-pipe-itemDot--" + (n.state || "candidate") +
7172
+ (onPath[n.id] ? " mneme-pipe-itemDot--onpath" : ""),
7173
+ "aria-hidden": "true"
7174
+ }),
7175
+ h("span", { className: "mneme-pipe-itemName" + (n.state === "refuted" ? " mneme-pipe-itemName--refuted" : "") }, n.title),
7176
+ h("span", { className: "mneme-pipe-itemMeta" },
7177
+ h("span", { className: "mneme-pipe-pill mneme-pipe-pill--" + (n.state || "candidate") }, t("memory.actions.state." + (n.state || "candidate"))),
7178
+ (deps[n.id] || []).length ? h("span", { className: "mneme-pipe-dep" }, t("memory.actions.dep") + " " + deps[n.id].join(" ")) : null,
7179
+ n.counterfactual ? null : h("span", { className: "mneme-pipe-dep" }, t("memory.actions.gate.none"))
7180
+ )
7181
+ ),
7182
+ h("div", { className: "mneme-pipe-itemBody" },
7183
+ n.situation ? h("div", null, h("span", { className: "mneme-pipe-dt" }, t("memory.actions.detail.situation") + ":"), n.situation) : null,
7184
+ n.action ? h("div", null, h("span", { className: "mneme-pipe-dt" }, t("memory.actions.detail.action") + ":"), n.action) : null,
7185
+ n.why ? h("div", null, h("span", { className: "mneme-pipe-dt" }, t("memory.actions.detail.why") + ":"), n.why) : null,
7186
+ n.counterfactual
7187
+ ? h("div", { className: "mneme-pipe-cf" }, h("span", { className: "mneme-pipe-dt" }, t("memory.actions.detail.cf") + ":"), n.counterfactual.contrast || "")
7188
+ : h("div", { className: "mneme-pipe-cf mneme-pipe-cf--none" }, t("memory.actions.detail.noCf"))
7189
+ )
7190
+ ))
7191
+ );
7192
+ }
7193
+
7194
+ // 条目清单(名字 + 规模 + 作用域):图上方的**选择器**。
7195
+ // 它解决的是「这是哪条工作流」——一进来就是一张图时这个问题没有落点,多条之间也没得选。
7196
+ // 但它不占整屏:图和它同屏,见下面的 PipelineBrowser。
7197
+ function PipelineWorkflowList(props) {
7198
+ const { t, data, activeId, onOpen } = props;
7199
+ const list = (data && data.available) || [];
7200
+ const scopeOf = (row) => (row.scope === "global"
7201
+ ? t("memory.actions.scope.global")
7202
+ : t("memory.actions.scope.project").replace("{name}", row.scopeLabel || row.scope));
7203
+ return h("div", { className: "mneme-pipe-wflist" },
7204
+ h("div", { className: "mneme-pipe-wfhead" },
7205
+ h("span", null, t("memory.actions.list.heading")),
7206
+ h("span", null, t("memory.actions.list.count").replace("{n}", String(list.length)))
7207
+ ),
7208
+ list.length
7209
+ ? list.map((row) => h("button", {
7210
+ key: row.id,
7211
+ type: "button",
7212
+ className: "mneme-pipe-wf" + (row.id === activeId ? " mneme-on" : ""),
7213
+ "aria-current": row.id === activeId ? "true" : undefined,
7214
+ onClick: () => onOpen(row.id)
7215
+ },
7216
+ h("span", { className: "mneme-pipe-wfTop" },
7217
+ h("span", { className: "mneme-pipe-wfName" }, row.title || row.id),
7218
+ h("span", { className: "mneme-pipe-wfScope" }, scopeOf(row))
7219
+ ),
7220
+ h("span", { className: "mneme-pipe-wfMeta" }, t("memory.actions.list.meta")
7221
+ .replace("{steps}", String(row.steps || 0))
7222
+ .replace("{gates}", String(row.gates || 0))
7223
+ .replace("{cf}", String(row.contrast || 0))),
7224
+ h("span", { className: "mneme-pipe-wfId" }, row.id)
7225
+ ))
7226
+ // 「读得出来但一份都没有」与「读不出来」是两回事,别画成同一张脸:
7227
+ // 前者要告诉人文件该放哪,后者只有 loadPipeline 返回 null 时才是。
7228
+ : h("div", { className: "mneme-pipe-empty" },
7229
+ h("div", { className: "mneme-pipe-emptyTitle" }, t("memory.actions.empty.title")),
7230
+ t("memory.actions.empty.body")
7231
+ ),
7232
+ data && data.unreadable && data.unreadable.length
7233
+ ? h("div", { className: "mneme-pipe-broken" },
7234
+ t("memory.actions.broken") + " " + data.unreadable.map((u) => u.detail || u.dir).join(";"))
7235
+ : null
7236
+ );
7237
+ }
7238
+
7239
+ // 清单与图**同屏**:上面清单(选择器),下面画布(主语),默认展开第一条。
7240
+ // 形状换过两次,两次的教训都留在这里,免得再翻回去:
7241
+ // 一进来就是图 —— 没有名字、没有落点,用户要的是「像 Hermes 那样一串条目」;
7242
+ // 清单 ⟷ 图两屏来回切 —— 条目有了,但画布藏在一次点击后面、界面上没有任何提示,
7243
+ // 反馈连着两次说「看不到画布和节点」。
7244
+ // 两个 usePipeline 都是无条件调用:hook 不能条件调用,而「默认选第一条」这件事
7245
+ // 恰好可以放在两个读取之间,不必为此拆组件。
7246
+ function PipelineBrowser(props) {
7247
+ const { t, project } = props;
7248
+ const [picked, setPicked] = useState(null);
7249
+ const listDoc = usePipeline(project, null);
7250
+ const rows = (listDoc && listDoc.available) || [];
7251
+ // 没手选过就跟第一条;手选之后清单再刷新也不跳回去(否则一刷新就把人的选择抹掉)。
7252
+ const activeId = picked || (rows.length ? rows[0].id : null);
7253
+ const detailDoc = usePipeline(project, activeId);
7254
+ return h("div", {
7255
+ className: "mneme-pipe",
7256
+ style: { height: "100%", display: "flex", flexDirection: "column", minHeight: "0" }
7257
+ },
7258
+ h(PipelineWorkflowList, { t, data: listDoc, activeId, onOpen: setPicked }),
7259
+ h(PipelineView, { t, data: detailDoc })
7260
+ );
7261
+ }
7262
+
7263
+ // 画布缩放。为什么要有:重排前 19 步是一条 18 层竖链(固有 354×1902),被 width:100%
7264
+ // 拉到栏宽之后高度上万——只能上下滚;重排成阶段带后(固有 354×836)仍有
7265
+ // 「凑近看某一段」的需求。按钮管精确、滚轮管顺手。
7266
+ const PIPELINE_ZOOM_MIN = 0.3;
7267
+ const PIPELINE_ZOOM_MAX = 4;
7268
+ const clampZoom = (z) => Math.min(PIPELINE_ZOOM_MAX, Math.max(PIPELINE_ZOOM_MIN, z));
7269
+
7270
+ function PipelineZoomBar(props) {
7271
+ const { t, zoom, onZoom } = props;
7272
+ return h("div", { className: "mneme-pipe-zoombar" },
7273
+ h("button", {
7274
+ type: "button", className: "mneme-pipe-zoombtn",
7275
+ title: t("memory.actions.zoom.out"), "aria-label": t("memory.actions.zoom.out"),
7276
+ onClick: () => onZoom(zoom / 1.25)
7277
+ }, "−"),
7278
+ h("span", { className: "mneme-pipe-zoomval" },
7279
+ t("memory.actions.zoom.value").replace("{n}", String(Math.round(zoom * 100)))),
7280
+ h("button", {
7281
+ type: "button", className: "mneme-pipe-zoombtn",
7282
+ title: t("memory.actions.zoom.in"), "aria-label": t("memory.actions.zoom.in"),
7283
+ onClick: () => onZoom(zoom * 1.25)
7284
+ }, "+"),
7285
+ h("button", {
7286
+ type: "button", className: "mneme-pipe-zoombtn",
7287
+ title: t("memory.actions.zoom.hint"),
7288
+ onClick: () => onZoom(1)
7289
+ }, t("memory.actions.zoom.fit"))
7290
+ );
7291
+ }
7292
+
7293
+ // 主视图:宽态 SVG 图 ↔ 窄态列表按容器宽切换(640px 阈值,ResizeObserver)。
7294
+ // 根挂 .mneme-pipeview 拿语义色变量层。
7295
+ function PipelineView(props) {
7296
+ const { t, data } = props;
7297
+ const nodes = (data && data.nodes) || [];
7298
+ const runs = (data && data.runs) || [];
7299
+ const [selectedRunId, setSelectedRunId] = react.useState(runs.length ? runs[0].id : null);
7300
+ const [selectedNodeId, setSelectedNodeId] = react.useState(null);
7301
+ const rootRef = react.useRef(null);
7302
+ const graphRef = react.useRef(null);
7303
+ const [narrow, setNarrow] = react.useState(false);
7304
+ const [zoom, setZoom] = react.useState(1);
7305
+ react.useEffect(() => {
7306
+ const el = rootRef.current;
7307
+ if (!el || typeof ResizeObserver !== "function") return;
7308
+ const ro = new ResizeObserver((entries) => {
7309
+ const w = entries && entries[0] ? entries[0].contentRect.width : 0;
7310
+ // 阈值从 640 降到 320:竖链与阶段带的固有宽度都是 354px——栏窄一点反而更该画图。
7311
+ // 640 那档是按当年那张摊得很开的假数据定的,真数据下它只会把右栏(通常 400~600px)
7312
+ // 全部推给「一列节点卡片」那一支,于是「看不到图谱」。
7313
+ setNarrow(w > 0 && w < 320);
7314
+ });
7315
+ ro.observe(el);
7316
+ return () => ro.disconnect();
7317
+ }, []);
7318
+ // 滚轮缩放必须**自己挂非被动监听**:React 的 onWheel 是挂在 root 上的被动监听,
7319
+ // preventDefault 会被浏览器忽略(控制台里那句 "Unable to preventDefault inside passive
7320
+ // event listener"),结果是 Ctrl+滚轮既缩放画布、又缩放整页,谁也没拦住。
7321
+ // 只认 Ctrl/⌘:普通滚轮仍旧是滚动画布——劫持它会让人滚不到下面的图例。
7322
+ react.useEffect(() => {
7323
+ const el = graphRef.current;
7324
+ if (!el || typeof el.addEventListener !== "function") return undefined;
7325
+ const onWheel = (ev) => {
7326
+ if (!ev.ctrlKey && !ev.metaKey) return;
7327
+ ev.preventDefault();
7328
+ setZoom((z) => clampZoom(z * (ev.deltaY < 0 ? 1.15 : 1 / 1.15)));
7329
+ };
7330
+ el.addEventListener("wheel", onWheel, { passive: false });
7331
+ return () => el.removeEventListener("wheel", onWheel);
7332
+ }, [narrow]);
7333
+
7334
+ const run = runs.find((r) => r.id === selectedRunId) || null;
7335
+ const selectedNode = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : null;
7336
+ if (!nodes.length) {
7337
+ return h("div", { className: "mneme-pipeview" },
7338
+ h("div", { className: "mneme-pipe-empty" },
7339
+ h("div", { className: "mneme-pipe-emptyTitle" }, t("memory.actions.empty.title")),
7340
+ t("memory.actions.empty.body")
7341
+ ),
7342
+ h("div", { className: "mneme-pipe-notice" }, t("memory.actions.notice"))
7343
+ );
7344
+ }
7345
+ return h("div", { className: "mneme-pipeview", ref: rootRef, style: { display: "flex", flexDirection: "column", flex: "1", minHeight: "0" } },
7346
+ h(PipelineRunChips, { t, runs, selectedRunId, onSelectRun: setSelectedRunId }),
7347
+ // 流程概览带:投影里 pipeline.done / trigger 此前一个都没渲染——第一屏
7348
+ // 说不出「这条流程要达成什么、什么时候起跑」。放画布上方、做成 HTML
7349
+ // 块而非 SVG 文本:125 字的完成判据要能换行。数据由投影保证存在。
7350
+ (data && data.pipeline) ? h("div", { className: "mneme-pipe-overview" },
7351
+ h("div", null,
7352
+ h("span", { className: "mneme-pipe-dt" }, t("memory.actions.ov.done") + ":"),
7353
+ data.pipeline.done
7354
+ ),
7355
+ h("div", null,
7356
+ h("span", { className: "mneme-pipe-dt" }, t("memory.actions.ov.trigger") + ":"),
7357
+ (data.pipeline.trigger || []).map((x) => x && x.value).filter(Boolean).join("、") || null
7358
+ )
7359
+ ) : null,
7360
+ // 一条运行记录都没有 = 全部候选。这句话得说出来:卡片上「候选」两个字本身
7361
+ // 不解释任何东西,而用户在这一屏最容易问的就是「为什么全是候选」。
7362
+ runs.length ? null : h("div", { className: "mneme-hint" }, t("memory.actions.noRuns")),
7363
+ // 缩放条放在分支**外面**:不论宽窄都在,既是控制,也是「新代码到没到」的肉眼标记。
7364
+ h(PipelineZoomBar, { t, zoom, onZoom: (z) => setZoom(clampZoom(z)) }),
7365
+ narrow
7366
+ ? h(PipelineList, { t, data, run, selectedNodeId, onSelectNode: setSelectedNodeId })
7367
+ : h("div", { className: "mneme-pipe-graph", ref: graphRef },
7368
+ // 宽度用百分比而不是 transform:百分比改的是**布局尺寸**,容器照常出滚动条,
7369
+ // 于是顺带就有了平移;transform 只改绘制,放大出去的那一半永远滚不到。
7370
+ h("div", { className: "mneme-pipe-zoomwrap", style: { width: (zoom * 100) + "%" } },
7371
+ h(PipelineGraphSvg, { t, data, run, selectedNodeId, onSelectNode: setSelectedNodeId })
7372
+ )
7373
+ ),
7374
+ h(PipelineLegend, { t }),
7375
+ selectedNode ? h(PipelineNodeDetail, { t, node: selectedNode }) : null,
7376
+ h("div", { className: "mneme-pipe-notice" }, t("memory.actions.notice"))
7377
+ );
7378
+ }
7379
+
7380
+ // 右栏 tab 的正文。单独包一层组件、而不是把 hook 塞进 slots.register 的回调里:
7381
+ // 那个回调是不是当组件渲染由宿主决定,hook 放在名字明确的组件里才不必赌这件事。
7382
+ function PipelinePane(props) {
7383
+ const { t, project } = props;
7384
+ return h(PipelineBrowser, { t, project });
7385
+ }
7386
+
7387
+ // 流水线标记:两个步骤块 + 依赖连线 + 状态点;开合状态用右上角
7388
+ // 实心/空心点区分(会话头图标要反映浮层开合)。
7389
+ function PipelineMark(props) {
7390
+ const { size = 16, open } = props || {};
7391
+ return h("svg", { width: size, height: size, viewBox: "0 0 16 16", "aria-hidden": "true", focusable: "false" },
7392
+ h("path", { d: "M2.5 3h4.2v4.2H2.5z M9.3 8.8h4.2V13H9.3z", fill: "none", stroke: "currentColor", "stroke-width": 1.4 }),
7393
+ h("path", { d: "M6.7 5.1h4v3.7", fill: "none", stroke: "currentColor", "stroke-width": 1.2 }),
7394
+ h("circle", {
7395
+ cx: 13.2, cy: 2.8, r: 1.9,
7396
+ fill: open ? "currentColor" : "none",
7397
+ stroke: "currentColor", "stroke-width": 1.2
7398
+ })
7399
+ );
7400
+ }
7401
+
7402
+ // 会话头入口:悬停 150ms 开 / 移开 120ms 关 / 点击固定 / Escape 关 /
7403
+ // 点击外部关(交互规格与 Agent Teams 浮层一致)。浮层锚 trigger、
7404
+ // portal 到 body。老宿主没有右栏服务时补一个「完整视图」按钮退回
7405
+ // 居中 sheet(形态沿用记忆库 overlay 的背板 + 居中面板)。
7406
+ function PipelineHeaderEntry(props) {
7407
+ const { t, hasPane, openPane, project } = props;
7408
+ const [open, setOpen] = useState(false);
7409
+ const [sheetOpen, setSheetOpen] = useState(false);
7410
+ const openRef = useRef(false);
7411
+ const pinnedRef = useRef(false);
7412
+ const hoverTimer = useRef(null);
7413
+ const rootRef = useRef(null);
7414
+ const anchorRef = useRef(null);
7415
+ const panelRef = useRef(null);
7416
+ const labelRef = useRef(null);
7417
+ const sheetRef = useRef(null);
7418
+
7419
+ const changeOpen = (next) => {
7420
+ const was = openRef.current;
7421
+ const wasPinned = pinnedRef.current;
7422
+ openRef.current = next;
7423
+ setOpen(next);
7424
+ if (!next) pinnedRef.current = false;
7425
+ // announce 只挂「用户明确动作」的开合(点击固定 / Esc / 点外关);
7426
+ // 悬停开合是瞬态,播报反而刷屏——与 §5 无障碍约束对齐。
7427
+ if (next && !was && wasPinned) announce(t("memory.actions.announce.open"));
7428
+ if (!next && was && wasPinned) announce(t("memory.actions.announce.close"));
7429
+ };
7430
+ const cancelHoverChange = () => {
7431
+ if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; }
7432
+ };
7433
+ const scheduleHoverOpen = () => {
7434
+ if (pinnedRef.current || openRef.current) return;
7435
+ // 窄容器下文字标签被 @container 隐藏(图标态)→ 不弹浮层,点击才开。
7436
+ if (labelRef.current && labelRef.current.offsetWidth === 0) return;
7437
+ cancelHoverChange();
7438
+ hoverTimer.current = setTimeout(() => {
7439
+ hoverTimer.current = null;
7440
+ openRef.current = true;
7441
+ setOpen(true);
7442
+ }, 150);
7443
+ };
7444
+ const scheduleHoverClose = () => {
7445
+ if (pinnedRef.current) return;
7446
+ cancelHoverChange();
7447
+ hoverTimer.current = setTimeout(() => {
7448
+ hoverTimer.current = null;
7449
+ openRef.current = false;
7450
+ setOpen(false);
7451
+ }, 120);
7452
+ };
7453
+
7454
+ // 钩子顺序稳定:useAnchoredPosition 的存在性是模块常量条件,两次
7455
+ // 判断在所有渲染中走同一分支,不违反 hook 规则。
7456
+ const positioned = primitives && typeof primitives.useAnchoredPosition === "function"
7457
+ ? primitives.useAnchoredPosition({ open, anchorRef, panelRef, gap: 5, margin: 16 })
7458
+ : null;
7459
+ const panelStyle = positioned
7460
+ ? { ...positioned }
7461
+ : { position: "fixed", left: 0, top: 0, visibility: "hidden" };
7462
+
7463
+ if (primitives && typeof primitives.useDismissOnOutsidePointer === "function") {
7464
+ primitives.useDismissOnOutsidePointer(rootRef, open, changeOpen, panelRef);
7465
+ }
7466
+ useEffect(() => {
7467
+ if (primitives && typeof primitives.useDismissOnOutsidePointer === "function") return;
7468
+ if (!open) return;
7469
+ const onDown = (ev) => {
7470
+ const panel = panelRef.current;
7471
+ const root = rootRef.current;
7472
+ if (panel && panel.contains(ev.target)) return;
7473
+ if (root && root.contains(ev.target)) return;
7474
+ changeOpen(false);
7475
+ };
7476
+ document.addEventListener("pointerdown", onDown, true);
7477
+ return () => document.removeEventListener("pointerdown", onDown, true);
7478
+ }, [open]);
7479
+ useEffect(() => () => cancelHoverChange(), []);
7480
+
7481
+ // 居中 sheet 是「宿主没有右栏服务」时的唯一完整入口,键盘用户必须能进能出。
7482
+ // 它 portal 到 body,焦点默认留在原 trigger 上,而挂在 sheet 元素上的
7483
+ // onKeyDown 既因没有 tabIndex 不被聚焦、也收不到外部键盘事件——Escape 于是
7484
+ // 不生效,只剩 Tab 摸到关闭按钮一条路。做法与 MemoryOverlay 一致:打开时把
7485
+ // 焦点移进 sheet,Escape 在 window 上收,关掉再把焦点还给 trigger。
7486
+ useEffect(() => {
7487
+ if (!sheetOpen) return undefined;
7488
+ if (sheetRef.current) sheetRef.current.focus();
7489
+ const onKey = (ev) => {
7490
+ if (ev.key !== "Escape") return;
7491
+ ev.preventDefault();
7492
+ ev.stopPropagation();
7493
+ setSheetOpen(false);
7494
+ if (anchorRef.current) anchorRef.current.focus();
7495
+ };
7496
+ window.addEventListener("keydown", onKey);
7497
+ return () => window.removeEventListener("keydown", onKey);
7498
+ }, [sheetOpen]);
7499
+
7500
+ const closeAll = () => {
7501
+ changeOpen(false);
7502
+ if (anchorRef.current) anchorRef.current.focus();
7503
+ };
7504
+ const onTriggerClick = () => {
7505
+ if (!openRef.current) {
7506
+ pinnedRef.current = true;
7507
+ changeOpen(true);
7508
+ setTimeout(() => { if (panelRef.current) panelRef.current.focus(); }, 0);
7509
+ } else if (pinnedRef.current) {
7510
+ changeOpen(false);
7511
+ } else {
7512
+ pinnedRef.current = true;
7513
+ if (panelRef.current) panelRef.current.focus();
7514
+ }
7515
+ };
7516
+ const doc = usePipeline(project, null);
7517
+ // 头部这一格标的是**这份清单读的是哪个项目**(某条工作流归谁,标在清单每一行上):
7518
+ // 只取目录名,绝对路径不上界面——面板是会被截图的那一面。认不出项目时如实说,
7519
+ // 而不是编一个「全局」顶上:认不出和全局是两件事。
7520
+ const projectSource = ((doc && doc.sources) || []).find((s) => s.scope === "project");
7521
+ const scopeLabel = projectSource && projectSource.label
7522
+ ? t("memory.actions.scope.project").replace("{name}", projectSource.label)
7523
+ : t("memory.actions.scope.none");
7524
+ // 数的是这个项目里读出几条工作流;没拿到数据时是 0,不是随便挑一个数。
7525
+ const workflowCount = doc && doc.available ? doc.available.length : 0;
7526
+
7527
+ const panel = (open && reactDom && typeof document !== "undefined")
7528
+ ? reactDom.createPortal(
7529
+ h("div", {
7530
+ ref: panelRef,
7531
+ // 注意:client.test.js 锁死全文件不得出现 role:"dialog"
7532
+ // (v0.7.15 清退 dialog 面的纪律),浮层只带 aria-label。
7533
+ "aria-label": t("memory.actions.title"),
7534
+ tabIndex: -1,
7535
+ className: "mneme-pipe mneme-pipe-panel",
7536
+ style: panelStyle,
7537
+ onMouseEnter: cancelHoverChange,
7538
+ onMouseLeave: scheduleHoverClose,
7539
+ onKeyDown: (ev) => { if (ev.key === "Escape") { ev.preventDefault(); ev.stopPropagation(); closeAll(); } }
7540
+ },
7541
+ h("div", { className: "mneme-pipe-head" },
7542
+ h("span", { className: "mneme-pipe-title" }, t("memory.actions.title")),
7543
+ h("span", { className: "mneme-pipe-beta" }, t("memory.actions.beta")),
7544
+ h("span", { className: "mneme-pipe-scope" }, scopeLabel),
7545
+ h("button", {
7546
+ type: "button",
7547
+ className: "mneme-pipe-run",
7548
+ // 有右栏服务:openTab(kind) 打开原生右栏 tab(页面类型无
7549
+ // patterns,按 kind 打开)并收起浮层;打不开或老宿主
7550
+ // (无 sidebarRight)退回本插件的居中 sheet——形态沿用
7551
+ // 记忆库 overlay 的背板 + 居中面板。
7552
+ onClick: () => {
7553
+ if (openPane && openPane()) { changeOpen(false); return; }
7554
+ changeOpen(false);
7555
+ setSheetOpen(true);
7556
+ }
7557
+ }, hasPane && openPane ? t("memory.actions.openPane") : t("memory.actions.viewFull"))
7558
+ ),
7559
+ h("div", { className: "mneme-pipe-notice" }, t("memory.actions.notice")),
7560
+ h(PipelineBrowser, { t, project })
7561
+ ),
7562
+ document.body
7563
+ )
7564
+ : null;
7565
+
7566
+ const sheet = (sheetOpen && reactDom && typeof document !== "undefined")
7567
+ ? reactDom.createPortal(
7568
+ h("div", {
7569
+ className: "mneme-backdrop",
7570
+ onClick: (ev) => { if (ev.target === ev.currentTarget) setSheetOpen(false); }
7571
+ },
7572
+ h("div", {
7573
+ ref: sheetRef,
7574
+ tabIndex: -1,
7575
+ className: "mneme-pipe mneme-pipe-sheet",
7576
+ "aria-label": t("memory.actions.title"),
7577
+ onKeyDown: (ev) => { if (ev.key === "Escape") { ev.stopPropagation(); setSheetOpen(false); } }
7578
+ },
7579
+ h("div", { className: "mneme-pipe-sheetbar" },
7580
+ h("span", { className: "mneme-pipe-title" }, t("memory.actions.title")),
7581
+ h("span", { className: "mneme-pipe-beta" }, t("memory.actions.beta")),
7582
+ h("span", { className: "mneme-pipe-scope" }, scopeLabel),
7583
+ h("button", { type: "button", className: "mneme-pipe-run", onClick: () => setSheetOpen(false) }, t("memory.actions.close"))
7584
+ ),
7585
+ h("div", { className: "mneme-pipe-sheetbody" }, h(PipelineBrowser, { t, project }))
7586
+ )
7587
+ ),
7588
+ document.body
7589
+ )
7590
+ : null;
7591
+
7592
+ return h("div", {
7593
+ className: "mneme-pipe-root",
7594
+ ref: rootRef,
7595
+ onMouseEnter: scheduleHoverOpen,
7596
+ onMouseLeave: scheduleHoverClose
7597
+ },
7598
+ h("button", {
7599
+ type: "button",
7600
+ ref: anchorRef,
7601
+ className: "mneme-pipe-trigger",
7602
+ "aria-haspopup": "true",
7603
+ "aria-expanded": open ? "true" : "false",
7604
+ title: t("memory.actions.open"),
7605
+ onClick: onTriggerClick,
7606
+ onKeyDown: (ev) => { if (ev.key === "Escape" && open) { ev.preventDefault(); ev.stopPropagation(); closeAll(); } }
7607
+ },
7608
+ h(PipelineMark, { open }),
7609
+ h("span", { ref: labelRef, className: "mneme-pipe-triggerLabel" }, t("memory.actions.title")),
7610
+ workflowCount > 0 ? h("span", {
7611
+ className: "mneme-pipe-dot",
7612
+ title: t("memory.actions.list.count").replace("{n}", String(workflowCount))
7613
+ }, String(workflowCount)) : null
7614
+ ),
7615
+ panel,
7616
+ sheet
7617
+ );
7618
+ }
7619
+
7620
+ // 对话行结算卡(turnTail):死的落款——「这次跑哪条被用上了」。
7621
+ // 与会话头浮层读同一份数据;卡上勾选 = usedNodeIds(真正被复用),
7622
+ // 其余 path 节点只是被走过。没有运行记录时整张卡不出现——发一条「本次没用上任何
7623
+ // 做法」的空卡比不发更吵:它每一轮都会来一次。
7624
+ function TurnRunCard(props) {
7625
+ const { t, project } = props;
7626
+ const doc = usePipeline(project, null);
7627
+ const data = doc || {};
7628
+ const run = (data.runs || []).find((r) => r.id === "r1") || (data.runs || [])[0];
7629
+ if (!run) return null;
7630
+ const used = run.usedNodeIds || [];
7631
+ const refutedUsed = used.some((id) => {
7632
+ const n = (data.nodes || []).find((x) => x.id === id);
7633
+ return n && n.state === "refuted";
7634
+ });
7635
+ return h("div", { className: "mneme-pipe mneme-pipe-settle" },
7636
+ h("div", { className: "mneme-pipe-settleHead" },
7637
+ h("span", { className: "mneme-pipe-settleTitle" },
7638
+ h(PipelineMark, { size: 13 }),
7639
+ t("memory.actions.settle.title")
7640
+ ),
7641
+ h("span", { className: "mneme-pipe-beta" }, t("memory.actions.beta")),
7642
+ h("span", { className: "mneme-pipe-scope" },
7643
+ t("memory.actions.settle.line").replace("{title}", data.pipeline.title) + " · " + pipeFmtWhen(run.at))
7644
+ ),
7645
+ h("div", { className: "mneme-pipe-steps" },
7646
+ (run.path || []).map((id, i) => {
7647
+ const n = (data.nodes || []).find((x) => x.id === id);
7648
+ if (!n) return null;
7649
+ const isUsed = used.includes(id);
7650
+ return h(react.Fragment, { key: id + ":" + i },
7651
+ i > 0 ? h("span", { className: "mneme-pipe-stepArrow", "aria-hidden": "true" }, "→") : null,
7652
+ h("span", {
7653
+ className: "mneme-pipe-stepChip" + (isUsed ? " mneme-pipe-stepChip--used" : ""),
7654
+ title: n.title
7655
+ },
7656
+ (isUsed ? "✓ " : "") + pipeClip(n.title, 12)
7657
+ )
7658
+ );
7659
+ })
7660
+ ),
7661
+ h("div", { className: "mneme-pipe-scope" },
7662
+ t("memory.actions.settle.used").replace("{n}", String(used.length))
7663
+ ),
7664
+ refutedUsed ? h("div", { className: "mneme-pipe-scope" }, t("memory.actions.settle.refutedWarn")) : null
7665
+ );
7666
+ }
7667
+
7668
+ // 座位注册(仅在旗标开启后被调用):会话头入口、对话行结算卡、
7669
+ // 右栏类型与正文。⚠️ 服务名 sidebarRight 与注册服务名 sidebarRightTabs
7670
+ // 是两个不同的名字——类型/引导挂 sidebarRightTabs,打开走
7671
+ // ctx.get("sidebarRight").openTab。无右栏服务时不注册 pane(浮层 +
7672
+ // 居中 sheet 退化已在 PipelineHeaderEntry 内)。
7673
+ function registerPipelineSeats(ctx, t) {
7674
+ let hasPane = false;
7675
+ try {
7676
+ const sb = typeof ctx.get === "function" ? ctx.get("sidebarRight") : null;
7677
+ hasPane = !!(sb && typeof sb.openTab === "function");
7678
+ } catch { hasPane = false; }
7679
+ // 右栏打开:页面类型(无 patterns)按 kind 打开——openTab(kind)。
7680
+ // 服务半可用 / kind 未注册抛错时返回 false,调用方退化到居中 sheet。
7681
+ const openPane = () => {
7682
+ try {
7683
+ const sb = typeof ctx.get === "function" ? ctx.get("sidebarRight") : null;
7684
+ if (sb && typeof sb.openTab === "function") {
7685
+ sb.openTab(PIPELINE_ACTIONS_TAB_KIND);
7686
+ return true;
7687
+ }
7688
+ } catch (err) {
7689
+ console.warn("[dsh-mneme] sidebarRight.openTab failed, falling back to sheet:", err);
7690
+ }
7691
+ return false;
7692
+ };
7693
+
7694
+ // 会话头入口:.actions 座位(.corner 被原生侧栏展开键占用,别用)。
7695
+ // 会话工作目录只有客户端拿得到,而「当前是哪个会话」只在座位注入里给——所以
7696
+ // 每个座位都要 inject: (sessionId) => ({ sessionId }),否则读到的永远是全局层。
7697
+ // 宿主若不吃这个选项,project 就是 null,面板如实说「项目未知」,不假装成功。
7698
+ ctx.slots.inject("conversation.session.header.actions", () => {
7699
+ return ctx.slots.register({
7700
+ name: "conversation.session.header.actions",
7701
+ id: "dsh-mneme:pipeline",
7702
+ order: 30,
7703
+ inject: (sessionId) => ({ sessionId })
7704
+ }, (props) => h(PipelineHeaderEntry, {
7705
+ t, hasPane, openPane, project: sessionProject(ctx, props && props.sessionId)
7706
+ }));
7707
+ });
7708
+
7709
+ // 对话行结算卡:回答「这次跑哪条被用上了」。
7710
+ ctx.slots.inject("conversation.chat.turnTail", () => {
7711
+ return ctx.slots.register({
7712
+ name: "conversation.chat.turnTail",
7713
+ id: "dsh-mneme:pipeline:settle",
7714
+ // kind/scope 是**声明方**(宿主)在 children 上写的,注册方不重复声明——
7715
+ // 座位本身已经声明成 { kind: "list", scope: "session" },多写一份只是噪音,
7716
+ // 万一注册侧按 schema 严校就是一次启动期抛错。
7717
+ inject: (sessionId) => ({ sessionId })
7718
+ }, (props) => h(TurnRunCard, { t, project: sessionProject(ctx, props && props.sessionId) }));
7719
+ });
7720
+
7721
+ // tab 的**类型**与它的**正文**必须各自兜自己的异常,而且类型注册要幂等。
7722
+ // 为什么:宿主对重复的类型 id 会抛(`sidebarRight: tab type id "…" is already registered`),
7723
+ // 而这两件事的生命周期不一样——类型注册挂在宿主服务 `sidebarRightTabs` 上、插件重挂(刷新 /
7724
+ // 热重载 / 会话重建)时**留着**;正文座位是挂插件 scope 的,会随插件回收、重挂时要重来。
7725
+ // 原来两件事写在同一个 try 里,于是第二次挂载时类型那一抛就把正文一起带走了:界面上
7726
+ // 只剩宿主的 fallback「这类内容还没有可用的查看方式」,而错误只落在浏览器控制台里
7727
+ // ——看不见、也搜不到,是最费时间的一类。
7728
+ try {
7729
+ const tabs = typeof ctx.get === "function" ? ctx.get("sidebarRightTabs") : null;
7730
+ if (tabs && typeof tabs.register === "function") {
7731
+ const already = typeof tabs.get === "function" ? tabs.get(PIPELINE_ACTIONS_TAB_ID) : null;
7732
+ if (!already) {
7733
+ tabs.register({
7734
+ id: PIPELINE_ACTIONS_TAB_ID,
7735
+ kind: PIPELINE_ACTIONS_TAB_KIND,
7736
+ title: () => t("memory.actions.title"),
7737
+ guide: [{
7738
+ id: "dsh-mneme:pipeline:guide",
7739
+ order: 10,
7740
+ title: () => t("memory.actions.title"),
7741
+ description: () => t("memory.actions.notice"),
7742
+ icon: (size) => h(PipelineMark, { size, open: false })
7743
+ }]
7744
+ });
7745
+ }
7746
+ }
7747
+ } catch (err) {
7748
+ console.warn("[dsh-mneme] action pipeline tab type registration failed:", err);
7749
+ }
7750
+
7751
+ try {
7752
+ const tabs = typeof ctx.get === "function" ? ctx.get("sidebarRightTabs") : null;
7753
+ if (tabs && typeof tabs.register === "function") {
7754
+ // 正文/标题都是按键座位:key = 类型 id,宿主按当前 tab 取。
7755
+ ctx.slots.inject("sidebar.right.pane.tab", () => {
7756
+ return ctx.slots.register({
7757
+ name: "sidebar.right.pane.tab",
7758
+ key: PIPELINE_ACTIONS_TAB_ID,
7759
+ inject: (sessionId) => ({ sessionId })
7760
+ }, (props) => h(PipelinePane, { t, project: sessionProject(ctx, props && props.sessionId) }));
7761
+ });
7762
+ ctx.slots.inject("sidebar.right.pane.tab.title", () => {
7763
+ return ctx.slots.register({
7764
+ name: "sidebar.right.pane.tab.title",
7765
+ key: PIPELINE_ACTIONS_TAB_ID
7766
+ }, () => t("memory.actions.title"));
7767
+ });
7768
+ }
7769
+ } catch (err) {
7770
+ console.warn("[dsh-mneme] action pipeline pane body registration failed:", err);
7771
+ }
7772
+
7773
+ if (!hasPane) {
7774
+ console.info("[dsh-mneme] action pipeline: no sidebarRight service, pane tab skipped (floating panel + sheet fallback active)");
7775
+ }
7776
+ }
7777
+
6277
7778
  function apply(ctx) {
6278
7779
  const t = ctx.locale.bind(NS);
6279
7780
  ctx.effect(() => ctx.locale.register(NS, dictionaries), "dsh-mneme: dictionaries");
@@ -6345,6 +7846,26 @@ window.__ModuleLoader__.load({
6345
7846
  }
6346
7847
  return () => { dead = true; };
6347
7848
  }, "dsh-mneme: better-sidebar mount");
7849
+
7850
+ // --- v0.9.0 M.O.(beta):旗标门控 ---
7851
+ // actionPipelineEnabled 关闭(默认)时一个座位都不注册——座位闸门
7852
+ // 在注册层而不是样式层(不是 display:none)。features 拉取失败时
7853
+ // 保持关闭(fail-closed),不猜。
7854
+ ctx.effect(() => {
7855
+ let dead = false;
7856
+ apiFetch("/api/dsh-mneme/features")
7857
+ .then((res) => { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); })
7858
+ .then((j) => {
7859
+ if (dead) return;
7860
+ const eff = (j && j.effective) || {};
7861
+ if (!eff.actionPipelineEnabled) return;
7862
+ registerPipelineSeats(ctx, t);
7863
+ })
7864
+ .catch((err) => {
7865
+ console.warn("[dsh-mneme] features fetch failed, action pipeline stays off:", err);
7866
+ });
7867
+ return () => { dead = true; };
7868
+ }, "dsh-mneme: action-pipeline gate");
6348
7869
  }
6349
7870
 
6350
7871
  exports.apply = apply;