@modusensus/dsh-mneme 0.9.5 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +9 -5
  2. package/docs/CONFIGURATION.md +21 -0
  3. package/docs/DAEMON.md +6 -2
  4. package/docs/DISTILL.md +90 -6
  5. package/docs/SLEEP.md +20 -6
  6. package/docs/STORAGE.md +1 -0
  7. package/lib/api-standalone.js +11 -3
  8. package/lib/api.js +25 -5
  9. package/lib/client.js +137 -10
  10. package/lib/config.js +33 -1
  11. package/lib/continuity.js +11 -3
  12. package/lib/dream/decisions.js +88 -27
  13. package/lib/dream/sleep.js +467 -81
  14. package/lib/dream.js +110 -13
  15. package/lib/fence.js +24 -0
  16. package/lib/index.js +15 -6
  17. package/lib/inject.js +9 -4
  18. package/lib/lang.js +17 -12
  19. package/lib/pipeline-nudge-auto.js +3 -0
  20. package/lib/pipeline-nudge.js +41 -8
  21. package/lib/pipeline-runner.js +15 -0
  22. package/lib/pre-inject-gate.js +14 -0
  23. package/lib/profile-extract.js +4 -1
  24. package/lib/provenance-http.js +162 -37
  25. package/lib/service.js +30 -0
  26. package/lib/session-gate.js +91 -0
  27. package/lib/settings.js +13 -1
  28. package/lib/store.js +526 -18
  29. package/lib/summarize.js +152 -15
  30. package/lib/tools.js +26 -6
  31. package/package.json +4 -2
  32. package/src/api-standalone.js +11 -3
  33. package/src/api.js +25 -5
  34. package/src/config.js +33 -1
  35. package/src/continuity.js +11 -3
  36. package/src/dream/decisions.js +88 -27
  37. package/src/dream/sleep.js +467 -81
  38. package/src/dream.js +110 -13
  39. package/src/fence.js +24 -0
  40. package/src/index.js +15 -6
  41. package/src/inject.js +9 -4
  42. package/src/lang.js +17 -12
  43. package/src/pipeline-nudge-auto.js +3 -0
  44. package/src/pipeline-nudge.js +41 -8
  45. package/src/pipeline-runner.js +15 -0
  46. package/src/pre-inject-gate.js +14 -0
  47. package/src/profile-extract.js +4 -1
  48. package/src/provenance-http.js +162 -37
  49. package/src/service.js +30 -0
  50. package/src/session-gate.js +91 -0
  51. package/src/settings.js +13 -1
  52. package/src/store.js +526 -18
  53. package/src/summarize.js +152 -15
  54. package/src/tools.js +26 -6
  55. package/test/api.test.js +153 -4
  56. package/test/client.test.js +215 -0
  57. package/test/config.test.js +8 -0
  58. package/test/conflict-reviewed.test.js +79 -0
  59. package/test/continuity.test.js +29 -1
  60. package/test/dependency-overrides.test.js +78 -0
  61. package/test/differentiate-feedback.test.js +248 -0
  62. package/test/dream-decision-evidence.test.js +314 -0
  63. package/test/dream-fence.test.js +43 -0
  64. package/test/inject-pools.test.js +2 -1
  65. package/test/mint-source.test.js +83 -0
  66. package/test/pipeline-nudge-auto.test.js +26 -2
  67. package/test/pipeline-nudge.test.js +87 -0
  68. package/test/pipeline-runner.test.js +26 -0
  69. package/test/prompt-stance.test.js +128 -0
  70. package/test/provenance-http.test.js +200 -17
  71. package/test/release-docs.test.js +7 -1
  72. package/test/security-doc.test.js +43 -4
  73. package/test/session-gate.test.js +264 -0
  74. package/test/settings.test.js +4 -0
  75. package/test/sleep-action-set.test.js +16 -11
  76. package/test/sleep-catchup.test.js +315 -0
  77. package/test/sleep-failure-backoff.test.js +156 -0
  78. package/test/sleep.test.js +293 -0
  79. package/test/standalone-api.test.js +50 -0
  80. package/test/store.test.js +220 -0
  81. package/test/summarize.test.js +172 -0
package/lib/client.js CHANGED
@@ -892,6 +892,12 @@ window.__ModuleLoader__.load({
892
892
  "memory.explorer.detail.quality": "质量分",
893
893
  "memory.explorer.detail.entities": "关联实体",
894
894
  "memory.explorer.detail.entitiesEmpty": "开启实体抽取后,相关人物/项目会出现在这里",
895
+ "memory.explorer.detail.provenance": "蒸发归因",
896
+ "memory.explorer.detail.provenanceEmpty": "这条记忆没有归因:早于改造的历史行不补、也不猜",
897
+ "memory.explorer.detail.provenanceMint": "铸造自",
898
+ "memory.explorer.detail.provenanceWindow": "蒸馏窗口",
899
+ "memory.explorer.detail.provenanceNoRun": "无运行归因(历史行)",
900
+ "memory.explorer.detail.provenanceRecords": "{n} 条原文",
895
901
  "memory.explorer.detail.closeAria": "关闭详情",
896
902
  "memory.explorer.detail.editTitle": "标题",
897
903
  "memory.explorer.detail.editContent": "内容",
@@ -899,6 +905,7 @@ window.__ModuleLoader__.load({
899
905
  "memory.explorer.detail.starLabel": "设为 {n} 星",
900
906
  "memory.status.dream": "最近巩固",
901
907
  "memory.status.dreamNever": "尚未运行",
908
+ "memory.status.dreamOrganizeOnly": "最近一次后台运行是 agent 主动整理({time}),不计入巩固",
902
909
  "memory.status.conflicts": "待确认冲突",
903
910
  "memory.status.conflictsHint": "冻结的矛盾记忆,等待人工确认",
904
911
  "memory.status.skippedTitle": "本轮跳过 {n} 条决定",
@@ -1654,6 +1661,12 @@ window.__ModuleLoader__.load({
1654
1661
  "memory.explorer.detail.quality": "Quality",
1655
1662
  "memory.explorer.detail.entities": "Related entities",
1656
1663
  "memory.explorer.detail.entitiesEmpty": "Turn on entity extraction and related people / projects appear here",
1664
+ "memory.explorer.detail.provenance": "Distillation provenance",
1665
+ "memory.explorer.detail.provenanceEmpty": "No attribution for this memory: rows older than the change are left blank, never guessed",
1666
+ "memory.explorer.detail.provenanceMint": "Minted by",
1667
+ "memory.explorer.detail.provenanceWindow": "Distill window",
1668
+ "memory.explorer.detail.provenanceNoRun": "No run attribution (legacy row)",
1669
+ "memory.explorer.detail.provenanceRecords": "{n} records",
1657
1670
  "memory.explorer.detail.closeAria": "Close details",
1658
1671
  "memory.explorer.detail.editTitle": "Title",
1659
1672
  "memory.explorer.detail.editContent": "Content",
@@ -1661,6 +1674,7 @@ window.__ModuleLoader__.load({
1661
1674
  "memory.explorer.detail.starLabel": "Set importance to {n} stars",
1662
1675
  "memory.status.dream": "Last consolidation",
1663
1676
  "memory.status.dreamNever": "Not yet run",
1677
+ "memory.status.dreamOrganizeOnly": "The last background run was an agent-initiated organize ({time}), not a consolidation",
1664
1678
  "memory.status.conflicts": "Pending conflicts",
1665
1679
  "memory.status.conflictsHint": "Frozen contradictory memories awaiting confirmation",
1666
1680
  "memory.status.skippedTitle": "{n} decisions skipped this run",
@@ -2015,6 +2029,13 @@ window.__ModuleLoader__.load({
2015
2029
  // --- graph sub-view (fills the content area under the tabs) ---
2016
2030
  ".mneme-graph{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;padding:12px 16px 16px;box-sizing:border-box}",
2017
2031
  ".mneme-graphbar{display:flex;gap:8px;align-items:center;flex:none;margin-bottom:10px}",
2032
+ // touch-action 保持 none:图谱的拖拽与缩放只挂了 mousedown/mousemove/wheel,
2033
+ // 全仓 onPointerDown/onTouchStart 命中 0,所以在触屏上它本来就不是一个可交手势的
2034
+ // 面。改成 pan-y 会让纵向拖拽退回页面滚动——而浏览器在 touch-action:none 下会派发
2035
+ // 兼容鼠标事件,触屏纵向拖拽究竟走哪条路,没有真机无法定论;这一版不做无法复验的
2036
+ // 行为变更,留给出指针事件重写的那条车道。高度也不在这里限:480 主断点已压到
2037
+ // min(320px,52vh),把 52vh 写进基础规则会在常见窗口高度(≈680px)下把桌面也压成
2038
+ // 353px,等于顺手改了大屏观感。
2018
2039
  ".mneme-graphsvg{flex:1;min-height:180px;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-button-elevated-fill);cursor:grab;touch-action:none}",
2019
2040
  ".mneme-gnode{cursor:pointer}",
2020
2041
  ".mneme-gnode circle{stroke:var(--dsw-alias-bg-layer-2);stroke-width:2;transition:stroke-width .12s}",
@@ -2058,7 +2079,9 @@ window.__ModuleLoader__.load({
2058
2079
  ".mneme-layer{margin-top:24px}",
2059
2080
  ".mneme-layerhead{display:flex;align-items:baseline;gap:8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:10px}",
2060
2081
  ".mneme-layercount{font-size:12px;font-weight:400;color:var(--mneme-fg-dim)}",
2061
- ".mneme-attrgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}",
2082
+ // 属性网格同理:min(200px,100%) 的 100% 兜底压在基础规则上,实体页 detail 在
2083
+ // 481–519px 只剩 257–276px(rail 仍占 224px 横排),裸 200px 下限会溢出。
2084
+ ".mneme-attrgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(200px,100%),1fr));gap:8px}",
2062
2085
  ".mneme-attrcard{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:8px 12px}",
2063
2086
  ".mneme-attrkey{font-size:11px;color:var(--mneme-fg-dim);margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
2064
2087
  ".mneme-attrval{font-size:13px;line-height:19px;color:var(--dsw-alias-label-primary);word-break:break-word}",
@@ -2173,8 +2196,12 @@ window.__ModuleLoader__.load({
2173
2196
  ".mneme-seg button{border:none;background:none;color:var(--mneme-fg-dim);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px;padding:5px 10px;display:inline-flex;align-items:center;gap:5px}",
2174
2197
  ".mneme-seg button:hover{color:var(--dsw-alias-label-primary)}",
2175
2198
  ".mneme-seg button.mneme-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);font-weight:500}",
2176
- // 卡片网格:响应式 auto-fill,卡片悬停轻浮起
2177
- ".mneme-cards{flex:1;min-height:0;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:14px;padding:20px 24px 32px;align-content:start}",
2199
+ // 卡片网格:响应式 auto-fill,卡片悬停轻浮起。
2200
+ // 轨道下限用 min(252px,100%) 而不是裸 252px:容器(.mneme-xcontent)只有
2201
+ // 265–294px 时裸下限仍要求 300px 轨道,网格横向溢出 2–11px 被外层 overflow:hidden
2202
+ // 切掉。这个洞出现在 481–519px——480 主断点之外、两栏又放不下的中间带,所以
2203
+ // 兜底必须落在基础规则上,只在断点里写等于给中间带留缺口。
2204
+ ".mneme-cards{flex:1;min-height:0;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(252px,100%),1fr));gap:14px;padding:20px 24px 32px;align-content:start}",
2178
2205
  ".mneme-card{display:flex;flex-direction:column;gap:7px;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 13px;background:var(--dsw-alias-bg-layer-1);cursor:pointer;font-family:inherit;text-align:left;transition:border-color .12s,box-shadow .12s,transform .12s}",
2179
2206
  ".mneme-card:hover{border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 45%,var(--dsw-alias-border-l2));box-shadow:0 6px 20px color-mix(in srgb,var(--dsw-alias-label-primary) 10%,transparent);transform:translateY(-1px)}",
2180
2207
  ".mneme-card.mneme-active{border-color:var(--dsw-alias-state-business-primary)}",
@@ -2627,6 +2654,48 @@ window.__ModuleLoader__.load({
2627
2654
  // 而「为什么禁用」本来就由 title 说明(读不到会话工作目录)。
2628
2655
  ".mneme-pipe-distillBtn:disabled{cursor:not-allowed;border-style:dashed;color:var(--mneme-fg-dim)}",
2629
2656
  ".mneme-pipe-distillNote{color:var(--mneme-fg-dim);line-height:17px;min-width:0}",
2657
+ // ===== 移动端适配(mobile-adapt 车道)=====
2658
+ // 为什么主通道是 @container 而不是 @media:面板有两种形态(overlay 弹层与
2659
+ // better-sidebar 工作台 tab),容器宽度与视口宽度并不相等;`.mneme-x` 是
2660
+ // container-type:inline-size 的容器根(1950 行),两种形态下容器查询都成立。
2661
+ // @media 只负责 `.mneme-overlay` 自己的外框——它不在 `.mneme-x` 之内,
2662
+ // 容器查询够不到它,这一条只能走视口。
2663
+ // 纪律:以下几块内一律不写 color/fill/stroke/background。contrast.test.js 的
2664
+ // 规则提取器按「单层花括号」匹配(/^([^{]+)\{([^}]*)\}$/),双花括号的
2665
+ // @container / @media 整串不匹配而被跳过——在这样的块里写颜色等于绕过 #178
2666
+ // 的 AA 主锁,属于静默失效,不是优化。布局属性不受影响。
2667
+ "@media (max-width: 640px){.mneme-overlay{width:calc(100vw - 16px);height:calc(100dvh - 16px);border-radius:12px}}",
2668
+ // 触屏没有 hover:设置页的删除钮 base 是 opacity:0、只靠 :hover/:focus-within
2669
+ // 恢复(2088-2089),手机上等于永久不可见。#457 与 #459 两次回滚都是因为把
2670
+ // hover 配色一起改了,所以这里只加「常显」,不碰颜色。min-height:24px 是
2671
+ // WCAG 2.5.8 AA 的触控目标下限,顺手补到常显按钮上。
2672
+ "@media (hover:none){.mneme-set-del{opacity:1;padding:0 8px;min-height:24px}.mneme-footbtn{min-height:24px}}",
2673
+ // 主断点 480:骨架换向,不是继续收窄数值。临界宽度实算——卡片要 252+48=300、
2674
+ // 左栏 216+1,面板得 ≥517px;实体属性网格要 248、rail 224+1,得 ≥473px。
2675
+ // 目标区间上沿只有 430px,靠收窄永远追不上,只能改骨架。
2676
+ // 左右分栏在窄容器里改上下:左栏/rail 限高可滚,不允许自然撑开把主区挤掉;
2677
+ // 不做折叠按钮(要 JS 状态 + 新 i18n 键,成本高于收益)。
2678
+ // 抽屉改满宽不丢关闭入口:抽屉自带 ✕(.mneme-drawerbar);锚点是
2679
+ // .mneme-xmain 自己的 position:relative(2252),换向后仍然成立。
2680
+ // 实体页换向的前提:rail 侧本就 .mneme-entrailbody{overflow-y:auto} +
2681
+ // .mneme-enttype{sticky}(2046/2047),叠放后天然可滚、零 JS、不加文案键。
2682
+ // 图谱只在 CSS 层止血(高度 + touch-action + 标签字号):它能拖能缩依赖
2683
+ // onMouseDown/onMouseMove/wheel,全仓 onPointerDown/onTouchStart 命中 0,
2684
+ // 那是能力缺失不是样式问题,归后续车道。
2685
+ // 页签的横向滚动也归这一档:4 个带图标的 tab 约 436px,而本档给 .mneme-xbar 留的
2686
+ // 可用宽度是容器宽减 62px(左 10 + 右 52 让位给绝对定位的 ⋯),480px 时只剩 418px
2687
+ // ——整段 361–480px 都会把页签顶到 ⋯ 底下,只写 360 档等于把症状留在主断点全程。
2688
+ // menu / impdialog / sharedialog 三处窄屏宽度必须带 box-sizing:border-box:全文件没有
2689
+ // *{box-sizing} 兜底(实数 0 处),声明的那个数是内容宽,外宽还要再加 padding+border
2690
+ // ——impdialog +30px、menu +12px,320px 视口下比屏幕还宽,会被 overlay 裁掉。
2691
+ // sharedialog 自己不带 padding:它和 impdialog 同挂一条 div(见 JSX 里的
2692
+ // `className:"mneme-impdialog mneme-sharedialog"`),盒模型由 impdialog 带过来,
2693
+ // 所以它同样 +30px;宽度取哪一条由源码顺序决定,故两条都得是 border-box。
2694
+ "@container (max-width: 480px){.mneme-xbar{justify-content:flex-start;padding:0 52px 0 10px}.mneme-vtab{padding:0 10px}.mneme-vtabs{min-width:0;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}.mneme-vtabs::-webkit-scrollbar{height:0}.mneme-xmain{flex-direction:column}.mneme-xside{width:100%;flex:0 1 auto;min-height:0;max-height:min(42vh,300px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2);overscroll-behavior:contain}.mneme-xbrowsebar{padding:8px 12px}.mneme-filterbar{padding:8px 12px}.mneme-drawer{width:100%}.mneme-drawerbody{padding:16px 14px 24px}.mneme-cards{padding:14px 12px 24px;overscroll-behavior:contain}.mneme-ent{flex-direction:column}.mneme-entrail{width:100%;flex:0 1 auto;min-height:0;max-height:min(40vh,280px);border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.mneme-entrailbody{overscroll-behavior:contain}.mneme-entdetail{padding:16px 14px 24px}.mneme-set{padding:8px 14px 40px}.mneme-status{padding:16px 14px 32px}.mneme-menu{box-sizing:border-box;min-width:0;width:min(200px,calc(100cqw - 32px))}.mneme-impdialog{box-sizing:border-box;width:min(300px,calc(100cqw - 32px))}.mneme-sharedialog{box-sizing:border-box;width:min(340px,calc(100cqw - 32px))}.mneme-graph{padding:10px 12px 12px}.mneme-graphsvg{max-height:min(320px,52vh);touch-action:pan-y}.mneme-glabel{font-size:14px}.mneme-graphside{position:static;width:auto;max-width:100%;max-height:min(200px,30vh);margin-top:8px}}",
2695
+ // 360 档只做两件事:隐掉 14px 的 tab 图标(.mneme-vtab 带图标时约 109px/个、
2696
+ // 4 个约 436px,320px 容器原地就横向溢出),再把正文 padding 收到 12–14px。
2697
+ // 页签横向滚动不在这一档、属于 480 主断点,理由见 480 档那条算式。
2698
+ "@container (max-width: 360px){.mneme-vtabico{display:none}.mneme-vtab{padding:0 8px;font-size:13px}.mneme-xbar{padding:0 46px 0 8px}.mneme-cards{grid-template-columns:1fr;padding:12px 10px 20px}.mneme-xside{max-height:min(46vh,260px)}.mneme-entrail{max-height:min(44vh,240px)}.mneme-entdetail{padding:14px 12px 22px}.mneme-set{padding:8px 12px 36px}.mneme-status{padding:14px 12px 30px}}",
2630
2699
  ].join("\n");
2631
2700
  if (typeof document !== "undefined" && document.querySelector(`style[data-plugin-css="${CSS_TAG}"]`) === null) {
2632
2701
  const tag = document.createElement("style");
@@ -2720,7 +2789,10 @@ window.__ModuleLoader__.load({
2720
2789
  // ③ 关联记忆 memories recalled through the entity: prefix search
2721
2790
  // ④ 关系图谱 the same ego graph the old graph-only tab drew
2722
2791
  function EntityPanel({ t, focusEntity, onJumpMemory }) {
2723
- const [entities, setEntities] = useState(null); // null = still loading
2792
+ const [entities, setEntities] = useState(null);
2793
+ // #512:这条记忆的归因——窗口级(哪一段原文蒸出来的)与行级(哪一轮铸造的)。
2794
+ // null = 还没读到(或读失败),两者要分得开,不能被当成「没有归因」。
2795
+ const [provenance, setProvenance] = useState(null); // null = still loading
2724
2796
  const [railQuery, setRailQuery] = useState("");
2725
2797
  const [entityName, setEntityName] = useState(focusEntity || "");
2726
2798
  const [depth, setDepth] = useState(1);
@@ -5501,11 +5573,16 @@ window.__ModuleLoader__.load({
5501
5573
  .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
5502
5574
  .then((d) => {
5503
5575
  if (cancelled) return;
5576
+ // #495:这块 KPI 说的是「上一轮巩固跳过了什么」,所以要的是**巩固轮**。
5577
+ // organize 回执同样带 skipped,但它的跳过不是巩固决策(例如「与既有
5578
+ // 记忆重复」),混进来会让人以为巩固那一整类动作被拦。后端 lastConsolidation
5579
+ // 只含 auto/sleep;旧服务端没有该键时退回 lastRun,保持原行为。
5580
+ const last = (d && (d.lastConsolidation || d.lastRun)) || null;
5504
5581
  setConf({
5505
5582
  loading: false,
5506
5583
  error: false,
5507
5584
  pending: Number((d && d.pendingConflicts) ?? 0),
5508
- skipped: Array.isArray(d && d.lastRun && d.lastRun.skippedReasons) ? d.lastRun.skippedReasons : []
5585
+ skipped: Array.isArray(last && last.skippedReasons) ? last.skippedReasons : []
5509
5586
  });
5510
5587
  })
5511
5588
  .catch(() => { if (!cancelled) setConf({ loading: false, error: true, pending: 0, skipped: [] }); });
@@ -5756,7 +5833,7 @@ window.__ModuleLoader__.load({
5756
5833
  // showConflicts=false 用于 2026-10 改造后的降级折叠区:待确认冲突已升入
5757
5834
  // KPI 带,这里不再重复渲染冲突卡,只留最近运行卡。
5758
5835
  function DreamStatusCards({ t, onGotoQueue, showConflicts = true }) {
5759
- const [state, setState] = useState({ loading: true, error: false, lastRun: null, pending: 0 });
5836
+ const [state, setState] = useState({ loading: true, error: false, lastRun: null, lastConsolidation: null, pending: 0 });
5760
5837
  useEffect(() => {
5761
5838
  let cancelled = false;
5762
5839
  const loadOnce = () => {
@@ -5768,10 +5845,11 @@ window.__ModuleLoader__.load({
5768
5845
  loading: false,
5769
5846
  error: false,
5770
5847
  lastRun: (d && d.lastRun) || null,
5848
+ lastConsolidation: (d && d.lastConsolidation) || null,
5771
5849
  pending: Number((d && d.pendingConflicts) ?? 0)
5772
5850
  });
5773
5851
  })
5774
- .catch(() => { if (!cancelled) setState({ loading: false, error: true, lastRun: null, pending: 0 }); });
5852
+ .catch(() => { if (!cancelled) setState({ loading: false, error: true, lastRun: null, lastConsolidation: null, pending: 0 }); });
5775
5853
  };
5776
5854
  loadOnce();
5777
5855
  // #295 评审:裁决完成后队列会广播 mneme:conflicts-changed,这里重取
@@ -5779,11 +5857,26 @@ window.__ModuleLoader__.load({
5779
5857
  window.addEventListener("mneme:conflicts-changed", loadOnce);
5780
5858
  return () => { cancelled = true; window.removeEventListener("mneme:conflicts-changed", loadOnce); };
5781
5859
  }, []);
5782
- const run = state.lastRun;
5860
+ // #495:这张卡的标题是「最近巩固」,取数就该是巩固轮,而不是 runs[0]。
5861
+ // organize 与巩固共用 dream_runs,一次 agent 主动整理会写两行(dryRun 报告 +
5862
+ // apply 回执),把它顶到首位后这张卡就变成「整理回执」了。
5863
+ // 卡面数字只认巩固轮:lastConsolidation 由后端排除 organize 后取一条。旧服务端
5864
+ // 没有这个键时退回 lastRun,但退回的那条若是 organize 就仍然不算「最近巩固」。
5865
+ const organizedOnly = state.lastRun && state.lastRun.run_type === "organize" ? state.lastRun : null;
5866
+ const run = state.lastConsolidation
5867
+ || (state.lastRun && !organizedOnly ? state.lastRun : null);
5783
5868
  const num = run ? formatRelativeTime(run.created_at, t) : t("memory.status.dreamNever");
5869
+ // issue 给的两条路里选「标注」:最近一次后台运行是 organize 时,用脚注点名它,
5870
+ // 而不是把它从审计面抹掉。它没有挤掉巩固数(卡面仍是巩固轮),只是补一句说明
5871
+ // ——否则读者会以为整理就是那次巩固。
5872
+ const organizeFoot = organizedOnly && !run
5873
+ ? t("memory.status.dreamOrganizeOnly").replace("{time}", formatRelativeTime(organizedOnly.created_at, t))
5874
+ : null;
5784
5875
  const cap = run
5785
5876
  ? `${run.status || "—"}${run.model ? ` · ${run.model}` : ""}`
5786
- : t("memory.settings.mode.offList");
5877
+ // 只有 organize 跑过时不能沿用 offList 那句「已关闭:巩固…」——autoDream 可能
5878
+ // 开着,只是还没轮到。这时只留脚注说明最近那一条是什么。
5879
+ : organizeFoot ? null : t("memory.settings.mode.offList");
5787
5880
  // #178:pending>0 时冲突卡可激活(role=button + 可聚焦 + 回车/空格),
5788
5881
  // 激活直达队列——StatusPanel 把 onGotoQueue 传进来(一步 prop,无线程)。
5789
5882
  const pending = state.pending > 0;
@@ -5791,7 +5884,7 @@ window.__ModuleLoader__.load({
5791
5884
  // #521 的跳过明细不在这里渲染:它已提到 TokenFlowSection 的 KPI 带下方,
5792
5885
  // 首屏可见。这张卡在折叠区里,展开后再重复一遍只会让人以为有两批跳过。
5793
5886
  return h(react.Fragment, null,
5794
- h(StatusCard, { t, title: t("memory.status.dream"), loading: state.loading, error: state.error, num, cap }),
5887
+ h(StatusCard, { t, title: t("memory.status.dream"), loading: state.loading, error: state.error, num, cap, foot: organizeFoot }),
5795
5888
  showConflicts
5796
5889
  ? h("div", {
5797
5890
  role: pending ? "button" : undefined,
@@ -6423,6 +6516,21 @@ window.__ModuleLoader__.load({
6423
6516
  return () => { cancelled = true; };
6424
6517
  }, [memory.id]);
6425
6518
 
6519
+ // #512:归因单独取一次。读失败/未开启时保持 null——「没读到」不等于
6520
+ // 「没有归因」,界面上不能把这两件事显示成同一句话。
6521
+ useEffect(() => {
6522
+ let cancelled = false;
6523
+ setProvenance(null);
6524
+ apiFetch("/api/dsh-mneme/memory/provenance?id=" + encodeURIComponent(memory.id))
6525
+ .then((res) => (res.ok ? res.json() : null))
6526
+ .then((d) => {
6527
+ if (cancelled) return;
6528
+ if (d && Array.isArray(d.windows) && Array.isArray(d.mints)) setProvenance(d);
6529
+ })
6530
+ .catch(() => { if (!cancelled) setProvenance(null); });
6531
+ return () => { cancelled = true; };
6532
+ }, [memory.id]);
6533
+
6426
6534
  const postUpdate = (patch, opts) => {
6427
6535
  setBusy(true);
6428
6536
  setSaveError(false);
@@ -6603,6 +6711,25 @@ window.__ModuleLoader__.load({
6603
6711
  h("span", { className: "mneme-xdot", style: { color: typeColor(e.type) }, "aria-hidden": "true" }),
6604
6712
  e.name
6605
6713
  )))),
6714
+ provenance !== null && h(react.Fragment, null,
6715
+ h("div", { className: "mneme-xcolhead", style: { padding: "16px 0 0" } }, t("memory.explorer.detail.provenance")),
6716
+ (provenance.windows.length === 0 && provenance.mints.length === 0)
6717
+ ? h("div", { className: "mneme-featsubhint", style: { marginTop: 4 } }, t("memory.explorer.detail.provenanceEmpty"))
6718
+ : h("div", { className: "mneme-dmeta" },
6719
+ provenance.mints.map((m, i) => h(react.Fragment, { key: "mint-" + m.run_id + "-" + i },
6720
+ h("span", { className: "mneme-dmetakey" }, t("memory.explorer.detail.provenanceMint")),
6721
+ h("span", { className: "mneme-dmetaval", title: m.run_id },
6722
+ [m.run_type, m.trigger_source, formatDateShort(m.created_at)].filter(Boolean).join(" · ")))),
6723
+ provenance.windows.map((w, i) => h(react.Fragment, { key: "win-" + w.session_id + "-" + w.seq_start + "-" + i },
6724
+ h("span", { className: "mneme-dmetakey" }, t("memory.explorer.detail.provenanceWindow")),
6725
+ h("span", { className: "mneme-dmetaval", title: w.session_id },
6726
+ [w.session_id + " · seq " + w.seq_start + "–" + w.seq_end,
6727
+ w.distill_run_id
6728
+ ? [w.run_type, w.run_status].filter(Boolean).join(" / ")
6729
+ : t("memory.explorer.detail.provenanceNoRun"),
6730
+ t("memory.explorer.detail.provenanceRecords").replace("{n}", String(w.records ? w.records.length : 0))
6731
+ ].join(" · ")))))
6732
+ ),
6606
6733
  editing && h("div", { className: "mneme-xcolhead", style: { padding: "16px 0 4px" } }, t("memory.explorer.detail.editContent")),
6607
6734
  editing
6608
6735
  ? h("textarea", { className: "mneme-dcontent-input", value: content, onChange: (e) => setContent(e.target.value) })
package/lib/config.js CHANGED
@@ -586,6 +586,16 @@ export const Config = z.object({
586
586
  // Minimum gap between two sleep runs (hours) — a second idle window within
587
587
  // this interval does not retrigger.
588
588
  sleepMinIntervalHours: z.natural().min(1).max(168).default(8),
589
+ // Issue #529(C 项的替代实现):sleep 连续失败退避(默认关 = 行为逐字节不变)。
590
+ // 基数是 sleepMinIntervalHours,有效间隔 = 基数 × 2^连续失败数(成功清零),
591
+ // 封顶 max(基数, 24 小时)——不是硬 24 小时:这个键允许配到 168,用户配 48 就是
592
+ // 明说「两天跑一次」,退避只能放宽间隔、不该把用户自己的基数压小。
593
+ // 为什么不按 issue 原案用 snapshot_hash 判据:sleep 的
594
+ // snapshot_hash 是**跑完之后**对 phases 结果算的哈希(dream/sleep.js 的
595
+ // createHash(JSON.stringify(phases))),不是输入快照;同一批输入跑两次结果不同
596
+ // 就会得到不同哈希,用它当「同一输入又在失败」的判据不成立。退避与 #292 的
597
+ // autoDreamFailureBackoff 同构:只放大既有的最小间隔闸,不新增状态面。
598
+ sleepFailureBackoff: z.boolean().default(false),
589
599
  // Conflict adjudication strictness:
590
600
  // gentle only high-confidence conflicts (threshold 0.92) are resolved
591
601
  // normal standard dream-level (threshold 0.85)
@@ -653,6 +663,22 @@ export const Config = z.object({
653
663
  // 增长);流式计费按实际用量,不按上限。
654
664
  sleepMaxTokens: z.natural().min(256).max(131072).default(8192).description("sleep 冲突消解与模式发现阶段的 LLM 输出预算上限(token)。原为硬编码 2048,sleepActionSet=full 实测需约 7000 导致裁决被截断而整轮失败;流式计费按实际用量,调大不增加成本。"),
655
665
 
666
+ // Issue #529:冲突阶段此前没有任何输入侧预算——同 scope 的全部相似对被一次性
667
+ // 拼进一个 prompt,实测单请求 163,054 输入 token(服务端 --ctx-size 163,840,
668
+ // 占 99.5%),449 对的池子必然撞墙;而失败的轮次不落 pairs、判 keep 又是
669
+ // no-op,下一轮冷却结束再送**完全相同的输入**,于是 23 轮连续 llm failed。
670
+ // 输出侧早有 sleepMaxTokens(#257),输入侧这一半一直是空的。
671
+ //
672
+ // 每批上限:0 = 不设上限(严格回退到旧的一次性全送,用于排查"是不是分批
673
+ // 引入的差异")。取 12 是因为实测单对约 112~168 输入 token、12 对落在
674
+ // 1.5K~2K 区间,远低于任何服务商的上下文窗口,也不至于让 LLM 在长清单里
675
+ // 走神。
676
+ sleepConflictMaxPairs: z.natural().min(0).max(500).default(12).description("sleep 冲突阶段每批送给 LLM 的候选对上限(0 = 不设上限,回退旧的一次性全送)。实测单对约 112~168 输入 token,默认 12 对约 1.5K~2K。"),
677
+ // 每轮总上限:分批之后仍要有个总闸,否则一个 449 对的池子会拆成 38 批、
678
+ // 每批一次 LLM 往返,单轮跑上几小时(本身也没比撞墙好)。0 = 不设上限。
679
+ // 未入选的对不是"被丢弃"——它们留在 conflict_pending 与下一轮的候选池里。
680
+ sleepConflictMaxPairsPerRun: z.natural().min(0).max(5000).default(60).description("sleep 冲突阶段单轮累计裁定的候选对上限(0 = 不设上限)。默认 60 对 ≈ 5 批,约 10K 输入 token。"),
681
+
656
682
  // --- epistemic trust: memory source credibility (v0.4.5) -----------------
657
683
  // Distinguish memories by source: observation (measured / witnessed),
658
684
  // subjective (opinion / guess) and inferred (derived from other evidence).
@@ -966,7 +992,13 @@ export const Config = z.object({
966
992
  // 显式保存,画像正文必须是用户手写的原文,不给模型改写权。
967
993
  // 进 LIGHT_MODE_OFF:一次调用要读上百行记忆 + 一轮模型生成,与低资源档互斥(同
968
994
  // preInjectGate.enabled 的定性);是预设不是强制,用户显式勾选仍然赢。
969
- profileExtractEnabled: z.boolean().default(false)
995
+ profileExtractEnabled: z.boolean().default(false),
996
+
997
+ // #539 逐会话无痕:总开关默认关,保持旧用户行为;子代理选择器默认开,但总开关
998
+ // 未开时不生效。手动名单是精确的 session id 匹配。
999
+ sessionGateEnabled: z.boolean().default(false),
1000
+ sessionGateSubagents: z.boolean().default(true),
1001
+ sessionGateSessions: z.array(z.string()).default([])
970
1002
  });
971
1003
 
972
1004
  // Fields forced to false by the light-mode preset. Everything not listed here
package/lib/continuity.js CHANGED
@@ -173,10 +173,10 @@ function noticeAlreadyInSurface(session, text) {
173
173
  * 挂两条监听(#249 §7 的 `agent/pre-step` 触发位 + 压缩事件面),返回 { dispose }。
174
174
  *
175
175
  * `store` 直接注入:本模块只写提案表、不碰检索/嵌入,没有用到 service 的任何能力。
176
- * 调用方负责父/子闸门(`autoInject` + `continuityRescueEnabled`);本函数不做开关判定,
177
- * 保持"挂上就是开"的单一口径。
176
+ * 调用方负责父/子开关(`autoInject` + `continuityRescueEnabled`),本函数另检查逐会话
177
+ * 无痕闸门;保持"挂上就是开、命中会话就不处理"的单一口径。
178
178
  */
179
- export function createContinuityRescue(ctx, store) {
179
+ export function createContinuityRescue(ctx, store, sessionGate = null) {
180
180
  // 待处理的边缘:sessionId → 压缩事件序号。**真落地**的压缩事件到达时置位,下一次 pre-step 消费后
181
181
  // 删除(同一个边缘绝不注入第二次)。只留序号、不留 Session:插件在宿主进程里只 apply
182
182
  // 一次、服务所有会话,握着 Session 就是握着它的全量日志不放——压缩完就收工、不再有
@@ -184,6 +184,10 @@ export function createContinuityRescue(ctx, store) {
184
184
  const edge = new Map();
185
185
 
186
186
  const unsubscribe = ctx.on("session/event", (session, event) => {
187
+ if (sessionGate?.isOff(session)) {
188
+ if (session?.id) edge.delete(session.id);
189
+ return;
190
+ }
187
191
  if (!isCompactionEdge(event)) return;
188
192
  if (!session?.id) return;
189
193
  if (!edge.has(session.id) && edge.size >= MAX_TRACKED_EDGES) {
@@ -200,6 +204,10 @@ export function createContinuityRescue(ctx, store) {
200
204
  // reject 是"这一步不该发生",往里面塞消息没有意义;未知形状原样放行。
201
205
  if (decision?.kind !== "enter") return decision;
202
206
  const session = payload?.agent?.session;
207
+ if (sessionGate?.isOff(session)) {
208
+ if (session?.id) edge.delete(session.id);
209
+ return decision;
210
+ }
203
211
  const sessionId = session?.id;
204
212
  if (!sessionId) return decision;
205
213
  if (!edge.has(sessionId)) return decision;
@@ -1,8 +1,10 @@
1
1
  import { STR, langOf } from "../lang.js";
2
+ import { contentHashOf } from "../content-hash.js";
2
3
 
3
4
  // Issue #126:sleepActionSet="full" 时新增两个动作——supersede(演进:新版取代
4
- // 旧版)与 differentiate(互补:两条都留、各追加差异注记)。两者的校验与执行都
5
- // 复用既有 merge/conflict 的机制(winner/loser、casGuard、transaction、receipt)。
5
+ // 旧版)与 differentiate(互补:两条都留,各自保留原本的正文)。两者的校验与执行
6
+ // 都复用既有 merge/conflict 的机制(winner/loser、casGuard、transaction、receipt)。
7
+ // Issue #533 起 differentiate 不再改写正文,只落裁决账本——见 applyDifferentiate。
6
8
  // 调用方(sleep)按档位派生「本档允许的动作集合」时需要读它,故导出。
7
9
  export const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create", "supersede", "differentiate"]);
8
10
 
@@ -179,9 +181,10 @@ export function validateDecisions(decisions, snapshot, options = {}) {
179
181
  local.push(`${at}: ${d.action} needs distinct winner and loser`);
180
182
  }
181
183
  } else if (d.action === "differentiate") {
182
- // Issue #126:互补型——两条都留下、各追加差异注记。ids 必须 ≥2(随后走下方
183
- // 通用 id 校验:unknown / archived / summary / 重复 claim),distinctions 必须
184
- // 非空,否则会写出空注记(写了等于没写,还会污染正文)。
184
+ // Issue #126:互补型——两条都留下。ids 必须 ≥2(随后走下方通用 id 校验:
185
+ // unknown / archived / summary / 重复 claim)。distinctions 仍然必填:#533 之后
186
+ // 它不再写进正文,但仍是「模型确实做过分类」的唯一凭据,落进裁决账本供审计
187
+ // (空数组意味着模型只是偷懒输出了一条动作名,不该被记成已裁决)。
185
188
  if (!Array.isArray(d.ids) || d.ids.length < 2) {
186
189
  local.push(`${at}: differentiate needs at least two ids`);
187
190
  } else if (!Array.isArray(d.distinctions) || !d.distinctions.some((s) => typeof s === "string" && s.trim())) {
@@ -318,7 +321,22 @@ export function validateDecisions(decisions, snapshot, options = {}) {
318
321
  // 单条非法 → 跳过该决策,不 claim id(其目标记忆留给其它合法决策/隐式
319
322
  // keep),并记录到 skipped 供调用方日志/审计。信息性跳过绝不写入全局
320
323
  // errors,否则会误触发下方的整单拒绝。
321
- skipped.push({ index, action: d?.action, ids, error: local.join("; ") });
324
+ //
325
+ // #534:除四键骨架外补三个「模型到底给没给」的布尔,把 skipped 从
326
+ // 「error 字符串」变成可计数的形状。作者实测 19 轮 17 轮整轮无效,但
327
+ // skipped 里只有一句错误串,事后无法判断模型是漏字段(merge 缺
328
+ // title/content)、还是字段给了但值不合规(has_* 为 true 仍报错)。
329
+ // 只加布尔不加原文:原文进 llm_audit_logs.metadata 与 dream_runs.raw_output,
330
+ // 这里保持每条记录足够小(skipped 会按轮累积进一行 JSON)。
331
+ skipped.push({
332
+ index,
333
+ action: d?.action,
334
+ ids,
335
+ error: local.join("; "),
336
+ has_keepSource: d?.keepSource !== undefined && d?.keepSource !== null,
337
+ has_title: typeof d?.title === "string" && d.title.trim().length > 0,
338
+ has_content: typeof d?.content === "string" && d.content.trim().length > 0
339
+ });
322
340
  } else {
323
341
  errors.push(...local);
324
342
  }
@@ -507,7 +525,7 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
507
525
  committed.push({ action: "keep", ids: d.ids });
508
526
  continue;
509
527
  }
510
- const outcome = applyOne(d, service, snapshot, config);
528
+ const outcome = applyOne(d, service, snapshot, config, logger);
511
529
  if (outcome === "skipped") continue;
512
530
  applied += outcome.applied;
513
531
  committed.push(outcome.committed);
@@ -524,14 +542,14 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
524
542
  return { applied, conflicts, failures, committed };
525
543
  }
526
544
 
527
- function applyOne(d, service, snapshot, config = {}) {
545
+ function applyOne(d, service, snapshot, config = {}, logger = null) {
528
546
  switch (d.action) {
529
547
  case "archive": return applyArchive(d, service, snapshot);
530
548
  case "merge": return applyMerge(d, service, snapshot, config);
531
549
  case "conflict": return applyConflict(d, service, snapshot, config);
532
550
  case "supersede": return applySupersede(d, service, snapshot, config);
533
551
  case "differentiate": return applyDifferentiate(d, service, snapshot, config);
534
- case "create": return applyCreate(d, service, config);
552
+ case "create": return applyCreate(d, service, config, logger);
535
553
  default: return applyUpdate(d, service, snapshot, config);
536
554
  }
537
555
  }
@@ -563,7 +581,7 @@ function pickBestKeeper(ids, preferred, service) {
563
581
  * Evidence ids ride in the content so a pattern stays traceable to its source
564
582
  * memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
565
583
  */
566
- function applyCreate(d, service, config = {}) {
584
+ function applyCreate(d, service, config = {}, logger = null) {
567
585
  const language = langOf(config);
568
586
  const title = String(d.title ?? "").trim();
569
587
  const content = String(d.content ?? "").trim();
@@ -588,6 +606,22 @@ function applyCreate(d, service, config = {}) {
588
606
  });
589
607
  const memory = created?.memory;
590
608
  if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
609
+ // #512:这条记忆出生自哪一轮。只记 create——merge/conflict/update/supersede 对
610
+ // 既有行的改动已由 receipt_chain 按 (run_id, record_id) 记账,再造一份就是第二套账。
611
+ // 记账失败不许拖垮这次铸造:归因是审计,记忆已经落盘了。
612
+ try {
613
+ if (typeof service?.saveMemoryMint === "function" && config?.runId) {
614
+ service.saveMemoryMint({
615
+ memoryId: memory.id,
616
+ runId: config.runId,
617
+ runType: config.mintSource === "sleep" ? "sleep" : "dream",
618
+ triggerSource: config.mintSource === "sleep" ? "sleep" : "autoDream",
619
+ createdAt: new Date().toISOString()
620
+ });
621
+ }
622
+ } catch (mintError) {
623
+ logger?.warn?.(`dsh-mneme dream: failed to record memory mint: ${String(mintError?.message ?? mintError)}`);
624
+ }
591
625
  return { applied: 1, committed: { action: "create", id: memory.id, type } };
592
626
  }
593
627
 
@@ -716,35 +750,62 @@ function applySupersede(d, service, snapshot, config = {}) {
716
750
 
717
751
  /**
718
752
  * differentiate(Issue #126,sleepActionSet="full"):互补型重复——两条各自成立、
719
- * 覆盖不同侧面,不该判输赢。双方都保留(不归档),各追加同一段差异注记;注记进
720
- * 正文 → 进注入与 embedding → 下一轮不会再被判成重复(这是本动作存在的意义)。
753
+ * 覆盖不同侧面,不该判输赢。双方都保留(不归档),**正文一个字节都不动**。
754
+ *
755
+ * 为什么不动正文(Issue #533):#126 的原设计是「双方各追加同一段差异注记,注记
756
+ * 进正文 → 进注入与 embedding → 下一轮就不会再被判成重复」。离线复现证伪了这个
757
+ * 假设——两侧共享同一段后缀会让两条向量**同向靠拢**,余弦一轮比一轮高(0.876 →
758
+ * 0.960 只用了 5 轮,把注记截到 0 字照样往上爬,因为推高相似度的是共享前缀本身而
759
+ * 不是注记长短),于是同一对下一轮又被判成重复、又追加一段,实测单条堆到 19 段。
760
+ * 现在的裁决落在 differentiated_pairs 账本(src/store.js)里:候选构造阶段读它,
761
+ * 已裁决且两侧正文都没改过就不再送模型,正文保持干净。
721
762
  *
722
- * 注记由插件按 distinctions 渲染,**不让模型整段重写 content**(重写有篡改/丢失
723
- * 风险)。幂等:注记已在正文里就不重复追加,否则同一决策重放会不断堆叠。
763
+ * 幂等三层,从便宜到贵:
764
+ * 1. 一侧已归档 → skipped(缺一条就没什么可区分的);
765
+ * 2. 存量兼容:两侧正文都还带着旧版注记块 → skipped(升级不清账);
766
+ * 3. 账本命中且两侧 content hash 与记账时一致 → skipped(真正的幂等重放)。
767
+ * 任一侧后来被改写 → hash 对不上 → 重新裁决,宁可再判一次也不静音真的演化。
724
768
  */
725
769
  function applyDifferentiate(d, service, snapshot, config = {}) {
726
- const language = langOf(config);
727
770
  const ids = Array.isArray(d.ids) ? d.ids : [];
728
- const notes = (Array.isArray(d.distinctions) ? d.distinctions : [])
729
- .filter((s) => typeof s === "string" && s.trim())
730
- .map((s) => s.trim());
731
- const marker = STR.differentiatedMarker[language](notes);
732
771
  const alive = ids.filter((id) => {
733
772
  const m = service.getById(id);
734
773
  return m && !m.archived;
735
774
  });
736
775
  if (alive.length < 2) return "skipped"; // 缺一条即已处理/不可用(幂等)
737
- if (alive.every((id) => String(service.getById(id)?.content ?? "").includes(marker))) {
738
- return "skipped"; // 同一条决策重放:注记已存在
776
+ const hashes = new Map(alive.map((id) => [id, contentHashOf(service.getById(id))]));
777
+ const [a, b] = alive.slice(0, 2);
778
+ const record = typeof service.findDifferentiatedPair === "function"
779
+ ? service.findDifferentiatedPair(a, b)
780
+ : undefined;
781
+ if (record) {
782
+ // 记账时 a/b 是按 id 排序存的,hash 也跟着换位,这里同样按排序取值比对。
783
+ const [ha, hb] = a <= b ? [hashes.get(a), hashes.get(b)] : [hashes.get(b), hashes.get(a)];
784
+ if (record.hash_a === ha && record.hash_b === hb) return "skipped";
785
+ } else {
786
+ // 存量库兼容:旧版本把注记写进了正文,那些行没有账本行。两侧都还带着注记块
787
+ // 才认定「已裁决过」——只有一侧有,说明另一侧后来被改写,应当重判。
788
+ const noted = alive.filter((id) => STR.differentiatedNoteRe.test(String(service.getById(id)?.content ?? "")));
789
+ if (noted.length >= 2) return "skipped";
739
790
  }
791
+ // 账本缺席(人工确认路径的裸 service)就什么都不写:正文一字不动本来就是本动作
792
+ // 的全部副作用,没有账本就只剩「这条决策无处落地」,不是错误,也不是 applied。
793
+ if (typeof service.saveDifferentiatedPair !== "function") return "skipped";
740
794
  service.transaction(() => {
741
795
  casGuard(service, snapshot, ids);
742
- for (const id of ids) {
743
- const m = service.getById(id);
744
- if (!m || m.archived) continue;
745
- if (String(m.content).includes(marker)) continue; // 单条已注记过
746
- service.update(id, { content: `${m.content}${marker}` });
747
- }
796
+ const [ia, ib] = alive.slice(0, 2);
797
+ service.saveDifferentiatedPair({
798
+ run_id: config.runId,
799
+ memory_a: ia,
800
+ memory_b: ib,
801
+ // hash 取**执行时**的正文(不是送审时),与账本里下一次比对的口径同源。
802
+ hash_a: hashes.get(ia),
803
+ hash_b: hashes.get(ib),
804
+ // 模型给的差异点也落账:正文不再写注记,这些就是「当初为什么判互补」的唯一凭据。
805
+ distinctions: (Array.isArray(d.distinctions) ? d.distinctions : [])
806
+ .filter((x) => typeof x === "string" && x.trim())
807
+ .map((x) => x.trim())
808
+ });
748
809
  });
749
810
  return {
750
811
  applied: 1,