export * as PatchTool from "./patch" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Schema } from "effect" import { PlatformError } from "effect/PlatformError" import path from "path" import { Bom } from "@opencode-ai/util/bom" import { FSUtil } from "@opencode-ai/util/fs-util" import { Formatter } from "../../formatter" import { Location } from "../../location" import { Patch } from "@opencode-ai/util/patch" import { Permission } from "../../permission" import DESCRIPTION from "../patch.txt" export const name = "patch" export const Input = Schema.Struct({ patchText: Schema.String.annotate({ description: "The full patch text describing add, update, and delete operations", }), }) export const Applied = Schema.Struct({ type: Schema.Literals(["add", "update", "delete"]), resource: Schema.String, target: Schema.String, }) export const Output = Schema.Struct({ applied: Schema.Array(Applied), files: Schema.Array(FileDiff.Info), }) export type Output = typeof Output.Type export const toModelOutput = (output: Output) => [ "Success. Updated the following files:", ...output.applied.map( (item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`, ), ].join("\n") type Prepared = | (Extract & { readonly target: Target readonly before: string readonly after: string }) | (Extract & { readonly target: Target readonly content: string readonly before: string readonly after: string readonly moveTarget?: Target }) interface Target { readonly absolute: string readonly resource: string readonly externalDirectory?: { readonly directory: string readonly resource: string } } export const Plugin = { id: "opencode.tool.patch", effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { const fs = yield* FSUtil.Service const formatter = yield* Formatter.Service const location = yield* Location.Service const permission = yield* Permission.Service yield* ctx.tool .transform((draft) => draft.add({ name, options: { codemode: false, permission: "edit" }, description: DESCRIPTION, input: Input, output: Output, execute: (input, context) => { const applied: Array = [] const fail = (operation: string, error: unknown) => { const completed = applied.map((item) => item.resource).join(", ") return new ToolFailure({ message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`, }) } return Effect.gen(function* () { const source = { type: "tool" as const, messageID: context.messageID, id: context.id, } if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" }) const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe( Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })), ) if (hunks.length === 0) { return yield* new ToolFailure({ message: "patch rejected: empty patch" }) } const prepared: Prepared[] = [] const targets: Target[] = [] const updates = new Map() for (const hunk of hunks) { yield* Effect.gen(function* () { const target = resolveTarget(location, hunk.path) targets.push(target) if (target.externalDirectory) { yield* permission.assert({ action: "external_directory", resources: [target.externalDirectory.resource], save: [target.externalDirectory.resource], metadata: { filepath: target.absolute, parentDir: target.externalDirectory.directory, }, sessionID: context.sessionID, agent: context.agent, source, }) } if (hunk.type === "add") { prepared.push({ ...hunk, target, before: "", after: Bom.split( hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`, ).text, }) return } if (hunk.type === "delete") { const content = yield* Bom.readFile(fs, target.absolute).pipe( Effect.mapError( (error) => new ToolFailure({ message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`, }), ), ) prepared.push({ ...hunk, target, before: content.text, after: "" }) return } const previous = updates.get(target.absolute) const original = previous ?? (yield* Effect.gen(function* () { const stats = yield* fs.stat(target.absolute).pipe( Effect.mapError( (error) => new ToolFailure({ message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`, }), ), ) if (stats.type === "Directory") { return yield* new ToolFailure({ message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`, }) } const content = yield* Bom.readFile(fs, target.absolute).pipe( Effect.mapError( (error) => new ToolFailure({ message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`, }), ), ) return Bom.join(content.text, content.bom) })) const before = Bom.split(original).text const update = yield* Effect.try({ try: () => Patch.derive(hunk.path, hunk.chunks, original), catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }), }) const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined if (moveTarget) targets.push(moveTarget) if (moveTarget?.externalDirectory) { yield* permission.assert({ action: "external_directory", resources: [moveTarget.externalDirectory.resource], save: [moveTarget.externalDirectory.resource], metadata: { filepath: moveTarget.absolute, parentDir: moveTarget.externalDirectory.directory, }, sessionID: context.sessionID, agent: context.agent, source, }) } prepared.push({ ...hunk, target, content: Patch.joinBom(update.content, update.bom), before, after: update.content, moveTarget, }) if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom)) }).pipe( Effect.mapError((error) => error instanceof ToolFailure ? error : new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }), ), ) } const patchFiles = prepared.map((change) => patchFile(change)) yield* permission.assert({ action: "edit", resources: [...new Set(targets.map((target) => target.resource))], save: ["*"], metadata: { filepath: targets.map((target) => target.resource).join(", "), diff: patchFiles.map((file) => `${file.patch}\n`).join(""), files: patchFiles, }, sessionID: context.sessionID, agent: context.agent, source, }) yield* Effect.forEach( prepared, (change) => Effect.gen(function* () { if (change.type === "add") { yield* fs .writeWithDirs( change.target.absolute, change.contents.endsWith("\n") || change.contents === "" ? change.contents : `${change.contents}\n`, ) .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error))) applied.push({ type: change.type, resource: change.target.resource, target: change.target.absolute, }) return } if (change.type === "delete") { yield* fs .remove(change.target.absolute) .pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error))) applied.push({ type: change.type, resource: change.target.resource, target: change.target.absolute, }) return } if (change.moveTarget) { const moveTarget = change.moveTarget yield* fs .writeWithDirs(moveTarget.absolute, change.content) .pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error))) yield* fs .remove(change.target.absolute) .pipe( Effect.mapError((error) => fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error), ), ) applied.push({ type: change.type, resource: change.moveTarget.resource, target: change.moveTarget.absolute, }) return } yield* fs .writeWithDirs(change.target.absolute, change.content) .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error))) applied.push({ type: change.type, resource: change.target.resource, target: change.target.absolute, }) }), { discard: true }, ) const formatted = new Map() yield* Effect.forEach( [...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))], (target) => Effect.gen(function* () { const current = yield* Bom.readFile(fs, target).pipe( Effect.mapError((error) => fail(`Failed to read ${target}`, error)), ) formatted.set( target, (yield* formatter.file(target)) ? yield* Bom.syncFile(fs, target, current.bom).pipe( Effect.mapError((error) => fail(`Failed to sync ${target}`, error)), ) : current.text, ) }), { discard: true }, ) const files = yield* Effect.forEach(prepared, (change) => { if (change.type === "delete") return Effect.succeed(patchFile(change)) const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target return Effect.succeed(patchFile(change, formatted.get(target.absolute))) }) return { applied, files } }).pipe( Effect.map((output) => ({ output, content: toModelOutput(output), metadata: { files: output.files }, })), Effect.mapError((error) => error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }), ), ) }, }), ) .pipe(Effect.orDie) yield* ctx.session.hook("context", (event) => Effect.sync(() => { const usePatch = event.model.id.includes("gpt-") && !event.model.id.includes("oss") && !event.model.id.includes("gpt-4") if (usePatch) { delete event.tools.edit delete event.tools.write return } delete event.tools.patch }), ) }), } function errorMessage(error: unknown) { if (error instanceof PlatformError) { if (error.reason._tag === "NotFound") return "file does not exist" return error.reason.description ?? error.reason.message } return error instanceof Error ? error.message : String(error) } function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type { const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource const patch = trimDiff(createTwoFilesPatch(change.target.absolute, change.target.absolute, change.before, after)) const counts = change.type === "delete" ? { additions: 0, deletions: change.before.split("\n").length } : diffLines(change.before, after).reduce( (result, item) => ({ additions: result.additions + (item.added ? (item.count ?? 0) : 0), deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), }), { additions: 0, deletions: 0 }, ) return { file: target, patch, status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified", ...counts, } } function trimDiff(diff: string) { const lines = diff.split("\n") const content = lines.filter( (line) => (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && !line.startsWith("---") && !line.startsWith("+++"), ) if (content.length === 0) return diff const indent = content.reduce((result, line) => { const value = line.slice(1) if (value.trim().length === 0) return result return Math.min(result, value.match(/^(\s*)/)?.[1].length ?? result) }, Infinity) if (indent === Infinity || indent === 0) return diff return lines .map((line) => { if ( (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && !line.startsWith("---") && !line.startsWith("+++") ) { return line[0] + line.slice(1 + indent) } return line }) .join("\n") } function resolveTarget(location: Location.Interface, value: string): Target { const absolute = process.platform === "win32" ? FSUtil.normalizePath(path.resolve(location.directory, value)) : path.resolve(location.directory, value) const projectRoot = path.parse(location.project.directory).root const external = !FSUtil.contains(location.directory, absolute) && (location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute)) const directory = path.dirname(absolute) const resource = process.platform === "win32" ? FSUtil.normalizePathPattern(path.join(directory, "*")) : path.join(directory, "*").replaceAll("\\", "/") return { absolute, resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".", externalDirectory: external ? { directory, resource } : undefined, } }