@logictan/dsh-verbatim-compaction 0.1.0 → 0.2.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,6 @@
1
1
  # @logictan/dsh-verbatim-compaction
2
2
 
3
- 把上下文压缩的**摘要**从「让模型重写一遍」换成「确定性地把正文抄进 checkpoint」:用户与助手的正文逐字保留,工具结果压成一行指针,推理块从不读取,全程零模型调用。
3
+ 把上下文压缩的**摘要**从「让模型重写一遍」换成「正文逐字抄进 checkpoint,再用一次模型调用从工具轨迹里补出结论」:用户与助手的正文逐字保留、推理块从不读取;每轮另调一次会话自身的模型,输出「做了什么 / 得到什么结论 / 哪些结论被推翻」。
4
4
 
5
5
  ## 它解决什么
6
6
 
@@ -37,6 +37,22 @@
37
37
 
38
38
  `turn/start` 不在 surface 上,但它是「这条 user 消息属于第几轮」的唯一记录(user 消息自身不带 turn),所以一并保留。
39
39
 
40
+ ## 工具轨迹补出的三节
41
+
42
+ 逐字抄正文救不了「正文为空」的轮次。实测一个 13878 事件的会话:1973 条 assistant 消息里只有 317 条带正文,其中一轮跑了 342 次工具调用却一个字没写,逐字摘要把它压成一行 `tools: bash×281`——活干了,记录没了。
43
+
44
+ 所以每轮另发一次模型调用,材料是三段:用户请求(逐字)、助手正文(逐字)、工具轨迹(每条调用的 `description`,失败的调用另记一行首行错误)。模型输出固定的三节:`## Did` / `## Concluded` / `## Overturned`。
45
+
46
+ 模型用的是会话自己的路由(`session.requestHeader()?.config`),和宿主原生压缩同一个取法,不需要单独配置凭据。
47
+
48
+ 三件事刻意做成这样:
49
+
50
+ - **失败信号只认 `data.isError`**。曾用 `/error/i` 匹配序列化文本,347 条成功结果全部命中(工具输出里常有 "error" 字样),模型据此报告「整轮失败」。测试里有一条专门钉住这个坑。
51
+ - **工具调用从 `assistant/message` 的 block 里取**。日志里没有独立的 `tool/call` 事件类型,按它过滤会一条都取不到。
52
+ - **提取失败就退回纯逐字摘要**。模型不可达、超时、输出为空都返回 null;压缩本身不能因此失败,否则会话死于上下文溢出,比丢细节更糟。
53
+
54
+ 代价是每轮一次模型调用(实测 13 轮合计约 74649 字符材料)。提取结果与逐字摘要共用同一个预算,超预算时只保留逐字摘要。
55
+
40
56
  ## 为什么不会触发 shrink 守卫
41
57
 
42
58
  基类的守卫比较「framed 摘要」与「被替换内容的 route 价」,摘要不小于原内容就整次压缩失败。本插件把预算直接算成 `min(196752, shadowedPrice − 86 − 512)`:上界取守卫自己的那个数,因此这条失败路径按构造不可能发生,而不是靠实测侥幸。实测 8 个会话 0 次触发,最小余量 174387。
package/lib/index.js CHANGED
@@ -115,6 +115,115 @@ function toolCallsOf(event) {
115
115
  }
116
116
  return calls;
117
117
  }
118
+ /**
119
+ * Whether one tool result is a failure.
120
+ *
121
+ * Reads `data.message.isError` and nothing else. Matching the serialized text
122
+ * for /error/i was tried and is wrong: tool output routinely contains that word
123
+ * (grep misses, compiler logs, dumps of source files), so it labelled every
124
+ * result in a 347-result turn as failed and the summarizer reported the whole
125
+ * turn as a failure.
126
+ *
127
+ * @param event - a `tool/result` event.
128
+ * @returns true only when the log itself marks the result as an error.
129
+ */
130
+ export function isErrorResult(event) {
131
+ const message = messageOf(event);
132
+ return message?.isError === true;
133
+ }
134
+ /** Directive appended as the final user message of the extraction call. */
135
+ 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.',
138
+ '',
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.',
140
+ '',
141
+ '## Did',
142
+ '- what was actually done, concretely',
143
+ '',
144
+ '## Concluded',
145
+ '- findings, decisions, or measurements that a later turn would need',
146
+ '',
147
+ '## Overturned',
148
+ '- earlier conclusions this turn disproved or replaced',
149
+ '',
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)".',
151
+ ].join('\n');
152
+ /** Most tool calls one turn may contribute to the hint block. */
153
+ const MAX_HINTS_PER_TURN = 120;
154
+ /** Characters kept from one call's description or command head. */
155
+ const HINT_CHARS = 100;
156
+ /** Characters kept from one failing result's first line. */
157
+ const ERROR_CHARS = 140;
158
+ /**
159
+ * The best short description of what one call was for.
160
+ *
161
+ * The description argument wins when present because the agent writes it for
162
+ * humans; the command body is the fallback and is truncated hard, since a single
163
+ * `python3 -c` can be hundreds of lines.
164
+ */
165
+ function hintOf(argumentsJson) {
166
+ let parsed;
167
+ try {
168
+ const value = JSON.parse(argumentsJson);
169
+ if (typeof value !== 'object' || value === null)
170
+ return '';
171
+ parsed = value;
172
+ }
173
+ catch {
174
+ return '';
175
+ }
176
+ for (const key of ['description', 'command', 'query', 'file_path', 'path', 'pattern']) {
177
+ const candidate = parsed[key];
178
+ if (typeof candidate === 'string' && candidate.trim().length > 0) {
179
+ return candidate.trim().replace(/\s+/g, ' ').slice(0, HINT_CHARS);
180
+ }
181
+ }
182
+ return '';
183
+ }
184
+ /**
185
+ * Build the compact per-turn trace fed to the summarizer.
186
+ *
187
+ * This is what lets a turn whose assistant prose is empty still leave a record:
188
+ * in the measured session one turn ran 342 tool calls and emitted zero prose, so
189
+ * the verbatim digest reduced it to a single `tools:` count line. The trace names
190
+ * what was attempted and which attempts failed, which is enough for a model to
191
+ * state the outcome.
192
+ *
193
+ * Bounded on purpose — 500 calls would otherwise produce a prompt larger than the
194
+ * material it summarizes.
195
+ *
196
+ * @param events - one turn's events, in log order.
197
+ * @returns newline-joined hints, or an empty string when the turn has none.
198
+ */
199
+ export function collectHints(events) {
200
+ const lines = [];
201
+ let calls = 0;
202
+ for (const event of events) {
203
+ // Tool calls are blocks inside `assistant/message`, not standalone events:
204
+ // `digestSource` emits no `tool/call` type at all, so selecting on that type
205
+ // matched nothing and a 342-call turn produced a single hint line.
206
+ if (event.type === 'assistant/message') {
207
+ for (const call of toolCallsOf(event)) {
208
+ if (calls >= MAX_HINTS_PER_TURN)
209
+ break;
210
+ calls += 1;
211
+ const hint = hintOf(call.arguments);
212
+ lines.push(hint.length > 0 ? `TOOL ${call.name}: ${hint}` : `TOOL ${call.name}`);
213
+ }
214
+ continue;
215
+ }
216
+ if (event.type !== 'tool/result')
217
+ continue;
218
+ if (!isErrorResult(event))
219
+ continue;
220
+ const firstLine = textOf(event).split('\n')[0] ?? '';
221
+ if (firstLine.trim().length === 0)
222
+ continue;
223
+ lines.push(`ERR ${firstLine.trim().slice(0, ERROR_CHARS)}`);
224
+ }
225
+ return lines.join('\n');
226
+ }
118
227
  /** The `path` argument of one tool call, when the call carries one. */
119
228
  function pathArgumentOf(argumentsJson) {
120
229
  try {
@@ -397,6 +506,97 @@ export function digestSource(session, retainedSeqs) {
397
506
  return !retainedSeqs.has(event.seq);
398
507
  });
399
508
  }
509
+ /**
510
+ * Assemble the three-part material one extraction call is given.
511
+ *
512
+ * Split out from the call itself so it can be asserted: the first hand-built
513
+ * version of this silently dropped the user request, because it selected user
514
+ * messages by `data.turn` and user messages carry no turn of their own. The model
515
+ * then summarized tool names with no idea what had been asked.
516
+ *
517
+ * @param turn - one turn's events, in log order.
518
+ * @param prose - that turn's assistant prose, already extracted by the caller.
519
+ * @returns the joined material, or an empty string when the turn has nothing.
520
+ */
521
+ export function buildExtractionMaterial(turn, prose) {
522
+ const userRequest = turn
523
+ .filter((event) => event.type === 'user/message' && !isInjection(event) && !isCheckpoint(event))
524
+ .map((event) => textOf(event))
525
+ .filter((text) => text.length > 0)
526
+ .join('\n');
527
+ const hints = collectHints(turn);
528
+ if (userRequest.length === 0 && prose.length === 0 && hints.length === 0)
529
+ return '';
530
+ return [
531
+ `USER REQUEST:\n${userRequest.slice(0, 4000) || '(none)'}`,
532
+ `ASSISTANT PROSE:\n${prose.slice(0, 6000) || '(none)'}`,
533
+ `TOOL TRACE:\n${hints || '(none)'}`,
534
+ ].join('\n\n');
535
+ }
536
+ /**
537
+ * Per-turn extraction through the host's own LLM service.
538
+ *
539
+ * This is the part the verbatim digest cannot do on its own. Measured on a real
540
+ * 13878-event session: 1973 assistant messages, of which only 317 carried any
541
+ * prose, and one turn ran 342 tool calls while emitting zero characters of text.
542
+ * A digest that only copies prose therefore reduced that whole turn to a `tools:`
543
+ * count line — the work happened, the record did not.
544
+ *
545
+ * The call reuses the session's routed model exactly as the host's own
546
+ * compaction does (`ctx.llm.stream` with the session's provider/model), so no
547
+ * separate credential or configuration is involved.
548
+ *
549
+ * Every failure path returns null and the caller falls back to the plain digest:
550
+ * a compaction that cannot reach the model must still compact, or the session
551
+ * dies of context overflow instead of losing detail.
552
+ *
553
+ * @param ctx - plugin context carrying the `llm` service.
554
+ * @param agent - owner of the session being compacted; supplies the routed model.
555
+ * @param turn - one turn's events.
556
+ * @param prose - that turn's assistant prose, already extracted.
557
+ * @param signal - cancellation forwarded to the adapter.
558
+ * @returns the extracted block, or null when extraction is unavailable.
559
+ */
560
+ async function extractTurn(ctx, agent, turn, prose, signal) {
561
+ const llm = ctx.llm;
562
+ if (llm === undefined)
563
+ return null;
564
+ const route = agent.session.requestHeader()?.config;
565
+ const provider = route?.provider ?? agent.options.provider;
566
+ const model = route?.model ?? agent.options.model;
567
+ if (provider === undefined || model === undefined)
568
+ return null;
569
+ const material = buildExtractionMaterial(turn, prose);
570
+ if (material.length === 0)
571
+ return null;
572
+ const chunks = [];
573
+ try {
574
+ const stream = llm.stream({
575
+ provider,
576
+ model,
577
+ messages: [
578
+ { role: 'user', content: [{ type: 'text', text: `${material}\n\n${EXTRACT_INSTRUCTION}` }] },
579
+ ],
580
+ maxTokens: 400,
581
+ sessionId: agent.session.id,
582
+ purpose: 'compaction',
583
+ ...(signal === undefined ? {} : { signal }),
584
+ });
585
+ for await (const chunk of stream) {
586
+ // Chunk kinds come from `@deepseek-ai/dsh-llm`'s BlockAssembler: text
587
+ // arrives as `text-delta`, and `reasoning-delta` is deliberately ignored —
588
+ // reasoning is 98.8% of a long session's output and is not the record.
589
+ const delta = chunk;
590
+ if (delta.type === 'text-delta' && typeof delta.text === 'string')
591
+ chunks.push(delta.text);
592
+ }
593
+ }
594
+ catch {
595
+ return null;
596
+ }
597
+ const text = chunks.join('').trim();
598
+ return text.length > 0 ? text : null;
599
+ }
400
600
  /**
401
601
  * Deterministic compaction backend: verbatim prose, pointerized tools, no model
402
602
  * call.
@@ -434,7 +634,15 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
434
634
  shadowedPrice += node.tokens;
435
635
  }
436
636
  const budget = Math.max(0, Math.min(DIGEST_BUDGET_TOKENS, shadowedPrice - FRAME_TOKENS - SHRINK_MARGIN_TOKENS));
437
- const text = renderDigest(source, budget);
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.
642
+ 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));
438
646
  const route = session.requestHeader()?.config;
439
647
  return {
440
648
  summary: [{ type: 'text', text }],
@@ -442,4 +650,54 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
442
650
  model: route?.model ?? agent.options.model ?? 'digest',
443
651
  };
444
652
  }
653
+ /**
654
+ * Run the extraction for every turn in the digest source.
655
+ *
656
+ * Sequential rather than parallel: these calls share the session's routed
657
+ * model and the provider's KV cache, and firing a dozen at once would both
658
+ * multiply the cost and defeat the cache the host's own summarizer relies on.
659
+ *
660
+ * A turn that fails is simply absent from the output; one bad turn must not
661
+ * drop the extraction for the rest.
662
+ *
663
+ * @param source - digest source events, in log order.
664
+ * @param agent - owner of the session being compacted.
665
+ * @param signal - cancellation forwarded to every call.
666
+ * @returns the extracted blocks joined by turn heading, or an empty string.
667
+ */
668
+ async extractAll(source, agent, signal) {
669
+ const turns = new Map();
670
+ let lastTurn;
671
+ for (const event of source) {
672
+ if (event.type === 'turn/start') {
673
+ const turn = turnOf(event);
674
+ if (turn !== undefined)
675
+ lastTurn = turn;
676
+ continue;
677
+ }
678
+ const turn = turnOf(event) ?? lastTurn;
679
+ if (turn === undefined)
680
+ continue;
681
+ const bucket = turns.get(turn);
682
+ if (bucket === undefined)
683
+ turns.set(turn, [event]);
684
+ else
685
+ bucket.push(event);
686
+ }
687
+ const blocks = [];
688
+ for (const [turn, events] of [...turns.entries()].sort((left, right) => left[0] - right[0])) {
689
+ if (signal?.aborted === true)
690
+ 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}`);
700
+ }
701
+ return blocks.join('\n');
702
+ }
445
703
  }
@@ -49,6 +49,35 @@ export interface SummaryResult {
49
49
  provider: string;
50
50
  model: string;
51
51
  }
52
+ /**
53
+ * Whether one tool result is a failure.
54
+ *
55
+ * Reads `data.message.isError` and nothing else. Matching the serialized text
56
+ * for /error/i was tried and is wrong: tool output routinely contains that word
57
+ * (grep misses, compiler logs, dumps of source files), so it labelled every
58
+ * result in a 347-result turn as failed and the summarizer reported the whole
59
+ * turn as a failure.
60
+ *
61
+ * @param event - a `tool/result` event.
62
+ * @returns true only when the log itself marks the result as an error.
63
+ */
64
+ export declare function isErrorResult(event: SessionEvent): boolean;
65
+ /**
66
+ * Build the compact per-turn trace fed to the summarizer.
67
+ *
68
+ * This is what lets a turn whose assistant prose is empty still leave a record:
69
+ * in the measured session one turn ran 342 tool calls and emitted zero prose, so
70
+ * the verbatim digest reduced it to a single `tools:` count line. The trace names
71
+ * what was attempted and which attempts failed, which is enough for a model to
72
+ * state the outcome.
73
+ *
74
+ * Bounded on purpose — 500 calls would otherwise produce a prompt larger than the
75
+ * material it summarizes.
76
+ *
77
+ * @param events - one turn's events, in log order.
78
+ * @returns newline-joined hints, or an empty string when the turn has none.
79
+ */
80
+ export declare function collectHints(events: readonly SessionEvent[]): string;
52
81
  /** One turn's accumulated digest material. */
53
82
  interface Turn {
54
83
  turn: number | undefined;
@@ -135,6 +164,19 @@ export declare function shadowedSeqs(session: Session, input: SummarizationInput
135
164
  * @returns the events the digest is built from, in log order.
136
165
  */
137
166
  export declare function digestSource(session: Session, retainedSeqs: ReadonlySet<SessionSeq>): readonly SessionEvent[];
167
+ /**
168
+ * Assemble the three-part material one extraction call is given.
169
+ *
170
+ * Split out from the call itself so it can be asserted: the first hand-built
171
+ * version of this silently dropped the user request, because it selected user
172
+ * messages by `data.turn` and user messages carry no turn of their own. The model
173
+ * then summarized tool names with no idea what had been asked.
174
+ *
175
+ * @param turn - one turn's events, in log order.
176
+ * @param prose - that turn's assistant prose, already extracted by the caller.
177
+ * @returns the joined material, or an empty string when the turn has nothing.
178
+ */
179
+ export declare function buildExtractionMaterial(turn: readonly SessionEvent[], prose: string): string;
138
180
  /**
139
181
  * Deterministic compaction backend: verbatim prose, pointerized tools, no model
140
182
  * call.
@@ -161,5 +203,21 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
161
203
  * @returns text summary blocks, unmarked by any LLM call.
162
204
  */
163
205
  protected summarize(input: SummarizationInput, agent: Agent, _signal?: AbortSignal): Promise<SummaryResult>;
206
+ /**
207
+ * Run the extraction for every turn in the digest source.
208
+ *
209
+ * Sequential rather than parallel: these calls share the session's routed
210
+ * model and the provider's KV cache, and firing a dozen at once would both
211
+ * multiply the cost and defeat the cache the host's own summarizer relies on.
212
+ *
213
+ * A turn that fails is simply absent from the output; one bad turn must not
214
+ * drop the extraction for the rest.
215
+ *
216
+ * @param source - digest source events, in log order.
217
+ * @param agent - owner of the session being compacted.
218
+ * @param signal - cancellation forwarded to every call.
219
+ * @returns the extracted blocks joined by turn heading, or an empty string.
220
+ */
221
+ private extractAll;
164
222
  }
165
223
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logictan/dsh-verbatim-compaction",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",