opencode-readseek 0.8.21 → 0.8.22

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 (2) hide show
  1. package/index.ts +124 -32
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -27,15 +27,15 @@ const PREFERRED_TOOL_DESCRIPTIONS: Record<string, string> = {
27
27
  readseek_read: "Preferred file reader; returns LINE:HASH anchors for durable references.",
28
28
  readseek_edit: "Preferred tool for editing existing text files with fresh LINE:HASH anchors.",
29
29
  readseek_write: "Preferred tool for creating or replacing a complete text file; returns LINE:HASH anchors.",
30
- readseek_rename: "Preferred tool for renaming a symbol while preserving same-named bindings.",
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
- readseek_search: "Preferred AST search for code shapes and call sites.",
33
- readseek_map: "Preferred symbol outline for a source file.",
34
- readseek_def: "Preferred symbol definition lookup.",
35
- readseek_refs: "Preferred identifier usage finder; use before rename or delete.",
36
- readseek_hover: "Preferred cursor token and enclosing-symbol inspector.",
37
- readseek_view: "Preferred PDF structure and content viewer.",
38
- readseek_check: "Preferred quick syntax check after source edits.",
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.",
34
+ readseek_def: "Use for go-to-definition or declaration lookup by name.",
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.",
39
39
  };
40
40
  const FALLBACK_TOOL_DESCRIPTIONS: Record<string, string> = {
41
41
  read: "Fallback only. Prefer readseek_read so edits can use LINE:HASH anchors.",
@@ -316,6 +316,39 @@ function readSeekBinaryPath(): string {
316
316
  return path.join(path.dirname(packageJson), "bin", process.platform === "win32" ? "readseek.exe" : "readseek");
317
317
  }
318
318
 
319
+ async function readBoundedText(
320
+ stream: ReadableStream<Uint8Array>,
321
+ streamName: "stdout" | "stderr",
322
+ onOverflow: () => void,
323
+ ): Promise<string> {
324
+ const reader = stream.getReader();
325
+ const chunks: Uint8Array[] = [];
326
+ let totalBytes = 0;
327
+ try {
328
+ while (true) {
329
+ const { done, value } = await reader.read();
330
+ if (done) break;
331
+ totalBytes += value.byteLength;
332
+ if (totalBytes > MAX_OUTPUT_BYTES) {
333
+ onOverflow();
334
+ void reader.cancel().catch(() => undefined);
335
+ throw new Error(`readseek ${streamName} exceeded the ${MAX_OUTPUT_BYTES}-byte output limit`);
336
+ }
337
+ chunks.push(value);
338
+ }
339
+ } finally {
340
+ reader.releaseLock();
341
+ }
342
+
343
+ const bytes = new Uint8Array(totalBytes);
344
+ let offset = 0;
345
+ for (const chunk of chunks) {
346
+ bytes.set(chunk, offset);
347
+ offset += chunk.byteLength;
348
+ }
349
+ return new TextDecoder().decode(bytes);
350
+ }
351
+
319
352
  async function runReadSeekRaw(
320
353
  context: ToolContext,
321
354
  args: string[],
@@ -325,7 +358,6 @@ async function runReadSeekRaw(
325
358
  const cancelable = options.cancelable !== false;
326
359
  const spawnOptions = {
327
360
  cwd: context.directory,
328
- maxBuffer: MAX_OUTPUT_BYTES,
329
361
  stderr: "pipe" as const,
330
362
  stdout: "pipe" as const,
331
363
  };
@@ -335,9 +367,19 @@ async function runReadSeekRaw(
335
367
  ? { ...spawnOptions, killSignal: "SIGKILL" as const, signal: context.abort }
336
368
  : spawnOptions,
337
369
  );
370
+ let killedForOverflow = false;
371
+ const killForOverflow = () => {
372
+ if (killedForOverflow) return;
373
+ killedForOverflow = true;
374
+ try {
375
+ child.kill("SIGKILL");
376
+ } catch {
377
+ // Preserve the output-limit error if the process exited concurrently.
378
+ }
379
+ };
338
380
  const [stdout, stderr, exitCode] = await Promise.all([
339
- new Response(child.stdout).text(),
340
- new Response(child.stderr).text(),
381
+ readBoundedText(child.stdout, "stdout", killForOverflow),
382
+ readBoundedText(child.stderr, "stderr", killForOverflow),
341
383
  child.exited,
342
384
  ]);
343
385
  if (cancelable) context.abort.throwIfAborted();
@@ -358,6 +400,25 @@ async function runReadSeek(
358
400
  }
359
401
  }
360
402
 
403
+ async function postMutationPreview(
404
+ context: ToolContext,
405
+ args: string[],
406
+ filePath: string,
407
+ changed: boolean,
408
+ ): Promise<unknown> {
409
+ try {
410
+ return await runReadSeek(context, args, { cancelable: false });
411
+ } catch (error) {
412
+ const previewError = error instanceof Error ? error.message : String(error);
413
+ return {
414
+ file: filePath,
415
+ changed,
416
+ preview_error: previewError,
417
+ warning: "File mutation completed, but the post-write preview failed. Do not retry the mutation; read the file to verify content and refresh anchors.",
418
+ };
419
+ }
420
+ }
421
+
361
422
  let readSeekCacheInit: Promise<string> | undefined;
362
423
 
363
424
  function readSeekCacheDir(): string {
@@ -954,8 +1015,10 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
954
1015
  return { title, metadata: { edits, conflicts, others } };
955
1016
  }
956
1017
  case "edit":
957
- case "write":
958
- return { title: `${kind === "edit" ? "Edited" : "Wrote"} ${args.path}`, metadata: {} };
1018
+ case "write": {
1019
+ const preview = typeof output.preview_error === "string" ? " (preview unavailable)" : "";
1020
+ return { title: `${kind === "edit" ? "Edited" : "Wrote"} ${args.path}${preview}`, metadata: {} };
1021
+ }
959
1022
  case "check": {
960
1023
  const errors = typeof output.error_count === "number" ? output.error_count : 0;
961
1024
  const missing = typeof output.missing_count === "number" ? output.missing_count : 0;
@@ -1064,7 +1127,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1064
1127
  formatReadResult,
1065
1128
  ),
1066
1129
  readseek_view: readseekTool(
1067
- "View PDF structure or selected content. Start with the overview, then narrow by page or node.",
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.",
1068
1131
  {
1069
1132
  path: tool.schema.string().describe("PDF path relative to the project directory"),
1070
1133
  node: tool.schema.string().min(1).optional().describe("Node ID to use as the view root"),
@@ -1089,7 +1152,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1089
1152
  (result) => ({ output: boundDocumentOutput(String(result)) }),
1090
1153
  ),
1091
1154
  readseek_map: readseekTool(
1092
- "Show a source file's symbol outline without reading the full file.",
1155
+ "Inspect a source file's symbol outline. Use this instead of reading the whole file when only declarations, names, kinds, and ranges are needed.",
1093
1156
  {
1094
1157
  path: tool.schema.string().describe("Path relative to the project directory"),
1095
1158
  language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
@@ -1141,32 +1204,55 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1141
1204
  const contextLines = (input.context as number | undefined) ?? 0;
1142
1205
  const results: { file: string; matches: unknown[]; hashlines: unknown[] }[] = [];
1143
1206
  let totalMatches = 0;
1207
+ let truncated = false;
1144
1208
  for (const filePath of files.sort()) {
1145
1209
  context.abort.throwIfAborted();
1146
- if (totalMatches >= maxMatches) break;
1210
+ if (truncated) break;
1147
1211
  const buffer = await readFile(filePath);
1148
1212
  if (buffer.includes(0)) continue;
1149
1213
  const lines = buffer.toString("utf8").replace(/\r\n/g, "\n").split("\n");
1214
+ const matchLines = new Set<number>();
1150
1215
  const ranges: [number, number][] = [];
1151
- for (let index = 0; index < lines.length && totalMatches < maxMatches; index++) {
1216
+ for (let index = 0; index < lines.length; index++) {
1152
1217
  expression.lastIndex = 0;
1153
1218
  if (!expression.test(lines[index] ?? "")) continue;
1154
- ranges.push([Math.max(1, index + 1 - contextLines), Math.min(lines.length, index + 1 + contextLines)]);
1219
+ if (totalMatches === maxMatches) {
1220
+ truncated = true;
1221
+ break;
1222
+ }
1223
+ const line = index + 1;
1224
+ matchLines.add(line);
1225
+ ranges.push([Math.max(1, line - contextLines), Math.min(lines.length, line + contextLines)]);
1155
1226
  totalMatches++;
1156
1227
  }
1157
1228
  if (ranges.length === 0) continue;
1158
- const matches: unknown[] = [];
1229
+
1230
+ const mergedRanges: [number, number][] = [];
1159
1231
  for (const [start, end] of ranges) {
1232
+ const previous = mergedRanges.at(-1);
1233
+ if (previous && start <= previous[1] + 1) previous[1] = Math.max(previous[1], end);
1234
+ else mergedRanges.push([start, end]);
1235
+ }
1236
+ const linesByNumber = new Map<number, unknown>();
1237
+ for (const [start, end] of mergedRanges) {
1160
1238
  const output = record(await runReadSeek(context, ["read", `${filePath}:${start}`, "--end", String(end)]));
1161
- matches.push(...items(output.hashlines));
1239
+ for (const hashline of items(output.hashlines)) {
1240
+ const line = record(hashline).line;
1241
+ if (typeof line === "number" && !linesByNumber.has(line)) linesByNumber.set(line, hashline);
1242
+ }
1162
1243
  }
1163
- results.push({ file: filePath, matches, hashlines: matches });
1244
+ const hashlines = [...linesByNumber.values()];
1245
+ const matches = hashlines.filter((hashline) => {
1246
+ const line = record(hashline).line;
1247
+ return typeof line === "number" && matchLines.has(line);
1248
+ });
1249
+ results.push({ file: filePath, matches, hashlines });
1164
1250
  }
1165
- return { results, truncated: totalMatches >= maxMatches };
1251
+ return { results, truncated };
1166
1252
  },
1167
1253
  ),
1168
1254
  readseek_search: readseekTool(
1169
- "Search AST patterns (ast-grep style) and return LINE:HASH anchors. Use for code structure; use readseek_grep for plain text.",
1255
+ "Search code by AST structure using ast-grep-style patterns. Results include edit-ready LINE:HASH anchors. Use readseek_grep for identifiers, strings, comments, configuration, documentation, or other plain text and regex.",
1170
1256
  {
1171
1257
  pattern: tool.schema.string().describe("AST pattern (ast-grep style), such as console.log($$$ARGS)"),
1172
1258
  path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
@@ -1187,7 +1273,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1187
1273
  },
1188
1274
  ),
1189
1275
  readseek_def: readseekTool(
1190
- "Find symbol declarations by name and return LINE:HASH anchors. Prefer this to text search for definitions.",
1276
+ "Go to a symbol definition or declaration by name. Accepts qualified or unqualified names and returns LINE:HASH anchors; use this instead of text search for functions, methods, classes, types, and other declarations.",
1191
1277
  {
1192
1278
  name: tool.schema.string().describe("Symbol name"),
1193
1279
  path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
@@ -1207,7 +1293,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1207
1293
  },
1208
1294
  ),
1209
1295
  readseek_refs: readseekTool(
1210
- "Find identifier usages with enclosing symbols. Use before renaming or deleting a symbol; add cursor scope to exclude same-named bindings.",
1296
+ "Find references or usages of an identifier by name or cursor binding. Results include enclosing symbols and edit-ready LINE:HASH anchors. Without cursor scope, results are identifier-name matches; with scope, they follow one binding and exclude shadows. Use text search for literal occurrences in strings or comments, and use this tool before renaming or deleting a symbol.",
1211
1297
  {
1212
1298
  name: tool.schema.string().describe("Identifier name"),
1213
1299
  path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
@@ -1237,7 +1323,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1237
1323
  },
1238
1324
  ),
1239
1325
  readseek_hover: readseekTool(
1240
- "Identify the token and enclosing symbol at a cursor. Use before definition lookup or rename.",
1326
+ "Inspect the symbol at a source location. This identifies the token and enclosing symbol; use it before definition lookup or binding-specific references.",
1241
1327
  {
1242
1328
  path: tool.schema.string().describe("Path relative to the project directory"),
1243
1329
  line: tool.schema.number().int().positive().describe("One-based cursor line"),
@@ -1255,14 +1341,14 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1255
1341
  },
1256
1342
  ),
1257
1343
  readseek_rename: readseekTool(
1258
- "Rename the symbol at a cursor while preserving same-named bindings. Applies verified edits atomically by default; set apply to false for a dry-run plan.",
1344
+ "Rename a symbol at a source location. Resolvable same-named bindings are preserved; verified edits apply by default. Set `apply` to false to preview the plan before changing files.",
1259
1345
  {
1260
1346
  path: tool.schema.string().describe("Path relative to the project directory"),
1261
1347
  line: tool.schema.number().int().positive().describe("One-based cursor line of the symbol to rename"),
1262
1348
  column: tool.schema.number().int().positive().optional().describe("One-based cursor byte column for disambiguation"),
1263
1349
  to: tool.schema.string().min(1).describe("New symbol name"),
1264
1350
  workspace: tool.schema.boolean().optional().describe("Include project-wide occurrences"),
1265
- apply: tool.schema.boolean().optional().describe("Apply verified edits atomically; defaults to true"),
1351
+ apply: tool.schema.boolean().optional().describe("Apply verified edits; defaults to true"),
1266
1352
  },
1267
1353
  "rename",
1268
1354
  async (input, context) => {
@@ -1326,10 +1412,11 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1326
1412
  if (before !== after) await writeText(filePath, { exists: true, content: before }, after);
1327
1413
  const firstEditedLine = Math.min(...edits.flatMap((edit) => anchorRefs(edit).map((ref) => ref.line)));
1328
1414
  const previewStart = Math.max(1, firstEditedLine - 3);
1329
- return runReadSeek(
1415
+ return postMutationPreview(
1330
1416
  context,
1331
1417
  ["read", `${filePath}:${previewStart}`, "--end", String(previewStart + EDIT_RESULT_READ_LIMIT - 1)],
1332
- { cancelable: false },
1418
+ filePath,
1419
+ before !== after,
1333
1420
  );
1334
1421
  });
1335
1422
  },
@@ -1353,12 +1440,17 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
1353
1440
  await authorizeEdit(context, [filePath], simpleDiff(filePath, before.content, content), false);
1354
1441
  context.abort.throwIfAborted();
1355
1442
  await writeText(filePath, before, content);
1356
- return runReadSeek(context, ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)], { cancelable: false });
1443
+ return postMutationPreview(
1444
+ context,
1445
+ ["read", filePath, "--end", String(DEFAULT_READ_LIMIT)],
1446
+ filePath,
1447
+ !before.exists || before.content !== content,
1448
+ );
1357
1449
  });
1358
1450
  },
1359
1451
  ),
1360
1452
  readseek_check: readseekTool(
1361
- "Run a quick syntax check on a source file. Reports parser errors and missing syntax; not a compiler or linter.",
1453
+ "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.",
1362
1454
  {
1363
1455
  path: tool.schema.string().describe("Path relative to the project directory"),
1364
1456
  language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-readseek",
3
- "version": "0.8.21",
3
+ "version": "0.8.22",
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.21",
34
+ "@jarkkojs/readseek": "^0.8.22",
35
35
  "@opencode-ai/plugin": "^1.17.20"
36
36
  },
37
37
  "devDependencies": {