pi-condense 2.13.1 → 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,18 @@ 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
+
10
22
  ## [2.13.1] - 2026-10-22
11
23
 
12
24
  ### 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.1",
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
  }
@@ -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 () => {
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.
@@ -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 });
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
@@ -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,
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
  *
@@ -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>;