318 lines
12 KiB
C#
318 lines
12 KiB
C#
using System.ComponentModel;
|
|
using System.Text.RegularExpressions;
|
|
using AnchorCli.Hashline;
|
|
|
|
namespace AnchorCli.Tools;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal static partial class EditTools
|
|
{
|
|
public static Action<string> Log { get; set; } = Console.WriteLine;
|
|
|
|
/// <summary>
|
|
/// Matches accidental hashline prefixes the LLM may include in new content.
|
|
/// Format: "lineNumber:hexHash|" e.g. "5:a3|"
|
|
/// </summary>
|
|
private static readonly Regex HashlinePrefix =
|
|
MyRegex();
|
|
|
|
/// <summary>
|
|
/// Strips accidental hashline prefixes from lines the LLM sends as new content.
|
|
/// Logs a warning when stripping occurs.
|
|
/// </summary>
|
|
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 in a file, identified by Hashline anchors. Both the line number and hash must match the current file state.")]
|
|
public static string ReplaceLines(
|
|
[Description("Path to the file.")] string path,
|
|
[Description("line:hash anchor of the first line to replace (e.g. '5:a3'). Both the line number AND hash must match.")] string startAnchor,
|
|
[Description("line:hash anchor of the last line to replace (e.g. '7:0e'). Use the same as startAnchor to replace a single line.")] string endAnchor,
|
|
[Description("New lines to insert in place of the replaced range. Each element becomes one line in the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] 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<string>(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 new lines immediately after the line identified by a Hashline anchor.")]
|
|
public static string InsertAfter(
|
|
[Description("Path to the file.")] string path,
|
|
[Description("line:hash anchor of the line to insert after (e.g. '3:0e'). Both the line number AND hash must match.")] string anchor,
|
|
[Description("Lines to insert after the anchor line. Each element becomes one line in the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] 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<string>(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 from a file, identified by Hashline anchors.")]
|
|
public static string DeleteRange(
|
|
[Description("Path to the file.")] string path,
|
|
[Description("line:hash anchor of the first line to delete (e.g. '4:7c'). Both the line number AND hash must match.")] string startAnchor,
|
|
[Description("line:hash anchor of the last line to delete (e.g. '6:19'). Both the line number AND hash must match.")] 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<string>(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 empty file, or a file with initial content. Creates missing parent directories automatically. If the agent doesn't succeed with initial content, they can also create an empty file first and add the content using AppendToFile.")]
|
|
public static string CreateFile(
|
|
[Description("Path to the new file to create.")] string path,
|
|
[Description("Optional initial content lines. If omitted, creates an empty file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] 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 from the disk.")]
|
|
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("Rename or move a file. Can move a file to a new directory (which will be created if it doesn't exist).")]
|
|
public static string RenameFile(
|
|
[Description("Current path to the file.")] string sourcePath,
|
|
[Description("New path for the file.")] string destinationPath)
|
|
{
|
|
sourcePath = FileTools.ResolvePath(sourcePath);
|
|
destinationPath = FileTools.ResolvePath(destinationPath);
|
|
Log($"Renaming 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);
|
|
|
|
File.Move(sourcePath, destinationPath);
|
|
return $"OK (moved to {destinationPath})";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return $"ERROR moving file: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
[Description("Copy a file to a new location.")]
|
|
public static string CopyFile(
|
|
[Description("Path to the existing file.")] string sourcePath,
|
|
[Description("Path for the copy.")] string destinationPath)
|
|
{
|
|
sourcePath = FileTools.ResolvePath(sourcePath);
|
|
destinationPath = FileTools.ResolvePath(destinationPath);
|
|
Log($"Copying 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);
|
|
|
|
File.Copy(sourcePath, destinationPath);
|
|
return $"OK (copied to {destinationPath})";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return $"ERROR copying file: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
[Description("Append lines to the end of a file without reading it first. Creates the file if it doesn't exist.")]
|
|
public static string AppendToFile(
|
|
[Description("Path to the file to append to.")] string path,
|
|
[Description("Lines to append to the end of the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] 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();
|
|
}
|