pi-condense 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,14 @@ publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.4.0] - 2026-07-09
13
+
14
+ - **Summarizer call timeout.** Every summarizer stream call is now bounded by an idle timeout (`summarizerIdleTimeoutMs`, default 20s - reset on every stream event, so it never false-aborts a flowing or reasoning generation) and a total-duration ceiling (`summarizerMaxTimeoutMs`, default 180s). Previously a stalled-but-open provider connection hung the whole agent turn indefinitely, since `runOnce` had no time budget and the automatic flush paths pass no abort signal. A timeout classifies as transient and feeds the existing outage-fallback retry (one bounded session-model attempt when a distinct `summarizerModel` is set), then surfaces a `warning` notice. Both timers are `0`-disablable and exposed in `/pruner settings` and `/pruner status`.
15
+
16
+ ## [2.3.0] - 2026-07-06
17
+
18
+ - **Recovery grace window for `context_tree_query` output.** The pruner used to re-stub its own recovery output at the next turn boundary, forcing a retrieve -> re-stub -> re-query loop the agent experiences as "fighting the pruner" (observed in a real session: a recovered tool dump was re-summarized on the very next flush, so the agent had to keep re-querying the same ref). A new `recoveryGraceTurns` setting (default `3`, `0` disables) keeps a recovered output verbatim for that many user-turn-groups before reverting to the stub. Enforced at **render time** in two places - Phase 1 stub-replace (`src/pruner.ts`) and chain-compression eligibility (`src/chain-compressor.ts`, which defers compressing any chain whose span still holds an in-grace recovery id) - never at capture, so the frontier, dedup, spill, and live `turn_end` paths are unchanged. The window is computed positionally from the message stream (no new `ToolCallRecord` field). Default `3` covers ~81% of same-ref re-queries observed in the local session corpus; the accepted trade-off is that a reference past the window is re-stubbed and may be re-queried, keeping context regrowth bounded rather than permanent. Tunable via `/pruner recovery-grace [n]` and the `/pruner settings` overlay. See [PRUNING.md § What Pruning Does](PRUNING.md#what-pruning-does).
19
+
12
20
  ## [2.2.1] - 2026-07-06
13
21
 
14
22
  - **Fix probe starvation in the summarizer outage fallback.** `FallbackController.onFallbackOnlyFail` reset the re-probe cooldown on every steady-state fallback failure, so a fallback (session) model that failed at least once per 10-minute cooldown perpetually pushed out the primary re-probe - a recovered `summarizerModel` was never re-tested and summarization stayed on the pricier session model indefinitely (the exact stall the feature exists to kill, in the fallback direction). The method is now a no-op on `lastProbeAt`: the primary re-probe fires on schedule regardless of fallback failures. In-memory only; no wire/config change.
package/PRUNING.md CHANGED
@@ -185,6 +185,9 @@ graph TB
185
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
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
187
  - The session JSONL file retains the original tool-result entries unchanged — pruning only affects what the *next* request sees in active context.
188
+ - **Recovery grace window (`recoveryGraceTurns`, default 3):** after the model recovers a tool call via `context_tree_query`, that tool call's output is rendered **verbatim** (not re-stubbed) for the next N user-turn-groups, then reverts to the normal stub. Without this, the pruner re-prunes its own recovery output on the very next flush, forcing the model into a retrieve -> re-stub -> re-query loop ("fighting the pruner") whenever it keeps referencing the same recovered data across a few turns.
189
+ - Enforced at **render time**, in Phase 1 stub-replace and in chain-compression eligibility — NOT at capture time. Capture-time exclusion would collide with frontier trim: a tool call already past the frontier is dropped forever, so excluding a recovered call from capture would either need to resurrect frontier state or degrade the lifetime bound into permanent verbatim retention for anything ever recovered.
190
+ - Trade-off: this bounds but does not eliminate regrowth. A tool call still referenced after its grace window expires is re-stubbed and may be re-queried again — the accepted cost of keeping the window's context-growth impact bounded instead of unbounded.
188
191
 
189
192
  ---
190
193
 
package/README.md CHANGED
@@ -6,251 +6,173 @@
6
6
 
7
7
  [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-donate-yellow?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/jjurasszek)
8
8
 
9
- A [Pi coding-agent](https://github.com/earendil-works/pi) extension that summarizes completed tool-call batches, replaces raw tool outputs with short stubs in future context, and lets the LLM recover any original via the `context_tree_query` tool.
9
+ A [Pi coding-agent](https://github.com/earendil-works/pi) extension that keeps long agent sessions cheap by pruning context - **the context-economy layer of the pi agent toolkit** (see below).
10
10
 
11
- The session JSONL file is never modified — pruning only affects what each *next* request sees.
11
+ ## The problem
12
12
 
13
- Adds pre-flush safeguards, agent-message batching, chain compression, and an npm release flow on top of the original approach from [`championswimmer/pi-context-prune`](https://github.com/championswimmer/pi-context-prune).
13
+ Every long agent session accumulates raw tool output - file reads, command dumps, search results - that the model already used and will never need again verbatim. Left in context, it degrades reasoning on later turns, inflates the cost of *every* subsequent request, and pushes a smaller/cheaper model past the point where it can still drive. Provider prompt-caching does not fix this on its own: naive trimming actively fights it, because rewriting the prompt on every turn busts the cache you were relying on to keep costs down.
14
14
 
15
- 📖 For the algorithm, design rationale, prompt-cache interaction, and the research behind summarization-based context management, see **[PRUNING.md](PRUNING.md)**.
15
+ **pi-condense** replaces finished tool-call batches with short, recoverable summaries, timed around exactly that caching problem. Nothing is deleted - the session file on disk is untouched, and any summarized result can be pulled back verbatim via the `context_tree_query` tool. Net effect: long sessions stay affordable, and a smaller/cheaper driver model stays viable for longer.
16
16
 
17
- ## Install
17
+ ## Why this, not naive trimming
18
18
 
19
- Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense).
19
+ - **Recoverable, not lossy.** Originals are archived and addressable by a short ref. Summarizing only changes what the model *sees by default* - not what it can retrieve on demand.
20
+ - **Batched against the cache, not per turn.** Pruning fires once per finished unit of work (configurable), so the prompt prefix stays stable in between and providers keep serving it from cache. Pruning every turn instead would bust the cache on every single turn - the opposite of the intended savings.
20
21
 
21
- **User scope** (all repos under your pi profile):
22
+ The full argument, with diagrams, is in **[PRUNING.md](PRUNING.md)**; this README stays at the "why and how to use it" level.
22
23
 
23
- ```bash
24
- pi install npm:pi-condense
25
- ```
24
+ ## Part of the pi agent toolkit
26
25
 
27
- **Project scope** (current repo only, committable via `.pi/settings.json`):
26
+ Four independent extensions for the [pi coding agent](https://github.com/earendil-works/pi), each owning one concern of running agents seriously:
28
27
 
29
- ```bash
30
- pi install -l npm:pi-condense
28
+ - [pi-quiver](https://github.com/jjuraszek/pi-quiver) - capabilities (fetch, doc conversion, session tools)
29
+ - [pi-cohort](https://github.com/jjuraszek/pi-cohort) - coordination (delegate to focused child agents)
30
+ - **pi-condense - context economy (this repo): prune context, keep it recoverable**
31
+ - [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) - process (the gated brainstorm->ship workflow)
32
+
33
+ No code dependency either way. The practical coupling: pi-condense is what keeps a long pi-cohort fan-out or a long pi-gauntlet gated run affordable as it grows, and both can surface pi-condense's live cost via the shared `cost:external` channel (see [External cost channel](#external-cost-channel)).
34
+
35
+ ## Mental model
36
+
37
+ Two-layer memory, not deletion:
38
+
39
+ - **Hot:** a compact summary of a finished batch, kept in active context.
40
+ - **Cold:** the full original tool results, archived in a session-local index and addressable by a short ref (`t1`, `t2`, ...).
41
+
42
+ The model reads the hot summary by default and calls `context_tree_query` when it actually needs the cold original back. See [PRUNING.md](PRUNING.md) for the full before/after diagrams and the prefix-cache mechanics behind the batching schedule.
43
+
44
+ ```mermaid
45
+ flowchart LR
46
+ B["finished tool-call batch<br/>(t1, t2, t3 ...)"] --> S[summarize into short stub]
47
+ S --> H["hot: model sees the stub"]
48
+ B -.archived, session file untouched.-> C["cold: originals by ref"]
49
+ H -->|"context_tree_query(t2)"| C
50
+ C -.restores original.-> H
31
51
  ```
32
52
 
33
- **Try without installing**:
53
+ ## Quick example
34
54
 
35
55
  ```bash
36
- pi -e npm:pi-condense
56
+ pi install npm:pi-condense
37
57
  ```
38
58
 
39
- **From a local checkout** (for hacking on the extension itself):
40
-
41
59
  ```bash
42
- git clone git@github.com:jjuraszek/pi-condense.git ~/repos/pi-condense
43
- cd ~/path/to/your/repo
44
- pi install -l ~/repos/pi-condense
45
- # or one-shot, no install:
46
- pi -e ~/repos/pi-condense/index.ts
60
+ /pruner on # enable pruning (off by default)
61
+ /pruner model openai/gpt-4.1-mini # pick a cheap summarizer
62
+ /pruner status # see mode, model, trigger, cumulative stats
47
63
  ```
48
64
 
49
- Pin a specific version with `npm:pi-condense@X.Y.Z`. Upgrade by re-running `pi install`. Remove with `pi remove pi-condense`. Once installed, the extension auto-loads on every `pi` invocation; no flags needed.
65
+ ## Architecture
50
66
 
51
- > See [CHANGELOG.md](CHANGELOG.md) for release history.
67
+ | Trigger mode | Fires | Cache impact |
68
+ |---|---|---|
69
+ | `agent-message` (default) | When the agent sends a final text-only reply | ~1 cache rewrite per task batch |
70
+ | `on-demand` | Only when you run `/pruner now` | None until you ask |
52
71
 
53
- ## Quick start
72
+ Before any summarizer call, a pre-flush pipeline can drop or redirect a batch at zero LLM cost: protected tools/paths are never touched, content-hash duplicates are aliased to the original, batches too small to be worth summarizing are skipped outright, and oversized single results are spilled straight to a sidecar file. Closed tool-call chains older than a rolling window are additionally range-compressed. Full pipeline and each safeguard: [PRUNING.md § Pre-flush Pipeline & Safeguards](PRUNING.md#pre-flush-pipeline--safeguards), [§ Chain Compression](PRUNING.md#chain-compression).
54
73
 
55
- ```bash
56
- /pruner on # enable pruning
57
- /pruner status # see current mode + cumulative cost
58
- /pruner model openai/gpt-4.1-mini # pick a cheap summarizer
59
- /pruner now # flush pending batches immediately
60
- ```
74
+ ### External cost channel
61
75
 
62
- By default the extension is **off**. Enable it once and it stays enabled across sessions in the same pi agent directory.
63
-
64
- ## How it decides when to prune
65
-
66
- Two trigger modes. The mode controls *when* summarization fires; the algorithm is the same in each.
67
-
68
- | Mode | Trigger | Cache impact | Use when |
69
- |---|---|---|---|
70
- | `agent-message` (default) | When the agent sends a final text-only reply | One cache rewrite per task batch | Normal coding-agent work — best balance |
71
- | `on-demand` | Only when you run `/pruner now` | None until you ask | Long investigations; manual control |
72
-
73
- Why `agent-message` is the default: provider prefix caches (Anthropic, OpenAI, Bedrock, vLLM) only hit when the prompt prefix matches exactly. Every prune rewrites that prefix. Batching tool turns and pruning once per agent reply means roughly one cache miss per task instead of one per turn. See [PRUNING.md § The Sweet Spot](PRUNING.md#the-sweet-spot-batch-and-prune) for the full argument.
74
-
75
- ## Configuration
76
-
77
- Settings live under the `contextPrune` key in `<agent-dir>/settings.json` (i.e. pi's own settings file). `<agent-dir>` is `$PI_CODING_AGENT_DIR` if set, otherwise `~/.pi/agent`. Each pi preset gets its own settings, so you can run different summarizer models per preset.
78
-
79
- ```json
80
- {
81
- "contextPrune": {
82
- "enabled": false,
83
- "showPruneStatusLine": true,
84
- "summarizerModel": "default",
85
- "summarizerThinking": "default",
86
- "pruneOn": "agent-message",
87
- "batchingMode": "turn",
88
- "quietOversizedSkips": false,
89
- "minBatchChars": 1000,
90
- "protectedTools": [],
91
- "protectedPaths": ["**/skills/**/*.md"],
92
- "dedupByContentHash": true,
93
- "autoBudgetThreshold": null,
94
- "spillThreshold": 65536,
95
- "spillPreviewBytes": 2048,
96
- "budgetTurnDelta": null,
97
- "chainCompression": {
98
- "enabled": true,
99
- "rollingWindow": 3,
100
- "stripFinalAssistantThinking": true,
101
- "fuseRangeSummary": true
102
- },
103
- "thinkingStrip": {
104
- "enabled": true,
105
- "keepLastTurns": 16
106
- }
107
- }
108
- }
109
- ```
76
+ Every summarizer cost update is emitted on the shared `pi.events` channel `cost:external` (`source: "pi-condense"`, cumulative per session, live only - not persisted, not re-seeded on restart). This is a generic channel: pi-condense is a producer, not the owner. [pi-cohort](https://github.com/jjuraszek/pi-cohort) is the canonical consumer, folding it into a single `Σ$` total alongside its own subagent costs.
110
77
 
111
- | Key | Values | Default | Notes |
112
- |---|---|---|---|
113
- | `enabled` | `true` / `false` | `false` | Master switch |
114
- | `showPruneStatusLine` | `true` / `false` | `true` | Footer widget + queued-turn notifications |
115
- | `summarizerModel` | `"default"` or `"provider/model-id"` | `"default"` | `default` = your active pi model. See [Choosing a summarizer model](#choosing-a-summarizer-model) |
116
- | `summarizerThinking` | `default`/`off`/`minimal`/`low`/`medium`/`high`/`xhigh` | `default` | Provider-specific reasoning effort knob |
117
- | `pruneOn` | see table above | `agent-message` | Trigger mode |
118
- | `batchingMode` | `turn` / `agent-message` | `turn` | How coarse each summary is (independent of `pruneOn`) |
119
- | `quietOversizedSkips` | `true` / `false` | `false` | Silences `skipped-oversized` / `skipped-trivial` info notifications |
120
- | `minBatchChars` | non-negative integer, `0` disables | `1000` | Pre-flush guard — batches smaller than this skip the LLM entirely |
121
- | `protectedTools` | `string[]` | `[]` | Never-pruned tool names (e.g. `["todowrite","todoread"]`). When a protected tool's chain is range-compressed, its output is preserved verbatim inside the `<compressed-chain>` block as `<protected-output>` — protected outputs are never lost. |
122
- | `protectedPaths` | `string[]` | `["**/skills/**/*.md"]` | Globs matched against a tool call's `args.path`; matching outputs are never pruned (same semantics as `protectedTools`, including `<protected-output>` relocation in compressed chains). Already-summarized matching reads are repaired on the next turn; chain-compressed ones are not. Set `[]` to disable. |
123
- | `dedupByContentHash` | `true` / `false` | `true` | Re-reads of identical (toolName, content) skip the LLM and alias the original |
124
- | `autoBudgetThreshold` | fraction `0`–`1`, or `null` | `null` | Token-budget auto-flush: force a prune when context usage reaches this share of the window, regardless of `pruneOn`. `0.8` = 80%, not `80`. `null` = off. See [Token-budget auto-flush](#token-budget-auto-flush) |
125
- | `spillThreshold` | positive integer | `65536` | Minimum chars (`resultText.length`) for a single tool result to be spilled eagerly to a sidecar file at capture time rather than waiting for normal summarization. Non-positive / invalid values fall back to the default; to effectively disable spilling, set it above any result you expect. See [Spilled outputs](#spilled-outputs) |
126
- | `spillPreviewBytes` | non-negative integer | `2048` | Head preview (bytes) kept inline in the stub and index record for a spilled result. Full body is on disk. |
127
- | `budgetTurnDelta` | fraction `0`–`1`, or `null` | `null` | Force a flush when a single turn's context-usage fraction jumps by at least this amount, ORed with `autoBudgetThreshold`. Catches sudden spikes a static threshold would miss until the next turn. `null` = off. |
128
- | `chainCompression.enabled` | `true` / `false` | `true` | Master toggle for chain-level range compression |
129
- | `chainCompression.rollingWindow` | positive integer | `3` | Keep this many most-recent closed chains raw; compress older ones |
130
- | `chainCompression.stripFinalAssistantThinking` | `true` / `false` | `true` | Strip thinking blocks from the kept final text-only assistant when compressing |
131
- | `chainCompression.fuseRangeSummary` | `true` / `false` | `true` | Fuse a compressed chain's per-batch summaries into one cohesive LLM summary (one extra summarizer call per multi-batch span); off keeps the per-batch concatenation |
132
- | `purgeErrors.enabled` | `true` / `false` | `true` | Replace failed toolCall argument bodies with compact stubs after cooldown |
133
- | `purgeErrors.cooldownTurns` | positive integer | `2` | Turns to wait after a tool error before purging its argument body |
134
- | `purgeErrors.minArgChars` | non-negative integer | `500` | Only purge arg bodies at least this many characters long |
135
- | `thinkingStrip.enabled` | `true` / `false` | `true` | Strip `thinking` blocks from assistant turns older than the last `keepLastTurns` |
136
- | `thinkingStrip.keepLastTurns` | positive integer | `16` | Keep thinking on the last N assistant turns; strip older. Counts assistant turns, not chains. No-op under N turns |
78
+ ## Key concepts
137
79
 
138
- See [PRUNING.md § Chain Compression](PRUNING.md#chain-compression), [PRUNING.md § Error Purge](PRUNING.md#error-purge), and [PRUNING.md § Main-loop Thinking Strip](PRUNING.md#main-loop-thinking-strip) for the full algorithms.
80
+ | Term | Meaning |
81
+ |---|---|
82
+ | Stub | The short breadcrumb (`[Summarized in pruner summary, ref \`t1\`...]`) that replaces a pruned tool result in context |
83
+ | `context_tree_query` | The tool the model calls to recover a stubbed original by ref |
84
+ | Batch vs chain | A batch is one flush's worth of tool calls; a chain is a longer closed sequence eligible for range compression |
85
+ | Prune frontier | The last attempted prune boundary - advances even on a skip, so nothing is reconsidered twice |
86
+ | Prompt-cache interaction | Why batching (not per-turn pruning) is the default - see [PRUNING.md](PRUNING.md#how-prefix-caching-works) |
87
+ | `cost:external` | The shared cost-reporting channel pi-condense emits on (see above) |
139
88
 
140
- The three pre-flush features (`minBatchChars`, `protectedTools`, `dedupByContentHash`) are explained in [PRUNING.md § Pre-flush Pipeline & Safeguards](PRUNING.md#pre-flush-pipeline--safeguards). They run BEFORE any summarizer LLM call and can each drop a batch outright while still advancing the prune frontier.
89
+ ## When to use / when NOT to use
141
90
 
142
- ### Token-budget auto-flush
91
+ **Use it for:** long coding or research sessions where tool output dominates the prompt; setups deliberately running a smaller/cheaper driver model; pi-cohort fan-outs or pi-gauntlet runs where cost compounds across many turns or many children.
143
92
 
144
- When `autoBudgetThreshold` is set to a value in `(0, 1]`, the extension checks context usage at the end of every tool-using turn. If `tokens / contextWindow` reaches the threshold, ALL pending batches are flushed immediately regardless of `pruneOn` mode. This is an **additional** trigger layered on top of `pruneOn`, not a replacement.
93
+ **Don't reach for it when:** the session is short and one-shot - there is nothing accumulated to prune, only latency to add. It also doesn't replace a provider's own native context-compaction feature if you already rely on that, and it doesn't reduce the cost of the *current* turn's tool calls - only of history that has already been produced.
145
94
 
146
- - `0.8` means 80% of the context window — it is a **fraction**, not a percentage. `0.8 ≠ 80`.
147
- - The trigger is a no-op when `tokens` is `null` (right after a provider-side compaction); it resumes once usage is known again.
148
- - Editable live via `/pruner settings` (row "Auto-flush at context %", presets Off / 60 / 70 / 80 / 90%).
149
- - Default `null` = off.
95
+ ## Limitations
150
96
 
151
- Inspired by DCP's `maxContextLimit` nudging; simplified to a single threshold that forces a flush rather than separate nudge/force levels.
97
+ - Pruning only applies to batches captured *while enabled*. Enabling mid-session does not retroactively summarize earlier turns.
98
+ - Summarizer calls run synchronously inside the turn boundary, so they add latency proportional to the summarizer model's response time. Pick a fast one.
99
+ - Content-hash dedup only matches against records already in the indexer (cross-flush); two identical outputs within the *same* flush both go through the summarizer.
100
+ - The tree browser (`/pruner tree`) does not inline original tool outputs - use `context_tree_query` for that.
152
101
 
153
- ### Spilled outputs
102
+ ## Install
154
103
 
155
- Single tool results larger than `spillThreshold` chars are written to `<session-dir>/<sessionId>-blobs/<toolCallId>.txt` at capture time and replaced in context with a short stub (tool name, byte size, head preview, file path). The full body is recoverable via the native `read` tool at the embedded path (offset/limit supported) or via `context_tree_query` by id, which falls back to the inline preview if the sidecar is missing. Moving a session `.jsonl` without its `-blobs/` directory loses only the giant-blob recovery path; bodies under `spillThreshold` stay inline in the index entry as usual.
104
+ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense).
156
105
 
157
- ### Choosing a summarizer model
106
+ **User scope** (all repos under your pi profile):
158
107
 
159
- The `default` setting reuses whatever model you have active in pi — convenient but wasteful, since summary writing doesn't need a top-tier coding model. Picking the smallest/fastest model on your plan saves both latency and cost.
108
+ ```bash
109
+ pi install npm:pi-condense
110
+ ```
160
111
 
161
- If the configured summarizer model suffers a transient outage while your active pi model is healthy, pi-condense automatically falls back to the session model for the duration (with a one-time notice) and probes the configured model back every few minutes — no configuration needed.
112
+ **Project scope** (current repo only, committable via `.pi/settings.json`):
162
113
 
163
- | Plan | Suggested summarizer |
164
- |---|---|
165
- | OpenAI / Codex / Copilot | `openai/gpt-4.1-mini`, `google/gemini-2.5-flash`, `xai/grok-3-fast` |
166
- | OpenRouter | `openrouter/qwen/qwen3-30b-a3b` (cheap MoE) |
167
- | Anthropic direct | `anthropic/claude-haiku-3-5` |
168
- | Google AI direct | `google/gemini-2.5-flash` |
114
+ ```bash
115
+ pi install -l npm:pi-condense
116
+ ```
169
117
 
170
- Set it from the slash command (saves immediately):
118
+ **Try without installing**:
171
119
 
172
120
  ```bash
173
- /pruner model openai/gpt-4.1-mini
174
- /pruner thinking low
175
- # or both in one go:
176
- /pruner model openai/gpt-4.1-mini:low
121
+ pi -e npm:pi-condense
177
122
  ```
178
123
 
179
- ## Commands
124
+ **From a local checkout** (for hacking on the extension itself):
180
125
 
181
- | Command | Effect |
182
- |---|---|
183
- | `/pruner` | Interactive picker over all subcommands |
184
- | `/pruner settings` | Settings overlay (toggle / cycle every option) |
185
- | `/pruner on` / `off` | Enable / disable pruning |
186
- | `/pruner status` | Show mode, model, trigger, cumulative stats |
187
- | `/pruner stats` | Detailed cumulative summarizer token/cost stats |
188
- | `/pruner model [id\[:thinking\]]` | Get / set summarizer model (and optionally thinking level) |
189
- | `/pruner thinking [level]` | Get / set summarizer reasoning effort |
190
- | `/pruner prune-on [mode]` | Get / set trigger mode |
191
- | `/pruner batching [mode]` | Get / set batching granularity (`turn` / `agent-message`) |
192
- | `/pruner protected-tools [names]` | Show or edit the never-pruned tool allowlist (comma- or space-separated; `none` clears) |
193
- | `/pruner protected-paths [globs]` | Show or edit the never-pruned path globs (`none` clears) |
194
- | `/pruner min-batch-chars [n]` | Show or set the pre-flush trivial-batch threshold (`0` disables) |
195
- | `/pruner dedup [on\|off\|status]` | Toggle pre-flush content-hash dedup |
196
- | `/pruner tree` | Foldable browser of pruned tool calls; `Ctrl-O` opens the full summary in an overlay |
197
- | `/pruner compact` | Retroactively compress every eligible closed chain (bypasses `rollingWindow`) |
198
- | `/pruner now` | Flush pending batches immediately with a multi-row progress widget above the input |
199
- | `/pruner help` | Full help text |
200
-
201
- ## Tools surfaced to the LLM
202
-
203
- **`context_tree_query`** — always available when the extension is loaded. Pruned summaries end with short refs like `Summarized tool refs: \`t1\`, \`t2\`. Use \`context_tree_query\` with these refs to retrieve the original full outputs.` The model passes those refs (or full `toolCallId`s) and gets back the original tool result text from the session index. Each per-tool bullet in the summary also carries its own inline `` `tN` `` ref, so recovering a specific tool is a single hop; the footer still lists every ref as a fallback. Content-hash-deduped duplicates resolve to the original's record automatically.
204
-
205
- ## Footer status widget
206
-
207
- A footer widget shows the current state, controlled by `showPruneStatusLine`:
208
-
209
- Every rendered state is wrapped in `│ … │` so the segment stays visually isolated in the shared footer regardless of where other extensions' status segments land (load-order independent).
210
-
211
- - `│ prune: OFF │` — disabled
212
- - `│ prune: ON │` — enabled, no flushes yet
213
- - `│ prune: ON · 92k->14k (-85%) │` — enabled; live reclaim ratio (estimated tokens before→after, percent reduction). Updates on every `pruneMessages` call.
214
- - `│ prune: 3 pending │` — batches queued, waiting for the trigger
215
- - `│ prune: summarizing… │` — flush in progress
216
-
217
- Setting `showPruneStatusLine: false` hides the widget and silences the queued-turn notice; pruning still runs.
218
-
219
- Cost no longer appears on the status line. Full token/cost detail is available via `/pruner stats`. The extension also emits cumulative session cost on the `cost:external` pi.events channel for external aggregators — see [External cost channel](#external-cost-channel).
220
-
221
- ## External cost channel
222
-
223
- Every time the summarizer cost updates, the extension emits on the shared `pi.events` channel identified by the constant `EXTERNAL_COST_CHANNEL = "cost:external"`. Payload shape:
224
-
225
- ```ts
226
- interface ExternalCostUpdate {
227
- source: string; // EXTERNAL_COST_SOURCE = "pi-condense"
228
- totalCost: number; // cumulative cost for the current session (USD)
229
- inputTokens?: number;
230
- outputTokens?: number;
231
- }
126
+ ```bash
127
+ git clone git@github.com:jjuraszek/pi-condense.git ~/repos/pi-condense
128
+ cd ~/path/to/your/repo
129
+ pi install -l ~/repos/pi-condense
130
+ # or one-shot, no install:
131
+ pi -e ~/repos/pi-condense/index.ts
232
132
  ```
233
133
 
234
- Semantics:
134
+ Pin a specific version with `npm:pi-condense@X.Y.Z`. Upgrade by re-running `pi install`. Remove with `pi remove pi-condense`. Once installed, the extension auto-loads on every `pi` invocation; no flags needed. See [CHANGELOG.md](CHANGELOG.md) for release history.
235
135
 
236
- - **Cumulative per session**, not all-time. Re-emitted on every update; aggregators key by `source` and replace the previous value.
237
- - **Live only.** Not persisted; not re-emitted on `session_start`. An aggregator that restarts mid-session sees cost from zero until the next summarizer call.
238
- - Designed for aggregators like pi-cohort that show a unified Σ$ total across extensions.
136
+ By default the extension is **off**. `/pruner on` enables it and it stays enabled across sessions in the same pi agent directory.
239
137
 
240
- ## Limitations
138
+ ## Configuration - the knobs most people touch
241
139
 
242
- - Pruning only applies to batches captured *while enabled*. Enabling mid-session does not retroactively summarize earlier turns.
243
- - Summarizer calls are synchronous inside `turn_end` (or `message_end` for `agent-message` mode), so they add latency between turns proportional to the summarizer model's response time. Pick a fast model.
244
- - Content-hash dedup only matches against records already in the indexer (cross-flush). Two identical outputs within the *same* flush are not deduped — both go through the summarizer.
245
- - The tree browser does not inline original tool outputs — use `context_tree_query` for that.
140
+ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_AGENT_DIR` if set, else `~/.pi/agent`). Each pi preset gets its own settings.
141
+
142
+ | Key | Default | Notes |
143
+ |---|---|---|
144
+ | `enabled` | `false` | Master switch (or just use `/pruner on`) |
145
+ | `summarizerModel` | `"default"` | Pin a cheap model instead of reusing your active one - see the plan-by-plan table in [doc/configuration.md](doc/configuration.md#choosing-a-summarizer-model) |
146
+ | `pruneOn` | `agent-message` | Trigger mode - see Architecture above |
147
+ | `autoBudgetThreshold` | `null` | Fraction (e.g. `0.8`) of the context window that force-flushes everything regardless of `pruneOn` |
148
+ | `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md"]` | Tool names / path globs that are never pruned |
149
+ | `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
150
+
151
+ The full settings JSON, every key, the commands table, footer widget states, spilled-output details, and the summarizer-model-by-plan table live in **[doc/configuration.md](doc/configuration.md)**.
152
+
153
+ ## Relationship to the rest of the platform
154
+
155
+ pi-condense is the context-economy layer: it has no code dependency on the other three, but it is what keeps a long [pi-cohort](https://github.com/jjuraszek/pi-cohort) parallel fan-out or a long [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) gated run affordable as they grow, and research on summarization-based context management suggests it can also make a smaller driver model hold up better on long tasks (see [PRUNING.md § Research Evidence](PRUNING.md#why-summarization-works-research-evidence) - a cited hypothesis, not a benchmark run in this repo).
156
+
157
+ ## Roadmap
158
+
159
+ No committed roadmap beyond what's already tracked in [CHANGELOG.md](CHANGELOG.md); proposals and in-progress work show up there and in repo issues first.
246
160
 
247
161
  ## Support
248
162
 
249
163
  If this saves you tokens, [buy me a coffee](https://buymeacoffee.com/jjurasszek).
250
164
 
165
+ ## Lineage
166
+
167
+ Adds pre-flush safeguards, agent-message batching, chain compression, and an npm release flow on top of the original approach from [`championswimmer/pi-context-prune`](https://github.com/championswimmer/pi-context-prune).
168
+
251
169
  ## References
252
170
 
253
171
  - Anthropic prompt caching: <https://docs.claude.com/en/docs/build-with-claude/prompt-caching>
254
172
  - AWS Bedrock prompt caching: <https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html>
255
173
  - OpenAI prompt caching: <https://platform.openai.com/docs/guides/prompt-caching>
256
174
  - Research backing summarization-based context management: see [PRUNING.md § Research Evidence](PRUNING.md#why-summarization-works-research-evidence)
175
+
176
+ ## License
177
+
178
+ MIT - see [LICENSE](LICENSE).
package/index.ts CHANGED
@@ -36,6 +36,7 @@ import { PruneFrontierTracker } from "./src/frontier.js";
36
36
  import { BlockRefIssuer } from "./src/block-refs.js";
37
37
  import { compressEligible } from "./src/chain-compressor.js";
38
38
  import { detectChains, withClosingMessage } from "./src/chain-detector.js";
39
+ import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js";
39
40
  import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js";
40
41
  import { spillOversizedBatch } from "./src/spill.js";
41
42
 
@@ -530,6 +531,7 @@ export default function (pi: ExtensionAPI) {
530
531
  // message_end fires before pi persists the closing assistant, so thread it
531
532
  // in here; otherwise the newest chain reads as open and K over-retains by 1.
532
533
  const chains = detectChains(withClosingMessage(branchMessages, options.closingMessage), protectionPredicate);
534
+ const inGrace = inGraceRecoveryToolCallIds(branchMessages, currentConfig.value.recoveryGraceTurns);
533
535
  const { compressedEntries } = await compressEligible(
534
536
  chains,
535
537
  currentConfig.value.chainCompression.rollingWindow,
@@ -540,6 +542,7 @@ export default function (pi: ExtensionAPI) {
540
542
  now: () => Date.now(),
541
543
  fuseRange: makeFuseRange(ctx),
542
544
  },
545
+ inGrace,
543
546
  );
544
547
  if (compressedEntries.length > 0) {
545
548
  statsAccum.addChainsCompressed(compressedEntries.length);
@@ -825,6 +828,7 @@ export default function (pi: ExtensionAPI) {
825
828
  currentConfig.value.purgeErrors,
826
829
  currentConfig.value.thinkingStrip,
827
830
  currentConfig.value,
831
+ currentConfig.value.recoveryGraceTurns,
828
832
  );
829
833
  if (result.pruned) {
830
834
  messages = result.messages;
@@ -847,6 +851,7 @@ export default function (pi: ExtensionAPI) {
847
851
  .filter((e: any) => e.type === "message" && e.message)
848
852
  .map((e: any) => e.message);
849
853
  const chains = detectChains(branchMessages, protectionPredicate);
854
+ const inGrace = inGraceRecoveryToolCallIds(branchMessages, currentConfig.value.recoveryGraceTurns);
850
855
  const result = await compressEligible(
851
856
  chains,
852
857
  0, // effectiveK=0: compress every closed chain not already compressed
@@ -857,6 +862,7 @@ export default function (pi: ExtensionAPI) {
857
862
  now: () => Date.now(),
858
863
  fuseRange: makeFuseRange(ctx),
859
864
  },
865
+ inGrace,
860
866
  );
861
867
  if (result.compressedEntries.length > 0) {
862
868
  statsAccum.addChainsCompressed(result.compressedEntries.length);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.2.1",
3
+ "version": "2.4.0",
4
4
  "description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -27,7 +27,7 @@
27
27
  "ai"
28
28
  ],
29
29
  "engines": {
30
- "node": ">=20"
30
+ "node": ">=20.3.0"
31
31
  },
32
32
  "files": [
33
33
  "index.ts",
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, it, test } from "bun:test";
2
2
  import { selectEligible, compressEligible } from "./chain-compressor.js";
3
3
  import type { ChainCompressorIndexerDeps } from "./chain-compressor.js";
4
4
  import type { ChainRange, ChainCompressionEntry } from "./types.js";
@@ -281,3 +281,35 @@ describe("compressEligible", () => {
281
281
  expect(result.compressedEntries[0].rangeSummaryText).toBeUndefined();
282
282
  });
283
283
  });
284
+
285
+ describe("selectEligible - recovery grace deferral", () => {
286
+ const chain = (startTs: number, ids: string[]) =>
287
+ ({
288
+ startUserTimestamp: startTs,
289
+ finalAssistantTimestamp: startTs + 10,
290
+ middleToolCallIds: ids,
291
+ protectedToolCallIds: [],
292
+ }) as any;
293
+ it("defers a chain whose span holds an in-grace recovery id", () => {
294
+ const chains = [chain(1, ["a", "t1"]), chain(2, ["b"])];
295
+ const eligible = selectEligible(chains, 0, new Set(), new Set(["t1"]));
296
+ expect(eligible.map((c) => c.startUserTimestamp)).toEqual([2]);
297
+ });
298
+ it("compresses normally when no in-grace recovery id is present", () => {
299
+ const chains = [chain(1, ["a", "t1"]), chain(2, ["b"])];
300
+ const eligible = selectEligible(chains, 0, new Set(), new Set());
301
+ expect(eligible.map((c) => c.startUserTimestamp)).toEqual([1, 2]);
302
+ });
303
+ it("defers only the grace chain without shrinking the rolling-window buffer", () => {
304
+ // 5 eligible chains, rollingWindow=2 -> the window boundary is at n-W=3, so chains
305
+ // 1,2,3 are candidates for compression and 4,5 sit in the protected buffer.
306
+ // The in-grace id lives on chain 4, which is already outside the compress slice and
307
+ // must not affect it. A pre-slice filter (the bug) removes chain 4 from `candidates`
308
+ // before the boundary is computed, shrinking it to 4 items and shifting the cut to
309
+ // n-W=2 -> wrongly dropping chain 3 too. The fix computes the boundary first, so the
310
+ // in-buffer grace id changes nothing and the compress set stays [1, 2, 3].
311
+ const chains = [chain(1, ["a"]), chain(2, ["b"]), chain(3, ["c"]), chain(4, ["d", "t1"]), chain(5, ["e"])];
312
+ const eligible = selectEligible(chains, 2, new Set(), new Set(["t1"]));
313
+ expect(eligible.map((c) => c.startUserTimestamp)).toEqual([1, 2, 3]);
314
+ });
315
+ });
@@ -13,11 +13,16 @@ import type { BlockRefIssuer } from "./block-refs.js";
13
13
  * @param chains Must be in chronological order (oldest first), as emitted by
14
14
  * chain-detector. Ordering is not validated here; out-of-order input silently
15
15
  * picks wrong chains because the rolling-window slice is positional.
16
+ * @param inGraceToolCallIds Recovery ids still within their grace window. Chains
17
+ * spanning one of these ids are deferred from compression, but the rolling-window
18
+ * boundary itself is computed BEFORE grace exclusion, so a grace-protected chain
19
+ * never shrinks the window buffer or shifts which other chains become eligible.
16
20
  */
17
21
  export function selectEligible(
18
22
  chains: ChainRange[],
19
23
  rollingWindow: number,
20
24
  alreadyCompressed: Set<number>,
25
+ inGraceToolCallIds: Set<string> = new Set(),
21
26
  ): ChainRange[] {
22
27
  const candidates = chains.filter(
23
28
  (c) =>
@@ -25,7 +30,8 @@ export function selectEligible(
25
30
  !alreadyCompressed.has(c.startUserTimestamp) &&
26
31
  c.middleToolCallIds.length > 0,
27
32
  );
28
- return candidates.slice(0, Math.max(0, candidates.length - rollingWindow));
33
+ const toCompress = candidates.slice(0, Math.max(0, candidates.length - rollingWindow));
34
+ return toCompress.filter((c) => !c.middleToolCallIds.some((id) => inGraceToolCallIds.has(id)));
29
35
  }
30
36
 
31
37
  /**
@@ -71,6 +77,7 @@ export async function compressEligible(
71
77
  chains: ChainRange[],
72
78
  rollingWindow: number,
73
79
  deps: CompressEligibleDeps,
80
+ inGraceToolCallIds: Set<string> = new Set(),
74
81
  ): Promise<CompressEligibleResult> {
75
82
  const alreadyCompressedTimestamps = new Set(
76
83
  deps.indexer.getChainEntries().map((e) => e.startUserTimestamp),
@@ -85,7 +92,7 @@ export async function compressEligible(
85
92
  }
86
93
  }
87
94
 
88
- const eligible = selectEligible(chains, rollingWindow, alreadyCompressedTimestamps);
95
+ const eligible = selectEligible(chains, rollingWindow, alreadyCompressedTimestamps, inGraceToolCallIds);
89
96
 
90
97
  const compressedEntries: ChainCompressionEntry[] = [];
91
98
  for (const chain of eligible) {