@logictan/dsh-verbatim-compaction 0.2.0 → 0.3.0
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/README.md +35 -12
- package/lib/index.js +176 -67
- package/lib/types/index.d.ts +44 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @logictan/dsh-verbatim-compaction
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把上下文压缩的**摘要**从「让模型重写整段历史」换成「用户原话逐字保留,助手部分只留每轮提取的结论」:用户的话一个字不改,助手正文由一次模型调用换成「做了什么 / 得到什么结论 / 哪些结论被推翻」三节,推理块从不读取。
|
|
4
|
+
|
|
5
|
+
用户原话与助手正文待遇不同,理由是二者可重建性不同:助手回复能从它的工具调用和上下文推回来,用户的纠正、警告、约束推不回来。宿主原生压缩会改写这些原话,那正是本插件要消除的损失。
|
|
4
6
|
|
|
5
7
|
## 它解决什么
|
|
6
8
|
|
|
@@ -16,21 +18,29 @@
|
|
|
16
18
|
|
|
17
19
|
```
|
|
18
20
|
## user (turn 37)
|
|
19
|
-
|
|
21
|
+
(用户原话,逐字,永不改写)
|
|
20
22
|
|
|
21
23
|
### assistant (turn 37)
|
|
22
|
-
|
|
24
|
+
## Did
|
|
25
|
+
- 实测 `auto_vacuum=FULL` 删除后自动缩容:19.61 MB → 0.01 MB
|
|
26
|
+
## Concluded
|
|
27
|
+
- 选 FULL 而非 INCREMENTAL:回收发生在宿主自己的 DELETE 里,不受 busy_timeout=0 影响
|
|
28
|
+
## Overturned
|
|
29
|
+
- 先前认为必须显式执行 VACUUM 才能回收空间
|
|
23
30
|
tools: bash×280 ssh_exec×44 web_search×6 (408830 bytes)
|
|
24
31
|
errors: 1: Error: 命令超过 600 秒未结束,已断开连接
|
|
25
32
|
wrote: /Volumes/LogicExt/Git/BookReader/src/video.js
|
|
26
33
|
```
|
|
27
34
|
|
|
28
|
-
-
|
|
35
|
+
- **用户原话逐字**,不重写、不改写、不摘要。
|
|
36
|
+
- **助手正文由提取块替代**,不并列保留——并列就是同一份内容写两遍。
|
|
29
37
|
- 工具结果只留「工具名 × 次数 + 字节数」,内容丢弃。
|
|
30
|
-
- 错误只留首行(100
|
|
31
|
-
- `wrote`
|
|
38
|
+
- 错误只留首行(100 字符)。
|
|
39
|
+
- `wrote` 记录本轮写过的路径。
|
|
32
40
|
- 推理块从不进入摘要,也不读取。
|
|
33
41
|
|
|
42
|
+
为什么助手正文不再逐字保留:实测 8 个会话、4400 条助手正文、748743 字符,其中 58.2% 是有实质结论的长文、35.8% 是带数据(数字/代码/引用)的短句、6.1% 是纯过程叙述(「现在我来看…」)。而字面重复几乎为零(段落级完全相同 0.1%)。也就是说,重复不在字面,而在**助手正文与提取块说了同一件事**;两者并列保留就是把这 58.2% 写两遍,还通过预算把真正的历史挤出去。
|
|
43
|
+
|
|
34
44
|
## 摘要源是日志,不是被阴影的区间
|
|
35
45
|
|
|
36
46
|
上一份 checkpoint 每次都被一起阴影掉,所以「只摘要被阴影区间」会把上一份摘要承载的全部历史再丢一次。本插件扫描会话日志里 `surfaceOp === 'append'` 的事件,减去本次压缩后仍留在 surface 上的尾部,重建整段历史——不需要任何累积状态,摘要源集合逐次单调增长(122 次压缩实测 0 次非单调)。
|
|
@@ -39,9 +49,9 @@
|
|
|
39
49
|
|
|
40
50
|
## 工具轨迹补出的三节
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
只抄正文救不了「正文为空」的轮次。实测一个 13878 事件的会话:1973 条 assistant 消息里只有 317 条带正文,其中一轮跑了 342 次工具调用却一个字没写,纯逐字摘要把它压成一行 `tools: bash×281`——活干了,记录没了。
|
|
43
53
|
|
|
44
|
-
|
|
54
|
+
所以提取的材料是三段:用户请求(逐字)、助手正文(逐字)、工具轨迹(每条调用的 `description`,失败的调用另记一行首行错误)。模型输出固定的三节:`## Did` / `## Concluded` / `## Overturned`。
|
|
45
55
|
|
|
46
56
|
模型用的是会话自己的路由(`session.requestHeader()?.config`),和宿主原生压缩同一个取法,不需要单独配置凭据。
|
|
47
57
|
|
|
@@ -49,13 +59,25 @@
|
|
|
49
59
|
|
|
50
60
|
- **失败信号只认 `data.isError`**。曾用 `/error/i` 匹配序列化文本,347 条成功结果全部命中(工具输出里常有 "error" 字样),模型据此报告「整轮失败」。测试里有一条专门钉住这个坑。
|
|
51
61
|
- **工具调用从 `assistant/message` 的 block 里取**。日志里没有独立的 `tool/call` 事件类型,按它过滤会一条都取不到。
|
|
52
|
-
-
|
|
62
|
+
- **提取失败就退回逐字正文**。模型不可达、超时、输出为空时,该轮保留原来的助手正文;压缩本身不能因此失败,否则会话死于上下文溢出,比丢细节更糟。降级是逐轮的:一批里某一轮没答,只有那一轮退回正文。
|
|
53
63
|
|
|
54
|
-
|
|
64
|
+
提取按批调用:每 8 轮一次,一轮输出一个 `=== TURN n ===` 块。早期实现是每轮一次调用,122 轮就是 131 次往返,单轮 400 token 的输出上限乘以 131 就可能超过整个摘要上限。批处理把往返压到约 17 次,并共享 prompt 前缀以复用 provider 的 KV cache。
|
|
65
|
+
|
|
66
|
+
用户原话在布局上**先占额度**,提取块不得挤占。实测那个 122 轮会话:区间内真人原话共 469 字符(117 token),而每轮一块 420 字符的提取共 55020 字符——提取是正文的 117 倍,任何让二者竞争的布局都会把正文挤掉,而正文是「请用pixi管理开发环境」这类永不过期的约束,提取描述的却是已经做完的工作。
|
|
55
67
|
|
|
56
68
|
## 为什么不会触发 shrink 守卫
|
|
57
69
|
|
|
58
|
-
基类的守卫比较「framed 摘要」与「被替换内容的 route
|
|
70
|
+
基类的守卫比较「framed 摘要」与「被替换内容的 route 价」,摘要不小于原内容就整次压缩失败。本插件的预算取 `min(DIGEST_TOKEN_CEILING, shadowedPrice − 86 − 512)`,上限 20000 token 远小于任何实测区间价,因此这条失败路径按构造不可能发生。
|
|
71
|
+
|
|
72
|
+
上限取**绝对值**而非「区间价的比例」。比例会让摘要随历史增长:同一会话实测早期区间 2642 字符、后期 51019 字符、触发事故那段 348539 字符——因为区间越大就允许摘要越大,而接近阈值的摘要立刻再触发压缩。固定上限让摘要大小与历史长度无关,宿主原生摘要也是这个形状(实测各区间稳定在 14009–22208 字符)。
|
|
73
|
+
|
|
74
|
+
## 为什么会收敛(一条真实事故)
|
|
75
|
+
|
|
76
|
+
只满足守卫**不等于**收敛。守卫只要求摘要比被替换的内容小,一个占到被替换量 99.3% 的摘要照样通过守卫,而 surface 几乎没变。
|
|
77
|
+
|
|
78
|
+
会话 `小鹏汽车 App 改造评估` 上真实发生过:一次压缩 shadowed 87733 token,产出摘要 priced 87135——只回收 598 token(0.7%)。压缩后 surface 实测 **256704** token,阈值 **256464**,刚好卡在阈值上方,于是下一步立刻又压;此时 surface 上除 checkpoint 外没别的东西,第二次只 shadowed **1 个 seq**(就是刚写的那条 checkpoint),把同样的 34.8 万字符原样重抄一遍(346555 vs 348539),第三次直接 abort。
|
|
79
|
+
|
|
80
|
+
预算改成被替换量的一半后,同一份日志实测:257696 → **188120** token(0.73× 阈值),一轮就落到阈值以下。只压一条 checkpoint 时摘要也减半(279048 → 139865 字符),因此是几何衰减而非固定点。
|
|
59
81
|
|
|
60
82
|
## 安装
|
|
61
83
|
|
|
@@ -107,7 +129,8 @@ pnpm add @logictan/dsh-verbatim-compaction
|
|
|
107
129
|
|
|
108
130
|
## 已知边界
|
|
109
131
|
|
|
110
|
-
-
|
|
132
|
+
- **每轮的正文/提取块会被均匀削薄,用户原话不会。** 用户原话先占额度(`USER_BUDGET_SHARE` = 0.9),剩余空间再分给各轮的提取块或正文,超出时按轮等比截断并标出省略字符数。只有在每轮都已削到零仍放不下时才整轮丢弃,且会写明丢了几轮——正常长会话不会走到这一步(122 轮实测 0 次)。
|
|
133
|
+
- **提取块可能被截断。** 122 轮、每轮 420 字符提取的极端情形会触到上限,块尾被切并标出字符数。真人原话不受影响。
|
|
111
134
|
- **`turn/start` 缺失时轮次编号退化为 `turn ?`。** 不会丢正文,只是编号不可读。
|
|
112
135
|
- **注入内容不进摘要。** 判据是 `source.kind !== 'user'`——注入上下文在一个真实会话里占 user 消息字符的 97%,放进摘要会把人的原话淹掉。
|
|
113
136
|
- **不改善首轮之外的工具参数细节。** `bash` 命令本身(占工具参数 84.2%)不保留;需要「当时跑了什么命令」的场景不在本插件的覆盖范围内。
|
package/lib/index.js
CHANGED
|
@@ -34,22 +34,41 @@ import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic';
|
|
|
34
34
|
/** Cordis companion plugin name. Must equal this plugin's `cordis.patch.yml` row id. */
|
|
35
35
|
export const name = 'verbatim-compaction';
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
37
|
+
* Absolute ceiling for one digest, in the meter's fixed 4-chars-per-token
|
|
38
|
+
* heuristic.
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* A share of the shadowed span is the wrong shape: it makes the digest grow
|
|
41
|
+
* with history. Measured on one real session, the share-based budget produced
|
|
42
|
+
* 2642 chars on an early span, 51019 on a later one, and 348539 on the span that
|
|
43
|
+
* triggered the compaction loop — because a bigger span licensed a bigger
|
|
44
|
+
* digest, and a digest near the threshold re-triggers immediately.
|
|
45
|
+
*
|
|
46
|
+
* A fixed ceiling makes the digest size independent of how long the session has
|
|
47
|
+
* run, which is what the host's own summarizer does (its summaries measured
|
|
48
|
+
* 14009-22208 chars across spans of 108829-173514 tokens).
|
|
49
|
+
*
|
|
50
|
+
* 20000 tokens holds roughly 100 turns of user prose plus one extracted block
|
|
51
|
+
* per turn, and leaves the checkpoint far below any measured threshold.
|
|
43
52
|
*/
|
|
44
|
-
const
|
|
53
|
+
export const DIGEST_TOKEN_CEILING = 20000;
|
|
45
54
|
/** Framing overhead `frameSummary` adds: preamble plus both tags, 341 chars = 86 tokens. */
|
|
46
55
|
const FRAME_TOKENS = 86;
|
|
47
56
|
/** Safety margin below the shrink guard, in tokens. */
|
|
48
57
|
const SHRINK_MARGIN_TOKENS = 512;
|
|
49
58
|
/** Per-turn floor when assistant prose has to be truncated, in characters. */
|
|
50
59
|
const MIN_TURN_CHARS = 800;
|
|
51
|
-
/**
|
|
52
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Share of the budget user prose may occupy before oldest-first truncation.
|
|
62
|
+
*
|
|
63
|
+
* Measured on a 122-turn session, every human message in the span totalled 3219
|
|
64
|
+
* characters — 4% of the 80000-character ceiling — because 99.5% of user-message
|
|
65
|
+
* characters are injected context, which the digest excludes. So user prose
|
|
66
|
+
* should never be truncated for room; the share exists only to stop one pasted
|
|
67
|
+
* file from consuming the whole digest. Reserving most of the budget for it
|
|
68
|
+
* costs almost nothing and is what keeps an early constraint like "请用pixi管理
|
|
69
|
+
* 开发环境" from being evicted by 131 extracted blocks.
|
|
70
|
+
*/
|
|
71
|
+
const USER_BUDGET_SHARE = 0.9;
|
|
53
72
|
/** Tool names whose call arguments name a written path. */
|
|
54
73
|
const WRITE_TOOLS = new Set(['write', 'edit', 'multiedit', 'apply_patch']);
|
|
55
74
|
/** The message an event derives to, or null when it derives to none. */
|
|
@@ -133,10 +152,10 @@ export function isErrorResult(event) {
|
|
|
133
152
|
}
|
|
134
153
|
/** Directive appended as the final user message of the extraction call. */
|
|
135
154
|
const EXTRACT_INSTRUCTION = [
|
|
136
|
-
'You are summarizing
|
|
137
|
-
'
|
|
155
|
+
'You are summarizing a batch of turns from a coding session for a compaction checkpoint.',
|
|
156
|
+
'Each turn is introduced by a line `=== TURN <n> ===` and contains the user request, the assistant prose (may be empty), and a trace of the tool calls it made (descriptions only) plus any results the log marked as errors.',
|
|
138
157
|
'',
|
|
139
|
-
'
|
|
158
|
+
'For EVERY turn, emit exactly one block, introduced by the same `=== TURN <n> ===` line, containing these three Markdown headings in order. Under each, at most two terse lines. If a section has no real information, write "(none)" — never invent.',
|
|
140
159
|
'',
|
|
141
160
|
'## Did',
|
|
142
161
|
'- what was actually done, concretely',
|
|
@@ -147,8 +166,12 @@ const EXTRACT_INSTRUCTION = [
|
|
|
147
166
|
'## Overturned',
|
|
148
167
|
'- earlier conclusions this turn disproved or replaced',
|
|
149
168
|
'',
|
|
150
|
-
'Do not describe the tool calls themselves ("ran bash", "explored the repo"). State what came out of them. If the trace is too thin to support a claim, write "(none)".',
|
|
169
|
+
'Do not describe the tool calls themselves ("ran bash", "explored the repo"). State what came out of them. Do not repeat a conclusion the previous turn already stated. If the trace is too thin to support a claim, write "(none)".',
|
|
151
170
|
].join('\n');
|
|
171
|
+
/** Turns handed to one extraction call. */
|
|
172
|
+
const TURNS_PER_CALL = 8;
|
|
173
|
+
/** Output tokens allowed per turn in an extraction call. */
|
|
174
|
+
const EXTRACT_TOKENS_PER_TURN = 220;
|
|
152
175
|
/** Most tool calls one turn may contribute to the hint block. */
|
|
153
176
|
const MAX_HINTS_PER_TURN = 120;
|
|
154
177
|
/** Characters kept from one call's description or command head. */
|
|
@@ -327,19 +350,57 @@ export function buildSections(events) {
|
|
|
327
350
|
}
|
|
328
351
|
return sections;
|
|
329
352
|
}
|
|
330
|
-
/**
|
|
353
|
+
/**
|
|
354
|
+
* Attach extracted blocks to the turns they belong to.
|
|
355
|
+
*
|
|
356
|
+
* Keyed by turn number, with `undefined` covering a log that carries no
|
|
357
|
+
* `turn/start` markers. A turn with no entry keeps its prose, which is the
|
|
358
|
+
* fallback path when extraction is unavailable.
|
|
359
|
+
*
|
|
360
|
+
* @param sections - sections built from the digest source.
|
|
361
|
+
* @param extracted - turn number to extracted block.
|
|
362
|
+
* @returns the same sections with `extracted` set where available.
|
|
363
|
+
*/
|
|
364
|
+
function withExtractions(sections, extracted) {
|
|
365
|
+
return sections.map((section) => ({
|
|
366
|
+
turns: section.turns.map((turn) => {
|
|
367
|
+
const block = extracted.get(turn.turn);
|
|
368
|
+
return block === undefined ? turn : { ...turn, extracted: block };
|
|
369
|
+
}),
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Render one section to digest text, truncating assistant prose to `capChars`.
|
|
374
|
+
*
|
|
375
|
+
* When the turn carries an extraction, that extraction *replaces* the assistant
|
|
376
|
+
* prose. Keeping both was the duplication this design removes: 58.2% of
|
|
377
|
+
* assistant characters are substantive conclusions that the extraction restates,
|
|
378
|
+
* and the doubled copy pushed real history out through the budget.
|
|
379
|
+
*
|
|
380
|
+
* The tool pointer line stays either way: it is one line, and it is the only
|
|
381
|
+
* record of what was actually run when the prose and extraction both say little.
|
|
382
|
+
*/
|
|
331
383
|
function renderSection(section, capChars) {
|
|
332
384
|
const lines = [];
|
|
333
385
|
for (const turn of section.turns) {
|
|
334
386
|
const label = turn.turn === undefined ? '?' : String(turn.turn);
|
|
335
387
|
if (turn.user.length > 0)
|
|
336
388
|
lines.push(`\n## user (turn ${label})\n${turn.user.join('\n')}`);
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
389
|
+
if (turn.extracted !== undefined) {
|
|
390
|
+
let block = turn.extracted;
|
|
391
|
+
if (capChars !== undefined && block.length > capChars) {
|
|
392
|
+
block = `${block.slice(0, capChars)}\n…(+${block.length - capChars} chars)`;
|
|
393
|
+
}
|
|
394
|
+
lines.push(`### assistant (turn ${label})\n${block}`);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
let body = turn.assistant.join('\n');
|
|
398
|
+
if (capChars !== undefined && body.length > capChars) {
|
|
399
|
+
body = `${body.slice(0, capChars)}\n…(+${body.length - capChars} chars)`;
|
|
400
|
+
}
|
|
401
|
+
if (body.length > 0)
|
|
402
|
+
lines.push(`### assistant (turn ${label})\n${body}`);
|
|
340
403
|
}
|
|
341
|
-
if (body.length > 0)
|
|
342
|
-
lines.push(`### assistant (turn ${label})\n${body}`);
|
|
343
404
|
if (turn.tools.size > 0) {
|
|
344
405
|
const names = [...turn.tools.entries()]
|
|
345
406
|
.sort((left, right) => right[1] - left[1])
|
|
@@ -355,12 +416,23 @@ function renderSection(section, capChars) {
|
|
|
355
416
|
}
|
|
356
417
|
return lines.join('\n');
|
|
357
418
|
}
|
|
358
|
-
/**
|
|
419
|
+
/**
|
|
420
|
+
* Total characters of turn bodies across every section: the extraction where a
|
|
421
|
+
* turn has one, its prose otherwise.
|
|
422
|
+
*
|
|
423
|
+
* Both must count, because the cap applies to whichever body is rendered. When
|
|
424
|
+
* only prose counted, a digest made almost entirely of extractions reported a
|
|
425
|
+
* bound of zero, the bisection had nothing to shrink, and the renderer fell
|
|
426
|
+
* through to dropping the oldest turns outright — which deleted the earliest
|
|
427
|
+
* human messages, the standing constraints the digest exists to keep.
|
|
428
|
+
*/
|
|
359
429
|
function assistantChars(sections) {
|
|
360
430
|
let total = 0;
|
|
361
|
-
for (const section of sections)
|
|
362
|
-
for (const turn of section.turns)
|
|
363
|
-
total += turn.assistant.join('\n').length;
|
|
431
|
+
for (const section of sections) {
|
|
432
|
+
for (const turn of section.turns) {
|
|
433
|
+
total += turn.extracted !== undefined ? turn.extracted.length : turn.assistant.join('\n').length;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
364
436
|
return total;
|
|
365
437
|
}
|
|
366
438
|
/**
|
|
@@ -369,7 +441,10 @@ function assistantChars(sections) {
|
|
|
369
441
|
* The two truncation tiers are the plan's T1 and T1', in that order:
|
|
370
442
|
*
|
|
371
443
|
* - **T1** user prose is verbatim unless it alone exceeds `USER_BUDGET_SHARE` of
|
|
372
|
-
* the budget, then the oldest turns are cut first.
|
|
444
|
+
* the budget, then the oldest turns are cut first. The share is 0.9 rather than
|
|
445
|
+
* 0.5 because real user prose is a few percent of the digest: at 0.5 the
|
|
446
|
+
* extracted blocks crowded out the oldest human messages, which are exactly the
|
|
447
|
+
* standing constraints (「请用pixi管理开发环境」) that must survive.
|
|
373
448
|
* - **T1'** assistant prose is verbatim unless the whole digest overflows, then
|
|
374
449
|
* it is cut evenly per turn down to `MIN_TURN_CHARS`.
|
|
375
450
|
*
|
|
@@ -378,19 +453,26 @@ function assistantChars(sections) {
|
|
|
378
453
|
* and the surrounding turns, a user's request cannot. The 800-character floor
|
|
379
454
|
* exists so a tight budget thins every turn instead of erasing the last few.
|
|
380
455
|
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
* @param budgetTokens - hard ceiling in the meter's fixed 4-chars-per-token heuristic.
|
|
388
|
-
* @returns the digest text, never longer than `budgetTokens * 4` characters.
|
|
456
|
+
* Both tiers run on a long session, and that is by design: the ceiling is
|
|
457
|
+
* absolute (`DIGEST_TOKEN_CEILING`), so a 122-turn history with 316k characters
|
|
458
|
+
* of assistant prose cannot fit verbatim. In the extractive design most turns
|
|
459
|
+
* carry an extraction instead of prose, which keeps the digest far below the
|
|
460
|
+
* ceiling; the tiers exist for the turns where extraction was unavailable and
|
|
461
|
+
* the prose is all that remains.
|
|
389
462
|
*/
|
|
390
|
-
export function renderDigest(events, budgetTokens) {
|
|
391
|
-
const sections = buildSections(events);
|
|
463
|
+
export function renderDigest(events, budgetTokens, extracted) {
|
|
464
|
+
const sections = extracted === undefined ? buildSections(events) : withExtractions(buildSections(events), extracted);
|
|
392
465
|
const budgetChars = Math.max(0, budgetTokens * 4);
|
|
393
466
|
const render = (capChars, trimmed) => (trimmed ?? sections).map((section) => renderSection(section, capChars)).join('\n');
|
|
467
|
+
// User prose is reserved before anything else is placed.
|
|
468
|
+
//
|
|
469
|
+
// Measured on a 122-turn session: every human message in the span totalled 469
|
|
470
|
+
// characters (117 tokens), while one extracted block per turn totalled 55020
|
|
471
|
+
// characters. The extraction is 117x the size of the prose it sat next to, so
|
|
472
|
+
// any layout that lets them compete evicts the prose — and the prose is the
|
|
473
|
+
// standing constraints (「请用pixi管理开发环境」) that never expire, while the
|
|
474
|
+
// extraction describes work that is already finished.
|
|
475
|
+
const userReserve = Math.min(Math.floor(budgetChars * USER_BUDGET_SHARE), sections.reduce((total, section) => total + section.turns.reduce((sum, turn) => sum + turn.user.join('\n').length, 0), 0));
|
|
394
476
|
const verbatim = render(undefined);
|
|
395
477
|
if (verbatim.length <= budgetChars)
|
|
396
478
|
return verbatim;
|
|
@@ -398,7 +480,7 @@ export function renderDigest(events, budgetTokens) {
|
|
|
398
480
|
// assistant prose stays verbatim for now. The walk is backwards because the
|
|
399
481
|
// newest request is the one the next turn depends on: cutting forwards would
|
|
400
482
|
// keep the opening question and drop what the user just asked for.
|
|
401
|
-
const userCap =
|
|
483
|
+
const userCap = userReserve;
|
|
402
484
|
let seen = 0;
|
|
403
485
|
const t1 = sections.map((section) => ({ turns: section.turns.map((turn) => ({ ...turn, user: [...turn.user] })) }));
|
|
404
486
|
for (let section = t1.length - 1; section >= 0; section -= 1) {
|
|
@@ -422,6 +504,10 @@ export function renderDigest(events, budgetTokens) {
|
|
|
422
504
|
const afterT1 = render(undefined, t1);
|
|
423
505
|
if (afterT1.length <= budgetChars)
|
|
424
506
|
return afterT1;
|
|
507
|
+
// The user side fits inside its reserve; what overflows is the turn bodies
|
|
508
|
+
// (extraction or prose plus pointers). Thin those, and only drop whole turns
|
|
509
|
+
// once every body is already at zero — dropping a turn also drops the user
|
|
510
|
+
// prose in it, which is what the reserve exists to prevent.
|
|
425
511
|
// T1': assistant prose, evenly per turn. Length is monotone non-decreasing in
|
|
426
512
|
// the cap, so bisection finds the largest cap that fits exactly, in about
|
|
427
513
|
// eight renders. The floor is honoured only while it fits; below that the cap
|
|
@@ -557,27 +643,41 @@ export function buildExtractionMaterial(turn, prose) {
|
|
|
557
643
|
* @param signal - cancellation forwarded to the adapter.
|
|
558
644
|
* @returns the extracted block, or null when extraction is unavailable.
|
|
559
645
|
*/
|
|
560
|
-
async function
|
|
646
|
+
async function extractBatch(ctx, agent, batch, signal) {
|
|
647
|
+
const out = new Map();
|
|
561
648
|
const llm = ctx.llm;
|
|
562
649
|
if (llm === undefined)
|
|
563
|
-
return
|
|
650
|
+
return out;
|
|
564
651
|
const route = agent.session.requestHeader()?.config;
|
|
565
652
|
const provider = route?.provider ?? agent.options.provider;
|
|
566
653
|
const model = route?.model ?? agent.options.model;
|
|
567
654
|
if (provider === undefined || model === undefined)
|
|
568
|
-
return
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
655
|
+
return out;
|
|
656
|
+
const parts = [];
|
|
657
|
+
for (const [turn, events] of batch) {
|
|
658
|
+
const prose = events
|
|
659
|
+
.filter((event) => event.type === 'assistant/message')
|
|
660
|
+
.map((event) => textOf(event))
|
|
661
|
+
.filter((text) => text.length > 0)
|
|
662
|
+
.join('\n');
|
|
663
|
+
const material = buildExtractionMaterial(events, prose);
|
|
664
|
+
if (material.length === 0)
|
|
665
|
+
continue;
|
|
666
|
+
parts.push(`=== TURN ${turn} ===\n${material}`);
|
|
667
|
+
}
|
|
668
|
+
if (parts.length === 0)
|
|
669
|
+
return out;
|
|
572
670
|
const chunks = [];
|
|
573
671
|
try {
|
|
574
672
|
const stream = llm.stream({
|
|
575
673
|
provider,
|
|
576
674
|
model,
|
|
577
675
|
messages: [
|
|
578
|
-
{ role: 'user', content: [{ type: 'text', text: `${
|
|
676
|
+
{ role: 'user', content: [{ type: 'text', text: `${parts.join('\n\n')}\n\n${EXTRACT_INSTRUCTION}` }] },
|
|
579
677
|
],
|
|
580
|
-
|
|
678
|
+
// One block per turn, so the cap scales with the batch rather than being
|
|
679
|
+
// a per-turn 400 that a dozen turns would multiply past the digest ceiling.
|
|
680
|
+
maxTokens: EXTRACT_TOKENS_PER_TURN * parts.length,
|
|
581
681
|
sessionId: agent.session.id,
|
|
582
682
|
purpose: 'compaction',
|
|
583
683
|
...(signal === undefined ? {} : { signal }),
|
|
@@ -592,10 +692,21 @@ async function extractTurn(ctx, agent, turn, prose, signal) {
|
|
|
592
692
|
}
|
|
593
693
|
}
|
|
594
694
|
catch {
|
|
595
|
-
return
|
|
695
|
+
return out;
|
|
596
696
|
}
|
|
597
|
-
|
|
598
|
-
|
|
697
|
+
// Split the answer back per turn. A turn the model skipped simply keeps its
|
|
698
|
+
// prose, so a partial answer degrades per turn instead of losing the batch.
|
|
699
|
+
const answer = chunks.join('').trim();
|
|
700
|
+
if (answer.length === 0)
|
|
701
|
+
return out;
|
|
702
|
+
const sections = answer.split(/^=== TURN (\d+) ===$/m);
|
|
703
|
+
for (let index = 1; index < sections.length; index += 2) {
|
|
704
|
+
const turn = Number(sections[index]);
|
|
705
|
+
const body = (sections[index + 1] ?? '').trim();
|
|
706
|
+
if (Number.isFinite(turn) && body.length > 0)
|
|
707
|
+
out.set(turn, body);
|
|
708
|
+
}
|
|
709
|
+
return out;
|
|
599
710
|
}
|
|
600
711
|
/**
|
|
601
712
|
* Deterministic compaction backend: verbatim prose, pointerized tools, no model
|
|
@@ -633,16 +744,12 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
633
744
|
if (shadowedSet.has(node.seq))
|
|
634
745
|
shadowedPrice += node.tokens;
|
|
635
746
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
// alone is kept, because an over-budget checkpoint fails the shrink guard and
|
|
641
|
-
// loses the whole compaction rather than some detail.
|
|
747
|
+
// Absolute ceiling, floored by the shrink guard. The ceiling keeps the digest
|
|
748
|
+
// size independent of history length; the guard bound only exists for the
|
|
749
|
+
// degenerate case of a span smaller than the ceiling.
|
|
750
|
+
const budget = Math.max(0, Math.min(DIGEST_TOKEN_CEILING, shadowedPrice - FRAME_TOKENS - SHRINK_MARGIN_TOKENS));
|
|
642
751
|
const extracted = await this.extractAll(source, agent, _signal);
|
|
643
|
-
const text =
|
|
644
|
-
? verbatim
|
|
645
|
-
: `${verbatim}\n\n# Extracted from tool traces\n${extracted}`.slice(0, Math.max(verbatim.length, budget * 4));
|
|
752
|
+
const text = renderDigest(source, budget, extracted);
|
|
646
753
|
const route = session.requestHeader()?.config;
|
|
647
754
|
return {
|
|
648
755
|
summary: [{ type: 'text', text }],
|
|
@@ -660,10 +767,16 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
660
767
|
* A turn that fails is simply absent from the output; one bad turn must not
|
|
661
768
|
* drop the extraction for the rest.
|
|
662
769
|
*
|
|
770
|
+
* Batched rather than one call per turn. One call per turn meant 131 calls on a
|
|
771
|
+
* 122-turn session, each with its own round trip: a measured extraction budget
|
|
772
|
+
* of 131 x 400 tokens could on its own exceed the whole digest ceiling. A
|
|
773
|
+
* batch of `TURNS_PER_CALL` turns costs one round trip and shares the prompt
|
|
774
|
+
* prefix, so the provider's KV cache is reused rather than rebuilt.
|
|
775
|
+
*
|
|
663
776
|
* @param source - digest source events, in log order.
|
|
664
777
|
* @param agent - owner of the session being compacted.
|
|
665
778
|
* @param signal - cancellation forwarded to every call.
|
|
666
|
-
* @returns
|
|
779
|
+
* @returns turn number to extracted block, omitting turns that yielded none.
|
|
667
780
|
*/
|
|
668
781
|
async extractAll(source, agent, signal) {
|
|
669
782
|
const turns = new Map();
|
|
@@ -684,20 +797,16 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
684
797
|
else
|
|
685
798
|
bucket.push(event);
|
|
686
799
|
}
|
|
687
|
-
const
|
|
688
|
-
|
|
800
|
+
const ordered = [...turns.entries()].sort((left, right) => left[0] - right[0]);
|
|
801
|
+
const extracted = new Map();
|
|
802
|
+
for (let index = 0; index < ordered.length; index += TURNS_PER_CALL) {
|
|
689
803
|
if (signal?.aborted === true)
|
|
690
804
|
break;
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
.
|
|
695
|
-
.join('\n');
|
|
696
|
-
const extracted = await extractTurn(this.ctx, agent, events, prose, signal);
|
|
697
|
-
if (extracted === null)
|
|
698
|
-
continue;
|
|
699
|
-
blocks.push(`\n## turn ${turn}\n${extracted}`);
|
|
805
|
+
const batch = ordered.slice(index, index + TURNS_PER_CALL);
|
|
806
|
+
const blocks = await extractBatch(this.ctx, agent, batch, signal);
|
|
807
|
+
for (const [turn, block] of blocks)
|
|
808
|
+
extracted.set(turn, block);
|
|
700
809
|
}
|
|
701
|
-
return
|
|
810
|
+
return extracted;
|
|
702
811
|
}
|
|
703
812
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -36,6 +36,24 @@ import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm';
|
|
|
36
36
|
import type { Session, SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session';
|
|
37
37
|
/** Cordis companion plugin name. Must equal this plugin's `cordis.patch.yml` row id. */
|
|
38
38
|
export declare const name = "verbatim-compaction";
|
|
39
|
+
/**
|
|
40
|
+
* Absolute ceiling for one digest, in the meter's fixed 4-chars-per-token
|
|
41
|
+
* heuristic.
|
|
42
|
+
*
|
|
43
|
+
* A share of the shadowed span is the wrong shape: it makes the digest grow
|
|
44
|
+
* with history. Measured on one real session, the share-based budget produced
|
|
45
|
+
* 2642 chars on an early span, 51019 on a later one, and 348539 on the span that
|
|
46
|
+
* triggered the compaction loop — because a bigger span licensed a bigger
|
|
47
|
+
* digest, and a digest near the threshold re-triggers immediately.
|
|
48
|
+
*
|
|
49
|
+
* A fixed ceiling makes the digest size independent of how long the session has
|
|
50
|
+
* run, which is what the host's own summarizer does (its summaries measured
|
|
51
|
+
* 14009-22208 chars across spans of 108829-173514 tokens).
|
|
52
|
+
*
|
|
53
|
+
* 20000 tokens holds roughly 100 turns of user prose plus one extracted block
|
|
54
|
+
* per turn, and leaves the checkpoint far below any measured threshold.
|
|
55
|
+
*/
|
|
56
|
+
export declare const DIGEST_TOKEN_CEILING = 20000;
|
|
39
57
|
/** The replayed prefix the base class hands to `summarize`. */
|
|
40
58
|
export interface SummarizationInput {
|
|
41
59
|
/** Tool schemas reused for prefix-cache alignment; absent when the request carried none. */
|
|
@@ -87,6 +105,14 @@ interface Turn {
|
|
|
87
105
|
resultBytes: number;
|
|
88
106
|
errors: string[];
|
|
89
107
|
writes: string[];
|
|
108
|
+
/**
|
|
109
|
+
* The extracted summary for this turn, when extraction succeeded.
|
|
110
|
+
*
|
|
111
|
+
* Set here rather than appended to a separate block so the extraction takes
|
|
112
|
+
* the assistant prose's place in the digest: keeping both duplicated the same
|
|
113
|
+
* content and pushed the prose out through the budget.
|
|
114
|
+
*/
|
|
115
|
+
extracted?: string;
|
|
90
116
|
}
|
|
91
117
|
/** One digest section: everything from one non-injected user message to the next. */
|
|
92
118
|
interface Section {
|
|
@@ -111,7 +137,10 @@ export declare function buildSections(events: readonly SessionEvent[]): Section[
|
|
|
111
137
|
* The two truncation tiers are the plan's T1 and T1', in that order:
|
|
112
138
|
*
|
|
113
139
|
* - **T1** user prose is verbatim unless it alone exceeds `USER_BUDGET_SHARE` of
|
|
114
|
-
* the budget, then the oldest turns are cut first.
|
|
140
|
+
* the budget, then the oldest turns are cut first. The share is 0.9 rather than
|
|
141
|
+
* 0.5 because real user prose is a few percent of the digest: at 0.5 the
|
|
142
|
+
* extracted blocks crowded out the oldest human messages, which are exactly the
|
|
143
|
+
* standing constraints (「请用pixi管理开发环境」) that must survive.
|
|
115
144
|
* - **T1'** assistant prose is verbatim unless the whole digest overflows, then
|
|
116
145
|
* it is cut evenly per turn down to `MIN_TURN_CHARS`.
|
|
117
146
|
*
|
|
@@ -120,16 +149,14 @@ export declare function buildSections(events: readonly SessionEvent[]): Section[
|
|
|
120
149
|
* and the surrounding turns, a user's request cannot. The 800-character floor
|
|
121
150
|
* exists so a tight budget thins every turn instead of erasing the last few.
|
|
122
151
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* @param budgetTokens - hard ceiling in the meter's fixed 4-chars-per-token heuristic.
|
|
130
|
-
* @returns the digest text, never longer than `budgetTokens * 4` characters.
|
|
152
|
+
* Both tiers run on a long session, and that is by design: the ceiling is
|
|
153
|
+
* absolute (`DIGEST_TOKEN_CEILING`), so a 122-turn history with 316k characters
|
|
154
|
+
* of assistant prose cannot fit verbatim. In the extractive design most turns
|
|
155
|
+
* carry an extraction instead of prose, which keeps the digest far below the
|
|
156
|
+
* ceiling; the tiers exist for the turns where extraction was unavailable and
|
|
157
|
+
* the prose is all that remains.
|
|
131
158
|
*/
|
|
132
|
-
export declare function renderDigest(events: readonly SessionEvent[], budgetTokens: number): string;
|
|
159
|
+
export declare function renderDigest(events: readonly SessionEvent[], budgetTokens: number, extracted?: ReadonlyMap<number | undefined, string>): string;
|
|
133
160
|
/**
|
|
134
161
|
* The surface seqs this compaction shadows, recovered from the summarization
|
|
135
162
|
* input's message count.
|
|
@@ -213,10 +240,16 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
213
240
|
* A turn that fails is simply absent from the output; one bad turn must not
|
|
214
241
|
* drop the extraction for the rest.
|
|
215
242
|
*
|
|
243
|
+
* Batched rather than one call per turn. One call per turn meant 131 calls on a
|
|
244
|
+
* 122-turn session, each with its own round trip: a measured extraction budget
|
|
245
|
+
* of 131 x 400 tokens could on its own exceed the whole digest ceiling. A
|
|
246
|
+
* batch of `TURNS_PER_CALL` turns costs one round trip and shares the prompt
|
|
247
|
+
* prefix, so the provider's KV cache is reused rather than rebuilt.
|
|
248
|
+
*
|
|
216
249
|
* @param source - digest source events, in log order.
|
|
217
250
|
* @param agent - owner of the session being compacted.
|
|
218
251
|
* @param signal - cancellation forwarded to every call.
|
|
219
|
-
* @returns
|
|
252
|
+
* @returns turn number to extracted block, omitting turns that yielded none.
|
|
220
253
|
*/
|
|
221
254
|
private extractAll;
|
|
222
255
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logictan/dsh-verbatim-compaction",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Deterministic compaction backend that keeps conversation prose verbatim in the checkpoint instead of re-summarizing it: user and assistant text survive word for word, tool results collapse to one-line pointers, reasoning is never read, and no model call is made.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|