pi-dynamic-context-pruning 0.1.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.
@@ -0,0 +1 @@
1
+ 0.80.3
package/README.md ADDED
@@ -0,0 +1,435 @@
1
+ # dynamic-context-pruning
2
+
3
+ A pi extension that automatically prunes stale, duplicate, and superseded tool-call
4
+ content from long-running sessions, gated by a cache-aware net-benefit calculation
5
+ so pruning only happens when it's actually worth the prompt-cache cost. Includes a
6
+ manual `/prune` picker and a `/context-pruning` status/control command.
7
+
8
+ This is v1: deterministic, non-agentic strategies only (no model-invoked
9
+ compression, no summarization). See [Roadmap](#roadmap) for what a v2 would add
10
+ and why it hasn't been built yet.
11
+
12
+ ## Design
13
+
14
+ - **Non-destructive.** Nothing is ever deleted from the session file. A prune
15
+ replaces a tool result's content (or a tool call's input arguments) with a
16
+ short placeholder string; the original message stays in the transcript and can
17
+ always be restored.
18
+ - **Branch-aware.** Prune decisions and restores are persisted as custom session
19
+ entries (`dynamic-context-pruning:decision`, `dynamic-context-pruning:stats`,
20
+ `dynamic-context-pruning:restore`) appended via `pi.appendEntry`. On
21
+ `session_start` and `session_tree`, the extension folds the current branch's
22
+ entries back into in-memory state, so forking/switching branches correctly
23
+ recomputes which decisions are active for that branch.
24
+ - **Tombstone-based restore.** Restoring a pruned result doesn't delete the
25
+ original decision entry — it appends a restore entry for that decision's
26
+ idempotency key. The most recent entry (by chronological order) for a given
27
+ key wins when replaying history, so a decision can be pruned, restored, and
28
+ re-pruned any number of times.
29
+ - **Recomputed every call, not on a timer.** All three automatic strategies run
30
+ fresh on every `context` event, over the current message array. Nothing is
31
+ pruned "once and forgotten"; if a tool result would no longer qualify (e.g.
32
+ it's now within the protected recency window), it isn't proposed again, but
33
+ an already-applied, persisted decision still applies until explicitly
34
+ restored.
35
+
36
+ ## Strategies
37
+
38
+ Three deterministic strategies propose prunes; each can be toggled independently.
39
+
40
+ ### 1. Deduplication (`dedupe`)
41
+
42
+ If the same tool is called more than once with **canonically identical
43
+ arguments**, every occurrence except the newest is proposed for pruning (its
44
+ tool result content is replaced with a placeholder pointing at the newest
45
+ call).
46
+
47
+ - Canonicalization: JSON keys are recursively sorted before hashing/comparing
48
+ (`{"b":1,"a":2}` and `{"a":2,"b":1}` are the same key). Tool name comparison
49
+ is case-insensitive.
50
+ - Only *completed* tool calls (an assistant `toolCall` block with a matching
51
+ `toolResult` message) are considered; in-flight calls are never touched.
52
+ - The **newest** occurrence in a duplicate group is always kept in full.
53
+
54
+ ### 2. Error-input purge (`error-purge`)
55
+
56
+ Once an errored tool call's *input arguments* are older than a configurable
57
+ number of turns, they're proposed for pruning (the call's `arguments` are
58
+ replaced, not the error message itself).
59
+
60
+ - Default: `minTurnsOld = 4` — a call must be **strictly older** than 4 turns
61
+ to qualify (eligible at 5+ turns elapsed, not at exactly 4).
62
+ - Only applies to tool calls whose result was `isError: true`.
63
+ - Configurable per session via `strategy error-purge on|off` and the
64
+ `strategies.errorPurge.minTurnsOld` config field.
65
+
66
+ ### 3. Superseded file operations (`superseded-file-ops`)
67
+
68
+ When the same file path is read and/or written more than once, older
69
+ `read`/`write`/`edit` tool *outputs* for that path can become stale. This
70
+ strategy is deliberately conservative — it only proposes a prune when it can
71
+ prove the older output is stale, never on a guess:
72
+
73
+ 1. A **full-file read** (no `offset`/`limit`) is superseded by a **later**
74
+ full-file read of the same normalized path.
75
+ 2. A **partial read** (has `offset`/`limit`) is *not* superseded by a later
76
+ partial read unless the later read's range fully covers the earlier one.
77
+ If range coverage can't be reliably determined from the arguments (e.g. the
78
+ earlier read has no `limit` — reads to EOF — but the later read is
79
+ bounded), the strategy skips it rather than guessing.
80
+ 3. **Any** read of a path (full or partial) is superseded once a **later,
81
+ successful** (non-error) write/edit to that path occurs — the old read is
82
+ now known-stale regardless of range comparisons. An errored later
83
+ write/edit does **not** count, since the file may be unchanged.
84
+ 4. Older write/edit outputs for a path are superseded by a **later,
85
+ successful** write/edit to that same path. An errored later write/edit
86
+ never supersedes.
87
+
88
+ The newest operation for a given path (by message order) is never
89
+ superseded, regardless of kind. Path extraction accepts `path`, `file_path`,
90
+ and `filePath` argument names, matched only against pi's built-in
91
+ `read`/`write`/`edit` tools; `bash` file writes are out of scope (this
92
+ strategy does not parse shell commands).
93
+
94
+ ## Protections
95
+
96
+ Before any strategy is allowed to propose a prune, several protections apply:
97
+
98
+ - **Protected tool names** (case-insensitive, never pruned): `todo`, `task`,
99
+ `subagent`, `agent`, `skill`, `oracle`, `librarian`, `contrarian`,
100
+ `code_reviewer`, `gnosis`, `triage_comments`.
101
+ - **Protected path globs** (never pruned if any string argument matches):
102
+ `**/.env`, `**/.env.*`, `**/*.pem`, `**/*.key`, `**/id_rsa*`,
103
+ `**/id_ed25519*`, `**/secrets/**`, `**/*.p12`, `**/*.pfx`.
104
+ - **Recency window**: the most recent `recentTurns` conversational turns
105
+ (default `4`) are never touched by any automatic strategy, regardless of
106
+ what they contain.
107
+
108
+ All three lists/values are configurable (see [Configuration](#configuration)).
109
+
110
+ ## Savings accounting and the net-benefit gate
111
+
112
+ Every applied decision's estimated token savings are folded into cumulative,
113
+ per-strategy stats (`/context-pruning stats`), persisted as
114
+ `dynamic-context-pruning:stats` session entries.
115
+
116
+ Before a *fresh, automatic* prune proposal is actually applied, it must pass a
117
+ **cache-aware net-benefit gate**. The intuition: prompt caches are
118
+ prefix-matched, so pruning at message position `p` invalidates ("busts") the
119
+ cached prefix from `p` onward, exactly once. That's a one-time cost, offset by
120
+ a smaller recurring saving on every subsequent call (fewer tokens sent). The
121
+ gate models this as a break-even calculation:
122
+
123
+ ```text
124
+ penalty ≈ (1 - r) * tailTokensAfterEarliestChange
125
+ recurringSaving ≈ r * tokensRemoved
126
+ breakEvenCalls = penalty / recurringSaving
127
+ ```
128
+
129
+ Where `r` is `gate.cachedPriceRatio` — the fraction of full price still paid
130
+ for cached tokens (default `0.1`, i.e. cached tokens cost ~10% of fresh
131
+ tokens). `breakEvenCalls` is how many subsequent LLM calls are needed to
132
+ recoup the one-time cache-bust cost via the recurring saving. The gate accepts
133
+ a batch of candidates only if `breakEvenCalls <= gate.breakEvenThreshold`
134
+ (default `22`, calibrated from the representative-corpus benchmark — see the
135
+ calibration note under [Configuration](#configuration) and the
136
+ [Roadmap](#roadmap) evidence).
137
+
138
+ All candidates proposed in the same `context` call share one cache bust (since
139
+ a prompt cache is a single linear prefix), so they're evaluated and
140
+ accepted/rejected jointly against the earliest position any of them touches.
141
+
142
+ Manual prunes via `/prune` bypass the gate entirely — you already decided the
143
+ result isn't worth keeping, so there's no threshold to check.
144
+
145
+ Gate modes (`/context-pruning gate <mode>`):
146
+
147
+ - `on` (default): reject batches above the threshold.
148
+ - `off`: bypass the gate entirely; no cost modelling, everything applies.
149
+ - `always-apply`: cost is still modelled (for stats/observability) but nothing
150
+ is ever rejected.
151
+
152
+ ## Commands
153
+
154
+ ### `/prune`
155
+
156
+ Interactive picker (TUI/RPC) over every prunable tool result in the current
157
+ branch:
158
+
159
+ ```text
160
+ /prune
161
+ ```
162
+
163
+ - Lists active, pruned, and previously-restored tool results.
164
+ - Selecting an active result shows a cost preview (predicted cache-bust
165
+ penalty vs. recurring saving) before you confirm the prune.
166
+ - Selecting a pruned result offers to restore it.
167
+ - Outside an interactive UI (no `ctx.hasUI`), prints a read-only report of
168
+ prunable results instead.
169
+
170
+ ### `/context-pruning`
171
+
172
+ ```text
173
+ /context-pruning status
174
+ /context-pruning stats
175
+ /context-pruning on
176
+ /context-pruning off
177
+ /context-pruning toggle
178
+ /context-pruning strategy <dedupe|error-purge|superseded-file-ops> on|off
179
+ /context-pruning gate <on|off|always-apply>
180
+ ```
181
+
182
+ - `status`: enabled state, gate mode/threshold/ratio, per-strategy on/off
183
+ state, protection counts, last call's raw/effective/saved token snapshot,
184
+ and current context usage.
185
+ - `stats`: cumulative tokens saved, overall and per strategy.
186
+ - `on` / `off` / `toggle`: enable or disable the extension entirely.
187
+ - `strategy <name> on|off`: toggle one strategy independently.
188
+ - `gate <mode>`: change the net-benefit gate mode at runtime.
189
+
190
+ Config changes made via `/context-pruning` are persisted to disk (see
191
+ [Configuration](#configuration)) and also applied in-memory immediately, even
192
+ if the write fails.
193
+
194
+ ## Configuration
195
+
196
+ Config is stored at:
197
+
198
+ ```text
199
+ <pi agent dir>/extensions/dynamic-context-pruning.json
200
+ ```
201
+
202
+ It's read once on `session_start` and rewritten whenever `/context-pruning`
203
+ mutates it. A missing or unparseable file falls back to defaults; the file is
204
+ never required to exist. Full shape, with defaults:
205
+
206
+ ```json
207
+ {
208
+ "enabled": true,
209
+ "protections": {
210
+ "toolNames": ["todo", "task", "subagent", "agent", "skill", "oracle", "librarian", "contrarian", "code_reviewer", "gnosis", "triage_comments"],
211
+ "pathGlobs": ["**/.env", "**/.env.*", "**/*.pem", "**/*.key", "**/id_rsa*", "**/id_ed25519*", "**/secrets/**", "**/*.p12", "**/*.pfx"],
212
+ "recentTurns": 4
213
+ },
214
+ "thresholds": {
215
+ "minCharsSaved": 200
216
+ },
217
+ "strategies": {
218
+ "dedupe": { "enabled": true },
219
+ "errorPurge": { "enabled": true, "minTurnsOld": 4 },
220
+ "supersededFileOps": { "enabled": true }
221
+ },
222
+ "gate": {
223
+ "mode": "on",
224
+ "cachedPriceRatio": 0.1,
225
+ "breakEvenThreshold": 22,
226
+ "breakEvenThresholdByState": { "idle": 22, "mid_loop": 22 }
227
+ }
228
+ }
229
+ ```
230
+
231
+ Notes:
232
+
233
+ - `thresholds.minCharsSaved` is reserved for a future minimum-size filter and
234
+ is **not** currently enforced by the pipeline; the net-benefit gate's
235
+ break-even math is what actually decides whether a prune applies.
236
+ - `gate.breakEvenThresholdByState` lets `idle` vs `mid_loop` agent states use
237
+ different thresholds; both default to `gate.breakEvenThreshold`. Real
238
+ mid-loop/idle detection isn't wired through yet, so this always evaluates as
239
+ `idle` in v1 — the field exists so the seam is ready once that lands. The
240
+ representative-corpus benchmark (see [Roadmap](#roadmap)) shows `mid_loop`
241
+ candidates carry ~all realized net benefit and `idle` candidates carry ~none
242
+ at r=0.1, which argues for a stricter `idle` default — but since every live
243
+ evaluation runs as `idle` today, defaulting it stricter would silently
244
+ disable most automatic pruning. Both states are intentionally kept at
245
+ parity until real agent-state detection lands; that's a follow-up ticket.
246
+ - `gate.breakEvenThreshold`'s default of `22` is calibrated from the
247
+ representative-corpus benchmark at `cachedPriceRatio` r=0.1 (aggressive
248
+ prompt caching, the common case) — see [Roadmap](#roadmap) for the full
249
+ evidence and its ratio-sensitivity caveats. It is **provider/ratio-
250
+ dependent**: the optimal threshold rises as caching gets weaker (T=29 at
251
+ r=0.25, T=54 at r=0.5, T=58 at r=0.9 on the same corpus).
252
+
253
+ ## Prompt-cache trade-off
254
+
255
+ Pruning trades a smaller stream of tokens sent on every future call against a
256
+ one-time cache-bust cost, because prompt caches match on a strict prefix: any
257
+ change to the message stream busts the cached prefix from that point onward
258
+ for the next call. This is exactly why the net-benefit gate exists rather than
259
+ pruning unconditionally — a prune that happens too late in a session (few
260
+ calls left to amortize the one-time cost over) or removes too few tokens
261
+ relative to the tail it invalidates can cost *more* than it saves. The gate's
262
+ break-even math (above) is the mechanism that keeps pruning net-positive on
263
+ average rather than accepting the cache trade-off unconditionally.
264
+
265
+ `gate.cachedPriceRatio` (`r`) should track your provider's real prompt-cache
266
+ pricing:
267
+
268
+ - **Anthropic** prompt caching: cached reads ~0.1x base input price, plus a
269
+ ~1.25x one-time cache-write premium → `r ≈ 0.1`.
270
+ - **Google Gemini** context caching: ~0.25x base input price, plus a
271
+ separate storage/time fee → `r ≈ 0.25`.
272
+ - **OpenAI** automatic prompt caching: ~0.5x base input price → `r ≈ 0.5`.
273
+ - **No caching** available or enabled → `r → 1`: the cache-bust penalty
274
+ vanishes, so pruning is a pure win regardless of threshold.
275
+
276
+ Provider pricing drifts — check current price sheets before trusting these
277
+ ratios. The penalty math also assumes the cache would still be warm at the
278
+ next call; if the provider's cache TTL (e.g. ~5 min default on Anthropic)
279
+ elapses between calls, the bust costs nothing and pruning is again a pure
280
+ win.
281
+
282
+ ## Benchmark harness
283
+
284
+ A standalone, read-only offline benchmark replays real (or fixture) pi
285
+ session `.jsonl` files through the extension's exported strategy/pipeline/cost
286
+ model helpers — it never writes back to a session file or changes runtime
287
+ behavior.
288
+
289
+ ```bash
290
+ node extensions/dynamic-context-pruning/scripts/benchmark.mjs [paths...] [options]
291
+ ```
292
+
293
+ Options:
294
+
295
+ - `--limit N`: only process the first N session files found.
296
+ - `--ratio R`: cached-price ratio(s) to model; repeatable and/or
297
+ comma-separated (e.g. `--ratio 0.1,0.2`). Default: `0.1`.
298
+ - `--json`: emit a full machine-readable JSON dump instead of aligned text.
299
+ - `--help`: print usage.
300
+
301
+ With no positional paths, it defaults to every `*.jsonl` file found
302
+ recursively under `~/.pi/agent/sessions`.
303
+
304
+ > **Corpus choice matters.** `~/.pi/agent/sessions` (the default) tends to be
305
+ > dominated by short orchestrator/delegation sessions, which structurally
306
+ > under-report candidates and skew the remaining-calls-after-position
307
+ > distribution low. The representative-corpus numbers in [Roadmap](#roadmap)
308
+ > instead come from `~/.the-last-harness/agent/sessions` — the harness's own
309
+ > longer, tool-heavy agent sessions — which is the corpus to point the
310
+ > harness at when re-deriving or sanity-checking the gate default.
311
+
312
+ For every point in a session where an LLM call would have happened
313
+ (approximated as immediately before each assistant message), the harness
314
+ reports two kinds of numbers:
315
+
316
+ - **Predicted**: what the strategies would propose right now, and what the
317
+ cache-aware cost model (penalty / recurring saving / break-even calls)
318
+ estimates, without knowing what happens next in the session.
319
+ - **Realized**: since replay knows the session's actual future, the harness
320
+ also reports how many subsequent LLM calls actually followed a candidate
321
+ prune point, and the *actual* net benefit that prune would have produced —
322
+ `realizedNetBenefit = actualRemainingCalls * recurringSaving - penalty`.
323
+
324
+ It also sweeps the break-even threshold `T` from 1 to 30 and reports the `T`
325
+ that maximizes total realized net benefit across all gate-eligible
326
+ candidates, both overall and split by idle/mid-loop agent state.
327
+
328
+ ## Comparison
329
+
330
+ - **[opencode-dynamic-context-pruning](https://github.com/sculptdotfun/opencode-dynamic-context-pruning) ("pi-dcp" upstream)**:
331
+ the OpenCode plugin this extension takes its name and general problem space
332
+ from. It ships a model-invoked `compress` tool (agentic, nested-summary
333
+ compression), autonomous nudges, manual mode, and a full `/dcp` command
334
+ surface. This extension's v1 implements none of that agentic layer — only
335
+ the deterministic automatic strategies and cache-aware gate, which is the
336
+ subset OpenCode DCP calls its own automatic dedup/error-purge strategies.
337
+ See [Roadmap](#roadmap) for the full parity gap and what would be needed to
338
+ close it.
339
+ - **[context-cap](../context-cap)** / **[context-inspector](../context-inspector)**:
340
+ siblings in this repo that address adjacent but different problems.
341
+ `context-cap` shrinks the *effective* model context window so pi's own
342
+ auto-compaction fires earlier; it doesn't remove or alter any content.
343
+ `context-inspector` is a read-only dashboard that shows where context is
344
+ going; it doesn't modify anything either. `dynamic-context-pruning` is the
345
+ only one of the three that actually removes/replaces content from what gets
346
+ sent to the model.
347
+
348
+ ## Roadmap
349
+
350
+ [`docs/v2-design.md`](./docs/v2-design.md) is the authoritative v2 design
351
+ spec: full OpenCode DCP parity, meaning a model-invoked `compress` tool,
352
+ nested block summaries, autonomous nudges, manual mode, and the full `/dcp`-
353
+ equivalent command surface (decompress/recompress, prompt overrides,
354
+ notifications).
355
+
356
+ **v2 proceeds only if benchmark evidence supports it.** The design doc's
357
+ benchmark decision gate requires a meaningfully positive aggregate realized
358
+ net benefit, at sufficient candidate volume, with enough remaining-calls
359
+ runway per session to plausibly amortize a compression summary's own token
360
+ cost — see `docs/v2-design.md` §4 for the exact go/no-go bar.
361
+
362
+ The representative-corpus run of the benchmark harness (`pe-c5n9`, over
363
+ `~/.the-last-harness/agent/sessions`: 1,390+ session files, 556 gate-eligible
364
+ candidates — see the corpus-choice note above) produced:
365
+
366
+ - **Candidates and tokens removed by strategy**: `dedupe` 111 candidates /
367
+ 93,330 tokens removed; `error-purge` 187 / 28,191; `superseded-file-ops`
368
+ 258 / 247,474.
369
+ - **Remaining-calls-after-position distribution: p50 = 47, p90 = 156** — far
370
+ more amortization runway than the earlier small-corpus run showed, because
371
+ this corpus is dominated by long, tool-heavy agent sessions rather than
372
+ short orchestrator sessions.
373
+ - **Hindsight-optimal break-even threshold `T`** (maximizes total *realized*
374
+ net benefit), by cached-price ratio `r`, split overall / mid_loop / idle:
375
+
376
+ | ratio `r` | overall `T` (benefit) | mid_loop `T` (benefit) | idle `T` (benefit) |
377
+ | --- | --- | --- | --- |
378
+ | 0.1 | T=22 (~20.6k) | T=22 (~20.6k) | T=1 (0 — zero benefit) |
379
+ | 0.25 | T=29 (~1.54M) | T=29 (~1.54M) | T=12 (~4.0k) |
380
+ | 0.5 | T=54 (~8.06M) | T=54 (~8.06M) | T=27 (~37.2k) |
381
+ | 0.9 | T=58 (~24.7M) | T=58 (~24.2M) | T=21 (~459k) |
382
+
383
+ **Reading**: `mid_loop` candidates carry essentially all of the realized
384
+ benefit; `idle` candidates carry essentially none (literally zero at r=0.1).
385
+ At r=0.1 (aggressive prompt caching, the common Anthropic case) the total
386
+ realized benefit across the whole 1,390-session corpus is only ~20.6k
387
+ token-units — economically marginal, on the order of pennies. Savings only
388
+ become material at weaker caching, r>=0.25.
389
+
390
+ **This reframes, rather than weakens, the case for v2.** Small deterministic
391
+ removals (dedupe/error-purge/superseded-file-ops) structurally cannot beat
392
+ the cache-bust penalty at r=0.1 — there just isn't enough single-message
393
+ token volume in them. If pruning is going to matter economically under
394
+ aggressive caching, it has to come from *large* removals: agentic range
395
+ compression and nested summaries (the v2 `compress` tool), which remove
396
+ orders of magnitude more tokens per operation than these deterministic
397
+ strategies do. So this evidence argues *for* prototyping v2 (contingent on
398
+ summary quality/hardening work), not against it — v1's mechanical strategies
399
+ remain useful as a free, always-on baseline, but they were never going to be
400
+ where the real savings live.
401
+
402
+ ## Install
403
+
404
+ ### Standalone npm package
405
+
406
+ ```bash
407
+ pi install npm:pi-dynamic-context-pruning
408
+ ```
409
+
410
+ ### Collection package
411
+
412
+ ```bash
413
+ pi install npm:@diegopetrucci/pi-extensions
414
+ ```
415
+
416
+ ### GitHub package
417
+
418
+ ```bash
419
+ pi install git:github.com/diegopetrucci/pi-extensions
420
+ ```
421
+
422
+ Then reload pi:
423
+
424
+ ```text
425
+ /reload
426
+ ```
427
+
428
+ ## Notes
429
+
430
+ - This extension's package name is intentionally unscoped (`pi-dynamic-context-pruning`,
431
+ not `@diegopetrucci/pi-dynamic-context-pruning`), a deliberate deviation from
432
+ this repo's usual package-naming convention.
433
+ - Token counts throughout (`status`, `stats`, the benchmark harness) are
434
+ estimates from a simple chars/4-style heuristic, not exact provider token
435
+ counts.