pi-condense 2.6.0 → 2.8.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,25 @@ 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.8.0] - 2026-08-13
11
+
12
+ - **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.
13
+ - `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.
14
+ - `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).
15
+ - **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.
16
+ - `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.
17
+ - Docs updated in step: `README.md`, `doc/configuration.md`, `PRUNING.md`. Spec: `doc/specs/2026-08-13-budget-window-cap.md`.
18
+
19
+ ## [2.7.0] - 2026-08-12
20
+
21
+ - **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.
22
+ - **Reload rearm.** `session_start` / `session_tree` now probe the branch via the existing capture rescan (no LLM work); when completed unsummarized batches exist past the frontier, a transient `rearmedPending` flag makes the `turn_end` budget/delta gate reachable without a freshly pushed batch - the next threshold crossing flushes automatically. Boolean only: no queue reconstruction, no flush at boot; reload followed by total idleness gets visibility, not silent compaction. Probe failures are reported (`console.error`) and never fail the reload. With the flag unset, `turn_end` behavior is byte-identical to before (regression-pinned).
23
+ - **Context metrics.** New pure module `src/context-metrics.ts` computes what the pruner cannot (yet) reclaim: **open-cycle thinking tokens** (thinking blocks in the trailing open segment), **largest-chain share** (max of largest closed chain vs the open segment, % of total branch chars), and **frontier gap** (summarization-eligible unsummarized toolResults past the frontier, occurrence-keyed and protection-aware). All `chars/4` estimates over the persisted branch, including persisted summary messages.
24
+ - **Surfacing.** `/pruner status` grows a `--- context ---` block (plus `rearmed: yes` when armed); the footer status line grows a self-hiding ` · think Nk · gap Nk · chain P%` suffix (rendered only while the frontier gap is non-zero); `agent_end` shows `prune: recovered pending (reload)` instead of a misleading `0 pending`.
25
+ - **New `context-prune-flush-metrics` session entry.** One per flush attempt - every outcome including `empty` and `error`, single `finally` emit site, delivery-routed writer - recording the trigger (`budget` / `delta` / `message-end` / `manual` / `rearmed`), batch counts, and the pre-flush metrics snapshot. Append-only observability log: never in LLM context, never reconstructed on load; exists so the next incident of this shape is diagnosable post-hoc. `cost:external` payload and `SummarizerStats` semantics unchanged.
26
+ - **Docs.** `PRUNING.md` documents the single-chain limitation (a design property of Phase 3) with `autoBudgetThreshold` / `budgetTurnDelta` guidance for long autonomous runs; README, `doc/configuration.md`, and the AGENTS.md entry table cover the new surfaces. Spec: `doc/specs/2026-08-12-single-chain-observability-trigger-repair.md`.
27
+ - Also hardens the test suite's `@earendil-works/pi-ai/compat` mocks to preserve real module exports (they were silently layout-sensitive under flat/deduped node_modules installs).
28
+
10
29
  ## [2.6.0] - 2026-08-12
11
30
 
12
31
  - **Fix: reused provider tool-call ids could delete a live turn and produce a rejected request ([#8](https://github.com/jjuraszek/pi-condense/issues/8)).** Provider `toolCallId`s (e.g. `bash_23`) are unique only within one response - some providers restart a `${tool}_${n}` counter, so the same bare id recurs across a session denoting different tool calls. `applyChainCompressions` treated the id as session-durable identity: it unioned every persisted chain entry's `droppedToolCallIds` into one session-wide set and dropped any message matching it anywhere in the array, deleting a live assistant turn that happened to reuse a compressed chain's id and orphaning its tool result - rejected outright by Anthropic (`unexpected tool_use_id found in tool_result blocks`) and Kimi K3 (unresolvable tool name), unrecoverable without hand-editing the session JSONL. The same bare ids also mis-keyed the indexer, dedup, `isSummarized`, and batch capture.
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
 
@@ -965,6 +967,33 @@ The `context-prune-chain` session entry carries the matching `protectedToolCallI
965
967
  - **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.
966
968
  - **Multi-turn span merging.** Each compressed span maps 1:1 to a closed chain (one user -> text-only-assistant round). Merging several consecutive closed spans into one topic summary is future work.
967
969
 
970
+ ### Single-chain sessions
971
+
972
+ Phase 3 (chain compression) only ever acts on **closed** chains - a chain closes when a text-only assistant reply follows the tool-call round (see [What a closed chain is](#what-a-closed-chain-is)). A session that runs one long, uninterrupted tool chain - an autonomous agent loop that never emits a text-only reply - never closes a chain, so the rolling window has nothing to compress. The entire run accumulates as one open segment that Phase 3 cannot touch. This is a known design property, not a bug: closed-chain detection is load-bearing for Phase 3's positional-range resolution (`resolveRange`), and there is no forced-close/reopen valve (see [Deferred](#deferred) above and issue #6 - a corpus replay would be needed before any such valve is considered).
973
+
974
+ 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:
975
+
976
+ - 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.
977
+ - 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.
978
+
979
+ 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.
980
+
981
+ **Observability metrics** (`src/context-metrics.ts`, `computeContextMetrics`) exist precisely to make this shape of session visible instead of silently reporting `calls: 1` the way the triggering incident did. All three are chars/4 token estimates (`Math.round`, same convention as the reclaim footer) and surface on `/pruner status` (a `--- context ---` block), the footer status line (a compact suffix, shown only when the frontier gap is non-zero), and a `context-prune-flush-metrics` session entry written once per flush attempt regardless of outcome:
982
+
983
+ | Metric | Definition |
984
+ |---|---|
985
+ | Open-cycle thinking tokens | Est. tokens of `thinking` content blocks in assistant messages strictly after the last text-only assistant reply (the open segment). Not windowed by the frontier, so a skip/oversized/trivial outcome (which advances the frontier without removing anything from context) never masks stranded thinking as ~0. |
986
+ | Largest-chain share | `max(largest closed chain, open segment)` chars, as a percentage of total branch chars. Interrupted chains count as closed for this purpose - they are retained context regardless of compressibility. A single-chain session's whole branch is its open segment, so this reads near 100% for the incident shape. |
987
+ | Frontier gap | Est. tokens of `ToolResultMessage`s after the persisted prune frontier that are summarization-eligible: not already summarized, not protected (`protectedTools`/`protectedPaths`). 0 when there is nothing left to capture. |
988
+
989
+ See `doc/specs/2026-08-12-single-chain-observability-trigger-repair.md` for the incident and full design rationale.
990
+
991
+ ### Reload rearm
992
+
993
+ A reload (`session_start` or `session_tree`) rescans the branch for completed-but-unflushed tool-call batches past the frontier (the same rescan `flushPending` itself uses, no queue reconstruction). If that rescan finds recoverable work, a transient in-memory flag arms: the next `turn_end` evaluates the budget/delta trigger even on a turn that contributes no new tool results itself, instead of silently requiring a fresh batch to reach the gate. No flush runs at reload time - arming only changes what an *existing* trigger sees on the next turn.
994
+
995
+ The flag clears the moment any flush attempt runs (any outcome) and is re-evaluated on the next reload. A reload followed by total idleness - no further turns at all - gets visibility only: the footer, `/pruner status`, and the `agent_end` pending notice (`prune: recovered pending (reload)`) all reflect the recoverable work, but nothing flushes automatically. This is by design - no immediate flush at boot (print-mode sessions may die under it; a boot-time compact was not requested by the user).
996
+
968
997
  ---
969
998
 
970
999
  ## Occurrence Identity
package/README.md CHANGED
@@ -84,6 +84,7 @@ Every summarizer cost update is emitted on the shared `pi.events` channel `cost:
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
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 |
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) |
87
88
  | Prompt-cache interaction | Why batching (not per-turn pruning) is the default - see [PRUNING.md](PRUNING.md#how-prefix-caching-works) |
88
89
  | `cost:external` | The shared cost-reporting channel pi-condense emits on (see above) |
89
90
 
@@ -91,6 +92,16 @@ Every summarizer cost update is emitted on the shared `pi.events` channel `cost:
91
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
94
 
95
+ ### Context metrics (`context-prune-flush-metrics`)
96
+
97
+ Three metrics the pruner cannot yet reclaim - open-cycle thinking tokens, largest-chain share (%), frontier gap tokens - surface in three places, all backed by `computeContextMetrics` (`src/context-metrics.ts`):
98
+
99
+ - `/pruner status` prints a `--- context ---` block: `thinking:`, `chain share:`, `frontier gap:`, plus a `rearmed: yes` line while a reload-rearm probe (below) has recoverable work armed.
100
+ - The footer status line appends `· think Nk · gap Nk · chain P%` - only when the frontier gap is non-zero, so an idle session's footer is unchanged.
101
+ - Each flush attempt (every outcome, including empty/error) writes one `context-prune-flush-metrics` session entry with the pre-flush snapshot - session-log-only, never added to what the model sees, and not reconstructed on reload.
102
+
103
+ These are most informative for long single-chain sessions where Phase 3 (chain compression) never gets a closed chain to act on - see [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) for the limitation and config guidance, and [PRUNING.md § Reload rearm](PRUNING.md#reload-rearm) for how a reload with recoverable pending work re-arms the automatic flush trigger.
104
+
94
105
  ## When to use / when NOT to use
95
106
 
96
107
  **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.
@@ -149,7 +160,7 @@ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_A
149
160
  | `enabled` | `false` | Master switch (or just use `/pruner on`) |
150
161
  | `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) |
151
162
  | `pruneOn` | `agent-message` | Trigger mode - see Architecture above |
152
- | `autoBudgetThreshold` | `null` | Fraction (e.g. `0.8`) of the context window that force-flushes everything regardless of `pruneOn` |
163
+ | `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 |
153
164
  | `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md"]` | Tool names / path globs that are never pruned |
154
165
  | `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
155
166