@logictan/dsh-verbatim-compaction 0.2.1 → 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 +27 -12
- package/lib/index.js +177 -85
- 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,17 @@
|
|
|
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 字符)。
|
|
59
73
|
|
|
60
74
|
## 为什么会收敛(一条真实事故)
|
|
61
75
|
|
|
@@ -115,7 +129,8 @@ pnpm add @logictan/dsh-verbatim-compaction
|
|
|
115
129
|
|
|
116
130
|
## 已知边界
|
|
117
131
|
|
|
118
|
-
-
|
|
132
|
+
- **每轮的正文/提取块会被均匀削薄,用户原话不会。** 用户原话先占额度(`USER_BUDGET_SHARE` = 0.9),剩余空间再分给各轮的提取块或正文,超出时按轮等比截断并标出省略字符数。只有在每轮都已削到零仍放不下时才整轮丢弃,且会写明丢了几轮——正常长会话不会走到这一步(122 轮实测 0 次)。
|
|
133
|
+
- **提取块可能被截断。** 122 轮、每轮 420 字符提取的极端情形会触到上限,块尾被切并标出字符数。真人原话不受影响。
|
|
119
134
|
- **`turn/start` 缺失时轮次编号退化为 `turn ?`。** 不会丢正文,只是编号不可读。
|
|
120
135
|
- **注入内容不进摘要。** 判据是 `source.kind !== 'user'`——注入上下文在一个真实会话里占 user 消息字符的 97%,放进摘要会把人的原话淹掉。
|
|
121
136
|
- **不改善首轮之外的工具参数细节。** `bash` 命令本身(占工具参数 84.2%)不保留;需要「当时跑了什么命令」的场景不在本插件的覆盖范围内。
|
package/lib/index.js
CHANGED
|
@@ -34,30 +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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* and
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
37
|
+
* Absolute ceiling for one digest, in the meter's fixed 4-chars-per-token
|
|
38
|
+
* heuristic.
|
|
39
|
+
*
|
|
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.
|
|
51
52
|
*/
|
|
52
|
-
const
|
|
53
|
+
export const DIGEST_TOKEN_CEILING = 20000;
|
|
53
54
|
/** Framing overhead `frameSummary` adds: preamble plus both tags, 341 chars = 86 tokens. */
|
|
54
55
|
const FRAME_TOKENS = 86;
|
|
55
56
|
/** Safety margin below the shrink guard, in tokens. */
|
|
56
57
|
const SHRINK_MARGIN_TOKENS = 512;
|
|
57
58
|
/** Per-turn floor when assistant prose has to be truncated, in characters. */
|
|
58
59
|
const MIN_TURN_CHARS = 800;
|
|
59
|
-
/**
|
|
60
|
-
|
|
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;
|
|
61
72
|
/** Tool names whose call arguments name a written path. */
|
|
62
73
|
const WRITE_TOOLS = new Set(['write', 'edit', 'multiedit', 'apply_patch']);
|
|
63
74
|
/** The message an event derives to, or null when it derives to none. */
|
|
@@ -139,14 +150,12 @@ export function isErrorResult(event) {
|
|
|
139
150
|
const message = messageOf(event);
|
|
140
151
|
return message?.isError === true;
|
|
141
152
|
}
|
|
142
|
-
/** Heading that introduces the extracted section at the top of a digest. */
|
|
143
|
-
const EXTRACTED_HEADING = '# Extracted from tool traces';
|
|
144
153
|
/** Directive appended as the final user message of the extraction call. */
|
|
145
154
|
const EXTRACT_INSTRUCTION = [
|
|
146
|
-
'You are summarizing
|
|
147
|
-
'
|
|
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.',
|
|
148
157
|
'',
|
|
149
|
-
'
|
|
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.',
|
|
150
159
|
'',
|
|
151
160
|
'## Did',
|
|
152
161
|
'- what was actually done, concretely',
|
|
@@ -157,8 +166,12 @@ const EXTRACT_INSTRUCTION = [
|
|
|
157
166
|
'## Overturned',
|
|
158
167
|
'- earlier conclusions this turn disproved or replaced',
|
|
159
168
|
'',
|
|
160
|
-
'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)".',
|
|
161
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;
|
|
162
175
|
/** Most tool calls one turn may contribute to the hint block. */
|
|
163
176
|
const MAX_HINTS_PER_TURN = 120;
|
|
164
177
|
/** Characters kept from one call's description or command head. */
|
|
@@ -337,19 +350,57 @@ export function buildSections(events) {
|
|
|
337
350
|
}
|
|
338
351
|
return sections;
|
|
339
352
|
}
|
|
340
|
-
/**
|
|
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
|
+
*/
|
|
341
383
|
function renderSection(section, capChars) {
|
|
342
384
|
const lines = [];
|
|
343
385
|
for (const turn of section.turns) {
|
|
344
386
|
const label = turn.turn === undefined ? '?' : String(turn.turn);
|
|
345
387
|
if (turn.user.length > 0)
|
|
346
388
|
lines.push(`\n## user (turn ${label})\n${turn.user.join('\n')}`);
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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}`);
|
|
350
403
|
}
|
|
351
|
-
if (body.length > 0)
|
|
352
|
-
lines.push(`### assistant (turn ${label})\n${body}`);
|
|
353
404
|
if (turn.tools.size > 0) {
|
|
354
405
|
const names = [...turn.tools.entries()]
|
|
355
406
|
.sort((left, right) => right[1] - left[1])
|
|
@@ -365,12 +416,23 @@ function renderSection(section, capChars) {
|
|
|
365
416
|
}
|
|
366
417
|
return lines.join('\n');
|
|
367
418
|
}
|
|
368
|
-
/**
|
|
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
|
+
*/
|
|
369
429
|
function assistantChars(sections) {
|
|
370
430
|
let total = 0;
|
|
371
|
-
for (const section of sections)
|
|
372
|
-
for (const turn of section.turns)
|
|
373
|
-
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
|
+
}
|
|
374
436
|
return total;
|
|
375
437
|
}
|
|
376
438
|
/**
|
|
@@ -379,7 +441,10 @@ function assistantChars(sections) {
|
|
|
379
441
|
* The two truncation tiers are the plan's T1 and T1', in that order:
|
|
380
442
|
*
|
|
381
443
|
* - **T1** user prose is verbatim unless it alone exceeds `USER_BUDGET_SHARE` of
|
|
382
|
-
* 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.
|
|
383
448
|
* - **T1'** assistant prose is verbatim unless the whole digest overflows, then
|
|
384
449
|
* it is cut evenly per turn down to `MIN_TURN_CHARS`.
|
|
385
450
|
*
|
|
@@ -388,19 +453,26 @@ function assistantChars(sections) {
|
|
|
388
453
|
* and the surrounding turns, a user's request cannot. The 800-character floor
|
|
389
454
|
* exists so a tight budget thins every turn instead of erasing the last few.
|
|
390
455
|
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
* @param budgetTokens - hard ceiling in the meter's fixed 4-chars-per-token heuristic.
|
|
398
|
-
* @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.
|
|
399
462
|
*/
|
|
400
|
-
export function renderDigest(events, budgetTokens) {
|
|
401
|
-
const sections = buildSections(events);
|
|
463
|
+
export function renderDigest(events, budgetTokens, extracted) {
|
|
464
|
+
const sections = extracted === undefined ? buildSections(events) : withExtractions(buildSections(events), extracted);
|
|
402
465
|
const budgetChars = Math.max(0, budgetTokens * 4);
|
|
403
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));
|
|
404
476
|
const verbatim = render(undefined);
|
|
405
477
|
if (verbatim.length <= budgetChars)
|
|
406
478
|
return verbatim;
|
|
@@ -408,7 +480,7 @@ export function renderDigest(events, budgetTokens) {
|
|
|
408
480
|
// assistant prose stays verbatim for now. The walk is backwards because the
|
|
409
481
|
// newest request is the one the next turn depends on: cutting forwards would
|
|
410
482
|
// keep the opening question and drop what the user just asked for.
|
|
411
|
-
const userCap =
|
|
483
|
+
const userCap = userReserve;
|
|
412
484
|
let seen = 0;
|
|
413
485
|
const t1 = sections.map((section) => ({ turns: section.turns.map((turn) => ({ ...turn, user: [...turn.user] })) }));
|
|
414
486
|
for (let section = t1.length - 1; section >= 0; section -= 1) {
|
|
@@ -432,6 +504,10 @@ export function renderDigest(events, budgetTokens) {
|
|
|
432
504
|
const afterT1 = render(undefined, t1);
|
|
433
505
|
if (afterT1.length <= budgetChars)
|
|
434
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.
|
|
435
511
|
// T1': assistant prose, evenly per turn. Length is monotone non-decreasing in
|
|
436
512
|
// the cap, so bisection finds the largest cap that fits exactly, in about
|
|
437
513
|
// eight renders. The floor is honoured only while it fits; below that the cap
|
|
@@ -567,27 +643,41 @@ export function buildExtractionMaterial(turn, prose) {
|
|
|
567
643
|
* @param signal - cancellation forwarded to the adapter.
|
|
568
644
|
* @returns the extracted block, or null when extraction is unavailable.
|
|
569
645
|
*/
|
|
570
|
-
async function
|
|
646
|
+
async function extractBatch(ctx, agent, batch, signal) {
|
|
647
|
+
const out = new Map();
|
|
571
648
|
const llm = ctx.llm;
|
|
572
649
|
if (llm === undefined)
|
|
573
|
-
return
|
|
650
|
+
return out;
|
|
574
651
|
const route = agent.session.requestHeader()?.config;
|
|
575
652
|
const provider = route?.provider ?? agent.options.provider;
|
|
576
653
|
const model = route?.model ?? agent.options.model;
|
|
577
654
|
if (provider === undefined || model === undefined)
|
|
578
|
-
return
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
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;
|
|
582
670
|
const chunks = [];
|
|
583
671
|
try {
|
|
584
672
|
const stream = llm.stream({
|
|
585
673
|
provider,
|
|
586
674
|
model,
|
|
587
675
|
messages: [
|
|
588
|
-
{ role: 'user', content: [{ type: 'text', text: `${
|
|
676
|
+
{ role: 'user', content: [{ type: 'text', text: `${parts.join('\n\n')}\n\n${EXTRACT_INSTRUCTION}` }] },
|
|
589
677
|
],
|
|
590
|
-
|
|
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,
|
|
591
681
|
sessionId: agent.session.id,
|
|
592
682
|
purpose: 'compaction',
|
|
593
683
|
...(signal === undefined ? {} : { signal }),
|
|
@@ -602,10 +692,21 @@ async function extractTurn(ctx, agent, turn, prose, signal) {
|
|
|
602
692
|
}
|
|
603
693
|
}
|
|
604
694
|
catch {
|
|
605
|
-
return
|
|
695
|
+
return out;
|
|
606
696
|
}
|
|
607
|
-
|
|
608
|
-
|
|
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;
|
|
609
710
|
}
|
|
610
711
|
/**
|
|
611
712
|
* Deterministic compaction backend: verbatim prose, pointerized tools, no model
|
|
@@ -643,23 +744,12 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
643
744
|
if (shadowedSet.has(node.seq))
|
|
644
745
|
shadowedPrice += node.tokens;
|
|
645
746
|
}
|
|
646
|
-
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
Math.
|
|
650
|
-
const verbatim = renderDigest(source, budget);
|
|
651
|
-
// Per-turn extraction, prepended rather than appended.
|
|
652
|
-
//
|
|
653
|
-
// Order matters under truncation: `slice` cuts the tail, so appending the
|
|
654
|
-
// extraction put the most reconstructible content first and the least
|
|
655
|
-
// reconstructible content last — a tight budget deleted exactly the "what
|
|
656
|
-
// did this turn conclude / what did it overturn" record and kept the prose
|
|
657
|
-
// it summarised. Leading with the extraction makes the digest degrade the
|
|
658
|
-
// way the plan intends: conclusions survive, prose thins.
|
|
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));
|
|
659
751
|
const extracted = await this.extractAll(source, agent, _signal);
|
|
660
|
-
const text =
|
|
661
|
-
? verbatim
|
|
662
|
-
: `${EXTRACTED_HEADING}\n${extracted}\n\n${verbatim}`.slice(0, Math.max(verbatim.length, budget * 4));
|
|
752
|
+
const text = renderDigest(source, budget, extracted);
|
|
663
753
|
const route = session.requestHeader()?.config;
|
|
664
754
|
return {
|
|
665
755
|
summary: [{ type: 'text', text }],
|
|
@@ -677,10 +767,16 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
677
767
|
* A turn that fails is simply absent from the output; one bad turn must not
|
|
678
768
|
* drop the extraction for the rest.
|
|
679
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
|
+
*
|
|
680
776
|
* @param source - digest source events, in log order.
|
|
681
777
|
* @param agent - owner of the session being compacted.
|
|
682
778
|
* @param signal - cancellation forwarded to every call.
|
|
683
|
-
* @returns
|
|
779
|
+
* @returns turn number to extracted block, omitting turns that yielded none.
|
|
684
780
|
*/
|
|
685
781
|
async extractAll(source, agent, signal) {
|
|
686
782
|
const turns = new Map();
|
|
@@ -701,20 +797,16 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
701
797
|
else
|
|
702
798
|
bucket.push(event);
|
|
703
799
|
}
|
|
704
|
-
const
|
|
705
|
-
|
|
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) {
|
|
706
803
|
if (signal?.aborted === true)
|
|
707
804
|
break;
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
.
|
|
712
|
-
.join('\n');
|
|
713
|
-
const extracted = await extractTurn(this.ctx, agent, events, prose, signal);
|
|
714
|
-
if (extracted === null)
|
|
715
|
-
continue;
|
|
716
|
-
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);
|
|
717
809
|
}
|
|
718
|
-
return
|
|
810
|
+
return extracted;
|
|
719
811
|
}
|
|
720
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",
|