@logictan/dsh-verbatim-compaction 0.3.1 → 0.5.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
@@ -17,14 +17,16 @@
17
17
  ## 摘要长什么样
18
18
 
19
19
  ```
20
- ## user (turn 37)
20
+ ## user
21
21
  (用户原话,逐字,永不改写)
22
22
 
23
- ### assistant (turn 37)
23
+ ### assistant
24
+ ## Asked
25
+ - 是否要用 FULL 而不是 INCREMENTAL
24
26
  ## Did
25
27
  - 实测 `auto_vacuum=FULL` 删除后自动缩容:19.61 MB → 0.01 MB
26
28
  ## Concluded
27
- - 选 FULL 而非 INCREMENTAL:回收发生在宿主自己的 DELETE 里,不受 busy_timeout=0 影响
29
+ - 选 FULL:回收发生在宿主自己的 DELETE 里,不受 busy_timeout=0 影响
28
30
  ## Overturned
29
31
  - 先前认为必须显式执行 VACUUM 才能回收空间
30
32
  tools: bash×280 ssh_exec×44 web_search×6 (408830 bytes)
@@ -32,6 +34,8 @@
32
34
  wrote: /Volumes/LogicExt/Git/BookReader/src/video.js
33
35
  ```
34
36
 
37
+ 标题不带轮次号:日志里的 `turn` 号既不唯一也不是一轮一个(一个真实会话把两条请求都编号成 1),印出来就是会撒谎的标题。
38
+
35
39
  - **用户原话逐字**,不重写、不改写、不摘要。
36
40
  - **助手正文由提取块替代**,不并列保留——并列就是同一份内容写两遍。
37
41
  - 工具结果只留「工具名 × 次数 + 字节数」,内容丢弃。
@@ -51,7 +55,11 @@
51
55
 
52
56
  只抄正文救不了「正文为空」的轮次。实测一个 13878 事件的会话:1973 条 assistant 消息里只有 317 条带正文,其中一轮跑了 342 次工具调用却一个字没写,纯逐字摘要把它压成一行 `tools: bash×281`——活干了,记录没了。
53
57
 
54
- 所以提取的材料是三段:用户请求(逐字)、助手正文(逐字)、工具轨迹(每条调用的 `description`,失败的调用另记一行首行错误)。模型输出固定的三节:`## Did` / `## Concluded` / `## Overturned`。
58
+ 所以提取的材料是三段:用户请求(逐字)、助手正文(逐字)、工具轨迹(每条调用的 `description`,失败的调用另记一行首行错误)。模型输出固定的四节:`## Asked` / `## Did` / `## Concluded` / `## Overturned`。
59
+
60
+ 空节直接删掉,不留 `(none)` 占位。实测一份真实提取块里 `## Asked` 和 `## Overturned` 都是 `(none)`,删掉后 312 → 271 字符;按 130 轮算,这些占位合计约 900 token 只是在记录「什么都没发生」。清理在代码里做(`pruneEmptySections`),不只依赖提示词——模型仍写了 `(none)` 也不会白花 token。判据是整节每一行去掉项目符号后都只是 `(none)` / `none` / `n/a` / `无` 之类的占位;`none of the tests passed`、`无法连接,改用代理` 这类真内容照原样保留。
61
+
62
+ `## Asked` 记的是「这一轮向用户提了什么、给了哪些选项」。实测 123 条真人消息里 43 条(35%)不超过 12 个字符——`3`、`1`、`同意`、`做`、`继续`——而 46% 是在回答上一轮助手的提问。只逐字保留「3」而不保留它在回答什么问题,等于记下一个决定却没记下决定的是什么。
55
63
 
56
64
  模型用的是会话自己的路由(`session.requestHeader()?.config`),和宿主原生压缩同一个取法,不需要单独配置凭据。
57
65
 
@@ -61,6 +69,10 @@
61
69
  - **工具调用从 `assistant/message` 的 block 里取**。日志里没有独立的 `tool/call` 事件类型,按它过滤会一条都取不到。
62
70
  - **提取失败就退回逐字正文**。模型不可达、超时、输出为空时,该轮保留原来的助手正文;压缩本身不能因此失败,否则会话死于上下文溢出,比丢细节更糟。降级是逐轮的:一批里某一轮没答,只有那一轮退回正文。
63
71
 
72
+ 提取结果按内容哈希缓存,同一次会话内复用。`digestSource` 每次压缩都从原始日志重建,而原始事件永远留在日志里,所以同一轮会被反复读到。实测那个压缩了 33 次的会话:共提取 142 轮,其中 **129 轮(91%)在更早的压缩里已经提取过**;全机 370 个会话、456 次压缩里,**81% 的压缩工作量是这种重复**(有压缩的 88 个会话中 70 个压缩过 2 次以上)。
73
+
74
+ 缓存的键是提取材料的哈希(用户请求 + 助手正文 + 工具轨迹),不是 slot 位置:位置会随历史增长而错位,内容变了哈希就变,不会读到过期块。缓存是进程内的,重启即失效——代价只是重新提取一次,与加缓存前行为一致。
75
+
64
76
  提取按批调用:每 8 轮一次,一轮输出一个 `=== TURN n ===` 块。早期实现是每轮一次调用,122 轮就是 131 次往返,单轮 400 token 的输出上限乘以 131 就可能超过整个摘要上限。批处理把往返压到约 17 次,并共享 prompt 前缀以复用 provider 的 KV cache。
65
77
 
66
78
  用户原话在布局上**先占额度**,提取块不得挤占。实测那个 122 轮会话:区间内真人原话共 469 字符(117 token),而每轮一块 420 字符的提取共 55020 字符——提取是正文的 117 倍,任何让二者竞争的布局都会把正文挤掉,而正文是「请用pixi管理开发环境」这类永不过期的约束,提取描述的却是已经做完的工作。
package/lib/index.js CHANGED
@@ -150,12 +150,64 @@ export function isErrorResult(event) {
150
150
  const message = messageOf(event);
151
151
  return message?.isError === true;
152
152
  }
153
+ /**
154
+ * Drop headings whose body says nothing.
155
+ *
156
+ * The instruction asks the model to omit empty sections, but a model that writes
157
+ * `## Overturned\n- (none)` anyway must not cost tokens on every turn: 130 turns
158
+ * of that placeholder is ~900 tokens recording that nothing was overturned. So
159
+ * the shape is enforced here as well, not only requested in the prompt.
160
+ *
161
+ * A heading is dropped when every non-blank line under it, once its bullet is
162
+ * stripped, is a placeholder — `(none)`, `none`, `n/a`, `-`, `无`. A heading with
163
+ * any real content is kept verbatim.
164
+ *
165
+ * @param body - one slot's extracted text.
166
+ * @returns the same text with empty sections removed, or an empty string when
167
+ * nothing substantive remains.
168
+ */
169
+ export function pruneEmptySections(body) {
170
+ const lines = body.split('\n');
171
+ const kept = [];
172
+ let pendingHeading;
173
+ let pendingBody = [];
174
+ const flush = () => {
175
+ if (pendingHeading === undefined) {
176
+ kept.push(...pendingBody);
177
+ pendingBody = [];
178
+ return;
179
+ }
180
+ const substantive = pendingBody.some((line) => {
181
+ const text = line.replace(/^\s*(?:[-*]|\d+\.)\s*/, '').trim();
182
+ if (text.length === 0)
183
+ return false;
184
+ return !/^(?:\(?none\)?|n\/a|na|-{1,2}|无|沒有|没有)$/i.test(text);
185
+ });
186
+ if (substantive)
187
+ kept.push(pendingHeading, ...pendingBody);
188
+ pendingHeading = undefined;
189
+ pendingBody = [];
190
+ };
191
+ for (const line of lines) {
192
+ if (/^#{1,6}\s/.test(line)) {
193
+ flush();
194
+ pendingHeading = line;
195
+ continue;
196
+ }
197
+ pendingBody.push(line);
198
+ }
199
+ flush();
200
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n').trim();
201
+ }
153
202
  /** Directive appended as the final user message of the extraction call. */
154
203
  const EXTRACT_INSTRUCTION = [
155
204
  'You are summarizing a batch of turns from a coding session for a compaction checkpoint.',
156
205
  '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.',
157
206
  '',
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.',
207
+ 'For EVERY item, emit exactly one block, introduced by `=== SLOT <key> ===` using that same key. Under each heading write at most two terse lines. OMIT a heading entirely when it has no real information — do not write "(none)" and do not invent. An item where nothing substantive happened may be omitted altogether.',
208
+ '',
209
+ '## Asked',
210
+ '- the question(s) this turn put to the user, and the options it offered — the next user message is usually the answer',
159
211
  '',
160
212
  '## Did',
161
213
  '- what was actually done, concretely',
@@ -382,6 +434,39 @@ function withExtractions(sections, extracted) {
382
434
  function turnKey(sectionIndex, turnIndex) {
383
435
  return `${sectionIndex}:${turnIndex}`;
384
436
  }
437
+ /**
438
+ * Cache key for one slot's extraction material: a hash of everything the model
439
+ * would be shown.
440
+ *
441
+ * The material, not the slot's position or turn number: positions shift as
442
+ * history grows and numbers repeat, but identical material must produce an
443
+ * identical extraction. Hashing the exact string `buildExtractionMaterial`
444
+ * returns means a change anywhere in the user request, the prose, or the tool
445
+ * trace invalidates the entry — nothing can silently go stale.
446
+ *
447
+ * FNV-1a, 32-bit, two seeds. Not cryptographic: the risk here is an accidental
448
+ * collision between two turns, and 64 bits over a few hundred entries makes that
449
+ * vanishingly unlikely.
450
+ *
451
+ * @param events - one slot's events, in log order.
452
+ * @returns a stable hex key.
453
+ */
454
+ function materialKey(events) {
455
+ const prose = events
456
+ .filter((event) => event.type === 'assistant/message')
457
+ .map((event) => textOf(event))
458
+ .filter((text) => text.length > 0)
459
+ .join('\n');
460
+ const material = buildExtractionMaterial(events, prose);
461
+ let a = 0x811c9dc5;
462
+ let b = 0x01000193;
463
+ for (let index = 0; index < material.length; index += 1) {
464
+ const code = material.charCodeAt(index);
465
+ a = Math.imul(a ^ code, 0x01000193) >>> 0;
466
+ b = Math.imul(b ^ (code + index), 0x85ebca6b) >>> 0;
467
+ }
468
+ return `${a.toString(16)}${b.toString(16)}`;
469
+ }
385
470
  /**
386
471
  * Render one section to digest text, truncating assistant prose to `capChars`.
387
472
  *
@@ -396,15 +481,14 @@ function turnKey(sectionIndex, turnIndex) {
396
481
  function renderSection(section, capChars) {
397
482
  const lines = [];
398
483
  for (const turn of section.turns) {
399
- const label = turn.turn === undefined ? '?' : String(turn.turn);
400
484
  if (turn.user.length > 0)
401
- lines.push(`\n## user (turn ${label})\n${turn.user.join('\n')}`);
485
+ lines.push(`\n## user\n${turn.user.join('\n')}`);
402
486
  if (turn.extracted !== undefined) {
403
487
  let block = turn.extracted;
404
488
  if (capChars !== undefined && block.length > capChars) {
405
489
  block = `${block.slice(0, capChars)}\n…(+${block.length - capChars} chars)`;
406
490
  }
407
- lines.push(`### assistant (turn ${label})\n${block}`);
491
+ lines.push(`### assistant\n${block}`);
408
492
  }
409
493
  else {
410
494
  let body = turn.assistant.join('\n');
@@ -412,7 +496,7 @@ function renderSection(section, capChars) {
412
496
  body = `${body.slice(0, capChars)}\n…(+${body.length - capChars} chars)`;
413
497
  }
414
498
  if (body.length > 0)
415
- lines.push(`### assistant (turn ${label})\n${body}`);
499
+ lines.push(`### assistant\n${body}`);
416
500
  }
417
501
  if (turn.tools.size > 0) {
418
502
  const names = [...turn.tools.entries()]
@@ -711,7 +795,7 @@ async function extractBatch(ctx, agent, batch, signal) {
711
795
  const blocks = answer.split(/^=== SLOT ([^\s(]+)[^=]*===$/m);
712
796
  for (let index = 1; index < blocks.length; index += 2) {
713
797
  const key = blocks[index];
714
- const body = (blocks[index + 1] ?? '').trim();
798
+ const body = pruneEmptySections((blocks[index + 1] ?? '').trim());
715
799
  if (key !== undefined && body.length > 0)
716
800
  out.set(key, body);
717
801
  }
@@ -727,6 +811,23 @@ async function extractBatch(ctx, agent, batch, signal) {
727
811
  * subclass cannot diverge from the host's transaction semantics.
728
812
  */
729
813
  export default class VerbatimCompactionEngine extends BasicCompactionEngine {
814
+ /**
815
+ * Extraction results, keyed by a hash of the material they were derived from.
816
+ *
817
+ * `digestSource` rebuilds from the raw log on every compaction, and raw events
818
+ * never leave the log, so the same turn is re-read on every later compaction.
819
+ * Measured on the session that compacted 33 times: 142 turns extracted, 129 of
820
+ * them (91%) already extracted earlier. Across all 370 local sessions, 81% of
821
+ * compaction work is that repeat.
822
+ *
823
+ * Keyed by content, not by slot position: a position holds different content
824
+ * once history grows, so a positional key would serve a stale block. A turn
825
+ * whose prose changed hashes differently and is extracted again.
826
+ *
827
+ * Process-local and bounded. A restart loses it, costing one re-extraction —
828
+ * the same behaviour as before this cache existed.
829
+ */
830
+ extractionCache = new Map();
730
831
  /**
731
832
  * Build the replacement checkpoint content from the session log.
732
833
  *
@@ -822,13 +923,26 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
822
923
  }
823
924
  }
824
925
  const extracted = new Map();
825
- for (let index = 0; index < slots.length; index += TURNS_PER_CALL) {
926
+ const missing = [];
927
+ for (const slot of slots) {
928
+ const cached = this.extractionCache.get(materialKey(slot.events));
929
+ if (cached === undefined)
930
+ missing.push(slot);
931
+ else
932
+ extracted.set(slot.key, cached);
933
+ }
934
+ for (let index = 0; index < missing.length; index += TURNS_PER_CALL) {
826
935
  if (signal?.aborted === true)
827
936
  break;
828
- const batch = slots.slice(index, index + TURNS_PER_CALL);
937
+ const batch = missing.slice(index, index + TURNS_PER_CALL);
829
938
  const blocks = await extractBatch(this.ctx, agent, batch, signal);
830
- for (const [key, block] of blocks)
831
- extracted.set(key, block);
939
+ for (const slot of batch) {
940
+ const block = blocks.get(slot.key);
941
+ if (block === undefined)
942
+ continue;
943
+ extracted.set(slot.key, block);
944
+ this.extractionCache.set(materialKey(slot.events), block);
945
+ }
832
946
  }
833
947
  return extracted;
834
948
  }
@@ -80,6 +80,23 @@ export interface SummaryResult {
80
80
  * @returns true only when the log itself marks the result as an error.
81
81
  */
82
82
  export declare function isErrorResult(event: SessionEvent): boolean;
83
+ /**
84
+ * Drop headings whose body says nothing.
85
+ *
86
+ * The instruction asks the model to omit empty sections, but a model that writes
87
+ * `## Overturned\n- (none)` anyway must not cost tokens on every turn: 130 turns
88
+ * of that placeholder is ~900 tokens recording that nothing was overturned. So
89
+ * the shape is enforced here as well, not only requested in the prompt.
90
+ *
91
+ * A heading is dropped when every non-blank line under it, once its bullet is
92
+ * stripped, is a placeholder — `(none)`, `none`, `n/a`, `-`, `无`. A heading with
93
+ * any real content is kept verbatim.
94
+ *
95
+ * @param body - one slot's extracted text.
96
+ * @returns the same text with empty sections removed, or an empty string when
97
+ * nothing substantive remains.
98
+ */
99
+ export declare function pruneEmptySections(body: string): string;
83
100
  /**
84
101
  * Build the compact per-turn trace fed to the summarizer.
85
102
  *
@@ -214,6 +231,23 @@ export declare function buildExtractionMaterial(turn: readonly SessionEvent[], p
214
231
  * subclass cannot diverge from the host's transaction semantics.
215
232
  */
216
233
  export default class VerbatimCompactionEngine extends BasicCompactionEngine {
234
+ /**
235
+ * Extraction results, keyed by a hash of the material they were derived from.
236
+ *
237
+ * `digestSource` rebuilds from the raw log on every compaction, and raw events
238
+ * never leave the log, so the same turn is re-read on every later compaction.
239
+ * Measured on the session that compacted 33 times: 142 turns extracted, 129 of
240
+ * them (91%) already extracted earlier. Across all 370 local sessions, 81% of
241
+ * compaction work is that repeat.
242
+ *
243
+ * Keyed by content, not by slot position: a position holds different content
244
+ * once history grows, so a positional key would serve a stale block. A turn
245
+ * whose prose changed hashes differently and is extracted again.
246
+ *
247
+ * Process-local and bounded. A restart loses it, costing one re-extraction —
248
+ * the same behaviour as before this cache existed.
249
+ */
250
+ private readonly extractionCache;
217
251
  /**
218
252
  * Build the replacement checkpoint content from the session log.
219
253
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logictan/dsh-verbatim-compaction",
3
- "version": "0.3.1",
3
+ "version": "0.5.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",