@shanepadgett/tau-agent 0.16.0 → 0.17.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 (55) hide show
  1. package/docs/subagents.md +25 -6
  2. package/extensions/attention/README.md +1 -0
  3. package/extensions/attention/index.ts +38 -0
  4. package/extensions/context/README.md +13 -3
  5. package/extensions/context/definitions.ts +3 -15
  6. package/extensions/context/evidence.ts +516 -0
  7. package/extensions/context/index.ts +114 -116
  8. package/extensions/context/panel.ts +60 -0
  9. package/extensions/context/settings.ts +30 -1
  10. package/extensions/context/sync.ts +171 -686
  11. package/extensions/context/validation.ts +4 -1
  12. package/extensions/context/write-scope.ts +109 -0
  13. package/extensions/context-pruning/README.md +22 -0
  14. package/extensions/context-pruning/file-evidence.ts +265 -0
  15. package/extensions/context-pruning/index.ts +379 -0
  16. package/extensions/context-pruning/projection.ts +108 -0
  17. package/extensions/context-pruning/prune.ts +346 -0
  18. package/extensions/context-pruning/render.ts +179 -0
  19. package/extensions/context-pruning/settings.ts +41 -0
  20. package/extensions/explore/README.md +3 -1
  21. package/extensions/explore/autoread.ts +85 -22
  22. package/extensions/explore/full-file-knowledge.ts +234 -0
  23. package/extensions/explore/index.ts +2 -3
  24. package/extensions/explore/read-cache.ts +150 -86
  25. package/extensions/explore/read-snapshots.ts +17 -4
  26. package/extensions/explore/read.ts +57 -38
  27. package/extensions/footer/index.ts +62 -60
  28. package/extensions/run-summary/index.ts +5 -5
  29. package/extensions/silent-command-runner/README.md +1 -1
  30. package/extensions/silent-command-runner/index.ts +45 -28
  31. package/extensions/soul/prompt.ts +3 -1
  32. package/extensions/subagent/README.md +21 -5
  33. package/extensions/subagent/agents/context-sync.md +90 -0
  34. package/extensions/subagent/agents/{generalist.md → dormant/generalist.md} +6 -0
  35. package/extensions/subagent/agents/{scout.md → dormant/scout.md} +6 -0
  36. package/extensions/subagent/agents/review.md +48 -0
  37. package/extensions/subagent/agents/web-research.md +6 -0
  38. package/extensions/subagent/agents.ts +15 -2
  39. package/extensions/subagent/cmux-dashboard.ts +454 -0
  40. package/extensions/subagent/index.ts +181 -234
  41. package/extensions/subagent/render.ts +1 -1
  42. package/extensions/subagent/resume.ts +78 -0
  43. package/extensions/subagent/run.ts +213 -118
  44. package/extensions/subagent/runtime.ts +856 -0
  45. package/extensions/subagent/session-resource.ts +169 -0
  46. package/extensions/tau-help/help.md +6 -2
  47. package/extensions/turn-budget/index.ts +8 -36
  48. package/package.json +2 -2
  49. package/schemas/tau.schema.json +48 -1
  50. package/shared/context-pruning-state.ts +364 -0
  51. package/shared/events.ts +18 -0
  52. package/shared/model-fallback/index.ts +21 -10
  53. package/shared/model-fallback/types.ts +5 -3
  54. package/shared/settings/load.ts +78 -1
  55. package/shared/tool-row-state.ts +21 -1
@@ -1,12 +1,16 @@
1
- import { resolve } from "node:path";
2
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { resolve } from "node:path";
3
+ import { replayContextPruningState } from "../../shared/context-pruning-state.ts";
4
+ import { applyCompleteFileDiff, COMPLETE_FILE_SCOPE, decodeCompleteFileSource } from "./full-file-knowledge.ts";
3
5
 
4
6
  export type ReadCacheMode = "baseline" | "recovery" | "unchanged" | "diff";
7
+ export type ReadCachePresentation = "plain" | "line-numbered";
5
8
 
6
9
  export interface ReadCacheMetaV1 {
7
10
  v: 1;
8
11
  pathKey: string;
9
12
  scopeKey: string;
13
+ presentation: ReadCachePresentation;
10
14
  servedHash: string;
11
15
  baseHash?: string;
12
16
  mode: ReadCacheMode;
@@ -16,70 +20,68 @@ export interface ReadCacheMetaV1 {
16
20
  summary: string;
17
21
  }
18
22
 
19
- interface ScopeTrust {
23
+ export interface ReadCacheScopeTrust {
20
24
  hash: string;
21
- sequence: number;
25
+ text?: string;
26
+ rowIds: readonly string[];
27
+ }
28
+
29
+ export interface AcceptedReadCacheRow {
30
+ rowId: string;
31
+ pathKey: string;
32
+ scopeKey: string;
33
+ meta: ReadCacheMetaV1;
34
+ dependencyRowIds: readonly string[];
22
35
  }
23
36
 
24
- interface ReplayState {
25
- trust: Map<string, Map<string, ScopeTrust>>;
26
- unlockedPaths: Set<string>;
27
- sequence: number;
37
+ export interface CompleteFileDependencyChain {
38
+ pathKey: string;
39
+ servedHash: string;
40
+ rowIds: readonly string[];
41
+ sourceText: string;
28
42
  }
29
43
 
30
- interface OverlayState {
31
- trust: Map<string, Map<string, ScopeTrust>>;
32
- sequence: number;
44
+ export interface ReadCacheReplayResult {
45
+ scopeTrust: ReadonlyMap<string, ReadonlyMap<string, ReadCacheScopeTrust>>;
46
+ acceptedRows: readonly AcceptedReadCacheRow[];
47
+ completeFileChains: ReadonlyMap<string, CompleteFileDependencyChain>;
48
+ failedPatchRecoveryPaths: ReadonlySet<string>;
33
49
  }
34
50
 
35
51
  export interface ReadCacheDecision {
36
52
  baseHash?: string;
53
+ baselineText?: string;
37
54
  recovery: boolean;
38
55
  }
39
56
 
40
57
  export interface ReadCacheStore {
41
58
  decision(ctx: ExtensionContext, pathKey: string, scopeKey: string): ReadCacheDecision;
42
- record(ctx: ExtensionContext, meta: ReadCacheMetaV1): void;
43
- clear(): void;
44
59
  }
45
60
 
46
61
  export function createReadCacheStore(): ReadCacheStore {
47
- const overlays = new Map<string, OverlayState>();
48
-
49
62
  return {
50
63
  decision(ctx, pathKey, scopeKey) {
51
- const state = replay(ctx);
52
- const overlay = overlays.get(overlayKey(ctx));
53
- if (overlay) mergeTrust(state, overlay.trust);
64
+ const manager = ctx.sessionManager;
65
+ if (!manager) {
66
+ return { baseHash: undefined, baselineText: undefined, recovery: false };
67
+ }
68
+ const pruning = replayContextPruningState(manager.getBranch());
69
+ const ignoredRowIds = new Set([...pruning.prunedToolCallIds, ...pruning.prunedAutoreadRowIds]);
70
+ const state = replayReadCache(manager.buildContextEntries(), ctx.cwd, ignoredRowIds);
71
+ const trust = state.scopeTrust.get(pathKey)?.get(scopeKey);
54
72
  return {
55
- baseHash: state.trust.get(pathKey)?.get(scopeKey)?.hash,
56
- recovery: state.unlockedPaths.has(pathKey),
73
+ baseHash: trust?.hash,
74
+ baselineText: trust?.text,
75
+ recovery: state.failedPatchRecoveryPaths.has(pathKey),
57
76
  };
58
77
  },
59
- record(ctx, meta) {
60
- const key = overlayKey(ctx);
61
- let overlay = overlays.get(key);
62
- if (!overlay) {
63
- overlay = { trust: new Map(), sequence: 1_000_000_000 };
64
- overlays.set(key, overlay);
65
- }
66
- overlay.sequence += 1;
67
- setTrust(overlay.trust, meta.pathKey, meta.scopeKey, meta.servedHash, overlay.sequence);
68
- while (overlays.size > 32) {
69
- const oldest = overlays.keys().next().value as string | undefined;
70
- if (oldest === undefined) break;
71
- overlays.delete(oldest);
72
- }
73
- },
74
- clear() {
75
- overlays.clear();
76
- },
77
78
  };
78
79
  }
79
80
 
80
81
  function parseReadCacheMeta(value: unknown): ReadCacheMetaV1 | undefined {
81
82
  if (!isRecord(value) || value.v !== 1) return undefined;
82
83
  if (typeof value.pathKey !== "string" || typeof value.scopeKey !== "string") return undefined;
84
+ if (value.presentation !== "plain" && value.presentation !== "line-numbered") return undefined;
83
85
  if (typeof value.servedHash !== "string" || value.servedHash.length === 0) return undefined;
84
86
  if (!isReadCacheMode(value.mode)) return undefined;
85
87
  if (!isNonNegativeFinite(value.baselineTokens) || !isNonNegativeFinite(value.returnedTokens)) return undefined;
@@ -90,6 +92,7 @@ function parseReadCacheMeta(value: unknown): ReadCacheMetaV1 | undefined {
90
92
  v: 1,
91
93
  pathKey: value.pathKey,
92
94
  scopeKey: value.scopeKey,
95
+ presentation: value.presentation,
93
96
  servedHash: value.servedHash,
94
97
  baseHash: value.baseHash,
95
98
  mode: value.mode,
@@ -110,41 +113,120 @@ export function readMetaFromMessage(message: unknown): ReadCacheMetaV1 | undefin
110
113
  return parseReadCacheMeta(message.details.readCache);
111
114
  }
112
115
 
113
- function replay(ctx: ExtensionContext): ReplayState {
114
- const state: ReplayState = { trust: new Map(), unlockedPaths: new Set(), sequence: 0 };
115
- const manager = ctx.sessionManager;
116
- if (!manager) return state;
117
- const branch = manager.getBranch();
118
- let start = 0;
119
- for (let index = 0; index < branch.length; index += 1) {
120
- const entry = branch[index];
121
- if (isRecord(entry) && entry.type === "compaction") start = index + 1;
122
- }
123
-
124
- for (const entry of branch.slice(start)) {
125
- if (!isRecord(entry) || entry.type !== "message" || !("message" in entry)) continue;
126
- const message = entry.message;
127
- const meta = readMetaFromMessage(message);
128
- if (meta) {
129
- state.sequence += 1;
130
- applyReadMeta(state, meta);
116
+ export function replayReadCache(
117
+ entries: readonly unknown[],
118
+ cwd: string,
119
+ ignoredRowIds: ReadonlySet<string> = new Set(),
120
+ ): ReadCacheReplayResult {
121
+ const trust = new Map<string, Map<string, ReadCacheScopeTrust>>();
122
+ const acceptedRows: AcceptedReadCacheRow[] = [];
123
+ const failedPatchRecoveryPaths = new Set<string>();
124
+ for (const entry of entries) {
125
+ if (!isRecord(entry)) continue;
126
+ let message: unknown;
127
+ if (entry.type === "message" && "message" in entry) message = entry.message;
128
+ else if (entry.type === "custom_message") message = entry;
129
+ else continue;
130
+ const parsedMeta = readMetaFromMessage(message);
131
+ if (parsedMeta) {
132
+ const rowId = readRowId(message);
133
+ if (rowId && ignoredRowIds.has(rowId)) continue;
134
+ const pathKey = resolve(cwd, parsedMeta.pathKey);
135
+ const meta = pathKey === parsedMeta.pathKey ? parsedMeta : { ...parsedMeta, pathKey };
136
+ if (rowId && applyReadMeta(trust, failedPatchRecoveryPaths, message, meta, rowId)) {
137
+ const acceptedTrust = trust.get(meta.pathKey)?.get(meta.scopeKey);
138
+ if (acceptedTrust) {
139
+ acceptedRows.push({
140
+ rowId,
141
+ pathKey: meta.pathKey,
142
+ scopeKey: meta.scopeKey,
143
+ meta,
144
+ dependencyRowIds: [...acceptedTrust.rowIds],
145
+ });
146
+ }
147
+ }
131
148
  continue;
132
149
  }
133
- for (const path of failedPatchPaths(message, ctx.cwd)) state.unlockedPaths.add(path);
150
+ for (const path of failedPatchPaths(message, cwd)) failedPatchRecoveryPaths.add(path);
151
+ }
152
+ const completeFileChains = new Map<string, CompleteFileDependencyChain>();
153
+ for (const [pathKey, scopes] of trust) {
154
+ const complete = scopes.get(COMPLETE_FILE_SCOPE);
155
+ if (!complete || complete.text === undefined) continue;
156
+ completeFileChains.set(pathKey, {
157
+ pathKey,
158
+ servedHash: complete.hash,
159
+ rowIds: [...complete.rowIds],
160
+ sourceText: complete.text,
161
+ });
134
162
  }
135
- return state;
163
+ return { scopeTrust: trust, acceptedRows, completeFileChains, failedPatchRecoveryPaths };
136
164
  }
137
165
 
138
- function applyReadMeta(state: ReplayState, meta: ReadCacheMetaV1): void {
139
- const existing = state.trust.get(meta.pathKey)?.get(meta.scopeKey);
166
+ function applyReadMeta(
167
+ trust: Map<string, Map<string, ReadCacheScopeTrust>>,
168
+ failedPatchRecoveryPaths: Set<string>,
169
+ message: unknown,
170
+ meta: ReadCacheMetaV1,
171
+ rowId: string,
172
+ ): boolean {
173
+ const existing = trust.get(meta.pathKey)?.get(meta.scopeKey);
140
174
  if (meta.mode === "baseline" || meta.mode === "recovery") {
141
- setTrust(state.trust, meta.pathKey, meta.scopeKey, meta.servedHash, state.sequence);
142
- state.unlockedPaths.delete(meta.pathKey);
143
- return;
175
+ let text: string | undefined;
176
+ if (meta.scopeKey === COMPLETE_FILE_SCOPE) {
177
+ if (!isRecord(message)) return false;
178
+ let pathHeader: string | undefined;
179
+ if (message.customType === "tau.autoread") {
180
+ if (!isRecord(message.details)) return false;
181
+ if (typeof message.details.path !== "string" || typeof message.details.cwd !== "string") return false;
182
+ if (resolve(message.details.cwd, message.details.path) !== meta.pathKey) return false;
183
+ pathHeader = message.details.path;
184
+ }
185
+ const decoded = decodeCompleteFileSource({
186
+ content: message.content,
187
+ autoread: message.customType === "tau.autoread",
188
+ ...(pathHeader === undefined ? {} : { pathHeader }),
189
+ presentation: meta.presentation,
190
+ servedHash: meta.servedHash,
191
+ });
192
+ if (!decoded.valid || decoded.text === undefined) return false;
193
+ text = decoded.text;
194
+ }
195
+ setTrust(trust, meta.pathKey, meta.scopeKey, { hash: meta.servedHash, text, rowIds: [rowId] });
196
+ failedPatchRecoveryPaths.delete(meta.pathKey);
197
+ return true;
198
+ }
199
+ if (!meta.baseHash || existing?.hash !== meta.baseHash) return false;
200
+ if (meta.mode === "unchanged" && meta.servedHash !== meta.baseHash) return false;
201
+ let text = meta.mode === "unchanged" ? existing.text : undefined;
202
+ if (meta.scopeKey === COMPLETE_FILE_SCOPE && meta.mode === "diff") {
203
+ if (existing.text === undefined) return false;
204
+ const decoded = applyCompleteFileDiff({
205
+ content: isRecord(message) ? message.content : undefined,
206
+ baseText: existing.text,
207
+ baseHash: meta.baseHash,
208
+ servedHash: meta.servedHash,
209
+ });
210
+ if (!decoded.valid || decoded.text === undefined) return false;
211
+ text = decoded.text;
212
+ }
213
+ setTrust(trust, meta.pathKey, meta.scopeKey, {
214
+ hash: meta.servedHash,
215
+ text,
216
+ rowIds: [...existing.rowIds, rowId],
217
+ });
218
+ return true;
219
+ }
220
+
221
+ function readRowId(message: unknown): string | undefined {
222
+ if (!isRecord(message)) return undefined;
223
+ if (message.role === "toolResult" && message.toolName === "read") {
224
+ return typeof message.toolCallId === "string" && message.toolCallId.length > 0 ? message.toolCallId : undefined;
144
225
  }
145
- if (!meta.baseHash || existing?.hash !== meta.baseHash) return;
146
- if (meta.mode === "unchanged" && meta.servedHash !== meta.baseHash) return;
147
- setTrust(state.trust, meta.pathKey, meta.scopeKey, meta.servedHash, state.sequence);
226
+ if (message.customType !== "tau.autoread" || !isRecord(message.details)) return undefined;
227
+ return typeof message.details.rowId === "string" && message.details.rowId.length > 0
228
+ ? message.details.rowId
229
+ : undefined;
148
230
  }
149
231
 
150
232
  function failedPatchPaths(message: unknown, cwd: string): string[] {
@@ -165,35 +247,17 @@ function failedPatchPaths(message: unknown, cwd: string): string[] {
165
247
  }
166
248
 
167
249
  function setTrust(
168
- trust: Map<string, Map<string, ScopeTrust>>,
250
+ trust: Map<string, Map<string, ReadCacheScopeTrust>>,
169
251
  pathKey: string,
170
252
  scopeKey: string,
171
- hash: string,
172
- sequence: number,
253
+ value: ReadCacheScopeTrust,
173
254
  ): void {
174
255
  let scopes = trust.get(pathKey);
175
256
  if (!scopes) {
176
257
  scopes = new Map();
177
258
  trust.set(pathKey, scopes);
178
259
  }
179
- scopes.set(scopeKey, { hash, sequence });
180
- }
181
-
182
- function mergeTrust(state: ReplayState, overlay: Map<string, Map<string, ScopeTrust>>): void {
183
- for (const [pathKey, scopes] of overlay) {
184
- for (const [scopeKey, candidate] of scopes) {
185
- const current = state.trust.get(pathKey)?.get(scopeKey);
186
- if (!current || candidate.sequence > current.sequence) {
187
- setTrust(state.trust, pathKey, scopeKey, candidate.hash, candidate.sequence);
188
- }
189
- }
190
- }
191
- }
192
-
193
- function overlayKey(ctx: ExtensionContext): string {
194
- const manager = ctx.sessionManager;
195
- if (!manager) return `test:${ctx.cwd}`;
196
- return `${manager.getSessionId()}:${manager.getLeafId() ?? "root"}`;
260
+ scopes.set(scopeKey, value);
197
261
  }
198
262
 
199
263
  function isReadCacheMode(value: unknown): value is ReadCacheMode {
@@ -1,4 +1,5 @@
1
- const MAX_SNAPSHOT_BYTES = 1024 * 1024;
1
+ import { MAX_COMPLETE_FILE_SNAPSHOT_BYTES } from "./full-file-knowledge.ts";
2
+
2
3
  const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
3
4
 
4
5
  interface Snapshot {
@@ -8,13 +9,16 @@ interface Snapshot {
8
9
 
9
10
  export interface ReadSnapshotStore {
10
11
  get(hash: string): string | undefined;
11
- set(hash: string, text: string, bytes: number): void;
12
+ epoch(): number;
13
+ isCurrent(epoch: number): boolean;
14
+ set(hash: string, text: string, bytes: number, epoch: number): boolean;
12
15
  clear(): void;
13
16
  }
14
17
 
15
18
  export function createReadSnapshotStore(): ReadSnapshotStore {
16
19
  const snapshots = new Map<string, Snapshot>();
17
20
  let totalBytes = 0;
21
+ let epoch = 0;
18
22
 
19
23
  return {
20
24
  get(hash) {
@@ -24,8 +28,15 @@ export function createReadSnapshotStore(): ReadSnapshotStore {
24
28
  snapshots.set(hash, snapshot);
25
29
  return snapshot.text;
26
30
  },
27
- set(hash, text, bytes) {
28
- if (bytes > MAX_SNAPSHOT_BYTES || snapshots.has(hash)) return;
31
+ epoch() {
32
+ return epoch;
33
+ },
34
+ isCurrent(candidate) {
35
+ return candidate === epoch;
36
+ },
37
+ set(hash, text, bytes, candidateEpoch) {
38
+ if (candidateEpoch !== epoch || bytes > MAX_COMPLETE_FILE_SNAPSHOT_BYTES) return false;
39
+ if (snapshots.has(hash)) return true;
29
40
  snapshots.set(hash, { text, bytes });
30
41
  totalBytes += bytes;
31
42
  while (totalBytes > MAX_TOTAL_BYTES) {
@@ -35,10 +46,12 @@ export function createReadSnapshotStore(): ReadSnapshotStore {
35
46
  snapshots.delete(oldestHash);
36
47
  totalBytes -= oldest?.bytes ?? 0;
37
48
  }
49
+ return true;
38
50
  },
39
51
  clear() {
40
52
  snapshots.clear();
41
53
  totalBytes = 0;
54
+ epoch += 1;
42
55
  },
43
56
  };
44
57
  }
@@ -2,7 +2,6 @@ import {
2
2
  createReadToolDefinition,
3
3
  DEFAULT_MAX_BYTES,
4
4
  formatSize,
5
- generateUnifiedPatch,
6
5
  truncateHead,
7
6
  type ReadToolDetails,
8
7
  type ToolDefinition,
@@ -13,9 +12,20 @@ import { readFile } from "node:fs/promises";
13
12
  import { isAbsolute, resolve } from "node:path";
14
13
  import { Type, type Static } from "typebox";
15
14
  import { formatToolRowTitle, type ToolRowStateStore } from "../../shared/tool-row-state.js";
15
+ import {
16
+ createCompleteFileMeta,
17
+ estimateTokens,
18
+ readScopeKey,
19
+ selectCompleteFileResponse,
20
+ } from "./full-file-knowledge.ts";
16
21
  import { normalizeCountLimit } from "./limits.ts";
17
22
  import { stripLeadingAt } from "./path-display.ts";
18
- import { createReadCacheStore, type ReadCacheMetaV1, type ReadCacheStore } from "./read-cache.ts";
23
+ import {
24
+ createReadCacheStore,
25
+ type ReadCacheMetaV1,
26
+ type ReadCachePresentation,
27
+ type ReadCacheStore,
28
+ } from "./read-cache.ts";
19
29
  import { createReadSnapshotStore, type ReadSnapshotStore } from "./read-snapshots.ts";
20
30
 
21
31
  const readSchema = Type.Object({
@@ -43,6 +53,7 @@ interface BaselineTextResult {
43
53
  endLine: number;
44
54
  completeFile: boolean;
45
55
  scopeKey: string;
56
+ presentation: ReadCachePresentation;
46
57
  summary: string;
47
58
  cacheable: boolean;
48
59
  }
@@ -86,10 +97,6 @@ function renderCallSummary(args: ReadToolInput | undefined): string {
86
97
  return `${path}:${start}${end === "" ? "" : `-${end}`}`;
87
98
  }
88
99
 
89
- function estimateTokens(text: string): number {
90
- return Math.ceil(text.length / 4);
91
- }
92
-
93
100
  function baselineText(text: string, params: ReadToolInput): BaselineTextResult {
94
101
  const allLines = text.split("\n");
95
102
  const startIndex = params.offset ? Math.max(0, params.offset - 1) : 0;
@@ -134,7 +141,8 @@ function baselineText(text: string, params: ReadToolInput): BaselineTextResult {
134
141
 
135
142
  const endLine = outputLines === 0 ? startLine : startLine + outputLines - 1;
136
143
  const completeFile = startIndex === 0 && selectedEnd === allLines.length && !truncation.truncated;
137
- const scopeKey = `${completeFile ? "full" : `r:${startLine}:${endLine}`}:n${params.lineNumbers ? 1 : 0}`;
144
+ const presentation: ReadCachePresentation = params.lineNumbers ? "line-numbered" : "plain";
145
+ const scopeKey = readScopeKey(completeFile, startLine, endLine, presentation);
138
146
  const summary = completeFile ? `${allLines.length} lines` : `${outputLines} lines`;
139
147
  return {
140
148
  text: outputText,
@@ -144,6 +152,7 @@ function baselineText(text: string, params: ReadToolInput): BaselineTextResult {
144
152
  endLine,
145
153
  completeFile,
146
154
  scopeKey,
155
+ presentation,
147
156
  summary,
148
157
  cacheable,
149
158
  };
@@ -165,10 +174,24 @@ function createMeta(
165
174
  baseHash?: string,
166
175
  summary = baseline.summary,
167
176
  ): ReadCacheMetaV1 {
177
+ if (baseline.completeFile) {
178
+ return createCompleteFileMeta({
179
+ pathKey,
180
+ presentation: baseline.presentation,
181
+ servedHash: hash,
182
+ mode,
183
+ sourceText: baseline.text,
184
+ returnedText,
185
+ totalLines: baseline.totalLines,
186
+ summary,
187
+ baseHash,
188
+ });
189
+ }
168
190
  return {
169
191
  v: 1,
170
192
  pathKey,
171
193
  scopeKey: baseline.scopeKey,
194
+ presentation: baseline.presentation,
172
195
  servedHash: hash,
173
196
  baseHash,
174
197
  mode,
@@ -179,16 +202,6 @@ function createMeta(
179
202
  };
180
203
  }
181
204
 
182
- function countDiffLines(patch: string): { added: number; removed: number } {
183
- let added = 0;
184
- let removed = 0;
185
- for (const line of patch.split("\n")) {
186
- if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
187
- else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
188
- }
189
- return { added, removed };
190
- }
191
-
192
205
  export function createExploreReadTool(
193
206
  rowState: ToolRowStateStore,
194
207
  cache: ReadCacheStore = createReadCacheStore(),
@@ -197,6 +210,8 @@ export function createExploreReadTool(
197
210
  const baseDefinition = readDefinitionForCwd(process.cwd());
198
211
  return {
199
212
  ...baseDefinition,
213
+ description:
214
+ "Read a relevant file as a whole by omitting offset, limit, and lineNumbers. Later whole-file reads avoid repeating unchanged content and return useful diffs or current source after changes. Use ranges only for targeted peripheral inspection or to continue after truncation.",
200
215
  parameters: readSchema,
201
216
  async execute(
202
217
  toolCallId: Parameters<ReadExecute>[0],
@@ -208,6 +223,7 @@ export function createExploreReadTool(
208
223
  const definition = readDefinitionForCwd(ctx.cwd);
209
224
  const normalized = normalizeReadParams(params);
210
225
  const path = isAbsolute(normalized.path) ? resolve(normalized.path) : resolve(ctx.cwd, normalized.path);
226
+ const snapshotEpoch = snapshots.epoch();
211
227
  const buffer = await readFile(path);
212
228
  if (isSupportedImage(buffer)) {
213
229
  return definition.execute(
@@ -222,7 +238,7 @@ export function createExploreReadTool(
222
238
  if (signal?.aborted) throw new Error("Operation aborted");
223
239
  let text: string;
224
240
  try {
225
- text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
241
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer);
226
242
  } catch {
227
243
  return definition.execute(toolCallId, normalized, signal, onUpdate, ctx);
228
244
  }
@@ -232,36 +248,39 @@ export function createExploreReadTool(
232
248
  return { content: [{ type: "text", text: baseline.text }], details: baseline.details };
233
249
  }
234
250
  const hash = createHash("sha256").update(buffer).digest("hex");
235
- const decision = cache.decision(ctx, path, baseline.scopeKey);
251
+ const staleEpoch = !snapshots.isCurrent(snapshotEpoch);
252
+ const decision = staleEpoch
253
+ ? { baseHash: undefined, baselineText: undefined, recovery: false }
254
+ : cache.decision(ctx, path, baseline.scopeKey);
236
255
  let output = baseline.text;
237
256
  let mode: ReadCacheMetaV1["mode"] = decision.recovery ? "recovery" : "baseline";
238
257
  let summary = baseline.summary;
239
258
 
240
- if (!decision.recovery && decision.baseHash === hash) {
241
- output = baseline.completeFile
242
- ? `unchanged, ${baseline.totalLines} lines`
243
- : `unchanged, lines ${baseline.startLine}-${baseline.endLine} of ${baseline.totalLines}`;
259
+ if (baseline.completeFile) {
260
+ const selected = selectCompleteFileResponse({
261
+ displayPath: normalized.path,
262
+ currentText: text,
263
+ currentHash: hash,
264
+ fullText: baseline.text,
265
+ totalLines: baseline.totalLines,
266
+ recovery: decision.recovery,
267
+ baseHash: decision.baseHash,
268
+ baselineText:
269
+ decision.baselineText ??
270
+ (decision.baseHash === undefined ? undefined : snapshots.get(decision.baseHash)),
271
+ });
272
+ output = selected.text;
273
+ mode = selected.mode;
274
+ summary = selected.summary;
275
+ } else if (!decision.recovery && decision.baseHash === hash) {
276
+ output = `unchanged, lines ${baseline.startLine}-${baseline.endLine} of ${baseline.totalLines}`;
244
277
  mode = "unchanged";
245
278
  summary = output;
246
- } else if (!decision.recovery && decision.baseHash && baseline.completeFile && !normalized.lineNumbers) {
247
- const baseText = snapshots.get(decision.baseHash);
248
- if (baseText !== undefined) {
249
- const patch = generateUnifiedPatch(normalized.path, baseText, text, 3);
250
- const counts = countDiffLines(patch);
251
- const candidate = `[read: ${counts.added} lines added, ${counts.removed} removed of ${baseline.totalLines}]\n${patch}`;
252
- const candidateTruncation = truncateHead(candidate);
253
- if (!candidateTruncation.truncated && estimateTokens(candidate) < estimateTokens(baseline.text)) {
254
- output = candidate;
255
- mode = "diff";
256
- summary = `+${counts.added} -${counts.removed}`;
257
- }
258
- }
259
279
  }
260
280
 
261
281
  if (signal?.aborted) throw new Error("Operation aborted");
262
- snapshots.set(hash, text, buffer.byteLength);
282
+ snapshots.set(hash, text, buffer.byteLength, snapshotEpoch);
263
283
  const meta = createMeta(baseline, path, hash, mode, output, decision.baseHash, summary);
264
- cache.record(ctx, meta);
265
284
  return withMeta(baseline, meta, output);
266
285
  },
267
286
  renderCall(