UNPKG

@deep-assistant/agent

Version:

A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.

605 lines (519 loc) 18.8 kB
// the approaches in this edit tool are sourced from // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts // https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts import z from "zod" import * as path from "path" import { Tool } from "./tool" import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" import { File } from "../file" import { Bus } from "../bus" import { FileTime } from "../file/time" import { Instance } from "../project/instance" import { Snapshot } from "../snapshot" function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") } 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") } if (params.oldString === params.newString) { throw new Error("oldString and newString must be different") } // No restrictions - unrestricted file editing const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) let diff = "" let contentOld = "" let contentNew = "" await (async () => { if (params.oldString === "") { contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) await Bun.write(filePath, params.newString) await Bus.publish(File.Event.Edited, { file: filePath, }) 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, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), ) await file.write(contentNew) await Bus.publish(File.Event.Edited, { file: filePath, }) contentNew = await file.text() diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), ) })() FileTime.read(ctx.sessionID, filePath) let output = "" const diagnostics = {} const filediff: Snapshot.FileDiff = { file: filePath, before: contentOld, after: contentNew, additions: 0, deletions: 0, } for (const change of diffLines(contentOld, contentNew)) { if (change.added) filediff.additions += change.count || 0 if (change.removed) filediff.deletions += change.count || 0 } return { metadata: { diagnostics, diff, filediff, }, title: `${path.relative(Instance.worktree, filePath)}`, output, } }, }) export type Replacer = (content: string, find: string) => Generator<string, void, unknown> // Similarity thresholds for block anchor fallback matching const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0 const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3 /** * Levenshtein distance algorithm implementation */ function levenshtein(a: string, b: string): number { // Handle empty strings if (a === "" || b === "") { return Math.max(a.length, b.length) } const matrix = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), ) for (let i = 1; i <= a.length; i++) { for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1 matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost) } } return matrix[a.length][b.length] } export const SimpleReplacer: Replacer = function* (_content, find) { yield find } export const LineTrimmedReplacer: Replacer = function* (content, find) { const originalLines = content.split("\n") const searchLines = find.split("\n") if (searchLines[searchLines.length - 1] === "") { searchLines.pop() } for (let i = 0; i <= originalLines.length - searchLines.length; i++) { let matches = true for (let j = 0; j < searchLines.length; j++) { const originalTrimmed = originalLines[i + j].trim() const searchTrimmed = searchLines[j].trim() if (originalTrimmed !== searchTrimmed) { matches = false break } } if (matches) { let matchStartIndex = 0 for (let k = 0; k < i; k++) { matchStartIndex += originalLines[k].length + 1 } let matchEndIndex = matchStartIndex for (let k = 0; k < searchLines.length; k++) { matchEndIndex += originalLines[i + k].length if (k < searchLines.length - 1) { matchEndIndex += 1 // Add newline character except for the last line } } yield content.substring(matchStartIndex, matchEndIndex) } } } export const BlockAnchorReplacer: Replacer = function* (content, find) { const originalLines = content.split("\n") const searchLines = find.split("\n") if (searchLines.length < 3) { return } if (searchLines[searchLines.length - 1] === "") { searchLines.pop() } const firstLineSearch = searchLines[0].trim() const lastLineSearch = searchLines[searchLines.length - 1].trim() const searchBlockSize = searchLines.length // Collect all candidate positions where both anchors match const candidates: Array<{ startLine: number; endLine: number }> = [] for (let i = 0; i < originalLines.length; i++) { if (originalLines[i].trim() !== firstLineSearch) { continue } // Look for the matching last line after this first line for (let j = i + 2; j < originalLines.length; j++) { if (originalLines[j].trim() === lastLineSearch) { candidates.push({ startLine: i, endLine: j }) break // Only match the first occurrence of the last line } } } // Return immediately if no candidates if (candidates.length === 0) { return } // Handle single candidate scenario (using relaxed threshold) if (candidates.length === 1) { const { startLine, endLine } = candidates[0] const actualBlockSize = endLine - startLine + 1 let similarity = 0 let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only if (linesToCheck > 0) { for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) { const originalLine = originalLines[startLine + j].trim() const searchLine = searchLines[j].trim() const maxLen = Math.max(originalLine.length, searchLine.length) if (maxLen === 0) { continue } const distance = levenshtein(originalLine, searchLine) similarity += (1 - distance / maxLen) / linesToCheck // Exit early when threshold is reached if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) { break } } } else { // No middle lines to compare, just accept based on anchors similarity = 1.0 } if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) { let matchStartIndex = 0 for (let k = 0; k < startLine; k++) { matchStartIndex += originalLines[k].length + 1 } let matchEndIndex = matchStartIndex for (let k = startLine; k <= endLine; k++) { matchEndIndex += originalLines[k].length if (k < endLine) { matchEndIndex += 1 // Add newline character except for the last line } } yield content.substring(matchStartIndex, matchEndIndex) } return } // Calculate similarity for multiple candidates let bestMatch: { startLine: number; endLine: number } | null = null let maxSimilarity = -1 for (const candidate of candidates) { const { startLine, endLine } = candidate const actualBlockSize = endLine - startLine + 1 let similarity = 0 let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only if (linesToCheck > 0) { for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) { const originalLine = originalLines[startLine + j].trim() const searchLine = searchLines[j].trim() const maxLen = Math.max(originalLine.length, searchLine.length) if (maxLen === 0) { continue } const distance = levenshtein(originalLine, searchLine) similarity += 1 - distance / maxLen } similarity /= linesToCheck // Average similarity } else { // No middle lines to compare, just accept based on anchors similarity = 1.0 } if (similarity > maxSimilarity) { maxSimilarity = similarity bestMatch = candidate } } // Threshold judgment if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) { const { startLine, endLine } = bestMatch let matchStartIndex = 0 for (let k = 0; k < startLine; k++) { matchStartIndex += originalLines[k].length + 1 } let matchEndIndex = matchStartIndex for (let k = startLine; k <= endLine; k++) { matchEndIndex += originalLines[k].length if (k < endLine) { matchEndIndex += 1 } } yield content.substring(matchStartIndex, matchEndIndex) } } export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) { const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim() const normalizedFind = normalizeWhitespace(find) // Handle single line matches const lines = content.split("\n") for (let i = 0; i < lines.length; i++) { const line = lines[i] if (normalizeWhitespace(line) === normalizedFind) { yield line } else { // Only check for substring matches if the full line doesn't match const normalizedLine = normalizeWhitespace(line) if (normalizedLine.includes(normalizedFind)) { // Find the actual substring in the original line that matches const words = find.trim().split(/\s+/) if (words.length > 0) { const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+") try { const regex = new RegExp(pattern) const match = line.match(regex) if (match) { yield match[0] } } catch (e) { // Invalid regex pattern, skip } } } } } // Handle multi-line matches const findLines = find.split("\n") if (findLines.length > 1) { for (let i = 0; i <= lines.length - findLines.length; i++) { const block = lines.slice(i, i + findLines.length) if (normalizeWhitespace(block.join("\n")) === normalizedFind) { yield block.join("\n") } } } } export const IndentationFlexibleReplacer: Replacer = function* (content, find) { const removeIndentation = (text: string) => { const lines = text.split("\n") const nonEmptyLines = lines.filter((line) => line.trim().length > 0) if (nonEmptyLines.length === 0) return text const minIndent = Math.min( ...nonEmptyLines.map((line) => { const match = line.match(/^(\s*)/) return match ? match[1].length : 0 }), ) return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n") } const normalizedFind = removeIndentation(find) const contentLines = content.split("\n") const findLines = find.split("\n") for (let i = 0; i <= contentLines.length - findLines.length; i++) { const block = contentLines.slice(i, i + findLines.length).join("\n") if (removeIndentation(block) === normalizedFind) { yield block } } } export const EscapeNormalizedReplacer: Replacer = function* (content, find) { const unescapeString = (str: string): string => { return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => { switch (capturedChar) { case "n": return "\n" case "t": return "\t" case "r": return "\r" case "'": return "'" case '"': return '"' case "`": return "`" case "\\": return "\\" case "\n": return "\n" case "$": return "$" default: return match } }) } const unescapedFind = unescapeString(find) // Try direct match with unescaped find string if (content.includes(unescapedFind)) { yield unescapedFind } // Also try finding escaped versions in content that match unescaped find const lines = content.split("\n") const findLines = unescapedFind.split("\n") for (let i = 0; i <= lines.length - findLines.length; i++) { const block = lines.slice(i, i + findLines.length).join("\n") const unescapedBlock = unescapeString(block) if (unescapedBlock === unescapedFind) { yield block } } } export const MultiOccurrenceReplacer: Replacer = function* (content, find) { // This replacer yields all exact matches, allowing the replace function // to handle multiple occurrences based on replaceAll parameter let startIndex = 0 while (true) { const index = content.indexOf(find, startIndex) if (index === -1) break yield find startIndex = index + find.length } } export const TrimmedBoundaryReplacer: Replacer = function* (content, find) { const trimmedFind = find.trim() if (trimmedFind === find) { // Already trimmed, no point in trying return } // Try to find the trimmed version if (content.includes(trimmedFind)) { yield trimmedFind } // Also try finding blocks where trimmed content matches const lines = content.split("\n") const findLines = find.split("\n") for (let i = 0; i <= lines.length - findLines.length; i++) { const block = lines.slice(i, i + findLines.length).join("\n") if (block.trim() === trimmedFind) { yield block } } } export const ContextAwareReplacer: Replacer = function* (content, find) { const findLines = find.split("\n") if (findLines.length < 3) { // Need at least 3 lines to have meaningful context return } // Remove trailing empty line if present if (findLines[findLines.length - 1] === "") { findLines.pop() } const contentLines = content.split("\n") // Extract first and last lines as context anchors const firstLine = findLines[0].trim() const lastLine = findLines[findLines.length - 1].trim() // Find blocks that start and end with the context anchors for (let i = 0; i < contentLines.length; i++) { if (contentLines[i].trim() !== firstLine) continue // Look for the matching last line for (let j = i + 2; j < contentLines.length; j++) { if (contentLines[j].trim() === lastLine) { // Found a potential context block const blockLines = contentLines.slice(i, j + 1) const block = blockLines.join("\n") // Check if the middle content has reasonable similarity // (simple heuristic: at least 50% of non-empty lines should match when trimmed) if (blockLines.length === findLines.length) { let matchingLines = 0 let totalNonEmptyLines = 0 for (let k = 1; k < blockLines.length - 1; k++) { const blockLine = blockLines[k].trim() const findLine = findLines[k].trim() if (blockLine.length > 0 || findLine.length > 0) { totalNonEmptyLines++ if (blockLine === findLine) { matchingLines++ } } } if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) { yield block break // Only match the first occurrence } } break } } } } export function trimDiff(diff: string): string { const lines = diff.split("\n") const contentLines = lines.filter( (line) => (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && !line.startsWith("---") && !line.startsWith("+++"), ) if (contentLines.length === 0) return diff let min = Infinity for (const line of contentLines) { const content = line.slice(1) if (content.trim().length > 0) { const match = content.match(/^(\s*)/) if (match) min = Math.min(min, match[1].length) } } if (min === Infinity || min === 0) return diff const trimmedLines = lines.map((line) => { if ( (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && !line.startsWith("---") && !line.startsWith("+++") ) { const prefix = line[0] const content = line.slice(1) return prefix + content.slice(min) } return line }) return trimmedLines.join("\n") } export function replace(content: string, oldString: string, newString: string, replaceAll = false): string { if (oldString === newString) { throw new Error("oldString and newString must be different") } let notFound = true for (const replacer of [ SimpleReplacer, LineTrimmedReplacer, BlockAnchorReplacer, WhitespaceNormalizedReplacer, IndentationFlexibleReplacer, EscapeNormalizedReplacer, TrimmedBoundaryReplacer, ContextAwareReplacer, MultiOccurrenceReplacer, ]) { for (const search of replacer(content, oldString)) { const index = content.indexOf(search) if (index === -1) continue notFound = false if (replaceAll) { return content.replaceAll(search, newString) } const lastIndex = content.lastIndexOf(search) if (index !== lastIndex) continue return content.substring(0, index) + newString + content.substring(index + search.length) } } if (notFound) { throw new Error("oldString not found in content") } throw new Error( "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.", ) }