1
0

Compare commits

..

26 Commits

Author SHA1 Message Date
3f187348d7 update gitignore 2026-03-16 16:18:33 +01:00
fe006a5256 feat: make cursor visible on exit 2026-03-12 00:49:12 +01:00
ef30a2254b chore: update .gitignore 2026-03-12 00:04:34 +01:00
1e943e6566 refactor: apply Single Responsibility Principle to Program.cs and ReplLoop.cs
extracted responsibilities from Program.cs (208→46 lines) and ReplLoop.cs (274→174 lines) into focused service classes: HeaderRenderer, SessionManager, ApplicationStartup, ResponseStreamer, SpinnerService, UsageDisplayer, and ContextCompactionService. Each class now has a single, well-defined responsibility, improving testability and maintainability.
2026-03-11 16:59:06 +01:00
ccfa7e1b9d chore: Remove obsolete planning and documentation files. 2026-03-11 14:28:37 +01:00
c9515a822d feat: add new input processors (readline), add new figlet font 2026-03-11 14:19:57 +01:00
75bbdda37d feat: enable UTF-8 encoding for console, and fix tool log formatting 2026-03-11 14:03:17 +01:00
46d32c43ba feat: update tool logs to be more coherent 2026-03-11 13:56:00 +01:00
35c8840ed4 refactor: Use collection expressions and add explicit names for AI tool registration. 2026-03-11 13:48:53 +01:00
e2ab10813c Refactor README to condense content, move detailed sections to external documentation files, and update slash commands. 2026-03-06 23:44:18 +01:00
acc04af4bc docs: restructure README, move detailed content to docs/ directory 2026-03-06 23:42:55 +01:00
977d772229 feat: decreased deduplication amount of read results from 5 to 3 2026-03-06 08:40:11 +01:00
a776d978ea feat: saving token data to sessions 2026-03-06 08:32:37 +01:00
91a44bb2a4 feat: add version string to startup and status menu 2026-03-06 08:13:39 +01:00
f687360c2b feat: document new batch editing feature in README.md 2026-03-06 03:22:08 +01:00
4fbbde32e3 feat: add EditTools.cs to implement file manipulation functions like replace_lines, delete_range, and batch_edit, updating README.md to reflect new tools and command descriptions. 2026-03-06 03:20:48 +01:00
8f2c72b3c5 docs: add batch_edit tool and load/save commands to README 2026-03-06 03:07:56 +01:00
829ba7a7f2 feat: Update chat session instructions to recommend batch editing for multiple operations. 2026-03-06 03:04:34 +01:00
8b48b0f866 feat: Add file editing tools with replace, delete, move, and write operations, featuring hashline anchor validation and input sanitization. 2026-03-06 02:48:43 +01:00
82ef63c731 feat: Introduce robust hashline anchor validation and new editing tools. 2026-03-06 02:35:46 +01:00
119e623f5a I need the actual code changes (diff) to generate an accurate and informative commit message. 2026-03-06 01:42:08 +01:00
e98cd3b19c feat: Add commands and functionality to save and load chat sessions. 2026-03-06 01:38:55 +01:00
50414e8b8c feat: introduce FileTools for LLM-accessible file system operations including read, list, find, and grep. 2026-03-06 01:23:19 +01:00
003345edc0 feat: consolidate file write, move, grep, and delete operations into unified tools and update context compaction heuristics 2026-03-06 01:14:56 +01:00
7a6e9785d6 feat: Introduce EditTools with file manipulation functions, replacing RenameFile and CopyFile with MoveFile in ToolRegistry. 2026-03-06 00:52:24 +01:00
1af1665839 feat: Add file editing and manipulation tools with Hashline anchor validation and integrate them into the tool registry. 2026-03-06 00:52:10 +01:00
37 changed files with 2439 additions and 2947 deletions

6
.gitignore vendored
View File

@@ -1,4 +1,8 @@
bin bin
obj obj
.vscode .vscode
publish publish
.anchor
.idea
.vs
.crush

View File

@@ -19,6 +19,10 @@
<PublishAot>false</PublishAot> <PublishAot>false</PublishAot>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Assets\3d.flf" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" /> <PackageReference Include="Microsoft.Extensions.AI" Version="10.3.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" /> <PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />

View File

@@ -1,23 +1,29 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using AnchorCli.OpenRouter; using AnchorCli.OpenRouter;
using AnchorCli.Tools;
namespace AnchorCli;
namespace AnchorCli;
/// <summary>
/// Source-generated JSON serializer context for Native AOT compatibility. /// <summary>
/// Covers all parameter / return types used by AIFunction tool methods /// Source-generated JSON serializer context for Native AOT compatibility.
/// and the OpenRouter models API. /// Covers all parameter / return types used by AIFunction tool methods
/// </summary> /// and the OpenRouter models API.
[JsonSerializable(typeof(string))] /// </summary>
[JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(string))]
[JsonSerializable(typeof(int))] [JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(bool))] [JsonSerializable(typeof(int))]
[JsonSerializable(typeof(string[][]))] [JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(ModelsResponse))] [JsonSerializable(typeof(string[][]))]
[JsonSerializable(typeof(ModelInfo))] [JsonSerializable(typeof(ModelsResponse))]
[JsonSerializable(typeof(ModelPricing))] [JsonSerializable(typeof(ModelInfo))]
[JsonSerializable(typeof(AnchorConfig))] [JsonSerializable(typeof(ModelPricing))]
[JsonSourceGenerationOptions( [JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))]
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, [JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))]
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] [JsonSerializable(typeof(AnchorConfig))]
internal partial class AppJsonContext : JsonSerializerContext; [JsonSerializable(typeof(BatchOperation))]
[JsonSerializable(typeof(BatchOperation[]))]
[JsonSerializable(typeof(TokenMetadata))]
[JsonSourceGenerationOptions(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class AppJsonContext : JsonSerializerContext;

176
ApplicationStartup.cs Normal file
View File

@@ -0,0 +1,176 @@
using System.ClientModel;
using AnchorCli.Commands;
using AnchorCli.OpenRouter;
using AnchorCli.Providers;
using AnchorCli.Tools;
using Microsoft.Extensions.AI;
using OpenAI;
using Spectre.Console;
namespace AnchorCli;
/// <summary>
/// Encapsulates application startup logic, including configuration loading,
/// API client creation, and component initialization.
/// </summary>
internal sealed class ApplicationStartup
{
private readonly string[] _args;
private AnchorConfig? _config;
private ITokenExtractor? _tokenExtractor;
private ModelInfo? _modelInfo;
private IChatClient? _chatClient;
private TokenTracker? _tokenTracker;
public ApplicationStartup(string[] args)
{
_args = args;
}
public AnchorConfig Config => _config ?? throw new InvalidOperationException("Run InitializeAsync first");
public string ApiKey => _config?.ApiKey ?? throw new InvalidOperationException("API key not loaded");
public string Model => _config?.Model ?? throw new InvalidOperationException("Model not loaded");
public string Endpoint => _config?.Endpoint ?? "https://openrouter.ai/api/v1";
public string ProviderName => _tokenExtractor?.ProviderName ?? "Unknown";
public ITokenExtractor TokenExtractor => _tokenExtractor ?? throw new InvalidOperationException("Token extractor not initialized");
public ModelInfo? ModelInfo => _modelInfo;
public IChatClient ChatClient => _chatClient ?? throw new InvalidOperationException("Chat client not initialized");
public TokenTracker TokenTracker => _tokenTracker ?? throw new InvalidOperationException("Token tracker not initialized");
/// <summary>
/// Runs the setup TUI if the "setup" subcommand was passed. Returns true if setup was run.
/// </summary>
public bool HandleSetupSubcommand()
{
if (_args.Length > 0 && _args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
{
SetupTui.Run();
return true;
}
return false;
}
/// <summary>
/// Initializes the application by loading configuration and creating the chat client.
/// </summary>
public async Task InitializeAsync()
{
// Load configuration
_config = AnchorConfig.Load();
if (string.IsNullOrWhiteSpace(_config.ApiKey))
{
AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]");
throw new InvalidOperationException("API key not configured");
}
// Create token extractor
_tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(Endpoint);
// Fetch model pricing (only for OpenRouter)
if (ProviderFactory.IsOpenRouter(Endpoint))
{
await AnsiConsole.Status()
.Spinner(Spinner.Known.BouncingBar)
.SpinnerStyle(Style.Parse("cornflowerblue"))
.StartAsync("Fetching model pricing...", async ctx =>
{
try
{
var pricingProvider = new OpenRouterProvider();
_modelInfo = await pricingProvider.GetModelInfoAsync(Model);
}
catch
{
// Pricing is best-effort
}
});
}
// Create chat client
var httpClient = new HttpClient();
OpenRouterHeaders.ApplyTo(httpClient);
var openAiClient = new OpenAIClient(
new ApiKeyCredential(ApiKey),
new OpenAIClientOptions
{
Endpoint = new Uri(Endpoint),
Transport = new System.ClientModel.Primitives.HttpClientPipelineTransport(httpClient)
});
_chatClient = openAiClient.GetChatClient(Model).AsIChatClient();
// Initialize token tracker
_tokenTracker = new TokenTracker(new ChatSession(_chatClient))
{
Provider = _tokenExtractor.ProviderName
};
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);
}
if (_modelInfo != null)
{
_tokenTracker.ContextLength = _modelInfo.ContextLength;
}
}
/// <summary>
/// Creates a new ChatSession with the initialized chat client.
/// </summary>
public ChatSession CreateSession()
{
return new ChatSession(ChatClient);
}
/// <summary>
/// Configures tool logging to use Spectre.Console.
/// </summary>
public void ConfigureToolLogging()
{
object consoleLock = new();
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;
}
/// <summary>
/// Creates and populates a CommandRegistry with all available commands.
/// </summary>
public CommandRegistry CreateCommandRegistry(ChatSession session)
{
var registry = new CommandRegistry();
registry.Register(new ExitCommand());
registry.Register(new HelpCommand(registry));
registry.Register(new ClearCommand());
registry.Register(new StatusCommand(Model, Endpoint));
registry.Register(new CompactCommand(session.Compactor, session.History));
registry.Register(new SetupCommand());
registry.Register(new ResetCommand(session, TokenTracker));
return registry;
}
/// <summary>
/// Creates a HeaderRenderer with the current configuration.
/// </summary>
public HeaderRenderer CreateHeaderRenderer()
{
return new HeaderRenderer(Model, Endpoint, ProviderName, _modelInfo, _tokenTracker);
}
}

818
Assets/3d.flf Normal file
View File

@@ -0,0 +1,818 @@
flf2a$ 8 8 20 -1 1
3d font created by xero <x@xero.nu>
$$@
$$@
$$@
$$@
$$@
$$@
$$@
$$@@
██@
░██@
░██@
░██@
░██@
░░ @
██@
░░ @@
█ █@
░█ ░█@
░ ░ @
@
@
@
@
@@
@
██ ██ @
████████████@
░░░██░░░░██░ @
░██ ░██ @
████████████@
░░░██░░░░██░ @
░░ ░░ @@
█ @
█████@
░█░█░ @
░█████@
░░░█░█@
█████@
░░░█░ @
░ @@
@
██ ██ @
░░ ██ @
██ @
██ @
██ @
██ ██ @
░░ ░░ @@
██ @
█░ █ @
░ ██ @
█░ █ █@
█ ░ █ @
░█ ░█ @
░ ████ █@
░░░░ ░ @@
██@
░░█@
░ @
@
@
@
@
@@
██@
██ @
██ @
░██ @
░██ @
░░██ @
░░██@
░░ @@
██ @
░░██ @
░░██@
░██@
░██@
██ @
██ @
░░ @@
██ @
██ ░██ ██ @
░░██ ░██ ██ @
██████████████@
░░░██░░██░░██░ @
██ ░██ ░░██ @
░░ ░██ ░░ @
░░ @@
@
█ @
░█ @
█████████@
░░░░░█░░░ @
░█ @
░ @
@@
@
@
@
@
@
██@
░░█@
░ @@
@
@
@
█████@
░░░░░ @
@
@
@@
@
@
@
@
@
██@
░██@
░░ @@
██@
██ @
██ @
██ @
██ @
██ @
██ @
░░ @@
████ @
█░░░██@
░█ █░█@
░█ █ ░█@
░██ ░█@
░█ ░█@
░ ████ @
░░░░ @@
██ @
███ @
░░██ @
░██ @
░██ @
░██ @
████@
░░░░ @@
████ @
█░░░ █@
░ ░█@
███ @
█░░ @
█ @
░██████@
░░░░░░ @@
████ @
█░░░ █@
░ ░█@
███ @
░░░ █@
█ ░█@
░ ████ @
░░░░ @@
██ @
█░█ @
█ ░█ @
██████@
░░░░░█ @
░█ @
░█ @
░ @@
██████@
░█░░░░ @
░█████ @
░░░░░ █@
░█@
█ ░█@
░ ████ @
░░░░ @@
████ @
█░░░ █@
░█ ░ @
░█████ @
░█░░░ █@
░█ ░█@
░ ████ @
░░░░ @@
██████@
░░░░░░█@
░█@
█ @
█ @
█ @
█ @
░ @@
████ @
█░░░ █@
░█ ░█@
░ ████ @
█░░░ █@
░█ ░█@
░ ████ @
░░░░ @@
████ @
█░░░ █@
░█ ░█@
░ ████ @
░░░█ @
█ @
█ @
░ @@
@
@
@
@
██@
░░ @
██@
░░ @@
@
@
@
██@
░░ @
██@
░░█@
░ @@
██@
██░ @
██░ @
██░ @
░░ ██ @
░░ ██ @
░░ ██@
░░ @@
@
@
██████@
░░░░░░ @
██████@
░░░░░░ @
@
@@
██ @
░░ ██ @
░░ ██ @
░░ ██@
██░ @
██░ @
██░ @
░░ @@
████ @
██░░██@
░██ ░██@
░░ ██ @
██ @
░░ @
██ @
░░ @@
████ @
█░░░ █@
░█ ██░█@
░█░█ ░█@
░█░ ██ @
░█ ░░ @
░ █████@
░░░░░ @@
██ @
████ @
██░░██ @
██ ░░██ @
██████████@
░██░░░░░░██@
░██ ░██@
░░ ░░ @@
██████ @
░█░░░░██ @
░█ ░██ @
░██████ @
░█░░░░ ██@
░█ ░██@
░███████ @
░░░░░░░ @@
██████ @
██░░░░██@
██ ░░ @
░██ @
░██ @
░░██ ██@
░░██████ @
░░░░░░ @@
███████ @
░██░░░░██ @
░██ ░██@
░██ ░██@
░██ ░██@
░██ ██ @
░███████ @
░░░░░░░ @@
████████@
░██░░░░░ @
░██ @
░███████ @
░██░░░░ @
░██ @
░████████@
░░░░░░░░ @@
████████@
░██░░░░░ @
░██ @
░███████ @
░██░░░░ @
░██ @
░██ @
░░ @@
████████ @
██░░░░░░██@
██ ░░ @
░██ @
░██ █████@
░░██ ░░░░██@
░░████████ @
░░░░░░░░ @@
██ ██@
░██ ░██@
░██ ░██@
░██████████@
░██░░░░░░██@
░██ ░██@
░██ ░██@
░░ ░░ @@
██@
░██@
░██@
░██@
░██@
░██@
░██@
░░ @@
██@
░██@
░██@
░██@
░██@
██ ░██@
░░█████ @
░░░░░ @@
██ ██@
░██ ██ @
░██ ██ @
░████ @
░██░██ @
░██░░██ @
░██ ░░██@
░░ ░░ @@
██ @
░██ @
░██ @
░██ @
░██ @
░██ @
░████████@
░░░░░░░░ @@
████ ████@
░██░██ ██░██@
░██░░██ ██ ░██@
░██ ░░███ ░██@
░██ ░░█ ░██@
░██ ░ ░██@
░██ ░██@
░░ ░░ @@
████ ██@
░██░██ ░██@
░██░░██ ░██@
░██ ░░██ ░██@
░██ ░░██░██@
░██ ░░████@
░██ ░░███@
░░ ░░░ @@
███████ @
██░░░░░██ @
██ ░░██@
░██ ░██@
░██ ░██@
░░██ ██ @
░░███████ @
░░░░░░░ @@
███████ @
░██░░░░██@
░██ ░██@
░███████ @
░██░░░░ @
░██ @
░██ @
░░ @@
███████ @
██░░░░░██ @
██ ░░██ @
░██ ░██ @
░██ ██░██ @
░░██ ░░ ██ @
░░███████ ██@
░░░░░░░ ░░ @@
███████ @
░██░░░░██ @
░██ ░██ @
░███████ @
░██░░░██ @
░██ ░░██ @
░██ ░░██@
░░ ░░ @@
████████@
██░░░░░░ @
░██ @
░█████████@
░░░░░░░░██@
░██@
████████ @
░░░░░░░░ @@
██████████@
░░░░░██░░░ @
░██ @
░██ @
░██ @
░██ @
░██ @
░░ @@
██ ██@
░██ ░██@
░██ ░██@
░██ ░██@
░██ ░██@
░██ ░██@
░░███████ @
░░░░░░░ @@
██ ██@
░██ ░██@
░██ ░██@
░░██ ██ @
░░██ ██ @
░░████ @
░░██ @
░░ @@
██ ██@
░██ ░██@
░██ █ ░██@
░██ ███ ░██@
░██ ██░██░██@
░████ ░░████@
░██░ ░░░██@
░░ ░░ @@
██ ██@
░░██ ██ @
░░██ ██ @
░░███ @
██░██ @
██ ░░██ @
██ ░░██@
░░ ░░ @@
██ ██@
░░██ ██ @
░░████ @
░░██ @
░██ @
░██ @
░██ @
░░ @@
████████@
░░░░░░██ @
██ @
██ @
██ @
██ @
████████@
░░░░░░░░ @@
█████@
░██░░ @
░██ @
░██ @
░██ @
░██ @
░█████@
░░░░░ @@
██ @
░░██ @
░░██ @
░░██ @
░░██ @
░░██ @
░░██@
░░ @@
█████@
░░░░██@
░██@
░██@
░██@
░██@
█████@
░░░░░ @@
██ @
██░ ██ @
██ ░░ ██@
░░ ░░ @
@
@
@
@@
@
@
@
@
@
@
█████@
░░░░░ @@
██@
░█ @
░ @
@
@
@
@
@@
@
@
██████ @
░░░░░░██ @
███████ @
██░░░░██ @
░░████████@
░░░░░░░░ @@
██ @
░██ @
░██ @
░██████ @
░██░░░██@
░██ ░██@
░██████ @
░░░░░ @@
@
@
█████ @
██░░░██@
░██ ░░ @
░██ ██@
░░█████ @
░░░░░ @@
██@
░██@
░██@
██████@
██░░░██@
░██ ░██@
░░██████@
░░░░░░ @@
@
@
█████ @
██░░░██@
░███████@
░██░░░░ @
░░██████@
░░░░░░ @@
████@
░██░ @
██████@
░░░██░ @
░██ @
░██ @
░██ @
░░ @@
@
█████ @
██░░░██@
░██ ░██@
░░██████@
░░░░░██@
█████ @
░░░░░ @@
██ @
░██ @
░██ @
░██████ @
░██░░░██@
░██ ░██@
░██ ░██@
░░ ░░ @@
██@
░░ @
██@
░██@
░██@
░██@
░██@
░░ @@
██@
░░ @
██@
░██@
░██@
██░██@
░░███ @
░░░ @@
██ @
░██ @
░██ ██@
░██ ██ @
░████ @
░██░██ @
░██░░██@
░░ ░░ @@
██@
░██@
░██@
░██@
░██@
░██@
███@
░░░ @@
@
@
██████████ @
░░██░░██░░██@
░██ ░██ ░██@
░██ ░██ ░██@
███ ░██ ░██@
░░░ ░░ ░░ @@
@
@
███████ @
░░██░░░██@
░██ ░██@
░██ ░██@
███ ░██@
░░░ ░░ @@
@
@
██████ @
██░░░░██@
░██ ░██@
░██ ░██@
░░██████ @
░░░░░░ @@
@
██████ @
░██░░░██@
░██ ░██@
░██████ @
░██░░░ @
░██ @
░░ @@
@
████ @
██░░██ @
░██ ░██ @
░░█████ @
░░░░██ @
░███@
░░░ @@
@
@
██████@
░░██░░█@
░██ ░ @
░██ @
░███ @
░░░ @@
@
@
██████@
██░░░░ @
░░█████ @
░░░░░██@
██████ @
░░░░░░ @@
██ @
░██ @
██████@
░░░██░ @
░██ @
░██ @
░░██ @
░░ @@
@
@
██ ██@
░██ ░██@
░██ ░██@
░██ ░██@
░░██████@
░░░░░░ @@
@
@
██ ██@
░██ ░██@
░░██ ░██ @
░░████ @
░░██ @
░░ @@
@
@
███ ██@
░░██ █ ░██@
░██ ███░██@
░████░████@
███░ ░░░██@
░░░ ░░░ @@
@
@
██ ██@
░░██ ██ @
░░███ @
██░██ @
██ ░░██@
░░ ░░ @@
@
██ ██@
░░██ ██ @
░░███ @
░██ @
██ @
██ @
░░ @@
@
@
██████@
░░░░██ @
██ @
██ @
██████@
░░░░░░ @@
███@
██░ @
░██ @
███ @
░░░██ @
░██ @
░░███@
░░░ @@
█@
░█@
░█@
░ @
█@
░█@
░█@
░ @@
███ @
░░░██ @
░██ @
░░███@
██░ @
░██ @
███ @
░░░ @@
██ ███ @
░░███░░██@
░░░ ░░ @
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@
@
@
@
@
@
@
@
@@

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using System.Text.Json;
namespace AnchorCli; namespace AnchorCli;
@@ -7,6 +8,11 @@ internal sealed class ChatSession
private readonly IChatClient _agent; private readonly IChatClient _agent;
public ContextCompactor Compactor { get; } public ContextCompactor Compactor { get; }
public List<ChatMessage> History { get; } public List<ChatMessage> History { get; }
// Token tracking state persisted across sessions
public long SessionInputTokens { get; set; }
public long SessionOutputTokens { get; set; }
public int RequestCount { get; set; }
public ChatSession(IChatClient innerClient) public ChatSession(IChatClient innerClient)
{ {
@@ -43,6 +49,7 @@ internal sealed class ChatSession
2. After reading, edit the file before verifying the returned fingerprint. 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 relevant range to get fresh anchors. 4. If an anchor fails validation, re-read the relevant range to get fresh anchors.
5. When making multiple edits to a file, use BatchEdit instead of multiple individual calls to prevent anchor invalidation between operations.
Keep responses concise. You have access to the current working directory. 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}}
@@ -69,4 +76,78 @@ internal sealed class ChatSession
yield return update; yield return update;
} }
} }
public async Task SaveAsync(string filePath, CancellationToken cancellationToken = default)
{
// Skip the system message when saving (it will be recreated on load)
var messagesToSave = History.Skip(1).ToList();
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
var json = JsonSerializer.Serialize(messagesToSave, AppJsonContext.Default.ListChatMessage);
// Save token stats to a separate metadata file
var metadataPath = Path.ChangeExtension(filePath, ".metadata.json");
var metadata = new TokenMetadata
{
SessionInputTokens = SessionInputTokens,
SessionOutputTokens = SessionOutputTokens,
RequestCount = RequestCount
};
var metadataJson = JsonSerializer.Serialize(metadata, AppJsonContext.Default.TokenMetadata);
await File.WriteAllTextAsync(metadataPath, metadataJson, cancellationToken);
await File.WriteAllTextAsync(filePath, json, cancellationToken);
}
public async Task LoadAsync(string filePath, CancellationToken cancellationToken = default)
{
var json = await File.ReadAllTextAsync(filePath, cancellationToken);
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var messages = JsonSerializer.Deserialize<List<ChatMessage>>(json, AppJsonContext.Default.ListChatMessage)
?? new List<ChatMessage>();
// Keep the system message and append loaded messages
var systemMessage = History[0];
History.Clear();
History.Add(systemMessage);
History.AddRange(messages);
// Load token stats from metadata file if it exists
var metadataPath = Path.ChangeExtension(filePath, ".metadata.json");
if (File.Exists(metadataPath))
{
try
{
var metadataJson = await File.ReadAllTextAsync(metadataPath, cancellationToken);
var metadata = JsonSerializer.Deserialize<TokenMetadata>(metadataJson, AppJsonContext.Default.TokenMetadata);
if (metadata != null)
{
SessionInputTokens = metadata.SessionInputTokens;
SessionOutputTokens = metadata.SessionOutputTokens;
RequestCount = metadata.RequestCount;
}
}
catch { /* Ignore metadata load errors */ }
}
}
}
/// <summary>
/// Token tracking metadata serialized with the session.
/// </summary>
internal sealed class TokenMetadata
{
public long SessionInputTokens { get; set; }
public long SessionOutputTokens { get; set; }
public int RequestCount { get; set; }
} }

View File

@@ -1,15 +1,16 @@
using Spectre.Console; using Spectre.Console;
namespace AnchorCli.Commands; namespace AnchorCli.Commands;
public class ExitCommand : ICommand public class ExitCommand : ICommand
{ {
public string Name => "exit"; public string Name => "exit";
public string Description => "Exit the application"; public string Description => "Exit the application";
public Task ExecuteAsync(string[] args, CancellationToken ct) public Task ExecuteAsync(string[] args, CancellationToken ct)
{ {
AnsiConsole.MarkupLine("[green]Goodbye![/]"); AnsiConsole.MarkupLine("[green]Goodbye![/]");
Environment.Exit(0); Console.CursorVisible = true;
return Task.CompletedTask; Environment.Exit(0);
} return Task.CompletedTask;
} }
}

View File

@@ -1,5 +1,5 @@
using Spectre.Console; using Spectre.Console;
using System.Linq; using System.Reflection;
namespace AnchorCli.Commands; namespace AnchorCli.Commands;
public class HelpCommand : ICommand public class HelpCommand : ICommand
@@ -16,6 +16,8 @@ public class HelpCommand : ICommand
public Task ExecuteAsync(string[] args, CancellationToken ct) public Task ExecuteAsync(string[] args, CancellationToken ct)
{ {
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
AnsiConsole.MarkupLine($"[cyan]Anchor CLI v{version}[/]");
AnsiConsole.MarkupLine("[cyan]Available commands:[/]"); AnsiConsole.MarkupLine("[cyan]Available commands:[/]");
var table = new Table(); var table = new Table();

View File

@@ -1,4 +1,5 @@
using Spectre.Console; using Spectre.Console;
using System.Reflection;
namespace AnchorCli.Commands; namespace AnchorCli.Commands;
public class StatusCommand : ICommand public class StatusCommand : ICommand
@@ -25,6 +26,7 @@ public class StatusCommand : ICommand
table.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(_model)}[/]"); table.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(_model)}[/]");
table.AddRow("[grey]Endpoint[/]", $"[blue]{Markup.Escape(_endpoint)}[/]"); table.AddRow("[grey]Endpoint[/]", $"[blue]{Markup.Escape(_endpoint)}[/]");
table.AddRow("[grey]Version[/]", $"[magenta]{Assembly.GetExecutingAssembly().GetName().Version}[/]");
table.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]"); table.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]");
AnsiConsole.Write(table); AnsiConsole.Write(table);

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.AI;
using Spectre.Console;
using AnchorCli.OpenRouter;
namespace AnchorCli;
/// <summary>
/// Handles context compaction when the conversation approaches token limits.
/// </summary>
internal sealed class ContextCompactionService
{
private readonly ContextCompactor _compactor;
private readonly List<ChatMessage> _history;
private readonly TokenTracker _tokenTracker;
public ContextCompactionService(
ContextCompactor compactor,
List<ChatMessage> history,
TokenTracker tokenTracker)
{
_compactor = compactor;
_history = history;
_tokenTracker = tokenTracker;
}
/// <summary>
/// Checks if compaction is needed and performs it if so.
/// Returns true if compaction was performed.
/// </summary>
public async Task<bool> TryCompactAsync()
{
if (!_tokenTracker.ShouldCompact())
{
return false;
}
var pct = _tokenTracker.ContextUsagePercent;
AnsiConsole.MarkupLine(
$"[yellow]⚠ Context at {pct:F0}% — compacting conversation history...[/]");
bool compacted = await AnsiConsole.Status()
.Spinner(Spinner.Known.BouncingBar)
.SpinnerStyle(Style.Parse("yellow"))
.StartAsync("Compacting context...", async ctx =>
await _compactor.TryCompactAsync(_history, default));
if (compacted)
{
AnsiConsole.MarkupLine(
$"[green]✓ Context compacted ({_history.Count} messages remaining)[/]");
}
else
{
AnsiConsole.MarkupLine(
"[dim grey] (compaction skipped — not enough history to compress)[/]");
}
AnsiConsole.WriteLine();
return compacted;
}
}

View File

@@ -74,7 +74,7 @@ internal sealed partial class ContextCompactor(IChatClient client)
if (filesRead.TryGetValue(filePath, out int count) && count >= 3) if (filesRead.TryGetValue(filePath, out int count) && count >= 3)
{ {
shouldRedact = true; shouldRedact = true;
reason = "deduplication — you read this file 3 or more times later"; reason = "deduplication — you read this file 5 or more times later";
} }
// Rule 2: TTL. If this was read 2 or more user turns ago, redact it. // Rule 2: TTL. If this was read 2 or more user turns ago, redact it.
else if (userTurnsSeen >= 2) else if (userTurnsSeen >= 2)

View File

@@ -62,17 +62,14 @@ internal static class HashlineValidator
if (lineNumber < 1 || lineNumber > lines.Length) if (lineNumber < 1 || lineNumber > lines.Length)
{ {
error = $"Anchor '{anchor}': line {lineNumber} is out of range " + error = $"Anchor '{anchor}': line {lineNumber} is out of range. Re-read the file ({lines.Length} lines).";
$"(file has {lines.Length} line(s)).";
return false; return false;
} }
string actualHash = HashlineEncoder.ComputeHash(lines[lineNumber - 1].AsSpan(), lineNumber); string actualHash = HashlineEncoder.ComputeHash(lines[lineNumber - 1].AsSpan(), lineNumber);
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{ {
error = $"Anchor '{anchor}': hash mismatch at line {lineNumber} " + error = $"Anchor '{anchor}': hash mismatch at line {lineNumber}. The file has changed — re-read before editing.";
$"(expected '{expectedHash}', got '{actualHash}'). " +
$"The file has changed — re-read before editing.";
return false; return false;
} }
@@ -98,10 +95,7 @@ internal static class HashlineValidator
out int endIndex, out int endIndex,
out string error) out string error)
{ {
startIndex = -1;
endIndex = -1; endIndex = -1;
error = string.Empty;
if (!TryResolve(startAnchor, lines, out startIndex, out error)) if (!TryResolve(startAnchor, lines, out startIndex, out error))
return false; return false;
@@ -110,8 +104,7 @@ internal static class HashlineValidator
if (startIndex > endIndex) if (startIndex > endIndex)
{ {
error = $"Range error: start anchor '{startAnchor}' (line {startIndex + 1}) " + error = $"Range error: start anchor is after end anchor.";
$"is after end anchor '{endAnchor}' (line {endIndex + 1}).";
return false; return false;
} }

110
HeaderRenderer.cs Normal file
View File

@@ -0,0 +1,110 @@
using System.Reflection;
using Spectre.Console;
using AnchorCli.OpenRouter;
namespace AnchorCli;
/// <summary>
/// Renders the application header, including ASCII art logo and configuration info table.
/// </summary>
internal sealed class HeaderRenderer
{
private readonly string _model;
private readonly string _endpoint;
private readonly string _providerName;
private readonly ModelInfo? _modelInfo;
private readonly TokenTracker? _tokenTracker;
public HeaderRenderer(
string model,
string endpoint,
string providerName,
ModelInfo? modelInfo = null,
TokenTracker? tokenTracker = null)
{
_model = model;
_endpoint = endpoint;
_providerName = providerName;
_modelInfo = modelInfo;
_tokenTracker = tokenTracker;
}
/// <summary>
/// Renders the full header including logo, subtitle, and info table.
/// </summary>
public void Render()
{
RenderLogo();
RenderSubtitle();
RenderInfoTable();
}
/// <summary>
/// Renders the ASCII art logo.
/// </summary>
public void RenderLogo()
{
var fontStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("AnchorCli.Assets.3d.flf");
if (fontStream != null)
{
var font = FigletFont.Load(fontStream);
AnsiConsole.Write(
new FigletText(font, "anchor")
.Color(Color.CornflowerBlue));
}
else
{
AnsiConsole.Write(
new FigletText("anchor")
.Color(Color.CornflowerBlue));
}
}
/// <summary>
/// Renders the subtitle rule.
/// </summary>
public void RenderSubtitle()
{
AnsiConsole.Write(
new Rule("[dim]AI-powered coding assistant[/]")
.RuleStyle(Style.Parse("cornflowerblue dim"))
.LeftJustified());
AnsiConsole.WriteLine();
}
/// <summary>
/// Renders the configuration info table.
/// </summary>
public void RenderInfoTable()
{
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
var table = new Table()
.Border(TableBorder.Rounded)
.BorderColor(Color.Grey)
.AddColumn(new TableColumn("[dim]Setting[/]").NoWrap())
.AddColumn(new TableColumn("[dim]Value[/]"));
table.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(_modelInfo?.Name ?? _model)}[/]");
table.AddRow("[grey]Provider[/]", $"[blue]{_providerName}[/]");
table.AddRow("[grey]Endpoint[/]", $"[dim]{_endpoint}[/]");
table.AddRow("[grey]Version[/]", $"[magenta]{version}[/]");
if (_modelInfo?.Pricing != null && _tokenTracker != null)
{
var inM = _tokenTracker.InputPrice * 1_000_000m;
var outM = _tokenTracker.OutputPrice * 1_000_000m;
table.AddRow("[grey]Pricing[/]",
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
}
if (_modelInfo != null)
{
table.AddRow("[grey]Context[/]",
$"[dim]{_modelInfo.ContextLength:N0} tokens[/]");
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
}
}

View File

@@ -1,81 +0,0 @@
# Command Ideas for AnchorCli
## Session & Help
### `/help`
Show available commands, version info, and tool capabilities. Combines `/help`, `/version`, `/about`, and `/tools`.
### `/clear`
Clear the terminal screen and optionally reset conversation with `/clear --reset`.
### `/history`
Show the current chat history. Use `/history <n>` to show last N messages.
## Navigation
### `/cd [path]`
Change directory. With no argument, shows current working directory (combines `/cwd`, `/pwd`, `/cd`).
### `/ls`
List files in current directory (alias for ListDir tool).
## Configuration
### `/config`
Show or modify settings. Subcommands:
- `/config model <name>` - Change AI model
- `/config endpoint <url>` - Change API endpoint
- `/config debug <on|off>` - Toggle debug mode
## Conversation Management
### `/save [filename]`
Save current conversation to a file (JSON or markdown format).
### `/load <filename>`
Load a previous conversation from a file.
### `/export <filename>`
Export chat history in a specific format (JSON, markdown, plain text).
## Advanced Features
### `/undo`
Undo the last file edit (requires edit history tracking).
### `/diff [file]`
Show differences between current and original file state. With no argument, shows all pending changes.
### `/search <pattern>`
Quick file/content search across the project.
### `/stats`
Show session statistics (files edited, tokens used, commands run, estimated costs).
### `/macro <name> [commands...]`
Create and execute multi-step command sequences.
### `/alias <name> <command>`
Create custom command shortcuts.
## Safety & Integration
### `--dry-run` / Read-only Mode
Run Anchor without mutating any files. Shows what *would* happen (edits, deletes, renames) without applying changes. Perfect for reviewing AI suggestions before committing.
### Git Integration
Seamless version control integration:
- Auto-create a branch per session (`anchor session --git-branch`)
- Auto-commit after successful edits with descriptive messages
- Show git diff before/after operations
- Revert to pre-session state if something goes wrong
### Mutation Rate Limits
Prevent runaway AI from trashing a project:
- Configurable max file edits per conversation turn
- Hard cap on delete/rename operations without confirmation
- Cooldown period after N rapid mutations
- Warning when approaching limits
### File Type Restrictions
Config to block edits on sensitive patterns (`*.config`, `*.sql`, `*.production.*`, etc.). Requires explicit override flag.

View File

@@ -1,90 +0,0 @@
# Improvements for AnchorCli
This document contains criticisms and suggestions for improving the AnchorCli project.
## Architecture
1. **Program.cs is too large (433 lines)** - Split into smaller classes: ChatSession, ReplLoop, ResponseStreamer
2. **No dependency injection** - Use Microsoft.Extensions.DependencyInjection for testability
3. **Static tool classes with global Log delegates** - Convert to instance classes with injected ILogger
## Testing
4. **No unit tests** - Add xUnit project, test HashlineEncoder/Validator, tools, and ContextCompactor
5. **No integration tests** - Use Spectre.Console.Testing for TUI workflows
6. **No CI/CD** - Add GitHub Actions for test runs on push/PR
## Documentation
7. **Missing XML docs** - Add summary docs to public APIs
8. **Incomplete README** - Add contributing, development, troubleshooting sections
9. **No CHANGELOG.md** - Track releases and changes
## Security & Safety
10. **Command execution unsandboxed** - Add allowlist/denylist, time limits, output size limits
11. **No mutation rate limiting** - Track edits per turn, add configurable limits
12. **API key in plain text** - Use OS keychain or env var, set restrictive file permissions
## Performance
13. **No file read caching** - Cache file content per-turn with invalidation on write
14. **Regex not static** - Make compiled regexes static readonly
## User Experience
15. **No undo** - Store edit history, add /undo command
16. **No session persistence** - Add /save and /load commands
17. **Limited error recovery** - Better error messages, /debug mode
## Developer Experience
18. **No .editorconfig** - Add code style enforcement
19. **No solution file** - Create AnchorCli.sln
20. **Hardcoded model list** - Fetch from OpenRouter API dynamically
21. **No version info** - Add <Version> to .csproj, display in /help
## Code Quality
22. **Inconsistent error handling** - Standardize on error strings, avoid empty catch blocks
23. **Magic numbers** - Extract to named constants (150_000, 300, KeepRecentTurns=2)
24. **Commented-out debug code** - Remove or use #if DEBUG
25. **Weak hash algorithm** - Adler-8 XOR only has 256 values; consider 4-char hex
## Build & Dependencies
26. **No LICENSE file** - Add MIT LICENSE file
## Priority
### High
- [ ] Add unit tests
- [ ] Implement undo functionality
- [ ] Add mutation rate limiting
- [x] Refactor Program.cs
- [x] Add LICENSE file
### Medium
- [ ] Session persistence
- [ ] XML documentation
- [ ] Error handling consistency
- [x] .editorconfig
- [ ] Dynamic model list
### Low
- [ ] CHANGELOG.md
- [ ] CI/CD pipeline
- [ ] Stronger hash algorithm
- [ ] Code coverage reporting
## Quick Wins (<1 hour each)
- [x] Add <Version> to .csproj
- [x] Create LICENSE file
- [x] Add .editorconfig
- [x] Remove commented code
- [x] Extract magic numbers to constants
- [x] Add XML docs to Hashline classes
- [x] Make regexes static readonly
*Prioritize based on goals: safety, testability, or user experience.*

125
InputProcessor.cs Normal file
View File

@@ -0,0 +1,125 @@
using Spectre.Console;
namespace AnchorCli
{
internal class InputProcessor
{
private static void DisplayText(int left, string buffer, int index = -1, string placeholder = "", int viewportOffset = 0)
{
Console.CursorLeft = left;
if (buffer.Length == 0 && index == 0)
{
AnsiConsole.Markup($"[grey dim]{placeholder}{new string(' ', Console.WindowWidth - 1 - left - placeholder.Length)}[/]");
return;
}
var visibleWidth = Console.WindowWidth - left - 1;
var displayStart = Math.Min(viewportOffset, Math.Max(0, buffer.Length - 1));
var displayEnd = Math.Min(displayStart + visibleWidth, buffer.Length);
var displayBuffer = string.Concat(buffer.AsSpan(displayStart, displayEnd - displayStart), " ");
for (var i = 0; i < displayBuffer.Length; i++)
{
var actualIndex = displayStart + i;
if (index != -1 && actualIndex == index)
{
Console.ForegroundColor = ConsoleColor.Black;
Console.BackgroundColor = ConsoleColor.White;
}
Console.Write(displayBuffer[i]);
if (index != -1 && actualIndex == index)
Console.ResetColor();
}
// Fill remaining space with spaces
var remainingSpaces = visibleWidth - displayBuffer.Length;
if (remainingSpaces > 0)
Console.Write(new string(' ', remainingSpaces));
}
public static string ReadLine(string placeholder = "")
{
Console.CursorVisible = false;
var buffer = string.Empty;
var index = 0;
var viewportOffset = 0;
var left = Console.CursorLeft;
DisplayText(left, buffer, index, placeholder, viewportOffset);
while (true)
{
var inputKey = Console.ReadKey(intercept: true);
switch (inputKey)
{
case { Key: ConsoleKey.Enter } when buffer.Length > 0:
DisplayText(left, buffer);
Console.WriteLine();
return buffer;
case { Key: ConsoleKey.Backspace } when index > 0:
index--;
buffer = buffer.Remove(index, 1);
break;
case { Key: ConsoleKey.Delete } when index < buffer.Length:
buffer = buffer.Remove(index, 1);
break;
case { Key: ConsoleKey.LeftArrow, Modifiers: ConsoleModifiers.Control }:
while (index > 0 && buffer[index - 1] == ' ')
index--;
while (index > 0 && buffer[index - 1] != ' ')
index--;
break;
case { Key: ConsoleKey.RightArrow, Modifiers: ConsoleModifiers.Control }:
while (index < buffer.Length && buffer[index] == ' ')
index++;
while (index < buffer.Length && buffer[index] != ' ')
index++;
break;
case { Key: ConsoleKey.LeftArrow } when index > 0:
index--;
break;
case { Key: ConsoleKey.RightArrow } when index < buffer.Length:
index++;
break;
case { Key: ConsoleKey.W, Modifiers: ConsoleModifiers.Control } when index > 0:
var deleteStart = index;
while (deleteStart > 0 && buffer[deleteStart - 1] == ' ')
deleteStart--;
while (deleteStart > 0 && buffer[deleteStart - 1] != ' ')
deleteStart--;
var charsToDelete = index - deleteStart;
buffer = buffer.Remove(deleteStart, charsToDelete);
index = deleteStart;
break;
default:
var keyChar = inputKey.KeyChar;
if (!(char.IsLetterOrDigit(keyChar) || char.IsWhiteSpace(keyChar) || char.IsPunctuation(keyChar) || char.IsSymbol(keyChar)))
break;
buffer = buffer.Insert(index, inputKey.KeyChar.ToString());
index++;
break;
}
// Adjust viewport for scrolling
var visibleWidth = Console.WindowWidth - left - 1;
if (index < viewportOffset)
viewportOffset = index;
else if (index >= viewportOffset + visibleWidth)
viewportOffset = index - visibleWidth + 1;
DisplayText(left, buffer, index, placeholder, viewportOffset);
}
}
}
}

View File

@@ -5,10 +5,27 @@ namespace AnchorCli.OpenRouter;
/// </summary> /// </summary>
internal sealed class TokenTracker internal sealed class TokenTracker
{ {
private ChatSession _session;
public TokenTracker(ChatSession session)
{
_session = session;
}
/// <summary>
/// Gets or sets the session. Allows setting the session after construction
/// to support dependency injection patterns.
/// </summary>
public ChatSession Session
{
get => _session;
set => _session = value;
}
public string Provider { get; set; } = "Unknown"; public string Provider { get; set; } = "Unknown";
public long SessionInputTokens { get; private set; } public long SessionInputTokens => _session.SessionInputTokens;
public long SessionOutputTokens { get; private set; } public long SessionOutputTokens => _session.SessionOutputTokens;
public int RequestCount { get; private set; } public int RequestCount => _session.RequestCount;
/// <summary>Maximum context window for the model (tokens). 0 = unknown.</summary> /// <summary>Maximum context window for the model (tokens). 0 = unknown.</summary>
public int ContextLength { get; set; } public int ContextLength { get; set; }
@@ -29,16 +46,16 @@ internal sealed class TokenTracker
/// </summary> /// </summary>
public void AddUsage(int inputTokens, int outputTokens) public void AddUsage(int inputTokens, int outputTokens)
{ {
SessionInputTokens += inputTokens; _session.SessionInputTokens += inputTokens;
SessionOutputTokens += outputTokens; _session.SessionOutputTokens += outputTokens;
LastInputTokens = inputTokens; LastInputTokens = inputTokens;
RequestCount++; _session.RequestCount++;
} }
public void Reset() public void Reset()
{ {
SessionInputTokens = 0; _session.SessionInputTokens = 0;
SessionOutputTokens = 0; _session.SessionOutputTokens = 0;
RequestCount = 0; _session.RequestCount = 0;
LastInputTokens = 0; LastInputTokens = 0;
} }

View File

@@ -1,293 +0,0 @@
# Provider Support Plan
## Current Problems
1. **OpenRouter Hardcoded**: Endpoint, headers, and pricing API calls are hardcoded to OpenRouter
2. **Config Ineffective**: SetupTui allows "custom endpoint" but Program.cs ignores it
3. **Token Count**: Token usage tracking only works with OpenRouter response headers
4. **Pricing Only for One Provider**: Models list shows pricing, but only when using OpenRouter
---
## Goals
1. Make the system **endpoint-agnostic**
2. Support pricing/token tracking for **multiple providers**
3. Keep **OpenRouter as the default** (familiar)
4. Allow users to configure any OpenAI-compatible endpoint
5. Show pricing/token info **only when available** for each provider
---
## Provider Categories
### Tier 1: Native Support (Built-in)
- OpenRouter (default)
- Ollama (local, no auth)
- Groq (high-speed inference)
- Anthropic (native or via API)
- OpenAI (official api)
### Tier 2: Config-Based Support
- Cerebras
- DeepSeek
- Any OpenAI-compatible endpoint that supports custom headers
### Tier 3: Manual Configuration Required
- Self-hosted endpoints
- Corporate proxies
- Custom middleware layers
---
```csharp
// Example: Provider interface
class PricingProvider
{
// Get pricing info from provider's API
async Task<List<ModelPricing>> GetModelsAsync(string apiKey);
// Get tokens from response
async Task<TokenUsage> GetTokensFromResponseAsync(HttpResponseMessage response);
// Add provider-specific headers if needed
void AddHeaders(HttpRequestMessage request, string apiKey);
}
```
**Supported Implementations:**
- `OpenRouterProvider` (uses `/api/v1/models` + `x-total-tokens`)
- `GroqProvider` (uses Groq's pricing API + response headers)
- `OllamaProvider` (free tier, no pricing lookup, basic token counting)
- `OpenAIProvider` (uses OpenAI's model list + token counting)
- `GenericProvider` (fallback for any OpenAI-compatible endpoint)
**Configuration:**
Store provider selection in `anchor.config.json`:
```json
{
"apiKey": "your-key",
"model": "qwen3.5-27b",
"endpoint": "https://openrouter.ai/api/v1",
"provider": "openrouter"
}
```
Auto-detect provider from endpoint URL if not specified.
---
## Pricing System
### Current State
- Uses OpenRouter's `/api/v1/models` endpoint
- Displays pricing in a table during startup
- Only works when using OpenRouter
### Improved Behavior
**When endpoint matches known provider:**
1. Fetch pricing from that provider's API
2. Display pricing in the startup table
3. Show per-prompt costs in chat output
**When endpoint is generic/unsupported:**
1. Skip API call (no pricing lookup)
2. Display `---` or `$` placeholders
3. Optional: Show "Pricing not available" note
**User Feedback:**
- Show clear messaging: "Pricing data loaded from OpenRouter"
- Show: "Pricing not available for this endpoint" (for unsupported)
- Don't break chat functionality if pricing fails
### Pricing Data Format
Store in `ModelPricing` class:
```csharp
class ModelPricing
{
string ModelId;
decimal InputPricePerMTokens;
decimal OutputPricePerMTokens;
double? CacheCreationPricePerMTokens; // if supported
}
```
---
## Token Tracking System
### Current State
- Uses `x-total-tokens` from OpenRouter headers
- Only works with OpenRouter responses
### Multi-Provider Strategy
**OpenRouter:**
- Use `x-total-tokens` header
- Use `x-response-timing` for latency tracking
**Groq:**
- Use `x-groq-tokens` header
- Use `x-groq-response-time` for latency
**OpenAI:**
- Use `x-ai-response-tokens` header (if available)
- Fall back to response body if needed
**Ollama:**
- No official token counting
- Use output length as proxy estimate
- Optional: Show message token estimates
**Generic/Fallback:**
- Parse `total_tokens` from response JSON
- Fall back to character count estimates
- Show placeholder when unavailable
### Integration Points
**During Chat Session:**
1. After each response, extract tokens from response headers
2. Store in `ChatSession.TokensUsed` object
3. Display in status bar: `Tokens: 128/2048 • Cost: $0.002`
**At Session End:**
1. Show summary: `Total tokens: 1,024 | Total cost: $0.015`
2. Write to session log or history file
---
## Implementation Roadmap
### Phase 1: Conditional Pricing (Current Issues First)
- [ ] Check if endpoint is OpenRouter before fetching pricing
- [ ] Skip pricing API call for non-OpenRouter endpoints
- [ ] Show placeholder message if pricing not available
- [ ] **Time estimate:** 2 hours
### Phase 2: Provider Configuration
- [ ] Add `provider` field to `AnchorConfig` model
- [ ] Update `SetupTui` to ask "Which provider?" (openrouter, ollama, groq, etc.)
- [ ] Auto-detect provider from endpoint URL (smart default)
- [ ] Write provider to config file on setup
- [ ] **Time estimate:** 3 hours
### Phase 3: Provider Abstraction
- [ ] Create `IPricingProvider` interface
- [ ] Move existing `PricingProvider` to `OpenRouterProvider`
- [ ] Create `GenericPricingProvider` for fallback
- [ ] Add provider factory: `ProviderFactory.Create(providerName)`
- [ ] **Time estimate:** 5 hours
### Phase 4: Token Tracking Enhancement
- [ ] Create `ITokenTracker` interface
- [ ] Implement token extraction for multiple providers
- [ ] Display token usage in status bar
- [ ] Add per-prompt cost calculation
- [ ] **Time estimate:** 6 hours
### Phase 5: Second Provider Implementation
- [ ] Implement `GroqProvider` (similar to OpenRouter)
- [ ] Test with Groq API
- [ ] Update documentation
- [ ] **Time estimate:** 4 hours
### Phase 6: Future-Proofing (Optional)
- [ ] Add plugin system for custom providers
- [ ] Allow users to define custom pricing rules
- [ ] Support OpenRouter-compatible custom endpoints
- [ ] **Time estimate:** 8+ hours
---
## User Configuration Guide
### Automatic Setup
Run `/setup` in the chat or `anchor setup` in CLI:
```
Which provider are you using?
1) OpenRouter (qwen models)
2) Groq (qwen/gemma models)
3) Ollama (local models)
4) OpenAI (gpt models)
5) Custom endpoint
```
### Manual Configuration
Edit `anchor.config.json` directly:
```json
{
"apiKey": "your-api-key",
"model": "qwen3.5-27b",
"endpoint": "https://api.groq.com/openai/v1",
"provider": "groq" // optional, auto-detected if missing
}
```
### Environment Variables
For custom setup:
```
ANCHOR_ENDPOINT=https://api.groq.com/openai/v1
ANCHOR_PROVIDER=groq
ANCHOR_API_KEY=...
ANCHOR_MODEL=qwen3.5-27b
```
---
## Known Limitations
### Tier 1 Providers (Full Support)
**✓ OpenRouter**
- Pricing: ✓ (native API)
- Tokens: ✓ (response headers)
- Cost tracking: ✓
**✓ Groq** (after Phase 4)
- Pricing: ✓ (will add)
- Tokens: ✓ (response headers)
- Cost tracking: ✓
### Tier 2 Providers (Partial Support)
**○ Ollama**
- Pricing: ○ (free, no lookup needed)
- Tokens: ○ (estimated from output)
- Cost tracking: ○ (placeholder)
**○ OpenAI**
- Pricing: ○ (manual pricing display)
- Tokens: ○ (header extraction)
- Cost tracking: ○ (config-based)
### Tier 3 Providers (Basic Support)
**□ Custom Endpoints**
- Pricing: □ (manual only)
- Tokens: □ (fallback parsing)
- Cost tracking: □ (user-defined)
---
## Future Enhancements
1. **Pricing Database**: Maintain own pricing database (like OpenRouter's)
2. **Cost Estimator**: Predict costs before sending message
3. **Usage Alerts**: Warn user when approaching budget limits
4. **Multi-Model Support**: Compare costs between different providers
5. **Plugin System**: Allow community to add new providers
---
## Success Criteria
- ✅ Users can choose from 3+ providers in setup
- ✅ Pricing displays only for supported endpoints
- ✅ Token tracking works for all Tier 1 providers
- ✅ No breaking changes to existing OpenRouter users
- ✅ Clear documentation on what each provider supports
- ✅ Graceful degradation for unsupported features
---
*Last Updated: 2025-12-23*

View File

@@ -1,153 +1,45 @@
using System.ClientModel;
using AnchorCli.Providers;
using Microsoft.Extensions.AI;
using OpenAI;
using AnchorCli; using AnchorCli;
using AnchorCli.Tools;
using AnchorCli.Commands; using AnchorCli.Commands;
using AnchorCli.OpenRouter;
using Spectre.Console; using Spectre.Console;
// ── Setup subcommand ───────────────────────────────────────────────────── Console.InputEncoding = System.Text.Encoding.UTF8;
if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) Console.OutputEncoding = System.Text.Encoding.UTF8;
// ── Application entry point ───────────────────────────────────────────────
var startup = new ApplicationStartup(args);
// Handle setup subcommand
if (startup.HandleSetupSubcommand())
{ {
SetupTui.Run();
return; return;
} }
// ── Config ────────────────────────────────────────────────────────────── // Initialize application (load config, create clients, fetch pricing)
var cfg = AnchorConfig.Load(); await startup.InitializeAsync();
string apiKey = cfg.ApiKey;
string model = cfg.Model;
string provider = cfg.Provider ?? "openrouter";
string endpoint = cfg.Endpoint ?? "https://openrouter.ai/api/v1";
if (string.IsNullOrWhiteSpace(apiKey)) // Render header
{ var headerRenderer = startup.CreateHeaderRenderer();
AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); headerRenderer.Render();
return;
}
// ── Create token extractor for this provider ─────────────────────────── // Configure tool logging
var tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(endpoint); startup.ConfigureToolLogging();
var tokenTracker = new TokenTracker { Provider = tokenExtractor.ProviderName };
// ── Fetch model pricing (only for supported providers) ─────────────────
ModelInfo? modelInfo = null;
if (ProviderFactory.IsOpenRouter(endpoint))
{
await AnsiConsole.Status()
.Spinner(Spinner.Known.BouncingBar)
.SpinnerStyle(Style.Parse("cornflowerblue"))
.StartAsync("Fetching model pricing...", async ctx =>
{
try
{
var pricingProvider = new OpenRouterProvider();
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]Provider[/]", $"[blue]{tokenExtractor.ProviderName}[/]");
infoTable.AddRow("[grey]Endpoint[/]", $"[dim]{endpoint}[/]");
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 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 httpClient = new HttpClient();
OpenRouterHeaders.ApplyTo(httpClient);
var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions
{
Endpoint = new Uri(endpoint),
Transport = new System.ClientModel.Primitives.HttpClientPipelineTransport(httpClient)
});
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));
// Create core components
var session = startup.CreateSession();
startup.TokenTracker.Session = session;
var commandRegistry = startup.CreateCommandRegistry(session);
var commandDispatcher = new CommandDispatcher(commandRegistry); var commandDispatcher = new CommandDispatcher(commandRegistry);
// ── Run Repl ──────────────────────────────────────────────────────────── // Create session manager
var sessionManager = new SessionManager(session);
var repl = new ReplLoop(session, tokenTracker, commandDispatcher); // Auto-load session if it exists
await sessionManager.TryLoadAsync();
// Run REPL loop
var repl = new ReplLoop(session, startup.TokenTracker, commandDispatcher, sessionManager);
await repl.RunAsync(); await repl.RunAsync();
// Auto-save session on clean exit
await sessionManager.TrySaveAsync();

135
README.md
View File

@@ -2,30 +2,14 @@
An AI-powered coding assistant built as a .NET 10.0 console application, featuring the **Hashline** technique for safe, precise file editing. An AI-powered coding assistant built as a .NET 10.0 console application, featuring the **Hashline** technique for safe, precise file editing.
## What is Hashline?
AnchorCli's unique approach to file editing. Every line returned by file tools is prefixed with a content-derived hash anchor:
```
1:a3| function hello() {
2:f1| return "world";
3:0e| }
```
When editing, you reference these `line:hash` anchors instead of reproducing old content. Before any mutation, both the line number **and** hash are validated — stale anchors are rejected immediately.
This eliminates:
- Whitespace/indentation reproduction errors
- Silent edits to the wrong line in large files
- Entire-file rewrites just to change one line
## Features ## Features
- **Batch Editing**: Apply multiple replace/delete operations atomically in a single pass without line drift
- **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`, `/reset` - **Slash Commands**: `/setup`, `/help`, `/exit`, `/clear`, `/status`, `/compact`, `/reset`, `/load`, `/save`
- **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
- **Comprehensive Toolset**: 15 tools for file operations, editing, directory management, and command execution - **Comprehensive Toolset**: 15 tools for file operations, editing, directory management, and command execution
- **AOT-Ready**: Native AOT compilation for ~12 MB binaries with no .NET runtime dependency - **AOT-Ready**: Native AOT compilation for ~12 MB binaries with no .NET runtime dependency
- **Rich CLI**: Beautiful terminal output using Spectre.Console with tables, rules, and colored text - **Rich CLI**: Beautiful terminal output using Spectre.Console with tables, rules, and colored text
@@ -33,12 +17,14 @@ This eliminates:
- **OpenAI-Compatible**: Works with any OpenAI-compatible API (OpenAI, Ollama, Cerebras, Groq, OpenRouter, etc.) - **OpenAI-Compatible**: Works with any OpenAI-compatible API (OpenAI, Ollama, Cerebras, Groq, OpenRouter, etc.)
- **Ctrl+C Support**: Cancel in-progress responses without exiting - **Ctrl+C Support**: Cancel in-progress responses without exiting
## Requirements ## Installation
### Requirements
- .NET 10 SDK - .NET 10 SDK
- `clang` (for native AOT publish on Linux) - `clang` (for native AOT publish on Linux)
## Quick Start ### Quick Start
```bash ```bash
# Run the application # Run the application
@@ -46,11 +32,10 @@ dotnet run --project AnchorCli
# First time? The app will prompt you to run /setup # First time? The app will prompt you to run /setup
# Or run it explicitly: # Or run it explicitly:
dotnet run --project AnchorCli
/setup /setup
``` ```
## Native AOT Build ### Native AOT Build
```bash ```bash
dotnet publish AnchorCli -r linux-x64 -c Release dotnet publish AnchorCli -r linux-x64 -c Release
@@ -59,91 +44,17 @@ dotnet publish AnchorCli -r linux-x64 -c Release
The resulting binary is ~12 MB, has no .NET runtime dependency, and starts instantly. The resulting binary is ~12 MB, has no .NET runtime dependency, and starts instantly.
## Slash Commands ## Usage
| Command | Description |
|---|---|
| `/setup` | Run interactive TUI to configure API key and model (also accessible via `anchor setup` subcommand) |
| `/help` | Show available tools and commands |
| `/exit` | Exit the application |
| `/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:**
- `read_file` - Read a file (or a window) with Hashline-tagged lines
- `grep_file` - Search a file by regex — results are pre-tagged for immediate editing
- `grep_recursive` - Search for a regex pattern across all files in a directory tree
- `find_files` - Search for files matching glob patterns
- `get_file_info` - Get detailed file information (size, permissions, etc.)
**Edit Operations:**
- `replace_lines` - Replace a range identified by `line:hash` anchors
- `insert_after` - Insert lines after an anchor
- `delete_range` - Delete a range between two anchors
- `create_file` - Create a new file with optional initial content
- `delete_file` - Delete a file permanently
- `rename_file` - Rename or move a file
- `copy_file` - Copy a file to a new location
- `append_to_file` - Append lines to the end of a file
**Directory Operations:**
- `list_dir` - List directory contents
- `create_dir` - Create a new directory (with parent directories)
- `rename_dir` - Rename or move a directory
- `delete_dir` - Delete a directory and all its contents
**Command Execution:**
- `execute_command` - Run shell commands (with user approval)
```
AnchorCli/
├── 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
│ └── 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
├── Commands/
│ ├── 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
1. **Setup**: Configure API credentials via the `/setup` command (or `anchor setup` subcommand) 1. **Setup**: Configure API credentials via the `/setup` command (or `anchor setup` subcommand)
2. **REPL Loop**: You interact with the AI through a conversational interface 2. **REPL Loop**: Interact with the AI through a conversational interface
3. **Tool Calling**: The AI can call any of the available tools to read/edit files, manage directories, or execute commands 3. **Tool Calling**: The AI can call tools to read/edit files, manage directories, or execute commands
4. **Hashline Validation**: All file edits are validated using the Hashline technique to ensure precision 4. **Safe Execution**: Commands require explicit user approval before running
5. **Token Tracking**: Responses show token usage and cost; session totals are maintained
6. **Context Compaction**: When approaching context limits, conversation history is automatically compressed
7. **Safe Execution**: Commands require explicit user approval before running
## Supported Models ### Supported Models
AnchorCli works with any OpenAI-compatible API endpoint:
AnchorCli works with any OpenAI-compatible API endpoint, including:
- OpenAI (gpt-4o, gpt-4.1, etc.) - OpenAI (gpt-4o, gpt-4.1, etc.)
- Ollama (local models) - Ollama (local models)
- Cerebras - Cerebras
@@ -151,6 +62,22 @@ AnchorCli works with any OpenAI-compatible API endpoint, including:
- OpenRouter (qwen3.5-27b, etc.) - OpenRouter (qwen3.5-27b, etc.)
- Any custom OpenAI-compatible server - Any custom OpenAI-compatible server
## Configuration
Configuration is stored in `~APPDATA~/anchor/config.json` on Windows or `~/.anchor/config.json` on Linux/macOS.
Use the `/setup` command or run `anchor setup` from the command line to configure:
- API Key
- API Endpoint (default: OpenRouter)
- Model selection
## Documentation
- [**Available Tools**](docs/TOOLS.md) - Complete list of all tools organized by category
- [**Hashline Technique**](docs/HASHLINE.md) - Detailed explanation of Hashline editing with examples
- [**Slash Commands**](docs/COMMANDS.md) - All available slash commands with descriptions
## License ## License
MIT License - See LICENSE file for details. MIT License - See LICENSE file for details.

View File

@@ -1,28 +1,42 @@
using Microsoft.Extensions.AI; using System.Text;
using OpenAI;
using Spectre.Console; using Spectre.Console;
using AnchorCli.OpenRouter;
using AnchorCli.Commands; using AnchorCli.Commands;
using AnchorCli.Tools; using AnchorCli.Tools;
using AnchorCli.OpenRouter;
namespace AnchorCli; namespace AnchorCli;
/// <summary>
/// Manages the interactive REPL (Read-Eval-Print Loop) for user interaction.
/// Orchestrates input handling, command dispatching, and response display.
/// </summary>
internal sealed class ReplLoop internal sealed class ReplLoop
{ {
private readonly ChatSession _session; private readonly ChatSession _session;
private readonly TokenTracker _tokenTracker; private readonly TokenTracker _tokenTracker;
private readonly CommandDispatcher _commandDispatcher; private readonly CommandDispatcher _commandDispatcher;
private readonly SessionManager _sessionManager;
private readonly ResponseStreamer _streamer;
private readonly UsageDisplayer _usageDisplayer;
private readonly ContextCompactionService _compactionService;
public ReplLoop(ChatSession session, TokenTracker tokenTracker, CommandDispatcher commandDispatcher) public ReplLoop(
ChatSession session,
TokenTracker tokenTracker,
CommandDispatcher commandDispatcher,
SessionManager sessionManager)
{ {
_session = session; _session = session;
_tokenTracker = tokenTracker; _tokenTracker = tokenTracker;
_commandDispatcher = commandDispatcher; _commandDispatcher = commandDispatcher;
_sessionManager = sessionManager;
_streamer = new ResponseStreamer(session);
_usageDisplayer = new UsageDisplayer(tokenTracker);
_compactionService = new ContextCompactionService(session.Compactor, session.History, tokenTracker);
} }
public async Task RunAsync() public async Task RunAsync()
{ {
AnsiConsole.MarkupLine("[dim]Type your message, or use [bold]/help[/] to see commands.[/]");
AnsiConsole.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]"); AnsiConsole.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]");
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
@@ -30,217 +44,36 @@ internal sealed class ReplLoop
Console.CancelKeyPress += (_, e) => Console.CancelKeyPress += (_, e) =>
{ {
e.Cancel = true; // Prevent process termination e.Cancel = true;
responseCts?.Cancel(); responseCts?.Cancel();
}; };
while (true) while (true)
{ {
string input = ReadLine.Read(" "); AnsiConsole.Markup("[grey] [/]");
string input = InputProcessor.ReadLine("Type your message, or use [bold]/help[/] to see commands.");
if (string.IsNullOrWhiteSpace(input)) continue; if (string.IsNullOrWhiteSpace(input)) continue;
if (await _commandDispatcher.TryExecuteAsync(input, default)) continue; if (await _commandDispatcher.TryExecuteAsync(input, default)) continue;
_session.History.Add(new ChatMessage(ChatRole.User, input)); _session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User, input));
int turnStartIndex = _session.History.Count;
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
responseCts?.Dispose();
responseCts = new CancellationTokenSource(); responseCts = new CancellationTokenSource();
string fullResponse = "";
try try
{ {
await using var stream = _session await ProcessTurnAsync(responseCts.Token);
.GetStreamingResponseAsync(responseCts.Token)
.GetAsyncEnumerator(responseCts.Token);
string? firstChunk = null;
int respIn = 0, respOut = 0;
void CaptureUsage(ChatResponseUpdate update)
{
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
&& raw.Usage != null)
{
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 = () =>
{
lock (consoleLock)
{
showSpinner = false;
Console.Write("\r" + new string(' ', 40) + "\r");
}
};
CommandTool.ResumeSpinner = () =>
{
lock (consoleLock)
{
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
{
while (!spinnerCts.Token.IsCancellationRequested)
{
lock (consoleLock)
{
if (showSpinner && !spinnerCts.Token.IsCancellationRequested)
{
var frame = frames[i % frames.Count];
Console.Write($"\r\x1b[38;5;69m{frame}\x1b[0m Thinking...");
i++;
}
}
try { await Task.Delay(interval, spinnerCts.Token); } catch { }
}
}
finally
{
lock (consoleLock)
{
if (showSpinner)
Console.Write("\r" + new string(' ', 40) + "\r");
Console.Write("\x1b[?25h");
}
}
});
try
{
while (await stream.MoveNextAsync())
{
responseCts.Token.ThrowIfCancellationRequested();
CaptureUsage(stream.Current);
if (!string.IsNullOrEmpty(stream.Current.Text))
{
firstChunk = stream.Current.Text;
fullResponse = firstChunk;
break;
}
}
}
finally
{
spinnerCts.Cancel();
await Task.WhenAny(spinnerTask);
CommandTool.PauseSpinner = null;
CommandTool.ResumeSpinner = null;
FileTools.OnFileRead = null;
}
if (firstChunk != null)
{
AnsiConsole.Markup(Markup.Escape(firstChunk));
}
while (await stream.MoveNextAsync())
{
responseCts.Token.ThrowIfCancellationRequested();
CaptureUsage(stream.Current);
var text = stream.Current.Text;
if (!string.IsNullOrEmpty(text))
{
AnsiConsole.Markup(Markup.Escape(text));
}
fullResponse += text;
}
if (respIn > 0 || respOut > 0)
{
_tokenTracker.AddUsage(respIn, respOut);
var cost = _tokenTracker.CalculateCost(respIn, respOut);
var ctxPct = _tokenTracker.ContextUsagePercent;
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine(
$"[dim grey] {TokenTracker.FormatTokens(respIn)}↑ {TokenTracker.FormatTokens(respOut)}↓" +
$" {TokenTracker.FormatCost(cost)}" +
(ctxPct >= 0 ? $" ctx:{ctxPct:F0}%" : "") +
$" │ session: {TokenTracker.FormatCost(_tokenTracker.SessionCost)}[/]");
}
else
{
AnsiConsole.WriteLine();
}
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
AnsiConsole.WriteLine();
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
if (_tokenTracker.ShouldCompact())
{
var pct = _tokenTracker.ContextUsagePercent;
AnsiConsole.MarkupLine(
$"[yellow]⚠ Context at {pct:F0}% — compacting conversation history...[/]");
bool compacted = await AnsiConsole.Status()
.Spinner(Spinner.Known.BouncingBar)
.SpinnerStyle(Style.Parse("yellow"))
.StartAsync("Compacting context...", async ctx =>
await _session.Compactor.TryCompactAsync(_session.History, default));
if (compacted)
{
AnsiConsole.MarkupLine(
$"[green]✓ Context compacted ({_session.History.Count} messages remaining)[/]");
}
else
{
AnsiConsole.MarkupLine(
"[dim grey] (compaction skipped — not enough history to compress)[/]");
}
AnsiConsole.WriteLine();
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
AnsiConsole.WriteLine(); HandleCancellation();
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
AnsiConsole.WriteLine();
if (!string.IsNullOrEmpty(fullResponse))
{
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
}
_session.History.Add(new ChatMessage(ChatRole.User,
"[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]"));
} }
catch (Exception ex) catch (Exception ex)
{ {
AnsiConsole.WriteLine(); DisplayError(ex);
AnsiConsole.Write(
new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
.Header("[bold red] Error [/]")
.BorderColor(Color.Red)
.RoundedBorder()
.Padding(1, 0));
AnsiConsole.WriteLine();
} }
finally finally
{ {
@@ -249,4 +82,91 @@ internal sealed class ReplLoop
} }
} }
} }
private async Task ProcessTurnAsync(CancellationToken cancellationToken)
{
using var spinner = new SpinnerService();
spinner.Start(cancellationToken);
// Configure tool callbacks for spinner control and stale result compaction
var originalPause = CommandTool.PauseSpinner;
var originalResume = CommandTool.ResumeSpinner;
var originalOnFileRead = FileTools.OnFileRead;
CommandTool.PauseSpinner = spinner.Pause;
CommandTool.ResumeSpinner = spinner.Resume;
FileTools.OnFileRead = _ =>
{
int n = ContextCompactor.CompactStaleToolResults(_session.History);
if (n > 0)
AnsiConsole.MarkupLine($"[dim grey] ♻ Compacted {n} stale tool result(s)[/]");
};
var responseBuilder = new StringBuilder();
bool firstChunkDisplayed = false;
try
{
await foreach (var chunk in _streamer.StreamAsync(cancellationToken))
{
// Stop spinner before displaying first chunk
if (!firstChunkDisplayed)
{
await spinner.StopAsync();
firstChunkDisplayed = true;
}
AnsiConsole.Markup(Markup.Escape(chunk));
responseBuilder.Append(chunk);
}
}
finally
{
if (!firstChunkDisplayed)
{
await spinner.StopAsync();
}
CommandTool.PauseSpinner = originalPause;
CommandTool.ResumeSpinner = originalResume;
FileTools.OnFileRead = originalOnFileRead;
}
var fullResponse = responseBuilder.ToString();
// Display usage statistics
_usageDisplayer.Display(_streamer.LastInputTokens, _streamer.LastOutputTokens);
_usageDisplayer.DisplaySeparator();
// Add response to history
_session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.Assistant, fullResponse));
// Check for context compaction
await _compactionService.TryCompactAsync();
// Save session after turn completes
await _sessionManager.SaveAfterTurnAsync();
}
private void HandleCancellation()
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
AnsiConsole.WriteLine();
_session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User,
"[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]"));
}
private void DisplayError(Exception ex)
{
AnsiConsole.WriteLine();
AnsiConsole.Write(
new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
.Header("[bold red] Error [/]")
.BorderColor(Color.Red)
.RoundedBorder()
.Padding(1, 0));
AnsiConsole.WriteLine();
}
} }

60
ResponseStreamer.cs Normal file
View File

@@ -0,0 +1,60 @@
using Microsoft.Extensions.AI;
using OpenAI;
namespace AnchorCli;
/// <summary>
/// Handles streaming responses from the chat client, including token usage capture.
/// </summary>
internal sealed class ResponseStreamer
{
private readonly ChatSession _session;
public ResponseStreamer(ChatSession session)
{
_session = session;
}
/// <summary>
/// Streams a response from the session and captures token usage.
/// Returns an async enumerable that yields text chunks as they arrive.
/// </summary>
public async IAsyncEnumerable<string> StreamAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await using var stream = _session
.GetStreamingResponseAsync(cancellationToken)
.GetAsyncEnumerator(cancellationToken);
int respIn = 0, respOut = 0;
void CaptureUsage(ChatResponseUpdate update)
{
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
&& raw.Usage != null)
{
respIn = raw.Usage.InputTokenCount;
respOut += raw.Usage.OutputTokenCount;
}
}
// Stream all chunks
while (await stream.MoveNextAsync())
{
cancellationToken.ThrowIfCancellationRequested();
CaptureUsage(stream.Current);
var text = stream.Current.Text;
if (!string.IsNullOrEmpty(text))
{
yield return text;
}
}
// Store final usage stats
LastInputTokens = respIn;
LastOutputTokens = respOut;
}
public int LastInputTokens { get; private set; }
public int LastOutputTokens { get; private set; }
}

View File

@@ -1,334 +0,0 @@
# 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

83
SessionManager.cs Normal file
View File

@@ -0,0 +1,83 @@
using Spectre.Console;
namespace AnchorCli;
/// <summary>
/// Manages session persistence, including auto-load on startup and auto-save on exit.
/// </summary>
internal sealed class SessionManager
{
private readonly ChatSession _session;
private readonly string _sessionPath;
public SessionManager(ChatSession session, string sessionPath = ".anchor/session.json")
{
_session = session;
_sessionPath = sessionPath;
}
/// <summary>
/// Attempts to load a session from disk. Returns true if successful.
/// </summary>
public async Task<bool> TryLoadAsync(CancellationToken cancellationToken = default)
{
if (!File.Exists(_sessionPath))
{
return false;
}
try
{
await _session.LoadAsync(_sessionPath, cancellationToken);
AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]");
// Print the last message if there is one
if (_session.History.Count > 1)
{
var lastMessage = _session.History[^1];
var preview = lastMessage.Text.Length > 280
? lastMessage.Text[..277] + "..."
: lastMessage.Text;
AnsiConsole.MarkupLine($"[dim grey] Last message: {Markup.Escape(preview)}[/]");
}
return true;
}
catch
{
// Ignore load errors
return false;
}
}
/// <summary>
/// Attempts to save the session to disk. Returns true if successful.
/// </summary>
public async Task<bool> TrySaveAsync(CancellationToken cancellationToken = default)
{
try
{
var directory = Path.GetDirectoryName(_sessionPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await _session.SaveAsync(_sessionPath, cancellationToken);
return true;
}
catch
{
// Ignore save errors
return false;
}
}
/// <summary>
/// Saves the session after an LLM turn completes.
/// </summary>
public async Task SaveAfterTurnAsync(CancellationToken cancellationToken = default)
{
await TrySaveAsync(cancellationToken);
}
}

100
SpinnerService.cs Normal file
View File

@@ -0,0 +1,100 @@
using Spectre.Console;
namespace AnchorCli;
/// <summary>
/// Manages the "thinking" spinner animation during AI response generation.
/// </summary>
internal sealed class SpinnerService : IDisposable
{
private readonly object _consoleLock = new();
private CancellationTokenSource? _spinnerCts;
private Task? _spinnerTask;
private bool _showSpinner = true;
private bool _disposed;
/// <summary>
/// Starts the spinner animation.
/// </summary>
public void Start(CancellationToken cancellationToken)
{
_spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_showSpinner = true;
_spinnerTask = Task.Run(async () =>
{
var frames = Spinner.Known.BouncingBar.Frames;
var interval = Spinner.Known.BouncingBar.Interval;
int i = 0;
Console.Write("\x1b[?25l");
try
{
while (!_spinnerCts.Token.IsCancellationRequested)
{
lock (_consoleLock)
{
if (_showSpinner && !_spinnerCts.Token.IsCancellationRequested)
{
var frame = frames[i % frames.Count];
Console.Write($"\r\x1b[38;5;69m{frame}\x1b[0m Thinking...");
i++;
}
}
try { await Task.Delay(interval, _spinnerCts.Token); } catch { }
}
}
finally
{
lock (_consoleLock)
{
if (_showSpinner)
Console.Write("\r" + new string(' ', 40) + "\r");
Console.Write("\x1b[?25h");
}
}
});
}
/// <summary>
/// Stops the spinner animation and waits for it to complete.
/// </summary>
public async Task StopAsync()
{
_spinnerCts?.Cancel();
if (_spinnerTask != null)
{
await Task.WhenAny(_spinnerTask);
}
}
/// <summary>
/// Pauses the spinner (e.g., during tool execution).
/// </summary>
public void Pause()
{
lock (_consoleLock)
{
_showSpinner = false;
Console.Write("\r" + new string(' ', 40) + "\r");
}
}
/// <summary>
/// Resumes the spinner after being paused.
/// </summary>
public void Resume()
{
lock (_consoleLock)
{
_showSpinner = true;
}
}
public void Dispose()
{
if (_disposed) return;
_spinnerCts?.Dispose();
_disposed = true;
}
}

View File

@@ -9,26 +9,22 @@ internal static class ToolRegistry
{ {
var jsonOptions = AppJsonContext.Default.Options; var jsonOptions = AppJsonContext.Default.Options;
return new List<AITool> return
{ [
AIFunctionFactory.Create(FileTools.ReadFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.ReadFile, name: "read_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.GrepFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.Grep, name: "grep", serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.ListDir, name: "list_dir", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.ReplaceLines, name: "replace_lines", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.InsertAfter, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.DeleteRange, name: "delete_range", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.BatchEdit, name: "batch_edit", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.CreateFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.Delete, name: "delete_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.DeleteFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.FindFiles, name: "find_files", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.RenameFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.GetFileInfo, name: "get_file_info", serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.CopyFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.WriteToFile, name: "write_to_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(CommandTool.ExecuteCommand, name: "execute_command", serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.MoveFile, name: "rename_file", serializerOptions: jsonOptions),
AIFunctionFactory.Create(DirTools.DeleteDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(DirTools.RenameDir, name: "rename_dir", serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions), AIFunctionFactory.Create(DirTools.CreateDir, name: "create_dir", serializerOptions: jsonOptions),
AIFunctionFactory.Create(FileTools.GrepRecursive, serializerOptions: jsonOptions), ];
AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions),
AIFunctionFactory.Create(EditTools.AppendToFile, serializerOptions: jsonOptions),
AIFunctionFactory.Create(CommandTool.ExecuteCommand, serializerOptions: jsonOptions),
};
} }
} }

View File

@@ -21,7 +21,7 @@ internal static class CommandTool
public static string ExecuteCommand( public static string ExecuteCommand(
[Description("The shell command to execute.")] string command) [Description("The shell command to execute.")] string command)
{ {
Log($"Command request: {command}"); Log($" ● execute_command: {command}");
// Prompt for user approval // Prompt for user approval
PauseSpinner?.Invoke(); PauseSpinner?.Invoke();

View File

@@ -17,7 +17,7 @@ internal static class DirTools
{ {
sourcePath = ResolvePath(sourcePath); sourcePath = ResolvePath(sourcePath);
destinationPath = ResolvePath(destinationPath); destinationPath = ResolvePath(destinationPath);
Log($"Renaming/moving directory: {sourcePath} -> {destinationPath}"); Log($" ● rename_dir: {sourcePath} -> {destinationPath}");
if (!Directory.Exists(sourcePath)) if (!Directory.Exists(sourcePath))
return $"ERROR: Directory not found: {sourcePath}"; return $"ERROR: Directory not found: {sourcePath}";
@@ -39,33 +39,12 @@ internal static class DirTools
} }
} }
[Description("Delete a directory and all its contents permanently.")]
public static string DeleteDir(
[Description("Path to the directory to delete.")] string path,
[Description("If true, delete recursively. Defaults to true.")] bool recursive = true)
{
path = ResolvePath(path);
Log($"Deleting directory: {path}");
if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}";
try
{
Directory.Delete(path, recursive);
return $"OK: Directory deleted: '{path}'";
}
catch (Exception ex)
{
return $"ERROR deleting directory '{path}': {ex.Message}";
}
}
[Description("Create a new directory. Creates parent directories if they don't exist. Returns OK on success, or an error message if the directory already exists or creation fails.")] [Description("Create a new directory. Creates parent directories if they don't exist. Returns OK on success, or an error message if the directory already exists or creation fails.")]
public static string CreateDir( public static string CreateDir(
[Description("Path to the directory to create.")] string path) [Description("Path to the directory to create.")] string path)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Creating directory: {path}"); Log($" ● create_dir: {path}");
if (Directory.Exists(path)) if (Directory.Exists(path))
return $"ERROR: Directory already exists: {path}"; return $"ERROR: Directory already exists: {path}";

View File

@@ -4,6 +4,15 @@ using AnchorCli.Hashline;
namespace AnchorCli.Tools; namespace AnchorCli.Tools;
/// <summary>
/// Represents a single operation within a batch edit.
public record BatchOperation(
[property: Description("Operation type: 'replace' or 'delete'")] string Type,
[property: Description("First line's line:hash anchor (e.g. '5:a3')")] string? StartAnchor,
[property: Description("Last line's line:hash anchor (e.g. '8:d4')")] string? EndAnchor,
[property: Description("Text content to insert. Required for 'replace' operations.")] string[]? Content);
/// <summary> /// <summary>
/// Mutating file tools exposed to the LLM as AIFunctions. /// Mutating file tools exposed to the LLM as AIFunctions.
/// Every operation validates Hashline anchors (line:hash format) before touching the file. /// Every operation validates Hashline anchors (line:hash format) before touching the file.
@@ -61,12 +70,11 @@ internal static partial class EditTools
{ {
newLines = SanitizeNewLines(newLines); newLines = SanitizeNewLines(newLines);
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"REPLACE_LINES: {path}"); Log($" ● replace_lines: {path}");
Log($" Range: {startAnchor} -> {endAnchor}"); Log($" {startAnchor.Split(':')[0]}-{endAnchor.Split(':')[0]} lines -> {newLines.Length} new lines");
Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}\n Check the correct path and try again.";
try try
{ {
@@ -74,7 +82,7 @@ internal static partial class EditTools
if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines, if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines,
out int startIdx, out int endIdx, out string error)) out int startIdx, out int endIdx, out string error))
return $"ERROR: {error}"; return $"ERROR: Anchor validation failed\n{error}";
var result = new List<string>(lines.Length - (endIdx - startIdx + 1) + newLines.Length); var result = new List<string>(lines.Length - (endIdx - startIdx + 1) + newLines.Length);
result.AddRange(lines[..startIdx]); result.AddRange(lines[..startIdx]);
@@ -86,48 +94,10 @@ internal static partial class EditTools
} }
catch (Exception ex) catch (Exception ex)
{ {
return $"ERROR modifying '{path}': {ex.Message}"; return $"ERROR modifying '{path}': {ex.Message}.\nThis is a bug. Tell the user about it.";
} }
} }
[Description("Insert lines after the specified line:hash anchor.")]
public static string InsertAfter(
[Description("Path to the file.")] string path,
[Description("line:hash anchor to insert after (e.g. '3:0e').")] string anchor,
[Description("Raw source code to insert. Do NOT include 'lineNumber:hash|' prefixes.")] string[] newLines)
{
newLines = SanitizeNewLines(newLines);
path = FileTools.ResolvePath(path);
Log($"INSERT_AFTER: {path}");
Log($" Anchor: {anchor}");
Log($" Inserting {newLines.Length} lines after line {anchor.Split(':')[0]}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
string[] lines = File.ReadAllLines(path);
if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error))
return $"ERROR: {error}";
var result = new List<string>(lines.Length + newLines.Length);
result.AddRange(lines[..(idx + 1)]);
result.AddRange(newLines);
result.AddRange(lines[(idx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
}
catch (Exception ex)
{
return $"ERROR modifying '{path}': {ex.Message}";
}
}
[Description("Delete a range of lines.")] [Description("Delete a range of lines.")]
public static string DeleteRange( public static string DeleteRange(
@@ -136,7 +106,8 @@ internal static partial class EditTools
[Description("Last line's line:hash anchor (e.g. '6:19').")] string endAnchor) [Description("Last line's line:hash anchor (e.g. '6:19').")] string endAnchor)
{ {
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"Deleting lines in file: {path}"); Log($" ● delete_range: {path}");
Log($" {startAnchor.Split(':')[0]}-{endAnchor.Split(':')[0]} lines");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";
@@ -158,157 +129,261 @@ internal static partial class EditTools
} }
catch (Exception ex) catch (Exception ex)
{ {
return $"ERROR modifying '{path}': {ex.Message}"; return $"ERROR modifying '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
} }
} }
[Description("Create a new file (parents auto-created). Max initial lines: 200. Alternatively, append lines later.")]
public static string CreateFile(
[Description("Path to the new file to create.")] string path, [Description("Delete a file or directory. Use mode='file' to delete a file, mode='dir' to delete a directory.")]
[Description("Optional initial raw source code. Do NOT include 'lineNumber:hash|' prefixes.")] string[]? initialLines = null) public static string Delete(
[Description("Path to the file or directory to delete.")] string path,
[Description("Type of deletion: 'file' or 'dir'. Defaults to 'file'.")] string mode = "file")
{ {
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"Creating file: {path}"); string targetType = mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase) ? "directory" : "file";
Log($" ● delete_{targetType}: {path}");
if (File.Exists(path)) if (mode.Equals("dir", StringComparison.CurrentCultureIgnoreCase))
return $"ERROR: File already exists: {path}"; {
if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}";
try
{
Directory.Delete(path, true);
return $"OK: Directory deleted: '{path}'";
}
catch (Exception ex)
{
return $"ERROR deleting directory '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
}
}
else
{
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
try
{
File.Delete(path);
return $"OK (deleted)";
}
catch (Exception ex)
{
return $"ERROR deleting '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
}
}
}
[Description("Move or copy a file to a new location.")]
public static string MoveFile(
[Description("Current path to the file.")] string sourcePath,
[Description("New path for the file.")] string destinationPath,
[Description("If true, copy the file instead of moving it. Defaults to false.")] bool copy = false)
{
sourcePath = FileTools.ResolvePath(sourcePath);
destinationPath = FileTools.ResolvePath(destinationPath);
string action = copy ? "copy" : "move";
Log($" ● {action}_file: {sourcePath} -> {destinationPath}");
if (!File.Exists(sourcePath))
return $"ERROR: Source file not found: {sourcePath}";
if (File.Exists(destinationPath))
return $"ERROR: Destination file already exists: {destinationPath}";
try
{
string? dir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
if (copy)
File.Copy(sourcePath, destinationPath);
else
File.Move(sourcePath, destinationPath);
return copy ? $"OK (copied to {destinationPath})" : $"OK (moved to {destinationPath})";
}
catch (Exception ex)
{
return $"ERROR {action.ToLower()} file: {ex.Message}\nThis is a bug. Tell the user about it.";
}
}
[Description("Write to a file with different modes: create, append, or insert.")]
public static string WriteToFile(
[Description("Path to the file.")] string path,
[Description("Content to write.")] string[] content,
[Description("Write mode: 'create' (error if exists), 'append' (creates if missing), 'insert' (requires anchor)")] string mode = "create",
[Description("line:hash anchor to insert after (required for mode='insert', e.g. '3:0e').")] string? anchor = null)
{
content = SanitizeNewLines(content);
path = FileTools.ResolvePath(path);
Log($" ● write_to_file: {path}");
Log($" mode: {mode} with {content.Length} lines");
try try
{ {
if (initialLines is not null)
initialLines = SanitizeNewLines(initialLines);
string? dir = Path.GetDirectoryName(path); string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
if (initialLines is not null && initialLines.Length > 0) switch (mode.ToLower())
File.WriteAllLines(path, initialLines); {
else case "create":
File.WriteAllText(path, ""); if (File.Exists(path))
return $"ERROR: File already exists: {path}";
return $"OK fp:{HashlineEncoder.FileFingerprint(initialLines ?? [])}"; if (content.Length > 0)
File.WriteAllLines(path, content);
else
File.WriteAllText(path, "");
return $"OK fp:{HashlineEncoder.FileFingerprint(content)}";
case "append":
if (!File.Exists(path))
{
File.WriteAllText(path, "");
Log($" (created new file)");
}
using (var writer = new System.IO.StreamWriter(path, true))
{
foreach (var line in content)
{
writer.WriteLine(line);
}
}
string[] appendedLines = File.ReadAllLines(path);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. appendedLines])}";
case "insert":
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
if (string.IsNullOrEmpty(anchor))
return "ERROR: mode='insert' requires an anchor parameter";
string[] lines = File.ReadAllLines(path);
if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error))
return $"ERROR: {error}";
var result = new List<string>(lines.Length + content.Length);
result.AddRange(lines[..(idx + 1)]);
result.AddRange(content);
result.AddRange(lines[(idx + 1)..]);
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
default:
return $"ERROR: Unknown mode '{mode}'. Valid modes: create, append, insert";
}
} }
catch (Exception ex) catch (Exception ex)
{ {
return $"ERROR creating '{path}': {ex.Message}"; return $"ERROR writing to '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
} }
} }
[Description("Delete a file permanently.")] [Description("Atomically apply multiple replace/delete operations to a file. All anchors validated upfront against original content. Operations auto-sorted bottom-to-top to prevent line drift. Prefer over individual calls when making multiple edits.")]
public static string DeleteFile( public static string BatchEdit(
[Description("Path to the file to delete.")] string path) [Description("Path to the file.")] string path,
[Description("Array of operations to apply. Operations are applied in bottom-to-top order automatically.")] BatchOperation[] operations)
{ {
path = FileTools.ResolvePath(path); path = FileTools.ResolvePath(path);
Log($"Deleting file: {path}"); Log($" ● batch_edit: {path}");
Log($" operations: {operations.Length}");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";
try if (operations.Length == 0)
{ return "ERROR: No operations provided";
File.Delete(path);
return $"OK (deleted)";
}
catch (Exception ex)
{
return $"ERROR deleting '{path}': {ex.Message}";
}
}
[Description("Rename or move a file. Auto-creates target dirs.")]
public static string RenameFile(
[Description("Current path to the file.")] string sourcePath,
[Description("New path for the file.")] string destinationPath)
{
sourcePath = FileTools.ResolvePath(sourcePath);
destinationPath = FileTools.ResolvePath(destinationPath);
Log($"Renaming file: {sourcePath} -> {destinationPath}");
if (!File.Exists(sourcePath))
return $"ERROR: Source file not found: {sourcePath}";
if (File.Exists(destinationPath))
return $"ERROR: Destination file already exists: {destinationPath}";
try try
{ {
string? dir = Path.GetDirectoryName(destinationPath); // Read file once
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) string[] lines = File.ReadAllLines(path);
Directory.CreateDirectory(dir);
File.Move(sourcePath, destinationPath); // Pre-validate all anchors against original content (fail-fast)
return $"OK (moved to {destinationPath})"; var resolvedOps = new List<(int StartIdx, int EndIdx, BatchOperation Op)>();
} for (int i = 0; i < operations.Length; i++)
catch (Exception ex)
{
return $"ERROR moving file: {ex.Message}";
}
}
[Description("Copy a file to a new location.")]
public static string CopyFile(
[Description("Path to the existing file.")] string sourcePath,
[Description("Path for the copy.")] string destinationPath)
{
sourcePath = FileTools.ResolvePath(sourcePath);
destinationPath = FileTools.ResolvePath(destinationPath);
Log($"Copying file: {sourcePath} -> {destinationPath}");
if (!File.Exists(sourcePath))
return $"ERROR: Source file not found: {sourcePath}";
if (File.Exists(destinationPath))
return $"ERROR: Destination file already exists: {destinationPath}";
try
{
string? dir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.Copy(sourcePath, destinationPath);
return $"OK (copied to {destinationPath})";
}
catch (Exception ex)
{
return $"ERROR copying file: {ex.Message}";
}
}
[Description("Append lines to EOF (auto-creating the file if missing).")]
public static string AppendToFile(
[Description("Path to the file to append to.")] string path,
[Description("Raw source code to append. Do NOT include 'lineNumber:hash|' prefixes.")] string[] lines)
{
lines = SanitizeNewLines(lines);
path = FileTools.ResolvePath(path);
Log($"Appending to file: {path}");
Log($" Appending {lines.Length} lines");
try
{
string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
if (!File.Exists(path))
{ {
File.WriteAllText(path, ""); var op = operations[i];
Log($" (created new file)");
if (string.IsNullOrWhiteSpace(op.Type))
return $"ERROR: Operation {i}: Type is required (use 'replace' or 'delete')";
var opType = op.Type.ToLowerInvariant();
if (opType != "replace" && opType != "delete")
return $"ERROR: Operation {i}: Invalid type '{op.Type}'. Must be 'replace' or 'delete'";
if (opType == "replace" && op.Content == null)
return $"ERROR: Operation {i}: 'replace' requires Content";
if (string.IsNullOrEmpty(op.StartAnchor) || string.IsNullOrEmpty(op.EndAnchor))
return $"ERROR: Operation {i}: StartAnchor and EndAnchor are required";
if (!HashlineValidator.TryResolveRange(op.StartAnchor, op.EndAnchor, lines,
out int startIdx, out int endIdx, out string error))
return $"ERROR: Operation {i}: Anchor validation failed\n{error}";
resolvedOps.Add((startIdx, endIdx, op));
} }
using (var writer = new System.IO.StreamWriter(path, true)) // Check for overlapping ranges (conflicting operations)
for (int i = 0; i < resolvedOps.Count; i++)
{ {
foreach (var line in lines) for (int j = i + 1; j < resolvedOps.Count; j++)
{ {
writer.WriteLine(line); var (startA, endA, _) = resolvedOps[i];
var (startB, endB, _) = resolvedOps[j];
if (!(endA < startB || endB < startA))
return $"ERROR: Operations {i} and {j} have overlapping ranges. " +
$"Range [{startA}-{endA}] overlaps with [{startB}-{endB}].";
} }
} }
string[] allLines = File.ReadAllLines(path); // Sort operations top-to-bottom (by start index ascending) because we build a new list sequentially
return $"OK fp:{HashlineEncoder.FileFingerprint([.. allLines])}"; var sortedOps = resolvedOps.OrderBy(x => x.StartIdx).ToList();
// Apply all operations to a single buffer
var result = new List<string>(lines.Length);
int nextLineIdx = 0;
foreach (var (startIdx, endIdx, op) in sortedOps)
{
// Copy lines before this operation
if (startIdx > nextLineIdx)
result.AddRange(lines[nextLineIdx..startIdx]);
// Apply operation
var opType = op.Type.ToLowerInvariant();
if (opType == "replace")
result.AddRange(SanitizeNewLines(op.Content!));
// delete: don't add anything (skip the range)
nextLineIdx = endIdx + 1;
}
// Copy remaining lines after the last operation
if (nextLineIdx < lines.Length)
result.AddRange(lines[nextLineIdx..]);
// Write file once
File.WriteAllLines(path, result);
return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}";
} }
catch (Exception ex) catch (Exception ex)
{ {
return $"ERROR appending to '{path}': {ex.Message}"; return $"ERROR batch editing '{path}': {ex.Message}\nThis is a bug. Tell the user about it.";
} }
} }

View File

@@ -27,7 +27,7 @@ internal static class FileTools
[Description("Last line to return (inclusive). Use 0 for EOF. Defaults to 0.")] int endLine = 0) [Description("Last line to return (inclusive). Use 0 for EOF. Defaults to 0.")] int endLine = 0)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Reading file: {path} {startLine}:{endLine}L"); Log($" ● read_file: {path} {startLine}:{endLine}L");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";
@@ -56,61 +56,13 @@ internal static class FileTools
} }
} }
[Description("Search a file for a regex pattern. Returns matches with line:hash| anchors.")]
public static string GrepFile(
[Description("Path to the file to search.")] string path,
[Description("Regex pattern.")] string pattern)
{
path = ResolvePath(path);
Log($"Searching file: {path}");
if (!File.Exists(path))
return $"ERROR: File not found: {path}";
Regex regex;
try
{
regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
catch (Exception ex)
{
return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}";
}
try
{
string[] lines = File.ReadAllLines(path);
var sb = new System.Text.StringBuilder();
int matchCount = 0;
for (int i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
{
int lineNumber = i + 1;
string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber);
sb.Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]);
matchCount++;
}
}
if (matchCount == 0)
return $"(no matches for '{pattern}' in {path})";
return sb.ToString();
}
catch (Exception ex)
{
return $"ERROR searching '{path}': {ex.Message}";
}
}
[Description("List files and subdirectories.")] [Description("List files and subdirectories.")]
public static string ListDir( public static string ListDir(
[Description("Path to the directory.")] string path = ".") [Description("Path to the directory.")] string path = ".")
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Listing directory: {path}"); Log($" ● list_dir: {path}");
if (!Directory.Exists(path)) if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}"; return $"ERROR: Directory not found: {path}";
@@ -143,7 +95,7 @@ internal static class FileTools
[Description("Glob pattern (supports * and **).")] string pattern) [Description("Glob pattern (supports * and **).")] string pattern)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Finding files: {pattern} in {path}"); Log($" ● find_files: {pattern} in {path}");
if (!Directory.Exists(path)) if (!Directory.Exists(path))
return $"ERROR: Directory not found: {path}"; return $"ERROR: Directory not found: {path}";
@@ -174,75 +126,136 @@ internal static class FileTools
} }
} }
[Description("Recursive regex search across all files. Returns matches with file:line:hash| format.")]
public static string GrepRecursive( [Description("Consolidated grep operation for single file or recursive directory search.")]
[Description("Directory to search.")] string path, public static string Grep(
[Description("Directory to search (for recursive mode) or file path (for file mode).")] string path,
[Description("Regex pattern.")] string pattern, [Description("Regex pattern.")] string pattern,
[Description("Optional glob to filter files (e.g. '*.cs').")] string? filePattern = null) [Description("Mode: 'file' for single file, 'recursive' for directory search.")] string mode = "recursive",
[Description("Optional glob to filter files in recursive mode (e.g. '*.cs').")] string? filePattern = null)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Recursive grep: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : "")); mode = mode.ToLowerInvariant();
if (!Directory.Exists(path)) if (mode == "file")
return $"ERROR: Directory not found: {path}";
Regex regex;
try
{ {
regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); Log($" ● grep_file: {path}");
}
catch (Exception ex)
{
return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}";
}
try if (!File.Exists(path))
{ if (Directory.Exists(path))
string globPattern = filePattern?.Replace("**/", "") ?? "*"; return $"ERROR: {path} is a directory, not a file.";
var sb = new System.Text.StringBuilder(); else
int totalMatches = 0; return $"ERROR: File not found: {path}";
foreach (var file in EnumerateFilesRecursive(path, globPattern)) Regex regex;
try
{ {
try regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
{ }
// Skip binary files: check first 512 bytes for null chars catch (Exception ex)
using var probe = new StreamReader(file); {
var buf = new char[512]; return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}";
int read = probe.Read(buf, 0, buf.Length);
if (new ReadOnlySpan<char>(buf, 0, read).Contains('\0'))
continue;
}
catch { continue; }
try
{
string[] lines = File.ReadAllLines(file);
for (int i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
{
int lineNumber = i + 1;
string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber);
sb.Append(file).Append(':').Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]);
totalMatches++;
}
}
}
catch
{
// Skip files that can't be read
}
} }
if (totalMatches == 0) try
return $"(no matches for '{pattern}' in {path})"; {
string[] lines = File.ReadAllLines(path);
var sb = new System.Text.StringBuilder();
int matchCount = 0;
return $"Found {totalMatches} match(es):\n" + sb.ToString(); for (int i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
{
int lineNumber = i + 1;
string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber);
sb.Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]);
matchCount++;
}
}
if (matchCount == 0)
return $"(no matches for '{pattern}' in {path})";
return sb.ToString();
}
catch (Exception ex)
{
return $"ERROR searching '{path}': {ex.Message}\nThis is a bug, tell the user about it.";
}
} }
catch (Exception ex) else if (mode == "recursive")
{ {
return $"ERROR in recursive grep: {ex.Message}"; Log($" ● grep_recursive: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : ""));
if (!Directory.Exists(path))
if (File.Exists(path))
return $"ERROR: {path} is a file, not a directory.";
else
return $"ERROR: Directory not found: {path}";
Regex regex;
try
{
regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
catch (Exception ex)
{
return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}";
}
try
{
string globPattern = filePattern?.Replace("**/", "") ?? "*";
var sb = new System.Text.StringBuilder();
int totalMatches = 0;
foreach (var file in EnumerateFilesRecursive(path, globPattern))
{
try
{
// Skip binary files: check first 512 bytes for null chars
using var probe = new StreamReader(file);
var buf = new char[512];
int read = probe.Read(buf, 0, buf.Length);
if (new ReadOnlySpan<char>(buf, 0, read).Contains('\0'))
continue;
}
catch { continue; }
try
{
string[] lines = File.ReadAllLines(file);
for (int i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
{
int lineNumber = i + 1;
string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber);
sb.Append(file).Append(':').Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]);
totalMatches++;
}
}
}
catch
{
// Skip files that can't be read
}
}
if (totalMatches == 0)
return $"(no matches for '{pattern}' in {path})";
return $"Found {totalMatches} match(es):\n" + sb.ToString();
}
catch (Exception ex)
{
return $"ERROR in recursive grep: {ex.Message}.\nThis is a bug, tell the user about it.";
}
}
else
{
return $"ERROR: Invalid mode '{mode}'. Use 'file' or 'recursive'.";
} }
} }
@@ -284,7 +297,7 @@ internal static class FileTools
[Description("Path to the file.")] string path) [Description("Path to the file.")] string path)
{ {
path = ResolvePath(path); path = ResolvePath(path);
Log($"Getting file info: {path}"); Log($" ● get_file_info: {path}");
if (!File.Exists(path)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";

48
UsageDisplayer.cs Normal file
View File

@@ -0,0 +1,48 @@
using Spectre.Console;
namespace AnchorCli.OpenRouter;
/// <summary>
/// Displays token usage and cost information to the console.
/// </summary>
internal sealed class UsageDisplayer
{
private readonly TokenTracker _tokenTracker;
public UsageDisplayer(TokenTracker tokenTracker)
{
_tokenTracker = tokenTracker;
}
/// <summary>
/// Displays the usage statistics for a single response.
/// </summary>
public void Display(int inputTokens, int outputTokens)
{
if (inputTokens > 0 || outputTokens > 0)
{
_tokenTracker.AddUsage(inputTokens, outputTokens);
var cost = _tokenTracker.CalculateCost(inputTokens, outputTokens);
var ctxPct = _tokenTracker.ContextUsagePercent;
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine(
$"[dim grey] {TokenTracker.FormatTokens(inputTokens)}↑ {TokenTracker.FormatTokens(outputTokens)}↓" +
$" {TokenTracker.FormatCost(cost)}" +
(ctxPct >= 0 ? $" ctx:{ctxPct:F0}%" : "") +
$" │ session: {TokenTracker.FormatCost(_tokenTracker.SessionCost)}[/]");
}
else
{
AnsiConsole.WriteLine();
}
}
/// <summary>
/// Displays a rule separator.
/// </summary>
public void DisplaySeparator()
{
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
}
}

File diff suppressed because it is too large Load Diff

36
docs/COMMANDS.md Normal file
View File

@@ -0,0 +1,36 @@
# Slash Commands
AnchorCli provides several slash commands for managing your session and interacting with the AI.
## Available Commands
| Command | Description |
|---------|-------------|
| `/setup` | Run interactive TUI to configure API key and model (also accessible via `anchor setup` subcommand) |
| `/help` | Show available tools and commands |
| `/exit` | Exit the application |
| `/clear` | Clear the conversation history |
| `/status` | Show session token usage and cost |
| `/compact` | Manually trigger context compaction |
| `/reset` | Clear session and reset token tracker |
| `/load` | Load a previous session from disk |
| `/save` | Save current session to disk |
## Usage
Type any command starting with `/` in the REPL to execute it:
```
> /status
Session: 1,234 tokens used ($0.0015)
```
```
> /help
Available tools: read_file, grep_file, replace_lines, ...
Available commands: /setup, /help, /exit, /clear, ...
```
---
*Back to [README.md](../README.md)*

55
docs/HASHLINE.md Normal file
View File

@@ -0,0 +1,55 @@
# The Hashline Technique
Hashline is AnchorCli's unique approach to safe, precise file editing.
## How It Works
Every line returned by file tools is prefixed with a content-derived hash anchor:
```
function hello() {
return "world";
}
```
The format is `lineNumber:hash|content` where:
- `lineNumber` is the current line number in the file
- `hash` is an Adler-8 checksum derived from the line content and position
- `content` is the actual line text
## Editing with Anchors
When editing, you reference these `line:hash` anchors instead of reproducing old content. Before any mutation, both the line number **and** hash are validated — stale anchors are rejected immediately.
### Example
To replace lines 2-3 from the example above:
```json
{
"tool": "replace_lines",
"path": "example.js",
"startAnchor": "2:f1",
"endAnchor": "3:0e",
"newLines": [" return \"hello world\";"]
}
```
## Benefits
This eliminates:
- **Whitespace/indentation reproduction errors** — No need to match exact spacing
- **Silent edits to the wrong line** — Hash validation ensures you're editing the right content
- **Entire-file rewrites** — Change just one line without touching the rest
- **Line drift in batch operations** — BatchEdit validates all anchors upfront and applies changes bottom-to-top
## Key Principles
1. **Never include anchors in your content** — When using `replace_lines`, `insert_after`, or similar tools, the `newLines` parameter should contain raw source code only, WITHOUT the `lineNumber:hash|` prefix
2. **Always validate before editing** — Re-read files to get fresh anchors if your previous anchors fail validation
3. **Use BatchEdit for multiple changes** — This validates all anchors upfront and applies operations atomically
---
*Back to [README.md](../README.md)* | *See [TOOLS.md](TOOLS.md) for all available tools*

View File

@@ -1,134 +0,0 @@
# Advanced AI Agent CLI System Design
This document outlines the architecture for a completely new, built-from-scratch AI Agent Command Line Interface system, inspired by the lessons learned from the `Anchor CLI` refactoring.
## 1. Core Principles
* **Event-Driven UI & Decoupled State:** The UI and display layers communicate exclusively through an asynchronous Event Bus.
* **Explicit Control Flow:** Core agent execution utilizes a Mediator pattern (Request/Response) for predictable, traceable control flow rather than pure event spaghetti.
* **Dependency Injection:** A robust IoC container manages lifecycles and dependencies.
* **Pluggable Architecture:** Everything—from the LLM provider to the UI renderer and memory storage—is an injectable plugin.
* **Stateless Components:** Services maintain minimal internal state. State is managed centrally in a session or context store with immutable snapshots.
* **Test-First Design:** Complete absence of static delegates and global mutable state ensures every component is unit-testable in isolation.
* **Pervasive Cancellation:** Every asynchronous operation accepts a `CancellationToken` for graceful termination.
## 2. High-Level Architecture & Project Structure (AOT-Ready)
The system is structurally divided into three distinct C# projects to enforce decoupling, testability, and future-proof design, while maintaining strict compatibility with **.NET Native AOT** compilation for single-file, zero-dependency distribution on Linux/Windows.
### 2.1 Project: `Anchor.AgentFramework` (Class Library)
The core logic and abstractions. It has **no knowledge** of the console, the file system, or specific LLM SDKs.
* **Contains:** Interfaces (`IEventBus`, `IMediator`, `IAgentAvatar`), Memory Management (`ISessionManager`), Execution Loop (`ChatCoordinator`), and the `ToolRunner`.
* **Responsibilities:** Orchestrating the agent's thought process, managing state, and firing events.
### 2.2 Project: `Anchor.Providers` (Class Library)
The vendor-specific implementations for Language Models.
* **Contains:** `OpenAIAvatar`, `AnthropicAvatar`.
* **Responsibilities:** Translating the framework's semantic requests into vendor-specific API calls (e.g., mapping `ToolResult` to OpenAI's tool response format) via SDKs like `Azure.AI.OpenAI`.
### 2.3 Project: `Anchor.Cli` (Console Application)
The "Hosting Shell" and the physical "Senses/Hands" of the application.
* **Contains:** `Program.cs` (Composition Root), `RichConsoleRenderer`, `ConsoleInputDispatcher`, and concrete Tool implementations (e.g., `FileSystemTool`, `CmdTool`).
* **Responsibilities:** Wiring up Dependency Injection, reading from stdin, rendering UI/spinners to stdout, and executing side-effects on the host OS.
### 2.4 Logical Layers
Across these projects, the system operates in five primary layers:
1. **Hosting & Lifecycle (The Host)**
2. **Event & Messaging Backbone (The Bus)**
3. **State & Memory Management (The Brain)**
4. **I/O & User Interface (The Senses & Voice)**
5. **Execution & Tooling (The Hands)**
### 2.5 Dependency Injection Graph
```text
Anchor.Cli (Composition Root - Program.cs)
├── IEventBus → AsyncEventBus
├── IMemoryStore → VectorMemoryStore / SQLiteMemoryStore
├── ISessionManager → ContextAwareSessionManager
│ └── ICompactionStrategy → SemanticCompactionStrategy
├── IUserInputDispatcher → ConsoleInputDispatcher
├── ICommandRegistry → DynamicCommandRegistry
├── IAgentAvatar (LLM Interface) → AnthropicAvatar / OpenAIAvatar
├── IResponseStreamer → TokenAwareResponseStreamer
├── IUiRenderer → RichConsoleRenderer
│ ├── ISpinnerManager → AsyncSpinnerManager
│ └── IStreamingRenderer → ConsoleStreamingRenderer
└── IToolRegistry → DynamicToolRegistry
└── (Injected Tools: FileSystemTool, CmdTool, WebSearchTool)
```
## 3. Component Details
### 3.1 The Messaging Backbone: `IEventBus` and `IMediator` (AOT Safe)
The system utilizes a dual-messaging approach to prevent "event spaghetti":
* **Publish-Subscribe (Events):** Used for things that *happened* and might have multiple or zero listeners (e.g., UI updates, diagnostics).
* `EventBus.PublishAsync(EventBase @event)`
* **Request-Response (Commands):** Used for linear, required actions with a return value.
* `Mediator.Send(IRequest<TResponse> request)`
> [!WARNING]
> Standard `MediatR` relies heavily on runtime reflection for handler discovery, making it **incompatible with Native AOT**. We must use an AOT-safe source-generated alternative, such as the [Mediator](https://github.com/martinothamar/Mediator) library, or implement a simple, source-generated Event/Command bus internally.
**Key Events (Pub/Sub):**
* `UserInputReceived`: Triggered when the user hits Enter.
* `LLMStreamDeltaReceived`: Emitted for token-by-token streaming to the UI.
* `ToolExecutionStarted` / `ToolExecutionCompleted`: Emitted for UI spinners and logging.
* `ContextLimitWarning`: High token usage indicator.
**Key Commands (Request/Response):**
* `ExecuteToolCommand`: Sent from the Avatar to the Tool Runner, returns a `ToolResult`.
### 3.2 The Brain: `ISessionManager` & Memory
Instead of just a simple list of messages, the new system uses a multi-tiered memory architecture with thread-safe access.
* **Short-Term Memory (Context Window):** The active conversation. Must yield **Immutable Context Snapshots** to prevent collection modification exceptions when tools/LLM run concurrently with background tasks.
* **Long-Term Memory (Vector DB):** Indexed facts, summaries, and user preferences.
* **ICompactionStrategy:**
Instead of implicitly using an LLM on the critical path, the system uses tiered, deterministic strategies:
1. **Sliding Window:** Automatically drop the oldest user/assistant message pairs.
2. **Tool Output Truncation:** Remove large file reads from old turns.
3. **LLM Summarization (Optional):** As a last resort, explicitly lock state and summarize old context into a "Context Digest".
### 3.3 The Senses & Voice: Event-Driven CLI UI
The UI is strictly separated from business logic, which is an ideal architecture for a dedicated CLI tool. The `RichConsoleRenderer` only listens to the `IEventBus`.
* **Input Loop:** `IUserInputDispatcher` sits in a loop reading stdin. When input is received, it fires `UserInputReceived`. It captures `Ctrl+C` to trigger a global `CancellationToken`.
* **Output Loop:** `IUiRenderer` subscribes to `LLMStreamDeltaReceived` and renders tokens. It subscribes to `ToolExecutionStarted` and spins up a dedicated UI spinner, preventing async console output from overwriting the active prompt.
* **Headless CLI Mode:** For CI/CD environments or scripting, the system can run non-interactively by simply swapping the `RichConsoleRenderer` with a `BasicLoggingRenderer`—the core agent logic remains untouched.
### 3.4 The Hands: Plugins and Tooling
Tools are no longer hardcoded.
* **IToolRegistry:** Discovers tools at startup via Reflection or Assembly Scanning.
* **Tool Execution:** When the LLM API returns a `tool_calls` stop reason, the `IAgentAvatar` iteratively or concurrently sends an `ExecuteToolCommand` via the Mediator. It directly awaits the results, appends them to the context snapshot, and resumes the LLM generation. This provides explicit, traceable control flow.
* **Cancellation:** Every async method across the entire system accepts a `CancellationToken` to allow graceful termination of infinite loops or runaway processes.
## 4. Execution Flow (Anatomy of a User Turn)
1. **Input:** User types "Find the bug in main.py".
2. **Dispatch:** `ConsoleInputDispatcher` reads it and publishes `UserInputReceived`.
3. **Routing:** Built-in command handler (if applicable) checks if it's a structural command (`/clear`, `/exit`). Otherwise `SessionManager` adds it to the active context.
4. **Inference:** A `ChatCoordinator` service reacts to the updated context and asks the `IAgentAvatar` for a response.
5. **Streaming:** The Avatar calls the Anthropic/OpenAI API. As tokens arrive, it publishes `LLMStreamDeltaReceived`.
6. **Rendering:** `RichConsoleRenderer` receives the deltas and prints them to the terminal.
7. **Tool Request:** The LLM API returns a tool call. The Avatar dispatches an `ExecuteToolCommand` via the Mediator. The EventBus also publishes a `ToolExecutionStarted` event for the UI spinner.
8. **Execution & Feedback:** `ToolRunner` handles the command, runs it safely with the `CancellationToken`, and returns the result back to the Avatar. The Avatar feeds this back to the LLM API automatically.
9. **Completion:** The turn ends. The `SessionManager` checks token bounds and runs compaction if necessary.
## 5. Conclusion (Native AOT Focus)
While `ARCHITECTURE_REFACTOR.md` focuses on migrating a legacy "God Class", this new design assumes a green-field, **AOT-first** approach.
To achieve true Native AOT, we must strictly avoid runtime reflection. This means:
1. Using `CreateSlimBuilder()` instead of `CreateDefaultBuilder()` in `Microsoft.Extensions.Hosting`.
2. Using Source Generators for Dependency Injection setup.
3. Using Source Generators for JSON Serialization (`System.Text.Json.Serialization.JsonSerializableAttribute`).
4. Replacing reflection-heavy libraries like `MediatR` and `Scrutor` with AOT-friendly source-generated alternatives.
By adhering to these constraints, the resulting single-binary Linux executable will have near-instant startup time and a dramatically reduced memory footprint compared to a standard JIT-compiled .NET application.

53
docs/TOOLS.md Normal file
View File

@@ -0,0 +1,53 @@
# Available Tools
AnchorCli provides a comprehensive set of tools for file operations, editing, directory management, and command execution.
## File Operations
| Tool | Description |
|------|-------------|
| `read_file` | Read a file (or a window) with Hashline-tagged lines |
| `grep_file` | Search a file by regex — results are pre-tagged for immediate editing |
| `grep_recursive` | Search for a regex pattern across all files in a directory tree |
| `find_files` | Search for files matching glob patterns |
| `get_file_info` | Get detailed file information (size, permissions, etc.) |
## Edit Operations
| Tool | Description |
|------|-------------|
| `replace_lines` | Replace a range identified by `line:hash` anchors |
| `insert_after` | Insert lines after an anchor |
| `delete_range` | Delete a range between two anchors |
| `create_file` | Create a new file with optional initial content |
| `delete_file` | Delete a file permanently |
| `rename_file` | Rename or move a file |
| `copy_file` | Copy a file to a new location |
| `append_to_file` | Append lines to the end of a file |
| `batch_edit` | Apply multiple replace/delete operations atomically in a single call |
## Directory Operations
| Tool | Description |
|------|-------------|
| `list_dir` | List directory contents |
| `create_dir` | Create a new directory (with parent directories) |
| `rename_dir` | Rename or move a directory |
| `delete_dir` | Delete a directory and all its contents |
## Command Execution
| Tool | Description |
|------|-------------|
| `execute_command` | Run shell commands (with user approval) |
## Tool Usage Guidelines
- All file editing tools use **Hashline anchors** (`line:hash`) for precise, safe edits
- Before any mutation, both the line number **and** hash are validated — stale anchors are rejected
- `batch_edit` is preferred for multiple operations to prevent line drift
- `grep_file` results are pre-tagged with anchors for immediate editing
---
*For more details on the Hashline technique, see [HASHLINE.md](HASHLINE.md)*

View File

@@ -1,112 +0,0 @@
# Tool Consolidation Ideas
This document outlines opportunities to merge similar tools to simplify the API.
## 1. File Write Operations
**Current tools:** `CreateFile`, `InsertAfter`, `AppendToFile`
**Proposed merge:** `WriteToFile`
```csharp
public static string WriteToFile(
string path,
string[] content,
string? mode = "create",
string? anchor = null)
```
**Behavior:**
- `mode="create"` - Creates new file (error if exists)
- `mode="append"` - Appends to EOF (creates if missing)
- `mode="insert"` - Inserts after anchor (requires existing file)
**Benefits:**
- Reduces 3 tools to 1
- Cleaner API for LLM
- Unified error handling
## 2. File Move Operations
**Current tools:** `RenameFile`, `CopyFile`
**Proposed merge:** `MoveFile`
```csharp
public static string MoveFile(
string sourcePath,
string destinationPath,
bool copy = false)
```
**Behavior:**
- `copy=false` - Moves file (current RenameFile behavior)
- `copy=true` - Copies file (current CopyFile behavior)
**Benefits:**
- 90% identical logic
- Only difference is File.Move vs File.Copy
- Both create parent directories
- Similar error handling patterns
## 3. Grep Operations
**Current tools:** `GrepFile`, `GrepRecursive`
**Proposed merge:** `Grep`
```csharp
public static string Grep(
string path,
string pattern,
bool recursive = false,
string? filePattern = null)
```
**Behavior:**
- `recursive=false` - Searches single file (current GrepFile)
- `recursive=true` - Searches directory recursively (current GrepRecursive)
- `filePattern` - Optional glob to filter files when recursive
**Benefits:**
- Very similar logic
- Reduces 2 tools to 1
- Cleaner API for LLM
## 4. Delete Operations
**Current tools:** `DeleteFile`, `DeleteDir`
**Proposed merge:** `Delete`
```csharp
public static string Delete(
string path,
bool recursive = true)
```
**Behavior:**
- Auto-detects if path is file or directory
- `recursive=true` - Delete directory and all contents
- `recursive=false` - Only matters for directories (error if not empty)
**Benefits:**
- Auto-detects file vs directory
- Similar error handling patterns
- Reduces 2 tools to 1
## Summary
These consolidations would reduce the tool count from 17 to 13 tools, making the API simpler and easier for the LLM to use effectively.
**High priority merges:**
1. ✅ File Write Operations (3 → 1)
2. ✅ File Move Operations (2 → 1)
3. ✅ Grep Operations (2 → 1)
4. ✅ Delete Operations (2 → 1)
**Kept separate:**
- `ReadFile` - distinct read-only operation
- `ListDir`, `FindFiles`, `GetFileInfo` - different purposes
- `CreateDir` - simple enough to keep standalone
- `ReplaceLines`, `InsertAfter`, `DeleteRange` - too complex to merge without confusing LLM