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.
Files changed (3) hide show
  1. package/README.md +17 -2
  2. package/index.ts +546 -60
  3. 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": [["opencode-readseek", { "imageMode": "auto" }]]
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; image/PDF handling is explicit.
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, writeFile } from "node:fs/promises";
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 runReadSeek(context: ToolContext, args: string[]): Promise<unknown> {
301
+ async function runReadSeekRaw(
302
+ context: ToolContext,
303
+ args: string[],
304
+ options: { cancelable?: boolean } = {},
305
+ ): Promise<string> {
263
306
  context.abort.throwIfAborted();
264
- const child = Bun.spawn([readSeekBinaryPath(), ...args], {
307
+ const cancelable = options.cancelable !== false;
308
+ const spawnOptions = {
265
309
  cwd: context.directory,
266
- killSignal: "SIGKILL",
267
310
  maxBuffer: MAX_OUTPUT_BYTES,
268
- signal: context.abort,
269
- stderr: "pipe",
270
- stdout: "pipe",
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
- return { line: Number(match[1]), hash: match[2].toLowerCase() };
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 newline = content.includes("\r\n") ? "\r\n" : "\n";
346
- const trailingNewline = content.endsWith("\n");
347
- const lines = content.replace(/\r\n/g, "\n").split("\n");
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 replacement = edit.text === "" ? [] : edit.text.replace(/\r\n/g, "\n").split("\n");
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.join(newline) + (trailingNewline ? newline : "");
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
- await writeFile(filePath, content, "utf8");
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
- return `--- ${filePath}\n+++ ${filePath}\n@@ full file @@\n-${before}\n+${after}`;
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 { title: presentation.title, output: render(result), metadata: presentation.metadata };
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
- if (image === undefined) {
588
- const detection = await runReadSeek(context, ["detect", filePath]);
589
- if (isVisualFile(detection)) {
590
- return { file: filePath, skipped: true, reason: "image mode not selected" };
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 (image === undefined) {
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
- if (input.workspace) {
796
- await context.ask({
797
- permission: "edit",
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
- return runReadSeek(context, [...args, "--apply"]);
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({ set_line: tool.schema.object({ anchor: tool.schema.string(), new_text: tool.schema.string() }) }),
814
- tool.schema.object({ replace_lines: tool.schema.object({ start_anchor: tool.schema.string(), end_anchor: tool.schema.string(), new_text: tool.schema.string() }) }),
815
- tool.schema.object({ insert_after: tool.schema.object({ anchor: tool.schema.string(), new_text: tool.schema.string() }) }),
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
- await authorizeRead(context, filePath);
822
- await rejectSymlinkMutation(filePath);
823
- const edits = input.edits as AnchorEdit[];
824
- const before = await readFile(filePath, "utf8");
825
- await verifyAnchors(context, filePath, edits);
826
- const after = applyAnchorEdits(before, edits);
827
- await authorizeEdit(context, [filePath], simpleDiff(filePath, before, after), false);
828
- if (await readFile(filePath, "utf8") !== before) throw new Error(`refusing to edit changed file: ${filePath}`);
829
- if (before !== after) await writeText(filePath, after);
830
- return runReadSeek(context, ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)]);
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
- await rejectSymlinkMutation(filePath);
844
- const before = await readFile(filePath, "utf8").catch(() => "");
845
- const content = input.content as string;
846
- if (content.includes("\0")) throw new Error("write content must be text");
847
- await authorizeEdit(context, [filePath], simpleDiff(filePath, before, content), false);
848
- const current = await readFile(filePath, "utf8").catch(() => "");
849
- if (current !== before) throw new Error(`refusing to overwrite changed file: ${filePath}`);
850
- if (before !== content) await writeText(filePath, content);
851
- return runReadSeek(context, ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)]);
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.6.13",
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.6.13",
33
+ "@jarkkojs/readseek": "^0.7.1",
34
34
  "@opencode-ai/plugin": "^1.17.20"
35
35
  },
36
36
  "devDependencies": {