jeopi-agent-core 16.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +1016 -0
  2. package/README.md +473 -0
  3. package/dist/types/agent-loop.d.ts +66 -0
  4. package/dist/types/agent.d.ts +427 -0
  5. package/dist/types/append-only-context.d.ts +133 -0
  6. package/dist/types/compaction/branch-summarization.d.ts +101 -0
  7. package/dist/types/compaction/compaction-v2-streaming.d.ts +82 -0
  8. package/dist/types/compaction/compaction.d.ts +283 -0
  9. package/dist/types/compaction/entries.d.ts +110 -0
  10. package/dist/types/compaction/errors.d.ts +26 -0
  11. package/dist/types/compaction/index.d.ts +12 -0
  12. package/dist/types/compaction/messages.d.ts +77 -0
  13. package/dist/types/compaction/openai.d.ts +77 -0
  14. package/dist/types/compaction/pruning.d.ts +105 -0
  15. package/dist/types/compaction/shake.d.ts +92 -0
  16. package/dist/types/compaction/tool-protection.d.ts +17 -0
  17. package/dist/types/compaction/utils.d.ts +58 -0
  18. package/dist/types/compaction.d.ts +1 -0
  19. package/dist/types/index.d.ts +12 -0
  20. package/dist/types/proxy.d.ts +85 -0
  21. package/dist/types/replay-policy.d.ts +5 -0
  22. package/dist/types/run-collector.d.ts +196 -0
  23. package/dist/types/telemetry.d.ts +590 -0
  24. package/dist/types/thinking.d.ts +17 -0
  25. package/dist/types/tokenizer.d.ts +1 -0
  26. package/dist/types/types.d.ts +640 -0
  27. package/dist/types/utils/yield.d.ts +71 -0
  28. package/package.json +78 -0
  29. package/src/agent-loop.ts +2188 -0
  30. package/src/agent.ts +1457 -0
  31. package/src/append-only-context.ts +348 -0
  32. package/src/compaction/branch-summarization.ts +370 -0
  33. package/src/compaction/compaction-v2-streaming.ts +719 -0
  34. package/src/compaction/compaction.ts +1553 -0
  35. package/src/compaction/entries.ts +142 -0
  36. package/src/compaction/errors.ts +31 -0
  37. package/src/compaction/index.ts +13 -0
  38. package/src/compaction/messages.ts +237 -0
  39. package/src/compaction/openai.ts +581 -0
  40. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  41. package/src/compaction/prompts/branch-summary-context.md +5 -0
  42. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  43. package/src/compaction/prompts/branch-summary.md +30 -0
  44. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  45. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  46. package/src/compaction/prompts/compaction-summary.md +38 -0
  47. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  48. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  49. package/src/compaction/prompts/file-operations.md +5 -0
  50. package/src/compaction/prompts/handoff-document.md +49 -0
  51. package/src/compaction/prompts/snapcompact-archive-context.md +3 -0
  52. package/src/compaction/prompts/summarization-system.md +3 -0
  53. package/src/compaction/pruning.ts +424 -0
  54. package/src/compaction/shake.ts +429 -0
  55. package/src/compaction/tool-protection.ts +55 -0
  56. package/src/compaction/utils.ts +323 -0
  57. package/src/compaction.ts +1 -0
  58. package/src/index.ts +24 -0
  59. package/src/proxy.ts +376 -0
  60. package/src/replay-policy.ts +13 -0
  61. package/src/run-collector.ts +631 -0
  62. package/src/telemetry.ts +2034 -0
  63. package/src/thinking.ts +19 -0
  64. package/src/tokenizer.ts +17 -0
  65. package/src/types.ts +718 -0
  66. package/src/utils/yield.ts +183 -0
@@ -0,0 +1,17 @@
1
+ This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
2
+
3
+ You MUST summarize the prefix to provide context for the retained suffix:
4
+
5
+ ## Original Request
6
+
7
+ [What did the user ask for in this turn?]
8
+
9
+ ## Early Progress
10
+ - [Key decisions and work done in the prefix]
11
+
12
+ ## Context for Suffix
13
+ - [Information needed to understand the retained recent work]
14
+
15
+ You MUST output only the structured summary. You NEVER include extra text.
16
+
17
+ You MUST be concise. You MUST preserve exact file paths, function names, error messages, and relevant tool outputs or command results if they appear. You MUST focus on what's needed to understand the kept suffix.
@@ -0,0 +1,45 @@
1
+ You MUST incorporate the new messages above into the existing handoff summary in <previous-summary> tags, used by another LLM to resume the task.
2
+ RULES:
3
+ - MUST preserve all information from the previous summary
4
+ - MUST add new progress, decisions, and context from new messages
5
+ - MUST update Progress: move items from "In Progress" to "Done" when completed
6
+ - MUST update "Next Steps" based on what was accomplished
7
+ - MUST preserve exact file paths, function names, and error messages
8
+ - You MAY remove anything no longer relevant
9
+
10
+ IMPORTANT: If the new messages end with an unanswered question or request to the user, you MUST add it to Critical Context (replacing any previous pending question if answered).
11
+
12
+ You MUST use this format (omit sections if not applicable):
13
+
14
+ ## Goal
15
+ [Preserve existing goals; add new ones if task expanded]
16
+
17
+ ## Constraints & Preferences
18
+ - [Preserve existing; add new ones discovered]
19
+
20
+ ## Progress
21
+
22
+ ### Done
23
+ - [x] [Include previously done and newly completed items]
24
+
25
+ ### In Progress
26
+ - [ ] [Current work—update based on progress]
27
+
28
+ ### Blocked
29
+ - [Current blockers—remove if resolved]
30
+
31
+ ## Key Decisions
32
+ - **[Decision]**: [Brief rationale] (preserve all previous, add new)
33
+
34
+ ## Next Steps
35
+ 1. [Update based on current state]
36
+
37
+ ## Critical Context
38
+ - [Preserve important context; add new if needed]
39
+
40
+ ## Additional Notes
41
+ [Other important info not fitting above]
42
+
43
+ You MUST output only the structured summary; you NEVER include extra text.
44
+
45
+ Sections MUST be kept concise. You MUST preserve relevant tool outputs/command results. You MUST include repository state changes (branch, uncommitted changes) if mentioned.
@@ -0,0 +1,5 @@
1
+ {{#if files}}
2
+ {{#xml "files"}}
3
+ {{files}}
4
+ {{/xml}}
5
+ {{/if}}
@@ -0,0 +1,49 @@
1
+ <critical>
2
+ Write a handoff document for another instance of yourself.
3
+ The handoff MUST be sufficient for seamless continuation without access to this conversation.
4
+ Output ONLY the handoff document. No preamble, no commentary, no wrapper text.
5
+ </critical>
6
+
7
+ <instruction>
8
+ Capture exact technical state, not abstractions.
9
+ - File paths, symbol names, commands run
10
+ - Test results, observed failures
11
+ - Decisions made
12
+ - Partial work affecting the next step
13
+ </instruction>
14
+
15
+ <output>
16
+ Use exactly this structure:
17
+
18
+ ## Goal
19
+ [What the user is trying to accomplish]
20
+
21
+ ## Constraints & Preferences
22
+ - [Any constraints, preferences, or requirements mentioned]
23
+
24
+ ## Progress
25
+ ### Done
26
+ - [x] [Completed tasks with specifics]
27
+
28
+ ### In Progress
29
+ - [ ] [Current work if any]
30
+
31
+ ### Pending
32
+ - [ ] [Tasks mentioned but not started]
33
+
34
+ ## Key Decisions
35
+ - **[Decision]**: [Rationale]
36
+
37
+ ## Critical Context
38
+ - Code snippets, file paths, function/type names, error messages, data essential to continue
39
+ - Repository state if relevant
40
+
41
+ ## Next Steps
42
+ 1. [What should happen next]
43
+ </output>
44
+
45
+ {{#if additionalFocus}}
46
+ <instruction>
47
+ Additional focus: {{additionalFocus}}
48
+ </instruction>
49
+ {{/if}}
@@ -0,0 +1,3 @@
1
+ Previous snapcompact archive source text:
2
+
3
+ {{archiveText}}
@@ -0,0 +1,3 @@
1
+ Summarize conversations between users and AI coding assistants. Produce structured summaries in the exact specified format.
2
+
3
+ NEVER continue the conversation. NEVER respond to questions in it. Output ONLY the structured summary.
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Tool output pruning utilities for compaction.
3
+ */
4
+
5
+ import type { ToolResultMessage } from "jeopi-ai";
6
+ import type { AgentMessage, AgentToolCall } from "../types";
7
+ import { estimateTokens } from "./compaction";
8
+ import type { SessionEntry, SessionMessageEntry } from "./entries";
9
+ import {
10
+ collectToolCallsById,
11
+ isProtectedToolResult,
12
+ isSkillReadToolResult,
13
+ type ProtectedToolMatcher,
14
+ } from "./tool-protection";
15
+ import { splitReadSelector } from "./utils";
16
+
17
+ export interface PruneConfig {
18
+ /** Keep the most recent tool output tokens intact. */
19
+ protectTokens: number;
20
+ /** Only prune if total savings meets this threshold. */
21
+ minimumSavings: number;
22
+ /** Tool-result protection matchers. String entries protect every result from that tool; predicates may inspect the paired tool call. */
23
+ protectedTools: ProtectedToolMatcher[];
24
+ /**
25
+ * Optional supersede key function (see {@link SupersedePruneConfig.supersedeKey}).
26
+ * When provided, superseded tool results are pruned first — even inside the
27
+ * `protectTokens` window — before age-based victims. Absent, behavior is
28
+ * unchanged.
29
+ */
30
+ supersedeKey?: SupersedeKeyFn;
31
+ /** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */
32
+ pruneUseless?: boolean;
33
+ /**
34
+ * Compaction boundary: the `firstKeptEntryId` of the latest compaction on
35
+ * the branch. Entries at indices BEFORE this id are summarized away and never
36
+ * sent to the model, so mutating them only churns persisted history without
37
+ * shrinking the prompt — they are skipped. Undefined = no compaction (the
38
+ * whole branch is sent).
39
+ */
40
+ keepBoundaryId?: string;
41
+ /**
42
+ * Prompt-cache guard. When set, a tool result whose all-message suffix
43
+ * (tokens of every message after it) EXCEEDS this is part of the warm,
44
+ * already-sent cache prefix: mutating it forces the provider to re-write the
45
+ * whole suffix (cacheWrite premium). Such results — including superseded and
46
+ * useless ones, which otherwise bypass {@link protectTokens} — are left for
47
+ * compaction/shake (which rebuild the cache anyway) to reclaim. Undefined =
48
+ * no cache guard (legacy: superseded/useless prune at any depth).
49
+ */
50
+ cacheWarmSuffixTokens?: number;
51
+ }
52
+
53
+ export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
54
+ protectTokens: 40_000,
55
+ minimumSavings: 20_000,
56
+ protectedTools: ["skill", isSkillReadToolResult],
57
+ pruneUseless: true,
58
+ };
59
+
60
+ export interface PruneResult {
61
+ prunedCount: number;
62
+ tokensSaved: number;
63
+ }
64
+
65
+ /** Exact placeholder written over a superseded tool result. */
66
+ export const SUPERSEDED_NOTICE = "[Superseded by a newer read of this file]";
67
+
68
+ /** Exact placeholder written over an elided useless tool result. */
69
+ export const USELESS_NOTICE = "[Uneventful result elided]";
70
+
71
+ /**
72
+ * Maps a tool call to a supersede key. Results sharing a key form a group in
73
+ * which every result except the newest is a supersede candidate. A key `K`
74
+ * additionally supersedes keys with prefix `K + "\u0000"` (selector-free read
75
+ * supersedes selector-carrying reads of the same base path). Return
76
+ * `undefined` to exempt a call from supersede grouping.
77
+ */
78
+ export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) => string | undefined;
79
+
80
+ export interface SupersedePruneConfig {
81
+ /** Supersede key function; results sharing a key supersede older ones. */
82
+ supersedeKey?: SupersedeKeyFn;
83
+ /** Also prune results flagged useless by their tool. Default false. */
84
+ pruneUseless?: boolean;
85
+ /** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
86
+ suffixTokenLimit?: number;
87
+ /**
88
+ * Prune all candidates when the last message is at least this old: the
89
+ * provider prompt cache is then cold, so re-writing it is free. MUST exceed
90
+ * the cache retention (Anthropic "long" = 1h) or a still-warm prefix is busted
91
+ * by the flush. Default 30 min — callers on long retention override it.
92
+ */
93
+ idleFlushMs?: number;
94
+ /** Clock override for tests. */
95
+ now?: number;
96
+ /**
97
+ * Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries
98
+ * before it are summarized away and never sent, so they are skipped in every
99
+ * path — including the idle flush — to avoid pointless history churn.
100
+ * Undefined = no compaction (the whole branch is sent).
101
+ */
102
+ keepBoundaryId?: string;
103
+ /** Tool-result protection matchers (same contract as {@link PruneConfig.protectedTools}). */
104
+ protectedTools: ProtectedToolMatcher[];
105
+ }
106
+
107
+ const DEFAULT_SUFFIX_TOKEN_LIMIT = 8_000;
108
+ const DEFAULT_IDLE_FLUSH_MS = 30 * 60_000;
109
+
110
+ function createPrunedNotice(tokens: number): string {
111
+ return `[Output truncated - ${tokens} tokens]`;
112
+ }
113
+
114
+ /**
115
+ * Generic age-based pruning floor. Below this, blanking a result to
116
+ * `[Output truncated - N tokens]` recovers nothing — the placeholder itself
117
+ * costs ~8 tokens, so a sub-floor result grows the context (and churns the
118
+ * prompt cache) instead of shrinking it. Superseded/useless results keep their
119
+ * own rules: useless already drops no-savings candidates, superseded prunes for
120
+ * correctness regardless of size.
121
+ */
122
+ const MIN_PRUNE_TOKENS = 50;
123
+
124
+ function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
125
+ if (entry.type !== "message") return undefined;
126
+ const message = entry.message as AgentMessage;
127
+ if (message.role !== "toolResult") return undefined;
128
+ return message as ToolResultMessage;
129
+ }
130
+
131
+ function estimatePrunedSavings(tokens: number, notice: string): number {
132
+ const noticeTokens = Math.ceil(notice.length / 4);
133
+ return Math.max(0, tokens - noticeTokens);
134
+ }
135
+
136
+ /**
137
+ * For each entry index, the estimated token total of all *message* entries
138
+ * strictly after it — how much prompt-cache content the provider must re-write
139
+ * (cacheWrite premium) if that entry is mutated in place. Used to keep prune
140
+ * mutations inside the cheap-to-recache tail.
141
+ */
142
+ function computeMessageSuffixTokens(entries: readonly SessionEntry[]): number[] {
143
+ const suffix = new Array<number>(entries.length);
144
+ let accumulated = 0;
145
+ for (let i = entries.length - 1; i >= 0; i--) {
146
+ suffix[i] = accumulated;
147
+ const entry = entries[i];
148
+ if (entry.type === "message") accumulated += estimateTokens(entry.message as AgentMessage);
149
+ }
150
+ return suffix;
151
+ }
152
+
153
+ /**
154
+ * Resolve the array index of the compaction boundary (`keepBoundaryId`). Entries
155
+ * before this index are summarized away by the latest compaction and never sent,
156
+ * so prune passes must not mutate them. Returns 0 when there is no boundary (no
157
+ * compaction → whole branch is sent) or the id is absent from `entries`.
158
+ */
159
+ function resolveBoundaryIndex(entries: readonly SessionEntry[], keepBoundaryId: string | undefined): number {
160
+ if (keepBoundaryId === undefined) return 0;
161
+ const index = entries.findIndex(entry => entry.id === keepBoundaryId);
162
+ return index < 0 ? 0 : index;
163
+ }
164
+
165
+ interface SupersedeCandidate {
166
+ entry: SessionMessageEntry;
167
+ message: ToolResultMessage;
168
+ /** Index of the entry within the `entries` array. */
169
+ index: number;
170
+ tokens: number;
171
+ /** Placeholder text written over the blanked result. */
172
+ notice: string;
173
+ }
174
+
175
+ /**
176
+ * Collect superseded tool results: for every unpruned, unprotected tool result
177
+ * whose paired call resolves a supersede key, a LATER result with the same key
178
+ * — or with a key that is the `"\u0000"`-prefix parent of this one — marks it
179
+ * superseded. Returned in message order.
180
+ */
181
+ function collectSupersededResults(
182
+ entries: readonly SessionEntry[],
183
+ toolCallsById: ReadonlyMap<string, AgentToolCall>,
184
+ supersedeKey: SupersedeKeyFn,
185
+ protectedTools: readonly ProtectedToolMatcher[],
186
+ ): SupersedeCandidate[] {
187
+ const candidates: SupersedeCandidate[] = [];
188
+ const seenKeys = new Set<string>();
189
+ for (let i = entries.length - 1; i >= 0; i--) {
190
+ const entry = entries[i];
191
+ const message = getToolResultMessage(entry);
192
+ if (!message || message.prunedAt !== undefined) continue;
193
+ const toolCall = toolCallsById.get(message.toolCallId);
194
+ if (!toolCall) continue;
195
+ if (isProtectedToolResult(message, toolCall, protectedTools)) continue;
196
+ const key = supersedeKey(toolCall.name, toolCall.arguments as Record<string, unknown>);
197
+ if (key === undefined) continue;
198
+ const separator = key.indexOf("\u0000");
199
+ const superseded = seenKeys.has(key) || (separator >= 0 && seenKeys.has(key.slice(0, separator)));
200
+ seenKeys.add(key);
201
+ if (!superseded) continue;
202
+ candidates.push({
203
+ entry: entry as SessionMessageEntry,
204
+ message,
205
+ index: i,
206
+ tokens: estimateTokens(message as AgentMessage),
207
+ notice: SUPERSEDED_NOTICE,
208
+ });
209
+ }
210
+ return candidates.reverse();
211
+ }
212
+
213
+ /**
214
+ * Collect tool results their tool flagged contextually useless (zero matches,
215
+ * elapsed wait): unpruned, non-error, unprotected, not in `exclude`, and large
216
+ * enough that blanking to {@link USELESS_NOTICE} actually saves tokens.
217
+ * Returned in message order.
218
+ */
219
+ function collectUselessResults(
220
+ entries: readonly SessionEntry[],
221
+ toolCallsById: ReadonlyMap<string, AgentToolCall>,
222
+ protectedTools: readonly ProtectedToolMatcher[],
223
+ exclude: ReadonlySet<ToolResultMessage>,
224
+ ): SupersedeCandidate[] {
225
+ const candidates: SupersedeCandidate[] = [];
226
+ for (let i = 0; i < entries.length; i++) {
227
+ const entry = entries[i];
228
+ const message = getToolResultMessage(entry);
229
+ if (message?.useless !== true || message.prunedAt !== undefined || message.isError === true) continue;
230
+ if (exclude.has(message)) continue;
231
+ if (isProtectedToolResult(message, toolCallsById.get(message.toolCallId), protectedTools)) continue;
232
+ const tokens = estimateTokens(message as AgentMessage);
233
+ if (estimatePrunedSavings(tokens, USELESS_NOTICE) <= 0) continue;
234
+ candidates.push({ entry: entry as SessionMessageEntry, message, index: i, tokens, notice: USELESS_NOTICE });
235
+ }
236
+ return candidates;
237
+ }
238
+
239
+ /**
240
+ * Prune superseded tool results (e.g. stale `read` outputs replaced by a newer
241
+ * read of the same file) and, when `pruneUseless` is set, results their tool
242
+ * flagged contextually useless. Cheap, incremental, and prompt-cache-aware: a
243
+ * candidate is pruned now only when the suffix after it is small (tail case —
244
+ * the read→edit→read loop) or when the context has been idle long enough that
245
+ * the provider cache is cold anyway (then all still-sent candidates flush).
246
+ * Never mutates entries before `keepBoundaryId` (summarized away — not sent).
247
+ */
248
+ export function pruneSupersededToolResults(entries: SessionEntry[], config: SupersedePruneConfig): PruneResult {
249
+ const toolCallsById = collectToolCallsById(entries);
250
+ const candidates = config.supersedeKey
251
+ ? collectSupersededResults(entries, toolCallsById, config.supersedeKey, config.protectedTools)
252
+ : [];
253
+ if (config.pruneUseless) {
254
+ const exclude = new Set(candidates.map(candidate => candidate.message));
255
+ candidates.push(...collectUselessResults(entries, toolCallsById, config.protectedTools, exclude));
256
+ candidates.sort((a, b) => a.index - b.index);
257
+ }
258
+ if (candidates.length === 0) return { prunedCount: 0, tokensSaved: 0 };
259
+
260
+ const now = config.now ?? Date.now();
261
+ let lastMessageTimestamp: number | undefined;
262
+ for (let i = entries.length - 1; i >= 0; i--) {
263
+ const entry = entries[i];
264
+ if (entry.type !== "message") continue;
265
+ const timestamp = (entry.message as AgentMessage).timestamp;
266
+ if (typeof timestamp === "number") lastMessageTimestamp = timestamp;
267
+ break;
268
+ }
269
+ const idle =
270
+ lastMessageTimestamp !== undefined && now - lastMessageTimestamp >= (config.idleFlushMs ?? DEFAULT_IDLE_FLUSH_MS);
271
+
272
+ const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
273
+
274
+ let toPrune: SupersedeCandidate[];
275
+ if (idle) {
276
+ // Provider cache is cold (idle exceeds the retention TTL), so re-writing
277
+ // the sent region costs nothing. Entries before the compaction boundary
278
+ // are summarized away and never sent — skip them to avoid pointless churn.
279
+ toPrune = candidates.filter(candidate => candidate.index >= boundaryIndex);
280
+ } else {
281
+ const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT;
282
+ // suffixTokens[i] = estimated tokens of all messages strictly after entry i.
283
+ // Mutating a candidate re-writes its suffix in the warm cache, so prune only
284
+ // when that suffix is small (cheap-to-recache tail) and the candidate sits
285
+ // at/after the compaction boundary.
286
+ const suffixTokens = computeMessageSuffixTokens(entries);
287
+ toPrune = candidates.filter(
288
+ candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit,
289
+ );
290
+ }
291
+ if (toPrune.length === 0) return { prunedCount: 0, tokensSaved: 0 };
292
+
293
+ const prunedAt = Date.now();
294
+ let tokensSaved = 0;
295
+ for (const candidate of toPrune) {
296
+ candidate.message.content = [{ type: "text", text: candidate.notice }];
297
+ candidate.message.prunedAt = prunedAt;
298
+ tokensSaved += estimatePrunedSavings(candidate.tokens, candidate.notice);
299
+ }
300
+ return { prunedCount: toPrune.length, tokensSaved };
301
+ }
302
+
303
+ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
304
+ let accumulatedTokens = 0;
305
+ let tokensSaved = 0;
306
+ let prunedCount = 0;
307
+
308
+ const candidates: Array<{ entry: SessionMessageEntry; tokens: number; superseded: boolean; useless: boolean }> = [];
309
+ const toolCallsById = collectToolCallsById(entries);
310
+ const supersededMessages = config.supersedeKey
311
+ ? new Set(
312
+ collectSupersededResults(entries, toolCallsById, config.supersedeKey, config.protectedTools).map(
313
+ candidate => candidate.message,
314
+ ),
315
+ )
316
+ : undefined;
317
+ const uselessMessages =
318
+ config.pruneUseless !== false
319
+ ? new Set(
320
+ collectUselessResults(
321
+ entries,
322
+ toolCallsById,
323
+ config.protectedTools,
324
+ supersededMessages ?? new Set(),
325
+ ).map(candidate => candidate.message),
326
+ )
327
+ : undefined;
328
+
329
+ const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
330
+ const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens;
331
+ // All-message suffix per index, only when the cache guard is armed.
332
+ const messageSuffix = cacheWarmSuffixTokens === undefined ? undefined : computeMessageSuffixTokens(entries);
333
+
334
+ for (let i = entries.length - 1; i >= 0; i--) {
335
+ const entry = entries[i];
336
+ const message = getToolResultMessage(entry);
337
+ if (!message) continue;
338
+
339
+ const tokens = estimateTokens(message as AgentMessage);
340
+ const isProtected = isProtectedToolResult(message, toolCallsById.get(message.toolCallId), config.protectedTools);
341
+
342
+ if (message.prunedAt !== undefined) {
343
+ accumulatedTokens += tokens;
344
+ continue;
345
+ }
346
+
347
+ // Prompt-cache guard: a result whose all-message suffix exceeds the
348
+ // warm-cache window sits in the already-sent cached prefix — mutating it
349
+ // re-writes the whole suffix (cacheWrite premium). Entries before the
350
+ // compaction boundary are summarized away (never sent). Both are skipped
351
+ // before any prune decision, so superseded/useless cannot reach a deep,
352
+ // still-cached copy; compaction/shake reclaim those when they rebuild.
353
+ const inWarmPrefix =
354
+ messageSuffix !== undefined && cacheWarmSuffixTokens !== undefined && messageSuffix[i] > cacheWarmSuffixTokens;
355
+ if (inWarmPrefix || i < boundaryIndex) {
356
+ accumulatedTokens += tokens;
357
+ continue;
358
+ }
359
+
360
+ // Superseded and useless results bypass the age-based protect window
361
+ // (a stale re-read copy, or a result the tool flagged as uninformative,
362
+ // is dead weight at any age) — but only within the cache-warm tail: the
363
+ // guard above already excluded deeper, still-cached copies.
364
+ const superseded = supersededMessages?.has(message) ?? false;
365
+ const useless = uselessMessages?.has(message) ?? false;
366
+ const tooSmall = tokens < MIN_PRUNE_TOKENS;
367
+ if (!superseded && !useless && (accumulatedTokens < config.protectTokens || isProtected || tooSmall)) {
368
+ accumulatedTokens += tokens;
369
+ continue;
370
+ }
371
+
372
+ candidates.push({ entry: entry as SessionMessageEntry, tokens, superseded, useless });
373
+ accumulatedTokens += tokens;
374
+ }
375
+
376
+ for (const candidate of candidates) {
377
+ tokensSaved += estimatePrunedSavings(
378
+ candidate.tokens,
379
+ candidate.superseded
380
+ ? SUPERSEDED_NOTICE
381
+ : candidate.useless
382
+ ? USELESS_NOTICE
383
+ : createPrunedNotice(candidate.tokens),
384
+ );
385
+ }
386
+
387
+ if (tokensSaved < config.minimumSavings || candidates.length === 0) {
388
+ return { prunedCount: 0, tokensSaved: 0 };
389
+ }
390
+
391
+ const prunedAt = Date.now();
392
+ for (const candidate of candidates) {
393
+ const message = candidate.entry.message as ToolResultMessage;
394
+ const notice = candidate.superseded
395
+ ? SUPERSEDED_NOTICE
396
+ : candidate.useless
397
+ ? USELESS_NOTICE
398
+ : createPrunedNotice(candidate.tokens);
399
+ message.content = [{ type: "text", text: notice }];
400
+ message.prunedAt = prunedAt;
401
+ prunedCount++;
402
+ }
403
+
404
+ return { prunedCount, tokensSaved };
405
+ }
406
+
407
+ /**
408
+ * Supersede key for the `read` tool: the file path with the trailing line/raw
409
+ * selector stripped (the read tool's own splitter grammar via
410
+ * {@link splitReadSelector}, e.g. `src/foo.ts:50-200`, `:2-4:raw`).
411
+ * Internal/URL-scheme paths (`skill://…`, `https://…`) are exempt.
412
+ * Selector-free reads key on the bare path; selector-carrying reads key on
413
+ * `path + "\u0000" + selector`, so two reads collide only when the newer is
414
+ * selector-free or the selectors are identical (the pass's prefix rule lets a
415
+ * bare-path read supersede selector-carrying reads of the same file).
416
+ */
417
+ export function readToolSupersedeKey(toolName: string, args: Record<string, unknown>): string | undefined {
418
+ if (toolName !== "read") return undefined;
419
+ const path = args.path;
420
+ if (typeof path !== "string" || path.length === 0) return undefined;
421
+ if (path.includes("://")) return undefined;
422
+ const { path: base, sel } = splitReadSelector(path);
423
+ return sel === undefined ? base : `${base}\u0000${sel}`;
424
+ }