pi-dynamic-context-pruning 0.1.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/.pi-fleet-tested-version +1 -0
- package/README.md +435 -0
- package/index.ts +2546 -0
- package/package.json +34 -0
package/index.ts
ADDED
|
@@ -0,0 +1,2546 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Types (kept intentionally minimal/structural so pure helpers stay testable
|
|
8
|
+
// without depending on the full @earendil-works/pi-ai type graph).
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export interface MinimalTextContent {
|
|
12
|
+
type: "text";
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MinimalImageContent {
|
|
17
|
+
type: "image";
|
|
18
|
+
data: string;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MinimalToolCallContent {
|
|
23
|
+
type: "toolCall";
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
arguments: Record<string, unknown>;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type MinimalAssistantContentBlock =
|
|
31
|
+
| MinimalTextContent
|
|
32
|
+
| { type: "thinking"; thinking: string; [key: string]: unknown }
|
|
33
|
+
| MinimalToolCallContent;
|
|
34
|
+
|
|
35
|
+
export interface MinimalUserMessage {
|
|
36
|
+
role: "user";
|
|
37
|
+
content: string | (MinimalTextContent | MinimalImageContent)[];
|
|
38
|
+
timestamp?: number;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MinimalAssistantMessage {
|
|
43
|
+
role: "assistant";
|
|
44
|
+
content: MinimalAssistantContentBlock[];
|
|
45
|
+
timestamp?: number;
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MinimalToolResultMessage {
|
|
50
|
+
role: "toolResult";
|
|
51
|
+
toolCallId: string;
|
|
52
|
+
toolName: string;
|
|
53
|
+
content: (MinimalTextContent | MinimalImageContent)[];
|
|
54
|
+
isError: boolean;
|
|
55
|
+
timestamp?: number;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MinimalOtherMessage {
|
|
60
|
+
role: Exclude<string, "user" | "assistant" | "toolResult">;
|
|
61
|
+
timestamp?: number;
|
|
62
|
+
[key: string]: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type MinimalMessage = MinimalUserMessage | MinimalAssistantMessage | MinimalToolResultMessage | MinimalOtherMessage;
|
|
66
|
+
|
|
67
|
+
export interface MinimalSessionEntry {
|
|
68
|
+
type: string;
|
|
69
|
+
id: string;
|
|
70
|
+
parentId: string | null;
|
|
71
|
+
timestamp: string;
|
|
72
|
+
message?: MinimalMessage;
|
|
73
|
+
customType?: string;
|
|
74
|
+
data?: unknown;
|
|
75
|
+
[key: string]: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// Constants
|
|
80
|
+
// ============================================================================
|
|
81
|
+
|
|
82
|
+
const EXTENSION_ID = "dynamic-context-pruning";
|
|
83
|
+
const CONFIG_FILE_NAME = "dynamic-context-pruning.json";
|
|
84
|
+
const DECISION_ENTRY_TYPE = "dynamic-context-pruning:decision";
|
|
85
|
+
const STATS_ENTRY_TYPE = "dynamic-context-pruning:stats";
|
|
86
|
+
/**
|
|
87
|
+
* Restore/undo entries (pe-8re9): a lightweight tombstone that records "the
|
|
88
|
+
* decision with this idempotencyKey should be treated as reverted". See
|
|
89
|
+
* `resolvePruneTombstoneState` for the exact chronological resolution rule.
|
|
90
|
+
*/
|
|
91
|
+
const RESTORE_ENTRY_TYPE = "dynamic-context-pruning:restore";
|
|
92
|
+
/** strategyId used for user-initiated prunes from the /prune picker (pe-8re9). Always source:"manual". */
|
|
93
|
+
const MANUAL_STRATEGY_ID = "manual";
|
|
94
|
+
|
|
95
|
+
const DEFAULT_RECENT_TURNS = 4;
|
|
96
|
+
|
|
97
|
+
/** Cache cost model default: fraction of full (non-cached) price still paid for cached tokens. */
|
|
98
|
+
const DEFAULT_CACHED_PRICE_RATIO = 0.1;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Default net-benefit gate threshold (max amortization calls). Recalibrated
|
|
102
|
+
* (pe-c5n9) from the representative-corpus benchmark (~/.the-last-harness/
|
|
103
|
+
* agent/sessions, 1,390+ session files, 556 candidates): this is the
|
|
104
|
+
* hindsight-optimal break-even T at cachedPriceRatio r=0.1 (aggressive
|
|
105
|
+
* prompt caching, the common Anthropic case). IMPORTANT caveats:
|
|
106
|
+
* - Provider/ratio-dependent: this optimum shifts higher as caching gets
|
|
107
|
+
* weaker (T=29 at r=0.25, T=54 at r=0.5, T=58 at r=0.9 on the same
|
|
108
|
+
* corpus) — see docs/v2-design.md section 4 and the extension README.
|
|
109
|
+
* - At r=0.1 the realized deterministic savings this threshold buys are
|
|
110
|
+
* economically marginal (~20.6k token-units total across the whole
|
|
111
|
+
* corpus, i.e. pennies); savings only become material at r>=0.25.
|
|
112
|
+
* - Re-derive via `scripts/benchmark.mjs <sessions-dir> --ratio 0.1` if
|
|
113
|
+
* the representative corpus changes meaningfully.
|
|
114
|
+
*/
|
|
115
|
+
const DEFAULT_BREAK_EVEN_THRESHOLD = 22;
|
|
116
|
+
|
|
117
|
+
/** Tool names never pruned by default: orchestration/subagent-style tools and todo-like state. */
|
|
118
|
+
const DEFAULT_PROTECTED_TOOL_NAMES = [
|
|
119
|
+
"todo",
|
|
120
|
+
"task",
|
|
121
|
+
"subagent",
|
|
122
|
+
"agent",
|
|
123
|
+
"skill",
|
|
124
|
+
"oracle",
|
|
125
|
+
"librarian",
|
|
126
|
+
"contrarian",
|
|
127
|
+
"code_reviewer",
|
|
128
|
+
"gnosis",
|
|
129
|
+
"triage_comments",
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
/** File globs never pruned by default: secrets/credential-shaped paths. */
|
|
133
|
+
const DEFAULT_PROTECTED_PATH_GLOBS = [
|
|
134
|
+
"**/.env",
|
|
135
|
+
"**/.env.*",
|
|
136
|
+
"**/*.pem",
|
|
137
|
+
"**/*.key",
|
|
138
|
+
"**/id_rsa*",
|
|
139
|
+
"**/id_ed25519*",
|
|
140
|
+
"**/secrets/**",
|
|
141
|
+
"**/*.p12",
|
|
142
|
+
"**/*.pfx",
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const DEFAULT_MIN_CHARS_SAVED = 200;
|
|
146
|
+
|
|
147
|
+
/** Default "older than N turns" threshold for the error-input purge strategy. */
|
|
148
|
+
const DEFAULT_ERROR_PURGE_MIN_TURNS_OLD = 4;
|
|
149
|
+
|
|
150
|
+
const DEDUPE_STRATEGY_ID = "dedupe";
|
|
151
|
+
const ERROR_PURGE_STRATEGY_ID = "error-purge";
|
|
152
|
+
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// Config
|
|
155
|
+
// ============================================================================
|
|
156
|
+
|
|
157
|
+
export interface PruneProtections {
|
|
158
|
+
/** Tool names (case-insensitive) whose calls are never pruned. */
|
|
159
|
+
toolNames: string[];
|
|
160
|
+
/** Glob patterns matched against string arguments; matches are never pruned. */
|
|
161
|
+
pathGlobs: string[];
|
|
162
|
+
/** Number of most recent conversational turns left untouched. */
|
|
163
|
+
recentTurns: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface PruneThresholds {
|
|
167
|
+
/**
|
|
168
|
+
* Minimum characters a prune must remove to be worth persisting. Reserved
|
|
169
|
+
* for a future minimum-size filter; NOT used by the net-benefit gate
|
|
170
|
+
* (see `PruneGateConfig`/`gate`), which uses break-even token math instead.
|
|
171
|
+
* Not enforced by this pipeline yet.
|
|
172
|
+
*/
|
|
173
|
+
minCharsSaved: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Per-strategy toggles/config (pe-u8gd, pe-qs8j). Each strategy can be disabled independently. */
|
|
177
|
+
export interface StrategiesConfig {
|
|
178
|
+
dedupe: { enabled: boolean };
|
|
179
|
+
errorPurge: {
|
|
180
|
+
enabled: boolean;
|
|
181
|
+
/** A tool call's input is only eligible for purging once it is older than this many turns. */
|
|
182
|
+
minTurnsOld: number;
|
|
183
|
+
};
|
|
184
|
+
/** Superseded file-ops strategy (pe-qs8j): stale read/write/edit outputs replaced by a placeholder. */
|
|
185
|
+
supersededFileOps: { enabled: boolean };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Net-benefit gate operating mode (pe-s2ho). */
|
|
189
|
+
export type GateMode = "on" | "off" | "always-apply";
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* State-conditioning hook (pe-s2ho notes): lets the gate use a different
|
|
193
|
+
* break-even threshold depending on whether the agent is mid-loop (actively
|
|
194
|
+
* making tool calls) vs idle (waiting on the user). Both states default to
|
|
195
|
+
* the same threshold today.
|
|
196
|
+
*
|
|
197
|
+
* pe-c5n9 evidence update: the representative-corpus benchmark shows
|
|
198
|
+
* mid_loop candidates carry ~all realized net benefit while idle candidates
|
|
199
|
+
* carry ~none (literally 0 at r=0.1). That would argue for a stricter idle
|
|
200
|
+
* default. However, the only live runtime caller (the `context` event
|
|
201
|
+
* handler below) does not yet perform real mid-loop/idle detection — it
|
|
202
|
+
* always passes agentState:"idle". Defaulting idle to a much stricter
|
|
203
|
+
* threshold today would therefore silently disable most automatic pruning
|
|
204
|
+
* for everyone (100% of live evaluations run as "idle"), which is a far
|
|
205
|
+
* bigger behavior change than a threshold recalibration. So the per-state
|
|
206
|
+
* split is intentionally left at parity for now; a follow-up ticket should
|
|
207
|
+
* wire real agent-state detection through `ctx` and then apply the
|
|
208
|
+
* idle-vs-mid_loop split (idle stricter, mid_loop at DEFAULT_BREAK_EVEN_THRESHOLD)
|
|
209
|
+
* once mid_loop is actually reachable.
|
|
210
|
+
*/
|
|
211
|
+
export type AgentState = "idle" | "mid_loop";
|
|
212
|
+
|
|
213
|
+
export interface PruneGateConfig {
|
|
214
|
+
/** "on": gate rejects new prunes above threshold. "off": gate is bypassed
|
|
215
|
+
* entirely (no cost modelling, everything applies). "always-apply": cost
|
|
216
|
+
* is still modelled (for stats/observability) but nothing is ever rejected. */
|
|
217
|
+
mode: GateMode;
|
|
218
|
+
/** Fraction of full price still paid for cached ( r ). Default 0.1. */
|
|
219
|
+
cachedPriceRatio: number;
|
|
220
|
+
/**
|
|
221
|
+
* Default break-even threshold (max future calls to amortize a cache
|
|
222
|
+
* bust) used when no state-specific override applies. Recalibrated from
|
|
223
|
+
* the representative-corpus benchmark; see DEFAULT_BREAK_EVEN_THRESHOLD
|
|
224
|
+
* for the full rationale and ratio-sensitivity caveats.
|
|
225
|
+
*/
|
|
226
|
+
breakEvenThreshold: number;
|
|
227
|
+
/** Per-agent-state threshold overrides; both default to `breakEvenThreshold`. */
|
|
228
|
+
breakEvenThresholdByState: { idle: number; mid_loop: number };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface DynamicContextPruningConfig {
|
|
232
|
+
enabled: boolean;
|
|
233
|
+
protections: PruneProtections;
|
|
234
|
+
thresholds: PruneThresholds;
|
|
235
|
+
gate: PruneGateConfig;
|
|
236
|
+
strategies: StrategiesConfig;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function defaultConfig(): DynamicContextPruningConfig {
|
|
240
|
+
return {
|
|
241
|
+
enabled: true,
|
|
242
|
+
protections: {
|
|
243
|
+
toolNames: [...DEFAULT_PROTECTED_TOOL_NAMES],
|
|
244
|
+
pathGlobs: [...DEFAULT_PROTECTED_PATH_GLOBS],
|
|
245
|
+
recentTurns: DEFAULT_RECENT_TURNS,
|
|
246
|
+
},
|
|
247
|
+
thresholds: {
|
|
248
|
+
minCharsSaved: DEFAULT_MIN_CHARS_SAVED,
|
|
249
|
+
},
|
|
250
|
+
strategies: {
|
|
251
|
+
dedupe: { enabled: true },
|
|
252
|
+
errorPurge: { enabled: true, minTurnsOld: DEFAULT_ERROR_PURGE_MIN_TURNS_OLD },
|
|
253
|
+
supersededFileOps: { enabled: true },
|
|
254
|
+
},
|
|
255
|
+
gate: {
|
|
256
|
+
mode: "on",
|
|
257
|
+
cachedPriceRatio: DEFAULT_CACHED_PRICE_RATIO,
|
|
258
|
+
breakEvenThreshold: DEFAULT_BREAK_EVEN_THRESHOLD,
|
|
259
|
+
breakEvenThresholdByState: { idle: DEFAULT_BREAK_EVEN_THRESHOLD, mid_loop: DEFAULT_BREAK_EVEN_THRESHOLD },
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function asStringArray(value: unknown, fallback: string[]): string[] {
|
|
265
|
+
if (!Array.isArray(value)) return fallback;
|
|
266
|
+
const out = value.filter((item): item is string => typeof item === "string" && item.length > 0);
|
|
267
|
+
return out.length > 0 ? out : fallback;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function asNonNegativeInt(value: unknown, fallback: number): number {
|
|
271
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
272
|
+
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
|
273
|
+
return Math.floor(parsed);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function asNonNegativeNumber(value: unknown, fallback: number): number {
|
|
277
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
278
|
+
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
|
279
|
+
return parsed;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function asRatio(value: unknown, fallback: number): number {
|
|
283
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
284
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) return fallback;
|
|
285
|
+
return parsed;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function asGateMode(value: unknown, fallback: GateMode): GateMode {
|
|
289
|
+
return value === "on" || value === "off" || value === "always-apply" ? value : fallback;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function asBoolean(value: unknown, fallback: boolean): boolean {
|
|
293
|
+
return typeof value === "boolean" ? value : fallback;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Merge a partially-shaped parsed config (e.g. from disk) onto defaults. Never throws. */
|
|
297
|
+
export function normalizeConfig(parsed: unknown): DynamicContextPruningConfig {
|
|
298
|
+
const defaults = defaultConfig();
|
|
299
|
+
if (!parsed || typeof parsed !== "object") return defaults;
|
|
300
|
+
const raw = parsed as Record<string, unknown>;
|
|
301
|
+
const rawProtections = (raw.protections && typeof raw.protections === "object" ? raw.protections : {}) as Record<
|
|
302
|
+
string,
|
|
303
|
+
unknown
|
|
304
|
+
>;
|
|
305
|
+
const rawThresholds = (raw.thresholds && typeof raw.thresholds === "object" ? raw.thresholds : {}) as Record<
|
|
306
|
+
string,
|
|
307
|
+
unknown
|
|
308
|
+
>;
|
|
309
|
+
const rawGate = (raw.gate && typeof raw.gate === "object" ? raw.gate : {}) as Record<string, unknown>;
|
|
310
|
+
const rawGateByState = (rawGate.breakEvenThresholdByState && typeof rawGate.breakEvenThresholdByState === "object"
|
|
311
|
+
? rawGate.breakEvenThresholdByState
|
|
312
|
+
: {}) as Record<string, unknown>;
|
|
313
|
+
const rawStrategies = (raw.strategies && typeof raw.strategies === "object" ? raw.strategies : {}) as Record<string, unknown>;
|
|
314
|
+
const rawDedupe = (rawStrategies.dedupe && typeof rawStrategies.dedupe === "object" ? rawStrategies.dedupe : {}) as Record<
|
|
315
|
+
string,
|
|
316
|
+
unknown
|
|
317
|
+
>;
|
|
318
|
+
const rawErrorPurge = (rawStrategies.errorPurge && typeof rawStrategies.errorPurge === "object"
|
|
319
|
+
? rawStrategies.errorPurge
|
|
320
|
+
: {}) as Record<string, unknown>;
|
|
321
|
+
const rawSupersededFileOps = (rawStrategies.supersededFileOps && typeof rawStrategies.supersededFileOps === "object"
|
|
322
|
+
? rawStrategies.supersededFileOps
|
|
323
|
+
: {}) as Record<string, unknown>;
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : defaults.enabled,
|
|
327
|
+
protections: {
|
|
328
|
+
toolNames: asStringArray(rawProtections.toolNames, defaults.protections.toolNames),
|
|
329
|
+
pathGlobs: asStringArray(rawProtections.pathGlobs, defaults.protections.pathGlobs),
|
|
330
|
+
recentTurns: asNonNegativeInt(rawProtections.recentTurns, defaults.protections.recentTurns),
|
|
331
|
+
},
|
|
332
|
+
thresholds: {
|
|
333
|
+
minCharsSaved: asNonNegativeInt(rawThresholds.minCharsSaved, defaults.thresholds.minCharsSaved),
|
|
334
|
+
},
|
|
335
|
+
strategies: {
|
|
336
|
+
dedupe: { enabled: asBoolean(rawDedupe.enabled, defaults.strategies.dedupe.enabled) },
|
|
337
|
+
errorPurge: {
|
|
338
|
+
enabled: asBoolean(rawErrorPurge.enabled, defaults.strategies.errorPurge.enabled),
|
|
339
|
+
minTurnsOld: asNonNegativeInt(rawErrorPurge.minTurnsOld, defaults.strategies.errorPurge.minTurnsOld),
|
|
340
|
+
},
|
|
341
|
+
supersededFileOps: {
|
|
342
|
+
enabled: asBoolean(rawSupersededFileOps.enabled, defaults.strategies.supersededFileOps.enabled),
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
gate: {
|
|
346
|
+
mode: asGateMode(rawGate.mode, defaults.gate.mode),
|
|
347
|
+
cachedPriceRatio: asRatio(rawGate.cachedPriceRatio, defaults.gate.cachedPriceRatio),
|
|
348
|
+
breakEvenThreshold: asNonNegativeNumber(rawGate.breakEvenThreshold, defaults.gate.breakEvenThreshold),
|
|
349
|
+
breakEvenThresholdByState: {
|
|
350
|
+
idle: asNonNegativeNumber(rawGateByState.idle, defaults.gate.breakEvenThresholdByState.idle),
|
|
351
|
+
mid_loop: asNonNegativeNumber(rawGateByState.mid_loop, defaults.gate.breakEvenThresholdByState.mid_loop),
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function getConfigPath(): string {
|
|
358
|
+
return path.join(getAgentDir(), "extensions", CONFIG_FILE_NAME);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function readConfig(): Promise<DynamicContextPruningConfig> {
|
|
362
|
+
try {
|
|
363
|
+
const raw = await fs.readFile(getConfigPath(), "utf8");
|
|
364
|
+
return normalizeConfig(JSON.parse(raw));
|
|
365
|
+
} catch {
|
|
366
|
+
return defaultConfig();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function writeConfig(config: DynamicContextPruningConfig): Promise<void> {
|
|
371
|
+
const configPath = getConfigPath();
|
|
372
|
+
const payload = { ...config, updatedAt: new Date().toISOString() };
|
|
373
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
374
|
+
await fs.writeFile(configPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ============================================================================
|
|
378
|
+
// Token estimator (chars/4-style; deliberately simple, good enough for
|
|
379
|
+
// relative before/after accounting used by later savings-accounting work).
|
|
380
|
+
// ============================================================================
|
|
381
|
+
|
|
382
|
+
export function estimateTokensForText(text: string): number {
|
|
383
|
+
if (!text) return 0;
|
|
384
|
+
return Math.max(0, Math.ceil(text.length / 4));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function estimateTokensForContent(content: string | (MinimalTextContent | MinimalImageContent)[]): number {
|
|
388
|
+
if (typeof content === "string") return estimateTokensForText(content);
|
|
389
|
+
if (!Array.isArray(content)) return 0;
|
|
390
|
+
let total = 0;
|
|
391
|
+
for (const block of content) {
|
|
392
|
+
if (block.type === "text") total += estimateTokensForText(block.text);
|
|
393
|
+
else if (block.type === "image") total += 1200; // rough fixed estimate, mirrors context-inspector's convention
|
|
394
|
+
}
|
|
395
|
+
return total;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export type TokenEstimator = (text: string) => number;
|
|
399
|
+
|
|
400
|
+
/** Resolve which break-even threshold applies for a given agent state (pe-s2ho state-conditioning hook). */
|
|
401
|
+
export function resolveBreakEvenThreshold(gateConfig: PruneGateConfig, agentState: AgentState): number {
|
|
402
|
+
const override = gateConfig.breakEvenThresholdByState[agentState];
|
|
403
|
+
return typeof override === "number" && Number.isFinite(override) ? override : gateConfig.breakEvenThreshold;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Estimate the token cost of a single message (all content kinds), for tail/context-size accounting. */
|
|
407
|
+
function estimateMessageTokens(message: MinimalMessage, estimateTokens: TokenEstimator): number {
|
|
408
|
+
if (message.role === "user" || message.role === "toolResult") {
|
|
409
|
+
const content = (message as MinimalUserMessage | MinimalToolResultMessage).content;
|
|
410
|
+
return estimateTokensForContent(content as string | (MinimalTextContent | MinimalImageContent)[]);
|
|
411
|
+
}
|
|
412
|
+
if (message.role === "assistant") {
|
|
413
|
+
let total = 0;
|
|
414
|
+
for (const block of (message as MinimalAssistantMessage).content) {
|
|
415
|
+
if (block.type === "text") total += estimateTokens(block.text);
|
|
416
|
+
else if (block.type === "thinking") total += estimateTokens(block.thinking);
|
|
417
|
+
else if (block.type === "toolCall") total += estimateTokens(JSON.stringify(block.arguments ?? {}));
|
|
418
|
+
}
|
|
419
|
+
return total;
|
|
420
|
+
}
|
|
421
|
+
return 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Sum estimated tokens for messages from `fromIndex` to the end of the array.
|
|
426
|
+
* Used both for cache-bust tail sizing (pe-s2ho cost model) and for raw/
|
|
427
|
+
* effective context-size snapshots. Clamped to valid indices; an out-of-range
|
|
428
|
+
* `fromIndex` (e.g. the very last message, or beyond) degrades gracefully.
|
|
429
|
+
*/
|
|
430
|
+
export function estimateTailTokens(
|
|
431
|
+
messages: MinimalMessage[],
|
|
432
|
+
fromIndex: number,
|
|
433
|
+
estimateTokens: TokenEstimator = estimateTokensForText,
|
|
434
|
+
): number {
|
|
435
|
+
let total = 0;
|
|
436
|
+
const start = Math.max(0, fromIndex);
|
|
437
|
+
for (let i = start; i < messages.length; i++) {
|
|
438
|
+
total += estimateMessageTokens(messages[i], estimateTokens);
|
|
439
|
+
}
|
|
440
|
+
return total;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ============================================================================
|
|
444
|
+
// Protections
|
|
445
|
+
// ============================================================================
|
|
446
|
+
|
|
447
|
+
function globToRegExp(glob: string): RegExp {
|
|
448
|
+
let pattern = "^";
|
|
449
|
+
for (let i = 0; i < glob.length; i++) {
|
|
450
|
+
const char = glob[i];
|
|
451
|
+
if (char === "*") {
|
|
452
|
+
if (glob[i + 1] === "*") {
|
|
453
|
+
i++;
|
|
454
|
+
if (glob[i + 1] === "/") {
|
|
455
|
+
pattern += "(?:.*/)?";
|
|
456
|
+
i++;
|
|
457
|
+
} else {
|
|
458
|
+
pattern += ".*";
|
|
459
|
+
}
|
|
460
|
+
} else {
|
|
461
|
+
pattern += "[^/]*";
|
|
462
|
+
}
|
|
463
|
+
} else if (char === "?") {
|
|
464
|
+
pattern += "[^/]";
|
|
465
|
+
} else if ("\\^$.|?*+()[]{}".includes(char)) {
|
|
466
|
+
pattern += `\\${char}`;
|
|
467
|
+
} else {
|
|
468
|
+
pattern += char;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
pattern += "$";
|
|
472
|
+
return new RegExp(pattern);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function isProtectedToolName(toolName: string | undefined, protections: PruneProtections): boolean {
|
|
476
|
+
if (!toolName) return false;
|
|
477
|
+
const normalized = toolName.trim().toLowerCase();
|
|
478
|
+
return protections.toolNames.some((name) => name.trim().toLowerCase() === normalized);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function isProtectedPath(value: string, protections: PruneProtections): boolean {
|
|
482
|
+
if (!value) return false;
|
|
483
|
+
const normalized = value.trim();
|
|
484
|
+
if (!normalized) return false;
|
|
485
|
+
return protections.pathGlobs.some((glob) => globToRegExp(glob).test(normalized));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Shallow-collect string values from a tool-call arguments object, for path-glob matching. */
|
|
489
|
+
export function collectArgStringValues(args: Record<string, unknown> | undefined, depth = 2): string[] {
|
|
490
|
+
if (!args || typeof args !== "object") return [];
|
|
491
|
+
const out: string[] = [];
|
|
492
|
+
const visit = (value: unknown, remainingDepth: number) => {
|
|
493
|
+
if (typeof value === "string") {
|
|
494
|
+
out.push(value);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (remainingDepth <= 0) return;
|
|
498
|
+
if (Array.isArray(value)) {
|
|
499
|
+
for (const item of value) visit(item, remainingDepth - 1);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (value && typeof value === "object") {
|
|
503
|
+
for (const item of Object.values(value as Record<string, unknown>)) visit(item, remainingDepth - 1);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
visit(args, depth);
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Index of the first message index considered part of the "recent" protected
|
|
512
|
+
* window. Messages at or after this index must never be pruned.
|
|
513
|
+
*
|
|
514
|
+
* A "turn" starts at each user message. Protects the last `recentTurns` turns.
|
|
515
|
+
*/
|
|
516
|
+
export function computeRecencyBoundaryIndex(messages: MinimalMessage[], recentTurns: number): number {
|
|
517
|
+
if (recentTurns <= 0) return messages.length;
|
|
518
|
+
const turnStarts: number[] = [];
|
|
519
|
+
messages.forEach((message, index) => {
|
|
520
|
+
if (message.role === "user") turnStarts.push(index);
|
|
521
|
+
});
|
|
522
|
+
if (turnStarts.length === 0) return 0;
|
|
523
|
+
const startIdx = Math.max(0, turnStarts.length - recentTurns);
|
|
524
|
+
return turnStarts[startIdx] ?? 0;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function isWithinRecencyWindow(index: number, boundaryIndex: number): boolean {
|
|
528
|
+
return index >= boundaryIndex;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Number of user-started turns that have elapsed strictly after `index`.
|
|
533
|
+
* Used by the error-input purge strategy's "older than N turns" rule; a
|
|
534
|
+
* message at the very end of the conversation has 0 turns elapsed.
|
|
535
|
+
*/
|
|
536
|
+
export function computeTurnsElapsedSince(messages: MinimalMessage[], index: number): number {
|
|
537
|
+
let count = 0;
|
|
538
|
+
for (let i = index + 1; i < messages.length; i++) {
|
|
539
|
+
if (messages[i].role === "user") count++;
|
|
540
|
+
}
|
|
541
|
+
return count;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ============================================================================
|
|
545
|
+
// Correlation
|
|
546
|
+
// ============================================================================
|
|
547
|
+
|
|
548
|
+
export interface ToolCallPairIndices {
|
|
549
|
+
assistantIndex?: number;
|
|
550
|
+
toolCallBlockIndex?: number;
|
|
551
|
+
resultIndex?: number;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Build a toolCallId -> pair-indices index for every tool call/result found
|
|
556
|
+
* in `messages`, in a single O(n) pass. Callers that need to resolve many
|
|
557
|
+
* toolCallIds against the same `messages` array (e.g. the pipeline's
|
|
558
|
+
* per-decision protection/savings/apply passes) should build this once and
|
|
559
|
+
* pass it to `findToolCallPairIndices` and friends to avoid an O(n) rescan
|
|
560
|
+
* per decision (pe-e0zd).
|
|
561
|
+
*/
|
|
562
|
+
export function buildToolCallPairIndex(messages: MinimalMessage[]): Map<string, ToolCallPairIndices> {
|
|
563
|
+
const index = new Map<string, ToolCallPairIndices>();
|
|
564
|
+
const getOrCreate = (toolCallId: string): ToolCallPairIndices => {
|
|
565
|
+
let pair = index.get(toolCallId);
|
|
566
|
+
if (!pair) {
|
|
567
|
+
pair = {};
|
|
568
|
+
index.set(toolCallId, pair);
|
|
569
|
+
}
|
|
570
|
+
return pair;
|
|
571
|
+
};
|
|
572
|
+
messages.forEach((message, messageIndex) => {
|
|
573
|
+
if (message.role === "assistant" && Array.isArray((message as MinimalAssistantMessage).content)) {
|
|
574
|
+
const content = (message as MinimalAssistantMessage).content;
|
|
575
|
+
const seenInThisMessage = new Set<string>();
|
|
576
|
+
content.forEach((block, blockIndex) => {
|
|
577
|
+
if (block.type !== "toolCall") return;
|
|
578
|
+
// Mirror findToolCallPairIndices' content.findIndex: within a single
|
|
579
|
+
// message, the FIRST matching block wins (duplicate ids in one
|
|
580
|
+
// message's content are pathological but must resolve identically).
|
|
581
|
+
if (seenInThisMessage.has(block.id)) return;
|
|
582
|
+
seenInThisMessage.add(block.id);
|
|
583
|
+
const pair = getOrCreate(block.id);
|
|
584
|
+
pair.assistantIndex = messageIndex;
|
|
585
|
+
pair.toolCallBlockIndex = blockIndex;
|
|
586
|
+
});
|
|
587
|
+
} else if (message.role === "toolResult") {
|
|
588
|
+
const pair = getOrCreate((message as MinimalToolResultMessage).toolCallId);
|
|
589
|
+
pair.resultIndex = messageIndex;
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
return index;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Primary correlation: locate an assistant toolCall block and/or its
|
|
597
|
+
* toolResult by toolCallId. Pass a precomputed `pairIndex` (see
|
|
598
|
+
* `buildToolCallPairIndex`) to resolve in O(1) instead of rescanning
|
|
599
|
+
* `messages`; omitting it preserves the original O(n) full-scan behavior.
|
|
600
|
+
*/
|
|
601
|
+
export function findToolCallPairIndices(
|
|
602
|
+
messages: MinimalMessage[],
|
|
603
|
+
toolCallId: string,
|
|
604
|
+
pairIndex?: Map<string, ToolCallPairIndices>,
|
|
605
|
+
): ToolCallPairIndices {
|
|
606
|
+
if (pairIndex) return pairIndex.get(toolCallId) ?? {};
|
|
607
|
+
const pair: ToolCallPairIndices = {};
|
|
608
|
+
messages.forEach((message, index) => {
|
|
609
|
+
if (message.role === "assistant" && Array.isArray((message as MinimalAssistantMessage).content)) {
|
|
610
|
+
const content = (message as MinimalAssistantMessage).content;
|
|
611
|
+
const blockIndex = content.findIndex((block) => block.type === "toolCall" && block.id === toolCallId);
|
|
612
|
+
if (blockIndex >= 0) {
|
|
613
|
+
pair.assistantIndex = index;
|
|
614
|
+
pair.toolCallBlockIndex = blockIndex;
|
|
615
|
+
}
|
|
616
|
+
} else if (message.role === "toolResult" && (message as MinimalToolResultMessage).toolCallId === toolCallId) {
|
|
617
|
+
pair.resultIndex = index;
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
return pair;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Fallback correlation for decisions that reference a session entry rather
|
|
625
|
+
* than a toolCallId (reserved for future non-tool-call strategies). Zips
|
|
626
|
+
* message-producing session entries against the current context projection
|
|
627
|
+
* by position; entry/message counts are expected to match in the common
|
|
628
|
+
* case, but this degrades gracefully otherwise.
|
|
629
|
+
*/
|
|
630
|
+
function expectedRoleForEntry(entry: MinimalSessionEntry): string | undefined {
|
|
631
|
+
if (entry.type === "message") return entry.message?.role;
|
|
632
|
+
if (entry.type === "custom_message") return "custom";
|
|
633
|
+
if (entry.type === "branch_summary") return "branchSummary";
|
|
634
|
+
if (entry.type === "compaction") return "compactionSummary";
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export function buildEntryIdToMessageIndexMap(
|
|
639
|
+
entries: MinimalSessionEntry[],
|
|
640
|
+
messages: MinimalMessage[],
|
|
641
|
+
): Map<string, number> {
|
|
642
|
+
const map = new Map<string, number>();
|
|
643
|
+
const producing = entries.filter((entry) =>
|
|
644
|
+
entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary" || entry.type === "compaction",
|
|
645
|
+
);
|
|
646
|
+
const length = Math.min(producing.length, messages.length);
|
|
647
|
+
for (let i = 0; i < length; i++) {
|
|
648
|
+
const entry = producing[i];
|
|
649
|
+
const expectedRole = expectedRoleForEntry(entry);
|
|
650
|
+
const candidate = messages[i];
|
|
651
|
+
// Guard against silent misalignment (e.g. compaction/branch-summary shifted
|
|
652
|
+
// the projection): only trust the positional zip when roles line up,
|
|
653
|
+
// otherwise leave it unset so callers fall back to role+timestamp matching.
|
|
654
|
+
if (expectedRole && candidate?.role !== expectedRole) continue;
|
|
655
|
+
map.set(entry.id, i);
|
|
656
|
+
}
|
|
657
|
+
return map;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Last-resort correlation fallback: match by role + timestamp when order-zip counts diverge. */
|
|
661
|
+
export function findMessageIndexByRoleAndTimestamp(
|
|
662
|
+
entry: MinimalSessionEntry,
|
|
663
|
+
messages: MinimalMessage[],
|
|
664
|
+
): number | undefined {
|
|
665
|
+
const message = entry.message;
|
|
666
|
+
if (!message || typeof message.role !== "string" || typeof message.timestamp !== "number") return undefined;
|
|
667
|
+
const index = messages.findIndex((m) => m.role === message.role && m.timestamp === message.timestamp);
|
|
668
|
+
return index >= 0 ? index : undefined;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Resolve a session entry id to a message index using order-zip, then role+timestamp fallback. */
|
|
672
|
+
export function resolveMessageIndexForEntry(
|
|
673
|
+
entryId: string,
|
|
674
|
+
entries: MinimalSessionEntry[],
|
|
675
|
+
messages: MinimalMessage[],
|
|
676
|
+
): number | undefined {
|
|
677
|
+
const zipMap = buildEntryIdToMessageIndexMap(entries, messages);
|
|
678
|
+
const zipped = zipMap.get(entryId);
|
|
679
|
+
if (zipped !== undefined) return zipped;
|
|
680
|
+
const entry = entries.find((candidate) => candidate.id === entryId);
|
|
681
|
+
if (!entry) return undefined;
|
|
682
|
+
return findMessageIndexByRoleAndTimestamp(entry, messages);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ============================================================================
|
|
686
|
+
// Proposals & persisted decisions
|
|
687
|
+
// ============================================================================
|
|
688
|
+
|
|
689
|
+
export type PruneTargetKind = "tool_result_content" | "tool_call_input";
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Where a decision came from. "manual" decisions (e.g. a future /prune
|
|
693
|
+
* picker, pe-8re9) always bypass the net-benefit gate: user intent wins.
|
|
694
|
+
* Absent/unspecified defaults to "auto".
|
|
695
|
+
*/
|
|
696
|
+
export type PruneDecisionSource = "auto" | "manual";
|
|
697
|
+
|
|
698
|
+
/** What a strategy proposes. The pipeline decides whether/how to apply it. */
|
|
699
|
+
export interface ProposedPrune {
|
|
700
|
+
strategyId: string;
|
|
701
|
+
toolCallId: string;
|
|
702
|
+
kind: PruneTargetKind;
|
|
703
|
+
reason: string;
|
|
704
|
+
placeholder?: string;
|
|
705
|
+
/** Defaults to "auto"; set to "manual" for user-initiated prunes that must bypass the gate. */
|
|
706
|
+
source?: PruneDecisionSource;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** What actually gets persisted once the pipeline applies a proposal. */
|
|
710
|
+
export interface PruneDecisionRecord {
|
|
711
|
+
idempotencyKey: string;
|
|
712
|
+
strategyId: string;
|
|
713
|
+
correlation: { type: "toolCallId"; toolCallId: string } | { type: "entryId"; entryId: string };
|
|
714
|
+
kind: PruneTargetKind;
|
|
715
|
+
reason: string;
|
|
716
|
+
placeholder?: string;
|
|
717
|
+
createdAt: string;
|
|
718
|
+
source: PruneDecisionSource;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function buildIdempotencyKey(proposal: ProposedPrune): string {
|
|
722
|
+
return `${proposal.strategyId}:${proposal.kind}:${proposal.toolCallId}`;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Key identifying WHAT a decision targets (kind + correlation), deliberately
|
|
727
|
+
* omitting `strategyId` (unlike `idempotencyKey`). Two decisions from
|
|
728
|
+
* different strategies that both replace the same tool result's content
|
|
729
|
+
* share this key even though their idempotencyKeys differ — that's exactly
|
|
730
|
+
* the overlap pe-j7sb collapses before gating/apply (see
|
|
731
|
+
* `runDynamicContextPruningPipeline`).
|
|
732
|
+
*/
|
|
733
|
+
function buildDecisionTargetKey(decision: PruneDecisionRecord): string {
|
|
734
|
+
const correlationId = decision.correlation.type === "toolCallId" ? decision.correlation.toolCallId : decision.correlation.entryId;
|
|
735
|
+
return `${decision.kind}:${decision.correlation.type}:${correlationId}`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export function proposalToDecisionRecord(proposal: ProposedPrune, createdAt: string = new Date().toISOString()): PruneDecisionRecord {
|
|
739
|
+
return {
|
|
740
|
+
idempotencyKey: buildIdempotencyKey(proposal),
|
|
741
|
+
strategyId: proposal.strategyId,
|
|
742
|
+
correlation: { type: "toolCallId", toolCallId: proposal.toolCallId },
|
|
743
|
+
kind: proposal.kind,
|
|
744
|
+
reason: proposal.reason,
|
|
745
|
+
placeholder: proposal.placeholder,
|
|
746
|
+
createdAt,
|
|
747
|
+
source: proposal.source ?? "auto",
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Parse a persisted CustomEntry payload back into a decision record. Returns undefined if malformed. */
|
|
752
|
+
export function parseDecisionRecord(data: unknown): PruneDecisionRecord | undefined {
|
|
753
|
+
if (!data || typeof data !== "object") return undefined;
|
|
754
|
+
const raw = data as Record<string, unknown>;
|
|
755
|
+
if (typeof raw.idempotencyKey !== "string" || !raw.idempotencyKey) return undefined;
|
|
756
|
+
if (typeof raw.strategyId !== "string") return undefined;
|
|
757
|
+
if (raw.kind !== "tool_result_content" && raw.kind !== "tool_call_input") return undefined;
|
|
758
|
+
if (typeof raw.reason !== "string") return undefined;
|
|
759
|
+
if (typeof raw.createdAt !== "string") return undefined;
|
|
760
|
+
const source: PruneDecisionSource = raw.source === "manual" ? "manual" : "auto";
|
|
761
|
+
|
|
762
|
+
const rawCorrelation = raw.correlation as Record<string, unknown> | undefined;
|
|
763
|
+
let correlation: PruneDecisionRecord["correlation"] | undefined;
|
|
764
|
+
if (rawCorrelation?.type === "toolCallId" && typeof rawCorrelation.toolCallId === "string") {
|
|
765
|
+
correlation = { type: "toolCallId", toolCallId: rawCorrelation.toolCallId };
|
|
766
|
+
} else if (rawCorrelation?.type === "entryId" && typeof rawCorrelation.entryId === "string") {
|
|
767
|
+
correlation = { type: "entryId", entryId: rawCorrelation.entryId };
|
|
768
|
+
}
|
|
769
|
+
if (!correlation) return undefined;
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
idempotencyKey: raw.idempotencyKey,
|
|
773
|
+
strategyId: raw.strategyId,
|
|
774
|
+
correlation,
|
|
775
|
+
kind: raw.kind,
|
|
776
|
+
reason: raw.reason,
|
|
777
|
+
placeholder: typeof raw.placeholder === "string" ? raw.placeholder : undefined,
|
|
778
|
+
createdAt: raw.createdAt,
|
|
779
|
+
source,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ============================================================================
|
|
784
|
+
// Restore / undo (pe-8re9): tombstones that reverse a persisted decision
|
|
785
|
+
// ============================================================================
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* A restore record is a tombstone: it records that the decision identified by
|
|
789
|
+
* `idempotencyKey` should stop being applied, WITHOUT deleting or mutating
|
|
790
|
+
* the original decision entry (the branch's persisted-entry log is
|
|
791
|
+
* append-only, same as decisions/stats). Restoring never "forgets" that a
|
|
792
|
+
* prune happened; it just flips it inactive.
|
|
793
|
+
*
|
|
794
|
+
* Restores are resolved chronologically against decision entries for the
|
|
795
|
+
* SAME idempotencyKey (see `resolvePruneTombstoneState`): whichever entry
|
|
796
|
+
* (decision or restore) for a given key is LATEST wins. This makes
|
|
797
|
+
* prune -> restore -> re-prune -> restore ... sequences behave correctly and
|
|
798
|
+
* idempotently no matter how many times they replay, without needing to
|
|
799
|
+
* mutate or delete anything.
|
|
800
|
+
*/
|
|
801
|
+
export interface RestoreRecord {
|
|
802
|
+
idempotencyKey: string;
|
|
803
|
+
createdAt: string;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export function buildRestoreRecord(idempotencyKey: string, createdAt: string = new Date().toISOString()): RestoreRecord {
|
|
807
|
+
return { idempotencyKey, createdAt };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Parse a persisted CustomEntry payload back into a restore record. Returns undefined if malformed. */
|
|
811
|
+
export function parseRestoreRecord(data: unknown): RestoreRecord | undefined {
|
|
812
|
+
if (!data || typeof data !== "object") return undefined;
|
|
813
|
+
const raw = data as Record<string, unknown>;
|
|
814
|
+
if (typeof raw.idempotencyKey !== "string" || !raw.idempotencyKey) return undefined;
|
|
815
|
+
if (typeof raw.createdAt !== "string") return undefined;
|
|
816
|
+
return { idempotencyKey: raw.idempotencyKey, createdAt: raw.createdAt };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Build the manual-prune proposal a /prune picker "prune" action emits. Always source: "manual" (gate bypass). */
|
|
820
|
+
export function buildManualPruneProposal(toolCallId: string, reason: string = "manual prune via /prune"): ProposedPrune {
|
|
821
|
+
return { strategyId: MANUAL_STRATEGY_ID, toolCallId, kind: "tool_result_content", reason, source: "manual" };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export interface PruneTombstoneState {
|
|
825
|
+
/** Decisions whose most recent event (decision vs restore) for their idempotencyKey is a decision. */
|
|
826
|
+
activeDecisions: PruneDecisionRecord[];
|
|
827
|
+
/** idempotencyKeys of `activeDecisions`, for pipeline seen/known-key checks. */
|
|
828
|
+
activeIdempotencyKeys: Set<string>;
|
|
829
|
+
/** idempotencyKeys whose most recent event is a restore: currently tombstoned/inactive. */
|
|
830
|
+
restoredKeys: Set<string>;
|
|
831
|
+
/** Most-recently-seen decision record per key, regardless of current tombstone state (for /prune history display). */
|
|
832
|
+
lastDecisionByKey: Map<string, PruneDecisionRecord>;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Chronologically resolve decision + restore CustomEntry records on a branch
|
|
837
|
+
* into "currently active" vs "currently restored (tombstoned)" state, per
|
|
838
|
+
* idempotencyKey. For each key, whichever entry (decision or restore)
|
|
839
|
+
* appears LAST in `entries` wins. This is the single source of truth
|
|
840
|
+
* consumed both by the pipeline (to stop automatic strategies from silently
|
|
841
|
+
* re-persisting a just-restored decision) and by the /prune picker (to show
|
|
842
|
+
* accurate status and support prune/restore/re-prune).
|
|
843
|
+
*
|
|
844
|
+
* Idempotent/rebuildable like `rebuildDecisionStateFromEntries`: replaying
|
|
845
|
+
* the same entries any number of times yields identical results.
|
|
846
|
+
*/
|
|
847
|
+
export function resolvePruneTombstoneState(entries: MinimalSessionEntry[]): PruneTombstoneState {
|
|
848
|
+
const lastDecisionByKey = new Map<string, PruneDecisionRecord>();
|
|
849
|
+
const lastEventKindByKey = new Map<string, "decision" | "restore">();
|
|
850
|
+
|
|
851
|
+
for (const entry of entries) {
|
|
852
|
+
if (entry.type !== "custom") continue;
|
|
853
|
+
if (entry.customType === DECISION_ENTRY_TYPE) {
|
|
854
|
+
const record = parseDecisionRecord(entry.data);
|
|
855
|
+
if (!record) continue;
|
|
856
|
+
lastDecisionByKey.set(record.idempotencyKey, record);
|
|
857
|
+
lastEventKindByKey.set(record.idempotencyKey, "decision");
|
|
858
|
+
} else if (entry.customType === RESTORE_ENTRY_TYPE) {
|
|
859
|
+
const record = parseRestoreRecord(entry.data);
|
|
860
|
+
if (!record) continue;
|
|
861
|
+
lastEventKindByKey.set(record.idempotencyKey, "restore");
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const activeDecisions: PruneDecisionRecord[] = [];
|
|
866
|
+
const activeIdempotencyKeys = new Set<string>();
|
|
867
|
+
const restoredKeys = new Set<string>();
|
|
868
|
+
for (const [key, kind] of lastEventKindByKey) {
|
|
869
|
+
if (kind === "decision") {
|
|
870
|
+
const record = lastDecisionByKey.get(key);
|
|
871
|
+
if (record) {
|
|
872
|
+
activeDecisions.push(record);
|
|
873
|
+
activeIdempotencyKeys.add(key);
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
restoredKeys.add(key);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return { activeDecisions, activeIdempotencyKeys, restoredKeys, lastDecisionByKey };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ============================================================================
|
|
884
|
+
// Apply step (content-replacement only; never drops messages)
|
|
885
|
+
// ============================================================================
|
|
886
|
+
|
|
887
|
+
export function buildPlaceholderText(reason: string, charsRemoved: number): string {
|
|
888
|
+
return `[pruned by ${EXTENSION_ID}: ${reason} (${charsRemoved} chars removed)]`;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function contentTextLength(content: (MinimalTextContent | MinimalImageContent)[]): number {
|
|
892
|
+
return content.reduce((sum, block) => (block.type === "text" ? sum + block.text.length : sum), 0);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export interface ApplyResult {
|
|
896
|
+
applied: boolean;
|
|
897
|
+
charsRemoved: number;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Apply a single decision to a mutable messages array (mutates in place).
|
|
902
|
+
* Never removes messages or content blocks; only replaces tool result content
|
|
903
|
+
* or (for errored calls only) tool call input arguments with a placeholder.
|
|
904
|
+
* Gracefully no-ops when the target is absent (e.g. after compaction).
|
|
905
|
+
*/
|
|
906
|
+
export function applyPruneDecision(
|
|
907
|
+
messages: MinimalMessage[],
|
|
908
|
+
decision: PruneDecisionRecord,
|
|
909
|
+
pairIndex?: Map<string, ToolCallPairIndices>,
|
|
910
|
+
): ApplyResult {
|
|
911
|
+
if (decision.correlation.type !== "toolCallId") {
|
|
912
|
+
// v1 only ever targets tool call/result pairs; entryId correlation is
|
|
913
|
+
// reserved for future strategies and intentionally not applied yet.
|
|
914
|
+
return { applied: false, charsRemoved: 0 };
|
|
915
|
+
}
|
|
916
|
+
const pair = findToolCallPairIndices(messages, decision.correlation.toolCallId, pairIndex);
|
|
917
|
+
|
|
918
|
+
if (decision.kind === "tool_result_content") {
|
|
919
|
+
if (pair.resultIndex === undefined) return { applied: false, charsRemoved: 0 };
|
|
920
|
+
const resultMessage = messages[pair.resultIndex] as MinimalToolResultMessage;
|
|
921
|
+
const charsRemoved = contentTextLength(resultMessage.content);
|
|
922
|
+
const placeholder = decision.placeholder ?? buildPlaceholderText(decision.reason, charsRemoved);
|
|
923
|
+
messages[pair.resultIndex] = {
|
|
924
|
+
...resultMessage,
|
|
925
|
+
content: [{ type: "text", text: placeholder }],
|
|
926
|
+
};
|
|
927
|
+
return { applied: true, charsRemoved };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (decision.kind === "tool_call_input") {
|
|
931
|
+
if (pair.assistantIndex === undefined || pair.toolCallBlockIndex === undefined) {
|
|
932
|
+
return { applied: false, charsRemoved: 0 };
|
|
933
|
+
}
|
|
934
|
+
if (pair.resultIndex === undefined) return { applied: false, charsRemoved: 0 };
|
|
935
|
+
const resultMessage = messages[pair.resultIndex] as MinimalToolResultMessage;
|
|
936
|
+
// Only errored-call inputs may ever be redacted (ticket invariant).
|
|
937
|
+
if (!resultMessage.isError) return { applied: false, charsRemoved: 0 };
|
|
938
|
+
|
|
939
|
+
const assistantMessage = messages[pair.assistantIndex] as MinimalAssistantMessage;
|
|
940
|
+
const block = assistantMessage.content[pair.toolCallBlockIndex] as MinimalToolCallContent;
|
|
941
|
+
const charsRemoved = JSON.stringify(block.arguments ?? {}).length;
|
|
942
|
+
const newContent = assistantMessage.content.slice();
|
|
943
|
+
newContent[pair.toolCallBlockIndex] = {
|
|
944
|
+
...block,
|
|
945
|
+
arguments: { pruned: true, reason: decision.reason },
|
|
946
|
+
};
|
|
947
|
+
messages[pair.assistantIndex] = { ...assistantMessage, content: newContent };
|
|
948
|
+
return { applied: true, charsRemoved };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return { applied: false, charsRemoved: 0 };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function resolveToolNameForPair(pair: ToolCallPairIndices, messages: MinimalMessage[]): string | undefined {
|
|
955
|
+
if (pair.resultIndex !== undefined) {
|
|
956
|
+
return (messages[pair.resultIndex] as MinimalToolResultMessage).toolName;
|
|
957
|
+
}
|
|
958
|
+
if (pair.assistantIndex !== undefined && pair.toolCallBlockIndex !== undefined) {
|
|
959
|
+
const assistantMessage = messages[pair.assistantIndex] as MinimalAssistantMessage;
|
|
960
|
+
const block = assistantMessage.content[pair.toolCallBlockIndex] as MinimalToolCallContent | undefined;
|
|
961
|
+
return block?.name;
|
|
962
|
+
}
|
|
963
|
+
return undefined;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function resolveArgStringsForPair(pair: ToolCallPairIndices, messages: MinimalMessage[]): string[] {
|
|
967
|
+
if (pair.assistantIndex === undefined || pair.toolCallBlockIndex === undefined) return [];
|
|
968
|
+
const assistantMessage = messages[pair.assistantIndex] as MinimalAssistantMessage;
|
|
969
|
+
const block = assistantMessage.content[pair.toolCallBlockIndex] as MinimalToolCallContent | undefined;
|
|
970
|
+
return collectArgStringValues(block?.arguments);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export function isDecisionProtected(
|
|
974
|
+
decision: PruneDecisionRecord,
|
|
975
|
+
messages: MinimalMessage[],
|
|
976
|
+
protections: PruneProtections,
|
|
977
|
+
recencyBoundaryIndex: number,
|
|
978
|
+
pairIndex?: Map<string, ToolCallPairIndices>,
|
|
979
|
+
): boolean {
|
|
980
|
+
if (decision.correlation.type !== "toolCallId") return true; // no safe way to check protections yet
|
|
981
|
+
const pair = findToolCallPairIndices(messages, decision.correlation.toolCallId, pairIndex);
|
|
982
|
+
if (pair.resultIndex !== undefined && isWithinRecencyWindow(pair.resultIndex, recencyBoundaryIndex)) return true;
|
|
983
|
+
if (pair.assistantIndex !== undefined && isWithinRecencyWindow(pair.assistantIndex, recencyBoundaryIndex)) return true;
|
|
984
|
+
|
|
985
|
+
const toolName = resolveToolNameForPair(pair, messages);
|
|
986
|
+
if (isProtectedToolName(toolName, protections)) return true;
|
|
987
|
+
|
|
988
|
+
const argStrings = resolveArgStringsForPair(pair, messages);
|
|
989
|
+
if (argStrings.some((value) => isProtectedPath(value, protections))) return true;
|
|
990
|
+
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ============================================================================
|
|
995
|
+
// Savings estimation, cache cost model & net-benefit gate (pe-s2ho)
|
|
996
|
+
// ============================================================================
|
|
997
|
+
|
|
998
|
+
export interface DecisionSavingsEstimate {
|
|
999
|
+
/** Earliest message index this decision changes; used for cache-bust tail sizing. */
|
|
1000
|
+
position: number;
|
|
1001
|
+
/** Estimated tokens removed by this decision, via the shared estimator. */
|
|
1002
|
+
tokensRemoved: number;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Estimate the token savings and affected position of a single decision
|
|
1007
|
+
* without mutating the caller's `messages`. Returns undefined when the
|
|
1008
|
+
* decision's target is absent or the decision would not actually apply
|
|
1009
|
+
* (mirrors `applyPruneDecision`'s no-op conditions).
|
|
1010
|
+
*/
|
|
1011
|
+
export function estimateDecisionSavings(
|
|
1012
|
+
messages: MinimalMessage[],
|
|
1013
|
+
decision: PruneDecisionRecord,
|
|
1014
|
+
estimateTokens: TokenEstimator = estimateTokensForText,
|
|
1015
|
+
pairIndex?: Map<string, ToolCallPairIndices>,
|
|
1016
|
+
): DecisionSavingsEstimate | undefined {
|
|
1017
|
+
if (decision.correlation.type !== "toolCallId") return undefined;
|
|
1018
|
+
const pair = findToolCallPairIndices(messages, decision.correlation.toolCallId, pairIndex);
|
|
1019
|
+
|
|
1020
|
+
// Apply against a shallow copy of the array (not the message objects) so
|
|
1021
|
+
// the caller's messages/content are never mutated by this preview. Indices
|
|
1022
|
+
// in `pairIndex` (built from `messages`) remain valid against `preview`
|
|
1023
|
+
// since slice() preserves length/order.
|
|
1024
|
+
const preview = messages.slice();
|
|
1025
|
+
const result = applyPruneDecision(preview, decision, pairIndex);
|
|
1026
|
+
if (!result.applied) return undefined;
|
|
1027
|
+
|
|
1028
|
+
if (decision.kind === "tool_result_content") {
|
|
1029
|
+
if (pair.resultIndex === undefined) return undefined;
|
|
1030
|
+
const before = messages[pair.resultIndex] as MinimalToolResultMessage;
|
|
1031
|
+
const after = preview[pair.resultIndex] as MinimalToolResultMessage;
|
|
1032
|
+
const tokensBefore = estimateTokensForContent(before.content);
|
|
1033
|
+
const tokensAfter = estimateTokensForContent(after.content);
|
|
1034
|
+
return { position: pair.resultIndex, tokensRemoved: Math.max(0, tokensBefore - tokensAfter) };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (decision.kind === "tool_call_input") {
|
|
1038
|
+
if (pair.assistantIndex === undefined || pair.toolCallBlockIndex === undefined) return undefined;
|
|
1039
|
+
const beforeBlock = (messages[pair.assistantIndex] as MinimalAssistantMessage).content[
|
|
1040
|
+
pair.toolCallBlockIndex
|
|
1041
|
+
] as MinimalToolCallContent;
|
|
1042
|
+
const afterBlock = (preview[pair.assistantIndex] as MinimalAssistantMessage).content[
|
|
1043
|
+
pair.toolCallBlockIndex
|
|
1044
|
+
] as MinimalToolCallContent;
|
|
1045
|
+
const tokensBefore = estimateTokens(JSON.stringify(beforeBlock.arguments ?? {}));
|
|
1046
|
+
const tokensAfter = estimateTokens(JSON.stringify(afterBlock.arguments ?? {}));
|
|
1047
|
+
return { position: pair.assistantIndex, tokensRemoved: Math.max(0, tokensBefore - tokensAfter) };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return undefined;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Cache-aware cost model (pe-s2ho ticket NOTES, binding over the looser prose
|
|
1055
|
+
* in the ticket body): prompt caches are prefix-based, so pruning at message
|
|
1056
|
+
* position p invalidates ('busts') the cached tail from p onward exactly
|
|
1057
|
+
* once. Modelled per NOTES as:
|
|
1058
|
+
*
|
|
1059
|
+
* penalty ~= (1 - r) * tailTokensAfterEarliestChange
|
|
1060
|
+
* recurringSaving ~= r * tokensRemoved
|
|
1061
|
+
* breakEvenCalls = penalty / recurringSaving
|
|
1062
|
+
*
|
|
1063
|
+
* where r is `cachedPriceRatio` (fraction of full price still paid for
|
|
1064
|
+
* cached tokens; default 0.1). `breakEvenCalls` is the number of subsequent
|
|
1065
|
+
* calls needed to amortize the one-time cache bust via the recurring saving.
|
|
1066
|
+
*/
|
|
1067
|
+
export interface CacheCostModelInput {
|
|
1068
|
+
tailTokensAfterEarliestChange: number;
|
|
1069
|
+
tokensRemoved: number;
|
|
1070
|
+
cachedPriceRatio: number;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
export interface CacheCostModelResult {
|
|
1074
|
+
penalty: number;
|
|
1075
|
+
recurringSaving: number;
|
|
1076
|
+
/** Infinity when there is a penalty but no recurring saving to amortize it. */
|
|
1077
|
+
breakEvenCalls: number;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export function computeCacheCostModel(input: CacheCostModelInput): CacheCostModelResult {
|
|
1081
|
+
const r = Number.isFinite(input.cachedPriceRatio) ? Math.min(1, Math.max(0, input.cachedPriceRatio)) : DEFAULT_CACHED_PRICE_RATIO;
|
|
1082
|
+
const tailTokens = Math.max(0, input.tailTokensAfterEarliestChange);
|
|
1083
|
+
const tokensRemoved = Math.max(0, input.tokensRemoved);
|
|
1084
|
+
const penalty = (1 - r) * tailTokens;
|
|
1085
|
+
const recurringSaving = r * tokensRemoved;
|
|
1086
|
+
const breakEvenCalls = recurringSaving > 0 ? penalty / recurringSaving : penalty > 0 ? Infinity : 0;
|
|
1087
|
+
return { penalty, recurringSaving, breakEvenCalls };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** A fresh, automatic (non-manual, non-persisted) decision pending the net-benefit gate. */
|
|
1091
|
+
export interface GateCandidate {
|
|
1092
|
+
decision: PruneDecisionRecord;
|
|
1093
|
+
position: number;
|
|
1094
|
+
tokensRemoved: number;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
export interface NetBenefitGateResult {
|
|
1098
|
+
accepted: PruneDecisionRecord[];
|
|
1099
|
+
rejected: PruneDecisionRecord[];
|
|
1100
|
+
mode: GateMode;
|
|
1101
|
+
threshold: number;
|
|
1102
|
+
earliestPosition: number | undefined;
|
|
1103
|
+
tailTokensAfterEarliestChange: number;
|
|
1104
|
+
totalTokensRemoved: number;
|
|
1105
|
+
cost: CacheCostModelResult | undefined;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Net-benefit gate (pe-s2ho): decides whether fresh, automatic prune
|
|
1110
|
+
* proposals are worth the one-time cache bust they cause. Batch-aware: since
|
|
1111
|
+
* prompt caches are a single linear prefix, every candidate in one pipeline
|
|
1112
|
+
* pass is "behind" the earliest changed position and shares that one cache
|
|
1113
|
+
* bust, so the whole batch is evaluated (and accepted/rejected) jointly.
|
|
1114
|
+
*
|
|
1115
|
+
* Already-applied/persisted decisions and manual decisions never reach this
|
|
1116
|
+
* gate (the pipeline routes them around it entirely; see
|
|
1117
|
+
* `runDynamicContextPruningPipeline`).
|
|
1118
|
+
*/
|
|
1119
|
+
export function evaluateNetBenefitGate(
|
|
1120
|
+
candidates: GateCandidate[],
|
|
1121
|
+
messages: MinimalMessage[],
|
|
1122
|
+
gateConfig: PruneGateConfig,
|
|
1123
|
+
agentState: AgentState = "idle",
|
|
1124
|
+
estimateTokens: TokenEstimator = estimateTokensForText,
|
|
1125
|
+
): NetBenefitGateResult {
|
|
1126
|
+
const threshold = resolveBreakEvenThreshold(gateConfig, agentState);
|
|
1127
|
+
|
|
1128
|
+
if (candidates.length === 0) {
|
|
1129
|
+
return {
|
|
1130
|
+
accepted: [],
|
|
1131
|
+
rejected: [],
|
|
1132
|
+
mode: gateConfig.mode,
|
|
1133
|
+
threshold,
|
|
1134
|
+
earliestPosition: undefined,
|
|
1135
|
+
tailTokensAfterEarliestChange: 0,
|
|
1136
|
+
totalTokensRemoved: 0,
|
|
1137
|
+
cost: undefined,
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (gateConfig.mode === "off") {
|
|
1142
|
+
// Gate fully bypassed: no cost modelling performed at all.
|
|
1143
|
+
return {
|
|
1144
|
+
accepted: candidates.map((c) => c.decision),
|
|
1145
|
+
rejected: [],
|
|
1146
|
+
mode: "off",
|
|
1147
|
+
threshold,
|
|
1148
|
+
earliestPosition: undefined,
|
|
1149
|
+
tailTokensAfterEarliestChange: 0,
|
|
1150
|
+
totalTokensRemoved: candidates.reduce((sum, c) => sum + c.tokensRemoved, 0),
|
|
1151
|
+
cost: undefined,
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const earliestPosition = Math.min(...candidates.map((c) => c.position));
|
|
1156
|
+
const tailTokensAfterEarliestChange = estimateTailTokens(messages, earliestPosition, estimateTokens);
|
|
1157
|
+
const totalTokensRemoved = candidates.reduce((sum, c) => sum + c.tokensRemoved, 0);
|
|
1158
|
+
const cost = computeCacheCostModel({
|
|
1159
|
+
tailTokensAfterEarliestChange,
|
|
1160
|
+
tokensRemoved: totalTokensRemoved,
|
|
1161
|
+
cachedPriceRatio: gateConfig.cachedPriceRatio,
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
if (gateConfig.mode === "always-apply" || cost.breakEvenCalls <= threshold) {
|
|
1165
|
+
return {
|
|
1166
|
+
accepted: candidates.map((c) => c.decision),
|
|
1167
|
+
rejected: [],
|
|
1168
|
+
mode: gateConfig.mode,
|
|
1169
|
+
threshold,
|
|
1170
|
+
earliestPosition,
|
|
1171
|
+
tailTokensAfterEarliestChange,
|
|
1172
|
+
totalTokensRemoved,
|
|
1173
|
+
cost,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
return {
|
|
1178
|
+
accepted: [],
|
|
1179
|
+
rejected: candidates.map((c) => c.decision),
|
|
1180
|
+
mode: gateConfig.mode,
|
|
1181
|
+
threshold,
|
|
1182
|
+
earliestPosition,
|
|
1183
|
+
tailTokensAfterEarliestChange,
|
|
1184
|
+
totalTokensRemoved,
|
|
1185
|
+
cost,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// ============================================================================
|
|
1190
|
+
// Savings accounting & stats API (consumed by pe-8re9 /context-pruning stats
|
|
1191
|
+
// and the pe-e9pv benchmark harness)
|
|
1192
|
+
// ============================================================================
|
|
1193
|
+
|
|
1194
|
+
export interface PruneStatsRecord {
|
|
1195
|
+
idempotencyKey: string;
|
|
1196
|
+
strategyId: string;
|
|
1197
|
+
tokensRemoved: number;
|
|
1198
|
+
appliedAt: string;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
export function buildStatsRecord(
|
|
1202
|
+
decision: PruneDecisionRecord,
|
|
1203
|
+
tokensRemoved: number,
|
|
1204
|
+
appliedAt: string = new Date().toISOString(),
|
|
1205
|
+
): PruneStatsRecord {
|
|
1206
|
+
return {
|
|
1207
|
+
idempotencyKey: decision.idempotencyKey,
|
|
1208
|
+
strategyId: decision.strategyId,
|
|
1209
|
+
tokensRemoved: Math.max(0, tokensRemoved),
|
|
1210
|
+
appliedAt,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Parse a persisted stats CustomEntry payload back into a record. Returns undefined if malformed. */
|
|
1215
|
+
export function parseStatsRecord(data: unknown): PruneStatsRecord | undefined {
|
|
1216
|
+
if (!data || typeof data !== "object") return undefined;
|
|
1217
|
+
const raw = data as Record<string, unknown>;
|
|
1218
|
+
if (typeof raw.idempotencyKey !== "string" || !raw.idempotencyKey) return undefined;
|
|
1219
|
+
if (typeof raw.strategyId !== "string") return undefined;
|
|
1220
|
+
if (typeof raw.tokensRemoved !== "number" || !Number.isFinite(raw.tokensRemoved)) return undefined;
|
|
1221
|
+
if (typeof raw.appliedAt !== "string") return undefined;
|
|
1222
|
+
return { idempotencyKey: raw.idempotencyKey, strategyId: raw.strategyId, tokensRemoved: raw.tokensRemoved, appliedAt: raw.appliedAt };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
export interface StrategyStats {
|
|
1226
|
+
tokensRemoved: number;
|
|
1227
|
+
pruneCount: number;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
export interface CumulativePruneStats {
|
|
1231
|
+
totalTokensRemoved: number;
|
|
1232
|
+
totalPruneCount: number;
|
|
1233
|
+
byStrategy: Record<string, StrategyStats>;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
export function emptyCumulativeStats(): CumulativePruneStats {
|
|
1237
|
+
return { totalTokensRemoved: 0, totalPruneCount: 0, byStrategy: {} };
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Fold a single stats record into cumulative stats (pure; returns a new object). */
|
|
1241
|
+
export function foldStatsRecord(stats: CumulativePruneStats, record: PruneStatsRecord): CumulativePruneStats {
|
|
1242
|
+
const existing = stats.byStrategy[record.strategyId] ?? { tokensRemoved: 0, pruneCount: 0 };
|
|
1243
|
+
return {
|
|
1244
|
+
totalTokensRemoved: stats.totalTokensRemoved + record.tokensRemoved,
|
|
1245
|
+
totalPruneCount: stats.totalPruneCount + 1,
|
|
1246
|
+
byStrategy: {
|
|
1247
|
+
...stats.byStrategy,
|
|
1248
|
+
[record.strategyId]: {
|
|
1249
|
+
tokensRemoved: existing.tokensRemoved + record.tokensRemoved,
|
|
1250
|
+
pruneCount: existing.pruneCount + 1,
|
|
1251
|
+
},
|
|
1252
|
+
},
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
export interface RebuiltStatsState {
|
|
1257
|
+
stats: CumulativePruneStats;
|
|
1258
|
+
seenKeys: Set<string>;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Rebuild cumulative stats from persisted CustomEntry records on a branch.
|
|
1263
|
+
* Idempotent/rebuildable like `rebuildDecisionStateFromEntries`: duplicate or
|
|
1264
|
+
* replayed entries for the same idempotencyKey collapse to a single fold.
|
|
1265
|
+
*/
|
|
1266
|
+
export function rebuildStatsStateFromEntries(entries: MinimalSessionEntry[]): RebuiltStatsState {
|
|
1267
|
+
let stats = emptyCumulativeStats();
|
|
1268
|
+
const seenKeys = new Set<string>();
|
|
1269
|
+
for (const entry of entries) {
|
|
1270
|
+
if (entry.type !== "custom" || entry.customType !== STATS_ENTRY_TYPE) continue;
|
|
1271
|
+
const record = parseStatsRecord(entry.data);
|
|
1272
|
+
if (!record) continue;
|
|
1273
|
+
if (seenKeys.has(record.idempotencyKey)) continue;
|
|
1274
|
+
seenKeys.add(record.idempotencyKey);
|
|
1275
|
+
stats = foldStatsRecord(stats, record);
|
|
1276
|
+
}
|
|
1277
|
+
return { stats, seenKeys };
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** Live (non-persisted) snapshot of raw vs effective (post-prune) context size for a single call. */
|
|
1281
|
+
export interface ContextSizeSnapshot {
|
|
1282
|
+
rawTokens: number;
|
|
1283
|
+
effectiveTokens: number;
|
|
1284
|
+
tokensSavedThisCall: number;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
export function computeContextSizeSnapshot(
|
|
1288
|
+
rawMessages: MinimalMessage[],
|
|
1289
|
+
effectiveMessages: MinimalMessage[],
|
|
1290
|
+
estimateTokens: TokenEstimator = estimateTokensForText,
|
|
1291
|
+
): ContextSizeSnapshot {
|
|
1292
|
+
const rawTokens = estimateTailTokens(rawMessages, 0, estimateTokens);
|
|
1293
|
+
const effectiveTokens = estimateTailTokens(effectiveMessages, 0, estimateTokens);
|
|
1294
|
+
return { rawTokens, effectiveTokens, tokensSavedThisCall: Math.max(0, rawTokens - effectiveTokens) };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// ============================================================================
|
|
1298
|
+
// Strategy registry (populated by follow-up tickets pe-u8gd/pe-qs8j)
|
|
1299
|
+
// ============================================================================
|
|
1300
|
+
|
|
1301
|
+
export interface StrategyProposeInput {
|
|
1302
|
+
messages: MinimalMessage[];
|
|
1303
|
+
protections: PruneProtections;
|
|
1304
|
+
estimateTokens: TokenEstimator;
|
|
1305
|
+
/**
|
|
1306
|
+
* Full config, so strategies can read their own enable flag and any
|
|
1307
|
+
* strategy-specific settings (e.g. error-purge's `minTurnsOld`).
|
|
1308
|
+
* Protection/recency filtering is handled centrally by the pipeline
|
|
1309
|
+
* (see `isDecisionProtected`); strategies do not need to re-check it.
|
|
1310
|
+
*/
|
|
1311
|
+
config: DynamicContextPruningConfig;
|
|
1312
|
+
/**
|
|
1313
|
+
* Session working directory (pe-qs8j), used to normalize relative file
|
|
1314
|
+
* paths (e.g. from read/write/edit tool args) against an absolute base so
|
|
1315
|
+
* the same file referenced via different relative/absolute spellings is
|
|
1316
|
+
* recognized as the same path. Optional/backward-compatible: undefined
|
|
1317
|
+
* when the caller doesn't have a cwd available (e.g. older callers/tests);
|
|
1318
|
+
* strategies that need it should degrade gracefully (best-effort relative
|
|
1319
|
+
* normalization only) rather than throw.
|
|
1320
|
+
*/
|
|
1321
|
+
cwd?: string;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
export interface PruneStrategy {
|
|
1325
|
+
id: string;
|
|
1326
|
+
propose(input: StrategyProposeInput): ProposedPrune[];
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// ----------------------------------------------------------------------
|
|
1330
|
+
// Argument canonicalization (shared by the dedupe strategy; exported for
|
|
1331
|
+
// direct unit testing of key-order/nesting/whitespace behavior).
|
|
1332
|
+
// ----------------------------------------------------------------------
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Recursively canonicalize a parsed tool-call arguments value: object keys
|
|
1336
|
+
* are sorted (recursively); array order and all string/number/boolean VALUES
|
|
1337
|
+
* are left untouched (per ticket: never normalize argument values beyond
|
|
1338
|
+
* structural key ordering). Serializing the result via JSON.stringify then
|
|
1339
|
+
* yields a deterministic, whitespace-free representation regardless of the
|
|
1340
|
+
* original key order.
|
|
1341
|
+
*/
|
|
1342
|
+
export function canonicalizeArguments(value: unknown): unknown {
|
|
1343
|
+
if (Array.isArray(value)) return value.map((item) => canonicalizeArguments(item));
|
|
1344
|
+
if (value && typeof value === "object") {
|
|
1345
|
+
const obj = value as Record<string, unknown>;
|
|
1346
|
+
const sorted: Record<string, unknown> = {};
|
|
1347
|
+
for (const key of Object.keys(obj).sort()) sorted[key] = canonicalizeArguments(obj[key]);
|
|
1348
|
+
return sorted;
|
|
1349
|
+
}
|
|
1350
|
+
return value;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** Canonical JSON string for a tool call's arguments (see `canonicalizeArguments`). */
|
|
1354
|
+
export function canonicalizeArgumentsJSON(args: unknown): string {
|
|
1355
|
+
return JSON.stringify(canonicalizeArguments(args ?? {}));
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** Dedup key: identical tool name (case-insensitive) + canonicalized args JSON. */
|
|
1359
|
+
export function buildDedupeKey(toolName: string, args: unknown): string {
|
|
1360
|
+
return `${toolName.trim().toLowerCase()}::${canonicalizeArgumentsJSON(args)}`;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** Exported for /prune picker item building (pe-8re9) and direct unit testing. */
|
|
1364
|
+
export interface ToolCallOccurrence {
|
|
1365
|
+
toolCallId: string;
|
|
1366
|
+
toolName: string;
|
|
1367
|
+
arguments: Record<string, unknown> | undefined;
|
|
1368
|
+
assistantIndex: number;
|
|
1369
|
+
resultIndex: number;
|
|
1370
|
+
isError: boolean;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/** Collect every assistant toolCall block that has a matching toolResult message, in message order. */
|
|
1374
|
+
export function collectCompletedToolCallOccurrences(messages: MinimalMessage[]): ToolCallOccurrence[] {
|
|
1375
|
+
const resultIndexById = new Map<string, number>();
|
|
1376
|
+
const isErrorById = new Map<string, boolean>();
|
|
1377
|
+
messages.forEach((message, index) => {
|
|
1378
|
+
if (message.role === "toolResult") {
|
|
1379
|
+
const result = message as MinimalToolResultMessage;
|
|
1380
|
+
resultIndexById.set(result.toolCallId, index);
|
|
1381
|
+
isErrorById.set(result.toolCallId, result.isError);
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
const occurrences: ToolCallOccurrence[] = [];
|
|
1386
|
+
messages.forEach((message, assistantIndex) => {
|
|
1387
|
+
if (message.role !== "assistant") return;
|
|
1388
|
+
for (const block of (message as MinimalAssistantMessage).content) {
|
|
1389
|
+
if (block.type !== "toolCall") continue;
|
|
1390
|
+
const resultIndex = resultIndexById.get(block.id);
|
|
1391
|
+
if (resultIndex === undefined) continue; // still in flight; nothing to dedupe/purge yet
|
|
1392
|
+
occurrences.push({
|
|
1393
|
+
toolCallId: block.id,
|
|
1394
|
+
toolName: block.name,
|
|
1395
|
+
arguments: block.arguments,
|
|
1396
|
+
assistantIndex,
|
|
1397
|
+
resultIndex,
|
|
1398
|
+
isError: isErrorById.get(block.id) ?? false,
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
return occurrences;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// ----------------------------------------------------------------------
|
|
1406
|
+
// Strategy 1: deduplication (pe-u8gd)
|
|
1407
|
+
// ----------------------------------------------------------------------
|
|
1408
|
+
|
|
1409
|
+
/** Placeholder text for a duplicate tool result whose content was replaced. */
|
|
1410
|
+
export function buildDedupePlaceholder(toolName: string, newestToolCallId: string): string {
|
|
1411
|
+
return `[pruned by ${EXTENSION_ID}: duplicate ${toolName} call result; see newest occurrence (call ${newestToolCallId})]`;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
export const dedupeStrategy: PruneStrategy = {
|
|
1415
|
+
id: DEDUPE_STRATEGY_ID,
|
|
1416
|
+
propose(input: StrategyProposeInput): ProposedPrune[] {
|
|
1417
|
+
if (input.config.strategies.dedupe.enabled === false) return [];
|
|
1418
|
+
|
|
1419
|
+
const occurrences = collectCompletedToolCallOccurrences(input.messages);
|
|
1420
|
+
const groups = new Map<string, ToolCallOccurrence[]>();
|
|
1421
|
+
for (const occurrence of occurrences) {
|
|
1422
|
+
const key = buildDedupeKey(occurrence.toolName, occurrence.arguments);
|
|
1423
|
+
const group = groups.get(key);
|
|
1424
|
+
if (group) group.push(occurrence);
|
|
1425
|
+
else groups.set(key, [occurrence]);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const proposals: ProposedPrune[] = [];
|
|
1429
|
+
for (const group of groups.values()) {
|
|
1430
|
+
if (group.length < 2) continue;
|
|
1431
|
+
// Occurrences are collected in message order, so the last one is newest.
|
|
1432
|
+
const newest = group[group.length - 1];
|
|
1433
|
+
for (const occurrence of group.slice(0, -1)) {
|
|
1434
|
+
proposals.push({
|
|
1435
|
+
strategyId: DEDUPE_STRATEGY_ID,
|
|
1436
|
+
toolCallId: occurrence.toolCallId,
|
|
1437
|
+
kind: "tool_result_content",
|
|
1438
|
+
reason: `duplicate ${occurrence.toolName} call; see result of call ${newest.toolCallId}`,
|
|
1439
|
+
placeholder: buildDedupePlaceholder(occurrence.toolName, newest.toolCallId),
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
return proposals;
|
|
1444
|
+
},
|
|
1445
|
+
};
|
|
1446
|
+
|
|
1447
|
+
// ----------------------------------------------------------------------
|
|
1448
|
+
// Strategy 2: error-input purge (pe-u8gd)
|
|
1449
|
+
// ----------------------------------------------------------------------
|
|
1450
|
+
|
|
1451
|
+
export const errorPurgeStrategy: PruneStrategy = {
|
|
1452
|
+
id: ERROR_PURGE_STRATEGY_ID,
|
|
1453
|
+
propose(input: StrategyProposeInput): ProposedPrune[] {
|
|
1454
|
+
if (input.config.strategies.errorPurge.enabled === false) return [];
|
|
1455
|
+
const minTurnsOld = input.config.strategies.errorPurge.minTurnsOld;
|
|
1456
|
+
|
|
1457
|
+
const occurrences = collectCompletedToolCallOccurrences(input.messages);
|
|
1458
|
+
const proposals: ProposedPrune[] = [];
|
|
1459
|
+
for (const occurrence of occurrences) {
|
|
1460
|
+
if (!occurrence.isError) continue;
|
|
1461
|
+
const turnsElapsed = computeTurnsElapsedSince(input.messages, occurrence.assistantIndex);
|
|
1462
|
+
// Strictly older than `minTurnsOld` turns (e.g. default 4: eligible at 5+, not at exactly 4).
|
|
1463
|
+
if (turnsElapsed <= minTurnsOld) continue;
|
|
1464
|
+
proposals.push({
|
|
1465
|
+
strategyId: ERROR_PURGE_STRATEGY_ID,
|
|
1466
|
+
toolCallId: occurrence.toolCallId,
|
|
1467
|
+
kind: "tool_call_input",
|
|
1468
|
+
reason: `errored ${occurrence.toolName} call input older than ${minTurnsOld} turns`,
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
return proposals;
|
|
1472
|
+
},
|
|
1473
|
+
};
|
|
1474
|
+
|
|
1475
|
+
// ----------------------------------------------------------------------
|
|
1476
|
+
// Strategy 3: superseded file operations (pe-qs8j)
|
|
1477
|
+
// ----------------------------------------------------------------------
|
|
1478
|
+
//
|
|
1479
|
+
// When the same file path is read and/or written multiple times in a
|
|
1480
|
+
// session, older read/write/edit tool *outputs* for that path can become
|
|
1481
|
+
// stale relative to a later operation. This strategy proposes replacing
|
|
1482
|
+
// those older outputs with a placeholder, per the following CONSERVATIVE,
|
|
1483
|
+
// binding rules (do not loosen without an architect decision):
|
|
1484
|
+
//
|
|
1485
|
+
// 1. A full-file read (no offset/limit args) is superseded by a LATER
|
|
1486
|
+
// full-file read of the same normalized path.
|
|
1487
|
+
// 2. A partial read (offset/limit args present) is NOT superseded by a
|
|
1488
|
+
// later partial read UNLESS the later read's range fully covers the
|
|
1489
|
+
// earlier one's range. If range coverage can't be reliably determined
|
|
1490
|
+
// from the args (e.g. earlier read has no limit — reads to EOF — but
|
|
1491
|
+
// the later read is bounded), skip: do not propose supersession.
|
|
1492
|
+
// Full-file reads are represented as the range [1, EOF); comparing
|
|
1493
|
+
// that representation via the same coverage check naturally satisfies
|
|
1494
|
+
// rule 1 as a special case of rule 2 (see `readRangeCovers`).
|
|
1495
|
+
// 3. ANY read of a path (full or partial) is superseded once a LATER
|
|
1496
|
+
// *successful* (non-error) write/edit to that same path occurs — the
|
|
1497
|
+
// old read output is now known-stale regardless of read-vs-read range
|
|
1498
|
+
// comparisons. The placeholder explicitly notes the file has since
|
|
1499
|
+
// changed. An ERRORED later write/edit does NOT count (the file may be
|
|
1500
|
+
// unchanged; conservative).
|
|
1501
|
+
// 4. Older write/edit outputs for a path are superseded by a LATER
|
|
1502
|
+
// *successful* write/edit to that same path (again, a later errored
|
|
1503
|
+
// write/edit never supersedes). Write/edit tool outputs are usually
|
|
1504
|
+
// small, so proposals are only ever emitted when there is a non-empty
|
|
1505
|
+
// result to prune; the shared net-benefit gate additionally decides
|
|
1506
|
+
// whether the estimated savings are worth the cache-bust cost.
|
|
1507
|
+
//
|
|
1508
|
+
// The NEWEST operation for a given path (by message order) is never
|
|
1509
|
+
// superseded/pruned, regardless of kind — there is nothing later to point
|
|
1510
|
+
// the placeholder at.
|
|
1511
|
+
//
|
|
1512
|
+
// Path extraction is defensive: pi's built-in `read`/`write`/`edit` tools
|
|
1513
|
+
// all accept a `path` argument (see packages/coding-agent/src/core/tools/
|
|
1514
|
+
// {read,write,edit}.ts); some tool-call renderers also fall back to a
|
|
1515
|
+
// legacy `file_path` name, and `filePath` is accepted as a further
|
|
1516
|
+
// defensive fallback. `bash` is explicitly OUT of scope: this strategy does
|
|
1517
|
+
// not attempt to parse shell commands for file paths.
|
|
1518
|
+
|
|
1519
|
+
const SUPERSEDED_FILE_OPS_STRATEGY_ID = "superseded-file-ops";
|
|
1520
|
+
|
|
1521
|
+
type FileOpKind = "read" | "write" | "edit";
|
|
1522
|
+
|
|
1523
|
+
const FILE_OP_TOOL_NAMES: ReadonlySet<string> = new Set(["read", "write", "edit"]);
|
|
1524
|
+
|
|
1525
|
+
/** Extract a file path from tool-call arguments, checking common arg names defensively. */
|
|
1526
|
+
export function extractFileOpPathArg(args: Record<string, unknown> | undefined): string | undefined {
|
|
1527
|
+
if (!args || typeof args !== "object") return undefined;
|
|
1528
|
+
const raw = (args.path ?? args.file_path ?? args.filePath) as unknown;
|
|
1529
|
+
return typeof raw === "string" && raw.trim().length > 0 ? raw : undefined;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* Normalize a raw path for path-identity comparisons. When `cwd` is
|
|
1534
|
+
* provided, relative paths are resolved against it so `"a.txt"`,
|
|
1535
|
+
* `"./a.txt"`, and `"<cwd>/a.txt"` are all recognized as the same file.
|
|
1536
|
+
* Without a `cwd` (best-effort/back-compat), only structural normalization
|
|
1537
|
+
* (`./` prefixes, redundant separators) is applied — relative vs. absolute
|
|
1538
|
+
* spellings of the same file will NOT be unified in that case.
|
|
1539
|
+
*/
|
|
1540
|
+
export function normalizeFileOpsPath(rawPath: string, cwd: string | undefined): string {
|
|
1541
|
+
const trimmed = rawPath.trim();
|
|
1542
|
+
if (!trimmed) return trimmed;
|
|
1543
|
+
const absoluteOrRelative = cwd && !path.isAbsolute(trimmed) ? path.resolve(cwd, trimmed) : trimmed;
|
|
1544
|
+
return path.normalize(absoluteOrRelative);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/** A read's line range. `end === undefined` means the read continues through end-of-file. */
|
|
1548
|
+
export interface FileReadRange {
|
|
1549
|
+
start: number;
|
|
1550
|
+
end: number | undefined;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** Compute a read's line range from its `offset`/`limit` args (see pi's `read` tool: 1-indexed offset, line-count limit). */
|
|
1554
|
+
export function computeFileReadRange(args: Record<string, unknown> | undefined): FileReadRange {
|
|
1555
|
+
const offsetRaw = args?.offset;
|
|
1556
|
+
const limitRaw = args?.limit;
|
|
1557
|
+
const offset = typeof offsetRaw === "number" && Number.isFinite(offsetRaw) && offsetRaw > 0 ? offsetRaw : undefined;
|
|
1558
|
+
const limit = typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : undefined;
|
|
1559
|
+
const start = offset ?? 1;
|
|
1560
|
+
const end = limit !== undefined ? start + limit - 1 : undefined;
|
|
1561
|
+
return { start, end };
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/** True when a range represents a full-file read (starts at line 1, no limit). */
|
|
1565
|
+
export function isFullFileReadRange(range: FileReadRange): boolean {
|
|
1566
|
+
return range.start === 1 && range.end === undefined;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* Conservative range-coverage check: does `later` fully cover `earlier`?
|
|
1571
|
+
* Implements rules 1+2 above via one unified representation (a full-file
|
|
1572
|
+
* read is just the range [1, EOF)):
|
|
1573
|
+
* - `later` must start at or before `earlier` (later.start <= earlier.start).
|
|
1574
|
+
* - If `later` has no end (reads to EOF), it covers any bounded-or-unbounded
|
|
1575
|
+
* earlier range that starts at/after it (the earlier read already proved
|
|
1576
|
+
* the file has at least `earlier.end` lines, if bounded).
|
|
1577
|
+
* - If `later` IS bounded but `earlier` is unbounded (reads to EOF),
|
|
1578
|
+
* coverage can't be reliably determined (later might stop short of the
|
|
1579
|
+
* file's actual end) — conservatively return false (skip).
|
|
1580
|
+
* - Otherwise (both bounded), `later` covers `earlier` iff later.end >= earlier.end.
|
|
1581
|
+
*/
|
|
1582
|
+
export function readRangeCovers(later: FileReadRange, earlier: FileReadRange): boolean {
|
|
1583
|
+
if (later.start > earlier.start) return false;
|
|
1584
|
+
if (later.end === undefined) return true;
|
|
1585
|
+
if (earlier.end === undefined) return false;
|
|
1586
|
+
return later.end >= earlier.end;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
interface FileOpOccurrence {
|
|
1590
|
+
toolCallId: string;
|
|
1591
|
+
kind: FileOpKind;
|
|
1592
|
+
normalizedPath: string;
|
|
1593
|
+
range: FileReadRange | undefined;
|
|
1594
|
+
isError: boolean;
|
|
1595
|
+
resultTextLength: number;
|
|
1596
|
+
assistantIndex: number;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function collectFileOpOccurrences(messages: MinimalMessage[], cwd: string | undefined): FileOpOccurrence[] {
|
|
1600
|
+
const occurrences: FileOpOccurrence[] = [];
|
|
1601
|
+
for (const occurrence of collectCompletedToolCallOccurrences(messages)) {
|
|
1602
|
+
const kind = occurrence.toolName.trim().toLowerCase();
|
|
1603
|
+
if (!FILE_OP_TOOL_NAMES.has(kind)) continue;
|
|
1604
|
+
const rawPath = extractFileOpPathArg(occurrence.arguments);
|
|
1605
|
+
if (!rawPath) continue; // can't safely correlate without a path
|
|
1606
|
+
const normalizedPath = normalizeFileOpsPath(rawPath, cwd);
|
|
1607
|
+
if (!normalizedPath) continue;
|
|
1608
|
+
const resultMessage = messages[occurrence.resultIndex] as MinimalToolResultMessage;
|
|
1609
|
+
occurrences.push({
|
|
1610
|
+
toolCallId: occurrence.toolCallId,
|
|
1611
|
+
kind: kind as FileOpKind,
|
|
1612
|
+
normalizedPath,
|
|
1613
|
+
range: kind === "read" ? computeFileReadRange(occurrence.arguments) : undefined,
|
|
1614
|
+
isError: occurrence.isError,
|
|
1615
|
+
resultTextLength: contentTextLength(resultMessage.content),
|
|
1616
|
+
assistantIndex: occurrence.assistantIndex,
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
return occurrences;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function buildFileChangedPlaceholder(normalizedPath: string, supersedingKind: FileOpKind, supersedingToolCallId: string): string {
|
|
1623
|
+
return `[pruned by ${EXTENSION_ID}: file has since changed — ${normalizedPath} was modified by a later ${supersedingKind} (call ${supersedingToolCallId})]`;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function buildSupersededReadPlaceholder(normalizedPath: string, supersedingToolCallId: string): string {
|
|
1627
|
+
return `[pruned by ${EXTENSION_ID}: superseded by newer read of ${normalizedPath} (call ${supersedingToolCallId})]`;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function buildSupersededWritePlaceholder(normalizedPath: string, supersedingKind: FileOpKind, supersedingToolCallId: string): string {
|
|
1631
|
+
return `[pruned by ${EXTENSION_ID}: superseded by newer ${supersedingKind} of ${normalizedPath} (call ${supersedingToolCallId})]`;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
export const supersededFileOpsStrategy: PruneStrategy = {
|
|
1635
|
+
id: SUPERSEDED_FILE_OPS_STRATEGY_ID,
|
|
1636
|
+
propose(input: StrategyProposeInput): ProposedPrune[] {
|
|
1637
|
+
if (input.config.strategies.supersededFileOps.enabled === false) return [];
|
|
1638
|
+
|
|
1639
|
+
const occurrences = collectFileOpOccurrences(input.messages, input.cwd);
|
|
1640
|
+
const byPath = new Map<string, FileOpOccurrence[]>();
|
|
1641
|
+
for (const occurrence of occurrences) {
|
|
1642
|
+
const group = byPath.get(occurrence.normalizedPath);
|
|
1643
|
+
if (group) group.push(occurrence);
|
|
1644
|
+
else byPath.set(occurrence.normalizedPath, [occurrence]);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
const proposals: ProposedPrune[] = [];
|
|
1648
|
+
for (const group of byPath.values()) {
|
|
1649
|
+
// `group` is already in message/chronological order because
|
|
1650
|
+
// `collectCompletedToolCallOccurrences` iterates messages in order.
|
|
1651
|
+
//
|
|
1652
|
+
// Single reverse pass (pe-e0zd): instead of `group.slice(i + 1).find(...)`
|
|
1653
|
+
// per index (O(k^2) allocations+scans for a group of size k), walk the
|
|
1654
|
+
// group back-to-front and maintain:
|
|
1655
|
+
// - `nearestLaterSuccessfulWrite`: the nearest (chronologically first)
|
|
1656
|
+
// later successful write/edit, updated as we pass one going backward.
|
|
1657
|
+
// This is exactly equivalent to `later.find(non-read && !isError)`
|
|
1658
|
+
// because `later` preserves chronological order and `.find` returns
|
|
1659
|
+
// the first (nearest) match.
|
|
1660
|
+
// - `nearestCoveringReads`: later reads not yet known-redundant, nearest
|
|
1661
|
+
// first. A read R dominates (renders redundant) any later-tracked read
|
|
1662
|
+
// E once `readRangeCovers(R.range, E.range)` holds, because coverage is
|
|
1663
|
+
// transitive and R is nearer than E — so R already satisfies anything
|
|
1664
|
+
// E would have satisfied, and is checked first anyway. Dominated reads
|
|
1665
|
+
// are dropped on insert, which keeps this list small in the common case
|
|
1666
|
+
// (e.g. monotonically-widening re-reads of the same file) while still
|
|
1667
|
+
// producing byte-for-byte the same first-match result as the original
|
|
1668
|
+
// forward `.find`.
|
|
1669
|
+
let nearestLaterSuccessfulWrite: FileOpOccurrence | undefined;
|
|
1670
|
+
const nearestCoveringReads: FileOpOccurrence[] = [];
|
|
1671
|
+
|
|
1672
|
+
for (let i = group.length - 1; i >= 0; i--) {
|
|
1673
|
+
const occurrence = group[i];
|
|
1674
|
+
// Never supersede the newest operation for this path (rule: "never
|
|
1675
|
+
// supersede the newest op for a path") — but still fold it into the
|
|
1676
|
+
// tracking state below so earlier occurrences can see it as "later".
|
|
1677
|
+
if (i !== group.length - 1) {
|
|
1678
|
+
if (occurrence.kind === "read") {
|
|
1679
|
+
// Rule 3 takes priority: any later successful write/edit makes
|
|
1680
|
+
// this read stale outright, regardless of read-vs-read coverage.
|
|
1681
|
+
if (nearestLaterSuccessfulWrite) {
|
|
1682
|
+
if (occurrence.resultTextLength > 0) {
|
|
1683
|
+
proposals.push({
|
|
1684
|
+
strategyId: SUPERSEDED_FILE_OPS_STRATEGY_ID,
|
|
1685
|
+
toolCallId: occurrence.toolCallId,
|
|
1686
|
+
kind: "tool_result_content",
|
|
1687
|
+
reason: `${occurrence.normalizedPath} has since been modified (see ${nearestLaterSuccessfulWrite.kind} call ${nearestLaterSuccessfulWrite.toolCallId})`,
|
|
1688
|
+
placeholder: buildFileChangedPlaceholder(
|
|
1689
|
+
occurrence.normalizedPath,
|
|
1690
|
+
nearestLaterSuccessfulWrite.kind,
|
|
1691
|
+
nearestLaterSuccessfulWrite.toolCallId,
|
|
1692
|
+
),
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
} else if (occurrence.range !== undefined) {
|
|
1696
|
+
// Rules 1+2: superseded by a later read whose range covers this one's.
|
|
1697
|
+
const occurrenceRange = occurrence.range;
|
|
1698
|
+
const laterCoveringRead = nearestCoveringReads.find(
|
|
1699
|
+
(candidate) => candidate.range !== undefined && readRangeCovers(candidate.range, occurrenceRange),
|
|
1700
|
+
);
|
|
1701
|
+
if (laterCoveringRead && occurrence.resultTextLength > 0) {
|
|
1702
|
+
proposals.push({
|
|
1703
|
+
strategyId: SUPERSEDED_FILE_OPS_STRATEGY_ID,
|
|
1704
|
+
toolCallId: occurrence.toolCallId,
|
|
1705
|
+
kind: "tool_result_content",
|
|
1706
|
+
reason: `superseded by newer read of ${occurrence.normalizedPath} (call ${laterCoveringRead.toolCallId})`,
|
|
1707
|
+
placeholder: buildSupersededReadPlaceholder(occurrence.normalizedPath, laterCoveringRead.toolCallId),
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
} else if (nearestLaterSuccessfulWrite) {
|
|
1712
|
+
// occurrence.kind is "write" or "edit": rule 4 — superseded only by a
|
|
1713
|
+
// LATER *successful* write/edit (an errored later write/edit never
|
|
1714
|
+
// supersedes; reads never supersede writes/edits).
|
|
1715
|
+
if (occurrence.resultTextLength > 0) {
|
|
1716
|
+
proposals.push({
|
|
1717
|
+
strategyId: SUPERSEDED_FILE_OPS_STRATEGY_ID,
|
|
1718
|
+
toolCallId: occurrence.toolCallId,
|
|
1719
|
+
kind: "tool_result_content",
|
|
1720
|
+
reason: `superseded by newer ${nearestLaterSuccessfulWrite.kind} of ${occurrence.normalizedPath} (call ${nearestLaterSuccessfulWrite.toolCallId})`,
|
|
1721
|
+
placeholder: buildSupersededWritePlaceholder(
|
|
1722
|
+
occurrence.normalizedPath,
|
|
1723
|
+
nearestLaterSuccessfulWrite.kind,
|
|
1724
|
+
nearestLaterSuccessfulWrite.toolCallId,
|
|
1725
|
+
),
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Fold this occurrence into the tracking state for the next (earlier) iteration.
|
|
1732
|
+
if (occurrence.kind !== "read") {
|
|
1733
|
+
if (!occurrence.isError) nearestLaterSuccessfulWrite = occurrence;
|
|
1734
|
+
} else if (occurrence.range !== undefined) {
|
|
1735
|
+
const occurrenceRange = occurrence.range;
|
|
1736
|
+
for (let j = nearestCoveringReads.length - 1; j >= 0; j--) {
|
|
1737
|
+
const existing = nearestCoveringReads[j];
|
|
1738
|
+
if (existing.range !== undefined && readRangeCovers(occurrenceRange, existing.range)) {
|
|
1739
|
+
nearestCoveringReads.splice(j, 1);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
nearestCoveringReads.unshift(occurrence);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return proposals;
|
|
1747
|
+
},
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Strategies PROPOSE prunes; only this pipeline ever APPLIES them.
|
|
1752
|
+
*/
|
|
1753
|
+
export const STRATEGIES: PruneStrategy[] = [dedupeStrategy, errorPurgeStrategy, supersededFileOpsStrategy];
|
|
1754
|
+
|
|
1755
|
+
function collectProposals(input: StrategyProposeInput): ProposedPrune[] {
|
|
1756
|
+
const proposals: ProposedPrune[] = [];
|
|
1757
|
+
for (const strategy of STRATEGIES) {
|
|
1758
|
+
try {
|
|
1759
|
+
proposals.push(...strategy.propose(input));
|
|
1760
|
+
} catch {
|
|
1761
|
+
// A misbehaving strategy must never break the pipeline or corrupt context.
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
return proposals;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// ============================================================================
|
|
1768
|
+
// Pipeline
|
|
1769
|
+
// ============================================================================
|
|
1770
|
+
|
|
1771
|
+
export interface PipelineInput {
|
|
1772
|
+
messages: MinimalMessage[];
|
|
1773
|
+
config: DynamicContextPruningConfig;
|
|
1774
|
+
persistedDecisions: PruneDecisionRecord[];
|
|
1775
|
+
/** Idempotency keys already known/persisted; used to avoid re-emitting appendEntry calls. */
|
|
1776
|
+
knownIdempotencyKeys: ReadonlySet<string>;
|
|
1777
|
+
/**
|
|
1778
|
+
* Idempotency keys most-recently restored/undone via the /prune picker
|
|
1779
|
+
* (pe-8re9; see `resolvePruneTombstoneState`). Fresh AUTOMATIC proposals
|
|
1780
|
+
* matching one of these keys are dropped so a restored decision does not
|
|
1781
|
+
* silently re-apply on the very next context event. Manual re-prunes
|
|
1782
|
+
* always use a fresh idempotencyKey namespaced by "manual" (see
|
|
1783
|
+
* `buildManualPruneProposal`), so they are unaffected by this filter and
|
|
1784
|
+
* always work regardless of tombstone state. Optional/back-compat: an
|
|
1785
|
+
* absent set behaves as if nothing was ever restored.
|
|
1786
|
+
*/
|
|
1787
|
+
restoredIdempotencyKeys?: ReadonlySet<string>;
|
|
1788
|
+
/**
|
|
1789
|
+
* State-conditioning hook (pe-s2ho notes): lets the net-benefit gate use a
|
|
1790
|
+
* different break-even threshold depending on whether the agent is
|
|
1791
|
+
* mid-loop vs idle. Defaults to "idle" until callers wire real detection.
|
|
1792
|
+
*/
|
|
1793
|
+
agentState?: AgentState;
|
|
1794
|
+
/** Session working directory (pe-qs8j), forwarded to strategies for path normalization. */
|
|
1795
|
+
cwd?: string;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
export interface PipelineResult {
|
|
1799
|
+
messages: MinimalMessage[];
|
|
1800
|
+
/** Decisions that were applied this call and are not yet persisted; caller should appendEntry these once. */
|
|
1801
|
+
newlyAppliedDecisions: PruneDecisionRecord[];
|
|
1802
|
+
/** Stats records to persist 1:1 alongside `newlyAppliedDecisions` (same idempotencyKey, same order). */
|
|
1803
|
+
newlyAppliedStats: PruneStatsRecord[];
|
|
1804
|
+
/** Live (non-persisted) snapshot of raw vs effective context size for this call. */
|
|
1805
|
+
contextSizeSnapshot: ContextSizeSnapshot;
|
|
1806
|
+
/** Net-benefit gate outcome for this call's fresh automatic proposals (debug/stats consumers). */
|
|
1807
|
+
gate: NetBenefitGateResult;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* Centrally applies persisted + freshly-proposed prune decisions to a deep
|
|
1812
|
+
* copy of the messages array. Strategies only propose; this function is the
|
|
1813
|
+
* single place that mutates content. Pairing invariants are preserved by
|
|
1814
|
+
* construction: messages/blocks are never added or removed, only replaced.
|
|
1815
|
+
*
|
|
1816
|
+
* Net-benefit gate (pe-s2ho): only NEW, automatic (non-manual) proposals are
|
|
1817
|
+
* gated; already-persisted decisions stay applied (their cache bust already
|
|
1818
|
+
* happened) and manual decisions always bypass the gate (user intent wins).
|
|
1819
|
+
*/
|
|
1820
|
+
export function runDynamicContextPruningPipeline(input: PipelineInput): PipelineResult {
|
|
1821
|
+
if (!input.config.enabled) {
|
|
1822
|
+
return {
|
|
1823
|
+
messages: input.messages,
|
|
1824
|
+
newlyAppliedDecisions: [],
|
|
1825
|
+
newlyAppliedStats: [],
|
|
1826
|
+
contextSizeSnapshot: computeContextSizeSnapshot(input.messages, input.messages),
|
|
1827
|
+
gate: {
|
|
1828
|
+
accepted: [],
|
|
1829
|
+
rejected: [],
|
|
1830
|
+
mode: input.config.gate.mode,
|
|
1831
|
+
threshold: resolveBreakEvenThreshold(input.config.gate, input.agentState ?? "idle"),
|
|
1832
|
+
earliestPosition: undefined,
|
|
1833
|
+
tailTokensAfterEarliestChange: 0,
|
|
1834
|
+
totalTokensRemoved: 0,
|
|
1835
|
+
cost: undefined,
|
|
1836
|
+
},
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
const agentState = input.agentState ?? "idle";
|
|
1841
|
+
const messages = input.messages.map((message) => ({ ...message }));
|
|
1842
|
+
const recencyBoundaryIndex = computeRecencyBoundaryIndex(messages, input.config.protections.recentTurns);
|
|
1843
|
+
// Built once and reused across all protection/savings/apply checks below so
|
|
1844
|
+
// each per-decision lookup is O(1) instead of an O(n) rescan of `messages`
|
|
1845
|
+
// (pe-e0zd). Positions are stable across the apply loop below (and against
|
|
1846
|
+
// `input.messages`) because decisions only ever replace a message's
|
|
1847
|
+
// content at its existing index, never insert/remove/reorder messages.
|
|
1848
|
+
const pairIndex = buildToolCallPairIndex(messages);
|
|
1849
|
+
|
|
1850
|
+
const proposals = collectProposals({
|
|
1851
|
+
messages,
|
|
1852
|
+
protections: input.config.protections,
|
|
1853
|
+
estimateTokens: estimateTokensForText,
|
|
1854
|
+
config: input.config,
|
|
1855
|
+
cwd: input.cwd,
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1858
|
+
const seenKeys = new Set(input.persistedDecisions.map((decision) => decision.idempotencyKey));
|
|
1859
|
+
const restoredKeys = input.restoredIdempotencyKeys ?? new Set<string>();
|
|
1860
|
+
const freshDecisions = proposals
|
|
1861
|
+
.filter((proposal) => !seenKeys.has(buildIdempotencyKey(proposal)))
|
|
1862
|
+
.filter((proposal) => !restoredKeys.has(buildIdempotencyKey(proposal)))
|
|
1863
|
+
.map((proposal) => proposalToDecisionRecord(proposal));
|
|
1864
|
+
|
|
1865
|
+
// De-dup fresh proposals against each other too (multiple strategies could
|
|
1866
|
+
// theoretically propose the same idempotency key in one call).
|
|
1867
|
+
const dedupedFresh: PruneDecisionRecord[] = [];
|
|
1868
|
+
const freshKeys = new Set<string>();
|
|
1869
|
+
for (const decision of freshDecisions) {
|
|
1870
|
+
if (freshKeys.has(decision.idempotencyKey)) continue;
|
|
1871
|
+
freshKeys.add(decision.idempotencyKey);
|
|
1872
|
+
dedupedFresh.push(decision);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// Manual decisions (source: "manual") always bypass the net-benefit gate.
|
|
1876
|
+
// Only fresh *automatic* proposals are ever gated; gating happens here
|
|
1877
|
+
// against the pre-mutation `messages` clone, before the apply loop below
|
|
1878
|
+
// mutates anything.
|
|
1879
|
+
const freshManual = dedupedFresh.filter((decision) => decision.source === "manual");
|
|
1880
|
+
const freshAutomatic = dedupedFresh.filter((decision) => decision.source !== "manual");
|
|
1881
|
+
|
|
1882
|
+
// Collapse fresh AUTOMATIC candidates that target the same (kind,
|
|
1883
|
+
// correlation) down to a single decision (pe-j7sb). Two strategies (e.g.
|
|
1884
|
+
// `dedupe` and `superseded-file-ops`) can independently propose a decision
|
|
1885
|
+
// for the exact same tool result; their idempotencyKeys differ (each
|
|
1886
|
+
// embeds its own strategyId) so the self-dedup above does not catch this,
|
|
1887
|
+
// and letting both through would double-count the same tokensRemoved in
|
|
1888
|
+
// the net-benefit gate and double-apply/double-credit in the apply loop
|
|
1889
|
+
// below. The winner is chosen by a DETERMINISTIC strategy priority derived
|
|
1890
|
+
// from `STRATEGIES` array order: earlier entries in `STRATEGIES` win. Only
|
|
1891
|
+
// the winning decision's placeholder/reason is what ends up gated/applied/
|
|
1892
|
+
// persisted. Manual decisions (already filtered out above) and persisted
|
|
1893
|
+
// decisions are never collapsed here — only fresh automatic overlap causes
|
|
1894
|
+
// the double-count this call.
|
|
1895
|
+
const strategyPriority = new Map(STRATEGIES.map((strategy, index) => [strategy.id, index]));
|
|
1896
|
+
const collapsedByTarget = new Map<string, PruneDecisionRecord>();
|
|
1897
|
+
for (const decision of freshAutomatic) {
|
|
1898
|
+
const targetKey = buildDecisionTargetKey(decision);
|
|
1899
|
+
const existing = collapsedByTarget.get(targetKey);
|
|
1900
|
+
if (!existing) {
|
|
1901
|
+
collapsedByTarget.set(targetKey, decision);
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
const existingPriority = strategyPriority.get(existing.strategyId) ?? Number.POSITIVE_INFINITY;
|
|
1905
|
+
const candidatePriority = strategyPriority.get(decision.strategyId) ?? Number.POSITIVE_INFINITY;
|
|
1906
|
+
if (candidatePriority < existingPriority) collapsedByTarget.set(targetKey, decision);
|
|
1907
|
+
}
|
|
1908
|
+
const collapsedFreshAutomatic = Array.from(collapsedByTarget.values());
|
|
1909
|
+
|
|
1910
|
+
// Fresh automatic candidates must also be excluded from gating (not just
|
|
1911
|
+
// from the apply loop) when their target is already claimed by a decision
|
|
1912
|
+
// that will apply earlier in `allCandidates` — i.e. a persisted decision or
|
|
1913
|
+
// a fresh manual decision. Persisted decisions come from a DIFFERENT prior
|
|
1914
|
+
// call, so a fresh proposal from a different strategy on the same
|
|
1915
|
+
// toolCallId+kind has a distinct idempotencyKey and slips past `seenKeys`
|
|
1916
|
+
// above; likewise a fresh manual decision has `source: "manual"` and is
|
|
1917
|
+
// filtered out of `freshAutomatic` but still claims its target. Without
|
|
1918
|
+
// this exclusion, `evaluateNetBenefitGate` below would double-count that
|
|
1919
|
+
// target's tokensRemoved even though the apply-loop guard further down
|
|
1920
|
+
// correctly skips applying the fresh candidate — corrupting the gate's
|
|
1921
|
+
// net-benefit math (and potentially flipping ACCEPT/REJECT) even though no
|
|
1922
|
+
// double-apply occurs. Only decisions with `correlation.type ===
|
|
1923
|
+
// "toolCallId"` are applyable targets worth tracking here; anything else is
|
|
1924
|
+
// ignored safely (it cannot collide with a toolCallId-keyed target).
|
|
1925
|
+
const claimedTargetKeys = new Set<string>();
|
|
1926
|
+
for (const decision of [...input.persistedDecisions, ...freshManual]) {
|
|
1927
|
+
if (decision.correlation.type !== "toolCallId") continue;
|
|
1928
|
+
claimedTargetKeys.add(buildDecisionTargetKey(decision));
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const savingsByKey = new Map<string, number>();
|
|
1932
|
+
const gateCandidates: GateCandidate[] = [];
|
|
1933
|
+
for (const decision of collapsedFreshAutomatic) {
|
|
1934
|
+
if (claimedTargetKeys.has(buildDecisionTargetKey(decision))) continue; // already claimed by a persisted/manual decision applying earlier
|
|
1935
|
+
if (isDecisionProtected(decision, messages, input.config.protections, recencyBoundaryIndex, pairIndex)) continue;
|
|
1936
|
+
const estimate = estimateDecisionSavings(messages, decision, estimateTokensForText, pairIndex);
|
|
1937
|
+
if (!estimate || estimate.tokensRemoved <= 0) continue;
|
|
1938
|
+
savingsByKey.set(decision.idempotencyKey, estimate.tokensRemoved);
|
|
1939
|
+
gateCandidates.push({ decision, position: estimate.position, tokensRemoved: estimate.tokensRemoved });
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
const gate = evaluateNetBenefitGate(gateCandidates, messages, input.config.gate, agentState, estimateTokensForText);
|
|
1943
|
+
|
|
1944
|
+
const allCandidates = [...input.persistedDecisions, ...freshManual, ...gate.accepted];
|
|
1945
|
+
const newlyAppliedDecisions: PruneDecisionRecord[] = [];
|
|
1946
|
+
const newlyAppliedStats: PruneStatsRecord[] = [];
|
|
1947
|
+
|
|
1948
|
+
// Guards against double-apply/double-count when more than one candidate in
|
|
1949
|
+
// `allCandidates` targets the same (kind, correlation) — e.g. a persisted
|
|
1950
|
+
// decision from a different strategy and a fresh automatic decision (from
|
|
1951
|
+
// a strategy that started proposing overlap only after the persisted one
|
|
1952
|
+
// was written) both targeting the same toolCallId+kind in this SAME call.
|
|
1953
|
+
// `applyPruneDecision` unconditionally re-applies its placeholder even when
|
|
1954
|
+
// the target is already a placeholder and reports `applied: true`, so
|
|
1955
|
+
// without this guard the second candidate would silently overwrite the
|
|
1956
|
+
// first's placeholder AND still be credited full stats/newlyAppliedDecisions
|
|
1957
|
+
// (pe-j7sb). The pre-gate collapse above already prevents this among fresh
|
|
1958
|
+
// automatic candidates; this guard additionally covers persisted-vs-fresh
|
|
1959
|
+
// (and any other) overlap within a single call.
|
|
1960
|
+
const appliedTargetKeys = new Set<string>();
|
|
1961
|
+
for (const decision of allCandidates) {
|
|
1962
|
+
if (isDecisionProtected(decision, messages, input.config.protections, recencyBoundaryIndex, pairIndex)) continue;
|
|
1963
|
+
const targetKey = buildDecisionTargetKey(decision);
|
|
1964
|
+
if (appliedTargetKeys.has(targetKey)) continue; // already pruned by an earlier candidate this call
|
|
1965
|
+
const result = applyPruneDecision(messages, decision, pairIndex);
|
|
1966
|
+
if (!result.applied) continue; // graceful no-op: target absent/already safe
|
|
1967
|
+
appliedTargetKeys.add(targetKey);
|
|
1968
|
+
if (!input.knownIdempotencyKeys.has(decision.idempotencyKey)) {
|
|
1969
|
+
newlyAppliedDecisions.push(decision);
|
|
1970
|
+
const tokensRemoved =
|
|
1971
|
+
savingsByKey.get(decision.idempotencyKey) ??
|
|
1972
|
+
estimateDecisionSavings(input.messages, decision, estimateTokensForText, pairIndex)?.tokensRemoved ??
|
|
1973
|
+
0;
|
|
1974
|
+
newlyAppliedStats.push(buildStatsRecord(decision, tokensRemoved));
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
const contextSizeSnapshot = computeContextSizeSnapshot(input.messages, messages, estimateTokensForText);
|
|
1979
|
+
|
|
1980
|
+
return { messages, newlyAppliedDecisions, newlyAppliedStats, contextSizeSnapshot, gate };
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// ============================================================================
|
|
1984
|
+
// Session-entry state rebuild (idempotent; tolerates duplicate/replayed entries)
|
|
1985
|
+
// ============================================================================
|
|
1986
|
+
|
|
1987
|
+
export interface RebuiltDecisionState {
|
|
1988
|
+
decisions: PruneDecisionRecord[];
|
|
1989
|
+
idempotencyKeys: Set<string>;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/** Rebuild in-memory decision state from persisted CustomEntry records on a branch. */
|
|
1993
|
+
export function rebuildDecisionStateFromEntries(entries: MinimalSessionEntry[]): RebuiltDecisionState {
|
|
1994
|
+
const decisions: PruneDecisionRecord[] = [];
|
|
1995
|
+
const idempotencyKeys = new Set<string>();
|
|
1996
|
+
for (const entry of entries) {
|
|
1997
|
+
if (entry.type !== "custom" || entry.customType !== DECISION_ENTRY_TYPE) continue;
|
|
1998
|
+
const record = parseDecisionRecord(entry.data);
|
|
1999
|
+
if (!record) continue;
|
|
2000
|
+
if (idempotencyKeys.has(record.idempotencyKey)) continue; // tolerate duplicate/replayed entries
|
|
2001
|
+
idempotencyKeys.add(record.idempotencyKey);
|
|
2002
|
+
decisions.push(record);
|
|
2003
|
+
}
|
|
2004
|
+
return { decisions, idempotencyKeys };
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// ============================================================================
|
|
2008
|
+
// /prune picker helpers (pe-8re9)
|
|
2009
|
+
// ============================================================================
|
|
2010
|
+
|
|
2011
|
+
/** Convert branch entries into the plain message array the pipeline/pickers correlate against. */
|
|
2012
|
+
export function sessionEntriesToMessages(entries: MinimalSessionEntry[]): MinimalMessage[] {
|
|
2013
|
+
const messages: MinimalMessage[] = [];
|
|
2014
|
+
for (const entry of entries) {
|
|
2015
|
+
if (entry.type === "message" && entry.message) messages.push(entry.message);
|
|
2016
|
+
}
|
|
2017
|
+
return messages;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/** Truncated, canonicalized single-line digest of a tool call's arguments, for compact picker display. */
|
|
2021
|
+
export function buildArgsDigest(args: unknown, maxLength = 80): string {
|
|
2022
|
+
const json = canonicalizeArgumentsJSON(args);
|
|
2023
|
+
if (json.length <= maxLength) return json;
|
|
2024
|
+
return `${json.slice(0, Math.max(0, maxLength - 1))}\u2026`;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
export type PrunableItemStatus = "active" | "pruned" | "restored";
|
|
2028
|
+
|
|
2029
|
+
export interface PrunableItem {
|
|
2030
|
+
toolCallId: string;
|
|
2031
|
+
toolName: string;
|
|
2032
|
+
argsDigest: string;
|
|
2033
|
+
/** Estimated tokens of the ORIGINAL (unpruned) tool result content, i.e. what pruning would save / restoring would bring back. */
|
|
2034
|
+
estimatedTokens: number;
|
|
2035
|
+
status: PrunableItemStatus;
|
|
2036
|
+
/** Set only when status === "pruned": the currently-active decision pruning this result. */
|
|
2037
|
+
activeDecision?: PruneDecisionRecord;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/**
|
|
2041
|
+
* Build picker rows for every completed tool call whose result is eligible
|
|
2042
|
+
* for manual prune/restore. `activeByToolCallId` should be built from
|
|
2043
|
+
* currently-ACTIVE decisions only (kind: "tool_result_content"), keyed by
|
|
2044
|
+
* toolCallId; `restoredToolCallIds` from currently-restored keys' underlying
|
|
2045
|
+
* toolCallId (see `buildActiveResultDecisionMap`/`extractToolCallIdFromDecisionKey`
|
|
2046
|
+
* usage in the extension wiring).
|
|
2047
|
+
*/
|
|
2048
|
+
export function buildPrunableItems(
|
|
2049
|
+
messages: MinimalMessage[],
|
|
2050
|
+
activeByToolCallId: Map<string, PruneDecisionRecord>,
|
|
2051
|
+
restoredToolCallIds: ReadonlySet<string>,
|
|
2052
|
+
_estimateTokens: TokenEstimator = estimateTokensForText,
|
|
2053
|
+
): PrunableItem[] {
|
|
2054
|
+
const items: PrunableItem[] = [];
|
|
2055
|
+
for (const occurrence of collectCompletedToolCallOccurrences(messages)) {
|
|
2056
|
+
const resultMessage = messages[occurrence.resultIndex] as MinimalToolResultMessage;
|
|
2057
|
+
const estimatedTokens = estimateTokensForContent(resultMessage.content);
|
|
2058
|
+
const activeDecision = activeByToolCallId.get(occurrence.toolCallId);
|
|
2059
|
+
const status: PrunableItemStatus = activeDecision
|
|
2060
|
+
? "pruned"
|
|
2061
|
+
: restoredToolCallIds.has(occurrence.toolCallId)
|
|
2062
|
+
? "restored"
|
|
2063
|
+
: "active";
|
|
2064
|
+
items.push({
|
|
2065
|
+
toolCallId: occurrence.toolCallId,
|
|
2066
|
+
toolName: occurrence.toolName,
|
|
2067
|
+
argsDigest: buildArgsDigest(occurrence.arguments),
|
|
2068
|
+
estimatedTokens,
|
|
2069
|
+
status,
|
|
2070
|
+
activeDecision,
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
return items;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
/** Build a Map<toolCallId, decision> from active decisions, restricted to tool_result_content kind. First match per toolCallId wins. */
|
|
2077
|
+
export function buildActiveResultDecisionMap(activeDecisions: PruneDecisionRecord[]): Map<string, PruneDecisionRecord> {
|
|
2078
|
+
const map = new Map<string, PruneDecisionRecord>();
|
|
2079
|
+
for (const decision of activeDecisions) {
|
|
2080
|
+
if (decision.kind !== "tool_result_content") continue;
|
|
2081
|
+
if (decision.correlation.type !== "toolCallId") continue;
|
|
2082
|
+
if (!map.has(decision.correlation.toolCallId)) map.set(decision.correlation.toolCallId, decision);
|
|
2083
|
+
}
|
|
2084
|
+
return map;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/** Single-line label for a /prune picker option row. */
|
|
2088
|
+
export function formatPrunableItemOption(item: PrunableItem): string {
|
|
2089
|
+
const statusLabel =
|
|
2090
|
+
item.status === "pruned"
|
|
2091
|
+
? `pruned by ${item.activeDecision?.strategyId ?? "?"}${item.activeDecision?.source === "manual" ? " (manual)" : ""}`
|
|
2092
|
+
: item.status === "restored"
|
|
2093
|
+
? "restored (prunable again)"
|
|
2094
|
+
: "not pruned";
|
|
2095
|
+
return `${item.toolName} ${item.argsDigest} \u2014 ~${item.estimatedTokens} tok \u2014 ${statusLabel}`;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
/**
|
|
2099
|
+
* Picker option rows for the /prune interactive selector: a stable, guaranteed-unique label
|
|
2100
|
+
* (a 1-based row-number prefix) paired with its source item, so the caller can recover the
|
|
2101
|
+
* exact selected row even when two items would otherwise render byte-identical labels
|
|
2102
|
+
* (e.g. duplicate tool calls with the same args digest, estimated tokens, and status).
|
|
2103
|
+
* Do NOT recover a selection via `formatPrunableItemOption(item) === choice` or
|
|
2104
|
+
* `options.indexOf(choice)` on re-derived labels — always match against these rows' `label`.
|
|
2105
|
+
*/
|
|
2106
|
+
export function buildPrunableItemPickerOptions(items: PrunableItem[]): Array<{ label: string; item: PrunableItem }> {
|
|
2107
|
+
return items.map((item, index) => ({ label: `${index + 1}) ${formatPrunableItemOption(item)}`, item }));
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
/** Multi-line detail text for a /prune confirm dialog, describing the item and (when pruning) the predicted cache-bust cost. */
|
|
2111
|
+
export function formatPrunableItemDetail(item: PrunableItem, cost?: CacheCostModelResult): string {
|
|
2112
|
+
const lines = [`Tool: ${item.toolName}`, `Args: ${item.argsDigest}`, `Estimated tokens: ${item.estimatedTokens}`];
|
|
2113
|
+
if (item.status === "pruned" && item.activeDecision) {
|
|
2114
|
+
lines.push(`Currently pruned by "${item.activeDecision.strategyId}": ${item.activeDecision.reason}`);
|
|
2115
|
+
}
|
|
2116
|
+
if (cost) {
|
|
2117
|
+
lines.push(
|
|
2118
|
+
`Predicted cache-bust cost: penalty \u2248${cost.penalty.toFixed(0)} tok, recurring saving \u2248${cost.recurringSaving.toFixed(0)} tok/call, break-even \u2248${Number.isFinite(cost.breakEvenCalls) ? cost.breakEvenCalls.toFixed(1) : "\u221e"} calls.`,
|
|
2119
|
+
"Manual prunes always bypass the net-benefit gate, so this preview is informational only.",
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
return lines.join("\n");
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
/** Plain-text report used both by the non-UI /prune fallback and as a debugging aid. */
|
|
2126
|
+
export function formatPrunableItemsReport(items: PrunableItem[]): string[] {
|
|
2127
|
+
if (items.length === 0) return ["No prunable tool results found in this session."];
|
|
2128
|
+
return items.map((item, index) => `${index + 1}. ${formatPrunableItemOption(item)}`);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// ============================================================================
|
|
2132
|
+
// /context-pruning control + status/stats helpers (pe-8re9)
|
|
2133
|
+
// ============================================================================
|
|
2134
|
+
|
|
2135
|
+
export type StrategyKey = "dedupe" | "errorPurge" | "supersededFileOps";
|
|
2136
|
+
|
|
2137
|
+
const STRATEGY_ALIASES: Record<string, StrategyKey> = {
|
|
2138
|
+
dedupe: "dedupe",
|
|
2139
|
+
"error-purge": "errorPurge",
|
|
2140
|
+
errorpurge: "errorPurge",
|
|
2141
|
+
error_purge: "errorPurge",
|
|
2142
|
+
errorPurge: "errorPurge",
|
|
2143
|
+
"superseded-file-ops": "supersededFileOps",
|
|
2144
|
+
supersededfileops: "supersededFileOps",
|
|
2145
|
+
superseded_file_ops: "supersededFileOps",
|
|
2146
|
+
supersededFileOps: "supersededFileOps",
|
|
2147
|
+
};
|
|
2148
|
+
|
|
2149
|
+
const STRATEGY_DISPLAY_NAMES: Record<StrategyKey, string> = {
|
|
2150
|
+
dedupe: "dedupe",
|
|
2151
|
+
errorPurge: "error-purge",
|
|
2152
|
+
supersededFileOps: "superseded-file-ops",
|
|
2153
|
+
};
|
|
2154
|
+
|
|
2155
|
+
export type ContextPruningSubcommand =
|
|
2156
|
+
| { kind: "help" }
|
|
2157
|
+
| { kind: "status" }
|
|
2158
|
+
| { kind: "stats" }
|
|
2159
|
+
| { kind: "enabled"; value: boolean }
|
|
2160
|
+
| { kind: "toggle" }
|
|
2161
|
+
| { kind: "strategy"; strategy: StrategyKey; value: boolean }
|
|
2162
|
+
| { kind: "gate"; mode: GateMode }
|
|
2163
|
+
| { kind: "unknown"; raw: string };
|
|
2164
|
+
|
|
2165
|
+
/** Parse `/context-pruning <...>` arguments into a typed subcommand. Pure; never throws. */
|
|
2166
|
+
export function parseContextPruningArgs(rawArgs: string): ContextPruningSubcommand {
|
|
2167
|
+
const tokens = rawArgs.trim().split(/\s+/).filter(Boolean);
|
|
2168
|
+
if (tokens.length === 0) return { kind: "help" };
|
|
2169
|
+
const [head, ...rest] = tokens;
|
|
2170
|
+
const cmd = head.toLowerCase();
|
|
2171
|
+
|
|
2172
|
+
switch (cmd) {
|
|
2173
|
+
case "help":
|
|
2174
|
+
case "-h":
|
|
2175
|
+
case "--help":
|
|
2176
|
+
return { kind: "help" };
|
|
2177
|
+
case "status":
|
|
2178
|
+
return { kind: "status" };
|
|
2179
|
+
case "stats":
|
|
2180
|
+
return { kind: "stats" };
|
|
2181
|
+
case "on":
|
|
2182
|
+
return { kind: "enabled", value: true };
|
|
2183
|
+
case "off":
|
|
2184
|
+
return { kind: "enabled", value: false };
|
|
2185
|
+
case "toggle":
|
|
2186
|
+
return { kind: "toggle" };
|
|
2187
|
+
case "strategy": {
|
|
2188
|
+
const [nameRaw, valueRaw] = rest;
|
|
2189
|
+
const key = nameRaw ? STRATEGY_ALIASES[nameRaw.toLowerCase()] ?? STRATEGY_ALIASES[nameRaw] : undefined;
|
|
2190
|
+
const value = valueRaw?.toLowerCase();
|
|
2191
|
+
if (!key || (value !== "on" && value !== "off")) return { kind: "unknown", raw: rawArgs };
|
|
2192
|
+
return { kind: "strategy", strategy: key, value: value === "on" };
|
|
2193
|
+
}
|
|
2194
|
+
case "gate": {
|
|
2195
|
+
const modeRaw = rest[0]?.toLowerCase();
|
|
2196
|
+
if (modeRaw === "on" || modeRaw === "off" || modeRaw === "always-apply") return { kind: "gate", mode: modeRaw };
|
|
2197
|
+
return { kind: "unknown", raw: rawArgs };
|
|
2198
|
+
}
|
|
2199
|
+
default:
|
|
2200
|
+
return { kind: "unknown", raw: rawArgs };
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
export function contextPruningUsage(): string {
|
|
2205
|
+
return [
|
|
2206
|
+
"Usage: /context-pruning <status|stats|on|off|toggle|strategy <name> on|off|gate <on|off|always-apply>>",
|
|
2207
|
+
"",
|
|
2208
|
+
" status Show enabled state, strategies, protections, and context size.",
|
|
2209
|
+
" stats Show cumulative tokens saved per strategy.",
|
|
2210
|
+
" on | off | toggle Enable/disable dynamic context pruning entirely.",
|
|
2211
|
+
" strategy <name> on|off Toggle one strategy: dedupe | error-purge | superseded-file-ops.",
|
|
2212
|
+
" gate on|off|always-apply Control the net-benefit gate: reject/bypass/model-only.",
|
|
2213
|
+
].join("\n");
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
export interface ConfigMutationResult {
|
|
2217
|
+
config: DynamicContextPruningConfig;
|
|
2218
|
+
message: string;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
/** Apply a config-mutating subcommand ((enabled|toggle|strategy|gate)) to a config. Pure; returns a new config object. */
|
|
2222
|
+
export function applyContextPruningConfigMutation(
|
|
2223
|
+
config: DynamicContextPruningConfig,
|
|
2224
|
+
subcommand: Extract<ContextPruningSubcommand, { kind: "enabled" | "toggle" | "strategy" | "gate" }>,
|
|
2225
|
+
): ConfigMutationResult {
|
|
2226
|
+
if (subcommand.kind === "enabled") {
|
|
2227
|
+
return {
|
|
2228
|
+
config: { ...config, enabled: subcommand.value },
|
|
2229
|
+
message: `Dynamic context pruning ${subcommand.value ? "enabled" : "disabled"}.`,
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
if (subcommand.kind === "toggle") {
|
|
2233
|
+
const value = !config.enabled;
|
|
2234
|
+
return { config: { ...config, enabled: value }, message: `Dynamic context pruning ${value ? "enabled" : "disabled"}.` };
|
|
2235
|
+
}
|
|
2236
|
+
if (subcommand.kind === "strategy") {
|
|
2237
|
+
const strategies = {
|
|
2238
|
+
...config.strategies,
|
|
2239
|
+
[subcommand.strategy]: { ...config.strategies[subcommand.strategy], enabled: subcommand.value },
|
|
2240
|
+
};
|
|
2241
|
+
return {
|
|
2242
|
+
config: { ...config, strategies },
|
|
2243
|
+
message: `Strategy "${STRATEGY_DISPLAY_NAMES[subcommand.strategy]}" ${subcommand.value ? "enabled" : "disabled"}.`,
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
return {
|
|
2247
|
+
config: { ...config, gate: { ...config.gate, mode: subcommand.mode } },
|
|
2248
|
+
message: `Net-benefit gate set to "${subcommand.mode}".`,
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
export interface StatusReportInput {
|
|
2253
|
+
config: DynamicContextPruningConfig;
|
|
2254
|
+
contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null };
|
|
2255
|
+
lastSnapshot?: ContextSizeSnapshot;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/** Human-readable `/context-pruning status` report lines. Pure; testable with fixture inputs. */
|
|
2259
|
+
export function formatStatusReport(input: StatusReportInput): string[] {
|
|
2260
|
+
const { config, contextUsage, lastSnapshot } = input;
|
|
2261
|
+
const lines: string[] = [];
|
|
2262
|
+
lines.push(`Dynamic context pruning: ${config.enabled ? "ENABLED" : "disabled"}`);
|
|
2263
|
+
lines.push(
|
|
2264
|
+
`Gate: mode=${config.gate.mode} threshold=${config.gate.breakEvenThreshold} cachedPriceRatio=${config.gate.cachedPriceRatio}`,
|
|
2265
|
+
);
|
|
2266
|
+
lines.push("Strategies:");
|
|
2267
|
+
lines.push(` dedupe: ${config.strategies.dedupe.enabled ? "on" : "off"}`);
|
|
2268
|
+
lines.push(
|
|
2269
|
+
` error-purge: ${config.strategies.errorPurge.enabled ? "on" : "off"} (minTurnsOld=${config.strategies.errorPurge.minTurnsOld})`,
|
|
2270
|
+
);
|
|
2271
|
+
lines.push(` superseded-file-ops: ${config.strategies.supersededFileOps.enabled ? "on" : "off"}`);
|
|
2272
|
+
lines.push(
|
|
2273
|
+
`Protections: recentTurns=${config.protections.recentTurns}, protectedTools=${config.protections.toolNames.length}, protectedPathGlobs=${config.protections.pathGlobs.length}`,
|
|
2274
|
+
);
|
|
2275
|
+
if (lastSnapshot) {
|
|
2276
|
+
lines.push(
|
|
2277
|
+
`Last call: raw=${lastSnapshot.rawTokens} tok, effective=${lastSnapshot.effectiveTokens} tok, saved=${lastSnapshot.tokensSavedThisCall} tok`,
|
|
2278
|
+
);
|
|
2279
|
+
} else {
|
|
2280
|
+
lines.push("Last call: no context snapshot yet (nothing has run through the pipeline this session).");
|
|
2281
|
+
}
|
|
2282
|
+
if (contextUsage) {
|
|
2283
|
+
const percent = contextUsage.percent != null ? ` (${contextUsage.percent.toFixed(1)}%)` : "";
|
|
2284
|
+
lines.push(`Current context usage: ${contextUsage.tokens ?? "unknown"} / ${contextUsage.contextWindow} tokens${percent}`);
|
|
2285
|
+
}
|
|
2286
|
+
return lines;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
/** Human-readable `/context-pruning stats` report lines. Pure; testable with fixture inputs. */
|
|
2290
|
+
export function formatStatsReport(stats: CumulativePruneStats): string[] {
|
|
2291
|
+
const lines: string[] = [`Total tokens saved: ${stats.totalTokensRemoved} across ${stats.totalPruneCount} prune(s).`];
|
|
2292
|
+
const strategyIds = Object.keys(stats.byStrategy).sort();
|
|
2293
|
+
if (strategyIds.length === 0) {
|
|
2294
|
+
lines.push("No prunes recorded yet.");
|
|
2295
|
+
return lines;
|
|
2296
|
+
}
|
|
2297
|
+
for (const id of strategyIds) {
|
|
2298
|
+
const s = stats.byStrategy[id];
|
|
2299
|
+
lines.push(` ${id}: ${s.tokensRemoved} tok saved across ${s.pruneCount} prune(s)`);
|
|
2300
|
+
}
|
|
2301
|
+
return lines;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// ============================================================================
|
|
2305
|
+
// Extension wiring
|
|
2306
|
+
// ============================================================================
|
|
2307
|
+
|
|
2308
|
+
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
2309
|
+
if (ctx.hasUI) {
|
|
2310
|
+
ctx.ui.notify(message, level);
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
console.error(message);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
export const __testing = {
|
|
2317
|
+
globToRegExp,
|
|
2318
|
+
getConfigPath,
|
|
2319
|
+
readConfig,
|
|
2320
|
+
writeConfig,
|
|
2321
|
+
};
|
|
2322
|
+
|
|
2323
|
+
export default function dynamicContextPruningExtension(pi: ExtensionAPI) {
|
|
2324
|
+
let config: DynamicContextPruningConfig = defaultConfig();
|
|
2325
|
+
let persistedDecisions: PruneDecisionRecord[] = [];
|
|
2326
|
+
let knownIdempotencyKeys = new Set<string>();
|
|
2327
|
+
let restoredIdempotencyKeys = new Set<string>();
|
|
2328
|
+
let lastDecisionByKey = new Map<string, PruneDecisionRecord>();
|
|
2329
|
+
let cumulativeStats: CumulativePruneStats = emptyCumulativeStats();
|
|
2330
|
+
let knownStatsKeys = new Set<string>();
|
|
2331
|
+
let lastContextSizeSnapshot: ContextSizeSnapshot | undefined;
|
|
2332
|
+
|
|
2333
|
+
const rebuildState = (ctx: ExtensionContext) => {
|
|
2334
|
+
const branchEntries = ctx.sessionManager.getBranch() as unknown as MinimalSessionEntry[];
|
|
2335
|
+
const tombstoneState = resolvePruneTombstoneState(branchEntries);
|
|
2336
|
+
persistedDecisions = tombstoneState.activeDecisions;
|
|
2337
|
+
knownIdempotencyKeys = tombstoneState.activeIdempotencyKeys;
|
|
2338
|
+
restoredIdempotencyKeys = tombstoneState.restoredKeys;
|
|
2339
|
+
lastDecisionByKey = tombstoneState.lastDecisionByKey;
|
|
2340
|
+
const rebuiltStats = rebuildStatsStateFromEntries(branchEntries);
|
|
2341
|
+
cumulativeStats = rebuiltStats.stats;
|
|
2342
|
+
knownStatsKeys = rebuiltStats.seenKeys;
|
|
2343
|
+
};
|
|
2344
|
+
|
|
2345
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
2346
|
+
config = await readConfig();
|
|
2347
|
+
rebuildState(ctx);
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
2351
|
+
rebuildState(ctx);
|
|
2352
|
+
});
|
|
2353
|
+
|
|
2354
|
+
pi.on("context", async (event, ctx) => {
|
|
2355
|
+
if (!config.enabled) return undefined;
|
|
2356
|
+
|
|
2357
|
+
const result = runDynamicContextPruningPipeline({
|
|
2358
|
+
messages: event.messages as unknown as MinimalMessage[],
|
|
2359
|
+
config,
|
|
2360
|
+
persistedDecisions,
|
|
2361
|
+
knownIdempotencyKeys,
|
|
2362
|
+
restoredIdempotencyKeys,
|
|
2363
|
+
// Real mid-loop/idle agent-state detection lands in a follow-up ticket;
|
|
2364
|
+
// "idle" is the safe default until it's wired through ctx.
|
|
2365
|
+
agentState: "idle",
|
|
2366
|
+
cwd: ctx.sessionManager.getCwd(),
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
for (const decision of result.newlyAppliedDecisions) {
|
|
2370
|
+
if (knownIdempotencyKeys.has(decision.idempotencyKey)) continue;
|
|
2371
|
+
knownIdempotencyKeys.add(decision.idempotencyKey);
|
|
2372
|
+
persistedDecisions.push(decision);
|
|
2373
|
+
lastDecisionByKey.set(decision.idempotencyKey, decision);
|
|
2374
|
+
try {
|
|
2375
|
+
pi.appendEntry(DECISION_ENTRY_TYPE, decision);
|
|
2376
|
+
} catch {
|
|
2377
|
+
// Persisting a decision for a call that never completes (abort/retry)
|
|
2378
|
+
// is harmless: pruning is recomputed fresh on every call. Swallow.
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
for (const statRecord of result.newlyAppliedStats) {
|
|
2383
|
+
if (knownStatsKeys.has(statRecord.idempotencyKey)) continue;
|
|
2384
|
+
knownStatsKeys.add(statRecord.idempotencyKey);
|
|
2385
|
+
cumulativeStats = foldStatsRecord(cumulativeStats, statRecord);
|
|
2386
|
+
try {
|
|
2387
|
+
pi.appendEntry(STATS_ENTRY_TYPE, statRecord);
|
|
2388
|
+
} catch {
|
|
2389
|
+
// Same reasoning as decision persistence above: safe to drop on abort/retry.
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
lastContextSizeSnapshot = result.contextSizeSnapshot;
|
|
2394
|
+
void ctx; // ctx currently unused beyond typing; kept for future protections/UI hooks.
|
|
2395
|
+
return { messages: result.messages as never };
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
/** Persist + apply a manual prune decision immediately (accounting included), bypassing the net-benefit gate. */
|
|
2399
|
+
const persistManualPrune = (messages: MinimalMessage[], toolCallId: string): { decision: PruneDecisionRecord; tokensRemoved: number } => {
|
|
2400
|
+
const proposal = buildManualPruneProposal(toolCallId);
|
|
2401
|
+
const decision = proposalToDecisionRecord(proposal);
|
|
2402
|
+
const estimate = estimateDecisionSavings(messages, decision, estimateTokensForText);
|
|
2403
|
+
const tokensRemoved = estimate?.tokensRemoved ?? 0;
|
|
2404
|
+
const statRecord = buildStatsRecord(decision, tokensRemoved);
|
|
2405
|
+
|
|
2406
|
+
pi.appendEntry(DECISION_ENTRY_TYPE, decision);
|
|
2407
|
+
pi.appendEntry(STATS_ENTRY_TYPE, statRecord);
|
|
2408
|
+
|
|
2409
|
+
persistedDecisions = [...persistedDecisions.filter((d) => d.idempotencyKey !== decision.idempotencyKey), decision];
|
|
2410
|
+
knownIdempotencyKeys.add(decision.idempotencyKey);
|
|
2411
|
+
restoredIdempotencyKeys.delete(decision.idempotencyKey);
|
|
2412
|
+
lastDecisionByKey.set(decision.idempotencyKey, decision);
|
|
2413
|
+
if (!knownStatsKeys.has(statRecord.idempotencyKey)) {
|
|
2414
|
+
knownStatsKeys.add(statRecord.idempotencyKey);
|
|
2415
|
+
cumulativeStats = foldStatsRecord(cumulativeStats, statRecord);
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
return { decision, tokensRemoved };
|
|
2419
|
+
};
|
|
2420
|
+
|
|
2421
|
+
/** Persist a restore (tombstone) for a currently-active decision. Idempotent: safe to call more than once. */
|
|
2422
|
+
const persistRestore = (idempotencyKey: string): void => {
|
|
2423
|
+
const record = buildRestoreRecord(idempotencyKey);
|
|
2424
|
+
pi.appendEntry(RESTORE_ENTRY_TYPE, record);
|
|
2425
|
+
persistedDecisions = persistedDecisions.filter((d) => d.idempotencyKey !== idempotencyKey);
|
|
2426
|
+
knownIdempotencyKeys.delete(idempotencyKey);
|
|
2427
|
+
restoredIdempotencyKeys.add(idempotencyKey);
|
|
2428
|
+
};
|
|
2429
|
+
|
|
2430
|
+
pi.registerCommand("prune", {
|
|
2431
|
+
description: "Review, manually prune, and restore prunable tool results (interactive picker in TUI/RPC)",
|
|
2432
|
+
handler: async (_args, ctx) => {
|
|
2433
|
+
const branchEntries = ctx.sessionManager.getBranch() as unknown as MinimalSessionEntry[];
|
|
2434
|
+
const messages = sessionEntriesToMessages(branchEntries);
|
|
2435
|
+
const activeByToolCallId = buildActiveResultDecisionMap(persistedDecisions);
|
|
2436
|
+
const restoredToolCallIds = new Set(
|
|
2437
|
+
Array.from(lastDecisionByKey.values())
|
|
2438
|
+
.filter((d) => d.correlation.type === "toolCallId" && restoredIdempotencyKeys.has(d.idempotencyKey))
|
|
2439
|
+
.map((d) => (d.correlation as { type: "toolCallId"; toolCallId: string }).toolCallId),
|
|
2440
|
+
);
|
|
2441
|
+
const items = buildPrunableItems(messages, activeByToolCallId, restoredToolCallIds);
|
|
2442
|
+
|
|
2443
|
+
if (!ctx.hasUI) {
|
|
2444
|
+
notify(ctx, ["/prune: no interactive UI available. Prunable tool results in this session:", "", ...formatPrunableItemsReport(items)].join("\n"), "info");
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
if (items.length === 0) {
|
|
2449
|
+
notify(ctx, "No prunable tool results found in this session.", "info");
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
const doneLabel = "Done";
|
|
2454
|
+
while (true) {
|
|
2455
|
+
const latestBranch = ctx.sessionManager.getBranch() as unknown as MinimalSessionEntry[];
|
|
2456
|
+
const latestMessages = sessionEntriesToMessages(latestBranch);
|
|
2457
|
+
const latestActive = buildActiveResultDecisionMap(persistedDecisions);
|
|
2458
|
+
const latestRestoredToolCallIds = new Set(
|
|
2459
|
+
Array.from(lastDecisionByKey.values())
|
|
2460
|
+
.filter((d) => d.correlation.type === "toolCallId" && restoredIdempotencyKeys.has(d.idempotencyKey))
|
|
2461
|
+
.map((d) => (d.correlation as { type: "toolCallId"; toolCallId: string }).toolCallId),
|
|
2462
|
+
);
|
|
2463
|
+
const latestItems = buildPrunableItems(latestMessages, latestActive, latestRestoredToolCallIds);
|
|
2464
|
+
if (latestItems.length === 0) {
|
|
2465
|
+
notify(ctx, "No prunable tool results found in this session.", "info");
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
// Labels may collide (e.g. duplicate tool calls with identical args/tokens/status), so each
|
|
2470
|
+
// option is disambiguated with a stable row-number prefix and the selection is recovered by
|
|
2471
|
+
// matching that unique label back to its own row — never by indexOf on re-derived base labels.
|
|
2472
|
+
const pickerOptions = buildPrunableItemPickerOptions(latestItems);
|
|
2473
|
+
const options = pickerOptions.map((option) => option.label);
|
|
2474
|
+
const choice = await ctx.ui.select("/prune \u2014 select a tool result to prune or restore", [...options, doneLabel]);
|
|
2475
|
+
if (!choice || choice === doneLabel) return;
|
|
2476
|
+
|
|
2477
|
+
const item = pickerOptions.find((option) => option.label === choice)?.item;
|
|
2478
|
+
if (!item) continue;
|
|
2479
|
+
|
|
2480
|
+
if (item.status === "pruned" && item.activeDecision) {
|
|
2481
|
+
const confirmed = await ctx.ui.confirm("Restore this tool result?", formatPrunableItemDetail(item));
|
|
2482
|
+
if (confirmed) {
|
|
2483
|
+
persistRestore(item.activeDecision.idempotencyKey);
|
|
2484
|
+
notify(ctx, `Restored ${item.toolName} result; it will show its original content on the next call.`, "info");
|
|
2485
|
+
}
|
|
2486
|
+
continue;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// active or restored: offer a (re-)prune, with a predicted cache-bust cost preview.
|
|
2490
|
+
const previewProposal = buildManualPruneProposal(item.toolCallId);
|
|
2491
|
+
const previewDecision = proposalToDecisionRecord(previewProposal);
|
|
2492
|
+
const estimate = estimateDecisionSavings(latestMessages, previewDecision, estimateTokensForText);
|
|
2493
|
+
const cost = estimate
|
|
2494
|
+
? computeCacheCostModel({
|
|
2495
|
+
tailTokensAfterEarliestChange: estimateTailTokens(latestMessages, estimate.position, estimateTokensForText),
|
|
2496
|
+
tokensRemoved: estimate.tokensRemoved,
|
|
2497
|
+
cachedPriceRatio: config.gate.cachedPriceRatio,
|
|
2498
|
+
})
|
|
2499
|
+
: undefined;
|
|
2500
|
+
|
|
2501
|
+
const confirmed = await ctx.ui.confirm(`Prune ${item.toolName} result?`, formatPrunableItemDetail(item, cost));
|
|
2502
|
+
if (confirmed) {
|
|
2503
|
+
persistManualPrune(latestMessages, item.toolCallId);
|
|
2504
|
+
notify(ctx, `Pruned ${item.toolName} result; it will apply on the next call.`, "info");
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
},
|
|
2508
|
+
});
|
|
2509
|
+
|
|
2510
|
+
pi.registerCommand("context-pruning", {
|
|
2511
|
+
description: "Inspect and control dynamic context pruning: status, stats, on/off, strategy, gate",
|
|
2512
|
+
handler: async (args, ctx) => {
|
|
2513
|
+
const subcommand = parseContextPruningArgs(args);
|
|
2514
|
+
|
|
2515
|
+
if (subcommand.kind === "help") {
|
|
2516
|
+
notify(ctx, contextPruningUsage(), "info");
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
if (subcommand.kind === "unknown") {
|
|
2520
|
+
notify(ctx, `Unknown /context-pruning subcommand: "${subcommand.raw}".\n\n${contextPruningUsage()}`, "error");
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
if (subcommand.kind === "status") {
|
|
2524
|
+
const usage = ctx.getContextUsage();
|
|
2525
|
+
notify(ctx, formatStatusReport({ config, contextUsage: usage, lastSnapshot: lastContextSizeSnapshot }).join("\n"), "info");
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
if (subcommand.kind === "stats") {
|
|
2529
|
+
notify(ctx, formatStatsReport(cumulativeStats).join("\n"), "info");
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
const { config: nextConfig, message } = applyContextPruningConfigMutation(config, subcommand);
|
|
2534
|
+
config = nextConfig;
|
|
2535
|
+
try {
|
|
2536
|
+
await writeConfig(config);
|
|
2537
|
+
} catch {
|
|
2538
|
+
// Config is still updated in-memory for this session even if the write fails
|
|
2539
|
+
// (e.g. read-only filesystem); surface the change but note persistence failed.
|
|
2540
|
+
notify(ctx, `${message} (warning: failed to persist config to disk)`, "warning");
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
notify(ctx, message, "info");
|
|
2544
|
+
},
|
|
2545
|
+
});
|
|
2546
|
+
}
|