@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.
- package/docs/subagents.md +1 -1
- package/extensions/appshot/README.md +1 -1
- package/extensions/appshot/index.ts +5 -11
- package/extensions/context-pruning/README.md +2 -2
- package/extensions/context-pruning/index.ts +58 -57
- package/extensions/context-pruning/render.ts +51 -39
- package/extensions/context-pruning/settings.ts +4 -5
- package/extensions/explore/README.md +18 -11
- package/extensions/explore/ast-guidance.ts +67 -0
- package/extensions/explore/ast-languages.ts +61 -0
- package/extensions/explore/ast-tools.ts +1257 -91
- package/extensions/explore/ast-worker.ts +735 -66
- package/extensions/explore/index.ts +62 -6
- package/extensions/explore/orientation-state.ts +280 -0
- package/extensions/explore/read-stats-panel.ts +23 -1
- package/extensions/explore/read-stats.ts +21 -3
- package/extensions/explore/read.ts +58 -21
- package/extensions/explore/settings.ts +46 -0
- package/extensions/explore/traverse.ts +15 -4
- package/extensions/patch/executor.ts +95 -1
- package/extensions/patch/index.ts +1 -0
- package/extensions/soul/prompt.ts +1 -3
- package/extensions/subagent/agents/review.md +76 -25
- package/extensions/subagent/agents/scout.md +118 -22
- package/extensions/tau-help/help.md +3 -1
- package/native-bin/darwin-arm64/tau-ast +0 -0
- package/package.json +2 -3
- package/schemas/tau.schema.json +44 -4
- package/shared/bounded-text-result.ts +304 -0
- package/shared/events.ts +2 -1
- package/shared/temporary-output-store.ts +275 -0
|
@@ -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
|
|
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", () =>
|
|
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
|
|
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(
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
255
|
+
const cacheDecision = staleEpoch
|
|
253
256
|
? { baseHash: undefined, baselineText: undefined, recovery: false }
|
|
254
|
-
: cache.decision(ctx,
|
|
257
|
+
: cache.decision(ctx, requestedPath, baseline.scopeKey);
|
|
255
258
|
let output = baseline.text;
|
|
256
|
-
let mode: ReadCacheMetaV1["mode"] =
|
|
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:
|
|
267
|
-
baseHash:
|
|
269
|
+
recovery: cacheDecision.recovery,
|
|
270
|
+
baseHash: cacheDecision.baseHash,
|
|
268
271
|
baselineText:
|
|
269
|
-
|
|
270
|
-
(
|
|
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 (!
|
|
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,
|
|
284
|
-
|
|
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],
|