@a9i5k4/dsh-auto-memory 2.5.1 → 2.5.3
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/TODO-BACKLOG.md +4 -4
- package/docs/internal/WB-GRAPH-INTEGRATION-PLAN.md +386 -0
- package/docs/internal/WB-GRAPH-RESEARCH-BRIEF.md +118 -0
- package/docs/internal/WB-GRAPH-RESEARCH-EXTERNAL.md +228 -0
- package/docs/internal/WB-GRAPH-RESEARCH-LOCAL.md +190 -0
- package/lib/client.js +29 -4
- package/lib/index.js +138 -30
- package/lib/water-window.js +186 -177
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1550,19 +1550,40 @@ class MemoryEngine {
|
|
|
1550
1550
|
// 审查修复轮2:路径解析前必须保证配置已加载 —— 否则首个工具调用会按默认 '~'(真实 homedir)
|
|
1551
1551
|
// 解析集中记忆根,造成测试/早期调用写穿真实用户记忆(本次 smoke 污染的根因)。
|
|
1552
1552
|
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
1553
|
-
let ws
|
|
1553
|
+
let ws, wsBound = false
|
|
1554
1554
|
try { ws = agent && agent.session && agent.session.header && agent.session.header.cwd } catch (e) {}
|
|
1555
|
+
if (ws) wsBound = true
|
|
1556
|
+
const sid = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
|
|
1557
|
+
// C 类回退(2026-09-13,工作区切换 bug·终端用户实证):header.cwd 缺失(如经 workspaceId 创建的
|
|
1558
|
+
// 会话)时按会话身份解析,而不是掉到全局 state.ws / process.cwd()。workspaceRegistry 的 sessionIds
|
|
1559
|
+
// 反查正是 workspaceId 绑定的权威解;持久化会话头为最后手段(带 per-sid 缓存,热路径零重复 IO)。
|
|
1560
|
+
if (!ws && sid) {
|
|
1561
|
+
const fb = await this.sessionWorkspaceFallback(sid)
|
|
1562
|
+
if (fb) { ws = fb; wsBound = true }
|
|
1563
|
+
}
|
|
1555
1564
|
if (!ws) ws = this.state.ws || process.cwd()
|
|
1556
|
-
// 无人值守模式(2026-08-26,issue 修复)
|
|
1557
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
|
|
1560
|
-
|
|
1565
|
+
// 无人值守模式(2026-08-26,issue 修复):锁定工作区防 cwd 漂移(如 subagent/工具改了 cwd),
|
|
1566
|
+
// 保持上下文路径稳定;路径漂移会导致工具调用失败/重读文件,必须杜绝。手动或自动(夜间/托管)均生效。
|
|
1567
|
+
// 2026-09-13 修正锁粒度:旧实现**全局**钉死 state.ws,一次定终身——多工作区人工切换场景下
|
|
1568
|
+
// 整个解析被冻结(终端用户实证:面板恒主目录、切换无效)。新语义=**按会话锁定**:每个会话
|
|
1569
|
+
// 首次解析出有会话身份依据(wsBound)的工作区时记入 rt.wsLocked,其后仅防**本会话内**漂移;
|
|
1570
|
+
// 未绑定(全局回退值)不锁,等本会话身份明朗;无会话身份的解析(全局刷新)沿用旧全局锁。
|
|
1571
|
+
if (this.isUnattendedNow()) {
|
|
1572
|
+
if (sid) {
|
|
1573
|
+
const rt = this.runtimeFor(agent)
|
|
1574
|
+
if (rt) {
|
|
1575
|
+
if (rt.wsLocked) { if (String(ws) !== String(rt.wsLocked)) ws = rt.wsLocked }
|
|
1576
|
+
else if (wsBound) rt.wsLocked = ws
|
|
1577
|
+
}
|
|
1578
|
+
} else if (this.state.ws && String(ws) !== String(this.state.ws)) {
|
|
1579
|
+
ws = this.state.ws
|
|
1580
|
+
}
|
|
1561
1581
|
}
|
|
1562
1582
|
const userDir = this.userDirOf()
|
|
1563
1583
|
const projectDir = this.projectDirOf(ws)
|
|
1564
1584
|
return {
|
|
1565
1585
|
ws,
|
|
1586
|
+
wsBound,
|
|
1566
1587
|
userDir,
|
|
1567
1588
|
userFile: path.join(userDir, 'MEMORY.md'),
|
|
1568
1589
|
calendarPath: path.join(userDir, 'CALENDAR.md'),
|
|
@@ -1578,6 +1599,67 @@ class MemoryEngine {
|
|
|
1578
1599
|
}
|
|
1579
1600
|
}
|
|
1580
1601
|
|
|
1602
|
+
/** C 类会话的工作区回退解析(2026-09-13,工作区切换 bug):header.cwd 缺失时按会话身份找工作区——
|
|
1603
|
+
* ①workspaceRegistry 反查(sessionIds 命中 → path;workspaceId 创建会话的权威解,接续流程同源);
|
|
1604
|
+
* ②持久化会话日志首行的 cwd(与 buildPrevSessionPack 的折叠来源一致;头帧解码防 Node 22 崩溃)。
|
|
1605
|
+
* 结果按 sid 记忆(命中长期/未命中 5 分钟),resolvePaths 是热路径,禁止每轮重复 IO。 */
|
|
1606
|
+
async sessionWorkspaceFallback(sid) {
|
|
1607
|
+
const id = String(sid || '')
|
|
1608
|
+
if (!id) return ''
|
|
1609
|
+
if (!this._wsFallbackCache) this._wsFallbackCache = new Map()
|
|
1610
|
+
const hit = this._wsFallbackCache.get(id)
|
|
1611
|
+
if (hit && (hit.v || Date.now() - hit.at < 300000)) return hit.v
|
|
1612
|
+
let out = ''
|
|
1613
|
+
try {
|
|
1614
|
+
const reg = this._ctxRef && typeof this._ctxRef.get === 'function' ? this._ctxRef.get('workspaceRegistry') : null
|
|
1615
|
+
if (reg && typeof reg.list === 'function') {
|
|
1616
|
+
const found = (reg.list() || []).find((w) => w && Array.isArray(w.sessionIds) && w.sessionIds.includes(id))
|
|
1617
|
+
if (found && found.path) out = String(found.path)
|
|
1618
|
+
}
|
|
1619
|
+
} catch (e) {}
|
|
1620
|
+
if (!out) {
|
|
1621
|
+
try {
|
|
1622
|
+
const dir = await this.locateSessionDir(id)
|
|
1623
|
+
const file = dir ? await this.resolveSessionFile(dir) : null
|
|
1624
|
+
if (file) {
|
|
1625
|
+
const buf = await readFile(file).catch(() => null)
|
|
1626
|
+
const text = buf ? (file.endsWith('.zstd') ? decodeZstdFramesHead(buf) : buf.toString('utf8')) : ''
|
|
1627
|
+
const firstLine = text.split('\n').find((l) => l.trim()) || ''
|
|
1628
|
+
try { const head = JSON.parse(firstLine); if (head && typeof head.cwd === 'string' && head.cwd) out = head.cwd } catch (e) {}
|
|
1629
|
+
}
|
|
1630
|
+
} catch (e) {}
|
|
1631
|
+
}
|
|
1632
|
+
if (this._wsFallbackCache.size > 200) this._wsFallbackCache.clear()
|
|
1633
|
+
this._wsFallbackCache.set(id, { v: out, at: Date.now() })
|
|
1634
|
+
return out
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/** 按会话解析路径(2026-09-13,工作区切换 bug):面板等「查看特定会话」的场景专用——
|
|
1638
|
+
* 活 agent 走 resolvePaths(其内部含 C 类回退与无人值守按会话锁);无活 agent(宿主重启/冷会话)
|
|
1639
|
+
* 时 registry/持久化头可直接给出工作区,不再回退到「最近活跃会话」的全局值。
|
|
1640
|
+
* wsBound=false = 本会话身份解析不出工作区(调用方应显示未取到,不得拿全局值冒充)。 */
|
|
1641
|
+
async resolvePathsForSession(sessionId) {
|
|
1642
|
+
const id = String(sessionId || '')
|
|
1643
|
+
if (!id) return { ...(await this.resolvePaths(undefined)), wsBound: true }
|
|
1644
|
+
const agent = this.agentForSessionId(id)
|
|
1645
|
+
if (agent) {
|
|
1646
|
+
const p = await this.resolvePaths(agent)
|
|
1647
|
+
return { ...p, wsBound: p.wsBound !== false }
|
|
1648
|
+
}
|
|
1649
|
+
const fb = await this.sessionWorkspaceFallback(id)
|
|
1650
|
+
if (fb) {
|
|
1651
|
+
const projectDir = this.projectDirOf(fb)
|
|
1652
|
+
return {
|
|
1653
|
+
ws: fb, wsBound: true, userDir: this.userDirOf(), projectDir,
|
|
1654
|
+
notesPath: path.join(projectDir, 'MEMORY.md'),
|
|
1655
|
+
logPath: path.join(projectDir, `${this.memToday()}.md`),
|
|
1656
|
+
handoffDir: path.join(projectDir, 'handoff'),
|
|
1657
|
+
planPath: path.join(projectDir, 'handoff', 'PLAN.md'),
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
return { ...(await this.resolvePaths(undefined)), wsBound: false }
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1581
1663
|
// ---------- M-CM1 交接白板(PLAN.md 全貌快照 + 四段式交接账本) ----------
|
|
1582
1664
|
|
|
1583
1665
|
/** 全局最近交接账本(跨工作区,新bug修复①):扫描各工作区记忆根的 handoff 目录下 handoff-*.md,取 mtime 最新一篇的绝对路径;找不到返回 ''。 */
|
|
@@ -2793,31 +2875,24 @@ class MemoryEngine {
|
|
|
2793
2875
|
|
|
2794
2876
|
/** M-CM1 白板面板数据:PLAN 当前版+归档版本列表+账本列表;fileQ 非空时返回指定文件文本(严格白名单,限 handoff 目录内)。 */
|
|
2795
2877
|
async handoffPanelData(fileQ, sessionId) {
|
|
2878
|
+
// 配置加载守卫(2026-09-13):handoffEnabled 出厂默认 false,若宿主刚重启、面板先于任何会话活动
|
|
2879
|
+
// 打开,旧实现按未加载的默认值误报「白板未启用」——与 resolvePaths 同款守卫。
|
|
2880
|
+
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
2796
2881
|
if (this.config.handoffEnabled === false) return { enabled: false }
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
}
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
if (!/^PLAN-\d{8}-\d{6}\.md$/.test(n)) continue
|
|
2809
|
-
const st = await stat(path.join(handoffDir, 'archive', n)).catch(() => null)
|
|
2810
|
-
if (st && st.isFile()) versions.push({ name: n, mtime: Number(st.mtimeMs) })
|
|
2811
|
-
}
|
|
2812
|
-
versions.sort((a, b) => b.mtime - a.mtime)
|
|
2813
|
-
const ledgers = []
|
|
2814
|
-
const names = await readdir(handoffDir).catch(() => [])
|
|
2815
|
-
for (const n of names) {
|
|
2816
|
-
if (!/^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n)) continue
|
|
2817
|
-
const st = await stat(path.join(handoffDir, n)).catch(() => null)
|
|
2818
|
-
if (st && st.isFile()) ledgers.push({ name: n, mtime: Number(st.mtimeMs) })
|
|
2882
|
+
// 2026-09-13(工作区切换 bug·终端用户实证):有 sessionId 时按会话解析路径 —— 旧实现白板/账本
|
|
2883
|
+
// 恒用全局单值 state.*(最近活跃会话),查看非活跃会话时张冠李戴;会话解析不出身份时如实返回
|
|
2884
|
+
// wsBound:false,不再拿 dsh 启动目录(process.cwd() 回退)冒充工作区。
|
|
2885
|
+
let p, wsBound = true
|
|
2886
|
+
if (sessionId) {
|
|
2887
|
+
p = await this.resolvePathsForSession(sessionId)
|
|
2888
|
+
wsBound = p.wsBound !== false
|
|
2889
|
+
} else {
|
|
2890
|
+
p = (this.state.handoffDir || this.state.planPath)
|
|
2891
|
+
? { handoffDir: this.state.handoffDir || path.dirname(this.state.planPath), planPath: this.state.planPath, ws: this.state.ws }
|
|
2892
|
+
: await this.resolvePaths(undefined)
|
|
2819
2893
|
}
|
|
2820
|
-
|
|
2894
|
+
const handoffDir = p.handoffDir
|
|
2895
|
+
const planPath = p.planPath || path.join(handoffDir, 'PLAN.md')
|
|
2821
2896
|
// 水位:优先返回「该会话」的记录(实测 → 磁盘推导);未指定 sessionId 时退回全局单值(兼容旧调用方)
|
|
2822
2897
|
let water = null
|
|
2823
2898
|
if (sessionId) {
|
|
@@ -2856,11 +2931,44 @@ class MemoryEngine {
|
|
|
2856
2931
|
live: Number(this.state.waterLevelAt) > 0,
|
|
2857
2932
|
}
|
|
2858
2933
|
}
|
|
2934
|
+
if (!wsBound) {
|
|
2935
|
+
// 会话身份解析不出工作区:白板/账本/归档一律不返回(避免跨工作区误读),水位照常(本就按会话取数);
|
|
2936
|
+
// 客户端据 wsBound:false 显示「未绑定」提示,而不是展示错误路径下的空内容。
|
|
2937
|
+
return {
|
|
2938
|
+
enabled: true, ws: '', wsBound: false, plan: '', planPath: '', planMtime: 0,
|
|
2939
|
+
planVersions: [], ledgers: [],
|
|
2940
|
+
refresh: { sessionId: '', prompt: '' },
|
|
2941
|
+
waterLevel: water,
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
if (fileQ) {
|
|
2945
|
+
const m = String(fileQ).match(/^(?:PLAN\.md|handoff-\d{8}-\d{6}(?:-[a-z])?\.md|archive\/PLAN-\d{8}-\d{6}\.md)$/)
|
|
2946
|
+
if (!m) return { enabled: true, error: 'bad file name' }
|
|
2947
|
+
const target = path.join(handoffDir, ...String(fileQ).split('/'))
|
|
2948
|
+
return { enabled: true, file: fileQ, text: await this.readTextSafe(target) }
|
|
2949
|
+
}
|
|
2950
|
+
const versions = []
|
|
2951
|
+
const archNames = await readdir(path.join(handoffDir, 'archive')).catch(() => [])
|
|
2952
|
+
for (const n of archNames) {
|
|
2953
|
+
if (!/^PLAN-\d{8}-\d{6}\.md$/.test(n)) continue
|
|
2954
|
+
const st = await stat(path.join(handoffDir, 'archive', n)).catch(() => null)
|
|
2955
|
+
if (st && st.isFile()) versions.push({ name: n, mtime: Number(st.mtimeMs) })
|
|
2956
|
+
}
|
|
2957
|
+
versions.sort((a, b) => b.mtime - a.mtime)
|
|
2958
|
+
const ledgers = []
|
|
2959
|
+
const names = await readdir(handoffDir).catch(() => [])
|
|
2960
|
+
for (const n of names) {
|
|
2961
|
+
if (!/^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n)) continue
|
|
2962
|
+
const st = await stat(path.join(handoffDir, n)).catch(() => null)
|
|
2963
|
+
if (st && st.isFile()) ledgers.push({ name: n, mtime: Number(st.mtimeMs) })
|
|
2964
|
+
}
|
|
2965
|
+
ledgers.sort((a, b) => b.mtime - a.mtime)
|
|
2859
2966
|
const planMt = await stat(planPath).then((s) => Number(s.mtimeMs)).catch(() => 0)
|
|
2860
2967
|
// 刷新仪式的会话 id:内存态为空时走磁盘回退(宿主重启后 _lastAgent 未重建的窗口期)
|
|
2861
2968
|
const sid = this.currentSessionId() || this.recentSessionIdFallback()
|
|
2862
2969
|
return {
|
|
2863
|
-
enabled: true,
|
|
2970
|
+
enabled: true, ws: p.ws || '', wsBound,
|
|
2971
|
+
planPath,
|
|
2864
2972
|
plan: await this.readTextSafe(planPath),
|
|
2865
2973
|
planMtime: planMt,
|
|
2866
2974
|
planVersions: versions.slice(0, 30),
|
package/lib/water-window.js
CHANGED
|
@@ -1,177 +1,186 @@
|
|
|
1
|
-
/** 上下文窗口解析(2.2.4,2026-09-08)。
|
|
2
|
-
*
|
|
3
|
-
* 背景:水位 = 当前上下文占用 / 模型窗口。占用取官方 tokenMeter(与聊天框 context ring 同源),
|
|
4
|
-
* 窗口此前有三条来源:手动覆盖 > 官方 request/context 的 contextWindow > settings.yaml 解析 > fallback。
|
|
5
|
-
* 实测发现两条路同时失效,导致窗口恒为 fallback 131072(真实模型 1M),水位被算成 150%+:
|
|
6
|
-
* 1) 官方 request/context 只在会话早期追加(本会话 2999 个事件里仅 2 条),而旧代码只扫最近 256 条 → 永远扫不到;
|
|
7
|
-
* 2) settings.yaml 的 llm-deepseek 段是 flow 风格 YAML(`{ models: [ { id: x, contextWindow: 1000000, ... } ] }`),
|
|
8
|
-
* 旧解析器只认 block 风格(`- id: x` + 行尾纯数字),整段解析不出来。
|
|
9
|
-
*
|
|
10
|
-
* 本模块把「settings.yaml → 模型窗口映射」与「按 provider/model 选窗口」抽成纯函数,便于 smoke 覆盖。
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* 从 settings.yaml 文本解析 `模型 id → contextWindow` 映射。
|
|
15
|
-
* 同时支持 block 风格(llm-pi-ai 那种 `- id: x` / `contextWindow: N`)与 flow 风格(llm-deepseek 那种 `id: x,` / `contextWindow: N,`)。
|
|
16
|
-
* @param {string} text - settings.yaml 原文。
|
|
17
|
-
* @returns {{byRoute:Record<string,number>,byModel:Record<string,number>}} byRoute 键为 provider/id,byModel 键为 id。
|
|
18
|
-
*/
|
|
19
|
-
export function parseModelWindowsPre(text) {
|
|
20
|
-
const byRoute = {}
|
|
21
|
-
const byModel = {}
|
|
22
|
-
if (!text || typeof text !== 'string') return { byRoute, byModel }
|
|
23
|
-
const lines = String(text).split(/\r?\n/)
|
|
24
|
-
let currentProvider = ''
|
|
25
|
-
let currentId = null
|
|
26
|
-
for (const line of lines) {
|
|
27
|
-
// provider 段(block 风格,2 空格缩进的裸键,如 `opencode-go:`);flow 风格段名同样以冒号结尾但缩进更深
|
|
28
|
-
const pm = line.match(/^ {2}([A-Za-z0-9_.-]+):\s*$/)
|
|
29
|
-
if (pm) { currentProvider = pm[1]; currentId = null; continue }
|
|
30
|
-
// 模型 id:兼容 block(`- id: x`)与 flow(`id: x,`)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
*
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (!
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return 0
|
|
177
|
-
|
|
1
|
+
/** 上下文窗口解析(2.2.4,2026-09-08)。
|
|
2
|
+
*
|
|
3
|
+
* 背景:水位 = 当前上下文占用 / 模型窗口。占用取官方 tokenMeter(与聊天框 context ring 同源),
|
|
4
|
+
* 窗口此前有三条来源:手动覆盖 > 官方 request/context 的 contextWindow > settings.yaml 解析 > fallback。
|
|
5
|
+
* 实测发现两条路同时失效,导致窗口恒为 fallback 131072(真实模型 1M),水位被算成 150%+:
|
|
6
|
+
* 1) 官方 request/context 只在会话早期追加(本会话 2999 个事件里仅 2 条),而旧代码只扫最近 256 条 → 永远扫不到;
|
|
7
|
+
* 2) settings.yaml 的 llm-deepseek 段是 flow 风格 YAML(`{ models: [ { id: x, contextWindow: 1000000, ... } ] }`),
|
|
8
|
+
* 旧解析器只认 block 风格(`- id: x` + 行尾纯数字),整段解析不出来。
|
|
9
|
+
*
|
|
10
|
+
* 本模块把「settings.yaml → 模型窗口映射」与「按 provider/model 选窗口」抽成纯函数,便于 smoke 覆盖。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 从 settings.yaml 文本解析 `模型 id → contextWindow` 映射。
|
|
15
|
+
* 同时支持 block 风格(llm-pi-ai 那种 `- id: x` / `contextWindow: N`)与 flow 风格(llm-deepseek 那种 `id: x,` / `contextWindow: N,`)。
|
|
16
|
+
* @param {string} text - settings.yaml 原文。
|
|
17
|
+
* @returns {{byRoute:Record<string,number>,byModel:Record<string,number>}} byRoute 键为 provider/id,byModel 键为 id。
|
|
18
|
+
*/
|
|
19
|
+
export function parseModelWindowsPre(text) {
|
|
20
|
+
const byRoute = {}
|
|
21
|
+
const byModel = {}
|
|
22
|
+
if (!text || typeof text !== 'string') return { byRoute, byModel }
|
|
23
|
+
const lines = String(text).split(/\r?\n/)
|
|
24
|
+
let currentProvider = ''
|
|
25
|
+
let currentId = null
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
// provider 段(block 风格,2 空格缩进的裸键,如 `opencode-go:`);flow 风格段名同样以冒号结尾但缩进更深
|
|
28
|
+
const pm = line.match(/^ {2}([A-Za-z0-9_.-]+):\s*$/)
|
|
29
|
+
if (pm) { currentProvider = pm[1]; currentId = null; continue }
|
|
30
|
+
// 模型 id:兼容 block(`- id: x`)与 flow(`id: x,`)两种写法,允许行尾逗号。
|
|
31
|
+
// 字符类须含 `/`:provider 前缀式 id(`deepseek/deepseek-v4.1-flash`、`z-ai/glm-5.3-flash`、
|
|
32
|
+
// `meituan/LongCat-2.0:free`)是常见形态,旧字符类 `[A-Za-z0-9._:-]` 不含斜杠 →
|
|
33
|
+
// 整条 id 行匹配失败,其 contextWindow 被静默丢弃,窗口退化为 fallback 131072。
|
|
34
|
+
// 实测:真实窗口 1,000,000 的会话被按 131,072 当分母,水位放大 7.63 倍(12.8% 显示成 98%),
|
|
35
|
+
// 未达 0.75 阈值即误弹「接续到新会话」确认卡。
|
|
36
|
+
const im = line.match(/^\s*(?:-\s*)?id:\s*([A-Za-z0-9._:/-]+?)\s*,?\s*$/)
|
|
37
|
+
if (im) { currentId = im[1]; if (byModel[currentId] === undefined) byModel[currentId] = 0; continue }
|
|
38
|
+
// id 行存在但格式仍不被识别(带引号/含空格/中文等):重置 currentId,避免紧随其后的
|
|
39
|
+
// contextWindow 被记到上一条模型头上 —— 静默错配比"读不到"更危险:读不到只退化为 fallback,
|
|
40
|
+
// 错配会给出**偏小**的窗口,使水位漏报。
|
|
41
|
+
if (/^\s*(?:-\s*)?id:/.test(line)) { currentId = null; continue }
|
|
42
|
+
// 窗口:允许行尾逗号(flow 风格)
|
|
43
|
+
const cm = line.match(/^\s*contextWindow:\s*([0-9]+)\s*,?\s*$/)
|
|
44
|
+
if (cm && currentId) {
|
|
45
|
+
const value = Number(cm[1])
|
|
46
|
+
const route = currentProvider + '/' + currentId
|
|
47
|
+
if (byRoute[route] === undefined) byRoute[route] = value
|
|
48
|
+
if (!byModel[currentId]) byModel[currentId] = value
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { byRoute, byModel }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 按 provider/model 选窗口:先精确匹配 provider/model,再按 model 名全局兜底。
|
|
56
|
+
* @param {{byRoute:Record<string,number>,byModel:Record<string,number>}} windows - parseModelWindowsPre 结果。
|
|
57
|
+
* @param {string} provider - 路由 provider(可空)。
|
|
58
|
+
* @param {string} model - 模型 id。
|
|
59
|
+
* @returns {number} 窗口 token 数;未命中返回 0。
|
|
60
|
+
*/
|
|
61
|
+
export function pickWindowPre(windows, provider, model) {
|
|
62
|
+
if (!windows || !model) return 0
|
|
63
|
+
const byRoute = windows.byRoute || {}
|
|
64
|
+
const byModel = windows.byModel || {}
|
|
65
|
+
const exact = byRoute[(provider || '') + '/' + model]
|
|
66
|
+
if (Number.isFinite(exact) && exact > 0) return exact
|
|
67
|
+
const direct = byModel[model]
|
|
68
|
+
if (Number.isFinite(direct) && direct > 0) return direct
|
|
69
|
+
// 后缀兜底:路由 id 可能带 provider 前缀或版本后缀差异
|
|
70
|
+
for (const route of Object.keys(byRoute)) {
|
|
71
|
+
if (route.endsWith('/' + model) && byRoute[route] > 0) return byRoute[route]
|
|
72
|
+
}
|
|
73
|
+
for (const id of Object.keys(byModel)) {
|
|
74
|
+
if (id.endsWith('-' + model) && byModel[id] > 0) return byModel[id]
|
|
75
|
+
}
|
|
76
|
+
return 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 从会话事件里取最近一条 request/header|request/context 的真实 provider/model(+ 顺带 contextWindow)。
|
|
81
|
+
*
|
|
82
|
+
* 背景(2026-09-08 用户实测):水位卡此前把「窗口来源」标成 settings.yaml 的 agent-default-model(新会话默认模型),
|
|
83
|
+
* 用户在 GUI 里切过默认模型后,当前会话实际跑的模型与卡片显示不符
|
|
84
|
+
* (实测:会话日志 request/header = deepseek-official/deepseek-v4.1-flash-expires-on-0910,卡片却写 opencode-go/deepseek-v4-flash)。
|
|
85
|
+
* 会话自己的请求头才是「这个会话正在用哪个模型」的权威来源;默认模型只作兜底。
|
|
86
|
+
*
|
|
87
|
+
* @param {object[]} events - 会话事件数组。
|
|
88
|
+
* @param {number} maxScan - 最多向前扫多少条(默认全部)。
|
|
89
|
+
* @returns {{provider:string,model:string,contextWindow:number}} 未找到时字段为空/0。
|
|
90
|
+
*/
|
|
91
|
+
export function findSessionModelPre(events, maxScan = 0) {
|
|
92
|
+
const out = { provider: '', model: '', contextWindow: 0, maxTokens: 0 }
|
|
93
|
+
if (!Array.isArray(events) || !events.length) return out
|
|
94
|
+
const floor = maxScan > 0 ? Math.max(0, events.length - maxScan) : 0
|
|
95
|
+
for (let i = events.length - 1; i >= floor; i--) {
|
|
96
|
+
const ev = events[i]
|
|
97
|
+
const t = ev && ev.type
|
|
98
|
+
if (t !== 'request/header' && t !== 'request/context') continue
|
|
99
|
+
const d = (ev && ev.data) || {}
|
|
100
|
+
const cfg = (d.header && d.header.config) || d.config || {}
|
|
101
|
+
const provider = String(cfg.provider || d.provider || '')
|
|
102
|
+
const model = String(cfg.model || d.model || '')
|
|
103
|
+
const cw = Number(d.contextWindow) || 0
|
|
104
|
+
const mt = Number(cfg.maxTokens) || 0
|
|
105
|
+
if (!out.model && model) { out.model = model; out.provider = provider }
|
|
106
|
+
if (!out.contextWindow && cw > 0) out.contextWindow = cw
|
|
107
|
+
// maxTokens = 路由为**本次请求预留的输出预算**,provider 会把它计入 "requested tokens"(实测:
|
|
108
|
+
// 消息 666,044 + 预留 384,000 = 1,050,044 > 上限 1,048,576 → 400 CONTEXT_WINDOW_EXCEEDED)。
|
|
109
|
+
if (!out.maxTokens && mt > 0) out.maxTokens = mt
|
|
110
|
+
if (out.model && out.contextWindow && out.maxTokens) break
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 水位「硬信号」扫描(2026-09-10,实机取证后新增)。
|
|
117
|
+
*
|
|
118
|
+
* 背景(用户实测:官方压缩又抢在 75% 接续之前):本机 provider 的硬上限是 1,048,576 token,
|
|
119
|
+
* 而**预留输出预算 384,000 也算在请求里** ⇒ 消息实际只能用到约 664,576。插件此前拿
|
|
120
|
+
* `contextWindow`(1,000,000)当分母、拿本地计量当分子,两边都偏乐观,于是 75% 永远到不了,
|
|
121
|
+
* 直到上游直接 400:
|
|
122
|
+
* "This model's maximum context length is 1048576 tokens. However, you requested 1050044 tokens
|
|
123
|
+
* (666044 in the messages, 384000 in the completion)."
|
|
124
|
+
* 这条错误是**唯一权威**的口径来源。本函数把它与 compaction 事件一起扫出来,供上层:
|
|
125
|
+
* ① 解析真实窗口/预留额度/消息实占 → 自我校准分子;
|
|
126
|
+
* ② 一旦发生 overflow 或 compaction,直接当作「已到阈值」触发接续(不再依赖估算)。
|
|
127
|
+
*
|
|
128
|
+
* @param {object[]} events - 会话事件数组。
|
|
129
|
+
* @returns {{reservedTokens:number,overflow:{seq:number,windowTokens:number,requestedTokens:number,messageTokens:number,completionTokens:number,code:string}|null,compactionSeq:number}}
|
|
130
|
+
*/
|
|
131
|
+
export function scanPressureSignalsPre(events) {
|
|
132
|
+
const out = { reservedTokens: 0, overflow: null, compactionSeq: 0 }
|
|
133
|
+
if (!Array.isArray(events) || !events.length) return out
|
|
134
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
135
|
+
const ev = events[i]
|
|
136
|
+
if (!ev) continue
|
|
137
|
+
const seq = Number(ev.seq) || 0
|
|
138
|
+
const t = ev.type
|
|
139
|
+
if (!out.reservedTokens && (t === 'request/header' || t === 'request/context')) {
|
|
140
|
+
const d = (ev.data || {})
|
|
141
|
+
const cfg = (d.header && d.header.config) || d.config || {}
|
|
142
|
+
const mt = Number(cfg.maxTokens) || 0
|
|
143
|
+
if (mt > 0) out.reservedTokens = mt
|
|
144
|
+
}
|
|
145
|
+
if (!out.compactionSeq && (t === 'compaction/start' || t === 'compaction/summary')) out.compactionSeq = seq
|
|
146
|
+
if (out.overflow && out.compactionSeq && out.reservedTokens) break
|
|
147
|
+
if (out.overflow || t !== 'assistant/attempt') continue
|
|
148
|
+
const stream = (ev.data && ev.data.stream) || []
|
|
149
|
+
for (const item of stream) {
|
|
150
|
+
const chunk = (item && item.chunk) || {}
|
|
151
|
+
if (chunk.type !== 'finish') continue
|
|
152
|
+
const failure = (chunk.reason && chunk.reason.failure) || {}
|
|
153
|
+
const code = String(failure.code || '')
|
|
154
|
+
const msg = String(failure.message || '')
|
|
155
|
+
if (code !== 'CONTEXT_WINDOW_EXCEEDED' && !/maximum context length/i.test(msg)) continue
|
|
156
|
+
const win = Number((msg.match(/maximum context length is\s+(\d+)\s+tokens/i) || [])[1]) || 0
|
|
157
|
+
const req = Number((msg.match(/you requested\s+(\d+)\s+tokens/i) || [])[1]) || 0
|
|
158
|
+
const inMsg = Number((msg.match(/\((\d+)\s+in the messages/i) || [])[1]) || 0
|
|
159
|
+
const inComp = Number((msg.match(/(\d+)\s+in the completion\)/i) || [])[1]) || 0
|
|
160
|
+
out.overflow = { seq, windowTokens: win, requestedTokens: req, messageTokens: inMsg, completionTokens: inComp, code: code || 'CONTEXT_WINDOW_EXCEEDED' }
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 从会话事件里取最近一条 request/context 的 contextWindow(路由权威值)。
|
|
170
|
+
* 不限制扫描窗口:该事件只在请求头部变化时追加,长会话里往往只存在于开头。
|
|
171
|
+
* @param {object[]} events - 会话事件数组。
|
|
172
|
+
* @param {number} maxScan - 最多向前扫多少条(默认全部;设小值可限制开销)。
|
|
173
|
+
* @returns {number} contextWindow;未找到返回 0。
|
|
174
|
+
*/
|
|
175
|
+
export function findOfficialContextWindowPre(events, maxScan = 0) {
|
|
176
|
+
if (!Array.isArray(events) || !events.length) return 0
|
|
177
|
+
const floor = maxScan > 0 ? Math.max(0, events.length - maxScan) : 0
|
|
178
|
+
for (let i = events.length - 1; i >= floor; i--) {
|
|
179
|
+
const ev = events[i]
|
|
180
|
+
if (ev && ev.type === 'request/context') {
|
|
181
|
+
const cw = Number(ev && ev.data && ev.data.contextWindow)
|
|
182
|
+
return Number.isFinite(cw) && cw > 0 ? cw : 0
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return 0
|
|
186
|
+
}
|
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.5.
|
|
4
|
+
"version": "2.5.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|