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/ARCHITECTURE.md +420 -2460
- package/README.md +140 -268
- package/client.mjs +701 -608
- package/compaction.mjs +184 -184
- package/index.mjs +939 -826
- package/{memory5.mjs → memory.mjs} +5280 -3403
- package/package.json +61 -63
- package/settings.mjs +335 -327
- package/silicon.mjs +24 -22
- package/memory6.mjs +0 -1553
- package/reindex.mjs +0 -162
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
|
+
}
|