diff --git a/README.md b/README.md index 1e16bde8cc56..b51935043e5d 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,17 @@ OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bas XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ``` +### Morph Fast Apply (Optional) + +For faster, more accurate code edits, opencode can integrate with [Morph](https://morphllm.com) - an AI model specialized for code merging at 4500+ tokens/second with 98.8% accuracy. + +```bash +# Enable Morph Fast Apply +export MORPH_API_KEY=your_api_key +``` + +When enabled, opencode's edit tool automatically uses Morph's intelligent code merging instead of search-and-replace, supporting multiple edits and `// ... existing code ...` syntax. + ### Documentation For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs). diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 8be41ecffed2..ee4da0bc180c 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -9,7 +9,10 @@ import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" import { Permission } from "../permission" +// @ts-ignore import DESCRIPTION from "./edit.txt" +// @ts-ignore +import MORPH_DESCRIPTION from "./edit-morph.txt" import { App } from "../app/app" import { File } from "../file" import { Bus } from "../bus" @@ -17,65 +20,165 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Agent } from "../agent/agent" -export const EditTool = Tool.define("edit", { - description: DESCRIPTION, - parameters: z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), - }), - async execute(params, ctx) { - if (!params.filePath) { - throw new Error("filePath is required") - } +// OpenAI-compatible client for Morph API +class MorphClient { + private apiKey: string + private baseURL = "https://api.morphllm.com/v1" + + constructor(apiKey: string) { + this.apiKey = apiKey + } - if (params.oldString === params.newString) { - throw new Error("oldString and newString must be different") + async apply(instruction: string, initialCode: string, codeEdit: string): Promise { + const response = await fetch(`${this.baseURL}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: "morph-v3-large", + messages: [ + { + role: "user", + content: `${instruction}\n${initialCode}\n${codeEdit}`, + }, + ], + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Morph API error: ${response.status} ${response.statusText}\n${errorText}`) } - const app = App.info() - const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath) - if (!Filesystem.contains(app.path.cwd, filePath)) { - throw new Error(`File ${filePath} is not in the current working directory`) + const result = await response.json() + return result.choices[0].message.content + } +} + +async function executeMorphEdit( + params: { target_file: string; instructions: string; code_edit: string }, + ctx: any, + morphApiKey: string +) { + if (!params.target_file) { + throw new Error("target_file is required") + } + + if (!params.instructions) { + throw new Error("instructions is required") + } + + if (!params.code_edit) { + throw new Error("code_edit is required") + } + + const app = App.info() + const filePath = path.isAbsolute(params.target_file) ? params.target_file : path.join(app.path.cwd, params.target_file) + + if (!Filesystem.contains(app.path.cwd, filePath)) { + throw new Error(`File ${filePath} is not in the current working directory`) + } + + const agent = await Agent.get(ctx.agent) + + // Read the existing file + const file = Bun.file(filePath) + const stats = await file.stat().catch(() => {}) + if (!stats) throw new Error(`File ${filePath} not found`) + if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) + + await FileTime.assert(ctx.sessionID, filePath) + const initialCode = await file.text() + + // Use Morph API to apply the edit + const morphClient = new MorphClient(morphApiKey) + let mergedCode: string + + try { + mergedCode = await morphClient.apply(params.instructions, initialCode, params.code_edit) + } catch (error) { + throw new Error(`Failed to apply edit with Morph: ${error instanceof Error ? error.message : String(error)}`) + } + + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, initialCode, mergedCode)) + + // Check permissions if needed + if (agent.permission.edit === "ask") { + await Permission.ask({ + type: "edit", + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: "🚀 Morph Fast Apply: " + filePath, + metadata: { + filePath, + diff, + morphApplied: true, + }, + }) + } + + // Write the merged code to file + await Bun.write(filePath, mergedCode) + await Bus.publish(File.Event.Edited, { + file: filePath, + }) + + FileTime.read(ctx.sessionID, filePath) + + let output = "" + await LSP.touchFile(filePath, true) + 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\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` + continue } + output += `\n\n${file}\n${issues + .filter((item) => item.severity === 1) + .map(LSP.Diagnostic.pretty) + .join("\n")}\n\n` + } - const agent = await Agent.get(ctx.agent) - let diff = "" - let contentOld = "" - let contentNew = "" - await (async () => { - if (params.oldString === "") { - contentNew = params.newString - diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - if (agent.permission.edit === "ask") { - await Permission.ask({ - type: "edit", - sessionID: ctx.sessionID, - messageID: ctx.messageID, - callID: ctx.callID, - title: "Edit this file: " + filePath, - metadata: { - filePath, - diff, - }, - }) - } - await Bun.write(filePath, params.newString) - await Bus.publish(File.Event.Edited, { - file: filePath, - }) - return - } + return { + metadata: { + diagnostics, + diff, + morphApplied: true, + }, + title: `🚀 ${path.relative(app.path.root, filePath)}`, + output: output || `Successfully applied edit using Morph Fast Apply:\n\n${diff}`, + } +} + +async function executeRegularEdit( + params: { filePath: string; oldString: string; newString: string; replaceAll?: boolean }, + ctx: any +) { + if (!params.filePath) { + throw new Error("filePath is required") + } - const file = Bun.file(filePath) - const stats = await file.stat().catch(() => {}) - if (!stats) throw new Error(`File ${filePath} not found`) - if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) - await FileTime.assert(ctx.sessionID, filePath) - contentOld = await file.text() - contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) + if (params.oldString === params.newString) { + throw new Error("oldString and newString must be different") + } + + const app = App.info() + const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath) + if (!Filesystem.contains(app.path.cwd, filePath)) { + throw new Error(`File ${filePath} is not in the current working directory`) + } + const agent = await Agent.get(ctx.agent) + let diff = "" + let contentOld = "" + let contentNew = "" + await (async () => { + if (params.oldString === "") { + contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) if (agent.permission.edit === "ask") { await Permission.ask({ @@ -83,7 +186,6 @@ export const EditTool = Tool.define("edit", { sessionID: ctx.sessionID, messageID: ctx.messageID, callID: ctx.callID, - pattern: filePath, title: "Edit this file: " + filePath, metadata: { filePath, @@ -91,43 +193,105 @@ export const EditTool = Tool.define("edit", { }, }) } - - await file.write(contentNew) + await Bun.write(filePath, params.newString) await Bus.publish(File.Event.Edited, { file: filePath, }) - contentNew = await file.text() - diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) - })() - - FileTime.read(ctx.sessionID, filePath) - - let output = "" - await LSP.touchFile(filePath, true) - 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\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` - continue - } - output += `\n\n${file}\n${issues - // TODO: may want to make more leniant for eslint - .filter((item) => item.severity === 1) - .map(LSP.Diagnostic.pretty) - .join("\n")}\n\n` + return + } + + const file = Bun.file(filePath) + const stats = await file.stat().catch(() => {}) + if (!stats) throw new Error(`File ${filePath} not found`) + if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) + await FileTime.assert(ctx.sessionID, filePath) + contentOld = await file.text() + contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll) + + diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + if (agent.permission.edit === "ask") { + await Permission.ask({ + type: "edit", + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + pattern: filePath, + title: "Edit this file: " + filePath, + metadata: { + filePath, + diff, + }, + }) } + await file.write(contentNew) + await Bus.publish(File.Event.Edited, { + file: filePath, + }) + contentNew = await file.text() + diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + })() + + FileTime.read(ctx.sessionID, filePath) + + let output = "" + await LSP.touchFile(filePath, true) + 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\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` + continue + } + output += `\n\n${file}\n${issues + // TODO: may want to make more leniant for eslint + .filter((item) => item.severity === 1) + .map(LSP.Diagnostic.pretty) + .join("\n")}\n\n` + } + + return { + metadata: { + diagnostics, + diff, + }, + title: `${path.relative(app.path.root, filePath)}`, + output, + } +} + +export const EditTool = Tool.define("edit", (async () => { + const morphApiKey = process.env['MORPH_API_KEY'] + + if (morphApiKey) { + // Morph Fast Apply mode return { - metadata: { - diagnostics, - diff, - }, - title: `${path.relative(app.path.root, filePath)}`, - output, + description: MORPH_DESCRIPTION as string, + parameters: z.object({ + target_file: z.string().describe("The target file to modify"), + instructions: z.string().describe("A single sentence written in the first person describing what you're changing. Used to help disambiguate uncertainty in the edit."), + code_edit: z.string().describe("Specify ONLY the precise lines of code that you wish to edit. Use `// ... existing code ...` for unchanged sections."), + }), + async execute(params: any, ctx: any) { + return executeMorphEdit(params, ctx, morphApiKey) + } + } + } else { + // Regular search-and-replace mode + return { + description: DESCRIPTION as string, + parameters: z.object({ + filePath: z.string().describe("The absolute path to the file to modify"), + oldString: z.string().describe("The text to replace"), + newString: z.string().describe("The text to replace it with (must be different from oldString)"), + replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), + }), + async execute(params: any, ctx: any) { + return executeRegularEdit(params, ctx) + } } - }, -}) + } +}) as any) export type Replacer = (content: string, find: string) => Generator @@ -606,7 +770,8 @@ export function replace(content: string, oldString: string, newString: string, r // ContextAwareReplacer, // MultiOccurrenceReplacer, ]) { - for (const search of replacer(content, oldString)) { + const searches = Array.from(replacer(content, oldString)) + for (const search of searches) { const index = content.indexOf(search) if (index === -1) continue if (replaceAll) { diff --git a/packages/opencode/src/tool/morphedit.ts b/packages/opencode/src/tool/morphedit.ts new file mode 100644 index 000000000000..02dc12b85660 --- /dev/null +++ b/packages/opencode/src/tool/morphedit.ts @@ -0,0 +1,164 @@ +import { z } from "zod" +import * as path from "path" +import { Tool } from "./tool" +import { createTwoFilesPatch } from "diff" +import { Permission } from "../permission" +import DESCRIPTION from "./morphedit.txt" +import { App } from "../app/app" +import { File } from "../file" +import { Bus } from "../bus" +import { FileTime } from "../file/time" +import { Filesystem } from "../util/filesystem" +import { Agent } from "../agent/agent" +import { LSP } from "../lsp" + +// OpenAI-compatible client for Morph API +class MorphClient { + private apiKey: string + private baseURL = "https://api.morphllm.com/v1" + + constructor(apiKey: string) { + this.apiKey = apiKey + } + + async apply(instruction: string, initialCode: string, codeEdit: string): Promise { + const response = await fetch(`${this.baseURL}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: "morph-v3-large", + messages: [ + { + role: "user", + content: `${instruction}\n${initialCode}\n${codeEdit}`, + }, + ], + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Morph API error: ${response.status} ${response.statusText}\n${errorText}`) + } + + const result = await response.json() + return result.choices[0].message.content + } +} + +function trimDiff(diff: string): string { + return diff + .split("\n") + .slice(4) // Remove the header lines + .join("\n") +} + +export const MorphEditTool = Tool.define("edit", { + description: DESCRIPTION, + parameters: z.object({ + target_file: z.string().describe("The target file to modify"), + instructions: z.string().describe("A single sentence written in the first person describing what you're changing. Used to help disambiguate uncertainty in the edit."), + code_edit: z.string().describe("Specify ONLY the precise lines of code that you wish to edit. Use `// ... existing code ...` for unchanged sections."), + }), + async execute(params, ctx) { + if (!params.target_file) { + throw new Error("target_file is required") + } + + if (!params.instructions) { + throw new Error("instructions is required") + } + + if (!params.code_edit) { + throw new Error("code_edit is required") + } + + // Check for Morph API key + const morphApiKey = process.env['MORPH_API_KEY'] + if (!morphApiKey) { + throw new Error("MORPH_API_KEY environment variable is required for morphedit tool") + } + + const app = App.info() + const filePath = path.isAbsolute(params.target_file) ? params.target_file : path.join(app.path.cwd, params.target_file) + + if (!Filesystem.contains(app.path.cwd, filePath)) { + throw new Error(`File ${filePath} is not in the current working directory`) + } + + const agent = await Agent.get(ctx.agent) + + // Read the existing file + const file = Bun.file(filePath) + const stats = await file.stat().catch(() => {}) + if (!stats) throw new Error(`File ${filePath} not found`) + if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`) + + await FileTime.assert(ctx.sessionID, filePath) + const initialCode = await file.text() + + // Use Morph API to apply the edit + const morphClient = new MorphClient(morphApiKey) + let mergedCode: string + + try { + mergedCode = await morphClient.apply(params.instructions, initialCode, params.code_edit) + } catch (error) { + throw new Error(`Failed to apply edit with Morph: ${error instanceof Error ? error.message : String(error)}`) + } + + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, initialCode, mergedCode)) + + // Check permissions if needed + if (agent.permission.edit === "ask") { + await Permission.ask({ + type: "edit", + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: "🚀 Morph Fast Apply: " + filePath, + metadata: { + filePath, + diff, + morphApplied: true, + }, + }) + } + + // Write the merged code to file + await Bun.write(filePath, mergedCode) + await Bus.publish(File.Event.Edited, { + file: filePath, + }) + + FileTime.read(ctx.sessionID, filePath) + + let output = "" + await LSP.touchFile(filePath, true) + 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\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` + continue + } + output += `\n\n${file}\n${issues + .filter((item) => item.severity === 1) + .map(LSP.Diagnostic.pretty) + .join("\n")}\n\n` + } + + return { + metadata: { + diagnostics, + diff, + morphApplied: true, + }, + title: `🚀 ${path.relative(app.path.root, filePath)}`, + output: output || `Successfully applied edit using Morph Fast Apply:\n\n${diff}`, + } + }, +}) \ No newline at end of file diff --git a/packages/opencode/src/tool/morphedit.txt b/packages/opencode/src/tool/morphedit.txt new file mode 100644 index 000000000000..55f3c1b18af2 --- /dev/null +++ b/packages/opencode/src/tool/morphedit.txt @@ -0,0 +1,21 @@ +MORPH FAST APPLY - Use this tool to make an edit to an existing file. + +This will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write. +When writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines. + +For example: + +// ... existing code ... +FIRST_EDIT +// ... existing code ... +SECOND_EDIT +// ... existing code ... +THIRD_EDIT +// ... existing code ... + +You should still bias towards repeating as few lines of the original file as possible to convey the change. +But, each edit should contain sufficient context of unchanged lines around the code you're editing to resolve ambiguity. +DO NOT omit spans of pre-existing code (or comments) without using the // ... existing code ... comment to indicate its absence. If you omit the existing code comment, the model may inadvertently delete these lines. +If you plan on deleting a section, you must provide context before and after to delete it. If the initial code is ```code \n Block 1 \n Block 2 \n Block 3 \n code```, and you want to remove Block 2, you would output ```// ... existing code ... \n Block 1 \n Block 3 \n // ... existing code ...```. +Make sure it is clear what the edit should be, and where it should be applied. +Make edits to a file in a single edit_file instead of multiple edit_file calls to the same file. The apply model can handle many distinct edits at once. \ No newline at end of file diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9e8f9638c84f..3ec3752e9baf 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,6 +1,7 @@ import z from "zod" import { BashTool } from "./bash" import { EditTool } from "./edit" +import { MorphEditTool } from "./morphedit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { ListTool } from "./ls" @@ -14,10 +15,13 @@ import { InvalidTool } from "./invalid" import type { Agent } from "../agent/agent" export namespace ToolRegistry { + // Use Morph Fast Apply as the default edit tool if MORPH_API_KEY is present + const DefaultEditTool = process.env['MORPH_API_KEY'] ? MorphEditTool : EditTool + const ALL = [ InvalidTool, BashTool, - EditTool, + DefaultEditTool, WebFetchTool, GlobTool, GrepTool, @@ -45,21 +49,21 @@ export namespace ToolRegistry { if (providerID === "openai") { return result.map((t) => ({ ...t, - parameters: optionalToNullable(t.parameters), + parameters: optionalToNullable(t.parameters as z.ZodTypeAny), })) } if (providerID === "azure") { return result.map((t) => ({ ...t, - parameters: optionalToNullable(t.parameters), + parameters: optionalToNullable(t.parameters as z.ZodTypeAny), })) } if (providerID === "google") { return result.map((t) => ({ ...t, - parameters: sanitizeGeminiParameters(t.parameters), + parameters: sanitizeGeminiParameters(t.parameters as z.ZodTypeAny), })) }