318 lines
11 KiB
C#
318 lines
11 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. 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<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 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<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.")]
|
|
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<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 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("Rename or move a file. Auto-creates target dirs.")]
|
|
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 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();
|
|
}
|