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