opencode-readseek 0.6.13 → 0.7.1
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 +17 -2
- package/index.ts +546 -60
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Add the plugin to `opencode.json`:
|
|
|
9
9
|
|
|
10
10
|
```json
|
|
11
11
|
{
|
|
12
|
-
"plugin": [
|
|
12
|
+
"plugin": ["opencode-readseek"]
|
|
13
13
|
}
|
|
14
14
|
```
|
|
15
15
|
|
|
@@ -18,7 +18,8 @@ binary dependency with Bun at startup.
|
|
|
18
18
|
|
|
19
19
|
## Tools
|
|
20
20
|
|
|
21
|
-
- `readseek_read`: read text with `LINE:HASH` anchors;
|
|
21
|
+
- `readseek_read`: read text with `LINE:HASH` anchors; PDFs select one page by
|
|
22
|
+
default and accept an explicit page.
|
|
22
23
|
- `readseek_edit`: apply line, range, and insertion edits with fresh `LINE:HASH` anchors.
|
|
23
24
|
- `readseek_write`: create or replace a complete text file.
|
|
24
25
|
- `readseek_grep`: plain-text or regular-expression search with anchored results.
|
|
@@ -27,14 +28,28 @@ binary dependency with Bun at startup.
|
|
|
27
28
|
- `readseek_def`, `readseek_refs`, `readseek_hover`: symbol navigation.
|
|
28
29
|
- `readseek_rename`: apply a verified rename by default; `apply: false` returns a dry-run plan.
|
|
29
30
|
- `readseek_check`: parse diagnostics.
|
|
31
|
+
- `readseek_view`: create or reuse a PDF index, return its overview, or narrow
|
|
32
|
+
it by page, node, kind, or depth.
|
|
30
33
|
|
|
31
34
|
The plugin requests OpenCode read, grep, external-directory, and edit permissions
|
|
32
35
|
as needed. File changes invalidate remembered anchors. Compaction context records
|
|
33
36
|
paths with fresh anchors and summaries of pending dry-run rename plans. Text reads
|
|
34
37
|
return at most 2,000 lines by default.
|
|
35
38
|
|
|
39
|
+
The plugin also instructs OpenCode to prefer ReadSeek's anchored read, edit,
|
|
40
|
+
write, and rename tools over built-in file mutation tools while leaving the
|
|
41
|
+
built-ins available as fallbacks.
|
|
42
|
+
|
|
36
43
|
## Configuration
|
|
37
44
|
|
|
45
|
+
Pass options with OpenCode's plugin tuple form:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"plugin": [["opencode-readseek", { "imageMode": "auto" }]]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
38
53
|
`imageMode` defaults to `"auto"`: it exposes `none`, `all`, `ocr`, `caption`, and
|
|
39
54
|
`objects`. `"on"` omits `none`; `"off"` skips image/PDF files. Omitting
|
|
40
55
|
`image` also skips visual files.
|
package/index.ts
CHANGED
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright (C) Jarkko Sakkinen 2026
|
|
3
3
|
|
|
4
|
-
import { lstat, mkdir, readFile, readlink, realpath, stat
|
|
4
|
+
import { lstat, mkdir, open, readFile, readlink, realpath, stat } from "node:fs/promises";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
+
import { homedir } from "node:os";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
|
|
8
|
-
import { tool, type Plugin, type PluginOptions, type ToolContext } from "@opencode-ai/plugin";
|
|
9
|
+
import { tool, type Plugin, type PluginOptions, type ToolAttachment, type ToolContext } from "@opencode-ai/plugin";
|
|
9
10
|
|
|
10
11
|
const require = createRequire(import.meta.url);
|
|
11
12
|
const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
12
13
|
const DEFAULT_READ_LIMIT = 2000;
|
|
14
|
+
const MAX_DOCUMENT_OUTPUT_BYTES = 256 * 1024;
|
|
15
|
+
const MAX_DOCUMENT_OUTPUT_LINES = 2000;
|
|
16
|
+
const EDITING_POLICY = [
|
|
17
|
+
"ReadSeek editing policy:",
|
|
18
|
+
"- Prefer readseek_read over built-in file reads when preparing to edit existing text; its LINE:HASH anchors are required by readseek_edit.",
|
|
19
|
+
"- Prefer readseek_edit for existing text files, readseek_write for whole-file creation or replacement, and readseek_rename for symbol renames.",
|
|
20
|
+
"- Do not use built-in edit, write, or apply_patch when the corresponding ReadSeek tool can perform the change.",
|
|
21
|
+
"- Use readseek_check after source edits for a quick syntax check.",
|
|
22
|
+
].join("\n");
|
|
23
|
+
const PREFERRED_TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
24
|
+
readseek_edit: "Preferred tool for editing existing text files with verified LINE:HASH anchors.",
|
|
25
|
+
readseek_read: "Preferred file reader when an existing text file may be edited; returns the anchors required by readseek_edit.",
|
|
26
|
+
readseek_rename: "Preferred tool for renaming code symbols safely across their resolved bindings.",
|
|
27
|
+
readseek_write: "Preferred tool for creating or replacing a complete text file.",
|
|
28
|
+
};
|
|
13
29
|
const READSEEK_PLATFORM_PACKAGES: Record<string, string> = {
|
|
14
30
|
"darwin-arm64": "@jarkkojs/readseek-darwin-arm64",
|
|
15
31
|
"linux-arm64": "@jarkkojs/readseek-linux-arm64",
|
|
@@ -22,15 +38,34 @@ type RenamePlan = {
|
|
|
22
38
|
files: Set<string>;
|
|
23
39
|
};
|
|
24
40
|
|
|
25
|
-
type PresentationKind = "read" | "map" | "search" | "grep" | "def" | "refs" | "hover" | "rename" | "edit" | "write" | "check";
|
|
41
|
+
type PresentationKind = "read" | "view" | "map" | "search" | "grep" | "def" | "refs" | "hover" | "rename" | "edit" | "write" | "check";
|
|
26
42
|
|
|
27
43
|
type Presentation = {
|
|
28
44
|
title: string;
|
|
29
45
|
metadata: Record<string, number>;
|
|
30
46
|
};
|
|
31
47
|
|
|
48
|
+
type RenderedToolResult = {
|
|
49
|
+
output: string;
|
|
50
|
+
attachments?: ToolAttachment[];
|
|
51
|
+
};
|
|
52
|
+
|
|
32
53
|
type ImagePolicy = "on" | "auto" | "off";
|
|
33
54
|
type ImageMode = "none" | "all" | "ocr" | "caption" | "objects";
|
|
55
|
+
type DocumentNodeKind = "artifact" | "footer" | "header" | "heading" | "marginal_label" | "page" | "page_number" | "paragraph" | "section" | "structural_section";
|
|
56
|
+
|
|
57
|
+
const DOCUMENT_NODE_KINDS: [DocumentNodeKind, ...DocumentNodeKind[]] = [
|
|
58
|
+
"artifact",
|
|
59
|
+
"footer",
|
|
60
|
+
"header",
|
|
61
|
+
"heading",
|
|
62
|
+
"marginal_label",
|
|
63
|
+
"page",
|
|
64
|
+
"page_number",
|
|
65
|
+
"paragraph",
|
|
66
|
+
"section",
|
|
67
|
+
"structural_section",
|
|
68
|
+
];
|
|
34
69
|
|
|
35
70
|
function resolveImagePolicy(options: PluginOptions | undefined): ImagePolicy {
|
|
36
71
|
const value = options?.imageMode;
|
|
@@ -45,6 +80,10 @@ function isVisualFile(value: unknown): boolean {
|
|
|
45
80
|
return typeof output.width === "number" || type === "application/pdf";
|
|
46
81
|
}
|
|
47
82
|
|
|
83
|
+
function isPdfFile(value: unknown): boolean {
|
|
84
|
+
return record(value).format === "pdf";
|
|
85
|
+
}
|
|
86
|
+
|
|
48
87
|
class SessionAnchors {
|
|
49
88
|
#pathsBySession = new Map<string, Set<string>>();
|
|
50
89
|
#renamePlans = new Map<string, RenamePlan>();
|
|
@@ -259,24 +298,41 @@ function readSeekBinaryPath(): string {
|
|
|
259
298
|
return path.join(path.dirname(packageJson), "bin", process.platform === "win32" ? "readseek.exe" : "readseek");
|
|
260
299
|
}
|
|
261
300
|
|
|
262
|
-
async function
|
|
301
|
+
async function runReadSeekRaw(
|
|
302
|
+
context: ToolContext,
|
|
303
|
+
args: string[],
|
|
304
|
+
options: { cancelable?: boolean } = {},
|
|
305
|
+
): Promise<string> {
|
|
263
306
|
context.abort.throwIfAborted();
|
|
264
|
-
const
|
|
307
|
+
const cancelable = options.cancelable !== false;
|
|
308
|
+
const spawnOptions = {
|
|
265
309
|
cwd: context.directory,
|
|
266
|
-
killSignal: "SIGKILL",
|
|
267
310
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
311
|
+
stderr: "pipe" as const,
|
|
312
|
+
stdout: "pipe" as const,
|
|
313
|
+
};
|
|
314
|
+
const child = Bun.spawn(
|
|
315
|
+
[readSeekBinaryPath(), ...args],
|
|
316
|
+
cancelable
|
|
317
|
+
? { ...spawnOptions, killSignal: "SIGKILL" as const, signal: context.abort }
|
|
318
|
+
: spawnOptions,
|
|
319
|
+
);
|
|
272
320
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
273
321
|
new Response(child.stdout).text(),
|
|
274
322
|
new Response(child.stderr).text(),
|
|
275
323
|
child.exited,
|
|
276
324
|
]);
|
|
277
|
-
context.abort.throwIfAborted();
|
|
325
|
+
if (cancelable) context.abort.throwIfAborted();
|
|
278
326
|
if (exitCode !== 0) throw new Error(stderr.trim() || `readseek exited with status ${exitCode}`);
|
|
327
|
+
return stdout;
|
|
328
|
+
}
|
|
279
329
|
|
|
330
|
+
async function runReadSeek(
|
|
331
|
+
context: ToolContext,
|
|
332
|
+
args: string[],
|
|
333
|
+
options: { cancelable?: boolean } = {},
|
|
334
|
+
): Promise<unknown> {
|
|
335
|
+
const stdout = await runReadSeekRaw(context, args, options);
|
|
280
336
|
try {
|
|
281
337
|
return JSON.parse(stdout) as unknown;
|
|
282
338
|
} catch {
|
|
@@ -284,6 +340,32 @@ async function runReadSeek(context: ToolContext, args: string[]): Promise<unknow
|
|
|
284
340
|
}
|
|
285
341
|
}
|
|
286
342
|
|
|
343
|
+
let readSeekCacheInit: Promise<string> | undefined;
|
|
344
|
+
|
|
345
|
+
function readSeekCacheDir(): string {
|
|
346
|
+
const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim();
|
|
347
|
+
const cacheHome = xdgCacheHome || path.join(homedir(), ".cache");
|
|
348
|
+
return path.join(cacheHome, "opencode", "readseek");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function ensureReadSeekCache(context: ToolContext): Promise<string> {
|
|
352
|
+
const cacheDir = readSeekCacheDir();
|
|
353
|
+
try {
|
|
354
|
+
const info = await stat(cacheDir);
|
|
355
|
+
if (!info.isDirectory()) throw new Error(`readseek cache is not a directory: ${cacheDir}`);
|
|
356
|
+
return cacheDir;
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
readSeekCacheInit ??= runReadSeekRaw(context, ["--readseek-dir", cacheDir, "init"])
|
|
362
|
+
.then(() => cacheDir)
|
|
363
|
+
.finally(() => {
|
|
364
|
+
readSeekCacheInit = undefined;
|
|
365
|
+
});
|
|
366
|
+
return readSeekCacheInit;
|
|
367
|
+
}
|
|
368
|
+
|
|
287
369
|
function render(value: unknown): string {
|
|
288
370
|
return JSON.stringify(value, null, 2);
|
|
289
371
|
}
|
|
@@ -310,10 +392,70 @@ type AnchorEdit =
|
|
|
310
392
|
| { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
|
|
311
393
|
| { insert_after: { anchor: string; new_text: string } };
|
|
312
394
|
|
|
395
|
+
type TextSnapshot = {
|
|
396
|
+
exists: boolean;
|
|
397
|
+
content: string;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const fileMutationTails = new Map<string, Promise<void>>();
|
|
401
|
+
const MAX_PERMISSION_DIFF_BYTES = 32 * 1024;
|
|
402
|
+
|
|
403
|
+
async function withFileMutationQueue<T>(filePath: string, operation: () => Promise<T>): Promise<T> {
|
|
404
|
+
const key = path.resolve(filePath);
|
|
405
|
+
const previous = fileMutationTails.get(key);
|
|
406
|
+
let release = () => {};
|
|
407
|
+
const current = new Promise<void>((resolve) => {
|
|
408
|
+
release = resolve;
|
|
409
|
+
});
|
|
410
|
+
fileMutationTails.set(key, current);
|
|
411
|
+
if (previous) await previous;
|
|
412
|
+
try {
|
|
413
|
+
return await operation();
|
|
414
|
+
} finally {
|
|
415
|
+
release();
|
|
416
|
+
if (fileMutationTails.get(key) === current) fileMutationTails.delete(key);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function validateAnchorEdits(value: unknown): asserts value is AnchorEdit[] {
|
|
421
|
+
if (!Array.isArray(value) || value.length === 0) throw new Error("edits must contain at least one edit");
|
|
422
|
+
const fields = {
|
|
423
|
+
set_line: ["anchor", "new_text"],
|
|
424
|
+
replace_lines: ["end_anchor", "new_text", "start_anchor"],
|
|
425
|
+
insert_after: ["anchor", "new_text"],
|
|
426
|
+
} as const;
|
|
427
|
+
for (const [index, item] of value.entries()) {
|
|
428
|
+
if (!item || typeof item !== "object") throw new Error(`edits[${index}] must be an edit object`);
|
|
429
|
+
const keys = Object.keys(item);
|
|
430
|
+
const variant = keys[0] as keyof typeof fields;
|
|
431
|
+
if (keys.length !== 1 || !Object.hasOwn(fields, variant)) {
|
|
432
|
+
throw new Error(`edits[${index}] must contain exactly one of: set_line, replace_lines, insert_after`);
|
|
433
|
+
}
|
|
434
|
+
const payload = (item as Record<string, unknown>)[variant];
|
|
435
|
+
if (!payload || typeof payload !== "object") throw new Error(`edits[${index}].${variant} must be an object`);
|
|
436
|
+
const expectedFields = fields[variant];
|
|
437
|
+
const actualFields = Object.keys(payload).sort();
|
|
438
|
+
if (actualFields.length !== expectedFields.length || actualFields.some((field, fieldIndex) => field !== expectedFields[fieldIndex])) {
|
|
439
|
+
throw new Error(`edits[${index}].${variant} contains invalid fields`);
|
|
440
|
+
}
|
|
441
|
+
const values = payload as Record<string, unknown>;
|
|
442
|
+
for (const field of expectedFields) {
|
|
443
|
+
if (typeof values[field] !== "string") {
|
|
444
|
+
throw new Error(`edits[${index}].${variant}.${field} must be a string`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
for (const field of expectedFields.filter((field) => field.endsWith("anchor"))) {
|
|
448
|
+
parseAnchor(values[field] as string);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
313
453
|
function parseAnchor(anchor: string): { line: number; hash: string } {
|
|
314
454
|
const match = /^(\d+):([0-9a-fA-F]{3})$/.exec(anchor.trim());
|
|
315
455
|
if (!match) throw new Error(`invalid LINE:HASH anchor: ${anchor}`);
|
|
316
|
-
|
|
456
|
+
const line = Number(match[1]);
|
|
457
|
+
if (line === 0) throw new Error(`anchor line must be greater than zero: ${anchor}`);
|
|
458
|
+
return { line, hash: match[2].toLowerCase() };
|
|
317
459
|
}
|
|
318
460
|
|
|
319
461
|
function anchorRefs(edit: AnchorEdit): { line: number; hash: string }[] {
|
|
@@ -341,11 +483,55 @@ async function verifyAnchors(context: ToolContext, filePath: string, edits: Anch
|
|
|
341
483
|
}
|
|
342
484
|
}
|
|
343
485
|
|
|
486
|
+
type EditableLine = {
|
|
487
|
+
text: string;
|
|
488
|
+
ending: "\r\n" | "\n" | "\r" | "";
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
function parseEditableLines(content: string): EditableLine[] {
|
|
492
|
+
const lines: EditableLine[] = [];
|
|
493
|
+
let start = 0;
|
|
494
|
+
for (let index = 0; index < content.length; index++) {
|
|
495
|
+
const character = content[index];
|
|
496
|
+
if (character !== "\r" && character !== "\n") continue;
|
|
497
|
+
const ending = character === "\r" && content[index + 1] === "\n" ? "\r\n" : character;
|
|
498
|
+
lines.push({ text: content.slice(start, index), ending });
|
|
499
|
+
if (ending === "\r\n") index++;
|
|
500
|
+
start = index + 1;
|
|
501
|
+
}
|
|
502
|
+
if (start < content.length || content.length === 0) lines.push({ text: content.slice(start), ending: "" });
|
|
503
|
+
return lines;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function detectLineEnding(lines: EditableLine[]): "\r\n" | "\n" | "\r" {
|
|
507
|
+
const counts = new Map<"\r\n" | "\n" | "\r", number>([
|
|
508
|
+
["\r\n", 0],
|
|
509
|
+
["\n", 0],
|
|
510
|
+
["\r", 0],
|
|
511
|
+
]);
|
|
512
|
+
for (const line of lines) {
|
|
513
|
+
if (line.ending !== "") counts.set(line.ending, (counts.get(line.ending) ?? 0) + 1);
|
|
514
|
+
}
|
|
515
|
+
let selected: "\r\n" | "\n" | "\r" = "\n";
|
|
516
|
+
for (const ending of ["\r\n", "\r"] as const) {
|
|
517
|
+
if ((counts.get(ending) ?? 0) > (counts.get(selected) ?? 0)) selected = ending;
|
|
518
|
+
}
|
|
519
|
+
return selected;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function normalizeToLf(content: string): string {
|
|
523
|
+
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function replacementLines(text: string): string[] {
|
|
527
|
+
if (text === "") return [];
|
|
528
|
+
return normalizeToLf(text).replace(/\n$/, "").split("\n");
|
|
529
|
+
}
|
|
530
|
+
|
|
344
531
|
function applyAnchorEdits(content: string, edits: AnchorEdit[]): string {
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
if (trailingNewline) lines.pop();
|
|
532
|
+
const bom = content.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
533
|
+
const lines = parseEditableLines(bom ? content.slice(1) : content);
|
|
534
|
+
const newline = detectLineEnding(lines);
|
|
349
535
|
const planned = edits.map((edit) => {
|
|
350
536
|
if ("set_line" in edit) {
|
|
351
537
|
const { line } = parseAnchor(edit.set_line.anchor);
|
|
@@ -372,15 +558,85 @@ function applyAnchorEdits(content: string, edits: AnchorEdit[]): string {
|
|
|
372
558
|
if (edit.start < 0 || edit.start > lines.length || edit.start + edit.deleteCount > lines.length) {
|
|
373
559
|
throw new Error("anchor line is outside the file");
|
|
374
560
|
}
|
|
375
|
-
const
|
|
561
|
+
const removed = lines.slice(edit.start, edit.start + edit.deleteCount);
|
|
562
|
+
const replacement = replacementLines(edit.text).map((text): EditableLine => ({ text, ending: newline }));
|
|
563
|
+
if (edit.deleteCount === 0 && edit.start === lines.length && replacement.length > 0) {
|
|
564
|
+
const previous = lines.at(-1);
|
|
565
|
+
const terminalEnding = previous?.ending ?? "";
|
|
566
|
+
if (previous?.ending === "") previous.ending = newline;
|
|
567
|
+
replacement[replacement.length - 1].ending = terminalEnding;
|
|
568
|
+
} else if (edit.deleteCount > 0) {
|
|
569
|
+
const terminalEnding = removed.at(-1)?.ending ?? "";
|
|
570
|
+
if (replacement.length > 0) {
|
|
571
|
+
replacement[replacement.length - 1].ending = terminalEnding;
|
|
572
|
+
} else if (edit.start + edit.deleteCount === lines.length && edit.start > 0) {
|
|
573
|
+
lines[edit.start - 1].ending = terminalEnding;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
376
576
|
lines.splice(edit.start, edit.deleteCount, ...replacement);
|
|
377
577
|
}
|
|
378
|
-
return lines.
|
|
578
|
+
return bom + lines.map((line) => line.text + line.ending).join("");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function readTextSnapshot(filePath: string): Promise<TextSnapshot> {
|
|
582
|
+
try {
|
|
583
|
+
return { exists: true, content: await readFile(filePath, "utf8") };
|
|
584
|
+
} catch (error) {
|
|
585
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false, content: "" };
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function writeHandle(handle: Awaited<ReturnType<typeof open>>, content: string): Promise<void> {
|
|
591
|
+
const data = Buffer.from(content, "utf8");
|
|
592
|
+
await handle.truncate(0);
|
|
593
|
+
let offset = 0;
|
|
594
|
+
while (offset < data.length) {
|
|
595
|
+
const { bytesWritten } = await handle.write(data, offset, data.length - offset, offset);
|
|
596
|
+
if (bytesWritten === 0) throw new Error("write made no progress");
|
|
597
|
+
offset += bytesWritten;
|
|
598
|
+
}
|
|
379
599
|
}
|
|
380
600
|
|
|
381
|
-
async function writeText(filePath: string, content: string): Promise<void> {
|
|
601
|
+
async function writeText(filePath: string, expected: TextSnapshot, content: string): Promise<void> {
|
|
382
602
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
383
|
-
|
|
603
|
+
if (!expected.exists) {
|
|
604
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
605
|
+
try {
|
|
606
|
+
handle = await open(filePath, "wx+");
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
609
|
+
throw new Error(`refusing to overwrite changed file: ${filePath}`);
|
|
610
|
+
}
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
await writeHandle(handle, content);
|
|
615
|
+
} finally {
|
|
616
|
+
await handle.close();
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const pathInfo = await lstat(filePath);
|
|
622
|
+
if (pathInfo.isSymbolicLink()) throw new Error(`refusing to modify symbolic link: ${filePath}`);
|
|
623
|
+
const handle = await open(filePath, "r+");
|
|
624
|
+
try {
|
|
625
|
+
const handleInfo = await handle.stat();
|
|
626
|
+
const currentPathInfo = await lstat(filePath);
|
|
627
|
+
if (
|
|
628
|
+
currentPathInfo.isSymbolicLink() ||
|
|
629
|
+
currentPathInfo.dev !== handleInfo.dev ||
|
|
630
|
+
currentPathInfo.ino !== handleInfo.ino
|
|
631
|
+
) {
|
|
632
|
+
throw new Error(`refusing to overwrite changed file: ${filePath}`);
|
|
633
|
+
}
|
|
634
|
+
const current = (await handle.readFile()).toString("utf8");
|
|
635
|
+
if (current !== expected.content) throw new Error(`refusing to overwrite changed file: ${filePath}`);
|
|
636
|
+
if (current !== content) await writeHandle(handle, content);
|
|
637
|
+
} finally {
|
|
638
|
+
await handle.close();
|
|
639
|
+
}
|
|
384
640
|
}
|
|
385
641
|
|
|
386
642
|
function renameFiles(value: unknown): string[] {
|
|
@@ -394,9 +650,176 @@ function renameFiles(value: unknown): string[] {
|
|
|
394
650
|
return files;
|
|
395
651
|
}
|
|
396
652
|
|
|
653
|
+
function diffLines(content: string): string[] {
|
|
654
|
+
if (content === "") return [];
|
|
655
|
+
const lines = normalizeToLf(content).split("\n");
|
|
656
|
+
if (lines.at(-1) === "") lines.pop();
|
|
657
|
+
return lines;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function diffRange(start: number, count: number): string {
|
|
661
|
+
return count === 0 ? `${start},0` : `${start + 1},${count}`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function utf8Prefix(value: string, maxBytes: number): string {
|
|
665
|
+
const bytes = Buffer.from(value, "utf8");
|
|
666
|
+
if (bytes.length <= maxBytes) return value;
|
|
667
|
+
let end = maxBytes;
|
|
668
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
669
|
+
while (end > 0) {
|
|
670
|
+
try {
|
|
671
|
+
return decoder.decode(bytes.subarray(0, end));
|
|
672
|
+
} catch {
|
|
673
|
+
end--;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return "";
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function boundDocumentOutput(value: string): string {
|
|
680
|
+
const lines = value.split("\n");
|
|
681
|
+
let output = lines.length > MAX_DOCUMENT_OUTPUT_LINES
|
|
682
|
+
? lines.slice(0, MAX_DOCUMENT_OUTPUT_LINES).join("\n")
|
|
683
|
+
: value;
|
|
684
|
+
const truncated = output !== value || Buffer.byteLength(output, "utf8") > MAX_DOCUMENT_OUTPUT_BYTES;
|
|
685
|
+
output = utf8Prefix(output, MAX_DOCUMENT_OUTPUT_BYTES);
|
|
686
|
+
return truncated
|
|
687
|
+
? `${output}\n[… document view truncated; narrow it with page, node, kind, or depth]`
|
|
688
|
+
: output;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function attachmentExtension(mime: string): string {
|
|
692
|
+
switch (mime) {
|
|
693
|
+
case "image/gif":
|
|
694
|
+
return "gif";
|
|
695
|
+
case "image/jpeg":
|
|
696
|
+
return "jpg";
|
|
697
|
+
case "image/png":
|
|
698
|
+
return "png";
|
|
699
|
+
case "image/webp":
|
|
700
|
+
return "webp";
|
|
701
|
+
default:
|
|
702
|
+
return "bin";
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function imageAttachment(image: Record<string, unknown>, filename: string): ToolAttachment | undefined {
|
|
707
|
+
if (image.encoding !== "base64" || typeof image.data !== "string" || typeof image.mime !== "string") {
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
return {
|
|
711
|
+
type: "file",
|
|
712
|
+
mime: image.mime,
|
|
713
|
+
url: `data:${image.mime};base64,${image.data}`,
|
|
714
|
+
filename: `${filename}.${attachmentExtension(image.mime)}`,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function formatReadResult(value: unknown): RenderedToolResult {
|
|
719
|
+
const output = record(value);
|
|
720
|
+
if (output.format === "pdf") {
|
|
721
|
+
const attachments: ToolAttachment[] = [];
|
|
722
|
+
const images = items(output.images).map((value, index) => {
|
|
723
|
+
const image = record(value);
|
|
724
|
+
const page = typeof image.page === "number" ? image.page : "unknown";
|
|
725
|
+
const attachment = imageAttachment(image, `pdf-page-${page}-image-${index + 1}`);
|
|
726
|
+
if (attachment) attachments.push(attachment);
|
|
727
|
+
const sanitized = { ...image };
|
|
728
|
+
delete sanitized.data;
|
|
729
|
+
delete sanitized.encoding;
|
|
730
|
+
return sanitized;
|
|
731
|
+
});
|
|
732
|
+
return {
|
|
733
|
+
output: boundDocumentOutput(render({ ...output, images })),
|
|
734
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const attachment = imageAttachment(output, "image");
|
|
739
|
+
if (!attachment) return { output: render(value) };
|
|
740
|
+
const sanitized = { ...output };
|
|
741
|
+
delete sanitized.data;
|
|
742
|
+
delete sanitized.encoding;
|
|
743
|
+
return { output: render(sanitized), attachments: [attachment] };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function previewDiffLine(prefix: string, line: string): string {
|
|
747
|
+
const value = `${prefix}${line}`;
|
|
748
|
+
const maxBytes = 256;
|
|
749
|
+
if (Buffer.byteLength(value) <= maxBytes) return value;
|
|
750
|
+
return `${utf8Prefix(value, maxBytes - Buffer.byteLength("…"))}…`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function sampleDiffLines(lines: string[], start: number, end: number, prefix: string): string[] {
|
|
754
|
+
const sampleSize = 20;
|
|
755
|
+
const count = end - start;
|
|
756
|
+
if (count <= sampleSize * 2) {
|
|
757
|
+
return lines.slice(start, end).map((line) => previewDiffLine(prefix, line));
|
|
758
|
+
}
|
|
759
|
+
return [
|
|
760
|
+
...lines.slice(start, start + sampleSize).map((line) => previewDiffLine(prefix, line)),
|
|
761
|
+
` ${count - sampleSize * 2} ${prefix === "-" ? "removed" : "added"} lines omitted`,
|
|
762
|
+
...lines.slice(end - sampleSize, end).map((line) => previewDiffLine(prefix, line)),
|
|
763
|
+
];
|
|
764
|
+
}
|
|
765
|
+
|
|
397
766
|
function simpleDiff(filePath: string, before: string, after: string): string {
|
|
398
767
|
if (before === after) return "";
|
|
399
|
-
|
|
768
|
+
const oldLines = diffLines(before);
|
|
769
|
+
const newLines = diffLines(after);
|
|
770
|
+
let prefix = 0;
|
|
771
|
+
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
|
|
772
|
+
let suffix = 0;
|
|
773
|
+
while (
|
|
774
|
+
suffix < oldLines.length - prefix &&
|
|
775
|
+
suffix < newLines.length - prefix &&
|
|
776
|
+
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
777
|
+
) suffix++;
|
|
778
|
+
if (prefix === oldLines.length && prefix === newLines.length) {
|
|
779
|
+
prefix = Math.max(0, prefix - 1);
|
|
780
|
+
suffix = 0;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const oldChangeEnd = oldLines.length - suffix;
|
|
784
|
+
const newChangeEnd = newLines.length - suffix;
|
|
785
|
+
const oldStart = Math.max(0, prefix - 3);
|
|
786
|
+
const newStart = Math.max(0, prefix - 3);
|
|
787
|
+
const oldEnd = Math.min(oldLines.length, oldChangeEnd + 3);
|
|
788
|
+
const newEnd = Math.min(newLines.length, newChangeEnd + 3);
|
|
789
|
+
const safePath = utf8Prefix(filePath.replace(/[\r\n]/g, ""), 1024);
|
|
790
|
+
const header = [
|
|
791
|
+
`--- ${safePath}`,
|
|
792
|
+
`+++ ${safePath}`,
|
|
793
|
+
`@@ -${diffRange(oldStart, oldEnd - oldStart)} +${diffRange(newStart, newEnd - newStart)} @@`,
|
|
794
|
+
];
|
|
795
|
+
const fullDiff = [...header];
|
|
796
|
+
let fullDiffBytes = Buffer.byteLength(header.join("\n"));
|
|
797
|
+
let complete = true;
|
|
798
|
+
const ranges: Array<[string[], number, number, string]> = [
|
|
799
|
+
[oldLines, oldStart, prefix, " "],
|
|
800
|
+
[oldLines, prefix, oldChangeEnd, "-"],
|
|
801
|
+
[newLines, prefix, newChangeEnd, "+"],
|
|
802
|
+
[newLines, newChangeEnd, newEnd, " "],
|
|
803
|
+
];
|
|
804
|
+
outer: for (const [lines, start, end, marker] of ranges) {
|
|
805
|
+
for (let index = start; index < end; index++) {
|
|
806
|
+
const lineBytes = 2 + Buffer.byteLength(lines[index]);
|
|
807
|
+
if (fullDiffBytes + lineBytes > MAX_PERMISSION_DIFF_BYTES) {
|
|
808
|
+
complete = false;
|
|
809
|
+
break outer;
|
|
810
|
+
}
|
|
811
|
+
fullDiff.push(`${marker}${lines[index]}`);
|
|
812
|
+
fullDiffBytes += lineBytes;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (complete) return fullDiff.join("\n");
|
|
816
|
+
|
|
817
|
+
return [
|
|
818
|
+
...header,
|
|
819
|
+
` diff truncated: ${oldChangeEnd - prefix} removed, ${newChangeEnd - prefix} added lines`,
|
|
820
|
+
...sampleDiffLines(oldLines, prefix, oldChangeEnd, "-"),
|
|
821
|
+
...sampleDiffLines(newLines, prefix, newChangeEnd, "+"),
|
|
822
|
+
].join("\n");
|
|
400
823
|
}
|
|
401
824
|
|
|
402
825
|
function identifiedName(value: unknown): string | undefined {
|
|
@@ -419,6 +842,8 @@ function initialTitle(kind: PresentationKind, args: any): string {
|
|
|
419
842
|
switch (kind) {
|
|
420
843
|
case "read":
|
|
421
844
|
return `Read ${args.path}`;
|
|
845
|
+
case "view":
|
|
846
|
+
return `View ${args.path}`;
|
|
422
847
|
case "map":
|
|
423
848
|
return `Map ${args.path}`;
|
|
424
849
|
case "search":
|
|
@@ -461,6 +886,8 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
|
|
|
461
886
|
}
|
|
462
887
|
break;
|
|
463
888
|
}
|
|
889
|
+
case "view":
|
|
890
|
+
return { title: `Viewed ${args.path}`, metadata: {} };
|
|
464
891
|
case "map": {
|
|
465
892
|
const symbols = items(output.symbols).length;
|
|
466
893
|
return { title: `Mapped ${args.path} (${symbols} symbols)`, metadata: { symbols } };
|
|
@@ -528,6 +955,7 @@ function readseekTool(
|
|
|
528
955
|
args: Record<string, any>,
|
|
529
956
|
kind: PresentationKind,
|
|
530
957
|
execute: (args: any, context: ToolContext) => Promise<unknown>,
|
|
958
|
+
formatResult: (result: unknown) => RenderedToolResult = (result) => ({ output: render(result) }),
|
|
531
959
|
) {
|
|
532
960
|
return tool({
|
|
533
961
|
description,
|
|
@@ -537,7 +965,11 @@ function readseekTool(
|
|
|
537
965
|
context.metadata({ title });
|
|
538
966
|
const result = await execute(args, context);
|
|
539
967
|
const presentation = resultPresentation(kind, args, result);
|
|
540
|
-
return {
|
|
968
|
+
return {
|
|
969
|
+
title: presentation.title,
|
|
970
|
+
...formatResult(result),
|
|
971
|
+
metadata: presentation.metadata,
|
|
972
|
+
};
|
|
541
973
|
},
|
|
542
974
|
});
|
|
543
975
|
}
|
|
@@ -569,6 +1001,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
569
1001
|
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
570
1002
|
offset: tool.schema.number().int().positive().optional().describe("One-based starting line"),
|
|
571
1003
|
limit: tool.schema.number().int().positive().optional().describe(`Maximum number of lines; defaults to ${DEFAULT_READ_LIMIT}`),
|
|
1004
|
+
page: tool.schema.number().int().positive().optional().describe("One-based PDF page; defaults to 1"),
|
|
572
1005
|
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
573
1006
|
...(imagePolicy === "off"
|
|
574
1007
|
? {}
|
|
@@ -584,20 +1017,55 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
584
1017
|
const image = input.image as ImageMode | undefined;
|
|
585
1018
|
if (imagePolicy === "off" && image !== undefined) throw new Error("image and PDF reads are disabled");
|
|
586
1019
|
if (imagePolicy === "on" && image === "none") throw new Error('image="none" requires imageMode="auto"');
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
1020
|
+
const detection = await runReadSeek(context, ["detect", filePath]);
|
|
1021
|
+
const visual = isVisualFile(detection);
|
|
1022
|
+
const pdf = isPdfFile(detection);
|
|
1023
|
+
if (visual && image === undefined) {
|
|
1024
|
+
return { file: filePath, skipped: true, reason: "image mode not selected" };
|
|
1025
|
+
}
|
|
1026
|
+
if (visual && (input.offset !== undefined || input.limit !== undefined)) {
|
|
1027
|
+
throw new Error("offset and limit do not apply to image or PDF reads");
|
|
1028
|
+
}
|
|
1029
|
+
if (visual && input.language !== undefined) {
|
|
1030
|
+
throw new Error("language does not apply to image or PDF reads");
|
|
592
1031
|
}
|
|
1032
|
+
if (!pdf && input.page !== undefined) throw new Error("page applies to PDF reads only");
|
|
1033
|
+
|
|
593
1034
|
const args = ["read", input.offset === undefined ? filePath : `${filePath}:${input.offset}`];
|
|
594
|
-
if (
|
|
1035
|
+
if (!visual) {
|
|
595
1036
|
args.push("--end", String((input.offset ?? 1) + ((input.limit as number | undefined) ?? DEFAULT_READ_LIMIT) - 1));
|
|
596
1037
|
}
|
|
597
1038
|
if (input.language) args.push("--language", input.language as string);
|
|
598
1039
|
if (image !== undefined) args.push("--image", image);
|
|
1040
|
+
if (pdf) args.push("--page", String(input.page ?? 1));
|
|
599
1041
|
return runReadSeek(context, args);
|
|
600
1042
|
},
|
|
1043
|
+
formatReadResult,
|
|
1044
|
+
),
|
|
1045
|
+
readseek_view: readseekTool(
|
|
1046
|
+
"View the structure or selected content of an indexed PDF. Start with the overview, then narrow by page or node.",
|
|
1047
|
+
{
|
|
1048
|
+
path: tool.schema.string().describe("PDF path relative to the project directory"),
|
|
1049
|
+
node: tool.schema.string().min(1).optional().describe("Node ID to use as the view root"),
|
|
1050
|
+
page: tool.schema.number().int().positive().optional().describe("One-based source page"),
|
|
1051
|
+
kind: tool.schema.enum(DOCUMENT_NODE_KINDS).optional().describe("Node kind filter"),
|
|
1052
|
+
depth: tool.schema.number().int().nonnegative().optional().describe("Maximum depth below selected roots"),
|
|
1053
|
+
outline: tool.schema.boolean().optional().describe("Return outline nodes only"),
|
|
1054
|
+
},
|
|
1055
|
+
"view",
|
|
1056
|
+
async (input, context) => {
|
|
1057
|
+
const filePath = resolvePath(context.directory, input.path as string);
|
|
1058
|
+
await authorizeRead(context, filePath);
|
|
1059
|
+
const cacheDir = await ensureReadSeekCache(context);
|
|
1060
|
+
const args = ["--readseek-dir", cacheDir, "view", filePath];
|
|
1061
|
+
if (input.node) args.push("--node", input.node as string);
|
|
1062
|
+
if (input.page !== undefined) args.push("--page", String(input.page));
|
|
1063
|
+
if (input.kind) args.push("--kind", input.kind as string);
|
|
1064
|
+
if (input.depth !== undefined) args.push("--depth", String(input.depth));
|
|
1065
|
+
optionalFlag(args, input.outline as boolean | undefined, "--outline");
|
|
1066
|
+
return runReadSeekRaw(context, args);
|
|
1067
|
+
},
|
|
1068
|
+
(result) => ({ output: boundDocumentOutput(String(result)) }),
|
|
601
1069
|
),
|
|
602
1070
|
readseek_map: readseekTool(
|
|
603
1071
|
"List symbols and ranges in a source file. Use to inspect structure without reading the full file.",
|
|
@@ -792,17 +1260,12 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
792
1260
|
const plan = await runReadSeek(context, args);
|
|
793
1261
|
if (input.apply === false) return plan;
|
|
794
1262
|
const files = renameFiles(plan);
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
patterns: ["**"],
|
|
799
|
-
always: ["**"],
|
|
800
|
-
metadata: { filepath: files.join(", "), diff: "" },
|
|
801
|
-
});
|
|
802
|
-
} else {
|
|
803
|
-
await authorizeEdit(context, files);
|
|
1263
|
+
const planHash = record(plan).plan_hash;
|
|
1264
|
+
if (typeof planHash !== "string" || planHash.length === 0) {
|
|
1265
|
+
throw new Error("readseek rename plan did not include a plan hash");
|
|
804
1266
|
}
|
|
805
|
-
|
|
1267
|
+
await authorizeEdit(context, files);
|
|
1268
|
+
return runReadSeek(context, [...args, "--plan-hash", planHash, "--apply"], { cancelable: false });
|
|
806
1269
|
},
|
|
807
1270
|
),
|
|
808
1271
|
readseek_edit: readseekTool(
|
|
@@ -810,24 +1273,38 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
810
1273
|
{
|
|
811
1274
|
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
812
1275
|
edits: tool.schema.array(tool.schema.union([
|
|
813
|
-
tool.schema.object({
|
|
814
|
-
|
|
815
|
-
|
|
1276
|
+
tool.schema.object({
|
|
1277
|
+
set_line: tool.schema.object({ anchor: tool.schema.string(), new_text: tool.schema.string() }).strict(),
|
|
1278
|
+
}).strict(),
|
|
1279
|
+
tool.schema.object({
|
|
1280
|
+
replace_lines: tool.schema.object({
|
|
1281
|
+
start_anchor: tool.schema.string(),
|
|
1282
|
+
end_anchor: tool.schema.string(),
|
|
1283
|
+
new_text: tool.schema.string(),
|
|
1284
|
+
}).strict(),
|
|
1285
|
+
}).strict(),
|
|
1286
|
+
tool.schema.object({
|
|
1287
|
+
insert_after: tool.schema.object({ anchor: tool.schema.string(), new_text: tool.schema.string() }).strict(),
|
|
1288
|
+
}).strict(),
|
|
816
1289
|
])).min(1).describe("Anchored edits to apply"),
|
|
817
1290
|
},
|
|
818
1291
|
"edit",
|
|
819
1292
|
async (input, context) => {
|
|
820
1293
|
const filePath = resolvePath(context.directory, input.path as string);
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1294
|
+
validateAnchorEdits(input.edits);
|
|
1295
|
+
return withFileMutationQueue(filePath, async () => {
|
|
1296
|
+
context.abort.throwIfAborted();
|
|
1297
|
+
await authorizeRead(context, filePath);
|
|
1298
|
+
await rejectSymlinkMutation(filePath);
|
|
1299
|
+
const edits = input.edits;
|
|
1300
|
+
const before = await readFile(filePath, "utf8");
|
|
1301
|
+
await verifyAnchors(context, filePath, edits);
|
|
1302
|
+
const after = applyAnchorEdits(before, edits);
|
|
1303
|
+
await authorizeEdit(context, [filePath], simpleDiff(filePath, before, after), false);
|
|
1304
|
+
context.abort.throwIfAborted();
|
|
1305
|
+
if (before !== after) await writeText(filePath, { exists: true, content: before }, after);
|
|
1306
|
+
return runReadSeek(context, ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)], { cancelable: false });
|
|
1307
|
+
});
|
|
831
1308
|
},
|
|
832
1309
|
),
|
|
833
1310
|
readseek_write: readseekTool(
|
|
@@ -840,15 +1317,17 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
840
1317
|
async (input, context) => {
|
|
841
1318
|
const filePath = resolvePath(context.directory, input.path as string);
|
|
842
1319
|
await authorizeExternal(context, filePath);
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1320
|
+
return withFileMutationQueue(filePath, async () => {
|
|
1321
|
+
context.abort.throwIfAborted();
|
|
1322
|
+
await rejectSymlinkMutation(filePath);
|
|
1323
|
+
const before = await readTextSnapshot(filePath);
|
|
1324
|
+
const content = input.content as string;
|
|
1325
|
+
if (content.includes("\0")) throw new Error("write content must be text");
|
|
1326
|
+
await authorizeEdit(context, [filePath], simpleDiff(filePath, before.content, content), false);
|
|
1327
|
+
context.abort.throwIfAborted();
|
|
1328
|
+
await writeText(filePath, before, content);
|
|
1329
|
+
return runReadSeek(context, ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)], { cancelable: false });
|
|
1330
|
+
});
|
|
852
1331
|
},
|
|
853
1332
|
),
|
|
854
1333
|
readseek_check: readseekTool(
|
|
@@ -867,6 +1346,13 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
867
1346
|
},
|
|
868
1347
|
),
|
|
869
1348
|
},
|
|
1349
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
1350
|
+
output.system.push(EDITING_POLICY);
|
|
1351
|
+
},
|
|
1352
|
+
"tool.definition": async (input, output) => {
|
|
1353
|
+
const preference = PREFERRED_TOOL_DESCRIPTIONS[input.toolID];
|
|
1354
|
+
if (preference) output.description = `${preference} ${output.description}`;
|
|
1355
|
+
},
|
|
870
1356
|
event: async ({ event }) => {
|
|
871
1357
|
if (event.type === "file.edited" || event.type === "file.watcher.updated") {
|
|
872
1358
|
anchors.forget(event.properties.file);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-readseek",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "OpenCode plugin for readseek-backed structural code navigation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"node": ">=20.0.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@jarkkojs/readseek": "^0.
|
|
33
|
+
"@jarkkojs/readseek": "^0.7.1",
|
|
34
34
|
"@opencode-ai/plugin": "^1.17.20"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|