@diegopetrucci/pi-extensions 0.1.74 → 0.1.76

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