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 +4 -0
- package/README.md +113 -194
- package/package.json +2 -2
- package/src/commands.ts +45 -1
- package/src/config.test.ts +44 -8
- package/src/config.ts +25 -6
- package/src/summarizer-wiring.test.ts +144 -2
- package/src/summarizer.ts +71 -10
- package/src/types.ts +41 -0
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
|
[](https://buymeacoffee.com/jjurasszek)
|
|
8
8
|
|
|
9
|
-
A [Pi coding-agent](https://github.com/earendil-works/pi) extension that
|
|
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
|
|
11
|
+
## The problem
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
17
|
+
## Why this, not naive trimming
|
|
18
18
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
pi install npm:pi-condense
|
|
25
|
-
```
|
|
24
|
+
## Part of the pi agent toolkit
|
|
26
25
|
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
pi
|
|
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
|
-
|
|
53
|
+
## Quick example
|
|
34
54
|
|
|
35
55
|
```bash
|
|
36
|
-
pi
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
65
|
+
## Architecture
|
|
50
66
|
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
+
## When to use / when NOT to use
|
|
143
90
|
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
+
## Install
|
|
156
103
|
|
|
157
|
-
|
|
104
|
+
Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense).
|
|
158
105
|
|
|
159
|
-
|
|
106
|
+
**User scope** (all repos under your pi profile):
|
|
160
107
|
|
|
161
|
-
|
|
108
|
+
```bash
|
|
109
|
+
pi install npm:pi-condense
|
|
110
|
+
```
|
|
162
111
|
|
|
163
|
-
|
|
112
|
+
**Project scope** (current repo only, committable via `.pi/settings.json`):
|
|
164
113
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
118
|
+
**Try without installing**:
|
|
173
119
|
|
|
174
120
|
```bash
|
|
175
|
-
|
|
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
|
-
|
|
124
|
+
**From a local checkout** (for hacking on the extension itself):
|
|
182
125
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
138
|
+
## Configuration - the knobs most people touch
|
|
244
139
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
+
"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
|
}
|
package/src/config.test.ts
CHANGED
|
@@ -5,22 +5,23 @@ import { join } from "node:path";
|
|
|
5
5
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* config.ts
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
144
|
+
const path = settingsPath();
|
|
145
|
+
const current = (await readJsonObject(path)) ?? {};
|
|
127
146
|
const next = { ...current, [SETTINGS_KEY]: config };
|
|
128
|
-
await mkdir(dirname(
|
|
129
|
-
const tmpPath = `${
|
|
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,
|
|
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: (
|
|
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
|
|
124
|
-
// when the user presses Esc
|
|
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
|
-
{
|
|
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
|
|
215
|
-
ctx.ui.notify(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|