@a9i5k4/dsh-auto-memory 2.3.1 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -23,6 +23,7 @@ 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
+ import { randomUUID } from 'node:crypto'
26
27
  import path from 'node:path'
27
28
  import { fileURLToPath } from 'node:url'
28
29
  import { MemoryDocumentStore } from './memory-writer.js'
@@ -44,7 +45,7 @@ import { pickConsolidationTextPre } from './intent-clean.js'
44
45
  import { createStorageManagerPre } from './storage-manage.js'
45
46
  import { rankWorkspacesByMemoryRecencyPre } from './ws-overview-rank.js'
46
47
  import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames } from './subagent-gc.js'
47
- import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre, findSessionModelPre } from './water-window.js'
48
+ import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre, findSessionModelPre, scanPressureSignalsPre } from './water-window.js'
48
49
  import { buildSourceCatalog, CorpusRegistry, canonicalize } from './m4-corpus.js'
49
50
  import * as nodeZlib from 'node:zlib'
50
51
  import { AsyncLocalStorage } from 'node:async_hooks'
@@ -161,6 +162,7 @@ export const API = {
161
162
  'semantic-deep-detect': '/api/dsh-auto-memory/semantic-deep-detect',
162
163
  'handoff-state': '/api/dsh-auto-memory/handoff-state',
163
164
  'handoff-continue': '/api/dsh-auto-memory/handoff-continue',
165
+ 'handoff-permission': '/api/dsh-auto-memory/handoff-permission',
164
166
  'auto-continue-state': '/api/dsh-auto-memory/auto-continue-state',
165
167
  'auto-continue-decide': '/api/dsh-auto-memory/auto-continue-decide',
166
168
  'subagent-gc': '/api/dsh-auto-memory/subagent-gc',
@@ -173,6 +175,25 @@ export const API = {
173
175
  models: '/api/dsh-auto-memory/models',
174
176
  }
175
177
 
178
+ /**
179
+ * 记忆文件**容量上限**(字符,非字节)。超过即触发一次自动整理(先 AI 折叠、后退整条归档),
180
+ * 整理后仍超才拒绝写入 —— 正常情况下"新记忆永不堵在外面"。
181
+ * 只影响"多久整理一次",不直接决定每轮注入体积(那是 injectBudgetChars 的职责,两者互不相干)。
182
+ */
183
+ const DEFAULT_NOTE_CAPACITY_CHARS = 12000
184
+ const DEFAULT_USER_CAPACITY_CHARS = 12000
185
+ /**
186
+ * 整理保护窗口:最近写入的这么多字符**不参与回收**(软下限)。
187
+ * 硬底线是"至少保留最新 1 条记录":若保护窗口自身就超过上限,允许对其折叠成要点(但不整条删除),
188
+ * 否则会在"最近内容本身就很大"时重新死锁(旧实现正是这么堵死的)。
189
+ */
190
+ const COMPACT_PROTECT_RECENT_CHARS = 2000
191
+ /** 折叠产物的大小上限(字符),避免"折叠结果 + 保留内容"仍然超容量。 */
192
+ const COMPACT_FOLD_MAX_CHARS = 1500
193
+ /** 同一层两次 **AI 折叠** 之间的最小间隔(ms);**按层独立计时**,互不牵连。
194
+ * 注意:节流只作用于"折叠"这一步,不阻止"整条归档"——否则短时间内反复超容量时仍会拒绝写入。 */
195
+ const COMPACT_THROTTLE_MS = 10 * 60 * 1000
196
+
176
197
  const DEFAULT_CONFIG = {
177
198
  /** 用户级记忆目录(绝对路径或 ~ 开头)。 */
178
199
  userMemoryDir: '~/.dsh/memory',
@@ -193,6 +214,20 @@ const DEFAULT_CONFIG = {
193
214
  subagentModel: '',
194
215
  /** subagent 模型所属 provider(设置页模型抽屉点选时与 subagentModel 成对写入;留空=spawn/首个注册项,兼容旧配置)。 */
195
216
  subagentProvider: '',
217
+ /**
218
+ * subagent 推理强度(DSH 0.1.5 的 agentOptions.reasoningEffort);留空=跟随模型默认。
219
+ * 合法值 off|low|high|max(DeepSeek 适配器口径);off 关闭思维链,high 为默认,max 最深。
220
+ * 非该集合的值会被静默忽略(见 subAgentOptions),避免 UNSUPPORTED_REASONING_EFFORT 触发子代理熔断。
221
+ */
222
+ subagentReasoningEffort: '',
223
+ /**
224
+ * 项目笔记 / 用户级记忆的**容量上限**(字符)。超过即自动整理(先 AI 折叠成要点,失败退回整条归档),
225
+ * 整理后仍超才拒绝写入 —— 新记忆不会被堵在外面。默认 12000(实测一个"信息量大"的工作日约写入 8700 字符,
226
+ * 12k 留出约 1.4 倍余量,避免同一天反复折叠)。与「注入预算」injectBudgetChars 是两回事:
227
+ * 容量上限管文件本体大小,注入预算管每轮往提示里塞多少摘要。
228
+ */
229
+ noteCapacityChars: DEFAULT_NOTE_CAPACITY_CHARS,
230
+ userCapacityChars: DEFAULT_USER_CAPACITY_CHARS,
196
231
  /** M3a 只读记忆索引开关(默认关闭;开启后仅构建只读索引与调试快照,不修改任何 Markdown)。 */
197
232
  memoryFileIndexEnabled: false,
198
233
  /** M3b 稳定 Anchor 写入开关(默认关闭=全部旧 Markdown 写法逐字节不变;开启后记忆写路径经 anchor-aware 事务,CALENDAR.md 始终除外)。 */
@@ -471,6 +506,27 @@ function diag(msg) {
471
506
  } catch (e) {}
472
507
  }
473
508
 
509
+ /**
510
+ * 子代理 agentOptions(DSH 0.1.5)。
511
+ * 官方把「每个 agent 的模型与推理强度」统一收进 AgentOptions{provider?,model?,reasoningEffort?,maxTokens?},
512
+ * 由 SubagentStartRequest.agentOptions 下发(provider 侧需 capabilities.agentOptions === true,
513
+ * in-process 的 spawn/fork 支持,ACP/Codex/ClaudeCode 不支持)。属主可选,故空配置返回 null。
514
+ * reasoningEffort 是 branded string,运行时由适配器校验:DeepSeek 适配器实收 off|low|high|max(默认 high),
515
+ * 传其它值会在联网前抛 UNSUPPORTED_REASONING_EFFORT —— 因此这里做白名单过滤,非法值直接不下发。
516
+ * @param {object} config 插件配置(subagentModel / subagentReasoningEffort)
517
+ * @returns {{model?: string, reasoningEffort?: string}|null}
518
+ */
519
+ function subAgentOptions(config) {
520
+ try {
521
+ const out = {}
522
+ const model = String((config && config.subagentModel) || '').trim()
523
+ const effort = String((config && config.subagentReasoningEffort) || '').trim().toLowerCase()
524
+ if (model) out.model = model
525
+ if (effort && /^(off|low|high|max)$/.test(effort)) out.reasoningEffort = effort
526
+ return Object.keys(out).length ? out : null
527
+ } catch (e) { return null }
528
+ }
529
+
474
530
  // ---------- 会话隔离层(M0/M1: SessionRuntimeStore,消灭进程级全局状态) ----------
475
531
  // ---------- M2 ContextObserver 常量与有界结构(只观察,不检索,不注入) ----------
476
532
  /** EventEnvelope 结构版本(系统地图 N-01/C-01 契约)。 */
@@ -811,7 +867,7 @@ class MemoryEngine {
811
867
  this._subagentInflight = 0 // 子代理并发闸门(启动瞬间多路 spawn 叠加是卡顿根因之一)
812
868
  this._subagentCircuit = null // 配置性错误熔断 { until, reason }(UNKNOWN_MODEL 等,重试无意义)
813
869
  this._smartRecallFlight = undefined
814
- this._budgets = undefined // 每日写入预算:用户级4000/项目级3000字/天(所有会话共享,跨天重置)
870
+ this._lastCompactAt = { user: 0, note: 0 } // 每层独立的整理节流时间戳(旧实现是单变量,压过用户级会连带节流项目级)
815
871
  // M2: 进程级观察统计(无 owner 而无法归属 runtime 的事件只在这里留痕,绝不落入 default runtime)
816
872
  this._observerStats = { ingestedEnvelopes: 0, segmentsCreated: 0, droppedNoOwner: 0, disabledObservations: 0, seedTruncatedEvents: 0 }
817
873
  this.external = new ExternalMemory(this)
@@ -1830,6 +1886,18 @@ class MemoryEngine {
1830
1886
  }
1831
1887
  if (win <= 0) return
1832
1888
  const rt = this.runtimeFor(agent)
1889
+ // ① 压力硬信号(2026-09-10 实机取证)
1890
+ // provider 的 "requested tokens" **含路由预留的输出预算**,消息实际额度 = window − maxTokens
1891
+ // (实测:消息 666,044 + 预留 384,000 = 1,050,044 > 上限 1,048,576 → 400 CONTEXT_WINDOW_EXCEEDED)。
1892
+ // 同时那条 400 的错误文本是**唯一权威口径**(真实窗口 / 消息实占 / 预留),拿它自我校准。
1893
+ let events = []
1894
+ let sig = { reservedTokens: 0, overflow: null, compactionSeq: 0 }
1895
+ try {
1896
+ events = sessionEventsOf(agent && agent.session) || []
1897
+ sig = scanPressureSignalsPre(events)
1898
+ } catch (eSig) {}
1899
+ const reserve = Number(sessModel.maxTokens) || Number(sig.reservedTokens) || 0
1900
+ const effectiveWin = (reserve > 0 && reserve < win * 0.9) ? (win - reserve) : win
1833
1901
  let estTokens = 0
1834
1902
  let meterLayer = 'heuristic'
1835
1903
  try {
@@ -1847,43 +1915,76 @@ class MemoryEngine {
1847
1915
  const messages = extractSessionMessages(agent) || []
1848
1916
  estTokens = this.estimateSessionTokens(messages)
1849
1917
  }
1850
- // compaction 事件感知:harness 刚发生 compaction/summary|prune → 无论水位,立即补写交接材料
1918
+ // ⚠️ 2026-09-10 更正:先前这里加过「本地计量偏乐观 2×」的校准系数 —— **判错了,已撤除**。
1919
+ // 证据:压缩前最后一条 usage 的 totalTokens=660,728,而 provider 8 秒后报 "666044 in the messages",
1920
+ // 两者差 0.8% —— 本地取数(usage / tokenMeter)本来就是准的。先前看到的 316,610 是**压缩之后**的值。
1921
+ // 真正不可达的只有阈值:0.75 × 1,000,000 = 750,000 > 消息实上限 664,576(= 1,048,576 − 预留 384,000)。
1922
+ // compaction / 溢出事件感知:harness 刚压缩过、或 provider 刚报窗口超限 → 无论比例多少,立即补写材料并接续
1851
1923
  let compacted = false
1924
+ let overflowed = false
1852
1925
  try {
1853
- const events = sessionEventsOf(agent && agent.session)
1854
- for (let i = Math.max(0, events.length - 64); i < events.length; i++) {
1855
- const t = events[i] && events[i].type
1856
- if (t === 'compaction/summary' || t === 'compaction/prune') {
1857
- const seq = Number(events[i].seq) || i + 1
1858
- if (seq > (Number(rt.lastCompactionSeen) || 0)) { compacted = true; rt.lastCompactionSeen = seq }
1859
- }
1926
+ const sid0 = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
1927
+ const cseq = Number(sig.compactionSeq) || 0
1928
+ const oseq = sig.overflow ? Number(sig.overflow.seq) || 0 : 0
1929
+ // 首次观测该会话(含宿主刚重启、或插件刚挂到既有会话)只**建立基线**:把启动前就已经存在的
1930
+ // compaction/溢出记为「已见」。否则每次 dsh web 重启都会把历史压缩当成刚发生 →
1931
+ // 硬触发立刻 armed 并接续(2026-09-10 实机踩到:17:50 重启后 0.7 分钟即 armed,而水位只有 48%)。
1932
+ if (rt.waterCompactBaselineSid !== sid0) {
1933
+ rt.waterCompactBaselineSid = sid0
1934
+ rt.lastCompactionSeen = cseq
1935
+ rt.lastOverflowSeen = oseq
1936
+ } else {
1937
+ if (cseq > (Number(rt.lastCompactionSeen) || 0)) { compacted = true; rt.lastCompactionSeen = cseq }
1938
+ if (oseq > (Number(rt.lastOverflowSeen) || 0)) { overflowed = true; rt.lastOverflowSeen = oseq }
1860
1939
  }
1861
1940
  } catch (e) {}
1862
1941
  // 旧实现硬截断到 1.5 → 任何超额水位都显示成 150%,掩盖真实占用(用户实测 761692/131072 被显示为 150%)。
1863
1942
  // 现在如实上报,仅做防溢出的 99 倍上限。
1864
- const ratio = win > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / win, 99) : 0
1865
- rt.waterLevel = ratio
1943
+ const ratio = effectiveWin > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / effectiveWin, 99) : 0
1944
+ const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.75, 0.1)
1945
+ // 硬触发:官方已经压缩过/已撞过窗口墙 —— 这一刻必须接续,不依赖比例是否算得准。
1946
+ const hard = compacted || overflowed
1947
+ const armRatio = hard ? Math.max(ratio, threshold) : ratio
1948
+ rt.waterLevel = armRatio
1866
1949
  rt.waterLevelTokens = estTokens
1867
- this.state.waterLevelRatio = ratio
1950
+ // 2026-09-10:pre-step 的 arm 走的是 runtime 字段(见 checkWaterLevelAtStep → armAutoContinue),
1951
+ // 这里只写了 ratio/tokens、漏写 window/source,于是确认卡显示「512,311 / 0 token」(实测)。
1952
+ // 与 state 同源同值,一处漏写两处都缺 —— 必须成对维护。
1953
+ rt.waterLevelWindow = effectiveWin
1954
+ rt.waterLevelSource = winSource
1955
+ this.state.waterLevelRatio = armRatio
1868
1956
  this.state.waterLevelTokens = estTokens
1869
- this.state.waterLevelWindow = win
1957
+ this.state.waterLevelWindow = effectiveWin
1870
1958
  this.state.waterLevelSource = winSource
1871
1959
  this.state.waterLevelMeter = meterLayer
1872
1960
  this.state.waterLevelModel = sessModel.model || winInfo.model || ''
1873
1961
  this.state.waterLevelAt = Date.now()
1874
- const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.75, 0.1)
1962
+ this.state.waterLevelMeasuredTokens = estTokens
1963
+ this.state.waterLevelReserve = reserve
1964
+ // 双口径(2026-09-10):官方小圈的分母是**声明窗口**(win),我们的分母是**可用额度**(effectiveWin),
1965
+ // 两者在本机差 384,000(预留输出)。用户看到「才 50% 就接续」正是因为只看到小圈那个数 ——
1966
+ // 把 ring 一并上报,界面就能同时显示「占可用额度 x% · 官方小圈 y%」,不再各说各话。
1967
+ this.state.waterLevelRing = (win > 0 && Number.isFinite(estTokens)) ? (estTokens / win) : 0
1968
+ // provider 自报的硬上限只有撞过墙(400 文本)才拿得到;拿得到就把「真实可写上限」也算出来,
1969
+ // 让界面能显示「距硬墙还剩多少 token」(本机 = 1,048,576 − 384,000 = 664,576)。
1970
+ const hardWin = Number(sig.overflow && sig.overflow.windowTokens) || 0
1971
+ this.state.waterLevelWall = hardWin > 0 ? Math.max(0, hardWin - reserve) : 0
1972
+ this.state.waterLevelHardTrigger = hard ? (compacted ? 'compaction' : 'overflow') : ''
1875
1973
  // 会话级记录:切会话时 /handoff-state?sessionId= 直接取这份,不再显示别的会话的数
1876
1974
  this.rememberWaterRecord(agent && agent.session && agent.session.id ? String(agent.session.id) : '', {
1877
- ratio, tokens: estTokens, window: win, source: winSource, meter: meterLayer,
1878
- model: this.state.waterLevelModel || '', threshold, at: Date.now(), live: true,
1975
+ ratio: armRatio, measuredRatio: ratio, tokens: estTokens, measuredTokens: estTokens, window: effectiveWin,
1976
+ windowRaw: win, reserve, source: winSource, meter: meterLayer, model: this.state.waterLevelModel || '',
1977
+ threshold, hardTrigger: this.state.waterLevelHardTrigger, at: Date.now(), live: true,
1879
1978
  })
1880
- const over = ratio >= threshold || (ratio >= 0.5 && compacted)
1979
+ const over = armRatio >= threshold
1881
1980
  if (!over) return
1882
- if (!rt.waterLevelAdvised || compacted) {
1981
+ if (!rt.waterLevelAdvised || compacted || overflowed) {
1883
1982
  rt.waterLevelAdvised = true
1884
- diag('water level advisory: ratio=' + ratio.toFixed(2) + ' tokens=' + estTokens + '/' + win + ' src=' + (winSource || '?') + ' meter=' + meterLayer + (compacted ? ' compaction-detected' : ''))
1983
+ diag('water level advisory: ratio=' + ratio.toFixed(2) + (hard ? '→' + armRatio.toFixed(2) + '(hard:' + this.state.waterLevelHardTrigger + ')' : '') +
1984
+ ' tokens=' + estTokens + '/' + effectiveWin + '(win=' + win + ' reserve=' + reserve + ')' +
1985
+ ' src=' + (winSource || '?') + ' meter=' + meterLayer + (compacted ? ' compaction-detected' : ''))
1885
1986
  }
1886
- if (this.config.waterLevelAutoHandoff !== false && (!rt.waterLevelAutoHandoffDone || compacted)) {
1987
+ if (this.config.waterLevelAutoHandoff !== false && (!rt.waterLevelAutoHandoffDone || compacted || overflowed)) {
1887
1988
  rt.waterLevelAutoHandoffDone = true
1888
1989
  const p = await this.resolvePaths(agent)
1889
1990
  // 骨架 v2:抽取性压缩——只从「已策展源」取材(沉淀子代理提炼过的今日日志尾部+反思摘要+项目笔记头部),去重限行,不做原始转储
@@ -1914,8 +2015,12 @@ class MemoryEngine {
1914
2015
  * 阈值 thresholdRatio=0.8 对「路由请求 token」;插件此前只在 turn-stopping 测量,一旦某一轮把水位从阈值下推到 0.8 以上,
1915
2016
  * 官方会在该轮的 pre-step 就压缩完,交接白板/账本根本来不及写(实测:20:11 轮末测得 768158/1000000=0.77 时,会话日志里
1916
2017
  * 已有 2 次 compaction/start+summary)。这里在 pre-step 也做同一测量,让交接与官方压缩站在同一条边界上。
1917
- * 节流:同一 agent 至少间隔 minGapMs(默认 5s),避免每个 step 都扫会话事件/读 tokenMeter。 */
1918
- checkWaterLevelAtStep(agent, minGapMs = 5000) {
2018
+ * 2026-09-10 修正:**取消 5 秒节流(默认 minGapMs 改为 0)**。旧默认 5000 使"两次 pre-step 间隔 <5 秒即整段跳过"
2019
+ * (不测量、不 arm),而官方压缩挂在**每个** pre-step 上、没有任何节流 —— 模型越快、单步越短,被跳过的概率越高,
2020
+ * 于是官方永远抢先压缩、接续从不触发(实测 DeepSeek-V41-Flash 下单步常低于 5 秒,用户亦观察到官方上下文条
2021
+ * 每次工具调用后就更新)。本函数开销很小(`tokenMeter.measure()` + 最近 64 条会话事件扫描),省这点开销不值当;
2022
+ * minGapMs 仅保留给测试注入。如需控频应改 `checkWaterLevel` 自身,而不是在这里整段跳过。 */
2023
+ checkWaterLevelAtStep(agent, minGapMs = 0) {
1919
2024
  try {
1920
2025
  if (this.config.handoffEnabled === false) return
1921
2026
  if (!agent || !this.hasReliableSessionIdentity(agent)) return
@@ -1935,6 +2040,54 @@ class MemoryEngine {
1935
2040
  } catch (e) { diag('checkWaterLevelAtStep error: ' + (e && e.message)) }
1936
2041
  }
1937
2042
 
2043
+ /** 已接续闩锁(2026-09-10):同一个会话**只允许被接续一次**。
2044
+ * 旧实现只有 30 分钟冷却:冷却一过,用户切回那个旧会话、水位仍然高 → 再次 arm → 再次建会话,
2045
+ * 表现为「切回旧窗口,它还想接续」(实测用户报障)。闩锁以会话 id 为键、落盘到
2046
+ * ~/.dsh/memory/auto-continue-done.json(重启后依旧生效),接续**失败不落闩**(允许重试)。 */
2047
+ continuedSessionsFile() { return path.join(dshHome(), 'memory', 'auto-continue-done.json') }
2048
+
2049
+ loadContinuedSessions() {
2050
+ if (this._continuedSessions instanceof Set) return this._continuedSessions
2051
+ const set = new Set()
2052
+ try {
2053
+ const obj = JSON.parse(readFileSync(this.continuedSessionsFile(), 'utf8'))
2054
+ const arr = Array.isArray(obj) ? obj : ((obj && Array.isArray(obj.sessions)) ? obj.sessions : [])
2055
+ for (const it of arr) {
2056
+ const k = this.waterKey(typeof it === 'string' ? it : ((it && it.from) || ''))
2057
+ if (k) set.add(k)
2058
+ }
2059
+ } catch (e) {}
2060
+ this._continuedSessions = set
2061
+ return set
2062
+ }
2063
+
2064
+ isContinuedSession(sid) {
2065
+ const k = this.waterKey(sid)
2066
+ return k ? this.loadContinuedSessions().has(k) : false
2067
+ }
2068
+
2069
+ markContinuedSession(sid, toSid) {
2070
+ const k = this.waterKey(sid)
2071
+ if (!k) return false
2072
+ const set = this.loadContinuedSessions()
2073
+ if (set.has(k)) return false
2074
+ set.add(k)
2075
+ try {
2076
+ const file = this.continuedSessionsFile()
2077
+ mkdirSync(path.dirname(file), { recursive: true })
2078
+ let arr = []
2079
+ try {
2080
+ const obj = JSON.parse(readFileSync(file, 'utf8'))
2081
+ arr = Array.isArray(obj) ? obj : ((obj && Array.isArray(obj.sessions)) ? obj.sessions : [])
2082
+ } catch (e0) {}
2083
+ arr.push({ from: k, to: this.waterKey(toSid), at: Date.now() })
2084
+ if (arr.length > 200) arr = arr.slice(-200)
2085
+ writeFileSync(file, JSON.stringify({ sessions: arr, updatedAt: Date.now() }, null, 2), 'utf8')
2086
+ } catch (e) { diag('markContinuedSession write failed: ' + ((e && e.message) || e)) }
2087
+ diag('auto-continue latch: session ' + k + ' marked continued' + (toSid ? ' -> ' + this.waterKey(toSid) : ''))
2088
+ return true
2089
+ }
2090
+
1938
2091
  /** M-CM6-C·宿主兜底自动接续(2026-09-08,2.2.6):浏览器端 AutoContinueHost 被后台节流/关闭时,
1939
2092
  * 宿主自身在倒计时到期后直接建新会话。协议:turn-stopping 且水位≥阈值 → arm 倒计时(confirmSeconds,
1940
2093
  * 默认 35s,写入 _autoContState)→ 浏览器轮询 GET auto-continue-state 显示确认卡(同意=POST decide
@@ -1951,6 +2104,8 @@ class MemoryEngine {
1951
2104
  const cooldownMin = Number(this.config.autoContinueCooldownMinutes) || 30
1952
2105
  if (st.lastRunAt && now - st.lastRunAt < cooldownMin * 60000) return
1953
2106
  const sid = String(agent.session.id || agent.session.header?.id || '')
2107
+ // 已接续闩锁(2026-09-10):该会话已经从它接续过一次 → 永远不再 arm(与 30 分钟冷却无关)
2108
+ if (this.isContinuedSession(sid)) return
1954
2109
  // 拒绝语义:时间窗 10 分钟内不再 arm(近似客户端「拒绝=跳过本边界」;窗口过后按新边界重新评估)
1955
2110
  if (st.rejectedEdgeAt && now - st.rejectedEdgeAt < 10 * 60 * 1000) return
1956
2111
  if (st.armed && Date.now() < st.armed.expiresAt) return // 已有进行中的倒计时
@@ -1965,6 +2120,10 @@ class MemoryEngine {
1965
2120
  tokens: Number(wl.tokens) || 0,
1966
2121
  window: Number(wl.window) || 0,
1967
2122
  source: String(wl.source || ''),
2123
+ // 双口径(2026-09-10):小圈读数(声明窗口为分母)与距硬墙剩余,供确认卡显示
2124
+ // (state 可能尚未建立 —— 例如测试夹具或极早的 arm,取不到就不显示,不得因此让 arm 失败)
2125
+ ring: Number(this.state && this.state.waterLevelRing) || 0,
2126
+ wall: Number(this.state && this.state.waterLevelWall) || 0,
1968
2127
  // 2.2.7:pre-step 侧 arm 的倒计时到期时,若回合仍活跃则推迟(避免打断进行中的对话)
1969
2128
  awaitIdle: !!(opts && opts.awaitIdle),
1970
2129
  deferCount: 0,
@@ -2007,7 +2166,12 @@ class MemoryEngine {
2007
2166
  try {
2008
2167
  const sc = this._sessionController || (() => { try { this._sessionController = this._ctxRef?.get?.('sessionController') || undefined } catch (e) {} return this._sessionController })()
2009
2168
  if (!sc || typeof sc.create !== 'function') throw new Error('sessionController service unavailable(宿主无法建会话;请重启 dsh web 或保持页面打开)')
2010
- const d = await this.buildContinueCarry()
2169
+ const oldSid = armed && armed.sessionId ? String(armed.sessionId) : ''
2170
+ // 刷新仪式(2026-09-10):先让旧会话把白板 PLAN 与账本刷到最新,再组装交接材料 ——
2171
+ // 与 client 路径同序(先仪式后材料)。失败/超时都 fail-soft,照常接续。
2172
+ let ritual = null
2173
+ try { ritual = await this.hostRefreshRitual(oldSid) } catch (eRf) { diag('host ritual error: ' + ((eRf && eRf.message) || eRf)) }
2174
+ const d = await this.buildContinueCarry(oldSid)
2011
2175
  if (!d || !d.ok) throw new Error((d && d.error) || 'no handoff material')
2012
2176
  const created = await sc.create({
2013
2177
  ...(d.workspaceId ? { workspaceId: d.workspaceId } : { cwd: d.ws }),
@@ -2015,19 +2179,48 @@ class MemoryEngine {
2015
2179
  })
2016
2180
  const newId = String((created && (created.sessionId || created.id)) || '')
2017
2181
  if (!newId) throw new Error('create returned no sessionId')
2182
+ // 接续序号标题(2026-09-10):浏览器路径一直有(「接续 #N · 工作区」),宿主兜底路径漏了 ——
2183
+ // 兜底接续出来的会话在侧栏是无名会话,用户无法一眼看出它是「延续会话」还是新开的
2184
+ // (实测 18:07 建出的 session-9cc01f76 日志里根本没有 session/title 事件)。
2185
+ if (d.contSeq && typeof sc.rename === 'function') {
2186
+ const title = '接续 #' + String(d.contSeq) + (d.wsBase ? ' · ' + d.wsBase : '')
2187
+ try { await sc.rename({ sessionId: newId, title }) } catch (eT) { diag('host auto-continue rename: ' + ((eT && eT.message) || eT)) }
2188
+ }
2018
2189
  if (d.provider && d.model && typeof sc.selectModel === 'function') {
2019
2190
  try { await sc.selectModel({ sessionId: newId, provider: d.provider, model: d.model, ...(d.reasoningEffort ? { reasoningEffort: d.reasoningEffort } : {}) }) } catch (eM) { diag('host auto-continue selectModel: ' + (eM && eM.message)) }
2020
2191
  }
2021
2192
  if (typeof sc.prompt === 'function') {
2022
2193
  const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(120000) : undefined
2023
- await sc.prompt({ sessionId: newId, content: [{ type: 'text', text: d.carryText }], mode: 'queue' }, signal)
2024
- }
2194
+ // DSH 0.1.5 起 SessionPromptRequest.requestId 为必填(客户端铸造的用户消息身份,落到 source.rpcId)。
2195
+ // 缺失时会在 createUserMessage 处抛普通 Error,被官方包装成误导性的 session/agent-busy "prompt rejected"
2196
+ // (实测:建出的新会话只有头部即停更,交接材料从未送达)。官方自己的客户端也做 requestId ?? randomUUID()。
2197
+ const reqId = (typeof randomUUID === 'function')
2198
+ ? randomUUID()
2199
+ : ('dam-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10))
2200
+ await sc.prompt({ sessionId: newId, requestId: reqId, content: [{ type: 'text', text: d.carryText }], mode: 'queue' }, signal)
2201
+ }
2202
+ // 权限继承(2026-09-10):官方 create 不收权限,新会话会落 settings 的 permission.defaultPreset,
2203
+ // 接续后用户每一步都要批准(静默运行被打断)。agent 在 prompt 之后才建,故在此按延迟重试取 agent.session 再套旧预设;
2204
+ // 旧会话优先按 id 从 agents 注册表取(_lastAgent 重启后可能为空),fail-soft 不影响接续结果。
2205
+ const perm = await this.inheritPermissionForContinue(oldSid, newId)
2206
+ const ritualNote = (ritual && ritual.ok) ? 'updated' : ((ritual && ritual.reason) || 'skipped')
2207
+ // 落闩:旧会话已接续过,此后不再被 arm(即使 30 分钟冷却已过)
2208
+ try { this.markContinuedSession(oldSid, newId) } catch (eL) {}
2025
2209
  st.lastRunAt = Date.now()
2026
- st.lastOk = { sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', workspaceId: d.workspaceId || '' }
2027
- diag('auto-continue host-executed: new session ' + newId + ' model=' + (d.model || 'default'))
2028
- return { ok: true, sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '' }
2210
+ st.lastOk = { sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', workspaceId: d.workspaceId || '', permissionPreset: (perm && perm.ok && perm.preset) ? perm.preset : '', refreshRitual: ritualNote }
2211
+ diag('auto-continue host-executed: new session ' + newId + ' model=' + (d.model || 'default') + ' perm=' + ((perm && perm.ok && perm.preset) || ('(' + ((perm && perm.reason) || 'n/a') + ')')) + ' ritual=' + ritualNote)
2212
+ return { ok: true, sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', permissionPreset: (perm && perm.ok && perm.preset) ? perm.preset : '', refreshRitual: ritualNote }
2029
2213
  } catch (e) {
2030
- st.error = String(e && e.message ? e.message : e)
2214
+ // 官方把 prompt 失败统一包成 session/agent-busy + message "prompt rejected",真实原因只藏在 details/value 里。
2215
+ // 不透出就会得到一句无法排查的 "prompt rejected"(2026-09-10 实测踩坑)。
2216
+ let reason = ''
2217
+ try {
2218
+ const det = (e && (e.details || e.data)) || {}
2219
+ reason = String(det.reason || det.value || det.cause || '')
2220
+ if (!reason && e && e.cause) reason = String((e.cause && e.cause.message) || e.cause)
2221
+ } catch (_) {}
2222
+ const code = String((e && (e.code || e.name)) || '')
2223
+ st.error = String(e && e.message ? e.message : e) + (reason ? ' — ' + reason : '') + (code && code !== 'Error' ? ' [' + code + ']' : '')
2031
2224
  diag('hostAutoContinue error: ' + st.error)
2032
2225
  return { ok: false, error: st.error }
2033
2226
  } finally {
@@ -2036,26 +2229,34 @@ class MemoryEngine {
2036
2229
  }
2037
2230
  }
2038
2231
 
2039
- /** 浏览器轮询视图:armed 倒计时 / 最近一次执行结果 / 拒绝边界 / 错误。 */
2040
- autoContinueState() {
2232
+ /** 浏览器轮询视图:armed 倒计时 / 最近一次执行结果 / 拒绝边界 / 错误。
2233
+ * selfSid(2026-09-10):本状态是**全局单值**,而 armed 只属于某一个会话 —— 不过滤的话,
2234
+ * 别的会话的窗口也会弹「本会话水位已达 x%」(实测:用户切回旧窗口看到属于它的卡片后就以为清不掉)。
2235
+ * 传了 selfSid 且不匹配 → armed 视为 null(取不到 selfSid 时不过滤,fail-open,卡片照常显示)。 */
2236
+ autoContinueState(selfSid) {
2041
2237
  const st = this._autoContState || (this._autoContState = {})
2042
- const armed = st.armed ? {
2238
+ const selfKey = this.waterKey(selfSid || '')
2239
+ const armedRaw = st.armed ? {
2043
2240
  edgeAt: st.armed.edgeAt,
2044
2241
  sessionId: st.armed.sessionId,
2045
2242
  ratio: st.armed.ratio,
2046
2243
  tokens: st.armed.tokens,
2047
2244
  window: st.armed.window,
2245
+ ring: Number(st.armed.ring) || 0,
2246
+ wall: Number(st.armed.wall) || 0,
2048
2247
  expiresAt: st.armed.expiresAt,
2049
2248
  leftMs: Math.max(0, st.armed.expiresAt - Date.now()),
2050
2249
  } : null
2250
+ const armedMine = !selfKey || !armedRaw || !armedRaw.sessionId || this.waterKey(armedRaw.sessionId) === selfKey
2051
2251
  return {
2052
2252
  enabled: this.config.autoContinueEnabled !== false && this.config.handoffEnabled !== false,
2053
2253
  threshold: Number(this.config.autoContinueThreshold) || 0.75,
2054
- armed,
2254
+ armed: armedMine ? armedRaw : null,
2055
2255
  executing: !!st.executing,
2056
2256
  rejectedEdgeAt: st.rejectedEdgeAt || 0,
2057
2257
  lastRunAt: st.lastRunAt || 0,
2058
- lastOk: st.lastOk || null,
2258
+ // at:客户端据此给「已完成」提示加 10 分钟有效期 —— 旧实现没有时间戳,提示会一直挂在所有窗口上
2259
+ lastOk: st.lastOk ? { sessionId: st.lastOk.sessionId, model: st.lastOk.model, reasoningEffort: st.lastOk.reasoningEffort, workspaceId: st.lastOk.workspaceId, permissionPreset: st.lastOk.permissionPreset, refreshRitual: st.lastOk.refreshRitual, at: st.lastRunAt || 0 } : null,
2059
2260
  error: st.error || '',
2060
2261
  }
2061
2262
  }
@@ -2099,6 +2300,83 @@ class MemoryEngine {
2099
2300
  } catch (e) { return '' }
2100
2301
  }
2101
2302
 
2303
+ /**
2304
+ * 最近活跃会话 id 的**磁盘回退**(2026-09-10)。
2305
+ * `_lastAgent` 是纯内存态,宿主重启后要等下一次 pre-step 才重建,而刷新仪式的
2306
+ * `handoff-state.refresh.sessionId` 正好依赖它 —— 重启后立刻点接续会拿到空串,
2307
+ * 客户端据此静默 `skipped`,旧会话收不到刷新指令(实机踩到:旧会话 16:20 后再无写入)。
2308
+ * 回退按会话日志 mtime 取最新,**只认 `session-` 前缀的普通会话目录**:裸 uuid 目录是子代理会话,
2309
+ * 把刷新仪式注进子代理等于白做(还会污染它的上下文),所以宁可返回 '' 让上层明确报"无可刷新目标"。
2310
+ * 5 秒缓存避免面板轮询反复扫盘。
2311
+ */
2312
+ recentSessionIdFallback() {
2313
+ try {
2314
+ const now = Date.now()
2315
+ if (this._sidFallbackAt && now - this._sidFallbackAt < 5000) return this._sidFallbackId || ''
2316
+ this._sidFallbackAt = now
2317
+ const root = path.join(dshHome(), 'sessions')
2318
+ let best = ''
2319
+ let bestMs = 0
2320
+ for (const wsDir of readdirSync(root, { withFileTypes: true })) {
2321
+ if (!wsDir.isDirectory()) continue
2322
+ const wsPath = path.join(root, wsDir.name)
2323
+ let entries = []
2324
+ try { entries = readdirSync(wsPath, { withFileTypes: true }) } catch (_) { continue }
2325
+ for (const ent of entries) {
2326
+ if (!ent.isDirectory()) continue
2327
+ if (!/^session-/.test(ent.name)) continue
2328
+ let ms = 0
2329
+ for (const f of ['session.v3.jsonl.zstd', 'session.jsonl.zstd']) {
2330
+ try { const st = statSync(path.join(wsPath, ent.name, f)); if (st.mtimeMs > ms) ms = st.mtimeMs } catch (_) {}
2331
+ }
2332
+ if (ms > bestMs) { bestMs = ms; best = ent.name }
2333
+ }
2334
+ }
2335
+ this._sidFallbackId = best
2336
+ if (this._sidFallbackId) diag('session id fallback(memory-empty) → ' + this._sidFallbackId)
2337
+ return this._sidFallbackId
2338
+ } catch (_) { return '' }
2339
+ }
2340
+
2341
+ /**
2342
+ * 权限预设继承(2026-09-10):官方 `session.create` 的入参只有
2343
+ * `{workspaceId, cwd, sessionId, agentPreset}`,**没有权限字段** —— 新会话一律走
2344
+ * `PermissionPresetService.pinInitialPermission()` 落 settings 里的 `permission.defaultPreset`。
2345
+ * 于是接续出来的新会话丢掉旧会话的完全权限,用户的静默运行每一步都要批准(实机踩到)。
2346
+ * 这里在读侧 `current(session)` / 写侧 `set(session, name)` 上补一步继承;两者都需要 **Session 对象**,
2347
+ * 官方 webhook 是经 `handle.agent.session` 拿到的,插件同样经 agent 取(拿不到就 fail-soft,不阻塞接续)。
2348
+ */
2349
+ async inheritPermissionPreset(oldAgent, newSid, opts = {}) {
2350
+ try {
2351
+ const ctxRef = this._ctxRef
2352
+ const pp = ctxRef && typeof ctxRef.get === 'function' ? ctxRef.get('permissionPresets') : null
2353
+ if (!pp || typeof pp.current !== 'function' || typeof pp.set !== 'function') return { ok: false, reason: 'no-permission-service' }
2354
+ const oldSession = oldAgent && oldAgent.session
2355
+ if (!oldSession) return { ok: false, reason: 'no-old-session' }
2356
+ let preset = ''
2357
+ try { preset = String(pp.current(oldSession) || '') } catch (eC) { return { ok: false, reason: 'current-failed:' + ((eC && eC.message) || eC) } }
2358
+ if (!preset) return { ok: false, reason: 'empty-preset' }
2359
+ if (preset === 'custom') return { ok: false, reason: 'custom-preset-not-switchable' }
2360
+ const attempts = Array.isArray(opts.attempts) && opts.attempts.length ? opts.attempts : [0, 700, 1600]
2361
+ for (const delay of attempts) {
2362
+ if (delay > 0) await new Promise((r) => setTimeout(r, delay))
2363
+ let ag = null
2364
+ try {
2365
+ const reg = typeof ctxRef.get === 'function' ? ctxRef.get('agents') : null
2366
+ ag = (reg && typeof reg.get === 'function') ? reg.get(newSid) : null
2367
+ } catch (_) {}
2368
+ if (!ag || !ag.session) continue
2369
+ try {
2370
+ pp.set(ag.session, preset)
2371
+ diag('auto-continue permission inherited: ' + preset + ' → ' + newSid)
2372
+ return { ok: true, preset }
2373
+ } catch (eS) { diag('auto-continue permission set failed: ' + ((eS && eS.message) || eS)) }
2374
+ }
2375
+ diag('auto-continue permission inherit skipped: no live agent for ' + newSid)
2376
+ return { ok: false, reason: 'no-agent-for-new-session' }
2377
+ } catch (e) { return { ok: false, reason: String((e && e.message) || e) } }
2378
+ }
2379
+
2102
2380
  /** 修A(2026-09-08):解析会话所属 workspaceId。官方 api-session-controller create() 只在传 workspaceId 时
2103
2381
  * 调 workspace.attachSession()(index.js:566-588),只传 cwd 仅设工作目录 → 新会话永远落「未分组工作区」。
2104
2382
  * 归属判定走 workspaceRegistry(与官方 forkWorkspace index.js:874 同款),服务缺失/无归属返回 ''(client 回退 cwd)。 */
@@ -2125,20 +2403,110 @@ class MemoryEngine {
2125
2403
  ].join(String.fromCharCode(10))
2126
2404
  }
2127
2405
 
2406
+ /** 按会话 id 取活动 agent(官方 agents 注册表)。取不到返回 null —— 权限/仪式都靠它拿 Session 对象。 */
2407
+ agentForSessionId(sid) {
2408
+ const id = String(sid || '')
2409
+ if (!id) return null
2410
+ try {
2411
+ const reg = (this._ctxRef && typeof this._ctxRef.get === 'function') ? this._ctxRef.get('agents') : null
2412
+ const ag = (reg && typeof reg.get === 'function') ? reg.get(id) : null
2413
+ return (ag && ag.session) ? ag : null
2414
+ } catch (e) { return null }
2415
+ }
2416
+
2417
+ /** 接续材料指纹(PLAN mtime + 最新账本名):刷新仪式靠它判断旧会话有没有真的产出新白板/账本。
2418
+ * 只 stat 一个文件 + 一次 readdir,比 handoffPanelData 便宜得多(仪式要按秒轮询)。 */
2419
+ async handoffMaterialStamp() {
2420
+ try {
2421
+ const dir = this.state.handoffDir || (await this.resolvePaths(undefined)).handoffDir
2422
+ const planPath = this.state.planPath || path.join(dir, 'PLAN.md')
2423
+ const planMt = await stat(planPath).then((s) => Number(s.mtimeMs)).catch(() => 0)
2424
+ const names = await readdir(dir).catch(() => [])
2425
+ const led = names.filter((n) => /^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n)).sort()
2426
+ return String(planMt) + '|' + (led[led.length - 1] || '')
2427
+ } catch (e) { return '' }
2428
+ }
2429
+
2430
+ /** 宿主侧刷新仪式(2026-09-10):原先只有 client 路径做刷新,宿主兜底接续(浏览器关着/后台节流)
2431
+ * 完全跳过 —— 旧会话收不到指令,新会话拿到的白板与账本就停在旧版。这里补齐同款顺序:
2432
+ * **先让旧会话刷白板+写账本,再组装材料**。
2433
+ * 安全约束:只在 `armed.sessionId` 明确时注入(绝不猜会话);同一旧会话 10 分钟内只注入一次
2434
+ * (接续失败重试时不刷屏);全程 fail-soft —— 仪式失败照常接续,只记 diag。 */
2435
+ async hostRefreshRitual(oldSid) {
2436
+ const sid = String(oldSid || '')
2437
+ if (!sid) return { ok: false, reason: 'no-old-session' }
2438
+ if (this.config.autoContinueRefreshRitual === false) return { ok: false, reason: 'disabled' }
2439
+ const st = this._autoContState || (this._autoContState = {})
2440
+ const now = Date.now()
2441
+ if (st.ritualForSid === sid && st.ritualAt && now - st.ritualAt < 10 * 60 * 1000) return { ok: false, reason: 'already-sent' }
2442
+ const sc = this._sessionController || (() => { try { this._sessionController = this._ctxRef?.get?.('sessionController') || undefined } catch (e) {} return this._sessionController })()
2443
+ if (!sc || typeof sc.prompt !== 'function') return { ok: false, reason: 'no-session-controller' }
2444
+ st.ritualForSid = sid
2445
+ st.ritualAt = now
2446
+ try {
2447
+ const before = await this.handoffMaterialStamp()
2448
+ const reqId = (typeof randomUUID === 'function')
2449
+ ? randomUUID()
2450
+ : ('dam-r-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10))
2451
+ const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(60000) : undefined
2452
+ diag('host refresh ritual → old session ' + sid)
2453
+ await sc.prompt({ sessionId: sid, requestId: reqId, content: [{ type: 'text', text: this.refreshRitualPrompt() }], mode: 'queue' }, signal)
2454
+ const sec = Number(this.config.autoContinueRefreshTimeoutSeconds)
2455
+ const timeoutMs = (sec >= 15 && sec <= 600 ? sec : 90) * 1000
2456
+ const t0 = Date.now()
2457
+ const pollMs = Number(this._ritualPollMs) > 0 ? Number(this._ritualPollMs) : 2500
2458
+ while (Date.now() - t0 < timeoutMs) {
2459
+ await new Promise((r) => setTimeout(r, pollMs))
2460
+ const cur = await this.handoffMaterialStamp()
2461
+ if (cur && cur !== before) { diag('host refresh ritual: material updated after ' + Math.round((Date.now() - t0) / 1000) + 's'); return { ok: true, waited: 'updated' } }
2462
+ }
2463
+ diag('host refresh ritual: timeout, continuing with current material')
2464
+ return { ok: false, reason: 'timeout' }
2465
+ } catch (e) {
2466
+ diag('host refresh ritual failed: ' + ((e && e.message) || e))
2467
+ return { ok: false, reason: String((e && e.message) || e) }
2468
+ }
2469
+ }
2470
+
2471
+ /** 给 client 路径用的权限继承入口(2026-09-10):client 自己调官方 session.create,
2472
+ * 宿主侧只负责把旧会话的权限预设套到新会话上。旧 agent 优先按 fromSessionId 从注册表取
2473
+ * (_lastAgent 重启后可能为空),取不到再退回内存态。 */
2474
+ async inheritPermissionForContinue(fromSessionId, toSessionId, opts = {}) {
2475
+ const from = String(fromSessionId || '')
2476
+ const to = String(toSessionId || '')
2477
+ if (!to) return { ok: false, reason: 'no-new-session' }
2478
+ const oldAgent = this.agentForSessionId(from)
2479
+ || ((this._lastAgent && this._lastAgent.session && String(this._lastAgent.session.id) === from) ? this._lastAgent : null)
2480
+ return this.inheritPermissionPreset(oldAgent, to, opts)
2481
+ }
2482
+
2128
2483
  /** M-CM6-B v2·旧会话上下文包(2026-09-08):定位当前(旧)会话持久化文件(~/.dsh/sessions/<ws>/<sid>/session.jsonl[.zstd]),
2129
2484
  * zstd 解压抽取 header(cwd/agentPreset)、最新 request/header 的 data.header.config(provider/model/reasoningEffort)、
2130
2485
  * user+assistant+工具 消息全量转写;转写落盘 handoff/prev-session-*.md → 新会话 AI 用 read 工具即可随时读取旧会话全部内容。
2131
2486
  * 返回 {sessionId, transcriptPath, provider, model, reasoningEffort, agentPreset, cwd, tailText, msgCount} 或部分为空(fail-soft)。 */
2132
- async buildPrevSessionPack() {
2487
+ async buildPrevSessionPack(preferSid) {
2133
2488
  try {
2489
+ // 2026-09-10 实机取证:此前只认 this._lastAgent(最近活跃的 agent),而自动接续的旧会话是
2490
+ // armed.sessionId —— 二者可以不是同一个会话(14:54:30 实测:armed=de10b34f,却把
2491
+ // 1f621132 的转写与 provider/model/effort 当成接续材料,新会话还会被 selectModel 套错模型)。
2492
+ // 现在改为:显式传入的旧会话优先;只有它取不到持久化文件时才退回 _lastAgent。
2134
2493
  const agent = this._lastAgent
2135
- const sid = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
2136
- if (!sid) return null
2494
+ const lastSid = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
2495
+ const want = String(preferSid || '')
2496
+ const cands = []
2497
+ if (want) cands.push(want)
2498
+ if (lastSid && lastSid !== want) cands.push(lastSid)
2499
+ if (!cands.length) return null
2137
2500
  const sessionsDir = path.join(dshHome(), 'sessions')
2501
+ const wsDirs = await readdir(sessionsDir).catch(() => [])
2502
+ let sid = cands[0]
2138
2503
  let file = null
2139
- for (const w of await readdir(sessionsDir).catch(() => [])) {
2140
- const resolved = await this.resolveSessionFile(path.join(sessionsDir, w, sid))
2141
- if (resolved) { file = resolved; break }
2504
+ for (const cand of cands) {
2505
+ for (const w of wsDirs) {
2506
+ const resolved = await this.resolveSessionFile(path.join(sessionsDir, w, cand))
2507
+ if (resolved) { file = resolved; sid = cand; break }
2508
+ }
2509
+ if (file) break
2142
2510
  }
2143
2511
  if (!file) return { sessionId: sid }
2144
2512
  const buf = await readFile(file)
@@ -2191,7 +2559,7 @@ class MemoryEngine {
2191
2559
  * 取当前工作区白板+最新账本;工作区未绑定/材料为空时降级用全局最近账本(findLatestGlobalHandoff)。
2192
2560
  * v2:附带旧会话上下文包(转写文件路径+主动读取指令)与新会话状态沿用参数(cwd/provider/model)。
2193
2561
  * 返回 {ok, carryText, planPath, ws, provider, model, prevSessionId, transcriptPath};无任何材料时 ok:false。 */
2194
- async buildContinueCarry() {
2562
+ async buildContinueCarry(preferSid) {
2195
2563
  try {
2196
2564
  if (this.config.handoffEnabled === false) return { ok: false, error: 'handoff disabled' }
2197
2565
  const p = await this.resolvePaths(undefined)
@@ -2203,7 +2571,7 @@ class MemoryEngine {
2203
2571
  const gl = await this.findLatestGlobalHandoff()
2204
2572
  if (gl) { ledger = await this.readTextSafe(gl); ledgerFrom = 'global' }
2205
2573
  }
2206
- const pack = await this.buildPrevSessionPack()
2574
+ const pack = await this.buildPrevSessionPack(preferSid)
2207
2575
  if (!plan && !ledger && !pack) return { ok: false, error: 'no handoff material (PLAN/账本/旧会话均为空)' }
2208
2576
  // 材料时间戳与新鲜度(分层压缩:过期提示)
2209
2577
  const fmtMt = (ms) => { try { return ms ? new Date(Number(ms)).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '(未知)' } catch (e) { return '(未知)' } }
@@ -2367,7 +2735,8 @@ class MemoryEngine {
2367
2735
  }
2368
2736
  }
2369
2737
  const planMt = await stat(planPath).then((s) => Number(s.mtimeMs)).catch(() => 0)
2370
- const sid = this.currentSessionId()
2738
+ // 刷新仪式的会话 id:内存态为空时走磁盘回退(宿主重启后 _lastAgent 未重建的窗口期)
2739
+ const sid = this.currentSessionId() || this.recentSessionIdFallback()
2371
2740
  return {
2372
2741
  enabled: true, planPath,
2373
2742
  plan: await this.readTextSafe(planPath),
@@ -2761,249 +3130,292 @@ class MemoryEngine {
2761
3130
  } catch (_) { return false }
2762
3131
  }
2763
3132
 
2764
- // ---------- 每日写入预算 + 超限自动压缩(硬约束,但不拒绝新内容) ----------
3133
+ // ---------- 记忆容量上限 + 超限自动整理(容量口径,单位=字符) ----------
2765
3134
  /**
2766
- * 用户级 ≤4000 字/天、项目级 ≤3000 字/天(所有会话共享一天预算,跨天重置)。
2767
- * 超限时不直接拒绝:先自动压缩"今天之前"的旧内容腾出空间(ensureBudget),压缩不可用时才拒绝。
3135
+ * 2026-09-10 重做(方案 0+1+2+3):
3136
+ * - **口径改为「文件容量」**:只要写入后总字符数 ≤ 上限就放行。旧口径按"当日写入量"计,
3137
+ * 而压缩对象只有"今天之前"的记录 → 当天写得多就必然堵死(即便文件很小),且跨天才能自解。
3138
+ * - **单位统一为字符**(`String.length`):旧实现记账用字符、压缩配额用字节(`rec.byteEnd - rec.markerByteStart`),
3139
+ * 同一个 3000 在中文内容下差 ~1.44 倍;legacy 文本路径用字符、anchor 路径用字节,还会随 anchor 开关跳变。
3140
+ * - **超限先整理**:优先"AI 折叠成要点",失败退回"整条归档";整理后仍超才拒绝(正常情况不会发生)。
2768
3141
  */
2769
- accountWrite(agent, layer, text) {
2770
- if (!this._budgets) this._budgets = new Map()
2771
- const today = this.memToday()
2772
- let b = this._budgets.get('today')
2773
- if (!b || b.date !== today) { b = { date: today, user: 0, note: 0 }; this._budgets.set('today', b) }
2774
- const limit = layer === 'user' ? 4000 : 3000
2775
- const next = b[layer] + String(text || '').length
2776
- if (next > limit) return { ok: false, used: b[layer], limit, remaining: Math.max(0, limit - b[layer]) }
2777
- b[layer] = next
2778
- return { ok: true, used: next, limit, remaining: limit - next }
3142
+ capacityLimit(layer) {
3143
+ const raw = Number(this.config[layer === 'user' ? 'userCapacityChars' : 'noteCapacityChars'])
3144
+ if (Number.isFinite(raw) && raw >= 500) return Math.floor(raw)
3145
+ return layer === 'user' ? DEFAULT_USER_CAPACITY_CHARS : DEFAULT_NOTE_CAPACITY_CHARS
3146
+ }
3147
+
3148
+ /** 追加写入时的固定开销(换行 + `## YYYY-MM-DD` 标题行)。容量检查必须计入,否则低估约 15 字符。 */
3149
+ appendOverheadChars() {
3150
+ try { return ('\n## ' + this.memToday() + '\n').length } catch (_) { return 16 }
3151
+ }
3152
+
3153
+ /** 容量检查:当前字符数 + 待写入字符数(含标题行与分隔换行) ≤ 上限则放行。 */
3154
+ capacityCheck(layer, curChars, text) {
3155
+ const limit = this.capacityLimit(layer)
3156
+ const add = String(text == null ? '' : String(text)).length + this.appendOverheadChars()
3157
+ const need = curChars + add
3158
+ return { ok: need <= limit, limit, used: curChars, need, add }
3159
+ }
3160
+
3161
+ /** 读某层当前字符数(优先内存态 `state.userText/notesText`,回退读盘)。 */
3162
+ async layerCharCount(layer, p) {
3163
+ const cached = layer === 'user' ? this.state.userText : this.state.notesText
3164
+ if (typeof cached === 'string' && cached.length) return cached.length
3165
+ try { return String((await this.readTextSafe(layer === 'user' ? p.userFile : p.notesPath)) || '').length } catch (_) { return 0 }
2779
3166
  }
2780
3167
 
2781
- /** 预算保障:超限时自动压缩旧内容腾位;压缩成功返回 compacted:true,否则 ok:false。 */
3168
+ /**
3169
+ * 容量保障:超限时自动整理腾位,并在**整理后复查实际文件大小**;仍超则按新的实际值再整理(最多 3 轮)。
3170
+ *
3171
+ * 2026-09-10 第二轮修正:旧实现只压一轮就复查,而单轮缺口是按"容纳本次写入"估算的 ——
3172
+ * 折叠产物、`## 日期` 标题行开销等会让实际结果偏小,于是出现"压缩确实成功(折叠块已写入)、
3173
+ * 复查却不过、工具还把成功标识 `folded` 当失败原因报出来"的怪象(实机踩到)。
3174
+ * 现在每轮都用**重新测量的实际文件大小**算缺口,复查不过就再来一轮;失败时返回**真实**原因。
3175
+ */
2782
3176
  async ensureBudget(agent, layer, text) {
2783
- const acct = this.accountWrite(agent, layer, text)
3177
+ const p = await this.resolvePaths(agent)
3178
+ let acct = this.capacityCheck(layer, await this.layerCharCount(layer, p), text)
2784
3179
  if (acct.ok) return { ok: true, acct }
2785
- const compacted = await this.compactLayer(agent, layer)
2786
- if (compacted) {
2787
- const acct2 = this.accountWrite(agent, layer, text)
2788
- if (acct2.ok) return { ok: true, acct: acct2, compacted: true }
3180
+ let last = null
3181
+ let compactedOnce = false
3182
+ for (let round = 0; round < 3; round++) {
3183
+ const cur = await this.layerCharCount(layer, p)
3184
+ const need = cur + String(text == null ? '' : String(text)).length + this.appendOverheadChars()
3185
+ last = await this.compactLayer(agent, layer, { needChars: need, round })
3186
+ if (!last || !last.ok) break
3187
+ compactedOnce = true
3188
+ acct = this.capacityCheck(layer, await this.layerCharCount(layer, p), text)
3189
+ if (acct.ok) return { ok: true, acct, compacted: true }
3190
+ }
3191
+ const reason = (!last || !last.ok)
3192
+ ? ((last && last.reason) || 'compact-failed')
3193
+ : (compactedOnce ? 'still-over-capacity' : 'compact-made-no-progress')
3194
+ return { ok: false, acct, reason, retryAfterMs: (last && last.retryAfterMs) || 0 }
3195
+ }
3196
+
3197
+ /**
3198
+ * 整理失败时的**准确**报错。旧实现把三种完全不同的原因压成一句
3199
+ * "(刚压缩过或 AI 不可用)" —— 实测会把使用者(和模型)直接带偏,故拆开:
3200
+ * throttled(节流中,给秒数) / no-removable(无可回收) / dirty-file(锚点文件脏,fail-closed) / 其它。
3201
+ */
3202
+ budgetRefusalTextPre(res, layer) {
3203
+ const name = layer === 'user' ? '用户级记忆' : '项目笔记'
3204
+ const limit = (res && res.acct && res.acct.limit) || this.capacityLimit(layer)
3205
+ const used = (res && res.acct && res.acct.used) || 0
3206
+ const why = String((res && res.reason) || 'compact-failed')
3207
+ const head = name + '已达容量上限(' + used + '/' + limit + ' 字符),自动整理未能腾出空间'
3208
+ if (why === 'throttled') {
3209
+ const sec = Math.max(1, Math.ceil((Number(res.retryAfterMs) || 0) / 1000))
3210
+ return head + ':距上次整理不足 10 分钟(节流中),约 ' + sec + ' 秒后可重试。'
3211
+ }
3212
+ if (why === 'no-removable') {
3213
+ return head + ':可回收内容为空(最新 1 条记录为硬底线,永不整条移除)。'
3214
+ + '可调大 ' + (layer === 'user' ? 'userCapacityChars' : 'noteCapacityChars') + ',或用 action=replace 整体重写。'
3215
+ }
3216
+ if (why === 'dirty-file') {
3217
+ return head + ':锚点文件校验未通过(脏文件,fail-closed 不做字符切片)。'
3218
+ + '请检查是否被手工编辑破坏了 <!-- memory:mem_xxx --> 标记。'
3219
+ }
3220
+ if (why === 'still-over-capacity') {
3221
+ return head + ':已自动整理,但整理后仍放不下本次内容(单次写入过大)。'
3222
+ + '请把内容拆小分批写,或调大 ' + (layer === 'user' ? 'userCapacityChars' : 'noteCapacityChars') + '。'
2789
3223
  }
2790
- return { ok: false, acct }
3224
+ if (why === 'compact-made-no-progress') {
3225
+ return head + ':整理了但没有腾出空间。可调大 ' + (layer === 'user' ? 'userCapacityChars' : 'noteCapacityChars') + ',或用 action=replace 整体重写。'
3226
+ }
3227
+ return head + ':折叠与归档均未成功(' + why + ')。原内容未改动,可稍后重试。'
2791
3228
  }
2792
3229
 
2793
- /** 压缩层内"今天之前"的旧段落:AI 提炼要点替换,不可用时归档最早段落;10 分钟节流。 */
2794
- async compactLayer(agent, layer) {
3230
+ /**
3231
+ * 层内整理:超出容量时腾出空间。**先 AI 折叠成要点,失败退回整条归档**——两者都把原文写进归档保底。
3232
+ *
3233
+ * 2026-09-10 重做(方案 0+1+2+3)要点:
3234
+ * - **单位统一为字符**:旧实现记账按字符、压缩配额按字节(`rec.byteEnd - rec.markerByteStart`),
3235
+ * 同一个 3000 在中文内容下差 ~1.44 倍;legacy 文本路径用字符、anchor 路径用字节,还会随 anchor 开关跳变。
3236
+ * - **保护窗口 + 硬底线**:最近写入的 `COMPACT_PROTECT_RECENT_CHARS` 字符不参与回收;硬底线=至少保留最新 1 条
3237
+ * (若保护窗口自身就超容量,允许对其折叠成要点,但不整条删除),否则会在"最近内容本身就很大"时重新死锁。
3238
+ * - **节流按层独立**:`_lastCompactAt` 旧实现是单个变量,压过用户级会连带节流项目级。
3239
+ * - **返回结构化结果**:让上层能给出准确报错(reason/retryAfterMs),不再把"节流中/无可压缩/AI 失败"混成一句。
3240
+ * @returns {Promise<{ok:boolean, reason?:string, retryAfterMs?:number}>}
3241
+ */
3242
+ async compactLayer(agent, layer, opts = {}) {
2795
3243
  const now = Date.now()
2796
- if (this._lastCompactAt && now - this._lastCompactAt < 10 * 60 * 1000) return false
3244
+ const state = (this._lastCompactAt && typeof this._lastCompactAt === 'object') ? this._lastCompactAt : (this._lastCompactAt = {})
3245
+ const last = Number(state[layer]) || 0
3246
+ // 节流只限制"昂贵的 AI 折叠",**不阻止"便宜的整条归档"** —— 否则短时间内反复超容量时
3247
+ // 仍会拒绝写入,违背"新记忆永不堵在外面"。归档只是一次文件追加 + 一次原子替换,代价可忽略。
3248
+ const allowFold = !(last && now - last < COMPACT_THROTTLE_MS)
2797
3249
  const p = await this.resolvePaths(agent)
2798
- // F1:anchor 开启 → 记录级压缩(整条保留/移除,经 store.replace;永不字符切片)。
2799
- // 目标文件无 sidecar('not-anchored')才回退旧文本路径。
3250
+ const limit = this.capacityLimit(layer)
3251
+ const curChars = await this.layerCharCount(layer, p)
3252
+ // 直接算**缺口**:要腾出多少字符才够本次写入放下。
3253
+ // 旧写法算的是"保留量目标 = limit - pending",方向反了 —— 当全部记录本来就小于该目标时会被判成
3254
+ // "无可回收",而文件其实只差几十字符就超限(2026-09-10 实机踩到,报错文案即"可回收内容为空")。
3255
+ const needChars = Number(opts.needChars || 0) || (curChars + 1)
3256
+ const deficit = Math.max(1, needChars - limit)
3257
+ const keepBudget = Math.max(COMPACT_PROTECT_RECENT_CHARS, limit - deficit - 1) // legacy 文本路径仍按"保留量"口径
3258
+
2800
3259
  const store0 = this.docStore
2801
3260
  if (store0) {
2802
3261
  try {
2803
- const r = await this.compactAnchoredLayer(store0, layer === 'user' ? p.userFile : p.notesPath, layer, this.memToday(), p)
2804
- if (process.env.DSH_F1_DEBUG) console.error('[f1-diag] compactAnchoredLayer -> ' + (layer === 'user' ? p.userFile : p.notesPath) + ' => ' + r)
2805
- if (r !== 'not-anchored') return r === true
3262
+ const r = await this.compactAnchoredLayer(store0, layer === 'user' ? p.userFile : p.notesPath, layer, this.memToday(), p, deficit, agent, allowFold)
3263
+ if (r !== 'not-anchored') return r || { ok: false, reason: 'no-removable' }
2806
3264
  } catch (e) {
2807
3265
  console.error('[compacted-anchored] failed: ' + (e && e.message ? e.message : e))
2808
- return false
3266
+ return { ok: false, reason: 'error:' + String((e && e.message) || e).slice(0, 80) }
2809
3267
  }
2810
3268
  }
2811
- const cur = (layer === 'user' ? this.state.userText : this.state.notesText) || ''
2812
- if (!cur.trim()) return true
2813
- const limit = layer === 'user' ? 4000 : 3000
2814
- const today = this.memToday()
2815
- // 按 "## 标题" 切段,今天段落保留,之前段落为压缩对象;文件头非 ## 内容归入头部段,不丢失
3269
+ return await this.compactLegacyLayer(agent, layer, p, keepBudget, allowFold)
3270
+ }
3271
+
3272
+ /**
3273
+ * 无 anchor(sidecar 未启用)时的文本段整理。原逻辑内联在 compactLayer 中,2026-09-10 抽出。
3274
+ * 同样取消"今日段无条件保留",改为**从尾往回**按 keepBudget 保留(含今日),最新一段是硬底线。
3275
+ */
3276
+ async compactLegacyLayer(agent, layer, p, keepBudget, allowFold) {
3277
+ const cur = String((layer === 'user' ? this.state.userText : this.state.notesText) || '')
3278
+ if (!cur.trim()) return { ok: true, reason: 'empty' }
2816
3279
  const segs = []
2817
- let curSeg = { title: '(文件头)', body: [], isToday: false }
3280
+ let curSeg = { title: '(文件头)', body: [] }
2818
3281
  for (const ln of cur.split('\n')) {
2819
3282
  const m = ln.match(/^##\s+(.+)$/)
2820
- if (m) {
2821
- if (curSeg) segs.push(curSeg)
2822
- const title = m[1].trim()
2823
- curSeg = { title, body: [], isToday: /^\d{4}-\d{2}-\d{2}/.test(title) && title.slice(0, 10) === today }
2824
- } else if (curSeg) {
2825
- curSeg.body.push(ln)
2826
- }
2827
- }
2828
- if (curSeg) segs.push(curSeg)
2829
- const oldSegs = segs.filter((s) => !s.isToday)
2830
- const todaySegs = segs.filter((s) => s.isToday)
3283
+ if (m) { segs.push(curSeg); curSeg = { title: m[1].trim(), body: [] } } else { curSeg.body.push(ln) }
3284
+ }
3285
+ segs.push(curSeg)
3286
+ const spanOf = (s) => ('## ' + s.title + '\n' + s.body.join('\n')).length
3287
+ const keep = new Array(segs.length).fill(false)
3288
+ let kept = 0
3289
+ for (let i = segs.length - 1; i >= 0; i--) {
3290
+ const span = spanOf(segs[i])
3291
+ if (i === segs.length - 1) { keep[i] = true; kept += span; continue }
3292
+ if (kept + span <= keepBudget) { keep[i] = true; kept += span; continue }
3293
+ break
3294
+ }
3295
+ const oldSegs = segs.filter((_, i) => !keep[i])
3296
+ const keptText = segs.filter((_, i) => keep[i]).map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
3297
+ if (!oldSegs.length) return { ok: false, reason: 'no-removable' }
3298
+
2831
3299
  const oldText = oldSegs.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
2832
- const todayText = todaySegs.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
2833
- const target = Math.max(limit, 200) // 压缩后旧部分目标:不超过层上限
2834
- if (oldText.length <= target) return true // 旧内容本来就不大,无需压缩
2835
- let newOld = ''
2836
- if (this._subagents && agent) {
2837
- try {
2838
- const layerName = layer === 'user' ? '用户级记忆' : '项目笔记'
2839
- const prompt = [
2840
- '你是记忆整理员。下面的' + layerName + '文件里"今天之前"的旧内容需要压缩,因为今天要写入的新内容已超当日预算。',
2841
- '规则:',
2842
- '- 保留所有仍有长期价值的硬信息:强制规则、偏好、关键决策、约定、账号/路径等',
2843
- '- 合并重复、删除已失效/过期条目、把长句压到 20 字内',
2844
- '- 输出 markdown,可用"## 主题"分组,输出将直接替换旧内容',
2845
- '- 只输出压缩结果正文,不要任何解释',
2846
- '',
2847
- '待压缩旧内容:',
2848
- truncateTail(oldText, 6000),
2849
- ].join('\n')
2850
- const out = await this.runSubagent(prompt, 'auto-memory-compact', agent)
2851
- if (out && !out.includes('(无)')) newOld = out.trim()
2852
- } catch (e) {
2853
- console.error('[dsh-auto-memory] compactLayer AI failed', e && e.message ? e.message : e)
2854
- }
2855
- }
2856
- if (!newOld) {
2857
- // AI 不可用降级:把最早段落移入归档文件,直到旧部分 ≤ target(保底不丢)
3300
+ let folded = allowFold ? await this.foldTextToSummaryPre(oldText, layer, agent) : ''
3301
+ if (!folded) {
3302
+ // AI 不可用降级:把最早段落整段移入归档,直到剩余 ≤ keepBudget(保底不丢)
2858
3303
  const archiveFile = layer === 'user'
2859
3304
  ? path.join(dshHome(), 'memory', 'archived-user.md')
2860
3305
  : path.join(p.projectDir, 'archive', 'notes-archived.md')
2861
3306
  await mkdir(path.dirname(archiveFile), { recursive: true })
2862
- let segs2 = oldSegs.slice()
2863
- let remain = oldText
2864
- while (remain.length > target && segs2.length) {
2865
- const seg = segs2.shift()
3307
+ const rest = oldSegs.slice()
3308
+ let remainText = rest.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
3309
+ while (remainText.length > keepBudget && rest.length > 1) {
3310
+ const seg = rest.shift()
2866
3311
  await this.appendText(archiveFile, '\n## ' + seg.title + '\n' + seg.body.join('\n'))
2867
- remain = segs2.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
3312
+ remainText = rest.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
2868
3313
  }
2869
- newOld = remain.slice(0, target)
3314
+ folded = remainText.slice(0, keepBudget)
3315
+ } else {
3316
+ // 折叠成功:原文同样归档(整段),主文件只留要点
3317
+ const archiveFile = layer === 'user'
3318
+ ? path.join(dshHome(), 'memory', 'archived-user.md')
3319
+ : path.join(p.projectDir, 'archive', 'notes-archived.md')
3320
+ await mkdir(path.dirname(archiveFile), { recursive: true })
3321
+ await this.appendText(archiveFile, '\n' + oldSegs.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n'))
2870
3322
  }
2871
- newOld = newOld.slice(0, target)
2872
- const body = [newOld, todayText].filter(Boolean).join('\n\n')
3323
+ const body = [folded, keptText].filter(Boolean).join('\n\n')
2873
3324
  await this.writeFull(layer === 'user' ? p.userFile : p.notesPath, body)
2874
3325
  if (layer === 'user') this.state.userText = body
2875
3326
  else this.state.notesText = body
2876
- // 压缩腾出的空间让当日额度重新计(今天段落仍在文件里,不丢)
2877
- if (this._budgets) {
2878
- const b = this._budgets.get('today')
2879
- if (b && b.date === this.memToday()) b[layer] = 0
2880
- }
2881
- this._lastCompactAt = now
3327
+ this._lastCompactAt[layer] = Date.now()
2882
3328
  this.state.loadedAt = Date.now()
2883
- console.log('[dsh-auto-memory] compacted ' + layer + ' memory: ' + cur.length + ' -> ' + body.length + ' chars')
2884
- return true
3329
+ console.log('[compacted] ' + layer + ': ' + cur.length + ' -> ' + body.length + ' chars (folded=' + (folded ? 'yes' : 'archive-only') + ')')
3330
+ return { ok: true, reason: folded ? 'folded' : 'archived' }
2885
3331
  }
2886
3332
 
2887
3333
  /**
2888
- * F1(M6-CONTRACT 外补丁,2026-08-23):anchor 开启时的记录级压缩。
2889
- * 按 sidecar 记录序整条保留/移除——今天(标题日期=插件今天)记录无条件保留,其余按字节配额
2890
- * 从最新往回保留(layer 上限 user 4000/note 3000);被移除记录整条原文写入归档文件
2891
- * (writeFullRaw,不经 anchor 事务,归档非检索语料)。重组经 store.replace 原子执行:
2892
- * 候选预检(clean)→被移除 id 自动删除→保留 id/版本不变。永不字符切片。
2893
- * @returns {Promise<boolean|'not-anchored'>} true=已腾位 false=无法腾位 'not-anchored'=无 sidecar 走旧路径
3334
+ * 把一段旧内容交给子代理折叠成要点(≤ COMPACT_FOLD_MAX_CHARS)。失败/无子代理返回 ''。
3335
+ * 折叠失败**不是错误**——调用方会退回整条归档,所以这里 fail-soft。
2894
3336
  */
2895
- async compactAnchoredLayer(store, filePath, layer, today, paths) {
2896
- const cur = await this.readTextSafe(filePath)
2897
- if (!cur || !cur.trim()) return false
2898
- let parsed
2899
- try { parsed = parseAnchors(Buffer.from(cur, 'utf8')) } catch (_) { return 'not-anchored' }
2900
- if (!parsed || parsed.status !== 'clean') return false // 脏文件 fail closed:不压缩、不切片
2901
- const anchored = parsed.records.filter((r) => r.kind === 'anchored')
2902
- if (!anchored.length) return 'not-anchored'
2903
- const limit = layer === 'user' ? 4000 : 3000
2904
- const isTodayHeading = (heading) => /^\d{4}-\d{2}-\d{2}/.test(String(heading || '')) && String(heading).slice(0, 10) === today
2905
- const buf = Buffer.from(cur, 'utf8')
2906
- // 配额从尾回溯(F1):新的留;今天记录无条件保留
2907
- const keep = new Array(anchored.length).fill(false)
2908
- let keptBytes = 0
2909
- for (let i = anchored.length - 1; i >= 0; i--) {
2910
- const rec = anchored[i]
2911
- const spanBytes = rec.byteEnd - rec.markerByteStart
2912
- if (isTodayHeading(rec.heading) || keptBytes + spanBytes <= limit) { keep[i] = true; keptBytes += spanBytes }
2913
- }
2914
- const removedIdx = []
2915
- anchored.forEach((rec, i) => { if (!keep[i]) removedIdx.push(i) })
2916
- if (!removedIdx.length) return false // 无可移除 → 无法腾位(预算照常拒绝)
2917
- // 有序块重组:legacy 块与 preamble/tail 原样保留,gap 字节逐段拷贝,仅丢弃被移除记录的 [marker..contentEnd]
2918
- const blocks = []
2919
- for (const r of parsed.records) {
2920
- if (r.kind === 'anchored') blocks.push({ start: r.markerByteStart, end: r.byteEnd, keep: keep[anchored.indexOf(r)] })
2921
- else blocks.push({ start: r.byteStart, end: r.byteEnd, keep: true })
2922
- }
2923
- blocks.sort((a, b) => a.start - b.start)
2924
- const parts = []
2925
- let cursorB = 0
2926
- for (let i = 0; i < blocks.length; i++) {
2927
- const b = blocks[i]
2928
- if (b.start > cursorB) parts.push(buf.slice(cursorB, b.start).toString('utf8'))
2929
- if (b.keep) parts.push(buf.slice(b.start, b.end).toString('utf8'))
2930
- cursorB = b.end
2931
- }
2932
- if (cursorB < buf.length) parts.push(buf.slice(cursorB).toString('utf8'))
2933
- const newText = parts.join('')
2934
- // 归档:整条原文(writeFullRaw 绕开 anchor 事务;归档文件从不参与解析/索引)
2935
- const archiveFile = layer === 'user'
2936
- ? path.join(dshHome(), 'memory', 'archived-user.md')
2937
- : path.join(paths.projectDir, 'archive', 'notes-archived.md')
2938
- const archiveText = removedIdx.map((i2) => {
2939
- const r = anchored[i2]
2940
- return buf.slice(r.markerByteStart, r.byteEnd).toString('utf8')
2941
- }).join('\n')
2942
- const existingArchive = await this.readTextSafe(archiveFile)
2943
- await this.writeFullRaw(archiveFile, (existingArchive ? existingArchive.replace(/\s+$/, '') + '\n' : '') + archiveText + '\n')
2944
- const rr = await store.replace(filePath, newText)
2945
- if (!rr.ok) { console.error('[compacted-anchored] replace failed: ' + rr.reason); return false }
2946
- const freshText = await this.readTextSafe(filePath)
2947
- if (layer === 'user') this.state.userText = freshText
2948
- else this.state.notesText = freshText
2949
- if (this._budgets) {
2950
- const b = this._budgets.get('today')
2951
- if (b && b.date === this.memToday()) b[layer] = 0
3337
+ async foldTextToSummaryPre(text, layer, agent) {
3338
+ try {
3339
+ if (!this._subagents || !agent || !String(text || '').trim()) return ''
3340
+ const layerName = layer === 'user' ? '用户级记忆' : '项目笔记'
3341
+ const prompt = [
3342
+ '你是记忆整理员。下面这段' + layerName + '内容已超出容量上限,需要**压缩成要点**后留在主文件里(原文会同时归档,不会丢)。',
3343
+ '规则:',
3344
+ '- 保留所有仍有长期价值的硬信息:强制规则、偏好、关键决策、约定、关键路径、踩过的坑及其规则',
3345
+ '- 合并重复、删除已失效/过期条目;每条一句话,尽量压到 30 字内',
3346
+ '- 按主题分组,用"### 主题"小标题;不要复述原文,只留结论',
3347
+ '- 语体:第三人称客观陈述,只落可复用结论;禁止第一人称思维叙述与思考腔',
3348
+ '- 只输出 markdown 正文,不要任何解释;总长度不超过 ' + COMPACT_FOLD_MAX_CHARS + ' 字符',
3349
+ '',
3350
+ '待折叠内容:',
3351
+ truncateTail(String(text), 6000),
3352
+ ].join('\n')
3353
+ const out = await this.runSubagent(prompt, 'auto-memory-fold', agent)
3354
+ const t = String(out || '').trim()
3355
+ if (!t || t.includes('(无)')) return ''
3356
+ return t.slice(0, COMPACT_FOLD_MAX_CHARS)
3357
+ } catch (e) {
3358
+ console.error('[auto-memory-fold] failed: ' + (e && e.message ? e.message : e))
3359
+ return ''
2952
3360
  }
2953
- this._lastCompactAt = Date.now()
2954
- this.state.loadedAt = Date.now()
2955
- console.log('[compacted-anchored] ' + layer + ': kept=' + (anchored.length - removedIdx.length) + '/' + anchored.length + ' archived=' + removedIdx.length + ' bytes=' + cur.length + '->' + newText.length)
2956
- return true
2957
3361
  }
2958
3362
 
2959
3363
  /**
2960
- * F1(M6-CONTRACT 外补丁,2026-08-23):anchor 开启时的记录级压缩。
2961
- * 按 sidecar 记录序整条保留/移除——今天(标题日期=插件今天)记录无条件保留,其余按字节配额
2962
- * 从最新往回保留(layer 上限 user 4000/note 3000);被移除记录整条原文写入归档文件
2963
- * (writeFullRaw,不经 anchor 事务,归档非检索语料)。重组经 store.replace 原子执行:
2964
- * 候选预检(clean)→被移除 id 自动删除→保留 id/版本不变。永不字符切片。
2965
- * @returns {Promise<boolean|'not-anchored'>} true=已腾位 false=无法腾位 'not-anchored'=无 sidecar 走旧路径
3364
+ * anchor 开启时的记录级整理(2026-09-10 重做,合并原先三份重复实现)。
3365
+ *
3366
+ * 旧行为的致命点:标题日期=今天的记录**无条件保留** → 当天写入量本身超过额度时 `removable` 为空、
3367
+ * 直接返回"无法腾位",于是当天再也写不进项目笔记(须跨天自解)。现在改为**只按容量配额从尾往回保留**,
3368
+ * 保护窗口外(含今天写入的较早条目)一律可回收。
3369
+ *
3370
+ * 回收方式:优先把可回收的前缀折叠成**一条要点记录**写回原位置(AI 不可用时退回整条移除);
3371
+ * 两种情况都把被回收记录的原文整条写入归档文件(writeFullRaw 绕开 anchor 事务,归档不参与解析/索引)。
3372
+ * 重组经 `store.replace` 原子执行:候选预检(clean)→被移除 id 自动删除→保留 id/版本不变,**永不字符切片**。
3373
+ * @param {number} keepBudget 保留内容的目标字符数(不含待写入余量)
3374
+ * @returns {Promise<{ok:boolean,reason?:string}|'not-anchored'>}
2966
3375
  */
2967
- async compactAnchoredLayer(store, filePath, layer, today, paths) {
3376
+ async compactAnchoredLayer(store, filePath, layer, today, paths, deficit, agent, allowFold) {
2968
3377
  const cur = await this.readTextSafe(filePath)
2969
- if (!cur || !cur.trim()) return false
3378
+ if (!cur || !cur.trim()) return { ok: true, reason: 'empty' }
2970
3379
  let parsed
2971
3380
  try { parsed = parseAnchors(Buffer.from(cur, 'utf8')) } catch (_) { return 'not-anchored' }
2972
- if (!parsed || parsed.status !== 'clean') return false // 脏文件 fail closed:不压缩、不切片
3381
+ if (!parsed || parsed.status !== 'clean') return { ok: false, reason: 'dirty-file' }
2973
3382
  const anchored = parsed.records.filter((r) => r.kind === 'anchored')
2974
3383
  if (!anchored.length) return 'not-anchored'
2975
- const limit = layer === 'user' ? 4000 : 3000
2976
- const isTodayHeading = (heading) => /^\d{4}-\d{2}-\d{2}/.test(String(heading || '')) && String(heading).slice(0, 10) === today
2977
3384
  const buf = Buffer.from(cur, 'utf8')
2978
- // 配额从尾回溯(F1):新的留;今天记录无条件保留
2979
- const keep = new Array(anchored.length).fill(false)
2980
- let keptBytes = 0
3385
+ // 单位统一:一律换算成**字符**(spanChars);byte 区间仍保留,供切片与归档使用。
3386
+ const spanCharsOf = (r) => buf.slice(r.markerByteStart, r.byteEnd).toString('utf8').length
3387
+ // 目标改成**缺口制**:从最旧开始回收,直到腾出的字符数 ≥ deficit(而不是"把保留量压到某个绝对值以下")。
3388
+ // 保护窗口(最近 COMPACT_PROTECT_RECENT_CHARS 字符)是**软**下限:窗口外不够腾时才向内侵占;
3389
+ // 唯一硬底线是"最新 1 条永不整条移除"。
3390
+ const protectBudget = COMPACT_PROTECT_RECENT_CHARS
3391
+ let tailKept = 0
3392
+ let protectFrom = 0
2981
3393
  for (let i = anchored.length - 1; i >= 0; i--) {
2982
- const rec = anchored[i]
2983
- const spanBytes = rec.byteEnd - rec.markerByteStart
2984
- if (isTodayHeading(rec.heading) || keptBytes + spanBytes <= limit) { keep[i] = true; keptBytes += spanBytes }
3394
+ tailKept += spanCharsOf(anchored[i])
3395
+ protectFrom = i
3396
+ if (tailKept >= protectBudget) break
2985
3397
  }
2986
- const removedIdx = []
2987
- anchored.forEach((rec, i) => { if (!keep[i]) removedIdx.push(i) })
2988
- if (!removedIdx.length) return false // 无可移除 → 无法腾位(预算照常拒绝)
2989
- // 有序块重组:legacy 块与 preamble/tail 原样保留,gap 字节逐段拷贝,仅丢弃被移除记录的 [marker..contentEnd]
2990
- const blocks = []
2991
- for (const r of parsed.records) {
2992
- if (r.kind === 'anchored') blocks.push({ start: r.markerByteStart, end: r.byteEnd, keep: keep[anchored.indexOf(r)] })
2993
- else blocks.push({ start: r.byteStart, end: r.byteEnd, keep: true })
3398
+ if (protectFrom > anchored.length - 1) protectFrom = anchored.length - 1
3399
+ const keep = new Array(anchored.length).fill(true)
3400
+ let removed = 0
3401
+ let cutIdx = -1
3402
+ for (let i = 0; i < protectFrom; i++) {
3403
+ removed += spanCharsOf(anchored[i]); cutIdx = i
3404
+ if (removed >= deficit) break
2994
3405
  }
2995
- blocks.sort((a, b) => a.start - b.start)
2996
- const parts = []
2997
- let cursorB = 0
2998
- for (let i = 0; i < blocks.length; i++) {
2999
- const b = blocks[i]
3000
- if (b.start > cursorB) parts.push(buf.slice(cursorB, b.start).toString('utf8'))
3001
- if (b.keep) parts.push(buf.slice(b.start, b.end).toString('utf8'))
3002
- cursorB = b.end
3406
+ if (removed < deficit) { // 保护窗口外不够 → 侵入保护窗口(仍保留最新 1 条)
3407
+ for (let i = protectFrom; i < anchored.length - 1; i++) {
3408
+ removed += spanCharsOf(anchored[i]); cutIdx = i
3409
+ if (removed >= deficit) break
3410
+ }
3003
3411
  }
3004
- if (cursorB < buf.length) parts.push(buf.slice(cursorB).toString('utf8'))
3005
- const newText = parts.join('')
3006
- // 归档:整条原文(writeFullRaw 绕开 anchor 事务;归档文件从不参与解析/索引)
3412
+ if (cutIdx < 0 || removed < deficit) return { ok: false, reason: 'no-removable' } // 受硬底线所限无法腾够
3413
+ for (let i = 0; i <= cutIdx; i++) keep[i] = false
3414
+ const removedIdx = []
3415
+ for (let i = 0; i <= cutIdx; i++) removedIdx.push(i)
3416
+ const removedSet = new Set(removedIdx)
3417
+
3418
+ // 归档:被回收记录的整条原文(两种回收方式都归档,保底不丢)
3007
3419
  const archiveFile = layer === 'user'
3008
3420
  ? path.join(dshHome(), 'memory', 'archived-user.md')
3009
3421
  : path.join(paths.projectDir, 'archive', 'notes-archived.md')
@@ -3013,93 +3425,46 @@ class MemoryEngine {
3013
3425
  }).join('\n')
3014
3426
  const existingArchive = await this.readTextSafe(archiveFile)
3015
3427
  await this.writeFullRaw(archiveFile, (existingArchive ? existingArchive.replace(/\s+$/, '') + '\n' : '') + archiveText + '\n')
3016
- const rr = await store.replace(filePath, newText)
3017
- if (!rr.ok) { console.error('[compacted-anchored] replace failed: ' + rr.reason); return false }
3018
- const freshText = await this.readTextSafe(filePath)
3019
- if (layer === 'user') this.state.userText = freshText
3020
- else this.state.notesText = freshText
3021
- if (this._budgets) {
3022
- const b = this._budgets.get('today')
3023
- if (b && b.date === this.memToday()) b[layer] = 0
3024
- }
3025
- this._lastCompactAt = Date.now()
3026
- this.state.loadedAt = Date.now()
3027
- console.log('[compacted-anchored] ' + layer + ': kept=' + (anchored.length - removedIdx.length) + '/' + anchored.length + ' archived=' + removedIdx.length + ' bytes=' + cur.length + '->' + newText.length)
3028
- return true
3029
- }
3030
3428
 
3031
- /**
3032
- * F1(M6-CONTRACT 外补丁,2026-08-23):anchor 开启时的记录级压缩。
3033
- * 按 sidecar 记录序整条保留/移除——今天(标题日期=插件今天)记录无条件保留,其余按字节配额
3034
- * 从最新往回保留(layer 上限 user 4000/note 3000);被移除记录整条原文写入归档文件
3035
- * (writeFullRaw,不经 anchor 事务,归档非检索语料)。重组经 store.replace 原子执行:
3036
- * 候选预检(clean)→被移除 id 自动删除→保留 id/版本不变。永不字符切片。
3037
- * @returns {Promise<boolean|'not-anchored'>} true=已腾位 false=无法腾位 'not-anchored'=无 sidecar 走旧路径
3038
- */
3039
- async compactAnchoredLayer(store, filePath, layer, today, paths) {
3040
- const cur = await this.readTextSafe(filePath)
3041
- if (!cur || !cur.trim()) return false
3042
- let parsed
3043
- try { parsed = parseAnchors(Buffer.from(cur, 'utf8')) } catch (_) { return 'not-anchored' }
3044
- if (!parsed || parsed.status !== 'clean') return false // 脏文件 fail closed:不压缩、不切片
3045
- const anchored = parsed.records.filter((r) => r.kind === 'anchored')
3046
- if (!anchored.length) return 'not-anchored'
3047
- const limit = layer === 'user' ? 4000 : 3000
3048
- const isTodayHeading = (heading) => /^\d{4}-\d{2}-\d{2}/.test(String(heading || '')) && String(heading).slice(0, 10) === today
3049
- const buf = Buffer.from(cur, 'utf8')
3050
- // 配额从尾回溯(F1):新的留;今天记录无条件保留
3051
- const keep = new Array(anchored.length).fill(false)
3052
- let keptBytes = 0
3053
- for (let i = anchored.length - 1; i >= 0; i--) {
3054
- const rec = anchored[i]
3055
- const spanBytes = rec.byteEnd - rec.markerByteStart
3056
- if (isTodayHeading(rec.heading) || keptBytes + spanBytes <= limit) { keep[i] = true; keptBytes += spanBytes }
3057
- }
3058
- const removedIdx = []
3059
- anchored.forEach((rec, i) => { if (!keep[i]) removedIdx.push(i) })
3060
- if (!removedIdx.length) return false // 无可移除 → 无法腾位(预算照常拒绝)
3061
- // 有序块重组:legacy 块与 preamble/tail 原样保留,gap 字节逐段拷贝,仅丢弃被移除记录的 [marker..contentEnd]
3429
+ // 优先折叠成要点;AI 不可用则整条移除(原文已进归档)
3430
+ let folded = allowFold ? await this.foldTextToSummaryPre(archiveText, layer, agent) : ''
3431
+ // 护栏:折叠产物若比"腾出来的空间减缺口"还大,写回去仍会超容量 → 退回整条归档(不写折叠块)。
3432
+ if (folded && folded.length > Math.max(0, removed - deficit)) folded = ''
3433
+ const foldedBlock = folded ? ('## 重组织要点(整理于 ' + today + ')\n' + folded + '\n') : ''
3434
+
3435
+ // 有序重组:legacy 块与 preamble/tail 原样保留;被移除记录处仅在**首个**移除位插入折叠要点
3062
3436
  const blocks = []
3063
3437
  for (const r of parsed.records) {
3064
- if (r.kind === 'anchored') blocks.push({ start: r.markerByteStart, end: r.byteEnd, keep: keep[anchored.indexOf(r)] })
3065
- else blocks.push({ start: r.byteStart, end: r.byteEnd, keep: true })
3438
+ if (r.kind === 'anchored') blocks.push({ start: r.markerByteStart, end: r.byteEnd, keep: keep[anchored.indexOf(r)], anchoredIdx: anchored.indexOf(r) })
3439
+ else blocks.push({ start: r.byteStart, end: r.byteEnd, keep: true, anchoredIdx: -1 })
3066
3440
  }
3067
3441
  blocks.sort((a, b) => a.start - b.start)
3068
3442
  const parts = []
3069
3443
  let cursorB = 0
3070
- for (let i = 0; i < blocks.length; i++) {
3071
- const b = blocks[i]
3444
+ let injected = false
3445
+ for (const b of blocks) {
3072
3446
  if (b.start > cursorB) parts.push(buf.slice(cursorB, b.start).toString('utf8'))
3073
3447
  if (b.keep) parts.push(buf.slice(b.start, b.end).toString('utf8'))
3448
+ else if (!injected && b.anchoredIdx >= 0) { injected = true; if (foldedBlock) parts.push(foldedBlock) }
3074
3449
  cursorB = b.end
3075
3450
  }
3076
3451
  if (cursorB < buf.length) parts.push(buf.slice(cursorB).toString('utf8'))
3077
3452
  const newText = parts.join('')
3078
- // 归档:整条原文(writeFullRaw 绕开 anchor 事务;归档文件从不参与解析/索引)
3079
- const archiveFile = layer === 'user'
3080
- ? path.join(dshHome(), 'memory', 'archived-user.md')
3081
- : path.join(paths.projectDir, 'archive', 'notes-archived.md')
3082
- const archiveText = removedIdx.map((i2) => {
3083
- const r = anchored[i2]
3084
- return buf.slice(r.markerByteStart, r.byteEnd).toString('utf8')
3085
- }).join('\n')
3086
- const existingArchive = await this.readTextSafe(archiveFile)
3087
- await this.writeFullRaw(archiveFile, (existingArchive ? existingArchive.replace(/\s+$/, '') + '\n' : '') + archiveText + '\n')
3453
+
3088
3454
  const rr = await store.replace(filePath, newText)
3089
- if (!rr.ok) { console.error('[compacted-anchored] replace failed: ' + rr.reason); return false }
3455
+ if (!rr.ok) { console.error('[compacted-anchored] replace failed: ' + rr.reason); return { ok: false, reason: 'replace-failed:' + rr.reason } }
3090
3456
  const freshText = await this.readTextSafe(filePath)
3091
3457
  if (layer === 'user') this.state.userText = freshText
3092
3458
  else this.state.notesText = freshText
3093
- if (this._budgets) {
3094
- const b = this._budgets.get('today')
3095
- if (b && b.date === this.memToday()) b[layer] = 0
3096
- }
3097
- this._lastCompactAt = Date.now()
3459
+ this._lastCompactAt[layer] = Date.now()
3098
3460
  this.state.loadedAt = Date.now()
3099
- console.log('[compacted-anchored] ' + layer + ': kept=' + (anchored.length - removedIdx.length) + '/' + anchored.length + ' archived=' + removedIdx.length + ' bytes=' + cur.length + '->' + newText.length)
3100
- return true
3461
+ console.log('[compacted-anchored] ' + layer + ': kept=' + (anchored.length - removedIdx.length) + '/' + anchored.length
3462
+ + ' reclaimed=' + removedIdx.length + ' folded=' + (folded ? 'yes' : 'no')
3463
+ + ' chars=' + cur.length + '->' + newText.length + ' deficit=' + deficit)
3464
+ return { ok: true, reason: folded ? 'folded' : 'archived' }
3101
3465
  }
3102
3466
 
3467
+
3103
3468
  // ---------- 注入渲染(同步,基于缓存) ----------
3104
3469
  // 动态记忆 → ctx.systemPrompt.context()(user-role 快照):内容变化才追加新快照,内容不变不重复注入(dsh-agent-loop project() 去重),
3105
3470
  // system prompt 不再包含动态内容 → 字节级稳定 → DeepSeek 前缀缓存全程命中(对比 section 方案:动态内容任何变化都从变化点起击穿整个前缀,含全部历史)
@@ -3215,7 +3580,7 @@ class MemoryEngine {
3215
3580
  lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
3216
3581
  lines.push('- **交接与白板(长任务续命)**:阶段产出或方向变化时,调用 memory_note(kind=handoff) 写四段式交接账本——任务状态/目标/已试方案与失败原因/进度与下一步,给下一个上下文窗口续命;对项目全貌的理解发生实质变化时,用 memory_note(kind=plan) 重写白板 PLAN.md(人能读的项目规划图,旧版自动归档,用户在面板实时可见)。账本质量纪律:每段 ≤5 行;失败项写成「方案→失败原因」并保留关键报错词;下一步必须是可直接执行的第一步(带文件路径或命令);不写临时信息。两者会注入到你的动态快照首位,是跨窗口交接的凭据。')
3217
3582
  lines.push('- 只记录有跨会话长期价值的;不记临时信息(搜索结果、临时路径、工具报错)。')
3218
- lines.push('- 每日写入预算(所有会话共享一天额度,跨天自动重置):用户级 ≤4000 字/天、项目笔记 ≤3000 字/天;超限不拒绝——框架自动把"今天之前"的旧内容交给 AI 浓缩成要点腾出空间(10 分钟内只压缩一次),AI 不可用时归档最早段落,信息不丢;每日日志无预算。')
3583
+ lines.push('- 记忆容量(设置项 noteCapacityChars/userCapacityChars,默认各 12000 字符):项目笔记与用户级记忆各有容量上限;超出时框架自动整理——先把较早内容交给 AI 折叠成要点、失败则退回整条归档(原文进 archive,信息不丢),整理后仍超才拒绝写入,所以**新记忆正常不会被堵在外面**。整理同一层 10 分钟内只做一次;每日日志无容量限制。注意这与「注入预算」不同:注入预算管每轮往上下文塞多少摘要,容量上限管文件本体大小。')
3219
3584
  lines.push('- **记忆操作必须在正文可见(摘要链)**:调用 memory_log/note/user/reflect 更新记忆后,必须把结果写进本轮回复的正文文本(用户直接看到的那段文字,不是工具调用区),并在**回复末尾**用加粗或换行使其醒目(如"**已更新今日日志**\n新增:修复了XXX");调用 memory_recall/memory_external 检索时,在正文开头写明"我查了记忆,发现..."。工具返回值只是辅助,正文转述是强制要求。')
3220
3585
  lines.push('- 用户明确要求长期记住:跨项目规则 → memory_user;仅本项目 → memory_note。')
3221
3586
  lines.push('- 定期调用 memory_maintain 做 30 天蒸馏:AI 提炼旧日志要点进项目笔记,原文保底归档;不存密钥,除非用户明确要求。')
@@ -3994,20 +4359,20 @@ class MemoryEngine {
3994
4359
  },
3995
4360
  duplicateHeadings,
3996
4361
  budgets: (() => {
3997
- const out = []
3998
- if (this._budgets) {
3999
- for (const [, b] of this._budgets.entries()) {
4000
- out.push({
4001
- scope: 'daily', date: b.date,
4002
- userUsed: b.user, userLimit: 4000,
4003
- noteUsed: b.note, noteLimit: 3000,
4004
- userFileSize: (this.state.userText || '').length,
4005
- noteFileSize: (this.state.notesText || '').length,
4006
- lastCompactAt: this._lastCompactAt || 0,
4007
- })
4008
- }
4009
- }
4010
- return out
4362
+ // 2026-09-10 改为**容量口径**:报的是"文件当前字符数 / 容量上限",不再有"当日已用/字每天"这种记账。
4363
+ const uLim = this.capacityLimit('user')
4364
+ const nLim = this.capacityLimit('note')
4365
+ const uUsed = (this.state.userText || '').length
4366
+ const nUsed = (this.state.notesText || '').length
4367
+ return [{
4368
+ scope: 'capacity',
4369
+ userUsed: uUsed, userLimit: uLim,
4370
+ noteUsed: nUsed, noteLimit: nLim,
4371
+ userFileSize: uUsed,
4372
+ noteFileSize: nUsed,
4373
+ lastCompactAt: (this._lastCompactAt && this._lastCompactAt.note) || 0,
4374
+ lastCompactAtUser: (this._lastCompactAt && this._lastCompactAt.user) || 0,
4375
+ }]
4011
4376
  })(),
4012
4377
  today: todayStr(),
4013
4378
  memoryDate: this.memToday(),
@@ -4390,15 +4755,15 @@ class MemoryEngine {
4390
4755
  let run
4391
4756
  try {
4392
4757
  // prompt 必须是 block 数组(createUserMessage 校验 content.some)
4393
- const subModel = String(this.config.subagentModel || '').trim()
4394
4758
  this._subagentInflight = (this._subagentInflight || 0) + 1
4395
4759
  run = await subagents.start(providerName, {
4396
4760
  label,
4397
4761
  prompt: [{ type: 'text', text }],
4398
4762
  signal: controller.signal,
4399
4763
  ...(parent ? { parent } : {}),
4400
- // 设置页「总结/问候默认模型」:非空时覆盖子代理的模型选择(空=跟随路由默认)
4401
- ...(subModel ? { agentOptions: { model: subModel } } : {}),
4764
+ // 设置页「子代理模型 / 思考强度」:非空时覆盖子代理的模型与推理档位(空=跟随路由默认)。
4765
+ // DSH 0.1.5:两者都经 SubagentStartRequest.agentOptions 下发(provider 侧需 capabilities.agentOptions)。
4766
+ ...(subAgentOptions(this.config) ? { agentOptions: subAgentOptions(this.config) } : {}),
4402
4767
  })
4403
4768
  // 套娃防护(问题②):登记本插件 spawn 的子代理,其生命周期事件在 pre-step/turn-stopping 零处理
4404
4769
  // 2026-09-09 修复:DSH 0.1.2 in-process run 对象字段为 localAgent(旧版为 agent),取两者兼容
@@ -4432,7 +4797,8 @@ class MemoryEngine {
4432
4797
  prompt: [{ type: 'text', text }],
4433
4798
  signal: controller.signal,
4434
4799
  ...(parent ? { parent } : {}),
4435
- agentOptions: { model: fbModel },
4800
+ // 回退必须显式用 fbModel(而不是设置页的 subagentModel),否则重试等于没换模型;强度沿用配置。
4801
+ agentOptions: subAgentOptions({ ...this.config, subagentModel: fbModel }),
4436
4802
  })
4437
4803
  result = await this.withTimeout(run2.result, tMs, null)
4438
4804
  }
@@ -4785,7 +5151,7 @@ class MemoryEngine {
4785
5151
  this.state.notesText = body
4786
5152
  written += notePts.length
4787
5153
  } else {
4788
- console.log('[dsh-auto-memory] auto-consolidate: NOTE 超预算且压缩不可用,已跳过')
5154
+ console.log('[dsh-auto-memory] auto-consolidate: NOTE ' + this.budgetRefusalTextPre(r, 'note') + ',已跳过')
4789
5155
  }
4790
5156
  }
4791
5157
  if (userPts.length) {
@@ -4795,7 +5161,7 @@ class MemoryEngine {
4795
5161
  this.state.userText = body
4796
5162
  written += userPts.length
4797
5163
  } else {
4798
- console.log('[dsh-auto-memory] auto-consolidate: USER 超预算且压缩不可用,已跳过')
5164
+ console.log('[dsh-auto-memory] auto-consolidate: USER ' + this.budgetRefusalTextPre(r, 'user') + ',已跳过')
4799
5165
  }
4800
5166
  }
4801
5167
  if (written) {
@@ -4879,7 +5245,7 @@ class MemoryEngine {
4879
5245
  const body = await this.appendText(p.notesPath, '\n## ' + today + '\n' + projectPts.map((x) => '- ' + x).join('\n'))
4880
5246
  this.state.notesText = body
4881
5247
  written.push('项目笔记 ' + projectPts.length + ' 条')
4882
- } else skipped.push('项目笔记(预算超限且压缩不可用)')
5248
+ } else skipped.push('项目笔记(' + this.budgetRefusalTextPre(r, 'note') + ')')
4883
5249
  }
4884
5250
  if (userPts.length) {
4885
5251
  const r = await this.ensureBudget(agent, 'user', userPts.map((x) => '- ' + x).join('\n'))
@@ -4887,11 +5253,11 @@ class MemoryEngine {
4887
5253
  const body = await this.appendText(p.userFile, '\n## ' + today + '\n' + userPts.map((x) => '- ' + x).join('\n'))
4888
5254
  this.state.userText = body
4889
5255
  written.push('用户级记忆 ' + userPts.length + ' 条')
4890
- } else skipped.push('用户级记忆(预算超限且压缩不可用)')
5256
+ } else skipped.push('用户级记忆(' + this.budgetRefusalTextPre(r, 'user') + ')')
4891
5257
  }
4892
5258
  this.state.loadedAt = Date.now()
4893
5259
  if (!written.length) {
4894
- if (skipped.length) return 'memory_consolidate: AI 已提炼,但今日写入预算已用尽,以下层被跳过(明天再固化): ' + skipped.join('、') + '。'
5260
+ if (skipped.length) return 'memory_consolidate: AI 已提炼,但自动整理未能腾出空间,以下层被跳过: ' + skipped.join('、') + '。'
4895
5261
  return 'memory_consolidate: AI 未提炼出值得长期记录的内容。'
4896
5262
  }
4897
5263
  const detail = []
@@ -6332,7 +6698,10 @@ export function apply(ctx, config) {
6332
6698
  try { ctx.on('dispose', () => { engine._disposed = true }) } catch (eDispose) {}
6333
6699
  // 时间检测用的服务(软获取,缺失时定时兜底自动降级)
6334
6700
  engine._sessionsSvc = ctx.get('sessions') || undefined
6335
- engine._agentSvc = ctx.get('agent') || undefined
6701
+ // DSH 0.1.5 破坏性变更:单数服务键 'agent' 已移除,Agent 注册表改名为 'agents'(AgentRegistry)。
6702
+ // 旧写法让 _agentSvc 恒 undefined → restoreLastAgent 永久失效(实测每 tick 刷 "svc missing ... agent=false")。
6703
+ // 先取新键,保留旧键兜底以兼容 0.1.4 及更早版本。
6704
+ engine._agentSvc = ctx.get('agents') || ctx.get('agent') || undefined
6336
6705
  // 模型目录(设置页「总结/问候默认模型」抽屉数据源);软获取,缺失时抽屉回退手动输入
6337
6706
  engine._llm = ctx.get('llm') || undefined
6338
6707
  // 工作区注册表(修A,2026-09-08):接续时解析旧会话所属 workspaceId——官方 create 只在传 workspaceId 时绑定工作区
@@ -6575,7 +6944,7 @@ export function apply(ctx, config) {
6575
6944
  return '已更新记忆文档: ' + logPath + '\n' + entry + (gate.truncated ? '\n(内容超长,已截断)' : '')
6576
6945
  }),
6577
6946
 
6578
- defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点),或写交接白板。kind=note(默认):action=append 追加一段(自动带日期标题)/action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容),每日预算 3000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。kind=handoff:写一篇四段式交接账本(任务状态/目标/已试方案与失败原因/进度与下一步)到 handoff/,阶段产出或方向变化时用,给下一个上下文窗口续命。kind=plan:整体重写白板 PLAN.md(人能读的项目全貌规划图),对项目全貌的理解发生实质变化时用,旧版自动归档。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
6947
+ defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点),或写交接白板。kind=note(默认):action=append 追加一段(自动带日期标题)/action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容),本项目笔记有**容量上限**(字符,设置项 noteCapacityChars,默认 12000);超出时自动整理——先把较早内容交给 AI 折叠成要点、失败则退回整条归档(原文都进 archive,不丢),整理后仍超才拒绝(正常不会发生)。kind=handoff:写一篇四段式交接账本(任务状态/目标/已试方案与失败原因/进度与下一步)到 handoff/,阶段产出或方向变化时用,给下一个上下文窗口续命。kind=plan:整体重写白板 PLAN.md(人能读的项目全貌规划图),对项目全貌的理解发生实质变化时用,旧版自动归档。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
6579
6948
  content: { type: 'string', required: true, description: '笔记内容。kind=plan 时给完整新全貌(不是增量)。' },
6580
6949
  action: { type: 'string', enum: ['append', 'replace'], description: '仅 kind=note 时有效:append=追加, replace=整体替换。' },
6581
6950
  kind: { type: 'string', enum: ['note', 'handoff', 'plan'], description: 'note=项目笔记(默认), handoff=四段式交接账本(新篇), plan=白板全貌重写。' },
@@ -6608,7 +6977,7 @@ export function apply(ctx, config) {
6608
6977
  if (tailHas(existing, write)) return 'memory_note: 与笔记尾部已有内容重复,拒绝写入(复读防护)。'
6609
6978
  }
6610
6979
  const acct = await engine.ensureBudget(exec.agent, 'note', write)
6611
- if (!acct.ok) return 'memory_note: 项目笔记今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
6980
+ if (!acct.ok) return 'memory_note: ' + engine.budgetRefusalTextPre(acct, 'note') + ' 本次未写入(原内容未改动)。'
6612
6981
  let body
6613
6982
  if (replace) {
6614
6983
  body = write
@@ -6620,7 +6989,7 @@ export function apply(ctx, config) {
6620
6989
  return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + write + (gate.truncated ? '\n(内容超长,已截断到 ' + write.length + ' 字符)' : '') + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
6621
6990
  }),
6622
6991
 
6623
- defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。每日预算 4000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
6992
+ defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。有**容量上限**(字符,设置项 userCapacityChars,默认 12000);超出时自动整理——先把较早内容交给 AI 折叠成要点、失败则退回整条归档(原文都进 archive,不丢),整理后仍超才拒绝(正常不会发生)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
6624
6993
  content: { type: 'string', required: true, description: '要记住的规则或偏好内容。' },
6625
6994
  action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
6626
6995
  }, async (args, exec) => {
@@ -6637,7 +7006,7 @@ export function apply(ctx, config) {
6637
7006
  if (tailHas(existing, write)) return 'memory_user: 与用户级记忆尾部已有内容重复,拒绝写入(复读防护)。'
6638
7007
  }
6639
7008
  const acct = await engine.ensureBudget(exec.agent, 'user', write)
6640
- if (!acct.ok) return 'memory_user: 用户级记忆今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
7009
+ if (!acct.ok) return 'memory_user: ' + engine.budgetRefusalTextPre(acct, 'user') + ' 本次未写入(原内容未改动)。'
6641
7010
  let body
6642
7011
  if (replace) {
6643
7012
  body = write
@@ -6894,18 +7263,44 @@ export function apply(ctx, config) {
6894
7263
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
6895
7264
  if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
6896
7265
  try {
6897
- const r = await engine.buildContinueCarry()
7266
+ // fromSessionId(2026-09-10):client 侧知道自己在续哪个会话,显式传进来,避免材料包取错会话。
7267
+ const body = await readJsonBody(req).catch(() => ({}))
7268
+ const r = await engine.buildContinueCarry((body && body.fromSessionId) || '')
6898
7269
  return writeJson(res, r.ok ? 200 : 400, r)
6899
7270
  } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
6900
7271
  },
6901
7272
  },
7273
+ {
7274
+ kind: 'exact',
7275
+ path: API['handoff-permission'],
7276
+ handler: async (req, res) => {
7277
+ // 一键接续(client 路径)的权限继承(POST;loopback):官方 session.create 不收权限字段,
7278
+ // 新会话必落 settings 的 permission.defaultPreset → 接续后每一步都要用户批准(静默运行被打断)。
7279
+ // 由 client 在建好新会话后回调本端点,宿主侧把旧会话的预设套到新会话上(fail-soft)。
7280
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
7281
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
7282
+ try {
7283
+ const body = await readJsonBody(req).catch(() => ({}))
7284
+ const r = await engine.inheritPermissionForContinue(body && body.fromSessionId, body && body.toSessionId, { attempts: (body && body.attempts) || undefined })
7285
+ // 落闩(2026-09-10):本端点由 client 在建好新会话后回调,带两个 sid → 正是「接续已发生」的事实点。
7286
+ // 放在这里而不是 handoff-continue(取材料,可能只是预览)是为了只在**真建成**时才关掉旧会话的自动接续。
7287
+ if (body && body.fromSessionId && body.toSessionId) {
7288
+ try { engine.markContinuedSession(body.fromSessionId, body.toSessionId) } catch (eL) {}
7289
+ }
7290
+ return writeJson(res, 200, r || { ok: false, reason: 'no result' })
7291
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
7292
+ },
7293
+ },
6902
7294
  {
6903
7295
  kind: 'exact',
6904
7296
  path: API['auto-continue-state'],
6905
7297
  handler: async (req, res) => {
6906
7298
  // M-CM6-C·宿主兜底状态(GET;loopback):浏览器 AutoContinueHost 轮询此端点展示确认卡/倒计时/执行结果。
6907
7299
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
6908
- try { return writeJson(res, 200, engine.autoContinueState()) } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
7300
+ try {
7301
+ const url = new URL(req.url, 'http://127.0.0.1')
7302
+ return writeJson(res, 200, engine.autoContinueState(url.searchParams.get('sessionId')))
7303
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
6909
7304
  },
6910
7305
  },
6911
7306
  {