@a9i5k4/dsh-auto-memory 2.4.0 → 2.4.2

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,12 @@ 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 改为默认关。原因:现有触发口径把「预留输出」从分母里扣掉(effectiveWin = win − reserve),
271
+ // 实测在本机路由下「上下文刚过半」就触发,与官方「约 80% 才压缩」相差约 45%,对公开用户是纯浪费。
272
+ // 待口径改为与官方压缩同坐标系后再评估是否翻回(见 docs/internal/NEXT-VERSION-TODO.md)。
273
+ // 注:老用户已落盘的显式值不受影响,只有新装用户吃这个默认。
274
+ autoContinueEnabled: false,
271
275
  /** 自动接续水位阈值(0.5-0.95);2026-09-08 与 waterLevelThreshold 同步下调到 0.75(官方自动压缩阈值 80%,必须留余量)。 */
272
276
  autoContinueThreshold: 0.75,
273
277
  /** 确认卡倒计时秒数(30-40s 无操作 = 挂机 → 自动接续兜底)。 */
@@ -1947,6 +1951,11 @@ class MemoryEngine {
1947
1951
  const armRatio = hard ? Math.max(ratio, threshold) : ratio
1948
1952
  rt.waterLevel = armRatio
1949
1953
  rt.waterLevelTokens = estTokens
1954
+ // 2026-09-10:pre-step 的 arm 走的是 runtime 字段(见 checkWaterLevelAtStep → armAutoContinue),
1955
+ // 这里只写了 ratio/tokens、漏写 window/source,于是确认卡显示「512,311 / 0 token」(实测)。
1956
+ // 与 state 同源同值,一处漏写两处都缺 —— 必须成对维护。
1957
+ rt.waterLevelWindow = effectiveWin
1958
+ rt.waterLevelSource = winSource
1950
1959
  this.state.waterLevelRatio = armRatio
1951
1960
  this.state.waterLevelTokens = estTokens
1952
1961
  this.state.waterLevelWindow = effectiveWin
@@ -1956,6 +1965,14 @@ class MemoryEngine {
1956
1965
  this.state.waterLevelAt = Date.now()
1957
1966
  this.state.waterLevelMeasuredTokens = estTokens
1958
1967
  this.state.waterLevelReserve = reserve
1968
+ // 双口径(2026-09-10):官方小圈的分母是**声明窗口**(win),我们的分母是**可用额度**(effectiveWin),
1969
+ // 两者在本机差 384,000(预留输出)。用户看到「才 50% 就接续」正是因为只看到小圈那个数 ——
1970
+ // 把 ring 一并上报,界面就能同时显示「占可用额度 x% · 官方小圈 y%」,不再各说各话。
1971
+ this.state.waterLevelRing = (win > 0 && Number.isFinite(estTokens)) ? (estTokens / win) : 0
1972
+ // provider 自报的硬上限只有撞过墙(400 文本)才拿得到;拿得到就把「真实可写上限」也算出来,
1973
+ // 让界面能显示「距硬墙还剩多少 token」(本机 = 1,048,576 − 384,000 = 664,576)。
1974
+ const hardWin = Number(sig.overflow && sig.overflow.windowTokens) || 0
1975
+ this.state.waterLevelWall = hardWin > 0 ? Math.max(0, hardWin - reserve) : 0
1959
1976
  this.state.waterLevelHardTrigger = hard ? (compacted ? 'compaction' : 'overflow') : ''
1960
1977
  // 会话级记录:切会话时 /handoff-state?sessionId= 直接取这份,不再显示别的会话的数
1961
1978
  this.rememberWaterRecord(agent && agent.session && agent.session.id ? String(agent.session.id) : '', {
@@ -2027,6 +2044,54 @@ class MemoryEngine {
2027
2044
  } catch (e) { diag('checkWaterLevelAtStep error: ' + (e && e.message)) }
2028
2045
  }
2029
2046
 
2047
+ /** 已接续闩锁(2026-09-10):同一个会话**只允许被接续一次**。
2048
+ * 旧实现只有 30 分钟冷却:冷却一过,用户切回那个旧会话、水位仍然高 → 再次 arm → 再次建会话,
2049
+ * 表现为「切回旧窗口,它还想接续」(实测用户报障)。闩锁以会话 id 为键、落盘到
2050
+ * ~/.dsh/memory/auto-continue-done.json(重启后依旧生效),接续**失败不落闩**(允许重试)。 */
2051
+ continuedSessionsFile() { return path.join(dshHome(), 'memory', 'auto-continue-done.json') }
2052
+
2053
+ loadContinuedSessions() {
2054
+ if (this._continuedSessions instanceof Set) return this._continuedSessions
2055
+ const set = new Set()
2056
+ try {
2057
+ const obj = JSON.parse(readFileSync(this.continuedSessionsFile(), 'utf8'))
2058
+ const arr = Array.isArray(obj) ? obj : ((obj && Array.isArray(obj.sessions)) ? obj.sessions : [])
2059
+ for (const it of arr) {
2060
+ const k = this.waterKey(typeof it === 'string' ? it : ((it && it.from) || ''))
2061
+ if (k) set.add(k)
2062
+ }
2063
+ } catch (e) {}
2064
+ this._continuedSessions = set
2065
+ return set
2066
+ }
2067
+
2068
+ isContinuedSession(sid) {
2069
+ const k = this.waterKey(sid)
2070
+ return k ? this.loadContinuedSessions().has(k) : false
2071
+ }
2072
+
2073
+ markContinuedSession(sid, toSid) {
2074
+ const k = this.waterKey(sid)
2075
+ if (!k) return false
2076
+ const set = this.loadContinuedSessions()
2077
+ if (set.has(k)) return false
2078
+ set.add(k)
2079
+ try {
2080
+ const file = this.continuedSessionsFile()
2081
+ mkdirSync(path.dirname(file), { recursive: true })
2082
+ let arr = []
2083
+ try {
2084
+ const obj = JSON.parse(readFileSync(file, 'utf8'))
2085
+ arr = Array.isArray(obj) ? obj : ((obj && Array.isArray(obj.sessions)) ? obj.sessions : [])
2086
+ } catch (e0) {}
2087
+ arr.push({ from: k, to: this.waterKey(toSid), at: Date.now() })
2088
+ if (arr.length > 200) arr = arr.slice(-200)
2089
+ writeFileSync(file, JSON.stringify({ sessions: arr, updatedAt: Date.now() }, null, 2), 'utf8')
2090
+ } catch (e) { diag('markContinuedSession write failed: ' + ((e && e.message) || e)) }
2091
+ diag('auto-continue latch: session ' + k + ' marked continued' + (toSid ? ' -> ' + this.waterKey(toSid) : ''))
2092
+ return true
2093
+ }
2094
+
2030
2095
  /** M-CM6-C·宿主兜底自动接续(2026-09-08,2.2.6):浏览器端 AutoContinueHost 被后台节流/关闭时,
2031
2096
  * 宿主自身在倒计时到期后直接建新会话。协议:turn-stopping 且水位≥阈值 → arm 倒计时(confirmSeconds,
2032
2097
  * 默认 35s,写入 _autoContState)→ 浏览器轮询 GET auto-continue-state 显示确认卡(同意=POST decide
@@ -2043,6 +2108,8 @@ class MemoryEngine {
2043
2108
  const cooldownMin = Number(this.config.autoContinueCooldownMinutes) || 30
2044
2109
  if (st.lastRunAt && now - st.lastRunAt < cooldownMin * 60000) return
2045
2110
  const sid = String(agent.session.id || agent.session.header?.id || '')
2111
+ // 已接续闩锁(2026-09-10):该会话已经从它接续过一次 → 永远不再 arm(与 30 分钟冷却无关)
2112
+ if (this.isContinuedSession(sid)) return
2046
2113
  // 拒绝语义:时间窗 10 分钟内不再 arm(近似客户端「拒绝=跳过本边界」;窗口过后按新边界重新评估)
2047
2114
  if (st.rejectedEdgeAt && now - st.rejectedEdgeAt < 10 * 60 * 1000) return
2048
2115
  if (st.armed && Date.now() < st.armed.expiresAt) return // 已有进行中的倒计时
@@ -2057,6 +2124,10 @@ class MemoryEngine {
2057
2124
  tokens: Number(wl.tokens) || 0,
2058
2125
  window: Number(wl.window) || 0,
2059
2126
  source: String(wl.source || ''),
2127
+ // 双口径(2026-09-10):小圈读数(声明窗口为分母)与距硬墙剩余,供确认卡显示
2128
+ // (state 可能尚未建立 —— 例如测试夹具或极早的 arm,取不到就不显示,不得因此让 arm 失败)
2129
+ ring: Number(this.state && this.state.waterLevelRing) || 0,
2130
+ wall: Number(this.state && this.state.waterLevelWall) || 0,
2060
2131
  // 2.2.7:pre-step 侧 arm 的倒计时到期时,若回合仍活跃则推迟(避免打断进行中的对话)
2061
2132
  awaitIdle: !!(opts && opts.awaitIdle),
2062
2133
  deferCount: 0,
@@ -2112,6 +2183,13 @@ class MemoryEngine {
2112
2183
  })
2113
2184
  const newId = String((created && (created.sessionId || created.id)) || '')
2114
2185
  if (!newId) throw new Error('create returned no sessionId')
2186
+ // 接续序号标题(2026-09-10):浏览器路径一直有(「接续 #N · 工作区」),宿主兜底路径漏了 ——
2187
+ // 兜底接续出来的会话在侧栏是无名会话,用户无法一眼看出它是「延续会话」还是新开的
2188
+ // (实测 18:07 建出的 session-9cc01f76 日志里根本没有 session/title 事件)。
2189
+ if (d.contSeq && typeof sc.rename === 'function') {
2190
+ const title = '接续 #' + String(d.contSeq) + (d.wsBase ? ' · ' + d.wsBase : '')
2191
+ try { await sc.rename({ sessionId: newId, title }) } catch (eT) { diag('host auto-continue rename: ' + ((eT && eT.message) || eT)) }
2192
+ }
2115
2193
  if (d.provider && d.model && typeof sc.selectModel === 'function') {
2116
2194
  try { await sc.selectModel({ sessionId: newId, provider: d.provider, model: d.model, ...(d.reasoningEffort ? { reasoningEffort: d.reasoningEffort } : {}) }) } catch (eM) { diag('host auto-continue selectModel: ' + (eM && eM.message)) }
2117
2195
  }
@@ -2130,6 +2208,8 @@ class MemoryEngine {
2130
2208
  // 旧会话优先按 id 从 agents 注册表取(_lastAgent 重启后可能为空),fail-soft 不影响接续结果。
2131
2209
  const perm = await this.inheritPermissionForContinue(oldSid, newId)
2132
2210
  const ritualNote = (ritual && ritual.ok) ? 'updated' : ((ritual && ritual.reason) || 'skipped')
2211
+ // 落闩:旧会话已接续过,此后不再被 arm(即使 30 分钟冷却已过)
2212
+ try { this.markContinuedSession(oldSid, newId) } catch (eL) {}
2133
2213
  st.lastRunAt = Date.now()
2134
2214
  st.lastOk = { sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', workspaceId: d.workspaceId || '', permissionPreset: (perm && perm.ok && perm.preset) ? perm.preset : '', refreshRitual: ritualNote }
2135
2215
  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)
@@ -2153,26 +2233,34 @@ class MemoryEngine {
2153
2233
  }
2154
2234
  }
2155
2235
 
2156
- /** 浏览器轮询视图:armed 倒计时 / 最近一次执行结果 / 拒绝边界 / 错误。 */
2157
- autoContinueState() {
2236
+ /** 浏览器轮询视图:armed 倒计时 / 最近一次执行结果 / 拒绝边界 / 错误。
2237
+ * selfSid(2026-09-10):本状态是**全局单值**,而 armed 只属于某一个会话 —— 不过滤的话,
2238
+ * 别的会话的窗口也会弹「本会话水位已达 x%」(实测:用户切回旧窗口看到属于它的卡片后就以为清不掉)。
2239
+ * 传了 selfSid 且不匹配 → armed 视为 null(取不到 selfSid 时不过滤,fail-open,卡片照常显示)。 */
2240
+ autoContinueState(selfSid) {
2158
2241
  const st = this._autoContState || (this._autoContState = {})
2159
- const armed = st.armed ? {
2242
+ const selfKey = this.waterKey(selfSid || '')
2243
+ const armedRaw = st.armed ? {
2160
2244
  edgeAt: st.armed.edgeAt,
2161
2245
  sessionId: st.armed.sessionId,
2162
2246
  ratio: st.armed.ratio,
2163
2247
  tokens: st.armed.tokens,
2164
2248
  window: st.armed.window,
2249
+ ring: Number(st.armed.ring) || 0,
2250
+ wall: Number(st.armed.wall) || 0,
2165
2251
  expiresAt: st.armed.expiresAt,
2166
2252
  leftMs: Math.max(0, st.armed.expiresAt - Date.now()),
2167
2253
  } : null
2254
+ const armedMine = !selfKey || !armedRaw || !armedRaw.sessionId || this.waterKey(armedRaw.sessionId) === selfKey
2168
2255
  return {
2169
2256
  enabled: this.config.autoContinueEnabled !== false && this.config.handoffEnabled !== false,
2170
2257
  threshold: Number(this.config.autoContinueThreshold) || 0.75,
2171
- armed,
2258
+ armed: armedMine ? armedRaw : null,
2172
2259
  executing: !!st.executing,
2173
2260
  rejectedEdgeAt: st.rejectedEdgeAt || 0,
2174
2261
  lastRunAt: st.lastRunAt || 0,
2175
- lastOk: st.lastOk || null,
2262
+ // at:客户端据此给「已完成」提示加 10 分钟有效期 —— 旧实现没有时间戳,提示会一直挂在所有窗口上
2263
+ lastOk: st.lastOk ? { sessionId: st.lastOk.sessionId, model: st.lastOk.model, reasoningEffort: st.lastOk.reasoningEffort, workspaceId: st.lastOk.workspaceId, permissionPreset: st.lastOk.permissionPreset, refreshRitual: st.lastOk.refreshRitual, at: st.lastRunAt || 0 } : null,
2176
2264
  error: st.error || '',
2177
2265
  }
2178
2266
  }
@@ -7198,6 +7286,11 @@ export function apply(ctx, config) {
7198
7286
  try {
7199
7287
  const body = await readJsonBody(req).catch(() => ({}))
7200
7288
  const r = await engine.inheritPermissionForContinue(body && body.fromSessionId, body && body.toSessionId, { attempts: (body && body.attempts) || undefined })
7289
+ // 落闩(2026-09-10):本端点由 client 在建好新会话后回调,带两个 sid → 正是「接续已发生」的事实点。
7290
+ // 放在这里而不是 handoff-continue(取材料,可能只是预览)是为了只在**真建成**时才关掉旧会话的自动接续。
7291
+ if (body && body.fromSessionId && body.toSessionId) {
7292
+ try { engine.markContinuedSession(body.fromSessionId, body.toSessionId) } catch (eL) {}
7293
+ }
7201
7294
  return writeJson(res, 200, r || { ok: false, reason: 'no result' })
7202
7295
  } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
7203
7296
  },
@@ -7208,7 +7301,10 @@ export function apply(ctx, config) {
7208
7301
  handler: async (req, res) => {
7209
7302
  // M-CM6-C·宿主兜底状态(GET;loopback):浏览器 AutoContinueHost 轮询此端点展示确认卡/倒计时/执行结果。
7210
7303
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
7211
- try { return writeJson(res, 200, engine.autoContinueState()) } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
7304
+ try {
7305
+ const url = new URL(req.url, 'http://127.0.0.1')
7306
+ return writeJson(res, 200, engine.autoContinueState(url.searchParams.get('sessionId')))
7307
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
7212
7308
  },
7213
7309
  },
7214
7310
  {
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.0",
4
+ "version": "2.4.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {