opencode-readseek 0.8.24 → 0.9.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 +20 -17
  2. package/index.ts +420 -275
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  <img alt="OpenCode running opencode-readseek" src="screenshot.png" width="800">
4
4
 
5
5
  `opencode-readseek` adds anchored file operations, structural search, symbol
6
- navigation, and PDF tools to OpenCode.
6
+ navigation, and document tools to OpenCode.
7
7
 
8
8
  ## Install
9
9
 
@@ -20,20 +20,18 @@ OpenCode installs the package and its platform-specific
20
20
 
21
21
  ## Tools
22
22
 
23
- | Tool | Purpose |
24
- | --- | --- |
25
- | `readseek_read` | Read text with `LINE:HASH` anchors; read images or one PDF page |
26
- | `readseek_edit` | Apply hash-verified edits to an existing text file |
27
- | `readseek_write` | Create or replace a complete text file |
28
- | `readseek_grep` | Search text or regex and return anchors |
29
- | `readseek_map` | Show a source file's symbol outline |
30
- | `readseek_search` | Search AST patterns |
31
- | `readseek_def` | Find symbol definitions |
32
- | `readseek_refs` | Find identifier usages, optionally scoped to a cursor binding |
33
- | `readseek_hover` | Identify the token and enclosing symbol at a cursor |
34
- | `readseek_rename` | Apply a binding-aware rename; set `apply: false` for a dry run |
35
- | `readseek_check` | Report parser errors and missing syntax |
36
- | `readseek_view` | View or narrow a structural PDF index |
23
+ | Tool | Purpose |
24
+ | ------------------ | ----------------------------------------------------------------------- |
25
+ | `readseek_read` | Read text with `LINE:HASH` anchors; read standalone images |
26
+ | `readseek_edit` | Apply hash-verified edits to an existing text file |
27
+ | `readseek_write` | Create or replace a complete text file |
28
+ | `readseek_grep` | Search text or regex and return anchors |
29
+ | `readseek_digest` | Native digest envelope: metadata, content, map, diagnostics, identity |
30
+ | `readseek_search` | Search AST patterns |
31
+ | `readseek_def` | Find symbol definitions |
32
+ | `readseek_refs` | Find identifier usages, optionally scoped to a cursor binding |
33
+ | `readseek_rename` | Apply a binding-aware rename; set `apply: false` for a dry run |
34
+ | `readseek_view` | View or narrow a structural document index |
37
35
 
38
36
  The plugin requests read, grep, external-directory, and edit permissions as
39
37
  needed. Mutations invalidate remembered anchors; compaction preserves fresh
@@ -44,6 +42,9 @@ OpenCode is instructed to prefer ReadSeek over the built-in `read`, `edit`,
44
42
  `write`, `grep`, and `apply_patch` tools when ReadSeek can do the job. Built-ins
45
43
  remain available as fallbacks.
46
44
 
45
+ Documents such as PDFs are owned by `readseek_view`. Calling `readseek_read` on
46
+ a document returns a cache receipt and directs the caller to `readseek_view`.
47
+
47
48
  ## Configuration
48
49
 
49
50
  Pass options with OpenCode's plugin tuple syntax:
@@ -58,9 +59,11 @@ Pass options with OpenCode's plugin tuple syntax:
58
59
 
59
60
  - `"auto"` (default): expose `none`, `all`, `ocr`, `caption`, and `objects`.
60
61
  - `"on"`: expose local analysis modes and omit `none`.
61
- - `"off"`: skip image and PDF reads.
62
+ - `"off"`: skip image reads.
62
63
 
63
- Omitting `image` from a read also skips visual files.
64
+ Omitting `image` from a read also skips image files. Document asset analysis
65
+ uses `readseek_view` with optional `visionMode` (`all` | `ocr` | `caption` |
66
+ `objects`) and `visionLevel` (`low` | `medium` | `high`).
64
67
 
65
68
  ## Development
66
69
 
package/index.ts CHANGED
@@ -10,6 +10,8 @@ import { tool, type Plugin, type PluginOptions, type ToolAttachment, type ToolCo
10
10
 
11
11
  const require = createRequire(import.meta.url);
12
12
  const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
13
+ const DEFAULT_READSEEK_TIMEOUT_MS = 120_000;
14
+ const DEFAULT_VISION_TIMEOUT_MS = 30 * 60 * 1000;
13
15
  const DEFAULT_READ_LIMIT = 2000;
14
16
  const EDIT_RESULT_READ_LIMIT = 40;
15
17
  const MAX_DOCUMENT_OUTPUT_BYTES = 256 * 1024;
@@ -18,10 +20,10 @@ const TOOL_POLICY = [
18
20
  "ReadSeek tool policy:",
19
21
  "- Prefer readseek_* tools over built-ins when they can do the job.",
20
22
  "- Read with readseek_read first; readseek_edit needs fresh LINE:HASH anchors.",
21
- "- Use readseek_grep for text/regex, readseek_search for AST patterns, and readseek_map/def/refs/hover for symbol navigation.",
23
+ "- Use readseek_grep for text/regex, readseek_search for AST patterns, and readseek_digest/def/refs for symbol navigation.",
22
24
  "- Use readseek_edit for existing files, readseek_write for whole-file creation or replacement, and readseek_rename for symbol renames.",
23
25
  "- Do not use built-in read, edit, write, grep, or apply_patch when a ReadSeek tool can do the job.",
24
- "- Run readseek_check after source edits for a quick syntax check.",
26
+ "- Run readseek_digest with select=diagnostics after source edits for a quick syntax check.",
25
27
  ].join("\n");
26
28
  const PREFERRED_TOOL_DESCRIPTIONS: Record<string, string> = {
27
29
  readseek_read: "Preferred file reader; returns LINE:HASH anchors for durable references.",
@@ -30,12 +32,10 @@ const PREFERRED_TOOL_DESCRIPTIONS: Record<string, string> = {
30
32
  readseek_rename: "Use to rename a symbol with binding-aware, verified edits.",
31
33
  readseek_grep: "Preferred text/regex search; returns edit-ready LINE:HASH anchors.",
32
34
  readseek_search: "Use for AST or structural code search with ast-grep-style patterns.",
33
- readseek_map: "Use to inspect a source file's symbol outline without reading the whole file.",
35
+ readseek_digest: "Use to digest native facets (metadata, content, map, diagnostics, identity) and return the full envelope.",
34
36
  readseek_def: "Use for go-to-definition or declaration lookup by name.",
35
37
  readseek_refs: "Use to find identifier-name matches or binding-specific references before rename or delete.",
36
- readseek_hover: "Use to inspect symbol identity at a source location.",
37
- readseek_view: "Use to inspect PDF structure or selected content.",
38
- readseek_check: "Use for a quick parser syntax check after source edits; unsupported languages are not validated.",
38
+ readseek_view: "Use to digest document structure or selected content.",
39
39
  };
40
40
  const FALLBACK_TOOL_DESCRIPTIONS: Record<string, string> = {
41
41
  read: "Fallback only. Prefer readseek_read so edits can use LINE:HASH anchors.",
@@ -56,7 +56,7 @@ type RenamePlan = {
56
56
  files: Set<string>;
57
57
  };
58
58
 
59
- type PresentationKind = "read" | "view" | "map" | "search" | "grep" | "def" | "refs" | "hover" | "rename" | "edit" | "write" | "check";
59
+ type PresentationKind = "read" | "view" | "digest" | "search" | "grep" | "def" | "refs" | "rename" | "edit" | "write";
60
60
 
61
61
  type Presentation = {
62
62
  title: string;
@@ -70,7 +70,11 @@ type RenderedToolResult = {
70
70
 
71
71
  type ImagePolicy = "on" | "auto" | "off";
72
72
  type ImageMode = "none" | "all" | "ocr" | "caption" | "objects";
73
+ type VisionAnalysisMode = Exclude<ImageMode, "none">;
74
+ type VisionLevel = "low" | "medium" | "high";
73
75
  type DocumentNodeKind = "artifact" | "footer" | "header" | "heading" | "marginal_label" | "page" | "page_number" | "paragraph" | "section" | "structural_section";
76
+ const VISION_ANALYSIS_MODES: [VisionAnalysisMode, ...VisionAnalysisMode[]] = ["all", "ocr", "caption", "objects"];
77
+ const VISION_LEVELS: [VisionLevel, ...VisionLevel[]] = ["low", "medium", "high"];
74
78
 
75
79
  const DOCUMENT_NODE_KINDS: [DocumentNodeKind, ...DocumentNodeKind[]] = [
76
80
  "artifact",
@@ -95,11 +99,65 @@ function resolveImagePolicy(options: PluginOptions | undefined): ImagePolicy {
95
99
  function isVisualFile(value: unknown): boolean {
96
100
  const output = record(value);
97
101
  const type = output.type;
98
- return typeof output.width === "number" || type === "application/pdf";
102
+ return typeof output.width === "number"
103
+ || (typeof type === "string" && type.startsWith("image/"));
104
+ }
105
+
106
+ function isVisualBoundsError(error: unknown): boolean {
107
+ if (!(error instanceof Error)) return false;
108
+ const message = error.message.replace(/^error:\s*/, "");
109
+ return message.startsWith("--end")
110
+ && /(?:does|do) not apply to images/.test(message);
111
+ }
112
+
113
+ function isDocumentBoundsError(error: unknown): boolean {
114
+ if (!(error instanceof Error)) return false;
115
+ const message = error.message.replace(/^error:\s*/, "");
116
+ return /(?:does|do) not apply to (?:documents|PDFs)/.test(message)
117
+ || /document content belongs to view/.test(message)
118
+ || /source facets do not apply to documents/.test(message);
99
119
  }
100
120
 
101
121
  function isPdfFile(value: unknown): boolean {
102
- return record(value).format === "pdf";
122
+ const output = record(value);
123
+ return output.format === "pdf" || output.type === "application/pdf";
124
+ }
125
+
126
+ function documentReceipt(filePath: string, envelope: unknown): Record<string, unknown> {
127
+ const recordEnvelope = record(envelope);
128
+ return {
129
+ file: filePath,
130
+ use: "readseek_view",
131
+ reason: "document content belongs to readseek_view",
132
+ metadata: digestMetadata(envelope),
133
+ ...(recordEnvelope.cache !== undefined ? { cache: recordEnvelope.cache } : {}),
134
+ };
135
+ }
136
+
137
+ async function digestDocumentReceipt(context: ToolContext, filePath: string): Promise<Record<string, unknown>> {
138
+ const cacheDir = await ensureReadSeekCache(context);
139
+ const envelope = await runReadSeek(context, [
140
+ "--readseek-dir",
141
+ cacheDir,
142
+ ...digestArgs(filePath, "metadata"),
143
+ ]);
144
+ return documentReceipt(filePath, envelope);
145
+ }
146
+
147
+ function digestArgs(filePath: string, select: string, extra: string[] = []): string[] {
148
+ return ["digest", filePath, "--select", select, ...extra];
149
+ }
150
+
151
+ function requireDigestFacet(envelope: unknown, facet: "content" | "map" | "diagnostics" | "identity"): unknown {
152
+ const value = record(envelope)[facet];
153
+ if (value === undefined || value === null || typeof value !== "object") {
154
+ throw new Error(`digest returned no ${facet} facet`);
155
+ }
156
+ return value;
157
+ }
158
+
159
+ function digestMetadata(envelope: unknown): Record<string, unknown> {
160
+ return record(record(envelope).metadata);
103
161
  }
104
162
 
105
163
  class SessionAnchors {
@@ -304,6 +362,37 @@ function optionalFlag(args: string[], enabled: boolean | undefined, flag: string
304
362
  if (enabled) args.push(flag);
305
363
  }
306
364
 
365
+ function atLocation(line: number, column?: number): string {
366
+ return column === undefined ? `line:${line}` : `line:${line}:${column}`;
367
+ }
368
+
369
+ function withAt(args: string[], line: number, column?: number): void {
370
+ args.push("--at", atLocation(line, column));
371
+ }
372
+
373
+ function isLikelyImagePath(filePath: string): boolean {
374
+ return /\.(avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i.test(filePath);
375
+ }
376
+
377
+ function isLikelyDocumentPath(filePath: string): boolean {
378
+ return /\.pdf$/i.test(filePath);
379
+ }
380
+
381
+ function readRangeArgs(filePath: string, start: number, end: number): string[] {
382
+ const extra: string[] = [];
383
+ if (start > 1) withAt(extra, start);
384
+ extra.push("--end", String(end));
385
+ return digestArgs(filePath, "content", extra);
386
+ }
387
+
388
+ async function runDigestContent(
389
+ context: ToolContext,
390
+ args: string[],
391
+ options: { cancelable?: boolean; timeoutMs?: number } = {},
392
+ ): Promise<unknown> {
393
+ return requireDigestFacet(await runReadSeek(context, args, options), "content");
394
+ }
395
+
307
396
  function readSeekBinaryPath(): string {
308
397
  const platform = `${process.platform}-${process.arch}`;
309
398
  const platformPackage = READSEEK_PLATFORM_PACKAGES[platform];
@@ -331,7 +420,7 @@ async function readBoundedText(
331
420
  totalBytes += value.byteLength;
332
421
  if (totalBytes > MAX_OUTPUT_BYTES) {
333
422
  onOverflow();
334
- void reader.cancel().catch(() => undefined);
423
+ await reader.cancel().catch(() => undefined);
335
424
  throw new Error(`readseek ${streamName} exceeded the ${MAX_OUTPUT_BYTES}-byte output limit`);
336
425
  }
337
426
  chunks.push(value);
@@ -352,13 +441,14 @@ async function readBoundedText(
352
441
  async function runReadSeekRaw(
353
442
  context: ToolContext,
354
443
  args: string[],
355
- options: { cancelable?: boolean } = {},
444
+ options: { cancelable?: boolean; stdin?: string; timeoutMs?: number } = {},
356
445
  ): Promise<string> {
357
446
  context.abort.throwIfAborted();
358
447
  const cancelable = options.cancelable !== false;
359
448
  const spawnOptions = {
360
449
  cwd: context.directory,
361
450
  stderr: "pipe" as const,
451
+ stdin: options.stdin === undefined ? undefined : Buffer.from(options.stdin),
362
452
  stdout: "pipe" as const,
363
453
  };
364
454
  const child = Bun.spawn(
@@ -377,11 +467,31 @@ async function runReadSeekRaw(
377
467
  // Preserve the output-limit error if the process exited concurrently.
378
468
  }
379
469
  };
380
- const [stdout, stderr, exitCode] = await Promise.all([
470
+ const timeoutMs = options.timeoutMs ?? DEFAULT_READSEEK_TIMEOUT_MS;
471
+ let timeout: ReturnType<typeof setTimeout> | undefined;
472
+ const timeoutResult = new Promise<never>((_resolve, reject) => {
473
+ timeout = setTimeout(() => {
474
+ try {
475
+ child.kill("SIGKILL");
476
+ } catch {
477
+ // Preserve the timeout error if the process exited concurrently.
478
+ }
479
+ reject(new Error(`readseek timed out after ${timeoutMs} ms`));
480
+ }, timeoutMs);
481
+ timeout.unref?.();
482
+ });
483
+ const pending = Promise.all([
381
484
  readBoundedText(child.stdout, "stdout", killForOverflow),
382
485
  readBoundedText(child.stderr, "stderr", killForOverflow),
383
486
  child.exited,
384
487
  ]);
488
+ let result: Awaited<typeof pending>;
489
+ try {
490
+ result = await Promise.race([pending, timeoutResult]);
491
+ } finally {
492
+ if (timeout !== undefined) clearTimeout(timeout);
493
+ }
494
+ const [stdout, stderr, exitCode] = result;
385
495
  if (cancelable) context.abort.throwIfAborted();
386
496
  if (exitCode !== 0) throw new Error(stderr.trim() || `readseek exited with status ${exitCode}`);
387
497
  return stdout;
@@ -390,7 +500,7 @@ async function runReadSeekRaw(
390
500
  async function runReadSeek(
391
501
  context: ToolContext,
392
502
  args: string[],
393
- options: { cancelable?: boolean } = {},
503
+ options: { cancelable?: boolean; stdin?: string; timeoutMs?: number } = {},
394
504
  ): Promise<unknown> {
395
505
  const stdout = await runReadSeekRaw(context, args, options);
396
506
  try {
@@ -407,7 +517,10 @@ async function postMutationPreview(
407
517
  changed: boolean,
408
518
  ): Promise<unknown> {
409
519
  try {
410
- return await runReadSeek(context, args, { cancelable: false });
520
+ return await runDigestContent(context, args, {
521
+ cancelable: false,
522
+ timeoutMs: DEFAULT_READSEEK_TIMEOUT_MS,
523
+ });
411
524
  } catch (error) {
412
525
  const previewError = error instanceof Error ? error.message : String(error);
413
526
  return {
@@ -471,6 +584,11 @@ type AnchorEdit =
471
584
  | { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
472
585
  | { insert_after: { anchor: string; new_text: string } };
473
586
 
587
+ type EditItem =
588
+ | AnchorEdit
589
+ | { replace: { old_text: string; new_text: string; all?: boolean } }
590
+ | { replace_symbol: { symbol: string; new_body: string } };
591
+
474
592
  type TextSnapshot = {
475
593
  exists: boolean;
476
594
  content: string;
@@ -496,25 +614,28 @@ async function withFileMutationQueue<T>(filePath: string, operation: () => Promi
496
614
  }
497
615
  }
498
616
 
499
- function validateAnchorEdits(value: unknown): asserts value is AnchorEdit[] {
617
+ function validateEdits(value: unknown): asserts value is EditItem[] {
500
618
  if (!Array.isArray(value) || value.length === 0) throw new Error("edits must contain at least one edit");
501
- const fields = {
619
+ const requiredFields = {
502
620
  set_line: ["anchor", "new_text"],
503
621
  replace_lines: ["end_anchor", "new_text", "start_anchor"],
504
622
  insert_after: ["anchor", "new_text"],
623
+ replace: ["new_text", "old_text"],
624
+ replace_symbol: ["new_body", "symbol"],
505
625
  } as const;
506
626
  for (const [index, item] of value.entries()) {
507
627
  if (!item || typeof item !== "object") throw new Error(`edits[${index}] must be an edit object`);
508
628
  const keys = Object.keys(item);
509
- const variant = keys[0] as keyof typeof fields;
510
- if (keys.length !== 1 || !Object.hasOwn(fields, variant)) {
511
- throw new Error(`edits[${index}] must contain exactly one of: set_line, replace_lines, insert_after`);
629
+ const variant = keys[0] as keyof typeof requiredFields;
630
+ if (keys.length !== 1 || !Object.hasOwn(requiredFields, variant)) {
631
+ throw new Error(`edits[${index}] must contain exactly one of: set_line, replace_lines, insert_after, replace, replace_symbol`);
512
632
  }
513
633
  const payload = (item as Record<string, unknown>)[variant];
514
634
  if (!payload || typeof payload !== "object") throw new Error(`edits[${index}].${variant} must be an object`);
515
- const expectedFields = fields[variant];
635
+ const expectedFields: readonly string[] = requiredFields[variant];
636
+ const allowedFields = variant === "replace" ? [...expectedFields, "all"] : expectedFields;
516
637
  const actualFields = Object.keys(payload).sort();
517
- if (actualFields.length !== expectedFields.length || actualFields.some((field, fieldIndex) => field !== expectedFields[fieldIndex])) {
638
+ if (actualFields.some((field) => !allowedFields.includes(field)) || expectedFields.some((field) => !actualFields.includes(field))) {
518
639
  throw new Error(`edits[${index}].${variant} contains invalid fields`);
519
640
  }
520
641
  const values = payload as Record<string, unknown>;
@@ -523,6 +644,9 @@ function validateAnchorEdits(value: unknown): asserts value is AnchorEdit[] {
523
644
  throw new Error(`edits[${index}].${variant}.${field} must be a string`);
524
645
  }
525
646
  }
647
+ if (variant === "replace" && values.all !== undefined && typeof values.all !== "boolean") {
648
+ throw new Error(`edits[${index}].replace.all must be a boolean`);
649
+ }
526
650
  for (const field of expectedFields.filter((field) => field.endsWith("anchor"))) {
527
651
  parseAnchor(values[field] as string);
528
652
  }
@@ -537,125 +661,16 @@ function parseAnchor(anchor: string): { line: number; hash: string } {
537
661
  return { line, hash: match[2].toLowerCase() };
538
662
  }
539
663
 
540
- function anchorRefs(edit: AnchorEdit): { line: number; hash: string }[] {
541
- if ("set_line" in edit) return [parseAnchor(edit.set_line.anchor)];
542
- if ("insert_after" in edit) return [parseAnchor(edit.insert_after.anchor)];
543
- return [parseAnchor(edit.replace_lines.start_anchor), parseAnchor(edit.replace_lines.end_anchor)];
544
- }
545
-
546
- async function verifyAnchors(context: ToolContext, filePath: string, edits: AnchorEdit[]): Promise<void> {
547
- const refs = new Map<number, string>();
548
- for (const edit of edits) {
549
- for (const ref of anchorRefs(edit)) {
550
- const previous = refs.get(ref.line);
551
- if (previous !== undefined && previous !== ref.hash) throw new Error(`conflicting hashes for line ${ref.line}`);
552
- refs.set(ref.line, ref.hash);
553
- }
554
- }
555
- for (const [line, expected] of refs) {
556
- const output = record(await runReadSeek(context, ["read", `${filePath}:${line}`, "--end", String(line)]));
557
- const hashline = items(output.hashlines).map(record)[0];
558
- const actual = hashline?.hash;
559
- if (typeof actual !== "string" || actual !== expected) {
560
- throw new Error(`stale anchor ${line}:${expected}; current hash is ${typeof actual === "string" ? actual : "unavailable"}`);
561
- }
664
+ function firstChangedLine(before: string, after: string): number {
665
+ const beforeLines = normalizeToLf(before).split("\n");
666
+ const afterLines = normalizeToLf(after).split("\n");
667
+ const count = Math.max(beforeLines.length, afterLines.length);
668
+ for (let index = 0; index < count; index++) {
669
+ if (beforeLines[index] !== afterLines[index]) return index + 1;
562
670
  }
671
+ return 1;
563
672
  }
564
673
 
565
- type EditableLine = {
566
- text: string;
567
- ending: "\r\n" | "\n" | "\r" | "";
568
- };
569
-
570
- function parseEditableLines(content: string): EditableLine[] {
571
- const lines: EditableLine[] = [];
572
- let start = 0;
573
- for (let index = 0; index < content.length; index++) {
574
- const character = content[index];
575
- if (character !== "\r" && character !== "\n") continue;
576
- const ending = character === "\r" && content[index + 1] === "\n" ? "\r\n" : character;
577
- lines.push({ text: content.slice(start, index), ending });
578
- if (ending === "\r\n") index++;
579
- start = index + 1;
580
- }
581
- if (start < content.length || content.length === 0) lines.push({ text: content.slice(start), ending: "" });
582
- return lines;
583
- }
584
-
585
- function detectLineEnding(lines: EditableLine[]): "\r\n" | "\n" | "\r" {
586
- const counts = new Map<"\r\n" | "\n" | "\r", number>([
587
- ["\r\n", 0],
588
- ["\n", 0],
589
- ["\r", 0],
590
- ]);
591
- for (const line of lines) {
592
- if (line.ending !== "") counts.set(line.ending, (counts.get(line.ending) ?? 0) + 1);
593
- }
594
- let selected: "\r\n" | "\n" | "\r" = "\n";
595
- for (const ending of ["\r\n", "\r"] as const) {
596
- if ((counts.get(ending) ?? 0) > (counts.get(selected) ?? 0)) selected = ending;
597
- }
598
- return selected;
599
- }
600
-
601
- function normalizeToLf(content: string): string {
602
- return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
603
- }
604
-
605
- function replacementLines(text: string): string[] {
606
- if (text === "") return [];
607
- return normalizeToLf(text).replace(/\n$/, "").split("\n");
608
- }
609
-
610
- function applyAnchorEdits(content: string, edits: AnchorEdit[]): string {
611
- const bom = content.startsWith("\uFEFF") ? "\uFEFF" : "";
612
- const lines = parseEditableLines(bom ? content.slice(1) : content);
613
- const newline = detectLineEnding(lines);
614
- const planned = edits.map((edit) => {
615
- if ("set_line" in edit) {
616
- const { line } = parseAnchor(edit.set_line.anchor);
617
- return { start: line - 1, deleteCount: 1, text: edit.set_line.new_text };
618
- }
619
- if ("insert_after" in edit) {
620
- const { line } = parseAnchor(edit.insert_after.anchor);
621
- return { start: line, deleteCount: 0, text: edit.insert_after.new_text };
622
- }
623
- const start = parseAnchor(edit.replace_lines.start_anchor).line;
624
- const end = parseAnchor(edit.replace_lines.end_anchor).line;
625
- if (end < start) throw new Error("replace_lines end anchor precedes start anchor");
626
- return { start: start - 1, deleteCount: end - start + 1, text: edit.replace_lines.new_text };
627
- });
628
- const ascending = [...planned].sort((left, right) => left.start - right.start || left.deleteCount - right.deleteCount);
629
- for (let index = 1; index < ascending.length; index++) {
630
- const previous = ascending[index - 1];
631
- const current = ascending[index];
632
- const previousEnd = previous.start + Math.max(previous.deleteCount, 1);
633
- if (current.start < previousEnd) throw new Error("anchored edits overlap or target the same location");
634
- }
635
- planned.sort((left, right) => right.start - left.start);
636
- for (const edit of planned) {
637
- if (edit.start < 0 || edit.start > lines.length || edit.start + edit.deleteCount > lines.length) {
638
- throw new Error("anchor line is outside the file");
639
- }
640
- const removed = lines.slice(edit.start, edit.start + edit.deleteCount);
641
- const replacement = replacementLines(edit.text).map((text): EditableLine => ({ text, ending: newline }));
642
- if (edit.deleteCount === 0 && edit.start === lines.length && replacement.length > 0) {
643
- const previous = lines.at(-1);
644
- const terminalEnding = previous?.ending ?? "";
645
- if (previous?.ending === "") previous.ending = newline;
646
- replacement[replacement.length - 1].ending = terminalEnding;
647
- } else if (edit.deleteCount > 0) {
648
- const terminalEnding = removed.at(-1)?.ending ?? "";
649
- if (replacement.length > 0) {
650
- replacement[replacement.length - 1].ending = terminalEnding;
651
- } else if (edit.start + edit.deleteCount === lines.length && edit.start > 0) {
652
- lines[edit.start - 1].ending = terminalEnding;
653
- }
654
- }
655
- lines.splice(edit.start, edit.deleteCount, ...replacement);
656
- }
657
- return bom + lines.map((line) => line.text + line.ending).join("");
658
- }
659
674
 
660
675
  async function readTextSnapshot(filePath: string): Promise<TextSnapshot> {
661
676
  try {
@@ -729,11 +744,19 @@ function renameFiles(value: unknown): string[] {
729
744
  return files;
730
745
  }
731
746
 
747
+ function normalizeToLf(content: string): string {
748
+ return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
749
+ }
750
+
732
751
  function diffLines(content: string): string[] {
733
752
  if (content === "") return [];
734
- const lines = normalizeToLf(content).split("\n");
753
+ const lines = content.split("\n");
735
754
  if (lines.at(-1) === "") lines.pop();
736
- return lines;
755
+ return lines.map((line) => line.replace(/\r/g, "\\r"));
756
+ }
757
+
758
+ function hasFinalNewline(content: string): boolean {
759
+ return content === "" || /[\r\n]$/.test(content);
737
760
  }
738
761
 
739
762
  function diffRange(start: number, count: number): string {
@@ -796,23 +819,7 @@ function imageAttachment(image: Record<string, unknown>, filename: string): Tool
796
819
 
797
820
  function formatReadResult(value: unknown): RenderedToolResult {
798
821
  const output = record(value);
799
- if (output.format === "pdf") {
800
- const attachments: ToolAttachment[] = [];
801
- const images = items(output.images).map((value, index) => {
802
- const image = record(value);
803
- const page = typeof image.page === "number" ? image.page : "unknown";
804
- const attachment = imageAttachment(image, `pdf-page-${page}-image-${index + 1}`);
805
- if (attachment) attachments.push(attachment);
806
- const sanitized = { ...image };
807
- delete sanitized.data;
808
- delete sanitized.encoding;
809
- return sanitized;
810
- });
811
- return {
812
- output: boundDocumentOutput(render({ ...output, images })),
813
- ...(attachments.length > 0 ? { attachments } : {}),
814
- };
815
- }
822
+ if (output.use === "readseek_view") return { output: render(value) };
816
823
 
817
824
  const attachment = imageAttachment(output, "image");
818
825
  if (!attachment) return { output: render(value) };
@@ -846,6 +853,8 @@ function simpleDiff(filePath: string, before: string, after: string): string {
846
853
  if (before === after) return "";
847
854
  const oldLines = diffLines(before);
848
855
  const newLines = diffLines(after);
856
+ const oldHasFinalNewline = hasFinalNewline(before);
857
+ const newHasFinalNewline = hasFinalNewline(after);
849
858
  let prefix = 0;
850
859
  while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
851
860
  let suffix = 0;
@@ -874,13 +883,13 @@ function simpleDiff(filePath: string, before: string, after: string): string {
874
883
  const fullDiff = [...header];
875
884
  let fullDiffBytes = Buffer.byteLength(header.join("\n"));
876
885
  let complete = true;
877
- const ranges: Array<[string[], number, number, string]> = [
878
- [oldLines, oldStart, prefix, " "],
879
- [oldLines, prefix, oldChangeEnd, "-"],
880
- [newLines, prefix, newChangeEnd, "+"],
881
- [newLines, newChangeEnd, newEnd, " "],
886
+ const ranges: Array<[string[], number, number, string, boolean]> = [
887
+ [oldLines, oldStart, prefix, " ", oldHasFinalNewline],
888
+ [oldLines, prefix, oldChangeEnd, "-", oldHasFinalNewline],
889
+ [newLines, prefix, newChangeEnd, "+", newHasFinalNewline],
890
+ [newLines, newChangeEnd, newEnd, " ", newHasFinalNewline],
882
891
  ];
883
- outer: for (const [lines, start, end, marker] of ranges) {
892
+ outer: for (const [lines, start, end, marker, hasNewline] of ranges) {
884
893
  for (let index = start; index < end; index++) {
885
894
  const lineBytes = 2 + Buffer.byteLength(lines[index]);
886
895
  if (fullDiffBytes + lineBytes > MAX_PERMISSION_DIFF_BYTES) {
@@ -889,13 +898,26 @@ function simpleDiff(filePath: string, before: string, after: string): string {
889
898
  }
890
899
  fullDiff.push(`${marker}${lines[index]}`);
891
900
  fullDiffBytes += lineBytes;
901
+ if (index === lines.length - 1 && !hasNewline) {
902
+ const eofMarker = "\";
903
+ if (fullDiffBytes + Buffer.byteLength(eofMarker) + 1 > MAX_PERMISSION_DIFF_BYTES) {
904
+ complete = false;
905
+ break outer;
906
+ }
907
+ fullDiff.push(eofMarker);
908
+ fullDiffBytes += Buffer.byteLength(eofMarker) + 1;
909
+ }
892
910
  }
893
911
  }
894
912
  if (complete) return fullDiff.join("\n");
895
913
 
914
+ const newlineSummary = oldHasFinalNewline === newHasFinalNewline
915
+ ? []
916
+ : [` final newline: ${oldHasFinalNewline ? "present" : "missing"} -> ${newHasFinalNewline ? "present" : "missing"}`];
896
917
  return [
897
918
  ...header,
898
919
  ` diff truncated: ${oldChangeEnd - prefix} removed, ${newChangeEnd - prefix} added lines`,
920
+ ...newlineSummary,
899
921
  ...sampleDiffLines(oldLines, prefix, oldChangeEnd, "-"),
900
922
  ...sampleDiffLines(newLines, prefix, newChangeEnd, "+"),
901
923
  ].join("\n");
@@ -923,8 +945,8 @@ function initialTitle(kind: PresentationKind, args: any): string {
923
945
  return `Read ${args.path}`;
924
946
  case "view":
925
947
  return `View ${args.path}`;
926
- case "map":
927
- return `Map ${args.path}`;
948
+ case "digest":
949
+ return `Digest ${args.path}`;
928
950
  case "search":
929
951
  return `Search ${args.pattern}`;
930
952
  case "grep":
@@ -933,16 +955,12 @@ function initialTitle(kind: PresentationKind, args: any): string {
933
955
  return `Find definition ${args.name}`;
934
956
  case "refs":
935
957
  return `Find references to ${args.name}`;
936
- case "hover":
937
- return `Identify ${args.path}:${args.line}`;
938
958
  case "rename":
939
959
  return `Rename ${args.path}:${args.line} to ${args.to}`;
940
960
  case "edit":
941
961
  return `Edit ${args.path}`;
942
962
  case "write":
943
963
  return `Write ${args.path}`;
944
- case "check":
945
- return `Check ${args.path}`;
946
964
  }
947
965
  }
948
966
 
@@ -967,9 +985,37 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
967
985
  }
968
986
  case "view":
969
987
  return { title: `Viewed ${args.path}`, metadata: {} };
970
- case "map": {
971
- const symbols = items(output.symbols).length;
972
- return { title: `Mapped ${args.path} (${symbols} symbols)`, metadata: { symbols } };
988
+ case "digest": {
989
+ const map = record(output.map);
990
+ const diagnostics = record(output.diagnostics);
991
+ const identity = record(output.identity);
992
+ const content = record(output.content);
993
+ const metadata: Record<string, number> = {};
994
+ const summary: string[] = [];
995
+ if (output.map !== undefined) {
996
+ const symbols = items(map.symbols).length;
997
+ metadata.symbols = symbols;
998
+ summary.push(`${symbols} symbols`);
999
+ }
1000
+ if (output.diagnostics !== undefined) {
1001
+ const errors = typeof diagnostics.error_count === "number" ? diagnostics.error_count : 0;
1002
+ const missing = typeof diagnostics.missing_count === "number" ? diagnostics.missing_count : 0;
1003
+ metadata.error_count = errors;
1004
+ metadata.missing_count = missing;
1005
+ summary.push(`${errors} errors, ${missing} missing`);
1006
+ }
1007
+ if (output.identity !== undefined) {
1008
+ if (typeof identity.line === "number") metadata.line = identity.line;
1009
+ if (typeof identity.column === "number") metadata.column = identity.column;
1010
+ const identifier = record(identity.identifier).text;
1011
+ if (typeof identifier === "string") summary.push(identifier);
1012
+ }
1013
+ if (typeof content.start_line === "number") metadata.start_line = content.start_line;
1014
+ if (typeof content.end_line === "number") metadata.end_line = content.end_line;
1015
+ return {
1016
+ title: summary.length > 0 ? `Digested ${args.path} (${summary.join(", ")})` : `Digested ${args.path}`,
1017
+ metadata,
1018
+ };
973
1019
  }
974
1020
  case "search": {
975
1021
  const results = items(output.results);
@@ -989,15 +1035,6 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
989
1035
  const references = items(output.references).length;
990
1036
  return { title: `Found ${references} references`, metadata: { references } };
991
1037
  }
992
- case "hover": {
993
- const identifier = record(output.identifier).text;
994
- const line = output.line;
995
- const column = output.column;
996
- const metadata: Record<string, number> = {};
997
- if (typeof line === "number") metadata.line = line;
998
- if (typeof column === "number") metadata.column = column;
999
- return { title: typeof identifier === "string" ? `Identified ${identifier}` : initialTitle(kind, args), metadata };
1000
- }
1001
1038
  case "rename": {
1002
1039
  const oldName = output.old_name;
1003
1040
  const newName = output.new_name;
@@ -1019,14 +1056,6 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
1019
1056
  const preview = typeof output.preview_error === "string" ? " (preview unavailable)" : "";
1020
1057
  return { title: `${kind === "edit" ? "Edited" : "Wrote"} ${args.path}${preview}`, metadata: {} };
1021
1058
  }
1022
- case "check": {
1023
- const errors = typeof output.error_count === "number" ? output.error_count : 0;
1024
- const missing = typeof output.missing_count === "number" ? output.missing_count : 0;
1025
- return {
1026
- title: `Checked ${args.path} (${errors} errors, ${missing} missing)`,
1027
- metadata: { error_count: errors, missing_count: missing },
1028
- };
1029
- }
1030
1059
  }
1031
1060
  return { title: initialTitle(kind, args), metadata: {} };
1032
1061
  }
@@ -1079,19 +1108,18 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1079
1108
  tool: {
1080
1109
  readseek_read: readseekTool(
1081
1110
  imagePolicy === "off"
1082
- ? "Read text with LINE:HASH anchors. Image and PDF reads are disabled."
1083
- : `Read text with LINE:HASH anchors. For an image or PDF, select image (${imageModes.join(", ")}); omit it to skip visual files.`,
1111
+ ? "Read text with LINE:HASH anchors. Image reads are disabled. For documents, use readseek_view."
1112
+ : `Read text with LINE:HASH anchors. For an image, select image (${imageModes.join(", ")}); omit it to skip image files. For documents such as PDFs, use readseek_view.`,
1084
1113
  {
1085
1114
  path: tool.schema.string().describe("Path relative to the project directory"),
1086
1115
  offset: tool.schema.number().int().positive().optional().describe("One-based starting line"),
1087
1116
  limit: tool.schema.number().int().positive().optional().describe(`Maximum number of lines; defaults to ${DEFAULT_READ_LIMIT}`),
1088
- page: tool.schema.number().int().positive().optional().describe("One-based PDF page; defaults to 1"),
1089
1117
  language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
1090
1118
  ...(imagePolicy === "off"
1091
1119
  ? {}
1092
1120
  : {
1093
1121
  image: tool.schema.enum(imageModes as [ImageMode, ...ImageMode[]]).optional()
1094
- .describe("Image/PDF processing mode")
1122
+ .describe("Standalone image processing mode")
1095
1123
  }),
1096
1124
  },
1097
1125
  "read",
@@ -1099,47 +1127,123 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1099
1127
  const filePath = resolvePath(context.directory, input.path as string);
1100
1128
  await authorizeRead(context, filePath);
1101
1129
  const image = input.image as ImageMode | undefined;
1102
- if (imagePolicy === "off" && image !== undefined) throw new Error("image and PDF reads are disabled");
1130
+ if (imagePolicy === "off" && image !== undefined) throw new Error("image reads are disabled");
1103
1131
  if (imagePolicy === "on" && image === "none") throw new Error('image="none" requires imageMode="auto"');
1104
- const detection = await runReadSeek(context, ["detect", filePath]);
1105
- const visual = isVisualFile(detection);
1106
- const pdf = isPdfFile(detection);
1132
+
1133
+ if (isLikelyDocumentPath(filePath)) {
1134
+ if (image !== undefined) throw new Error("image does not apply to documents; use readseek_view");
1135
+ if (input.offset !== undefined || input.limit !== undefined) {
1136
+ throw new Error("offset and limit do not apply to documents; use readseek_view");
1137
+ }
1138
+ if (input.language !== undefined) {
1139
+ throw new Error("language does not apply to documents; use readseek_view");
1140
+ }
1141
+ return digestDocumentReceipt(context, filePath);
1142
+ }
1143
+
1144
+ // Image paths without an image mode only need metadata (skip without loading content).
1145
+ // Text paths always use one digest --select content spawn.
1146
+ let detectedText = false;
1147
+ if (image === undefined && isLikelyImagePath(filePath)) {
1148
+ const envelope = await runReadSeek(context, digestArgs(filePath, "metadata"));
1149
+ const metadata = digestMetadata(envelope);
1150
+ if (isPdfFile(metadata)) return documentReceipt(filePath, envelope);
1151
+ if (isVisualFile(metadata)) {
1152
+ return { file: filePath, skipped: true, reason: "image mode not selected" };
1153
+ }
1154
+ detectedText = true;
1155
+ }
1156
+
1157
+ const extra: string[] = [];
1158
+ const wantsImage = !detectedText
1159
+ && (image !== undefined || isLikelyImagePath(filePath));
1160
+ if (!wantsImage) {
1161
+ const start = (input.offset as number | undefined) ?? 1;
1162
+ const end = start + ((input.limit as number | undefined) ?? DEFAULT_READ_LIMIT) - 1;
1163
+ if (start > 1) withAt(extra, start);
1164
+ extra.push("--end", String(end));
1165
+ if (input.language) extra.push("--language", input.language as string);
1166
+ } else {
1167
+ if (input.offset !== undefined || input.limit !== undefined) {
1168
+ throw new Error("offset and limit do not apply to image reads");
1169
+ }
1170
+ if (input.language !== undefined) {
1171
+ throw new Error("language does not apply to image reads");
1172
+ }
1173
+ if (image !== undefined) extra.push("--vision-mode", image);
1174
+ }
1175
+
1176
+ let envelope: unknown;
1177
+ try {
1178
+ envelope = await runReadSeek(
1179
+ context,
1180
+ digestArgs(filePath, "content", extra),
1181
+ image !== undefined && image !== "none" ? { timeoutMs: DEFAULT_VISION_TIMEOUT_MS } : {},
1182
+ );
1183
+ } catch (error) {
1184
+ // A visual or document file with an uncommon extension rejects text bounds before
1185
+ // an envelope can be returned. Probe metadata only on this cold path.
1186
+ if (image !== undefined) throw error;
1187
+ if (isDocumentBoundsError(error)) {
1188
+ return digestDocumentReceipt(context, filePath);
1189
+ }
1190
+ if (isVisualBoundsError(error)) {
1191
+ const metadataEnvelope = await runReadSeek(context, digestArgs(filePath, "metadata"));
1192
+ if (isPdfFile(digestMetadata(metadataEnvelope))) {
1193
+ return documentReceipt(filePath, metadataEnvelope);
1194
+ }
1195
+ if (isVisualFile(digestMetadata(metadataEnvelope))) {
1196
+ return { file: filePath, skipped: true, reason: "image mode not selected" };
1197
+ }
1198
+ }
1199
+ throw error;
1200
+ }
1201
+ const metadata = digestMetadata(envelope);
1202
+ if (isPdfFile(metadata) || record(envelope).cache !== undefined) {
1203
+ return documentReceipt(filePath, envelope);
1204
+ }
1205
+ const visual = isVisualFile(metadata);
1206
+
1107
1207
  if (visual && image === undefined) {
1108
1208
  return { file: filePath, skipped: true, reason: "image mode not selected" };
1109
1209
  }
1110
1210
  if (visual && (input.offset !== undefined || input.limit !== undefined)) {
1111
- throw new Error("offset and limit do not apply to image or PDF reads");
1211
+ throw new Error("offset and limit do not apply to image reads");
1112
1212
  }
1113
1213
  if (visual && input.language !== undefined) {
1114
- throw new Error("language does not apply to image or PDF reads");
1214
+ throw new Error("language does not apply to image reads");
1115
1215
  }
1116
- if (!pdf && input.page !== undefined) throw new Error("page applies to PDF reads only");
1117
1216
 
1118
- const args = ["read", input.offset === undefined ? filePath : `${filePath}:${input.offset}`];
1119
- if (!visual) {
1120
- args.push("--end", String((input.offset ?? 1) + ((input.limit as number | undefined) ?? DEFAULT_READ_LIMIT) - 1));
1121
- }
1122
- if (input.language) args.push("--language", input.language as string);
1123
- if (image !== undefined) args.push("--vision-mode", image);
1124
- if (pdf) args.push("--page", String(input.page ?? 1));
1125
- return runReadSeek(context, args);
1217
+ return requireDigestFacet(envelope, "content");
1126
1218
  },
1127
1219
  formatReadResult,
1128
1220
  ),
1129
1221
  readseek_view: readseekTool(
1130
- "Inspect a PDF's structure or selected content. Start with the overview, then narrow by page, node, kind, or depth instead of reading the whole document.",
1222
+ "Digest a document's structure or selected content. Start with the overview, then narrow by page, node, kind, or depth instead of reading the whole document.",
1131
1223
  {
1132
- path: tool.schema.string().describe("PDF path relative to the project directory"),
1224
+ path: tool.schema.string().describe("Document path relative to the project directory"),
1133
1225
  node: tool.schema.string().min(1).optional().describe("Node ID to use as the view root"),
1134
1226
  page: tool.schema.number().int().positive().optional().describe("One-based source page"),
1135
1227
  kind: tool.schema.enum(DOCUMENT_NODE_KINDS).optional().describe("Node kind filter"),
1136
1228
  depth: tool.schema.number().int().nonnegative().optional().describe("Maximum depth below selected roots"),
1137
1229
  outline: tool.schema.boolean().optional().describe("Return outline nodes only"),
1230
+ visionMode: tool.schema.enum(VISION_ANALYSIS_MODES).optional()
1231
+ .describe("Analyze selected assets: all, ocr, caption, or objects"),
1232
+ visionLevel: tool.schema.enum(VISION_LEVELS).optional()
1233
+ .describe("Vision inference level: low (default), medium, or high"),
1138
1234
  },
1139
1235
  "view",
1140
1236
  async (input, context) => {
1141
1237
  const filePath = resolvePath(context.directory, input.path as string);
1142
1238
  await authorizeRead(context, filePath);
1239
+ const visionMode = input.visionMode as VisionAnalysisMode | undefined;
1240
+ const visionLevel = input.visionLevel as VisionLevel | undefined;
1241
+ if (visionLevel !== undefined && visionMode === undefined) {
1242
+ throw new Error("visionLevel requires visionMode");
1243
+ }
1244
+ if (input.outline && (visionMode !== undefined || visionLevel !== undefined)) {
1245
+ throw new Error("vision options cannot be combined with outline");
1246
+ }
1143
1247
  const cacheDir = await ensureReadSeekCache(context);
1144
1248
  const args = ["--readseek-dir", cacheDir, "view", filePath];
1145
1249
  if (input.node) args.push("--node", input.node as string);
@@ -1147,23 +1251,57 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1147
1251
  if (input.kind) args.push("--kind", input.kind as string);
1148
1252
  if (input.depth !== undefined) args.push("--depth", String(input.depth));
1149
1253
  optionalFlag(args, input.outline as boolean | undefined, "--outline");
1150
- return runReadSeekRaw(context, args);
1254
+ if (visionMode !== undefined) args.push("--vision-mode", visionMode);
1255
+ if (visionLevel !== undefined) args.push("--vision-level", visionLevel);
1256
+ return runReadSeekRaw(
1257
+ context,
1258
+ args,
1259
+ visionMode === undefined ? {} : { timeoutMs: DEFAULT_VISION_TIMEOUT_MS },
1260
+ );
1151
1261
  },
1152
1262
  (result) => ({ output: boundDocumentOutput(String(result)) }),
1153
1263
  ),
1154
- readseek_map: readseekTool(
1155
- "Inspect a source file's symbol outline. Use this instead of reading the whole file when only declarations, names, kinds, ranges, and line hashes are needed.",
1264
+ readseek_digest: readseekTool(
1265
+ "Digest native facets for a path and return the full envelope unchanged. Facets: metadata, content, map, diagnostics, identity (comma-separated). Metadata is always present; other selected facets appear when available.",
1156
1266
  {
1157
1267
  path: tool.schema.string().describe("Path relative to the project directory"),
1268
+ select: tool.schema.string().optional()
1269
+ .describe("Comma-separated facets: metadata, content, map, diagnostics, identity; defaults to content"),
1270
+ at: tool.schema.string().optional()
1271
+ .describe("Location: line:N[:COLUMN], hash:HASH[:COLUMN], or symbol:NAME"),
1272
+ end: tool.schema.number().int().positive().optional().describe("Last line to include; content facet only"),
1273
+ limit: tool.schema.number().int().positive().optional().describe("Maximum number of lines; content facet only"),
1158
1274
  language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
1275
+ visionMode: tool.schema.enum(["none", ...VISION_ANALYSIS_MODES] as [ImageMode, ...ImageMode[]]).optional()
1276
+ .describe("Standalone image mode for the content facet: none, all, ocr, caption, or objects"),
1277
+ visionLevel: tool.schema.enum(VISION_LEVELS).optional()
1278
+ .describe("Vision inference level: low (default), medium, or high; content facet only"),
1159
1279
  },
1160
- "map",
1280
+ "digest",
1161
1281
  async (input, context) => {
1162
1282
  const filePath = resolvePath(context.directory, input.path as string);
1163
1283
  await authorizeRead(context, filePath);
1164
- const args = ["map", filePath];
1284
+ const select = ((input.select as string | undefined) ?? "content").trim() || "content";
1285
+ const visionMode = input.visionMode as ImageMode | undefined;
1286
+ const visionLevel = input.visionLevel as VisionLevel | undefined;
1287
+ if (visionLevel !== undefined && visionMode === undefined) {
1288
+ throw new Error("visionLevel requires visionMode");
1289
+ }
1290
+ const cacheDir = await ensureReadSeekCache(context);
1291
+ const args = ["--readseek-dir", cacheDir, ...digestArgs(filePath, select)];
1292
+ if (input.at) args.push("--at", input.at as string);
1293
+ if (input.end !== undefined) args.push("--end", String(input.end));
1294
+ if (input.limit !== undefined) args.push("--limit", String(input.limit));
1165
1295
  if (input.language) args.push("--language", input.language as string);
1166
- return runReadSeek(context, args);
1296
+ if (visionMode !== undefined) args.push("--vision-mode", visionMode);
1297
+ if (visionLevel !== undefined) args.push("--vision-level", visionLevel);
1298
+ return runReadSeek(
1299
+ context,
1300
+ args,
1301
+ visionMode === undefined || visionMode === "none"
1302
+ ? {}
1303
+ : { timeoutMs: DEFAULT_VISION_TIMEOUT_MS },
1304
+ );
1167
1305
  },
1168
1306
  ),
1169
1307
  readseek_grep: readseekTool(
@@ -1235,7 +1373,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1235
1373
  }
1236
1374
  const linesByNumber = new Map<number, unknown>();
1237
1375
  for (const [start, end] of mergedRanges) {
1238
- const output = record(await runReadSeek(context, ["read", `${filePath}:${start}`, "--end", String(end)]));
1376
+ const output = record(await runDigestContent(context, readRangeArgs(filePath, start, end)));
1239
1377
  for (const hashline of items(output.hashlines)) {
1240
1378
  const line = record(hashline).line;
1241
1379
  if (typeof line === "number" && !linesByNumber.has(line)) linesByNumber.set(line, hashline);
@@ -1312,34 +1450,18 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1312
1450
  throw new Error("line and column require scope");
1313
1451
  }
1314
1452
  const target = resolvePath(context.directory, (input.path as string | undefined) ?? ".");
1315
- const args = ["refs", target, input.name as string];
1316
- optionalFlag(args, input.scope as boolean | undefined, "--scope");
1317
- if (input.line !== undefined) args.push("--line", String(input.line));
1318
- if (input.column !== undefined) args.push("--column", String(input.column));
1453
+ const args = ["refs", target];
1454
+ if (input.scope) {
1455
+ withAt(args, input.line as number, input.column as number | undefined);
1456
+ } else {
1457
+ args.push(input.name as string);
1458
+ }
1319
1459
  if (input.language) args.push("--language", input.language as string);
1320
1460
  withSearchFlags(args, input);
1321
1461
  await authorizeSearch(context, target, input.name as string);
1322
1462
  return runReadSeek(context, args);
1323
1463
  },
1324
1464
  ),
1325
- readseek_hover: readseekTool(
1326
- "Inspect the symbol at a source location. This identifies the token and enclosing symbol; use it before definition lookup or binding-specific references.",
1327
- {
1328
- path: tool.schema.string().describe("Path relative to the project directory"),
1329
- line: tool.schema.number().int().positive().describe("One-based cursor line"),
1330
- column: tool.schema.number().int().positive().optional().describe("One-based cursor byte column for disambiguation"),
1331
- language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
1332
- },
1333
- "hover",
1334
- async (input, context) => {
1335
- const filePath = resolvePath(context.directory, input.path as string);
1336
- await authorizeRead(context, filePath);
1337
- const args = ["identify", `${filePath}:${input.line}`];
1338
- if (input.column !== undefined) args.push("--column", String(input.column));
1339
- if (input.language) args.push("--language", input.language as string);
1340
- return runReadSeek(context, args);
1341
- },
1342
- ),
1343
1465
  readseek_rename: readseekTool(
1344
1466
  "Rename a symbol at a source location. Resolvable same-named bindings are preserved; verified edits apply by default. Set `apply: false` to preview the plan before changing files.",
1345
1467
  {
@@ -1355,14 +1477,16 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1355
1477
  const filePath = resolvePath(context.directory, input.path as string);
1356
1478
  await authorizeRead(context, filePath);
1357
1479
  if (input.workspace) {
1358
- const identifyArgs = ["identify", `${filePath}:${input.line}`];
1359
- if (input.column !== undefined) identifyArgs.push("--column", String(input.column));
1360
- const name = identifiedName(await runReadSeek(context, identifyArgs));
1480
+ const identifyArgs = digestArgs(filePath, "identity");
1481
+ withAt(identifyArgs, input.line as number, input.column as number | undefined);
1482
+ const envelope = await runReadSeek(context, identifyArgs);
1483
+ const name = identifiedName(requireDigestFacet(envelope, "identity"));
1361
1484
  if (!name) throw new Error("readseek could not identify a binding at the rename cursor");
1362
1485
  await authorizeSearch(context, context.directory, name);
1363
1486
  }
1364
- const args = ["rename", filePath, "--line", String(input.line), "--to", input.to as string];
1365
- if (input.column !== undefined) args.push("--column", String(input.column));
1487
+ const args = ["rename", filePath];
1488
+ withAt(args, input.line as number, input.column as number | undefined);
1489
+ args.push("--to", input.to as string);
1366
1490
  if (input.workspace) args.push("--workspace", context.directory);
1367
1491
  const plan = await runReadSeek(context, args);
1368
1492
  if (input.apply === false) return plan;
@@ -1372,11 +1496,14 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1372
1496
  throw new Error("readseek rename plan did not include a plan hash");
1373
1497
  }
1374
1498
  await authorizeEdit(context, files);
1375
- return runReadSeek(context, [...args, "--plan-hash", planHash, "--apply"], { cancelable: false });
1499
+ return runReadSeek(context, [...args, "--plan-hash", planHash, "--apply"], {
1500
+ cancelable: false,
1501
+ timeoutMs: DEFAULT_READSEEK_TIMEOUT_MS,
1502
+ });
1376
1503
  },
1377
1504
  ),
1378
1505
  readseek_edit: readseekTool(
1379
- "Edit an existing text file with fresh LINE:HASH anchors. Supports set_line, replace_lines, and insert_after only. Stale hashes are rejected before writing.",
1506
+ "Edit an existing text file with fresh LINE:HASH anchors, exact text, or a mapped symbol. The native engine rejects stale or overlapping edits before writing.",
1380
1507
  {
1381
1508
  path: tool.schema.string().describe("Path relative to the project directory"),
1382
1509
  edits: tool.schema.array(tool.schema.union([
@@ -1399,31 +1526,64 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1399
1526
  new_text: tool.schema.string().describe("Text to insert after the anchored line"),
1400
1527
  }).strict(),
1401
1528
  }).strict(),
1402
- ])).min(1).describe("Anchored edits: set_line, replace_lines, or insert_after"),
1529
+ tool.schema.object({
1530
+ replace: tool.schema.object({
1531
+ old_text: tool.schema.string().describe("Exact text to find"),
1532
+ new_text: tool.schema.string().describe("Replacement text"),
1533
+ all: tool.schema.boolean().optional().describe("Replace every exact match"),
1534
+ }).strict(),
1535
+ }).strict(),
1536
+ tool.schema.object({
1537
+ replace_symbol: tool.schema.object({
1538
+ symbol: tool.schema.string().describe("Mapped symbol name"),
1539
+ new_body: tool.schema.string().describe("Complete replacement body for the symbol"),
1540
+ }).strict(),
1541
+ }).strict(),
1542
+ ])).min(1).describe("Use set_line, replace_lines, insert_after, replace, or replace_symbol"),
1543
+ language: tool.schema.string().optional().describe("Language override for symbol replacement"),
1403
1544
  },
1404
1545
  "edit",
1405
1546
  async (input, context) => {
1406
1547
  const filePath = resolvePath(context.directory, input.path as string);
1407
- validateAnchorEdits(input.edits);
1548
+ validateEdits(input.edits);
1408
1549
  return withFileMutationQueue(filePath, async () => {
1409
1550
  context.abort.throwIfAborted();
1410
1551
  await authorizeRead(context, filePath);
1411
1552
  await rejectSymlinkMutation(filePath);
1412
- const edits = input.edits as AnchorEdit[];
1413
- const before = await readFile(filePath, "utf8");
1414
- await verifyAnchors(context, filePath, edits);
1415
- const after = applyAnchorEdits(before, edits);
1553
+ const edits = input.edits as EditItem[];
1554
+ const request = JSON.stringify({ edits });
1555
+ const baseArgs = ["edit", filePath];
1556
+ if (input.language) baseArgs.push("--language", input.language as string);
1557
+ const plan = record(await runReadSeek(context, baseArgs, { stdin: request }));
1558
+ if (
1559
+ typeof plan.before_content !== "string" ||
1560
+ typeof plan.content !== "string" ||
1561
+ typeof plan.plan_hash !== "string"
1562
+ ) {
1563
+ throw new Error("readseek edit plan is missing before_content, content, or plan_hash");
1564
+ }
1565
+ const before = plan.before_content;
1566
+ const after = plan.content;
1567
+ const previewStart = Math.max(1, firstChangedLine(before, after) - 3);
1568
+ const previewArgs = readRangeArgs(filePath, previewStart, previewStart + EDIT_RESULT_READ_LIMIT - 1);
1569
+ if (before === after) {
1570
+ return postMutationPreview(context, previewArgs, filePath, false);
1571
+ }
1416
1572
  await authorizeEdit(context, [filePath], simpleDiff(filePath, before, after), false);
1417
1573
  context.abort.throwIfAborted();
1418
- if (before !== after) await writeText(filePath, { exists: true, content: before }, after);
1419
- const firstEditedLine = Math.min(...edits.flatMap((edit) => anchorRefs(edit).map((ref) => ref.line)));
1420
- const previewStart = Math.max(1, firstEditedLine - 3);
1421
- return postMutationPreview(
1574
+ const applied = record(await runReadSeek(
1422
1575
  context,
1423
- ["read", `${filePath}:${previewStart}`, "--end", String(previewStart + EDIT_RESULT_READ_LIMIT - 1)],
1424
- filePath,
1425
- before !== after,
1426
- );
1576
+ [...baseArgs, "--apply", "--plan-hash", plan.plan_hash],
1577
+ {
1578
+ cancelable: false,
1579
+ stdin: request,
1580
+ timeoutMs: DEFAULT_READSEEK_TIMEOUT_MS,
1581
+ },
1582
+ ));
1583
+ if (applied.applied !== true) {
1584
+ throw new Error("readseek did not confirm that the edit was applied");
1585
+ }
1586
+ return postMutationPreview(context, previewArgs, filePath, true);
1427
1587
  });
1428
1588
  },
1429
1589
  ),
@@ -1448,28 +1608,13 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1448
1608
  await writeText(filePath, before, content);
1449
1609
  return postMutationPreview(
1450
1610
  context,
1451
- ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)],
1611
+ digestArgs(filePath, "content", ["--end", String(DEFAULT_READ_LIMIT)]),
1452
1612
  filePath,
1453
1613
  !before.exists || before.content !== content,
1454
1614
  );
1455
1615
  });
1456
1616
  },
1457
1617
  ),
1458
- readseek_check: readseekTool(
1459
- "Check a source file for parser errors or missing syntax after edits. Validation requires a supported tree-sitter language; files without a supported parser are not checked. This is not a compiler, type checker, test runner, or linter.",
1460
- {
1461
- path: tool.schema.string().describe("Path relative to the project directory"),
1462
- language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
1463
- },
1464
- "check",
1465
- async (input, context) => {
1466
- const filePath = resolvePath(context.directory, input.path as string);
1467
- await authorizeRead(context, filePath);
1468
- const args = ["check", filePath];
1469
- if (input.language) args.push("--language", input.language as string);
1470
- return runReadSeek(context, args);
1471
- },
1472
- ),
1473
1618
  },
1474
1619
  "experimental.chat.system.transform": async (_input, output) => {
1475
1620
  output.system.push(TOOL_POLICY);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-readseek",
3
- "version": "0.8.24",
3
+ "version": "0.9.1",
4
4
  "description": "OpenCode plugin for anchored file operations and structural code navigation",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -31,7 +31,7 @@
31
31
  "node": ">=20.0.0"
32
32
  },
33
33
  "dependencies": {
34
- "@jarkkojs/readseek": "^0.8.24",
34
+ "@jarkkojs/readseek": "^0.9.1",
35
35
  "@opencode-ai/plugin": "^1.17.20"
36
36
  },
37
37
  "devDependencies": {