opencode-readseek 0.5.19

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 +37 -0
  2. package/index.ts +267 -0
  3. package/package.json +38 -0
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # opencode-readseek
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.
6
+
7
+ ## Installation
8
+
9
+ Add the plugin to `opencode.json`:
10
+
11
+ ```json
12
+ {
13
+ "plugin": ["opencode-readseek"]
14
+ }
15
+ ```
16
+
17
+ OpenCode installs the package and its supported-platform `@jarkkojs/readseek`
18
+ binary dependency with Bun at startup.
19
+
20
+ ## Tools
21
+
22
+ - `readseek_read`: read text with `LINE:HASH` anchors.
23
+ - `readseek_map`: generate a structural symbol map.
24
+ - `readseek_search`: AST-pattern search.
25
+ - `readseek_def`, `readseek_refs`, `readseek_hover`: symbol navigation.
26
+ - `readseek_rename`: generate a rename plan without writing files.
27
+ - `readseek_check`: parse diagnostics.
28
+
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.
33
+
34
+ ## Licensing
35
+
36
+ This package is Apache-2.0. `@jarkkojs/readseek` is licensed separately as
37
+ Apache-2.0 AND LGPL-2.1-or-later.
package/index.ts ADDED
@@ -0,0 +1,267 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) Jarkko Sakkinen 2026
3
+
4
+ import { createRequire } from "node:module";
5
+ import path from "node:path";
6
+
7
+ import { tool, type Plugin } from "@opencode-ai/plugin";
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const readseekScript = require.resolve("@jarkkojs/readseek/bin/readseek.js");
11
+
12
+ class SessionAnchors {
13
+ #pathsBySession = new Map<string, Set<string>>();
14
+ #renamePlans = new Map<string, string>();
15
+
16
+ mark(sessionID: string, filePath: string): void {
17
+ let paths = this.#pathsBySession.get(sessionID);
18
+ if (!paths) {
19
+ paths = new Set<string>();
20
+ this.#pathsBySession.set(sessionID, paths);
21
+ }
22
+ paths.add(filePath);
23
+ }
24
+
25
+ forget(filePath: string): void {
26
+ for (const paths of this.#pathsBySession.values()) paths.delete(filePath);
27
+ }
28
+
29
+ planRename(sessionID: string, output: unknown): void {
30
+ if (!output || typeof output !== "object") return;
31
+ const { old_name: oldName, new_name: newName } = output as Record<string, unknown>;
32
+ if (typeof oldName === "string" && typeof newName === "string") {
33
+ this.#renamePlans.set(sessionID, `${oldName} -> ${newName}`);
34
+ }
35
+ }
36
+
37
+ render(sessionID: string): string | undefined {
38
+ const paths = this.#pathsBySession.get(sessionID);
39
+ const sections: string[] = [];
40
+ if (paths?.size) {
41
+ sections.push(`## ReadSeek Anchors\nThe following files have fresh LINE:HASH anchors:\n${[...paths]
42
+ .sort()
43
+ .map((filePath) => `- ${filePath}`)
44
+ .join("\n")}`);
45
+ }
46
+ const renamePlan = this.#renamePlans.get(sessionID);
47
+ if (renamePlan) sections.push(`## Pending ReadSeek Rename Plan\n- ${renamePlan}`);
48
+ return sections.length === 0 ? undefined : sections.join("\n\n");
49
+ }
50
+ }
51
+
52
+ function resolvePath(directory: string, filePath: string): string {
53
+ return path.resolve(directory, filePath);
54
+ }
55
+
56
+ function optionalFlag(args: string[], enabled: boolean | undefined, flag: string): void {
57
+ if (enabled) args.push(flag);
58
+ }
59
+
60
+ async function runReadSeek(directory: string, args: string[]): Promise<unknown> {
61
+ const child = Bun.spawn([process.execPath, readseekScript, ...args], {
62
+ cwd: directory,
63
+ stderr: "pipe",
64
+ stdout: "pipe",
65
+ });
66
+ const [stdout, stderr, exitCode] = await Promise.all([
67
+ new Response(child.stdout).text(),
68
+ new Response(child.stderr).text(),
69
+ child.exited,
70
+ ]);
71
+ if (exitCode !== 0) throw new Error(stderr.trim() || `readseek exited with status ${exitCode}`);
72
+
73
+ try {
74
+ return JSON.parse(stdout) as unknown;
75
+ } catch {
76
+ throw new Error(`readseek returned invalid JSON: ${stdout.trim()}`);
77
+ }
78
+ }
79
+
80
+ function render(value: unknown): string {
81
+ return JSON.stringify(value, null, 2);
82
+ }
83
+
84
+ function collectFiles(value: unknown, files: Set<string>): void {
85
+ if (Array.isArray(value)) {
86
+ for (const item of value) collectFiles(item, files);
87
+ return;
88
+ }
89
+ if (!value || typeof value !== "object") return;
90
+
91
+ for (const [key, item] of Object.entries(value)) {
92
+ if (key === "file" && typeof item === "string") files.add(item);
93
+ else collectFiles(item, files);
94
+ }
95
+ }
96
+
97
+ function readseekTool(
98
+ description: string,
99
+ args: Record<string, any>,
100
+ execute: (args: any, directory: string) => Promise<unknown>,
101
+ ) {
102
+ return tool({
103
+ description,
104
+ args,
105
+ async execute(args, context) {
106
+ return render(await execute(args, context.directory));
107
+ },
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Adds readseek's anchored reads and structural navigation without replacing
113
+ * OpenCode's built-in file tools.
114
+ */
115
+ export const ReadSeekPlugin: Plugin = async () => {
116
+ const anchors = new SessionAnchors();
117
+ const withSearchFlags = (args: string[], input: { cached?: boolean; others?: boolean; ignored?: boolean }) => {
118
+ optionalFlag(args, input.cached, "--cached");
119
+ optionalFlag(args, input.others, "--others");
120
+ optionalFlag(args, input.ignored, "--ignored");
121
+ };
122
+
123
+ return {
124
+ tool: {
125
+ readseek_read: readseekTool(
126
+ "Read a text file with stable LINE:HASH anchors. Use those anchors when describing a later edit.",
127
+ {
128
+ path: tool.schema.string().describe("Path relative to the project directory"),
129
+ offset: tool.schema.number().int().positive().optional().describe("One-based starting line"),
130
+ limit: tool.schema.number().int().positive().optional().describe("Maximum number of lines"),
131
+ },
132
+ async (input, directory) => {
133
+ const filePath = resolvePath(directory, input.path as string);
134
+ const args = ["read", input.offset === undefined ? filePath : `${filePath}:${input.offset}`];
135
+ if (input.limit !== undefined) args.push("--end", String((input.offset as number) + (input.limit as number) - 1));
136
+ return runReadSeek(directory, args);
137
+ },
138
+ ),
139
+ readseek_map: readseekTool(
140
+ "Build a structural symbol map for a source file.",
141
+ { path: tool.schema.string().describe("Path relative to the project directory") },
142
+ async (input, directory) => runReadSeek(directory, ["map", resolvePath(directory, input.path as string)]),
143
+ ),
144
+ readseek_search: readseekTool(
145
+ "Search source code using an ast-grep structural pattern. Results include LINE:HASH anchors.",
146
+ {
147
+ pattern: tool.schema.string().describe("AST pattern"),
148
+ path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
149
+ language: tool.schema.string().optional().describe("ast-grep language"),
150
+ cached: tool.schema.boolean().optional(),
151
+ others: tool.schema.boolean().optional(),
152
+ ignored: tool.schema.boolean().optional(),
153
+ },
154
+ async (input, directory) => {
155
+ const target = resolvePath(directory, (input.path as string | undefined) ?? ".");
156
+ const args = ["search", target, input.pattern as string];
157
+ if (input.language) args.push("--language", input.language as string);
158
+ withSearchFlags(args, input);
159
+ const result = await runReadSeek(directory, args);
160
+ return result;
161
+ },
162
+ ),
163
+ readseek_def: readseekTool(
164
+ "Find structural definitions of a symbol. Results include LINE:HASH anchors.",
165
+ {
166
+ name: tool.schema.string().describe("Symbol name"),
167
+ path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
168
+ language: tool.schema.string().optional(),
169
+ cached: tool.schema.boolean().optional(),
170
+ others: tool.schema.boolean().optional(),
171
+ ignored: tool.schema.boolean().optional(),
172
+ },
173
+ async (input, directory) => {
174
+ const args = ["def", resolvePath(directory, (input.path as string | undefined) ?? "."), "--format", "plain", input.name as string];
175
+ if (input.language) args.push("--language", input.language as string);
176
+ withSearchFlags(args, input);
177
+ return runReadSeek(directory, args);
178
+ },
179
+ ),
180
+ readseek_refs: readseekTool(
181
+ "Find references to an identifier. Use scope with a cursor to identify a specific binding.",
182
+ {
183
+ name: tool.schema.string().describe("Identifier name"),
184
+ path: tool.schema.string().optional().describe("File or directory, defaulting to the project directory"),
185
+ language: tool.schema.string().optional(),
186
+ scope: tool.schema.boolean().optional(),
187
+ line: tool.schema.number().int().positive().optional(),
188
+ column: tool.schema.number().int().positive().optional(),
189
+ cached: tool.schema.boolean().optional(),
190
+ others: tool.schema.boolean().optional(),
191
+ ignored: tool.schema.boolean().optional(),
192
+ },
193
+ async (input, directory) => {
194
+ const args = ["refs", resolvePath(directory, (input.path as string | undefined) ?? "."), input.name as string];
195
+ optionalFlag(args, input.scope as boolean | undefined, "--scope");
196
+ if (input.line) args.push("--line", String(input.line));
197
+ if (input.column) args.push("--column", String(input.column));
198
+ if (input.language) args.push("--language", input.language as string);
199
+ withSearchFlags(args, input);
200
+ return runReadSeek(directory, args);
201
+ },
202
+ ),
203
+ readseek_hover: readseekTool(
204
+ "Identify the token and enclosing symbol at a source cursor.",
205
+ {
206
+ path: tool.schema.string().describe("Path relative to the project directory"),
207
+ line: tool.schema.number().int().positive().describe("One-based cursor line"),
208
+ column: tool.schema.number().int().positive().optional().describe("One-based cursor byte column"),
209
+ language: tool.schema.string().optional(),
210
+ },
211
+ async (input, directory) => {
212
+ const args = ["identify", `${resolvePath(directory, input.path as string)}:${input.line}`];
213
+ if (input.column) args.push("--column", String(input.column));
214
+ if (input.language) args.push("--language", input.language as string);
215
+ return runReadSeek(directory, args);
216
+ },
217
+ ),
218
+ readseek_rename: readseekTool(
219
+ "Plan a binding-aware rename. This tool never writes files; apply the returned plan through OpenCode's normal edit tools.",
220
+ {
221
+ path: tool.schema.string().describe("Path relative to the project directory"),
222
+ line: tool.schema.number().int().positive().describe("One-based cursor line of the binding"),
223
+ column: tool.schema.number().int().positive().optional().describe("One-based cursor byte column"),
224
+ to: tool.schema.string().min(1).describe("New binding name"),
225
+ workspace: tool.schema.boolean().optional().describe("Include project-wide occurrences"),
226
+ },
227
+ async (input, directory) => {
228
+ const args = ["rename", resolvePath(directory, input.path as string), "--line", String(input.line), "--to", input.to as string];
229
+ if (input.column) args.push("--column", String(input.column));
230
+ if (input.workspace) args.push("--workspace", directory);
231
+ return runReadSeek(directory, args);
232
+ },
233
+ ),
234
+ readseek_check: readseekTool(
235
+ "Check a source file for parser errors and missing syntax.",
236
+ { path: tool.schema.string().describe("Path relative to the project directory") },
237
+ async (input, directory) => runReadSeek(directory, ["check", resolvePath(directory, input.path as string)]),
238
+ ),
239
+ },
240
+ event: async ({ event }) => {
241
+ if (event.type !== "file.edited") return;
242
+ anchors.forget(path.resolve(event.properties.file));
243
+ },
244
+ "tool.execute.before": async (input, output) => {
245
+ if (input.tool === "readseek_rename" && output.args.apply === true) {
246
+ throw new Error("readseek_rename only creates plans; apply its edits with OpenCode's edit tools");
247
+ }
248
+ },
249
+ "tool.execute.after": async (input, output) => {
250
+ if (!input.tool.startsWith("readseek_")) return;
251
+ try {
252
+ const result = JSON.parse(output.output) as unknown;
253
+ if (input.tool === "readseek_rename") anchors.planRename(input.sessionID, result);
254
+
255
+ const files = new Set<string>();
256
+ collectFiles(result, files);
257
+ for (const filePath of files) anchors.mark(input.sessionID, path.resolve(filePath));
258
+ } catch {
259
+ // A failed tool result has no valid anchors to retain.
260
+ }
261
+ },
262
+ "experimental.session.compacting": async (input, output) => {
263
+ const context = anchors.render(input.sessionID);
264
+ if (context) output.context.push(context);
265
+ },
266
+ };
267
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "opencode-readseek",
3
+ "version": "0.5.19",
4
+ "description": "OpenCode plugin for readseek-backed structural code navigation",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.ts"
8
+ },
9
+ "keywords": [
10
+ "opencode",
11
+ "opencode-plugin",
12
+ "readseek",
13
+ "structural-search"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/jarkkojs/readseek.git"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "files": [
21
+ "index.ts",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit"
26
+ },
27
+ "engines": {
28
+ "node": ">=20.0.0"
29
+ },
30
+ "dependencies": {
31
+ "@jarkkojs/readseek": "^0.5.19",
32
+ "@opencode-ai/plugin": "^1.17.20"
33
+ },
34
+ "devDependencies": {
35
+ "@types/bun": "^1.3.14",
36
+ "typescript": "^5.9.3"
37
+ }
38
+ }