Dax Raad 8 месяцев назад
Родитель
Сommit
fb88705bdc

+ 4 - 0
packages/opencode/src/provider/provider.ts

@@ -18,6 +18,8 @@ import { LspHoverTool } from "../tool/lsp-hover"
 import { PatchTool } from "../tool/patch"
 import { ReadTool } from "../tool/read"
 import type { Tool } from "../tool/tool"
+import { MultiEditTool } from "../tool/multiedit"
+import { WriteTool } from "../tool/write"
 
 export namespace Provider {
   const log = Log.create({ service: "provider" })
@@ -174,6 +176,8 @@ export namespace Provider {
     PatchTool,
     ReadTool,
     EditTool,
+    MultiEditTool,
+    WriteTool,
   ]
   const TOOL_MAPPING: Record<string, Tool.Info[]> = {
     anthropic: TOOLS.filter((t) => t.id !== "opencode.patch"),

+ 1 - 10
packages/opencode/src/tool/edit.ts

@@ -52,21 +52,12 @@ export const EditTool = Tool.define({
         return
       }
 
-      const read = FileTimes.get(ctx.sessionID, filepath)
-      if (!read)
-        throw new Error(
-          `You must read the file ${filepath} before editing it. Use the View tool first`,
-        )
       const file = Bun.file(filepath)
       if (!(await file.exists())) throw new Error(`File ${filepath} not found`)
       const stats = await file.stat()
       if (stats.isDirectory())
         throw new Error(`Path is a directory, not a file: ${filepath}`)
-      if (stats.mtime.getTime() > read.getTime())
-        throw new Error(
-          `File ${filepath} has been modified since it was last read.\nLast modification: ${read.toISOString()}\nLast read: ${stats.mtime.toISOString()}\n\nPlease read the file again before modifying it.`,
-        )
-
+      await FileTimes.assert(ctx.sessionID, filepath)
       contentOld = await file.text()
       const index = contentOld.indexOf(params.oldString)
       if (index === -1)

+ 2 - 2
packages/opencode/src/tool/ls.ts

@@ -22,8 +22,8 @@ export const ListTool = Tool.define({
   id: "opencode.list",
   description: DESCRIPTION,
   parameters: z.object({
-    path: z.string().optional(),
-    ignore: z.array(z.string()).optional(),
+    path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
+    ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
   }),
   async execute(params) {
     const app = App.info()

+ 37 - 0
packages/opencode/src/tool/multiedit.ts

@@ -0,0 +1,37 @@
+import { z } from "zod"
+import { Tool } from "./tool"
+import { EditTool } from "./edit"
+import DESCRIPTION from "./multiedit.txt"
+
+export const MultiEditTool = Tool.define({
+  id: "opencode.multiedit",
+  description: DESCRIPTION,
+  parameters: z.object({
+    filePath: z.string().describe("The absolute path to the file to modify"),
+    edits: z
+      .array(EditTool.parameters)
+      .describe("Array of edit operations to perform sequentially on the file"),
+  }),
+  async execute(params, ctx) {
+    const results = []
+    for (const [, edit] of params.edits.entries()) {
+      const result = await EditTool.execute(
+        {
+          filePath: params.filePath,
+          oldString: edit.oldString,
+          newString: edit.newString,
+          replaceAll: edit.replaceAll,
+        },
+        ctx,
+      )
+      results.push(result)
+    }
+
+    return {
+      metadata: {
+        results: results.map((r) => r.metadata),
+      },
+      output: results.at(-1)!.output,
+    }
+  },
+})

+ 1 - 12
packages/opencode/src/tool/patch.ts

@@ -254,24 +254,13 @@ export const PatchTool = Tool.define({
         absPath = path.resolve(process.cwd(), absPath)
       }
 
-      if (!FileTimes.get(ctx.sessionID, absPath)) {
-        throw new Error(
-          `you must read the file ${filePath} before patching it. Use the FileRead tool first`,
-        )
-      }
+      await FileTimes.assert(ctx.sessionID, absPath)
 
       try {
         const stats = await fs.stat(absPath)
         if (stats.isDirectory()) {
           throw new Error(`path is a directory, not a file: ${absPath}`)
         }
-
-        const lastRead = FileTimes.get(ctx.sessionID, absPath)
-        if (lastRead && stats.mtime > lastRead) {
-          throw new Error(
-            `file ${absPath} has been modified since it was last read (mod time: ${stats.mtime.toISOString()}, last read: ${lastRead.toISOString()})`,
-          )
-        }
       } catch (error: any) {
         if (error.code === "ENOENT") {
           throw new Error(`file not found: ${absPath}`)

+ 14 - 0
packages/opencode/src/tool/util/file-times.ts

@@ -21,4 +21,18 @@ export namespace FileTimes {
   export function get(sessionID: string, file: string) {
     return state().read[sessionID]?.[file]
   }
+
+  export async function assert(sessionID: string, filepath: string) {
+    const time = get(sessionID, filepath)
+    if (!time)
+      throw new Error(
+        `You must read the file ${filepath} before overwriting it. Use the Read tool first`,
+      )
+    const stats = await Bun.file(filepath).stat()
+    if (stats.mtime.getTime() > time.getTime()) {
+      throw new Error(
+        `File ${filepath} has been modified since it was last read.\nLast modification: ${stats.mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
+      )
+    }
+  }
 }

+ 67 - 0
packages/opencode/src/tool/write.ts

@@ -0,0 +1,67 @@
+import { z } from "zod"
+import * as path from "path"
+import { Tool } from "./tool"
+import { FileTimes } from "./util/file-times"
+import { LSP } from "../lsp"
+import { Permission } from "../permission"
+import DESCRIPTION from "./write.txt"
+
+export const WriteTool = Tool.define({
+  id: "opencode.write",
+  description: DESCRIPTION,
+  parameters: z.object({
+    filePath: z
+      .string()
+      .describe(
+        "The absolute path to the file to write (must be absolute, not relative)",
+      ),
+    content: z.string().describe("The content to write to the file"),
+  }),
+  async execute(params, ctx) {
+    const filepath = path.isAbsolute(params.filePath)
+      ? params.filePath
+      : path.join(process.cwd(), params.filePath)
+
+    const file = Bun.file(filepath)
+    const exists = await file.exists()
+    if (exists) await FileTimes.assert(ctx.sessionID, filepath)
+
+    await Permission.ask({
+      id: "opencode.write",
+      sessionID: ctx.sessionID,
+      title: exists
+        ? "Overwrite this file: " + filepath
+        : "Create new file: " + filepath,
+      metadata: {
+        filePath: filepath,
+        content: params.content,
+        exists,
+      },
+    })
+
+    await Bun.write(filepath, params.content)
+    FileTimes.read(ctx.sessionID, filepath)
+
+    let output = ""
+    await LSP.file(filepath)
+    const diagnostics = await LSP.diagnostics()
+    for (const [file, issues] of Object.entries(diagnostics)) {
+      if (issues.length === 0) continue
+      if (file === filepath) {
+        output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
+        continue
+      }
+      output += `\n<project_diagnostics>\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</project_diagnostics>\n`
+    }
+
+    return {
+      metadata: {
+        diagnostics,
+        filepath,
+        exists: exists,
+      },
+      output,
+    }
+  },
+})
+