Compare commits
5 Commits
50414e8b8c
...
829ba7a7f2
| Author | SHA1 | Date | |
|---|---|---|---|
| 829ba7a7f2 | |||
| 8b48b0f866 | |||
| 82ef63c731 | |||
| 119e623f5a | |||
| e98cd3b19c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ bin
|
|||||||
obj
|
obj
|
||||||
.vscode
|
.vscode
|
||||||
publish
|
publish
|
||||||
|
.anchor
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using AnchorCli.OpenRouter;
|
using AnchorCli.OpenRouter;
|
||||||
|
using AnchorCli.Tools;
|
||||||
|
|
||||||
namespace AnchorCli;
|
namespace AnchorCli;
|
||||||
|
|
||||||
@@ -16,7 +17,11 @@ namespace AnchorCli;
|
|||||||
[JsonSerializable(typeof(ModelsResponse))]
|
[JsonSerializable(typeof(ModelsResponse))]
|
||||||
[JsonSerializable(typeof(ModelInfo))]
|
[JsonSerializable(typeof(ModelInfo))]
|
||||||
[JsonSerializable(typeof(ModelPricing))]
|
[JsonSerializable(typeof(ModelPricing))]
|
||||||
|
[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))]
|
||||||
|
[JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))]
|
||||||
[JsonSerializable(typeof(AnchorConfig))]
|
[JsonSerializable(typeof(AnchorConfig))]
|
||||||
|
[JsonSerializable(typeof(BatchOperation))]
|
||||||
|
[JsonSerializable(typeof(BatchOperation[]))]
|
||||||
[JsonSourceGenerationOptions(
|
[JsonSourceGenerationOptions(
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace AnchorCli;
|
namespace AnchorCli;
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ internal sealed class ChatSession
|
|||||||
2. After reading, edit the file before verifying the returned fingerprint.
|
2. After reading, edit the file before verifying the returned fingerprint.
|
||||||
3. Edit from bottom to top so line numbers don't shift.
|
3. Edit from bottom to top so line numbers don't shift.
|
||||||
4. If an anchor fails validation, re-read the relevant range to get fresh anchors.
|
4. If an anchor fails validation, re-read the relevant range to get fresh anchors.
|
||||||
|
5. When making multiple edits to a file, use BatchEdit instead of multiple individual calls to prevent anchor invalidation between operations.
|
||||||
|
|
||||||
Keep responses concise. You have access to the current working directory.
|
Keep responses concise. You have access to the current working directory.
|
||||||
You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}}
|
You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}}
|
||||||
@@ -69,4 +71,38 @@ internal sealed class ChatSession
|
|||||||
yield return update;
|
yield return update;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(string filePath, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Skip the system message when saving (it will be recreated on load)
|
||||||
|
var messagesToSave = History.Skip(1).ToList();
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(messagesToSave, AppJsonContext.Default.ListChatMessage);
|
||||||
|
await File.WriteAllTextAsync(filePath, json, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadAsync(string filePath, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(filePath, cancellationToken);
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
var messages = JsonSerializer.Deserialize<List<ChatMessage>>(json, AppJsonContext.Default.ListChatMessage)
|
||||||
|
?? new List<ChatMessage>();
|
||||||
|
|
||||||
|
// Keep the system message and append loaded messages
|
||||||
|
var systemMessage = History[0];
|
||||||
|
History.Clear();
|
||||||
|
History.Add(systemMessage);
|
||||||
|
History.AddRange(messages);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
Commands/LoadCommand.cs
Normal file
31
Commands/LoadCommand.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace AnchorCli.Commands;
|
||||||
|
|
||||||
|
internal class LoadCommand(ChatSession session) : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "load";
|
||||||
|
public string Description => "Load a chat session from a file";
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(string[] args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string filePath = args.Length > 0 ? args[0] : ".anchor/session.json";
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[yellow]No session file found at {Markup.Escape(filePath)}[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await session.LoadAsync(filePath, ct);
|
||||||
|
AnsiConsole.MarkupLine($"[green]Session loaded from {Markup.Escape(filePath)}[/]");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[red]Failed to load session: {Markup.Escape(ex.Message)}[/]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
31
Commands/SaveCommand.cs
Normal file
31
Commands/SaveCommand.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace AnchorCli.Commands;
|
||||||
|
|
||||||
|
internal class SaveCommand(ChatSession session) : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "save";
|
||||||
|
public string Description => "Save the current chat session to a file";
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(string[] args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string filePath = args.Length > 0 ? args[0] : ".anchor/session.json";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(filePath);
|
||||||
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.SaveAsync(filePath, ct);
|
||||||
|
AnsiConsole.MarkupLine($"[green]Session saved to {Markup.Escape(filePath)}[/]");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[red]Failed to save session: {Markup.Escape(ex.Message)}[/]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -62,17 +62,14 @@ internal static class HashlineValidator
|
|||||||
|
|
||||||
if (lineNumber < 1 || lineNumber > lines.Length)
|
if (lineNumber < 1 || lineNumber > lines.Length)
|
||||||
{
|
{
|
||||||
error = $"Anchor '{anchor}': line {lineNumber} is out of range " +
|
error = $"Anchor '{anchor}': line {lineNumber} is out of range. Re-read the file ({lines.Length} lines).";
|
||||||
$"(file has {lines.Length} line(s)).";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
string actualHash = HashlineEncoder.ComputeHash(lines[lineNumber - 1].AsSpan(), lineNumber);
|
string actualHash = HashlineEncoder.ComputeHash(lines[lineNumber - 1].AsSpan(), lineNumber);
|
||||||
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
error = $"Anchor '{anchor}': hash mismatch at line {lineNumber} " +
|
error = $"Anchor '{anchor}': hash mismatch at line {lineNumber}. The file has changed — re-read before editing.";
|
||||||
$"(expected '{expectedHash}', got '{actualHash}'). " +
|
|
||||||
$"The file has changed — re-read before editing.";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,10 +95,7 @@ internal static class HashlineValidator
|
|||||||
out int endIndex,
|
out int endIndex,
|
||||||
out string error)
|
out string error)
|
||||||
{
|
{
|
||||||
startIndex = -1;
|
|
||||||
endIndex = -1;
|
endIndex = -1;
|
||||||
error = string.Empty;
|
|
||||||
|
|
||||||
if (!TryResolve(startAnchor, lines, out startIndex, out error))
|
if (!TryResolve(startAnchor, lines, out startIndex, out error))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -110,8 +104,7 @@ internal static class HashlineValidator
|
|||||||
|
|
||||||
if (startIndex > endIndex)
|
if (startIndex > endIndex)
|
||||||
{
|
{
|
||||||
error = $"Range error: start anchor '{startAnchor}' (line {startIndex + 1}) " +
|
error = $"Range error: start anchor is after end anchor.";
|
||||||
$"is after end anchor '{endAnchor}' (line {endIndex + 1}).";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
Program.cs
44
Program.cs
@@ -81,13 +81,13 @@ infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDire
|
|||||||
|
|
||||||
if (modelInfo?.Pricing != null)
|
if (modelInfo?.Pricing != null)
|
||||||
|
|
||||||
if (modelInfo?.Pricing != null)
|
if (modelInfo?.Pricing != null)
|
||||||
{
|
{
|
||||||
var inM = tokenTracker.InputPrice * 1_000_000m;
|
var inM = tokenTracker.InputPrice * 1_000_000m;
|
||||||
var outM = tokenTracker.OutputPrice * 1_000_000m;
|
var outM = tokenTracker.OutputPrice * 1_000_000m;
|
||||||
infoTable.AddRow("[grey]Pricing[/]",
|
infoTable.AddRow("[grey]Pricing[/]",
|
||||||
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
|
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
|
||||||
}
|
}
|
||||||
if (modelInfo != null)
|
if (modelInfo != null)
|
||||||
{
|
{
|
||||||
infoTable.AddRow("[grey]Context[/]",
|
infoTable.AddRow("[grey]Context[/]",
|
||||||
@@ -110,7 +110,7 @@ var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClie
|
|||||||
IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
|
IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
|
||||||
|
|
||||||
// ── Tool call logging via Spectre ───────────────────────────────────────
|
// ── Tool call logging via Spectre ───────────────────────────────────────
|
||||||
object consoleLock = new object();
|
object consoleLock = new();
|
||||||
|
|
||||||
void ToolLog(string message)
|
void ToolLog(string message)
|
||||||
{
|
{
|
||||||
@@ -142,12 +142,48 @@ commandRegistry.Register(new StatusCommand(model, endpoint));
|
|||||||
commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
|
commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
|
||||||
commandRegistry.Register(new SetupCommand());
|
commandRegistry.Register(new SetupCommand());
|
||||||
commandRegistry.Register(new ResetCommand(session, tokenTracker));
|
commandRegistry.Register(new ResetCommand(session, tokenTracker));
|
||||||
|
commandRegistry.Register(new SaveCommand(session));
|
||||||
|
commandRegistry.Register(new LoadCommand(session));
|
||||||
|
|
||||||
|
|
||||||
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
||||||
|
|
||||||
// ── Run Repl ────────────────────────────────────────────────────────────
|
// ── Run Repl ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Auto-load session if it exists
|
||||||
|
const string sessionPath = ".anchor/session.json";
|
||||||
|
if (File.Exists(sessionPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await session.LoadAsync(sessionPath, default);
|
||||||
|
AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]");
|
||||||
|
|
||||||
|
// Print the last message if there is one
|
||||||
|
if (session.History.Count > 1)
|
||||||
|
{
|
||||||
|
var lastMessage = session.History[^1];
|
||||||
|
var preview = lastMessage.Text.Length > 280
|
||||||
|
? lastMessage.Text[..277] + "..."
|
||||||
|
: lastMessage.Text;
|
||||||
|
AnsiConsole.MarkupLine($"[dim grey] Last message: {Markup.Escape(preview)}[/]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* Ignore load errors on startup */ }
|
||||||
|
}
|
||||||
|
|
||||||
var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
|
var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
|
||||||
await repl.RunAsync();
|
await repl.RunAsync();
|
||||||
|
|
||||||
|
// Auto-save session on clean exit
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(sessionPath);
|
||||||
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
await session.SaveAsync(sessionPath, default);
|
||||||
|
}
|
||||||
|
catch { /* Ignore save errors on exit */ }
|
||||||
|
|
||||||
|
|||||||
23
ReplLoop.cs
23
ReplLoop.cs
@@ -216,6 +216,17 @@ internal sealed class ReplLoop
|
|||||||
}
|
}
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save session after each LLM turn completes
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const string sessionPath = ".anchor/session.json";
|
||||||
|
var directory = Path.GetDirectoryName(sessionPath);
|
||||||
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
await _session.SaveAsync(sessionPath, default);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -248,5 +259,17 @@ internal sealed class ReplLoop
|
|||||||
responseCts = null;
|
responseCts = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
AnsiConsole.Write(
|
||||||
|
new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
|
||||||
|
.Header("[bold red] Error [/]")
|
||||||
|
.BorderColor(Color.Red)
|
||||||
|
.RoundedBorder()
|
||||||
|
.Padding(1, 0));
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ internal static class ToolRegistry
|
|||||||
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions),
|
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions),
|
||||||
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions),
|
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions),
|
||||||
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions),
|
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.BatchEdit, serializerOptions: jsonOptions),
|
||||||
AIFunctionFactory.Create(EditTools.Delete, serializerOptions: jsonOptions),
|
AIFunctionFactory.Create(EditTools.Delete, serializerOptions: jsonOptions),
|
||||||
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions),
|
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions),
|
||||||
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions),
|
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions),
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ using AnchorCli.Hashline;
|
|||||||
|
|
||||||
namespace AnchorCli.Tools;
|
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>
|
/// <summary>
|
||||||
/// Mutating file tools exposed to the LLM as AIFunctions.
|
/// Mutating file tools exposed to the LLM as AIFunctions.
|
||||||
/// Every operation validates Hashline anchors (line:hash format) before touching the file.
|
/// Every operation validates Hashline anchors (line:hash format) before touching the file.
|
||||||
@@ -66,7 +75,7 @@ internal static partial class EditTools
|
|||||||
Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines");
|
Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines");
|
||||||
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
return $"ERROR: File not found: {path}";
|
return $"ERROR: File not found: {path}\n Check the correct path and try again.";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -74,7 +83,7 @@ internal static partial class EditTools
|
|||||||
|
|
||||||
if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines,
|
if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines,
|
||||||
out int startIdx, out int endIdx, out string error))
|
out int startIdx, out int endIdx, out string error))
|
||||||
return $"ERROR: {error}";
|
return $"ERROR: Anchor validation failed\n{error}";
|
||||||
|
|
||||||
var result = new List<string>(lines.Length - (endIdx - startIdx + 1) + newLines.Length);
|
var result = new List<string>(lines.Length - (endIdx - startIdx + 1) + newLines.Length);
|
||||||
result.AddRange(lines[..startIdx]);
|
result.AddRange(lines[..startIdx]);
|
||||||
@@ -86,7 +95,7 @@ internal static partial class EditTools
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"ERROR modifying '{path}': {ex.Message}";
|
return $"ERROR modifying '{path}': {ex.Message}.\nThis is a bug. Tell the user about it.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +129,7 @@ internal static partial class EditTools
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"ERROR modifying '{path}': {ex.Message}";
|
return $"ERROR modifying '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +141,10 @@ internal static partial class EditTools
|
|||||||
[Description("Type of deletion: 'file' or 'dir'. Defaults to 'file'.")] string mode = "file")
|
[Description("Type of deletion: 'file' or 'dir'. Defaults to 'file'.")] string mode = "file")
|
||||||
{
|
{
|
||||||
path = FileTools.ResolvePath(path);
|
path = FileTools.ResolvePath(path);
|
||||||
string targetType = mode.ToLower() == "dir" ? "directory" : "file";
|
string targetType = mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase) ? "directory" : "file";
|
||||||
Log($"Deleting {targetType}: {path}");
|
Log($"Deleting {targetType}: {path}");
|
||||||
|
|
||||||
if (mode.ToLower() == "dir")
|
if (mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase))
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(path))
|
if (!Directory.Exists(path))
|
||||||
return $"ERROR: Directory not found: {path}";
|
return $"ERROR: Directory not found: {path}";
|
||||||
@@ -147,7 +156,7 @@ internal static partial class EditTools
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"ERROR deleting directory '{path}': {ex.Message}";
|
return $"ERROR deleting directory '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -162,7 +171,7 @@ internal static partial class EditTools
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"ERROR deleting '{path}': {ex.Message}";
|
return $"ERROR deleting '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,7 +207,7 @@ internal static partial class EditTools
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"ERROR {action.ToLower()} file: {ex.Message}";
|
return $"ERROR {action.ToLower()} file: {ex.Message}\nThis is a bug. Tell the user about it.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +288,103 @@ internal static partial class EditTools
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"ERROR writing to '{path}': {ex.Message}";
|
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.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user