pi-condense 2.3.0 → 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,10 @@ 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
+
12
16
  ## [2.3.0] - 2026-07-06
13
17
 
14
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).
package/README.md CHANGED
@@ -6,254 +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
- "recoveryGraceTurns": 3,
91
- "protectedTools": [],
92
- "protectedPaths": ["**/skills/**/*.md"],
93
- "dedupByContentHash": true,
94
- "autoBudgetThreshold": null,
95
- "spillThreshold": 65536,
96
- "spillPreviewBytes": 2048,
97
- "budgetTurnDelta": null,
98
- "chainCompression": {
99
- "enabled": true,
100
- "rollingWindow": 3,
101
- "stripFinalAssistantThinking": true,
102
- "fuseRangeSummary": true
103
- },
104
- "thinkingStrip": {
105
- "enabled": true,
106
- "keepLastTurns": 16
107
- }
108
- }
109
- }
110
- ```
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.
111
77
 
112
- | Key | Values | Default | Notes |
113
- |---|---|---|---|
114
- | `enabled` | `true` / `false` | `false` | Master switch |
115
- | `showPruneStatusLine` | `true` / `false` | `true` | Footer widget + queued-turn notifications |
116
- | `summarizerModel` | `"default"` or `"provider/model-id"` | `"default"` | `default` = your active pi model. See [Choosing a summarizer model](#choosing-a-summarizer-model) |
117
- | `summarizerThinking` | `default`/`off`/`minimal`/`low`/`medium`/`high`/`xhigh` | `default` | Provider-specific reasoning effort knob |
118
- | `pruneOn` | see table above | `agent-message` | Trigger mode |
119
- | `batchingMode` | `turn` / `agent-message` | `turn` | How coarse each summary is (independent of `pruneOn`) |
120
- | `quietOversizedSkips` | `true` / `false` | `false` | Silences `skipped-oversized` / `skipped-trivial` info notifications |
121
- | `minBatchChars` | non-negative integer, `0` disables | `1000` | Pre-flush guard — batches smaller than this skip the LLM entirely |
122
- | `recoveryGraceTurns` | non-negative integer (user-turn-groups), `0` disables | `3` | After a `context_tree_query` recovery, render that tool's output verbatim for this many user-turn-groups before re-stubbing it. Enforced at render time (Phase 1 + chain-compression eligibility), not at capture time. See [PRUNING.md § What Pruning Does](PRUNING.md#what-pruning-does) |
123
- | `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. |
124
- | `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. |
125
- | `dedupByContentHash` | `true` / `false` | `true` | Re-reads of identical (toolName, content) skip the LLM and alias the original |
126
- | `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) |
127
- | `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) |
128
- | `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. |
129
- | `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. |
130
- | `chainCompression.enabled` | `true` / `false` | `true` | Master toggle for chain-level range compression |
131
- | `chainCompression.rollingWindow` | positive integer | `3` | Keep this many most-recent closed chains raw; compress older ones |
132
- | `chainCompression.stripFinalAssistantThinking` | `true` / `false` | `true` | Strip thinking blocks from the kept final text-only assistant when compressing |
133
- | `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 |
134
- | `purgeErrors.enabled` | `true` / `false` | `true` | Replace failed toolCall argument bodies with compact stubs after cooldown |
135
- | `purgeErrors.cooldownTurns` | positive integer | `2` | Turns to wait after a tool error before purging its argument body |
136
- | `purgeErrors.minArgChars` | non-negative integer | `500` | Only purge arg bodies at least this many characters long |
137
- | `thinkingStrip.enabled` | `true` / `false` | `true` | Strip `thinking` blocks from assistant turns older than the last `keepLastTurns` |
138
- | `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
139
79
 
140
- 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) |
141
88
 
142
- 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
143
90
 
144
- ### 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.
145
92
 
146
- 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.
147
94
 
148
- - `0.8` means 80% of the context window — it is a **fraction**, not a percentage. `0.8 ≠ 80`.
149
- - The trigger is a no-op when `tokens` is `null` (right after a provider-side compaction); it resumes once usage is known again.
150
- - Editable live via `/pruner settings` (row "Auto-flush at context %", presets Off / 60 / 70 / 80 / 90%).
151
- - Default `null` = off.
95
+ ## Limitations
152
96
 
153
- 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.
154
101
 
155
- ### Spilled outputs
102
+ ## Install
156
103
 
157
- 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).
158
105
 
159
- ### Choosing a summarizer model
106
+ **User scope** (all repos under your pi profile):
160
107
 
161
- 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
+ ```
162
111
 
163
- 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`):
164
113
 
165
- | Plan | Suggested summarizer |
166
- |---|---|
167
- | OpenAI / Codex / Copilot | `openai/gpt-4.1-mini`, `google/gemini-2.5-flash`, `xai/grok-3-fast` |
168
- | OpenRouter | `openrouter/qwen/qwen3-30b-a3b` (cheap MoE) |
169
- | Anthropic direct | `anthropic/claude-haiku-3-5` |
170
- | Google AI direct | `google/gemini-2.5-flash` |
114
+ ```bash
115
+ pi install -l npm:pi-condense
116
+ ```
171
117
 
172
- Set it from the slash command (saves immediately):
118
+ **Try without installing**:
173
119
 
174
120
  ```bash
175
- /pruner model openai/gpt-4.1-mini
176
- /pruner thinking low
177
- # or both in one go:
178
- /pruner model openai/gpt-4.1-mini:low
121
+ pi -e npm:pi-condense
179
122
  ```
180
123
 
181
- ## Commands
124
+ **From a local checkout** (for hacking on the extension itself):
182
125
 
183
- | Command | Effect |
184
- |---|---|
185
- | `/pruner` | Interactive picker over all subcommands |
186
- | `/pruner settings` | Settings overlay (toggle / cycle every option) |
187
- | `/pruner on` / `off` | Enable / disable pruning |
188
- | `/pruner status` | Show mode, model, trigger, cumulative stats |
189
- | `/pruner stats` | Detailed cumulative summarizer token/cost stats |
190
- | `/pruner model [id\[:thinking\]]` | Get / set summarizer model (and optionally thinking level) |
191
- | `/pruner thinking [level]` | Get / set summarizer reasoning effort |
192
- | `/pruner prune-on [mode]` | Get / set trigger mode |
193
- | `/pruner batching [mode]` | Get / set batching granularity (`turn` / `agent-message`) |
194
- | `/pruner protected-tools [names]` | Show or edit the never-pruned tool allowlist (comma- or space-separated; `none` clears) |
195
- | `/pruner protected-paths [globs]` | Show or edit the never-pruned path globs (`none` clears) |
196
- | `/pruner min-batch-chars [n]` | Show or set the pre-flush trivial-batch threshold (`0` disables) |
197
- | `/pruner recovery-grace [n]` | Show or set the post-recovery verbatim grace window, in user-turn-groups (`0` disables) |
198
- | `/pruner dedup [on\|off\|status]` | Toggle pre-flush content-hash dedup |
199
- | `/pruner tree` | Foldable browser of pruned tool calls; `Ctrl-O` opens the full summary in an overlay |
200
- | `/pruner compact` | Retroactively compress every eligible closed chain (bypasses `rollingWindow`) |
201
- | `/pruner now` | Flush pending batches immediately with a multi-row progress widget above the input |
202
- | `/pruner help` | Full help text |
203
-
204
- ## Tools surfaced to the LLM
205
-
206
- **`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.
207
-
208
- ## Footer status widget
209
-
210
- A footer widget shows the current state, controlled by `showPruneStatusLine`:
211
-
212
- 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).
213
-
214
- - `│ prune: OFF │` — disabled
215
- - `│ prune: ON │` — enabled, no flushes yet
216
- - `│ prune: ON · 92k->14k (-85%) │` — enabled; live reclaim ratio (estimated tokens before→after, percent reduction). Updates on every `pruneMessages` call.
217
- - `│ prune: 3 pending │` — batches queued, waiting for the trigger
218
- - `│ prune: summarizing… │` — flush in progress
219
-
220
- Setting `showPruneStatusLine: false` hides the widget and silences the queued-turn notice; pruning still runs.
221
-
222
- 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).
223
-
224
- ## External cost channel
225
-
226
- 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:
227
-
228
- ```ts
229
- interface ExternalCostUpdate {
230
- source: string; // EXTERNAL_COST_SOURCE = "pi-condense"
231
- totalCost: number; // cumulative cost for the current session (USD)
232
- inputTokens?: number;
233
- outputTokens?: number;
234
- }
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
235
132
  ```
236
133
 
237
- 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.
238
135
 
239
- - **Cumulative per session**, not all-time. Re-emitted on every update; aggregators key by `source` and replace the previous value.
240
- - **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.
241
- - 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.
242
137
 
243
- ## Limitations
138
+ ## Configuration - the knobs most people touch
244
139
 
245
- - Pruning only applies to batches captured *while enabled*. Enabling mid-session does not retroactively summarize earlier turns.
246
- - 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.
247
- - 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.
248
- - 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.
249
160
 
250
161
  ## Support
251
162
 
252
163
  If this saves you tokens, [buy me a coffee](https://buymeacoffee.com/jjurasszek).
253
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
+
254
169
  ## References
255
170
 
256
171
  - Anthropic prompt caching: <https://docs.claude.com/en/docs/build-with-claude/prompt-caching>
257
172
  - AWS Bedrock prompt caching: <https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html>
258
173
  - OpenAI prompt caching: <https://platform.openai.com/docs/guides/prompt-caching>
259
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.3.0",
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",
package/src/commands.ts CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  SUMMARIZER_THINKING_LEVELS,
13
13
  MIN_BATCH_CHARS_PRESETS,
14
14
  RECOVERY_GRACE_PRESETS,
15
+ SUMMARIZER_IDLE_TIMEOUT_PRESETS,
16
+ SUMMARIZER_MAX_TIMEOUT_PRESETS,
15
17
  AUTO_BUDGET_PRESETS,
16
18
  ROLLING_WINDOW_PRESETS,
17
19
  KEEP_LAST_TURNS_PRESETS,
@@ -199,6 +201,19 @@ function recoveryGraceDescription(config: ContextPruneConfig): string {
199
201
  return `context_tree_query (recovery) output stays verbatim for ${config.recoveryGraceTurns} user-turn-group(s) after recovery, then reverts to the stub. Bounds the recover->re-stub->re-query loop. Currently ${config.recoveryGraceTurns}. Set to 0 to disable.`;
200
202
  }
201
203
 
204
+ function idleTimeoutDescription(config: ContextPruneConfig): string {
205
+ if (config.summarizerIdleTimeoutMs === 0) {
206
+ return "Summarizer idle timeout DISABLED - a stalled stream is only bounded by the ceiling (or not at all if that is 0 too).";
207
+ }
208
+ return `Abort a summarizer call after ${Math.round(config.summarizerIdleTimeoutMs / 1000)}s of silence (no stream event). Resets on every event, so it never aborts a flowing generation; a timeout feeds the same outage-fallback retry as a provider error. Set 0 to disable.`;
209
+ }
210
+ function maxTimeoutDescription(config: ContextPruneConfig): string {
211
+ if (config.summarizerMaxTimeoutMs === 0) {
212
+ return "Summarizer total-duration ceiling DISABLED - only the idle timeout bounds a call.";
213
+ }
214
+ return `Hard ceiling on total duration of a single summarizer call: ${Math.round(config.summarizerMaxTimeoutMs / 1000)}s. Backstop for a stream that dribbles forever without going idle. Set 0 to disable.`;
215
+ }
216
+
202
217
  function autoBudgetThresholdDescription(config: ContextPruneConfig): string {
203
218
  if (config.autoBudgetThreshold == null) {
204
219
  return `Token-budget auto-flush: force a prune when context usage reaches this share of the window, regardless of prune-on mode. Currently off. Pick a percentage to enable.`;
@@ -566,6 +581,24 @@ export function registerCommands(
566
581
  : RECOVERY_GRACE_PRESETS[2].value,
567
582
  description: recoveryGraceDescription(config),
568
583
  },
584
+ {
585
+ id: "summarizerIdleTimeoutMs",
586
+ label: "Summarizer idle timeout",
587
+ values: SUMMARIZER_IDLE_TIMEOUT_PRESETS.map((p) => p.value),
588
+ currentValue: SUMMARIZER_IDLE_TIMEOUT_PRESETS.some((p) => p.value === String(config.summarizerIdleTimeoutMs))
589
+ ? String(config.summarizerIdleTimeoutMs)
590
+ : (SUMMARIZER_IDLE_TIMEOUT_PRESETS.find((p) => p.value === String(DEFAULT_CONFIG.summarizerIdleTimeoutMs))?.value ?? SUMMARIZER_IDLE_TIMEOUT_PRESETS[0].value), // fall back to the default preset if a custom value isn't in the cycle
591
+ description: idleTimeoutDescription(config),
592
+ },
593
+ {
594
+ id: "summarizerMaxTimeoutMs",
595
+ label: "Summarizer max timeout",
596
+ values: SUMMARIZER_MAX_TIMEOUT_PRESETS.map((p) => p.value),
597
+ currentValue: SUMMARIZER_MAX_TIMEOUT_PRESETS.some((p) => p.value === String(config.summarizerMaxTimeoutMs))
598
+ ? String(config.summarizerMaxTimeoutMs)
599
+ : (SUMMARIZER_MAX_TIMEOUT_PRESETS.find((p) => p.value === String(DEFAULT_CONFIG.summarizerMaxTimeoutMs))?.value ?? SUMMARIZER_MAX_TIMEOUT_PRESETS[0].value), // fall back to the default preset if a custom value isn't in the cycle
600
+ description: maxTimeoutDescription(config),
601
+ },
569
602
  {
570
603
  id: "autoBudgetThreshold",
571
604
  label: "Auto-flush at context %",
@@ -732,6 +765,16 @@ export function registerCommands(
732
765
  if (rgItem) {
733
766
  rgItem.description = recoveryGraceDescription(newConfig);
734
767
  }
768
+ } else if (id === "summarizerIdleTimeoutMs") {
769
+ const parsed = Number.parseInt(newValue, 10);
770
+ newConfig.summarizerIdleTimeoutMs = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CONFIG.summarizerIdleTimeoutMs;
771
+ const it = items.find((item) => item.id === "summarizerIdleTimeoutMs");
772
+ if (it) it.description = idleTimeoutDescription(newConfig);
773
+ } else if (id === "summarizerMaxTimeoutMs") {
774
+ const parsed = Number.parseInt(newValue, 10);
775
+ newConfig.summarizerMaxTimeoutMs = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CONFIG.summarizerMaxTimeoutMs;
776
+ const it = items.find((item) => item.id === "summarizerMaxTimeoutMs");
777
+ if (it) it.description = maxTimeoutDescription(newConfig);
735
778
  } else if (id === "autoBudgetThreshold") {
736
779
  const parsed = Number.parseFloat(newValue);
737
780
  newConfig.autoBudgetThreshold =
@@ -839,8 +882,9 @@ export function registerCommands(
839
882
  const statsLine = s.callCount > 0
840
883
  ? `\n --- summarizer ---\n calls: ${s.callCount}\n input: ${formatTokens(s.totalInputTokens)} tokens\n output: ${formatTokens(s.totalOutputTokens)} tokens\n cost: ${formatCost(s.totalCost)}`
841
884
  : "\n (no summarizer calls yet)";
885
+ const fmtTimeout = (ms: number) => (ms === 0 ? "disabled" : `${Math.round(ms / 1000)}s`);
842
886
  ctx.ui.notify(
843
- `pruner status:\n enabled: ${cfg.enabled}\n model: ${cfg.summarizerModel}\n thinking: ${summarizerThinkingLabel(cfg.summarizerThinking)} (${cfg.summarizerThinking})\n trigger: ${mode}\n batching: ${batchingModeLabel(cfg.batchingMode)} (${cfg.batchingMode})\n dedup: ${cfg.dedupByContentHash ? "on" : "off"}\n status: ${cfg.showPruneStatusLine ? "on" : "off"}${statsLine}`,
887
+ `pruner status:\n enabled: ${cfg.enabled}\n model: ${cfg.summarizerModel}\n thinking: ${summarizerThinkingLabel(cfg.summarizerThinking)} (${cfg.summarizerThinking})\n idle to: ${fmtTimeout(cfg.summarizerIdleTimeoutMs)}\n max to: ${fmtTimeout(cfg.summarizerMaxTimeoutMs)}\n trigger: ${mode}\n batching: ${batchingModeLabel(cfg.batchingMode)} (${cfg.batchingMode})\n dedup: ${cfg.dedupByContentHash ? "on" : "off"}\n status: ${cfg.showPruneStatusLine ? "on" : "off"}${statsLine}`,
844
888
  );
845
889
  break;
846
890
  }
@@ -5,22 +5,23 @@ import { join } from "node:path";
5
5
  import { DEFAULT_CONFIG } from "./types.js";
6
6
 
7
7
  /**
8
- * config.ts computes SETTINGS_PATH from getAgentDir() at module-load time, so
9
- * PI_CODING_AGENT_DIR must be set before the module is imported. normalize()
10
- * itself isn't exported; loadConfig() is the only public entry point that
11
- * exercises it, so these tests drive normalization indirectly by writing
12
- * settings.json into an isolated agent dir and reading it back.
8
+ * config.ts resolves the settings path from getAgentDir() lazily on each
9
+ * read/write, so PI_CODING_AGENT_DIR set here is honored regardless of import
10
+ * order (bun shares the module registry across test files). normalize() itself
11
+ * isn't exported; loadConfig() is the only public entry point that exercises
12
+ * it, so these tests drive normalization indirectly by writing settings.json
13
+ * into an isolated agent dir and reading it back.
13
14
  */
14
15
  let tmpDir: string;
15
16
  let loadConfig: typeof import("./config.js").loadConfig;
16
- let SETTINGS_PATH: string;
17
+ let settingsPath: typeof import("./config.js").settingsPath;
17
18
 
18
19
  beforeAll(async () => {
19
20
  tmpDir = await mkdtemp(join(tmpdir(), "pi-condense-config-test-"));
20
21
  process.env.PI_CODING_AGENT_DIR = tmpDir;
21
22
  const mod = await import("./config.js");
22
23
  loadConfig = mod.loadConfig;
23
- SETTINGS_PATH = mod.SETTINGS_PATH;
24
+ settingsPath = mod.settingsPath;
24
25
  });
25
26
 
26
27
  afterAll(async () => {
@@ -29,7 +30,7 @@ afterAll(async () => {
29
30
  });
30
31
 
31
32
  async function writeContextPrune(overrides: Record<string, unknown>): Promise<void> {
32
- await writeFile(SETTINGS_PATH, JSON.stringify({ contextPrune: overrides }));
33
+ await writeFile(settingsPath(), JSON.stringify({ contextPrune: overrides }));
33
34
  }
34
35
 
35
36
  describe("loadConfig recoveryGraceTurns normalization", () => {
@@ -63,3 +64,38 @@ describe("loadConfig recoveryGraceTurns normalization", () => {
63
64
  expect(config.recoveryGraceTurns).toBe(DEFAULT_CONFIG.recoveryGraceTurns);
64
65
  });
65
66
  });
67
+
68
+ describe("loadConfig summarizer timeout normalization", () => {
69
+ it("defaults both timeouts when absent", async () => {
70
+ await writeContextPrune({});
71
+ const config = await loadConfig();
72
+ expect(config.summarizerIdleTimeoutMs).toBe(DEFAULT_CONFIG.summarizerIdleTimeoutMs);
73
+ expect(config.summarizerMaxTimeoutMs).toBe(DEFAULT_CONFIG.summarizerMaxTimeoutMs);
74
+ });
75
+
76
+ it("preserves explicit 0 (disabled) for both", async () => {
77
+ await writeContextPrune({ summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 0 });
78
+ const config = await loadConfig();
79
+ expect(config.summarizerIdleTimeoutMs).toBe(0);
80
+ expect(config.summarizerMaxTimeoutMs).toBe(0);
81
+ });
82
+
83
+ it("falls back to default for a negative idle timeout", async () => {
84
+ await writeContextPrune({ summarizerIdleTimeoutMs: -5 });
85
+ const config = await loadConfig();
86
+ expect(config.summarizerIdleTimeoutMs).toBe(DEFAULT_CONFIG.summarizerIdleTimeoutMs);
87
+ });
88
+
89
+ it("falls back to default for NaN max timeout", async () => {
90
+ // JSON.stringify serializes NaN to null; normalize's typeof-number guard rejects it.
91
+ await writeContextPrune({ summarizerMaxTimeoutMs: Number.NaN });
92
+ const config = await loadConfig();
93
+ expect(config.summarizerMaxTimeoutMs).toBe(DEFAULT_CONFIG.summarizerMaxTimeoutMs);
94
+ });
95
+
96
+ it("floors a fractional idle timeout", async () => {
97
+ await writeContextPrune({ summarizerIdleTimeoutMs: 1234.9 });
98
+ const config = await loadConfig();
99
+ expect(config.summarizerIdleTimeoutMs).toBe(1234);
100
+ });
101
+ });
package/src/config.ts CHANGED
@@ -15,8 +15,14 @@ import { DEFAULT_CONFIG, PRUNE_ON_MODES, SUMMARIZER_THINKING_LEVELS } from "./ty
15
15
  * Resolved against `getAgentDir()` so it honors `PI_CODING_AGENT_DIR`
16
16
  * (defaults to `~/.pi/agent`). Each pi preset directory therefore gets its
17
17
  * own context-prune config — including its own summarizer model.
18
+ *
19
+ * Computed lazily on each read/write rather than frozen at module load, so the
20
+ * resolved path always reflects the current `PI_CODING_AGENT_DIR` regardless of
21
+ * when the module was first imported.
18
22
  */
19
- export const SETTINGS_PATH = join(getAgentDir(), "settings.json");
23
+ export function settingsPath(): string {
24
+ return join(getAgentDir(), "settings.json");
25
+ }
20
26
 
21
27
  /** Top-level key under which context-prune state lives in `settings.json`. */
22
28
  export const SETTINGS_KEY = "contextPrune" as const;
@@ -52,6 +58,18 @@ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
52
58
  merged.minBatchChars >= 0
53
59
  ? Math.floor(merged.minBatchChars)
54
60
  : DEFAULT_CONFIG.minBatchChars,
61
+ summarizerIdleTimeoutMs:
62
+ typeof merged.summarizerIdleTimeoutMs === "number" &&
63
+ Number.isFinite(merged.summarizerIdleTimeoutMs) &&
64
+ merged.summarizerIdleTimeoutMs >= 0
65
+ ? Math.floor(merged.summarizerIdleTimeoutMs)
66
+ : DEFAULT_CONFIG.summarizerIdleTimeoutMs,
67
+ summarizerMaxTimeoutMs:
68
+ typeof merged.summarizerMaxTimeoutMs === "number" &&
69
+ Number.isFinite(merged.summarizerMaxTimeoutMs) &&
70
+ merged.summarizerMaxTimeoutMs >= 0
71
+ ? Math.floor(merged.summarizerMaxTimeoutMs)
72
+ : DEFAULT_CONFIG.summarizerMaxTimeoutMs,
55
73
  recoveryGraceTurns:
56
74
  typeof merged.recoveryGraceTurns === "number" &&
57
75
  Number.isFinite(merged.recoveryGraceTurns) &&
@@ -106,7 +124,7 @@ async function readJsonObject(path: string): Promise<Record<string, unknown> | u
106
124
 
107
125
  /** Reads `<agent-dir>/settings.json` and returns the `contextPrune` block, or defaults. */
108
126
  export async function loadConfig(): Promise<ContextPruneConfig> {
109
- const main = await readJsonObject(SETTINGS_PATH);
127
+ const main = await readJsonObject(settingsPath());
110
128
  const namespaced = main?.[SETTINGS_KEY];
111
129
  if (namespaced && typeof namespaced === "object" && !Array.isArray(namespaced)) {
112
130
  return normalize(namespaced as Partial<ContextPruneConfig>);
@@ -123,10 +141,11 @@ export async function loadConfig(): Promise<ContextPruneConfig> {
123
141
  * last-write-wins race only loses a single change, never corrupts the file.
124
142
  */
125
143
  export async function saveConfig(config: ContextPruneConfig): Promise<void> {
126
- const current = (await readJsonObject(SETTINGS_PATH)) ?? {};
144
+ const path = settingsPath();
145
+ const current = (await readJsonObject(path)) ?? {};
127
146
  const next = { ...current, [SETTINGS_KEY]: config };
128
- await mkdir(dirname(SETTINGS_PATH), { recursive: true });
129
- const tmpPath = `${SETTINGS_PATH}.${randomBytes(8).toString("hex")}.tmp`;
147
+ await mkdir(dirname(path), { recursive: true });
148
+ const tmpPath = `${path}.${randomBytes(8).toString("hex")}.tmp`;
130
149
  await writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`);
131
- await rename(tmpPath, SETTINGS_PATH);
150
+ await rename(tmpPath, path);
132
151
  }
@@ -2,11 +2,11 @@ import { describe, it, expect, mock } from "bun:test";
2
2
 
3
3
  // Stub pi-ai's `stream` so runSummarization can be exercised without a network
4
4
  // call. `streamImpl` is swapped per test to simulate primary/fallback outcomes.
5
- let streamImpl: (model: any) => any = () => {
5
+ let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
6
6
  throw new Error("streamImpl not set");
7
7
  };
8
8
  mock.module("@earendil-works/pi-ai", () => ({
9
- stream: (model: any) => streamImpl(model),
9
+ stream: (...args: any[]) => streamImpl(...args),
10
10
  }));
11
11
 
12
12
  const { summarizeBatch } = await import("./summarizer.js");
@@ -45,6 +45,61 @@ function errStream(message: string) {
45
45
  };
46
46
  }
47
47
 
48
+ // Hangs until `opts.signal` (the combined caller+timeout signal runOnce
49
+ // passes to stream()) aborts. With no signal it never settles.
50
+ function hangingStream(opts: any) {
51
+ const signal: AbortSignal | undefined = opts?.signal;
52
+ const untilAbort = () =>
53
+ new Promise<never>((_, reject) => {
54
+ if (!signal) return; // no signal => never settles
55
+ if (signal.aborted) return reject(new Error("aborted"));
56
+ signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
57
+ });
58
+ return {
59
+ async *[Symbol.asyncIterator]() {
60
+ await untilAbort();
61
+ },
62
+ async result() {
63
+ return untilAbort();
64
+ },
65
+ };
66
+ }
67
+
68
+ // Emits `events` thinking_delta events spaced `gapMs` apart, then completes
69
+ // successfully — UNLESS `opts.signal` aborts mid-drip, in which case the
70
+ // current sleep rejects, exactly like a real provider stream cancelling on
71
+ // abort. This is what gives the idle-reset test teeth: if runOnce's in-loop
72
+ // bumpIdle() is ever removed, the idle timer fires at the configured window
73
+ // and the combined signal aborts, so this stream rejects instead of
74
+ // completing — the test then fails instead of passing vacuously.
75
+ function drippingStream(opts: any, text: string, events: number, gapMs: number) {
76
+ const signal: AbortSignal | undefined = opts?.signal;
77
+ const sleepOrAbort = (ms: number) =>
78
+ new Promise<void>((resolve, reject) => {
79
+ if (signal?.aborted) return reject(new Error("aborted"));
80
+ const timer = setTimeout(resolve, ms);
81
+ signal?.addEventListener(
82
+ "abort",
83
+ () => {
84
+ clearTimeout(timer);
85
+ reject(new Error("aborted"));
86
+ },
87
+ { once: true }
88
+ );
89
+ });
90
+ return {
91
+ async *[Symbol.asyncIterator]() {
92
+ for (let i = 0; i < events; i++) {
93
+ await sleepOrAbort(gapMs);
94
+ yield { type: "thinking_delta" };
95
+ }
96
+ },
97
+ async result() {
98
+ return { stopReason: "stop", content: [{ type: "text", text }], usage: USAGE };
99
+ },
100
+ };
101
+ }
102
+
48
103
  interface Note {
49
104
  msg: string;
50
105
  level: string;
@@ -163,3 +218,90 @@ describe("runSummarization wiring — abort", () => {
163
218
  ).rejects.toThrow();
164
219
  });
165
220
  });
221
+
222
+ describe("runSummarization wiring — timeouts", () => {
223
+ it("idle timeout (default model): transient warning, returns null", async () => {
224
+ streamImpl = (_m, _i, opts) => hangingStream(opts);
225
+ const notes: Note[] = [];
226
+ const ctx = makeCtx(notes);
227
+ const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 20, summarizerMaxTimeoutMs: 0 };
228
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
229
+ expect(r).toBeNull();
230
+ const warnings = notes.filter((n) => n.level === "warning");
231
+ expect(warnings).toHaveLength(1);
232
+ expect(warnings[0].msg).toMatch(/stalled/);
233
+ expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
234
+ });
235
+
236
+ it("ceiling timeout (idle disabled): transient warning mentioning ceiling", async () => {
237
+ streamImpl = (_m, _i, opts) => hangingStream(opts);
238
+ const notes: Note[] = [];
239
+ const ctx = makeCtx(notes);
240
+ const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 20 };
241
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
242
+ expect(r).toBeNull();
243
+ const warnings = notes.filter((n) => n.level === "warning");
244
+ expect(warnings).toHaveLength(1);
245
+ expect(warnings[0].msg).toMatch(/ceiling/);
246
+ });
247
+
248
+ it("option B: primary idle-times-out, session model rescues", async () => {
249
+ streamImpl = (model, _i, opts) => (model.id === PRIMARY.id ? hangingStream(opts) : okStream("- rescued"));
250
+ const notes: Note[] = [];
251
+ const ctx = makeCtx(notes);
252
+ const controller = new FallbackController();
253
+ const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 };
254
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, { controller });
255
+ expect(r?.summaryText).toBe("- rescued");
256
+ expect(controller.inFallback).toBe(true);
257
+ expect(notes.filter((n) => n.level === "warning")).toHaveLength(1); // generic "enter" fallback warning
258
+ expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
259
+ });
260
+
261
+ it("both time out: null, both-down notice at warning severity", async () => {
262
+ streamImpl = (_m, _i, opts) => hangingStream(opts);
263
+ const notes: Note[] = [];
264
+ const ctx = makeCtx(notes);
265
+ const controller = new FallbackController();
266
+ const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 };
267
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, { controller });
268
+ expect(r).toBeNull();
269
+ expect(notes.filter((n) => n.level === "warning")).toHaveLength(1);
270
+ expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
271
+ });
272
+
273
+ it("pre-aborted signal is not a timeout (throws, no warning)", async () => {
274
+ streamImpl = (_m, _i, opts) => hangingStream(opts);
275
+ const notes: Note[] = [];
276
+ const ctx = makeCtx(notes);
277
+ const ac = new AbortController();
278
+ ac.abort();
279
+ const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 };
280
+ await expect(summarizeBatch(makeBatch(), cfg, ctx, { signal: ac.signal })).rejects.toThrow();
281
+ expect(notes.filter((n) => n.level === "warning")).toHaveLength(0);
282
+ });
283
+
284
+ it("both timeouts disabled: okStream succeeds unchanged", async () => {
285
+ streamImpl = () => okStream("- ok");
286
+ const notes: Note[] = [];
287
+ const ctx = makeCtx(notes);
288
+ const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 0 };
289
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
290
+ expect(r?.summaryText).toBe("- ok");
291
+ expect(notes).toHaveLength(0);
292
+ });
293
+ });
294
+
295
+ describe("runSummarization wiring — idle reset keeps a flowing stream alive", () => {
296
+ it("does not time out while events keep arriving within the idle window", async () => {
297
+ // 6 events, 10ms apart = 60ms total > 25ms idle window; only survives if
298
+ // the idle timer resets on every event (bumpIdle() inside the loop).
299
+ streamImpl = (_m, _i, opts) => drippingStream(opts, "- flowing summary", 6, 10);
300
+ const notes: Note[] = [];
301
+ const ctx = makeCtx(notes);
302
+ const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 25, summarizerMaxTimeoutMs: 0 };
303
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
304
+ expect(r?.summaryText).toBe("- flowing summary");
305
+ expect(notes.filter((n) => n.level === "warning")).toHaveLength(0);
306
+ });
307
+ });
package/src/summarizer.ts CHANGED
@@ -90,7 +90,7 @@ type RunOutcome =
90
90
  | { kind: "ok"; result: SummarizeResult }
91
91
  | { kind: "auth"; message: string }
92
92
  | { kind: "unusable" }
93
- | { kind: "transient"; message: string };
93
+ | { kind: "transient"; message: string; timedOut?: boolean };
94
94
 
95
95
  /** Human label for a model in notify text: prefer name, fall back to provider/id. */
96
96
  function modelLabel(model: any): string {
@@ -98,6 +98,14 @@ function modelLabel(model: any): string {
98
98
  return model.name || `${model.provider}/${model.id}`;
99
99
  }
100
100
 
101
+ /** Combines any present abort signals into one; undefined if none are given. */
102
+ function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal | undefined {
103
+ const present = signals.filter((s): s is AbortSignal => !!s);
104
+ if (present.length === 0) return undefined;
105
+ if (present.length === 1) return present[0];
106
+ return AbortSignal.any(present); // Node 20+; host runtime is node 24.5.0
107
+ }
108
+
101
109
  /**
102
110
  * One summarization attempt against a specific model. Returns a classified
103
111
  * outcome instead of throwing (except aborts, which propagate so flushPending
@@ -113,6 +121,29 @@ async function runOnce(
113
121
  ctx: ExtensionContext,
114
122
  options: SummarizeBatchOptions
115
123
  ): Promise<RunOutcome> {
124
+ const idleMs = config.summarizerIdleTimeoutMs;
125
+ const maxMs = config.summarizerMaxTimeoutMs;
126
+ const timeoutController = new AbortController();
127
+ let timedOut = false;
128
+ let timeoutKind: "idle" | "ceiling" | null = null;
129
+ let idleTimerId: ReturnType<typeof setTimeout> | null = null;
130
+ let ceilingTimerId: ReturnType<typeof setTimeout> | null = null;
131
+
132
+ const bumpIdle = () => {
133
+ if (idleTimerId !== null) clearTimeout(idleTimerId);
134
+ if (idleMs > 0) {
135
+ idleTimerId = setTimeout(() => {
136
+ timedOut = true;
137
+ timeoutKind = "idle";
138
+ timeoutController.abort();
139
+ }, idleMs);
140
+ }
141
+ };
142
+ const timeoutMessage = () =>
143
+ timeoutKind === "ceiling"
144
+ ? `summarizer ${modelLabel(model)} exceeded ${Math.round(maxMs / 1000)}s ceiling`
145
+ : `summarizer ${modelLabel(model)} stalled (no output for ${Math.round(idleMs / 1000)}s)`;
146
+
116
147
  try {
117
148
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
118
149
  if (!auth.ok) {
@@ -120,8 +151,8 @@ async function runOnce(
120
151
  return { kind: "auth", message: authMessage };
121
152
  }
122
153
 
123
- // Pass the abort signal so the underlying fetch is cancelled immediately
124
- // when the user presses Esc while the tool is running.
154
+ // Pass the combined signal so the underlying fetch is cancelled immediately
155
+ // either when the user presses Esc, or when an idle/ceiling timeout fires.
125
156
  const responseStream = stream(
126
157
  model,
127
158
  {
@@ -133,9 +164,25 @@ async function runOnce(
133
164
  },
134
165
  ],
135
166
  },
136
- { apiKey: auth.apiKey, headers: auth.headers, signal: options.signal, ...summarizerThinkingOptions(config) }
167
+ {
168
+ apiKey: auth.apiKey,
169
+ headers: auth.headers,
170
+ signal: combineSignals(options.signal, timeoutController.signal),
171
+ ...summarizerThinkingOptions(config),
172
+ }
137
173
  );
138
174
 
175
+ // Ceiling arms once at call start; idle arms/resets on every stream event
176
+ // (including before the first one, so it also bounds time-to-first-token).
177
+ if (maxMs > 0) {
178
+ ceilingTimerId = setTimeout(() => {
179
+ timedOut = true;
180
+ timeoutKind ??= "ceiling";
181
+ timeoutController.abort();
182
+ }, maxMs);
183
+ }
184
+ bumpIdle();
185
+
139
186
  let lastReportedChars = -1;
140
187
  options.onTextProgress?.(0);
141
188
  const reportTextProgress = (message: AssistantMessage) => {
@@ -147,6 +194,10 @@ async function runOnce(
147
194
  };
148
195
 
149
196
  for await (const event of responseStream) {
197
+ // Reset idle on ANY event (text_* and thinking_*), not just text — a
198
+ // reasoning-heavy model stays alive via thinking_delta and is never
199
+ // false-aborted for being quiet on text while it reasons.
200
+ bumpIdle();
150
201
  // Belt-and-suspenders: break early when signal fires mid-stream.
151
202
  if (options.signal?.aborted) break;
152
203
  if (event.type === "text_start" || event.type === "text_delta" || event.type === "text_end") {
@@ -167,6 +218,7 @@ async function runOnce(
167
218
  throw new Error("summarize: stream stopped with reason aborted");
168
219
  }
169
220
  if (response.stopReason === "error") {
221
+ if (timedOut) return { kind: "transient", message: timeoutMessage(), timedOut: true };
170
222
  return { kind: "transient", message: response.errorMessage ?? "Summarizer stopped with reason: error" };
171
223
  }
172
224
 
@@ -182,7 +234,11 @@ async function runOnce(
182
234
  // Propagate abort errors upward so flushPending can check signal.aborted
183
235
  // and return { ok: false, reason: "aborted" } without showing a UI error.
184
236
  if (options.signal?.aborted) throw err;
237
+ if (timedOut) return { kind: "transient", message: timeoutMessage(), timedOut: true };
185
238
  return { kind: "transient", message: err.message };
239
+ } finally {
240
+ if (idleTimerId !== null) clearTimeout(idleTimerId);
241
+ if (ceilingTimerId !== null) clearTimeout(ceilingTimerId);
186
242
  }
187
243
  }
188
244
 
@@ -211,8 +267,13 @@ async function runSummarization(
211
267
  const controller = options.controller;
212
268
  const sessionModel = ctx.model;
213
269
 
214
- const notifyError = (msg: string) =>
215
- ctx.ui.notify(`pruner: summarization failed: ${msg}`, "error");
270
+ const notifyFailure = (o: { message: string; timedOut?: boolean }) =>
271
+ ctx.ui.notify(
272
+ o.timedOut
273
+ ? `pi-condense: ${o.message}; summarizer call abandoned`
274
+ : `pruner: summarization failed: ${o.message}`,
275
+ o.timedOut ? "warning" : "error",
276
+ );
216
277
 
217
278
  // No controller or no distinct fallback: single attempt, legacy behavior.
218
279
  if (!controller || !FallbackController.hasDistinctFallback(primary, sessionModel)) {
@@ -222,7 +283,7 @@ async function runSummarization(
222
283
  return r.result;
223
284
  case "auth":
224
285
  case "transient":
225
- notifyError(r.message);
286
+ notifyFailure(r);
226
287
  return null;
227
288
  case "unusable":
228
289
  return null;
@@ -250,14 +311,14 @@ async function runSummarization(
250
311
  else emit(controller.onFallbackSuccess());
251
312
  return r.result;
252
313
  case "auth":
253
- notifyError(r.message); // auth never trips the controller
314
+ notifyFailure(r); // auth never trips the controller
254
315
  return null;
255
316
  case "unusable":
256
317
  return null; // probe unusable => stay (no state change)
257
318
  case "transient": {
258
319
  if (decision.target === "fallback") {
259
320
  controller.onFallbackOnlyFail();
260
- notifyError(r.message);
321
+ notifyFailure(r);
261
322
  return null;
262
323
  }
263
324
  // target was primary (initial detection or probe): retry once on the session model.
@@ -267,7 +328,7 @@ async function runSummarization(
267
328
  return r2.result; // suppress the legacy error notify — fallback rescued the call
268
329
  }
269
330
  controller.onBothDown();
270
- notifyError(r2.kind === "transient" || r2.kind === "auth" ? r2.message : r.message);
331
+ notifyFailure(r2.kind === "transient" || r2.kind === "auth" ? r2 : r);
271
332
  return null;
272
333
  }
273
334
  }
package/src/types.ts CHANGED
@@ -202,6 +202,30 @@ export const RECOVERY_GRACE_PRESETS: { value: string; label: string }[] = [
202
202
  { value: "8", label: "8" },
203
203
  ];
204
204
 
205
+ /**
206
+ * Cycling presets for `summarizerIdleTimeoutMs` (stored as strings; the
207
+ * settings UI cycles string values). "0" is the disabling sentinel.
208
+ */
209
+ export const SUMMARIZER_IDLE_TIMEOUT_PRESETS: { value: string; label: string }[] = [
210
+ { value: "0", label: "0 (disabled)" },
211
+ { value: "10000", label: "10s" },
212
+ { value: "20000", label: "20s (default)" },
213
+ { value: "45000", label: "45s" },
214
+ { value: "90000", label: "90s" },
215
+ ];
216
+
217
+ /**
218
+ * Cycling presets for `summarizerMaxTimeoutMs` (stored as strings). "0" is
219
+ * the disabling sentinel - no total-duration ceiling.
220
+ */
221
+ export const SUMMARIZER_MAX_TIMEOUT_PRESETS: { value: string; label: string }[] = [
222
+ { value: "0", label: "0 (disabled)" },
223
+ { value: "120000", label: "120s" },
224
+ { value: "180000", label: "180s (default)" },
225
+ { value: "300000", label: "300s" },
226
+ { value: "600000", label: "600s" },
227
+ ];
228
+
205
229
  /**
206
230
  * Cycling presets for the `autoBudgetThreshold` setting (stored as strings;
207
231
  * the settings UI cycles string values). "0" is the disabled sentinel → null.
@@ -281,6 +305,21 @@ export interface ContextPruneConfig {
281
305
  * (Phase 1) and chain-compressor.ts (eligibility), not at capture.
282
306
  */
283
307
  recoveryGraceTurns: number;
308
+ /**
309
+ * Idle (inactivity) timeout for a single summarizer stream call, in ms.
310
+ * Reset on every received stream event; armed before the first event so it
311
+ * also bounds time-to-first-token. If no event arrives within this window
312
+ * the call is aborted and classified transient (feeds the outage-fallback
313
+ * retry). 0 disables the idle timer. Default 20000.
314
+ */
315
+ summarizerIdleTimeoutMs: number;
316
+ /**
317
+ * Total-duration ceiling for a single summarizer stream call, in ms. Armed
318
+ * once at call start, never reset - a hard upper bound catching a stream
319
+ * that keeps dribbling events but never completes. Same transient/warning
320
+ * handling as the idle timeout. 0 disables the ceiling. Default 180000.
321
+ */
322
+ summarizerMaxTimeoutMs: number;
284
323
  /**
285
324
  * Tool names whose outputs must NEVER be pruned or summarized. Tool calls
286
325
  * with matching `toolName` are filtered out of the pruning capture path so
@@ -480,6 +519,8 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
480
519
  quietOversizedSkips: false,
481
520
  minBatchChars: 1000,
482
521
  recoveryGraceTurns: 3,
522
+ summarizerIdleTimeoutMs: 20000,
523
+ summarizerMaxTimeoutMs: 180000,
483
524
  protectedTools: [],
484
525
  protectedPaths: ["**/skills/**/*.md"],
485
526
  chainCompression: {