@a9i5k4/dsh-auto-memory 3.0.0 → 3.0.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 (90) hide show
  1. package/README.md +19 -7
  2. package/README.zh-CN.md +19 -7
  3. package/docs/FRONTEND-CO-CREATION.md +191 -0
  4. package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
  5. package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
  6. package/docs/PROMO-PROMPT-3.0.md +100 -0
  7. package/docs/USER-GUIDE.en.md +2 -2
  8. package/docs/USER-GUIDE.zh-CN.md +2 -2
  9. package/docs/WHITEPAPER.md +207 -0
  10. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
  11. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
  12. package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
  13. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
  14. package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
  15. package/docs/internal/FEATURE-INVENTORY.md +531 -0
  16. package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
  17. package/docs/internal/G3-DESIGN-20260918.md +82 -0
  18. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
  19. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
  20. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
  21. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
  22. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
  23. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
  24. package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
  25. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
  26. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
  27. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
  28. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
  29. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
  30. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
  31. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
  32. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
  33. package/docs/internal/PROGRESS-20260917.md +93 -0
  34. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
  35. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
  36. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
  37. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
  38. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
  39. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
  40. package/docs/internal/RESUME-20260918.md +171 -0
  41. package/docs/internal/RESUME-20260919.md +104 -0
  42. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
  43. package/docs/internal/ROADMAP-20260917-WEEK.md +134 -0
  44. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +13 -3
  45. package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
  46. package/docs/internal/T6-EXECUTION-20260920.md +130 -0
  47. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
  48. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
  49. package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
  50. package/docs/internal/THREE-LAYER-CONTRACT.md +10 -1
  51. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
  52. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
  53. package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
  54. package/lib/activation-host.js +63 -9
  55. package/lib/board-mode.js +1 -1
  56. package/lib/client.js +892 -27
  57. package/lib/config-io.js +156 -0
  58. package/lib/context-bridge.js +3 -0
  59. package/lib/context-host.js +16 -9
  60. package/lib/degrade.js +385 -0
  61. package/lib/dsh-home.js +143 -0
  62. package/lib/episodic-store.js +52 -2
  63. package/lib/evidence-store.js +8 -1
  64. package/lib/fact-store.js +21 -2
  65. package/lib/index-sync.js +13 -1
  66. package/lib/index.js +1507 -158
  67. package/lib/intent-clean-safe.js +258 -40
  68. package/lib/l0-extract.js +231 -16
  69. package/lib/m4-corpus.js +8 -2
  70. package/lib/m7-index-sync-host.js +8 -1
  71. package/lib/memory-envelope.js +6 -1
  72. package/lib/memory-hub.js +127 -12
  73. package/lib/memory-index.js +4 -2
  74. package/lib/note-status-apply.js +118 -0
  75. package/lib/note-status.js +196 -0
  76. package/lib/procedure-store.js +84 -3
  77. package/lib/python-sidecar-client.js +29 -3
  78. package/lib/recall-fusion.js +83 -12
  79. package/lib/rules-edit.js +159 -0
  80. package/lib/semantic-decide.js +41 -8
  81. package/lib/semantic-js.js +51 -6
  82. package/lib/shadow-host.js +3 -5
  83. package/lib/skill-export-host.js +153 -0
  84. package/lib/skill-export.js +239 -0
  85. package/lib/storage-manage.js +6 -0
  86. package/lib/temporal-parse.js +191 -159
  87. package/lib/tier0-catalog.js +45 -3
  88. package/lib/wb-contract.js +198 -2
  89. package/lib/wb-sidecar.js +54 -3
  90. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -31,6 +31,9 @@ import { retryRename } from './fs-retry.js'
31
31
  import { parseAnchors, stripAnchorLines } from './memory-anchor.js'
32
32
  import { createShadowHost } from './shadow-host.js'
33
33
  import { createContextHost } from './context-host.js'
34
+ // R3(2026-09-18):降级留痕层。fail-soft 本身是对的,缺陷在「降级不可见」——
35
+ // 检索链四条臂各自静默失效,使用者只感到「检索不太对」。本层让「哪条臂没在工作」可查询。
36
+ import { createDegradeSinkPre, deriveArmsHealthPre, persistDegradeLedgerPre, createQuotaProbePre, deriveQuotaVerdictPre } from './degrade.js'
34
37
  import { createSuccessEvidencePre } from './context-bridge.js'
35
38
  import { createIndexSyncHostPre } from './m7-index-sync-host.js'
36
39
  import { createActivationHost } from './activation-host.js'
@@ -44,8 +47,12 @@ import { createPythonSetupPre } from './python-setup.js'
44
47
  import { createEpisodicStorePre } from './episodic-store.js'
45
48
  import { createFactStorePre } from './fact-store.js'
46
49
  import { createProcedureStorePre } from './procedure-store.js'
50
+ import { exportSkillForPre, resolveSkillsRootPre } from './skill-export-host.js'
47
51
  import { createMemoryHubPre } from './memory-hub.js'
48
52
  import { pickConsolidationTextPre } from './intent-clean.js'
53
+ // T1-3(2026-09-19):hubFlushTick 写入前的**内容卫生门**复用 ⑨ 同源清洗器。
54
+ // 必须独占一行(本仓契约守卫以字面量断言既有 import 行,禁止为排版合并)。
55
+ import { stripRuntimeIntentPre, looksRuntimeResiduePre } from './intent-clean-safe.js'
49
56
  import { createStorageManagerPre } from './storage-manage.js'
50
57
  import { rankWorkspacesByMemoryRecencyPre } from './ws-overview-rank.js'
51
58
  import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames, decodeZstdFramesHead } from './subagent-gc.js'
@@ -61,6 +68,14 @@ import { composeMemoryEnvelopePre } from './memory-envelope.js'
61
68
  import { validateMutationBoundaryPre, mutationRefusalTextPre } from './memory-mutation.js'
62
69
  import { parseWhiteboardPre, toMutationProjectionPre, extractProtectedRegionsPre, checkHandoffCriteriaPre, checkPlanCriteriaPre, criteriaRefusalTextPre, WB_MARKERS_V1, WB_CONTRACT_VERSION } from './wb-contract.js'
63
70
  import { resolveBoardModePre } from './board-mode.js'
71
+ // ★#86-3(2026-09-20):DSH_HOME 解析统一到单一口径(此前全仓 7 处、4 种回落)。
72
+ import { resolveDshHomePre } from './dsh-home.js'
73
+ // ★R7(2026-09-20 用户要求「用户级硬性约束必须可以让用户自己增删改」):
74
+ // 条目级解析/增删改的**纯逻辑**(不含 IO;IO 走既有 writeFull 事务)。
75
+ import { listRuleItemsPre, updateRuleItemPre, removeRuleItemPre, appendRuleItemPre } from './rules-edit.js'
76
+ // ★#82(2026-09-20):配置读写切到原子写 + 损坏隔离模块。
77
+ // 原实现:写侧裸 writeFileSync(写到一半被杀 ⇒ 半截 JSON),读侧 catch 静默回落出厂默认。
78
+ import { writeTextAtomicPreSync, writeTextAtomicPre, readJsonQuarantinePreSync } from './config-io.js'
64
79
  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'
65
80
  // P6A 规则层(规则类与参考类分开措辞;规则真源 = 既有用户级记忆,不新增 RULES.md)
66
81
  import { extractRulesLayerPre, renderRulesSectionPre, RULES_SECTION_TITLE_V1, RULES_SECTION_GUIDE_V1, REFERENCE_SECTION_GUIDE_V1, LOG_KINDS_V1 } from './rules-layer.js'
@@ -140,7 +155,7 @@ const SECTION_ORDER = 10000
140
155
  const NOTICES_URL = 'https://raw.githubusercontent.com/Aik358/dsh-auto-memory/main/notices.json'
141
156
 
142
157
  /** Model-facing announcement (tools + engine). */
143
- 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 的记忆」时即指本插件,请据此协作。'
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 = 可复用的结论/决策**。维护时机(**条件触发,不是每轮**):完成阶段性工作、或发现白板/账本所述与现状不符、或方向有实质变化时——**写记忆的同时顺手维护白板**,不要新开一轮专程去做。**禁止**:未得用户同意不得删减白板既有内容(只报告过时,不自动删);白板/产物功能关闭时跳过白板维护、不要因此报错。'
144
159
 
145
160
  /** Route family. */
146
161
  export const API = {
@@ -160,6 +175,9 @@ export const API = {
160
175
  reflect: '/api/dsh-auto-memory/reflect',
161
176
  'reflect-auto': '/api/dsh-auto-memory/reflect-auto',
162
177
  note: '/api/dsh-auto-memory/note',
178
+ // ★R7:用户级硬性约束的条目级读写(前端「设置 → 硬性约束」页用)
179
+ 'rules-list': '/api/dsh-auto-memory/rules',
180
+ 'rules-apply': '/api/dsh-auto-memory/rules/apply',
163
181
  external: '/api/dsh-auto-memory/external',
164
182
  'external-view': '/api/dsh-auto-memory/external-view',
165
183
  'external-import': '/api/dsh-auto-memory/external-import',
@@ -197,9 +215,20 @@ export const API = {
197
215
  * 记忆文件**容量上限**(字符,非字节)。超过即触发一次自动整理(先 AI 折叠、后退整条归档),
198
216
  * 整理后仍超才拒绝写入 —— 正常情况下"新记忆永不堵在外面"。
199
217
  * 只影响"多久整理一次",不直接决定每轮注入体积(那是 injectBudgetChars 的职责,两者互不相干)。
218
+ *
219
+ * ★2026-09-18(用户裁定,用户级反馈驱动):12000 → **24000**。多个真实用户报"写满了、写不进去",
220
+ * 12k 对长期项目(多天累积、大量决策/路径)偏紧,触发整理过于频繁。翻倍到 24k 后,
221
+ * 一个"信息量大"的工作日(实测约 8700 字符)能连续记录近 3 天而不整理。
222
+ * ⚠️ 只改常量**救不了老用户** —— `saveConfig` 会把整个合并后的 config 落盘,
223
+ * 老用户只要在设置页存过任何一项,12000 就已被钉死在磁盘上。故配套一次性迁移
224
+ * `upgradeCapacityDefaultsPre()`,见其注释。
200
225
  */
201
- const DEFAULT_NOTE_CAPACITY_CHARS = 12000
202
- const DEFAULT_USER_CAPACITY_CHARS = 12000
226
+ const DEFAULT_NOTE_CAPACITY_CHARS = 24000
227
+ const DEFAULT_USER_CAPACITY_CHARS = 24000
228
+ /** 上一版出厂默认容量(迁移判据:配置里仍是这个值 ⇒ 视为"用户没表达过偏好")。 */
229
+ const DEFAULT_CAPACITY_CHARS_PREV = 12000
230
+ /** 容量出厂默认的档位版本(用于老配置**只升一次**;用户此后手动设回 12000 也不再被覆盖)。 */
231
+ const CAPACITY_DEFAULTS_VERSION = 24
203
232
  /**
204
233
  * 整理保护窗口:最近写入的这么多字符**不参与回收**(软下限)。
205
234
  * 硬底线是"至少保留最新 1 条记录":若保护窗口自身就超过上限,允许对其折叠成要点(但不整条删除),
@@ -212,6 +241,20 @@ const COMPACT_FOLD_MAX_CHARS = 1500
212
241
  * 注意:节流只作用于"折叠"这一步,不阻止"整条归档"——否则短时间内反复超容量时仍会拒绝写入。 */
213
242
  const COMPACT_THROTTLE_MS = 10 * 60 * 1000
214
243
 
244
+ /** ★T7-a(2026-09-20 · 上游 #86-4):水位建议阈值的**唯一真源**。
245
+ * 为什么抽常量:此前 0.75 以字面量散在 8 处(7 处 `|| 0.75` 兜底 + DEFAULT_CONFIG 一行),
246
+ * 而该默认值历史上**全局调过一次**(2026-09-08 由 0.8 下调到 0.75)——下次再调极易漏站点,
247
+ * 任何一处漏改都会造成"部分路径用新值、部分路径用旧值"的静默不一致。
248
+ * ⚠️ 不要把它和 `Math.max(..., 0.1)` 的下限钳制混为一谈:后者是另一件事,保持原样。 */
249
+ export const DEFAULT_WATER_LEVEL_THRESHOLD = 0.75
250
+
251
+ /** ★T7-a(2026-09-20 · 上游 #86-4):自动接续阈值的**唯一真源**。
252
+ * ⚠️ **与 waterLevelThreshold 是两个独立配置项**(各自可单独设、各自有 `|| 兜底`),
253
+ * 只是历史上被**同时**下调过一次(2026-09-08 两者一起 0.8→0.75,见 DEFAULT_CONFIG 注释)。
254
+ * ⇒ 故意**不复用** DEFAULT_WATER_LEVEL_THRESHOLD:复用会让"只调水位、不动自动接续"变成不可能,
255
+ * 那是把两个开关耦合成一个(违反本仓「功能开关必须解耦」纪律)。 */
256
+ export const DEFAULT_AUTO_CONTINUE_THRESHOLD = 0.75
257
+
215
258
  const DEFAULT_CONFIG = {
216
259
  /** WB-GRAPH 白板线总开关(2026-09-16, board_mode_v1)。
217
260
  * ★2026-09-17(3.0.0 大版本,用户裁定「白板默认新版,旧版为了兼容而保留」):默认由 'legacy'
@@ -277,12 +320,15 @@ const DEFAULT_CONFIG = {
277
320
  subagentReasoningEffort: '',
278
321
  /**
279
322
  * 项目笔记 / 用户级记忆的**容量上限**(字符)。超过即自动整理(先 AI 折叠成要点,失败退回整条归档),
280
- * 整理后仍超才拒绝写入 —— 新记忆不会被堵在外面。默认 12000(实测一个"信息量大"的工作日约写入 8700 字符,
281
- * 12k 留出约 1.4 倍余量,避免同一天反复折叠)。与「注入预算」injectBudgetChars 是两回事:
282
- * 容量上限管文件本体大小,注入预算管每轮往提示里塞多少摘要。
323
+ * 整理后仍超才拒绝写入 —— 新记忆不会被堵在外面。默认 **24000**(2026-09-18 由 12000 上调;
324
+ * 实测一个"信息量大"的工作日约写入 8700 字符,24k 能连续记录近 3 天而不整理)。与「注入预算」
325
+ * injectBudgetChars 是两回事:容量上限管文件本体大小,注入预算管每轮往提示里塞多少摘要。
283
326
  */
284
327
  noteCapacityChars: DEFAULT_NOTE_CAPACITY_CHARS,
285
328
  userCapacityChars: DEFAULT_USER_CAPACITY_CHARS,
329
+ /** 容量出厂默认的档位版本(2026-09-18 新增)。低于当前值时,`upgradeCapacityDefaultsPre()`
330
+ * 会把仍是上一版默认(12000)的容量键抬到新默认;只升一次 ⇒ 用户自设值永不被覆盖。 */
331
+ capacityDefaultsVersion: CAPACITY_DEFAULTS_VERSION,
286
332
  /** M3a 只读记忆索引开关(默认关闭;开启后仅构建只读索引与调试快照,不修改任何 Markdown)。 */
287
333
  memoryFileIndexEnabled: false,
288
334
  /** M3b 稳定 Anchor 写入开关(默认关闭=全部旧 Markdown 写法逐字节不变;开启后记忆写路径经 anchor-aware 事务,CALENDAR.md 始终除外)。 */
@@ -341,8 +387,12 @@ const DEFAULT_CONFIG = {
341
387
  waterLevelWindowTokens: 0,
342
388
  /** 水位建议阈值(0.1-1.5):ratio 越阈值时注入交接建议。
343
389
  * 2026-09-08 由 0.8 下调到 0.75:harness 官方自动压缩阈值是 80%,阈值贴着 80% 会在交接动作完成前先被官方压缩掉,
344
- * 留 5% 余量(约 1M 窗口的 50K token)才来得及走完「写账本 → 建新会话 → 注入材料」。 */
345
- waterLevelThreshold: 0.75,
390
+ * 留 5% 余量(约 1M 窗口的 50K token)才来得及走完「写账本 → 建新会话 → 注入材料」。
391
+ * ★T7-a(2026-09-20 · 上游 #86-4):**该默认值的唯一真源 = 常量 DEFAULT_WATER_LEVEL_THRESHOLD**。
392
+ * 此前 0.75 在 7 处内联兜底 + 本行字面量共 8 处;该默认值历史上**全局调过一次**(0.8→0.75),
393
+ * 下次再调极易漏站点,任何一处漏改即静默不一致。故抽常量、全站引用。
394
+ * ⚠️ `Math.max(..., 0.1)` 的下限钳制是**另一件事**,不在本常量职责内,保持原样。 */
395
+ waterLevelThreshold: DEFAULT_WATER_LEVEL_THRESHOLD,
346
396
  /** 水位建议注入(无人值守时始终静默)。 */
347
397
  waterLevelAdvisory: true,
348
398
  /** 水位越阈时自动写一篇系统骨架账本(每会话一次;模型仍应自己写正式交接)。 */
@@ -353,7 +403,7 @@ const DEFAULT_CONFIG = {
353
403
  // 注:老用户已落盘的显式值不受影响,只有新装用户吃这个默认。
354
404
  autoContinueEnabled: false,
355
405
  /** 自动接续水位阈值(0.5-0.95);2026-09-08 与 waterLevelThreshold 同步下调到 0.75(官方自动压缩阈值 80%,必须留余量)。 */
356
- autoContinueThreshold: 0.75,
406
+ autoContinueThreshold: DEFAULT_AUTO_CONTINUE_THRESHOLD,
357
407
  /** 确认卡倒计时秒数(30-40s 无操作 = 挂机 → 自动接续兜底)。 */
358
408
  autoContinueConfirmSeconds: 35,
359
409
  /** 接续前刷新仪式(默认开):先让旧 Agent 刷新 PLAN.md + 交接账本,host 再用最新材料组装交接。 */
@@ -494,6 +544,17 @@ const DEFAULT_CONFIG = {
494
544
  reasoningObserverEnabled: true,
495
545
  /** Procedure 自动晋升。 */
496
546
  procedurePromotionEnabled: false,
547
+ /** ★T10(2026-09-20):**机械 procedure 切片**开关。
548
+ * 背景(用户 2026-09-20 报「白板/审批里的技能名与内容看不懂」):
549
+ * `memory-hub.js` 的 `crossFeed()` 会把 episode 的 intent **机械截断**成
550
+ * `title: intent.slice(0, 40)` / `steps: ['观察任务:' + intent.slice(0, 80)]`
551
+ * —— **无任何模型介入**,产出的观察行既不像技能名也不像步骤。
552
+ * 默认 **false**:关闭该机械来源。procedural 线的写入只剩两条正经通路——
553
+ * ① 模型直写 `memory_procedure`(T4);② 用户手动。
554
+ * ⚠️ **解耦声明**:本开关**只**控制 procedure 切片;fact 分支、episode 巩固、
555
+ * judgement 消费等一律不受影响(对应用户硬规矩「单一开关不得顺带改变其他功能」)。
556
+ * 设为 true 可恢复旧行为(回退通路)。 */
557
+ hubMechanicalProcedureFeedEnabled: false,
497
558
  /** ── M8 记忆中枢(Memory Hub)── 三层记忆(episodic/semantic/procedural)编排参数。
498
559
  * 所有参数都在设置页「记忆中枢」分组可调;默认值对应 M-02/M-03/M-04 元代码门槛。 */
499
560
  /** 记忆中枢总开关(2026-09-09 M8-3 经用户书面确认默认启用;开启后消费 judgement-shadow + 三层 store 运行;设置页「记忆中枢」开关可回滚)。 */
@@ -553,7 +614,25 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
553
614
  snapshotCalendarTitle: '[日历与日程(未完成)]',
554
615
  snapshotWelcomeTitle: '[欢迎回来]',
555
616
  snapshotWelcomeBody: '用户离开已超过 1 小时(暂离/下班后回来)。在本轮回复的开头,先用一句简短温暖的话欢迎用户回来(如"欢迎回来!你离开的这段时间,我已经帮你把日志整理好了。"),然后提示"自动记忆窗口将打开,方便你了解这段时间的状况"(如已由 GUI 弹出概览则不必重复提示)。语气自然,一两句即可,不要长篇大论。',
556
- snapshotInscription: '[铭文 · 每轮提醒 {date}]',
617
+ // ★T5(2026-09-20 用户拍板):**填回收尾自检正文**。
618
+ // 背景取证:本常量自 v0.1.30(fbc14fb, 2026-09-01)重构起即为空壳标题——v0.1.9(85d9340)
619
+ // 的尾部提醒正文在那次重构中被固化进 renderMemoryStatic(走 system prompt,位置在最前)。
620
+ // 用户裁定「记忆写入提醒必须固定在每轮收尾,不能只靠开头注入」⇒ 本段正是收尾位
621
+ // (动态快照倒数第二段,仅 frame-tail 在其后),recency 最高。
622
+ // 与 G4-6 的关系:G4-6 当时否决「往铭文塞白板散文」,理由是"纯每轮成本";
623
+ // 2026-09-20 用户明确要求恢复三大方向收尾提醒 ⇒ **该否决被推翻**,G4-6 断言同步改写。
624
+ // 成本纪律:只列**方向 + 工具名 + 分类枚举**,不写解释性散文(详版在 renderMemoryStatic,
625
+ // 那里是 system prompt,不随对话增长)。实测约 420 字符 ≈ 210 token,单份快照预算 8000 的 5%。
626
+ // 缓存纪律:本段走 systemPrompt.context()(user-role,追加在历史尾部),**不击穿前缀缓存**
627
+ // (见 :5194 设计说明);含 {date} 模板 ⇒ 日期不变则该段内容不变。
628
+ snapshotInscription: '[铭文 · 每轮提醒 {date}]\n'
629
+ + '【收尾自检】本轮有实质产出才做;纯只读/闲聊轮跳过。三个方向 + 分类别丢:\n'
630
+ + '① 写记忆文件 memory_log —— kind 按性质选,**不要一律 fact**:rule=用户约束/约定(会被规则层当硬约束注入)、preference=偏好、fact=事实记录、todo=待办;\n'
631
+ + '② 更新白板与账本 —— ★**硬映射(满足即必须做,别自行判断"算不算"变化)**:\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'
633
+ + '④ 结论失效/被取代 —— 传 `supersedes=` 旧条目 mem_id 标 **superseded(被更新结论取代)**;**当时就做错了要撤回**则传 `retract=` 标 **retracted(撤回)**并尽量附 `retractReason=` 说明错在哪(两者不同:前者有后继,后者本身就是教训);标错了用 `restore=` 撤回;\n'
634
+ + '⑤ 看板落列 —— 白板/账本内容要进面板看板泳道,须在标题或正文写 tag:`type:goal` / `type:state` / `type:dead-end` / `type:progress`(5 条泳道含「版本归档」,靠归档动作而非 tag);不写 tag 系统只能按标题文字猜,常落空;\n'
635
+ + '⑥ 语体 —— 客观陈述、第三人称,只留可复用的事实/决策/规则/路径(不写"我考虑/我排查/我想")。',
557
636
  // ★2026-09-15(用户裁定"不是不注入,而是精简注入"):精简版尾部说明。
558
637
  // 作用:让模型知道**这是精简版**、完整版每 {n} 轮来一次、以及"知道有什么但没给全文"时怎么取。
559
638
  // 不写"请稍后再看"这类无效指令——只给可执行的取用方式(与 Tier-0 目录的用法一致)。
@@ -621,12 +700,132 @@ const truncateLinesBounded = (s, n) => {
621
700
  return (nl > Math.floor(n * 0.5) ? cut.slice(0, nl) : cut) + '\n…(截断,全文见 handoff/ 白板与账本)'
622
701
  }
623
702
  const truncateTail = (s, n) => (s && s.length > n) ? '…(截断,完整内容用 memory_recall 或 GUI 面板)\n' + s.slice(-n) : (s || '')
703
+
704
+ /**
705
+ * 接续材料组装(L3, 2026-09-17) —— **导航区不可截断**。
706
+ *
707
+ * 为什么需要它(终端用户实测报障「有些文件没有办法接续过去」):
708
+ * 旧实现把所有层顺序 push 进一个数组, 最后 `parts.join(NL+NL).slice(0, 18000)` **从尾部一刀切**。
709
+ * 而 第2层(20 条 × 700 字 = 最多 14000) + 白板 3000 + 账本 8000 最坏 ≈ 25000 > 18000 ⇒ 必然溢出,
710
+ * 于是**第一个被砍掉的正是排在最后的第3层「完整转写路径」**——那是模型的**逃生通道**
711
+ * ("前 0-2 层不够时去 read 全量转写")。逃生通道被砍 ⇒ 模型根本不知道全量转写存在
712
+ * ⇒ 只能靠被砍过的摘要干活 ⇒ 表现为"接不过去"。
713
+ *
714
+ * 语义(与调用方约定):
715
+ * - `nav`: **永不截断**, 配额先扣(转写路径 / 锚点入口)。
716
+ * - `head`: 指令区, 也基本固定(短)。
717
+ * - `bulk`: 正文层(白板/账本/近期线程/辅助表), **可截断**; 超出时按预算切, 并**如实报告哪些层被丢**。
718
+ * - 发生截断时,**显式写入未包含清单** —— 让模型知道自己拿到的**不是全部**, 从而主动去 read,
719
+ * 而不是以为已经拿全了(旧实现的最大隐患是"沉默截断")。
720
+ *
721
+ * 纯函数、无副作用、字节稳定(同输入 → 同输出)。
722
+ */
723
+ export function assembleCarryPre({ head = [], nav = [], bulk = [], budget = 18000 } = {}) {
724
+ const E = '\n\n'
725
+ const headText = head.filter(Boolean).join(E)
726
+ const navText = nav.filter(Boolean).join(E)
727
+ const bulkParts = bulk.filter(Boolean)
728
+ const fullBulk = bulkParts.join(E)
729
+ const fixed = headText.length + navText.length + (headText || navText ? E.length * 2 : 0)
730
+ const room = Math.max(0, Number(budget) - fixed)
731
+ if (fullBulk.length <= room) {
732
+ return { text: [headText, navText, fullBulk].filter(Boolean).join(E), truncated: false, dropped: [] }
733
+ }
734
+ // 需截断: 逐段累计, 记录被整体丢弃的段
735
+ const kept = []
736
+ const dropped = []
737
+ let used = 0
738
+ for (const p of bulkParts) {
739
+ const cost = p.length + (kept.length ? E.length : 0)
740
+ if (used + cost <= room) { kept.push(p); used += cost } else { dropped.push(p) }
741
+ }
742
+ // 若一段都放不下(room 太小), 至少保底切一段的开头
743
+ if (!kept.length && bulkParts.length) {
744
+ kept.push(bulkParts[0].slice(0, Math.max(0, room)))
745
+ }
746
+ const droppedHeads = dropped.map((p) => {
747
+ const heads = p.match(/^【[^】]{0,60}】/gm)
748
+ return heads ? heads.join(' ') : '(一段正文)'
749
+ })
750
+ const notice = '⚠️ **材料因预算被截断, 以下内容未包含**: ' + (droppedHeads.length ? droppedHeads.join('; ') : '(部分正文尾部)') +
751
+ '。**完整材料仍在第3层转写里**(见上方路径), 需要时请直接 read, 不要仅凭本摘要下结论。'
752
+ const text = [headText, navText, kept.join(E), notice].filter(Boolean).join(E)
753
+ return { text, truncated: true, dropped: droppedHeads }
754
+ }
755
+
756
+ /** ★L3.6(2026-09-17)·旧会话转写**瘦身**(纯函数,供 smoke 驱动)。
757
+ * 实测(2026-09-17): 一个会话的事件里 `tool/ptc-dispatch` 1854 条 vs `assistant/message` 530 条
758
+ * ⇒ 工具噪声压过正文 3 倍以上。全量转写既浪费磁盘也让模型检索时被噪声淹没。
759
+ * 用户批准的方案:**只保留用户输入与助手的最终输出**, 工具调用/结果压成**计数行**。
760
+ * 保留角色标记与顺序 ⇒ 第2层"近期线程"的结构还原不受影响。
761
+ * 返回 { body, keptMsgs, toolCalls, toolResults, droppedChars }。 */
762
+ export function slimTranscriptPre(msgs, opts = {}) {
763
+ const NL = String.fromCharCode(10)
764
+ const perMsg = Number(opts.perMsgChars) > 0 ? Number(opts.perMsgChars) : 2000
765
+ const totalCap = Number(opts.totalChars) > 0 ? Number(opts.totalChars) : 60000
766
+ const decorate = typeof opts.decorate === 'function' ? opts.decorate : null
767
+ const list = Array.isArray(msgs) ? msgs : []
768
+ const out = []
769
+ let toolCalls = 0, toolResults = 0, droppedChars = 0, keptMsgs = 0, used = 0, trimmed = false
770
+ for (const m of list) {
771
+ const role = String((m && m.role) || '')
772
+ const text = String((m && m.text) || '')
773
+ if (role === 'tool_call') { toolCalls++; droppedChars += text.length; continue }
774
+ if (role === 'tool_result') { toolResults++; droppedChars += text.length; continue }
775
+ if (role !== 'user' && role !== 'assistant') continue
776
+ // 超总长即停(与旧实现同口径: 保留**较早**内容, 尾部截断并如实告知)
777
+ const extra = decorate ? String(decorate(m) || '') : ''
778
+ const chunk = '**' + role + '**: ' + (text.length > perMsg ? text.slice(0, perMsg) : text) + extra
779
+ if (used + chunk.length > totalCap) { trimmed = true; break }
780
+ out.push(chunk)
781
+ used += chunk.length + NL.length * 2
782
+ keptMsgs++
783
+ if (text.length > perMsg) droppedChars += text.length - perMsg
784
+ }
785
+ return { body: out.join(NL + NL), keptMsgs, toolCalls, toolResults, droppedChars, trimmed }
786
+ }
787
+
788
+ /** ★L3.6·会话转写的**检索锚点**(纯函数,供 smoke 驱动)。
789
+ * 旧会话转写此前是"孤岛": `listHandoffLedgers` 的正则
790
+ * `/^(?:handoff-\d{8}-\d{6}(-[a-z])?|PLAN-\d{8}-\d{6})\.md$/` **不匹配 prev-session-***,
791
+ * 所以 `scope='handoff'` 永远看不见它 ⇒ 用户说"有些文件接不过去"。
792
+ * 这里给每篇转写发一个**由 sid 决定的稳定锚点**(同 sid ⇒ 同 id, 不随写入时刻漂移),
793
+ * 格式与既有记忆锚点同域(`mem_` + 32 hex), 便于 L0 抽取与 grep。 */
794
+ export function prevSessionSidAnchorPre(sid, createHashFn) {
795
+ try {
796
+ const s = String(sid || '')
797
+ if (!s) return ''
798
+ const h = createHashFn ? createHashFn() : null
799
+ if (!h) return ''
800
+ return 'mem_' + h.update('prev-session\u0000' + s).digest('hex').slice(0, 32)
801
+ } catch (e) { return '' }
802
+ }
803
+
804
+ /** ★L3.6·为一篇旧会话转写生成 L0 摘要行(纯函数,供 smoke 驱动)。
805
+ * L0 = 一句话"这个会话在干什么" —— 取**首条用户输入** + **末条助手结论**各截断,
806
+ * 与 `l0-extract.js` 的 L0 口径一致(单行、可 grep、不整段读)。
807
+ * 作用: 让 `searchHandoffCorpus` 能按关键词命中旧会话, 而不必先通读全文。 */
808
+ export function prevSessionL0Pre(msgs, sid, opts = {}) {
809
+ try {
810
+ const cap = Number(opts.cap) > 0 ? Number(opts.cap) : 120
811
+ const oneLine = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, cap)
812
+ const list = Array.isArray(msgs) ? msgs : []
813
+ const firstUser = list.find((m) => m && m.role === 'user' && String(m.text || '').trim())
814
+ let lastAsst = null
815
+ for (const m of list) { if (m && m.role === 'assistant' && String(m.text || '').trim()) lastAsst = m }
816
+ const a = oneLine(firstUser && firstUser.text)
817
+ const b = oneLine(lastAsst && lastAsst.text)
818
+ if (!a && !b) return ''
819
+ const sid8 = String(sid || '').slice(0, 8)
820
+ return '[' + sid8 + '] ' + (a || '(无用户输入)') + (b ? ' → ' + b : '')
821
+ } catch (e) { return '' }
822
+ }
823
+
624
824
  const fmtBytes = (n) => (n >= 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : n + ' B')
625
825
 
626
826
  function dshHome() {
627
- const env = process.env.DSH_HOME
628
- if (env && env.trim()) return env.trim()
629
- return path.join(homedir(), '.dsh')
827
+ // ★#86-3:统一口径(原实现自带一套回落链,与其余 6 处不一致)。
828
+ return resolveDshHomePre()
630
829
  }
631
830
 
632
831
  /** 本插件 lib/ 的上级目录(开发树=仓库根;发行包=包根)。 */
@@ -634,6 +833,76 @@ function pluginRootDir() {
634
833
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
635
834
  }
636
835
 
836
+ /**
837
+ * 诊断节流(2026-09-21):同一 key 在 windowMs(默认 5 分钟)内只输出一次。
838
+ *
839
+ * 为什么需要: tickTime 是**定时**驱动的,任何「每 tick 都失败且都打日志」的分支都会
840
+ * 在宿主控制台无限刷屏。实测事故: restoreLastAgent 的 candidate rejected 每 tick 一条,
841
+ * 用户侧表现为「打开就一直弹这些消息」。诊断信息本身有用,但**不该以 tick 频率重复**。
842
+ */
843
+ const _diagLastAt = new Map()
844
+ function diagThrottled(key, msg, windowMs) {
845
+ try {
846
+ const w = Number(windowMs) > 0 ? Number(windowMs) : 300000
847
+ const now = Date.now()
848
+ if (now - (Number(_diagLastAt.get(key)) || 0) < w) return
849
+ _diagLastAt.set(key, now)
850
+ } catch (e) {}
851
+ diag(msg)
852
+ }
853
+
854
+ /**
855
+ * 会话归属判定 —— 区分「子代理会话」与「接续会话」(2026-09-21 修 bug)。
856
+ *
857
+ * ── 背景(实测证据: tools/probe-session-kind.mjs 解压会话头部得到) ──
858
+ * 本插件原判据是「`header.parentSession` 非空 ⇒ 子代理, 一律排除」。实测**该判据过宽**:
859
+ * 带 parentSession 的会话其实分两类 ——
860
+ * · **子代理** : `delegationDepth: 1`, `origin: 'subagent'`
861
+ * · **接续会话**: `delegationDepth: 0`, `origin` 缺省 ← **用户真实在用的会话**
862
+ * 接续会话由「一键接续 / 自动接续」从旧会话派生, 是**用户会话**, 却被旧判据当子代理拒掉。
863
+ *
864
+ * ── 后果(用户报告的现象) ──
865
+ * `_lastAgent` 永远恢复不了, 而 `tickTime` 每 15 秒重试一次 ⇒ 控制台**无限刷屏**
866
+ * `restoreLastAgent: candidate rejected`。诊断日志可证: 该行**首次出现于 2026-09-20 17:16**,
867
+ * 正是 session-85e2b7e8(接续会话)的创建时刻 —— 此前用户一直用无 parent 的顶层会话,
868
+ * 故从未触发。⇒ 不是回归, 是「接续会话」这一新形态第一次撞上过宽判据。
869
+ *
870
+ * ── 判定方向(安全优先: 只认「明确是子代理」, 其余放行) ──
871
+ * 漏判子代理的代价 = 少一次恢复(退化为旧行为); 误拒用户会话的代价 = 功能失效 + 刷屏。
872
+ * 两害相权取轻。放行时会打一条**可观测**的诊断(带 origin/depth 原值), 便于事后核对误判。
873
+ */
874
+ const SESSION_SUBAGENT_ORIGIN = 'subagent'
875
+ /** 从各种可能形态里取出会话头部(list 项 / agent.session.header / list 项自身)。 */
876
+ function sessionHeaderOf(x) {
877
+ try {
878
+ if (!x) return null
879
+ if (x.session && x.session.header) return x.session.header
880
+ if (x.header) return x.header
881
+ if (x.session) return x.session
882
+ return x
883
+ } catch (e) { return null }
884
+ }
885
+ /** 是否**明确**是子代理会话。字段缺失一律不算(⇒ 放行)。 */
886
+ function isSubAgentSession(x) {
887
+ try {
888
+ const h = sessionHeaderOf(x)
889
+ if (!h) return false
890
+ if (String(h.origin || '') === SESSION_SUBAGENT_ORIGIN) return true
891
+ const d = Number(h.delegationDepth)
892
+ if (Number.isFinite(d) && d > 0) return true
893
+ } catch (e) {}
894
+ return false
895
+ }
896
+ /** 是否有 parentSession(用于诊断与「接续会话」识别, 不单独作为排除依据)。 */
897
+ function hasParentSession(x) {
898
+ try {
899
+ const h = sessionHeaderOf(x)
900
+ if (!h) return false
901
+ const p = h.parentSession
902
+ return p !== undefined && p !== null && p !== ''
903
+ } catch (e) { return false }
904
+ }
905
+
637
906
  /** 诊断输出:写 ~/.dsh/dsh-auto-memory-diagnose.log(append)+console.log 双保险。验证完移除。 */
638
907
  let _diagChain = Promise.resolve()
639
908
  function diag(msg) {
@@ -943,6 +1212,11 @@ class SessionRuntimeStore {
943
1212
  runtime.pendingPacket = undefined
944
1213
  // M4-3:Shadow per-runtime 状态与 inFlight abort
945
1214
  try { if (this._shadowHost) this._shadowHost.disposeRuntime(runtime) } catch (e) {}
1215
+ // M6(issue#58 修复,2026-09-19):activation host 的 per-runtime 投影与步进计数器同样必须回收。
1216
+ // 旧实现只接了 shadowHost,`activationHost.disposeRuntime` 全仓零调用方 ⇒ `runtimeState`
1217
+ // (step/claimed)与 `stepsByRuntime`(会话×工作区步进)只增不减,长进程内存单调增长。
1218
+ // 传 runtime.key(与 activation-host 内部 runtimeState 的键空间一致)。
1219
+ try { if (this._activationHost && runtime.key) this._activationHost.disposeRuntime(runtime.key) } catch (e) {}
946
1220
  // M2: 清空观察账本与语义环,断开 call 关联(abort 已由 abortController 完成);ring 为惰性分配,可能为 null
947
1221
  if (runtime.envelopes) runtime.envelopes.clear()
948
1222
  if (runtime.segments) runtime.segments.clear()
@@ -1620,6 +1894,51 @@ class MemoryEngine {
1620
1894
  return this.config
1621
1895
  }
1622
1896
 
1897
+ /**
1898
+ * 一次性迁移(2026-09-18,容量默认 12000 → 24000)。
1899
+ *
1900
+ * **为什么需要它**:`saveConfig` 把**整个合并后的 config** 落盘(实测某实例 95 个键全部在盘上)。
1901
+ * 所以老用户只要在设置页存过**任何一项**,`noteCapacityChars: 12000` 就已经被固化在磁盘里 ——
1902
+ * 光把 `DEFAULT_NOTE_CAPACITY_CHARS` 改成 24000 **对老用户完全无效**(配置值覆盖默认值)。
1903
+ * 这正是"很多人抱怨写满了、写不进去"的机制:他们被钉在 12k 上,永远不会拿到新默认。
1904
+ *
1905
+ * **幂等且尊重用户偏好**:
1906
+ * - 只在配置里**恰好等于上一版默认(12000)**时才抬到新默认 ⇒ "没表达过偏好"才动;
1907
+ * 用户若显式设成别的数(如 8000 或 50000)一律不动。
1908
+ * - 用 `capacityDefaultsVersion` 守卫 ⇒ **只升一次**。用户日后手动改回 12000 不会被再次覆盖。
1909
+ * - 任何异常都吞掉(fail-soft):迁移失败不能挡住插件启动。
1910
+ *
1911
+ * ★★ `rawCfg` 参数是**必须的**, 不是可选装饰 —— 这是 2026-09-18 实测踩到的真 bug:
1912
+ * `_mergeConfigPre` 做的是 `{...DEFAULT_CONFIG, ...parsed}`, 而 `DEFAULT_CONFIG` 里
1913
+ * **也有** `capacityDefaultsVersion: CAPACITY_DEFAULTS_VERSION` ⇒ 合并后的 config
1914
+ * 永远带着当前版本号 ⇒ 守卫 `ver >= VER` **结构性恒真** ⇒ 迁移函数一进就 return,
1915
+ * **永远不会执行**(单测用手搓 config 喂函数, 没走合并路径, 所以自洽地绿了)。
1916
+ * 修法:守卫读**磁盘原文**(未经默认值补全的 parsed), 而不是合并结果。
1917
+ * 调用方必须把 `JSON.parse(raw)` 的结果原样传进来。
1918
+ *
1919
+ * 返回被改动的键名数组(供日志/诊断;空数组表示无需迁移)。
1920
+ */
1921
+ upgradeCapacityDefaultsPre(rawCfg) {
1922
+ const changed = []
1923
+ try {
1924
+ // 守卫读**磁盘原文**的版本号(缺失 = 老配置 = 0, 必然 < 当前版本 ⇒ 继续)
1925
+ const onDiskVer = Number(rawCfg && rawCfg.capacityDefaultsVersion)
1926
+ if (Number.isFinite(onDiskVer) && onDiskVer >= CAPACITY_DEFAULTS_VERSION) return changed
1927
+ for (const key of ['noteCapacityChars', 'userCapacityChars']) {
1928
+ const raw = Number(this.config[key])
1929
+ // 只在"仍是上一版出厂默认"时抬升;用户自设值(含 <500 的脏值由 capacityLimit 兜底)不动。
1930
+ if (Number.isFinite(raw) && raw === DEFAULT_CAPACITY_CHARS_PREV) {
1931
+ this.config[key] = key === 'userCapacityChars' ? DEFAULT_USER_CAPACITY_CHARS : DEFAULT_NOTE_CAPACITY_CHARS
1932
+ changed.push(key)
1933
+ }
1934
+ }
1935
+ this.config.capacityDefaultsVersion = CAPACITY_DEFAULTS_VERSION
1936
+ return changed
1937
+ } catch (e) {
1938
+ return changed
1939
+ }
1940
+ }
1941
+
1623
1942
  /**
1624
1943
  * 同步配置加载(2026-09-16, 修 BUG-1/BUG-11 —— 注册闸门结构性恒假)。
1625
1944
  *
@@ -1635,8 +1954,27 @@ class MemoryEngine {
1635
1954
  */
1636
1955
  loadConfigSync() {
1637
1956
  try {
1638
- const raw = readFileSync(this._configPath, 'utf8')
1639
- return this._mergeConfigPre(JSON.parse(raw))
1957
+ // ★#82:解析失败时先**把坏文件挪走留存**(readJsonQuarantinePreSync 内部完成),
1958
+ // 再把「坏过、坏在哪」写进 _readError 让诊断面可见 —— 旧实现只 catch 后静默回落,
1959
+ // 用户看到的只是「设置全没了」,无从知道是被重置。
1960
+ const rd = readJsonQuarantinePreSync(this._configPath)
1961
+ if (!rd.ok) {
1962
+ if (rd.missing) return this._mergeConfigPre(null)
1963
+ this._readError = rd.corrupted
1964
+ ? ('config corrupted, quarantined=' + (rd.quarantined || '(failed)') + ' reason=' + rd.reason)
1965
+ : String(rd.reason)
1966
+ return this._mergeConfigPre(null)
1967
+ }
1968
+ const parsed = rd.value
1969
+ const cfg = this._mergeConfigPre(parsed)
1970
+ // ⚠️ 必须传 parsed(磁盘原文) —— 合并结果里 capacityDefaultsVersion 恒等于当前版本,
1971
+ // 用它做守卫会让迁移结构性永不执行(见 upgradeCapacityDefaultsPre 的注释)。
1972
+ const bumped = this.upgradeCapacityDefaultsPre(parsed)
1973
+ // ★ 这条路径**必须自己落盘**:它是注册期唯一真正跑的那条(apply 不是 async,
1974
+ // 工具注册前就要拿到真配置),而 `loadConfig` 往往再也不会被调用 ⇒
1975
+ // 不落盘的话内存已是 24000、磁盘仍写 12000,设置页读盘显示旧值(会被当成"没生效")。
1976
+ if (bumped.length) this.persistConfigSyncPre()
1977
+ return cfg
1640
1978
  } catch (e) {
1641
1979
  if (e && e.code !== 'ENOENT') this._readError = String(e && e.message ? e.message : e)
1642
1980
  return this._mergeConfigPre(null)
@@ -1645,14 +1983,62 @@ class MemoryEngine {
1645
1983
 
1646
1984
  async loadConfig() {
1647
1985
  try {
1648
- const raw = await readFile(this._configPath, 'utf8')
1649
- return this._mergeConfigPre(JSON.parse(raw))
1986
+ // ★#82:与 loadConfigSync 同口径(同一模块,保证两条路径行为一致)。
1987
+ const rd = readJsonQuarantinePreSync(this._configPath)
1988
+ if (!rd.ok) {
1989
+ if (rd.missing) return this._mergeConfigPre(null)
1990
+ this._readError = rd.corrupted
1991
+ ? ('config corrupted, quarantined=' + (rd.quarantined || '(failed)') + ' reason=' + rd.reason)
1992
+ : String(rd.reason)
1993
+ return this._mergeConfigPre(null)
1994
+ }
1995
+ const parsed = rd.value
1996
+ const cfg = this._mergeConfigPre(parsed)
1997
+ // ⚠️ 传 parsed(磁盘原文) —— 合并结果里版本号恒等于当前版本,会使命中外层守卫而永不迁移。
1998
+ const bumped = this.upgradeCapacityDefaultsPre(parsed)
1999
+ // 内存里已抬升,但磁盘上还是旧值 ⇒ 落盘一次(否则下次重启重复走迁移分支;
2000
+ // 且设置页读的是磁盘,不落盘会显示 12000 与实际生效值不一致)。
2001
+ if (bumped.length) await this.persistConfigPre()
2002
+ return cfg
1650
2003
  } catch (e) {
1651
2004
  if (e && e.code !== 'ENOENT') this._readError = String(e && e.message ? e.message : e)
1652
2005
  return this._mergeConfigPre(null)
1653
2006
  }
1654
2007
  }
1655
2008
 
2009
+ /** 把当前内存配置原子写盘(迁移用;与 saveConfig 的写盘段同口径,但不做迁移/联动副作用)。 */
2010
+ async persistConfigPre() {
2011
+ try {
2012
+ // ★#82:tmp → rename 原子写。读方永远只见到完整版本,不会看到半截 JSON。
2013
+ const r = await writeTextAtomicPre(this._configPath, JSON.stringify(this.config, null, 2))
2014
+ if (!r.ok) { console.error('[dsh-auto-memory] persistConfigPre failed', r.error); return false }
2015
+ return true
2016
+ } catch (e) {
2017
+ console.error('[dsh-auto-memory] persistConfigPre failed', e)
2018
+ return false
2019
+ }
2020
+ }
2021
+
2022
+ /**
2023
+ * `persistConfigPre` 的**同步**版本 —— 给注册期的 `loadConfigSync` 用。
2024
+ *
2025
+ * 为什么不能用异步那个:`loadConfigSync` 是在 `apply()` 里同步调用的(见其注释:
2026
+ * apply 不是 async 且返回值不被 await),异步写盘会在插件"加载完成"之后才落,
2027
+ * 期间设置页读盘拿到旧值 ⇒ 用户看到"改了没生效"。同步写盘量极小(一个配置 JSON),
2028
+ * 代价可接受,换来的是"内存值 === 磁盘值"这条不变量在任何时刻都成立。
2029
+ */
2030
+ persistConfigSyncPre() {
2031
+ try {
2032
+ // ★#82:tmp → rename 原子写(与异步版同模块,行为一致)。
2033
+ const r = writeTextAtomicPreSync(this._configPath, JSON.stringify(this.config, null, 2))
2034
+ if (!r.ok) { console.error('[dsh-auto-memory] persistConfigSyncPre failed', r.error); return false }
2035
+ return true
2036
+ } catch (e) {
2037
+ console.error('[dsh-auto-memory] persistConfigSyncPre failed', e)
2038
+ return false
2039
+ }
2040
+ }
2041
+
1656
2042
  async saveConfig(patch) {
1657
2043
  await this.loadConfig()
1658
2044
  const oldRoot = this.expandUserPath(this.config.memoryRoot)
@@ -1953,8 +2339,12 @@ class MemoryEngine {
1953
2339
  let archived = ''
1954
2340
  if (existing && existing.trim() && existing.trim() !== incoming.trim()) {
1955
2341
  const archDir = path.join(dir, 'archive')
1956
- const archPath = path.join(archDir, 'PLAN-' + handoffStamp() + '.md')
2342
+ // ★2026-09-20 移植(issue #94② / PR #100):同秒两次重写会算出同一归档名并**静默覆盖**
2343
+ // 上一份 —— 补上与姊妹路径(PLAN-history / 账本)同款的防撞后缀循环。
2344
+ const archBase = 'PLAN-' + handoffStamp()
2345
+ let archPath = path.join(archDir, archBase + '.md')
1957
2346
  await mkdir(archDir, { recursive: true })
2347
+ for (let c = 98; c <= 122 && existsSync(archPath); c++) { archPath = path.join(archDir, archBase + '-' + String.fromCharCode(c) + '.md') }
1958
2348
  await writeFile(archPath, existing, 'utf8')
1959
2349
  archived = archPath
1960
2350
  }
@@ -1989,14 +2379,19 @@ class MemoryEngine {
1989
2379
  // WB-FORMAT-CONVENTION §2 锚点写入(仅 graph 档; legacy 档逐字节不变)
1990
2380
  // 根因修复: 白板内容凭锚点自动进 L0 检索语料 —— 此前只把 id 写进 sidecar, Markdown 正文零锚点,
1991
2381
  // 导致 §2 承诺的收益恒为零(规划 §5「已知现状缺口: 规范已批准、代码从未实现」)。
2382
+ // ★L5(2026-09-17) 解除 boardMode 闸门: 锚点是**写入格式契约**(WB-FORMAT-CONVENTION §2),
2383
+ // 与看板**渲染形态**无关。旧实现在这里误把它当渲染闸门 ⇒ legacy 档的白板永远拿不到锚点,
2384
+ // §2 承诺的「白板内容凭锚点自动进 L0 检索语料」恒为空(规划 §5「规范已批准、代码从未实现」的真因)。
2385
+ // 判据(用户已批准): 不问是不是 boardMode 门, 只问**门控的是「渲染」还是「写入/取材」**。
2386
+ // ⚠️ 注意: `written` 的初始化**必须保留** —— 无锚点变化时它就是写盘内容;
2387
+ // 漏掉会让下面的 writeSidecarEntryPre / return 里的 written 全是 undefined
2388
+ // (p7-write-fix G2 实测抓到: 白板写入直接失败、历史簿路径 undefined)。
1992
2389
  let written = finalContent
1993
- if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
1994
- try {
1995
- const wsKey = this.wbWsKeyPre(projectDir)
1996
- const anchored = applyAnchorsPre(wsKey, 'handoff/PLAN.md', finalContent)
1997
- if (anchored.text !== finalContent) { written = anchored.text; await this.writeFullRaw(planPath, written) }
1998
- } catch (_) { /* fail-soft: 锚点绝不阻塞主写入 */ }
1999
- }
2390
+ try {
2391
+ const wsKey = this.wbWsKeyPre(projectDir)
2392
+ const anchored = applyAnchorsPre(wsKey, 'handoff/PLAN.md', finalContent)
2393
+ if (anchored.text !== finalContent) { written = anchored.text; await this.writeFullRaw(planPath, written) }
2394
+ } catch (_) { /* fail-soft: 锚点绝不阻塞主写入 */ }
2000
2395
  // P2 sidecar(仅 boardMode=graph; fail-soft, 不阻塞主写入)
2001
2396
  await this.writeSidecarEntryPre(projectDir, 'handoff/PLAN.md', written, '白板 PLAN', '')
2002
2397
  if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
@@ -2032,15 +2427,13 @@ class MemoryEngine {
2032
2427
  })
2033
2428
  if (!gate.ok) return { ok: false, error: gate.error, gate: gate.gate, report: gate.report }
2034
2429
  for (let c = 98; c <= 122 && existsSync(p); c++) { p = path.join(dir, base + '-' + String.fromCharCode(c) + '.md') }
2035
- // WB-FORMAT-CONVENTION §2 锚点写入(仅 graph 档) —— 账本同样按 `##` 小节切卡
2430
+ // ★L5(2026-09-17) 同上: 账本锚点也是**写入格式契约**, 解除 boardMode 闸门。
2036
2431
  let writtenLedger = fullText
2037
- if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
2038
- try {
2039
- const wsKey = this.wbWsKeyPre(projectDir)
2040
- const anchored = applyAnchorsPre(wsKey, 'handoff/' + path.basename(p), fullText)
2041
- if (anchored.text !== fullText) writtenLedger = anchored.text
2042
- } catch (_) { /* fail-soft */ }
2043
- }
2432
+ try {
2433
+ const wsKey = this.wbWsKeyPre(projectDir)
2434
+ const anchored = applyAnchorsPre(wsKey, 'handoff/' + path.basename(p), fullText)
2435
+ if (anchored.text !== fullText) writtenLedger = anchored.text
2436
+ } catch (_) { /* fail-soft */ }
2044
2437
  await this.writeFullRaw(p, writtenLedger)
2045
2438
  // P2 sidecar(仅 boardMode=graph; fail-soft, 不阻塞主写入)
2046
2439
  await this.writeSidecarEntryPre(projectDir, 'handoff/' + path.basename(p), writtenLedger, '交接账本 ' + path.basename(p), '')
@@ -2228,10 +2621,17 @@ class MemoryEngine {
2228
2621
  * 闸门: 仅 boardMode='graph' 返回看板数据; legacy 返回 { enabled:false, reason:'legacy-mode' }
2229
2622
  * —— 前端据此保持旧文字白板(legacy 逐字节不变)。
2230
2623
  * fail-soft: sidecar 缺失由 _loadSidecarIndexPre 自愈重建; 全程 try/catch 不阻塞面板。
2624
+ *
2625
+ * ★2026-09-17 解耦(L1, 终端用户报障): **移除 handoffEnabled 门**。
2626
+ * 原实现首行 `if (handoffEnabled === false) return {enabled:false, reason:'handoff-disabled'}`,
2627
+ * 导致关掉白板产物的用户看到「未启用或加载失败」, 而 `handoff/` 里的 PLAN.md 与账本**根本没被删**——
2628
+ * 数据还在, 只是被这道门挡在门外。
2629
+ * 判据(全批统一): 门控的是「渲染」还是「写入/取材」? `handoffEnabled` 的语义是**产物层**
2630
+ * (见 buildContinueCarry 头注释: 关时既不写、也不读) ⇒ 它管写入/取材; **看板是渲染**, 不该被它拦。
2631
+ * 保持者: boardMode 门(下方那条) —— 那是渲染形态开关, 且 legacy 须与旧行为逐字节相同(:3673 纪律)。
2231
2632
  */
2232
2633
  async kanbanBoardData(sessionId, opts = {}) {
2233
2634
  if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
2234
- if (this.config.handoffEnabled === false) return { enabled: false, reason: 'handoff-disabled' }
2235
2635
  if (!(String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
2236
2636
  return { enabled: false, reason: 'legacy-mode' }
2237
2637
  }
@@ -2310,12 +2710,19 @@ class MemoryEngine {
2310
2710
  return { ok: false, gate: 'criteria', error: criteriaRefusalTextPre(crit), report: crit, criteria: crit }
2311
2711
  }
2312
2712
  }
2313
- // ── ② 共同保护门(无条件;只在"重写既有目标"时有意义)──
2713
+ // ── ② 共同保护门(**仅对 plan 生效**;见下方 L6 说明)──
2314
2714
  // 锚点完备性属白板线 P1/P2,**不在此拦截**:现存 PLAN.md 一个锚点都没有,
2315
2715
  // 硬判会让所有既有白板立刻写不进去(那会把"保护"变成"锁死")。
2316
2716
  // 保护门在**能证明丢卡**时生效:即 before 侧解析出了卡片集合,而 after 侧少了。
2317
- if (target !== 'plan' && target !== 'handoff') return { ok: true }
2318
- if (target === 'handoff') return { ok: true }
2717
+ //
2718
+ // ★L6(2026-09-17)·注释与实现对齐:下面两行**不是**"账本被静默放行", 而是**显式不适用**——
2719
+ // 保护门判的是"同一目标被**重写**时是否丢卡", 而账本的写入路径恒为
2720
+ // `beforeText: ''`(每次新写一篇, 见 writeHandoffLedger 的调用), 无 before 侧可比
2721
+ // ⇒ 结构性谈不上丢卡。故此处**显式**提前返回, 并把理由写在这里。
2722
+ // ⚠️ 与 `:332` 模块注释及本函数 docblock 曾声称的"无条件生效"**措辞不一致**, 现以本处为准:
2723
+ // 保护门 = **plan 专属**; 账本/其他 target 走的是"不适用"而非"跳过检查"。
2724
+ if (target !== 'plan' && target !== 'handoff') return { ok: true, gate: 'not-applicable', reason: 'target-not-protected' }
2725
+ if (target === 'handoff') return { ok: true, gate: 'not-applicable', reason: 'ledger-append-only-no-before-side' }
2319
2726
  if (!beforeText.trim()) return { ok: true }
2320
2727
  const projB = parseWhiteboardPre(beforeText, { kind: 'plan' })
2321
2728
  const projA = parseWhiteboardPre(afterText, { kind: 'plan' })
@@ -2354,6 +2761,17 @@ class MemoryEngine {
2354
2761
  } catch (e) { return [] }
2355
2762
  }
2356
2763
 
2764
+ /** ★L3.6(2026-09-17): 列出旧会话转写(名字字典序倒序=新→旧)。
2765
+ * 存在的意义: `listHandoffLedgers` 的正则**结构化地**排除 `prev-session-*`,
2766
+ * 导致这些转写在 scope='handoff' 检索里恒不可见(用户报障"有些文件接不过去"的一条)。
2767
+ * 单独给一个 lister 而不是放宽原正则 —— 原正则同时被账本/归档的白板血缘逻辑复用,
2768
+ * 放宽会改变那些语义(它们本来就只该看见账本与 PLAN 归档)。 */
2769
+ async listPrevSessionTranscripts(dir, limit = 8) {
2770
+ try {
2771
+ return (await readdir(dir)).filter((n) => /^prev-session-[\w-]+\.md$/.test(n)).sort().reverse().slice(0, limit)
2772
+ } catch (e) { return [] }
2773
+ }
2774
+
2357
2775
  /** M-CM2:交接白板语料检索(PLAN.md+账本+归档;词法直返,轻量)。 */
2358
2776
  /**
2359
2777
  * 白板语料检索。**P2-3(2026-09-16 接线)**:由「纯词法」升级为
@@ -2416,6 +2834,18 @@ class MemoryEngine {
2416
2834
  }
2417
2835
  }
2418
2836
  await scan('白板 PLAN.md', path.join(p.handoffDir, 'PLAN.md'), 3)
2837
+ // ★L3.6(2026-09-17): 旧会话转写此前是**检索孤岛** —— listHandoffLedgers 的正则只认
2838
+ // `handoff-<ts>` / `PLAN-<ts>`, 不认 `prev-session-*` ⇒ scope='handoff' 永远看不见旧会话。
2839
+ // 这里显式纳入(最多 8 篇最新), 并**优先用文件头部的 L0 摘要行**做卡片, 让模型先看到
2840
+ // "这个会话在干什么"而不是被整篇正文淹没。fail-soft: 读不到就跳过这一篇, 不阻塞其余语料。
2841
+ // ⚠️ 必须用 typeof 守卫: 本仓老 smoke 用 new Function 从源码重建方法, 重建体里没有本方法
2842
+ // (它挂在类上、不在这段源码里) ⇒ 直接调用会 TypeError 把整条检索打断。
2843
+ // 守卫后语义正确: 没有该 lister 的宿主就跳过这一类来源, 其余语料照常返回(fail-soft)。
2844
+ const prevList = typeof this.listPrevSessionTranscripts === 'function' ? await this.listPrevSessionTranscripts(p.handoffDir, 8) : []
2845
+ for (const n of prevList) {
2846
+ if (hits.length >= limit) break
2847
+ await scan('旧会话转写/' + n, path.join(p.handoffDir, n), 2)
2848
+ }
2419
2849
  for (const n of await this.listHandoffLedgers(p.handoffDir, 12)) {
2420
2850
  if (hits.length >= limit) break
2421
2851
  await scan('交接账本/' + n, path.join(p.handoffDir, n), 2)
@@ -2689,7 +3119,7 @@ class MemoryEngine {
2689
3119
  // 旧实现硬截断到 1.5 → 任何超额水位都显示成 150%,掩盖真实占用(用户实测 761692/131072 被显示为 150%)。
2690
3120
  // 现在如实上报,仅做防溢出的 99 倍上限。
2691
3121
  const ratio = triggerWin > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / triggerWin, 99) : 0
2692
- const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.75, 0.1)
3122
+ const threshold = Math.max(Number(this.config.waterLevelThreshold) || DEFAULT_WATER_LEVEL_THRESHOLD, 0.1)
2693
3123
  // 硬触发:官方已经压缩过/已撞过窗口墙 —— 这一刻必须接续,不依赖比例是否算得准。
2694
3124
  // 2026-09-13 增加预测性硬墙:estTokens + reserve > 判定窗 ⇒ 下一次请求必被 provider 拒绝
2695
3125
  // (消息实占 + 预留输出 > 上限,v2.4.1 之前的 400 事故就是这个等式)。
@@ -3018,7 +3448,7 @@ class MemoryEngine {
3018
3448
  diag('auto-continue not armed: session model unknown (window derived from default model), waiting for a hard signal')
3019
3449
  return
3020
3450
  }
3021
- if (!(wl.ratio >= (Number(this.config.autoContinueThreshold) || 0.75))) return
3451
+ if (!(wl.ratio >= (Number(this.config.autoContinueThreshold) || DEFAULT_AUTO_CONTINUE_THRESHOLD))) return
3022
3452
  const st = this._autoContState || (this._autoContState = {})
3023
3453
  const now = Date.now()
3024
3454
  const cooldownMin = Number(this.config.autoContinueCooldownMinutes) || 30
@@ -3204,7 +3634,7 @@ class MemoryEngine {
3204
3634
  // 2026-09-14 解耦:卡片可用性只看 autoContinueEnabled(与 armAutoContinue 同源同判);
3205
3635
  // 旧实现的 `&& handoffEnabled !== false` 是同一处耦合的第二份副本(白板关⇒卡片报 disabled)。
3206
3636
  enabled: this.config.autoContinueEnabled !== false,
3207
- threshold: Number(this.config.autoContinueThreshold) || 0.75,
3637
+ threshold: Number(this.config.autoContinueThreshold) || DEFAULT_AUTO_CONTINUE_THRESHOLD,
3208
3638
  armed: armedMine ? armedRaw : null,
3209
3639
  executing: !!st.executing,
3210
3640
  rejectedEdgeAt: st.rejectedEdgeAt || 0,
@@ -3269,6 +3699,13 @@ class MemoryEngine {
3269
3699
  * 回退按会话日志 mtime 取最新,**只认 `session-` 前缀的普通会话目录**:裸 uuid 目录是子代理会话,
3270
3700
  * 把刷新仪式注进子代理等于白做(还会污染它的上下文),所以宁可返回 '' 让上层明确报"无可刷新目标"。
3271
3701
  * 5 秒缓存避免面板轮询反复扫盘。
3702
+ *
3703
+ * ★2026-09-21 补注(实测校准): 上述前缀规则**成立**, 但有一处需要说清 —— 带 `session-` 前缀的
3704
+ * 目录**不全是「顶层会话」**: 接续会话(由「一键接续/自动接续」派生)同样带此前缀, 且**带
3705
+ * parentSession**。它是**用户真实会话**(delegationDepth=0), 正是刷新仪式该注入的目标 ——
3706
+ * 故前缀规则无需改。⚠️ **切忌改用「有无 parentSession」来判断归属**: 那会把接续会话误判成
3707
+ * 子代理(restoreLastAgent 的历史 bug 即此, 详见 isSubAgentSession 的注释)。
3708
+ * 判据请统一用 isSubAgentSession()(看 origin / delegationDepth, 不看 parentSession)。
3272
3709
  */
3273
3710
  recentSessionIdFallback() {
3274
3711
  try {
@@ -3283,7 +3720,14 @@ class MemoryEngine {
3283
3720
  const wsPath = path.join(root, wsDir.name)
3284
3721
  let entries = []
3285
3722
  try { entries = readdirSync(wsPath, { withFileTypes: true }) } catch (_) { continue }
3286
- for (const ent of entries) {
3723
+ // ★2026-09-21 核查结论(证据: tools/probe-session-kind.mjs 解压会话头部):
3724
+ // 「`session-` 前缀 = 用户会话 / 裸 uuid = 子代理」这条规则**经实测仍然成立**, 无需改:
3725
+ // · session-85e2b7e8…(接续会话, parentSession=aa9ba629) depth=0 origin=- ⇒ 用户会话 ✓
3726
+ // · session-aa9ba629…(顶层会话, 无 parent) depth=0 origin=- ⇒ 用户会话 ✓
3727
+ // · 3cf74a9f… / bae475f2… / c9e60d61…(裸 uuid) depth=1 origin=subagent ⇒ 子代理 ✗
3728
+ // 注意「接续会话也带 session- 前缀」——这是**期望行为**(它是用户会话, 正是刷新目标),
3729
+ // 但也说明**不能用 parentSession 有无来判断归属**(那正是 restoreLastAgent 那个 bug 的成因)。
3730
+ for (const ent of entries) {
3287
3731
  if (!ent.isDirectory()) continue
3288
3732
  if (!/^session-/.test(ent.name)) continue
3289
3733
  let ms = 0
@@ -3532,6 +3976,20 @@ class MemoryEngine {
3532
3976
  const msgs = folded.msgs
3533
3977
  const p = await this.resolvePaths(undefined)
3534
3978
  const NL = String.fromCharCode(10)
3979
+ // ★L3.6(2026-09-17): 稳定锚点 + L0 摘要行(让旧会话转写**可被检索到**, 见 prevSessionSidAnchorPre 注释)
3980
+ const PER_MSG = 2000
3981
+ const sidAnchor = prevSessionSidAnchorPre(sid, () => createHash('sha256'))
3982
+ const l0Line = prevSessionL0Pre(msgs, sid, { cap: 120 }) || '(无可摘要内容)'
3983
+ const slim = slimTranscriptPre(msgs, {
3984
+ perMsgChars: PER_MSG,
3985
+ totalChars: 60000,
3986
+ // L3.5 的附件内联行保留在**消息附近**(位置语义: 这张图是在说什么的时候投的)
3987
+ decorate: (m) => {
3988
+ if (!Array.isArray(m.attachments) || !m.attachments.length) return ''
3989
+ const inl = renderAttachmentLinesPre(m.attachments)
3990
+ return inl.length ? NL + inl.map((x) => ' - ' + x).join(NL) : ''
3991
+ },
3992
+ })
3535
3993
  const stamp = new Date().toISOString().slice(11, 19).replace(/:/g, '')
3536
3994
  const outPath = path.join(p.handoffDir, 'prev-session-' + sid.slice(0, 8) + '-' + stamp + '.md')
3537
3995
  // 接续序号 v2(2026-09-13):先分配序号、再写包;写包失败回滚计数器(不跳号)。
@@ -3540,20 +3998,34 @@ class MemoryEngine {
3540
3998
  try { contSeq = await this.allocContSeq(p.ws) } catch (eSeq) { diag('allocContSeq error: ' + ((eSeq && eSeq.message) || eSeq)) }
3541
3999
  const body = [
3542
4000
  '# 旧会话完整转写(' + sid + ')', '',
4001
+ '<!-- ' + sidAnchor + ' -->',
3543
4002
  '- 旧会话 ID: ' + sid,
3544
4003
  '- 工作区: ' + (cwd || p.ws || '(未知)'),
3545
4004
  '- 模型: ' + (model || '(未记录)') + (provider ? ' @ ' + provider : ''),
3546
4005
  '- agentPreset: ' + (preset || '(默认)'),
3547
- '- 消息数: ' + msgs.length + '(单条截断 2000 字符,总长上限 60000)',
4006
+ '- 消息数: ' + msgs.length + '(已瘦身: 仅用户输入与助手最终输出;单条截断 ' + PER_MSG + ' 字符,总长上限 60000)',
4007
+ '- **L0**: ' + l0Line,
3548
4008
  '- 原始持久化: ' + file + '(zstd 压缩帧,AI 的 read 工具读不了;本文件是插件解压后的可读转写)', '',
3549
4009
  ]
3550
- let total = 0
3551
- for (const m of msgs) {
3552
- const chunk = '**' + m.role + '**: ' + m.text.slice(0, 2000) + NL + NL
3553
- if (total + chunk.length > 60000) { body.push('(更早内容已截断——完整历史可试 memory_recall(scope=\'sessions\', query=\'关键词\') 检索;该检索需宿主侧开启会话内容检索,条件不满足时会明确报错,此时请改用 scope=\'handoff\' 或直接 read 本文件)'); break }
3554
- body.push(chunk)
3555
- total += chunk.length
3556
- }
4010
+ // ★L3.5(2026-09-17): 附件清单 —— 把被丢弃的 attachment 字段还原成"去哪儿找"的路径。
4011
+ // 旧行为: 附件在 foldSessionLogEvents 里就不进 msgs ⇒ 接续会话完全不知道有这些材料。
4012
+ const allAtts = []
4013
+ for (const m of msgs) { if (Array.isArray(m.attachments)) for (const a of m.attachments) allAtts.push(a) }
4014
+ if (allAtts.length) {
4015
+ const attLines = renderAttachmentLinesPre(allAtts)
4016
+ body.push('## 附件清单(' + allAtts.length + ' 项,按出现顺序;同一 blob 只列一次)', '')
4017
+ for (const ln of attLines) body.push('- ' + ln)
4018
+ body.push('', '> 图片是内容寻址 blob,直接 read 上述路径即可看到原图;文件对象同理。', '')
4019
+ }
4020
+ // ★L3.6(2026-09-17): 正文改用**瘦身**结果 —— 只留用户输入与助手最终输出,
4021
+ // 工具调用/结果压成一行计数(实测工具事件是正文的 3 倍以上, 全量转写既费盘又淹没检索)。
4022
+ // 附件行仍按消息内联(见 renderAttachmentLinesPre), 位置语义不变。
4023
+ body.push(slim.body)
4024
+ if (slim.toolCalls || slim.toolResults) {
4025
+ body.push('', '> 本次转写已瘦身: 省略了 ' + slim.toolCalls + ' 次工具调用与 ' + slim.toolResults +
4026
+ ' 条工具结果(共约 ' + slim.droppedChars + ' 字符)。完整原始事件见上方"原始持久化"所指的会话日志文件。')
4027
+ }
4028
+ if (slim.trimmed) body.push('', '(更早内容已截断——完整历史可试 memory_recall(scope=\'sessions\', query=\'关键词\') 检索;该检索需宿主侧开启会话内容检索,条件不满足时会明确报错,此时请改用 scope=\'handoff\' 或直接 read 本文件)')
3557
4029
  try {
3558
4030
  // P2 ALS 修复后 refresh 真正跑起来,与 handoff 写包并发创建同一 handoffDir;
3559
4031
  // Windows 上并发 mkdir(recursive) 有 EEXIST 竞态(nodejs/node#31453 类)——目录已存在即达成目标,
@@ -3565,9 +4037,16 @@ class MemoryEngine {
3565
4037
  if (contSeq) { try { this.rollbackContSeq(p.ws, contSeq) } catch (eR) {} }
3566
4038
  throw eW
3567
4039
  }
3568
- const tail = msgs.slice(-20).map((m) => m.role + ': ' + m.text.slice(0, 700)).join(NL + '---' + NL)
3569
- diag('prev-session pack built: sid=' + sid + ' msgs=' + msgs.length + ' model=' + (model || '-') + '@' + (provider || '-') + ' effort=' + (reasoningEffort || '-') + ' contSeq=' + contSeq + ' -> ' + outPath)
3570
- return { sessionId: sid, transcriptPath: outPath, contSeq, provider, model, reasoningEffort, agentPreset: preset, cwd: cwd || p.ws, tailText: tail, msgCount: msgs.length }
4040
+ // ★L3.5: 第2层近期线程也带上附件行(单行), 让"最近投过什么"在层2就可见
4041
+ const tail = msgs.slice(-20).map((m) => {
4042
+ const base = m.role + ': ' + m.text.slice(0, 700)
4043
+ if (!Array.isArray(m.attachments) || !m.attachments.length) return base
4044
+ const inl = renderAttachmentLinesPre(m.attachments)
4045
+ return inl.length ? base + NL + inl.map((x) => ' ' + x).join(NL) : base
4046
+ }).join(NL + '---' + NL)
4047
+ const attLines = allAtts.length ? renderAttachmentLinesPre(allAtts) : []
4048
+ diag('prev-session pack built: sid=' + sid + ' msgs=' + msgs.length + ' atts=' + allAtts.length + ' model=' + (model || '-') + '@' + (provider || '-') + ' effort=' + (reasoningEffort || '-') + ' contSeq=' + contSeq + ' -> ' + outPath)
4049
+ return { sessionId: sid, transcriptPath: outPath, contSeq, provider, model, reasoningEffort, agentPreset: preset, cwd: cwd || p.ws, tailText: tail, msgCount: msgs.length, attachmentCount: allAtts.length, attachmentLines: attLines }
3571
4050
  } catch (e) { diag('buildPrevSessionPack error: ' + (e && e.message)); return null }
3572
4051
  }
3573
4052
 
@@ -3612,13 +4091,19 @@ class MemoryEngine {
3612
4091
  const prevSid = (pack && pack.sessionId) || this.currentSessionId()
3613
4092
  const wsForSession = (pack && pack.cwd) || p.ws
3614
4093
  const workspaceId = this.resolveWorkspaceIdForSession(prevSid, wsForSession)
3615
- const parts = [
4094
+ // ★2026-09-17(L3): 材料改为**三段**结构, 由 assembleCarryPre 组装 ——
4095
+ // headParts(指令, 固定) / navParts(导航区, **永不截断**) / bulkParts(正文层, 可截断)。
4096
+ // 旧实现把导航区(第3层转写路径)**放在数组末尾**, 而 `join().slice(0,18000)` 从尾部砍
4097
+ // ⇒ 逃生通道第一个被砍(用户实测报障「有些文件接不过去」)。此处把导航区提前并钉住。
4098
+ const headParts = [
3616
4099
  '接续上一会话的任务。材料已分层,请按需取用而非通读:先看第0层白板建立全局图景,再视需要看第1层账本(含已试方案与失败原因)与第2层近期线程;第3层完整转写仅在前三层不足以推进时才 read。恢复上下文后直接继续推进未完成事项,不要重新开始。',
3617
4100
  '材料分层:第0层=指令+白板(全局图景);第1层=交接账本(四段式,最新);第2层=近期线程(最近 20 条,单条上限 700 字,保留角色与工具标记);第3层=完整转写(按需 read)。',
3618
4101
  ]
4102
+ const navParts = []
4103
+ const bulkParts = []
3619
4104
  // 白板关(载体第0/1层缺失)时如实自述,避免新会话去找不存在的 PLAN.md/账本
3620
- if (!withHandoff) parts.push('注意:本次启用了自动接续但**未启用白板/账本**(handoffEnabled=false)——没有第0/1层材料,请以第2层近期线程与第3层完整转写恢复上下文,不要去找 PLAN.md 或交接账本。')
3621
- if (plan) parts.push('【第0层 · 白板 PLAN.md(节选)】更新于 ' + fmtMt(planMt) + staleNote + NL + plan.slice(0, 3000))
4105
+ if (!withHandoff) headParts.push('注意:本次启用了自动接续但**未启用白板/账本**(handoffEnabled=false)——没有第0/1层材料,请以第2层近期线程与第3层完整转写恢复上下文,不要去找 PLAN.md 或交接账本。')
4106
+ if (plan) bulkParts.push('【第0层 · 白板 PLAN.md(节选)】更新于 ' + fmtMt(planMt) + staleNote + NL + plan.slice(0, 3000))
3622
4107
  if (ledger) {
3623
4108
  // P6(2026-09-09):账本权重化截断 —— 按账本自身四段标题赋权(失败原因.35>下一步.30>目标.20>任务状态.15),
3624
4109
  // 预算不足从最低权重段开始截(段标题保留)。解析失败/动态 import 失败 fail-soft 回落位置截断,绝不阻塞接续(I4)。
@@ -3629,11 +4114,20 @@ class MemoryEngine {
3629
4114
  const weighted = weightedTrimHandoffLedgerPre(ledger, 8000)
3630
4115
  if (weighted != null) ledgerBody = weighted
3631
4116
  } catch (eLedW) {}
3632
- parts.push('【第1层 · 交接账本 ' + (ledgerName || ledgerFrom) + '】写于 ' + fmtMt(ledgerMt) + ' (' + ledgerFrom + ')' + NL + ledgerBody)
4117
+ bulkParts.push('【第1层 · 交接账本 ' + (ledgerName || ledgerFrom) + '】写于 ' + fmtMt(ledgerMt) + ' (' + ledgerFrom + ')' + NL + ledgerBody)
3633
4118
  }
3634
4119
  if (pack) {
3635
- if (pack.tailText) parts.push('【第2层 · 近期线程(最近 ' + Math.min(20, Number(pack.msgCount) || 20) + ' 条 / 共 ' + (pack.msgCount || 0) + ' 条)】' + NL + pack.tailText)
4120
+ if (pack.tailText) bulkParts.push('【第2层 · 近期线程(最近 ' + Math.min(20, Number(pack.msgCount) || 20) + ' 条 / 共 ' + (pack.msgCount || 0) + ' 条)】' + NL + pack.tailText)
4121
+ // ★L3.5(2026-09-17): 附件清单进 **bulk**(可截断的身体层) —— 用户批准的方案 A:
4122
+ // 清单是"材料", 可能很长; 而"去哪儿取"的**指令**进 nav(永不截断), 二者分离。
4123
+ if (Array.isArray(pack.attachmentLines) && pack.attachmentLines.length) {
4124
+ bulkParts.push('【第2.5层 · 旧会话附件清单(' + pack.attachmentCount + ' 项)】' + NL +
4125
+ pack.attachmentLines.map((x) => '- ' + x).join(NL))
4126
+ }
3636
4127
  const guide = ['【第3层 · 完整转写与检索(按需)】']
4128
+ if (pack.attachmentCount > 0) {
4129
+ guide.push('- 旧会话里带有 **' + pack.attachmentCount + ' 个附件**(图片/文件)。它们**不在转写正文里**,而是内容寻址 blob,已在上方"附件清单"逐条列出绝对路径 —— 需要看图/看文件时直接 read 那一路径(图片是 PNG/JPEG 原文件,可直接读);不要因为正文里只有文字就以为用户没投过材料。')
4130
+ }
3637
4131
  if (pack.transcriptPath) {
3638
4132
  guide.push('- 旧会话(' + pack.sessionId + ')的完整对话转写已写入: ' + pack.transcriptPath)
3639
4133
  guide.push('- 完整转写已归档,**不必在接续前通读**:仅在第0-2层不足以推进时再 read;也可用 memory_recall(scope=\'sessions\', query=\'关键词\') 定位片段(前提:宿主侧已开启会话内容检索;该调用返回不可用时改用 scope=\'handoff\' 或直接 read 本转写,不要反复重试)。')
@@ -3643,7 +4137,8 @@ class MemoryEngine {
3643
4137
  guide.push('- 历史会话全文检索: memory_recall(scope=\'sessions\', query=\'关键词\') 可跨全部历史会话查证细节 —— **前提:宿主 DSH 侧已开启会话内容检索(出厂默认关闭)且历史日志格式与当前编解码器兼容**;不满足时该调用会明确报错(未部署/格式不兼容),此时改用 scope=\'handoff\' 或 read 转写文件,不要重复重试。')
3644
4138
  if (pack.model) guide.push('- 状态沿用: 模型 ' + pack.model + (pack.provider ? ' @ ' + pack.provider : '') + (pack.reasoningEffort ? '(思考档位 ' + pack.reasoningEffort + ')' : '') + ' 已在新会话自动选回(失败则回退路由默认);工作区 ' + (workspaceId ? '按 workspaceId 绑定 ' + workspaceId : '按 cwd 绑定 ' + wsForSession) + ';插件配置(无人值守/水位/交接等)为全局配置,自动继承。')
3645
4139
  if (pack.agentPreset) guide.push('- 旧会话 agentPreset: ' + pack.agentPreset + '(已随创建传递;注意 code→ptc 改名的历史会话需用户级兼容预设)。')
3646
- parts.push(guide.join(NL))
4140
+ // ★L3: 导航区(逃生通道)入 navParts —— **永不截断**, 预算先扣。
4141
+ navParts.push(guide.join(NL))
3647
4142
  }
3648
4143
  // P5(2026-09-09):接续锚点表 —— 用 T1 的 L0 抽取生成「记忆条目地图」,供新会话按需下钻
3649
4144
  // (notesPath/logPath 正是 `<!-- memory:mem_<32hex> -->` 锚点的载体;纯解析零 LLM;字节稳定:
@@ -3664,7 +4159,7 @@ class MemoryEngine {
3664
4159
  anchorSectionFor('今日日志', p.logPath, 10),
3665
4160
  ])).filter(Boolean)
3666
4161
  if (anchorSections.length) {
3667
- parts.push('【锚点表 · 记忆条目地图(按需下钻,不必通读)】' + NL + anchorSections.join(NL))
4162
+ bulkParts.push('【锚点表 · 记忆条目地图(按需下钻,不必通读)】' + NL + anchorSections.join(NL))
3668
4163
  }
3669
4164
  } catch (eAnchor) {}
3670
4165
  // ── P2-4(2026-09-16 接线):注入端白板 tag 导航层 ─────────────────────────
@@ -3675,17 +4170,17 @@ class MemoryEngine {
3675
4170
  if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
3676
4171
  try {
3677
4172
  const tagMap = await this.whiteboardTagMapPre(null, p)
3678
- if (tagMap) parts.push('【白板 tag 地图(结构化导航)】' + tagMap + NL + ' · 用 memory_expand_pre(tag) 按 tag 展开条目;用 memory_trace_pre(id) 回溯某条的来源与版本链。')
4173
+ if (tagMap) bulkParts.push('【白板 tag 地图(结构化导航)】' + tagMap + NL + ' · 用 memory_expand(tag) 按 tag 展开条目;用 memory_trace(id) 回溯某条的来源与版本链。')
3679
4174
  } catch (eTagMap) {}
3680
4175
  }
3681
4176
  // ── P3-2(2026-09-16 接线):第3层唤醒句 ───────────────────────────────────
3682
- // 规划 §5 P3-2 原文:第3层 guide 加一句「白板已结构化:可用 memory_expand_pre/memory_trace_pre
4177
+ // 规划 §5 P3-2 原文:第3层 guide 加一句「白板已结构化:可用 memory_expand/memory_trace
3683
4178
  // 按 tag 主动重建,先于通读第3层」。此前只在工具描述里写了用途,接续首条消息里从未提示 ⇒
3684
4179
  // 模型不会主动去用(存量行为等同工具不存在)。同样只在 graph 档注入。
3685
4180
  if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
3686
- parts.push('【白板结构化检索(优先于通读第3层)】' + NL +
4181
+ bulkParts.push('【白板结构化检索(优先于通读第3层)】' + NL +
3687
4182
  '- 白板/账本已建结构化索引(handoff/index.json:按 tag 倒排 + 条目 id + 归档版本链)。' + NL +
3688
- '- **先按 tag 主动重建,不要直接通读第3层转写**:memory_expand_pre(tag=\'type:dead-end\') 列出所有失败方案;memory_expand_pre(tag=\'*\') 看全部条目;条目 id 可用 memory_trace_pre(id) 回溯其来源(source 文件+行)、同 tag 邻居与归档版本链。' + NL +
4183
+ '- **先按 tag 主动重建,不要直接通读第3层转写**:memory_expand(tag=\'type:dead-end\') 列出所有失败方案;memory_expand(tag=\'*\') 看全部条目;条目 id 可用 memory_trace(id) 回溯其来源(source 文件+行)、同 tag 邻居与归档版本链。' + NL +
3689
4184
  '- 仅当索引查不到所需内容时,再回落 read 第3层转写或 memory_recall(scope=\'handoff\')。')
3690
4185
  }
3691
4186
  // 接续序号 v2(2026-09-13,NEXT-VERSION-TODO 改点2):持久计数器,全局单调(跨工作区不重号)。
@@ -3699,9 +4194,15 @@ class MemoryEngine {
3699
4194
  }
3700
4195
  if (!contSeq) contSeq = 1
3701
4196
  const wsBase = String((pack && pack.cwd) || p.ws || '').split(/[\\/]/).filter(Boolean).pop() || ''
4197
+ // ★L3(2026-09-17): 用 assembleCarryPre 组装 —— headParts/navParts 永不截断, bulkParts 可截断,
4198
+ // 且截断时**显式告知未包含哪些层**。旧实现 `parts.join().slice(0,18000)` 从尾部砍,
4199
+ // 首先砍掉第3层转写路径(模型逃生通道) ⇒ 用户实测"有些文件接不过去"。
4200
+ const carry = assembleCarryPre({ head: headParts, nav: navParts, bulk: bulkParts, budget: 18000 })
3702
4201
  return {
3703
4202
  ok: true,
3704
- carryText: parts.join(NL + NL).slice(0, 18000),
4203
+ carryText: carry.text,
4204
+ carryTruncated: carry.truncated,
4205
+ carryDropped: carry.dropped,
3705
4206
  planPath: p.planPath,
3706
4207
  ws: (pack && pack.cwd) || p.ws,
3707
4208
  workspaceId: workspaceId,
@@ -3726,7 +4227,12 @@ class MemoryEngine {
3726
4227
  // 配置加载守卫(2026-09-13):handoffEnabled 出厂默认 false,若宿主刚重启、面板先于任何会话活动
3727
4228
  // 打开,旧实现按未加载的默认值误报「白板未启用」——与 resolvePaths 同款守卫。
3728
4229
  if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
3729
- if (this.config.handoffEnabled === false) return { enabled: false }
4230
+ // ★2026-09-17 解耦(L1, 与 kanbanBoardData 同批): **移除 handoffEnabled 门**,并**补上 reason**
4231
+ // (旧实现 `return { enabled:false }` 连原因都不给,前端只能猜,与看板那个误导提示同源)。
4232
+ // 判据同 kanbanBoardData: 面板是**渲染**, `handoffEnabled` 管的是**写入/取材**, 不该互相牵连。
4233
+ if (!(String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
4234
+ return { enabled: false, reason: 'legacy-mode' }
4235
+ }
3730
4236
  // 2026-09-13(工作区切换 bug·终端用户实证):有 sessionId 时按会话解析路径 —— 旧实现白板/账本
3731
4237
  // 恒用全局单值 state.*(最近活跃会话),查看非活跃会话时张冠李戴;会话解析不出身份时如实返回
3732
4238
  // wsBound:false,不再拿 dsh 启动目录(process.cwd() 回退)冒充工作区。
@@ -3749,13 +4255,13 @@ class MemoryEngine {
3749
4255
  water = {
3750
4256
  ratio: Number(rec.ratio) || 0, tokens: Number(rec.tokens) || 0, window: Number(rec.window) || 0,
3751
4257
  source: rec.source || '', meter: rec.meter || '', model: rec.model || '',
3752
- threshold: Number(this.config.waterLevelThreshold) || 0.75, at: Number(rec.at) || 0, live: true,
4258
+ threshold: Number(this.config.waterLevelThreshold) || DEFAULT_WATER_LEVEL_THRESHOLD, at: Number(rec.at) || 0, live: true,
3753
4259
  }
3754
4260
  } else {
3755
4261
  const wi = await this.waterWindowForSession(sessionId)
3756
4262
  water = {
3757
4263
  ratio: 0, tokens: 0, window: Number(wi.window) || 0, source: wi.source || '', meter: '',
3758
- model: wi.model || '', threshold: Number(this.config.waterLevelThreshold) || 0.75, at: 0, live: false,
4264
+ model: wi.model || '', threshold: Number(this.config.waterLevelThreshold) || DEFAULT_WATER_LEVEL_THRESHOLD, at: 0, live: false,
3759
4265
  }
3760
4266
  }
3761
4267
  } else {
@@ -3774,7 +4280,7 @@ class MemoryEngine {
3774
4280
  source: this.state.waterLevelSource || '',
3775
4281
  meter: this.state.waterLevelMeter || '',
3776
4282
  model: this.state.waterLevelModel || '',
3777
- threshold: Number(this.config.waterLevelThreshold) || 0.75,
4283
+ threshold: Number(this.config.waterLevelThreshold) || DEFAULT_WATER_LEVEL_THRESHOLD,
3778
4284
  at: Number(this.state.waterLevelAt) || 0,
3779
4285
  live: Number(this.state.waterLevelAt) > 0,
3780
4286
  }
@@ -4418,7 +4924,19 @@ class MemoryEngine {
4418
4924
  // "无可回收",而文件其实只差几十字符就超限(2026-09-10 实机踩到,报错文案即"可回收内容为空")。
4419
4925
  const needChars = Number(opts.needChars || 0) || (curChars + 1)
4420
4926
  const deficit = Math.max(1, needChars - limit)
4421
- const keepBudget = Math.max(COMPACT_PROTECT_RECENT_CHARS, limit - deficit - 1) // legacy 文本路径仍按"保留量"口径
4927
+ // ★R4(2026-09-18)修缺陷:保留量目标必须按**当前长度 − 缺口**算,**不能用 limit**。
4928
+ //
4929
+ // 旧写法 `Math.max(COMPACT_PROTECT_RECENT_CHARS, limit - deficit - 1)` 有两处方向性错误:
4930
+ // ① 代入 deficit = curChars + add − limit 后展开为 `2*limit − curChars − add − 1`
4931
+ // ⇒ **保留目标随额度单调递增**(额度调大 → 目标变大 → 越"没东西可回收");
4932
+ // ② 被 `COMPACT_PROTECT_RECENT_CHARS`(2000) 抬起 ⇒ **软**保护窗口变成了**硬**下限,
4933
+ // 文件本身不足 2000 字符时"可回收"恒为空 —— 与锚点路径
4934
+ // (compactAnchoredLayer 的注释明写"保护窗口是**软**下限,唯一硬底线是最新 1 条")语义打架。
4935
+ // 后果实测:额度落在 [L2, L1) 区间时写入被拒(no-removable),而更小或更大的额度都能写
4936
+ // ⇒ **非单调**,用户看到的正是「把额度加大成两倍反而锁死」。回归见
4937
+ // tests/smoke/smoke-test-r4-budget-lockup-pre.mjs。
4938
+ // 现在:保留量 = 恰好能放下本次写入的量;保护窗口仍由"从尾往前保留 + 最新段硬底线"实现。
4939
+ const keepBudget = Math.max(0, curChars - deficit - 1)
4422
4940
 
4423
4941
  const store0 = this.docStore
4424
4942
  if (store0) {
@@ -4493,6 +5011,20 @@ class MemoryEngine {
4493
5011
  const archText = oldSegs.map(seqOf).filter(Boolean).join('\n')
4494
5012
  if (archText) await this.appendText(archiveFile, '\n' + archText)
4495
5013
  }
5014
+ // ★R4(2026-09-18)修缺陷:**回写量护栏** —— 与锚点路径同一纪律(见 compactAnchoredLayer 的
5015
+ // `if (folded && folded.length > Math.max(0, removed - deficit)) folded = ''`)。
5016
+ //
5017
+ // 旧实现缺这道护栏 ⇒ 上面那条"AI 不可用"分支会把**刚归档掉的老段落原样写回主文件**
5018
+ // (`folded = remainText.slice(0, keepBudget)`,而 keepBudget 够大时它等于全部老段落)⇒
5019
+ // **归档确实发生了、空间却没腾出来**(实测 `[compacted] note: 1729 -> 1730 chars`,
5020
+ // 不降反升)。后果:ensureBudget 三轮整理后仍超限 ⇒ 返回 `still-over-capacity` ⇒
5021
+ // 用户看到"写不进去",而日志里明明写着整理成功 —— 正是最难排查的那种静默失效。
5022
+ // 判据:回写量不得超过"本次腾出的空间 − 还需的缺口"。
5023
+ // 注:本函数签名没有 `deficit`(只收 keepBudget),但 keepBudget 恒等于 `cur.length - deficit - 1`
5024
+ // 的推导目标 ⇒ 用 `cur.length - keepBudget` 等价还原缺口,无需改签名(不动调用契约)。
5025
+ const reclaimed = oldText.length
5026
+ const deficitHere = Math.max(0, cur.length - keepBudget)
5027
+ if (folded && folded.length > Math.max(0, reclaimed - deficitHere)) folded = ''
4496
5028
  const body = [folded, keptText].filter(Boolean).join('\n\n')
4497
5029
  await this.writeFull(layer === 'user' ? p.userFile : p.notesPath, body)
4498
5030
  if (layer === 'user') this.state.userText = body
@@ -4794,6 +5326,10 @@ class MemoryEngine {
4794
5326
  excludedSources: res.excluded || { count: 0, total: 0, patterns: [] },
4795
5327
  at: Date.now(),
4796
5328
  }
5329
+ // ★R4(2026-09-18):把本轮配额切片喂给探针(观测面,不参与任何判定/行为)。
5330
+ // 采集点紧贴 tier0Meta 赋值 ⇒ 采到的就是本轮**真实生效**的配额与丢弃数。
5331
+ // 与降级台账**判据并列不混**(见 _quotaProbe 的创建注释)。
5332
+ try { if (this._quotaProbe) this._quotaProbe.observe(s.tier0Meta) } catch (_) {}
4797
5333
  return s.tier0LayerText
4798
5334
  } catch (e) {
4799
5335
  // C5 fail-open:目录层出任何异常都不拖垮记忆快照(I7:但必须留下可见痕迹,不静默)
@@ -5091,13 +5627,15 @@ class MemoryEngine {
5091
5627
  // 防御式调用:`renderMemoryDynamic` 是源码抽取式测试的被测对象(`new Function` 里 `this` 是 fake),
5092
5628
  // 宿主方法在沙箱里可能不存在 ⇒ 用 typeof 兜底,避免 ReferenceError 被外层 catch 吞成**整块空串**。
5093
5629
  const planAsk = (typeof this.renderPlanUpdateRequest === 'function') ? this.renderPlanUpdateRequest() : ''
5094
- if (planAsk) pushPart('otherDynamic', 'plan-update-request', planAsk, null, 'must')
5630
+ // ★2026-09-20 移植(issue #88 / PR #95):pushPart 只收 4 参(第 4 参才是 priority)——
5631
+ // 旧调用传了 5 个实参,'must' 被 JS 静默丢弃 ⇒ 该段落成 normal,预算超限时可被整段丢弃。
5632
+ if (planAsk) pushPart('otherDynamic', 'plan-update-request', planAsk, 'must')
5095
5633
  // 新bug修复①(2026-09-08):工作区未绑定/项目记忆为空时,注入全局最近账本的绝对路径指针(模型可直接读取续命)
5096
5634
  if (cfg.handoffEnabled !== false && !s.planText && !s.latestHandoffText && s.globalLedgerPath) {
5097
5635
  pushPart('otherDynamic', 'handoff-pointer', '\n[交接续命] 当前会话未绑定工作区(项目记忆不可用)。全局最近交接账本: ' + s.globalLedgerPath + ' —— 需要续接上次任务时,直接读取该文件恢复上下文;工作区绑定后白板/账本即恢复正常注入。')
5098
5636
  }
5099
5637
  // M-CM4/M-CM5 水位建议:越阈注入交接+派子代理建议(advisory;10 分钟新鲜度;无人值守静默)
5100
- if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) || 0.75) && Date.now() - (s.waterLevelAt || 0) < 600000) {
5638
+ if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) || DEFAULT_WATER_LEVEL_THRESHOLD) && Date.now() - (s.waterLevelAt || 0) < 600000) {
5101
5639
  pushPart('otherDynamic', 'water-advisory', '\n' + L('snapshotWaterTitle') + '\n' + L('snapshotWaterBody', { pct: Math.round((s.waterLevelRatio || 0) * 100) }))
5102
5640
  }
5103
5641
  // 2026-09-14 T0-3 修复(P0):原实现把"目录可扣额度"硬封顶为总预算的 35%,
@@ -5164,7 +5702,10 @@ class MemoryEngine {
5164
5702
  pushPart('otherDynamic', 'welcome-title', '\n' + L('snapshotWelcomeTitle'))
5165
5703
  pushPart('otherDynamic', 'welcome-body', L('snapshotWelcomeBody'))
5166
5704
  }
5167
- pushPart('otherDynamic', 'frame-inscription', '\n' + L('snapshotInscription', { date: this.memToday() })) // 动态快照追加在历史尾部,变化只 miss 快照本身;秒级时间戳也不再击穿 system prompt 前缀
5705
+ // ★T5(2026-09-20 用户拍板):加 'must' 保护 —— 本段是**收尾自检位**(recency 最高),
5706
+ // 额度紧张时不许被「按优先级丢弃」静默砍掉(那是 M-CM4 期的段丢弃规则)。
5707
+ // 对照:紧随其后的 frame-tail 一直是 'must',而本段此前裸奔 ⇒ 保护等级反而低于它的结束标记。
5708
+ pushPart('otherDynamic', 'frame-inscription', '\n' + L('snapshotInscription', { date: this.memToday() }), 'must') // 动态快照追加在历史尾部,变化只 miss 快照本身;秒级时间戳也不再击穿 system prompt 前缀
5168
5709
  pushPart('otherDynamic', 'frame-tail', L('snapshotTail') || '</memory_system>', 'must')
5169
5710
  // 0.1.39 兜底:整个动态快照出插件前统一中和模板变量(覆盖反思摘要/日历/外部/层覆盖文案等所有支路)
5170
5711
  // —— T0-3 后改为**逐段**中和:双花括号→全角双花括号 是等长替换,逐段与整篇结果逐字节相同,
@@ -5230,7 +5771,24 @@ class MemoryEngine {
5230
5771
  lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
5231
5772
  lines.push('- **交接与白板(长任务续命)**:阶段产出或方向变化时,调用 memory_note(kind=handoff) 写四段式交接账本——任务状态/目标/已试方案与失败原因/进度与下一步,给下一个上下文窗口续命;对项目全貌的理解发生实质变化时,用 memory_note(kind=plan) 重写白板 PLAN.md(人能读的项目规划图,旧版自动归档,用户在面板实时可见)。账本质量纪律:每段 ≤5 行;失败项写成「方案→失败原因」并保留关键报错词;下一步必须是可直接执行的第一步(带文件路径或命令);不写临时信息。两者会注入到你的动态快照首位,是跨窗口交接的凭据。')
5232
5773
  lines.push('- 只记录有跨会话长期价值的;不记临时信息(搜索结果、临时路径、工具报错)。')
5233
- lines.push('- 记忆容量(设置项 noteCapacityChars/userCapacityChars,默认各 12000 字符):项目笔记与用户级记忆各有容量上限;超出时框架自动整理——先把较早内容交给 AI 折叠成要点、失败则退回整条归档(原文进 archive,信息不丢),整理后仍超才拒绝写入,所以**新记忆正常不会被堵在外面**。整理同一层 10 分钟内只做一次;每日日志无容量限制。注意这与「注入预算」不同:注入预算管每轮往上下文塞多少摘要,容量上限管文件本体大小。')
5774
+ // ★T6(2026-09-20 用户拍板):**看板分列机制** —— 模型此前完全不知道有 tag 这回事。
5775
+ // 根因:wb-sidecar.js:594-597 的 WB_KANBAN_LANES_V1 靠 matchTags 匹配
5776
+ // `type:goal` / `type:state` / `type:dead-end` / `type:progress`;不写 tag 就只能靠
5777
+ // matchTitle 正则(标题含"目标"/"任务状态"/"失败"/"进度")兜底 ⇒ 模型自拟标题时看板常空列。
5778
+ lines.push('- **看板分列(白板与账本的可视化归类)**:面板看板按 **5 条泳道**分列 —— `目标` / `进行中` / `失败与弯路` / `进度与下一步` / `版本归档`。'
5779
+ + '**要让内容落进对应泳道,须在小节标题行或正文里写 tag**:`type:goal`(目标)、`type:state`(进行中)、`type:dead-end`(失败与弯路)、`type:progress`(进度与下一步)。'
5780
+ + '不写 tag 时系统会退回**按标题文字猜**(含"目标"/"任务状态"/"失败"/"进度"等词),猜不中就等于看板上是空的。'
5781
+ + '注意:这是**白板/账本**的分列规则,与 `memory_note` 的 `kind=plan/handoff/note` 是两回事 —— 后者决定"写到哪个文件",前者决定"面板上排到哪列"。')
5782
+ // ★ T4(2026-09-19 用户拍板):**procedure memory 的模型直写通路**。
5783
+ // 为什么必须显式提示:此前 procedural 线**只有机械生成**(crossFeed 把 episode 的
5784
+ // actions=['user','user','user'] 切成候选),产出 14 条里 13 条是空壳;清洗器无法
5785
+ // 把机械切片变成有价值的流程(输入本就不含流程信息)。模型不主动写,这条路就是空的。
5786
+ // 用户原话:「如果有值得介入 procedure memory 的东西,那就接入写进审批列表。」
5787
+ lines.push('- **★技能库(procedure memory)直写**:你刚跑通一个**多步骤、可重复、下次遇到类似场景能照做**的流程(或踩坑后总结出正确做法)时,调 `memory_procedure` 把它写进去——**这是技能库的唯一模型入口,不写就没有**。'
5788
+ + '默认 `action=write` 进审批列表(保守,推荐先这样);**若你确信它稳定可复用**,用 `action=activate` 一步激活(可被自动召回、并自动导出 SKILL.md)。'
5789
+ + '**必填** title + steps(steps 一行一步);**强烈建议填 successCriteria**(怎么算跑通)——**没有 successCriteria 的条目结构上永远无法晋升**。'
5790
+ + '**什么值得写**:可复用的操作序列、正确的排查顺序、被验证过的配置步骤。**什么不值得**:一次性的问答、纯信息查询、还没跑通的尝试。判据是「下次我遇到类似场景,会不会想照做」。')
5791
+ lines.push('- 记忆容量(设置项 noteCapacityChars/userCapacityChars,默认各 **24000** 字符;2026-09-18 由 12000 上调,老配置仍是 12000 的会自动抬到 24000 一次):项目笔记与用户级记忆各有容量上限;超出时框架自动整理——先把较早内容交给 AI 折叠成要点、失败则退回整条归档(原文进 archive,信息不丢),整理后仍超才拒绝写入,所以**新记忆正常不会被堵在外面**。整理同一层 10 分钟内只做一次;每日日志无容量限制。注意这与「注入预算」不同:注入预算管每轮往上下文塞多少摘要,容量上限管文件本体大小。')
5234
5792
  lines.push('- **记忆操作必须在正文可见(摘要链)**:调用 memory_log/note/user/reflect 更新记忆后,必须把结果写进本轮回复的正文文本(用户直接看到的那段文字,不是工具调用区),并在**回复末尾**用加粗或换行使其醒目(如"**已更新今日日志**\n新增:修复了XXX");调用 memory_recall/memory_external 检索时,在正文开头写明"我查了记忆,发现..."。工具返回值只是辅助,正文转述是强制要求。')
5235
5793
  lines.push('- 用户明确要求长期记住:跨项目规则 → memory_user;仅本项目 → memory_note。')
5236
5794
  lines.push('- 定期调用 memory_maintain 做 30 天蒸馏:AI 提炼旧日志要点进项目笔记,原文保底归档;不存密钥,除非用户明确要求。')
@@ -5374,6 +5932,13 @@ class MemoryEngine {
5374
5932
 
5375
5933
  async appendText(p, text) {
5376
5934
  if (process.env.DSH_F1_DEBUG) console.error('[f1-diag] appendText -> ' + p + ' len=' + String(text || '').length)
5935
+ // ★ T3-1(2026-09-19):**新增守卫,一行收口**。
5936
+ // 只做卫生检查(拦乱码/复读/raw-json/base64/重复行),**不做任何体量截断** ——
5937
+ // 故 archive 全文保底(:7716 走 writeFull)与本处的大文本内联(:7733)不受影响。
5938
+ // 拒绝时**抛出**:写入原语的既有契约就是"失败即抛"(见下方 memoryWriteError),
5939
+ // 调用方已有 try/catch 兜底(如 maintain 的 per-log catch、hubFlushTick 的 eFlush 分支)。
5940
+ const _hg = hygieneGateForPrimitive(text)
5941
+ if (!_hg.ok) throw memoryWriteError('hygiene', _hg)
5377
5942
  // M3b-3 分流:anchor 开启 → 记忆文档走原子写入事务(稳定 marker/ID);关闭 → 原逻辑逐字节不变。
5378
5943
  const store = this.docStore
5379
5944
  if (store) {
@@ -5423,6 +5988,87 @@ class MemoryEngine {
5423
5988
  await writeFile(p, text, 'utf8')
5424
5989
  }
5425
5990
 
5991
+ /**
5992
+ * ★G3(2026-09-19)结论层状态写入 —— 给指定条目落 `status`,**只在显式传参时被调用**。
5993
+ *
5994
+ * 纪律(逐条对应设计稿,违反即回滚):
5995
+ * ① **不新建状态源**(S10.4):状态写在**条目自身**正文末尾,走既有写盘通道。
5996
+ * ② **不绕过写入纪律**:读原文 → 纯函数生成新文 → `writeFull`(备份/校验/无 BOM 全由既有事务负责)。
5997
+ * ③ **只动目标条目**:`note-status-apply-pre` 保证其余字节不变(含 CRLF),避免无关条目 digest 漂移。
5998
+ * ④ **留痕**:每次状态变更都写一行到既有日志通道旁(不新建状态源),否则又是"静默改写"。
5999
+ * ⑤ **fail-soft**:任何一步失败只返回说明并记降级台账,**绝不抛出**(调用方已成功写入笔记)。
6000
+ *
6001
+ * @param {string} notesPath 项目笔记路径
6002
+ * @param {{supersedes?:string[], retract?:string[], restore?:string[], reason?:string}} plan
6003
+ * @param {string} [fallbackText] 调用方刚写入的全文(读取失败时兜底)
6004
+ * @returns {Promise<string>} 追加到工具返回值末尾的说明(无动作时为空串)
6005
+ */
6006
+ async applyNoteStatusPre(notesPath, plan, fallbackText) {
6007
+ try {
6008
+ const { applyStatusToRecordPre, readRecordStatusPre } = await import('./note-status-apply.js')
6009
+ let text = ''
6010
+ try { text = await this.readTextSafe(notesPath) } catch (_) { text = '' }
6011
+ if (!text) text = String(fallbackText || '')
6012
+ if (!text) return '\n(状态写入跳过:笔记为空)'
6013
+
6014
+ const done = []
6015
+ const skipped = []
6016
+ // 指向:用本次**新写入内容**里最后一个锚点 id(即"新结论")作为 supersededBy。
6017
+ const newIds = (String(fallbackText || '').match(/mem_[0-9a-f]{32}/g) || [])
6018
+ const byId = newIds.length ? newIds[newIds.length - 1] : ''
6019
+
6020
+ for (const id of (plan.supersedes || [])) {
6021
+ const cur = readRecordStatusPre(text, id)
6022
+ if (!cur) { skipped.push(id.slice(4, 12) + '(未找到)'); continue }
6023
+ if (cur.status === 'superseded') { skipped.push(id.slice(4, 12) + '(已作废)'); continue }
6024
+ const next = applyStatusToRecordPre(text, id, 'superseded', byId ? { supersededBy: byId } : {})
6025
+ if (!next) { skipped.push(id.slice(4, 12) + '(不可应用)'); continue }
6026
+ text = next
6027
+ done.push(id.slice(4, 12) + '→superseded')
6028
+ }
6029
+ // ★T6(2026-09-20 用户拍板):**retracted 通道**。
6030
+ // 为什么必须有:三态枚举(note-status-pre 的 NOTE_STATUSES_V1)含 retracted,renderStatusLinePre
6031
+ // 也支持渲染它,L0 侧还专门有 L0_RETRACTED_MARK_V1='⚠已撤回' 的呈现后缀 —— 但此前
6032
+ // memory_note 只有 `supersedes` 一个通道且硬编码映射到 superseded ⇒ **retracted 模型写不了**。
6033
+ // 用户 2026-09-18 裁定「retracted 不是垃圾,是教训,不过滤只备注」⇒ 教训通路必须可写。
6034
+ // 语义分工(写进 tool description,模型据此选):
6035
+ // supersedes = 被**更新的结论取代**(有后继,可追 mem_id)
6036
+ // retract = **做错了、撤回**(无后继,本身就是教训;可带 reason 说明错在哪)
6037
+ for (const id of (plan.retract || [])) {
6038
+ const cur = readRecordStatusPre(text, id)
6039
+ if (!cur) { skipped.push(id.slice(4, 12) + '(未找到)'); continue }
6040
+ if (cur.status === 'retracted') { skipped.push(id.slice(4, 12) + '(已撤回)'); continue }
6041
+ const next = applyStatusToRecordPre(text, id, 'retracted', plan.reason ? { reason: String(plan.reason) } : {})
6042
+ if (!next) { skipped.push(id.slice(4, 12) + '(不可应用)'); continue }
6043
+ text = next
6044
+ done.push(id.slice(4, 12) + '→retracted')
6045
+ }
6046
+ for (const id of (plan.restore || [])) {
6047
+ const cur = readRecordStatusPre(text, id)
6048
+ if (!cur) { skipped.push(id.slice(4, 12) + '(未找到)'); continue }
6049
+ if (cur.status === 'current') { skipped.push(id.slice(4, 12) + '(已是 current)'); continue }
6050
+ const next = applyStatusToRecordPre(text, id, 'current')
6051
+ if (!next) { skipped.push(id.slice(4, 12) + '(不可应用)'); continue }
6052
+ text = next
6053
+ done.push(id.slice(4, 12) + '→current(撤销)')
6054
+ }
6055
+
6056
+ if (!done.length) return skipped.length ? '\n(状态未变更:' + skipped.join(', ') + ')' : ''
6057
+
6058
+ await this.writeFull(notesPath, text)
6059
+ this.state.notesText = text; this.state.loadedAt = Date.now()
6060
+ // ④ 留痕:与笔记同目录(.dsh-memory/),append-only
6061
+ try {
6062
+ await this.appendText(path.join(path.dirname(notesPath), 'STATUS-CHANGES.log'),
6063
+ '[' + new Date().toISOString() + '] ' + done.join(', ') + (skipped.length ? ' | skipped: ' + skipped.join(', ') : '') + '\n')
6064
+ } catch (_) { /* 留痕失败不影响主流程 */ }
6065
+ return '\n状态已更新:' + done.join(', ') + (skipped.length ? '\n(跳过:' + skipped.join(', ') + ')' : '')
6066
+ } catch (e) {
6067
+ try { if (this._degradePre && typeof this._degradePre.record === 'function') this._degradePre.record('note-status', String((e && e.message) || e)) } catch (_) {}
6068
+ return '\n(状态写入失败,笔记已正常保存:' + String((e && e.message) || e) + ')'
6069
+ }
6070
+ }
6071
+
5426
6072
  // ---------- 检索 ----------
5427
6073
  async recall(query, limit = 8, agent, scope = 'all', opts = null) {
5428
6074
  // P4(2026-09-09):按需展开入口 —— opts.expand 指定 mem_<32hex> 时跳过检索,按锚点字节区间直接返回该条原文。
@@ -5453,6 +6099,13 @@ class MemoryEngine {
5453
6099
  }
5454
6100
  const out = []
5455
6101
  const hits = []
6102
+ // ★G3 读侧(2026-09-19):磁盘状态行解析器。**单一解析点** —— 写侧与读侧共用
6103
+ // `note-status-apply-pre` / `note-status-pre` 的同一套语法,避免读写口径漂移。
6104
+ // ★声明位置必须在**方法体顶层**:两个消费点分属互斥分支
6105
+ // (词法臂在 `if (l0Mode)`、语义臂在 `if (... && !l0Mode)`),
6106
+ // 声明若放进任一分支,另一分支引用即 ReferenceError 并被下游 fail-soft 吞掉
6107
+ // ⇒ **静默降级、语义臂整体失效**(本仓「标识符作用域」类缺陷,写前必核)。
6108
+ const { readRecordStatusPre: statusOfNotePre } = await import('./note-status-apply.js')
5456
6109
  const scanFile = async (label, filePath, maxMatches = 3, target = hits) => {
5457
6110
  const text = await this.readTextSafe(filePath)
5458
6111
  if (!text) return
@@ -5497,20 +6150,48 @@ class MemoryEngine {
5497
6150
  // 用户级/项目笔记在日志很多时**永远进不了候选**,表现为"够不到 ~5 天前的记录"。)
5498
6151
  if (l0Mode) {
5499
6152
  const { buildL0IndexPre, isCurrentPre } = await import('./l0-extract.js')
6153
+ // ★R4-B(2026-09-18):分层呈现的分组函数 —— **单独一行 import**,刻意不改上面那行的
6154
+ // 字面形态:`smoke-test-three-layer-pre` 的「三层契约 I5 检索侧接线」断言以**字面量**
6155
+ // 锁定它(`const { buildL0IndexPre, isCurrentPre } = await import(...)`),
6156
+ // 合并 import 会让该契约守卫假红。合并是"更漂亮",但契约守卫的价值高于排版。
6157
+ const { groupL0ByLayerPre: groupL0 } = await import('./l0-extract.js')
6158
+ // ★R4-A 落地(2026-09-19):检索侧改用**准入谓词** isRetrievablePre(三态一律放行),
6159
+ // 并在输出处附 supersededMarkPre 的标记后缀。「返回但标记」是用户两次修正后的定稿:
6160
+ // retracted 不是垃圾,**它是教训**(「不记住这个教训你还会再踩」)。
6161
+ // 注入侧仍用 isCurrentPre(常驻 800 token 不装过时条目)—— 两处判据不同是有意为之。
6162
+ const { isRetrievablePre: retrievableL0, supersededMarkPre: markL0 } = await import('./l0-extract.js')
5500
6163
  const l0Corpus = []
5501
6164
  // C2(2026-09-14,三层契约):语料条目带 layer/status —— 层名由**来源路径**判定(classifyLayerPre);
5502
- // 非 current(superseded/retracted)在这里就被剔除(契约 I5「检索侧」过滤;注入侧在 context-host 另有一道)。
6165
+ // R4-A(2026-09-19):非 current 的三态条目**不再剔除**,改为标记后一并返回(见上)。
5503
6166
  const pushL0 = (label, text, srcPath) => {
5504
6167
  if (!text) return
5505
- for (const it of buildL0IndexPre(text, srcPath ? { layer: srcPath } : undefined)) {
5506
- if (!isCurrentPre(it)) continue
5507
- l0Corpus.push({ id: it.id, l0: it.l0, label, layer: it.layer || 'log', status: it.status || 'current' })
6168
+ // ★G3 读侧接线(2026-09-19):把**磁盘上的状态行**解析出来经 statusOf 注入。
6169
+ // 没有这一步,写进 MEMORY.md 的 `<!-- dsh-status: ... -->` 永远读不回来
6170
+ // ⇒ G3 只会"写得很热闹、检索侧毫无变化"(正是本仓最忌的"接线正确但功能不存在")。
6171
+ // 解析失败 ⇒ statusOf 返回 current(fail-soft,与索引层「缺失即默认」口径一致)。
6172
+ const opts = srcPath ? { layer: srcPath, statusOf: (id) => statusOfNotePre(text, id) } : undefined
6173
+ for (const it of buildL0IndexPre(text, opts)) {
6174
+ if (!retrievableL0(it)) continue
6175
+ l0Corpus.push({ id: it.id, l0: it.l0, label, layer: it.layer || 'log', status: it.status || 'current', mark: markL0(it) })
5508
6176
  }
5509
6177
  }
5510
6178
  for (const log of logs) { const f = path.join(p.projectDir, log.name); pushL0(log.name, await this.readTextSafe(f), f) }
5511
6179
  for (const rf of reflections) { const f = path.join(p.reflectDir, rf.name); pushL0('reflections/' + rf.name, await this.readTextSafe(f), f) }
5512
6180
  pushL0(p.projectDir + '/MEMORY.md', await this.readTextSafe(p.notesPath), p.notesPath)
5513
6181
  pushL0('~' + p.userFile.slice(homedir().length), await this.readTextSafe(p.userFile), p.userFile)
6182
+ // ★L4(2026-09-17)·白板进检索 —— 缺口 2 的**读取侧**接续。
6183
+ // 此前白板只进"注入"(:4706 一线), **不进检索** ⇒ 模型问"上次那个失败方案是啥"时,
6184
+ // 检索臂扫不到 PLAN/账本, 只能靠注入的目录层碰运气。这里补上检索侧的两个来源。
6185
+ // 闸门口径: 白板是**产物层**, 归 `handoffEnabled` 管(关时既不写也不读, 见 buildContinueCarry 头注释);
6186
+ // **不是** boardMode 渲染门 —— 故此处只判 handoffEnabled, 不判 boardMode(与 L5 判据同源)。
6187
+ const withHandoffL4 = (this.config || {}).handoffEnabled !== false
6188
+ if (withHandoffL4) {
6189
+ try {
6190
+ pushL0('handoff/PLAN.md', await this.readTextSafe(p.planPath), p.planPath)
6191
+ const ledgerNameL4 = await this.latestLedgerNamePre(p.handoffDir)
6192
+ if (ledgerNameL4) pushL0('handoff/' + ledgerNameL4, await this.readTextSafe(path.join(p.handoffDir, ledgerNameL4)), path.join(p.handoffDir, ledgerNameL4))
6193
+ } catch (eL4) { /* fail-soft: 白板取不到就跳过这两个来源, 绝不阻塞检索 */ }
6194
+ }
5514
6195
  // #45:不再在此处按 256 截断 —— 截断会使追加顺序靠后的来源(笔记/用户级)永久失去候选资格。
5515
6196
  // 输出量由下游 rank + limit 控制,不牺牲召回覆盖。
5516
6197
  for (const c of l0Corpus) {
@@ -5532,7 +6213,11 @@ class MemoryEngine {
5532
6213
  if (typeof this._semanticRankBest === 'function') rank = await this._semanticRankBest(rankSnap, query)
5533
6214
  else if (typeof this._jsSemanticRank === 'function') rank = await this._jsSemanticRank(rankSnap, query)
5534
6215
  if (rank && rank.scores && rank.scores.size) semScores = rank.scores
5535
- } catch (eBest) { try { diag('recall 语义臂择优降级: ' + String((eBest && eBest.message) || eBest).slice(0, 120)) } catch (_) {} }
6216
+ } catch (eBest) {
6217
+ // R3:预期外失败 —— 引擎/worker 抛错导致回退词法,用户应当能知道(不只是 diag 一行)。
6218
+ try { diag('recall 语义臂择优降级: ' + String((eBest && eBest.message) || eBest).slice(0, 120)) } catch (_) {}
6219
+ try { if (this._degradeSink) this._degradeSink.record('semantic-arm', String((eBest && eBest.message) || eBest).slice(0, 160)) } catch (_) {}
6220
+ }
5536
6221
  if (semScores && semScores.size) {
5537
6222
  for (const c of l0Corpus) {
5538
6223
  const sc = semScores.get(c.id)
@@ -5543,16 +6228,35 @@ class MemoryEngine {
5543
6228
  // 复用 :6876 证据读取范式);中性 0.5 → 因子 0.75 全体一致缩放=排序不变;correction 重则因子降至 0.5。
5544
6229
  // 任何失败 → impMap 空 → 全体中性,绝不阻塞检索。importance 仅为加权因子之一,lex 臂不受影响。
5545
6230
  const impMap = new Map()
6231
+ // ★R2-E1(2026-09-18):读侧守卫 —— 与写侧「懒建」契约对齐。
6232
+ // 写侧 `evidence-store.js:126` 的 mkdirSync 只在**首次成功 append** 时建目录,
6233
+ // 且 persistEvidence 的两个调用点都带内容前置条件(context-host.js:653/:699)
6234
+ // ⇒ **「目录不存在」在写侧是合法状态**(新装 / 用法未触发写入的用户永远没有它)。
6235
+ // 原实现读侧无条件 readdirSync ⇒ ENOENT 被下方 catch 吞掉 ⇒ impMap 恒空
6236
+ // ⇒ importance 全体中性 ⇒ dense 臂因子恒 0.75 ⇒ **该用户每次 recall 都静默失去 importance 加权**。
6237
+ // 定性:契约缺口(读写对「目录可能不存在」无共识),非容错不足。
6238
+ // 处置:此处**静默跳过**(预期内分支,不算降级、不写 diag,避免刷屏);
6239
+ // 「该臂整体未生效」的可见性交给 R3 留痕层。
5546
6240
  try {
5547
- const { scanEvidenceEventsPre, aggregateEvidenceEventsPre } = await import('./evidence-agg.js')
5548
- const { computeImportancePre } = await import('./memory-importance.js')
5549
6241
  const evDir = path.join(dshHome(), 'memory', 'evidence', 'events')
5550
- const agg = aggregateEvidenceEventsPre(scanEvidenceEventsPre({
5551
- listFiles: () => readdirSync(evDir).filter((x) => x.endsWith('.jsonl')),
5552
- readFile: (name) => readFileSync(path.join(evDir, name), 'utf8'),
5553
- }, {}))
5554
- for (const [mid, a] of agg) impMap.set(mid, computeImportancePre(a).importance)
5555
- } catch (eImp) { try { diag('evidence-agg 降级为中性(impMap 空): ' + String((eImp && eImp.message) || eImp).slice(0, 140)) } catch (_) {} }
6242
+ if (!existsSync(evDir)) {
6243
+ // 目录不存在 = 从未产生过证据事件 = 合法状态 ⇒ 保持 impMap 空(全体中性),直接跳过。
6244
+ // 不抛异常、不写 diag:这不是故障,是「这条臂暂无输入」。
6245
+ } else {
6246
+ const { scanEvidenceEventsPre, aggregateEvidenceEventsPre } = await import('./evidence-agg.js')
6247
+ const { computeImportancePre } = await import('./memory-importance.js')
6248
+ const agg = aggregateEvidenceEventsPre(scanEvidenceEventsPre({
6249
+ listFiles: () => readdirSync(evDir).filter((x) => x.endsWith('.jsonl')),
6250
+ readFile: (name) => readFileSync(path.join(evDir, name), 'utf8'),
6251
+ }, {}))
6252
+ for (const [mid, a] of agg) impMap.set(mid, computeImportancePre(a).importance)
6253
+ }
6254
+ } catch (eImp) {
6255
+ // R3:预期外失败才记。目录不存在已被上面的 existsSync 守卫拦下(预期内分支,静默);
6256
+ // 走到这里的都是真异常(聚合/读文件/import 失败)⇒ 应当留痕。
6257
+ try { diag('evidence-agg 降级为中性(impMap 空): ' + String((eImp && eImp.message) || eImp).slice(0, 140)) } catch (_) {}
6258
+ try { if (this._degradeSink) this._degradeSink.record('evidence-arm', String((eImp && eImp.message) || eImp).slice(0, 160)) } catch (_) {}
6259
+ }
5556
6260
  const l0Hits = l0Corpus.filter((c) => c.lex > 0 || (typeof c.sem === 'number' && c.sem >= 0.5))
5557
6261
  // 时间臂(2026-09-09):查询含中文时间表达 → 解析 [startMs,endMs),候选 label 中的日志日期
5558
6262
  // (YYYY-MM-DD)命中 → temp=1 软提升;非日期来源(MEMORY.md/~userfile)不给 temp(中性)。
@@ -5582,7 +6286,7 @@ class MemoryEngine {
5582
6286
  // P3(2026-09-16) R1 双显示:RRF 融合输出的 finalRank(输出序=注入/展示序)保留到候选对象上,
5583
6287
  // 与 denseScore(绝对,决策用)并存 —— 相似度在前、融合序号在后,不拿融合分冒充相似度。
5584
6288
  // finalRank 口径 = 本次融合的最终输出序(1 起);决策仍走 sem>=0.5 绝对阈值(R2 维持)。
5585
- const fusion = rankFusionRRFPre(l0Hits.map((c) => ({ memoryId: c.id, dense: typeof c.sem === 'number' ? c.sem * (0.5 + 0.5 * (impMap.get(c.id) != null ? impMap.get(c.id) : 0.5)) : null, lex: c.lex, temp: tempFieldOf(c) })))
6289
+ const fusion = rankFusionRRFPre(l0Hits.map((c) => ({ memoryId: c.id, dense: typeof c.sem === 'number' ? c.sem * (0.5 + 0.5 * (impMap.get(c.id) != null ? impMap.get(c.id) : 0.5)) : null, lex: c.lex, temp: tempFieldOf(c), layer: c.layer })))
5586
6290
  fusion.forEach((f, i) => {
5587
6291
  const c = byId.get(f.memoryId)
5588
6292
  if (!c) return
@@ -5601,12 +6305,21 @@ class MemoryEngine {
5601
6305
  }
5602
6306
  if (l0Top.length) {
5603
6307
  out.push('== L0 命中(摘要,含 id/得分/匹配原因;展开单条原文传 expand="mem_xxx") ==')
5604
- for (const c of l0Top) {
5605
- // R1 双显示(2026-09-16):绝对分(决策用)在前;融合序号 #N(排序用,即 finalRank 口径)在后。
5606
- // legacy 路径/RRF 降级时无 finalRank,显示保持旧格式不变(回滚=opts.fusion:'legacy')。
5607
- const sc = typeof c.sem === 'number' ? c.sem.toFixed(2) : String(c.lex)
5608
- const fr = Number.isInteger(c.finalRank) ? ' #' + c.finalRank : ''
5609
- out.push('· [' + c.id + '] ×' + sc + fr + ' ' + (c.reason || '语义') + ' ' + c.label + ' — ' + c.l0)
6308
+ // ★R4-B(2026-09-18)分层**呈现**:把已排好序的命中**按层分组**后展示。
6309
+ // ★排序零改动:分组只重排"行",不改 `l0Top` 的融合/词法序;组内保持原相对顺序,
6310
+ // 且每行仍带 `#finalRank` ⇒ 全局序完全可由读者还原。**只有多于一层时才打标题**
6311
+ // ⇒ 单层命中(如纯日志)输出与旧版**逐字节相同**(回滚安全)。
6312
+ const groups0 = groupL0(l0Top, (c) => c.layer)
6313
+ const multi = groups0.length > 1
6314
+ for (const g of groups0) {
6315
+ if (multi) out.push('【' + g.label + '】')
6316
+ for (const c of g.items) {
6317
+ // R1 双显示(2026-09-16):绝对分(决策用)在前;融合序号 #N(排序用,即 finalRank 口径)在后。
6318
+ // legacy 路径/RRF 降级时无 finalRank,显示保持旧格式不变(回滚=opts.fusion:'legacy')。
6319
+ const sc = typeof c.sem === 'number' ? c.sem.toFixed(2) : String(c.lex)
6320
+ const fr = Number.isInteger(c.finalRank) ? ' #' + c.finalRank : ''
6321
+ out.push('· [' + c.id + '] ×' + sc + fr + ' ' + (c.reason || '语义') + ' ' + c.label + ' — ' + c.l0 + (c.mark || ''))
6322
+ }
5610
6323
  }
5611
6324
  }
5612
6325
  } else {
@@ -5671,20 +6384,28 @@ class MemoryEngine {
5671
6384
  if (scope === 'all' && !l0Mode && typeof this._jsSemanticRank === 'function') {
5672
6385
  try {
5673
6386
  const { buildL0IndexPre } = await import('./l0-extract.js')
5674
- const { isCurrentPre: isCurrentPreArms } = await import('./l0-extract.js')
6387
+ // ★R4-A 落地(2026-09-19):语义臂与词法臂**同源同判** —— 检索侧三态一律放行 + 标记。
6388
+ const { isRetrievablePre: retrievableSem, supersededMarkPre: markSem } = await import('./l0-extract.js')
6389
+ // R4-B:分层呈现用的分组函数(单独 import,**不动上面两行的字面形态** —— 既有套件
6390
+ // smoke-test-p2 以源码字面量锁定它们,改形态会造成假红)。
6391
+ const { groupL0ByLayerPre: groupSem } = await import('./l0-extract.js')
5675
6392
  const { createHash } = await import('node:crypto')
5676
- // C2(三层契约 I5『检索侧』过滤):谓词经 env 传入 —— semanticArm 会被 smoke 套件"抽源码单独求值",
5677
- // 那时闭包变量不可见,故这里给**等价内联兜底**(行为与 l0-extract-pre 的 isCurrentPre 一致,勿改语义)。
6393
+ // C2(三层契约 I5『检索侧』准入):谓词经 env 传入 —— semanticArm 会被 smoke 套件"抽源码单独求值",
6394
+ // 那时闭包变量不可见,故这里给**等价内联兜底**(行为与 l0-extract-pre 的 isRetrievablePre 一致,
6395
+ // 即:已知三态一律放行、只对未知值 fail-closed,勿改语义)。
5678
6396
  const semanticArm = async (env) => {
5679
- const cur = typeof env.isCurrentPre === 'function' ? env.isCurrentPre : (r) => !r || !r.status || r.status === 'current'
6397
+ const cur = typeof env.isRetrievablePre === 'function' ? env.isRetrievablePre : ((r) => !r || !r.status || ['current', 'superseded', 'retracted'].includes(r.status))
6398
+ const mk = typeof env.supersededMarkPre === 'function' ? env.supersededMarkPre : (() => '')
6399
+ // ★G3 读侧:语义臂与词法臂**同源同法**(都在 statusOf 处读磁盘状态行)。
6400
+ const st = typeof env.statusOf === 'function' ? env.statusOf : (() => undefined)
5680
6401
  const corpus = []
5681
6402
  for (const src of env.sources) {
5682
6403
  const text = await this.readTextSafe(src.path)
5683
6404
  if (!text) continue
5684
- const items = env.buildL0IndexPre(text, { layer: src.path })
6405
+ const items = env.buildL0IndexPre(text, { layer: src.path, statusOf: (id) => st(text, id) })
5685
6406
  for (const it of items) {
5686
6407
  if (!cur(it)) continue
5687
- corpus.push({ memoryId: it.id, text: it.l0, label: src.label, layer: it.layer || 'log' })
6408
+ corpus.push({ memoryId: it.id, text: it.l0, label: src.label, layer: it.layer || 'log', mark: mk(it) })
5688
6409
  }
5689
6410
  }
5690
6411
  if (!corpus.length) return []
@@ -5702,7 +6423,9 @@ class MemoryEngine {
5702
6423
  .slice(0, Math.max(2, env.limit))
5703
6424
  .map(([id, sc]) => {
5704
6425
  const rec = corpus.find((c) => c.memoryId === id) || {}
5705
- return { label: rec.label || '', id8: String(id).slice(4, 12), score: sc, l0: rec.text || '' }
6426
+ // R4-B:把层带出去(分组在调用点做)。★本闭包会被 smoke 套件用 `new Function`
6427
+ // 抽源码单独求值,故**只能依赖 env/自身**:这里不做任何分组、不引用外部符号。
6428
+ return { label: rec.label || '', id8: String(id).slice(4, 12), score: sc, l0: rec.text || '', layer: rec.layer || 'log', mark: rec.mark || '' }
5706
6429
  })
5707
6430
  }
5708
6431
  const semSources = []
@@ -5710,11 +6433,27 @@ class MemoryEngine {
5710
6433
  for (const rf of reflections) semSources.push({ label: 'reflections/' + rf.name, path: path.join(p.reflectDir, rf.name) })
5711
6434
  semSources.push({ label: p.projectDir + '/MEMORY.md', path: p.notesPath })
5712
6435
  semSources.push({ label: '~' + p.userFile.slice(homedir().length), path: p.userFile })
6436
+ // ★L4: 语义臂同样接上白板(与上方 pushL0 同源同闸; fail-soft 取不到就跳过)
6437
+ if ((this.config || {}).handoffEnabled !== false) {
6438
+ try {
6439
+ semSources.push({ label: 'handoff/PLAN.md', path: p.planPath })
6440
+ const ledgerNameL4s = await this.latestLedgerNamePre(p.handoffDir)
6441
+ if (ledgerNameL4s) semSources.push({ label: 'handoff/' + ledgerNameL4s, path: path.join(p.handoffDir, ledgerNameL4s) })
6442
+ } catch (eL4s) {}
6443
+ }
5713
6444
  // #45:不再传 maxRecords(=256) —— 语料截断即召回截断,会让靠后的来源永远够不到。
5714
- const semHits = await semanticArm({ sources: semSources, buildL0IndexPre, isCurrentPre: isCurrentPreArms, createHash, query, limit, minScore: 0.5 })
6445
+ const semHits = await semanticArm({ sources: semSources, buildL0IndexPre, isRetrievablePre: retrievableSem, supersededMarkPre: markSem, statusOf: statusOfNotePre, createHash, query, limit, minScore: 0.5 })
5715
6446
  if (semHits.length) {
5716
6447
  out.push('== 语义命中(L0 摘要,按相关度;可按锚点下钻) ==')
5717
- for (const s of semHits) out.push('· ' + s.label + ' [' + s.id8 + '] ×' + s.score.toFixed(2) + ' ' + s.l0)
6448
+ // ★R4-B(2026-09-18)分层**呈现**(与上方 L0 命中段同源同法):只重排"行"、不改分数序,
6449
+ // 组内保持原相对序(原行无序号,故组内相对序即原排序序)。**只有一层时不打标题**
6450
+ // ⇒ 单层输出与旧版逐字节相同(回滚安全)。
6451
+ const groupsS = groupSem(semHits, (s) => s.layer)
6452
+ const multiS = groupsS.length > 1
6453
+ for (const g of groupsS) {
6454
+ if (multiS) out.push('【' + g.label + '】')
6455
+ for (const s of g.items) out.push('· ' + s.label + ' [' + s.id8 + '] ×' + s.score.toFixed(2) + ' ' + s.l0 + (s.mark || ''))
6456
+ }
5718
6457
  }
5719
6458
  } catch (eSem) {}
5720
6459
  }
@@ -5975,6 +6714,78 @@ class MemoryEngine {
5975
6714
  return { workspaces, graph, cached: false, generatedAt: result.generatedAt }
5976
6715
  }
5977
6716
 
6717
+ // ---------- R3-②(2026-09-18):降级台账视图 + 落盘 ----------
6718
+ /**
6719
+ * 返回降级台账最小投影,并**顺带落盘**为可查询状态文件。
6720
+ *
6721
+ * 形态:**读驱动写** —— 只在 `debugInfo()`(诊断端点)被调用时写一次,
6722
+ * 不引入定时器、不新增常驻任务、不产生空闲期 IO。
6723
+ *
6724
+ * ★ 元规则:**落盘失败绝不可影响 debugInfo** —— 全程 try/catch,失败仅返回快照。
6725
+ */
6726
+ _degradeViewSnapshot() {
6727
+ let snap
6728
+ try {
6729
+ snap = this._degradeSink
6730
+ ? this._degradeSink.snapshot()
6731
+ : { schemaVersion: 'degrade_v1', updatedAt: null, counts: {}, recent: [], evicted: 0, cap: 0 }
6732
+ } catch (_) {
6733
+ snap = { schemaVersion: 'degrade_v1', updatedAt: null, counts: {}, recent: [], evicted: 0, cap: 0 }
6734
+ }
6735
+ // 落盘(best-effort)。失败不影响返回值,也不抛 —— 但**必须可见**。
6736
+ // ★ 本文件 `import path from 'node:path'` 是**默认导入**,没有裸 `join` 可用。
6737
+ // 曾误写裸 join ⇒ ReferenceError 被本 catch 静默吞掉 ⇒ 落盘长期失效而无人知晓
6738
+ // (同族事故第三次:dshHome / evDir / join 均属「闭包或导入不可见」类)。
6739
+ // ⇒ 因此返回值带 `persisted` 字段:失败在诊断面板/响应里**一眼可见**,
6740
+ // 符合本仓铁律「静默降级视为结构性缺陷」。
6741
+ // ★R4(2026-09-18):改为**合并写入** —— 同一文件同时带降级台账与配额测量两个键。
6742
+ // 保持向后兼容:degrade 的原有键(schemaVersion/counts/recent/…)位置与含义不变,
6743
+ // 仅**追加** `quota` 键 ⇒ 既有读者(面板/排障脚本)零改动。
6744
+ let persisted = false
6745
+ try {
6746
+ const quota = this._quotaViewSnapshot()
6747
+ persisted = this._persistObservabilityPre(snap, quota)
6748
+ } catch (_) { /* fail-soft:台账持久化不得打断诊断 */ }
6749
+ return Object.assign({}, snap, { persisted })
6750
+ }
6751
+
6752
+ // ---------- R4(2026-09-18):配额测量视图 ----------
6753
+ /**
6754
+ * 返回**配额测量**视图:采样快照 + 判定结论,并**顺带落盘**到与降级台账**同一个文件**
6755
+ * (多一个 `quota` 键 —— 不新增文件、不新增配置键、不新增常驻任务,遵守 S10.4)。
6756
+ *
6757
+ * 与 `_degradeViewSnapshot` 同一形态(读驱动写)与同一元规则(落盘失败绝不影响诊断),
6758
+ * 但**判据并列不混**:那边只记预期外失败,这边是常规业务观测。
6759
+ */
6760
+ _quotaViewSnapshot() {
6761
+ let snap
6762
+ try {
6763
+ snap = this._quotaProbe
6764
+ ? this._quotaProbe.snapshot()
6765
+ : { schemaVersion: 'quota_probe_v1', updatedAt: null, samples: [], evicted: 0, cap: 0 }
6766
+ } catch (_) {
6767
+ snap = { schemaVersion: 'quota_probe_v1', updatedAt: null, samples: [], evicted: 0, cap: 0 }
6768
+ }
6769
+ let verdict
6770
+ try {
6771
+ verdict = deriveQuotaVerdictPre(snap)
6772
+ } catch (_) {
6773
+ verdict = { version: 'quota_verdict_v1', verdict: 'insufficient-data', samples: 0, dropRate: 0, usage: 0, perLayer: {}, reasons: ['判定异常'] }
6774
+ }
6775
+ return Object.assign({}, snap, { verdict })
6776
+ }
6777
+
6778
+ /** 把降级台账与配额测量**合并写入同一状态文件**(读驱动写;失败返回 false,不抛)。 */
6779
+ _persistObservabilityPre(snap, quota) {
6780
+ let persisted = false
6781
+ try {
6782
+ const file = path.join(dshHome(), 'memory', 'degrade-pre', 'latest.json')
6783
+ // 合并形态:保留 degrade 的原有键(向后兼容既有读者),追加 quota 键。
6784
+ persisted = persistDegradeLedgerPre({ file, snapshot: Object.assign({}, snap, { quota }), mkdirSync, writeFileSync }) === true
6785
+ } catch (_) { /* fail-soft:观测面持久化不得打断诊断 */ }
6786
+ return persisted
6787
+ }
6788
+
5978
6789
  // ---------- 调试中心(为提 issue 提供诊断信息) ----------
5979
6790
  async debugInfo() {
5980
6791
  const p = await this.resolvePaths(undefined)
@@ -6062,6 +6873,13 @@ class MemoryEngine {
6062
6873
  return Object.assign({ enabled: this.config.pythonBackendEnabled === true }, c ? c.debugView() : {})
6063
6874
  })(),
6064
6875
  indexSyncHost: this._indexSyncHost ? this._indexSyncHost.debugView() : { enabled: false },
6876
+ // R3-②(2026-09-18):降级台账最小投影。与各 host 的 debugView 走**同一出口**,不新增通道;
6877
+ // 只暴露 counts/recent/evicted(无原文、无路径)—— 与既有「§17 最小投影」纪律一致。
6878
+ degrade: this._degradeViewSnapshot(),
6879
+ // ★R4(2026-09-18):配额测量视图 —— 与降级台账**同一出口、同一落盘文件**(多一个 `quota` 键)。
6880
+ // 回答用户那两个问题:「配额太少(内容进不来)」还是「配额多了(白花 token)」。
6881
+ // 判定默认 `insufficient-data`(样本不足**不猜**),符合用户「基于长期观察」的要求。
6882
+ quota: this._quotaViewSnapshot(),
6065
6883
  runtimes: this.runtimes.values().map((rt) => ({
6066
6884
  key: rt.key,
6067
6885
  sessionId: rt.sessionId,
@@ -6401,25 +7219,46 @@ class MemoryEngine {
6401
7219
  try {
6402
7220
  const sessionsSvc = this._sessionsSvc
6403
7221
  const agentSvc = this._agentSvc
6404
- if (!sessionsSvc || !agentSvc || typeof sessionsSvc.list !== 'function') { diag('restoreLastAgent: svc missing sessions=' + !!sessionsSvc + ' agent=' + !!agentSvc); return }
7222
+ if (!sessionsSvc || !agentSvc || typeof sessionsSvc.list !== 'function') { diagThrottled('rla:svc', 'restoreLastAgent: svc missing sessions=' + !!sessionsSvc + ' agent=' + !!agentSvc); return }
6405
7223
  const sessions = sessionsSvc.list()
6406
- if (!sessions || !sessions.length) { diag('restoreLastAgent: no sessions'); return }
6407
- // 找最近活跃的顶层会话(log 最后事件时间最大)
7224
+ if (!sessions || !sessions.length) { diagThrottled('rla:none', 'restoreLastAgent: no sessions'); return }
7225
+ if (typeof agentSvc.get !== 'function') { diagThrottled('rla:get', 'restoreLastAgent: agentSvc.get unavailable'); return }
7226
+ // 找最近活跃的**用户会话**(log 最后事件时间最大)。
7227
+ // ★2026-09-21 二次修 bug(证据: tools/probe-session-kind.mjs 解压会话头部):
7228
+ // 上一轮修的判据是「有 parentSession 就跳过」, **过宽** —— 它把「接续会话」也一并排除。
7229
+ // 实测两类会话的区别:
7230
+ // · 子代理 : origin='subagent', delegationDepth=1 ⇒ 该排除
7231
+ // · 接续会话 : origin 缺省, delegationDepth=0 ⇒ **是用户会话, 必须收**
7232
+ // 当列表里只有接续会话时(用户点过「一键接续」后即为此形态), best 永远是 null ⇒
7233
+ // `_lastAgent` 恢复不了 + 每 tick 重试 ⇒ 刷屏。改用 isSubAgentSession() 精确判定。
6408
7234
  let best = null, bestTime = 0
7235
+ let skippedSub = 0
6409
7236
  for (const s of sessions) {
7237
+ if (isSubAgentSession(s)) { skippedSub++; continue }
6410
7238
  let t = 0
6411
7239
  try { const last = s.log && s.log[s.log.length - 1]; if (last && last.time) t = last.time } catch (e) {}
6412
7240
  if (t >= bestTime) { bestTime = t; best = s }
6413
7241
  }
6414
- if (!best || typeof agentSvc.get !== 'function') { diag('restoreLastAgent: no best session or no get'); return }
7242
+ if (!best) { diagThrottled('rla:nobest', 'restoreLastAgent: no usable session (总 ' + sessions.length + ' 个, 其中子代理 ' + skippedSub + ' 个)'); return }
6415
7243
  const a = agentSvc.get(best.id)
6416
- if (a && a.session && a.session.header && a.session.header.parentSession === undefined) {
7244
+ // 接受条件: agent 可用且**不是子代理**。接续会话(有 parent 但 delegationDepth=0)必须接受 ——
7245
+ // 这正是本 bug 的核心: 旧判据 `parentSession === undefined` 会把它拒掉。
7246
+ if (a && a.session && !isSubAgentSession(a)) {
6417
7247
  this._lastAgent = a
6418
- diag('restoreLastAgent: recovered agent id=' + a.id + ' session=' + a.session.id + ' logEvents=' + sessionEventsOf(a.session).length)
7248
+ _diagLastAt.delete('rla:reject') // 成功即清退避, 下次真失败能立刻看到
7249
+ const h = sessionHeaderOf(a) || {}
7250
+ diag('restoreLastAgent: recovered agent id=' + a.id + ' session=' + a.session.id +
7251
+ ' logEvents=' + sessionEventsOf(a.session).length +
7252
+ // 带归属信息便于事后核对: continuation 表示它是接续会话(修复的主要目标形态)
7253
+ ' kind=' + (hasParentSession(a) ? 'continuation' : 'top') +
7254
+ ' depth=' + (h.delegationDepth === undefined ? '-' : h.delegationDepth) +
7255
+ ' origin=' + (h.origin || '-'))
6419
7256
  } else {
6420
- diag('restoreLastAgent: candidate rejected (id=' + (a && a.id) + ' hasSession=' + !!(a && a.session) + ' parent=' + (a && a.session && a.session.header && a.session.header.parentSession) + ')')
7257
+ // 选中了可用候选却仍拿不到 agent —— 多为宿主尚未把该会话实例化, 属**暂时**状态。
7258
+ // 用节流(5 分钟一条)而非每 tick 一条, 避免刷屏; 恢复成功后自动清退避。
7259
+ diagThrottled('rla:reject', 'restoreLastAgent: candidate rejected (id=' + (a && a.id) + ' hasSession=' + !!(a && a.session) + ' parent=' + (a && a.session && a.session.header && a.session.header.parentSession) + ')')
6421
7260
  }
6422
- } catch (e) { diag('restoreLastAgent error: ' + (e && e.message)) }
7261
+ } catch (e) { diagThrottled('rla:err', 'restoreLastAgent error: ' + (e && e.message)) }
6423
7262
  }
6424
7263
 
6425
7264
  /** 自动总结:按时间点推断时段,生成总结并置 pendingSummary(供 client 弹窗)。 */
@@ -6728,8 +7567,10 @@ class MemoryEngine {
6728
7567
  if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
6729
7568
  if (this.config.autoConsolidate === false) { why('config.autoConsolidate=false'); return }
6730
7569
  if (!agent || !agent.session) { why('no agent/session'); return }
6731
- // 只处理顶层会话(子代理/接续会话的 header.parentSession 非空,避免子代理轮次误沉淀)
6732
- try { if (agent.session.header && agent.session.header.parentSession) { why('parentSession sub-agent'); return } } catch (e) {}
7570
+ // 只处理**用户会话**(2026-09-21 修: 原判据「有 parentSession 就跳过」过宽, 会把接续会话一并跳过
7571
+ // ⇒ 接续会话里的对话**永远不自动沉淀**。改用 isSubAgentSession() 精确排除子代理;
7572
+ // 接续会话(delegationDepth=0)是用户真实会话, 必须正常沉淀。)
7573
+ try { if (isSubAgentSession(agent)) { why('sub-agent session skipped'); return } } catch (e) {}
6733
7574
  const minChars = Math.max(Number(this.config.autoConsolidateMinChars) || 240, 80)
6734
7575
  const today = this.memToday()
6735
7576
  if (this._autoCallDate !== today) { this._autoCallDate = today; this._autoCallCount = 0 }
@@ -7128,9 +7969,14 @@ class MemoryEngine {
7128
7969
  noteMsg = '\nAI 蒸馏不可用,原文已按老方式归档到 ' + p.notesPath
7129
7970
  }
7130
7971
  // 4) 活跃目录移除旧日志(原文已在 archive/ 保底)
7972
+ // ★ T3-1(2026-09-19)**数据丢失修复**:原实现遍历 `oldLogs` ⇒
7973
+ // 归档失败(磁盘/权限/新卫生门拒绝)的日志**照样被 rm** ⇒ 永久丢失。
7974
+ // 上方注释虽写「原文已在 archive/ 保底」,但第 2 步的 catch 只记日志、
7975
+ // 并不保证 archived 收录了它。此处改为**以 archived 为准**:没归档成功的一律保留在活跃目录。
7131
7976
  const deleted = []
7132
7977
  const kept = []
7133
7978
  for (const log of oldLogs) {
7979
+ if (archived.indexOf(log.name) === -1) { kept.push(log.name); continue }
7134
7980
  try {
7135
7981
  await rm(path.join(p.projectDir, log.name), { force: true })
7136
7982
  deleted.push(log.name)
@@ -7184,6 +8030,9 @@ class MemoryEngine {
7184
8030
  periodSummary: this.periodSummary(),
7185
8031
  refreshedAt: this.state.loadedAt,
7186
8032
  configReadError: this._readError,
8033
+ // ★#82:配置是否曾被隔离过(坏文件已挪到 .corrupt-<ts>,用户数据未被覆盖)。
8034
+ // 供前端「设置」页与诊断接口显示,避免「设置莫名全没了」无从解释。
8035
+ configCorrupted: !!(this._readError && String(this._readError).includes('quarantined=')),
7187
8036
  }
7188
8037
  }
7189
8038
  }
@@ -7706,6 +8555,62 @@ function sanitizeForInjection(text, maxChars) {
7706
8555
  return neutralizePromptTemplateVars(truncateHead(s.clean, maxChars || 2000))
7707
8556
  }
7708
8557
  /** 写入端: 单条上限 + 乱码拒写 + 连续重复拒写。返回 { ok, reason, clean }。 */
8558
+ /**
8559
+ * ★ T3-1(2026-09-19):**写入原语卫生门(hygiene-only,无体量语义)**。
8560
+ *
8561
+ * **为什么另开一个函数,而不是把 `sanitizeForWrite` 下沉:**
8562
+ * `sanitizeForWrite` 除了卫生检查,还带一条**单条载荷上限 8000 字**,
8563
+ * 超长时**不是拒绝而是 `slice(0, 8000)` 静默截断**(见其 `:8327-8329`)。
8564
+ * 而 `appendText` / `writeFull` 目前**没有任何体量限制**,且承担两类"必须写全文"的职责:
8565
+ * · `:7716` **原文保底归档**(maintain 的最后一道保险,注释即写「绝不丢信息」)
8566
+ * · `:7733` AI 不可用时把归档日志**原文内联**回笔记
8567
+ * 实测真实日志 77805 / 52394 / 43224 字 ⇒ 若把带截断的闸门下沉,这两条会被**静默砍到 8000 字**。
8568
+ *
8569
+ * **所以本函数只做「拦脏」,绝不做「截断」** —— 体量策略属于调用方的业务语义,
8570
+ * 不该进写入原语。`sanitizeForWrite` 保持原样、现有 6 个入口继续用它,
8571
+ * 二者**判据同源**(复用同一批正则与同一批函数),因此不会出现"两套标准"。
8572
+ *
8573
+ * **返回**:`{ ok, reason? }`。**不返回 clean** —— 本门不改写正文,只决定放行/拒绝。
8574
+ *
8575
+ * **★★ 判据范围(2026-09-19 实测决定,勿扩)**:
8576
+ * 只保留**内容级**判据(无论文本多长、无论写什么文件都该拦的):
8577
+ * `mojibake`(乱码)/ `stutter`(复读退化)/ `base64`(base64 残骸行)/ `duplicate-lines`(连续重复行)
8578
+ * **刻意不含两样**:
8579
+ * · **`RAW_JSON_MARK`** —— 它是**入口级**判据(针对"AI 调写入工具时传了外部画像 raw JSON"),
8580
+ * 且含**裸词 `updatedAt`**。实测扫描 541 个真实记忆文件:**124 个命中该正则**(多数是正常提到
8581
+ * `updatedAt` 的正文),若下沉到原语层会**大面积误伤**。它继续留在 `sanitizeForWrite` 入口层。
8582
+ * · **体量上限** —— 见上文,截断是调用方的业务语义。
8583
+ *
8584
+ * **★ 只挂在 `appendText` 一处,不挂 `writeFull`**(实测依据):
8585
+ * `writeFull` 写的是**整篇文档**(`:8193` 移除导入段落 / `:9905`/`:9943` 整篇重写),
8586
+ * 用内容级判据审"整篇文档"命中面过大;且 `:7723` 是**原文保底归档**,绝不能因判据误伤而失败。
8587
+ * `appendText` 追加的是**单条新内容**,正是判据设计时面对的形态。
8588
+ *
8589
+ * **fail-soft 纪律**:本函数**绝不抛出**;任何内部异常一律视为放行(`ok:true`),
8590
+ * 因为它是**新增的守卫**,不能成为新的失败源。
8591
+ */
8592
+ function hygieneGateForPrimitive(text) {
8593
+ try {
8594
+ var raw = String(text == null ? '' : text)
8595
+ if (raw.length === 0) return { ok: true } // 空串由调用方语义决定(append 空串无害),本门不拦
8596
+ if (mojibakeDensity(raw) > 0.001) return { ok: false, reason: 'mojibake' }
8597
+ if (hasStutter(raw)) return { ok: false, reason: 'stutter' }
8598
+ var b64line = raw.split('\n').some(function (l) { var k = l.trim(); return k.length > 100 && BASE64_LINE.test(k) })
8599
+ if (b64line) return { ok: false, reason: 'base64' }
8600
+ // 连续行重复(同一段一模一样的行连续 ≥3 次 → 疑似退化 writer 循环;空行打断连续)
8601
+ var seq = 0, prev = '', repeated = false
8602
+ for (var l of raw.split('\n')) {
8603
+ var t = l.trim()
8604
+ if (!t) { seq = 0; prev = ''; continue }
8605
+ if (t === prev) { seq++; if (seq >= 3) { repeated = true; break } } else { prev = t; seq = 1 }
8606
+ }
8607
+ if (repeated) return { ok: false, reason: 'duplicate-lines' }
8608
+ return { ok: true }
8609
+ } catch (e) {
8610
+ return { ok: true } // fail-soft:新守卫绝不成为新失败源
8611
+ }
8612
+ }
8613
+
7709
8614
  function sanitizeForWrite(text, opts) {
7710
8615
  var o = opts || {}
7711
8616
  var maxEntry = o.maxEntryChars || 8000
@@ -7841,7 +8746,13 @@ function foldSessionLogEvents(lines) {
7841
8746
  const m = messageOfEvent(ev)
7842
8747
  if (m && m.role && Array.isArray(m.content)) {
7843
8748
  const txt = textOfContent(m.content)
7844
- if (txt) out.msgs.push({ role: m.role, text: txt })
8749
+ // ★L3.5(2026-09-17): 附件描述符必须在这里就带上 —— 旧实现只取 text, 附件字段
8750
+ // 在源头被丢弃, 转写/接续材料因此完全看不到"用户投过这张图/这个文件"。
8751
+ // 只有文本的消息保持**原样形状** {role,text}(不新增字段), 守 legacy 逐字节兼容纪律。
8752
+ const atts = attachmentsOfContent(m.content)
8753
+ if (txt && atts.length) out.msgs.push({ role: m.role, text: txt, attachments: atts })
8754
+ else if (txt) out.msgs.push({ role: m.role, text: txt })
8755
+ else if (atts.length) out.msgs.push({ role: m.role, text: '', attachments: atts })
7845
8756
  }
7846
8757
  } else if (t === 'tool/call') {
7847
8758
  const d = (ev && ev.data) || {}
@@ -7875,6 +8786,34 @@ function workspaceIdForSession(workspaces, sessionId, cwd) {
7875
8786
  return ''
7876
8787
  }
7877
8788
 
8789
+ /** ★L5(2026-09-17)·板式判定(纯函数,单一真源)。
8790
+ * **本函数只服务于真正的 graph 特性**(看板卡渲染、sidecar 事件、白板 tag 地图等)。
8791
+ * ⚠️ 纪律(用户已批准的判据):锚点是**写入格式契约**, 与看板**渲染形态**无关 ⇒
8792
+ * **锚点写入处不得用本函数把关** —— 旧实现在 PLAN 锚点与账本锚点两处误把它当渲染闸门,
8793
+ * 导致 legacy 档的白板/账本永远拿不到锚点, §2 承诺的「白板内容凭锚点进 L0 检索语料」恒为空。
8794
+ * 这条判据取代早先"只数 boardMode 门"的窄口径:不问是不是 boardMode 门, 只问
8795
+ * **这个门控的是「渲染」还是「写入/取材」**。 */
8796
+ function isGraphModePre(cfg) {
8797
+ try { return String((cfg || {}).boardMode || '').trim().toLowerCase() === 'graph' } catch (e) { return false }
8798
+ }
8799
+
8800
+ /** ★L4(2026-09-17)·取交接目录里**最新一篇**账本的文件名(纯 IO 助手)。
8801
+ * 判据与 buildContinueCarry 的 ledgerName 选取**同源**(mtime 最大, 平手取字典序靠后者),
8802
+ * 但不复用其中的内联循环 —— 那段落有它自己的 staleNote 计算, 抽出来会改变它的形状。
8803
+ * 找不到/无权限 ⇒ 空串(fail-soft, 绝不抛, 白板进检索绝不因它阻塞)。 */
8804
+ async function latestLedgerNamePre(handoffDir) {
8805
+ try {
8806
+ const names = (await readdir(handoffDir).catch(() => [])).filter((n) => /^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n))
8807
+ let best = '', bestMt = -1
8808
+ for (const n of names.sort()) {
8809
+ const st = await stat(path.join(handoffDir, n)).catch(() => null)
8810
+ const mt = st ? Number(st.mtimeMs) : 0
8811
+ if (mt >= bestMt) { bestMt = mt; best = n }
8812
+ }
8813
+ return best
8814
+ } catch (e) { return '' }
8815
+ }
8816
+
7878
8817
  /** 从 session 提取消息。surface 不是完整可靠的 user 来源,因此失败时回退完整事件日志。 */
7879
8818
  function messageOfEvent(ev) {
7880
8819
  if (!ev) return null
@@ -7896,6 +8835,84 @@ function textOfContent(content) {
7896
8835
  walk(content, 0)
7897
8836
  return out.join('')
7898
8837
  }
8838
+ /** ★L3.5(2026-09-17)·附件描述符抽取(纯函数,供 smoke 驱动)。
8839
+ * 实测:DSH 附件是**内容寻址 blob**(非 base64 内嵌),会话日志里存的是结构化 part
8840
+ * `{type:'image'|'file', attachment:{attachmentId:'sha256:<hex>', mediaType, name, bytes, width, height}}`;
8841
+ * 官方落盘规则(@deepseek-ai/dsh-attachment-local/lib/index.js):
8842
+ * 图片对象 :290 join(root,'objects', sha256.slice(0,2), sha256)
8843
+ * 文件对象 :661 join(root,'files', sha256.slice(0,2), sha256, ref.name)
8844
+ * 旧实现 `textOfContent(m.content)` **只取文本** ⇒ 附件字段在源头就被丢掉, 转写里看不到
8845
+ * "用户投过这张图/这个文件", 接续会话因此完全不知道有这些材料可读。 */
8846
+ function attachmentsOfContent(content) {
8847
+ const out = []
8848
+ const seen = Object.create(null)
8849
+ const walk = (v, depth) => {
8850
+ if (depth > 8 || v == null) return
8851
+ if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return }
8852
+ if (typeof v !== 'object') return
8853
+ const a = v.attachment && typeof v.attachment === 'object' ? v.attachment : null
8854
+ if (a && typeof a.attachmentId === 'string' && a.attachmentId) {
8855
+ const kind = v.type === 'file' ? 'file' : (v.type === 'image' ? 'image' : '')
8856
+ const key = kind + '|' + a.attachmentId + '|' + String(a.name || '')
8857
+ if (kind && !seen[key]) {
8858
+ seen[key] = 1
8859
+ out.push({
8860
+ kind,
8861
+ ref: a.attachmentId,
8862
+ id: a.attachmentId.indexOf('sha256:') === 0 ? a.attachmentId.slice(7) : a.attachmentId,
8863
+ mediaType: String(a.mediaType || ''),
8864
+ name: String(a.name || ''),
8865
+ bytes: Number(a.bytes) || 0,
8866
+ width: Number(a.width) || 0,
8867
+ height: Number(a.height) || 0,
8868
+ })
8869
+ }
8870
+ }
8871
+ if (v.content !== undefined) walk(v.content, depth + 1)
8872
+ }
8873
+ walk(content, 0)
8874
+ return out
8875
+ }
8876
+
8877
+ /** ★L3.5·按 attachmentId 推导 blob 落盘路径(纯函数,供 smoke 驱动;不校验存在性)。
8878
+ * 返回 {objectPath, filePath} 两个候选:图片在 objects/<前2位>/<hex>, 具名文件在
8879
+ * files/<前2位>/<hex>/<name>。未知/异常 id ⇒ 两值均为 ''(fail-soft,绝不抛)。 */
8880
+ function attachmentBlobPathsPre(att, homeDir) {
8881
+ const empty = { objectPath: '', filePath: '' }
8882
+ try {
8883
+ if (!att || !att.id) return empty
8884
+ const hex = String(att.id)
8885
+ if (!/^[0-9a-f]{16,}$/i.test(hex)) return empty
8886
+ const root = path.join(String(homeDir || dshHome()), 'attachments', 'v1')
8887
+ const two = hex.slice(0, 2)
8888
+ return {
8889
+ objectPath: path.join(root, 'objects', two, hex),
8890
+ filePath: att.kind === 'file' && att.name ? path.join(root, 'files', two, hex, att.name) : '',
8891
+ }
8892
+ } catch (e) { return empty }
8893
+ }
8894
+
8895
+ /** ★L3.5·把附件描述符渲染成可读文本行(纯函数,供 smoke 驱动)。
8896
+ * 输出形如 `[附件 image] name.png (12.3 KB) → <绝对路径>`;blob 不存在时也照样列出
8897
+ * —— 路径是"去哪儿找"的线索, 存在性由调用方(接续会话的 read)自行判定。
8898
+ * homeDir 传空时调用 dshHome(); 渲染零副作用、零 IO。 */
8899
+ function renderAttachmentLinesPre(atts, homeDir) {
8900
+ try {
8901
+ const list = Array.isArray(atts) ? atts : []
8902
+ if (!list.length) return []
8903
+ return list.map((a) => {
8904
+ const paths = attachmentBlobPathsPre(a, homeDir)
8905
+ const target = paths.filePath || paths.objectPath
8906
+ const bits = []
8907
+ if (a.mediaType) bits.push(a.mediaType)
8908
+ if (a.bytes > 0) bits.push(a.bytes >= 1024 ? (Math.round(a.bytes / 1024 * 10) / 10) + ' KB' : a.bytes + ' B')
8909
+ if (a.width && a.height) bits.push(a.width + 'x' + a.height)
8910
+ const label = a.kind === 'file' ? '文件' : '图片'
8911
+ return '[附件 ' + label + '] ' + (a.name || (a.id || '').slice(0, 12)) + (bits.length ? ' (' + bits.join(', ') + ')' : '') + ' → ' + (target || '(路径不可解析)')
8912
+ })
8913
+ } catch (e) { return [] }
8914
+ }
8915
+
7899
8916
  /**
7900
8917
  * 新旧两代 Session API 兼容的原始事件数组读取:
7901
8918
  * - 旧版 @deepseek-ai/dsh-session 在 Session 上暴露 .events 数组;
@@ -8051,7 +9068,21 @@ export function apply(ctx, config) {
8051
9068
  try {
8052
9069
  if (!process._dshAutoMemoryRejectionGuard) {
8053
9070
  process._dshAutoMemoryRejectionGuard = true
8054
- process.on('unhandledRejection', (reason) => { damSafeDiag(process.stderr, '[dsh-auto-memory] unhandledRejection guard: ' + damDiagLine(reason)) })
9071
+ // ★#84(2026-09-20):**加计数**。原实现只打一行日志 ⇒ 无法回答
9072
+ // 「一共几次、是否在频繁发生、最近一次何时」——而这条 guard 恰好会**吞掉**
9073
+ // 本该炸出来的错误(本仓 ⑩-b 那类「fail-soft 吞错」的典型形态)。
9074
+ // 计数暴露到诊断面后,这类问题才有观测入口。
9075
+ const rejStat = { count: 0, firstAt: 0, lastAt: 0, lastLine: '' }
9076
+ process._dshAutoMemoryRejectionStat = rejStat
9077
+ process._dshAutoMemoryRejectionGuard = true
9078
+ process.on('unhandledRejection', (reason) => {
9079
+ const line = damDiagLine(reason)
9080
+ rejStat.count++
9081
+ rejStat.lastAt = Date.now()
9082
+ rejStat.lastLine = line
9083
+ if (!rejStat.firstAt) rejStat.firstAt = rejStat.lastAt
9084
+ damSafeDiag(process.stderr, '[dsh-auto-memory] unhandledRejection guard #' + rejStat.count + ': ' + line)
9085
+ })
8055
9086
  }
8056
9087
  } catch (e) {}
8057
9088
  const engine = new MemoryEngine()
@@ -8067,6 +9098,17 @@ export function apply(ctx, config) {
8067
9098
  engine._shadowHost = createShadowHost({ engine })
8068
9099
  // M5-3:Context Bridge Host 接线(assoc+contextBridge 双门;默认关闭零构造/零 IO)
8069
9100
  engine._contextHost = createContextHost({ engine })
9101
+ // R3(2026-09-18):降级台账。挂在引擎上,与各 host 同层 —— 跨臂统一,不分散到各 host。
9102
+ // 机制只记录不阻断;留痕自身 fail-soft(见 degrade.js 的元规则)。
9103
+ engine._degradeSink = createDegradeSinkPre({})
9104
+ // R4(2026-09-18):配额探针 —— 与降级台账**同模块、同落盘通道,但判据并列不混**。
9105
+ // 用户要求「配额得基于长期的观察,科学的(测量),不能拍脑子」,故每轮采集
9106
+ // tier0Meta 的配额切片(tokens/dropped/perLayer),供 deriveQuotaVerdictPre 下结论。
9107
+ // 注意:**绝不能**把常规观测塞进 `_degradeSink.record` —— 台账判据是「只记预期外失败」,
9108
+ // 混入后「有没有降级」将永远非空,降级信号被淹没。
9109
+ engine._quotaProbe = createQuotaProbePre({})
9110
+ // 注:臂健康快照(deriveArmsHealthPre)在各臂执行路径上按实际情况标注,
9111
+ // 见 recall() 内 semantic/evidence 段与 _l0IndexSync 段。
8070
9112
  // M6-3:Activation Inbox Host 接线(assoc+activationInbox 双门;默认关闭)
8071
9113
  engine._activationHost = createActivationHost({ engine })
8072
9114
  // M7-8:Host Index Sync Orchestrator(四门全开才启用;默认关闭零 IO;修复 M7-8 Phase E blocker)
@@ -8122,6 +9164,10 @@ export function apply(ctx, config) {
8122
9164
  io: hubIo('procedures.json'),
8123
9165
  }),
8124
9166
  },
9167
+ // ★T10:机械 procedure 切片开关(默认 false)。
9168
+ // 用 getter 活读 engine.config(与上面 gates 同理:挂载时 config 尚未 loadConfig,
9169
+ // 静态读会冻结 DEFAULT 值,导致设置页改了不生效)。
9170
+ get mechanicalProcedureFeedEnabled() { return engine.config.hubMechanicalProcedureFeedEnabled === true },
8125
9171
  })
8126
9172
  try {
8127
9173
  // restore 逐条校验,坏记录跳过(fail closed 幂等恢复);无文件/损坏 → 空启动
@@ -8165,7 +9211,17 @@ export function apply(ctx, config) {
8165
9211
  try { const d = JSON.parse(readFileSync(hubFlushFile(), 'utf8')); if (d && typeof d === 'object') { hubFlushState.date = String(d.date || ''); hubFlushState.count = Number(d.count) || 0; hubFlushState.flushed = d.flushed || {} } } catch (_) {}
8166
9212
  }
8167
9213
  const hubFlushSave = () => {
8168
- try { mkdirSync(path.dirname(hubFlushFile()), { recursive: true }); writeFileSync(hubFlushFile(), JSON.stringify({ date: hubFlushState.date, count: hubFlushState.count, flushed: hubFlushState.flushed }), 'utf8') } catch (_) {}
9214
+ // ★ T2-3(2026-09-19):改**原子写**(tmp + rename)。
9215
+ // 原实现是裸 `writeFileSync` —— 无锁、无 tmp+rename ⇒ 并发/中断时可能写坏,
9216
+ // 而写坏后 `hubFlushLoad` 静默吞掉异常 ⇒ `flushed` 回退到旧值 ⇒ **已写过的 fact 会被再写一遍**
9217
+ // (正文出现两个同名 `(M8 固化)` 段落)。rename 在同目录内是原子替换。
9218
+ try {
9219
+ const f = hubFlushFile()
9220
+ mkdirSync(path.dirname(f), { recursive: true })
9221
+ const tmp = f + '.tmp'
9222
+ writeFileSync(tmp, JSON.stringify({ date: hubFlushState.date, count: hubFlushState.count, flushed: hubFlushState.flushed }), 'utf8')
9223
+ renameSync(tmp, f)
9224
+ } catch (_) {}
8169
9225
  }
8170
9226
  hubFlushLoad()
8171
9227
  // 语料查询器(heading 富化用):与 context-host 同源 sidecar 目录,懒建缓存
@@ -8245,31 +9301,100 @@ export function apply(ctx, config) {
8245
9301
  if (typeof fact.confidence === 'number' && fact.confidence < 0.6) continue
8246
9302
  const subj = String(fact.subject || '').trim()
8247
9303
  if (!subj || subj.startsWith('mem_')) continue // 无富化的 memoryId 主语不入正文
9304
+ // ★ T1-3(2026-09-19 真机追加):**写入前内容卫生门**。
9305
+ // 背景:本通路的 6 道过滤全是「结构性」检查,**没有一道是内容卫生** —— 实测脏 fact
9306
+ // 已被写入正文并归档(`archive/notes-archived.md:493` 的 `## DSH ������(M8 固化)`)。
9307
+ // 判据复用 ⑨ 同源清洗器(F3 召回块标记 / F4 U+FFFD 编码损坏)。
9308
+ // ★ 处置选择「**脏则 skip + 留痕**」,**不做「清洗后照写」**:
9309
+ // 本仓纪律是 fail-soft 必须留痕、不得静默改写;清洗后照写等于悄悄改用户数据,
9310
+ // 且会丢失「曾经出现过脏 fact」这一诊断事实。
9311
+ const objRaw = fact.object ? String(fact.object) : ''
9312
+ const predRaw = String(fact.predicate || '要点')
9313
+ const cSubj = stripRuntimeIntentPre(subj).trim()
9314
+ const cObj = stripRuntimeIntentPre(objRaw).trim()
9315
+ const cPred = stripRuntimeIntentPre(predRaw).trim()
9316
+ const dirty = (cSubj !== subj) || (cObj !== objRaw.trim()) || (cPred !== predRaw.trim())
9317
+ // ★ F5(行内残留):清洗器按行判断,**真人与信封同一行**时整行保留(删了丢人话)
9318
+ // ⇒ 「清洗后是否变化」对这种形态无效。故再补一道行内残留检测:
9319
+ // 实测真机 fact[1] 的 object 就是 `现在是什么情况? Current DSH file policy: …`。
9320
+ || looksRuntimeResiduePre(subj) || looksRuntimeResiduePre(objRaw) || looksRuntimeResiduePre(predRaw)
9321
+ if (dirty) {
9322
+ hubFlushState.flushed[fact.factId] = true // 标记为已处理,避免每轮重扫
9323
+ try { diag('hub flush skip(hygiene): fact ' + String(fact.factId).slice(0, 16)) } catch (_) {}
9324
+ try {
9325
+ if (engine._degradePre && typeof engine._degradePre.record === 'function') {
9326
+ engine._degradePre.record('hub-flush', 'dirty-fact-skipped:' + String(fact.factId).slice(0, 16))
9327
+ }
9328
+ } catch (_) {}
9329
+ continue
9330
+ }
8248
9331
  try {
8249
9332
  if (!p) p = await engine.resolvePaths(engine.currentRuntime().agent)
8250
9333
  const target = fact.scope === 'User' ? p.userFile : p.notesPath
8251
9334
  if (!target) continue
8252
9335
  const cur = await engine.readTextSafe(target)
8253
- if (cur && cur.includes(subj)) { hubFlushState.flushed[fact.factId] = true; continue } // 已在正文,只记标记
8254
- const body = '\n## ' + subj + '(M8 固化)\n- ' + String(fact.predicate || '要点') + (fact.object ? ':' + String(fact.object) : '') + '\n- 来源:记忆中枢治理固化' + (typeof fact.confidence === 'number' ? '(confidence=' + fact.confidence.toFixed(2) + ')' : '')
9336
+ if (cur && cur.includes(subj)) {
9337
+ // ★ T2-1(2026-09-19):**标记与计数必须自洽**。
9338
+ // 原实现只 `flushed[...] = true` 而不 `count++` ⇒ 「已处理条数」被系统性低估,
9339
+ // 「今日写了几条」与「标记了几条」长期对不上(实测 count=0 / flushed=4 即此现象)。
9340
+ // 注意:此分支**并未真正写入正文**,故不计入写额度(count),但必须计入「已处理」标记 ——
9341
+ // 两者语义不同,此处显式留痕以便诊断区分(不写正文不计额度是正确行为,问题只在于原先完全静默)。
9342
+ hubFlushState.flushed[fact.factId] = true
9343
+ try { diag('hub flush skip(already-in-body): fact ' + String(fact.factId).slice(0, 16)) } catch (_) {}
9344
+ continue
9345
+ }
9346
+ // ★ T1-4:换行归一化 —— `## <subj>(M8 固化)` 与 `- <pred>:<obj>` 都是**单行**模板,
9347
+ // 若内容含 `\n` 可**伪造出新的 `## ` 标题**,并被 `compactLegacyLayer`
9348
+ // (`:4796` `^##\s+(.+)$`)当成独立段落搬运。此处把残存换行压成空格。
9349
+ const subj1 = cSubj.replace(/\s*[\r\n]+\s*/g, ' ').trim()
9350
+ const pred1 = cPred.replace(/\s*[\r\n]+\s*/g, ' ').trim()
9351
+ const obj1 = cObj.replace(/\s*[\r\n]+\s*/g, ' ').trim()
9352
+ const body = '\n## ' + subj1 + '(M8 固化)\n- ' + pred1 + (obj1 ? ':' + obj1 : '') + '\n- 来源:记忆中枢治理固化' + (typeof fact.confidence === 'number' ? '(confidence=' + fact.confidence.toFixed(2) + ')' : '')
8255
9353
  const written = await engine.appendText(target, body)
8256
9354
  try { if (engine.state) { if (fact.scope === 'User') engine.state.userText = written; else engine.state.notesText = written } } catch (_) {}
8257
9355
  hubFlushState.flushed[fact.factId] = true
8258
9356
  hubFlushState.count++
8259
9357
  diag('hub flush: fact ' + String(fact.factId).slice(0, 16) + ' → ' + (fact.scope === 'User' ? 'user' : 'notes'))
8260
- } catch (_) {}
9358
+ } catch (eFlush) {
9359
+ // ★ T1-5:**失败必须留痕**。原实现是 `catch (_) {}` 全吞 ⇒ 成功有 diag、失败零留痕,
9360
+ // 「写了但没成功」完全不可观测(面板 overview() 也不含 flush 字段)。
9361
+ // 对照本仓既有正确做法:`:5853` note-status 路径有 `_degradePre.record`。
9362
+ try { diag('hub flush FAIL: fact ' + String(fact.factId).slice(0, 16) + ' → ' + String((eFlush && eFlush.message) || eFlush)) } catch (_) {}
9363
+ try {
9364
+ if (engine._degradePre && typeof engine._degradePre.record === 'function') {
9365
+ engine._degradePre.record('hub-flush', 'append-failed:' + String(fact.factId).slice(0, 16) + ':' + String((eFlush && eFlush.message) || eFlush).slice(0, 80))
9366
+ }
9367
+ } catch (_) {}
9368
+ }
8261
9369
  }
8262
9370
  hubFlushSave()
8263
9371
  } catch (_) {}
8264
9372
  }
9373
+ // ★ T2-4(2026-09-19):**重入保护**。原实现 `setInterval(() => { void hubFlushTick() }, …)`
9374
+ // 只吞 Promise、无 in-flight 标志 ⇒ 若某次 tick 因 async IO 超过 30 分钟,下一次会并发进入,
9375
+ // 而 `hubFlushState` 是共享可变状态(读 flushed → 写 flushed/count)⇒ 竞态下「两条并发各读到 count=7」
9376
+ // 会写出超限条数。此处用单标志串行化:进行中则直接跳过本轮(不排队,避免堆积)。
9377
+ let hubFlushInFlight = false
9378
+ const hubFlushTickGuarded = async () => {
9379
+ if (hubFlushInFlight) {
9380
+ try { diag('hub flush skip(in-flight)') } catch (_) {}
9381
+ return
9382
+ }
9383
+ hubFlushInFlight = true
9384
+ try { await hubFlushTick() } finally { hubFlushInFlight = false }
9385
+ }
8265
9386
  const hubFeedTimer = setInterval(hubFeedTick, 60 * 1000)
8266
- const hubFlushTimer = setInterval(() => { void hubFlushTick() }, 30 * 60 * 1000)
9387
+ const hubFlushTimer = setInterval(() => { void hubFlushTickGuarded() }, 30 * 60 * 1000)
8267
9388
  // unref:定时器不阻止进程退出(测试 settle 不经过 apply 的 disposer 链会挂住)
8268
9389
  hubFeedTimer.unref(); hubFlushTimer.unref()
8269
- const hubBootTimer = setTimeout(() => { hubFeedTick(); void hubFlushTick() }, 90 * 1000)
9390
+ const hubBootTimer = setTimeout(() => { hubFeedTick(); void hubFlushTickGuarded() }, 90 * 1000)
8270
9391
  hubBootTimer.unref()
8271
9392
  if (!engine._hubFeedDisposers) engine._hubFeedDisposers = []
8272
- engine._hubFeedDisposers.push(() => { clearInterval(hubFeedTimer); clearInterval(hubFlushTimer) })
9393
+ // ★ T2-2(2026-09-19):**补 clearTimeout(hubBootTimer)**。
9394
+ // 原 disposer 只 clear 两个 interval,漏了 boot timer ⇒ 若在启动后 90s 内 dispose,
9395
+ // boot 回调仍会在 dispose **之后**触发一次(写入侧 dispose 已跑)。虽然 `:8958` 的门控仍在,
9396
+ // 但「dispose 后仍执行回调」本身违反 disposer 契约,必须补上。
9397
+ engine._hubFeedDisposers.push(() => { clearInterval(hubFeedTimer); clearInterval(hubFlushTimer); clearTimeout(hubBootTimer) })
8273
9398
  }
8274
9399
  // C2 内置语义引擎宿主(2026-08-26 用户裁定:C2=默认主路径)。懒加载 e5-small q8;
8275
9400
  // 只做检索排序,激活决策仍属两车道策略。下载器落位=发行包布局 lib/models。
@@ -8277,7 +9402,8 @@ export function apply(ctx, config) {
8277
9402
  const pluginDir = path.dirname(fileURLToPath(import.meta.url))
8278
9403
  engine._jsSemantic = createJsSemanticEnginePre({ pluginDir })
8279
9404
  // #15 后续/B:JS 模型下载落位用户目录(~/.dsh/models/js-semantic/)——包目录在 npm 更新时被重装,130MB 曾被冲掉
8280
- const userModelsRoot = path.join((process.env.DSH_HOME && process.env.DSH_HOME.trim()) || path.join(homedir(), '.dsh'), 'models', 'js-semantic')
9405
+ // ★#86-3:统一口径(原为内联三元,与 dshHome() 重复实现)。
9406
+ const userModelsRoot = path.join(resolveDshHomePre(), 'models', 'js-semantic')
8281
9407
  mkdirSync(userModelsRoot, { recursive: true })
8282
9408
  engine._jsDownload = createSemanticDownloaderPre({ modelsRoot: userModelsRoot })
8283
9409
  // ─────────── 三层检索契约 C3(2026-09-14):L0 向量索引接线 ───────────
@@ -8333,7 +9459,9 @@ export function apply(ctx, config) {
8333
9459
  try { for (const lg of await engine.listDailyLogs(p.projectDir, 14)) push('log', path.join(p.projectDir, lg && lg.name ? lg.name : '')) } catch (_) {}
8334
9460
  return await engine._l0IndexSync.sync({ enabled: true, workspaceKey: p.ws || p.projectDir || '', dir: engine.l0IndexDir(), sources })
8335
9461
  } catch (e) {
9462
+ // R3:预期外失败 —— 索引同步失败会让检索长期用陈旧索引,值得留痕。
8336
9463
  try { diag('l0-index sync 降级: ' + String((e && e.message) || e).slice(0, 120)) } catch (_) {}
9464
+ try { if (engine._degradeSink) engine._degradeSink.record('l0-sync', String((e && e.message) || e).slice(0, 160)) } catch (_) {}
8337
9465
  return { ok: false, reason: 'error', enabled: true, written: 0, count: 0, files: [] }
8338
9466
  }
8339
9467
  }
@@ -8352,7 +9480,18 @@ export function apply(ctx, config) {
8352
9480
  // probeJsSemanticAssets —— peer 探测与引擎加载走同一套 Node 解析(2026-09-02 issue 修正)。
8353
9481
  // _peerExtraDirs = 深度扫描热接入位(semanticDeepDetect 命中后 probe/加载/档位即时生效)。
8354
9482
  engine._peerExtraDirs = []
8355
- engine.semanticAssetProbe = async () => probeJsSemanticAssets(pluginDir, engine._peerExtraDirs)
9483
+ // ★ issue #70 修复(2026-09-19):把引擎的**运行期降级状态**注入探测结果。
9484
+ // 旧实现只回 `ready = assetPresent && peerPresent`(纯文件存在性)⇒ 引擎一旦 degrade
9485
+ // (onnx 损坏 / 维度不符 / peer 加载失败),引导卡仍显示「✓ 就绪」、`resolveSemanticTier`
9486
+ // 仍给 c2,而每次检索都在静默词法兜底 —— 三条用户可见路径与真实可用性脱钩。
9487
+ // 此处是**唯一接点**:`semantic-status`、`resolveSemanticTier`(:9148)、引导卡(:10000) 全走它。
9488
+ engine.semanticAssetProbe = async () => probeJsSemanticAssets(
9489
+ pluginDir,
9490
+ engine._peerExtraDirs,
9491
+ (engine._jsSemantic && typeof engine._jsSemantic.status === 'function')
9492
+ ? (engine._jsSemantic.status() || {}).degraded
9493
+ : '',
9494
+ )
8356
9495
  // 打开即自动检测(0.1.37,#14 后续):快检 → 模型在场但推理库缺失时深度扫描
8357
9496
  // (~/.dsh/profiles/* 全家 + pnpm 虚拟存储) → 命中即热接入(probe/加载双注入,无需重启),
8358
9497
  // 并给出 recommendation 供引导卡分流(none/setup-both/download-model/install-peer)。
@@ -8420,11 +9559,28 @@ export function apply(ctx, config) {
8420
9559
  try { return (await engine.semanticAssetProbe()).ready ? 'c2' : 'c1' } catch (_) { return 'c1' }
8421
9560
  }
8422
9561
  // context-host refs 选择钩子:C2 就绪时返回 {scores:Map};任何失败回退词法序。
9562
+ // ★R4-留痕(2026-09-18):原实现的 `catch (_) { return null }` 是**静默的** ——
9563
+ // 它把两类完全不同的情形压成同一个 null:
9564
+ // ① `tier !== 'c2'`(C2 资产未就绪)—— **预期内**,用户就是没装语义模型;
9565
+ // ② `_jsSemantic.rank` **抛错**(模型损坏/内存不足/代码缺陷)—— **预期外**,能力在运行时失效。
9566
+ // 二者不可区分 ⇒ 用户只感到"语义唤回不太灵",日志里什么都没有(这正是用户报的
9567
+ // 「静默失效困扰我一些时间了」)。现在:① 保持静默(不刷屏),② 记台账 + 留 tier 痕迹。
9568
+ // 铁律遵守:JS 与 Python 两套引擎仍**互不依赖**,此处只观测 JS 这一套自身的状态。
9569
+ engine._jsRankTier = ''
9570
+ engine._jsRankError = ''
8423
9571
  engine._jsSemanticRank = async (corpusSnap, queryText) => {
8424
9572
  try {
8425
- if ((await engine.resolveSemanticTier()) !== 'c2') return null
8426
- return await engine._jsSemantic.rank(corpusSnap, queryText)
8427
- } catch (_) { return null }
9573
+ const tier = await engine.resolveSemanticTier()
9574
+ engine._jsRankTier = String(tier || '')
9575
+ if (tier !== 'c2') return null
9576
+ const r = await engine._jsSemantic.rank(corpusSnap, queryText)
9577
+ engine._jsRankError = ''
9578
+ return r
9579
+ } catch (eJs) {
9580
+ engine._jsRankError = String((eJs && eJs.message) || eJs).slice(0, 140)
9581
+ try { if (engine._degradeSink) engine._degradeSink.record('semantic-arm', 'js 语义引擎抛错 → 该臂失效: ' + engine._jsRankError) } catch (_) {}
9582
+ return null
9583
+ }
8428
9584
  }
8429
9585
  // P13(2026-09-09):C3(python)语义臂 —— 经 sidecar recall_rank 调 worker.dense_search
8430
9586
  // (三重过滤:workspaceRef+scope+miv),返回 {scores:Map<memoryId,score>, source:'c3'}。
@@ -8474,14 +9630,37 @@ export function apply(ctx, config) {
8474
9630
  }
8475
9631
  // P13 择优:lexical → null;python 档 → _pySemanticRank;auto → 先 py(模型就绪,失败回 C2);js/C2 → _jsSemanticRank。
8476
9632
  // 逐级 fail-soft,任何一级失败自动落到下一级,绝不抛错阻塞检索。_jsSemanticRank 保留不动(激活路径仍在用)。
9633
+ //
9634
+ // ★R4-留痕(2026-09-18):三级降级链 `py → C2 → 词法` **每一跳都留痕**。
9635
+ // 旧实现只有最外层一个 catch 写台账 ⇒ 只有"抛错"这一种失败可见;而 `_pySemanticRank`
9636
+ // 失败的**常见形态是返回 null**(worker 拒绝 / 超时 / 空 scores / 三重过滤不匹配),
9637
+ // 它静默落回 C2 ⇒ 用户完全看不出 C3 档没在工作(正是用户报的「静默失效」)。
9638
+ // 现在分两层留痕,遵循 R3 判据(预期内静默、预期外记台账,绝不刷屏):
9639
+ // · **过程状态** `engine._rankPath` = 本轮降级链快照,成功也写(诊断一眼看全走到哪一跳)
9640
+ // · **degrade 台账** 只记预期外失败(该跳**抛错**)—— "返回 null" 属合法回退,不记
9641
+ engine._rankPath = ''
8477
9642
  engine._semanticRankBest = async (corpusSnap, queryText) => {
9643
+ const hops = []
8478
9644
  try {
8479
9645
  const mode = String(engine.config.semanticEngineMode || 'auto')
8480
- if (mode === 'lexical') return null
9646
+ if (mode === 'lexical') { engine._rankPath = '配置 lexical(无语义臂)'; return null }
8481
9647
  const r = await engine._pySemanticRank(corpusSnap, queryText)
8482
- if (r) return r
8483
- } catch (_) { /* fall through */ }
8484
- try { return await engine._jsSemanticRank(corpusSnap, queryText) } catch (_) { return null }
9648
+ if (r) { engine._rankPath = 'c3(python)'; return r }
9649
+ hops.push('c3 无结果')
9650
+ } catch (ePyBest) {
9651
+ hops.push('c3 抛错')
9652
+ try { if (engine._degradeSink) engine._degradeSink.record('semantic-arm', 'c3(python) 跳抛错 → 落 C2: ' + String((ePyBest && ePyBest.message) || ePyBest).slice(0, 120)) } catch (_) {}
9653
+ }
9654
+ try {
9655
+ const r2 = await engine._jsSemanticRank(corpusSnap, queryText)
9656
+ if (r2) { engine._rankPath = hops.join(' → ') + ' → c2(js)'; return r2 }
9657
+ hops.push('c2 无结果')
9658
+ } catch (eJsBest) {
9659
+ hops.push('c2 抛错')
9660
+ try { if (engine._degradeSink) engine._degradeSink.record('semantic-arm', 'c2(js) 跳抛错 → 落词法: ' + String((eJsBest && eJsBest.message) || eJsBest).slice(0, 120)) } catch (_) {}
9661
+ }
9662
+ engine._rankPath = hops.join(' → ') + ' → 词法(无语义臂)'
9663
+ return null
8485
9664
  }
8486
9665
  // JS 端判定核(2026-08-27):读策略工件(懒加载+缓存),对 C2 检索结果做 fv2 决策。
8487
9666
  // 完全独立于 Python——JS 端默认闭环(C2 检索 + JS 判定 + M6 投递)的核心。
@@ -8580,21 +9759,15 @@ export function apply(ctx, config) {
8580
9759
  // 默认关闭=零 Python process、零协议 IO、零 semantic 目录)
8581
9760
  // M7.6 Python 一键向导(#16-#20 配套):detect/venv/deps/model 四步,落盘用户目录,不碰 npm 包目录
8582
9761
  engine._pythonSetup = createPythonSetupPre({
8583
- dshHome: () => {
8584
- const env = process.env.DSH_HOME
8585
- if (env && env.trim()) return env.trim()
8586
- try { return path.join(homedir(), '.dsh') } catch (_) { return homedir() }
8587
- },
9762
+ // ★#86-3:统一口径(原回落链失败时返回 homedir() 本身,**丢掉 .dsh 后缀**)。
9763
+ dshHome: () => resolveDshHomePre(),
8588
9764
  diag: (m) => diag('python-setup: ' + m),
8589
9765
  })
8590
9766
  engine._pythonSidecar = createPythonSidecarClientPre({
8591
9767
  command: () => String(engine.config.pythonBackendExecutable || '').trim() || 'python',
8592
9768
  scriptPath: () => String(engine.config.pythonBackendWorkerPath || '').trim() || defaultWorkerScriptPathPre(),
8593
- dshHome: () => {
8594
- const env = process.env.DSH_HOME
8595
- if (env && env.trim()) return env.trim()
8596
- try { return path.join(homedir(), '.dsh') } catch (_) { return '' }
8597
- },
9769
+ // ★#86-3:统一口径(原回落链失败时返回**空串** ⇒ 调用方拼出相对路径)。
9770
+ dshHome: () => resolveDshHomePre(),
8598
9771
  })
8599
9772
  engine.__homedirFn = homedir
8600
9773
  const sessionQuery = ctx.get('sessionQuery')
@@ -8722,9 +9895,11 @@ export function apply(ctx, config) {
8722
9895
  try { engine.checkWaterLevelAtStep(agent) } catch (eWL) {}
8723
9896
  // M6-3:pre-step 时序(§8)——校验 cursor/index/TTL 后 claim packet,等待渲染面消费
8724
9897
  try { if (engine._activationHost) engine._activationHost.onPreStep(agent) } catch (_) {}
8725
- // 只刷新顶层会话:子代理(自动沉淀/固化的 subagent)session 无 cwd,刷新会把 state 切到错误工作区
9898
+ // 只刷新**用户会话**:子代理(subagent)session 无 cwd,刷新会把 state 切到错误工作区。
9899
+ // 2026-09-21 修: 原判据「有 parentSession 就跳」会把接续会话一并跳过 ⇒ 接续会话的
9900
+ // 工作区状态永远不刷新。改用 isSubAgentSession() 精确排除子代理(接续会话有 cwd,可安全刷新)。
8726
9901
  let skip = false
8727
- try { if (agent.session && agent.session.header && agent.session.header.parentSession) skip = true } catch (e) {}
9902
+ try { if (isSubAgentSession(agent)) skip = true } catch (e) {}
8728
9903
  const st = engine.stateFor(agent)
8729
9904
  if (!skip && (!st.loadedAt || Date.now() - st.loadedAt > 15000)) {
8730
9905
  // ★2026-09-15(修「注入头工作区 (未知)」· 病因 C):**必须包进 withAgent**。
@@ -8913,9 +10088,9 @@ export function apply(ctx, config) {
8913
10088
 
8914
10089
  // ---------- 工具 ----------
8915
10090
  const tools = [
8916
- defineTool('memory_log', '向当前工作区的 .dsh-memory/ 今日日志追加一条工作记录(append-only,自动建目录/文件)。完成实质性工作(改代码/修 bug/写文档/重构/技术选型/用户偏好约定)后必须调用;有跨会话长期价值的内容在同一轮内一并写入记忆(memory_note 项目/ memory_user 跨项目),progress 与 memory 一起写;不要记录临时信息。**调用后必须在本轮回复正文(摘要可见的正文,不是工具调用区)中向用户转述一句:如"已把 X 记入今日日志"**。', {
10091
+ defineTool('memory_log', '向当前工作区的 .dsh-memory/ 今日日志追加一条工作记录(append-only,自动建目录/文件)。完成实质性工作(改代码/修 bug/写文档/重构/技术选型/用户偏好约定)后必须调用;有跨会话长期价值的内容在同一轮内一并写入记忆(memory_note 项目/ memory_user 跨项目),progress 与 memory 一起写;不要记录临时信息。**★顺手维护白板(G4/M3,2026-09-17)**:若本次工作让**白板或账本所述与现状不符**(方向变了/阶段完成/旧结论被推翻),请在同一次回复里顺带调用 `memory_note`:项目稳定事实变了用 `kind=plan` 重写白板(旧版自动归档),动态状态变了用 `kind=handoff` 新开一篇账本(append-only,不追改旧账本)。**这是条件触发,不是每回都做**;白板功能关闭时跳过即可,不要因此报错。**调用后必须在本轮回复正文(摘要可见的正文,不是工具调用区)中向用户转述一句:如"已把 X 记入今日日志"**。', {
8917
10092
  note: { type: 'string', required: true, description: '简短条目:一句话概括做了什么、结果如何。' },
8918
- date: { type: 'string', description: '日志日期 YYYY-MM-DD,缺省今天。' },
10093
+ date: { type: 'string', description: '日志日期 YYYY-MM-DD,**缺省今天**。★可补写过去某天(如补记昨天的工作)——会写进那一天的文件,不会覆盖同文件已有内容(append-only)。格式非法时静默回退到今天。' },
8919
10094
  kind: { type: 'string', enum: ['rule', 'preference', 'fact', 'todo'], description: '★P6B:条目性质标记(缺省 fact)。rule=用户规则/约定;preference=偏好;todo=待办;fact=事实记录。是规则或用户明确约定的条目请传 kind=rule——它会被规则层识别为必须遵守的约束。纯标记、零额外 LLM 调用。' },
8920
10095
  }, async (args, exec) => {
8921
10096
  const date = DATE_RE.test(args.date || '') ? args.date : engine.memToday()
@@ -8938,10 +10113,18 @@ export function apply(ctx, config) {
8938
10113
  return '已更新记忆文档: ' + logPath + '\n' + entry + (gate.truncated ? '\n(内容超长,已截断)' : '')
8939
10114
  }),
8940
10115
 
8941
- defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点),或写交接白板。kind=note(默认):action=append 追加一段(自动带日期标题)/action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容),本项目笔记有**容量上限**(字符,设置项 noteCapacityChars,默认 12000);超出时自动整理——先把较早内容交给 AI 折叠成要点、失败则退回整条归档(原文都进 archive,不丢),整理后仍超才拒绝(正常不会发生)。kind=handoff:写一篇四段式交接账本(任务状态/目标/已试方案与失败原因/进度与下一步)到 handoff/,阶段产出或方向变化时用,给下一个上下文窗口续命。kind=plan:整体重写白板 PLAN.md(人能读的项目全貌规划图),对项目全貌的理解发生实质变化时用,旧版自动归档。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
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=**当时就做错了、直接撤回**(无后继,本身即教训)。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
8942
10117
  content: { type: 'string', required: true, description: '笔记内容。kind=plan 时给完整新全貌(不是增量)。' },
8943
10118
  action: { type: 'string', enum: ['append', 'replace'], description: '仅 kind=note 时有效:append=追加, replace=整体替换。' },
8944
10119
  kind: { type: 'string', enum: ['note', 'handoff', 'plan'], description: 'note=项目笔记(默认), handoff=四段式交接账本(新篇), plan=白板全貌重写。' },
10120
+ // ★G3(2026-09-19):结论层状态写入 —— **显式可选参数**,不传时行为与从前逐字节相同(零自动行为、零误判)。
10121
+ // 设计依据:用户 2026-09-18 裁定「只认显式声明」(门槛 = 结构化参数 = 最强的显式)。
10122
+ // 为什么不做"自动比对同主题旧条目":误判代价不对称(漏判=维持现状;误判=有效结论被标作废)。
10123
+ supersedes: { type: 'array', items: { type: 'string' }, description: '(可选)要标为 superseded 的旧条目 memoryId 列表(mem_<32hex>)。**仅在你明确知道被取代的是哪条时传**;不确定就不要传。会在旧条目正文末尾追加一行 `<!-- dsh-status: superseded by=<新条目id> -->`。' },
10124
+ // ★T6(2026-09-20 用户拍板):**retracted 通道** —— 与 supersedes 严格分工,别混用。
10125
+ retract: { type: 'array', items: { type: 'string' }, description: '(可选)要标为 **retracted(撤回)** 的旧条目 memoryId 列表(mem_<32hex>)。**与 supersedes 的分工**:supersedes = 被**更新的结论取代**(有后继结论,可追 mem_id);**retract = 当时就做错了、直接撤回**(无后继,"错误本身"就是教训)。用户裁定「retracted 不是垃圾,是教训,不过滤只备注」⇒ 检索仍会返回它并标 ⚠已撤回。**强烈建议同时传 retractReason 说明错在哪**。仅在你确知标错的是哪条时传。' },
10126
+ retractReason: { type: 'string', description: '(可选,配合 retract)撤回原因,一行内说明**错在哪**,上限 120 字符。会写成 `reason="…"` 附在状态行上,供检索时显示。' },
10127
+ restore: { type: 'array', items: { type: 'string' }, description: '(可选)**撤销通道**:把指定 memoryId 的状态改回 current(移除状态行)。用于纠正标错的 superseded/retracted。' },
8945
10128
  }, async (args, exec) => {
8946
10129
  const p = await engine.resolvePaths(exec.agent)
8947
10130
  const content = String(args.content || '').trim()
@@ -8988,10 +10171,20 @@ export function apply(ctx, config) {
8988
10171
  body = await engine.appendText(p.notesPath, '\n## ' + engine.memToday() + '\n' + write)
8989
10172
  }
8990
10173
  engine.state.notesText = body; engine.state.loadedAt = Date.now()
8991
- return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + write + (gate.truncated ? '\n(内容超长,已截断到 ' + write.length + ' 字符)' : '') + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
10174
+ // ★G3(2026-09-19)结论层状态写入 —— **仅在显式传参时执行**;不传 ⇒ 本段零作用。
10175
+ // 顺序:**先正常写入成功,再改状态**(写入失败就不该动状态,避免"新结论没进去、旧结论却被标废")。
10176
+ // fail-soft:状态应用失败**绝不影响**已成功的笔记写入(照常返回成功,另附一行说明)。
10177
+ let statusNote = ''
10178
+ try {
10179
+ const sup = Array.isArray(args.supersedes) ? args.supersedes.filter((x) => typeof x === 'string') : []
10180
+ const ret = Array.isArray(args.retract) ? args.retract.filter((x) => typeof x === 'string') : []
10181
+ const res = Array.isArray(args.restore) ? args.restore.filter((x) => typeof x === 'string') : []
10182
+ if (sup.length || ret.length || res.length) statusNote = await engine.applyNoteStatusPre(p.notesPath, { supersedes: sup, retract: ret, restore: res, reason: args.retractReason }, body)
10183
+ } catch (e) { statusNote = '\n(状态写入异常,已跳过:' + String((e && e.message) || e) + ')' }
10184
+ return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + write + (gate.truncated ? '\n(内容超长,已截断到 ' + write.length + ' 字符)' : '') + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '') + statusNote
8992
10185
  }),
8993
10186
 
8994
- defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。有**容量上限**(字符,设置项 userCapacityChars,默认 12000);超出时自动整理——先把较早内容交给 AI 折叠成要点、失败则退回整条归档(原文都进 archive,不丢),整理后仍超才拒绝(正常不会发生)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
10187
+ defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。有**容量上限**(字符,设置项 userCapacityChars,默认 24000);**超出时的行为**(你不需要自己控制长度,写就是):①先把较早内容交给 AI 折叠成要点(同一层 10 分钟内只整理一次);②整理失败则**整条原文**归档到 `.dsh-memory/archive/`(信息不丢);③整理后仍超才拒绝(正常不会发生)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
8995
10188
  content: { type: 'string', required: true, description: '要记住的规则或偏好内容。' },
8996
10189
  action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
8997
10190
  }, async (args, exec) => {
@@ -9048,14 +10241,14 @@ export function apply(ctx, config) {
9048
10241
  limit: { type: 'integer', description: '最多返回条数,缺省 8。' },
9049
10242
  scope: { type: 'string', enum: ['all', 'handoff', 'sessions'], description: '检索范围:all=全部(默认), handoff=交接白板语料(跨窗口续命材料), sessions=历史 DSH 会话。' },
9050
10243
  format: { type: 'string', enum: ['l0', 'full'], description: '本地记忆命中格式:l0=L0 摘要列表(默认,每条含 id/得分/匹配原因),full=整条原文(旧行为)。' },
9051
- expand: { type: 'string', description: '按记忆 id(mem_ + 32 个十六进制字符)展开该条完整原文;提供时忽略检索语义。' },
10244
+ expand: { type: 'string', description: '按记忆 id(mem_ + 32 个十六进制字符)展开该条完整原文。★**提供 expand 时 query 只作占位、检索语义被忽略**(仍必填,随便填该 id 即可);这是**两段式用法**:先用默认 l0 拿到候选列表与 id,再对感兴趣的那条 expand 取全文,避免一次性灌入大量原文。' },
9052
10245
  }, async (args, exec) => engine.recall(args.query, args.limit, exec.agent, args.scope || 'all', { format: args.format || 'l0', expand: args.expand })),
9053
10246
 
9054
10247
  defineTool('memory_maintain', '维护记忆(30 天蒸馏):把 days(缺省30)天前的 .dsh-memory/ 每日日志交给 AI 蒸馏提炼出有长期价值的要点写入项目 MEMORY.md,原文保底归档到 .dsh-memory/archive/ 后从活跃日志移除。AI 不可用时降级为原样归档,不丢信息。', {
9055
- days: { type: 'integer', description: '归档阈值天数,缺省 30。' },
10248
+ days: { type: 'integer', description: '**归档阈值天数**,缺省 30。语义:把**早于「今天 − days 天」**的每日日志挑出来蒸馏,不是「最近 days 天」。**只影响活跃日志的可见性,不删信息**——要点进 MEMORY.md,原文整份归档到 `.dsh-memory/archive/`。AI 不可用时降级为原样归档。★属**低频维护动作**,不要每轮调。' },
9056
10249
  }, async (args, exec) => engine.maintain(args.days, exec.agent)),
9057
10250
 
9058
- defineTool('memory_status', '查看自动记忆的当前状态:存储位置、各记忆文件大小、今日日志条数、待反思、上次刷新时间。用于确认记忆系统工作正常。', {}, async (_args, exec) => {
10251
+ defineTool('memory_status', '查看自动记忆的当前状态:存储位置、各记忆文件大小、今日日志条数、待反思、上次刷新时间。★**什么时候用**:①用户问「记忆系统正常吗/我的记忆存在哪/有多少条」;②怀疑某轮没写进记忆时自查;③新工作区开工前确认路径对不对。★**不该用于**:每轮例行检查(它是只读诊断,不是流程环节);想看记忆**内容**用 memory_read / memory_recall。', {}, async (_args, exec) => {
9059
10252
  const snap = await engine.snapshot(exec.agent)
9060
10253
  const lines = []
9061
10254
  lines.push('工作区: ' + snap.ws)
@@ -9067,12 +10260,12 @@ export function apply(ctx, config) {
9067
10260
  return lines.join('\n')
9068
10261
  }),
9069
10262
 
9070
- defineTool('memory_reflect', '保存每日反思(在收到「昨日反思待生成」提示、并已在回复中呈现反思后调用)。将反思全文落盘到 .dsh-memory/reflections/YYYY-MM-DD.md,并标记该日反思完成。', {
10263
+ defineTool('memory_reflect', '保存每日反思。★**触发条件严格**:仅在收到框架的「昨日反思待生成」提示、且**已在回复正文中向用户呈现了反思内容之后**才调用——不是你想反思就反思。落盘到 .dsh-memory/reflections/YYYY-MM-DD.md 并标记该日完成(标记后当天不再提示)。date 传**被反思那天的日志日期**(通常是昨天),不是今天。', {
9071
10264
  date: { type: 'string', required: true, description: '反思对应的日期 YYYY-MM-DD(即被反思那天的日志日期)。' },
9072
10265
  text: { type: 'string', required: true, description: '完整反思内容:成果回顾 / 教训改进 / 今日可延续要点。' },
9073
10266
  }, async (args, exec) => engine.saveReflection(args.date, args.text, exec.agent)),
9074
10267
 
9075
- defineTool('memory_external', '查看/接入其他 AI 工具(AI 助手/CodeBuddy/Claude Code/Codex/ZCode/Kimi Code/TRAE/项目约定文件)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import 以纯链接模式接入(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆,只记录源文件路径指针、不写入内容,需要时按需读取;防止外部脏内容混入本地记忆)。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
10268
+ defineTool('memory_external', '查看/接入其他 AI 工具(AI 助手/CodeBuddy/Claude Code/Codex/ZCode/Kimi Code/TRAE/项目约定文件)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import **以纯链接模式**接入(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆)。★**「纯链接模式」的含义**:只在你的记忆里写一条**源文件绝对路径指针**,**不把对方内容抄进来**——目的是①防外部脏内容混入、②对方内容会变而指针不会过期。⇒ 需要内容时**按指针路径读取原文件**或记忆里说明的路径,不要去猜。★首次在新工作区工作时先 list,判断该项目是否曾在其他 AI 工具里做过。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
9076
10269
  action: { type: 'string', enum: ['list', 'import'], required: true, description: 'list=列出外部记忆源; import=接入指定源。' },
9077
10270
  source: { type: 'string', description: '要接入的源 id(action=import 时必填,来自 list 结果)。' },
9078
10271
  target: { type: 'string', enum: ['project', 'user'], description: '接入目标: project=项目笔记(默认), user=用户级记忆。' },
@@ -9129,9 +10322,89 @@ export function apply(ctx, config) {
9129
10322
  title: { type: 'string', required: true, description: '事项标题。' },
9130
10323
  }, async (args, exec) => engine.calendarRemove(args.date, args.time, args.title, exec.agent)),
9131
10324
 
9132
- defineTool('memory_consolidate', 'AI 主动维护长期记忆(做梦式固化):读最近 days 天的工作日志,由 AI 发散提炼出有跨会话长期价值的决策/架构/用户偏好,自动写入项目笔记 MEMORY.md(带日期标题)与用户级 MEMORY.md(跨项目规则),并在正文向用户转述固化结果。适合隔一段时间主动调用一次;每轮对话结束的自动沉淀也基于同一套提炼逻辑。', {
10325
+ defineTool('memory_consolidate', 'AI 主动维护长期记忆(做梦式固化):读最近 days 天的工作日志,由 AI 发散提炼出有跨会话长期价值的决策/架构/用户偏好,自动写入项目笔记 MEMORY.md(带日期标题)与用户级 MEMORY.md(跨项目规则),并在正文向用户转述固化结果。★**与「自动沉淀」的边界**:每轮对话结束框架会**自动**评估并写日志/升格要点,**你不需要为日常轮次做这件事**;本工具是**你主动发起的加料**——隔一段时间(或一个阶段收尾时)用来做一次更彻底的提炼,读的是**多日日志**、输出进项目笔记与用户级记忆。⇒ 用它做「阶段性固化」,不要用它替代每轮的 memory_log。', {
9133
10326
  days: { type: 'integer', description: '读取最近 N 天日志,缺省 7,上限 30。' },
9134
10327
  }, async (args, exec) => engine.consolidateMemory(exec.agent, Math.min(Math.max(Number(args.days) || 7, 1), 30))),
10328
+
10329
+ // ★★★ T4(2026-09-19 用户拍板「让大模型来介入 procedure memory」)★★★
10330
+ // **工具数 16→17**(无条件注册;四处硬锁联动:smoke-test.mjs / m3b3 / context-observer / graph-mode)。
10331
+ // 背景:此前三条记忆线(episodic / semantic / procedural)**全部只有机械生成**,
10332
+ // 模型没有任何写入通路。procedural 线的唯一来源是 `memory-hub.js` 的 crossFeed:
10333
+ // 它把每个"成功 episode"机械切成候选,而 episode 的 actions 就是 `['user','user','user']`
10334
+ // ⇒ 产出 14 条里 13 条 evidence 全 0 / successCriteria 全 0 / steps 是 "步骤1: user" 占位符。
10335
+ // 清洗器只能删信封文字,**无法把 ['user','user','user'] 变成有价值的流程**(输入本就不含流程信息)。
10336
+ // 本工具给出**模型直写通路**:模型看懂了什么值得复用,就直接写进来。
10337
+ // action='write' → observe()(进审批列表,等人工或后续晋升)
10338
+ // action='activate' → observe → promote(model 授权跳统计门) → activate → **自动导出 SKILL.md**
10339
+ // 即用户原话「如果我这个模型觉得值得上升,那就可以直接上升到这个激活列表」。
10340
+ // 护栏(授权也不放行,见 procedure-store.js promote 注释):observationOnly 短路 /
10341
+ // 必须有 successCriteria / correction 记录阻止。行为全程 fail-soft + diag 留痕。
10342
+ defineTool('memory_procedure', '把一个**值得复用的流程**写进 procedure memory(技能库)——这是模型直写通路,取代此前的机械生成。什么时候用:你刚跑通了一个多步骤流程、踩坑后总结出了正确做法、或发现某个操作值得下次照做时。**判据**:有明确步骤、可重复、下次遇到类似场景能直接照做。action=write 进审批列表(保守,推荐先这样);action=activate 一步到位激活并可被自动召回(仅当你确信它稳定可复用)。写出的条目须含 title + steps,建议一并给 successCriteria(**没有 successCriteria 的条目永远无法晋升**)。', {
10343
+ action: { type: 'string', enum: ['write', 'activate'], description: 'write=写入并进审批列表(默认);activate=写入后直接晋升并激活(可被召回,同时自动导出 SKILL.md)。' },
10344
+ title: { type: 'string', required: true, description: '一句话说清这是什么流程(如「发布前跑全量回归并核对 SHA256」)。不要用运行时样板文字或纯提问句。' },
10345
+ steps: { type: 'string', required: true, description: '流程步骤,**一行一步**(换行分隔);行首的 "1. " / "- " 会自动去掉。' },
10346
+ successCriteria: { type: 'string', description: '怎么算跑通,一行一条。**强烈建议填写**——缺它则该条目结构上无法晋升。' },
10347
+ preconditions: { type: 'string', description: '前置条件,一行一条(可选)。' },
10348
+ checks: { type: 'string', description: '过程中要检查的点,一行一条(可选)。' },
10349
+ rollback: { type: 'string', description: '失败时怎么回滚,一行一条(可选)。' },
10350
+ riskLevel: { type: 'string', enum: ['low', 'medium', 'high'], description: '风险等级,缺省 low。high 会要求人工批准后才可激活。' },
10351
+ }, async (args, exec) => {
10352
+ try {
10353
+ const hub = engine._memoryHub
10354
+ if (!hub || !hub.stores || !hub.stores.procedures) return 'memory_procedure: 记忆中枢未启用(hub 或 procedure store 不可用),未写入。'
10355
+ const procs = hub.stores.procedures
10356
+ // 一行一条:去掉行首编号/项目符号,丢弃空行(模型输出格式不稳定的兜底)
10357
+ const toLines = (v) => String(v == null ? '' : v).split(/\r?\n/)
10358
+ .map((s) => s.replace(/^\s*(?:\d+[.、)]|[-*•])\s*/, '').trim())
10359
+ .filter(Boolean)
10360
+ const title = String(args.title || '').trim()
10361
+ const steps = toLines(args.steps)
10362
+ const successCriteria = toLines(args.successCriteria)
10363
+ if (!title) return 'memory_procedure: title 必填。'
10364
+ if (!steps.length) return 'memory_procedure: steps 必填(至少一步)。'
10365
+ const cand = {
10366
+ title, steps, successCriteria,
10367
+ preconditions: toLines(args.preconditions),
10368
+ checks: toLines(args.checks),
10369
+ rollback: toLines(args.rollback),
10370
+ riskLevel: ['low', 'medium', 'high'].includes(args.riskLevel) ? args.riskLevel : 'low',
10371
+ // origin='agent' ⇒ 走 agent 口径;**不设 observationOnly** —— 它是真技能(有 steps + criteria),
10372
+ // 与机械切出来的空壳观察行在结构上区分开,因此天然可晋升。
10373
+ origin: 'agent',
10374
+ sourceMemoryIds: [], sourceEpisodes: [],
10375
+ }
10376
+ const r = procs.observe(cand)
10377
+ if (!r || !r.ok) return 'memory_procedure: 写入失败(' + String((r && r.reason) || 'unknown') + ')。'
10378
+ const pid = r.procedure && r.procedure.procedureId
10379
+ let out = (r.merged ? '已并入既有条目(指纹相同)' : '已写入') + ':' + title + ' [id=' + String(pid).slice(0, 20) + ']'
10380
+ if (!successCriteria.length) out += '\n⚠ 未提供 successCriteria —— 该条目**结构上无法晋升**,之后请补写。'
10381
+ if (args.action !== 'activate') return out + '\n(当前在审批列表,未激活。需要时再调 action=activate)'
10382
+
10383
+ // —— action=activate:模型授权跳统计门 → 晋升 → 激活 → 导出 SKILL.md ——
10384
+ const pr = procs.promote(pid, {}, { authorizedBy: 'model' })
10385
+ if (!pr || !pr.ok || pr.decision !== 'promote') {
10386
+ return out + '\n未激活:晋升未通过(decision=' + String((pr && pr.decision) || '?') +
10387
+ ' reasonCodes=' + JSON.stringify((pr && pr.reasonCodes) || []) + ')。条目仍在审批列表。'
10388
+ }
10389
+ const ar = procs.activate(pid)
10390
+ if (!ar || !ar.ok) return out + '\n已晋升但激活失败:' + String((ar && ar.reason) || 'unknown')
10391
+ out += '\n已晋升(授权=model)并激活。'
10392
+ try {
10393
+ const ex = exportSkillForPre(ar.procedure, {
10394
+ skillsRoot: resolveSkillsRootPre({ dshHome: dshHome() }),
10395
+ projectPath: process.cwd(),
10396
+ exportedAt: new Date().toISOString(),
10397
+ })
10398
+ out += ex && ex.ok ? '\n已导出 SKILL.md:' + String(ex.dirName || '') : '\nSKILL.md 导出失败(' + String((ex && ex.reason) || '?') + '),不影响已激活状态。'
10399
+ } catch (e) {
10400
+ out += '\nSKILL.md 导出异常:' + String((e && e.message) || e) + '(不影响已激活状态)'
10401
+ }
10402
+ try { diag('procedure write(by model): ' + String(pid).slice(0, 20) + ' action=' + String(args.action)) } catch (_) {}
10403
+ return out
10404
+ } catch (e) {
10405
+ return 'memory_procedure 失败: ' + ((e && e.message) || String(e))
10406
+ }
10407
+ }),
9135
10408
  ]
9136
10409
 
9137
10410
  // ── WB-GRAPH 白板线新工具(board_mode_v1 闸门, 2026-09-16)──
@@ -9143,11 +10416,11 @@ export function apply(ctx, config) {
9143
10416
  // **数组之外**, 返回值**从未 push 进 tools** ⇒ 即便闸门判定为 true, 两个工具也不会被注册。
9144
10417
  // 这是与 BUG-1 独立的第二道致命缺陷: 修好「读到真配置」还不够, 还必须真的把定义收进数组。
9145
10418
  if (resolveBoardModePre(engine.config.boardMode).graphEnabled) {
9146
- tools.push(defineTool('memory_expand_pre', '白板结构化展开(P3, 需 boardMode=graph):正向遍历——给定 tag(如 type:dead-end / topic:登录)展开所有匹配的账本/白板条目 Content,默认 limit 10、硬帽 20。返回条目 id、标题、来源(source 文件+行)与判据状态。适合主动重建上下文(如「把所有失败方案列出来」)。', {
10419
+ tools.push(defineTool('memory_expand', '白板结构化展开(P3, 需 boardMode=graph):正向遍历——给定 tag(如 type:dead-end / topic:登录)展开所有匹配的账本/白板条目 Content,默认 limit 10、硬帽 20。返回条目 id、标题、来源(source 文件+行)与判据状态。适合主动重建上下文(如「把所有失败方案列出来」)。', {
9147
10420
  tag: { type: 'string', description: '要展开的 tag,如 type:dead-end 或 topic:主题名。' },
9148
10421
  limit: { type: 'integer', description: '返回条数上限,缺省 10,硬帽 20。' },
9149
10422
  }, async (args, exec) => engine.expandWhiteboardByTagPre(exec.agent, String(args.tag || ''), Math.min(Math.max(Number(args.limit) || 10, 1), 20))))
9150
- tools.push(defineTool('memory_trace_pre', '白板结构化回溯(P3, 需 boardMode=graph):反向遍历——给定条目 id 回溯它的 cue(入口关键词/路径)、tag 与相邻条目,以及归档版本链(prev_version)。适合「这条结论从哪来」的溯源。', {
10423
+ tools.push(defineTool('memory_trace', '白板结构化回溯(P3, 需 boardMode=graph):反向遍历——给定条目 id 回溯它的 cue(入口关键词/路径)、tag 与相邻条目,以及归档版本链(prev_version)。适合「这条结论从哪来」的溯源。', {
9151
10424
  id: { type: 'string', description: '条目 id(index.json 里的条目标识)。' },
9152
10425
  }, async (args, exec) => engine.traceWhiteboardByIdPre(exec.agent, String(args.id || ''))))
9153
10426
  }
@@ -9442,7 +10715,8 @@ export function apply(ctx, config) {
9442
10715
  if (!cfg || typeof cfg !== 'object') cfg = {}
9443
10716
  cfg.activationEmitMode = mode
9444
10717
  fsMod.mkdirSync(pathMod.dirname(cfgPath), { recursive: true })
9445
- fsMod.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2), 'utf8')
10718
+ // ★#82:同一类裸写 —— 切原子写(语义引擎开关的读数来源,半截 JSON 会让双轨读到不同值)。
10719
+ writeTextAtomicPreSync(cfgPath, JSON.stringify(cfg, null, 2))
9446
10720
  return writeJson(res, 200, { ok: true, mode })
9447
10721
  } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
9448
10722
  },
@@ -9628,7 +10902,27 @@ export function apply(ctx, config) {
9628
10902
  if (!pid) return writeJson(res, 400, { error: 'procedureId required' })
9629
10903
  let r
9630
10904
  if (action === 'promote') r = procs.promote(pid)
9631
- else if (action === 'activate') r = procs.activate(pid)
10905
+ else if (action === 'activate') {
10906
+ r = procs.activate(pid)
10907
+ // ★ ⑪-2(用户 2026-09-19 拍板):晋升为 active 后**自动导出** SKILL.md。
10908
+ // 落点 = 用户级 `<dshHome>/skills/`(DSH 四条发现路径之一,可跨项目迁移);
10909
+ // 导出物**必须标注适用项目**(⑪-3),且随附程序只作**参考**、不得直接运行(⑪-1)。
10910
+ // 全程 fail-soft:导出失败只记诊断,绝不回滚已成功的 activate。
10911
+ if (r && r.ok) {
10912
+ try {
10913
+ const ex = exportSkillForPre(r.procedure, {
10914
+ skillsRoot: resolveSkillsRootPre({ dshHome: dshHome() }),
10915
+ projectPath: process.cwd(),
10916
+ exportedAt: new Date().toISOString(),
10917
+ })
10918
+ r.skillExport = ex
10919
+ try { diag('hub review: skill export ' + pid.slice(0, 20) + ' → ' + JSON.stringify({ ok: ex.ok, reason: ex.reason, dir: ex.dirName })) } catch (_) {}
10920
+ } catch (e) {
10921
+ r.skillExport = { ok: false, reason: 'export-threw:' + String((e && e.message) || e) }
10922
+ try { diag('hub review: skill export threw ' + String((e && e.message) || e)) } catch (_) {}
10923
+ }
10924
+ }
10925
+ }
9632
10926
  else if (action === 'deprecate') r = procs.deprecate(pid, 'user-disabled')
9633
10927
  else r = procs.setPinned(pid, (body && body.v) !== false)
9634
10928
  // issue #30:旧日志只记 `r.ok` —— 而 promote 的"拒绝晋升"也是 ok:true(decision='keep'),
@@ -9963,6 +11257,61 @@ export function apply(ctx, config) {
9963
11257
  } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
9964
11258
  },
9965
11259
  },
11260
+ // ── ★R7(2026-09-20):用户级硬性约束的条目级读写 ──────────────────
11261
+ // 背景:`[规则 — 用户级硬性约束]` 段**每轮无条件注入、不走语义层** ⇒
11262
+ // 过时条目不会被自动淘汰,AI 也可能写错 ⇒ 必须让用户能自己增删改。
11263
+ // 纪律:① 真源仍是 `~/.dsh/memory/MEMORY.md`,本路由**不新增事实来源**;
11264
+ // ② 写入复用既有 `writeFull` 事务(备份 + 校验),不绕过;
11265
+ // ③ 删除**是真删**(本层渲染器不认状态标记,软删会被当正文注入模型)⇒
11266
+ // 由前端做二次确认,host 侧只如实执行。
11267
+ {
11268
+ kind: 'exact',
11269
+ path: API['rules-list'],
11270
+ handler: async (req, res) => {
11271
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
11272
+ if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
11273
+ try {
11274
+ const p = await engine.resolvePaths(undefined)
11275
+ const text = (await engine.readTextSafe(p.userFile)) || ''
11276
+ const items = listRuleItemsPre(text)
11277
+ writeJson(res, 200, {
11278
+ path: p.userFile,
11279
+ items,
11280
+ // 缺省注入时这些条目会长成什么样(R7-6 预览用)
11281
+ preview: items.map((x) => '- ' + x.text).join('\n'),
11282
+ })
11283
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
11284
+ },
11285
+ },
11286
+ {
11287
+ kind: 'exact',
11288
+ path: API['rules-apply'],
11289
+ handler: async (req, res) => {
11290
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
11291
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
11292
+ const body = await readJsonBody(req)
11293
+ const op = String((body && body.op) || '')
11294
+ if (!['add', 'update', 'remove'].includes(op)) return writeJson(res, 400, { error: 'invalid-op' })
11295
+ 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
+ })
11312
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
11313
+ },
11314
+ },
9966
11315
  {
9967
11316
  kind: 'exact',
9968
11317
  path: API.external,
@@ -10220,4 +11569,4 @@ export function apply(ctx, config) {
10220
11569
  }
10221
11570
 
10222
11571
  /** 导出卫生守卫与脏 token 检查器(供 smoke-test / 回归测试直接调用)。 */
10223
- export { sanitizeForWrite, dirtyScanForFiles, mojibakeDensity, tailHas, hasStutter, WRITE_GATE_REASON, foldSessionLogEvents, workspaceIdForSession }
11572
+ export { sanitizeForWrite, hygieneGateForPrimitive, dirtyScanForFiles, mojibakeDensity, tailHas, hasStutter, WRITE_GATE_REASON, foldSessionLogEvents, workspaceIdForSession, attachmentsOfContent, attachmentBlobPathsPre, renderAttachmentLinesPre }