@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/docs/internal/GROUP-DIGEST-SETUP.md +62 -0
- package/docs/internal/GROUP-LISTENER-SETUP.md +49 -0
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +60 -0
- package/docs/internal/HANDOFF-TO-ZCODE.md +168 -0
- package/docs/internal/NEXT-VERSION-TODO.md +95 -0
- package/docs/internal/RELEASE-PROCESS.md +99 -0
- package/docs/internal/TODO-BACKLOG.md +137 -0
- package/docs/prompts/DOCS-AUDIT-AGENT.md +32 -0
- package/docs/prompts/README.md +4 -0
- package/docs/prompts/REGRESSION-AGENT.md +42 -0
- package/docs/prompts/RELEASE-AGENT.md +119 -0
- package/docs/prompts/TRACE-PATROL-AGENT.md +29 -0
- package/lib/client.js +30 -8
- package/lib/index.js +141 -22
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -266,8 +266,11 @@ const DEFAULT_CONFIG = {
|
|
|
266
266
|
waterLevelAdvisory: true,
|
|
267
267
|
/** 水位越阈时自动写一篇系统骨架账本(每会话一次;模型仍应自己写正式交接)。 */
|
|
268
268
|
waterLevelAutoHandoff: true,
|
|
269
|
-
/** 自动接续(
|
|
270
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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-
|
|
1965
|
-
//
|
|
1966
|
-
//
|
|
1967
|
-
this.state.waterLevelRing = (
|
|
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:
|
|
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 + '/' +
|
|
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
|
-
|
|
2551
|
-
|
|
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
|
-
//
|
|
2647
|
-
|
|
2648
|
-
|
|
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
|
+
"version": "2.5.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|