| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417 |
- 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 { Effect, Result, Schema } from "effect"
- import path from "path"
- import { Bom } from "@opencode-ai/util/bom"
- import { FSUtil } from "@opencode-ai/util/fs-util"
- import { Environment } from "../../environment"
- import { Formatter } from "../../formatter"
- import { FileMutation } from "../../file-mutation"
- import { Location } from "../../location"
- import { Patch } from "@opencode-ai/util/patch"
- import { Permission } from "../../permission"
- import DESCRIPTION from "../patch.txt"
- import { fileDiff } from "./file-diff"
- 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<Patch.Hunk, { readonly type: "add" }> & {
- readonly target: Target
- readonly content: string
- readonly before: string
- readonly after: string
- })
- | (Extract<Patch.Hunk, { readonly type: "delete" }> & {
- readonly target: Target
- readonly before: string
- readonly after: string
- })
- | (Extract<Patch.Hunk, { readonly type: "update" }> & {
- 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 environment = yield* Environment.Service
- const mutation = yield* FileMutation.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<typeof Applied.Type> = []
- const parsed = Patch.parse(input.patchText)
- const lockTargets = Result.isSuccess(parsed)
- ? parsed.success.flatMap((hunk) => [
- path.resolve(location.directory, hunk.path),
- ...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
- ])
- : []
- 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(parsed).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<string, string>()
- 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") {
- const content =
- hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
- prepared.push({
- ...hunk,
- target,
- content,
- before: "",
- after: Bom.split(content).text,
- })
- return
- }
- if (hunk.type === "delete") {
- const content = yield* FileMutation.readText(environment.files, 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 content = yield* FileMutation.readText(environment.files, 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* environment.files
- .write(change.target.absolute, new TextEncoder().encode(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,
- })
- return
- }
- if (change.type === "delete") {
- yield* environment.files
- .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* environment.files
- .write(moveTarget.absolute, new TextEncoder().encode(change.content))
- .pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
- yield* environment.files
- .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* environment.files
- .write(change.target.absolute, new TextEncoder().encode(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<string, string>()
- yield* Effect.forEach(
- [...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
- (target) =>
- Effect.gen(function* () {
- const current = yield* FileMutation.readText(environment.files, target).pipe(
- Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
- )
- formatted.set(
- target,
- (yield* formatter.file(target))
- ? yield* FileMutation.syncTextBom(environment.files, 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(
- mutation.withLock(lockTargets),
- 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 Environment.NotFound) return "file does not exist"
- if (error instanceof Environment.WrongKind)
- return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}`
- if (error instanceof Environment.Failed) return errorMessage(error.cause)
- 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 diff = fileDiff(
- change.target.absolute,
- change.before,
- after,
- change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
- )
- return {
- ...diff,
- file: target,
- patch: trimDiff(diff.patch),
- }
- }
- 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,
- }
- }
|