@a9i5k4/dsh-auto-memory 0.1.28 → 0.1.30

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.
package/lib/index.js CHANGED
@@ -18,19 +18,43 @@
18
18
  */
19
19
 
20
20
  import { readFile, writeFile, mkdir, readdir, stat, rm, copyFile, appendFile } from 'node:fs/promises'
21
- import { createReadStream, existsSync } from 'node:fs'
21
+ import { createReadStream, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
22
22
  import { exec as cpExec } from 'node:child_process'
23
23
  import { promisify } from 'node:util'
24
24
  const execP = promisify(cpExec)
25
25
  import { homedir } from 'node:os'
26
26
  import path from 'node:path'
27
27
  import { fileURLToPath } from 'node:url'
28
+ import { MemoryDocumentStore } from './memory-writer.js'
29
+ import { parseAnchors } from './memory-anchor.js'
30
+ import { createShadowHost } from './shadow-host.js'
31
+ import { createContextHost } from './context-host.js'
32
+ import { createSuccessEvidencePre } from './context-bridge.js'
33
+ import { createIndexSyncHostPre } from './m7-index-sync-host.js'
34
+ import { createActivationHost } from './activation-host.js'
35
+ import { createJsSemanticEnginePre, createSemanticDownloaderPre, fuseD6Pre, E5_SMALL_Q8_MANIFEST_V1 } from './semantic-js.js'
36
+ import { loadAndVerifyPolicy, decideActivationV2, lexicalContainment } from './semantic-decide.js'
37
+ import { createPythonSidecarClientPre, defaultWorkerScriptPathPre } from './python-sidecar-client.js'
38
+ import { createEpisodicStorePre } from './episodic-store.js'
39
+ import { createFactStorePre } from './fact-store.js'
40
+ import { createProcedureStorePre } from './procedure-store.js'
41
+ import { createMemoryHubPre } from './memory-hub.js'
42
+ import { pickConsolidationTextPre } from './intent-clean.js'
43
+ import { createStorageManagerPre } from './storage-manage.js'
44
+ import { buildSourceCatalog, CorpusRegistry, canonicalize } from './m4-corpus.js'
45
+ import * as nodeZlib from 'node:zlib'
46
+ import { AsyncLocalStorage } from 'node:async_hooks'
47
+ import { createHash } from 'node:crypto'
48
+ import { buildIndex as buildMemoryIndexFile, verifyRecord as verifyMemoryRecord, coverage as memoryCoverage, INDEX_MAX_FILE_BYTES } from './memory-index.js'
49
+
50
+ /** zstd 解压(DSH 新版会话持久化 session.jsonl.zstd);Node <22 无此能力时为空,自动回退明文读。 */
51
+ const zstdDec = typeof nodeZlib.zstdDecompressSync === 'function' ? nodeZlib.zstdDecompressSync : null
28
52
 
29
53
  /** Stable cordis plugin name. */
30
54
  export const name = 'auto-memory'
31
55
 
32
56
  /** Services required before the memory surfaces can mount. */
33
- export const inject = ['webServer', 'tools', 'systemPrompt', 'subagents']
57
+ export const inject = ['webServer', 'tools', 'systemPrompt', 'subagents', 'llm']
34
58
 
35
59
  /** Prompt order of the memory section. 10000 = 末尾注入(紧跟用户消息,recency 最高,保证记忆纪律/自动沉淀说明被模型最后读到,遵循度更高)。 */
36
60
  const SECTION_ORDER = 10000
@@ -39,7 +63,7 @@ const SECTION_ORDER = 10000
39
63
  const NOTICES_URL = 'https://raw.githubusercontent.com/Aik358/dsh-auto-memory/main/notices.json'
40
64
 
41
65
  /** Model-facing announcement (tools + engine). */
42
- 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 历史会话与画像)+ 历史 DSH 会话;memory_external 查看/接入外部记忆源;memory_maintain 归档 30 天前日志;memory_reflect 保存每日反思;memory_status 查看状态;memory_consolidate 让 AI 读日志发散提炼长期要点固化进笔记。自动沉淀:每轮对话结束插件自动评估本轮内容并写今日日志/升格长期记忆(寒暄轮跳过,间隔与每日额度可在设置页「自动化」分组调整),无需你手动调 memory_log。主动性纪律:任务开始遇到不熟悉的代码/领域/历史决策时,先 memory_recall 检索本机全部 AI 工具历史,不凭空猜测;新工作区主动探索历史。限制:记忆文件为明文 Markdown;不存密钥除非用户明确要求;外部会话检索为关键词级(非语义);GUI 侧边栏「记忆」面板(含「接续」页签,可查看来源内容、从记忆 prompt 移除已导入段落)与设置页可查看/配置/接入。用户提到「记忆 / 昨天做了什么 / 之前怎么做的 / 每日反思 / 接续 / 其他 AI 的记忆」时即指本插件,请据此协作。'
66
+ 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 历史会话与画像)+ 历史 DSH 会话;memory_external 查看/接入外部记忆源;memory_maintain 归档 30 天前日志;memory_reflect 保存每日反思;memory_status 查看状态;memory_consolidate 让 AI 读日志发散提炼长期要点固化进笔记。自动沉淀:每轮对话结束插件自动评估本轮内容并写今日日志/升格长期记忆(寒暄轮跳过,间隔与每日额度可在设置页「自动化」分组调整),无需你手动调 memory_log。主动性纪律:任务开始遇到不熟悉的代码/领域/历史决策时,先 memory_recall 检索本机全部 AI 工具历史,不凭空猜测;新工作区主动探索历史。限制:记忆文件为明文 Markdown;不存密钥除非用户明确要求;外部会话检索为关键词级(非语义);GUI 侧边栏「记忆」面板(含「接续」页签,可查看来源内容、从记忆 prompt 移除已导入段落)与设置页可查看/配置/接入。用户提到「记忆 / 昨天做了什么 / 之前怎么做的 / 每日反思 / 接续 / 其他 AI 的记忆」时即指本插件,请据此协作。'
43
67
 
44
68
  /** Route family. */
45
69
  export const API = {
@@ -67,6 +91,15 @@ export const API = {
67
91
  summarize: '/api/dsh-auto-memory/summarize',
68
92
  greet: '/api/dsh-auto-memory/greet',
69
93
  notices: '/api/dsh-auto-memory/notices',
94
+ 'activation-inbox': '/api/dsh-auto-memory/activation-inbox',
95
+ 'semantic-status': '/api/dsh-auto-memory/semantic-status',
96
+ 'semantic-download': '/api/dsh-auto-memory/semantic-download',
97
+ 'semantic-emit': '/api/dsh-auto-memory/semantic-emit',
98
+ 'shadow-recent': '/api/dsh-auto-memory/shadow-recent',
99
+ 'review-feedback': '/api/dsh-auto-memory/review-feedback',
100
+ 'memory-hub': '/api/dsh-auto-memory/memory-hub',
101
+ 'storage-manage': '/api/dsh-auto-memory/storage-manage',
102
+ models: '/api/dsh-auto-memory/models',
70
103
  }
71
104
 
72
105
  const DEFAULT_CONFIG = {
@@ -78,10 +111,19 @@ const DEFAULT_CONFIG = {
78
111
  memoryRoot: '~/.dsh/memory/workspaces',
79
112
  /** 是否注入记忆上下文。 */
80
113
  injectEnabled: true,
81
- /** 注入总预算(字符)。 */
82
- injectBudgetChars: 2400,
114
+ /** 注入总预算(字符)。动态记忆快照(最近日志+反思+用户级/项目笔记摘要)每轮最多注入的字符数。
115
+ * 2026-08-26 调低:该快照每轮都会追加到历史尾部(尾部追加不击穿前缀缓存,但有稳态 token 成本,
116
+ * 活跃会话中每 ~15s 刷新一次)。默认 2400≈600-800 token/轮 已是平衡点;建议 1600-2400 之间,
117
+ * 设置页「记忆窗口→注入预算」可调。调太低会截断记忆内容,太高会拉长每轮 token 基数。 */
118
+ injectBudgetChars: 1600,
83
119
  /** 注入的最近日志天数。 */
84
120
  recentDaysInjected: 1,
121
+ /** subagent 类功能(时段总结/问候语/自动沉淀)使用的模型;留空=跟随系统路由默认。 */
122
+ subagentModel: '',
123
+ /** M3a 只读记忆索引开关(默认关闭;开启后仅构建只读索引与调试快照,不修改任何 Markdown)。 */
124
+ memoryFileIndexEnabled: false,
125
+ /** M3b 稳定 Anchor 写入开关(默认关闭=全部旧 Markdown 写法逐字节不变;开启后记忆写路径经 anchor-aware 事务,CALENDAR.md 始终除外)。 */
126
+ memoryAnchorEnabled: false,
85
127
  /** 每轮对话结束自动沉淀记忆(subagent 判断+提炼,有 API 成本;默认开)。 */
86
128
  autoConsolidate: true,
87
129
  /** 自动沉淀内容门槛:本轮 user+assistant 文本总字符数低于此值视为寒暄,跳过。 */
@@ -92,6 +134,36 @@ const DEFAULT_CONFIG = {
92
134
  autoConsolidateDailyMax: 8,
93
135
  /** 暂离阈值(分钟):距上次活动超过该值视为暂离,回归时自动弹出记忆窗口并欢迎。默认 60。 */
94
136
  awayMinutes: 60,
137
+ /** 无人值守/托管模式(默认关):面向无人值守批量任务。开启后剥离所有"会话性/行为性"注入——
138
+ * 不注入欢迎回来指令、不注入行为指令(如"以X开头")、不注入暂离/回归提示、不注入日历提醒;
139
+ * 只保留纯事实记忆(最近日志/反思/笔记摘要)。避免无人值守时模型浪费 token 在寒暄上。
140
+ * 与模型侧的"托管模式"判断联动(如晚上自动判断托管)。设置页「自动化→无人值守模式」可调。
141
+ * roadmap 2026-08-26:独立开关,不只阈值/弹窗。 */
142
+ unattendedMode: false,
143
+ /** 无人值守自动检测(默认关):开启后,当本地时间为深夜/凌晨/非工作时间(默认 22:00-08:00,
144
+ * 可配 unattendedAutoHours),或检测到自动托管任务运行时,自动进入无人值守模式(等同
145
+ * unattendedMode=true),不弹出欢迎窗、不注入寒暄,批量场景零配置免打扰。
146
+ * 手动开关 unattendedMode 优先;自动检测只在手动未开启时生效。设置页可调。 */
147
+ unattendedAuto: false,
148
+ /** 无人值守自动检测的非工作时间窗(24h 字符串数组,如 ["22:00-08:00"];跨午夜支持)。
149
+ * 空数组=不按时间自动(仅托管任务触发)。 */
150
+ unattendedAutoHours: ['22:00-08:00'],
151
+ /** 动态记忆快照注入频率控制(2026-08-27):
152
+ * snapshotMinGapRounds = 最小注入间隔轮数(同内容在 N 轮内不重复注入,默认 5;
153
+ * 0=每轮都尝试(受变化检测约束))。
154
+ * snapshotReinjectOnCompact = 上下文压缩/截断后是否强制重注入一次(默认 true)。 */
155
+ snapshotMinGapRounds: 5,
156
+ snapshotReinjectOnCompact: true,
157
+ /** 自定义 prompt 层(2026-08-27,小众功能):把记忆注入 prompt 拆成可自定义的层,
158
+ * 用户可改任意层的文案(JSON 对象,key=层名,value=覆盖文本;空字符串=使用默认)。
159
+ * 支持占位符:{date}=今天日期,{ws}=工作区,{budget}=注入预算。
160
+ * DEFAULT_PROMPT_LAYERS 定义默认文案;promptLayerOverrides 覆盖其中任意层;
161
+ * 一键恢复默认 = 清空 promptLayerOverrides。层清单见 renderMemoryDynamic/renderMemoryStatic。 */
162
+ promptLayerOverrides: {},
163
+ /** 暂离回来自动弹出记忆窗口(corner/问候栏)开关:false=关闭,只能手动打开。默认 true。 */
164
+ autoPopupEnabled: true,
165
+ /** 首启欢迎向导(分步功能介绍+语义引擎检测/下载):true=首次启动后自动播放(设置页可重看);false=不自动弹,仍可手动触发。 */
166
+ welcomeTourEnabled: true,
95
167
  /** 自动总结时间点(24h "HH:MM" 数组,如 ["12:00","18:00","22:00"]):到点自动生成本时段总结并弹窗展示。空数组=关闭。 */
96
168
  autoSummaryTimes: [],
97
169
  /** 日界(分钟,从 0 点起算):凌晨在此之前的活儿归前一天日志;默认 450=早上 7:30 后才进入新一天。 */
@@ -115,15 +187,118 @@ const DEFAULT_CONFIG = {
115
187
  'claude-sessions': true,
116
188
  'codex-sessions': true,
117
189
  },
190
+ // ---------- 主动联想记忆实验基线(M0-R 恢复;全部默认关闭,关闭时现有行为零变化) ----------
191
+ /** 主动联想记忆总开关(观察账本之外的检索/注入行为门)。 */
192
+ associativeMemoryEnabled: false,
193
+ /** Shadow Retrieval(只记录候选不注入)。 */
194
+ shadowRetrievalEnabled: false,
195
+ /** M5 Context/Evidence Bridge(实时上下文组装+Access Evidence;需 associativeMemoryEnabled 同时开启;默认关闭零 IO)。 */
196
+ contextBridgeEnabled: false,
197
+ /** M5 sink 类型:'null'=关闭态语义(零 IO)/'fake'=内存 fixtures 记账;'python' 属 M7,当前非法值回退 null。 */
198
+ contextSinkMode: 'null',
199
+ /** M6 Activation Inbox(fake activation → Reference Tail;需 associativeMemoryEnabled 同时开启;默认关闭零注入)。 */
200
+ activationInboxEnabled: false,
201
+ /** M6 激活来源:'fake'=确定性 fixtures(路由注入);'js'=JS 判定核(C2 检索+JS 决策,默认闭环);
202
+ * 'python' 仅在 assoc∧inbox∧pythonBackend 三重门下解锁(M7-1)。2026-08-27 默认改 js。 */
203
+ activationSource: 'js',
204
+ /** JS 判定冷却(分钟):JS 判定 emit 注入后,N 分钟内不再判定,防止连续唤起浪费 token。
205
+ * 默认 1(working memory 实时定位:procedural 举一反三需频繁浮现;token 靠精简注入控制)。
206
+ * 0=不冷却。与 M6 投递冷却(TTL/2 步)叠加。设置页可调。 */
207
+ jsDecideCooldownRounds: 1,
208
+ /** JS 档 margin 阈值覆盖(2026-08-28 e5 校准增量):fv2 冻结策略的 deltaExp=0.03 是在
209
+ * bge-m3 余弦分布上校准的(held-out emit margin 0.033-0.223);e5-small 聚类更紧、
210
+ * margin 系统性压缩 3-5 倍(实测 0-0.028),同语义阈值 ≈0.01。
211
+ * 仅覆盖 JS(e5) 判定档;Python(bge-m3) 档继续用冻结策略工件。null=用冻结值。
212
+ * 决策核 decideActivationV2 一字不动——覆盖在调用侧克隆 policy 实现。 */
213
+ jsDecideDeltaExp: 0.01,
214
+ /** 唤起注入的 excerpt 长度(字符):Reference Tail 的 Reference 行内容上限。
215
+ * 默认 40=几个字/关键词级(省 token,agent 需要细节时用 memory_read 取全文);
216
+ * 可调 20-480(M6 excerpt 上限)。设置页可调。 */
217
+ jsDecideExcerptChars: 40,
218
+ /** 唤起候选方案(2026-08-27 优化③):'balanced'=3条×40字符(默认,信息量/token 平衡);
219
+ * 'dense'=6条×20字符(更多候选更广联想,每条更短);'custom'=用 jsDecideCandidatesN/jsDecideExcerptChars
220
+ * 自定义。设置页档位切换 + 自定义数字。 */
221
+ jsDecideCandidateScheme: 'balanced',
222
+ /** custom 档的候选条数(1-8)。 */
223
+ jsDecideCandidatesN: 4,
224
+ /** M7 Python sidecar worker 脚本路径;留空=捆绑的 python/worker_v1.py(fake 确定性实现)。 */
225
+ pythonBackendWorkerPath: '',
226
+ /** M7 Python 可执行文件;留空='python'(PATH 解析,no-shell spawn)。 */
227
+ pythonBackendExecutable: '',
228
+ /** M7.5 语义引擎档位:'auto'=C1 词法保底+C2 就绪即用(默认)/'lexical'=仅词法/'js'=内置语义(e5-small q8)/'python'=Python sidecar 高级档(bge-m3 int8)。 */
229
+ semanticEngineMode: 'auto',
230
+ /** pre-step 软注入。 */
231
+ softInjectionEnabled: false,
232
+ /** M7.5:分支/子代理会话是否也纳入上下文观测(shadow 观测零注入风险;2026-08-26 裁定默认开:开源模型为主,思维链/分支是主要观测面)。 */
233
+ contextBridgeObserveChildSessions: true,
234
+ /** Python sidecar(embedding/graph)。 */
235
+ pythonBackendEnabled: false,
236
+ /** 推理 trace 观察器(2026-08-26 裁定默认开:监听目标是模型思维链,闭源概括式 CoT 同样纳入)。 */
237
+ reasoningObserverEnabled: true,
238
+ /** Procedure 自动晋升。 */
239
+ procedurePromotionEnabled: false,
240
+ /** ── M8 记忆中枢(Memory Hub)── 三层记忆(episodic/semantic/procedural)编排参数。
241
+ * 所有参数都在设置页「记忆中枢」分组可调;默认值对应 M-02/M-03/M-04 元代码门槛。 */
242
+ /** 记忆中枢总开关(默认关;开启后消费 judgement-shadow + 三层 store 运行)。 */
243
+ memoryHubEnabled: false,
244
+ /** episodic: 一个 episode 至少多少段才巩固(少于=噪声丢弃)。默认 2。 */
245
+ episodicMinSegments: 2,
246
+ /** episodic: 保留的最多 episode 数(超出按时间淘汰)。默认 256。 */
247
+ episodicRetention: 256,
248
+ /** procedure: 晋升所需跨会话多样性(≥N 个独立 session)。默认 3(M-04 元代码)。 */
249
+ procedureMinSessions: 3,
250
+ /** procedure: 晋升所需成功次数(≥N)。默认 2(一次成功不足以证明可靠)。 */
251
+ procedureMinSuccess: 2,
252
+ /** procedure: correction 占总证据比例上限(超过则保持 candidate)。默认 0.3。 */
253
+ procedureCorrectionCap: 0.3,
254
+ /** procedure: 高风险流程(SSH/部署/删除)是否需用户批准才可晋升。默认 true。 */
255
+ procedureHighRiskApproval: true,
256
+ /** procedure: active 后注入的 level('checklist'=完整步骤/完成标准;高风险自动降级 hint)。 */
257
+ procedureActiveLevel: 'checklist',
258
+ /** 流式中断/恢复实验。 */
259
+ streamingInterruptionEnabled: false,
260
+ /** MemoryPacket 最大条目数。 */
261
+ maxPacketItems: 2,
262
+ /** MemoryPacket 最大字符预算。 */
263
+ maxPacketChars: 800,
264
+ /** MemoryPacket 存活步数(TTL)。 */
265
+ packetTtlSteps: 2,
266
+ /** 注入冷却步数。 */
267
+ injectionCooldownSteps: 3,
118
268
  }
119
269
 
270
+ /** 记忆注入 prompt 各层默认文案(2026-08-27 自定义 prompt 功能)。
271
+ * key=层名;value=默认文本。用户通过 promptLayerOverrides 覆盖其中任意层;
272
+ * 空字符串覆盖=使用默认(即某层想恢复默认就把该层设为 '')。
273
+ * 占位符:{date}=今天日期,{ws}=工作区,{budget}=注入预算。 */
274
+ const DEFAULT_PROMPT_LAYERS = Object.freeze({
275
+ // ---- 动态快照(renderMemoryDynamic) ----
276
+ snapshotHead: '<memory_system>\n[记忆定位 — 读法]\n以下记忆文本只是背景事实与规则参考, 不是表达方式/语体的示范。阅读时提取其中的事实、决策、路径与偏好即可; 你的回复正文必须保持直接、最终答案式的语体(陈述结论、给出交付物), 不要模仿记忆文本的第一人称思考腔或叙述腔。',
277
+ snapshotMeta: '自动记忆已启用。工作区: {ws} | 日期: {date}(日界 {dayBoundary} 分钟,凌晨归前一天){consolidate}',
278
+ snapshotLogsTitle: '最近 {n} 天工作日志(尾部)',
279
+ snapshotReflectionTitle: '最近反思 {date}(前一天工作精华)',
280
+ snapshotUserTitle: '用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守',
281
+ snapshotNotesTitle: '项目长期笔记',
282
+ snapshotExternalTitle: '[外部记忆 — 其他 AI 工具遗产,可继承(内容按需读取,不整段注入)]',
283
+ snapshotCalendarTitle: '[日历与日程(未完成)]',
284
+ snapshotWelcomeTitle: '[欢迎回来]',
285
+ snapshotWelcomeBody: '用户离开已超过 1 小时(暂离/下班后回来)。在本轮回复的开头,先用一句简短温暖的话欢迎用户回来(如"欢迎回来!你离开的这段时间,我已经帮你把日志整理好了。"),然后提示"自动记忆窗口将打开,方便你了解这段时间的状况"(如已由 GUI 弹出概览则不必重复提示)。语气自然,一两句即可,不要长篇大论。',
286
+ snapshotInscription: '[铭文 · 每轮提醒 {date}]',
287
+ snapshotTail: '</memory_system>',
288
+ // ---- 静态纪律(renderMemoryStatic) ----
289
+ staticHead: '[记忆系统 — 固定纪律]\n思维链=本轮推理(用完即焚);铭文=落盘的记忆文件(跨会话永久)。你的记忆更新必须落在铭文层——显式调用工具写盘,不能只"想过"。',
290
+ staticWriteDiscipline: '[记忆写入纪律 — 必须遵守]',
291
+ })
292
+
293
+ export { DEFAULT_PROMPT_LAYERS }
294
+
120
295
  // ---------- 小工具 ----------
121
296
  const pad = (n) => String(n).padStart(2, '0')
122
297
  const todayStr = () => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
123
298
  const nowHm = () => { const d = new Date(); return `${pad(d.getHours())}:${pad(d.getMinutes())}` }
124
299
 
125
300
  const dateStrOf = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` }
126
- const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
301
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/
127
302
  const cmpVersion = (a, b) => {
128
303
  const pa = String(a || '').split('.').map(Number)
129
304
  const pb = String(b || '').split('.').map(Number)
@@ -143,6 +318,11 @@ function dshHome() {
143
318
  return path.join(homedir(), '.dsh')
144
319
  }
145
320
 
321
+ /** 本插件 lib/ 的上级目录(开发树=仓库根;发行包=包根)。 */
322
+ function pluginRootDir() {
323
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
324
+ }
325
+
146
326
  /** 诊断输出:写 ~/.dsh/dsh-auto-memory-diagnose.log(append)+console.log 双保险。验证完移除。 */
147
327
  let _diagChain = Promise.resolve()
148
328
  function diag(msg) {
@@ -153,37 +333,878 @@ function diag(msg) {
153
333
  } catch (e) {}
154
334
  }
155
335
 
336
+ // ---------- 会话隔离层(M0/M1: SessionRuntimeStore,消灭进程级全局状态) ----------
337
+ // ---------- M2 ContextObserver 常量与有界结构(只观察,不检索,不注入) ----------
338
+ /** EventEnvelope 结构版本(系统地图 N-01/C-01 契约)。 */
339
+ const OBSERVER_SCHEMA_VERSION = 1
340
+ /** 每 runtime envelope audit ring 容量(条)。超出淘汰最旧;本轮不暴露为设置项。 */
341
+ const ENVELOPE_RING_LIMIT = 128
342
+ /** 每 runtime Segment ring 容量(条)。 */
343
+ const SEGMENT_RING_LIMIT = 64
344
+ /** 每 runtime Segment ring 总字符预算(所有 segment.text 之和)。 */
345
+ const SEGMENT_RING_CHAR_BUDGET = 32768
346
+ /** 单个 Segment 文本上限(语义切片,不是"最后 N token")。 */
347
+ const SEGMENT_TEXT_MAX = 1200
348
+ /** payload 内预览字段截断上限(有界、可序列化最小投影)。 */
349
+ const OBSERVER_PREVIEW_MAX = 240
350
+ /** seed replay 单次补放事件上限(取尾部窗口):长会话恢复不做无界同步遍历。 */
351
+ const SEED_REPLAY_MAX_EVENTS = 512
352
+ // 【默认关闭语义契约(2026-08-22 审查修复轮,采用方案 B)】
353
+ // associativeMemoryEnabled=false(默认)时:观察器不建立 envelope/segment ring(ring 惰性分配,
354
+ // 关闭期间为 null)、不保存任何 payload/segment 文本,仅保留最小计数(eventSeq 序号、标量元数据、
355
+ // dropped/disabled 计数);配置 true→false 切换时立即清零已采集数据(purgeObserverStorage)。
356
+ // 因此工具参数/结果、用户文本等潜在敏感内容在默认配置下零留存;"关闭时零行为变化"覆盖
357
+ // model-visible prompt、14 个 _pre 工具、24 条路由、持久 Markdown 与观察账本存储。
358
+ // associativeMemoryEnabled=true 时才启用完整账本(ring+payload+segment)。
359
+
360
+ /**
361
+ * 有界环形缓冲:M2 envelope/segment 共用。容量按条数(+可选总字符预算)限制,淘汰最旧。
362
+ */
363
+ class ObserverRing {
364
+ constructor(limit, charBudget) {
365
+ this.limit = Math.max(Number(limit) || 1, 1)
366
+ this.charBudget = Number(charBudget) > 0 ? Number(charBudget) : Infinity
367
+ this.items = []
368
+ this.totalChars = 0
369
+ this.evicted = 0
370
+ }
371
+
372
+ /** 文本长度按字符计(segment 用);envelope 无文本传 undefined。 */
373
+ push(item, chars) {
374
+ this.items.push(item)
375
+ if (Number.isFinite(chars)) this.totalChars += chars
376
+ while (this.items.length > this.limit || this.totalChars > this.charBudget) {
377
+ const dropped = this.items.shift()
378
+ if (!dropped) break
379
+ this.evicted += 1
380
+ if (Number.isFinite(dropped._chars)) this.totalChars -= dropped._chars
381
+ }
382
+ return item
383
+ }
384
+
385
+ get length() { return this.items.length }
386
+
387
+ clear() { this.items.length = 0; this.totalChars = 0; /* evicted 保留累计值供调试 */ }
388
+
389
+ snapshot() { return this.items.slice() }
390
+ }
391
+
392
+ /** 规范化 payload 的确定性 digest:覆盖实际最小 payload 全文,而非 sourceKind/turn/callId 摘要。 */
393
+ function observerPayloadDigest(payload) {
394
+ return createHash('sha256').update(JSON.stringify(payload === undefined ? null : payload)).digest('hex')
395
+ }
396
+
397
+ /**
398
+ * 确定性 Segment id:基于稳定原生坐标(sessionId+nativeSeq+eventType)而非 runtime 内观察序号,
399
+ * 保证 live 运行与 dispose 后 resume 重放对同一持久事件生成相同 id(审查修复轮)。
400
+ * nativeCoord 由调用方给出:session 通道用 'seq:'+nativeSeq;无原生 seq 的流退化为 'ord:'+eventSeq(仅限不可重放流,文档明示)。
401
+ */
402
+ function stableSegmentId(sessionId, nativeCoord, eventType, payloadDigest) {
403
+ return 'seg-' + createHash('sha256').update(String(sessionId) + '|' + String(nativeCoord) + '|' + String(eventType) + '|' + String(payloadDigest)).digest('hex').slice(0, 20)
404
+ }
405
+
406
+ /** 从消息 content 块中提取纯文本(有界);返回 {text, chars},text 已截断,chars 为原始全长。 */
407
+ function extractBoundedText(content, maxChars) {
408
+ const cap = Math.max(Number(maxChars) || SEGMENT_TEXT_MAX, 1)
409
+ let full = ''
410
+ try {
411
+ if (typeof content === 'string') full = content
412
+ else if (Array.isArray(content)) {
413
+ const parts = []
414
+ for (const block of content) {
415
+ if (block && typeof block === 'object' && typeof block.text === 'string') parts.push(block.text)
416
+ else if (typeof block === 'string') parts.push(block)
417
+ }
418
+ full = parts.join('\n')
419
+ }
420
+ } catch (e) { full = '' }
421
+ const trimmed = full.trim()
422
+ return { text: trimmed.length > cap ? trimmed.slice(0, cap) : trimmed, chars: trimmed.length }
423
+ }
424
+
425
+ /** 字符串安全截断(undefined→''),用于 payload 预览字段。 */
426
+ function boundedStr(v, max) {
427
+ const s = v === undefined || v === null ? '' : String(v)
428
+ const cap = Math.max(Number(max) || OBSERVER_PREVIEW_MAX, 1)
429
+ return s.length > cap ? s.slice(0, cap) : s
430
+ }
431
+
432
+ /**
433
+ * @typedef {Object} EventEnvelope
434
+ * @property {1} schemaVersion
435
+ * @property {string} sessionId
436
+ * @property {string} agentId
437
+ * @property {number} eventSeq
438
+ * @property {'session'|'tools'|'agent'} channel
439
+ * @property {string} eventType
440
+ * @property {number} timestamp
441
+ * @property {number|undefined} nativeSeq
442
+ * @property {number|undefined} turn
443
+ * @property {number|undefined} step
444
+ * @property {'user'|'tool'|'agent'|'lifecycle'} sourceKind
445
+ * @property {string|undefined} messageId
446
+ * @property {string|undefined} callId
447
+ * @property {string|undefined} rootCallId
448
+ * @property {string} payloadDigest
449
+ * @property {Object} payload 有界规范化最小投影(绝不持有 Agent/AbortSignal/DSH 内部对象)
450
+ */
451
+
452
+ /**
453
+ * @typedef {Object} ContextSegment
454
+ * @property {string} id 确定性 segment id(sessionId+eventSeq+payloadDigest 派生)
455
+ * @property {string} sessionId
456
+ * @property {'user'|'tool_call'|'tool_result'|'assistant'} kind
457
+ * @property {string} eventType
458
+ * @property {number} eventSeq
459
+ * @property {number} contextVersion
460
+ * @property {string} text
461
+ * @property {string} digest
462
+ * @property {number} ts
463
+ */
464
+
465
+ /**
466
+ * @typedef {Object} MemoryPacket
467
+ * @property {string} packetId
468
+ * @property {number} contextVersion
469
+ * @property {Array<ContextSegment>} items
470
+ * @property {number} expiresAtStep
471
+ */
472
+
473
+ /**
474
+ * @typedef {Object} SessionRuntime
475
+ * @property {string} key
476
+ * @property {string} sessionId
477
+ * @property {string} agentId
478
+ * @property {Object} state
479
+ * @property {number} contextVersion 仅在 observer 接受有效 Segment 时递增
480
+ * @property {number} eventCursor 每个 accepted envelope 单调递增
481
+ * @property {ObserverRing} envelopes M2 envelope audit ring(可回放最小观察账本)
482
+ * @property {ObserverRing} segments M2 语义 Segment ring(有界)
483
+ * @property {number} nativeCursor 已消费的最大原生 session seq(seed/live 去重游标)
484
+ * @property {Map<string,Object>} callLinks callId→{rootCallId,name,frozen,persisted}(root/nested 关联,有界)
485
+ * @property {{noOwner:number,duplicate:number,ignored:number}} observerDropped 每 runtime 观察丢弃计数
486
+ * @property {MemoryPacket|undefined} pendingPacket
487
+ * @property {number|undefined} cooldownUntilStep
488
+ * @property {number|undefined} lastInjectionAt
489
+ */
490
+
491
+ function createRuntimeState() {
492
+ return {
493
+ home: undefined, ws: undefined,
494
+ userDir: undefined, notesPath: undefined, logPath: undefined, reflectDir: undefined, projectDir: undefined,
495
+ userText: '', notesText: '', logText: '',
496
+ recentLogs: [], // {date, text}
497
+ latestReflection: '', latestReflectionDate: '',
498
+ pendingReflection: undefined, // {date, text}
499
+ reflectionShownSession: undefined,
500
+ todayGreeting: '', greetingShownSession: undefined,
501
+ calendarText: '', calendarPath: undefined,
502
+ away: false, pendingSummary: undefined, // 时间检测:暂离标记 / 待展示的自动时段总结
503
+ workspaceMap: [],
504
+ workspaceCache: undefined,
505
+ loadedAt: 0, loading: undefined, configLoaded: false,
506
+ }
507
+ }
508
+
509
+ function createSessionRuntime(key) {
510
+ return {
511
+ key,
512
+ sessionId: '',
513
+ agentId: '',
514
+ agent: undefined,
515
+ state: createRuntimeState(),
516
+ contextVersion: 0,
517
+ eventCursor: 0,
518
+ pendingPacket: undefined,
519
+ cooldownUntilStep: undefined,
520
+ lastInjectionAt: undefined,
521
+ lastTurn: undefined,
522
+ consolidating: undefined,
523
+ pendingConsolidations: [],
524
+ autoStats: { count: 0, lastAt: 0, lastText: '', lastDate: '' },
525
+ lastActiveAt: 0,
526
+ lastCompactAt: 0,
527
+ lastConsolidateAt: 0, // 自动沉淀冷却起点(per-session 隔离)
528
+ debug: { observedEvents: 0, lastEventKind: '', lastEventAt: 0, lastEventSeq: 0, lastEnvelope: undefined },
529
+ // M2: 观察账本与语义上下文环(per-runtime 隔离;dispose 时清空)。
530
+ // 审查修复轮2:惰性分配——默认关闭(方案 B)时不构造任何 ring 对象,
531
+ // 仅在 associativeMemoryEnabled=true 且首条观察写入时才创建。
532
+ envelopes: null,
533
+ segments: null,
534
+ // M7.5 CoT 监听(reasoning-delta 聚合缓冲):惰性创建;
535
+ // { text, chars, lastSeq, lastFlushAt } —— 有界 4096,防高频流刷爆 ring。
536
+ reasoningBuf: null,
537
+ nativeCursor: 0,
538
+ callLinks: null,
539
+ observerDropped: { noOwner: 0, duplicate: 0, ignored: 0 },
540
+ abortController: new AbortController(),
541
+ disposed: false,
542
+ }
543
+ }
544
+
545
+ function identityOfAgent(agent) {
546
+ try {
547
+ const session = agent && agent.session
548
+ const sessionId = session && (session.id || (session.header && session.header.id))
549
+ if (sessionId) return 'session:' + String(sessionId)
550
+ if (agent && agent.id) return 'agent:' + String(agent.id)
551
+ } catch (e) {}
552
+ return ''
553
+ }
554
+
555
+ class SessionRuntimeStore {
556
+ constructor() {
557
+ this._byAgent = new WeakMap()
558
+ this._byIdentity = new Map()
559
+ this._sequence = 0
560
+ this._default = createSessionRuntime('default')
561
+ this._all = new Set([this._default])
562
+ }
563
+
564
+ get(agent) {
565
+ if (!agent || typeof agent !== 'object') return this._default
566
+ let runtime = this._byAgent.get(agent)
567
+ const identity = identityOfAgent(agent)
568
+ if (!runtime && identity) runtime = this._byIdentity.get(identity)
569
+ if (!runtime) {
570
+ runtime = createSessionRuntime(identity || 'agent-object:' + (++this._sequence))
571
+ this._all.add(runtime)
572
+ if (identity) this._byIdentity.set(identity, runtime)
573
+ }
574
+ runtime.agent = agent
575
+ try {
576
+ runtime.agentId = agent.id ? String(agent.id) : runtime.agentId
577
+ const session = agent.session
578
+ runtime.sessionId = session && (session.id || (session.header && session.header.id))
579
+ ? String(session.id || session.header.id)
580
+ : runtime.sessionId
581
+ } catch (e) {}
582
+ this._byAgent.set(agent, runtime)
583
+ return runtime
584
+ }
585
+
586
+ dispose(agent) {
587
+ if (!agent || typeof agent !== 'object') return false
588
+ const runtime = this._byAgent.get(agent)
589
+ if (!runtime || runtime === this._default) return false
590
+ runtime.disposed = true
591
+ try { runtime.abortController.abort('agent disposed') } catch (e) {}
592
+ if (runtime.key && this._byIdentity.get(runtime.key) === runtime) this._byIdentity.delete(runtime.key)
593
+ this._byAgent.delete(agent)
594
+ this._all.delete(runtime)
595
+ runtime.agent = undefined
596
+ runtime.pendingConsolidations.length = 0
597
+ runtime.pendingPacket = undefined
598
+ // M4-3:Shadow per-runtime 状态与 inFlight abort
599
+ try { if (this._shadowHost) this._shadowHost.disposeRuntime(runtime) } catch (e) {}
600
+ // M2: 清空观察账本与语义环,断开 call 关联(abort 已由 abortController 完成);ring 为惰性分配,可能为 null
601
+ if (runtime.envelopes) runtime.envelopes.clear()
602
+ if (runtime.segments) runtime.segments.clear()
603
+ if (runtime.callLinks) runtime.callLinks.clear()
604
+ runtime.envelopes = null
605
+ runtime.segments = null
606
+ runtime.callLinks = null
607
+ return true
608
+ }
609
+
610
+ disposeAll() {
611
+ for (const runtime of this._all) {
612
+ runtime.disposed = true
613
+ try { runtime.abortController.abort('plugin disposed') } catch (e) {}
614
+ runtime.pendingConsolidations.length = 0
615
+ runtime.pendingPacket = undefined
616
+ runtime.agent = undefined
617
+ // M2: 插件级 dispose 同步清空观察账本与语义环(惰性分配可能为 null)
618
+ if (runtime.envelopes) runtime.envelopes.clear()
619
+ if (runtime.segments) runtime.segments.clear()
620
+ if (runtime.callLinks) runtime.callLinks.clear()
621
+ runtime.envelopes = null
622
+ runtime.segments = null
623
+ runtime.callLinks = null
624
+ }
625
+ this._byIdentity.clear()
626
+ this._byAgent = new WeakMap()
627
+ this._all.clear()
628
+ this._default.disposed = true
629
+ try { this._default.abortController.abort('plugin disposed') } catch (e) {}
630
+ }
631
+
632
+ findBySessionId(sessionId) {
633
+ if (!sessionId) return undefined
634
+ return this._byIdentity.get('session:' + String(sessionId))
635
+ }
636
+
637
+ disposeSession(session) {
638
+ try {
639
+ const sessionId = session && (session.id || (session.header && session.header.id))
640
+ const runtime = this.findBySessionId(sessionId)
641
+ if (!runtime || !runtime.agent) return false
642
+ return this.dispose(runtime.agent)
643
+ } catch (e) { return false }
644
+ }
645
+
646
+ values() {
647
+ return Array.from(this._all)
648
+ }
649
+ }
650
+
651
+ // M0/M1 的粗粒度 recordRuntimeEvent 已由 M2 ContextObserver 取代:
652
+ // 观察入口收敛到 MemoryEngine.observeSessionEvent / observeToolResult / ingestAgentLifecycle / seedRuntimeFromSession。
653
+ // 语义严格分工:eventSeq(=eventCursor)对每个 accepted envelope 递增;
654
+ // contextVersion 仅在产生有效 Segment、实际改变检索上下文时递增,二者绝不机械同步。
655
+
156
656
  /** 记忆引擎:路径解析、缓存、文件读写、检索、反思状态。 */
157
657
  class MemoryEngine {
158
658
  constructor() {
159
659
  this.config = { ...DEFAULT_CONFIG }
160
- this.state = {
161
- home: undefined, ws: undefined,
162
- userDir: undefined, notesPath: undefined, logPath: undefined, reflectDir: undefined,
163
- userText: '', notesText: '', logText: '',
164
- recentLogs: [], // {date, text}
165
- latestReflection: '', latestReflectionDate: '',
166
- pendingReflection: undefined, // {date, text}
167
- reflectionShownSession: undefined,
168
- todayGreeting: '', greetingShownSession: undefined,
169
- calendarText: '', calendarPath: undefined,
170
- away: false, pendingSummary: undefined, // 时间检测:暂离标记 / 待展示的自动时段总结
171
- loadedAt: 0, loading: undefined, configLoaded: false,
172
- }
660
+ this.runtimes = new SessionRuntimeStore() // M0/M1: per-agent/session 运行态(消灭全局状态)
661
+ this._runtimeContext = new AsyncLocalStorage() // 执行 token: 关联嵌套观测
662
+ this._routeAgents = new Map() // sessionId -> agent(路由请求定位)
173
663
  this._configPath = path.join(dshHome(), 'dsh-auto-memory.json')
174
664
  this._readError = undefined
175
- this._lastAgent = undefined // 最近一次 agent 引用(subagent parent 需要完整 agent 对象)
176
- this._lastTurnByAgent = undefined // Map<agentId, turn>:自动沉淀去重(每轮只写一次)
177
- this._consolidating = undefined // 自动沉淀进行中标记(防重入)
665
+ this._lastAgent = undefined // 最近一次 agent 引用(subagent parent 需要完整 agent 对象);M1 主状态已按 session 隔离,runtime 优先
666
+ this._lastTurnByAgent = undefined // Map<agentId, turn>:自动沉淀去重(每轮只写一次);runtime.lastTurn 优先
667
+ this._consolidating = undefined // 自动沉淀进行中标记(防重入);runtime.consolidating 优先
178
668
  this._autoCallDate = ''
179
669
  this._autoCallCount = 0
180
670
  this._lastConsolidateStartedAt = 0
181
671
  this._smartRecallFlight = undefined
182
672
  this._budgets = undefined // 每日写入预算:用户级4000/项目级3000字/天(所有会话共享,跨天重置)
183
- this.autoStats = { count: 0, lastAt: 0, lastText: '', lastDate: '' } // 自动沉淀统计(GUI 即时反馈)
673
+ // M2: 进程级观察统计(无 owner 而无法归属 runtime 的事件只在这里留痕,绝不落入 default runtime)
674
+ this._observerStats = { ingestedEnvelopes: 0, segmentsCreated: 0, droppedNoOwner: 0, disabledObservations: 0, seedTruncatedEvents: 0 }
184
675
  this.external = new ExternalMemory(this)
185
676
  }
186
677
 
678
+ get state() {
679
+ return this.currentRuntime().state
680
+ }
681
+
682
+ currentRuntime() {
683
+ return this._runtimeContext.getStore() || this.runtimes.get(undefined)
684
+ }
685
+
686
+ runtimeFor(agent) {
687
+ return this.runtimes.get(agent)
688
+ }
689
+
690
+ stateFor(agent) {
691
+ return this.runtimeFor(agent || this.currentRuntime().agent).state
692
+ }
693
+
694
+ get autoStats() {
695
+ return this.currentRuntime().autoStats
696
+ }
697
+
698
+ aggregateAutoStats() {
699
+ const stats = { count: 0, lastAt: 0, lastText: '', lastDate: '' }
700
+ for (const runtime of this.runtimes.values()) {
701
+ const current = runtime.autoStats
702
+ stats.count += Number(current.count) || 0
703
+ if ((Number(current.lastAt) || 0) > stats.lastAt) {
704
+ stats.lastAt = current.lastAt
705
+ stats.lastText = current.lastText || ''
706
+ stats.lastDate = current.lastDate || ''
707
+ }
708
+ }
709
+ return stats
710
+ }
711
+
712
+ disposeAgent(agent) {
713
+ return this.runtimes.dispose(agent)
714
+ }
715
+
716
+ withAgent(agent, callback) {
717
+ return this._runtimeContext.run(this.runtimeFor(agent), callback)
718
+ }
719
+
720
+ // ---------- M2 ContextObserver(C-01):结构化事件 → EventEnvelope → 语义 Segment ----------
721
+ // 只观察:不检索、不注入、不改写 Markdown、不写主 Session。检索中间态绝不进入事实源。
722
+
723
+ /**
724
+ * 可靠 session 身份判定(审查修复轮2):生命周期入口必须先通过本检查,
725
+ * 才允许把 agent 送进 runtimeFor/stateFor/refresh 等会创建 runtime 的路径。
726
+ */
727
+ hasReliableSessionIdentity(agent) {
728
+ try {
729
+ const s = agent && agent.session
730
+ return !!(s && (s.id || (s.header && s.header.id)))
731
+ } catch (e) { return false }
732
+ }
733
+
734
+ /**
735
+ * M3a 只读记忆索引快照:memoryFileIndexEnabled=false(默认)时零 IO、零行为变化;
736
+ * 开启时对用户级/项目笔记/今日日志构建只读索引(不修改 Markdown)。
737
+ * sourceVersion 进程内递增:按绝对路径缓存 {fileDigest, sourceVersion},
738
+ * digest 不变复用版本、变化则 +1(跨重启持久化版本留待 M3b sidecar)。
739
+ * 归属:优先当前 runtime 的 agent(AsyncLocalStorage),路由等无 ALS 上下文时回退 _lastAgent。
740
+ * 读取前 stat 预检 >5MB 跳过(不读取内容);模块层 buildIndex 亦自带超限保护(双保险)。
741
+ */
742
+ async memoryIndexSnapshot() {
743
+ const out = { enabled: this.config.memoryFileIndexEnabled === true }
744
+ if (!out.enabled) return out
745
+ if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
746
+ if (!this._memoryIndexState) this._memoryIndexState = new Map()
747
+ try {
748
+ const owner = this.currentRuntime().agent || this._lastAgent
749
+ const p = await this.resolvePaths(owner)
750
+ const files = [p.userFile, p.notesPath, p.logPath].filter(Boolean)
751
+ out.files = []
752
+ for (const f of files) {
753
+ try {
754
+ // 先 stat:超限文件不读取内容(零大文件 IO)
755
+ const st = await stat(f)
756
+ if (st.size > INDEX_MAX_FILE_BYTES) { out.files.push({ sourceFile: f, skipped: true, bytes: st.size, ownerWs: p.ws }); continue }
757
+ const b = await readFile(f)
758
+ if (b.length > INDEX_MAX_FILE_BYTES) { out.files.push({ sourceFile: f, skipped: true, bytes: b.length, ownerWs: p.ws }); continue }
759
+ const prev = this._memoryIndexState.get(f)
760
+ const idx = buildMemoryIndexFile(f, b, prev)
761
+ this._memoryIndexState.set(f, { fileDigest: idx.fileDigest, sourceVersion: idx.sourceVersion })
762
+ out.files.push({
763
+ sourceFile: f,
764
+ sourceVersion: idx.sourceVersion,
765
+ fileDigest: idx.fileDigest,
766
+ records: idx.records.length,
767
+ charTotal: idx.records.reduce((sum, r) => sum + r.chars, 0),
768
+ ownerWs: p.ws,
769
+ })
770
+ } catch (e) { /* ENOENT 等跳过 */ }
771
+ }
772
+ } catch (e) { out.error = String(e && e.message ? e.message : e) }
773
+ return out
774
+ }
775
+
776
+ /**
777
+ * 观察存储清零(审查修复轮2):associativeMemoryEnabled true→false 切换时调用,
778
+ * 清空全部 runtime 的 envelope/segment ring、callLinks 与 lastEnvelope 引用,contextVersion 归零
779
+ * ——方案 B 的"关闭时零留存"覆盖配置切换前已采集的数据。
780
+ * false→true:不从当前 Session 追溯回放(观察自切换点重新开始);因 Segment id 由原生坐标派生,
781
+ * 未来若实现显式恢复回放,补写的 Segment id 与 live 一致、不会冲突。
782
+ */
783
+ purgeObserverStorage(reason) {
784
+ let purged = 0
785
+ for (const runtime of this.runtimes.values()) {
786
+ if (runtime.envelopes && runtime.envelopes.length) purged += runtime.envelopes.length
787
+ if (runtime.envelopes) runtime.envelopes.clear()
788
+ if (runtime.segments) runtime.segments.clear()
789
+ if (runtime.callLinks) runtime.callLinks.clear()
790
+ runtime.envelopes = null
791
+ runtime.segments = null
792
+ runtime.callLinks = null
793
+ runtime.reasoningBuf = null // M7.5:CoT 聚合缓冲随观察存储一并清零(审查修复轮2 方案 B 语义)
794
+ runtime.debug.lastEnvelope = undefined
795
+ runtime.contextVersion = 0
796
+ }
797
+ this._observerStats.lastPurgeReason = String(reason || '')
798
+ try { diag('observer storage purged (' + String(reason || '') + '): envelopes=' + purged) } catch (e) {}
799
+ return purged
800
+ }
801
+
802
+ /**
803
+ * 严格身份解析(审查修复轮):观察路径必须拥有可靠 session.id/session.header.id 才允许归属 runtime;
804
+ * 无身份对象直接计入 droppedNoOwner,绝不调用会伪造 agent-object:* 匿名 runtime 的 get()。
805
+ * 身份存在时才允许 identity-backed 创建(key 必为 session:<id>);仅 agent.id 不足以成立。
806
+ */
807
+ resolveObserverRuntime(agent, sessionId) {
808
+ let sid = ''
809
+ if (sessionId) sid = String(sessionId)
810
+ if (!sid && agent && typeof agent === 'object') {
811
+ try {
812
+ const s = agent.session
813
+ const candidate = s && (s.id || (s.header && s.header.id))
814
+ if (candidate) sid = String(candidate)
815
+ } catch (e) {}
816
+ }
817
+ if (!sid) { this._observerStats.droppedNoOwner += 1; return undefined }
818
+ try {
819
+ if (agent && typeof agent === 'object') {
820
+ // sessionId 已验证:此调用只会产生/命中 session:<id> 键,不会伪造匿名 runtime
821
+ const rt = this.runtimes.get(agent)
822
+ if (rt && !rt.disposed && rt.key === 'session:' + sid) return rt
823
+ }
824
+ const bySession = this.runtimes.findBySessionId(sid)
825
+ if (bySession && !bySession.disposed) return bySession
826
+ } catch (e) {}
827
+ this._observerStats.droppedNoOwner += 1
828
+ return undefined
829
+ }
830
+
831
+ /**
832
+ * 核心 ingest:校验并接受一条观察事件。
833
+ * 关闭模式(associativeMemoryEnabled=false,默认):只保留最小计数——递增序号与标量元数据,
834
+ * 不建立 ring、不保存任何 payload/segment(默认关闭语义契约,见文件头常量区注释)。
835
+ * 启用模式:分配 eventSeq → 写 envelope audit ring;有效 Segment 推进 contextVersion 并写 Segment ring。
836
+ * spec.time 为原生事实时间(session/event 的 event.time);tools/lifecycle 无原生时间时才用采集时间。
837
+ * @returns {{envelope:Object,segment:(Object|undefined)}|null}
838
+ */
839
+ ingestEnvelope(runtime, spec) {
840
+ if (!runtime || runtime.disposed || !spec || typeof spec !== 'object') return null
841
+ const channel = spec.channel === 'session' || spec.channel === 'tools' || spec.channel === 'agent' ? spec.channel : 'agent'
842
+ const eventType = String(spec.eventType || '')
843
+ // ---- 默认关闭:最小计数路径,零留存 ----
844
+ if (this.config.associativeMemoryEnabled !== true) {
845
+ const seq = runtime.eventCursor + 1
846
+ runtime.eventCursor = seq
847
+ runtime.debug.observedEvents += 1
848
+ runtime.debug.lastEventKind = channel + '/' + eventType
849
+ runtime.debug.lastEventAt = Number.isFinite(spec.time) ? Math.trunc(spec.time) : Date.now()
850
+ runtime.debug.lastEventSeq = seq
851
+ this._observerStats.disabledObservations += 1
852
+ return null
853
+ }
854
+ // ---- 启用:完整账本(惰性建环:首次写入才分配,关闭期间零对象) ----
855
+ if (!runtime.envelopes) runtime.envelopes = new ObserverRing(ENVELOPE_RING_LIMIT)
856
+ const sourceKind = spec.sourceKind === 'user' || spec.sourceKind === 'tool' || spec.sourceKind === 'lifecycle'
857
+ ? spec.sourceKind
858
+ : (spec.sourceKind === 'agent' ? 'agent' : 'lifecycle')
859
+ const payload = (spec.payload && typeof spec.payload === 'object' && !Array.isArray(spec.payload)) ? spec.payload : {}
860
+ const observedAt = Date.now()
861
+ const factTime = Number.isFinite(spec.time) ? Math.trunc(spec.time) : observedAt
862
+ const nativeSeq = Number.isFinite(spec.nativeSeq) ? Math.trunc(spec.nativeSeq) : undefined
863
+ const eventSeq = runtime.eventCursor + 1
864
+ const envelope = {
865
+ schemaVersion: OBSERVER_SCHEMA_VERSION,
866
+ sessionId: runtime.sessionId || '',
867
+ agentId: runtime.agentId || '',
868
+ eventSeq,
869
+ channel,
870
+ eventType,
871
+ timestamp: factTime,
872
+ observedAt,
873
+ nativeSeq,
874
+ turn: Number.isFinite(spec.turn) ? Math.trunc(spec.turn) : undefined,
875
+ step: Number.isFinite(spec.step) ? Math.trunc(spec.step) : undefined,
876
+ sourceKind,
877
+ messageId: spec.messageId ? String(spec.messageId) : undefined,
878
+ callId: spec.callId ? String(spec.callId) : undefined,
879
+ rootCallId: spec.rootCallId ? String(spec.rootCallId) : undefined,
880
+ payloadDigest: observerPayloadDigest(payload),
881
+ payload,
882
+ }
883
+ runtime.eventCursor = eventSeq
884
+ runtime.envelopes.push(envelope)
885
+ runtime.debug.observedEvents += 1
886
+ runtime.debug.lastEventKind = channel + '/' + eventType
887
+ runtime.debug.lastEventAt = factTime
888
+ runtime.debug.lastEventSeq = eventSeq
889
+ runtime.debug.lastEnvelope = envelope
890
+ this._observerStats.ingestedEnvelopes += 1
891
+
892
+ let segment
893
+ if (spec.segment && typeof spec.segment.text === 'string' && spec.segment.text.length > 0) {
894
+ const segText = spec.segment.text.length > SEGMENT_TEXT_MAX ? spec.segment.text.slice(0, SEGMENT_TEXT_MAX) : spec.segment.text
895
+ // contextVersion 仅在有效 Segment 实际改变检索上下文时递增
896
+ // Segment 身份使用稳定原生坐标(nativeSeq),live 与 resume 重放对同一持久事件同 id
897
+ const nativeCoord = nativeSeq !== undefined ? 'seq:' + nativeSeq : 'ord:' + eventSeq
898
+ const seg = {
899
+ id: stableSegmentId(envelope.sessionId || runtime.key, nativeCoord, eventType, envelope.payloadDigest),
900
+ sessionId: envelope.sessionId,
901
+ kind: String(spec.segment.kind),
902
+ eventType,
903
+ eventSeq,
904
+ nativeSeq,
905
+ contextVersion: runtime.contextVersion + 1,
906
+ text: segText,
907
+ digest: observerPayloadDigest({ kind: String(spec.segment.kind), text: segText }),
908
+ ts: factTime,
909
+ }
910
+ runtime.contextVersion = seg.contextVersion
911
+ seg._chars = seg.text.length
912
+ if (!runtime.segments) runtime.segments = new ObserverRing(SEGMENT_RING_LIMIT, SEGMENT_RING_CHAR_BUDGET)
913
+ runtime.segments.push(seg, seg._chars)
914
+ this._observerStats.segmentsCreated += 1
915
+ segment = seg
916
+ }
917
+ // M4-3:accepted Segment → Shadow 异步调度(fire-and-forget;三开关全开时才构造状态/IO)
918
+ if (segment && this._shadowHost) {
919
+ try { this._shadowHost.onSegmentAccepted(runtime, segment, envelope) } catch (_) {}
920
+ }
921
+ // M5-3:accepted Segment → Context Bridge envelope 组装/cite/correction 扫描(assoc+contextBridge 双门)
922
+ if (segment && this._contextHost) {
923
+ try { this._contextHost.onSegmentAccepted(runtime, segment, envelope) } catch (_) {}
924
+ }
925
+ return { envelope, segment }
926
+ }
927
+
928
+ /** callId→rootCallId 关联账本(root/nested tool call;tools/result 与持久 tool/result 双通道合并,有界)。关闭模式零留存。 */
929
+ linkObserverCall(runtime, callId, rootCallId, name, via) {
930
+ if (!runtime || !callId) return
931
+ if (this.config.associativeMemoryEnabled !== true) return // 方案 B:关闭时不保留任何关联数据
932
+ if (!runtime.callLinks) runtime.callLinks = new Map()
933
+ const key = String(callId)
934
+ let link = runtime.callLinks.get(key)
935
+ if (!link) {
936
+ link = { rootCallId: undefined, name: '', frozen: false, persisted: false }
937
+ runtime.callLinks.set(key, link)
938
+ }
939
+ if (rootCallId !== undefined && rootCallId !== null && rootCallId !== '') link.rootCallId = String(rootCallId)
940
+ if (name) link.name = boundedStr(name)
941
+ if (via === 'frozen') link.frozen = true
942
+ if (via === 'persisted') link.persisted = true
943
+ while (runtime.callLinks.size > 64) {
944
+ const oldest = runtime.callLinks.keys().next().value
945
+ runtime.callLinks.delete(oldest)
946
+ }
947
+ }
948
+
949
+ /** M2.1 session/event 入口(post-commit append feed)。无 owner 或无法定位 runtime 的事件被丢弃留痕。 */
950
+ observeSessionEvent(session, event) {
951
+ try {
952
+ if (!event || typeof event !== 'object' || !event.type) { this._observerStats.droppedNoOwner += 1; return null }
953
+ let sessionId = ''
954
+ try {
955
+ sessionId = session && (session.id || (session.header && session.header.id))
956
+ ? String(session.id || session.header.id)
957
+ : ''
958
+ } catch (e) {}
959
+ if (!sessionId) { this._observerStats.droppedNoOwner += 1; return null }
960
+ const runtime = this.runtimes.findBySessionId(sessionId)
961
+ if (!runtime) { this._observerStats.droppedNoOwner += 1; return null }
962
+ return this.ingestSessionEventRecord(runtime, event)
963
+ } catch (e) { return null }
964
+ }
965
+
966
+ /** M2.4 seed/live 共用的持久事件消费路径:nativeSeq 游标去重,禁止重复计数。 */
967
+ ingestSessionEventRecord(runtime, event) {
968
+ try {
969
+ if (!runtime || runtime.disposed || !event || typeof event !== 'object') return null
970
+ const type = typeof event.type === 'string' ? event.type : ''
971
+ if (!type) { runtime.observerDropped.ignored += 1; return null }
972
+ const nativeSeq = Number.isFinite(event.seq) ? Math.trunc(event.seq) : undefined
973
+ if (nativeSeq !== undefined) {
974
+ if (nativeSeq <= runtime.nativeCursor) { runtime.observerDropped.duplicate += 1; return null }
975
+ runtime.nativeCursor = nativeSeq
976
+ }
977
+ const data = (event.data && typeof event.data === 'object') ? event.data : {}
978
+ const turn = Number.isFinite(data.turn) ? Math.trunc(data.turn) : undefined
979
+ const step = Number.isFinite(data.step) ? Math.trunc(data.step) : undefined
980
+ // 原生事实时间(审查修复轮):envelope.timestamp 优先取 session/event 的 event.time,不用采集时间覆盖
981
+ const nativeTime = Number.isFinite(event.time) ? Math.trunc(event.time) : undefined
982
+ const baseSpec = { channel: 'session', eventType: type, nativeSeq, turn, step, time: nativeTime }
983
+
984
+ if (type === 'user/message') {
985
+ const text = extractBoundedText(data.content !== undefined ? data.content : (data.message && data.message.content))
986
+ const messageId = (data.id || (data.message && data.message.id)) ? String(data.id || data.message.id) : undefined
987
+ // 来源溯源(审查修复轮):user/message 统一承载用户输入/插件注入/续接等多种来源,
988
+ // 记录 source.kind 与 plugin 名,后续 M4/M5 才能区分用户事实与插件生成文本。
989
+ const srcObj = (data.source && typeof data.source === 'object') ? data.source : null
990
+ return this.ingestEnvelope(runtime, {
991
+ ...baseSpec,
992
+ sourceKind: 'user',
993
+ messageId,
994
+ payload: {
995
+ role: 'user',
996
+ inputSource: boundedStr(srcObj ? (srcObj.kind !== undefined ? srcObj.kind : '') : data.source) || null,
997
+ sourcePlugin: srcObj ? (boundedStr(srcObj.plugin) || null) : null,
998
+ text: text.text,
999
+ textChars: text.chars,
1000
+ },
1001
+ segment: text.text ? { kind: 'user', text: text.text } : undefined,
1002
+ })
1003
+ }
1004
+ if (type === 'assistant/message') {
1005
+ const msg = (data.message && typeof data.message === 'object') ? data.message : data
1006
+ const text = extractBoundedText(msg.content)
1007
+ return this.ingestEnvelope(runtime, {
1008
+ ...baseSpec,
1009
+ sourceKind: 'agent',
1010
+ payload: { turn, step, interrupted: data.interrupted === true, text: text.text, textChars: text.chars },
1011
+ segment: text.text ? { kind: 'assistant', text: text.text } : undefined,
1012
+ })
1013
+ }
1014
+ if (type === 'tool/call') {
1015
+ const argsRaw = typeof data.arguments === 'string' ? data.arguments : JSON.stringify(data.arguments ?? null)
1016
+ const callId = data.callId ? String(data.callId) : undefined
1017
+ this.linkObserverCall(runtime, callId, undefined, data.name)
1018
+ return this.ingestEnvelope(runtime, {
1019
+ ...baseSpec,
1020
+ sourceKind: 'tool',
1021
+ callId,
1022
+ payload: {
1023
+ turn, step,
1024
+ name: boundedStr(data.name),
1025
+ argsPreview: boundedStr(argsRaw),
1026
+ argsChars: String(argsRaw == null ? '' : argsRaw).length,
1027
+ },
1028
+ segment: { kind: 'tool_call', text: boundedStr(data.name) + '(' + boundedStr(argsRaw) + ')' },
1029
+ })
1030
+ }
1031
+ if (type === 'tool/result') {
1032
+ const blocks = data.message && Array.isArray(data.message.content) ? data.message.content : []
1033
+ const block = blocks.length && blocks[0] && typeof blocks[0] === 'object' ? blocks[0] : {}
1034
+ const callId = block.toolCallId ? String(block.toolCallId) : (data.callId ? String(data.callId) : undefined)
1035
+ const errInfo = (data.error && typeof data.error === 'object') ? data.error : null
1036
+ const isError = !!errInfo || block.isError === true
1037
+ const result = extractBoundedText(block.content)
1038
+ this.linkObserverCall(runtime, callId, undefined, undefined, 'persisted')
1039
+ return this.ingestEnvelope(runtime, {
1040
+ ...baseSpec,
1041
+ sourceKind: 'tool',
1042
+ callId,
1043
+ payload: {
1044
+ turn, step,
1045
+ isError,
1046
+ errorName: errInfo ? boundedStr(errInfo.name) : null,
1047
+ errorCode: errInfo ? boundedStr(errInfo.code) : null,
1048
+ resultPreview: result.text,
1049
+ resultChars: result.chars,
1050
+ },
1051
+ segment: { kind: 'tool_result', text: (isError ? '[error] ' : '') + result.text },
1052
+ })
1053
+ }
1054
+ if (type === 'turn/start' || type === 'turn/end' || type === 'step/start' || type === 'step/end') {
1055
+ // 生命周期边界:进入 envelope audit,但无语义内容时不产生 Segment、不推进 contextVersion
1056
+ const payload = { turn: data.turn, step: data.step }
1057
+ if (type === 'turn/end' && data.reason !== undefined) payload.reason = boundedStr(data.reason)
1058
+ return this.ingestEnvelope(runtime, { ...baseSpec, sourceKind: 'lifecycle', payload })
1059
+ }
1060
+ if (type === 'assistant/chunk') {
1061
+ // M7.5 CoT 监听(RFC docs/COT-WATCH-RFC.md):chunk.type==='reasoning-delta'
1062
+ // 是模型思维链的唯一流式载体;text-delta 由 assistant/message 完整承载,不重复观察。
1063
+ // 断言式单播:仅本 runtime 的 ring;幂等由 session.append 的 nativeSeq 天然保证。
1064
+ const chunk = (data.chunk && typeof data.chunk === 'object') ? data.chunk : {}
1065
+ if (!this.config.reasoningObserverEnabled || chunk.type !== 'reasoning-delta') {
1066
+ runtime.observerDropped.ignored += 1
1067
+ return this.ingestEnvelope(runtime, { ...baseSpec, sourceKind: 'agent', payload: { chunkType: boundedStr(chunk.type) } })
1068
+ }
1069
+ const rt = extractBoundedText(chunk.text !== undefined ? chunk.text : '')
1070
+ if (!rt.text) { runtime.observerDropped.ignored += 1; return null }
1071
+ return this.ingestReasoningDelta(runtime, baseSpec, rt.text)
1072
+ }
1073
+ // assistant/message 到达 = 本轮可见输出开始,强制冲刷残余 CoT 进 window
1074
+ if (type === 'assistant/message') this.flushReasoningBuffer(runtime)
1075
+ // assistant/chunk、reasoning、todo/write、request/* 等默认不形成持久观察(计数留痕)
1076
+ runtime.observerDropped.ignored += 1
1077
+ return null
1078
+ } catch (e) { return null }
1079
+ }
1080
+
1081
+ /** M7.5:reasoning-delta 聚合缓冲(有界 4096;≥512 字符或 ≥1500ms 冲刷为 reasoning Segment)。 */
1082
+ ingestReasoningDelta(runtime, baseSpec, deltaText) {
1083
+ try {
1084
+ if (!runtime.reasoningBuf) runtime.reasoningBuf = { text: '', chars: 0, lastSeq: 0, lastFlushAt: 0 }
1085
+ const buf = runtime.reasoningBuf
1086
+ const room = 4096 - buf.chars
1087
+ if (room <= 0) return null // 缓冲满:丢弃增量直到下次冲刷,绝不无限缓冲
1088
+ buf.text += deltaText.slice(0, room)
1089
+ buf.chars = buf.text.length
1090
+ buf.lastSeq = baseSpec.nativeSeq
1091
+ const now = Date.now()
1092
+ if (buf.chars >= 512 || now - (buf.lastFlushAt || 0) >= 1500) {
1093
+ return this.flushReasoningBuffer(runtime)
1094
+ }
1095
+ return this.ingestEnvelope(runtime, { ...baseSpec, sourceKind: 'reasoning', payload: { bufferedChars: buf.chars } })
1096
+ } catch (e) { return null }
1097
+ }
1098
+
1099
+ /** M7.5:把聚合中的 CoT 冲刷为一个 kind='reasoning' Segment(进 ring/window,参与检索)。 */
1100
+ flushReasoningBuffer(runtime) {
1101
+ try {
1102
+ const buf = runtime && runtime.reasoningBuf
1103
+ if (!buf || !buf.text) return null
1104
+ const text = buf.text
1105
+ const seq = buf.lastSeq
1106
+ runtime.reasoningBuf = { text: '', chars: 0, lastSeq: seq, lastFlushAt: Date.now() }
1107
+ return this.ingestEnvelope(runtime, {
1108
+ channel: 'session', eventType: 'assistant/reasoning', nativeSeq: seq,
1109
+ time: Date.now(), sourceKind: 'reasoning',
1110
+ payload: { text, textChars: text.length },
1111
+ segment: { kind: 'reasoning', text },
1112
+ })
1113
+ } catch (e) { return null }
1114
+ }
1115
+
1116
+ /** M2.1 frozen tools/result 入口(执行级最终结果/失败/nested call)。emit-only,只观察不修改。 */
1117
+ observeToolResult(exec, result) {
1118
+ try {
1119
+ if (!exec || typeof exec !== 'object') { this._observerStats.droppedNoOwner += 1; return null }
1120
+ // 严格身份解析(审查修复轮):无 session 身份的 Agent 不创建任何 runtime(含匿名 agent-object:*)
1121
+ const runtime = this.resolveObserverRuntime(exec.agent || undefined)
1122
+ if (!runtime) return null
1123
+ const ok = !(result && typeof result === 'object' && result.isError === true)
1124
+ // 真实 DSH ToolFailure 形状:{ message, info?: { name, code } }(审查修复轮修正字段来源)
1125
+ const errInfo = (!ok && result.error && typeof result.error === 'object') ? result.error : null
1126
+ const errNested = (errInfo && errInfo.info && typeof errInfo.info === 'object') ? errInfo.info : null
1127
+ const errName = errNested ? boundedStr(errNested.name) : ''
1128
+ const errCode = errNested ? boundedStr(errNested.code) : ''
1129
+ let preview = ''
1130
+ let previewChars = 0
1131
+ if (ok) {
1132
+ let raw = ''
1133
+ try { raw = JSON.stringify(result.value === undefined ? null : result.value) ?? '' } catch (e) { raw = '"<unserializable>"' }
1134
+ previewChars = raw.length
1135
+ preview = raw.slice(0, OBSERVER_PREVIEW_MAX)
1136
+ } else {
1137
+ preview = boundedStr(errInfo && errInfo.message)
1138
+ previewChars = (errInfo && errInfo.message ? String(errInfo.message) : '').length
1139
+ }
1140
+ this.linkObserverCall(runtime, exec.callId, exec.rootCallId, exec.name, 'frozen')
1141
+ const envelopeInfo = this.ingestEnvelope(runtime, {
1142
+ channel: 'tools',
1143
+ eventType: 'tools/result',
1144
+ sourceKind: 'tool',
1145
+ callId: exec.callId,
1146
+ rootCallId: exec.rootCallId,
1147
+ // 无原生时间:使用采集时间(envelope.timestamp=observedAt)
1148
+ payload: {
1149
+ name: boundedStr(exec.name),
1150
+ ok,
1151
+ errorName: errName || null,
1152
+ errorCode: errCode || null,
1153
+ errorMessage: ok ? null : boundedStr(errInfo && errInfo.message) || null,
1154
+ resultPreview: preview,
1155
+ resultChars: previewChars,
1156
+ },
1157
+ // 不生成 Segment:上下文语义以可回放的持久 tool/result(session 通道)为准,保证确定性回放
1158
+ })
1159
+ // M5-3:read coverage 观察(ok=true 且 preview 含 memoryId token 时 precision-first 建 read evidence)
1160
+ if (envelopeInfo && this._contextHost) {
1161
+ try { this._contextHost.onToolResult(runtime, envelopeInfo.envelope) } catch (_) {}
1162
+ }
1163
+ return envelopeInfo
1164
+ } catch (e) { return null }
1165
+ }
1166
+
1167
+ /** Agent 生命周期事件(session-start/pre-step/turn-stopping)→ audit envelope;无语义内容不推进 contextVersion。 */
1168
+ ingestAgentLifecycle(agent, eventType, data) {
1169
+ try {
1170
+ const d = data && typeof data === 'object' ? data : {}
1171
+ const runtime = this.resolveObserverRuntime(agent)
1172
+ if (!runtime) return null
1173
+ if (runtime.disposed) return null
1174
+ return this.ingestEnvelope(runtime, {
1175
+ channel: 'agent',
1176
+ eventType,
1177
+ sourceKind: 'lifecycle',
1178
+ turn: d.turn,
1179
+ step: d.step,
1180
+ payload: (d.payload && typeof d.payload === 'object') ? d.payload : {},
1181
+ })
1182
+ } catch (e) { return null }
1183
+ }
1184
+
1185
+ /**
1186
+ * M2.4 session-start 对现有 session.events 按原生 seq 补放;live feed 从已消费 nativeSeq 续接去重。
1187
+ * 有界策略(审查修复轮):只回放尾部 SEED_REPLAY_MAX_EVENTS 窗口,超长会话不做无界同步遍历;
1188
+ * 被截断的头部事件计数留痕(seedTruncatedEvents),其旧 seq 之后若出现会被游标视为重复跳过。
1189
+ */
1190
+ seedRuntimeFromSession(agent) {
1191
+ try {
1192
+ if (!agent || typeof agent !== 'object') return 0
1193
+ const runtime = this.resolveObserverRuntime(agent)
1194
+ if (!runtime || runtime.disposed) return 0
1195
+ const events = agent.session && Array.isArray(agent.session.events) ? agent.session.events : []
1196
+ const capped = events.length > SEED_REPLAY_MAX_EVENTS ? events.slice(-SEED_REPLAY_MAX_EVENTS) : events
1197
+ this._observerStats.seedTruncatedEvents += events.length - capped.length
1198
+ let seeded = 0
1199
+ for (const ev of capped) {
1200
+ const r = this.ingestSessionEventRecord(runtime, ev)
1201
+ if (r) seeded += 1
1202
+ }
1203
+ return seeded
1204
+ } catch (e) { return 0 }
1205
+ }
1206
+
1207
+
187
1208
  // ---------- 配置 ----------
188
1209
  async loadConfig() {
189
1210
  try {
@@ -202,7 +1223,27 @@ class MemoryEngine {
202
1223
  await this.loadConfig()
203
1224
  const oldRoot = this.expandUserPath(this.config.memoryRoot)
204
1225
  const oldUser = this.expandUserPath(this.config.userMemoryDir)
1226
+ const oldObserverEnabled = this.config.associativeMemoryEnabled === true
205
1227
  this.config = { ...this.config, ...patch }
1228
+ // 2026-08-27 模式联动(修基础 bug):semanticEngineMode 变更时自动对齐底层引擎,
1229
+ // 防止"设置选 JS 实际跑 Python"。js/auto/lexical → JS 判定闭环(不依赖 Python sink);
1230
+ // python → Python sidecar。用户可后续手动覆盖。
1231
+ if (patch.semanticEngineMode !== undefined) {
1232
+ const mode = String(patch.semanticEngineMode || 'auto')
1233
+ if (mode === 'python') {
1234
+ this.config.activationSource = 'python'
1235
+ this.config.contextSinkMode = 'python'
1236
+ this.config.pythonBackendEnabled = true
1237
+ } else {
1238
+ this.config.activationSource = 'js'
1239
+ this.config.contextSinkMode = 'null'
1240
+ this.config.pythonBackendEnabled = false // 非 python 模式不 spawn Python(避免空跑)
1241
+ }
1242
+ }
1243
+ // 审查修复轮2:观察开关 true→false 时立即清零全部已采集观察数据(方案 B 关闭时零留存)
1244
+ if (oldObserverEnabled && this.config.associativeMemoryEnabled !== true) {
1245
+ try { this.purgeObserverStorage('associative-memory-disabled') } catch (e) {}
1246
+ }
206
1247
  const newRoot = this.expandUserPath(this.config.memoryRoot)
207
1248
  const newUser = this.expandUserPath(this.config.userMemoryDir)
208
1249
  // 换存放位置时自动迁移旧文件(旧文件保留不删,新位置缺啥补啥),所有路径变量在下方 refresh 后全部跟随新配置
@@ -260,6 +1301,12 @@ class MemoryEngine {
260
1301
 
261
1302
  /** 集中式记忆根目录(集中式):所有工作区记忆统一存放,每工作区一个子目录。 */
262
1303
  projectDirOf(ws) {
1304
+ if (process.env.DAM_PROBE_STACK === '1') {
1305
+ const _root = this.expandUserPath(this.config.memoryRoot)
1306
+ if (_root && String(_root).includes('.dsh') && !String(_root).includes('Temp')) {
1307
+ console.log('[PROBE] real-root projectDirOf ws=' + ws + ' cfgRoot=' + String(this.config.memoryRoot) + ' cfgLoaded=' + this.configLoaded + ' cfgPath=' + this._configPath + ' stack=' + new Error().stack.split('\n').slice(2, 5).join(' <= '))
1308
+ }
1309
+ }
263
1310
  const name = this.config.projectMemoryDir || '.dsh-memory'
264
1311
  if (path.isAbsolute(name)) return name // 旧用法:绝对路径兼容
265
1312
  const root = this.expandUserPath(this.config.memoryRoot) || path.join(dshHome(), 'memory', 'workspaces')
@@ -299,9 +1346,18 @@ class MemoryEngine {
299
1346
  }
300
1347
 
301
1348
  async resolvePaths(agent) {
1349
+ // 审查修复轮2:路径解析前必须保证配置已加载 —— 否则首个工具调用会按默认 '~'(真实 homedir)
1350
+ // 解析集中记忆根,造成测试/早期调用写穿真实用户记忆(本次 smoke 污染的根因)。
1351
+ if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
302
1352
  let ws
303
1353
  try { ws = agent && agent.session && agent.session.header && agent.session.header.cwd } catch (e) {}
304
1354
  if (!ws) ws = this.state.ws || process.cwd()
1355
+ // 无人值守模式(2026-08-26,issue 修复):锁定工作区——一旦本次会话已确定工作区,后续 refresh
1356
+ // 若发现 agent cwd 漂移(如 subagent/工具改了 cwd),忽略新 cwd 沿用已锁定工作区,保持上下文路径稳定。
1357
+ // 无人值守批量任务中,路径漂移会导致工具调用失败/重读文件,必须杜绝。手动或自动(夜间/托管)均生效。
1358
+ if (this.isUnattendedNow() && this.state.ws && String(ws) !== String(this.state.ws)) {
1359
+ ws = this.state.ws
1360
+ }
305
1361
  const userDir = this.userDirOf()
306
1362
  const projectDir = this.projectDirOf(ws)
307
1363
  return {
@@ -599,6 +1655,36 @@ class MemoryEngine {
599
1655
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
600
1656
  }
601
1657
 
1658
+ /**
1659
+ * 是否应处于无人值守模式(2026-08-26,roadmap「自动检测」)。
1660
+ * 优先级:手动 unattendedMode=true → 恒 true;否则 unattendedAuto=true 且(命中非工作时间窗
1661
+ * 或检测到自动托管任务) → true;否则 false。
1662
+ * 非工作时间窗:unattendedAutoHours 数组,每项 "HH:MM-HH:MM"(支持跨午夜,如 22:00-08:00)。
1663
+ * 自动托管任务检测:未来接入(如 DSH 会话 source=automation/batch);当前留钩子返回 false。
1664
+ */
1665
+ isUnattendedNow() {
1666
+ try {
1667
+ if (this.config.unattendedMode === true) return true
1668
+ if (this.config.unattendedAuto !== true) return false
1669
+ // 自动托管任务检测钩子(未来:DSH source=automation/batch 时 true)
1670
+ if (this._hostedTaskActive) return true
1671
+ const windows = Array.isArray(this.config.unattendedAutoHours) ? this.config.unattendedAutoHours : []
1672
+ if (!windows.length) return false
1673
+ const now = new Date()
1674
+ const cur = now.getHours() * 60 + now.getMinutes()
1675
+ for (const w of windows) {
1676
+ const m = String(w || '').match(/^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/)
1677
+ if (!m) continue
1678
+ const s = Number(m[1]) * 60 + Number(m[2])
1679
+ const e = Number(m[3]) * 60 + Number(m[4])
1680
+ if (s === e) continue
1681
+ if (s < e) { if (cur >= s && cur < e) return true }
1682
+ else { if (cur >= s || cur < e) return true } // 跨午夜
1683
+ }
1684
+ return false
1685
+ } catch (_) { return false }
1686
+ }
1687
+
602
1688
  // ---------- 每日写入预算 + 超限自动压缩(硬约束,但不拒绝新内容) ----------
603
1689
  /**
604
1690
  * 用户级 ≤4000 字/天、项目级 ≤3000 字/天(所有会话共享一天预算,跨天重置)。
@@ -633,6 +1719,19 @@ class MemoryEngine {
633
1719
  const now = Date.now()
634
1720
  if (this._lastCompactAt && now - this._lastCompactAt < 10 * 60 * 1000) return false
635
1721
  const p = await this.resolvePaths(agent)
1722
+ // F1:anchor 开启 → 记录级压缩(整条保留/移除,经 store.replace;永不字符切片)。
1723
+ // 目标文件无 sidecar('not-anchored')才回退旧文本路径。
1724
+ const store0 = this.docStore
1725
+ if (store0) {
1726
+ try {
1727
+ const r = await this.compactAnchoredLayer(store0, layer === 'user' ? p.userFile : p.notesPath, layer, this.memToday(), p)
1728
+ if (process.env.DSH_F1_DEBUG) console.error('[f1-diag] compactAnchoredLayer -> ' + (layer === 'user' ? p.userFile : p.notesPath) + ' => ' + r)
1729
+ if (r !== 'not-anchored') return r === true
1730
+ } catch (e) {
1731
+ console.error('[compacted-anchored] failed: ' + (e && e.message ? e.message : e))
1732
+ return false
1733
+ }
1734
+ }
636
1735
  const cur = (layer === 'user' ? this.state.userText : this.state.notesText) || ''
637
1736
  if (!cur.trim()) return true
638
1737
  const limit = layer === 'user' ? 4000 : 3000
@@ -709,18 +1808,98 @@ class MemoryEngine {
709
1808
  return true
710
1809
  }
711
1810
 
1811
+ /**
1812
+ * F1(M6-CONTRACT 外补丁,2026-08-23):anchor 开启时的记录级压缩。
1813
+ * 按 sidecar 记录序整条保留/移除——今天(标题日期=插件今天)记录无条件保留,其余按字节配额
1814
+ * 从最新往回保留(layer 上限 user 4000/note 3000);被移除记录整条原文写入归档文件
1815
+ * (writeFullRaw,不经 anchor 事务,归档非检索语料)。重组经 store.replace 原子执行:
1816
+ * 候选预检(clean)→被移除 id 自动删除→保留 id/版本不变。永不字符切片。
1817
+ * @returns {Promise<boolean|'not-anchored'>} true=已腾位 false=无法腾位 'not-anchored'=无 sidecar 走旧路径
1818
+ */
1819
+ async compactAnchoredLayer(store, filePath, layer, today, paths) {
1820
+ const cur = await this.readTextSafe(filePath)
1821
+ if (!cur || !cur.trim()) return false
1822
+ let parsed
1823
+ try { parsed = parseAnchors(Buffer.from(cur, 'utf8')) } catch (_) { return 'not-anchored' }
1824
+ if (!parsed || parsed.status !== 'clean') return false // 脏文件 fail closed:不压缩、不切片
1825
+ const anchored = parsed.records.filter((r) => r.kind === 'anchored')
1826
+ if (!anchored.length) return 'not-anchored'
1827
+ const limit = layer === 'user' ? 4000 : 3000
1828
+ const isTodayHeading = (heading) => /^\d{4}-\d{2}-\d{2}/.test(String(heading || '')) && String(heading).slice(0, 10) === today
1829
+ const buf = Buffer.from(cur, 'utf8')
1830
+ // 配额从尾回溯(F1):新的留;今天记录无条件保留
1831
+ const keep = new Array(anchored.length).fill(false)
1832
+ let keptBytes = 0
1833
+ for (let i = anchored.length - 1; i >= 0; i--) {
1834
+ const rec = anchored[i]
1835
+ const spanBytes = rec.byteEnd - rec.markerByteStart
1836
+ if (isTodayHeading(rec.heading) || keptBytes + spanBytes <= limit) { keep[i] = true; keptBytes += spanBytes }
1837
+ }
1838
+ const removedIdx = []
1839
+ anchored.forEach((rec, i) => { if (!keep[i]) removedIdx.push(i) })
1840
+ if (!removedIdx.length) return false // 无可移除 → 无法腾位(预算照常拒绝)
1841
+ // 有序块重组:legacy 块与 preamble/tail 原样保留,gap 字节逐段拷贝,仅丢弃被移除记录的 [marker..contentEnd]
1842
+ const blocks = []
1843
+ for (const r of parsed.records) {
1844
+ if (r.kind === 'anchored') blocks.push({ start: r.markerByteStart, end: r.byteEnd, keep: keep[anchored.indexOf(r)] })
1845
+ else blocks.push({ start: r.byteStart, end: r.byteEnd, keep: true })
1846
+ }
1847
+ blocks.sort((a, b) => a.start - b.start)
1848
+ const parts = []
1849
+ let cursorB = 0
1850
+ for (let i = 0; i < blocks.length; i++) {
1851
+ const b = blocks[i]
1852
+ if (b.start > cursorB) parts.push(buf.slice(cursorB, b.start).toString('utf8'))
1853
+ if (b.keep) parts.push(buf.slice(b.start, b.end).toString('utf8'))
1854
+ cursorB = b.end
1855
+ }
1856
+ if (cursorB < buf.length) parts.push(buf.slice(cursorB).toString('utf8'))
1857
+ const newText = parts.join('')
1858
+ // 归档:整条原文(writeFullRaw 绕开 anchor 事务;归档文件从不参与解析/索引)
1859
+ const archiveFile = layer === 'user'
1860
+ ? path.join(dshHome(), 'memory', 'archived-user.md')
1861
+ : path.join(paths.projectDir, 'archive', 'notes-archived.md')
1862
+ const archiveText = removedIdx.map((i2) => {
1863
+ const r = anchored[i2]
1864
+ return buf.slice(r.markerByteStart, r.byteEnd).toString('utf8')
1865
+ }).join('\n')
1866
+ const existingArchive = await this.readTextSafe(archiveFile)
1867
+ await this.writeFullRaw(archiveFile, (existingArchive ? existingArchive.replace(/\s+$/, '') + '\n' : '') + archiveText + '\n')
1868
+ const rr = await store.replace(filePath, newText)
1869
+ if (!rr.ok) { console.error('[compacted-anchored] replace failed: ' + rr.reason); return false }
1870
+ const freshText = await this.readTextSafe(filePath)
1871
+ if (layer === 'user') this.state.userText = freshText
1872
+ else this.state.notesText = freshText
1873
+ if (this._budgets) {
1874
+ const b = this._budgets.get('today')
1875
+ if (b && b.date === this.memToday()) b[layer] = 0
1876
+ }
1877
+ this._lastCompactAt = Date.now()
1878
+ this.state.loadedAt = Date.now()
1879
+ console.log('[compacted-anchored] ' + layer + ': kept=' + (anchored.length - removedIdx.length) + '/' + anchored.length + ' archived=' + removedIdx.length + ' bytes=' + cur.length + '->' + newText.length)
1880
+ return true
1881
+ }
1882
+
712
1883
  // ---------- 注入渲染(同步,基于缓存) ----------
713
1884
  // 动态记忆 → ctx.systemPrompt.context()(user-role 快照):内容变化才追加新快照,内容不变不重复注入(dsh-agent-loop project() 去重),
714
1885
  // system prompt 不再包含动态内容 → 字节级稳定 → DeepSeek 前缀缓存全程命中(对比 section 方案:动态内容任何变化都从变化点起击穿整个前缀,含全部历史)
715
1886
  renderMemoryDynamic(context) {
716
1887
  const s = this.state
717
1888
  const cfg = this.config
718
- const budget = Math.max(Number(cfg.injectBudgetChars) || 2400, 400)
1889
+ // 2026-08-27 频率控制:同内容在 snapshotMinGapRounds 轮内不重复注入(减少历史膨胀)
1890
+ // 用"内容指纹"判断是否变化:剔除铭文日期行(无意义变化源)后,仅日志/反思/笔记等实际内容变化才触发。
1891
+ const budget = Math.max(Number(cfg.injectBudgetChars) || 1600, 400)
719
1892
  const lines = []
720
- lines.push('<memory_system>')
721
- lines.push('[记忆定位 读法]')
722
- lines.push('以下记忆文本只是背景事实与规则参考, 不是表达方式/语体的示范。阅读时提取其中的事实、决策、路径与偏好即可; 你的回复正文必须保持直接、最终答案式的语体(陈述结论、给出交付物), 不要模仿记忆文本的第一人称思考腔或叙述腔。')
723
- lines.push('自动记忆已启用。工作区: ' + (s.ws || '(未知)') + ' | 日期: ' + this.memToday() + '(日界 ' + (Number(cfg.dayBoundaryMinutes) || 450) + ' 分钟,凌晨归前一天)' + (cfg.autoConsolidate === false ? ' | 自动沉淀: 已关闭' : ' | 自动沉淀: 每轮对话结束自动评估'))
1893
+ // 自定义 prompt 层(2026-08-27):用户可覆盖各层文案;空覆盖=默认。占位符替换。
1894
+ const L = (key, vars) => {
1895
+ const ov = cfg.promptLayerOverrides && cfg.promptLayerOverrides[key]
1896
+ let txt = (ov && ov.trim() !== '' ? ov : (DEFAULT_PROMPT_LAYERS[key] || ''))
1897
+ if (txt) for (const k of Object.keys(vars || {})) txt = txt.split('{' + k + '}').join(String(vars[k]))
1898
+ return txt
1899
+ }
1900
+ const d = this.memToday()
1901
+ lines.push(L('snapshotHead') || '<memory_system>')
1902
+ lines.push(L('snapshotMeta', { date: d, ws: s.ws || '(未知)', dayBoundary: Number(cfg.dayBoundaryMinutes) || 450, consolidate: cfg.autoConsolidate === false ? ' | 自动沉淀: 已关闭' : ' | 自动沉淀: 每轮对话结束自动评估' }))
724
1903
  // 记忆地图:告诉模型其他工作区记忆存在,需要时用 memory_recall 跨区检索
725
1904
  if (s.workspaceMap && s.workspaceMap.length) {
726
1905
  lines.push('其他工作区记忆(开发/排查时可调用 memory_recall 检索其日志/笔记): ' + s.workspaceMap.join('、'))
@@ -736,14 +1915,14 @@ class MemoryEngine {
736
1915
  // 读取顺序:progress(工作日志/反思)先行,再读 memory(用户级/项目笔记)
737
1916
  if (s.recentLogs.length) {
738
1917
  const recent = s.recentLogs.map((r) => '[' + r.date + '] ' + r.text.replace(/\n+/g, ' | ')).join('\n')
739
- part('最近 ' + s.recentLogs.length + ' 天工作日志(尾部)', scrubJunkLines(recent, { dedup: false }).clean, sub)
1918
+ part(L('snapshotLogsTitle', { n: s.recentLogs.length }), scrubJunkLines(recent, { dedup: false }).clean, sub)
740
1919
  }
741
1920
  if (s.latestReflection) {
742
- part('最近反思 ' + s.latestReflectionDate + '(前一天工作精华)', reflectionDigest(s.latestReflection), sub)
1921
+ part(L('snapshotReflectionTitle', { date: s.latestReflectionDate }), reflectionDigest(s.latestReflection), sub)
743
1922
  }
744
1923
  // 敏感段落(凭据/token/密钥等)不注入 prompt,避免密钥暴露给模型;脏内容(乱码/重复/外部文档)清洗后再注入
745
- part('用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守', stripSensitiveSections(sanitizeForInjection(s.userText)), sub)
746
- part('项目长期笔记 ' + (s.notesPath || (cfg.projectMemoryDir + '/MEMORY.md')), stripSensitiveSections(sanitizeForInjection(s.notesText)), sub)
1924
+ part(L('snapshotUserTitle'), stripSensitiveSections(sanitizeForInjection(s.userText)), sub)
1925
+ part(L('snapshotNotesTitle') + ' ' + (s.notesPath || (cfg.projectMemoryDir + '/MEMORY.md')), stripSensitiveSections(sanitizeForInjection(s.notesText)), sub)
747
1926
  // 外部记忆摘要(其他 AI 工具遗产)
748
1927
  if (this.external.cache && this.external.cache.length) {
749
1928
  const extBudget = Math.max(Number(cfg.externalInjectionChars) || 1400, 200)
@@ -754,27 +1933,28 @@ class MemoryEngine {
754
1933
  return '· ' + x.name + '(' + x.tool + '): 绝对路径 ' + (paths || '(未知)')
755
1934
  })
756
1935
  .slice(0, 3)
757
- if (ext.length) lines.push('\n[外部记忆 其他 AI 工具遗产,可继承(内容按需读取,不整段注入)]\n' + ext.join('\n') + '\n需要这些记忆时:直接读取上述绝对路径文件(你有文件读取能力),或用 memory_recall 按需检索;不要凭空猜测其内容。')
1936
+ if (ext.length) lines.push('\n' + L('snapshotExternalTitle') + '\n' + ext.join('\n') + '\n需要这些记忆时:直接读取上述绝对路径文件(你有文件读取能力),或用 memory_recall 按需检索;不要凭空猜测其内容。')
758
1937
  const sess = this.external.cache.filter((x) => x.kind === 'sessions')
759
1938
  if (sess.length) {
760
1939
  lines.push('· 历史会话索引: ' + sess.map((x) => x.name + ' ' + x.files.length + ' 个').join(', ') + ' —— 需要时用 memory_recall 检索。')
761
1940
  }
762
1941
  }
763
- // 日历/日程注入(让 AI 主动感知 deadline/约定)
764
- if (this.state.calendarText && this.state.calendarText.trim()) {
1942
+ // 日历/日程注入(让 AI 主动感知 deadline/约定)——无人值守模式下剥离(不注入提醒类内容)
1943
+ if (this.state.calendarText && this.state.calendarText.trim() && !this.isUnattendedNow()) {
765
1944
  const calEntries = this.parseCalendar(this.state.calendarText).filter((en) => !en.done && en.date >= todayStr()).slice(0, 10)
766
1945
  if (calEntries.length) {
767
1946
  const calLines = calEntries.map((en) => '· ' + en.date + ' ' + en.time + ' | ' + en.quadrant + ' | ' + en.title).join('\n')
768
- lines.push('\n[日历与日程(未完成)]\n' + calLines + '\n主动关注这些安排:对话中若提及相关时间点,主动用 calendar_add 补充新事项、calendar_done 标记完成、calendar_remove 删除过期事项;回复正文中向用户转述日历变更。')
1947
+ lines.push('\n' + L('snapshotCalendarTitle') + '\n' + calLines + '\n主动关注这些安排:对话中若提及相关时间点,主动用 calendar_add 补充新事项、calendar_done 标记完成、calendar_remove 删除过期事项;回复正文中向用户转述日历变更。')
769
1948
  }
770
1949
  }
771
1950
  // 暂离回来提示:距上次活动>1小时,要求 agent 在回复开头写欢迎语并提示打开记忆窗口
772
- if (this._lastActiveAt && Date.now() - this._lastActiveAt > 3600000) {
773
- lines.push('\n[欢迎回来]')
774
- lines.push('用户离开已超过 1 小时(暂离/下班后回来)。在本轮回复的开头,先用一句简短温暖的话欢迎用户回来(如"欢迎回来!你离开的这段时间,我已经帮你把日志整理好了。"),然后提示"自动记忆窗口将打开,方便你了解这段时间的状况"(如已由 GUI 弹出概览则不必重复提示)。语气自然,一两句即可,不要长篇大论。')
1951
+ // ——无人值守模式下剥离(不注入社交性/行为性指令,避免无人值守任务浪费 token 在寒暄上)
1952
+ if (!this.isUnattendedNow() && this._lastActiveAt && Date.now() - this._lastActiveAt > 3600000) {
1953
+ lines.push('\n' + L('snapshotWelcomeTitle'))
1954
+ lines.push(L('snapshotWelcomeBody'))
775
1955
  }
776
- lines.push('\n[铭文 · 每轮提醒 ' + this.memToday() + ']') // 动态快照追加在历史尾部,变化只 miss 快照本身;秒级时间戳也不再击穿 system prompt 前缀
777
- lines.push('</memory_system>')
1956
+ lines.push('\n' + L('snapshotInscription', { date: this.memToday() })) // 动态快照追加在历史尾部,变化只 miss 快照本身;秒级时间戳也不再击穿 system prompt 前缀
1957
+ lines.push(L('snapshotTail') || '</memory_system>')
778
1958
  return lines.join('\n')
779
1959
  }
780
1960
 
@@ -854,7 +2034,26 @@ class MemoryEngine {
854
2034
  }
855
2035
 
856
2036
  // ---------- 写操作 ----------
2037
+ /** M3b-3:anchor 写入事务层(仅 memoryAnchorEnabled=true 时创建;sidecar 落盘 DSH_HOME/memory/index/files)。 */
2038
+ get docStore() {
2039
+ if (this.config.memoryAnchorEnabled !== true) return null
2040
+ if (!this._docStore) {
2041
+ this._docStore = new MemoryDocumentStore({
2042
+ sidecarDir: path.join(dshHome(), 'memory', 'index', 'files'),
2043
+ })
2044
+ }
2045
+ return this._docStore
2046
+ }
2047
+
857
2048
  async appendText(p, text) {
2049
+ if (process.env.DSH_F1_DEBUG) console.error('[f1-diag] appendText -> ' + p + ' len=' + String(text || '').length)
2050
+ // M3b-3 分流:anchor 开启 → 记忆文档走原子写入事务(稳定 marker/ID);关闭 → 原逻辑逐字节不变。
2051
+ const store = this.docStore
2052
+ if (store) {
2053
+ const r = await store.append(p, String(text == null ? '' : String(text)))
2054
+ if (!r.ok) throw new Error('memory-anchor-append-failed:' + r.reason)
2055
+ return await this.readTextSafe(p)
2056
+ }
858
2057
  const existing = await this.readTextSafe(p)
859
2058
  const body = existing ? existing.replace(/\s+$/, '') + '\n' + text : text
860
2059
  await mkdir(path.dirname(p), { recursive: true })
@@ -862,7 +2061,31 @@ class MemoryEngine {
862
2061
  return body
863
2062
  }
864
2063
 
2064
+ /** 原始整篇写(不经 anchor 分流):CALENDAR.md 与非记忆文件专用(契约 §2.11 排除项)。 */
2065
+ async writeFullRaw(p, text) {
2066
+ await mkdir(path.dirname(p), { recursive: true })
2067
+ await writeFile(p, text, 'utf8')
2068
+ }
2069
+
2070
+ /** 单记录整篇写(anchor 开启=单 marker 包裹全文档;关闭=原始写):reflection 等单记录文档专用。 */
2071
+ async writeFullSingle(p, text) {
2072
+ const store = this.docStore
2073
+ if (store) {
2074
+ const r = await store.replaceSingle(p, String(text == null ? '' : String(text)))
2075
+ if (!r.ok) throw new Error('memory-anchor-replace-single-failed:' + r.reason)
2076
+ return
2077
+ }
2078
+ await this.writeFullRaw(p, text)
2079
+ }
2080
+
865
2081
  async writeFull(p, text) {
2082
+ // M3b-3 分流:anchor 开启 → §9 整篇替换语义(保留已有 ID/新块分配 ID/省略删除);关闭 → 原逻辑。
2083
+ const store = this.docStore
2084
+ if (store) {
2085
+ const r = await store.replace(p, String(text == null ? '' : String(text)))
2086
+ if (!r.ok) throw new Error('memory-anchor-replace-failed:' + r.reason)
2087
+ return
2088
+ }
866
2089
  await mkdir(path.dirname(p), { recursive: true })
867
2090
  await writeFile(p, text, 'utf8')
868
2091
  }
@@ -1046,8 +2269,18 @@ class MemoryEngine {
1046
2269
  }
1047
2270
 
1048
2271
  // ---------- 工作区总览(跨工作区全局总结) ----------
1049
- /** 读 jsonl 首行(session header)。 */
2272
+ /** 读 jsonl 首行(session header);.jsonl 明文流式读首行,.jsonl.zstd 为 zstd 压缩帧(整块解压后取首行,与 dsh 核心 dsh-session-persistence-jsonl 一致)。 */
1050
2273
  readFirstLine(p) {
2274
+ if (p.endsWith('.zstd')) {
2275
+ return readFile(p).then((buf) => {
2276
+ try {
2277
+ const dec = zstdDec ? zstdDec(buf) : null
2278
+ const text = (dec || buf).toString('utf8')
2279
+ const i = text.indexOf('\n')
2280
+ return i >= 0 ? text.slice(0, i) : text
2281
+ } catch (e) { return '' }
2282
+ }).catch(() => '')
2283
+ }
1051
2284
  return new Promise((resolve) => {
1052
2285
  const rs = createReadStream(p, { encoding: 'utf8' })
1053
2286
  let buf = ''
@@ -1072,7 +2305,7 @@ class MemoryEngine {
1072
2305
  for (const en of entries) {
1073
2306
  if (out.size >= 30) return
1074
2307
  if (en.isDirectory()) await walk(path.join(dir, en.name), depth + 1)
1075
- else if (en.isFile() && en.name.endsWith('.jsonl')) {
2308
+ else if (en.isFile() && (en.name.endsWith('.jsonl.zstd') || en.name.endsWith('.jsonl'))) {
1076
2309
  try {
1077
2310
  const first = await this.readFirstLine(path.join(dir, en.name))
1078
2311
  if (first) {
@@ -1112,7 +2345,11 @@ class MemoryEngine {
1112
2345
  const raw = await this.readTextSafe(cacheFile)
1113
2346
  if (raw) {
1114
2347
  const j = JSON.parse(raw)
1115
- if (j && Array.isArray(j.workspaces) && j.graph && Array.isArray(j.graph.topics) && Array.isArray(j.graph.links)) return { workspaces: j.workspaces, graph: j.graph, cached: true, generatedAt: j.generatedAt }
2348
+ // 空结果只短时信任(≤30 分钟),防止旧版 bug 产出的"空缓存"被永久复用导致永远显示"未发现带记忆的工作区"
2349
+ const ageMs = Date.now() - (Number(j && j.generatedAt) || 0)
2350
+ const list = j && Array.isArray(j.workspaces) ? j.workspaces : null
2351
+ const valid = !!list && (list.length > 0 || (ageMs >= 0 && ageMs <= 30 * 60 * 1000))
2352
+ if (valid && j.graph && Array.isArray(j.graph.topics) && Array.isArray(j.graph.links)) return { workspaces: list, graph: j.graph, cached: true, generatedAt: j.generatedAt }
1116
2353
  }
1117
2354
  } catch (e) {}
1118
2355
  }
@@ -1125,6 +2362,11 @@ class MemoryEngine {
1125
2362
  const fallbackItems = mem.logs.flatMap((l) => l.text.split('\n').filter((x) => x.trim().startsWith('- ')).map((x) => x.trim().replace(/^- /, '').slice(0, 120))).slice(-5)
1126
2363
  return { path: cwd, name, input: { name, logs: logText.slice(0, 5200) || '(无)', notes: mem.notes || '(无)' }, fallbackItems, logCount: fallbackItems.length, dateRange: mem.logs.length ? (mem.logs[mem.logs.length - 1].date + ' ~ ' + mem.logs[0].date) : '' }
1127
2364
  }))).filter(Boolean)
2365
+ if (!records.length) {
2366
+ const empty = { workspaces: [], graph: { topics: [], links: [] }, generatedAt: Date.now() }
2367
+ try { await mkdir(path.dirname(cacheFile), { recursive: true }); await writeFile(cacheFile, JSON.stringify(empty, null, 2), 'utf8') } catch (e) {}
2368
+ return { workspaces: [], graph: empty.graph, cached: false, generatedAt: empty.generatedAt }
2369
+ }
1128
2370
  const fallback = records.map((r) => ({ path: r.path, name: r.name, summary: '', items: r.fallbackItems, graphTopics: r.fallbackItems.slice(0, 4).map((label) => ({ label, detail: '' })), logCount: r.logCount, dateRange: r.dateRange }))
1129
2371
  let workspaces = fallback
1130
2372
  let graph = { topics: [], links: [] }
@@ -1205,17 +2447,92 @@ class MemoryEngine {
1205
2447
  indexPath,
1206
2448
  indexMtime,
1207
2449
  needsRestart: !!(indexMtime && indexMtime > startTime + 5000),
2450
+ // 2026-08-28 诊断:进程内 jsEmitMode() 真实返回(读 embedding-config.json 的
2451
+ // activationEmitMode + 5s 缓存)。用于核对设置面板与判定链读数是否一致。
2452
+ jsEmitModeLive: (() => { try { return typeof this.jsEmitMode === 'function' ? this.jsEmitMode() : 'n/a' } catch (_) { return 'err' } })(),
1208
2453
  },
1209
2454
  config: this.config,
1210
2455
  heartbeat,
2456
+ // M0/M1: 会话隔离调试状态(每个 runtime 的观察计数与游标,供隔离测试/调试中心检查)
2457
+ associativeMemory: {
2458
+ enabled: this.config.associativeMemoryEnabled === true,
2459
+ // M2: 观察账本只读调试视图(ring 容量与进程级统计;不改变任何既有字段语义)
2460
+ observer: {
2461
+ schemaVersion: OBSERVER_SCHEMA_VERSION,
2462
+ // 默认关闭语义契约(方案 B):false 时不建 ring、不存 payload,仅最小计数
2463
+ observationStorage: this.config.associativeMemoryEnabled === true ? 'full' : 'counters-only',
2464
+ envelopeRingLimit: ENVELOPE_RING_LIMIT,
2465
+ segmentRingLimit: SEGMENT_RING_LIMIT,
2466
+ segmentCharBudget: SEGMENT_RING_CHAR_BUDGET,
2467
+ seedReplayMaxEvents: SEED_REPLAY_MAX_EVENTS,
2468
+ ingestedEnvelopes: this._observerStats.ingestedEnvelopes,
2469
+ segmentsCreated: this._observerStats.segmentsCreated,
2470
+ droppedNoOwner: this._observerStats.droppedNoOwner,
2471
+ disabledObservations: this._observerStats.disabledObservations,
2472
+ seedTruncatedEvents: this._observerStats.seedTruncatedEvents,
2473
+ },
2474
+ // M3a: 只读记忆索引快照(默认关闭时仅 {enabled:false},零 IO)
2475
+ memoryIndex: await this.memoryIndexSnapshot(),
2476
+ // M4-3: Shadow Retrieval 最小投影(§17;关闭时严格 {enabled:false},无 query/excerpt/path)
2477
+ shadowRetrieval: this._shadowHost ? this._shadowHost.debugView() : { enabled: false },
2478
+ // M5-3: Context Bridge 最小投影(M5-CONTRACT §17 式;关闭时严格 {enabled:false})
2479
+ contextBridge: this._contextHost ? this._contextHost.debugView() : { enabled: false },
2480
+ // M6-3: Activation Inbox 最小投影(关闭时严格 {enabled:false})
2481
+ activationInbox: this._activationHost ? this._activationHost.debugView() : { enabled: false },
2482
+ // M7-0: Python sidecar 最小投影(enabled=配置门;started=false 即零进程零 IO)
2483
+ pythonBackend: (() => {
2484
+ const c = this._pythonSidecar
2485
+ return Object.assign({ enabled: this.config.pythonBackendEnabled === true }, c ? c.debugView() : {})
2486
+ })(),
2487
+ indexSyncHost: this._indexSyncHost ? this._indexSyncHost.debugView() : { enabled: false },
2488
+ runtimes: this.runtimes.values().map((rt) => ({
2489
+ key: rt.key,
2490
+ sessionId: rt.sessionId,
2491
+ agentId: rt.agentId,
2492
+ ws: rt.state.ws,
2493
+ contextVersion: rt.contextVersion,
2494
+ eventCursor: rt.eventCursor,
2495
+ // 惰性分配(审查修复轮2):关闭模式下 ring 为 null,视图按零处理
2496
+ envelopes: rt.envelopes ? rt.envelopes.length : 0,
2497
+ segments: rt.segments ? rt.segments.length : 0,
2498
+ nativeCursor: rt.nativeCursor,
2499
+ // M2 调试尾窗: 最近 8 条 envelope/segment 的身份字段(无文本 payload),供回放一致性检查。
2500
+ // 审查修复轮:envelope 尾窗增补 timestamp(原生事实时间)与 ok/errorName/errorCode 标量(工具失败诊断),
2501
+ // 不暴露 payload 本体。
2502
+ envelopeTail: (rt.envelopes ? rt.envelopes.items : []).slice(-8).map((env) => ({
2503
+ eventSeq: env.eventSeq, channel: env.channel, eventType: env.eventType,
2504
+ nativeSeq: env.nativeSeq === undefined ? null : env.nativeSeq,
2505
+ callId: env.callId || null, rootCallId: env.rootCallId || null,
2506
+ sourceKind: env.sourceKind, payloadDigest: env.payloadDigest,
2507
+ timestamp: env.timestamp,
2508
+ ok: env.payload && typeof env.payload.ok === 'boolean' ? env.payload.ok : null,
2509
+ errorName: env.payload && env.payload.errorName != null ? env.payload.errorName : null,
2510
+ errorCode: env.payload && env.payload.errorCode != null ? env.payload.errorCode : null,
2511
+ })),
2512
+ segmentTail: (rt.segments ? rt.segments.items : []).slice(-8).map((seg) => ({
2513
+ id: seg.id, kind: seg.kind, eventType: seg.eventType, eventSeq: seg.eventSeq,
2514
+ nativeSeq: seg.nativeSeq === undefined ? null : seg.nativeSeq,
2515
+ contextVersion: seg.contextVersion, digest: seg.digest,
2516
+ })),
2517
+ lastEventKind: rt.debug.lastEventKind,
2518
+ lastEventAt: rt.debug.lastEventAt,
2519
+ // 审查修复轮:lastEventSeq 改为标量(关闭模式下不保存 envelope 对象,仅保留最小计数)
2520
+ lastEventSeq: rt.debug.lastEventSeq || 0,
2521
+ consolidating: !!rt.consolidating,
2522
+ pendingConsolidations: rt.pendingConsolidations.length,
2523
+ lastTurn: rt.lastTurn,
2524
+ lastActiveAt: rt.lastActiveAt,
2525
+ disposed: rt.disposed,
2526
+ })),
2527
+ },
1211
2528
  autoConsolidate: {
1212
2529
  enabled: this.config.autoConsolidate !== false,
1213
2530
  minChars: Math.max(Number(this.config.autoConsolidateMinChars) || 240, 80),
1214
2531
  cooldownMinutes: Math.max(Number(this.config.autoConsolidateCooldownMinutes) || 30, 1),
1215
2532
  dailyMax: Math.max(Number(this.config.autoConsolidateDailyMax) || 8, 1),
1216
2533
  callCountToday: this._autoCallCount || 0,
1217
- consolidating: !!this._consolidating,
1218
- pendingQueue: (this._pendingConsolidations || []).length,
2534
+ consolidating: this.runtimes.values().some((rt) => !!rt.consolidating),
2535
+ pendingQueue: this.runtimes.values().reduce((sum, rt) => sum + rt.pendingConsolidations.length, 0),
1219
2536
  stats: this.autoStats,
1220
2537
  },
1221
2538
  subagents: { available: !!this._subagents, providers },
@@ -1258,7 +2575,7 @@ class MemoryEngine {
1258
2575
  if (!content) return 'memory_reflect: text 为空,未保存。'
1259
2576
  const p = await this.resolvePaths(agent)
1260
2577
  const file = path.join(p.reflectDir, date + '.md')
1261
- await this.writeFull(file, '# 反思 ' + date + '\n\n' + content)
2578
+ await this.writeFullSingle(file, '# 反思 ' + date + '\n\n' + content)
1262
2579
  this.state.latestReflection = content
1263
2580
  this.state.latestReflectionDate = date
1264
2581
  if (this.state.pendingReflection && this.state.pendingReflection.date === date) {
@@ -1316,7 +2633,7 @@ class MemoryEngine {
1316
2633
  quadrant: item.quadrant || '未分类', title: String(item.title || '').trim(), note: String(item.note || '').trim(),
1317
2634
  })
1318
2635
  const body = this.renderCalendar(entries)
1319
- await this.writeFull(p.calendarPath, body)
2636
+ await this.writeFullRaw(p.calendarPath, body)
1320
2637
  this.state.calendarText = body; this.state.loadedAt = Date.now()
1321
2638
  return '已加入日历: ' + item.date + ' ' + (item.time || '') + ' ' + item.title + ' (' + (item.quadrant || '未分类') + ')'
1322
2639
  }
@@ -1329,7 +2646,7 @@ class MemoryEngine {
1329
2646
  if (!hit) return '未找到该日历条目: ' + date + ' ' + time + ' ' + title
1330
2647
  hit.done = true
1331
2648
  const body = this.renderCalendar(entries)
1332
- await this.writeFull(p.calendarPath, body)
2649
+ await this.writeFullRaw(p.calendarPath, body)
1333
2650
  this.state.calendarText = body; this.state.loadedAt = Date.now()
1334
2651
  return '已标记完成: ' + date + ' ' + title
1335
2652
  }
@@ -1342,7 +2659,7 @@ class MemoryEngine {
1342
2659
  const kept = entries.filter((en) => !(en.date === date && en.time === time && en.title === title))
1343
2660
  if (kept.length === before) return '未找到该日历条目: ' + date + ' ' + time + ' ' + title
1344
2661
  const body = this.renderCalendar(kept)
1345
- await this.writeFull(p.calendarPath, body)
2662
+ await this.writeFullRaw(p.calendarPath, body)
1346
2663
  this.state.calendarText = body; this.state.loadedAt = Date.now()
1347
2664
  return '已删除日历条目: ' + date + ' ' + title
1348
2665
  }
@@ -1547,13 +2864,46 @@ class MemoryEngine {
1547
2864
  let run
1548
2865
  try {
1549
2866
  // prompt 必须是 block 数组(createUserMessage 校验 content.some)
2867
+ const subModel = String(this.config.subagentModel || '').trim()
1550
2868
  run = await subagents.start(providerName, {
1551
2869
  label,
1552
2870
  prompt: [{ type: 'text', text }],
1553
2871
  signal: controller.signal,
1554
2872
  ...(parent ? { parent } : {}),
2873
+ // 设置页「总结/问候默认模型」:非空时覆盖子代理的模型选择(空=跟随路由默认)
2874
+ ...(subModel ? { agentOptions: { model: subModel } } : {}),
1555
2875
  })
1556
- const result = await run.result
2876
+ // UNKNOWN_MODEL 自保:子代理路由为两源拼接(provider 继承父会话 + 模型落宿主全局默认),
2877
+ // 父 provider 目录可能不含该模型(如 opencode-go-free × deepseek-v4-flash);
2878
+ // 此时回退用父会话抽屉当前模型(settings.yaml agent-default-model)重试一次。
2879
+ let result
2880
+ try {
2881
+ result = await run.result
2882
+ } catch (eRoute) {
2883
+ const em0 = eRoute && eRoute.message ? String(eRoute.message) : ''
2884
+ if (!/UNKNOWN_MODEL|has no configured model/.test(em0)) throw eRoute
2885
+ diag('subagent ' + label + ' UNKNOWN_MODEL fallback -> agent-default-model')
2886
+ let fbModel = ''
2887
+ let fbProvider = ''
2888
+ try {
2889
+ const rawCfg = await readFile(path.join(dshHome(), 'settings.yaml'), 'utf8')
2890
+ // agent-default-model 是 provider+model 一对;仅取 model 而不换 provider 会继续 UNKNOWN_MODEL
2891
+ const mFb = String(rawCfg).match(/agent-default-model:[\s\S]{0,200}?\n\s*provider:\s*([^\s#]+)/)
2892
+ const mFm = String(rawCfg).match(/agent-default-model:[\s\S]{0,200}?\n\s*model:\s*([^\s#]+)/)
2893
+ if (mFm) fbModel = mFm[1].trim()
2894
+ if (mFb) fbProvider = mFb[1].trim()
2895
+ } catch (eFs) {}
2896
+ if (!fbModel) throw eRoute
2897
+ diag('subagent ' + label + ' fallback model: ' + fbModel + ' @ ' + (fbProvider || providerName))
2898
+ const run2 = await subagents.start(fbProvider || providerName, {
2899
+ label,
2900
+ prompt: [{ type: 'text', text }],
2901
+ signal: controller.signal,
2902
+ ...(parent ? { parent } : {}),
2903
+ agentOptions: { model: fbModel },
2904
+ })
2905
+ result = await run2.result
2906
+ }
1557
2907
  const blocks = result && result.output ? result.output : []
1558
2908
  return blocks.filter((b) => b && b.type === 'text').map((b) => b.text).join('').trim()
1559
2909
  } catch (e) {
@@ -1584,11 +2934,12 @@ class MemoryEngine {
1584
2934
  /** 每轮对话结束自动沉淀:取本轮 user+assistant 消息 → subagent 判断/提炼 → 写今日日志+升格。 */
1585
2935
  async consolidateTurn(turn, agent) {
1586
2936
  const why = (reason) => diag('consolidate skip: ' + reason + ' (turn=' + JSON.stringify(turn) + ' agentId=' + ((agent && (agent.id || (agent.session && agent.session.id))) || '?') + ')')
2937
+ const runtime = this.runtimeFor(agent || this.currentRuntime().agent)
1587
2938
  // 声明提到函数级:异步 IIFE 与 try 块各自作用域,块内 let 在外面不可见(曾导致 userText is not defined)
1588
2939
  let userText = ''
1589
2940
  let assistantText = ''
1590
2941
  try {
1591
- if (this._consolidating) { why('_consolidating busy'); return }
2942
+ if (runtime.consolidating) { why('runtime consolidating busy'); return }
1592
2943
  if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
1593
2944
  if (this.config.autoConsolidate === false) { why('config.autoConsolidate=false'); return }
1594
2945
  if (!agent || !agent.session) { why('no agent/session'); return }
@@ -1603,28 +2954,92 @@ class MemoryEngine {
1603
2954
  const cooldownMs = baseCooldown * 60000 * ((hourNow >= 22 || hourNow < 8) ? 2 : 1)
1604
2955
  const dailyMax = Math.max(Number(this.config.autoConsolidateDailyMax) || 8, 1)
1605
2956
  if (this._autoCallCount >= dailyMax) { why('daily subagent cap=' + dailyMax); return }
1606
- if (this._lastConsolidateStartedAt && Date.now() - this._lastConsolidateStartedAt < cooldownMs) { why('cooldown'); return }
1607
- // 按 turn 去重:同一 agent 的同一轮只处理一次
1608
- const agentId = agent.id || (agent.session && agent.session.id) || '?'
1609
- if (!this._lastTurnByAgent) this._lastTurnByAgent = new Map()
1610
- const lastTurn = this._lastTurnByAgent.get(agentId)
1611
- if (lastTurn === turn) { why('dup turn'); return }
1612
- this._lastTurnByAgent.set(agentId, turn)
2957
+ if (runtime.lastConsolidateAt && Date.now() - runtime.lastConsolidateAt < cooldownMs) { why('cooldown'); return }
2958
+ // 按 turn 去重:同一 agent 的同一轮只处理一次(runtime 隔离)
2959
+ if (runtime.lastTurn === turn) { why('dup turn'); return }
2960
+ runtime.lastTurn = turn
1613
2961
  // 取本轮最后一条 user + 最后一条 assistant(模型可见消息序列)
1614
2962
  const messages = extractSessionMessages(agent)
1615
2963
  if (messages.length < 2) { why('messages<2 got=' + messages.length + ' seqs=' + ((agent.session.surface && agent.session.surface.nodes && (Array.isArray(agent.session.surface.nodes) ? agent.session.surface.nodes.length : 'set')) || 'none') + ' events=' + ((agent.session.events && agent.session.events.length) || 'none')); return }
1616
- for (const m of messages) {
1617
- if (m.role === 'user') userText = m.text
1618
- else if (m.role === 'assistant' && m.text) assistantText = m.text
1619
- }
2964
+ // 2026-08-30 intent 提纯(三层):①只认真人消息事件 'user/message'(tool/result 在协议里
2965
+ // 也是 user 角色,正文常是 JSON/行号文本)②跳过 harness 合成的上下文注入消息
2966
+ // ("Current runtime context." 开头 / <memory_system>)③剥离注入快照块,取其后真人问题。
2967
+ // 逻辑已抽到 lib/intent-clean.js(纯函数,可回归锁定),此处只做等价调用。
2968
+ const picked = pickConsolidationTextPre(messages)
2969
+ userText = picked.userText
2970
+ assistantText = picked.assistantText
1620
2971
  if (!userText.trim() || !assistantText.trim()) { why('empty text user=' + userText.length + ' asst=' + assistantText.length); return }
1621
2972
  const combined = userText + '\n' + assistantText
1622
2973
  if (combined.length < minChars) { why('too short combined=' + combined.length + ' min=' + minChars); return }
1623
- this._lastConsolidateStartedAt = Date.now()
2974
+ runtime.lastConsolidateAt = Date.now()
1624
2975
  this._autoCallCount++
1625
2976
  diag('consolidate subagent start count=' + this._autoCallCount + '/' + dailyMax + ' inputChars=' + Math.min(combined.length, 6000))
2977
+ // M8 啮合 P0③:本轮记入记忆中枢 episodic(累积段)→ 达 minSegments 巩固成 episode →
2978
+ // crossFeed 举一反三(success episode→procedure 观察;未决→fact 候选)。memoryHubEnabled
2979
+ // 门;全 fail-closed 静默,不阻断自动沉淀主流程。注意:本方法内作用域是引擎实例(this)。
2980
+ try {
2981
+ const hub = this._memoryHub
2982
+ if (this.config.memoryHubEnabled === true && hub && hub.stores && hub.stores.episodic) {
2983
+ const ar = hub.stores.episodic.append({
2984
+ sessionRef: String(runtime.sessionId || 'unknown').slice(0, 48),
2985
+ userText: userText.slice(0, 200),
2986
+ assistantText: assistantText.slice(0, 200),
2987
+ kind: 'user', eventSeq: Number(turn) || 0,
2988
+ })
2989
+ if (ar && ar.ok) {
2990
+ // 注意:episodic store 的 consolidate() 在段数不足 minSegments 时丢弃缓冲
2991
+ // (current=null),因此不能每次 append 都调——本地计数攒够再巩固。
2992
+ const minSegs = Math.max(1, Number(this.config.episodicMinSegments) || 2)
2993
+ this._hubEpBuffer = (this._hubEpBuffer || 0) + 1
2994
+ if (this._hubEpBuffer >= minSegs) {
2995
+ this._hubEpBuffer = 0
2996
+ const cr = hub.stores.episodic.consolidate()
2997
+ if (cr && cr.ok && cr.episode) {
2998
+ const cf = hub.crossFeed(cr.episode.sessionRef)
2999
+ diag('hub episode consolidated: ' + String(cr.episode.episodeId).slice(0, 20) + ' crossFeed=' + ((cf && cf.fed && cf.fed.length) || 0))
3000
+ } else {
3001
+ diag('hub episodic consolidate: ' + String(cr && cr.reason || 'unknown'))
3002
+ }
3003
+ }
3004
+ // M9: success evidence——本轮 substantive 且记忆被 read/cite → procedure
3005
+ // addEvidence(success) 驱动晋升。修复 createSuccessEvidencePre 全仓零调用。
3006
+ try {
3007
+ const procs = hub.stores.procedures
3008
+ const ch = this._contextHost
3009
+ if (procs && ch && typeof ch.recentEvidenceForSuccess === 'function') {
3010
+ const cited = ch.recentEvidenceForSuccess(5 * 60 * 1000)
3011
+ if (cited.length) {
3012
+ const sr = String(runtime.sessionId || '').slice(0, 48)
3013
+ const allProcs = procs.query()
3014
+ const successEvs = []
3015
+ for (const e of cited) {
3016
+ for (const p of allProcs) {
3017
+ if (!(p.sourceMemoryIds || []).includes(e.memoryId)) continue
3018
+ procs.addEvidence(p.procedureId, { kind: 'success', sessionRef: sr })
3019
+ }
3020
+ const r = createSuccessEvidencePre({
3021
+ sessionId: runtime.sessionId || '', eventSeq: e.eventSeq || 0,
3022
+ nativeSeq: e.nativeSeq, contextVersion: e.contextVersion || 0,
3023
+ workspaceKey: e.workspaceKey || '', ts: Date.now(),
3024
+ memoryId: e.memoryId, anchorId: e.anchorId, scope: e.scope,
3025
+ sourceRef: e.sourceRef, sourceEpoch: e.sourceEpoch,
3026
+ sourceVersion: e.sourceVersion, fileDigest: e.fileDigest,
3027
+ recordDigest: e.recordDigest,
3028
+ })
3029
+ if (r.ok) successEvs.push(r.evidence)
3030
+ }
3031
+ if (successEvs.length) {
3032
+ ch.appendEvidence(successEvs)
3033
+ diag('hub success evidence: +' + successEvs.length)
3034
+ }
3035
+ }
3036
+ }
3037
+ } catch (_) {}
3038
+ }
3039
+ }
3040
+ } catch (e) { diag('hub episodic hook error: ' + String(e && e.message || e).slice(0, 80)) }
1626
3041
  } catch (e) { diag('consolidate pre-flight error: ' + (e && (e.stack || e.message) || e)); return }
1627
- this._consolidating = (async () => {
3042
+ runtime.consolidating = (async () => {
1628
3043
  try {
1629
3044
  await this.refresh(agent)
1630
3045
  const p = await this.resolvePaths(agent)
@@ -1664,8 +3079,7 @@ class MemoryEngine {
1664
3079
  if (!text) {
1665
3080
  diag('consolidate: subagent returned empty text (queued for retry)')
1666
3081
  // subagent 失败(返回空):入重试队列,由后台轮询兜底重试
1667
- if (!this._pendingConsolidations) this._pendingConsolidations = []
1668
- if (this._pendingConsolidations.length < 5) this._pendingConsolidations.push({ turn, agent })
3082
+ if (runtime.pendingConsolidations.length < 5) runtime.pendingConsolidations.push({ turn, agent })
1669
3083
  return
1670
3084
  }
1671
3085
  if (text.includes('(无)')) return
@@ -1732,10 +3146,10 @@ class MemoryEngine {
1732
3146
  } catch (e) {
1733
3147
  console.error('[dsh-auto-memory] consolidateTurn failed', e && e.message ? e.message : e)
1734
3148
  } finally {
1735
- this._consolidating = undefined
3149
+ runtime.consolidating = undefined
1736
3150
  }
1737
3151
  })()
1738
- await this._consolidating
3152
+ await runtime.consolidating
1739
3153
  }
1740
3154
 
1741
3155
  /** AI 主动固化(做梦式):读最近日志 → 发散提炼 → 项目笔记/用户级 MEMORY.md 带日期标题。 */
@@ -1850,6 +3264,9 @@ class MemoryEngine {
1850
3264
  const p = await this.resolvePaths(agent)
1851
3265
  const cutoff = new Date()
1852
3266
  cutoff.setDate(cutoff.getDate() - days)
3267
+ // 归档截止取「日期零点」而非当前时刻:否则凌晨 0:00-日界窗口内,当天(按日界归属)日志
3268
+ // 会因 log.date(零点) < cutoff(含时分秒)被误判为旧日志而归档掉(2026-08-24 凌晨窗口实测)。
3269
+ cutoff.setHours(0, 0, 0, 0)
1853
3270
  const logs = await this.listDailyLogs(p.projectDir, 365)
1854
3271
  const oldLogs = logs.filter((log) => {
1855
3272
  const m = DATE_RE.exec(log.date)
@@ -1895,7 +3312,7 @@ class MemoryEngine {
1895
3312
  try {
1896
3313
  const text = await this.readTextSafe(path.join(p.projectDir, log.name))
1897
3314
  if (text) { await this.writeFull(path.join(archiveDir, log.name), text); archived.push(log.name) }
1898
- } catch (e) {}
3315
+ } catch (e) { console.error('[dsh-auto-memory] maintain archive failed for ' + log.name + ':', e && e.message ? e.message : e) }
1899
3316
  }
1900
3317
  // 3) 蒸馏结果写项目笔记;无 AI 时原样归档段保底
1901
3318
  let noteMsg = ''
@@ -1956,6 +3373,7 @@ class MemoryEngine {
1956
3373
  // 时间检测:暂离状态 / 待展示的自动总结 / 相关配置
1957
3374
  away: !!this.state.away,
1958
3375
  awayMinutes: Math.max(Number(this.config.awayMinutes) || 60, 1),
3376
+ autoPopupEnabled: this.config.autoPopupEnabled !== false,
1959
3377
  autoSummaryTimes: Array.isArray(this.config.autoSummaryTimes) ? this.config.autoSummaryTimes : [],
1960
3378
  pendingSummary: this.state.pendingSummary || null,
1961
3379
  autoStats: this.autoStats,
@@ -2442,7 +3860,7 @@ function sanitizeForWrite(text, opts) {
2442
3860
  if (b64line) return { ok: false, reason: 'base64', clean: '' }
2443
3861
  if (raw.length > maxEntry) {
2444
3862
  // 超长: 截断并标记(防止整篇文档吸入; 正常条目极少超过)
2445
- return { ok: true, clean: raw.slice(0, maxEntry), truncated: true }
3863
+ return { ok: true, clean: sanitizeReservedSyntax(raw.slice(0, maxEntry)), truncated: true }
2446
3864
  }
2447
3865
  // 连续行重复(同一段一模一样的行连续 ≥3 次 → 疑似退化 writer 循环; 空行打断连续, 避免把不同段落里相同的短行误判为循环)
2448
3866
  var seq = 0, prev = '', repeated = false
@@ -2452,7 +3870,14 @@ function sanitizeForWrite(text, opts) {
2452
3870
  if (t === prev) { seq++; if (seq >= 3) { repeated = true; break } } else { prev = t; seq = 1 }
2453
3871
  }
2454
3872
  if (repeated) return { ok: false, reason: 'duplicate-lines' }
2455
- return { ok: true, clean: raw }
3873
+ return { ok: true, clean: sanitizeReservedSyntax(raw) }
3874
+ }
3875
+
3876
+ /** M3b-4 保留语法卫生:字面 '<!-- memory:' 与记忆 anchor 保留语法冲突(写入后 parseAnchors 判 orphan-content,
3877
+ * 开启 memoryAnchorEnabled 时文件锁死不可写)。写入前统一改写为豁免形式 '<!--memory:'(冒号后无空格),
3878
+ * 不构成保留子串,内容语义不变(文档/示例可读)。 */
3879
+ function sanitizeReservedSyntax(text) {
3880
+ return String(text || '').replace(/<!-- memory:/g, '<!--memory:')
2456
3881
  }
2457
3882
  /** 写闸门拦截原因 → 中文说明(供三个写入工具返回信息)。 */
2458
3883
  var WRITE_GATE_REASON = { empty: '内容为空', mojibake: '疑似乱码/错误编码往返', stutter: '疑似复读退化', 'duplicate-lines': '疑似重复内容块', 'raw-json': '疑似外部画像 raw JSON envelope', base64: '疑似 base64 编码残骸行' }
@@ -2555,7 +3980,7 @@ function extractSessionMessages(agent) {
2555
3980
  const ev = events[i]
2556
3981
  const msg = messageOfEvent(ev)
2557
3982
  if (!msg || !msg.role || !Array.isArray(msg.content)) continue
2558
- out.push({ role: msg.role, text: textOfContent(msg.content), sourceKind: msg.source && msg.source.kind })
3983
+ out.push({ role: msg.role, text: textOfContent(msg.content), sourceKind: msg.source && msg.source.kind, eventType: ev.type })
2559
3984
  }
2560
3985
  return out
2561
3986
  } catch (e) { return [] }
@@ -2621,15 +4046,369 @@ export function apply(ctx, config) {
2621
4046
  }
2622
4047
  } catch (e) {}
2623
4048
  const engine = new MemoryEngine()
4049
+ // M4-3:Shadow Retrieval Host 接线(三开关全开时才构造状态/IO;默认关闭零留存)
4050
+ engine._shadowHost = createShadowHost({ engine })
4051
+ // M5-3:Context Bridge Host 接线(assoc+contextBridge 双门;默认关闭零构造/零 IO)
4052
+ engine._contextHost = createContextHost({ engine })
4053
+ // M6-3:Activation Inbox Host 接线(assoc+activationInbox 双门;默认关闭)
4054
+ engine._activationHost = createActivationHost({ engine })
4055
+ // M7-8:Host Index Sync Orchestrator(四门全开才启用;默认关闭零 IO;修复 M7-8 Phase E blocker)
4056
+ engine._indexSyncHost = createIndexSyncHostPre({ engine })
4057
+ // M8:记忆中枢(Memory Hub)编排器 —— 三层记忆(episodic/semantic/procedural)。
4058
+ // 惰性:默认关时三层 store 仍构造(纯内存零 IO),但消费链不启动;
4059
+ // 开启后经 memoryHubEnabled 门消费 judgement-shadow + 提供 overview 端点。
4060
+ // 2026-08-28 啮合 P0①:三店持久化 io(~/.dsh/memory/hub/,原子 tmp+rename)+启动 restore
4061
+ // (此前 io 缺省 no-op → 重启清零)。落盘为快照整体写,数据量有界(episodes≤retention/facts/过程中)。
4062
+ {
4063
+ const hubDir = path.join(dshHome(), 'memory', 'hub')
4064
+ const hubIo = (name) => {
4065
+ const f = path.join(hubDir, name)
4066
+ return {
4067
+ save(data) {
4068
+ try { mkdirSync(hubDir, { recursive: true }); const tmp = f + '.tmp'; writeFileSync(tmp, JSON.stringify(data), 'utf8'); renameSync(tmp, f) } catch (_) {}
4069
+ },
4070
+ load() { try { return JSON.parse(readFileSync(f, 'utf8')) } catch (_) { return null } },
4071
+ clear() { try { rmSync(f, { force: true }) } catch (_) {} },
4072
+ }
4073
+ }
4074
+ engine._memoryHub = createMemoryHubPre({
4075
+ stores: {
4076
+ episodic: createEpisodicStorePre({ config: { minSegments: Number(engine.config.episodicMinSegments) || 2, retention: Number(engine.config.episodicRetention) || 256 }, io: hubIo('episodes.json') }),
4077
+ facts: createFactStorePre({ io: hubIo('facts.json') }),
4078
+ procedures: createProcedureStorePre({
4079
+ // 2026-08-30 修复:gates 用 getter 活读 engine.config——挂载(apply 同步)时
4080
+ // engine.config 尚未 loadConfig,静态读会冻结 DEFAULT(3/2),设置页改门槛永不生效
4081
+ gates: {
4082
+ // 0 值合法(验证用);仅非有限负值回退默认——不可用 || 0 会 falsy 穿透
4083
+ get minSessionDiversity() { const v = Number(engine.config.procedureMinSessions); return (Number.isFinite(v) && v >= 0) ? v : 3 },
4084
+ get minSuccessCount() { const v = Number(engine.config.procedureMinSuccess); return (Number.isFinite(v) && v >= 0) ? v : 2 },
4085
+ get maxCorrectionRate() { return Number(engine.config.procedureCorrectionCap) || 0.3 },
4086
+ get maxContradictions() { return 0 },
4087
+ get highRiskRequiresApproval() { return engine.config.procedureHighRiskApproval !== false },
4088
+ },
4089
+ activeLevel: engine.config.procedureActiveLevel || 'checklist',
4090
+ io: hubIo('procedures.json'),
4091
+ }),
4092
+ },
4093
+ })
4094
+ try {
4095
+ // restore 逐条校验,坏记录跳过(fail closed 幂等恢复);无文件/损坏 → 空启动
4096
+ const eData = hubIo('episodes.json').load()
4097
+ if (eData) engine._memoryHub.stores.episodic.restore(eData)
4098
+ const fData = hubIo('facts.json').load()
4099
+ if (fData) engine._memoryHub.stores.facts.restore(fData)
4100
+ const pData = hubIo('procedures.json').load()
4101
+ if (pData) engine._memoryHub.stores.procedures.restore(pData)
4102
+ } catch (_) {}
4103
+ }
4104
+ // ---------- M10 存储管理编排器(2026-08-30 P3) ----------
4105
+ // 原语(docStore.replace / rebuildSidecar / activationHost.purgeMemory / factStore.revokeBySource)
4106
+ // 此前零调用方,此处组装成「扫描 → 修复 → 删除三联动」三个动作,经 loopback 端点与
4107
+ // 设置页「存储管理」消费。docStore 与 hub 均为懒加载 → 用 getter 活读,apply 同步阶段安全。
4108
+ engine._storageManager = createStorageManagerPre({
4109
+ get docStore() { return engine.docStore },
4110
+ io: { sidecarDir: path.join(dshHome(), 'memory', 'index', 'files'), readFileSync },
4111
+ pathsOf: () => (engine.state && engine.state.ws ? {
4112
+ workspaceKey: canonicalize(engine.state.ws),
4113
+ userMemoryPath: engine.state.userDir ? path.join(engine.state.userDir, 'MEMORY.md') : undefined,
4114
+ workspaceMemoryPath: engine.state.notesPath,
4115
+ todayLogPath: engine.state.logPath,
4116
+ } : null),
4117
+ activationHostOf: () => engine._activationHost || null,
4118
+ factStoreOf: () => (engine._memoryHub && engine._memoryHub.stores ? engine._memoryHub.stores.facts : null),
4119
+ })
4120
+
4121
+ // ---------- M8 啮合 P0②/P1④:judgement 文件队列喂数 + fact 治理式写回 ----------
4122
+ // 喂数:60s 轮询 semantic/judgement-shadow.jsonl(Python 建议行)增量 → heading 富化 →
4123
+ // hub.ingestJudgement。memoryHubEnabled 门;行无 subject/predicate(memoryId 级建议),
4124
+ // 富化用 sidecar 语料记录的 heading/text;不可解析的行跳过。js-decide-shadow.jsonl 不喂
4125
+ // (JS 判定行无候选载荷,JS 侧记忆供给走 episodic/crossFeed 直接入店)。
4126
+ // 写回:P1④ 主闭环——hub.facts 中 confirmed/未过期/未撤销/置信≥0.6 的事实,每日限额
4127
+ // 治理式写入 notesPath/userFile(autoConsolidate 同款 appendText 原子事务)→ 进 M7 语料。
4128
+ {
4129
+ const hubFeedState = { shadow: null } // 文件指纹 {size,mtimeMs,count}
4130
+ const hubFlushState = { date: '', count: 0, flushed: {} } // flushed[factId]=true;持久化 hub/flush-state.json
4131
+ const hubFlushFile = () => path.join(dshHome(), 'memory', 'hub', 'flush-state.json')
4132
+ const hubFlushLoad = () => {
4133
+ 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 (_) {}
4134
+ }
4135
+ const hubFlushSave = () => {
4136
+ try { mkdirSync(path.dirname(hubFlushFile()), { recursive: true }); writeFileSync(hubFlushFile(), JSON.stringify({ date: hubFlushState.date, count: hubFlushState.count, flushed: hubFlushState.flushed }), 'utf8') } catch (_) {}
4137
+ }
4138
+ hubFlushLoad()
4139
+ // 语料查询器(heading 富化用):与 context-host 同源 sidecar 目录,懒建缓存
4140
+ let hubCorpusCache = { key: '', snap: null }
4141
+ const hubCorpusLookup = async (memoryId) => {
4142
+ try {
4143
+ const rt = engine.currentRuntime()
4144
+ const p = await engine.resolvePaths(rt && rt.agent)
4145
+ const key = canonicalize(p.notesPath || p.ws || '')
4146
+ if (!key) return null
4147
+ if (hubCorpusCache.key !== key || !hubCorpusCache.snap) {
4148
+ const cat = buildSourceCatalog({ workspaceKey: p.ws || '', userMemoryPath: p.userFile, workspaceMemoryPath: p.notesPath, todayLogPath: p.logPath })
4149
+ if (!engine._hubCorpusRegistry) engine._hubCorpusRegistry = new CorpusRegistry({ sidecarDir: path.join(dshHome(), 'memory', 'index', 'files') })
4150
+ const res = engine._hubCorpusRegistry.get(cat)
4151
+ if (!(res && res.ok)) return null
4152
+ hubCorpusCache = { key, snap: res.snapshot }
4153
+ }
4154
+ return (hubCorpusCache.snap.records || []).find((r) => r.memoryId === memoryId) || null
4155
+ } catch (_) { return null }
4156
+ }
4157
+ const hubFeedTick = () => {
4158
+ try {
4159
+ if (engine.config.memoryHubEnabled !== true) return
4160
+ const hub = engine._memoryHub
4161
+ if (!hub || !hub.ingestJudgementRows) return
4162
+ const f = path.join(dshHome(), 'memory', 'semantic', 'judgement-shadow.jsonl')
4163
+ let st
4164
+ try { st = statSync(f) } catch (_) { return }
4165
+ const prev = hubFeedState.shadow
4166
+ if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs) return
4167
+ const lines = readFileSync(f, 'utf8').split('\n').filter((l) => l.trim())
4168
+ const start = prev && lines.length >= prev.count ? prev.count : lines.length // 文件被截断则只取尾部
4169
+ const newRows = []
4170
+ for (let i = start; i < lines.length; i++) {
4171
+ try { const d = JSON.parse(lines[i]); if (d && d.kindCandidate) newRows.push(d) } catch (_) {}
4172
+ }
4173
+ hubFeedState.shadow = { size: st.size, mtimeMs: st.mtimeMs, count: lines.length }
4174
+ if (!newRows.length) return
4175
+ // 异步富化+喂送(不阻塞 tick);单行失败静默
4176
+ void (async () => {
4177
+ let fed = 0
4178
+ for (const row of newRows) {
4179
+ try {
4180
+ const src = Array.isArray(row.sourceIds) ? row.sourceIds[0] : null
4181
+ if (src) {
4182
+ const rec = await hubCorpusLookup(src)
4183
+ if (rec) {
4184
+ row.subject = String(rec.heading || rec.text || '').split('\n')[0].slice(0, 30)
4185
+ row.predicate = '记录要点'
4186
+ row.object = String(rec.text || '').slice(0, 120)
4187
+ }
4188
+ }
4189
+ const r = hub.ingestJudgement(row)
4190
+ if (r && r.consumed) fed++
4191
+ } catch (_) {}
4192
+ }
4193
+ if (fed) diag('hub feed: +' + fed + '/' + newRows.length + ' rows consumed')
4194
+ })()
4195
+ } catch (_) {}
4196
+ }
4197
+ const hubFlushTick = async () => {
4198
+ try {
4199
+ if (engine.config.memoryHubEnabled !== true) return
4200
+ const hub = engine._memoryHub
4201
+ if (!hub || !hub.stores.facts) return
4202
+ const today = engine.memToday()
4203
+ if (hubFlushState.date !== today) { hubFlushState.date = today; hubFlushState.count = 0 }
4204
+ const DAILY_MAX = 8
4205
+ if (hubFlushState.count >= DAILY_MAX) return
4206
+ const facts = hub.stores.facts.query ? hub.stores.facts.query() : []
4207
+ const now = Date.now()
4208
+ let p = null
4209
+ for (const fact of facts) {
4210
+ if (hubFlushState.count >= DAILY_MAX) break
4211
+ if (!fact || fact.revoked || hubFlushState.flushed[fact.factId]) continue
4212
+ if (fact.ttl && now >= (fact.confirmedAt || 0) + fact.ttl) continue
4213
+ if (typeof fact.confidence === 'number' && fact.confidence < 0.6) continue
4214
+ const subj = String(fact.subject || '').trim()
4215
+ if (!subj || subj.startsWith('mem_')) continue // 无富化的 memoryId 主语不入正文
4216
+ try {
4217
+ if (!p) p = await engine.resolvePaths(engine.currentRuntime().agent)
4218
+ const target = fact.scope === 'User' ? p.userFile : p.notesPath
4219
+ if (!target) continue
4220
+ const cur = await engine.readTextSafe(target)
4221
+ if (cur && cur.includes(subj)) { hubFlushState.flushed[fact.factId] = true; continue } // 已在正文,只记标记
4222
+ const body = '\n## ' + subj + '(M8 固化)\n- ' + String(fact.predicate || '要点') + (fact.object ? ':' + String(fact.object) : '') + '\n- 来源:记忆中枢治理固化' + (typeof fact.confidence === 'number' ? '(confidence=' + fact.confidence.toFixed(2) + ')' : '')
4223
+ const written = await engine.appendText(target, body)
4224
+ try { if (engine.state) { if (fact.scope === 'User') engine.state.userText = written; else engine.state.notesText = written } } catch (_) {}
4225
+ hubFlushState.flushed[fact.factId] = true
4226
+ hubFlushState.count++
4227
+ diag('hub flush: fact ' + String(fact.factId).slice(0, 16) + ' → ' + (fact.scope === 'User' ? 'user' : 'notes'))
4228
+ } catch (_) {}
4229
+ }
4230
+ hubFlushSave()
4231
+ } catch (_) {}
4232
+ }
4233
+ const hubFeedTimer = setInterval(hubFeedTick, 60 * 1000)
4234
+ const hubFlushTimer = setInterval(() => { void hubFlushTick() }, 30 * 60 * 1000)
4235
+ // unref:定时器不阻止进程退出(测试 settle 不经过 apply 的 disposer 链会挂住)
4236
+ hubFeedTimer.unref(); hubFlushTimer.unref()
4237
+ const hubBootTimer = setTimeout(() => { hubFeedTick(); void hubFlushTick() }, 90 * 1000)
4238
+ hubBootTimer.unref()
4239
+ if (!engine._hubFeedDisposers) engine._hubFeedDisposers = []
4240
+ engine._hubFeedDisposers.push(() => { clearInterval(hubFeedTimer); clearInterval(hubFlushTimer) })
4241
+ }
4242
+ // C2 内置语义引擎宿主(2026-08-26 用户裁定:C2=默认主路径)。懒加载 e5-small q8;
4243
+ // 只做检索排序,激活决策仍属两车道策略。下载器落位=发行包布局 lib/models。
4244
+ {
4245
+ const pluginDir = path.dirname(fileURLToPath(import.meta.url))
4246
+ engine._jsSemantic = createJsSemanticEnginePre({ pluginDir })
4247
+ engine._jsDownload = createSemanticDownloaderPre({ modelsRoot: path.join(pluginDir, 'models') })
4248
+ // 资产探测(semantic-status / 档位解析 / 引导卡共用):发行包 models/ 优先,其次开发树
4249
+ engine.semanticAssetProbe = async () => {
4250
+ const fsMod = await import('node:fs')
4251
+ const cands = [
4252
+ path.join(pluginDir, 'models', 'multilingual-e5-small', 'onnx', 'model_quantized.onnx'),
4253
+ path.join(pluginDir, '..', 'artifacts', 'm7-live-pre', 'js-semantic-trial', 'models', 'multilingual-e5-small', 'onnx', 'model_quantized.onnx'),
4254
+ ]
4255
+ const modelOnnx = cands.find((c) => fsMod.existsSync(c)) || cands[0]
4256
+ const peerCands = [
4257
+ path.join(pluginDir, 'node_modules', '@huggingface', 'transformers'),
4258
+ path.join(pluginDir, '..', 'artifacts', 'm7-live-pre', 'js-semantic-trial', 'node_modules', '@huggingface', 'transformers'),
4259
+ ]
4260
+ const peerDir = peerCands.find((c) => fsMod.existsSync(c)) || peerCands[0]
4261
+ const assetPresent = fsMod.existsSync(modelOnnx)
4262
+ return {
4263
+ assetPresent,
4264
+ peerPresent: fsMod.existsSync(peerDir),
4265
+ ready: Boolean(assetPresent && fsMod.existsSync(peerDir)),
4266
+ assetBytes: assetPresent ? fsMod.statSync(modelOnnx).size : 0,
4267
+ assetPath: modelOnnx,
4268
+ }
4269
+ }
4270
+ // 档位解析:lexical→C1 强制保底;其余(auto/js/python)在资产就绪时启用 C2 臂
4271
+ // (python 模式下 C2 只改善 envelope.refs/candidateHit,sink 检索仍归 sidecar)。
4272
+ engine.resolveSemanticTier = async () => {
4273
+ const mode = String(engine.config.semanticEngineMode || 'auto')
4274
+ // 2026-08-27 修正:区分引擎模式(修复「怎么切都显示 C2」)。
4275
+ // lexical→c1 强制;python→c3(高级档);auto/js→资产就绪 c2 否则 c1。
4276
+ if (mode === 'lexical') return 'c1'
4277
+ if (mode === 'python') return 'c3'
4278
+ try { return (await engine.semanticAssetProbe()).ready ? 'c2' : 'c1' } catch (_) { return 'c1' }
4279
+ }
4280
+ // context-host refs 选择钩子:C2 就绪时返回 {scores:Map};任何失败回退词法序。
4281
+ engine._jsSemanticRank = async (corpusSnap, queryText) => {
4282
+ try {
4283
+ if ((await engine.resolveSemanticTier()) !== 'c2') return null
4284
+ return await engine._jsSemantic.rank(corpusSnap, queryText)
4285
+ } catch (_) { return null }
4286
+ }
4287
+ // JS 端判定核(2026-08-27):读策略工件(懒加载+缓存),对 C2 检索结果做 fv2 决策。
4288
+ // 完全独立于 Python——JS 端默认闭环(C2 检索 + JS 判定 + M6 投递)的核心。
4289
+ engine._jsDecideCtx = null
4290
+ engine._jsDecideErr = ''
4291
+ engine.ensureJsDecide = () => {
4292
+ try {
4293
+ if (engine._jsDecideCtx) return engine._jsDecideCtx
4294
+ // 策略工件多路径探测:JS 侧 lib/policies 优先(纯 JS 部署必达,npm 包 JS-only 也含);
4295
+ // fallback python/policies(兼容旧部署)。纯 JS 用户系统无 Python,必须能从 JS 侧读到。
4296
+ const pluginDir = path.dirname(fileURLToPath(import.meta.url))
4297
+ const jsPolicyDir = path.join(pluginDir, 'policies')
4298
+ const pyPolicyDir = path.join(pluginRootDir(), 'python', 'policies')
4299
+ const jsIntent = path.join(jsPolicyDir, 'recall_intent_lr_v1.json')
4300
+ const jsAct = path.join(jsPolicyDir, 'activation_policy_v2.json')
4301
+ const intentPath = existsSync(jsIntent) ? jsIntent : path.join(pyPolicyDir, 'recall_intent_lr_v1.json')
4302
+ const actPath = existsSync(jsAct) ? jsAct : path.join(pyPolicyDir, 'activation_policy_v2.json')
4303
+ engine._jsDecideCtx = loadAndVerifyPolicy(intentPath, actPath)
4304
+ return engine._jsDecideCtx
4305
+ } catch (e) {
4306
+ engine._jsDecideErr = String(e && e.message || e).slice(0, 160)
4307
+ return null
4308
+ }
4309
+ }
4310
+ // JS 发射门(2026-08-27):读 embedding-config.json 的 activationEmitMode(与 Python worker 同源)。
4311
+ // shadow=只记录不注入;canary-explicit/active=注入。JS 判定 emit 前必须过此门。
4312
+ // 5 秒缓存避免每次判定都读盘(设置页 semantic-emit 写后 5 秒内生效)。
4313
+ engine._jsEmitCache = { at: 0, mode: '' }
4314
+ engine.jsEmitMode = () => {
4315
+ const now = Date.now()
4316
+ if (now - engine._jsEmitCache.at < 5000) return engine._jsEmitCache.mode || 'shadow'
4317
+ try {
4318
+ const raw = JSON.parse(readFileSync(path.join(dshHome(), 'memory', 'semantic', 'embedding-config.json'), 'utf8'))
4319
+ const em = String((raw && raw.activationEmitMode) || 'shadow')
4320
+ const mode = ['shadow', 'canary-explicit', 'active'].includes(em) ? em : 'shadow'
4321
+ engine._jsEmitCache = { at: now, mode }
4322
+ return mode
4323
+ } catch (_) { return 'shadow' }
4324
+ }
4325
+ // JS 判定入口:输入 C2 排名结果 + envelope 上下文,输出 fv2 决策。
4326
+ // 返回 { ok, decision, lane, reasonCodes, features } 或 { ok:false }。
4327
+ engine._jsDecide = async (queryText, rankRes, envelope) => {
4328
+ try {
4329
+ const ctx = engine.ensureJsDecide()
4330
+ if (!ctx) return { ok: false, reason: 'policy-unavailable: ' + engine._jsDecideErr }
4331
+ // 从 rankRes 构建特征:candidateHit=refs 与语义候选重叠
4332
+ const refIds = new Set((envelope && envelope.memoryRefs || []).map((r) => r.memoryId))
4333
+ const scores = rankRes && rankRes.scores
4334
+ // 2026-08-28 孪生对齐(浏览器实调发现):Python fv2 的候选池=稠密 top-8(SHADOW_TOP_K),
4335
+ // 按 D6 融合序重排,margin=融合第1/2名的稠密分差(worker_semantic_v1.py dense_search
4336
+ // top_k=8 + hybrid_rank + candidates[0/1]['score'])。JS 旧实现=全量裸稠密分 top1-top2,
4337
+ // 在致密语料(105条同项目记忆)中恒 <deltaExp → emit 永不触发,与 held-out 校准分布脱节。
4338
+ let candIds = []
4339
+ let denseTop = 0
4340
+ let margin = 0
4341
+ let nCand = 0
4342
+ if (scores && scores.size) {
4343
+ const lexMap = (rankRes._lex instanceof Map) ? rankRes._lex : new Map()
4344
+ const denseSorted = [...scores.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)
4345
+ const fusedOrder = fuseD6Pre(denseSorted.map(([id, d]) => ({ memoryId: id, dense: d, lex: lexMap.get(id) || 0 })))
4346
+ const ordered = (fusedOrder.length ? fusedOrder : denseSorted.map(([id]) => ({ memoryId: id })))
4347
+ .map((f) => ({ memoryId: f.memoryId, dense: scores.get(f.memoryId) || 0 }))
4348
+ candIds = ordered.map((c) => c.memoryId)
4349
+ denseTop = ordered.length ? ordered[0].dense : 0
4350
+ margin = ordered.length > 1 ? Math.max(0, denseTop - ordered[1].dense) : (ordered.length ? denseTop : 0)
4351
+ nCand = ordered.length
4352
+ }
4353
+ const candidateHit = [...refIds].some((id) => candIds.includes(id))
4354
+ const topRec = candIds.length ? ((rankRes._records || []).find((r) => r.memoryId === candIds[0]) || null) : null
4355
+ const containment = topRec ? lexicalContainment(queryText, topRec.text || '') : 0
4356
+ const tl = String(queryText || '').toLowerCase()
4357
+ const mark = (['?', '?', '什么', '如何', '怎么', '哪些', '哪个', '为什么', '多少', '吗', '呢', '是不是', '有没有', '之前', '上次', '当时'].some((k) => tl.includes(k))) ? 1 : 0
4358
+ const features = {
4359
+ text: String(queryText || '').slice(0, 2000),
4360
+ denseTop, margin, containment, mark,
4361
+ nCand, candidateHit,
4362
+ hardGates: {},
4363
+ requiresRelayFlag: false, piiClass: 'unknown',
4364
+ }
4365
+ // 2026-08-28 e5 校准增量:JS 档用配置覆盖 deltaExp(克隆 policy,冻结决策核不动)。
4366
+ // bge-m3 校准的 0.03 对 e5 的压缩分布过严(live 实测 margin 0-0.0284 全被拦)。
4367
+ let policy = ctx.policy
4368
+ const deltaOverride = Number(engine.config.jsDecideDeltaExp)
4369
+ if (Number.isFinite(deltaOverride) && deltaOverride >= 0 && policy && policy.thresholds) {
4370
+ policy = Object.assign({}, policy, { thresholds: Object.assign({}, policy.thresholds, { deltaExp: deltaOverride }) })
4371
+ }
4372
+ const out = decideActivationV2(features, ctx.head, policy)
4373
+ return { ok: true, decision: out.decision, lane: out.lane, reasonCodes: out.reasonCodes, features: out.features, _margin: margin, _denseTop: denseTop, _candN: nCand, _hit: candidateHit }
4374
+ } catch (e) {
4375
+ return { ok: false, reason: String(e && e.message || e).slice(0, 160) }
4376
+ }
4377
+ }
4378
+ }
4379
+ try { engine._activationHost.initCapability(ctx) } catch (_) {}
4380
+ // M7-0/M7-1:engine 级共享 SidecarClient(对象构造零副作用;进程仅在显式启用路径上 lazy spawn,
4381
+ // 默认关闭=零 Python process、零协议 IO、零 semantic 目录)
4382
+ engine._pythonSidecar = createPythonSidecarClientPre({
4383
+ command: () => String(engine.config.pythonBackendExecutable || '').trim() || 'python',
4384
+ scriptPath: () => String(engine.config.pythonBackendWorkerPath || '').trim() || defaultWorkerScriptPathPre(),
4385
+ dshHome: () => {
4386
+ const env = process.env.DSH_HOME
4387
+ if (env && env.trim()) return env.trim()
4388
+ try { return path.join(homedir(), '.dsh') } catch (_) { return '' }
4389
+ },
4390
+ })
4391
+ engine.__homedirFn = homedir
2624
4392
  const sessionQuery = ctx.get('sessionQuery')
2625
4393
  engine._sessionQuery = sessionQuery
2626
4394
  engine._subagents = ctx.get('subagents') || undefined
2627
4395
  // 时间检测用的服务(软获取,缺失时定时兜底自动降级)
2628
4396
  engine._sessionsSvc = ctx.get('sessions') || undefined
2629
4397
  engine._agentSvc = ctx.get('agent') || undefined
4398
+ // 模型目录(设置页「总结/问候默认模型」抽屉数据源);软获取,缺失时抽屉回退手动输入
4399
+ engine._llm = ctx.get('llm') || undefined
2630
4400
 
2631
4401
  // 生命周期刷新
2632
- const refreshAll = (agent) => { void engine.refresh(agent) }
4402
+ const refreshAll = (agent) => {
4403
+ void engine.refresh(agent).then(() => {
4404
+ // M4-3:refresh 完成后同步捕获 paths 快照供 Shadow 调度(§7.2 禁止异步 tick 内裸调 resolvePaths)
4405
+ try { if (engine._shadowHost && agent && engine.state.ws) engine._shadowHost.capturePaths(engine.runtimeFor(agent).key, engine.state) } catch (_) {}
4406
+ // M5-3:同一 paths 快照共享给 Context Bridge(仅相对投影,不落盘路径)
4407
+ try { if (engine._contextHost && agent && engine.state.ws) engine._contextHost.capturePaths(engine.runtimeFor(agent).key, engine.state) } catch (_) {}
4408
+ // M6-3:同一 paths 快照共享给 Activation Inbox(corpus 查询用)
4409
+ try { if (engine._activationHost && agent && engine.state.ws) engine._activationHost.capturePaths(engine.runtimeFor(agent).key, engine.state) } catch (_) {}
4410
+ }).catch(() => {})
4411
+ }
2633
4412
  refreshAll()
2634
4413
  // 自动检查更新:host 启动时查一次 npm registry(结果缓存 12 小时,设置页打开直接读缓存显示)
2635
4414
  void engine.checkUpdate(false)
@@ -2638,23 +4417,41 @@ export function apply(ctx, config) {
2638
4417
  const noticesTimer = setInterval(noticesRefresh, 3600 * 1000)
2639
4418
  void engine.fetchNotices(false).then((l) => { engine._noticesCache = l }).catch(() => {})
2640
4419
  ctx.on('agent/session-start', (payload) => {
2641
- refreshAll(payload && payload.agent)
2642
- // 缓存最近 agent 引用(subagent parent 需要完整 agent 对象)
2643
- try { if (payload && payload.agent) engine._lastAgent = payload.agent } catch (e) {}
4420
+ // M0/M1: 建立该 agent/session 的 runtime(WeakMap/Map 登记),之后所有状态按 session 隔离。
4421
+ // 审查修复轮2:生命周期入口同样走严格身份——无 session 身份的对象不得经 runtimeFor 伪造匿名 runtime,
4422
+ // 也不得进入 _lastAgent(subagent parent 需要 session)
4423
+ try {
4424
+ if (payload && payload.agent && engine.hasReliableSessionIdentity(payload.agent)) {
4425
+ const rt = engine.runtimeFor(payload.agent)
4426
+ engine._lastAgent = payload.agent
4427
+ // M2.4: 对已存在的 session.events 按原生 seq 补放(live feed 之后从游标续接去重)
4428
+ engine.seedRuntimeFromSession(payload.agent)
4429
+ engine.ingestAgentLifecycle(payload.agent, 'agent/session-start', { payload: { source: boundedStr(payload && payload.source) } })
4430
+ }
4431
+ } catch (e) {}
4432
+ // 审查修复轮3:refreshAll 同样受严格身份守卫 —— 匿名对象不得把 cwd 写进 default runtime state
4433
+ const ssa = payload && payload.agent
4434
+ if (!ssa || engine.hasReliableSessionIdentity(ssa)) refreshAll(ssa)
2644
4435
  })
2645
4436
  ctx.on('agent/turn-stopping', (payload) => {
2646
- refreshAll(payload && payload.agent)
2647
- // 记录最后活动时间(判断暂离回来用)
2648
- try { engine._lastActiveAt = Date.now() } catch (e) {}
4437
+ // 审查修复轮3:refreshAll 只对可靠身份 agent 执行,匿名对象不得污染 default runtime state
4438
+ const evtAgent = payload && payload.agent
4439
+ if (!evtAgent || engine.hasReliableSessionIdentity(evtAgent)) refreshAll(evtAgent)
2649
4440
  // 每轮自动沉淀:取本轮消息 → subagent 判断/提炼 → 写今日日志([自动沉淀])+升格长期记忆
2650
4441
  try {
2651
- const agent = (payload && payload.agent) || engine._lastAgent
2652
- const hasAgent = !!(agent && agent.session)
2653
- diag('turn-stopping fired: turn=' + JSON.stringify(payload && payload.turn) + ' hasAgent=' + hasAgent + ' payloadKeys=' + (payload ? Object.keys(payload).join(',') : 'null') + ' _lastAgent=' + !!(engine._lastAgent && engine._lastAgent.session))
4442
+ const agent = evtAgent
4443
+ // 审查修复轮3:有效性判断必须是严格 session 身份 —— !!agent.session 会放行无身份对象
4444
+ const hasAgent = !!(agent && engine.hasReliableSessionIdentity(agent))
4445
+ if (hasAgent) {
4446
+ // 最后活动时间写入该 agent 自己的 runtime(不再落到 currentRuntime()/default)
4447
+ try { engine.runtimeFor(agent).lastActiveAt = Date.now() } catch (e) {}
4448
+ try { engine.ingestAgentLifecycle(agent, 'agent/turn-stopping', { turn: payload && payload.turn, payload: { turn: payload && payload.turn } }) } catch (e) {}
4449
+ }
4450
+ diag('turn-stopping fired: turn=' + JSON.stringify(payload && payload.turn) + ' hasAgent=' + hasAgent + ' payloadKeys=' + (payload ? Object.keys(payload).join(',') : 'null'))
2654
4451
  if (hasAgent) {
2655
4452
  // 延迟到 turn-stopping 收尾完成后再启动 subagent,避免与 DSH 会话收尾竞争导致进程级崩溃
2656
4453
  setTimeout(() => {
2657
- void engine.consolidateTurn(payload.turn, agent).catch((e) => console.error('[dsh-auto-memory] consolidateTurn unhandled', e && (e.stack || e.message) || e))
4454
+ void engine.withAgent(agent, () => engine.consolidateTurn(payload.turn, agent)).catch((e) => console.error('[dsh-auto-memory] consolidateTurn unhandled', e && (e.stack || e.message) || e))
2658
4455
  }, 600)
2659
4456
  }
2660
4457
  } catch (e) { diag('turn-stopping handler error: ' + (e && e.message)) }
@@ -2664,20 +4461,45 @@ export function apply(ctx, config) {
2664
4461
  // 首轮(loadedAt=0)必定 await 完 → 模型从第一个 token 起就看到记忆;之后每 15s 跟进轮间新写入。
2665
4462
  ctx.on('agent/pre-step', async (payload, next) => {
2666
4463
  try {
2667
- const agent = (payload && payload.agent) || engine._lastAgent
2668
- // 兜底恢复 _lastAgent(仅启动后首次为空时设置一次,不每步覆盖):重启后恢复的会话不触发 session-start,
2669
- // 导致 turn-stopping 自动沉淀拿不到 agent 而永久失效;首次 pre-step 即恢复,之后由 session-start 正常维护
2670
- try { if (!engine._lastAgent && agent && agent.session && agent.session.header && agent.session.header.parentSession === undefined) { engine._lastAgent = agent; diag('pre-step recovered _lastAgent id=' + agent.id) } } catch (e) {}
2671
- // 只刷新顶层会话:子代理(自动沉淀/固化的 subagent)session 无 cwd,刷新会把 state 切到错误工作区
2672
- let skip = false
2673
- try { if (agent && agent.session && agent.session.header && agent.session.header.parentSession) skip = true } catch (e) {}
2674
- if (!skip && agent && (!engine.state.loadedAt || Date.now() - engine.state.loadedAt > 15000)) {
2675
- await engine.refresh(agent)
4464
+ const agent = payload && payload.agent // M1: 精确取当前 agent 的 runtime,不猜
4465
+ // 审查修复轮2:无 session 身份的 agent 不登记 runtime、不进 stateFor/refresh,直接放行
4466
+ if (agent && engine.hasReliableSessionIdentity(agent)) {
4467
+ // 首步即登记 runtime(重启恢复的会话也可能不触发 session-start,这里补登记)
4468
+ const rt = engine.runtimeFor(agent)
4469
+ // M7.5:pre-step 边界先冲刷 CoT 缓冲——保证本轮 context_push 的 window 携带最新思维链
4470
+ try { engine.flushReasoningBuffer(rt) } catch (_) {}
4471
+ engine.ingestAgentLifecycle(agent, 'agent/pre-step', { turn: payload && payload.turn, step: payload && payload.step, payload: { turn: payload && payload.turn, step: payload && payload.step } })
4472
+ // M6-3:pre-step 时序(§8)——校验 cursor/index/TTL 后 claim packet,等待渲染面消费
4473
+ try { if (engine._activationHost) engine._activationHost.onPreStep(agent) } catch (_) {}
4474
+ // 只刷新顶层会话:子代理(自动沉淀/固化的 subagent)session 无 cwd,刷新会把 state 切到错误工作区
4475
+ let skip = false
4476
+ try { if (agent.session && agent.session.header && agent.session.header.parentSession) skip = true } catch (e) {}
4477
+ const st = engine.stateFor(agent)
4478
+ if (!skip && (!st.loadedAt || Date.now() - st.loadedAt > 15000)) {
4479
+ await engine.refresh(agent)
4480
+ }
2676
4481
  }
2677
4482
  } catch (e) {}
2678
4483
  return next()
2679
4484
  })
2680
4485
 
4486
+ // M0/M1: agent/session 销毁时清理对应 runtime(abort 挂起任务 + 从 store 移除;M2 同步清空 ring/pending)
4487
+ ctx.on('agent/disposed', (payload) => {
4488
+ try { if (payload && payload.agent) engine.disposeAgent(payload.agent) } catch (e) {}
4489
+ })
4490
+ ctx.on('session/disposed', (session) => {
4491
+ try { engine.runtimes.disposeSession(session) } catch (e) {}
4492
+ })
4493
+
4494
+ // M2.1: 结构化事件入口 —— session/event(post-commit append feed)+ frozen tools/result(执行级最终观察点)。
4495
+ // 只观察、有界最小投影;无可靠 owner 的事件被丢弃留痕,绝不落入 default runtime。
4496
+ ctx.on('session/event', (session, event) => {
4497
+ try { engine.observeSessionEvent(session, event) } catch (e) {}
4498
+ })
4499
+ ctx.on('tools/result', (exec, result) => {
4500
+ try { engine.observeToolResult(exec, result) } catch (e) {}
4501
+ })
4502
+
2681
4503
  // ---------- 系统提示词注入 ----------
2682
4504
  // 动态记忆 → ctx.systemPrompt.context():渲染为 user-role 快照追加在历史尾部,内容不变不重复注入(dsh-agent-loop project() 去重)。
2683
4505
  // system prompt 不再包含动态内容 → 字节级稳定 → DeepSeek 前缀缓存全程命中(自动沉淀/跨天/切模型都不再击穿前缀)。
@@ -2688,10 +4510,63 @@ export function apply(ctx, config) {
2688
4510
  try {
2689
4511
  const agent = context && context.agent
2690
4512
  if (!agent) return ''
2691
- if (!engine.state.loadedAt || Date.now() - engine.state.loadedAt > 15000) {
2692
- void engine.refresh(agent)
2693
- }
2694
- return engine.renderMemoryDynamic(context) + engine.renderReflectionRequest()
4513
+ // M0/M1: 绑定到该 agent 的 runtime 再读取(state per-session getter,避免落到 default runtime 读到空)
4514
+ return engine.withAgent(agent, () => {
4515
+ const st = engine.stateFor(agent)
4516
+ if (!st.loadedAt || Date.now() - st.loadedAt > 15000) {
4517
+ void engine.refresh(agent)
4518
+ }
4519
+ // 2026-08-27 频率控制(在调用方做,不破坏 renderMemoryDynamic 契约):
4520
+ // 指纹只基于"日志段"(recentLogs)——日志是每轮微变源。日志变化且距上次注入不足
4521
+ // snapshotMinGapRounds 轮 → 暂缓;反思/用户/笔记/欢迎等结构性变化始终立即注入。
4522
+ // 内容未变则正常返回(由 project 去重)。状态 per-agent(挂 runtime.state)防 A/B 串线。
4523
+ const snap = engine.renderMemoryDynamic(context)
4524
+ const gap = Math.max(0, Number(engine.config.snapshotMinGapRounds) || 5)
4525
+ if (gap > 0 && snap) {
4526
+ const st = engine.stateFor(agent)
4527
+ // 2026-08-27 压缩检测:上下文压缩/截断常伴随 contextVersion 重置(归零/倒退)。
4528
+ // 开启 snapshotReinjectOnCompact 时,压缩后绕过间隔立即重注入(快照被清掉后必须重建)。
4529
+ try {
4530
+ const rt = engine.runtimeFor(agent)
4531
+ const cv = (rt && rt.contextVersion) || 0
4532
+ if (engine.config.snapshotReinjectOnCompact !== false && st._snapCv !== undefined && cv < st._snapCv) {
4533
+ st._snapLogFp = '' // 强制重注入:清掉指纹,下个分支当"日志变化"处理
4534
+ st._snapPendingSnap = null
4535
+ st._snapPendingLogFp = null
4536
+ }
4537
+ st._snapCv = cv
4538
+ } catch (_) {}
4539
+ // 日志段指纹:从快照中截出"最近 N 天工作日志"段(含标题行到下一个 [ 段)
4540
+ const logMatch = snap.match(/\[最近 \d+ 天工作日志[^\]]*\][\s\S]*?(?=\n\[|\n<memory_system>|\n$)/)
4541
+ const logFp = createHash('sha256').update(logMatch ? logMatch[0] : '').digest('hex').slice(0, 16)
4542
+ if (logFp !== (st._snapLogFp || '')) {
4543
+ // 日志变化:距上次注入不足间隔 → 暂缓
4544
+ st._snapRound = (st._snapRound || 0) + 1
4545
+ if (st._snapFp === undefined) {
4546
+ st._snapFp = snap
4547
+ st._snapLogFp = logFp
4548
+ st._snapLastRound = st._snapRound
4549
+ } else if (st._snapRound - (st._snapLastRound || 0) < gap) {
4550
+ st._snapPendingSnap = snap
4551
+ st._snapPendingLogFp = logFp
4552
+ return engine.renderReflectionRequest()
4553
+ } else {
4554
+ st._snapFp = snap
4555
+ st._snapLogFp = logFp
4556
+ st._snapLastRound = st._snapRound
4557
+ }
4558
+ } else if (st._snapPendingSnap && logFp === st._snapPendingLogFp) {
4559
+ // 日志已稳定到间隔:放行暂缓版本
4560
+ st._snapFp = st._snapPendingSnap
4561
+ st._snapLogFp = logFp
4562
+ st._snapLastRound = st._snapRound
4563
+ st._snapPendingSnap = null
4564
+ st._snapPendingLogFp = null
4565
+ return snap + engine.renderReflectionRequest()
4566
+ }
4567
+ }
4568
+ return snap + engine.renderReflectionRequest()
4569
+ })
2695
4570
  } catch (e) { return '' }
2696
4571
  },
2697
4572
  })
@@ -2703,6 +4578,20 @@ export function apply(ctx, config) {
2703
4578
  try { return engine.renderMemoryStatic() } catch (e) { return '' }
2704
4579
  },
2705
4580
  })
4581
+ // M6-3:专用动态 Reference Tail context surface('dynamic-context' capability)。
4582
+ // 仅渲染已 claimed 的 packet;返回非空=实际进入下一请求 messages → 同步 markDelivered + seen。
4583
+ // systemPrompt.section 永不承载该动态内容。
4584
+ const disposeTailSurface = ctx.systemPrompt.context({
4585
+ name: 'dsh:m6-reference-tail-pre',
4586
+ order: SECTION_ORDER + 1,
4587
+ text: (context) => {
4588
+ try {
4589
+ const agent = context && context.agent
4590
+ if (!agent || !engine._activationHost) return ''
4591
+ return engine.withAgent(agent, () => engine._activationHost.renderTailFor(agent)) || ''
4592
+ } catch (e) { return '' }
4593
+ },
4594
+ })
2706
4595
 
2707
4596
  // ---------- 工具 ----------
2708
4597
  const tools = [
@@ -2896,6 +4785,340 @@ export function apply(ctx, config) {
2896
4785
 
2897
4786
  // ---------- 路由 ----------
2898
4787
  const routes = [
4788
+ {
4789
+ kind: 'exact',
4790
+ path: API['semantic-status'],
4791
+ handler: async (req, res) => {
4792
+ // M7.5 C2 资产检测(只读;loopback):供首启向导/设置页判断语义引擎就绪状态
4793
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
4794
+ try {
4795
+ const probe = await engine.semanticAssetProbe()
4796
+ const pyCands = [
4797
+ path.join(pluginRootDir(), 'python', 'bench', 'models-xenova-bge-m3-int8', 'onnx', 'model_int8.onnx'),
4798
+ ]
4799
+ const fsMod = await import('node:fs')
4800
+ const pyOnnx = pyCands.find((c) => fsMod.existsSync(c)) || pyCands[0]
4801
+ // 发射开关读数(memory/semantic/embedding-config.json 的
4802
+ // activationEmitMode;worker 同源读取,缺省 shadow=fail closed)
4803
+ let activationEmitMode = 'shadow'
4804
+ try {
4805
+ const emRaw = JSON.parse(fsMod.readFileSync(path.join(dshHome(), 'memory', 'semantic', 'embedding-config.json'), 'utf8'))
4806
+ const em = String((emRaw && emRaw.activationEmitMode) || 'shadow')
4807
+ if (['shadow', 'canary-explicit', 'active'].includes(em)) activationEmitMode = em
4808
+ } catch (_) {}
4809
+ const resolvedTier = await engine.resolveSemanticTier()
4810
+ return writeJson(res, 200, {
4811
+ engineDefault: 'js',
4812
+ activationEmitMode,
4813
+ resolvedTier,
4814
+ jsSemantic: engine._jsSemantic ? engine._jsSemantic.status() : null,
4815
+ download: engine._jsDownload ? engine._jsDownload.state() : null,
4816
+ manifestBytes: E5_SMALL_Q8_MANIFEST_V1.totalBytes,
4817
+ assetPresent: probe.assetPresent,
4818
+ peerPresent: probe.peerPresent,
4819
+ ready: probe.ready,
4820
+ assetBytes: probe.assetBytes,
4821
+ assetPath: probe.assetPath,
4822
+ pythonInt8Present: fsMod.existsSync(pyOnnx),
4823
+ pythonInt8Bytes: fsMod.existsSync(pyOnnx) ? fsMod.statSync(pyOnnx).size : 0,
4824
+ tuning: {
4825
+ tauHi: null, deltaExp: null, deltaPro: null, // 由策略 JSON 权威;设置页只读展示
4826
+ },
4827
+ note: 'C3 python int8 tier optional; fp32 suspended per 2026-08-26 ruling',
4828
+ })
4829
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
4830
+ },
4831
+ },
4832
+ {
4833
+ kind: 'exact',
4834
+ path: API['semantic-download'],
4835
+ handler: async (req, res) => {
4836
+ // C2 资产下载器(POST start/cancel;loopback-only)。GET 不支持——进度走 semantic-status。
4837
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
4838
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
4839
+ const body = await readJsonBody(req)
4840
+ const action = String((body && body.action) || '')
4841
+ if (action === 'start') {
4842
+ const r = engine._jsDownload.start(body && body.mirror)
4843
+ return writeJson(res, r.ok ? 200 : 400, Object.assign({ action: 'start' }, r))
4844
+ }
4845
+ if (action === 'cancel') {
4846
+ const r = engine._jsDownload.cancel()
4847
+ return writeJson(res, r.ok ? 200 : 409, Object.assign({ action: 'cancel' }, r))
4848
+ }
4849
+ return writeJson(res, 400, { error: 'invalid action' })
4850
+ },
4851
+ },
4852
+ {
4853
+ kind: 'exact',
4854
+ path: API['semantic-emit'],
4855
+ handler: async (req, res) => {
4856
+ // 唤起注入模式(POST {mode:shadow|canary-explicit|active};loopback-only)。
4857
+ // 写入 embedding-config.json 的 activationEmitMode,JS/Python 双轨同源读取。
4858
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
4859
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
4860
+ const body = await readJsonBody(req).catch(() => ({}))
4861
+ const mode = String((body && body.mode) || '')
4862
+ if (!['shadow', 'canary-explicit', 'active'].includes(mode)) return writeJson(res, 400, { error: 'invalid mode' })
4863
+ try {
4864
+ const fsMod = await import('node:fs')
4865
+ const pathMod = await import('node:path')
4866
+ const cfgPath = pathMod.join(dshHome(), 'memory', 'semantic', 'embedding-config.json')
4867
+ let cfg = {}
4868
+ try { cfg = JSON.parse(fsMod.readFileSync(cfgPath, 'utf8')) } catch (_) {}
4869
+ if (!cfg || typeof cfg !== 'object') cfg = {}
4870
+ cfg.activationEmitMode = mode
4871
+ fsMod.mkdirSync(pathMod.dirname(cfgPath), { recursive: true })
4872
+ fsMod.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2), 'utf8')
4873
+ return writeJson(res, 200, { ok: true, mode })
4874
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
4875
+ },
4876
+ },
4877
+ {
4878
+ kind: 'exact',
4879
+ path: API['shadow-recent'],
4880
+ handler: async (req, res) => {
4881
+ // G-02 前置(只读;loopback):返回最近 shadow 决策行的脱敏投影,供「语料精修」面板渲染 A/P/S/H/E
4882
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
4883
+ try {
4884
+ const fsMod = await import('node:fs')
4885
+ const pathMod = await import('node:path')
4886
+ const file = pathMod.join(dshHome(), 'memory', 'semantic', 'activation-shadow-v2.jsonl')
4887
+ if (!fsMod.existsSync(file)) return writeJson(res, 200, { rows: [] })
4888
+ const all = fsMod.readFileSync(file, 'utf8').split('\n').filter(Boolean)
4889
+ const tail = all.slice(-24).map((l) => {
4890
+ try {
4891
+ const r = JSON.parse(l)
4892
+ const f = r.features || {}
4893
+ return {
4894
+ observationId: r.observationId, decision: r.decision,
4895
+ reasonCodes: r.reasonCodes || [], lane: (f && f.lane) || r.lane || null,
4896
+ intentProb: (f && f.intentProb) != null ? f.intentProb : null,
4897
+ margin: (f && f.margin) != null ? f.margin : null,
4898
+ candidateHit: r.candidateHit != null ? r.candidateHit : null,
4899
+ maxLexRaw: r.maxLexRaw != null ? r.maxLexRaw : null,
4900
+ memoryRefCount: Array.isArray(r.candidateProvenance) ? r.candidateProvenance.length : null,
4901
+ // G-02 v1 增补:候选锚点前 3(8 字符前缀,脱敏——完整 id 不出端点)
4902
+ anchors: (Array.isArray(r.candidateProvenance) ? r.candidateProvenance : [])
4903
+ .slice(0, 3).map((p) => (p && p.memoryId ? String(p.memoryId).slice(0, 12) : null))
4904
+ .filter(Boolean),
4905
+ queryChars: r.queryChars || null, ts: r.ts || null,
4906
+ }
4907
+ } catch (e) { return { observationId: 'parse-error' } }
4908
+ })
4909
+ // G-02 v2(2026-08-30):决策↔投递时间线——把每条决策行与其真实投递结果关联。
4910
+ // 投递面=证据事件里的 seen 簇(5s 内聚合成一次投递)∪ activation host 的 render
4911
+ // 事件(volatile,含 skill 标志)。关联是启发式(时间窗+memoryId 交集),仅用于
4912
+ // 可视化,不参与任何决策。完整 memoryId 只在服务端做交集,不出端点(v1 隐私口径)。
4913
+ try {
4914
+ const fullIdsByRow = all.slice(-24).map((l) => {
4915
+ try { return (JSON.parse(l).candidateProvenance || []).map((p) => p && p.memoryId).filter(Boolean) } catch (_) { return [] }
4916
+ })
4917
+ const evDir2 = pathMod.join(dshHome(), 'memory', 'evidence', 'events')
4918
+ const seenClusters = []
4919
+ if (fsMod.existsSync(evDir2)) {
4920
+ const raw = []
4921
+ for (const f of fsMod.readdirSync(evDir2).filter((x) => x.endsWith('.jsonl')).slice(-2)) {
4922
+ const lines = fsMod.readFileSync(pathMod.join(evDir2, f), 'utf8').split('\n').filter(Boolean)
4923
+ for (const ln of lines.slice(-400)) {
4924
+ try { const r = JSON.parse(ln); if (r.kind === 'seen' && r.memoryId) raw.push({ at: Number((r.event && r.event.ts) || r.ts) || 0, memoryId: r.memoryId }) } catch (_) {}
4925
+ }
4926
+ }
4927
+ raw.sort((a, b) => a.at - b.at)
4928
+ for (const s of raw) {
4929
+ const last = seenClusters[seenClusters.length - 1]
4930
+ if (last && s.at - last.at <= 5000) last.memoryIds.push(s.memoryId)
4931
+ else seenClusters.push({ at: s.at, memoryIds: [s.memoryId] })
4932
+ }
4933
+ }
4934
+ let renderEvents = []
4935
+ try {
4936
+ const dv = engine._activationHost && engine._activationHost.debugView && engine._activationHost.debugView()
4937
+ renderEvents = ((dv && dv.recentEvents) || []).filter((e) => e && e.kind === 'render')
4938
+ } catch (_) {}
4939
+ tail.forEach((row, i) => {
4940
+ const ids = fullIdsByRow[i] || []
4941
+ let delivery = null
4942
+ // 只有关 deliverable 决策(emit/prefetch)才存在投递;取决策后最近的 seen 簇(≤300s)
4943
+ if (row.ts && ids.length && (row.decision === 'emit' || row.decision === 'prefetch')) {
4944
+ const tsMs = row.ts * 1000
4945
+ let best = null
4946
+ for (const c of seenClusters) {
4947
+ if (c.at < tsMs || c.at - tsMs > 300000) continue
4948
+ if (!c.memoryIds.some((m) => ids.includes(m))) continue
4949
+ if (!best || c.at < best.at) best = c
4950
+ }
4951
+ if (best) {
4952
+ const re = renderEvents.filter((e) => Math.abs(e.at - best.at) <= 15000).slice(-1)[0]
4953
+ delivery = { at: best.at, count: best.memoryIds.length, skill: !!(re && re.skill) }
4954
+ }
4955
+ }
4956
+ row.delivery = delivery
4957
+ })
4958
+ } catch (_) {}
4959
+ return writeJson(res, 200, { rows: tail })
4960
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message) }) }
4961
+ },
4962
+ },
4963
+ {
4964
+ kind: 'exact',
4965
+ path: API['review-feedback'],
4966
+ handler: async (req, res) => {
4967
+ // G-02 前置(append-only 审批队列;loopback):用户 A/P/S/H/E 判定落盘,不直接改任何策略。
4968
+ // G-02 v2(2026-08-30)新增 GET:队列投影 + 判定×决策行联查汇总 + 政策提示(纯描述,
4969
+ // 不改参数——政策演进仍走离线 replay/审批流程)。
4970
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
4971
+ try {
4972
+ const fsMod = await import('node:fs')
4973
+ const pathMod = await import('node:path')
4974
+ const dir = pathMod.join(dshHome(), 'memory', 'semantic')
4975
+ const file = pathMod.join(dir, 'review-queue.jsonl')
4976
+ if ((req.method || 'GET') === 'GET') {
4977
+ let entries = []
4978
+ if (fsMod.existsSync(file)) {
4979
+ entries = fsMod.readFileSync(file, 'utf8').split('\n').filter(Boolean).slice(-100)
4980
+ .map((l) => { try { return JSON.parse(l) } catch (_) { return null } }).filter(Boolean)
4981
+ }
4982
+ // 联查 shadow 决策行(observationId → 行摘要)
4983
+ const shadowFile = pathMod.join(dir, 'activation-shadow-v2.jsonl')
4984
+ const byObs = new Map()
4985
+ if (fsMod.existsSync(shadowFile)) {
4986
+ const lines = fsMod.readFileSync(shadowFile, 'utf8').split('\n').filter(Boolean)
4987
+ for (const ln of lines.slice(-600)) {
4988
+ try { const r = JSON.parse(ln); if (r.observationId && !byObs.has(r.observationId)) byObs.set(r.observationId, r) } catch (_) {}
4989
+ }
4990
+ }
4991
+ const joined = entries.map((e) => {
4992
+ const r = byObs.get(e.observationId)
4993
+ const f = (r && r.features) || {}
4994
+ return {
4995
+ at: e.at, choice: e.choice, observationId: e.observationId,
4996
+ decision: r ? r.decision : null, lane: (f && f.lane) || null,
4997
+ intentProb: (f && f.intentProb) != null ? f.intentProb : null,
4998
+ topReasons: r && Array.isArray(r.reasonCodes) ? r.reasonCodes.slice(0, 3) : [],
4999
+ }
5000
+ })
5001
+ const byChoice = {}
5002
+ for (const j of joined) byChoice[j.choice] = (byChoice[j.choice] || 0) + 1
5003
+ const hints = []
5004
+ for (const ch of ['H', 'S', 'E', 'A', 'P']) {
5005
+ const group = joined.filter((j) => j.choice === ch)
5006
+ if (!group.length) continue
5007
+ const rc = {}
5008
+ let hiConfidence = 0
5009
+ for (const g of group) { for (const x of g.topReasons) rc[x] = (rc[x] || 0) + 1; if (g.decision === 'emit' && (g.intentProb || 0) >= 0.6) hiConfidence++ }
5010
+ const topRc = Object.entries(rc).sort((a, b) => b[1] - a[1]).slice(0, 3).map((x) => x[0]).join(',')
5011
+ if (ch === 'H' && group.some((g) => g.decision === 'emit')) hints.push('H 有害判定落在 emit 决策×' + group.filter((g) => g.decision === 'emit').length + '——建议复核对应 reasonCodes 的精确率: ' + topRc)
5012
+ if (ch === 'S' && hiConfidence > 0) hints.push('S 应抑制判定命中高置信 emit(intentProb≥0.6)×' + hiConfidence + '——建议复核 explicit 车道 margin 阈值')
5013
+ if (ch === 'A' && group.length >= 2) hints.push('A 该激活×' + group.length + '(常见原因码: ' + (topRc || '—') + ')——可作晋升采纳候选')
5014
+ }
5015
+ return writeJson(res, 200, { queue: joined.slice(-50), byChoice, hints })
5016
+ }
5017
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
5018
+ const body = await readJsonBody(req).catch(() => ({}))
5019
+ const choice = String((body && body.choice) || '').toUpperCase()
5020
+ if (!['A', 'P', 'S', 'H', 'E'].includes(choice)) return writeJson(res, 400, { error: 'invalid choice' })
5021
+ fsMod.mkdirSync(dir, { recursive: true })
5022
+ const rec = { schemaVersion: 1, at: Date.now(),
5023
+ observationId: String((body && body.observationId) || '').slice(0, 64),
5024
+ choice, targetMemoryId: String((body && body.targetMemoryId) || '').slice(0, 64) || null,
5025
+ source: 'client-refine-panel' }
5026
+ fsMod.appendFileSync(file, JSON.stringify(rec) + '\n', 'utf8')
5027
+ return writeJson(res, 200, { ok: true, queued: true })
5028
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message) }) }
5029
+ },
5030
+ },
5031
+ {
5032
+ kind: 'exact',
5033
+ path: API['memory-hub'],
5034
+ handler: async (req, res) => {
5035
+ // M8 记忆中枢(loopback):GET 返回三层记忆 overview(episodic/facts/procedures);
5036
+ // POST {action} 触发编排(consolidate=巩固会话 / feed=喂 judgement 行 / render=渲染 checklists)。
5037
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
5038
+ try {
5039
+ const hub = engine._memoryHub
5040
+ if (!hub) return writeJson(res, 200, { error: 'hub-unavailable' })
5041
+ if ((req.method || 'GET') === 'POST') {
5042
+ const body = await readJsonBody(req).catch(() => ({}))
5043
+ const action = String((body && body.action) || '')
5044
+ if (action === 'consolidate') return writeJson(res, 200, hub.consolidateEpisodes())
5045
+ if (action === 'feed') return writeJson(res, 200, hub.ingestJudgementRows(Array.isArray(body.rows) ? body.rows : []))
5046
+ if (action === 'render') return writeJson(res, 200, { checklists: hub.renderChecklists() })
5047
+ if (action === 'crossfeed') return writeJson(res, 200, hub.crossFeed(String((body && body.sessionRef) || '')))
5048
+ // M9 审批动作(2026-08-30,G-02 同款 loopback+append-only 精神):用户在 hubTab
5049
+ // 对技能做晋升/激活/弃用/置顶。晋升走 store 的门槛判定(调门槛=设置页参数),
5050
+ // 不绕过任何 gate;每次动作进 diagnose 审计。
5051
+ const procs = hub.stores && hub.stores.procedures
5052
+ const pid = String((body && body.procedureId) || '')
5053
+ if (['promote', 'activate', 'deprecate', 'pin'].includes(action)) {
5054
+ if (!procs) return writeJson(res, 200, { ok: false, reason: 'no-procedure-store' })
5055
+ if (!pid) return writeJson(res, 400, { error: 'procedureId required' })
5056
+ let r
5057
+ if (action === 'promote') r = procs.promote(pid)
5058
+ else if (action === 'activate') r = procs.activate(pid)
5059
+ else if (action === 'deprecate') r = procs.deprecate(pid, 'user-disabled')
5060
+ else r = procs.setPinned(pid, (body && body.v) !== false)
5061
+ try { diag('hub review: ' + action + ' ' + pid.slice(0, 20) + ' → ' + JSON.stringify(r.ok !== undefined ? r.ok : r.decision || r.reason || '')) } catch (_) {}
5062
+ return writeJson(res, 200, r)
5063
+ }
5064
+ return writeJson(res, 400, { error: 'unknown action' })
5065
+ }
5066
+ return writeJson(res, 200, hub.overview())
5067
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message) }) }
5068
+ },
5069
+ },
5070
+ {
5071
+ kind: 'exact',
5072
+ path: API['storage-manage'],
5073
+ handler: async (req, res) => {
5074
+ // M10 存储管理(loopback):GET=语料健康扫描(逐源 sidecar↔正文 digest 比对);
5075
+ // POST {action:'scan'|'repair'|'delete'} —— repair 只重建 sidecar(不动正文),
5076
+ // delete 走「正文原子删除 + 在途激活包清理 + 派生事实撤销」三联动。
5077
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
5078
+ try {
5079
+ const sm = engine._storageManager
5080
+ if (!sm) return writeJson(res, 200, { error: 'storage-manager-unavailable' })
5081
+ if ((req.method || 'GET') === 'POST') {
5082
+ const body = await readJsonBody(req).catch(() => ({}))
5083
+ const action = String((body && body.action) || '')
5084
+ if (action === 'scan') return writeJson(res, 200, sm.scanHealth())
5085
+ if (action === 'repair') return writeJson(res, 200, await sm.repair(Array.isArray(body.items) ? body.items : null))
5086
+ if (action === 'delete') {
5087
+ const filePath = String((body && body.filePath) || '')
5088
+ const memoryId = String((body && body.memoryId) || '')
5089
+ if (!filePath || !memoryId) return writeJson(res, 400, { error: 'filePath and memoryId required' })
5090
+ // 路径白名单:只允许当前工作区三源之一,防止外部路径穿越
5091
+ const sc = sm.scanHealth()
5092
+ const allowed = sc.ok ? sc.sources.some((s) => s.file === filePath) : false
5093
+ if (!allowed) return writeJson(res, 403, { error: 'path-not-in-corpus' })
5094
+ const r = await sm.deleteMemory({ filePath, memoryId, expectedDigest: body && body.expectedDigest })
5095
+ try { diag('storage-manage delete: ' + memoryId.slice(0, 20) + ' → ' + JSON.stringify(r.ok) + ' revoked=' + ((r.cascade && r.cascade.revoked && r.cascade.revoked.revoked) || 0)) } catch (_) {}
5096
+ return writeJson(res, 200, r)
5097
+ }
5098
+ return writeJson(res, 400, { error: 'unknown action' })
5099
+ }
5100
+ return writeJson(res, 200, { ...sm.scanHealth(), audit: sm.auditLog().slice(-8) })
5101
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message) }) }
5102
+ },
5103
+ },
5104
+ {
5105
+ kind: 'exact',
5106
+ path: API['activation-inbox'],
5107
+ handler: async (req, res) => {
5108
+ // M6-3:fake activation 注入/状态(仅 loopback;activationInboxEnabled∧assoc 双门在 host 内再校验)
5109
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
5110
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
5111
+ const body = await readJsonBody(req).catch(() => ({}))
5112
+ try {
5113
+ const host = engine._activationHost
5114
+ if (!host) return writeJson(res, 200, { error: 'host-unavailable' })
5115
+ const action = String((body && body.action) || '')
5116
+ if (action === 'inject') return writeJson(res, 200, host.injectActivation(body.request))
5117
+ if (action === 'status') return writeJson(res, 200, { activationInbox: host.debugView() })
5118
+ return writeJson(res, 400, { error: 'unknown action' })
5119
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
5120
+ },
5121
+ },
2899
5122
  {
2900
5123
  kind: 'exact',
2901
5124
  path: API.state,
@@ -3120,7 +5343,11 @@ export function apply(ctx, config) {
3120
5343
  if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'invalid body' })
3121
5344
  const allowed = Object.keys(DEFAULT_CONFIG)
3122
5345
  const patch = {}
3123
- for (const key of allowed) if (body[key] !== undefined) patch[key] = body[key]
5346
+ for (const key of allowed) if (body[key] !== undefined) {
5347
+ // semanticEngineMode 枚举门:非法值直接丢弃(fail-closed,不落盘)
5348
+ if (key === 'semanticEngineMode' && !['auto', 'lexical', 'js', 'python'].includes(body[key])) continue
5349
+ patch[key] = body[key]
5350
+ }
3124
5351
  const saved = await engine.saveConfig(patch)
3125
5352
  writeJson(res, 200, { config: saved.config, migrated: saved.migrated || '' })
3126
5353
  return
@@ -3296,13 +5523,46 @@ export function apply(ctx, config) {
3296
5523
  } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
3297
5524
  },
3298
5525
  },
5526
+ {
5527
+ // 设置页「总结/问候默认模型」抽屉数据源:枚举 llm 服务全部 provider/model(只读目录)
5528
+ kind: 'exact',
5529
+ path: API.models,
5530
+ handler: async (req, res) => {
5531
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
5532
+ if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
5533
+ try {
5534
+ const llm = engine._llm
5535
+ if (!llm || typeof llm.listProviders !== 'function' || typeof llm.listModels !== 'function') {
5536
+ return writeJson(res, 200, { providers: [], failures: [], unsupported: true })
5537
+ }
5538
+ const providers = []
5539
+ const failures = []
5540
+ await Promise.all(llm.listProviders().map(async (provider) => {
5541
+ const pid = provider && provider.id !== undefined ? String(provider.id) : String(provider)
5542
+ const pname = provider && provider.name ? String(provider.name) : pid
5543
+ try {
5544
+ const models = await llm.listModels(pid)
5545
+ providers.push({
5546
+ id: pid,
5547
+ name: pname,
5548
+ models: (models || []).map((m2) => ({ id: m2 && m2.id !== undefined ? String(m2.id) : String(m2), name: m2 && m2.name ? String(m2.name) : undefined })),
5549
+ })
5550
+ } catch (e2) {
5551
+ failures.push({ id: pid, name: pname, message: String(e2 && e2.message ? e2.message : e2) })
5552
+ }
5553
+ }))
5554
+ providers.sort((a, b) => a.id.localeCompare(b.id))
5555
+ writeJson(res, 200, { providers, failures })
5556
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
5557
+ },
5558
+ },
3299
5559
  ]
3300
5560
 
3301
5561
  // ---------- 后台轮询兜底(对标外部助手的心跳轮询):每 5 分钟重试失败的自动沉淀 + 心跳文件 ----------
3302
5562
  // 心跳:每次轮询把存活状态写入 ~/.dsh/memory/polling-heartbeat.json(可随时查看 LastWriteTime 确认轮询活着)
3303
5563
  const writeHeartbeat = async () => {
3304
5564
  try {
3305
- const q = engine._pendingConsolidations || []
5565
+ const q = engine.runtimes.values().reduce((sum, rt) => sum + rt.pendingConsolidations.length, 0)
3306
5566
  const hb = path.join(dshHome(), 'memory', 'polling-heartbeat.json')
3307
5567
  await mkdir(path.dirname(hb), { recursive: true })
3308
5568
  await writeFile(hb, JSON.stringify({
@@ -3318,10 +5578,11 @@ export function apply(ctx, config) {
3318
5578
  const retryTimer = setInterval(() => {
3319
5579
  void (async () => {
3320
5580
  try {
3321
- const q = engine._pendingConsolidations
3322
- if (q && q.length && !engine._consolidating) {
3323
- const item = q.shift()
3324
- if (item && item.agent) void engine.consolidateTurn(item.turn, item.agent)
5581
+ for (const rt of engine.runtimes.values()) {
5582
+ if (rt.pendingConsolidations.length && !rt.consolidating) {
5583
+ const item = rt.pendingConsolidations.shift()
5584
+ if (item && item.agent) void engine.withAgent(item.agent, () => engine.consolidateTurn(item.turn, item.agent))
5585
+ }
3325
5586
  }
3326
5587
  } catch (e) {}
3327
5588
  })()
@@ -3332,8 +5593,28 @@ export function apply(ctx, config) {
3332
5593
 
3333
5594
  // ---------- 注册与清理 ----------
3334
5595
  const disposers = []
3335
- disposers.push(disposeContext, disposeSection)
3336
- for (const tool of tools) disposers.push(ctx.tools.register(tool))
5596
+ disposers.push(disposeContext, disposeSection, () => {
5597
+ try { if (engine._shadowHost) engine._shadowHost.disposeAll('plugin disposed') } catch (e) {}
5598
+ try { if (engine._contextHost) engine._contextHost.disposeAll('plugin disposed') } catch (e) {}
5599
+ try { if (engine._activationHost) engine._activationHost.disposeAll('plugin disposed') } catch (e) {}
5600
+ try { if (engine._indexSyncHost) engine._indexSyncHost.dispose('plugin disposed') } catch (e) {}
5601
+ try { if (engine._pythonSidecar) engine._pythonSidecar.dispose('plugin disposed') } catch (e) {}
5602
+ // M8 hub:清定时器 + 落盘 dispose(各店 dispose 时 io.save 兜底)
5603
+ try { if (engine._hubFeedDisposers) for (const d of engine._hubFeedDisposers) { try { d() } catch (_) {} } } catch (e) {}
5604
+ try { if (engine._memoryHub) engine._memoryHub.dispose('plugin disposed') } catch (e) {}
5605
+ try { engine.runtimes.disposeAll() } catch (e) {}
5606
+ })
5607
+ for (const tool of tools) {
5608
+ // M0/M1: 工具执行绑定到其 agent 的 runtime(exec.agent 精确取 runtime),this.state/autoStats 读写不串线
5609
+ const rawExec = tool.execute
5610
+ if (typeof rawExec === 'function') {
5611
+ tool.execute = async (args, exec) => {
5612
+ const agent = exec && exec.agent
5613
+ return agent ? engine.withAgent(agent, () => rawExec(args, exec)) : rawExec(args, exec)
5614
+ }
5615
+ }
5616
+ disposers.push(ctx.tools.register(tool))
5617
+ }
3337
5618
  for (const route of routes) disposers.push(ctx.webServer.register(route))
3338
5619
  ctx.effect(() => () => {
3339
5620
  clearInterval(retryTimer)