@a9i5k4/dsh-auto-memory 2.3.0 → 2.4.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.
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)
@@ -1750,6 +1806,23 @@ class MemoryEngine {
1750
1806
  return rec && Date.now() - (Number(rec.at) || 0) < 600000 ? rec : null
1751
1807
  }
1752
1808
 
1809
+ /** 2.3.1:解析会话日志文件路径 —— DSH 0.1.5 起会话格式迁移到 `session.v3.jsonl.zstd`
1810
+ * (旧 `session.jsonl.zstd` 保留但停止更新);按 mtime 取最新存在的文件,兼容两代与未来命名。 */
1811
+ async resolveSessionFile(dir) {
1812
+ try {
1813
+ const cands = ['session.v3.jsonl.zstd', 'session.jsonl.zstd', 'session.v3.jsonl', 'session.jsonl']
1814
+ let best = null
1815
+ for (const name of cands) {
1816
+ const p = path.join(dir, name)
1817
+ try {
1818
+ const st = await stat(p)
1819
+ if (st.isFile() && (!best || st.mtimeMs > best.mtimeMs)) best = { path: p, mtimeMs: st.mtimeMs }
1820
+ } catch (e) {}
1821
+ }
1822
+ return best ? best.path : ''
1823
+ } catch (e) { return '' }
1824
+ }
1825
+
1753
1826
  /** 从会话日志(磁盘)推导窗口与模型:不需要 agent,切换会话即可得到正确的模型名与窗口。 */
1754
1827
  async waterWindowForSession(sid) {
1755
1828
  const empty = { window: 0, source: '', model: '', provider: '', contextWindow: 0 }
@@ -1761,7 +1834,8 @@ class MemoryEngine {
1761
1834
  try {
1762
1835
  const dir = await this.locateSessionDir(String(sid))
1763
1836
  if (dir) {
1764
- const raw = await readFile(path.join(dir, 'session.jsonl.zstd')).catch(() => null)
1837
+ const sessionFile = await this.resolveSessionFile(dir)
1838
+ const raw = sessionFile ? await readFile(sessionFile).catch(() => null) : null
1765
1839
  const events = raw ? decodeZstdFrames(raw).split('\n').map((l) => { try { return JSON.parse(l) } catch (e) { return null } }).filter(Boolean) : []
1766
1840
  const info = findSessionModelPre(events)
1767
1841
  const wi = await this.resolveWaterWindow(info.provider, info.model)
@@ -1812,6 +1886,18 @@ class MemoryEngine {
1812
1886
  }
1813
1887
  if (win <= 0) return
1814
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
1815
1901
  let estTokens = 0
1816
1902
  let meterLayer = 'heuristic'
1817
1903
  try {
@@ -1829,43 +1915,63 @@ class MemoryEngine {
1829
1915
  const messages = extractSessionMessages(agent) || []
1830
1916
  estTokens = this.estimateSessionTokens(messages)
1831
1917
  }
1832
- // 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 刚报窗口超限 → 无论比例多少,立即补写材料并接续
1833
1923
  let compacted = false
1924
+ let overflowed = false
1834
1925
  try {
1835
- const events = sessionEventsOf(agent && agent.session)
1836
- for (let i = Math.max(0, events.length - 64); i < events.length; i++) {
1837
- const t = events[i] && events[i].type
1838
- if (t === 'compaction/summary' || t === 'compaction/prune') {
1839
- const seq = Number(events[i].seq) || i + 1
1840
- if (seq > (Number(rt.lastCompactionSeen) || 0)) { compacted = true; rt.lastCompactionSeen = seq }
1841
- }
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 }
1842
1939
  }
1843
1940
  } catch (e) {}
1844
1941
  // 旧实现硬截断到 1.5 → 任何超额水位都显示成 150%,掩盖真实占用(用户实测 761692/131072 被显示为 150%)。
1845
1942
  // 现在如实上报,仅做防溢出的 99 倍上限。
1846
- const ratio = win > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / win, 99) : 0
1847
- 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
1848
1949
  rt.waterLevelTokens = estTokens
1849
- this.state.waterLevelRatio = ratio
1950
+ this.state.waterLevelRatio = armRatio
1850
1951
  this.state.waterLevelTokens = estTokens
1851
- this.state.waterLevelWindow = win
1952
+ this.state.waterLevelWindow = effectiveWin
1852
1953
  this.state.waterLevelSource = winSource
1853
1954
  this.state.waterLevelMeter = meterLayer
1854
1955
  this.state.waterLevelModel = sessModel.model || winInfo.model || ''
1855
1956
  this.state.waterLevelAt = Date.now()
1856
- const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.75, 0.1)
1957
+ this.state.waterLevelMeasuredTokens = estTokens
1958
+ this.state.waterLevelReserve = reserve
1959
+ this.state.waterLevelHardTrigger = hard ? (compacted ? 'compaction' : 'overflow') : ''
1857
1960
  // 会话级记录:切会话时 /handoff-state?sessionId= 直接取这份,不再显示别的会话的数
1858
1961
  this.rememberWaterRecord(agent && agent.session && agent.session.id ? String(agent.session.id) : '', {
1859
- ratio, tokens: estTokens, window: win, source: winSource, meter: meterLayer,
1860
- model: this.state.waterLevelModel || '', threshold, at: Date.now(), live: true,
1962
+ ratio: armRatio, measuredRatio: ratio, tokens: estTokens, measuredTokens: estTokens, window: effectiveWin,
1963
+ windowRaw: win, reserve, source: winSource, meter: meterLayer, model: this.state.waterLevelModel || '',
1964
+ threshold, hardTrigger: this.state.waterLevelHardTrigger, at: Date.now(), live: true,
1861
1965
  })
1862
- const over = ratio >= threshold || (ratio >= 0.5 && compacted)
1966
+ const over = armRatio >= threshold
1863
1967
  if (!over) return
1864
- if (!rt.waterLevelAdvised || compacted) {
1968
+ if (!rt.waterLevelAdvised || compacted || overflowed) {
1865
1969
  rt.waterLevelAdvised = true
1866
- diag('water level advisory: ratio=' + ratio.toFixed(2) + ' tokens=' + estTokens + '/' + win + ' src=' + (winSource || '?') + ' meter=' + meterLayer + (compacted ? ' compaction-detected' : ''))
1970
+ diag('water level advisory: ratio=' + ratio.toFixed(2) + (hard ? '→' + armRatio.toFixed(2) + '(hard:' + this.state.waterLevelHardTrigger + ')' : '') +
1971
+ ' tokens=' + estTokens + '/' + effectiveWin + '(win=' + win + ' reserve=' + reserve + ')' +
1972
+ ' src=' + (winSource || '?') + ' meter=' + meterLayer + (compacted ? ' compaction-detected' : ''))
1867
1973
  }
1868
- if (this.config.waterLevelAutoHandoff !== false && (!rt.waterLevelAutoHandoffDone || compacted)) {
1974
+ if (this.config.waterLevelAutoHandoff !== false && (!rt.waterLevelAutoHandoffDone || compacted || overflowed)) {
1869
1975
  rt.waterLevelAutoHandoffDone = true
1870
1976
  const p = await this.resolvePaths(agent)
1871
1977
  // 骨架 v2:抽取性压缩——只从「已策展源」取材(沉淀子代理提炼过的今日日志尾部+反思摘要+项目笔记头部),去重限行,不做原始转储
@@ -1896,8 +2002,12 @@ class MemoryEngine {
1896
2002
  * 阈值 thresholdRatio=0.8 对「路由请求 token」;插件此前只在 turn-stopping 测量,一旦某一轮把水位从阈值下推到 0.8 以上,
1897
2003
  * 官方会在该轮的 pre-step 就压缩完,交接白板/账本根本来不及写(实测:20:11 轮末测得 768158/1000000=0.77 时,会话日志里
1898
2004
  * 已有 2 次 compaction/start+summary)。这里在 pre-step 也做同一测量,让交接与官方压缩站在同一条边界上。
1899
- * 节流:同一 agent 至少间隔 minGapMs(默认 5s),避免每个 step 都扫会话事件/读 tokenMeter。 */
1900
- checkWaterLevelAtStep(agent, minGapMs = 5000) {
2005
+ * 2026-09-10 修正:**取消 5 秒节流(默认 minGapMs 改为 0)**。旧默认 5000 使"两次 pre-step 间隔 <5 秒即整段跳过"
2006
+ * (不测量、不 arm),而官方压缩挂在**每个** pre-step 上、没有任何节流 —— 模型越快、单步越短,被跳过的概率越高,
2007
+ * 于是官方永远抢先压缩、接续从不触发(实测 DeepSeek-V41-Flash 下单步常低于 5 秒,用户亦观察到官方上下文条
2008
+ * 每次工具调用后就更新)。本函数开销很小(`tokenMeter.measure()` + 最近 64 条会话事件扫描),省这点开销不值当;
2009
+ * minGapMs 仅保留给测试注入。如需控频应改 `checkWaterLevel` 自身,而不是在这里整段跳过。 */
2010
+ checkWaterLevelAtStep(agent, minGapMs = 0) {
1901
2011
  try {
1902
2012
  if (this.config.handoffEnabled === false) return
1903
2013
  if (!agent || !this.hasReliableSessionIdentity(agent)) return
@@ -1989,7 +2099,12 @@ class MemoryEngine {
1989
2099
  try {
1990
2100
  const sc = this._sessionController || (() => { try { this._sessionController = this._ctxRef?.get?.('sessionController') || undefined } catch (e) {} return this._sessionController })()
1991
2101
  if (!sc || typeof sc.create !== 'function') throw new Error('sessionController service unavailable(宿主无法建会话;请重启 dsh web 或保持页面打开)')
1992
- const d = await this.buildContinueCarry()
2102
+ const oldSid = armed && armed.sessionId ? String(armed.sessionId) : ''
2103
+ // 刷新仪式(2026-09-10):先让旧会话把白板 PLAN 与账本刷到最新,再组装交接材料 ——
2104
+ // 与 client 路径同序(先仪式后材料)。失败/超时都 fail-soft,照常接续。
2105
+ let ritual = null
2106
+ try { ritual = await this.hostRefreshRitual(oldSid) } catch (eRf) { diag('host ritual error: ' + ((eRf && eRf.message) || eRf)) }
2107
+ const d = await this.buildContinueCarry(oldSid)
1993
2108
  if (!d || !d.ok) throw new Error((d && d.error) || 'no handoff material')
1994
2109
  const created = await sc.create({
1995
2110
  ...(d.workspaceId ? { workspaceId: d.workspaceId } : { cwd: d.ws }),
@@ -2002,14 +2117,34 @@ class MemoryEngine {
2002
2117
  }
2003
2118
  if (typeof sc.prompt === 'function') {
2004
2119
  const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(120000) : undefined
2005
- await sc.prompt({ sessionId: newId, content: [{ type: 'text', text: d.carryText }], mode: 'queue' }, signal)
2006
- }
2120
+ // DSH 0.1.5 起 SessionPromptRequest.requestId 为必填(客户端铸造的用户消息身份,落到 source.rpcId)。
2121
+ // 缺失时会在 createUserMessage 处抛普通 Error,被官方包装成误导性的 session/agent-busy "prompt rejected"
2122
+ // (实测:建出的新会话只有头部即停更,交接材料从未送达)。官方自己的客户端也做 requestId ?? randomUUID()。
2123
+ const reqId = (typeof randomUUID === 'function')
2124
+ ? randomUUID()
2125
+ : ('dam-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10))
2126
+ await sc.prompt({ sessionId: newId, requestId: reqId, content: [{ type: 'text', text: d.carryText }], mode: 'queue' }, signal)
2127
+ }
2128
+ // 权限继承(2026-09-10):官方 create 不收权限,新会话会落 settings 的 permission.defaultPreset,
2129
+ // 接续后用户每一步都要批准(静默运行被打断)。agent 在 prompt 之后才建,故在此按延迟重试取 agent.session 再套旧预设;
2130
+ // 旧会话优先按 id 从 agents 注册表取(_lastAgent 重启后可能为空),fail-soft 不影响接续结果。
2131
+ const perm = await this.inheritPermissionForContinue(oldSid, newId)
2132
+ const ritualNote = (ritual && ritual.ok) ? 'updated' : ((ritual && ritual.reason) || 'skipped')
2007
2133
  st.lastRunAt = Date.now()
2008
- st.lastOk = { sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', workspaceId: d.workspaceId || '' }
2009
- diag('auto-continue host-executed: new session ' + newId + ' model=' + (d.model || 'default'))
2010
- return { ok: true, sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '' }
2134
+ st.lastOk = { sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', workspaceId: d.workspaceId || '', permissionPreset: (perm && perm.ok && perm.preset) ? perm.preset : '', refreshRitual: ritualNote }
2135
+ 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)
2136
+ return { ok: true, sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', permissionPreset: (perm && perm.ok && perm.preset) ? perm.preset : '', refreshRitual: ritualNote }
2011
2137
  } catch (e) {
2012
- st.error = String(e && e.message ? e.message : e)
2138
+ // 官方把 prompt 失败统一包成 session/agent-busy + message "prompt rejected",真实原因只藏在 details/value 里。
2139
+ // 不透出就会得到一句无法排查的 "prompt rejected"(2026-09-10 实测踩坑)。
2140
+ let reason = ''
2141
+ try {
2142
+ const det = (e && (e.details || e.data)) || {}
2143
+ reason = String(det.reason || det.value || det.cause || '')
2144
+ if (!reason && e && e.cause) reason = String((e.cause && e.cause.message) || e.cause)
2145
+ } catch (_) {}
2146
+ const code = String((e && (e.code || e.name)) || '')
2147
+ st.error = String(e && e.message ? e.message : e) + (reason ? ' — ' + reason : '') + (code && code !== 'Error' ? ' [' + code + ']' : '')
2013
2148
  diag('hostAutoContinue error: ' + st.error)
2014
2149
  return { ok: false, error: st.error }
2015
2150
  } finally {
@@ -2081,6 +2216,83 @@ class MemoryEngine {
2081
2216
  } catch (e) { return '' }
2082
2217
  }
2083
2218
 
2219
+ /**
2220
+ * 最近活跃会话 id 的**磁盘回退**(2026-09-10)。
2221
+ * `_lastAgent` 是纯内存态,宿主重启后要等下一次 pre-step 才重建,而刷新仪式的
2222
+ * `handoff-state.refresh.sessionId` 正好依赖它 —— 重启后立刻点接续会拿到空串,
2223
+ * 客户端据此静默 `skipped`,旧会话收不到刷新指令(实机踩到:旧会话 16:20 后再无写入)。
2224
+ * 回退按会话日志 mtime 取最新,**只认 `session-` 前缀的普通会话目录**:裸 uuid 目录是子代理会话,
2225
+ * 把刷新仪式注进子代理等于白做(还会污染它的上下文),所以宁可返回 '' 让上层明确报"无可刷新目标"。
2226
+ * 5 秒缓存避免面板轮询反复扫盘。
2227
+ */
2228
+ recentSessionIdFallback() {
2229
+ try {
2230
+ const now = Date.now()
2231
+ if (this._sidFallbackAt && now - this._sidFallbackAt < 5000) return this._sidFallbackId || ''
2232
+ this._sidFallbackAt = now
2233
+ const root = path.join(dshHome(), 'sessions')
2234
+ let best = ''
2235
+ let bestMs = 0
2236
+ for (const wsDir of readdirSync(root, { withFileTypes: true })) {
2237
+ if (!wsDir.isDirectory()) continue
2238
+ const wsPath = path.join(root, wsDir.name)
2239
+ let entries = []
2240
+ try { entries = readdirSync(wsPath, { withFileTypes: true }) } catch (_) { continue }
2241
+ for (const ent of entries) {
2242
+ if (!ent.isDirectory()) continue
2243
+ if (!/^session-/.test(ent.name)) continue
2244
+ let ms = 0
2245
+ for (const f of ['session.v3.jsonl.zstd', 'session.jsonl.zstd']) {
2246
+ try { const st = statSync(path.join(wsPath, ent.name, f)); if (st.mtimeMs > ms) ms = st.mtimeMs } catch (_) {}
2247
+ }
2248
+ if (ms > bestMs) { bestMs = ms; best = ent.name }
2249
+ }
2250
+ }
2251
+ this._sidFallbackId = best
2252
+ if (this._sidFallbackId) diag('session id fallback(memory-empty) → ' + this._sidFallbackId)
2253
+ return this._sidFallbackId
2254
+ } catch (_) { return '' }
2255
+ }
2256
+
2257
+ /**
2258
+ * 权限预设继承(2026-09-10):官方 `session.create` 的入参只有
2259
+ * `{workspaceId, cwd, sessionId, agentPreset}`,**没有权限字段** —— 新会话一律走
2260
+ * `PermissionPresetService.pinInitialPermission()` 落 settings 里的 `permission.defaultPreset`。
2261
+ * 于是接续出来的新会话丢掉旧会话的完全权限,用户的静默运行每一步都要批准(实机踩到)。
2262
+ * 这里在读侧 `current(session)` / 写侧 `set(session, name)` 上补一步继承;两者都需要 **Session 对象**,
2263
+ * 官方 webhook 是经 `handle.agent.session` 拿到的,插件同样经 agent 取(拿不到就 fail-soft,不阻塞接续)。
2264
+ */
2265
+ async inheritPermissionPreset(oldAgent, newSid, opts = {}) {
2266
+ try {
2267
+ const ctxRef = this._ctxRef
2268
+ const pp = ctxRef && typeof ctxRef.get === 'function' ? ctxRef.get('permissionPresets') : null
2269
+ if (!pp || typeof pp.current !== 'function' || typeof pp.set !== 'function') return { ok: false, reason: 'no-permission-service' }
2270
+ const oldSession = oldAgent && oldAgent.session
2271
+ if (!oldSession) return { ok: false, reason: 'no-old-session' }
2272
+ let preset = ''
2273
+ try { preset = String(pp.current(oldSession) || '') } catch (eC) { return { ok: false, reason: 'current-failed:' + ((eC && eC.message) || eC) } }
2274
+ if (!preset) return { ok: false, reason: 'empty-preset' }
2275
+ if (preset === 'custom') return { ok: false, reason: 'custom-preset-not-switchable' }
2276
+ const attempts = Array.isArray(opts.attempts) && opts.attempts.length ? opts.attempts : [0, 700, 1600]
2277
+ for (const delay of attempts) {
2278
+ if (delay > 0) await new Promise((r) => setTimeout(r, delay))
2279
+ let ag = null
2280
+ try {
2281
+ const reg = typeof ctxRef.get === 'function' ? ctxRef.get('agents') : null
2282
+ ag = (reg && typeof reg.get === 'function') ? reg.get(newSid) : null
2283
+ } catch (_) {}
2284
+ if (!ag || !ag.session) continue
2285
+ try {
2286
+ pp.set(ag.session, preset)
2287
+ diag('auto-continue permission inherited: ' + preset + ' → ' + newSid)
2288
+ return { ok: true, preset }
2289
+ } catch (eS) { diag('auto-continue permission set failed: ' + ((eS && eS.message) || eS)) }
2290
+ }
2291
+ diag('auto-continue permission inherit skipped: no live agent for ' + newSid)
2292
+ return { ok: false, reason: 'no-agent-for-new-session' }
2293
+ } catch (e) { return { ok: false, reason: String((e && e.message) || e) } }
2294
+ }
2295
+
2084
2296
  /** 修A(2026-09-08):解析会话所属 workspaceId。官方 api-session-controller create() 只在传 workspaceId 时
2085
2297
  * 调 workspace.attachSession()(index.js:566-588),只传 cwd 仅设工作目录 → 新会话永远落「未分组工作区」。
2086
2298
  * 归属判定走 workspaceRegistry(与官方 forkWorkspace index.js:874 同款),服务缺失/无归属返回 ''(client 回退 cwd)。 */
@@ -2107,20 +2319,108 @@ class MemoryEngine {
2107
2319
  ].join(String.fromCharCode(10))
2108
2320
  }
2109
2321
 
2322
+ /** 按会话 id 取活动 agent(官方 agents 注册表)。取不到返回 null —— 权限/仪式都靠它拿 Session 对象。 */
2323
+ agentForSessionId(sid) {
2324
+ const id = String(sid || '')
2325
+ if (!id) return null
2326
+ try {
2327
+ const reg = (this._ctxRef && typeof this._ctxRef.get === 'function') ? this._ctxRef.get('agents') : null
2328
+ const ag = (reg && typeof reg.get === 'function') ? reg.get(id) : null
2329
+ return (ag && ag.session) ? ag : null
2330
+ } catch (e) { return null }
2331
+ }
2332
+
2333
+ /** 接续材料指纹(PLAN mtime + 最新账本名):刷新仪式靠它判断旧会话有没有真的产出新白板/账本。
2334
+ * 只 stat 一个文件 + 一次 readdir,比 handoffPanelData 便宜得多(仪式要按秒轮询)。 */
2335
+ async handoffMaterialStamp() {
2336
+ try {
2337
+ const dir = this.state.handoffDir || (await this.resolvePaths(undefined)).handoffDir
2338
+ const planPath = this.state.planPath || path.join(dir, 'PLAN.md')
2339
+ const planMt = await stat(planPath).then((s) => Number(s.mtimeMs)).catch(() => 0)
2340
+ const names = await readdir(dir).catch(() => [])
2341
+ const led = names.filter((n) => /^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n)).sort()
2342
+ return String(planMt) + '|' + (led[led.length - 1] || '')
2343
+ } catch (e) { return '' }
2344
+ }
2345
+
2346
+ /** 宿主侧刷新仪式(2026-09-10):原先只有 client 路径做刷新,宿主兜底接续(浏览器关着/后台节流)
2347
+ * 完全跳过 —— 旧会话收不到指令,新会话拿到的白板与账本就停在旧版。这里补齐同款顺序:
2348
+ * **先让旧会话刷白板+写账本,再组装材料**。
2349
+ * 安全约束:只在 `armed.sessionId` 明确时注入(绝不猜会话);同一旧会话 10 分钟内只注入一次
2350
+ * (接续失败重试时不刷屏);全程 fail-soft —— 仪式失败照常接续,只记 diag。 */
2351
+ async hostRefreshRitual(oldSid) {
2352
+ const sid = String(oldSid || '')
2353
+ if (!sid) return { ok: false, reason: 'no-old-session' }
2354
+ if (this.config.autoContinueRefreshRitual === false) return { ok: false, reason: 'disabled' }
2355
+ const st = this._autoContState || (this._autoContState = {})
2356
+ const now = Date.now()
2357
+ if (st.ritualForSid === sid && st.ritualAt && now - st.ritualAt < 10 * 60 * 1000) return { ok: false, reason: 'already-sent' }
2358
+ const sc = this._sessionController || (() => { try { this._sessionController = this._ctxRef?.get?.('sessionController') || undefined } catch (e) {} return this._sessionController })()
2359
+ if (!sc || typeof sc.prompt !== 'function') return { ok: false, reason: 'no-session-controller' }
2360
+ st.ritualForSid = sid
2361
+ st.ritualAt = now
2362
+ try {
2363
+ const before = await this.handoffMaterialStamp()
2364
+ const reqId = (typeof randomUUID === 'function')
2365
+ ? randomUUID()
2366
+ : ('dam-r-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10))
2367
+ const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(60000) : undefined
2368
+ diag('host refresh ritual → old session ' + sid)
2369
+ await sc.prompt({ sessionId: sid, requestId: reqId, content: [{ type: 'text', text: this.refreshRitualPrompt() }], mode: 'queue' }, signal)
2370
+ const sec = Number(this.config.autoContinueRefreshTimeoutSeconds)
2371
+ const timeoutMs = (sec >= 15 && sec <= 600 ? sec : 90) * 1000
2372
+ const t0 = Date.now()
2373
+ const pollMs = Number(this._ritualPollMs) > 0 ? Number(this._ritualPollMs) : 2500
2374
+ while (Date.now() - t0 < timeoutMs) {
2375
+ await new Promise((r) => setTimeout(r, pollMs))
2376
+ const cur = await this.handoffMaterialStamp()
2377
+ if (cur && cur !== before) { diag('host refresh ritual: material updated after ' + Math.round((Date.now() - t0) / 1000) + 's'); return { ok: true, waited: 'updated' } }
2378
+ }
2379
+ diag('host refresh ritual: timeout, continuing with current material')
2380
+ return { ok: false, reason: 'timeout' }
2381
+ } catch (e) {
2382
+ diag('host refresh ritual failed: ' + ((e && e.message) || e))
2383
+ return { ok: false, reason: String((e && e.message) || e) }
2384
+ }
2385
+ }
2386
+
2387
+ /** 给 client 路径用的权限继承入口(2026-09-10):client 自己调官方 session.create,
2388
+ * 宿主侧只负责把旧会话的权限预设套到新会话上。旧 agent 优先按 fromSessionId 从注册表取
2389
+ * (_lastAgent 重启后可能为空),取不到再退回内存态。 */
2390
+ async inheritPermissionForContinue(fromSessionId, toSessionId, opts = {}) {
2391
+ const from = String(fromSessionId || '')
2392
+ const to = String(toSessionId || '')
2393
+ if (!to) return { ok: false, reason: 'no-new-session' }
2394
+ const oldAgent = this.agentForSessionId(from)
2395
+ || ((this._lastAgent && this._lastAgent.session && String(this._lastAgent.session.id) === from) ? this._lastAgent : null)
2396
+ return this.inheritPermissionPreset(oldAgent, to, opts)
2397
+ }
2398
+
2110
2399
  /** M-CM6-B v2·旧会话上下文包(2026-09-08):定位当前(旧)会话持久化文件(~/.dsh/sessions/<ws>/<sid>/session.jsonl[.zstd]),
2111
2400
  * zstd 解压抽取 header(cwd/agentPreset)、最新 request/header 的 data.header.config(provider/model/reasoningEffort)、
2112
2401
  * user+assistant+工具 消息全量转写;转写落盘 handoff/prev-session-*.md → 新会话 AI 用 read 工具即可随时读取旧会话全部内容。
2113
2402
  * 返回 {sessionId, transcriptPath, provider, model, reasoningEffort, agentPreset, cwd, tailText, msgCount} 或部分为空(fail-soft)。 */
2114
- async buildPrevSessionPack() {
2403
+ async buildPrevSessionPack(preferSid) {
2115
2404
  try {
2405
+ // 2026-09-10 实机取证:此前只认 this._lastAgent(最近活跃的 agent),而自动接续的旧会话是
2406
+ // armed.sessionId —— 二者可以不是同一个会话(14:54:30 实测:armed=de10b34f,却把
2407
+ // 1f621132 的转写与 provider/model/effort 当成接续材料,新会话还会被 selectModel 套错模型)。
2408
+ // 现在改为:显式传入的旧会话优先;只有它取不到持久化文件时才退回 _lastAgent。
2116
2409
  const agent = this._lastAgent
2117
- const sid = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
2118
- if (!sid) return null
2410
+ const lastSid = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
2411
+ const want = String(preferSid || '')
2412
+ const cands = []
2413
+ if (want) cands.push(want)
2414
+ if (lastSid && lastSid !== want) cands.push(lastSid)
2415
+ if (!cands.length) return null
2119
2416
  const sessionsDir = path.join(dshHome(), 'sessions')
2417
+ const wsDirs = await readdir(sessionsDir).catch(() => [])
2418
+ let sid = cands[0]
2120
2419
  let file = null
2121
- for (const w of await readdir(sessionsDir).catch(() => [])) {
2122
- for (const cand of [path.join(sessionsDir, w, sid, 'session.jsonl.zstd'), path.join(sessionsDir, w, sid, 'session.jsonl')]) {
2123
- try { const st = await stat(cand); if (st.isFile()) { file = cand; break } } catch (e) {}
2420
+ for (const cand of cands) {
2421
+ for (const w of wsDirs) {
2422
+ const resolved = await this.resolveSessionFile(path.join(sessionsDir, w, cand))
2423
+ if (resolved) { file = resolved; sid = cand; break }
2124
2424
  }
2125
2425
  if (file) break
2126
2426
  }
@@ -2175,7 +2475,7 @@ class MemoryEngine {
2175
2475
  * 取当前工作区白板+最新账本;工作区未绑定/材料为空时降级用全局最近账本(findLatestGlobalHandoff)。
2176
2476
  * v2:附带旧会话上下文包(转写文件路径+主动读取指令)与新会话状态沿用参数(cwd/provider/model)。
2177
2477
  * 返回 {ok, carryText, planPath, ws, provider, model, prevSessionId, transcriptPath};无任何材料时 ok:false。 */
2178
- async buildContinueCarry() {
2478
+ async buildContinueCarry(preferSid) {
2179
2479
  try {
2180
2480
  if (this.config.handoffEnabled === false) return { ok: false, error: 'handoff disabled' }
2181
2481
  const p = await this.resolvePaths(undefined)
@@ -2187,7 +2487,7 @@ class MemoryEngine {
2187
2487
  const gl = await this.findLatestGlobalHandoff()
2188
2488
  if (gl) { ledger = await this.readTextSafe(gl); ledgerFrom = 'global' }
2189
2489
  }
2190
- const pack = await this.buildPrevSessionPack()
2490
+ const pack = await this.buildPrevSessionPack(preferSid)
2191
2491
  if (!plan && !ledger && !pack) return { ok: false, error: 'no handoff material (PLAN/账本/旧会话均为空)' }
2192
2492
  // 材料时间戳与新鲜度(分层压缩:过期提示)
2193
2493
  const fmtMt = (ms) => { try { return ms ? new Date(Number(ms)).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '(未知)' } catch (e) { return '(未知)' } }
@@ -2351,7 +2651,8 @@ class MemoryEngine {
2351
2651
  }
2352
2652
  }
2353
2653
  const planMt = await stat(planPath).then((s) => Number(s.mtimeMs)).catch(() => 0)
2354
- const sid = this.currentSessionId()
2654
+ // 刷新仪式的会话 id:内存态为空时走磁盘回退(宿主重启后 _lastAgent 未重建的窗口期)
2655
+ const sid = this.currentSessionId() || this.recentSessionIdFallback()
2355
2656
  return {
2356
2657
  enabled: true, planPath,
2357
2658
  plan: await this.readTextSafe(planPath),
@@ -2745,249 +3046,292 @@ class MemoryEngine {
2745
3046
  } catch (_) { return false }
2746
3047
  }
2747
3048
 
2748
- // ---------- 每日写入预算 + 超限自动压缩(硬约束,但不拒绝新内容) ----------
3049
+ // ---------- 记忆容量上限 + 超限自动整理(容量口径,单位=字符) ----------
2749
3050
  /**
2750
- * 用户级 ≤4000 字/天、项目级 ≤3000 字/天(所有会话共享一天预算,跨天重置)。
2751
- * 超限时不直接拒绝:先自动压缩"今天之前"的旧内容腾出空间(ensureBudget),压缩不可用时才拒绝。
3051
+ * 2026-09-10 重做(方案 0+1+2+3):
3052
+ * - **口径改为「文件容量」**:只要写入后总字符数 ≤ 上限就放行。旧口径按"当日写入量"计,
3053
+ * 而压缩对象只有"今天之前"的记录 → 当天写得多就必然堵死(即便文件很小),且跨天才能自解。
3054
+ * - **单位统一为字符**(`String.length`):旧实现记账用字符、压缩配额用字节(`rec.byteEnd - rec.markerByteStart`),
3055
+ * 同一个 3000 在中文内容下差 ~1.44 倍;legacy 文本路径用字符、anchor 路径用字节,还会随 anchor 开关跳变。
3056
+ * - **超限先整理**:优先"AI 折叠成要点",失败退回"整条归档";整理后仍超才拒绝(正常情况不会发生)。
2752
3057
  */
2753
- accountWrite(agent, layer, text) {
2754
- if (!this._budgets) this._budgets = new Map()
2755
- const today = this.memToday()
2756
- let b = this._budgets.get('today')
2757
- if (!b || b.date !== today) { b = { date: today, user: 0, note: 0 }; this._budgets.set('today', b) }
2758
- const limit = layer === 'user' ? 4000 : 3000
2759
- const next = b[layer] + String(text || '').length
2760
- if (next > limit) return { ok: false, used: b[layer], limit, remaining: Math.max(0, limit - b[layer]) }
2761
- b[layer] = next
2762
- return { ok: true, used: next, limit, remaining: limit - next }
3058
+ capacityLimit(layer) {
3059
+ const raw = Number(this.config[layer === 'user' ? 'userCapacityChars' : 'noteCapacityChars'])
3060
+ if (Number.isFinite(raw) && raw >= 500) return Math.floor(raw)
3061
+ return layer === 'user' ? DEFAULT_USER_CAPACITY_CHARS : DEFAULT_NOTE_CAPACITY_CHARS
3062
+ }
3063
+
3064
+ /** 追加写入时的固定开销(换行 + `## YYYY-MM-DD` 标题行)。容量检查必须计入,否则低估约 15 字符。 */
3065
+ appendOverheadChars() {
3066
+ try { return ('\n## ' + this.memToday() + '\n').length } catch (_) { return 16 }
2763
3067
  }
2764
3068
 
2765
- /** 预算保障:超限时自动压缩旧内容腾位;压缩成功返回 compacted:true,否则 ok:false。 */
3069
+ /** 容量检查:当前字符数 + 待写入字符数(含标题行与分隔换行) ≤ 上限则放行。 */
3070
+ capacityCheck(layer, curChars, text) {
3071
+ const limit = this.capacityLimit(layer)
3072
+ const add = String(text == null ? '' : String(text)).length + this.appendOverheadChars()
3073
+ const need = curChars + add
3074
+ return { ok: need <= limit, limit, used: curChars, need, add }
3075
+ }
3076
+
3077
+ /** 读某层当前字符数(优先内存态 `state.userText/notesText`,回退读盘)。 */
3078
+ async layerCharCount(layer, p) {
3079
+ const cached = layer === 'user' ? this.state.userText : this.state.notesText
3080
+ if (typeof cached === 'string' && cached.length) return cached.length
3081
+ try { return String((await this.readTextSafe(layer === 'user' ? p.userFile : p.notesPath)) || '').length } catch (_) { return 0 }
3082
+ }
3083
+
3084
+ /**
3085
+ * 容量保障:超限时自动整理腾位,并在**整理后复查实际文件大小**;仍超则按新的实际值再整理(最多 3 轮)。
3086
+ *
3087
+ * 2026-09-10 第二轮修正:旧实现只压一轮就复查,而单轮缺口是按"容纳本次写入"估算的 ——
3088
+ * 折叠产物、`## 日期` 标题行开销等会让实际结果偏小,于是出现"压缩确实成功(折叠块已写入)、
3089
+ * 复查却不过、工具还把成功标识 `folded` 当失败原因报出来"的怪象(实机踩到)。
3090
+ * 现在每轮都用**重新测量的实际文件大小**算缺口,复查不过就再来一轮;失败时返回**真实**原因。
3091
+ */
2766
3092
  async ensureBudget(agent, layer, text) {
2767
- const acct = this.accountWrite(agent, layer, text)
3093
+ const p = await this.resolvePaths(agent)
3094
+ let acct = this.capacityCheck(layer, await this.layerCharCount(layer, p), text)
2768
3095
  if (acct.ok) return { ok: true, acct }
2769
- const compacted = await this.compactLayer(agent, layer)
2770
- if (compacted) {
2771
- const acct2 = this.accountWrite(agent, layer, text)
2772
- if (acct2.ok) return { ok: true, acct: acct2, compacted: true }
3096
+ let last = null
3097
+ let compactedOnce = false
3098
+ for (let round = 0; round < 3; round++) {
3099
+ const cur = await this.layerCharCount(layer, p)
3100
+ const need = cur + String(text == null ? '' : String(text)).length + this.appendOverheadChars()
3101
+ last = await this.compactLayer(agent, layer, { needChars: need, round })
3102
+ if (!last || !last.ok) break
3103
+ compactedOnce = true
3104
+ acct = this.capacityCheck(layer, await this.layerCharCount(layer, p), text)
3105
+ if (acct.ok) return { ok: true, acct, compacted: true }
3106
+ }
3107
+ const reason = (!last || !last.ok)
3108
+ ? ((last && last.reason) || 'compact-failed')
3109
+ : (compactedOnce ? 'still-over-capacity' : 'compact-made-no-progress')
3110
+ return { ok: false, acct, reason, retryAfterMs: (last && last.retryAfterMs) || 0 }
3111
+ }
3112
+
3113
+ /**
3114
+ * 整理失败时的**准确**报错。旧实现把三种完全不同的原因压成一句
3115
+ * "(刚压缩过或 AI 不可用)" —— 实测会把使用者(和模型)直接带偏,故拆开:
3116
+ * throttled(节流中,给秒数) / no-removable(无可回收) / dirty-file(锚点文件脏,fail-closed) / 其它。
3117
+ */
3118
+ budgetRefusalTextPre(res, layer) {
3119
+ const name = layer === 'user' ? '用户级记忆' : '项目笔记'
3120
+ const limit = (res && res.acct && res.acct.limit) || this.capacityLimit(layer)
3121
+ const used = (res && res.acct && res.acct.used) || 0
3122
+ const why = String((res && res.reason) || 'compact-failed')
3123
+ const head = name + '已达容量上限(' + used + '/' + limit + ' 字符),自动整理未能腾出空间'
3124
+ if (why === 'throttled') {
3125
+ const sec = Math.max(1, Math.ceil((Number(res.retryAfterMs) || 0) / 1000))
3126
+ return head + ':距上次整理不足 10 分钟(节流中),约 ' + sec + ' 秒后可重试。'
3127
+ }
3128
+ if (why === 'no-removable') {
3129
+ return head + ':可回收内容为空(最新 1 条记录为硬底线,永不整条移除)。'
3130
+ + '可调大 ' + (layer === 'user' ? 'userCapacityChars' : 'noteCapacityChars') + ',或用 action=replace 整体重写。'
3131
+ }
3132
+ if (why === 'dirty-file') {
3133
+ return head + ':锚点文件校验未通过(脏文件,fail-closed 不做字符切片)。'
3134
+ + '请检查是否被手工编辑破坏了 <!-- memory:mem_xxx --> 标记。'
2773
3135
  }
2774
- return { ok: false, acct }
3136
+ if (why === 'still-over-capacity') {
3137
+ return head + ':已自动整理,但整理后仍放不下本次内容(单次写入过大)。'
3138
+ + '请把内容拆小分批写,或调大 ' + (layer === 'user' ? 'userCapacityChars' : 'noteCapacityChars') + '。'
3139
+ }
3140
+ if (why === 'compact-made-no-progress') {
3141
+ return head + ':整理了但没有腾出空间。可调大 ' + (layer === 'user' ? 'userCapacityChars' : 'noteCapacityChars') + ',或用 action=replace 整体重写。'
3142
+ }
3143
+ return head + ':折叠与归档均未成功(' + why + ')。原内容未改动,可稍后重试。'
2775
3144
  }
2776
3145
 
2777
- /** 压缩层内"今天之前"的旧段落:AI 提炼要点替换,不可用时归档最早段落;10 分钟节流。 */
2778
- async compactLayer(agent, layer) {
3146
+ /**
3147
+ * 层内整理:超出容量时腾出空间。**先 AI 折叠成要点,失败退回整条归档**——两者都把原文写进归档保底。
3148
+ *
3149
+ * 2026-09-10 重做(方案 0+1+2+3)要点:
3150
+ * - **单位统一为字符**:旧实现记账按字符、压缩配额按字节(`rec.byteEnd - rec.markerByteStart`),
3151
+ * 同一个 3000 在中文内容下差 ~1.44 倍;legacy 文本路径用字符、anchor 路径用字节,还会随 anchor 开关跳变。
3152
+ * - **保护窗口 + 硬底线**:最近写入的 `COMPACT_PROTECT_RECENT_CHARS` 字符不参与回收;硬底线=至少保留最新 1 条
3153
+ * (若保护窗口自身就超容量,允许对其折叠成要点,但不整条删除),否则会在"最近内容本身就很大"时重新死锁。
3154
+ * - **节流按层独立**:`_lastCompactAt` 旧实现是单个变量,压过用户级会连带节流项目级。
3155
+ * - **返回结构化结果**:让上层能给出准确报错(reason/retryAfterMs),不再把"节流中/无可压缩/AI 失败"混成一句。
3156
+ * @returns {Promise<{ok:boolean, reason?:string, retryAfterMs?:number}>}
3157
+ */
3158
+ async compactLayer(agent, layer, opts = {}) {
2779
3159
  const now = Date.now()
2780
- if (this._lastCompactAt && now - this._lastCompactAt < 10 * 60 * 1000) return false
3160
+ const state = (this._lastCompactAt && typeof this._lastCompactAt === 'object') ? this._lastCompactAt : (this._lastCompactAt = {})
3161
+ const last = Number(state[layer]) || 0
3162
+ // 节流只限制"昂贵的 AI 折叠",**不阻止"便宜的整条归档"** —— 否则短时间内反复超容量时
3163
+ // 仍会拒绝写入,违背"新记忆永不堵在外面"。归档只是一次文件追加 + 一次原子替换,代价可忽略。
3164
+ const allowFold = !(last && now - last < COMPACT_THROTTLE_MS)
2781
3165
  const p = await this.resolvePaths(agent)
2782
- // F1:anchor 开启 → 记录级压缩(整条保留/移除,经 store.replace;永不字符切片)。
2783
- // 目标文件无 sidecar('not-anchored')才回退旧文本路径。
3166
+ const limit = this.capacityLimit(layer)
3167
+ const curChars = await this.layerCharCount(layer, p)
3168
+ // 直接算**缺口**:要腾出多少字符才够本次写入放下。
3169
+ // 旧写法算的是"保留量目标 = limit - pending",方向反了 —— 当全部记录本来就小于该目标时会被判成
3170
+ // "无可回收",而文件其实只差几十字符就超限(2026-09-10 实机踩到,报错文案即"可回收内容为空")。
3171
+ const needChars = Number(opts.needChars || 0) || (curChars + 1)
3172
+ const deficit = Math.max(1, needChars - limit)
3173
+ const keepBudget = Math.max(COMPACT_PROTECT_RECENT_CHARS, limit - deficit - 1) // legacy 文本路径仍按"保留量"口径
3174
+
2784
3175
  const store0 = this.docStore
2785
3176
  if (store0) {
2786
3177
  try {
2787
- const r = await this.compactAnchoredLayer(store0, layer === 'user' ? p.userFile : p.notesPath, layer, this.memToday(), p)
2788
- if (process.env.DSH_F1_DEBUG) console.error('[f1-diag] compactAnchoredLayer -> ' + (layer === 'user' ? p.userFile : p.notesPath) + ' => ' + r)
2789
- if (r !== 'not-anchored') return r === true
3178
+ const r = await this.compactAnchoredLayer(store0, layer === 'user' ? p.userFile : p.notesPath, layer, this.memToday(), p, deficit, agent, allowFold)
3179
+ if (r !== 'not-anchored') return r || { ok: false, reason: 'no-removable' }
2790
3180
  } catch (e) {
2791
3181
  console.error('[compacted-anchored] failed: ' + (e && e.message ? e.message : e))
2792
- return false
3182
+ return { ok: false, reason: 'error:' + String((e && e.message) || e).slice(0, 80) }
2793
3183
  }
2794
3184
  }
2795
- const cur = (layer === 'user' ? this.state.userText : this.state.notesText) || ''
2796
- if (!cur.trim()) return true
2797
- const limit = layer === 'user' ? 4000 : 3000
2798
- const today = this.memToday()
2799
- // 按 "## 标题" 切段,今天段落保留,之前段落为压缩对象;文件头非 ## 内容归入头部段,不丢失
3185
+ return await this.compactLegacyLayer(agent, layer, p, keepBudget, allowFold)
3186
+ }
3187
+
3188
+ /**
3189
+ * 无 anchor(sidecar 未启用)时的文本段整理。原逻辑内联在 compactLayer 中,2026-09-10 抽出。
3190
+ * 同样取消"今日段无条件保留",改为**从尾往回**按 keepBudget 保留(含今日),最新一段是硬底线。
3191
+ */
3192
+ async compactLegacyLayer(agent, layer, p, keepBudget, allowFold) {
3193
+ const cur = String((layer === 'user' ? this.state.userText : this.state.notesText) || '')
3194
+ if (!cur.trim()) return { ok: true, reason: 'empty' }
2800
3195
  const segs = []
2801
- let curSeg = { title: '(文件头)', body: [], isToday: false }
3196
+ let curSeg = { title: '(文件头)', body: [] }
2802
3197
  for (const ln of cur.split('\n')) {
2803
3198
  const m = ln.match(/^##\s+(.+)$/)
2804
- if (m) {
2805
- if (curSeg) segs.push(curSeg)
2806
- const title = m[1].trim()
2807
- curSeg = { title, body: [], isToday: /^\d{4}-\d{2}-\d{2}/.test(title) && title.slice(0, 10) === today }
2808
- } else if (curSeg) {
2809
- curSeg.body.push(ln)
2810
- }
2811
- }
2812
- if (curSeg) segs.push(curSeg)
2813
- const oldSegs = segs.filter((s) => !s.isToday)
2814
- const todaySegs = segs.filter((s) => s.isToday)
3199
+ if (m) { segs.push(curSeg); curSeg = { title: m[1].trim(), body: [] } } else { curSeg.body.push(ln) }
3200
+ }
3201
+ segs.push(curSeg)
3202
+ const spanOf = (s) => ('## ' + s.title + '\n' + s.body.join('\n')).length
3203
+ const keep = new Array(segs.length).fill(false)
3204
+ let kept = 0
3205
+ for (let i = segs.length - 1; i >= 0; i--) {
3206
+ const span = spanOf(segs[i])
3207
+ if (i === segs.length - 1) { keep[i] = true; kept += span; continue }
3208
+ if (kept + span <= keepBudget) { keep[i] = true; kept += span; continue }
3209
+ break
3210
+ }
3211
+ const oldSegs = segs.filter((_, i) => !keep[i])
3212
+ const keptText = segs.filter((_, i) => keep[i]).map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
3213
+ if (!oldSegs.length) return { ok: false, reason: 'no-removable' }
3214
+
2815
3215
  const oldText = oldSegs.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
2816
- const todayText = todaySegs.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
2817
- const target = Math.max(limit, 200) // 压缩后旧部分目标:不超过层上限
2818
- if (oldText.length <= target) return true // 旧内容本来就不大,无需压缩
2819
- let newOld = ''
2820
- if (this._subagents && agent) {
2821
- try {
2822
- const layerName = layer === 'user' ? '用户级记忆' : '项目笔记'
2823
- const prompt = [
2824
- '你是记忆整理员。下面的' + layerName + '文件里"今天之前"的旧内容需要压缩,因为今天要写入的新内容已超当日预算。',
2825
- '规则:',
2826
- '- 保留所有仍有长期价值的硬信息:强制规则、偏好、关键决策、约定、账号/路径等',
2827
- '- 合并重复、删除已失效/过期条目、把长句压到 20 字内',
2828
- '- 输出 markdown,可用"## 主题"分组,输出将直接替换旧内容',
2829
- '- 只输出压缩结果正文,不要任何解释',
2830
- '',
2831
- '待压缩旧内容:',
2832
- truncateTail(oldText, 6000),
2833
- ].join('\n')
2834
- const out = await this.runSubagent(prompt, 'auto-memory-compact', agent)
2835
- if (out && !out.includes('(无)')) newOld = out.trim()
2836
- } catch (e) {
2837
- console.error('[dsh-auto-memory] compactLayer AI failed', e && e.message ? e.message : e)
2838
- }
2839
- }
2840
- if (!newOld) {
2841
- // AI 不可用降级:把最早段落移入归档文件,直到旧部分 ≤ target(保底不丢)
3216
+ let folded = allowFold ? await this.foldTextToSummaryPre(oldText, layer, agent) : ''
3217
+ if (!folded) {
3218
+ // AI 不可用降级:把最早段落整段移入归档,直到剩余 ≤ keepBudget(保底不丢)
2842
3219
  const archiveFile = layer === 'user'
2843
3220
  ? path.join(dshHome(), 'memory', 'archived-user.md')
2844
3221
  : path.join(p.projectDir, 'archive', 'notes-archived.md')
2845
3222
  await mkdir(path.dirname(archiveFile), { recursive: true })
2846
- let segs2 = oldSegs.slice()
2847
- let remain = oldText
2848
- while (remain.length > target && segs2.length) {
2849
- const seg = segs2.shift()
3223
+ const rest = oldSegs.slice()
3224
+ let remainText = rest.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
3225
+ while (remainText.length > keepBudget && rest.length > 1) {
3226
+ const seg = rest.shift()
2850
3227
  await this.appendText(archiveFile, '\n## ' + seg.title + '\n' + seg.body.join('\n'))
2851
- remain = segs2.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
3228
+ remainText = rest.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n').trim()
2852
3229
  }
2853
- newOld = remain.slice(0, target)
3230
+ folded = remainText.slice(0, keepBudget)
3231
+ } else {
3232
+ // 折叠成功:原文同样归档(整段),主文件只留要点
3233
+ const archiveFile = layer === 'user'
3234
+ ? path.join(dshHome(), 'memory', 'archived-user.md')
3235
+ : path.join(p.projectDir, 'archive', 'notes-archived.md')
3236
+ await mkdir(path.dirname(archiveFile), { recursive: true })
3237
+ await this.appendText(archiveFile, '\n' + oldSegs.map((s) => '## ' + s.title + '\n' + s.body.join('\n')).join('\n'))
2854
3238
  }
2855
- newOld = newOld.slice(0, target)
2856
- const body = [newOld, todayText].filter(Boolean).join('\n\n')
3239
+ const body = [folded, keptText].filter(Boolean).join('\n\n')
2857
3240
  await this.writeFull(layer === 'user' ? p.userFile : p.notesPath, body)
2858
3241
  if (layer === 'user') this.state.userText = body
2859
3242
  else this.state.notesText = body
2860
- // 压缩腾出的空间让当日额度重新计(今天段落仍在文件里,不丢)
2861
- if (this._budgets) {
2862
- const b = this._budgets.get('today')
2863
- if (b && b.date === this.memToday()) b[layer] = 0
2864
- }
2865
- this._lastCompactAt = now
3243
+ this._lastCompactAt[layer] = Date.now()
2866
3244
  this.state.loadedAt = Date.now()
2867
- console.log('[dsh-auto-memory] compacted ' + layer + ' memory: ' + cur.length + ' -> ' + body.length + ' chars')
2868
- return true
3245
+ console.log('[compacted] ' + layer + ': ' + cur.length + ' -> ' + body.length + ' chars (folded=' + (folded ? 'yes' : 'archive-only') + ')')
3246
+ return { ok: true, reason: folded ? 'folded' : 'archived' }
2869
3247
  }
2870
3248
 
2871
3249
  /**
2872
- * F1(M6-CONTRACT 外补丁,2026-08-23):anchor 开启时的记录级压缩。
2873
- * 按 sidecar 记录序整条保留/移除——今天(标题日期=插件今天)记录无条件保留,其余按字节配额
2874
- * 从最新往回保留(layer 上限 user 4000/note 3000);被移除记录整条原文写入归档文件
2875
- * (writeFullRaw,不经 anchor 事务,归档非检索语料)。重组经 store.replace 原子执行:
2876
- * 候选预检(clean)→被移除 id 自动删除→保留 id/版本不变。永不字符切片。
2877
- * @returns {Promise<boolean|'not-anchored'>} true=已腾位 false=无法腾位 'not-anchored'=无 sidecar 走旧路径
3250
+ * 把一段旧内容交给子代理折叠成要点(≤ COMPACT_FOLD_MAX_CHARS)。失败/无子代理返回 ''。
3251
+ * 折叠失败**不是错误**——调用方会退回整条归档,所以这里 fail-soft。
2878
3252
  */
2879
- async compactAnchoredLayer(store, filePath, layer, today, paths) {
2880
- const cur = await this.readTextSafe(filePath)
2881
- if (!cur || !cur.trim()) return false
2882
- let parsed
2883
- try { parsed = parseAnchors(Buffer.from(cur, 'utf8')) } catch (_) { return 'not-anchored' }
2884
- if (!parsed || parsed.status !== 'clean') return false // 脏文件 fail closed:不压缩、不切片
2885
- const anchored = parsed.records.filter((r) => r.kind === 'anchored')
2886
- if (!anchored.length) return 'not-anchored'
2887
- const limit = layer === 'user' ? 4000 : 3000
2888
- const isTodayHeading = (heading) => /^\d{4}-\d{2}-\d{2}/.test(String(heading || '')) && String(heading).slice(0, 10) === today
2889
- const buf = Buffer.from(cur, 'utf8')
2890
- // 配额从尾回溯(F1):新的留;今天记录无条件保留
2891
- const keep = new Array(anchored.length).fill(false)
2892
- let keptBytes = 0
2893
- for (let i = anchored.length - 1; i >= 0; i--) {
2894
- const rec = anchored[i]
2895
- const spanBytes = rec.byteEnd - rec.markerByteStart
2896
- if (isTodayHeading(rec.heading) || keptBytes + spanBytes <= limit) { keep[i] = true; keptBytes += spanBytes }
2897
- }
2898
- const removedIdx = []
2899
- anchored.forEach((rec, i) => { if (!keep[i]) removedIdx.push(i) })
2900
- if (!removedIdx.length) return false // 无可移除 → 无法腾位(预算照常拒绝)
2901
- // 有序块重组:legacy 块与 preamble/tail 原样保留,gap 字节逐段拷贝,仅丢弃被移除记录的 [marker..contentEnd]
2902
- const blocks = []
2903
- for (const r of parsed.records) {
2904
- if (r.kind === 'anchored') blocks.push({ start: r.markerByteStart, end: r.byteEnd, keep: keep[anchored.indexOf(r)] })
2905
- else blocks.push({ start: r.byteStart, end: r.byteEnd, keep: true })
2906
- }
2907
- blocks.sort((a, b) => a.start - b.start)
2908
- const parts = []
2909
- let cursorB = 0
2910
- for (let i = 0; i < blocks.length; i++) {
2911
- const b = blocks[i]
2912
- if (b.start > cursorB) parts.push(buf.slice(cursorB, b.start).toString('utf8'))
2913
- if (b.keep) parts.push(buf.slice(b.start, b.end).toString('utf8'))
2914
- cursorB = b.end
2915
- }
2916
- if (cursorB < buf.length) parts.push(buf.slice(cursorB).toString('utf8'))
2917
- const newText = parts.join('')
2918
- // 归档:整条原文(writeFullRaw 绕开 anchor 事务;归档文件从不参与解析/索引)
2919
- const archiveFile = layer === 'user'
2920
- ? path.join(dshHome(), 'memory', 'archived-user.md')
2921
- : path.join(paths.projectDir, 'archive', 'notes-archived.md')
2922
- const archiveText = removedIdx.map((i2) => {
2923
- const r = anchored[i2]
2924
- return buf.slice(r.markerByteStart, r.byteEnd).toString('utf8')
2925
- }).join('\n')
2926
- const existingArchive = await this.readTextSafe(archiveFile)
2927
- await this.writeFullRaw(archiveFile, (existingArchive ? existingArchive.replace(/\s+$/, '') + '\n' : '') + archiveText + '\n')
2928
- const rr = await store.replace(filePath, newText)
2929
- if (!rr.ok) { console.error('[compacted-anchored] replace failed: ' + rr.reason); return false }
2930
- const freshText = await this.readTextSafe(filePath)
2931
- if (layer === 'user') this.state.userText = freshText
2932
- else this.state.notesText = freshText
2933
- if (this._budgets) {
2934
- const b = this._budgets.get('today')
2935
- if (b && b.date === this.memToday()) b[layer] = 0
3253
+ async foldTextToSummaryPre(text, layer, agent) {
3254
+ try {
3255
+ if (!this._subagents || !agent || !String(text || '').trim()) return ''
3256
+ const layerName = layer === 'user' ? '用户级记忆' : '项目笔记'
3257
+ const prompt = [
3258
+ '你是记忆整理员。下面这段' + layerName + '内容已超出容量上限,需要**压缩成要点**后留在主文件里(原文会同时归档,不会丢)。',
3259
+ '规则:',
3260
+ '- 保留所有仍有长期价值的硬信息:强制规则、偏好、关键决策、约定、关键路径、踩过的坑及其规则',
3261
+ '- 合并重复、删除已失效/过期条目;每条一句话,尽量压到 30 字内',
3262
+ '- 按主题分组,用"### 主题"小标题;不要复述原文,只留结论',
3263
+ '- 语体:第三人称客观陈述,只落可复用结论;禁止第一人称思维叙述与思考腔',
3264
+ '- 只输出 markdown 正文,不要任何解释;总长度不超过 ' + COMPACT_FOLD_MAX_CHARS + ' 字符',
3265
+ '',
3266
+ '待折叠内容:',
3267
+ truncateTail(String(text), 6000),
3268
+ ].join('\n')
3269
+ const out = await this.runSubagent(prompt, 'auto-memory-fold', agent)
3270
+ const t = String(out || '').trim()
3271
+ if (!t || t.includes('(无)')) return ''
3272
+ return t.slice(0, COMPACT_FOLD_MAX_CHARS)
3273
+ } catch (e) {
3274
+ console.error('[auto-memory-fold] failed: ' + (e && e.message ? e.message : e))
3275
+ return ''
2936
3276
  }
2937
- this._lastCompactAt = Date.now()
2938
- this.state.loadedAt = Date.now()
2939
- console.log('[compacted-anchored] ' + layer + ': kept=' + (anchored.length - removedIdx.length) + '/' + anchored.length + ' archived=' + removedIdx.length + ' bytes=' + cur.length + '->' + newText.length)
2940
- return true
2941
3277
  }
2942
3278
 
2943
3279
  /**
2944
- * F1(M6-CONTRACT 外补丁,2026-08-23):anchor 开启时的记录级压缩。
2945
- * 按 sidecar 记录序整条保留/移除——今天(标题日期=插件今天)记录无条件保留,其余按字节配额
2946
- * 从最新往回保留(layer 上限 user 4000/note 3000);被移除记录整条原文写入归档文件
2947
- * (writeFullRaw,不经 anchor 事务,归档非检索语料)。重组经 store.replace 原子执行:
2948
- * 候选预检(clean)→被移除 id 自动删除→保留 id/版本不变。永不字符切片。
2949
- * @returns {Promise<boolean|'not-anchored'>} true=已腾位 false=无法腾位 'not-anchored'=无 sidecar 走旧路径
3280
+ * anchor 开启时的记录级整理(2026-09-10 重做,合并原先三份重复实现)。
3281
+ *
3282
+ * 旧行为的致命点:标题日期=今天的记录**无条件保留** → 当天写入量本身超过额度时 `removable` 为空、
3283
+ * 直接返回"无法腾位",于是当天再也写不进项目笔记(须跨天自解)。现在改为**只按容量配额从尾往回保留**,
3284
+ * 保护窗口外(含今天写入的较早条目)一律可回收。
3285
+ *
3286
+ * 回收方式:优先把可回收的前缀折叠成**一条要点记录**写回原位置(AI 不可用时退回整条移除);
3287
+ * 两种情况都把被回收记录的原文整条写入归档文件(writeFullRaw 绕开 anchor 事务,归档不参与解析/索引)。
3288
+ * 重组经 `store.replace` 原子执行:候选预检(clean)→被移除 id 自动删除→保留 id/版本不变,**永不字符切片**。
3289
+ * @param {number} keepBudget 保留内容的目标字符数(不含待写入余量)
3290
+ * @returns {Promise<{ok:boolean,reason?:string}|'not-anchored'>}
2950
3291
  */
2951
- async compactAnchoredLayer(store, filePath, layer, today, paths) {
3292
+ async compactAnchoredLayer(store, filePath, layer, today, paths, deficit, agent, allowFold) {
2952
3293
  const cur = await this.readTextSafe(filePath)
2953
- if (!cur || !cur.trim()) return false
3294
+ if (!cur || !cur.trim()) return { ok: true, reason: 'empty' }
2954
3295
  let parsed
2955
3296
  try { parsed = parseAnchors(Buffer.from(cur, 'utf8')) } catch (_) { return 'not-anchored' }
2956
- if (!parsed || parsed.status !== 'clean') return false // 脏文件 fail closed:不压缩、不切片
3297
+ if (!parsed || parsed.status !== 'clean') return { ok: false, reason: 'dirty-file' }
2957
3298
  const anchored = parsed.records.filter((r) => r.kind === 'anchored')
2958
3299
  if (!anchored.length) return 'not-anchored'
2959
- const limit = layer === 'user' ? 4000 : 3000
2960
- const isTodayHeading = (heading) => /^\d{4}-\d{2}-\d{2}/.test(String(heading || '')) && String(heading).slice(0, 10) === today
2961
3300
  const buf = Buffer.from(cur, 'utf8')
2962
- // 配额从尾回溯(F1):新的留;今天记录无条件保留
2963
- const keep = new Array(anchored.length).fill(false)
2964
- let keptBytes = 0
3301
+ // 单位统一:一律换算成**字符**(spanChars);byte 区间仍保留,供切片与归档使用。
3302
+ const spanCharsOf = (r) => buf.slice(r.markerByteStart, r.byteEnd).toString('utf8').length
3303
+ // 目标改成**缺口制**:从最旧开始回收,直到腾出的字符数 ≥ deficit(而不是"把保留量压到某个绝对值以下")。
3304
+ // 保护窗口(最近 COMPACT_PROTECT_RECENT_CHARS 字符)是**软**下限:窗口外不够腾时才向内侵占;
3305
+ // 唯一硬底线是"最新 1 条永不整条移除"。
3306
+ const protectBudget = COMPACT_PROTECT_RECENT_CHARS
3307
+ let tailKept = 0
3308
+ let protectFrom = 0
2965
3309
  for (let i = anchored.length - 1; i >= 0; i--) {
2966
- const rec = anchored[i]
2967
- const spanBytes = rec.byteEnd - rec.markerByteStart
2968
- if (isTodayHeading(rec.heading) || keptBytes + spanBytes <= limit) { keep[i] = true; keptBytes += spanBytes }
3310
+ tailKept += spanCharsOf(anchored[i])
3311
+ protectFrom = i
3312
+ if (tailKept >= protectBudget) break
2969
3313
  }
2970
- const removedIdx = []
2971
- anchored.forEach((rec, i) => { if (!keep[i]) removedIdx.push(i) })
2972
- if (!removedIdx.length) return false // 无可移除 → 无法腾位(预算照常拒绝)
2973
- // 有序块重组:legacy 块与 preamble/tail 原样保留,gap 字节逐段拷贝,仅丢弃被移除记录的 [marker..contentEnd]
2974
- const blocks = []
2975
- for (const r of parsed.records) {
2976
- if (r.kind === 'anchored') blocks.push({ start: r.markerByteStart, end: r.byteEnd, keep: keep[anchored.indexOf(r)] })
2977
- else blocks.push({ start: r.byteStart, end: r.byteEnd, keep: true })
3314
+ if (protectFrom > anchored.length - 1) protectFrom = anchored.length - 1
3315
+ const keep = new Array(anchored.length).fill(true)
3316
+ let removed = 0
3317
+ let cutIdx = -1
3318
+ for (let i = 0; i < protectFrom; i++) {
3319
+ removed += spanCharsOf(anchored[i]); cutIdx = i
3320
+ if (removed >= deficit) break
2978
3321
  }
2979
- blocks.sort((a, b) => a.start - b.start)
2980
- const parts = []
2981
- let cursorB = 0
2982
- for (let i = 0; i < blocks.length; i++) {
2983
- const b = blocks[i]
2984
- if (b.start > cursorB) parts.push(buf.slice(cursorB, b.start).toString('utf8'))
2985
- if (b.keep) parts.push(buf.slice(b.start, b.end).toString('utf8'))
2986
- cursorB = b.end
3322
+ if (removed < deficit) { // 保护窗口外不够 → 侵入保护窗口(仍保留最新 1 条)
3323
+ for (let i = protectFrom; i < anchored.length - 1; i++) {
3324
+ removed += spanCharsOf(anchored[i]); cutIdx = i
3325
+ if (removed >= deficit) break
3326
+ }
2987
3327
  }
2988
- if (cursorB < buf.length) parts.push(buf.slice(cursorB).toString('utf8'))
2989
- const newText = parts.join('')
2990
- // 归档:整条原文(writeFullRaw 绕开 anchor 事务;归档文件从不参与解析/索引)
3328
+ if (cutIdx < 0 || removed < deficit) return { ok: false, reason: 'no-removable' } // 受硬底线所限无法腾够
3329
+ for (let i = 0; i <= cutIdx; i++) keep[i] = false
3330
+ const removedIdx = []
3331
+ for (let i = 0; i <= cutIdx; i++) removedIdx.push(i)
3332
+ const removedSet = new Set(removedIdx)
3333
+
3334
+ // 归档:被回收记录的整条原文(两种回收方式都归档,保底不丢)
2991
3335
  const archiveFile = layer === 'user'
2992
3336
  ? path.join(dshHome(), 'memory', 'archived-user.md')
2993
3337
  : path.join(paths.projectDir, 'archive', 'notes-archived.md')
@@ -2997,93 +3341,46 @@ class MemoryEngine {
2997
3341
  }).join('\n')
2998
3342
  const existingArchive = await this.readTextSafe(archiveFile)
2999
3343
  await this.writeFullRaw(archiveFile, (existingArchive ? existingArchive.replace(/\s+$/, '') + '\n' : '') + archiveText + '\n')
3000
- const rr = await store.replace(filePath, newText)
3001
- if (!rr.ok) { console.error('[compacted-anchored] replace failed: ' + rr.reason); return false }
3002
- const freshText = await this.readTextSafe(filePath)
3003
- if (layer === 'user') this.state.userText = freshText
3004
- else this.state.notesText = freshText
3005
- if (this._budgets) {
3006
- const b = this._budgets.get('today')
3007
- if (b && b.date === this.memToday()) b[layer] = 0
3008
- }
3009
- this._lastCompactAt = Date.now()
3010
- this.state.loadedAt = Date.now()
3011
- console.log('[compacted-anchored] ' + layer + ': kept=' + (anchored.length - removedIdx.length) + '/' + anchored.length + ' archived=' + removedIdx.length + ' bytes=' + cur.length + '->' + newText.length)
3012
- return true
3013
- }
3014
3344
 
3015
- /**
3016
- * F1(M6-CONTRACT 外补丁,2026-08-23):anchor 开启时的记录级压缩。
3017
- * 按 sidecar 记录序整条保留/移除——今天(标题日期=插件今天)记录无条件保留,其余按字节配额
3018
- * 从最新往回保留(layer 上限 user 4000/note 3000);被移除记录整条原文写入归档文件
3019
- * (writeFullRaw,不经 anchor 事务,归档非检索语料)。重组经 store.replace 原子执行:
3020
- * 候选预检(clean)→被移除 id 自动删除→保留 id/版本不变。永不字符切片。
3021
- * @returns {Promise<boolean|'not-anchored'>} true=已腾位 false=无法腾位 'not-anchored'=无 sidecar 走旧路径
3022
- */
3023
- async compactAnchoredLayer(store, filePath, layer, today, paths) {
3024
- const cur = await this.readTextSafe(filePath)
3025
- if (!cur || !cur.trim()) return false
3026
- let parsed
3027
- try { parsed = parseAnchors(Buffer.from(cur, 'utf8')) } catch (_) { return 'not-anchored' }
3028
- if (!parsed || parsed.status !== 'clean') return false // 脏文件 fail closed:不压缩、不切片
3029
- const anchored = parsed.records.filter((r) => r.kind === 'anchored')
3030
- if (!anchored.length) return 'not-anchored'
3031
- const limit = layer === 'user' ? 4000 : 3000
3032
- const isTodayHeading = (heading) => /^\d{4}-\d{2}-\d{2}/.test(String(heading || '')) && String(heading).slice(0, 10) === today
3033
- const buf = Buffer.from(cur, 'utf8')
3034
- // 配额从尾回溯(F1):新的留;今天记录无条件保留
3035
- const keep = new Array(anchored.length).fill(false)
3036
- let keptBytes = 0
3037
- for (let i = anchored.length - 1; i >= 0; i--) {
3038
- const rec = anchored[i]
3039
- const spanBytes = rec.byteEnd - rec.markerByteStart
3040
- if (isTodayHeading(rec.heading) || keptBytes + spanBytes <= limit) { keep[i] = true; keptBytes += spanBytes }
3041
- }
3042
- const removedIdx = []
3043
- anchored.forEach((rec, i) => { if (!keep[i]) removedIdx.push(i) })
3044
- if (!removedIdx.length) return false // 无可移除 → 无法腾位(预算照常拒绝)
3045
- // 有序块重组:legacy 块与 preamble/tail 原样保留,gap 字节逐段拷贝,仅丢弃被移除记录的 [marker..contentEnd]
3345
+ // 优先折叠成要点;AI 不可用则整条移除(原文已进归档)
3346
+ let folded = allowFold ? await this.foldTextToSummaryPre(archiveText, layer, agent) : ''
3347
+ // 护栏:折叠产物若比"腾出来的空间减缺口"还大,写回去仍会超容量 → 退回整条归档(不写折叠块)。
3348
+ if (folded && folded.length > Math.max(0, removed - deficit)) folded = ''
3349
+ const foldedBlock = folded ? ('## 重组织要点(整理于 ' + today + ')\n' + folded + '\n') : ''
3350
+
3351
+ // 有序重组:legacy 块与 preamble/tail 原样保留;被移除记录处仅在**首个**移除位插入折叠要点
3046
3352
  const blocks = []
3047
3353
  for (const r of parsed.records) {
3048
- if (r.kind === 'anchored') blocks.push({ start: r.markerByteStart, end: r.byteEnd, keep: keep[anchored.indexOf(r)] })
3049
- else blocks.push({ start: r.byteStart, end: r.byteEnd, keep: true })
3354
+ if (r.kind === 'anchored') blocks.push({ start: r.markerByteStart, end: r.byteEnd, keep: keep[anchored.indexOf(r)], anchoredIdx: anchored.indexOf(r) })
3355
+ else blocks.push({ start: r.byteStart, end: r.byteEnd, keep: true, anchoredIdx: -1 })
3050
3356
  }
3051
3357
  blocks.sort((a, b) => a.start - b.start)
3052
3358
  const parts = []
3053
3359
  let cursorB = 0
3054
- for (let i = 0; i < blocks.length; i++) {
3055
- const b = blocks[i]
3360
+ let injected = false
3361
+ for (const b of blocks) {
3056
3362
  if (b.start > cursorB) parts.push(buf.slice(cursorB, b.start).toString('utf8'))
3057
3363
  if (b.keep) parts.push(buf.slice(b.start, b.end).toString('utf8'))
3364
+ else if (!injected && b.anchoredIdx >= 0) { injected = true; if (foldedBlock) parts.push(foldedBlock) }
3058
3365
  cursorB = b.end
3059
3366
  }
3060
3367
  if (cursorB < buf.length) parts.push(buf.slice(cursorB).toString('utf8'))
3061
3368
  const newText = parts.join('')
3062
- // 归档:整条原文(writeFullRaw 绕开 anchor 事务;归档文件从不参与解析/索引)
3063
- const archiveFile = layer === 'user'
3064
- ? path.join(dshHome(), 'memory', 'archived-user.md')
3065
- : path.join(paths.projectDir, 'archive', 'notes-archived.md')
3066
- const archiveText = removedIdx.map((i2) => {
3067
- const r = anchored[i2]
3068
- return buf.slice(r.markerByteStart, r.byteEnd).toString('utf8')
3069
- }).join('\n')
3070
- const existingArchive = await this.readTextSafe(archiveFile)
3071
- await this.writeFullRaw(archiveFile, (existingArchive ? existingArchive.replace(/\s+$/, '') + '\n' : '') + archiveText + '\n')
3369
+
3072
3370
  const rr = await store.replace(filePath, newText)
3073
- if (!rr.ok) { console.error('[compacted-anchored] replace failed: ' + rr.reason); return false }
3371
+ if (!rr.ok) { console.error('[compacted-anchored] replace failed: ' + rr.reason); return { ok: false, reason: 'replace-failed:' + rr.reason } }
3074
3372
  const freshText = await this.readTextSafe(filePath)
3075
3373
  if (layer === 'user') this.state.userText = freshText
3076
3374
  else this.state.notesText = freshText
3077
- if (this._budgets) {
3078
- const b = this._budgets.get('today')
3079
- if (b && b.date === this.memToday()) b[layer] = 0
3080
- }
3081
- this._lastCompactAt = Date.now()
3375
+ this._lastCompactAt[layer] = Date.now()
3082
3376
  this.state.loadedAt = Date.now()
3083
- console.log('[compacted-anchored] ' + layer + ': kept=' + (anchored.length - removedIdx.length) + '/' + anchored.length + ' archived=' + removedIdx.length + ' bytes=' + cur.length + '->' + newText.length)
3084
- return true
3377
+ console.log('[compacted-anchored] ' + layer + ': kept=' + (anchored.length - removedIdx.length) + '/' + anchored.length
3378
+ + ' reclaimed=' + removedIdx.length + ' folded=' + (folded ? 'yes' : 'no')
3379
+ + ' chars=' + cur.length + '->' + newText.length + ' deficit=' + deficit)
3380
+ return { ok: true, reason: folded ? 'folded' : 'archived' }
3085
3381
  }
3086
3382
 
3383
+
3087
3384
  // ---------- 注入渲染(同步,基于缓存) ----------
3088
3385
  // 动态记忆 → ctx.systemPrompt.context()(user-role 快照):内容变化才追加新快照,内容不变不重复注入(dsh-agent-loop project() 去重),
3089
3386
  // system prompt 不再包含动态内容 → 字节级稳定 → DeepSeek 前缀缓存全程命中(对比 section 方案:动态内容任何变化都从变化点起击穿整个前缀,含全部历史)
@@ -3199,7 +3496,7 @@ class MemoryEngine {
3199
3496
  lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
3200
3497
  lines.push('- **交接与白板(长任务续命)**:阶段产出或方向变化时,调用 memory_note(kind=handoff) 写四段式交接账本——任务状态/目标/已试方案与失败原因/进度与下一步,给下一个上下文窗口续命;对项目全貌的理解发生实质变化时,用 memory_note(kind=plan) 重写白板 PLAN.md(人能读的项目规划图,旧版自动归档,用户在面板实时可见)。账本质量纪律:每段 ≤5 行;失败项写成「方案→失败原因」并保留关键报错词;下一步必须是可直接执行的第一步(带文件路径或命令);不写临时信息。两者会注入到你的动态快照首位,是跨窗口交接的凭据。')
3201
3498
  lines.push('- 只记录有跨会话长期价值的;不记临时信息(搜索结果、临时路径、工具报错)。')
3202
- lines.push('- 每日写入预算(所有会话共享一天额度,跨天自动重置):用户级 ≤4000 字/天、项目笔记 ≤3000 字/天;超限不拒绝——框架自动把"今天之前"的旧内容交给 AI 浓缩成要点腾出空间(10 分钟内只压缩一次),AI 不可用时归档最早段落,信息不丢;每日日志无预算。')
3499
+ lines.push('- 记忆容量(设置项 noteCapacityChars/userCapacityChars,默认各 12000 字符):项目笔记与用户级记忆各有容量上限;超出时框架自动整理——先把较早内容交给 AI 折叠成要点、失败则退回整条归档(原文进 archive,信息不丢),整理后仍超才拒绝写入,所以**新记忆正常不会被堵在外面**。整理同一层 10 分钟内只做一次;每日日志无容量限制。注意这与「注入预算」不同:注入预算管每轮往上下文塞多少摘要,容量上限管文件本体大小。')
3203
3500
  lines.push('- **记忆操作必须在正文可见(摘要链)**:调用 memory_log/note/user/reflect 更新记忆后,必须把结果写进本轮回复的正文文本(用户直接看到的那段文字,不是工具调用区),并在**回复末尾**用加粗或换行使其醒目(如"**已更新今日日志**\n新增:修复了XXX");调用 memory_recall/memory_external 检索时,在正文开头写明"我查了记忆,发现..."。工具返回值只是辅助,正文转述是强制要求。')
3204
3501
  lines.push('- 用户明确要求长期记住:跨项目规则 → memory_user;仅本项目 → memory_note。')
3205
3502
  lines.push('- 定期调用 memory_maintain 做 30 天蒸馏:AI 提炼旧日志要点进项目笔记,原文保底归档;不存密钥,除非用户明确要求。')
@@ -3229,7 +3526,7 @@ class MemoryEngine {
3229
3526
  pending.text,
3230
3527
  '请在本轮回复开头,以「昨日反思 · ' + pending.date + '」小节向用户呈现前一天的工作反思与要点:成果回顾、值得注意的教训或改进、今天可延续的要点。',
3231
3528
  '要求:' + styleText,
3232
- '生成后调用 memory_reflect(date="' + pending.date + '", text=完整反思内容)保存,之后该提示不再出现。',
3529
+ '生成后**必须**调用 memory_reflect(date="' + pending.date + '", text=完整反思内容)保存落盘 —— 只呈现不落盘会导致该提示在后续轮次重复出现。',
3233
3530
  ].join('\n'))
3234
3531
  }
3235
3532
 
@@ -3978,20 +4275,20 @@ class MemoryEngine {
3978
4275
  },
3979
4276
  duplicateHeadings,
3980
4277
  budgets: (() => {
3981
- const out = []
3982
- if (this._budgets) {
3983
- for (const [, b] of this._budgets.entries()) {
3984
- out.push({
3985
- scope: 'daily', date: b.date,
3986
- userUsed: b.user, userLimit: 4000,
3987
- noteUsed: b.note, noteLimit: 3000,
3988
- userFileSize: (this.state.userText || '').length,
3989
- noteFileSize: (this.state.notesText || '').length,
3990
- lastCompactAt: this._lastCompactAt || 0,
3991
- })
3992
- }
3993
- }
3994
- return out
4278
+ // 2026-09-10 改为**容量口径**:报的是"文件当前字符数 / 容量上限",不再有"当日已用/字每天"这种记账。
4279
+ const uLim = this.capacityLimit('user')
4280
+ const nLim = this.capacityLimit('note')
4281
+ const uUsed = (this.state.userText || '').length
4282
+ const nUsed = (this.state.notesText || '').length
4283
+ return [{
4284
+ scope: 'capacity',
4285
+ userUsed: uUsed, userLimit: uLim,
4286
+ noteUsed: nUsed, noteLimit: nLim,
4287
+ userFileSize: uUsed,
4288
+ noteFileSize: nUsed,
4289
+ lastCompactAt: (this._lastCompactAt && this._lastCompactAt.note) || 0,
4290
+ lastCompactAtUser: (this._lastCompactAt && this._lastCompactAt.user) || 0,
4291
+ }]
3995
4292
  })(),
3996
4293
  today: todayStr(),
3997
4294
  memoryDate: this.memToday(),
@@ -4374,15 +4671,15 @@ class MemoryEngine {
4374
4671
  let run
4375
4672
  try {
4376
4673
  // prompt 必须是 block 数组(createUserMessage 校验 content.some)
4377
- const subModel = String(this.config.subagentModel || '').trim()
4378
4674
  this._subagentInflight = (this._subagentInflight || 0) + 1
4379
4675
  run = await subagents.start(providerName, {
4380
4676
  label,
4381
4677
  prompt: [{ type: 'text', text }],
4382
4678
  signal: controller.signal,
4383
4679
  ...(parent ? { parent } : {}),
4384
- // 设置页「总结/问候默认模型」:非空时覆盖子代理的模型选择(空=跟随路由默认)
4385
- ...(subModel ? { agentOptions: { model: subModel } } : {}),
4680
+ // 设置页「子代理模型 / 思考强度」:非空时覆盖子代理的模型与推理档位(空=跟随路由默认)。
4681
+ // DSH 0.1.5:两者都经 SubagentStartRequest.agentOptions 下发(provider 侧需 capabilities.agentOptions)。
4682
+ ...(subAgentOptions(this.config) ? { agentOptions: subAgentOptions(this.config) } : {}),
4386
4683
  })
4387
4684
  // 套娃防护(问题②):登记本插件 spawn 的子代理,其生命周期事件在 pre-step/turn-stopping 零处理
4388
4685
  // 2026-09-09 修复:DSH 0.1.2 in-process run 对象字段为 localAgent(旧版为 agent),取两者兼容
@@ -4416,7 +4713,8 @@ class MemoryEngine {
4416
4713
  prompt: [{ type: 'text', text }],
4417
4714
  signal: controller.signal,
4418
4715
  ...(parent ? { parent } : {}),
4419
- agentOptions: { model: fbModel },
4716
+ // 回退必须显式用 fbModel(而不是设置页的 subagentModel),否则重试等于没换模型;强度沿用配置。
4717
+ agentOptions: subAgentOptions({ ...this.config, subagentModel: fbModel }),
4420
4718
  })
4421
4719
  result = await this.withTimeout(run2.result, tMs, null)
4422
4720
  }
@@ -4769,7 +5067,7 @@ class MemoryEngine {
4769
5067
  this.state.notesText = body
4770
5068
  written += notePts.length
4771
5069
  } else {
4772
- console.log('[dsh-auto-memory] auto-consolidate: NOTE 超预算且压缩不可用,已跳过')
5070
+ console.log('[dsh-auto-memory] auto-consolidate: NOTE ' + this.budgetRefusalTextPre(r, 'note') + ',已跳过')
4773
5071
  }
4774
5072
  }
4775
5073
  if (userPts.length) {
@@ -4779,7 +5077,7 @@ class MemoryEngine {
4779
5077
  this.state.userText = body
4780
5078
  written += userPts.length
4781
5079
  } else {
4782
- console.log('[dsh-auto-memory] auto-consolidate: USER 超预算且压缩不可用,已跳过')
5080
+ console.log('[dsh-auto-memory] auto-consolidate: USER ' + this.budgetRefusalTextPre(r, 'user') + ',已跳过')
4783
5081
  }
4784
5082
  }
4785
5083
  if (written) {
@@ -4863,7 +5161,7 @@ class MemoryEngine {
4863
5161
  const body = await this.appendText(p.notesPath, '\n## ' + today + '\n' + projectPts.map((x) => '- ' + x).join('\n'))
4864
5162
  this.state.notesText = body
4865
5163
  written.push('项目笔记 ' + projectPts.length + ' 条')
4866
- } else skipped.push('项目笔记(预算超限且压缩不可用)')
5164
+ } else skipped.push('项目笔记(' + this.budgetRefusalTextPre(r, 'note') + ')')
4867
5165
  }
4868
5166
  if (userPts.length) {
4869
5167
  const r = await this.ensureBudget(agent, 'user', userPts.map((x) => '- ' + x).join('\n'))
@@ -4871,11 +5169,11 @@ class MemoryEngine {
4871
5169
  const body = await this.appendText(p.userFile, '\n## ' + today + '\n' + userPts.map((x) => '- ' + x).join('\n'))
4872
5170
  this.state.userText = body
4873
5171
  written.push('用户级记忆 ' + userPts.length + ' 条')
4874
- } else skipped.push('用户级记忆(预算超限且压缩不可用)')
5172
+ } else skipped.push('用户级记忆(' + this.budgetRefusalTextPre(r, 'user') + ')')
4875
5173
  }
4876
5174
  this.state.loadedAt = Date.now()
4877
5175
  if (!written.length) {
4878
- if (skipped.length) return 'memory_consolidate: AI 已提炼,但今日写入预算已用尽,以下层被跳过(明天再固化): ' + skipped.join('、') + '。'
5176
+ if (skipped.length) return 'memory_consolidate: AI 已提炼,但自动整理未能腾出空间,以下层被跳过: ' + skipped.join('、') + '。'
4879
5177
  return 'memory_consolidate: AI 未提炼出值得长期记录的内容。'
4880
5178
  }
4881
5179
  const detail = []
@@ -6316,7 +6614,10 @@ export function apply(ctx, config) {
6316
6614
  try { ctx.on('dispose', () => { engine._disposed = true }) } catch (eDispose) {}
6317
6615
  // 时间检测用的服务(软获取,缺失时定时兜底自动降级)
6318
6616
  engine._sessionsSvc = ctx.get('sessions') || undefined
6319
- engine._agentSvc = ctx.get('agent') || undefined
6617
+ // DSH 0.1.5 破坏性变更:单数服务键 'agent' 已移除,Agent 注册表改名为 'agents'(AgentRegistry)。
6618
+ // 旧写法让 _agentSvc 恒 undefined → restoreLastAgent 永久失效(实测每 tick 刷 "svc missing ... agent=false")。
6619
+ // 先取新键,保留旧键兜底以兼容 0.1.4 及更早版本。
6620
+ engine._agentSvc = ctx.get('agents') || ctx.get('agent') || undefined
6320
6621
  // 模型目录(设置页「总结/问候默认模型」抽屉数据源);软获取,缺失时抽屉回退手动输入
6321
6622
  engine._llm = ctx.get('llm') || undefined
6322
6623
  // 工作区注册表(修A,2026-09-08):接续时解析旧会话所属 workspaceId——官方 create 只在传 workspaceId 时绑定工作区
@@ -6559,7 +6860,7 @@ export function apply(ctx, config) {
6559
6860
  return '已更新记忆文档: ' + logPath + '\n' + entry + (gate.truncated ? '\n(内容超长,已截断)' : '')
6560
6861
  }),
6561
6862
 
6562
- defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点),或写交接白板。kind=note(默认):action=append 追加一段(自动带日期标题)/action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容),每日预算 3000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。kind=handoff:写一篇四段式交接账本(任务状态/目标/已试方案与失败原因/进度与下一步)到 handoff/,阶段产出或方向变化时用,给下一个上下文窗口续命。kind=plan:整体重写白板 PLAN.md(人能读的项目全貌规划图),对项目全貌的理解发生实质变化时用,旧版自动归档。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
6863
+ 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(人能读的项目全貌规划图),对项目全貌的理解发生实质变化时用,旧版自动归档。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
6563
6864
  content: { type: 'string', required: true, description: '笔记内容。kind=plan 时给完整新全貌(不是增量)。' },
6564
6865
  action: { type: 'string', enum: ['append', 'replace'], description: '仅 kind=note 时有效:append=追加, replace=整体替换。' },
6565
6866
  kind: { type: 'string', enum: ['note', 'handoff', 'plan'], description: 'note=项目笔记(默认), handoff=四段式交接账本(新篇), plan=白板全貌重写。' },
@@ -6592,7 +6893,7 @@ export function apply(ctx, config) {
6592
6893
  if (tailHas(existing, write)) return 'memory_note: 与笔记尾部已有内容重复,拒绝写入(复读防护)。'
6593
6894
  }
6594
6895
  const acct = await engine.ensureBudget(exec.agent, 'note', write)
6595
- if (!acct.ok) return 'memory_note: 项目笔记今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
6896
+ if (!acct.ok) return 'memory_note: ' + engine.budgetRefusalTextPre(acct, 'note') + ' 本次未写入(原内容未改动)。'
6596
6897
  let body
6597
6898
  if (replace) {
6598
6899
  body = write
@@ -6604,7 +6905,7 @@ export function apply(ctx, config) {
6604
6905
  return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + write + (gate.truncated ? '\n(内容超长,已截断到 ' + write.length + ' 字符)' : '') + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
6605
6906
  }),
6606
6907
 
6607
- defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。每日预算 4000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
6908
+ defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。有**容量上限**(字符,设置项 userCapacityChars,默认 12000);超出时自动整理——先把较早内容交给 AI 折叠成要点、失败则退回整条归档(原文都进 archive,不丢),整理后仍超才拒绝(正常不会发生)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
6608
6909
  content: { type: 'string', required: true, description: '要记住的规则或偏好内容。' },
6609
6910
  action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
6610
6911
  }, async (args, exec) => {
@@ -6621,7 +6922,7 @@ export function apply(ctx, config) {
6621
6922
  if (tailHas(existing, write)) return 'memory_user: 与用户级记忆尾部已有内容重复,拒绝写入(复读防护)。'
6622
6923
  }
6623
6924
  const acct = await engine.ensureBudget(exec.agent, 'user', write)
6624
- if (!acct.ok) return 'memory_user: 用户级记忆今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
6925
+ if (!acct.ok) return 'memory_user: ' + engine.budgetRefusalTextPre(acct, 'user') + ' 本次未写入(原内容未改动)。'
6625
6926
  let body
6626
6927
  if (replace) {
6627
6928
  body = write
@@ -6878,11 +7179,29 @@ export function apply(ctx, config) {
6878
7179
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
6879
7180
  if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
6880
7181
  try {
6881
- const r = await engine.buildContinueCarry()
7182
+ // fromSessionId(2026-09-10):client 侧知道自己在续哪个会话,显式传进来,避免材料包取错会话。
7183
+ const body = await readJsonBody(req).catch(() => ({}))
7184
+ const r = await engine.buildContinueCarry((body && body.fromSessionId) || '')
6882
7185
  return writeJson(res, r.ok ? 200 : 400, r)
6883
7186
  } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
6884
7187
  },
6885
7188
  },
7189
+ {
7190
+ kind: 'exact',
7191
+ path: API['handoff-permission'],
7192
+ handler: async (req, res) => {
7193
+ // 一键接续(client 路径)的权限继承(POST;loopback):官方 session.create 不收权限字段,
7194
+ // 新会话必落 settings 的 permission.defaultPreset → 接续后每一步都要用户批准(静默运行被打断)。
7195
+ // 由 client 在建好新会话后回调本端点,宿主侧把旧会话的预设套到新会话上(fail-soft)。
7196
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
7197
+ if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
7198
+ try {
7199
+ const body = await readJsonBody(req).catch(() => ({}))
7200
+ const r = await engine.inheritPermissionForContinue(body && body.fromSessionId, body && body.toSessionId, { attempts: (body && body.attempts) || undefined })
7201
+ return writeJson(res, 200, r || { ok: false, reason: 'no result' })
7202
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
7203
+ },
7204
+ },
6886
7205
  {
6887
7206
  kind: 'exact',
6888
7207
  path: API['auto-continue-state'],