pi-condense 2.2.1 → 2.4.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/src/commands.ts CHANGED
@@ -11,6 +11,9 @@ import {
11
11
  PROGRESS_WIDGET_ID,
12
12
  SUMMARIZER_THINKING_LEVELS,
13
13
  MIN_BATCH_CHARS_PRESETS,
14
+ RECOVERY_GRACE_PRESETS,
15
+ SUMMARIZER_IDLE_TIMEOUT_PRESETS,
16
+ SUMMARIZER_MAX_TIMEOUT_PRESETS,
14
17
  AUTO_BUDGET_PRESETS,
15
18
  ROLLING_WINDOW_PRESETS,
16
19
  KEEP_LAST_TURNS_PRESETS,
@@ -99,6 +102,7 @@ const SUBCOMMANDS = [
99
102
  { value: "protected-tools", label: "protected-tools — show or edit the never-pruned tool allowlist" },
100
103
  { value: "protected-paths", label: "protected-paths — show or edit the never-pruned path globs" },
101
104
  { value: "min-batch-chars", label: "min-batch-chars — show or set the pre-flush trivial-batch threshold" },
105
+ { value: "recovery-grace", label: "recovery-grace - show or set how long context_tree_query output stays verbatim (user-turn-groups)" },
102
106
  { value: "dedup", label: "dedup — toggle pre-flush content-hash dedup (on/off/status)" },
103
107
  { value: "help", label: "help — show this help" },
104
108
  ] as const;
@@ -190,6 +194,26 @@ function minBatchCharsDescription(config: ContextPruneConfig): string {
190
194
  return `Pre-flush guard: skip batches whose total raw resultText is below this many chars (no LLM call, frontier advances anyway). Currently ${config.minBatchChars}. Useful for sessions with many tiny tool calls. Set to 0 to disable.`;
191
195
  }
192
196
 
197
+ function recoveryGraceDescription(config: ContextPruneConfig): string {
198
+ if (config.recoveryGraceTurns === 0) {
199
+ return "context_tree_query output is stubbed immediately (grace disabled). Set to a positive integer to keep recovered output verbatim for that many user-turn-groups.";
200
+ }
201
+ return `context_tree_query (recovery) output stays verbatim for ${config.recoveryGraceTurns} user-turn-group(s) after recovery, then reverts to the stub. Bounds the recover->re-stub->re-query loop. Currently ${config.recoveryGraceTurns}. Set to 0 to disable.`;
202
+ }
203
+
204
+ function idleTimeoutDescription(config: ContextPruneConfig): string {
205
+ if (config.summarizerIdleTimeoutMs === 0) {
206
+ return "Summarizer idle timeout DISABLED - a stalled stream is only bounded by the ceiling (or not at all if that is 0 too).";
207
+ }
208
+ return `Abort a summarizer call after ${Math.round(config.summarizerIdleTimeoutMs / 1000)}s of silence (no stream event). Resets on every event, so it never aborts a flowing generation; a timeout feeds the same outage-fallback retry as a provider error. Set 0 to disable.`;
209
+ }
210
+ function maxTimeoutDescription(config: ContextPruneConfig): string {
211
+ if (config.summarizerMaxTimeoutMs === 0) {
212
+ return "Summarizer total-duration ceiling DISABLED - only the idle timeout bounds a call.";
213
+ }
214
+ return `Hard ceiling on total duration of a single summarizer call: ${Math.round(config.summarizerMaxTimeoutMs / 1000)}s. Backstop for a stream that dribbles forever without going idle. Set 0 to disable.`;
215
+ }
216
+
193
217
  function autoBudgetThresholdDescription(config: ContextPruneConfig): string {
194
218
  if (config.autoBudgetThreshold == null) {
195
219
  return `Token-budget auto-flush: force a prune when context usage reaches this share of the window, regardless of prune-on mode. Currently off. Pick a percentage to enable.`;
@@ -244,6 +268,8 @@ Usage:
244
268
  /pruner protected-paths <globs> Set the globs (comma- or space-separated; 'none' clears)
245
269
  /pruner min-batch-chars Show the current pre-flush trivial-batch threshold
246
270
  /pruner min-batch-chars <n> Set the threshold (non-negative integer; 0 disables)
271
+ /pruner recovery-grace Show the current recovery grace window (user-turn-groups)
272
+ /pruner recovery-grace <n> Set the window (non-negative integer; 0 disables)
247
273
  /pruner compact Retroactively compress all closed chains (ignores rollingWindow; force-compresses every eligible chain)
248
274
  /pruner dedup Show the current pre-flush content-hash dedup state
249
275
  /pruner dedup on|off Enable or disable content-hash dedup
@@ -546,6 +572,33 @@ export function registerCommands(
546
572
  : MIN_BATCH_CHARS_PRESETS[2].value, // fall back to "1000" if a custom value isn't in the preset cycle
547
573
  description: minBatchCharsDescription(config),
548
574
  },
575
+ {
576
+ id: "recoveryGraceTurns",
577
+ label: "Recovery grace (user-turn-groups)",
578
+ values: RECOVERY_GRACE_PRESETS.map((p) => p.value),
579
+ currentValue: RECOVERY_GRACE_PRESETS.some((p) => p.value === String(config.recoveryGraceTurns))
580
+ ? String(config.recoveryGraceTurns)
581
+ : RECOVERY_GRACE_PRESETS[2].value,
582
+ description: recoveryGraceDescription(config),
583
+ },
584
+ {
585
+ id: "summarizerIdleTimeoutMs",
586
+ label: "Summarizer idle timeout",
587
+ values: SUMMARIZER_IDLE_TIMEOUT_PRESETS.map((p) => p.value),
588
+ currentValue: SUMMARIZER_IDLE_TIMEOUT_PRESETS.some((p) => p.value === String(config.summarizerIdleTimeoutMs))
589
+ ? String(config.summarizerIdleTimeoutMs)
590
+ : (SUMMARIZER_IDLE_TIMEOUT_PRESETS.find((p) => p.value === String(DEFAULT_CONFIG.summarizerIdleTimeoutMs))?.value ?? SUMMARIZER_IDLE_TIMEOUT_PRESETS[0].value), // fall back to the default preset if a custom value isn't in the cycle
591
+ description: idleTimeoutDescription(config),
592
+ },
593
+ {
594
+ id: "summarizerMaxTimeoutMs",
595
+ label: "Summarizer max timeout",
596
+ values: SUMMARIZER_MAX_TIMEOUT_PRESETS.map((p) => p.value),
597
+ currentValue: SUMMARIZER_MAX_TIMEOUT_PRESETS.some((p) => p.value === String(config.summarizerMaxTimeoutMs))
598
+ ? String(config.summarizerMaxTimeoutMs)
599
+ : (SUMMARIZER_MAX_TIMEOUT_PRESETS.find((p) => p.value === String(DEFAULT_CONFIG.summarizerMaxTimeoutMs))?.value ?? SUMMARIZER_MAX_TIMEOUT_PRESETS[0].value), // fall back to the default preset if a custom value isn't in the cycle
600
+ description: maxTimeoutDescription(config),
601
+ },
549
602
  {
550
603
  id: "autoBudgetThreshold",
551
604
  label: "Auto-flush at context %",
@@ -705,6 +758,23 @@ export function registerCommands(
705
758
  if (mbItem) {
706
759
  mbItem.description = minBatchCharsDescription(newConfig);
707
760
  }
761
+ } else if (id === "recoveryGraceTurns") {
762
+ const parsed = Number.parseInt(newValue, 10);
763
+ newConfig.recoveryGraceTurns = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CONFIG.recoveryGraceTurns;
764
+ const rgItem = items.find((item) => item.id === "recoveryGraceTurns");
765
+ if (rgItem) {
766
+ rgItem.description = recoveryGraceDescription(newConfig);
767
+ }
768
+ } else if (id === "summarizerIdleTimeoutMs") {
769
+ const parsed = Number.parseInt(newValue, 10);
770
+ newConfig.summarizerIdleTimeoutMs = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CONFIG.summarizerIdleTimeoutMs;
771
+ const it = items.find((item) => item.id === "summarizerIdleTimeoutMs");
772
+ if (it) it.description = idleTimeoutDescription(newConfig);
773
+ } else if (id === "summarizerMaxTimeoutMs") {
774
+ const parsed = Number.parseInt(newValue, 10);
775
+ newConfig.summarizerMaxTimeoutMs = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CONFIG.summarizerMaxTimeoutMs;
776
+ const it = items.find((item) => item.id === "summarizerMaxTimeoutMs");
777
+ if (it) it.description = maxTimeoutDescription(newConfig);
708
778
  } else if (id === "autoBudgetThreshold") {
709
779
  const parsed = Number.parseFloat(newValue);
710
780
  newConfig.autoBudgetThreshold =
@@ -812,8 +882,9 @@ export function registerCommands(
812
882
  const statsLine = s.callCount > 0
813
883
  ? `\n --- summarizer ---\n calls: ${s.callCount}\n input: ${formatTokens(s.totalInputTokens)} tokens\n output: ${formatTokens(s.totalOutputTokens)} tokens\n cost: ${formatCost(s.totalCost)}`
814
884
  : "\n (no summarizer calls yet)";
885
+ const fmtTimeout = (ms: number) => (ms === 0 ? "disabled" : `${Math.round(ms / 1000)}s`);
815
886
  ctx.ui.notify(
816
- `pruner status:\n enabled: ${cfg.enabled}\n model: ${cfg.summarizerModel}\n thinking: ${summarizerThinkingLabel(cfg.summarizerThinking)} (${cfg.summarizerThinking})\n trigger: ${mode}\n batching: ${batchingModeLabel(cfg.batchingMode)} (${cfg.batchingMode})\n dedup: ${cfg.dedupByContentHash ? "on" : "off"}\n status: ${cfg.showPruneStatusLine ? "on" : "off"}${statsLine}`,
887
+ `pruner status:\n enabled: ${cfg.enabled}\n model: ${cfg.summarizerModel}\n thinking: ${summarizerThinkingLabel(cfg.summarizerThinking)} (${cfg.summarizerThinking})\n idle to: ${fmtTimeout(cfg.summarizerIdleTimeoutMs)}\n max to: ${fmtTimeout(cfg.summarizerMaxTimeoutMs)}\n trigger: ${mode}\n batching: ${batchingModeLabel(cfg.batchingMode)} (${cfg.batchingMode})\n dedup: ${cfg.dedupByContentHash ? "on" : "off"}\n status: ${cfg.showPruneStatusLine ? "on" : "off"}${statsLine}`,
817
888
  );
818
889
  break;
819
890
  }
@@ -1153,6 +1224,29 @@ export function registerCommands(
1153
1224
  break;
1154
1225
  }
1155
1226
 
1227
+ case "recovery-grace": {
1228
+ const arg = subArgs[0];
1229
+ if (!arg) {
1230
+ const cur = currentConfig.value.recoveryGraceTurns;
1231
+ const state = cur === 0 ? "disabled" : `${cur} user-turn-group(s)`;
1232
+ ctx.ui.notify(`Current recovery grace: ${state}.`);
1233
+ break;
1234
+ }
1235
+ const parsed = Number.parseInt(arg, 10);
1236
+ if (!Number.isFinite(parsed) || parsed < 0) {
1237
+ ctx.ui.notify(`Invalid recovery-grace: "${arg}". Expected a non-negative integer (0 disables).`, "warning");
1238
+ break;
1239
+ }
1240
+ currentConfig.value = { ...currentConfig.value, recoveryGraceTurns: parsed };
1241
+ saveConfig(currentConfig.value);
1242
+ ctx.ui.notify(
1243
+ parsed === 0
1244
+ ? "recovery-grace set to 0 - context_tree_query output stubs immediately."
1245
+ : `recovery-grace set to ${parsed} user-turn-group(s).`,
1246
+ );
1247
+ break;
1248
+ }
1249
+
1156
1250
  // ── /pruner dedup [on|off|status] ──
1157
1251
  // Bare form shows current state; `on`/`off` flip and persist;
1158
1252
  // `status` is an explicit synonym for bare.
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it, beforeAll, afterAll } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DEFAULT_CONFIG } from "./types.js";
6
+
7
+ /**
8
+ * config.ts resolves the settings path from getAgentDir() lazily on each
9
+ * read/write, so PI_CODING_AGENT_DIR set here is honored regardless of import
10
+ * order (bun shares the module registry across test files). normalize() itself
11
+ * isn't exported; loadConfig() is the only public entry point that exercises
12
+ * it, so these tests drive normalization indirectly by writing settings.json
13
+ * into an isolated agent dir and reading it back.
14
+ */
15
+ let tmpDir: string;
16
+ let loadConfig: typeof import("./config.js").loadConfig;
17
+ let settingsPath: typeof import("./config.js").settingsPath;
18
+
19
+ beforeAll(async () => {
20
+ tmpDir = await mkdtemp(join(tmpdir(), "pi-condense-config-test-"));
21
+ process.env.PI_CODING_AGENT_DIR = tmpDir;
22
+ const mod = await import("./config.js");
23
+ loadConfig = mod.loadConfig;
24
+ settingsPath = mod.settingsPath;
25
+ });
26
+
27
+ afterAll(async () => {
28
+ delete process.env.PI_CODING_AGENT_DIR;
29
+ await rm(tmpDir, { recursive: true, force: true });
30
+ });
31
+
32
+ async function writeContextPrune(overrides: Record<string, unknown>): Promise<void> {
33
+ await writeFile(settingsPath(), JSON.stringify({ contextPrune: overrides }));
34
+ }
35
+
36
+ describe("loadConfig recoveryGraceTurns normalization", () => {
37
+ it("preserves an explicit 0", async () => {
38
+ await writeContextPrune({ recoveryGraceTurns: 0 });
39
+ const config = await loadConfig();
40
+ expect(config.recoveryGraceTurns).toBe(0);
41
+ });
42
+
43
+ it("falls back to the default for a negative value", async () => {
44
+ await writeContextPrune({ recoveryGraceTurns: -1 });
45
+ const config = await loadConfig();
46
+ expect(config.recoveryGraceTurns).toBe(DEFAULT_CONFIG.recoveryGraceTurns);
47
+ });
48
+
49
+ it("falls back to the default for NaN", async () => {
50
+ await writeContextPrune({ recoveryGraceTurns: Number.NaN });
51
+ const config = await loadConfig();
52
+ expect(config.recoveryGraceTurns).toBe(DEFAULT_CONFIG.recoveryGraceTurns);
53
+ });
54
+
55
+ it("floors a fractional value", async () => {
56
+ await writeContextPrune({ recoveryGraceTurns: 2.7 });
57
+ const config = await loadConfig();
58
+ expect(config.recoveryGraceTurns).toBe(2);
59
+ });
60
+
61
+ it("falls back to the default when unset", async () => {
62
+ await writeContextPrune({});
63
+ const config = await loadConfig();
64
+ expect(config.recoveryGraceTurns).toBe(DEFAULT_CONFIG.recoveryGraceTurns);
65
+ });
66
+ });
67
+
68
+ describe("loadConfig summarizer timeout normalization", () => {
69
+ it("defaults both timeouts when absent", async () => {
70
+ await writeContextPrune({});
71
+ const config = await loadConfig();
72
+ expect(config.summarizerIdleTimeoutMs).toBe(DEFAULT_CONFIG.summarizerIdleTimeoutMs);
73
+ expect(config.summarizerMaxTimeoutMs).toBe(DEFAULT_CONFIG.summarizerMaxTimeoutMs);
74
+ });
75
+
76
+ it("preserves explicit 0 (disabled) for both", async () => {
77
+ await writeContextPrune({ summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 0 });
78
+ const config = await loadConfig();
79
+ expect(config.summarizerIdleTimeoutMs).toBe(0);
80
+ expect(config.summarizerMaxTimeoutMs).toBe(0);
81
+ });
82
+
83
+ it("falls back to default for a negative idle timeout", async () => {
84
+ await writeContextPrune({ summarizerIdleTimeoutMs: -5 });
85
+ const config = await loadConfig();
86
+ expect(config.summarizerIdleTimeoutMs).toBe(DEFAULT_CONFIG.summarizerIdleTimeoutMs);
87
+ });
88
+
89
+ it("falls back to default for NaN max timeout", async () => {
90
+ // JSON.stringify serializes NaN to null; normalize's typeof-number guard rejects it.
91
+ await writeContextPrune({ summarizerMaxTimeoutMs: Number.NaN });
92
+ const config = await loadConfig();
93
+ expect(config.summarizerMaxTimeoutMs).toBe(DEFAULT_CONFIG.summarizerMaxTimeoutMs);
94
+ });
95
+
96
+ it("floors a fractional idle timeout", async () => {
97
+ await writeContextPrune({ summarizerIdleTimeoutMs: 1234.9 });
98
+ const config = await loadConfig();
99
+ expect(config.summarizerIdleTimeoutMs).toBe(1234);
100
+ });
101
+ });
package/src/config.ts CHANGED
@@ -15,8 +15,14 @@ import { DEFAULT_CONFIG, PRUNE_ON_MODES, SUMMARIZER_THINKING_LEVELS } from "./ty
15
15
  * Resolved against `getAgentDir()` so it honors `PI_CODING_AGENT_DIR`
16
16
  * (defaults to `~/.pi/agent`). Each pi preset directory therefore gets its
17
17
  * own context-prune config — including its own summarizer model.
18
+ *
19
+ * Computed lazily on each read/write rather than frozen at module load, so the
20
+ * resolved path always reflects the current `PI_CODING_AGENT_DIR` regardless of
21
+ * when the module was first imported.
18
22
  */
19
- export const SETTINGS_PATH = join(getAgentDir(), "settings.json");
23
+ export function settingsPath(): string {
24
+ return join(getAgentDir(), "settings.json");
25
+ }
20
26
 
21
27
  /** Top-level key under which context-prune state lives in `settings.json`. */
22
28
  export const SETTINGS_KEY = "contextPrune" as const;
@@ -52,6 +58,24 @@ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
52
58
  merged.minBatchChars >= 0
53
59
  ? Math.floor(merged.minBatchChars)
54
60
  : DEFAULT_CONFIG.minBatchChars,
61
+ summarizerIdleTimeoutMs:
62
+ typeof merged.summarizerIdleTimeoutMs === "number" &&
63
+ Number.isFinite(merged.summarizerIdleTimeoutMs) &&
64
+ merged.summarizerIdleTimeoutMs >= 0
65
+ ? Math.floor(merged.summarizerIdleTimeoutMs)
66
+ : DEFAULT_CONFIG.summarizerIdleTimeoutMs,
67
+ summarizerMaxTimeoutMs:
68
+ typeof merged.summarizerMaxTimeoutMs === "number" &&
69
+ Number.isFinite(merged.summarizerMaxTimeoutMs) &&
70
+ merged.summarizerMaxTimeoutMs >= 0
71
+ ? Math.floor(merged.summarizerMaxTimeoutMs)
72
+ : DEFAULT_CONFIG.summarizerMaxTimeoutMs,
73
+ recoveryGraceTurns:
74
+ typeof merged.recoveryGraceTurns === "number" &&
75
+ Number.isFinite(merged.recoveryGraceTurns) &&
76
+ merged.recoveryGraceTurns >= 0
77
+ ? Math.floor(merged.recoveryGraceTurns)
78
+ : DEFAULT_CONFIG.recoveryGraceTurns,
55
79
  dedupByContentHash:
56
80
  typeof merged.dedupByContentHash === "boolean"
57
81
  ? merged.dedupByContentHash
@@ -100,7 +124,7 @@ async function readJsonObject(path: string): Promise<Record<string, unknown> | u
100
124
 
101
125
  /** Reads `<agent-dir>/settings.json` and returns the `contextPrune` block, or defaults. */
102
126
  export async function loadConfig(): Promise<ContextPruneConfig> {
103
- const main = await readJsonObject(SETTINGS_PATH);
127
+ const main = await readJsonObject(settingsPath());
104
128
  const namespaced = main?.[SETTINGS_KEY];
105
129
  if (namespaced && typeof namespaced === "object" && !Array.isArray(namespaced)) {
106
130
  return normalize(namespaced as Partial<ContextPruneConfig>);
@@ -117,10 +141,11 @@ export async function loadConfig(): Promise<ContextPruneConfig> {
117
141
  * last-write-wins race only loses a single change, never corrupts the file.
118
142
  */
119
143
  export async function saveConfig(config: ContextPruneConfig): Promise<void> {
120
- const current = (await readJsonObject(SETTINGS_PATH)) ?? {};
144
+ const path = settingsPath();
145
+ const current = (await readJsonObject(path)) ?? {};
121
146
  const next = { ...current, [SETTINGS_KEY]: config };
122
- await mkdir(dirname(SETTINGS_PATH), { recursive: true });
123
- const tmpPath = `${SETTINGS_PATH}.${randomBytes(8).toString("hex")}.tmp`;
147
+ await mkdir(dirname(path), { recursive: true });
148
+ const tmpPath = `${path}.${randomBytes(8).toString("hex")}.tmp`;
124
149
  await writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`);
125
- await rename(tmpPath, SETTINGS_PATH);
150
+ await rename(tmpPath, path);
126
151
  }
@@ -451,6 +451,123 @@ describe("render-time protection re-check", () => {
451
451
  });
452
452
  });
453
453
 
454
+ describe("pruneMessages recovery grace", () => {
455
+ const mkQueryResult = (toolCallId: string, timestamp: number) => ({
456
+ role: "toolResult",
457
+ toolCallId,
458
+ toolName: "context_tree_query",
459
+ content: [{ type: "text", text: "VERBATIM RECOVERY OUTPUT" }],
460
+ isError: false,
461
+ timestamp,
462
+ });
463
+ const mkUser = (timestamp: number) => ({ role: "user", content: [{ type: "text", text: "go" }], timestamp });
464
+
465
+ it("renders a context_tree_query recovery output verbatim at age 0 within grace", () => {
466
+ const indexer = makeMockIndexer({
467
+ summarized: new Set(["tc-recover"]),
468
+ shortRefs: new Map([["tc-recover", "t1"]]),
469
+ });
470
+ const messages = [mkQueryResult("tc-recover", 1)];
471
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
472
+ expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
473
+ });
474
+
475
+ it("stubs a context_tree_query recovery output aged past the grace window", () => {
476
+ const indexer = makeMockIndexer({
477
+ summarized: new Set(["tc-recover"]),
478
+ shortRefs: new Map([["tc-recover", "t1"]]),
479
+ });
480
+ const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
481
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
482
+ const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
483
+ expect(tr.content[0].text).toContain("context_tree_query");
484
+ expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
485
+ });
486
+
487
+ it("stubs at age 0 when recoveryGraceTurns is 0 (feature off)", () => {
488
+ const indexer = makeMockIndexer({
489
+ summarized: new Set(["tc-recover"]),
490
+ shortRefs: new Map([["tc-recover", "t1"]]),
491
+ });
492
+ const messages = [mkQueryResult("tc-recover", 1)];
493
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 0);
494
+ expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
495
+ expect(out[0].content[0].text).toContain("context_tree_query");
496
+ });
497
+
498
+ it("does not apply the grace window to non-context_tree_query outputs", () => {
499
+ const indexer = makeMockIndexer({
500
+ summarized: new Set(["tc-bash"]),
501
+ shortRefs: new Map([["tc-bash", "t1"]]),
502
+ });
503
+ const messages = [
504
+ {
505
+ role: "toolResult",
506
+ toolCallId: "tc-bash",
507
+ toolName: "bash",
508
+ content: [{ type: "text", text: "VERBATIM RECOVERY OUTPUT" }],
509
+ isError: false,
510
+ timestamp: 1,
511
+ },
512
+ ];
513
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
514
+ expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
515
+ expect(out[0].content[0].text).toContain("context_tree_query");
516
+ });
517
+
518
+ it("isProtected precedence: a protected context_tree_query output stays verbatim even with grace off", () => {
519
+ const indexer = makeMockIndexer({
520
+ summarized: new Set(["tc-recover"]),
521
+ shortRefs: new Map([["tc-recover", "t1"]]),
522
+ records: new Map([["tc-recover", {
523
+ toolCallId: "tc-recover", toolName: "context_tree_query", args: { path: "/h/skills/x/SKILL.md" },
524
+ resultText: "", isError: false, turnIndex: 0, timestamp: 1,
525
+ }]]),
526
+ });
527
+ const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
528
+ const { messages: out } = pruneMessages(
529
+ messages, indexer, undefined, undefined, undefined,
530
+ { protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
531
+ 0,
532
+ );
533
+ const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
534
+ expect(tr.content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
535
+ });
536
+
537
+ it("renders a spilled context_tree_query recovery output verbatim at age 0 within grace", () => {
538
+ const indexer = makeMockIndexer({
539
+ summarized: new Set(["tc-recover"]),
540
+ shortRefs: new Map([["tc-recover", "t1"]]),
541
+ records: new Map([["tc-recover", {
542
+ toolCallId: "tc-recover", toolName: "context_tree_query", args: {},
543
+ resultText: "", resultPreview: "PREVIEW-HEAD", spillPath: "/blobs/tc-recover.txt",
544
+ spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1,
545
+ }]]),
546
+ });
547
+ const messages = [mkQueryResult("tc-recover", 1)];
548
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
549
+ expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
550
+ });
551
+
552
+ it("stubs a spilled context_tree_query recovery output aged past the grace window to the spill-pointer stub", () => {
553
+ const indexer = makeMockIndexer({
554
+ summarized: new Set(["tc-recover"]),
555
+ shortRefs: new Map([["tc-recover", "t1"]]),
556
+ records: new Map([["tc-recover", {
557
+ toolCallId: "tc-recover", toolName: "context_tree_query", args: {},
558
+ resultText: "", resultPreview: "PREVIEW-HEAD", spillPath: "/blobs/tc-recover.txt",
559
+ spillBytes: 1048576, isError: false, turnIndex: 0, timestamp: 1,
560
+ }]]),
561
+ });
562
+ const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
563
+ const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, undefined, 3);
564
+ const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
565
+ expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
566
+ expect(tr.content[0].text).toContain("/blobs/tc-recover.txt");
567
+ expect(tr.content[0].text).toContain("spilled");
568
+ });
569
+ });
570
+
454
571
  describe("sizeMessages", () => {
455
572
  it("counts hidden fields (thinking blocks), not just visible text", () => {
456
573
  // Two messages with identical visible .text but different hidden content.
package/src/pruner.ts CHANGED
@@ -4,6 +4,7 @@ import { isProtected, type ProtectionConfig } from "./protected.js";
4
4
  import { applyChainCompressions } from "./chain-range-prune.js";
5
5
  import { purgeErroredArgs } from "./error-purge.js";
6
6
  import { stripOldThinking } from "./thinking-strip.js";
7
+ import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
7
8
 
8
9
  /**
9
10
  * Estimate of a message array's context weight. Serializing the whole array
@@ -66,10 +67,12 @@ export function pruneMessages(
66
67
  errorPurge?: ErrorPurgeConfig,
67
68
  thinkingStrip?: ThinkingStripConfig,
68
69
  protection?: ProtectionConfig,
70
+ recoveryGraceTurns: number = 0,
69
71
  ): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
70
72
  const beforeChars = sizeMessages(messages);
71
73
  // Phase 1: stub-replace summarized tool results
72
74
  let pruned = false;
75
+ const inGrace = inGraceRecoveryToolCallIds(messages, recoveryGraceTurns);
73
76
  const next = messages.map((msg) => {
74
77
  if (msg.role === "toolResult" && indexer.isSummarized(msg.toolCallId)) {
75
78
  const record = indexer.getRecord(msg.toolCallId);
@@ -81,6 +84,9 @@ export function pruneMessages(
81
84
  if (protection && record && isProtected(record.toolName, record.args, protection)) {
82
85
  return msg;
83
86
  }
87
+ if (inGrace.has(msg.toolCallId)) {
88
+ return msg;
89
+ }
84
90
  pruned = true;
85
91
  const ref = indexer.getShortRefForToolCallId(msg.toolCallId) ?? msg.toolCallId;
86
92
  const text = record?.spillPath
package/src/query-tool.ts CHANGED
@@ -3,10 +3,11 @@ import { Type } from "@sinclair/typebox";
3
3
  import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import type { ToolCallIndexer } from "./indexer.js";
6
+ import { QUERY_TOOL_NAME } from "./types.js";
6
7
 
7
8
  export function registerQueryTool(pi: ExtensionAPI, indexer: ToolCallIndexer): void {
8
9
  pi.registerTool({
9
- name: "context_tree_query",
10
+ name: QUERY_TOOL_NAME,
10
11
  label: "Query Original Tool History",
11
12
  description:
12
13
  "Retrieve original tool call results that have been pruned from active context. Pass the short refs listed in a pruner-summary message, e.g. context_tree_query({ toolCallIds: [\"t12\", \"t3\"] }), to get back the full original outputs.",
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
3
+
4
+ const user = () => ({ role: "user", content: [{ type: "text", text: "u" }] });
5
+ const ctq = (id: string) => ({ role: "toolResult", toolCallId: id, toolName: "context_tree_query", content: [{ type: "text", text: "x" }] });
6
+ const bash = (id: string) => ({ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: "x" }] });
7
+
8
+ describe("inGraceRecoveryToolCallIds", () => {
9
+ it("includes a recovery output in the current user-turn-group (age 0)", () => {
10
+ const msgs = [user(), ctq("t1")];
11
+ expect([...inGraceRecoveryToolCallIds(msgs, 3)]).toEqual(["t1"]);
12
+ });
13
+ it("includes a recovery output exactly K user-turns old", () => {
14
+ const msgs = [user(), ctq("t1"), user(), user(), user()];
15
+ expect(inGraceRecoveryToolCallIds(msgs, 3).has("t1")).toBe(true);
16
+ });
17
+ it("excludes a recovery output older than K user-turns", () => {
18
+ const msgs = [user(), ctq("t1"), user(), user(), user(), user()];
19
+ expect(inGraceRecoveryToolCallIds(msgs, 3).has("t1")).toBe(false);
20
+ });
21
+ it("returns empty set when grace disabled (K=0)", () => {
22
+ const msgs = [user(), ctq("t1")];
23
+ expect(inGraceRecoveryToolCallIds(msgs, 0).size).toBe(0);
24
+ });
25
+ it("ignores non-recovery tool outputs", () => {
26
+ const msgs = [user(), bash("t1"), ctq("t2")];
27
+ expect([...inGraceRecoveryToolCallIds(msgs, 3)]).toEqual(["t2"]);
28
+ });
29
+ it("judges multiple recovery outputs by their own positions", () => {
30
+ const msgs = [user(), ctq("t1"), user(), user(), user(), user(), ctq("t2")];
31
+ const set = inGraceRecoveryToolCallIds(msgs, 3);
32
+ expect(set.has("t1")).toBe(false);
33
+ expect(set.has("t2")).toBe(true);
34
+ });
35
+ });
@@ -0,0 +1,33 @@
1
+ import { QUERY_TOOL_NAME } from "./types.js";
2
+
3
+ /**
4
+ * Set of `context_tree_query` toolCallIds still inside the recovery grace
5
+ * window, computed positionally from the message array (no stored metadata).
6
+ *
7
+ * A recovery output's "user-turn-group" is the count of `role === "user"`
8
+ * messages at or before its position; its age is `nowUTG - that count`, where
9
+ * `nowUTG` is the total user messages in the array. It is in grace while
10
+ * `age <= graceTurns`. Works uniformly for render-context and session-branch
11
+ * arrays, so pruner Phase 1 and chain-compressor eligibility share one rule.
12
+ *
13
+ * `graceTurns <= 0` returns an empty set (feature disabled).
14
+ */
15
+ export function inGraceRecoveryToolCallIds(messages: any[], graceTurns: number): Set<string> {
16
+ const result = new Set<string>();
17
+ if (!(graceTurns > 0)) return result;
18
+
19
+ let nowUTG = 0;
20
+ for (const m of messages) if (m?.role === "user") nowUTG++;
21
+
22
+ let seen = 0;
23
+ for (const m of messages) {
24
+ if (m?.role === "user") {
25
+ seen++;
26
+ continue;
27
+ }
28
+ if (m?.role === "toolResult" && m.toolName === QUERY_TOOL_NAME && typeof m.toolCallId === "string") {
29
+ if (nowUTG - seen <= graceTurns) result.add(m.toolCallId);
30
+ }
31
+ }
32
+ return result;
33
+ }