UNPKG

abyss-ai

Version:

Autonomous AI coding agent - enhanced OpenCode with autonomous capabilities

343 lines (296 loc) 10 kB
import { z } from "zod" import * as path from "path" import * as fs from "fs/promises" import { Tool } from "./tool" import { FileTime } from "../file/time" import DESCRIPTION from "./patch.txt" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), }) interface Change { type: "add" | "update" | "delete" old_content?: string new_content?: string } interface Commit { changes: Record<string, Change> } interface PatchOperation { type: "update" | "add" | "delete" filePath: string hunks?: PatchHunk[] content?: string } interface PatchHunk { contextLine: string changes: PatchChange[] } interface PatchChange { type: "keep" | "remove" | "add" content: string } function identifyFilesNeeded(patchText: string): string[] { const files: string[] = [] const lines = patchText.split("\n") for (const line of lines) { if (line.startsWith("*** Update File:") || line.startsWith("*** Delete File:")) { const filePath = line.split(":", 2)[1]?.trim() if (filePath) files.push(filePath) } } return files } function identifyFilesAdded(patchText: string): string[] { const files: string[] = [] const lines = patchText.split("\n") for (const line of lines) { if (line.startsWith("*** Add File:")) { const filePath = line.split(":", 2)[1]?.trim() if (filePath) files.push(filePath) } } return files } function textToPatch(patchText: string, _currentFiles: Record<string, string>): [PatchOperation[], number] { const operations: PatchOperation[] = [] const lines = patchText.split("\n") let i = 0 let fuzz = 0 while (i < lines.length) { const line = lines[i] if (line.startsWith("*** Update File:")) { const filePath = line.split(":", 2)[1]?.trim() if (!filePath) { i++ continue } const hunks: PatchHunk[] = [] i++ while (i < lines.length && !lines[i].startsWith("***")) { if (lines[i].startsWith("@@")) { const contextLine = lines[i].substring(2).trim() const changes: PatchChange[] = [] i++ while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) { const changeLine = lines[i] if (changeLine.startsWith(" ")) { changes.push({ type: "keep", content: changeLine.substring(1) }) } else if (changeLine.startsWith("-")) { changes.push({ type: "remove", content: changeLine.substring(1), }) } else if (changeLine.startsWith("+")) { changes.push({ type: "add", content: changeLine.substring(1) }) } i++ } hunks.push({ contextLine, changes }) } else { i++ } } operations.push({ type: "update", filePath, hunks }) } else if (line.startsWith("*** Add File:")) { const filePath = line.split(":", 2)[1]?.trim() if (!filePath) { i++ continue } let content = "" i++ while (i < lines.length && !lines[i].startsWith("***")) { if (lines[i].startsWith("+")) { content += lines[i].substring(1) + "\n" } i++ } operations.push({ type: "add", filePath, content: content.slice(0, -1) }) } else if (line.startsWith("*** Delete File:")) { const filePath = line.split(":", 2)[1]?.trim() if (filePath) { operations.push({ type: "delete", filePath }) } i++ } else { i++ } } return [operations, fuzz] } function patchToCommit(operations: PatchOperation[], currentFiles: Record<string, string>): Commit { const changes: Record<string, Change> = {} for (const op of operations) { if (op.type === "delete") { changes[op.filePath] = { type: "delete", old_content: currentFiles[op.filePath] || "", } } else if (op.type === "add") { changes[op.filePath] = { type: "add", new_content: op.content || "", } } else if (op.type === "update" && op.hunks) { const originalContent = currentFiles[op.filePath] || "" const lines = originalContent.split("\n") for (const hunk of op.hunks) { const contextIndex = lines.findIndex((line) => line.includes(hunk.contextLine)) if (contextIndex === -1) { throw new Error(`Context line not found: ${hunk.contextLine}`) } let currentIndex = contextIndex for (const change of hunk.changes) { if (change.type === "keep") { currentIndex++ } else if (change.type === "remove") { lines.splice(currentIndex, 1) } else if (change.type === "add") { lines.splice(currentIndex, 0, change.content) currentIndex++ } } } changes[op.filePath] = { type: "update", old_content: originalContent, new_content: lines.join("\n"), } } } return { changes } } function generateDiff(oldContent: string, newContent: string, filePath: string): [string, number, number] { // Mock implementation - would need actual diff generation const lines1 = oldContent.split("\n") const lines2 = newContent.split("\n") const additions = Math.max(0, lines2.length - lines1.length) const removals = Math.max(0, lines1.length - lines2.length) return [`--- ${filePath}\n+++ ${filePath}\n`, additions, removals] } async function applyCommit( commit: Commit, writeFile: (path: string, content: string) => Promise<void>, deleteFile: (path: string) => Promise<void>, ): Promise<void> { for (const [filePath, change] of Object.entries(commit.changes)) { if (change.type === "delete") { await deleteFile(filePath) } else if (change.new_content !== undefined) { await writeFile(filePath, change.new_content) } } } export const PatchTool = Tool.define("patch", { description: DESCRIPTION, parameters: PatchParams, execute: async (params, ctx) => { // Identify all files needed for the patch and verify they've been read const filesToRead = identifyFilesNeeded(params.patchText) for (const filePath of filesToRead) { let absPath = filePath if (!path.isAbsolute(absPath)) { absPath = path.resolve(process.cwd(), absPath) } await FileTime.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}`) } } catch (error: any) { if (error.code === "ENOENT") { throw new Error(`file not found: ${absPath}`) } throw new Error(`failed to access file: ${error.message}`) } } // Check for new files to ensure they don't already exist const filesToAdd = identifyFilesAdded(params.patchText) for (const filePath of filesToAdd) { let absPath = filePath if (!path.isAbsolute(absPath)) { absPath = path.resolve(process.cwd(), absPath) } try { await fs.stat(absPath) throw new Error(`file already exists and cannot be added: ${absPath}`) } catch (error: any) { if (error.code !== "ENOENT") { throw new Error(`failed to check file: ${error.message}`) } } } // Load all required files const currentFiles: Record<string, string> = {} for (const filePath of filesToRead) { let absPath = filePath if (!path.isAbsolute(absPath)) { absPath = path.resolve(process.cwd(), absPath) } try { const content = await fs.readFile(absPath, "utf-8") currentFiles[filePath] = content } catch (error: any) { throw new Error(`failed to read file ${absPath}: ${error.message}`) } } // Process the patch const [patch, fuzz] = textToPatch(params.patchText, currentFiles) if (fuzz > 3) { throw new Error(`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`) } // Convert patch to commit const commit = patchToCommit(patch, currentFiles) // Apply the changes to the filesystem await applyCommit( commit, async (filePath: string, content: string) => { let absPath = filePath if (!path.isAbsolute(absPath)) { absPath = path.resolve(process.cwd(), absPath) } // Create parent directories if needed const dir = path.dirname(absPath) await fs.mkdir(dir, { recursive: true }) await fs.writeFile(absPath, content, "utf-8") }, async (filePath: string) => { let absPath = filePath if (!path.isAbsolute(absPath)) { absPath = path.resolve(process.cwd(), absPath) } await fs.unlink(absPath) }, ) // Calculate statistics const changedFiles: string[] = [] let totalAdditions = 0 let totalRemovals = 0 for (const [filePath, change] of Object.entries(commit.changes)) { let absPath = filePath if (!path.isAbsolute(absPath)) { absPath = path.resolve(process.cwd(), absPath) } changedFiles.push(absPath) const oldContent = change.old_content || "" const newContent = change.new_content || "" // Calculate diff statistics const [, additions, removals] = generateDiff(oldContent, newContent, filePath) totalAdditions += additions totalRemovals += removals FileTime.read(ctx.sessionID, absPath) } const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals` const output = result return { title: `${filesToRead.length} files`, metadata: { changed: changedFiles, additions: totalAdditions, removals: totalRemovals, }, output, } }, })