@sayknow-cli/agent-core 0.2.2

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 (55) hide show
  1. package/CHANGELOG.md +588 -0
  2. package/README.md +473 -0
  3. package/dist/types/agent-loop.d.ts +56 -0
  4. package/dist/types/agent.d.ts +381 -0
  5. package/dist/types/append-only-context.d.ts +124 -0
  6. package/dist/types/compaction/branch-summarization.d.ts +103 -0
  7. package/dist/types/compaction/compaction.d.ts +253 -0
  8. package/dist/types/compaction/entries.d.ts +109 -0
  9. package/dist/types/compaction/errors.d.ts +26 -0
  10. package/dist/types/compaction/index.d.ts +11 -0
  11. package/dist/types/compaction/messages.d.ts +61 -0
  12. package/dist/types/compaction/openai.d.ts +62 -0
  13. package/dist/types/compaction/pruning.d.ts +37 -0
  14. package/dist/types/compaction/utils.d.ts +32 -0
  15. package/dist/types/compaction.d.ts +1 -0
  16. package/dist/types/harmony-leak.d.ts +99 -0
  17. package/dist/types/index.d.ts +10 -0
  18. package/dist/types/proxy.d.ts +84 -0
  19. package/dist/types/run-collector.d.ts +196 -0
  20. package/dist/types/telemetry.d.ts +596 -0
  21. package/dist/types/thinking.d.ts +18 -0
  22. package/dist/types/types.d.ts +430 -0
  23. package/package.json +75 -0
  24. package/src/agent-loop.ts +1302 -0
  25. package/src/agent.ts +1531 -0
  26. package/src/append-only-context.ts +460 -0
  27. package/src/compaction/branch-summarization.ts +358 -0
  28. package/src/compaction/compaction.ts +1342 -0
  29. package/src/compaction/entries.ts +139 -0
  30. package/src/compaction/errors.ts +31 -0
  31. package/src/compaction/index.ts +12 -0
  32. package/src/compaction/messages.ts +212 -0
  33. package/src/compaction/openai.ts +570 -0
  34. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  35. package/src/compaction/prompts/branch-summary-context.md +5 -0
  36. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  37. package/src/compaction/prompts/branch-summary.md +30 -0
  38. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  39. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  40. package/src/compaction/prompts/compaction-summary.md +38 -0
  41. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  42. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  43. package/src/compaction/prompts/file-operations.md +10 -0
  44. package/src/compaction/prompts/handoff-document.md +49 -0
  45. package/src/compaction/prompts/summarization-system.md +3 -0
  46. package/src/compaction/pruning.ts +431 -0
  47. package/src/compaction/utils.ts +185 -0
  48. package/src/compaction.ts +1 -0
  49. package/src/harmony-leak.ts +428 -0
  50. package/src/index.ts +19 -0
  51. package/src/proxy.ts +326 -0
  52. package/src/run-collector.ts +631 -0
  53. package/src/telemetry.ts +2049 -0
  54. package/src/thinking.ts +20 -0
  55. package/src/types.ts +490 -0
@@ -0,0 +1,45 @@
1
+ You MUST incorporate new messages above into the existing handoff summary in <previous-summary> tags, used by another LLM to resume task.
2
+ RULES:
3
+ - MUST preserve all information from 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 new messages end with unanswered question or request to 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,10 @@
1
+ {{#if readFiles.length}}
2
+ {{#xml "read-files"}}
3
+ {{join readFiles "\n"}}
4
+ {{/xml}}
5
+ {{/if}}
6
+ {{#if modifiedFiles.length}}
7
+ {{#xml "modified-files"}}
8
+ {{join modifiedFiles "\n"}}
9
+ {{/xml}}
10
+ {{/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
+ Summarize conversations between users and AI coding assistants. Produce structured summaries in the exact specified format.
2
+
3
+ Do NOT continue the conversation. Do NOT respond to questions in the conversation. Output ONLY the structured summary.
@@ -0,0 +1,431 @@
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 type { ToolCall, ToolResultMessage } from "@sayknow-cli/ai";
12
+ import type { AgentMessage } from "../types";
13
+ import { estimateEntryTokens } from "./compaction";
14
+ import type { SessionEntry, SessionMessageEntry } from "./entries";
15
+
16
+ export interface PruneConfig {
17
+ /** Keep the most recent tool output tokens intact. */
18
+ protectTokens: number;
19
+ /** Only prune if total savings meets this threshold. */
20
+ minimumSavings: number;
21
+ /** Tool names that should never be pruned. */
22
+ protectedTools: string[];
23
+ /**
24
+ * Tools in `protectedTools` whose protection is waived once the result is
25
+ * superseded (a later result for the same target, or a later successful
26
+ * edit/write to the covered file). The most recent result per target is
27
+ * never considered superseded. Optional; defaults to none.
28
+ */
29
+ staleOverridableTools?: string[];
30
+ }
31
+
32
+ export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
33
+ protectTokens: 40_000,
34
+ minimumSavings: 20_000,
35
+ protectedTools: ["skill", "read"],
36
+ staleOverridableTools: ["read"],
37
+ };
38
+
39
+ export interface PruneResult {
40
+ prunedCount: number;
41
+ tokensSaved: number;
42
+ /**
43
+ * The mutated message entries. Callers whose entry source returns
44
+ * materialized copies (not live references) must write these back into
45
+ * their canonical store by id.
46
+ */
47
+ prunedEntries: SessionMessageEntry[];
48
+ }
49
+
50
+ const DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER = 1.25;
51
+
52
+ function createGenericPrunedNotice(tokens: number): string {
53
+ return `[Output truncated - ${tokens} tokens]`;
54
+ }
55
+
56
+ function firstTextContent(message: ToolResultMessage): string {
57
+ if (typeof message.content === "string") return message.content;
58
+ const block = message.content.find(part => part.type === "text");
59
+ return block?.type === "text" ? block.text : "";
60
+ }
61
+
62
+ function firstErrorLine(text: string): string | undefined {
63
+ return text
64
+ .split(/\r?\n/)
65
+ .find(line => /error|failed|exception|panic/i.test(line))
66
+ ?.trim();
67
+ }
68
+
69
+ function truncateField(value: string, maxLength: number): string {
70
+ if (value.length <= maxLength) return value;
71
+ if (maxLength <= 1) return "…";
72
+ return `${value.slice(0, maxLength - 1)}…`;
73
+ }
74
+
75
+ function resultDigest(message: ToolResultMessage): string | undefined {
76
+ const toolName = message.toolName.toLowerCase();
77
+ const text = firstTextContent(message);
78
+ if (toolName === "bash") {
79
+ const details = message as { details?: { exitCode?: unknown } };
80
+ const exitCode =
81
+ typeof details.details?.exitCode === "number" ? details.details.exitCode : message.isError ? 1 : 0;
82
+ const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
83
+ const error = firstErrorLine(text);
84
+ return [`exit=${exitCode}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined]
85
+ .filter((part): part is string => part !== undefined)
86
+ .join("; ");
87
+ }
88
+ if (toolName === "search" || toolName === "grep") {
89
+ const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i);
90
+ const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i);
91
+ const error = firstErrorLine(text);
92
+ return (
93
+ [
94
+ match ? `matches=${match[1]}` : undefined,
95
+ files ? `files=${files[1]}` : undefined,
96
+ error ? `error=${error}` : undefined,
97
+ ]
98
+ .filter((part): part is string => part !== undefined)
99
+ .join("; ") || "search digest unavailable"
100
+ );
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ function createPrunedNotice(tokens: number, message?: ToolResultMessage): string {
106
+ const generic = createGenericPrunedNotice(tokens);
107
+ const digest = message ? resultDigest(message) : undefined;
108
+ if (!digest) return generic;
109
+ const genericTokens = Math.ceil(generic.length / 4);
110
+ const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
111
+ const prefix = `[Output truncated - ${tokens} tokens; `;
112
+ const suffix = "]";
113
+ const maxChars = Math.max(0, maxTokens * 4 - prefix.length - suffix.length);
114
+ return `${prefix}${truncateField(digest, maxChars)}${suffix}`;
115
+ }
116
+
117
+ function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
118
+ if (entry.type !== "message") return undefined;
119
+ const message = entry.message as AgentMessage;
120
+ if (message.role !== "toolResult") return undefined;
121
+ return message as ToolResultMessage;
122
+ }
123
+
124
+ function estimatePrunedSavings(tokens: number, notice: string): number {
125
+ const noticeTokens = Math.ceil(notice.length / 4);
126
+ return Math.max(0, tokens - noticeTokens);
127
+ }
128
+
129
+ const EDIT_TOOL_NAMES = new Set(["edit", "write", "apply_patch", "ast_edit"]);
130
+
131
+ /** Extract the file-path argument from a tool call, when the tool has one. */
132
+ function toolCallPath(call: ToolCall): string | undefined {
133
+ const args = call.arguments;
134
+ const path = args.path ?? args.file_path ?? args.filePath;
135
+ return typeof path === "string" && path.length > 0 ? path : undefined;
136
+ }
137
+
138
+ /**
139
+ * `*** Add|Update|Delete File: <path>` headers open a hunk; `*** Move to:
140
+ * <path>` attaches a rename destination to the current hunk. Move
141
+ * destinations count as touched paths: a rename onto a file invalidates
142
+ * earlier reads of that destination.
143
+ */
144
+ const APPLY_PATCH_HEADER = /^\*\*\* (?:((?:Add|Update|Delete) File)|(Move to)): (.+)$/gm;
145
+
146
+ /**
147
+ * Paths touched by an edit-class tool call, grouped per hunk so a failed
148
+ * hunk can be excluded wholesale (its rename destination included). Most
149
+ * edit tools carry a single path argument; apply_patch envelopes carry an
150
+ * `input` string with per-file headers instead. The envelope shape can
151
+ * arrive under the custom `apply_patch` tool OR the regular `edit` tool
152
+ * (providers without custom-tool support fall back to the JSON function), so
153
+ * any edit-class call with a string `input` is parsed for headers.
154
+ */
155
+ function editToolPathGroups(call: ToolCall): string[][] {
156
+ const path = toolCallPath(call);
157
+ if (path !== undefined) return [[path]];
158
+ const input = call.arguments.input;
159
+ if (typeof input !== "string") return [];
160
+ const groups: string[][] = [];
161
+ for (const match of input.matchAll(APPLY_PATCH_HEADER)) {
162
+ const headerPath = match[3]?.trim();
163
+ if (!headerPath) continue;
164
+ const isMoveTo = match[2] !== undefined;
165
+ if (isMoveTo && groups.length > 0) {
166
+ groups[groups.length - 1].push(headerPath);
167
+ } else {
168
+ groups.push([headerPath]);
169
+ }
170
+ }
171
+ return groups;
172
+ }
173
+
174
+ /**
175
+ * Trailing read selectors (`:50`, `:50-200`, `:50+150`, `:5-16,960-973`,
176
+ * `:raw`, `:conflicts`), possibly stacked (`:2-4:raw`). Stripped to resolve
177
+ * the underlying file for edit invalidation.
178
+ */
179
+ const READ_SELECTOR_SUFFIX = /:(?:raw|conflicts|\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/;
180
+
181
+ /** Base file path of a read target with any line/mode selectors stripped. */
182
+ function readBasePath(path: string): string {
183
+ let base = path;
184
+ while (READ_SELECTOR_SUFFIX.test(base)) {
185
+ base = base.replace(READ_SELECTOR_SUFFIX, "");
186
+ }
187
+ return base;
188
+ }
189
+
190
+ /**
191
+ * Stable identity for "the same logical lookup": same tool re-targeting the
192
+ * same subject. A later result with the same key supersedes earlier ones.
193
+ * Keys are canonical JSON tuples so user-controlled text (patterns, paths)
194
+ * can never collide via delimiter ambiguity. Search keys include pagination
195
+ * (`skip`) and result-shaping flags (`i`, `gitignore`): a later page or a
196
+ * differently-shaped search complements earlier output, it does not replace it.
197
+ */
198
+ function toolTargetKey(call: ToolCall): string | undefined {
199
+ const path = toolCallPath(call);
200
+ if (path !== undefined) return JSON.stringify([call.name, "path", path]);
201
+ const pattern = call.arguments.pattern;
202
+ if (typeof pattern === "string" && pattern.length > 0) {
203
+ const paths = call.arguments.paths;
204
+ const pathList = Array.isArray(paths) ? paths.filter((p): p is string => typeof p === "string") : [];
205
+ const skip = typeof call.arguments.skip === "number" ? call.arguments.skip : 0;
206
+ const caseInsensitive = call.arguments.i === true;
207
+ const gitignore = call.arguments.gitignore !== false;
208
+ return JSON.stringify([call.name, "pattern", pattern, pathList, skip, caseInsensitive, gitignore]);
209
+ }
210
+ return undefined;
211
+ }
212
+
213
+ /**
214
+ * Files actually mutated according to a tool result's details. Used for
215
+ * AST-edit-shaped results (`ast_edit` direct-apply and the hidden `resolve`
216
+ * apply step), which report `{ applied: true, files: [...] }` — the resolve
217
+ * tool nests that payload under `details.sourceResultDetails`. Conservative:
218
+ * returns nothing unless the details explicitly mark the change as applied.
219
+ * Checked even on `isError` results: a stale-preview apply reports an error
220
+ * while still having mutated the listed files.
221
+ */
222
+ function resultDetailFiles(message: ToolResultMessage): string[] {
223
+ const raw = message.details as { applied?: unknown; files?: unknown; sourceResultDetails?: unknown } | undefined;
224
+ const candidates = [raw, raw?.sourceResultDetails as { applied?: unknown; files?: unknown } | undefined];
225
+ for (const details of candidates) {
226
+ if (details?.applied === true && Array.isArray(details.files)) {
227
+ return details.files.filter((file): file is string => typeof file === "string" && file.length > 0);
228
+ }
229
+ }
230
+ return [];
231
+ }
232
+
233
+ /**
234
+ * Paths that FAILED in a per-file edit result (`details.perFileResults`) and
235
+ * were NOT mutated by any same-path entry. Multi-file apply_patch catches
236
+ * per-file failures and still returns a non-error result; a purely-failed
237
+ * path was not mutated and must not stale reads. But apply_patch can emit
238
+ * multiple entries for the same path (e.g. several hunks): if any same-path
239
+ * entry succeeded the file still mutated, so it must NOT be suppressed.
240
+ * Conservative: only an entry explicitly marked `isError === true` counts as
241
+ * a failure; anything else (including ambiguous/malformed entries) counts as
242
+ * a success and keeps the path out of the suppression set.
243
+ */
244
+ function failedEditPaths(message: ToolResultMessage): Set<string> {
245
+ const details = message.details as { perFileResults?: unknown } | undefined;
246
+ const perFile = details?.perFileResults;
247
+ if (!Array.isArray(perFile)) return new Set();
248
+ const failed = new Set<string>();
249
+ const succeeded = new Set<string>();
250
+ for (const item of perFile) {
251
+ const entry = item as { path?: unknown; isError?: unknown };
252
+ if (typeof entry?.path !== "string") continue;
253
+ if (entry.isError === true) failed.add(entry.path);
254
+ else succeeded.add(entry.path);
255
+ }
256
+ // A path mutated if any same-path entry succeeded, even when another
257
+ // same-path entry failed; drop those from the suppression set.
258
+ for (const path of succeeded) failed.delete(path);
259
+ return failed;
260
+ }
261
+
262
+ /**
263
+ * Concrete file path a `read` result actually came from, when the tool
264
+ * reported one (`details.resolvedPath`). Suffix resolution can map a bare
265
+ * filename argument onto a different concrete path.
266
+ */
267
+ function readResolvedPath(message: ToolResultMessage): string | undefined {
268
+ const details = message.details as { resolvedPath?: unknown } | undefined;
269
+ const resolved = details?.resolvedPath;
270
+ return typeof resolved === "string" && resolved.length > 0 ? resolved : undefined;
271
+ }
272
+
273
+ interface StalenessIndex {
274
+ /** Entry indices of toolResults superseded by a later same-target result or a later edit. */
275
+ staleResultIndices: Set<number>;
276
+ }
277
+
278
+ /**
279
+ * Build a staleness index over session entries (oldest -> newest):
280
+ * - a toolResult is stale when a later non-error toolResult shares its target key;
281
+ * - a `read` result is stale when a later non-error edit/write touches its file.
282
+ * The most recent result per target is never stale.
283
+ */
284
+ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
285
+ const callsById = new Map<string, ToolCall>();
286
+ for (const entry of entries) {
287
+ if (entry.type !== "message") continue;
288
+ const message = entry.message as AgentMessage;
289
+ if (message.role !== "assistant") continue;
290
+ for (const content of message.content) {
291
+ if (content.type === "toolCall") callsById.set(content.id, content);
292
+ }
293
+ }
294
+
295
+ const lastResultIndexByKey = new Map<string, number>();
296
+ const resultMeta = new Map<number, { key?: string; call: ToolCall; message: ToolResultMessage }>();
297
+ const lastEditIndexByPath = new Map<string, number>();
298
+
299
+ for (let i = 0; i < entries.length; i++) {
300
+ const message = getToolResultMessage(entries[i]);
301
+ if (!message) continue;
302
+ const call = callsById.get(message.toolCallId);
303
+ if (!call) continue;
304
+
305
+ // AST edits mutate files when previews are applied via the hidden
306
+ // `resolve` tool; the call args carry globs, not concrete paths. Both
307
+ // tools report actually-touched files in result details. Collected
308
+ // BEFORE the error gate: a stale-preview apply reports an error while
309
+ // still having mutated the listed files.
310
+ if (call.name === "resolve" || call.name === "ast_edit") {
311
+ for (const editPath of resultDetailFiles(message)) {
312
+ lastEditIndexByPath.set(editPath, i);
313
+ }
314
+ }
315
+ if (message.isError) continue;
316
+
317
+ const key = toolTargetKey(call);
318
+ resultMeta.set(i, { key, call, message });
319
+ if (key !== undefined) lastResultIndexByKey.set(key, i);
320
+ if (EDIT_TOOL_NAMES.has(call.name)) {
321
+ // Per-file edit results record failures in details.perFileResults;
322
+ // a failed hunk mutated nothing, so exclude its whole path group
323
+ // (rename destination included) from touched paths.
324
+ const failed = failedEditPaths(message);
325
+ for (const group of editToolPathGroups(call)) {
326
+ if (group.some(groupPath => failed.has(groupPath))) continue;
327
+ for (const editPath of group) {
328
+ lastEditIndexByPath.set(editPath, i);
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ const staleResultIndices = new Set<number>();
335
+ for (const [index, meta] of resultMeta) {
336
+ if (meta.key !== undefined) {
337
+ const lastIndex = lastResultIndexByKey.get(meta.key);
338
+ if (lastIndex !== undefined && lastIndex > index) {
339
+ staleResultIndices.add(index);
340
+ continue;
341
+ }
342
+ }
343
+ if (meta.call.name === "read") {
344
+ // Check both the call argument (selectors stripped) and the resolved
345
+ // path from result details: suffix resolution can map a bare filename
346
+ // onto a different concrete path, and edits may use either form.
347
+ const lookupPaths = new Set<string>();
348
+ const argPath = toolCallPath(meta.call);
349
+ if (argPath !== undefined) lookupPaths.add(readBasePath(argPath));
350
+ const resolved = readResolvedPath(meta.message);
351
+ if (resolved !== undefined) lookupPaths.add(resolved);
352
+ for (const lookupPath of lookupPaths) {
353
+ const editIndex = lastEditIndexByPath.get(lookupPath);
354
+ if (editIndex !== undefined && editIndex > index) {
355
+ staleResultIndices.add(index);
356
+ break;
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ return { staleResultIndices };
363
+ }
364
+
365
+ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
366
+ let accumulatedTokens = 0;
367
+ let tokensSaved = 0;
368
+ let prunedCount = 0;
369
+
370
+ const { staleResultIndices } = buildStalenessIndex(entries);
371
+ const staleOverridable = new Set(config.staleOverridableTools ?? []);
372
+ const candidates: Array<{ entry: SessionMessageEntry; tokens: number; notice: string; savings: number }> = [];
373
+
374
+ for (let i = entries.length - 1; i >= 0; i--) {
375
+ const entry = entries[i];
376
+ const message = getToolResultMessage(entry);
377
+ if (!message) continue;
378
+
379
+ const tokens = estimateEntryTokens(entry);
380
+ const isStale = staleResultIndices.has(i);
381
+ // Staleness waives protected-tool immunity for overridable tools
382
+ // (e.g. a superseded `read`); the most recent result per target is
383
+ // never stale, so the latest read of each file stays protected.
384
+ const isProtected =
385
+ config.protectedTools.includes(message.toolName) && !(isStale && staleOverridable.has(message.toolName));
386
+
387
+ if (message.prunedAt !== undefined) {
388
+ accumulatedTokens += tokens;
389
+ continue;
390
+ }
391
+
392
+ // Stale results are prunable even inside the recency protect window —
393
+ // they are superseded, so recency no longer implies relevance. They
394
+ // still count toward window accounting so non-stale protection is
395
+ // unchanged.
396
+ const insideProtectWindow = accumulatedTokens < config.protectTokens;
397
+ if ((insideProtectWindow && !isStale) || isProtected) {
398
+ accumulatedTokens += tokens;
399
+ continue;
400
+ }
401
+
402
+ const notice = createPrunedNotice(tokens, message);
403
+ candidates.push({
404
+ entry: entry as SessionMessageEntry,
405
+ tokens,
406
+ notice,
407
+ savings: estimatePrunedSavings(tokens, notice),
408
+ });
409
+ accumulatedTokens += tokens;
410
+ }
411
+
412
+ for (const candidate of candidates) {
413
+ tokensSaved += candidate.savings;
414
+ }
415
+
416
+ if (tokensSaved < config.minimumSavings || candidates.length === 0) {
417
+ return { prunedCount: 0, tokensSaved: 0, prunedEntries: [] };
418
+ }
419
+
420
+ const prunedAt = Date.now();
421
+ const prunedEntries: SessionMessageEntry[] = [];
422
+ for (const candidate of candidates) {
423
+ const message = candidate.entry.message as ToolResultMessage;
424
+ message.content = [{ type: "text", text: candidate.notice }];
425
+ message.prunedAt = prunedAt;
426
+ prunedEntries.push(candidate.entry);
427
+ prunedCount++;
428
+ }
429
+
430
+ return { prunedCount, tokensSaved, prunedEntries };
431
+ }