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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/index.ts +526 -60
  3. 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, 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;
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 runReadSeek(context: ToolContext, args: string[]): Promise<unknown> {
288
+ async function runReadSeekRaw(
289
+ context: ToolContext,
290
+ args: string[],
291
+ options: { cancelable?: boolean } = {},
292
+ ): Promise<string> {
263
293
  context.abort.throwIfAborted();
264
- const child = Bun.spawn([readSeekBinaryPath(), ...args], {
294
+ const cancelable = options.cancelable !== false;
295
+ const spawnOptions = {
265
296
  cwd: context.directory,
266
- killSignal: "SIGKILL",
267
297
  maxBuffer: MAX_OUTPUT_BYTES,
268
- signal: context.abort,
269
- stderr: "pipe",
270
- stdout: "pipe",
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
- return { line: Number(match[1]), hash: match[2].toLowerCase() };
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 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();
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 replacement = edit.text === "" ? [] : edit.text.replace(/\r\n/g, "\n").split("\n");
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.join(newline) + (trailingNewline ? newline : "");
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
- await writeFile(filePath, content, "utf8");
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
- return `--- ${filePath}\n+++ ${filePath}\n@@ full file @@\n-${before}\n+${after}`;
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 { title: presentation.title, output: render(result), metadata: presentation.metadata };
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
- 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
- }
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 (image === undefined) {
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
- 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);
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
- return runReadSeek(context, [...args, "--apply"]);
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({ 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() }) }),
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
- 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)]);
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
- 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)]);
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.6.12",
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.6.12",
33
+ "@jarkkojs/readseek": "^0.7.0",
34
34
  "@opencode-ai/plugin": "^1.17.20"
35
35
  },
36
36
  "devDependencies": {