From ed897aeb01a7b182083133da38a6cc619abbe626 Mon Sep 17 00:00:00 2001 From: TomiEckert Date: Wed, 4 Mar 2026 22:24:05 +0100 Subject: [PATCH] feat: Introduce a `/reset` command to clear the chat session and token tracking, and update documentation. --- ChatSession.cs | 23 ++- Commands/ResetCommand.cs | 21 ++ ContextCompactor.cs | 406 ++++++++++++++++++------------------- OpenRouter/TokenTracker.cs | 8 + Program.cs | 275 ++++++++++++------------- README.md | 48 +++-- ReplLoop.cs | 37 ++-- SANDBOX.md | 334 ++++++++++++++++++++++++++++++ Tools/FileTools.cs | 12 +- 9 files changed, 777 insertions(+), 387 deletions(-) create mode 100644 Commands/ResetCommand.cs create mode 100644 SANDBOX.md diff --git a/ChatSession.cs b/ChatSession.cs index 866ca0b..fee75fb 100644 --- a/ChatSession.cs +++ b/ChatSession.cs @@ -11,7 +11,7 @@ internal sealed class ChatSession public ChatSession(IChatClient innerClient) { Compactor = new ContextCompactor(innerClient); - + var tools = ToolRegistry.GetTools(); _agent = new ChatClientBuilder(innerClient) .UseFunctionInvocation() @@ -34,23 +34,36 @@ internal sealed class ChatSession Never include the "lineNumber:hash|" prefix in content you write — it will corrupt the file. ## Workflow - 1. Always read a file before editing it. - 2. After a mutation, verify the returned fingerprint. + 1. ALWAYS call GrepFile before ReadFile on any source file. + Use patterns like "public|func|function|class|interface|enum|def|fn " to get a structural + outline of the file and identify the exact line numbers of the section you need. + Only then call ReadFile with a targeted startLine/endLine range. + ❌ WRONG: ReadFile("Foo.cs") — reads blindly without knowing the structure. + ✅ RIGHT: GrepFile("Foo.cs", "public|class|interface") → ReadFile("Foo.cs", 42, 90) + 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 file to get fresh anchors. + 4. If an anchor fails validation, re-read the relevant range to get fresh anchors. Keep responses concise. You have access to the current working directory. You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}} """) }; } + public void Reset() + { + // Keep only the system message + var systemMessage = History[0]; + History.Clear(); + History.Add(systemMessage); + } + public async IAsyncEnumerable GetStreamingResponseAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { var options = new ChatOptions { Tools = ToolRegistry.GetTools() }; var stream = _agent.GetStreamingResponseAsync(History, options, cancellationToken); - + await foreach (var update in stream.WithCancellation(cancellationToken)) { yield return update; diff --git a/Commands/ResetCommand.cs b/Commands/ResetCommand.cs new file mode 100644 index 0000000..3b2af5b --- /dev/null +++ b/Commands/ResetCommand.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.AI; +using Spectre.Console; +using AnchorCli.OpenRouter; + +namespace AnchorCli.Commands; + +internal class ResetCommand(ChatSession session, TokenTracker tokenTracker) : ICommand +{ + public string Name => "reset"; + public string Description => "Reset the chat session (clear history and token count)"; + + public Task ExecuteAsync(string[] args, CancellationToken ct) + { + session.Reset(); + tokenTracker.Reset(); + AnsiConsole.MarkupLine("[green]Chat session reset.[/]"); + return Task.CompletedTask; + } +} + + diff --git a/ContextCompactor.cs b/ContextCompactor.cs index 7b4ee08..0bbda8b 100644 --- a/ContextCompactor.cs +++ b/ContextCompactor.cs @@ -1,205 +1,201 @@ -using Microsoft.Extensions.AI; -using System.Text.RegularExpressions; -using Spectre.Console; - -namespace AnchorCli; - -/// -/// Compacts the chat history when context usage grows too large. -/// Asks the same model to summarize the conversation, then replaces -/// the history with [system prompt, summary, last N user/assistant turns]. -/// -internal sealed partial class ContextCompactor(IChatClient client) -{ - /// Number of recent user+assistant turn pairs to keep verbatim. - private const int KeepRecentTurns = 2; - - /// Minimum result length to consider for compaction. - private const int MinResultLength = 300; - - /// Matches hashline-encoded output: "lineNumber:hash|content" - private static readonly Regex HashlinePattern = - MyRegex(); - - private readonly IChatClient _client = client; - - /// - /// Compacts large hashline-encoded tool results from previous turns. - /// This is the cheapest and most impactful optimization — no LLM call needed. - /// A 300-line ReadFile result (~10K tokens) becomes a one-line note (~20 tokens). - /// - /// The chat history to compact in-place. - /// - /// Index of the first message added during the current turn. - /// Messages before this index are from previous turns and eligible for compaction. - /// - /// Number of tool results that were compacted. - public static int CompactStaleToolResults(List history) - { - int compacted = 0; - int userTurnsSeen = 0; - var filesRead = new HashSet(StringComparer.OrdinalIgnoreCase); - - // Walk backwards: index 0 is system prompt, so we stop at 1. - for (int i = history.Count - 1; i >= 1; i--) - { - var msg = history[i]; - - if (msg.Role == ChatRole.User) - { - userTurnsSeen++; - } - else if (msg.Role == ChatRole.Assistant) - { - // Find all FunctionCalls in this assistant message to map call IDs to file paths - var calls = msg.Contents.OfType().ToList(); - - // If the very next message is a System/Tool role containing FunctionResults - if (i + 1 < history.Count && history[i + 1].Role == ChatRole.Tool) - { - var resultMsg = history[i + 1]; - foreach (var content in resultMsg.Contents) - { - if (content is FunctionResultContent frc && - frc.Result is string resultStr && - resultStr.Length >= MinResultLength && - HashlinePattern.IsMatch(resultStr)) - { - // Find the corresponding function call to check its name/arguments - var call = calls.FirstOrDefault(c => c.CallId == frc.CallId); - if (call?.Name == "ReadFile" && call.Arguments != null && call.Arguments.TryGetValue("path", out var pathObj)) - { - string filePath = pathObj?.ToString() ?? ""; - - bool shouldRedact = false; - string reason = ""; - - // Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one. - if (filesRead.Contains(filePath)) - { - shouldRedact = true; - reason = "deduplication — you read this file again later"; - } - // Rule 2: TTL. If this was read 2 or more user turns ago, redact it. - else if (userTurnsSeen >= 2) - { - shouldRedact = true; - reason = "expired — read over 2 turns ago"; - } - - if (shouldRedact) - { - int lineCount = resultStr.Count(c => c == '\n'); - frc.Result = $"[File content: {lineCount} lines redacted for {reason}. Re-read the file if you need fresh anchors.]"; - compacted++; - } - else - { - // Keep it, but mark that we've seen it so older reads of the same file are redacted. - filesRead.Add(filePath); - } - } - } - } - } - } - } - - return compacted; - } - - /// - /// Compacts the history in-place via LLM summarization. Returns true if compaction was performed. - /// - public async Task TryCompactAsync( - List history, - CancellationToken ct = default) - { - // Need at least: system + some conversation to compact - if (history.Count < 5) - return false; - - // Split: system prompt (index 0) | middle (compactable) | tail (keep verbatim) - var systemMsg = history[0]; - - // Find the cut point: keep the last N user+assistant pairs - int keepFromIndex = FindKeepIndex(history); - if (keepFromIndex <= 1) - return false; // Nothing to compact - - // Extract the middle section to summarize - var toSummarize = history.Skip(1).Take(keepFromIndex - 1).ToList(); - var tail = history.Skip(keepFromIndex).ToList(); - - // Build a summarization prompt - var summaryMessages = new List - { - new(ChatRole.System, """ - You are a conversation summarizer. Summarize the following coding conversation - between a user and an AI assistant. Focus on: - - What files were read, created, or modified (and their paths) - - What changes were made and why - - Any decisions or preferences expressed by the user - - Current state of the work (what's done, what's pending) - Be concise but preserve all actionable context. Output a single summary paragraph. - Do NOT include any hashline anchors (lineNumber:hash|) in your summary. - """) - }; - summaryMessages.AddRange(toSummarize); - summaryMessages.Add(new(ChatRole.User, - "Summarize the above conversation concisely, preserving all important context for continuing the work.")); - - string summary; - try - { - var response = await _client.GetResponseAsync(summaryMessages, cancellationToken: ct); - summary = response.Text ?? "(summary unavailable)"; - } - catch - { - // If summarization fails, don't compact - return false; - } - - // Rebuild history: system + summary + recent turns - history.Clear(); - history.Add(systemMsg); - history.Add(new(ChatRole.User, - "[CONTEXT COMPACTED — The following is a summary of the earlier conversation]\n" + summary)); - history.Add(new(ChatRole.Assistant, - "Understood, I have the context from our earlier conversation. I'll continue from where we left off.")); - history.AddRange(tail); - - return true; - } - - /// - /// Finds the index from which to keep messages verbatim (the last N turn pairs). - /// - private static int FindKeepIndex(List history) - { - int pairsFound = 0; - int idx = history.Count - 1; - - while (idx > 0 && pairsFound < KeepRecentTurns) - { - // Walk backwards looking for user+assistant pairs - if (history[idx].Role == ChatRole.Assistant && idx > 1 && - history[idx - 1].Role == ChatRole.User) - { - pairsFound++; - idx -= 2; - } - else - { - idx--; - } - } - - // idx+1 is the first message to keep - return Math.Max(1, idx + 1); - } - - [GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Multiline | RegexOptions.Compiled)] - private static partial Regex MyRegex(); -} +using Microsoft.Extensions.AI; +using System.Text.RegularExpressions; +using Spectre.Console; + +namespace AnchorCli; + +/// +/// Compacts the chat history when context usage grows too large. +/// Asks the same model to summarize the conversation, then replaces +/// the history with [system prompt, summary, last N user/assistant turns]. +/// +internal sealed partial class ContextCompactor(IChatClient client) +{ + /// Number of recent user+assistant turn pairs to keep verbatim. + private const int KeepRecentTurns = 2; + + /// Minimum result length to consider for compaction. + private const int MinResultLength = 300; + + /// Matches hashline-encoded output: "lineNumber:hash|content" + private static readonly Regex HashlinePattern = + MyRegex(); + + private readonly IChatClient _client = client; + + /// + /// Compacts large hashline-encoded tool results from previous turns. + /// This is the cheapest and most impactful optimization — no LLM call needed. + /// A 300-line ReadFile result (~10K tokens) becomes a one-line note (~20 tokens). + /// + /// The chat history to compact in-place. + /// Number of tool results that were compacted. + public static int CompactStaleToolResults(List history) + { + int compacted = 0; + int userTurnsSeen = 0; + var filesRead = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Walk backwards: index 0 is system prompt, so we stop at 1. + for (int i = history.Count - 1; i >= 1; i--) + { + var msg = history[i]; + + if (msg.Role == ChatRole.User) + { + userTurnsSeen++; + } + else if (msg.Role == ChatRole.Assistant) + { + // Find all FunctionCalls in this assistant message to map call IDs to file paths + var calls = msg.Contents.OfType().ToList(); + + // If the very next message is a System/Tool role containing FunctionResults + if (i + 1 < history.Count && history[i + 1].Role == ChatRole.Tool) + { + var resultMsg = history[i + 1]; + foreach (var content in resultMsg.Contents) + { + if (content is FunctionResultContent frc && + frc.Result is string resultStr && + resultStr.Length >= MinResultLength && + HashlinePattern.IsMatch(resultStr)) + { + // Find the corresponding function call to check its name/arguments + var call = calls.FirstOrDefault(c => c.CallId == frc.CallId); + if (call?.Name == "ReadFile" && call.Arguments != null && call.Arguments.TryGetValue("path", out var pathObj)) + { + string filePath = pathObj?.ToString() ?? ""; + + bool shouldRedact = false; + string reason = ""; + + // Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one. + if (filesRead.Contains(filePath)) + { + shouldRedact = true; + reason = "deduplication — you read this file again later"; + } + // Rule 2: TTL. If this was read 2 or more user turns ago, redact it. + else if (userTurnsSeen >= 2) + { + shouldRedact = true; + reason = "expired — read over 2 turns ago"; + } + + if (shouldRedact) + { + int lineCount = resultStr.Count(c => c == '\n'); + frc.Result = $"[File content: {lineCount} lines redacted for {reason}. Re-read the file if you need fresh anchors.]"; + compacted++; + } + else + { + // Keep it, but mark that we've seen it so older reads of the same file are redacted. + filesRead.Add(filePath); + } + } + } + } + } + } + } + + return compacted; + } + + /// + /// Compacts the history in-place via LLM summarization. Returns true if compaction was performed. + /// + public async Task TryCompactAsync( + List history, + CancellationToken ct = default) + { + // Need at least: system + some conversation to compact + if (history.Count < 5) + return false; + + // Split: system prompt (index 0) | middle (compactable) | tail (keep verbatim) + var systemMsg = history[0]; + + // Find the cut point: keep the last N user+assistant pairs + int keepFromIndex = FindKeepIndex(history); + if (keepFromIndex <= 1) + return false; // Nothing to compact + + // Extract the middle section to summarize + var toSummarize = history.Skip(1).Take(keepFromIndex - 1).ToList(); + var tail = history.Skip(keepFromIndex).ToList(); + + // Build a summarization prompt + var summaryMessages = new List + { + new(ChatRole.System, """ + You are a conversation summarizer. Summarize the following coding conversation + between a user and an AI assistant. Focus on: + - What files were read, created, or modified (and their paths) + - What changes were made and why + - Any decisions or preferences expressed by the user + - Current state of the work (what's done, what's pending) + Be concise but preserve all actionable context. Output a single summary paragraph. + Do NOT include any hashline anchors (lineNumber:hash|) in your summary. + """) + }; + summaryMessages.AddRange(toSummarize); + summaryMessages.Add(new(ChatRole.User, + "Summarize the above conversation concisely, preserving all important context for continuing the work.")); + + string summary; + try + { + var response = await _client.GetResponseAsync(summaryMessages, cancellationToken: ct); + summary = response.Text ?? "(summary unavailable)"; + } + catch + { + // If summarization fails, don't compact + return false; + } + + // Rebuild history: system + summary + recent turns + history.Clear(); + history.Add(systemMsg); + history.Add(new(ChatRole.User, + "[CONTEXT COMPACTED — The following is a summary of the earlier conversation]\n" + summary)); + history.Add(new(ChatRole.Assistant, + "Understood, I have the context from our earlier conversation. I'll continue from where we left off.")); + history.AddRange(tail); + + return true; + } + + /// + /// Finds the index from which to keep messages verbatim (the last N turn pairs). + /// + private static int FindKeepIndex(List history) + { + int pairsFound = 0; + int idx = history.Count - 1; + + while (idx > 0 && pairsFound < KeepRecentTurns) + { + // Walk backwards looking for user+assistant pairs + if (history[idx].Role == ChatRole.Assistant && idx > 1 && + history[idx - 1].Role == ChatRole.User) + { + pairsFound++; + idx -= 2; + } + else + { + idx--; + } + } + + // idx+1 is the first message to keep + return Math.Max(1, idx + 1); + } + + [GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex MyRegex(); +} diff --git a/OpenRouter/TokenTracker.cs b/OpenRouter/TokenTracker.cs index 7ab75b2..eaf1925 100644 --- a/OpenRouter/TokenTracker.cs +++ b/OpenRouter/TokenTracker.cs @@ -34,6 +34,14 @@ internal sealed class TokenTracker LastInputTokens = inputTokens; RequestCount++; } + public void Reset() + { + SessionInputTokens = 0; + SessionOutputTokens = 0; + RequestCount = 0; + LastInputTokens = 0; + } + private const int MaxContextReserve = 150_000; diff --git a/Program.cs b/Program.cs index a5ddf8b..e16067b 100644 --- a/Program.cs +++ b/Program.cs @@ -1,136 +1,139 @@ -using System.ClientModel; -using Microsoft.Extensions.AI; -using OpenAI; -using AnchorCli; -using AnchorCli.Tools; -using AnchorCli.Commands; -using AnchorCli.OpenRouter; -using Spectre.Console; - -// ── Setup subcommand ───────────────────────────────────────────────────── -if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) -{ - SetupTui.Run(); - return; -} - -// ── Config ────────────────────────────────────────────────────────────── -const string endpoint = "https://openrouter.ai/api/v1"; -var cfg = AnchorConfig.Load(); -string apiKey = cfg.ApiKey; -string model = cfg.Model; - -if (string.IsNullOrWhiteSpace(apiKey)) -{ - AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); - return; -} - -// ── Fetch model pricing from OpenRouter ───────────────────────────────── -var pricingProvider = new PricingProvider(); -var tokenTracker = new TokenTracker(); - -ModelInfo? modelInfo = null; -await AnsiConsole.Status() - .Spinner(Spinner.Known.BouncingBar) - .SpinnerStyle(Style.Parse("cornflowerblue")) - .StartAsync("Fetching model pricing...", async ctx => - { - try - { - modelInfo = await pricingProvider.GetModelInfoAsync(model); - if (modelInfo?.Pricing != null) - { - tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt); - tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion); - tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request); - } - } - catch { /* pricing is best-effort */ } - }); - -// ── Pretty header ─────────────────────────────────────────────────────── -AnsiConsole.Write( - new FigletText("anchor") - .Color(Color.CornflowerBlue)); - -AnsiConsole.Write( - new Rule("[dim]AI-powered coding assistant[/]") - .RuleStyle(Style.Parse("cornflowerblue dim")) - .LeftJustified()); - -AnsiConsole.WriteLine(); - -var infoTable = new Table() - .Border(TableBorder.Rounded) - .BorderColor(Color.Grey) - .AddColumn(new TableColumn("[dim]Setting[/]").NoWrap()) - .AddColumn(new TableColumn("[dim]Value[/]")); - -infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]"); -infoTable.AddRow("[grey]Endpoint[/]", $"[blue]OpenRouter[/]"); -infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]"); - -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[/]", - $"[dim]{modelInfo.ContextLength:N0} tokens[/]"); -} - -AnsiConsole.Write(infoTable); -AnsiConsole.WriteLine(); - -// ── Build the chat client with tool-calling support ───────────────────── -var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions -{ - Endpoint = new Uri(endpoint) -}); - -IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient(); - -// ── Tool call logging via Spectre ─────────────────────────────────────── -object consoleLock = new object(); - -void ToolLog(string message) -{ - lock (consoleLock) - { - Console.Write("\r" + new string(' ', 40) + "\r"); - AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]"); - } -} - -CommandTool.Log = -DirTools.Log = -FileTools.Log = -EditTools.Log = ToolLog; - -// ── Instantiate Core Components ────────────────────────────────────────── - -var session = new ChatSession(innerClient); -if (modelInfo != null) -{ - tokenTracker.ContextLength = modelInfo.ContextLength; -} - -var commandRegistry = new CommandRegistry(); -commandRegistry.Register(new ExitCommand()); -commandRegistry.Register(new HelpCommand(commandRegistry)); -commandRegistry.Register(new ClearCommand()); -commandRegistry.Register(new StatusCommand(model, endpoint)); -commandRegistry.Register(new CompactCommand(session.Compactor, session.History)); -commandRegistry.Register(new SetupCommand()); - -var commandDispatcher = new CommandDispatcher(commandRegistry); - -// ── Run Repl ──────────────────────────────────────────────────────────── - -var repl = new ReplLoop(session, tokenTracker, commandDispatcher); -await repl.RunAsync(); +using System.ClientModel; +using Microsoft.Extensions.AI; +using OpenAI; +using AnchorCli; +using AnchorCli.Tools; +using AnchorCli.Commands; +using AnchorCli.OpenRouter; +using Spectre.Console; + +// ── Setup subcommand ───────────────────────────────────────────────────── +if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) +{ + SetupTui.Run(); + return; +} + +// ── Config ────────────────────────────────────────────────────────────── +const string endpoint = "https://openrouter.ai/api/v1"; +var cfg = AnchorConfig.Load(); +string apiKey = cfg.ApiKey; +string model = cfg.Model; + +if (string.IsNullOrWhiteSpace(apiKey)) +{ + AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); + return; +} + +// ── Fetch model pricing from OpenRouter ───────────────────────────────── +var pricingProvider = new PricingProvider(); +var tokenTracker = new TokenTracker(); + +ModelInfo? modelInfo = null; +await AnsiConsole.Status() + .Spinner(Spinner.Known.BouncingBar) + .SpinnerStyle(Style.Parse("cornflowerblue")) + .StartAsync("Fetching model pricing...", async ctx => + { + try + { + modelInfo = await pricingProvider.GetModelInfoAsync(model); + if (modelInfo?.Pricing != null) + { + tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt); + tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion); + tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request); + } + } + catch { /* pricing is best-effort */ } + }); + +// ── Pretty header ─────────────────────────────────────────────────────── +AnsiConsole.Write( + new FigletText("anchor") + .Color(Color.CornflowerBlue)); + +AnsiConsole.Write( + new Rule("[dim]AI-powered coding assistant[/]") + .RuleStyle(Style.Parse("cornflowerblue dim")) + .LeftJustified()); + +AnsiConsole.WriteLine(); + +var infoTable = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Grey) + .AddColumn(new TableColumn("[dim]Setting[/]").NoWrap()) + .AddColumn(new TableColumn("[dim]Value[/]")); + +infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]"); +infoTable.AddRow("[grey]Endpoint[/]", $"[blue]OpenRouter[/]"); +infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]"); + +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[/]", + $"[dim]{modelInfo.ContextLength:N0} tokens[/]"); +} + +AnsiConsole.Write(infoTable); +AnsiConsole.WriteLine(); + +// ── Build the chat client with tool-calling support ───────────────────── +var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions +{ + Endpoint = new Uri(endpoint) +}); + +IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient(); + +// ── Tool call logging via Spectre ─────────────────────────────────────── +object consoleLock = new object(); + +void ToolLog(string message) +{ + lock (consoleLock) + { + Console.Write("\r" + new string(' ', 40) + "\r"); + AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]"); + } +} + +CommandTool.Log = +DirTools.Log = +FileTools.Log = +EditTools.Log = ToolLog; + +// ── Instantiate Core Components ────────────────────────────────────────── + +var session = new ChatSession(innerClient); +if (modelInfo != null) +{ + tokenTracker.ContextLength = modelInfo.ContextLength; +} + +var commandRegistry = new CommandRegistry(); +commandRegistry.Register(new ExitCommand()); +commandRegistry.Register(new HelpCommand(commandRegistry)); +commandRegistry.Register(new ClearCommand()); +commandRegistry.Register(new StatusCommand(model, endpoint)); +commandRegistry.Register(new CompactCommand(session.Compactor, session.History)); +commandRegistry.Register(new SetupCommand()); +commandRegistry.Register(new ResetCommand(session, tokenTracker)); + + +var commandDispatcher = new CommandDispatcher(commandRegistry); + +// ── Run Repl ──────────────────────────────────────────────────────────── + +var repl = new ReplLoop(session, tokenTracker, commandDispatcher); +await repl.RunAsync(); + diff --git a/README.md b/README.md index b051aae..6216248 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This eliminates: ## Features - **Interactive REPL**: Chat with an AI model to edit files, manage directories, and execute commands -- **Slash Commands**: `/setup`, `/help`, `/exit`, `/clear`, `/status`, `/compact` +- **Slash Commands**: `/setup`, `/help`, `/exit`, `/clear`, `/status`, `/compact`, `/reset` - **Token Tracking**: Real-time token usage and cost per response, plus session totals - **Model Pricing Display**: Shows current model pricing from OpenRouter in the header - **Context Compaction**: Automatic conversation history compression when approaching context limits, including stale tool result compaction @@ -69,7 +69,7 @@ The resulting binary is ~12 MB, has no .NET runtime dependency, and starts insta | `/clear` | Clear the conversation history | | `/status` | Show session token usage and cost | | `/compact` | Manually trigger context compaction | - +| `/reset` | Clear session and reset token tracker | ## Available Tools **File Operations:** @@ -98,31 +98,37 @@ The resulting binary is ~12 MB, has no .NET runtime dependency, and starts insta **Command Execution:** - `execute_command` - Run shell commands (with user approval) -## Project Structure - ``` AnchorCli/ -├── Program.cs # Entry point + REPL loop + AI client setup -├── AnchorConfig.cs # JSON file-based configuration (~APPDATA~\anchor\config.json) -├── ContextCompactor.cs # Conversation history compression -├── AppJsonContext.cs # Source-generated JSON context (AOT) +├── Program.cs # Entry point + CLI parsing +├── ReplLoop.cs # Main REPL loop with streaming, spinners, and cancellation +├── ChatSession.cs # AI chat client wrapper with message history +├── ToolRegistry.cs # Centralized tool registration and dispatch +├── AnchorConfig.cs # JSON file-based configuration (~APPDATA~/anchor/config.json) +├── ContextCompactor.cs # Conversation history compression +├── AppJsonContext.cs # Source-generated JSON context (AOT) +├── SetupTui.cs # Interactive setup TUI ├── Hashline/ -│ ├── HashlineEncoder.cs # Adler-8 + position-seed hashing +│ ├── HashlineEncoder.cs # Adler-8 + position-seed hashing │ └── HashlineValidator.cs # Anchor resolution + validation ├── Tools/ -│ ├── FileTools.cs # read_file, grep_file, grep_recursive, find_files, get_file_info -│ ├── EditTools.cs # replace_lines, insert_after, delete_range, create/delete/rename/copy/append -│ ├── DirTools.cs # list_dir, create_dir, rename_dir, delete_dir -│ └── CommandTool.cs # execute_command +│ ├── FileTools.cs # read_file, grep_file, grep_recursive, find_files, get_file_info +│ ├── EditTools.cs # replace_lines, insert_after, delete_range, create/delete/rename/copy/append +│ ├── DirTools.cs # list_dir, create_dir, rename_dir, delete_dir +│ └── CommandTool.cs # execute_command ├── Commands/ -│ ├── ExitCommand.cs # /exit command -│ ├── HelpCommand.cs # /help command -│ ├── ClearCommand.cs # /clear command -│ ├── StatusCommand.cs # /status command -│ └── CompactCommand.cs # /compact command -├── OpenRouter/ -│ └── PricingProvider.cs # Fetch model pricing from OpenRouter -└── SetupTui.cs # Interactive setup TUI +│ ├── ICommand.cs # Command interface +│ ├── CommandRegistry.cs # Command registration +│ ├── CommandDispatcher.cs # Command dispatch logic +│ ├── ExitCommand.cs # /exit command +│ ├── HelpCommand.cs # /help command +│ ├── ClearCommand.cs # /clear command +│ ├── StatusCommand.cs # /status command +│ ├── CompactCommand.cs # /compact command +│ ├── ResetCommand.cs # /reset command +│ └── SetupCommand.cs # /setup command +└── OpenRouter/ + └── PricingProvider.cs # Fetch model pricing from OpenRouter ``` ## How It Works diff --git a/ReplLoop.cs b/ReplLoop.cs index 3c8e217..f4d0b94 100644 --- a/ReplLoop.cs +++ b/ReplLoop.cs @@ -64,37 +64,44 @@ internal sealed class ReplLoop if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw && raw.Usage != null) { - respIn += raw.Usage.InputTokenCount; - respOut += raw.Usage.OutputTokenCount; + respIn = raw.Usage.InputTokenCount; // last call = actual context size + respOut += raw.Usage.OutputTokenCount; // additive — each round generates new output } } object consoleLock = new(); using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token); bool showSpinner = true; - - CommandTool.PauseSpinner = () => - { + + CommandTool.PauseSpinner = () => + { lock (consoleLock) { - showSpinner = false; - Console.Write("\r" + new string(' ', 40) + "\r"); + showSpinner = false; + Console.Write("\r" + new string(' ', 40) + "\r"); } }; - CommandTool.ResumeSpinner = () => - { + CommandTool.ResumeSpinner = () => + { lock (consoleLock) { - showSpinner = true; + showSpinner = true; } }; + FileTools.OnFileRead = _ => + { + int n = ContextCompactor.CompactStaleToolResults(_session.History); + if (n > 0) + AnsiConsole.MarkupLine( + $"[dim grey] ♻ Compacted {n} stale tool result(s)[/]"); + }; var spinnerTask = Task.Run(async () => { var frames = Spinner.Known.BouncingBar.Frames; var interval = Spinner.Known.BouncingBar.Interval; int i = 0; - + Console.Write("\x1b[?25l"); try { @@ -143,6 +150,7 @@ internal sealed class ReplLoop await Task.WhenAny(spinnerTask); CommandTool.PauseSpinner = null; CommandTool.ResumeSpinner = null; + FileTools.OnFileRead = null; } if (firstChunk != null) @@ -184,13 +192,6 @@ internal sealed class ReplLoop _session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse)); - int compactedResults = ContextCompactor.CompactStaleToolResults(_session.History); - if (compactedResults > 0) - { - AnsiConsole.MarkupLine( - $"[dim grey] ♻ Compacted {compactedResults} stale tool result(s) from previous turns[/]"); - } - if (_tokenTracker.ShouldCompact()) { var pct = _tokenTracker.ContextUsagePercent; diff --git a/SANDBOX.md b/SANDBOX.md new file mode 100644 index 0000000..232baca --- /dev/null +++ b/SANDBOX.md @@ -0,0 +1,334 @@ +# Sandbox Implementation Plan for AnchorCli + +## Overview + +By default, all file and directory operations are restricted to the current working directory (CWD). +Users can bypass this restriction with the `--no-sandbox` flag. + +## Usage + +```bash +# Default: sandbox enabled (operations limited to CWD) +anchor + +# Disable sandbox (allow operations anywhere) +anchor --no-sandbox +``` + +## Architecture + +The implementation leverages the existing `ResolvePath()` methods in `FileTools` and `DirTools`. +Since tools are static classes without dependency injection, we use a static `SandboxContext` class. + +--- + +## Implementation Steps + +### Step 1: Create `SandboxContext.cs` + +Create a new file `Core/SandboxContext.cs`: + +```csharp +using System; + +namespace AnchorCli; + +/// +/// Static context holding sandbox configuration. +/// Checked by ResolvePath() to validate paths are within working directory. +/// +internal static class SandboxContext +{ + private static string? _workingDirectory; + private static bool _enabled = true; + + public static bool Enabled + { + get => _enabled; + set => _enabled = value; + } + + public static string WorkingDirectory + { + get => _workingDirectory ?? Environment.CurrentDirectory; + set => _workingDirectory = value; + } + + /// + /// Validates that a resolved path is within the working directory (if sandbox is enabled). + /// Returns the resolved path if valid, or null if outside sandbox (no exception thrown). + /// When null is returned, the calling tool should return an error message to the agent. + /// + public static string? ValidatePath(string resolvedPath) + { + if (!_enabled) + return resolvedPath; + + var workDir = WorkingDirectory; + + // Normalize paths for comparison + var normalizedPath = Path.GetFullPath(resolvedPath).TrimEnd(Path.DirectorySeparatorChar); + var normalizedWorkDir = Path.GetFullPath(workDir).TrimEnd(Path.DirectorySeparatorChar); + + // Check if path starts with working directory + if (!normalizedPath.StartsWith(normalizedWorkDir, StringComparison.OrdinalIgnoreCase)) + { + // Return null to signal violation - caller handles error messaging + return null; + } + + return resolvedPath; + } + + public static void Initialize(bool sandboxEnabled) + { + _enabled = sandboxEnabled; + _workingDirectory = Environment.CurrentDirectory; + } +} +``` + +--- + +### Step 2: Modify `Program.cs` + +Add argument parsing and initialize the sandbox context: + +**After line 15** (after the `setup` subcommand check), add: + +```csharp +// ── Parse sandbox flag ────────────────────────────────────────────────── +bool sandboxEnabled = !args.Contains("--no-sandbox"); +SandboxContext.Initialize(sandboxEnabled); + +if (!sandboxEnabled) +{ + AnsiConsole.MarkupLine("[dim grey]Sandbox disabled (--no-sandbox)[/]"); +} +``` + +--- + +### Step 3: Update `FileTools.ResolvePath()` + +**Replace lines 322-323** with: + + internal static string? ResolvePath(string path, out string? errorMessage) + { + errorMessage = null; + var resolved = Path.IsPathRooted(path) + ? path + : Path.GetFullPath(path, Environment.CurrentDirectory); + + var validated = SandboxContext.ValidatePath(resolved); + if (validated == null) + { + errorMessage = $"Sandbox violation: Path '{path}' is outside working directory '{SandboxContext.WorkingDirectory}'. Use --no-sandbox to disable restrictions."; + return null; + } + + return validated; + } + +--- + +### Step 4: Update `DirTools.ResolvePath()` + +**Replace lines 84-85** with: + +```csharp + internal static string? ResolvePath(string path, out string? errorMessage) + { + errorMessage = null; + var resolved = Path.IsPathRooted(path) + ? path + : Path.GetFullPath(path, Environment.CurrentDirectory); + + var validated = SandboxContext.ValidatePath(resolved); + if (validated == null) + { + errorMessage = $"Sandbox violation: Path '{path}' is outside working directory '{SandboxContext.WorkingDirectory}'. Use --no-sandbox to disable restrictions."; + return null; + } + + return validated; + } + +--- + +### Step 5: Update Tool Descriptions (Optional but Recommended) + +Update the `[Description]` attributes to mention sandbox behavior: + +**FileTools.cs - ReadFile** (line 23): +```csharp +[Description("Read a file. Max 200 lines per call. Returns lines with line:hash| anchors. Sandbox: restricted to working directory unless --no-sandbox is used. IMPORTANT: Call GrepFile first...")] +``` + +**DirTools.cs - CreateDir** (line 63): +```csharp +[Description("Create a new directory. Creates parent directories if they don't exist. Sandbox: restricted to working directory unless --no-sandbox is used. Returns OK on success...")] +``` + +Repeat for other tools as needed. + +--- + +## How Tools Handle Sandbox Violations + +Each tool that uses `ResolvePath()` must check for `null` return and handle it gracefully: + +### FileTools Pattern + +```csharp +// Before (old code): +var resolvedPath = ResolvePath(path); +var content = File.ReadAllText(resolvedPath); + +// After (new code): +var resolvedPath = ResolvePath(path, out var errorMessage); +if (resolvedPath == null) + return $"ERROR: {errorMessage}"; + +var content = File.ReadAllText(resolvedPath); +``` + +### DirTools Pattern + +```csharp +// Before (old code): +var resolvedPath = ResolvePath(path); +Directory.CreateDirectory(resolvedPath); + +// After (new code): +var resolvedPath = ResolvePath(path, out var errorMessage); +if (resolvedPath == null) + return $"ERROR: {errorMessage}"; + +Directory.CreateDirectory(resolvedPath); +return "OK"; +``` + +### EditTools + +No changes needed - it already calls `FileTools.ResolvePath()`, so the sandbox check happens there. + +### Tools That Don't Use ResolvePath + +- `ListDir` with no path argument (uses current directory) +- `GetFileInfo` - needs to be updated to use `ResolvePath()` +- `FindFiles` - needs to be updated to validate the search path + +--- +--- + +## Error Handling - No Crashes + +When a sandbox violation occurs, the program **does not crash**. Instead: + +1. `ResolvePath()` returns `null` and sets `errorMessage` +2. The tool returns the error message to the agent +3. The agent sees the error and can continue the conversation +4. The user sees a clear error message in the chat + +**Example tool implementation pattern:** + +```csharp +public static async Task ReadFile(string path, int startLine, int endLine) +{ + var resolvedPath = ResolvePath(path, out var errorMessage); + if (resolvedPath == null) + return $"ERROR: {errorMessage}"; // Return error, don't throw + + // ... rest of the tool logic +} +``` + +**What the agent sees:** +``` +Tool result: ERROR: Sandbox violation: Path '/home/tomi/.ssh' is outside working directory '/home/tomi/dev/anchor'. Use --no-sandbox to disable restrictions. +``` + +**What the user sees in chat:** +> The agent tried to read `/home/tomi/.ssh` but was blocked by the sandbox. The agent can now adjust its approach or ask you to run with `--no-sandbox`. + +--- + +## Edge Cases Handled + +| Case | Behavior | +|------|----------| +| **Symlinks inside CWD pointing outside** | Follows symlink (user-created link = intentional) | +| **Path traversal (`../..`)** | Blocked if result is outside CWD | +| **Absolute paths** | Validated against CWD | +| **Network paths** | Blocked (not under CWD) | +| **Case sensitivity** | Uses `OrdinalIgnoreCase` for cross-platform compatibility | + +--- + +## Security Notes + +⚠️ **The sandbox is a safety feature, not a security boundary.** + +- It prevents **accidental** modifications to system files +- It does **not** protect against malicious intent +- `CommandTool.ExecuteCommand()` can still run arbitrary shell commands +- A determined user can always use `--no-sandbox` + +For true isolation, run anchor in a container or VM. + +--- + +## Testing Checklist + +- [ ] `ReadFile` on file inside CWD → **Success** +- [ ] `ReadFile` on file outside CWD → **Sandbox violation error** +- [ ] `ReadFile` with `../` traversal outside CWD → **Sandbox violation error** +- [ ] `CreateDir` outside CWD → **Sandbox violation error** +- [ ] `anchor --no-sandbox` then read `/etc/passwd` → **Success** +- [ ] Symlink inside CWD pointing to `/etc/passwd` → **Success** (user-created link) +- [ ] Case variations on Windows (`C:\Users` vs `c:\users`) → **Success** + +--- + +## Migration Guide + +### Existing Workflows + +If you have scripts or workflows that rely on accessing files outside the project: + +```bash +# Update your scripts to use --no-sandbox +anchor --no-sandbox +``` + +### CI/CD Integration + +For CI environments where sandbox may not be needed: + +```yaml +# GitHub Actions example +- name: Run anchor + run: anchor --no-sandbox +``` + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `Core/SandboxContext.cs` | **New file** - Static sandbox state and validation | +| `Program.cs` | Add `--no-sandbox` parsing, call `SandboxContext.Initialize()` | +| `Tools/FileTools.cs` | Update `ResolvePath()` signature to return `string?` with `out errorMessage`; update all tool methods to check for null | +| `Tools/DirTools.cs` | Update `ResolvePath()` signature to return `string?` with `out errorMessage`; update all tool methods to check for null | +| `Tools/EditTools.cs` | No changes (uses `FileTools.ResolvePath()`, sandbox check happens there) | +| `Tools/CommandTool.cs` | **Not sandboxed** - shell commands can access any path (documented limitation) | +--- + +## Future Enhancements + +- **Allowlist**: Let users specify additional safe directories via config +- **Per-tool sandbox**: Some tools (e.g., `GrepRecursive`) could have different rules +- **Audit mode**: Log all file operations for review +- **Interactive prompt**: Ask for confirmation before violating sandbox instead of hard fail diff --git a/Tools/FileTools.cs b/Tools/FileTools.cs index 4e7559e..7a7d9de 100644 --- a/Tools/FileTools.cs +++ b/Tools/FileTools.cs @@ -14,7 +14,13 @@ internal static class FileTools { public static Action Log { get; set; } = Console.WriteLine; - [Description("Read a file. Max 200 lines per call. Returns lines with line:hash| anchors.")] + /// + /// Optional callback invoked after each successful ReadFile call, with the resolved path. + /// Set by ReplLoop to trigger deduplication compaction while the tool loop is still active. + /// + public static Action? OnFileRead { get; set; } + + [Description("Read a file. Max 200 lines per call. Returns lines with line:hash| anchors. IMPORTANT: Call GrepFile first (pattern: 'public|class|func|interface|enum|def') to get a structural outline and target startLine/endLine before calling this.")] public static string ReadFile( [Description("Path to the file.")] string path, [Description("First line to return (inclusive). Defaults to 1.")] int startLine = 1, @@ -40,7 +46,9 @@ internal static class FileTools return $"ERROR: File too large to read at once ({lines.Length} lines). Provide startLine and endLine to read a chunk of max 200 lines. Use GrepFile to get an outline (grep 'public') and find the line numbers."; } - return HashlineEncoder.Encode(lines, startLine, endLine); + string result = HashlineEncoder.Encode(lines, startLine, endLine); + OnFileRead?.Invoke(path); + return result; } catch (Exception ex) {