dsh-memory-tiers 0.8.1 → 0.8.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.
Files changed (3) hide show
  1. package/README.md +8 -8
  2. package/compaction.mjs +184 -184
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-memory-tiers
2
2
 
3
- 给 dsh 加长期记忆,同时把每轮对话的 token 压住不涨。
3
+ 聊久了它不会失忆,账单也不会越滚越大。
4
4
 
5
5
  装好之后你不用刻意「记一下」——它自己记、自己整理、需要时自己找回来。
6
6
 
@@ -8,17 +8,17 @@
8
8
 
9
9
  ## 它解决两个问题
10
10
 
11
- **① 上下文不够用**
11
+ **① 聊着聊着就忘了前面**
12
12
 
13
- dsh 聊久了会遇到两种结局:要么历史太长爆上下文,要么被压缩掉、事后想不起来。
14
- 这个插件把对话记到本地,压缩掉的内容仍在库里,随时能搜回来。
13
+ dsh 聊久了会遇到两种结局:要么历史太长塞不下,要么被自动压缩掉、事后想不起来。
14
+ 这个插件把对话存在本地,被压掉的内容也还在,随时能搜回来。
15
15
 
16
- **② 每轮花费随历史增长**
16
+ **② 聊得越久,每一句越贵**
17
17
 
18
- 默认它会接管 dsh 的历史压缩:**上一轮说完,历史就被压成一条 20 字占位**(一条不留),
19
- 下一轮只带记忆块 + 你这句话。上下文长度不随轮数增长,token 花费也就平了。
18
+ 每次说话,前面的历史都要重新发一遍。聊 100 轮和聊 10 轮,花销差十倍。
19
+ 装上它,历史会被替换成一页摘要:聊到第 100 轮,和聊到第 10 轮的开销差不多。
20
20
 
21
- 省下来的比它花的多:整理是有触发条件的(L1/L2 满了),不是每轮都调模型。
21
+ 整理是有触发条件的(攒够了才做),不是每轮都调模型——省下来的比它花的多。
22
22
 
23
23
  ---
24
24
 
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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-memory-tiers",
3
- "version": "0.8.1",
4
- "description": "给 dsh 加长期记忆,并把每轮 token 压住不涨:自动记住对话、按需检索、接管历史压缩(零 LLM)。",
3
+ "version": "0.8.2",
4
+ "description": "让 dsh 记住你说过的话,忘掉无关的,聊再久也不多花钱。",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
7
7
  "license": "MIT",