opencode-readseek 0.6.3 → 0.6.5

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 +11 -8
  2. package/index.ts +355 -55
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,8 +1,7 @@
1
1
  # opencode-readseek
2
2
 
3
- `opencode-readseek` exposes ReadSeek's hash-anchored reads and structural code
4
- navigation as OpenCode tools. It intentionally does not replace OpenCode's
5
- built-in `read`, `edit`, or `write` tools.
3
+ `opencode-readseek` exposes ReadSeek's hash-anchored text tools and structural
4
+ code navigation as OpenCode tools.
6
5
 
7
6
  ## Installation
8
7
 
@@ -20,16 +19,20 @@ binary dependency with Bun at startup.
20
19
  ## Tools
21
20
 
22
21
  - `readseek_read`: read text with `LINE:HASH` anchors; image/PDF handling is explicit.
22
+ - `readseek_edit`: apply line, range, and insertion edits with fresh `LINE:HASH` anchors.
23
+ - `readseek_write`: create or replace a complete text file.
24
+ - `readseek_grep`: plain-text or regular-expression search with anchored results.
23
25
  - `readseek_map`: generate a structural symbol map.
24
26
  - `readseek_search`: AST-pattern search.
25
27
  - `readseek_def`, `readseek_refs`, `readseek_hover`: symbol navigation.
26
- - `readseek_rename`: generate a rename plan without writing files.
28
+ - `readseek_rename`: atomically apply a verified rename by default; `apply: false` returns a dry-run plan.
27
29
  - `readseek_check`: parse diagnostics.
28
30
 
29
- The plugin discards remembered anchors after OpenCode reports `file.edited`,
30
- records anchors from successful ReadSeek tool results, refuses any attempt to
31
- apply a rename directly, and adds current anchors plus a pending rename plan to
32
- the OpenCode compaction context.
31
+ The plugin asks for OpenCode read, grep, external-directory, and edit permissions
32
+ as appropriate. It discards remembered anchors after file changes, records only
33
+ results that contain actual hashlines, and adds current anchors plus pending
34
+ dry-run rename plans to the OpenCode compaction context. Text reads return at
35
+ most 2000 lines by default.
33
36
 
34
37
  `imageMode` defaults to `"auto"`: it exposes `none`, `ocr`, `caption`, and
35
38
  `objects`. `"on"` omits `none`; `"off"` skips image/PDF files. Omitting
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Copyright (C) Jarkko Sakkinen 2026
3
3
 
4
- import { stat } from "node:fs/promises";
4
+ import { lstat, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
5
5
  import { createRequire } from "node:module";
6
6
  import path from "node:path";
7
7
 
@@ -9,6 +9,7 @@ import { tool, type Plugin, type PluginOptions, type ToolContext } from "@openco
9
9
 
10
10
  const require = createRequire(import.meta.url);
11
11
  const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
12
+ const DEFAULT_READ_LIMIT = 2000;
12
13
  const READSEEK_PLATFORM_PACKAGES: Record<string, string> = {
13
14
  "darwin-arm64": "@jarkkojs/readseek-darwin-arm64",
14
15
  "linux-arm64": "@jarkkojs/readseek-linux-arm64",
@@ -21,7 +22,7 @@ type RenamePlan = {
21
22
  files: Set<string>;
22
23
  };
23
24
 
24
- type PresentationKind = "read" | "map" | "search" | "def" | "refs" | "hover" | "rename" | "check";
25
+ type PresentationKind = "read" | "map" | "search" | "grep" | "def" | "refs" | "hover" | "rename" | "edit" | "write" | "check";
25
26
 
26
27
  type Presentation = {
27
28
  title: string;
@@ -73,6 +74,10 @@ class SessionAnchors {
73
74
  planRename(sessionID: string, output: unknown): void {
74
75
  if (!output || typeof output !== "object") return;
75
76
  const record = output as Record<string, unknown>;
77
+ if (record.applied === true) {
78
+ this.#renamePlans.delete(sessionID);
79
+ return;
80
+ }
76
81
  const { file, old_name: oldName, new_name: newName, others } = record;
77
82
  if (typeof file !== "string" || typeof oldName !== "string" || typeof newName !== "string") return;
78
83
 
@@ -111,13 +116,34 @@ function containsPath(directory: string, filePath: string): boolean {
111
116
  return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
112
117
  }
113
118
 
119
+ async function canonicalPath(filePath: string): Promise<string> {
120
+ const missing: string[] = [];
121
+ let existing = filePath;
122
+ while (true) {
123
+ try {
124
+ return path.join(await realpath(existing), ...missing.reverse());
125
+ } catch (error) {
126
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
127
+ const parent = path.dirname(existing);
128
+ if (parent === existing) throw error;
129
+ missing.push(path.basename(existing));
130
+ existing = parent;
131
+ }
132
+ }
133
+ }
134
+
114
135
  async function authorizeExternal(context: ToolContext, filePath: string): Promise<void> {
115
- if (containsPath(context.directory, filePath) || (context.worktree !== "/" && containsPath(context.worktree, filePath))) {
136
+ const [accessPath, directory, worktree] = await Promise.all([
137
+ canonicalPath(filePath),
138
+ canonicalPath(context.directory),
139
+ context.worktree === "/" ? Promise.resolve("/") : canonicalPath(context.worktree),
140
+ ]);
141
+ if (containsPath(directory, accessPath) || (worktree !== "/" && containsPath(worktree, accessPath))) {
116
142
  return;
117
143
  }
118
144
 
119
- const info = await stat(filePath).catch(() => undefined);
120
- const parentDir = info?.isDirectory() ? filePath : path.dirname(filePath);
145
+ const info = await stat(accessPath).catch(() => undefined);
146
+ const parentDir = info?.isDirectory() ? accessPath : path.dirname(accessPath);
121
147
  const pattern = path.join(parentDir, "*").replaceAll("\\", "/");
122
148
  await context.ask({
123
149
  permission: "external_directory",
@@ -127,6 +153,14 @@ async function authorizeExternal(context: ToolContext, filePath: string): Promis
127
153
  });
128
154
  }
129
155
 
156
+ async function rejectSymlinkMutation(filePath: string): Promise<void> {
157
+ const info = await lstat(filePath).catch((error: NodeJS.ErrnoException) => {
158
+ if (error.code === "ENOENT") return undefined;
159
+ throw error;
160
+ });
161
+ if (info?.isSymbolicLink()) throw new Error(`refusing to modify symbolic link: ${filePath}`);
162
+ }
163
+
130
164
  async function authorizeRead(context: ToolContext, filePath: string): Promise<void> {
131
165
  await authorizeExternal(context, filePath);
132
166
  await context.ask({
@@ -147,6 +181,24 @@ async function authorizeSearch(context: ToolContext, filePath: string, pattern:
147
181
  await authorizeExternal(context, filePath);
148
182
  }
149
183
 
184
+ async function authorizeEdit(
185
+ context: ToolContext,
186
+ filePaths: string[],
187
+ diff = "",
188
+ authorizeExternalPaths = true,
189
+ ): Promise<void> {
190
+ if (authorizeExternalPaths) {
191
+ for (const filePath of filePaths) await authorizeExternal(context, filePath);
192
+ }
193
+ const patterns = filePaths.map((filePath) => path.relative(context.worktree, filePath).replaceAll("\\", "/"));
194
+ await context.ask({
195
+ permission: "edit",
196
+ patterns,
197
+ always: ["*"],
198
+ metadata: { filepath: filePaths.join(", "), diff },
199
+ });
200
+ }
201
+
150
202
  function optionalFlag(args: string[], enabled: boolean | undefined, flag: string): void {
151
203
  if (enabled) args.push(flag);
152
204
  }
@@ -192,17 +244,115 @@ function render(value: unknown): string {
192
244
  return JSON.stringify(value, null, 2);
193
245
  }
194
246
 
195
- function collectFiles(value: unknown, files: Set<string>): void {
247
+ function collectAnchoredFiles(value: unknown, files: Set<string>): void {
196
248
  if (Array.isArray(value)) {
197
- for (const item of value) collectFiles(item, files);
249
+ for (const item of value) collectAnchoredFiles(item, files);
198
250
  return;
199
251
  }
200
252
  if (!value || typeof value !== "object") return;
201
253
 
202
- for (const [key, item] of Object.entries(value)) {
203
- if (key === "file" && typeof item === "string") files.add(item);
204
- else collectFiles(item, files);
254
+ const output = value as Record<string, unknown>;
255
+ const hashlines = output.hashlines;
256
+ if (
257
+ typeof output.file === "string" &&
258
+ Array.isArray(hashlines) &&
259
+ hashlines.some((line) => typeof record(line).line === "number" && typeof record(line).hash === "string")
260
+ ) files.add(output.file);
261
+ for (const item of Object.values(output)) collectAnchoredFiles(item, files);
262
+ }
263
+
264
+ type AnchorEdit =
265
+ | { set_line: { anchor: string; new_text: string } }
266
+ | { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
267
+ | { insert_after: { anchor: string; new_text: string } };
268
+
269
+ function parseAnchor(anchor: string): { line: number; hash: string } {
270
+ const match = /^(\d+):([0-9a-fA-F]{3})$/.exec(anchor.trim());
271
+ if (!match) throw new Error(`invalid LINE:HASH anchor: ${anchor}`);
272
+ return { line: Number(match[1]), hash: match[2].toLowerCase() };
273
+ }
274
+
275
+ function anchorRefs(edit: AnchorEdit): { line: number; hash: string }[] {
276
+ if ("set_line" in edit) return [parseAnchor(edit.set_line.anchor)];
277
+ if ("insert_after" in edit) return [parseAnchor(edit.insert_after.anchor)];
278
+ return [parseAnchor(edit.replace_lines.start_anchor), parseAnchor(edit.replace_lines.end_anchor)];
279
+ }
280
+
281
+ async function verifyAnchors(context: ToolContext, filePath: string, edits: AnchorEdit[]): Promise<void> {
282
+ const refs = new Map<number, string>();
283
+ for (const edit of edits) {
284
+ for (const ref of anchorRefs(edit)) {
285
+ const previous = refs.get(ref.line);
286
+ if (previous !== undefined && previous !== ref.hash) throw new Error(`conflicting hashes for line ${ref.line}`);
287
+ refs.set(ref.line, ref.hash);
288
+ }
205
289
  }
290
+ for (const [line, expected] of refs) {
291
+ const output = record(await runReadSeek(context, ["read", `${filePath}:${line}`, "--end", String(line)]));
292
+ const hashline = items(output.hashlines).map(record)[0];
293
+ const actual = hashline?.hash;
294
+ if (typeof actual !== "string" || actual !== expected) {
295
+ throw new Error(`stale anchor ${line}:${expected}; current hash is ${typeof actual === "string" ? actual : "unavailable"}`);
296
+ }
297
+ }
298
+ }
299
+
300
+ function applyAnchorEdits(content: string, edits: AnchorEdit[]): string {
301
+ const newline = content.includes("\r\n") ? "\r\n" : "\n";
302
+ const trailingNewline = content.endsWith("\n");
303
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
304
+ if (trailingNewline) lines.pop();
305
+ const planned = edits.map((edit) => {
306
+ if ("set_line" in edit) {
307
+ const { line } = parseAnchor(edit.set_line.anchor);
308
+ return { start: line - 1, deleteCount: 1, text: edit.set_line.new_text };
309
+ }
310
+ if ("insert_after" in edit) {
311
+ const { line } = parseAnchor(edit.insert_after.anchor);
312
+ return { start: line, deleteCount: 0, text: edit.insert_after.new_text };
313
+ }
314
+ const start = parseAnchor(edit.replace_lines.start_anchor).line;
315
+ const end = parseAnchor(edit.replace_lines.end_anchor).line;
316
+ if (end < start) throw new Error("replace_lines end anchor precedes start anchor");
317
+ return { start: start - 1, deleteCount: end - start + 1, text: edit.replace_lines.new_text };
318
+ });
319
+ const ascending = [...planned].sort((left, right) => left.start - right.start || left.deleteCount - right.deleteCount);
320
+ for (let index = 1; index < ascending.length; index++) {
321
+ const previous = ascending[index - 1];
322
+ const current = ascending[index];
323
+ const previousEnd = previous.start + Math.max(previous.deleteCount, 1);
324
+ if (current.start < previousEnd) throw new Error("anchored edits overlap or target the same location");
325
+ }
326
+ planned.sort((left, right) => right.start - left.start);
327
+ for (const edit of planned) {
328
+ if (edit.start < 0 || edit.start > lines.length || edit.start + edit.deleteCount > lines.length) {
329
+ throw new Error("anchor line is outside the file");
330
+ }
331
+ const replacement = edit.text === "" ? [] : edit.text.replace(/\r\n/g, "\n").split("\n");
332
+ lines.splice(edit.start, edit.deleteCount, ...replacement);
333
+ }
334
+ return lines.join(newline) + (trailingNewline ? newline : "");
335
+ }
336
+
337
+ async function writeText(filePath: string, content: string): Promise<void> {
338
+ await mkdir(path.dirname(filePath), { recursive: true });
339
+ await writeFile(filePath, content, "utf8");
340
+ }
341
+
342
+ function renameFiles(value: unknown): string[] {
343
+ const output = record(value);
344
+ const files: string[] = [];
345
+ if (typeof output.file === "string" && items(output.edits).length > 0) files.push(path.resolve(output.file));
346
+ for (const other of items(output.others)) {
347
+ const item = record(other);
348
+ if (typeof item.file === "string" && items(item.edits).length > 0) files.push(path.resolve(item.file));
349
+ }
350
+ return files;
351
+ }
352
+
353
+ function simpleDiff(filePath: string, before: string, after: string): string {
354
+ if (before === after) return "";
355
+ return `--- ${filePath}\n+++ ${filePath}\n@@ full file @@\n-${before}\n+${after}`;
206
356
  }
207
357
 
208
358
  function identifiedName(value: unknown): string | undefined {
@@ -229,6 +379,8 @@ function initialTitle(kind: PresentationKind, args: any): string {
229
379
  return `Map ${args.path}`;
230
380
  case "search":
231
381
  return `Search ${args.pattern}`;
382
+ case "grep":
383
+ return `Grep ${args.pattern}`;
232
384
  case "def":
233
385
  return `Find definition ${args.name}`;
234
386
  case "refs":
@@ -237,6 +389,10 @@ function initialTitle(kind: PresentationKind, args: any): string {
237
389
  return `Identify ${args.path}:${args.line}`;
238
390
  case "rename":
239
391
  return `Rename ${args.path}:${args.line} to ${args.to}`;
392
+ case "edit":
393
+ return `Edit ${args.path}`;
394
+ case "write":
395
+ return `Write ${args.path}`;
240
396
  case "check":
241
397
  return `Check ${args.path}`;
242
398
  }
@@ -270,6 +426,11 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
270
426
  const matches = results.reduce<number>((total, result) => total + items(record(result).matches).length, 0);
271
427
  return { title: `Found ${matches} matches`, metadata: { results: results.length, matches } };
272
428
  }
429
+ case "grep": {
430
+ const results = items(output.results);
431
+ const matches = results.reduce<number>((total, result) => total + items(record(result).matches).length, 0);
432
+ return { title: `Found ${matches} matches`, metadata: { results: results.length, matches } };
433
+ }
273
434
  case "def": {
274
435
  const locations = items(output.locations).length;
275
436
  return { title: `Found ${locations} definitions`, metadata: { locations } };
@@ -299,10 +460,13 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
299
460
  const others = otherOutputs.length;
300
461
  const title =
301
462
  typeof oldName === "string" && typeof newName === "string"
302
- ? `Plan ${oldName} -> ${newName}`
463
+ ? `${output.applied === true ? "Renamed" : "Plan"} ${oldName} -> ${newName}`
303
464
  : initialTitle(kind, args);
304
465
  return { title, metadata: { edits, conflicts, others } };
305
466
  }
467
+ case "edit":
468
+ case "write":
469
+ return { title: `${kind === "edit" ? "Edited" : "Wrote"} ${args.path}`, metadata: {} };
306
470
  case "check": {
307
471
  const errors = typeof output.error_count === "number" ? output.error_count : 0;
308
472
  const missing = typeof output.missing_count === "number" ? output.missing_count : 0;
@@ -355,17 +519,18 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
355
519
  tool: {
356
520
  readseek_read: readseekTool(
357
521
  imagePolicy === "off"
358
- ? "Read text with stable LINE:HASH anchors. Image and PDF files are skipped."
359
- : `Read text with stable LINE:HASH anchors. For images and PDFs, explicitly select image: ${imageModes.join(", ")}; omitting image skips the file.`,
522
+ ? "Read text with LINE:HASH anchors for durable references. Images and PDFs are skipped."
523
+ : `Read text with LINE:HASH anchors for durable references. For images or PDFs, select a mode (${imageModes.join(", ")}) or omit image to skip.`,
360
524
  {
361
525
  path: tool.schema.string().describe("Path relative to the project directory"),
362
526
  offset: tool.schema.number().int().positive().optional().describe("One-based starting line"),
363
- limit: tool.schema.number().int().positive().optional().describe("Maximum number of lines"),
527
+ limit: tool.schema.number().int().positive().optional().describe(`Maximum number of lines; defaults to ${DEFAULT_READ_LIMIT}`),
528
+ language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
364
529
  ...(imagePolicy === "off"
365
530
  ? {}
366
531
  : {
367
532
  image: tool.schema.enum(imageModes as [ImageMode, ...ImageMode[]]).optional()
368
- .describe(`Image/PDF mode: ${imageModes.join(", ")}. Must be selected explicitly.`),
533
+ .describe("Image or PDF processing mode"),
369
534
  }),
370
535
  },
371
536
  "read",
@@ -382,30 +547,100 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
382
547
  }
383
548
  }
384
549
  const args = ["read", input.offset === undefined ? filePath : `${filePath}:${input.offset}`];
385
- if (input.limit !== undefined) args.push("--end", String((input.offset ?? 1) + input.limit - 1));
550
+ if (image === undefined) {
551
+ args.push("--end", String((input.offset ?? 1) + ((input.limit as number | undefined) ?? DEFAULT_READ_LIMIT) - 1));
552
+ }
553
+ if (input.language) args.push("--language", input.language as string);
386
554
  if (image !== undefined) args.push("--image", image);
387
555
  return runReadSeek(context, args);
388
556
  },
389
557
  ),
390
558
  readseek_map: readseekTool(
391
- "Build a structural symbol map for a source file.",
392
- { path: tool.schema.string().describe("Path relative to the project directory") },
559
+ "List symbols and ranges in a source file. Use to inspect structure without reading the full file.",
560
+ {
561
+ path: tool.schema.string().describe("Path relative to the project directory"),
562
+ language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
563
+ },
393
564
  "map",
394
565
  async (input, context) => {
395
566
  const filePath = resolvePath(context.directory, input.path as string);
396
567
  await authorizeRead(context, filePath);
397
- return runReadSeek(context, ["map", filePath]);
568
+ const args = ["map", filePath];
569
+ if (input.language) args.push("--language", input.language as string);
570
+ return runReadSeek(context, args);
571
+ },
572
+ ),
573
+ readseek_grep: readseekTool(
574
+ "Search text or regular expressions and return matching LINE:HASH anchors. Use literal for exact text.",
575
+ {
576
+ pattern: tool.schema.string().describe("Text or regular expression to search for"),
577
+ path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
578
+ glob: tool.schema.string().optional().describe("File-name glob, such as **/*.ts"),
579
+ literal: tool.schema.boolean().optional().describe("Treat pattern as literal text"),
580
+ ignoreCase: tool.schema.boolean().optional().describe("Ignore case"),
581
+ context: tool.schema.number().int().nonnegative().optional().describe("Surrounding lines to return"),
582
+ limit: tool.schema.number().int().positive().optional().describe("Maximum matching lines; defaults to 100"),
583
+ },
584
+ "grep",
585
+ async (input, context) => {
586
+ const target = resolvePath(context.directory, (input.path as string | undefined) ?? ".");
587
+ const pattern = input.pattern as string;
588
+ await authorizeSearch(context, target, pattern);
589
+ let expression: RegExp;
590
+ try {
591
+ const source = input.literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
592
+ expression = new RegExp(source, input.ignoreCase ? "i" : "");
593
+ } catch (error) {
594
+ throw new Error(`invalid regular expression: ${error instanceof Error ? error.message : String(error)}`);
595
+ }
596
+
597
+ const info = await stat(target);
598
+ const files: string[] = [];
599
+ if (info.isFile()) files.push(target);
600
+ else if (info.isDirectory()) {
601
+ const glob = new Bun.Glob((input.glob as string | undefined) ?? "**/*");
602
+ for await (const filePath of glob.scan({ cwd: target, absolute: true, onlyFiles: true, followSymlinks: false })) {
603
+ files.push(filePath);
604
+ }
605
+ } else throw new Error(`grep target is not a file or directory: ${target}`);
606
+
607
+ const maxMatches = (input.limit as number | undefined) ?? 100;
608
+ const contextLines = (input.context as number | undefined) ?? 0;
609
+ const results: { file: string; matches: unknown[]; hashlines: unknown[] }[] = [];
610
+ let totalMatches = 0;
611
+ for (const filePath of files.sort()) {
612
+ context.abort.throwIfAborted();
613
+ if (totalMatches >= maxMatches) break;
614
+ const buffer = await readFile(filePath);
615
+ if (buffer.includes(0)) continue;
616
+ const lines = buffer.toString("utf8").replace(/\r\n/g, "\n").split("\n");
617
+ const ranges: [number, number][] = [];
618
+ for (let index = 0; index < lines.length && totalMatches < maxMatches; index++) {
619
+ expression.lastIndex = 0;
620
+ if (!expression.test(lines[index] ?? "")) continue;
621
+ ranges.push([Math.max(1, index + 1 - contextLines), Math.min(lines.length, index + 1 + contextLines)]);
622
+ totalMatches++;
623
+ }
624
+ if (ranges.length === 0) continue;
625
+ const matches: unknown[] = [];
626
+ for (const [start, end] of ranges) {
627
+ const output = record(await runReadSeek(context, ["read", `${filePath}:${start}`, "--end", String(end)]));
628
+ matches.push(...items(output.hashlines));
629
+ }
630
+ results.push({ file: filePath, matches, hashlines: matches });
631
+ }
632
+ return { results, truncated: totalMatches >= maxMatches };
398
633
  },
399
634
  ),
400
635
  readseek_search: readseekTool(
401
- "Search source code using an ast-grep structural pattern. Results include LINE:HASH anchors.",
636
+ "Search syntax-aware code shapes with an ast-grep pattern and return LINE:HASH anchors. Use for calls, imports, declarations, JSX, or control flow; use text search for plain text.",
402
637
  {
403
- pattern: tool.schema.string().describe("AST pattern"),
638
+ pattern: tool.schema.string().describe("ast-grep pattern, such as console.log($$$ARGS)"),
404
639
  path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
405
- language: tool.schema.string().optional().describe("ast-grep language"),
406
- cached: tool.schema.boolean().optional(),
407
- others: tool.schema.boolean().optional(),
408
- ignored: tool.schema.boolean().optional(),
640
+ language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
641
+ cached: tool.schema.boolean().optional().describe("Search tracked or indexed files in a Git repository"),
642
+ others: tool.schema.boolean().optional().describe("Search untracked files in a Git repository"),
643
+ ignored: tool.schema.boolean().optional().describe("Include ignored untracked files; requires others"),
409
644
  },
410
645
  "search",
411
646
  async (input, context) => {
@@ -419,14 +654,14 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
419
654
  },
420
655
  ),
421
656
  readseek_def: readseekTool(
422
- "Find structural definitions of a symbol. Results include LINE:HASH anchors.",
657
+ "Find symbol declarations by name and return LINE:HASH anchors. Use instead of text search to locate where a function, class, type, or other symbol is defined.",
423
658
  {
424
659
  name: tool.schema.string().describe("Symbol name"),
425
660
  path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
426
- language: tool.schema.string().optional(),
427
- cached: tool.schema.boolean().optional(),
428
- others: tool.schema.boolean().optional(),
429
- ignored: tool.schema.boolean().optional(),
661
+ language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
662
+ cached: tool.schema.boolean().optional().describe("Search tracked or indexed files in a Git repository"),
663
+ others: tool.schema.boolean().optional().describe("Search untracked files in a Git repository"),
664
+ ignored: tool.schema.boolean().optional().describe("Include ignored untracked files; requires others"),
430
665
  },
431
666
  "def",
432
667
  async (input, context) => {
@@ -439,17 +674,17 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
439
674
  },
440
675
  ),
441
676
  readseek_refs: readseekTool(
442
- "Find references to an identifier. Use scope with a cursor to identify a specific binding.",
677
+ "Find identifier usages with enclosing symbols. Use before changing or deleting a symbol; add a cursor scope to exclude same-named bindings.",
443
678
  {
444
679
  name: tool.schema.string().describe("Identifier name"),
445
680
  path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
446
- language: tool.schema.string().optional(),
447
- scope: tool.schema.boolean().optional(),
448
- line: tool.schema.number().int().positive().optional(),
449
- column: tool.schema.number().int().positive().optional(),
450
- cached: tool.schema.boolean().optional(),
451
- others: tool.schema.boolean().optional(),
452
- ignored: tool.schema.boolean().optional(),
681
+ language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
682
+ scope: tool.schema.boolean().optional().describe("Restrict results to the binding at the cursor"),
683
+ line: tool.schema.number().int().positive().optional().describe("Cursor line, required with scope"),
684
+ column: tool.schema.number().int().positive().optional().describe("Cursor byte column for disambiguation"),
685
+ cached: tool.schema.boolean().optional().describe("Search tracked or indexed files in a Git repository"),
686
+ others: tool.schema.boolean().optional().describe("Search untracked files in a Git repository"),
687
+ ignored: tool.schema.boolean().optional().describe("Include ignored untracked files; requires others"),
453
688
  },
454
689
  "refs",
455
690
  async (input, context) => {
@@ -469,12 +704,12 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
469
704
  },
470
705
  ),
471
706
  readseek_hover: readseekTool(
472
- "Identify the token and enclosing symbol at a source cursor.",
707
+ "Identify the token and enclosing symbol at a cursor. Use before definition lookup or rename, or to identify a line's enclosing symbol.",
473
708
  {
474
709
  path: tool.schema.string().describe("Path relative to the project directory"),
475
710
  line: tool.schema.number().int().positive().describe("One-based cursor line"),
476
711
  column: tool.schema.number().int().positive().optional().describe("One-based cursor byte column"),
477
- language: tool.schema.string().optional(),
712
+ language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
478
713
  },
479
714
  "hover",
480
715
  async (input, context) => {
@@ -487,13 +722,14 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
487
722
  },
488
723
  ),
489
724
  readseek_rename: readseekTool(
490
- "Plan a binding-aware rename. This tool never writes files; apply the returned plan through OpenCode's normal edit tools.",
725
+ "Rename a symbol without changing same-named bindings. Applies verified edits atomically by default; set apply false for a dry-run plan.",
491
726
  {
492
727
  path: tool.schema.string().describe("Path relative to the project directory"),
493
- line: tool.schema.number().int().positive().describe("One-based cursor line of the binding"),
494
- column: tool.schema.number().int().positive().optional().describe("One-based cursor byte column"),
495
- to: tool.schema.string().min(1).describe("New binding name"),
728
+ line: tool.schema.number().int().positive().describe("One-based line of the symbol to rename"),
729
+ column: tool.schema.number().int().positive().optional().describe("One-based byte column for disambiguation"),
730
+ to: tool.schema.string().min(1).describe("New symbol name"),
496
731
  workspace: tool.schema.boolean().optional().describe("Include project-wide occurrences"),
732
+ apply: tool.schema.boolean().optional().describe("Apply verified edits atomically; defaults to true"),
497
733
  },
498
734
  "rename",
499
735
  async (input, context) => {
@@ -509,17 +745,81 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
509
745
  const args = ["rename", filePath, "--line", String(input.line), "--to", input.to as string];
510
746
  if (input.column) args.push("--column", String(input.column));
511
747
  if (input.workspace) args.push("--workspace", context.directory);
512
- return runReadSeek(context, args);
748
+ const plan = await runReadSeek(context, args);
749
+ if (input.apply === false) return plan;
750
+ const files = renameFiles(plan);
751
+ if (input.workspace) {
752
+ await context.ask({
753
+ permission: "edit",
754
+ patterns: ["**"],
755
+ always: ["**"],
756
+ metadata: { filepath: files.join(", "), diff: "" },
757
+ });
758
+ } else {
759
+ await authorizeEdit(context, files);
760
+ }
761
+ return runReadSeek(context, [...args, "--apply"]);
762
+ },
763
+ ),
764
+ readseek_edit: readseekTool(
765
+ "Edit an existing text file using LINE:HASH anchors. Stale hashes are rejected before writing.",
766
+ {
767
+ path: tool.schema.string().describe("Path relative to the project directory"),
768
+ edits: tool.schema.array(tool.schema.union([
769
+ tool.schema.object({ set_line: tool.schema.object({ anchor: tool.schema.string(), new_text: tool.schema.string() }) }),
770
+ tool.schema.object({ replace_lines: tool.schema.object({ start_anchor: tool.schema.string(), end_anchor: tool.schema.string(), new_text: tool.schema.string() }) }),
771
+ tool.schema.object({ insert_after: tool.schema.object({ anchor: tool.schema.string(), new_text: tool.schema.string() }) }),
772
+ ])).min(1).describe("Anchored edits to apply"),
773
+ },
774
+ "edit",
775
+ async (input, context) => {
776
+ const filePath = resolvePath(context.directory, input.path as string);
777
+ await authorizeRead(context, filePath);
778
+ await rejectSymlinkMutation(filePath);
779
+ const edits = input.edits as AnchorEdit[];
780
+ const before = await readFile(filePath, "utf8");
781
+ await verifyAnchors(context, filePath, edits);
782
+ const after = applyAnchorEdits(before, edits);
783
+ await authorizeEdit(context, [filePath], simpleDiff(filePath, before, after), false);
784
+ if (await readFile(filePath, "utf8") !== before) throw new Error(`refusing to edit changed file: ${filePath}`);
785
+ if (before !== after) await writeText(filePath, after);
786
+ return runReadSeek(context, ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)]);
787
+ },
788
+ ),
789
+ readseek_write: readseekTool(
790
+ "Create or replace a whole text file and return LINE:HASH anchors.",
791
+ {
792
+ path: tool.schema.string().describe("Path relative to the project directory"),
793
+ content: tool.schema.string().describe("Complete text file content"),
794
+ },
795
+ "write",
796
+ async (input, context) => {
797
+ const filePath = resolvePath(context.directory, input.path as string);
798
+ await authorizeExternal(context, filePath);
799
+ await rejectSymlinkMutation(filePath);
800
+ const before = await readFile(filePath, "utf8").catch(() => "");
801
+ const content = input.content as string;
802
+ if (content.includes("\0")) throw new Error("write content must be text");
803
+ await authorizeEdit(context, [filePath], simpleDiff(filePath, before, content), false);
804
+ const current = await readFile(filePath, "utf8").catch(() => "");
805
+ if (current !== before) throw new Error(`refusing to overwrite changed file: ${filePath}`);
806
+ if (before !== content) await writeText(filePath, content);
807
+ return runReadSeek(context, ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)]);
513
808
  },
514
809
  ),
515
810
  readseek_check: readseekTool(
516
- "Check a source file for parser errors and missing syntax.",
517
- { path: tool.schema.string().describe("Path relative to the project directory") },
811
+ "Check a source file for parser errors and missing syntax. Use after edits for quick syntax validation, not as a compiler or linter.",
812
+ {
813
+ path: tool.schema.string().describe("Path relative to the project directory"),
814
+ language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
815
+ },
518
816
  "check",
519
817
  async (input, context) => {
520
818
  const filePath = resolvePath(context.directory, input.path as string);
521
819
  await authorizeRead(context, filePath);
522
- return runReadSeek(context, ["check", filePath]);
820
+ const args = ["check", filePath];
821
+ if (input.language) args.push("--language", input.language as string);
822
+ return runReadSeek(context, args);
523
823
  },
524
824
  ),
525
825
  },
@@ -530,19 +830,19 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
530
830
  }
531
831
  if (event.type === "session.deleted") anchors.deleteSession(event.properties.info.id);
532
832
  },
533
- "tool.execute.before": async (input, output) => {
534
- if (input.tool === "readseek_rename" && output.args.apply === true) {
535
- throw new Error("readseek_rename only creates plans; apply its edits with OpenCode's edit tools");
536
- }
537
- },
538
833
  "tool.execute.after": async (input, output) => {
539
834
  if (!input.tool.startsWith("readseek_")) return;
540
835
  try {
541
836
  const result = JSON.parse(output.output) as unknown;
542
- if (input.tool === "readseek_rename") anchors.planRename(input.sessionID, result);
837
+ if (input.tool === "readseek_rename") {
838
+ anchors.planRename(input.sessionID, result);
839
+ if (record(result).applied === true) {
840
+ for (const filePath of renameFiles(result)) anchors.forget(filePath);
841
+ }
842
+ }
543
843
 
544
844
  const files = new Set<string>();
545
- collectFiles(result, files);
845
+ collectAnchoredFiles(result, files);
546
846
  for (const filePath of files) anchors.mark(input.sessionID, path.resolve(filePath));
547
847
  } catch {
548
848
  // A failed tool result has no valid anchors to retain.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-readseek",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "OpenCode plugin for readseek-backed structural code navigation",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -30,7 +30,7 @@
30
30
  "node": ">=20.0.0"
31
31
  },
32
32
  "dependencies": {
33
- "@jarkkojs/readseek": "^0.6.3",
33
+ "@jarkkojs/readseek": "^0.6.5",
34
34
  "@opencode-ai/plugin": "^1.17.20"
35
35
  },
36
36
  "devDependencies": {