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)
{
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<ChatResponseUpdate> 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;

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 System.Text.RegularExpressions;
using Spectre.Console;
namespace AnchorCli;
/// <summary>
/// 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].
/// </summary>
internal sealed partial class ContextCompactor(IChatClient client)
{
/// <summary>Number of recent user+assistant turn pairs to keep verbatim.</summary>
private const int KeepRecentTurns = 2;
/// <summary>Minimum result length to consider for compaction.</summary>
private const int MinResultLength = 300;
/// <summary>Matches hashline-encoded output: "lineNumber:hash|content"</summary>
private static readonly Regex HashlinePattern =
MyRegex();
private readonly IChatClient _client = client;
/// <summary>
/// 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).
/// </summary>
/// <param name="history">The chat history to compact in-place.</param>
/// <param name="currentTurnStartIndex">
/// Index of the first message added during the current turn.
/// Messages before this index are from previous turns and eligible for compaction.
/// </param>
/// <returns>Number of tool results that were compacted.</returns>
public static int CompactStaleToolResults(List<ChatMessage> history)
{
int compacted = 0;
int userTurnsSeen = 0;
var filesRead = new HashSet<string>(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<FunctionCallContent>().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;
}
/// <summary>
/// Compacts the history in-place via LLM summarization. Returns true if compaction was performed.
/// </summary>
public async Task<bool> TryCompactAsync(
List<ChatMessage> 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<ChatMessage>
{
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;
}
/// <summary>
/// Finds the index from which to keep messages verbatim (the last N turn pairs).
/// </summary>
private static int FindKeepIndex(List<ChatMessage> 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;
/// <summary>
/// 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].
/// </summary>
internal sealed partial class ContextCompactor(IChatClient client)
{
/// <summary>Number of recent user+assistant turn pairs to keep verbatim.</summary>
private const int KeepRecentTurns = 2;
/// <summary>Minimum result length to consider for compaction.</summary>
private const int MinResultLength = 300;
/// <summary>Matches hashline-encoded output: "lineNumber:hash|content"</summary>
private static readonly Regex HashlinePattern =
MyRegex();
private readonly IChatClient _client = client;
/// <summary>
/// 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).
/// </summary>
/// <param name="history">The chat history to compact in-place.</param>
/// <returns>Number of tool results that were compacted.</returns>
public static int CompactStaleToolResults(List<ChatMessage> history)
{
int compacted = 0;
int userTurnsSeen = 0;
var filesRead = new HashSet<string>(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<FunctionCallContent>().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;
}
/// <summary>
/// Compacts the history in-place via LLM summarization. Returns true if compaction was performed.
/// </summary>
public async Task<bool> TryCompactAsync(
List<ChatMessage> 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<ChatMessage>
{
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;
}
/// <summary>
/// Finds the index from which to keep messages verbatim (the last N turn pairs).
/// </summary>
private static int FindKeepIndex(List<ChatMessage> 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();
}

View File

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

View File

@@ -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();

View File

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

View File

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

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;
[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(
[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)
{