From cdf613b5270bd62345e9bba59a5c0e155a075600 Mon Sep 17 00:00:00 2001 From: bhaktatejas922 Date: Wed, 13 Aug 2025 16:28:31 -0700 Subject: [PATCH 1/3] morph file edit tool --- packages/opencode/src/tool/morphedit.ts | 146 +++++++++++++++++++++++ packages/opencode/src/tool/morphedit.txt | 21 ++++ packages/opencode/src/tool/registry.ts | 12 ++ 3 files changed, 179 insertions(+) create mode 100644 packages/opencode/src/tool/morphedit.ts create mode 100644 packages/opencode/src/tool/morphedit.txt diff --git a/packages/opencode/src/tool/morphedit.ts b/packages/opencode/src/tool/morphedit.ts new file mode 100644 index 000000000000..f10314a3dc47 --- /dev/null +++ b/packages/opencode/src/tool/morphedit.ts @@ -0,0 +1,146 @@ +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" + +// 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("morphedit", { + 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: "Edit this file: " + filePath, + metadata: { + filePath, + diff, + }, + }) + } + + // Write the merged code to file + await Bun.write(filePath, mergedCode) + await Bus.publish(File.Event.Edited, { + file: filePath, + }) + + // Return the result + return { + title: path.relative(app.path.root, filePath), + metadata: { + filePath, + diff, + morphApplied: true, + }, + output: `Successfully applied edit to ${path.relative(app.path.root, filePath)} 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..bf63a5fa8a5b --- /dev/null +++ b/packages/opencode/src/tool/morphedit.txt @@ -0,0 +1,21 @@ +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..3d98a711d93c 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" @@ -18,6 +19,7 @@ export namespace ToolRegistry { InvalidTool, BashTool, EditTool, + MorphEditTool, WebFetchTool, GlobTool, GrepTool, @@ -91,6 +93,16 @@ export namespace ToolRegistry { result["todoread"] = false } + // If MORPH_API_KEY exists, only enable morphedit and disable other edit tools + if (process.env.MORPH_API_KEY) { + result["edit"] = false + result["multiedit"] = false + result["patch"] = false + } else { + // If no MORPH_API_KEY, disable morphedit tool + result["morphedit"] = false + } + return result } From 92eee5ee1e093fbea6a5447ee150d760c71ca42b Mon Sep 17 00:00:00 2001 From: bhaktatejas922 Date: Tue, 19 Aug 2025 01:28:13 -0700 Subject: [PATCH 2/3] morphedit as edit tool --- README.md | 11 + .../tool/{morphedit.txt => edit-morph.txt} | 2 +- packages/opencode/src/tool/edit.ts | 333 +++++++++++++----- packages/opencode/src/tool/morphedit.ts | 146 -------- packages/opencode/src/tool/registry.ts | 18 +- 5 files changed, 264 insertions(+), 246 deletions(-) rename packages/opencode/src/tool/{morphedit.txt => edit-morph.txt} (95%) delete mode 100644 packages/opencode/src/tool/morphedit.ts 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/morphedit.txt b/packages/opencode/src/tool/edit-morph.txt similarity index 95% rename from packages/opencode/src/tool/morphedit.txt rename to packages/opencode/src/tool/edit-morph.txt index bf63a5fa8a5b..062025b89db9 100644 --- a/packages/opencode/src/tool/morphedit.txt +++ b/packages/opencode/src/tool/edit-morph.txt @@ -1,4 +1,4 @@ -Use this tool to make an edit to an existing file. +🚀 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. 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 deleted file mode 100644 index f10314a3dc47..000000000000 --- a/packages/opencode/src/tool/morphedit.ts +++ /dev/null @@ -1,146 +0,0 @@ -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" - -// 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("morphedit", { - 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: "Edit this file: " + filePath, - metadata: { - filePath, - diff, - }, - }) - } - - // Write the merged code to file - await Bun.write(filePath, mergedCode) - await Bus.publish(File.Event.Edited, { - file: filePath, - }) - - // Return the result - return { - title: path.relative(app.path.root, filePath), - metadata: { - filePath, - diff, - morphApplied: true, - }, - output: `Successfully applied edit to ${path.relative(app.path.root, filePath)} using Morph Fast Apply:\n\n${diff}`, - } - }, -}) \ No newline at end of file diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 3d98a711d93c..75e998b519be 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,7 +1,6 @@ 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" @@ -19,7 +18,6 @@ export namespace ToolRegistry { InvalidTool, BashTool, EditTool, - MorphEditTool, WebFetchTool, GlobTool, GrepTool, @@ -47,21 +45,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), })) } @@ -93,16 +91,6 @@ export namespace ToolRegistry { result["todoread"] = false } - // If MORPH_API_KEY exists, only enable morphedit and disable other edit tools - if (process.env.MORPH_API_KEY) { - result["edit"] = false - result["multiedit"] = false - result["patch"] = false - } else { - // If no MORPH_API_KEY, disable morphedit tool - result["morphedit"] = false - } - return result } From 9c247a72e35e2f2dd89f7185c753cd521cbeef29 Mon Sep 17 00:00:00 2001 From: bhaktatejas922 Date: Wed, 20 Aug 2025 16:07:15 -0700 Subject: [PATCH 3/3] addressingcomments --- packages/opencode/src/tool/morphedit.ts | 164 ++++++++++++++++++ .../tool/{edit-morph.txt => morphedit.txt} | 2 +- packages/opencode/src/tool/registry.ts | 6 +- 3 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/tool/morphedit.ts rename packages/opencode/src/tool/{edit-morph.txt => morphedit.txt} (95%) 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/edit-morph.txt b/packages/opencode/src/tool/morphedit.txt similarity index 95% rename from packages/opencode/src/tool/edit-morph.txt rename to packages/opencode/src/tool/morphedit.txt index 062025b89db9..55f3c1b18af2 100644 --- a/packages/opencode/src/tool/edit-morph.txt +++ b/packages/opencode/src/tool/morphedit.txt @@ -1,4 +1,4 @@ -🚀 MORPH FAST APPLY - Use this tool to make an edit to an existing file. +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. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 75e998b519be..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,