opencode-readseek 0.8.24 → 0.9.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 +20 -17
  2. package/index.ts +370 -271
  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
@@ -18,10 +18,10 @@ const TOOL_POLICY = [
18
18
  "ReadSeek tool policy:",
19
19
  "- Prefer readseek_* tools over built-ins when they can do the job.",
20
20
  "- 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.",
21
+ "- Use readseek_grep for text/regex, readseek_search for AST patterns, and readseek_digest/def/refs for symbol navigation.",
22
22
  "- Use readseek_edit for existing files, readseek_write for whole-file creation or replacement, and readseek_rename for symbol renames.",
23
23
  "- 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.",
24
+ "- Run readseek_digest with select=diagnostics after source edits for a quick syntax check.",
25
25
  ].join("\n");
26
26
  const PREFERRED_TOOL_DESCRIPTIONS: Record<string, string> = {
27
27
  readseek_read: "Preferred file reader; returns LINE:HASH anchors for durable references.",
@@ -30,12 +30,10 @@ const PREFERRED_TOOL_DESCRIPTIONS: Record<string, string> = {
30
30
  readseek_rename: "Use to rename a symbol with binding-aware, verified edits.",
31
31
  readseek_grep: "Preferred text/regex search; returns edit-ready LINE:HASH anchors.",
32
32
  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.",
33
+ readseek_digest: "Use to digest native facets (metadata, content, map, diagnostics, identity) and return the full envelope.",
34
34
  readseek_def: "Use for go-to-definition or declaration lookup by name.",
35
35
  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.",
36
+ readseek_view: "Use to digest document structure or selected content.",
39
37
  };
40
38
  const FALLBACK_TOOL_DESCRIPTIONS: Record<string, string> = {
41
39
  read: "Fallback only. Prefer readseek_read so edits can use LINE:HASH anchors.",
@@ -56,7 +54,7 @@ type RenamePlan = {
56
54
  files: Set<string>;
57
55
  };
58
56
 
59
- type PresentationKind = "read" | "view" | "map" | "search" | "grep" | "def" | "refs" | "hover" | "rename" | "edit" | "write" | "check";
57
+ type PresentationKind = "read" | "view" | "digest" | "search" | "grep" | "def" | "refs" | "rename" | "edit" | "write";
60
58
 
61
59
  type Presentation = {
62
60
  title: string;
@@ -70,7 +68,11 @@ type RenderedToolResult = {
70
68
 
71
69
  type ImagePolicy = "on" | "auto" | "off";
72
70
  type ImageMode = "none" | "all" | "ocr" | "caption" | "objects";
71
+ type VisionAnalysisMode = Exclude<ImageMode, "none">;
72
+ type VisionLevel = "low" | "medium" | "high";
73
73
  type DocumentNodeKind = "artifact" | "footer" | "header" | "heading" | "marginal_label" | "page" | "page_number" | "paragraph" | "section" | "structural_section";
74
+ const VISION_ANALYSIS_MODES: [VisionAnalysisMode, ...VisionAnalysisMode[]] = ["all", "ocr", "caption", "objects"];
75
+ const VISION_LEVELS: [VisionLevel, ...VisionLevel[]] = ["low", "medium", "high"];
74
76
 
75
77
  const DOCUMENT_NODE_KINDS: [DocumentNodeKind, ...DocumentNodeKind[]] = [
76
78
  "artifact",
@@ -95,11 +97,65 @@ function resolveImagePolicy(options: PluginOptions | undefined): ImagePolicy {
95
97
  function isVisualFile(value: unknown): boolean {
96
98
  const output = record(value);
97
99
  const type = output.type;
98
- return typeof output.width === "number" || type === "application/pdf";
100
+ return typeof output.width === "number"
101
+ || (typeof type === "string" && type.startsWith("image/"));
102
+ }
103
+
104
+ function isVisualBoundsError(error: unknown): boolean {
105
+ if (!(error instanceof Error)) return false;
106
+ const message = error.message.replace(/^error:\s*/, "");
107
+ return message.startsWith("--end")
108
+ && /(?:does|do) not apply to images/.test(message);
109
+ }
110
+
111
+ function isDocumentBoundsError(error: unknown): boolean {
112
+ if (!(error instanceof Error)) return false;
113
+ const message = error.message.replace(/^error:\s*/, "");
114
+ return /(?:does|do) not apply to (?:documents|PDFs)/.test(message)
115
+ || /document content belongs to view/.test(message)
116
+ || /source facets do not apply to documents/.test(message);
99
117
  }
100
118
 
101
119
  function isPdfFile(value: unknown): boolean {
102
- return record(value).format === "pdf";
120
+ const output = record(value);
121
+ return output.format === "pdf" || output.type === "application/pdf";
122
+ }
123
+
124
+ function documentReceipt(filePath: string, envelope: unknown): Record<string, unknown> {
125
+ const recordEnvelope = record(envelope);
126
+ return {
127
+ file: filePath,
128
+ use: "readseek_view",
129
+ reason: "document content belongs to readseek_view",
130
+ metadata: digestMetadata(envelope),
131
+ ...(recordEnvelope.cache !== undefined ? { cache: recordEnvelope.cache } : {}),
132
+ };
133
+ }
134
+
135
+ async function digestDocumentReceipt(context: ToolContext, filePath: string): Promise<Record<string, unknown>> {
136
+ const cacheDir = await ensureReadSeekCache(context);
137
+ const envelope = await runReadSeek(context, [
138
+ "--readseek-dir",
139
+ cacheDir,
140
+ ...digestArgs(filePath, "metadata"),
141
+ ]);
142
+ return documentReceipt(filePath, envelope);
143
+ }
144
+
145
+ function digestArgs(filePath: string, select: string, extra: string[] = []): string[] {
146
+ return ["digest", filePath, "--select", select, ...extra];
147
+ }
148
+
149
+ function requireDigestFacet(envelope: unknown, facet: "content" | "map" | "diagnostics" | "identity"): unknown {
150
+ const value = record(envelope)[facet];
151
+ if (value === undefined || value === null || typeof value !== "object") {
152
+ throw new Error(`digest returned no ${facet} facet`);
153
+ }
154
+ return value;
155
+ }
156
+
157
+ function digestMetadata(envelope: unknown): Record<string, unknown> {
158
+ return record(record(envelope).metadata);
103
159
  }
104
160
 
105
161
  class SessionAnchors {
@@ -304,6 +360,37 @@ function optionalFlag(args: string[], enabled: boolean | undefined, flag: string
304
360
  if (enabled) args.push(flag);
305
361
  }
306
362
 
363
+ function atLocation(line: number, column?: number): string {
364
+ return column === undefined ? `line:${line}` : `line:${line}:${column}`;
365
+ }
366
+
367
+ function withAt(args: string[], line: number, column?: number): void {
368
+ args.push("--at", atLocation(line, column));
369
+ }
370
+
371
+ function isLikelyImagePath(filePath: string): boolean {
372
+ return /\.(avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i.test(filePath);
373
+ }
374
+
375
+ function isLikelyDocumentPath(filePath: string): boolean {
376
+ return /\.pdf$/i.test(filePath);
377
+ }
378
+
379
+ function readRangeArgs(filePath: string, start: number, end: number): string[] {
380
+ const extra: string[] = [];
381
+ if (start > 1) withAt(extra, start);
382
+ extra.push("--end", String(end));
383
+ return digestArgs(filePath, "content", extra);
384
+ }
385
+
386
+ async function runDigestContent(
387
+ context: ToolContext,
388
+ args: string[],
389
+ options: { cancelable?: boolean } = {},
390
+ ): Promise<unknown> {
391
+ return requireDigestFacet(await runReadSeek(context, args, options), "content");
392
+ }
393
+
307
394
  function readSeekBinaryPath(): string {
308
395
  const platform = `${process.platform}-${process.arch}`;
309
396
  const platformPackage = READSEEK_PLATFORM_PACKAGES[platform];
@@ -331,7 +418,7 @@ async function readBoundedText(
331
418
  totalBytes += value.byteLength;
332
419
  if (totalBytes > MAX_OUTPUT_BYTES) {
333
420
  onOverflow();
334
- void reader.cancel().catch(() => undefined);
421
+ await reader.cancel().catch(() => undefined);
335
422
  throw new Error(`readseek ${streamName} exceeded the ${MAX_OUTPUT_BYTES}-byte output limit`);
336
423
  }
337
424
  chunks.push(value);
@@ -352,13 +439,14 @@ async function readBoundedText(
352
439
  async function runReadSeekRaw(
353
440
  context: ToolContext,
354
441
  args: string[],
355
- options: { cancelable?: boolean } = {},
442
+ options: { cancelable?: boolean; stdin?: string } = {},
356
443
  ): Promise<string> {
357
444
  context.abort.throwIfAborted();
358
445
  const cancelable = options.cancelable !== false;
359
446
  const spawnOptions = {
360
447
  cwd: context.directory,
361
448
  stderr: "pipe" as const,
449
+ stdin: options.stdin === undefined ? undefined : Buffer.from(options.stdin),
362
450
  stdout: "pipe" as const,
363
451
  };
364
452
  const child = Bun.spawn(
@@ -390,7 +478,7 @@ async function runReadSeekRaw(
390
478
  async function runReadSeek(
391
479
  context: ToolContext,
392
480
  args: string[],
393
- options: { cancelable?: boolean } = {},
481
+ options: { cancelable?: boolean; stdin?: string } = {},
394
482
  ): Promise<unknown> {
395
483
  const stdout = await runReadSeekRaw(context, args, options);
396
484
  try {
@@ -407,7 +495,7 @@ async function postMutationPreview(
407
495
  changed: boolean,
408
496
  ): Promise<unknown> {
409
497
  try {
410
- return await runReadSeek(context, args, { cancelable: false });
498
+ return await runDigestContent(context, args, { cancelable: false });
411
499
  } catch (error) {
412
500
  const previewError = error instanceof Error ? error.message : String(error);
413
501
  return {
@@ -471,6 +559,11 @@ type AnchorEdit =
471
559
  | { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
472
560
  | { insert_after: { anchor: string; new_text: string } };
473
561
 
562
+ type EditItem =
563
+ | AnchorEdit
564
+ | { replace: { old_text: string; new_text: string; all?: boolean } }
565
+ | { replace_symbol: { symbol: string; new_body: string } };
566
+
474
567
  type TextSnapshot = {
475
568
  exists: boolean;
476
569
  content: string;
@@ -496,25 +589,28 @@ async function withFileMutationQueue<T>(filePath: string, operation: () => Promi
496
589
  }
497
590
  }
498
591
 
499
- function validateAnchorEdits(value: unknown): asserts value is AnchorEdit[] {
592
+ function validateEdits(value: unknown): asserts value is EditItem[] {
500
593
  if (!Array.isArray(value) || value.length === 0) throw new Error("edits must contain at least one edit");
501
- const fields = {
594
+ const requiredFields = {
502
595
  set_line: ["anchor", "new_text"],
503
596
  replace_lines: ["end_anchor", "new_text", "start_anchor"],
504
597
  insert_after: ["anchor", "new_text"],
598
+ replace: ["new_text", "old_text"],
599
+ replace_symbol: ["new_body", "symbol"],
505
600
  } as const;
506
601
  for (const [index, item] of value.entries()) {
507
602
  if (!item || typeof item !== "object") throw new Error(`edits[${index}] must be an edit object`);
508
603
  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`);
604
+ const variant = keys[0] as keyof typeof requiredFields;
605
+ if (keys.length !== 1 || !Object.hasOwn(requiredFields, variant)) {
606
+ throw new Error(`edits[${index}] must contain exactly one of: set_line, replace_lines, insert_after, replace, replace_symbol`);
512
607
  }
513
608
  const payload = (item as Record<string, unknown>)[variant];
514
609
  if (!payload || typeof payload !== "object") throw new Error(`edits[${index}].${variant} must be an object`);
515
- const expectedFields = fields[variant];
610
+ const expectedFields: readonly string[] = requiredFields[variant];
611
+ const allowedFields = variant === "replace" ? [...expectedFields, "all"] : expectedFields;
516
612
  const actualFields = Object.keys(payload).sort();
517
- if (actualFields.length !== expectedFields.length || actualFields.some((field, fieldIndex) => field !== expectedFields[fieldIndex])) {
613
+ if (actualFields.some((field) => !allowedFields.includes(field)) || expectedFields.some((field) => !actualFields.includes(field))) {
518
614
  throw new Error(`edits[${index}].${variant} contains invalid fields`);
519
615
  }
520
616
  const values = payload as Record<string, unknown>;
@@ -523,6 +619,9 @@ function validateAnchorEdits(value: unknown): asserts value is AnchorEdit[] {
523
619
  throw new Error(`edits[${index}].${variant}.${field} must be a string`);
524
620
  }
525
621
  }
622
+ if (variant === "replace" && values.all !== undefined && typeof values.all !== "boolean") {
623
+ throw new Error(`edits[${index}].replace.all must be a boolean`);
624
+ }
526
625
  for (const field of expectedFields.filter((field) => field.endsWith("anchor"))) {
527
626
  parseAnchor(values[field] as string);
528
627
  }
@@ -537,125 +636,16 @@ function parseAnchor(anchor: string): { line: number; hash: string } {
537
636
  return { line, hash: match[2].toLowerCase() };
538
637
  }
539
638
 
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
- }
562
- }
563
- }
564
-
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;
639
+ function firstChangedLine(before: string, after: string): number {
640
+ const beforeLines = normalizeToLf(before).split("\n");
641
+ const afterLines = normalizeToLf(after).split("\n");
642
+ const count = Math.max(beforeLines.length, afterLines.length);
643
+ for (let index = 0; index < count; index++) {
644
+ if (beforeLines[index] !== afterLines[index]) return index + 1;
580
645
  }
581
- if (start < content.length || content.length === 0) lines.push({ text: content.slice(start), ending: "" });
582
- return lines;
646
+ return 1;
583
647
  }
584
648
 
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
649
 
660
650
  async function readTextSnapshot(filePath: string): Promise<TextSnapshot> {
661
651
  try {
@@ -729,11 +719,19 @@ function renameFiles(value: unknown): string[] {
729
719
  return files;
730
720
  }
731
721
 
722
+ function normalizeToLf(content: string): string {
723
+ return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
724
+ }
725
+
732
726
  function diffLines(content: string): string[] {
733
727
  if (content === "") return [];
734
- const lines = normalizeToLf(content).split("\n");
728
+ const lines = content.split("\n");
735
729
  if (lines.at(-1) === "") lines.pop();
736
- return lines;
730
+ return lines.map((line) => line.replace(/\r/g, "\\r"));
731
+ }
732
+
733
+ function hasFinalNewline(content: string): boolean {
734
+ return content === "" || /[\r\n]$/.test(content);
737
735
  }
738
736
 
739
737
  function diffRange(start: number, count: number): string {
@@ -796,23 +794,7 @@ function imageAttachment(image: Record<string, unknown>, filename: string): Tool
796
794
 
797
795
  function formatReadResult(value: unknown): RenderedToolResult {
798
796
  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
- }
797
+ if (output.use === "readseek_view") return { output: render(value) };
816
798
 
817
799
  const attachment = imageAttachment(output, "image");
818
800
  if (!attachment) return { output: render(value) };
@@ -846,6 +828,8 @@ function simpleDiff(filePath: string, before: string, after: string): string {
846
828
  if (before === after) return "";
847
829
  const oldLines = diffLines(before);
848
830
  const newLines = diffLines(after);
831
+ const oldHasFinalNewline = hasFinalNewline(before);
832
+ const newHasFinalNewline = hasFinalNewline(after);
849
833
  let prefix = 0;
850
834
  while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
851
835
  let suffix = 0;
@@ -874,13 +858,13 @@ function simpleDiff(filePath: string, before: string, after: string): string {
874
858
  const fullDiff = [...header];
875
859
  let fullDiffBytes = Buffer.byteLength(header.join("\n"));
876
860
  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, " "],
861
+ const ranges: Array<[string[], number, number, string, boolean]> = [
862
+ [oldLines, oldStart, prefix, " ", oldHasFinalNewline],
863
+ [oldLines, prefix, oldChangeEnd, "-", oldHasFinalNewline],
864
+ [newLines, prefix, newChangeEnd, "+", newHasFinalNewline],
865
+ [newLines, newChangeEnd, newEnd, " ", newHasFinalNewline],
882
866
  ];
883
- outer: for (const [lines, start, end, marker] of ranges) {
867
+ outer: for (const [lines, start, end, marker, hasNewline] of ranges) {
884
868
  for (let index = start; index < end; index++) {
885
869
  const lineBytes = 2 + Buffer.byteLength(lines[index]);
886
870
  if (fullDiffBytes + lineBytes > MAX_PERMISSION_DIFF_BYTES) {
@@ -889,13 +873,26 @@ function simpleDiff(filePath: string, before: string, after: string): string {
889
873
  }
890
874
  fullDiff.push(`${marker}${lines[index]}`);
891
875
  fullDiffBytes += lineBytes;
876
+ if (index === lines.length - 1 && !hasNewline) {
877
+ const eofMarker = "\";
878
+ if (fullDiffBytes + Buffer.byteLength(eofMarker) + 1 > MAX_PERMISSION_DIFF_BYTES) {
879
+ complete = false;
880
+ break outer;
881
+ }
882
+ fullDiff.push(eofMarker);
883
+ fullDiffBytes += Buffer.byteLength(eofMarker) + 1;
884
+ }
892
885
  }
893
886
  }
894
887
  if (complete) return fullDiff.join("\n");
895
888
 
889
+ const newlineSummary = oldHasFinalNewline === newHasFinalNewline
890
+ ? []
891
+ : [` final newline: ${oldHasFinalNewline ? "present" : "missing"} -> ${newHasFinalNewline ? "present" : "missing"}`];
896
892
  return [
897
893
  ...header,
898
894
  ` diff truncated: ${oldChangeEnd - prefix} removed, ${newChangeEnd - prefix} added lines`,
895
+ ...newlineSummary,
899
896
  ...sampleDiffLines(oldLines, prefix, oldChangeEnd, "-"),
900
897
  ...sampleDiffLines(newLines, prefix, newChangeEnd, "+"),
901
898
  ].join("\n");
@@ -923,8 +920,8 @@ function initialTitle(kind: PresentationKind, args: any): string {
923
920
  return `Read ${args.path}`;
924
921
  case "view":
925
922
  return `View ${args.path}`;
926
- case "map":
927
- return `Map ${args.path}`;
923
+ case "digest":
924
+ return `Digest ${args.path}`;
928
925
  case "search":
929
926
  return `Search ${args.pattern}`;
930
927
  case "grep":
@@ -933,16 +930,12 @@ function initialTitle(kind: PresentationKind, args: any): string {
933
930
  return `Find definition ${args.name}`;
934
931
  case "refs":
935
932
  return `Find references to ${args.name}`;
936
- case "hover":
937
- return `Identify ${args.path}:${args.line}`;
938
933
  case "rename":
939
934
  return `Rename ${args.path}:${args.line} to ${args.to}`;
940
935
  case "edit":
941
936
  return `Edit ${args.path}`;
942
937
  case "write":
943
938
  return `Write ${args.path}`;
944
- case "check":
945
- return `Check ${args.path}`;
946
939
  }
947
940
  }
948
941
 
@@ -967,9 +960,37 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
967
960
  }
968
961
  case "view":
969
962
  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 } };
963
+ case "digest": {
964
+ const map = record(output.map);
965
+ const diagnostics = record(output.diagnostics);
966
+ const identity = record(output.identity);
967
+ const content = record(output.content);
968
+ const metadata: Record<string, number> = {};
969
+ const summary: string[] = [];
970
+ if (output.map !== undefined) {
971
+ const symbols = items(map.symbols).length;
972
+ metadata.symbols = symbols;
973
+ summary.push(`${symbols} symbols`);
974
+ }
975
+ if (output.diagnostics !== undefined) {
976
+ const errors = typeof diagnostics.error_count === "number" ? diagnostics.error_count : 0;
977
+ const missing = typeof diagnostics.missing_count === "number" ? diagnostics.missing_count : 0;
978
+ metadata.error_count = errors;
979
+ metadata.missing_count = missing;
980
+ summary.push(`${errors} errors, ${missing} missing`);
981
+ }
982
+ if (output.identity !== undefined) {
983
+ if (typeof identity.line === "number") metadata.line = identity.line;
984
+ if (typeof identity.column === "number") metadata.column = identity.column;
985
+ const identifier = record(identity.identifier).text;
986
+ if (typeof identifier === "string") summary.push(identifier);
987
+ }
988
+ if (typeof content.start_line === "number") metadata.start_line = content.start_line;
989
+ if (typeof content.end_line === "number") metadata.end_line = content.end_line;
990
+ return {
991
+ title: summary.length > 0 ? `Digested ${args.path} (${summary.join(", ")})` : `Digested ${args.path}`,
992
+ metadata,
993
+ };
973
994
  }
974
995
  case "search": {
975
996
  const results = items(output.results);
@@ -989,15 +1010,6 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
989
1010
  const references = items(output.references).length;
990
1011
  return { title: `Found ${references} references`, metadata: { references } };
991
1012
  }
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
1013
  case "rename": {
1002
1014
  const oldName = output.old_name;
1003
1015
  const newName = output.new_name;
@@ -1019,14 +1031,6 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
1019
1031
  const preview = typeof output.preview_error === "string" ? " (preview unavailable)" : "";
1020
1032
  return { title: `${kind === "edit" ? "Edited" : "Wrote"} ${args.path}${preview}`, metadata: {} };
1021
1033
  }
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
1034
  }
1031
1035
  return { title: initialTitle(kind, args), metadata: {} };
1032
1036
  }
@@ -1079,19 +1083,18 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1079
1083
  tool: {
1080
1084
  readseek_read: readseekTool(
1081
1085
  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.`,
1086
+ ? "Read text with LINE:HASH anchors. Image reads are disabled. For documents, use readseek_view."
1087
+ : `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
1088
  {
1085
1089
  path: tool.schema.string().describe("Path relative to the project directory"),
1086
1090
  offset: tool.schema.number().int().positive().optional().describe("One-based starting line"),
1087
1091
  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
1092
  language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
1090
1093
  ...(imagePolicy === "off"
1091
1094
  ? {}
1092
1095
  : {
1093
1096
  image: tool.schema.enum(imageModes as [ImageMode, ...ImageMode[]]).optional()
1094
- .describe("Image/PDF processing mode")
1097
+ .describe("Standalone image processing mode")
1095
1098
  }),
1096
1099
  },
1097
1100
  "read",
@@ -1099,47 +1102,119 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1099
1102
  const filePath = resolvePath(context.directory, input.path as string);
1100
1103
  await authorizeRead(context, filePath);
1101
1104
  const image = input.image as ImageMode | undefined;
1102
- if (imagePolicy === "off" && image !== undefined) throw new Error("image and PDF reads are disabled");
1105
+ if (imagePolicy === "off" && image !== undefined) throw new Error("image reads are disabled");
1103
1106
  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);
1107
+
1108
+ if (isLikelyDocumentPath(filePath)) {
1109
+ if (image !== undefined) throw new Error("image does not apply to documents; use readseek_view");
1110
+ if (input.offset !== undefined || input.limit !== undefined) {
1111
+ throw new Error("offset and limit do not apply to documents; use readseek_view");
1112
+ }
1113
+ if (input.language !== undefined) {
1114
+ throw new Error("language does not apply to documents; use readseek_view");
1115
+ }
1116
+ return digestDocumentReceipt(context, filePath);
1117
+ }
1118
+
1119
+ // Image paths without an image mode only need metadata (skip without loading content).
1120
+ // Text paths always use one digest --select content spawn.
1121
+ let detectedText = false;
1122
+ if (image === undefined && isLikelyImagePath(filePath)) {
1123
+ const envelope = await runReadSeek(context, digestArgs(filePath, "metadata"));
1124
+ const metadata = digestMetadata(envelope);
1125
+ if (isPdfFile(metadata)) return documentReceipt(filePath, envelope);
1126
+ if (isVisualFile(metadata)) {
1127
+ return { file: filePath, skipped: true, reason: "image mode not selected" };
1128
+ }
1129
+ detectedText = true;
1130
+ }
1131
+
1132
+ const extra: string[] = [];
1133
+ const wantsImage = !detectedText
1134
+ && (image !== undefined || isLikelyImagePath(filePath));
1135
+ if (!wantsImage) {
1136
+ const start = (input.offset as number | undefined) ?? 1;
1137
+ const end = start + ((input.limit as number | undefined) ?? DEFAULT_READ_LIMIT) - 1;
1138
+ if (start > 1) withAt(extra, start);
1139
+ extra.push("--end", String(end));
1140
+ if (input.language) extra.push("--language", input.language as string);
1141
+ } else {
1142
+ if (input.offset !== undefined || input.limit !== undefined) {
1143
+ throw new Error("offset and limit do not apply to image reads");
1144
+ }
1145
+ if (input.language !== undefined) {
1146
+ throw new Error("language does not apply to image reads");
1147
+ }
1148
+ if (image !== undefined) extra.push("--vision-mode", image);
1149
+ }
1150
+
1151
+ let envelope: unknown;
1152
+ try {
1153
+ envelope = await runReadSeek(context, digestArgs(filePath, "content", extra));
1154
+ } catch (error) {
1155
+ // A visual or document file with an uncommon extension rejects text bounds before
1156
+ // an envelope can be returned. Probe metadata only on this cold path.
1157
+ if (image !== undefined) throw error;
1158
+ if (isDocumentBoundsError(error)) {
1159
+ return digestDocumentReceipt(context, filePath);
1160
+ }
1161
+ if (isVisualBoundsError(error)) {
1162
+ const metadataEnvelope = await runReadSeek(context, digestArgs(filePath, "metadata"));
1163
+ if (isPdfFile(digestMetadata(metadataEnvelope))) {
1164
+ return documentReceipt(filePath, metadataEnvelope);
1165
+ }
1166
+ if (isVisualFile(digestMetadata(metadataEnvelope))) {
1167
+ return { file: filePath, skipped: true, reason: "image mode not selected" };
1168
+ }
1169
+ }
1170
+ throw error;
1171
+ }
1172
+ const metadata = digestMetadata(envelope);
1173
+ if (isPdfFile(metadata) || record(envelope).cache !== undefined) {
1174
+ return documentReceipt(filePath, envelope);
1175
+ }
1176
+ const visual = isVisualFile(metadata);
1177
+
1107
1178
  if (visual && image === undefined) {
1108
1179
  return { file: filePath, skipped: true, reason: "image mode not selected" };
1109
1180
  }
1110
1181
  if (visual && (input.offset !== undefined || input.limit !== undefined)) {
1111
- throw new Error("offset and limit do not apply to image or PDF reads");
1182
+ throw new Error("offset and limit do not apply to image reads");
1112
1183
  }
1113
1184
  if (visual && input.language !== undefined) {
1114
- throw new Error("language does not apply to image or PDF reads");
1185
+ throw new Error("language does not apply to image reads");
1115
1186
  }
1116
- if (!pdf && input.page !== undefined) throw new Error("page applies to PDF reads only");
1117
1187
 
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);
1188
+ return requireDigestFacet(envelope, "content");
1126
1189
  },
1127
1190
  formatReadResult,
1128
1191
  ),
1129
1192
  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.",
1193
+ "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
1194
  {
1132
- path: tool.schema.string().describe("PDF path relative to the project directory"),
1195
+ path: tool.schema.string().describe("Document path relative to the project directory"),
1133
1196
  node: tool.schema.string().min(1).optional().describe("Node ID to use as the view root"),
1134
1197
  page: tool.schema.number().int().positive().optional().describe("One-based source page"),
1135
1198
  kind: tool.schema.enum(DOCUMENT_NODE_KINDS).optional().describe("Node kind filter"),
1136
1199
  depth: tool.schema.number().int().nonnegative().optional().describe("Maximum depth below selected roots"),
1137
1200
  outline: tool.schema.boolean().optional().describe("Return outline nodes only"),
1201
+ visionMode: tool.schema.enum(VISION_ANALYSIS_MODES).optional()
1202
+ .describe("Analyze selected assets: all, ocr, caption, or objects"),
1203
+ visionLevel: tool.schema.enum(VISION_LEVELS).optional()
1204
+ .describe("Vision inference level: low (default), medium, or high"),
1138
1205
  },
1139
1206
  "view",
1140
1207
  async (input, context) => {
1141
1208
  const filePath = resolvePath(context.directory, input.path as string);
1142
1209
  await authorizeRead(context, filePath);
1210
+ const visionMode = input.visionMode as VisionAnalysisMode | undefined;
1211
+ const visionLevel = input.visionLevel as VisionLevel | undefined;
1212
+ if (visionLevel !== undefined && visionMode === undefined) {
1213
+ throw new Error("visionLevel requires visionMode");
1214
+ }
1215
+ if (input.outline && (visionMode !== undefined || visionLevel !== undefined)) {
1216
+ throw new Error("vision options cannot be combined with outline");
1217
+ }
1143
1218
  const cacheDir = await ensureReadSeekCache(context);
1144
1219
  const args = ["--readseek-dir", cacheDir, "view", filePath];
1145
1220
  if (input.node) args.push("--node", input.node as string);
@@ -1147,22 +1222,46 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1147
1222
  if (input.kind) args.push("--kind", input.kind as string);
1148
1223
  if (input.depth !== undefined) args.push("--depth", String(input.depth));
1149
1224
  optionalFlag(args, input.outline as boolean | undefined, "--outline");
1225
+ if (visionMode !== undefined) args.push("--vision-mode", visionMode);
1226
+ if (visionLevel !== undefined) args.push("--vision-level", visionLevel);
1150
1227
  return runReadSeekRaw(context, args);
1151
1228
  },
1152
1229
  (result) => ({ output: boundDocumentOutput(String(result)) }),
1153
1230
  ),
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.",
1231
+ readseek_digest: readseekTool(
1232
+ "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
1233
  {
1157
1234
  path: tool.schema.string().describe("Path relative to the project directory"),
1235
+ select: tool.schema.string().optional()
1236
+ .describe("Comma-separated facets: metadata, content, map, diagnostics, identity; defaults to content"),
1237
+ at: tool.schema.string().optional()
1238
+ .describe("Location: line:N[:COLUMN], hash:HASH[:COLUMN], or symbol:NAME"),
1239
+ end: tool.schema.number().int().positive().optional().describe("Last line to include; content facet only"),
1240
+ limit: tool.schema.number().int().positive().optional().describe("Maximum number of lines; content facet only"),
1158
1241
  language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
1242
+ visionMode: tool.schema.enum(["none", ...VISION_ANALYSIS_MODES] as [ImageMode, ...ImageMode[]]).optional()
1243
+ .describe("Standalone image mode for the content facet: none, all, ocr, caption, or objects"),
1244
+ visionLevel: tool.schema.enum(VISION_LEVELS).optional()
1245
+ .describe("Vision inference level: low (default), medium, or high; content facet only"),
1159
1246
  },
1160
- "map",
1247
+ "digest",
1161
1248
  async (input, context) => {
1162
1249
  const filePath = resolvePath(context.directory, input.path as string);
1163
1250
  await authorizeRead(context, filePath);
1164
- const args = ["map", filePath];
1251
+ const select = ((input.select as string | undefined) ?? "content").trim() || "content";
1252
+ const visionMode = input.visionMode as ImageMode | undefined;
1253
+ const visionLevel = input.visionLevel as VisionLevel | undefined;
1254
+ if (visionLevel !== undefined && visionMode === undefined) {
1255
+ throw new Error("visionLevel requires visionMode");
1256
+ }
1257
+ const cacheDir = await ensureReadSeekCache(context);
1258
+ const args = ["--readseek-dir", cacheDir, ...digestArgs(filePath, select)];
1259
+ if (input.at) args.push("--at", input.at as string);
1260
+ if (input.end !== undefined) args.push("--end", String(input.end));
1261
+ if (input.limit !== undefined) args.push("--limit", String(input.limit));
1165
1262
  if (input.language) args.push("--language", input.language as string);
1263
+ if (visionMode !== undefined) args.push("--vision-mode", visionMode);
1264
+ if (visionLevel !== undefined) args.push("--vision-level", visionLevel);
1166
1265
  return runReadSeek(context, args);
1167
1266
  },
1168
1267
  ),
@@ -1235,7 +1334,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1235
1334
  }
1236
1335
  const linesByNumber = new Map<number, unknown>();
1237
1336
  for (const [start, end] of mergedRanges) {
1238
- const output = record(await runReadSeek(context, ["read", `${filePath}:${start}`, "--end", String(end)]));
1337
+ const output = record(await runDigestContent(context, readRangeArgs(filePath, start, end)));
1239
1338
  for (const hashline of items(output.hashlines)) {
1240
1339
  const line = record(hashline).line;
1241
1340
  if (typeof line === "number" && !linesByNumber.has(line)) linesByNumber.set(line, hashline);
@@ -1312,34 +1411,18 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1312
1411
  throw new Error("line and column require scope");
1313
1412
  }
1314
1413
  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));
1414
+ const args = ["refs", target];
1415
+ if (input.scope) {
1416
+ withAt(args, input.line as number, input.column as number | undefined);
1417
+ } else {
1418
+ args.push(input.name as string);
1419
+ }
1319
1420
  if (input.language) args.push("--language", input.language as string);
1320
1421
  withSearchFlags(args, input);
1321
1422
  await authorizeSearch(context, target, input.name as string);
1322
1423
  return runReadSeek(context, args);
1323
1424
  },
1324
1425
  ),
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
1426
  readseek_rename: readseekTool(
1344
1427
  "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
1428
  {
@@ -1355,14 +1438,16 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1355
1438
  const filePath = resolvePath(context.directory, input.path as string);
1356
1439
  await authorizeRead(context, filePath);
1357
1440
  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));
1441
+ const identifyArgs = digestArgs(filePath, "identity");
1442
+ withAt(identifyArgs, input.line as number, input.column as number | undefined);
1443
+ const envelope = await runReadSeek(context, identifyArgs);
1444
+ const name = identifiedName(requireDigestFacet(envelope, "identity"));
1361
1445
  if (!name) throw new Error("readseek could not identify a binding at the rename cursor");
1362
1446
  await authorizeSearch(context, context.directory, name);
1363
1447
  }
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));
1448
+ const args = ["rename", filePath];
1449
+ withAt(args, input.line as number, input.column as number | undefined);
1450
+ args.push("--to", input.to as string);
1366
1451
  if (input.workspace) args.push("--workspace", context.directory);
1367
1452
  const plan = await runReadSeek(context, args);
1368
1453
  if (input.apply === false) return plan;
@@ -1376,7 +1461,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1376
1461
  },
1377
1462
  ),
1378
1463
  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.",
1464
+ "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
1465
  {
1381
1466
  path: tool.schema.string().describe("Path relative to the project directory"),
1382
1467
  edits: tool.schema.array(tool.schema.union([
@@ -1399,31 +1484,60 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1399
1484
  new_text: tool.schema.string().describe("Text to insert after the anchored line"),
1400
1485
  }).strict(),
1401
1486
  }).strict(),
1402
- ])).min(1).describe("Anchored edits: set_line, replace_lines, or insert_after"),
1487
+ tool.schema.object({
1488
+ replace: tool.schema.object({
1489
+ old_text: tool.schema.string().describe("Exact text to find"),
1490
+ new_text: tool.schema.string().describe("Replacement text"),
1491
+ all: tool.schema.boolean().optional().describe("Replace every exact match"),
1492
+ }).strict(),
1493
+ }).strict(),
1494
+ tool.schema.object({
1495
+ replace_symbol: tool.schema.object({
1496
+ symbol: tool.schema.string().describe("Mapped symbol name"),
1497
+ new_body: tool.schema.string().describe("Complete replacement body for the symbol"),
1498
+ }).strict(),
1499
+ }).strict(),
1500
+ ])).min(1).describe("Use set_line, replace_lines, insert_after, replace, or replace_symbol"),
1501
+ language: tool.schema.string().optional().describe("Language override for symbol replacement"),
1403
1502
  },
1404
1503
  "edit",
1405
1504
  async (input, context) => {
1406
1505
  const filePath = resolvePath(context.directory, input.path as string);
1407
- validateAnchorEdits(input.edits);
1506
+ validateEdits(input.edits);
1408
1507
  return withFileMutationQueue(filePath, async () => {
1409
1508
  context.abort.throwIfAborted();
1410
1509
  await authorizeRead(context, filePath);
1411
1510
  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);
1511
+ const edits = input.edits as EditItem[];
1512
+ const request = JSON.stringify({ edits });
1513
+ const baseArgs = ["edit", filePath];
1514
+ if (input.language) baseArgs.push("--language", input.language as string);
1515
+ const plan = record(await runReadSeek(context, baseArgs, { stdin: request }));
1516
+ if (
1517
+ typeof plan.before_content !== "string" ||
1518
+ typeof plan.content !== "string" ||
1519
+ typeof plan.plan_hash !== "string"
1520
+ ) {
1521
+ throw new Error("readseek edit plan is missing before_content, content, or plan_hash");
1522
+ }
1523
+ const before = plan.before_content;
1524
+ const after = plan.content;
1525
+ const previewStart = Math.max(1, firstChangedLine(before, after) - 3);
1526
+ const previewArgs = readRangeArgs(filePath, previewStart, previewStart + EDIT_RESULT_READ_LIMIT - 1);
1527
+ if (before === after) {
1528
+ return postMutationPreview(context, previewArgs, filePath, false);
1529
+ }
1416
1530
  await authorizeEdit(context, [filePath], simpleDiff(filePath, before, after), false);
1417
1531
  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(
1532
+ const applied = record(await runReadSeek(
1422
1533
  context,
1423
- ["read", `${filePath}:${previewStart}`, "--end", String(previewStart + EDIT_RESULT_READ_LIMIT - 1)],
1424
- filePath,
1425
- before !== after,
1426
- );
1534
+ [...baseArgs, "--apply", "--plan-hash", plan.plan_hash],
1535
+ { cancelable: false, stdin: request },
1536
+ ));
1537
+ if (applied.applied !== true) {
1538
+ throw new Error("readseek did not confirm that the edit was applied");
1539
+ }
1540
+ return postMutationPreview(context, previewArgs, filePath, true);
1427
1541
  });
1428
1542
  },
1429
1543
  ),
@@ -1448,28 +1562,13 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1448
1562
  await writeText(filePath, before, content);
1449
1563
  return postMutationPreview(
1450
1564
  context,
1451
- ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)],
1565
+ digestArgs(filePath, "content", ["--end", String(DEFAULT_READ_LIMIT)]),
1452
1566
  filePath,
1453
1567
  !before.exists || before.content !== content,
1454
1568
  );
1455
1569
  });
1456
1570
  },
1457
1571
  ),
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
1572
  },
1474
1573
  "experimental.chat.system.transform": async (_input, output) => {
1475
1574
  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.0",
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.0",
35
35
  "@opencode-ai/plugin": "^1.17.20"
36
36
  },
37
37
  "devDependencies": {