opencode-readseek 0.6.12 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/index.ts +526 -60
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ binary dependency with Bun at startup.
|
|
|
18
18
|
|
|
19
19
|
## Tools
|
|
20
20
|
|
|
21
|
-
- `readseek_read`: read text with `LINE:HASH` anchors; image/PDF handling is explicit.
|
|
21
|
+
- `readseek_read`: read text with `LINE:HASH` anchors; image/PDF handling is explicit and PDFs default to one page.
|
|
22
22
|
- `readseek_edit`: apply line, range, and insertion edits with fresh `LINE:HASH` anchors.
|
|
23
23
|
- `readseek_write`: create or replace a complete text file.
|
|
24
24
|
- `readseek_grep`: plain-text or regular-expression search with anchored results.
|
|
@@ -27,6 +27,7 @@ binary dependency with Bun at startup.
|
|
|
27
27
|
- `readseek_def`, `readseek_refs`, `readseek_hover`: symbol navigation.
|
|
28
28
|
- `readseek_rename`: apply a verified rename by default; `apply: false` returns a dry-run plan.
|
|
29
29
|
- `readseek_check`: parse diagnostics.
|
|
30
|
+
- `readseek_view`: view an indexed PDF overview or narrow it by page, node, kind, or depth.
|
|
30
31
|
|
|
31
32
|
The plugin requests OpenCode read, grep, external-directory, and edit permissions
|
|
32
33
|
as needed. File changes invalidate remembered anchors. Compaction context records
|
package/index.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
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;
|
|
13
16
|
const READSEEK_PLATFORM_PACKAGES: Record<string, string> = {
|
|
14
17
|
"darwin-arm64": "@jarkkojs/readseek-darwin-arm64",
|
|
15
18
|
"linux-arm64": "@jarkkojs/readseek-linux-arm64",
|
|
@@ -22,15 +25,34 @@ type RenamePlan = {
|
|
|
22
25
|
files: Set<string>;
|
|
23
26
|
};
|
|
24
27
|
|
|
25
|
-
type PresentationKind = "read" | "map" | "search" | "grep" | "def" | "refs" | "hover" | "rename" | "edit" | "write" | "check";
|
|
28
|
+
type PresentationKind = "read" | "view" | "map" | "search" | "grep" | "def" | "refs" | "hover" | "rename" | "edit" | "write" | "check";
|
|
26
29
|
|
|
27
30
|
type Presentation = {
|
|
28
31
|
title: string;
|
|
29
32
|
metadata: Record<string, number>;
|
|
30
33
|
};
|
|
31
34
|
|
|
35
|
+
type RenderedToolResult = {
|
|
36
|
+
output: string;
|
|
37
|
+
attachments?: ToolAttachment[];
|
|
38
|
+
};
|
|
39
|
+
|
|
32
40
|
type ImagePolicy = "on" | "auto" | "off";
|
|
33
41
|
type ImageMode = "none" | "all" | "ocr" | "caption" | "objects";
|
|
42
|
+
type DocumentNodeKind = "artifact" | "footer" | "header" | "heading" | "marginal_label" | "page" | "page_number" | "paragraph" | "section" | "structural_section";
|
|
43
|
+
|
|
44
|
+
const DOCUMENT_NODE_KINDS: [DocumentNodeKind, ...DocumentNodeKind[]] = [
|
|
45
|
+
"artifact",
|
|
46
|
+
"footer",
|
|
47
|
+
"header",
|
|
48
|
+
"heading",
|
|
49
|
+
"marginal_label",
|
|
50
|
+
"page",
|
|
51
|
+
"page_number",
|
|
52
|
+
"paragraph",
|
|
53
|
+
"section",
|
|
54
|
+
"structural_section",
|
|
55
|
+
];
|
|
34
56
|
|
|
35
57
|
function resolveImagePolicy(options: PluginOptions | undefined): ImagePolicy {
|
|
36
58
|
const value = options?.imageMode;
|
|
@@ -45,6 +67,10 @@ function isVisualFile(value: unknown): boolean {
|
|
|
45
67
|
return typeof output.width === "number" || type === "application/pdf";
|
|
46
68
|
}
|
|
47
69
|
|
|
70
|
+
function isPdfFile(value: unknown): boolean {
|
|
71
|
+
return record(value).format === "pdf";
|
|
72
|
+
}
|
|
73
|
+
|
|
48
74
|
class SessionAnchors {
|
|
49
75
|
#pathsBySession = new Map<string, Set<string>>();
|
|
50
76
|
#renamePlans = new Map<string, RenamePlan>();
|
|
@@ -259,24 +285,41 @@ function readSeekBinaryPath(): string {
|
|
|
259
285
|
return path.join(path.dirname(packageJson), "bin", process.platform === "win32" ? "readseek.exe" : "readseek");
|
|
260
286
|
}
|
|
261
287
|
|
|
262
|
-
async function
|
|
288
|
+
async function runReadSeekRaw(
|
|
289
|
+
context: ToolContext,
|
|
290
|
+
args: string[],
|
|
291
|
+
options: { cancelable?: boolean } = {},
|
|
292
|
+
): Promise<string> {
|
|
263
293
|
context.abort.throwIfAborted();
|
|
264
|
-
const
|
|
294
|
+
const cancelable = options.cancelable !== false;
|
|
295
|
+
const spawnOptions = {
|
|
265
296
|
cwd: context.directory,
|
|
266
|
-
killSignal: "SIGKILL",
|
|
267
297
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
298
|
+
stderr: "pipe" as const,
|
|
299
|
+
stdout: "pipe" as const,
|
|
300
|
+
};
|
|
301
|
+
const child = Bun.spawn(
|
|
302
|
+
[readSeekBinaryPath(), ...args],
|
|
303
|
+
cancelable
|
|
304
|
+
? { ...spawnOptions, killSignal: "SIGKILL" as const, signal: context.abort }
|
|
305
|
+
: spawnOptions,
|
|
306
|
+
);
|
|
272
307
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
273
308
|
new Response(child.stdout).text(),
|
|
274
309
|
new Response(child.stderr).text(),
|
|
275
310
|
child.exited,
|
|
276
311
|
]);
|
|
277
|
-
context.abort.throwIfAborted();
|
|
312
|
+
if (cancelable) context.abort.throwIfAborted();
|
|
278
313
|
if (exitCode !== 0) throw new Error(stderr.trim() || `readseek exited with status ${exitCode}`);
|
|
314
|
+
return stdout;
|
|
315
|
+
}
|
|
279
316
|
|
|
317
|
+
async function runReadSeek(
|
|
318
|
+
context: ToolContext,
|
|
319
|
+
args: string[],
|
|
320
|
+
options: { cancelable?: boolean } = {},
|
|
321
|
+
): Promise<unknown> {
|
|
322
|
+
const stdout = await runReadSeekRaw(context, args, options);
|
|
280
323
|
try {
|
|
281
324
|
return JSON.parse(stdout) as unknown;
|
|
282
325
|
} catch {
|
|
@@ -284,6 +327,32 @@ async function runReadSeek(context: ToolContext, args: string[]): Promise<unknow
|
|
|
284
327
|
}
|
|
285
328
|
}
|
|
286
329
|
|
|
330
|
+
let readSeekCacheInit: Promise<string> | undefined;
|
|
331
|
+
|
|
332
|
+
function readSeekCacheDir(): string {
|
|
333
|
+
const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim();
|
|
334
|
+
const cacheHome = xdgCacheHome || path.join(homedir(), ".cache");
|
|
335
|
+
return path.join(cacheHome, "opencode", "readseek");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function ensureReadSeekCache(context: ToolContext): Promise<string> {
|
|
339
|
+
const cacheDir = readSeekCacheDir();
|
|
340
|
+
try {
|
|
341
|
+
const info = await stat(cacheDir);
|
|
342
|
+
if (!info.isDirectory()) throw new Error(`readseek cache is not a directory: ${cacheDir}`);
|
|
343
|
+
return cacheDir;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
readSeekCacheInit ??= runReadSeekRaw(context, ["--readseek-dir", cacheDir, "init"])
|
|
349
|
+
.then(() => cacheDir)
|
|
350
|
+
.finally(() => {
|
|
351
|
+
readSeekCacheInit = undefined;
|
|
352
|
+
});
|
|
353
|
+
return readSeekCacheInit;
|
|
354
|
+
}
|
|
355
|
+
|
|
287
356
|
function render(value: unknown): string {
|
|
288
357
|
return JSON.stringify(value, null, 2);
|
|
289
358
|
}
|
|
@@ -310,10 +379,70 @@ type AnchorEdit =
|
|
|
310
379
|
| { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
|
|
311
380
|
| { insert_after: { anchor: string; new_text: string } };
|
|
312
381
|
|
|
382
|
+
type TextSnapshot = {
|
|
383
|
+
exists: boolean;
|
|
384
|
+
content: string;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const fileMutationTails = new Map<string, Promise<void>>();
|
|
388
|
+
const MAX_PERMISSION_DIFF_BYTES = 32 * 1024;
|
|
389
|
+
|
|
390
|
+
async function withFileMutationQueue<T>(filePath: string, operation: () => Promise<T>): Promise<T> {
|
|
391
|
+
const key = path.resolve(filePath);
|
|
392
|
+
const previous = fileMutationTails.get(key);
|
|
393
|
+
let release = () => {};
|
|
394
|
+
const current = new Promise<void>((resolve) => {
|
|
395
|
+
release = resolve;
|
|
396
|
+
});
|
|
397
|
+
fileMutationTails.set(key, current);
|
|
398
|
+
if (previous) await previous;
|
|
399
|
+
try {
|
|
400
|
+
return await operation();
|
|
401
|
+
} finally {
|
|
402
|
+
release();
|
|
403
|
+
if (fileMutationTails.get(key) === current) fileMutationTails.delete(key);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function validateAnchorEdits(value: unknown): asserts value is AnchorEdit[] {
|
|
408
|
+
if (!Array.isArray(value) || value.length === 0) throw new Error("edits must contain at least one edit");
|
|
409
|
+
const fields = {
|
|
410
|
+
set_line: ["anchor", "new_text"],
|
|
411
|
+
replace_lines: ["end_anchor", "new_text", "start_anchor"],
|
|
412
|
+
insert_after: ["anchor", "new_text"],
|
|
413
|
+
} as const;
|
|
414
|
+
for (const [index, item] of value.entries()) {
|
|
415
|
+
if (!item || typeof item !== "object") throw new Error(`edits[${index}] must be an edit object`);
|
|
416
|
+
const keys = Object.keys(item);
|
|
417
|
+
const variant = keys[0] as keyof typeof fields;
|
|
418
|
+
if (keys.length !== 1 || !Object.hasOwn(fields, variant)) {
|
|
419
|
+
throw new Error(`edits[${index}] must contain exactly one of: set_line, replace_lines, insert_after`);
|
|
420
|
+
}
|
|
421
|
+
const payload = (item as Record<string, unknown>)[variant];
|
|
422
|
+
if (!payload || typeof payload !== "object") throw new Error(`edits[${index}].${variant} must be an object`);
|
|
423
|
+
const expectedFields = fields[variant];
|
|
424
|
+
const actualFields = Object.keys(payload).sort();
|
|
425
|
+
if (actualFields.length !== expectedFields.length || actualFields.some((field, fieldIndex) => field !== expectedFields[fieldIndex])) {
|
|
426
|
+
throw new Error(`edits[${index}].${variant} contains invalid fields`);
|
|
427
|
+
}
|
|
428
|
+
const values = payload as Record<string, unknown>;
|
|
429
|
+
for (const field of expectedFields) {
|
|
430
|
+
if (typeof values[field] !== "string") {
|
|
431
|
+
throw new Error(`edits[${index}].${variant}.${field} must be a string`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
for (const field of expectedFields.filter((field) => field.endsWith("anchor"))) {
|
|
435
|
+
parseAnchor(values[field] as string);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
313
440
|
function parseAnchor(anchor: string): { line: number; hash: string } {
|
|
314
441
|
const match = /^(\d+):([0-9a-fA-F]{3})$/.exec(anchor.trim());
|
|
315
442
|
if (!match) throw new Error(`invalid LINE:HASH anchor: ${anchor}`);
|
|
316
|
-
|
|
443
|
+
const line = Number(match[1]);
|
|
444
|
+
if (line === 0) throw new Error(`anchor line must be greater than zero: ${anchor}`);
|
|
445
|
+
return { line, hash: match[2].toLowerCase() };
|
|
317
446
|
}
|
|
318
447
|
|
|
319
448
|
function anchorRefs(edit: AnchorEdit): { line: number; hash: string }[] {
|
|
@@ -341,11 +470,55 @@ async function verifyAnchors(context: ToolContext, filePath: string, edits: Anch
|
|
|
341
470
|
}
|
|
342
471
|
}
|
|
343
472
|
|
|
473
|
+
type EditableLine = {
|
|
474
|
+
text: string;
|
|
475
|
+
ending: "\r\n" | "\n" | "\r" | "";
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
function parseEditableLines(content: string): EditableLine[] {
|
|
479
|
+
const lines: EditableLine[] = [];
|
|
480
|
+
let start = 0;
|
|
481
|
+
for (let index = 0; index < content.length; index++) {
|
|
482
|
+
const character = content[index];
|
|
483
|
+
if (character !== "\r" && character !== "\n") continue;
|
|
484
|
+
const ending = character === "\r" && content[index + 1] === "\n" ? "\r\n" : character;
|
|
485
|
+
lines.push({ text: content.slice(start, index), ending });
|
|
486
|
+
if (ending === "\r\n") index++;
|
|
487
|
+
start = index + 1;
|
|
488
|
+
}
|
|
489
|
+
if (start < content.length || content.length === 0) lines.push({ text: content.slice(start), ending: "" });
|
|
490
|
+
return lines;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function detectLineEnding(lines: EditableLine[]): "\r\n" | "\n" | "\r" {
|
|
494
|
+
const counts = new Map<"\r\n" | "\n" | "\r", number>([
|
|
495
|
+
["\r\n", 0],
|
|
496
|
+
["\n", 0],
|
|
497
|
+
["\r", 0],
|
|
498
|
+
]);
|
|
499
|
+
for (const line of lines) {
|
|
500
|
+
if (line.ending !== "") counts.set(line.ending, (counts.get(line.ending) ?? 0) + 1);
|
|
501
|
+
}
|
|
502
|
+
let selected: "\r\n" | "\n" | "\r" = "\n";
|
|
503
|
+
for (const ending of ["\r\n", "\r"] as const) {
|
|
504
|
+
if ((counts.get(ending) ?? 0) > (counts.get(selected) ?? 0)) selected = ending;
|
|
505
|
+
}
|
|
506
|
+
return selected;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function normalizeToLf(content: string): string {
|
|
510
|
+
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function replacementLines(text: string): string[] {
|
|
514
|
+
if (text === "") return [];
|
|
515
|
+
return normalizeToLf(text).replace(/\n$/, "").split("\n");
|
|
516
|
+
}
|
|
517
|
+
|
|
344
518
|
function applyAnchorEdits(content: string, edits: AnchorEdit[]): string {
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
if (trailingNewline) lines.pop();
|
|
519
|
+
const bom = content.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
520
|
+
const lines = parseEditableLines(bom ? content.slice(1) : content);
|
|
521
|
+
const newline = detectLineEnding(lines);
|
|
349
522
|
const planned = edits.map((edit) => {
|
|
350
523
|
if ("set_line" in edit) {
|
|
351
524
|
const { line } = parseAnchor(edit.set_line.anchor);
|
|
@@ -372,15 +545,85 @@ function applyAnchorEdits(content: string, edits: AnchorEdit[]): string {
|
|
|
372
545
|
if (edit.start < 0 || edit.start > lines.length || edit.start + edit.deleteCount > lines.length) {
|
|
373
546
|
throw new Error("anchor line is outside the file");
|
|
374
547
|
}
|
|
375
|
-
const
|
|
548
|
+
const removed = lines.slice(edit.start, edit.start + edit.deleteCount);
|
|
549
|
+
const replacement = replacementLines(edit.text).map((text): EditableLine => ({ text, ending: newline }));
|
|
550
|
+
if (edit.deleteCount === 0 && edit.start === lines.length && replacement.length > 0) {
|
|
551
|
+
const previous = lines.at(-1);
|
|
552
|
+
const terminalEnding = previous?.ending ?? "";
|
|
553
|
+
if (previous?.ending === "") previous.ending = newline;
|
|
554
|
+
replacement[replacement.length - 1].ending = terminalEnding;
|
|
555
|
+
} else if (edit.deleteCount > 0) {
|
|
556
|
+
const terminalEnding = removed.at(-1)?.ending ?? "";
|
|
557
|
+
if (replacement.length > 0) {
|
|
558
|
+
replacement[replacement.length - 1].ending = terminalEnding;
|
|
559
|
+
} else if (edit.start + edit.deleteCount === lines.length && edit.start > 0) {
|
|
560
|
+
lines[edit.start - 1].ending = terminalEnding;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
376
563
|
lines.splice(edit.start, edit.deleteCount, ...replacement);
|
|
377
564
|
}
|
|
378
|
-
return lines.
|
|
565
|
+
return bom + lines.map((line) => line.text + line.ending).join("");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function readTextSnapshot(filePath: string): Promise<TextSnapshot> {
|
|
569
|
+
try {
|
|
570
|
+
return { exists: true, content: await readFile(filePath, "utf8") };
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false, content: "" };
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function writeHandle(handle: Awaited<ReturnType<typeof open>>, content: string): Promise<void> {
|
|
578
|
+
const data = Buffer.from(content, "utf8");
|
|
579
|
+
await handle.truncate(0);
|
|
580
|
+
let offset = 0;
|
|
581
|
+
while (offset < data.length) {
|
|
582
|
+
const { bytesWritten } = await handle.write(data, offset, data.length - offset, offset);
|
|
583
|
+
if (bytesWritten === 0) throw new Error("write made no progress");
|
|
584
|
+
offset += bytesWritten;
|
|
585
|
+
}
|
|
379
586
|
}
|
|
380
587
|
|
|
381
|
-
async function writeText(filePath: string, content: string): Promise<void> {
|
|
588
|
+
async function writeText(filePath: string, expected: TextSnapshot, content: string): Promise<void> {
|
|
382
589
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
383
|
-
|
|
590
|
+
if (!expected.exists) {
|
|
591
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
592
|
+
try {
|
|
593
|
+
handle = await open(filePath, "wx+");
|
|
594
|
+
} catch (error) {
|
|
595
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
596
|
+
throw new Error(`refusing to overwrite changed file: ${filePath}`);
|
|
597
|
+
}
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
await writeHandle(handle, content);
|
|
602
|
+
} finally {
|
|
603
|
+
await handle.close();
|
|
604
|
+
}
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const pathInfo = await lstat(filePath);
|
|
609
|
+
if (pathInfo.isSymbolicLink()) throw new Error(`refusing to modify symbolic link: ${filePath}`);
|
|
610
|
+
const handle = await open(filePath, "r+");
|
|
611
|
+
try {
|
|
612
|
+
const handleInfo = await handle.stat();
|
|
613
|
+
const currentPathInfo = await lstat(filePath);
|
|
614
|
+
if (
|
|
615
|
+
currentPathInfo.isSymbolicLink() ||
|
|
616
|
+
currentPathInfo.dev !== handleInfo.dev ||
|
|
617
|
+
currentPathInfo.ino !== handleInfo.ino
|
|
618
|
+
) {
|
|
619
|
+
throw new Error(`refusing to overwrite changed file: ${filePath}`);
|
|
620
|
+
}
|
|
621
|
+
const current = (await handle.readFile()).toString("utf8");
|
|
622
|
+
if (current !== expected.content) throw new Error(`refusing to overwrite changed file: ${filePath}`);
|
|
623
|
+
if (current !== content) await writeHandle(handle, content);
|
|
624
|
+
} finally {
|
|
625
|
+
await handle.close();
|
|
626
|
+
}
|
|
384
627
|
}
|
|
385
628
|
|
|
386
629
|
function renameFiles(value: unknown): string[] {
|
|
@@ -394,9 +637,176 @@ function renameFiles(value: unknown): string[] {
|
|
|
394
637
|
return files;
|
|
395
638
|
}
|
|
396
639
|
|
|
640
|
+
function diffLines(content: string): string[] {
|
|
641
|
+
if (content === "") return [];
|
|
642
|
+
const lines = normalizeToLf(content).split("\n");
|
|
643
|
+
if (lines.at(-1) === "") lines.pop();
|
|
644
|
+
return lines;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function diffRange(start: number, count: number): string {
|
|
648
|
+
return count === 0 ? `${start},0` : `${start + 1},${count}`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function utf8Prefix(value: string, maxBytes: number): string {
|
|
652
|
+
const bytes = Buffer.from(value, "utf8");
|
|
653
|
+
if (bytes.length <= maxBytes) return value;
|
|
654
|
+
let end = maxBytes;
|
|
655
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
656
|
+
while (end > 0) {
|
|
657
|
+
try {
|
|
658
|
+
return decoder.decode(bytes.subarray(0, end));
|
|
659
|
+
} catch {
|
|
660
|
+
end--;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return "";
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function boundDocumentOutput(value: string): string {
|
|
667
|
+
const lines = value.split("\n");
|
|
668
|
+
let output = lines.length > MAX_DOCUMENT_OUTPUT_LINES
|
|
669
|
+
? lines.slice(0, MAX_DOCUMENT_OUTPUT_LINES).join("\n")
|
|
670
|
+
: value;
|
|
671
|
+
const truncated = output !== value || Buffer.byteLength(output, "utf8") > MAX_DOCUMENT_OUTPUT_BYTES;
|
|
672
|
+
output = utf8Prefix(output, MAX_DOCUMENT_OUTPUT_BYTES);
|
|
673
|
+
return truncated
|
|
674
|
+
? `${output}\n[… document view truncated; narrow it with page, node, kind, or depth]`
|
|
675
|
+
: output;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function attachmentExtension(mime: string): string {
|
|
679
|
+
switch (mime) {
|
|
680
|
+
case "image/gif":
|
|
681
|
+
return "gif";
|
|
682
|
+
case "image/jpeg":
|
|
683
|
+
return "jpg";
|
|
684
|
+
case "image/png":
|
|
685
|
+
return "png";
|
|
686
|
+
case "image/webp":
|
|
687
|
+
return "webp";
|
|
688
|
+
default:
|
|
689
|
+
return "bin";
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function imageAttachment(image: Record<string, unknown>, filename: string): ToolAttachment | undefined {
|
|
694
|
+
if (image.encoding !== "base64" || typeof image.data !== "string" || typeof image.mime !== "string") {
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
type: "file",
|
|
699
|
+
mime: image.mime,
|
|
700
|
+
url: `data:${image.mime};base64,${image.data}`,
|
|
701
|
+
filename: `${filename}.${attachmentExtension(image.mime)}`,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function formatReadResult(value: unknown): RenderedToolResult {
|
|
706
|
+
const output = record(value);
|
|
707
|
+
if (output.format === "pdf") {
|
|
708
|
+
const attachments: ToolAttachment[] = [];
|
|
709
|
+
const images = items(output.images).map((value, index) => {
|
|
710
|
+
const image = record(value);
|
|
711
|
+
const page = typeof image.page === "number" ? image.page : "unknown";
|
|
712
|
+
const attachment = imageAttachment(image, `pdf-page-${page}-image-${index + 1}`);
|
|
713
|
+
if (attachment) attachments.push(attachment);
|
|
714
|
+
const sanitized = { ...image };
|
|
715
|
+
delete sanitized.data;
|
|
716
|
+
delete sanitized.encoding;
|
|
717
|
+
return sanitized;
|
|
718
|
+
});
|
|
719
|
+
return {
|
|
720
|
+
output: boundDocumentOutput(render({ ...output, images })),
|
|
721
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const attachment = imageAttachment(output, "image");
|
|
726
|
+
if (!attachment) return { output: render(value) };
|
|
727
|
+
const sanitized = { ...output };
|
|
728
|
+
delete sanitized.data;
|
|
729
|
+
delete sanitized.encoding;
|
|
730
|
+
return { output: render(sanitized), attachments: [attachment] };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function previewDiffLine(prefix: string, line: string): string {
|
|
734
|
+
const value = `${prefix}${line}`;
|
|
735
|
+
const maxBytes = 256;
|
|
736
|
+
if (Buffer.byteLength(value) <= maxBytes) return value;
|
|
737
|
+
return `${utf8Prefix(value, maxBytes - Buffer.byteLength("…"))}…`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function sampleDiffLines(lines: string[], start: number, end: number, prefix: string): string[] {
|
|
741
|
+
const sampleSize = 20;
|
|
742
|
+
const count = end - start;
|
|
743
|
+
if (count <= sampleSize * 2) {
|
|
744
|
+
return lines.slice(start, end).map((line) => previewDiffLine(prefix, line));
|
|
745
|
+
}
|
|
746
|
+
return [
|
|
747
|
+
...lines.slice(start, start + sampleSize).map((line) => previewDiffLine(prefix, line)),
|
|
748
|
+
` ${count - sampleSize * 2} ${prefix === "-" ? "removed" : "added"} lines omitted`,
|
|
749
|
+
...lines.slice(end - sampleSize, end).map((line) => previewDiffLine(prefix, line)),
|
|
750
|
+
];
|
|
751
|
+
}
|
|
752
|
+
|
|
397
753
|
function simpleDiff(filePath: string, before: string, after: string): string {
|
|
398
754
|
if (before === after) return "";
|
|
399
|
-
|
|
755
|
+
const oldLines = diffLines(before);
|
|
756
|
+
const newLines = diffLines(after);
|
|
757
|
+
let prefix = 0;
|
|
758
|
+
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
|
|
759
|
+
let suffix = 0;
|
|
760
|
+
while (
|
|
761
|
+
suffix < oldLines.length - prefix &&
|
|
762
|
+
suffix < newLines.length - prefix &&
|
|
763
|
+
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
764
|
+
) suffix++;
|
|
765
|
+
if (prefix === oldLines.length && prefix === newLines.length) {
|
|
766
|
+
prefix = Math.max(0, prefix - 1);
|
|
767
|
+
suffix = 0;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const oldChangeEnd = oldLines.length - suffix;
|
|
771
|
+
const newChangeEnd = newLines.length - suffix;
|
|
772
|
+
const oldStart = Math.max(0, prefix - 3);
|
|
773
|
+
const newStart = Math.max(0, prefix - 3);
|
|
774
|
+
const oldEnd = Math.min(oldLines.length, oldChangeEnd + 3);
|
|
775
|
+
const newEnd = Math.min(newLines.length, newChangeEnd + 3);
|
|
776
|
+
const safePath = utf8Prefix(filePath.replace(/[\r\n]/g, ""), 1024);
|
|
777
|
+
const header = [
|
|
778
|
+
`--- ${safePath}`,
|
|
779
|
+
`+++ ${safePath}`,
|
|
780
|
+
`@@ -${diffRange(oldStart, oldEnd - oldStart)} +${diffRange(newStart, newEnd - newStart)} @@`,
|
|
781
|
+
];
|
|
782
|
+
const fullDiff = [...header];
|
|
783
|
+
let fullDiffBytes = Buffer.byteLength(header.join("\n"));
|
|
784
|
+
let complete = true;
|
|
785
|
+
const ranges: Array<[string[], number, number, string]> = [
|
|
786
|
+
[oldLines, oldStart, prefix, " "],
|
|
787
|
+
[oldLines, prefix, oldChangeEnd, "-"],
|
|
788
|
+
[newLines, prefix, newChangeEnd, "+"],
|
|
789
|
+
[newLines, newChangeEnd, newEnd, " "],
|
|
790
|
+
];
|
|
791
|
+
outer: for (const [lines, start, end, marker] of ranges) {
|
|
792
|
+
for (let index = start; index < end; index++) {
|
|
793
|
+
const lineBytes = 2 + Buffer.byteLength(lines[index]);
|
|
794
|
+
if (fullDiffBytes + lineBytes > MAX_PERMISSION_DIFF_BYTES) {
|
|
795
|
+
complete = false;
|
|
796
|
+
break outer;
|
|
797
|
+
}
|
|
798
|
+
fullDiff.push(`${marker}${lines[index]}`);
|
|
799
|
+
fullDiffBytes += lineBytes;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (complete) return fullDiff.join("\n");
|
|
803
|
+
|
|
804
|
+
return [
|
|
805
|
+
...header,
|
|
806
|
+
` diff truncated: ${oldChangeEnd - prefix} removed, ${newChangeEnd - prefix} added lines`,
|
|
807
|
+
...sampleDiffLines(oldLines, prefix, oldChangeEnd, "-"),
|
|
808
|
+
...sampleDiffLines(newLines, prefix, newChangeEnd, "+"),
|
|
809
|
+
].join("\n");
|
|
400
810
|
}
|
|
401
811
|
|
|
402
812
|
function identifiedName(value: unknown): string | undefined {
|
|
@@ -419,6 +829,8 @@ function initialTitle(kind: PresentationKind, args: any): string {
|
|
|
419
829
|
switch (kind) {
|
|
420
830
|
case "read":
|
|
421
831
|
return `Read ${args.path}`;
|
|
832
|
+
case "view":
|
|
833
|
+
return `View ${args.path}`;
|
|
422
834
|
case "map":
|
|
423
835
|
return `Map ${args.path}`;
|
|
424
836
|
case "search":
|
|
@@ -461,6 +873,8 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
|
|
|
461
873
|
}
|
|
462
874
|
break;
|
|
463
875
|
}
|
|
876
|
+
case "view":
|
|
877
|
+
return { title: `Viewed ${args.path}`, metadata: {} };
|
|
464
878
|
case "map": {
|
|
465
879
|
const symbols = items(output.symbols).length;
|
|
466
880
|
return { title: `Mapped ${args.path} (${symbols} symbols)`, metadata: { symbols } };
|
|
@@ -528,6 +942,7 @@ function readseekTool(
|
|
|
528
942
|
args: Record<string, any>,
|
|
529
943
|
kind: PresentationKind,
|
|
530
944
|
execute: (args: any, context: ToolContext) => Promise<unknown>,
|
|
945
|
+
formatResult: (result: unknown) => RenderedToolResult = (result) => ({ output: render(result) }),
|
|
531
946
|
) {
|
|
532
947
|
return tool({
|
|
533
948
|
description,
|
|
@@ -537,7 +952,11 @@ function readseekTool(
|
|
|
537
952
|
context.metadata({ title });
|
|
538
953
|
const result = await execute(args, context);
|
|
539
954
|
const presentation = resultPresentation(kind, args, result);
|
|
540
|
-
return {
|
|
955
|
+
return {
|
|
956
|
+
title: presentation.title,
|
|
957
|
+
...formatResult(result),
|
|
958
|
+
metadata: presentation.metadata,
|
|
959
|
+
};
|
|
541
960
|
},
|
|
542
961
|
});
|
|
543
962
|
}
|
|
@@ -569,6 +988,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
569
988
|
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
570
989
|
offset: tool.schema.number().int().positive().optional().describe("One-based starting line"),
|
|
571
990
|
limit: tool.schema.number().int().positive().optional().describe(`Maximum number of lines; defaults to ${DEFAULT_READ_LIMIT}`),
|
|
991
|
+
page: tool.schema.number().int().positive().optional().describe("One-based PDF page; defaults to 1"),
|
|
572
992
|
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
573
993
|
...(imagePolicy === "off"
|
|
574
994
|
? {}
|
|
@@ -584,20 +1004,55 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
584
1004
|
const image = input.image as ImageMode | undefined;
|
|
585
1005
|
if (imagePolicy === "off" && image !== undefined) throw new Error("image and PDF reads are disabled");
|
|
586
1006
|
if (imagePolicy === "on" && image === "none") throw new Error('image="none" requires imageMode="auto"');
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
1007
|
+
const detection = await runReadSeek(context, ["detect", filePath]);
|
|
1008
|
+
const visual = isVisualFile(detection);
|
|
1009
|
+
const pdf = isPdfFile(detection);
|
|
1010
|
+
if (visual && image === undefined) {
|
|
1011
|
+
return { file: filePath, skipped: true, reason: "image mode not selected" };
|
|
1012
|
+
}
|
|
1013
|
+
if (visual && (input.offset !== undefined || input.limit !== undefined)) {
|
|
1014
|
+
throw new Error("offset and limit do not apply to image or PDF reads");
|
|
592
1015
|
}
|
|
1016
|
+
if (visual && input.language !== undefined) {
|
|
1017
|
+
throw new Error("language does not apply to image or PDF reads");
|
|
1018
|
+
}
|
|
1019
|
+
if (!pdf && input.page !== undefined) throw new Error("page applies to PDF reads only");
|
|
1020
|
+
|
|
593
1021
|
const args = ["read", input.offset === undefined ? filePath : `${filePath}:${input.offset}`];
|
|
594
|
-
if (
|
|
1022
|
+
if (!visual) {
|
|
595
1023
|
args.push("--end", String((input.offset ?? 1) + ((input.limit as number | undefined) ?? DEFAULT_READ_LIMIT) - 1));
|
|
596
1024
|
}
|
|
597
1025
|
if (input.language) args.push("--language", input.language as string);
|
|
598
1026
|
if (image !== undefined) args.push("--image", image);
|
|
1027
|
+
if (pdf) args.push("--page", String(input.page ?? 1));
|
|
599
1028
|
return runReadSeek(context, args);
|
|
600
1029
|
},
|
|
1030
|
+
formatReadResult,
|
|
1031
|
+
),
|
|
1032
|
+
readseek_view: readseekTool(
|
|
1033
|
+
"View the structure or selected content of an indexed PDF. Start with the overview, then narrow by page or node.",
|
|
1034
|
+
{
|
|
1035
|
+
path: tool.schema.string().describe("PDF path relative to the project directory"),
|
|
1036
|
+
node: tool.schema.string().min(1).optional().describe("Node ID to use as the view root"),
|
|
1037
|
+
page: tool.schema.number().int().positive().optional().describe("One-based source page"),
|
|
1038
|
+
kind: tool.schema.enum(DOCUMENT_NODE_KINDS).optional().describe("Node kind filter"),
|
|
1039
|
+
depth: tool.schema.number().int().nonnegative().optional().describe("Maximum depth below selected roots"),
|
|
1040
|
+
outline: tool.schema.boolean().optional().describe("Return outline nodes only"),
|
|
1041
|
+
},
|
|
1042
|
+
"view",
|
|
1043
|
+
async (input, context) => {
|
|
1044
|
+
const filePath = resolvePath(context.directory, input.path as string);
|
|
1045
|
+
await authorizeRead(context, filePath);
|
|
1046
|
+
const cacheDir = await ensureReadSeekCache(context);
|
|
1047
|
+
const args = ["--readseek-dir", cacheDir, "view", filePath];
|
|
1048
|
+
if (input.node) args.push("--node", input.node as string);
|
|
1049
|
+
if (input.page !== undefined) args.push("--page", String(input.page));
|
|
1050
|
+
if (input.kind) args.push("--kind", input.kind as string);
|
|
1051
|
+
if (input.depth !== undefined) args.push("--depth", String(input.depth));
|
|
1052
|
+
optionalFlag(args, input.outline as boolean | undefined, "--outline");
|
|
1053
|
+
return runReadSeekRaw(context, args);
|
|
1054
|
+
},
|
|
1055
|
+
(result) => ({ output: boundDocumentOutput(String(result)) }),
|
|
601
1056
|
),
|
|
602
1057
|
readseek_map: readseekTool(
|
|
603
1058
|
"List symbols and ranges in a source file. Use to inspect structure without reading the full file.",
|
|
@@ -792,17 +1247,12 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
792
1247
|
const plan = await runReadSeek(context, args);
|
|
793
1248
|
if (input.apply === false) return plan;
|
|
794
1249
|
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);
|
|
1250
|
+
const planHash = record(plan).plan_hash;
|
|
1251
|
+
if (typeof planHash !== "string" || planHash.length === 0) {
|
|
1252
|
+
throw new Error("readseek rename plan did not include a plan hash");
|
|
804
1253
|
}
|
|
805
|
-
|
|
1254
|
+
await authorizeEdit(context, files);
|
|
1255
|
+
return runReadSeek(context, [...args, "--plan-hash", planHash, "--apply"], { cancelable: false });
|
|
806
1256
|
},
|
|
807
1257
|
),
|
|
808
1258
|
readseek_edit: readseekTool(
|
|
@@ -810,24 +1260,38 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
810
1260
|
{
|
|
811
1261
|
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
812
1262
|
edits: tool.schema.array(tool.schema.union([
|
|
813
|
-
tool.schema.object({
|
|
814
|
-
|
|
815
|
-
|
|
1263
|
+
tool.schema.object({
|
|
1264
|
+
set_line: tool.schema.object({ anchor: tool.schema.string(), new_text: tool.schema.string() }).strict(),
|
|
1265
|
+
}).strict(),
|
|
1266
|
+
tool.schema.object({
|
|
1267
|
+
replace_lines: tool.schema.object({
|
|
1268
|
+
start_anchor: tool.schema.string(),
|
|
1269
|
+
end_anchor: tool.schema.string(),
|
|
1270
|
+
new_text: tool.schema.string(),
|
|
1271
|
+
}).strict(),
|
|
1272
|
+
}).strict(),
|
|
1273
|
+
tool.schema.object({
|
|
1274
|
+
insert_after: tool.schema.object({ anchor: tool.schema.string(), new_text: tool.schema.string() }).strict(),
|
|
1275
|
+
}).strict(),
|
|
816
1276
|
])).min(1).describe("Anchored edits to apply"),
|
|
817
1277
|
},
|
|
818
1278
|
"edit",
|
|
819
1279
|
async (input, context) => {
|
|
820
1280
|
const filePath = resolvePath(context.directory, input.path as string);
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1281
|
+
validateAnchorEdits(input.edits);
|
|
1282
|
+
return withFileMutationQueue(filePath, async () => {
|
|
1283
|
+
context.abort.throwIfAborted();
|
|
1284
|
+
await authorizeRead(context, filePath);
|
|
1285
|
+
await rejectSymlinkMutation(filePath);
|
|
1286
|
+
const edits = input.edits;
|
|
1287
|
+
const before = await readFile(filePath, "utf8");
|
|
1288
|
+
await verifyAnchors(context, filePath, edits);
|
|
1289
|
+
const after = applyAnchorEdits(before, edits);
|
|
1290
|
+
await authorizeEdit(context, [filePath], simpleDiff(filePath, before, after), false);
|
|
1291
|
+
context.abort.throwIfAborted();
|
|
1292
|
+
if (before !== after) await writeText(filePath, { exists: true, content: before }, after);
|
|
1293
|
+
return runReadSeek(context, ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)], { cancelable: false });
|
|
1294
|
+
});
|
|
831
1295
|
},
|
|
832
1296
|
),
|
|
833
1297
|
readseek_write: readseekTool(
|
|
@@ -840,15 +1304,17 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
840
1304
|
async (input, context) => {
|
|
841
1305
|
const filePath = resolvePath(context.directory, input.path as string);
|
|
842
1306
|
await authorizeExternal(context, filePath);
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1307
|
+
return withFileMutationQueue(filePath, async () => {
|
|
1308
|
+
context.abort.throwIfAborted();
|
|
1309
|
+
await rejectSymlinkMutation(filePath);
|
|
1310
|
+
const before = await readTextSnapshot(filePath);
|
|
1311
|
+
const content = input.content as string;
|
|
1312
|
+
if (content.includes("\0")) throw new Error("write content must be text");
|
|
1313
|
+
await authorizeEdit(context, [filePath], simpleDiff(filePath, before.content, content), false);
|
|
1314
|
+
context.abort.throwIfAborted();
|
|
1315
|
+
await writeText(filePath, before, content);
|
|
1316
|
+
return runReadSeek(context, ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)], { cancelable: false });
|
|
1317
|
+
});
|
|
852
1318
|
},
|
|
853
1319
|
),
|
|
854
1320
|
readseek_check: readseekTool(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-readseek",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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.0",
|
|
34
34
|
"@opencode-ai/plugin": "^1.17.20"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|