@a9i5k4/dsh-auto-memory 3.0.1 → 3.1.1

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 (134) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +13 -8
  3. package/docs/HANDBOOK.md +92 -52
  4. package/docs/USER-GUIDE.en.md +9 -9
  5. package/docs/USER-GUIDE.zh-CN.md +9 -9
  6. package/docs/screenshots/promo/promo-0-banner-v4.png +0 -0
  7. package/docs/screenshots/promo/promo-1b-auto-recall.png +0 -0
  8. package/lib/activation-host.js +6 -1
  9. package/lib/client.js +1376 -255
  10. package/lib/config-io.js +59 -6
  11. package/lib/context-host.js +17 -2
  12. package/lib/episodic-store.js +90 -16
  13. package/lib/evidence-store.js +27 -1
  14. package/lib/fact-store.js +463 -41
  15. package/lib/hub-io.js +217 -0
  16. package/lib/index.js +904 -87
  17. package/lib/intent-clean-safe.js +1 -1
  18. package/lib/jsonl-tail-cursor.js +75 -0
  19. package/lib/m7-index-sync-host.js +4 -6
  20. package/lib/memory-hub.js +62 -7
  21. package/lib/migrate-pack.js +351 -0
  22. package/lib/note-status.js +9 -1
  23. package/lib/procedure-store.js +252 -31
  24. package/lib/procedure-switch.js +38 -0
  25. package/lib/python-setup.js +109 -27
  26. package/lib/python-sidecar-client.js +285 -8
  27. package/lib/recall-stats.js +242 -0
  28. package/lib/rules-layer.js +106 -15
  29. package/lib/semantic-js.js +25 -7
  30. package/lib/shadow-host.js +41 -2
  31. package/package.json +6 -2
  32. package/docs/internal/ACCEPT-35-LIVE.md +0 -143
  33. package/docs/internal/ACCEPTANCE-20260914.md +0 -90
  34. package/docs/internal/ARCH-REVIEW-BRIEF.md +0 -411
  35. package/docs/internal/ARCH-REVIEW-REQUEST.md +0 -201
  36. package/docs/internal/ARCH-REVIEW-ROUND2.md +0 -169
  37. package/docs/internal/ARCH-REVIEW-ROUND3.md +0 -206
  38. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +0 -397
  39. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +0 -351
  40. package/docs/internal/ART-DIRECTION-WIREFRAME.md +0 -191
  41. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +0 -181
  42. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +0 -314
  43. package/docs/internal/BATTLE-PLAN-20260917.md +0 -871
  44. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +0 -192
  45. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +0 -72
  46. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +0 -131
  47. package/docs/internal/CUA-VISION-FIX-NOTES.md +0 -78
  48. package/docs/internal/DECISIONS-20260914-SESSION.md +0 -269
  49. package/docs/internal/DESIGN-OVERHAUL-PRE-RESEARCH.md +0 -292
  50. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +0 -219
  51. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +0 -132
  52. package/docs/internal/FEATURE-INVENTORY.md +0 -531
  53. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +0 -13
  54. package/docs/internal/G-SERIES-EXECUTION-20260917.md +0 -248
  55. package/docs/internal/G3-DESIGN-20260918.md +0 -82
  56. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +0 -92
  57. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +0 -74
  58. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +0 -352
  59. package/docs/internal/GPT-REVIEW-PROMPT.md +0 -216
  60. package/docs/internal/GROUP-DIGEST-SETUP.md +0 -62
  61. package/docs/internal/GROUP-LISTENER-SETUP.md +0 -49
  62. package/docs/internal/GROUP-WEBHOOK-SETUP.md +0 -93
  63. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +0 -309
  64. package/docs/internal/HANDOFF-TO-ZCODE.md +0 -168
  65. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +0 -120
  66. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +0 -74
  67. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +0 -175
  68. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +0 -389
  69. package/docs/internal/ISSUE10-PLAN-20260919.md +0 -254
  70. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +0 -468
  71. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +0 -150
  72. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +0 -114
  73. package/docs/internal/KICKOFF-P0.md +0 -254
  74. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +0 -79
  75. package/docs/internal/MASTER-PLAN-3.0.md +0 -411
  76. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +0 -309
  77. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +0 -85
  78. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +0 -222
  79. package/docs/internal/NEXT-VERSION-TODO.md +0 -95
  80. package/docs/internal/OFFICIAL-DISCUSSION-DRAFT.md +0 -80
  81. package/docs/internal/PENDING-FIXES-20260916.md +0 -289
  82. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +0 -705
  83. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +0 -649
  84. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +0 -225
  85. package/docs/internal/PROGRESS-20260917.md +0 -93
  86. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +0 -128
  87. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +0 -163
  88. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +0 -127
  89. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +0 -140
  90. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +0 -138
  91. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +0 -218
  92. package/docs/internal/RAG-KARPATHY-PROGRAM.md +0 -229
  93. package/docs/internal/RELEASE-PROCESS.md +0 -99
  94. package/docs/internal/REPORT-P0-NIGHTLY.md +0 -212
  95. package/docs/internal/REPORT-P5-ACCEPTANCE.md +0 -31
  96. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +0 -153
  97. package/docs/internal/RESUME-20260918.md +0 -171
  98. package/docs/internal/RESUME-20260919.md +0 -104
  99. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +0 -81
  100. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +0 -198
  101. package/docs/internal/ROADMAP-20260917-WEEK.md +0 -439
  102. package/docs/internal/ROADMAP.md +0 -106
  103. package/docs/internal/RUN-P0-NIGHTLY.md +0 -227
  104. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +0 -185
  105. package/docs/internal/S10-GAP-INVENTORY-20260917.md +0 -239
  106. package/docs/internal/S10-GAPS-PLAIN-20260917.md +0 -125
  107. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +0 -360
  108. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +0 -90
  109. package/docs/internal/SUBAGENT-REPORT-ROUTING-PRE-RESEARCH.md +0 -261
  110. package/docs/internal/T6-EXECUTION-20260920.md +0 -130
  111. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +0 -146
  112. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +0 -89
  113. package/docs/internal/THESIS-OUTLINE-20260918.md +0 -147
  114. package/docs/internal/THREE-LAYER-CONTRACT.md +0 -219
  115. package/docs/internal/TODO-BACKLOG.md +0 -263
  116. package/docs/internal/TODO-GRAPH.html +0 -715
  117. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +0 -493
  118. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +0 -710
  119. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +0 -710
  120. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +0 -703
  121. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +0 -710
  122. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +0 -715
  123. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +0 -297
  124. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +0 -104
  125. package/docs/internal/WB-FORMAT-CONVENTION.md +0 -112
  126. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +0 -71
  127. package/docs/internal/WB-GRAPH-INTEGRATION-PLAN.md +0 -386
  128. package/docs/internal/WB-GRAPH-RESEARCH-BRIEF.md +0 -118
  129. package/docs/internal/WB-GRAPH-RESEARCH-EXTERNAL.md +0 -228
  130. package/docs/internal/WB-GRAPH-RESEARCH-LOCAL.md +0 -190
  131. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +0 -56
  132. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +0 -787
  133. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +0 -112
  134. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +0 -230
package/lib/index.js CHANGED
@@ -49,11 +49,17 @@ import { createFactStorePre } from './fact-store.js'
49
49
  import { createProcedureStorePre } from './procedure-store.js'
50
50
  import { exportSkillForPre, resolveSkillsRootPre } from './skill-export-host.js'
51
51
  import { createMemoryHubPre } from './memory-hub.js'
52
+ // ★#110(2026-09-22):hub 持久化 IO 适配器与其健康度投影(原本内联在本文件里、失败被静默吞掉)。
53
+ import { createHubIoPre, createHubIoHealthPre, hubIoHealthSnapshotPre } from './hub-io.js'
54
+ // ★issue#103:环形 JSONL 增量游标(内容指纹)。见 lib/jsonl-tail-cursor.js 文件头。
55
+ import { createJsonlTailCursorPre } from './jsonl-tail-cursor.js'
56
+ import { createRecallStatsPre } from './recall-stats.js'
52
57
  import { pickConsolidationTextPre } from './intent-clean.js'
53
58
  // T1-3(2026-09-19):hubFlushTick 写入前的**内容卫生门**复用 ⑨ 同源清洗器。
54
59
  // 必须独占一行(本仓契约守卫以字面量断言既有 import 行,禁止为排版合并)。
55
60
  import { stripRuntimeIntentPre, looksRuntimeResiduePre } from './intent-clean-safe.js'
56
61
  import { createStorageManagerPre } from './storage-manage.js'
62
+ import { workspaceSlugPre as migrateSlugPre, buildPackPre, validatePackPre, planImportPre, mergeSummaryRecordPre, MIGRATE_PACK_FORMAT_PRE } from './migrate-pack.js'
57
63
  import { rankWorkspacesByMemoryRecencyPre } from './ws-overview-rank.js'
58
64
  import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames, decodeZstdFramesHead } from './subagent-gc.js'
59
65
  import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre, findSessionModelPre, scanPressureSignalsPre, reusableWindowCachePre, shouldArmAutoContinuePre } from './water-window.js'
@@ -79,6 +85,56 @@ import { writeTextAtomicPreSync, writeTextAtomicPre, readJsonQuarantinePreSync }
79
85
  import { WB_SIDECAR_VERSION, buildSidecarEntryPre, rebuildSidecarIndexPre, expandByTagPre, traceByIdPre, applyAnchorsPre, collectAnchorIdsPre, wbRefPre, normalizeRelPathPre, normalizeTitlePre, buildKanbanPre, buildSectionCardsPre, splitSectionsPre, buildKanbanMatrixPre, ledgerDateOfPre, WB_KANBAN_LANES_V1 } from './wb-sidecar.js'
80
86
  // P6A 规则层(规则类与参考类分开措辞;规则真源 = 既有用户级记忆,不新增 RULES.md)
81
87
  import { extractRulesLayerPre, renderRulesSectionPre, RULES_SECTION_TITLE_V1, RULES_SECTION_GUIDE_V1, REFERENCE_SECTION_GUIDE_V1, LOG_KINDS_V1 } from './rules-layer.js'
88
+
89
+ /**
90
+ * ★P9(2026-09-22):用户级硬性约束的**条目级增删改** —— 前端路由与模型工具共用的**唯一写盘口**。
91
+ *
92
+ * 为什么必须存在:[规则 — 用户级硬性约束 · 必须遵守] 段**每轮无条件注入且不参与裁剪**
93
+ * (memory-envelope.js 的 rules 分项不受限),而这一层在 P9 之前**认不出状态标记**。
94
+ * ⇒ 过时条目只能**真删**;而该能力此前只接在前端路由(R7),**模型够不着** ⇒ 模型只能整篇重写
95
+ * 用户级记忆(高风险、易丢条),或干脆不动 ⇒ 错误规则永久留在每轮 prompt 里。
96
+ *
97
+ * 纪律:①真源仍是 userFile,本函数不新增事实来源;②写入复用既有 writeFull 事务(备份 + 校验),
98
+ * 绝不绕过;③删/改支持 requireExpect **内容锚定**(模型侧强制、GUI 侧沿用 R7 的前端二次确认)。
99
+ *
100
+ * @param {object} engine 宿主引擎
101
+ * @param {string} op list | add | update | remove
102
+ * @param {object} payload { index?, expect?, text?, dateSection? }
103
+ * @param {object} [opts] { requireExpect?:boolean }
104
+ * @returns {Promise<{ok:boolean, error?:string, path?:string, items?:Array, preview?:string}>}
105
+ */
106
+ async function applyRuleEditPre(engine, op, payload = {}, opts = {}) {
107
+ const o = String(op || '')
108
+ if (!['list', 'add', 'update', 'remove'].includes(o)) return { ok: false, error: 'invalid-op' }
109
+ try {
110
+ const p = await engine.resolvePaths(undefined)
111
+ const before = (await engine.readTextSafe(p.userFile)) || ''
112
+ const view = (text) => {
113
+ const items = listRuleItemsPre(text)
114
+ return { ok: true, path: p.userFile, items, preview: items.map((x) => '- ' + x.text).join('\n') }
115
+ }
116
+ if (o === 'list') return view(before)
117
+ // ★内容锚定:删/改前先核对「你认为这条现在是什么」,不符即拒 —— 防索引漂移删错行
118
+ if (o === 'update' || o === 'remove') {
119
+ const items0 = listRuleItemsPre(before)
120
+ const idx = Number(payload.index)
121
+ if (!Number.isInteger(idx) || idx < 0 || idx >= items0.length) return { ok: false, error: '编辑被拒: index-out-of-range' }
122
+ if (opts && opts.requireExpect) {
123
+ const expect = String(payload.expect == null ? '' : payload.expect).trim()
124
+ if (!expect) return { ok: false, error: '编辑被拒: 缺少 expect(先 op=list 取该条当前文本再回填)' }
125
+ if (items0[idx].text !== expect) return { ok: false, error: '编辑被拒: expect 与当前第 ' + idx + ' 条不符(内容锚定失败,请重新 list)' }
126
+ }
127
+ }
128
+ let r
129
+ if (o === 'add') r = appendRuleItemPre(before, payload.text, { dateSection: payload.dateSection })
130
+ else if (o === 'update') r = updateRuleItemPre(before, Number(payload.index), payload.text)
131
+ else r = removeRuleItemPre(before, Number(payload.index))
132
+ if (!r.ok) return { ok: false, error: '编辑被拒: ' + r.error }
133
+ const written = await engine.writeFull(p.userFile, r.text)
134
+ const after = (await engine.readTextSafe(p.userFile)) || written || r.text
135
+ return view(after)
136
+ } catch (e) { return { ok: false, error: String((e && e.message) || e) } }
137
+ }
82
138
  import * as nodeZlib from 'node:zlib'
83
139
  import { AsyncLocalStorage } from 'node:async_hooks'
84
140
  import { createHash } from 'node:crypto'
@@ -155,7 +211,7 @@ const SECTION_ORDER = 10000
155
211
  const NOTICES_URL = 'https://raw.githubusercontent.com/Aik358/dsh-auto-memory/main/notices.json'
156
212
 
157
213
  /** Model-facing announcement (tools + engine). */
158
- export const GUIDANCE = '本机已安装 dsh-auto-memory 插件(集中式自动记忆 + 外部记忆继承):三层本地记忆(用户级 ~/.dsh/memory/MEMORY.md、项目笔记与每日日志 .dsh-memory/)+ 会话自动注入 + 每日反思 + 其他 AI 工具记忆接入。能力:memory_log 追加今日日志(append-only,完成实质性工作后必须调用);memory_note 更新项目笔记;memory_user 更新用户级规则;memory_recall 检索本地记忆 + 外部记忆(WorkBuddy/CodeBuddy/Claude Code/Codex/ZCode/Kimi Code/TRAE 记忆与会话)+ 历史 DSH 会话;memory_external 查看/接入外部记忆源;memory_maintain 归档 30 天前日志;memory_reflect 保存每日反思;memory_status 查看状态;memory_consolidate 让 AI 读日志发散提炼长期要点固化进笔记。自动沉淀:每轮对话结束插件自动评估本轮内容并写今日日志/升格长期记忆(寒暄轮跳过,间隔与每日额度可在设置页「自动化」分组调整),无需你手动调 memory_log。主动性纪律:任务开始遇到不熟悉的代码/领域/历史决策时,先 memory_recall 检索本机全部 AI 工具历史,不凭空猜测;新工作区主动探索历史。限制:记忆文件为明文 Markdown;不存密钥除非用户明确要求;外部会话检索为关键词级(非语义);GUI 侧边栏「记忆」面板(含「接续」页签,可查看来源内容、从记忆 prompt 移除已导入段落)与设置页可查看/配置/接入。用户提到「记忆 / 昨天做了什么 / 之前怎么做的 / 每日反思 / 接续 / 其他 AI 的记忆」时即指本插件,请据此协作。白板纪律(三层分工,2026-09-17 定稿):①**白板 PLAN.md = 项目稳定的"是什么/怎么跑"事实**,由 memory_note(kind=plan) 整体重写(这是 12 个工具里**唯一**能覆盖已有结论的能力,旧版自动归档);②**交接账本 handoff-*.md = 动态状态的唯一权威**(任务状态/目标/已试方案与失败原因/进度与下一步),由 memory_note(kind=handoff) 新开一篇,**append-only、不追改旧账本**;③**项目笔记 MEMORY.md = 可复用的结论/决策**。维护时机(**条件触发,不是每轮**):完成阶段性工作、或发现白板/账本所述与现状不符、或方向有实质变化时——**写记忆的同时顺手维护白板**,不要新开一轮专程去做。**禁止**:未得用户同意不得删减白板既有内容(只报告过时,不自动删);白板/产物功能关闭时跳过白板维护、不要因此报错。'
214
+ export const GUIDANCE = '本机已安装 dsh-auto-memory 插件(集中式自动记忆 + 外部记忆继承):三层本地记忆(用户级 ~/.dsh/memory/MEMORY.md、项目笔记与每日日志 .dsh-memory/)+ 会话自动注入 + 每日反思 + 其他 AI 工具记忆接入。能力:memory_log 追加今日日志(append-only,完成实质性工作后必须调用);memory_note 更新项目笔记;memory_user 更新用户级规则(整篇/追加);memory_rules 对用户级硬约束做**条目级**增删改(op=list/add/update/remove,删改需带 expect 内容锚定;这是清理「每轮必注入的 [规则] 段」过时条目的正确手段);memory_recall 检索本地记忆 + 外部记忆(WorkBuddy/CodeBuddy/Claude Code/Codex/ZCode/Kimi Code/TRAE 记忆与会话)+ 历史 DSH 会话;memory_external 查看/接入外部记忆源;memory_maintain 归档 30 天前日志;memory_reflect 保存每日反思;memory_status 查看状态;memory_consolidate 让 AI 读日志发散提炼长期要点固化进笔记。自动沉淀:每轮对话结束插件自动评估本轮内容并写今日日志/升格长期记忆(寒暄轮跳过,间隔与每日额度可在设置页「自动化」分组调整),无需你手动调 memory_log。主动性纪律:任务开始遇到不熟悉的代码/领域/历史决策时,先 memory_recall 检索本机全部 AI 工具历史,不凭空猜测;新工作区主动探索历史。限制:记忆文件为明文 Markdown;不存密钥除非用户明确要求;外部会话检索为关键词级(非语义);GUI 侧边栏「记忆」面板(含「接续」页签,可查看来源内容、从记忆 prompt 移除已导入段落)与设置页可查看/配置/接入。用户提到「记忆 / 昨天做了什么 / 之前怎么做的 / 每日反思 / 接续 / 其他 AI 的记忆」时即指本插件,请据此协作。白板纪律(三层分工,2026-09-17 定稿):①**白板 PLAN.md = 项目稳定的"是什么/怎么跑"事实**,由 memory_note(kind=plan) 整体重写(这是 12 个工具里**唯一**能覆盖已有结论的能力,旧版自动归档);②**交接账本 handoff-*.md = 动态状态的唯一权威**(任务状态/目标/已试方案与失败原因/进度与下一步),由 memory_note(kind=handoff) 新开一篇,**append-only、不追改旧账本**;③**项目笔记 MEMORY.md = 可复用的结论/决策**。维护时机(**条件触发,不是每轮**):**① 白板还是「自动建立 · 待模型重写」的骨架时(说明本工作区还没有真白板)——在理解项目全貌后先把它重写成真内容**;② 完成阶段性工作、或发现白板/账本所述与现状不符、或方向有实质变化时——**写记忆的同时顺手维护白板**,不要新开一轮专程去做。(骨架由宿主自动落,但那只是占位;**不要停在骨架**。)**禁止**:未得用户同意不得删减白板既有内容(只报告过时,不自动删);白板/产物功能关闭时跳过白板维护、不要因此报错。'
159
215
 
160
216
  /** Route family. */
161
217
  export const API = {
@@ -170,6 +226,7 @@ export const API = {
170
226
  browseDir: '/api/dsh-auto-memory/browse-dir',
171
227
  pickDir: '/api/dsh-auto-memory/pick-dir',
172
228
  updateCheck: '/api/dsh-auto-memory/update-check',
229
+ recallStats: '/api/dsh-auto-memory/recall-stats',
173
230
  update: '/api/dsh-auto-memory/update',
174
231
  config: '/api/dsh-auto-memory/config',
175
232
  reflect: '/api/dsh-auto-memory/reflect',
@@ -209,6 +266,9 @@ export const API = {
209
266
  'memory-hub': '/api/dsh-auto-memory/memory-hub',
210
267
  'storage-manage': '/api/dsh-auto-memory/storage-manage',
211
268
  models: '/api/dsh-auto-memory/models',
269
+ 'migrate-export': '/api/dsh-auto-memory/migrate-export',
270
+ 'migrate-inspect': '/api/dsh-auto-memory/migrate-inspect',
271
+ 'migrate-import': '/api/dsh-auto-memory/migrate-import',
212
272
  }
213
273
 
214
274
  /**
@@ -296,6 +356,12 @@ const DEFAULT_CONFIG = {
296
356
  /** Tier-0 目录最多占「注入预算」的比例(默认 **0.25**):目录是索引层,不得把证据层(日志/笔记)挤空。
297
357
  * 与 `tier0MaxTokens` 取小生效;另在注入侧再封顶一次(见 renderMemoryDynamic 的 catalogCost)。 */
298
358
  tier0BudgetShare: 0.25,
359
+ /** ★P10(2026-09-22 用户拍板):**注入分区开关**(对象:kind → boolean)。
360
+ * **缺省 = 全开**(只有显式 false 才关)⇒ 出厂行为逐字节不变、可一键回退。
361
+ * 可关清单 = PROMPT_SECTION_KEYS_V1;关掉某段即该段不再进入本轮注入。
362
+ * 设计取向(用户原话:不是为了优化,是让看不惯的用户能自己关):**这是用户主权开关,
363
+ * 不是性能门** —— 因此不做任何"自动判断该不该关"的启发式。 */
364
+ promptSectionToggles: {},
299
365
  /** 群反馈第 4 条 / P0-④d:**注入来源排除**(坏记忆不再反复灌入)。
300
366
  * 字符串数组,四种写法(写死口径,可测;只做精确/前缀匹配,不做模糊猜测):
301
367
  * · 记忆 id:`mem_<32hex>` → 排除该条命中
@@ -542,7 +608,19 @@ const DEFAULT_CONFIG = {
542
608
  pythonBackendEnabled: false,
543
609
  /** 推理 trace 观察器(2026-08-26 裁定默认开:监听目标是模型思维链,闭源概括式 CoT 同样纳入)。 */
544
610
  reasoningObserverEnabled: true,
545
- /** Procedure 自动晋升。 */
611
+ /** ★ B-2 修复(2026-09-22):**技能注入/唤起总闸**,语义纠正后的正确键(默认 **true**)。
612
+ * 取证:旧键 `procedurePromotionEnabled` 名字叫「技能固化与晋升」,但它的两个消费者
613
+ * 都与"晋升"无关 —— context-host.js:537(技能是否**注入上下文**)、
614
+ * activation-host.js:330(**主动唤起**这条臂开不开);真正的晋升由 store 门限 +
615
+ * 路由动作决定。旧键默认 false ⇒ 新用户装完"技能永不生效"且无任何提示。
616
+ * ⚠️ 解析口径统一走 `lib/procedure-switch.js` 的 `resolveProcedureInjectEnabledPre`,
617
+ * 不得在本文件或其它文件里各自 inline 判断(否则又是一次口径漂移)。 */
618
+ procedureInjectEnabled: true,
619
+ /** 已弃用:语义错配的旧键,保留为**兼容别名**。
620
+ * 新键缺省时按本键取值(老用户显式关掉的不翻面);两键都缺省才取 true。
621
+ * 设置页已改为操作新键,本键仅作读取回退,不再新增写入入口。
622
+ * 注:**不新增 `procedureAutoPromoteEnabled`** —— 全仓无任何自动晋升代码路径
623
+ * (`applyAutomaticTransitions` 只导出零调用,见 BUGLIST P2-4),建了就是死配置。 */
546
624
  procedurePromotionEnabled: false,
547
625
  /** ★T10(2026-09-20):**机械 procedure 切片**开关。
548
626
  * 背景(用户 2026-09-20 报「白板/审批里的技能名与内容看不懂」):
@@ -563,6 +641,12 @@ const DEFAULT_CONFIG = {
563
641
  episodicMinSegments: 2,
564
642
  /** episodic: 保留的最多 episode 数(超出按时间淘汰)。默认 256。 */
565
643
  episodicRetention: 256,
644
+ /** ★#110(2026-09-22,用户拍板 1000):facts 保留上限。超出按「撤销优先 → 最旧优先」淘汰,
645
+ * 重要项(pinned / user-memory / explicit / confidence≥0.8)最后才删。<=0 表示不限。 */
646
+ factRetentionMax: 1000,
647
+ /** ★#102(2026-09-22):工作区发现上限。旧的硬编码 30 会把第 31 个起的工作区**静默丢弃**
648
+ * (跨区检索/注入索引/总览一起少项)。现按「最近会话 mtime」降序截断,默认 200。 */
649
+ workspaceDiscoverMax: 200,
566
650
  /** procedure: 晋升所需跨会话多样性(≥N 个独立 session)。默认 3(M-04 元代码)。 */
567
651
  procedureMinSessions: 3,
568
652
  /** procedure: 晋升所需成功次数(≥N)。默认 2(一次成功不足以证明可靠)。 */
@@ -606,7 +690,7 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
606
690
  snapshotReflectionTitle: '最近反思 {date}(前一天工作精华)',
607
691
  snapshotUserTitle: '用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守',
608
692
  snapshotNotesTitle: '项目长期笔记',
609
- snapshotPlanTitle: '[白板 PLAN.md — 项目全貌快照(模型维护,用户在面板可见);内容过时/理解有实质变化时,用 memory_note(kind=plan) 重写]',
693
+ snapshotPlanTitle: '[白板 PLAN.md — 项目全貌快照(模型维护,用户在面板可见);**还是上面那种自动骨架、或内容已过时、或理解有实质变化时,用 memory_note(kind=plan) 重写**(重写=整体覆盖,旧版自动归档)]',
610
694
  snapshotHandoffTitle: '[最近交接 — 四段式账本(任务状态/目标/已试方案与失败原因/进度与下一步)]',
611
695
  snapshotWaterTitle: '[上下文水位提示 — 接近窗口上限]',
612
696
  snapshotWaterBody: '上下文水位约 {pct}%(官方公式估算:4 字符≈1 token)。窗口将满,按序执行:①memory_note(kind=handoff) 写交接账本——四段各 ≤5 行:任务状态(一句话+当前产物路径)、目标(可验证的完成标准)、已试方案与失败原因(写成「方案→失败原因」,保留关键报错词)、进度与下一步(可直接执行的第一步,带文件或命令);②memory_note(kind=plan) 刷新白板全貌;③在回复正文明确建议用户开新窗口续接。可分解的子任务优先派子代理隔离上下文。不要跳过账本直接继续对话。',
@@ -629,7 +713,7 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
629
713
  + '【收尾自检】本轮有实质产出才做;纯只读/闲聊轮跳过。三个方向 + 分类别丢:\n'
630
714
  + '① 写记忆文件 memory_log —— kind 按性质选,**不要一律 fact**:rule=用户约束/约定(会被规则层当硬约束注入)、preference=偏好、fact=事实记录、todo=待办;\n'
631
715
  + '② 更新白板与账本 —— ★**硬映射(满足即必须做,别自行判断"算不算"变化)**:\n · 本轮**改过 lib/ 下任何文件**(含测试/工具) ⇒ 必写 **memory_note(kind=handoff)** 四段式账本;\n · 白板"当前进度"与本轮结束时的**事实不符**(回归数字/已完成项/下一步) ⇒ 必做 **memory_note(kind=plan)** 重写白板;\n · 仅补充一条可复用结论 ⇒ 才用 **memory_note(kind=note, action=append)**。\n ⚠️ **kind=note 不等于白板/账本**——只写 note 却宣称"已更新白板与账本"是**失职**;write 直接写 docs/ 的 md 也**不算**插件记忆。\n'
632
- + '③ 长期记忆判断(三个去处)—— 跨会话有用的 → memory_note / 跨项目规则 → memory_user / **跑通且可复用的多步流程 → memory_procedure**(技能库唯一模型入口,不写就没有;建议填 successCriteria);\n'
716
+ + '③ 长期记忆判断(三个去处)—— 跨会话有用的 → memory_note / 跨项目规则 → memory_user / **规则条目增删改(用户级硬约束,含过时条目真删)→ memory_rules** / **跑通且可复用的多步流程 → memory_procedure**(技能库唯一模型入口,不写就没有;建议填 successCriteria);\n'
633
717
  + '④ 结论失效/被取代 —— 传 `supersedes=` 旧条目 mem_id 标 **superseded(被更新结论取代)**;**当时就做错了要撤回**则传 `retract=` 标 **retracted(撤回)**并尽量附 `retractReason=` 说明错在哪(两者不同:前者有后继,后者本身就是教训);标错了用 `restore=` 撤回;\n'
634
718
  + '⑤ 看板落列 —— 白板/账本内容要进面板看板泳道,须在标题或正文写 tag:`type:goal` / `type:state` / `type:dead-end` / `type:progress`(5 条泳道含「版本归档」,靠归档动作而非 tag);不写 tag 系统只能按标题文字猜,常落空;\n'
635
719
  + '⑥ 语体 —— 客观陈述、第三人称,只留可复用的事实/决策/规则/路径(不写"我考虑/我排查/我想")。',
@@ -646,6 +730,40 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
646
730
  staticWriteDiscipline: '[记忆写入纪律 — 必须遵守]',
647
731
  })
648
732
 
733
+ /**
734
+ * ★P10:**可关的注入分区**(设置页开关据此枚举)。
735
+ *
736
+ * 判据:①该段是"内容/行为"而非"框架包裹";②关掉它不会破坏注入块结构。
737
+ * **刻意排除**(不暴露开关):`frame-head` / `frame-meta` / `frame-inscription` / `frame-tail`
738
+ * —— 它们是 `<memory_system>` 的开合与状态行,关掉会产出没有闭合标签的块。
739
+ * 值与 `renderMemoryDynamic` 里 `pushPart(bucket, kind, …)` 的 kind 逐字对应(守卫锁一致性)。
740
+ */
741
+ export const PROMPT_SECTION_KEYS_V1 = Object.freeze([
742
+ 'rules-section', // 规则段(用户级硬约束;关掉=不再注入「必须遵守」层)
743
+ 'tier0-catalog', // Tier-0 常驻目录(指引层)
744
+ 'whiteboard-plan', // 白板 PLAN 快照
745
+ 'handoff-ledger', // 交接账本
746
+ 'calendar', // 日历与日程
747
+ 'external-memory', // 其他 AI 工具记忆
748
+ 'external-sessions', // 历史会话索引
749
+ 'workspace-map', // 其他工作区记忆索引
750
+ 'welcome-title', // 新工作区欢迎语(标题)
751
+ 'welcome-body', // 新工作区欢迎语(正文)
752
+ 'plan-update-request',// 白板更新请求(must;关掉=模型不再被要求更新白板)
753
+ 'water-advisory', // 上下文水位提醒(must;关掉=不再提醒收尾写账本)
754
+ 'handoff-pointer', // 未绑定工作区时的账本指针
755
+ ])
756
+
757
+ /**
758
+ * ★P10-T2(2026-09-22):上面 13 段里**带硬性行为要求**的子集 —— 设置页二级页据此在开关旁
759
+ * 标注「关掉会发生什么」(普通段落只需通用提示)。
760
+ * 纪律:本集合**必须是 PROMPT_SECTION_KEYS_V1 的子集**(守卫断言),不得独立增删语义。
761
+ */
762
+ export const PROMPT_SECTION_MUST_V1 = Object.freeze([
763
+ 'plan-update-request', // 关掉 = 模型不再被要求更新白板
764
+ 'water-advisory', // 关掉 = 不再提醒收尾写账本
765
+ ])
766
+
649
767
  export { DEFAULT_PROMPT_LAYERS }
650
768
 
651
769
  // ---------- 小工具 ----------
@@ -903,13 +1021,44 @@ function hasParentSession(x) {
903
1021
  } catch (e) { return false }
904
1022
  }
905
1023
 
906
- /** 诊断输出:写 ~/.dsh/dsh-auto-memory-diagnose.log(append)+console.log 双保险。验证完移除。 */
1024
+ /**
1025
+ * 诊断日志路径(单一来源)。★2026-09-22(用户拍板):路径只在这里拼一次,
1026
+ * diag() 落盘、轮转、debugInfo().logs 三处共用,避免三份拼法各自漂移。
1027
+ */
1028
+ function diagLogFile() {
1029
+ return path.join(dshHome(), 'dsh-auto-memory-diagnose.log')
1030
+ }
1031
+
1032
+ /**
1033
+ * 诊断日志轮转(★2026-09-22 用户拍板「记录太多了」)。
1034
+ * 触发:单文件 > DIAG_MAX_BYTES。动作:改名为 `.1`(覆盖上一份 .1)后重新开始写。
1035
+ * 只保留当前 + 1 份历史(最多 2 个文件),**不是无限增长**——实测未轮转时已达 11 MB / 11 万行。
1036
+ * 全程 try/catch:轮转失败绝不影响落盘本身(fail-soft,与既有 _diagChain 同纪律)。
1037
+ */
1038
+ const DIAG_MAX_BYTES = 2 * 1024 * 1024
1039
+ function rotateDiagLogIfNeeded() {
1040
+ try {
1041
+ const f = diagLogFile()
1042
+ if (!existsSync(f)) return false
1043
+ if (statSync(f).size <= DIAG_MAX_BYTES) return false
1044
+ const bak = f + '.1'
1045
+ try { if (existsSync(bak)) rmSync(bak, { force: true }) } catch (e) {}
1046
+ renameSync(f, bak)
1047
+ return true
1048
+ } catch (e) { return false }
1049
+ }
1050
+
1051
+ /** 诊断输出:写 diagLogFile()(append,超限自动轮转)。★2026-09-22 起**不再** console.log。 */
907
1052
  let _diagChain = Promise.resolve()
1053
+ let _diagRotated = false
908
1054
  function diag(msg) {
909
1055
  try {
910
1056
  const line = new Date().toISOString() + ' ' + msg + '\n'
911
- _diagChain = _diagChain.then(() => appendFile(path.join(dshHome(), 'dsh-auto-memory-diagnose.log'), line, 'utf8')).catch(() => {})
912
- console.log('[dsh-auto-memory] ' + msg)
1057
+ // ★2026-09-22(用户拍板):**不再向控制台输出**。诊断行只落盘,PowerShell 保持干净。
1058
+ // 根治的是"循环刷屏":本函数是唯一每轮/每次动作都会调用的日志出口(旧实现写盘后又打一遍控制台)。
1059
+ // 要看他:面板「诊断」页签(走 debugInfo().logs),或直接打开 diagLogFile() 指的路径。
1060
+ if (!_diagRotated) { _diagRotated = rotateDiagLogIfNeeded() }
1061
+ _diagChain = _diagChain.then(() => appendFile(diagLogFile(), line, 'utf8')).catch(() => {})
913
1062
  } catch (e) {}
914
1063
  }
915
1064
 
@@ -1608,7 +1757,9 @@ class MemoryEngine {
1608
1757
 
1609
1758
  /** callId→rootCallId 关联账本(root/nested tool call;tools/result 与持久 tool/result 双通道合并,有界)。关闭模式零留存。 */
1610
1759
  linkObserverCall(runtime, callId, rootCallId, name, via) {
1611
- if (!runtime || !callId) return
1760
+ // ★#117 P3-1②(2026-09-22):与同文件 ingestEnvelope 的 disposed 闸对齐 ——
1761
+ // 旧实现漏了这一处 ⇒ dispose 之后 linkObserverCall 仍会惰性重建 callLinks(挂在已 disposed runtime 上)。
1762
+ if (!runtime || runtime.disposed || !callId) return
1612
1763
  if (this.config.associativeMemoryEnabled !== true) return // 方案 B:关闭时不保留任何关联数据
1613
1764
  if (!runtime.callLinks) runtime.callLinks = new Map()
1614
1765
  const key = String(callId)
@@ -2318,6 +2469,73 @@ class MemoryEngine {
2318
2469
  /** 白板重写:旧 PLAN.md 有实质内容且与新内容不同时,先归档到 handoff/archive/PLAN-<ts>.md 再写新快照(保留更改历史)。
2319
2470
  * P7(2026-09-09)白板老化:新内容中标题命中 /历史|踩坑|流水线要点/ 的顶层「## 」节移入 handoff/archive/PLAN-history-<ts>.md
2320
2471
  * (同秒多写 -b/-c 后缀防撞),PLAN.md 只留当前状态;内容只移动不删除;无匹配节 → 行为与旧版完全一致。 */
2472
+ /**
2473
+ * ★2026-09-22(用户拍板):**新工作区自动首建白板骨架** —— 白板首建缺口的正解。
2474
+ *
2475
+ * 缺口(证据见 docs/internal/TODO-NEXT-20260922.md §C.4):写盘链路完整(含首建、有用例且绿),
2476
+ * 但四处指令**只描述「重写」**(见本批 ③④⑤⑥)⇒ 没有 PLAN.md 的工作区里触发条件结构性恒假;
2477
+ * 且注入侧 planText||latestHandoffText 为假时整段白板提示不注入,模型连暗示都收不到。
2478
+ *
2479
+ * 修法:宿主不再等模型 —— 工作区第一次有会话活动就把骨架落下来。骨架一存在:
2480
+ * ① 注入条件转真 ⇒ 白板快照每轮注入 ⇒ 既有「维护/重写」纪律自举生效;
2481
+ * ② 面板不再显示空态 ⇒ 前端承诺与后端事实一致;
2482
+ * ③ kind=plan 的「重写」语义有了对象,不再空转。
2483
+ *
2484
+ * 纪律:
2485
+ * - **只写一次**:PLAN.md 已存在且非空即跳过(模型重写过的白板绝不被骨架覆盖)。
2486
+ * - **只走唯一写盘口**:委托 writePlanSnapshot ⇒ 归档/锚点/sidecar/事件日志/写入门全复用。
2487
+ * - **产物层闸门**:只判 handoffEnabled(白板是产物层),**不判** boardMode 与 autoContinueEnabled
2488
+ * —— 后者管的是"要不要接续材料",与"白板存不存在"无关(功能开关解耦纪律)。
2489
+ * - **fail-soft**:任何失败只回 {ok:false},绝不影响轮次收尾。
2490
+ * - 骨架必须过 P-H1 判据(≥1 个 body ≥20 非空白字符的 '## ' 顶节),否则"首建"落不下去。
2491
+ */
2492
+ async ensurePlanBoardPre(projectDir) {
2493
+ try {
2494
+ if (!projectDir) return { ok: false, error: 'no-project-dir' }
2495
+ if (this.config && this.config.handoffEnabled === false) return { ok: true, skipped: 'handoff-disabled' }
2496
+ const planPath = path.join(projectDir, 'handoff', 'PLAN.md')
2497
+ const existing = await this.readTextSafe(planPath)
2498
+ if (existing && existing.trim()) return { ok: true, skipped: 'exists' }
2499
+ const r = await this.writePlanSnapshot(projectDir, this.skeletonPlanTextPre())
2500
+ if (!r || !r.ok) return { ok: false, error: (r && r.error) || 'seed-write-failed', gate: r && r.gate }
2501
+ return { ok: true, seeded: true, path: planPath, at: Date.now() }
2502
+ } catch (e) {
2503
+ return { ok: false, error: String((e && e.message) || e) }
2504
+ }
2505
+ }
2506
+
2507
+ /** 白板骨架正文(纯字符串,零 IO)。必须过 P-H1 判据;刻意不写任何假内容。 */
2508
+ skeletonPlanTextPre() {
2509
+ const NL = String.fromCharCode(10)
2510
+ return [
2511
+ '# 项目全貌(自动建立 · 待模型重写)',
2512
+ '',
2513
+ '## 这是什么',
2514
+ 'type:state',
2515
+ '本白板由 dsh-auto-memory 在本工作区首次活动时**自动建立**,内容尚未填写。',
2516
+ '模型在理解本项目全貌后,请用 memory_note(kind=plan) 整体重写本页,写入真实的全貌。',
2517
+ '',
2518
+ '## 进度与下一步',
2519
+ 'type:progress',
2520
+ '本工作区暂无阶段性结论。完成首轮实质工作后请重写白板,写明当前状态与下一步(可直接执行的第一步)。',
2521
+ '',
2522
+ ].join(NL)
2523
+ }
2524
+
2525
+ /** 轮末兜底:为当前 agent 的工作区自动首建白板(异步、fail-soft、不阻塞收尾)。 */
2526
+ async ensurePlanBoardForAgentPre(agent) {
2527
+ try {
2528
+ if (!this.configLoaded) { try { await this.loadConfig() } catch (_) {} }
2529
+ if (this.config && this.config.handoffEnabled === false) return { ok: true, skipped: 'handoff-disabled' }
2530
+ const p = await this.resolvePaths(agent)
2531
+ const projectDir = (p && p.projectDir) || (p && p.handoffDir ? path.dirname(p.handoffDir) : '')
2532
+ if (!projectDir) return { ok: false, error: 'no-project-dir' }
2533
+ return await this.ensurePlanBoardPre(projectDir)
2534
+ } catch (e) {
2535
+ return { ok: false, error: String((e && e.message) || e) }
2536
+ }
2537
+ }
2538
+
2321
2539
  async writePlanSnapshot(projectDir, content, opts) {
2322
2540
  const dir = path.join(projectDir, 'handoff')
2323
2541
  const planPath = path.join(dir, 'PLAN.md')
@@ -2499,7 +2717,18 @@ class MemoryEngine {
2499
2717
  // 保留调用方刚写入的富条目(rebuild 只是补倒排, 正文预览以已有条目为准)
2500
2718
  fresh.entries = entries
2501
2719
  await mkdir(sideDir, { recursive: true })
2502
- await writeFile(path.join(sideDir, 'index.json'), JSON.stringify(fresh, null, 2), 'utf8')
2720
+ // ★2026-09-22:改**原子写**(tmp + 有界重试 rename)。
2721
+ // 旧实现是裸 `await writeFile(index.json, …)` —— 这份索引 235 KB,写入过程**不是原子的**:
2722
+ // 面板/工具恰在此时读,读到的要么是零字节、要么是半截 JSON ⇒ `JSON.parse` 失败 ⇒ 看板/白板
2723
+ // **整块空白**,写完才恢复(用户实测:「白板不见了 → 过了一会儿又出现了」)。时间戳可对:
2724
+ // 索引在 09-22 00:52:10 被整份重写,同一秒宿主诊断 `…-diagnose.log` 连出 6 条
2725
+ // `ctx-host degrade: index-not-ready(commit)` —— 就是这段「不可读窗口」。
2726
+ // rename 在同目录内是原子替换;Windows 上读侧句柄争用会抛 EPERM/EBUSY,
2727
+ // 故复用 #48 的 `retryRename`(有界退避,且不做 unlink/copy 回退以免破坏原子性)。
2728
+ const idxPath = path.join(sideDir, 'index.json')
2729
+ const tmpPath = idxPath + '.tmp'
2730
+ await writeFile(tmpPath, JSON.stringify(fresh, null, 2), 'utf8')
2731
+ await retryRename(tmpPath, idxPath)
2503
2732
  return fresh
2504
2733
  }
2505
2734
 
@@ -2522,7 +2751,17 @@ class MemoryEngine {
2522
2751
  const sideDir = path.join(projectDir, 'handoff')
2523
2752
  const docs = await this._collectWhiteboardDocsPre(projectDir).catch(() => [])
2524
2753
  const index = rebuildSidecarIndexPre(this.wbWsKeyPre(projectDir), docs)
2525
- try { await mkdir(sideDir, { recursive: true }); await writeFile(path.join(sideDir, 'index.json'), JSON.stringify(index, null, 2), 'utf8') } catch (_) {}
2754
+ try {
2755
+ await mkdir(sideDir, { recursive: true })
2756
+ // ★2026-09-22:与 `_writeSidecarIndexPre` 同一口径 —— **重建路径也必须原子替换**。
2757
+ // 这里同样是整份索引(实测 235 KB)裸写;而重建恰恰是在「索引缺失/损坏」时被触发的,
2758
+ // 此刻面板正等着读这份文件 ⇒ 裸写期间的读者拿到半截 JSON,白板依旧整块空白。
2759
+ // (本处由守卫 `smoke-test-board-index-atomic-pre.mjs` 抓出:它断言「全仓不得再有裸写 index.json」。)
2760
+ const rIdx = path.join(sideDir, 'index.json')
2761
+ const rTmp = rIdx + '.tmp'
2762
+ await writeFile(rTmp, JSON.stringify(index, null, 2), 'utf8')
2763
+ await retryRename(rTmp, rIdx)
2764
+ } catch (_) {}
2526
2765
  return index
2527
2766
  }
2528
2767
 
@@ -5558,7 +5797,7 @@ class MemoryEngine {
5558
5797
  // 旧行为的病(两条独立病因,都有代码行号):
5559
5798
  // A. 措辞:开场白把"规矩"与"资料"统一降格为「只是背景事实与规则参考」⇒ 规则不醒目;
5560
5799
  // B. 节奏:`snapshotMinGapRounds` 使规矩在第 2–5 轮**不在场**(模型不是不听话,是没收到)。
5561
- // 开关 `rulesLayeringMode`(**默认 'off'**)= 新旧并存 + 一键回退:关掉即完全回到旧行为。
5800
+ // 开关 `rulesLayeringMode`(**出厂默认 'self'**,见 DEFAULT_CONFIG;旧注释写成 'off' 是错的)= 新旧并存 + 一键回退:关掉即完全回到旧行为。
5562
5801
  // 开启后规则段走分项账本的 `rules` 分项:**不受 gap 约束、不参与裁剪**(v2 §3.3 选 (ii))。
5563
5802
  let rulesLayer = { enabled: false, text: '', rules: [] }
5564
5803
  let rulesSection = { text: '', chars: 0 }
@@ -5592,7 +5831,13 @@ class MemoryEngine {
5592
5831
  // ★2026-09-15 修复:每段带 `priority`(low / normal / must)。超额时**先丢 low,must 永不丢**。
5593
5832
  // 旧实现是**纯位置式**丢弃(从分项尾部丢),尾部恰好是 user-memory / project-notes
5594
5833
  // ⇒ 价值最高的两段先死(本机实测)。位置顺序是渲染顺序,不是价值顺序。
5834
+ // ★P10:**注入分区开关**的唯一闸门(每段都过这里)。缺省全开 ⇒ 只有显式 false 才拦。
5835
+ // 只影响本段自己,不触发任何其他功能变化(用户铁律:单一开关不得顺带改变别的行为)。
5836
+ const secToggles = (cfg && cfg.promptSectionToggles && typeof cfg.promptSectionToggles === 'object')
5837
+ ? cfg.promptSectionToggles : {}
5838
+ const sectionsOff = []
5595
5839
  const pushPart = (bucket, kind, text, priority) => {
5840
+ if (secToggles[kind] === false) { sectionsOff.push(kind); return }
5596
5841
  if (text === undefined || text === null || text === '') return
5597
5842
  segs.push({ bucket, kind, text, priority: priority || 'normal' })
5598
5843
  }
@@ -5732,6 +5977,22 @@ class MemoryEngine {
5732
5977
  // 只用于账本的「合计是否超预算」可见性(落 meta;诊断类降级不进注入文本,避免每轮噪声)
5733
5978
  budgetChars,
5734
5979
  })
5980
+ // ★③ 召回统计 · inject 通道(2026-09-22 用户拍板「分开计算」):记**这一轮实际注入了哪些段、各多少字符**。
5981
+ // 为什么与 model 通道分开:inject = 「系统一直塞给我」= **成本**;model = 「我明确想找」= 高信号。
5982
+ // 合成一个数会把「模型懒得检索」误读成「这条不重要」,而这个误读会被写进加权公式、越滚越偏。
5983
+ // 数据源用 `envelope`(不是 `segs`):它已过预算裁剪 ⇒ 记的是**真正进 prompt 的段**,不是想注入的段。
5984
+ // ★只读:本块不修改 envelope、不影响注入文本(守卫断言:注入文本逐字节不变)。
5985
+ try {
5986
+ if (this._recallStats && envelope && Array.isArray(envelope.segments)) {
5987
+ const segList = envelope.segments.map((sg) => ({
5988
+ name: sg.kind, chars: (sg.text || '').length, layer: sg.bucket,
5989
+ }))
5990
+ const droppedList = (envelope.dropped || []).map((x) => ({
5991
+ name: x.kind + '(dropped)', chars: 0, layer: x.bucket,
5992
+ }))
5993
+ this._recallStats.observeInjection(segList.concat(droppedList))
5994
+ }
5995
+ } catch (_) {}
5735
5996
  // T0-3:账本挂 state,面板/排障/后续 P6A 都读它(**不再有第二本账**)
5736
5997
  try {
5737
5998
  this.state.envelopeMeta = {
@@ -5769,7 +6030,7 @@ class MemoryEngine {
5769
6030
  lines.push('- 新工作区(无历史日志/笔记):主动用 memory_recall 探索本机历史,判断该项目是否曾在其他 AI 工具中工作过;也可调用 memory_external 查看并接入外部记忆;检索时在正文中说明"我先查一下之前的记录"。')
5770
6031
  lines.push('- 完成实质性工作后立即调用 memory_log 追加今日日志(append-only,绝不覆盖):建/改应用、修 bug、写文档、重构、技术选型、用户约定或偏好。')
5771
6032
  lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
5772
- lines.push('- **交接与白板(长任务续命)**:阶段产出或方向变化时,调用 memory_note(kind=handoff) 写四段式交接账本——任务状态/目标/已试方案与失败原因/进度与下一步,给下一个上下文窗口续命;对项目全貌的理解发生实质变化时,用 memory_note(kind=plan) 重写白板 PLAN.md(人能读的项目规划图,旧版自动归档,用户在面板实时可见)。账本质量纪律:每段 ≤5 行;失败项写成「方案→失败原因」并保留关键报错词;下一步必须是可直接执行的第一步(带文件路径或命令);不写临时信息。两者会注入到你的动态快照首位,是跨窗口交接的凭据。')
6033
+ lines.push('- **交接与白板(长任务续命)**:阶段产出或方向变化时,调用 memory_note(kind=handoff) 写四段式交接账本——任务状态/目标/已试方案与失败原因/进度与下一步,给下一个上下文窗口续命;白板 PLAN.md(人能读的项目规划图,用户在面板实时可见):**新工作区的骨架由宿主自动落(标题带「自动建立 · 待模型重写」),那只是占位——你理解项目全貌后要用 memory_note(kind=plan) 把它重写成真内容**;此后对项目全貌的理解发生实质变化时同样重写(整体覆盖,旧版自动归档)。账本质量纪律:每段 ≤5 行;失败项写成「方案→失败原因」并保留关键报错词;下一步必须是可直接执行的第一步(带文件路径或命令);不写临时信息。两者会注入到你的动态快照首位,是跨窗口交接的凭据。')
5773
6034
  lines.push('- 只记录有跨会话长期价值的;不记临时信息(搜索结果、临时路径、工具报错)。')
5774
6035
  // ★T6(2026-09-20 用户拍板):**看板分列机制** —— 模型此前完全不知道有 tag 这回事。
5775
6036
  // 根因:wb-sidecar.js:594-597 的 WB_KANBAN_LANES_V1 靠 matchTags 匹配
@@ -6215,7 +6476,7 @@ class MemoryEngine {
6215
6476
  if (rank && rank.scores && rank.scores.size) semScores = rank.scores
6216
6477
  } catch (eBest) {
6217
6478
  // R3:预期外失败 —— 引擎/worker 抛错导致回退词法,用户应当能知道(不只是 diag 一行)。
6218
- try { diag('recall 语义臂择优降级: ' + String((eBest && eBest.message) || eBest).slice(0, 120)) } catch (_) {}
6479
+ try { diagThrottled('recall-best', 'recall 语义臂择优降级: ' + String((eBest && eBest.message) || eBest).slice(0, 120)) } catch (_) {}
6219
6480
  try { if (this._degradeSink) this._degradeSink.record('semantic-arm', String((eBest && eBest.message) || eBest).slice(0, 160)) } catch (_) {}
6220
6481
  }
6221
6482
  if (semScores && semScores.size) {
@@ -6254,7 +6515,7 @@ class MemoryEngine {
6254
6515
  } catch (eImp) {
6255
6516
  // R3:预期外失败才记。目录不存在已被上面的 existsSync 守卫拦下(预期内分支,静默);
6256
6517
  // 走到这里的都是真异常(聚合/读文件/import 失败)⇒ 应当留痕。
6257
- try { diag('evidence-agg 降级为中性(impMap 空): ' + String((eImp && eImp.message) || eImp).slice(0, 140)) } catch (_) {}
6518
+ try { diagThrottled('evidence-agg', 'evidence-agg 降级为中性(impMap 空): ' + String((eImp && eImp.message) || eImp).slice(0, 140)) } catch (_) {}
6258
6519
  try { if (this._degradeSink) this._degradeSink.record('evidence-arm', String((eImp && eImp.message) || eImp).slice(0, 160)) } catch (_) {}
6259
6520
  }
6260
6521
  const l0Hits = l0Corpus.filter((c) => c.lex > 0 || (typeof c.sem === 'number' && c.sem >= 0.5))
@@ -6267,7 +6528,7 @@ class MemoryEngine {
6267
6528
  const tp = await import('./temporal-parse.js')
6268
6529
  tr = tp.parseTemporalQueryPre(query, { now: Date.now() })
6269
6530
  dateMsOf = tp.labelToDateMsPre
6270
- } catch (eTr) { try { diag('temporal-parse 降级(无时间臂): ' + String((eTr && eTr.message) || eTr).slice(0, 140)) } catch (_) {} }
6531
+ } catch (eTr) { try { diagThrottled('temporal-parse', 'temporal-parse 降级(无时间臂): ' + String((eTr && eTr.message) || eTr).slice(0, 140)) } catch (_) {} }
6271
6532
  const tempFieldOf = (c) => {
6272
6533
  if (!tr || typeof dateMsOf !== 'function') return undefined
6273
6534
  const d = dateMsOf(c.label)
@@ -6303,7 +6564,17 @@ class MemoryEngine {
6303
6564
  .sort((a, b) => b.lex - a.lex || (b.sem || 0) - (a.sem || 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
6304
6565
  .slice(0, Math.max(1, Number(limit) || 8))
6305
6566
  }
6306
- if (l0Top.length) {
6567
+ // ③ 召回统计埋点(2026-09-22):**只读** l0Top 做计数,绝不改其顺序/内容。
6568
+ // 为什么放这里:l0Top 已定型(融合或降级排序完成)、尚未被转成输出文本
6569
+ // ⇒ 记的就是"本轮真实交付的命中集",含 finalRank 口径。fail-soft 包住,统计绝不干扰召回。
6570
+ try {
6571
+ if (this._recallStats) {
6572
+ this._recallStats.observe(l0Top.map((c) => ({
6573
+ id: c.id, layer: c.layer, score: typeof c.sem === 'number' ? c.sem : c.lex, reason: c.reason,
6574
+ })), { channel: 'model' })
6575
+ }
6576
+ } catch (_) {}
6577
+ if (l0Top.length) {
6307
6578
  out.push('== L0 命中(摘要,含 id/得分/匹配原因;展开单条原文传 expand="mem_xxx") ==')
6308
6579
  // ★R4-B(2026-09-18)分层**呈现**:把已排好序的命中**按层分组**后展示。
6309
6580
  // ★排序零改动:分组只重排"行",不改 `l0Top` 的融合/词法序;组内保持原相对顺序,
@@ -6603,28 +6874,44 @@ class MemoryEngine {
6603
6874
 
6604
6875
  /** 扫描 ~/.dsh/sessions 下所有会话,提取去重后的工作区路径。 */
6605
6876
  async discoverWorkspaces() {
6606
- const out = new Map()
6877
+ // ★#102(2026-09-22):解除「硬编码 30 且静默截断」。
6878
+ // 旧实现:`if (out.size >= 30) return` —— 上限写死、按**目录遍历顺序**取前 30,
6879
+ // 第 31 个工作区起被**静默丢弃**(跨区检索 / 注入索引 / 工作区总览一起少项,且无任何信号)。
6880
+ // 现在:① 上限走配置 `workspaceDiscoverMax`(默认 200,非法值回落默认);
6881
+ // ② 以「该工作区最近一次会话文件的 mtime」为活跃度,按新→旧排序后再截断 ——
6882
+ // 即便真到上限,丢掉的也是**最久没用过**的,而不是碰巧排在后面的;
6883
+ // ③ 仅保留一个 5×上限的遍历安全阀,防止 sessions 目录异常膨胀时无限走盘。
6884
+ const capRaw = Number(this.config && this.config.workspaceDiscoverMax)
6885
+ const cap = (Number.isFinite(capRaw) && capRaw >= 1) ? Math.floor(capRaw) : 200
6886
+ const walkLimit = cap * 5
6887
+ const out = new Map() // cwd → 最近会话文件 mtimeMs
6607
6888
  const sessionsDir = path.join(dshHome(), 'sessions')
6608
6889
  const walk = async (dir, depth) => {
6609
6890
  if (depth > 4) return
6610
6891
  let entries
6611
6892
  try { entries = await readdir(dir, { withFileTypes: true }) } catch (e) { return }
6612
6893
  for (const en of entries) {
6613
- if (out.size >= 30) return
6614
- if (en.isDirectory()) await walk(path.join(dir, en.name), depth + 1)
6615
- else if (en.isFile() && (en.name.endsWith('.jsonl.zstd') || en.name.endsWith('.jsonl'))) {
6616
- try {
6617
- const first = await this.readFirstLine(path.join(dir, en.name))
6618
- if (first) {
6619
- const h = JSON.parse(first)
6620
- if (h && typeof h.cwd === 'string' && h.cwd) out.set(h.cwd, true)
6621
- }
6622
- } catch (e) {}
6623
- }
6894
+ if (out.size >= walkLimit) return
6895
+ if (en.isDirectory()) { await walk(path.join(dir, en.name), depth + 1); continue }
6896
+ if (!en.isFile() || !(en.name.endsWith('.jsonl.zstd') || en.name.endsWith('.jsonl'))) continue
6897
+ const full = path.join(dir, en.name)
6898
+ try {
6899
+ const first = await this.readFirstLine(full)
6900
+ if (!first) continue
6901
+ const h = JSON.parse(first)
6902
+ if (!h || typeof h.cwd !== 'string' || !h.cwd) continue
6903
+ let mt = 0
6904
+ try { mt = (await stat(full)).mtimeMs } catch (e) {}
6905
+ const prev = out.get(h.cwd) || 0
6906
+ if (mt > prev) out.set(h.cwd, mt)
6907
+ } catch (e) {}
6624
6908
  }
6625
6909
  }
6626
6910
  await walk(sessionsDir, 0)
6627
- return Array.from(out.keys())
6911
+ return Array.from(out.entries())
6912
+ .sort((a, b) => b[1] - a[1]) // 活跃度降序(mtime 新→旧)
6913
+ .slice(0, cap)
6914
+ .map((e) => e[0])
6628
6915
  }
6629
6916
 
6630
6917
  /** 读取某工作区的近期记忆(最近5天日志 + 项目笔记头部),无 .dsh-memory 返回 null。 */
@@ -6644,6 +6931,208 @@ class MemoryEngine {
6644
6931
  return { logs, notes: truncateHead(notes || '', 800) }
6645
6932
  }
6646
6933
 
6934
+ // ───────────────────── 迁移搬包(migrate pack)─────────────────────
6935
+ // 用户拍板:导出范围=主体(不带语义索引);路径需重写;入口放存储管理;先做一次性搬包。
6936
+ // ★IO 全在本节,纯逻辑全在 lib/migrate-pack.js(可单测、可守卫)。
6937
+
6938
+ /** 汇总文件路径(跨工作区全局)。 */
6939
+ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.json') }
6940
+
6941
+ /** 当前工作区在 summary 里的那条记录(导出时随包带走)。 */
6942
+ async _wsSummaryRecord(ws) {
6943
+ try {
6944
+ const raw = await this.readTextSafe(this._wsSummaryFile())
6945
+ if (!raw) return null
6946
+ const j = JSON.parse(raw)
6947
+ const list = j && Array.isArray(j.workspaces) ? j.workspaces : []
6948
+ return list.find((x) => x && x.path === ws) || null
6949
+ } catch (_) { return null }
6950
+ }
6951
+
6952
+ /** 目标工作区目录里现有文件(键=相对 slug 目录的路径)—— 供「预览差异」算新增/覆盖。
6953
+ * ★只读,且只读文本类文件(记忆数据全是文本)。 */
6954
+ async _readExistingWorkspaceFiles(ws) {
6955
+ const dir = this.projectDirOf(ws)
6956
+ const out = {}
6957
+ const walk = async (q, rel) => {
6958
+ let ents = []
6959
+ try { ents = await readdir(q, { withFileTypes: true }) } catch (_) { return }
6960
+ for (const en of ents) {
6961
+ const abs = path.join(q, en.name)
6962
+ const r = rel ? rel + '/' + en.name : en.name
6963
+ if (en.isDirectory()) { await walk(abs, r); continue }
6964
+ try {
6965
+ const st = await stat(abs)
6966
+ if (!st.isFile() || st.size > 8 * 1024 * 1024) continue
6967
+ out[r] = await readFile(abs, 'utf8')
6968
+ } catch (_) {}
6969
+ }
6970
+ }
6971
+ await walk(dir, '')
6972
+ return out
6973
+ }
6974
+
6975
+ /** 导出:打包一个工作区的全部记忆到一个 .dam-pack(单 JSON;compress=true 时 gzip)。 */
6976
+ async migrateExport(opts) {
6977
+ const o = opts || {}
6978
+ const ws = String(o.ws || '')
6979
+ if (!ws) return { ok: false, error: 'missing-ws' }
6980
+ const outPath = String(o.outPath || '')
6981
+ if (!outPath) return { ok: false, error: 'missing-out-path' }
6982
+ const dir = this.projectDirOf(ws)
6983
+ const st = await stat(dir).catch(() => null)
6984
+ if (!st || !st.isDirectory()) return { ok: false, error: 'workspace-dir-missing' }
6985
+ // ★只收文本类文件:记忆数据全部是 .md/.json/.jsonl/.txt;二进制跳过并记警告(不静默丢)
6986
+ const TEXT_RE = /\.(md|json|jsonl|txt|markdown)$/i
6987
+ const files = {}
6988
+ const skipped = []
6989
+ let bytes = 0
6990
+ const walk = async (q, rel) => {
6991
+ let ents = []
6992
+ try { ents = await readdir(q, { withFileTypes: true }) } catch (_) { return }
6993
+ for (const en of ents) {
6994
+ const abs = path.join(q, en.name)
6995
+ const r = rel ? rel + '/' + en.name : en.name
6996
+ if (en.isDirectory()) { await walk(abs, r); continue }
6997
+ if (!TEXT_RE.test(en.name)) { skipped.push(r); continue }
6998
+ try {
6999
+ const s = await stat(abs)
7000
+ if (!s.isFile()) continue
7001
+ if (s.size > 32 * 1024 * 1024) { skipped.push(r + '(too-large)'); continue }
7002
+ const txt = await readFile(abs, 'utf8')
7003
+ files[r] = txt
7004
+ bytes += Buffer.byteLength(txt, 'utf8')
7005
+ } catch (_) { skipped.push(r + '(unreadable)') }
7006
+ }
7007
+ }
7008
+ await walk(dir, '')
7009
+ const built = buildPackPre({
7010
+ ws, files,
7011
+ summaryRecord: await this._wsSummaryRecord(ws),
7012
+ pluginVersion: (this.manifest && this.manifest.version) || '',
7013
+ sourceHost: os.hostname(),
7014
+ })
7015
+ if (!built.ok) return { ok: false, error: built.error, skipped }
7016
+ const json = JSON.stringify(built.pack)
7017
+ const compress = o.compress !== false
7018
+ let payload = json, ext = '.dam-pack'
7019
+ if (compress) { payload = nodeZlib.gzipSync(Buffer.from(json, 'utf8')); ext = '.dam-pack.gz' }
7020
+ let finalPath = outPath
7021
+ if (!/\.dam-pack(\.gz)?$/i.test(finalPath)) finalPath = finalPath.replace(/[\\/]+$/, '') + path.sep + migrateSlugPre(ws) + ext
7022
+ await mkdir(path.dirname(finalPath), { recursive: true })
7023
+ const tmp = finalPath + '.tmp'
7024
+ await writeFile(tmp, payload)
7025
+ await rename(tmp, finalPath) // 原子落盘:写一半的包不会被认为是完整的
7026
+ return {
7027
+ ok: true, path: finalPath, format: MIGRATE_PACK_FORMAT_PRE,
7028
+ stats: { fileCount: built.pack.stats.fileCount, bytes: built.pack.stats.bytes, packedBytes: Buffer.byteLength(payload) },
7029
+ checksum: built.pack.checksum.value, compress, skipped,
7030
+ warnings: built.warnings,
7031
+ }
7032
+ }
7033
+
7034
+ /** 读包(自动识别 gzip:gzip 魔数 1f 8b)。★坏包在 validate 阶段即拒,不写任何文件。 */
7035
+ async _readPack(packPath) {
7036
+ const buf = await readFile(packPath).catch(() => null)
7037
+ if (!buf || !buf.length) return { ok: false, errors: ['pack-unreadable'] }
7038
+ let json = null
7039
+ try {
7040
+ json = (buf[0] === 0x1f && buf[1] === 0x8b)
7041
+ ? nodeZlib.gunzipSync(buf).toString('utf8')
7042
+ : buf.toString('utf8')
7043
+ } catch (_) { return { ok: false, errors: ['pack-corrupt'] } }
7044
+ let pack = null
7045
+ try { pack = JSON.parse(json) } catch (_) { return { ok: false, errors: ['pack-not-json'] } }
7046
+ const v = validatePackPre(pack)
7047
+ if (!v.ok) return { ok: false, errors: v.errors, warnings: v.warnings }
7048
+ return { ok: true, pack, warnings: v.warnings }
7049
+ }
7050
+
7051
+ /** 预览:算出「导入将要发生什么」。★这是**唯一**能算差异的地方;import 会重算同一份计划。 */
7052
+ async migrateInspect(opts) {
7053
+ const o = opts || {}
7054
+ const r = await this._readPack(String(o.packPath || ''))
7055
+ if (!r.ok) return { ok: false, errors: r.errors }
7056
+ const targetWs = String(o.targetWs || r.pack.source.ws)
7057
+ let existing = {}
7058
+ try { existing = await this._readExistingWorkspaceFiles(targetWs) } catch (_) {}
7059
+ const plan = planImportPre(r.pack, { targetWs, existingFiles: existing, onConflict: o.onConflict, rewriteBody: o.rewriteBody })
7060
+ const { writeFiles, ...wire } = plan
7061
+ return {
7062
+ ok: true,
7063
+ pack: {
7064
+ format: r.pack.format, createdAt: r.pack.createdAt,
7065
+ source: r.pack.source, runtime: r.pack.runtime,
7066
+ stats: r.pack.stats, checksum: r.pack.checksum,
7067
+ },
7068
+ plan: wire,
7069
+ sourceMissing: !(await stat(this.projectDirOf(r.pack.source.ws)).catch(() => null)),
7070
+ warnings: r.warnings,
7071
+ }
7072
+ }
7073
+
7074
+ /** 导入:执行。★顺序 = 重算计划 → 备份 → 落盘 → 合并 summary(任一步失败即中止并回报)。 */
7075
+ async migrateImport(opts) {
7076
+ const o = opts || {}
7077
+ const r = await this._readPack(String(o.packPath || ''))
7078
+ if (!r.ok) return { ok: false, error: 'pack-invalid', errors: r.errors }
7079
+ const targetWs = String(o.targetWs || r.pack.source.ws)
7080
+ if (!targetWs) return { ok: false, error: 'missing-target-ws' }
7081
+ const targetDir = this.projectDirOf(targetWs)
7082
+ let existing = {}
7083
+ try { existing = await this._readExistingWorkspaceFiles(targetWs) } catch (_) {}
7084
+ // ★重算(而非信任前端传来的 plan):防「预览与执行之间目标被改动」的 TOCTOU
7085
+ const plan = planImportPre(r.pack, { targetWs, existingFiles: existing, onConflict: o.onConflict, rewriteBody: o.rewriteBody })
7086
+ /** @type {{path:string, bytes:number, action:string}[]} */
7087
+ const written = []
7088
+ // S1:目标目录存在且非空 ⇒ 先整体备份(不可逆操作前的唯一保险)
7089
+ let backup = null
7090
+ const tst = await stat(targetDir).catch(() => null)
7091
+ const hasExisting = Object.keys(existing).length > 0
7092
+ if (tst && tst.isDirectory() && hasExisting) {
7093
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
7094
+ backup = targetDir + '.bak-' + stamp
7095
+ try { await this.copyDir(targetDir, backup) } catch (e) { return { ok: false, error: 'backup-failed', detail: String(e && e.message || e) } }
7096
+ }
7097
+ try {
7098
+ await mkdir(targetDir, { recursive: true })
7099
+ for (const [rel, text] of Object.entries(plan.writeFiles)) {
7100
+ const abs = path.join(targetDir, rel)
7101
+ await mkdir(path.dirname(abs), { recursive: true })
7102
+ const tmp = abs + '.dam-tmp'
7103
+ await writeFile(tmp, text, 'utf8')
7104
+ await rename(tmp, abs)
7105
+ written.push({ path: rel, bytes: Buffer.byteLength(text, 'utf8'), action: 'write' })
7106
+ }
7107
+ } catch (e) {
7108
+ return { ok: false, error: 'write-failed', detail: String(e && e.message || e), backup, written }
7109
+ }
7110
+ // S4:summary 用 merge 不用 replace —— 目标机的其它工作区记录必须原样保留
7111
+ let summaryAction = 'skipped'
7112
+ let summaryError = null
7113
+ try {
7114
+ const cur = await this.readTextSafe(this._wsSummaryFile())
7115
+ const merged = mergeSummaryRecordPre(cur ? JSON.parse(cur) : null, r.pack.summaryRecord, targetWs)
7116
+ if (merged.changed) {
7117
+ const f = this._wsSummaryFile()
7118
+ const tmp = f + '.dam-tmp'
7119
+ await writeFile(tmp, JSON.stringify(merged.summary, null, 2), 'utf8')
7120
+ await rename(tmp, f)
7121
+ }
7122
+ summaryAction = merged.action
7123
+ } catch (e) { summaryError = String(e && e.message || e) }
7124
+ // 索引失效:语义索引按工作区缓存,路径/内容变了必须让它重建(S5:索引本身不随包走)
7125
+ try { this._semanticIndexStaleByWorkspace = this._semanticIndexStaleByWorkspace || new Set(); this._semanticIndexStaleByWorkspace.add(targetWs) } catch (_) {}
7126
+ try { if (this.state) this.state.loadedAt = 0; await this.refresh(undefined) } catch (_) {}
7127
+ return {
7128
+ ok: true, targetWs, targetSlug: migrateSlugPre(targetWs), backup,
7129
+ pathChanged: plan.pathChanged, onConflict: plan.onConflict,
7130
+ written: written.length, writtenFiles: written.slice(0, 200),
7131
+ rewrite: { totalHits: plan.rewrite.totalHits, fileCount: plan.rewrite.fileCount, files: plan.rewrite.files.slice(0, 50) },
7132
+ additions: plan.additions.length, overwrites: plan.overwrites.length,
7133
+ summaryAction, summaryError,
7134
+ }
7135
+ }
6647
7136
  /** 工作区总览:每个工作区一个小总结 + 区内细分总结;结果缓存到用户级 ~/.dsh/memory/workspaces-summary.json(跨工作区全局)。 */
6648
7137
  async workspaceOverview(agent, force) {
6649
7138
  const cacheFile = path.join(dshHome(), 'memory', 'workspaces-summary.json')
@@ -6775,6 +7264,91 @@ class MemoryEngine {
6775
7264
  return Object.assign({}, snap, { verdict })
6776
7265
  }
6777
7266
 
7267
+ // ---------- ★#110(2026-09-22):hub 持久化 IO 健康度视图 ----------
7268
+ /**
7269
+ * 返回三层记忆(episodes/facts/procedures)落盘 IO 的健康度投影。
7270
+ *
7271
+ * **为什么需要**:hub 三店的 `io` 由 `hubIo()` 提供,旧实现 save/clear 内 `catch (_) {}`
7272
+ * 把异常吞在这一层 ⇒ 上层三店(A-8 之后)的 `try { io.save() } catch` 永远不触发、
7273
+ * 返回值恒为 `{ok:true, persisted:true}` ⇒ 「写不进去」在**任何**出口都不可见。
7274
+ * 现在失败会照抛并同时记进 `_hubIoHealth`,本方法就是它的只读投影。
7275
+ *
7276
+ * 纪律:与 `_degradeViewSnapshot` / `_quotaViewSnapshot` 同款 —— 只出计数 + 人话原因 + 时间戳,
7277
+ * **无路径、无原文**;任何异常都不得打断 debugInfo(全程 try/catch,失败返回空计数)。
7278
+ */
7279
+ _hubIoViewSnapshot() {
7280
+ return hubIoHealthSnapshotPre(this._hubIoHealth)
7281
+ }
7282
+ /**
7283
+ * 事实保留上限淘汰台账的只读投影(★P0-3 / 2026-09-22)。
7284
+ *
7285
+ * **为什么不能只用 stats**:`getStats()`(fact-store.js:824)返回的是累计计数,
7286
+ * 而 `lastPrune` 是闭包变量、只经 `getLastPrune()`(:832)单独暴露 ⇒
7287
+ * 「最近一次淘汰发生在什么时候、当时上限多少、超出多少」只有它能给。
7288
+ *
7289
+ * 纪律:与 `_hubIoViewSnapshot` / `_degradeViewSnapshot` 同款 —— 只出聚合数 + 时间戳,
7290
+ * **无事实正文、无路径**;任何异常都不得打断 debugInfo(全程 try/catch,失败返回 null)。
7291
+ * `protected > 0` 表示已轮到删「重要项」⇒ 上限设得过低,前端据此给告警色与指引。
7292
+ */
7293
+ /**
7294
+ * ★2026-09-22(用户拍板):诊断日志只读投影 —— 让「日志」在**界面里可查**,
7295
+ * 不必再去翻 PowerShell 或手动找目录。与既有最小投影同一纪律:
7296
+ * **只出计数/大小/时间戳/路径,不出日志正文**(正文可能含工作区内容)。
7297
+ * 形状:{ path, dir, sizeBytes, sizeText, historyBytes, historyPath, lines, exists, rotatedNow }
7298
+ * 消费点:lib/client.js 诊断页签(模块级渲染,不随刷新闪烁)。
7299
+ */
7300
+ _logsViewSnapshot() {
7301
+ try {
7302
+ const f = diagLogFile()
7303
+ const bak = f + '.1'
7304
+ let sizeBytes = 0
7305
+ let exists = false
7306
+ try { const st = statSync(f); sizeBytes = st.size; exists = true } catch (e) {}
7307
+ let historyBytes = 0
7308
+ try { historyBytes = statSync(bak).size } catch (e) {}
7309
+ // 行数:只在文件不太大时数(避免每次开面板都全量扫 10MB+)
7310
+ let lines = null
7311
+ if (exists && sizeBytes <= DIAG_MAX_BYTES * 2) {
7312
+ try {
7313
+ const text = readFileSync(f, 'utf8')
7314
+ lines = text.length ? text.replace(/\r\n$/, '').split(/\r?\n/).length : 0
7315
+ } catch (e) {}
7316
+ }
7317
+ return {
7318
+ path: f,
7319
+ dir: path.dirname(f),
7320
+ sizeBytes,
7321
+ sizeText: fmtBytes(sizeBytes),
7322
+ historyBytes,
7323
+ historyPath: bak,
7324
+ lines,
7325
+ exists,
7326
+ rotatedNow: _diagRotated,
7327
+ note: '诊断日志(只读投影,不含正文)。崩溃/异常时请把这个路径下的文件附给维护者。',
7328
+ }
7329
+ } catch (e) { return { exists: false, error: String((e && e.message) || e) } }
7330
+ }
7331
+
7332
+ _factsPruneViewSnapshot() {
7333
+ try {
7334
+ const st = this._factStore
7335
+ if (!st) return null
7336
+ const last = typeof st.getLastPrune === 'function' ? st.getLastPrune() : null
7337
+ const limit = typeof st.retentionLimit === 'function' ? st.retentionLimit() : null
7338
+ if (!last && limit === null) return null
7339
+ return {
7340
+ pruned: Number(last && last.pruned) || 0,
7341
+ protected: Number(last && last.protected) || 0,
7342
+ limit: limit === null || limit === undefined
7343
+ ? (last && last.limit !== undefined ? last.limit : null)
7344
+ : limit,
7345
+ over: Number(last && last.over) || 0,
7346
+ oldestAt: (last && last.oldestAt) || null,
7347
+ at: (last && last.at) || null,
7348
+ }
7349
+ } catch (_) { return null }
7350
+ }
7351
+
6778
7352
  /** 把降级台账与配额测量**合并写入同一状态文件**(读驱动写;失败返回 false,不抛)。 */
6779
7353
  _persistObservabilityPre(snap, quota) {
6780
7354
  let persisted = false
@@ -6880,6 +7454,17 @@ class MemoryEngine {
6880
7454
  // 回答用户那两个问题:「配额太少(内容进不来)」还是「配额多了(白花 token)」。
6881
7455
  // 判定默认 `insufficient-data`(样本不足**不猜**),符合用户「基于长期观察」的要求。
6882
7456
  quota: this._quotaViewSnapshot(),
7457
+ // ★#110(2026-09-22):hub 三层记忆持久化 IO 健康度。与既有最小投影同一出口、同一纪律
7458
+ // (只出计数 + 人话原因 + 时间戳,**无路径、无原文**)。回答的问题:
7459
+ // 「记忆到底写进磁盘了没有」——此前 save 失败被静默吞掉,面板/日志/返回值三处都看不出来。
7460
+ hubIo: this._hubIoViewSnapshot(),
7461
+ // ★P0-3(2026-09-22):事实保留上限淘汰台账 —— 与既有最小投影同一出口、同一纪律
7462
+ // (只出聚合计数与时间戳,**无事实正文、无路径**)。此前 getLastPrune/retentionLimit
7463
+ // 在双侧零调用方 ⇒「事实被悄悄淘汰了」在界面上无处可查。
7464
+ factsPrune: this._factsPruneViewSnapshot(),
7465
+ // ★2026-09-22(用户拍板):诊断日志投影 —— 与既有最小投影同一出口、同一纪律。
7466
+ // 回答:「日志在哪、多大、多少行」——用户不必再翻 PowerShell 或手动找目录。
7467
+ logs: this._logsViewSnapshot(),
6883
7468
  runtimes: this.runtimes.values().map((rt) => ({
6884
7469
  key: rt.key,
6885
7470
  sessionId: rt.sessionId,
@@ -9094,8 +9679,11 @@ export function apply(ctx, config) {
9094
9679
  // 新bug修复①·启动预热(2026-09-08):重启后首轮 renderMemoryDynamic 可能先于 _doRefresh 完成,
9095
9680
  // 此时 globalLedgerPath 尚未就位,兜底行会错过它要救的那一轮。挂载即异步预热全局账本缓存(只读扫描,零副作用)。
9096
9681
  try { void engine.findLatestGlobalHandoff().then((v) => { if (!engine._globalLedgerCache) engine._globalLedgerCache = { at: Date.now(), value: v || '' } }).catch(() => {}) } catch (e) {}
9682
+ engine._recallStats = createRecallStatsPre({
9683
+ file: () => path.join(dshHome(), 'memory', 'recall-stats.json'),
9684
+ })
9097
9685
  // M4-3:Shadow Retrieval Host 接线(三开关全开时才构造状态/IO;默认关闭零留存)
9098
- engine._shadowHost = createShadowHost({ engine })
9686
+ engine._shadowHost = createShadowHost({ engine, onDiag: (key, msg) => diagThrottled(key, msg), recallStats: engine._recallStats })
9099
9687
  // M5-3:Context Bridge Host 接线(assoc+contextBridge 双门;默认关闭零构造/零 IO)
9100
9688
  engine._contextHost = createContextHost({ engine })
9101
9689
  // R3(2026-09-18):降级台账。挂在引擎上,与各 host 同层 —— 跨臂统一,不分散到各 host。
@@ -9107,10 +9695,25 @@ export function apply(ctx, config) {
9107
9695
  // 注意:**绝不能**把常规观测塞进 `_degradeSink.record` —— 台账判据是「只记预期外失败」,
9108
9696
  // 混入后「有没有降级」将永远非空,降级信号被淹没。
9109
9697
  engine._quotaProbe = createQuotaProbePre({})
9698
+ // ③ 召回统计(2026-09-22 用户拍板「先只记录、不加权」)—— 数据源给面板「统计」页签。
9699
+ // ★为什么只记录:加权会自我强化(召回多→权重高→更易被召回),在埋点口径被真实数据检验前加权
9700
+ // 会把口径错误放大成系统性偏差。故本模块**只累积计数并落盘**,召回排序与返回逐字节不变
9701
+ // (守卫 smoke-test-recall-stats-pre.mjs 断言:埋点调用不改任何排序输入)。
9702
+ // file 传**函数**而非字符串:DSH_HOME 可能在运行期变化,每次落盘重新解析路径。
9703
+
9110
9704
  // 注:臂健康快照(deriveArmsHealthPre)在各臂执行路径上按实际情况标注,
9111
9705
  // 见 recall() 内 semantic/evidence 段与 _l0IndexSync 段。
9112
9706
  // M6-3:Activation Inbox Host 接线(assoc+activationInbox 双门;默认关闭)
9113
9707
  engine._activationHost = createActivationHost({ engine })
9708
+ // ★issue#104(2026-09-22 移植自 PR #118 / 孤儿快照 475abfe):把两个 host **回填给 runtime 释放路径**。
9709
+ // `SessionRuntimeStore.dispose()` 里按 `this._shadowHost` / `this._activationHost` 清理 per-runtime 态,
9710
+ // 而这两个属性只挂在 engine 上 ⇒ 不回填则两处 `if` 恒假、清理恒不执行(激活态与 shadow 态随会话数单调增长)。
9711
+ // runtimes 在引擎构造期创建,早于各 host ⇒ 只能在此处回填。
9712
+ // ★回归警示:3.0.1 发布提交 53d20e7 曾整行删掉 activation 侧的回填,而旧测试只断言 dispose
9713
+ // 函数体里的**字符串** ⇒ 静默回退。现由 smoke-test-issue103-105-portfix-pre.mjs 的「消费侧行为」
9714
+ // 断言 + 本处「生产侧成对」断言双锁住守,删任一侧必红。
9715
+ engine.runtimes._shadowHost = engine._shadowHost
9716
+ engine.runtimes._activationHost = engine._activationHost
9114
9717
  // M7-8:Host Index Sync Orchestrator(四门全开才启用;默认关闭零 IO;修复 M7-8 Phase E blocker)
9115
9718
  engine._indexSyncHost = createIndexSyncHostPre({
9116
9719
  engine,
@@ -9135,20 +9738,36 @@ export function apply(ctx, config) {
9135
9738
  // (此前 io 缺省 no-op → 重启清零)。落盘为快照整体写,数据量有界(episodes≤retention/facts/过程中)。
9136
9739
  {
9137
9740
  const hubDir = path.join(dshHome(), 'memory', 'hub')
9138
- const hubIo = (name) => {
9139
- const f = path.join(hubDir, name)
9140
- return {
9141
- save(data) {
9142
- try { mkdirSync(hubDir, { recursive: true }); const tmp = f + '.tmp'; writeFileSync(tmp, JSON.stringify(data), 'utf8'); renameSync(tmp, f) } catch (_) {}
9143
- },
9144
- load() { try { return JSON.parse(readFileSync(f, 'utf8')) } catch (_) { return null } },
9145
- clear() { try { rmSync(f, { force: true }) } catch (_) {} },
9146
- }
9147
- }
9741
+ // ★#110(2026-09-22):hub 持久化 IO 失败**可见化**(实现已抽到 `hub-io.js`,本处只做接线)。
9742
+ // 旧实现三个方法各自 `catch (_) {}` 静默吞异常,后果有两层:
9743
+ // ① 异常在**适配器这一层**就被吃掉 ⇒ 三店 A-8 的 `try { io.save() } catch` 永远走不到 catch 分支
9744
+ // ⇒ store 照样报 `{ok:true, persisted:true}`,上层统计与返回值全是绿的;
9745
+ // ② 用户侧表现为「记忆看着存上了,重启清零」,日志、计数、面板三处都拿不到信号。
9746
+ // 现在:save/clear 失败**照原样抛出**(让 A-8 的既有 try/catch 真正生效)+ 每次失败记账到
9747
+ // `engine._hubIoHealth`(人话原因 + 累计次数 + 时间),经 debugInfo().associativeMemory.hubIo
9748
+ // 与诊断日志(节流 5 分钟/键)输出;load 的「无文件/损坏 → null(空启动)」语义不变。
9749
+ const hubIoHealth = createHubIoHealthPre()
9750
+ engine._hubIoHealth = hubIoHealth
9751
+ const hubIo = createHubIoPre({
9752
+ dir: hubDir,
9753
+ health: hubIoHealth,
9754
+ onError: (key, msg) => diagThrottled('hubIo:' + key, msg),
9755
+ })
9756
+ // ★#110:把 io 工厂挂到 engine,供 hub 喂数循环做**批内合并落盘**(`beginBatch` / `endBatch`)。
9757
+ // 喂数循环在另一个块作用域里,拿不到本块的 `hubIo` 局部变量,只能走 engine 这条既有通道。
9758
+ engine._hubIoFactory = hubIo
9759
+ // ★P0-3(2026-09-22):事实库此前是**内联**创建在下面的 stores 字面量里、engine 上无引用。
9760
+ // 这里提到局部常量并单独挂一份,供 debugInfo() 的只读投影(factsPrune)使用。
9761
+ // 不改任何既有行为:stores.facts 仍是同一个实例,只是有了名字。
9762
+ const factStore = createFactStorePre({ config: { maxFacts: Number(engine.config.factRetentionMax) || 1000 }, io: hubIo('facts.json') })
9763
+ engine._factStore = factStore
9148
9764
  engine._memoryHub = createMemoryHubPre({
9765
+ // issue #110:把 io 工厂的批控制交给 hub 本身 ⇒ 所有批量喂数入口(喂数循环、HTTP
9766
+ // `action=feed`、将来的重放器)都自动「一批一次落盘」,不必各自记得在外面 begin/end。
9767
+ batch: hubIo,
9149
9768
  stores: {
9150
9769
  episodic: createEpisodicStorePre({ config: { minSegments: Number(engine.config.episodicMinSegments) || 2, retention: Number(engine.config.episodicRetention) || 256 }, io: hubIo('episodes.json') }),
9151
- facts: createFactStorePre({ io: hubIo('facts.json') }),
9770
+ facts: factStore,
9152
9771
  procedures: createProcedureStorePre({
9153
9772
  // 2026-08-30 修复:gates 用 getter 活读 engine.config——挂载(apply 同步)时
9154
9773
  // engine.config 尚未 loadConfig,静态读会冻结 DEFAULT(3/2),设置页改门槛永不生效
@@ -9156,7 +9775,7 @@ export function apply(ctx, config) {
9156
9775
  // 0 值合法(验证用);仅非有限负值回退默认——不可用 || 0 会 falsy 穿透
9157
9776
  get minSessionDiversity() { const v = Number(engine.config.procedureMinSessions); return (Number.isFinite(v) && v >= 0) ? v : 3 },
9158
9777
  get minSuccessCount() { const v = Number(engine.config.procedureMinSuccess); return (Number.isFinite(v) && v >= 0) ? v : 2 },
9159
- get maxCorrectionRate() { return Number(engine.config.procedureCorrectionCap) || 0.3 },
9778
+ get maxCorrectionRate() { const v = Number(engine.config.procedureCorrectionCap); return (Number.isFinite(v) && v >= 0) ? v : 0.3 },
9160
9779
  get maxContradictions() { return 0 },
9161
9780
  get highRiskRequiresApproval() { return engine.config.procedureHighRiskApproval !== false },
9162
9781
  },
@@ -9204,7 +9823,15 @@ export function apply(ctx, config) {
9204
9823
  // 写回:P1④ 主闭环——hub.facts 中 confirmed/未过期/未撤销/置信≥0.6 的事实,每日限额
9205
9824
  // 治理式写入 notesPath/userFile(autoConsolidate 同款 appendText 原子事务)→ 进 M7 语料。
9206
9825
  {
9207
- const hubFeedState = { shadow: null } // 文件指纹 {size,mtimeMs,count}
9826
+ const hubFeedState = { shadow: null } // 文件指纹 {size,mtimeMs}
9827
+ // ★issue#103(2026-09-22 移植自 PR #119):环形 JSONL 的增量游标 —— **内容指纹**,替代原来的行数游标。
9828
+ // 为什么不能用行数:该文件由 Python 侧按「保尾丢弃」维护(SHADOW_LOG_MAX=256,每次重写为
9829
+ // lines[-256:])⇒ 写满后 lines.length 恒等于上次的 count,增量区间恒为空 ⇒ 消费端永久停摆,
9830
+ // 且因为「没有新行」是合法状态而**不留任何痕迹**、事后不可发现。
9831
+ // maxSeen 取环形上限 256 的 4 倍,避免指纹被淘汰后旧行被重复交付。
9832
+ // ★本次移植实测教训:锚点不匹配会让整块补丁被静默跳过,而 `node --check` 与静态守卫**都不会报**
9833
+ // (引用在另一处)⇒ 运行期才炸 ReferenceError。故补丁脚本必须配「声明与使用成对」的哨兵断言。
9834
+ const hubFeedCursor = createJsonlTailCursorPre({ maxSeen: 1024 })
9208
9835
  const hubFlushState = { date: '', count: 0, flushed: {} } // flushed[factId]=true;持久化 hub/flush-state.json
9209
9836
  const hubFlushFile = () => path.join(dshHome(), 'memory', 'hub', 'flush-state.json')
9210
9837
  const hubFlushLoad = () => {
@@ -9250,33 +9877,49 @@ export function apply(ctx, config) {
9250
9877
  const f = path.join(dshHome(), 'memory', 'semantic', 'judgement-shadow.jsonl')
9251
9878
  let st
9252
9879
  try { st = statSync(f) } catch (_) { return }
9253
- const prev = hubFeedState.shadow
9254
- if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs) return
9255
9880
  const lines = readFileSync(f, 'utf8').split('\n').filter((l) => l.trim())
9256
- const start = prev && lines.length >= prev.count ? prev.count : lines.length // 文件被截断则只取尾部
9881
+ // ★issue#103(2026-09-22 移植自 PR #119):游标**不能再用行数**。该文件由 Python 侧按
9882
+ // 「保尾丢弃」维护(SHADOW_LOG_MAX=256,每次重写为 lines[-256:])⇒ 写满后 lines.length 恒等于
9883
+ // 上次的 count,增量区间恒为空、消费端永久停摆,而且"没有新行"是合法状态、不留任何痕迹。
9884
+ // 改为行内容指纹:环内每行只交付一次,与截断无关。(首次 take() 只登记现状不喂历史行,
9885
+ // 避免重启后把环里旧判据再吃一遍导致 observe 重复计数。)
9886
+ const cursor = (hubFeedCursor.take(lines).fresh)
9257
9887
  const newRows = []
9258
- for (let i = start; i < lines.length; i++) {
9259
- try { const d = JSON.parse(lines[i]); if (d && d.kindCandidate) newRows.push(d) } catch (_) {}
9888
+ for (let i = 0; i < cursor.length; i++) {
9889
+ try { const d = JSON.parse(cursor[i]); if (d && d.kindCandidate) newRows.push(d) } catch (_) {}
9260
9890
  }
9261
- hubFeedState.shadow = { size: st.size, mtimeMs: st.mtimeMs, count: lines.length }
9891
+ hubFeedState.shadow = { size: st.size, mtimeMs: st.mtimeMs }
9262
9892
  if (!newRows.length) return
9263
9893
  // 异步富化+喂送(不阻塞 tick);单行失败静默
9264
9894
  void (async () => {
9265
9895
  let fed = 0
9266
- for (const row of newRows) {
9267
- try {
9268
- const src = Array.isArray(row.sourceIds) ? row.sourceIds[0] : null
9269
- if (src) {
9270
- const rec = await hubCorpusLookup(src)
9271
- if (rec) {
9272
- row.subject = String(rec.heading || rec.text || '').split('\n')[0].slice(0, 30)
9273
- row.predicate = '记录要点'
9274
- row.object = String(rec.text || '').slice(0, 120)
9896
+ // ★#110(2026-09-22):批内合并落盘 —— 旧实现每行一次 upsert ⇒ **一次整份快照写盘**,
9897
+ // 一批 N 行就是 N 次写放大。这里用 hubIo 的批控制把整批收敛成「批末最多一次/文件」。
9898
+ // 批末一定 flush(finally),任何一行抛错也不会把已接受的改动留在内存不落盘。
9899
+ const ioFactory = engine._hubIoFactory
9900
+ try {
9901
+ if (ioFactory && typeof ioFactory.beginBatch === 'function') ioFactory.beginBatch()
9902
+ for (const row of newRows) {
9903
+ try {
9904
+ const src = Array.isArray(row.sourceIds) ? row.sourceIds[0] : null
9905
+ if (src) {
9906
+ const rec = await hubCorpusLookup(src)
9907
+ if (rec) {
9908
+ row.subject = String(rec.heading || rec.text || '').split('\n')[0].slice(0, 30)
9909
+ row.predicate = '记录要点'
9910
+ row.object = String(rec.text || '').slice(0, 120)
9911
+ }
9275
9912
  }
9276
- }
9277
- const r = hub.ingestJudgement(row)
9278
- if (r && r.consumed) fed++
9279
- } catch (_) {}
9913
+ const r = hub.ingestJudgement(row)
9914
+ if (r && r.consumed) fed++
9915
+ } catch (_) {}
9916
+ }
9917
+ } finally {
9918
+ if (ioFactory && typeof ioFactory.endBatch === 'function') {
9919
+ const b = ioFactory.endBatch()
9920
+ if (b && b.written) diag('hub feed batch: ' + b.written + ' 份快照批量落盘(合并 ' + newRows.length + ' 行喂入)')
9921
+ if (b && b.ok === false) diag('hub feed batch: 有快照落盘失败 —— 原因见 hubIo 健康度(debugInfo().associativeMemory.hubIo)')
9922
+ }
9280
9923
  }
9281
9924
  if (fed) diag('hub feed: +' + fed + '/' + newRows.length + ' rows consumed')
9282
9925
  })()
@@ -9852,6 +10495,9 @@ export function apply(ctx, config) {
9852
10495
  try { if (engine._ownSubagents && engine._ownSubagents.has(agent)) return } catch (e) {}
9853
10496
  // 最后活动时间写入该 agent 自己的 runtime(不再落到 currentRuntime()/default)
9854
10497
  try { engine.runtimeFor(agent).lastActiveAt = Date.now() } catch (e) {}
10498
+ // ★2026-09-22(用户拍板):新工作区自动首建白板骨架 —— 只写一次、fail-soft、不阻塞收尾。
10499
+ // 放在水位测量之前:首建是"产物存在性"兜底,与水位阈值无关,新工作区首轮就该落下来。
10500
+ try { void engine.ensurePlanBoardForAgentPre(agent).catch(function () {}) } catch (eSeed) {}
9855
10501
  // M-CM4 水位感知:每轮测量会话消息体量,越阈值→advisory+自动骨架账本(异步,不阻塞收尾)
9856
10502
  try { void engine.checkWaterLevel(agent).then(function () {
9857
10503
  // M-CM6-C 宿主兜底:轮末测量完成后若达标 → arm 倒计时(浏览器活着时由它展示确认卡;被节流/关闭则宿主到期自执行)
@@ -10113,7 +10759,7 @@ export function apply(ctx, config) {
10113
10759
  return '已更新记忆文档: ' + logPath + '\n' + entry + (gate.truncated ? '\n(内容超长,已截断)' : '')
10114
10760
  }),
10115
10761
 
10116
- defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点),或写交接白板。kind=note(默认):action=append 追加一段(自动带日期标题)/action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容),本项目笔记有**容量上限**(字符,设置项 noteCapacityChars,默认 24000);**超出时的行为**(你不需要自己控制长度,写就是):①先把较早内容交给 AI 折叠成要点(同一层 10 分钟内只整理一次);②整理失败则**整条原文**归档到 `.dsh-memory/archive/`(信息不丢);③整理后仍超才拒绝(正常不会发生)。kind=handoff:写一篇四段式交接账本(任务状态/目标/已试方案与失败原因/进度与下一步)到 handoff/,阶段产出或方向变化时用,给下一个上下文窗口续命;**账本/白板内容要落进面板看板泳道,须在标题或正文写 tag**:type:goal / type:state / type:dead-end / type:progress。kind=plan:整体重写白板 PLAN.md(人能读的项目全貌规划图),对项目全貌的理解发生实质变化时用,旧版自动归档。**结论失效时的两个通道(不要混用)**:supersedes=被更新结论取代(有后继);retract=**当时就做错了、直接撤回**(无后继,本身即教训)。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
10762
+ defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点),或写交接白板。kind=note(默认):action=append 追加一段(自动带日期标题)/action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容),本项目笔记有**容量上限**(字符,设置项 noteCapacityChars,默认 24000);**超出时的行为**(你不需要自己控制长度,写就是):①先把较早内容交给 AI 折叠成要点(同一层 10 分钟内只整理一次);②整理失败则**整条原文**归档到 `.dsh-memory/archive/`(信息不丢);③整理后仍超才拒绝(正常不会发生)。kind=handoff:写一篇四段式交接账本(任务状态/目标/已试方案与失败原因/进度与下一步)到 handoff/,阶段产出或方向变化时用,给下一个上下文窗口续命;**账本/白板内容要落进面板看板泳道,须在标题或正文写 tag**:type:goal / type:state / type:dead-end / type:progress。kind=plan:整体写入/重写白板 PLAN.md(人能读的项目全貌规划图)。**首建亦可**——工作区若还没有白板,宿主已自动落了一版骨架,你理解全貌后直接整体重写成真内容即可;对项目全貌的理解发生实质变化时同样重写。旧版自动归档。**别把骨架留在那里当白板**。**结论失效时的两个通道(不要混用)**:supersedes=被更新结论取代(有后继);retract=**当时就做错了、直接撤回**(无后继,本身即教训)。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
10117
10763
  content: { type: 'string', required: true, description: '笔记内容。kind=plan 时给完整新全貌(不是增量)。' },
10118
10764
  action: { type: 'string', enum: ['append', 'replace'], description: '仅 kind=note 时有效:append=追加, replace=整体替换。' },
10119
10765
  kind: { type: 'string', enum: ['note', 'handoff', 'plan'], description: 'note=项目笔记(默认), handoff=四段式交接账本(新篇), plan=白板全貌重写。' },
@@ -10150,7 +10796,13 @@ export function apply(ctx, config) {
10150
10796
  else { engine.state.latestHandoffText = '# 交接账本 · ' + engine.memToday() + ' ' + nowHm() + '\n\n' + ((r && r.clean) || writeH); engine.state.loadedAt = Date.now() }
10151
10797
  return (args.kind === 'plan'
10152
10798
  ? '白板 PLAN.md 已更新' + (r.archived ? '(旧版已归档: ' + r.archived + ')' : '(首建)') + ': ' + r.path
10153
- : '交接账本已写入: ' + r.path) + '\n请在本轮回复正文向用户转述本次更新要点。'
10799
+ : '交接账本已写入: ' + r.path)
10800
+ // ★ 2026-09-21 bugfix(A-2):handoff 此前**静默截断** —— sanitizeForWrite 超限时是
10801
+ // 「截断 + truncated:true」返回 ok(:8625-8628),而本分支接住了 gateH 却从不外显该标志
10802
+ // (对照 note 分支 :10184 会拼提示)。账本四段固定以「进度与下一步」结尾,截断恰好砍掉它,
10803
+ // 模型却收到「已写入」⇒ 跨窗口续命材料无声缩水。现与 note 分支对齐。
10804
+ + (gateH.truncated ? '\n⚠ 账本超长已截断到 ' + writeH.length + ' 字符(上限 ' + (args.kind === 'plan' ? 200000 : 8000) + '),尾部「进度与下一步」可能已丢失 —— 请精简后重写。' : '')
10805
+ + '\n请在本轮回复正文向用户转述本次更新要点。'
10154
10806
  }
10155
10807
  const replace = args.action === 'replace'
10156
10808
  // 写闸门: append 单条上限 8000 字; replace(整篇重写)放行到 20 万字, 但同样经受乱码/复读/重复块质量闸门
@@ -10322,12 +10974,47 @@ export function apply(ctx, config) {
10322
10974
  title: { type: 'string', required: true, description: '事项标题。' },
10323
10975
  }, async (args, exec) => engine.calendarRemove(args.date, args.time, args.title, exec.agent)),
10324
10976
 
10977
+ defineTool('memory_rules', '用户级硬性约束(每轮无条件注入的 [规则] 段,真源 ~/.dsh/memory/MEMORY.md)的**条目级增删改**。op=list 列出全部条目(0 基序号/来源日期段/文本);op=add 追加;op=update 改写一条;op=remove **真删**一条。★**删/改必须带 expect**(=该条**当前**文本,从 list 原样回填),不符即拒 —— 防索引漂移删错行。★这段**每轮注入且不参与裁剪**,所以它同时是 prompt 成本的主要来源:清理过时条目的首选手段就是本工具的 remove(而不是把整篇用户级记忆用 memory_user replace 重写)。★status 被标 superseded/retracted 的条目自 P9 起**不再进规则段**,但仍留在 list 里(要彻底消失用 remove;要软失效用 memory_note 的 retract/supersedes)。**调用后必须在本轮回复正文中向用户转述改了什么**。', {
10978
+ op: { type: 'string', enum: ['list', 'add', 'update', 'remove'], required: true, description: 'list=列出; add=追加; update=改写一条(需 index+expect+content); remove=真删一条(需 index+expect)。' },
10979
+ index: { type: 'number', description: '条目序号(0 基,来自 op=list 的顺序)。' },
10980
+ expect: { type: 'string', description: '该条**当前**文本(内容锚定,防索引漂移)。update/remove 必填。' },
10981
+ content: { type: 'string', description: 'update/add 的新文本(单行,≤2000 字符)。' },
10982
+ dateSection: { type: 'string', description: 'add 可选:追加到指定日期段(YYYY-MM-DD);缺省追加到文件头部(用户手写区)。' },
10983
+ }, async (args) => {
10984
+ const op = String(args.op || '')
10985
+ if (op === 'list') {
10986
+ const r0 = await applyRuleEditPre(engine, 'list', {})
10987
+ if (!r0.ok) return 'memory_rules: ' + r0.error
10988
+ const rows = (r0.items || []).map((x, i) => '[' + i + '] (' + x.source + (x.dateSection ? ' ' + x.dateSection : '') + ') ' + x.text)
10989
+ let extra = ''
10990
+ try {
10991
+ const lay = extractRulesLayerPre({ userText: await engine.readTextSafe(r0.path), rulesLayeringMode: 'self' })
10992
+ const n = (lay.retired || []).length
10993
+ if (n) extra = '\\n注:其中 ' + n + ' 条 status≠current,**已不再进规则段**(仍可用 remove 真删)。'
10994
+ } catch (_) {}
10995
+ return '用户级硬约束共 ' + rows.length + ' 条(0 基):\\n' + rows.join('\\n') + extra
10996
+ }
10997
+ if (op === 'add') {
10998
+ const gate = sanitizeForWrite(String(args.content || ''), { maxEntryChars: 2000 })
10999
+ if (!gate.ok) return 'memory_rules: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。'
11000
+ const ra = await applyRuleEditPre(engine, 'add', { text: gate.clean || String(args.content || ''), dateSection: args.dateSection })
11001
+ if (!ra.ok) return 'memory_rules: ' + ra.error
11002
+ return '已追加规则,现共 ' + (ra.items || []).length + ' 条。\\n' + ra.preview
11003
+ }
11004
+ const r = await applyRuleEditPre(engine, op, { index: args.index, expect: args.expect, text: args.content }, { requireExpect: true })
11005
+ if (!r.ok) return 'memory_rules: ' + r.error
11006
+ return (op === 'remove' ? '已删除' : '已改写') + '第 ' + args.index + ' 条,现共 ' + (r.items || []).length + ' 条。\\n' + r.preview
11007
+ }),
11008
+
10325
11009
  defineTool('memory_consolidate', 'AI 主动维护长期记忆(做梦式固化):读最近 days 天的工作日志,由 AI 发散提炼出有跨会话长期价值的决策/架构/用户偏好,自动写入项目笔记 MEMORY.md(带日期标题)与用户级 MEMORY.md(跨项目规则),并在正文向用户转述固化结果。★**与「自动沉淀」的边界**:每轮对话结束框架会**自动**评估并写日志/升格要点,**你不需要为日常轮次做这件事**;本工具是**你主动发起的加料**——隔一段时间(或一个阶段收尾时)用来做一次更彻底的提炼,读的是**多日日志**、输出进项目笔记与用户级记忆。⇒ 用它做「阶段性固化」,不要用它替代每轮的 memory_log。', {
10326
11010
  days: { type: 'integer', description: '读取最近 N 天日志,缺省 7,上限 30。' },
10327
11011
  }, async (args, exec) => engine.consolidateMemory(exec.agent, Math.min(Math.max(Number(args.days) || 7, 1), 30))),
10328
11012
 
10329
11013
  // ★★★ T4(2026-09-19 用户拍板「让大模型来介入 procedure memory」)★★★
10330
- // **工具数 16→17**(无条件注册;四处硬锁联动:smoke-test.mjs / m3b3 / context-observer / graph-mode)。
11014
+ // **工具数 17→18**(无条件注册;P9 新增 memory_rules)。★硬锁联动**六处**(原注释只写四处,
11015
+ // 实测还漏了 p23-wb-sidecar 与 docs/HANDBOOK.md §6 表 ⇒ 加工具时一并改):
11016
+ // tests/smoke/smoke-test.mjs / m3b3-pre / context-observer / graph-mode-pre / p23-wb-sidecar-pre / docs/HANDBOOK.md。
11017
+ // 另有发版侧两表必然漏登记(memory_rules,见 tools/release.mjs 的 T7-e 注与 smoke-test-t7e)。
10331
11018
  // 背景:此前三条记忆线(episodic / semantic / procedural)**全部只有机械生成**,
10332
11019
  // 模型没有任何写入通路。procedural 线的唯一来源是 `memory-hub.js` 的 crossFeed:
10333
11020
  // 它把每个"成功 episode"机械切成候选,而 episode 的 actions 就是 `['user','user','user']`
@@ -10383,8 +11070,26 @@ export function apply(ctx, config) {
10383
11070
  // —— action=activate:模型授权跳统计门 → 晋升 → 激活 → 导出 SKILL.md ——
10384
11071
  const pr = procs.promote(pid, {}, { authorizedBy: 'model' })
10385
11072
  if (!pr || !pr.ok || pr.decision !== 'promote') {
11073
+ // ★ B-1 配套(用户 2026-09-22 方针①「模型友好」+ ②「原因要人能看懂」):
11074
+ // 旧文案只甩 decision + reasonCodes 机器码,模型读完不知道下一步该做什么,
11075
+ // 遇到 high-risk 就以为"卡死了"。现在补一层**人话解释 + 可执行的下一步**,
11076
+ // 机器码原样保留(便于日志/前端复用),模型据此能自己决定是补 successCriteria、
11077
+ // 还是提示用户去「记忆中枢」点「批准」。
11078
+ const codes = ((pr && pr.reasonCodes) || []).map(String)
11079
+ const EXPLAIN = {
11080
+ 'high-risk-awaiting-approval': '该条被标为高风险,需要**用户在「记忆中枢」页签点「批准」**后才能晋升(批准只解这一道门,其余门槛不变)。你可以告诉用户这条待批准。',
11081
+ 'no-success-criteria': '缺少 successCriteria ⇒ 结构上无法晋升。请用 memory_procedure 补写「怎么算跑通」再调 action=activate。',
11082
+ 'observation-only': '这条是纯 episode 观察行(没有 steps/successCriteria),设计上不参与晋升;请改为写入一条真正的技能条目。',
11083
+ 'has-correction': '该条存在纠正记录(maxContradictions=0)⇒ 先解决矛盾,不要强推。',
11084
+ }
11085
+ const c3 = codes[0]
11086
+ const human = EXPLAIN[c3] || (codes.find((c) => EXPLAIN[c]) ? EXPLAIN[codes.find((c) => EXPLAIN[c])] : '')
11087
+ const corrCode = codes.find((c) => c.startsWith('correction-rate-'))
10386
11088
  return out + '\n未激活:晋升未通过(decision=' + String((pr && pr.decision) || '?') +
10387
- ' reasonCodes=' + JSON.stringify((pr && pr.reasonCodes) || []) + ')。条目仍在审批列表。'
11089
+ ' reasonCodes=' + JSON.stringify(codes) + ')。条目仍在审批列表。' +
11090
+ (human ? '\n→ ' + human : '') +
11091
+ (corrCode ? '\n→ 纠正率超过当前上限(' + corrCode + '):说明该条历史执行里被纠正过,先改进流程再重试。' : '') +
11092
+ (codes.includes('diversity-below-3') ? '\n→ 跨会话证据不足(需要至少 3 个不同会话的成功记录):继续在别的会话里用一次,或由用户手动晋升。' : '')
10388
11093
  }
10389
11094
  const ar = procs.activate(pid)
10390
11095
  if (!ar || !ar.ok) return out + '\n已晋升但激活失败:' + String((ar && ar.reason) || 'unknown')
@@ -10889,7 +11594,24 @@ export function apply(ctx, config) {
10889
11594
  const body = await readJsonBody(req).catch(() => ({}))
10890
11595
  const action = String((body && body.action) || '')
10891
11596
  if (action === 'consolidate') return writeJson(res, 200, hub.consolidateEpisodes())
10892
- if (action === 'feed') return writeJson(res, 200, hub.ingestJudgementRows(Array.isArray(body.rows) ? body.rows : []))
11597
+ if (action === 'feed') {
11598
+ // ★#110(2026-09-22):HTTP feed 入口也要走批内合并落盘 —— 旧实现直调 ingestJudgementRows,
11599
+ // 每行一次 upsert 就是一次整份快照写盘(N 行 = N 次写放大)。批控制与 hub 喂数循环同源,
11600
+ // 挂在 engine._hubIoFactory;批末一定 flush(finally),任何行抛错也不会把已接受的改动留在内存。
11601
+ const rows = Array.isArray(body.rows) ? body.rows : []
11602
+ const ioFactory = engine._hubIoFactory
11603
+ let fedResult
11604
+ try {
11605
+ if (ioFactory && typeof ioFactory.beginBatch === 'function') ioFactory.beginBatch()
11606
+ fedResult = hub.ingestJudgementRows(rows)
11607
+ } finally {
11608
+ if (ioFactory && typeof ioFactory.endBatch === 'function') {
11609
+ const b = ioFactory.endBatch()
11610
+ if (b && b.ok === false) diag('hub feed(http) batch: 有快照落盘失败 —— 原因见 hubIo 健康度(debugInfo().associativeMemory.hubIo)')
11611
+ }
11612
+ }
11613
+ return writeJson(res, 200, fedResult)
11614
+ }
10893
11615
  if (action === 'render') return writeJson(res, 200, { checklists: hub.renderChecklists() })
10894
11616
  if (action === 'crossfeed') return writeJson(res, 200, hub.crossFeed(String((body && body.sessionRef) || '')))
10895
11617
  // M9 审批动作(2026-08-30,G-02 同款 loopback+append-only 精神):用户在 hubTab
@@ -10897,11 +11619,29 @@ export function apply(ctx, config) {
10897
11619
  // 不绕过任何 gate;每次动作进 diagnose 审计。
10898
11620
  const procs = hub.stores && hub.stores.procedures
10899
11621
  const pid = String((body && body.procedureId) || '')
10900
- if (['promote', 'activate', 'deprecate', 'pin'].includes(action)) {
11622
+ // ★ B-1 修复(2026-09-22):白名单补 `approve`。
11623
+ // 旧白名单只有 promote/activate/deprecate/pin,而高风险条目建候选时恒 `approved:false`
11624
+ // (procedure-store.js:235)且全仓无任何代码能置真,promote 只会返回 decision:'ask'
11625
+ // ⇒ 高风险条目的晋升是**死路**。现在把"人来确认"这只手补上:approve 只解一道门,
11626
+ // 其余五道结构门(已弃用 / 观察型 / 缺 successCriteria / 纠正率超限 / 存在纠正)照旧拦。
11627
+ // ★B(2026-09-22):白名单补 `force-promote`(人工**强制晋升**)。
11628
+ // 动机(有数据证据):只读投影 evaluatePromotion 对盘上 30 条**全部**返回 keep,
11629
+ // ⇒ 按 A-9 的门控,界面上一个可点的晋升按钮都没有("手动通道看起来被关掉了")。
11630
+ // 其中「只被 diversity/success 两道统计门拦住」的行(实际 14 条里绝大多数)
11631
+ // 在语义上是可以人工越过的 —— promote() 的 `if (!authorizedBy)` 正是为这场景留的口子。
11632
+ if (['promote', 'force-promote', 'approve', 'activate', 'deprecate', 'pin'].includes(action)) {
10901
11633
  if (!procs) return writeJson(res, 200, { ok: false, reason: 'no-procedure-store' })
10902
11634
  if (!pid) return writeJson(res, 400, { error: 'procedureId required' })
10903
11635
  let r
10904
- if (action === 'promote') r = procs.promote(pid)
11636
+ if (action === 'approve') r = procs.approve(pid, 'user')
11637
+ else if (action === 'promote') r = procs.promote(pid)
11638
+ // ★B(2026-09-22):人工强制晋升 —— `authorizedBy:'user'`。
11639
+ // · 只越过 diversity / success 两道**统计门**;
11640
+ // · 结构门(deprecated / observationOnly / no-success-criteria / has-correction /
11641
+ // correction-rate / 高风险待批准)**照旧拦** —— 与 T4 冻结的"授权语义"逐字一致;
11642
+ // · 留痕:晋升成功会写 `p.authorizedBy = 'user'`,reasonCodes 记 `user-authorized`,
11643
+ // 与模型授权(`model-authorized`)在审计面可区分。
11644
+ else if (action === 'force-promote') r = procs.promote(pid, {}, { authorizedBy: 'user' })
10905
11645
  else if (action === 'activate') {
10906
11646
  r = procs.activate(pid)
10907
11647
  // ★ ⑪-2(用户 2026-09-19 拍板):晋升为 active 后**自动导出** SKILL.md。
@@ -11083,6 +11823,46 @@ export function apply(ctx, config) {
11083
11823
  } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
11084
11824
  },
11085
11825
  },
11826
+ {
11827
+ kind: 'exact',
11828
+ path: API['migrate-export'],
11829
+ handler: async (req, res) => {
11830
+ // 迁移搬包 P1:导出。loopback-only(记忆含隐私,绝不对外)。
11831
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
11832
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
11833
+ const body = await readJsonBody(req).catch(() => ({}))
11834
+ try {
11835
+ const ws = String((body && body.ws) || (engine._lastAgent && engine._lastAgent.cwd) || '')
11836
+ writeJson(res, 200, await engine.migrateExport({ ws, outPath: body && body.outPath, compress: !(body && body.compress === false) }))
11837
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
11838
+ },
11839
+ },
11840
+ {
11841
+ kind: 'exact',
11842
+ path: API['migrate-inspect'],
11843
+ handler: async (req, res) => {
11844
+ // 迁移搬包 P2:预览差异(只读,不写任何文件)。
11845
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
11846
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
11847
+ const body = await readJsonBody(req).catch(() => ({}))
11848
+ try {
11849
+ writeJson(res, 200, await engine.migrateInspect({ packPath: body && body.packPath, targetWs: body && body.targetWs, onConflict: body && body.onConflict, rewriteBody: !(body && body.rewriteBody === false) }))
11850
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
11851
+ },
11852
+ },
11853
+ {
11854
+ kind: 'exact',
11855
+ path: API['migrate-import'],
11856
+ handler: async (req, res) => {
11857
+ // 迁移搬包 P3:执行(先备份后落盘;冲突默认 keep)。
11858
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
11859
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
11860
+ const body = await readJsonBody(req).catch(() => ({}))
11861
+ try {
11862
+ writeJson(res, 200, await engine.migrateImport({ packPath: body && body.packPath, targetWs: body && body.targetWs, onConflict: body && body.onConflict, rewriteBody: !(body && body.rewriteBody === false) }))
11863
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
11864
+ },
11865
+ },
11086
11866
  {
11087
11867
  kind: 'exact',
11088
11868
  path: API.debug,
@@ -11159,6 +11939,28 @@ export function apply(ctx, config) {
11159
11939
  },
11160
11940
  },
11161
11941
  {
11942
+ kind: 'exact',
11943
+ path: API.recallStats,
11944
+ handler: async (req, res) => {
11945
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
11946
+ const method = req.method || 'GET'
11947
+ // 只读:GET 取快照;POST 仅用于显式复位(面板按钮),同样 loopback-only。
11948
+ if (method !== 'GET' && method !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
11949
+ try {
11950
+ if (!engine._recallStats) return writeJson(res, 200, { enabled: false })
11951
+ if (method === 'POST') {
11952
+ const url = new URL(req.url || '/', 'http://localhost')
11953
+ if (url.searchParams.get('reset') === '1') {
11954
+ engine._recallStats.reset()
11955
+ return writeJson(res, 200, { ok: true, reset: true })
11956
+ }
11957
+ return writeJson(res, 400, { error: 'unknown action' })
11958
+ }
11959
+ writeJson(res, 200, { enabled: true, stats: engine._recallStats.snapshot() })
11960
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
11961
+ },
11962
+ },
11963
+ {
11162
11964
  kind: 'exact',
11163
11965
  path: API.updateCheck,
11164
11966
  handler: async (req, res) => {
@@ -11205,7 +12007,15 @@ export function apply(ctx, config) {
11205
12007
  const method = req.method || 'GET'
11206
12008
  try {
11207
12009
  if (method === 'GET') {
11208
- writeJson(res, 200, { config: await engine.loadConfig(), path: engine._configPath })
12010
+ // ★P10-T1(2026-09-22):把「可关的注入分区」清单**从宿主常量发给前端**——设置页
12011
+ // 二级页据此枚举开关,前端不得硬编码第二份清单(守卫断言集合相等)。
12012
+ // 搭本路由的便车而非新开路由:避免 API 路径锁(A1/A2/A4)三处同步的额外风险。
12013
+ writeJson(res, 200, {
12014
+ config: await engine.loadConfig(),
12015
+ path: engine._configPath,
12016
+ promptSections: PROMPT_SECTION_KEYS_V1,
12017
+ promptSectionMust: PROMPT_SECTION_MUST_V1,
12018
+ })
11209
12019
  return
11210
12020
  }
11211
12021
  if (method === 'POST' || method === 'PUT') {
@@ -11224,6 +12034,22 @@ export function apply(ctx, config) {
11224
12034
  patch[key] = body[key].map((x) => x.trim()).filter(Boolean)
11225
12035
  continue
11226
12036
  }
12037
+ // ★#117 P3-20②(2026-09-22):路径类键 fail-closed —— 旧实现对绝大多数键裸赋值,
12038
+ // 而 memoryRoot / userMemoryDir 就在 DEFAULT_CONFIG 键集内 ⇒ 一次 POST 可把后续
12039
+ // append/rewrite 的落点移出记忆区。判据:非空字符串 + 展开后必须落在 dshHome() 之下。
12040
+ // 注意:① 必须先经 expandUserPath 展开(默认值本身就带 `~`,否则默认值过不了自己的闸);
12041
+ // ② 用 path.relative 判,纯 startsWith 会漏放行 `~/.dsh/memory-backup` 这类前缀兄弟目录。
12042
+ // 通过校验后仍存**原样**(保持 DEFAULT_CONFIG 的 `~` 形书写约定),与 injectExcludeSources 同款 fail-closed 丢弃。
12043
+ if (key === 'memoryRoot' || key === 'userMemoryDir') {
12044
+ const rawPath = body[key]
12045
+ if (typeof rawPath !== 'string' || !rawPath.trim()) continue
12046
+ const expanded = engine.expandUserPath(rawPath)
12047
+ if (typeof expanded !== 'string' || !path.isAbsolute(expanded)) continue
12048
+ const rel = path.relative(dshHome(), expanded)
12049
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue
12050
+ patch[key] = rawPath
12051
+ continue
12052
+ }
11227
12053
  patch[key] = body[key]
11228
12054
  }
11229
12055
  const saved = await engine.saveConfig(patch)
@@ -11293,22 +12119,11 @@ export function apply(ctx, config) {
11293
12119
  const op = String((body && body.op) || '')
11294
12120
  if (!['add', 'update', 'remove'].includes(op)) return writeJson(res, 400, { error: 'invalid-op' })
11295
12121
  try {
11296
- const p = await engine.resolvePaths(undefined)
11297
- const before = (await engine.readTextSafe(p.userFile)) || ''
11298
- let r
11299
- if (op === 'add') r = appendRuleItemPre(before, body.text, { dateSection: body.dateSection })
11300
- else if (op === 'update') r = updateRuleItemPre(before, Number(body.index), body.text)
11301
- else r = removeRuleItemPre(before, Number(body.index))
11302
- if (!r.ok) return writeJson(res, 400, { error: '编辑被拒: ' + r.error })
11303
- // ★ 复用既有事务(备份 + 校验 + anchor 处理),绝不绕过
11304
- const written = await engine.writeFull(p.userFile, r.text)
11305
- const after = (await engine.readTextSafe(p.userFile)) || written || r.text
11306
- const items = listRuleItemsPre(after)
11307
- writeJson(res, 200, {
11308
- result: op + ' ok',
11309
- items,
11310
- preview: items.map((x) => '- ' + x.text).join('\n'),
11311
- })
12122
+ // ★P9:与模型工具 memory_rules 共用同一写盘口(requireExpect 保持 false ——
12123
+ // R7 的设计是「前端做二次确认,host 侧只如实执行」,不在此处引入第二套语义)
12124
+ const r = await applyRuleEditPre(engine, op, { index: body.index, text: body.text, dateSection: body.dateSection })
12125
+ if (!r.ok) return writeJson(res, r.error === 'invalid-op' ? 400 : 400, { error: r.error })
12126
+ writeJson(res, 200, { result: op + ' ok', items: r.items, preview: r.preview })
11312
12127
  } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
11313
12128
  },
11314
12129
  },
@@ -11503,7 +12318,7 @@ export function apply(ctx, config) {
11503
12318
  pid: process.pid,
11504
12319
  heartbeatAt: Date.now(),
11505
12320
  uptimeMs: Math.round(process.uptime() * 1000),
11506
- queueLength: q.length,
12321
+ queueLength: q,
11507
12322
  lastConsolidatedAt: (engine.autoStats && engine.autoStats.lastAt) || 0,
11508
12323
  todayCount: (engine.autoStats && engine.autoStats.count) || 0,
11509
12324
  }, null, 2), 'utf8')
@@ -11570,3 +12385,5 @@ export function apply(ctx, config) {
11570
12385
 
11571
12386
  /** 导出卫生守卫与脏 token 检查器(供 smoke-test / 回归测试直接调用)。 */
11572
12387
  export { sanitizeForWrite, hygieneGateForPrimitive, dirtyScanForFiles, mojibakeDensity, tailHas, hasStutter, WRITE_GATE_REASON, foldSessionLogEvents, workspaceIdForSession, attachmentsOfContent, attachmentBlobPathsPre, renderAttachmentLinesPre }
12388
+ /** 导出会话运行态仓库(issue#104):让 dispose 的清理接线能被行为测试直接驱动,不必靠断言源码字符串。 */
12389
+ export { SessionRuntimeStore }