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.
Files changed (45) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +22 -0
  3. package/PRUNING.md +1028 -0
  4. package/README.md +243 -0
  5. package/index.ts +858 -0
  6. package/package.json +56 -0
  7. package/src/batch-capture.ts +226 -0
  8. package/src/block-refs.test.ts +42 -0
  9. package/src/block-refs.ts +16 -0
  10. package/src/budget.test.ts +66 -0
  11. package/src/budget.ts +39 -0
  12. package/src/chain-compressor.test.ts +283 -0
  13. package/src/chain-compressor.ts +132 -0
  14. package/src/chain-detector.test.ts +302 -0
  15. package/src/chain-detector.ts +128 -0
  16. package/src/chain-range-prune.test.ts +522 -0
  17. package/src/chain-range-prune.ts +128 -0
  18. package/src/commands.test.ts +67 -0
  19. package/src/commands.ts +1207 -0
  20. package/src/config.ts +126 -0
  21. package/src/content-hash.ts +35 -0
  22. package/src/error-purge.test.ts +186 -0
  23. package/src/error-purge.ts +71 -0
  24. package/src/frontier.ts +62 -0
  25. package/src/indexer.ts +393 -0
  26. package/src/nested-placeholders.test.ts +82 -0
  27. package/src/nested-placeholders.ts +20 -0
  28. package/src/oversized-spill.integration.test.ts +73 -0
  29. package/src/protected.test.ts +62 -0
  30. package/src/protected.ts +51 -0
  31. package/src/pruner.test.ts +508 -0
  32. package/src/pruner.ts +156 -0
  33. package/src/query-tool.ts +78 -0
  34. package/src/range-compression.integration.test.ts +252 -0
  35. package/src/spill.test.ts +102 -0
  36. package/src/spill.ts +90 -0
  37. package/src/stats.test.ts +114 -0
  38. package/src/stats.ts +190 -0
  39. package/src/summarizer.test.ts +17 -0
  40. package/src/summarizer.ts +262 -0
  41. package/src/summary-refs.ts +61 -0
  42. package/src/thinking-strip.test.ts +175 -0
  43. package/src/thinking-strip.ts +42 -0
  44. package/src/tree-browser.ts +382 -0
  45. 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
+ }