@shanepadgett/tau-agent 0.26.0 → 0.27.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.
@@ -1,31 +1,74 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { resolve } from "node:path";
3
- import { onTauEvent } from "../../shared/events.js";
3
+ import { emitTauEvent, onTauEvent } from "../../shared/events.js";
4
+ import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
5
+ import { createTemporaryOutputStore } from "../../shared/temporary-output-store.ts";
4
6
  import { createToolRowStateStore } from "../../shared/tool-row-state.js";
7
+ import { AST_DISCOVERY_BUDGET, effectiveAstGuidance } from "./ast-guidance.ts";
5
8
  import { createAstTools } from "./ast-tools.ts";
6
9
  import { AstWorkerClient } from "./ast-worker.ts";
7
10
  import { registerAutoread } from "./autoread.ts";
8
11
  import { createFindTool } from "./find.ts";
9
12
  import { createGrepTool } from "./grep.ts";
10
13
  import { createLsTool } from "./ls.ts";
14
+ import { createOrientationState } from "./orientation-state.ts";
11
15
  import { createReadCacheStore } from "./read-cache.ts";
12
16
  import { createReadSnapshotStore } from "./read-snapshots.ts";
13
17
  import { showReadStats } from "./read-stats.ts";
14
18
  import { createExploreReadTool } from "./read.ts";
19
+ import exploreSettings from "./settings.ts";
15
20
 
16
21
  export default function exploreExtension(pi: ExtensionAPI): void {
22
+ let settings = exploreSettings.defaults;
17
23
  const rowState = createToolRowStateStore(pi, "explore.tool-row-state");
18
24
  const readCache = createReadCacheStore();
19
25
  const readSnapshots = createReadSnapshotStore();
20
26
  const astClient = new AstWorkerClient();
21
- const ast = createAstTools(astClient, rowState);
27
+ const orientation = createOrientationState(() => astClient.supportedLanguages());
28
+ const temporaryOutput = createTemporaryOutputStore();
29
+ const ast = createAstTools(astClient, rowState, temporaryOutput, orientation, (toolCallId, cwd, summary) => {
30
+ emitTauEvent(pi, "tau:file-mutation.applied", {
31
+ source: "locatorEdit",
32
+ toolCallId,
33
+ cwd,
34
+ status: summary.status,
35
+ changes: summary.changes.map((change) => ({
36
+ path: change.path,
37
+ kind: change.kind,
38
+ move: change.move,
39
+ linesAdded: change.linesAdded,
40
+ linesRemoved: change.linesRemoved,
41
+ resultingFingerprint: change.resultingFingerprint,
42
+ snapshotRanges: change.snapshotRanges,
43
+ })),
44
+ });
45
+ });
22
46
  registerAutoread(pi, rowState);
23
47
  pi.registerTool(ast.outline);
24
48
  pi.registerTool(ast.symbol);
49
+ pi.registerTool(ast.replace_declaration);
50
+ pi.registerTool(ast.replace_body);
51
+ pi.registerTool(ast.insert_declaration);
52
+ pi.registerTool(ast.rename_declaration);
53
+ pi.registerTool(ast.api_discover);
54
+ pi.registerTool(ast.ast_search);
55
+ pi.registerTool(ast.references);
56
+ pi.registerTool(ast.callers);
57
+ pi.registerTool(ast.callees);
58
+ pi.registerTool(ast.implementations);
59
+ pi.registerTool(ast.tests);
25
60
  pi.registerTool(createLsTool(rowState));
26
61
  pi.registerTool(createFindTool(rowState));
27
62
  pi.registerTool(createGrepTool(rowState));
28
- pi.registerTool(createExploreReadTool(rowState, readCache, readSnapshots));
63
+ pi.registerTool(createExploreReadTool(rowState, orientation, readCache, readSnapshots, () => settings.readGate));
64
+ pi.on("before_agent_start", async (event, ctx) => {
65
+ const guidance = await effectiveAstGuidance({
66
+ cwd: ctx.cwd,
67
+ workerLanguages: () => astClient.supportedLanguages(),
68
+ discoveryBudget: AST_DISCOVERY_BUDGET,
69
+ });
70
+ return guidance ? { systemPrompt: `${event.systemPrompt}\n\n${guidance}` } : undefined;
71
+ });
29
72
  pi.registerCommand("read-stats", {
30
73
  description: "Show estimated read token and cost savings for this session",
31
74
  async handler(_args, ctx) {
@@ -33,10 +76,13 @@ export default function exploreExtension(pi: ExtensionAPI): void {
33
76
  ctx.ui.notify("Read stats require TUI mode", "error");
34
77
  return;
35
78
  }
36
- await showReadStats(ctx);
79
+ await showReadStats(ctx, orientation);
37
80
  },
38
81
  });
39
- pi.on("session_start", () => {
82
+ pi.on("session_start", async (_event, ctx) => {
83
+ settings = await loadTauExtensionSettings(ctx, exploreSettings);
84
+ await temporaryOutput.shutdown();
85
+ await temporaryOutput.start();
40
86
  rowState.clear();
41
87
  readSnapshots.clear();
42
88
  ast.clear();
@@ -47,11 +93,21 @@ export default function exploreExtension(pi: ExtensionAPI): void {
47
93
  ...(change.move ? [resolve(event.cwd, change.move.from), resolve(event.cwd, change.move.to)] : []),
48
94
  ]);
49
95
  ast.invalidate(paths);
96
+ orientation.recordPatched(
97
+ event.changes.map((change) => ({
98
+ path: resolve(event.cwd, change.path),
99
+ resultingFingerprint: change.resultingFingerprint,
100
+ })),
101
+ );
50
102
  });
51
103
  pi.on("session_compact", () => readSnapshots.clear());
52
- pi.on("session_tree", () => readSnapshots.clear());
104
+ pi.on("session_tree", () => {
105
+ readSnapshots.clear();
106
+ ast.resetForTree();
107
+ });
53
108
  pi.on("session_shutdown", async () => {
54
109
  ast.clear();
55
110
  await astClient.shutdown();
111
+ await temporaryOutput.shutdown();
56
112
  });
57
113
  }
@@ -0,0 +1,280 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { astLanguageForPath, type AstLanguage } from "./ast-languages.ts";
5
+
6
+ export type StructuralAttemptKind =
7
+ | "directOutline"
8
+ | "symbol"
9
+ | "apiCandidate"
10
+ | "structuralMatch"
11
+ | "relationshipLocation"
12
+ | "relationshipScope";
13
+ export type ReadGateDecision = StructuralAttemptKind | "fatalFallback" | "blocked";
14
+ export type ReadPermission = Exclude<ReadGateDecision, "blocked"> | "postPatchDiff" | "ungated";
15
+
16
+ export interface OrientationTelemetry {
17
+ blockedReadAttempts: number;
18
+ permittedReadAttempts: number;
19
+ fallbackReadAttempts: number;
20
+ permissionReadAttempts: Record<Exclude<ReadPermission, "ungated">, number>;
21
+ workerInputBytes: number;
22
+ completeRenderedBytes: number;
23
+ modelVisibleAstBytes: number;
24
+ sourceBytesDeflected: number;
25
+ temporaryOutputBytes: number;
26
+ directReadBytes: number;
27
+ overflowReadBytes: number;
28
+ }
29
+
30
+ interface AttemptRecord {
31
+ fingerprint: string;
32
+ kind: StructuralAttemptKind;
33
+ }
34
+
35
+ interface FatalFallbackRecord {
36
+ fingerprint: string;
37
+ includePrivate: boolean;
38
+ names: readonly string[];
39
+ diagnostic: {
40
+ code: "outline_failed" | "outlineFailed" | "response_too_large" | "resultFrameTooLarge";
41
+ message: string;
42
+ };
43
+ }
44
+
45
+ interface FileState {
46
+ attempt: AttemptRecord | undefined;
47
+ fallback: FatalFallbackRecord | undefined;
48
+ patchFingerprint: string | undefined;
49
+ }
50
+
51
+ type TelemetryEvent =
52
+ | {
53
+ toolCallId: string;
54
+ kind: "outline";
55
+ workerInputBytes: number;
56
+ completeRenderedBytes: number;
57
+ modelVisibleAstBytes: number;
58
+ temporaryOutputBytes: number;
59
+ }
60
+ | {
61
+ toolCallId: string;
62
+ kind: "structuralAttempt";
63
+ sourceBytesDeflected: number;
64
+ }
65
+ | {
66
+ toolCallId: string;
67
+ kind: "blockedRead";
68
+ }
69
+ | {
70
+ toolCallId: string;
71
+ kind: "read";
72
+ permission: ReadPermission;
73
+ returnedBytes: number;
74
+ overflow: boolean;
75
+ };
76
+
77
+ export interface OrientationState {
78
+ supports(path: string): Promise<boolean>;
79
+ check(path: string, fingerprint: string): ReadGateDecision;
80
+ checkPatch(path: string, fingerprint: string): boolean;
81
+ recordAttempts(
82
+ records: readonly {
83
+ path: string;
84
+ fingerprint: string;
85
+ kind: StructuralAttemptKind;
86
+ toolCallId: string;
87
+ sourceBytesDeflected: number;
88
+ }[],
89
+ ): void;
90
+ recordFatal(input: {
91
+ path: string;
92
+ fingerprint: string;
93
+ includePrivate: boolean;
94
+ names: readonly string[];
95
+ code: "outline_failed" | "outlineFailed" | "response_too_large" | "resultFrameTooLarge";
96
+ message: string;
97
+ }): void;
98
+ recordPatched(records: readonly { path: string; resultingFingerprint: string | null }[]): void;
99
+ recordOutlineTelemetry(
100
+ toolCallId: string,
101
+ metrics: {
102
+ workerInputBytes: number;
103
+ completeRenderedBytes: number;
104
+ modelVisibleAstBytes: number;
105
+ temporaryOutputBytes: number;
106
+ },
107
+ ): void;
108
+ recordBlockedRead(toolCallId: string, path: string): void;
109
+ recordRead(toolCallId: string, path: string, permission: ReadPermission, returnedBytes: number): void;
110
+ recordTemporaryOutput(path: string): void;
111
+ telemetry(includedToolCallIds: ReadonlySet<string> | undefined): OrientationTelemetry;
112
+ invalidate(paths: readonly string[]): void;
113
+ clear(): void;
114
+ resetGate(): void;
115
+ }
116
+
117
+ export function sourceFingerprint(source: Uint8Array): string {
118
+ return `sha256:${createHash("sha256").update(source).digest("hex")}`;
119
+ }
120
+
121
+ export function createOrientationState(workerLanguages: () => Promise<readonly AstLanguage[]>): OrientationState {
122
+ const files = new Map<string, FileState>();
123
+ const temporaryOutputs = new Set<string>();
124
+ const events: TelemetryEvent[] = [];
125
+
126
+ function stateFor(path: string): FileState {
127
+ const canonicalPath = canonicalPathForState(path);
128
+ const existing = files.get(canonicalPath);
129
+ if (existing) return existing;
130
+ const state: FileState = { attempt: undefined, fallback: undefined, patchFingerprint: undefined };
131
+ files.set(canonicalPath, state);
132
+ return state;
133
+ }
134
+
135
+ return {
136
+ async supports(path) {
137
+ const language = astLanguageForPath(canonicalPathForState(path));
138
+ if (!language) return false;
139
+ try {
140
+ return (await workerLanguages()).includes(language);
141
+ } catch {
142
+ return false;
143
+ }
144
+ },
145
+ check(path, fingerprint) {
146
+ const state = files.get(canonicalPathForState(path));
147
+ if (!state) return "blocked";
148
+ if (state.attempt && state.attempt.fingerprint !== fingerprint) state.attempt = undefined;
149
+ if (state.fallback && state.fallback.fingerprint !== fingerprint) state.fallback = undefined;
150
+ if (state.attempt?.fingerprint === fingerprint) return state.attempt.kind;
151
+ return state.fallback?.fingerprint === fingerprint ? "fatalFallback" : "blocked";
152
+ },
153
+ checkPatch(path, fingerprint) {
154
+ const state = files.get(canonicalPathForState(path));
155
+ if (!state?.patchFingerprint) return false;
156
+ if (state.patchFingerprint === fingerprint) return true;
157
+ state.patchFingerprint = undefined;
158
+ return false;
159
+ },
160
+ recordAttempts(records) {
161
+ for (const record of records) {
162
+ const state = stateFor(record.path);
163
+ state.attempt = { fingerprint: record.fingerprint, kind: record.kind };
164
+ state.fallback = undefined;
165
+ events.push({
166
+ toolCallId: record.toolCallId,
167
+ kind: "structuralAttempt",
168
+ sourceBytesDeflected: record.sourceBytesDeflected,
169
+ });
170
+ }
171
+ },
172
+ recordFatal(input) {
173
+ const state = stateFor(input.path);
174
+ state.fallback = {
175
+ fingerprint: input.fingerprint,
176
+ includePrivate: input.includePrivate,
177
+ names: [...input.names],
178
+ diagnostic: { code: input.code, message: input.message },
179
+ };
180
+ },
181
+ recordPatched(records) {
182
+ for (const record of records) {
183
+ if (record.resultingFingerprint === null) continue;
184
+ stateFor(record.path).patchFingerprint = record.resultingFingerprint;
185
+ }
186
+ },
187
+ recordOutlineTelemetry(toolCallId, metrics) {
188
+ events.push({ toolCallId, kind: "outline", ...metrics });
189
+ },
190
+ recordBlockedRead(toolCallId, path) {
191
+ stateFor(path);
192
+ events.push({ toolCallId, kind: "blockedRead" });
193
+ },
194
+ recordRead(toolCallId, path, permission, returnedBytes) {
195
+ events.push({
196
+ toolCallId,
197
+ kind: "read",
198
+ permission,
199
+ returnedBytes,
200
+ overflow: temporaryOutputs.has(canonicalPathForState(path)),
201
+ });
202
+ },
203
+ recordTemporaryOutput(path) {
204
+ temporaryOutputs.add(canonicalPathForState(path));
205
+ },
206
+ telemetry(includedToolCallIds) {
207
+ const permissionReadAttempts: OrientationTelemetry["permissionReadAttempts"] = {
208
+ directOutline: 0,
209
+ symbol: 0,
210
+ apiCandidate: 0,
211
+ structuralMatch: 0,
212
+ relationshipLocation: 0,
213
+ relationshipScope: 0,
214
+ fatalFallback: 0,
215
+ postPatchDiff: 0,
216
+ };
217
+ const result: OrientationTelemetry = {
218
+ blockedReadAttempts: 0,
219
+ permittedReadAttempts: 0,
220
+ fallbackReadAttempts: 0,
221
+ permissionReadAttempts,
222
+ workerInputBytes: 0,
223
+ completeRenderedBytes: 0,
224
+ modelVisibleAstBytes: 0,
225
+ sourceBytesDeflected: 0,
226
+ temporaryOutputBytes: 0,
227
+ directReadBytes: 0,
228
+ overflowReadBytes: 0,
229
+ };
230
+ for (const event of events) {
231
+ if (includedToolCallIds && !includedToolCallIds.has(event.toolCallId)) continue;
232
+ if (event.kind === "outline") {
233
+ result.workerInputBytes += event.workerInputBytes;
234
+ result.completeRenderedBytes += event.completeRenderedBytes;
235
+ result.modelVisibleAstBytes += event.modelVisibleAstBytes;
236
+ result.temporaryOutputBytes += event.temporaryOutputBytes;
237
+ } else if (event.kind === "structuralAttempt") {
238
+ result.sourceBytesDeflected += event.sourceBytesDeflected;
239
+ } else if (event.kind === "blockedRead") {
240
+ result.blockedReadAttempts += 1;
241
+ } else {
242
+ result.directReadBytes += event.returnedBytes;
243
+ if (event.overflow) result.overflowReadBytes += event.returnedBytes;
244
+ if (event.permission !== "ungated") {
245
+ result.permittedReadAttempts += 1;
246
+ result.permissionReadAttempts[event.permission] += 1;
247
+ }
248
+ if (event.permission === "fatalFallback") result.fallbackReadAttempts += 1;
249
+ }
250
+ }
251
+ return result;
252
+ },
253
+ invalidate(paths) {
254
+ for (const path of paths) {
255
+ const state = files.get(canonicalPathForState(path));
256
+ if (!state) continue;
257
+ state.attempt = undefined;
258
+ state.fallback = undefined;
259
+ state.patchFingerprint = undefined;
260
+ }
261
+ },
262
+ clear() {
263
+ files.clear();
264
+ temporaryOutputs.clear();
265
+ events.length = 0;
266
+ },
267
+ resetGate() {
268
+ files.clear();
269
+ },
270
+ };
271
+ }
272
+
273
+ function canonicalPathForState(path: string): string {
274
+ const absolutePath = resolve(path);
275
+ try {
276
+ return realpathSync(absolutePath);
277
+ } catch {
278
+ return absolutePath;
279
+ }
280
+ }
@@ -1,7 +1,8 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
1
+ import { formatSize, type Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { type Component, type KeybindingsManager, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
3
3
  import { bindingHint, Tabs, ToolPanel, type ToolPanelConfig } from "@shanepadgett/tau-tui";
4
4
  import type { ReadCacheMode } from "./read-cache.ts";
5
+ import type { OrientationTelemetry } from "./orientation-state.ts";
5
6
 
6
7
  export interface ReadSavingsSnapshot {
7
8
  label: string;
@@ -12,6 +13,7 @@ export interface ReadSavingsSnapshot {
12
13
  unchangedCost: number;
13
14
  diffCost: number;
14
15
  counts: Record<ReadCacheMode, number>;
16
+ gate: OrientationTelemetry;
15
17
  }
16
18
 
17
19
  export function createReadStatsPanel(
@@ -100,6 +102,7 @@ class SavingsBody implements Component {
100
102
  render(width: number): string[] {
101
103
  const avoided = Math.max(0, this.snapshot.baselineTokens - this.snapshot.returnedTokens);
102
104
  const reduction = this.snapshot.baselineTokens > 0 ? avoided / this.snapshot.baselineTokens : 0;
105
+ const sourceAvoided = Math.max(0, this.snapshot.gate.sourceBytesDeflected - this.snapshot.gate.overflowReadBytes);
103
106
  return [
104
107
  this.theme.bold("Tokens"),
105
108
  metricRow("Without cache", formatTokens(this.snapshot.baselineTokens), width),
@@ -119,6 +122,25 @@ class SavingsBody implements Component {
119
122
  countRow("Baseline", this.snapshot.counts.baseline, "Unchanged", this.snapshot.counts.unchanged, width),
120
123
  countRow("Changes", this.snapshot.counts.diff, "Recovery", this.snapshot.counts.recovery, width),
121
124
  "",
125
+ this.theme.bold("AST-first reads"),
126
+ countRow(
127
+ "Blocked",
128
+ this.snapshot.gate.blockedReadAttempts,
129
+ "Permitted",
130
+ this.snapshot.gate.permittedReadAttempts,
131
+ width,
132
+ ),
133
+ metricRow("Fallback reads", String(this.snapshot.gate.fallbackReadAttempts), width),
134
+ metricRow("Source avoided", formatSize(sourceAvoided), width),
135
+ metricRow("Direct returned", formatSize(this.snapshot.gate.directReadBytes), width),
136
+ metricRow("Overflow reread", formatSize(this.snapshot.gate.overflowReadBytes), width),
137
+ "",
138
+ this.theme.bold("AST bytes"),
139
+ metricRow("Worker input", formatSize(this.snapshot.gate.workerInputBytes), width),
140
+ metricRow("Full rendered", formatSize(this.snapshot.gate.completeRenderedBytes), width),
141
+ metricRow("Model visible", formatSize(this.snapshot.gate.modelVisibleAstBytes), width),
142
+ metricRow("Temporary", formatSize(this.snapshot.gate.temporaryOutputBytes), width),
143
+ "",
122
144
  truncateToWidth(
123
145
  this.theme.fg("dim", "Estimates include repeated chat history and prompt caching."),
124
146
  width,
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { OrientationState } from "./orientation-state.ts";
2
3
  import { readMetaFromMessage, type ReadCacheMode } from "./read-cache.ts";
3
4
  import { createReadStatsPanel, type ReadSavingsSnapshot } from "./read-stats-panel.ts";
4
5
 
@@ -9,11 +10,16 @@ interface ContextTotals {
9
10
  diffSaved: number;
10
11
  }
11
12
 
12
- export async function showReadStats(ctx: ExtensionCommandContext): Promise<void> {
13
+ export async function showReadStats(ctx: ExtensionCommandContext, orientation: OrientationState): Promise<void> {
13
14
  const entries = ctx.sessionManager.getEntries();
14
15
  const branchIds = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
15
- const current = calculateSnapshot(entries, branchIds, "Current chat");
16
- const whole = calculateSnapshot(entries, undefined, "Whole session");
16
+ const current = calculateSnapshot(
17
+ entries,
18
+ branchIds,
19
+ "Current chat",
20
+ orientation.telemetry(toolCallIds(entries, branchIds)),
21
+ );
22
+ const whole = calculateSnapshot(entries, undefined, "Whole session", orientation.telemetry(undefined));
17
23
  await ctx.ui.custom(
18
24
  (tui, theme, keybindings, done) => createReadStatsPanel(tui, theme, keybindings, done, current, whole),
19
25
  {
@@ -27,6 +33,7 @@ function calculateSnapshot(
27
33
  entries: readonly unknown[],
28
34
  includedIds: ReadonlySet<string> | undefined,
29
35
  label: string,
36
+ gate: ReadSavingsSnapshot["gate"],
30
37
  ): ReadSavingsSnapshot {
31
38
  const states = new Map<string, ContextTotals>();
32
39
  const counts: Record<ReadCacheMode, number> = { baseline: 0, recovery: 0, unchanged: 0, diff: 0 };
@@ -91,9 +98,20 @@ function calculateSnapshot(
91
98
  unchangedCost,
92
99
  diffCost,
93
100
  counts,
101
+ gate,
94
102
  };
95
103
  }
96
104
 
105
+ function toolCallIds(entries: readonly unknown[], includedIds: ReadonlySet<string>): Set<string> {
106
+ const ids = new Set<string>();
107
+ for (const entry of entries) {
108
+ if (!isRecord(entry) || typeof entry.id !== "string" || !includedIds.has(entry.id)) continue;
109
+ if (entry.type !== "message" || !isRecord(entry.message)) continue;
110
+ if (typeof entry.message.toolCallId === "string") ids.add(entry.message.toolCallId);
111
+ }
112
+ return ids;
113
+ }
114
+
97
115
  function isAssistantMessage(value: unknown): value is {
98
116
  role: "assistant";
99
117
  usage: {
@@ -8,7 +8,7 @@ import {
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { Container, Text } from "@earendil-works/pi-tui";
10
10
  import { createHash } from "node:crypto";
11
- import { readFile } from "node:fs/promises";
11
+ import { readFile, realpath } from "node:fs/promises";
12
12
  import { isAbsolute, resolve } from "node:path";
13
13
  import { Type, type Static } from "typebox";
14
14
  import { formatToolRowTitle, type ToolRowStateStore } from "../../shared/tool-row-state.js";
@@ -19,6 +19,7 @@ import {
19
19
  selectCompleteFileResponse,
20
20
  } from "./full-file-knowledge.ts";
21
21
  import { normalizeCountLimit } from "./limits.ts";
22
+ import type { OrientationState, ReadPermission } from "./orientation-state.ts";
22
23
  import { stripLeadingAt } from "./path-display.ts";
23
24
  import {
24
25
  createReadCacheStore,
@@ -27,6 +28,7 @@ import {
27
28
  type ReadCacheStore,
28
29
  } from "./read-cache.ts";
29
30
  import { createReadSnapshotStore, type ReadSnapshotStore } from "./read-snapshots.ts";
31
+ import exploreSettings, { matchesExploreReadGate, type ExploreReadGateSettings } from "./settings.ts";
30
32
 
31
33
  const readSchema = Type.Object({
32
34
  path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
@@ -204,14 +206,16 @@ function createMeta(
204
206
 
205
207
  export function createExploreReadTool(
206
208
  rowState: ToolRowStateStore,
209
+ orientation: OrientationState,
207
210
  cache: ReadCacheStore = createReadCacheStore(),
208
211
  snapshots: ReadSnapshotStore = createReadSnapshotStore(),
212
+ readGate: () => ExploreReadGateSettings = () => exploreSettings.defaults.readGate,
209
213
  ): ReadDefinition {
210
214
  const baseDefinition = readDefinitionForCwd(process.cwd());
211
215
  return {
212
216
  ...baseDefinition,
213
217
  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.",
218
+ "Read a text or image file with optional line ranges and line numbers. Configured supported source requires a current structural attempt first. Repeated complete-file reads avoid repeating unchanged content and can return a useful diff or current source after changes.",
215
219
  parameters: readSchema,
216
220
  async execute(
217
221
  toolCallId: Parameters<ReadExecute>[0],
@@ -222,13 +226,16 @@ export function createExploreReadTool(
222
226
  ) {
223
227
  const definition = readDefinitionForCwd(ctx.cwd);
224
228
  const normalized = normalizeReadParams(params);
225
- const path = isAbsolute(normalized.path) ? resolve(normalized.path) : resolve(ctx.cwd, normalized.path);
229
+ const requestedPath = isAbsolute(normalized.path)
230
+ ? resolve(normalized.path)
231
+ : resolve(ctx.cwd, normalized.path);
232
+ const path = await realpath(requestedPath);
226
233
  const snapshotEpoch = snapshots.epoch();
227
234
  const buffer = await readFile(path);
228
235
  if (isSupportedImage(buffer)) {
229
236
  return definition.execute(
230
237
  toolCallId,
231
- { path: normalized.path, offset: normalized.offset, limit: normalized.limit },
238
+ { path, offset: normalized.offset, limit: normalized.limit },
232
239
  signal,
233
240
  onUpdate,
234
241
  ctx,
@@ -240,48 +247,78 @@ export function createExploreReadTool(
240
247
  try {
241
248
  text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer);
242
249
  } catch {
243
- return definition.execute(toolCallId, normalized, signal, onUpdate, ctx);
244
- }
245
-
246
- const baseline = baselineText(text, normalized);
247
- if (!baseline.cacheable) {
248
- return { content: [{ type: "text", text: baseline.text }], details: baseline.details };
250
+ return definition.execute(toolCallId, { ...normalized, path }, signal, onUpdate, ctx);
249
251
  }
250
252
  const hash = createHash("sha256").update(buffer).digest("hex");
253
+ const baseline = baselineText(text, normalized);
251
254
  const staleEpoch = !snapshots.isCurrent(snapshotEpoch);
252
- const decision = staleEpoch
255
+ const cacheDecision = staleEpoch
253
256
  ? { baseHash: undefined, baselineText: undefined, recovery: false }
254
- : cache.decision(ctx, path, baseline.scopeKey);
257
+ : cache.decision(ctx, requestedPath, baseline.scopeKey);
255
258
  let output = baseline.text;
256
- let mode: ReadCacheMetaV1["mode"] = decision.recovery ? "recovery" : "baseline";
259
+ let mode: ReadCacheMetaV1["mode"] = cacheDecision.recovery ? "recovery" : "baseline";
257
260
  let summary = baseline.summary;
258
261
 
259
- if (baseline.completeFile) {
262
+ if (baseline.cacheable && baseline.completeFile) {
260
263
  const selected = selectCompleteFileResponse({
261
264
  displayPath: normalized.path,
262
265
  currentText: text,
263
266
  currentHash: hash,
264
267
  fullText: baseline.text,
265
268
  totalLines: baseline.totalLines,
266
- recovery: decision.recovery,
267
- baseHash: decision.baseHash,
269
+ recovery: cacheDecision.recovery,
270
+ baseHash: cacheDecision.baseHash,
268
271
  baselineText:
269
- decision.baselineText ??
270
- (decision.baseHash === undefined ? undefined : snapshots.get(decision.baseHash)),
272
+ cacheDecision.baselineText ??
273
+ (cacheDecision.baseHash === undefined ? undefined : snapshots.get(cacheDecision.baseHash)),
271
274
  });
272
275
  output = selected.text;
273
276
  mode = selected.mode;
274
277
  summary = selected.summary;
275
- } else if (!decision.recovery && decision.baseHash === hash) {
278
+ } else if (baseline.cacheable && !cacheDecision.recovery && cacheDecision.baseHash === hash) {
276
279
  output = `unchanged, lines ${baseline.startLine}-${baseline.endLine} of ${baseline.totalLines}`;
277
280
  mode = "unchanged";
278
281
  summary = output;
279
282
  }
280
283
 
284
+ let permission: ReadPermission = "ungated";
285
+ if ((await orientation.supports(path)) && matchesExploreReadGate(path, ctx.cwd, readGate())) {
286
+ const fingerprint = `sha256:${hash}`;
287
+ const gateDecision = orientation.check(path, fingerprint);
288
+ if (gateDecision === "blocked") {
289
+ const patchDiffPermitted =
290
+ baseline.cacheable &&
291
+ baseline.completeFile &&
292
+ normalized.offset === undefined &&
293
+ normalized.limit === undefined &&
294
+ !cacheDecision.recovery &&
295
+ cacheDecision.baselineText !== undefined &&
296
+ (mode === "diff" || mode === "unchanged") &&
297
+ orientation.checkPatch(path, fingerprint);
298
+ if (!patchDiffPermitted) {
299
+ orientation.recordBlockedRead(toolCallId, path);
300
+ throw new Error(
301
+ `Read blocked for ${normalized.path}: configured source has no current structural attempt. Use exact-name outline, focused api_discover, file-scoped ast_search, or symbol with a fresh locator, then retry read.`,
302
+ );
303
+ }
304
+ permission = "postPatchDiff";
305
+ } else {
306
+ permission = gateDecision;
307
+ }
308
+ }
309
+
310
+ if (!baseline.cacheable) {
311
+ const result = { content: [{ type: "text" as const, text: baseline.text }], details: baseline.details };
312
+ orientation.recordRead(toolCallId, path, permission, Buffer.byteLength(baseline.text));
313
+ return result;
314
+ }
315
+
281
316
  if (signal?.aborted) throw new Error("Operation aborted");
282
317
  snapshots.set(hash, text, buffer.byteLength, snapshotEpoch);
283
- const meta = createMeta(baseline, path, hash, mode, output, decision.baseHash, summary);
284
- return withMeta(baseline, meta, output);
318
+ const meta = createMeta(baseline, requestedPath, hash, mode, output, cacheDecision.baseHash, summary);
319
+ const result = withMeta(baseline, meta, output);
320
+ orientation.recordRead(toolCallId, path, permission, Buffer.byteLength(output));
321
+ return result;
285
322
  },
286
323
  renderCall(
287
324
  args: Parameters<ReadRenderCall>[0],