394 lines
15 KiB
C#
394 lines
15 KiB
C#
using System.ComponentModel;
|
|
using System.Text.RegularExpressions;
|
|
using AnchorCli.Hashline;
|
|
|
|
namespace AnchorCli.Tools;
|
|
|
|
|
|
/// <summary>
|
|
/// 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);
|
|
|
|
/// <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}\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<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}.\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<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}\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<string>(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 bottom-to-top (by start index descending) for safe application
|
|
var sortedOps = resolvedOps.OrderByDescending(x => x.StartIdx).ToList();
|
|
|
|
// Apply all operations to a single buffer
|
|
var result = new List<string>(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();
|
|
}
|