#!/usr/bin/env node const desc = ` This is a custom utility that makes it more convenient to add, remove, move, or edit code files. 'apply_patch' effectively allows you to execute a diff/patch against a file, but the format of the diff specification is unique to this task, so pay careful attention to these instructions. To use the 'apply_patch' command, you should pass a message of the following structure as "input": *** Begin Patch [YOUR_PATH] *** End Patch Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format. *** [ACTION] File: [path/to/file] -> ACTION can be one of Add, Update, or Delete. For each snippet of code that needs to be changed, repeat the following: [context_before] -> See below for further instructions on context. - [old_code] -> Precede the old code with a minus sign. + [new_code] -> Precede the new, replacement code with a plus sign. [context_after] -> See below for further instructions on context. For instructions on [context_before] and [context_after]: - By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first changes [context_after] lines in the second changes [context_before] lines. - If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have: @@ class BaseClass [3 lines of pre-context] - [old_code] + [new_code] [3 lines of post-context] - If a code block is repeated so many times in a class or function such that even a single @@ statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple '@@' statements to jump to the right context. For instance: @@ class BaseClass @@ def method(): [3 lines of pre-context] - [old_code] + [new_code] [3 lines of post-context] Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as "input" to this function, in order to apply a patch, is shown below. `; // --------------------------------------------------------------------------- // // Domain objects // --------------------------------------------------------------------------- // class ActionType { static ADD = "add"; static DELETE = "delete"; static UPDATE = "update"; } class FileChange { constructor(type, old_content = null, new_content = null, move_path = null) { this.type = type; this.old_content = old_content; this.new_content = new_content; this.move_path = move_path; } } class Commit { constructor() { this.changes = {}; } } // --------------------------------------------------------------------------- // // Exceptions // --------------------------------------------------------------------------- // class DiffError extends Error { /** Any problem detected while parsing or applying a patch. */ constructor(message) { super(message); this.name = "DiffError"; } } // --------------------------------------------------------------------------- // // Helper classes used while parsing patches // --------------------------------------------------------------------------- // class Chunk { constructor(orig_index = -1, del_lines = [], ins_lines = []) { this.orig_index = orig_index; this.del_lines = del_lines; this.ins_lines = ins_lines; } } class PatchAction { constructor(type, new_file = null, chunks = [], move_path = null) { this.type = type; this.new_file = new_file; this.chunks = chunks; this.move_path = move_path; } } class Patch { constructor() { this.actions = {}; } } // --------------------------------------------------------------------------- // // Path utilities for chroot functionality // --------------------------------------------------------------------------- // function normalizePath(p) { return (p || '').replace(/\\/g, '/').replace(/\/+/g, '/'); } function joinPaths(...parts) { const joined = parts.filter(Boolean).join('/'); return normalizePath(joined); } function resolvePath(chroot, filepath) { const file = normalizePath(filepath); if (!chroot) return file; const root = normalizePath(chroot); // If file is absolute, use it as-is (after normalization). // We assume the caller ensures it is inside the chroot. if (file.startsWith('/')) { return file; } // If file is relative, join with chroot const resolved = joinPaths(root, file); return resolved.startsWith('/') ? resolved : '/' + resolved; } function unresolvePath(chroot, filepath) { const file = normalizePath(filepath); if (!chroot) return file; const root = normalizePath(chroot); const rootWithSlash = root.endsWith('/') ? root : root + '/'; // Convert absolute path back to what user would expect if (file.startsWith(rootWithSlash)) { // Return path relative to chroot (without leading slash for user expectation) return file.substring(rootWithSlash.length); } if (file === root) { return ''; } // If somehow outside chroot, return as-is return file; } // --------------------------------------------------------------------------- // // Patch text parser // --------------------------------------------------------------------------- // class Parser { constructor(current_files, lines, chroot = null) { this.current_files = current_files; this.lines = lines; this.index = 0; this.patch = new Patch(); this.fuzz = 0; this.chroot = chroot; } // ------------- low-level helpers -------------------------------------- // _cur_line() { if (this.index >= this.lines.length) { throw new DiffError("Unexpected end of input while parsing patch"); } return this.lines[this.index]; } static _norm(line) { /** Strip CR so comparisons work for both LF and CRLF input. */ return line.replace(/\r$/, ""); } // ------------- scanning convenience ----------------------------------- // is_done(prefixes = null) { if (this.index >= this.lines.length) { return true; } if ( prefixes && prefixes.length > 0 ) { const currentLine = Parser._norm(this._cur_line()); for (const prefix of prefixes) { if (currentLine.startsWith(prefix)) { return true; } } } return false; } startswith(prefix) { return Parser._norm(this._cur_line()).startsWith(prefix); } read_str(prefix) { /** * Consume the current line if it starts with *prefix* and return the text * **after** the prefix. Raises if prefix is empty. */ if (prefix === "") { throw new Error("read_str() requires a non-empty prefix"); } if (Parser._norm(this._cur_line()).startsWith(prefix)) { const text = this._cur_line().substring(prefix.length); this.index += 1; return text; } return ""; } read_line() { /** Return the current raw line and advance. */ const line = this._cur_line(); this.index += 1; return line; } // ------------- public entry point -------------------------------------- // parse() { while (!this.is_done(["*** End Patch"])) { // ---------- UPDATE ---------- // let path = this.read_str("*** Update File: "); if (path) { // Resolve path with chroot const resolvedPath = resolvePath(this.chroot, path); if (resolvedPath in this.patch.actions) { throw new DiffError(`Duplicate update for file: ${resolvedPath}`); } const move_to = this.read_str("*** Move to: "); if (!(resolvedPath in this.current_files)) { throw new DiffError(`Update File Error - missing file: ${resolvedPath}`); } const text = this.current_files[resolvedPath]; const action = this._parse_update_file(text); action.move_path = move_to ? resolvePath(this.chroot, move_to) : null; this.patch.actions[resolvedPath] = action; continue; } // ---------- DELETE ---------- // path = this.read_str("*** Delete File: "); if (path) { // Resolve path with chroot const resolvedPath = resolvePath(this.chroot, path); if (resolvedPath in this.patch.actions) { throw new DiffError(`Duplicate delete for file: ${resolvedPath}`); } if (!(resolvedPath in this.current_files)) { throw new DiffError(`Delete File Error - missing file: ${resolvedPath}`); } this.patch.actions[resolvedPath] = new PatchAction(ActionType.DELETE); continue; } // ---------- ADD ---------- // path = this.read_str("*** Add File: "); if (path) { // Resolve path with chroot const resolvedPath = resolvePath(this.chroot, path); if (resolvedPath in this.patch.actions) { throw new DiffError(`Duplicate add for file: ${resolvedPath}`); } if (resolvedPath in this.current_files) { throw new DiffError(`Add File Error - file already exists: ${resolvedPath}`); } this.patch.actions[resolvedPath] = this._parse_add_file(); continue; } throw new DiffError(`Unknown line while parsing: ${this._cur_line()}`); } if (!this.startswith("*** End Patch")) { throw new DiffError("Missing *** End Patch sentinel"); } this.index += 1; // consume sentinel } // ------------- section parsers ---------------------------------------- // _parse_update_file(text) { const action = new PatchAction(ActionType.UPDATE); const lines = text.split("\n"); let index = 0; while (!this.is_done([ "*** End Patch", "*** Update File:", "*** Delete File:", "*** Add File:", "*** End of File", ])) { const def_str = this.read_str("@@ "); let section_str = ""; if (!def_str && Parser._norm(this._cur_line()) === "@@") { section_str = this.read_line(); } if (!(def_str || section_str || index === 0)) { throw new DiffError(`Invalid line in update section:\n${this._cur_line()}`); } if (def_str && def_str.trim()) { let found = false; if (!lines.slice(0, index).includes(def_str)) { for (let i = index; i < lines.length; i++) { if (lines[i] === def_str) { index = i + 1; found = true; break; } } } if (!found && !lines.slice(0, index).map(s => s.trim()).includes(def_str.trim())) { for (let i = index; i < lines.length; i++) { if (lines[i].trim() === def_str.trim()) { index = i + 1; this.fuzz += 1; found = true; break; } } } } const [next_ctx, chunks, end_idx, eof] = peek_next_section(this.lines, this.index); const [new_index, fuzz] = find_context(lines, next_ctx, index, eof); if (new_index === -1) { const ctx_txt = next_ctx.join("\n"); throw new DiffError( `Invalid ${eof ? 'EOF ' : ''}context at ${index}:\n${ctx_txt}` ); } this.fuzz += fuzz; for (const ch of chunks) { ch.orig_index += new_index; action.chunks.push(ch); } index = new_index + next_ctx.length; this.index = end_idx; } return action; } _parse_add_file() { const lines = []; while (!this.is_done([ "*** End Patch", "*** Update File:", "*** Delete File:", "*** Add File:" ])) { const s = this.read_line(); if (!s.startsWith("+")) { throw new DiffError(`Invalid Add File line (missing '+'): ${s}`); } lines.push(s.substring(1)); // strip leading '+' } return new PatchAction(ActionType.ADD, lines.join("\n")); } } // --------------------------------------------------------------------------- // // Helper functions // --------------------------------------------------------------------------- // function find_context_core(lines, context, start) { if (!context || context.length === 0) { return [start, 0]; } // Exact match for (let i = start; i <= lines.length - context.length; i++) { let match = true; for (let j = 0; j < context.length; j++) { if (lines[i + j] !== context[j]) { match = false; break; } } if (match) { return [i, 0]; } } // Rstrip match for (let i = start; i <= lines.length - context.length; i++) { let match = true; for (let j = 0; j < context.length; j++) { if (lines[i + j].replace(/\s+$/, "") !== context[j].replace(/\s+$/, "")) { match = false; break; } } if (match) { return [i, 1]; } } // Trim match for (let i = start; i <= lines.length - context.length; i++) { let match = true; for (let j = 0; j < context.length; j++) { if (lines[i + j].trim() !== context[j].trim()) { match = false; break; } } if (match) { return [i, 100]; } } return [-1, 0]; } function find_context(lines, context, start, eof) { // Special case: if context is empty, return start position if (!context || context.length === 0) { return [start, 0]; } // Special handling for full-file replacement patterns // If context is large and starts with deletion lines, try to match at beginning if (context.length > 3) { // Try exact match at start let [new_index, fuzz] = find_context_core(lines, context, 0); if (new_index !== -1) { return [new_index, fuzz]; } // Try fuzzy match at start (allowing for whitespace differences) let match = true; let local_fuzz = 0; const compare_length = Math.min(context.length, lines.length); for (let j = 0; j < compare_length; j++) { if (j < lines.length && j < context.length) { if (lines[j] !== context[j]) { if (lines[j].trim() === context[j].trim()) { local_fuzz += 10; } else if (lines[j].replace(/\s+$/, "") === context[j].replace(/\s+$/, "")) { local_fuzz += 1; } else { // Allow some mismatch for full-file replacements local_fuzz += 100; } } } } if (local_fuzz < context.length * 50) { // Allow up to 50 fuzz per line return [0, local_fuzz]; } } if (eof) { let [new_index, fuzz] = find_context_core(lines, context, Math.max(0, lines.length - context.length)); if (new_index !== -1) { return [new_index, fuzz]; } [new_index, fuzz] = find_context_core(lines, context, start); return [new_index, fuzz + 10000]; } return find_context_core(lines, context, start); } function peek_next_section(lines, index) { const old = []; let del_lines = []; let ins_lines = []; const chunks = []; let mode = "keep"; const orig_index = index; while (index < lines.length) { let s = lines[index]; if (s.startsWith("@@") || s === "*** End Patch" || s.startsWith("*** Update File:") || s.startsWith("*** Delete File:") || s.startsWith("*** Add File:") || s === "*** End of File") { break; } if (s === "***") { break; } if (s.startsWith("***")) { throw new DiffError(`Invalid Line: ${s}`); } index += 1; const last_mode = mode; if (s === "") { s = " "; } if (s[0] === "+") { mode = "add"; } else if (s[0] === "-") { mode = "delete"; } else if (s[0] === " ") { mode = "keep"; } else { throw new DiffError(`Invalid Line: ${s}`); } s = s.substring(1); if (mode === "keep" && last_mode !== mode && (ins_lines.length > 0 || del_lines.length > 0)) { const chunk_orig_index = old.length - del_lines.length; chunks.push( new Chunk( chunk_orig_index, [...del_lines], [...ins_lines] ) ); del_lines = []; ins_lines = []; } if (mode === "delete") { del_lines.push(s); old.push(s); } else if (mode === "add") { ins_lines.push(s); } else if (mode === "keep") { old.push(s); } } // Handle any remaining content if (ins_lines.length > 0 || del_lines.length > 0) { const chunk_orig_index = old.length - del_lines.length; chunks.push( new Chunk( chunk_orig_index, [...del_lines], [...ins_lines] ) ); } if (index < lines.length && lines[index] === "*** End of File") { index += 1; return [old, chunks, index, true]; } if (index === orig_index) { throw new DiffError("Nothing in this section"); } return [old, chunks, index, false]; } // --------------------------------------------------------------------------- // // Patch → Commit and Commit application // --------------------------------------------------------------------------- // function _get_updated_file(text, action, path) { if (action.type !== ActionType.UPDATE) { throw new DiffError("_get_updated_file called with non-update action"); } const orig_lines = text.split("\n"); const dest_lines = []; let orig_index = 0; for (const chunk of action.chunks) { if (chunk.orig_index > orig_lines.length) { throw new DiffError( `${path}: chunk.orig_index ${chunk.orig_index} exceeds file length` ); } if (orig_index > chunk.orig_index) { throw new DiffError( `${path}: overlapping chunks at ${orig_index} > ${chunk.orig_index}` ); } // Add lines before the chunk for (let i = orig_index; i < chunk.orig_index; i++) { dest_lines.push(orig_lines[i]); } orig_index = chunk.orig_index; // Add inserted lines dest_lines.push(...chunk.ins_lines); // Skip deleted lines orig_index += chunk.del_lines.length; } // Add remaining lines for (let i = orig_index; i < orig_lines.length; i++) { dest_lines.push(orig_lines[i]); } return dest_lines.join("\n"); } function patch_to_commit(patch, orig, chroot = null) { const commit = new Commit(); for (const [path, action] of Object.entries(patch.actions)) { if (action.type === ActionType.DELETE) { commit.changes[path] = new FileChange( ActionType.DELETE, orig[path], null, null ); } else if (action.type === ActionType.ADD) { if (action.new_file === null) { throw new DiffError("ADD action without file content"); } commit.changes[path] = new FileChange( ActionType.ADD, null, action.new_file, null ); } else if (action.type === ActionType.UPDATE) { const new_content = _get_updated_file(orig[path], action, path); const move_path = action.move_path ? unresolvePath(chroot, action.move_path) : null; commit.changes[path] = new FileChange( ActionType.UPDATE, orig[path], new_content, move_path ); } } return commit; } // --------------------------------------------------------------------------- // // User-facing helpers // --------------------------------------------------------------------------- // function text_to_patch(text, orig, chroot = null) { // Handle different line ending scenarios let lines = text.split(/\r?\n/); // Remove trailing empty line if it exists (from trailing newline) if (lines.length > 0 && lines[lines.length - 1] === "") { lines = lines.slice(0, -1); } // Debug logging //console.log("Lines count:", lines.length); //console.log("First line:", JSON.stringify(lines[0])); if (lines.length > 0) { //console.log("Last line:", JSON.stringify(lines[lines.length - 1])); //console.log("First line normalized:", JSON.stringify(Parser._norm(lines[0]))); //console.log("Last line normalized:", JSON.stringify(Parser._norm(lines[lines.length - 1]))); } if ( lines.length < 2 || !Parser._norm(lines[0]).startsWith("*** Begin Patch") || (lines.length > 0 && Parser._norm(lines[lines.length - 1]) !== "*** End Patch") ) { throw new DiffError("Invalid patch text - missing sentinels"); } const parser = new Parser(orig, lines, chroot); parser.index = 1; parser.parse(); return [parser.patch, parser.fuzz]; } function identify_files_needed(text, chroot = null) { // Handle line splitting consistently let lines = text.split(/\r?\n/); if (lines.length > 0 && lines[lines.length - 1] === "") { lines = lines.slice(0, -1); } const updateFiles = lines .filter(line => line.startsWith("*** Update File: ")) .map(line => resolvePath(chroot, line.substring("*** Update File: ".length))); const deleteFiles = lines .filter(line => line.startsWith("*** Delete File: ")) .map(line => resolvePath(chroot, line.substring("*** Delete File: ".length))); return [...updateFiles, ...deleteFiles]; } function identify_files_added(text, chroot = null) { // Handle line splitting consistently let lines = text.split(/\r?\n/); if (lines.length > 0 && lines[lines.length - 1] === "") { lines = lines.slice(0, -1); } return lines .filter(line => line.startsWith("*** Add File: ")) .map(line => resolvePath(chroot, line.substring("*** Add File: ".length))); } // --------------------------------------------------------------------------- // // File-system helpers // --------------------------------------------------------------------------- // function load_files(paths, open_fn) { const result = {}; for (const p of paths) { try { result[p] = open_fn(p); } catch (err) { // Skip truly missing files so parser can emit precise DiffErrors if (err && (err.code === 'ENOENT' || /ENOENT/.test(String(err)))) { continue; } throw err; } } return result; } function apply_commit(commit, write_fn, remove_fn, chroot = null) { for (const [path, change] of Object.entries(commit.changes)) { if (change.type === ActionType.DELETE) { remove_fn(path); } else if (change.type === ActionType.ADD) { if (change.new_content === null) { throw new DiffError(`ADD change for ${path} has no content`); } write_fn(path, change.new_content); } else if (change.type === ActionType.UPDATE) { if (change.new_content === null) { throw new DiffError(`UPDATE change for ${path} has no new content`); } const target = change.move_path ? resolvePath(chroot, change.move_path) : path; write_fn(target, change.new_content); if (change.move_path) { remove_fn(path); } } } } function process_patch(text, open_fn, write_fn, remove_fn, chroot = null) { if (!text.startsWith("*** Begin Patch")) { throw new DiffError("Patch text must start with *** Begin Patch"); } // Load update/delete targets and also ADD targets if they already exist, // so the parser can detect "Add File Error - file already exists". const updateDeletePaths = identify_files_needed(text, chroot); const addPaths = identify_files_added(text, chroot); const allPaths = [...updateDeletePaths, ...addPaths]; const orig = load_files(allPaths, open_fn); const [patch, _fuzz] = text_to_patch(text, orig, chroot); const commit = patch_to_commit(patch, orig, chroot); apply_commit(commit, write_fn, remove_fn, chroot); return "Done!"; } // --------------------------------------------------------------------------- // // Default FS helpers // --------------------------------------------------------------------------- // import fs from 'fs'; import path from 'path'; function open_file(filepath) { return fs.readFileSync(filepath, 'utf8'); } function write_file(filepath, content) { const dir = path.dirname(filepath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(filepath, content, 'utf8'); } function remove_file(filepath) { if (fs.existsSync(filepath)) { fs.unlinkSync(filepath); } } // Export for the new interface export default { type: "function", name: "patch_files", description: "Generic Text File Editor create,edit,delete - Apply a unified diff patch " + desc, parameters: { type: "object", properties: { patch: { type: "string", description: "The patch string to apply.", } }, required: ["patch"], additionalProperties: false, }, strict: true, }; export async function run(args) { try { const result = process_patch( args.patch, open_file, write_file, remove_file, '/workspaces/aiTools/root' ); return result; } catch (error) { return `Patch error: ${error.message}` } }