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
package/PRUNING.md ADDED
@@ -0,0 +1,1028 @@
1
+ # Understanding Context Pruning in Pi
2
+
3
+ > How `pi-condense` compresses tool-call history, why it matters for long-running sessions, and how it balances context size against provider-side prefix caching.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ 1. [What Does a Long Session Look Like?](#what-does-a-long-session-look-like)
10
+ 2. [What Pruning Does](#what-pruning-does)
11
+ 3. [Pruned Data Is Still Available](#pruned-data-is-still-available)
12
+ 4. [What Actually Lives in the Pruner Index](#what-actually-lives-in-the-pruner-index)
13
+ 5. [How the Model Re-reads Raw Outputs](#how-the-model-re-reads-raw-outputs)
14
+ 6. [How Prefix Caching Works](#how-prefix-caching-works)
15
+ 7. [Why Frequent Pruning Busts Cache](#why-frequent-pruning-busts-cache)
16
+ 8. [The Sweet Spot: Batch and Prune](#the-sweet-spot-batch-and-prune)
17
+ 9. [Pre-flush Pipeline & Safeguards](#pre-flush-pipeline--safeguards)
18
+ - [Stub-replace instead of delete](#stub-replace-instead-of-delete)
19
+ - [Protected tools](#protected-tools)
20
+ - [Eager single-result spill](#eager-single-result-spill)
21
+ - [Trivial-batch skip (minBatchChars)](#trivial-batch-skip-minbatchchars)
22
+ - [Content-hash dedup](#content-hash-dedup)
23
+ - [Oversized summary skip](#oversized-summary-skip)
24
+ - [Frontier persistence](#frontier-persistence)
25
+ - [Other UI / observability features](#other-ui--observability-features)
26
+ - [Token-budget auto-flush trigger](#token-budget-auto-flush-trigger)
27
+ - [Budget-delta flush](#budget-delta-flush)
28
+ 10. [Chain Compression](#chain-compression)
29
+ - [Protected-output relocation](#protected-output-relocation)
30
+ 11. [Error Purge](#error-purge)
31
+ 12. [Main-loop Thinking Strip](#main-loop-thinking-strip)
32
+ 13. [Why Summarization Works: Research Evidence](#why-summarization-works-research-evidence)
33
+ - [SUPO — Summarization augmented Policy Optimization](#supo--summarization-augmented-policy-optimization)
34
+ - [ReSum — Recursive Summarization for Long-Horizon Agents](#resum--recursive-summarization-for-long-horizon-agents)
35
+ - [ACON — Agent Context Optimization](#acon--agent-context-optimization)
36
+ 14. [Summary](#summary)
37
+
38
+ ---
39
+
40
+ ## What Does a Long Session Look Like?
41
+
42
+ In Pi, every assistant turn that calls tools produces a sequence of messages in the context tree. In a long coding or research session, this accumulates rapidly:
43
+
44
+ ### ASCII: A typical Pi context tree (before pruning)
45
+
46
+ ```
47
+ ┌─────────────────────────────────────────────────────────────────────────┐
48
+ │ SESSION CONTEXT (growing without bound) │
49
+ ├─────────────────────────────────────────────────────────────────────────┤
50
+ │ │
51
+ │ [system] You are Pi, a helpful coding assistant... │
52
+ │ │
53
+ │ [user] Build a React component that fetches data from │
54
+ │ an API and displays it in a table... │
55
+ │ │
56
+ │ ── Turn 1 ───────────────────────────────────────── │
57
+ │ [assistant] <tool_call name="read_file" id="tc-001"> │
58
+ │ {"path": "src/App.tsx"} │
59
+ │ [tool] export default function App() { ... } ← 45 tokens │
60
+ │ │
61
+ │ ── Turn 2 ───────────────────────────────────────── │
62
+ │ [assistant] <tool_call name="read_file" id="tc-002"> │
63
+ │ {"path": "package.json"} │
64
+ │ [tool] { "dependencies": { "react": "^18.2.0", ... } ← 120 tok │
65
+ │ │
66
+ │ ── Turn 3 ───────────────────────────────────────── │
67
+ │ [assistant] <tool_call name="web_search" id="tc-003"> │
68
+ │ <tool_call name="read_file" id="tc-004"> │
69
+ │ [tool-003] React Table v7 docs, TanStack Table API... ← 3,400 tok │
70
+ │ [tool-004] import { useState } from 'react'; ... ← 200 tokens │
71
+ │ │
72
+ │ ── Turn 4 ───────────────────────────────────────── │
73
+ │ [assistant] <tool_call name="edit_file" id="tc-005"> │
74
+ │ [tool] ✔︎ File updated successfully ← 15 tokens │
75
+ │ │
76
+ │ ── Turn 5 ───────────────────────────────────────── │
77
+ │ [assistant] <tool_call name="bash" id="tc-006"> │
78
+ │ <tool_call name="read_file" id="tc-007"> │
79
+ │ [tool-006] BUILD OUTPUT (npm run build): ← 2,800 tok │
80
+ │ [warn] Circular dependency detected... │
81
+ │ [warn] Chunk size exceeds 500kb... │
82
+ │ [error] TypeScript compilation failed... │
83
+ │ [tool-007] Updated file contents... ← 180 tokens │
84
+ │ │
85
+ │ ── Turn 6 ── ... (more turns, more tool calls) ── │
86
+ │ │
87
+ │ ═══════════════════════════════════════════════════════ │
88
+ │ Context size: ~15,000 tokens and growing... │
89
+ │ Most tokens are raw tool outputs the model already "consumed" │
90
+ │ ═══════════════════════════════════════════════════════ │
91
+ │ │
92
+ └─────────────────────────────────────────────────────────────────────────┘
93
+ ```
94
+
95
+ In a long session this can grow to **30k–100k+ tokens**. The model pays for every token on every subsequent request. More importantly, the "signal" (what the model actually needs to know) is buried in a mountain of "noise" (full build logs, search results, file contents it already processed).
96
+
97
+ ---
98
+
99
+ ## What Pruning Does
100
+
101
+ `pi-condense` intercepts completed tool-call batches, summarizes them, and replaces each raw `ToolResultMessage` in future context with a small breadcrumb stub that points at `context_tree_query` for recovery. The original full output is archived in the session index.
102
+
103
+ ### ASCII: The same session *after* pruning Turns 1–5
104
+
105
+ ```
106
+ ┌─────────────────────────────────────────────────────────────────────────┐
107
+ │ SESSION CONTEXT (after pruning Turns 1-5) │
108
+ ├─────────────────────────────────────────────────────────────────────────┤
109
+ │ │
110
+ │ [system] You are Pi, a helpful coding assistant... │
111
+ │ │
112
+ │ [user] Build a React component that fetches data from │
113
+ │ an API and displays it in a table... │
114
+ │ │
115
+ │ [summary] ╔════════════════════════════════════════════╗ │
116
+ │ ║ ⚃ [pruner] Turn 1–5 summary (7 tools) ║ │
117
+ │ ║ ║ │
118
+ │ ║ • Read existing App.tsx and package.json ║ │
119
+ │ ║ • Searched React Table docs; decided on ║ │
120
+ │ ║ @tanstack/react-table v8 ║ │
121
+ │ ║ • Created DataTable component with ║ │
122
+ │ ║ sorting, pagination, useQuery hook ║ │
123
+ │ ║ • Build failed: circular dependency in ║ │
124
+ │ ║ utils/index.ts → fix by inlining helpers ║ │
125
+ │ ║ ║ │
126
+ │ ║ Summarized tool refs: t1..t7 ║ │
127
+ │ ║ Use context_tree_query for raw outputs ║ │
128
+ │ ╚════════════════════════════════════════════╝ │
129
+ │ ← ~200 tokens (was ~6,760 tokens) │
130
+ │ │
131
+ │ ── Turn 6 ───────────────────────────────────────── │
132
+ │ [assistant] <tool_call name="read_file" id="tc-008"> │
133
+ │ <tool_call name="edit_file" id="tc-009"> │
134
+ │ [tool-008] import { helperA } from './helpers'; ... ← 90 tokens │
135
+ │ [tool-009] ✔︎ File updated successfully ← 15 tokens │
136
+ │ │
137
+ │ ═══════════════════════════════════════════════════════ │
138
+ │ Context size: ~500 tokens (plus current turn) │
139
+ │ ~96% reduction in "stale" context tokens │
140
+ │ ═══════════════════════════════════════════════════════ │
141
+ │ │
142
+ └─────────────────────────────────────────────────────────────────────────┘
143
+ ```
144
+
145
+ ### Mermaid: The pruning transformation
146
+
147
+ ```mermaid
148
+ %%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e1f5fe', 'primaryTextColor': '#01579b', 'primaryBorderColor': '#0288d1', 'lineColor': '#0288d1', 'secondaryColor': '#fff3e0', 'tertiaryColor': '#e8f5e9'}}}%%
149
+
150
+ graph TB
151
+ subgraph Before["Context BEFORE Pruning"]
152
+ direction TB
153
+ U1["👤 User Request"] --> A1["🤖 Assistant (tool calls)"]
154
+ A1 --> T1["🔧 Tool Result 1<br/>~3,400 tokens"]
155
+ A1 --> T2["🔧 Tool Result 2<br/>~200 tokens"]
156
+ T1 --> A2["🤖 Assistant (tool calls)"]
157
+ A2 --> T3["🔧 Tool Result 3<br/>~2,800 tokens"]
158
+ A2 --> T4["🔧 Tool Result 4<br/>~180 tokens"]
159
+ T3 --> A3["🤖 Assistant (text response)"]
160
+ end
161
+
162
+ subgraph After["Context AFTER Pruning"]
163
+ direction TB
164
+ U2["👤 User Request"] --> S["📋 Summary Message<br/>~200 tokens"]
165
+ S --> A4["🤖 Assistant (tool calls)"]
166
+ A4 --> T5["🔧 Tool Result 5<br/>~90 tokens"]
167
+ A4 --> T6["🔧 Tool Result 6<br/>~15 tokens"]
168
+ end
169
+
170
+ Before -->|"prune summarized<br/>tool results"| After
171
+
172
+ style T1 fill:#ffebee
173
+ style T3 fill:#ffebee
174
+ style T2 fill:#ffebee
175
+ style T4 fill:#ffebee
176
+ style T5 fill:#e8f5e9
177
+ style T6 fill:#e8f5e9
178
+ style S fill:#fff3e0
179
+ ```
180
+
181
+ **Key points:**
182
+
183
+ - The `AssistantMessage` tool-call blocks are **kept** (they carry the `toolCallId`s the model uses to reference originals via `context_tree_query`).
184
+ - `ToolResultMessage` entries for summarized tool calls are **replaced with a small stub** (`[Summarized in pruner summary, ref \`tN\`. Use context_tree_query to retrieve full output.]`) carrying `role: "toolResult"`, the original `toolCallId`/`toolName`/`timestamp`, and `isError: false`. The stub preserves role alternation, so pi-ai's `transformMessages.insertSyntheticToolResults` no longer injects a synthetic `{ isError: true, "No result provided" }` for the (no-longer-)orphaned tool call. See [Stub-replace instead of delete](#stub-replace-instead-of-delete).
185
+ - Every pruned tool call is also copied into the pruner's runtime/session index with its `toolCallId`, tool name, args, status, turn index, timestamp, and full `resultText`.
186
+ - A summary message is injected as a `"steer"` (`pi.sendMessage` runtime path) or appended directly via `sessionManager.appendCustomMessageEntry` (session path, used when Pi may already be shutting down). Both deliver before the next LLM call.
187
+ - The session JSONL file retains the original tool-result entries unchanged — pruning only affects what the *next* request sees in active context.
188
+
189
+ ---
190
+
191
+ ## Pruned Data Is Still Available
192
+
193
+ Pruning does **not** delete data. It moves raw tool results out of the hot path (active LLM context) and into an indexed archive the model can query later.
194
+
195
+ There are two separate things happening during pruning:
196
+
197
+ 1. **Context filtering:** future requests stop including the old `toolResult` messages.
198
+ 2. **Index preservation:** the extension stores each summarized tool call in the pruner index, keyed by `toolCallId`.
199
+
200
+ That distinction is the core idea:
201
+
202
+ - **Pruned from context** does **not** mean **lost**
203
+ - It means **hidden from the default prompt**, but still **recoverable on demand**
204
+
205
+ ### ASCII: How `context_tree_query` recovers pruned data
206
+
207
+ ```
208
+ ┌─────────────────────────────────────────────────────────────────────────┐
209
+ │ RECOVERING PRUNED DATA via context_tree_query │
210
+ ├─────────────────────────────────────────────────────────────────────────┤
211
+ │ │
212
+ │ [summary] ... build failed: circular dependency ... │
213
+ │ Summarized tool refs: `t1` │
214
+ │ Use `context_tree_query` with these refs │
215
+ │ │
216
+ │ ── LLM calls context_tree_query({ toolCallIds: ["t1"] }) ── │
217
+ │ │
218
+ │ [tool] ⌕ context_tree_query result │
219
+ │ ┌─────────────────────────────────────────────────────┐ │
220
+ │ │ Tool: bash (t1) │ │
221
+ │ │ Status: OK │ │
222
+ │ │ ───────────────────────────────────────────────── │ │
223
+ │ │ $ npm run build │ │
224
+ │ │ > react-app@0.1.0 build │ │
225
+ │ │ > tsc && vite build │ │
226
+ │ │ │ │
227
+ │ │ [warn] Circular dependency: src/utils/index.ts -> │ │
228
+ │ │ src/utils/helpers.ts -> src/utils/index.ts │ │
229
+ │ │ [warn] (!) Some chunks are larger than 500 kBs │ │
230
+ │ │ [error] TS2345: Argument of type 'X' not assignable│ │
231
+ │ │ to parameter of type 'Y'... │ │
232
+ │ │ │ │
233
+ │ │ [Output truncated: 200/512 lines shown] │ │
234
+ │ └─────────────────────────────────────────────────────┘ │
235
+ │ │
236
+ │ The LLM now has the full build log back in context, on demand, │
237
+ │ without permanently inflating the context window. │
238
+ │ │
239
+ └─────────────────────────────────────────────────────────────────────────┘
240
+ ```
241
+
242
+ ## What Actually Lives in the Pruner Index
243
+
244
+ When a batch is summarized, the extension writes a record for each tool call into `ToolCallIndexer` and persists that record into the session as a custom index entry.
245
+
246
+ Conceptually, each indexed record looks like this (see `ToolCallRecord` in `src/types.ts`):
247
+
248
+ ```ts
249
+ {
250
+ toolCallId: "tc-006",
251
+ toolName: "bash",
252
+ args: { command: "npm run build" },
253
+ resultText: "full original raw output...",
254
+ isError: false,
255
+ turnIndex: 5,
256
+ timestamp: 1745251200000 // epoch ms
257
+ }
258
+ ```
259
+
260
+ This matters because the summary is **not** the only surviving representation of the old tool call.
261
+ The model still has access to:
262
+
263
+ - the original `toolCallId`
264
+ - the tool name and arguments
265
+ - whether the tool errored
266
+ - which turn it came from
267
+ - the full original raw result text
268
+
269
+ So after pruning, the model is working with a **two-layer memory**:
270
+
271
+ 1. **Hot memory:** compact summary text kept directly in context
272
+ 2. **Cold memory:** full raw tool outputs stored in the pruner index and retrievable by ID
273
+
274
+ ### What is removed vs what is preserved
275
+
276
+ | Part of old turn | After pruning | Why |
277
+ |---|---|---|
278
+ | Assistant tool-call block | **Kept in context** | Preserves the `toolCallId` anchors the model uses to reference originals |
279
+ | Tool result message | **Replaced by a short stub in active context** | Saves tokens (typical 50–100× reduction per call) while keeping the `toolCallId` anchor and giving the model an explicit `context_tree_query` breadcrumb. The stub keeps `role: "toolResult"` and `isError: false` so role alternation stays intact |
280
+ | Summary message | **Added to context** | Gives the model a compact description of what happened |
281
+ | Indexed tool-call record | **Stored in pruner index** (`context-prune-index` session entry) | Lets the model re-open the original raw output later via `context_tree_query` |
282
+ | Duplicate of an already-indexed record (same toolName + content) | **Aliased to the original; no new summary, no LLM call** (`context-prune-dedup-alias` session entry) | See [Content-hash dedup](#content-hash-dedup) |
283
+
284
+ ## How the Model Re-reads Raw Outputs
285
+
286
+ The intended recovery flow is:
287
+
288
+ 1. The model reads a summary message.
289
+ 2. The summary lists the short refs (`t1`, `t2`, …) that were summarized.
290
+ 3. The model decides the summary is not enough and wants exact raw output.
291
+ 4. The model calls `context_tree_query({ toolCallIds: ["t1", ...] })`. The tool accepts short refs and full `toolCallId`s interchangeably (`indexer.resolveToolCallId`).
292
+ 5. The tool looks up those IDs in the pruner index.
293
+ 6. The tool returns the original stored output back into the current turn.
294
+ 7. The model can now inspect that raw result and continue reasoning.
295
+
296
+ ### ASCII: end-to-end "prune, then re-read" flow
297
+
298
+ ```text
299
+ assistant turn with tools
300
+
301
+
302
+ raw tool results exist in context
303
+
304
+
305
+ batch gets summarized
306
+
307
+ ├─► summary message added to context
308
+ │ └─► includes short refs (`t1`, `t2`, …)
309
+
310
+ ├─► tool results indexed by toolCallId
311
+ │ └─► full raw resultText stored in index/session
312
+
313
+ └─► old toolResult messages removed from future context
314
+
315
+ later...
316
+
317
+
318
+ model sees summary and decides: "I need the exact old output"
319
+
320
+
321
+ context_tree_query({ toolCallIds: ["t1"] })
322
+
323
+
324
+ query tool resolves short ref / id and loads the indexed record
325
+
326
+
327
+ original raw output is returned into the current turn
328
+
329
+
330
+ model continues with exact old context back in view
331
+ ```
332
+
333
+ ### Why this is important
334
+
335
+ - summaries keep the default context small
336
+ - short refs / `toolCallId`s keep old work addressable
337
+ - `context_tree_query` makes the archive readable again
338
+ - the model can "page in" exact old context only when it actually needs it
339
+
340
+ Summaries are the default view; raw data remains addressable through `context_tree_query`.
341
+
342
+ ---
343
+
344
+ ## How Prefix Caching Works
345
+
346
+ Modern LLM API providers (Anthropic, OpenAI, vLLM, etc.) implement **prefix caching** (also called "prompt caching") to speed up repeated requests with similar prompts.
347
+
348
+ ### How it works
349
+
350
+ LLM inference has two phases:
351
+
352
+ 1. **Prefill** — compute Key-Value (KV) attention states for all input tokens
353
+ 2. **Decode** — generate output tokens autoregressively, reusing the cached KV states
354
+
355
+ Prefix caching stores the KV states for an exact token sequence on the provider's GPU. When a new request shares an identical prefix, the provider **skips prefill** for that prefix and starts from the cached KV state.
356
+
357
+ ### ASCII: Cache hit vs cache miss
358
+
359
+ ```
360
+ ┌─────────────────────────────────────────────────────────────────────────┐
361
+ │ WITHOUT PREFIX CACHING │
362
+ ├─────────────────────────────────────────────────────────────────────────┤
363
+ │ │
364
+ │ Request 1: [System] [User Q1] ──► LLM computes KV for ALL tokens │
365
+ │ │ │
366
+ │ ▼ │
367
+ │ Generate answer │
368
+ │ │
369
+ │ Request 2: [System] [User Q2] ──► LLM computes KV for ALL tokens │
370
+ │ ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲ │ (EVERYTHING recomputed) │
371
+ │ Same prefix as Request 1 ▼ │
372
+ │ Generate answer │
373
+ │ │
374
+ │ Time: ████████████████████████████████████████ ~2.5s each │
375
+ │ Cost: Full input tokens priced at standard rate │
376
+ │ │
377
+ └─────────────────────────────────────────────────────────────────────────┘
378
+
379
+ ┌─────────────────────────────────────────────────────────────────────────┐
380
+ │ WITH PREFIX CACHING (HIT) │
381
+ ├─────────────────────────────────────────────────────────────────────────┤
382
+ │ │
383
+ │ Request 1: [System] [User Q1] ──► LLM computes KV for ALL tokens │
384
+ │ │ │
385
+ │ ┌───────────────────┘ │
386
+ │ ▼ │
387
+ │ [STORED IN CACHE] │
388
+ │ │
389
+ │ Request 2: [System] [User Q2] ──► SKIP! KV loaded from cache │
390
+ │ ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲ │ (prefix match = instant) │
391
+ │ Cache hit! ▼ │
392
+ │ Only compute NEW tokens │
393
+ │ Generate answer │
394
+ │ │
395
+ │ Time: ████████████████████░░░░░░░░░░░░░░░░░░░ ~0.5s (80% faster) │
396
+ │ Cost: Cached prefix at 50-90% discount (provider-dependent) │
397
+ │ │
398
+ └─────────────────────────────────────────────────────────────────────────┘
399
+ ```
400
+
401
+ ### Provider specifics
402
+
403
+ | Provider | Cache Activation | Match Type | Duration |
404
+ |---|---|---|---|
405
+ | **Anthropic** | Manual — `cache_control: {type: "ephemeral"}` on message blocks | Exact prefix from breakpoints | Tied to usage pattern |
406
+ | **OpenAI** | Automatic for prompts ≥1,024 tokens | Exact prefix match; ~first 256 tokens hashed for routing | 5–10 min inactivity (up to 1 hr) |
407
+ | **vLLM / Self-hosted** | Automatic via hash-based block matching | Exact block match | Instance lifetime |
408
+
409
+ ### Critical rule
410
+
411
+ > **Cache matching requires *exact* token sequences.** Any change — reordering messages, editing text, adding/removing tool results, even whitespace — alters the prefix hash and triggers a **cache miss**.
412
+
413
+ ---
414
+
415
+ ## Why Frequent Pruning Busts Cache
416
+
417
+ Every time you prune, you **rewrite the prefix**. The message that was previously a 3,400-token tool result is now a 200-token summary. That's a different token sequence, so the cache is invalidated.
418
+
419
+ ### ASCII: The per-turn pruning trap (naive per-turn pruning)
420
+
421
+ ```
422
+ ┌─────────────────────────────────────────────────────────────────────────┐
423
+ │ NAIVE PER-TURN PRUNING — AGGRESSIVE BUT CACHE-UNFRIENDLY │
424
+ ├─────────────────────────────────────────────────────────────────────────┤
425
+ │ │
426
+ │ Turn 1: Read file → 45 tokens │
427
+ │ └──► summarize → inject summary │
428
+ │ │ │
429
+ │ └──► CACHE BUST: context changed from [toolResult] to [sum] │
430
+ │ │
431
+ │ Turn 2: Read package.json → 120 tokens │
432
+ │ └──► summarize → inject summary │
433
+ │ │ │
434
+ │ └──► CACHE BUST again: prefix rewritten AGAIN │
435
+ │ │
436
+ │ Turn 3: Web search + read file → 3,600 tokens │
437
+ │ └──► summarize → inject summary │
438
+ │ │ │
439
+ │ └──► CACHE BUST again: prefix rewritten AGAIN │
440
+ │ │
441
+ │ After 5 turns: │
442
+ │ • 5 summarizer LLM calls (latency + cost) │
443
+ │ • 5 cache busts (FULL prefill every time) │
444
+ │ • No prefix ever stayed stable long enough to benefit from caching │
445
+ │ │
446
+ │ Time: ████████████████░░░░░░ ~80% spent on re-computing prefixes │
447
+ │ │
448
+ └─────────────────────────────────────────────────────────────────────────┘
449
+ ```
450
+
451
+ ---
452
+
453
+ ## The Sweet Spot: Batch and Prune
454
+
455
+ The insight is simple: **batch many tool turns, then prune once**. Everything before the prune point stays in the prefix cache and remains cacheable. Only the new suffix (since the last prune) needs fresh computation.
456
+
457
+ ### ASCII: Batch pruning (`agent-message` mode)
458
+
459
+ ```
460
+ ┌─────────────────────────────────────────────────────────────────────────┐
461
+ │ agent-message MODE — BATCH THEN PRUNE (RECOMMENDED) │
462
+ ├─────────────────────────────────────────────────────────────────────────┤
463
+ │ │
464
+ │ ┌─────────────────────────────────────────────────────────────────┐ │
465
+ │ │ BATCH PHASE: Tool turns accumulate (not pruned yet) │ │
466
+ │ │ │ │
467
+ │ │ Turn 1: Read file → 45 tokens ═══════╗ │ │
468
+ │ │ Turn 2: Read package → 120 tokens ════╬══════╗ │ │
469
+ │ │ Turn 3: Web search + read → 3,600 tok ═════╬═╬═════╗ │ │
470
+ │ │ Turn 4: Edit file → 15 tokens ═══════╬═╬═╬═╬════╬═════╗ │ │
471
+ │ │ Turn 5: Build + read → 2,980 tokens ═╬═╬═╬═╬════╬═════╬═╗ │ │
472
+ │ │ Turn 6: Read + edit → 105 tokens ═════╬═╬═╬═╬════╬═════╬═╬ │ │
473
+ │ │ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ │ │
474
+ │ │ All these tool results stay in context UNCHANGED │ │
475
+ │ │ → Prefix cache is STABLE → cache HITS on every turn │ │
476
+ │ └─────────────────────────────────────────────────────────────────┘ │
477
+ │ │ │
478
+ │ │ Agent sends final text reply │
479
+ │ ▼ │
480
+ │ ┌─────────────────────────────────────────────────────────────────┐ │
481
+ │ │ PRUNE PHASE: Single summary replaces batch │ │
482
+ │ │ │ │
483
+ │ │ [summary] "Built React table component. Key decisions:..." │ │
484
+ │ │ │ │
485
+ │ │ ONE cache bust, then context is STABLE again │ │
486
+ │ └─────────────────────────────────────────────────────────────────┘ │
487
+ │ │ │
488
+ │ ┌─────────────────────────────────────────────────────────────────┐ │
489
+ │ │ STABLE PHASE: New requests reuse cached prefix │ │
490
+ │ │ │ │
491
+ │ │ User: "Now add sorting" │ │
492
+ │ │ → Cache HIT on [system] + [user] + [summary] prefix │ │
493
+ │ │ → Only "Now add sorting" needs prefill │ │
494
+ │ │ → Fast + cheap │ │
495
+ │ └─────────────────────────────────────────────────────────────────┘ │
496
+ │ │
497
+ │ Result: 1 cache bust per meaningful work unit, not per turn │
498
+ │ │
499
+ └─────────────────────────────────────────────────────────────────────────┘
500
+ ```
501
+
502
+ ### Mermaid: Cache-friendly pruning lifecycle
503
+
504
+ ```mermaid
505
+ %%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e8f5e9', 'primaryTextColor': '#1b5e20', 'primaryBorderColor': '#4caf50', 'lineColor': '#4caf50', 'secondaryColor': '#ffebee', 'tertiaryColor': '#fff3e0'}}}%%
506
+
507
+ graph LR
508
+ subgraph Phase1["🟢 Stable Prefix<br/>(Cacheable)"]
509
+ direction TB
510
+ S1["System Prompt"] --> U1["User Request"]
511
+ U1 --> P1["Previous Summary"]
512
+ P1 --> C1["Current Turn"]
513
+ end
514
+
515
+ subgraph Phase2["🟡 Batch & Grow<br/>(Still Stable)"]
516
+ direction TB
517
+ B1["Turn N: tool calls"]
518
+ B2["Turn N+1: tool calls"]
519
+ B3["Turn N+2: tool calls"]
520
+ B1 --> B2 --> B3
521
+ end
522
+
523
+ subgraph Phase3["🔴 Prune Once<br/>(Cache Bust)"]
524
+ P["Single Summarize + Prune"]
525
+ end
526
+
527
+ subgraph Phase4["🟢 Stable Prefix Again<br/>(Cacheable)"]
528
+ direction TB
529
+ S2["System Prompt"] --> U2["User Request"]
530
+ U2 --> P2["New Summary"]
531
+ P2 --> C2["Current Turn"]
532
+ end
533
+
534
+ Phase1 -->|"agent starts<br/>tool work"| Phase2
535
+ Phase2 -->|"final text reply<br/>or loop ends"| Phase3
536
+ Phase3 -->|"context now<br/>compact & stable"| Phase4
537
+ Phase4 -->|"next task batch"| Phase2
538
+
539
+ style Phase1 fill:#e8f5e9
540
+ style Phase2 fill:#fff3e0
541
+ style Phase3 fill:#ffebee
542
+ style Phase4 fill:#e8f5e9
543
+ ```
544
+
545
+ ### Cache impact trade-off
546
+
547
+ | Mode | Cache Busts per 5 Turns | Context Reclaimed | Recommended for |
548
+ |---|---|---|---|
549
+ | `agent-message` | 1 | After batch | **Default** — best balance |
550
+ | `on-demand` | 0–1 | When you say so | Maximum cache preservation |
551
+
552
+ > **Everything before the previous pruning point stays in the prefix cache.** The cached prefix is the stable foundation; only the new suffix (recent turns since last prune) changes per request.
553
+
554
+ ---
555
+
556
+ ## Pre-flush Pipeline & Safeguards
557
+
558
+ `flushPending` runs a deterministic pipeline BEFORE any summarizer LLM call. Each step is configurable and each one can drop a batch entirely, in which case the prune frontier still advances so the same tool calls are not reconsidered next flush.
559
+
560
+ ```
561
+ captured batches (from turn_end or session scan)
562
+
563
+ ├─ 1. Protected-tools/paths filter (capture-time, see below)
564
+ │ tool calls whose toolName is in protectedTools, OR whose args.path
565
+ │ matches any protectedPaths glob, never enter the batch
566
+
567
+ ├─ 2. Eager single-result spill (config: spillThreshold, default 65536)
568
+ │ turn_end: single result >= spillThreshold chars → write sidecar;
569
+ │ index immediately via addBatch (no LLM); pruner emits file-pointer stub;
570
+ │ dedup precedence: protected → dedup → spill (duplicate → alias, no second file)
571
+
572
+ ├─ 3. Frontier trim (drop tool calls already past the frontier)
573
+
574
+ ├─ 4. Content-hash dedup (config: dedupByContentHash, default ON)
575
+ │ identical (toolName, normalize(resultText)) → alias of original;
576
+ │ no LLM call; persist as context-prune-dedup-alias
577
+
578
+ ├─ 5. Trivial-batch skip (config: minBatchChars, default 1000)
579
+ │ batches whose remaining raw chars < threshold → skip; no LLM call;
580
+ │ leave originals in context; advance frontier
581
+
582
+ ├─ 6. Summarizer LLM call (parallel: one call per batch)
583
+ │ resolveModel + summarizeBatch / summarizeBatches
584
+
585
+ └─ 7. Oversized post-check (summary >= raw? → skip; advance frontier)
586
+ ```
587
+
588
+ The outcome label written into `context-prune-frontier` is one of `summarized`, `skipped-deduped`, `skipped-trivial`, or `skipped-oversized` so the audit trail captures *why* a range was passed over.
589
+
590
+ ### Stub-replace instead of delete
591
+
592
+ Before v0.11.0 the pruner deleted summarized `ToolResultMessage` entries outright. This worked, but it left the matching `AssistantMessage.toolCall` blocks orphaned, and pi-ai's `transformMessages.insertSyntheticToolResults` would inject `{ isError: true, content: "No result provided" }` for every orphan at LLM call time. The model then saw a parade of fake "tool errors" before the unrelated summary user-message appeared later, which mildly confused larger reasoning models.
593
+
594
+ The pruner now keeps the toolResult message but replaces its content with a small stub:
595
+
596
+ ```
597
+ [Summarized in pruner summary, ref `tN`. Use context_tree_query to retrieve full output.]
598
+ ```
599
+
600
+ Properties:
601
+ - `role: "toolResult"` and the original `toolCallId` / `toolName` / `timestamp` are preserved — role alternation is intact; no synthetic-result injection.
602
+ - `isError: false`, so the model does not interpret the stub as a tool failure.
603
+ - The stub references the **short ref** (`t1`, `t2`, …) the indexer assigned at summary time. Legacy entries from before short-refs landed fall back to the raw `toolCallId`.
604
+ - Deterministic per `toolCallId` — the stub text never changes across calls, so the prefix cache continues to hit on the pruned range.
605
+
606
+ Implementation: `src/pruner.ts` `pruneMessages(messages, indexer)` returns `{ messages, pruned }`. When `pruned === false`, the original array reference is returned and the `context` handler skips reconstruction entirely.
607
+
608
+ ### Protected tools & paths
609
+
610
+ A tool call is protected if **either** its `toolName` is in `protectedTools` **or** its `args.path` (string) matches any glob in `protectedPaths`. Protected calls are filtered out **at capture time** - they never enter the `pendingBatches` queue, so their raw `ToolResultMessage` stays verbatim in future LLM context.
611
+
612
+ **`protectedTools: string[]`** (default `[]`) - allowlist of tool names. Covers tools whose output is a small handle that must be reused byte-for-byte (e.g. a session-id) or planning tools like `todowrite` / `todoread`.
613
+
614
+ **`protectedPaths: string[]`** (default `["**/skills/**/*.md"]`) - glob list matched against `args.path`. Designed for skill files that carry multi-step workflow gates; summarizing them is categorically lossy. Non-string or missing `path` arguments never match. Set `[]` to disable. Edit with `/pruner protected-paths`.
615
+
616
+ Glob contract: full-path match against the raw `args.path` string with `\` normalized to `/`. `*` and `?` match within a segment (no `/`); `**` crosses segments; `**/` also matches zero directories (so `**/SKILL.md` matches a bare relative `SKILL.md`). Case-sensitive. All other characters are regex-escaped literals.
617
+
618
+ **Render-time re-check:** stub replacement runs in-flight on every turn (`pruneMessages`). If a tool call's persisted `args` now satisfy `isProtected` (e.g. a pattern was added mid-session), the stub is skipped and the raw result is left verbatim - this repairs already-summarized records in existing sessions with no schema change. Declared limitation: records inside already-compressed chains (`context-prune-chain` entries) are NOT repaired - their `protectedToolCallIds` set is fixed at compression time (forward-only). Dedup-alias edge: an alias resolving to an unprotected original stays stubbed.
619
+
620
+ Names and patterns that don't match any captured tool call are silently ignored.
621
+
622
+ ### Eager single-result spill
623
+
624
+ `spillThreshold: number` (default `65536`) is a capture-time safeguard for outsized single tool results (e.g. a 1 MB web fetch, a full binary diff). When a single `ToolResultMessage`'s `resultText.length` reaches the threshold, the result is spilled immediately at `turn_end` — before the pending-queue trim and before any LLM call.
625
+
626
+ **Sidecar location:** `<sessionDir>/<sessionId>-blobs/<sanitizedToolCallId>.txt`.
627
+
628
+ **Index entry:** `addBatch` is called synchronously with the spilled body (no LLM round-trip). The record is immediately `isSummarized = true`; the pruner emits a mechanical file-pointer stub:
629
+
630
+ ```
631
+ [Spilled: <toolName> (<N> bytes). Head preview:
632
+ <first spillPreviewBytes bytes>
633
+ Full output: <sidecar path>. Use context_tree_query(<shortRef>) to retrieve.]
634
+ ```
635
+
636
+ **Dedup precedence:** `protected → dedup → spill`. An oversized result that is also a content-hash duplicate of a prior record is aliased to the original; no second sidecar is written.
637
+
638
+ **Atomicity:** the sidecar is written first; only on success is the in-memory record mutated. A write failure leaves the result inline for the normal flush and is logged via `console.error` — no data is lost.
639
+
640
+ **Hybrid storage:** bodies below `spillThreshold` stay inline in the `context-prune-index` session entry (portable, as before); only oversized bodies are spilled. Moving the session `.jsonl` without its `-blobs/` directory loses only the giant-blob recovery path; the stub and head preview remain in the index entry.
641
+
642
+ ### Trivial-batch skip (minBatchChars)
643
+
644
+ `minBatchChars: number` (default `1000`) is a pre-flush guard against "summary would be roughly the same size as the input" cases. If the total raw `resultText` across a batch is below the threshold, the batch is skipped: no summarizer LLM call, no `context-prune-index` entry, no `context-prune-summary` injection. The frontier still advances, so the same tool calls are not reconsidered next flush.
645
+
646
+ Why it exists: a short LLM summary like "Tool X did Y" is itself ~50–150 chars per call. For a 200-byte file read or an `ls` of a short directory, the summary is the same size or larger than the input — the post-call `skipped-oversized` mechanism would catch it anyway, but only after the LLM round-trip and the cost. `minBatchChars` short-circuits the obvious cases at zero LLM cost.
647
+
648
+ Set `minBatchChars: 0` to disable. The default `1000` skips obvious trivial batches (`git status`, small file reads, short directory listings) without affecting realistic tool outputs. Edit with `/pruner min-batch-chars <n>` or via the settings overlay.
649
+
650
+ ### Content-hash dedup
651
+
652
+ `dedupByContentHash: boolean` (default `true`) catches re-reads of already-pruned tool outputs at zero LLM cost.
653
+
654
+ Mechanism:
655
+ 1. When a batch enters `flushPending`, each tool call is hashed by `SHA-1(toolName + "\0" + normalize(resultText))`.
656
+ 2. The indexer's `contentHashToOriginal` map (populated by every earlier `addBatch` / `reconstructFromSession`) is consulted.
657
+ 3. A hit means an earlier prune already covered identical content. The duplicate is registered as an alias of the original via `indexer.registerDuplicate(newId, originalId, appendEntry)`:
658
+ - `dedupAliasToOriginal[newId] = originalId` (so `isSummarized(newId) === true` and `resolveToolCallId(newId) === originalId`).
659
+ - `toolCallIdToAlias[newId] = toolCallIdToAlias[originalId]` (so `getShortRefForToolCallId(newId)` returns the **same** `tN` as the original).
660
+ - A `context-prune-dedup-alias` custom entry is persisted so `reconstructFromSession` rebuilds the maps after a restart.
661
+ 4. The duplicate is removed from the batch — no summarizer call, no new index entry.
662
+ 5. Later, `pruneMessages` stub-replaces the duplicate's `ToolResultMessage` using the original's short ref, and `context_tree_query` returns the original's record whether the model passes the duplicate's id or the original's.
663
+
664
+ Normalization is conservative: `\r\n` → `\n`, per-line trailing whitespace stripping, final `trim()`. Internal whitespace, tabs, and capitalization are preserved so two genuinely different outputs do **not** collide.
665
+
666
+ Typical wins: re-reading an unchanged file, repeated `git status` / `ls`, retries of the same command. v1 only matches against records **already in the indexer** (cross-flush dedup); intra-flush dedup is deferred so canonicals that get skipped as oversized / trivial never produce dangling aliases.
667
+
668
+ Edit with `/pruner dedup on|off|status` or the settings overlay.
669
+
670
+ ### Oversized summary skip
671
+
672
+ Last-resort safeguard: if the summarizer LLM produces a summary longer than the raw tool-result text it would replace, the batch is left untouched — the original tool results stay in context, no summary is injected, and the frontier still advances so the next prune attempt starts after this range instead of retrying it. The `quietOversizedSkips` config silences the info notification (the skip itself still happens).
673
+
674
+ This is rare in practice once `minBatchChars` is on, because the cases where summarization makes things bigger are exactly the cases the trivial-batch skip already catches earlier.
675
+
676
+ ### Frontier persistence
677
+
678
+ The last attempted prune boundary is persisted as `context-prune-frontier` so `flushPending` knows where the previous attempt left off, even if that attempt was a skip rather than a real summary. Without this, a batch that's been skipped as oversized would be re-attempted (with the same LLM call, the same oversize result, the same skip) on every subsequent flush.
679
+
680
+ ### Other UI / observability features
681
+
682
+ - **Tree browser (`/pruner tree`):** interactive, foldable tree of pruned tool calls grouped under their summaries. `Ctrl-O` on a summary node opens the full markdown summary in a bordered overlay.
683
+ - **Configurable summarizer thinking (`summarizerThinking`):** trade summary cost / latency for quality (`off` / `minimal` / `low` / `medium` / `high` / `xhigh`). `default` omits the option entirely so the provider chooses.
684
+ - **Cumulative stats:** `context-prune-stats` entries track input/output tokens and cost of every summarizer call; full detail surfaces in `/pruner stats`. Cost is also emitted on the `cost:external` pi.events channel for external aggregators (cumulative per session, live only).
685
+ - **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages) = JSON.stringify(messages).length`, comparing the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all four reclaim mechanisms in a single point (stub-replace, error-purge, chain-range-prune, thinking-strip); appears on the status line as `│ prune: ON · 92k->14k (-85%) │` once at least one prune has occurred (the `│ … │` wrapper keeps the segment visually isolated in the shared footer, load-order independent).
686
+ - **Live progress for `/pruner now`:** an `aboveEditor` widget shows one row per pending batch with braille spinner, streamed summary-char count, and ✓ / ⚠ status.
687
+
688
+ ---
689
+
690
+ ## Why Summarization Works: Research Evidence
691
+
692
+ Summarizing tool-call history is not just a hack — it is an active research area with strong empirical support. Three recent papers establish the benefits:
693
+
694
+ ---
695
+
696
+ ### SUPO — Summarization augmented Policy Optimization
697
+
698
+ > **Paper:** *SUPO (arXiv:2510.06727)* — Miao Lu et al.
699
+ > **TL;DR:** RL-trained agents with built-in summarization outperform standard agents on long-horizon tasks while using *less* context.
700
+
701
+ **Core idea:**
702
+ SUPO integrates summarization directly into the RL training pipeline for tool-using agents. Instead of treating context compression as an afterthought, the policy gradient is derived to optimize **both** tool-use behavior **and** summarization strategy end-to-end.
703
+
704
+ **Method:**
705
+ - Periodically compresses tool-using history via LLM-generated summaries
706
+ - Retains task-relevant information in compact form
707
+ - Derives a policy gradient that lets standard LLM RL infrastructure optimize both behaviors simultaneously
708
+ - Enables training beyond fixed context limits
709
+
710
+ **Key results:**
711
+ - Significantly improved success rate on interactive function calling and search tasks
712
+ - **Same or lower working context length** compared to baselines that don't summarize
713
+ - Test-time scaling: increasing the maximum summarization rounds during evaluation further improves performance
714
+
715
+ **Why this matters for Pi:**
716
+ SUPO proves that summarization is not just about saving tokens — it actively **improves task success** on long-horizon multi-turn problems by preventing context overflow and keeping relevant signals prominent.
717
+
718
+ ---
719
+
720
+ ### ReSum — Recursive Summarization for Long-Horizon Agents
721
+
722
+ > **Paper:** *ReSum (arXiv:2509.13313)* — Xixi Wu et al. (Alibaba)
723
+ > **TL;DR:** A plug-and-play summarization tool enables web agents to explore indefinitely without hitting context limits, achieving 4.5–12.7% gains over ReAct.
724
+
725
+ **Core idea:**
726
+ ReSum addresses the fundamental conflict between **exploration** (needing many tool calls) and **context limits** (fixed window size). Current agents append every thought/action/observation to history until they crash into the context ceiling.
727
+
728
+ **Method:**
729
+ - Periodically invokes an external **summary tool** to condense interaction history
730
+ - The agent restarts reasoning from the compressed summary
731
+ - Introduces **ReSum-GRPO**: adapts Group Relative Policy Optimization with **advantage broadcasting** — propagates final trajectory rewards across all segments so early exploration steps get proper credit
732
+ - Trained a specialized **ReSumTool-30B** to extract key evidence and propose next steps
733
+
734
+ **Key results:**
735
+ - **4.5% improvement** over ReAct in training-free settings
736
+ - **Further 8.2% gain** with ReSum-GRPO training
737
+ - A 30B ReSum-enhanced agent with only 1K training samples achieves competitive performance with leading open-source models
738
+ - Enables "unbounded exploration" — the agent never hits a hard context wall
739
+
740
+ **Why this matters for Pi:**
741
+ ReSum validates the exact architecture `pi-condense` uses: an external summarizer module, periodic compression, and recovery from compressed state. The plug-and-play nature means it works with off-the-shelf agents — no retraining required.
742
+
743
+ ---
744
+
745
+ ### ACON — Agent Context Optimization
746
+
747
+ > **Paper:** *ACON (arXiv:2510.00615)* — Minki Kang et al. (Microsoft/ KAIST)
748
+ > **TL;DR:** Optimized compression guidelines reduce memory by 26–54% while preserving accuracy; distilled compressors retain >95% of performance.
749
+
750
+ **Core idea:**
751
+ ACON is a unified framework that compresses **both** environment observations and interaction histories into "concise yet informative condensations." It treats compression as an optimization problem: maximize task reward while minimizing context cost.
752
+
753
+ **Method:**
754
+ - **Gradient-free** — uses natural language space optimization (no model fine-tuning)
755
+ - **Failure-driven guideline optimization:** runs the agent with and without compression, collects cases where compression caused failure, and uses an optimizer LLM to refine compression guidelines
756
+ - Two-step alternation:
757
+ 1. **Utility maximization** — ensure task success is preserved
758
+ 2. **Compression maximization** — make summaries shorter while keeping sufficiency
759
+ - **Distillation:** optimized compressor can be distilled into smaller models (e.g., Qwen-14B) with >95% accuracy retention
760
+
761
+ **Key results:**
762
+ - **26–54% reduction in peak tokens** across AppWorld, OfficeBench, and Multi-objective QA
763
+ - Preserves task performance with large models
764
+ - **Smaller LMs improve 20–46%** as agents when context compression removes distracting noise
765
+ - Distilled compressor retains **>95% accuracy**
766
+
767
+ **Why this matters for Pi:**
768
+ ACON demonstrates that **compression not only saves tokens but can improve agent performance** — especially for smaller models, where long noisy context actively degrades reasoning quality. The failure-driven optimization approach shows that even simple summarization, when guided by task structure, preserves critical signals.
769
+
770
+ ### Token-budget auto-flush trigger
771
+
772
+ `autoBudgetThreshold` (default `null`) is an ADDITIONAL flush trigger orthogonal to `pruneOn`. When set to a fraction in `(0, 1]`, the extension evaluates `tokens / contextWindow` at the end of every tool-using turn; when the ratio meets the threshold, all pending batches are flushed immediately regardless of the configured `pruneOn` mode.
773
+
774
+ Why we compute the ratio ourselves rather than using `ContextUsage.percent`: the provider's `percent` field is a 0–100 value, and both it and `tokens` are `null` immediately after a provider-side compaction. Using `tokens / contextWindow` directly gives a 0–1 fraction that matches the config unit and is independently null-safe — a `null` tokens value makes the trigger a no-op until usage is reported again.
775
+
776
+ Lineage: simplified take on DCP's `maxContextLimit` nudging — a single threshold that forces a flush rather than separate nudge/force thresholds.
777
+
778
+ ### Budget-delta flush
779
+
780
+ `budgetTurnDelta: number | null` (default `null`) is a per-turn usage-jump trigger ORed with `autoBudgetThreshold`. When set to a fraction in `(0, 1]`, the extension compares the current turn's usage fraction (`tokens / contextWindow`) to the previous turn's and forces a flush if the jump meets or exceeds the delta.
781
+
782
+ Use case: a single enormous tool result can jump context usage by 20–30 percentage points in one turn; `autoBudgetThreshold` misses this until the next turn. `budgetTurnDelta` catches the spike immediately.
783
+
784
+ **`previousFraction` tracking:**
785
+ - Reset to `null` on `session_start` and `session_tree` (session reload).
786
+ - Left unchanged on a null-tokens turn immediately following a provider-side compaction (treating a post-compaction null as `0` would produce a spurious spike on the next real turn).
787
+ - The post-restart first turn cannot fire a delta trigger (no prior fraction to compare) and falls back to `autoBudgetThreshold` alone.
788
+
789
+ `null` = off (default).
790
+
791
+ ---
792
+
793
+ ## Chain Compression
794
+
795
+ Chain compression is a second layer on top of the per-batch tool-result stub pruner. It operates on entire closed conversation chains rather than individual tool results, reclaiming the tokens that the stub pruner cannot touch: assistant thinking blocks, encrypted thinking signatures, and tool-call argument bodies.
796
+
797
+ ### What a closed chain is
798
+
799
+ A **closed chain** is a span of messages from one user message through any number of tool-using assistant turns and their results, ending in a final text-only assistant reply:
800
+
801
+ ```
802
+ [user msg] ← chain start (kept raw)
803
+ [assistant: thinking + toolCalls] ← middle (dropped)
804
+ [toolResult] ← middle (dropped)
805
+ [assistant: thinking + toolCalls] ← middle (dropped)
806
+ [toolResult] ← middle (dropped)
807
+ [assistant: text-only] ← chain close (kept, thinking stripped)
808
+ ```
809
+
810
+ ### What gets dropped vs. kept
811
+
812
+ | Part | After chain compression |
813
+ |---|---|
814
+ | Start user message | **Kept raw** |
815
+ | Middle assistant turns (all) | **Dropped** — assistant thinking + signatures + toolCall argument blocks |
816
+ | Middle tool results (all) | **Dropped** — already stub-replaced by the per-batch pruner; now fully removed |
817
+ | Per-batch summary message(s) for this chain | **Suppressed** — replaced by the chain-level synthetic |
818
+ | Final text-only assistant | **Kept**, thinking blocks stripped (safe — no following tool cycle depends on the signature) |
819
+ | Synthetic `<compressed-chain>` user message | **Injected** immediately after the start user message |
820
+
821
+ ### Transform composition order
822
+
823
+ ```
824
+ raw messages from session
825
+
826
+ ├─ [1] tool-result stub-replace (per-batch; existing)
827
+ ├─ [2] error-purge (phase 2)
828
+ ├─ [3] chain-range-prune (runs AFTER stubs)
829
+ │ for each compressed chain:
830
+ │ drop middle assistants (by toolCallId overlap)
831
+ │ drop middle toolResults (by toolCallId)
832
+ │ suppress per-batch summaries (by toolCallRefs overlap)
833
+ │ inject <compressed-chain> after start user
834
+ │ strip thinking from final assistant
835
+ └─ [4] thinking-strip (keep thinking on last K assistant turns)
836
+ ```
837
+
838
+ ### Identification model
839
+
840
+ Pi-ai's `Message` union (`UserMessage | AssistantMessage | ToolResultMessage`) has no `.id` field. Chain compression uses:
841
+ - `timestamp: number` to identify user / final-assistant boundary messages
842
+ - `toolCallId` sets to identify middle assistant turns and their tool results
843
+
844
+ This is why the persisted `ChainCompressionEntry` stores `startUserTimestamp` + `droppedToolCallIds` rather than message IDs.
845
+
846
+ ### Rolling window
847
+
848
+ `chainCompression.rollingWindow` (default `3`) controls how many recently-closed chains stay raw. Once the (K+1)-th chain closes, the oldest chain beyond the window is compressed.
849
+
850
+ - With K=3: the three most-recently-closed chains stay as-is; the fourth triggers compression of the oldest. Exactly K closed chains are kept raw (the open/live chain is never counted).
851
+ - `/pruner compact` bypasses the window and compresses every eligible chain immediately (retroactive).
852
+
853
+ **Closing-message threading.** In agent-message mode the flush runs from `message_end`, which pi emits to extensions *before* it persists the message to the session (`agent-session.js` runs `_emitExtensionEvent` ahead of `sessionManager.appendMessage`). So at flush time `getBranch()` is missing the just-closed final assistant, and the newest chain would read as open — over-retaining by one (effective K+1). `index.ts` threads the triggering `event.message` through `FlushOptions.closingMessage` into `withClosingMessage` so the chain closes and the window is exactly K.
854
+
855
+ ### Synthetic message format
856
+
857
+ The injected user message body:
858
+
859
+ ```
860
+ <compressed-chain id="b1" tools="t3,t4,t5">
861
+ [existing per-batch summary text]
862
+ </compressed-chain>
863
+ ```
864
+
865
+ - `id="b1"` is a stable monotonic block ID (`b1`, `b2`, …) assigned at compression time and persisted in the `context-prune-chain` session entry.
866
+ - `tools="t3,t4,t5"` lists the short `tN` refs from the per-batch index so the model can call `context_tree_query` to recover individual tool outputs.
867
+ - The body is the cohesive LLM **range summary** when present (see below), else the existing `context-prune-summary` text reused verbatim.
868
+
869
+ ### Range summary fusion
870
+
871
+ By default (`chainCompression.fuseRangeSummary`, default `true`) a compressed chain's per-batch summaries are fused into ONE cohesive summary by a single summarizer call at compression time, persisted as `rangeSummaryText` on the `context-prune-chain` entry and used as the synthetic body. This is recursive summarization (summary-of-summaries): the input is the already-pruned per-batch summary text, so it never re-sends raw tool output.
872
+
873
+ - **Gate:** only spans with >= 2 distinct per-batch summaries are fused (nothing to fuse otherwise); single-summary spans use that summary directly.
874
+ - **Non-fatal:** if the fusion call fails or returns empty, the entry stores no `rangeSummaryText` and the renderer falls back to the per-batch concatenation — the chain still compresses.
875
+ - **Cost:** one extra summarizer call per multi-batch span, charged to the same summarizer model and folded into the usage stats (`rangesSummarized` counter). Set `fuseRangeSummary: false` to keep the zero-LLM concatenation.
876
+ - **Recovery:** unchanged — the span's tool outputs stay in `context-prune-index` and are recoverable via `context_tree_query`; the raw span text stays in the session JSONL.
877
+
878
+ ### Cache impact
879
+
880
+ Each new chain compression busts the prefix cache from the affected chain's start-user-message onward. Mitigation: the rolling window concentrates compression decisions at predictable points (one event per chain close), so cache is only invalidated when an old chain ages out of the window — not on every turn.
881
+
882
+ ### Recovery
883
+
884
+ Chain compression does not delete data from the session JSONL. The original tool outputs remain in `context-prune-index` entries and are recoverable via `context_tree_query`. To undo compression of a specific chain, delete the matching `context-prune-chain` entry from the session file — the chain will re-appear in context on next load.
885
+
886
+ ### Protected-output relocation
887
+
888
+ **Contract:** outputs protected by tool name (`protectedTools`) or path glob (`protectedPaths`) must never be pruned from LLM context. The per-batch pipeline honours this at capture time (protected tool calls never enter the batch). Chain compression, however, operates at the *message range* level and drops entire middle turns wholesale - which would silently remove any protected `ToolResultMessage` residing in those turns.
889
+
890
+ **Mechanism:** detection (`detectChains`) records which middle tool-call ids are protected on the `ChainRange`; `compressEligible` copies that id list onto the persisted `ChainCompressionEntry.protectedToolCallIds` (it stores ids only, no text). At render time, `applyChainCompressions` pulls each protected `ToolResultMessage`'s verbatim text live from the raw branch and embeds it inside the synthetic `<compressed-chain>` block:
891
+
892
+ ```
893
+ <compressed-chain id="b1" tools="t3,t4,t5">
894
+ [range summary text]
895
+ <protected-output tool="todoread">...verbatim output...</protected-output>
896
+ </compressed-chain>
897
+ ```
898
+
899
+ The protected output is relocated (moved), not copied — the original `ToolResultMessage` is dropped with the rest of the middle turns. The text stays in LLM context because it is embedded in the surviving synthetic block. It is NOT registered in the tool-call index and is NOT recoverable via `context_tree_query`; it does not need to be, because it is present verbatim.
900
+
901
+ The `context-prune-chain` session entry carries the matching `protectedToolCallIds` array so `session_start` reconstruction can re-embed the outputs on reload.
902
+
903
+ **Rejected alternative:** skip compression for any chain that contains a protected tool. Rejected because `todowrite`/`todoread` recur in most chains for opted-in users, so this strategy would forfeit most chain compression for the people who most need `protectedTools`.
904
+
905
+ ### Deferred
906
+
907
+ - **Model-driven trigger.** The compressor is autonomous (rolling window). A model-callable compress tool (DCP-style: the model compresses a sub-task as it closes) is not implemented; the earlier scaffolded `agentic-auto` mode + `context_prune` tool were removed in v1.0.0.
908
+ - **Multi-turn span merging.** Each compressed span maps 1:1 to a closed chain (one user -> text-only-assistant round). Merging several consecutive closed spans into one topic summary is future work.
909
+
910
+ ---
911
+
912
+ ## Error Purge
913
+
914
+ Failed tool calls often embed large argument bodies in the assistant message — a `write` call with a 30 KB file body, an `edit` call with a multiline diff. The error result is small (e.g. `"Error: file not found"`), but the original `arguments` stay in the assistant turn indefinitely.
915
+
916
+ Error purge replaces those arg bodies with compact stubs after the error has cooled down:
917
+
918
+ ```
919
+ { "_purged": "<purged-errored-args size=\"N\"/>" }
920
+ ```
921
+
922
+ **What triggers a purge:**
923
+ - The matching `ToolResultMessage` has `isError: true`.
924
+ - The error occurred at least `purgeErrors.cooldownTurns` assistant turns ago (default 2). The cooldown gives the model 1–2 turns to retry before context is mutated.
925
+ - The JSON-stringified argument body is at least `purgeErrors.minArgChars` characters long (default 500). Small args are not worth the substitution.
926
+
927
+ **What error purge does NOT touch:**
928
+ - The `ToolResultMessage` content — the error message stays visible so the model can see what went wrong.
929
+ - Non-errored `toolCall` argument bodies.
930
+ - Argument bodies below `minArgChars`.
931
+ - Anything when `purgeErrors.enabled` is `false`.
932
+
933
+ **Transform position:** Error purge runs in Phase 2, after stub-replace and before chain range prune.
934
+
935
+ ```
936
+ [stub-replace] → [error-purge] → [chain-range-prune] → [thinking-strip]
937
+ ```
938
+
939
+ **Config keys:**
940
+
941
+ | Key | Default | Description |
942
+ |---|---|---|
943
+ | `purgeErrors.enabled` | `true` | Master toggle |
944
+ | `purgeErrors.cooldownTurns` | `2` | Turns to wait after error before purging |
945
+ | `purgeErrors.minArgChars` | `500` | Minimum argument body size to purge |
946
+
947
+ ---
948
+
949
+ ## Main-loop Thinking Strip
950
+
951
+ Chain compression and the summarizer target *tool* mass. But in long single-agent sessions the dominant cost is often **assistant `thinking` blocks**: on Opus 4.5+/Sonnet 4.6+ the API retains every prior-turn thinking block by default, and pi-ai replays them all (with signatures) on every request. One autonomous ops session held ~405 K tokens (~80% of a 500 K window) in thinking alone, untouched by every other strategy — chain compression only fires on *closed* spans, and that session was one long open span.
952
+
953
+ Thinking strip is a deterministic, zero-LLM transform (Phase 4) that keeps `thinking` blocks only on the last `keepLastTurns` **assistant turns** and strips them from older assistant messages, leaving each message's `text` and `toolCall` blocks intact.
954
+
955
+ ### Turn unit
956
+
957
+ `keepLastTurns` counts **assistant messages**, not user-bounded spans. The target failure mode is a single long open chain (zero subagents, near-zero user turns) where a span-based window would keep everything. Counting assistant turns directly bounds thinking accumulation regardless of whether any chain closes.
958
+
959
+ ### Provider safety
960
+
961
+ Anthropic's extended-thinking contract during tool use:
962
+ - Only the **last assistant turn's** thinking is required; "you can omit thinking blocks from prior assistant role turns" and the API auto-filters them.
963
+ - A message's thinking blocks must be dropped **all-or-nothing** ("the entire sequence of consecutive thinking blocks must match the outputs … you can't rearrange or modify the sequence"). The strip reuses `withoutThinkingBlocks`, which removes every thinking block (and its signature) from a message.
964
+
965
+ `keepLastTurns` is clamped to `>= 1`, so the most-recent assistant turn — the one that may be awaiting tool results — always keeps its thinking. This is the minimum safe window; the default of 16 is far above the floor and preserves recent reasoning continuity.
966
+
967
+ ### Transform position
968
+
969
+ Thinking strip runs **last**, after chain-range-prune, so "last K assistant turns" is measured over the turns that actually survive to the LLM:
970
+
971
+ ```
972
+ [stub-replace] → [error-purge] → [chain-range-prune] → [thinking-strip]
973
+ ```
974
+
975
+ In a session with no closed chains, Phases 1–3 may be no-ops and thinking strip does all the work. Where chain compression *does* fire, the two cooperate: chain compression drops whole old middle turns (including their thinking); thinking strip mops up thinking in the surviving recent / in-flight turns beyond K.
976
+
977
+ ### Cache impact
978
+
979
+ Each new assistant turn slides the keep-window by one, stripping the turn that falls out and invalidating the prefix cache from that point (~K turns deep). The stable cached prefix (everything older than the window) still grows monotonically; only a K-deep tail churns. The trade vs the status quo: without stripping, thinking accrues without bound and is billed as cached input on every request until the window overflows; with stripping, total context is bounded at the cost of re-processing the last ~K turns' thinking each turn. Net-positive for long sessions; a literal no-op for sessions under `keepLastTurns` turns. Smaller K is cheaper on both savings and churn (worse only for reasoning continuity).
980
+
981
+ ### Recovery
982
+
983
+ Stripped thinking is **not** recoverable via `context_tree_query` — unlike tool outputs, thinking blocks are not indexed. The raw thinking remains in the session JSONL on disk (the `context` hook never mutates storage); reloading the session without the extension, or reading the file directly, shows the original blocks. Thinking is transient model-internal reasoning, so drop-without-recovery is intentional.
984
+
985
+ ### Config keys
986
+
987
+ | Key | Default | Description |
988
+ |---|---|---|
989
+ | `thinkingStrip.enabled` | `true` | Master toggle (gated behind the top-level `enabled`) |
990
+ | `thinkingStrip.keepLastTurns` | `16` | Keep thinking on the last N assistant turns; strip older. Clamped to `>= 1` |
991
+
992
+ ---
993
+
994
+ ## Summary
995
+
996
+ | Concern | How Pruning Addresses It |
997
+ |---|---|
998
+ | **Context grows without bound** | Replaces raw tool outputs (~thousands of tokens) with compact summaries (~hundreds) |
999
+ | **Signal lost in noise** | Summaries surface the key decisions and facts; raw data is demoted to on-demand query |
1000
+ | **Cache performance** | Batch-then-prune (`agent-message`) minimizes cache invalidation; stub-replace keeps the pruned tail deterministic so it stays cacheable |
1001
+ | **Data availability** | `context_tree_query` recovers full original outputs at any time, including aliased duplicates |
1002
+ | **Wasted LLM calls** | Pre-flush content-hash dedup catches re-reads at zero cost; `minBatchChars` short-circuits batches too small to be worth summarizing |
1003
+ | **Plan / state churn** | `protectedTools` keeps allowlisted tool outputs verbatim across turns |
1004
+ | **Empirical benefit** | SUPO, ReSum, and ACON all show summarization improves or preserves task success while reducing context length 26–54% |
1005
+
1006
+ ### Choosing a mode
1007
+
1008
+ ```
1009
+ ┌─────────────────────────────────────────────────────────────────────┐
1010
+ │ MODE DECISION TREE │
1011
+ ├─────────────────────────────────────────────────────────────────────┤
1012
+ │ │
1013
+ │ "I want maximum control" │
1014
+ │ └──► on-demand + /pruner now │
1015
+ │ │
1016
+ │ "I want the best balance of automation, savings, and cache hits" │
1017
+ │ └──► agent-message ◄── DEFAULT │
1018
+ │ │
1019
+ └─────────────────────────────────────────────────────────────────────┘
1020
+ ```
1021
+
1022
+ ### Recommended reading
1023
+
1024
+ - Anthropic prompt caching docs: <https://docs.claude.com/en/docs/build-with-claude/prompt-caching>
1025
+ - OpenAI prompt caching docs: <https://platform.openai.com/docs/guides/prompt-caching>
1026
+ - SUPO: <https://arxiv.org/abs/2510.06727>
1027
+ - ReSum: <https://arxiv.org/abs/2509.13313>
1028
+ - ACON: <https://arxiv.org/abs/2510.00615>