feat(pi-condense): vendor source and add summarizer retries

This commit is contained in:
云服务部-叶林立
2026-08-31 15:13:41 +08:00
parent 8002d7c213
commit e80959cc3f
94 changed files with 21327 additions and 185 deletions
@@ -0,0 +1,414 @@
---
title: Protected-tool chain safety, empty-summary guard, typecheck fix, token-budget auto-flush
date: 2026-05-30
status: draft
branch: protected-chain-budget
worktree: .worktrees/protected-chain-budget
supersedes: untracked .agents/plans/034-protectedTools-dedup-pending-fixes.md (deleted)
---
# Summary
Four changes shipped together. Three are correctness/hygiene fixes surfaced by the DCP-parity
review; one is an opt-in feature (default-off).
| # | Item | Type | Default behavior change |
|---|---|---|---|
| 1 | `protectedTools` × chain compression: relocate protected outputs into the compressed-chain body instead of dropping them | correctness | none (only fires when `protectedTools` non-empty) |
| 2 | Empty / truncated summary soft-fail guard | correctness | none (only changes the empty/`length` failure path) |
| 3 | Test fixtures `fuseRangeSummary` typecheck fix | hygiene | none |
| 4 | Token-budget auto-flush (`autoBudgetThreshold`) | feature, opt-in | none (`null` = off) |
**Regression invariant for the whole release:** with default config
(`protectedTools: []`, `autoBudgetThreshold: null`) every code path below is inert and behavior
is byte-identical to today. Items 1 and 4 only activate on explicit opt-in. Item 2 only changes
behavior when the summarizer returns empty or truncated text (today a latent bug). Item 3 is
test-only.
Dropped from the original review scope: the `selectEligible` "no-summary blocks next-oldest"
claim (false positive — the loop `continue`s past no-summary chains and processes all remaining
eligible chains; `chain-compressor.ts:88-95`), and the content-hash dedup normalization /
args-blind concerns (downgraded to Minor; tracked separately, not in this release).
---
# Item 1 — protectedTools × chain compression
## Problem (confirmed against source)
`protectedTools` is an allowlist of tool names whose outputs must never be pruned
(`types.ts` `ContextPruneConfig.protectedTools`, doc-comment: "outputs must NEVER be pruned or
summarized"). Enforced at capture time: `turn_end` filters them out of the `CapturedBatch`
(`index.ts:710-722`) so they are **never indexed** and stay verbatim in context.
Chain range-compression violates this:
1. `detectChains` adds **every** middle `toolCallId` to `ChainRange.middleToolCallIds`, protected
included (`chain-detector.ts:62-71`).
2. `compressEligible` sets `entry.droppedToolCallIds = chain.middleToolCallIds` with no protection
filter (`chain-compressor.ts:118`). The eligibility gate `hasPerBatchSummaryCoveringAny`
passes whenever **any one** sibling tool in the chain was summarized
(`chain-compressor.ts:92`), so a mixed chain (≥1 summarized + ≥1 protected) is eligible.
3. `applyChainCompressions` drops any `toolResult` whose id ∈ `droppedToolCallIds`
(`chain-range-prune.ts:79`).
Because protected outputs are never indexed, a dropped protected output is **unrecoverable** via
`context_tree_query`. Trigger: `protectedTools` non-empty (e.g. the documented `todowrite`/
`todoread`) + `chainCompression.enabled` (default on). Default `protectedTools: []` masks it
out-of-box, but the documented opt-in breaks the contract.
## Why not the obvious fix (skip mixed chains — rejected "Approach C")
Skipping any chain that contains a protected tool would forfeit a large fraction of compaction for
exactly the users who enable the feature: a chain is `[user] → [tool turns…] → [text]`
(`chain-detector.ts:30`), i.e. usually a whole task, and high-frequency protected tools
(`todowrite`/`todoread`/`edit`) recur throughout most tasks. Most chains would become ineligible.
Rejected.
Also rejected — "Approach B1" (exclude protected ids from `droppedToolCallIds`, keep the protected
turn in place): a mixed assistant turn that calls both a protected and a non-protected tool is
dropped as a whole (`chain-range-prune.ts:84-87` drops the assistant message if *any* of its
toolCall ids is dropped), which would orphan the surviving protected `toolResult` (no matching
`toolCall`). pi-ai's `insertSyntheticToolResults` repairs orphaned *calls*, not orphaned
*results*, so this risks breaking role alternation.
## Design (chosen — "Approach B", DCP-faithful: relocate)
Keep the contiguous range-drop exactly as today (protected ids stay in `droppedToolCallIds`, so the
protected `toolResult` and its assistant turn drop cleanly — no orphans, no alternation risk), but
**relocate** the protected outputs' verbatim text into the synthetic `<compressed-chain>` body.
Protected outputs are never indexed, but the `context` event always re-runs `pruneMessages` on the
**raw branch** (session is never mutated), so the protected `toolResult` is always present at render
time. Therefore the verbatim text is pulled live from `messages` during
`applyChainCompressions` — nothing large is persisted in the entry (only the id list).
### Data model change
`ChainRange` (detection-time, `types.ts`): add
```ts
/** Subset of middleToolCallIds whose tool name ∈ protectedTools (detection-time fact). */
protectedToolCallIds: string[];
```
`ChainCompressionEntry` (persisted, `types.ts`): add
```ts
/**
* Subset of droppedToolCallIds whose tool was user-protected. Membership is decided
* per call by tool name (every call whose name ∈ protectedTools), not a per-id allowlist —
* so every todowrite call in the chain is captured regardless of its id. Their verbatim
* ToolResultMessage text is relocated into the synthetic <compressed-chain> body
* at render time (pulled live from the raw branch) instead of being dropped.
* Absent/empty ⇒ no protected outputs in this chain (identical to pre-feature behavior).
*/
protectedToolCallIds?: string[];
```
### Code changes
1. **`src/chain-detector.ts`** — `detectChains(messages, protectedTools: string[] = [])`:
- Build a `Set<string>` from `protectedTools`.
- While collecting middle ids, also record protected ones. The assistant branch has
`b.name`+`b.id` (`collectToolCallIds` currently drops the name); the toolResult branch has
`msg.toolName`+`msg.toolCallId`. Track a per-chain `protectedIds: Set<string>` alongside
`middleIds` and populate from both branches when the name is in the protected set.
- Emit `protectedToolCallIds: [...protectedIds]` on every `ChainRange` (including the
`emitInterrupted` path). Default param `[]` ⇒ always empty ⇒ current behavior.
2. **`src/chain-compressor.ts`** — `compressEligible`:
- Copy `protectedToolCallIds` from the `ChainRange` onto the `ChainCompressionEntry`
(`...(chain.protectedToolCallIds?.length ? { protectedToolCallIds: chain.protectedToolCallIds } : {})`).
- `droppedToolCallIds` is unchanged (still `chain.middleToolCallIds`, protected included).
- No new dependency: names were resolved in the detector; the compressor only forwards ids.
3. **`src/chain-range-prune.ts`** — `applyChainCompressions` + `buildSyntheticChainMessage`:
- Before the rebuild loop, build `protectedTextByBlock: Map<blockId, {tool: string; text: string}[]>`
by scanning `messages` once: for each `toolResult` whose `toolCallId` is in some entry's
`protectedToolCallIds`, push `{ tool: msg.toolName, text: extractToolResultText(msg) }` under
that entry's `blockId`. Preserve message order.
- `buildSyntheticChainMessage` gains a `protectedOutputs: {tool; text}[]` param. Render each as a
verbatim block appended after the (substituted) summary, inside the `<compressed-chain>` tag:
```
<compressed-chain id="b7" tools="t1,t2">
{resolvedSummary}
<protected-output tool="todowrite">
{verbatim text}
</protected-output>
</compressed-chain>
```
No `id=` attribute on `<protected-output>` (not recoverable via `context_tree_query` — must not
look queryable). Empty `protectedOutputs` ⇒ no block ⇒ byte-identical to today's render.
- Protected `toolResult`s are still dropped by the existing `droppedToolCallIds` filter — the
relocation does not change the drop set.
4. **`src/batch-capture.ts`** — extract the inline toolResult text logic (lines 28-38) into an
exported `extractToolResultText(msg): string` and reuse it in both `captureBatch` and
`chain-range-prune` so the relocated text is identical to what capture would have produced.
5. **`index.ts`** — pass `currentConfig.value.protectedTools` to both `detectChains` call sites
(`:521` flush path via `withClosingMessage`, `:859` `/pruner compact`).
6. **Session rebuild** — confirm the `session_start` rebuild that replays `CUSTOM_TYPE_CHAIN`
entries into the chain registry preserves the new optional `protectedToolCallIds` (it should, if
it stores the entry as-is; verify in `indexer.ts` `registerChain` / rebuild and add coverage).
### Edge cases
- **Chain of only protected tools** (no summarizable sibling): `hasPerBatchSummaryCoveringAny`
returns false (protected tools are never summarized) ⇒ chain ineligible ⇒ never compressed.
Naturally safe; no special-casing.
- **Protected toolResult missing at render** (truncated/edited history): skip that id (best-effort);
the rest of the chain still compresses.
- **Protected tool's call args** (in the assistant `toolCall` block) are dropped with the turn. The
contract protects *outputs*, not call args (matches DCP). Documented, not preserved. The relocated
block is labeled `<protected-output tool="name">`; the feature targets self-describing state tools
(`todowrite`/`todoread`) whose output stands alone. Protecting a tool whose output is meaningless
without its args (e.g. `read_file`, where the path lives in the args) is a misuse, not a goal of
this release.
- **Large protected outputs** inflate the synthetic message. Inherent to "keep verbatim" — net
context size is comparable to leaving the raw `toolResult` in place. No size guard in this release.
- **`protectedTools` changed after compression**: the entry's `protectedToolCallIds` is fixed at
compression time, so already-compressed chains keep their relocation regardless of later config
edits. Correct and stable — by design.
---
# Item 2 — empty / truncated summary guard
## Problem (confirmed)
`runSummarization` (`summarizer.ts:171-180`) returns `{ summaryText: llmText, usage }` where
`llmText` is the joined text blocks. Two unsafe cases pass through:
- **Empty text** (e.g. a reasoning model emits only `thinking`, no `text`): `llmText === ""`. In
`flushPending`, `summaryText = "" + formatSummaryToolCallRefs(refs)` (footer only),
`shouldSkipOversized = footer.length > batchRawCharCount` is usually false ⇒ the footer-only
"summary" is injected and the raw batch is pruned behind it (`index.ts:393-394`).
- **Truncated** (`response.stopReason === "length"`): currently unhandled (only `aborted`/`error`
are; `summarizer.ts:159-166`) ⇒ a partial summary is treated as complete.
## Design
Treat both as a **soft failure** (return `null`). `flushPending` already maps a `null` per-batch
result to `firstFailureIndex` → `restoreBatches(...)`, keeping the raw batch in context
(`index.ts:431-447`), and reports `summarizer-failed` only if nothing processed. This is the exact
desired behavior — no raw pruned behind a useless summary, retried next flush.
### Code change (`src/summarizer.ts`)
Extract a pure decision helper for testability and wire it into `runSummarization` after computing
`llmText`:
```ts
/** A summary is usable only if it has non-whitespace text and was not truncated. */
export function isUsableSummary(llmText: string, stopReason: string): boolean {
return llmText.trim().length > 0 && stopReason !== "length";
}
```
In `runSummarization`, after building `llmText`:
```ts
if (!isUsableSummary(llmText, response.stopReason)) return null;
```
Both the per-batch (`summarizeBatch`) and range-fusion (`summarizeRange`) callers already handle
`null`: range fusion falls back to per-batch concatenation (`chain-compressor.ts:104-112`), per-batch
restores the batch. No caller changes needed.
---
# Item 3 — test fixtures typecheck fix
## Problem (confirmed)
`fuseRangeSummary` is a required field of `ChainCompressionConfig` (`types.ts`), but two test
fixtures omit it:
- `src/pruner.test.ts:31` → TS2741
- `src/pruner.test.ts:299` → TS2345
Runtime tests pass (the field is unread by those cases), but the repo does not typecheck. AGENTS.md
mandates a clean `tsc` before commit.
## Design
Set `fuseRangeSummary: false` on both literals (concrete, no runtime effect):
- `:31` is the shared `enabledCC` const — these tests pass no `fuseRange` dep and assert no fusion, so `false` keeps behavior.
- `:299` is an inline literal with `enabled: false`, so the value is inert.
Two-line change.
**Done-when:** the AGENTS.md typecheck command runs clean across the whole repo **including tests**:
```
bun x tsc --noEmit --target es2022 --module nodenext --moduleResolution nodenext --strict \
--skipLibCheck --allowJs --esModuleInterop --resolveJsonModule --lib es2022 --types node \
index.ts src/*.ts src/*.test.ts
```
---
# Item 4 — token-budget auto-flush (opt-in)
## Motivation + DCP lineage
DCP nudges/compresses when context approaches `maxContextLimit`. This fork only triggers flushes on
turn/agent-message/context-tag boundaries — a long task can pile up raw tool output and approach the
context limit *between* flush boundaries (e.g. many tool turns before the final text response in
`agent-message` mode). A single opt-in threshold closes that gap: when context usage crosses the
threshold, flush the pending batches immediately regardless of `pruneOn`.
## Design
### Config (`src/types.ts`)
Add to `ContextPruneConfig`:
```ts
/**
* Token-budget auto-flush trigger. When set to a fraction in (0, 1] (a 0–1 share of the
* context window, NOT a 0–100 percentage; e.g. 0.8 = flush at 80% of the window), a flush of all
* pending batches is forced at the end of any tool-using turn once context usage
* (tokens / contextWindow) reaches the threshold — regardless of `pruneOn`. This is an
* ADDITIONAL trigger layered on top of `pruneOn`, not a replacement; it lets long tasks
* compact before hitting the context limit instead of waiting for the next flush boundary.
*
* null (default) = disabled, exactly preserving pre-feature behavior. Out-of-range values
* (<= 0 or > 1) are treated as disabled.
*/
autoBudgetThreshold: number | null;
```
`DEFAULT_CONFIG.autoBudgetThreshold = null`.
### Pure helper (`src/budget.ts`, new)
```ts
import type { ContextUsage } from "@mariozechner/pi-coding-agent";
/**
* True iff a budget-triggered flush should fire. Computes the ratio ourselves
* (tokens / contextWindow, a 0–1 fraction) rather than ContextUsage.percent (a 0–100 value,
* null when tokens is null). tokens is also null right after a compaction — guarded below.
*/
export function shouldBudgetFlush(usage: ContextUsage | undefined, threshold: number | null): boolean {
if (threshold == null || threshold <= 0 || threshold > 1) return false;
if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return false;
return usage.tokens / usage.contextWindow >= threshold;
}
```
(`ContextUsage` = `{ tokens: number | null; contextWindow: number; percent: number | null }`,
verified in `@mariozechner/pi-coding-agent` types. `getContextUsage(): ContextUsage | undefined` is
on `ExtensionContext`.)
### Trigger wiring (`index.ts`, `turn_end`)
At the **tail** of the `turn_end` handler (after the existing `pruneOn` dispatch block, ~`:755`):
```ts
if (
currentConfig.value.pruneOn !== "every-turn" &&
shouldBudgetFlush(ctx.getContextUsage?.(), currentConfig.value.autoBudgetThreshold) &&
!isFlushing
) {
const before = pendingBatches.length;
if (before > 0) {
safeNotify(ctx, `pruner: context budget reached — compacting ${before} pending turn(s)`, "info");
await flushPending(ctx, { delivery: "session" });
}
}
```
Notes:
- `every-turn` already flushed above ⇒ excluded (no double flush).
- `isFlushing` (existing guard, `index.ts:63`) makes this race-safe; `flushPending` also re-checks
and returns `already-flushing`.
- Budget flush only summarizes **completed** turns' pending batches. The current open chain is not
closed here (the detector drops open chains), so chain compression is untouched mid-task — only
per-batch summarization runs. Safe.
- Guarded on `before > 0` so a hot-but-empty queue (everything already pruned) is a no-op.
### Settings surface (`src/commands.ts`, `src/config.ts`)
- `config.ts`: normalize/clamp on load — coerce non-number/out-of-range to `null`.
- `commands.ts`: add a cycling preset to the `/pruner` settings overlay and a
`AUTO_BUDGET_PRESETS` list in `types.ts`:
```ts
export const AUTO_BUDGET_PRESETS: { value: string; label: string }[] = [
{ value: "0", label: "Off (default)" },
{ value: "0.6", label: "60%" },
{ value: "0.7", label: "70%" },
{ value: "0.8", label: "80%" },
{ value: "0.9", label: "90%" },
];
```
`"0"` is the disabled sentinel (maps to `null`), consistent with `minBatchChars`'s `"0"` pattern.
---
# Documentation deliverables (required — feature must be well-documented)
| File | Addition |
|---|---|
| `src/types.ts` | Doc-comments on `autoBudgetThreshold` (units = fraction 0–1, `null`=off, out-of-range=off) and on the two new `protectedToolCallIds` fields (already specified above). |
| `README.md` | New config-table row for `autoBudgetThreshold`. New subsection **"Token-budget auto-flush"**: what it does, default off, fraction semantics (`0.8` = 80% of the window, not `80`), that it's *additional* to `pruneOn`, the `tokens==null`-after-compaction caveat, and the `/pruner` setting. One line in the protectedTools section noting protected outputs are now preserved (relocated into the compressed-chain block) when their chain is compressed. |
| `PRUNING.md` | Short subsection: budget trigger as an additional flush trigger orthogonal to `pruneOn`, why we compute `tokens/contextWindow` as a 0–1 fraction (percent is 0–100 and null when tokens is null; tokens is null right after compaction), and the DCP `maxContextLimit` lineage. Update the chain-compression section to document protected-output relocation (Approach B) and why skip-the-chain (C) was rejected. |
| `AGENTS.md` | Update the `CUSTOM_TYPE_CHAIN` row to note the entry now carries optional `protectedToolCallIds`. |
---
# Testing strategy
All pure-unit where possible; existing 108 tests must stay green and `tsc` must be clean.
| Item | Test | Key cases |
|---|---|---|
| 1 | `chain-detector` | `protectedTools` populates `ChainRange.protectedToolCallIds` for both assistant-call and toolResult branches; empty/omitted ⇒ `[]`; interrupted-chain path also populated. |
| 1 | `chain-range-prune` | Mixed chain: non-protected results dropped + summarized, protected result dropped from position **and** rendered verbatim inside `<compressed-chain>` under `<protected-output tool="…">`; pure chain (no protected) renders byte-identical to pre-change; idempotent across two passes; protected id missing from messages ⇒ skipped, rest compresses; relocated `<protected-output>` is NOT registered in the tool-call index ⇒ not recoverable via `context_tree_query` and carries no queryable id. |
| 1 | `chain-compressor` | `protectedToolCallIds` copied from range onto entry; all-protected chain stays ineligible (no per-batch summary). |
| 1 | session rebuild | `CUSTOM_TYPE_CHAIN` replay preserves `protectedToolCallIds`. |
| 2 | `summarizer` (new file) | `isUsableSummary`: empty/whitespace ⇒ false; `stopReason==="length"` ⇒ false; normal ⇒ true. (summarizer.ts currently has zero tests.) |
| 3 | n/a | `tsc` clean across `index.ts src/*.ts src/*.test.ts`. |
| 4 | `budget` (new file) | `shouldBudgetFlush`: `null`/`0`/`>1` threshold ⇒ false; `usage` undefined ⇒ false; `tokens==null` ⇒ false; `contextWindow<=0` ⇒ false; exactly-at ⇒ true; over ⇒ true; under ⇒ false. |
**Done-when:** new + existing tests green (`bun test`); `tsc` clean; manual smoke per AGENTS.md
(`pi -e ./index.ts …` against an isolated `$PI_CODING_AGENT_DIR`) confirming (a) a protected-tool
output survives a chain compression inside the synthetic block, and (b) setting
`autoBudgetThreshold` forces an early flush.
---
# Implementation order
The four items are **mutually orthogonal** — no shared runtime path and no shared mutable test
state. Specifically, Item 4's budget trigger flushes pending per-batch summaries on `turn_end` and
**never invokes chain compression** (chains only close on `message_end`), so Item 1's protected-output
relocation does not affect Item 4's behavior or its tests. There is therefore no hard ordering
requirement.
Recommended commit/PR sequence for **review hygiene only** (not a dependency): #3 (typecheck — makes
the repo green immediately) → #2 (empty-summary guard) → #1 (protected-tool relocation) → #4
(token-budget). Each can land and be tested in isolation.
---
# Out of scope
- Content-hash dedup normalization / args-blind keying (Minor; separate follow-up).
- `selectEligible` change (withdrawn false positive).
- DCP parity items deliberately deferred (not regressions — features we don't yet have):
`turnProtection`, `protectedFilePatterns`, `protectTags` (DCP's `<protect>` content tags),
`/pruner decompress|recompress`, `focus` param on the compress tool, message-mode compression.
- DCP `protectUserMessages`: N/A in this harness — this extension never prunes user messages (only
tool results and assistant turns), so user messages are already always preserved.
- Size guard on relocated protected outputs (revisit only if large protected outputs bloat context
in practice).
@@ -0,0 +1,147 @@
# Remove underdeveloped `pruneOn` modes (→ v1.0.0)
- **Date:** 2026-05-31
- **Branch / worktree:** `remove-prune-on-modes` (`.worktrees/remove-prune-on-modes`)
- **Status:** spec — awaiting review
- **Release target:** `v1.0.0` (major)
## Summary
Drop three `pruneOn` trigger modes — `every-turn`, `on-context-tag`, `agentic-auto` — and every artifact that exists only to serve them. Keep the two load-bearing modes: `agent-message` (default) and `on-demand`. Delete the dead `remindUnprunedCount` config field. Cut a `v1.0.0` release marking the stable, two-mode core after removing experimental/underdeveloped surface.
The removed surface is either experimental-but-unwired (`agentic-auto`'s `context_prune` tool was scaffolded, not productionized), coupled to an external extension we don't control (`on-context-tag` soft-depends on `ttttmr/pi-context`), or a cache-hostile debugging aid (`every-turn`).
## Motivation & recorded assessment
**Local setup impact: none.** Verified across all three agent profiles:
| Profile | `contextPrune.pruneOn` | Affected? |
|---|---|---|
| `~/.pi/agent` | (no `contextPrune` key) | no |
| `~/.pi/agent.anthropic` | `agent-message` | no |
| `~/.pi/agent.bedrock` | `agent-message` | no |
Both `.anthropic` and `.bedrock` also carry `remindUnprunedCount: true`, which is already a no-op for them (only ever honored in `agentic-auto`).
**Graceful fallback already exists.** `isPruneOn()` in `src/config.ts` validates the persisted `pruneOn` against `PRUNE_ON_MODES`; an unknown/removed value falls back to `DEFAULT_CONFIG.pruneOn` (`agent-message`). So even a config pinned to a removed mode degrades silently rather than erroring.
**pi-context coupling is soft, and overlaps this extension.** There is no package/peer dependency on `ttttmr/pi-context` (`package.json` has zero deps on it). The only coupling is runtime: the `tool_execution_end` handler listens for the `context_tag` / `context_checkpoint` tool names. If pi-context isn't installed, `on-context-tag` silently never fires. Separately, pi-context's own `context_compact` tool performs LLM-driven compaction of a path into a handoff summary — partial functional overlap with this extension's rolling tool-batch summarization (different trigger models: checkpoint/time-travel vs. automatic). Removing `on-context-tag` drops a redundant trigger wired to an uncontrolled extension.
## Scope
| Mode | Disposition | Rationale |
|---|---|---|
| `agent-message` | **keep (default)** | Flushes on the agent's next text response; prefix-cache friendly. |
| `on-demand` | **keep** | Manual flush via `/pruner now`. |
| `every-turn` | **remove** | Debugging-only; worst prompt-cache churn. |
| `on-context-tag` | **remove** | Soft-depends on external `pi-context`; redundant with its `context_compact`. |
| `agentic-auto` | **remove** | Model-driven `context_prune` tool was scaffolded, never productionized. |
End state: `export type PruneOn = "on-demand" | "agent-message";`
## Removal inventory (the contract)
### Files deleted
| File | Why it's now dead |
|---|---|
| `src/reminder.ts` | `annotateWithUnprunedCount` / `countUnprunedToolCalls` feed the `<pruner-note>`, only emitted in `agentic-auto`. |
| `src/context-prune-tool.ts` | Registers the `context_prune` tool, only activated in `agentic-auto`. |
| `src/progress-text.ts` | `pruneProgressText` is imported only by `context-prune-tool.ts`. (`formatCharProgress` lives in `src/stats.ts` and stays — used by the `/pruner now` widget.) |
### `index.ts`
- Remove imports: `annotateWithUnprunedCount, countUnprunedToolCalls` (`./src/reminder.js`); `registerContextPruneTool` (`./src/context-prune-tool.js`); `CONTEXT_PRUNE_TOOL_NAME`, `AGENTIC_AUTO_SYSTEM_PROMPT` (`./src/types.js`).
- Fix initial `currentConfig` value: `{ ...DEFAULT_CONFIG, pruneOn: "every-turn" }` → `{ ...DEFAULT_CONFIG }` (the `every-turn` seed is a pre-`session_start` placeholder and references a removed mode).
- Delete the `syncToolActivation` helper and all calls to it (also its `session_start` call).
- Delete the `tool_execution_end` handler in full (`on-context-tag`).
- Delete the `before_agent_start` handler in full (`agentic-auto` system prompt).
- Delete the `registerContextPruneTool(...)` registration call.
- In the `context` handler, delete the `agentic-auto` `<pruner-note>` block (the `countUnprunedToolCalls` / `annotateWithUnprunedCount` path).
- In `turn_end`: delete the `if (pruneOn === "every-turn") { flush }` branch; drop the `pruneOn !== "every-turn"` clause from the budget-flush guard (now unconditional); collapse the trigger-label switch to the two remaining modes:
```ts
const trigger = currentConfig.value.pruneOn === "agent-message"
? "agent's next text response"
: "/pruner now";
```
- Drop `CONTEXT_PRUNE_TOOL_NAME` from the two capture-exclusion sets (the `turn_end` protected-tool `Set` and the `capturePendingBatches` exclude list); both reduce to `currentConfig.value.protectedTools`.
- Drop the `syncToolActivation` argument from the `registerCommands(...)` call.
### `types.ts`
- `PruneOn`: narrow to `"on-demand" | "agent-message"`; update its doc comment.
- `PRUNE_ON_MODES`: keep only `agent-message` (listed first, the default) and `on-demand`.
- Delete `CONTEXT_PRUNE_TOOL_NAME` and `AGENTIC_AUTO_SYSTEM_PROMPT` consts. (`PROGRESS_WIDGET_ID` stays.)
- Delete `remindUnprunedCount` from the `ContextPruneConfig` interface and from `DEFAULT_CONFIG`.
- Update the `protectedTools` doc comment that references the `agentic-auto` `<pruner-note>`.
### `config.ts`
- Delete the `remindUnprunedCount` normalization block in `normalize()`.
- No other change: `isPruneOn` derives from `PRUNE_ON_MODES`, so trimming that array narrows validation automatically.
- **No migration code added** (per decision below).
### `commands.ts`
- `PRUNE_MODE_GUIDANCE`: remove the `every-turn` / `on-context-tag` / `agentic-auto` entries.
- Delete `remindUnprunedCountDescription` and its uses.
- Settings overlay: remove the `remindUnprunedCount` `SettingItem`, its `onChange` branch, and the cross-update of its description in the `pruneOn` `onChange` branch.
- Remove the `syncToolActivation` parameter from `registerCommands` and every call site inside it.
- `/pruner status`: drop the `remind: … (agentic-auto only)` line.
- `HELP_TEXT`: remove the `prune-on every-turn` / `on-context-tag` / `agentic-auto` lines, the `remindUnprunedCount` note, the `protectedTools` `<pruner-note>` sentence, and the removed-mode guidance bullets. In the `Related:` block, drop the `pi-context` link line (orphaned by removing `on-context-tag`); keep the `Related:` header and the Anthropic prompt-caching bullet.
- Update the comment that parses a selector label (example currently `"every-turn — Every turn"`) to use a surviving mode.
## Config & migration behavior
- **Persisted removed modes** (`pruneOn: "every-turn" | "on-context-tag" | "agentic-auto"`): silently fall back to `agent-message` via `isPruneOn()`. No crash, no warning.
- **Dead `remindUnprunedCount` key:** no strip/migration code is added. Because `normalize()` does `{ ...DEFAULT_CONFIG, ...existing }`, a stale `remindUnprunedCount` in an existing file is carried through into the runtime config object and re-persisted on the next settings write — until the key is physically removed from the file. It is therefore **hand-cleaned from the two settings files after release** (see Release plan). Once removed from the file it cannot reappear: nothing writes it back (the field is gone from the interface, `DEFAULT_CONFIG`, and the settings UI).
- **Rationale for no migration code:** the user explicitly chose hand-clean-after-release over adding a one-line strip. Keeps the change pure-deletion; the release script rewrites the version pin in those same files, so tidying the dead key afterward avoids racing it.
## Documentation changes
| Doc | Edits |
|---|---|
| `README.md` | Mode table: drop the three removed rows, keep `agent-message` + `on-demand`. Remove `remindUnprunedCount` from the example config block and the settings table. Update the `autoBudgetThreshold` note that references `every-turn`. Remove the `context_prune` tool description. Remove the pi-context "Related extensions" link tied to `on-context-tag` (keep unrelated pi-cache-graph tuning references). |
| `PRUNING.md` | **Reframe** the "per-turn pruning trap" ASCII section as *naive per-turn pruning* (a hypothetical bad approach that motivates the batched `agent-message` default), not a selectable mode. Remove the three modes from the comparison table and the trigger decision-tree. Fix the `protectedTools` `<pruner-note>` reference, the `remindUnprunedCount` bullet, the `autoBudgetThreshold`/`every-turn` note, the "scaffolded but not wired" `agentic-auto` compress-tool note, the cache-performance mode mentions, and the pi-context link. |
| `AGENTS.md` | Project-layout block: drop `context-prune-tool` from the `(other src/*.ts)` comment. |
| `CHANGELOG.md` | New top entry for `v1.0.0` (terse bullets, newest-first, dated), recording the mode/field removals and the 1.0 framing. |
| `src/batch-capture.ts` | Update the comment (~line 128) that references `agentic-auto`; the trim-to-ready-tool-calls behavior stays (valid for any flush). |
## Release plan (`v1.0.0`)
Final phase, in order:
1. **Typecheck** — the AGENTS.md one-liner (`bun x tsc --noEmit …`). Must pass.
2. **Pre-release smoke (release gate)** — end-to-end dummy session against an isolated `$PI_CODING_AGENT_DIR`: `pi -e ./index.ts --no-extensions -p "…"`. Confirm the flush path still writes the expected `context-prune-*` session entries and that startup logs no error about missing modes/symbols. Inspect with:
```bash
jq -r 'select(.type=="custom" or .type=="custom_message") | .customType' session.jsonl | sort | uniq -c
```
3. **CHANGELOG entry + commit** on the worktree branch.
4. **Release** via the `release` skill (`release.sh`): bump `package.json` to `1.0.0`, create + push tag `v1.0.0`, auto-rewrite the `git:…@vX.Y.Z` pins in `~/.pi/agent*/settings.json`.
5. **Clean config** — hand-remove the dead `remindUnprunedCount` key from `~/.pi/agent.anthropic/settings.json` and `~/.pi/agent.bedrock/settings.json`.
6. **Final sanity** — one more end-to-end dummy session, now against the real pinned `v1.0.0` + cleaned config, confirming `context-prune-*` entries are written and the session is error-free.
## Testing / verification approach
- **Baseline (captured now):** `bun test src/` → 130 pass, 0 fail. No test references any removed symbol, mode, or deleted file, so the suite stays green by construction.
- **Typecheck** gates the release (step 1 above); catches any missed import or symbol reference.
- **End-to-end dummy session** is the integration gate (steps 2 and 6): pure-deletion changes that typecheck can still break runtime wiring (e.g. a removed handler another path depended on), so a live flush is exercised before and after release.
- No new unit tests: this is removal of code paths that have no dedicated tests; the surviving `agent-message` / `on-demand` paths are already covered by `pruner.test.ts` and friends.
## Edge cases & risks
- **Stale `remindUnprunedCount` re-persisted before hand-clean.** Expected and benign — nothing reads it. Resolved permanently by step 5.
- **A user (not in scope here) on a removed mode.** Falls back to `agent-message`; acceptable for a 1.0 that drops the modes.
- **`PRUNE_ON_MODES` ordering.** Cosmetic (drives only the settings selector + a label-parse). Spec lists `agent-message` first as the default; the label-parse splits on `—` and is order-independent.
- **Broken release tag if step 2 is skipped.** Mitigated by making the pre-release smoke a hard gate before `release.sh`.
## Out of scope
- Touching the surviving `agent-message` / `on-demand` flush logic, batching, dedup, chain compression, or thinking-strip.
- Any change to `package.json` beyond the version bump.
- Adding migration/normalization code for the dead key (explicitly declined).
- Changing pi-context or interacting with it in any way.
## Open questions
- **Changelog wording for the 1.0 framing.** The major bump was justified as "DCP parity + remove underdeveloped parts." This spec records the substance neutrally ("stable two-mode core after removing experimental surface"). If the literal "DCP parity" phrasing should appear verbatim in the `CHANGELOG`/release notes, say so and name what "DCP" refers to; otherwise the neutral phrasing ships.
@@ -0,0 +1,324 @@
# Oversized tool-result spill + budget-delta flush
Date: 2026-06-02
Branch/worktree: `oversized-output-spill`
Status: spec (pre-implementation)
## Problem
A single large tool result can dominate the context window before any
existing pruning mechanism reacts. Observed: a session at ~70% of a 1M-token
window where one `fetch` result was ~1.08 MB (~270K tokens, ~27% of the
window) on its own.
Why nothing "kicked in":
1. **No single-result cap.** Every captured result — regardless of size —
waits for normal batch close → async summarization → rolling-window aging
before its raw body leaves context. A 1 MB output sits at full size for
several turns.
2. **Budget trigger is a single aggregate gate.** `shouldBudgetFlush`
(`src/budget.ts`) fires only when `usage.tokens / usage.contextWindow >=
autoBudgetThreshold`, evaluated once per `turn_end`
(`index.ts:714-730`). The user's `autoBudgetThreshold` was `0.8`
(`DEFAULT_CONFIG` ships `null` = off); at 70% it had not yet tripped, and a
single turn can jump from below to over-limit between two boundaries.
This spec adds two layers:
- **A — eager spill:** synchronously offload any single oversized result to a
file and index it immediately, so it never reaches a request at full size.
- **B — budget-delta flush:** force a flush when one turn adds a large
fraction of the window, independent of the absolute aggregate threshold.
Both reuse existing machinery (`Indexer.addBatch` → `isSummarized` →
`pruneMessages` stub-replace; `shouldBudgetFlush` + `turn_end` flush).
## Ground truth (package namespace)
This extension currently pins and imports **`@mariozechner/*`**
(`pi-coding-agent`, `pi-ai`, `pi-tui`, `pi-agent-core`) — that is what is
installed and what every source file imports, so API verification for this
spec resolves under `node_modules/@mariozechner/pi-coding-agent/dist`.
**Caveat:** the entire `@mariozechner/*` family is **deprecated**; each
package's npm metadata says "please use `@earendil-works/<pkg>` instead going
forward" (the global pi harness already runs `@earendil-works/pi-coding-agent`).
`@earendil-works/*` is the renamed continuation — same symbols, same paths —
so the table below is expected to hold verbatim after migration. Migrating
this repo's peer deps from `@mariozechner` to `@earendil-works` is a
repo-wide dependency rename, **out of scope for this spec** and tracked
separately; this feature builds against whatever the peer deps resolve to at
implementation time.
Symbols this spec depends on, verified present (in the installed
`@mariozechner` types):
| symbol | location | note |
|---|---|---|
| `getSessionDir()`, `getSessionId()`, `getSessionFile()` | `core/session-manager.d.ts:189-191` (on `ReadonlySessionManager`, l.136) | spill dir + id |
| `getContextUsage(): ContextUsage \| undefined` | `core/extensions/types.d.ts:231` | budget triggers |
| `ContextUsage.tokens: number \| null`, `contextWindow: number`, `percent: number \| null` | `core/extensions/types.d.ts:192-198` | **`tokens` is null right after compaction** — must guard |
| `truncateHead(content, options?)`, `DEFAULT_MAX_BYTES`, `DEFAULT_MAX_LINES` | `core/tools/truncate.d.ts` (re-exported from index) | options object, not positional |
## Non-goals
- Changing the `fetch` / `bash` tools (harness-owned, separate repo). A is
tool-agnostic and covers them all.
- Orphan-blob garbage collection / startup sweep. Out of scope for v1; blob
cleanup rides on session deletion only (see Cleanup).
- Replacing the LLM summarizer. Eager-spilled results are NOT summarized by an
LLM; the stub is mechanical.
## A — Eager single-result spill
### Trigger
At tool-result capture (live `turn_end` path in `src/batch-capture.ts`, and
the reconstruction path `captureUnindexedBatchesFromSession`), evaluate each
`CapturedToolCall` independently. A call is **oversized** when
`resultText.length >= spillThreshold` (chars — matches `resultText.length`,
not bytes).
Ordering relative to existing pre-flush passes (precedence is strict):
1. **Protected-tools filter first** — a `protectedTools` result is never
spilled.
2. **Dedup check second** — compute the content hash on the full in-memory
body. If it matches an existing original, alias as today (`registerDuplicate`)
and **do not** write a file. A duplicate of an already-spilled original
aliases to that original's record (and thus its `spillPath`); the byte cost
is paid once.
3. **Spill last** — only a non-duplicate oversized result is written to a
sidecar file and indexed.
The spill + `addBatch` happens before the existing `flushPending` call in the
`turn_end` handler. No extra `context` event is forced; the stub takes effect
on the next natural `context` build (same as today's summarized records).
### Storage (hybrid)
- Bodies `< spillThreshold` stay **inline** in the `context-prune-index`
entry exactly as today (`IndexEntryData.toolCalls[].resultText`). Portable:
the JSONL remains self-contained for all normal content.
- Bodies `>= spillThreshold` are written to a **sidecar file**; the index
entry stores a reference + head preview instead of the full body.
File location:
```
<sessionDir>/<sessionId>-blobs/<toolCallId>.txt
```
- `sessionDir = ctx.sessionManager.getSessionDir()`, `sessionId =
ctx.sessionManager.getSessionId()` (confirmed on `ReadonlySessionManager`,
`session-manager.d.ts`). Persistent root alongside the session JSONL —
survives reboot/resume, not subject to `os.tmpdir()` reapers.
- One file per tool call, full verbatim `resultText`, UTF-8.
- `toolCallId` is filename-safe in practice (provider ids like
`toolu_...`); sanitize defensively (`[^A-Za-z0-9_-]` → `_`) to avoid path
traversal / separators.
### Record / persistence changes
`ToolCallRecord` (`src/types.ts`) gains optional fields:
```ts
spillPath?: string; // absolute path to the sidecar blob
spillBytes?: number; // full byte length of the spilled body
resultPreview?: string; // head preview kept inline when spilled
contentHash?: string; // dedup hash of the FULL body, persisted
```
**`resultText` invariant.** Persisting the full body in the index entry would
defeat the spill (MB back in the JSONL). So for a spilled record the persisted
entry carries `resultPreview` (not the full body), `spillPath`, `spillBytes`,
and `contentHash`. The full `resultText` exists only transiently in memory at
capture time — long enough to compute `contentHash` and write the file — and
is not serialized.
**Dedup hash persistence.** Today `addBatch` computes the content hash from
`resultText` and `reconstructFromSession` recomputes it on reload. A spilled
record has no full `resultText` after reload, so the hash is computed once at
spill time and **persisted** as `contentHash`; reconstruct reads it back
instead of recomputing. Non-spilled records keep the existing
recompute-from-`resultText` path unchanged.
**No summarizer path.** Eager-spilled records bypass
`serializeBatchForSummarizer` entirely (they are never sent to the LLM
summarizer), so there is no double-truncation between preview and summarizer
input.
`IndexEntryData.toolCalls[]` already serializes `ToolCallRecord`, so adding the
optional fields persists them with no new custom entry type.
`Indexer.reconstructFromSession` replays them unchanged; the preview + hash
travel inline, the full body stays on disk.
### What the model sees (stub)
`pruneMessages` already stub-replaces any `isSummarized` tool result. For a
spilled record the stub text is **mechanical** (no LLM summary) and contains:
- tool name + a compact rendering of args,
- size (`spillBytes` and line count),
- the head preview (`resultText`),
- the absolute `spillPath` and an explicit hint:
`read this file (offset/limit supported) to access the full output`,
- the existing short-ref line so `context_tree_query` still works.
This gives file-handle ergonomics: the model slices the body on demand with
the native `read` tool; no LLM round-trip happened on capture.
### Stub branching (`src/pruner.ts`)
`pruneMessages` Phase 1 already replaces a summarized tool result with a single
hardcoded `[Summarized … ref tN]` stub. It must branch on the resolved record:
- `getRecord(toolCallId)` has `spillPath` set → emit the **mechanical spill
stub**: tool name, compact args, `spillBytes` + line count, the
`resultPreview`, the absolute `spillPath`, a `read this file (offset/limit
supported)` hint, and the existing short-ref line.
- otherwise → emit the **existing summary-ref stub** unchanged.
Both branches return a stub message (never delete the result) so pi-ai's
orthan-tool-result repair is not triggered.
### Indexing (synchronous, no summarizer)
Eager spill must make the result `isSummarized` immediately so `pruneMessages`
stubs it on the very next `context` build, without waiting for `flushPending`
or an LLM call. Reuse `Indexer.addBatch` with a single-call batch carrying the
spilled record(s); it issues short refs, persists the index entry via
`appendEntry`, and updates the in-memory map. No `context-prune-summary`
message is emitted for eager-spilled results (the stub is self-describing).
### Recovery
`context_tree_query` (`src/query-tool.ts`): when the resolved record has
`spillPath`, read the file and apply the **existing**
`truncateHead(content, { maxBytes: DEFAULT_MAX_BYTES, maxLines:
DEFAULT_MAX_LINES })` call already used in this file to the file contents
instead of to `resultText` — no new constants or exports. Falls back to
`resultPreview` if the file is missing. The primary recovery path the model
should use is the native `read` tool on `spillPath` (full slicing);
`context_tree_query` remains the head-truncated fallback.
### Failure handling
- **Write-then-mutate (atomic).** Write the sidecar file first; mutate the
record fields (`spillPath`/`spillBytes`/`resultPreview`/`contentHash`) only
after `writeFile` resolves. A throw leaves the `CapturedToolCall` untouched
and the result falls through to the normal flush pipeline. Never drop a
result. Log at debug.
- **Filename collision.** `toolCallId` is unique per call and the sanitized
alphabet (`[^A-Za-z0-9_-]` → `_`) collides only on pathological ids; before
writing, if the target path already exists for a different record, fall back
to inline rather than overwrite.
- Missing blob at query time → return `resultPreview` + a note that the full
body is unavailable.
## B — Budget-delta flush
Add a second, delta-based flush trigger in the `turn_end` handler, evaluated
alongside the existing absolute `shouldBudgetFlush`.
- New config `budgetTurnDelta: number | null` (fraction of window, default
`null` = off; suggested operational value `0.15`).
- Track the previous turn's usage fraction (`tokens / contextWindow`) as a
module-level variable in `index.ts`, set to `null` on `session_start`.
After a restart the first turn cannot fire the delta trigger (no previous
fraction); this gap is accepted because the absolute `autoBudgetThreshold`
still guards the post-restart aggregate. On a turn where `usage.tokens` is
null (e.g. immediately after compaction), leave `previousFraction`
unchanged rather than overwriting it, so the next real reading is compared
against the last known fraction.
- If `(currentFraction - previousFraction) >= budgetTurnDelta`, call
`flushPending(ctx, { delivery: "session" })` — same flush the aggregate
trigger uses.
- Either trigger (absolute OR delta) firing causes a flush; they are ORed.
Rationale: catches a turn that adds a large aggregate (many medium outputs, or
a spike that A's per-result threshold didn't individually catch) without
waiting to cross the absolute `autoBudgetThreshold`.
Helper lives in `src/budget.ts` next to `shouldBudgetFlush`:
```ts
export function shouldDeltaFlush(
usage: ContextUsage | undefined,
previousFraction: number | null,
delta: number | null,
): boolean
```
Pure; mirrors `shouldBudgetFlush`'s guards exactly — returns `false` when
`!usage || usage.tokens == null || !(usage.contextWindow > 0) || delta == null
|| previousFraction == null`. Uses the same 0–1 `tokens / contextWindow`
fraction (not `ContextUsage.percent`, which is 0–100 and null when `tokens`
is). Because `tokens` is null right after a compaction, `previousFraction` is
left unchanged on a null-token turn (no spurious delta when usage
"reappears").
## Config (`DEFAULT_CONFIG`, `src/types.ts`)
| field | type | default | meaning |
|---|---|---|---|
| `spillThreshold` | `number` | `65536` | min chars (`resultText.length`) for a single result to spill |
| `spillPreviewBytes` | `number` | `2048` | head-preview size in **bytes** kept inline as `resultPreview` |
| `budgetTurnDelta` | `number \| null` | `null` | per-turn usage-fraction increase that forces a flush |
`spillThreshold` is intentionally high (64 KB): only genuinely huge outputs
spill; medium outputs (tens of KB) keep flowing through normal
summarization/dedup so the model sees them without a `read` round-trip.
## Cleanup
- The blob dir is a sibling of the session file; deleting the session dir
removes its blobs. No separate GC in v1.
- No orphan sweep on startup (explicit non-goal). Accepted: blobs for deleted
sessions whose dir was partially removed can linger; revisit if it matters.
## Touched files
| file | change |
|---|---|
| `src/types.ts` | `ToolCallRecord.spillPath?/spillBytes?`; `DEFAULT_CONFIG` + `ContextPruneConfig` gain `spillThreshold`, `spillPreviewBytes`, `budgetTurnDelta` |
| `src/spill.ts` (new) | pure-ish helpers: `blobDirFor`, `blobPathFor`, `headPreview`, `writeSpill`, `readSpill`; isolates fs + path sanitize |
| `src/batch-capture.ts` | protected filter → dedup → spill non-duplicate oversized `CapturedToolCall`s (write file, set `spillPath`/`spillBytes`/`resultPreview`/`contentHash`), `addBatch` synchronously |
| `src/indexer.ts` | persist + replay `contentHash` for spilled records (skip recompute when present); pass new optional fields through `reconstructFromSession` |
| `src/pruner.ts` | branch on `spillPath`: mechanical spill stub vs. existing summary-ref stub |
| `src/query-tool.ts` | `spillPath` → read file then `truncateHead`; fallback to preview |
| `src/budget.ts` | `shouldDeltaFlush` |
| `index.ts` | `turn_end`: track previous usage fraction; OR `shouldDeltaFlush` into the flush decision; reset on `session_start` |
| `README.md` | document the 3 new settings + `read`-the-blob recovery |
| `PRUNING.md` | document eager-spill layer + budget-delta trigger |
## Testing
Pure unit:
- threshold gating (`< / == / >` `spillThreshold`).
- `headPreview` (line-boundary cut, byte cap, multibyte safety).
- blob path sanitize (no traversal/separators).
- `shouldDeltaFlush` truth table (null delta, missing usage, below/at/above).
Integration (Pi extension smoke, per AGENTS.md):
- oversized result → sidecar file exists with full body; index entry has
`spillPath` + preview, not full body.
- `pruneMessages` stubs the spilled result on the next `context` build with
**no** `context-prune-summary` and **no** summarizer LLM call.
- `context_tree_query` on the spilled id returns file-backed (head-truncated)
content; `read` on `spillPath` returns the full body.
- spill + dedup: a large result spills once; an identical later result aliases
to the spilled original (no second file write) and resolves to the same
`spillPath`.
- reconstruct-from-session: after restart, record still resolves, blob still
read.
- budget-delta: a turn whose usage jump `>= budgetTurnDelta` triggers
`flushPending`; below it does not; a null-`tokens` turn neither fires nor
overwrites `previousFraction`.
Typecheck before commit per AGENTS.md (`bun x tsc --noEmit ...`).
## Open questions
None. Threshold (64 KB), B in scope, no orphan sweep — all decided.
@@ -0,0 +1,86 @@
# Path-based protection: `protectedPaths`
GitHub issue: [#1](https://github.com/jjuraszek/pi-context-prune/issues/1)
## Problem
The pruner treats all `read` results as data, but skill bodies are standing instructions. Summarizing them is categorically lossy: the model later executes the summary, not the skill.
Observed failure (real session): an SDD run read `subagent-driven-development/SKILL.md`; the pruner replaced it ~6 turns later with a 2-sentence summary that omitted the closing-loop step, and the session marked verify complete without running the gate. Verified against the session JSONL: all 4 SKILL.md reads (`writing-plans`, `subagent-driven-development`, `finishing-a-development-branch`, `linear`) were indexed and summarized. All 4 went through `read` with a `path` argument.
`protectedTools` cannot fix this: it is tool-name based, and protecting all of `read` defeats the pruner. This bites hardest with pi-gauntlet, where skill files carry multi-step workflow gates.
## Decision
New config field `protectedPaths: string[]`, default `["**/skills/**/*.md"]`. A tool call whose `args.path` matches any pattern is protected with **identical semantics to `protectedTools`**: never enters a batch, never indexed, never spilled, raw output stays verbatim in context; inside compressed chains it is relocated verbatim as `<protected-output>`.
### Defaults and matching
| Aspect | Decision |
|---|---|
| Default | `["**/skills/**/*.md"]` — covers `SKILL.md` AND sibling reference files under any `skills/` dir (superpowers `.../skills/<name>/*.md`, in-repo `.agents/skills/...`, `~/.agents/skills/...`) |
| Matched argument | `args.path` only (string). pi's `read` and `navigator_slice` use `path`. Non-string or missing `path` → not protected. |
| Glob contract | Hand-rolled glob→regex, no new runtime dependency. Full-path match against the raw arg with `\` normalized to `/`. `*` and `?` match within a path segment (no `/`); `**` crosses segments; `**/` also matches zero directories (so `**/SKILL.md` matches a bare relative `SKILL.md`). All other characters are regex-escaped literals. Case-sensitive. |
| Kill switch | `"contextPrune": { "protectedPaths": [] }` in settings.json. `normalize()` spreads `existing` over `DEFAULT_CONFIG`, so an explicit `[]` wins. |
| Command | `/pruner protected-paths [patterns]` mirroring `protected-tools` (interactive prompt; `none` clears). Also shown in the `/pruner settings` overlay. |
**Behavior change on upgrade:** unlike `protectedTools` (default `[]`), the non-empty default changes behavior for existing users — that is the point of the issue. Risk: an unrelated large docs tree under a `skills/` dir never prunes; mitigated by the kill switch.
## Implementation
### New module: `src/protected.ts` (pure)
```ts
isProtected(toolName: string, args: unknown, config): boolean
```
True iff `toolName ∈ protectedTools` OR `args.path` is a string matching any `protectedPaths` glob. Compiled patterns cached in a module-level `Map<string, RegExp>` keyed by pattern string (config edits mid-session pick up new patterns without invalidation logic).
### Wiring — same 3 surfaces as `protectedTools`
| Surface | Today | Change |
|---|---|---|
| Live capture filter (`index.ts` turn_end) | name-set check | call `isProtected(name, args, config)` with the captured call's normalized args; protected calls never enter the batch (also exempts them from eager spill) |
| `captureUnindexedBatchesFromSession` (`src/batch-capture.ts`) | `excludeToolNames: Set<string>` | generalize to a predicate param `(toolName, args) => boolean`; call sites extract args from the raw toolCall block as `tc.input ?? tc.arguments` |
| `detectChains` (`src/chain-detector.ts`) | `protectedSet.has(name)` on assistant toolCall blocks and toolResult fallback | assistant block check uses the predicate with `block.input ?? block.arguments`. The toolResult fallback stays **name-only**: results carry no args, and the assistant block always precedes its result, so no protection is lost. |
### Repairing already-summarized reads (render-time re-check)
Stub replacement is applied in-flight by `pruneMessages` on every turn; the raw toolResult bodies still live in the session JSONL, and `ToolCallRecord.args` is already persisted in `context-prune-index` entries and rebuilt on `session_start`. So `pruneMessages` adds one condition to the stub-replace path: if the record's `args` now satisfies `isProtected`, skip stubbing and leave the raw result verbatim. This repairs existing sessions (including the motivating one) with no schema change and no migration.
**Declared limitation:** records inside already-compressed chains (`context-prune-chain` entries) are NOT repaired — the chain's `protectedToolCallIds` set is fixed at compression time. Forward-only there.
### Config plumbing
- `src/types.ts`: add `protectedPaths: string[]` to `ContextPruneConfig`; `DEFAULT_CONFIG.protectedPaths = ["**/skills/**/*.md"]`.
- `src/config.ts`: no explicit `normalize()` clause (follows `protectedTools` precedent — spread handles it).
- `src/commands.ts`: `protected-paths` subcommand + config-overlay row, cloned from `protectedTools` handling.
### Out of scope
- `file_path`/other arg names (pi tools use `path`; revisit if a real tool surfaces).
- Dedup, error-purge, thinking-strip — untouched.
- Protecting skill content injected via user/system messages — the pruner never touches those.
- Path resolution/normalization: match against the raw `path` string as the model sent it (absolute or relative). Patterns anchored with `**/` make this robust.
## Edge cases
- Oversized protected read: stays verbatim in context forever, never spilled. By design — that is what "protected" means.
- Pattern matching a non-read tool's `path` arg (e.g. a write target): protected. Harmless — write results are tiny.
- Invalid glob: hand-rolled converter cannot throw; every pattern compiles to some regex.
## Testing
- Unit tests: glob matcher (`**`, `*`, `?`, zero-dir `**/`, segment-locality of `*`, regex-char escaping, `\` normalization, no-match cases) and `isProtected` (name OR path, non-string path, empty config).
- Unit test: chain compression with a path-protected call — its output relocates verbatim as `<protected-output>` into the synthetic body.
- Unit test: render-time re-check — a record summarized under old config is left unstubbed once its path matches `protectedPaths`.
- Smoke test per AGENTS.md: isolated `$PI_CODING_AGENT_DIR`, read a `skills/**/*.md` file plus enough unprotected calls to clear `minBatchChars`, force a flush (`/pruner now`), then assert (a) a `context-prune-index` entry exists for the batch, (b) the protected toolCallId is absent from it, (c) the raw result is intact in the next-turn context. Asserting (a) guards against the false-pass where the whole batch was skipped as trivial.
- Regression: existing `protectedTools` tests stay green.
## Documentation updates
- `README.md`: `protectedPaths` row in the config table (next to `protectedTools`), default shown in the example settings block, `/pruner protected-paths` in the command table.
- `PRUNING.md`: extend "Protected tools" section to "Protected tools & paths" — predicate semantics, capture-time filtering, chain-compression relocation contract (§ existing protected-output rationale applies unchanged).
- `AGENTS.md`: no structural change needed; `context-prune-chain` row already documents `protectedToolCallIds` — reword to say ids can be protected by name or path.
Keep doc tone dense and human-readable per repo conventions.
@@ -0,0 +1,185 @@
# Spec: unified cost event + live-reclaim status line
Date: 2026-06-21
Branch: `cost-event-reclaim-status`
## Problem
Two defects in how the pruner surfaces itself:
1. **Pruner spend is invisible to the unified total.** The summarizer makes its own
`stream()` calls (`src/summarizer.ts` `runSummarization`) that bypass the agent loop, so
they never reach `message_end` and never enter pi-subagents' grand total (`Σ$...`). The
pruner instead shows its own isolated cost (`↑3.0k ↓1.3k $0.021`).
2. **The status line shows price, not value.** `prune: ON (On agent message) │ ↑3.0k ↓1.3k
$0.021` reports what pruning *costs*, never what it *reclaims*. A user cannot tell whether
pruning is doing a good job.
## Goals
- Pruner publishes its cumulative LLM spend on a shared `pi.events` channel so pi-subagents
(or any aggregator) can fold it into one `Σ$` as a third cost signal. Fire-and-forget; no
hard dependency on pi-subagents.
- Status line headline becomes a compact reclaim ratio: `prune: ON │ 92k->14k (-85%)`.
## Non-goals
- pi-subagents-side aggregation (separate spec in that repo, against the same contract).
- Durability of the external-cost slice across session reload. Decided **live-only**: the
external cost is ephemeral; no session reseed. Rationale: it is a minor fraction of the
total and durability is not worth coupling an aggregator to the pruner's entry schema.
- Exact token accounting for reclaim. The headline uses an estimate (chars / 4).
## Change 1 - emit cost on the shared bus
### Contract (fixed interface, shared with the pi-subagents spec)
- **Channel:** `pi.events` string channel `"cost:external"`.
`EventBus.emit(channel: string, data: unknown)` per
`node_modules/@earendil-works/pi-coding-agent/dist/core/event-bus.d.ts`. `ExtensionAPI`
exposes this bus as `events: EventBus` (types.d.ts ~line 962), so any site that already
holds `pi: ExtensionAPI` can emit with no signature change.
- **Payload** (cumulative-per-source, NOT deltas):
```ts
interface ExternalCostUpdate {
source: string; // stable producer id: "pi-context-prune"
totalCost: number; // cumulative USD this source spent THIS SESSION
inputTokens?: number; // cumulative this session, optional (for breakdown display)
outputTokens?: number; // cumulative this session, optional
}
```
`ExternalCostUpdate`, plus the constants `EXTERNAL_COST_CHANNEL = "cost:external"` and
`EXTERNAL_COST_SOURCE = "pi-context-prune"`, are declared and **exported** from
`src/types.ts` (not spec-prose only) so the contract is importable in one place.
- **Semantics:** "cumulative" means cumulative **for the current session**, not all-time. The
producer emits its running session total on every update. Cumulative-per-source is
idempotent and replay-safe within an aggregator run: a re-emit overwrites the same key,
never double-counts. The aggregator keeps `Map<source, payload>` and sums `totalCost`.
### Producer wiring (this repo)
**Session-scoped delta, not raw cumulative.** `StatsAccumulator.reconstructFromSession`
(`src/stats.ts:96`) reloads prior-session `context-prune-stats` totals on `session_start`.
Emitting `getStats()` directly would republish historical spend to a freshly started
aggregator, breaking the live-only contract. Fix:
- `StatsAccumulator` captures a **session baseline** - a snapshot of `{ totalCost,
totalInputTokens, totalOutputTokens }` taken at construction (zero) and re-captured at the
end of `reconstructFromSession` (equal to the carried-over totals).
- New method `getSessionDelta()` returns `{ totalCost, inputTokens, outputTokens }` = current
totals minus the baseline - i.e. only this session's spend.
- The `cost:external` payload maps from `getSessionDelta()`, with the constant `source`.
**Emit at every stats-write site, not only `persist`.** `persist(pi)` is NOT the sole
stats-write path: the default `message_end` -> `delivery: "session"` branch writes via
`appendEntry(CUSTOM_TYPE_STATS, statsAccum.getStats())` (`index.ts:498`), bypassing
`persist`. Emitting only inside `persist` would miss the main agent-message summarization
path. Fix:
- Add a free helper `emitExternalCost(pi, accumulator)` (in `src/stats.ts` or a small util)
that emits `{ source: EXTERNAL_COST_SOURCE, ...accumulator.getSessionDelta() }` on
`EXTERNAL_COST_CHANNEL`.
- Call it immediately after **every** site that writes stats: both branches of the
stats-write in `flushPending` (`index.ts` runtime `statsAccum.persist(pi)` ~493 and session
`appendEntry` ~498) and the chain-compression stats writes (~534, ~839). Simplest form:
fold the emit just after the runtime/session if-else so it runs unconditionally on each
flush.
- Idempotency makes the multi-site emit safe: re-emitting the same session delta is a no-op
at the aggregator.
## Change 2 - live reclaim headline
### Measurement (single point, all mechanisms)
`pruneMessages` (`src/pruner.ts`) is the one place where every reclaim mechanism
(stub-replace, error-purge, chain-range-prune, thinking-strip) runs against the full message
array. Measure once there, only when a prune actually occurs (the function already
short-circuits and returns the original array reference when nothing changes):
- `beforeChars` = `sizeMessages(messages)` (the input array, raw outputs still present).
- `afterChars` = `sizeMessages(pruned)` (the returned pruned array).
`sizeMessages(messages: AgentMessage[]): number` is a pure helper defined as
`JSON.stringify(messages).length`. Serializing the whole array (not just visible `.text`)
is deliberate: it counts tool-call argument bodies (error-purge reclaim), thinking blocks
(thinking-strip reclaim), and tool-result arrays (stub-replace / chain-range reclaim) -
so all four mechanisms register. It is an estimate of context weight, not an exact token
count.
This captures all four mechanisms with zero per-path plumbing. It is a **live** measurement
(current pruned context vs the same context unpruned), self-correcting each turn, with no
cumulative drift.
### Carrying the measurement to the status line
- `StatsAccumulator` (`src/stats.ts`) gains a **transient** live-reclaim field
(`liveBeforeChars`, `liveAfterChars`), set via a new method (e.g. `setLiveReclaim(before,
after)`).
- These transient fields are **separate from `SummarizerStats`** (which stays the persisted
schema). `getStats()` returns the persisted `SummarizerStats` for the
`context-prune-stats` entry unchanged; the status widget reads the live reclaim via a
distinct accessor (e.g. `getLiveReclaim()`) or an extended widget-only view object. The
persisted entry schema is untouched; the live value resets to unset on `session_start` and
stays so until the next prune.
- The `context` event handler in `index.ts:787` currently destructures the context as `_ctx`
(unused). Rename it to `ctx` and use it: stash the `pruneMessages` before/after into the
accumulator and refresh the status widget via `setPruneStatusWidget(ctx, config,
statsAccum.getStats())`. `ExtensionContext.ui.setStatus` is already available - no
signature change beyond using the parameter.
### Status line format
`pruneStatusText(config, stats)` (`src/commands.ts`) renders:
| State | Line |
|---|---|
| Enabled, reclaim measured this session | `prune: ON \| 92k->14k (-85%)` |
| Enabled, nothing pruned yet | `prune: ON` |
| Disabled | `prune: OFF` |
- Tokens estimated as `Math.round(chars / 4)`, formatted with the existing
`formatCompactCount` (`k`/`M`) helper used elsewhere in `commands.ts`.
- Reduction `= round((1 - afterTokens / beforeTokens) * 100)`, clamped to `>= 0`. A full
strip (`afterTokens == 0`, `beforeTokens > 0`) correctly renders `-100%`.
- Drops the `(On agent message)` mode label and the `↑↓$` figures from the line.
- **Cost leaves the status line entirely.** It now flows into pi-subagents' `Σ$`. Full
token/cost/call detail remains available in `/pruner stats` (unchanged).
## Edge cases
- **No prune this turn:** `pruneMessages` returns early; no measurement, status line keeps
the last value (or `prune: ON` if never pruned).
- **`beforeChars == 0`:** show `prune: ON` (no ratio); never divide by zero.
- **Negative/zero reclaim** (summary larger than raw, rare): clamp reduction to `0%`.
- **Oversized/spilled outputs not in context:** measured as-is; the headline is an estimate,
not an exact byte count.
- **No aggregator listening:** `pi.events.emit` is a no-op with no subscribers; pruner
behavior is unchanged.
## Testing
- **Reclaim calc (pure):** unit test in the `pruner` test that before/after char totals and
the formatted ratio string are correct, including the clamp and zero-before cases.
- **Emit (unit):** assert `persist` emits one `"cost:external"` event whose payload carries
the cumulative `{ source, totalCost, inputTokens, outputTokens }` from current stats.
- **Status text (unit):** `pruneStatusText` renders each of the three states correctly.
- **Smoke:** `pi -e ./index.ts -p "..."` against an isolated `$PI_CODING_AGENT_DIR`.
`--no-extensions` is dropped: it would suppress the very extension under test. Verify the
ratio renders (inspect the status widget / session output) and that `context-prune-stats`
JSONL entries still validate against the unchanged `SummarizerStats` schema (`jq` the
`customType` counts per AGENTS.md).
## Documentation impact
- **README.md:** update the status-line description; document the `cost:external` emit and the
`ExternalCostUpdate` contract.
- **PRUNING.md:** note the live-reclaim metric (single-point measurement in `pruneMessages`,
estimated tokens).
- **AGENTS.md:** add `cost:external` to the event/contract surface (it is an outbound
`pi.events` emit, not a session custom entry, so note it as such).
## Open questions
None.
@@ -0,0 +1,357 @@
# Spec: per-bullet ref mapping in prune summaries
Date: 2026-07-04
Branch: `per-bullet-refs`
Issue: #2 (Per-bullet ref mapping in prune summaries (recovery UX))
## Problem
A prune summary lists its recovery refs as a flat footer appended by
`formatSummaryToolCallRefs` (`src/summary-refs.ts:46`):
```
---
**Summarized tool refs**: `t12`, `t3`, `t7`
Use `context_tree_query` with these refs to retrieve the original full outputs.
```
The refs are positional against the summarized batch's tool-call order, but no
per-bullet body carries its own ref. When the model wants to recover one tool's
raw output it must guess which footer ref maps to the bullet it just read, then
call `context_tree_query`. The mapping exists deterministically in the code
(both the serializer and the ref allocator iterate the same post-dedup
`batch.toolCalls` in the same order) but is never surfaced onto the bullet, so
the one datum that would make recovery a single hop is discarded at render time.
The roast on issue #2 (two reviewers) killed the originally-proposed
`context_search` FTS tool and converged on this 1:1 bullet->ref mapping as the
only real gap: "put the ref on the bullet, not build a search tool." The roast
rationale, inlined so an implementer needs no issue access: a search tool adds a
new tool surface, spill/sidecar coverage, and match-cap handling for payoff a
deterministic per-bullet ref already delivers; the model is not ref-blind (the
prompt already orders paths/ids/errors into the body and refs are in-context) -
the fix is to attach the ref to the bullet. No new tool, no index.
## Goals
- Each per-tool block in a per-batch prune summary carries its own recovery ref
inline (`` `t12` ``), so the model recovers a specific tool's raw output in one
hop instead of eyeballing a flat footer.
- The existing footer stays verbatim as an always-correct fallback (satisfies
issue #2 AC "footer preserved").
- Deterministic mapping: number->shortId is pure index arithmetic on the shared
`batch.toolCalls` order; the LLM only copies a label, it never invents a ref.
- Graceful degradation: any missing, malformed, out-of-range, **or
tool-name-mismatched** label leaves the affected bullet ref-less (footer still
covers it) rather than emitting a broken *or confidently wrong* token.
## Non-goals
- **No `context_search` / FTS / recovery-search tool.** Killed in the roast;
explicitly out of scope.
- **No change to ref allocation, shortId format, or `context_tree_query`.**
`buildShortToolCallRefs` (`t${startIndex+offset}`) and the indexer's
`allocateSummaryRefs` are untouched.
- **No new config knob.** Decoration is unconditional; there is no opt-out.
Rationale: it strictly improves recovery UX, degrades to today's footer when
the LLM omits a label, so a knob gating an unconditional-improvement would be
dead weight (YAGNI).
- **No separate range-summary implementation.** Range fusion inherits inline
refs for free (see "Range-summary propagation"); we add no code there beyond a
confirming test.
## Mechanism
The change rests on an ordering invariant that already holds end-to-end:
`serializeBatchForSummarizer` (`src/batch-capture.ts:147`) and
`allocateSummaryRefs` (`src/indexer.ts`, via `buildShortToolCallRefs`) both
iterate the **same** post-dedup `batch.toolCalls` array in the **same** order.
So the serialized tool at 1-based position `N` maps deterministically to
`refs[N-1].shortId`. Two transforms that could have desynced it do not:
- **Dedup** rewrites `batches[i] = { ...batch, toolCalls: remaining }` in
`index.ts` *before* both serialize and allocate run, so both observe the
identical filtered array.
- **agent-message merge** (`groupBatchesByMode`, `src/batch-capture.ts`)
concatenates `toolCalls` in order into one `CapturedBatch`; refs are allocated
over that merged array. Numbering stays 1..N over the merged whole.
### Touch point 1 - label the serialization (`src/batch-capture.ts`)
In `serializeBatchForSummarizer`, prepend `[[N:toolname]] ` (double-bracket
label carrying position **and** tool name + one space) immediately before
`Tool:` at the start of each tool block, so the model can copy (not count) it.
`N = index + 1` from the existing `batch.toolCalls.map((tc, index) => ...)` loop;
`toolname` is `tc.toolName` (already in scope). The name is carried so the
substitution step can *validate* the model's echoed label against the actual
tool at that position (see Touch point 3). Nothing else in the block changes.
Concrete before/after (block for the first tool):
```
Before:
Tool: read({"path":"a.ts"})
Result (OK): ...
After:
[[1:read]] Tool: read({"path":"a.ts"})
Result (OK): ...
```
The change to the returned block string is exactly:
``return `[[${index + 1}:${tc.toolName}]] Tool: ${tc.toolName}(${argsJson})\nResult (${status}): ${resultText}`;``
### Touch point 2 - one instruction line (`src/summarizer.ts`)
`SYSTEM_PROMPT` is concatenated as the leading text of the single user message
(`summarizeBatch`: `SYSTEM_PROMPT + "\n\n<tool-call-batch>\n" + serialized + ...`;
there is no separate system role in this call path), so adding a line to the
constant lands it in the message the model reads. Append one paragraph to
`SYSTEM_PROMPT` **after** the existing final paragraph (`Keep each tool call to
1-3 bullet points. ... Be concise.`), and only there - `RANGE_SYSTEM_PROMPT` is
left untouched. Exact text to add:
> Begin the first bullet of each tool call with that tool's `[[N:toolname]]`
> label, copied verbatim (both the number and the name) from its line in the
> input, as the plain, first thing on the line (no bold, backticks, or list
> numbering around it). Do not renumber, rename, or invent labels; if you skip a
> tool, skip its label too.
"Copy the label" is a far more reliable ask than "count correctly": if the model
skips a tool (the prompt already says "Skip calls that succeeded with nothing
reusable to record"), copying keeps every emitted label pointing at the right
source, whereas a running counter would silently shift.
### Touch point 3 - substitute inline (`src/summary-refs.ts` + `index.ts`)
New pure function in `src/summary-refs.ts`:
```ts
export function substituteInlineRefs(
text: string,
refs: SummaryToolCallRef[],
toolNames: string[],
): string
```
It rewrites line-leading `[[N:name]]` labels to the backtick-wrapped shortId
using `refs[N-1]`, **after validating** that the echoed `name` matches
`toolNames[N-1]` (comparison is trimmed + case-insensitive). `refs` and
`toolNames` are positionally aligned to the same `batch.toolCalls` order
(`refs` from `buildShortToolCallRefs(batch.toolCalls.map(tc => tc.toolCallId))`,
`toolNames` from `batch.toolCalls.map(tc => tc.toolName)` - the exact array
`makeSummaryDetails` already builds). `SummaryToolCallRef` carries no tool name,
so `toolNames` is passed as a third argument rather than read off `refs`.
Note `SummaryToolCallRef.shortId` **already includes** the `t` prefix
(`buildShortToolCallRefs` returns `` shortId: `${SHORT_ID_PREFIX}${...}` ``, e.g.
`"t12"`), so the replacement wraps `shortId` verbatim - it does **not** prepend
another `t`. The emitted token is `` `t12` ``, never `` `tt12` ``.
Applied at the single existing call site in `index.ts` (currently line 389-390),
between allocation and footer append. `toolNames` come from the same `batch`,
keeping the two arrays aligned:
```ts
const summaryRefs = indexer.allocateSummaryRefs(batch);
const toolNames = batch.toolCalls.map((tc) => tc.toolName);
const decorated = substituteInlineRefs(result.summaryText, summaryRefs, toolNames);
const summaryText = decorated + formatSummaryToolCallRefs(summaryRefs);
```
`decorated` is the body with inline refs; `summaryText` is `decorated` plus the
unchanged footer. `shouldSkipOversized = summaryText.length > batchRawCharCount`
is computed on `summaryText`; substituting `[[9:read]]` -> `` `t12` `` is roughly
length-neutral and does not meaningfully move the oversize guard.
## Marker choice and substitution rules (load-bearing)
The marker is `[[N:name]]` (double square bracket, `N` = decimal digits, `name`
= the tool name), matched **only as the first non-whitespace token of a line and
outside fenced code blocks**, via:
```
/^(\s*(?:[-*]\s+)?)\[\[(\d+):([^\]\n]+)\]\]\s*/gm
```
(capture 3 = echoed tool name, validated against `toolNames[N-1]`). The name
capture is `[^\]\n]+` (any run of non-`]`, non-newline chars, stopping before
the closing `]]`) rather than a restricted `[A-Za-z0-9_-]` class **so the
degradation contract holds for every tool name**: a namespaced/dotted tool
(`server.tool`, `a:b`) still produces a *matching* label that is then validated
and, on mismatch or out-of-range, stripped. A narrower class would fail to match
such a label at all - leaving raw `[[N:name]]` in the body (fail-open), which
contradicts "any malformed/mismatched label degrades to footer-only". Trailing
whitespace after `]]` is consumed with `\s*` (not `\s?`) so the substitution
normalizes to exactly one separating space regardless of how many spaces the
model emitted.
processed line-by-line while tracking a fenced-code toggle (a line whose trimmed
form starts with ```` ``` ```` flips "inside fence"; lines inside a fence are
never substituted). Guards protecting the verbatim content the prompt is
explicitly told to copy (`argv[1]`, `items[0]`, file paths, error strings, quoted
tool output):
1. **Double brackets** are rare in copied code/prose/errors; single `[1]` (the
common array-index shape) never matches. Obsidian-style `[[Page]]` wikilinks
are non-numeric and never match `\d+`.
2. **Line-start anchor** (`^` with the `m` flag, allowing leading whitespace and
an optional `-`/`*` bullet marker) means a mid-line `[[x]]`-lookalike is left
untouched.
3. **Fenced-code exclusion** shields the realistic false-positive vector: a
verbatim-copied log or serialized block whose line happens to start with
`[[<digits>]]`.
Substitution semantics, per matched label with captured number `N` and echoed
name (capture 1 = leading whitespace + optional bullet prefix; capture 2 =
digits; capture 3 = echoed tool name). The name check runs first:
| Case | Action |
|---|---|
| `1 <= N <= refs.length` **and** echoed name == `toolNames[N-1]` (trimmed, case-insensitive) | Replace the whole match with `` $1`SHORTID` `` - the captured prefix, then the backtick-wrapped `refs[N-1].shortId`, then one space separator (so `- [[1:read]] Read` becomes ``- `t1` Read``, never ``- `t1`Read``). `shortId` is emitted as-is (no extra `t`) |
| `1 <= N <= refs.length` **but** echoed name != `toolNames[N-1]` | **Mismatch -> strip** the whole label (prefix re-emitted, no ref); the model desynced, so footer-only is the safe outcome instead of a confident wrong ref |
| `N` out of range (`< 1` or `> refs.length`) | Strip the label, re-emitting the captured prefix only (no dangling token); footer still covers that tool |
| `N` unparseable | Cannot occur - regex captures `\d+` only; non-numeric never matches and is left verbatim |
| Label absent on a line | No-op for that block; it renders ref-less, footer covers it |
| Same `[[N:name]]` on multiple lines of one tool | Each occurrence validated + substituted to the same `` `tN` `` (harmless, mildly helpful) |
| Label wrapped/formatted or non-standard bullet (`**[[1:read]]**`, `` `[[1:read]]` ``, `1. [[1:read]]`, `> [[1:read]]`) | The anchored substitution does not match, so no inline ref is produced. A second **catch-all strip** pass (below) then removes the raw `[[N:name]]` token from the line, leaving footer-only recovery with **no internal syntax leaked** into context. |
**Catch-all strip (leak guard).** After the anchored substitution, every
non-fenced line is swept once more with `/\[\[\d+:[^\]\n]+\]\]\s*/g` and any
surviving well-formed label token is deleted. This guarantees the invariant
*"the feature never injects a raw `[[N:name]]` token into LLM context"* even when
the model ignores the prompt and wraps/renumbers its label (the anchored pass
recovers the ref for compliant `-`/`*` bullets; the strip pass discards an
unrecoverable label rather than leaking it). It runs inside the same fence-aware
line loop, so labels inside fenced code blocks are still never touched. It
cannot hit mid-line lookalikes (`argv[1]` is a single bracket; `[[Page]]`
wikilinks have no `:digits`).
The footer from `formatSummaryToolCallRefs` is appended unchanged in every case,
so recovery is never worse than today.
### Why the name tag (mis-copy mitigation)
The number->shortId map is deterministic, but the number->bullet association is
the model's; a bare number could be copied onto the wrong bullet (typically the
model renumbering after skipping a tool) and substitute a *confidently wrong*
`` `tX` ``. Echoing the tool name in the label and validating it against the
known tool at position `N` converts that failure into a **detected mismatch that
degrades to footer-only**. This catches the dominant desync - a skip-induced
renumber lands `[[2:read]]` where position 2 is a `bash`, mismatch, strip. It is
not the earlier-rejected *prose* cross-check (parsing the bullet's free text for
a tool name, which is unreliable); it validates the model's own echoed tag, a
machine-exact token.
### Residual risk (accepted)
- **Same-type swap.** Two calls of the *same* tool (two `read`s) with their
labels swapped both pass the name check, so a swapped `` `tX` `` still points
at a same-kind call (wrong file). Rarer than cross-type renumber and
lower-harm; catching it would need a content discriminator in the label
(`[[1:read:a.ts]]`), forcing the model to copy a path verbatim - more
error-prone and heavier, so out of scope. Footer still lists every ref.
- **Non-fenced line-start lookalike.** A verbatim-copied non-fenced line that
begins with `[[<digits>:<word>]]` can be mis-handled (substituted if it
happens to name-match, else stripped). Judged vanishingly rare given the
double-bracket + `N:name` shape + line-start + fenced guards; accepted rather
than parsing summary prose further.
- **Non-fenced mid-line label.** The catch-all strip also removes a well-formed
`[[<digits>:<name>]]` token appearing mid-line in non-fenced prose (not just
at line start). This is the intended leak-guard behavior; a verbatim-copied
prose sentence that contains that exact token shape mid-line is vanishingly
rare and accepted.
- **No flush-path integration test.** The `index.ts` flush pipeline
(`allocateSummaryRefs` -> `substituteInlineRefs` -> footer) has no isolated
unit harness, so the positional alignment between `refs` and `toolNames` is
covered by the pure-function + composition unit tests, not an end-to-end
stubbed-summarizer test. Accepted: building a `flushPending` harness is
disproportionate to a five-line wiring change whose invariant is verified by
reading `allocateSummaryRefs` (both arrays map the same `batch.toolCalls`).
## Range-summary propagation (inherited, not implemented)
`RANGE_SYSTEM_PROMPT` (`src/summarizer.ts`) already instructs: "Keep any
reference tokens like `` `t12` `` or `` `b3` `` intact." Chain range fusion
(`summarizeRange`) consumes the **stored** per-batch summary text, which by then
already has inline `` `tX` `` refs substituted in. So inline refs **propagate
when present in the per-batch input** with no additional code - subject to the
same LLM-compliance caveat as any ref token in the range prompt (the fusion
model is instructed to keep them but is not forced to). No production change in
the range path; covered by a confirming test asserting the fusion *input* text
carries the inline ref (the fusion *output* is mocked in existing tests, so
asserting against it would be circular).
## Testing approach
Tests follow the repo convention (`src/*.test.ts`, run by `bun test src/`). Two
new files, since neither exists today:
New file `src/summary-refs.test.ts` - `substituteInlineRefs(text, refs, toolNames)`:
- in-range + name-match replaces `[[1:read]]`/`[[2:bash]]` with the right
shortIds (asserting the exact `` `t1` `` token, no `tt1`);
- **name mismatch** (`[[2:read]]` where `toolNames[1] === "bash"`) stripped, no
ref emitted (the core mis-copy-mitigation case);
- name-match is trimmed + case-insensitive (`[[1:Read]]` vs `read` -> match);
- out-of-range `[[9:read]]` (only 2 refs) stripped, leaving the bullet prefix
and no dangling token;
- absent label -> text unchanged;
- duplicate `[[2:bash]]` on two lines both substituted;
- separator preserved: `- [[1:read]] Read` -> ``- `t1` Read`` (single space,
not glued);
- left untouched: mid-line `argv[1]`, a mid-line `[[1:read]]`-lookalike, a
line-start `[[1:read]]` **inside a fenced code block**, and a wrapped
`**[[1:read]]**`.
New file `src/batch-capture.test.ts` - `serializeBatchForSummarizer`:
- emits `[[1:name]]..[[N:name]]` (correct position and tool name) immediately
before each `Tool:` in tool order;
- numbering stays 1..N over a dedup-filtered batch (fewer tool calls, still
contiguous from 1);
- numbering stays 1..N over an agent-message-merged batch (concatenated
`toolCalls` via `groupBatchesByMode`).
Composition (unit, in `src/summary-refs.test.ts`) - no `index.ts`/`flushPending`
integration harness exists, so the no-drift check is a pure composition of the
two helpers over one `SummaryToolCallRef[]`:
- `substituteInlineRefs(body, refs, toolNames)` inline refs and
`formatSummaryToolCallRefs(refs)` footer refs reference the same shortIds for
the same positions.
- A per-batch body carrying an inline `` `tX` `` still contains that token after
passing through the range-fusion **input** assembly (asserted on the input, per
the range-propagation note).
## Documentation impact
- Feature / user-facing docs introduced: none (behavioral refinement of an
existing output; no new command, setting, or surface).
- Materially amended existing docs:
- `PRUNING.md` - the "How the Model Re-reads Raw Outputs" section (line ~284)
and the recovery ASCII ("RECOVERING PRUNED DATA", line ~209) gain a short
note that per-tool bullets now carry an inline `` `tX` `` ref (footer
retained as always-correct fallback).
- `README.md` - the `context_tree_query` paragraph (line 195) currently
describes only the footer ("Pruned summaries end with short refs like
`Summarized tool refs: ...`"); amended in the same commit to add that each
per-tool bullet also carries its own inline ref. (Checked now: this is the
only summary-format description in `README.md`; no code-block sample to
update.)
- Derived / memory docs invalidated: none (no router, AGENTS.md section, or
index changes; the `context-prune-summary` customType row in `AGENTS.md`
still accurately describes what the entry is).
## Acceptance criteria (from issue #2)
1. A multi-tool prune summary shows, per tool block, either an inline ref or a
stable positional number->ref mapping. **Met** by inline `` `tX` ``
substitution.
2. The flat footer ref list is preserved. **Met** - `formatSummaryToolCallRefs`
output is appended unchanged.
3. No new tool, config knob, or index is added. **Met** - three edits to
existing functions plus one new pure helper; no config, no tool, no index.
4. Degrades safely when the LLM omits/garbles a label. **Met** - name-mismatch
and out-of-range stripped, absent label -> footer-only, mid-line lookalikes
untouched. The name-tag validation additionally downgrades a *confidently
wrong* in-range ref (the dominant desync) to footer-only.
@@ -0,0 +1,159 @@
# Recovery grace window: render-time bounded verbatim for `context_tree_query` output
## Problem
The pruner re-prunes its own recovery tool's output, forcing a retrieve -> re-stub -> re-query loop the agent experiences as "fighting the pruner."
Observed failure (real session `019f383f-...`, repo `gridstrong`, 2026-07-06): a `collect.py` standup dump (7784 chars, ref `t1`) was summarized to a 742-char stub at the first turn boundary. A later user question ("why the Jacek prefix?") needed the raw output, so the agent issued `context_tree_query(["t1"])` and recovered the full text into context. At the **next** turn boundary the pruner captured and re-stubbed that recovered output - the batch index at that turn lists `toolNames: ["context_tree_query", "context_tree_query", "bash", "bash"]`, i.e. the extension summarized its own recovery call. The agent had to re-query, and the cycle repeated. The agent's own words in that session: *"I've been fighting the context pruner (my `context_tree_query` calls kept coming back summarized)."*
Root cause: `context_tree_query` output is an ordinary tool result. It has `toolName: "context_tree_query"`, is `> minBatchChars` (1000), and is not in `protectedTools` (default `[]`), so it is captured, indexed, and stubbed at render like any other output. Content-hash dedup does not help: two identical `context_tree_query(["t1"])` results dedup to *each other*, but dedup runs at prune time, not call time, so it never prevents the agent from issuing the re-query - it only avoids double-summarizing.
`protectedTools` cannot fix this cleanly. Adding `context_tree_query` there means **permanent** verbatim retention (the record is never stubbed, ever), so a session that recovers many large originals slowly regrows context with no ceiling - defeating the pruner's purpose. The exemption needs a **lifetime bound**.
### Empirical grounding
Mined from the local pi session corpus (`~/.pi/agent.anthropic/sessions/**`, 4194 sessions, 3879 with pi-condense active, 518 actually issuing `context_tree_query`, 1415 total ctq calls):
Re-query gap - user-turn-groups (UTG) between successive `context_tree_query` calls for the **same** ref within a session:
| metric | value |
|---|---|
| sessions with >=1 re-query | 54 (10.4% of ctq sessions) |
| total re-query events | 99 |
| gap median | 2 UTG |
| gap <= 1 / <= 2 / <= 3 / <= 5 | 44% / 71% / 81% / 86% |
| gap == 0 (same user-turn) | 0 |
Two load-bearing facts:
1. **Every re-query crosses a user turn** (gap == 0 is literally zero across 99 events). The thrash is an across-user-turn phenomenon, never within a single user-turn-group. So user-turn-groups is the correct unit, and a window of **3 UTG covers 81%** of re-queries (the gap<=3 mass; diminishing returns past 3).
2. **A user-turn-group window must NOT be applied globally.** Session length is median **1** UTG, and **77% of pi-condense sessions have <= 3 UTG**. Those single-UTG sessions are long autonomous agent runs - exactly where tool-output accumulation is worst and pruning matters most. A general "keep last K UTG of all tool outputs verbatim" window would keep the entire session verbatim in 77% of sessions and disable pruning where it is needed most. Therefore the grace window is scoped to `context_tree_query` output only, not all outputs.
## Decision
Add a **bounded-lifetime verbatim exemption** for `context_tree_query` output, enforced at **render/prune time**, not at capture time.
Recovery output is captured, indexed, spilled, deduped, and frontier-tracked exactly like any other output - no capture-path change. What changes is the two points where a summarized output is actually *dropped from what the model sees*:
- **Phase 1 stub-replace** (`src/pruner.ts`) renders the recovery output **verbatim** (skips the stub) while it is within grace, and renders the normal stub once it ages out.
- **Chain compression eligibility** (`src/chain-compressor.ts`) **defers** compressing any chain whose span still contains an in-grace recovery output, so Phase 3 cannot drop the span out from under Phase 1.
Both consult one predicate: a recovery output is *in grace* while `nowUTG - recoveryUTG <= recoveryGraceTurns`.
- **K is configurable**, default **3** (empirical: covers 81% of re-queries at gap<=3).
- **Unit is user-turn-groups.**
- **Clock is fixed from recovery.** Recovered in UTG N -> verbatim through UTG N+K -> stub from UTG N+K+1. No reset-on-reference (the model reading a block is not observable) and no reset-on-re-query (per the dedup analysis, a re-query can only fire *after* the output aged out, and the fresh record starts its own clock, so a reset path would be dead code).
### Why render-time, not capture-time exclusion
An earlier draft excluded recovery output from *capture*. The spec council (4 members + chair) killed it against source: `trimBatchToPendingRange` (`index.ts:113`) drops any batch whose `turnIndex < frontier`. A capture-excluded recovery call keeps its old `turnIndex`; by the time it "ages out," the frontier has advanced past it, so the batch returns `null` and is dropped **forever** - i.e. the lifetime bound silently degrades to permanent verbatim, the exact unbounded-regrowth failure `protectedTools` was rejected for. Capture-time exclusion also bypassed the live `turn_end` path (`index.ts:696-724`, which spills + marks summarized before any branch scan) and the chain-compression drop path.
Render-time enforcement sidesteps all three: because indexing stays uniform, the frontier, dedup, spill, and live path all behave exactly as today. The recovery output always has a `t<N>` ref and a record; the only decision deferred to render is *stub vs verbatim*, and that decision is recomputed every turn from live state. `src/pruner.ts:74-81` **already** performs a render-time verbatim skip for `isProtected` records - this feature extends that exact seam with a time-bounded predicate.
### Computing UTG at render (positional, no stored field)
`pruneMessages` receives the full branch message array every turn. UTG is derived positionally, requiring **no** new field on `ToolCallRecord` and **no** `addBatch` change:
- `nowUTG` = total count of `role === "user"` messages in the array.
- `recoveryUTG` for a given toolResult = count of `role === "user"` messages at or before its position (a prefix count over the same array).
- age (in UTG) = `nowUTG - recoveryUTG`; in grace while `age <= recoveryGraceTurns`.
This works uniformly for branch-scan-captured, live-`turn_end`-captured, and spilled recovery outputs, because it reads the rendered message stream rather than capture metadata. (The existing `userTurnGroup` counter in `src/batch-capture.ts` is a capture-path concept used by `groupBatchesByMode`; it is deliberately not reused here.)
### Spilled recovery output
No special handling. A live-captured oversized recovery output is spilled to a sidecar and marked summarized (unchanged). At render, the raw toolResult is still present in the branch, so the grace path returns it verbatim and the sidecar simply goes unused until the output ages out, at which point Phase 1 renders the existing spill-pointer stub. Consequence, stated deliberately: an oversized recovery stays fully in context for up to K UTG (the agent recovered it because it needs it); this is bounded and intended.
### What this does NOT change
- Capture, indexing, frontier trim, dedup, spill, the live `turn_end` path - all unchanged.
- All other tool outputs (`bash`, `read`, etc.) stub at render exactly as today. No global grace window.
- `protectedTools`, `protectedPaths`, `minBatchChars`, `pruneOn`, `batchingMode`, `thinkingStrip`, error-purge - unchanged.
- A recovery call also present in `protectedTools` stays permanently verbatim: the existing `isProtected` skip (`pruner.ts:78`) runs first, so permanent protection wins over the bounded grace.
### Deliberate trade-off
A recovery output still referenced *after* K UTG is stubbed and may be re-queried (the ~19% tail of the gap distribution). This is the accepted cost of bounding regrowth; permanent retention (rejected) would eliminate the tail but reintroduce unbounded growth.
## Config
New field on `ContextPruneConfig` (`src/types.ts`), added to `DEFAULT_CONFIG`:
| Aspect | Decision |
|---|---|
| Field | `recoveryGraceTurns: number` |
| Default | `3` (covers gap<=3 = 81% of re-queries) |
| Unit | user-turn-groups |
| Semantics | recovery output rendered verbatim while `nowUTG - recoveryUTG <= recoveryGraceTurns` |
| Disable | `0` = no grace; recovery output stubs immediately like today (opt-out to pre-feature behavior) |
| Placement | top-level scalar on `ContextPruneConfig`, sibling to `minBatchChars` (it governs the Phase-1 render path and chain eligibility, not chain compression internals) |
| Consumed in | `src/pruner.ts` (Phase 1 render) and `src/chain-compressor.ts` (eligibility deferral) - **not** the capture path |
Normalization matches the sibling-scalar convention in `normalize()` (align with how `minBatchChars` / `rollingWindow` handle bad input): an explicit `0` is a valid kill switch and is preserved; values `< 0`, `NaN`, or non-finite fall back to the default `3`; a positive non-integer is floored.
### Tool-name constant
`src/query-tool.ts:9` registers the name as the string literal `"context_tree_query"`. Introduce a shared exported constant (`QUERY_TOOL_NAME` in `src/types.ts`, beside the `CUSTOM_TYPE_*` constants) and reference it from `query-tool.ts`, `src/pruner.ts`, and `src/chain-compressor.ts`, so the registration and the two grace checks cannot drift.
### UI surface
Mirror the existing scalar knobs (`minBatchChars`, `rollingWindow`): add a `RECOVERY_GRACE_PRESETS` array in `src/types.ts`, a `recovery-grace` subcommand in `src/commands.ts` (show-or-set with a description string), and a row in the `/pruner settings` interactive overlay. Settings-parity requirement, not a new UX pattern - a user who can tune `minBatchChars` must be able to tune `recoveryGraceTurns` the same way.
## Implementation
### 1. `src/pruner.ts` - Phase 1 grace render
`pruneMessages` gains the `recoveryGraceTurns` value (threaded from config alongside the existing `protection` argument). Before the map, compute a prefix count of user messages; `nowUTG` is the total. Inside the existing summarized-toolResult branch, after the `isProtected` skip (`pruner.ts:78`) and before building the stub:
```
if (recoveryGraceTurns > 0 && record?.toolName === QUERY_TOOL_NAME) {
const age = nowUTG - userTurnsAtOrBefore[i];
if (age <= recoveryGraceTurns) return msg; // verbatim; raw toolResult already in branch
}
```
`msg` is returned unchanged, so an in-grace recovery output (spilled or not) stays verbatim. The `.map` index `i` keys into the prefix array. Error-result recovery calls (e.g. empty-arg `ctq({})`) are trivially small, so grace on them is a no-op; no special-casing.
### 2. `src/chain-compressor.ts` - eligibility deferral
`compressEligible` selects closed chains beyond the rolling window and drops all `middleToolCallIds` (`chain-compressor.ts:92,118`). Add a guard: a chain is **not** eligible while its span contains a `context_tree_query` toolCallId whose recovery is within grace. The compressor runs over the branch (in `flushPending` and `/pruner compact`) and can compute UTG the same positional way. Once the recovery ages out, the chain becomes eligible normally. This keeps the raw span verbatim during grace, consistent with Phase 1, and prevents the deterministic `/pruner compact` drop the council flagged.
### 3. Config plumbing
`recoveryGraceTurns` flows from `ContextPruneConfig` to the `pruneMessages` call site (`index.ts`, the `context` handler) and to the chain-compressor call sites, beside the values already passed there. No new event wiring, no new custom session-entry type, no summarizer change, no `addBatch`/`ToolCallRecord` change.
## Testing
Unit tests, matching existing `src/` test style.
`src/pruner.ts` (Phase 1 render):
- Recovery output at `nowUTG` (age 0) -> returned verbatim.
- Recovery output aged exactly `K` UTG -> verbatim (`<= K`).
- Recovery output aged `K+1` UTG -> stubbed.
- `recoveryGraceTurns: 0` -> recovery output stubbed immediately at age 0 (pre-feature behavior).
- Non-`context_tree_query` output at age 0 -> stubbed (no global window).
- Spilled recovery output in grace -> verbatim (raw returned, sidecar stub not used); same output past grace -> spill-pointer stub.
- Recovery call also in `protectedTools` -> verbatim at any age (permanent protection precedence).
- Multi-ref / repeated recovery of the same ref -> each toolResult judged by its own position.
`src/chain-compressor.ts` (eligibility):
- Chain whose span holds an in-grace recovery id -> deferred (not compressed).
- Same chain after the recovery ages out -> eligible and compressed normally.
- Chain with no recovery id -> unaffected.
`normalize()`:
- `0` preserved (kill switch); `-1` / `NaN` -> default `3`; `2.7` -> floored to `2`; unset -> default `3`.
Manual smoke-test (concrete repro, no private-session dependency): in an isolated `$PI_CODING_AGENT_DIR`, run a tool producing >1KB output, let it stub, then in a later user turn `context_tree_query` its ref; within K UTG the recovered output must stay verbatim across turn boundaries (inspect the rendered context / session JSONL), and past K UTG it must return to a stub.
## Out of scope
- **Empty-arg `context_tree_query({})` calls.** The corpus shows 475 (33.6% of all ctq calls) issued with no `toolCallIds` - the leaky "first call" that v2.0.1 tried to design out via the tool description. Separate defect (tool-call hygiene, not render policy); deferred to its own ticket.
- A general grace window over all tool outputs (rejected on the 77%-single-UTG data).
- Any change to the summarizer, dedup, spill, frontier, or the capture path.
## Documentation impact
- Feature / user-facing docs introduced: none
- Materially amended existing docs: `README.md` (config + `/pruner` reference gains `recoveryGraceTurns` - tunable parameter); `PRUNING.md` (algorithm section notes the render-time bounded recovery exemption at Phase 1 + chain-eligibility deferral - non-obvious rationale, incl. why render-time over capture-time); `CHANGELOG.md` - deferred: release
- Derived / memory docs invalidated: none
@@ -0,0 +1,239 @@
# Summarizer outage fallback
## Context
pi-condense summarizes completed tool-call batches with an LLM (`src/summarizer.ts`,
`runSummarization`). Users commonly point `summarizerModel` at a cheap model (e.g.
`anthropic/claude-haiku-*`) distinct from the session's main model. Provider incidents are
routinely **per-model**: Anthropic's status page regularly shows one model (Haiku) degraded
while Sonnet/Opus stay green.
Today, when the configured summarizer model fails at runtime, `runSummarization` catches the
error, fires `ctx.ui.notify(..., "error")`, and returns `null`. `index.ts` `flushPending`
restores the batch to `pendingBatches` and retries it **on the next flush against the same
dead model**. During a multi-hour single-model outage, pruning stalls for the whole outage
even though the session's main model (`ctx.model`) is healthy and already authenticated
in-session. No data is lost (raw outputs stay in context), but context grows unbounded until
the outage ends.
## Problem
The retry loop re-hits the one model that is down. There is no path that routes summarization
to a different, healthy model during a per-model outage. The result is stalled pruning and
unbounded context growth for the outage duration, despite a usable main model being available.
## Idea
Add a **sticky, in-memory fallback** to the session's main model (`ctx.model`) that engages
only on **transient** (outage-shaped) failures of the configured summarizer model, and returns
to the primary model automatically when it recovers.
Non-goals (explicitly out of scope):
- No new config knob. The fallback target is always `ctx.model`; the 10-minute re-probe
cooldown is an internal constant. A pin-a-specific-fallback-model config is YAGNI until
requested.
- No fallback on **auth** failures (bad/missing key is a config problem; `ctx.model` may use
different auth, so switching would mask a real misconfig).
- No fallback on **truncated/empty** results (`!isUsableSummary`); a one-off bad generation is
not an outage and must not flip the whole session onto the main model.
- No persistence. Fallback state is a live signal (like the `cost:external` channel), never
written as a `context-prune-*` session entry.
- Aborts (`signal.aborted`) remain re-thrown, exactly as today.
### Architecture
New module `src/summarizer-fallback.ts` exports a session-scoped `FallbackController`
(class). `index.ts` constructs one instance in the module scope of the extension and calls
`controller.reset()` from the `session_start` handler (matching how `indexer` / `statsAccum`
are reconstructed there), so each session starts with `inFallback=false`. State it holds:
- `inFallback: boolean`
- `lastProbeAt: number` (epoch ms)
- an injected `now(): number` clock (defaults to `Date.now`) for deterministic tests
Method surface (the wiring tests exercise these; a plain field holder cannot implement the
concurrency/dedup behavior below):
- `reset(): void` - clears `inFallback`, `lastProbeAt`; called from `session_start`.
- `pickTarget(primary, ctx, isProbe): Model` - returns `primary` when `!inFallback || isProbe`,
else `ctx.model`.
- `claimProbe(batchIndex): boolean` - returns `true` for exactly the elected probe batch of a
flush when `inFallback && now - lastProbeAt >= COOLDOWN_MS`; advances `lastProbeAt` on claim
so no sibling batch in the same flush also probes. Idempotent per flush.
- `recordOutcome({ target, outcome, wasProbe }): void` - the single transition point;
`outcome` is one of `success | transient | auth | unusable | abort`. Applies the matrix
below and fires at most one notify per transition (gated on the `inFallback` flag flip, so N
concurrent batches produce one warning, not N).
- `notifier` - the controller is constructed with a `notify` callback (bound to `ctx.ui.notify`)
so transitions can message without importing `ctx`.
Controller plumbing must reach **every** summarization call site, not just the parallel
fan-out. Enumerated call sites (verified in `index.ts` / `src/summarizer.ts`):
1. `summarizeBatches` (parallel fan-out) - controller passed via `SummarizeBatchesOptions`.
2. The sequential progress path in `flushPending` (the `options.onProgress` branch, ~lines
291-311) calls `summarizeBatch` per batch directly - controller passed via
`SummarizeBatchOptions`.
3. Range/chain fusion: `makeFuseRange` calls `summarizeRange(text, config, ctx, {})` and the
`/pruner compact` path via `chain-compressor` - both must pass the controller through
`summarizeRange`'s options.
`isProbe` is a new field on `SummarizeBatchOptions`, set only by the caller that elected the
probe (see Probe / recovery). All three paths funnel into `runSummarization`, which calls
`pickTarget` -> runs -> `recordOutcome`.
The controller is **inert** when no distinct fallback exists. `Model.provider` is a plain
string in `@earendil-works/pi-ai` (`Provider = KnownProvider | string`), not an object, so
"distinct fallback exists" is evaluated per call as:
```
ctx.model != null && ( primary.provider !== ctx.model.provider || primary.id !== ctx.model.id )
```
where `primary = resolveModel(config, ctx)`. When they match (e.g. `summarizerModel:"default"`)
or `ctx.model` is unavailable, all controller logic is skipped and behavior is byte-for-byte
today's.
### Failure classification
A caught summarizer failure carries no structured status code: `runSummarization` either
throws `new Error(response.errorMessage ?? ...)` on `stopReason === "error"` or catches a raw
stream throw as `err.message` (`src/summarizer.ts` ~lines 160, 175). There is no reliable
5xx-vs-4xx signal to inspect, so a precise `isTransientError` classifier is **not buildable**
without brittle message string-matching, which this spec rejects.
Classification is therefore **coarse**, keyed off the existing control flow rather than error
introspection:
- `abort` - `options.signal.aborted` (re-thrown today; unchanged, never trips the controller).
- `auth` - the **pre-flight** `getApiKeyAndHeaders` failure branch (`src/summarizer.ts`
~line 107) returns before any stream call. This is the common bad/missing-key case; it does
**not** reach the fallback path, so the "don't mask auth misconfig" non-goal holds for it.
- `unusable` - `!isUsableSummary` (empty or `stopReason === "length"`); returns `null` today,
never trips the controller.
- `transient` - **everything else** that reaches the catch (stream throw or
`stopReason === "error"`). This is the outage bucket that engages fallback.
**Known bounded gap (accepted):** a key that passes pre-flight but is rejected by the provider
mid-stream surfaces as a generic throw and is bucketed `transient`, so it would flip to
`ctx.model`. This is rare (pre-flight already caught the usual bad-key case) and is not silent
- the enter-fallback warning fires. Falling back to the session model + notifying is an
acceptable degradation, not the masked-misconfig failure the non-goal targets.
### State machine
Per call, `target = controller.pickTarget(primary, ctx, isProbe)`: `primary` when
`!inFallback || isProbe`, else `fallback` (= `ctx.model`). After the call resolves,
`recordOutcome` applies the matrix below. Outcome x target grid (rows = classified outcome,
cells describe state change / notify / return for that target):
| Outcome | target = primary (initial or probe) | target = fallback (steady-state) |
|---|---|---|
| `success` | if this was a probe: **recover** - `inFallback=false`, info notify. Else no transition. Return summary. | no transition; return summary. |
| `transient` | retry **once** on `fallback`. If retry `success`: if `!inFallback` **enter fallback** (`inFallback=true`, `lastProbeAt=now`, warning notify); if probe, **stay** (`lastProbeAt=now`, no notify). Return the fallback summary; suppress the legacy `error` notify. If retry also `transient`: **both-down** (see below). | **both-down**: keep legacy `error` notify, return `null`, set/keep `inFallback=true`, `lastProbeAt=now`. |
| `auth` | pre-flight: return `null` as today, **no** fallback retry, no transition (keeps legacy auth notify). | return `null`, legacy auth notify, no transition. |
| `unusable` | return `null` as today. If this was a probe, treat as probe-inconclusive: **stay**, `lastProbeAt=now` (do not recover on a truncated probe). Else no transition. | return `null`, no transition. |
| `abort` | re-throw; no transition. | re-throw; no transition. |
Retry orchestration lives in `runSummarization`: a call targeting `primary` that returns
`transient` re-invokes the model run once against `fallback` in the same call before returning.
Calls already targeting `fallback` never retry.
**both-down messaging:** the enter-fallback warning is suppressed on a both-down event (the
fallback attempt failed, so nothing is "using the session model" yet). To avoid the user never
learning the session flipped, the warning fires on the **first subsequent flush where a
fallback call succeeds** (i.e. the warning is owed until an actual successful fallback run
emits it once).
Notify text (uses `model.name`; falls back to `provider/id` if `name` is empty):
- warning: `pi-condense: summarizer model <primary.name> failing, using session model <ctx.model.name> until it recovers`
- info: `pi-condense: summarizer model <primary.name> recovered`
- legacy (unchanged, `src/summarizer.ts` ~line 179): `pruner: summarization failed: <err.message>`; auth variant ~line 110: `pruner: summarization failed: <authMessage>`.
The legacy per-failure `error` notify is **suppressed when the fallback retry succeeds**
(otherwise the user gets error + warning for one event). It is kept for the both-down and
steady-state-fallback-failure cases.
### Probe / recovery
While `inFallback`, summarization keeps routing to `fallback`. On the **first flush after the
10-minute cooldown** (`now - lastProbeAt >= COOLDOWN_MS`), exactly **one** batch is elected as
the probe and routed to `primary`; the remaining batches stay on `fallback`. This bounds
steady-state doomed calls to **1 per cooldown interval**.
Probe election is via `claimProbe(batchIndex)`, not a raw "index 0" assumption. `Promise.all`
gives no completion-order guarantee, so:
- The **caller** iterating batches (before dispatch) calls `claimProbe` synchronously as it
builds each batch's options; the first eligible index wins and `lastProbeAt` advances
immediately, so no sibling in the same flush also claims. That batch's options carry
`isProbe: true`.
- Probe **outcome** handling runs in that batch's own `recordOutcome` (keyed to its result),
independent of when other batches settle - recovery is never triggered by a non-probe batch.
- Sequential progress path and single-batch / fusion calls: the probe is the **first eligible
call of the flush** (`claimProbe` on the first batch index / the fusion call). A fusion-only
flush may serve as the probe.
Probe `success` -> recover; probe `transient` -> stay, that batch retries on fallback (no work
lost), cooldown already reset by `claimProbe`; probe `unusable` -> stay (inconclusive).
`COOLDOWN_MS = 10 * 60 * 1000`, an internal constant in `src/summarizer-fallback.ts`.
### Cost / blast radius (honest notes)
- The **initial detection flush** can fire up to N primary calls: before the controller knows
the model is down, `inFallback` is false, so every batch in that flush targets `primary`,
fails, and retries on `fallback`. This is unavoidable - you cannot detect an outage without
attempting the model. Steady-state is **0** doomed calls; re-probe is exactly **1** per 10
minutes.
- Fallback calls run on `ctx.model` and their cost is attributed to the summarizer exactly as
today (the `cost:external` / stats path is unchanged - it keys on the summarizer, not the
model id).
- A session restart mid-outage yields a fresh controller (`inFallback=false`), so the next
flush re-detects (one detection flush) and re-enters fallback. Acceptable per the
no-persistence non-goal.
- Whole-provider outages (both primary and `ctx.model` down) fall through to the both-down
`error` + `null` + retry-next-flush path. One behavior delta vs today: after such an outage
ends, primary is re-tried only on the next 10-min probe (not every flush), so summarization
runs on the (typically pricier) `ctx.model` for up to `COOLDOWN_MS` post-recovery before
probing back. Bounded and acceptable; not "zero change".
## Testing approach
Unit tests over `FallbackController` with an injected clock and simulated call outcomes
(`transient-fail` / `success`), plus targeted `summarizer.ts` wiring tests:
- enter-fallback: primary transient fail -> fallback success flips `inFallback`, emits one
warning.
- single-notify: N concurrent batches all failing primary emit **one** warning, not N.
- steady-state: while `inFallback`, non-probe batches route straight to `ctx.model`, never
touch primary.
- cooldown gating: no probe before `COOLDOWN_MS`; exactly one probe batch after; `lastProbeAt`
advances on probe.
- recover: probe success clears `inFallback`, emits one info.
- probe-fail: probe transient fail keeps `inFallback`, resets cooldown, probe batch still
produces a summary via fallback.
- same-model no-op: `primary` equal to `ctx.model` -> controller inert, zero transitions,
identical to current behavior.
- both-down: primary + fallback both transient-fail -> `error` notify preserved, `null`
returned, `inFallback` set; owed warning fires on the next successful fallback call.
- both-down mixed error types (e.g. primary `stopReason==='error'`, fallback raw stream throw)
-> same both-down handling.
- auth / unusable: neither trips the controller; probe returning `unusable` stays in fallback
(no false recovery).
- abort: `signal.aborted` still re-throws, no transition.
- bypass coverage: sequential progress path, single-batch, and `summarizeRange` fusion calls
all consult the controller (probe election + transitions), not just `summarizeBatches`.
- restart-mid-outage: `reset()` clears state; next flush re-detects and re-enters fallback.
## Documentation impact
- Feature / user-facing docs introduced: none
- Materially amended existing docs: PRUNING.md (new "Summarizer outage fallback" subsection -
state machine, transient-only trigger, sticky + 10-min re-probe, cost notes; clears the
materiality bar as durable rationale for non-obvious runtime behavior). README.md gets a
one-line mention in the summarizer/model section noting automatic fallback to the session
model during a summarizer-model outage (no config).
- Derived / memory docs invalidated: AGENTS.md "Project Layout" list (add `src/summarizer-fallback.ts`);
no customType table change (state is in-memory, not a `context-prune-*` entry).
@@ -0,0 +1,195 @@
# Summarizer call timeout (idle + ceiling)
Date: 2026-07-09
Status: proposed
Area: `src/summarizer.ts`, `src/types.ts`, `src/config.ts`, `src/commands.ts`, docs
## Context
`runOnce` in `src/summarizer.ts` drives a `stream()` call with exactly one escape hatch: `options.signal` (the user's Esc). It has no time budget. The stream loop `for await (const event of responseStream)` and the trailing `await responseStream.result()` both block indefinitely if the provider accepts the connection but stalls (no bytes, no close — common under provider congestion).
The automatic flush paths (`turn_end`, `message_end`, `agent_end` in `index.ts`) call `flushPending` **without** a signal, and `flushPending` awaits summarization inline. So a stalled summarizer call hangs the whole agent turn with no user-visible cause and no recovery.
Measured on this laptop (32,122 real assistant-generation latency samples across all sessions; dominant model `claude-opus-4-8`), a **legitimate** summarizer call is not "a handful of seconds":
| Summarizer output size | median | p95 | p99 | observed max |
|---|---|---|---|---|
| ~800 tok (median call) | 12.9s | 29.3s | 40.7s | 331s |
| ~1.7-2k tok (p90-95) | 26.6s | 44.2s | 55.4s | 290s |
| ~2.8k tok (p99) | 40.7s | 62.7s | 76.3s | 256s |
| ~5k tok (max) | 64s | 92.5s | 124.6s | 208s |
Per-call output-token distribution (11,092 samples from consecutive `context-prune-stats` deltas): median 798, p95 2032, p99 2779 tokens.
Implication: a flat total-duration timeout that avoids false-aborting healthy calls would have to sit at ~200s+, and under the outage-fallback retry (below) a hang would then burn ~2x that before giving up. That is not an acceptable "short" bound.
## Problem
A stalled summarizer connection hangs the agent turn indefinitely. There is no timeout anywhere in the call chain, and the automatic flush paths pass no abort signal, so nothing can cancel it.
## Idea
Bound every summarizer call with a **two-part timeout inside `runOnce`**, driven by a local `AbortController` combined with the caller's `options.signal`:
1. **Idle (inactivity) timeout — primary mechanism.** A healthy stream emits `text_start` / `text_delta` / `text_end` events continuously (sub-second inter-token gaps). A true hang emits nothing. Arm a timer for `summarizerIdleTimeoutMs` **before** the loop (this covers time-to-first-token) and reset it on every stream event. If it fires, abort the stream. Because it measures silence, not total time, it never false-aborts a legitimately long-but-flowing generation regardless of output size.
2. **Total-duration ceiling — backstop.** A single timer for `summarizerMaxTimeoutMs` armed once at call start, never reset. Catches the pathological "slow-drip-forever" stream that keeps emitting a token every N < idle seconds but never completes. Generous by design: it only clips calls beyond the observed legitimate p99.
Both are configurable; `0` disables either independently.
A timeout classifies as **`transient`** so it flows through the existing outage-fallback state machine in `runSummarization` (option B): when a distinct `summarizerModel` is configured, a primary that times out gets the one existing session-model retry (itself bounded by the same idle+ceiling timeout). This is **not** a new retry loop — it reuses the machine that already exists for provider outages. When `summarizerModel` is `"default"` (no distinct fallback), the single attempt times out, warns, and drops the batch (retried on the next natural flush; frontier does not advance).
A timeout is surfaced to the user via `ctx.ui.notify(..., "warning")` with timeout-specific wording, distinct from the existing `"error"`-severity API-failure notice.
### Values (grounded in the data above)
| Setting | Default | Rationale |
|---|---|---|
| `summarizerIdleTimeoutMs` | `20000` (20s) | Healthy inter-token gaps are sub-second; the only real spike is TTFT under congestion (~<3s, occasionally ~10s). 20s gives comfortable headroom over TTFT while catching a silent hang fast. Values < 15s risk false-aborting TTFT spikes. |
| `summarizerMaxTimeoutMs` | `180000` (180s / 3 min) | Clears every observed p99 (max p99 was 124.6s) with headroom; only clips pathological multi-minute outliers (208-331s completed calls, i.e. provider degradation). Pure backstop. |
Worst-case wall-clock for a genuine silent hang under option B: `idle` (primary stalls, ~20s) + `idle` (session-model retry also stalls, ~20s) = ~40s, then one warning + drop. The 180s ceiling only participates when a stream is dribbling (never goes fully idle) yet never finishes.
## Design
### Config (`src/types.ts`)
Add two fields to `ContextPruneConfig` and `DEFAULT_CONFIG`:
```ts
/**
* Idle (inactivity) timeout for a single summarizer stream call, in ms.
* Reset on every received stream event; armed before the first event so it
* also bounds time-to-first-token. If no event arrives within this window the
* call is aborted and classified transient (feeds the outage-fallback retry).
* 0 disables the idle timer. Default 20000.
*/
summarizerIdleTimeoutMs: number;
/**
* Total-duration ceiling for a single summarizer stream call, in ms. Armed
* once at call start, never reset — a hard upper bound that catches a stream
* that keeps dribbling events but never completes. Same transient/warning
* handling as the idle timeout. 0 disables the ceiling. Default 180000.
*/
summarizerMaxTimeoutMs: number;
```
`DEFAULT_CONFIG`: `summarizerIdleTimeoutMs: 20000`, `summarizerMaxTimeoutMs: 180000`.
Add two preset arrays for the settings overlay (strings, `"0"` = disabled sentinel), mirroring the existing `*_PRESETS` pattern:
```ts
export const IDLE_TIMEOUT_PRESETS = [
{ value: "0", label: "0 (disabled)" },
{ value: "10000", label: "10s" },
{ value: "20000", label: "20s (default)" },
{ value: "45000", label: "45s" },
{ value: "90000", label: "90s" },
];
export const MAX_TIMEOUT_PRESETS = [
{ value: "0", label: "0 (disabled)" },
{ value: "120000", label: "120s" },
{ value: "180000", label: "180s (default)" },
{ value: "300000", label: "300s" },
{ value: "600000", label: "600s" },
];
```
### Normalization (`src/config.ts`)
In `normalize()`, clamp both like the existing `minBatchChars` guard: finite `number >= 0` → `Math.floor(value)`, else the default. `0` is a valid (disabling) value, so the guard is `>= 0`, not `> 0`.
### `runOnce` (`src/summarizer.ts`) — the core change
- Extend the existing `transient` variant with an **optional** `timedOut` field — it keeps `message` (do not drop it or add a new variant):
```ts
| { kind: "transient"; message: string; timedOut?: boolean }
```
- Read the budgets from config at the top of `runOnce`:
```ts
const idleMs = config.summarizerIdleTimeoutMs;
const maxMs = config.summarizerMaxTimeoutMs;
let timedOut = false;
let timeoutKind: "idle" | "ceiling" | null = null;
let idleTimerId: ReturnType<typeof setTimeout> | null = null;
let ceilingTimerId: ReturnType<typeof setTimeout> | null = null;
```
- `combineSignals` is a small local helper (inline in `summarizer.ts`), tolerant of an absent caller signal:
```ts
function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal | undefined {
const present = signals.filter((s): s is AbortSignal => !!s);
if (present.length === 0) return undefined;
if (present.length === 1) return present[0];
return AbortSignal.any(present); // Node 20+; host runtime is node 24.5.0
}
```
Build a local `AbortController` (`timeoutController`) and pass `stream(..., { signal: combineSignals(options.signal, timeoutController.signal), ... })`.
- Idle timer: a helper `bumpIdle()` that `clearTimeout(idleTimerId)` and, when `idleMs > 0`, re-arms `idleTimerId = setTimeout(() => { timedOut = true; timeoutKind = "idle"; timeoutController.abort(); }, idleMs)`. Call `bumpIdle()` once before the loop (covers TTFT), then inside the loop **on every received event** — not only `text_*`. Reasoning-capable summarizer models (`summarizerThinking` wires `reasoningEffort`) emit `thinking_start`/`thinking_delta`/`thinking_end` (pi-ai `types.d.ts:275-287`); bumping on any event keeps a legitimately-reasoning-but-quiet stream alive, so extended reasoning never false-aborts. `reportTextProgress` stays gated to `text_*` events (unchanged).
- Ceiling timer: `ceilingTimerId = setTimeout(() => { timedOut = true; timeoutKind ??= "ceiling"; timeoutController.abort(); }, maxMs)` when `maxMs > 0`, armed once at call start.
- Clear both timers in a `finally` (`clearTimeout(idleTimerId); clearTimeout(ceilingTimerId);`) so no timer leaks after the call returns or throws.
- Classification (centralize so no transient site escapes it): a timeout abort can surface either as a throw in the catch OR as `response.stopReason === "error"` (pi-ai's error event, `types.d.ts:307`). At **every** transient return site — the `stopReason === "error"` branch and the catch — set `timedOut`/`timeoutKind` into the outcome: if `timedOut && !options.signal?.aborted`, return `{ kind: "transient", timedOut: true, message: <worded by timeoutKind> }` — e.g. `` `summarizer ${modelLabel(model)} stalled (no output for ${idleMs / 1000}s)` `` for idle, `` `summarizer ${modelLabel(model)} exceeded ${maxMs / 1000}s ceiling` `` for ceiling. A simple way: compute the timeout message once from `timeoutKind` and prefer it whenever `timedOut` is set, at both sites.
- User abort is unchanged: `options.signal?.aborted` still short-circuits and re-throws so `flushPending` restores state silently. The timeout controller aborting does **not** set `options.signal.aborted` (it is a separate controller), so a timeout is never misclassified as a user abort.
### `runSummarization` (`src/summarizer.ts`) — warning severity
Replace the bare `notifyError` sites with a helper that picks severity from the outcome. Wording is **path-neutral** — `runSummarization` is shared by `summarizeBatch` and `summarizeRange`, and a timed-out range fusion neither "drops a batch" nor "retries next flush" (it falls back to per-batch concat at render, see below), so the message must not assert batch semantics:
```ts
const notifyFailure = (o: { message: string; timedOut?: boolean }) =>
ctx.ui.notify(
o.timedOut
? `pi-condense: ${o.message}; summarizer call abandoned`
: `pruner: summarization failed: ${o.message}`,
o.timedOut ? "warning" : "error",
);
```
Threaded through:
- Legacy no-fallback path: `case "transient": notifyFailure(r); return null;`
- Fallback path, fallback-only fail: `notifyFailure(r)`.
- Fallback path, both-down: `notifyFailure(r2.kind === "transient" || r2.kind === "auth" ? r2 : r)` — preserves today's exact message-selection (the auth arm must stay: a session-model auth failure after a primary timeout should surface the auth error, not the stale timeout message) and carries whichever `timedOut` flag applies.
- Fallback rescue (primary times out, session model succeeds): no failure notice; the existing `"enter"` fallback warning (`summarizer model X failing, using session model Y until it recovers`, already `"warning"` severity) fires via `emit`. A timeout is treated exactly like any transient here. **Scope note:** the "every timeout gets timeout-specific wording" guarantee applies to **non-rescued** timeouts only; a rescued primary timeout is reported solely as the generic fallback-entry warning. This is intentional — the rescue path is not a failure and does not warrant a second, redundant notice.
No change to the `FallbackController` itself: a timeout is a transient, and the controller already handles transient primary → session retry → sticky-until-probe. Option B falls out of the existing machine for free.
### Commands / UI (`src/commands.ts`)
- **Imports:** export `IDLE_TIMEOUT_PRESETS` / `MAX_TIMEOUT_PRESETS` from `src/types.ts` and import them in `src/commands.ts` alongside the existing `MIN_BATCH_CHARS_PRESETS` etc.
- **SettingsList entries:** add two entries in the settings overlay (placed after `recoveryGraceTurns`), each using its preset array via `values: PRESETS.map((p) => p.value)` with the same `currentValue`-in-cycle fallback as `minBatchChars` (fall back to the default preset value when the stored value is not in the cycle). Description text states the disabling sentinel (`0`) and the idle-vs-ceiling distinction.
- **`onChange` wiring (required):** every setting has an explicit `id`-matched branch in the overlay's `onChange` handler; a `SettingsList` entry without one renders but never persists. Add branches for `summarizerIdleTimeoutMs` and `summarizerMaxTimeoutMs` that `parseInt` the new value, clamp `>= 0` (fall back to the default on NaN/negative), assign to `newConfig`, and refresh the description — mirroring the `minBatchChars` branch.
- **`/pruner status`:** append two lines alongside the existing model/thinking lines, formatted `idle timeout: 20s` / `max timeout: 180s`, rendering `disabled` when the value is `0`.
- **Out of scope:** dedicated `/pruner idle-timeout` / `/pruner max-timeout` subcommands. The settings overlay + `settings.json` are the surfaces; a bespoke subcommand each is YAGNI. Add later if asked.
## Error handling and edge cases
- **Idle disabled (`0`), ceiling set:** only the ceiling timer arms. The ceiling `setTimeout` is armed at call start and fires on schedule regardless of stream activity (JS timers are not blocked by a pending `await`), so even a stream that never yields its first event is still caught — at the ceiling (up to 180s), not fast. Accepted; it is the user's explicit choice. With **both** disabled there is no first-token bound at all (see next).
- **Both disabled (`0`/`0`):** exact current behavior (no timeout), including no first-token bound — a stream that never yields hangs indefinitely, the pre-feature bug. Preserved deliberately as the escape hatch for anyone who hits false aborts.
- **Reasoning summarizer models:** because the idle timer resets on `thinking_*` events too (not just `text_*`), a model doing long extended reasoning before emitting text keeps the idle timer alive and is not false-aborted. Residual risk only if a provider streams reasoning with inter-event gaps beyond `summarizerIdleTimeoutMs`; mitigation is to raise it.
- **User Esc during a bounded call:** `options.signal` fires; the combined signal aborts the stream; the existing `options.signal?.aborted` checks re-throw the abort so `flushPending` restores state. Not classified as a timeout (no warning).
- **Timeout fires after the stream already completed:** the `finally` clears both timers on the normal-completion path before they can fire; a race where the timer fires between `result()` resolving and cleanup is harmless (the outcome is already built; aborting a settled stream is a no-op).
- **Parallel batches (`summarizeBatches`):** each `runOnce` owns its own timeout controller, so one batch timing out does not abort its siblings; each is independently classified.
- **Range fusion (`summarizeRange`) and per-batch both** route through `runSummarization` → `runOnce`, so both inherit the timeout with no extra code. On a range-fusion timeout, `runSummarization` returns `null`; `compressEligible` then proceeds **without** `rangeSummaryText`, and the renderer falls back to the per-batch concatenation (per `ChainCompressionEntry.rangeSummaryText` semantics). The chain is still compressed — only the fused summary is skipped. This is why the warning wording is batch-neutral.
- **Timer leak:** `finally` clears both `setTimeout` handles on every exit path (success, unusable, transient, throw).
## Testing approach
`bun test src/` — extend `src/summarizer-wiring.test.ts` (its `mock.module` swap of `stream` and note-capturing `ctx` are the right harness). **Mock-harness change required:** the current stub is `stream: (model: any) => streamImpl(model)`, which drops the options/signal argument, so a hanging stream cannot observe the abort. Change it to forward all args — `stream: (...args: any[]) => streamImpl(...args)` — and type `streamImpl` as `(model, input, opts)` so helpers can read `opts.signal`. Add a `hangingStream(signal)` helper whose async iterator awaits a promise that rejects when `signal` aborts, and whose `result()` likewise never resolves until abort. Drive with a tiny `summarizerIdleTimeoutMs` (e.g. `20`) so tests run fast.
Cases:
1. **Idle timeout → transient + warning.** `summarizerModel: "default"`, hanging stream, `summarizerIdleTimeoutMs: 20`. Assert `summarizeBatch` returns `null`, one note at level `"warning"` whose text mentions stalling.
2. **Ceiling timeout → transient + warning.** Idle disabled (`0`), tiny `summarizerMaxTimeoutMs`, a stream that dribbles events forever (never idles). Assert ceiling warning fires.
3. **Option B: primary idle-times-out, session model rescues.** Distinct `summarizerModel`, controller present; `streamImpl` returns a hanging stream for `PRIMARY` and an `okStream` for `SESSION`. Assert result is the session summary, `"enter"` fallback warning present, no failure notice.
4. **Both time out → both-down warning.** Distinct model; both models hang. Assert `null` + `onBothDown`-path warning at `"warning"` level.
5. **User abort is not a timeout.** Pre-aborted `options.signal`; assert the call throws (propagated abort) and no `"warning"` note is pushed.
6. **Disabled (`0`/`0`) → no timer, unchanged behavior.** Existing ok/err wiring tests still pass with the new fields defaulted; add one asserting an `okStream` succeeds with both timeouts `0`.
## Documentation impact
- Feature / user-facing docs introduced: none.
- Materially amended existing docs: `doc/configuration.md` and `CHANGELOG.md` (one entry). `README.md` settings table lists only a subset, so no change there. The exact `doc/configuration.md` deltas:
- Two rows in the "Every key" table:
```
| `summarizerIdleTimeoutMs` | non-negative integer (ms), `0` disables | `20000` | Abort a summarizer stream call after this much silence (no stream event). Resets on every event, so it never false-aborts a flowing generation; catches a stalled connection fast. A timeout feeds the same outage-fallback retry as a provider error. `0` = no idle bound. |
| `summarizerMaxTimeoutMs` | non-negative integer (ms), `0` disables | `180000` | Hard ceiling on total duration of a single summarizer stream call. Backstop for a stream that dribbles forever without going idle. Generous by design (clears the observed p99). `0` = no ceiling. |
```
- Two keys added to the "Full settings JSON" sample (after `summarizerThinking`): `"summarizerIdleTimeoutMs": 20000,` and `"summarizerMaxTimeoutMs": 180000,`.
- A short "Summarizer timeouts" subsection near the existing outage-fallback note (line ~98): idle-vs-ceiling distinction, that a timeout classifies as transient and feeds the same session-model fallback retry, and that both tries are timeout-bounded.
- Derived / memory docs invalidated: none. (`AGENTS.md` project-layout and customType tables are unaffected — no new session entry types, events, or `src/*.ts` files; the change is confined to existing files.)
@@ -0,0 +1,90 @@
# Pruner no-op serialization + fast-path comment fix
Fixes [issue #4](https://github.com/jjuraszek/pi-condense/issues/4). Two coupled corrections in the render-time prune path plus a folded-in dependency refresh.
## Problem
`pruneMessages` (`src/pruner.ts`) runs on every main-loop render. It computes `const beforeChars = sizeMessages(messages)` **unconditionally** at line 72 - a full `JSON.stringify(messages).length` over the entire context - before any pruning phase runs. On a no-op render (nothing to prune) that serialization is pure waste: the result is returned as both `beforeChars` and `afterChars` but the sole consumer never reads them on the no-op path.
Cost is CPU/GC only (zero token cost), and it is narrower than it first looks: at large context under the default config, phase 4 (thinking-strip) fires most turns, so `pruned` is usually `true` and `beforeChars` **is** consumed. The wasted serialization bites on small sessions and genuine no-ops.
Separately, the fast-path comment at `index.ts:820-823` is wrong: it claims the fast path triggers "when both the tool-call index and chain registry are empty." Phases 2 (error-purge) and 4 (thinking-strip) prune independently of index/registry state, so that emptiness does not imply a no-op. The comment is worth correcting regardless of the serialization fix, and the two belong in one change.
## Non-goals
- **Removing the phase-1 `.map()`** (`src/pruner.ts:76`). It allocates an N-pointer array, not a deep copy; skipping it needs a pre-scan and is not worth it. Asymptotically it is O(n) pointer copies against the O(total serialized bytes) of the `JSON.stringify` this spec removes - a different order of magnitude, so the `.map()` is not the win here.
- **Coupling to issue #1** ("fast-path predicate covers all four phases"). Speculative; dropped.
- **CI node pinning.** `test.yml` is bun-only (no node pin) and `release.yml` pins node 24; leave both as-is.
- **Any Python work.** There is no Python in this repo (confirmed: no `.py`, `pyproject.toml`, `requirements.txt`, `.python-version`, mise/`.tool-versions`, or Python in CI/scripts). pi-condense is pure TS/Bun/Node.
## Ground truth (verified against source)
- `src/pruner.ts:71` return type is inline: `{ messages: any[]; pruned: boolean; beforeChars: number; afterChars: number }`. JSDoc (lines 33-61) documents phases + `pruned`/`messages` but not `beforeChars`/`afterChars`.
- `src/pruner.ts:161` currently returns `{ messages: current, pruned, beforeChars, afterChars: pruned ? sizeMessages(current) : beforeChars }`.
- `sizeMessages` (`src/pruner.ts:15-17`): `JSON.stringify(messages).length`; deliberately serializes hidden fields (thinking, tool args, tool-result arrays).
- **Mutation invariant (load-bearing):** no phase mutates the input `messages` in place. Phase 1 builds `next` via `.map()` and sets `current = pruned ? next : messages`; phase 2 (`error-purge.ts:60`) returns `messages` unchanged when inactive; phase 3 (`chain-range-prune.ts`) builds a fresh `out`; phase 4 (`thinking-strip.ts:37`) returns `messages` unchanged when inactive. Therefore `sizeMessages(messages)` evaluated at return time equals its value at entry - moving the call into the pruned branch is behavior-preserving.
- **No-op path is `JSON.stringify`-free apart from the `beforeChars` line:** with default `recoveryGraceTurns=0`, `inGraceRecoveryToolCallIds` returns immediately without serializing; phase-1 `.map` and all phase gates use no serialization. So removing line 72's call makes a no-op perform zero `JSON.stringify` over the message array.
### Consumers of `beforeChars` / `afterChars`
- `index.ts:836` `statsAccum.setLiveReclaim(result.beforeChars, result.afterChars)` - **gated on `result.pruned`** (`index.ts:835`). Never runs on a no-op, so `{0,0}` on the no-op path is never read.
- `src/commands.ts:63` `pruneStatusText` reads the stored `LiveReclaim` and guards `if (!reclaim || reclaim.beforeChars <= 0) return "prune: ON"`. `LiveReclaim` (`src/types.ts:695-698`) is only ever fed by `setLiveReclaim`, i.e. only from the pruned path.
- `commands.test.ts:28-29` already asserts `{beforeChars:0, afterChars:0} -> "prune: ON"`. The `{0,0}` sentinel is already an accepted input shape downstream.
## Design
### 1. Core fix - `src/pruner.ts`
Remove the unconditional `const beforeChars = sizeMessages(messages)` at line 72. Compute both sizes lazily in the pruned branch only, and return the `{0,0}` sentinel on no-op:
```ts
return pruned
? { messages: current, pruned, beforeChars: sizeMessages(messages), afterChars: sizeMessages(current) }
: { messages, pruned, beforeChars: 0, afterChars: 0 };
```
Net effect: a no-op render performs zero `JSON.stringify` over the message array. The pruned path is unchanged (still two serializations: input and output). This is **non-breaking** for consumers: the return shape is identical, and the only reader of the size fields (`index.ts:836`) is gated on `result.pruned`, so it never observes the `{0,0}` sentinel; `pruneStatusText` already treats `beforeChars <= 0` as "no reclaim data yet".
Approaches considered: (A) `{0,0}` sentinel - chosen; (B) lazy getter fields; (C) optional fields dropped on no-op. B and C add type churn and touch consumers for a CPU-only win. A keeps the flat return shape and matches the issue AC.
### 2. Field-semantics JSDoc - `src/pruner.ts:33-61`
Extend the existing JSDoc to document the two size fields: when `pruned` is `true` they are the serialized-context sizes before and after pruning; when `pruned` is `false` both are `0` - a no-op sentinel, not a measurement. Match the existing comment style in the file (no banner comments).
### 3. Fast-path comment fix - `index.ts:820-823`
Replace the inaccurate claim ("fast path when the tool-call index and chain registry are empty") with the truth: phases 2 (error-purge) and 4 (thinking-strip) can prune regardless of index/registry state, so `pruned` reflects any of the four phases firing - index/registry emptiness alone does not imply a no-op.
### 4. Dependency refresh - `package.json`
- `engines.node`: `>=20.3.0` -> `>=22.19.0`, matching the host pi runtime (`@earendil-works/pi-coding-agent@0.83.0` declares `engines.node: >=22.19.0`). This is the one real "match current pi recommendation" gap; it drops advertised support for Node 20/21, which pi itself no longer runs on. Consumer impact: no CI or runtime impact (the test/release pipeline is Bun-only), but `npm install` under Node <22.19.0 will now warn (or fail under `engine-strict`) - acceptable, since such a Node can't run the host pi anyway.
- Preserve `package.json`'s trailing newline when editing (it ends with `}\n` in HEAD); do not let an editor strip it.
- `@sinclair/typebox`: floor `^0.34.49` -> `^0.34.52` (latest; the caret already resolved to `.52`, so this is a freshness-only bump with no resolution change).
- `@earendil-works/pi-coding-agent`, `pi-ai`, `pi-tui`: left at `^0.83.0` (already the latest published; == host runtime).
This is an independent workstream from the pruner fix, folded into one change at the user's request; both are small.
## Edge cases
- **Empty input** (`messages = []`): no-op path returns `{0,0}`. Correct - no serialization needed.
- **Reverted fix regression:** if a future change moves `sizeMessages(messages)` back to the unconditional path, `beforeChars` becomes nonzero on a no-op and the rewritten test (below) fails. This is the intended guard.
- **`prune: ON` status:** unaffected - `pruneStatusText` already treats `beforeChars <= 0` as "no reclaim data yet" and the live-reclaim store is only written from the pruned path.
## Testing
Runner: `bun test src/` (per AGENTS.md).
- **Rewrite `src/pruner.test.ts:593-601`** (the no-op case). It currently asserts `beforeChars === afterChars === sizeMessages(input)` - the exact behavior being removed. New assertion, with a **non-empty** input: no-op returns `{ pruned:false, beforeChars:0, afterChars:0 }`. This asserts the return contract, not the serialization-skip directly. What it catches: the realistic regression where `sizeMessages(messages)` is moved back to the unconditional path *and consumed* into `beforeChars` (the pre-fix code shape) - the non-empty input then yields nonzero and the test fails. Accepted residual gap: a contrived regression that calls `sizeMessages(messages)`, discards the result, and still returns the `{0,0}` sentinel would pass this test while re-introducing the CPU cost. A `JSON.stringify` call-count spy would close that gap but was **rejected** as disproportionate for a CPU-only micro-opt (it would also couple the test to every phase's internal gating and false-positive on any future legitimate cheap serialization on the path). This is an informed decision, not an oversight.
- **Pruning-path test (`src/pruner.test.ts:603-623`)** stays valid and unchanged - it asserts real `beforeChars`/`afterChars` values on a pruning render.
- **`sizeMessages` test (`src/pruner.test.ts:571-589`)** unchanged.
Verification commands:
- `bun test src/` - all green.
- Typecheck per AGENTS.md: `bun x tsc --noEmit --target es2022 --module nodenext --moduleResolution nodenext --strict --skipLibCheck --allowJs --esModuleInterop --resolveJsonModule --lib es2022 --types node index.ts`.
- `bun install --no-save` - resolves clean under the bumped `engines.node` and typebox floor. Use `--no-save` (or delete the generated `bun.lock` afterward): the repo tracks no lockfile and `.gitignore` excludes only `package-lock.json`, so a bare `bun install` under Bun 1.3.x leaves an untracked `bun.lock`.
- `git status --porcelain` after the above - confirm no stray files (e.g. `bun.lock`) beyond the intended edits.
## Documentation impact
- Feature / user-facing docs introduced: none
- Materially amended existing docs: CHANGELOG.md (fix entry: no-op zero-serialization + corrected fast-path comment; plus `engines.node` bump to `>=22.19.0` and typebox floor bump). `src/pruner.ts` JSDoc field semantics (implementation surface, in the same change).
- Derived / memory docs invalidated: none (no AGENTS.md routing, PRUNING.md algorithm, or README config surface changes - the return shape and phase behavior are unchanged, only the no-op field values and an inaccurate comment).
@@ -0,0 +1,164 @@
# Flush-gated thinking strip (timestamp-keyed boundary)
> **Superseded by:** [doc/specs/2026-08-05-remove-thinking-strip.md](./2026-08-05-remove-thinking-strip.md) - fully
Issue: [jjuraszek/pi-condense#3](https://github.com/jjuraszek/pi-condense/issues/3) - "thinkingStrip advances its window every render, so the prompt cache falls back to tools+system on ~57% of turns".
Status: accepted (brainstorm). Target: v2.4.x. Worktree: `fix/thinking-strip-flush-gated`.
## Problem
Phase 4 of `pruneMessages` (`stripOldThinking`, `src/thinking-strip.ts`) derives its keep-window from the **live assistant count in the current array on every `context` render**. As turns advance, the `(count - keepLastTurns)`-th assistant slides forward one turn per render, so the message that falls out of the window has its thinking stripped - a mutation **deep in history**, not at the tail.
pi-ai's serializer sets prompt-cache breakpoints only at `tools + system + last message` (no in-history breakpoint). Verified in `node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js` `convertMessages`: `cache_control` is attached to exactly the system prompt block, the last tool definition (`index === tools.length - 1`), and the last conversation message ("Add cache_control to the last user message to cache conversation history"). A deep-history mutation therefore invalidates the entire cached suffix after `tools + system`. Because phase 4 mutates on *every* render, the cache busts on roughly every render inside a tool loop - the ~57% tools+system fallback the issue reports.
The behaviour (bounding retained thinking to the last K turns) is wanted; unbounded thinking bloats context. Only the **cadence** (per-render) and the **documented cost model** are wrong.
## Goals
- Phase 4 **does not mutate any previously-rendered historical message between flushes** - so the shared historical prefix of consecutive `context` renders is byte-stable and the cache prefix survives a tool loop. (Not "the whole array is byte-identical": a tool loop appends a new toolCall/toolResult at the tail before each render, so the arrays differ at the tail by construction; the invariant is about the *unchanged prefix*, and `error-purge` remains an independent, out-of-scope live-count mutator - see [Cache economics](#cache-economics).)
- Retained-thinking window stays anchored to `keepLastTurns` (preset semantics preserved: 4/8/16/32/64).
- No new config key.
- `PRUNING.md` "Cache impact" corrected.
- Existing 12 `thinking-strip.test.ts` tests pass unchanged.
## Non-goals (out of scope)
- **No cadence/stride knob.** The core fix already turns k-busts-per-request into 1 (see [Cache economics](#cache-economics)); a stride is a modest, workload-dependent further lever, deferred until telemetry justifies it. If ever added, it is expressed in **turns**, not messages (self-adjusts to variable request length; `keepLastTurns` is already in turns).
- **No change to default budget knobs** (`autoBudgetThreshold` / `budgetTurnDelta` stay `null`).
- **Not** reusing `PruneFrontier.lastAttemptedTimestamp` for the window (rejected in Q1 - it is a batch-capture wall-clock time at the *front* of the un-summarized region, coupled to prune progress and `rollingWindow`, unrelated to `keepLastTurns`; reusing it would deprecate `keepLastTurns`).
## Design overview
The strip boundary stops being derived per-render and becomes a **flush-computed, persisted assistant-message timestamp** that only moves forward at flushes. Everything else in phase 4 (all-or-nothing per-message strip, same-ref fast paths, order/length preservation) is untouched.
- **Compute at flush** (`flushPending`): `boundary = timestamp of the (assistantCount - keepLastTurns)-th assistant message`, over the raw session branch plus the not-yet-persisted `closingMessage`, clamped monotonic non-decreasing. Stored on the existing frontier snapshot.
- **Consume at render** (`context` handler -> `pruneMessages` -> `stripOldThinking`): strip thinking from every assistant message with `timestamp < boundary`. Between flushes the boundary is a fixed number -> byte-identical output.
- **Persist** on the existing `context-prune-frontier` entry - no new customType, no new persistence layer, no new config key.
Boundary keyed by **message timestamp**, not array index: phase 3 (`chain-range-prune`) drops middle messages, so an absolute index into a shrinking array is a landmine. Timestamp identity is the precedent phase 3 already uses (`msg.timestamp`).
## Components and signatures
Five touch points. All new params are **optional**; absent/`undefined` -> the current live-count logic runs verbatim (this is what keeps the 12 existing tests green and handles genuinely pre-first-flush and pre-feature session entries). Feature-written frontier entries must survive session reload - see `src/frontier.ts` below; without that change every resumed session drops the boundary and falls back to the buggy per-render path until its next flush.
### `src/types.ts` - one new frontier field
Add to `PruneFrontier`:
```ts
thinkingStripBoundaryTimestamp?: number;
```
Rides the existing `CUSTOM_TYPE_FRONTIER` entry. No change to `ThinkingStripConfig` (`{ enabled, keepLastTurns }`) or `DEFAULT_CONFIG`.
### `src/frontier.ts` - persist the field across reload
`PruneFrontierTracker.fromJSON` (frontier.ts:22-40, verified) reconstructs the frontier by **explicitly whitelisting** known fields, so a field added only in `types.ts` is silently dropped on every `session_start` / `session_tree` reconstruct. Add one line inside `fromJSON`:
```ts
thinkingStripBoundaryTimestamp: data.thinkingStripBoundaryTimestamp,
```
(Left `undefined` when absent, which the render path already treats as the live-count fallback.) This is a **blocker**: without it the fix regresses to the per-render bug on every resumed session, not just genuinely pre-feature ones. Requires a persist-then-`fromJSON` round-trip test asserting the field survives.
### `src/thinking-strip.ts` - boundary input + pure helper
- `stripOldThinking(messages, config, boundaryTimestamp?)` - new optional 3rd arg.
- `boundaryTimestamp` is a `number` -> strip thinking from every assistant message with `msg.timestamp < boundaryTimestamp` (all-or-nothing per message; same same-ref fast paths).
- `undefined` / `null` -> **fall back to the current live-count logic verbatim.**
- `computeThinkingBoundary(assistantTimestamps, keepLastTurns, prev?)` (new, pure, unit-testable):
- Input is the ordered list of assistant-message timestamps (raw branch + `closingMessage` when it is an assistant message).
- **Clamp `keep = Math.max(1, keepLastTurns)` before indexing** - mirrors the existing `stripOldThinking` clamp (thinking-strip.ts:25) and the locked `keepLastTurns=0` contract (thinking-strip.test.ts:99). Without it `keepLastTurns=0` indexes `assistantTimestamps[count]` (out of bounds -> `undefined`/`NaN` boundary that then gets persisted).
- `count <= keep` -> return `prev` (nothing to strip yet; do not regress).
- else `candidate = assistantTimestamps[count - keep]`; return `max(prev ?? candidate, candidate)` (monotonic non-decreasing = advance-only).
### `src/pruner.ts` - thread the boundary through
- `pruneMessages(messages, indexer, chainCompression?, errorPurge?, thinkingStrip?, protection?, recoveryGraceTurns=0, thinkingBoundaryTimestamp?)` - one new trailing optional param, passed straight into the phase-4 call at pruner.ts:154 (`stripOldThinking(current, thinkingStrip, thinkingBoundaryTimestamp)`).
### `index.ts` - source at render, compute at flush
- **Render** (`context` handler, index.ts:824): pass the persisted boundary as the new trailing arg -
```ts
pruneMessages(messages, indexer, ...existing args..., frontier.get()?.thinkingStripBoundaryTimestamp)
```
which reaches phase 4 as `stripOldThinking(current, thinkingStrip, thinkingBoundaryTimestamp)`.
- **Flush** (`flushPending`, at the `frontierSnapshot` construction, index.ts:489): when `thinkingStrip.enabled`, gather assistant timestamps from the session branch, using the exact unwrap precedent already in-repo (index.ts:527-530):
```ts
const branchMessages = ctx.sessionManager.getBranch()
.filter((e: any) => e.type === "message" && e.message)
.map((e: any) => e.message);
```
This must run **regardless of `chainCompression.enabled`** - the existing occurrence is inside the `chainCompression.enabled` branch, so the boundary computation needs its own independent call. Include `options.closingMessage` when it is a not-yet-persisted assistant message (agent-message mode fires `message_end` before pi persists it), take assistant-message timestamps in order, call `computeThinkingBoundary(..., prev = frontier.get()?.thinkingStripBoundaryTimestamp)`, and set `thinkingStripBoundaryTimestamp` on `frontierSnapshot`. It then persists via the existing `frontier.advance(frontierSnapshot)` + `context-prune-frontier` write. When disabled, leave the field carrying `prev` (harmless; phase 4 ignores it).
## Data flow
- **Render (every provider request):** read `frontier.get()?.thinkingStripBoundaryTimestamp` -> `pruneMessages` -> phase 4 strips against that fixed value. Between flushes the value is identical -> byte-identical output -> cached prefix survives the whole tool loop.
- **Flush (`flushPending`, non-empty batches only):** the boundary is computed and stored inside the *same* `frontierSnapshot` transaction that already advances the prune frontier and writes `context-prune-frontier`. This is the [Q2 decision](#boundary-cadence-q2): advance on **every non-empty flush**. The `batches.length === 0` early-return (index.ts:176) means a pure-text turn with no captured batches does **not** advance the boundary (nothing to summarize either).
- **`session_start` / `session_tree`:** the frontier reconstructs from the persisted entry (existing behaviour); the new field rides along. Entries written before this change lack the field -> `undefined` -> live-count fallback until the first new flush.
## Cache economics
The premise "the boundary still moves once per request, so the cache still busts" is true but must be read as *per request*, not *per render* - that distinction is the whole win:
| Design | Thinking-strip cache busts | Thinking retained |
|---|---|---|
| Today (bug: advance every render) | **k per request** (k = tool-loop length, ~5-30 renders) | `keepLastTurns` |
| This spec (advance every non-empty flush) | **1 per request** | `keepLastTurns` (drifts up to `keepLastTurns + turns-since-flush` between flushes) |
| (deferred) flush-gated + turns-stride M | ~1 per M turns of growth | `keepLastTurns + up to M` |
The retained-window "drift up" between flushes is deliberate, not a leak: freezing the boundary is exactly what keeps renders prefix-stable, and the extra thinking is re-read from cache at ~0.1x until the next flush snaps the window back to `keepLastTurns`. Separately, `error-purge` (`purgeErroredArgs`, error-purge.ts:20) derives its cooldown from a **live per-render assistant count** and can rewrite an old assistant's toolCall arguments with no flush involved - an independent live-count history mutator outside this fix's scope, so a literal whole-array-identity claim would be false regardless. That is why the goal/AC are scoped to phase 4's prefix stability, not global byte-identity.
Grounded in the code, the residual 1-per-request bust is **not** always free:
- Phase-1 stub-replace keys off `indexer.isSummarized` (pruner.ts:78), a cumulative set; `rollingWindow` gates only phase-3 chain compression, not per-batch summarization. At a flush the current request's batches are summarized immediately, so at the next render they turn to stubs. The oldest such change sits at ~*start of the just-finished request*.
- **Short request (< `keepLastTurns` turns, the common interactive case):** start-of-request is newer than `tail - keepLastTurns`, so the thinking boundary is the **deeper, dominant** invalidator - not subsumed by summarization. Residual: ~1 deep reprocess per request.
- **Long request (>= `keepLastTurns` turns):** summarization's stub-replace reaches older than the thinking boundary -> thinking-strip is subsumed -> ~0 marginal bust.
Net: flush-gating removes the k-per-request busts (the ~57% figure) unconditionally, and leaves at most one deep reprocess per short request. The steady-state cost of *not* adding a stride knob is that ~1/request reprocess in high-frequency short-request sessions; the cost of adding one would be a config knob plus more retained thinking read from cache every call. Deferred by decision (option 1).
## Boundary semantics
**Stateless recompute, not a running counter.** At each flush the boundary is recomputed from scratch as the `(count - keepLastTurns)`-th assistant timestamp over the raw branch (+ `closingMessage`). There is no "turns since last flush" tally to maintain or reset - the recompute *is* the re-anchor. This matches the current live-count mechanism and how the frontier reconstructs on `session_start`, and it self-corrects for free on session reload. (Relative-advance `prev + (count_now - count_prev)` collapses to `count_now - keepLastTurns` anyway, so the stateful form buys nothing and adds reload/rebuild failure modes.)
**Retained window over time:** `keepLastTurns` **raw-session turns** immediately after a flush; drifts up to `keepLastTurns + (turns since flush)` between flushes (boundary frozen for cache stability); snaps back on the next flush. The count is over the raw branch, so the number of *surviving rendered* turns that keep thinking can be fewer than `keepLastTurns` when phase-3 chain compression has dropped closed chains inside the window - benign, see [Edge cases](#edge-cases).
**Monotonic clamp `max(prev, candidate)`** is a no-op in the normal case (`count` only grows, so `candidate >= prev`). Its sole purpose is the **`keepLastTurns` increased mid-session** case (user cycles the preset up, e.g. 16 -> 32): `count - 32 < count - 16`, so `candidate` would move *backward*, re-adding thinking to already-stripped messages and busting the cache. The clamp forbids that: a larger window applies going forward only. This is the issue's AC restated as a **logical invariant** - thinking is never re-added to a message already stripped - not a numeric one. (`keepLastTurns` *decreased* mid-session moves `candidate` forward and strips more aggressively next flush - allowed, that is what the user asked for.)
## Edge cases
- **Pre-first-flush / no frontier field / old persisted entry:** `undefined` -> live-count fallback (today's behaviour). Cache is not yet valuable at low turn counts.
- **`count <= keepLastTurns`:** `computeThinkingBoundary` returns `prev`; phase 4 fast-paths to the same array reference.
- **Phase-3 middle drops:** the boundary is a timestamp computed over the **raw branch**, while phase 4 runs at render over the post-phase-1-3 survivor array. The timestamp comparison is robust to drops (a dropped message simply is not present to compare). Because chain compression's rolling window (default 3 turns) is *inside* the `keepLastTurns` (16) window, it can drop closed chains that sit newer than the boundary, so immediately after such a flush fewer than `keepLastTurns` surviving turns retain thinking. This is **intended and benign**: it strips *more*, never re-adds, and a dropped turn's thinking is gone with the whole turn anyway. It does **not** "self-correct at the next flush" (the raw branch keeps counting the dropped positions); computing over the raw branch is the deliberate choice because it keeps the boundary stable and decoupled from chain-compression cadence. If "exactly K surviving turns" ever became a hard requirement, the flush would instead compute over the post-phase-1-3 survivor sequence - explicitly out of scope here.
- **Messages without a `timestamp`:** the design assumes every assistant message carries a `timestamp` (true across current fixtures and pi's message shapes). Stated as an explicit assumption; a message lacking one would be treated as older-than-any-boundary by a `< boundary` comparison against `undefined` - acceptable, but noted.
- **Multi-thinking-block message:** `withoutThinkingBlocks` (chain-range-prune.ts:16) filters *all* `type === "thinking"` parts (including signatures), not just the last - locked by an explicit test.
- **`enabled: false`:** phase 4 no-ops as today; the flush skips boundary computation (field left at `prev`).
- **Long tool-only chain, budgets off (default):** no `message_end`, no budget flush -> no flush -> boundary frozen -> thinking accumulates until the chain finally flushes. This is the existing agent-message flush contract (summarization is gated identically); the budget flush is the named backstop when a user opts in. Documented, not fixed here.
- **Session reload:** frontier reconstructs the field from `context-prune-frontier`; stateless recompute resumes at the next flush.
## Testing
`bun test src/` (258 green at baseline) must stay green.
- **Unchanged:** all 12 existing `thinking-strip.test.ts` tests (2-arg calls hit the fallback path). Note the existing suite *already* covers all-or-nothing multi-thinking-block stripping (thinking-strip.test.ts:132) and the `keepLastTurns=0` clamp (thinking-strip.test.ts:99) - do not duplicate them.
- **New - prefix stability across a growing array:** starting from a fixed boundary, render N times where each render **appends** a tail toolCall/toolResult (a real tool loop), and assert the serialized *historical prefix* (everything before the appended tail) is byte-identical across renders. This exercises the actual bug; N reruns over one static array would only re-test idempotence.
- **New - advance only on flush + monotonic:** boundary moves only when recomputed at flush; never regresses, including under a mid-session `keepLastTurns` increase.
- **New - frontier reload round-trip:** persist a frontier carrying `thinkingStripBoundaryTimestamp`, run it through `fromJSON`, assert the field survives (guards the blocker).
- **New - post-chain-drop window:** after a flush whose phase-3 dropped a closed chain inside the K-window, surviving turns older than the boundary have no thinking and the boundary is unchanged from its raw-branch computation.
- **New - `computeThinkingBoundary` unit tests:** `count <= keep` returns `prev`; `count > keep` returns the correct timestamp; `keepLastTurns=0` is clamped to 1 (no out-of-bounds); monotonic clamp holds on `keepLastTurns` increase; `closingMessage` is counted.
- **New - flush integration:** an agent-message flush (`message_end` with captured batches) advances the boundary and strips thinking older than it.
## Acceptance criteria
- [ ] Phase 4 does not mutate any previously-rendered historical message between flushes (shared prefix byte-stable across a growing tool loop).
- [ ] Strip boundary advances on every non-empty flush (covers `message_end` and budget flushes; both route through `flushPending`), not on assistant-turn count per render.
- [ ] Boundary keyed by assistant-message timestamp, computed over the raw branch; robust to phase-3 middle drops (surviving-turn count may dip below K after a chain drop - accepted).
- [ ] Persisted `thinkingStripBoundaryTimestamp` survives session reload (`fromJSON` copies it through).
- [ ] Logical invariant: thinking is never re-added to a message already stripped (monotonic clamp; holds under `keepLastTurns` increase).
- [ ] `keepLastTurns=0` clamped to 1 in `computeThinkingBoundary` (no out-of-bounds boundary).
- [ ] No new config key; `keepLastTurns` presets 4/8/16/32/64 retain their meaning.
- [ ] `PRUNING.md` "Cache impact" corrected.
- [ ] Existing 12 `thinking-strip.test.ts` tests pass unchanged; new tests above added and green.
## Documentation impact
- Feature / user-facing docs introduced: none
- Materially amended existing docs: `PRUNING.md` "Cache impact" (in "Main-loop Thinking Strip", PRUNING.md:1024-1028). Replace the current paragraph ("Each new assistant turn slides the keep-window by one ... Smaller K is cheaper on both savings and churn (worse only for reasoning continuity).") with, verbatim:
> pi-ai serializes prompt-cache breakpoints only at `tools`, `system`, and the last conversation message (verified in `@earendil-works/pi-ai` `api/anthropic-messages.js` `convertMessages`) - there is **no in-history breakpoint**. So the strip boundary is flush-gated, not per-render: it is a persisted assistant-message timestamp on the `context-prune-frontier` entry that stays fixed between flushes and advances only on a non-empty flush. Between flushes every render is byte-stable in its historical prefix, so the cache holds through a whole tool loop; the boundary moves at most once per flush (~once per request), turning the old k-busts-per-render into ~1-per-request. That residual bust is not always free: for a request shorter than `keepLastTurns` turns the boundary (tail-K) sits deeper than the current request's just-summarized tool results, so thinking-strip is the dominant invalidator, ~1 deep reprocess per request; for a request longer than `keepLastTurns` turns summarization's stub-replace reaches deeper and subsumes it (~0 marginal). Retained thinking is bounded to `keepLastTurns` raw-session turns, drifting up to `keepLastTurns + turns-since-flush` between flushes (deliberate: the frozen boundary is what buys cache stability). Note `error-purge` still mutates old history off a live per-render count, an independent cache-bust source not addressed here.
- Derived / memory docs invalidated: none - no new customType; the new field rides the existing `context-prune-frontier` entry, whose row in the `AGENTS.md` customType table describes purpose, not schema, so no amendment is required
## Open questions
- **Attribution is inferential.** Phase 4 is the only *per-render* history mutator this fix removes, but flush stub-replace, model switches, and `error-purge`'s live-count cooldown also mutate history; a residual cache bust after this change may come from those (notably `error-purge`, error-purge.ts:20, which is out of scope here). Not blocking - the fix is correct on its own terms; attribution matters only for interpreting post-ship cache telemetry. A follow-up could give `error-purge` the same flush-gated treatment.
@@ -0,0 +1,145 @@
# Remove the thinking strip
Supersedes: [doc/specs/2026-08-04-thinking-strip-flush-gated.md](./2026-08-04-thinking-strip-flush-gated.md) - fully.
## Context
`thinkingStrip` removes `thinking` blocks from older assistant turns at render time, keeping them on the last `keepLastTurns` (default 16). It has shipped since v0.13.0; v2.4.3 made its boundary flush-gated to stop a per-render cache break.
The feature was designed against an assumed API model: that thinking blocks we send are thinking blocks the model reads and we are billed for, so removing old ones saves tokens. That model is wrong. This spec records the measurements that disprove it and removes the feature.
## Problem
Anthropic's actual handling of `thinking` blocks, verified against the live API (`count_tokens` and real billed usage agree to the token):
| regime | billed input tokens | probe |
|---|---|---|
| thinking in a **closed** cycle (a user text turn has since occurred) | 0 | `withThinking=566 stripped=566 delta=0` |
| thinking in an **open** cycle, contiguous from the cycle's first assistant turn | billed in full | `first 4` bills 4 blocks, `ALL 0-7` bills 8 |
| thinking in an open cycle **after any gap** | 0 | `last 4 (4-7)` bills 0, `middle (3-4)` bills 0 |
Three consequences, each independently fatal to the feature:
**1. It strips what is already free.** Closed-cycle thinking costs zero input tokens and is dropped from the context window before the model attends to it. Stripping it saves nothing and changes nothing the model sees.
**2. It delivers no thinking to the model, and pays for the privilege.** In an open cycle, retention is a *prefix* property: only the unbroken run starting at the cycle's first assistant turn survives. `keepLastTurns` keeps the *last* K, so the moment it fires it punches a gap at the front and Anthropic discards every block after it - including the K it just preserved. The model receives zero thinking either way, and the transition from no-gap to gap is a full cache invalidation.
Measured in a controlled A/B (24 sequential file reads, isolated `PI_CODING_AGENT_DIR`, identical prompts):
```
BASE (bare pi, no extension) cacheWrite= 89449 cost=$0.6495
NOSTRIP (pi-condense, thinkingStrip disabled) cacheWrite= 87781 cost=$0.6430
COND (pi-condense, defaults) cacheWrite=148222 cost=$0.8531 +33%
```
`COND`'s only non-warmup break is turn 17 - the first turn past `keepLastTurns=16` - which rewrote 63113 tokens at 0% cache reuse. `NOSTRIP` has no extra breaks over `BASE`.
Direct API confirmation that gap *creation* is the invalidating event, and that moving an existing gap is free:
```
req2 a1=think (no gap) write=5004 read=0 prime
req3 a1=strip a2=think (GAP) write=3485 read=0 full miss
req3 a1+a2=think (no gap) write=1633 read=5004 hit
```
**3. The economics can never work.** Retained thinking bills at cache-read rates ($0.30/Mtok); removing it costs a cache write ($3.75/Mtok). The 12.5x asymmetry means a strip only pays back if the same cycle continues for roughly another 130 turns. Across 58 gridstrong sessions (1233 open cycles):
| | |
|---|---|
| median cycle length | 3 assistant turns |
| cycles > 16 turns (strip fires) | 253 (20.5%) |
| cycles > ~130 turns (strip breaks even) | **2 (0.2%)** |
| estimated strip cost across the archive | ~$95 of $2564 total spend |
The feature is net-negative in roughly 250 of its 253 firings.
**4. It is redundant with chain compression.** `applyChainCompressions` in `src/chain-range-prune.ts` emits the synthetic chain block as `role: "user"` with a `type: "text"` block (line 34 at time of writing). Anthropic reads that as a genuine user turn, which closes the cycle and discards all prior thinking - at zero cache cost, because the range drop was already rewriting that region. Stub replacement does not do this: `src/pruner.ts:109` preserves `role: "toolResult"`, so the cycle stays open.
```
full toolResult, a1+a2 think billedThinking=3152
STUBBED toolResult (role kept), a1+a2 think billedThinking=3152 no effect
chain-drop user TEXT inserted before a2 billedThinking=0 cycle closed
```
Chain compression is enabled by default and fires several times per session, against the strip's ~4.3. It already performs the strip's intended job, for free, at a semantic boundary rather than an arbitrary turn count.
## Decision
Remove `thinkingStrip` entirely. No replacement mechanism, no replacement config key.
The resulting behavior is the union of two things that are already correct and already free: Anthropic drops closed-cycle thinking server-side, and chain compression closes cycles as a structural side effect of range dropping. What remains resident is open-cycle thinking since the last chain drop, which is the reasoning the model is actively using and which bills at cache-read rates.
### Alternatives considered and rejected
**Lower or raise `keepLastTurns`.** The knob has two reachable states, not a range. Below the cycle length there is a gap and the model sees no thinking; above it there is no gap and the model sees all of it. Every value from 1 to 15 produces identical behavior in a 20-turn cycle. Raising to 32 moves the cliff later and makes more cycles pay full freight before reaching it.
**Keep the first N turns instead of the last N.** Matches the API's prefix semantics, but inverts relevance: the model would retain reasoning about work already completed and lose the reasoning that produced the tool call whose result just arrived. Dominated by keep-all (which includes those same blocks plus the relevant ones) and by keep-none (which is free).
**Keep only the most recent turn (keep-last-1).** Forces a no-gap-to-gap transition at the third request of *every* cycle, raising the invalidation count from ~4.3/session to ~21/session. Roughly 4x worse than the bug it would replace.
**Gate the boundary to the stub frontier** (strip thinking exactly when its tool results are stubbed - the original proposal this worktree was opened for). Genuinely free: the invalidation is already happening at that position. But worth $4.59 across all 58 sessions (15.3M resident tokens at cache-read rates), or $0.08/session against a $44/session average. It also cannot express the intent cleanly - punching the gap drops *all* open-cycle thinking, including reasoning about tool results that are still fully present in context. Rejected on the repo's own bar: the coupling from `thinking-strip` to `frontier` is not worth 0.2%.
### Accepted consequence
Thinking blocks that reason about pruned tool outputs are retained rather than stripped. This is bounded and acceptable:
- Before the last chain drop: thinking is dropped server-side and never reaches the model. No exposure.
- After it: the referenced outputs are mostly unpruned anyway (`recoveryGraceTurns` protects the last 3 turns), and pruned ones are replaced by a stub plus its summary, recoverable via `context_tree_query`.
- The extension already retains assistant *text* and *tool calls* that reference pruned outputs, unconditionally and always live in the window. Singling out thinking would be inconsistent.
Residual risk is summarizer fidelity - a summary that drops a detail the retained thinking cites. That is a summarizer concern, not a thinking-retention one, and is out of scope here.
With the strip gone, `chainCompression.enabled: false` becomes the only configuration that carries open-cycle thinking indefinitely. Measured worst case across 1233 cycles is 30750 tokens (126 turns); median 388, p99 8968; zero cycles above 50k. Documented, not guarded.
## Scope
Delete:
| target | note |
|---|---|
| `src/thinking-strip.ts` | whole module (`stripOldThinking`, `computeThinkingBoundary`) |
| `src/pruner.ts` phase 4 | and the `thinkingStrip` + `thinkingBoundaryTimestamp` params of `pruneMessages` |
| `src/types.ts` | `ThinkingStripConfig`, `ContextPruneConfig.thinkingStrip`, `DEFAULT_CONFIG.thinkingStrip`, `PruneFrontier.thinkingStripBoundaryTimestamp`, and the `KEEP_LAST_TURNS_PRESETS` export (line 171) - it has no other consumer |
| `index.ts` | the `computeThinkingBoundary` import (line 39); boundary computation in `flushPending` (lines 507-511); its assignment to `frontierSnapshot`; the argument at the `pruneMessages` call site. Also narrow the `branchMessages` unwrap conditional (line 495) from `thinkingStrip.enabled \|\| chainCompression.enabled` to `chainCompression.enabled` alone |
| `src/frontier.ts` | `thinkingStripBoundaryTimestamp` in `fromJSON` |
| `src/commands.ts` | the `KEEP_LAST_TURNS_PRESETS` import (line 19), the `thinkingStripEnabled` toggle and `thinkingStripKeepLastTurns` selector (its preset usage at lines 661-664), and both `onChange` handlers |
| tests | delete `src/thinking-strip.test.ts`; strip phase-4 cases from `src/pruner.test.ts` and boundary cases from `src/frontier.test.ts`; update the positional `pruneMessages` call sites in `src/range-compression.integration.test.ts` and `src/oversized-spill.integration.test.ts` for the shortened signature |
Backward compatibility, both directions of an in-flight session:
- A `contextPrune.thinkingStrip` block left in a user's `settings.json` is inert but **persists**. `normalize()` in `src/config.ts` builds `{ ...DEFAULT_CONFIG, ...existing }` and then re-spreads it, so unrecognized keys are neither filtered nor rejected: the block survives `loadConfig()` untouched and is re-serialized verbatim by the next `saveConfig()`. It never throws or warns. No code change and no deprecation notice - the key simply stops being read, and stays in the file until a user removes it by hand.
- A persisted `context-prune-frontier` entry carrying `thinkingStripBoundaryTimestamp` is ignored on `session_start`. The field is optional and read-only at that site.
## Testing
- Delete `src/thinking-strip.test.ts`. Remove the phase-4 assertions from `src/pruner.test.ts` and the boundary-specific cases from `src/frontier.test.ts`. Update the positional `pruneMessages` calls in `src/range-compression.integration.test.ts` and `src/oversized-spill.integration.test.ts` to the shortened signature.
- **New** (additive) test: `pruneMessages` leaves `thinking` blocks untouched on assistant messages. Pin `stripFinalAssistantThinking: false` in this fixture - with the default `true`, `applyChainCompressions` legitimately strips thinking from a compressed chain's kept final assistant, so an unqualified "untouched everywhere" assertion would contradict that mechanism.
- Leave the existing `"strips thinking blocks from final assistant when stripFinalAssistantThinking is true"` case (`src/pruner.test.ts:182`) in place - that mechanism is out of scope and must keep working.
- Assert a session resumed from a frontier entry containing `thinkingStripBoundaryTimestamp` loads without error and strips nothing.
- Assert a `settings.json` containing `contextPrune.thinkingStrip` loads without error and round-trips the key unchanged.
- Full suite green: baseline on this branch is `274 pass, 0 fail, 23 files` (`bun test src/`).
- Typecheck per AGENTS.md before committing.
- Smoke test against an isolated `$PI_CODING_AGENT_DIR`: `DEFAULT_CONFIG.enabled` is `false`, so the settings file must set `contextPrune.enabled: true` and the prompt must force real tool calls and a flush. Assert **at least one** `context-prune-frontier` entry was written before asserting that none of them carries `thinkingStripBoundaryTimestamp` - otherwise the check passes vacuously on a session that never flushed.
## Documentation impact
- Feature / user-facing docs introduced: none
- Materially amended existing docs: `PRUNING.md` (delete the "Main-loop thinking strip" section and its cache-impact discussion; add a short note that chain drops close the assistant cycle server-side and that closed-cycle thinking is free, so no strip is needed); `doc/configuration.md` (delete the `thinkingStrip` block from the settings JSON example at line 39 and both `thinkingStrip.*` rows from the settings table at lines 76-77 - this is the file README designates as the full settings reference); `CHANGELOG.md` (breaking: config key removed). `README.md` needs no edit - it contains no `thinking` references.
- Derived / memory docs invalidated: `AGENTS.md` - the Project Layout block lists `src/thinking-strip.ts`, and the `src/pruner.ts` line describes the phase composition as `stub-replace -> error-purge -> chain-range-prune -> thinking-strip`; both need updating in the same commit
## Out of scope
- **`chainCompression.stripFinalAssistantThinking`** - a separate mechanism, not measured here, plausibly carrying the same defect. Belongs to chain compression; decide separately rather than removing it as a ride-along.
- **Chain compression's own cache behavior.** A 6-prompt A/B showed chain compression costing +4.5% ($0.4784 -> $0.5000) with 2 full invalidations from 3 chain drops, but the fixture peaked at 20k tokens where the archive median is 131k - the regime where compression is supposed to pay. Not a verdict; needs a large-fixture A/B before any conclusion.
- **The unattributed remainder of archive rewrite spend.** The strip explains ~$95 of ~$1997. A prompt-delta proxy suggested far more breaks than the controlled runs reproduce; the proxy is unreliable and the remainder is unexplained.
- **Summarizer fidelity** for details cited by retained thinking.
## Measurement caveats
- The 58-session gridstrong archive predates v2.4.3 for 55 of 58 sessions, so it measures the per-render boundary, not the flush-gated one. The cycle-length and thinking-volume statistics are unaffected by this (they are properties of the transcript, not of the strip); the ~$95 cost figure is specific to the pre-2.4.3 path and is an upper bound for the current one.
- Cost figures use Sonnet 4.5 pricing: $3/Mtok input, $15/Mtok output, $3.75/Mtok cache write (5m), $0.30/Mtok cache read.
- The controlled A/B peaks at 89k tokens; archive median peak is 224k. Conclusions about the strip hold regardless of scale (the mechanism is positional, not size-dependent), but absolute costs do not extrapolate linearly.
## Open questions
- `chainCompression.stripFinalAssistantThinking` defaults to `true` and is untested against the prefix-retention rule. Track separately.
@@ -0,0 +1,144 @@
# Single-chain observability + reload trigger repair (issue #6)
Implements [jjuraszek/pi-condense#6](https://github.com/jjuraszek/pi-condense/issues/6): long single-chain sessions expose two compounding gaps - no observability into what the pruner *cannot* see (open-cycle thinking, chain dominance, frontier gap), and reloads discarding the in-memory `pendingBatches` queue, which strands the automatic flush trigger even though the branch-rescan data path could recover the work.
Issue AC 2, verbatim: "`/pruner status` + footer show open-cycle thinking, largest-chain share, frontier gap; a session entry records them per flush." All three metrics therefore appear on both surfaces.
## Incident summary (evidence basis)
Real 1M-window session, 256 assistant turns, ~5h, single chain (zero mid-run text-only assistant replies, so the `agent-message` flush boundary never fired). `autoBudgetThreshold` 0.8 never crossed (peak 53.7%). Result: ~195k tokens of raw toolResults never summarized; `/pruner status` showed `calls: 1, input: 3 tokens`.
**AC 1 disposition (user decision):** the incident JSONL is gone. AC 1 is downgraded to source-level verification, which is complete:
- The flush data path is reload-safe: `capturePendingBatches` (`index.ts:138-152`) rescans the persisted branch via `captureUnindexedBatchesFromSession` (`src/batch-capture.ts:69-145`), occurrence-key-filters against the indexer, and trims at the frontier. Captured-but-unflushed work is always recoverable from the session.
- The flush *trigger* is not: `pendingBatches` is zeroed on `session_start` (`index.ts:679`) and `session_tree` (`index.ts:709`). The `turn_end` handler returns early when the turn has no toolResults or when trim leaves no batch (`index.ts:719-724`, `764`), so the budget gate at `index.ts:794` is only ever reached with a freshly pushed batch - after a reload, no path evaluates the budget trigger against the recoverable work until a *new* unprotected toolResult turn arrives. The `agent_end` pending-notify (`index.ts:823-825`) is likewise silent when the in-memory queue is empty.
- The "nothing pending" `/pruner now` report from the incident is **an accepted unknown** - trigger loss alone does not explain it, and without the JSONL it cannot be investigated further. Recorded here so a future recurrence has a named prior (the per-attempt flush-metrics entries in Component 3 exist precisely to make a recurrence diagnosable).
## Goals
1. **Metrics (AC 2):** three new observability metrics, surfaced in `/pruner status`, the footer widget, and a per-flush-attempt session entry.
2. **Trigger repair (AC 3):** after a reload with recoverable work on the branch, the budget/delta trigger fires on the next `turn_end` - including turns that contribute no new batch - and the recoverable work is visible at `agent_end` and in `/pruner status`. Reload followed by total idleness (no further turns at all) remains visibility-only by design (no flush at boot).
3. **Docs (AC 4):** PRUNING.md documents the single-chain limitation of Phase 3 as a known design property, with config guidance.
4. **No `cost:external` change (AC 5):** satisfied by non-action; the payload and emission points are untouched.
## Out of scope (roast-killed in the issue; restated as binding)
- No new pruning valve of any kind (forced-close/reopen, stub-replace-inside-recent-chain, cycle-closing via toolResult mutation, thinking stripping). NO-GO pending a corpus replay this change does not authorize.
- No executor-behavior fixes (upstream cause shipped in pi-gauntlet v4.7.0, jjuraszek/pi-gauntlet#6).
- No queue reconstruction on reload - the repair is a trigger flag over the existing branch-rescan data path.
- No immediate flush on `session_start`/`session_tree` (no LLM call at boot; print-mode sessions may die under it; compacts work the user did not ask to compact yet).
## Design
### Component 1: metrics module (`src/context-metrics.ts`, pure)
One pure function computing a snapshot from a branch (`AgentMessage[]`, the raw unwrapped `type === "message"` projection of `getBranch()` - measuring what is persisted, not the post-prune in-flight context), the persisted frontier, a summarized-predicate, and a protection predicate:
```ts
interface ContextMetricsSnapshot {
openCycleThinkingTokens: number; // est. tokens of thinking blocks retained in the trailing open segment
largestChainSharePct: number; // max(largest closed chain, open segment) chars / total branch chars, 0-100
frontierGapTokens: number; // est. tokens of summarization-eligible unsummarized toolResults after the frontier
}
computeContextMetrics(branch, frontier: PruneFrontier | null, isSummarized, isProtected): ContextMetricsSnapshot
```
**Measurement convention (one convention for everything in this module):** per-message chars = `JSON.stringify(message).length`; tokens = `Math.round(chars / 4)`. This matches the reclaim footer's basis (`src/commands.ts:76-77`); the `Math.ceil` used by the compact preview (`src/commands.ts:1032`) is not adopted. Numerator and denominator of the share metric use this same basis.
**Open segment:** the message range strictly after the last text-only assistant message (assistant with no `toolCall` content blocks, same predicate as `isFinalAssistantMessage`, `index.ts:100-106`) through the branch tail. A branch with zero text-only assistants has the whole branch as its open segment (the incident case). All messages in the range count toward its chars, including user/steer messages - the range is measured as retained context, not filtered content.
**Open-cycle thinking tokens:** sum over assistant messages in the open segment of the chars of their content blocks with `type === "thinking"`, divided per the convention. Deliberately *not* windowed by the frontier: a skipped/oversized/trivial flush attempt advances the frontier but removes nothing from context, so a frontier window would reset to ~0 right after a skip while the stranded thinking is still fully retained - reproducing the incident's misleading-small-numbers symptom. This metric answers "how much thinking is retained in the chain Phase 3 cannot yet compress".
**Largest-chain share:** numerator = `max(largest closed chain, open segment)` - max, not sum. Closed chains come from `detectChains` over the branch; interrupted chains (`finalAssistantTimestamp: null`) count as closed for measurement purposes (they are retained context regardless of compressibility). A chain's chars = sum of per-message chars over the messages in its range. Denominator = sum of per-message chars over the entire branch projection (including custom summary messages present as branch messages). Share = `Math.round(100 * numerator / denominator)`; 0 when the denominator is 0. `src/chain-detector.ts` is not modified - the open-segment scan is a metrics-module concern (its closed-chain contract is load-bearing for Phase 3).
**Frontier boundary locator (used by frontier gap only):** replicate `captureUnindexedBatchesFromSession`'s turn-counter convention (`src/batch-capture.ts:81-86`): walk the branch incrementing a counter on *every* assistant message. The boundary turn is the assistant message whose counter equals `frontier.lastAttemptedTurnIndex` and which contains a `toolCall` block with id `frontier.lastAttemptedToolCallId`. Within the boundary turn, toolResults paired to calls up to and including that id are *at or before* the boundary (excluded); results paired to later calls in that turn and all messages after that turn are *after* the boundary. If `frontier` is null, or the turn is not found, or the id is not in that turn's calls (bare-id miss after tree navigation), the boundary is the branch start - whole-branch window. This mirrors `trimBatchToPendingRange`'s miss semantics (`index.ts:108-129`) without pretending to share its code, which operates on captured batches, not branches.
**Frontier gap:** sum of per-convention tokens over `ToolResultMessage`s after the boundary that are summarization-eligible: occurrence key not summarized (`isSummarized`) *and* not protected (`isProtected`, same predicate the `turn_end` capture filter uses, `index.ts:735-738`). Protected outputs are excluded because they are never capturable - counting them would keep the gap permanently non-zero with no pending work, contradicting the metric's meaning ("what could summarization reclaim").
### Component 2: reload rearm flag (`index.ts`)
New module-level `rearmedPending: boolean` (transient, never persisted):
- On `session_start` and `session_tree`, **after** reconstruction and the `pendingBatches` clear, run the existing capture rescan (`capturePendingBatches` - a pure branch scan + grouping, no LLM work). If it yields at least one non-empty batch after frontier trim, set `rearmedPending = true`. The rescan result is discarded - only the boolean survives (no queue reconstruction). In the same pass, compute the metrics snapshot for the new branch and refresh the footer widget (see Component 4) - this is the reload-with-no-further-turn visibility path.
- **`turn_end` restructure (the load-bearing part):** the budget/delta gate must be reachable without a freshly pushed batch. When `rearmedPending` is true, `turn_end` skips the `!hasToolResults` and `!batch` early returns for the purpose of gate evaluation: it still performs capture/spill/trim/push only when the turn has toolResults, but the budget/delta computation and the flush gate run regardless. The gate becomes `(pendingBatches.length > 0 || rearmedPending) && !isFlushing && (budgetHit || deltaHit)`. When `rearmedPending` is false, current behavior is unchanged (early returns intact; the pushed-batch guarantee makes the `pendingBatches.length > 0` operand redundant there, which is exactly why the flag alone - without this restructure - would be dead code).
- `message_end` (agent-message mode) already calls `flushPending` unconditionally, so "reload, then the chain closes" needs no change - the flush's own rescan recovers the work.
- `agent_end`: the guard becomes `pendingBatches.length === 0 && !rearmedPending`; widget text when the queue is empty but rearmed: `prune: recovered pending (reload)` (never the misleading `prune: 0 pending`); otherwise the existing `prune: N pending`.
- `rearmedPending` clears at the start of every `flushPending` invocation (any outcome - the flush's own capture rescan is then the source of truth) and is re-evaluated on the next reload.
- **Rescan failure:** if the reload rearm probe throws, `console.error` (the codebase's existing sink for non-prune-time failures, e.g. `src/spill.ts:71`) and leave `rearmedPending = false` - reload must never fail because of the probe. `DiagnosticSink` is not used: it is scoped to prune-time degradations.
- **`enabled: false`:** probe skipped entirely.
Rationale for flag-not-flush: the armed state changes nothing until an existing trigger (budget, delta, `message_end`, `/pruner now`) fires; immediate flush at boot was rejected (see out of scope). Honest residual gap: reload followed by *no further turns at all* gets visibility (footer, `agent_end`, `/pruner status`) but no automatic flush - by design.
### Component 3: per-attempt flush-metrics entry (`context-prune-flush-metrics`)
New custom session entry type, constant in `src/types.ts`. Written **once per non-concurrent `flushPending` invocation, regardless of outcome** - including empty-capture and error paths, because the incident's undiagnosable "`/pruner now` said nothing pending" is exactly an empty-capture attempt this log must record. Payload:
```ts
{
ts: number;
trigger: "budget" | "delta" | "message-end" | "manual" | "rearmed";
capturedBatches: number; // batches after rescan+trim, before processing
processedBatches: number;
outcome: "summarized" | "skipped-oversized" | "skipped-deduped" | "skipped-trivial" | "empty" | "error";
metrics: ContextMetricsSnapshot; // computed at flush ENTRY (pre-flush), so the pressure that triggered the flush is recorded
}
```
`outcome` reuses the `PruneFrontier.outcome` union verbatim for processed attempts and adds `empty` (nothing captured) and `error`. `trigger` is threaded from the call site.
- **Emission point:** the very end of `flushPending`, after the chain-compression block and outside its try/catch (compression failure is non-fatal and must not eat the entry). Routing mirrors frontier persistence (`index.ts:517-531`): `delivery === "runtime"` -> `pi.appendEntry`, else `sessionManager.appendCustomEntry`. Write failure is non-fatal (same posture as stats persistence).
- **Never in LLM context** (same hidden treatment as `context-prune-stats`).
- **Not reconstructed on `session_start`** - append-only observability log, not state; nothing reads it back at runtime.
- Deliberately **not** an extension of `context-prune-stats`: point-in-time metrics vs cumulative last-snapshot-wins counters would force reconstruction to distinguish field classes. `SummarizerStats`, `StatsAccumulator`, and the `cost:external` emission are untouched.
- AGENTS.md custom-entry table gains one row.
### Component 4: surfacing (`src/commands.ts` + cache in `index.ts`)
- **Snapshot cache:** owned by the `index.ts` closure. Recomputed at: reload rearm probe (Component 2), `turn_end` with toolResults, and `flushPending` entry. `commands.ts` call sites never compute - they read the cache via a getter callback wired at registration (`index.ts:891`, same pattern as `getStats`). Call sites that refresh the widget for unrelated reasons (`commands.ts:819/854/863/982/1079`) reuse the cached snapshot as-is.
- **`/pruner status`:** new `context` block - open-cycle thinking, largest-chain share, frontier gap (from an on-demand fresh computation via the callback, not the cache - status is rare and should be exact), plus a `rearmed: yes` line rendered only while the flag is set (omitted otherwise).
- **Footer widget:** `pruneStatusText` gains a compact suffix rendered **only when `frontierGapTokens > 0`**: `· think <N>k · gap <N>k · chain <P>%` (all three metrics per AC 2; `formatCompactCount` for the numbers). Idle footer (gap 0) is unchanged - no new noise. The suffix source is the cached snapshot, threaded as a new optional parameter alongside the existing `reclaim`/`diagnostics` parameters.
### Data flow
```
session_start/session_tree -> rebuild index/stats/frontier -> clear pendingBatches
-> rearm probe: capture rescan -> rearmedPending = batches found
-> compute metrics snapshot -> refresh footer (visibility without further turns)
turn_end -> [capture/spill/trim/push when toolResults] -> recompute snapshot
-> gate (reachable even with no new batch when rearmed):
(pending>0 || rearmed) && !isFlushing && (budgetHit || deltaHit) -> flushPending
flushPending -> clear rearmedPending -> pre-flush snapshot -> capture rescan (existing)
-> summarize -> persist index/frontier/stats -> chain compression
-> write context-prune-flush-metrics (always, outside compression try/catch)
/pruner status -> on-demand metrics computation -> context block
```
### Error handling / edge cases
- **Rearm probe throws:** `console.error`, `rearmedPending = false`, reload proceeds (Component 2).
- **Rearmed but flush-time rescan finds nothing** (another client summarized; trim excluded everything): flush takes its existing empty path; the flush-metrics entry records `outcome: "empty"`; flag already cleared.
- **Empty branch / no assistant messages:** all metrics 0; share 0 when the denominator is 0.
- **Frontier boundary unresolvable in branch** (tree navigation, legacy frontier): whole-branch window (Component 1 locator).
- **Flush-metrics entry write failure:** non-fatal; flush outcome unaffected.
- **`enabled: false`:** rearm probe skipped; no flush-metrics entries; `/pruner status` context block still renders (read-only scan, useful while deciding whether to enable).
### Testing
Bun test style:
- `src/context-metrics.test.ts` (new, pure): open-segment detection (incl. zero-text-only-assistant branch = whole branch); thinking sum over open segment only; share max-not-sum, interrupted chains as closed, denominator with custom summary messages, zero denominator; frontier-gap boundary location (intra-turn split, bare-id miss -> whole branch, null frontier), protected + summarized exclusion; measurement convention (JSON.stringify, Math.round).
- **AC 3 regression test** (`src/reload-rearm.integration.test.ts`, new): the repo has no existing harness that drives `index.ts` event handlers (all current suites test pure modules), so this test builds one: invoke the extension's default export with a mock `pi` whose `on` captures handlers into a map; fabricate a `ctx` with a fake `sessionManager` (`getBranch` returning a branch containing a captured-but-unflushed toolResult batch, `appendCustomEntry`, `getSessionDir`/`getSessionId`) and `getContextUsage` returning budget-crossing usage; stub the summarizer via the `mock.module("@earendil-works/pi-ai/compat")` pattern from `src/summarizer-wiring.test.ts`. Scenario: fire `session_start` (asserts rearm), then a `turn_end` with **no toolResults** and budget-crossing usage -> assert `flushPending` ran (summarizer stub invoked), the batch was summarized/indexed, and `pruneMessages` stubs its result. This scenario **fails on current `main`** (the `!hasToolResults` early return prevents the gate from ever being evaluated), which is what makes it a regression test.
- `src/commands.test.ts` additions: footer suffix present iff gap > 0 and contains all three metrics; status context block formatting; `rearmed:` line only when armed; `agent_end` string `prune: recovered pending (reload)` for the rearmed-empty case.
- Flush-metrics entry: exactly one entry per flush attempt, including empty-capture attempts and when chain compression runs or fails (guards the double-persist shape of stats at `index.ts:500-524` + `561-563` and the try/catch placement).
Verification: `bun test src/` plus the AGENTS.md typecheck command. Smoke test per AGENTS.md (`pi -e ./index.ts --no-extensions -p ...`, then jq over the session JSONL asserting a `context-prune-flush-metrics` entry appears).
## Documentation impact
- Feature / user-facing docs introduced: none (no new standalone doc; all changes amend existing docs)
- Materially amended existing docs: `doc/configuration.md` (footer metrics-suffix state, `/pruner status` context block, `prune: recovered pending (reload)` state - it is the repo's designated home for footer states and full command detail; ratified at the finish gate, G6), `PRUNING.md` (new "Single-chain sessions" subsection: Phase 3's closed-chain requirement as a known design property; guidance to lower `autoBudgetThreshold` / rely on `budgetTurnDelta` for long autonomous runs; the three metrics and their definitions; the rearm behavior), `README.md` (`/pruner status` context block, footer suffix)
- Derived / memory docs invalidated: `AGENTS.md` (custom-entry table: add `context-prune-flush-metrics` row; project-layout line for `src/context-metrics.ts`)
## Open questions
None blocking. Accepted unknown: the incident's "nothing pending" `/pruner now` output remains unexplained (JSONL gone); the per-attempt flush-metrics entries exist precisely so a recurrence is diagnosable.
@@ -0,0 +1,236 @@
# Tool-call id collisions: positional chain drops, occurrence identity, orphan sweep
> **Superseded by:** [doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md](./2026-08-18-gh-11-orphan-sweep-barrier.md) - "C. Orphan sweep" section: barrier rule and the claim that C covers the `stopReason: error|aborted` orphan path
Issue: [jjuraszek/pi-condense#8](https://github.com/jjuraszek/pi-condense/issues/8) (issue #9 merged in as section B).
## Problem
Provider tool-call ids are unique only within one response. `pi-ai` passes them through verbatim (Google mints its own, `api/google-generative-ai.js:136`), and github-copilot/kimi-k3 restarts a `${tool}_${n}` counter, so `bash_23` recurs inside one session. pi-condense treats those ids as session-durable identity in two places, producing one fatal and one silent failure.
**Fatal.** `applyChainCompressions` (`src/chain-range-prune.ts:61-119`) unions every `ChainCompressionEntry.droppedToolCallIds` into one session-wide `Set<string>` and then drops *any* message in the array whose id is in that set - with no positional bound. `:104-110` drops an entire assistant message when **any** of its `toolCall` ids matches. `pi-ai`'s `insertSyntheticToolResults` repairs orphan tool *calls* only, never orphan tool *results*, so the provider rejects the request, and every retry rebuilds the identical broken context. The session is unrecoverable without hand-editing the JSONL. Risk grows with session length - worst on exactly the long sessions pi-condense targets.
Provider rejection wording varies while the defect is identical: Anthropic returns `400 unexpected tool_use_id found in tool_result blocks`; Kimi K3 returns `400 Kimi K3 tool messages need a resolvable tool name: carry `tool`/`name`, or match a preceding assistant tool_call by order` - i.e. it fails to resolve the name of a tool message that matches no preceding `tool_call` **by order**, which is an orphan result by another name. Any orphan-result check must therefore be provider-agnostic and structural, not keyed to one error string.
**Why this is a code defect, not an observed-once anomaly.** The drop test is `dropped.has(id)` evaluated over the entire message array, and `:104-110` removes an entire assistant message when any single one of its `toolCall` ids matches. Nothing bounds the match to the compressed range. Therefore *any* recurrence of a dropped id later in the session deletes a live turn, and whether that turn's siblings survive orphaned depends only on whether the live assistant also carried a non-dropped id. Reuse frequency sets the blast radius; the defect is unconditional.
**Observation (no longer reproducible).** A live 2.5.0 kimi-k3 session that failed with the Kimi wording was inspected mid-incident and showed id reuse at scale (one id recurring across hundreds of tool results) plus one orphan result in the pruned view. That session, and every other affected one, was subsequently **repaired in place** - the repair rewrote ids to be unique and removed the orphan - so the pre-repair bytes are gone and those figures cannot be re-derived. They are recorded here as provenance only and **no acceptance criterion depends on them**; the case for the fix rests on the code reading above plus the constructed fixture in Testing. Post-repair, the same file replays cleanly through the shipped `pruneMessages` (1731 -> 1660 messages, zero orphans), which is expected: with unique ids the current logic and a positional range coincide.
**Silent.** The same ids key `ToolCallIndexer.index` (`src/indexer.ts:77`), `contentHashToOriginal` dedup (`:81-82`), the summarized-set check in `pruneMessages` (`src/pruner.ts:74`), and batch-capture result matching (`src/batch-capture.ts:36` first-wins live, `:73-78` last-wins on rescan). A colliding id therefore stub-replaces a live result with a stale `tN`, skips capture of a live batch, resolves `context_tree_query` to the wrong batch, and can alias unrelated content in dedup. The session keeps running on fabricated recovery data with no error.
## Goals / non-goals
Goals: remove the fatal path; make record identity collision-safe; add a last-resort orphan invariant. All three land in one branch (issue #8: "land together").
Non-goals, decided:
- **No human intervention anywhere in the repair path.** The orphan sweep repairs unconditionally at render time - no prompt, no `/pruner` confirmation, no gate. The only alternatives at that point are "drop the orphan" or "send a request that 400s"; there is nothing a user could usefully decide, and the condition is not explainable in situ. The human sees a counter after the fact.
- No id rewriting at capture time. pi-condense does not own session ids, and `pi-ai` re-normalizes them per target model anyway.
- No migration and no re-summarization of existing sessions.
- No new config key.
- Filing the upstream `pi-ai` ask (repair orphan tool *results*, not only orphan tool *calls*) is out of scope here; this spec's sweep is defense in depth and is not contingent on it.
## Design
### A. Chain drops become positional (`src/chain-range-prune.ts`)
The range information already exists - `detectChains` (`src/chain-detector.ts:36-108`) emits `{ startUserTimestamp, middleToolCallIds, protectedToolCallIds, finalAssistantTimestamp }`, and `droppedToolCallIds` is only a denormalization of the middles. The drop decision moves back onto positions.
`resolveRange(entry, messages): { startIndex, endIndex } | null`:
- `startIndex` = index of the **user-role** message with `timestamp === entry.startUserTimestamp`; `endIndex` = index of the **assistant-role** message with `timestamp === entry.finalAssistantTimestamp`. Role-gating is required, not cosmetic: the incident session has two `toolResult`s sharing millisecond `1786533716958`, and a role-less scan would read cross-role timestamp equality as ambiguity and disable compression. This mirrors the existing role-gated insertion lookup at `chain-range-prune.ts:94-97`.
- **Exactly one match on each boundary, and `startIndex < endIndex`, or the result is `null`.** More than one match is treated as unresolvable, not disambiguated: strictest reading of "resolve or drop nothing", and no rule can over-drop. Cost of a duplicated boundary timestamp is that the chain's compression is disabled until it is re-detected (context regrows for that range), made visible by a diagnostic rather than silent.
- `entry.finalAssistantTimestamp === null` (interrupted chain) is unresolvable by definition. `selectEligible` (`src/chain-compressor.ts:28-29`) already filters those out of eligibility, but the persisted type permits `null` and old entries exist, so the explicit skip stays.
- **No id-array-wide fallback, ever.** Failing closed to a no-op is the point; the previously considered timestamp-*window* variant is rejected (timestamps interleave on `/import` and forked sessions, and the incident session itself has two `toolResult`s sharing millisecond `1786533716958`).
`null` → the entry contributes nothing: no drops, and **no synthetic message inserted** (a synthetic without its range would present a summary next to the live originals).
Resolved entries contribute the half-open index range `(startIndex, endIndex)` to a `Set<number>`. Composition across entries is union-of-indices. `chain-detector` emits non-overlapping chains, but entries persist independently, so an entry whose `startIndex` falls inside another entry's dropped range is skipped (its synthetic would be dropped anyway) with a diagnostic.
Per resolved entry: the synthetic `<compressed-chain>` message is inserted immediately after `startIndex`; `endIndex` is **kept** and thinking-stripped when `stripFinalThinking`. `endIndex` is always a text-only assistant (`chain-detector.ts:96` closes a chain on an assistant message with no `toolCall`s), so keeping it cannot orphan anything.
**Drops are role-restricted: only `assistant`, `toolResult`, and `CUSTOM_TYPE_SUMMARY` messages inside a range are removed.** Two consequences, both load-bearing:
- The synthetic chain message is itself **user-role** (`chain-range-prune.ts:61-64` recovers `existingSyntheticBlockIds` by regex over user-message text). Under a blanket index drop, a second application would delete the synthetic sitting at `startIndex + 1` - inside its own `(startIndex, endIndex)` - and then `existingSyntheticBlockIds` would suppress re-insertion, silently deleting the chain body. Role restriction preserves it in place, so re-application is a true no-op (AC 5). Today's id-based drop never touches user-role messages; the restriction keeps that property.
- Third-party extensions' `custom_message` entries inside a chain range are **not** dropped. A blanket positional drop would widen today's behavior (which only removes assistant-with-dropped-id, `toolResult`-with-dropped-id, and overlapping summaries) into deleting other extensions' context. Out of scope here.
Ids stop driving drops entirely. The three id/timestamp-keyed matches inside the function become range-scoped as a consequence:
| Today | After |
|---|---|
| `perBatchSummaryOverlapsDropped` matches `details.toolCallRefs` against the dropped id set (`:11-14`) | range-scoped **coverage** check: a `CUSTOM_TYPE_SUMMARY` message whose `toolCallRefs` overlap the ids inside `(startIndex, endIndex)` is suppressed **regardless of its own index** (see the `agent-message` note below) |
| `protectedIdToBlock` resolves protected ids array-wide (`:74`) | protected ids resolved **within the entry's range only** |
| `stripFinalAtTimestamp.has(msg.timestamp)` strips any assistant sharing a millisecond (`:110`) | strip at the resolved `endIndex` |
Pure index membership would be insufficient for the first row: under `batchingMode: "agent-message"`, `flushPending` runs at agent-message end, i.e. *after* `finalAssistantTimestamp`, so the per-batch summary is appended **outside** `(startIndex, endIndex)`. Today's id-overlap check drops it; an index-only rule would keep it, leaving both the old per-batch summary and the new chain synthetic in context. The coverage check preserves today's behavior for a shipped config mode.
Ids remain in use for summary text, `tN` refs, per-batch summary coverage, and the `droppedToolCallIds` cross-check (below).
### B. Occurrence identity for records (`src/indexer.ts`, `src/batch-capture.ts`, `src/types.ts`)
Records are keyed by an occurrence key, `` `${toolCallId}@${resultTimestamp}` ``, instead of the bare provider id.
The discriminant is the **`toolResult` message timestamp** (`ToolResultMessage.timestamp`, required per `@earendil-works/pi-ai/dist/types.d.ts:318`). It is the one piece of identity material readable identically on both sides: `pruneMessages` has the `toolResult` message in hand at `src/pruner.ts:74`, and capture has the matched result message.
The existing `ToolCallRecord.timestamp` is **not** usable: the live path stamps it `Date.now()` (`index.ts:721`) while the branch-rescan path uses the session entry / message timestamp (`src/batch-capture.ts:132`), so it cannot be re-derived from context. Hence a new field rather than reuse.
Residual collision: the same provider id on two results persisted in the same millisecond. Accepted - it requires id reuse *and* a sub-millisecond coincidence, and an occurrence-ordinal alternative (k-th occurrence in branch order, recomputed per render) was rejected as a materially larger diff for that margin.
Surfaces changed:
- `src/types.ts`: `CapturedToolCall.resultTimestamp?: number`, `ToolCallRecord.resultTimestamp?: number` (persisted in `context-prune-index`, optional so pre-upgrade entries stay readable). **Also `SummaryToolCallRef.resultTimestamp?: number` (`:613-616`) and `DedupAliasEntryData` (`:603-607`) gaining `newResultTimestamp?`/`originalResultTimestamp?`** - without these the occurrence-keyed maps cannot survive a restart: `tN` aliases live only in the summary message's `details.toolCallRefs`, so `registerSummaryRefs` would rebuild `t5 -> bash_23` while the index holds `bash_23@ts`, and `resolveToolCallId("t5")` would miss. `buildShortToolCallRefs` / `makeSummaryDetails` propagate the field; `registerSummaryRefs` and the dedup-alias rebuild key by occurrence key.
- `src/batch-capture.ts`: both capture paths record the matched result's `timestamp`; the `isSummarized` check at `:119` passes the occurrence key. **The rescan path's pairing changes**: `captureUnindexedBatchesFromSession` currently builds one branch-wide `Map<toolCallId, ToolResultMessage>` (`:73-78`, last-wins), so with two `bash_23` calls the *earlier* assistant's call is paired with the *later* turn's result - a fabricated record that occurrence keys make distinct but no less wrong. Pairing becomes positional: for each assistant message, scan forward to the next assistant message and match its `toolCall` blocks against the `toolResult`s in that span. The live path's first-wins pairing (`:36`) already operates within a single turn and is unchanged.
- `src/indexer.ts`: an `occKey(id, resultTimestamp)` helper; `index`, `contentHashToOriginal`, `dedupAliasToOriginal`, `aliasToToolCallId`/`toolCallIdToAlias`, and `summaryBodies` refs are keyed by occurrence key. `tN` aliases stay 1:1 with occurrences and remain the primary recovery handle. `isSummarized` / `getRecord` accept an occurrence key, a `tN`, or a bare id.
- `src/pruner.ts`: computes `id@msg.timestamp` for each `toolResult` before the `isSummarized`/`getRecord` pair, and for `getShortRefForToolCallId` at `:88`. **The bare-id fallback is fail-closed**: it applies only when the message carries no `timestamp` (legacy shape). When `msg.timestamp` is present and `id@timestamp` is absent from the index, `isSummarized` returns **false**. A permissive fallback would stub a live `bash_23` result because an *older* `bash_23` was summarized - exactly the silent failure AC 7 forbids.
- `src/chain-compressor.ts`: `chain.middleToolCallIds` are bare ids, and `:99-111` feeds them to `hasPerBatchSummaryCoveringAny` / `getToolRefsForToolCallIds` / `getPerBatchSummariesForToolCallIds`, which now live in occurrence-key space - bare lookups would find nothing and every chain would be skipped with empty `toolRefs`. `ChainRange` gains an in-memory `middleOccurrenceKeys: string[]` collected during the same detection walk; the three lookups take it. `middleToolCallIds` stays bare (the recovery-grace filter at `:34` is untouched); the persisted `ChainCompressionEntry.droppedToolCallIds` shape gains a companion field for the same reason - see "`droppedToolCallIds` becomes diagnostic-only" below.
- `src/spill.ts`: dedup registration (`:56-58`) uses occurrence keys. `blobPathFor` (`:16-17`) names sidecars from the sanitized **occurrence key**, so two occurrences of one id cannot overwrite each other's blob; recovery falls back to the legacy bare-id path when the occurrence-keyed file is absent.
- `index.ts:106`: the pending-capture filter's `isSummarized(tc.toolCallId)` passes the occurrence key.
- `src/tree-browser.ts:122`: `getRecord(ref.toolCallId)` resolves via the `SummaryToolCallRef`'s occurrence key.
- `src/query-tool.ts`: a bare id that matches more than one occurrence returns **all** matches, chronological, one `## toolRef:` block each. No newest-wins pick, no error-out: `tN` is the primary path, raw ids are the documented fallback (`src/query-tool.ts` tool description), collisions are rare, and each block is already truncation-bounded. Bare-id multi-match is as likely as id reuse itself, which is the premise of the issue - so the honest answer is all of them.
- `src/commands.ts`: the lookup at `:1014` follows the same resolution.
### C. Orphan sweep (`src/pruner.ts`)
A final post-condition after all three existing phases: a single forward pass with **per-turn** open-call tracking. Walking the array, an assistant message *replaces* the open set with its own `toolCall` ids; each matching `toolResult` consumes its id; a `toolResult` whose id is not open at that point is an orphan and is removed. O(n).
The open set must not be session-cumulative. With one global seen-id set, an id used *validly* in an early turn stays "open" forever, so a later genuine orphan carrying that same id passes the check - which is precisely the collision case this spec exists for.
This is exactly the incident's shape (live assistant dropped, its sibling's result left behind), and it also covers orphans A cannot reach: `pi-ai`'s `transform-messages.js` skips assistants with `stopReason === "error" | "aborted"` while keeping their results.
A and C are not substitutes and the split of duties is exact: **A prevents the deletion of live turns; C prevents a deletion (from any cause) from being fatal.** C alone would have turned the observed incident into a legal request over a gutted context - failure with no error, which is worse to diagnose. A alone would leave the `stopReason === "error" | "aborted"` orphan path unhandled.
Synthetic `"No result provided"` results injected by `insertSyntheticToolResults` have a matching `toolCall`, so the sweep leaves them alone.
**Cache-prefix neutrality (load-bearing).** `doc/specs/2026-08-04-pruner-noop-serialization.md` pins two invariants: `pruneMessages` never mutates its input in place, and a no-op render returns the *same array reference* with `pruned: false` - that is what keeps the prompt-cache prefix byte-identical across renders. The sweep pre-scans and, finding no orphan, returns `messages` unchanged: no `.map()`, no re-serialization, `pruned` untouched. When it does fire it changes the prefix once, which is acceptable (the alternative is a fatal request); rewriting the prefix every turn to carry bookkeeping is not.
### Diagnostics
Three conditions report out-of-band: `unresolved-range` (A: unresolvable/ambiguous boundary, or skipped overlapping entry), `range-id-mismatch` (the `droppedToolCallIds` cross-check below), `orphan-sweep` (C fired).
Channel: one new session entry type `context-prune-diagnostic` (`CUSTOM_TYPE_DIAGNOSTIC`, `DiagnosticEntryData { kind, detail }`) plus in-memory counters shown in the `/pruner` status line. Like every `context-prune-*` type except `-summary`, it is **not** in LLM context: zero tokens, zero prefix change, and it survives a restart so a user reporting a recurrence has evidence. Steering a warning into context is rejected (invalidates the cache prefix and spends tokens on a condition the agent cannot act on); `console.warn` alone is rejected (leaves no record).
Diagnostics are deduped per `(blockId, kind)` per session, so a permanently unresolvable entry writes one entry, not one per render. `orphan-sweep` has no `blockId`; its dedup key is `("orphan-sweep", hash of the sorted swept ids)`, so a recurring identical sweep writes once per session while a *new* orphan shape still reports.
### `droppedToolCallIds` becomes diagnostic-only
The boundary timestamps A needs are *already* persisted on `ChainCompressionEntry` (`src/types.ts:422-459`), so old entries resolve to ranges with no migration and no re-summarization. That leaves the id array with no role in dropping.
It is kept, still written by `compressEligible`, and **consumed as a cross-check**: after resolving a range, compare the ids actually dropped against the persisted array; on mismatch emit `range-id-mismatch` with both counts. The range always wins - the check never changes what is dropped. This keeps `droppedToolCallIds` itself untouched (smallest diff, no writer/test churn for that field) while making it earn its place by detecting boundary-resolution drift. A benign mismatch class exists (ids the persisted array picked up from a duplicate-id collision), so this is an inspection signal, not an error. The field's doc comment is demoted accordingly.
### `ChainCompressionEntry` gains `droppedOccurrenceKeys` (persisted)
The cross-check above is the only thing that still needs `droppedToolCallIds` at render time. The *synthetic chain body* needs something else: `chainSummaryText` (`src/pruner.ts:154-155`) looks up per-batch summary text via `indexer.getPerBatchSummaryTextForToolCallIds`, and that lookup is occurrence-keyed (section B) - `summaryBodies` entries are stored under `id@resultTimestamp`, not the bare id. A bare-id lookup at render time would either miss entirely (empty synthetic body on restart) or, worse, hit a *different* occurrence of a reused id and splice an unrelated batch's summary into this chain - the exact collision this spec's section B exists to prevent. Session-only (in-memory) `middleOccurrenceKeys` is not enough: it does not survive a restart, and `droppedToolCallIds` is all that gets replayed from `context-prune-chain` entries into the chain registry.
`ChainCompressionEntry` therefore gains a second optional field, written alongside `droppedToolCallIds`:
- `droppedOccurrenceKeys?: string[]` - the occurrence keys (`id@resultTimestamp`) for the same calls as `droppedToolCallIds`, populated from `chain.middleOccurrenceKeys` at compression time (`src/chain-compressor.ts`) and persisted on the same `context-prune-chain` entry.
- Read at render time by `src/pruner.ts` (`chainSummaryText`) and by `src/commands.ts` (`/pruner compact` re-summarization lookup): both prefer `entry.droppedOccurrenceKeys ?? entry.droppedToolCallIds`, falling back to the bare-id array when the new field is absent.
- **Additive and backward-compatible.** Optional, so pre-upgrade `context-prune-chain` entries - persisted before this field existed - deserialize unchanged and simply take the fallback arm: they look up their own summary bodies by bare id, which is correct for them because those bodies were themselves stored bare-keyed (an entry from before occurrence keying shipped never has an occurrence-keyed sibling to collide with).
- The cross-check in the previous section still reads `droppedToolCallIds` only - `droppedOccurrenceKeys` has no diagnostic role, it exists purely to make the synthetic-body lookup resolve correctly after a restart.
This supersedes the "persisted shape untouched" framing used when `droppedToolCallIds` was demoted to diagnostic-only above (and in section B's summary of `src/chain-compressor.ts` changes): that statement is true of `droppedToolCallIds` itself, not of `ChainCompressionEntry` as a whole, which gains this one companion field.
## Data flow
**Capture (`turn_end`).** `captureBatch` pairs each assistant `toolCall` with its `toolResult` and stores that result's `timestamp` on the `CapturedToolCall`. `addBatch` keys the record `id@resultTimestamp`, mints/reuses the `tN` alias against that key, persists both in `context-prune-index`. Dedup compares content hashes as today but stores occurrence keys on both sides, so a repeated id can no longer alias an unrelated call.
**Render (`pruneMessages`).** Phase order unchanged. Phase 1 computes each `toolResult`'s occurrence key for the summarized/record lookups. Phase 3 resolves ranges positionally, unions index ranges, inserts synthetics after each `startIndex`, strips thinking at each `endIndex`. Then the sweep runs once over the result.
**Chain compression.** `compressEligible` keeps its shape and both entry points (`flushPending`, `/pruner compact` via `src/commands.ts:995-1026`) are unaffected, but its three indexer summary lookups take `middleOccurrenceKeys` instead of bare `middleToolCallIds` (section B).
**Rebuild (`session_start`).** Index/alias/dedup replay from persisted entries; entries lacking `resultTimestamp` key by bare id, i.e. today's behavior. Chain entries replay as today.
## Edge cases
- **Unresolvable / ambiguous boundary, `finalAssistantTimestamp === null`, boundary message absent** → zero drops, no synthetic, one deduped `unresolved-range` diagnostic.
- **Overlapping entries** → union of indices; an entry starting inside another's dropped range is skipped with a diagnostic.
- **Legacy records** (pre-upgrade, no `resultTimestamp`) → bare-id keyed; a session spanning the upgrade retains today's mis-key risk for its older half: a live tool result whose provider id collides with a pre-upgrade summarized record can be stub-replaced with that record's stale content via the `hasLegacyBareRecord` fallback in `src/pruner.ts`. Accepted explicitly: no migration, no re-summarization. New (occurrence-keyed) records are unaffected.
- **Idempotency** → the existing `existingSyntheticBlockIds` pre-scan (`chain-range-prune.ts:56-58`) stays, and because drops are role-restricted the already-inserted user-role synthetic survives inside its own range, so a second application yields the identical output. The sweep is a pure function of its input.
- **Mixed batching modes** → a session that switched `batchingMode` mid-flight holds per-batch summaries both inside and outside chain ranges; the coverage check suppresses both.
## Testing
`bun test src/`, colocated `*.test.ts`, existing fixture style (plain messages with explicit `timestamp`s; `entry()` helper for persisted chain entries). Fixtures without timestamps gain them - "existing tests pass unchanged" is explicitly **not** an acceptance criterion.
- **#8 regression, failing-first**, constructed to the shape the code defect permits (two compressed chains, then a live turn reusing a dropped id alongside a fresh one). Fully specified here; no incident artifact is needed to write it:
```
0 user ts=1000 <- chain b5 start
1 assistant ts=1100 toolCalls[bash_18]
2 toolResult ts=1150 bash_18
3 assistant ts=1200 (text only) <- chain b5 end
4 user ts=2000 <- chain b7 start
5 assistant ts=2100 toolCalls[bash_23]
6 toolResult ts=2150 bash_23
7 assistant ts=2200 (text only) <- chain b7 end
8 user ts=3000 <- live turn, no chain entry
9 assistant ts=3100 toolCalls[bash_23, gauntlet_setting_24] <- id reuse
10 toolResult ts=3150 bash_23
11 toolResult ts=3160 gauntlet_setting_24
```
Entries: `b5 = {start 1000, final 1200, dropped [bash_18]}`, `b7 = {start 2000, final 2200, dropped [bash_23]}`. Pre-fix, indices 9-10 are deleted and 11 survives orphaned. Post-fix: 9, 10, 11 all survive; only 1-2 and 5-6 are dropped.
- `resolveRange`: unique match; duplicate boundary timestamp; `finalAssistantTimestamp === null`; `endIndex <= startIndex`; missing boundary message - each "drop nothing, no synthetic", each with its diagnostic.
- Positional replacements for the id-based assertions: protected-output relocation (`chain-range-prune.test.ts:422-443`, `:448-510`), per-batch summary suppression (both inside and after the range), thinking strip at `endIndex`, overlapping-entry skip, role restriction (a third-party `custom_message` inside a range survives).
- Occurrence identity (issue #9's AC set): two batches both containing `bash_23` keep both records recoverable and each `tN` returns its own batch's output; a live result colliding with a summarized id is not stub-replaced (fail-closed fallback); a live batch colliding with summarized ids is still captured and summarized; content-hash dedup does not alias across batches that merely share a colliding id; branch rescan pairs each turn's calls with its own results; `session_start` rebuild reproduces both colliding records *and* both `tN` aliases via summary refs and dedup aliases; two spilled occurrences of one id get distinct sidecar paths, and a legacy bare-id blob still resolves.
- `query-tool`: bare id with two occurrences returns both blocks, chronological.
- **Orphan invariant as one shared helper** ("no `toolResult` survives without its `toolCall`"), applied across the chain/prune cases rather than restated per test.
- Sweep: fires on a hand-built orphan and emits its diagnostic; clean input returns the **identical** array (`toBe`, not `toEqual`) so the cache/no-op guarantee is pinned by a test; a fixture-wide assertion that the sweep fires **zero** times across every existing chain/prune fixture - it is a net, not a crutch.
- Idempotency: applying the same `ChainCompressionEntry[]` twice is a no-op.
- Rebuild from `context-prune-index` entries lacking `resultTimestamp` → id-only keys, today's behavior.
Plus the repo's `tsc --noEmit` invocation (AGENTS.md) and an end-to-end smoke run (`pi -e ./index.ts --no-extensions -p ...` against an isolated `$PI_CODING_AGENT_DIR`), checking that `context-prune-diagnostic` entries appear and do not appear as expected.
**No replay against a real incident session is possible or required.** Every affected session was repaired in place (ids rewritten unique, orphans removed), so no artifact reproduces the failure; the constructed fixture above is the sole regression vehicle. A repaired session remains useful as a **non-regression** check only: replaying one through the new `pruneMessages` must leave its orphan count at zero and must not drop more messages than the persisted chain ranges cover.
## Acceptance criteria
Issue #8's own AC section is empty (two headings, `Drop path:` and `Identity:`, no bullets; part C has no heading at all). These are the criteria for this change.
Drop path:
1. A live assistant turn whose tool-call id collides with a dropped chain id survives the render intact, together with all of its tool results (failing-first constructed fixture; no real-session artifact is required, and none exists).
2. Chain drops are decided by index range only; no chain code path drops or strips by id-set or timestamp membership without a positional bound. The part C orphan sweep is excepted - it is an id/ordering invariant by construction, not a range rule.
3. Unresolvable or ambiguous boundaries, and `finalAssistantTimestamp === null`, drop nothing and insert no synthetic; no id-array-wide fallback exists.
4. `perBatchSummaryOverlapsDropped`, protected-output relocation, and final-thinking strip are each range-scoped and each has a test; per-batch summary suppression also holds when the summary sits *after* `endIndex` (`batchingMode: "agent-message"`).
5. Re-applying the same `ChainCompressionEntry[]` is a no-op, including preservation of the already-inserted synthetic chain message.
5a. Only `assistant`, `toolResult`, and `CUSTOM_TYPE_SUMMARY` messages are dropped from a range; user-role and third-party `custom_message` entries inside the range survive.
Identity:
6. Two batches that both contain `bash_23` keep both records recoverable, and `context_tree_query` on each `tN` returns its own batch's output.
7. A live tool result whose id collides with a summarized id is not stub-replaced.
8. A live batch whose ids collide with summarized ids is still captured and summarized.
9. Content-hash dedup does not alias across batches that merely share a colliding id, and two spilled occurrences of one id do not share a sidecar path.
10. `session_start` rebuild reproduces both colliding records **and both `tN` aliases** (summary refs and dedup aliases included); entries without `resultTimestamp` fall back to bare-id keys.
10a. Branch rescan pairs each assistant's tool calls with the results of its own turn, so a colliding id cannot pair an earlier call with a later result.
10b. A live tool result carrying a `timestamp` with no occurrence-keyed record is treated as *not* summarized (fail-closed), even when an older occurrence of the same bare id was summarized.
11. `context_tree_query` with a bare id matching multiple occurrences returns every match, chronologically.
Orphan post-condition:
12. A rendered array never contains a `toolResult` without a preceding surviving `toolCall`, asserted via one shared helper across cases. The check is structural and provider-agnostic: it must not branch on any provider's error string (see Problem section for why Anthropic's and Kimi K3's rejections are the same defect).
13. The sweep fires zero times across all existing chain/prune fixtures, and its open-call tracking is per-turn (an id validly used earlier does not license a later orphan).
14. A render in which the sweep does not fire returns the identical input array reference (`toBe`) with `pruned` unchanged.
15. Every diagnostic condition writes a `context-prune-diagnostic` session entry and increments a `/pruner` status counter; none of them adds anything to LLM context.
## Provenance
Two incidents, same defect, both on pi-condense 2.5.0:
1. The session referenced in issue #8 (`400 unexpected tool_use_id found in tool_result blocks`). Its JSONL is hand-edited-during-recovery and was not retrievable via `gh issue view --json body,comments`; provenance only.
2. A `gridstrong` session that failed with `400 Kimi K3 tool messages need a resolvable tool name: carry `tool`/`name`, or match a preceding assistant tool_call by order` - the Kimi wording for an unmatchable (orphan) tool message. Inspected mid-incident, then repaired in place, so its pre-repair state is unrecoverable.
**No incident artifact survives**: all affected sessions were repaired in place, which rewrote tool-call ids to be unique and removed orphans. Nothing in this spec depends on one - the constructed fixture in Testing is self-contained, and the acceptance criteria are stated against it.
## Open questions
None open. (The raw incident JSONL would add nothing beyond the minimized fixture; if it later surfaces, it is a cross-check, not a prerequisite.)
## Documentation impact
Materiality bar: `pi-gauntlet/skills/brainstorming/reference/documentation-impact.md`.
- Feature / user-facing docs introduced: none
- Materially amended existing docs: `PRUNING.md` (range drop is positional; identity is occurrence-keyed; orphan sweep and its cache-neutrality), `README.md` (`/pruner` status diagnostic counters, `context_tree_query` bare-id multi-match), `CHANGELOG.md`
- Derived / memory docs invalidated: `AGENTS.md` customTypes table (new `context-prune-diagnostic` row; the `context-prune-chain` row's id-drop wording)
@@ -0,0 +1,161 @@
# Budget-trigger window ceiling (300k)
Both token-budget flush triggers scale off the host-reported model context window, which makes them unreachable on huge-window models. This spec caps the window the triggers reason about at 300,000 tokens, without adding a setting.
Origin: [jjuraszek/pi-condense#7](https://github.com/jjuraszek/pi-condense/issues/7) - not its title (which asked for a threshold trigger that already shipped), but the reporter's follow-up comment.
## Problem
`src/budget.ts` computes both triggers against `usage.contextWindow`:
- `shouldBudgetFlush` (`src/budget.ts:9-16`): `usage.tokens / usage.contextWindow >= threshold`
- `usageFraction` (`src/budget.ts:18-22`), consumed by `shouldDeltaFlush` (`src/budget.ts:24-38`): the same ratio
`usage.contextWindow` comes from the host's `AgentSession.getContextUsage()`, whose value is `model.contextWindow` - the model's advertised window. On a 1M-window model, therefore:
| setting | 200k model | 1M model |
|---|---|---|
| `autoBudgetThreshold = 0.4` | fires at 80k tokens | fires at 400k tokens |
| `autoBudgetThreshold = 0.9` | fires at 180k tokens | fires at 900k tokens |
| `budgetTurnDelta = 0.1` | fires on +20k in a turn | fires on +100k in a turn |
Two consequences:
1. **The threshold trigger fires far too late, or never.** A session ends long before 900k tokens, so a user who set 0.9 observes "pruning never happens" - exactly what #7's reporter reported: *"using gpt model that have 1 million window when I set 272k window config still not pruning it, but using deepseek flash at 200k is pruning."*
2. **The delta trigger is dead, not merely late.** A single turn practically never grows context by 100k tokens, so on big-window models the re-arm path never fires at all.
This repo already has the receipts for the cost of pruning too late: `doc/specs/2026-08-12-single-chain-observability-trigger-repair.md` documents a real 1M-window session - 256 turns, ~5h, single tool chain, ~195k raw tool-result tokens resident, peak usage 53.7%, `autoBudgetThreshold = 0.8` never crossed. Every one of those 256 turns paid full prompt cost on tool output that pruning exists to remove.
Prior work addressed adjacent gaps and not this one: `doc/specs/2026-05-30-protected-chain-empty-summary-budget.md:257-365` introduced `autoBudgetThreshold`; `doc/specs/2026-06-02-oversized-output-spill.md:220-318` added `budgetTurnDelta` for the *timing* gap (a single oversized result spiking usage between turn boundaries); the #6 spec addressed the *structural* gap (no `agent-message` boundary in a single-chain session). The window-size interpretation mismatch is a third, independent failure mode.
### Non-goals
- No new user-facing setting. `autoBudgetThreshold` and `budgetTurnDelta` remain the only budget knobs; a third knob would have to be explained in terms of the other two.
- No escape hatch to restore uncapped behavior. This is a product decision, not a compatibility argument: above the cap, flushing earlier is the fix, and below it nothing changes.
- No change to what pruning *does* once triggered, to `pruneOn` modes, to chain compression, or to the status widget.
- Not a diagnostic-only fix (e.g. surfacing the reported `contextWindow` on `/pruner status`). That would inform users while leaving the default behavior broken.
## Design
One exported constant and two one-line formula changes in `src/budget.ts` - the only **production-logic** change. Full changed-file list: `src/budget.ts` (logic + docstrings), `src/budget.test.ts` (tests), `src/types.ts` + `src/commands.ts` (docstrings and the live settings description), `README.md`, `doc/configuration.md`, `PRUNING.md` (docs).
```ts
export const MAX_BUDGET_WINDOW = 300_000;
```
**Threshold trigger** - absolute ceiling on the trigger *level*:
```ts
return usage.tokens >= Math.min(MAX_BUDGET_WINDOW, threshold * usage.contextWindow);
```
Reads as: *fire at your percentage of the model's window, or at 300k tokens, whichever comes first.* The threshold keeps its literal meaning; the cap is only a ceiling. (`threshold <= 1` is already enforced, so a `min` against `usage.contextWindow` itself would be redundant and is omitted.)
**Delta trigger** - ceiling moved into the denominator:
```ts
return usage.tokens / Math.min(usage.contextWindow, MAX_BUDGET_WINDOW);
```
The two shapes differ deliberately, and that asymmetry is the one non-obvious thing in this change, so it carries a code comment above `MAX_BUDGET_WINDOW`, verbatim:
```ts
// Ceiling on what the budget triggers treat as the context window. Advertised
// windows reach 1M, which makes any (0,1] fraction unreachable in a real session.
// The two triggers apply it in different shapes on purpose: the threshold is a
// LEVEL, so the cap bounds the level itself (min(CAP, threshold * window)); the
// delta is a GROWTH RATE, where a 300k ceiling could never bind, so the cap
// enters through the denominator instead (delta * min(window, CAP)).
```
The threshold is a *level*, so a ceiling on the level is meaningful. The delta is a *growth rate*, and a 300k ceiling on growth would never bind (no turn grows context by 300k), so the cap has to enter through the denominator. Algebraically `delta * min(W, CAP)` is `min(delta*W, delta*CAP)` - it is still a ceiling, just one proportional to the configured delta. A standalone growth constant (e.g. a hardcoded 30k) was rejected: at `budgetTurnDelta = 0.3` on a 200k model it would fire at +30k where today it fires at +60k, breaking the invariant below, and it would be a second magic number silently required to stay in ratio with the first.
### Why 300,000
The ceiling's only job is to stop the setting from being *unreachable*. Users keep full downward control - on a 1M model, `autoBudgetThreshold = 0.1` still means 100k - so the ceiling does not need to be tight, it needs to clip the pathological tail.
300k buys a clean invariant: **the ceiling never binds on a model advertising 300k or less, at any threshold or delta value** - so no user on a model at or below 300k sees any change. Above the cap, behavior does change: those users now flush earlier, which is the entire point of the fix (a 400k window at `0.9` moves from 360k to 300k; 1M at `0.9` from 900k to 300k). Alternatives considered and rejected:
- **200k** - breaks the invariant: a 256k-window model at `0.9` would clip from 230.4k to 200k, silently changing behavior for a mainstream model.
- **250k** - breaks it marginally (a 272k window at `>= 0.92`).
- **400k** - fixes `0.9` on 1M (900k -> 400k) but leaves `0.4` on 1M unchanged, i.e. no improvement for the common case.
Long-context quality evidence supports a ceiling in this region but is **not** the primary argument: RULER ([arXiv:2404.06654](https://arxiv.org/abs/2404.06654), COLM 2024) found that of 17 models claiming >= 32k, "only half of them can maintain satisfactory performance at the length of 32K"; NoLiMa ([arXiv:2502.05167](https://arxiv.org/abs/2502.05167), ICML 2025) found that at 32k, 11 of 13 models claiming >= 128k fall below 50% of their own short-context baseline (GPT-4o: 99.3% -> 69.7%). Neither covers a 2026 frontier 1M-window model, so neither can justify a specific ceiling on quality grounds. The primary argument is per-turn prompt cost, evidenced by the #6 session above.
### Effect
| model window | setting | today | after |
|---|---|---|---|
| 200k | `threshold 0.4` | 80k | 80k (unchanged) |
| 256k | `threshold 0.9` | 230.4k | 230.4k (unchanged) |
| 272k | `threshold 1.0` | 272k | 272k (unchanged) |
| 1M | `threshold 0.4` | 400k | 300k |
| 1M | `threshold 0.9` | 900k | 300k |
| 200k | `delta 0.1` | +20k | +20k (unchanged) |
| 1M | `delta 0.1` | +100k (never fires) | +30k |
For any model at or below the cap, the change is a no-op; above it, flushes happen earlier by design, and no case flushes *later* than today.
### Contract change: `usageFraction` may exceed 1
`usageFraction`'s docstring currently promises a 0-1 fraction. That promise is retracted: at 600k tokens on a 1M-window model it now returns 2.0. The value is **not** clamped - clamping at 1.0 would make the delta trigger saturate above the cap and stop re-arming, reproducing the exact failure this spec removes.
Both consumers are safe with unbounded values, verified: `shouldDeltaFlush` (`src/budget.ts:24-38`) only takes a difference of two fractions, and `index.ts:950` stores the value as `previousFraction` for the next turn. The status widget does not consume `usageFraction`, so there is no display math to reconcile.
### Edge cases
| case | behavior |
|---|---|
| `contextWindow <= 300k` | `min` never binds; byte-identical to today |
| `usage.tokens == null` (right after host compaction; the host only trusts assistant usage newer than the last compaction) | existing guards return `false` / `null`; unchanged |
| `usage` absent, or `contextWindow <= 0` | existing guards return `false` / `null`; unchanged |
| `threshold` outside `(0, 1]` | rejected by existing guard in `shouldBudgetFlush` and by `src/config.ts:83-89` validation; unchanged |
| fraction above 1.0 | permitted, unclamped; `previousFraction` may hold `> 1` |
| `threshold = 1.0` on a 1M model | fires at 300k, not 1M - intended tail-clipping |
| tiny window (e.g. 8k local model) | `min(300k, 0.4 * 8k) = 3.2k`; unchanged |
| `previousFraction == null` (first turn after load) | delta never fires; unchanged - the threshold trigger covers that turn |
## Testing
Extend `src/budget.test.ts` (bun, existing `describe` / `it` / `expect` style). Tests import `MAX_BUDGET_WINDOW` rather than duplicating the literal, except the one assertion on the constant's own value.
`shouldBudgetFlush`:
- 1M window, `0.4`: fires at 300,000; does not fire at 299,999.
- 1M window, `0.9`: fires at 300,000 (the ceiling, not 900k).
- 200k window, `0.4`: fires at 80,000, not at 79,999 (unchanged behavior).
- 256k window, `0.9`: fires at 230,400, not below (invariant at the boundary that 200k would have broken).
- 300k window, `1.0`: fires at exactly 300,000.
`usageFraction`:
- 1M window, 600k tokens: returns 2.0 (proves no clamp).
- 200k window, 80k tokens: returns 0.4 (unchanged).
`shouldDeltaFlush`:
- 1M window, `0.1`: fires on 100k -> 130k; does not fire on 100k -> 120k.
- 200k window, `0.3`: still requires +60k (unchanged; the rejected standalone-constant design would fail this).
All existing tests in `src/budget.test.ts` must pass unmodified - that is the mechanical proof of the invariant.
Verification: `bun test src/` plus the typecheck command in `AGENTS.md`.
## Documentation impact
- Feature / user-facing docs introduced: none
- Materially amended existing docs: `README.md` - the single `autoBudgetThreshold` row (`README.md:163`); the settings table has no `budgetTurnDelta` row and this spec does not add one (that knob is documented in `doc/configuration.md`, which the README row links onward to). Replacement text for that row's description: `Fraction (e.g. 0.8) of the context window that force-flushes everything regardless of pruneOn; the trigger point is capped at 300k tokens`. `doc/configuration.md` - the `autoBudgetThreshold` row (`:61`), the `budgetTurnDelta` row (`:64`), and the "Token-budget auto-flush" section (`:77`). `PRUNING.md` - the two trigger sections, "Token-budget auto-flush trigger" (`:817`) and "Budget-delta flush" (`:825`, whose formula is written as `tokens / contextWindow`), **and separately** "Single-chain sessions" (`:968-976`), whose "lower `autoBudgetThreshold`" advice is partly obsoleted by the ceiling
- Derived / memory docs invalidated: none - `AGENTS.md`'s routing table and layout section describe `src/budget.ts` at a granularity this change does not alter
In-code documentation surface (implementation, not doc-impact entries): the `src/budget.ts` docstrings (including `usageFraction`'s retracted 0-1 promise and the level-vs-growth asymmetry comment), `src/types.ts:385-407` setting docstrings, and `src/commands.ts:236-240` `autoBudgetThresholdDescription`, which renders live in `/pruner settings`.
Wording across all sites states the two contracts separately and exactly as coded - there is no single unified "effective window" phrase, because the two triggers do not share one denominator:
- threshold: fires when context reaches `min(300k, threshold * model window)` tokens - *your percentage of the model's window, or 300k tokens, whichever comes first*
- delta: fires when a single turn's growth reaches `delta * min(model window, 300k)` tokens - i.e. the growth fraction is measured against a window capped at 300k
Writing the threshold as "`threshold` of an effective window of `min(window, 300k)`" would be wrong: on a 1M model at `0.4` that reads as 120k, where the implementation fires at 300k.
## Open questions
None blocking. Recorded for completeness, all deliberately not resolved by this spec:
- Where the reporter's "272k window config" actually lived (host `models.json` override, a gateway, or misremembering) is unknown; the reporter never replied and #7 is closed. The design does not depend on the answer - a host-side `contextWindow` override does reach `getContextUsage()`, and the ceiling works whether or not one is configured.
- Whether to reply on the closed #7 or open a fresh issue per this repo's ticket convention is a process choice, made at ship time, not a design input.
@@ -0,0 +1,360 @@
# Uncovered-chain deterministic compression with recoverability backfill
Fixes [jjuraszek/pi-condense#10](https://github.com/jjuraszek/pi-condense/issues/10).
## Problem
`compressEligible` (`src/chain-compressor.ts:118-126`) permanently skips eligible
chains with zero per-batch summary coverage (`reason: "no-summary"`). The skip is
structural: once the prune frontier passes a span without a summary body (trivial
batch, `skipped-oversized`, fully-deduped batch, or a capture miss),
`trimBatchToPendingRange` excludes it from future capture forever. No amount of
waiting, `/pruner now`, or `/pruner compact` recovers it.
Motivating incident (session `2026-08-12T10-47-56`): a 639-call chain, ~935k chars
(~300k tokens, ~62% of a 620k context window) stranded live, repeatedly skipped
with `no-summary`.
Zero coverage is a normal-path occurrence, not exotic - the fix must be harmless
when triggered: deterministic, zero LLM calls, fail-closed.
## Decisions (user-ratified)
| # | Decision |
|---|---|
| D1 | Body contract: reuse `rangeSummaryText` for the deterministic stub text + optional `bodySource: "deterministic"` discriminant on the chain entry. Zero renderer change; entries without `bodySource` keep current semantics. |
| D2 | Anti-regression cage: deterministic unit tests only, exhaustive over the ACs. No automated e2e harness (repo convention: `bun test src/`; e2e smoke stays manual per AGENTS.md). |
| D3 | Architecture: compressor-owned backfill. Branch messages threaded into `compressEligible` deps; new indexer method `backfillChainRecords` persists the index entry and issues refs, skipping dedup-canonical registration. Covered path untouched. |
| D4 | Design rule (now in AGENTS.core.md, all four repos): no new machinery if not essential. Every addition below reuses an existing field, type, or path plus a small discriminant. |
| D5 | Adjudicated at review, not pre-planned (see "Implementation deviations" below): fully-protected zero-coverage chains skip silently (no `backfill-empty`); `extractChainRecords` takes `isIndexed` only, protection is read from `chain.protectedToolCallIds`; `buildDeterministicBody` drops the unused `chain` param; `backfillChainRecords` takes an `opts` object (spill config + session identity + `appendEntry`), not a bare callback; `resolveRange`'s entry param widens to a `Pick<...>` type (no behavior change). |
## Architecture
`compressEligible` gains a second branch at the current `no-summary` skip.
`CompressEligibleDeps` grows: `messages: any[]` (**the same array chain
detection ran on**, i.e. the `withClosingMessage(...)` output - threading raw
`branchMessages` would lose the closing assistant on the message_end path with
`rollingWindow: 0` and spuriously fail span resolution), `diagnostics:
Pick<DiagnosticSink, "report">`, and `backfill: { spillThreshold;
spillPreviewBytes; sessionDir; sessionId }` (the spill seam). Both call sites
(`flushPending` at `index.ts:658-671` and `/pruner compact` at
`index.ts:1035-1043`) already hold all of these.
For an eligible zero-coverage chain:
1. **Resolve + extract** - resolve the chain's positional span with the existing
`resolveRange` (`src/chain-range-prune.ts`) - same fail-closed duplicate-
timestamp semantics as the drop path. As shipped, `resolveRange`'s entry
param widens to `Pick<ChainCompressionEntry, "startUserTimestamp" |
"finalAssistantTimestamp">` so `extractChainRecords` can call it with a
`ChainRange` instead of a persisted chain entry - type-level only, behavior
identical, regression-tested (see "Unchanged" below for scope). Walk the
resolved span; for each middle tool call **not protected** (read from
`chain.protectedToolCallIds`, the same in-chain data `detectChains` already
populated - protected outputs are relocated verbatim at render time and must
never be phase-1 stubbed, so they are never backfilled) and **not already
indexed** (predicate: index membership, i.e. the indexer's private `index` map
has the occurrence key - NOT `isSummarized()`, which also covers dedup aliases
and would strand the fully-deduped sub-case named in Problem), build a
`ToolCallRecord`:
- `toolCallId`, `toolName`, `args`, `isError` from the messages;
- `resultText` = full result text; `timestamp` = the toolResult message
timestamp; `resultTimestamp` likewise (occurrence-key discriminant, via
existing `occurrence-key.ts` helpers);
- `turnIndex` = `-1` sentinel (backfilled records have no batch turn;
`context_tree_query` renders `Turn: -1` - accepted cosmetic).
Dedup-aliased members are re-indexed verbatim; their existing alias entries
keep resolving unchanged. Extraction is a pure walk returning full-text
records; spilling happens in step 2.
2. **Backfill (fail-closed, append-before-commit)** -
`indexer.backfillChainRecords(records, opts)` (async), where `opts = {
spillThreshold, spillPreviewBytes, sessionDir, sessionId, appendEntry }`:
- spill oversized results via the shared spill helpers (`blobPathFor`/
`applySpill`), a fail-closed variant of the eager `spillOversizedBatch`
path (record gets `spillPath`/`spillBytes`/`resultPreview`/`contentHash`,
`resultText` emptied) - a ~935k-char member must not be inlined into the
session JSONL;
- allocate `t<N>` refs via the existing `allocateSummaryRefs` /
`buildShortToolCallRefs` (monotonic `nextShortAliasNumber`);
- persist **one** `context-prune-index` entry carrying the records, the
allocated `SummaryToolCallRef[]`, and `backfilled: true` - refs ride the
index entry so they are durable the moment the records are (atomic);
- **only after the append succeeds**, commit in-memory state: records into
the `index` map, aliases via `registerSummaryRefs`. A failed append leaves
records and alias maps unchanged (burned alias numbers are acceptable -
`t<N>` is opaque and monotonic, gaps are harmless);
- never touch `contentHashToOriginal`.
Any throw -> keep the existing `no-summary` skip; retried next flush.
3. **Compose** - `buildDeterministicBody(records, refs)`: call count,
tool-name histogram, span duration, first/last call name + args excerpt capped
at 200 chars, and the refs. Zero LLM calls. On a retry where some/all members
are already indexed (prior partial failure), refs for indexed members are
recovered via the existing `getToolRefsForToolCallIds` and the records via the
index - the body is composed over the full chain, not just newly extracted
records.
4. **Persist** - chain entry as today, plus `rangeSummaryText` (the deterministic
body) and `bodySource: "deterministic"`.
**Retry discriminator** (partial-failure convergence): zero extractable records
AND zero chain members present in the index -> genuine span mismatch ->
fail-closed skip + `backfill-empty` diagnostic. Zero newly extracted records but
members ARE in the index (prior index append succeeded, chain append failed) ->
proceed to compose + persist the chain entry using the already-durable records
and refs. The same observable "nothing new to extract" state never routes to a
permanent skip when recovery data exists.
**Fully-protected exception to the discriminator** (D5, adjudicated at review):
if the zero-extractable/zero-indexed state above is instead explained by every
middle call being protected (`chain.middleToolCallIds` non-empty and all present
in `chain.protectedToolCallIds`), the chain still takes the plain `no-summary`
skip but **without** the `backfill-empty` diagnostic. Compressing such a chain
saves zero tokens - every output would be relocated verbatim into the synthetic
body at render time regardless - and reporting `backfill-empty` for it would
misreport a healthy, fully-protected span as a mismatch, re-firing on every
restart for protected-heavy configs. This guard runs first; `backfill-empty`
only fires for the remaining genuine-mismatch case.
Covered chains take the existing branch untouched - byte-identical by construction.
## Components
### `src/chain-compressor.ts`
- `CompressEligibleDeps` gains `messages`, `diagnostics`, and the spill seam
(see Architecture).
- New pure helper `extractChainRecords(messages, chain, isIndexed):
ToolCallRecord[]` - `resolveRange`-based span walk; no separate protection
predicate param, protection is read from `chain.protectedToolCallIds`
(in-chain data already populated by `detectChains`).
- New pure helper `buildDeterministicBody(records, refs): string` - no `chain`
param (unused by the body grammar).
- `CompressEligibleResult` unchanged (no new counters - cut as non-essential per
D4; `bodySource` on the persisted entries already distinguishes deterministic
compressions for offline inspection).
### `src/indexer.ts`
- `backfillChainRecords(records, opts): Promise<SummaryToolCallRef[]>` where
`opts = { spillThreshold, spillPreviewBytes, sessionDir, sessionId,
appendEntry }` - async; spill, allocate refs, append the marked index entry,
then commit maps (order per Architecture step 2). `ToolCallIndexer` owns no
appender today, so the callback rides the options object alongside the spill
config and session identity it also needs (mirrors what the `turn_end`
eager-spill path already assembles for `spillOversizedBatch`, `index.ts:900`).
- `reconstructFromSession`: index entries with `backfilled: true` -> records
restored into the `index` map, refs re-registered via `registerSummaryRefs`,
dedup-canonical registration (`contentHashToOriginal`) skipped. Rationale:
alias reconstruction today happens only from `CUSTOM_TYPE_SUMMARY` entries
(`src/indexer.ts:95-97`); backfilled chains have no summary message, so the
backfilled index entry is the durable ref carrier.
### `src/types.ts`
- `ChainCompressionEntry` gains `bodySource?: "deterministic"`.
- Index entry data gains `backfilled?: true` and optional
`refs?: SummaryToolCallRef[]` (present only on backfilled entries).
- `DiagnosticKind` union gains `"backfill-empty"` (closed 3-member union today,
`src/types.ts:97`).
- All optional -> pre-existing entries load unchanged.
### `src/diagnostics.ts` / `src/commands.ts`
- `DiagnosticSink.counters` literal gains the new kind.
- `pruneStatusText` (`src/commands.ts:70-77`) hardcodes the three existing kinds;
gains `backfill-empty` (widget suffix, e.g. `diag ... b<N>`).
- `/pruner compact` report text unchanged (deterministic-vs-fused distinction cut
as non-essential; the persisted `bodySource` field carries that information).
### `index.ts`
- Threads `messages` (the `withClosingMessage` array), `diagnostics`, and the
spill seam into both `compressEligible` call sites.
- `context-prune-flush-metrics`: unchanged (new counter cut as non-essential).
### `src/chain-range-prune.ts`
- `resolveRange`'s entry param widens from `ChainCompressionEntry` to
`Pick<ChainCompressionEntry, "startUserTimestamp" | "finalAssistantTimestamp">`
so `extractChainRecords` (which walks a `ChainRange`, not a persisted entry)
can call it directly. Type-level only; behavior identical, regression-tested.
Renderer behavior otherwise unchanged: still prefers `entry.rangeSummaryText`
(`src/pruner.ts:154`); protected-output relocation is still live-by-id,
coverage-independent.
### Unchanged
`src/pruner.ts`, `src/summarizer.ts`, `src/block-refs.ts` (`b<N>` issuance
identical for both branches), `CompressEligibleResult`, flush-metrics entry
shape, compact report text.
## Data flow
**Live flush (automatic - no command required).** `flushPending` finishes
per-batch summarization -> `detectChains` -> `compressEligible`. Zero-coverage
chains take resolve/extract -> backfill -> compose -> persist. Ordering: index
entry (with refs) before chain entry. If the chain-entry persist fails, records
AND refs are already durable; the next flush retries and the retry discriminator
routes it to compose-from-index, converging without duplicate index entries.
**Known limitation (stated, not fixed here):** `flushPending` returns early when
there are no pending batches, before chain detection. A stranded chain in an
otherwise idle session is therefore healed on the next flush that has any work,
or immediately by `/pruner compact` - not by `/pruner now` on an empty queue.
Acceptable: the motivating incident occurred in an actively flushing session.
**Render.** Unchanged: `pruneMessages` resolves ranges positionally and prefers
`entry.rangeSummaryText`. The deterministic body renders inside the same
`<compressed-chain id="bN" tools="...">` envelope with working `t<N>` refs.
**Recovery.** `context_tree_query("tN")` -> alias map -> occurrence key ->
backfilled record (or its spill file) -> raw body. Identical to summary-issued
refs.
**Restart.** `reconstructFromSession` replays entries in order: backfilled index
entries restore records + aliases (dedup skipped), chain entries restore the
registry. Post-restart behavior is identical to pre-restart, including the
partial-failure retry (records + refs reconstruct from the index entry alone).
**`/pruner compact`.** Same `compressEligible` call - all uncovered chains handled
in one pass, no cap (no LLM cost to bound), zero summarizer traffic.
## Error handling and edge cases
- **Backfill throws** (spill I/O, append failure, malformed span) -> local catch,
chain skipped with `reason: "no-summary"` (fail-closed). Append-before-commit
guarantees in-memory maps are untouched on failure, so phase-1 stub-replace
cannot fire for records whose backfill never became durable. Retried next flush.
- **Index entry persisted, chain entry persist fails** -> records + refs durable;
retry discriminator (see Architecture) composes from the index. Converges, no
duplicate index entries, no re-stranding.
- **Genuine span mismatch** (zero extractable records AND zero members indexed,
and NOT explained by full protection - see next bullet) -> fail-closed skip +
`DiagnosticSink` report, kind `backfill-empty`, deduped per chain key.
- **Fully-protected uncovered chain** (D5) -> zero extractable records and zero
members indexed, but every middle call is in `chain.protectedToolCallIds` ->
fail-closed skip, same as a genuine mismatch, but **no** `backfill-empty`
diagnostic. Compressing would save zero tokens (everything is relocated
verbatim at render regardless), and reporting a mismatch here would misreport
a healthy span, re-firing every restart for protected-heavy configs.
- **Oversized member results** -> spilled via the shared spill helpers
(`blobPathFor`/`applySpill`), a fail-closed variant of the eager
`spillOversizedBatch` path; the index entry carries `spillPath`/
`resultPreview`, never the full body.
- **Empty args/results** -> body non-empty by construction (count / histogram /
duration always present); excerpts capped at 200 chars.
- **Protected outputs inside an uncovered chain** -> excluded from backfill
entirely (never indexed -> never phase-1 stubbed, even in the chain-entry
failure window); at render time `chain-range-prune.ts` relocates them verbatim
by bare id within the resolved range, unchanged. If protection covers *every*
middle call in the chain, the chain hits the fully-protected exception above
instead of `backfill-empty`.
- **Dedup** - backfilled records never become `contentHashToOriginal` canonicals
(live and reconstruction paths both honor `backfilled: true`). A later identical
output finds no canonical and is indexed normally. Fully-deduped chains ARE
backfilled (index-membership filter, not `isSummarized`); their pre-existing
alias entries keep resolving.
- **`isSummarized()`** is true for backfilled occurrences once the index append
commits: phase-1 stub-replace may fire even if the chain entry append later
fails - acceptable, the raw content is durable and recoverable.
- **Legacy entries** (no `bodySource` / `backfilled` / `refs`) -> absence
preserves today's semantics exactly.
- **Cache-prefix impact** - compressing a chain rewrites in-flight history from
that point, invalidating the provider prompt-cache suffix **once**. Frequency
is unchanged: eligibility timing is owned by the existing rolling-window logic;
this fix only removes the terminal skip, so marginal invalidations = one per
previously-stranded chain. The deterministic body is render-stable (no `now()`,
no LLM nondeterminism), so post-compression renders are byte-identical and
cache-friendly. Retry convergence cannot thrash: retries run only while the
chain entry never persisted (nothing was rewritten yet); once it lands the
output is frozen. Economics: a stranded ~300k-token chain costs a cache-read
(~0.1x input price) on every call forever; the one-time suffix re-write
(~1.25x) breaks even within ~1-2 calls. Same trade every existing prune
(phase-1 stub replacement) already makes - no new invalidation class.
## Out of scope (follow-up tickets per the issue's roast)
- Coverage-ratio gate for `hasPerBatchSummaryCoveringAny` (any-overlap today: a
639-call chain with 1 covered call takes the covered path).
- Capture-layer anomaly root cause (why an 800-turn agent-message cycle produced
no batch).
- Partial-coverage handling: chains with any coverage take the existing covered
path, unchanged.
- Chain-only compression on an empty flush (see Known limitation).
## Testing approach
Deterministic unit cage (D2), all through pure seams (`compressEligible` with
stubbed deps, indexer directly). `bun test src/` green is an AC.
New behavior (`src/chain-compressor.test.ts`, `src/indexer.test.ts`):
- zero-coverage chain -> compressed; body contains count, histogram, duration,
refs; `fuseRange` spy never called (zero-LLM assertion)
- `backfillChainRecords`: records queryable by raw id and `t<N>` ref;
`contentHashToOriginal` untouched; index entry carries `backfilled: true` +
`refs`
- append failure -> in-memory index and alias maps unchanged, no chain entry,
`no-summary` skip (fail-closed transactionality)
- chain-append-failure -> restart -> retry: records + refs reconstruct from the
backfilled index entry; retry composes and persists the chain entry; no
duplicate index entries (convergence test)
- retry discriminator: members-indexed state composes; zero-extracted +
zero-indexed state skips with `backfill-empty` diagnostic
- fully-deduped chain -> backfilled (index-membership filter), compressed, alias
entries still resolve
- protected member -> excluded from backfill records and refs; relocated verbatim
at render
- oversized member -> spilled (`spillPath`/`resultPreview`/`contentHash` set,
`resultText` empty, full body absent from the persisted entry)
- empty-args chain -> non-empty body; 200-char excerpt cap enforced
- backfilled record renders `Turn: -1` in `context_tree_query` output (pinned
cosmetic)
Anti-regression cage:
- covered-path byte-identity: chain with coverage -> entry deep-equals pre-change
snapshot; `bodySource` unset; backfill spy never invoked (the existing
"skips chain with no summary" test at `src/chain-compressor.test.ts:129-140`
becomes the deterministic-compression test; the covered path gets its own
identity test)
- legacy round-trip: real pre-change `context-prune-chain` and
`context-prune-index` JSONL fixtures reconstruct to identical state
- restart: write entries -> `reconstructFromSession` -> refs resolve, dedup
canonicals exclude backfilled records
- integration (`src/range-compression.integration.test.ts`, existing file): one
covered + one uncovered chain in a single flush -> both compressed, covered
output unchanged, `expectNoOrphanToolResults` holds
- multi-chain compact: three uncovered chains, one `compressEligible` call ->
three deterministic entries
## Documentation impact
- Feature / user-facing docs introduced: none
- Materially amended existing docs: PRUNING.md (deterministic-fallback subsection
in the algorithm + rationale; diagnostics table gains `backfill-empty`),
README.md (compact behavior note + empty-flush limitation), AGENTS.md
(`context-prune-chain` row: `bodySource`; `context-prune-index` row:
`backfilled` + `refs`; diagnostics row: `backfill-empty`)
- Derived / memory docs invalidated: none
## Acceptance criteria (from the issue, verbatim intent)
- [ ] Covered chains: output byte-identical to current behavior; new unit test
asserts the deterministic path is not taken when coverage exists.
- [ ] An eligible zero-coverage chain is compressed with a deterministic body:
call count, tool histogram, span duration, working `t<N>` refs; zero LLM
calls asserted.
- [ ] `context_tree_query` returns the raw output for a tool call inside a
backfilled compressed range.
- [ ] Backfill failure -> chain remains uncompressed with `no-summary` skip;
partial failure (index persisted, chain entry not) converges on retry
instead of re-stranding.
- [ ] Backfilled index records are excluded from content-hash dedup canonical
lookup.
- [ ] Synthetic body renders non-empty even for a chain whose calls have empty
args/results.
- [ ] `session_start` reconstruction round-trips the new entry fields (including
refs on backfilled index entries); pre-existing entries without them load
unchanged.
- [ ] `/pruner compact` handles multiple uncovered chains in one invocation.
- [ ] PRUNING.md and README updated; `bun test src/` green.
@@ -0,0 +1,74 @@
# Orphan-sweep barrier semantics (gh-11)
Ticket: [jjuraszek/pi-condense#11](https://github.com/jjuraszek/pi-condense/issues/11).
Partially supersedes `doc/specs/2026-08-12-toolcall-id-collisions.md` - the "C. Orphan sweep" section's barrier rule and its claim that C covers the `stopReason: error|aborted` orphan path (it does not - see Out of scope); its per-turn open-set tracking, no-op invariant, and diagnostics stay authoritative.
## Problem
`sweepOrphanToolResults` (`src/orphan-sweep.ts`) is the last phase of `pruneMessages` and owns the structural post-condition: never hand the provider a `toolResult` whose `tool_use` is gone. Its open-call set is replaced only on `role === "assistant"`; every other non-`toolResult` role is silent pass-through.
Downstream, the barrier definition is different. `convertToLlm` (`pi-coding-agent/dist/core/messages.js`, wired into the agent via `core/sdk.js`; `pi-agent-core/dist/harness/messages.js:57-96` is an equivalent duplicate, cited by the ticket) maps `custom`, `branchSummary`, `compactionSummary`, `bashExecution` to `role: "user"` (dropping `bashExecution{excludeFromContext:true}` and unknown roles). `transformMessages` (`pi-ai/dist/api/transform-messages.js`) flushes `insertSyntheticToolResults()` on both `assistant` and `user` boundaries.
So `assistant(toolCall X) / custom(...) / toolResult(X)` passes the sweep clean, but at the provider boundary pi-ai emits a synthetic `{isError: true, "No result provided"}` for X at the barrier and then still pushes the real result - a duplicate `tool_use_id`. Anthropic 400s permanently on that branch; the shape is unrepairable by pi-ai (it repairs orphan calls, not orphan results). Measured occurrence: pi-cohort spliced a `subagent_control_notice` mid-cycle (upstream cause: jjuraszek/pi-cohort#7); the session survived ~100 assistant turns on kimi-k3 and hard-bricked on the first Claude request. Corpus: 690 mid-cycle barriers across 2170 sessions (687 `user`, 3 `context-prune-summary`), of which 0 are followed by a real `toolResult` for a still-open id - the barrier reset sweeps nothing in today's corpus (the one known bricking instance was hand-repaired out). Rare shape, total failure mode.
This fix is defense-in-depth in pi-condense; it does not gate on, and is not substituted by, pi-cohort#7's steer-delivery fix.
## Decision
Any message that is neither `assistant` nor `toolResult` is a barrier that resets the open-call set:
```ts
if (msg.role !== "assistant" && msg.role !== "toolResult") {
open = new Set();
continue;
}
```
placed as the fall-through branch in the existing forward pass of `src/orphan-sweep.ts` (equivalently: an `else` after the `toolResult` branch). Everything else is unchanged: per-turn replacement on `assistant`, single-consume on `toolResult`, swept-id ordering, and the same-array-reference no-op return (`doc/specs/2026-08-04-pruner-noop-serialization.md`).
Consequence, accepted: the interleaved real `toolResult` is swept, and pi-ai injects its repairable synthetic failure for the now-orphaned call. One visible synthetic tool failure instead of a bricked session; structurally the model already saw that synthetic (`transformMessages` emitted it before the barrier). The fix is retroactive - an already-broken branch un-bricks on the next render with no session surgery.
Deliberate over-sweep, not an exact mirror of `convertToLlm`: `bashExecution{excludeFromContext:true}` and unknown roles are dropped by `convertToLlm`, so strictly they need not be barriers - we treat them as barriers anyway. A role allowlist would have to track the open `CustomAgentMessages` interface for zero observed benefit (0 `bashExecution` entries in the corpus). Do not "fix" this into an allowlist.
Rejected alternative: hoisting the barrier message out of the cycle - the hoist index moves as the branch grows (breaks render-is-a-stable-function-of-prefix), reorders a message this extension does not own, and cannot be expressed in the single forward pass.
## Change surface
Minimal by explicit user constraint; tests carry the regression guarantee.
1. `src/orphan-sweep.ts` - the barrier branch above; update the header comment's "Open-call tracking is PER TURN" paragraph to state the barrier reset. (The "immediately preceding assistant turn" phrasing does not appear in this file - it lives in `PRUNING.md:1099,1103`, `src/pruner.ts:51`, and `AGENTS.md:97`, handled in items 4 and 6.)
2. `src/test-support.ts` - `expectNoOrphanToolResults` gets the identical barrier reset. It copies the sweep's own orphan rule, over-sweep included (decision: do NOT delegate to `sweepOrphanToolResults`; that would make sweep tests tautological). One definition of "orphan", two lockstep call sites.
3. `src/orphan-sweep.test.ts` - invert the line-63 test ("non-assistant messages between a call and its result do not close the open set") plus the new tests below. Add a minimal `bashExecution`-role fixture helper; a bare `{role: "bashExecution"}` message is sufficient - the sweep never reads `excludeFromContext`, and plumbing it would imply a distinction the barrier deliberately ignores.
4. `PRUNING.md` (Orphan Sweep section, ~lines 1095-1107 - re-verify at implementation time) - the barrier rule, described as tracking the post-`convertToLlm` `user`/`assistant` flush points of `insertSyntheticToolResults` **plus** the accepted `excludeFromContext`/unknown-role over-sweep (not an exact mirror), and the trade-off: a mid-cycle foreign message now costs one tool output and converts an unrepairable provider shape into pi-ai's repairable one. Fix the "immediately preceding assistant turn" phrasing at lines 1099 and 1103.
5. `doc/specs/2026-08-12-toolcall-id-collisions.md` - supersession banner (per the marking convention): scope `"C. Orphan sweep" section: barrier rule and stopReason error|aborted coverage claim`. Already applied in this worktree; ships with the spec commit - do not add a second banner.
6. Comment-only touch-ups where the old rule is restated: `src/pruner.ts:51` (Phase 4 JSDoc) and `AGENTS.md:97` (layout-table row for `orphan-sweep.ts`).
No behavior changes to `src/pruner.ts` (Phase 4 wiring, diagnostics, and the `orphan-sweep` diagnostic emission are untouched), no signature changes, no new modules.
## Testing
TDD. Exactly one existing test goes red before the change (`orphan-sweep.test.ts:63`, verified by the roaster: patch applied -> 466 pass / 1 fail); new tests are written red-first. Bun `describe`/`test`/`expect` with the file's small message-factory style.
- **AC1** - parametrized over `[custom, user, branchSummary, compactionSummary, bashExecution]` **plus one unknown role** (e.g. `{role: "future-role"}`) so a role-allowlist implementation cannot pass: `assistant(toolCall X) / <role> / toolResult(X)` sweeps X and reports it in `sweptIds`. The line-63 test is inverted to this.
- **AC2** - multi-call turn `assistant(X, Y) / toolResult(X) / custom / toolResult(Y)` sweeps only Y (barrier clears the remainder of the open set; `toolResult` itself is not a barrier - keep the existing consecutive-results control test).
- **AC3** - `assistant(toolCall X) / toolResult(X) / custom` is untouched and returns the **same array reference**.
- **AC4** - pi-condense's own output never trips the barrier, via the existing `pruneWithZeroSweepAssertion` / `expectZeroOrphanSweep` helpers (`src/test-support.ts:31,47`). Shape (a) - a compressed-chain fixture whose range contains a third-party `custom` message - already exists: `chain-range-prune.test.ts:789-793` ("a third-party custom message inside a range survives"), driven through `expectZeroOrphanSweep` at `:862`; it must stay green, no new fixture. Shape (b) - a `context-prune-summary` following a completed cycle sweeps zero - is genuinely missing and is the only new AC4 fixture; extend the nearest suite (`chain-range-prune.test.ts` zero-sweep table or `pruner.test.ts:941-1033`) rather than authoring a fresh harness. Structural reason the invariant holds: summaries are delivered via `deliverAs: "steer"` or at `turn_end`/final-assistant `message_end`, and `applyChainCompressions` inserts its synthetic strictly after a `user`-role `startIndex` and drops every in-range `assistant`/`toolResult` - no partial cycle survives to be split.
- **AC5** - one red-first test that directly exercises the helper's barrier reset: `expectNoOrphanToolResults([assistant(X), custom, toolResult(X)])` must throw (today it passes - the helper still uses the pre-fix rule, and none of its 9 existing call sites across `chain-range-prune.test.ts`, `range-compression.integration.test.ts`, `id-collision.integration.test.ts` contains a mid-cycle foreign message, so "staying green" alone proves nothing). Those 9 call sites additionally stay green.
- **AC6** - `bun test src/` green (baseline 467 pass / 0 fail).
Constraint: do not value-import or execute pi-ai's real `transformMessages` in the suite - it is reachable only via the `./api/*` subpath and nothing else in this repo value-imports pi-ai; wiring it in would let unrelated pi-ai version bumps red this repo's CI. The end-to-end evidence (fixture -> real `applyChainCompressions` -> sweep -> real `convertToLlm` -> real `transformMessages`) is roast-time manual verification, not automated.
## Out of scope
- `transformMessages` skips `stopReason: "error" | "aborted"` assistant messages before recording their tool calls, so their real tool results become provider-side orphans the sweep (before and after this fix) keeps. 0 corpus occurrences; no code. The predecessor spec claimed the sweep covers this path; the supersession banner's scope explicitly includes that claim.
- pi-cohort#7 (upstream steer-delivery fix) - separate repo, separate PR, no code dependency either way.
- `AGENTS.md` ground-truth pointer cites `pi-ai/dist/providers/transform-messages.js`; the actual path is `pi-ai/dist/api/transform-messages.js`. Known one-liner, not part of this ticket.
## Documentation impact
- Feature / user-facing docs introduced: none
- Materially amended existing docs: `PRUNING.md` (Orphan Sweep section - barrier rule, rationale, over-sweep trade-off)
- Derived / memory docs invalidated: `doc/specs/2026-08-12-toolcall-id-collisions.md` (supersession banner, section C barrier rule only)
## Open questions
None.