@shanepadgett/tau-agent 0.20.2 → 0.22.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/README.md +18 -0
- package/docs/extending-tau-agent.md +34 -2
- package/extensions/context-pruning/README.md +18 -114
- package/extensions/context-pruning/index.ts +72 -74
- package/extensions/context-pruning/projection.ts +38 -96
- package/extensions/context-pruning/prune.ts +135 -287
- package/extensions/context-pruning/render.ts +95 -35
- package/extensions/context-pruning/settings.ts +14 -15
- package/extensions/explore/read-cache.ts +31 -7
- package/extensions/handoff/README.md +7 -0
- package/extensions/handoff/index.ts +145 -0
- package/extensions/handoff/model.ts +71 -0
- package/extensions/image-gen/index.ts +24 -133
- package/extensions/subagent/index.ts +3 -1
- package/extensions/tau-help/help.md +5 -1
- package/package.json +9 -2
- package/schemas/tau.schema.json +15 -12
- package/shared/context-pruning-state.ts +69 -273
- package/shared/model-fallback/index.ts +1 -1
- package/{extensions/image-gen → src/image-generation}/client.ts +30 -20
- package/src/image-generation/index.ts +224 -0
- package/src/index.ts +7 -0
- package/extensions/context-pruning/file-evidence.ts +0 -265
- /package/{extensions/image-gen → src/image-generation}/constants.ts +0 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { withFileMutationQueue, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { imageSize } from "image-size";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { link, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { detectImageMimeType, editImage, requestGeneratedImage } from "./client.ts";
|
|
8
|
+
import { OPENAI_IMAGE_MODEL, OPENAI_PROVIDER, XAI_IMAGE_MODEL, XAI_PROVIDER, type ImageProvider } from "./constants.ts";
|
|
9
|
+
|
|
10
|
+
export type { ImageProvider } from "./constants.ts";
|
|
11
|
+
|
|
12
|
+
const MAX_INPUT_BYTES = 50 * 1024 * 1024;
|
|
13
|
+
const MAX_ERROR_MESSAGE_LENGTH = 2000;
|
|
14
|
+
|
|
15
|
+
export type ImageGenerationContext = Pick<ExtensionContext, "cwd" | "model" | "modelRegistry">;
|
|
16
|
+
|
|
17
|
+
export interface GenerateImageRequest {
|
|
18
|
+
prompt: string;
|
|
19
|
+
provider?: ImageProvider;
|
|
20
|
+
path?: string;
|
|
21
|
+
referencedImagePaths?: readonly string[];
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GeneratedImageResult {
|
|
26
|
+
bytes: Buffer;
|
|
27
|
+
path: string;
|
|
28
|
+
provider: ImageProvider;
|
|
29
|
+
model: typeof OPENAI_IMAGE_MODEL | typeof XAI_IMAGE_MODEL;
|
|
30
|
+
operation: "generate" | "edit";
|
|
31
|
+
mimeType: "image/png" | "image/jpeg" | "image/webp";
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function outputExtension(mimeType: GeneratedImageResult["mimeType"]): string {
|
|
37
|
+
if (mimeType === "image/png") return ".png";
|
|
38
|
+
if (mimeType === "image/webp") return ".webp";
|
|
39
|
+
return ".jpg";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sanitizedError(error: unknown, signal: AbortSignal | undefined, secrets: readonly string[]): unknown {
|
|
43
|
+
if (signal?.aborted) return signal.reason;
|
|
44
|
+
let message = error instanceof Error ? error.message : "Image generation failed";
|
|
45
|
+
for (const secret of secrets) {
|
|
46
|
+
if (secret) message = message.replaceAll(secret, "[redacted]");
|
|
47
|
+
}
|
|
48
|
+
message = [...message]
|
|
49
|
+
.filter((character) => {
|
|
50
|
+
const code = character.charCodeAt(0);
|
|
51
|
+
return code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127);
|
|
52
|
+
})
|
|
53
|
+
.join("")
|
|
54
|
+
.slice(0, MAX_ERROR_MESSAGE_LENGTH)
|
|
55
|
+
.trim();
|
|
56
|
+
return new Error(message || "Image generation failed");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function generateImage(
|
|
60
|
+
ctx: ImageGenerationContext,
|
|
61
|
+
request: GenerateImageRequest,
|
|
62
|
+
): Promise<GeneratedImageResult> {
|
|
63
|
+
const signal = request.signal;
|
|
64
|
+
const secrets: string[] = [];
|
|
65
|
+
try {
|
|
66
|
+
signal?.throwIfAborted();
|
|
67
|
+
if (typeof request.prompt !== "string") throw new Error("Image prompt must be a string");
|
|
68
|
+
if (request.provider !== undefined && request.provider !== "openai" && request.provider !== "xai") {
|
|
69
|
+
throw new Error("Image provider must be openai or xai");
|
|
70
|
+
}
|
|
71
|
+
if (request.path !== undefined && typeof request.path !== "string")
|
|
72
|
+
throw new Error("Image path must be a string");
|
|
73
|
+
if (request.referencedImagePaths !== undefined && !Array.isArray(request.referencedImagePaths)) {
|
|
74
|
+
throw new Error("Referenced image paths must be an array");
|
|
75
|
+
}
|
|
76
|
+
const prompt = request.prompt.trim();
|
|
77
|
+
if (!prompt) throw new Error("Image prompt cannot be empty");
|
|
78
|
+
if (request.referencedImagePaths && request.referencedImagePaths.length > 3) {
|
|
79
|
+
throw new Error("Image generation accepts at most three referenced images");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const requestedPath = request.path?.startsWith("@") ? request.path.slice(1) : request.path;
|
|
83
|
+
if (requestedPath !== undefined && !requestedPath.trim()) throw new Error("Image path cannot be empty");
|
|
84
|
+
const requestedAbsolutePath = requestedPath
|
|
85
|
+
? isAbsolute(requestedPath)
|
|
86
|
+
? requestedPath
|
|
87
|
+
: resolve(ctx.cwd, requestedPath)
|
|
88
|
+
: undefined;
|
|
89
|
+
if (
|
|
90
|
+
requestedAbsolutePath &&
|
|
91
|
+
![".jpg", ".jpeg", ".png", ".webp"].includes(extname(requestedAbsolutePath).toLowerCase())
|
|
92
|
+
) {
|
|
93
|
+
throw new Error("Image path must end in .jpg, .jpeg, .png, or .webp");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parentUsesXai =
|
|
97
|
+
ctx.model?.provider.toLowerCase() === XAI_PROVIDER || ctx.model?.id.toLowerCase().includes("grok") === true;
|
|
98
|
+
const preferredProvider: ImageProvider = request.provider ?? (parentUsesXai ? "xai" : "openai");
|
|
99
|
+
const providers: readonly ImageProvider[] = request.provider
|
|
100
|
+
? [request.provider]
|
|
101
|
+
: preferredProvider === "xai"
|
|
102
|
+
? ["xai", "openai"]
|
|
103
|
+
: ["openai", "xai"];
|
|
104
|
+
let provider: ImageProvider | undefined;
|
|
105
|
+
let token: string | undefined;
|
|
106
|
+
for (const candidate of providers) {
|
|
107
|
+
signal?.throwIfAborted();
|
|
108
|
+
const candidateToken = await ctx.modelRegistry.getApiKeyForProvider(
|
|
109
|
+
candidate === "openai" ? OPENAI_PROVIDER : XAI_PROVIDER,
|
|
110
|
+
);
|
|
111
|
+
signal?.throwIfAborted();
|
|
112
|
+
if (candidateToken) {
|
|
113
|
+
provider = candidate;
|
|
114
|
+
token = candidateToken;
|
|
115
|
+
secrets.push(candidateToken);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!provider || !token) {
|
|
120
|
+
if (request.provider === "openai") {
|
|
121
|
+
throw new Error("OpenAI Codex authentication is unavailable. Run /login openai-codex.");
|
|
122
|
+
}
|
|
123
|
+
if (request.provider === "xai") {
|
|
124
|
+
throw new Error("xAI authentication is unavailable. Run /login xai and choose a login method.");
|
|
125
|
+
}
|
|
126
|
+
throw new Error("Image generation authentication is unavailable. Run /login for OpenAI Codex or xAI.");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const images: Array<{
|
|
130
|
+
mimeType: "image/png" | "image/jpeg" | "image/webp";
|
|
131
|
+
data: string;
|
|
132
|
+
}> = [];
|
|
133
|
+
for (const path of request.referencedImagePaths ?? []) {
|
|
134
|
+
signal?.throwIfAborted();
|
|
135
|
+
if (typeof path !== "string") throw new Error("Referenced image path must be a string");
|
|
136
|
+
const rawPath = path.startsWith("@") ? path.slice(1) : path;
|
|
137
|
+
if (!rawPath.trim()) throw new Error("Referenced image path cannot be empty");
|
|
138
|
+
const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(ctx.cwd, rawPath);
|
|
139
|
+
const metadata = await stat(absolutePath);
|
|
140
|
+
signal?.throwIfAborted();
|
|
141
|
+
if (!metadata.isFile()) throw new Error(`Referenced image is not a file: ${absolutePath}`);
|
|
142
|
+
if (metadata.size > MAX_INPUT_BYTES) throw new Error(`Referenced image exceeds 50 MiB: ${absolutePath}`);
|
|
143
|
+
const bytes = await readFile(absolutePath, { signal });
|
|
144
|
+
if (bytes.length > MAX_INPUT_BYTES) throw new Error(`Referenced image exceeds 50 MiB: ${absolutePath}`);
|
|
145
|
+
const mimeType = detectImageMimeType(bytes);
|
|
146
|
+
if (!mimeType) {
|
|
147
|
+
throw new Error(`Referenced image is not a supported PNG, JPEG, or WebP file: ${absolutePath}`);
|
|
148
|
+
}
|
|
149
|
+
images.push({ mimeType, data: bytes.toString("base64") });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const operation = images.length === 0 ? "generate" : "edit";
|
|
153
|
+
const model = provider === "openai" ? OPENAI_IMAGE_MODEL : XAI_IMAGE_MODEL;
|
|
154
|
+
const selectedProvider = provider;
|
|
155
|
+
const selectedToken = token;
|
|
156
|
+
signal?.throwIfAborted();
|
|
157
|
+
const generated =
|
|
158
|
+
operation === "generate"
|
|
159
|
+
? await requestGeneratedImage(selectedProvider, prompt, selectedToken, signal)
|
|
160
|
+
: await editImage(selectedProvider, prompt, images, selectedToken, signal);
|
|
161
|
+
signal?.throwIfAborted();
|
|
162
|
+
const generatedExtension = outputExtension(generated.mimeType);
|
|
163
|
+
if (requestedAbsolutePath) {
|
|
164
|
+
const requestedExtension = extname(requestedAbsolutePath).toLowerCase();
|
|
165
|
+
const matches =
|
|
166
|
+
requestedExtension === generatedExtension ||
|
|
167
|
+
(generatedExtension === ".jpg" && requestedExtension === ".jpeg");
|
|
168
|
+
if (!matches) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`${selectedProvider === "openai" ? "OpenAI Codex" : "xAI"} returned ${generated.mimeType}; destination must end in ${generatedExtension}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let dimensions: ReturnType<typeof imageSize>;
|
|
176
|
+
try {
|
|
177
|
+
dimensions = imageSize(generated.bytes);
|
|
178
|
+
} catch {
|
|
179
|
+
throw new Error(`${selectedProvider === "openai" ? "OpenAI Codex" : "xAI"} returned invalid image dimensions`);
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
!Number.isInteger(dimensions.width) ||
|
|
183
|
+
dimensions.width <= 0 ||
|
|
184
|
+
!Number.isInteger(dimensions.height) ||
|
|
185
|
+
dimensions.height <= 0
|
|
186
|
+
) {
|
|
187
|
+
throw new Error(`${selectedProvider === "openai" ? "OpenAI Codex" : "xAI"} returned invalid image dimensions`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const absolutePath =
|
|
191
|
+
requestedAbsolutePath ??
|
|
192
|
+
join(homedir(), ".local", "share", "tau-agent", "images", `image-${randomUUID()}${generatedExtension}`);
|
|
193
|
+
const outputDirectory = dirname(absolutePath);
|
|
194
|
+
await withFileMutationQueue(absolutePath, async () => {
|
|
195
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
196
|
+
const temporaryPath = join(
|
|
197
|
+
outputDirectory,
|
|
198
|
+
`.${basename(absolutePath)}.${randomUUID()}.tmp${generatedExtension}`,
|
|
199
|
+
);
|
|
200
|
+
try {
|
|
201
|
+
await writeFile(temporaryPath, generated.bytes, { flag: "wx", signal });
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
await link(temporaryPath, absolutePath);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
bytes: generated.bytes,
|
|
213
|
+
path: absolutePath,
|
|
214
|
+
provider: selectedProvider,
|
|
215
|
+
model,
|
|
216
|
+
operation,
|
|
217
|
+
mimeType: generated.mimeType,
|
|
218
|
+
width: dimensions.width,
|
|
219
|
+
height: dimensions.height,
|
|
220
|
+
};
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw sanitizedError(error, signal, secrets);
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/index.ts
ADDED
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
import { realpath } from "node:fs/promises";
|
|
2
|
-
import { basename, dirname, relative, resolve, sep } from "node:path";
|
|
3
|
-
import { estimateTokens, type ContextEvent } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { prepareAutoreadMessage, type PreparedAutoreadMessage } from "../explore/autoread.ts";
|
|
5
|
-
import { COMPLETE_FILE_SCOPE, MAX_COMPLETE_FILE_SNAPSHOT_BYTES } from "../explore/full-file-knowledge.ts";
|
|
6
|
-
import { readMetaFromMessage, replayReadCache } from "../explore/read-cache.ts";
|
|
7
|
-
|
|
8
|
-
type ContextMessage = ContextEvent["messages"][number];
|
|
9
|
-
type ReadEvidenceMessage =
|
|
10
|
-
| Extract<ContextMessage, { role: "toolResult" }>
|
|
11
|
-
| Extract<ContextMessage, { role: "custom" }>;
|
|
12
|
-
|
|
13
|
-
interface RequestedFile {
|
|
14
|
-
path: string;
|
|
15
|
-
relevance: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface RequestedDeferredFile {
|
|
19
|
-
path: string;
|
|
20
|
-
reason: string;
|
|
21
|
-
relevantWhen: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface CanonicalFile<T> {
|
|
25
|
-
canonicalPath: string;
|
|
26
|
-
displayPath: string;
|
|
27
|
-
request: T;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface FileEvidenceSelection {
|
|
31
|
-
retainedToolCallIds: ReadonlySet<string>;
|
|
32
|
-
retainedAutoreadRowIds: ReadonlySet<string>;
|
|
33
|
-
preparedSnapshots: readonly PreparedAutoreadMessage[];
|
|
34
|
-
refreshedFiles: readonly { path: string; rowId: string; servedHash: string }[];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function canonicalizeFileSelections(options: {
|
|
38
|
-
cwd: string;
|
|
39
|
-
keepFiles: readonly RequestedFile[];
|
|
40
|
-
deferFiles: readonly RequestedDeferredFile[];
|
|
41
|
-
}): Promise<{
|
|
42
|
-
keepFiles: CanonicalFile<RequestedFile>[];
|
|
43
|
-
deferFiles: CanonicalFile<RequestedDeferredFile>[];
|
|
44
|
-
}> {
|
|
45
|
-
const seen = new Map<string, string>();
|
|
46
|
-
const canonicalCwd = await canonicalizePath(resolve(options.cwd));
|
|
47
|
-
const canonicalize = async <T extends { path: string }>(request: T): Promise<CanonicalFile<T>> => {
|
|
48
|
-
const requested = resolve(options.cwd, request.path.replace(/^@/, ""));
|
|
49
|
-
const canonicalPath = await canonicalizePath(requested);
|
|
50
|
-
const previous = seen.get(canonicalPath);
|
|
51
|
-
if (previous !== undefined) {
|
|
52
|
-
throw new Error(`Duplicate file selection: ${request.path} resolves to the same path as ${previous}`);
|
|
53
|
-
}
|
|
54
|
-
seen.set(canonicalPath, request.path);
|
|
55
|
-
const relativePath = relative(canonicalCwd, canonicalPath);
|
|
56
|
-
const displayPath =
|
|
57
|
-
relativePath !== "" && relativePath !== ".." && !relativePath.startsWith(`..${sep}`)
|
|
58
|
-
? relativePath
|
|
59
|
-
: canonicalPath;
|
|
60
|
-
return { canonicalPath, displayPath, request };
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const keepFiles: CanonicalFile<RequestedFile>[] = [];
|
|
64
|
-
for (const request of options.keepFiles) keepFiles.push(await canonicalize(request));
|
|
65
|
-
const deferFiles: CanonicalFile<RequestedDeferredFile>[] = [];
|
|
66
|
-
for (const request of options.deferFiles) deferFiles.push(await canonicalize(request));
|
|
67
|
-
return { keepFiles, deferFiles };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export async function selectFileEvidence(options: {
|
|
71
|
-
cwd: string;
|
|
72
|
-
messages: readonly ContextMessage[];
|
|
73
|
-
files: readonly CanonicalFile<RequestedFile>[];
|
|
74
|
-
anchorToolCallId: string;
|
|
75
|
-
signal: AbortSignal | undefined;
|
|
76
|
-
isLifecycleCurrent: () => boolean;
|
|
77
|
-
}): Promise<FileEvidenceSelection> {
|
|
78
|
-
const entries = options.messages.map((message) =>
|
|
79
|
-
message.role === "custom" ? { type: "custom_message", ...message } : { type: "message", message },
|
|
80
|
-
);
|
|
81
|
-
const replay = replayReadCache(entries, options.cwd);
|
|
82
|
-
const retainedToolCallIds = new Set<string>();
|
|
83
|
-
const retainedAutoreadRowIds = new Set<string>();
|
|
84
|
-
const preparedSnapshots: PreparedAutoreadMessage[] = [];
|
|
85
|
-
const refreshedFiles: Array<{ path: string; rowId: string; servedHash: string }> = [];
|
|
86
|
-
const rowMessages = indexEvidenceRows(options.messages);
|
|
87
|
-
|
|
88
|
-
for (let fileIndex = 0; fileIndex < options.files.length; fileIndex += 1) {
|
|
89
|
-
const file = options.files[fileIndex];
|
|
90
|
-
if (!file) continue;
|
|
91
|
-
options.signal?.throwIfAborted();
|
|
92
|
-
if (!options.isLifecycleCurrent()) throw new Error("Prune preparation crossed a session lifecycle boundary");
|
|
93
|
-
|
|
94
|
-
const rawRows: ContextMessage[] = [];
|
|
95
|
-
for (const message of options.messages) {
|
|
96
|
-
const pathKey = rawReadPath(message, options.cwd);
|
|
97
|
-
if (pathKey && (await canonicalizePath(pathKey)) === file.canonicalPath) rawRows.push(message);
|
|
98
|
-
}
|
|
99
|
-
if (rawRows.length === 0) throw new Error(`${file.displayPath} has no prior read evidence; read the file first`);
|
|
100
|
-
const rawCompleteRows = rawRows.filter((message) => rawScopeKey(message) === COMPLETE_FILE_SCOPE);
|
|
101
|
-
if (rawCompleteRows.length === 0) {
|
|
102
|
-
throw new Error(`${file.displayPath} has only partial read evidence; read the complete file first`);
|
|
103
|
-
}
|
|
104
|
-
const acceptedRows: Array<(typeof replay.acceptedRows)[number]> = [];
|
|
105
|
-
for (const row of replay.acceptedRows) {
|
|
106
|
-
if (row.scopeKey === COMPLETE_FILE_SCOPE && (await canonicalizePath(row.pathKey)) === file.canonicalPath)
|
|
107
|
-
acceptedRows.push(row);
|
|
108
|
-
}
|
|
109
|
-
if (acceptedRows.length === 0) {
|
|
110
|
-
throw new Error(`${file.displayPath} has malformed complete-file evidence; read the file again`);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const rowId = `${options.anchorToolCallId}:${fileIndex}`;
|
|
114
|
-
const prepared = await prepareAutoreadMessage({
|
|
115
|
-
rowId,
|
|
116
|
-
path: file.displayPath,
|
|
117
|
-
cwd: options.cwd,
|
|
118
|
-
source: "context-pruning",
|
|
119
|
-
batchId: options.anchorToolCallId,
|
|
120
|
-
signal: options.signal,
|
|
121
|
-
isLifecycleCurrent: options.isLifecycleCurrent,
|
|
122
|
-
maximumBytes: MAX_COMPLETE_FILE_SNAPSHOT_BYTES,
|
|
123
|
-
});
|
|
124
|
-
const currentHash = prepared.details.readCache.servedHash;
|
|
125
|
-
const unchangedBaseline = acceptedRows.find(
|
|
126
|
-
(row) => (row.meta.mode === "baseline" || row.meta.mode === "recovery") && row.meta.servedHash === currentHash,
|
|
127
|
-
);
|
|
128
|
-
if (unchangedBaseline) {
|
|
129
|
-
retainRows(unchangedBaseline.dependencyRowIds, rowMessages, retainedToolCallIds, retainedAutoreadRowIds);
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
let chain = replay.completeFileChains.get(file.canonicalPath);
|
|
134
|
-
if (!chain) {
|
|
135
|
-
for (const candidate of replay.completeFileChains.values()) {
|
|
136
|
-
if ((await canonicalizePath(candidate.pathKey)) === file.canonicalPath) {
|
|
137
|
-
chain = candidate;
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
if (chain?.servedHash === currentHash) {
|
|
143
|
-
const chainCost = chain.rowIds.reduce((total, id) => total + (rowMessages.get(id)?.tokens ?? 0), 0);
|
|
144
|
-
const snapshotCost = estimatePreparedSnapshot(prepared);
|
|
145
|
-
if (chainCost <= snapshotCost) {
|
|
146
|
-
retainRows(chain.rowIds, rowMessages, retainedToolCallIds, retainedAutoreadRowIds);
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
preparedSnapshots.push(prepared);
|
|
152
|
-
retainedAutoreadRowIds.add(rowId);
|
|
153
|
-
refreshedFiles.push({ path: file.displayPath, rowId, servedHash: currentHash });
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return { retainedToolCallIds, retainedAutoreadRowIds, preparedSnapshots, refreshedFiles };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function indexEvidenceRows(
|
|
160
|
-
messages: readonly ContextMessage[],
|
|
161
|
-
): Map<string, { tokens: number; kind: "tool" | "autoread" }> {
|
|
162
|
-
const rows = new Map<string, { tokens: number; kind: "tool" | "autoread" }>();
|
|
163
|
-
const calls = new Map<string, { message: Extract<ContextMessage, { role: "assistant" }>; blockIndex: number }>();
|
|
164
|
-
for (const message of messages) {
|
|
165
|
-
if (message.role === "assistant") {
|
|
166
|
-
for (let blockIndex = 0; blockIndex < message.content.length; blockIndex += 1) {
|
|
167
|
-
const block = message.content[blockIndex];
|
|
168
|
-
if (block?.type === "toolCall") calls.set(block.id, { message, blockIndex });
|
|
169
|
-
}
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
if (message.role === "toolResult" && message.toolName === "read") {
|
|
173
|
-
const call = calls.get(message.toolCallId);
|
|
174
|
-
const block = call?.message.content[call.blockIndex];
|
|
175
|
-
if (call && block?.type === "toolCall") {
|
|
176
|
-
rows.set(message.toolCallId, {
|
|
177
|
-
tokens: estimateTokens({ ...call.message, content: [block] }) + estimateTokens(message),
|
|
178
|
-
kind: "tool",
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
if (message.role === "custom" && message.customType === "tau.autoread") {
|
|
184
|
-
const rowId = autoreadRowId(message);
|
|
185
|
-
if (rowId) rows.set(rowId, { tokens: estimateTokens(message), kind: "autoread" });
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return rows;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function retainRows(
|
|
192
|
-
rowIds: readonly string[],
|
|
193
|
-
rows: ReadonlyMap<string, { kind: "tool" | "autoread" }>,
|
|
194
|
-
toolIds: Set<string>,
|
|
195
|
-
autoreadIds: Set<string>,
|
|
196
|
-
): void {
|
|
197
|
-
for (const rowId of rowIds) {
|
|
198
|
-
const row = rows.get(rowId);
|
|
199
|
-
if (!row) throw new Error(`Complete-file dependency row is absent from projected context: ${rowId}`);
|
|
200
|
-
if (row.kind === "tool") toolIds.add(rowId);
|
|
201
|
-
else autoreadIds.add(rowId);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function estimatePreparedSnapshot(prepared: PreparedAutoreadMessage): number {
|
|
206
|
-
const message: ContextMessage = {
|
|
207
|
-
role: "custom",
|
|
208
|
-
customType: prepared.customType,
|
|
209
|
-
content: prepared.content,
|
|
210
|
-
display: prepared.display,
|
|
211
|
-
details: prepared.details,
|
|
212
|
-
timestamp: 0,
|
|
213
|
-
};
|
|
214
|
-
return estimateTokens(message);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function rawReadPath(message: ContextMessage, cwd: string): string | undefined {
|
|
218
|
-
const meta = readMetaFromMessage(message);
|
|
219
|
-
if (meta) return resolve(cwd, meta.pathKey);
|
|
220
|
-
if (!isReadEvidence(message) || !isRecord(message.details) || !isRecord(message.details.readCache)) return undefined;
|
|
221
|
-
const pathKey = message.details.readCache.pathKey;
|
|
222
|
-
return typeof pathKey === "string" ? resolve(cwd, pathKey) : undefined;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function rawScopeKey(message: ContextMessage): string | undefined {
|
|
226
|
-
if (!isReadEvidence(message) || !isRecord(message.details) || !isRecord(message.details.readCache)) return undefined;
|
|
227
|
-
return typeof message.details.readCache.scopeKey === "string" ? message.details.readCache.scopeKey : undefined;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function isReadEvidence(message: ContextMessage): message is ReadEvidenceMessage {
|
|
231
|
-
return (
|
|
232
|
-
(message.role === "toolResult" && message.toolName === "read") ||
|
|
233
|
-
(message.role === "custom" && message.customType === "tau.autoread")
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function autoreadRowId(message: ContextMessage): string | undefined {
|
|
238
|
-
if (message.role !== "custom" || !isRecord(message.details)) return undefined;
|
|
239
|
-
return typeof message.details.rowId === "string" ? message.details.rowId : undefined;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function isMissingPathError(error: unknown): boolean {
|
|
243
|
-
return isRecord(error) && error.code === "ENOENT";
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
async function canonicalizePath(path: string): Promise<string> {
|
|
247
|
-
const missingSegments: string[] = [];
|
|
248
|
-
let cursor = path;
|
|
249
|
-
while (true) {
|
|
250
|
-
try {
|
|
251
|
-
const canonicalAncestor = await realpath(cursor);
|
|
252
|
-
return resolve(canonicalAncestor, ...missingSegments.reverse());
|
|
253
|
-
} catch (error) {
|
|
254
|
-
if (!isMissingPathError(error)) throw error;
|
|
255
|
-
const parent = dirname(cursor);
|
|
256
|
-
if (parent === cursor) return path;
|
|
257
|
-
missingSegments.push(basename(cursor));
|
|
258
|
-
cursor = parent;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
264
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
265
|
-
}
|
|
File without changes
|