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

@@ -34,16 +34,29 @@ 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)

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

@@ -29,10 +29,6 @@ internal sealed partial class ContextCompactor(IChatClient client)
/// 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">
/// 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> /// <returns>Number of tool results that were compacted.</returns>
public static int CompactStaleToolResults(List<ChatMessage> history) public static int CompactStaleToolResults(List<ChatMessage> history)
{ {

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

@@ -127,6 +127,8 @@ 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); var commandDispatcher = new CommandDispatcher(commandRegistry);
@@ -134,3 +136,4 @@ var commandDispatcher = new CommandDispatcher(commandRegistry);
var repl = new ReplLoop(session, tokenTracker, commandDispatcher); var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
await repl.RunAsync(); 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,8 +64,8 @@ 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
} }
} }
@@ -88,6 +88,13 @@ internal sealed class ReplLoop
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 () =>
{ {
@@ -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)
{ {