@a9i5k4/dsh-auto-memory 2.4.1 → 2.5.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
@@ -266,8 +266,11 @@ const DEFAULT_CONFIG = {
266
266
  waterLevelAdvisory: true,
267
267
  /** 水位越阈时自动写一篇系统骨架账本(每会话一次;模型仍应自己写正式交接)。 */
268
268
  waterLevelAutoHandoff: true,
269
- /** 自动接续(默认开):水位≥阈值 且 harness 权威 running==false(轮次边界,取代旧"两轮水位持平")时弹确认卡。 */
270
- autoContinueEnabled: true,
269
+ /** 自动接续(默认关):水位≥阈值 且 harness 权威 running==false(轮次边界,取代旧"两轮水位持平")时弹确认卡。 */
270
+ // 2026-09-10 改为默认关(口径未修前刚过半就触发,纯浪费)。2026-09-13 口径已修:分母=官方声明窗口
271
+ // (不再扣预留输出),与官方压缩同坐标系 —— 是否翻回默认开由用户验证后定夺,此处保持 false 不动。
272
+ // 注:老用户已落盘的显式值不受影响,只有新装用户吃这个默认。
273
+ autoContinueEnabled: false,
271
274
  /** 自动接续水位阈值(0.5-0.95);2026-09-08 与 waterLevelThreshold 同步下调到 0.75(官方自动压缩阈值 80%,必须留余量)。 */
272
275
  autoContinueThreshold: 0.75,
273
276
  /** 确认卡倒计时秒数(30-40s 无操作 = 挂机 → 自动接续兜底)。 */
@@ -1897,7 +1900,14 @@ class MemoryEngine {
1897
1900
  sig = scanPressureSignalsPre(events)
1898
1901
  } catch (eSig) {}
1899
1902
  const reserve = Number(sessModel.maxTokens) || Number(sig.reservedTokens) || 0
1900
- const effectiveWin = (reserve > 0 && reserve < win * 0.9) ? (win - reserve) : win
1903
+ // ② 判定窗(2026-09-13 口径修正,NEXT-VERSION-TODO 改点1):正常触发线与官方压缩同坐标系 ——
1904
+ // 分母 = 官方声明窗口 win,provider 自报过硬限(400 文本)时取二者较小值。**不再把预留输出 reserve
1905
+ // 从分母里扣**:旧口径(分母 = win − reserve =「单请求可用额度」)让水位被系统性放大 —— 本机
1906
+ // 1,048,576 − 384,000 = 664,576,上下文刚过半(≈46 万)就触发接续,而官方约 80%(≈83.9 万)才压缩。
1907
+ // reserve 收缩为两个职责:①「距硬墙余量」展示(waterLevelWall) ②硬判据 estTokens + reserve > 判定窗
1908
+ // (下一次请求就会被 provider 拒绝时才硬触发;本机即 400 事故的复现边界:666,044 + 384,000 > 1,048,576)。
1909
+ const hardWin = Number(sig.overflow && sig.overflow.windowTokens) || 0
1910
+ const triggerWin = hardWin > 0 ? Math.min(win, hardWin) : win
1901
1911
  let estTokens = 0
1902
1912
  let meterLayer = 'heuristic'
1903
1913
  try {
@@ -1940,39 +1950,41 @@ class MemoryEngine {
1940
1950
  } catch (e) {}
1941
1951
  // 旧实现硬截断到 1.5 → 任何超额水位都显示成 150%,掩盖真实占用(用户实测 761692/131072 被显示为 150%)。
1942
1952
  // 现在如实上报,仅做防溢出的 99 倍上限。
1943
- const ratio = effectiveWin > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / effectiveWin, 99) : 0
1953
+ const ratio = triggerWin > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / triggerWin, 99) : 0
1944
1954
  const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.75, 0.1)
1945
1955
  // 硬触发:官方已经压缩过/已撞过窗口墙 —— 这一刻必须接续,不依赖比例是否算得准。
1946
- const hard = compacted || overflowed
1956
+ // 2026-09-13 增加预测性硬墙:estTokens + reserve > 判定窗 ⇒ 下一次请求必被 provider 拒绝
1957
+ // (消息实占 + 预留输出 > 上限,v2.4.1 之前的 400 事故就是这个等式)。
1958
+ const hardWall = estTokens > 0 && (estTokens + reserve > triggerWin)
1959
+ const hard = compacted || overflowed || hardWall
1947
1960
  const armRatio = hard ? Math.max(ratio, threshold) : ratio
1948
1961
  rt.waterLevel = armRatio
1949
1962
  rt.waterLevelTokens = estTokens
1950
1963
  // 2026-09-10:pre-step 的 arm 走的是 runtime 字段(见 checkWaterLevelAtStep → armAutoContinue),
1951
1964
  // 这里只写了 ratio/tokens、漏写 window/source,于是确认卡显示「512,311 / 0 token」(实测)。
1952
1965
  // 与 state 同源同值,一处漏写两处都缺 —— 必须成对维护。
1953
- rt.waterLevelWindow = effectiveWin
1966
+ rt.waterLevelWindow = triggerWin
1954
1967
  rt.waterLevelSource = winSource
1955
1968
  this.state.waterLevelRatio = armRatio
1956
1969
  this.state.waterLevelTokens = estTokens
1957
- this.state.waterLevelWindow = effectiveWin
1970
+ this.state.waterLevelWindow = triggerWin
1958
1971
  this.state.waterLevelSource = winSource
1959
1972
  this.state.waterLevelMeter = meterLayer
1960
1973
  this.state.waterLevelModel = sessModel.model || winInfo.model || ''
1961
1974
  this.state.waterLevelAt = Date.now()
1962
1975
  this.state.waterLevelMeasuredTokens = estTokens
1963
1976
  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
1977
+ // 双口径(2026-09-13 起):水位/触发/ring 同用一个分母(判定窗 = 官方声明窗口,provider 自报过硬限时取 min)。
1978
+ // 旧「可用额度 vs 声明窗口」双口径随 reserve 退出分母而消失;保留的第二个数是「距硬墙余量」(hardWin − reserve,
1979
+ // 只有撞过 400 的会话才有 hardWin)。ring 与 ratio 同分母:硬限未知的普通会话里 ring 就等于官方小圈读数。
1980
+ this.state.waterLevelRing = (triggerWin > 0 && Number.isFinite(estTokens)) ? (estTokens / triggerWin) : 0
1968
1981
  // provider 自报的硬上限只有撞过墙(400 文本)才拿得到;拿得到就把「真实可写上限」也算出来,
1969
1982
  // 让界面能显示「距硬墙还剩多少 token」(本机 = 1,048,576 − 384,000 = 664,576)。
1970
- const hardWin = Number(sig.overflow && sig.overflow.windowTokens) || 0
1971
1983
  this.state.waterLevelWall = hardWin > 0 ? Math.max(0, hardWin - reserve) : 0
1972
- this.state.waterLevelHardTrigger = hard ? (compacted ? 'compaction' : 'overflow') : ''
1984
+ this.state.waterLevelHardTrigger = hard ? (compacted ? 'compaction' : overflowed ? 'overflow' : 'wall') : ''
1973
1985
  // 会话级记录:切会话时 /handoff-state?sessionId= 直接取这份,不再显示别的会话的数
1974
1986
  this.rememberWaterRecord(agent && agent.session && agent.session.id ? String(agent.session.id) : '', {
1975
- ratio: armRatio, measuredRatio: ratio, tokens: estTokens, measuredTokens: estTokens, window: effectiveWin,
1987
+ ratio: armRatio, measuredRatio: ratio, tokens: estTokens, measuredTokens: estTokens, window: triggerWin,
1976
1988
  windowRaw: win, reserve, source: winSource, meter: meterLayer, model: this.state.waterLevelModel || '',
1977
1989
  threshold, hardTrigger: this.state.waterLevelHardTrigger, at: Date.now(), live: true,
1978
1990
  })
@@ -1981,7 +1993,7 @@ class MemoryEngine {
1981
1993
  if (!rt.waterLevelAdvised || compacted || overflowed) {
1982
1994
  rt.waterLevelAdvised = true
1983
1995
  diag('water level advisory: ratio=' + ratio.toFixed(2) + (hard ? '→' + armRatio.toFixed(2) + '(hard:' + this.state.waterLevelHardTrigger + ')' : '') +
1984
- ' tokens=' + estTokens + '/' + effectiveWin + '(win=' + win + ' reserve=' + reserve + ')' +
1996
+ ' tokens=' + estTokens + '/' + triggerWin + '(win=' + win + ' reserve=' + reserve + (hardWin > 0 ? ' hardWin=' + hardWin : '') + ')' +
1985
1997
  ' src=' + (winSource || '?') + ' meter=' + meterLayer + (compacted ? ' compaction-detected' : ''))
1986
1998
  }
1987
1999
  if (this.config.waterLevelAutoHandoff !== false && (!rt.waterLevelAutoHandoffDone || compacted || overflowed)) {
@@ -2088,6 +2100,96 @@ class MemoryEngine {
2088
2100
  return true
2089
2101
  }
2090
2102
 
2103
+ // ---------- 接续序号 v2(2026-09-13,NEXT-VERSION-TODO 改点2):持久计数器 ----------
2104
+ /** 接续序号计数器文件(~/.dsh/memory/cont-seq.json)。放在全局记忆根而非各工作区 handoff/ 目录:
2105
+ * handoffDir 按工作区解析,放那里既会被"落盘失败/清理"带偏计数,也无法保证「换工作区接续不重复已有序号」;
2106
+ * 全局单文件 + 工作区记账键(byWorkspace,键=工作区路径,仅作记账与兜底)既跨重启持久、又全局单调不重号。
2107
+ * 旧口径(数 handoff 目录里 prev-session-*.md 的文件数 +1)在落盘失败/跨工作区/carry 复用时重复或跳号,
2108
+ * 且取数为空时 rename 被整段跳过、标题退回自动生成 —— 均为用户实机观察过的现象。 */
2109
+ contSeqFile() { return path.join(dshHome(), 'memory', 'cont-seq.json') }
2110
+
2111
+ loadContSeqState() {
2112
+ try {
2113
+ const obj = JSON.parse(readFileSync(this.contSeqFile(), 'utf8'))
2114
+ if (obj && typeof obj === 'object') {
2115
+ return {
2116
+ last: Number(obj.last) || 0,
2117
+ byWs: (obj.byWorkspace && typeof obj.byWorkspace === 'object') ? obj.byWorkspace : {},
2118
+ }
2119
+ }
2120
+ } catch (e) {}
2121
+ return null
2122
+ }
2123
+
2124
+ saveContSeqState(st) {
2125
+ try {
2126
+ mkdirSync(path.dirname(this.contSeqFile()), { recursive: true })
2127
+ writeFileSync(this.contSeqFile(), JSON.stringify({ last: st.last, byWorkspace: st.byWs, updatedAt: Date.now() }, null, 2), 'utf8')
2128
+ return true
2129
+ } catch (e) { diag('cont-seq persist failed (in-memory only): ' + ((e && e.message) || e)); return false }
2130
+ }
2131
+
2132
+ /** 兼容老数据:计数器文件缺失/为空时,从既有会话日志解析历史「接续 #N / Cont.#N」标题的最大序号。
2133
+ * 扫描 ~/.dsh/sessions/<工作区>/<会话>/session.jsonl[.zstd],按 mtime 新→旧最多 120 个;找不到返回 0。 */
2134
+ async scanMaxContSeq() {
2135
+ try {
2136
+ const sessionsDir = path.join(dshHome(), 'sessions')
2137
+ const wsDirs = await readdir(sessionsDir).catch(() => [])
2138
+ const cands = []
2139
+ for (const w of wsDirs) {
2140
+ const sids = await readdir(path.join(sessionsDir, w)).catch(() => [])
2141
+ for (const sid of sids) {
2142
+ const file = await this.resolveSessionFile(path.join(sessionsDir, w, sid))
2143
+ if (!file) continue
2144
+ const st = await stat(file).catch(() => null)
2145
+ if (st && st.isFile()) cands.push({ file, mt: Number(st.mtimeMs) || 0 })
2146
+ }
2147
+ }
2148
+ cands.sort((a, b) => b.mt - a.mt)
2149
+ let max = 0
2150
+ for (const { file } of cands.slice(0, 120)) {
2151
+ try {
2152
+ const buf = await readFile(file)
2153
+ const text = file.endsWith('.zstd') ? (zstdDec ? zstdDecodeAllFrames(buf) : '') : buf.toString('utf8')
2154
+ if (!text) continue
2155
+ const re = /(?:接续 #|Cont\.#)(\d+)/g
2156
+ let m
2157
+ while ((m = re.exec(text))) { const n = Number(m[1]) || 0; if (n > max) max = n }
2158
+ } catch (eF) {}
2159
+ }
2160
+ return max
2161
+ } catch (e) { return 0 }
2162
+ }
2163
+
2164
+ /** 分配下一个接续序号:全局单调递增(换工作区也不重号),持久化到 cont-seq.json。
2165
+ * 文件缺失时先从历史标题解析最大值兜底(兼容 v2.4.2 及之前的文件计数老数据)。
2166
+ * 持久化失败只记 diag、序号照常返回(退化为进程内计数),不得阻塞接续。 */
2167
+ async allocContSeq(wsKey) {
2168
+ const st = this.loadContSeqState() || { last: 0, byWs: {} }
2169
+ if (!(st.last > 0)) {
2170
+ const scanned = await this.scanMaxContSeq().catch(() => 0)
2171
+ if (scanned > st.last) st.last = scanned
2172
+ }
2173
+ for (const v of Object.values(st.byWs)) { const n = Number(v) || 0; if (n > st.last) st.last = n }
2174
+ const next = st.last + 1
2175
+ st.last = next
2176
+ if (wsKey) st.byWs[String(wsKey)] = next
2177
+ this.saveContSeqState(st)
2178
+ return next
2179
+ }
2180
+
2181
+ /** 回滚刚分配的序号(转写包落盘失败时调用,保证不跳号)。只撤自己刚发出的那个号。 */
2182
+ rollbackContSeq(wsKey, seq) {
2183
+ try {
2184
+ const n = Number(seq) || 0
2185
+ if (!n) return
2186
+ const st = this.loadContSeqState() || { last: n, byWs: {} }
2187
+ if (st.last === n) st.last = n - 1
2188
+ if (wsKey && Number(st.byWs[String(wsKey)]) === n) delete st.byWs[String(wsKey)]
2189
+ this.saveContSeqState(st)
2190
+ } catch (e) { diag('cont-seq rollback failed: ' + ((e && e.message) || e)) }
2191
+ }
2192
+
2091
2193
  /** M-CM6-C·宿主兜底自动接续(2026-09-08,2.2.6):浏览器端 AutoContinueHost 被后台节流/关闭时,
2092
2194
  * 宿主自身在倒计时到期后直接建新会话。协议:turn-stopping 且水位≥阈值 → arm 倒计时(confirmSeconds,
2093
2195
  * 默认 35s,写入 _autoContState)→ 浏览器轮询 GET auto-continue-state 显示确认卡(同意=POST decide
@@ -2531,6 +2633,10 @@ class MemoryEngine {
2531
2633
  const NL = String.fromCharCode(10)
2532
2634
  const stamp = new Date().toISOString().slice(11, 19).replace(/:/g, '')
2533
2635
  const outPath = path.join(p.handoffDir, 'prev-session-' + sid.slice(0, 8) + '-' + stamp + '.md')
2636
+ // 接续序号 v2(2026-09-13):先分配序号、再写包;写包失败回滚计数器(不跳号)。
2637
+ // 序号属于这次接续(新会话标题「接续 #N」用),分配即持久化,重启/换工作区都不重号。
2638
+ let contSeq = 0
2639
+ try { contSeq = await this.allocContSeq(p.ws) } catch (eSeq) { diag('allocContSeq error: ' + ((eSeq && eSeq.message) || eSeq)) }
2534
2640
  const body = [
2535
2641
  '# 旧会话完整转写(' + sid + ')', '',
2536
2642
  '- 旧会话 ID: ' + sid,
@@ -2547,11 +2653,17 @@ class MemoryEngine {
2547
2653
  body.push(chunk)
2548
2654
  total += chunk.length
2549
2655
  }
2550
- await mkdir(p.handoffDir, { recursive: true })
2551
- await writeFile(outPath, body.join(NL), 'utf8')
2656
+ try {
2657
+ await mkdir(p.handoffDir, { recursive: true })
2658
+ await writeFile(outPath, body.join(NL), 'utf8')
2659
+ } catch (eW) {
2660
+ // 包落盘失败 → 回滚刚分配的序号,保证不跳号(carry 会用持久计数器重新兜底)
2661
+ if (contSeq) { try { this.rollbackContSeq(p.ws, contSeq) } catch (eR) {} }
2662
+ throw eW
2663
+ }
2552
2664
  const tail = msgs.slice(-20).map((m) => m.role + ': ' + m.text.slice(0, 700)).join(NL + '---' + NL)
2553
- diag('prev-session pack built: sid=' + sid + ' msgs=' + msgs.length + ' model=' + (model || '-') + '@' + (provider || '-') + ' effort=' + (reasoningEffort || '-') + ' -> ' + outPath)
2554
- return { sessionId: sid, transcriptPath: outPath, provider, model, reasoningEffort, agentPreset: preset, cwd: cwd || p.ws, tailText: tail, msgCount: msgs.length }
2665
+ diag('prev-session pack built: sid=' + sid + ' msgs=' + msgs.length + ' model=' + (model || '-') + '@' + (provider || '-') + ' effort=' + (reasoningEffort || '-') + ' contSeq=' + contSeq + ' -> ' + outPath)
2666
+ return { sessionId: sid, transcriptPath: outPath, contSeq, provider, model, reasoningEffort, agentPreset: preset, cwd: cwd || p.ws, tailText: tail, msgCount: msgs.length }
2555
2667
  } catch (e) { diag('buildPrevSessionPack error: ' + (e && e.message)); return null }
2556
2668
  }
2557
2669
 
@@ -2643,9 +2755,16 @@ class MemoryEngine {
2643
2755
  parts.push('【锚点表 · 记忆条目地图(按需下钻,不必通读)】' + NL + anchorSections.join(NL))
2644
2756
  }
2645
2757
  } catch (eAnchor) {}
2646
- // 接续序号:统计 handoff 目录里已有的 prev-session 包数量 +1(每次一键/自动接续产出一个)
2647
- let contSeq = 1
2648
- try { const prevs = (await readdir(p.handoffDir).catch(() => [])).filter((f) => /^prev-session-.*\.md$/.test(f)); contSeq = prevs.length + 1 } catch (eS) {}
2758
+ // 接续序号 v2(2026-09-13,NEXT-VERSION-TODO 改点2):持久计数器,全局单调(跨工作区不重号)。
2759
+ // 取值链:转写包路径已分配的(pack.contSeq)→ 包缺失/失败时用持久计数器兜底分配 → 计数器彻底不可用
2760
+ // 再退旧「文件数+1」口径 → 最后退 1。contSeq 必须有值:为空时两侧 rename 都被跳过、新会话标题
2761
+ // 退回自动生成(v2.4.2 前的老毛病),这里从源头保证非空。
2762
+ let contSeq = Number(pack && pack.contSeq) || 0
2763
+ if (!contSeq) { try { contSeq = await this.allocContSeq(p.ws) } catch (eSeq2) {} }
2764
+ if (!contSeq) {
2765
+ try { const prevs = (await readdir(p.handoffDir).catch(() => [])).filter((f) => /^prev-session-.*\.md$/.test(f)); contSeq = prevs.length + 1 } catch (eS) {}
2766
+ }
2767
+ if (!contSeq) contSeq = 1
2649
2768
  const wsBase = String((pack && pack.cwd) || p.ws || '').split(/[\\/]/).filter(Boolean).pop() || ''
2650
2769
  return {
2651
2770
  ok: true,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@a9i5k4/dsh-auto-memory",
3
3
  "description": "Proactive associative memory for DSH: zero-prompt recall injected before the model speaks, three-layer auto-consolidation, skill crystallization, and Astra-style context management - handoff ledgers, PLAN whiteboard, water-level sensing. Local-first, model-agnostic, zero deps. 主动联想记忆+Astra 式上下文管理:自动唤回/自动沉淀/技能固化/交接账本与白板跨窗口续命/水位感知。",
4
- "version": "2.4.1",
4
+ "version": "2.5.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {