pi-condense 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
7
7
  Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
8
8
  publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
9
9
 
10
+ ## [2.14.0] - 2026-11-08
11
+
12
+ ### Changed
13
+
14
+ - The compiled-against pi packages (`@earendil-works/pi-ai`, `pi-coding-agent`, `pi-tui`) move from `^1.3.0` to `^1.5.0`; the runtime floor stays pi >= 1.3.0 (`streamSimple`).
15
+
16
+ ### Added
17
+
18
+ - Protected tool names and path globs also cover a result's recorded nested calls, including reads inside codemode scripts. Incomplete nested-call records keep the whole result inline verbatim even above the spill threshold and with path matching disabled; protected scripted reads are not superseded by later reads of the same path. (#27)
19
+ - Paid synthetic behavioral evals measure downstream context quality after summarization and pruning, with frozen committed baselines for both summarizers and an eval-needed decision gate for every repository change. Offline harness tests remain separate from paid runs. (#22)
20
+ - A private-session replay harness (`npm run eval:replay`) rebuilds real tool-call batches from session files into a corpus outside every checkout, summarizes each with production and a `--candidate` module, scores both against strings the assistant later reused, and fails on regressions; `matrix` compares model x thinking cells on retention, latency and cost. Synthetic eval reports now count evidence retained in the summary separately from evidence recovered on demand. (#23)
21
+
22
+ ## [2.13.1] - 2026-10-22
23
+
24
+ ### Changed
25
+
26
+ - The summarizer idle default is 45s instead of 20s, allowing queued providers longer before session-model takeover; the silent-primary wait is 45s on initial detection and each failed 10-minute probe. Explicit settings keep their values. (#21)
27
+ - Immediate session-model rescue notices include the primary failure reason, including idle/ceiling timeout or provider error text; deferred fallback notices remain unchanged. (#21)
28
+
10
29
  ## [2.13.0] - 2026-10-20
11
30
 
12
31
  ### Changed
package/PRUNING.md CHANGED
@@ -633,15 +633,17 @@ Implementation: `src/pruner.ts` `pruneMessages(messages, indexer)` returns `{ me
633
633
 
634
634
  ### Protected tools & paths
635
635
 
636
- A tool call is protected if **either** its `toolName` is in `protectedTools` **or** its `args.path` (string) matches any glob in `protectedPaths`. Protected calls are filtered out **at capture time** - they never enter the `pendingBatches` queue, so their raw `ToolResultMessage` stays verbatim in future LLM context until a later read of the same path covers its delivered lines and supersedes it (below).
636
+ A tool call is protected if its `toolName` is in `protectedTools`, its `args.path` (string) matches any glob in `protectedPaths`, or its result's recorded nested calls (`nestedCalls.calls`) include a name or path matching the same rules. This covers reads made inside codemode scripts and any other tool carrying `nestedCalls`, without a tool-name restriction. An incomplete nested-call record (`nestedCalls.complete === false`) protects the whole result unconditionally, even with both configuration lists empty. The recorder marks a record incomplete when arguments exceed 8 KiB per call or 32 KiB total, calls exceed 256, or a call is still running when the script ends; even a script that reads nothing protected can therefore stay verbatim. Nested call status does not affect matching.
637
+
638
+ Protected calls are filtered out **at capture time** - they never enter the `pendingBatches` queue, summarizer, new index entries, or oversized spill, so their raw `ToolResultMessage` stays inline verbatim even above `spillThreshold`. Inside newly compressed chains their outputs relocate verbatim as `<protected-output>` without blocking compression. Only direct reads can be superseded when a later read of the same path covers their delivered lines and the cadence gate allows it (below); a protected scripted read stays verbatim and is not stubbed by a later read of the same path.
637
639
 
638
640
  **`protectedTools: string[]`** (default `[]`) - allowlist of tool names. Covers tools whose output is a small handle that must be reused byte-for-byte (e.g. a session-id) or planning tools like `todowrite` / `todoread`.
639
641
 
640
- **`protectedPaths: string[]`** (default `["**/skills/**/*.md", "**/gauntlet-overrides.md"]`) - glob list matched against `args.path`. Designed for skill files that carry multi-step workflow gates; summarizing them is categorically lossy. Non-string or missing `path` arguments never match. Set `[]` to disable. Edit with `/pruner protected-paths`.
642
+ **`protectedPaths: string[]`** (default `["**/skills/**/*.md", "**/gauntlet-overrides.md"]`) - glob list matched against direct and recorded nested `args.path`. Designed for skill files that carry multi-step workflow gates; summarizing them is categorically lossy. Non-string or missing `path` arguments never match. Set `[]` to disable path matching only; nested tool-name matches and incomplete-record protection remain active. Edit with `/pruner protected-paths`.
641
643
 
642
644
  Glob contract: full-path match against the raw `args.path` string with `\` normalized to `/`. `*` and `?` match within a segment (no `/`); `**` crosses segments; `**/` also matches zero directories (so `**/SKILL.md` matches a bare relative `SKILL.md`). Case-sensitive. All other characters are regex-escaped literals.
643
645
 
644
- **Render-time re-check:** stub replacement runs in-flight on every turn (`pruneMessages`). If a tool call's persisted `args` now satisfy `isProtected` (e.g. a pattern was added mid-session), the stub is skipped and the raw result is left verbatim - this repairs already-summarized records in existing sessions with no schema change. Declared limitation: records inside already-compressed chains (`context-prune-chain` entries) are NOT repaired - their `protectedToolCallIds` set is fixed at compression time (forward-only). Dedup-alias edge: an alias resolving to an unprotected original stays stubbed.
646
+ **Render-time re-check:** stub replacement runs in-flight on every turn (`pruneMessages`). If a tool call's persisted name and `args`, or its live result's `nestedCalls`, now satisfy `isProtected` (e.g. a pattern was added mid-session or a scripted read was summarized before nested-call protection), the stub is skipped and the raw result is left verbatim. The stored index entry stays but is not applied; this repairs already-summarized records in existing sessions with no schema change. Declared limitation: records inside already-compressed chains (`context-prune-chain` entries) are NOT repaired - their `protectedToolCallIds` set is fixed at compression time (forward-only). Dedup-alias edge: an alias resolving to an unprotected original stays stubbed unless its own live result carries protecting `nestedCalls`, and its own `args.path` is not re-checked.
645
647
 
646
648
  **Supersession (phase 1b, `src/supersede.ts`):** protected calls are never indexed, so content-hash dedup never sees them. A paired occurrence participates only when `isProtected(toolName, args)` is true and `typeof args.path === "string"`. Occurrences group by normalized `args.path` (backslash -> slash). An earlier occurrence is a supersession candidate only when **any later occurrence** of that path covers its delivered line range, not just when the last occurrence covers it. Different paths never interact; disjoint chunks stay verbatim.
647
649
 
package/README.md CHANGED
@@ -172,7 +172,7 @@ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_A
172
172
  | `autoBudgetThreshold` | `null` | Fraction (e.g. `0.8`) of the context window that force-flushes everything regardless of `pruneOn`; the trigger point is capped at 300k tokens |
173
173
  | `frontierGapThresholdTokens` | `null` | Opt-in absolute-token flush trigger: fires at `turn_end` once the un-pruned tail past the prune frontier reaches N tokens, regardless of window size; recommended starting value `80000` |
174
174
  | `maxImagesPerRequest` | `null` | Once a request carries more than N images, the oldest become a text note (in cache-friendly steps of half of N) so a long session stays under a provider's per-request image limit; applies even with pruning off. `null` = built-in limit for the model's API (Anthropic Messages: 100; others: no cap); a number overrides it for every API |
175
- | `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md", "**/gauntlet-overrides.md"]` | Tool names / path globs that are never summarized; only the newest read per protected path stays verbatim (older reads of the same path are stubbed once the prompt cache is cold anyway) |
175
+ | `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md", "**/gauntlet-overrides.md"]` | Tool names / path globs that bypass summarization, including reads made inside codemode scripts through the result's recorded nested calls (`nestedCalls`). A protected scripted read stays verbatim and is not stubbed by a later read of the same path; only direct reads can supersede earlier reads when their delivered lines cover them and the cache-rewrite cadence allows it. An incomplete nested-call record (over 8 KiB of arguments per call, 32 KiB total, 256 calls, or a call still running when the script ends) keeps the whole result inline verbatim regardless of `protectedPaths`, even above `spillThreshold`. `protectedPaths: []` disables path matching only |
176
176
  | `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
177
177
 
178
178
  The default also protects reads of [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet)'s per-repo `gauntlet-overrides.md` so the repo's harness contract stays available for gate decisions after pruning.
@@ -191,6 +191,8 @@ No committed roadmap beyond what's already tracked in [CHANGELOG.md](CHANGELOG.m
191
191
 
192
192
  See [CONTRIBUTING.md](CONTRIBUTING.md) - issues follow a Context / Problem / Idea / Acceptance Criteria template; PRs run the [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) workflow (one-liners exempt from ceremony, never from keeping docs truthful).
193
193
 
194
+ For paid synthetic context-quality checks and retained behavioral baselines, see the [behavioral eval guide](eval/README.md).
195
+
194
196
  ## Support
195
197
 
196
198
  If this saves you tokens, [buy me a coffee](https://buymeacoffee.com/jjurasszek).
package/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * Usage: pi -e .
14
14
  */
15
15
 
16
+ import type { NestedToolCalls } from "@earendil-works/pi-ai";
16
17
  import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
17
18
  import { loadConfig } from "./src/config.js";
18
19
  import { capImages, imageLimitFor } from "./src/image-cap.js";
@@ -62,7 +63,7 @@ export default function (pi: ExtensionAPI) {
62
63
  value: { ...DEFAULT_CONFIG },
63
64
  };
64
65
 
65
- const protectionPredicate = (name: string, args: unknown) => isProtected(name, args, currentConfig.value);
66
+ const protectionPredicate = (name: string, args: unknown, nested?: NestedToolCalls) => isProtected(name, args, currentConfig.value, nested);
66
67
 
67
68
  // Shared indexer — rebuilt from session on every session_start / session_tree
68
69
  const indexer = new ToolCallIndexer();
@@ -926,7 +927,7 @@ export default function (pi: ExtensionAPI) {
926
927
  // CapturedBatch is pruned, which is exactly what we want.
927
928
  const filtered = {
928
929
  ...capturedBatch,
929
- toolCalls: capturedBatch.toolCalls.filter((tc) => !isProtected(tc.toolName, tc.args, currentConfig.value)),
930
+ toolCalls: capturedBatch.toolCalls.filter((tc) => !protectionPredicate(tc.toolName, tc.args, tc.nestedCalls)),
930
931
  };
931
932
 
932
933
  // Eager spill: offload oversized single results to sidecar files before they
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -38,7 +38,10 @@
38
38
  ],
39
39
  "scripts": {
40
40
  "check:agents-core": "node scripts/check-agents-core.mjs",
41
- "test": "bun test src/"
41
+ "test": "bun test src/",
42
+ "eval": "bun eval/run.ts",
43
+ "eval:replay": "bun eval/replay/run.ts",
44
+ "test:eval": "bun test eval/"
42
45
  },
43
46
  "pi": {
44
47
  "extensions": [
@@ -57,9 +60,9 @@
57
60
  "@sinclair/typebox": "*"
58
61
  },
59
62
  "devDependencies": {
60
- "@earendil-works/pi-ai": "^1.3.0",
61
- "@earendil-works/pi-coding-agent": "^1.3.0",
62
- "@earendil-works/pi-tui": "^1.3.0",
63
+ "@earendil-works/pi-ai": "^1.5.0",
64
+ "@earendil-works/pi-coding-agent": "^1.5.0",
65
+ "@earendil-works/pi-tui": "^1.5.0",
63
66
  "@sinclair/typebox": "^0.34.52"
64
67
  }
65
68
  }
@@ -11,6 +11,26 @@ import {
11
11
  } from "./batch-capture.js";
12
12
  import type { CapturedBatch, CapturedToolCall } from "./types.js";
13
13
  import { ToolCallIndexer } from "./indexer.js";
14
+ import { isProtected } from "./protected.js";
15
+ import { DEFAULT_CONFIG } from "./types.js";
16
+
17
+ test("nested protection uses each turn's first paired result and preserves capture metadata", () => {
18
+ const nestedCalls = { complete: true, calls: [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" as const }] };
19
+ const assistant = { role: "assistant", content: [{ type: "toolCall", id: "same", name: "codemode", arguments: { code: "read()" } }] };
20
+ const result = (timestamp: number, nested?: typeof nestedCalls) => ({ role: "toolResult", toolCallId: "same", toolName: "codemode", timestamp, content: [{ type: "text", text: "body" }], ...(nested ? { nestedCalls: nested } : {}) });
21
+ const protectedResult = result(1, nestedCalls);
22
+ const plainResult = result(2);
23
+ const captured = captureBatch(assistant, [protectedResult, plainResult], 0, 0);
24
+ expect(captured.toolCalls[0].nestedCalls).toBe(nestedCalls);
25
+ expect(captureBatch(assistant, [plainResult], 1, 0).toolCalls[0]).not.toHaveProperty("nestedCalls");
26
+ expect(serializeBatchForSummarizer(captured)).not.toContain("nestedCalls");
27
+ const branch = [assistant, protectedResult, plainResult, assistant, plainResult].map(message => ({ type: "message", message }));
28
+ const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false },
29
+ (name, args, nested) => isProtected(name, args, DEFAULT_CONFIG, nested));
30
+ expect(batches).toHaveLength(1);
31
+ expect(batches[0].turnIndex).toBe(1);
32
+ expect(batches[0].toolCalls[0].resultTimestamp).toBe(2);
33
+ });
14
34
 
15
35
  function toolCall(overrides: Partial<CapturedToolCall> = {}): CapturedToolCall {
16
36
  return {
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import type { NestedToolCalls } from "@earendil-works/pi-ai";
2
3
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
4
  import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
4
5
  import { occKey, resultTimestampOf } from "./occurrence-key.js";
@@ -111,6 +112,7 @@ export function captureBatch(
111
112
  resultText,
112
113
  isError,
113
114
  ...(resultTimestamp !== undefined ? { resultTimestamp } : {}),
115
+ ...(match?.nestedCalls !== undefined ? { nestedCalls: match.nestedCalls } : {}),
114
116
  } satisfies CapturedToolCall;
115
117
  });
116
118
 
@@ -128,7 +130,7 @@ export function captureBatch(
128
130
  export function captureUnindexedBatchesFromSession(
129
131
  branch: any[],
130
132
  indexer: { isSummarized(id: string): boolean },
131
- exclude: (toolName: string, args: unknown) => boolean = () => false
133
+ exclude: (toolName: string, args: unknown, nested?: NestedToolCalls) => boolean = () => false
132
134
  ): CapturedBatch[] {
133
135
  // Keep the SessionEntry wrapper alongside each projected message so the
134
136
  // entry's own timestamp remains available as the preferred source below
@@ -191,7 +193,7 @@ export function captureUnindexedBatchesFromSession(
191
193
  const result = turnResults.get(id);
192
194
  if (!result) return false;
193
195
  if (indexer.isSummarized(occKey(id, resultTimestampOf(result.timestamp)))) return false;
194
- if (exclude(tc.name, tc.input ?? tc.arguments)) return false;
196
+ if (exclude(tc.name, tc.input ?? tc.arguments, result.nestedCalls)) return false;
195
197
  return true;
196
198
  });
197
199
 
@@ -1,5 +1,21 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { detectChains, isChainAnchorCustom, withClosingMessage } from "./chain-detector.js";
3
+ import { isProtected } from "./protected.js";
4
+ import { DEFAULT_CONFIG } from "./types.js";
5
+
6
+ test("nested protected reads join a closed chain's protected ids", () => {
7
+ const messages = [
8
+ userMsg(1),
9
+ { role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "script", name: "codemode", arguments: {} }] },
10
+ { role: "toolResult", timestamp: 3, toolCallId: "script", toolName: "codemode", nestedCalls: {
11
+ complete: true, calls: [{ id: "read", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }],
12
+ } },
13
+ { role: "assistant", timestamp: 4, content: [{ type: "text", text: "done" }] },
14
+ ];
15
+ const [chain] = detectChains(messages, (name, args, nested) => isProtected(name, args, DEFAULT_CONFIG, nested));
16
+ expect(chain.protectedToolCallIds).toEqual(["script"]);
17
+ expect(chain.finalAssistantTimestamp).toBe(4);
18
+ });
3
19
 
4
20
  const custom = (customType: string, timestamp: number) => ({ role: "custom", customType, timestamp });
5
21
 
@@ -1,3 +1,4 @@
1
+ import type { NestedToolCalls } from "@earendil-works/pi-ai";
1
2
  import { occKey, resultTimestampOf } from "./occurrence-key.js";
2
3
  import type { ChainRange } from "./types.js";
3
4
 
@@ -45,12 +46,12 @@ type State = "idle" | "inChain";
45
46
  * NOTE: Message identity uses `timestamp` (for user / final text-only assistant) and
46
47
  * `toolCallId` sets (for middle tool-using turns). AgentMessage has no `.id` field.
47
48
  *
48
- * @param isProtected Predicate over (toolName, args); matching calls are never pruned
49
+ * @param isProtected Predicate over (toolName, args, nestedCalls); matching calls are never pruned
49
50
  * and their outputs are relocated verbatim into compressed chains.
50
51
  */
51
52
  export function detectChains(
52
53
  messages: any[],
53
- isProtected: (toolName: string, args: unknown) => boolean = () => false,
54
+ isProtected: (toolName: string, args: unknown, nested?: NestedToolCalls) => boolean = () => false,
54
55
  ): ChainRange[] {
55
56
  const ranges: ChainRange[] = [];
56
57
  let state: State = "idle";
@@ -108,9 +109,8 @@ export function detectChains(
108
109
  if (msg.toolCallId) {
109
110
  middleIds.add(msg.toolCallId);
110
111
  middleKeys.add(occKey(msg.toolCallId, resultTimestampOf(msg.timestamp)));
111
- // toolResult fallback — results carry no args; name-only by design,
112
- // the assistant block always precedes its result so no protection is lost
113
- if (isProtected(msg.toolName, undefined)) protectedIds.add(msg.toolCallId);
112
+ // Outer args live on the assistant block; nested calls live on the result.
113
+ if (isProtected(msg.toolName, undefined, msg.nestedCalls)) protectedIds.add(msg.toolCallId);
114
114
  }
115
115
  continue;
116
116
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, mock } from "bun:test";
2
- import { mkdtempSync } from "node:fs";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
@@ -9,7 +9,7 @@ import { join } from "node:path";
9
9
  process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-condense-commands-test-"));
10
10
 
11
11
  import { pruneStatusText, setPruneStatusWidget, registerCommands } from "./commands.js";
12
- import { settingsPath } from "./config.js";
12
+ import { settingsPath, loadConfig } from "./config.js";
13
13
  import type { ContextPruneConfig, ContextMetricsSnapshot, SummarizerStats } from "./types.js";
14
14
  import { DEFAULT_CONFIG } from "./types.js";
15
15
  import { Container, SettingsList } from "@earendil-works/pi-tui";
@@ -97,13 +97,14 @@ function setupPrunerCommand(overrides: {
97
97
  };
98
98
  }
99
99
 
100
- describe("/pruner settings argument purge", () => {
100
+ describe("/pruner settings descriptions", () => {
101
101
  initTheme("dark");
102
102
 
103
103
  it.each([
104
104
  ["purgeErrorsEnabled", "Argument purge", "Shrink long strings in summarized calls when their results are stubbed in the same render, and in errored calls after the error cooldown, keeping argument shape."],
105
105
  ["purgeErrorsCooldown", "Error purge cooldown (turns)", "Wait this many turns after a tool error before purging its argument body."],
106
106
  ["purgeErrorsMinArgChars", "Argument purge min arg chars", "Only purge summarized or errored argument bodies at least this many chars."],
107
+ ["protectedPaths", "Protected paths", "reads made inside codemode scripts"],
107
108
  ])("describes %s through the settings overlay", async (id, label, description) => {
108
109
  const harness = setupPrunerCommand({ save: async () => {} });
109
110
  await harness.run("settings");
@@ -114,6 +115,11 @@ describe("/pruner settings argument purge", () => {
114
115
  expect(rendered).toContain(label);
115
116
  expect(rendered).toContain(description);
116
117
  if (id === "purgeErrorsCooldown") expect(rendered).not.toContain("same render");
118
+ if (id === "protectedPaths") {
119
+ expect(rendered).toContain("nestedCalls");
120
+ expect(rendered).toContain("incomplete nested-call record is kept verbatim regardless of this setting");
121
+ expect(rendered).toContain("Set to 'none' to disable path matching only");
122
+ }
117
123
  });
118
124
 
119
125
  it("persists the shared disabled toggle and minimum using only existing purgeErrors fields", async () => {
@@ -148,6 +154,23 @@ describe("/pruner now (empty capture)", () => {
148
154
  });
149
155
 
150
156
  describe("/pruner status context block", () => {
157
+ it("reports the resolved 45s idle default when the setting is absent", async () => {
158
+ const scratchDir = mkdtempSync(join(tmpdir(), "pi-condense-idle-status-"));
159
+ const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
160
+ try {
161
+ process.env.PI_CODING_AGENT_DIR = scratchDir;
162
+ writeFileSync(settingsPath(), JSON.stringify({ contextPrune: { enabled: true } }));
163
+ const harness = setupPrunerCommand();
164
+ harness.currentConfig.value = await loadConfig();
165
+ await harness.run("status");
166
+ expect(harness.notifications[0]?.message).toContain("idle to: 45s");
167
+ } finally {
168
+ if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
169
+ else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
170
+ rmSync(scratchDir, { recursive: true, force: true });
171
+ }
172
+ });
173
+
151
174
  const metrics: ContextMetricsSnapshot = {
152
175
  openCycleThinkingTokens: 12000,
153
176
  largestChainSharePct: 62,
package/src/commands.ts CHANGED
@@ -256,7 +256,7 @@ function protectedToolsDescription(config: ContextPruneConfig): string {
256
256
  }
257
257
 
258
258
  function protectedPathsDescription(config: ContextPruneConfig): string {
259
- return `Glob patterns matched against a tool call's \`args.path\`; matching outputs are NEVER pruned. Currently: ${protectedToolsDisplay(config.protectedPaths)}. Edit via \`/pruner protected-paths\` (interactive) or \`/pruner protected-paths <comma-separated globs>\`. Set to 'none' to disable (kill switch). Default protects skill files and per-repo gauntlet overrides: **/skills/**/*.md, **/gauntlet-overrides.md`;
259
+ return `Glob patterns match a tool call's \`args.path\` and reads recorded in a result's \`nestedCalls\`, including reads made inside codemode scripts. Matching outputs bypass summarization. An incomplete nested-call record is kept verbatim regardless of this setting. Set to 'none' to disable path matching only. Currently: ${protectedToolsDisplay(config.protectedPaths)}. Edit via \`/pruner protected-paths\` (interactive) or \`/pruner protected-paths <comma-separated globs>\`. Default protects skill files and per-repo gauntlet overrides: **/skills/**/*.md, **/gauntlet-overrides.md`;
260
260
  }
261
261
 
262
262
  const HELP_TEXT = `pruner — automatically summarizes tool-call outputs to keep context lean.
@@ -99,8 +99,15 @@ describe("loadConfig summarizer timeout normalization", () => {
99
99
  it("defaults both timeouts when absent", async () => {
100
100
  await writeContextPrune({});
101
101
  const config = await loadConfig();
102
- expect(config.summarizerIdleTimeoutMs).toBe(DEFAULT_CONFIG.summarizerIdleTimeoutMs);
102
+ expect(config.summarizerIdleTimeoutMs).toBe(45000);
103
103
  expect(config.summarizerMaxTimeoutMs).toBe(DEFAULT_CONFIG.summarizerMaxTimeoutMs);
104
+ expect(config.summarizerMaxTimeoutMs).toBe(180000);
105
+ });
106
+
107
+ it("preserves an explicit legacy 20s idle timeout", async () => {
108
+ await writeContextPrune({ summarizerIdleTimeoutMs: 20000 });
109
+ const config = await loadConfig();
110
+ expect(config.summarizerIdleTimeoutMs).toBe(20000);
104
111
  });
105
112
 
106
113
  it("preserves explicit 0 (disabled) for both", async () => {
@@ -1,6 +1,16 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { computeContextMetrics } from "./context-metrics.js";
3
- import type { PruneFrontier } from "./types.js";
3
+ import { DEFAULT_CONFIG, type PruneFrontier } from "./types.js";
4
+ import { isProtected } from "./protected.js";
5
+
6
+ test("frontier gap excludes a result with a protected nested read", () => {
7
+ const messages = [userMsg(1), assistantWithTools(2, ["script"]), {
8
+ ...toolResult(3, "script", "codemode"),
9
+ nestedCalls: { complete: true, calls: [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }] },
10
+ }];
11
+ expect(computeContextMetrics(messages, null, () => false,
12
+ (name, args, nested) => isProtected(name, args, DEFAULT_CONFIG, nested)).frontierGapTokens).toBe(0);
13
+ });
4
14
 
5
15
  // ── Minimal message factories (mirrors src/chain-detector.test.ts style) ───
6
16
 
@@ -1,3 +1,4 @@
1
+ import type { NestedToolCalls } from "@earendil-works/pi-ai";
1
2
  import { detectChains, isChainAnchorCustom } from "./chain-detector.js";
2
3
  import { occKey, resultTimestampOf } from "./occurrence-key.js";
3
4
  import type { ContextMetricsSnapshot, PruneFrontier } from "./types.js";
@@ -49,7 +50,7 @@ export function computeContextMetrics(
49
50
  branch: any[],
50
51
  frontier: PruneFrontier | null,
51
52
  isSummarized: (occurrenceKey: string) => boolean,
52
- isProtected: (toolName: string, args: unknown) => boolean,
53
+ isProtected: (toolName: string, args: unknown, nested?: NestedToolCalls) => boolean,
53
54
  ): ContextMetricsSnapshot {
54
55
  if (branch.length === 0) {
55
56
  return { openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 };
@@ -156,7 +157,7 @@ export function computeContextMetrics(
156
157
  const key = occKey(m.toolCallId, resultTimestampOf(m.timestamp));
157
158
  if (isSummarized(key)) continue;
158
159
  const args = findArgsForToolCallId(branch, i, m.toolCallId);
159
- if (isProtected(m.toolName, args)) continue;
160
+ if (isProtected(m.toolName, args, m.nestedCalls)) continue;
160
161
  frontierGapTokens += tokensOf(m);
161
162
  }
162
163
 
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { NestedToolCalls } from "@earendil-works/pi-ai";
2
3
  import { globToRegExp, isProtected, normalizePath } from "./protected.js";
3
4
  import { DEFAULT_CONFIG } from "./types.js";
4
5
 
@@ -67,6 +68,46 @@ describe("isProtected", () => {
67
68
  expect(isProtected("read", { path: "/h/src/app.ts" }, cfg)).toBe(false);
68
69
  });
69
70
 
71
+ test.each(["ok", "error"] as const)("protects nested skill reads with status %s", (status) => {
72
+ expect(isProtected("codemode", { code: "console.log(await tools.read({ path: 'skills/x/SKILL.md' }))" }, cfg, {
73
+ complete: true,
74
+ calls: [{ id: "nested-read", name: "read", arguments: { path: "skills/x/SKILL.md" }, status }],
75
+ })).toBe(true);
76
+ });
77
+
78
+ test.each([undefined, {}, { path: "source.ts" }])("protects nested tool names with arguments %j and path matching disabled", (args) => {
79
+ for (const outerName of ["codemode", "another-script-tool"]) {
80
+ expect(isProtected(outerName, {}, { protectedTools: ["todowrite"], protectedPaths: [] }, {
81
+ complete: true, calls: [{ id: "nested", name: "todowrite", arguments: args, status: "ok" }],
82
+ })).toBe(true);
83
+ }
84
+ });
85
+
86
+ test.each([
87
+ { complete: false, calls: [] },
88
+ { complete: false, calls: [{ id: "write", name: "write", arguments: { path: "source.ts" }, status: "ok" }] },
89
+ { complete: false, calls: [{ id: "read", name: "read", argumentsBytes: 8193, status: "ok" }] },
90
+ { complete: false, calls: [{ id: "read", name: "read", arguments: { path: "source.ts" }, status: "unfinished" }] },
91
+ ] satisfies NestedToolCalls[])("protects incomplete records %j even with empty configuration", (nested) => {
92
+ for (const config of [cfg, { protectedTools: [], protectedPaths: [] }]) {
93
+ expect(isProtected("codemode", {}, config, nested)).toBe(true);
94
+ }
95
+ });
96
+
97
+ test.each([
98
+ undefined,
99
+ { complete: true, calls: [] },
100
+ { complete: true, calls: [{ id: "read", name: "read", arguments: { path: "source.ts" }, status: "ok" }] },
101
+ { complete: true, calls: [{ id: "read", name: "read", arguments: { path: 42 }, status: "ok" }] },
102
+ "malformed",
103
+ { complete: false, calls: {} },
104
+ ])("keeps unmatched or malformed nested records %j prunable without changing direct protection", (nested) => {
105
+ const record = nested as NestedToolCalls | undefined;
106
+ expect(isProtected("codemode", { code: "tools.read({path: 'skills/x/SKILL.md'})" }, cfg, record)).toBe(false);
107
+ expect(isProtected("todowrite", {}, cfg, record)).toBe(true);
108
+ expect(isProtected("read", { path: "skills/x/SKILL.md" }, cfg, record)).toBe(true);
109
+ });
110
+
70
111
  test("backslashes normalized to forward slashes", () => {
71
112
  expect(isProtected("read", { path: "h\\skills\\x\\SKILL.md" }, cfg)).toBe(true);
72
113
  });
package/src/protected.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { NestedToolCalls } from "@earendil-works/pi-ai";
2
+
1
3
  /** Structural pick of ContextPruneConfig — keeps this module dependency-free. */
2
4
  export interface ProtectionConfig {
3
5
  protectedTools: readonly string[];
@@ -46,7 +48,7 @@ export function normalizePath(path: string): string {
46
48
  return path.replace(/\\/g, "/");
47
49
  }
48
50
 
49
- export function isProtected(toolName: string, args: unknown, config: ProtectionConfig): boolean {
51
+ function matches(toolName: string, args: unknown, config: ProtectionConfig): boolean {
50
52
  if (config.protectedTools.includes(toolName)) return true;
51
53
  if (config.protectedPaths.length === 0) return false;
52
54
  const path = (args as Record<string, unknown> | null | undefined)?.path;
@@ -54,3 +56,10 @@ export function isProtected(toolName: string, args: unknown, config: ProtectionC
54
56
  const normalized = normalizePath(path);
55
57
  return config.protectedPaths.some((p) => globToRegExp(p).test(normalized));
56
58
  }
59
+
60
+ export function isProtected(toolName: string, args: unknown, config: ProtectionConfig, nested?: NestedToolCalls): boolean {
61
+ if (matches(toolName, args, config)) return true;
62
+ if (!nested || typeof nested !== "object" || !Array.isArray(nested.calls)) return false;
63
+ if (nested.complete === false) return true;
64
+ return nested.calls.some((call) => matches(call?.name, call?.arguments, config));
65
+ }
@@ -599,6 +599,27 @@ describe("render-time protection re-check", () => {
599
599
  expect(messages[1].content[0].text).toBe("FULL SKILL BODY");
600
600
  });
601
601
 
602
+ it("repairs an indexed script result from nested metadata with byte-identical repeat renders", () => {
603
+ const record = { toolCallId: "script", toolName: "codemode", args: { code: "read()" }, resultText: "FULL SKILL BODY", isError: false, turnIndex: 0, timestamp: 10 };
604
+ const oldIndexer = makeMockIndexer({
605
+ summarized: new Set(["script"]), shortRefs: new Map([["script", "t1"]]), records: new Map([["script", record]]),
606
+ });
607
+ const raw = [
608
+ { ...skillAsst, content: [{ type: "toolCall", id: "script", name: "codemode", arguments: record.args }] },
609
+ { ...skillMsg, toolCallId: "script", toolName: "codemode", nestedCalls: {
610
+ complete: true, calls: [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }],
611
+ } },
612
+ ];
613
+ const before = JSON.stringify(raw);
614
+ const render = () => pruneMessages(raw, oldIndexer as any, undefined, undefined,
615
+ { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] });
616
+ const first = render();
617
+ expect(first.pruned).toBe(false);
618
+ expect(JSON.stringify(first.messages)).toBe(before);
619
+ expect(JSON.stringify(render().messages)).toBe(JSON.stringify(first.messages));
620
+ expect(oldIndexer.getRecord("script")).toBe(record);
621
+ });
622
+
602
623
  it("still stubs when no protection config is passed", () => {
603
624
  const { messages, pruned } = pruneMessages([skillAsst, skillMsg], indexer as any);
604
625
  expect(pruned).toBe(true);
package/src/pruner.ts CHANGED
@@ -116,7 +116,7 @@ export function pruneMessages(
116
116
  // session JSONL, so skipping the stub restores it verbatim.
117
117
  // Dedup aliases resolve to the original record, so an alias whose own
118
118
  // path is protected but whose original isn't stays stubbed (edge case).
119
- if (protection && record && isProtected(record.toolName, record.args, protection)) {
119
+ if (protection && record && isProtected(record.toolName, record.args, protection, msg.nestedCalls)) {
120
120
  return msg;
121
121
  }
122
122
  if (inGrace.has(key)) {
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { supersededStub } from "./supersede.js";
6
+ import { DEFAULT_CONFIG } from "./types.js";
6
7
 
7
8
  // This must run before any module that transitively reads PI_CODING_AGENT_DIR
8
9
  // (src/config.ts's getAgentDir()) is imported/executed.
@@ -323,6 +324,47 @@ async function boot(options?: Parameters<typeof bootExtension>[0]) {
323
324
  return harness;
324
325
  }
325
326
 
327
+ describe("nested protection capture", () => {
328
+ it.each([
329
+ { mode: "live", incomplete: false },
330
+ { mode: "rescan", incomplete: false },
331
+ { mode: "live", incomplete: true },
332
+ { mode: "rescan", incomplete: true },
333
+ ])("%j excludes protected script output from summarizer and index", async ({ mode, incomplete }) => {
334
+ const branch: any[] = [];
335
+ const { handlers, ctx, appended } = await boot({ branch, ...(incomplete ? { protectedPaths: [] } : {}) });
336
+ await handlers.get("session_start")!({}, ctx);
337
+ const message = { role: "assistant", content: [
338
+ { type: "toolCall", id: "script", name: "codemode", arguments: { code: "read()" } },
339
+ { type: "toolCall", id: "control", name: "read", arguments: { path: "source.ts" } },
340
+ ] };
341
+ const toolResults = [
342
+ { role: "toolResult", toolCallId: "script", toolName: "codemode", timestamp: 10, content: [{ type: "text", text: "PROTECTED-SCRIPT-BODY".repeat(incomplete ? Math.ceil(DEFAULT_CONFIG.spillThreshold / "PROTECTED-SCRIPT-BODY".length) + 1 : 200) }], nestedCalls: {
343
+ complete: !incomplete, calls: incomplete ? [] : [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }],
344
+ } },
345
+ { role: "toolResult", toolCallId: "control", toolName: "read", timestamp: 11, content: [{ type: "text", text: "UNPROTECTED-CONTROL".repeat(200) }] },
346
+ ];
347
+ branch.push(...[message, ...toolResults].map(message => ({ type: "message", message })));
348
+ ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
349
+ const inputs: string[] = [];
350
+ const previousStream = streamImpl;
351
+ streamImpl = (_model, input) => { inputs.push(JSON.stringify(input)); return okStream(); };
352
+ try {
353
+ await handlers.get("turn_end")!({ message, toolResults, turnIndex: 0 }, ctx);
354
+ if (mode === "live") ctx.sessionManager.getBranch = () => { throw new Error("force pending live batch"); };
355
+ await handlers.get("message_end")!({ message: { role: "assistant", content: [{ type: "text", text: "done" }] } }, ctx);
356
+ expect(inputs).toHaveLength(1);
357
+ expect(inputs[0]).toContain("UNPROTECTED-CONTROL");
358
+ expect(inputs[0]).not.toContain("PROTECTED-SCRIPT-BODY");
359
+ const indexed = appended.filter(entry => entry.type === "context-prune-index")
360
+ .flatMap(entry => (entry.data as any).toolCalls.map((call: any) => call.toolCallId));
361
+ expect(indexed).toEqual(["control"]);
362
+ } finally {
363
+ streamImpl = previousStream;
364
+ }
365
+ });
366
+ });
367
+
326
368
  describe("reload rearm (issue #6)", () => {
327
369
  it("hides the startup widget when the prune status line is disabled", async () => {
328
370
  const { handlers, ctx, widgets } = await boot({ showPruneStatusLine: false });
@@ -4,7 +4,7 @@ let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
4
4
  };
5
5
  const { summarizeBatch, summarizeRange } = await import("./summarizer.js");
6
6
  const { FallbackController } = await import("./summarizer-fallback.js");
7
- const { DEFAULT_CONFIG } = await import("./types.js");
7
+ const { DEFAULT_CONFIG, SUMMARIZER_IDLE_TIMEOUT_PRESETS } = await import("./types.js");
8
8
 
9
9
  const PRIMARY = { id: "primary-model", provider: "provider-a", name: "Primary", api: "openai-responses" };
10
10
  const SESSION = { id: "session-model", provider: "provider-b", name: "Session", api: "openai-responses" };
@@ -161,6 +161,7 @@ describe("virtual models through the registry", () => {
161
161
  expect(result?.summaryText).toBe("- routed summary");
162
162
  expect(controller.inFallback).toBe(true);
163
163
  expect(seen[1]).toBe(VIRTUAL);
164
+ expect(notes[0]).toEqual({ msg: "pi-condense: summarizer model Primary failing (down), using session model Auto until it recovers", level: "warning" });
164
165
  expect(notes.filter(n => n.level === "warning")).toHaveLength(1);
165
166
  expect(notes.filter(n => n.level === "error")).toHaveLength(0);
166
167
  });
@@ -300,6 +301,7 @@ describe("runSummarization wiring — both-down + deferred warning", () => {
300
301
  const r2 = await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller });
301
302
  expect(r2?.summaryText).toBe("- rescued");
302
303
  expect(notes.filter((n) => n.level === "warning")).toHaveLength(1);
304
+ expect(notes[0].msg).toBe("pi-condense: summarizer model Primary failing, using session model Session until it recovers");
303
305
  });
304
306
  });
305
307
 
@@ -319,6 +321,7 @@ describe("runSummarization wiring — abort", () => {
319
321
 
320
322
  describe("runSummarization wiring — timeouts", () => {
321
323
  for (const [kind, idleMs, maxMs, message] of [
324
+ // Millisecond-scale overrides exercise timer behavior, not the default.
322
325
  ["idle", 20, 0, /stalled/],
323
326
  ["ceiling", 0, 20, /ceiling/],
324
327
  ] as const) {
@@ -379,7 +382,7 @@ describe("runSummarization wiring — timeouts", () => {
379
382
  const r = await summarizeBatch(makeBatch(), cfg, ctx, { controller });
380
383
  expect(r?.summaryText).toBe("- rescued");
381
384
  expect(controller.inFallback).toBe(true);
382
- expect(notes.filter((n) => n.level === "warning")).toHaveLength(1); // generic "enter" fallback warning
385
+ expect(notes.filter((n) => n.level === "warning")).toHaveLength(1);
383
386
  expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
384
387
  });
385
388
 
@@ -430,3 +433,235 @@ describe("runSummarization wiring — idle reset keeps a flowing stream alive",
430
433
  expect(notes.filter((n) => n.level === "warning")).toHaveLength(0);
431
434
  });
432
435
  });
436
+
437
+ async function withTimerClock(
438
+ run: (clock: { advance(ms: number): Promise<void>; now(): number }) => Promise<void>,
439
+ ) {
440
+ const realSetTimeout = globalThis.setTimeout;
441
+ const realClearTimeout = globalThis.clearTimeout;
442
+ const timers = new Map<number, { at: number; fire(): void }>();
443
+ let now = 0;
444
+ let nextId = 0;
445
+ const drain = () => new Promise<void>((resolve) => realSetTimeout(resolve, 0));
446
+ globalThis.setTimeout = ((callback: (...args: any[]) => void, delay = 0, ...args: any[]) => {
447
+ const id = ++nextId;
448
+ timers.set(id, { at: now + Number(delay), fire: () => callback(...args) });
449
+ return id;
450
+ }) as unknown as typeof globalThis.setTimeout;
451
+ globalThis.clearTimeout = ((id: any) => { timers.delete(Number(id)); }) as unknown as typeof globalThis.clearTimeout;
452
+ try {
453
+ await run({
454
+ now: () => now,
455
+ async advance(ms) {
456
+ await drain();
457
+ const end = now + ms;
458
+ while (true) {
459
+ const next = [...timers.entries()]
460
+ .filter(([, timer]) => timer.at <= end)
461
+ .sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
462
+ if (!next) break;
463
+ now = next[1].at;
464
+ timers.delete(next[0]);
465
+ next[1].fire();
466
+ await drain();
467
+ }
468
+ now = end;
469
+ await drain();
470
+ },
471
+ });
472
+ } finally {
473
+ globalThis.setTimeout = realSetTimeout;
474
+ globalThis.clearTimeout = realClearTimeout;
475
+ timers.clear();
476
+ }
477
+ }
478
+
479
+ const genericEnter = "pi-condense: summarizer model Primary failing, using session model Session until it recovers";
480
+
481
+ describe("immediate fallback reasons", () => {
482
+ it("marks only the 45s preset as default", () => {
483
+ expect(DEFAULT_CONFIG.summarizerIdleTimeoutMs).toBe(45000);
484
+ expect(SUMMARIZER_IDLE_TIMEOUT_PRESETS.filter(p => p.label.includes("(default)")))
485
+ .toEqual([{ value: "45000", label: "45s (default)" }]);
486
+ });
487
+
488
+ for (const raw of [
489
+ { summarizerModel: "provider-a/primary-model" },
490
+ { summarizerModel: "provider-a/primary-model", summarizerIdleTimeoutMs: 20000 },
491
+ ]) {
492
+ it(`delayed first event with ${raw.summarizerIdleTimeoutMs ?? "resolved default"}`, async () => {
493
+ await withTimerClock(async clock => {
494
+ const config = { ...DEFAULT_CONFIG, ...raw };
495
+ const notes: Note[] = [];
496
+ const attempts: any[] = [];
497
+ let primarySignal: AbortSignal;
498
+ streamImpl = (model, _input, opts) => {
499
+ attempts.push(model);
500
+ if (model === PRIMARY) {
501
+ primarySignal = opts.signal;
502
+ return drippingStream(opts, "- primary summary", 1, 25000);
503
+ }
504
+ return okStream("- rescued");
505
+ };
506
+ const controller = new FallbackController(clock.now);
507
+ let settled = false;
508
+ const pending = summarizeBatch(makeBatch(), config, makeCtx(notes), { controller });
509
+ void pending.then(() => { settled = true; });
510
+ await clock.advance(19999);
511
+ expect(settled).toBe(false);
512
+ await clock.advance(1);
513
+ if (raw.summarizerIdleTimeoutMs === 20000) {
514
+ expect(primarySignal!.aborted).toBe(true);
515
+ expect((await pending)?.summaryText).toBe("- rescued");
516
+ expect(attempts).toEqual([PRIMARY, SESSION]);
517
+ expect(notes).toEqual([{ msg: genericEnter.replace("failing,", "failing (stalled (no output for 20s)),"), level: "warning" }]);
518
+ } else {
519
+ expect(config.summarizerIdleTimeoutMs).toBe(45000);
520
+ expect(settled).toBe(false);
521
+ expect(primarySignal!.aborted).toBe(false);
522
+ await clock.advance(5000);
523
+ expect((await pending)?.summaryText).toBe("- primary summary");
524
+ expect(attempts).toEqual([PRIMARY]);
525
+ expect(controller.inFallback).toBe(false);
526
+ expect(notes).toEqual([]);
527
+ }
528
+ });
529
+ });
530
+ }
531
+
532
+ for (const kind of ["idle", "ceiling"] as const) {
533
+ it(`${kind} rescue carries the prefix-free reason`, async () => {
534
+ await withTimerClock(async clock => {
535
+ const notes: Note[] = [];
536
+ streamImpl = (model, _input, opts) => {
537
+ if (model === SESSION) return okStream("- rescued");
538
+ if (kind === "ceiling") return hangingStream(opts);
539
+ const drip = drippingStream(opts, "- partial", 1, 1000);
540
+ const hang = hangingStream(opts);
541
+ return {
542
+ async *[Symbol.asyncIterator]() { yield* drip; yield* hang; },
543
+ result: () => hang.result(),
544
+ };
545
+ };
546
+ let settled = false;
547
+ const pending = summarizeBatch(makeBatch(), {
548
+ ...distinctConfig, summarizerIdleTimeoutMs: kind === "idle" ? 45000 : 0,
549
+ summarizerMaxTimeoutMs: 180000,
550
+ }, makeCtx(notes), { controller: new FallbackController(clock.now) });
551
+ void pending.then(() => { settled = true; });
552
+ await clock.advance(kind === "idle" ? 45000 : 179999);
553
+ expect(settled).toBe(false);
554
+ await clock.advance(kind === "idle" ? 1000 : 1);
555
+ expect((await pending)?.summaryText).toBe("- rescued");
556
+ const reason = kind === "idle" ? "stalled (no output for 45s)" : "exceeded 180s ceiling";
557
+ expect(notes).toEqual([{ msg: genericEnter.replace("failing,", `failing (${reason}),`), level: "warning" }]);
558
+ if (kind === "idle") expect(notes[0].msg).toMatch(/summarizer model .* failing \(stalled \(no output for \d+s\)\), using session model/);
559
+ });
560
+ });
561
+ }
562
+
563
+ for (const path of ["response", "throw"] as const) {
564
+ for (const [name, message] of [
565
+ ["non-empty", "boom"],
566
+ ["multiline", " long provider message\n" + "detail ".repeat(60) + "\n "],
567
+ ["empty", ""],
568
+ ]) {
569
+ it(`${path} preserves ${name} message verbatim`, async () => {
570
+ streamImpl = model => {
571
+ if (model === SESSION) return okStream("- rescued");
572
+ if (path === "throw") throw new Error(message);
573
+ return errStream(message);
574
+ };
575
+ const notes: Note[] = [];
576
+ const result = await summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes), { controller: new FallbackController() });
577
+ expect(result?.summaryText).toBe("- rescued");
578
+ expect(notes).toEqual([{ msg: message ? genericEnter.replace("failing,", `failing (${message}),`) : genericEnter, level: "warning" }]);
579
+ });
580
+ }
581
+ }
582
+ });
583
+
584
+ describe("terminal timeout labels", () => {
585
+ for (const kind of ["idle", "ceiling"] as const) {
586
+ for (const mode of ["legacy", "fallback-only", "both-down", "unusable-retry"] as const) {
587
+ it(`${kind}: ${mode} labels the selected outcome`, async () => {
588
+ await withTimerClock(async clock => {
589
+ const notes: Note[] = [];
590
+ streamImpl = (model, _input, opts) => mode === "unusable-retry" && model === SESSION
591
+ ? okStream(" ") : hangingStream(opts);
592
+ const controller = new FallbackController(clock.now);
593
+ if (mode === "fallback-only") controller.onBothDown();
594
+ const pending = summarizeBatch(makeBatch(), {
595
+ ...distinctConfig,
596
+ summarizerModel: mode === "legacy" ? "default" : distinctConfig.summarizerModel,
597
+ summarizerIdleTimeoutMs: kind === "idle" ? 20 : 0,
598
+ summarizerMaxTimeoutMs: kind === "ceiling" ? 20 : 0,
599
+ }, makeCtx(notes), mode === "legacy" ? {} : { controller });
600
+ await clock.advance(40);
601
+ expect(await pending).toBeNull();
602
+ const label = mode === "unusable-retry" ? "Primary" : "Session";
603
+ const reason = kind === "idle" ? "stalled (no output for 0s)" : "exceeded 0s ceiling";
604
+ expect(notes).toEqual([{ msg: `pi-condense: summarizer ${label} ${reason}; summarizer call abandoned`, level: "warning" }]);
605
+ });
606
+ });
607
+ }
608
+ }
609
+
610
+ it("session-only auth failure after transient primary keeps its error text", async () => {
611
+ streamImpl = () => errStream("down");
612
+ const notes: Note[] = [];
613
+ const ctx = makeCtx(notes);
614
+ ctx.modelRegistry.getApiKeyAndHeaders = async (model: any) => model === PRIMARY
615
+ ? { ok: true, apiKey: "k", headers: {} } : { ok: false, error: "session key missing" };
616
+ expect(await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller: new FallbackController() })).toBeNull();
617
+ expect(notes).toEqual([{ msg: "pruner: summarization failed: session key missing", level: "error" }]);
618
+ });
619
+ });
620
+
621
+ describe("deferred entry and probe notices", () => {
622
+ for (const initial of ["both-down", "rescued"] as const) {
623
+ it(`${initial}: failed probe rescue then recovery`, async () => {
624
+ let now = 0;
625
+ const controller = new FallbackController(() => now);
626
+ const notes: Note[] = [];
627
+ const ctx = makeCtx(notes);
628
+ streamImpl = model => model === PRIMARY || initial === "both-down" ? errStream("down") : okStream("- ok");
629
+ await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller });
630
+ notes.length = 0;
631
+ now += 600001;
632
+ streamImpl = model => model === PRIMARY ? errStream("probe failed") : okStream("- rescued");
633
+ expect((await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller }))?.summaryText).toBe("- rescued");
634
+ expect(notes).toEqual(initial === "both-down" ? [{ msg: genericEnter, level: "warning" }] : []);
635
+ notes.length = 0;
636
+ now += 600001;
637
+ streamImpl = () => okStream("- recovered");
638
+ await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller });
639
+ expect(notes).toEqual([{ msg: "pi-condense: summarizer model Primary recovered", level: "info" }]);
640
+ });
641
+ }
642
+
643
+ it("blank primary never retries or enters fallback", async () => {
644
+ const attempts: any[] = [];
645
+ streamImpl = model => { attempts.push(model); return okStream(" "); };
646
+ const controller = new FallbackController();
647
+ const notes: Note[] = [];
648
+ expect(await summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes), { controller })).toBeNull();
649
+ expect(attempts).toEqual([PRIMARY]);
650
+ expect(controller.inFallback).toBe(false);
651
+ expect(notes).toEqual([]);
652
+ });
653
+
654
+ it("abort during pending stream rejects without notices", async () => {
655
+ await withTimerClock(async clock => {
656
+ streamImpl = (_model, _input, opts) => hangingStream(opts);
657
+ const notes: Note[] = [];
658
+ const ac = new AbortController();
659
+ const pending = summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes), { controller: new FallbackController(clock.now), signal: ac.signal });
660
+ const rejected = pending.then(() => null, error => error);
661
+ await clock.advance(1);
662
+ ac.abort();
663
+ expect(await rejected).toBeInstanceOf(Error);
664
+ expect(notes).toEqual([]);
665
+ });
666
+ });
667
+ });
package/src/summarizer.ts CHANGED
@@ -11,7 +11,7 @@ import type {
11
11
  import { serializeBatchForSummarizer } from "./batch-capture.js";
12
12
  import { FallbackController, type FallbackTransition } from "./summarizer-fallback.js";
13
13
 
14
- const SYSTEM_PROMPT = `You are summarizing a batch of tool calls made by an AI coding assistant.
14
+ export const SYSTEM_PROMPT = `You are summarizing a batch of tool calls made by an AI coding assistant.
15
15
  For each tool call provide:
16
16
  - Tool name and a one-sentence description of what it did
17
17
  - Key outcome, plus any file paths, identifiers, signatures, or error strings copied verbatim - never reword these
@@ -153,8 +153,8 @@ async function runOnce(
153
153
  };
154
154
  const timeoutMessage = () =>
155
155
  timeoutKind === "ceiling"
156
- ? `summarizer ${modelLabel(model)} exceeded ${Math.round(maxMs / 1000)}s ceiling`
157
- : `summarizer ${modelLabel(model)} stalled (no output for ${Math.round(idleMs / 1000)}s)`;
156
+ ? `exceeded ${Math.round(maxMs / 1000)}s ceiling`
157
+ : `stalled (no output for ${Math.round(idleMs / 1000)}s)`;
158
158
 
159
159
  try {
160
160
  // Preserve auth classification for physical models without pre-checking
@@ -273,7 +273,7 @@ async function runOnce(
273
273
  * session model, and the controller stays sticky in fallback until a
274
274
  * per-cooldown probe of the primary succeeds.
275
275
  */
276
- async function runSummarization(
276
+ export async function runSummarization(
277
277
  userMessage: string,
278
278
  config: ContextPruneConfig,
279
279
  ctx: ExtensionContext,
@@ -286,10 +286,10 @@ async function runSummarization(
286
286
  const controller = options.controller;
287
287
  const sessionModel = ctx.model;
288
288
 
289
- const notifyFailure = (o: { message: string; timedOut?: boolean }) =>
289
+ const notifyFailure = (model: any, o: { message: string; timedOut?: boolean }) =>
290
290
  ctx.ui.notify(
291
291
  o.timedOut
292
- ? `pi-condense: ${o.message}; summarizer call abandoned`
292
+ ? `pi-condense: summarizer ${modelLabel(model)} ${o.message}; summarizer call abandoned`
293
293
  : `pruner: summarization failed: ${o.message}`,
294
294
  o.timedOut ? "warning" : "error",
295
295
  );
@@ -302,17 +302,18 @@ async function runSummarization(
302
302
  return r.result;
303
303
  case "auth":
304
304
  case "transient":
305
- notifyFailure(r);
305
+ notifyFailure(primary, r);
306
306
  return null;
307
307
  case "unusable":
308
308
  return null;
309
309
  }
310
310
  }
311
311
 
312
- const emit = (t: FallbackTransition) => {
312
+ const emit = (t: FallbackTransition, reason?: string) => {
313
313
  if (t === "enter") {
314
+ const failureReason = typeof reason === "string" && reason.length > 0 ? ` (${reason})` : "";
314
315
  ctx.ui.notify(
315
- `pi-condense: summarizer model ${modelLabel(primary)} failing, using session model ${modelLabel(sessionModel)} until it recovers`,
316
+ `pi-condense: summarizer model ${modelLabel(primary)} failing${failureReason}, using session model ${modelLabel(sessionModel)} until it recovers`,
316
317
  "warning"
317
318
  );
318
319
  } else if (t === "recover") {
@@ -330,24 +331,29 @@ async function runSummarization(
330
331
  else emit(controller.onFallbackSuccess());
331
332
  return r.result;
332
333
  case "auth":
333
- notifyFailure(r); // auth never trips the controller
334
+ notifyFailure(model, r); // auth never trips the controller
334
335
  return null;
335
336
  case "unusable":
336
337
  return null; // probe unusable => stay (no state change)
337
338
  case "transient": {
338
339
  if (decision.target === "fallback") {
339
340
  controller.onFallbackOnlyFail();
340
- notifyFailure(r);
341
+ notifyFailure(model, r);
341
342
  return null;
342
343
  }
343
344
  // target was primary (initial detection or probe): retry once on the session model.
344
345
  const r2 = await runOnce(sessionModel, userMessage, config, ctx, options);
345
346
  if (r2.kind === "ok") {
346
- emit(controller.onPrimaryFailFallbackOk(decision.wasProbe));
347
+ const wasInFallback = controller.inFallback;
348
+ emit(controller.onPrimaryFailFallbackOk(decision.wasProbe), wasInFallback ? undefined : r.message);
347
349
  return r2.result; // suppress the legacy error notify — fallback rescued the call
348
350
  }
349
351
  controller.onBothDown();
350
- notifyFailure(r2.kind === "transient" || r2.kind === "auth" ? r2 : r);
352
+ if (r2.kind === "transient" || r2.kind === "auth") {
353
+ notifyFailure(sessionModel, r2);
354
+ } else {
355
+ notifyFailure(model, r);
356
+ }
351
357
  return null;
352
358
  }
353
359
  }
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { NestedToolCalls } from "@earendil-works/pi-ai";
2
+
1
3
  /**
2
4
  * Shared types for the context-prune extension.
3
5
  *
@@ -219,8 +221,8 @@ export const RECOVERY_GRACE_PRESETS: { value: string; label: string }[] = [
219
221
  export const SUMMARIZER_IDLE_TIMEOUT_PRESETS: { value: string; label: string }[] = [
220
222
  { value: "0", label: "0 (disabled)" },
221
223
  { value: "10000", label: "10s" },
222
- { value: "20000", label: "20s (default)" },
223
- { value: "45000", label: "45s" },
224
+ { value: "20000", label: "20s" },
225
+ { value: "45000", label: "45s (default)" },
224
226
  { value: "90000", label: "90s" },
225
227
  ];
226
228
 
@@ -321,7 +323,7 @@ export interface ContextPruneConfig {
321
323
  * Reset on every received stream event; armed before the first event so it
322
324
  * also bounds time-to-first-token. If no event arrives within this window
323
325
  * the call is aborted and classified transient (feeds the outage-fallback
324
- * retry). 0 disables the idle timer. Default 20000.
326
+ * retry). 0 disables the idle timer. Default 45000.
325
327
  */
326
328
  summarizerIdleTimeoutMs: number;
327
329
  /**
@@ -569,7 +571,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
569
571
  quietOversizedSkips: false,
570
572
  minBatchChars: 2000,
571
573
  recoveryGraceTurns: 3,
572
- summarizerIdleTimeoutMs: 20000,
574
+ summarizerIdleTimeoutMs: 45000,
573
575
  summarizerMaxTimeoutMs: 180000,
574
576
  protectedTools: [],
575
577
  protectedPaths: ["**/skills/**/*.md", "**/gauntlet-overrides.md"],
@@ -597,6 +599,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
597
599
 
598
600
  /** A single tool call + its result as captured from turn_end */
599
601
  export interface CapturedToolCall {
602
+ nestedCalls?: NestedToolCalls;
600
603
  toolCallId: string;
601
604
  toolName: string;
602
605
  args: Record<string, unknown>;