@cortexkit/aft-opencode 0.2.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/bridge.d.ts +8 -0
  2. package/dist/bridge.d.ts.map +1 -1
  3. package/dist/bridge.js +45 -2
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/config.d.ts +2 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +17 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/downloader.d.ts.map +1 -1
  10. package/dist/downloader.js +51 -15
  11. package/dist/downloader.js.map +1 -1
  12. package/dist/index.d.ts +4 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +73 -15
  15. package/dist/index.js.map +1 -1
  16. package/dist/metadata-store.d.ts +29 -0
  17. package/dist/metadata-store.d.ts.map +1 -0
  18. package/dist/metadata-store.js +53 -0
  19. package/dist/metadata-store.js.map +1 -0
  20. package/dist/normalize-schemas.d.ts +16 -0
  21. package/dist/normalize-schemas.d.ts.map +1 -0
  22. package/dist/normalize-schemas.js +45 -0
  23. package/dist/normalize-schemas.js.map +1 -0
  24. package/dist/patch-parser.d.ts +33 -0
  25. package/dist/patch-parser.d.ts.map +1 -0
  26. package/dist/patch-parser.js +247 -0
  27. package/dist/patch-parser.js.map +1 -0
  28. package/dist/platform.d.ts +21 -0
  29. package/dist/platform.d.ts.map +1 -0
  30. package/dist/platform.js +31 -0
  31. package/dist/platform.js.map +1 -0
  32. package/dist/pool.d.ts.map +1 -1
  33. package/dist/pool.js +14 -5
  34. package/dist/pool.js.map +1 -1
  35. package/dist/resolver.d.ts.map +1 -1
  36. package/dist/resolver.js +6 -9
  37. package/dist/resolver.js.map +1 -1
  38. package/dist/tools/ast.d.ts.map +1 -1
  39. package/dist/tools/ast.js +179 -89
  40. package/dist/tools/ast.js.map +1 -1
  41. package/dist/tools/hoisted.d.ts +26 -0
  42. package/dist/tools/hoisted.d.ts.map +1 -0
  43. package/dist/tools/hoisted.js +852 -0
  44. package/dist/tools/hoisted.js.map +1 -0
  45. package/dist/tools/imports.d.ts.map +1 -1
  46. package/dist/tools/imports.js +41 -21
  47. package/dist/tools/imports.js.map +1 -1
  48. package/dist/tools/lsp.d.ts +1 -2
  49. package/dist/tools/lsp.d.ts.map +1 -1
  50. package/dist/tools/lsp.js +45 -110
  51. package/dist/tools/lsp.js.map +1 -1
  52. package/dist/tools/navigation.d.ts.map +1 -1
  53. package/dist/tools/navigation.js +25 -10
  54. package/dist/tools/navigation.js.map +1 -1
  55. package/dist/tools/permissions.d.ts +8 -0
  56. package/dist/tools/permissions.d.ts.map +1 -0
  57. package/dist/tools/permissions.js +50 -0
  58. package/dist/tools/permissions.js.map +1 -0
  59. package/dist/tools/reading.d.ts +1 -1
  60. package/dist/tools/reading.d.ts.map +1 -1
  61. package/dist/tools/reading.js +170 -54
  62. package/dist/tools/reading.js.map +1 -1
  63. package/dist/tools/refactoring.d.ts.map +1 -1
  64. package/dist/tools/refactoring.js +73 -24
  65. package/dist/tools/refactoring.js.map +1 -1
  66. package/dist/tools/safety.d.ts.map +1 -1
  67. package/dist/tools/safety.js +38 -11
  68. package/dist/tools/safety.js.map +1 -1
  69. package/dist/tools/structure.d.ts.map +1 -1
  70. package/dist/tools/structure.js +70 -20
  71. package/dist/tools/structure.js.map +1 -1
  72. package/package.json +7 -7
  73. package/dist/tools/editing.d.ts +0 -7
  74. package/dist/tools/editing.d.ts.map +0 -1
  75. package/dist/tools/editing.js +0 -150
  76. package/dist/tools/editing.js.map +0 -1
@@ -0,0 +1,852 @@
1
+ /**
2
+ * Hoisted tools that replace opencode's built-in tools (read, write, edit, apply_patch).
3
+ *
4
+ * When hoist_builtin_tools is enabled (default), these tools are registered with
5
+ * the SAME names as opencode's built-in tools, effectively overriding them.
6
+ * When disabled, they're registered with aft_ prefix (e.g., aft_read).
7
+ *
8
+ * All file operations go through AFT's Rust binary for better performance,
9
+ * backup tracking, formatting, and inline diagnostics.
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { tool } from "@opencode-ai/plugin";
14
+ import { storeToolMetadata } from "../metadata-store.js";
15
+ import { applyUpdateChunks, parsePatch } from "../patch-parser.js";
16
+ /** Extract callID from plugin context (exists on object but not in TS type). */
17
+ function getCallID(ctx) {
18
+ const c = ctx;
19
+ return c.callID ?? c.callId ?? c.call_id;
20
+ }
21
+ /** Get relative path matching opencode's format — the desktop UI parses it to extract filename + dir. */
22
+ function relativeToWorktree(fp, worktree) {
23
+ return path.relative(worktree, fp);
24
+ }
25
+ /** Build a simple unified diff string from before/after content. */
26
+ function buildUnifiedDiff(fp, before, after) {
27
+ const beforeLines = before.split("\n");
28
+ const afterLines = after.split("\n");
29
+ let diff = `Index: ${fp}\n===================================================================\n--- ${fp}\n+++ ${fp}\n`;
30
+ let firstChange = -1;
31
+ let lastChange = -1;
32
+ const maxLen = Math.max(beforeLines.length, afterLines.length);
33
+ for (let i = 0; i < maxLen; i++) {
34
+ if ((beforeLines[i] ?? "") !== (afterLines[i] ?? "")) {
35
+ if (firstChange === -1)
36
+ firstChange = i;
37
+ lastChange = i;
38
+ }
39
+ }
40
+ if (firstChange === -1)
41
+ return diff;
42
+ const ctxStart = Math.max(0, firstChange - 2);
43
+ const ctxEnd = Math.min(maxLen - 1, lastChange + 2);
44
+ diff += `@@ -${ctxStart + 1},${Math.min(beforeLines.length, ctxEnd + 1) - ctxStart} +${ctxStart + 1},${Math.min(afterLines.length, ctxEnd + 1) - ctxStart} @@\n`;
45
+ for (let i = ctxStart; i <= ctxEnd; i++) {
46
+ const bl = i < beforeLines.length ? beforeLines[i] : undefined;
47
+ const al = i < afterLines.length ? afterLines[i] : undefined;
48
+ if (bl === al) {
49
+ diff += ` ${bl}\n`;
50
+ }
51
+ else {
52
+ if (bl !== undefined)
53
+ diff += `-${bl}\n`;
54
+ if (al !== undefined)
55
+ diff += `+${al}\n`;
56
+ }
57
+ }
58
+ return diff;
59
+ }
60
+ const z = tool.schema;
61
+ // ---------------------------------------------------------------------------
62
+ // Tool descriptions focus on behavior, modes, and return values.
63
+ // Parameter docs live in Zod .describe() and reach the LLM via JSON Schema.
64
+ // ---------------------------------------------------------------------------
65
+ const READ_DESCRIPTION = `Read file contents or list directory entries.
66
+
67
+ Use either startLine/endLine OR offset/limit to read a section of a file.
68
+
69
+ Behavior:
70
+ - Returns line-numbered content (e.g., "1: const x = 1")
71
+ - Lines longer than 2000 characters are truncated
72
+ - Output capped at 50KB
73
+ - Binary files are auto-detected and return a size-only message
74
+ - Image files (.png, .jpg, .gif, .webp, etc.) and PDFs return a metadata string (format, size, path) — no file content is returned
75
+ - Directories return sorted entries with trailing / for subdirectories
76
+
77
+ Examples:
78
+ Read full file: { "filePath": "src/app.ts" }
79
+ Read lines 50-100: { "filePath": "src/app.ts", "startLine": 50, "endLine": 100 }
80
+ Read 30 lines from line 200: { "filePath": "src/app.ts", "offset": 200, "limit": 30 }
81
+ List directory: { "filePath": "src/" }
82
+
83
+ Returns: Line-numbered file content string. For directories: newline-joined sorted entries. For binary files: size/message string.`;
84
+ /**
85
+ * Creates the simple read tool. Registers as "read" when hoisted, "aft_read" when not.
86
+ */
87
+ export function createReadTool(ctx) {
88
+ return {
89
+ description: READ_DESCRIPTION,
90
+ args: {
91
+ filePath: z
92
+ .string()
93
+ .describe("Path to file or directory (absolute or relative to project root)"),
94
+ startLine: z.number().optional().describe("1-based line to start reading from"),
95
+ endLine: z.number().optional().describe("1-based line to stop reading at (inclusive)"),
96
+ limit: z.number().optional().describe("Max lines to return (default: 2000)"),
97
+ offset: z
98
+ .number()
99
+ .optional()
100
+ .describe("1-based line number to start reading from (use with limit). Ignored if startLine is provided"),
101
+ },
102
+ execute: async (args, context) => {
103
+ const bridge = ctx.pool.getBridge(context.directory);
104
+ const file = args.filePath;
105
+ // Resolve relative paths
106
+ const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
107
+ // Permission check
108
+ await context.ask({
109
+ permission: "read",
110
+ patterns: [filePath],
111
+ always: ["*"],
112
+ metadata: {},
113
+ });
114
+ // Image/PDF detection — return metadata for UI preview
115
+ const ext = path.extname(filePath).toLowerCase();
116
+ const mimeMap = {
117
+ ".png": "image/png",
118
+ ".jpg": "image/jpeg",
119
+ ".jpeg": "image/jpeg",
120
+ ".gif": "image/gif",
121
+ ".webp": "image/webp",
122
+ ".bmp": "image/bmp",
123
+ ".ico": "image/x-icon",
124
+ ".tiff": "image/tiff",
125
+ ".tif": "image/tiff",
126
+ ".avif": "image/avif",
127
+ ".heic": "image/heic",
128
+ ".heif": "image/heif",
129
+ ".pdf": "application/pdf",
130
+ };
131
+ const mime = mimeMap[ext];
132
+ if (mime) {
133
+ const isImage = mime.startsWith("image/");
134
+ const label = isImage ? "Image" : "PDF";
135
+ let fileSize = 0;
136
+ try {
137
+ const stat = await import("node:fs/promises").then((fs) => fs.stat(filePath));
138
+ fileSize = stat.size;
139
+ }
140
+ catch {
141
+ /* ignore */
142
+ }
143
+ const sizeStr = fileSize > 1024 * 1024
144
+ ? `${(fileSize / (1024 * 1024)).toFixed(1)}MB`
145
+ : fileSize > 1024
146
+ ? `${(fileSize / 1024).toFixed(0)}KB`
147
+ : `${fileSize} bytes`;
148
+ const msg = `${label} read successfully`;
149
+ const imgCallID = getCallID(context);
150
+ if (imgCallID) {
151
+ storeToolMetadata(context.sessionID, imgCallID, {
152
+ title: path.relative(context.worktree, filePath),
153
+ metadata: {
154
+ preview: msg,
155
+ filepath: filePath,
156
+ isImage,
157
+ isPdf: mime === "application/pdf",
158
+ },
159
+ });
160
+ }
161
+ return `${msg} (${ext.slice(1).toUpperCase()}, ${sizeStr}). File: ${filePath}`;
162
+ }
163
+ // Normalize offset/limit to startLine/endLine (backward compat with opencode's read)
164
+ let startLine = args.startLine;
165
+ let endLine = args.endLine;
166
+ if (startLine === undefined && args.offset !== undefined) {
167
+ startLine = args.offset;
168
+ if (args.limit !== undefined) {
169
+ endLine = Number(args.offset) + Number(args.limit) - 1;
170
+ }
171
+ }
172
+ // Always use Rust read command — simple file reading only
173
+ const params = { file: filePath };
174
+ if (startLine !== undefined)
175
+ params.start_line = startLine;
176
+ if (endLine !== undefined)
177
+ params.end_line = endLine;
178
+ // Only send limit if we did NOT convert offset to startLine/endLine
179
+ if (args.limit !== undefined && args.offset === undefined)
180
+ params.limit = args.limit;
181
+ const data = await bridge.send("read", params);
182
+ const readCallID = getCallID(context);
183
+ // Directory response
184
+ if (data.entries) {
185
+ if (readCallID) {
186
+ const dp = relativeToWorktree(filePath, context.worktree) || file;
187
+ storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
188
+ }
189
+ return data.entries.join("\n");
190
+ }
191
+ // Binary response
192
+ if (data.binary) {
193
+ if (readCallID) {
194
+ const dp = relativeToWorktree(filePath, context.worktree) || file;
195
+ storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
196
+ }
197
+ return data.message;
198
+ }
199
+ // File content — already line-numbered from Rust
200
+ if (readCallID) {
201
+ const dp = relativeToWorktree(filePath, context.worktree) || file;
202
+ storeToolMetadata(context.sessionID, readCallID, { title: dp, metadata: { title: dp } });
203
+ }
204
+ let output = data.content;
205
+ // Add navigation hint if truncated
206
+ if (data.truncated) {
207
+ output += `\n(Showing lines ${data.start_line}-${data.end_line} of ${data.total_lines}. Use startLine/endLine to read other sections.)`;
208
+ }
209
+ return output;
210
+ },
211
+ };
212
+ }
213
+ // ---------------------------------------------------------------------------
214
+ // WRITE tool
215
+ // ---------------------------------------------------------------------------
216
+ function getWriteDescription(editToolName) {
217
+ return `Write content to a file, creating it (and parent directories) if needed.
218
+
219
+ Automatically creates parent directories. Backs up existing files before overwriting.
220
+ If the project has a formatter configured (biome, prettier, rustfmt, etc.), the file
221
+ is auto-formatted after writing. Returns inline LSP diagnostics when available.
222
+
223
+ **Behavior:**
224
+ - Creates parent directories automatically (no need to mkdir first)
225
+ - Existing files are backed up before overwriting (recoverable via aft_safety undo)
226
+ - Auto-formats using project formatter if configured (biome.json, .prettierrc, etc.)
227
+ - Returns LSP error-level diagnostics inline if type errors are introduced
228
+ - Use this for creating new files or completely replacing file contents
229
+ - For partial edits (find/replace), use the \`${editToolName}\` tool instead
230
+
231
+ Returns: Status message string (for example: "Created new file. Auto-formatted.") with optional inline LSP error lines.`;
232
+ }
233
+ function createWriteTool(ctx, editToolName = "edit") {
234
+ return {
235
+ description: getWriteDescription(editToolName),
236
+ args: {
237
+ filePath: z
238
+ .string()
239
+ .describe("Path to the file to write (absolute or relative to project root)"),
240
+ content: z.string().describe("The full content to write to the file"),
241
+ },
242
+ execute: async (args, context) => {
243
+ const bridge = ctx.pool.getBridge(context.directory);
244
+ const file = args.filePath;
245
+ const content = args.content;
246
+ const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
247
+ const relPath = path.relative(context.worktree, filePath);
248
+ // Permission check
249
+ await context.ask({
250
+ permission: "edit",
251
+ patterns: [relPath],
252
+ always: ["*"],
253
+ metadata: { filepath: filePath },
254
+ });
255
+ const data = await bridge.send("write", {
256
+ file: filePath,
257
+ content,
258
+ create_dirs: true,
259
+ diagnostics: true,
260
+ include_diff: true,
261
+ });
262
+ let output = data.created ? "Created new file." : "File updated.";
263
+ if (data.formatted)
264
+ output += " Auto-formatted.";
265
+ // Append inline diagnostics if present
266
+ const diags = data.lsp_diagnostics;
267
+ if (diags && diags.length > 0) {
268
+ const errors = diags.filter((d) => d.severity === "error");
269
+ if (errors.length > 0) {
270
+ output += "\n\nLSP errors detected, please fix:\n";
271
+ for (const d of errors) {
272
+ output += ` Line ${d.line}: ${d.message}\n`;
273
+ }
274
+ }
275
+ }
276
+ // Store metadata for tool.execute.after hook (fromPlugin overwrites context.metadata)
277
+ const diff = data.diff;
278
+ const callID = getCallID(context);
279
+ if (callID) {
280
+ const dp = relativeToWorktree(filePath, context.worktree);
281
+ const beforeContent = diff?.before ?? "";
282
+ const afterContent = diff?.after ?? content;
283
+ storeToolMetadata(context.sessionID, callID, {
284
+ title: dp,
285
+ metadata: {
286
+ diff: buildUnifiedDiff(filePath, beforeContent, afterContent),
287
+ filediff: {
288
+ file: filePath,
289
+ before: beforeContent,
290
+ after: afterContent,
291
+ additions: diff?.additions ?? 0,
292
+ deletions: diff?.deletions ?? 0,
293
+ },
294
+ diagnostics: {},
295
+ },
296
+ });
297
+ }
298
+ return output;
299
+ },
300
+ };
301
+ }
302
+ // ---------------------------------------------------------------------------
303
+ // EDIT tool
304
+ // ---------------------------------------------------------------------------
305
+ function getEditDescription(writeToolName) {
306
+ return `Edit a file by finding and replacing text, or by targeting named symbols.
307
+
308
+ **Modes** (determined by which parameters you provide):
309
+
310
+ Mode priority: operations > edits > symbol (without oldString) > oldString (find/replace) > content-only (${writeToolName})
311
+
312
+ 1. **Multi-file transaction** — pass \`operations\` array
313
+ Edits across multiple files with checkpoint-based rollback on failure.
314
+ Each operation: \`{ "file": "path", "command": "edit_match" | "write", ... }\`.
315
+ For \`edit_match\`: include \`match\`, \`replacement\`. For \`write\`: include \`content\`.
316
+ Example: \`{ "operations": [{ "file": "a.ts", "command": "edit_match", "match": "old", "replacement": "new" }, { "file": "b.ts", "command": "write", "content": "..." }] }\`
317
+
318
+ 2. **Batch edits** — pass \`filePath\` + \`edits\` array
319
+ Multiple edits in one file atomically. Each edit is either:
320
+ - \`{ "oldString": "old", "newString": "new" }\` — find/replace
321
+ - \`{ "startLine": 5, "endLine": 7, "content": "new lines" }\` — replace line range (1-based, both inclusive)
322
+ Set content to empty string to delete lines.
323
+
324
+ 3. **Symbol replace** — pass \`filePath\` + \`symbol\` + \`content\`
325
+ Replaces an entire named symbol (function, class, type) with new content.
326
+ Includes decorators, attributes, and doc comments in the replacement range.
327
+ **Important:** You must NOT provide \`oldString\` when using symbol mode — if present, the tool silently falls back to find/replace mode.
328
+ Example: \`{ "filePath": "src/app.ts", "symbol": "handleRequest", "content": "function handleRequest() { ... }" }\`
329
+
330
+ 4. **Find and replace** — pass \`filePath\` + \`oldString\` + \`newString\`
331
+ Finds the exact text in \`oldString\` and replaces it with \`newString\`.
332
+ Supports fuzzy matching (handles whitespace differences automatically).
333
+ If multiple matches exist, specify which one with \`occurrence\` or use \`replaceAll: true\`.
334
+ Example: \`{ "filePath": "src/app.ts", "oldString": "const x = 1", "newString": "const x = 2" }\`
335
+
336
+ 5. **Replace all occurrences** — add \`replaceAll: true\`
337
+ Replaces every occurrence of \`oldString\` in the file.
338
+ Example: \`{ "filePath": "src/app.ts", "oldString": "oldName", "newString": "newName", "replaceAll": true }\`
339
+
340
+ 6. **Select specific occurrence** — add \`occurrence: N\` (0-indexed)
341
+ When multiple matches exist, select the Nth one (0 = first, 1 = second, etc.).
342
+ Example: \`{ "filePath": "src/app.ts", "oldString": "TODO", "newString": "DONE", "occurrence": 0 }\`
343
+
344
+ Note: Modes 5 and 6 are options on mode 4 (find/replace) — they require \`oldString\`.
345
+
346
+ **Behavior:**
347
+ - Backs up files before editing (recoverable via aft_safety undo)
348
+ - Auto-formats using project formatter if configured
349
+ - Tree-sitter syntax validation on all edits
350
+ - Symbol replace includes decorators, attributes, and doc comments in range
351
+ - LSP error-level diagnostics are returned automatically after non-dry-run edits
352
+
353
+ Returns: JSON string for the selected edit mode. Dry runs return diff data; non-dry-run edits may append inline LSP error lines.
354
+
355
+ Common response fields: success (boolean), diff (object with before/after), backup_id (string), syntax_valid (boolean). Exact fields vary by mode.`;
356
+ // Note: The Returns section intentionally stays high-level because per-mode JSON shapes
357
+ // vary by Rust command and documenting each would bloat the description for minimal gain.
358
+ // Agents can parse the JSON response generically — key fields include 'success' and 'diff'.
359
+ }
360
+ function createEditTool(ctx, writeToolName = "write") {
361
+ return {
362
+ description: getEditDescription(writeToolName),
363
+ args: {
364
+ filePath: z
365
+ .string()
366
+ .optional()
367
+ .describe("Path to the file to edit (absolute or relative to project root). Required for all modes except 'operations' multi-file transactions"),
368
+ oldString: z.string().optional().describe("Text to find (exact match, with fuzzy fallback)"),
369
+ newString: z
370
+ .string()
371
+ .optional()
372
+ .describe("Text to replace with (omit or set to empty string to delete the matched text)"),
373
+ replaceAll: z.boolean().optional().describe("Replace all occurrences"),
374
+ occurrence: z
375
+ .number()
376
+ .optional()
377
+ .describe("0-indexed occurrence to replace when multiple matches exist"),
378
+ symbol: z.string().optional().describe("Named symbol to replace (function, class, type)"),
379
+ content: z.string().optional().describe("New content for symbol replace or file write"),
380
+ edits: z
381
+ .array(z.record(z.string(), z.unknown()))
382
+ .optional()
383
+ .describe("Batch edits — array of { oldString: string, newString: string } or { startLine: number (1-based), endLine: number (1-based, inclusive), content: string }"),
384
+ operations: z
385
+ .array(z.record(z.string(), z.unknown()))
386
+ .optional()
387
+ .describe("Transaction — array of { file: string, command: 'edit_match' | 'write', match?: string, replacement?: string, content?: string } for multi-file edits with rollback. Note: uses 'file'/'match'/'replacement' (not filePath/oldString/newString)"),
388
+ dryRun: z
389
+ .boolean()
390
+ .optional()
391
+ .describe("Preview changes without applying (returns diff, default: false)"),
392
+ },
393
+ execute: async (args, context) => {
394
+ const bridge = ctx.pool.getBridge(context.directory);
395
+ // Transaction mode — multi-file
396
+ if (Array.isArray(args.operations)) {
397
+ const ops = args.operations;
398
+ const files = ops.map((op) => op.file).filter(Boolean);
399
+ await context.ask({
400
+ permission: "edit",
401
+ patterns: files.map((f) => path.relative(context.worktree, path.resolve(context.directory, f))),
402
+ always: ["*"],
403
+ metadata: {},
404
+ });
405
+ const resolvedOps = ops.map((op) => ({
406
+ ...op,
407
+ file: path.isAbsolute(op.file)
408
+ ? op.file
409
+ : path.resolve(context.directory, op.file),
410
+ }));
411
+ const params = { operations: resolvedOps };
412
+ params.dry_run = args.dryRun === true;
413
+ const data = await bridge.send("transaction", params);
414
+ return JSON.stringify(data);
415
+ }
416
+ const file = args.filePath;
417
+ if (!file)
418
+ throw new Error("'filePath' parameter is required");
419
+ const filePath = path.isAbsolute(file) ? file : path.resolve(context.directory, file);
420
+ const relPath = path.relative(context.worktree, filePath);
421
+ await context.ask({
422
+ permission: "edit",
423
+ patterns: [relPath],
424
+ always: ["*"],
425
+ metadata: { filepath: filePath },
426
+ });
427
+ const params = { file: filePath };
428
+ // Route to appropriate Rust command
429
+ let command;
430
+ if (Array.isArray(args.edits)) {
431
+ // Batch mode — translate camelCase to snake_case for Rust
432
+ command = "batch";
433
+ params.edits = args.edits.map((edit) => {
434
+ const translated = {};
435
+ for (const [key, value] of Object.entries(edit)) {
436
+ if (key === "oldString")
437
+ translated.match = value;
438
+ else if (key === "newString")
439
+ translated.replacement = value;
440
+ else if (key === "startLine")
441
+ translated.line_start = value;
442
+ else if (key === "endLine")
443
+ translated.line_end = value;
444
+ else
445
+ translated[key] = value;
446
+ }
447
+ return translated;
448
+ });
449
+ }
450
+ else if (typeof args.symbol === "string" &&
451
+ typeof args.oldString !== "string" &&
452
+ args.content !== undefined) {
453
+ // Symbol replace — only when content is provided and oldString is NOT present
454
+ // (agents often pass symbol as "what to search for", not "replace whole symbol")
455
+ command = "edit_symbol";
456
+ params.symbol = args.symbol;
457
+ params.operation = "replace";
458
+ params.content = args.content;
459
+ }
460
+ else if (typeof args.oldString === "string") {
461
+ // Find/replace mode — default newString to "" (deletion) if not provided
462
+ command = "edit_match";
463
+ params.match = args.oldString;
464
+ params.replacement = args.newString ?? "";
465
+ if (args.replaceAll !== undefined)
466
+ params.replace_all = args.replaceAll;
467
+ if (args.occurrence !== undefined)
468
+ params.occurrence = args.occurrence;
469
+ }
470
+ else if (typeof args.content === "string") {
471
+ // Write mode
472
+ command = "write";
473
+ params.content = args.content;
474
+ params.create_dirs = true;
475
+ }
476
+ else {
477
+ throw new Error("Provide 'oldString' + 'newString', 'symbol' + 'content', 'edits' array, or 'content' for write");
478
+ }
479
+ if (args.dryRun)
480
+ params.dry_run = true;
481
+ if (!args.dryRun)
482
+ params.diagnostics = true;
483
+ // Request diff from Rust for UI metadata (avoids extra file reads in TS)
484
+ if (!args.dryRun)
485
+ params.include_diff = true;
486
+ const data = await bridge.send(command, params);
487
+ // Store metadata for tool.execute.after hook (fromPlugin overwrites context.metadata)
488
+ if (!args.dryRun && data.success && data.diff) {
489
+ const diff = data.diff;
490
+ const callID = getCallID(context);
491
+ if (callID) {
492
+ const dp = relativeToWorktree(filePath, context.worktree);
493
+ const beforeContent = diff.before ?? "";
494
+ const afterContent = diff.after ?? "";
495
+ storeToolMetadata(context.sessionID, callID, {
496
+ title: dp,
497
+ metadata: {
498
+ diff: buildUnifiedDiff(filePath, beforeContent, afterContent),
499
+ filediff: {
500
+ file: filePath,
501
+ before: beforeContent,
502
+ after: afterContent,
503
+ additions: diff.additions ?? 0,
504
+ deletions: diff.deletions ?? 0,
505
+ },
506
+ diagnostics: {},
507
+ },
508
+ });
509
+ }
510
+ }
511
+ let result = JSON.stringify(data);
512
+ // Append inline diagnostics to output (matching write tool pattern)
513
+ if (!args.dryRun) {
514
+ const diags = data.lsp_diagnostics;
515
+ if (diags && diags.length > 0) {
516
+ const errors = diags.filter((d) => d.severity === "error");
517
+ if (errors.length > 0) {
518
+ const diagLines = errors.map((d) => ` Line ${d.line}: ${d.message}`).join("\n");
519
+ result += `\n\nLSP errors detected, please fix:\n${diagLines}`;
520
+ }
521
+ }
522
+ }
523
+ return result;
524
+ },
525
+ };
526
+ }
527
+ // ---------------------------------------------------------------------------
528
+ // APPLY_PATCH tool
529
+ // ---------------------------------------------------------------------------
530
+ const APPLY_PATCH_DESCRIPTION = `Apply a multi-file patch to create, update, delete, or move files in one operation.
531
+
532
+ Uses the opencode patch format with \`*** Begin Patch\` / \`*** End Patch\` markers.
533
+
534
+ **Patch format:**
535
+ \`\`\`
536
+ *** Begin Patch
537
+ *** Add File: path/to/new-file.ts
538
+ +line 1 of new file
539
+ +line 2 of new file
540
+ *** Update File: path/to/existing-file.ts
541
+ @@ function targetFunction()
542
+ -old line to remove
543
+ +new line to add
544
+ context line (unchanged, prefixed with space)
545
+ *** Update File: path/to/old-name.ts
546
+ *** Move to: path/to/new-name.ts
547
+ @@ import { foo }
548
+ -import { foo } from './old'
549
+ +import { foo } from './new'
550
+ *** Delete File: path/to/obsolete-file.ts
551
+ *** End Patch
552
+ \`\`\`
553
+
554
+ **File operations:**
555
+ - \`*** Add File: <path>\` — Create a new file. Every line prefixed with \`+\`.
556
+ - \`*** Update File: <path>\` — Patch an existing file. Uses \`@@\` context anchors.
557
+ - \`*** Delete File: <path>\` — Remove a file.
558
+ - \`*** Move to: <path>\` — After Update File header, renames the file.
559
+
560
+ **Update file syntax:**
561
+ - \`@@ context line\` — Anchor: finds this line in the file to locate the edit
562
+ - \`-line\` — Remove this line
563
+ - \`+line\` — Add this line
564
+ - \` line\` — Context line (space prefix), appears in both old and new
565
+
566
+ **Behavior:**
567
+ - All file changes are applied with checkpoint-based rollback — if any file fails, previous changes are rolled back (best-effort)
568
+ - Files are backed up before modification
569
+ - Parent directories are created automatically for new files
570
+ - Fuzzy matching for context anchors (handles whitespace and Unicode differences)
571
+
572
+ Returns: Status message string listing created, updated, moved, deleted, or failed file operations. May include inline LSP errors if type errors are introduced by the patch.`;
573
+ function createApplyPatchTool(ctx) {
574
+ return {
575
+ description: APPLY_PATCH_DESCRIPTION,
576
+ args: {
577
+ patchText: z.string().describe("The full patch text including Begin/End markers"),
578
+ },
579
+ execute: async (args, context) => {
580
+ const bridge = ctx.pool.getBridge(context.directory);
581
+ const patchText = args.patchText;
582
+ if (!patchText)
583
+ throw new Error("'patchText' is required");
584
+ // Parse the patch
585
+ let hunks;
586
+ try {
587
+ hunks = parsePatch(patchText);
588
+ }
589
+ catch (e) {
590
+ throw new Error(`Patch parse error: ${e instanceof Error ? e.message : e}`);
591
+ }
592
+ if (hunks.length === 0) {
593
+ throw new Error("Empty patch: no file operations found");
594
+ }
595
+ // Resolve all paths and ask permission
596
+ const allPaths = hunks.map((h) => path.relative(context.worktree, path.resolve(context.directory, h.path)));
597
+ await context.ask({
598
+ permission: "edit",
599
+ patterns: allPaths,
600
+ always: ["*"],
601
+ metadata: {},
602
+ });
603
+ // Checkpoint all affected files for atomic rollback
604
+ const checkpointName = `apply_patch_${Date.now()}`;
605
+ try {
606
+ await bridge.send("checkpoint", {
607
+ name: checkpointName,
608
+ files: allPaths.map((p) => path.resolve(context.directory, p)),
609
+ });
610
+ }
611
+ catch {
612
+ // Checkpoint failure is non-fatal — proceed without rollback protection
613
+ }
614
+ // Process each hunk, track diffs for metadata
615
+ const results = [];
616
+ let combinedBefore = "";
617
+ let combinedAfter = "";
618
+ let patchFailed = false;
619
+ for (const hunk of hunks) {
620
+ const filePath = path.resolve(context.directory, hunk.path);
621
+ switch (hunk.type) {
622
+ case "add": {
623
+ try {
624
+ await bridge.send("write", {
625
+ file: filePath,
626
+ content: hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`,
627
+ create_dirs: true,
628
+ diagnostics: true,
629
+ });
630
+ combinedAfter += hunk.contents;
631
+ results.push(`Created ${hunk.path}`);
632
+ }
633
+ catch (e) {
634
+ patchFailed = true;
635
+ results.push(`Failed to create ${hunk.path}: ${e instanceof Error ? e.message : e}`);
636
+ }
637
+ break;
638
+ }
639
+ case "delete": {
640
+ try {
641
+ const before = await fs.promises.readFile(filePath, "utf-8").catch(() => "");
642
+ await bridge.send("delete_file", { file: filePath });
643
+ combinedBefore += before;
644
+ results.push(`Deleted ${hunk.path}`);
645
+ }
646
+ catch (e) {
647
+ patchFailed = true;
648
+ results.push(`Failed to delete ${hunk.path}: ${e instanceof Error ? e.message : e}`);
649
+ }
650
+ break;
651
+ }
652
+ case "update": {
653
+ try {
654
+ // Read original, apply chunks, write back
655
+ const original = await fs.promises.readFile(filePath, "utf-8");
656
+ const newContent = applyUpdateChunks(original, filePath, hunk.chunks);
657
+ const targetPath = hunk.move_path
658
+ ? path.resolve(context.directory, hunk.move_path)
659
+ : filePath;
660
+ const writeResult = await bridge.send("write", {
661
+ file: targetPath,
662
+ content: newContent,
663
+ create_dirs: true,
664
+ diagnostics: true,
665
+ });
666
+ // Collect diagnostics from this file
667
+ const diags = writeResult.lsp_diagnostics;
668
+ if (diags && diags.length > 0) {
669
+ const errors = diags.filter((d) => d.severity === "error");
670
+ if (errors.length > 0) {
671
+ const relPath = path.relative(context.worktree, targetPath);
672
+ const diagLines = errors.map((d) => ` Line ${d.line}: ${d.message}`).join("\n");
673
+ results.push(`\nLSP errors detected in ${relPath}, please fix:\n${diagLines}`);
674
+ }
675
+ }
676
+ // Track diff for metadata
677
+ combinedBefore += original;
678
+ combinedAfter += newContent;
679
+ if (hunk.move_path) {
680
+ await bridge.send("delete_file", { file: filePath });
681
+ results.push(`Updated and moved ${hunk.path} → ${hunk.move_path}`);
682
+ }
683
+ else {
684
+ results.push(`Updated ${hunk.path}`);
685
+ }
686
+ }
687
+ catch (e) {
688
+ patchFailed = true;
689
+ results.push(`Failed to update ${hunk.path}: ${e instanceof Error ? e.message : e}`);
690
+ break;
691
+ }
692
+ break;
693
+ }
694
+ }
695
+ }
696
+ // On failure, restore checkpoint to undo partial changes
697
+ if (patchFailed) {
698
+ try {
699
+ await bridge.send("restore_checkpoint", { name: checkpointName });
700
+ results.push("Patch failed — restored files to pre-patch state.");
701
+ }
702
+ catch {
703
+ results.push("Patch failed — checkpoint restore also failed, files may be inconsistent.");
704
+ }
705
+ return results.join("\n");
706
+ }
707
+ // Store metadata for tool.execute.after hook (match opencode built-in format)
708
+ const callID = getCallID(context);
709
+ if (callID) {
710
+ // Build per-file metadata matching opencode's files array
711
+ const files = hunks.map((h) => {
712
+ const relPath = path.relative(context.worktree, path.resolve(context.directory, h.path));
713
+ return {
714
+ filePath: path.resolve(context.directory, h.path),
715
+ relativePath: relPath,
716
+ type: h.type,
717
+ };
718
+ });
719
+ // Build title matching built-in: "Success. Updated the following files:\nM path/to/file.ts"
720
+ const fileList = files
721
+ .map((f) => {
722
+ const prefix = f.type === "add" ? "A" : f.type === "delete" ? "D" : "M";
723
+ return `${prefix} ${f.relativePath}`;
724
+ })
725
+ .join("\n");
726
+ const title = `Success. Updated the following files:\n${fileList}`;
727
+ storeToolMetadata(context.sessionID, callID, {
728
+ title,
729
+ metadata: {
730
+ diff: buildUnifiedDiff(files.length === 1 ? files[0].filePath : "patch", combinedBefore, combinedAfter),
731
+ files,
732
+ },
733
+ });
734
+ }
735
+ return results.join("\n");
736
+ },
737
+ };
738
+ }
739
+ // ---------------------------------------------------------------------------
740
+ // Delete
741
+ // ---------------------------------------------------------------------------
742
+ const DELETE_DESCRIPTION = "Delete a file with backup (recoverable via aft_safety undo).\n\n" +
743
+ "Returns: { file, deleted, backup_id } on success.\n" +
744
+ "The file content is backed up before deletion — use aft_safety undo to recover if needed.";
745
+ function createDeleteTool(ctx) {
746
+ return {
747
+ description: DELETE_DESCRIPTION,
748
+ args: {
749
+ filePath: z
750
+ .string()
751
+ .describe("Path to file to delete (absolute or relative to project root)"),
752
+ },
753
+ execute: async (args, context) => {
754
+ const bridge = ctx.pool.getBridge(context.directory);
755
+ const filePath = path.isAbsolute(args.filePath)
756
+ ? args.filePath
757
+ : path.resolve(context.directory, args.filePath);
758
+ await context.ask({
759
+ permission: "edit",
760
+ patterns: [filePath],
761
+ always: ["*"],
762
+ metadata: { action: "delete" },
763
+ });
764
+ const result = await bridge.send("delete_file", { file: filePath });
765
+ return JSON.stringify(result);
766
+ },
767
+ };
768
+ }
769
+ // ---------------------------------------------------------------------------
770
+ // Move / Rename
771
+ // ---------------------------------------------------------------------------
772
+ const MOVE_DESCRIPTION = "Move or rename a file with backup (recoverable via aft_safety undo).\n\n" +
773
+ "Creates parent directories for destination automatically.\n" +
774
+ "Falls back to copy+delete for cross-filesystem moves.\n" +
775
+ "Returns: { file, destination, moved, backup_id } on success.\n\n" +
776
+ "Note: This moves/renames files at the OS level. To move a code symbol (function, class) to another file while updating all imports, use aft_refactor with op='move' instead.";
777
+ function createMoveTool(ctx) {
778
+ return {
779
+ description: MOVE_DESCRIPTION,
780
+ args: {
781
+ filePath: z
782
+ .string()
783
+ .describe("Source file path to move (absolute or relative to project root)"),
784
+ destination: z
785
+ .string()
786
+ .describe("Destination file path (absolute or relative to project root)"),
787
+ },
788
+ execute: async (args, context) => {
789
+ const bridge = ctx.pool.getBridge(context.directory);
790
+ const filePath = path.isAbsolute(args.filePath)
791
+ ? args.filePath
792
+ : path.resolve(context.directory, args.filePath);
793
+ const destPath = path.isAbsolute(args.destination)
794
+ ? args.destination
795
+ : path.resolve(context.directory, args.destination);
796
+ await context.ask({
797
+ permission: "edit",
798
+ patterns: [filePath, destPath],
799
+ always: ["*"],
800
+ metadata: { action: "move" },
801
+ });
802
+ const result = await bridge.send("move_file", {
803
+ file: filePath,
804
+ destination: destPath,
805
+ });
806
+ return JSON.stringify(result);
807
+ },
808
+ };
809
+ }
810
+ // ---------------------------------------------------------------------------
811
+ // Exports
812
+ // ---------------------------------------------------------------------------
813
+ /**
814
+ * Returns hoisted tools keyed by opencode's built-in names.
815
+ * Overrides: read, write, edit, apply_patch.
816
+ */
817
+ export function hoistedTools(ctx) {
818
+ return {
819
+ read: createReadTool(ctx),
820
+ write: createWriteTool(ctx, "edit"),
821
+ edit: createEditTool(ctx, "write"),
822
+ apply_patch: createApplyPatchTool(ctx),
823
+ aft_delete: createDeleteTool(ctx),
824
+ aft_move: createMoveTool(ctx),
825
+ };
826
+ }
827
+ /**
828
+ * Returns the same tools with aft_ prefix (for when hoisting is disabled).
829
+ */
830
+ export function aftPrefixedTools(ctx) {
831
+ const aftEditTool = createEditTool(ctx, "aft_write");
832
+ return {
833
+ aft_read: createReadTool(ctx),
834
+ aft_write: createWriteTool(ctx, "aft_edit"),
835
+ aft_edit: {
836
+ ...aftEditTool,
837
+ execute: async (args, context) => {
838
+ const argRecord = args;
839
+ const normalizedArgs = argRecord.mode !== undefined &&
840
+ argRecord.filePath === undefined &&
841
+ typeof argRecord.file === "string"
842
+ ? { ...argRecord, filePath: argRecord.file }
843
+ : argRecord;
844
+ return aftEditTool.execute(normalizedArgs, context);
845
+ },
846
+ },
847
+ aft_apply_patch: createApplyPatchTool(ctx),
848
+ aft_delete: createDeleteTool(ctx),
849
+ aft_move: createMoveTool(ctx),
850
+ };
851
+ }
852
+ //# sourceMappingURL=hoisted.js.map