@gajae-code/agent-core 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.5] - 2026-06-12
6
+
7
+ ### Changed
8
+
9
+ - Made tool-output pruning staleness-aware: results superseded by a later same-target result (re-read file, re-run search) or invalidated by a later successful edit/write are pruned before merely-old ones, including inside the recency protect window. New optional `PruneConfig.staleOverridableTools` (default `["read"]`) waives protected-tool immunity for superseded results while the most recent result per target stays protected. Target identity uses collision-proof canonical JSON tuple keys.
10
+ - `PruneResult` now returns `prunedEntries` so callers whose entry source materializes copies (e.g. blob-externalized session entries) can write mutations back into their canonical store.
11
+
12
+ ### Fixed
13
+
14
+ - Preserved Cursor-native tool call rendering and execution through the agent tool-call path, including runtime tool details.
15
+
16
+ ## [0.4.4] - 2026-06-10
17
+
18
+ - Version aligned with the 0.4.4 monorepo release; no functional changes in this package.
19
+
5
20
  ## [0.4.3] - 2026-06-10
6
21
 
7
22
  ### Fixed
@@ -68,11 +68,35 @@ export declare function effectiveReserveTokens(contextWindow: number, settings:
68
68
  export declare function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): boolean;
69
69
  export declare function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): number;
70
70
  /**
71
- * Estimate token count for a message using cl100k_base via the native
72
- * tokenizer. This is not Anthropic's first-party tokenizer (Anthropic doesn't
73
- * publish one) but is within ~5–10% across English/code text.
71
+ * Estimate token count for a message using the native o200k tokenizer.
72
+ * Exact for o200k only; an approximation for Anthropic/other model families
73
+ * (Anthropic doesn't publish a tokenizer) within ~5–10% on English/code text.
74
+ *
75
+ * This materializes the native BPE table (~50MB RSS) on first call. Use it
76
+ * only for context-changing decisions (compaction trigger/cut points, pruning
77
+ * budgets, branch summarization, fork-context seeding, context-limit
78
+ * enforcement). For display-only totals use
79
+ * {@link estimateMessageTokensHeuristic}.
80
+ */
81
+ export declare function countMessageTokensNativeO200k(message: AgentMessage): number;
82
+ /**
83
+ * Backwards-compatible alias for {@link countMessageTokensNativeO200k}.
84
+ * Existing callers treat this as the canonical message-token estimator for
85
+ * context-changing decisions.
86
+ */
87
+ export declare const estimateTokens: typeof countMessageTokensNativeO200k;
88
+ /**
89
+ * Cheap, native-free token estimate for a message. Suitable ONLY for
90
+ * display/init surfaces (status line, /context report, HUD totals) — never
91
+ * for context-changing decisions, which must use
92
+ * {@link countMessageTokensNativeO200k}.
93
+ */
94
+ export declare function estimateMessageTokensHeuristic(message: AgentMessage): number;
95
+ /**
96
+ * Cheap, native-free token estimate for plain string fragments. Display-only
97
+ * counterpart of the native `countTokens(fragments)` aggregate.
74
98
  */
75
- export declare function estimateTokens(message: AgentMessage): number;
99
+ export declare function estimateTextTokensHeuristic(fragments: string | readonly string[]): number;
76
100
  /**
77
101
  * Find the user message (or bashExecution) that starts the turn containing the given entry index.
78
102
  * Returns -1 if no turn start found before the index.
@@ -1,7 +1,13 @@
1
1
  /**
2
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.
3
9
  */
4
- import type { SessionEntry } from "./entries";
10
+ import type { SessionEntry, SessionMessageEntry } from "./entries";
5
11
  export interface PruneConfig {
6
12
  /** Keep the most recent tool output tokens intact. */
7
13
  protectTokens: number;
@@ -9,10 +15,23 @@ export interface PruneConfig {
9
15
  minimumSavings: number;
10
16
  /** Tool names that should never be pruned. */
11
17
  protectedTools: string[];
18
+ /**
19
+ * Tools in `protectedTools` whose protection is waived once the result is
20
+ * superseded (a later result for the same target, or a later successful
21
+ * edit/write to the covered file). The most recent result per target is
22
+ * never considered superseded. Optional; defaults to none.
23
+ */
24
+ staleOverridableTools?: string[];
12
25
  }
13
26
  export declare const DEFAULT_PRUNE_CONFIG: PruneConfig;
14
27
  export interface PruneResult {
15
28
  prunedCount: number;
16
29
  tokensSaved: number;
30
+ /**
31
+ * The mutated message entries. Callers whose entry source returns
32
+ * materialized copies (not live references) must write these back into
33
+ * their canonical store by id.
34
+ */
35
+ prunedEntries: SessionMessageEntry[];
17
36
  }
18
37
  export declare function pruneToolOutputs(entries: SessionEntry[], config?: PruneConfig): PruneResult;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.4.4",
4
+ "version": "0.4.5",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://gaebal-gajae.dev",
7
7
  "author": "Yeachan-Heo",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@gajae-code/ai": "0.4.4",
39
- "@gajae-code/natives": "0.4.4",
40
- "@gajae-code/utils": "0.4.4",
38
+ "@gajae-code/ai": "0.4.5",
39
+ "@gajae-code/natives": "0.4.5",
40
+ "@gajae-code/utils": "0.4.5",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "devDependencies": {
package/src/agent.ts CHANGED
@@ -680,7 +680,11 @@ export class Agent {
680
680
  if (!source) return undefined;
681
681
 
682
682
  const guarded: CursorExecHandlers = {};
683
- const read = source.read;
683
+ // Bind each handler to `source`: they are methods of a CursorExecHandlers
684
+ // instance that reference private fields via `this`. Extracting them bare
685
+ // (`const read = source.read`) and calling `read(args)` would invoke them with
686
+ // `this === undefined`, throwing "undefined is not an object (this.#optionsForCall)".
687
+ const read = source.read?.bind(source);
684
688
  if (read) {
685
689
  guarded.read = async args => {
686
690
  this.#assertActiveRun(runId);
@@ -689,7 +693,7 @@ export class Agent {
689
693
  return result;
690
694
  };
691
695
  }
692
- const ls = source.ls;
696
+ const ls = source.ls?.bind(source);
693
697
  if (ls) {
694
698
  guarded.ls = async args => {
695
699
  this.#assertActiveRun(runId);
@@ -698,7 +702,7 @@ export class Agent {
698
702
  return result;
699
703
  };
700
704
  }
701
- const grep = source.grep;
705
+ const grep = source.grep?.bind(source);
702
706
  if (grep) {
703
707
  guarded.grep = async args => {
704
708
  this.#assertActiveRun(runId);
@@ -707,7 +711,7 @@ export class Agent {
707
711
  return result;
708
712
  };
709
713
  }
710
- const write = source.write;
714
+ const write = source.write?.bind(source);
711
715
  if (write) {
712
716
  guarded.write = async args => {
713
717
  this.#assertActiveRun(runId);
@@ -716,7 +720,7 @@ export class Agent {
716
720
  return result;
717
721
  };
718
722
  }
719
- const deleteHandler = source.delete;
723
+ const deleteHandler = source.delete?.bind(source);
720
724
  if (deleteHandler) {
721
725
  guarded.delete = async args => {
722
726
  this.#assertActiveRun(runId);
@@ -725,7 +729,7 @@ export class Agent {
725
729
  return result;
726
730
  };
727
731
  }
728
- const shell = source.shell;
732
+ const shell = source.shell?.bind(source);
729
733
  if (shell) {
730
734
  guarded.shell = async args => {
731
735
  this.#assertActiveRun(runId);
@@ -734,7 +738,7 @@ export class Agent {
734
738
  return result;
735
739
  };
736
740
  }
737
- const shellStream = source.shellStream;
741
+ const shellStream = source.shellStream?.bind(source);
738
742
  if (shellStream) {
739
743
  guarded.shellStream = async (args, callbacks) => {
740
744
  this.#assertActiveRun(runId);
@@ -743,7 +747,7 @@ export class Agent {
743
747
  return result;
744
748
  };
745
749
  }
746
- const diagnostics = source.diagnostics;
750
+ const diagnostics = source.diagnostics?.bind(source);
747
751
  if (diagnostics) {
748
752
  guarded.diagnostics = async args => {
749
753
  this.#assertActiveRun(runId);
@@ -752,7 +756,7 @@ export class Agent {
752
756
  return result;
753
757
  };
754
758
  }
755
- const mcp = source.mcp;
759
+ const mcp = source.mcp?.bind(source);
756
760
  if (mcp) {
757
761
  guarded.mcp = async call => {
758
762
  this.#assertActiveRun(runId);
@@ -13,7 +13,6 @@ import {
13
13
  type Model,
14
14
  type Usage,
15
15
  } from "@gajae-code/ai";
16
- import { countTokens } from "@gajae-code/natives";
17
16
  import { logger, prompt } from "@gajae-code/utils";
18
17
  import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
19
18
  import type { AgentMessage, AgentTool } from "../types";
@@ -268,18 +267,95 @@ export function resolveThresholdTokens(
268
267
  const IMAGE_TOKEN_ESTIMATE = 1200;
269
268
 
270
269
  /**
271
- * Estimate token count for a message using cl100k_base via the native
272
- * tokenizer. This is not Anthropic's first-party tokenizer (Anthropic doesn't
273
- * publish one) but is within ~5–10% across English/code text.
270
+ * Lazily-required native `countTokens`. `@gajae-code/natives` dlopens a ~39MB
271
+ * addon; importing it at module scope would put that cost on every cold path
272
+ * that touches compaction exports (status line, print mode, context report).
273
+ * Deferring the require to the first context-changing call keeps the trivial
274
+ * `-p` / display paths native-free.
274
275
  */
275
- export function estimateTokens(message: AgentMessage): number {
276
+ let cachedNativeCountTokens: ((input: string | string[], encoding?: unknown) => number) | null = null;
277
+
278
+ function nativeCountTokens(fragments: string[]): number {
279
+ if (!cachedNativeCountTokens) {
280
+ const { createRequire } = require("node:module") as typeof import("node:module");
281
+ const requireFromHere = createRequire(import.meta.url);
282
+ const natives = requireFromHere("@gajae-code/natives") as {
283
+ countTokens: (input: string | string[], encoding?: unknown) => number;
284
+ };
285
+ cachedNativeCountTokens = natives.countTokens;
286
+ }
287
+ return cachedNativeCountTokens(fragments);
288
+ }
289
+
290
+ /**
291
+ * Estimate token count for a message using the native o200k tokenizer.
292
+ * Exact for o200k only; an approximation for Anthropic/other model families
293
+ * (Anthropic doesn't publish a tokenizer) within ~5–10% on English/code text.
294
+ *
295
+ * This materializes the native BPE table (~50MB RSS) on first call. Use it
296
+ * only for context-changing decisions (compaction trigger/cut points, pruning
297
+ * budgets, branch summarization, fork-context seeding, context-limit
298
+ * enforcement). For display-only totals use
299
+ * {@link estimateMessageTokensHeuristic}.
300
+ */
301
+ export function countMessageTokensNativeO200k(message: AgentMessage): number {
302
+ const { fragments, extra } = collectMessageFragments(message);
303
+ if (fragments.length === 0) return extra;
304
+ return extra + nativeCountTokens(fragments);
305
+ }
306
+
307
+ /**
308
+ * Backwards-compatible alias for {@link countMessageTokensNativeO200k}.
309
+ * Existing callers treat this as the canonical message-token estimator for
310
+ * context-changing decisions.
311
+ */
312
+ export const estimateTokens = countMessageTokensNativeO200k;
313
+
314
+ /**
315
+ * Average bytes per token for the cheap heuristic. ~4 bytes/token is the
316
+ * conventional approximation for English/code text under modern BPE
317
+ * vocabularies; it intentionally errs slightly low-precision in exchange for
318
+ * never touching the native tokenizer (and its ~50MB BPE table).
319
+ */
320
+ const HEURISTIC_BYTES_PER_TOKEN = 4;
321
+
322
+ /**
323
+ * Cheap, native-free token estimate for a message. Suitable ONLY for
324
+ * display/init surfaces (status line, /context report, HUD totals) — never
325
+ * for context-changing decisions, which must use
326
+ * {@link countMessageTokensNativeO200k}.
327
+ */
328
+ export function estimateMessageTokensHeuristic(message: AgentMessage): number {
329
+ const { fragments, extra } = collectMessageFragments(message);
330
+ let bytes = 0;
331
+ for (const fragment of fragments) {
332
+ bytes += fragment.length;
333
+ }
334
+ return extra + Math.ceil(bytes / HEURISTIC_BYTES_PER_TOKEN);
335
+ }
336
+
337
+ /**
338
+ * Cheap, native-free token estimate for plain string fragments. Display-only
339
+ * counterpart of the native `countTokens(fragments)` aggregate.
340
+ */
341
+ export function estimateTextTokensHeuristic(fragments: string | readonly string[]): number {
342
+ if (typeof fragments === "string") return Math.ceil(fragments.length / HEURISTIC_BYTES_PER_TOKEN);
343
+ let bytes = 0;
344
+ for (const fragment of fragments) {
345
+ bytes += fragment.length;
346
+ }
347
+ return Math.ceil(bytes / HEURISTIC_BYTES_PER_TOKEN);
348
+ }
349
+
350
+ /** Shared content walk for both the native and heuristic estimators. */
351
+ function collectMessageFragments(message: AgentMessage): { fragments: string[]; extra: number } {
276
352
  const fragments: string[] = [];
277
353
  let extra = 0;
278
354
  if ((message as { role?: string }).role === "bashExecution") {
279
355
  const bash = message as { command?: unknown; output?: unknown };
280
356
  if (typeof bash.command === "string") fragments.push(bash.command);
281
357
  if (typeof bash.output === "string") fragments.push(bash.output);
282
- return fragments.length === 0 ? 0 : countTokens(fragments);
358
+ return { fragments, extra };
283
359
  }
284
360
 
285
361
  switch (message.role) {
@@ -331,11 +407,10 @@ export function estimateTokens(message: AgentMessage): number {
331
407
  break;
332
408
  }
333
409
  default:
334
- return 0;
410
+ break;
335
411
  }
336
412
 
337
- if (fragments.length === 0) return extra;
338
- return extra + countTokens(fragments);
413
+ return { fragments, extra };
339
414
  }
340
415
 
341
416
  function estimateEntriesTokens(entries: SessionEntry[], startIndex: number, endIndex: number): number {
@@ -1,8 +1,14 @@
1
1
  /**
2
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.
3
9
  */
4
10
 
5
- import type { ToolResultMessage } from "@gajae-code/ai";
11
+ import type { ToolCall, ToolResultMessage } from "@gajae-code/ai";
6
12
  import type { AgentMessage } from "../types";
7
13
  import { estimateTokens } from "./compaction";
8
14
  import type { SessionEntry, SessionMessageEntry } from "./entries";
@@ -14,17 +20,31 @@ export interface PruneConfig {
14
20
  minimumSavings: number;
15
21
  /** Tool names that should never be pruned. */
16
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[];
17
30
  }
18
31
 
19
32
  export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
20
33
  protectTokens: 40_000,
21
34
  minimumSavings: 20_000,
22
35
  protectedTools: ["skill", "read"],
36
+ staleOverridableTools: ["read"],
23
37
  };
24
38
 
25
39
  export interface PruneResult {
26
40
  prunedCount: number;
27
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[];
28
48
  }
29
49
 
30
50
  function createPrunedNotice(tokens: number): string {
@@ -43,11 +63,249 @@ function estimatePrunedSavings(tokens: number): number {
43
63
  return Math.max(0, tokens - noticeTokens);
44
64
  }
45
65
 
66
+ const EDIT_TOOL_NAMES = new Set(["edit", "write", "apply_patch", "ast_edit"]);
67
+
68
+ /** Extract the file-path argument from a tool call, when the tool has one. */
69
+ function toolCallPath(call: ToolCall): string | undefined {
70
+ const args = call.arguments;
71
+ const path = args.path ?? args.file_path ?? args.filePath;
72
+ return typeof path === "string" && path.length > 0 ? path : undefined;
73
+ }
74
+
75
+ /**
76
+ * `*** Add|Update|Delete File: <path>` headers open a hunk; `*** Move to:
77
+ * <path>` attaches a rename destination to the current hunk. Move
78
+ * destinations count as touched paths: a rename onto a file invalidates
79
+ * earlier reads of that destination.
80
+ */
81
+ const APPLY_PATCH_HEADER = /^\*\*\* (?:((?:Add|Update|Delete) File)|(Move to)): (.+)$/gm;
82
+
83
+ /**
84
+ * Paths touched by an edit-class tool call, grouped per hunk so a failed
85
+ * hunk can be excluded wholesale (its rename destination included). Most
86
+ * edit tools carry a single path argument; apply_patch envelopes carry an
87
+ * `input` string with per-file headers instead. The envelope shape can
88
+ * arrive under the custom `apply_patch` tool OR the regular `edit` tool
89
+ * (providers without custom-tool support fall back to the JSON function), so
90
+ * any edit-class call with a string `input` is parsed for headers.
91
+ */
92
+ function editToolPathGroups(call: ToolCall): string[][] {
93
+ const path = toolCallPath(call);
94
+ if (path !== undefined) return [[path]];
95
+ const input = call.arguments.input;
96
+ if (typeof input !== "string") return [];
97
+ const groups: string[][] = [];
98
+ for (const match of input.matchAll(APPLY_PATCH_HEADER)) {
99
+ const headerPath = match[3]?.trim();
100
+ if (!headerPath) continue;
101
+ const isMoveTo = match[2] !== undefined;
102
+ if (isMoveTo && groups.length > 0) {
103
+ groups[groups.length - 1].push(headerPath);
104
+ } else {
105
+ groups.push([headerPath]);
106
+ }
107
+ }
108
+ return groups;
109
+ }
110
+
111
+ /**
112
+ * Trailing read selectors (`:50`, `:50-200`, `:50+150`, `:5-16,960-973`,
113
+ * `:raw`, `:conflicts`), possibly stacked (`:2-4:raw`). Stripped to resolve
114
+ * the underlying file for edit invalidation.
115
+ */
116
+ const READ_SELECTOR_SUFFIX = /:(?:raw|conflicts|\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/;
117
+
118
+ /** Base file path of a read target with any line/mode selectors stripped. */
119
+ function readBasePath(path: string): string {
120
+ let base = path;
121
+ while (READ_SELECTOR_SUFFIX.test(base)) {
122
+ base = base.replace(READ_SELECTOR_SUFFIX, "");
123
+ }
124
+ return base;
125
+ }
126
+
127
+ /**
128
+ * Stable identity for "the same logical lookup": same tool re-targeting the
129
+ * same subject. A later result with the same key supersedes earlier ones.
130
+ * Keys are canonical JSON tuples so user-controlled text (patterns, paths)
131
+ * can never collide via delimiter ambiguity. Search keys include pagination
132
+ * (`skip`) and result-shaping flags (`i`, `gitignore`): a later page or a
133
+ * differently-shaped search complements earlier output, it does not replace it.
134
+ */
135
+ function toolTargetKey(call: ToolCall): string | undefined {
136
+ const path = toolCallPath(call);
137
+ if (path !== undefined) return JSON.stringify([call.name, "path", path]);
138
+ const pattern = call.arguments.pattern;
139
+ if (typeof pattern === "string" && pattern.length > 0) {
140
+ const paths = call.arguments.paths;
141
+ const pathList = Array.isArray(paths) ? paths.filter((p): p is string => typeof p === "string") : [];
142
+ const skip = typeof call.arguments.skip === "number" ? call.arguments.skip : 0;
143
+ const caseInsensitive = call.arguments.i === true;
144
+ const gitignore = call.arguments.gitignore !== false;
145
+ return JSON.stringify([call.name, "pattern", pattern, pathList, skip, caseInsensitive, gitignore]);
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ /**
151
+ * Files actually mutated according to a tool result's details. Used for
152
+ * AST-edit-shaped results (`ast_edit` direct-apply and the hidden `resolve`
153
+ * apply step), which report `{ applied: true, files: [...] }` — the resolve
154
+ * tool nests that payload under `details.sourceResultDetails`. Conservative:
155
+ * returns nothing unless the details explicitly mark the change as applied.
156
+ * Checked even on `isError` results: a stale-preview apply reports an error
157
+ * while still having mutated the listed files.
158
+ */
159
+ function resultDetailFiles(message: ToolResultMessage): string[] {
160
+ const raw = message.details as { applied?: unknown; files?: unknown; sourceResultDetails?: unknown } | undefined;
161
+ const candidates = [raw, raw?.sourceResultDetails as { applied?: unknown; files?: unknown } | undefined];
162
+ for (const details of candidates) {
163
+ if (details?.applied === true && Array.isArray(details.files)) {
164
+ return details.files.filter((file): file is string => typeof file === "string" && file.length > 0);
165
+ }
166
+ }
167
+ return [];
168
+ }
169
+
170
+ /**
171
+ * Paths that FAILED in a per-file edit result (`details.perFileResults`) and
172
+ * were NOT mutated by any same-path entry. Multi-file apply_patch catches
173
+ * per-file failures and still returns a non-error result; a purely-failed
174
+ * path was not mutated and must not stale reads. But apply_patch can emit
175
+ * multiple entries for the same path (e.g. several hunks): if any same-path
176
+ * entry succeeded the file still mutated, so it must NOT be suppressed.
177
+ * Conservative: only an entry explicitly marked `isError === true` counts as
178
+ * a failure; anything else (including ambiguous/malformed entries) counts as
179
+ * a success and keeps the path out of the suppression set.
180
+ */
181
+ function failedEditPaths(message: ToolResultMessage): Set<string> {
182
+ const details = message.details as { perFileResults?: unknown } | undefined;
183
+ const perFile = details?.perFileResults;
184
+ if (!Array.isArray(perFile)) return new Set();
185
+ const failed = new Set<string>();
186
+ const succeeded = new Set<string>();
187
+ for (const item of perFile) {
188
+ const entry = item as { path?: unknown; isError?: unknown };
189
+ if (typeof entry?.path !== "string") continue;
190
+ if (entry.isError === true) failed.add(entry.path);
191
+ else succeeded.add(entry.path);
192
+ }
193
+ // A path mutated if any same-path entry succeeded, even when another
194
+ // same-path entry failed; drop those from the suppression set.
195
+ for (const path of succeeded) failed.delete(path);
196
+ return failed;
197
+ }
198
+
199
+ /**
200
+ * Concrete file path a `read` result actually came from, when the tool
201
+ * reported one (`details.resolvedPath`). Suffix resolution can map a bare
202
+ * filename argument onto a different concrete path.
203
+ */
204
+ function readResolvedPath(message: ToolResultMessage): string | undefined {
205
+ const details = message.details as { resolvedPath?: unknown } | undefined;
206
+ const resolved = details?.resolvedPath;
207
+ return typeof resolved === "string" && resolved.length > 0 ? resolved : undefined;
208
+ }
209
+
210
+ interface StalenessIndex {
211
+ /** Entry indices of toolResults superseded by a later same-target result or a later edit. */
212
+ staleResultIndices: Set<number>;
213
+ }
214
+
215
+ /**
216
+ * Build a staleness index over session entries (oldest -> newest):
217
+ * - a toolResult is stale when a later non-error toolResult shares its target key;
218
+ * - a `read` result is stale when a later non-error edit/write touches its file.
219
+ * The most recent result per target is never stale.
220
+ */
221
+ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
222
+ const callsById = new Map<string, ToolCall>();
223
+ for (const entry of entries) {
224
+ if (entry.type !== "message") continue;
225
+ const message = entry.message as AgentMessage;
226
+ if (message.role !== "assistant") continue;
227
+ for (const content of message.content) {
228
+ if (content.type === "toolCall") callsById.set(content.id, content);
229
+ }
230
+ }
231
+
232
+ const lastResultIndexByKey = new Map<string, number>();
233
+ const resultMeta = new Map<number, { key?: string; call: ToolCall; message: ToolResultMessage }>();
234
+ const lastEditIndexByPath = new Map<string, number>();
235
+
236
+ for (let i = 0; i < entries.length; i++) {
237
+ const message = getToolResultMessage(entries[i]);
238
+ if (!message) continue;
239
+ const call = callsById.get(message.toolCallId);
240
+ if (!call) continue;
241
+
242
+ // AST edits mutate files when previews are applied via the hidden
243
+ // `resolve` tool; the call args carry globs, not concrete paths. Both
244
+ // tools report actually-touched files in result details. Collected
245
+ // BEFORE the error gate: a stale-preview apply reports an error while
246
+ // still having mutated the listed files.
247
+ if (call.name === "resolve" || call.name === "ast_edit") {
248
+ for (const editPath of resultDetailFiles(message)) {
249
+ lastEditIndexByPath.set(editPath, i);
250
+ }
251
+ }
252
+ if (message.isError) continue;
253
+
254
+ const key = toolTargetKey(call);
255
+ resultMeta.set(i, { key, call, message });
256
+ if (key !== undefined) lastResultIndexByKey.set(key, i);
257
+ if (EDIT_TOOL_NAMES.has(call.name)) {
258
+ // Per-file edit results record failures in details.perFileResults;
259
+ // a failed hunk mutated nothing, so exclude its whole path group
260
+ // (rename destination included) from touched paths.
261
+ const failed = failedEditPaths(message);
262
+ for (const group of editToolPathGroups(call)) {
263
+ if (group.some(groupPath => failed.has(groupPath))) continue;
264
+ for (const editPath of group) {
265
+ lastEditIndexByPath.set(editPath, i);
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ const staleResultIndices = new Set<number>();
272
+ for (const [index, meta] of resultMeta) {
273
+ if (meta.key !== undefined) {
274
+ const lastIndex = lastResultIndexByKey.get(meta.key);
275
+ if (lastIndex !== undefined && lastIndex > index) {
276
+ staleResultIndices.add(index);
277
+ continue;
278
+ }
279
+ }
280
+ if (meta.call.name === "read") {
281
+ // Check both the call argument (selectors stripped) and the resolved
282
+ // path from result details: suffix resolution can map a bare filename
283
+ // onto a different concrete path, and edits may use either form.
284
+ const lookupPaths = new Set<string>();
285
+ const argPath = toolCallPath(meta.call);
286
+ if (argPath !== undefined) lookupPaths.add(readBasePath(argPath));
287
+ const resolved = readResolvedPath(meta.message);
288
+ if (resolved !== undefined) lookupPaths.add(resolved);
289
+ for (const lookupPath of lookupPaths) {
290
+ const editIndex = lastEditIndexByPath.get(lookupPath);
291
+ if (editIndex !== undefined && editIndex > index) {
292
+ staleResultIndices.add(index);
293
+ break;
294
+ }
295
+ }
296
+ }
297
+ }
298
+
299
+ return { staleResultIndices };
300
+ }
301
+
46
302
  export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
47
303
  let accumulatedTokens = 0;
48
304
  let tokensSaved = 0;
49
305
  let prunedCount = 0;
50
306
 
307
+ const { staleResultIndices } = buildStalenessIndex(entries);
308
+ const staleOverridable = new Set(config.staleOverridableTools ?? []);
51
309
  const candidates: Array<{ entry: SessionMessageEntry; tokens: number }> = [];
52
310
 
53
311
  for (let i = entries.length - 1; i >= 0; i--) {
@@ -56,14 +314,24 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
56
314
  if (!message) continue;
57
315
 
58
316
  const tokens = estimateTokens(message as AgentMessage);
59
- const isProtected = config.protectedTools.includes(message.toolName);
317
+ const isStale = staleResultIndices.has(i);
318
+ // Staleness waives protected-tool immunity for overridable tools
319
+ // (e.g. a superseded `read`); the most recent result per target is
320
+ // never stale, so the latest read of each file stays protected.
321
+ const isProtected =
322
+ config.protectedTools.includes(message.toolName) && !(isStale && staleOverridable.has(message.toolName));
60
323
 
61
324
  if (message.prunedAt !== undefined) {
62
325
  accumulatedTokens += tokens;
63
326
  continue;
64
327
  }
65
328
 
66
- if (accumulatedTokens < config.protectTokens || isProtected) {
329
+ // Stale results are prunable even inside the recency protect window —
330
+ // they are superseded, so recency no longer implies relevance. They
331
+ // still count toward window accounting so non-stale protection is
332
+ // unchanged.
333
+ const insideProtectWindow = accumulatedTokens < config.protectTokens;
334
+ if ((insideProtectWindow && !isStale) || isProtected) {
67
335
  accumulatedTokens += tokens;
68
336
  continue;
69
337
  }
@@ -77,16 +345,18 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
77
345
  }
78
346
 
79
347
  if (tokensSaved < config.minimumSavings || candidates.length === 0) {
80
- return { prunedCount: 0, tokensSaved: 0 };
348
+ return { prunedCount: 0, tokensSaved: 0, prunedEntries: [] };
81
349
  }
82
350
 
83
351
  const prunedAt = Date.now();
352
+ const prunedEntries: SessionMessageEntry[] = [];
84
353
  for (const candidate of candidates) {
85
354
  const message = candidate.entry.message as ToolResultMessage;
86
355
  message.content = [{ type: "text", text: createPrunedNotice(candidate.tokens) }];
87
356
  message.prunedAt = prunedAt;
357
+ prunedEntries.push(candidate.entry);
88
358
  prunedCount++;
89
359
  }
90
360
 
91
- return { prunedCount, tokensSaved };
361
+ return { prunedCount, tokensSaved, prunedEntries };
92
362
  }