1
0

feat: Introduce a /reset command to clear the chat session and token tracking, and update documentation.

This commit is contained in:
2026-03-04 22:24:05 +01:00
parent d7a94436d1
commit ed897aeb01
9 changed files with 777 additions and 387 deletions

View File

@@ -11,7 +11,7 @@ internal sealed class ChatSession
public ChatSession(IChatClient innerClient) public ChatSession(IChatClient innerClient)
{ {
Compactor = new ContextCompactor(innerClient); Compactor = new ContextCompactor(innerClient);
var tools = ToolRegistry.GetTools(); var tools = ToolRegistry.GetTools();
_agent = new ChatClientBuilder(innerClient) _agent = new ChatClientBuilder(innerClient)
.UseFunctionInvocation() .UseFunctionInvocation()
@@ -34,23 +34,36 @@ internal sealed class ChatSession
Never include the "lineNumber:hash|" prefix in content you write it will corrupt the file. Never include the "lineNumber:hash|" prefix in content you write it will corrupt the file.
## Workflow ## Workflow
1. Always read a file before editing it. 1. ALWAYS call GrepFile before ReadFile on any source file.
2. After a mutation, verify the returned fingerprint. 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. 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. 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}}
""") """)
}; };
} }
public void Reset()
{
// Keep only the system message
var systemMessage = History[0];
History.Clear();
History.Add(systemMessage);
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync( public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{ {
var options = new ChatOptions { Tools = ToolRegistry.GetTools() }; var options = new ChatOptions { Tools = ToolRegistry.GetTools() };
var stream = _agent.GetStreamingResponseAsync(History, options, cancellationToken); var stream = _agent.GetStreamingResponseAsync(History, options, cancellationToken);
await foreach (var update in stream.WithCancellation(cancellationToken)) await foreach (var update in stream.WithCancellation(cancellationToken))
{ {
yield return update; yield return update;

21
Commands/ResetCommand.cs Normal file
View File

@@ -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;
}
}

View File

@@ -1,205 +1,201 @@
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Spectre.Console; using Spectre.Console;
namespace AnchorCli; namespace AnchorCli;
/// <summary> /// <summary>
/// Compacts the chat history when context usage grows too large. /// Compacts the chat history when context usage grows too large.
/// Asks the same model to summarize the conversation, then replaces /// Asks the same model to summarize the conversation, then replaces
/// the history with [system prompt, summary, last N user/assistant turns]. /// the history with [system prompt, summary, last N user/assistant turns].
/// </summary> /// </summary>
internal sealed partial class ContextCompactor(IChatClient client) internal sealed partial class ContextCompactor(IChatClient client)
{ {
/// <summary>Number of recent user+assistant turn pairs to keep verbatim.</summary> /// <summary>Number of recent user+assistant turn pairs to keep verbatim.</summary>
private const int KeepRecentTurns = 2; private const int KeepRecentTurns = 2;
/// <summary>Minimum result length to consider for compaction.</summary> /// <summary>Minimum result length to consider for compaction.</summary>
private const int MinResultLength = 300; private const int MinResultLength = 300;
/// <summary>Matches hashline-encoded output: "lineNumber:hash|content"</summary> /// <summary>Matches hashline-encoded output: "lineNumber:hash|content"</summary>
private static readonly Regex HashlinePattern = private static readonly Regex HashlinePattern =
MyRegex(); MyRegex();
private readonly IChatClient _client = client; private readonly IChatClient _client = client;
/// <summary> /// <summary>
/// Compacts large hashline-encoded tool results from previous turns. /// Compacts large hashline-encoded tool results from previous turns.
/// This is the cheapest and most impactful optimization — no LLM call needed. /// 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). /// A 300-line ReadFile result (~10K tokens) becomes a one-line note (~20 tokens).
/// </summary> /// </summary>
/// <param name="history">The chat history to compact in-place.</param> /// <param name="history">The chat history to compact in-place.</param>
/// <param name="currentTurnStartIndex"> /// <returns>Number of tool results that were compacted.</returns>
/// Index of the first message added during the current turn. public static int CompactStaleToolResults(List<ChatMessage> history)
/// Messages before this index are from previous turns and eligible for compaction. {
/// </param> int compacted = 0;
/// <returns>Number of tool results that were compacted.</returns> int userTurnsSeen = 0;
public static int CompactStaleToolResults(List<ChatMessage> history) var filesRead = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
{
int compacted = 0; // Walk backwards: index 0 is system prompt, so we stop at 1.
int userTurnsSeen = 0; for (int i = history.Count - 1; i >= 1; i--)
var filesRead = new HashSet<string>(StringComparer.OrdinalIgnoreCase); {
var msg = history[i];
// Walk backwards: index 0 is system prompt, so we stop at 1.
for (int i = history.Count - 1; i >= 1; i--) if (msg.Role == ChatRole.User)
{ {
var msg = history[i]; userTurnsSeen++;
}
if (msg.Role == ChatRole.User) else if (msg.Role == ChatRole.Assistant)
{ {
userTurnsSeen++; // Find all FunctionCalls in this assistant message to map call IDs to file paths
} var calls = msg.Contents.OfType<FunctionCallContent>().ToList();
else if (msg.Role == ChatRole.Assistant)
{ // If the very next message is a System/Tool role containing FunctionResults
// Find all FunctionCalls in this assistant message to map call IDs to file paths if (i + 1 < history.Count && history[i + 1].Role == ChatRole.Tool)
var calls = msg.Contents.OfType<FunctionCallContent>().ToList(); {
var resultMsg = history[i + 1];
// If the very next message is a System/Tool role containing FunctionResults foreach (var content in resultMsg.Contents)
if (i + 1 < history.Count && history[i + 1].Role == ChatRole.Tool) {
{ if (content is FunctionResultContent frc &&
var resultMsg = history[i + 1]; frc.Result is string resultStr &&
foreach (var content in resultMsg.Contents) resultStr.Length >= MinResultLength &&
{ HashlinePattern.IsMatch(resultStr))
if (content is FunctionResultContent frc && {
frc.Result is string resultStr && // Find the corresponding function call to check its name/arguments
resultStr.Length >= MinResultLength && var call = calls.FirstOrDefault(c => c.CallId == frc.CallId);
HashlinePattern.IsMatch(resultStr)) if (call?.Name == "ReadFile" && call.Arguments != null && call.Arguments.TryGetValue("path", out var pathObj))
{ {
// Find the corresponding function call to check its name/arguments string filePath = pathObj?.ToString() ?? "";
var call = calls.FirstOrDefault(c => c.CallId == frc.CallId);
if (call?.Name == "ReadFile" && call.Arguments != null && call.Arguments.TryGetValue("path", out var pathObj)) bool shouldRedact = false;
{ string reason = "";
string filePath = pathObj?.ToString() ?? "";
// Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one.
bool shouldRedact = false; if (filesRead.Contains(filePath))
string reason = ""; {
shouldRedact = true;
// Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one. reason = "deduplication — you read this file again later";
if (filesRead.Contains(filePath)) }
{ // Rule 2: TTL. If this was read 2 or more user turns ago, redact it.
shouldRedact = true; else if (userTurnsSeen >= 2)
reason = "deduplication — you read this file again later"; {
} shouldRedact = true;
// Rule 2: TTL. If this was read 2 or more user turns ago, redact it. reason = "expired — read over 2 turns ago";
else if (userTurnsSeen >= 2) }
{
shouldRedact = true; if (shouldRedact)
reason = "expired — read over 2 turns ago"; {
} 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.]";
if (shouldRedact) compacted++;
{ }
int lineCount = resultStr.Count(c => c == '\n'); else
frc.Result = $"[File content: {lineCount} lines redacted for {reason}. Re-read the file if you need fresh anchors.]"; {
compacted++; // Keep it, but mark that we've seen it so older reads of the same file are redacted.
} filesRead.Add(filePath);
else }
{ }
// Keep it, but mark that we've seen it so older reads of the same file are redacted. }
filesRead.Add(filePath); }
} }
} }
} }
}
} return compacted;
} }
}
/// <summary>
return compacted; /// Compacts the history in-place via LLM summarization. Returns true if compaction was performed.
} /// </summary>
public async Task<bool> TryCompactAsync(
/// <summary> List<ChatMessage> history,
/// Compacts the history in-place via LLM summarization. Returns true if compaction was performed. CancellationToken ct = default)
/// </summary> {
public async Task<bool> TryCompactAsync( // Need at least: system + some conversation to compact
List<ChatMessage> history, if (history.Count < 5)
CancellationToken ct = default) return false;
{
// Need at least: system + some conversation to compact // Split: system prompt (index 0) | middle (compactable) | tail (keep verbatim)
if (history.Count < 5) var systemMsg = history[0];
return false;
// Find the cut point: keep the last N user+assistant pairs
// Split: system prompt (index 0) | middle (compactable) | tail (keep verbatim) int keepFromIndex = FindKeepIndex(history);
var systemMsg = history[0]; if (keepFromIndex <= 1)
return false; // Nothing to compact
// Find the cut point: keep the last N user+assistant pairs
int keepFromIndex = FindKeepIndex(history); // Extract the middle section to summarize
if (keepFromIndex <= 1) var toSummarize = history.Skip(1).Take(keepFromIndex - 1).ToList();
return false; // Nothing to compact var tail = history.Skip(keepFromIndex).ToList();
// Extract the middle section to summarize // Build a summarization prompt
var toSummarize = history.Skip(1).Take(keepFromIndex - 1).ToList(); var summaryMessages = new List<ChatMessage>
var tail = history.Skip(keepFromIndex).ToList(); {
new(ChatRole.System, """
// Build a summarization prompt You are a conversation summarizer. Summarize the following coding conversation
var summaryMessages = new List<ChatMessage> between a user and an AI assistant. Focus on:
{ - What files were read, created, or modified (and their paths)
new(ChatRole.System, """ - What changes were made and why
You are a conversation summarizer. Summarize the following coding conversation - Any decisions or preferences expressed by the user
between a user and an AI assistant. Focus on: - Current state of the work (what's done, what's pending)
- What files were read, created, or modified (and their paths) Be concise but preserve all actionable context. Output a single summary paragraph.
- What changes were made and why Do NOT include any hashline anchors (lineNumber:hash|) in your summary.
- 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. summaryMessages.AddRange(toSummarize);
Do NOT include any hashline anchors (lineNumber:hash|) in your summary. summaryMessages.Add(new(ChatRole.User,
""") "Summarize the above conversation concisely, preserving all important context for continuing the work."));
};
summaryMessages.AddRange(toSummarize); string summary;
summaryMessages.Add(new(ChatRole.User, try
"Summarize the above conversation concisely, preserving all important context for continuing the work.")); {
var response = await _client.GetResponseAsync(summaryMessages, cancellationToken: ct);
string summary; summary = response.Text ?? "(summary unavailable)";
try }
{ catch
var response = await _client.GetResponseAsync(summaryMessages, cancellationToken: ct); {
summary = response.Text ?? "(summary unavailable)"; // If summarization fails, don't compact
} return false;
catch }
{
// If summarization fails, don't compact // Rebuild history: system + summary + recent turns
return false; history.Clear();
} history.Add(systemMsg);
history.Add(new(ChatRole.User,
// Rebuild history: system + summary + recent turns "[CONTEXT COMPACTED — The following is a summary of the earlier conversation]\n" + summary));
history.Clear(); history.Add(new(ChatRole.Assistant,
history.Add(systemMsg); "Understood, I have the context from our earlier conversation. I'll continue from where we left off."));
history.Add(new(ChatRole.User, history.AddRange(tail);
"[CONTEXT COMPACTED — The following is a summary of the earlier conversation]\n" + summary));
history.Add(new(ChatRole.Assistant, return true;
"Understood, I have the context from our earlier conversation. I'll continue from where we left off.")); }
history.AddRange(tail);
/// <summary>
return true; /// Finds the index from which to keep messages verbatim (the last N turn pairs).
} /// </summary>
private static int FindKeepIndex(List<ChatMessage> history)
/// <summary> {
/// Finds the index from which to keep messages verbatim (the last N turn pairs). int pairsFound = 0;
/// </summary> int idx = history.Count - 1;
private static int FindKeepIndex(List<ChatMessage> history)
{ while (idx > 0 && pairsFound < KeepRecentTurns)
int pairsFound = 0; {
int idx = history.Count - 1; // Walk backwards looking for user+assistant pairs
if (history[idx].Role == ChatRole.Assistant && idx > 1 &&
while (idx > 0 && pairsFound < KeepRecentTurns) history[idx - 1].Role == ChatRole.User)
{ {
// Walk backwards looking for user+assistant pairs pairsFound++;
if (history[idx].Role == ChatRole.Assistant && idx > 1 && idx -= 2;
history[idx - 1].Role == ChatRole.User) }
{ else
pairsFound++; {
idx -= 2; idx--;
} }
else }
{
idx--; // idx+1 is the first message to keep
} return Math.Max(1, idx + 1);
} }
// idx+1 is the first message to keep [GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Multiline | RegexOptions.Compiled)]
return Math.Max(1, idx + 1); private static partial Regex MyRegex();
} }
[GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Multiline | RegexOptions.Compiled)]
private static partial Regex MyRegex();
}

View File

@@ -34,6 +34,14 @@ internal sealed class TokenTracker
LastInputTokens = inputTokens; LastInputTokens = inputTokens;
RequestCount++; RequestCount++;
} }
public void Reset()
{
SessionInputTokens = 0;
SessionOutputTokens = 0;
RequestCount = 0;
LastInputTokens = 0;
}
private const int MaxContextReserve = 150_000; private const int MaxContextReserve = 150_000;

View File

@@ -1,136 +1,139 @@
using System.ClientModel; using System.ClientModel;
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using OpenAI; using OpenAI;
using AnchorCli; using AnchorCli;
using AnchorCli.Tools; using AnchorCli.Tools;
using AnchorCli.Commands; using AnchorCli.Commands;
using AnchorCli.OpenRouter; using AnchorCli.OpenRouter;
using Spectre.Console; using Spectre.Console;
// ── Setup subcommand ───────────────────────────────────────────────────── // ── Setup subcommand ─────────────────────────────────────────────────────
if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
{ {
SetupTui.Run(); SetupTui.Run();
return; return;
} }
// ── Config ────────────────────────────────────────────────────────────── // ── Config ──────────────────────────────────────────────────────────────
const string endpoint = "https://openrouter.ai/api/v1"; const string endpoint = "https://openrouter.ai/api/v1";
var cfg = AnchorConfig.Load(); var cfg = AnchorConfig.Load();
string apiKey = cfg.ApiKey; string apiKey = cfg.ApiKey;
string model = cfg.Model; string model = cfg.Model;
if (string.IsNullOrWhiteSpace(apiKey)) if (string.IsNullOrWhiteSpace(apiKey))
{ {
AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]");
return; return;
} }
// ── Fetch model pricing from OpenRouter ───────────────────────────────── // ── Fetch model pricing from OpenRouter ─────────────────────────────────
var pricingProvider = new PricingProvider(); var pricingProvider = new PricingProvider();
var tokenTracker = new TokenTracker(); var tokenTracker = new TokenTracker();
ModelInfo? modelInfo = null; ModelInfo? modelInfo = null;
await AnsiConsole.Status() await AnsiConsole.Status()
.Spinner(Spinner.Known.BouncingBar) .Spinner(Spinner.Known.BouncingBar)
.SpinnerStyle(Style.Parse("cornflowerblue")) .SpinnerStyle(Style.Parse("cornflowerblue"))
.StartAsync("Fetching model pricing...", async ctx => .StartAsync("Fetching model pricing...", async ctx =>
{ {
try try
{ {
modelInfo = await pricingProvider.GetModelInfoAsync(model); modelInfo = await pricingProvider.GetModelInfoAsync(model);
if (modelInfo?.Pricing != null) if (modelInfo?.Pricing != null)
{ {
tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt); tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt);
tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion); tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion);
tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request); tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request);
} }
} }
catch { /* pricing is best-effort */ } catch { /* pricing is best-effort */ }
}); });
// ── Pretty header ─────────────────────────────────────────────────────── // ── Pretty header ───────────────────────────────────────────────────────
AnsiConsole.Write( AnsiConsole.Write(
new FigletText("anchor") new FigletText("anchor")
.Color(Color.CornflowerBlue)); .Color(Color.CornflowerBlue));
AnsiConsole.Write( AnsiConsole.Write(
new Rule("[dim]AI-powered coding assistant[/]") new Rule("[dim]AI-powered coding assistant[/]")
.RuleStyle(Style.Parse("cornflowerblue dim")) .RuleStyle(Style.Parse("cornflowerblue dim"))
.LeftJustified()); .LeftJustified());
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
var infoTable = new Table() var infoTable = new Table()
.Border(TableBorder.Rounded) .Border(TableBorder.Rounded)
.BorderColor(Color.Grey) .BorderColor(Color.Grey)
.AddColumn(new TableColumn("[dim]Setting[/]").NoWrap()) .AddColumn(new TableColumn("[dim]Setting[/]").NoWrap())
.AddColumn(new TableColumn("[dim]Value[/]")); .AddColumn(new TableColumn("[dim]Value[/]"));
infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]"); infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]");
infoTable.AddRow("[grey]Endpoint[/]", $"[blue]OpenRouter[/]"); infoTable.AddRow("[grey]Endpoint[/]", $"[blue]OpenRouter[/]");
infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]"); infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]");
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[/]",
$"[dim]{modelInfo.ContextLength:N0} tokens[/]"); $"[dim]{modelInfo.ContextLength:N0} tokens[/]");
} }
AnsiConsole.Write(infoTable); AnsiConsole.Write(infoTable);
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
// ── Build the chat client with tool-calling support ───────────────────── // ── Build the chat client with tool-calling support ─────────────────────
var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions
{ {
Endpoint = new Uri(endpoint) Endpoint = new Uri(endpoint)
}); });
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 object();
void ToolLog(string message) void ToolLog(string message)
{ {
lock (consoleLock) lock (consoleLock)
{ {
Console.Write("\r" + new string(' ', 40) + "\r"); Console.Write("\r" + new string(' ', 40) + "\r");
AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]"); AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]");
} }
} }
CommandTool.Log = CommandTool.Log =
DirTools.Log = DirTools.Log =
FileTools.Log = FileTools.Log =
EditTools.Log = ToolLog; EditTools.Log = ToolLog;
// ── Instantiate Core Components ────────────────────────────────────────── // ── Instantiate Core Components ──────────────────────────────────────────
var session = new ChatSession(innerClient); var session = new ChatSession(innerClient);
if (modelInfo != null) if (modelInfo != null)
{ {
tokenTracker.ContextLength = modelInfo.ContextLength; tokenTracker.ContextLength = modelInfo.ContextLength;
} }
var commandRegistry = new CommandRegistry(); var commandRegistry = new CommandRegistry();
commandRegistry.Register(new ExitCommand()); commandRegistry.Register(new ExitCommand());
commandRegistry.Register(new HelpCommand(commandRegistry)); commandRegistry.Register(new HelpCommand(commandRegistry));
commandRegistry.Register(new ClearCommand()); commandRegistry.Register(new ClearCommand());
commandRegistry.Register(new StatusCommand(model, endpoint)); 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));
var commandDispatcher = new CommandDispatcher(commandRegistry);
// ── Run Repl ──────────────────────────────────────────────────────────── var commandDispatcher = new CommandDispatcher(commandRegistry);
var repl = new ReplLoop(session, tokenTracker, commandDispatcher); // ── Run Repl ────────────────────────────────────────────────────────────
await repl.RunAsync();
var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
await repl.RunAsync();

View File

@@ -22,7 +22,7 @@ This eliminates:
## Features ## Features
- **Interactive REPL**: Chat with an AI model to edit files, manage directories, and execute commands - **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 - **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 - **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 - **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 | | `/clear` | Clear the conversation history |
| `/status` | Show session token usage and cost | | `/status` | Show session token usage and cost |
| `/compact` | Manually trigger context compaction | | `/compact` | Manually trigger context compaction |
| `/reset` | Clear session and reset token tracker |
## Available Tools ## Available Tools
**File Operations:** **File Operations:**
@@ -98,31 +98,37 @@ The resulting binary is ~12 MB, has no .NET runtime dependency, and starts insta
**Command Execution:** **Command Execution:**
- `execute_command` - Run shell commands (with user approval) - `execute_command` - Run shell commands (with user approval)
## Project Structure
``` ```
AnchorCli/ AnchorCli/
├── Program.cs # Entry point + REPL loop + AI client setup ├── Program.cs # Entry point + CLI parsing
├── AnchorConfig.cs # JSON file-based configuration (~APPDATA~\anchor\config.json) ├── ReplLoop.cs # Main REPL loop with streaming, spinners, and cancellation
├── ContextCompactor.cs # Conversation history compression ├── ChatSession.cs # AI chat client wrapper with message history
├── AppJsonContext.cs # Source-generated JSON context (AOT) ├── 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/ ├── Hashline/
│ ├── HashlineEncoder.cs # Adler-8 + position-seed hashing │ ├── HashlineEncoder.cs # Adler-8 + position-seed hashing
│ └── HashlineValidator.cs # Anchor resolution + validation │ └── HashlineValidator.cs # Anchor resolution + validation
├── Tools/ ├── Tools/
│ ├── FileTools.cs # read_file, grep_file, grep_recursive, find_files, get_file_info │ ├── 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 │ ├── EditTools.cs # replace_lines, insert_after, delete_range, create/delete/rename/copy/append
│ ├── DirTools.cs # list_dir, create_dir, rename_dir, delete_dir │ ├── DirTools.cs # list_dir, create_dir, rename_dir, delete_dir
│ └── CommandTool.cs # execute_command │ └── CommandTool.cs # execute_command
├── Commands/ ├── Commands/
│ ├── ExitCommand.cs # /exit command │ ├── ICommand.cs # Command interface
│ ├── HelpCommand.cs # /help command │ ├── CommandRegistry.cs # Command registration
│ ├── ClearCommand.cs # /clear command │ ├── CommandDispatcher.cs # Command dispatch logic
│ ├── StatusCommand.cs # /status command │ ├── ExitCommand.cs # /exit command
── CompactCommand.cs # /compact command ── HelpCommand.cs # /help command
├── OpenRouter/ │ ├── ClearCommand.cs # /clear command
── PricingProvider.cs # Fetch model pricing from OpenRouter ── StatusCommand.cs # /status command
└── SetupTui.cs # Interactive setup TUI │ ├── CompactCommand.cs # /compact command
│ ├── ResetCommand.cs # /reset command
│ └── SetupCommand.cs # /setup command
└── OpenRouter/
└── PricingProvider.cs # Fetch model pricing from OpenRouter
``` ```
## How It Works ## How It Works

View File

@@ -64,37 +64,44 @@ internal sealed class ReplLoop
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
&& raw.Usage != null) && raw.Usage != null)
{ {
respIn += raw.Usage.InputTokenCount; respIn = raw.Usage.InputTokenCount; // last call = actual context size
respOut += raw.Usage.OutputTokenCount; respOut += raw.Usage.OutputTokenCount; // additive — each round generates new output
} }
} }
object consoleLock = new(); object consoleLock = new();
using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token); using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token);
bool showSpinner = true; bool showSpinner = true;
CommandTool.PauseSpinner = () => CommandTool.PauseSpinner = () =>
{ {
lock (consoleLock) lock (consoleLock)
{ {
showSpinner = false; showSpinner = false;
Console.Write("\r" + new string(' ', 40) + "\r"); Console.Write("\r" + new string(' ', 40) + "\r");
} }
}; };
CommandTool.ResumeSpinner = () => CommandTool.ResumeSpinner = () =>
{ {
lock (consoleLock) 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 spinnerTask = Task.Run(async () =>
{ {
var frames = Spinner.Known.BouncingBar.Frames; var frames = Spinner.Known.BouncingBar.Frames;
var interval = Spinner.Known.BouncingBar.Interval; var interval = Spinner.Known.BouncingBar.Interval;
int i = 0; int i = 0;
Console.Write("\x1b[?25l"); Console.Write("\x1b[?25l");
try try
{ {
@@ -143,6 +150,7 @@ internal sealed class ReplLoop
await Task.WhenAny(spinnerTask); await Task.WhenAny(spinnerTask);
CommandTool.PauseSpinner = null; CommandTool.PauseSpinner = null;
CommandTool.ResumeSpinner = null; CommandTool.ResumeSpinner = null;
FileTools.OnFileRead = null;
} }
if (firstChunk != null) if (firstChunk != null)
@@ -184,13 +192,6 @@ internal sealed class ReplLoop
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse)); _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()) if (_tokenTracker.ShouldCompact())
{ {
var pct = _tokenTracker.ContextUsagePercent; var pct = _tokenTracker.ContextUsagePercent;

334
SANDBOX.md Normal file
View File

@@ -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;
/// <summary>
/// Static context holding sandbox configuration.
/// Checked by ResolvePath() to validate paths are within working directory.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<string> 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

View File

@@ -14,7 +14,13 @@ internal static class FileTools
{ {
public static Action<string> Log { get; set; } = Console.WriteLine; public static Action<string> Log { get; set; } = Console.WriteLine;
[Description("Read a file. Max 200 lines per call. Returns lines with line:hash| anchors.")] /// <summary>
/// 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.
/// </summary>
public static Action<string>? 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( public static string ReadFile(
[Description("Path to the file.")] string path, [Description("Path to the file.")] string path,
[Description("First line to return (inclusive). Defaults to 1.")] int startLine = 1, [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 $"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) catch (Exception ex)
{ {