Compare commits
26 Commits
112f1f3202
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f187348d7 | |||
| fe006a5256 | |||
| ef30a2254b | |||
| 1e943e6566 | |||
| ccfa7e1b9d | |||
| c9515a822d | |||
| 75bbdda37d | |||
| 46d32c43ba | |||
| 35c8840ed4 | |||
| e2ab10813c | |||
| acc04af4bc | |||
| 977d772229 | |||
| a776d978ea | |||
| 91a44bb2a4 | |||
| f687360c2b | |||
| 4fbbde32e3 | |||
| 8f2c72b3c5 | |||
| 829ba7a7f2 | |||
| 8b48b0f866 | |||
| 82ef63c731 | |||
| 119e623f5a | |||
| e98cd3b19c | |||
| 50414e8b8c | |||
| 003345edc0 | |||
| 7a6e9785d6 | |||
| 1af1665839 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,4 +1,8 @@
|
|||||||
bin
|
bin
|
||||||
obj
|
obj
|
||||||
.vscode
|
.vscode
|
||||||
publish
|
publish
|
||||||
|
.anchor
|
||||||
|
.idea
|
||||||
|
.vs
|
||||||
|
.crush
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
176
ApplicationStartup.cs
Normal 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
818
Assets/3d.flf
Normal file
@@ -0,0 +1,818 @@
|
|||||||
|
flf2a$ 8 8 20 -1 1
|
||||||
|
3d font created by xero <x@xero.nu>
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░░ @@
|
||||||
|
█ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
██ ██ @
|
||||||
|
████████████@
|
||||||
|
░░░██░░░░██░ @
|
||||||
|
░██ ░██ @
|
||||||
|
████████████@
|
||||||
|
░░░██░░░░██░ @
|
||||||
|
░░ ░░ @@
|
||||||
|
█ @
|
||||||
|
█████@
|
||||||
|
░█░█░ @
|
||||||
|
░█████@
|
||||||
|
░░░█░█@
|
||||||
|
█████@
|
||||||
|
░░░█░ @
|
||||||
|
░ @@
|
||||||
|
@
|
||||||
|
██ ██ @
|
||||||
|
░░ ██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ ██ @
|
||||||
|
░░ ░░ @@
|
||||||
|
██ @
|
||||||
|
█░ █ @
|
||||||
|
░ ██ @
|
||||||
|
█░ █ █@
|
||||||
|
█ ░ █ @
|
||||||
|
░█ ░█ @
|
||||||
|
░ ████ █@
|
||||||
|
░░░░ ░ @@
|
||||||
|
██@
|
||||||
|
░░█@
|
||||||
|
░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
██@
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██@
|
||||||
|
░░ @@
|
||||||
|
██ @
|
||||||
|
░░██ @
|
||||||
|
░░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
██ @
|
||||||
|
██ ░██ ██ @
|
||||||
|
░░██ ░██ ██ @
|
||||||
|
██████████████@
|
||||||
|
░░░██░░██░░██░ @
|
||||||
|
██ ░██ ░░██ @
|
||||||
|
░░ ░██ ░░ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
█ @
|
||||||
|
░█ @
|
||||||
|
█████████@
|
||||||
|
░░░░░█░░░ @
|
||||||
|
░█ @
|
||||||
|
░ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░░█@
|
||||||
|
░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████@
|
||||||
|
░░░░░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░░ @@
|
||||||
|
██@
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░██@
|
||||||
|
░█ █░█@
|
||||||
|
░█ █ ░█@
|
||||||
|
░██ ░█@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
██ @
|
||||||
|
███ @
|
||||||
|
░░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
████@
|
||||||
|
░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░ ░█@
|
||||||
|
███ @
|
||||||
|
█░░ @
|
||||||
|
█ @
|
||||||
|
░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░ ░█@
|
||||||
|
███ @
|
||||||
|
░░░ █@
|
||||||
|
█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
██ @
|
||||||
|
█░█ @
|
||||||
|
█ ░█ @
|
||||||
|
██████@
|
||||||
|
░░░░░█ @
|
||||||
|
░█ @
|
||||||
|
░█ @
|
||||||
|
░ @@
|
||||||
|
██████@
|
||||||
|
░█░░░░ @
|
||||||
|
░█████ @
|
||||||
|
░░░░░ █@
|
||||||
|
░█@
|
||||||
|
█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░ @
|
||||||
|
░█████ @
|
||||||
|
░█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
██████@
|
||||||
|
░░░░░░█@
|
||||||
|
░█@
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░█ @
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░░█@
|
||||||
|
░ @@
|
||||||
|
██@
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██@
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
░░░░░░ @
|
||||||
|
██████@
|
||||||
|
░░░░░░ @
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
██ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██@
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
░░ @@
|
||||||
|
████ @
|
||||||
|
██░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ██ @
|
||||||
|
██ @
|
||||||
|
░░ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ██░█@
|
||||||
|
░█░█ ░█@
|
||||||
|
░█░ ██ @
|
||||||
|
░█ ░░ @
|
||||||
|
░ █████@
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
████ @
|
||||||
|
██░░██ @
|
||||||
|
██ ░░██ @
|
||||||
|
██████████@
|
||||||
|
░██░░░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██████ @
|
||||||
|
░█░░░░██ @
|
||||||
|
░█ ░██ @
|
||||||
|
░██████ @
|
||||||
|
░█░░░░ ██@
|
||||||
|
░█ ░██@
|
||||||
|
░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
██████ @
|
||||||
|
██░░░░██@
|
||||||
|
██ ░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░██ ██@
|
||||||
|
░░██████ @
|
||||||
|
░░░░░░ @@
|
||||||
|
███████ @
|
||||||
|
░██░░░░██ @
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ██ @
|
||||||
|
░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
████████@
|
||||||
|
░██░░░░░ @
|
||||||
|
░██ @
|
||||||
|
░███████ @
|
||||||
|
░██░░░░ @
|
||||||
|
░██ @
|
||||||
|
░████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
████████@
|
||||||
|
░██░░░░░ @
|
||||||
|
░██ @
|
||||||
|
░███████ @
|
||||||
|
░██░░░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
████████ @
|
||||||
|
██░░░░░░██@
|
||||||
|
██ ░░ @
|
||||||
|
░██ @
|
||||||
|
░██ █████@
|
||||||
|
░░██ ░░░░██@
|
||||||
|
░░████████ @
|
||||||
|
░░░░░░░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██████████@
|
||||||
|
░██░░░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██ ░██@
|
||||||
|
░░█████ @
|
||||||
|
░░░░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ██ @
|
||||||
|
░██ ██ @
|
||||||
|
░████ @
|
||||||
|
░██░██ @
|
||||||
|
░██░░██ @
|
||||||
|
░██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
████ ████@
|
||||||
|
░██░██ ██░██@
|
||||||
|
░██░░██ ██ ░██@
|
||||||
|
░██ ░░███ ░██@
|
||||||
|
░██ ░░█ ░██@
|
||||||
|
░██ ░ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
████ ██@
|
||||||
|
░██░██ ░██@
|
||||||
|
░██░░██ ░██@
|
||||||
|
░██ ░░██ ░██@
|
||||||
|
░██ ░░██░██@
|
||||||
|
░██ ░░████@
|
||||||
|
░██ ░░███@
|
||||||
|
░░ ░░░ @@
|
||||||
|
███████ @
|
||||||
|
██░░░░░██ @
|
||||||
|
██ ░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
███████ @
|
||||||
|
░██░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░███████ @
|
||||||
|
░██░░░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
███████ @
|
||||||
|
██░░░░░██ @
|
||||||
|
██ ░░██ @
|
||||||
|
░██ ░██ @
|
||||||
|
░██ ██░██ @
|
||||||
|
░░██ ░░ ██ @
|
||||||
|
░░███████ ██@
|
||||||
|
░░░░░░░ ░░ @@
|
||||||
|
███████ @
|
||||||
|
░██░░░░██ @
|
||||||
|
░██ ░██ @
|
||||||
|
░███████ @
|
||||||
|
░██░░░██ @
|
||||||
|
░██ ░░██ @
|
||||||
|
░██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
████████@
|
||||||
|
██░░░░░░ @
|
||||||
|
░██ @
|
||||||
|
░█████████@
|
||||||
|
░░░░░░░░██@
|
||||||
|
░██@
|
||||||
|
████████ @
|
||||||
|
░░░░░░░░ @@
|
||||||
|
██████████@
|
||||||
|
░░░░░██░░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░██ ██ @
|
||||||
|
░░████ @
|
||||||
|
░░██ @
|
||||||
|
░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ █ ░██@
|
||||||
|
░██ ███ ░██@
|
||||||
|
░██ ██░██░██@
|
||||||
|
░████ ░░████@
|
||||||
|
░██░ ░░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░██ ██ @
|
||||||
|
░░███ @
|
||||||
|
██░██ @
|
||||||
|
██ ░░██ @
|
||||||
|
██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░████ @
|
||||||
|
░░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
████████@
|
||||||
|
░░░░░░██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
█████@
|
||||||
|
░██░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░█████@
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██@
|
||||||
|
░░ @@
|
||||||
|
█████@
|
||||||
|
░░░░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
█████@
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
██░ ██ @
|
||||||
|
██ ░░ ██@
|
||||||
|
░░ ░░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████@
|
||||||
|
░░░░░ @@
|
||||||
|
██@
|
||||||
|
░█ @
|
||||||
|
░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████ @
|
||||||
|
░░░░░░██ @
|
||||||
|
███████ @
|
||||||
|
██░░░░██ @
|
||||||
|
░░████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██████ @
|
||||||
|
░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██████ @
|
||||||
|
░░░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████ @
|
||||||
|
██░░░██@
|
||||||
|
░██ ░░ @
|
||||||
|
░██ ██@
|
||||||
|
░░█████ @
|
||||||
|
░░░░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██████@
|
||||||
|
██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████ @
|
||||||
|
██░░░██@
|
||||||
|
░███████@
|
||||||
|
░██░░░░ @
|
||||||
|
░░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
████@
|
||||||
|
░██░ @
|
||||||
|
██████@
|
||||||
|
░░░██░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
█████ @
|
||||||
|
██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████@
|
||||||
|
░░░░░██@
|
||||||
|
█████ @
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██████ @
|
||||||
|
░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░░ @@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██░██@
|
||||||
|
░░███ @
|
||||||
|
░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ ██@
|
||||||
|
░██ ██ @
|
||||||
|
░████ @
|
||||||
|
░██░██ @
|
||||||
|
░██░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
███@
|
||||||
|
░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████████ @
|
||||||
|
░░██░░██░░██@
|
||||||
|
░██ ░██ ░██@
|
||||||
|
░██ ░██ ░██@
|
||||||
|
███ ░██ ░██@
|
||||||
|
░░░ ░░ ░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
███████ @
|
||||||
|
░░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
███ ░██@
|
||||||
|
░░░ ░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████ @
|
||||||
|
██░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████ @
|
||||||
|
░░░░░░ @@
|
||||||
|
@
|
||||||
|
██████ @
|
||||||
|
░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██████ @
|
||||||
|
░██░░░ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
████ @
|
||||||
|
██░░██ @
|
||||||
|
░██ ░██ @
|
||||||
|
░░█████ @
|
||||||
|
░░░░██ @
|
||||||
|
░███@
|
||||||
|
░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
░░██░░█@
|
||||||
|
░██ ░ @
|
||||||
|
░██ @
|
||||||
|
░███ @
|
||||||
|
░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
██░░░░ @
|
||||||
|
░░█████ @
|
||||||
|
░░░░░██@
|
||||||
|
██████ @
|
||||||
|
░░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
██████@
|
||||||
|
░░░██░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██ ░██ @
|
||||||
|
░░████ @
|
||||||
|
░░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
███ ██@
|
||||||
|
░░██ █ ░██@
|
||||||
|
░██ ███░██@
|
||||||
|
░████░████@
|
||||||
|
███░ ░░░██@
|
||||||
|
░░░ ░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░███ @
|
||||||
|
██░██ @
|
||||||
|
██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░███ @
|
||||||
|
░██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
░░░░██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
███@
|
||||||
|
██░ @
|
||||||
|
░██ @
|
||||||
|
███ @
|
||||||
|
░░░██ @
|
||||||
|
░██ @
|
||||||
|
░░███@
|
||||||
|
░░░ @@
|
||||||
|
█@
|
||||||
|
░█@
|
||||||
|
░█@
|
||||||
|
░ @
|
||||||
|
█@
|
||||||
|
░█@
|
||||||
|
░█@
|
||||||
|
░ @@
|
||||||
|
███ @
|
||||||
|
░░░██ @
|
||||||
|
░██ @
|
||||||
|
░░███@
|
||||||
|
██░ @
|
||||||
|
░██ @
|
||||||
|
███ @
|
||||||
|
░░░ @@
|
||||||
|
██ ███ @
|
||||||
|
░░███░░██@
|
||||||
|
░░░ ░░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
61
ContextCompactionService.cs
Normal file
61
ContextCompactionService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
110
HeaderRenderer.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
81
IDEAS.md
81
IDEAS.md
@@ -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.
|
|
||||||
90
IMPROVEME.md
90
IMPROVEME.md
@@ -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
125
InputProcessor.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
293
PROVIDERS.md
293
PROVIDERS.md
@@ -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*
|
|
||||||
|
|
||||||
164
Program.cs
164
Program.cs
@@ -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
135
README.md
@@ -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.
|
||||||
|
|||||||
308
ReplLoop.cs
308
ReplLoop.cs
@@ -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
60
ResponseStreamer.cs
Normal 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; }
|
||||||
|
}
|
||||||
334
SANDBOX.md
334
SANDBOX.md
@@ -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
83
SessionManager.cs
Normal 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
100
SpinnerService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}";
|
||||||
|
|||||||
@@ -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.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
48
UsageDisplayer.cs
Normal 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
36
docs/COMMANDS.md
Normal 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
55
docs/HASHLINE.md
Normal 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*
|
||||||
@@ -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
53
docs/TOOLS.md
Normal 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)*
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user