1
0

Compare commits

...

5 Commits

10 changed files with 310 additions and 48 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
bin
obj
.vscode
publish
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

@@ -81,13 +81,13 @@ infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDire
if (modelInfo?.Pricing != null)
if (modelInfo?.Pricing != null)
{
var inM = tokenTracker.InputPrice * 1_000_000m;
var outM = tokenTracker.OutputPrice * 1_000_000m;
infoTable.AddRow("[grey]Pricing[/]",
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
}
if (modelInfo?.Pricing != null)
{
var inM = tokenTracker.InputPrice * 1_000_000m;
var outM = tokenTracker.OutputPrice * 1_000_000m;
infoTable.AddRow("[grey]Pricing[/]",
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
}
if (modelInfo != null)
{
infoTable.AddRow("[grey]Context[/]",
@@ -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,20 +216,48 @@ internal sealed class ReplLoop
}
AnsiConsole.WriteLine();
}
}
catch (OperationCanceledException)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
AnsiConsole.WriteLine();
if (!string.IsNullOrEmpty(fullResponse))
// Save session after each LLM turn completes
try
{
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
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)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
AnsiConsole.WriteLine();
if (!string.IsNullOrEmpty(fullResponse))
{
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
}
_session.History.Add(new ChatMessage(ChatRole.User,
"[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]"));
}
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();
}
finally
{
responseCts?.Dispose();
responseCts = null;
}
_session.History.Add(new ChatMessage(ChatRole.User,
"[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]"));
}
catch (Exception ex)
{
@@ -242,11 +270,6 @@ internal sealed class ReplLoop
.Padding(1, 0));
AnsiConsole.WriteLine();
}
finally
{
responseCts?.Dispose();
responseCts = null;
}
}
}
}

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,22 +129,22 @@ 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.";
}
}
[Description("Delete a file or directory. Use mode='file' to delete a file, mode='dir' to delete a directory.")]
[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.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,12 +171,12 @@ 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.";
}
}
}
[Description("Move or copy a file to a new location.")]
[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,
@@ -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.";
}
}
@@ -241,7 +250,7 @@ internal static partial class EditTools
File.WriteAllText(path, "");
Log($" (created new file)");
}
using (var writer = new System.IO.StreamWriter(path, true))
{
foreach (var line in content)
@@ -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.";
}
}