@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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # @logictan/dsh-verbatim-compaction
2
2
 
3
- 把上下文压缩的**摘要**从「让模型重写一遍」换成「正文逐字抄进 checkpoint,再用一次模型调用从工具轨迹里补出结论」:用户与助手的正文逐字保留、推理块从不读取;每轮另调一次会话自身的模型,输出「做了什么 / 得到什么结论 / 哪些结论被推翻」。
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
- 逐字抄正文救不了「正文为空」的轮次。实测一个 13878 事件的会话:1973 条 assistant 消息里只有 317 条带正文,其中一轮跑了 342 次工具调用却一个字没写,逐字摘要把它压成一行 `tools: bash×281`——活干了,记录没了。
52
+ 只抄正文救不了「正文为空」的轮次。实测一个 13878 事件的会话:1973 条 assistant 消息里只有 317 条带正文,其中一轮跑了 342 次工具调用却一个字没写,纯逐字摘要把它压成一行 `tools: bash×281`——活干了,记录没了。
43
53
 
44
- 所以每轮另发一次模型调用,材料是三段:用户请求(逐字)、助手正文(逐字)、工具轨迹(每条调用的 `description`,失败的调用另记一行首行错误)。模型输出固定的三节:`## Did` / `## Concluded` / `## Overturned`。
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
- - **提取失败就退回纯逐字摘要**。模型不可达、超时、输出为空都返回 null;压缩本身不能因此失败,否则会话死于上下文溢出,比丢细节更糟。
62
+ - **提取失败就退回逐字正文**。模型不可达、超时、输出为空时,该轮保留原来的助手正文;压缩本身不能因此失败,否则会话死于上下文溢出,比丢细节更糟。降级是逐轮的:一批里某一轮没答,只有那一轮退回正文。
53
63
 
54
- 代价是每轮一次模型调用(实测 13 轮合计约 74649 字符材料)。提取结果与逐字摘要共用同一个预算,超预算时只保留逐字摘要。
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 价」,摘要不小于原内容就整次压缩失败。本插件把预算直接算成 `min(196752, shadowedPrice − 86 − 512)`:上界取守卫自己的那个数,因此这条失败路径按构造不可能发生,而不是靠实测侥幸。实测 8 个会话 0 次触发,最小余量 174387。
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
- - **正文超预算会被截断。** 预算是硬的,两级依次触发:**先** user 正文超过预算一半时从**最新**往前算、最旧的先丢(用户刚说的话是下一轮唯一依赖的东西,丢它等于丢任务);**仍不够**才把助手正文按轮均分截断,每轮下限 800 字符。8 个实测会话里两级都没跑过(最大摘要 44542 token,预算 196752),所以这是守卫而不是常态。
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
- * Ceiling for one digest, in the meter's fixed 4-chars-per-token heuristic.
37
+ * Absolute ceiling for one digest, in the meter's fixed 4-chars-per-token
38
+ * heuristic.
38
39
  *
39
- * The base class's own summary budget is `thresholdTokens - retainTokens -
40
- * 8192`, which is 196752 for the route this was measured on. The digest is also
41
- * bounded by the shrink guard itself, which is tighter in practice; see
42
- * `summarize`.
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 DIGEST_BUDGET_TOKENS = 196752;
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
- /** Share of the budget user prose may occupy before oldest-first truncation. */
52
- const USER_BUDGET_SHARE = 0.5;
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 ONE turn of a coding session for a compaction checkpoint.',
137
- 'The material below is the turn\'s user request, its assistant prose (may be empty), and a trace of the tool calls it made (descriptions only) plus any results the log marked as errors.',
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
- 'Output EXACTLY these three Markdown headings, in order. Under each, at most two terse lines. If a section has no real information, write "(none)" — never invent.',
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
- /** Render one section to digest text, truncating assistant prose to `capChars`. */
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
- let body = turn.assistant.join('\n');
338
- if (capChars !== undefined && body.length > capChars) {
339
- body = `${body.slice(0, capChars)}\n…(+${body.length - capChars} chars)`;
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
- /** Total characters of assistant prose across every section. */
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
- * Neither tier runs in practice: over the eight measured sessions the largest
382
- * digest was 44542 tokens against a 196752 budget, and user prose totalled 2591
383
- * tokens against a 98376 cap. They are a guard against a pasted file, not the
384
- * normal path.
385
- *
386
- * @param events - the digest source in log order.
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 = Math.floor(budgetChars * USER_BUDGET_SHARE);
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 extractTurn(ctx, agent, turn, prose, signal) {
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 null;
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 null;
569
- const material = buildExtractionMaterial(turn, prose);
570
- if (material.length === 0)
571
- return null;
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: `${material}\n\n${EXTRACT_INSTRUCTION}` }] },
676
+ { role: 'user', content: [{ type: 'text', text: `${parts.join('\n\n')}\n\n${EXTRACT_INSTRUCTION}` }] },
579
677
  ],
580
- maxTokens: 400,
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 null;
695
+ return out;
596
696
  }
597
- const text = chunks.join('').trim();
598
- return text.length > 0 ? text : null;
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
- const budget = Math.max(0, Math.min(DIGEST_BUDGET_TOKENS, shadowedPrice - FRAME_TOKENS - SHRINK_MARGIN_TOKENS));
637
- const verbatim = renderDigest(source, budget);
638
- // Per-turn extraction, appended to the verbatim digest. Bounded by the same
639
- // budget: if the extracted text would push the digest past it, the digest
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 = extracted.length === 0
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 the extracted blocks joined by turn heading, or an empty string.
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 blocks = [];
688
- for (const [turn, events] of [...turns.entries()].sort((left, right) => left[0] - right[0])) {
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 prose = events
692
- .filter((event) => event.type === 'assistant/message')
693
- .map((event) => textOf(event))
694
- .filter((text) => text.length > 0)
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 blocks.join('\n');
810
+ return extracted;
702
811
  }
703
812
  }
@@ -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
- * Neither tier runs in practice: over the eight measured sessions the largest
124
- * digest was 44542 tokens against a 196752 budget, and user prose totalled 2591
125
- * tokens against a 98376 cap. They are a guard against a pasted file, not the
126
- * normal path.
127
- *
128
- * @param events - the digest source in log order.
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 the extracted blocks joined by turn heading, or an empty string.
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.2.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",