pi-condense 2.0.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/CHANGELOG.md +73 -0
- package/LICENSE +22 -0
- package/PRUNING.md +1028 -0
- package/README.md +243 -0
- package/index.ts +858 -0
- package/package.json +56 -0
- package/src/batch-capture.ts +226 -0
- package/src/block-refs.test.ts +42 -0
- package/src/block-refs.ts +16 -0
- package/src/budget.test.ts +66 -0
- package/src/budget.ts +39 -0
- package/src/chain-compressor.test.ts +283 -0
- package/src/chain-compressor.ts +132 -0
- package/src/chain-detector.test.ts +302 -0
- package/src/chain-detector.ts +128 -0
- package/src/chain-range-prune.test.ts +522 -0
- package/src/chain-range-prune.ts +128 -0
- package/src/commands.test.ts +67 -0
- package/src/commands.ts +1207 -0
- package/src/config.ts +126 -0
- package/src/content-hash.ts +35 -0
- package/src/error-purge.test.ts +186 -0
- package/src/error-purge.ts +71 -0
- package/src/frontier.ts +62 -0
- package/src/indexer.ts +393 -0
- package/src/nested-placeholders.test.ts +82 -0
- package/src/nested-placeholders.ts +20 -0
- package/src/oversized-spill.integration.test.ts +73 -0
- package/src/protected.test.ts +62 -0
- package/src/protected.ts +51 -0
- package/src/pruner.test.ts +508 -0
- package/src/pruner.ts +156 -0
- package/src/query-tool.ts +78 -0
- package/src/range-compression.integration.test.ts +252 -0
- package/src/spill.test.ts +102 -0
- package/src/spill.ts +90 -0
- package/src/stats.test.ts +114 -0
- package/src/stats.ts +190 -0
- package/src/summarizer.test.ts +17 -0
- package/src/summarizer.ts +262 -0
- package/src/summary-refs.ts +61 -0
- package/src/thinking-strip.test.ts +175 -0
- package/src/thinking-strip.ts +42 -0
- package/src/tree-browser.ts +382 -0
- package/src/types.ts +764 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { pruneMessages, sizeMessages } from "./pruner.js";
|
|
3
|
+
import type { ChainCompressionConfig, ChainCompressionEntry } from "./types.js";
|
|
4
|
+
|
|
5
|
+
// Minimal mock exposing only the ToolCallIndexer surface that pruneMessages calls.
|
|
6
|
+
function makeMockIndexer({
|
|
7
|
+
summarized = new Set<string>(),
|
|
8
|
+
shortRefs = new Map<string, string>(),
|
|
9
|
+
chainEntries = [] as ChainCompressionEntry[],
|
|
10
|
+
summaryBodyMap = new Map<string, string>(),
|
|
11
|
+
records = new Map<string, any>(),
|
|
12
|
+
}: {
|
|
13
|
+
summarized?: Set<string>;
|
|
14
|
+
shortRefs?: Map<string, string>;
|
|
15
|
+
chainEntries?: ChainCompressionEntry[];
|
|
16
|
+
summaryBodyMap?: Map<string, string>;
|
|
17
|
+
records?: Map<string, any>;
|
|
18
|
+
} = {}) {
|
|
19
|
+
return {
|
|
20
|
+
isSummarized: (id: string) => summarized.has(id),
|
|
21
|
+
getShortRefForToolCallId: (id: string) => shortRefs.get(id),
|
|
22
|
+
getRecord: (id: string) => records.get(id),
|
|
23
|
+
getChainEntries: () => chainEntries,
|
|
24
|
+
getPerBatchSummaryTextForToolCallIds: (ids: string[]) => {
|
|
25
|
+
for (const id of ids) {
|
|
26
|
+
const text = summaryBodyMap.get(id);
|
|
27
|
+
if (text) return text;
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
},
|
|
31
|
+
} as any;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const enabledCC: ChainCompressionConfig = {
|
|
35
|
+
enabled: true,
|
|
36
|
+
rollingWindow: 0,
|
|
37
|
+
stripFinalAssistantThinking: true,
|
|
38
|
+
fuseRangeSummary: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("pruneMessages", () => {
|
|
42
|
+
it("stub-replaces a summarized tool result", () => {
|
|
43
|
+
const indexer = makeMockIndexer({
|
|
44
|
+
summarized: new Set(["tc1"]),
|
|
45
|
+
shortRefs: new Map([["tc1", "t1"]]),
|
|
46
|
+
});
|
|
47
|
+
const messages = [
|
|
48
|
+
{
|
|
49
|
+
role: "toolResult",
|
|
50
|
+
toolCallId: "tc1",
|
|
51
|
+
toolName: "bash",
|
|
52
|
+
content: [{ type: "text", text: "big output" }],
|
|
53
|
+
isError: false,
|
|
54
|
+
timestamp: 1,
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
const { messages: out, pruned } = pruneMessages(messages, indexer);
|
|
58
|
+
expect(pruned).toBe(true);
|
|
59
|
+
expect(out[0].content[0].text).toContain("`t1`");
|
|
60
|
+
expect(out[0].content[0].text).toContain("context_tree_query");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns original array reference when nothing is summarized or compressed", () => {
|
|
64
|
+
const indexer = makeMockIndexer();
|
|
65
|
+
const messages = [{ role: "user", content: "hello", timestamp: 1 }];
|
|
66
|
+
const { messages: out, pruned } = pruneMessages(messages, indexer, enabledCC);
|
|
67
|
+
expect(pruned).toBe(false);
|
|
68
|
+
expect(out).toBe(messages);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("applies chain compression after stub-replace", () => {
|
|
72
|
+
const toolCallId = "tc-mid";
|
|
73
|
+
const chainEntry: ChainCompressionEntry = {
|
|
74
|
+
blockId: "b1",
|
|
75
|
+
startUserTimestamp: 100,
|
|
76
|
+
droppedToolCallIds: [toolCallId],
|
|
77
|
+
finalAssistantTimestamp: 300,
|
|
78
|
+
toolRefs: ["t1"],
|
|
79
|
+
compressedAt: 999,
|
|
80
|
+
};
|
|
81
|
+
const summaryText = "ran bash, got results";
|
|
82
|
+
const indexer = makeMockIndexer({
|
|
83
|
+
summarized: new Set([toolCallId]),
|
|
84
|
+
shortRefs: new Map([[toolCallId, "t1"]]),
|
|
85
|
+
chainEntries: [chainEntry],
|
|
86
|
+
summaryBodyMap: new Map([[toolCallId, summaryText]]),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const messages: any[] = [
|
|
90
|
+
{ role: "user", content: [{ type: "text", text: "do it" }], timestamp: 100 },
|
|
91
|
+
{
|
|
92
|
+
role: "assistant",
|
|
93
|
+
content: [{ type: "toolCall", id: toolCallId, name: "bash", arguments: {} }],
|
|
94
|
+
timestamp: 200,
|
|
95
|
+
api: "anthropic",
|
|
96
|
+
provider: "anthropic",
|
|
97
|
+
model: "x",
|
|
98
|
+
usage: {},
|
|
99
|
+
stopReason: "tool_use",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
role: "toolResult",
|
|
103
|
+
toolCallId,
|
|
104
|
+
toolName: "bash",
|
|
105
|
+
content: [{ type: "text", text: "output" }],
|
|
106
|
+
isError: false,
|
|
107
|
+
timestamp: 210,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
role: "assistant",
|
|
111
|
+
content: [{ type: "text", text: "done" }],
|
|
112
|
+
timestamp: 300,
|
|
113
|
+
api: "anthropic",
|
|
114
|
+
provider: "anthropic",
|
|
115
|
+
model: "x",
|
|
116
|
+
usage: {},
|
|
117
|
+
stopReason: "end_turn",
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const { messages: out, pruned } = pruneMessages(messages, indexer, enabledCC);
|
|
122
|
+
expect(pruned).toBe(true);
|
|
123
|
+
|
|
124
|
+
// Middle assistant + toolResult are dropped
|
|
125
|
+
const roles = out.map((m: any) => m.role);
|
|
126
|
+
expect(roles.filter((r: string) => r === "toolResult")).toHaveLength(0);
|
|
127
|
+
|
|
128
|
+
// Synthetic chain message injected after the start user message
|
|
129
|
+
const synthetic = out.find(
|
|
130
|
+
(m: any) =>
|
|
131
|
+
m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
|
|
132
|
+
);
|
|
133
|
+
expect(synthetic).toBeDefined();
|
|
134
|
+
expect(synthetic.content[0].text).toContain('id="b1"');
|
|
135
|
+
expect(synthetic.content[0].text).toContain('tools="t1"');
|
|
136
|
+
expect(synthetic.content[0].text).toContain(summaryText);
|
|
137
|
+
|
|
138
|
+
// Start user message still present
|
|
139
|
+
const startUser = out.find((m: any) => m.role === "user" && m.timestamp === 100);
|
|
140
|
+
expect(startUser).toBeDefined();
|
|
141
|
+
|
|
142
|
+
// Final assistant kept (no thinking block to strip here)
|
|
143
|
+
const finalAsst = out.find((m: any) => m.role === "assistant" && m.timestamp === 300);
|
|
144
|
+
expect(finalAsst).toBeDefined();
|
|
145
|
+
|
|
146
|
+
// Ordering: start user → synthetic → final assistant
|
|
147
|
+
const startIdx = out.indexOf(startUser);
|
|
148
|
+
const synthIdx = out.indexOf(synthetic);
|
|
149
|
+
const finalIdx = out.indexOf(finalAsst);
|
|
150
|
+
expect(startIdx).toBeLessThan(synthIdx);
|
|
151
|
+
expect(synthIdx).toBeLessThan(finalIdx);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("prefers rangeSummaryText over per-batch concat in the synthetic body (B)", () => {
|
|
155
|
+
const toolCallId = "tc-range";
|
|
156
|
+
const chainEntry: ChainCompressionEntry = {
|
|
157
|
+
blockId: "b9",
|
|
158
|
+
startUserTimestamp: 100,
|
|
159
|
+
droppedToolCallIds: [toolCallId],
|
|
160
|
+
finalAssistantTimestamp: 300,
|
|
161
|
+
toolRefs: ["t9"],
|
|
162
|
+
compressedAt: 777,
|
|
163
|
+
rangeSummaryText: "FUSED cohesive summary",
|
|
164
|
+
};
|
|
165
|
+
const indexer = makeMockIndexer({
|
|
166
|
+
chainEntries: [chainEntry],
|
|
167
|
+
summaryBodyMap: new Map([[toolCallId, "per-batch concat body"]]),
|
|
168
|
+
});
|
|
169
|
+
const messages: any[] = [
|
|
170
|
+
{ role: "user", content: [{ type: "text", text: "do it" }], timestamp: 100 },
|
|
171
|
+
{ role: "assistant", content: [{ type: "toolCall", id: toolCallId, name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" },
|
|
172
|
+
{ role: "toolResult", toolCallId, toolName: "bash", content: [{ type: "text", text: "output" }], isError: false, timestamp: 210 },
|
|
173
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 300, usage: {}, stopReason: "end_turn" },
|
|
174
|
+
];
|
|
175
|
+
const { messages: out } = pruneMessages(messages, indexer, enabledCC);
|
|
176
|
+
const synthetic = out.find((m: any) => m.role === "user" && m.content?.[0]?.text?.startsWith("<compressed-chain"));
|
|
177
|
+
expect(synthetic.content[0].text).toContain("FUSED cohesive summary");
|
|
178
|
+
expect(synthetic.content[0].text).not.toContain("per-batch concat body");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("strips thinking blocks from final assistant when stripFinalAssistantThinking is true", () => {
|
|
182
|
+
const toolCallId = "tc-think";
|
|
183
|
+
const chainEntry: ChainCompressionEntry = {
|
|
184
|
+
blockId: "b2",
|
|
185
|
+
startUserTimestamp: 100,
|
|
186
|
+
droppedToolCallIds: [toolCallId],
|
|
187
|
+
finalAssistantTimestamp: 300,
|
|
188
|
+
toolRefs: ["t2"],
|
|
189
|
+
compressedAt: 888,
|
|
190
|
+
};
|
|
191
|
+
const indexer = makeMockIndexer({
|
|
192
|
+
summarized: new Set([toolCallId]),
|
|
193
|
+
shortRefs: new Map([[toolCallId, "t2"]]),
|
|
194
|
+
chainEntries: [chainEntry],
|
|
195
|
+
summaryBodyMap: new Map([[toolCallId, "summary"]]),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const messages: any[] = [
|
|
199
|
+
{ role: "user", content: [{ type: "text", text: "think" }], timestamp: 100 },
|
|
200
|
+
{
|
|
201
|
+
role: "assistant",
|
|
202
|
+
content: [{ type: "toolCall", id: toolCallId, name: "bash", arguments: {} }],
|
|
203
|
+
timestamp: 200,
|
|
204
|
+
api: "anthropic",
|
|
205
|
+
provider: "anthropic",
|
|
206
|
+
model: "x",
|
|
207
|
+
usage: {},
|
|
208
|
+
stopReason: "tool_use",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
role: "toolResult",
|
|
212
|
+
toolCallId,
|
|
213
|
+
toolName: "bash",
|
|
214
|
+
content: [{ type: "text", text: "out" }],
|
|
215
|
+
isError: false,
|
|
216
|
+
timestamp: 210,
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
role: "assistant",
|
|
220
|
+
content: [
|
|
221
|
+
{ type: "thinking", thinking: "deep thoughts", thinkingSignature: "sig123" },
|
|
222
|
+
{ type: "text", text: "answer" },
|
|
223
|
+
],
|
|
224
|
+
timestamp: 300,
|
|
225
|
+
api: "anthropic",
|
|
226
|
+
provider: "anthropic",
|
|
227
|
+
model: "x",
|
|
228
|
+
usage: {},
|
|
229
|
+
stopReason: "end_turn",
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
const { messages: out } = pruneMessages(messages, indexer, enabledCC);
|
|
234
|
+
const finalAsst = out.find((m: any) => m.role === "assistant" && m.timestamp === 300);
|
|
235
|
+
expect(finalAsst).toBeDefined();
|
|
236
|
+
const contentTypes = finalAsst.content.map((c: any) => c.type);
|
|
237
|
+
expect(contentTypes).not.toContain("thinking");
|
|
238
|
+
expect(contentTypes).toContain("text");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("purges errored toolCall args through errorPurge wiring", () => {
|
|
242
|
+
const indexer = makeMockIndexer();
|
|
243
|
+
const largeArgs = { content: "x".repeat(200) };
|
|
244
|
+
const messages: any[] = [
|
|
245
|
+
{
|
|
246
|
+
role: "assistant",
|
|
247
|
+
content: [{ type: "toolCall", id: "tc-err", name: "write", arguments: largeArgs }],
|
|
248
|
+
timestamp: 100,
|
|
249
|
+
api: "anthropic",
|
|
250
|
+
provider: "anthropic",
|
|
251
|
+
model: "x",
|
|
252
|
+
usage: {},
|
|
253
|
+
stopReason: "tool_use",
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
role: "toolResult",
|
|
257
|
+
toolCallId: "tc-err",
|
|
258
|
+
toolName: "write",
|
|
259
|
+
content: [{ type: "text", text: "Error: permission denied" }],
|
|
260
|
+
isError: true,
|
|
261
|
+
timestamp: 110,
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
role: "assistant",
|
|
265
|
+
content: [{ type: "toolCall", id: "tc2", name: "bash", arguments: { cmd: "ls" } }],
|
|
266
|
+
timestamp: 200,
|
|
267
|
+
api: "anthropic",
|
|
268
|
+
provider: "anthropic",
|
|
269
|
+
model: "x",
|
|
270
|
+
usage: {},
|
|
271
|
+
stopReason: "tool_use",
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
role: "toolResult",
|
|
275
|
+
toolCallId: "tc2",
|
|
276
|
+
toolName: "bash",
|
|
277
|
+
content: [{ type: "text", text: "ok" }],
|
|
278
|
+
isError: false,
|
|
279
|
+
timestamp: 210,
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
role: "assistant",
|
|
283
|
+
content: [{ type: "toolCall", id: "tc3", name: "bash", arguments: { cmd: "pwd" } }],
|
|
284
|
+
timestamp: 300,
|
|
285
|
+
api: "anthropic",
|
|
286
|
+
provider: "anthropic",
|
|
287
|
+
model: "x",
|
|
288
|
+
usage: {},
|
|
289
|
+
stopReason: "tool_use",
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
role: "toolResult",
|
|
293
|
+
toolCallId: "tc3",
|
|
294
|
+
toolName: "bash",
|
|
295
|
+
content: [{ type: "text", text: "ok" }],
|
|
296
|
+
isError: false,
|
|
297
|
+
timestamp: 310,
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
const { messages: out, pruned } = pruneMessages(
|
|
301
|
+
messages,
|
|
302
|
+
indexer,
|
|
303
|
+
{ enabled: false, rollingWindow: 3, stripFinalAssistantThinking: true, fuseRangeSummary: false },
|
|
304
|
+
{ enabled: true, cooldownTurns: 2, minArgChars: 100 },
|
|
305
|
+
);
|
|
306
|
+
expect(pruned).toBe(true);
|
|
307
|
+
const errAsst = out.find((m: any) => m.role === "assistant" && m.timestamp === 100) as any;
|
|
308
|
+
expect(errAsst).toBeDefined();
|
|
309
|
+
expect(errAsst.content[0].arguments._purged).toMatch(/^<purged-errored-args size=/);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("spill stub tolerates absent spillBytes/resultPreview", () => {
|
|
313
|
+
const indexer = makeMockIndexer({
|
|
314
|
+
summarized: new Set(["tc1"]),
|
|
315
|
+
records: new Map([["tc1", {
|
|
316
|
+
toolCallId: "tc1", toolName: "bash", args: {}, resultText: "",
|
|
317
|
+
spillPath: "/blobs/tc1.txt", isError: false, turnIndex: 0, timestamp: 1,
|
|
318
|
+
}]]),
|
|
319
|
+
});
|
|
320
|
+
const messages = [{ role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1 }];
|
|
321
|
+
const { messages: out } = pruneMessages(messages, indexer);
|
|
322
|
+
const text = out[0].content[0].text as string;
|
|
323
|
+
expect(text).toContain("/blobs/tc1.txt");
|
|
324
|
+
expect(text).toContain("?");
|
|
325
|
+
expect(text).not.toContain("Summarized in pruner summary");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("emits a mechanical spill stub for a spilled record", () => {
|
|
329
|
+
const indexer = makeMockIndexer({
|
|
330
|
+
summarized: new Set(["tc1"]),
|
|
331
|
+
records: new Map([["tc1", {
|
|
332
|
+
toolCallId: "tc1", toolName: "fetch", args: { url: "https://x" },
|
|
333
|
+
resultText: "", resultPreview: "PREVIEW-HEAD", spillPath: "/blobs/tc1.txt",
|
|
334
|
+
spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1,
|
|
335
|
+
}]]),
|
|
336
|
+
});
|
|
337
|
+
const messages = [{
|
|
338
|
+
role: "toolResult", toolCallId: "tc1", toolName: "fetch",
|
|
339
|
+
content: [{ type: "text", text: "huge" }], isError: false, timestamp: 1,
|
|
340
|
+
}];
|
|
341
|
+
const { messages: out, pruned } = pruneMessages(messages, indexer);
|
|
342
|
+
expect(pruned).toBe(true);
|
|
343
|
+
const text = out[0].content[0].text as string;
|
|
344
|
+
expect(text).toContain("/blobs/tc1.txt");
|
|
345
|
+
expect(text).toContain("PREVIEW-HEAD");
|
|
346
|
+
expect(text).toContain("1048576");
|
|
347
|
+
expect(text).not.toContain("Summarized in pruner summary");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("skips chain compression when disabled", () => {
|
|
351
|
+
const chainEntry: ChainCompressionEntry = {
|
|
352
|
+
blockId: "b1",
|
|
353
|
+
startUserTimestamp: 100,
|
|
354
|
+
droppedToolCallIds: ["tc-x"],
|
|
355
|
+
finalAssistantTimestamp: 200,
|
|
356
|
+
toolRefs: [],
|
|
357
|
+
compressedAt: 999,
|
|
358
|
+
};
|
|
359
|
+
const indexer = makeMockIndexer({ chainEntries: [chainEntry] });
|
|
360
|
+
const messages = [
|
|
361
|
+
{ role: "user", content: "hi", timestamp: 100 },
|
|
362
|
+
{
|
|
363
|
+
role: "toolResult",
|
|
364
|
+
toolCallId: "tc-x",
|
|
365
|
+
toolName: "bash",
|
|
366
|
+
content: [],
|
|
367
|
+
isError: false,
|
|
368
|
+
timestamp: 150,
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
const disabled: ChainCompressionConfig = { ...enabledCC, enabled: false };
|
|
372
|
+
const { pruned } = pruneMessages(messages, indexer, disabled);
|
|
373
|
+
// tc-x is not in summarized set, so stub-replace doesn't fire; chain disabled
|
|
374
|
+
expect(pruned).toBe(false);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("composes stub-replace (Phase 1) with thinking-strip (Phase 4)", () => {
|
|
378
|
+
const indexer = makeMockIndexer({ summarized: new Set(["c10"]), shortRefs: new Map([["c10", "t1"]]) });
|
|
379
|
+
const mkAsst = (ts: number) => ({
|
|
380
|
+
role: "assistant",
|
|
381
|
+
content: [
|
|
382
|
+
{ type: "thinking", thinking: "t", thinkingSignature: "s" },
|
|
383
|
+
{ type: "text", text: "x" },
|
|
384
|
+
{ type: "toolCall", id: `c${ts}`, name: "bash", arguments: {} },
|
|
385
|
+
],
|
|
386
|
+
timestamp: ts,
|
|
387
|
+
usage: {},
|
|
388
|
+
stopReason: "tool_use",
|
|
389
|
+
});
|
|
390
|
+
const messages: any[] = [{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 }];
|
|
391
|
+
for (let i = 0; i < 5; i++) {
|
|
392
|
+
const id = `c${10 + i}`;
|
|
393
|
+
messages.push(mkAsst(10 + i));
|
|
394
|
+
messages.push({ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: "o" }], isError: false, timestamp: 100 + i });
|
|
395
|
+
}
|
|
396
|
+
const { messages: out, pruned } = pruneMessages(messages, indexer, undefined, undefined, {
|
|
397
|
+
enabled: true,
|
|
398
|
+
keepLastTurns: 2,
|
|
399
|
+
});
|
|
400
|
+
expect(pruned).toBe(true);
|
|
401
|
+
|
|
402
|
+
// Phase 1: c10 toolResult stub-replaced
|
|
403
|
+
const tr = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "c10") as any;
|
|
404
|
+
expect(tr.content[0].text).toContain("`t1`");
|
|
405
|
+
|
|
406
|
+
// Phase 4: oldest 3 assistant turns stripped, last 2 keep thinking
|
|
407
|
+
const assistants = out.filter((m: any) => m.role === "assistant");
|
|
408
|
+
const hasThinking = (m: any) => m.content.some((c: any) => c.type === "thinking");
|
|
409
|
+
expect(assistants.slice(0, 3).every((a: any) => !hasThinking(a))).toBe(true);
|
|
410
|
+
expect(assistants.slice(-2).every((a: any) => hasThinking(a))).toBe(true);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("render-time protection re-check", () => {
|
|
415
|
+
const skillMsg = {
|
|
416
|
+
role: "toolResult",
|
|
417
|
+
toolCallId: "tc-skill",
|
|
418
|
+
toolName: "read",
|
|
419
|
+
content: [{ type: "text", text: "FULL SKILL BODY" }],
|
|
420
|
+
isError: false,
|
|
421
|
+
timestamp: 10,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const indexer = makeMockIndexer({
|
|
425
|
+
summarized: new Set(["tc-skill"]),
|
|
426
|
+
shortRefs: new Map([["tc-skill", "t1"]]),
|
|
427
|
+
records: new Map([["tc-skill", {
|
|
428
|
+
toolCallId: "tc-skill",
|
|
429
|
+
toolName: "read",
|
|
430
|
+
args: { path: "/h/skills/x/SKILL.md" },
|
|
431
|
+
resultText: "",
|
|
432
|
+
isError: false,
|
|
433
|
+
turnIndex: 0,
|
|
434
|
+
timestamp: 10,
|
|
435
|
+
}]]),
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("leaves a summarized record verbatim once its path matches protectedPaths", () => {
|
|
439
|
+
const { messages, pruned } = pruneMessages(
|
|
440
|
+
[skillMsg], indexer as any, undefined, undefined, undefined,
|
|
441
|
+
{ protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
|
|
442
|
+
);
|
|
443
|
+
expect(pruned).toBe(false);
|
|
444
|
+
expect(messages[0].content[0].text).toBe("FULL SKILL BODY");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("still stubs when no protection config is passed", () => {
|
|
448
|
+
const { messages, pruned } = pruneMessages([skillMsg], indexer as any);
|
|
449
|
+
expect(pruned).toBe(true);
|
|
450
|
+
expect(messages[0].content[0].text).toContain("context_tree_query");
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
describe("sizeMessages", () => {
|
|
455
|
+
it("counts hidden fields (thinking blocks), not just visible text", () => {
|
|
456
|
+
// Two messages with identical visible .text but different hidden content.
|
|
457
|
+
// sizeMessages must count the full serialized weight so all reclaim
|
|
458
|
+
// mechanisms (thinking-strip, error-purge, etc.) register correctly.
|
|
459
|
+
const withThinking = [{
|
|
460
|
+
role: "assistant",
|
|
461
|
+
content: [
|
|
462
|
+
{ type: "thinking", thinking: "x".repeat(1000) },
|
|
463
|
+
{ type: "text", text: "hello" },
|
|
464
|
+
],
|
|
465
|
+
}];
|
|
466
|
+
const withoutThinking = [{
|
|
467
|
+
role: "assistant",
|
|
468
|
+
content: [
|
|
469
|
+
{ type: "text", text: "hello" },
|
|
470
|
+
],
|
|
471
|
+
}];
|
|
472
|
+
expect(sizeMessages(withThinking)).toBeGreaterThan(sizeMessages(withoutThinking));
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("pruneMessages beforeChars/afterChars", () => {
|
|
477
|
+
it("no-op fast path: beforeChars === afterChars === sizeMessages(input) and pruned false", () => {
|
|
478
|
+
const indexer = makeMockIndexer();
|
|
479
|
+
const messages = [{ role: "user", content: "hello", timestamp: 1 }];
|
|
480
|
+
const result = pruneMessages(messages, indexer);
|
|
481
|
+
expect(result.pruned).toBe(false);
|
|
482
|
+
const expected = sizeMessages(messages);
|
|
483
|
+
expect(result.beforeChars).toBe(expected);
|
|
484
|
+
expect(result.afterChars).toBe(expected);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("pruning path: beforeChars > afterChars when stubs shrink content", () => {
|
|
488
|
+
const indexer = makeMockIndexer({
|
|
489
|
+
summarized: new Set(["tc1"]),
|
|
490
|
+
shortRefs: new Map([["tc1", "t1"]]),
|
|
491
|
+
});
|
|
492
|
+
const messages = [
|
|
493
|
+
{
|
|
494
|
+
role: "toolResult",
|
|
495
|
+
toolCallId: "tc1",
|
|
496
|
+
toolName: "bash",
|
|
497
|
+
content: [{ type: "text", text: "x".repeat(500) }],
|
|
498
|
+
isError: false,
|
|
499
|
+
timestamp: 1,
|
|
500
|
+
},
|
|
501
|
+
];
|
|
502
|
+
const result = pruneMessages(messages, indexer);
|
|
503
|
+
expect(result.pruned).toBe(true);
|
|
504
|
+
expect(result.beforeChars).toBe(sizeMessages(messages));
|
|
505
|
+
expect(result.afterChars).toBe(sizeMessages(result.messages));
|
|
506
|
+
expect(result.afterChars).toBeLessThan(result.beforeChars);
|
|
507
|
+
});
|
|
508
|
+
});
|
package/src/pruner.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { ToolCallIndexer } from "./indexer.js";
|
|
2
|
+
import type { ChainCompressionConfig, ErrorPurgeConfig, ThinkingStripConfig } from "./types.js";
|
|
3
|
+
import { isProtected, type ProtectionConfig } from "./protected.js";
|
|
4
|
+
import { applyChainCompressions } from "./chain-range-prune.js";
|
|
5
|
+
import { purgeErroredArgs } from "./error-purge.js";
|
|
6
|
+
import { stripOldThinking } from "./thinking-strip.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Estimate of a message array's context weight. Serializing the whole array
|
|
10
|
+
* (not just visible text) is deliberate: it counts tool-call argument bodies
|
|
11
|
+
* (error-purge), thinking blocks (thinking-strip), and tool-result arrays
|
|
12
|
+
* (stub-replace / chain-range) so all reclaim mechanisms register.
|
|
13
|
+
*/
|
|
14
|
+
export function sizeMessages(messages: any[]): number {
|
|
15
|
+
return JSON.stringify(messages).length;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Transforms the `context` event message array in two passes:
|
|
20
|
+
*
|
|
21
|
+
* Phase 1 — stub-replace: ToolResultMessages for summarized tool calls are
|
|
22
|
+
* replaced with short stubs pointing the model at `context_tree_query`.
|
|
23
|
+
*
|
|
24
|
+
* Why stubs instead of dropping the message entirely:
|
|
25
|
+
* - Dropping orphans the matching `toolCall` block inside the
|
|
26
|
+
* preceding AssistantMessage. pi-ai's `transformMessages` then
|
|
27
|
+
* injects a synthetic `{ role: "toolResult", isError: true,
|
|
28
|
+
* content: "No result provided" }` for every orphan, which the LLM
|
|
29
|
+
* reads as a real tool failure. Replacing the toolResult with a
|
|
30
|
+
* stub keeps role alternation intact and suppresses that injection.
|
|
31
|
+
* - The stub carries the short ref (`tN`) the model can pass to
|
|
32
|
+
* `context_tree_query` to recover the raw output, so the breadcrumb
|
|
33
|
+
* to recovery is present on the toolResult itself, not only in the
|
|
34
|
+
* separate summary message.
|
|
35
|
+
*
|
|
36
|
+
* Phase 2 — error purge: replaces failed toolCall arg bodies with stubs after a
|
|
37
|
+
* cooldown, reclaiming context from large `write`/`edit` arguments that will
|
|
38
|
+
* never succeed. The toolResult error message stays visible.
|
|
39
|
+
*
|
|
40
|
+
* Phase 3 — chain range prune: closed chains older than the rolling window
|
|
41
|
+
* are dropped (middle assistant + toolResult messages) and replaced with a
|
|
42
|
+
* synthetic user message wrapping the existing per-batch summary text.
|
|
43
|
+
* Only runs when `chainCompression.enabled` and chain entries exist.
|
|
44
|
+
*
|
|
45
|
+
* Phase 4 — thinking strip: keep `thinking` blocks only on the last
|
|
46
|
+
* `keepLastTurns` assistant turns; strip them from older assistant messages
|
|
47
|
+
* (preserving text + toolCall). Runs last so the window counts the assistant
|
|
48
|
+
* turns that actually survive to the LLM. Only runs when
|
|
49
|
+
* `thinkingStrip.enabled`.
|
|
50
|
+
*
|
|
51
|
+
* Return shape:
|
|
52
|
+
* - `pruned: true` — at least one change happened; the returned
|
|
53
|
+
* `messages` is a freshly allocated array.
|
|
54
|
+
* - `pruned: false` — nothing matched; the returned `messages` is the
|
|
55
|
+
* **original input array reference** so the caller can cheaply skip
|
|
56
|
+
* the reconstruction path.
|
|
57
|
+
*
|
|
58
|
+
* AssistantMessage tool-call blocks (which carry the IDs) are kept
|
|
59
|
+
* unchanged so the model can still reference them by id when calling
|
|
60
|
+
* `context_tree_query`.
|
|
61
|
+
*/
|
|
62
|
+
export function pruneMessages(
|
|
63
|
+
messages: any[],
|
|
64
|
+
indexer: ToolCallIndexer,
|
|
65
|
+
chainCompression?: ChainCompressionConfig,
|
|
66
|
+
errorPurge?: ErrorPurgeConfig,
|
|
67
|
+
thinkingStrip?: ThinkingStripConfig,
|
|
68
|
+
protection?: ProtectionConfig,
|
|
69
|
+
): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
|
|
70
|
+
const beforeChars = sizeMessages(messages);
|
|
71
|
+
// Phase 1: stub-replace summarized tool results
|
|
72
|
+
let pruned = false;
|
|
73
|
+
const next = messages.map((msg) => {
|
|
74
|
+
if (msg.role === "toolResult" && indexer.isSummarized(msg.toolCallId)) {
|
|
75
|
+
const record = indexer.getRecord(msg.toolCallId);
|
|
76
|
+
// Render-time re-check: a record summarized before protectedPaths
|
|
77
|
+
// covered it is repaired here — the raw toolResult still lives in the
|
|
78
|
+
// session JSONL, so skipping the stub restores it verbatim.
|
|
79
|
+
// Dedup aliases resolve to the original record, so an alias whose own
|
|
80
|
+
// path is protected but whose original isn't stays stubbed (edge case).
|
|
81
|
+
if (protection && record && isProtected(record.toolName, record.args, protection)) {
|
|
82
|
+
return msg;
|
|
83
|
+
}
|
|
84
|
+
pruned = true;
|
|
85
|
+
const ref = indexer.getShortRefForToolCallId(msg.toolCallId) ?? msg.toolCallId;
|
|
86
|
+
const text = record?.spillPath
|
|
87
|
+
? [
|
|
88
|
+
`[Oversized output spilled to file — ${record.spillBytes ?? "?"} bytes.]`,
|
|
89
|
+
`Tool: ${record.toolName}`,
|
|
90
|
+
`Preview (head):`,
|
|
91
|
+
record.resultPreview ?? "",
|
|
92
|
+
`Full output — read this file (offset/limit supported): ${record.spillPath}`,
|
|
93
|
+
`Or use context_tree_query with ref \`${ref}\`.`,
|
|
94
|
+
].join("\n")
|
|
95
|
+
: `[Summarized in pruner summary, ref \`${ref}\`. Use context_tree_query to retrieve full output.]`;
|
|
96
|
+
return {
|
|
97
|
+
role: "toolResult",
|
|
98
|
+
toolCallId: msg.toolCallId,
|
|
99
|
+
toolName: msg.toolName,
|
|
100
|
+
content: [{ type: "text", text }],
|
|
101
|
+
isError: false,
|
|
102
|
+
timestamp: msg.timestamp,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return msg;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
let current: any[] = pruned ? next : messages;
|
|
109
|
+
|
|
110
|
+
// Phase 2: error purge — replace failed toolCall arg bodies after cooldown
|
|
111
|
+
if (errorPurge?.enabled) {
|
|
112
|
+
const afterPurge = purgeErroredArgs(current, errorPurge);
|
|
113
|
+
if (afterPurge !== current) {
|
|
114
|
+
current = afterPurge;
|
|
115
|
+
pruned = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Phase 3: chain range prune — drop closed chains beyond the rolling window
|
|
120
|
+
if (chainCompression?.enabled) {
|
|
121
|
+
const chainEntries = indexer.getChainEntries();
|
|
122
|
+
if (chainEntries.length > 0) {
|
|
123
|
+
// Prefer the cohesive LLM range summary (B) when present; fall back to the
|
|
124
|
+
// per-batch concatenation for spans compressed before fusion / on failure.
|
|
125
|
+
const chainSummaryText = (entry: typeof chainEntries[number]): string =>
|
|
126
|
+
entry.rangeSummaryText ?? indexer.getPerBatchSummaryTextForToolCallIds(entry.droppedToolCallIds);
|
|
127
|
+
const blockSummaryLookup = (blockId: string): string | undefined => {
|
|
128
|
+
const entry = indexer.findChainEntryByBlockId(blockId);
|
|
129
|
+
if (!entry) return undefined;
|
|
130
|
+
return chainSummaryText(entry) || undefined;
|
|
131
|
+
};
|
|
132
|
+
const compressed = applyChainCompressions(
|
|
133
|
+
current,
|
|
134
|
+
chainEntries,
|
|
135
|
+
chainSummaryText,
|
|
136
|
+
chainCompression.stripFinalAssistantThinking,
|
|
137
|
+
blockSummaryLookup,
|
|
138
|
+
);
|
|
139
|
+
if (compressed !== current) {
|
|
140
|
+
current = compressed;
|
|
141
|
+
pruned = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Phase 4: thinking strip — keep thinking only on the last K assistant turns
|
|
147
|
+
if (thinkingStrip?.enabled) {
|
|
148
|
+
const afterStrip = stripOldThinking(current, thinkingStrip);
|
|
149
|
+
if (afterStrip !== current) {
|
|
150
|
+
current = afterStrip;
|
|
151
|
+
pruned = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { messages: current, pruned, beforeChars, afterChars: pruned ? sizeMessages(current) : beforeChars };
|
|
156
|
+
}
|