@a9i5k4/dsh-auto-memory 2.2.6 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +20 -10
  2. package/README.zh-CN.md +22 -10
  3. package/docs/CONTINUITY-FLOW.md +222 -0
  4. package/docs/HANDBOOK.md +354 -0
  5. package/docs/INTEGRATION-ANALYSIS.md +348 -0
  6. package/docs/M-CM7-HANDOFF-LAYERED-RETRIEVAL.md +311 -0
  7. package/docs/M8-MEMORY-HUB.md +1 -1
  8. package/docs/PROMPT-PACK-LAYERED-RECALL.md +474 -0
  9. package/docs/PROMPT-SET-STRICT.md +389 -0
  10. package/docs/RELEASE-GO-NOGO.md +82 -0
  11. package/docs/ROADMAP.md +162 -0
  12. package/docs/STATUS-BOARD.md +147 -0
  13. package/docs/USER-GUIDE.en.md +382 -0
  14. package/docs/USER-GUIDE.zh-CN.md +382 -289
  15. package/docs/prompts/EXEC-ORDER.md +77 -0
  16. package/docs/prompts/FEEDING-SCRIPT.md +174 -0
  17. package/docs/prompts/FEEDING-SEQUENCE.md +61 -0
  18. package/docs/prompts/FIX-AGENT-M8-2b.md +119 -0
  19. package/docs/prompts/FIX-AGENT-P11.md +97 -0
  20. package/docs/prompts/FIX-AGENT-P12-FULL-REGRESSION.md +135 -0
  21. package/docs/prompts/FIX-AGENT-P12.md +113 -0
  22. package/docs/prompts/FIX-AGENT-P13-PYTHON-RANK.md +100 -0
  23. package/docs/prompts/FIX-AGENT-P8.md +120 -0
  24. package/docs/prompts/FIX-AGENT-P9.md +110 -0
  25. package/docs/prompts/FIX-AGENT-P9a.md +94 -0
  26. package/docs/prompts/FIX-AGENT-P9d.md +114 -0
  27. package/docs/prompts/FIX-AGENT-TEMPORAL-ARM.md +148 -0
  28. package/docs/prompts/LIVE-VERIFY-ZCODE.md +105 -0
  29. package/docs/prompts/M8-1-fact-metadata.md +45 -0
  30. package/docs/prompts/M8-2-ADJUDICATION.md +98 -0
  31. package/docs/prompts/M8-2-importance-wiring.md +42 -0
  32. package/docs/prompts/M8-2b-evidence-pipeline.md +52 -0
  33. package/docs/prompts/M8-3-enable-verify.md +49 -0
  34. package/docs/prompts/M8-R-REPORT.md +156 -0
  35. package/docs/prompts/M8-R-research.md +67 -0
  36. package/docs/prompts/P1-l0-index.md +30 -0
  37. package/docs/prompts/P10-importance-calibration.md +45 -0
  38. package/docs/prompts/P11-silent-catch-observability.md +43 -0
  39. package/docs/prompts/P2-semantic-recall.md +30 -0
  40. package/docs/prompts/P3-fusion.md +28 -0
  41. package/docs/prompts/P4-l0-response.md +28 -0
  42. package/docs/prompts/P5-handoff-anchor.md +28 -0
  43. package/docs/prompts/P6-ledger-weight.md +27 -0
  44. package/docs/prompts/P7-write-fix.md +26 -0
  45. package/docs/prompts/P8-rrf-wiring.md +47 -0
  46. package/docs/prompts/P9-REVIEW-DECISION.md +95 -0
  47. package/docs/prompts/P9-evidence-write-coverage.md +113 -0
  48. package/docs/prompts/README.md +105 -0
  49. package/docs/prompts/ZCODE-DROPIN.md +229 -0
  50. package/docs/prompts/_COMMON.md +88 -0
  51. package/lib/client.js +36 -2
  52. package/lib/context-host.js +77 -2
  53. package/lib/evidence-agg.js +81 -0
  54. package/lib/fact-store.js +32 -0
  55. package/lib/handoff-anchor.js +114 -0
  56. package/lib/index.js +402 -43
  57. package/lib/l0-extract.js +149 -0
  58. package/lib/l0-index.js +239 -0
  59. package/lib/m7-wire.js +4 -3
  60. package/lib/memory-importance.js +70 -0
  61. package/lib/python-setup.js +16 -4
  62. package/lib/recall-fusion.js +99 -0
  63. package/lib/shadow-retrieval.js +2 -2
  64. package/lib/storage-manage.js +17 -0
  65. package/lib/subagent-gc.js +8 -1
  66. package/lib/temporal-parse.js +159 -0
  67. package/package.json +1 -1
  68. package/python/worker_semantic_v1.py +28 -1
package/lib/index.js CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { readFile, writeFile, mkdir, readdir, stat, rm, copyFile, appendFile, rename } from 'node:fs/promises'
21
- import { createReadStream, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
21
+ import { createReadStream, existsSync, readdirSync, 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)
@@ -361,8 +361,8 @@ const DEFAULT_CONFIG = {
361
361
  procedurePromotionEnabled: false,
362
362
  /** ── M8 记忆中枢(Memory Hub)── 三层记忆(episodic/semantic/procedural)编排参数。
363
363
  * 所有参数都在设置页「记忆中枢」分组可调;默认值对应 M-02/M-03/M-04 元代码门槛。 */
364
- /** 记忆中枢总开关(默认关;开启后消费 judgement-shadow + 三层 store 运行)。 */
365
- memoryHubEnabled: false,
364
+ /** 记忆中枢总开关(2026-09-09 M8-3 经用户书面确认默认启用;开启后消费 judgement-shadow + 三层 store 运行;设置页「记忆中枢」开关可回滚)。 */
365
+ memoryHubEnabled: true,
366
366
  /** episodic: 一个 episode 至少多少段才巩固(少于=噪声丢弃)。默认 2。 */
367
367
  episodicMinSegments: 2,
368
368
  /** episodic: 保留的最多 episode 数(超出按时间淘汰)。默认 256。 */
@@ -1555,7 +1555,9 @@ class MemoryEngine {
1555
1555
  } catch (e) { return '' }
1556
1556
  }
1557
1557
 
1558
- /** 白板重写:旧 PLAN.md 有实质内容且与新内容不同时,先归档到 handoff/archive/PLAN-<ts>.md 再写新快照(保留更改历史)。 */
1558
+ /** 白板重写:旧 PLAN.md 有实质内容且与新内容不同时,先归档到 handoff/archive/PLAN-<ts>.md 再写新快照(保留更改历史)。
1559
+ * P7(2026-09-09)白板老化:新内容中标题命中 /历史|踩坑|流水线要点/ 的顶层「## 」节移入 handoff/archive/PLAN-history-<ts>.md
1560
+ * (同秒多写 -b/-c 后缀防撞),PLAN.md 只留当前状态;内容只移动不删除;无匹配节 → 行为与旧版完全一致。 */
1559
1561
  async writePlanSnapshot(projectDir, content) {
1560
1562
  const dir = path.join(projectDir, 'handoff')
1561
1563
  const planPath = path.join(dir, 'PLAN.md')
@@ -1569,22 +1571,49 @@ class MemoryEngine {
1569
1571
  await writeFile(archPath, existing, 'utf8')
1570
1572
  archived = archPath
1571
1573
  }
1572
- await this.writeFullRaw(planPath, String(content || ''))
1573
- return { ok: true, path: planPath, archived }
1574
+ // P7 白板老化(确定性节分类;移动不删除;全部节都命中时 fail-soft 放弃老化原样写入)
1575
+ let finalContent = String(content || '')
1576
+ let movedHistory = 0, historyPath = ''
1577
+ const sections = finalContent.split(/(?=^## )/m)
1578
+ if (sections.length > 1) {
1579
+ const keep = [], hist = []
1580
+ for (const sec of sections) {
1581
+ const head = sec.split('\n', 1)[0] || ''
1582
+ if (/^## .*(历史|踩坑|流水线要点)/.test(head)) hist.push(sec.replace(/\n+$/, ''))
1583
+ else keep.push(sec)
1584
+ }
1585
+ // 兜底:老化后必须至少保留一个顶层节,否则(如全部节都命中)放弃老化原样写入,不留空白板
1586
+ if (hist.length && keep.some((s) => /^## /.test((s.split('\n', 1)[0] || '')))) {
1587
+ const archDir = path.join(dir, 'archive')
1588
+ await mkdir(archDir, { recursive: true })
1589
+ const base = 'PLAN-history-' + handoffStamp()
1590
+ let hp = path.join(archDir, base + '.md')
1591
+ for (let c = 98; c <= 122 && existsSync(hp); c++) { hp = path.join(archDir, base + '-' + String.fromCharCode(c) + '.md') }
1592
+ await writeFile(hp, '# 白板历史归档 · ' + this.memToday() + ' ' + nowHm() + '\n\n' + hist.join('\n\n') + '\n', 'utf8')
1593
+ finalContent = keep.join('').replace(/^\n+/, '')
1594
+ movedHistory = hist.length
1595
+ historyPath = hp
1596
+ }
1597
+ }
1598
+ await this.writeFullRaw(planPath, finalContent)
1599
+ return { ok: true, path: planPath, archived, movedHistory: movedHistory || undefined, historyPath: historyPath || undefined, final: finalContent }
1574
1600
  } catch (e) {
1575
1601
  return { ok: false, error: String((e && e.message) || e) }
1576
1602
  }
1577
1603
  }
1578
1604
 
1579
- /** 交接账本:每次一篇新文件(append-only 语料,不并入日志/笔记)。同秒多写用 -b/-c 后缀防撞且保持字典序=时间序。 */
1605
+ /** 交接账本:每次一篇新文件(append-only 语料,不并入日志/笔记)。同秒多写用 -b/-c 后缀防撞且保持字典序=时间序。
1606
+ * P7(2026-09-09):标题行归属写入函数 —— 模型 content 自带的「交接账本」标题行一律剔除(实测最近 8 篇中
1607
+ * 3 篇双标题且时间戳互相矛盾),正文零丢失仅去冗余标题元数据;剔除后正文经返回值 clean 带回供状态字段同步。 */
1580
1608
  async writeHandoffLedger(projectDir, content) {
1581
1609
  const dir = path.join(projectDir, 'handoff')
1582
1610
  const base = 'handoff-' + handoffStamp()
1583
1611
  let p = path.join(dir, base + '.md')
1584
1612
  try {
1585
1613
  for (let c = 98; c <= 122 && existsSync(p); c++) { p = path.join(dir, base + '-' + String.fromCharCode(c) + '.md') }
1586
- await this.writeFullRaw(p, '# 交接账本 · ' + this.memToday() + ' ' + nowHm() + '\n\n' + String(content || ''))
1587
- return { ok: true, path: p }
1614
+ const clean = String(content || '').split(/\r?\n/).filter((l) => !/^#\s*交接账本/.test(l)).join('\n').replace(/^\n+/, '')
1615
+ await this.writeFullRaw(p, '# 交接账本 · ' + this.memToday() + ' ' + nowHm() + '\n\n' + clean)
1616
+ return { ok: true, path: p, clean }
1588
1617
  } catch (e) {
1589
1618
  return { ok: false, error: String((e && e.message) || e) }
1590
1619
  }
@@ -1876,7 +1905,15 @@ class MemoryEngine {
1876
1905
  const now = Date.now()
1877
1906
  if (rt && rt.waterStepAt && now - rt.waterStepAt < minGapMs) return
1878
1907
  if (rt) rt.waterStepAt = now
1879
- void this.checkWaterLevel(agent)
1908
+ // 2.2.7 修复:pre-step 测到水位后同样 arm 自动接续 —— 旧实现只在 turn-stopping arm,
1909
+ // 而官方压缩挂在 pre-step(80%)且长回合中轮末可能永远等不到 → 压缩抢先、接续从不触发。
1910
+ // 这里 arm 的倒计时标记 awaitIdle,由 tickAutoContinue 在回合仍活跃时推迟执行,避免打断正在进行的回合。
1911
+ void this.checkWaterLevel(agent).then(() => {
1912
+ try {
1913
+ const rt2 = this.runtimeFor(agent)
1914
+ this.armAutoContinue(agent, { ratio: rt2.waterLevel, tokens: rt2.waterLevelTokens, window: rt2.waterLevelWindow, source: rt2.waterLevelSource }, { awaitIdle: true })
1915
+ } catch (eArm) {}
1916
+ }).catch(() => {})
1880
1917
  } catch (e) { diag('checkWaterLevelAtStep error: ' + (e && e.message)) }
1881
1918
  }
1882
1919
 
@@ -1885,7 +1922,7 @@ class MemoryEngine {
1885
1922
  * 默认 35s,写入 _autoContState)→ 浏览器轮询 GET auto-continue-state 显示确认卡(同意=POST decide
1886
1923
  * agree,宿主立即执行;拒绝=POST decide reject,同一边界不再触发)→ 到期无人响应 → 宿主自动执行,
1887
1924
  * 与官方 SessionController service(ctx.get('sessionController'))同进程直调,不依赖浏览器。 */
1888
- armAutoContinue(agent, wl) {
1925
+ armAutoContinue(agent, wl, opts = null) {
1889
1926
  try {
1890
1927
  if (!agent || !this.hasReliableSessionIdentity(agent)) return
1891
1928
  if (this.config.handoffEnabled === false) return
@@ -1910,8 +1947,11 @@ class MemoryEngine {
1910
1947
  tokens: Number(wl.tokens) || 0,
1911
1948
  window: Number(wl.window) || 0,
1912
1949
  source: String(wl.source || ''),
1950
+ // 2.2.7:pre-step 侧 arm 的倒计时到期时,若回合仍活跃则推迟(避免打断进行中的对话)
1951
+ awaitIdle: !!(opts && opts.awaitIdle),
1952
+ deferCount: 0,
1913
1953
  }
1914
- diag('auto-continue armed: sid=' + sid + ' ratio=' + st.armed.ratio.toFixed(2) + ' expiresAt=' + st.armed.expiresAt)
1954
+ diag('auto-continue armed: sid=' + sid + ' ratio=' + st.armed.ratio.toFixed(2) + ' awaitIdle=' + st.armed.awaitIdle + ' expiresAt=' + st.armed.expiresAt)
1915
1955
  } catch (e) { diag('armAutoContinue error: ' + (e && e.message)) }
1916
1956
  }
1917
1957
 
@@ -1921,6 +1961,18 @@ class MemoryEngine {
1921
1961
  const st = this._autoContState || (this._autoContState = {})
1922
1962
  if (!st.armed || st.executing) return
1923
1963
  if (Date.now() < st.armed.expiresAt) return
1964
+ // 2.2.7:pre-step 侧 arm 的接续若恰逢回合仍活跃(30s 内有活动),推迟 20s 再评估,最多 5 次;
1965
+ // 超过后仍执行(避免长任务里永不接续)。turn-stopping 侧 arm 不带 awaitIdle,行为不变。
1966
+ if (st.armed.awaitIdle) {
1967
+ const lastActive = Number(this._globalLastActiveAt) || 0
1968
+ const busy = lastActive > 0 && Date.now() - lastActive < 30000
1969
+ if (busy && (st.armed.deferCount || 0) < 5) {
1970
+ st.armed.deferCount = (st.armed.deferCount || 0) + 1
1971
+ st.armed.expiresAt = Date.now() + 20000
1972
+ diag('auto-continue deferred (turn still active): count=' + st.armed.deferCount)
1973
+ return
1974
+ }
1975
+ }
1924
1976
  diag('auto-continue deadline reached, executing host-side')
1925
1977
  await this.hostAutoContinue()
1926
1978
  } catch (e) { diag('tickAutoContinue error: ' + (e && e.message)) }
@@ -2155,17 +2207,28 @@ class MemoryEngine {
2155
2207
  const wsForSession = (pack && pack.cwd) || p.ws
2156
2208
  const workspaceId = this.resolveWorkspaceIdForSession(prevSid, wsForSession)
2157
2209
  const parts = [
2158
- '接续上一会话的任务。请先读完下面的交接材料恢复上下文,然后直接继续推进未完成事项,不要重新开始。',
2210
+ '接续上一会话的任务。材料已分层,请按需取用而非通读:先看第0层白板建立全局图景,再视需要看第1层账本(含已试方案与失败原因)与第2层近期线程;第3层完整转写仅在前三层不足以推进时才 read。恢复上下文后直接继续推进未完成事项,不要重新开始。',
2159
2211
  '材料分层:第0层=指令+白板(全局图景);第1层=交接账本(四段式,最新);第2层=近期线程(最近 20 条,单条上限 700 字,保留角色与工具标记);第3层=完整转写(按需 read)。',
2160
2212
  ]
2161
2213
  if (plan) parts.push('【第0层 · 白板 PLAN.md(节选)】更新于 ' + fmtMt(planMt) + staleNote + NL + plan.slice(0, 3000))
2162
- if (ledger) parts.push('【第1层 · 交接账本 ' + (ledgerName || ledgerFrom) + '】写于 ' + fmtMt(ledgerMt) + ' (' + ledgerFrom + ')' + NL + ledger.slice(0, 8000))
2214
+ if (ledger) {
2215
+ // P6(2026-09-09):账本权重化截断 —— 按账本自身四段标题赋权(失败原因.35>下一步.30>目标.20>任务状态.15),
2216
+ // 预算不足从最低权重段开始截(段标题保留)。解析失败/动态 import 失败 fail-soft 回落位置截断,绝不阻塞接续(I4)。
2217
+ // 预算沿用现状 8000:handoffLedgerChars(默认 800)语义为动态快照注入预算(:3082),与 carry 层不同源,不复用。
2218
+ let ledgerBody = ledger.slice(0, 8000)
2219
+ try {
2220
+ const { weightedTrimHandoffLedgerPre } = await import('./handoff-anchor.js')
2221
+ const weighted = weightedTrimHandoffLedgerPre(ledger, 8000)
2222
+ if (weighted != null) ledgerBody = weighted
2223
+ } catch (eLedW) {}
2224
+ parts.push('【第1层 · 交接账本 ' + (ledgerName || ledgerFrom) + '】写于 ' + fmtMt(ledgerMt) + ' (' + ledgerFrom + ')' + NL + ledgerBody)
2225
+ }
2163
2226
  if (pack) {
2164
2227
  if (pack.tailText) parts.push('【第2层 · 近期线程(最近 ' + Math.min(20, Number(pack.msgCount) || 20) + ' 条 / 共 ' + (pack.msgCount || 0) + ' 条)】' + NL + pack.tailText)
2165
2228
  const guide = ['【第3层 · 完整转写与检索(按需)】']
2166
2229
  if (pack.transcriptPath) {
2167
2230
  guide.push('- 旧会话(' + pack.sessionId + ')的完整对话转写已写入: ' + pack.transcriptPath)
2168
- guide.push('- **接续前先用 read 工具读取该转写文件**,以完全理解旧会话的讨论、结论与未竟事项;任务推进中也可随时回读。')
2231
+ guide.push('- 完整转写已归档,**不必在接续前通读**:仅在第0-2层不足以推进时再 read;优先用 memory_recall(scope=\'sessions\', query=\'关键词\') 定位片段,避免整篇读入。')
2169
2232
  } else {
2170
2233
  guide.push('- 旧会话 ID: ' + pack.sessionId + '(转写文件生成失败,可用 memory_recall 检索)')
2171
2234
  }
@@ -2174,6 +2237,28 @@ class MemoryEngine {
2174
2237
  if (pack.agentPreset) guide.push('- 旧会话 agentPreset: ' + pack.agentPreset + '(已随创建传递;注意 code→ptc 改名的历史会话需用户级兼容预设)。')
2175
2238
  parts.push(guide.join(NL))
2176
2239
  }
2240
+ // P5(2026-09-09):接续锚点表 —— 用 T1 的 L0 抽取生成「记忆条目地图」,供新会话按需下钻
2241
+ // (notesPath/logPath 正是 `<!-- memory:mem_<32hex> -->` 锚点的载体;纯解析零 LLM;字节稳定:
2242
+ // 同输入 → 同抽取+同排序+同格式)。fail-soft:任何失败(读文件/动态 import/解析)→ 跳过整表,
2243
+ // 材料回落现有平铺,绝不阻塞接续;表置于 parts 末尾 —— 预算超限时 slice(0,18000) 先截掉表,
2244
+ // 等价于自动回退现有行为。
2245
+ try {
2246
+ const { buildL0IndexPre } = await import('./l0-extract.js')
2247
+ const anchorSectionFor = async (label, file, cap) => {
2248
+ const body = await this.readTextSafe(file)
2249
+ if (!body) return ''
2250
+ const rows = buildL0IndexPre(body, { maxChars: 48 }).slice(0, cap)
2251
+ if (!rows.length) return ''
2252
+ return '- ' + label + ':' + NL + rows.map((r) => ' - ' + String(r.id).slice(4, 12) + ' ' + r.l0).join(NL)
2253
+ }
2254
+ const anchorSections = (await Promise.all([
2255
+ anchorSectionFor('工作区笔记', p.notesPath, 10),
2256
+ anchorSectionFor('今日日志', p.logPath, 10),
2257
+ ])).filter(Boolean)
2258
+ if (anchorSections.length) {
2259
+ parts.push('【锚点表 · 记忆条目地图(按需下钻,不必通读)】' + NL + anchorSections.join(NL))
2260
+ }
2261
+ } catch (eAnchor) {}
2177
2262
  // 接续序号:统计 handoff 目录里已有的 prev-session 包数量 +1(每次一键/自动接续产出一个)
2178
2263
  let contSeq = 1
2179
2264
  try { const prevs = (await readdir(p.handoffDir).catch(() => [])).filter((f) => /^prev-session-.*\.md$/.test(f)); contSeq = prevs.length + 1 } catch (eS) {}
@@ -3236,9 +3321,13 @@ class MemoryEngine {
3236
3321
  }
3237
3322
 
3238
3323
  // ---------- 检索 ----------
3239
- async recall(query, limit = 8, agent, scope = 'all') {
3324
+ async recall(query, limit = 8, agent, scope = 'all', opts = null) {
3325
+ // P4(2026-09-09):按需展开入口 —— opts.expand 指定 mem_<32hex> 时跳过检索,按锚点字节区间直接返回该条原文。
3326
+ if (opts && opts.expand) return this.expandMemoryRecordPre(opts.expand, agent)
3240
3327
  const q = String(query || '').toLowerCase().trim()
3241
3328
  if (!q) return 'memory_recall: query 为空。'
3329
+ // P4(2026-09-09):L0 模式开关 —— 工具层缺省 'l0';engine 直调不传 opts 保持旧行为。
3330
+ const l0Mode = !!(opts && opts.format === 'l0')
3242
3331
  // 多词查询:按空白/中文标点分词,任一词命中即算命中(OR),按命中词数排序取相关度最高的
3243
3332
  const terms = q.split(/[\s,,、;;。::]+/).filter((t) => t.length > 0)
3244
3333
  const p = await this.resolvePaths(agent)
@@ -3288,20 +3377,117 @@ class MemoryEngine {
3288
3377
  }
3289
3378
  }
3290
3379
  const logs = await this.listDailyLogs(p.projectDir, 40)
3291
- for (const log of logs) {
3292
- if (hits.length >= limit) break
3293
- await scanFile(log.name, path.join(p.projectDir, log.name), 2)
3294
- }
3295
3380
  const reflections = await this.listReflections(p.reflectDir, 30)
3296
- for (const r of reflections) {
3297
- if (hits.length >= limit) break
3298
- await scanFile('reflections/' + r.name, path.join(p.reflectDir, r.name), 2)
3299
- }
3300
- await scanFile('~' + p.userFile.slice(homedir().length), p.userFile)
3301
- await scanFile(p.projectDir + '/MEMORY.md', p.notesPath)
3302
- if (hits.length) {
3303
- out.push('== 本地记忆文件命中 ==')
3304
- for (const h of hits) out.push('· ' + h.where + ':\n' + h.matches.map((m) => ' - ' + m).join('\n'))
3381
+ // P4(2026-09-09):L0 模式 —— 本地记忆段默认只列 L0 摘要(每条含 id/得分/匹配原因),原文经 expand="mem_xxx" 按需展开。
3382
+ // 词法口径与词法臂一致(小写包含);语义分 fail-soft 叠加(_jsSemanticRank,与 P2 语义臂同源)。语料上限 256 对齐 P2。
3383
+ if (l0Mode) {
3384
+ const { buildL0IndexPre } = await import('./l0-extract.js')
3385
+ const l0Corpus = []
3386
+ const pushL0 = (label, text) => { if (text) for (const it of buildL0IndexPre(text)) l0Corpus.push({ id: it.id, l0: it.l0, label }) }
3387
+ for (const log of logs) pushL0(log.name, await this.readTextSafe(path.join(p.projectDir, log.name)))
3388
+ for (const rf of reflections) pushL0('reflections/' + rf.name, await this.readTextSafe(path.join(p.reflectDir, rf.name)))
3389
+ pushL0(p.projectDir + '/MEMORY.md', await this.readTextSafe(p.notesPath))
3390
+ pushL0('~' + p.userFile.slice(homedir().length), await this.readTextSafe(p.userFile))
3391
+ if (l0Corpus.length > 256) l0Corpus.length = 256
3392
+ for (const c of l0Corpus) {
3393
+ const low = c.l0.toLowerCase()
3394
+ const hitTerms = terms.filter((t) => low.includes(t))
3395
+ c.lex = hitTerms.length
3396
+ if (hitTerms.length) c.reason = '词法×' + hitTerms.length + '(' + hitTerms.slice(0, 3).join(',') + ')'
3397
+ }
3398
+ // P13:语义臂择优 —— python(c3 dense_search)优先,失败回 C2(_jsSemanticRank),lexical → 无语义臂。
3399
+ // pyCorpus:与 context-host index-sync 同源的 corpus 快照(workspaceKey/scope/miv 须与已同步索引一致,
3400
+ // 否则 worker 三重过滤拒绝 → 空 scores → 自动回退,不报错)。任何异常 → semScores=null → 纯词法。
3401
+ let semScores = null
3402
+ try {
3403
+ const { createHash } = await import('node:crypto')
3404
+ const canon = l0Corpus.map((c) => [c.id, c.l0]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
3405
+ const miv = 'idx_' + createHash('sha256').update(JSON.stringify(canon)).digest('hex').slice(0, 32)
3406
+ const rankSnap = { memoryIndexVersion: miv, records: l0Corpus.map((c) => ({ memoryId: c.id, text: c.l0 })), pyPaths: { ws: p.ws || '', userFile: p.userFile, notesPath: p.notesPath, logPath: p.logPath } }
3407
+ let rank = null
3408
+ if (typeof this._semanticRankBest === 'function') rank = await this._semanticRankBest(rankSnap, query)
3409
+ else if (typeof this._jsSemanticRank === 'function') rank = await this._jsSemanticRank(rankSnap, query)
3410
+ if (rank && rank.scores && rank.scores.size) semScores = rank.scores
3411
+ } catch (eBest) { try { diag('recall 语义臂择优降级: ' + String((eBest && eBest.message) || eBest).slice(0, 120)) } catch (_) {} }
3412
+ if (semScores && semScores.size) {
3413
+ for (const c of l0Corpus) {
3414
+ const sc = semScores.get(c.id)
3415
+ if (typeof sc === 'number' && sc >= 0.5) { c.sem = sc; c.reason = (c.reason ? c.reason + '+' : '') + '语义×' + sc.toFixed(2) }
3416
+ }
3417
+ }
3418
+ // M8-2b(2026-09-09):evidence → importance → dense 臂加权因子。只读有界聚合(近 7 天/每文件末 400 行,
3419
+ // 复用 :6876 证据读取范式);中性 0.5 → 因子 0.75 全体一致缩放=排序不变;correction 重则因子降至 0.5。
3420
+ // 任何失败 → impMap 空 → 全体中性,绝不阻塞检索。importance 仅为加权因子之一,lex 臂不受影响。
3421
+ const impMap = new Map()
3422
+ try {
3423
+ const { scanEvidenceEventsPre, aggregateEvidenceEventsPre } = await import('./evidence-agg.js')
3424
+ const { computeImportancePre } = await import('./memory-importance.js')
3425
+ const evDir = path.join(dshHome(), 'memory', 'evidence', 'events')
3426
+ const agg = aggregateEvidenceEventsPre(scanEvidenceEventsPre({
3427
+ listFiles: () => readdirSync(evDir).filter((x) => x.endsWith('.jsonl')),
3428
+ readFile: (name) => readFileSync(path.join(evDir, name), 'utf8'),
3429
+ }, {}))
3430
+ for (const [mid, a] of agg) impMap.set(mid, computeImportancePre(a).importance)
3431
+ } catch (eImp) { try { diag('evidence-agg 降级为中性(impMap 空): ' + String((eImp && eImp.message) || eImp).slice(0, 140)) } catch (_) {} }
3432
+ const l0Hits = l0Corpus.filter((c) => c.lex > 0 || (typeof c.sem === 'number' && c.sem >= 0.5))
3433
+ // 时间臂(2026-09-09):查询含中文时间表达 → 解析 [startMs,endMs),候选 label 中的日志日期
3434
+ // (YYYY-MM-DD)命中 → temp=1 软提升;非日期来源(MEMORY.md/~userfile)不给 temp(中性)。
3435
+ // 软性第三臂只提升不硬过滤;查询无时间表达 → tr=null → 不传 temp → 与现状逐字节一致。
3436
+ let tr = null
3437
+ let dateMsOf = null
3438
+ try {
3439
+ const tp = await import('./temporal-parse.js')
3440
+ tr = tp.parseTemporalQueryPre(query, { now: Date.now() })
3441
+ dateMsOf = tp.labelToDateMsPre
3442
+ } catch (eTr) { try { diag('temporal-parse 降级(无时间臂): ' + String((eTr && eTr.message) || eTr).slice(0, 140)) } catch (_) {} }
3443
+ const tempFieldOf = (c) => {
3444
+ if (!tr || typeof dateMsOf !== 'function') return undefined
3445
+ const d = dateMsOf(c.label)
3446
+ if (d === null || d === undefined) return undefined
3447
+ return d >= tr.startMs && d < tr.endMs ? 1 : 0
3448
+ }
3449
+ // P8(2026-09-09):RRF 融合排序取代 lex 绝对主导的字典序(旧排序下 sem 仅在 lex 全等时平局生效=语义臂实效为零)。
3450
+ // rank-space 1/(k+rank/divisor),k=60 复用 FUSION_RRF_K_V1(Hindsight #3956:禁止 score-space 加权)。
3451
+ // 开关 opts.fusion:'rrf'(默认)|'legacy';dense 臂=sem(缺席→null 臂贡献 0,等价纯词法),lex 臂=词命中数。
3452
+ // fail-soft:动态 import 失败/任何异常 → 回落 legacy 字典序;_jsSemanticRank 抛错时 sem 全空,RRF 自动退化为纯词法序。
3453
+ let l0Top = null
3454
+ if ((opts && opts.fusion) !== 'legacy') {
3455
+ try {
3456
+ const { rankFusionRRFPre } = await import('./recall-fusion.js')
3457
+ const byId = new Map(l0Hits.map((c) => [c.id, c]))
3458
+ l0Top = rankFusionRRFPre(l0Hits.map((c) => ({ memoryId: c.id, dense: typeof c.sem === 'number' ? c.sem * (0.5 + 0.5 * (impMap.get(c.id) != null ? impMap.get(c.id) : 0.5)) : null, lex: c.lex, temp: tempFieldOf(c) })))
3459
+ .map((f) => byId.get(f.memoryId))
3460
+ .filter(Boolean)
3461
+ .slice(0, Math.max(1, Number(limit) || 8))
3462
+ } catch (eRrf) {}
3463
+ }
3464
+ if (!l0Top) {
3465
+ l0Top = l0Hits.slice()
3466
+ .sort((a, b) => b.lex - a.lex || (b.sem || 0) - (a.sem || 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
3467
+ .slice(0, Math.max(1, Number(limit) || 8))
3468
+ }
3469
+ if (l0Top.length) {
3470
+ out.push('== L0 命中(摘要,含 id/得分/匹配原因;展开单条原文传 expand="mem_xxx") ==')
3471
+ for (const c of l0Top) {
3472
+ const sc = typeof c.sem === 'number' ? c.sem.toFixed(2) : String(c.lex)
3473
+ out.push('· [' + c.id + '] ×' + sc + ' ' + (c.reason || '语义') + ' ' + c.label + ' — ' + c.l0)
3474
+ }
3475
+ }
3476
+ } else {
3477
+ for (const log of logs) {
3478
+ if (hits.length >= limit) break
3479
+ await scanFile(log.name, path.join(p.projectDir, log.name), 2)
3480
+ }
3481
+ for (const r of reflections) {
3482
+ if (hits.length >= limit) break
3483
+ await scanFile('reflections/' + r.name, path.join(p.reflectDir, r.name), 2)
3484
+ }
3485
+ await scanFile('~' + p.userFile.slice(homedir().length), p.userFile)
3486
+ await scanFile(p.projectDir + '/MEMORY.md', p.notesPath)
3487
+ if (hits.length) {
3488
+ out.push('== 本地记忆文件命中 ==')
3489
+ for (const h of hits) out.push('· ' + h.where + ':\n' + h.matches.map((m) => ' - ' + m).join('\n'))
3490
+ }
3305
3491
  }
3306
3492
  // 其他 DSH 工作区记忆(跨工作区检索:从 sessions 发现的所有工作区,任何模型/新会话都能查到)
3307
3493
  const otherHits = []
@@ -3341,10 +3527,85 @@ class MemoryEngine {
3341
3527
  for (const l of sessionLines) out.push(l)
3342
3528
  }
3343
3529
  } catch (e) {}
3530
+ // P2(2026-09-09):语义臂 —— 输入用 T1 的 L0(≈160 字符/条)而非全文(e5 512 token 上限会截断),
3531
+ // 与词法臂互补:主题性查询("发布踩坑")可召回词法不重合但语义相关的记忆。
3532
+ // fail-soft:非 C2 环境(_jsSemanticRank=null)/动态 import 失败/任何异常 → 跳过语义节,
3533
+ // 纯词法照常返回,不报错不阻塞。词法臂与全文展示零改动;recall 签名与工具 schema 零改动。
3534
+ // P4:l0Mode 时语义分已并入 L0 列表(match_reason),独立语义节跳过防重复注入。
3535
+ if (scope === 'all' && !l0Mode && typeof this._jsSemanticRank === 'function') {
3536
+ try {
3537
+ const { buildL0IndexPre } = await import('./l0-extract.js')
3538
+ const { createHash } = await import('node:crypto')
3539
+ const semanticArm = async (env) => {
3540
+ const corpus = []
3541
+ for (const src of env.sources) {
3542
+ const text = await this.readTextSafe(src.path)
3543
+ if (!text) continue
3544
+ const items = env.buildL0IndexPre(text)
3545
+ for (const it of items) corpus.push({ memoryId: it.id, text: it.l0, label: src.label })
3546
+ if (corpus.length >= env.maxRecords) break
3547
+ }
3548
+ if (!corpus.length) return []
3549
+ corpus.length = Math.min(corpus.length, env.maxRecords)
3550
+ // 语料版本:canonical sorted [id,L0] 元组哈希(idx_ 前缀匹配 rank() 校验);同语料同 miv → 引擎嵌入缓存命中
3551
+ const canon = corpus.map((c) => [c.memoryId, c.text]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
3552
+ const miv = 'idx_' + env.createHash('sha256').update(JSON.stringify(canon)).digest('hex').slice(0, 32)
3553
+ const rank = await this._jsSemanticRank({ memoryIndexVersion: miv, records: corpus.map((c) => ({ memoryId: c.memoryId, text: c.text })) }, env.query)
3554
+ if (!rank || !rank.scores || !rank.scores.size) return []
3555
+ return [...rank.scores.entries()]
3556
+ .filter(([, sc]) => sc >= env.minScore)
3557
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
3558
+ .slice(0, Math.max(2, env.limit))
3559
+ .map(([id, sc]) => {
3560
+ const rec = corpus.find((c) => c.memoryId === id) || {}
3561
+ return { label: rec.label || '', id8: String(id).slice(4, 12), score: sc, l0: rec.text || '' }
3562
+ })
3563
+ }
3564
+ const semSources = []
3565
+ for (const log of logs) semSources.push({ label: log.name, path: path.join(p.projectDir, log.name) })
3566
+ for (const rf of reflections) semSources.push({ label: 'reflections/' + rf.name, path: path.join(p.reflectDir, rf.name) })
3567
+ semSources.push({ label: p.projectDir + '/MEMORY.md', path: p.notesPath })
3568
+ semSources.push({ label: '~' + p.userFile.slice(homedir().length), path: p.userFile })
3569
+ const semHits = await semanticArm({ sources: semSources, buildL0IndexPre, createHash, query, limit, maxRecords: 256, minScore: 0.5 })
3570
+ if (semHits.length) {
3571
+ out.push('== 语义命中(L0 摘要,按相关度;可按锚点下钻) ==')
3572
+ for (const s of semHits) out.push('· ' + s.label + ' [' + s.id8 + '] ×' + s.score.toFixed(2) + ' ' + s.l0)
3573
+ }
3574
+ } catch (eSem) {}
3575
+ }
3344
3576
  if (!out.length) return '[记忆检索] 查询 "' + q + '" —— 未找到相关记忆。'
3345
3577
  return '[记忆检索] "' + q + '":\n' + out.join('\n')
3346
3578
  }
3347
3579
 
3580
+ /** P4(2026-09-09):按 mem_<32hex> 展开记忆原文。复用 parseAnchors 的字节区间定位(UTF-8 半开 [byteStart,byteEnd)
3581
+ * + recordDigest,memory-anchor.js:82),每条原文=锚点标记之后到下一条标记之前的切片,天然不串条。
3582
+ * 搜索面与 L0 语料同源(当前项目日志/反思/项目笔记/用户级记忆);找不到如实回报,不猜测不串条。 */
3583
+ async expandMemoryRecordPre(id, agent) {
3584
+ const mid = String(id || '').trim()
3585
+ if (!/^mem_[0-9a-f]{32}$/.test(mid)) return 'memory_recall: expand 需要合法记忆 id(mem_ + 32 个十六进制字符),收到: ' + mid.slice(0, 64)
3586
+ const p = await this.resolvePaths(agent)
3587
+ const sources = []
3588
+ for (const log of await this.listDailyLogs(p.projectDir, 40)) sources.push({ label: log.name, path: path.join(p.projectDir, log.name) })
3589
+ for (const r of await this.listReflections(p.reflectDir, 30)) sources.push({ label: 'reflections/' + r.name, path: path.join(p.reflectDir, r.name) })
3590
+ sources.push({ label: p.projectDir + '/MEMORY.md', path: p.notesPath })
3591
+ sources.push({ label: '~' + p.userFile.slice(homedir().length), path: p.userFile })
3592
+ const searched = []
3593
+ for (const src of sources) {
3594
+ searched.push(src.label)
3595
+ let buf
3596
+ try { buf = await readFile(src.path) } catch (e) { continue }
3597
+ if (buf.length > INDEX_MAX_FILE_BYTES) continue
3598
+ let parsed
3599
+ try { parsed = parseAnchors(buf) } catch (e) { continue }
3600
+ const rec = (parsed.records || []).find((r) => r.kind === 'anchored' && r.memoryId === mid)
3601
+ if (!rec) continue
3602
+ const body = buf.subarray(rec.byteStart, rec.byteEnd).toString('utf8').trim()
3603
+ if (!body) continue
3604
+ return '[记忆展开] ' + mid + ' ← ' + src.label + '(第 ' + rec.lineStart + '-' + rec.lineEnd + ' 行,' + rec.chars + ' 字符,digest ' + String(rec.recordDigest).slice(0, 16) + ')\n' + body
3605
+ }
3606
+ return '[记忆展开] 未找到 ' + mid + '(已搜索: ' + searched.join('、') + ')。该条可能在其他工作区/已归档文件中,可换关键词重检索。'
3607
+ }
3608
+
3348
3609
  /** 多关键词扫描三层记忆(供智能检索用),返回 {where, line} 列表。 */
3349
3610
  async collectHits(keywords, limit) {
3350
3611
  const p = await this.resolvePaths(undefined)
@@ -4075,6 +4336,8 @@ class MemoryEngine {
4075
4336
 
4076
4337
  /** 调用 DSH subagent 发散/提炼一段文本(90s 超时,结果取 text 块;parent 用最近 agent)。 */
4077
4338
  async runSubagent(text, label, agent, timeoutMs) {
4339
+ // 2.2.7:插件 context 已卸载(重启/禁用中)→ 不再 spawn,避免向失效 context 注册 effect
4340
+ if (this._disposed) return ''
4078
4341
  const subagents = this._subagents
4079
4342
  if (!subagents) return ''
4080
4343
  // 配置性错误熔断(问题③):UNKNOWN_MODEL 等错误不会自愈,熔断期内直接返回空,不再反复 spawn(卡顿根因)
@@ -4106,7 +4369,8 @@ class MemoryEngine {
4106
4369
  const cfgProvider = String(this.config.subagentProvider || '').trim()
4107
4370
  if (cfgProvider && Array.isArray(registered) && registered.includes(cfgProvider)) providerName = cfgProvider
4108
4371
  const controller = new AbortController()
4109
- const timer = setTimeout(() => controller.abort(label + ' timeout'), Math.max(Number(timeoutMs) || 90000, 1000))
4372
+ const tMs = Math.max(Number(timeoutMs) || 90000, 1000)
4373
+ const timer = setTimeout(() => controller.abort(label + ' timeout'), tMs)
4110
4374
  let run
4111
4375
  try {
4112
4376
  // prompt 必须是 block 数组(createUserMessage 校验 content.some)
@@ -4121,13 +4385,16 @@ class MemoryEngine {
4121
4385
  ...(subModel ? { agentOptions: { model: subModel } } : {}),
4122
4386
  })
4123
4387
  // 套娃防护(问题②):登记本插件 spawn 的子代理,其生命周期事件在 pre-step/turn-stopping 零处理
4124
- try { if (run && run.agent) this._ownSubagents.add(run.agent) } catch (eReg) {}
4388
+ // 2026-09-09 修复:DSH 0.1.2 in-process run 对象字段为 localAgent(旧版为 agent),取两者兼容
4389
+ try { const ra = run && (run.localAgent || run.agent); if (ra) this._ownSubagents.add(ra) } catch (eReg) {}
4125
4390
  // UNKNOWN_MODEL 自保:子代理路由为两源拼接(provider 继承父会话 + 模型落宿主全局默认),
4126
4391
  // 父 provider 目录可能不含该模型(如 opencode-go-free × deepseek-v4-flash);
4127
4392
  // 此时回退用父会话抽屉当前模型(settings.yaml agent-default-model)重试一次。
4128
4393
  let result
4129
4394
  try {
4130
- result = await run.result
4395
+ // 2026-09-09 修复:run.result 依赖 child.whenIdle() 且 DSH 无内置超时,卡死会泄漏 inflight 并使 finally 不执行。
4396
+ // 包 withTimeout 兜底(超时返回 null → 空结果),保证 finally 的递减/dispose/回收总能跑。
4397
+ result = await this.withTimeout(run.result, tMs, null)
4131
4398
  } catch (eRoute) {
4132
4399
  const em0 = eRoute && eRoute.message ? String(eRoute.message) : ''
4133
4400
  if (!/UNKNOWN_MODEL|has no configured model/.test(em0)) throw eRoute
@@ -4151,12 +4418,17 @@ class MemoryEngine {
4151
4418
  ...(parent ? { parent } : {}),
4152
4419
  agentOptions: { model: fbModel },
4153
4420
  })
4154
- result = await run2.result
4421
+ result = await this.withTimeout(run2.result, tMs, null)
4155
4422
  }
4156
4423
  const blocks = result && result.output ? result.output : []
4157
4424
  return blocks.filter((b) => b && b.type === 'text').map((b) => b.text).join('').trim()
4158
4425
  } catch (e) {
4159
4426
  const em = e && e.message ? e.message : String(e)
4427
+ // 2.2.7:context 已卸载导致的 effect 创建失败是重启/卸载竞态,不是功能缺陷 —— 只记一次 diag,不刷屏
4428
+ if (/inactive context/i.test(em)) {
4429
+ if (!this._inactiveWarned) { this._inactiveWarned = true; diag('subagent ' + label + ' skipped: plugin context inactive (宿主已卸载,重启后自动恢复)') }
4430
+ return ''
4431
+ }
4160
4432
  console.error('[dsh-auto-memory] ' + label + ' failed', em)
4161
4433
  diag('subagent ' + label + ' failed: ' + em)
4162
4434
  // 配置性错误熔断(问题③):UNKNOWN_MODEL / 无配置模型 / 供应商不含该模型——重试无意义,30 分钟内不再 spawn
@@ -4174,7 +4446,9 @@ class MemoryEngine {
4174
4446
  }
4175
4447
  // 子代理痕迹回收(2026-09-08):本插件的一次性子代理(auto-memory-*)结束即把会话目录移入备份,
4176
4448
  // 避免 ~/.dsh/sessions 下插件痕迹堆积拖慢宿主会话列表。失败静默,由每日兜底巡检补收。
4177
- if (run && run.agent) { try { await this.recycleSubagentSession(run.agent) } catch (e3) {} }
4449
+ // 2026-09-09 修复:DSH 0.1.2 run 对象字段为 localAgent(旧版为 agent),旧写法 run.agent 恒 undefined 导致回收永不触发(子代理爆炸)
4450
+ const runAgent = run && (run.localAgent || run.agent)
4451
+ if (runAgent) { try { await this.recycleSubagentSession(runAgent) } catch (e3) {} }
4178
4452
  }
4179
4453
  }
4180
4454
 
@@ -4188,13 +4462,30 @@ class MemoryEngine {
4188
4462
  const sid = session && (session.id || (header && header.id)) ? String(session.id || header.id) : ''
4189
4463
  if (!sid) return { ok: false, reason: 'no-id' }
4190
4464
  if (!header || header.origin !== 'subagent') return { ok: false, reason: 'not-subagent' }
4465
+ // 2.3.0:Windows 下 DSH 常持有会话文件句柄(子代理刚结束时仍在写)→ rename 抛 EPERM。
4466
+ // 同一 sid 失败后冷却 30 分钟不再尝试(避免刷屏),占用释放后由每日 sweep 兜底回收。
4467
+ const cool = this._gcCooldown || (this._gcCooldown = new Map())
4468
+ const coolUntil = cool.get(sid) || 0
4469
+ if (Date.now() < coolUntil) return { ok: false, reason: 'cooldown' }
4191
4470
  const dir = await this.locateSessionDir(sid, header)
4192
4471
  if (!dir) return { ok: false, reason: 'not-located' }
4193
4472
  const wsDir = path.basename(path.dirname(dir))
4194
4473
  const backupRoot = path.join(dshHome(), 'subagent-gc-backup')
4195
4474
  const dest = path.join(backupRoot, wsDir, sid)
4196
4475
  await mkdir(path.dirname(dest), { recursive: true })
4197
- await rename(dir, dest)
4476
+ let renamed = false
4477
+ let lastErr = null
4478
+ for (const delay of [0, 300, 1000]) {
4479
+ if (delay) await new Promise((r) => setTimeout(r, delay))
4480
+ try { await rename(dir, dest); renamed = true; break } catch (e) { lastErr = e }
4481
+ }
4482
+ if (!renamed) {
4483
+ cool.set(sid, Date.now() + 30 * 60 * 1000)
4484
+ // 首次失败记 diag,后续静默(同一 sid 冷却期内不再刷屏)
4485
+ const warned = this._gcWarned || (this._gcWarned = new Set())
4486
+ if (!warned.has(sid)) { warned.add(sid); diag('subagent gc deferred (file locked by host): ' + sid) }
4487
+ return { ok: false, reason: 'locked', sid }
4488
+ }
4198
4489
  // 投影缓存同步移走,避免孤儿条目长期占用索引
4199
4490
  try {
4200
4491
  const cacheFile = path.join(dshHome(), 'storages', 'session_projcache', 'sessions', sid)
@@ -4244,9 +4535,11 @@ class MemoryEngine {
4244
4535
  const keepDays = Math.max(0, Number(this.config.subagentGcKeepDays) || 0)
4245
4536
  const scan = await scanPluginSubagentSessions({ sessionsRoot, labelPrefix: PLUGIN_LABEL_PREFIX, keepMs: 0 })
4246
4537
  const now = Date.now()
4247
- // 无论保留期多长,最近 10 分钟的一律不动(可能与正在运行的子代理竞争)
4248
- const floorMs = Math.max(keepDays * 24 * 3600 * 1000, 10 * 60 * 1000)
4249
- const eligible = scan.candidates.filter((c) => now - c.mtimeMs >= floorMs)
4538
+ // 无论保留期多长,最近 60 分钟的一律不动(2.3.0:Windows 下宿主可能长期持有会话文件句柄,
4539
+ // 10 分钟窗口不足以判断"已不再写入";占用中的目录 rename 必失败)
4540
+ const floorMs = Math.max(keepDays * 24 * 3600 * 1000, 60 * 60 * 1000)
4541
+ const cool = this._gcCooldown || (this._gcCooldown = new Map())
4542
+ const eligible = scan.candidates.filter((c) => now - c.mtimeMs >= floorMs && now >= (cool.get(c.sid) || 0))
4250
4543
  if (!eligible.length) return { ok: true, scanned: scan.scanned, recycled: 0 }
4251
4544
  const res = await recycleSessions({
4252
4545
  candidates: eligible,
@@ -4255,6 +4548,8 @@ class MemoryEngine {
4255
4548
  apply: true,
4256
4549
  })
4257
4550
  diag('subagent gc sweep: recycled=' + res.moved.length + ' failed=' + res.failed.length + ' bytes=' + res.bytes)
4551
+ // 2.3.0:被占用的目录进入 30 分钟冷却,避免每日巡检重复无效重试
4552
+ for (const f of res.failed || []) { if (f && f.sid) cool.set(String(f.sid), Date.now() + 30 * 60 * 1000) }
4258
4553
  return { ok: true, scanned: scan.scanned, recycled: res.moved.length, failed: res.failed.length, bytes: res.bytes }
4259
4554
  } catch (e) {
4260
4555
  diag('subagent gc sweep failed: ' + (e && e.message ? e.message : String(e)))
@@ -5840,6 +6135,63 @@ export function apply(ctx, config) {
5840
6135
  return await engine._jsSemantic.rank(corpusSnap, queryText)
5841
6136
  } catch (_) { return null }
5842
6137
  }
6138
+ // P13(2026-09-09):C3(python)语义臂 —— 经 sidecar recall_rank 调 worker.dense_search
6139
+ // (三重过滤:workspaceRef+scope+miv),返回 {scores:Map<memoryId,score>, source:'c3'}。
6140
+ // 前提:tier=c3,或 auto 档且 python 模型就绪(auto 先探 python,失败回 C2,见 _semanticRankBest)。
6141
+ // 任何失败(sidecar 抛错/超时/空/档位不符)→ null → 调用方 fail-soft 回退。
6142
+ engine._pythonModelReady = async () => {
6143
+ try {
6144
+ const fsMod = await import('node:fs')
6145
+ const pyCands = (engine._pythonSetup ? [engine._pythonSetup.modelPath()] : []).concat([
6146
+ path.join(pluginRootDir(), 'python', 'bench', 'models-xenova-bge-m3-int8', 'onnx', 'model_int8.onnx'),
6147
+ ])
6148
+ return pyCands.some((c) => fsMod.existsSync(c))
6149
+ } catch (_) { return false }
6150
+ }
6151
+ engine._pySemanticRank = async (corpusSnap, queryText) => {
6152
+ try {
6153
+ const tier = await engine.resolveSemanticTier()
6154
+ const autoProbe = String(engine.config.semanticEngineMode || 'auto') === 'auto'
6155
+ if (tier !== 'c3' && !(autoProbe && await engine._pythonModelReady())) return null
6156
+ const sc = engine._pythonSidecar
6157
+ if (!sc || typeof sc.request !== 'function') return null
6158
+ // 语料快照:与 context-host index-sync 同源(buildSourceCatalog+CorpusRegistry)。
6159
+ // workspaceKey/scope/miv 须与已同步索引一致,否则 worker 三重过滤拒绝 → 空 scores → 回退。
6160
+ let py = null
6161
+ const pp = corpusSnap && corpusSnap.pyPaths
6162
+ if (pp && pp.ws) {
6163
+ const cat = buildSourceCatalog({ workspaceKey: pp.ws, userMemoryPath: pp.userFile, workspaceMemoryPath: pp.notesPath, todayLogPath: pp.logPath })
6164
+ if (!engine._pyRankCorpusRegistry) engine._pyRankCorpusRegistry = new CorpusRegistry({ sidecarDir: path.join(dshHome(), 'memory', 'index', 'files') })
6165
+ const res = engine._pyRankCorpusRegistry.get(cat)
6166
+ if (res && res.ok && res.snapshot.memoryIndexVersion) {
6167
+ py = { workspaceKey: canonicalize(pp.ws), scope: 'Workspace', memoryIndexVersion: res.snapshot.memoryIndexVersion }
6168
+ }
6169
+ }
6170
+ if (!py) return null
6171
+ const r = await sc.request('recall_rank', {
6172
+ workspaceKey: py.workspaceKey, scope: py.scope || 'Workspace',
6173
+ miv: py.memoryIndexVersion, query: queryText, topK: 20,
6174
+ }, { timeoutMs: 8000 })
6175
+ if (!r || !r.ok || !r.frame || !r.frame.payload) return null
6176
+ const arr = r.frame.payload.scores || []
6177
+ const scores = new Map()
6178
+ for (const it of arr) {
6179
+ if (it && typeof it.memoryId === 'string' && typeof it.score === 'number' && Number.isFinite(it.score)) scores.set(it.memoryId, it.score)
6180
+ }
6181
+ return scores.size ? { scores, source: 'c3' } : null
6182
+ } catch (_) { return null }
6183
+ }
6184
+ // P13 择优:lexical → null;python 档 → _pySemanticRank;auto → 先 py(模型就绪,失败回 C2);js/C2 → _jsSemanticRank。
6185
+ // 逐级 fail-soft,任何一级失败自动落到下一级,绝不抛错阻塞检索。_jsSemanticRank 保留不动(激活路径仍在用)。
6186
+ engine._semanticRankBest = async (corpusSnap, queryText) => {
6187
+ try {
6188
+ const mode = String(engine.config.semanticEngineMode || 'auto')
6189
+ if (mode === 'lexical') return null
6190
+ const r = await engine._pySemanticRank(corpusSnap, queryText)
6191
+ if (r) return r
6192
+ } catch (_) { /* fall through */ }
6193
+ try { return await engine._jsSemanticRank(corpusSnap, queryText) } catch (_) { return null }
6194
+ }
5843
6195
  // JS 端判定核(2026-08-27):读策略工件(懒加载+缓存),对 C2 检索结果做 fv2 决策。
5844
6196
  // 完全独立于 Python——JS 端默认闭环(C2 检索 + JS 判定 + M6 投递)的核心。
5845
6197
  engine._jsDecideCtx = null
@@ -5957,6 +6309,11 @@ export function apply(ctx, config) {
5957
6309
  const sessionQuery = ctx.get('sessionQuery')
5958
6310
  engine._sessionQuery = sessionQuery
5959
6311
  engine._subagents = ctx.get('subagents') || undefined
6312
+ // 2.2.7 修复:宿主 context 卸载后,残留的定时器/面板请求仍会调 runSubagent,
6313
+ // DSH 0.1.2 的 in-process 子代理会向已失效 context 注册 effect → "cannot create effect on inactive context" 刷屏。
6314
+ // 记 dispose 标记,runSubagent 入口直接短路。
6315
+ engine._disposed = false
6316
+ try { ctx.on('dispose', () => { engine._disposed = true }) } catch (eDispose) {}
5960
6317
  // 时间检测用的服务(软获取,缺失时定时兜底自动降级)
5961
6318
  engine._sessionsSvc = ctx.get('sessions') || undefined
5962
6319
  engine._agentSvc = ctx.get('agent') || undefined
@@ -6219,8 +6576,8 @@ export function apply(ctx, config) {
6219
6576
  ? await engine.writePlanSnapshot(p.projectDir, writeH)
6220
6577
  : await engine.writeHandoffLedger(p.projectDir, writeH)
6221
6578
  if (!r || !r.ok) return 'memory_note: 白板写入失败(' + ((r && r.error) || '未知') + ')。'
6222
- if (args.kind === 'plan') { engine.state.planText = writeH; engine.state.loadedAt = Date.now() }
6223
- else { engine.state.latestHandoffText = '# 交接账本 · ' + engine.memToday() + ' ' + nowHm() + '\n\n' + writeH; engine.state.loadedAt = Date.now() }
6579
+ if (args.kind === 'plan') { engine.state.planText = (r && r.final) || writeH; engine.state.loadedAt = Date.now() }
6580
+ else { engine.state.latestHandoffText = '# 交接账本 · ' + engine.memToday() + ' ' + nowHm() + '\n\n' + ((r && r.clean) || writeH); engine.state.loadedAt = Date.now() }
6224
6581
  return (args.kind === 'plan'
6225
6582
  ? '白板 PLAN.md 已更新' + (r.archived ? '(旧版已归档: ' + r.archived + ')' : '(首建)') + ': ' + r.path
6226
6583
  : '交接账本已写入: ' + r.path) + '\n请在本轮回复正文向用户转述本次更新要点。'
@@ -6299,11 +6656,13 @@ export function apply(ctx, config) {
6299
6656
  : label + '(' + file + '):\n' + text
6300
6657
  }),
6301
6658
 
6302
- defineTool('memory_recall', '检索记忆:本地记忆文件(当前工作区 + 其他所有工作区的每日日志、项目笔记、用户级记忆、反思、交接白板)关键词匹配 + 历史 DSH 会话全文检索(如部署启用)。开发/排查中不懂的、用户提到过去的做法/讨论/决定而当前上下文没有时调用——跨工作区的记忆也能检索到(结果标注来源工作区)。查询必须自包含。scope=handoff 只搜交接白板语料(白板 PLAN+交接账本——接续长任务时先查这里);scope=sessions 只搜历史会话。**检索后必须在本轮回复正文中向用户转述:检索了什么、找到什么(或没找到)**。', {
6303
- query: { type: 'string', required: true, description: '检索关键词或自包含描述。' },
6659
+ defineTool('memory_recall', '检索记忆:本地记忆文件(当前工作区 + 其他所有工作区的每日日志、项目笔记、用户级记忆、反思、交接白板)关键词匹配 + 历史 DSH 会话全文检索(如部署启用)。开发/排查中不懂的、用户提到过去的做法/讨论/决定而当前上下文没有时调用——跨工作区的记忆也能检索到(结果标注来源工作区)。查询必须自包含。默认返回 L0 摘要列表(每条含 id/得分/匹配原因,省 token);需要某条完整原文时传 expand="mem_xxx" 按需展开(渐进式加载)。scope=handoff 只搜交接白板语料(白板 PLAN+交接账本——接续长任务时先查这里);scope=sessions 只搜历史会话。**检索后必须在本轮回复正文中向用户转述:检索了什么、找到什么(或没找到)**。', {
6660
+ query: { type: 'string', required: true, description: '检索关键词或自包含描述;expand 时填该记忆 id 即可。' },
6304
6661
  limit: { type: 'integer', description: '最多返回条数,缺省 8。' },
6305
6662
  scope: { type: 'string', enum: ['all', 'handoff', 'sessions'], description: '检索范围:all=全部(默认), handoff=交接白板语料(跨窗口续命材料), sessions=历史 DSH 会话。' },
6306
- }, async (args, exec) => engine.recall(args.query, args.limit, exec.agent, args.scope || 'all')),
6663
+ format: { type: 'string', enum: ['l0', 'full'], description: '本地记忆命中格式:l0=L0 摘要列表(默认,每条含 id/得分/匹配原因),full=整条原文(旧行为)。' },
6664
+ expand: { type: 'string', description: '按记忆 id(mem_ + 32 个十六进制字符)展开该条完整原文;提供时忽略检索语义。' },
6665
+ }, async (args, exec) => engine.recall(args.query, args.limit, exec.agent, args.scope || 'all', { format: args.format || 'l0', expand: args.expand })),
6307
6666
 
6308
6667
  defineTool('memory_maintain', '维护记忆(30 天蒸馏):把 days(缺省30)天前的 .dsh-memory/ 每日日志交给 AI 蒸馏提炼出有长期价值的要点写入项目 MEMORY.md,原文保底归档到 .dsh-memory/archive/ 后从活跃日志移除。AI 不可用时降级为原样归档,不丢信息。', {
6309
6668
  days: { type: 'integer', description: '归档阈值天数,缺省 30。' },