using System.ComponentModel; using System.Text.RegularExpressions; using AnchorCli.Hashline; namespace AnchorCli.Tools; /// /// 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}"; 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) + 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}"; } } [Description("Insert lines after the specified line:hash anchor.")] public static string InsertAfter( [Description("Path to the file.")] string path, [Description("line:hash anchor to insert after (e.g. '3:0e').")] string anchor, [Description("Raw source code to insert. Do NOT include 'lineNumber:hash|' prefixes.")] string[] newLines) { newLines = SanitizeNewLines(newLines); path = FileTools.ResolvePath(path); Log($"INSERT_AFTER: {path}"); Log($" Anchor: {anchor}"); Log($" Inserting {newLines.Length} lines after line {anchor.Split(':')[0]}"); if (!File.Exists(path)) return $"ERROR: File not found: {path}"; try { 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 + newLines.Length); result.AddRange(lines[..(idx + 1)]); result.AddRange(newLines); result.AddRange(lines[(idx + 1)..]); File.WriteAllLines(path, result); return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}"; } catch (Exception ex) { return $"ERROR modifying '{path}': {ex.Message}"; } } [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}"; } } [Description("Create a new file (parents auto-created). Max initial lines: 200. Alternatively, append lines later.")] public static string CreateFile( [Description("Path to the new file to create.")] string path, [Description("Optional initial raw source code. Do NOT include 'lineNumber:hash|' prefixes.")] string[]? initialLines = null) { path = FileTools.ResolvePath(path); Log($"Creating file: {path}"); if (File.Exists(path)) return $"ERROR: File already exists: {path}"; try { if (initialLines is not null) initialLines = SanitizeNewLines(initialLines); string? dir = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); if (initialLines is not null && initialLines.Length > 0) File.WriteAllLines(path, initialLines); else File.WriteAllText(path, ""); return $"OK fp:{HashlineEncoder.FileFingerprint(initialLines ?? [])}"; } catch (Exception ex) { return $"ERROR creating '{path}': {ex.Message}"; } } [Description("Delete a file permanently.")] public static string DeleteFile( [Description("Path to the file to delete.")] string path) { path = FileTools.ResolvePath(path); Log($"Deleting file: {path}"); 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}"; } } [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}"; } } [Description("Append lines to EOF (auto-creating the file if missing).")] public static string AppendToFile( [Description("Path to the file to append to.")] string path, [Description("Raw source code to append. Do NOT include 'lineNumber:hash|' prefixes.")] string[] lines) { lines = SanitizeNewLines(lines); path = FileTools.ResolvePath(path); Log($"Appending to file: {path}"); Log($" Appending {lines.Length} lines"); try { string? dir = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); if (!File.Exists(path)) { File.WriteAllText(path, ""); Log($" (created new file)"); } using (var writer = new System.IO.StreamWriter(path, true)) { foreach (var line in lines) { writer.WriteLine(line); } } string[] allLines = File.ReadAllLines(path); return $"OK fp:{HashlineEncoder.FileFingerprint([.. allLines])}"; } catch (Exception ex) { return $"ERROR appending to '{path}': {ex.Message}"; } } [GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Compiled)] private static partial Regex MyRegex(); }