@logictan/dsh-verbatim-compaction 0.2.1 → 0.3.1
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 +218 -103
- 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 item is introduced by a line `=== SLOT <key> (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 item, emit exactly one block, introduced by `=== SLOT <key> ===` using that same key, 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,70 @@ 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, sectionIndex) => ({
|
|
366
|
+
turns: section.turns.map((turn, turnIndex) => {
|
|
367
|
+
const block = extracted.get(turnKey(sectionIndex, turnIndex));
|
|
368
|
+
return block === undefined ? turn : { ...turn, extracted: block };
|
|
369
|
+
}),
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Position-based key for one turn: `<section index>:<turn index>`.
|
|
374
|
+
*
|
|
375
|
+
* Not the turn number. `turn/start` is emitted per step in some sessions and only
|
|
376
|
+
* once in others, so the number in the log is neither unique nor one-per-request.
|
|
377
|
+
* A real session carried 2 user requests under a single `turn/start` (turn 1), so
|
|
378
|
+
* both sections were keyed `1`; the second extraction overwrote the first, and
|
|
379
|
+
* the digest printed the same block twice — once under a request about source
|
|
380
|
+
* refresh, once under 「模拟器使用硬件加速!」.
|
|
381
|
+
*/
|
|
382
|
+
function turnKey(sectionIndex, turnIndex) {
|
|
383
|
+
return `${sectionIndex}:${turnIndex}`;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Render one section to digest text, truncating assistant prose to `capChars`.
|
|
387
|
+
*
|
|
388
|
+
* When the turn carries an extraction, that extraction *replaces* the assistant
|
|
389
|
+
* prose. Keeping both was the duplication this design removes: 58.2% of
|
|
390
|
+
* assistant characters are substantive conclusions that the extraction restates,
|
|
391
|
+
* and the doubled copy pushed real history out through the budget.
|
|
392
|
+
*
|
|
393
|
+
* The tool pointer line stays either way: it is one line, and it is the only
|
|
394
|
+
* record of what was actually run when the prose and extraction both say little.
|
|
395
|
+
*/
|
|
341
396
|
function renderSection(section, capChars) {
|
|
342
397
|
const lines = [];
|
|
343
398
|
for (const turn of section.turns) {
|
|
344
399
|
const label = turn.turn === undefined ? '?' : String(turn.turn);
|
|
345
400
|
if (turn.user.length > 0)
|
|
346
401
|
lines.push(`\n## user (turn ${label})\n${turn.user.join('\n')}`);
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
402
|
+
if (turn.extracted !== undefined) {
|
|
403
|
+
let block = turn.extracted;
|
|
404
|
+
if (capChars !== undefined && block.length > capChars) {
|
|
405
|
+
block = `${block.slice(0, capChars)}\n…(+${block.length - capChars} chars)`;
|
|
406
|
+
}
|
|
407
|
+
lines.push(`### assistant (turn ${label})\n${block}`);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
let body = turn.assistant.join('\n');
|
|
411
|
+
if (capChars !== undefined && body.length > capChars) {
|
|
412
|
+
body = `${body.slice(0, capChars)}\n…(+${body.length - capChars} chars)`;
|
|
413
|
+
}
|
|
414
|
+
if (body.length > 0)
|
|
415
|
+
lines.push(`### assistant (turn ${label})\n${body}`);
|
|
350
416
|
}
|
|
351
|
-
if (body.length > 0)
|
|
352
|
-
lines.push(`### assistant (turn ${label})\n${body}`);
|
|
353
417
|
if (turn.tools.size > 0) {
|
|
354
418
|
const names = [...turn.tools.entries()]
|
|
355
419
|
.sort((left, right) => right[1] - left[1])
|
|
@@ -365,12 +429,23 @@ function renderSection(section, capChars) {
|
|
|
365
429
|
}
|
|
366
430
|
return lines.join('\n');
|
|
367
431
|
}
|
|
368
|
-
/**
|
|
432
|
+
/**
|
|
433
|
+
* Total characters of turn bodies across every section: the extraction where a
|
|
434
|
+
* turn has one, its prose otherwise.
|
|
435
|
+
*
|
|
436
|
+
* Both must count, because the cap applies to whichever body is rendered. When
|
|
437
|
+
* only prose counted, a digest made almost entirely of extractions reported a
|
|
438
|
+
* bound of zero, the bisection had nothing to shrink, and the renderer fell
|
|
439
|
+
* through to dropping the oldest turns outright — which deleted the earliest
|
|
440
|
+
* human messages, the standing constraints the digest exists to keep.
|
|
441
|
+
*/
|
|
369
442
|
function assistantChars(sections) {
|
|
370
443
|
let total = 0;
|
|
371
|
-
for (const section of sections)
|
|
372
|
-
for (const turn of section.turns)
|
|
373
|
-
total += turn.assistant.join('\n').length;
|
|
444
|
+
for (const section of sections) {
|
|
445
|
+
for (const turn of section.turns) {
|
|
446
|
+
total += turn.extracted !== undefined ? turn.extracted.length : turn.assistant.join('\n').length;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
374
449
|
return total;
|
|
375
450
|
}
|
|
376
451
|
/**
|
|
@@ -379,7 +454,10 @@ function assistantChars(sections) {
|
|
|
379
454
|
* The two truncation tiers are the plan's T1 and T1', in that order:
|
|
380
455
|
*
|
|
381
456
|
* - **T1** user prose is verbatim unless it alone exceeds `USER_BUDGET_SHARE` of
|
|
382
|
-
* the budget, then the oldest turns are cut first.
|
|
457
|
+
* the budget, then the oldest turns are cut first. The share is 0.9 rather than
|
|
458
|
+
* 0.5 because real user prose is a few percent of the digest: at 0.5 the
|
|
459
|
+
* extracted blocks crowded out the oldest human messages, which are exactly the
|
|
460
|
+
* standing constraints (「请用pixi管理开发环境」) that must survive.
|
|
383
461
|
* - **T1'** assistant prose is verbatim unless the whole digest overflows, then
|
|
384
462
|
* it is cut evenly per turn down to `MIN_TURN_CHARS`.
|
|
385
463
|
*
|
|
@@ -388,19 +466,26 @@ function assistantChars(sections) {
|
|
|
388
466
|
* and the surrounding turns, a user's request cannot. The 800-character floor
|
|
389
467
|
* exists so a tight budget thins every turn instead of erasing the last few.
|
|
390
468
|
*
|
|
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.
|
|
469
|
+
* Both tiers run on a long session, and that is by design: the ceiling is
|
|
470
|
+
* absolute (`DIGEST_TOKEN_CEILING`), so a 122-turn history with 316k characters
|
|
471
|
+
* of assistant prose cannot fit verbatim. In the extractive design most turns
|
|
472
|
+
* carry an extraction instead of prose, which keeps the digest far below the
|
|
473
|
+
* ceiling; the tiers exist for the turns where extraction was unavailable and
|
|
474
|
+
* the prose is all that remains.
|
|
399
475
|
*/
|
|
400
|
-
export function renderDigest(events, budgetTokens) {
|
|
401
|
-
const sections = buildSections(events);
|
|
476
|
+
export function renderDigest(events, budgetTokens, extracted) {
|
|
477
|
+
const sections = extracted === undefined ? buildSections(events) : withExtractions(buildSections(events), extracted);
|
|
402
478
|
const budgetChars = Math.max(0, budgetTokens * 4);
|
|
403
479
|
const render = (capChars, trimmed) => (trimmed ?? sections).map((section) => renderSection(section, capChars)).join('\n');
|
|
480
|
+
// User prose is reserved before anything else is placed.
|
|
481
|
+
//
|
|
482
|
+
// Measured on a 122-turn session: every human message in the span totalled 469
|
|
483
|
+
// characters (117 tokens), while one extracted block per turn totalled 55020
|
|
484
|
+
// characters. The extraction is 117x the size of the prose it sat next to, so
|
|
485
|
+
// any layout that lets them compete evicts the prose — and the prose is the
|
|
486
|
+
// standing constraints (「请用pixi管理开发环境」) that never expire, while the
|
|
487
|
+
// extraction describes work that is already finished.
|
|
488
|
+
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
489
|
const verbatim = render(undefined);
|
|
405
490
|
if (verbatim.length <= budgetChars)
|
|
406
491
|
return verbatim;
|
|
@@ -408,7 +493,7 @@ export function renderDigest(events, budgetTokens) {
|
|
|
408
493
|
// assistant prose stays verbatim for now. The walk is backwards because the
|
|
409
494
|
// newest request is the one the next turn depends on: cutting forwards would
|
|
410
495
|
// keep the opening question and drop what the user just asked for.
|
|
411
|
-
const userCap =
|
|
496
|
+
const userCap = userReserve;
|
|
412
497
|
let seen = 0;
|
|
413
498
|
const t1 = sections.map((section) => ({ turns: section.turns.map((turn) => ({ ...turn, user: [...turn.user] })) }));
|
|
414
499
|
for (let section = t1.length - 1; section >= 0; section -= 1) {
|
|
@@ -432,6 +517,10 @@ export function renderDigest(events, budgetTokens) {
|
|
|
432
517
|
const afterT1 = render(undefined, t1);
|
|
433
518
|
if (afterT1.length <= budgetChars)
|
|
434
519
|
return afterT1;
|
|
520
|
+
// The user side fits inside its reserve; what overflows is the turn bodies
|
|
521
|
+
// (extraction or prose plus pointers). Thin those, and only drop whole turns
|
|
522
|
+
// once every body is already at zero — dropping a turn also drops the user
|
|
523
|
+
// prose in it, which is what the reserve exists to prevent.
|
|
435
524
|
// T1': assistant prose, evenly per turn. Length is monotone non-decreasing in
|
|
436
525
|
// the cap, so bisection finds the largest cap that fits exactly, in about
|
|
437
526
|
// eight renders. The floor is honoured only while it fits; below that the cap
|
|
@@ -567,45 +656,66 @@ export function buildExtractionMaterial(turn, prose) {
|
|
|
567
656
|
* @param signal - cancellation forwarded to the adapter.
|
|
568
657
|
* @returns the extracted block, or null when extraction is unavailable.
|
|
569
658
|
*/
|
|
570
|
-
async function
|
|
659
|
+
async function extractBatch(ctx, agent, batch, signal) {
|
|
660
|
+
const out = new Map();
|
|
571
661
|
const llm = ctx.llm;
|
|
572
662
|
if (llm === undefined)
|
|
573
|
-
return
|
|
663
|
+
return out;
|
|
574
664
|
const route = agent.session.requestHeader()?.config;
|
|
575
665
|
const provider = route?.provider ?? agent.options.provider;
|
|
576
666
|
const model = route?.model ?? agent.options.model;
|
|
577
667
|
if (provider === undefined || model === undefined)
|
|
578
|
-
return
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
668
|
+
return out;
|
|
669
|
+
// The label the model echoes back is the slot key, not the turn number: two
|
|
670
|
+
// requests can share a number, and a duplicate label would collapse two blocks
|
|
671
|
+
// into one entry.
|
|
672
|
+
const parts = [];
|
|
673
|
+
for (const slot of batch) {
|
|
674
|
+
const prose = slot.events
|
|
675
|
+
.filter((event) => event.type === 'assistant/message')
|
|
676
|
+
.map((event) => textOf(event))
|
|
677
|
+
.filter((text) => text.length > 0)
|
|
678
|
+
.join('\n');
|
|
679
|
+
const material = buildExtractionMaterial(slot.events, prose);
|
|
680
|
+
if (material.length === 0)
|
|
681
|
+
continue;
|
|
682
|
+
parts.push(`=== SLOT ${slot.key} (turn ${slot.turn ?? '?'}) ===\n${material}`);
|
|
683
|
+
}
|
|
684
|
+
if (parts.length === 0)
|
|
685
|
+
return out;
|
|
582
686
|
const chunks = [];
|
|
583
687
|
try {
|
|
584
688
|
const stream = llm.stream({
|
|
585
689
|
provider,
|
|
586
690
|
model,
|
|
587
691
|
messages: [
|
|
588
|
-
{ role: 'user', content: [{ type: 'text', text: `${
|
|
692
|
+
{ role: 'user', content: [{ type: 'text', text: `${parts.join('\n\n')}\n\n${EXTRACT_INSTRUCTION}` }] },
|
|
589
693
|
],
|
|
590
|
-
maxTokens:
|
|
694
|
+
maxTokens: EXTRACT_TOKENS_PER_TURN * parts.length,
|
|
591
695
|
sessionId: agent.session.id,
|
|
592
696
|
purpose: 'compaction',
|
|
593
697
|
...(signal === undefined ? {} : { signal }),
|
|
594
698
|
});
|
|
595
699
|
for await (const chunk of stream) {
|
|
596
|
-
// Chunk kinds come from `@deepseek-ai/dsh-llm`'s BlockAssembler: text
|
|
597
|
-
// arrives as `text-delta`, and `reasoning-delta` is deliberately ignored —
|
|
598
|
-
// reasoning is 98.8% of a long session's output and is not the record.
|
|
599
700
|
const delta = chunk;
|
|
600
701
|
if (delta.type === 'text-delta' && typeof delta.text === 'string')
|
|
601
702
|
chunks.push(delta.text);
|
|
602
703
|
}
|
|
603
704
|
}
|
|
604
705
|
catch {
|
|
605
|
-
return
|
|
706
|
+
return out;
|
|
606
707
|
}
|
|
607
|
-
const
|
|
608
|
-
|
|
708
|
+
const answer = chunks.join('').trim();
|
|
709
|
+
if (answer.length === 0)
|
|
710
|
+
return out;
|
|
711
|
+
const blocks = answer.split(/^=== SLOT ([^\s(]+)[^=]*===$/m);
|
|
712
|
+
for (let index = 1; index < blocks.length; index += 2) {
|
|
713
|
+
const key = blocks[index];
|
|
714
|
+
const body = (blocks[index + 1] ?? '').trim();
|
|
715
|
+
if (key !== undefined && body.length > 0)
|
|
716
|
+
out.set(key, body);
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
609
719
|
}
|
|
610
720
|
/**
|
|
611
721
|
* Deterministic compaction backend: verbatim prose, pointerized tools, no model
|
|
@@ -643,23 +753,12 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
643
753
|
if (shadowedSet.has(node.seq))
|
|
644
754
|
shadowedPrice += node.tokens;
|
|
645
755
|
}
|
|
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.
|
|
756
|
+
// Absolute ceiling, floored by the shrink guard. The ceiling keeps the digest
|
|
757
|
+
// size independent of history length; the guard bound only exists for the
|
|
758
|
+
// degenerate case of a span smaller than the ceiling.
|
|
759
|
+
const budget = Math.max(0, Math.min(DIGEST_TOKEN_CEILING, shadowedPrice - FRAME_TOKENS - SHRINK_MARGIN_TOKENS));
|
|
659
760
|
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));
|
|
761
|
+
const text = renderDigest(source, budget, extracted);
|
|
663
762
|
const route = session.requestHeader()?.config;
|
|
664
763
|
return {
|
|
665
764
|
summary: [{ type: 'text', text }],
|
|
@@ -677,44 +776,60 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
677
776
|
* A turn that fails is simply absent from the output; one bad turn must not
|
|
678
777
|
* drop the extraction for the rest.
|
|
679
778
|
*
|
|
779
|
+
* Batched rather than one call per turn. One call per turn meant 131 calls on a
|
|
780
|
+
* 122-turn session, each with its own round trip: a measured extraction budget
|
|
781
|
+
* of 131 x 400 tokens could on its own exceed the whole digest ceiling. A
|
|
782
|
+
* batch of `TURNS_PER_CALL` turns costs one round trip and shares the prompt
|
|
783
|
+
* prefix, so the provider's KV cache is reused rather than rebuilt.
|
|
784
|
+
*
|
|
680
785
|
* @param source - digest source events, in log order.
|
|
681
786
|
* @param agent - owner of the session being compacted.
|
|
682
787
|
* @param signal - cancellation forwarded to every call.
|
|
683
|
-
* @returns
|
|
788
|
+
* @returns turn number to extracted block, omitting turns that yielded none.
|
|
684
789
|
*/
|
|
685
790
|
async extractAll(source, agent, signal) {
|
|
686
|
-
|
|
791
|
+
// Walk the same sections the renderer will use, so a key addresses the exact
|
|
792
|
+
// slot the block will be written into. Grouping by turn number instead lost
|
|
793
|
+
// blocks whenever the log reused a number.
|
|
794
|
+
const sections = buildSections(source);
|
|
795
|
+
const slots = [];
|
|
687
796
|
let lastTurn;
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
797
|
+
let cursor = 0;
|
|
798
|
+
for (const [sectionIndex, section] of sections.entries()) {
|
|
799
|
+
for (const [turnIndex, turn] of section.turns.entries()) {
|
|
800
|
+
// Collect this turn's events by walking the source in order; the sections
|
|
801
|
+
// were built from the same array, so the cursor never rewinds.
|
|
802
|
+
const collected = [];
|
|
803
|
+
while (cursor < source.length) {
|
|
804
|
+
const event = source[cursor];
|
|
805
|
+
cursor += 1;
|
|
806
|
+
if (event === undefined)
|
|
807
|
+
break;
|
|
808
|
+
if (event.type === 'turn/start') {
|
|
809
|
+
const turnNumber = turnOf(event);
|
|
810
|
+
if (turnNumber !== undefined)
|
|
811
|
+
lastTurn = turnNumber;
|
|
812
|
+
collected.push(event);
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
collected.push(event);
|
|
816
|
+
if (event.type === 'user/message' || event.type === 'assistant/message') {
|
|
817
|
+
if (!isInjection(event) && !isCheckpoint(event))
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
slots.push({ key: turnKey(sectionIndex, turnIndex), turn: turn.turn, events: collected });
|
|
694
822
|
}
|
|
695
|
-
const turn = turnOf(event) ?? lastTurn;
|
|
696
|
-
if (turn === undefined)
|
|
697
|
-
continue;
|
|
698
|
-
const bucket = turns.get(turn);
|
|
699
|
-
if (bucket === undefined)
|
|
700
|
-
turns.set(turn, [event]);
|
|
701
|
-
else
|
|
702
|
-
bucket.push(event);
|
|
703
823
|
}
|
|
704
|
-
const
|
|
705
|
-
for (
|
|
824
|
+
const extracted = new Map();
|
|
825
|
+
for (let index = 0; index < slots.length; index += TURNS_PER_CALL) {
|
|
706
826
|
if (signal?.aborted === true)
|
|
707
827
|
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}`);
|
|
828
|
+
const batch = slots.slice(index, index + TURNS_PER_CALL);
|
|
829
|
+
const blocks = await extractBatch(this.ctx, agent, batch, signal);
|
|
830
|
+
for (const [key, block] of blocks)
|
|
831
|
+
extracted.set(key, block);
|
|
717
832
|
}
|
|
718
|
-
return
|
|
833
|
+
return extracted;
|
|
719
834
|
}
|
|
720
835
|
}
|
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<string, 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.1",
|
|
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",
|