dsh-ebbing-context 0.0.0-stage → 0.2.7

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.
@@ -0,0 +1,1015 @@
1
+ // 一次压缩的事务:校验 -> 按"从最前面往后"的顺序逐个落盘。
2
+ // 每个子事务四步:压缩开始 -> 摘要 -> 替代表层 -> 压缩结束。
3
+ // 失败必须补结束事件:留下未配对的开始会让这条会话此后任何压缩都被拒。
4
+
5
+ import { userMessage } from './messages.js'
6
+ import { estimateTokens, hostTokens, messageTokens, IMAGE_TOKENS } from './tokens.js'
7
+ import { imagePrices } from './imageprice.js'
8
+ import {
9
+ CHECKPOINT_PREAMBLE,
10
+ SUMMARY_CLOSE_TAG,
11
+ SUMMARY_OPEN_TAG,
12
+ TITLE_CLOSE,
13
+ TITLE_OPEN,
14
+ } from './config.js'
15
+ import { canCompressZone, isTurnBoundaryPair, lastCompressibleTurn, openCompaction, productFloor, readZones, resolveTurnAnchor, resolveZoneIndex, routeOf, writableTo } from './blocks.js'
16
+ import { check, consume } from './license.js'
17
+ import { siblingCompressCount } from './callfold.js'
18
+
19
+ const CHECKPOINT_KIND = 'compact-checkpoint'
20
+ const TIER_OF_KEY = { l0: 1, l1: 2, l2: 3, l3: 3 }
21
+ const KEYS = ['l3', 'l2', 'l1', 'l0']
22
+ const FIELD_NAMES = ['title', 'summary', 'to']
23
+
24
+ // 填表格式校验:格式错就一次性说清楚错在哪、该长什么样,别让模型猜。
25
+ function checkShape(args) {
26
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
27
+ return { ok: false, reason: '参数必须是对象,形如 {"l0":{"title":"…","summary":"…","to":"T19"},"l1":{"title":"…","summary":"…","to":"3"}}(l0 的 to 是轮号,l1/l2 的 to 是块序号,l3 不写 to)' }
28
+ }
29
+ const unknown = Object.keys(args).filter((k) => !KEYS.includes(k))
30
+ if (unknown.length) {
31
+ return {
32
+ ok: false,
33
+ reason: `不认识这些键:${unknown.join('、')}。只能写 l3 / l2 / l1 / l0,每个键的值是 {title, summary, to}(l3 不写 to)`,
34
+ }
35
+ }
36
+ const given = KEYS.filter((k) => args[k] !== undefined)
37
+ if (!given.length) return { ok: false, reason: '至少要给 l3 / l2 / l1 / l0 里的一个键' }
38
+ for (const key of given) {
39
+ const spec = args[key]
40
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
41
+ return { ok: false, reason: `${key} 的值必须是一个对象,形如 {"title":"…","summary":"…","to":"…"}` }
42
+ }
43
+ const extra = Object.keys(spec).filter((k) => !FIELD_NAMES.includes(k))
44
+ if (extra.length) {
45
+ return { ok: false, reason: `${key} 里不认识这些字段:${extra.join('、')};只接受 title / summary / to` }
46
+ }
47
+ if (typeof spec.title !== 'string') return { ok: false, reason: `${key}.title 必须是字符串` }
48
+ if (typeof spec.summary !== 'string') return { ok: false, reason: `${key}.summary 必须是字符串` }
49
+ const to = spec.to
50
+ // 只收字符串(契约收紧):数字进来会在别处被隐式转成 "3",而 "T19" 这种轮锚点
51
+ // 一旦被写成数字就彻底变味。写清楚该写什么,比悄悄接受两种形态可靠。
52
+ if (to !== undefined && typeof to !== 'string') {
53
+ return {
54
+ ok: false,
55
+ reason: `${key}.to 要写成字符串:l0 写 "T19" 这种轮号,l1/l2 写 "3" 这种块序号(都从地图抄);l3 不写 to`,
56
+ }
57
+ }
58
+ if (key === 'l3' && to !== undefined) return { ok: false, reason: 'l3 不写 to:它一次把整个 L3 区压成一块' }
59
+ if (key !== 'l3' && to === undefined) {
60
+ return { ok: false, reason: `${key} 必须写 to:${key === 'l0' ? '轮锚点 T19' : '该区从 1 起的块序号'},从地图里抄` }
61
+ }
62
+ }
63
+ return { ok: true }
64
+ }
65
+
66
+ export async function runCompress(ctx, agent, args, config, log) {
67
+ const session = ctx.sessions.get(agent.id)
68
+ if (!session) return fail('拿不到当前会话,无法压缩')
69
+
70
+ // 0) 先看填表格式对不对:格式错就没必要再往下算区间,也不用读表层(回归修复)。
71
+ const shape = checkShape(args)
72
+ if (!shape.ok) return fail(shape.reason)
73
+
74
+ // 同一条助手消息里并行的第二个 compress:立刻拒,别赌顺序(并发边界)。
75
+ // 压缩会整段替换表层,两个一起发时后一个手里的区间已经是旧的;宿主没有事务可回滚,
76
+ // 与其写出半成品,不如让模型一个一个来。
77
+ if (siblingCompressCount(session, log) > 1) {
78
+ return fail(
79
+ '这条消息里同时发了不止一个 compress 调用。压缩会整段替换表层,两个一起发没法保证先后顺序,' +
80
+ '所以这一批一个都没执行——请一次只发一个,等结果回来再发下一个。'
81
+ )
82
+ }
83
+
84
+ // 先查许可证:没看过地图、或地图之后这几层的块变过,都不许压。
85
+ // 判据是"块指纹"(见 license.js),不是宿主的 replaceGeneration——后者会被宿主自己的
86
+ // 提示词重写、图片离线化 +1,把刚发的地图白白打废。
87
+ const license = check(agent.id, session, readZones(ctx, session, agent, log))
88
+ if (license) return fail(license)
89
+
90
+ // 日志里若停着一个没闭合的压缩事务,这一版绝不再写第二个。
91
+ // 宿主重放校验器:未闭合 start 之后的任何 turn/* 都会让整份日志判 SessionFormatError,
92
+ // 也就是"再聊一轮,这条会话就重开打不开了"(见 blocks.js 的 openCompaction)。
93
+ const stuck = openCompaction(session)
94
+ if (stuck) {
95
+ return fail(
96
+ '这条会话的日志里停着一个没闭合的压缩事务(`compaction/start` 没有对应的 `compaction/end`),' +
97
+ '通常意味着上一次压缩在写盘时因特殊原因中断(进程被杀、磁盘出错)。现在再压会再写一个 start,让日志更没法解析。' +
98
+ '这一版不再压缩。**不要先打开这条会话**:宿主打开/恢复会话时会先补写 turn/end,' +
99
+ '那一步会让日志再也修不回来(重放会报 SessionFormatError)。' +
100
+ '修法是离线的:完全退出 DSH → 备份这条会话的日志 → 补一条配对的 compaction/end' +
101
+ '(同一个 compactionId、同一个 turn,带 error 字段),或者从那条 compaction/start 之后截断 → ' +
102
+ '确认能正常重放之后再打开。'
103
+ )
104
+ }
105
+
106
+ // 0.5) 让宿主先剪一遍超大的工具结果:单条太大归它,条数太多归我们。
107
+ // pruner 与宿主的 auto 无关,但关掉 auto 之后没人叫它,所以由我们在压缩前显式调。
108
+ let prunedChars = 0
109
+ if (config.pruneFirst) {
110
+ try {
111
+ const pruner = ctx.get('toolResultPruner')
112
+ if (pruner) {
113
+ const r = pruner.pruneSession(session)
114
+ prunedChars = (r && typeof r.charsRemoved === 'number') ? r.charsRemoved : 0
115
+ }
116
+ } catch { /* 剪枝失败不影响压缩本身 */ }
117
+ }
118
+
119
+ // 读一次表层:四区、当前轮、每个块的身份都从它推出来(不读日志、不读台账)。
120
+ const zones = readZones(ctx, session, agent, log)
121
+ const meter = ctx.tokenMeter
122
+
123
+ // 表层里有内容为空的节点、而且已经有压缩块时,块在哪一行推不出来。
124
+ // 注意这里已经不是"宿主的常态":`deriveExactly()` 能逐个节点投影(见 blocks.js),
125
+ // 只有那条路也失效(`session.log` 不是数组、事件读不到、投影抛错)才会走到这里。
126
+ // 所以这句话要说得比"这一版压不了"更准确:是宿主接口退化,不是历史坏了。
127
+ if (zones.ambiguous) {
128
+ return fail(
129
+ '这一版不敢压:表层里有空步骤(宿主把没内容的消息跳过了),而表层上已经有压缩块,' +
130
+ '插件拿不到逐节点的映射,块的位置推不出来。压错了会把旧摘要再压成一份新的,原文找不回来。' +
131
+ '别用宿主自带的 /compact 顶替(它会把分层拍平);先照常对话,等宿主恢复逐节点投影后会自动好转'
132
+ )
133
+ }
134
+
135
+ // 1) 先把每个键翻译成一段真实区间,全部算完再落盘(一个键失败整批不写)。
136
+ // 遍历顺序按时间线:最老的一层先算,跟模型填表的顺序一致。
137
+ const ops = []
138
+ for (const key of ['l3', 'l2', 'l1', 'l0']) {
139
+ const spec = args[key]
140
+ if (!spec) continue
141
+ const built = buildOp(key, spec, zones, config, meter)
142
+ if (!built.ok) return fail(built.reason)
143
+ ops.push(built.op)
144
+ }
145
+ if (!ops.length) return fail('没有指定要压哪一层:至少要给 l0 / l1 / l2 / l3 里的一个')
146
+
147
+ // 2) 区间不能重叠
148
+ for (let i = 0; i < ops.length; i++) {
149
+ for (let j = i + 1; j < ops.length; j++) {
150
+ if (overlaps(ops[i], ops[j])) {
151
+ return fail(`${ops[i].key} 和 ${ops[j].key} 选的区间有重叠,一次只能压互不相交的几段`)
152
+ }
153
+ }
154
+ }
155
+
156
+ // 3) 级联前提:压第 n 层,比它浅的层必须一起压。
157
+ // 模型可能只想动 l2,但 l0/l1 还没压完——那样 l2 的产物底下压着的原文并没有变少。
158
+ const cascade = checkCascade(args, zones, config.keepTurns)
159
+ if (!cascade.ok) return fail(cascade.reason)
160
+
161
+ // 4) 整批预演:每一层先在"只校验、不写入"的模式下走一遍。
162
+ // 任何一层过不了,整批一个字都不写——不会出现"前面的写进去了、后面的不合格"。
163
+ ops.sort((a, b) => a.startIdx - b.startIdx)
164
+ for (const op of ops) {
165
+ const pre = commitOne(ctx, session, agent, op, meter, config, zones, true, log)
166
+ if (!pre.ok) return fail(`整批预演没通过,一个字都没写。${pre.reason}`)
167
+ }
168
+
169
+ // 5) 逐个子事务落盘:**按表层位置升序**(先 l3、再 l2、l1、l0),与填表顺序一致。
170
+ // 宿主没有事务,写进去的撤不回;能控制的只有顺序与每个子事务内部的写前复核。
171
+ // 每个子事务在表层上只做一次 replace,不会写一半;某个子事务失败时,已落地的都是
172
+ // "上层已经并好、下层还是原文",留下的仍是合法金字塔(`zoneOrderFault` 兜底)。
173
+ // 升序还有个好处:上面的层先写,下面的下标整体往前挪,每个子事务前重读 live 即可。
174
+ const writeOrder = ops
175
+ const results = []
176
+ for (const op of writeOrder) {
177
+ // 每个子事务前重算一次被遮蔽节点:前面的写入会改变下标
178
+ const r = commitOne(ctx, session, agent, op, meter, config, zones, false, log)
179
+ // applied 要冒到整批结果上:调用方靠它决定「基线要不要重设、要不要提醒模型别再压同一段」。
180
+ if (!r.ok) {
181
+ const failed = fail(r.reason, results)
182
+ if (r.applied === true) failed.applied = true
183
+ return failed
184
+ }
185
+ results.push(r.result)
186
+ }
187
+ // 压成功了,这张地图用掉了:想再压必须重新看一遍地图。
188
+ consume(agent.id)
189
+ return { ok: true, results, prunedChars }
190
+ }
191
+
192
+ function buildOp(key, spec, zones, config, meter) {
193
+ const title = typeof spec.title === 'string' ? spec.title.trim() : ''
194
+ const summary = typeof spec.summary === 'string' ? spec.summary : ''
195
+ const maxTitle = (config.title && config.title.maxTokens) || 50
196
+ if (!title) return { ok: false, reason: `${key} 少了一句标题:地图每块只显示这一句,必须给` }
197
+ const titleTokens = estimateTokens(title)
198
+ if (titleTokens > maxTitle) {
199
+ return { ok: false, reason: `${key} 的标题约 ${titleTokens} token,上限 ${maxTitle},请改成电报体` }
200
+ }
201
+ if (!summary.trim()) return { ok: false, reason: `${key} 的摘要正文是空的` }
202
+ // 长度按等效 token 算(中文 1 字 = 0.6,英文 1 字 = 0.3):同一把尺子,中英文不会被同一个数字卡出两种结果。
203
+ const summaryTokens = estimateTokens(summary)
204
+ const targetLayer = key === 'l0' ? 'l1' : key === 'l1' ? 'l2' : 'l3'
205
+ const cap = config.segment[targetLayer]
206
+ if (summaryTokens > cap) {
207
+ return {
208
+ ok: false,
209
+ reason: `${key} 的摘要正文约 ${summaryTokens} token,超过 ${targetLayer.toUpperCase()} 单块上限 ${cap}`,
210
+ }
211
+ }
212
+
213
+ if (key === 'l0') {
214
+ const r = resolveTurnAnchor(zones, spec.to, config.keepTurns)
215
+ if (!r.ok) return { ok: false, reason: r.reason }
216
+ // 大纲自检没过时,锚点可能指到别的轮上;这类情况只有 l0 受影响(l1/l2 按块序号)。
217
+ // 说"轮次大纲损坏"而不是"不连续":用户要的是看到就知道哪坏了,而不是猜。
218
+ if (zones.outlineFault) {
219
+ return {
220
+ ok: false,
221
+ reason:
222
+ `轮次大纲损坏:${zones.outlineFault === 'gap' ? '缺轮' : '乱序'},判不准每一轮到哪儿结束,压不了 l0。` +
223
+ '自检都过不去的东西不能拿去切表层。别用宿主自带的 /compact 顶替——它会把分层拍平、原文再也取不回。这一批不用带 l0 键(判不了轮就没有能压的 l0),l1 / l2 的 to 不受影响',
224
+ }
225
+ }
226
+ const first = zones.zones.l0[0]
227
+ if (!first) return { ok: false, reason: '原文区已经空了,没有可压的原文' }
228
+ // 把 to 一并带进 op:块尾要写"其下的第一条原文轮次是 T(to+1)"(交界声明)。
229
+ const op = makeOp(key, first.seq, r.seq, zones, { title, summary, to: String(spec.to || '').trim(), swallowed: r.swallowed || [] })
230
+ const impure = impureRange(key, op, zones)
231
+ if (impure) return { ok: false, reason: impure }
232
+ // 区间里夹着的旧块,块头写的上限轮不能超过 to,否则说明块头快照跟实际对不上,
233
+ // 压下去会把不在 to 名下的内容一起吸走(块头的轮号还会被新一代继承,错了会一直传)。
234
+ // 注意:l0 区间在 impureRange 那一关就已经因为"含块"被拒(同一区间里不许有块),
235
+ // 所以下面这段在 l0 上走不到——留着是因为它是"按块头判区间"的通用兜底,
236
+ // 将来 impureRange 的判据一旦放开(比如允许吞掉块),这里必须还在(回归修复)。
237
+ const toTurn = Number(/^T(\d+)$/.exec(String(spec.to || '').trim())[1])
238
+ for (let i = op.startIdx; i <= op.endIdx; i += 1) {
239
+ const entry = zones.info.get(zones.nodes[i])
240
+ if (!entry || entry.kind !== 'block') continue
241
+ const turns = entry.block && Array.isArray(entry.block.turns) ? entry.block.turns : []
242
+ // 块头知道自己盖到哪儿才有资格比较:`T?`(完全没有轮号)和 `T16–T19+`
243
+ // (下限,实际更宽)都不能用来证明"这块在 to 以内"。宁可拒这一次。
244
+ if (entry.block.turnsOpen || turns.some((t) => typeof t !== 'number')) {
245
+ return {
246
+ ok: false,
247
+ reason:
248
+ `区间里有一块(L${entry.block.tier})的块头没有轮号,查不出它盖到第几轮,` +
249
+ '压下去可能把 to 之外的轮次一并吸走。先别压 l0:要么先用 l1 把这一区更早的块压进 L2(该区至少两块、且不是最后一块),要么这次先不压',
250
+ }
251
+ }
252
+ const hi = turns[1]
253
+ if (typeof hi === 'number' && hi > toTurn) {
254
+ return {
255
+ ok: false,
256
+ reason:
257
+ `区间里有一块(L${entry.block.tier},块头写着盖到 T${hi})超出了 to=T${toTurn} 的范围,` +
258
+ '说明这一块的轮号跟表层对不上,压下去会把它一并吸走。先别压 l0——重看一次地图;确实要腾地方的话,用 l1 把这一区更早的块压进 L2(该区至少两块、且不是最后一块)',
259
+ }
260
+ }
261
+ }
262
+ return { ok: true, op }
263
+ }
264
+
265
+ if (key === 'l1') {
266
+ const r = resolveZoneIndex(zones, 'l1', spec.to)
267
+ if (!r.ok) return { ok: false, reason: r.reason }
268
+ const first = zones.zones.l1[0]
269
+ if (!first) return { ok: false, reason: 'L1 区是空的,没有可合并的块' }
270
+ const op = makeOp(key, first.seq, r.seq, zones, { title, summary })
271
+ const impure = impureRange(key, op, zones)
272
+ if (impure) return { ok: false, reason: impure }
273
+ return { ok: true, op }
274
+ }
275
+
276
+ if (key === 'l2') {
277
+ const r = resolveZoneIndex(zones, 'l2', spec.to)
278
+ if (!r.ok) return { ok: false, reason: r.reason }
279
+ const first = zones.zones.l2[0]
280
+ if (!first) return { ok: false, reason: 'L2 区是空的,没有可合并的块' }
281
+ // 区间只取 L2 区,不吸收 L3 区:产物自成一块落在 L2 区原位(替换后自然顶到旧 L3 块下面),
282
+ // 旧的 L3 块原样不动、不拼接、不合并(见 commitOne 里 op.l2Recompute 分支)。
283
+ const op = makeOp(key, first.seq, r.seq, zones, { title, summary, l2Recompute: true, l2To: spec.to })
284
+ const impure = impureRange(key, op, zones)
285
+ if (impure) return { ok: false, reason: impure }
286
+ return { ok: true, op }
287
+ }
288
+
289
+ // l3:整个 L3 区压成一块,不接受 to
290
+ if (spec.to !== undefined) return { ok: false, reason: 'l3 不能写 to:它一次把整个 L3 区压成一块' }
291
+ const list = zones.zones.l3
292
+ if (!list.length) return { ok: false, reason: 'L3 区是空的,没有可压缩的块' }
293
+ const l3op = makeOp('l3', list[0].seq, list[list.length - 1].seq, zones, { title, summary })
294
+ const l3impure = impureRange('l3', l3op, zones)
295
+ if (l3impure) return { ok: false, reason: l3impure }
296
+ return { ok: true, op: l3op }
297
+ }
298
+
299
+ function makeOp(key, startSeq, endSeq, zones, extra) {
300
+ const startIdx = zones.nodes.indexOf(startSeq)
301
+ const endIdx = zones.nodes.indexOf(endSeq)
302
+ return { key, startSeq, endSeq, startIdx, endIdx, ...extra }
303
+ }
304
+
305
+ // 区间里是不是只有"本区的东西"。
306
+ //
307
+ // 为什么必须查:块是按**块头**分层的(blocks.js 只看 `〔L2|…〕` 那行),不看它在表层上的位置。
308
+ // 只要出现一个"块头层级与位置不一致"的块(用户先跑过宿主 /compact、别的插件写过检查点、
309
+ // 文件被手改过),l1 的区间就可能从第一块量到最后一块、中间夹着原文甚至当前轮。
310
+ // 宿主对 replace 只拦 node 0 和工具结果改写,不拦"横跨区界",于是当前轮会被一起压掉,
311
+ // 或者一对调用/结果被切开——下一次请求直接被服务端 400 拒。
312
+ // 所以每一层都要求:区间内每个节点的身份都必须属于这一层。
313
+ // 导出只为测试:区间纯度与批次快照的判定都要能单独喂样例。
314
+ export function impureRange(key, op, zones) {
315
+ if (op.startIdx < 0 || op.endIdx >= zones.nodes.length || op.endIdx < op.startIdx) {
316
+ return (
317
+ `${key} 的区间在表层上找不到(起止下标 ${op.startIdx}/${op.endIdx},表层共 ${zones.nodes.length} 个节点)` +
318
+ '——重看一次地图再压'
319
+ )
320
+ }
321
+ const want = key === 'l0' ? null : key === 'l1' ? 1 : key === 'l2' ? 2 : 3
322
+ for (let i = op.startIdx; i <= op.endIdx; i += 1) {
323
+ const entry = zones.info.get(zones.nodes[i])
324
+ const isBlock = !!(entry && entry.kind === 'block')
325
+ if (want === null) {
326
+ if (isBlock) {
327
+ return (
328
+ `原文区这一段的中间夹着一个压缩块(L${entry.block.tier},块头写着 ${entry.block.title || '无标题'})。` +
329
+ '块的位置和它的层级对不上,直接压会把这块前后的内容一起遮掉。' +
330
+ '先用 l1 把这一区更早的块压进 L2(该区至少两块、且不是最后一块),把原文区腾成纯原文再压 l0'
331
+ )
332
+ }
333
+ continue
334
+ }
335
+ if (!isBlock) {
336
+ return (
337
+ `${key} 的区间里混进了原文(第 ${i} 个节点不是 ${key.toUpperCase()} 块)。` +
338
+ '块的层级和位置对不上,这一段不能整段替换——先把原文区清干净,或者改压别的层'
339
+ )
340
+ }
341
+ if (entry.block.tier !== want) {
342
+ return (
343
+ `${key} 的区间里混进了一个 L${entry.block.tier} 块(第 ${i} 个节点),` +
344
+ `不是 ${key.toUpperCase()} 块。块的层级和位置对不上,这一段不能整段替换——先把它按真层级压上去`
345
+ )
346
+ }
347
+ }
348
+ return null
349
+ }
350
+
351
+ function overlaps(a, b) {
352
+ return a.startIdx <= b.endIdx && b.startIdx <= a.endIdx
353
+ }
354
+
355
+ // 级联前提:要压某一层,比它浅的、还能压的层必须同时压。
356
+ // 判据是"还能压"而不是"还有内容"——某层只剩 1 块(最后一块不压)、或者原文区只剩
357
+ // 被 keepTurns 护住的最后几轮时,那一层本来就没得压,再要求它一起压就会把整批锁死。
358
+ function checkCascade(args, zones, keepTurns = 0) {
359
+ const order = ['l0', 'l1', 'l2', 'l3']
360
+ const label = { l0: '原文区', l1: 'L1 区', l2: 'L2 区' }
361
+ // 报"可压的张数",不是"张数":l1/l2 的最后一块按设计不压,
362
+ // 报总数会把模型引去压那一块,然后被"最后一块不能压,最多写到 N−1"再顶回来,白跑一趟。
363
+ const pending = (key) => {
364
+ if (key === 'l0') {
365
+ const cutoff = lastCompressibleTurn(zones, keepTurns)
366
+ if (cutoff === null) return 0
367
+ return (zones.zones.l0 || []).filter((n) => typeof n.turn === 'number' && n.turn <= cutoff).length
368
+ }
369
+ return Math.max(0, (zones.zones[key] || []).length - 1)
370
+ }
371
+ for (const key of ['l3', 'l2', 'l1']) {
372
+ if (!args[key]) continue
373
+ const shallower = order.slice(0, order.indexOf(key))
374
+ const missing = shallower.filter((k) => !args[k] && canCompressZone(zones, k, keepTurns))
375
+ if (!missing.length) continue
376
+ const detail = missing.map((k) => `${label[k]}还有 ${pending(k)} ${k === 'l0' ? '段原文' : '块'}没压`).join('、')
377
+ const hints = missing.map((k) => `${k} 的 to 可以写 ${anchorHint(zones, k, keepTurns)}`).join(';')
378
+ return {
379
+ ok: false,
380
+ reason:
381
+ `要压 ${key},就必须同时压 ${missing.join('、')}——${detail}。` +
382
+ '一次调用把所有该压的键一起给完(填表顺序 l3 → l2 → l1 → l0),别只动深层。' +
383
+ `补的时候:${hints}`,
384
+ }
385
+ }
386
+ return { ok: true }
387
+ }
388
+
389
+ // 拒绝时直接告诉模型"那一层的 to 能写什么",省掉它再算一遍(多数拒绝只差这一个数字)。
390
+ function anchorHint(zones, key, keepTurns = 0) {
391
+ if (key === 'l0') {
392
+ const cutoff = lastCompressibleTurn(zones, keepTurns)
393
+ // 显示用「真能写」的上界,过滤仍用物理切点:否则会写出「可写 T1–T4;T4 写不得」这种自相矛盾的话。
394
+ const writable = writableTo(zones, keepTurns)
395
+ if (cutoff === null || writable === null) return '(暂时没有可压的轮)'
396
+ const lo = zones.zones.l0.filter((n) => typeof n.turn === 'number' && n.turn <= cutoff).map((n) => n.turn)
397
+ if (!lo.length) return '(暂时没有可压的轮)'
398
+ const first = Math.min(...lo)
399
+ const missing = (Array.isArray(zones.turnNotes) ? zones.turnNotes : [])
400
+ .filter((n) => n && n.reason === 'missing' && typeof n.turn === 'number')
401
+ .map((n) => n.turn)
402
+ .filter((t) => t >= first && t <= cutoff)
403
+ .sort((a, b) => a - b)
404
+ const range = first === writable ? `T${first}` : `T${first}–T${writable} 里的一个`
405
+ return missing.length ? `${range}(缺的轮号 ${missing.map((t) => 'T' + t).join('、')} 写不得)` : range
406
+ }
407
+ const list = zones.zones[key] || []
408
+ const max = list.length - 1
409
+ if (max < 1) return '(这一层暂时没得压)'
410
+ return max === 1 ? '1' : `1–${max} 里的一个`
411
+ }
412
+
413
+ // 低于地板时的下一步建议必须**可执行**:l3 没有 to,两块层的"更靠后的块"不存在,
414
+ // l0 的 to 已经是最后可写轮时也没有"更晚的轮次"。写做不到的指令=让模型白烧一次调用。
415
+ function tooSmallAdvice(op, live, config) {
416
+ if (op.key === 'l3') return 'l3 只能整区压,没有更大的区间可换;这一段就这么长的话,先别压它。'
417
+ if (op.key === 'l1' || op.key === 'l2') {
418
+ const count = (live.zones[op.key] || []).length
419
+ return count < 3
420
+ ? `这一层只剩 ${count} 块(最后一块不能压),没有更大的区间可换;先别压它。`
421
+ : '换一个更大的区间(把 to 写到更靠后的块);这一层的一段就这么长的话,先别压它。'
422
+ }
423
+ // l0:to 已经是最晚可写轮时,就别让它再往后写
424
+ const last = lastCompressibleTurn(live, config.keepTurns)
425
+ // op.to 是 buildOp 写进去的原文("T2" 这种字符串,见 :229),不是数字——早先按 number 判,
426
+ // 这支永远不触发(回归修复)。从字符串里取轮号。
427
+ const wrote = (() => { const m = /^T(\d+)$/.exec(String(op.to || '').trim()); return m ? Number(m[1]) : null })()
428
+ if (last !== null && wrote !== null && wrote >= last) return '这已经是最后能写的轮了,没有更晚的轮次可并;段本身就这么长的话,这次先别压 l0。'
429
+ return '把 to 写到更晚的轮次、把更多原文并进来;段本身就这么长的话,这次先别压 l0。'
430
+ }
431
+
432
+ function commitOne(ctx, session, agent, op, meter, config, zones, dry = false, log = null) {
433
+ // 每次落盘前重读一次表层:同批里排在前面(更靠上)的层已经写进去了,
434
+ // 它们的替换会把下面的节点下标整体往前挪。沿用批次开始时的快照,
435
+ // 就会出现"用新下标去比旧边界表"——非 exact 分支(消息数与节点数不等)会因此
436
+ // 在第二个键上判边界失败,甚至在偏移后巧合命中别的轮的边界而放行一个没对齐的区间。
437
+ let live = readZones(ctx, session, agent, log)
438
+ let body = op.summary
439
+ let startSeq = op.startSeq
440
+ let endSeq = op.endSeq
441
+ // L3 区是多块区:L2 的产物自成一块,落在 L3 区末尾(区间起点就是 L2 区第一块,
442
+ // 替换之后新块自然顶到旧 L3 块的下面 = L3 区尾部)。旧 L3 块原样不动,不拼接、不合并。
443
+ if (op.l2Recompute) {
444
+ const target = live.zones.l2[Number(op.l2To) - 1]
445
+ if (!target) {
446
+ return { ok: false, reason: 'L2 区的块在写入前变了(同一次调用里被前面的操作改过)——照本次结果里附的新地图改一处再试' }
447
+ }
448
+ startSeq = live.zones.l2[0].seq
449
+ endSeq = target.seq
450
+ body = op.summary
451
+ }
452
+
453
+ const nodes = live.nodes
454
+ const startIdx = nodes.indexOf(startSeq)
455
+ const endIdx = nodes.indexOf(endSeq)
456
+ if (startIdx < 0 || endIdx < 0 || startIdx > endIdx) {
457
+ return { ok: false, reason: `${op.key} 选的区间在表层上已经找不到了(地图过期或被别的东西改过)——照本次结果里附的新地图改一处再试` }
458
+ }
459
+ // 写入前**实时**再查一遍区间纯度(防护性检查):预演用的是一批开始时的快照,
460
+ // 同批里更深的层已经写进去了,表层结构可能已经变了。这里以刚读到的 live 为准,
461
+ // 宁可这一层失败整批作废,也不要凭旧快照把别的层的内容一起遮掉。
462
+ const impure = impureRange(op.key, { startIdx, endIdx }, live)
463
+ if (impure) {
464
+ return { ok: false, reason: `${impure}(这是写入前的实时复核,说明同批里前面的操作已经改过这一层——照新地图重来一次)` }
465
+ }
466
+
467
+ // 表层节点与派生消息不一定一一对应:宿主会把派生为空的节点跳过(被 max_tokens 截断的空
468
+ // 助手消息、空的注入消息)。以前这里一律拒绝,结果是一条会话一旦出现空节点,此后永远压不了。
469
+ // 现在:只要能按节点认出"这一条的消息"(`live.messageFor`,来自 blocks.js 的精确投影),
470
+ // 就照旧逐条查工具调用配对(最严,且对块起点也成立);
471
+ // 只有连精确映射都拿不到时,才退回"区间两端必须是整轮边界"这条更粗的判据。
472
+ const messages = session.deriveMessages()
473
+ const mapped = live.aligned === true
474
+ if (mapped) {
475
+ const unbalanced = checkPairing(live, startIdx, endIdx)
476
+ if (unbalanced) return { ok: false, reason: `${op.key}:${unbalanced}` }
477
+ } else {
478
+ const aligned = isTurnBoundaryPair(live, startIdx, endIdx)
479
+ if (aligned === null) {
480
+ return {
481
+ ok: false,
482
+ reason:
483
+ `会话派生消息数(${messages.length})与表层节点数(${nodes.length})对不上,` +
484
+ '而且拿不到轮次大纲,就没法确认区间两端落在整轮边界上。' +
485
+ '这一版压不了——不是坏了,等宿主把轮次大纲投影挂上就能继续。' +
486
+ '宿主自带的 /compact 会把分层全部拍平(表层上只剩它写的那段摘要,我们的块不再出现);' +
487
+ '原文事件仍在会话日志里,装了会话搜索时可以按事件号读回来。不要用它代替 compress。',
488
+ }
489
+ }
490
+ if (!aligned) {
491
+ return {
492
+ ok: false,
493
+ reason:
494
+ `${op.key} 这个区间两端没落在整轮边界上(拿到的起止下标 ${startIdx}/${endIdx})。` +
495
+ `这次会话里有 ${nodes.length - messages.length} 个空节点,宿主会跳过它们,消息与节点对不上,` +
496
+ '所以只能整轮整轮地压。把 to 挪到某一轮真正结束的位置(对照地图上可压的轮号),' +
497
+ '(to 只能调小,不能调大。)',
498
+ }
499
+ }
500
+ }
501
+
502
+ const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
503
+ const product = frame(op.key, op.title, body, shadowedSeqs, live, session, op.swallowed)
504
+
505
+ const compactionId = `ic-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
506
+ // 块正文自带一行取回线索:模型要知道"这块底下压着的原文还能按事件编号读回来",
507
+ // 否则它永远等于不知道有取回这回事,只会越写越啰嗦。
508
+ //
509
+ // 事件号必须是**真的原文事件号**(回归修复):压 l0 时区间节点就是原文事件,
510
+ // 直接用;压 l1/l2/l3 时区间里是旧块,节点的 seq 是那些块**写入时**的新事件号,
511
+ // 拿它当"原文事件号"会指向完全不同的另一条消息。这时改从被吸收的块头
512
+ // `〔L2|T16–T19|ev 4121–4390〕` 里把 ev 范围取出来并成一段。
513
+ const hint = retrievalLine(op.key, shadowedSeqs, live, config)
514
+ // 图片台账落在块正文的**最后一行**(规范 §17):取回线索常驻、台账按需有,
515
+ // 顺序反了的话,模型顺着"末行"去找会先撞上取回线索。
516
+ const ledger = imageLedger(live, shadowedSeqs, config).line
517
+ // L1/L0 交界声明:只有压 l0 才有意义——它下面紧接着的就是原文区。
518
+ // 为什么必须写在块里:压完之后,原本标着"下一轮 T16"的那条标记被并进了这块
519
+ // (它按表层下标属于 T15,区间必须连续,躲不掉),交界处就再也没有轮号了。
520
+ // 写"本块写就时"而不是"以下是":以后 T16–T18 再被压成第二块时,这块会落在
521
+ // 第二块正上方,那句话就成了历史陈述而不是假话。
522
+ const boundary = boundaryLine(op.key, op.to, live)
523
+ const message = userMessage({
524
+ content: [{ type: 'text', text: product + hint + boundary + ledger }],
525
+ source: { kind: CHECKPOINT_KIND, compactionId },
526
+ })
527
+
528
+ // 收益校验:用我们自己的估算器。宿主那份按 text.length/4 定价,中英文混排时
529
+ // 两侧偏差不同(英文几乎准、中文低估约 4 倍),比值会失真得很厉害。
530
+ // 事件里的 shadowedTokenCount 仍用宿主的口径,那是宿主的账。
531
+ // 只有图片这一项反过来:它按路由声明的真实图价算(A4),拿不到才退回固定 800。
532
+ const before = measureRange(meter, session, shadowedSeqs)
533
+ // 我们自己那份估值;取不到正文(0)时退回宿主口径,别把校验变成"永远不通过"。
534
+ // 消息与节点对不上时没法按下标取原文,只能把尺子整体降级成宿主口径——但两侧仍然同一把,
535
+ // 不做一边宿主一边自研这种不可比的比较。下面 beforeOwn/afterOwn 的成对选择已经保证了这点。
536
+ // 文本与图片分开算:文本走自研估算,图片走路由实价(两边都不重复计同一张图)。
537
+ const imagePart = mapped ? rangeImageTokens(ctx, agent, live, startIdx, endIdx, log) : { count: 0, tokens: 0 }
538
+ const beforeOwnRaw = mapped ? ownTokens(live, startIdx, endIdx, 0) + imagePart.tokens : 0
539
+ // 实际写进表层的正文含取回线索,校验必须按它算,否则"通过"之后写出来反而更大。
540
+ // 两侧必须同一把尺子:区间里没有我们能估的内容时(例如整段是未支持的块),
541
+ // 两侧一起退回宿主的 text.length/4,绝不允许一边宿主一边自研——那样比值会失真到没有意义。
542
+ const beforeOwn = beforeOwnRaw > 0 ? beforeOwnRaw : before
543
+ const afterOwn = beforeOwnRaw > 0 ? estimateTokens(product + hint + boundary + ledger) : hostTokens(product + hint + boundary + ledger)
544
+ if (afterOwn >= beforeOwn) {
545
+ // 两种成因要分开说:①摘要本身太长(写短就行);②这一段的原文太少,连块头开销都盖不住
546
+ // (写成负的也不会更小,只能把区间往大了取)。混成一句会让模型一直去改摘要(第四轮 C 的 D1)。
547
+ // 判据用真实地板 productFloor(key),不是"值不值得叫醒"的 MIN_L0_COST:
548
+ // 一段刚够地板地原文配短摘要真能压成一块,按叫醒门槛归因会让模型去改 to 而不是改摘要。
549
+ // 每一层都要判,不能只管 l0——l1 首块 96 < 地板 139 时同样压得动才怪(实测 116 对 139)。
550
+ const floorHere = productFloor(op.key, config)
551
+ const tooSmall = beforeOwn < floorHere
552
+ return {
553
+ ok: false,
554
+ reason: tooSmall
555
+ ? `这一段的原文太少(只有 ${beforeOwn} token),连一块的地板(${floorHere} token:块头、摘要须知、交界声明)都盖不住,压完不会比原样更小。` +
556
+ tooSmallAdvice(op, live, config) +
557
+ '这一批一个字都没写(预检阶段就停住了)。'
558
+ : `${op.key} 压完反而更大(被压 ${beforeOwn} token,压后 ${afterOwn} token),这一批一个字都没写(预检阶段就停住了)。` +
559
+ `宿主要求替换的内容必须比被替换的更小——把摘要写短一点(一块的地板只要 ${floorHere} token,这一段的原文并不算少),或者把区间往大了取。`,
560
+ }
561
+ }
562
+
563
+ // 路由:地图算过的同一份(zones.route);退不到再用 agent 的实时选择。
564
+ // 记录里的 provider/model 必须与实际计价的模型一致,否则回执的 savedTokens 会串账。
565
+ const route = (zones && zones.route) || routeOf(agent, null) || {}
566
+ const turn = live.currentTurn
567
+ // 预演:所有校验都过了就停在这里,一个字都不写。
568
+ // 调用方先让整批每一层都过一遍预演,全部通过才真的落盘——
569
+ // 这样"第二层不合格"不会发生在"第一层已经写进去"之后。
570
+ if (dry) return { ok: true, result: { key: op.key, dry: true } }
571
+ let started = false
572
+ let stage = 'start'
573
+ try {
574
+ const startEvent = session.append('compaction/start', { compactionId, turn })
575
+ started = true
576
+ stage = 'summary'
577
+ const summaryData = {
578
+ compactionId,
579
+ summary: [{ type: 'text', text: body }],
580
+ shadowedRange: { start: startSeq, end: endSeq },
581
+ shadowedSeqs,
582
+ shadowedTokenCount: before,
583
+ // provider/model 是宿主记录里的常驻字段:读不到路由时写空串而不是省略。
584
+ // 省略在现存日志里没问题,但旧日志升 v4 时会被 assertReleasedEventPayload 判缺字段拒掉。
585
+ provider: typeof route.provider === 'string' ? route.provider : '',
586
+ model: typeof route.model === 'string' ? route.model : '',
587
+ }
588
+ const summaryEvent = session.append('compaction/summary', summaryData)
589
+ stage = 'replace'
590
+ session.append('user/message', message, {
591
+ surfaceOp: { op: 'replace', startSeq, endSeq },
592
+ sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
593
+ })
594
+ stage = 'end'
595
+ session.append('compaction/end', { compactionId, turn })
596
+ } catch (error) {
597
+ const why = String(error && error.message ? error.message : error)
598
+ // 必须补结束,否则这条会话此后所有压缩都会被拒。
599
+ // 但如果开始事件本身就没写进去,补一条结束反而制造孤儿记录。
600
+ if (started) {
601
+ try {
602
+ session.append('compaction/end', { compactionId, turn, error: why })
603
+ } catch (endError) {
604
+ // 补不上就是真的补不上了。真实后果比"宿主一直 busy"重得多:
605
+ // 日志里留下未闭合的 `compaction/start`,之后**再聊一轮**就会往后面写 turn 事件,
606
+ // 而重放校验器遇到「未闭合 start 之后的 turn/*」直接判 SessionFormatError
607
+ // (ref/…/session-format-v3-to-v4/lib/index.js:743),这条会话**重开时打不开**。
608
+ // 所以这里必须说清"现在别在这条会话里继续",而不只是"别再压"。
609
+ return {
610
+ ok: false,
611
+ // 走到 stage === 'end' 说明替换那一步已经落地——块在表层上**已经生效**,
612
+ // 只是结束记录补不进去。回执再说"整批失败"会让模型以为没压成、再压一次。
613
+ applied: stage === 'end',
614
+ reason:
615
+ `${op.key} 写到 ${stage} 这一步时失败:${why};` +
616
+ `而且结束记录也补不上(${String(endError && endError.message ? endError.message : endError)})。` +
617
+ '这条会话的日志里留下了一个没闭合的压缩事务:**继续在这条会话里对话**会让它重开时加载失败,' +
618
+ '宿主自带的 /compact 也会一直报 busy。请立刻停手:**别再在这条会话里发消息,也不要先打开它**——' +
619
+ '宿主打开/恢复会话时会先补写 turn/end,那一步会让日志再也修不回来(重放报 SessionFormatError)。' +
620
+ '修法是离线的:完全退出 DSH → 备份日志 → 补一条配对的 compaction/end(同一个 compactionId、同一个 turn,带 error)' +
621
+ '或从那条 compaction/start 之后截断 → 确认能正常重放之后再打开。' +
622
+ (stage === 'end'
623
+ ? '另外:这一层的块**已经写进表层生效了**,不要再压同一段(会重复压缩);要确认就调 context_map 看块列表。'
624
+ : ''),
625
+ }
626
+ }
627
+ }
628
+ // 已经写进去的 start / summary 会留在日志里:宿主不会因此误锁(它有配对的 end),
629
+ // 但那条 summary 描述了一次没发生的压缩,所以把停在哪一步讲清楚,别让人以为整批都没写。
630
+ // applied 也要带上(回归修复):走到 stage === 'end' 说明替换已经落地、块已生效,
631
+ // 只是第一次写 end 失败、补写的那次成功了。漏掉它会让调用方以为"整批没发生":
632
+ // 增长基线不重设(用量已降,增长触发被饿死一段时间),模型还可能再压同一段。
633
+ const where = stage === 'start' ? '连开始记录都没写进去' : `停在 ${stage} 这一步(前面已写入的记录补了结束标记)`
634
+ const landed = stage === 'end'
635
+ return {
636
+ ok: false,
637
+ applied: landed,
638
+ reason: `${op.key} 写入失败:${why}(${where})` +
639
+ (landed ? '另外:这一层的块**已经写进表层生效了**,不要再压同一段(会重复压缩);要确认就调 context_map 看块列表。' : ''),
640
+ }
641
+ }
642
+
643
+ // 条数与块尾取回线索同一口径:宿主跳过的空节点不占"一条消息"(回归修复)。
644
+ // 两处不一致时,回执说 37 条、块里写 30 条,模型转述给用户就自相矛盾。
645
+ const bodyCount = shadowedSeqs.filter((seq) => {
646
+ const meta = zones && zones.info && typeof zones.info.get === 'function' ? zones.info.get(seq) : null
647
+ return !meta || meta.kind !== 'empty'
648
+ }).length
649
+ return {
650
+ ok: true,
651
+ result: {
652
+ key: op.key,
653
+ compactionId,
654
+ title: op.title,
655
+ shadowedCount: bodyCount || shadowedSeqs.length,
656
+ shadowedTokenCount: before,
657
+ framedTokenCount: afterOwn,
658
+ savedTokens: Math.max(0, beforeOwn - afterOwn),
659
+ // 这次切点跨过了轮次记录里缺失的轮:回执要说明,不然块头标签看起来像"压少了"。
660
+ swallowed: Array.isArray(op.swallowed) ? op.swallowed : [],
661
+ },
662
+ }
663
+ }
664
+
665
+ // 区间边界不能切开一对工具调用与它的结果:切开之后下一轮请求会被服务端拒。
666
+ // 判据:区间内开着的工具调用,必须在区间内全部有结果;区间内的结果,也必须有对应的调用。
667
+ function checkPairing(zones, startIdx, endIdx) {
668
+ const calls = new Set()
669
+ const results = new Set()
670
+ for (let i = startIdx; i <= endIdx; i++) {
671
+ const seq = zones.nodes[i]
672
+ // 有精确映射时按节点取消息(空节点取到 null,正好跳过),否则退回按下标。
673
+ const message = zones.messageFor ? zones.messageFor(seq) : zones.messages[i]
674
+ if (!message || typeof message !== 'object') continue
675
+ // 结果也可能是独立的 role:'tool' 消息,id 挂在消息顶层(不一定是 content 里的块)。
676
+ if (message.role === 'tool' && message.toolCallId) results.add(String(message.toolCallId))
677
+ const content = message.content
678
+ if (!Array.isArray(content)) continue
679
+ for (const block of content) {
680
+ if (!block || typeof block !== 'object') continue
681
+ const id = block.toolCallId || block.id
682
+ if (block.type === 'tool-call' && id) calls.add(String(id))
683
+ if (block.type === 'tool-result' && id) results.add(String(id))
684
+ }
685
+ }
686
+ const unanswered = [...calls].filter((id) => !results.has(id))
687
+ const orphaned = [...results].filter((id) => !calls.has(id))
688
+ if (!unanswered.length && !orphaned.length) return null
689
+ const parts = []
690
+ if (unanswered.length) parts.push(`有 ${unanswered.length} 个工具调用没有结果跟着一起被压`)
691
+ if (orphaned.length) parts.push(`有 ${orphaned.length} 个工具结果找不到对应的调用`)
692
+ return (
693
+ `这个边界会把工具调用和它的结果切开(${parts.join('、')}),` +
694
+ '切开之后下一轮请求会被服务端拒。把 to 调小一轮,让整对调用和结果都落在区间里。'
695
+ )
696
+ }
697
+
698
+
699
+ // 用我们自己的估算器给一段区间计价:按派生消息里可见的文本累加。
700
+ // 不碰 session.eventAt(官方已标弃用、禁止新增调用)。
701
+ function safeJson(value) {
702
+ try {
703
+ return JSON.stringify(value) ?? ''
704
+ } catch {
705
+ return ''
706
+ }
707
+ }
708
+ function ownTokens(zones, startIdx, endIdx, imageTokens = IMAGE_TOKENS) {
709
+ let total = 0
710
+ for (let i = startIdx; i <= endIdx; i++) {
711
+ const seq = zones.nodes[i]
712
+ total += messageTokens(zones.messageFor ? zones.messageFor(seq) : zones.messages[i], imageTokens)
713
+ }
714
+ return total
715
+ }
716
+
717
+ // 区间里所有图片出现位置的真实价(A4)。拿不到按路由的价就退回固定 800——
718
+ // 设计约束 的条件:频繁切模型时最坏只是退回固定价,不能报错、不能中断压缩。
719
+ function rangeImageTokens(ctx, agent, zones, startIdx, endIdx, log) {
720
+ const images = []
721
+ // 只给"这次请求里真会出现"的图片定价(回归修复):宿主图片离线化是 message 投影,
722
+ // 投影之后请求里已经没有 image 块了,按原始事件里的图定价会让回执虚高、savedTokens 变负。
723
+ // 投影可用(aligned 且与节点等长)时以投影为准,否则退回逐节点精确映射。
724
+ const projected = zones.aligned === true && Array.isArray(zones.messages) && zones.messages.length === zones.nodes.length
725
+ ? zones.messages
726
+ : null
727
+ for (let i = startIdx; i <= endIdx; i += 1) {
728
+ const seq = zones.nodes[i]
729
+ const message = projected ? projected[i] : (zones.messageFor ? zones.messageFor(seq) : zones.messages[i])
730
+ const content = message && Array.isArray(message.content) ? message.content : []
731
+ for (const block of content) if (block && block.type === 'image') images.push(block)
732
+ }
733
+ if (!images.length) return { count: 0, tokens: 0 }
734
+ const route = (zones && zones.route) || routeOf(agent, null) || {}
735
+ const prices = imagePrices(ctx, route, images, log)
736
+ const tokens = prices ? prices.reduce((a, b) => a + b, 0) : images.length * IMAGE_TOKENS
737
+ return { count: images.length, tokens }
738
+ }
739
+ function measureRange(meter, session, seqs) {
740
+ if (!meter || typeof meter.measure !== 'function') return 0
741
+ const m = meter.measure(session)
742
+ const prices = new Map((m.nodes || []).map((n) => [n.seq, n.tokens]))
743
+ let total = 0
744
+ for (const seq of seqs) total += prices.get(seq) || 0
745
+ return total
746
+ }
747
+
748
+ // L1/L0 交界声明:块正文里最后一句"其下是什么"。
749
+ // 只有 l0 的产品需要——它下面紧接着就是原文区;l1/l2/l3 的产品不产生新的 L0 边界。
750
+ // 为什么必须写:压 l0 时,原本标着"下一轮 T16"的轮次标记会被一起折进这块
751
+ // (它按表层下标属于 T15,区间必须连续,躲不掉),交界处就再也没有轮号了。
752
+ // 措辞用"本块写就时"而不是"以下是":以后 T16–T18 再被压成第二块时,这块会落在
753
+ // 第二块正上方,那句"其下是原文区"就不再是当前事实;加了"写就时"它就永远是历史陈述。
754
+ export function boundaryLine(key, to, zones) {
755
+ if (key !== 'l0') return ''
756
+ const m = /^T(\d+)$/.exec(String(to || '').trim())
757
+ if (!m) return ''
758
+ const toTurn = Number(m[1])
759
+ // 别写死 to+1:T(to+1) 可能是空轮(有 turn/start、没有任何表层节点,比如上一轮被取消),
760
+ // 那样模型照着这个号去写 to,`resolveTurnAnchor` 会判"第 N 轮在表层上找不到原文",
761
+ // 白烧一次调用(回归修复)。所以取"块后第一个真的还有原文的轮"。
762
+ let next = toTurn + 1
763
+ const raw = zones && zones.zones ? zones.zones.l0 : null
764
+ if (Array.isArray(raw)) {
765
+ const after = raw.filter((n) => n && typeof n.turn === 'number' && n.turn > toTurn)
766
+ if (after.length) next = after[0].turn
767
+ }
768
+ return `\n(本块写就时,其下是原文区,第一条原文轮次为 T${next})`
769
+ }
770
+
771
+ // 块头自带元信息:插件以后不需要任何台账就能知道它是哪一层、盖了哪些轮。
772
+ // 〔L2|T16–T19|ev 4121–4390〕标题
773
+ //
774
+ // 图片台账:压掉一块之后,那块里的图片在模型眼里就彻底消失了(宿主的图片离线化只处理
775
+ // "最老的图片",不会因为压缩而单独记一笔)。所以在块正文末尾留一行"这块里有几张图、事件号
776
+ // 是多少",需要看图时能按事件号取回。没有图片时不写这一行——绝大多数块都没有。
777
+ // 外来检查点(宿主压缩写的块)块头里没有我们的 `ev`,但它正文的台账行写着事件号范围。
778
+ // 折叠这种块时如果不把它并进来,新块头与取回线索会把那一整段历史漏掉(回归修复)。
779
+ // 只认〔…〕包起来的台账行,并取**最后一条**:摘要正文里随口写一句「事件号 5000」
780
+ // 不能把块头的 ev 上界拉宽(回归修复)。
781
+ // 两类台账共用「这块压掉了」前缀:消息台账写「压掉了 12 条」,图片台账写「压掉了 2 张图片」。
782
+ // 只认"条消息 / 个字块"那一条,否则块正文里靠后的图片台账会把事件号范围带偏(回归修复)。
783
+ const TEXT_EV_RE = /〔这块(?:压掉了 \d+ 条消息|并掉了 \d+ 个字块)[^〕]{0,80}?事件号\s*(\d+)\s*(?:[–-]\s*(\d+))?/g
784
+ function ledgerEvents(text) {
785
+ const out = []
786
+ const s = String(text || '')
787
+ TEXT_EV_RE.lastIndex = 0
788
+ let m
789
+ while ((m = TEXT_EV_RE.exec(s)) !== null) {
790
+ const a = Number(m[1])
791
+ const b = m[2] === undefined ? a : Number(m[2])
792
+ if (Number.isFinite(a) && Number.isFinite(b)) out.push(a, b)
793
+ }
794
+ // 有多条就取最后一条(台账总在块尾);前面的匹配可能来自摘要正文
795
+ return out.length ? out.slice(-2) : null
796
+ }
797
+
798
+ function imageSeqs(zones, shadowedSeqs) {
799
+ const out = []
800
+ let count = 0
801
+ if (!zones || typeof zones.messageFor !== 'function') return Object.assign(out, { count: 0 })
802
+ const LEDGER_RE = /这块压掉了 (\d+) 张图片(事件号 ([^)]*))/
803
+
804
+ for (const seq of shadowedSeqs) {
805
+ let msg = null
806
+ try { msg = zones.messageFor(seq) } catch { msg = null }
807
+ if (!msg || !Array.isArray(msg.content)) continue
808
+ if (msg.content.some((part) => part && part.type === 'image')) {
809
+ out.push(seq)
810
+ // 张数按**图片块**数,不按消息数:一条消息带 3 张图就是 3 张。
811
+ // 成本侧(rangeImageTokens/costOfMessage)本来就是按块累加的,这里少算会让
812
+ // 「台账写 2 张、省钱按 5 张算」,模型按台账也找回不全(隔离实验抓到的缺陷)。
813
+ count += msg.content.filter((part) => part && part.type === 'image').length
814
+ continue
815
+ }
816
+ // 合并旧块时图片台账要往下传(回归修复):图片本体早就在上一层被压掉了,
817
+ // 只留下块尾那行"事件号 …"。不接手的话,L1 并成 L2 之后这些图片就再也点不到名。
818
+ // 张数取上一层的总数(hit[1]),不是"它列出来的几个号"——上层只列前 6 个,
819
+ // 按号数重算会把 100 张缩成 6 张(回归修复)。
820
+ // 只有"我们自己的块"才认台账句:正文里引用同一句话都是模型写的散文,
821
+ // 拿它当台账会产出互斥的两个事件号区间(回归修复)。
822
+ const text = msg.content.map((part) => (part && part.type === 'text' && part.text) || '').join('')
823
+ const info = zones.info && zones.info.get ? zones.info.get(seq) : null
824
+ // 认"我们自己的块":有 info 就以 info 为准;退化的桩/老数据没有 info 时,
825
+ // 只有块头形态(〔L1|…〕)才算——纯正文里的引用不算。
826
+ const looksBlock = info ? info.kind === 'block' : /^〔L[123]|/.test(String(text).trim())
827
+ if (!looksBlock) continue
828
+ const hit = LEDGER_RE.exec(text)
829
+ if (!hit) continue
830
+ const inherited = Number(hit[1])
831
+ count += Number.isInteger(inherited) && inherited > 0 ? inherited : 0
832
+ for (const piece of hit[2].split(/[、,,\s]+| 等/)) {
833
+ const n = Number(piece)
834
+ if (Number.isInteger(n) && n > 0) out.push(n)
835
+ }
836
+ }
837
+ const seqs = [...new Set(out)]
838
+ // 数量挂在数组上:台账行只列前 6 个号,但"有几张"必须一直是真数。
839
+ seqs.count = count
840
+ return seqs
841
+ }
842
+
843
+ // 台账行的拼装单独抽出来,好让测试直接断言"有图才写、写的是哪几个事件号"。
844
+ export function imageLedger(zones, shadowedSeqs, config) {
845
+ const seqs = imageSeqs(zones, shadowedSeqs)
846
+ const count = typeof seqs.count === 'number' ? seqs.count : seqs.length
847
+ // 台账行也是块正文的一部分,跟块尾同一套口径(回归修复)。
848
+ const line = count === 0
849
+ ? ''
850
+ : `\n\n〔这块压掉了 ${count} 张图片(事件号 ${seqs.slice(0, 6).join('、')}${count > 6 ? ' 等' : ''})。${retrievalHintTool(config)}〕`
851
+ return { seqs, count, line }
852
+ }
853
+
854
+ // 块尾的取回线索:只写真的原文事件号,别把"块写入时的新事件号"当成原文号。
855
+ // 取回线索:默认点名 session_event_read(装了会话搜索才有)。用户把 requireSessionQuery 关掉,
856
+ // 正是为了"没装也照常用"——那时候点名一个不存在的工具,等于教模型去撞一次失败(审查 A#3)。
857
+ // 两段分开:**"原文还在"永远要说**(安抚是免费的事实),只有"用哪个工具读"才看开关
858
+ // (回归修复)。
859
+ function retrievalKeep() {
860
+ return '原文还在日志里。'
861
+ }
862
+
863
+ function retrievalToolName(config) {
864
+ return config && config.requireSessionQuery === false ? '' : '用 session_event_read 按事件号读。'
865
+ }
866
+
867
+ function retrievalHintTool(config) {
868
+ return retrievalKeep() + retrievalToolName(config)
869
+ }
870
+
871
+ function retrievalLine(key, shadowedSeqs, live, config) {
872
+ const tail = retrievalHintTool(config)
873
+ if (key === 'l0') {
874
+ // 端点要落在"真有正文"的节点上:空派生节点也占事件号,指向它等于让人去搜一条空气。
875
+ const body = shadowedSeqs.filter((seq) => {
876
+ const meta = live && live.info && typeof live.info.get === 'function' ? live.info.get(seq) : null
877
+ return !meta || meta.kind !== 'empty'
878
+ })
879
+ const list = body.length ? body : shadowedSeqs
880
+ const first = list[0]
881
+ const last = list[list.length - 1]
882
+ // 条数也要按"真有正文"的节点算:端点跳过了空节点、条数却把空气算进去,
883
+ // 就成了"37 条消息(事件号 12–48)"而中间其实只有 30 条(回归修复)。
884
+ return `\n\n〔这块压掉了 ${list.length} 条消息(事件号 ${first}–${last})。${tail}〕`
885
+ }
886
+ const evs = []
887
+ let blocks = 0
888
+ let unknownBlocks = 0
889
+ for (const seq of shadowedSeqs) {
890
+ const meta = live.info && typeof live.info.get === 'function' ? live.info.get(seq) : null
891
+ if (!meta || meta.kind !== 'block' || !meta.block) continue
892
+ blocks += 1
893
+ if (Array.isArray(meta.block.events) && meta.block.events.some((ev) => typeof ev === 'number')) {
894
+ for (const ev of meta.block.events) if (typeof ev === 'number') evs.push(ev)
895
+ } else {
896
+ const parsed = ledgerEvents(meta.block.text)
897
+ if (parsed) for (const ev of parsed) evs.push(ev)
898
+ else unknownBlocks += 1
899
+ }
900
+ }
901
+ if (!evs.length) {
902
+ // 全都读不出事件号时也不能装没事:块尾必须交代"这一段没有索引",否则模型会以为
903
+ // 这块压根没盖到历史内容(SPEC §4.8 的登记例外)。
904
+ // 用词要分清:一块都没读懂就是"这 N 块",只漏了一部分才说"另有 N 块"(回归修复)。
905
+ const blind = unknownBlocks
906
+ ? unknownBlocks === blocks
907
+ ? `(这 ${blocks} 块的事件号在块头里都没有记录,只能从日志里找)`
908
+ : `(另有 ${unknownBlocks} 块的事件号在块头里没有记录,只能从日志里找)`
909
+ : ''
910
+ // 交代盲区的话放在"原文还在"之前:先说清"索引缺了一半",再说"原文没删"(G3)。
911
+ return `\n\n〔这块并掉了 ${blocks} 个字块。${blind}${tail}〕`
912
+ }
913
+ const first = Math.min(...evs)
914
+ const last = Math.max(...evs)
915
+ const caveat = unknownBlocks ? `(另有 ${unknownBlocks} 块的事件号在块头里没有记录,只能从日志里找)` : ''
916
+ return `\n\n〔这块并掉了 ${blocks} 个字块,它们合计压掉事件号 ${first}–${last}。${caveat}${tail}〕`
917
+ }
918
+
919
+ function frame(key, title, body, shadowedSeqs, zones, session, swallowed) {
920
+ const tier = key === 'l0' ? 1 : key === 'l1' ? 2 : 3
921
+ let lo = null, hi = null, evLo = null, evHi = null
922
+ let turnsUnknown = false
923
+ // 无主原文(首界标之前的节点)没有轮号:它的下界就是"未知",不能拿别的节点的轮号顶上去。
924
+ let loUnknown = false
925
+ let evUnknown = false
926
+ // 吸收过"事件号上界只是下限"的块(块头写过 `ev …+`):新一代也必须继续带 `+`,
927
+ // 否则界限会被越传越窄。
928
+ let evOpen = false
929
+ for (const seq of shadowedSeqs) {
930
+ const entry = zones.info.get(seq)
931
+ let turns = null, events = null
932
+ if (entry && entry.kind === 'block') {
933
+ turns = entry.block.turns
934
+ // 无主原文压出来的块,下界是 `null`。再压一次时不能把它当成"有一个未知的上界"
935
+ // (那会写出 `T2+`:下界谎报成 T2、`+` 也是假的,还会被后代继承)。
936
+ // 下界未知 = loUnknown;上界仍然准,所以只把 hi 留给通用循环(回归修复)。
937
+ if (Array.isArray(turns) && turns[0] === null) {
938
+ loUnknown = true
939
+ turns = typeof turns[1] === 'number' ? [turns[1], turns[1]] : turns
940
+ }
941
+ events = entry.block.events
942
+ // 外来块的 events 是 [null, null](有数组、没有数字),也要走"从正文台账补"这条路。
943
+ if (!Array.isArray(events) || !events.some((ev) => typeof ev === 'number')) {
944
+ const parsed = ledgerEvents(entry.block.text)
945
+ if (parsed) events = parsed
946
+ else evUnknown = true
947
+ }
948
+ // 旧块头写过 `ev …+`:上界本来就只是下限,新一代继续带 `+`。
949
+ if (entry.block.eventsOpen) evOpen = true
950
+ // 块头带 `+`:说明这块吸收过轮号未知的内容,它写的 hi 只是下限。
951
+ // 再把它吸收一次时,新一代的块头必须继续带 `+`,否则界限会被收窄、越传越窄。
952
+ if (entry.block.turnsOpen) turnsUnknown = true
953
+ } else if (entry && entry.kind === 'empty') {
954
+ // 空派生节点:没有轮次可归,但确实占了这一个事件号——只把事件号算进范围,不动轮号。
955
+ events = [seq, seq]
956
+ } else {
957
+ const raw = zones.zones.l0.find((n) => n.seq === seq)
958
+ if (raw) {
959
+ // 无主原文:下界未知(loUnknown),但**上界仍然准确**——它不是"可能更宽",
960
+ // 所以不该打 `+`(`+` 留给吸收过 turnsOpen 的块)。回归修复。
961
+ if (typeof raw.turn === 'number') turns = [raw.turn, raw.turn]
962
+ else loUnknown = true
963
+ events = [raw.originSeq, raw.originSeq]
964
+ }
965
+ }
966
+ if (turns) {
967
+ for (const t of turns) {
968
+ if (typeof t === 'number') { lo = lo === null ? t : Math.min(lo, t); hi = hi === null ? t : Math.max(hi, t) }
969
+ else turnsUnknown = true
970
+ }
971
+ }
972
+ if (events) {
973
+ for (const e of events) if (typeof e === 'number') { evLo = evLo === null ? e : Math.min(evLo, e); evHi = evHi === null ? e : Math.max(evHi, e) }
974
+ }
975
+ }
976
+ // 切点跨过缺失的轮(`swallowed`):那些轮的内容确实被压进来了,但没有自己的边界。
977
+ // 块头不把它们算进来的话,这块明明含着 T3 的内容、却写"轮 1–2",地图和下一代判断
978
+ // 都会按更窄的范围理解——审查 A 抓到的"永久低报覆盖面"。
979
+ // 做法:把被跨过的轮号并进 hi(它们的边界未知,所以同时打 `+`,表示"至少到此、可能更宽")。
980
+ if (Array.isArray(swallowed) && swallowed.length) {
981
+ for (const t of swallowed) {
982
+ if (typeof t !== 'number') continue
983
+ lo = lo === null ? t : Math.min(lo, t)
984
+ hi = hi === null ? t : Math.max(hi, t)
985
+ }
986
+ turnsUnknown = true
987
+ }
988
+ // 吸收进来的块里只要有一块不知道自己盖了哪些轮(外来检查点、旧版本写的块),
989
+ // 块头就写成 `T16–T19+`:加号表示"至少到这里,实际可能更宽"。
990
+ // 不标的话,块头会报得比实际窄,之后 l0 的越界检查(块头 hi > to 就拒)就看不见它,
991
+ // 会把不在 to 名下的轮次一起吸进新摘要——静默多压,而且错会被下一代块继承下去。
992
+ // 下界未知(块里含"首界标之前的无主原文")时不能只写 `T?`——上界其实是知道的,
993
+ // 写 `T?–T2` 才诚实:模型既知道盖到哪一轮,也知道更早还有东西(回归修复)。
994
+ const turnPart = loUnknown || lo === null
995
+ ? (hi === null ? 'T?' : `T?–T${hi}`) + (turnsUnknown ? '+' : '')
996
+ : (lo === hi ? `T${lo}` : `T${lo}–T${hi}`) + (turnsUnknown ? '+' : '')
997
+ // 混进过「事件号读不出来」的块:ev 上界同样只是下限,与轮号的 `+` 同一约定(回归修复)。
998
+ const evPart = evLo === null ? '' : `|ev ${evLo}–${evHi}${evUnknown || evOpen ? '+' : ''}`
999
+ const head = `${TITLE_OPEN}L${tier}|${turnPart}${evPart}${TITLE_CLOSE}${title}`
1000
+ return `${head}\n${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}${body}${SUMMARY_CLOSE_TAG}`
1001
+ }
1002
+
1003
+
1004
+
1005
+ function fail(reason, partial) {
1006
+ // 万一真写到一半断了:把"哪几层已经进去了"直接写进给人看的话里,
1007
+ // 不能只说"整批没写"——那会让模型和人都以为表层没变,接着拿旧地图去压。
1008
+ const done = Array.isArray(partial) && partial.length
1009
+ ? partial.map((r) => r && r.key ? r.key : '?').join('、')
1010
+ : ''
1011
+ const text = done ? `${reason}\n(注意:${done} 这几层已经写进会话表层了,旧地图作废,照本次结果里附的新地图再继续。)` : reason
1012
+ // 只要已经落地过任何一层,就必须让调用方知道(`applied`)——否则增长基线不重设,
1013
+ // "又长了"这条提醒会被饿死到用量重新长过旧峰值(回归修复)。
1014
+ return { ok: false, reason: text, partial: partial || [], applied: Array.isArray(partial) && partial.length > 0 }
1015
+ }