@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 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,17 @@
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(shadowedPrice × 0.5, shadowedPrice − 86 − 512)`,因此这条失败路径按构造不可能发生,而不是靠实测侥幸。
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
- - **正文超预算会被截断。** 预算是硬的,两级依次触发:**先** user 正文超过预算一半时从**最新**往前算、最旧的先丢(用户刚说的话是下一轮唯一依赖的东西,丢它等于丢任务);**仍不够**才把助手正文按轮均分截断,每轮下限 800 字符。在 122 轮、助手正文 31.6 万字符的真实会话上,第一级触发 104 处、第二级没触发——长会话里这是常态而不是守卫。
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
- * Largest share of the shadowed price one digest may occupy.
38
- *
39
- * Satisfying the shrink guard is not convergence. The guard only requires the
40
- * framed digest to be *smaller* than what it replaces; a digest priced at 99.3%
41
- * of its span passes the guard and still leaves the surface where it started.
42
- * Measured on session `小鹏汽车 App 改造评估`: one compaction shadowed 87733
43
- * tokens and produced a digest priced at 87135 — 598 tokens (0.7%) reclaimed —
44
- * and the resulting surface measured 256704 tokens against a 256464 threshold,
45
- * so the very next step compacted again, this time shadowing exactly one seq
46
- * (the checkpoint it had just written) and reproducing the same 348k characters
47
- * until the third attempt was aborted.
48
- *
49
- * At half, each compaction makes progress however much history has accumulated,
50
- * so one pass drops the surface well below the threshold.
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 DIGEST_SHARE_OF_SHADOWED = 0.5;
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
- /** Share of the budget user prose may occupy before oldest-first truncation. */
60
- 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;
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 ONE turn of a coding session for a compaction checkpoint.',
147
- '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 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
- '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 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
- /** 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, 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
- let body = turn.assistant.join('\n');
348
- if (capChars !== undefined && body.length > capChars) {
349
- body = `${body.slice(0, capChars)}\n…(+${body.length - capChars} chars)`;
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
- /** Total characters of assistant prose across every section. */
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
- * Neither tier runs in practice: over the eight measured sessions the largest
392
- * digest was 44542 tokens against a 196752 budget, and user prose totalled 2591
393
- * tokens against a 98376 cap. They are a guard against a pasted file, not the
394
- * normal path.
395
- *
396
- * @param events - the digest source in log order.
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 = Math.floor(budgetChars * USER_BUDGET_SHARE);
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 extractTurn(ctx, agent, turn, prose, signal) {
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 null;
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 null;
579
- const material = buildExtractionMaterial(turn, prose);
580
- if (material.length === 0)
581
- return null;
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: `${material}\n\n${EXTRACT_INSTRUCTION}` }] },
692
+ { role: 'user', content: [{ type: 'text', text: `${parts.join('\n\n')}\n\n${EXTRACT_INSTRUCTION}` }] },
589
693
  ],
590
- maxTokens: 400,
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 null;
706
+ return out;
606
707
  }
607
- const text = chunks.join('').trim();
608
- return text.length > 0 ? text : null;
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
- const budget = Math.max(0, Math.min(
647
- // Half the span is the convergence contract, not the guard's own bound.
648
- // See `DIGEST_SHARE_OF_SHADOWED` for the 87733→87135 measurement.
649
- Math.floor(shadowedPrice * DIGEST_SHARE_OF_SHADOWED), shadowedPrice - FRAME_TOKENS - SHRINK_MARGIN_TOKENS));
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 = extracted.length === 0
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 the extracted blocks joined by turn heading, or an empty string.
788
+ * @returns turn number to extracted block, omitting turns that yielded none.
684
789
  */
685
790
  async extractAll(source, agent, signal) {
686
- const turns = new Map();
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
- for (const event of source) {
689
- if (event.type === 'turn/start') {
690
- const turn = turnOf(event);
691
- if (turn !== undefined)
692
- lastTurn = turn;
693
- continue;
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 blocks = [];
705
- for (const [turn, events] of [...turns.entries()].sort((left, right) => left[0] - right[0])) {
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 prose = events
709
- .filter((event) => event.type === 'assistant/message')
710
- .map((event) => textOf(event))
711
- .filter((text) => text.length > 0)
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 blocks.join('\n');
833
+ return extracted;
719
834
  }
720
835
  }
@@ -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<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 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.1",
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",