opencode-readseek 0.9.4 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2170 -0
- package/package.json +8 -6
- package/index.ts +0 -1663
package/index.ts
DELETED
|
@@ -1,1663 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
|
-
|
|
4
|
-
import { lstat, mkdir, open, readFile, readlink, realpath, stat } from "node:fs/promises";
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
|
|
9
|
-
import { tool, type Plugin, type PluginOptions, type ToolAttachment, type ToolContext } from "@opencode-ai/plugin";
|
|
10
|
-
|
|
11
|
-
const require = createRequire(import.meta.url);
|
|
12
|
-
const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
13
|
-
const DEFAULT_READSEEK_TIMEOUT_MS = 120_000;
|
|
14
|
-
const DEFAULT_VISION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
15
|
-
const DEFAULT_READ_LIMIT = 2000;
|
|
16
|
-
const EDIT_RESULT_READ_LIMIT = 40;
|
|
17
|
-
const MAX_DOCUMENT_OUTPUT_BYTES = 256 * 1024;
|
|
18
|
-
const MAX_DOCUMENT_OUTPUT_LINES = 2000;
|
|
19
|
-
const TOOL_POLICY = [
|
|
20
|
-
"ReadSeek tool policy:",
|
|
21
|
-
"- Prefer readseek_* tools over built-ins when they can do the job.",
|
|
22
|
-
"- Read with readseek_read first; readseek_edit needs fresh LINE:HASH anchors.",
|
|
23
|
-
"- Use readseek_grep for text/regex, readseek_search for AST patterns, and readseek_digest/def/refs for symbol navigation.",
|
|
24
|
-
"- Use readseek_edit for existing files, readseek_write for whole-file creation or replacement, and readseek_rename for symbol renames.",
|
|
25
|
-
"- Do not use built-in read, edit, write, grep, or apply_patch when a ReadSeek tool can do the job.",
|
|
26
|
-
"- Run readseek_digest with select=diagnostics after source edits for a quick syntax check.",
|
|
27
|
-
].join("\n");
|
|
28
|
-
const PREFERRED_TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
29
|
-
readseek_read: "Preferred file reader; returns LINE:HASH anchors for durable references.",
|
|
30
|
-
readseek_edit: "Preferred tool for editing existing text files with fresh LINE:HASH anchors.",
|
|
31
|
-
readseek_write: "Preferred tool for creating or replacing a complete text file; returns LINE:HASH anchors.",
|
|
32
|
-
readseek_rename: "Use to rename a symbol with binding-aware, verified edits.",
|
|
33
|
-
readseek_grep: "Preferred text/regex search; returns edit-ready LINE:HASH anchors.",
|
|
34
|
-
readseek_search: "Use for AST or structural code search with ast-grep-style patterns.",
|
|
35
|
-
readseek_digest: "Use to digest native facets (metadata, content, map, diagnostics, identity) and return the full envelope.",
|
|
36
|
-
readseek_def: "Use for go-to-definition or declaration lookup by name.",
|
|
37
|
-
readseek_refs: "Use to find identifier-name matches or binding-specific references before rename or delete.",
|
|
38
|
-
readseek_view: "Use to digest document structure or selected content.",
|
|
39
|
-
};
|
|
40
|
-
const FALLBACK_TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
41
|
-
read: "Fallback only. Prefer readseek_read so edits can use LINE:HASH anchors.",
|
|
42
|
-
edit: "Fallback only. Prefer readseek_edit with fresh LINE:HASH anchors.",
|
|
43
|
-
write: "Fallback only. Prefer readseek_write so the result returns LINE:HASH anchors.",
|
|
44
|
-
grep: "Fallback only. Prefer readseek_grep for text/regex search with edit-ready anchors.",
|
|
45
|
-
apply_patch: "Fallback only. Prefer readseek_edit for existing text files.",
|
|
46
|
-
};
|
|
47
|
-
const READSEEK_PLATFORM_PACKAGES: Record<string, string> = {
|
|
48
|
-
"darwin-arm64": "@jarkkojs/readseek-darwin-arm64",
|
|
49
|
-
"linux-arm64": "@jarkkojs/readseek-linux-arm64",
|
|
50
|
-
"linux-x64": "@jarkkojs/readseek-linux-x64",
|
|
51
|
-
"win32-x64": "@jarkkojs/readseek-win32-x64",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
type RenamePlan = {
|
|
55
|
-
summary: string;
|
|
56
|
-
files: Set<string>;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
type PresentationKind = "read" | "view" | "digest" | "search" | "grep" | "def" | "refs" | "rename" | "edit" | "write";
|
|
60
|
-
|
|
61
|
-
type Presentation = {
|
|
62
|
-
title: string;
|
|
63
|
-
metadata: Record<string, number>;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
type RenderedToolResult = {
|
|
67
|
-
output: string;
|
|
68
|
-
attachments?: ToolAttachment[];
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
type ImagePolicy = "on" | "auto" | "off";
|
|
72
|
-
type ImageMode = "none" | "all" | "ocr" | "caption" | "objects";
|
|
73
|
-
type VisionAnalysisMode = Exclude<ImageMode, "none">;
|
|
74
|
-
type VisionLevel = "low" | "medium" | "high";
|
|
75
|
-
type DocumentNodeKind = "artifact" | "footer" | "header" | "heading" | "marginal_label" | "page" | "page_number" | "paragraph" | "section" | "structural_section";
|
|
76
|
-
const VISION_ANALYSIS_MODES: [VisionAnalysisMode, ...VisionAnalysisMode[]] = ["all", "ocr", "caption", "objects"];
|
|
77
|
-
const VISION_LEVELS: [VisionLevel, ...VisionLevel[]] = ["low", "medium", "high"];
|
|
78
|
-
|
|
79
|
-
const DOCUMENT_NODE_KINDS: [DocumentNodeKind, ...DocumentNodeKind[]] = [
|
|
80
|
-
"artifact",
|
|
81
|
-
"footer",
|
|
82
|
-
"header",
|
|
83
|
-
"heading",
|
|
84
|
-
"marginal_label",
|
|
85
|
-
"page",
|
|
86
|
-
"page_number",
|
|
87
|
-
"paragraph",
|
|
88
|
-
"section",
|
|
89
|
-
"structural_section",
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
function resolveImagePolicy(options: PluginOptions | undefined): ImagePolicy {
|
|
93
|
-
const value = options?.imageMode;
|
|
94
|
-
if (value === undefined) return "auto";
|
|
95
|
-
if (value === "on" || value === "auto" || value === "off") return value;
|
|
96
|
-
throw new Error('opencode-readseek imageMode must be "on", "auto", or "off"');
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function isVisualFile(value: unknown): boolean {
|
|
100
|
-
const output = record(value);
|
|
101
|
-
const type = output.type;
|
|
102
|
-
return typeof output.width === "number"
|
|
103
|
-
|| (typeof type === "string" && type.startsWith("image/"));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function isVisualBoundsError(error: unknown): boolean {
|
|
107
|
-
if (!(error instanceof Error)) return false;
|
|
108
|
-
const message = error.message.replace(/^error:\s*/, "");
|
|
109
|
-
return message.startsWith("--end")
|
|
110
|
-
&& /(?:does|do) not apply to images/.test(message);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function isDocumentBoundsError(error: unknown): boolean {
|
|
114
|
-
if (!(error instanceof Error)) return false;
|
|
115
|
-
const message = error.message.replace(/^error:\s*/, "");
|
|
116
|
-
return /(?:does|do) not apply to (?:documents|PDFs)/.test(message)
|
|
117
|
-
|| /document content belongs to view/.test(message)
|
|
118
|
-
|| /source facets do not apply to documents/.test(message);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function isPdfFile(value: unknown): boolean {
|
|
122
|
-
const output = record(value);
|
|
123
|
-
return output.format === "pdf" || output.type === "application/pdf";
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function documentReceipt(filePath: string, envelope: unknown): Record<string, unknown> {
|
|
127
|
-
const recordEnvelope = record(envelope);
|
|
128
|
-
return {
|
|
129
|
-
file: filePath,
|
|
130
|
-
use: "readseek_view",
|
|
131
|
-
reason: "document content belongs to readseek_view",
|
|
132
|
-
metadata: digestMetadata(envelope),
|
|
133
|
-
...(recordEnvelope.cache !== undefined ? { cache: recordEnvelope.cache } : {}),
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function digestDocumentReceipt(context: ToolContext, filePath: string): Promise<Record<string, unknown>> {
|
|
138
|
-
const cacheDir = await ensureReadSeekCache(context);
|
|
139
|
-
const envelope = await runReadSeek(context, [
|
|
140
|
-
"--readseek-dir",
|
|
141
|
-
cacheDir,
|
|
142
|
-
...digestArgs(filePath, "metadata"),
|
|
143
|
-
]);
|
|
144
|
-
return documentReceipt(filePath, envelope);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function digestArgs(filePath: string, select: string, extra: string[] = []): string[] {
|
|
148
|
-
return ["digest", filePath, "--select", select, ...extra];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function requireDigestFacet(envelope: unknown, facet: "content" | "map" | "diagnostics" | "identity"): unknown {
|
|
152
|
-
const value = record(envelope)[facet];
|
|
153
|
-
if (value === undefined || value === null || typeof value !== "object") {
|
|
154
|
-
throw new Error(`digest returned no ${facet} facet`);
|
|
155
|
-
}
|
|
156
|
-
return value;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function digestMetadata(envelope: unknown): Record<string, unknown> {
|
|
160
|
-
return record(record(envelope).metadata);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
class SessionAnchors {
|
|
164
|
-
#pathsBySession = new Map<string, Set<string>>();
|
|
165
|
-
#renamePlans = new Map<string, RenamePlan>();
|
|
166
|
-
|
|
167
|
-
mark(sessionID: string, filePath: string): void {
|
|
168
|
-
let paths = this.#pathsBySession.get(sessionID);
|
|
169
|
-
if (!paths) {
|
|
170
|
-
paths = new Set<string>();
|
|
171
|
-
this.#pathsBySession.set(sessionID, paths);
|
|
172
|
-
}
|
|
173
|
-
paths.add(filePath);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
forget(filePath: string): void {
|
|
177
|
-
const absolutePath = path.resolve(filePath);
|
|
178
|
-
for (const paths of this.#pathsBySession.values()) paths.delete(absolutePath);
|
|
179
|
-
for (const [sessionID, plan] of this.#renamePlans) {
|
|
180
|
-
if (plan.files.has(absolutePath)) this.#renamePlans.delete(sessionID);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
deleteSession(sessionID: string): void {
|
|
185
|
-
this.#pathsBySession.delete(sessionID);
|
|
186
|
-
this.#renamePlans.delete(sessionID);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
planRename(sessionID: string, output: unknown): void {
|
|
190
|
-
if (!output || typeof output !== "object") return;
|
|
191
|
-
const record = output as Record<string, unknown>;
|
|
192
|
-
if (record.applied === true) {
|
|
193
|
-
this.#renamePlans.delete(sessionID);
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
const { file, old_name: oldName, new_name: newName, others } = record;
|
|
197
|
-
if (typeof file !== "string" || typeof oldName !== "string" || typeof newName !== "string") return;
|
|
198
|
-
|
|
199
|
-
const files = new Set([path.resolve(file)]);
|
|
200
|
-
if (Array.isArray(others)) {
|
|
201
|
-
for (const item of others) {
|
|
202
|
-
if (!item || typeof item !== "object") continue;
|
|
203
|
-
const otherFile = (item as Record<string, unknown>).file;
|
|
204
|
-
if (typeof otherFile === "string") files.add(path.resolve(otherFile));
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
this.#renamePlans.set(sessionID, { summary: `${oldName} -> ${newName}`, files });
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
render(sessionID: string): string | undefined {
|
|
211
|
-
const paths = this.#pathsBySession.get(sessionID);
|
|
212
|
-
const sections: string[] = [];
|
|
213
|
-
if (paths?.size) {
|
|
214
|
-
sections.push(`## ReadSeek Anchors\nThe following files have fresh LINE:HASH anchors:\n${[...paths]
|
|
215
|
-
.sort()
|
|
216
|
-
.map((filePath) => `- ${filePath}`)
|
|
217
|
-
.join("\n")}`);
|
|
218
|
-
}
|
|
219
|
-
const renamePlan = this.#renamePlans.get(sessionID);
|
|
220
|
-
if (renamePlan) sections.push(`## Pending ReadSeek Rename Plan\n- ${renamePlan.summary}`);
|
|
221
|
-
return sections.length === 0 ? undefined : sections.join("\n\n");
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function resolvePath(directory: string, filePath: string): string {
|
|
226
|
-
return path.resolve(directory, filePath);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function containsPath(directory: string, filePath: string): boolean {
|
|
230
|
-
const relative = path.relative(directory, filePath);
|
|
231
|
-
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async function resolveSymlinks(filePath: string): Promise<string> {
|
|
235
|
-
let unresolved = path.resolve(filePath);
|
|
236
|
-
let symlinks = 0;
|
|
237
|
-
|
|
238
|
-
while (true) {
|
|
239
|
-
const parsed = path.parse(unresolved);
|
|
240
|
-
const parts = unresolved.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
241
|
-
let resolved = parsed.root;
|
|
242
|
-
let restart = false;
|
|
243
|
-
|
|
244
|
-
for (let index = 0; index < parts.length; index++) {
|
|
245
|
-
const candidate = path.join(resolved, parts[index]);
|
|
246
|
-
const info = await lstat(candidate);
|
|
247
|
-
if (!info.isSymbolicLink()) {
|
|
248
|
-
resolved = candidate;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
symlinks++;
|
|
253
|
-
if (symlinks > 40) {
|
|
254
|
-
const error = new Error(`too many symbolic links: ${filePath}`) as NodeJS.ErrnoException;
|
|
255
|
-
error.code = "ELOOP";
|
|
256
|
-
throw error;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const target = await readlink(candidate);
|
|
260
|
-
unresolved = path.resolve(path.dirname(candidate), target, ...parts.slice(index + 1));
|
|
261
|
-
restart = true;
|
|
262
|
-
break;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (!restart) return resolved;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async function portableRealpath(filePath: string): Promise<string> {
|
|
270
|
-
try {
|
|
271
|
-
return await realpath(filePath);
|
|
272
|
-
} catch (error) {
|
|
273
|
-
if ((error as NodeJS.ErrnoException).code !== "EACCES") throw error;
|
|
274
|
-
return resolveSymlinks(filePath);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
async function canonicalPath(filePath: string): Promise<string> {
|
|
279
|
-
const missing: string[] = [];
|
|
280
|
-
let existing = filePath;
|
|
281
|
-
while (true) {
|
|
282
|
-
try {
|
|
283
|
-
return path.join(await portableRealpath(existing), ...missing.reverse());
|
|
284
|
-
} catch (error) {
|
|
285
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
286
|
-
const parent = path.dirname(existing);
|
|
287
|
-
if (parent === existing) throw error;
|
|
288
|
-
missing.push(path.basename(existing));
|
|
289
|
-
existing = parent;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
async function authorizeExternal(context: ToolContext, filePath: string): Promise<void> {
|
|
295
|
-
const [accessPath, directory, worktree] = await Promise.all([
|
|
296
|
-
canonicalPath(filePath),
|
|
297
|
-
canonicalPath(context.directory),
|
|
298
|
-
context.worktree === "/" ? Promise.resolve("/") : canonicalPath(context.worktree),
|
|
299
|
-
]);
|
|
300
|
-
if (containsPath(directory, accessPath) || (worktree !== "/" && containsPath(worktree, accessPath))) {
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const info = await stat(accessPath).catch(() => undefined);
|
|
305
|
-
const parentDir = info?.isDirectory() ? accessPath : path.dirname(accessPath);
|
|
306
|
-
const pattern = path.join(parentDir, "*").replaceAll("\\", "/");
|
|
307
|
-
await context.ask({
|
|
308
|
-
permission: "external_directory",
|
|
309
|
-
patterns: [pattern],
|
|
310
|
-
always: [pattern],
|
|
311
|
-
metadata: { filepath: filePath, parentDir },
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
async function rejectSymlinkMutation(filePath: string): Promise<void> {
|
|
316
|
-
const info = await lstat(filePath).catch((error: NodeJS.ErrnoException) => {
|
|
317
|
-
if (error.code === "ENOENT") return undefined;
|
|
318
|
-
throw error;
|
|
319
|
-
});
|
|
320
|
-
if (info?.isSymbolicLink()) throw new Error(`refusing to modify symbolic link: ${filePath}`);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
async function authorizeRead(context: ToolContext, filePath: string): Promise<void> {
|
|
324
|
-
await authorizeExternal(context, filePath);
|
|
325
|
-
await context.ask({
|
|
326
|
-
permission: "read",
|
|
327
|
-
patterns: [path.relative(context.worktree, filePath).replaceAll("\\", "/")],
|
|
328
|
-
always: ["*"],
|
|
329
|
-
metadata: {},
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
async function authorizeSearch(context: ToolContext, filePath: string, pattern: string): Promise<void> {
|
|
334
|
-
await context.ask({
|
|
335
|
-
permission: "grep",
|
|
336
|
-
patterns: [pattern],
|
|
337
|
-
always: ["*"],
|
|
338
|
-
metadata: { pattern, path: filePath },
|
|
339
|
-
});
|
|
340
|
-
await authorizeExternal(context, filePath);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
async function authorizeEdit(
|
|
344
|
-
context: ToolContext,
|
|
345
|
-
filePaths: string[],
|
|
346
|
-
diff = "",
|
|
347
|
-
authorizeExternalPaths = true,
|
|
348
|
-
): Promise<void> {
|
|
349
|
-
if (authorizeExternalPaths) {
|
|
350
|
-
for (const filePath of filePaths) await authorizeExternal(context, filePath);
|
|
351
|
-
}
|
|
352
|
-
const patterns = filePaths.map((filePath) => path.relative(context.worktree, filePath).replaceAll("\\", "/"));
|
|
353
|
-
await context.ask({
|
|
354
|
-
permission: "edit",
|
|
355
|
-
patterns,
|
|
356
|
-
always: ["*"],
|
|
357
|
-
metadata: { filepath: filePaths.join(", "), diff },
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function optionalFlag(args: string[], enabled: boolean | undefined, flag: string): void {
|
|
362
|
-
if (enabled) args.push(flag);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function atLocation(line: number, column?: number): string {
|
|
366
|
-
return column === undefined ? `line:${line}` : `line:${line}:${column}`;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function withAt(args: string[], line: number, column?: number): void {
|
|
370
|
-
args.push("--at", atLocation(line, column));
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function isLikelyImagePath(filePath: string): boolean {
|
|
374
|
-
return /\.(avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i.test(filePath);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function isLikelyDocumentPath(filePath: string): boolean {
|
|
378
|
-
return /\.pdf$/i.test(filePath);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function readRangeArgs(filePath: string, start: number, end: number): string[] {
|
|
382
|
-
const extra: string[] = [];
|
|
383
|
-
if (start > 1) withAt(extra, start);
|
|
384
|
-
extra.push("--end", String(end));
|
|
385
|
-
return digestArgs(filePath, "content", extra);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async function runDigestContent(
|
|
389
|
-
context: ToolContext,
|
|
390
|
-
args: string[],
|
|
391
|
-
options: { cancelable?: boolean; timeoutMs?: number } = {},
|
|
392
|
-
): Promise<unknown> {
|
|
393
|
-
return requireDigestFacet(await runReadSeek(context, args, options), "content");
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function readSeekBinaryPath(): string {
|
|
397
|
-
const platform = `${process.platform}-${process.arch}`;
|
|
398
|
-
const platformPackage = READSEEK_PLATFORM_PACKAGES[platform];
|
|
399
|
-
if (!platformPackage) {
|
|
400
|
-
throw new Error(`@jarkkojs/readseek ships no binary for ${platform}`);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const readseekPackageDir = path.dirname(require.resolve("@jarkkojs/readseek/package.json"));
|
|
404
|
-
const packageJson = require.resolve(`${platformPackage}/package.json`, { paths: [readseekPackageDir] });
|
|
405
|
-
return path.join(path.dirname(packageJson), "bin", process.platform === "win32" ? "readseek.exe" : "readseek");
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
async function readBoundedText(
|
|
409
|
-
stream: ReadableStream<Uint8Array>,
|
|
410
|
-
streamName: "stdout" | "stderr",
|
|
411
|
-
onOverflow: () => void,
|
|
412
|
-
): Promise<string> {
|
|
413
|
-
const reader = stream.getReader();
|
|
414
|
-
const chunks: Uint8Array[] = [];
|
|
415
|
-
let totalBytes = 0;
|
|
416
|
-
try {
|
|
417
|
-
while (true) {
|
|
418
|
-
const { done, value } = await reader.read();
|
|
419
|
-
if (done) break;
|
|
420
|
-
totalBytes += value.byteLength;
|
|
421
|
-
if (totalBytes > MAX_OUTPUT_BYTES) {
|
|
422
|
-
onOverflow();
|
|
423
|
-
await reader.cancel().catch(() => undefined);
|
|
424
|
-
throw new Error(`readseek ${streamName} exceeded the ${MAX_OUTPUT_BYTES}-byte output limit`);
|
|
425
|
-
}
|
|
426
|
-
chunks.push(value);
|
|
427
|
-
}
|
|
428
|
-
} finally {
|
|
429
|
-
reader.releaseLock();
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const bytes = new Uint8Array(totalBytes);
|
|
433
|
-
let offset = 0;
|
|
434
|
-
for (const chunk of chunks) {
|
|
435
|
-
bytes.set(chunk, offset);
|
|
436
|
-
offset += chunk.byteLength;
|
|
437
|
-
}
|
|
438
|
-
return new TextDecoder().decode(bytes);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
async function runReadSeekRaw(
|
|
442
|
-
context: ToolContext,
|
|
443
|
-
args: string[],
|
|
444
|
-
options: { cancelable?: boolean; stdin?: string; timeoutMs?: number } = {},
|
|
445
|
-
): Promise<string> {
|
|
446
|
-
context.abort.throwIfAborted();
|
|
447
|
-
const cancelable = options.cancelable !== false;
|
|
448
|
-
const spawnOptions = {
|
|
449
|
-
cwd: context.directory,
|
|
450
|
-
stderr: "pipe" as const,
|
|
451
|
-
stdin: options.stdin === undefined ? undefined : Buffer.from(options.stdin),
|
|
452
|
-
stdout: "pipe" as const,
|
|
453
|
-
};
|
|
454
|
-
const child = Bun.spawn(
|
|
455
|
-
[readSeekBinaryPath(), ...args],
|
|
456
|
-
cancelable
|
|
457
|
-
? { ...spawnOptions, killSignal: "SIGKILL" as const, signal: context.abort }
|
|
458
|
-
: spawnOptions,
|
|
459
|
-
);
|
|
460
|
-
let killedForOverflow = false;
|
|
461
|
-
const killForOverflow = () => {
|
|
462
|
-
if (killedForOverflow) return;
|
|
463
|
-
killedForOverflow = true;
|
|
464
|
-
try {
|
|
465
|
-
child.kill("SIGKILL");
|
|
466
|
-
} catch {
|
|
467
|
-
// Preserve the output-limit error if the process exited concurrently.
|
|
468
|
-
}
|
|
469
|
-
};
|
|
470
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_READSEEK_TIMEOUT_MS;
|
|
471
|
-
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
472
|
-
const timeoutResult = new Promise<never>((_resolve, reject) => {
|
|
473
|
-
timeout = setTimeout(() => {
|
|
474
|
-
try {
|
|
475
|
-
child.kill("SIGKILL");
|
|
476
|
-
} catch {
|
|
477
|
-
// Preserve the timeout error if the process exited concurrently.
|
|
478
|
-
}
|
|
479
|
-
reject(new Error(`readseek timed out after ${timeoutMs} ms`));
|
|
480
|
-
}, timeoutMs);
|
|
481
|
-
timeout.unref?.();
|
|
482
|
-
});
|
|
483
|
-
const pending = Promise.all([
|
|
484
|
-
readBoundedText(child.stdout, "stdout", killForOverflow),
|
|
485
|
-
readBoundedText(child.stderr, "stderr", killForOverflow),
|
|
486
|
-
child.exited,
|
|
487
|
-
]);
|
|
488
|
-
let result: Awaited<typeof pending>;
|
|
489
|
-
try {
|
|
490
|
-
result = await Promise.race([pending, timeoutResult]);
|
|
491
|
-
} finally {
|
|
492
|
-
if (timeout !== undefined) clearTimeout(timeout);
|
|
493
|
-
}
|
|
494
|
-
const [stdout, stderr, exitCode] = result;
|
|
495
|
-
if (cancelable) context.abort.throwIfAborted();
|
|
496
|
-
if (exitCode !== 0) throw new Error(stderr.trim() || `readseek exited with status ${exitCode}`);
|
|
497
|
-
return stdout;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
async function runReadSeek(
|
|
501
|
-
context: ToolContext,
|
|
502
|
-
args: string[],
|
|
503
|
-
options: { cancelable?: boolean; stdin?: string; timeoutMs?: number } = {},
|
|
504
|
-
): Promise<unknown> {
|
|
505
|
-
const stdout = await runReadSeekRaw(context, args, options);
|
|
506
|
-
try {
|
|
507
|
-
return JSON.parse(stdout) as unknown;
|
|
508
|
-
} catch {
|
|
509
|
-
throw new Error(`readseek returned invalid JSON: ${stdout.trim()}`);
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
async function postMutationPreview(
|
|
514
|
-
context: ToolContext,
|
|
515
|
-
args: string[],
|
|
516
|
-
filePath: string,
|
|
517
|
-
changed: boolean,
|
|
518
|
-
): Promise<unknown> {
|
|
519
|
-
try {
|
|
520
|
-
return await runDigestContent(context, args, {
|
|
521
|
-
cancelable: false,
|
|
522
|
-
timeoutMs: DEFAULT_READSEEK_TIMEOUT_MS,
|
|
523
|
-
});
|
|
524
|
-
} catch (error) {
|
|
525
|
-
const previewError = error instanceof Error ? error.message : String(error);
|
|
526
|
-
return {
|
|
527
|
-
file: filePath,
|
|
528
|
-
changed,
|
|
529
|
-
preview_error: previewError,
|
|
530
|
-
warning: "File mutation completed, but the post-write preview failed. Do not retry the mutation; read the file to verify content and refresh anchors.",
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
let readSeekCacheInit: Promise<string> | undefined;
|
|
536
|
-
|
|
537
|
-
function readSeekCacheDir(): string {
|
|
538
|
-
const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim();
|
|
539
|
-
const cacheHome = xdgCacheHome || path.join(homedir(), ".cache");
|
|
540
|
-
return path.join(cacheHome, "opencode", "readseek");
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
async function ensureReadSeekCache(context: ToolContext): Promise<string> {
|
|
544
|
-
const cacheDir = readSeekCacheDir();
|
|
545
|
-
try {
|
|
546
|
-
const info = await stat(cacheDir);
|
|
547
|
-
if (!info.isDirectory()) throw new Error(`readseek cache is not a directory: ${cacheDir}`);
|
|
548
|
-
return cacheDir;
|
|
549
|
-
} catch (error) {
|
|
550
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
readSeekCacheInit ??= runReadSeekRaw(context, ["--readseek-dir", cacheDir, "init"])
|
|
554
|
-
.then(() => cacheDir)
|
|
555
|
-
.finally(() => {
|
|
556
|
-
readSeekCacheInit = undefined;
|
|
557
|
-
});
|
|
558
|
-
return readSeekCacheInit;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function render(value: unknown): string {
|
|
562
|
-
return JSON.stringify(value, null, 2);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
function collectAnchoredFiles(value: unknown, files: Set<string>): void {
|
|
566
|
-
if (Array.isArray(value)) {
|
|
567
|
-
for (const item of value) collectAnchoredFiles(item, files);
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
if (!value || typeof value !== "object") return;
|
|
571
|
-
|
|
572
|
-
const output = value as Record<string, unknown>;
|
|
573
|
-
const hashlines = output.hashlines;
|
|
574
|
-
if (
|
|
575
|
-
typeof output.file === "string" &&
|
|
576
|
-
Array.isArray(hashlines) &&
|
|
577
|
-
hashlines.some((line) => typeof record(line).line === "number" && typeof record(line).hash === "string")
|
|
578
|
-
) files.add(output.file);
|
|
579
|
-
for (const item of Object.values(output)) collectAnchoredFiles(item, files);
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
type AnchorEdit =
|
|
583
|
-
| { set_line: { anchor: string; new_text: string } }
|
|
584
|
-
| { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
|
|
585
|
-
| { insert_after: { anchor: string; new_text: string } };
|
|
586
|
-
|
|
587
|
-
type EditItem =
|
|
588
|
-
| AnchorEdit
|
|
589
|
-
| { replace: { old_text: string; new_text: string; all?: boolean } }
|
|
590
|
-
| { replace_symbol: { symbol: string; new_body: string } };
|
|
591
|
-
|
|
592
|
-
type TextSnapshot = {
|
|
593
|
-
exists: boolean;
|
|
594
|
-
content: string;
|
|
595
|
-
};
|
|
596
|
-
|
|
597
|
-
const fileMutationTails = new Map<string, Promise<void>>();
|
|
598
|
-
const MAX_PERMISSION_DIFF_BYTES = 32 * 1024;
|
|
599
|
-
|
|
600
|
-
async function withFileMutationQueue<T>(filePath: string, operation: () => Promise<T>): Promise<T> {
|
|
601
|
-
const key = path.resolve(filePath);
|
|
602
|
-
const previous = fileMutationTails.get(key);
|
|
603
|
-
let release = () => {};
|
|
604
|
-
const current = new Promise<void>((resolve) => {
|
|
605
|
-
release = resolve;
|
|
606
|
-
});
|
|
607
|
-
fileMutationTails.set(key, current);
|
|
608
|
-
if (previous) await previous;
|
|
609
|
-
try {
|
|
610
|
-
return await operation();
|
|
611
|
-
} finally {
|
|
612
|
-
release();
|
|
613
|
-
if (fileMutationTails.get(key) === current) fileMutationTails.delete(key);
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function validateEdits(value: unknown): asserts value is EditItem[] {
|
|
618
|
-
if (!Array.isArray(value) || value.length === 0) throw new Error("edits must contain at least one edit");
|
|
619
|
-
const requiredFields = {
|
|
620
|
-
set_line: ["anchor", "new_text"],
|
|
621
|
-
replace_lines: ["end_anchor", "new_text", "start_anchor"],
|
|
622
|
-
insert_after: ["anchor", "new_text"],
|
|
623
|
-
replace: ["new_text", "old_text"],
|
|
624
|
-
replace_symbol: ["new_body", "symbol"],
|
|
625
|
-
} as const;
|
|
626
|
-
for (const [index, item] of value.entries()) {
|
|
627
|
-
if (!item || typeof item !== "object") throw new Error(`edits[${index}] must be an edit object`);
|
|
628
|
-
const keys = Object.keys(item);
|
|
629
|
-
const variant = keys[0] as keyof typeof requiredFields;
|
|
630
|
-
if (keys.length !== 1 || !Object.hasOwn(requiredFields, variant)) {
|
|
631
|
-
throw new Error(`edits[${index}] must contain exactly one of: set_line, replace_lines, insert_after, replace, replace_symbol`);
|
|
632
|
-
}
|
|
633
|
-
const payload = (item as Record<string, unknown>)[variant];
|
|
634
|
-
if (!payload || typeof payload !== "object") throw new Error(`edits[${index}].${variant} must be an object`);
|
|
635
|
-
const expectedFields: readonly string[] = requiredFields[variant];
|
|
636
|
-
const allowedFields = variant === "replace" ? [...expectedFields, "all"] : expectedFields;
|
|
637
|
-
const actualFields = Object.keys(payload).sort();
|
|
638
|
-
if (actualFields.some((field) => !allowedFields.includes(field)) || expectedFields.some((field) => !actualFields.includes(field))) {
|
|
639
|
-
throw new Error(`edits[${index}].${variant} contains invalid fields`);
|
|
640
|
-
}
|
|
641
|
-
const values = payload as Record<string, unknown>;
|
|
642
|
-
for (const field of expectedFields) {
|
|
643
|
-
if (typeof values[field] !== "string") {
|
|
644
|
-
throw new Error(`edits[${index}].${variant}.${field} must be a string`);
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
if (variant === "replace" && values.all !== undefined && typeof values.all !== "boolean") {
|
|
648
|
-
throw new Error(`edits[${index}].replace.all must be a boolean`);
|
|
649
|
-
}
|
|
650
|
-
for (const field of expectedFields.filter((field) => field.endsWith("anchor"))) {
|
|
651
|
-
parseAnchor(values[field] as string);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
function parseAnchor(anchor: string): { line: number; hash: string } {
|
|
657
|
-
const match = /^(\d+):([0-9a-fA-F]{3})$/.exec(anchor.trim());
|
|
658
|
-
if (!match) throw new Error(`invalid LINE:HASH anchor: ${anchor}`);
|
|
659
|
-
const line = Number(match[1]);
|
|
660
|
-
if (line === 0) throw new Error(`anchor line must be greater than zero: ${anchor}`);
|
|
661
|
-
return { line, hash: match[2].toLowerCase() };
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function firstChangedLine(before: string, after: string): number {
|
|
665
|
-
const beforeLines = normalizeToLf(before).split("\n");
|
|
666
|
-
const afterLines = normalizeToLf(after).split("\n");
|
|
667
|
-
const count = Math.max(beforeLines.length, afterLines.length);
|
|
668
|
-
for (let index = 0; index < count; index++) {
|
|
669
|
-
if (beforeLines[index] !== afterLines[index]) return index + 1;
|
|
670
|
-
}
|
|
671
|
-
return 1;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
async function readTextSnapshot(filePath: string): Promise<TextSnapshot> {
|
|
676
|
-
try {
|
|
677
|
-
return { exists: true, content: await readFile(filePath, "utf8") };
|
|
678
|
-
} catch (error) {
|
|
679
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false, content: "" };
|
|
680
|
-
throw error;
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
async function writeHandle(handle: Awaited<ReturnType<typeof open>>, content: string): Promise<void> {
|
|
685
|
-
const data = Buffer.from(content, "utf8");
|
|
686
|
-
await handle.truncate(0);
|
|
687
|
-
let offset = 0;
|
|
688
|
-
while (offset < data.length) {
|
|
689
|
-
const { bytesWritten } = await handle.write(data, offset, data.length - offset, offset);
|
|
690
|
-
if (bytesWritten === 0) throw new Error("write made no progress");
|
|
691
|
-
offset += bytesWritten;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
async function writeText(filePath: string, expected: TextSnapshot, content: string): Promise<void> {
|
|
696
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
697
|
-
if (!expected.exists) {
|
|
698
|
-
let handle: Awaited<ReturnType<typeof open>>;
|
|
699
|
-
try {
|
|
700
|
-
handle = await open(filePath, "wx+");
|
|
701
|
-
} catch (error) {
|
|
702
|
-
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
703
|
-
throw new Error(`refusing to overwrite changed file: ${filePath}`);
|
|
704
|
-
}
|
|
705
|
-
throw error;
|
|
706
|
-
}
|
|
707
|
-
try {
|
|
708
|
-
await writeHandle(handle, content);
|
|
709
|
-
} finally {
|
|
710
|
-
await handle.close();
|
|
711
|
-
}
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
const pathInfo = await lstat(filePath);
|
|
716
|
-
if (pathInfo.isSymbolicLink()) throw new Error(`refusing to modify symbolic link: ${filePath}`);
|
|
717
|
-
const handle = await open(filePath, "r+");
|
|
718
|
-
try {
|
|
719
|
-
const handleInfo = await handle.stat();
|
|
720
|
-
const currentPathInfo = await lstat(filePath);
|
|
721
|
-
if (
|
|
722
|
-
currentPathInfo.isSymbolicLink() ||
|
|
723
|
-
currentPathInfo.dev !== handleInfo.dev ||
|
|
724
|
-
currentPathInfo.ino !== handleInfo.ino
|
|
725
|
-
) {
|
|
726
|
-
throw new Error(`refusing to overwrite changed file: ${filePath}`);
|
|
727
|
-
}
|
|
728
|
-
const current = (await handle.readFile()).toString("utf8");
|
|
729
|
-
if (current !== expected.content) throw new Error(`refusing to overwrite changed file: ${filePath}`);
|
|
730
|
-
if (current !== content) await writeHandle(handle, content);
|
|
731
|
-
} finally {
|
|
732
|
-
await handle.close();
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
function renameFiles(value: unknown): string[] {
|
|
737
|
-
const output = record(value);
|
|
738
|
-
const files: string[] = [];
|
|
739
|
-
if (typeof output.file === "string" && items(output.edits).length > 0) files.push(path.resolve(output.file));
|
|
740
|
-
for (const other of items(output.others)) {
|
|
741
|
-
const item = record(other);
|
|
742
|
-
if (typeof item.file === "string" && items(item.edits).length > 0) files.push(path.resolve(item.file));
|
|
743
|
-
}
|
|
744
|
-
return files;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
function normalizeToLf(content: string): string {
|
|
748
|
-
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function diffLines(content: string): string[] {
|
|
752
|
-
if (content === "") return [];
|
|
753
|
-
const lines = content.split("\n");
|
|
754
|
-
if (lines.at(-1) === "") lines.pop();
|
|
755
|
-
return lines.map((line) => line.replace(/\r/g, "\\r"));
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
function hasFinalNewline(content: string): boolean {
|
|
759
|
-
return content === "" || /[\r\n]$/.test(content);
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
function diffRange(start: number, count: number): string {
|
|
763
|
-
return count === 0 ? `${start},0` : `${start + 1},${count}`;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
function utf8Prefix(value: string, maxBytes: number): string {
|
|
767
|
-
const bytes = Buffer.from(value, "utf8");
|
|
768
|
-
if (bytes.length <= maxBytes) return value;
|
|
769
|
-
let end = maxBytes;
|
|
770
|
-
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
771
|
-
while (end > 0) {
|
|
772
|
-
try {
|
|
773
|
-
return decoder.decode(bytes.subarray(0, end));
|
|
774
|
-
} catch {
|
|
775
|
-
end--;
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
return "";
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
function boundDocumentOutput(value: string): string {
|
|
782
|
-
const lines = value.split("\n");
|
|
783
|
-
let output = lines.length > MAX_DOCUMENT_OUTPUT_LINES
|
|
784
|
-
? lines.slice(0, MAX_DOCUMENT_OUTPUT_LINES).join("\n")
|
|
785
|
-
: value;
|
|
786
|
-
const truncated = output !== value || Buffer.byteLength(output, "utf8") > MAX_DOCUMENT_OUTPUT_BYTES;
|
|
787
|
-
output = utf8Prefix(output, MAX_DOCUMENT_OUTPUT_BYTES);
|
|
788
|
-
return truncated
|
|
789
|
-
? `${output}\n[… document view truncated; narrow it with page, node, kind, or depth]`
|
|
790
|
-
: output;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
function attachmentExtension(mime: string): string {
|
|
794
|
-
switch (mime) {
|
|
795
|
-
case "image/gif":
|
|
796
|
-
return "gif";
|
|
797
|
-
case "image/jpeg":
|
|
798
|
-
return "jpg";
|
|
799
|
-
case "image/png":
|
|
800
|
-
return "png";
|
|
801
|
-
case "image/webp":
|
|
802
|
-
return "webp";
|
|
803
|
-
default:
|
|
804
|
-
return "bin";
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
function imageAttachment(image: Record<string, unknown>, filename: string): ToolAttachment | undefined {
|
|
809
|
-
if (image.encoding !== "base64" || typeof image.data !== "string" || typeof image.mime !== "string") {
|
|
810
|
-
return undefined;
|
|
811
|
-
}
|
|
812
|
-
return {
|
|
813
|
-
type: "file",
|
|
814
|
-
mime: image.mime,
|
|
815
|
-
url: `data:${image.mime};base64,${image.data}`,
|
|
816
|
-
filename: `${filename}.${attachmentExtension(image.mime)}`,
|
|
817
|
-
};
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
function formatReadResult(value: unknown): RenderedToolResult {
|
|
821
|
-
const output = record(value);
|
|
822
|
-
if (output.use === "readseek_view") return { output: render(value) };
|
|
823
|
-
|
|
824
|
-
const attachment = imageAttachment(output, "image");
|
|
825
|
-
if (!attachment) return { output: render(value) };
|
|
826
|
-
const sanitized = { ...output };
|
|
827
|
-
delete sanitized.data;
|
|
828
|
-
delete sanitized.encoding;
|
|
829
|
-
return { output: render(sanitized), attachments: [attachment] };
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function previewDiffLine(prefix: string, line: string): string {
|
|
833
|
-
const value = `${prefix}${line}`;
|
|
834
|
-
const maxBytes = 256;
|
|
835
|
-
if (Buffer.byteLength(value) <= maxBytes) return value;
|
|
836
|
-
return `${utf8Prefix(value, maxBytes - Buffer.byteLength("…"))}…`;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function sampleDiffLines(lines: string[], start: number, end: number, prefix: string): string[] {
|
|
840
|
-
const sampleSize = 20;
|
|
841
|
-
const count = end - start;
|
|
842
|
-
if (count <= sampleSize * 2) {
|
|
843
|
-
return lines.slice(start, end).map((line) => previewDiffLine(prefix, line));
|
|
844
|
-
}
|
|
845
|
-
return [
|
|
846
|
-
...lines.slice(start, start + sampleSize).map((line) => previewDiffLine(prefix, line)),
|
|
847
|
-
` ${count - sampleSize * 2} ${prefix === "-" ? "removed" : "added"} lines omitted`,
|
|
848
|
-
...lines.slice(end - sampleSize, end).map((line) => previewDiffLine(prefix, line)),
|
|
849
|
-
];
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
function simpleDiff(filePath: string, before: string, after: string): string {
|
|
853
|
-
if (before === after) return "";
|
|
854
|
-
const oldLines = diffLines(before);
|
|
855
|
-
const newLines = diffLines(after);
|
|
856
|
-
const oldHasFinalNewline = hasFinalNewline(before);
|
|
857
|
-
const newHasFinalNewline = hasFinalNewline(after);
|
|
858
|
-
let prefix = 0;
|
|
859
|
-
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
|
|
860
|
-
let suffix = 0;
|
|
861
|
-
while (
|
|
862
|
-
suffix < oldLines.length - prefix &&
|
|
863
|
-
suffix < newLines.length - prefix &&
|
|
864
|
-
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
865
|
-
) suffix++;
|
|
866
|
-
if (prefix === oldLines.length && prefix === newLines.length) {
|
|
867
|
-
prefix = Math.max(0, prefix - 1);
|
|
868
|
-
suffix = 0;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
const oldChangeEnd = oldLines.length - suffix;
|
|
872
|
-
const newChangeEnd = newLines.length - suffix;
|
|
873
|
-
const oldStart = Math.max(0, prefix - 3);
|
|
874
|
-
const newStart = Math.max(0, prefix - 3);
|
|
875
|
-
const oldEnd = Math.min(oldLines.length, oldChangeEnd + 3);
|
|
876
|
-
const newEnd = Math.min(newLines.length, newChangeEnd + 3);
|
|
877
|
-
const safePath = utf8Prefix(filePath.replace(/[\r\n]/g, ""), 1024);
|
|
878
|
-
const header = [
|
|
879
|
-
`--- ${safePath}`,
|
|
880
|
-
`+++ ${safePath}`,
|
|
881
|
-
`@@ -${diffRange(oldStart, oldEnd - oldStart)} +${diffRange(newStart, newEnd - newStart)} @@`,
|
|
882
|
-
];
|
|
883
|
-
const fullDiff = [...header];
|
|
884
|
-
let fullDiffBytes = Buffer.byteLength(header.join("\n"));
|
|
885
|
-
let complete = true;
|
|
886
|
-
const ranges: Array<[string[], number, number, string, boolean]> = [
|
|
887
|
-
[oldLines, oldStart, prefix, " ", oldHasFinalNewline],
|
|
888
|
-
[oldLines, prefix, oldChangeEnd, "-", oldHasFinalNewline],
|
|
889
|
-
[newLines, prefix, newChangeEnd, "+", newHasFinalNewline],
|
|
890
|
-
[newLines, newChangeEnd, newEnd, " ", newHasFinalNewline],
|
|
891
|
-
];
|
|
892
|
-
outer: for (const [lines, start, end, marker, hasNewline] of ranges) {
|
|
893
|
-
for (let index = start; index < end; index++) {
|
|
894
|
-
const lineBytes = 2 + Buffer.byteLength(lines[index]);
|
|
895
|
-
if (fullDiffBytes + lineBytes > MAX_PERMISSION_DIFF_BYTES) {
|
|
896
|
-
complete = false;
|
|
897
|
-
break outer;
|
|
898
|
-
}
|
|
899
|
-
fullDiff.push(`${marker}${lines[index]}`);
|
|
900
|
-
fullDiffBytes += lineBytes;
|
|
901
|
-
if (index === lines.length - 1 && !hasNewline) {
|
|
902
|
-
const eofMarker = "\";
|
|
903
|
-
if (fullDiffBytes + Buffer.byteLength(eofMarker) + 1 > MAX_PERMISSION_DIFF_BYTES) {
|
|
904
|
-
complete = false;
|
|
905
|
-
break outer;
|
|
906
|
-
}
|
|
907
|
-
fullDiff.push(eofMarker);
|
|
908
|
-
fullDiffBytes += Buffer.byteLength(eofMarker) + 1;
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
if (complete) return fullDiff.join("\n");
|
|
913
|
-
|
|
914
|
-
const newlineSummary = oldHasFinalNewline === newHasFinalNewline
|
|
915
|
-
? []
|
|
916
|
-
: [` final newline: ${oldHasFinalNewline ? "present" : "missing"} -> ${newHasFinalNewline ? "present" : "missing"}`];
|
|
917
|
-
return [
|
|
918
|
-
...header,
|
|
919
|
-
` diff truncated: ${oldChangeEnd - prefix} removed, ${newChangeEnd - prefix} added lines`,
|
|
920
|
-
...newlineSummary,
|
|
921
|
-
...sampleDiffLines(oldLines, prefix, oldChangeEnd, "-"),
|
|
922
|
-
...sampleDiffLines(newLines, prefix, newChangeEnd, "+"),
|
|
923
|
-
].join("\n");
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
function identifiedName(value: unknown): string | undefined {
|
|
927
|
-
if (!value || typeof value !== "object") return undefined;
|
|
928
|
-
const identifier = (value as Record<string, unknown>).identifier;
|
|
929
|
-
if (!identifier || typeof identifier !== "object") return undefined;
|
|
930
|
-
const text = (identifier as Record<string, unknown>).text;
|
|
931
|
-
return typeof text === "string" ? text : undefined;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
function record(value: unknown): Record<string, unknown> {
|
|
935
|
-
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
function items(value: unknown): unknown[] {
|
|
939
|
-
return Array.isArray(value) ? value : [];
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
function initialTitle(kind: PresentationKind, args: any): string {
|
|
943
|
-
switch (kind) {
|
|
944
|
-
case "read":
|
|
945
|
-
return `Read ${args.path}`;
|
|
946
|
-
case "view":
|
|
947
|
-
return `View ${args.path}`;
|
|
948
|
-
case "digest":
|
|
949
|
-
return `Digest ${args.path}`;
|
|
950
|
-
case "search":
|
|
951
|
-
return `Search ${args.pattern}`;
|
|
952
|
-
case "grep":
|
|
953
|
-
return `Grep ${args.pattern}`;
|
|
954
|
-
case "def":
|
|
955
|
-
return `Find definition ${args.name}`;
|
|
956
|
-
case "refs":
|
|
957
|
-
return `Find references to ${args.name}`;
|
|
958
|
-
case "rename":
|
|
959
|
-
return `Rename ${args.path}:${args.line} to ${args.to}`;
|
|
960
|
-
case "edit":
|
|
961
|
-
return `Edit ${args.path}`;
|
|
962
|
-
case "write":
|
|
963
|
-
return `Write ${args.path}`;
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
function resultPresentation(kind: PresentationKind, args: any, value: unknown): Presentation {
|
|
968
|
-
const output = record(value);
|
|
969
|
-
switch (kind) {
|
|
970
|
-
case "read": {
|
|
971
|
-
const startLine = output.start_line;
|
|
972
|
-
const endLine = output.end_line;
|
|
973
|
-
if (typeof startLine === "number" && typeof endLine === "number") {
|
|
974
|
-
return {
|
|
975
|
-
title: `Read ${args.path}:${startLine}-${endLine}`,
|
|
976
|
-
metadata: { start_line: startLine, end_line: endLine, line_count: endLine - startLine + 1 },
|
|
977
|
-
};
|
|
978
|
-
}
|
|
979
|
-
const width = output.width;
|
|
980
|
-
const height = output.height;
|
|
981
|
-
if (typeof width === "number" && typeof height === "number") {
|
|
982
|
-
return { title: `Read ${args.path} (${width}x${height})`, metadata: { width, height } };
|
|
983
|
-
}
|
|
984
|
-
break;
|
|
985
|
-
}
|
|
986
|
-
case "view":
|
|
987
|
-
return { title: `Viewed ${args.path}`, metadata: {} };
|
|
988
|
-
case "digest": {
|
|
989
|
-
const map = record(output.map);
|
|
990
|
-
const diagnostics = record(output.diagnostics);
|
|
991
|
-
const identity = record(output.identity);
|
|
992
|
-
const content = record(output.content);
|
|
993
|
-
const metadata: Record<string, number> = {};
|
|
994
|
-
const summary: string[] = [];
|
|
995
|
-
if (output.map !== undefined) {
|
|
996
|
-
const symbols = items(map.symbols).length;
|
|
997
|
-
metadata.symbols = symbols;
|
|
998
|
-
summary.push(`${symbols} symbols`);
|
|
999
|
-
}
|
|
1000
|
-
if (output.diagnostics !== undefined) {
|
|
1001
|
-
const errors = typeof diagnostics.error_count === "number" ? diagnostics.error_count : 0;
|
|
1002
|
-
const missing = typeof diagnostics.missing_count === "number" ? diagnostics.missing_count : 0;
|
|
1003
|
-
metadata.error_count = errors;
|
|
1004
|
-
metadata.missing_count = missing;
|
|
1005
|
-
summary.push(`${errors} errors, ${missing} missing`);
|
|
1006
|
-
}
|
|
1007
|
-
if (output.identity !== undefined) {
|
|
1008
|
-
if (typeof identity.line === "number") metadata.line = identity.line;
|
|
1009
|
-
if (typeof identity.column === "number") metadata.column = identity.column;
|
|
1010
|
-
const identifier = record(identity.identifier).text;
|
|
1011
|
-
if (typeof identifier === "string") summary.push(identifier);
|
|
1012
|
-
}
|
|
1013
|
-
if (typeof content.start_line === "number") metadata.start_line = content.start_line;
|
|
1014
|
-
if (typeof content.end_line === "number") metadata.end_line = content.end_line;
|
|
1015
|
-
return {
|
|
1016
|
-
title: summary.length > 0 ? `Digested ${args.path} (${summary.join(", ")})` : `Digested ${args.path}`,
|
|
1017
|
-
metadata,
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
case "search": {
|
|
1021
|
-
const results = items(output.results);
|
|
1022
|
-
const matches = results.reduce<number>((total, result) => total + items(record(result).matches).length, 0);
|
|
1023
|
-
return { title: `Found ${matches} matches`, metadata: { results: results.length, matches } };
|
|
1024
|
-
}
|
|
1025
|
-
case "grep": {
|
|
1026
|
-
const results = items(output.results);
|
|
1027
|
-
const matches = results.reduce<number>((total, result) => total + items(record(result).matches).length, 0);
|
|
1028
|
-
return { title: `Found ${matches} matches`, metadata: { results: results.length, matches } };
|
|
1029
|
-
}
|
|
1030
|
-
case "def": {
|
|
1031
|
-
const locations = items(output.locations).length;
|
|
1032
|
-
return { title: `Found ${locations} definitions`, metadata: { locations } };
|
|
1033
|
-
}
|
|
1034
|
-
case "refs": {
|
|
1035
|
-
const references = items(output.references).length;
|
|
1036
|
-
return { title: `Found ${references} references`, metadata: { references } };
|
|
1037
|
-
}
|
|
1038
|
-
case "rename": {
|
|
1039
|
-
const oldName = output.old_name;
|
|
1040
|
-
const newName = output.new_name;
|
|
1041
|
-
const otherOutputs = items(output.others).map(record);
|
|
1042
|
-
const edits = otherOutputs.reduce((total, item) => total + items(item.edits).length, items(output.edits).length);
|
|
1043
|
-
const conflicts = otherOutputs.reduce(
|
|
1044
|
-
(total, item) => total + items(item.conflicts).length,
|
|
1045
|
-
items(output.conflicts).length,
|
|
1046
|
-
);
|
|
1047
|
-
const others = otherOutputs.length;
|
|
1048
|
-
const title =
|
|
1049
|
-
typeof oldName === "string" && typeof newName === "string"
|
|
1050
|
-
? `${output.applied === true ? "Renamed" : "Plan"} ${oldName} -> ${newName}`
|
|
1051
|
-
: initialTitle(kind, args);
|
|
1052
|
-
return { title, metadata: { edits, conflicts, others } };
|
|
1053
|
-
}
|
|
1054
|
-
case "edit":
|
|
1055
|
-
case "write": {
|
|
1056
|
-
const preview = typeof output.preview_error === "string" ? " (preview unavailable)" : "";
|
|
1057
|
-
return { title: `${kind === "edit" ? "Edited" : "Wrote"} ${args.path}${preview}`, metadata: {} };
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
return { title: initialTitle(kind, args), metadata: {} };
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
function readseekTool(
|
|
1064
|
-
description: string,
|
|
1065
|
-
args: Record<string, any>,
|
|
1066
|
-
kind: PresentationKind,
|
|
1067
|
-
execute: (args: any, context: ToolContext) => Promise<unknown>,
|
|
1068
|
-
formatResult: (result: unknown) => RenderedToolResult = (result) => ({ output: render(result) }),
|
|
1069
|
-
) {
|
|
1070
|
-
return tool({
|
|
1071
|
-
description,
|
|
1072
|
-
args,
|
|
1073
|
-
async execute(args, context) {
|
|
1074
|
-
const title = initialTitle(kind, args);
|
|
1075
|
-
context.metadata({ title });
|
|
1076
|
-
const result = await execute(args, context);
|
|
1077
|
-
const presentation = resultPresentation(kind, args, result);
|
|
1078
|
-
return {
|
|
1079
|
-
title: presentation.title,
|
|
1080
|
-
...formatResult(result),
|
|
1081
|
-
metadata: presentation.metadata,
|
|
1082
|
-
};
|
|
1083
|
-
},
|
|
1084
|
-
});
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
/**
|
|
1088
|
-
* Adds readseek's anchored reads and structural navigation without replacing
|
|
1089
|
-
* OpenCode's built-in file tools.
|
|
1090
|
-
*/
|
|
1091
|
-
export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
1092
|
-
const anchors = new SessionAnchors();
|
|
1093
|
-
const imagePolicy = resolveImagePolicy(options);
|
|
1094
|
-
const imageModes: readonly ImageMode[] = imagePolicy === "auto"
|
|
1095
|
-
? ["none", "all", "ocr", "caption", "objects"]
|
|
1096
|
-
: ["all", "ocr", "caption", "objects"];
|
|
1097
|
-
const withSearchFlags = (args: string[], input: { cached?: boolean; others?: boolean; ignored?: boolean }) => {
|
|
1098
|
-
if (input.ignored && !input.others) throw new Error("ignored requires others");
|
|
1099
|
-
const selection = [
|
|
1100
|
-
input.cached && "cached",
|
|
1101
|
-
input.others && "others",
|
|
1102
|
-
input.ignored && "ignored",
|
|
1103
|
-
].filter((value): value is string => Boolean(value));
|
|
1104
|
-
if (selection.length > 0) args.push("--git", selection.join(","));
|
|
1105
|
-
};
|
|
1106
|
-
|
|
1107
|
-
return {
|
|
1108
|
-
tool: {
|
|
1109
|
-
readseek_read: readseekTool(
|
|
1110
|
-
imagePolicy === "off"
|
|
1111
|
-
? "Read text with LINE:HASH anchors. Image reads are disabled. For documents, use readseek_view."
|
|
1112
|
-
: `Read text with LINE:HASH anchors. For an image, select image (${imageModes.join(", ")}); omit it to skip image files. For documents such as PDFs, use readseek_view.`,
|
|
1113
|
-
{
|
|
1114
|
-
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
1115
|
-
offset: tool.schema.number().int().positive().optional().describe("One-based starting line"),
|
|
1116
|
-
limit: tool.schema.number().int().positive().optional().describe(`Maximum number of lines; defaults to ${DEFAULT_READ_LIMIT}`),
|
|
1117
|
-
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
1118
|
-
...(imagePolicy === "off"
|
|
1119
|
-
? {}
|
|
1120
|
-
: {
|
|
1121
|
-
image: tool.schema.enum(imageModes as [ImageMode, ...ImageMode[]]).optional()
|
|
1122
|
-
.describe("Standalone image processing mode")
|
|
1123
|
-
}),
|
|
1124
|
-
},
|
|
1125
|
-
"read",
|
|
1126
|
-
async (input, context) => {
|
|
1127
|
-
const filePath = resolvePath(context.directory, input.path as string);
|
|
1128
|
-
await authorizeRead(context, filePath);
|
|
1129
|
-
const image = input.image as ImageMode | undefined;
|
|
1130
|
-
if (imagePolicy === "off" && image !== undefined) throw new Error("image reads are disabled");
|
|
1131
|
-
if (imagePolicy === "on" && image === "none") throw new Error('image="none" requires imageMode="auto"');
|
|
1132
|
-
|
|
1133
|
-
if (isLikelyDocumentPath(filePath)) {
|
|
1134
|
-
if (image !== undefined) throw new Error("image does not apply to documents; use readseek_view");
|
|
1135
|
-
if (input.offset !== undefined || input.limit !== undefined) {
|
|
1136
|
-
throw new Error("offset and limit do not apply to documents; use readseek_view");
|
|
1137
|
-
}
|
|
1138
|
-
if (input.language !== undefined) {
|
|
1139
|
-
throw new Error("language does not apply to documents; use readseek_view");
|
|
1140
|
-
}
|
|
1141
|
-
return digestDocumentReceipt(context, filePath);
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
// Image paths without an image mode only need metadata (skip without loading content).
|
|
1145
|
-
// Text paths always use one digest --select content spawn.
|
|
1146
|
-
let detectedText = false;
|
|
1147
|
-
if (image === undefined && isLikelyImagePath(filePath)) {
|
|
1148
|
-
const envelope = await runReadSeek(context, digestArgs(filePath, "metadata"));
|
|
1149
|
-
const metadata = digestMetadata(envelope);
|
|
1150
|
-
if (isPdfFile(metadata)) return documentReceipt(filePath, envelope);
|
|
1151
|
-
if (isVisualFile(metadata)) {
|
|
1152
|
-
return { file: filePath, skipped: true, reason: "image mode not selected" };
|
|
1153
|
-
}
|
|
1154
|
-
detectedText = true;
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
const extra: string[] = [];
|
|
1158
|
-
const wantsImage = !detectedText
|
|
1159
|
-
&& (image !== undefined || isLikelyImagePath(filePath));
|
|
1160
|
-
if (!wantsImage) {
|
|
1161
|
-
const start = (input.offset as number | undefined) ?? 1;
|
|
1162
|
-
const end = start + ((input.limit as number | undefined) ?? DEFAULT_READ_LIMIT) - 1;
|
|
1163
|
-
if (start > 1) withAt(extra, start);
|
|
1164
|
-
extra.push("--end", String(end));
|
|
1165
|
-
if (input.language) extra.push("--language", input.language as string);
|
|
1166
|
-
} else {
|
|
1167
|
-
if (input.offset !== undefined || input.limit !== undefined) {
|
|
1168
|
-
throw new Error("offset and limit do not apply to image reads");
|
|
1169
|
-
}
|
|
1170
|
-
if (input.language !== undefined) {
|
|
1171
|
-
throw new Error("language does not apply to image reads");
|
|
1172
|
-
}
|
|
1173
|
-
if (image !== undefined) extra.push("--vision-mode", image);
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
let envelope: unknown;
|
|
1177
|
-
try {
|
|
1178
|
-
envelope = await runReadSeek(
|
|
1179
|
-
context,
|
|
1180
|
-
digestArgs(filePath, "content", extra),
|
|
1181
|
-
image !== undefined && image !== "none" ? { timeoutMs: DEFAULT_VISION_TIMEOUT_MS } : {},
|
|
1182
|
-
);
|
|
1183
|
-
} catch (error) {
|
|
1184
|
-
// A visual or document file with an uncommon extension rejects text bounds before
|
|
1185
|
-
// an envelope can be returned. Probe metadata only on this cold path.
|
|
1186
|
-
if (image !== undefined) throw error;
|
|
1187
|
-
if (isDocumentBoundsError(error)) {
|
|
1188
|
-
return digestDocumentReceipt(context, filePath);
|
|
1189
|
-
}
|
|
1190
|
-
if (isVisualBoundsError(error)) {
|
|
1191
|
-
const metadataEnvelope = await runReadSeek(context, digestArgs(filePath, "metadata"));
|
|
1192
|
-
if (isPdfFile(digestMetadata(metadataEnvelope))) {
|
|
1193
|
-
return documentReceipt(filePath, metadataEnvelope);
|
|
1194
|
-
}
|
|
1195
|
-
if (isVisualFile(digestMetadata(metadataEnvelope))) {
|
|
1196
|
-
return { file: filePath, skipped: true, reason: "image mode not selected" };
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
throw error;
|
|
1200
|
-
}
|
|
1201
|
-
const metadata = digestMetadata(envelope);
|
|
1202
|
-
if (isPdfFile(metadata) || record(envelope).cache !== undefined) {
|
|
1203
|
-
return documentReceipt(filePath, envelope);
|
|
1204
|
-
}
|
|
1205
|
-
const visual = isVisualFile(metadata);
|
|
1206
|
-
|
|
1207
|
-
if (visual && image === undefined) {
|
|
1208
|
-
return { file: filePath, skipped: true, reason: "image mode not selected" };
|
|
1209
|
-
}
|
|
1210
|
-
if (visual && (input.offset !== undefined || input.limit !== undefined)) {
|
|
1211
|
-
throw new Error("offset and limit do not apply to image reads");
|
|
1212
|
-
}
|
|
1213
|
-
if (visual && input.language !== undefined) {
|
|
1214
|
-
throw new Error("language does not apply to image reads");
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
return requireDigestFacet(envelope, "content");
|
|
1218
|
-
},
|
|
1219
|
-
formatReadResult,
|
|
1220
|
-
),
|
|
1221
|
-
readseek_view: readseekTool(
|
|
1222
|
-
"Digest a document's structure or selected content. Start with the overview, then narrow by page, node, kind, or depth instead of reading the whole document.",
|
|
1223
|
-
{
|
|
1224
|
-
path: tool.schema.string().describe("Document path relative to the project directory"),
|
|
1225
|
-
node: tool.schema.string().min(1).optional().describe("Node ID to use as the view root"),
|
|
1226
|
-
page: tool.schema.number().int().positive().optional().describe("One-based source page"),
|
|
1227
|
-
kind: tool.schema.enum(DOCUMENT_NODE_KINDS).optional().describe("Node kind filter"),
|
|
1228
|
-
depth: tool.schema.number().int().nonnegative().optional().describe("Maximum depth below selected roots"),
|
|
1229
|
-
outline: tool.schema.boolean().optional().describe("Return outline nodes only"),
|
|
1230
|
-
visionMode: tool.schema.enum(VISION_ANALYSIS_MODES).optional()
|
|
1231
|
-
.describe("Analyze selected assets: all, ocr, caption, or objects"),
|
|
1232
|
-
visionLevel: tool.schema.enum(VISION_LEVELS).optional()
|
|
1233
|
-
.describe("Vision inference level: low (default), medium, or high"),
|
|
1234
|
-
},
|
|
1235
|
-
"view",
|
|
1236
|
-
async (input, context) => {
|
|
1237
|
-
const filePath = resolvePath(context.directory, input.path as string);
|
|
1238
|
-
await authorizeRead(context, filePath);
|
|
1239
|
-
const visionMode = input.visionMode as VisionAnalysisMode | undefined;
|
|
1240
|
-
const visionLevel = input.visionLevel as VisionLevel | undefined;
|
|
1241
|
-
if (visionLevel !== undefined && visionMode === undefined) {
|
|
1242
|
-
throw new Error("visionLevel requires visionMode");
|
|
1243
|
-
}
|
|
1244
|
-
if (input.outline && (visionMode !== undefined || visionLevel !== undefined)) {
|
|
1245
|
-
throw new Error("vision options cannot be combined with outline");
|
|
1246
|
-
}
|
|
1247
|
-
const cacheDir = await ensureReadSeekCache(context);
|
|
1248
|
-
const args = ["--readseek-dir", cacheDir, "view", filePath];
|
|
1249
|
-
if (input.node) args.push("--node", input.node as string);
|
|
1250
|
-
if (input.page !== undefined) args.push("--page", String(input.page));
|
|
1251
|
-
if (input.kind) args.push("--kind", input.kind as string);
|
|
1252
|
-
if (input.depth !== undefined) args.push("--depth", String(input.depth));
|
|
1253
|
-
optionalFlag(args, input.outline as boolean | undefined, "--outline");
|
|
1254
|
-
if (visionMode !== undefined) args.push("--vision-mode", visionMode);
|
|
1255
|
-
if (visionLevel !== undefined) args.push("--vision-level", visionLevel);
|
|
1256
|
-
return runReadSeekRaw(
|
|
1257
|
-
context,
|
|
1258
|
-
args,
|
|
1259
|
-
visionMode === undefined ? {} : { timeoutMs: DEFAULT_VISION_TIMEOUT_MS },
|
|
1260
|
-
);
|
|
1261
|
-
},
|
|
1262
|
-
(result) => ({ output: boundDocumentOutput(String(result)) }),
|
|
1263
|
-
),
|
|
1264
|
-
readseek_digest: readseekTool(
|
|
1265
|
-
"Digest native facets for a path and return the full envelope unchanged. Facets: metadata, content, map, diagnostics, identity (comma-separated). Metadata is always present; other selected facets appear when available.",
|
|
1266
|
-
{
|
|
1267
|
-
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
1268
|
-
select: tool.schema.string().optional()
|
|
1269
|
-
.describe("Comma-separated facets: metadata, content, map, diagnostics, identity; defaults to content"),
|
|
1270
|
-
at: tool.schema.string().optional()
|
|
1271
|
-
.describe("Location: line:N[:COLUMN], hash:HASH[:COLUMN], or symbol:NAME"),
|
|
1272
|
-
end: tool.schema.number().int().positive().optional().describe("Last line to include; content facet only"),
|
|
1273
|
-
limit: tool.schema.number().int().positive().optional().describe("Maximum number of lines; content facet only"),
|
|
1274
|
-
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
1275
|
-
visionMode: tool.schema.enum(["none", ...VISION_ANALYSIS_MODES] as [ImageMode, ...ImageMode[]]).optional()
|
|
1276
|
-
.describe("Standalone image mode for the content facet: none, all, ocr, caption, or objects"),
|
|
1277
|
-
visionLevel: tool.schema.enum(VISION_LEVELS).optional()
|
|
1278
|
-
.describe("Vision inference level: low (default), medium, or high; content facet only"),
|
|
1279
|
-
},
|
|
1280
|
-
"digest",
|
|
1281
|
-
async (input, context) => {
|
|
1282
|
-
const filePath = resolvePath(context.directory, input.path as string);
|
|
1283
|
-
await authorizeRead(context, filePath);
|
|
1284
|
-
const select = ((input.select as string | undefined) ?? "content").trim() || "content";
|
|
1285
|
-
const visionMode = input.visionMode as ImageMode | undefined;
|
|
1286
|
-
const visionLevel = input.visionLevel as VisionLevel | undefined;
|
|
1287
|
-
if (visionLevel !== undefined && visionMode === undefined) {
|
|
1288
|
-
throw new Error("visionLevel requires visionMode");
|
|
1289
|
-
}
|
|
1290
|
-
const cacheDir = await ensureReadSeekCache(context);
|
|
1291
|
-
const args = ["--readseek-dir", cacheDir, ...digestArgs(filePath, select)];
|
|
1292
|
-
if (input.at) args.push("--at", input.at as string);
|
|
1293
|
-
if (input.end !== undefined) args.push("--end", String(input.end));
|
|
1294
|
-
if (input.limit !== undefined) args.push("--limit", String(input.limit));
|
|
1295
|
-
if (input.language) args.push("--language", input.language as string);
|
|
1296
|
-
if (visionMode !== undefined) args.push("--vision-mode", visionMode);
|
|
1297
|
-
if (visionLevel !== undefined) args.push("--vision-level", visionLevel);
|
|
1298
|
-
return runReadSeek(
|
|
1299
|
-
context,
|
|
1300
|
-
args,
|
|
1301
|
-
visionMode === undefined || visionMode === "none"
|
|
1302
|
-
? {}
|
|
1303
|
-
: { timeoutMs: DEFAULT_VISION_TIMEOUT_MS },
|
|
1304
|
-
);
|
|
1305
|
-
},
|
|
1306
|
-
),
|
|
1307
|
-
readseek_grep: readseekTool(
|
|
1308
|
-
"Search text or regex in files and return matching LINE:HASH anchors. Set literal for exact text. Use for identifiers, strings, configuration, errors, comments, and documentation.",
|
|
1309
|
-
{
|
|
1310
|
-
pattern: tool.schema.string().describe("Text or regular expression to search for; set literal for exact text"),
|
|
1311
|
-
path: tool.schema.string().optional().describe("File or directory to search; defaults to the project directory"),
|
|
1312
|
-
glob: tool.schema.string().optional().describe("File-name glob, such as **/*.ts"),
|
|
1313
|
-
literal: tool.schema.boolean().optional().describe("Treat pattern as literal text"),
|
|
1314
|
-
ignoreCase: tool.schema.boolean().optional().describe("Ignore case"),
|
|
1315
|
-
context: tool.schema.number().int().nonnegative().optional().describe("Surrounding lines for each match"),
|
|
1316
|
-
limit: tool.schema.number().int().positive().optional().describe("Maximum matching lines; defaults to 100"),
|
|
1317
|
-
},
|
|
1318
|
-
"grep",
|
|
1319
|
-
async (input, context) => {
|
|
1320
|
-
const target = resolvePath(context.directory, (input.path as string | undefined) ?? ".");
|
|
1321
|
-
const pattern = input.pattern as string;
|
|
1322
|
-
await authorizeSearch(context, target, pattern);
|
|
1323
|
-
let expression: RegExp;
|
|
1324
|
-
try {
|
|
1325
|
-
const source = input.literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
|
|
1326
|
-
expression = new RegExp(source, input.ignoreCase ? "i" : "");
|
|
1327
|
-
} catch (error) {
|
|
1328
|
-
throw new Error(`invalid regular expression: ${error instanceof Error ? error.message : String(error)}`);
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
const info = await stat(target);
|
|
1332
|
-
const files: string[] = [];
|
|
1333
|
-
if (info.isFile()) files.push(target);
|
|
1334
|
-
else if (info.isDirectory()) {
|
|
1335
|
-
const glob = new Bun.Glob((input.glob as string | undefined) ?? "**/*");
|
|
1336
|
-
for await (const filePath of glob.scan({ cwd: target, absolute: true, onlyFiles: true, followSymlinks: false })) {
|
|
1337
|
-
files.push(filePath);
|
|
1338
|
-
}
|
|
1339
|
-
} else throw new Error(`grep target is not a file or directory: ${target}`);
|
|
1340
|
-
|
|
1341
|
-
const maxMatches = (input.limit as number | undefined) ?? 100;
|
|
1342
|
-
const contextLines = (input.context as number | undefined) ?? 0;
|
|
1343
|
-
const results: { file: string; matches: unknown[]; hashlines: unknown[] }[] = [];
|
|
1344
|
-
let totalMatches = 0;
|
|
1345
|
-
let truncated = false;
|
|
1346
|
-
for (const filePath of files.sort()) {
|
|
1347
|
-
context.abort.throwIfAborted();
|
|
1348
|
-
if (truncated) break;
|
|
1349
|
-
const buffer = await readFile(filePath);
|
|
1350
|
-
if (buffer.includes(0)) continue;
|
|
1351
|
-
const lines = buffer.toString("utf8").replace(/\r\n/g, "\n").split("\n");
|
|
1352
|
-
const matchLines = new Set<number>();
|
|
1353
|
-
const ranges: [number, number][] = [];
|
|
1354
|
-
for (let index = 0; index < lines.length; index++) {
|
|
1355
|
-
expression.lastIndex = 0;
|
|
1356
|
-
if (!expression.test(lines[index] ?? "")) continue;
|
|
1357
|
-
if (totalMatches === maxMatches) {
|
|
1358
|
-
truncated = true;
|
|
1359
|
-
break;
|
|
1360
|
-
}
|
|
1361
|
-
const line = index + 1;
|
|
1362
|
-
matchLines.add(line);
|
|
1363
|
-
ranges.push([Math.max(1, line - contextLines), Math.min(lines.length, line + contextLines)]);
|
|
1364
|
-
totalMatches++;
|
|
1365
|
-
}
|
|
1366
|
-
if (ranges.length === 0) continue;
|
|
1367
|
-
|
|
1368
|
-
const mergedRanges: [number, number][] = [];
|
|
1369
|
-
for (const [start, end] of ranges) {
|
|
1370
|
-
const previous = mergedRanges.at(-1);
|
|
1371
|
-
if (previous && start <= previous[1] + 1) previous[1] = Math.max(previous[1], end);
|
|
1372
|
-
else mergedRanges.push([start, end]);
|
|
1373
|
-
}
|
|
1374
|
-
const linesByNumber = new Map<number, unknown>();
|
|
1375
|
-
for (const [start, end] of mergedRanges) {
|
|
1376
|
-
const output = record(await runDigestContent(context, readRangeArgs(filePath, start, end)));
|
|
1377
|
-
for (const hashline of items(output.hashlines)) {
|
|
1378
|
-
const line = record(hashline).line;
|
|
1379
|
-
if (typeof line === "number" && !linesByNumber.has(line)) linesByNumber.set(line, hashline);
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
const hashlines = [...linesByNumber.values()];
|
|
1383
|
-
const matches = hashlines.filter((hashline) => {
|
|
1384
|
-
const line = record(hashline).line;
|
|
1385
|
-
return typeof line === "number" && matchLines.has(line);
|
|
1386
|
-
});
|
|
1387
|
-
results.push({ file: filePath, matches, hashlines });
|
|
1388
|
-
}
|
|
1389
|
-
return { results, truncated };
|
|
1390
|
-
},
|
|
1391
|
-
),
|
|
1392
|
-
readseek_search: readseekTool(
|
|
1393
|
-
"Search code by AST structure using ast-grep-style patterns. Results include line hashes usable as LINE:HASH anchors. Use readseek_grep for identifiers, strings, comments, configuration, documentation, or other plain text and regex.",
|
|
1394
|
-
{
|
|
1395
|
-
pattern: tool.schema.string().describe("AST pattern (ast-grep style), such as console.log($$$ARGS)"),
|
|
1396
|
-
path: tool.schema.string().optional().describe("File or directory to search; defaults to the project directory"),
|
|
1397
|
-
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
1398
|
-
cached: tool.schema.boolean().optional().describe("In a Git repository, search tracked/indexed files"),
|
|
1399
|
-
others: tool.schema.boolean().optional().describe("In a Git repository, search untracked files"),
|
|
1400
|
-
ignored: tool.schema.boolean().optional().describe("With others=true, include ignored untracked files"),
|
|
1401
|
-
},
|
|
1402
|
-
"search",
|
|
1403
|
-
async (input, context) => {
|
|
1404
|
-
const target = resolvePath(context.directory, (input.path as string | undefined) ?? ".");
|
|
1405
|
-
const args = ["search", target, input.pattern as string];
|
|
1406
|
-
if (input.language) args.push("--language", input.language as string);
|
|
1407
|
-
withSearchFlags(args, input);
|
|
1408
|
-
await authorizeSearch(context, target, input.pattern as string);
|
|
1409
|
-
const result = await runReadSeek(context, args);
|
|
1410
|
-
return result;
|
|
1411
|
-
},
|
|
1412
|
-
),
|
|
1413
|
-
readseek_def: readseekTool(
|
|
1414
|
-
"Go to a symbol definition or declaration by name. Accepts qualified or unqualified names; use this instead of text search for functions, methods, classes, types, and other declarations.",
|
|
1415
|
-
{
|
|
1416
|
-
name: tool.schema.string().describe("Qualified or unqualified symbol name"),
|
|
1417
|
-
path: tool.schema.string().optional().describe("File or directory to search; defaults to the project directory"),
|
|
1418
|
-
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
1419
|
-
cached: tool.schema.boolean().optional().describe("In a Git repository, search tracked/indexed files"),
|
|
1420
|
-
others: tool.schema.boolean().optional().describe("In a Git repository, search untracked files"),
|
|
1421
|
-
ignored: tool.schema.boolean().optional().describe("With others=true, include ignored untracked files"),
|
|
1422
|
-
},
|
|
1423
|
-
"def",
|
|
1424
|
-
async (input, context) => {
|
|
1425
|
-
const target = resolvePath(context.directory, (input.path as string | undefined) ?? ".");
|
|
1426
|
-
const args = ["def", target, "--format", "plain", input.name as string];
|
|
1427
|
-
if (input.language) args.push("--language", input.language as string);
|
|
1428
|
-
withSearchFlags(args, input);
|
|
1429
|
-
await authorizeSearch(context, target, input.name as string);
|
|
1430
|
-
return runReadSeek(context, args);
|
|
1431
|
-
},
|
|
1432
|
-
),
|
|
1433
|
-
readseek_refs: readseekTool(
|
|
1434
|
-
"Find references or usages of an identifier by name or cursor binding. Results include enclosing symbols and line hashes usable as LINE:HASH anchors. Without cursor scope, results are identifier-name matches; with scope, they follow one binding and exclude shadows. Use text search for literal occurrences in strings or comments, and use this tool before renaming or deleting a symbol.",
|
|
1435
|
-
{
|
|
1436
|
-
name: tool.schema.string().describe("Identifier to find references for"),
|
|
1437
|
-
path: tool.schema.string().optional().describe("File or directory to search; defaults to the project directory"),
|
|
1438
|
-
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
1439
|
-
scope: tool.schema.boolean().optional().describe("Restrict results to the binding at the cursor"),
|
|
1440
|
-
line: tool.schema.number().int().positive().optional().describe("One-based cursor line; required with scope"),
|
|
1441
|
-
column: tool.schema.number().int().positive().optional().describe("One-based cursor byte column for disambiguation"),
|
|
1442
|
-
cached: tool.schema.boolean().optional().describe("In a Git repository, search tracked/indexed files"),
|
|
1443
|
-
others: tool.schema.boolean().optional().describe("In a Git repository, search untracked files"),
|
|
1444
|
-
ignored: tool.schema.boolean().optional().describe("With others=true, include ignored untracked files"),
|
|
1445
|
-
},
|
|
1446
|
-
"refs",
|
|
1447
|
-
async (input, context) => {
|
|
1448
|
-
if (input.scope && input.line === undefined) throw new Error("scope requires line");
|
|
1449
|
-
if (!input.scope && (input.line !== undefined || input.column !== undefined)) {
|
|
1450
|
-
throw new Error("line and column require scope");
|
|
1451
|
-
}
|
|
1452
|
-
const target = resolvePath(context.directory, (input.path as string | undefined) ?? ".");
|
|
1453
|
-
const args = ["refs", target];
|
|
1454
|
-
if (input.scope) {
|
|
1455
|
-
withAt(args, input.line as number, input.column as number | undefined);
|
|
1456
|
-
} else {
|
|
1457
|
-
args.push(input.name as string);
|
|
1458
|
-
}
|
|
1459
|
-
if (input.language) args.push("--language", input.language as string);
|
|
1460
|
-
withSearchFlags(args, input);
|
|
1461
|
-
await authorizeSearch(context, target, input.name as string);
|
|
1462
|
-
return runReadSeek(context, args);
|
|
1463
|
-
},
|
|
1464
|
-
),
|
|
1465
|
-
readseek_rename: readseekTool(
|
|
1466
|
-
"Rename a symbol at a source location. Resolvable same-named bindings are preserved; verified edits apply by default. Set `apply: false` to preview the plan before changing files.",
|
|
1467
|
-
{
|
|
1468
|
-
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
1469
|
-
line: tool.schema.number().int().positive().describe("One-based cursor line of the symbol to rename"),
|
|
1470
|
-
column: tool.schema.number().int().positive().optional().describe("One-based cursor byte column for disambiguation"),
|
|
1471
|
-
to: tool.schema.string().min(1).describe("New symbol name"),
|
|
1472
|
-
workspace: tool.schema.boolean().optional().describe("Rename across the project"),
|
|
1473
|
-
apply: tool.schema.boolean().optional().describe("Apply the verified edits; defaults to true"),
|
|
1474
|
-
},
|
|
1475
|
-
"rename",
|
|
1476
|
-
async (input, context) => {
|
|
1477
|
-
const filePath = resolvePath(context.directory, input.path as string);
|
|
1478
|
-
await authorizeRead(context, filePath);
|
|
1479
|
-
if (input.workspace) {
|
|
1480
|
-
const identifyArgs = digestArgs(filePath, "identity");
|
|
1481
|
-
withAt(identifyArgs, input.line as number, input.column as number | undefined);
|
|
1482
|
-
const envelope = await runReadSeek(context, identifyArgs);
|
|
1483
|
-
const name = identifiedName(requireDigestFacet(envelope, "identity"));
|
|
1484
|
-
if (!name) throw new Error("readseek could not identify a binding at the rename cursor");
|
|
1485
|
-
await authorizeSearch(context, context.directory, name);
|
|
1486
|
-
}
|
|
1487
|
-
const args = ["rename", filePath];
|
|
1488
|
-
withAt(args, input.line as number, input.column as number | undefined);
|
|
1489
|
-
args.push("--to", input.to as string);
|
|
1490
|
-
if (input.workspace) args.push("--workspace", context.directory);
|
|
1491
|
-
const plan = await runReadSeek(context, args);
|
|
1492
|
-
if (input.apply === false) return plan;
|
|
1493
|
-
const files = renameFiles(plan);
|
|
1494
|
-
const planHash = record(plan).plan_hash;
|
|
1495
|
-
if (typeof planHash !== "string" || planHash.length === 0) {
|
|
1496
|
-
throw new Error("readseek rename plan did not include a plan hash");
|
|
1497
|
-
}
|
|
1498
|
-
await authorizeEdit(context, files);
|
|
1499
|
-
return runReadSeek(context, [...args, "--plan-hash", planHash, "--apply"], {
|
|
1500
|
-
cancelable: false,
|
|
1501
|
-
timeoutMs: DEFAULT_READSEEK_TIMEOUT_MS,
|
|
1502
|
-
});
|
|
1503
|
-
},
|
|
1504
|
-
),
|
|
1505
|
-
readseek_edit: readseekTool(
|
|
1506
|
-
"Edit an existing text file with fresh LINE:HASH anchors, exact text, or a mapped symbol. The native engine rejects stale or overlapping edits before writing.",
|
|
1507
|
-
{
|
|
1508
|
-
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
1509
|
-
edits: tool.schema.array(tool.schema.union([
|
|
1510
|
-
tool.schema.object({
|
|
1511
|
-
set_line: tool.schema.object({
|
|
1512
|
-
anchor: tool.schema.string().describe("Fresh LINE:HASH anchor for the line to replace"),
|
|
1513
|
-
new_text: tool.schema.string().describe("Replacement text; use an empty string to delete the line"),
|
|
1514
|
-
}).strict(),
|
|
1515
|
-
}).strict(),
|
|
1516
|
-
tool.schema.object({
|
|
1517
|
-
replace_lines: tool.schema.object({
|
|
1518
|
-
start_anchor: tool.schema.string().describe("Fresh LINE:HASH anchor for the first line in the range"),
|
|
1519
|
-
end_anchor: tool.schema.string().describe("Fresh LINE:HASH anchor for the last line in the range"),
|
|
1520
|
-
new_text: tool.schema.string().describe("Replacement text; use an empty string to delete the range"),
|
|
1521
|
-
}).strict(),
|
|
1522
|
-
}).strict(),
|
|
1523
|
-
tool.schema.object({
|
|
1524
|
-
insert_after: tool.schema.object({
|
|
1525
|
-
anchor: tool.schema.string().describe("Fresh LINE:HASH anchor for the line after which to insert"),
|
|
1526
|
-
new_text: tool.schema.string().describe("Text to insert after the anchored line"),
|
|
1527
|
-
}).strict(),
|
|
1528
|
-
}).strict(),
|
|
1529
|
-
tool.schema.object({
|
|
1530
|
-
replace: tool.schema.object({
|
|
1531
|
-
old_text: tool.schema.string().describe("Exact text to find"),
|
|
1532
|
-
new_text: tool.schema.string().describe("Replacement text"),
|
|
1533
|
-
all: tool.schema.boolean().optional().describe("Replace every exact match"),
|
|
1534
|
-
}).strict(),
|
|
1535
|
-
}).strict(),
|
|
1536
|
-
tool.schema.object({
|
|
1537
|
-
replace_symbol: tool.schema.object({
|
|
1538
|
-
symbol: tool.schema.string().describe("Mapped symbol name"),
|
|
1539
|
-
new_body: tool.schema.string().describe("Complete replacement body for the symbol"),
|
|
1540
|
-
}).strict(),
|
|
1541
|
-
}).strict(),
|
|
1542
|
-
])).min(1).describe("Use set_line, replace_lines, insert_after, replace, or replace_symbol"),
|
|
1543
|
-
language: tool.schema.string().optional().describe("Language override for symbol replacement"),
|
|
1544
|
-
},
|
|
1545
|
-
"edit",
|
|
1546
|
-
async (input, context) => {
|
|
1547
|
-
const filePath = resolvePath(context.directory, input.path as string);
|
|
1548
|
-
validateEdits(input.edits);
|
|
1549
|
-
return withFileMutationQueue(filePath, async () => {
|
|
1550
|
-
context.abort.throwIfAborted();
|
|
1551
|
-
await authorizeRead(context, filePath);
|
|
1552
|
-
await rejectSymlinkMutation(filePath);
|
|
1553
|
-
const edits = input.edits as EditItem[];
|
|
1554
|
-
const request = JSON.stringify({ edits });
|
|
1555
|
-
const baseArgs = ["edit", filePath];
|
|
1556
|
-
if (input.language) baseArgs.push("--language", input.language as string);
|
|
1557
|
-
const plan = record(await runReadSeek(context, baseArgs, { stdin: request }));
|
|
1558
|
-
if (
|
|
1559
|
-
typeof plan.before_content !== "string" ||
|
|
1560
|
-
typeof plan.content !== "string" ||
|
|
1561
|
-
typeof plan.plan_hash !== "string"
|
|
1562
|
-
) {
|
|
1563
|
-
throw new Error("readseek edit plan is missing before_content, content, or plan_hash");
|
|
1564
|
-
}
|
|
1565
|
-
const before = plan.before_content;
|
|
1566
|
-
const after = plan.content;
|
|
1567
|
-
const previewStart = Math.max(1, firstChangedLine(before, after) - 3);
|
|
1568
|
-
const previewArgs = readRangeArgs(filePath, previewStart, previewStart + EDIT_RESULT_READ_LIMIT - 1);
|
|
1569
|
-
if (before === after) {
|
|
1570
|
-
return postMutationPreview(context, previewArgs, filePath, false);
|
|
1571
|
-
}
|
|
1572
|
-
await authorizeEdit(context, [filePath], simpleDiff(filePath, before, after), false);
|
|
1573
|
-
context.abort.throwIfAborted();
|
|
1574
|
-
const applied = record(await runReadSeek(
|
|
1575
|
-
context,
|
|
1576
|
-
[...baseArgs, "--apply", "--plan-hash", plan.plan_hash],
|
|
1577
|
-
{
|
|
1578
|
-
cancelable: false,
|
|
1579
|
-
stdin: request,
|
|
1580
|
-
timeoutMs: DEFAULT_READSEEK_TIMEOUT_MS,
|
|
1581
|
-
},
|
|
1582
|
-
));
|
|
1583
|
-
if (applied.applied !== true) {
|
|
1584
|
-
throw new Error("readseek did not confirm that the edit was applied");
|
|
1585
|
-
}
|
|
1586
|
-
return postMutationPreview(context, previewArgs, filePath, true);
|
|
1587
|
-
});
|
|
1588
|
-
},
|
|
1589
|
-
),
|
|
1590
|
-
readseek_write: readseekTool(
|
|
1591
|
-
"Create or replace a complete text file and return LINE:HASH anchors. Use anchored edits for small changes to an existing file. Binary-looking content is rejected before writing.",
|
|
1592
|
-
{
|
|
1593
|
-
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
1594
|
-
content: tool.schema.string().describe("Complete text file content"),
|
|
1595
|
-
},
|
|
1596
|
-
"write",
|
|
1597
|
-
async (input, context) => {
|
|
1598
|
-
const filePath = resolvePath(context.directory, input.path as string);
|
|
1599
|
-
await authorizeExternal(context, filePath);
|
|
1600
|
-
return withFileMutationQueue(filePath, async () => {
|
|
1601
|
-
context.abort.throwIfAborted();
|
|
1602
|
-
await rejectSymlinkMutation(filePath);
|
|
1603
|
-
const before = await readTextSnapshot(filePath);
|
|
1604
|
-
const content = input.content as string;
|
|
1605
|
-
if (content.includes("\0")) throw new Error("write content must be text");
|
|
1606
|
-
await authorizeEdit(context, [filePath], simpleDiff(filePath, before.content, content), false);
|
|
1607
|
-
context.abort.throwIfAborted();
|
|
1608
|
-
await writeText(filePath, before, content);
|
|
1609
|
-
return postMutationPreview(
|
|
1610
|
-
context,
|
|
1611
|
-
digestArgs(filePath, "content", ["--end", String(DEFAULT_READ_LIMIT)]),
|
|
1612
|
-
filePath,
|
|
1613
|
-
!before.exists || before.content !== content,
|
|
1614
|
-
);
|
|
1615
|
-
});
|
|
1616
|
-
},
|
|
1617
|
-
),
|
|
1618
|
-
},
|
|
1619
|
-
"experimental.chat.system.transform": async (_input, output) => {
|
|
1620
|
-
output.system.push(TOOL_POLICY);
|
|
1621
|
-
},
|
|
1622
|
-
"tool.definition": async (input, output) => {
|
|
1623
|
-
const preference = PREFERRED_TOOL_DESCRIPTIONS[input.toolID];
|
|
1624
|
-
if (preference) {
|
|
1625
|
-
output.description = `${preference} ${output.description}`;
|
|
1626
|
-
return;
|
|
1627
|
-
}
|
|
1628
|
-
const fallback = FALLBACK_TOOL_DESCRIPTIONS[input.toolID];
|
|
1629
|
-
if (fallback) output.description = `${fallback} ${output.description}`;
|
|
1630
|
-
},
|
|
1631
|
-
event: async ({ event }) => {
|
|
1632
|
-
if (event.type === "file.edited" || event.type === "file.watcher.updated") {
|
|
1633
|
-
anchors.forget(event.properties.file);
|
|
1634
|
-
return;
|
|
1635
|
-
}
|
|
1636
|
-
if (event.type === "session.deleted") anchors.deleteSession(event.properties.info.id);
|
|
1637
|
-
},
|
|
1638
|
-
"tool.execute.after": async (input, output) => {
|
|
1639
|
-
if (!input.tool.startsWith("readseek_")) return;
|
|
1640
|
-
try {
|
|
1641
|
-
const result = JSON.parse(output.output) as unknown;
|
|
1642
|
-
if (input.tool === "readseek_rename") {
|
|
1643
|
-
anchors.planRename(input.sessionID, result);
|
|
1644
|
-
if (record(result).applied === true) {
|
|
1645
|
-
for (const filePath of renameFiles(result)) anchors.forget(filePath);
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
const files = new Set<string>();
|
|
1650
|
-
collectAnchoredFiles(result, files);
|
|
1651
|
-
for (const filePath of files) anchors.mark(input.sessionID, path.resolve(filePath));
|
|
1652
|
-
} catch {
|
|
1653
|
-
// A failed tool result has no valid anchors to retain.
|
|
1654
|
-
}
|
|
1655
|
-
},
|
|
1656
|
-
"experimental.session.compacting": async (input, output) => {
|
|
1657
|
-
const context = anchors.render(input.sessionID);
|
|
1658
|
-
if (context) output.context.push(context);
|
|
1659
|
-
},
|
|
1660
|
-
};
|
|
1661
|
-
};
|
|
1662
|
-
|
|
1663
|
-
export default { id: "opencode-readseek", server: ReadSeekPlugin };
|