@logictan/dsh-verbatim-compaction 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -3
- package/lib/index.js +283 -8
- package/lib/types/index.d.ts +58 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @logictan/dsh-verbatim-compaction
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把上下文压缩的**摘要**从「让模型重写一遍」换成「正文逐字抄进 checkpoint,再用一次模型调用从工具轨迹里补出结论」:用户与助手的正文逐字保留、推理块从不读取;每轮另调一次会话自身的模型,输出「做了什么 / 得到什么结论 / 哪些结论被推翻」。
|
|
4
4
|
|
|
5
5
|
## 它解决什么
|
|
6
6
|
|
|
@@ -37,9 +37,33 @@
|
|
|
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
|
-
基类的守卫比较「framed 摘要」与「被替换内容的 route
|
|
58
|
+
基类的守卫比较「framed 摘要」与「被替换内容的 route 价」,摘要不小于原内容就整次压缩失败。本插件把预算算成 `min(shadowedPrice × 0.5, shadowedPrice − 86 − 512)`,因此这条失败路径按构造不可能发生,而不是靠实测侥幸。
|
|
59
|
+
|
|
60
|
+
## 为什么会收敛(一条真实事故)
|
|
61
|
+
|
|
62
|
+
只满足守卫**不等于**收敛。守卫只要求摘要比被替换的内容小,一个占到被替换量 99.3% 的摘要照样通过守卫,而 surface 几乎没变。
|
|
63
|
+
|
|
64
|
+
会话 `小鹏汽车 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。
|
|
65
|
+
|
|
66
|
+
预算改成被替换量的一半后,同一份日志实测:257696 → **188120** token(0.73× 阈值),一轮就落到阈值以下。只压一条 checkpoint 时摘要也减半(279048 → 139865 字符),因此是几何衰减而非固定点。
|
|
43
67
|
|
|
44
68
|
## 安装
|
|
45
69
|
|
|
@@ -91,7 +115,7 @@ pnpm add @logictan/dsh-verbatim-compaction
|
|
|
91
115
|
|
|
92
116
|
## 已知边界
|
|
93
117
|
|
|
94
|
-
- **正文超预算会被截断。** 预算是硬的,两级依次触发:**先** user 正文超过预算一半时从**最新**往前算、最旧的先丢(用户刚说的话是下一轮唯一依赖的东西,丢它等于丢任务);**仍不够**才把助手正文按轮均分截断,每轮下限 800
|
|
118
|
+
- **正文超预算会被截断。** 预算是硬的,两级依次触发:**先** user 正文超过预算一半时从**最新**往前算、最旧的先丢(用户刚说的话是下一轮唯一依赖的东西,丢它等于丢任务);**仍不够**才把助手正文按轮均分截断,每轮下限 800 字符。在 122 轮、助手正文 31.6 万字符的真实会话上,第一级触发 104 处、第二级没触发——长会话里这是常态而不是守卫。
|
|
95
119
|
- **`turn/start` 缺失时轮次编号退化为 `turn ?`。** 不会丢正文,只是编号不可读。
|
|
96
120
|
- **注入内容不进摘要。** 判据是 `source.kind !== 'user'`——注入上下文在一个真实会话里占 user 消息字符的 97%,放进摘要会把人的原话淹掉。
|
|
97
121
|
- **不改善首轮之外的工具参数细节。** `bash` 命令本身(占工具参数 84.2%)不保留;需要「当时跑了什么命令」的场景不在本插件的覆盖范围内。
|
package/lib/index.js
CHANGED
|
@@ -34,14 +34,22 @@ 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
|
-
*
|
|
37
|
+
* Largest share of the shadowed price one digest may occupy.
|
|
38
38
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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.
|
|
43
51
|
*/
|
|
44
|
-
const
|
|
52
|
+
const DIGEST_SHARE_OF_SHADOWED = 0.5;
|
|
45
53
|
/** Framing overhead `frameSummary` adds: preamble plus both tags, 341 chars = 86 tokens. */
|
|
46
54
|
const FRAME_TOKENS = 86;
|
|
47
55
|
/** Safety margin below the shrink guard, in tokens. */
|
|
@@ -115,6 +123,117 @@ function toolCallsOf(event) {
|
|
|
115
123
|
}
|
|
116
124
|
return calls;
|
|
117
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Whether one tool result is a failure.
|
|
128
|
+
*
|
|
129
|
+
* Reads `data.message.isError` and nothing else. Matching the serialized text
|
|
130
|
+
* for /error/i was tried and is wrong: tool output routinely contains that word
|
|
131
|
+
* (grep misses, compiler logs, dumps of source files), so it labelled every
|
|
132
|
+
* result in a 347-result turn as failed and the summarizer reported the whole
|
|
133
|
+
* turn as a failure.
|
|
134
|
+
*
|
|
135
|
+
* @param event - a `tool/result` event.
|
|
136
|
+
* @returns true only when the log itself marks the result as an error.
|
|
137
|
+
*/
|
|
138
|
+
export function isErrorResult(event) {
|
|
139
|
+
const message = messageOf(event);
|
|
140
|
+
return message?.isError === true;
|
|
141
|
+
}
|
|
142
|
+
/** Heading that introduces the extracted section at the top of a digest. */
|
|
143
|
+
const EXTRACTED_HEADING = '# Extracted from tool traces';
|
|
144
|
+
/** Directive appended as the final user message of the extraction call. */
|
|
145
|
+
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.',
|
|
148
|
+
'',
|
|
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.',
|
|
150
|
+
'',
|
|
151
|
+
'## Did',
|
|
152
|
+
'- what was actually done, concretely',
|
|
153
|
+
'',
|
|
154
|
+
'## Concluded',
|
|
155
|
+
'- findings, decisions, or measurements that a later turn would need',
|
|
156
|
+
'',
|
|
157
|
+
'## Overturned',
|
|
158
|
+
'- earlier conclusions this turn disproved or replaced',
|
|
159
|
+
'',
|
|
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)".',
|
|
161
|
+
].join('\n');
|
|
162
|
+
/** Most tool calls one turn may contribute to the hint block. */
|
|
163
|
+
const MAX_HINTS_PER_TURN = 120;
|
|
164
|
+
/** Characters kept from one call's description or command head. */
|
|
165
|
+
const HINT_CHARS = 100;
|
|
166
|
+
/** Characters kept from one failing result's first line. */
|
|
167
|
+
const ERROR_CHARS = 140;
|
|
168
|
+
/**
|
|
169
|
+
* The best short description of what one call was for.
|
|
170
|
+
*
|
|
171
|
+
* The description argument wins when present because the agent writes it for
|
|
172
|
+
* humans; the command body is the fallback and is truncated hard, since a single
|
|
173
|
+
* `python3 -c` can be hundreds of lines.
|
|
174
|
+
*/
|
|
175
|
+
function hintOf(argumentsJson) {
|
|
176
|
+
let parsed;
|
|
177
|
+
try {
|
|
178
|
+
const value = JSON.parse(argumentsJson);
|
|
179
|
+
if (typeof value !== 'object' || value === null)
|
|
180
|
+
return '';
|
|
181
|
+
parsed = value;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return '';
|
|
185
|
+
}
|
|
186
|
+
for (const key of ['description', 'command', 'query', 'file_path', 'path', 'pattern']) {
|
|
187
|
+
const candidate = parsed[key];
|
|
188
|
+
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
|
189
|
+
return candidate.trim().replace(/\s+/g, ' ').slice(0, HINT_CHARS);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return '';
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Build the compact per-turn trace fed to the summarizer.
|
|
196
|
+
*
|
|
197
|
+
* This is what lets a turn whose assistant prose is empty still leave a record:
|
|
198
|
+
* in the measured session one turn ran 342 tool calls and emitted zero prose, so
|
|
199
|
+
* the verbatim digest reduced it to a single `tools:` count line. The trace names
|
|
200
|
+
* what was attempted and which attempts failed, which is enough for a model to
|
|
201
|
+
* state the outcome.
|
|
202
|
+
*
|
|
203
|
+
* Bounded on purpose — 500 calls would otherwise produce a prompt larger than the
|
|
204
|
+
* material it summarizes.
|
|
205
|
+
*
|
|
206
|
+
* @param events - one turn's events, in log order.
|
|
207
|
+
* @returns newline-joined hints, or an empty string when the turn has none.
|
|
208
|
+
*/
|
|
209
|
+
export function collectHints(events) {
|
|
210
|
+
const lines = [];
|
|
211
|
+
let calls = 0;
|
|
212
|
+
for (const event of events) {
|
|
213
|
+
// Tool calls are blocks inside `assistant/message`, not standalone events:
|
|
214
|
+
// `digestSource` emits no `tool/call` type at all, so selecting on that type
|
|
215
|
+
// matched nothing and a 342-call turn produced a single hint line.
|
|
216
|
+
if (event.type === 'assistant/message') {
|
|
217
|
+
for (const call of toolCallsOf(event)) {
|
|
218
|
+
if (calls >= MAX_HINTS_PER_TURN)
|
|
219
|
+
break;
|
|
220
|
+
calls += 1;
|
|
221
|
+
const hint = hintOf(call.arguments);
|
|
222
|
+
lines.push(hint.length > 0 ? `TOOL ${call.name}: ${hint}` : `TOOL ${call.name}`);
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (event.type !== 'tool/result')
|
|
227
|
+
continue;
|
|
228
|
+
if (!isErrorResult(event))
|
|
229
|
+
continue;
|
|
230
|
+
const firstLine = textOf(event).split('\n')[0] ?? '';
|
|
231
|
+
if (firstLine.trim().length === 0)
|
|
232
|
+
continue;
|
|
233
|
+
lines.push(`ERR ${firstLine.trim().slice(0, ERROR_CHARS)}`);
|
|
234
|
+
}
|
|
235
|
+
return lines.join('\n');
|
|
236
|
+
}
|
|
118
237
|
/** The `path` argument of one tool call, when the call carries one. */
|
|
119
238
|
function pathArgumentOf(argumentsJson) {
|
|
120
239
|
try {
|
|
@@ -397,6 +516,97 @@ export function digestSource(session, retainedSeqs) {
|
|
|
397
516
|
return !retainedSeqs.has(event.seq);
|
|
398
517
|
});
|
|
399
518
|
}
|
|
519
|
+
/**
|
|
520
|
+
* Assemble the three-part material one extraction call is given.
|
|
521
|
+
*
|
|
522
|
+
* Split out from the call itself so it can be asserted: the first hand-built
|
|
523
|
+
* version of this silently dropped the user request, because it selected user
|
|
524
|
+
* messages by `data.turn` and user messages carry no turn of their own. The model
|
|
525
|
+
* then summarized tool names with no idea what had been asked.
|
|
526
|
+
*
|
|
527
|
+
* @param turn - one turn's events, in log order.
|
|
528
|
+
* @param prose - that turn's assistant prose, already extracted by the caller.
|
|
529
|
+
* @returns the joined material, or an empty string when the turn has nothing.
|
|
530
|
+
*/
|
|
531
|
+
export function buildExtractionMaterial(turn, prose) {
|
|
532
|
+
const userRequest = turn
|
|
533
|
+
.filter((event) => event.type === 'user/message' && !isInjection(event) && !isCheckpoint(event))
|
|
534
|
+
.map((event) => textOf(event))
|
|
535
|
+
.filter((text) => text.length > 0)
|
|
536
|
+
.join('\n');
|
|
537
|
+
const hints = collectHints(turn);
|
|
538
|
+
if (userRequest.length === 0 && prose.length === 0 && hints.length === 0)
|
|
539
|
+
return '';
|
|
540
|
+
return [
|
|
541
|
+
`USER REQUEST:\n${userRequest.slice(0, 4000) || '(none)'}`,
|
|
542
|
+
`ASSISTANT PROSE:\n${prose.slice(0, 6000) || '(none)'}`,
|
|
543
|
+
`TOOL TRACE:\n${hints || '(none)'}`,
|
|
544
|
+
].join('\n\n');
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Per-turn extraction through the host's own LLM service.
|
|
548
|
+
*
|
|
549
|
+
* This is the part the verbatim digest cannot do on its own. Measured on a real
|
|
550
|
+
* 13878-event session: 1973 assistant messages, of which only 317 carried any
|
|
551
|
+
* prose, and one turn ran 342 tool calls while emitting zero characters of text.
|
|
552
|
+
* A digest that only copies prose therefore reduced that whole turn to a `tools:`
|
|
553
|
+
* count line — the work happened, the record did not.
|
|
554
|
+
*
|
|
555
|
+
* The call reuses the session's routed model exactly as the host's own
|
|
556
|
+
* compaction does (`ctx.llm.stream` with the session's provider/model), so no
|
|
557
|
+
* separate credential or configuration is involved.
|
|
558
|
+
*
|
|
559
|
+
* Every failure path returns null and the caller falls back to the plain digest:
|
|
560
|
+
* a compaction that cannot reach the model must still compact, or the session
|
|
561
|
+
* dies of context overflow instead of losing detail.
|
|
562
|
+
*
|
|
563
|
+
* @param ctx - plugin context carrying the `llm` service.
|
|
564
|
+
* @param agent - owner of the session being compacted; supplies the routed model.
|
|
565
|
+
* @param turn - one turn's events.
|
|
566
|
+
* @param prose - that turn's assistant prose, already extracted.
|
|
567
|
+
* @param signal - cancellation forwarded to the adapter.
|
|
568
|
+
* @returns the extracted block, or null when extraction is unavailable.
|
|
569
|
+
*/
|
|
570
|
+
async function extractTurn(ctx, agent, turn, prose, signal) {
|
|
571
|
+
const llm = ctx.llm;
|
|
572
|
+
if (llm === undefined)
|
|
573
|
+
return null;
|
|
574
|
+
const route = agent.session.requestHeader()?.config;
|
|
575
|
+
const provider = route?.provider ?? agent.options.provider;
|
|
576
|
+
const model = route?.model ?? agent.options.model;
|
|
577
|
+
if (provider === undefined || model === undefined)
|
|
578
|
+
return null;
|
|
579
|
+
const material = buildExtractionMaterial(turn, prose);
|
|
580
|
+
if (material.length === 0)
|
|
581
|
+
return null;
|
|
582
|
+
const chunks = [];
|
|
583
|
+
try {
|
|
584
|
+
const stream = llm.stream({
|
|
585
|
+
provider,
|
|
586
|
+
model,
|
|
587
|
+
messages: [
|
|
588
|
+
{ role: 'user', content: [{ type: 'text', text: `${material}\n\n${EXTRACT_INSTRUCTION}` }] },
|
|
589
|
+
],
|
|
590
|
+
maxTokens: 400,
|
|
591
|
+
sessionId: agent.session.id,
|
|
592
|
+
purpose: 'compaction',
|
|
593
|
+
...(signal === undefined ? {} : { signal }),
|
|
594
|
+
});
|
|
595
|
+
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
|
+
const delta = chunk;
|
|
600
|
+
if (delta.type === 'text-delta' && typeof delta.text === 'string')
|
|
601
|
+
chunks.push(delta.text);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
const text = chunks.join('').trim();
|
|
608
|
+
return text.length > 0 ? text : null;
|
|
609
|
+
}
|
|
400
610
|
/**
|
|
401
611
|
* Deterministic compaction backend: verbatim prose, pointerized tools, no model
|
|
402
612
|
* call.
|
|
@@ -433,8 +643,23 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
433
643
|
if (shadowedSet.has(node.seq))
|
|
434
644
|
shadowedPrice += node.tokens;
|
|
435
645
|
}
|
|
436
|
-
const budget = Math.max(0, Math.min(
|
|
437
|
-
|
|
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.
|
|
659
|
+
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));
|
|
438
663
|
const route = session.requestHeader()?.config;
|
|
439
664
|
return {
|
|
440
665
|
summary: [{ type: 'text', text }],
|
|
@@ -442,4 +667,54 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
442
667
|
model: route?.model ?? agent.options.model ?? 'digest',
|
|
443
668
|
};
|
|
444
669
|
}
|
|
670
|
+
/**
|
|
671
|
+
* Run the extraction for every turn in the digest source.
|
|
672
|
+
*
|
|
673
|
+
* Sequential rather than parallel: these calls share the session's routed
|
|
674
|
+
* model and the provider's KV cache, and firing a dozen at once would both
|
|
675
|
+
* multiply the cost and defeat the cache the host's own summarizer relies on.
|
|
676
|
+
*
|
|
677
|
+
* A turn that fails is simply absent from the output; one bad turn must not
|
|
678
|
+
* drop the extraction for the rest.
|
|
679
|
+
*
|
|
680
|
+
* @param source - digest source events, in log order.
|
|
681
|
+
* @param agent - owner of the session being compacted.
|
|
682
|
+
* @param signal - cancellation forwarded to every call.
|
|
683
|
+
* @returns the extracted blocks joined by turn heading, or an empty string.
|
|
684
|
+
*/
|
|
685
|
+
async extractAll(source, agent, signal) {
|
|
686
|
+
const turns = new Map();
|
|
687
|
+
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;
|
|
694
|
+
}
|
|
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
|
+
}
|
|
704
|
+
const blocks = [];
|
|
705
|
+
for (const [turn, events] of [...turns.entries()].sort((left, right) => left[0] - right[0])) {
|
|
706
|
+
if (signal?.aborted === true)
|
|
707
|
+
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}`);
|
|
717
|
+
}
|
|
718
|
+
return blocks.join('\n');
|
|
719
|
+
}
|
|
445
720
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
|
3
|
+
"version": "0.2.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",
|