pi-condense 2.2.1 → 2.3.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 +4 -0
- package/PRUNING.md +3 -0
- package/README.md +3 -0
- package/index.ts +6 -0
- package/package.json +1 -1
- package/src/chain-compressor.test.ts +33 -1
- package/src/chain-compressor.ts +9 -2
- package/src/commands.ts +50 -0
- package/src/config.test.ts +65 -0
- package/src/config.ts +6 -0
- 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/types.ts +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,10 @@ publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.3.0] - 2026-07-06
|
|
13
|
+
|
|
14
|
+
- **Recovery grace window for `context_tree_query` output.** The pruner used to re-stub its own recovery output at the next turn boundary, forcing a retrieve -> re-stub -> re-query loop the agent experiences as "fighting the pruner" (observed in a real session: a recovered tool dump was re-summarized on the very next flush, so the agent had to keep re-querying the same ref). A new `recoveryGraceTurns` setting (default `3`, `0` disables) keeps a recovered output verbatim for that many user-turn-groups before reverting to the stub. Enforced at **render time** in two places - Phase 1 stub-replace (`src/pruner.ts`) and chain-compression eligibility (`src/chain-compressor.ts`, which defers compressing any chain whose span still holds an in-grace recovery id) - never at capture, so the frontier, dedup, spill, and live `turn_end` paths are unchanged. The window is computed positionally from the message stream (no new `ToolCallRecord` field). Default `3` covers ~81% of same-ref re-queries observed in the local session corpus; the accepted trade-off is that a reference past the window is re-stubbed and may be re-queried, keeping context regrowth bounded rather than permanent. Tunable via `/pruner recovery-grace [n]` and the `/pruner settings` overlay. See [PRUNING.md § What Pruning Does](PRUNING.md#what-pruning-does).
|
|
15
|
+
|
|
12
16
|
## [2.2.1] - 2026-07-06
|
|
13
17
|
|
|
14
18
|
- **Fix probe starvation in the summarizer outage fallback.** `FallbackController.onFallbackOnlyFail` reset the re-probe cooldown on every steady-state fallback failure, so a fallback (session) model that failed at least once per 10-minute cooldown perpetually pushed out the primary re-probe - a recovered `summarizerModel` was never re-tested and summarization stayed on the pricier session model indefinitely (the exact stall the feature exists to kill, in the fallback direction). The method is now a no-op on `lastProbeAt`: the primary re-probe fires on schedule regardless of fallback failures. In-memory only; no wire/config change.
|
package/PRUNING.md
CHANGED
|
@@ -185,6 +185,9 @@ graph TB
|
|
|
185
185
|
- Every pruned tool call is also copied into the pruner's runtime/session index with its `toolCallId`, tool name, args, status, turn index, timestamp, and full `resultText`.
|
|
186
186
|
- A summary message is injected as a `"steer"` (`pi.sendMessage` runtime path) or appended directly via `sessionManager.appendCustomMessageEntry` (session path, used when Pi may already be shutting down). Both deliver before the next LLM call.
|
|
187
187
|
- The session JSONL file retains the original tool-result entries unchanged — pruning only affects what the *next* request sees in active context.
|
|
188
|
+
- **Recovery grace window (`recoveryGraceTurns`, default 3):** after the model recovers a tool call via `context_tree_query`, that tool call's output is rendered **verbatim** (not re-stubbed) for the next N user-turn-groups, then reverts to the normal stub. Without this, the pruner re-prunes its own recovery output on the very next flush, forcing the model into a retrieve -> re-stub -> re-query loop ("fighting the pruner") whenever it keeps referencing the same recovered data across a few turns.
|
|
189
|
+
- Enforced at **render time**, in Phase 1 stub-replace and in chain-compression eligibility — NOT at capture time. Capture-time exclusion would collide with frontier trim: a tool call already past the frontier is dropped forever, so excluding a recovered call from capture would either need to resurrect frontier state or degrade the lifetime bound into permanent verbatim retention for anything ever recovered.
|
|
190
|
+
- Trade-off: this bounds but does not eliminate regrowth. A tool call still referenced after its grace window expires is re-stubbed and may be re-queried again — the accepted cost of keeping the window's context-growth impact bounded instead of unbounded.
|
|
188
191
|
|
|
189
192
|
---
|
|
190
193
|
|
package/README.md
CHANGED
|
@@ -87,6 +87,7 @@ Settings live under the `contextPrune` key in `<agent-dir>/settings.json` (i.e.
|
|
|
87
87
|
"batchingMode": "turn",
|
|
88
88
|
"quietOversizedSkips": false,
|
|
89
89
|
"minBatchChars": 1000,
|
|
90
|
+
"recoveryGraceTurns": 3,
|
|
90
91
|
"protectedTools": [],
|
|
91
92
|
"protectedPaths": ["**/skills/**/*.md"],
|
|
92
93
|
"dedupByContentHash": true,
|
|
@@ -118,6 +119,7 @@ Settings live under the `contextPrune` key in `<agent-dir>/settings.json` (i.e.
|
|
|
118
119
|
| `batchingMode` | `turn` / `agent-message` | `turn` | How coarse each summary is (independent of `pruneOn`) |
|
|
119
120
|
| `quietOversizedSkips` | `true` / `false` | `false` | Silences `skipped-oversized` / `skipped-trivial` info notifications |
|
|
120
121
|
| `minBatchChars` | non-negative integer, `0` disables | `1000` | Pre-flush guard — batches smaller than this skip the LLM entirely |
|
|
122
|
+
| `recoveryGraceTurns` | non-negative integer (user-turn-groups), `0` disables | `3` | After a `context_tree_query` recovery, render that tool's output verbatim for this many user-turn-groups before re-stubbing it. Enforced at render time (Phase 1 + chain-compression eligibility), not at capture time. See [PRUNING.md § What Pruning Does](PRUNING.md#what-pruning-does) |
|
|
121
123
|
| `protectedTools` | `string[]` | `[]` | Never-pruned tool names (e.g. `["todowrite","todoread"]`). When a protected tool's chain is range-compressed, its output is preserved verbatim inside the `<compressed-chain>` block as `<protected-output>` — protected outputs are never lost. |
|
|
122
124
|
| `protectedPaths` | `string[]` | `["**/skills/**/*.md"]` | Globs matched against a tool call's `args.path`; matching outputs are never pruned (same semantics as `protectedTools`, including `<protected-output>` relocation in compressed chains). Already-summarized matching reads are repaired on the next turn; chain-compressed ones are not. Set `[]` to disable. |
|
|
123
125
|
| `dedupByContentHash` | `true` / `false` | `true` | Re-reads of identical (toolName, content) skip the LLM and alias the original |
|
|
@@ -192,6 +194,7 @@ Set it from the slash command (saves immediately):
|
|
|
192
194
|
| `/pruner protected-tools [names]` | Show or edit the never-pruned tool allowlist (comma- or space-separated; `none` clears) |
|
|
193
195
|
| `/pruner protected-paths [globs]` | Show or edit the never-pruned path globs (`none` clears) |
|
|
194
196
|
| `/pruner min-batch-chars [n]` | Show or set the pre-flush trivial-batch threshold (`0` disables) |
|
|
197
|
+
| `/pruner recovery-grace [n]` | Show or set the post-recovery verbatim grace window, in user-turn-groups (`0` disables) |
|
|
195
198
|
| `/pruner dedup [on\|off\|status]` | Toggle pre-flush content-hash dedup |
|
|
196
199
|
| `/pruner tree` | Foldable browser of pruned tool calls; `Ctrl-O` opens the full summary in an overlay |
|
|
197
200
|
| `/pruner compact` | Retroactively compress every eligible closed chain (bypasses `rollingWindow`) |
|
package/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { PruneFrontierTracker } from "./src/frontier.js";
|
|
|
36
36
|
import { BlockRefIssuer } from "./src/block-refs.js";
|
|
37
37
|
import { compressEligible } from "./src/chain-compressor.js";
|
|
38
38
|
import { detectChains, withClosingMessage } from "./src/chain-detector.js";
|
|
39
|
+
import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js";
|
|
39
40
|
import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js";
|
|
40
41
|
import { spillOversizedBatch } from "./src/spill.js";
|
|
41
42
|
|
|
@@ -530,6 +531,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
530
531
|
// message_end fires before pi persists the closing assistant, so thread it
|
|
531
532
|
// in here; otherwise the newest chain reads as open and K over-retains by 1.
|
|
532
533
|
const chains = detectChains(withClosingMessage(branchMessages, options.closingMessage), protectionPredicate);
|
|
534
|
+
const inGrace = inGraceRecoveryToolCallIds(branchMessages, currentConfig.value.recoveryGraceTurns);
|
|
533
535
|
const { compressedEntries } = await compressEligible(
|
|
534
536
|
chains,
|
|
535
537
|
currentConfig.value.chainCompression.rollingWindow,
|
|
@@ -540,6 +542,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
540
542
|
now: () => Date.now(),
|
|
541
543
|
fuseRange: makeFuseRange(ctx),
|
|
542
544
|
},
|
|
545
|
+
inGrace,
|
|
543
546
|
);
|
|
544
547
|
if (compressedEntries.length > 0) {
|
|
545
548
|
statsAccum.addChainsCompressed(compressedEntries.length);
|
|
@@ -825,6 +828,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
825
828
|
currentConfig.value.purgeErrors,
|
|
826
829
|
currentConfig.value.thinkingStrip,
|
|
827
830
|
currentConfig.value,
|
|
831
|
+
currentConfig.value.recoveryGraceTurns,
|
|
828
832
|
);
|
|
829
833
|
if (result.pruned) {
|
|
830
834
|
messages = result.messages;
|
|
@@ -847,6 +851,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
847
851
|
.filter((e: any) => e.type === "message" && e.message)
|
|
848
852
|
.map((e: any) => e.message);
|
|
849
853
|
const chains = detectChains(branchMessages, protectionPredicate);
|
|
854
|
+
const inGrace = inGraceRecoveryToolCallIds(branchMessages, currentConfig.value.recoveryGraceTurns);
|
|
850
855
|
const result = await compressEligible(
|
|
851
856
|
chains,
|
|
852
857
|
0, // effectiveK=0: compress every closed chain not already compressed
|
|
@@ -857,6 +862,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
857
862
|
now: () => Date.now(),
|
|
858
863
|
fuseRange: makeFuseRange(ctx),
|
|
859
864
|
},
|
|
865
|
+
inGrace,
|
|
860
866
|
);
|
|
861
867
|
if (result.compressedEntries.length > 0) {
|
|
862
868
|
statsAccum.addChainsCompressed(result.compressedEntries.length);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, it, test } from "bun:test";
|
|
2
2
|
import { selectEligible, compressEligible } from "./chain-compressor.js";
|
|
3
3
|
import type { ChainCompressorIndexerDeps } from "./chain-compressor.js";
|
|
4
4
|
import type { ChainRange, ChainCompressionEntry } from "./types.js";
|
|
@@ -281,3 +281,35 @@ describe("compressEligible", () => {
|
|
|
281
281
|
expect(result.compressedEntries[0].rangeSummaryText).toBeUndefined();
|
|
282
282
|
});
|
|
283
283
|
});
|
|
284
|
+
|
|
285
|
+
describe("selectEligible - recovery grace deferral", () => {
|
|
286
|
+
const chain = (startTs: number, ids: string[]) =>
|
|
287
|
+
({
|
|
288
|
+
startUserTimestamp: startTs,
|
|
289
|
+
finalAssistantTimestamp: startTs + 10,
|
|
290
|
+
middleToolCallIds: ids,
|
|
291
|
+
protectedToolCallIds: [],
|
|
292
|
+
}) as any;
|
|
293
|
+
it("defers a chain whose span holds an in-grace recovery id", () => {
|
|
294
|
+
const chains = [chain(1, ["a", "t1"]), chain(2, ["b"])];
|
|
295
|
+
const eligible = selectEligible(chains, 0, new Set(), new Set(["t1"]));
|
|
296
|
+
expect(eligible.map((c) => c.startUserTimestamp)).toEqual([2]);
|
|
297
|
+
});
|
|
298
|
+
it("compresses normally when no in-grace recovery id is present", () => {
|
|
299
|
+
const chains = [chain(1, ["a", "t1"]), chain(2, ["b"])];
|
|
300
|
+
const eligible = selectEligible(chains, 0, new Set(), new Set());
|
|
301
|
+
expect(eligible.map((c) => c.startUserTimestamp)).toEqual([1, 2]);
|
|
302
|
+
});
|
|
303
|
+
it("defers only the grace chain without shrinking the rolling-window buffer", () => {
|
|
304
|
+
// 5 eligible chains, rollingWindow=2 -> the window boundary is at n-W=3, so chains
|
|
305
|
+
// 1,2,3 are candidates for compression and 4,5 sit in the protected buffer.
|
|
306
|
+
// The in-grace id lives on chain 4, which is already outside the compress slice and
|
|
307
|
+
// must not affect it. A pre-slice filter (the bug) removes chain 4 from `candidates`
|
|
308
|
+
// before the boundary is computed, shrinking it to 4 items and shifting the cut to
|
|
309
|
+
// n-W=2 -> wrongly dropping chain 3 too. The fix computes the boundary first, so the
|
|
310
|
+
// in-buffer grace id changes nothing and the compress set stays [1, 2, 3].
|
|
311
|
+
const chains = [chain(1, ["a"]), chain(2, ["b"]), chain(3, ["c"]), chain(4, ["d", "t1"]), chain(5, ["e"])];
|
|
312
|
+
const eligible = selectEligible(chains, 2, new Set(), new Set(["t1"]));
|
|
313
|
+
expect(eligible.map((c) => c.startUserTimestamp)).toEqual([1, 2, 3]);
|
|
314
|
+
});
|
|
315
|
+
});
|
package/src/chain-compressor.ts
CHANGED
|
@@ -13,11 +13,16 @@ import type { BlockRefIssuer } from "./block-refs.js";
|
|
|
13
13
|
* @param chains Must be in chronological order (oldest first), as emitted by
|
|
14
14
|
* chain-detector. Ordering is not validated here; out-of-order input silently
|
|
15
15
|
* picks wrong chains because the rolling-window slice is positional.
|
|
16
|
+
* @param inGraceToolCallIds Recovery ids still within their grace window. Chains
|
|
17
|
+
* spanning one of these ids are deferred from compression, but the rolling-window
|
|
18
|
+
* boundary itself is computed BEFORE grace exclusion, so a grace-protected chain
|
|
19
|
+
* never shrinks the window buffer or shifts which other chains become eligible.
|
|
16
20
|
*/
|
|
17
21
|
export function selectEligible(
|
|
18
22
|
chains: ChainRange[],
|
|
19
23
|
rollingWindow: number,
|
|
20
24
|
alreadyCompressed: Set<number>,
|
|
25
|
+
inGraceToolCallIds: Set<string> = new Set(),
|
|
21
26
|
): ChainRange[] {
|
|
22
27
|
const candidates = chains.filter(
|
|
23
28
|
(c) =>
|
|
@@ -25,7 +30,8 @@ export function selectEligible(
|
|
|
25
30
|
!alreadyCompressed.has(c.startUserTimestamp) &&
|
|
26
31
|
c.middleToolCallIds.length > 0,
|
|
27
32
|
);
|
|
28
|
-
|
|
33
|
+
const toCompress = candidates.slice(0, Math.max(0, candidates.length - rollingWindow));
|
|
34
|
+
return toCompress.filter((c) => !c.middleToolCallIds.some((id) => inGraceToolCallIds.has(id)));
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/**
|
|
@@ -71,6 +77,7 @@ export async function compressEligible(
|
|
|
71
77
|
chains: ChainRange[],
|
|
72
78
|
rollingWindow: number,
|
|
73
79
|
deps: CompressEligibleDeps,
|
|
80
|
+
inGraceToolCallIds: Set<string> = new Set(),
|
|
74
81
|
): Promise<CompressEligibleResult> {
|
|
75
82
|
const alreadyCompressedTimestamps = new Set(
|
|
76
83
|
deps.indexer.getChainEntries().map((e) => e.startUserTimestamp),
|
|
@@ -85,7 +92,7 @@ export async function compressEligible(
|
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
const eligible = selectEligible(chains, rollingWindow, alreadyCompressedTimestamps);
|
|
95
|
+
const eligible = selectEligible(chains, rollingWindow, alreadyCompressedTimestamps, inGraceToolCallIds);
|
|
89
96
|
|
|
90
97
|
const compressedEntries: ChainCompressionEntry[] = [];
|
|
91
98
|
for (const chain of eligible) {
|
package/src/commands.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
PROGRESS_WIDGET_ID,
|
|
12
12
|
SUMMARIZER_THINKING_LEVELS,
|
|
13
13
|
MIN_BATCH_CHARS_PRESETS,
|
|
14
|
+
RECOVERY_GRACE_PRESETS,
|
|
14
15
|
AUTO_BUDGET_PRESETS,
|
|
15
16
|
ROLLING_WINDOW_PRESETS,
|
|
16
17
|
KEEP_LAST_TURNS_PRESETS,
|
|
@@ -99,6 +100,7 @@ const SUBCOMMANDS = [
|
|
|
99
100
|
{ value: "protected-tools", label: "protected-tools — show or edit the never-pruned tool allowlist" },
|
|
100
101
|
{ value: "protected-paths", label: "protected-paths — show or edit the never-pruned path globs" },
|
|
101
102
|
{ value: "min-batch-chars", label: "min-batch-chars — show or set the pre-flush trivial-batch threshold" },
|
|
103
|
+
{ value: "recovery-grace", label: "recovery-grace - show or set how long context_tree_query output stays verbatim (user-turn-groups)" },
|
|
102
104
|
{ value: "dedup", label: "dedup — toggle pre-flush content-hash dedup (on/off/status)" },
|
|
103
105
|
{ value: "help", label: "help — show this help" },
|
|
104
106
|
] as const;
|
|
@@ -190,6 +192,13 @@ function minBatchCharsDescription(config: ContextPruneConfig): string {
|
|
|
190
192
|
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
193
|
}
|
|
192
194
|
|
|
195
|
+
function recoveryGraceDescription(config: ContextPruneConfig): string {
|
|
196
|
+
if (config.recoveryGraceTurns === 0) {
|
|
197
|
+
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.";
|
|
198
|
+
}
|
|
199
|
+
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.`;
|
|
200
|
+
}
|
|
201
|
+
|
|
193
202
|
function autoBudgetThresholdDescription(config: ContextPruneConfig): string {
|
|
194
203
|
if (config.autoBudgetThreshold == null) {
|
|
195
204
|
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 +253,8 @@ Usage:
|
|
|
244
253
|
/pruner protected-paths <globs> Set the globs (comma- or space-separated; 'none' clears)
|
|
245
254
|
/pruner min-batch-chars Show the current pre-flush trivial-batch threshold
|
|
246
255
|
/pruner min-batch-chars <n> Set the threshold (non-negative integer; 0 disables)
|
|
256
|
+
/pruner recovery-grace Show the current recovery grace window (user-turn-groups)
|
|
257
|
+
/pruner recovery-grace <n> Set the window (non-negative integer; 0 disables)
|
|
247
258
|
/pruner compact Retroactively compress all closed chains (ignores rollingWindow; force-compresses every eligible chain)
|
|
248
259
|
/pruner dedup Show the current pre-flush content-hash dedup state
|
|
249
260
|
/pruner dedup on|off Enable or disable content-hash dedup
|
|
@@ -546,6 +557,15 @@ export function registerCommands(
|
|
|
546
557
|
: MIN_BATCH_CHARS_PRESETS[2].value, // fall back to "1000" if a custom value isn't in the preset cycle
|
|
547
558
|
description: minBatchCharsDescription(config),
|
|
548
559
|
},
|
|
560
|
+
{
|
|
561
|
+
id: "recoveryGraceTurns",
|
|
562
|
+
label: "Recovery grace (user-turn-groups)",
|
|
563
|
+
values: RECOVERY_GRACE_PRESETS.map((p) => p.value),
|
|
564
|
+
currentValue: RECOVERY_GRACE_PRESETS.some((p) => p.value === String(config.recoveryGraceTurns))
|
|
565
|
+
? String(config.recoveryGraceTurns)
|
|
566
|
+
: RECOVERY_GRACE_PRESETS[2].value,
|
|
567
|
+
description: recoveryGraceDescription(config),
|
|
568
|
+
},
|
|
549
569
|
{
|
|
550
570
|
id: "autoBudgetThreshold",
|
|
551
571
|
label: "Auto-flush at context %",
|
|
@@ -705,6 +725,13 @@ export function registerCommands(
|
|
|
705
725
|
if (mbItem) {
|
|
706
726
|
mbItem.description = minBatchCharsDescription(newConfig);
|
|
707
727
|
}
|
|
728
|
+
} else if (id === "recoveryGraceTurns") {
|
|
729
|
+
const parsed = Number.parseInt(newValue, 10);
|
|
730
|
+
newConfig.recoveryGraceTurns = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CONFIG.recoveryGraceTurns;
|
|
731
|
+
const rgItem = items.find((item) => item.id === "recoveryGraceTurns");
|
|
732
|
+
if (rgItem) {
|
|
733
|
+
rgItem.description = recoveryGraceDescription(newConfig);
|
|
734
|
+
}
|
|
708
735
|
} else if (id === "autoBudgetThreshold") {
|
|
709
736
|
const parsed = Number.parseFloat(newValue);
|
|
710
737
|
newConfig.autoBudgetThreshold =
|
|
@@ -1153,6 +1180,29 @@ export function registerCommands(
|
|
|
1153
1180
|
break;
|
|
1154
1181
|
}
|
|
1155
1182
|
|
|
1183
|
+
case "recovery-grace": {
|
|
1184
|
+
const arg = subArgs[0];
|
|
1185
|
+
if (!arg) {
|
|
1186
|
+
const cur = currentConfig.value.recoveryGraceTurns;
|
|
1187
|
+
const state = cur === 0 ? "disabled" : `${cur} user-turn-group(s)`;
|
|
1188
|
+
ctx.ui.notify(`Current recovery grace: ${state}.`);
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1191
|
+
const parsed = Number.parseInt(arg, 10);
|
|
1192
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
1193
|
+
ctx.ui.notify(`Invalid recovery-grace: "${arg}". Expected a non-negative integer (0 disables).`, "warning");
|
|
1194
|
+
break;
|
|
1195
|
+
}
|
|
1196
|
+
currentConfig.value = { ...currentConfig.value, recoveryGraceTurns: parsed };
|
|
1197
|
+
saveConfig(currentConfig.value);
|
|
1198
|
+
ctx.ui.notify(
|
|
1199
|
+
parsed === 0
|
|
1200
|
+
? "recovery-grace set to 0 - context_tree_query output stubs immediately."
|
|
1201
|
+
: `recovery-grace set to ${parsed} user-turn-group(s).`,
|
|
1202
|
+
);
|
|
1203
|
+
break;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1156
1206
|
// ── /pruner dedup [on|off|status] ──
|
|
1157
1207
|
// Bare form shows current state; `on`/`off` flip and persist;
|
|
1158
1208
|
// `status` is an explicit synonym for bare.
|
|
@@ -0,0 +1,65 @@
|
|
|
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 computes SETTINGS_PATH from getAgentDir() at module-load time, so
|
|
9
|
+
* PI_CODING_AGENT_DIR must be set before the module is imported. normalize()
|
|
10
|
+
* itself isn't exported; loadConfig() is the only public entry point that
|
|
11
|
+
* exercises it, so these tests drive normalization indirectly by writing
|
|
12
|
+
* settings.json into an isolated agent dir and reading it back.
|
|
13
|
+
*/
|
|
14
|
+
let tmpDir: string;
|
|
15
|
+
let loadConfig: typeof import("./config.js").loadConfig;
|
|
16
|
+
let SETTINGS_PATH: string;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
tmpDir = await mkdtemp(join(tmpdir(), "pi-condense-config-test-"));
|
|
20
|
+
process.env.PI_CODING_AGENT_DIR = tmpDir;
|
|
21
|
+
const mod = await import("./config.js");
|
|
22
|
+
loadConfig = mod.loadConfig;
|
|
23
|
+
SETTINGS_PATH = mod.SETTINGS_PATH;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(async () => {
|
|
27
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
28
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function writeContextPrune(overrides: Record<string, unknown>): Promise<void> {
|
|
32
|
+
await writeFile(SETTINGS_PATH, JSON.stringify({ contextPrune: overrides }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("loadConfig recoveryGraceTurns normalization", () => {
|
|
36
|
+
it("preserves an explicit 0", async () => {
|
|
37
|
+
await writeContextPrune({ recoveryGraceTurns: 0 });
|
|
38
|
+
const config = await loadConfig();
|
|
39
|
+
expect(config.recoveryGraceTurns).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("falls back to the default for a negative value", async () => {
|
|
43
|
+
await writeContextPrune({ recoveryGraceTurns: -1 });
|
|
44
|
+
const config = await loadConfig();
|
|
45
|
+
expect(config.recoveryGraceTurns).toBe(DEFAULT_CONFIG.recoveryGraceTurns);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("falls back to the default for NaN", async () => {
|
|
49
|
+
await writeContextPrune({ recoveryGraceTurns: Number.NaN });
|
|
50
|
+
const config = await loadConfig();
|
|
51
|
+
expect(config.recoveryGraceTurns).toBe(DEFAULT_CONFIG.recoveryGraceTurns);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("floors a fractional value", async () => {
|
|
55
|
+
await writeContextPrune({ recoveryGraceTurns: 2.7 });
|
|
56
|
+
const config = await loadConfig();
|
|
57
|
+
expect(config.recoveryGraceTurns).toBe(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to the default when unset", async () => {
|
|
61
|
+
await writeContextPrune({});
|
|
62
|
+
const config = await loadConfig();
|
|
63
|
+
expect(config.recoveryGraceTurns).toBe(DEFAULT_CONFIG.recoveryGraceTurns);
|
|
64
|
+
});
|
|
65
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -52,6 +52,12 @@ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
|
|
|
52
52
|
merged.minBatchChars >= 0
|
|
53
53
|
? Math.floor(merged.minBatchChars)
|
|
54
54
|
: DEFAULT_CONFIG.minBatchChars,
|
|
55
|
+
recoveryGraceTurns:
|
|
56
|
+
typeof merged.recoveryGraceTurns === "number" &&
|
|
57
|
+
Number.isFinite(merged.recoveryGraceTurns) &&
|
|
58
|
+
merged.recoveryGraceTurns >= 0
|
|
59
|
+
? Math.floor(merged.recoveryGraceTurns)
|
|
60
|
+
: DEFAULT_CONFIG.recoveryGraceTurns,
|
|
55
61
|
dedupByContentHash:
|
|
56
62
|
typeof merged.dedupByContentHash === "boolean"
|
|
57
63
|
? merged.dedupByContentHash
|
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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -78,6 +78,10 @@ export const CUSTOM_TYPE_DEDUP_ALIAS = "context-prune-dedup-alias";
|
|
|
78
78
|
*/
|
|
79
79
|
export const CUSTOM_TYPE_CHAIN = "context-prune-chain";
|
|
80
80
|
|
|
81
|
+
/** The registered name of the recovery tool (src/query-tool.ts). Shared so the
|
|
82
|
+
* grace checks in pruner.ts / chain-compressor.ts cannot drift from registration. */
|
|
83
|
+
export const QUERY_TOOL_NAME = "context_tree_query";
|
|
84
|
+
|
|
81
85
|
/** pi.events channel for cross-extension cost contributions (an aggregator like pi-subagents folds these into one total). */
|
|
82
86
|
export const EXTERNAL_COST_CHANNEL = "cost:external";
|
|
83
87
|
|
|
@@ -185,6 +189,19 @@ export const MIN_BATCH_CHARS_PRESETS: { value: string; label: string }[] = [
|
|
|
185
189
|
{ value: "5000", label: "5000" },
|
|
186
190
|
];
|
|
187
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Cycling presets for the `recoveryGraceTurns` setting in the SettingsList.
|
|
194
|
+
* Stored as strings; converted to number when applied. "0" disables the grace
|
|
195
|
+
* (recovery output stubs immediately, pre-feature behavior).
|
|
196
|
+
*/
|
|
197
|
+
export const RECOVERY_GRACE_PRESETS: { value: string; label: string }[] = [
|
|
198
|
+
{ value: "0", label: "0 (disabled)" },
|
|
199
|
+
{ value: "1", label: "1" },
|
|
200
|
+
{ value: "3", label: "3 (default)" },
|
|
201
|
+
{ value: "5", label: "5" },
|
|
202
|
+
{ value: "8", label: "8" },
|
|
203
|
+
];
|
|
204
|
+
|
|
188
205
|
/**
|
|
189
206
|
* Cycling presets for the `autoBudgetThreshold` setting (stored as strings;
|
|
190
207
|
* the settings UI cycles string values). "0" is the disabled sentinel → null.
|
|
@@ -256,6 +273,14 @@ export interface ContextPruneConfig {
|
|
|
256
273
|
* Default: 1000.
|
|
257
274
|
*/
|
|
258
275
|
minBatchChars: number;
|
|
276
|
+
/**
|
|
277
|
+
* User-turn-groups a `context_tree_query` (recovery) output stays verbatim in
|
|
278
|
+
* context after recovery, before it reverts to the normal stub. Bounds the
|
|
279
|
+
* retrieve->re-stub->re-query loop without permanent retention. 0 disables
|
|
280
|
+
* (recovery output stubs immediately). Enforced at render time in pruner.ts
|
|
281
|
+
* (Phase 1) and chain-compressor.ts (eligibility), not at capture.
|
|
282
|
+
*/
|
|
283
|
+
recoveryGraceTurns: number;
|
|
259
284
|
/**
|
|
260
285
|
* Tool names whose outputs must NEVER be pruned or summarized. Tool calls
|
|
261
286
|
* with matching `toolName` are filtered out of the pruning capture path so
|
|
@@ -454,6 +479,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
|
454
479
|
batchingMode: "turn",
|
|
455
480
|
quietOversizedSkips: false,
|
|
456
481
|
minBatchChars: 1000,
|
|
482
|
+
recoveryGraceTurns: 3,
|
|
457
483
|
protectedTools: [],
|
|
458
484
|
protectedPaths: ["**/skills/**/*.md"],
|
|
459
485
|
chainCompression: {
|