@vib-rato/agent-core 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +852 -0
  2. package/README.md +493 -0
  3. package/dist/types/agent-loop.d.ts +229 -0
  4. package/dist/types/agent.d.ts +533 -0
  5. package/dist/types/append-only-context.d.ts +141 -0
  6. package/dist/types/attempt-scope.d.ts +84 -0
  7. package/dist/types/compaction/adaptive.d.ts +31 -0
  8. package/dist/types/compaction/branch-summarization.d.ts +103 -0
  9. package/dist/types/compaction/compaction.d.ts +330 -0
  10. package/dist/types/compaction/entries.d.ts +124 -0
  11. package/dist/types/compaction/errors.d.ts +26 -0
  12. package/dist/types/compaction/index.d.ts +12 -0
  13. package/dist/types/compaction/messages.d.ts +61 -0
  14. package/dist/types/compaction/openai.d.ts +65 -0
  15. package/dist/types/compaction/pruning.d.ts +130 -0
  16. package/dist/types/compaction/utils.d.ts +32 -0
  17. package/dist/types/compaction.d.ts +1 -0
  18. package/dist/types/harmony-leak.d.ts +100 -0
  19. package/dist/types/heap-eviction-retainers.test.d.ts +1 -0
  20. package/dist/types/image-placeholder-guard.d.ts +4 -0
  21. package/dist/types/index.d.ts +13 -0
  22. package/dist/types/proxy.d.ts +95 -0
  23. package/dist/types/run-collector.d.ts +223 -0
  24. package/dist/types/run-resource-ledger.d.ts +2 -0
  25. package/dist/types/telemetry.d.ts +605 -0
  26. package/dist/types/thinking.d.ts +18 -0
  27. package/dist/types/tool-dispatch-identity.d.ts +27 -0
  28. package/dist/types/types.d.ts +790 -0
  29. package/package.json +72 -0
  30. package/src/agent-loop.ts +5632 -0
  31. package/src/agent.ts +2437 -0
  32. package/src/append-only-context.ts +496 -0
  33. package/src/attempt-scope.ts +195 -0
  34. package/src/compaction/adaptive.ts +92 -0
  35. package/src/compaction/branch-summarization.ts +358 -0
  36. package/src/compaction/compaction.ts +1569 -0
  37. package/src/compaction/entries.ts +158 -0
  38. package/src/compaction/errors.ts +31 -0
  39. package/src/compaction/index.ts +13 -0
  40. package/src/compaction/messages.ts +212 -0
  41. package/src/compaction/openai.ts +580 -0
  42. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  43. package/src/compaction/prompts/branch-summary-context.md +5 -0
  44. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  45. package/src/compaction/prompts/branch-summary.md +30 -0
  46. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  47. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  48. package/src/compaction/prompts/compaction-summary.md +38 -0
  49. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  50. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  51. package/src/compaction/prompts/file-operations.md +10 -0
  52. package/src/compaction/prompts/handoff-document.md +56 -0
  53. package/src/compaction/prompts/summarization-system.md +3 -0
  54. package/src/compaction/pruning.ts +1026 -0
  55. package/src/compaction/utils.ts +189 -0
  56. package/src/compaction.ts +1 -0
  57. package/src/harmony-leak.ts +457 -0
  58. package/src/heap-eviction-retainers.test.ts +293 -0
  59. package/src/image-placeholder-guard.ts +20 -0
  60. package/src/index.ts +23 -0
  61. package/src/prompts/escaped-nonascii-recovery.md +3 -0
  62. package/src/prompts/repeated-tool-failure-recovery.md +1 -0
  63. package/src/proxy.ts +408 -0
  64. package/src/run-collector.ts +728 -0
  65. package/src/run-resource-ledger.ts +345 -0
  66. package/src/telemetry.ts +2161 -0
  67. package/src/thinking.ts +20 -0
  68. package/src/tool-dispatch-identity.ts +87 -0
  69. package/src/types.ts +882 -0
@@ -0,0 +1,1026 @@
1
+ /**
2
+ * Tool output pruning utilities for compaction.
3
+ *
4
+ * Candidate selection is staleness-aware: tool results that have been
5
+ * superseded by a later result for the same target (same file read again,
6
+ * same search re-run) or invalidated by a later successful edit/write to a
7
+ * covered file are pruned in preference to merely-old results. Protect-window
8
+ * and minimum-savings hysteresis semantics are unchanged.
9
+ */
10
+
11
+ import { createHash } from "node:crypto";
12
+ import type { ToolCall, ToolResultMessage } from "@vib-rato/ai";
13
+ import { sanitizeText } from "@vib-rato/utils";
14
+ import type { AgentMessage } from "../types";
15
+ import { estimateEntryTokens, estimateTextTokensHeuristic } from "./compaction";
16
+ import type { SessionEntry, SessionMessageEntry } from "./entries";
17
+
18
+ export interface PruneConfig {
19
+ /** Keep the most recent tool output tokens intact. */
20
+ protectTokens: number;
21
+ /** Only prune if total savings meets this threshold. */
22
+ minimumSavings: number;
23
+ /** Tool names that should never be pruned. */
24
+ protectedTools: string[];
25
+ /** Number of newest user turns whose tool outputs must remain intact. Defaults to 2. */
26
+ protectRecentTurns?: number;
27
+ /**
28
+ * Tools in `protectedTools` whose protection is waived once the result is
29
+ * superseded (a later result for the same target, or a later successful
30
+ * edit/write to the covered file). The most recent result per target is
31
+ * never considered superseded. Optional; defaults to none.
32
+ */
33
+ staleOverridableTools?: string[];
34
+ }
35
+
36
+ export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
37
+ protectTokens: 40_000,
38
+ minimumSavings: 20_000,
39
+ protectedTools: ["skill", "read"],
40
+ protectRecentTurns: 2,
41
+ staleOverridableTools: ["read"],
42
+ };
43
+
44
+ export interface ToolOutputPruneDigest {
45
+ entryId: string;
46
+ sha256: string;
47
+ bytes: number;
48
+ }
49
+
50
+ export interface ToolOutputPruneReplacement {
51
+ entryId: string;
52
+ replacementText: string;
53
+ /** Text-only results are the only entries safe to evict to an artifact. */
54
+ complete: boolean;
55
+ tokens: number;
56
+ }
57
+
58
+ export interface ToolOutputPrunePlan {
59
+ prunedCount: number;
60
+ tokensSaved: number;
61
+ /** Digest-only identity records; no original output text is retained. */
62
+ digests: readonly ToolOutputPruneDigest[];
63
+ /** Immutable replacement proposals keyed by entry id. */
64
+ replacements: readonly ToolOutputPruneReplacement[];
65
+ }
66
+
67
+ export interface ToolOutputPruneEvictionHandle {
68
+ v: 1;
69
+ artifactId: string;
70
+ uri: string;
71
+ encoding: "utf-8";
72
+ bytes: number;
73
+ sha256: string;
74
+ complete: true;
75
+ }
76
+
77
+ export interface ToolOutputPruneCommitReplacement {
78
+ replacementText?: string;
79
+ eviction?: ToolOutputPruneEvictionHandle;
80
+ }
81
+
82
+ export interface ToolOutputPruneCommitOptions {
83
+ replacements?: ReadonlyMap<string, ToolOutputPruneCommitReplacement>;
84
+ }
85
+
86
+ export type ToolOutputCommitOutcome =
87
+ | { entryId: string; outcome: "committed" }
88
+ | { entryId: string; outcome: "mismatch"; diagnostic: string }
89
+ | { entryId: string; outcome: "unavailable"; diagnostic: string };
90
+
91
+ const ERROR_DIGEST_MAX_CHARS = 240;
92
+ const TAIL_DIGEST_MAX_CHARS = 160;
93
+ const PATH_DIGEST_MAX_CHARS = 120;
94
+ /**
95
+ * Absolute budget for the assembled digest (~64 tokens). Fields are ordered
96
+ * error-first, so truncating the assembled digest drops tail/counts before it
97
+ * ever touches the error signal.
98
+ */
99
+ const DIGEST_TOTAL_MAX_CHARS = 256;
100
+
101
+ function createGenericPrunedNotice(tokens: number): string {
102
+ return `[Output truncated - ${tokens} tokens]`;
103
+ }
104
+
105
+ export function extractToolOutputText(message: ToolResultMessage): { text: string; complete: boolean } {
106
+ if (typeof message.content === "string") return { text: message.content, complete: true };
107
+ const textBlocks: string[] = [];
108
+ let complete = true;
109
+ for (const block of message.content) {
110
+ if (block.type === "text") textBlocks.push(block.text);
111
+ else complete = false;
112
+ }
113
+ return { text: textBlocks.join("\n"), complete };
114
+ }
115
+
116
+ function firstTextContent(message: ToolResultMessage): string {
117
+ return extractToolOutputText(message).text;
118
+ }
119
+
120
+ function firstErrorLine(text: string): string | undefined {
121
+ return text
122
+ .split(/\r?\n/)
123
+ .find(line => /error|failed|exception|panic/i.test(line))
124
+ ?.trim();
125
+ }
126
+
127
+ function firstNonEmptyLine(text: string): string | undefined {
128
+ return text
129
+ .split(/\r?\n/)
130
+ .find(line => line.trim().length > 0)
131
+ ?.trim();
132
+ }
133
+
134
+ function lastNonEmptyLine(text: string): string | undefined {
135
+ return text.trim().split(/\r?\n/).filter(Boolean).at(-1)?.trim();
136
+ }
137
+
138
+ function truncateField(value: string, maxLength: number): string {
139
+ if (value.length <= maxLength) return value;
140
+ if (maxLength <= 1) return "…";
141
+ return `${value.slice(0, maxLength - 1)}…`;
142
+ }
143
+
144
+ function resultPathHint(message: ToolResultMessage, call?: ToolCall): string | undefined {
145
+ return (call && toolCallPath(call)) ?? readResolvedPath(message);
146
+ }
147
+
148
+ function resultDigest(message: ToolResultMessage, call?: ToolCall): string | undefined {
149
+ const toolName = message.toolName.toLowerCase();
150
+ const text = sanitizeText(firstTextContent(message));
151
+ const error = firstErrorLine(text);
152
+ const path = resultPathHint(message, call);
153
+ const pathPart = path ? `path=${truncateField(path, PATH_DIGEST_MAX_CHARS)}` : undefined;
154
+ if (toolName === "bash") {
155
+ const details = message as { details?: { exitCode?: unknown } };
156
+ const exitCode =
157
+ typeof details.details?.exitCode === "number" ? details.details.exitCode : message.isError ? 1 : 0;
158
+ const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
159
+ return [
160
+ `exit=${exitCode}`,
161
+ error ? `error=${truncateField(error, ERROR_DIGEST_MAX_CHARS)}` : undefined,
162
+ pathPart,
163
+ tail ? `tail=${truncateField(tail, TAIL_DIGEST_MAX_CHARS)}` : undefined,
164
+ ]
165
+ .filter((part): part is string => part !== undefined)
166
+ .join("; ");
167
+ }
168
+ if (toolName === "search" || toolName === "grep") {
169
+ const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i);
170
+ const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i);
171
+ return (
172
+ [
173
+ error ? `error=${truncateField(error, ERROR_DIGEST_MAX_CHARS)}` : undefined,
174
+ pathPart,
175
+ match ? `matches=${match[1]}` : undefined,
176
+ files ? `files=${files[1]}` : undefined,
177
+ ]
178
+ .filter((part): part is string => part !== undefined)
179
+ .join("; ") || "search digest unavailable"
180
+ );
181
+ }
182
+ if (message.isError !== true) return undefined;
183
+ if (text.trim().length === 0) return "error=tool result failed without text";
184
+ if (error) return [`error=${truncateField(error, ERROR_DIGEST_MAX_CHARS)}`, pathPart].filter(Boolean).join("; ");
185
+ const summary = firstNonEmptyLine(text) ?? lastNonEmptyLine(text);
186
+ return summary ? `summary=${truncateField(summary, ERROR_DIGEST_MAX_CHARS)}` : undefined;
187
+ }
188
+
189
+ export function createPrunedNotice(
190
+ tokens: number,
191
+ message?: ToolResultMessage,
192
+ call?: ToolCall,
193
+ artifact?: string,
194
+ ): string {
195
+ const generic = createGenericPrunedNotice(tokens);
196
+ const digest =
197
+ truncateField(message ? (resultDigest(message, call) ?? "") : "", DIGEST_TOTAL_MAX_CHARS) || undefined;
198
+ if (!digest && !artifact) return generic;
199
+ if (artifact) {
200
+ return `[Output truncated - ${tokens} tokens; full output: ${artifact}]${digest ? ` ${digest}` : ""}`;
201
+ }
202
+ return `[Output truncated - ${tokens} tokens; ${digest}]`;
203
+ }
204
+
205
+ function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
206
+ if (entry.type !== "message") return undefined;
207
+ const message = entry.message as AgentMessage;
208
+ if (message.role !== "toolResult") return undefined;
209
+ return message as ToolResultMessage;
210
+ }
211
+
212
+ function estimatePrunedSavings(tokens: number, notice: string): number {
213
+ return tokens - estimateTextTokensHeuristic(notice);
214
+ }
215
+
216
+ export interface AssistantArgumentPruneResult {
217
+ argumentPrunedCount: number;
218
+ argumentTokensSaved: number;
219
+ /**
220
+ * The mutated assistant message entries. Callers whose entry source returns
221
+ * materialized copies must write these back into their canonical store by id.
222
+ */
223
+ prunedEntries: SessionMessageEntry[];
224
+ }
225
+
226
+ interface PrunedToolArgumentsSentinel {
227
+ pruned: true;
228
+ reason: "stale_tool_arguments";
229
+ pathHints: string[];
230
+ originalChars: number;
231
+ prunedAt: number;
232
+ }
233
+
234
+ const EDIT_TOOL_NAMES = new Set(["edit", "write", "apply_patch", "ast_edit"]);
235
+
236
+ /**
237
+ * A tool call's arguments, or `undefined` when the persisted payload is not an
238
+ * object.
239
+ *
240
+ * `ToolCall.arguments` is typed non-nullable, but sessions written by an older
241
+ * cold-spill eviction path carry `arguments: null` where the spill sentinel
242
+ * should be. Reading `.path` off that null threw a TypeError that surfaced as
243
+ * `null is not an object (evaluating 'args.path')` and killed the turn, so
244
+ * every reader of persisted arguments must treat them as untrusted.
245
+ */
246
+ function toolArguments(call: ToolCall): Record<string, unknown> | undefined {
247
+ const args = call.arguments;
248
+ return typeof args === "object" && args !== null ? args : undefined;
249
+ }
250
+
251
+ /** Extract the file-path argument from a tool call, when the tool has one. */
252
+ function toolCallPath(call: ToolCall): string | undefined {
253
+ const args = toolArguments(call);
254
+ if (!args) return undefined;
255
+ const path = args.path ?? args.file_path ?? args.filePath;
256
+ return typeof path === "string" && path.length > 0 ? path : undefined;
257
+ }
258
+
259
+ /**
260
+ * `*** Add|Update|Delete File: <path>` headers open a hunk; `*** Move to:
261
+ * <path>` attaches a rename destination to the current hunk. Move
262
+ * destinations count as touched paths: a rename onto a file invalidates
263
+ * earlier reads of that destination.
264
+ */
265
+ const APPLY_PATCH_HEADER = /^\*\*\* (?:((?:Add|Update|Delete) File)|(Move to)): (.+)$/gm;
266
+
267
+ /**
268
+ * Paths touched by an edit-class tool call, grouped per hunk so a failed
269
+ * hunk can be excluded wholesale (its rename destination included). Most
270
+ * edit tools carry a single path argument; apply_patch envelopes carry an
271
+ * `input` string with per-file headers instead. The envelope shape can
272
+ * arrive under the custom `apply_patch` tool OR the regular `edit` tool
273
+ * (providers without custom-tool support fall back to the JSON function), so
274
+ * any edit-class call with a string `input` is parsed for headers.
275
+ */
276
+ function editToolPathGroups(call: ToolCall): string[][] {
277
+ const path = toolCallPath(call);
278
+ if (path !== undefined) return [[path]];
279
+ const input = toolArguments(call)?.input;
280
+ if (typeof input !== "string") return [];
281
+ const groups: string[][] = [];
282
+ for (const match of input.matchAll(APPLY_PATCH_HEADER)) {
283
+ const headerPath = match[3]?.trim();
284
+ if (!headerPath) continue;
285
+ const isMoveTo = match[2] !== undefined;
286
+ if (isMoveTo && groups.length > 0) {
287
+ groups[groups.length - 1].push(headerPath);
288
+ } else {
289
+ groups.push([headerPath]);
290
+ }
291
+ }
292
+ return groups;
293
+ }
294
+ function pathGroupKey(group: string[]): string {
295
+ return JSON.stringify([...group].sort());
296
+ }
297
+
298
+ function pathHintsForGroups(groups: string[][]): string[] {
299
+ return [...new Set(groups.flat())].sort();
300
+ }
301
+
302
+ function isPrunedToolArgumentsSentinel(value: unknown): value is PrunedToolArgumentsSentinel {
303
+ return (
304
+ typeof value === "object" &&
305
+ value !== null &&
306
+ (value as { pruned?: unknown; reason?: unknown }).pruned === true &&
307
+ (value as { pruned?: unknown; reason?: unknown }).reason === "stale_tool_arguments"
308
+ );
309
+ }
310
+
311
+ function isEditToolCall(call: ToolCall): boolean {
312
+ return EDIT_TOOL_NAMES.has(call.name) || call.customWireName === "apply_patch";
313
+ }
314
+
315
+ interface AssistantArgumentStalenessIndex {
316
+ latestSuccessfulMutationByPathGroup: Map<string, { index: number; callId: string }>;
317
+ failedCallIds: Set<string>;
318
+ }
319
+
320
+ function buildAssistantArgumentStalenessIndex(entries: SessionEntry[]): AssistantArgumentStalenessIndex {
321
+ const callsById = new Map<string, ToolCall>();
322
+ for (const entry of entries) {
323
+ if (entry.type !== "message") continue;
324
+ const message = entry.message as AgentMessage;
325
+ if (message.role !== "assistant") continue;
326
+ for (const content of message.content) {
327
+ if (content.type === "toolCall") callsById.set(content.id, content);
328
+ }
329
+ }
330
+
331
+ const latestSuccessfulMutationByPathGroup = new Map<string, { index: number; callId: string }>();
332
+ const failedCallIds = new Set<string>();
333
+ for (let i = 0; i < entries.length; i++) {
334
+ const message = getToolResultMessage(entries[i]);
335
+ if (!message) continue;
336
+ const call = callsById.get(message.toolCallId);
337
+ if (!call || !isEditToolCall(call)) continue;
338
+ const detailFiles = call.name === "ast_edit" ? resultDetailFiles(message) : [];
339
+ const groups = detailFiles.length > 0 ? detailFiles.map(file => [file]) : editToolPathGroups(call);
340
+ if (groups.length === 0) continue;
341
+ if (message.isError) {
342
+ failedCallIds.add(call.id);
343
+ continue;
344
+ }
345
+ const successfulPaths = successfulEditPaths(message);
346
+ let mutated = false;
347
+ for (const group of groups) {
348
+ if (successfulPaths !== undefined && !group.some(groupPath => successfulPaths.has(groupPath))) continue;
349
+ latestSuccessfulMutationByPathGroup.set(pathGroupKey(group), { index: i, callId: call.id });
350
+ mutated = true;
351
+ }
352
+ if (!mutated) failedCallIds.add(call.id);
353
+ }
354
+ return { latestSuccessfulMutationByPathGroup, failedCallIds };
355
+ }
356
+
357
+ /** Exact read selector grammar mirrored from the read tool without importing its package layer. */
358
+ const READ_SELECTOR_RE = /^(?:L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|raw|conflicts)$/i;
359
+ const READ_RANGE_SELECTOR_RE = /^L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*$/i;
360
+ const READ_RAW_SELECTOR_RE = /^raw$/i;
361
+
362
+ type ReadTarget = { basePath: string; selector?: string };
363
+
364
+ function splitReadTarget(path: string): ReadTarget {
365
+ const outerColon = path.lastIndexOf(":");
366
+ if (outerColon <= 0) return { basePath: path };
367
+ const outer = path.slice(outerColon + 1);
368
+ if (!READ_SELECTOR_RE.test(outer)) return { basePath: path };
369
+
370
+ let basePath = path.slice(0, outerColon);
371
+ let selector = outer;
372
+ const innerColon = basePath.lastIndexOf(":");
373
+ if (innerColon > 0) {
374
+ const inner = basePath.slice(innerColon + 1);
375
+ const compoundRawRange =
376
+ (READ_RAW_SELECTOR_RE.test(inner) && READ_RANGE_SELECTOR_RE.test(outer)) ||
377
+ (READ_RANGE_SELECTOR_RE.test(inner) && READ_RAW_SELECTOR_RE.test(outer));
378
+ if (compoundRawRange) {
379
+ selector = `${inner}:${outer}`;
380
+ basePath = basePath.slice(0, innerColon);
381
+ }
382
+ }
383
+ return { basePath, selector };
384
+ }
385
+
386
+ /** Base file path of a read target with its one valid selector stripped. */
387
+ function readBasePath(path: string): string {
388
+ return splitReadTarget(path).basePath;
389
+ }
390
+
391
+ type ReadLineRange = { start: number; end: number };
392
+
393
+ /** Parse only one explicit, provably bounded trailing read range. */
394
+ function readLineRanges(path: string): ReadLineRange[] {
395
+ const selector = splitReadTarget(path).selector;
396
+ if (!selector || /(?:^|:)raw(?:$|:)/i.test(selector) || /^conflicts$/i.test(selector)) return [];
397
+ return selector.split(",").flatMap(part => {
398
+ const range = part.match(/^L?(\d+)([-+])L?(\d+)$/i);
399
+ if (!range) return [];
400
+ const start = Number(range[1]);
401
+ const end = range[2] === "+" ? start + Number(range[3]) - 1 : Number(range[3]);
402
+ return start > 0 && end >= start ? [{ start, end }] : [];
403
+ });
404
+ }
405
+
406
+ function strictlyContainsReadRange(container: ReadLineRange, contained: ReadLineRange): boolean {
407
+ return (
408
+ container.start <= contained.start &&
409
+ container.end >= contained.end &&
410
+ (container.start < contained.start || container.end > contained.end)
411
+ );
412
+ }
413
+
414
+ function readSupersedesRead(
415
+ later: ToolCall,
416
+ earlier: ToolCall,
417
+ lineRangesByCall: ReadonlyMap<ToolCall, ReadLineRange[]>,
418
+ ): boolean {
419
+ const laterRanges = lineRangesByCall.get(later);
420
+ const earlierRanges = lineRangesByCall.get(earlier);
421
+ return (
422
+ laterRanges?.length === 1 &&
423
+ earlierRanges?.length === 1 &&
424
+ strictlyContainsReadRange(laterRanges[0], earlierRanges[0])
425
+ );
426
+ }
427
+
428
+ /**
429
+ * Stable identity for "the same logical lookup": same tool re-targeting the
430
+ * same subject. A later result with the same key supersedes earlier ones.
431
+ * Keys are canonical JSON tuples so user-controlled text (patterns, paths)
432
+ * can never collide via delimiter ambiguity. Search keys include pagination
433
+ * (`skip`) and result-shaping flags (`i`, `gitignore`): a later page or a
434
+ * differently-shaped search complements earlier output, it does not replace it.
435
+ */
436
+ const IDEMPOTENT_BASH_COMMAND =
437
+ /^(?:(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?(?:test|build)\b|git\s+status\b|cargo\s+build\b|(?:make|just)\s+build\b)/;
438
+
439
+ function normalizedIdempotentBashCommand(call: ToolCall): string | undefined {
440
+ if (call.name !== "bash") return undefined;
441
+ const args = toolArguments(call);
442
+ if (!args) return undefined;
443
+ const command = args.command;
444
+ if (typeof command !== "string") return undefined;
445
+ const normalized = command.trim().replace(/\s+/g, " ");
446
+ if (/[;&|]/.test(normalized) || !IDEMPOTENT_BASH_COMMAND.test(normalized)) return undefined;
447
+ return JSON.stringify([normalized, typeof args.cwd === "string" ? args.cwd : undefined]);
448
+ }
449
+
450
+ function toolTargetKey(call: ToolCall): string | undefined {
451
+ const path = toolCallPath(call);
452
+ if (path !== undefined) return JSON.stringify([call.name, "path", path]);
453
+ const command = normalizedIdempotentBashCommand(call);
454
+ if (command !== undefined) return JSON.stringify([call.name, "command", command]);
455
+ const args = toolArguments(call);
456
+ if (!args) return undefined;
457
+ const pattern = args.pattern;
458
+ if (typeof pattern === "string" && pattern.length > 0) {
459
+ const paths = args.paths;
460
+ const pathList = Array.isArray(paths) ? paths.filter((p): p is string => typeof p === "string") : [];
461
+ const skip = typeof args.skip === "number" ? args.skip : 0;
462
+ const caseInsensitive = args.i === true;
463
+ const gitignore = args.gitignore !== false;
464
+ return JSON.stringify([call.name, "pattern", pattern, pathList, skip, caseInsensitive, gitignore]);
465
+ }
466
+ return undefined;
467
+ }
468
+
469
+ /**
470
+ * Files actually mutated according to a tool result's details. Used for
471
+ * AST-edit-shaped results (`ast_edit` direct-apply and the hidden `resolve`
472
+ * apply step), which report `{ applied: true, files: [...] }` — the resolve
473
+ * tool nests that payload under `details.sourceResultDetails`. Conservative:
474
+ * returns nothing unless the details explicitly mark the change as applied.
475
+ * Checked even on `isError` results: a stale-preview apply reports an error
476
+ * while still having mutated the listed files.
477
+ */
478
+ function resultDetailFiles(message: ToolResultMessage): string[] {
479
+ const raw = message.details as { applied?: unknown; files?: unknown; sourceResultDetails?: unknown } | undefined;
480
+ const candidates = [raw, raw?.sourceResultDetails as { applied?: unknown; files?: unknown } | undefined];
481
+ for (const details of candidates) {
482
+ if (details?.applied === true && Array.isArray(details.files)) {
483
+ return details.files.filter((file): file is string => typeof file === "string" && file.length > 0);
484
+ }
485
+ }
486
+ return [];
487
+ }
488
+
489
+ /**
490
+ * Paths that a per-file edit result proves were mutated. Multi-file
491
+ * `apply_patch` can return a non-error envelope while individual files fail, so
492
+ * only explicit success (`isError === false`) or the normal successful result
493
+ * shape (a string `diff` with no error flag) counts. Ambiguous/malformed rows
494
+ * fail closed. If no per-file result array exists, return undefined so ordinary
495
+ * single-file successful tool results retain their established behavior.
496
+ */
497
+ function successfulEditPaths(message: ToolResultMessage): Set<string> | undefined {
498
+ const details = message.details as { perFileResults?: unknown } | undefined;
499
+ const perFile = details?.perFileResults;
500
+ if (!Array.isArray(perFile)) return undefined;
501
+ const succeeded = new Set<string>();
502
+ for (const item of perFile) {
503
+ const entry = item as { path?: unknown; isError?: unknown; diff?: unknown };
504
+ if (typeof entry?.path !== "string") continue;
505
+ if (entry.isError === false || (entry.isError === undefined && typeof entry.diff === "string")) {
506
+ succeeded.add(entry.path);
507
+ }
508
+ }
509
+ return succeeded;
510
+ }
511
+
512
+ /**
513
+ * Concrete file path a `read` result actually came from, when the tool
514
+ * reported one (`details.resolvedPath`). Suffix resolution can map a bare
515
+ * filename argument onto a different concrete path.
516
+ */
517
+ function readResolvedPath(message: ToolResultMessage): string | undefined {
518
+ const details = message.details as { resolvedPath?: unknown } | undefined;
519
+ const resolved = details?.resolvedPath;
520
+ return typeof resolved === "string" && resolved.length > 0 ? resolved : undefined;
521
+ }
522
+
523
+ interface StalenessIndex {
524
+ /** Entry indices of toolResults superseded by a later same-target result or a later edit. */
525
+ staleResultIndices: Set<number>;
526
+ }
527
+
528
+ /**
529
+ * Build a staleness index over session entries (oldest -> newest):
530
+ * - a toolResult is stale when a later non-error toolResult shares its target key;
531
+ * - a `read` result is stale when a later non-error edit/write touches its file.
532
+ * The most recent result per target is never stale.
533
+ */
534
+ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
535
+ const callsById = new Map<string, ToolCall>();
536
+ for (const entry of entries) {
537
+ if (entry.type !== "message") continue;
538
+ const message = entry.message as AgentMessage;
539
+ if (message.role !== "assistant") continue;
540
+ for (const content of message.content) {
541
+ if (content.type === "toolCall") callsById.set(content.id, content);
542
+ }
543
+ }
544
+
545
+ type ResultMeta = { key?: string; call: ToolCall; message: ToolResultMessage };
546
+ const lastResultIndexByKey = new Map<string, number>();
547
+ const resultMeta = new Map<number, ResultMeta>();
548
+ const lastEditIndexByPath = new Map<string, number>();
549
+
550
+ for (let i = 0; i < entries.length; i++) {
551
+ const message = getToolResultMessage(entries[i]);
552
+ if (!message) continue;
553
+ const call = callsById.get(message.toolCallId);
554
+ if (!call) continue;
555
+
556
+ // AST edits mutate files when previews are applied via the hidden
557
+ // `resolve` tool; the call args carry globs, not concrete paths. Both
558
+ // tools report actually-touched files in result details. Collected
559
+ // BEFORE the error gate: a stale-preview apply reports an error while
560
+ // still having mutated the listed files.
561
+ if (call.name === "resolve" || call.name === "ast_edit") {
562
+ for (const editPath of resultDetailFiles(message)) {
563
+ lastEditIndexByPath.set(editPath, i);
564
+ }
565
+ }
566
+ if (message.isError) continue;
567
+
568
+ const key = toolTargetKey(call);
569
+ resultMeta.set(i, { key, call, message });
570
+ if (key !== undefined) lastResultIndexByKey.set(key, i);
571
+ if (EDIT_TOOL_NAMES.has(call.name)) {
572
+ // Per-file edit results prove which path groups actually mutated. A
573
+ // malformed or ambiguous row cannot invalidate earlier read evidence.
574
+ const successfulPaths = successfulEditPaths(message);
575
+ for (const group of editToolPathGroups(call)) {
576
+ if (successfulPaths !== undefined && !group.some(groupPath => successfulPaths.has(groupPath))) continue;
577
+ for (const editPath of group) {
578
+ lastEditIndexByPath.set(editPath, i);
579
+ }
580
+ }
581
+ }
582
+ }
583
+
584
+ const staleResultIndices = new Set<number>();
585
+ for (const [index, meta] of resultMeta) {
586
+ if (meta.key !== undefined) {
587
+ const lastIndex = lastResultIndexByKey.get(meta.key);
588
+ if (lastIndex !== undefined && lastIndex > index) {
589
+ staleResultIndices.add(index);
590
+ continue;
591
+ }
592
+ }
593
+ if (meta.call.name === "read") {
594
+ // Check both the call argument (selectors stripped) and the resolved
595
+ // path from result details: suffix resolution can map a bare filename
596
+ // onto a different concrete path, and edits may use either form.
597
+ const lookupPaths = new Set<string>();
598
+ const argPath = toolCallPath(meta.call);
599
+ if (argPath !== undefined) lookupPaths.add(readBasePath(argPath));
600
+ const resolved = readResolvedPath(meta.message);
601
+ if (resolved !== undefined) lookupPaths.add(resolved);
602
+ for (const lookupPath of lookupPaths) {
603
+ const editIndex = lastEditIndexByPath.get(lookupPath);
604
+ if (editIndex !== undefined && editIndex > index) {
605
+ staleResultIndices.add(index);
606
+ break;
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ const readsByBasePath = new Map<string, Array<[number, ResultMeta]>>();
613
+ const lineRangesByCall = new Map<ToolCall, ReadLineRange[]>();
614
+ for (const [index, meta] of resultMeta) {
615
+ if (meta.call.name !== "read") continue;
616
+ const path = toolCallPath(meta.call);
617
+ if (!path) continue;
618
+ lineRangesByCall.set(meta.call, readLineRanges(path));
619
+ const basePath = readBasePath(path);
620
+ const group = readsByBasePath.get(basePath);
621
+ if (group) group.push([index, meta]);
622
+ else readsByBasePath.set(basePath, [[index, meta]]);
623
+ }
624
+ for (const reads of readsByBasePath.values()) {
625
+ if (reads.length < 2) continue;
626
+ for (let earlier = 0; earlier < reads.length - 1; earlier++) {
627
+ const [index, meta] = reads[earlier];
628
+ for (let later = earlier + 1; later < reads.length; later++) {
629
+ if (readSupersedesRead(reads[later][1].call, meta.call, lineRangesByCall)) {
630
+ staleResultIndices.add(index);
631
+ break;
632
+ }
633
+ }
634
+ }
635
+ }
636
+
637
+ return { staleResultIndices };
638
+ }
639
+ export function pruneAssistantToolArguments(
640
+ entries: SessionEntry[],
641
+ config: PruneConfig = DEFAULT_PRUNE_CONFIG,
642
+ ): AssistantArgumentPruneResult {
643
+ let accumulatedTokens = 0;
644
+ const { latestSuccessfulMutationByPathGroup, failedCallIds } = buildAssistantArgumentStalenessIndex(entries);
645
+ const argumentFenceStart = recentTurnFenceStart(entries, config.protectRecentTurns ?? 2);
646
+ const candidates: Array<{
647
+ entry: SessionMessageEntry;
648
+ call: ToolCall;
649
+ pathHints: string[];
650
+ originalChars: number;
651
+ }> = [];
652
+
653
+ for (let i = entries.length - 1; i >= 0; i--) {
654
+ const entry = entries[i];
655
+ if (entry.type !== "message") continue;
656
+ // Same newest-turn fence as tool-output pruning: edit/apply_patch
657
+ // arguments in the active (or otherwise protected) turn are live
658
+ // context, even when a later call in that turn superseded their path.
659
+ if (argumentFenceStart !== undefined && i >= argumentFenceStart) continue;
660
+ const message = entry.message as AgentMessage;
661
+ if (message.role !== "assistant") continue;
662
+ const entryTokens = estimateEntryTokens(entry);
663
+ const insideProtectWindow = accumulatedTokens < config.protectTokens;
664
+ accumulatedTokens += entryTokens;
665
+ for (const content of message.content) {
666
+ if (content.type !== "toolCall" || !isEditToolCall(content)) continue;
667
+ const argumentJson = JSON.stringify(content.arguments);
668
+ if (argumentJson === undefined) continue;
669
+ const originalChars = argumentJson.length;
670
+ if (isPrunedToolArgumentsSentinel(content.arguments)) continue;
671
+ if (insideProtectWindow || failedCallIds.has(content.id)) continue;
672
+ const groups = editToolPathGroups(content);
673
+ if (groups.length === 0) continue;
674
+ // Arguments are pruned as one indivisible payload, so require EVERY
675
+ // concrete path group to be stale from a later successful mutation.
676
+ // A group with no later success (failed/unknown/ambiguous) protects the
677
+ // whole call rather than dropping non-stale multi-file patch evidence.
678
+ const isStale = groups.every(group => {
679
+ const latest = latestSuccessfulMutationByPathGroup.get(pathGroupKey(group));
680
+ return latest !== undefined && latest.index > i && latest.callId !== content.id;
681
+ });
682
+ if (!isStale) continue;
683
+ candidates.push({
684
+ entry: entry as SessionMessageEntry,
685
+ call: content,
686
+ pathHints: pathHintsForGroups(groups),
687
+ originalChars,
688
+ });
689
+ }
690
+ }
691
+
692
+ if (candidates.length === 0) {
693
+ return { argumentPrunedCount: 0, argumentTokensSaved: 0, prunedEntries: [] };
694
+ }
695
+
696
+ const prunedAt = Date.now();
697
+ const candidatesByEntry = new Map<SessionMessageEntry, typeof candidates>();
698
+ for (const candidate of candidates) {
699
+ const group = candidatesByEntry.get(candidate.entry);
700
+ if (group) group.push(candidate);
701
+ else candidatesByEntry.set(candidate.entry, [candidate]);
702
+ }
703
+
704
+ let argumentTokensSaved = 0;
705
+ const admittedGroups: Array<{ entry: SessionMessageEntry; candidates: typeof candidates }> = [];
706
+ for (const [entry, entryCandidates] of candidatesByEntry) {
707
+ const candidateByCallId = new Map(entryCandidates.map(candidate => [candidate.call.id, candidate]));
708
+ const message = entry.message as AgentMessage;
709
+ if (message.role !== "assistant") continue;
710
+ const stagedEntry = {
711
+ ...entry,
712
+ message: {
713
+ ...message,
714
+ content: message.content.map(content => {
715
+ if (content.type !== "toolCall") return content;
716
+ const candidate = candidateByCallId.get(content.id);
717
+ if (!candidate) return content;
718
+ return {
719
+ ...content,
720
+ arguments: {
721
+ pruned: true,
722
+ reason: "stale_tool_arguments",
723
+ pathHints: candidate.pathHints,
724
+ originalChars: candidate.originalChars,
725
+ prunedAt,
726
+ } satisfies PrunedToolArgumentsSentinel,
727
+ };
728
+ }),
729
+ },
730
+ } as SessionMessageEntry;
731
+ const savings = Math.max(0, estimateEntryTokens(entry) - estimateEntryTokens(stagedEntry));
732
+ if (savings === 0) continue;
733
+ argumentTokensSaved += savings;
734
+ admittedGroups.push({ entry, candidates: entryCandidates });
735
+ }
736
+
737
+ if (argumentTokensSaved < config.minimumSavings || admittedGroups.length === 0) {
738
+ return { argumentPrunedCount: 0, argumentTokensSaved: 0, prunedEntries: [] };
739
+ }
740
+
741
+ let argumentPrunedCount = 0;
742
+ const prunedEntries: SessionMessageEntry[] = [];
743
+ for (const group of admittedGroups) {
744
+ for (const candidate of group.candidates) {
745
+ candidate.call.arguments = {
746
+ pruned: true,
747
+ reason: "stale_tool_arguments",
748
+ pathHints: candidate.pathHints,
749
+ originalChars: candidate.originalChars,
750
+ prunedAt,
751
+ };
752
+ argumentPrunedCount++;
753
+ }
754
+ prunedEntries.push(group.entry);
755
+ }
756
+ return { argumentPrunedCount, argumentTokensSaved, prunedEntries };
757
+ }
758
+
759
+ interface ToolOutputPruneCandidate {
760
+ entry: SessionMessageEntry;
761
+ call?: ToolCall;
762
+ tokens: number;
763
+ originalText: string;
764
+ complete: boolean;
765
+ notice: string;
766
+ savings: number;
767
+ }
768
+
769
+ function recentTurnFenceStart(entries: SessionEntry[], protectRecentTurns: number): number | undefined {
770
+ if (protectRecentTurns <= 0) return undefined;
771
+ const starts: number[] = [];
772
+ for (let i = 0; i < entries.length; i++) {
773
+ const entry = entries[i];
774
+ if (entry.type !== "message") continue;
775
+ const role = entry.message.role as string;
776
+ if (role === "user" || role === "bashExecution") starts.push(i);
777
+ }
778
+ return starts.length === 0 ? undefined : starts[Math.max(0, starts.length - protectRecentTurns)];
779
+ }
780
+
781
+ /**
782
+ * Read-only candidate collection shared by the digest-only plan and the
783
+ * non-mutating {@link estimateToolOutputPruneSavings} gate.
784
+ */
785
+ function collectToolOutputPruneCandidates(
786
+ entries: SessionEntry[],
787
+ config: PruneConfig,
788
+ ): { candidates: ToolOutputPruneCandidate[]; tokensSaved: number } {
789
+ let accumulatedTokens = 0;
790
+
791
+ const { staleResultIndices } = buildStalenessIndex(entries);
792
+ const callsById = new Map<string, ToolCall>();
793
+ for (const entry of entries) {
794
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
795
+ for (const content of entry.message.content) {
796
+ if (content.type === "toolCall") callsById.set(content.id, content);
797
+ }
798
+ }
799
+ const fenceStart = recentTurnFenceStart(entries, config.protectRecentTurns ?? 2);
800
+ const staleOverridable = new Set(config.staleOverridableTools ?? []);
801
+ const candidates: ToolOutputPruneCandidate[] = [];
802
+
803
+ for (let i = entries.length - 1; i >= 0; i--) {
804
+ const entry = entries[i];
805
+ const message = getToolResultMessage(entry);
806
+ if (!message) continue;
807
+
808
+ const tokens = estimateEntryTokens(entry);
809
+ const isStale = staleResultIndices.has(i);
810
+ // Staleness waives protected-tool immunity for overridable tools
811
+ // (e.g. a superseded `read`); the most recent result per target is
812
+ // never stale, so the latest read of each file stays protected.
813
+ const isProtected =
814
+ config.protectedTools.includes(message.toolName) && !(isStale && staleOverridable.has(message.toolName));
815
+
816
+ if (message.prunedAt !== undefined || (fenceStart !== undefined && i >= fenceStart)) {
817
+ accumulatedTokens += tokens;
818
+ continue;
819
+ }
820
+
821
+ // Stale results are prunable even inside the recency protect window —
822
+ // they are superseded, so recency no longer implies relevance. They
823
+ // still count toward window accounting so non-stale protection is
824
+ // unchanged.
825
+ const insideProtectWindow = accumulatedTokens < config.protectTokens;
826
+ if ((insideProtectWindow && !isStale) || isProtected) {
827
+ accumulatedTokens += tokens;
828
+ continue;
829
+ }
830
+
831
+ const call = callsById.get(message.toolCallId);
832
+ const captured = extractToolOutputText(message);
833
+ const notice = createPrunedNotice(tokens, message, call);
834
+ const savings = estimatePrunedSavings(tokens, notice);
835
+ const errorNoticeGrows = message.isError === true && notice.length > captured.text.length;
836
+ if (savings <= 0 || errorNoticeGrows) {
837
+ accumulatedTokens += tokens;
838
+ continue;
839
+ }
840
+ candidates.push({
841
+ entry: entry as SessionMessageEntry,
842
+ call,
843
+ tokens,
844
+ originalText: captured.text,
845
+ complete: captured.complete,
846
+ notice,
847
+ savings,
848
+ });
849
+
850
+ accumulatedTokens += tokens;
851
+ }
852
+
853
+ let tokensSaved = 0;
854
+ for (const candidate of candidates) {
855
+ tokensSaved += candidate.savings;
856
+ }
857
+ return { candidates, tokensSaved };
858
+ }
859
+
860
+ function minimumSavings(config: PruneConfig, options: PruneToolOutputsOptions = {}): number {
861
+ const relaxedMinimum = options.relaxedMinimum;
862
+ return typeof relaxedMinimum === "number" && Number.isFinite(relaxedMinimum)
863
+ ? Math.min(config.minimumSavings, Math.max(0, relaxedMinimum))
864
+ : config.minimumSavings;
865
+ }
866
+
867
+ /**
868
+ * Estimate conservative savings for a digest-only prune plan without mutating
869
+ * entries or invoking artifact publication.
870
+ */
871
+ export function estimateToolOutputPruneSavings(
872
+ entries: SessionEntry[],
873
+ config: PruneConfig = DEFAULT_PRUNE_CONFIG,
874
+ options: PruneToolOutputsOptions = {},
875
+ ): { prunableCount: number; tokensSaved: number } {
876
+ const { candidates, tokensSaved: baseTokensSaved } = collectToolOutputPruneCandidates(entries, config);
877
+ const minimum = minimumSavings(config, options);
878
+ if (baseTokensSaved < minimum || candidates.length === 0) return { prunableCount: 0, tokensSaved: 0 };
879
+ const planned = planToolOutputPruneCandidates(candidates, options);
880
+ const tokensSaved = planned.reduce((total, candidate) => total + candidate.savings, 0);
881
+ if (tokensSaved < minimum || planned.length === 0) return { prunableCount: 0, tokensSaved: 0 };
882
+ return { prunableCount: planned.length, tokensSaved };
883
+ }
884
+
885
+ /**
886
+ * Evidence gate for below-threshold maintenance pruning (Finding 13). Pruning
887
+ * forces a prompt-cache-epoch reset, so it only runs when opted in AND the
888
+ * estimated stale savings clear a high minimum AND exceed the one-time reset
889
+ * cost (so the reclaim pays the reset back). Default-off/blocked until live
890
+ * evidence justifies enabling.
891
+ */
892
+ export function shouldRunMaintenancePrune(args: {
893
+ enabled: boolean;
894
+ estimatedSavings: number;
895
+ minSavings: number;
896
+ cacheEpochResetCost: number;
897
+ }): boolean {
898
+ if (!args.enabled) return false;
899
+ if (args.estimatedSavings < args.minSavings) return false;
900
+ return args.estimatedSavings > args.cacheEpochResetCost;
901
+ }
902
+
903
+ const MAX_ARTIFACT_REF_CHARS = 16_384;
904
+
905
+ export interface PruneToolOutputsOptions {
906
+ /** Lower the usual minimum only when the caller is already over its compaction threshold. */
907
+ relaxedMinimum?: number;
908
+ /** Conservative maximum ASCII length of every planned artifact reference. */
909
+ artifactRefMaxChars?: number;
910
+ }
911
+
912
+ interface PlannedToolOutputPruneCandidate extends ToolOutputPruneCandidate {}
913
+
914
+ function artifactRefMaxChars(options: PruneToolOutputsOptions): number {
915
+ const maxChars = options.artifactRefMaxChars;
916
+ if (maxChars === undefined) return 0;
917
+ if (!Number.isSafeInteger(maxChars) || maxChars <= 0 || maxChars > MAX_ARTIFACT_REF_CHARS) {
918
+ throw new RangeError(`artifactRefMaxChars must be an integer between 1 and ${MAX_ARTIFACT_REF_CHARS}`);
919
+ }
920
+ return maxChars;
921
+ }
922
+
923
+ function planToolOutputPruneCandidates(
924
+ candidates: ToolOutputPruneCandidate[],
925
+ options: PruneToolOutputsOptions,
926
+ ): PlannedToolOutputPruneCandidate[] {
927
+ const maxArtifactChars = artifactRefMaxChars(options);
928
+ const artifactBudget = maxArtifactChars > 0 ? "x".repeat(maxArtifactChars) : undefined;
929
+ return candidates.flatMap(candidate => {
930
+ const notice = createPrunedNotice(
931
+ candidate.tokens,
932
+ candidate.entry.message as ToolResultMessage,
933
+ candidate.call,
934
+ candidate.complete ? artifactBudget : undefined,
935
+ );
936
+ const savings = estimatePrunedSavings(candidate.tokens, notice);
937
+ const errorNoticeGrows =
938
+ (candidate.entry.message as ToolResultMessage).isError === true &&
939
+ notice.length > candidate.originalText.length;
940
+ return savings > 0 && !errorNoticeGrows ? [{ ...candidate, notice, savings }] : [];
941
+ });
942
+ }
943
+
944
+ function emptyToolOutputPrunePlan(): ToolOutputPrunePlan {
945
+ return { prunedCount: 0, tokensSaved: 0, digests: [], replacements: [] };
946
+ }
947
+
948
+ /**
949
+ * Build a digest-only pruning plan. This function is deliberately read-only:
950
+ * candidates are inspected in private locals and the returned plan never keeps
951
+ * references to the source entries or their original output text.
952
+ */
953
+ export function planToolOutputPrune(
954
+ entries: SessionEntry[],
955
+ config: PruneConfig = DEFAULT_PRUNE_CONFIG,
956
+ options: PruneToolOutputsOptions = {},
957
+ ): ToolOutputPrunePlan {
958
+ const { candidates, tokensSaved: baseTokensSaved } = collectToolOutputPruneCandidates(entries, config);
959
+ const minimum = minimumSavings(config, options);
960
+ if (baseTokensSaved < minimum || candidates.length === 0) return emptyToolOutputPrunePlan();
961
+
962
+ const planned = planToolOutputPruneCandidates(candidates, options);
963
+ const tokensSaved = planned.reduce((total, candidate) => total + candidate.savings, 0);
964
+ if (tokensSaved < minimum || planned.length === 0) return emptyToolOutputPrunePlan();
965
+
966
+ const digests = planned.map(candidate => ({
967
+ entryId: candidate.entry.id,
968
+ bytes: Buffer.byteLength(candidate.originalText, "utf8"),
969
+ sha256: createHash("sha256").update(candidate.originalText, "utf8").digest("hex"),
970
+ }));
971
+ const replacements = planned.map(candidate => ({
972
+ entryId: candidate.entry.id,
973
+ replacementText: candidate.notice,
974
+ complete: candidate.complete,
975
+ tokens: candidate.tokens,
976
+ }));
977
+ return Object.freeze({
978
+ prunedCount: planned.length,
979
+ tokensSaved,
980
+ digests: Object.freeze(digests),
981
+ replacements: Object.freeze(replacements),
982
+ });
983
+ }
984
+
985
+ /**
986
+ * Re-check and commit a digest-only plan against the supplied live entries.
987
+ * Each entry is mutated only after its canonical full-text digest matches.
988
+ */
989
+ export function commitToolOutputPrune(
990
+ entries: SessionEntry[],
991
+ plan: ToolOutputPrunePlan,
992
+ options: ToolOutputPruneCommitOptions = {},
993
+ ): ToolOutputCommitOutcome[] {
994
+ const byId = new Map(entries.filter((e): e is SessionMessageEntry => e.type === "message").map(e => [e.id, e]));
995
+ const proposals = new Map(plan.replacements.map(replacement => [replacement.entryId, replacement]));
996
+ return plan.digests.map(digest => {
997
+ const entry = byId.get(digest.entryId);
998
+ if (!entry) return { entryId: digest.entryId, outcome: "unavailable", diagnostic: "entry not found" };
999
+ const message = entry.message as ToolResultMessage;
1000
+ const captured = extractToolOutputText(message);
1001
+ const bytes = Buffer.byteLength(captured.text, "utf8");
1002
+ const sha = createHash("sha256").update(captured.text, "utf8").digest("hex");
1003
+ if (bytes !== digest.bytes || sha !== digest.sha256) {
1004
+ return { entryId: digest.entryId, outcome: "mismatch", diagnostic: "tool output changed before commit" };
1005
+ }
1006
+ const proposal = proposals.get(digest.entryId);
1007
+ if (!proposal)
1008
+ return { entryId: digest.entryId, outcome: "unavailable", diagnostic: "replacement proposal missing" };
1009
+ const override = options.replacements?.get(digest.entryId);
1010
+ const replacementText = override?.replacementText ?? proposal.replacementText;
1011
+ message.content = [{ type: "text", text: replacementText }];
1012
+ message.prunedAt = Date.now();
1013
+ if (override?.eviction) {
1014
+ const details =
1015
+ message.details && typeof message.details === "object" && !Array.isArray(message.details)
1016
+ ? (message.details as Record<string, unknown>)
1017
+ : {};
1018
+ const meta =
1019
+ details.meta && typeof details.meta === "object" && !Array.isArray(details.meta)
1020
+ ? (details.meta as Record<string, unknown>)
1021
+ : {};
1022
+ message.details = { ...details, meta: { ...meta, eviction: override.eviction } };
1023
+ }
1024
+ return { entryId: digest.entryId, outcome: "committed" };
1025
+ });
1026
+ }