dsh-memory-tiers 0.8.1 → 0.8.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/compaction.mjs CHANGED
@@ -1,184 +1,184 @@
1
- /**
2
- * 历史压缩:把 dsh 自带的会话历史压成一条极短占位,内容归档进记忆系统。
3
- *
4
- * 为什么不接管 ctx.compaction:
5
- * dsh 的 CompactionEngine 要求凑齐 compaction/start + summary + end 三件套,
6
- * 还要实现选区、tool 配对、并发锁、失败回滚;而它的摘要必须调 LLM(既慢又要钱)。
7
- * 这里改成:把 compaction-basic 的阈值调到永不触发(thresholdRatio 0.99),
8
- * 由插件自己在 turn 结束时直接做 surface 替换 —— 零 LLM、逻辑完全可控。
9
- *
10
- * 关键约束(踩过的坑):
11
- * - assistant/message 不能带 sourceEventSeqs(它内嵌 provider stream),
12
- * 所以替换节点只能用 user/message。
13
- * - 选区必须结束在 user/message 之前,否则会把 tool/call 和 tool/result 切开。
14
- * - startSeq/endSeq 是 surface 位置,不是数值区间(多次替换后高位 seq 会落到老位置)。
15
- */
16
-
17
- /** 从一个事件里抽出可读文本(只取 text 块,reasoning 是草稿不进记忆) */
18
- function eventText(ev) {
19
- const msg = ev?.data?.message ?? ev?.data
20
- const blocks = msg?.content
21
- if (!Array.isArray(blocks)) return ''
22
- const parts = []
23
- for (const b of blocks) {
24
- if (!b) continue
25
- if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text)
26
- else if (b.type === 'tool_call' || b.type === 'tool-call') {
27
- const n = b.name || b.toolName || 'tool'
28
- const a = typeof b.arguments === 'string' ? b.arguments : JSON.stringify(b.arguments ?? '')
29
- parts.push(`[调用 ${n} ${String(a).slice(0, 80)}]`)
30
- }
31
- }
32
- return parts.join(' ').replace(/\s+/g, ' ').trim()
33
- }
34
-
35
- /** 事件所属角色(用于归档时打标签) */
36
- function eventRole(ev) {
37
- if (ev.type === 'user/message') return 'user'
38
- if (ev.type === 'assistant/message') return 'bot'
39
- if (ev.type === 'tool/result') return 'tool'
40
- return 'sys'
41
- }
42
-
43
- /**
44
- * 把 dsh 可见历史压成一条占位。
45
- *
46
- * @param {object} session dsh 的 Session 实例(有 surface / eventAt / append)
47
- * @param {object} mem createMemory5 的实例(只用到 writeMany)
48
- * @param {object} opts
49
- * keepTurns 保留最近几轮原文(按 user/message 计数),默认 1
50
- * archiveChars 单次归档最多写多少字,默认 1200
51
- * log 日志
52
- * @returns {Promise<{compacted:boolean, removed:number, archived:number, reason?:string}>}
53
- */
54
- export async function compactSurface(session, mem, opts = {}) {
55
- const keepTurns = Math.max(0, Number(opts.keepTurns ?? 1))
56
- const archiveChars = Math.max(100, Number(opts.archiveChars ?? 1200))
57
- // ★ 归档开关:autoCapture 已经把这一轮(问+答+工具)写进 L1 了,
58
- // 这里再归档一次会把同一轮存两遍。所以调用方在"rows 已写成功"时传 archive:false,
59
- // 只做"把历史换成占位",不再抄一份进记忆。
60
- const doArchive = opts.archive !== false
61
- const log = opts.log || console
62
-
63
- const nodes = session?.surface?.nodes
64
- if (!Array.isArray(nodes) || nodes.length < 2) {
65
- return { compacted: false, removed: 0, archived: 0, reason: 'empty' }
66
- }
67
-
68
- // ★ 选区起点必须跳过开头的 system/message。
69
- // dsh 会拒绝:「node 0 holds the system prompt and may be rewritten only by
70
- // a system/message over exactly that node」。系统提示词不属于可压缩范围。
71
- let first = 0
72
- while (first < nodes.length && session.eventAt(nodes[first])?.type === 'system/message') first++
73
- if (first >= nodes.length - 1) {
74
- return { compacted: false, removed: 0, archived: 0, reason: 'only-system' }
75
- }
76
-
77
- // 找所有「真实用户轮次」在 surface 里的位置。
78
- // ★ user/message 有四种来源,只有 source.kind === 'user' 是真人在说话:
79
- // user ← 真人
80
- // runtime-context ← 运行时快照(工作目录、沙箱策略…)
81
- // skill-catalog ← 技能清单
82
- // time-context ← 时间快照
83
- // compaction ← 我们自己生成的占位
84
- // 把后四种算成"轮次"会让 keepTurns 完全失真(实测 6 轮被算成 13 轮)。
85
- const userIdx = []
86
- for (let i = first; i < nodes.length; i++) {
87
- const ev = session.eventAt(nodes[i])
88
- if (ev?.type !== 'user/message') continue
89
- const src = (ev.data?.message ?? ev.data)?.source
90
- if (src?.kind !== 'user') continue
91
- userIdx.push(i)
92
- }
93
-
94
- // 要留 keepTurns 轮 → 至少要 keepTurns+1 个 user 才有东西可压
95
- if (userIdx.length <= keepTurns) {
96
- return { compacted: false, removed: 0, archived: 0, reason: 'nothing-to-compact' }
97
- }
98
-
99
- // 选区 = [first, keepStart),keepStart 是「保留的第一轮」的 user 位置
100
- // ★ keepTurns === 0 是**受支持的显式选项**:整段历史全压成占位,dsh 侧一条原文不留。
101
- // 以前靠 `userIdx[length - 0] === undefined` + `slice(first, undefined)` 恰好取到末尾
102
- // 才"能用",纯属巧合;这里写死,免得以后重构踩空。
103
- // ⚠️ 用 0 时必须同时开 injectHot(注入 L1),否则模型完全看不见刚才聊了什么。
104
- const keepStart = keepTurns === 0 ? nodes.length : userIdx[userIdx.length - keepTurns]
105
- if (keepStart <= first) {
106
- return { compacted: false, removed: 0, archived: 0, reason: 'nothing-to-compact' }
107
- }
108
-
109
- const sliceSeqs = nodes.slice(first, keepStart)
110
- const startSeq = sliceSeqs[0]
111
- const endSeq = sliceSeqs[sliceSeqs.length - 1]
112
-
113
- // ── 先把要丢的内容归档进记忆,写成功才动手丢 ──
114
- // archive:false 时整段跳过(这一轮已经由 autoCapture 的 rows 写进 L1 了)。
115
- let archived = 0
116
- if (doArchive) {
117
- const lines = []
118
- let totalChars = 0
119
- let dropped = 0
120
- // ★ 只归档「真人在说」「模型在答」「工具结果」三类。
121
- // surface 里还混着 runtime-context(工作目录/沙箱策略)、skill-catalog(技能清单)、
122
- // time-context(时间快照)——这些是框架注入的,归档进去不仅没用,
123
- // 还会把注入块(含记忆块自身)原样抄进记忆,形成自我污染。
124
- const ARCHIVABLE = new Set(['user', 'model', 'tool'])
125
- for (const seq of sliceSeqs) {
126
- const ev = session.eventAt(seq)
127
- if (!ev) continue
128
- const src = (ev.data?.message ?? ev.data)?.source
129
- if (!ARCHIVABLE.has(src?.kind)) { dropped++; continue }
130
- const t = eventText(ev)
131
- if (!t) { dropped++; continue }
132
- const line = `${eventRole(ev)}: ${t}`
133
- if (totalChars + line.length > archiveChars) {
134
- // ★ 不能整段丢掉:至少保留能塞下的部分,否则归档变成一句「(还有 N 条略)」,
135
- // 这个 bug 在第一次上线时真实发生过(3 条空归档躺在 L1 里)。
136
- const room = archiveChars - totalChars
137
- if (room > 60) {
138
- lines.push(line.slice(0, room - 1) + '…')
139
- totalChars += room
140
- }
141
- dropped += sliceSeqs.length - lines.length
142
- break
143
- }
144
- lines.push(line)
145
- totalChars += line.length
146
- }
147
- if (dropped > 0 && lines.length) lines.push(`…(另有 ${dropped} 条系统注入或过长内容未收录)`)
148
-
149
- if (lines.length && mem?.writeMany) {
150
- try {
151
- const rounds = userIdx.filter((i) => i < keepStart).length
152
- const res = await mem.writeMany([{
153
- text: `[归档] 前 ${rounds} 轮对话(已被压缩出上下文):\n${lines.join('\n')}`,
154
- speaker: 'archive',
155
- channel: 'dialog',
156
- }])
157
- archived = Array.isArray(res) ? res.length : 1
158
- } catch (err) {
159
- // ★ 归档失败就绝不压缩:宁可上下文大,也不能丢内容
160
- log.warn?.('[memory-tiers] 压缩前归档失败,放弃本次压缩:%s', err?.message ?? err)
161
- return { compacted: false, removed: 0, archived: 0, reason: 'archive-failed' }
162
- }
163
- }
164
- }
165
-
166
- // ── 用一条 user/message 顶掉整段 ──
167
- const rounds = userIdx.filter((i) => i < keepStart).length
168
- const placeholder = `(此前 ${rounds} 轮对话已归档到长期记忆,需要细节可以让我检索)`
169
- try {
170
- session.append('user/message', {
171
- role: 'user',
172
- content: [{ type: 'text', text: placeholder }],
173
- source: { kind: 'compaction', compactionId: `mem-tiers-${Date.now()}` },
174
- }, {
175
- surfaceOp: { op: 'replace', startSeq, endSeq },
176
- sourceEventSeqs: sliceSeqs,
177
- })
178
- } catch (err) {
179
- log.warn?.('[memory-tiers] 压缩替换失败:%s', err?.message ?? err)
180
- return { compacted: false, removed: 0, archived, reason: 'replace-failed' }
181
- }
182
-
183
- return { compacted: true, removed: sliceSeqs.length, archived }
184
- }
1
+ /**
2
+ * 历史压缩:把 dsh 自带的会话历史压成一条极短占位,内容归档进记忆系统。
3
+ *
4
+ * 为什么不接管 ctx.compaction:
5
+ * dsh 的 CompactionEngine 要求凑齐 compaction/start + summary + end 三件套,
6
+ * 还要实现选区、tool 配对、并发锁、失败回滚;而它的摘要必须调 LLM(既慢又要钱)。
7
+ * 这里改成:把 compaction-basic 的阈值调到永不触发(thresholdRatio 0.99),
8
+ * 由插件自己在 turn 结束时直接做 surface 替换 —— 零 LLM、逻辑完全可控。
9
+ *
10
+ * 关键约束(踩过的坑):
11
+ * - assistant/message 不能带 sourceEventSeqs(它内嵌 provider stream),
12
+ * 所以替换节点只能用 user/message。
13
+ * - 选区必须结束在 user/message 之前,否则会把 tool/call 和 tool/result 切开。
14
+ * - startSeq/endSeq 是 surface 位置,不是数值区间(多次替换后高位 seq 会落到老位置)。
15
+ */
16
+
17
+ /** 从一个事件里抽出可读文本(只取 text 块,reasoning 是草稿不进记忆) */
18
+ function eventText(ev) {
19
+ const msg = ev?.data?.message ?? ev?.data
20
+ const blocks = msg?.content
21
+ if (!Array.isArray(blocks)) return ''
22
+ const parts = []
23
+ for (const b of blocks) {
24
+ if (!b) continue
25
+ if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text)
26
+ else if (b.type === 'tool_call' || b.type === 'tool-call') {
27
+ const n = b.name || b.toolName || 'tool'
28
+ const a = typeof b.arguments === 'string' ? b.arguments : JSON.stringify(b.arguments ?? '')
29
+ parts.push(`[调用 ${n} ${String(a).slice(0, 80)}]`)
30
+ }
31
+ }
32
+ return parts.join(' ').replace(/\s+/g, ' ').trim()
33
+ }
34
+
35
+ /** 事件所属角色(用于归档时打标签) */
36
+ function eventRole(ev) {
37
+ if (ev.type === 'user/message') return 'user'
38
+ if (ev.type === 'assistant/message') return 'bot'
39
+ if (ev.type === 'tool/result') return 'tool'
40
+ return 'sys'
41
+ }
42
+
43
+ /**
44
+ * 把 dsh 可见历史压成一条占位。
45
+ *
46
+ * @param {object} session dsh 的 Session 实例(有 surface / eventAt / append)
47
+ * @param {object} mem createMemory5 的实例(只用到 writeMany)
48
+ * @param {object} opts
49
+ * keepTurns 保留最近几轮原文(按 user/message 计数),默认 1
50
+ * archiveChars 单次归档最多写多少字,默认 1200
51
+ * log 日志
52
+ * @returns {Promise<{compacted:boolean, removed:number, archived:number, reason?:string}>}
53
+ */
54
+ export async function compactSurface(session, mem, opts = {}) {
55
+ const keepTurns = Math.max(0, Number(opts.keepTurns ?? 1))
56
+ const archiveChars = Math.max(100, Number(opts.archiveChars ?? 1200))
57
+ // ★ 归档开关:autoCapture 已经把这一轮(问+答+工具)写进 L1 了,
58
+ // 这里再归档一次会把同一轮存两遍。所以调用方在"rows 已写成功"时传 archive:false,
59
+ // 只做"把历史换成占位",不再抄一份进记忆。
60
+ const doArchive = opts.archive !== false
61
+ const log = opts.log || console
62
+
63
+ const nodes = session?.surface?.nodes
64
+ if (!Array.isArray(nodes) || nodes.length < 2) {
65
+ return { compacted: false, removed: 0, archived: 0, reason: 'empty' }
66
+ }
67
+
68
+ // ★ 选区起点必须跳过开头的 system/message。
69
+ // dsh 会拒绝:「node 0 holds the system prompt and may be rewritten only by
70
+ // a system/message over exactly that node」。系统提示词不属于可压缩范围。
71
+ let first = 0
72
+ while (first < nodes.length && session.eventAt(nodes[first])?.type === 'system/message') first++
73
+ if (first >= nodes.length - 1) {
74
+ return { compacted: false, removed: 0, archived: 0, reason: 'only-system' }
75
+ }
76
+
77
+ // 找所有「真实用户轮次」在 surface 里的位置。
78
+ // ★ user/message 有四种来源,只有 source.kind === 'user' 是真人在说话:
79
+ // user ← 真人
80
+ // runtime-context ← 运行时快照(工作目录、沙箱策略…)
81
+ // skill-catalog ← 技能清单
82
+ // time-context ← 时间快照
83
+ // compaction ← 我们自己生成的占位
84
+ // 把后四种算成"轮次"会让 keepTurns 完全失真(实测 6 轮被算成 13 轮)。
85
+ const userIdx = []
86
+ for (let i = first; i < nodes.length; i++) {
87
+ const ev = session.eventAt(nodes[i])
88
+ if (ev?.type !== 'user/message') continue
89
+ const src = (ev.data?.message ?? ev.data)?.source
90
+ if (src?.kind !== 'user') continue
91
+ userIdx.push(i)
92
+ }
93
+
94
+ // 要留 keepTurns 轮 → 至少要 keepTurns+1 个 user 才有东西可压
95
+ if (userIdx.length <= keepTurns) {
96
+ return { compacted: false, removed: 0, archived: 0, reason: 'nothing-to-compact' }
97
+ }
98
+
99
+ // 选区 = [first, keepStart),keepStart 是「保留的第一轮」的 user 位置
100
+ // ★ keepTurns === 0 是**受支持的显式选项**:整段历史全压成占位,dsh 侧一条原文不留。
101
+ // 以前靠 `userIdx[length - 0] === undefined` + `slice(first, undefined)` 恰好取到末尾
102
+ // 才"能用",纯属巧合;这里写死,免得以后重构踩空。
103
+ // ⚠️ 用 0 时必须同时开 injectHot(注入 L1),否则模型完全看不见刚才聊了什么。
104
+ const keepStart = keepTurns === 0 ? nodes.length : userIdx[userIdx.length - keepTurns]
105
+ if (keepStart <= first) {
106
+ return { compacted: false, removed: 0, archived: 0, reason: 'nothing-to-compact' }
107
+ }
108
+
109
+ const sliceSeqs = nodes.slice(first, keepStart)
110
+ const startSeq = sliceSeqs[0]
111
+ const endSeq = sliceSeqs[sliceSeqs.length - 1]
112
+
113
+ // ── 先把要丢的内容归档进记忆,写成功才动手丢 ──
114
+ // archive:false 时整段跳过(这一轮已经由 autoCapture 的 rows 写进 L1 了)。
115
+ let archived = 0
116
+ if (doArchive) {
117
+ const lines = []
118
+ let totalChars = 0
119
+ let dropped = 0
120
+ // ★ 只归档「真人在说」「模型在答」「工具结果」三类。
121
+ // surface 里还混着 runtime-context(工作目录/沙箱策略)、skill-catalog(技能清单)、
122
+ // time-context(时间快照)——这些是框架注入的,归档进去不仅没用,
123
+ // 还会把注入块(含记忆块自身)原样抄进记忆,形成自我污染。
124
+ const ARCHIVABLE = new Set(['user', 'model', 'tool'])
125
+ for (const seq of sliceSeqs) {
126
+ const ev = session.eventAt(seq)
127
+ if (!ev) continue
128
+ const src = (ev.data?.message ?? ev.data)?.source
129
+ if (!ARCHIVABLE.has(src?.kind)) { dropped++; continue }
130
+ const t = eventText(ev)
131
+ if (!t) { dropped++; continue }
132
+ const line = `${eventRole(ev)}: ${t}`
133
+ if (totalChars + line.length > archiveChars) {
134
+ // ★ 不能整段丢掉:至少保留能塞下的部分,否则归档变成一句「(还有 N 条略)」,
135
+ // 这个 bug 在第一次上线时真实发生过(3 条空归档躺在 L1 里)。
136
+ const room = archiveChars - totalChars
137
+ if (room > 60) {
138
+ lines.push(line.slice(0, room - 1) + '…')
139
+ totalChars += room
140
+ }
141
+ dropped += sliceSeqs.length - lines.length
142
+ break
143
+ }
144
+ lines.push(line)
145
+ totalChars += line.length
146
+ }
147
+ if (dropped > 0 && lines.length) lines.push(`…(另有 ${dropped} 条系统注入或过长内容未收录)`)
148
+
149
+ if (lines.length && mem?.writeMany) {
150
+ try {
151
+ const rounds = userIdx.filter((i) => i < keepStart).length
152
+ const res = await mem.writeMany([{
153
+ text: `[归档] 前 ${rounds} 轮对话(已被压缩出上下文):\n${lines.join('\n')}`,
154
+ speaker: 'archive',
155
+ channel: 'dialog',
156
+ }])
157
+ archived = Array.isArray(res) ? res.length : 1
158
+ } catch (err) {
159
+ // ★ 归档失败就绝不压缩:宁可上下文大,也不能丢内容
160
+ log.warn?.('[memory-tiers] 压缩前归档失败,放弃本次压缩:%s', err?.message ?? err)
161
+ return { compacted: false, removed: 0, archived: 0, reason: 'archive-failed' }
162
+ }
163
+ }
164
+ }
165
+
166
+ // ── 用一条 user/message 顶掉整段 ──
167
+ const rounds = userIdx.filter((i) => i < keepStart).length
168
+ const placeholder = `(此前 ${rounds} 轮对话已归档到长期记忆,需要细节可以让我检索)`
169
+ try {
170
+ session.append('user/message', {
171
+ role: 'user',
172
+ content: [{ type: 'text', text: placeholder }],
173
+ source: { kind: 'compaction', compactionId: `mem-tiers-${Date.now()}` },
174
+ }, {
175
+ surfaceOp: { op: 'replace', startSeq, endSeq },
176
+ sourceEventSeqs: sliceSeqs,
177
+ })
178
+ } catch (err) {
179
+ log.warn?.('[memory-tiers] 压缩替换失败:%s', err?.message ?? err)
180
+ return { compacted: false, removed: 0, archived, reason: 'replace-failed' }
181
+ }
182
+
183
+ return { compacted: true, removed: sliceSeqs.length, archived }
184
+ }