using System.ComponentModel; using System.Text.RegularExpressions; using AnchorCli.Hashline; namespace AnchorCli.Tools; /// /// Represents a single operation within a batch edit. public record BatchOperation( [property: Description("Operation type: 'replace' or 'delete'")] string Type, [property: Description("First line's line:hash anchor (e.g. '5:a3')")] string? StartAnchor, [property: Description("Last line's line:hash anchor (e.g. '8:d4')")] string? EndAnchor, [property: Description("Text content to insert. Required for 'replace' operations.")] string[]? Content); /// /// Mutating file tools exposed to the LLM as AIFunctions. /// Every operation validates Hashline anchors (line:hash format) before touching the file. /// On success, returns "OK fp:{fingerprint}" so the model can verify the mutation. /// On failure, returns a descriptive ERROR string — no exceptions reach the LLM. /// internal static partial class EditTools { public static Action Log { get; set; } = Console.WriteLine; /// /// Matches accidental hashline prefixes the LLM may include in new content. /// Format: "lineNumber:hexHash|" e.g. "5:a3|" /// private static readonly Regex HashlinePrefix = MyRegex(); /// /// Strips accidental hashline prefixes from lines the LLM sends as new content. /// Logs a warning when stripping occurs. /// private static string[] SanitizeNewLines(string[] newLines) { bool anyStripped = false; var result = new string[newLines.Length]; for (int i = 0; i < newLines.Length; i++) { var match = HashlinePrefix.Match(newLines[i]); if (match.Success) { result[i] = newLines[i][match.Length..]; anyStripped = true; } else { result[i] = newLines[i]; } } if (anyStripped) { Log(" ⚠ Stripped hashline prefixes from new content (LLM included anchors in edit content)"); } return result; } [Description("Replace a range of lines. Both line number and hash in the line:hash anchor must match.")] public static string ReplaceLines( [Description("Path to the file.")] string path, [Description("First line's line:hash anchor (e.g. '5:a3').")] string startAnchor, [Description("Last line's line:hash anchor. Use same as startAnchor for a single line.")] string endAnchor, [Description("Raw source code to insert. Do NOT include 'lineNumber:hash|' prefixes.")] string[] newLines) { newLines = SanitizeNewLines(newLines); path = FileTools.ResolvePath(path); Log($"REPLACE_LINES: {path}"); Log($" Range: {startAnchor} -> {endAnchor}"); Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines"); if (!File.Exists(path)) return $"ERROR: File not found: {path}\n Check the correct path and try again."; try { string[] lines = File.ReadAllLines(path); if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines, out int startIdx, out int endIdx, out string error)) return $"ERROR: Anchor validation failed\n{error}"; var result = new List(lines.Length - (endIdx - startIdx + 1) + newLines.Length); result.AddRange(lines[..startIdx]); result.AddRange(newLines); result.AddRange(lines[(endIdx + 1)..]); File.WriteAllLines(path, result); return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}"; } catch (Exception ex) { return $"ERROR modifying '{path}': {ex.Message}.\nThis is a bug. Tell the user about it."; } } [Description("Delete a range of lines.")] public static string DeleteRange( [Description("Path to the file.")] string path, [Description("First line's line:hash anchor (e.g. '4:7c').")] string startAnchor, [Description("Last line's line:hash anchor (e.g. '6:19').")] string endAnchor) { path = FileTools.ResolvePath(path); Log($"Deleting lines in file: {path}"); if (!File.Exists(path)) return $"ERROR: File not found: {path}"; try { string[] lines = File.ReadAllLines(path); if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines, out int startIdx, out int endIdx, out string error)) return $"ERROR: {error}"; var result = new List(lines.Length - (endIdx - startIdx + 1)); result.AddRange(lines[..startIdx]); result.AddRange(lines[(endIdx + 1)..]); File.WriteAllLines(path, result); return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}"; } catch (Exception ex) { return $"ERROR modifying '{path}': {ex.Message}\nThis is a bug. Tell the user about it."; } } [Description("Delete a file or directory. Use mode='file' to delete a file, mode='dir' to delete a directory.")] public static string Delete( [Description("Path to the file or directory to delete.")] string path, [Description("Type of deletion: 'file' or 'dir'. Defaults to 'file'.")] string mode = "file") { path = FileTools.ResolvePath(path); string targetType = mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase) ? "directory" : "file"; Log($"Deleting {targetType}: {path}"); if (mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase)) { if (!Directory.Exists(path)) return $"ERROR: Directory not found: {path}"; try { Directory.Delete(path, true); return $"OK: Directory deleted: '{path}'"; } catch (Exception ex) { return $"ERROR deleting directory '{path}': {ex.Message}\nThis is a bug. Tell the user about it."; } } else { if (!File.Exists(path)) return $"ERROR: File not found: {path}"; try { File.Delete(path); return $"OK (deleted)"; } catch (Exception ex) { return $"ERROR deleting '{path}': {ex.Message}\nThis is a bug. Tell the user about it."; } } } [Description("Move or copy a file to a new location.")] public static string MoveFile( [Description("Current path to the file.")] string sourcePath, [Description("New path for the file.")] string destinationPath, [Description("If true, copy the file instead of moving it. Defaults to false.")] bool copy = false) { sourcePath = FileTools.ResolvePath(sourcePath); destinationPath = FileTools.ResolvePath(destinationPath); string action = copy ? "Copying" : "Moving"; Log($"{action} file: {sourcePath} -> {destinationPath}"); if (!File.Exists(sourcePath)) return $"ERROR: Source file not found: {sourcePath}"; if (File.Exists(destinationPath)) return $"ERROR: Destination file already exists: {destinationPath}"; try { string? dir = Path.GetDirectoryName(destinationPath); if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); if (copy) File.Copy(sourcePath, destinationPath); else File.Move(sourcePath, destinationPath); return copy ? $"OK (copied to {destinationPath})" : $"OK (moved to {destinationPath})"; } catch (Exception ex) { return $"ERROR {action.ToLower()} file: {ex.Message}\nThis is a bug. Tell the user about it."; } } [Description("Write to a file with different modes: create, append, or insert.")] public static string WriteToFile( [Description("Path to the file.")] string path, [Description("Content to write.")] string[] content, [Description("Write mode: 'create' (error if exists), 'append' (creates if missing), 'insert' (requires anchor)")] string mode = "create", [Description("line:hash anchor to insert after (required for mode='insert', e.g. '3:0e').")] string? anchor = null) { content = SanitizeNewLines(content); path = FileTools.ResolvePath(path); Log($"WRITE_TO_FILE: {path}"); Log($" Mode: {mode}"); Log($" Writing {content.Length} lines"); try { string? dir = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); switch (mode.ToLower()) { case "create": if (File.Exists(path)) return $"ERROR: File already exists: {path}"; if (content.Length > 0) File.WriteAllLines(path, content); else File.WriteAllText(path, ""); return $"OK fp:{HashlineEncoder.FileFingerprint(content)}"; case "append": if (!File.Exists(path)) { File.WriteAllText(path, ""); Log($" (created new file)"); } using (var writer = new System.IO.StreamWriter(path, true)) { foreach (var line in content) { writer.WriteLine(line); } } string[] appendedLines = File.ReadAllLines(path); return $"OK fp:{HashlineEncoder.FileFingerprint([.. appendedLines])}"; case "insert": if (!File.Exists(path)) return $"ERROR: File not found: {path}"; if (string.IsNullOrEmpty(anchor)) return "ERROR: mode='insert' requires an anchor parameter"; string[] lines = File.ReadAllLines(path); if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error)) return $"ERROR: {error}"; var result = new List(lines.Length + content.Length); result.AddRange(lines[..(idx + 1)]); result.AddRange(content); result.AddRange(lines[(idx + 1)..]); File.WriteAllLines(path, result); return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}"; default: return $"ERROR: Unknown mode '{mode}'. Valid modes: create, append, insert"; } } catch (Exception ex) { return $"ERROR writing to '{path}': {ex.Message}\nThis is a bug. Tell the user about it."; } } [Description("Atomically apply multiple replace/delete operations to a file. All anchors validated upfront against original content. Operations auto-sorted bottom-to-top to prevent line drift. Prefer over individual calls when making multiple edits.")] public static string BatchEdit( [Description("Path to the file.")] string path, [Description("Array of operations to apply. Operations are applied in bottom-to-top order automatically.")] BatchOperation[] operations) { path = FileTools.ResolvePath(path); Log($"BATCH_EDIT: {path}"); Log($" Operations: {operations.Length}"); if (!File.Exists(path)) return $"ERROR: File not found: {path}"; if (operations.Length == 0) return "ERROR: No operations provided"; try { // Read file once string[] lines = File.ReadAllLines(path); // Pre-validate all anchors against original content (fail-fast) var resolvedOps = new List<(int StartIdx, int EndIdx, BatchOperation Op)>(); for (int i = 0; i < operations.Length; i++) { var op = operations[i]; if (string.IsNullOrWhiteSpace(op.Type)) return $"ERROR: Operation {i}: Type is required (use 'replace' or 'delete')"; var opType = op.Type.ToLowerInvariant(); if (opType != "replace" && opType != "delete") return $"ERROR: Operation {i}: Invalid type '{op.Type}'. Must be 'replace' or 'delete'"; if (opType == "replace" && op.Content == null) return $"ERROR: Operation {i}: 'replace' requires Content"; if (string.IsNullOrEmpty(op.StartAnchor) || string.IsNullOrEmpty(op.EndAnchor)) return $"ERROR: Operation {i}: StartAnchor and EndAnchor are required"; if (!HashlineValidator.TryResolveRange(op.StartAnchor, op.EndAnchor, lines, out int startIdx, out int endIdx, out string error)) return $"ERROR: Operation {i}: Anchor validation failed\n{error}"; resolvedOps.Add((startIdx, endIdx, op)); } // Check for overlapping ranges (conflicting operations) for (int i = 0; i < resolvedOps.Count; i++) { for (int j = i + 1; j < resolvedOps.Count; j++) { var (startA, endA, _) = resolvedOps[i]; var (startB, endB, _) = resolvedOps[j]; if (!(endA < startB || endB < startA)) return $"ERROR: Operations {i} and {j} have overlapping ranges. " + $"Range [{startA}-{endA}] overlaps with [{startB}-{endB}]."; } } // Sort operations top-to-bottom (by start index ascending) because we build a new list sequentially var sortedOps = resolvedOps.OrderBy(x => x.StartIdx).ToList(); // Apply all operations to a single buffer var result = new List(lines.Length); int nextLineIdx = 0; foreach (var (startIdx, endIdx, op) in sortedOps) { // Copy lines before this operation if (startIdx > nextLineIdx) result.AddRange(lines[nextLineIdx..startIdx]); // Apply operation var opType = op.Type.ToLowerInvariant(); if (opType == "replace") result.AddRange(SanitizeNewLines(op.Content!)); // delete: don't add anything (skip the range) nextLineIdx = endIdx + 1; } // Copy remaining lines after the last operation if (nextLineIdx < lines.Length) result.AddRange(lines[nextLineIdx..]); // Write file once File.WriteAllLines(path, result); return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}"; } catch (Exception ex) { return $"ERROR batch editing '{path}': {ex.Message}\nThis is a bug. Tell the user about it."; } } [GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Compiled)] private static partial Regex MyRegex(); }