pi-condense 2.7.0 → 2.9.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
@@ -7,6 +7,24 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
7
7
  Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
8
8
  publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
9
9
 
10
+ ## [2.9.0] - 2026-08-14
11
+
12
+ - **Uncovered chains now compress deterministically instead of stranding forever ([#10](https://github.com/jjuraszek/pi-condense/issues/10)).** Chain compression (Phase 3) required per-batch summary coverage; a closed eligible chain whose span produced no summaries (trivial batches, `skipped-oversized`, fully-deduped spans, capture misses) hit a permanent `no-summary` skip - once the prune frontier passed it, nothing could ever reclaim it. The motivating incident left a 639-call, ~935k-char chain (~62% of a 620k window) live in context indefinitely.
13
+ - **Deterministic zero-LLM fallback.** When coverage is zero, `compressEligible` extracts the chain's middle tool calls positionally (`resolveRange`; protected and already-indexed members excluded), backfills them into the tool-call index, and compresses the chain with a synthetic body - call count, tool histogram, span duration, first/last args excerpts (200-char cap), and `t<N>` refs - carried in `rangeSummaryText` with a new optional `bodySource: "deterministic"` marker. No summarizer traffic, no renderer changes, body render-stable (cache-friendly after the one-time compression).
14
+ - **Mandatory fail-closed recoverability backfill.** New `ToolCallIndexer.backfillChainRecords`: append-before-commit atomicity (in-memory maps mutate only after the `context-prune-index` entry persisted), oversized results spilled via the shared `applySpill` helper (extracted from the eager spill path), refs ride the backfilled entry (`backfilled: true` + `refs`) so they survive restart without a summary message, and backfilled records never seed content-hash dedup canonicals - live or on reconstruction. Any failure preserves the historical skip; the next flush retries and converges by composing from durable records without duplicate index entries.
15
+ - **Edge cases.** Fully-protected chains keep the plain skip (compressing would save zero tokens - every output relocates verbatim). A genuine span mismatch (zero extractable, zero indexed, not fully protected) emits a new `backfill-empty` diagnostic (widget segment is now `diag u/m/o/b`). `/pruner compact` handles all uncovered chains in one pass. Known limitation: an idle session heals a stranded chain on the next working flush or `/pruner compact`, not via `/pruner now` on an empty queue.
16
+ - **Anti-regression cage.** Covered-path entries pinned byte-identical; legacy chain/index entries round-trip unchanged; restart-mid-failure-window convergence, fully-deduped backfill, protected exclusion+verbatim relocation, and multi-chain compact all integration-pinned. All optional fields - pre-upgrade sessions load with today's semantics.
17
+ - **Docs.** `PRUNING.md` deterministic-fallback subsection + diagnostics row, README widget legend + compact note, AGENTS.md entry-table updates. Spec: `doc/specs/2026-08-14-uncovered-chain-deterministic-backfill.md`.
18
+
19
+ ## [2.8.0] - 2026-08-13
20
+
21
+ - **Both token-budget flush triggers now cap the context window they reason about at 300,000 tokens** (`MAX_BUDGET_WINDOW`, `src/budget.ts`) ([#7](https://github.com/jjuraszek/pi-condense/issues/7)). Previously both scaled purely off the model's advertised window, which made them unreachable as windows grew: on a 1M-window model `autoBudgetThreshold: 0.9` meant 900k tokens - a session ends long before that, so pruning never fired - while the same setting worked on a 200k model. `budgetTurnDelta: 0.1` was worse than late: it meant +100k context growth inside a single turn, which effectively never happens, so the re-arm trigger was dead.
22
+ - `autoBudgetThreshold` now fires at `min(300_000, threshold * contextWindow)` tokens - your percentage of the model's window, or 300k tokens, whichever comes first. The threshold keeps its literal meaning; the cap is only a ceiling.
23
+ - `budgetTurnDelta` applies the same ceiling in the other shape - a 300k ceiling on a single turn's *growth* could never bind - so the fraction is measured against `min(contextWindow, 300_000)`: `0.1` means +30k tokens in one turn on any model at or above 300k (+20k on a 200k model, unchanged).
24
+ - **No new setting, and no behavior change for any model advertising 300k or less, at any setting** - a 256k window at `0.9` still fires at 230.4k. Above 300k flushes happen earlier; nothing ever flushes later than before. Downward control is unaffected: `0.1` on a 1M model still means 100k tokens.
25
+ - `usageFraction` may now exceed `1.0` above the ceiling (600k tokens on a 1M window returns `2.0`) and is deliberately **not** clamped - clamping would saturate the delta trigger and stop it re-arming.
26
+ - Docs updated in step: `README.md`, `doc/configuration.md`, `PRUNING.md`. Spec: `doc/specs/2026-08-13-budget-window-cap.md`.
27
+
10
28
  ## [2.7.0] - 2026-08-12
11
29
 
12
30
  - **Single-chain observability + reload trigger repair ([#6](https://github.com/jjuraszek/pi-condense/issues/6)).** A 5-hour, 256-turn session running one uninterrupted tool chain (no text-only assistant reply ever closed it) accumulated ~195k tokens of raw toolResults while `/pruner status` showed near-zero activity: chain compression (Phase 3) requires *closed* chains by design, and a session reload cleared the in-memory pending queue, leaving the automatic flush trigger stranded even though the branch rescan could recover the work.
package/PRUNING.md CHANGED
@@ -816,15 +816,17 @@ ACON demonstrates that **compression not only saves tokens but can improve agent
816
816
 
817
817
  ### Token-budget auto-flush trigger
818
818
 
819
- `autoBudgetThreshold` (default `null`) is an ADDITIONAL flush trigger orthogonal to `pruneOn`. When set to a fraction in `(0, 1]`, the extension evaluates `tokens / contextWindow` at the end of every tool-using turn; when the ratio meets the threshold, all pending batches are flushed immediately regardless of the configured `pruneOn` mode.
819
+ `autoBudgetThreshold` (default `null`) is an ADDITIONAL flush trigger orthogonal to `pruneOn`. When set to a fraction in `(0, 1]`, the extension evaluates context usage at the end of every tool-using turn; when `tokens` reaches `min(MAX_BUDGET_WINDOW, threshold * contextWindow)` - i.e. the configured share of the model's window, or 300,000 tokens, whichever comes first - all pending batches are flushed immediately regardless of the configured `pruneOn` mode.
820
820
 
821
- Why we compute the ratio ourselves rather than using `ContextUsage.percent`: the provider's `percent` field is a 0100 value, and both it and `tokens` are `null` immediately after a provider-side compaction. Using `tokens / contextWindow` directly gives a 0–1 fraction that matches the config unit and is independently null-safe a `null` tokens value makes the trigger a no-op until usage is reported again.
821
+ **Why we compute this ourselves rather than using `ContextUsage.percent`:** the provider's `percent` field is a 0-100 value, and both it and `tokens` are `null` immediately after a provider-side compaction. Comparing `tokens` against a token level we derive ourselves keeps the unit unambiguous and is independently null-safe - a `null` tokens value makes the trigger a no-op until usage is reported again. Note this trigger never divides: the fraction form (`tokens / min(contextWindow, MAX_BUDGET_WINDOW)`) belongs to `usageFraction` and the delta trigger below, and reading the threshold as a share of a capped window would be wrong - on a 1M-window model, `0.4` fires at 300,000 tokens, not at 120,000.
822
+
823
+ **Why the 300k ceiling (`MAX_BUDGET_WINDOW`, `src/budget.ts`):** the trigger was originally a pure fraction of the advertised window, which made it unreachable as windows grew. On a 1M-window model, `0.9` means 900k tokens - a session ends long before that, so users observed "pruning never happens" (issue #7), while the same setting worked on a 200k model. The ceiling is deliberately chosen so it never binds at or below a 300k advertised window (a 256k model at `0.9` still fires at 230.4k), so it changes behavior only where the fraction was already unusable. Users keep full downward control: `0.1` on a 1M model still means 100k tokens.
822
824
 
823
825
  Lineage: simplified take on DCP's `maxContextLimit` nudging — a single threshold that forces a flush rather than separate nudge/force thresholds.
824
826
 
825
827
  ### Budget-delta flush
826
828
 
827
- `budgetTurnDelta: number | null` (default `null`) is a per-turn usage-jump trigger ORed with `autoBudgetThreshold`. When set to a fraction in `(0, 1]`, the extension compares the current turn's usage fraction (`tokens / contextWindow`) to the previous turn's and forces a flush if the jump meets or exceeds the delta.
829
+ `budgetTurnDelta: number | null` (default `null`) is a per-turn usage-jump trigger ORed with `autoBudgetThreshold`. When set to a fraction in `(0, 1]`, the extension compares the current turn's usage fraction (`tokens / min(contextWindow, MAX_BUDGET_WINDOW)`) to the previous turn's and forces a flush if the jump meets or exceeds the delta. Because the denominator carries the same 300k ceiling, the required growth is `delta * min(contextWindow, MAX_BUDGET_WINDOW)` tokens: `0.1` = +30k on any model at or above 300k, +20k on a 200k model. The ceiling enters through the denominator here rather than bounding a level, because a 300k ceiling on one turn's growth could never bind. Note the fraction is therefore not bounded by 1 above the ceiling (600k tokens on a 1M window = 2.0) and is deliberately not clamped - clamping would saturate this trigger and stop it re-arming.
828
830
 
829
831
  Use case: a single enormous tool result can jump context usage by 20–30 percentage points in one turn; `autoBudgetThreshold` misses this until the next turn. `budgetTurnDelta` catches the spike immediately.
830
832
 
@@ -960,6 +962,23 @@ The `context-prune-chain` session entry carries the matching `protectedToolCallI
960
962
 
961
963
  **Rejected alternative:** skip compression for any chain that contains a protected tool. Rejected because `todowrite`/`todoread` recur in most chains for opted-in users, so this strategy would forfeit most chain compression for the people who most need `protectedTools`.
962
964
 
965
+ ### Deterministic fallback (uncovered chains)
966
+
967
+ A chain becomes eligible for compression (closed, older than the rolling window) independently of whether its middle tool calls were ever summarized. Per-batch coverage can be zero - a trivial batch, an oversized-skip, a fully-deduped batch, or a plain capture miss - and historically that meant `compressEligible` skipped the chain forever with `reason: "no-summary"`: the prune frontier had already advanced past the span, so no future flush would recapture it. A 639-call, ~935k-char chain stranded live this way (`doc/specs/2026-08-14-uncovered-chain-deterministic-backfill.md`).
968
+
969
+ When `hasPerBatchSummaryCoveringAny` is false for a chain's middle ids, `compressEligible` takes a second, zero-LLM branch instead of skipping:
970
+
971
+ 1. **Resolve + extract.** `extractChainRecords` resolves the chain's span with the same `resolveRange` used by the drop path, then walks it positionally. Middles are excluded when protected (relocated verbatim at render, never phase-1 stubbed) or already indexed (occurrence-key membership in the indexer's record map - not `isSummarized`, which also covers dedup aliases and would wrongly re-strand a fully-deduped chain). Each surviving call becomes a `ToolCallRecord` with `turnIndex: -1` (no batch turn; `context_tree_query` renders `Turn: -1`, a pinned cosmetic).
972
+ 2. **Backfill, atomically.** `indexer.backfillChainRecords` spills oversized results via the shared spill helpers (`blobPathFor`/`applySpill`), a fail-closed variant of the eager `spillOversizedBatch` path, allocates `t<N>` refs, and appends **one** `context-prune-index` entry carrying the records plus `backfilled: true` and the allocated refs - append-before-commit, so refs are durable the instant the records are. Only after the append succeeds does it commit to the in-memory index and alias maps. Backfilled records never seed `contentHashToOriginal`: a poisoned dedup canonical could point future identical outputs at a record whose durability was never actually verified end-to-end for that purpose, so backfilled entries are excluded from canonical-seeding on both the live path and `reconstructFromSession`.
973
+ 3. **Compose a deterministic body.** `buildDeterministicBody` builds a zero-LLM stub - call count, a tool-name histogram, span duration, and a `First:`/`Last:` line with args JSON capped at 200 chars, plus the `t<N>` refs - and stores it as `rangeSummaryText` on the `context-prune-chain` entry with `bodySource: "deterministic"`. The renderer already prefers `entry.rangeSummaryText` (`src/pruner.ts`), so no renderer change was needed; entries without `bodySource` keep today's semantics unchanged.
974
+ 4. **Fail closed.** Any throw during backfill (spill I/O, append failure, malformed span) preserves the historical `no-summary` skip - nothing partial is committed. A chain with zero freshly-extracted records is only treated as a genuine span mismatch (fail-closed skip + `backfill-empty` diagnostic) when it ALSO has zero members already present in the index; if a prior attempt's index append succeeded but the chain-entry append then failed, the retry composes and persists straight from the durable index records instead of re-extracting, so recovery never produces a second `context-prune-index` entry for the same span.
975
+
976
+ **Fully-protected exception.** If every middle id in a zero-extractable, zero-indexed chain is protected (`chain.protectedToolCallIds` covers all of `chain.middleToolCallIds`), the chain stays on the plain `no-summary` skip with no `backfill-empty` diagnostic. Compressing would relocate every output verbatim into the synthetic body anyway (zero tokens saved), and the diagnostic would misreport a healthy span - firing on every restart for protected-heavy configs.
977
+
978
+ **Known limitation.** `flushPending` returns early when there are no pending batches, before chain detection runs at all. A chain stranded in an otherwise-idle session is not healed by `/pruner now` on an empty queue - it heals on the next flush that has any work, or immediately via `/pruner compact`.
979
+
980
+ **Cache-prefix impact.** Same as any chain compression: rewriting the span busts the prefix cache from that chain's start-user-message onward, once. The deterministic body has no `now()` or LLM nondeterminism, so it renders byte-identical on every subsequent pass - no repeated invalidation from retries or reloads.
981
+
963
982
  ### Deferred
964
983
 
965
984
  - **Model-driven trigger.** The compressor is autonomous (rolling window). A model-callable compress tool (DCP-style: the model compresses a sub-task as it closes) is not implemented; the earlier scaffolded `agentic-auto` mode + `context_prune` tool were removed in v1.0.0.
@@ -971,7 +990,7 @@ Phase 3 (chain compression) only ever acts on **closed** chains - a chain closes
971
990
 
972
991
  Phase 1 (per-batch summarization) is unaffected by chain closure and remains the only lever for this shape of session. For long autonomous runs where no chain is expected to close:
973
992
 
974
- - Lower `autoBudgetThreshold` (e.g. `0.5`-`0.6` instead of the default `0.8`) so the budget trigger fires well before the open segment dominates the window.
993
+ - Lower `autoBudgetThreshold` (e.g. `0.5`-`0.6` instead of `0.8`) so the budget trigger fires well before the open segment dominates the window. On a window larger than 300k the ceiling already caps the trigger point at 300,000 tokens once `threshold * contextWindow` exceeds it - that is any setting at or above `300k / contextWindow` (`0.3` on a 1M window, `0.75` on a 400k one) - so lowering the setting only matters below that point.
975
994
  - Set `budgetTurnDelta` so a single turn's sudden context jump force-flushes even between budget-threshold crossings - this catches spikes a static threshold misses until the next turn.
976
995
 
977
996
  Neither knob makes a chain close; they just keep Phase 1 flushing on schedule so raw toolResults do not pile up unsummarized for the whole run.
@@ -1091,13 +1110,14 @@ Every swept id is reported once (hashed batch, not per-id) as an `orphan-sweep`
1091
1110
 
1092
1111
  ## Diagnostics
1093
1112
 
1094
- Prune-time degradations - an unresolvable chain range, a detection/render id mismatch, a swept orphan - are recorded on an out-of-band channel instead of surfacing in the model's context. `DiagnosticSink` (`src/diagnostics.ts`) writes a `context-prune-diagnostic` session entry (`{ kind, detail }`) for each of three kinds:
1113
+ Prune-time degradations - an unresolvable chain range, a detection/render id mismatch, a swept orphan, a chain with nothing left to backfill - are recorded on an out-of-band channel instead of surfacing in the model's context. `DiagnosticSink` (`src/diagnostics.ts`) writes a `context-prune-diagnostic` session entry (`{ kind, detail }`) for each of four kinds:
1095
1114
 
1096
1115
  | Kind | Emitted from | Meaning |
1097
1116
  |---|---|---|
1098
1117
  | `unresolved-range` | `applyChainCompressions` | A persisted chain entry's boundaries didn't resolve to a unique range (or the range was rejected as nested/duplicate) - the entry compressed nothing |
1099
1118
  | `range-id-mismatch` | `applyChainCompressions` | The ids actually inside a resolved range don't match the entry's recorded `droppedToolCallIds` - informational only, the range still wins |
1100
1119
  | `orphan-sweep` | `pruneMessages` (Phase 4) | One or more `toolResult` messages were removed for having no open matching `toolCall` |
1120
+ | `backfill-empty` | `chain-compressor.compressEligible` | A zero-coverage chain's deterministic-backfill span yielded zero extractable records AND zero members already in the index AND the chain is not fully-protected - a genuine span mismatch, not a partial-failure retry state. Fully-protected zero-coverage chains skip silently instead (see [Deterministic fallback § Fully-protected exception](#deterministic-fallback-uncovered-chains)). Deduped per chain start timestamp (`chain.startUserTimestamp`). Widget letter `b` |
1101
1121
 
1102
1122
  **Never in LLM context.** These are session entries only - zero tokens added, zero cache-prefix change, never read back into the message array the model sees.
1103
1123
 
@@ -1105,7 +1125,7 @@ Prune-time degradations - an unresolvable chain range, a detection/render id mis
1105
1125
 
1106
1126
  **Reset on `session_start` and `session_tree`**, matching every other in-memory, non-persisted piece of prune state.
1107
1127
 
1108
- **Surfaced on the status line.** The footer status widget (`setPruneStatusWidget`, gated by `showPruneStatusLine`) appends a self-hiding ` · diag u<N>/m<N>/o<N>` segment (u = `unresolved-range`, m = `range-id-mismatch`, o = `orphan-sweep`) built from the sink's live counters via `pruneStatusText`. Each letter is omitted when its counter is zero, and the whole segment is absent when all three are zero. `/pruner status` (the slash command) prints a separate settings/stats block and does not include this segment.
1128
+ **Surfaced on the status line.** The footer status widget (`setPruneStatusWidget`, gated by `showPruneStatusLine`) appends a self-hiding ` · diag u<N>/m<N>/o<N>/b<N>` segment (u = `unresolved-range`, m = `range-id-mismatch`, o = `orphan-sweep`, b = `backfill-empty`) built from the sink's live counters via `pruneStatusText`. Each letter is omitted when its counter is zero, and the whole segment is absent when all four are zero. `/pruner status` (the slash command) prints a separate settings/stats block and does not include this segment.
1109
1129
 
1110
1130
  ---
1111
1131
 
package/README.md CHANGED
@@ -83,14 +83,20 @@ Every summarizer cost update is emitted on the shared `pi.events` channel `cost:
83
83
  | `context_tree_query` | The tool the model calls to recover a stubbed original by ref (`tN`) or `toolCallId`. A reused id returns every matching occurrence, not just one, including any that were content-deduplicated to an earlier record - see [PRUNING.md § Occurrence Identity](PRUNING.md#occurrence-identity) |
84
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
85
  | Prune frontier | The last attempted prune boundary - advances even on a skip, so nothing is reconsidered twice |
86
- | Diagnostics (`diag u/m/o`) | A self-hiding status-line segment surfacing prune-time degradations: `u` = unresolved chain range, `m` = detection/render id mismatch (informational, does not change what's dropped), `o` = orphan tool-result sweep. Each letter's count is omitted when zero; the whole segment disappears when all three are zero. Backing session entries are `context-prune-diagnostic` - see below |
86
+ | Diagnostics (`diag u/m/o/b`) | A self-hiding status-line segment surfacing prune-time degradations: `u` = unresolved chain range, `m` = detection/render id mismatch (informational, does not change what's dropped), `o` = orphan tool-result sweep, `b` = a zero-coverage chain with nothing left to backfill (genuine span mismatch, see below). Each letter's count is omitted when zero; the whole segment disappears when all four are zero. Backing session entries are `context-prune-diagnostic` - see below |
87
87
  | Context metrics (`think`/`gap`/`chain`) | Open-cycle thinking tokens, largest-chain share, frontier gap - what the pruner cannot (yet) reclaim, notably in single-chain sessions. See below and [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) |
88
88
  | Prompt-cache interaction | Why batching (not per-turn pruning) is the default - see [PRUNING.md](PRUNING.md#how-prefix-caching-works) |
89
89
  | `cost:external` | The shared cost-reporting channel pi-condense emits on (see above) |
90
90
 
91
91
  ### Diagnostic entries (`context-prune-diagnostic`)
92
92
 
93
- The status-line `diag u<N>/m<N>/o<N>` segment above is backed by `context-prune-diagnostic` session entries - session-log-only, never added to what the model sees. Full mechanics: [PRUNING.md § Diagnostics](PRUNING.md#diagnostics).
93
+ The status-line `diag u<N>/m<N>/o<N>/b<N>` segment above is backed by `context-prune-diagnostic` session entries - session-log-only, never added to what the model sees. Full mechanics: [PRUNING.md § Diagnostics](PRUNING.md#diagnostics).
94
+
95
+ ### Uncovered chains compress too
96
+
97
+ `/pruner compact` and the automatic flush both compress eligible chains **even when no per-batch summary ever covered them** - a trivial batch, an oversized-skip, a fully-deduped batch, or a plain capture miss all used to strand the chain permanently with a `no-summary` skip. These chains now get a deterministic, zero-LLM-cost stub body (call count, tool histogram, span duration, working `t<N>` refs) instead; the raw tool outputs are archived exactly like the covered path and stay recoverable via `context_tree_query`. **Exception:** a zero-coverage chain whose middle calls are *all* protected stays uncompressed (plain `no-summary` skip, no diagnostic) - every output would relocate verbatim into the synthetic body anyway, so compressing saves nothing. Full mechanics: [PRUNING.md § Deterministic fallback (uncovered chains)](PRUNING.md#deterministic-fallback-uncovered-chains).
98
+
99
+ **Limitation:** a chain stranded in an otherwise-idle session is not healed by `/pruner now` on an empty queue (the flush returns early before chain detection runs at all) - it heals on the next flush that has any work, or immediately via `/pruner compact`.
94
100
 
95
101
  ### Context metrics (`context-prune-flush-metrics`)
96
102
 
@@ -160,7 +166,7 @@ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_A
160
166
  | `enabled` | `false` | Master switch (or just use `/pruner on`) |
161
167
  | `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) |
162
168
  | `pruneOn` | `agent-message` | Trigger mode - see Architecture above |
163
- | `autoBudgetThreshold` | `null` | Fraction (e.g. `0.8`) of the context window that force-flushes everything regardless of `pruneOn` |
169
+ | `autoBudgetThreshold` | `null` | Fraction (e.g. `0.8`) of the context window that force-flushes everything regardless of `pruneOn`; the trigger point is capped at 300k tokens |
164
170
  | `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md"]` | Tool names / path globs that are never pruned |
165
171
  | `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
166
172
 
package/index.ts CHANGED
@@ -655,7 +655,8 @@ export default function (pi: ExtensionAPI) {
655
655
  // message_end fires before pi persists the closing assistant, so thread it
656
656
  // in here; otherwise the newest chain reads as open and K over-retains by 1.
657
657
  // branchMessages was unwrapped once above, gated on chainCompression.enabled.
658
- const chains = detectChains(withClosingMessage(branchMessages!, options.closingMessage), protectionPredicate);
658
+ const detectionMessages = withClosingMessage(branchMessages!, options.closingMessage);
659
+ const chains = detectChains(detectionMessages, protectionPredicate);
659
660
  const inGrace = inGraceRecoveryToolCallIds(branchMessages!, currentConfig.value.recoveryGraceTurns);
660
661
  const { compressedEntries } = await compressEligible(
661
662
  chains,
@@ -666,6 +667,14 @@ export default function (pi: ExtensionAPI) {
666
667
  appendEntry: persistAlias,
667
668
  now: () => Date.now(),
668
669
  fuseRange: makeFuseRange(ctx),
670
+ messages: detectionMessages,
671
+ diagnostics,
672
+ backfill: {
673
+ spillThreshold: currentConfig.value.spillThreshold,
674
+ spillPreviewBytes: currentConfig.value.spillPreviewBytes,
675
+ sessionDir: ctx.sessionManager.getSessionDir(),
676
+ sessionId: ctx.sessionManager.getSessionId(),
677
+ },
669
678
  },
670
679
  inGrace,
671
680
  );
@@ -1043,6 +1052,14 @@ export default function (pi: ExtensionAPI) {
1043
1052
  appendEntry: (type: string, data: unknown) => pi.appendEntry(type, data),
1044
1053
  now: () => Date.now(),
1045
1054
  fuseRange: makeFuseRange(ctx),
1055
+ messages: branchMessages,
1056
+ diagnostics,
1057
+ backfill: {
1058
+ spillThreshold: currentConfig.value.spillThreshold,
1059
+ spillPreviewBytes: currentConfig.value.spillPreviewBytes,
1060
+ sessionDir: ctx.sessionManager.getSessionDir(),
1061
+ sessionId: ctx.sessionManager.getSessionId(),
1062
+ },
1046
1063
  },
1047
1064
  inGrace,
1048
1065
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.7.0",
3
+ "version": "2.9.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",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "bun:test";
2
- import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./budget.js";
2
+ import { shouldBudgetFlush, shouldDeltaFlush, usageFraction, MAX_BUDGET_WINDOW } from "./budget.js";
3
3
 
4
4
  const usage = (tokens: number | null, contextWindow: number) =>
5
5
  ({ tokens, contextWindow, percent: null }) as any;
@@ -31,6 +31,29 @@ describe("shouldBudgetFlush", () => {
31
31
  expect(shouldBudgetFlush(usage(1000, 1000), 1)).toBe(true);
32
32
  expect(shouldBudgetFlush(usage(999, 1000), 1)).toBe(false);
33
33
  });
34
+
35
+ it("exposes the window ceiling as 300k", () => {
36
+ expect(MAX_BUDGET_WINDOW).toBe(300_000);
37
+ });
38
+
39
+ it("caps the trigger level at MAX_BUDGET_WINDOW on a huge window", () => {
40
+ expect(shouldBudgetFlush(usage(300_000, 1_000_000), 0.4)).toBe(true);
41
+ expect(shouldBudgetFlush(usage(299_999, 1_000_000), 0.4)).toBe(false);
42
+ expect(shouldBudgetFlush(usage(300_000, 1_000_000), 0.9)).toBe(true);
43
+ });
44
+
45
+ it("leaves models at or below the ceiling unchanged", () => {
46
+ expect(shouldBudgetFlush(usage(80_000, 200_000), 0.4)).toBe(true);
47
+ expect(shouldBudgetFlush(usage(79_999, 200_000), 0.4)).toBe(false);
48
+ expect(shouldBudgetFlush(usage(230_400, 256_000), 0.9)).toBe(true);
49
+ expect(shouldBudgetFlush(usage(230_399, 256_000), 0.9)).toBe(false);
50
+ expect(shouldBudgetFlush(usage(300_000, 300_000), 1)).toBe(true);
51
+ });
52
+
53
+ it("lets a low threshold govern below the ceiling on a huge window", () => {
54
+ expect(shouldBudgetFlush(usage(100_000, 1_000_000), 0.1)).toBe(true);
55
+ expect(shouldBudgetFlush(usage(99_999, 1_000_000), 0.1)).toBe(false);
56
+ });
34
57
  });
35
58
 
36
59
  describe("usageFraction", () => {
@@ -39,9 +62,14 @@ describe("usageFraction", () => {
39
62
  expect(usageFraction(usage(null, 1000))).toBeNull();
40
63
  expect(usageFraction(usage(900, 0))).toBeNull();
41
64
  });
42
- it("returns the 0–1 fraction", () => {
65
+ it("returns the fraction against the effective window", () => {
43
66
  expect(usageFraction(usage(750, 1000))).toBe(0.75);
44
67
  });
68
+
69
+ it("is not clamped above 1 when the window exceeds the ceiling", () => {
70
+ expect(usageFraction(usage(600_000, 1_000_000))).toBe(2);
71
+ expect(usageFraction(usage(80_000, 200_000))).toBe(0.4);
72
+ });
45
73
  });
46
74
 
47
75
  describe("shouldDeltaFlush", () => {
@@ -63,4 +91,23 @@ describe("shouldDeltaFlush", () => {
63
91
  expect(shouldDeltaFlush(usage(640, 1000), 0.5, 0.15)).toBe(false); // 0.14 < 0.15
64
92
  expect(shouldDeltaFlush(usage(600, 1000), 0.5, 0.15)).toBe(false); // 0.10 < 0.15
65
93
  });
94
+
95
+ it("measures growth against the capped window on a huge-window model", () => {
96
+ // previousFraction for 100k tokens on a 1M window = 100_000 / 300_000
97
+ const prev = 100_000 / MAX_BUDGET_WINDOW;
98
+ expect(shouldDeltaFlush(usage(130_000, 1_000_000), prev, 0.1)).toBe(true);
99
+ expect(shouldDeltaFlush(usage(120_000, 1_000_000), prev, 0.1)).toBe(false);
100
+ });
101
+
102
+ it("leaves the growth requirement unchanged at or below the ceiling", () => {
103
+ const prev = 40_000 / 200_000; // 0.2
104
+ expect(shouldDeltaFlush(usage(100_000, 200_000), prev, 0.3)).toBe(true); // +60k
105
+ expect(shouldDeltaFlush(usage(99_000, 200_000), prev, 0.3)).toBe(false); // +59k
106
+ });
107
+
108
+ it("keeps re-arming above the ceiling, where the fraction exceeds 1", () => {
109
+ const prev = 600_000 / MAX_BUDGET_WINDOW; // 2.0 - unclamped by design
110
+ expect(shouldDeltaFlush(usage(630_000, 1_000_000), prev, 0.1)).toBe(true);
111
+ expect(shouldDeltaFlush(usage(620_000, 1_000_000), prev, 0.1)).toBe(false);
112
+ });
66
113
  });
package/src/budget.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  import type { ContextUsage } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ // Ceiling on what the budget triggers treat as the context window. Advertised
4
+ // windows reach 1M, which makes any (0,1] fraction unreachable in a real session.
5
+ // The two triggers apply it in different shapes on purpose: the threshold is a
6
+ // LEVEL, so the cap bounds the level itself (min(CAP, threshold * window)); the
7
+ // delta is a GROWTH RATE, where a 300k ceiling could never bind, so the cap
8
+ // enters through the denominator instead (delta * min(window, CAP)).
9
+ export const MAX_BUDGET_WINDOW = 300_000;
10
+
3
11
  /**
4
- * True iff a budget-triggered flush should fire. Computes the ratio ourselves
5
- * (tokens / contextWindow, a 0–1 fraction) rather than using ContextUsage.percent
6
- * (a 0–100 value, null when tokens is null). tokens is also null right after a
7
- * compaction — guarded here.
12
+ * True iff a budget-triggered flush should fire: at `threshold` of the model's
13
+ * window, or at MAX_BUDGET_WINDOW tokens, whichever comes first. Computes the
14
+ * level ourselves rather than using ContextUsage.percent (a 0–100 value, null
15
+ * when tokens is null). tokens is also null right after a compaction — guarded here.
8
16
  */
9
17
  export function shouldBudgetFlush(
10
18
  usage: ContextUsage | undefined,
@@ -12,17 +20,23 @@ export function shouldBudgetFlush(
12
20
  ): boolean {
13
21
  if (threshold == null || threshold <= 0 || threshold > 1) return false;
14
22
  if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return false;
15
- return usage.tokens / usage.contextWindow >= threshold;
23
+ return usage.tokens >= Math.min(MAX_BUDGET_WINDOW, threshold * usage.contextWindow);
16
24
  }
17
25
 
18
- /** 0–1 usage fraction, or null when usage is missing / tokens null / window non-positive. */
26
+ /**
27
+ * Usage fraction against the effective window (min(contextWindow, MAX_BUDGET_WINDOW)),
28
+ * or null when usage is missing / tokens null / window non-positive. NOT bounded by 1:
29
+ * 600k tokens on a 1M window returns 2.0. Deliberately unclamped — clamping would make
30
+ * shouldDeltaFlush saturate above the ceiling and stop re-arming.
31
+ */
19
32
  export function usageFraction(usage: ContextUsage | undefined): number | null {
20
33
  if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return null;
21
- return usage.tokens / usage.contextWindow;
34
+ return usage.tokens / Math.min(usage.contextWindow, MAX_BUDGET_WINDOW);
22
35
  }
23
36
 
24
37
  /**
25
- * True iff this turn's usage fraction rose by at least `delta` versus the previous turn.
38
+ * True iff this turn's usage fraction rose by at least `delta` versus the previous turn,
39
+ * i.e. growth of at least `delta * min(contextWindow, MAX_BUDGET_WINDOW)` tokens.
26
40
  * Mirrors shouldBudgetFlush's guards. previousFraction === null (first turn or post-restart)
27
41
  * never fires; the absolute autoBudgetThreshold covers that gap.
28
42
  */