1
0

Compare commits

...

5 Commits

10 changed files with 310 additions and 48 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ bin
obj
.vscode
publish
.anchor

View File

@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using AnchorCli.OpenRouter;
using AnchorCli.Tools;
namespace AnchorCli;
@@ -16,7 +17,11 @@ namespace AnchorCli;
[JsonSerializable(typeof(ModelsResponse))]
[JsonSerializable(typeof(ModelInfo))]
[JsonSerializable(typeof(ModelPricing))]
[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))]
[JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))]
[JsonSerializable(typeof(AnchorConfig))]
[JsonSerializable(typeof(BatchOperation))]
[JsonSerializable(typeof(BatchOperation[]))]
[JsonSourceGenerationOptions(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.AI;
using System.Text.Json;
namespace AnchorCli;
@@ -43,6 +44,7 @@ internal sealed class ChatSession
2. After reading, edit the file before verifying the returned fingerprint.
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.
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.
You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}}
@@ -69,4 +71,38 @@ internal sealed class ChatSession
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
View 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
View 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)}[/]");
}
}
}

View File

@@ -62,17 +62,14 @@ internal static class HashlineValidator
if (lineNumber < 1 || lineNumber > lines.Length)
{
error = $"Anchor '{anchor}': line {lineNumber} is out of range " +
$"(file has {lines.Length} line(s)).";
error = $"Anchor '{anchor}': line {lineNumber} is out of range. Re-read the file ({lines.Length} lines).";
return false;
}
string actualHash = HashlineEncoder.ComputeHash(lines[lineNumber - 1].AsSpan(), lineNumber);
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
error = $"Anchor '{anchor}': hash mismatch at line {lineNumber} " +
$"(expected '{expectedHash}', got '{actualHash}'). " +
$"The file has changed — re-read before editing.";
error = $"Anchor '{anchor}': hash mismatch at line {lineNumber}. The file has changed — re-read before editing.";
return false;
}
@@ -98,10 +95,7 @@ internal static class HashlineValidator
out int endIndex,
out string error)
{
startIndex = -1;
endIndex = -1;
error = string.Empty;
if (!TryResolve(startAnchor, lines, out startIndex, out error))
return false;
@@ -110,8 +104,7 @@ internal static class HashlineValidator
if (startIndex > endIndex)
{
error = $"Range error: start anchor '{startAnchor}' (line {startIndex + 1}) " +
$"is after end anchor '{endAnchor}' (line {endIndex + 1}).";
error = $"Range error: start anchor is after end anchor.";
return false;
}

View File

@@ -110,7 +110,7 @@ var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClie
IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
// ── Tool call logging via Spectre ───────────────────────────────────────
object consoleLock = new object();
object consoleLock = new();
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 SetupCommand());
commandRegistry.Register(new ResetCommand(session, tokenTracker));
commandRegistry.Register(new SaveCommand(session));
commandRegistry.Register(new LoadCommand(session));
var commandDispatcher = new CommandDispatcher(commandRegistry);
// ── 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);
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 */ }

View File

@@ -216,6 +216,17 @@ internal sealed class ReplLoop
}
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)
{
@@ -248,5 +259,17 @@ internal sealed class ReplLoop
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();
}
}
}
}

View File

@@ -16,6 +16,7 @@ internal static class ToolRegistry
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.BatchEdit, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.Delete, serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions),

View File

@@ -4,6 +4,15 @@ 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.
@@ -66,7 +75,7 @@ internal static partial class EditTools
Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines");
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
{
@@ -74,7 +83,7 @@ internal static partial class EditTools
if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines,
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);
result.AddRange(lines[..startIdx]);
@@ -86,7 +95,7 @@ internal static partial class EditTools
}
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)
{
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")
{
path = FileTools.ResolvePath(path);
string targetType = mode.ToLower() == "dir" ? "directory" : "file";
string targetType = mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase) ? "directory" : "file";
Log($"Deleting {targetType}: {path}");
if (mode.ToLower() == "dir")
if (mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase))
{
if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}";
@@ -147,7 +156,7 @@ internal static partial class EditTools
}
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
@@ -162,7 +171,7 @@ internal static partial class EditTools
}
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)
{
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)
{
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.";
}
}