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/CHANGELOG.md +8 -0
- package/PRUNING.md +3 -0
- package/README.md +113 -191
- package/index.ts +6 -0
- package/package.json +2 -2
- package/src/chain-compressor.test.ts +33 -1
- package/src/chain-compressor.ts +9 -2
- package/src/commands.ts +95 -1
- package/src/config.test.ts +101 -0
- package/src/config.ts +31 -6
- package/src/pruner.test.ts +117 -0
- package/src/pruner.ts +6 -0
- package/src/query-tool.ts +2 -1
- package/src/recovery-grace.test.ts +35 -0
- package/src/recovery-grace.ts +33 -0
- package/src/summarizer-wiring.test.ts +144 -2
- package/src/summarizer.ts +71 -10
- package/src/types.ts +67 -0
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
|
|
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(
|
|
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
|
|
144
|
+
const path = settingsPath();
|
|
145
|
+
const current = (await readJsonObject(path)) ?? {};
|
|
121
146
|
const next = { ...current, [SETTINGS_KEY]: config };
|
|
122
|
-
await mkdir(dirname(
|
|
123
|
-
const tmpPath = `${
|
|
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,
|
|
150
|
+
await rename(tmpPath, path);
|
|
126
151
|
}
|
package/src/pruner.test.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|