@logictan/dsh-verbatim-compaction 0.3.0 → 0.4.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 +12 -4
- package/lib/index.js +120 -46
- package/lib/types/index.d.ts +18 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,14 +17,16 @@
|
|
|
17
17
|
## 摘要长什么样
|
|
18
18
|
|
|
19
19
|
```
|
|
20
|
-
## user
|
|
20
|
+
## user
|
|
21
21
|
(用户原话,逐字,永不改写)
|
|
22
22
|
|
|
23
|
-
### assistant
|
|
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
|
|
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
|
|
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
|
|
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
|
-
'Each
|
|
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.',
|
|
206
|
+
'',
|
|
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.',
|
|
157
208
|
'',
|
|
158
|
-
'
|
|
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',
|
|
@@ -362,13 +414,26 @@ export function buildSections(events) {
|
|
|
362
414
|
* @returns the same sections with `extracted` set where available.
|
|
363
415
|
*/
|
|
364
416
|
function withExtractions(sections, extracted) {
|
|
365
|
-
return sections.map((section) => ({
|
|
366
|
-
turns: section.turns.map((turn) => {
|
|
367
|
-
const block = extracted.get(
|
|
417
|
+
return sections.map((section, sectionIndex) => ({
|
|
418
|
+
turns: section.turns.map((turn, turnIndex) => {
|
|
419
|
+
const block = extracted.get(turnKey(sectionIndex, turnIndex));
|
|
368
420
|
return block === undefined ? turn : { ...turn, extracted: block };
|
|
369
421
|
}),
|
|
370
422
|
}));
|
|
371
423
|
}
|
|
424
|
+
/**
|
|
425
|
+
* Position-based key for one turn: `<section index>:<turn index>`.
|
|
426
|
+
*
|
|
427
|
+
* Not the turn number. `turn/start` is emitted per step in some sessions and only
|
|
428
|
+
* once in others, so the number in the log is neither unique nor one-per-request.
|
|
429
|
+
* A real session carried 2 user requests under a single `turn/start` (turn 1), so
|
|
430
|
+
* both sections were keyed `1`; the second extraction overwrote the first, and
|
|
431
|
+
* the digest printed the same block twice — once under a request about source
|
|
432
|
+
* refresh, once under 「模拟器使用硬件加速!」.
|
|
433
|
+
*/
|
|
434
|
+
function turnKey(sectionIndex, turnIndex) {
|
|
435
|
+
return `${sectionIndex}:${turnIndex}`;
|
|
436
|
+
}
|
|
372
437
|
/**
|
|
373
438
|
* Render one section to digest text, truncating assistant prose to `capChars`.
|
|
374
439
|
*
|
|
@@ -383,15 +448,14 @@ function withExtractions(sections, extracted) {
|
|
|
383
448
|
function renderSection(section, capChars) {
|
|
384
449
|
const lines = [];
|
|
385
450
|
for (const turn of section.turns) {
|
|
386
|
-
const label = turn.turn === undefined ? '?' : String(turn.turn);
|
|
387
451
|
if (turn.user.length > 0)
|
|
388
|
-
lines.push(`\n## user
|
|
452
|
+
lines.push(`\n## user\n${turn.user.join('\n')}`);
|
|
389
453
|
if (turn.extracted !== undefined) {
|
|
390
454
|
let block = turn.extracted;
|
|
391
455
|
if (capChars !== undefined && block.length > capChars) {
|
|
392
456
|
block = `${block.slice(0, capChars)}\n…(+${block.length - capChars} chars)`;
|
|
393
457
|
}
|
|
394
|
-
lines.push(`### assistant
|
|
458
|
+
lines.push(`### assistant\n${block}`);
|
|
395
459
|
}
|
|
396
460
|
else {
|
|
397
461
|
let body = turn.assistant.join('\n');
|
|
@@ -399,7 +463,7 @@ function renderSection(section, capChars) {
|
|
|
399
463
|
body = `${body.slice(0, capChars)}\n…(+${body.length - capChars} chars)`;
|
|
400
464
|
}
|
|
401
465
|
if (body.length > 0)
|
|
402
|
-
lines.push(`### assistant
|
|
466
|
+
lines.push(`### assistant\n${body}`);
|
|
403
467
|
}
|
|
404
468
|
if (turn.tools.size > 0) {
|
|
405
469
|
const names = [...turn.tools.entries()]
|
|
@@ -653,17 +717,20 @@ async function extractBatch(ctx, agent, batch, signal) {
|
|
|
653
717
|
const model = route?.model ?? agent.options.model;
|
|
654
718
|
if (provider === undefined || model === undefined)
|
|
655
719
|
return out;
|
|
720
|
+
// The label the model echoes back is the slot key, not the turn number: two
|
|
721
|
+
// requests can share a number, and a duplicate label would collapse two blocks
|
|
722
|
+
// into one entry.
|
|
656
723
|
const parts = [];
|
|
657
|
-
for (const
|
|
658
|
-
const prose = events
|
|
724
|
+
for (const slot of batch) {
|
|
725
|
+
const prose = slot.events
|
|
659
726
|
.filter((event) => event.type === 'assistant/message')
|
|
660
727
|
.map((event) => textOf(event))
|
|
661
728
|
.filter((text) => text.length > 0)
|
|
662
729
|
.join('\n');
|
|
663
|
-
const material = buildExtractionMaterial(events, prose);
|
|
730
|
+
const material = buildExtractionMaterial(slot.events, prose);
|
|
664
731
|
if (material.length === 0)
|
|
665
732
|
continue;
|
|
666
|
-
parts.push(`===
|
|
733
|
+
parts.push(`=== SLOT ${slot.key} (turn ${slot.turn ?? '?'}) ===\n${material}`);
|
|
667
734
|
}
|
|
668
735
|
if (parts.length === 0)
|
|
669
736
|
return out;
|
|
@@ -675,17 +742,12 @@ async function extractBatch(ctx, agent, batch, signal) {
|
|
|
675
742
|
messages: [
|
|
676
743
|
{ role: 'user', content: [{ type: 'text', text: `${parts.join('\n\n')}\n\n${EXTRACT_INSTRUCTION}` }] },
|
|
677
744
|
],
|
|
678
|
-
// One block per turn, so the cap scales with the batch rather than being
|
|
679
|
-
// a per-turn 400 that a dozen turns would multiply past the digest ceiling.
|
|
680
745
|
maxTokens: EXTRACT_TOKENS_PER_TURN * parts.length,
|
|
681
746
|
sessionId: agent.session.id,
|
|
682
747
|
purpose: 'compaction',
|
|
683
748
|
...(signal === undefined ? {} : { signal }),
|
|
684
749
|
});
|
|
685
750
|
for await (const chunk of stream) {
|
|
686
|
-
// Chunk kinds come from `@deepseek-ai/dsh-llm`'s BlockAssembler: text
|
|
687
|
-
// arrives as `text-delta`, and `reasoning-delta` is deliberately ignored —
|
|
688
|
-
// reasoning is 98.8% of a long session's output and is not the record.
|
|
689
751
|
const delta = chunk;
|
|
690
752
|
if (delta.type === 'text-delta' && typeof delta.text === 'string')
|
|
691
753
|
chunks.push(delta.text);
|
|
@@ -694,17 +756,15 @@ async function extractBatch(ctx, agent, batch, signal) {
|
|
|
694
756
|
catch {
|
|
695
757
|
return out;
|
|
696
758
|
}
|
|
697
|
-
// Split the answer back per turn. A turn the model skipped simply keeps its
|
|
698
|
-
// prose, so a partial answer degrades per turn instead of losing the batch.
|
|
699
759
|
const answer = chunks.join('').trim();
|
|
700
760
|
if (answer.length === 0)
|
|
701
761
|
return out;
|
|
702
|
-
const
|
|
703
|
-
for (let index = 1; index <
|
|
704
|
-
const
|
|
705
|
-
const body = (
|
|
706
|
-
if (
|
|
707
|
-
out.set(
|
|
762
|
+
const blocks = answer.split(/^=== SLOT ([^\s(]+)[^=]*===$/m);
|
|
763
|
+
for (let index = 1; index < blocks.length; index += 2) {
|
|
764
|
+
const key = blocks[index];
|
|
765
|
+
const body = pruneEmptySections((blocks[index + 1] ?? '').trim());
|
|
766
|
+
if (key !== undefined && body.length > 0)
|
|
767
|
+
out.set(key, body);
|
|
708
768
|
}
|
|
709
769
|
return out;
|
|
710
770
|
}
|
|
@@ -779,33 +839,47 @@ export default class VerbatimCompactionEngine extends BasicCompactionEngine {
|
|
|
779
839
|
* @returns turn number to extracted block, omitting turns that yielded none.
|
|
780
840
|
*/
|
|
781
841
|
async extractAll(source, agent, signal) {
|
|
782
|
-
|
|
842
|
+
// Walk the same sections the renderer will use, so a key addresses the exact
|
|
843
|
+
// slot the block will be written into. Grouping by turn number instead lost
|
|
844
|
+
// blocks whenever the log reused a number.
|
|
845
|
+
const sections = buildSections(source);
|
|
846
|
+
const slots = [];
|
|
783
847
|
let lastTurn;
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
848
|
+
let cursor = 0;
|
|
849
|
+
for (const [sectionIndex, section] of sections.entries()) {
|
|
850
|
+
for (const [turnIndex, turn] of section.turns.entries()) {
|
|
851
|
+
// Collect this turn's events by walking the source in order; the sections
|
|
852
|
+
// were built from the same array, so the cursor never rewinds.
|
|
853
|
+
const collected = [];
|
|
854
|
+
while (cursor < source.length) {
|
|
855
|
+
const event = source[cursor];
|
|
856
|
+
cursor += 1;
|
|
857
|
+
if (event === undefined)
|
|
858
|
+
break;
|
|
859
|
+
if (event.type === 'turn/start') {
|
|
860
|
+
const turnNumber = turnOf(event);
|
|
861
|
+
if (turnNumber !== undefined)
|
|
862
|
+
lastTurn = turnNumber;
|
|
863
|
+
collected.push(event);
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
collected.push(event);
|
|
867
|
+
if (event.type === 'user/message' || event.type === 'assistant/message') {
|
|
868
|
+
if (!isInjection(event) && !isCheckpoint(event))
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
slots.push({ key: turnKey(sectionIndex, turnIndex), turn: turn.turn, events: collected });
|
|
790
873
|
}
|
|
791
|
-
const turn = turnOf(event) ?? lastTurn;
|
|
792
|
-
if (turn === undefined)
|
|
793
|
-
continue;
|
|
794
|
-
const bucket = turns.get(turn);
|
|
795
|
-
if (bucket === undefined)
|
|
796
|
-
turns.set(turn, [event]);
|
|
797
|
-
else
|
|
798
|
-
bucket.push(event);
|
|
799
874
|
}
|
|
800
|
-
const ordered = [...turns.entries()].sort((left, right) => left[0] - right[0]);
|
|
801
875
|
const extracted = new Map();
|
|
802
|
-
for (let index = 0; index <
|
|
876
|
+
for (let index = 0; index < slots.length; index += TURNS_PER_CALL) {
|
|
803
877
|
if (signal?.aborted === true)
|
|
804
878
|
break;
|
|
805
|
-
const batch =
|
|
879
|
+
const batch = slots.slice(index, index + TURNS_PER_CALL);
|
|
806
880
|
const blocks = await extractBatch(this.ctx, agent, batch, signal);
|
|
807
|
-
for (const [
|
|
808
|
-
extracted.set(
|
|
881
|
+
for (const [key, block] of blocks)
|
|
882
|
+
extracted.set(key, block);
|
|
809
883
|
}
|
|
810
884
|
return extracted;
|
|
811
885
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
*
|
|
@@ -156,7 +173,7 @@ export declare function buildSections(events: readonly SessionEvent[]): Section[
|
|
|
156
173
|
* ceiling; the tiers exist for the turns where extraction was unavailable and
|
|
157
174
|
* the prose is all that remains.
|
|
158
175
|
*/
|
|
159
|
-
export declare function renderDigest(events: readonly SessionEvent[], budgetTokens: number, extracted?: ReadonlyMap<
|
|
176
|
+
export declare function renderDigest(events: readonly SessionEvent[], budgetTokens: number, extracted?: ReadonlyMap<string, string>): string;
|
|
160
177
|
/**
|
|
161
178
|
* The surface seqs this compaction shadows, recovered from the summarization
|
|
162
179
|
* input's message count.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logictan/dsh-verbatim-compaction",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|