Compare commits
36 Commits
928ca8c454
...
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 | |||
| 112f1f3202 | |||
| c7e7976d9d | |||
| 4476cc7f15 | |||
| f2c3e5032d | |||
| 941894761a | |||
| 5fb914dbc8 | |||
| de6a21fb5a | |||
| ed897aeb01 | |||
| d7a94436d1 | |||
| 31cf7cb4c1 |
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = crlf
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
dotnet_sort_system_directives_first = true
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,4 +1,8 @@
|
|||||||
bin
|
bin
|
||||||
obj
|
obj
|
||||||
.vscode
|
.vscode
|
||||||
publish
|
publish
|
||||||
|
.anchor
|
||||||
|
.idea
|
||||||
|
.vs
|
||||||
|
.crush
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
<StripSymbols>true</StripSymbols>
|
<StripSymbols>true</StripSymbols>
|
||||||
<AssemblyName>anchor</AssemblyName>
|
<AssemblyName>anchor</AssemblyName>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
|
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
|
||||||
@@ -18,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" />
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ internal sealed class AnchorConfig
|
|||||||
{
|
{
|
||||||
public string ApiKey { get; set; } = "";
|
public string ApiKey { get; set; } = "";
|
||||||
public string Model { get; set; } = "qwen/qwen3.5-397b-a17b";
|
public string Model { get; set; } = "qwen/qwen3.5-397b-a17b";
|
||||||
|
public string Provider { get; set; } = "openrouter";
|
||||||
|
public string Endpoint { get; set; } = "https://openrouter.ai/api/v1";
|
||||||
// ── Persistence ──────────────────────────────────────────────────────
|
// ── Persistence ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static string ConfigPath =>
|
private static string ConfigPath =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@
|
||||||
|
$$@@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░░ @@
|
||||||
|
█ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
██ ██ @
|
||||||
|
████████████@
|
||||||
|
░░░██░░░░██░ @
|
||||||
|
░██ ░██ @
|
||||||
|
████████████@
|
||||||
|
░░░██░░░░██░ @
|
||||||
|
░░ ░░ @@
|
||||||
|
█ @
|
||||||
|
█████@
|
||||||
|
░█░█░ @
|
||||||
|
░█████@
|
||||||
|
░░░█░█@
|
||||||
|
█████@
|
||||||
|
░░░█░ @
|
||||||
|
░ @@
|
||||||
|
@
|
||||||
|
██ ██ @
|
||||||
|
░░ ██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ ██ @
|
||||||
|
░░ ░░ @@
|
||||||
|
██ @
|
||||||
|
█░ █ @
|
||||||
|
░ ██ @
|
||||||
|
█░ █ █@
|
||||||
|
█ ░ █ @
|
||||||
|
░█ ░█ @
|
||||||
|
░ ████ █@
|
||||||
|
░░░░ ░ @@
|
||||||
|
██@
|
||||||
|
░░█@
|
||||||
|
░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
██@
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██@
|
||||||
|
░░ @@
|
||||||
|
██ @
|
||||||
|
░░██ @
|
||||||
|
░░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
██ @
|
||||||
|
██ ░██ ██ @
|
||||||
|
░░██ ░██ ██ @
|
||||||
|
██████████████@
|
||||||
|
░░░██░░██░░██░ @
|
||||||
|
██ ░██ ░░██ @
|
||||||
|
░░ ░██ ░░ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
█ @
|
||||||
|
░█ @
|
||||||
|
█████████@
|
||||||
|
░░░░░█░░░ @
|
||||||
|
░█ @
|
||||||
|
░ @
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░░█@
|
||||||
|
░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████@
|
||||||
|
░░░░░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░░ @@
|
||||||
|
██@
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░██@
|
||||||
|
░█ █░█@
|
||||||
|
░█ █ ░█@
|
||||||
|
░██ ░█@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
██ @
|
||||||
|
███ @
|
||||||
|
░░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
████@
|
||||||
|
░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░ ░█@
|
||||||
|
███ @
|
||||||
|
█░░ @
|
||||||
|
█ @
|
||||||
|
░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░ ░█@
|
||||||
|
███ @
|
||||||
|
░░░ █@
|
||||||
|
█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
██ @
|
||||||
|
█░█ @
|
||||||
|
█ ░█ @
|
||||||
|
██████@
|
||||||
|
░░░░░█ @
|
||||||
|
░█ @
|
||||||
|
░█ @
|
||||||
|
░ @@
|
||||||
|
██████@
|
||||||
|
░█░░░░ @
|
||||||
|
░█████ @
|
||||||
|
░░░░░ █@
|
||||||
|
░█@
|
||||||
|
█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░ @
|
||||||
|
░█████ @
|
||||||
|
░█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
██████@
|
||||||
|
░░░░░░█@
|
||||||
|
░█@
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ░█@
|
||||||
|
░ ████ @
|
||||||
|
░░░█ @
|
||||||
|
█ @
|
||||||
|
█ @
|
||||||
|
░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░░█@
|
||||||
|
░ @@
|
||||||
|
██@
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██@
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
░░░░░░ @
|
||||||
|
██████@
|
||||||
|
░░░░░░ @
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
██ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██ @
|
||||||
|
░░ ██@
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
██░ @
|
||||||
|
░░ @@
|
||||||
|
████ @
|
||||||
|
██░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ██ @
|
||||||
|
██ @
|
||||||
|
░░ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
████ @
|
||||||
|
█░░░ █@
|
||||||
|
░█ ██░█@
|
||||||
|
░█░█ ░█@
|
||||||
|
░█░ ██ @
|
||||||
|
░█ ░░ @
|
||||||
|
░ █████@
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
████ @
|
||||||
|
██░░██ @
|
||||||
|
██ ░░██ @
|
||||||
|
██████████@
|
||||||
|
░██░░░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██████ @
|
||||||
|
░█░░░░██ @
|
||||||
|
░█ ░██ @
|
||||||
|
░██████ @
|
||||||
|
░█░░░░ ██@
|
||||||
|
░█ ░██@
|
||||||
|
░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
██████ @
|
||||||
|
██░░░░██@
|
||||||
|
██ ░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░██ ██@
|
||||||
|
░░██████ @
|
||||||
|
░░░░░░ @@
|
||||||
|
███████ @
|
||||||
|
░██░░░░██ @
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ██ @
|
||||||
|
░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
████████@
|
||||||
|
░██░░░░░ @
|
||||||
|
░██ @
|
||||||
|
░███████ @
|
||||||
|
░██░░░░ @
|
||||||
|
░██ @
|
||||||
|
░████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
████████@
|
||||||
|
░██░░░░░ @
|
||||||
|
░██ @
|
||||||
|
░███████ @
|
||||||
|
░██░░░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
████████ @
|
||||||
|
██░░░░░░██@
|
||||||
|
██ ░░ @
|
||||||
|
░██ @
|
||||||
|
░██ █████@
|
||||||
|
░░██ ░░░░██@
|
||||||
|
░░████████ @
|
||||||
|
░░░░░░░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██████████@
|
||||||
|
░██░░░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██ ░██@
|
||||||
|
░░█████ @
|
||||||
|
░░░░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ██ @
|
||||||
|
░██ ██ @
|
||||||
|
░████ @
|
||||||
|
░██░██ @
|
||||||
|
░██░░██ @
|
||||||
|
░██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
████ ████@
|
||||||
|
░██░██ ██░██@
|
||||||
|
░██░░██ ██ ░██@
|
||||||
|
░██ ░░███ ░██@
|
||||||
|
░██ ░░█ ░██@
|
||||||
|
░██ ░ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
████ ██@
|
||||||
|
░██░██ ░██@
|
||||||
|
░██░░██ ░██@
|
||||||
|
░██ ░░██ ░██@
|
||||||
|
░██ ░░██░██@
|
||||||
|
░██ ░░████@
|
||||||
|
░██ ░░███@
|
||||||
|
░░ ░░░ @@
|
||||||
|
███████ @
|
||||||
|
██░░░░░██ @
|
||||||
|
██ ░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
███████ @
|
||||||
|
░██░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░███████ @
|
||||||
|
░██░░░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
███████ @
|
||||||
|
██░░░░░██ @
|
||||||
|
██ ░░██ @
|
||||||
|
░██ ░██ @
|
||||||
|
░██ ██░██ @
|
||||||
|
░░██ ░░ ██ @
|
||||||
|
░░███████ ██@
|
||||||
|
░░░░░░░ ░░ @@
|
||||||
|
███████ @
|
||||||
|
░██░░░░██ @
|
||||||
|
░██ ░██ @
|
||||||
|
░███████ @
|
||||||
|
░██░░░██ @
|
||||||
|
░██ ░░██ @
|
||||||
|
░██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
████████@
|
||||||
|
██░░░░░░ @
|
||||||
|
░██ @
|
||||||
|
░█████████@
|
||||||
|
░░░░░░░░██@
|
||||||
|
░██@
|
||||||
|
████████ @
|
||||||
|
░░░░░░░░ @@
|
||||||
|
██████████@
|
||||||
|
░░░░░██░░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░███████ @
|
||||||
|
░░░░░░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░██ ██ @
|
||||||
|
░░████ @
|
||||||
|
░░██ @
|
||||||
|
░░ @@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ █ ░██@
|
||||||
|
░██ ███ ░██@
|
||||||
|
░██ ██░██░██@
|
||||||
|
░████ ░░████@
|
||||||
|
░██░ ░░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░██ ██ @
|
||||||
|
░░███ @
|
||||||
|
██░██ @
|
||||||
|
██ ░░██ @
|
||||||
|
██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░████ @
|
||||||
|
░░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
████████@
|
||||||
|
░░░░░░██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
█████@
|
||||||
|
░██░░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░█████@
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██ @
|
||||||
|
░░██@
|
||||||
|
░░ @@
|
||||||
|
█████@
|
||||||
|
░░░░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
█████@
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
██░ ██ @
|
||||||
|
██ ░░ ██@
|
||||||
|
░░ ░░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████@
|
||||||
|
░░░░░ @@
|
||||||
|
██@
|
||||||
|
░█ @
|
||||||
|
░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████ @
|
||||||
|
░░░░░░██ @
|
||||||
|
███████ @
|
||||||
|
██░░░░██ @
|
||||||
|
░░████████@
|
||||||
|
░░░░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██████ @
|
||||||
|
░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██████ @
|
||||||
|
░░░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████ @
|
||||||
|
██░░░██@
|
||||||
|
░██ ░░ @
|
||||||
|
░██ ██@
|
||||||
|
░░█████ @
|
||||||
|
░░░░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██████@
|
||||||
|
██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
█████ @
|
||||||
|
██░░░██@
|
||||||
|
░███████@
|
||||||
|
░██░░░░ @
|
||||||
|
░░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
████@
|
||||||
|
░██░ @
|
||||||
|
██████@
|
||||||
|
░░░██░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
█████ @
|
||||||
|
██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████@
|
||||||
|
░░░░░██@
|
||||||
|
█████ @
|
||||||
|
░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░██████ @
|
||||||
|
░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░░ @@
|
||||||
|
██@
|
||||||
|
░░ @
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
██░██@
|
||||||
|
░░███ @
|
||||||
|
░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
░██ ██@
|
||||||
|
░██ ██ @
|
||||||
|
░████ @
|
||||||
|
░██░██ @
|
||||||
|
░██░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
░██@
|
||||||
|
███@
|
||||||
|
░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████████ @
|
||||||
|
░░██░░██░░██@
|
||||||
|
░██ ░██ ░██@
|
||||||
|
░██ ░██ ░██@
|
||||||
|
███ ░██ ░██@
|
||||||
|
░░░ ░░ ░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
███████ @
|
||||||
|
░░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
███ ░██@
|
||||||
|
░░░ ░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████ @
|
||||||
|
██░░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████ @
|
||||||
|
░░░░░░ @@
|
||||||
|
@
|
||||||
|
██████ @
|
||||||
|
░██░░░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██████ @
|
||||||
|
░██░░░ @
|
||||||
|
░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
████ @
|
||||||
|
██░░██ @
|
||||||
|
░██ ░██ @
|
||||||
|
░░█████ @
|
||||||
|
░░░░██ @
|
||||||
|
░███@
|
||||||
|
░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
░░██░░█@
|
||||||
|
░██ ░ @
|
||||||
|
░██ @
|
||||||
|
░███ @
|
||||||
|
░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
██░░░░ @
|
||||||
|
░░█████ @
|
||||||
|
░░░░░██@
|
||||||
|
██████ @
|
||||||
|
░░░░░░ @@
|
||||||
|
██ @
|
||||||
|
░██ @
|
||||||
|
██████@
|
||||||
|
░░░██░ @
|
||||||
|
░██ @
|
||||||
|
░██ @
|
||||||
|
░░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░██ ░██@
|
||||||
|
░░██ ░██ @
|
||||||
|
░░████ @
|
||||||
|
░░██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
███ ██@
|
||||||
|
░░██ █ ░██@
|
||||||
|
░██ ███░██@
|
||||||
|
░████░████@
|
||||||
|
███░ ░░░██@
|
||||||
|
░░░ ░░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░███ @
|
||||||
|
██░██ @
|
||||||
|
██ ░░██@
|
||||||
|
░░ ░░ @@
|
||||||
|
@
|
||||||
|
██ ██@
|
||||||
|
░░██ ██ @
|
||||||
|
░░███ @
|
||||||
|
░██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
░░ @@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
██████@
|
||||||
|
░░░░██ @
|
||||||
|
██ @
|
||||||
|
██ @
|
||||||
|
██████@
|
||||||
|
░░░░░░ @@
|
||||||
|
███@
|
||||||
|
██░ @
|
||||||
|
░██ @
|
||||||
|
███ @
|
||||||
|
░░░██ @
|
||||||
|
░██ @
|
||||||
|
░░███@
|
||||||
|
░░░ @@
|
||||||
|
█@
|
||||||
|
░█@
|
||||||
|
░█@
|
||||||
|
░ @
|
||||||
|
█@
|
||||||
|
░█@
|
||||||
|
░█@
|
||||||
|
░ @@
|
||||||
|
███ @
|
||||||
|
░░░██ @
|
||||||
|
░██ @
|
||||||
|
░░███@
|
||||||
|
██░ @
|
||||||
|
░██ @
|
||||||
|
███ @
|
||||||
|
░░░ @@
|
||||||
|
██ ███ @
|
||||||
|
░░███░░██@
|
||||||
|
░░░ ░░ @
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@
|
||||||
|
@@
|
||||||
153
ChatSession.cs
Normal file
153
ChatSession.cs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
internal sealed class ChatSession
|
||||||
|
{
|
||||||
|
private readonly IChatClient _agent;
|
||||||
|
public ContextCompactor Compactor { 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)
|
||||||
|
{
|
||||||
|
Compactor = new ContextCompactor(innerClient);
|
||||||
|
|
||||||
|
var tools = ToolRegistry.GetTools();
|
||||||
|
_agent = new ChatClientBuilder(innerClient)
|
||||||
|
.UseFunctionInvocation()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
History = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new(ChatRole.System, $$"""
|
||||||
|
You are anchor, a coding assistant that edits files using the Hashline technique.
|
||||||
|
|
||||||
|
## Reading files
|
||||||
|
When you read a file, lines are returned in the format: lineNumber:hash|content
|
||||||
|
The "lineNumber:hash|" prefix is METADATA for anchoring — it is NOT part of the file.
|
||||||
|
|
||||||
|
## Editing files
|
||||||
|
To edit, reference anchors as "lineNumber:hash" in startAnchor/endAnchor parameters.
|
||||||
|
The newLines/initialLines parameter must contain RAW SOURCE CODE ONLY.
|
||||||
|
❌ WRONG: ["5:a3| public void Foo()"]
|
||||||
|
✅ RIGHT: [" public void Foo()"]
|
||||||
|
Never include the "lineNumber:hash|" prefix in content you write — it will corrupt the file.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
1. ALWAYS call GrepFile before ReadFile on any source file.
|
||||||
|
Use patterns like "public|func|function|class|interface|enum|def|fn " to get a structural
|
||||||
|
outline of the file and identify the exact line numbers of the section you need.
|
||||||
|
Only then call ReadFile with a targeted startLine/endLine range.
|
||||||
|
❌ WRONG: ReadFile("Foo.cs") — reads blindly without knowing the structure.
|
||||||
|
✅ RIGHT: GrepFile("Foo.cs", "public|class|interface") → ReadFile("Foo.cs", 42, 90)
|
||||||
|
2. After reading, edit the file before verifying the returned fingerprint.
|
||||||
|
3. Edit from bottom to top so line numbers don't shift.
|
||||||
|
4. If an anchor fails validation, re-read the 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.
|
||||||
|
You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}}
|
||||||
|
""")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
// Keep only the system message
|
||||||
|
var systemMessage = History[0];
|
||||||
|
History.Clear();
|
||||||
|
History.Add(systemMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var options = new ChatOptions { Tools = ToolRegistry.GetTools() };
|
||||||
|
var stream = _agent.GetStreamingResponseAsync(History, options, cancellationToken);
|
||||||
|
|
||||||
|
await foreach (var update in stream.WithCancellation(cancellationToken))
|
||||||
|
{
|
||||||
|
yield return update;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|||||||
21
Commands/ResetCommand.cs
Normal file
21
Commands/ResetCommand.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using Spectre.Console;
|
||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
|
||||||
|
namespace AnchorCli.Commands;
|
||||||
|
|
||||||
|
internal class ResetCommand(ChatSession session, TokenTracker tokenTracker) : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "reset";
|
||||||
|
public string Description => "Reset the chat session (clear history and token count)";
|
||||||
|
|
||||||
|
public Task ExecuteAsync(string[] args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
session.Reset();
|
||||||
|
tokenTracker.Reset();
|
||||||
|
AnsiConsole.MarkupLine("[green]Chat session reset.[/]");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,157 +1,201 @@
|
|||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
namespace AnchorCli;
|
namespace AnchorCli;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compacts the chat history when context usage grows too large.
|
/// Compacts the chat history when context usage grows too large.
|
||||||
/// Asks the same model to summarize the conversation, then replaces
|
/// Asks the same model to summarize the conversation, then replaces
|
||||||
/// the history with [system prompt, summary, last N user/assistant turns].
|
/// the history with [system prompt, summary, last N user/assistant turns].
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed partial class ContextCompactor(IChatClient client)
|
internal sealed partial class ContextCompactor(IChatClient client)
|
||||||
{
|
{
|
||||||
/// <summary>Number of recent user+assistant turn pairs to keep verbatim.</summary>
|
/// <summary>Number of recent user+assistant turn pairs to keep verbatim.</summary>
|
||||||
private const int KeepRecentTurns = 2;
|
private const int KeepRecentTurns = 2;
|
||||||
|
|
||||||
/// <summary>Minimum result length to consider for compaction.</summary>
|
/// <summary>Minimum result length to consider for compaction.</summary>
|
||||||
private const int MinResultLength = 300;
|
private const int MinResultLength = 300;
|
||||||
|
|
||||||
/// <summary>Matches hashline-encoded output: "lineNumber:hash|content"</summary>
|
/// <summary>Matches hashline-encoded output: "lineNumber:hash|content"</summary>
|
||||||
private static readonly Regex HashlinePattern =
|
private static readonly Regex HashlinePattern =
|
||||||
MyRegex();
|
MyRegex();
|
||||||
|
|
||||||
private readonly IChatClient _client = client;
|
private readonly IChatClient _client = client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compacts large hashline-encoded tool results from previous turns.
|
/// Compacts large hashline-encoded tool results from previous turns.
|
||||||
/// This is the cheapest and most impactful optimization — no LLM call needed.
|
/// This is the cheapest and most impactful optimization — no LLM call needed.
|
||||||
/// A 300-line ReadFile result (~10K tokens) becomes a one-line note (~20 tokens).
|
/// A 300-line ReadFile result (~10K tokens) becomes a one-line note (~20 tokens).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="history">The chat history to compact in-place.</param>
|
/// <param name="history">The chat history to compact in-place.</param>
|
||||||
/// <param name="currentTurnStartIndex">
|
/// <returns>Number of tool results that were compacted.</returns>
|
||||||
/// Index of the first message added during the current turn.
|
public static int CompactStaleToolResults(List<ChatMessage> history)
|
||||||
/// Messages before this index are from previous turns and eligible for compaction.
|
{
|
||||||
/// </param>
|
int compacted = 0;
|
||||||
/// <returns>Number of tool results that were compacted.</returns>
|
int userTurnsSeen = 0;
|
||||||
public static int CompactStaleToolResults(List<ChatMessage> history, int currentTurnStartIndex)
|
Dictionary<string, int> filesRead = new(StringComparer.OrdinalIgnoreCase);
|
||||||
{
|
|
||||||
int compacted = 0;
|
// Walk backwards: index 0 is system prompt, so we stop at 1.
|
||||||
|
for (int i = history.Count - 1; i >= 1; i--)
|
||||||
for (int i = 0; i < currentTurnStartIndex && i < history.Count; i++)
|
{
|
||||||
{
|
var msg = history[i];
|
||||||
var msg = history[i];
|
|
||||||
|
if (msg.Role == ChatRole.User)
|
||||||
foreach (var content in msg.Contents)
|
{
|
||||||
{
|
userTurnsSeen++;
|
||||||
if (content is FunctionResultContent frc &&
|
}
|
||||||
frc.Result is string resultStr &&
|
else if (msg.Role == ChatRole.Assistant)
|
||||||
resultStr.Length >= MinResultLength &&
|
{
|
||||||
HashlinePattern.IsMatch(resultStr))
|
// Find all FunctionCalls in this assistant message to map call IDs to file paths
|
||||||
{
|
var calls = msg.Contents.OfType<FunctionCallContent>().ToList();
|
||||||
// Count lines and replace with compact summary
|
|
||||||
int lineCount = resultStr.Count(c => c == '\n');
|
// If the very next message is a System/Tool role containing FunctionResults
|
||||||
frc.Result = $"[File content: {lineCount} lines — already consumed. Re-read the file if you need fresh anchors.]";
|
if (i + 1 < history.Count && history[i + 1].Role == ChatRole.Tool)
|
||||||
compacted++;
|
{
|
||||||
}
|
var resultMsg = history[i + 1];
|
||||||
}
|
foreach (var content in resultMsg.Contents)
|
||||||
}
|
{
|
||||||
|
if (content is FunctionResultContent frc &&
|
||||||
return compacted;
|
frc.Result is string resultStr &&
|
||||||
}
|
resultStr.Length >= MinResultLength &&
|
||||||
|
HashlinePattern.IsMatch(resultStr))
|
||||||
/// <summary>
|
{
|
||||||
/// Compacts the history in-place via LLM summarization. Returns true if compaction was performed.
|
// Find the corresponding function call to check its name/arguments
|
||||||
/// </summary>
|
var call = calls.FirstOrDefault(c => c.CallId == frc.CallId);
|
||||||
public async Task<bool> TryCompactAsync(
|
if (call?.Name == "ReadFile" && call.Arguments != null && call.Arguments.TryGetValue("path", out var pathObj))
|
||||||
List<ChatMessage> history,
|
{
|
||||||
CancellationToken ct = default)
|
string filePath = pathObj?.ToString() ?? "";
|
||||||
{
|
|
||||||
// Need at least: system + some conversation to compact
|
bool shouldRedact = false;
|
||||||
if (history.Count < 5)
|
string reason = "";
|
||||||
return false;
|
|
||||||
|
// Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one.
|
||||||
// Split: system prompt (index 0) | middle (compactable) | tail (keep verbatim)
|
if (filesRead.TryGetValue(filePath, out int count) && count >= 3)
|
||||||
var systemMsg = history[0];
|
{
|
||||||
|
shouldRedact = true;
|
||||||
// Find the cut point: keep the last N user+assistant pairs
|
reason = "deduplication — you read this file 5 or more times later";
|
||||||
int keepFromIndex = FindKeepIndex(history);
|
}
|
||||||
if (keepFromIndex <= 1)
|
// Rule 2: TTL. If this was read 2 or more user turns ago, redact it.
|
||||||
return false; // Nothing to compact
|
else if (userTurnsSeen >= 2)
|
||||||
|
{
|
||||||
// Extract the middle section to summarize
|
shouldRedact = true;
|
||||||
var toSummarize = history.Skip(1).Take(keepFromIndex - 1).ToList();
|
reason = "expired — read over 2 turns ago";
|
||||||
var tail = history.Skip(keepFromIndex).ToList();
|
}
|
||||||
|
|
||||||
// Build a summarization prompt
|
if (shouldRedact)
|
||||||
var summaryMessages = new List<ChatMessage>
|
{
|
||||||
{
|
int lineCount = resultStr.Count(c => c == '\n');
|
||||||
new(ChatRole.System, """
|
frc.Result = $"[File content: {lineCount} lines redacted for {reason}. Re-read the file if you need fresh anchors.]";
|
||||||
You are a conversation summarizer. Summarize the following coding conversation
|
compacted++;
|
||||||
between a user and an AI assistant. Focus on:
|
}
|
||||||
- What files were read, created, or modified (and their paths)
|
else
|
||||||
- What changes were made and why
|
{
|
||||||
- Any decisions or preferences expressed by the user
|
// Keep it, but mark that we've seen it so older reads of the same file are redacted.
|
||||||
- Current state of the work (what's done, what's pending)
|
filesRead[filePath] = filesRead.GetValueOrDefault(filePath) + 1;
|
||||||
Be concise but preserve all actionable context. Output a single summary paragraph.
|
}
|
||||||
Do NOT include any hashline anchors (lineNumber:hash|) in your summary.
|
}
|
||||||
""")
|
}
|
||||||
};
|
}
|
||||||
summaryMessages.AddRange(toSummarize);
|
}
|
||||||
summaryMessages.Add(new(ChatRole.User,
|
}
|
||||||
"Summarize the above conversation concisely, preserving all important context for continuing the work."));
|
}
|
||||||
|
|
||||||
string summary;
|
return compacted;
|
||||||
try
|
}
|
||||||
{
|
|
||||||
var response = await _client.GetResponseAsync(summaryMessages, cancellationToken: ct);
|
/// <summary>
|
||||||
summary = response.Text ?? "(summary unavailable)";
|
/// Compacts the history in-place via LLM summarization. Returns true if compaction was performed.
|
||||||
}
|
/// </summary>
|
||||||
catch
|
public async Task<bool> TryCompactAsync(
|
||||||
{
|
List<ChatMessage> history,
|
||||||
// If summarization fails, don't compact
|
CancellationToken ct = default)
|
||||||
return false;
|
{
|
||||||
}
|
// Need at least: system + some conversation to compact
|
||||||
|
if (history.Count < 5)
|
||||||
// Rebuild history: system + summary + recent turns
|
return false;
|
||||||
history.Clear();
|
|
||||||
history.Add(systemMsg);
|
// Split: system prompt (index 0) | middle (compactable) | tail (keep verbatim)
|
||||||
history.Add(new(ChatRole.User,
|
var systemMsg = history[0];
|
||||||
"[CONTEXT COMPACTED — The following is a summary of the earlier conversation]\n" + summary));
|
|
||||||
history.Add(new(ChatRole.Assistant,
|
// Find the cut point: keep the last N user+assistant pairs
|
||||||
"Understood, I have the context from our earlier conversation. I'll continue from where we left off."));
|
int keepFromIndex = FindKeepIndex(history);
|
||||||
history.AddRange(tail);
|
if (keepFromIndex <= 1)
|
||||||
|
return false; // Nothing to compact
|
||||||
return true;
|
|
||||||
}
|
// Extract the middle section to summarize
|
||||||
|
var toSummarize = history.Skip(1).Take(keepFromIndex - 1).ToList();
|
||||||
/// <summary>
|
var tail = history.Skip(keepFromIndex).ToList();
|
||||||
/// Finds the index from which to keep messages verbatim (the last N turn pairs).
|
|
||||||
/// </summary>
|
// Build a summarization prompt
|
||||||
private static int FindKeepIndex(List<ChatMessage> history)
|
var summaryMessages = new List<ChatMessage>
|
||||||
{
|
{
|
||||||
int pairsFound = 0;
|
new(ChatRole.System, """
|
||||||
int idx = history.Count - 1;
|
You are a conversation summarizer. Summarize the following coding conversation
|
||||||
|
between a user and an AI assistant. Focus on:
|
||||||
while (idx > 0 && pairsFound < KeepRecentTurns)
|
- What files were read, created, or modified (and their paths)
|
||||||
{
|
- What changes were made and why
|
||||||
// Walk backwards looking for user+assistant pairs
|
- Any decisions or preferences expressed by the user
|
||||||
if (history[idx].Role == ChatRole.Assistant && idx > 1 &&
|
- Current state of the work (what's done, what's pending)
|
||||||
history[idx - 1].Role == ChatRole.User)
|
Be concise but preserve all actionable context. Output a single summary paragraph.
|
||||||
{
|
Do NOT include any hashline anchors (lineNumber:hash|) in your summary.
|
||||||
pairsFound++;
|
""")
|
||||||
idx -= 2;
|
};
|
||||||
}
|
summaryMessages.AddRange(toSummarize);
|
||||||
else
|
summaryMessages.Add(new(ChatRole.User,
|
||||||
{
|
"Summarize the above conversation concisely, preserving all important context for continuing the work."));
|
||||||
idx--;
|
|
||||||
}
|
string summary;
|
||||||
}
|
try
|
||||||
|
{
|
||||||
// idx+1 is the first message to keep
|
var response = await _client.GetResponseAsync(summaryMessages, cancellationToken: ct);
|
||||||
return Math.Max(1, idx + 1);
|
summary = response.Text ?? "(summary unavailable)";
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
[GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Multiline | RegexOptions.Compiled)]
|
{
|
||||||
private static partial Regex MyRegex();
|
// If summarization fails, don't compact
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild history: system + summary + recent turns
|
||||||
|
history.Clear();
|
||||||
|
history.Add(systemMsg);
|
||||||
|
history.Add(new(ChatRole.User,
|
||||||
|
"[CONTEXT COMPACTED — The following is a summary of the earlier conversation]\n" + summary));
|
||||||
|
history.Add(new(ChatRole.Assistant,
|
||||||
|
"Understood, I have the context from our earlier conversation. I'll continue from where we left off."));
|
||||||
|
history.AddRange(tail);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the index from which to keep messages verbatim (the last N turn pairs).
|
||||||
|
/// </summary>
|
||||||
|
private static int FindKeepIndex(List<ChatMessage> history)
|
||||||
|
{
|
||||||
|
int pairsFound = 0;
|
||||||
|
int idx = history.Count - 1;
|
||||||
|
|
||||||
|
while (idx > 0 && pairsFound < KeepRecentTurns)
|
||||||
|
{
|
||||||
|
// Walk backwards looking for user+assistant pairs
|
||||||
|
if (history[idx].Role == ChatRole.Assistant && idx > 1 &&
|
||||||
|
history[idx - 1].Role == ChatRole.User)
|
||||||
|
{
|
||||||
|
pairsFound++;
|
||||||
|
idx -= 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
idx--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// idx+1 is the first message to keep
|
||||||
|
return Math.Max(1, idx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Multiline | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex MyRegex();
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ internal static class HashlineEncoder
|
|||||||
/// Computes a short file-level fingerprint: XOR of all per-line hashes (as bytes).
|
/// Computes a short file-level fingerprint: XOR of all per-line hashes (as bytes).
|
||||||
/// Useful for cheap full-file staleness checks.
|
/// Useful for cheap full-file staleness checks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="lines">All lines of the file (without trailing newlines).</param>
|
||||||
|
/// <returns>A 2-character hex fingerprint.</returns>
|
||||||
public static string FileFingerprint(string[] lines)
|
public static string FileFingerprint(string[] lines)
|
||||||
{
|
{
|
||||||
int fp = 0;
|
int fp = 0;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +80,13 @@ internal static class HashlineValidator
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates both a start and end anchor, and ensures start <= end.
|
/// Validates both a start and end anchor, and ensures start <= end.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="startAnchor">The starting anchor string.</param>
|
||||||
|
/// <param name="endAnchor">The ending anchor string.</param>
|
||||||
|
/// <param name="lines">Current file lines (without newlines).</param>
|
||||||
|
/// <param name="startIndex">Resolved 0-based start index on success.</param>
|
||||||
|
/// <param name="endIndex">Resolved 0-based end index on success.</param>
|
||||||
|
/// <param name="error">Human-readable error message on failure.</param>
|
||||||
|
/// <returns>True if the range is valid; false otherwise.</returns>
|
||||||
public static bool TryResolveRange(
|
public static bool TryResolveRange(
|
||||||
string startAnchor,
|
string startAnchor,
|
||||||
string endAnchor,
|
string endAnchor,
|
||||||
@@ -91,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;
|
||||||
|
|
||||||
@@ -103,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.
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 AnchorCli Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
19
OpenRouter/OpenRouterHeaders.cs
Normal file
19
OpenRouter/OpenRouterHeaders.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
|
||||||
|
namespace AnchorCli.OpenRouter;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides extension methods for adding OpenRouter-specific HTTP headers.
|
||||||
|
/// </summary>
|
||||||
|
public static class OpenRouterHeaders
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the required OpenRouter headers to the specified HttpClient.
|
||||||
|
/// </summary>
|
||||||
|
public static void ApplyTo(HttpClient httpClient)
|
||||||
|
{
|
||||||
|
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("HTTP-Referer", "https://git.technopunk.space/tomi/AnchorCli");
|
||||||
|
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("X-OpenRouter-Title", "Anchor CLI");
|
||||||
|
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("X-OpenRouter-Categories", "cli-agent");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ internal sealed class PricingProvider
|
|||||||
private static readonly HttpClient Http = new();
|
private static readonly HttpClient Http = new();
|
||||||
private Dictionary<string, ModelInfo>? _models;
|
private Dictionary<string, ModelInfo>? _models;
|
||||||
|
|
||||||
|
static PricingProvider()
|
||||||
|
{
|
||||||
|
OpenRouterHeaders.ApplyTo(Http);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches the full model list from OpenRouter (cached after first call).
|
/// Fetches the full model list from OpenRouter (cached after first call).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -5,9 +5,27 @@ namespace AnchorCli.OpenRouter;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class TokenTracker
|
internal sealed class TokenTracker
|
||||||
{
|
{
|
||||||
public long SessionInputTokens { get; private set; }
|
private ChatSession _session;
|
||||||
public long SessionOutputTokens { get; private set; }
|
|
||||||
public int RequestCount { get; private set; }
|
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 long SessionInputTokens => _session.SessionInputTokens;
|
||||||
|
public long SessionOutputTokens => _session.SessionOutputTokens;
|
||||||
|
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; }
|
||||||
@@ -23,17 +41,26 @@ internal sealed class TokenTracker
|
|||||||
|
|
||||||
/// <summary>Fixed USD per API request.</summary>
|
/// <summary>Fixed USD per API request.</summary>
|
||||||
public decimal RequestPrice { get; set; }
|
public decimal RequestPrice { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Record usage from one response (may span multiple LLM rounds).
|
/// Record usage from one response (may span multiple LLM rounds).
|
||||||
/// </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()
|
||||||
|
{
|
||||||
|
_session.SessionInputTokens = 0;
|
||||||
|
_session.SessionOutputTokens = 0;
|
||||||
|
_session.RequestCount = 0;
|
||||||
|
LastInputTokens = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private const int MaxContextReserve = 150_000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if the context is getting too large and should be compacted.
|
/// Returns true if the context is getting too large and should be compacted.
|
||||||
@@ -44,8 +71,8 @@ internal sealed class TokenTracker
|
|||||||
if (LastInputTokens <= 0) return false;
|
if (LastInputTokens <= 0) return false;
|
||||||
|
|
||||||
int threshold = ContextLength > 0
|
int threshold = ContextLength > 0
|
||||||
? Math.Min((int)(ContextLength * 0.75), 150_000)
|
? Math.Min((int)(ContextLength * 0.75), MaxContextReserve)
|
||||||
: 150_000;
|
: MaxContextReserve;
|
||||||
|
|
||||||
return LastInputTokens >= threshold;
|
return LastInputTokens >= threshold;
|
||||||
}
|
}
|
||||||
|
|||||||
478
Program.cs
478
Program.cs
@@ -1,433 +1,45 @@
|
|||||||
using System.ClientModel;
|
using AnchorCli;
|
||||||
using Microsoft.Extensions.AI;
|
using AnchorCli.Commands;
|
||||||
using OpenAI;
|
using Spectre.Console;
|
||||||
using AnchorCli;
|
|
||||||
using AnchorCli.Tools;
|
Console.InputEncoding = System.Text.Encoding.UTF8;
|
||||||
using AnchorCli.Commands;
|
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||||
using AnchorCli.OpenRouter;
|
|
||||||
using Spectre.Console;
|
// ── Application entry point ───────────────────────────────────────────────
|
||||||
|
var startup = new ApplicationStartup(args);
|
||||||
// ── Setup subcommand ─────────────────────────────────────────────────────
|
|
||||||
if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
|
// Handle setup subcommand
|
||||||
{
|
if (startup.HandleSetupSubcommand())
|
||||||
SetupTui.Run();
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Config ──────────────────────────────────────────────────────────────
|
// Initialize application (load config, create clients, fetch pricing)
|
||||||
const string endpoint = "https://openrouter.ai/api/v1";
|
await startup.InitializeAsync();
|
||||||
var cfg = AnchorConfig.Load();
|
|
||||||
string apiKey = cfg.ApiKey;
|
// Render header
|
||||||
string model = cfg.Model;
|
var headerRenderer = startup.CreateHeaderRenderer();
|
||||||
|
headerRenderer.Render();
|
||||||
if (string.IsNullOrWhiteSpace(apiKey))
|
|
||||||
{
|
// Configure tool logging
|
||||||
AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]");
|
startup.ConfigureToolLogging();
|
||||||
return;
|
|
||||||
}
|
// Create core components
|
||||||
|
var session = startup.CreateSession();
|
||||||
// ── Fetch model pricing from OpenRouter ─────────────────────────────────
|
startup.TokenTracker.Session = session;
|
||||||
var pricingProvider = new PricingProvider();
|
|
||||||
var tokenTracker = new TokenTracker();
|
var commandRegistry = startup.CreateCommandRegistry(session);
|
||||||
|
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
||||||
ModelInfo? modelInfo = null;
|
|
||||||
await AnsiConsole.Status()
|
// Create session manager
|
||||||
.Spinner(Spinner.Known.BouncingBar)
|
var sessionManager = new SessionManager(session);
|
||||||
.SpinnerStyle(Style.Parse("cornflowerblue"))
|
|
||||||
.StartAsync("Fetching model pricing...", async ctx =>
|
// Auto-load session if it exists
|
||||||
{
|
await sessionManager.TryLoadAsync();
|
||||||
try
|
|
||||||
{
|
// Run REPL loop
|
||||||
modelInfo = await pricingProvider.GetModelInfoAsync(model);
|
var repl = new ReplLoop(session, startup.TokenTracker, commandDispatcher, sessionManager);
|
||||||
if (modelInfo?.Pricing != null)
|
await repl.RunAsync();
|
||||||
{
|
|
||||||
tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt);
|
// Auto-save session on clean exit
|
||||||
tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion);
|
await sessionManager.TrySaveAsync();
|
||||||
tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { /* pricing is best-effort */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Pretty header ───────────────────────────────────────────────────────
|
|
||||||
AnsiConsole.Write(
|
|
||||||
new FigletText("anchor")
|
|
||||||
.Color(Color.CornflowerBlue));
|
|
||||||
|
|
||||||
AnsiConsole.Write(
|
|
||||||
new Rule("[dim]AI-powered coding assistant[/]")
|
|
||||||
.RuleStyle(Style.Parse("cornflowerblue dim"))
|
|
||||||
.LeftJustified());
|
|
||||||
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
var infoTable = new Table()
|
|
||||||
.Border(TableBorder.Rounded)
|
|
||||||
.BorderColor(Color.Grey)
|
|
||||||
.AddColumn(new TableColumn("[dim]Setting[/]").NoWrap())
|
|
||||||
.AddColumn(new TableColumn("[dim]Value[/]"));
|
|
||||||
|
|
||||||
infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]");
|
|
||||||
infoTable.AddRow("[grey]Endpoint[/]", $"[blue]OpenRouter[/]");
|
|
||||||
infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]");
|
|
||||||
|
|
||||||
if (modelInfo?.Pricing != null)
|
|
||||||
{
|
|
||||||
var inM = tokenTracker.InputPrice * 1_000_000m;
|
|
||||||
var outM = tokenTracker.OutputPrice * 1_000_000m;
|
|
||||||
infoTable.AddRow("[grey]Pricing[/]",
|
|
||||||
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
|
|
||||||
}
|
|
||||||
if (modelInfo != null)
|
|
||||||
{
|
|
||||||
infoTable.AddRow("[grey]Context[/]",
|
|
||||||
$"[dim]{modelInfo.ContextLength:N0} tokens[/]");
|
|
||||||
}
|
|
||||||
|
|
||||||
AnsiConsole.Write(infoTable);
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
// ── Build the chat client with tool-calling support ─────────────────────
|
|
||||||
var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions
|
|
||||||
{
|
|
||||||
Endpoint = new Uri(endpoint)
|
|
||||||
});
|
|
||||||
|
|
||||||
IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
|
|
||||||
|
|
||||||
// ── Tool call logging via Spectre ───────────────────────────────────────
|
|
||||||
object consoleLock = new object();
|
|
||||||
|
|
||||||
void ToolLog(string message)
|
|
||||||
{
|
|
||||||
lock (consoleLock)
|
|
||||||
{
|
|
||||||
Console.Write("\r" + new string(' ', 40) + "\r");
|
|
||||||
AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CommandTool.Log =
|
|
||||||
DirTools.Log =
|
|
||||||
FileTools.Log =
|
|
||||||
EditTools.Log = ToolLog;
|
|
||||||
|
|
||||||
// ── Collect all tool methods ────────────────────────────────────────────
|
|
||||||
var jsonOptions = AppJsonContext.Default.Options;
|
|
||||||
|
|
||||||
var tools = new List<AITool>
|
|
||||||
{
|
|
||||||
AIFunctionFactory.Create(FileTools.ReadFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(FileTools.GrepFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.InsertAfter, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.CreateFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.DeleteFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.RenameFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(EditTools.CopyFile, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(DirTools.DeleteDir, serializerOptions: jsonOptions),
|
|
||||||
AIFunctionFactory.Create(FileTools.FindFiles, 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),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wrap with automatic function invocation
|
|
||||||
IChatClient agent = new ChatClientBuilder(innerClient)
|
|
||||||
.UseFunctionInvocation()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
// ── Context compactor ──────────────────────────────────────────────────
|
|
||||||
var compactor = new ContextCompactor(innerClient);
|
|
||||||
|
|
||||||
if (modelInfo != null)
|
|
||||||
tokenTracker.ContextLength = modelInfo.ContextLength;
|
|
||||||
|
|
||||||
// ── Chat history with system prompt ─────────────────────────────────────
|
|
||||||
List<ChatMessage> history =
|
|
||||||
[
|
|
||||||
new(ChatRole.System, $$"""
|
|
||||||
You are anchor, a coding assistant that edits files using the Hashline technique.
|
|
||||||
|
|
||||||
## Reading files
|
|
||||||
When you read a file, lines are returned in the format: lineNumber:hash|content
|
|
||||||
The "lineNumber:hash|" prefix is METADATA for anchoring — it is NOT part of the file.
|
|
||||||
|
|
||||||
## Editing files
|
|
||||||
To edit, reference anchors as "lineNumber:hash" in startAnchor/endAnchor parameters.
|
|
||||||
The newLines/initialLines parameter must contain RAW SOURCE CODE ONLY.
|
|
||||||
❌ WRONG: ["5:a3| public void Foo()"]
|
|
||||||
✅ RIGHT: [" public void Foo()"]
|
|
||||||
Never include the "lineNumber:hash|" prefix in content you write — it will corrupt the file.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
1. Always read a file before editing it.
|
|
||||||
2. After a mutation, verify the returned fingerprint.
|
|
||||||
3. Edit from bottom to top so line numbers don't shift.
|
|
||||||
4. If an anchor fails validation, re-read the file to get fresh anchors.
|
|
||||||
|
|
||||||
Keep responses concise. You have access to the current working directory.
|
|
||||||
You are running on: {{System.Runtime.InteropServices.RuntimeInformation.OSDescription}}
|
|
||||||
""")
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Command system ─────────────────────────────────────────────────────
|
|
||||||
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(compactor, history));
|
|
||||||
commandRegistry.Register(new SetupCommand());
|
|
||||||
|
|
||||||
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
|
||||||
|
|
||||||
|
|
||||||
// ── REPL ────────────────────────────────────────────────────────────────
|
|
||||||
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.WriteLine();
|
|
||||||
|
|
||||||
// Ctrl+C cancellation: cancel the current response, not the process
|
|
||||||
CancellationTokenSource? responseCts = null;
|
|
||||||
|
|
||||||
Console.CancelKeyPress += (_, e) =>
|
|
||||||
{
|
|
||||||
e.Cancel = true; // Prevent process termination
|
|
||||||
responseCts?.Cancel();
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
string input = ReadLine.Read("❯ ");
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(input)) continue;
|
|
||||||
|
|
||||||
// Try to execute slash command
|
|
||||||
if (await commandDispatcher.TryExecuteAsync(input, default)) continue;
|
|
||||||
|
|
||||||
history.Add(new ChatMessage(ChatRole.User, input));
|
|
||||||
|
|
||||||
// Track where this turn starts so we can compact previous turns' tool results
|
|
||||||
int turnStartIndex = history.Count;
|
|
||||||
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
// Create a fresh CancellationTokenSource for this response
|
|
||||||
responseCts = new CancellationTokenSource();
|
|
||||||
string fullResponse = "";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var options = new ChatOptions { Tools = tools };
|
|
||||||
|
|
||||||
// Get the async enumerator so we can split into spinner + streaming phases
|
|
||||||
await using var stream = agent
|
|
||||||
.GetStreamingResponseAsync(history, options, responseCts.Token)
|
|
||||||
.GetAsyncEnumerator(responseCts.Token);
|
|
||||||
|
|
||||||
string? firstChunk = null;
|
|
||||||
int respIn = 0, respOut = 0;
|
|
||||||
|
|
||||||
// Helper: extract usage from a streaming update's raw OpenAI representation
|
|
||||||
void CaptureUsage(ChatResponseUpdate update)
|
|
||||||
{
|
|
||||||
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
|
|
||||||
&& raw.Usage != null)
|
|
||||||
{
|
|
||||||
respIn += raw.Usage.InputTokenCount;
|
|
||||||
respOut += raw.Usage.OutputTokenCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1: Show BouncingBar spinner while agent thinks & invokes tools
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var spinnerTask = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
var frames = Spinner.Known.BouncingBar.Frames;
|
|
||||||
var interval = Spinner.Known.BouncingBar.Interval;
|
|
||||||
int i = 0;
|
|
||||||
|
|
||||||
// Hide cursor
|
|
||||||
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
|
|
||||||
{
|
|
||||||
// Clear the spinner line and show cursor
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Stream text tokens directly to the console
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record usage and display cost
|
|
||||||
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();
|
|
||||||
|
|
||||||
history.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
|
|
||||||
|
|
||||||
// ── Compact stale ReadFile/Grep results from previous turns ─
|
|
||||||
int compactedResults = ContextCompactor.CompactStaleToolResults(history, turnStartIndex);
|
|
||||||
if (compactedResults > 0)
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(
|
|
||||||
$"[dim grey] ♻ Compacted {compactedResults} stale tool result(s) from previous turns[/]");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auto-compact context if approaching the limit ───────────
|
|
||||||
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 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// Keep partial response in history so the agent has context
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
|
|
||||||
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(fullResponse))
|
|
||||||
{
|
|
||||||
history.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
AnsiConsole.Write(
|
|
||||||
new Panel($"[red]{Markup.Escape(ex.Message)}[/]")
|
|
||||||
.Header("[bold red] Error [/]")
|
|
||||||
.BorderColor(Color.Red)
|
|
||||||
.RoundedBorder()
|
|
||||||
.Padding(1, 0));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
responseCts?.Dispose();
|
|
||||||
responseCts = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
89
Providers/GenericTokenExtractor.cs
Normal file
89
Providers/GenericTokenExtractor.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic token extractor for any OpenAI-compatible endpoint.
|
||||||
|
/// Tries common header names and JSON body parsing.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GenericTokenExtractor : ITokenExtractor
|
||||||
|
{
|
||||||
|
public string ProviderName => "Generic";
|
||||||
|
|
||||||
|
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
|
||||||
|
{
|
||||||
|
// Try various common header names
|
||||||
|
var headerNames = new[] {
|
||||||
|
"x-total-tokens",
|
||||||
|
"x-ai-response-tokens",
|
||||||
|
"x-tokens",
|
||||||
|
"x-prompt-tokens",
|
||||||
|
"x-completion-tokens"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var headerName in headerNames)
|
||||||
|
{
|
||||||
|
if (headers.TryGetValues(headerName, out var values))
|
||||||
|
{
|
||||||
|
if (int.TryParse(values.FirstOrDefault(), out var tokens))
|
||||||
|
{
|
||||||
|
// Assume all tokens are output if we can't determine split
|
||||||
|
return (0, tokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try parsing from response body JSON
|
||||||
|
if (!string.IsNullOrEmpty(responseBody))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(responseBody);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
// Try standard OpenAI format: usage.prompt_tokens, usage.completion_tokens
|
||||||
|
if (root.TryGetProperty("usage", out var usage))
|
||||||
|
{
|
||||||
|
var prompt = usage.TryGetProperty("prompt_tokens", out var p) ? p.GetInt32() : 0;
|
||||||
|
var completion = usage.TryGetProperty("completion_tokens", out var c) ? c.GetInt32() : 0;
|
||||||
|
|
||||||
|
if (prompt > 0 || completion > 0)
|
||||||
|
{
|
||||||
|
return (prompt, completion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore parsing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? ExtractLatency(HttpResponseHeaders headers)
|
||||||
|
{
|
||||||
|
// Try various common latency headers
|
||||||
|
var headerNames = new[] {
|
||||||
|
"x-response-time",
|
||||||
|
"x-response-timing",
|
||||||
|
"x-latency-ms",
|
||||||
|
"x-duration-ms"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var headerName in headerNames)
|
||||||
|
{
|
||||||
|
if (headers.TryGetValues(headerName, out var values))
|
||||||
|
{
|
||||||
|
if (int.TryParse(values.FirstOrDefault(), out var latency))
|
||||||
|
{
|
||||||
|
return latency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Providers/GroqProvider.cs
Normal file
61
Providers/GroqProvider.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Token extractor for Groq responses.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GroqTokenExtractor : ITokenExtractor
|
||||||
|
{
|
||||||
|
public string ProviderName => "Groq";
|
||||||
|
|
||||||
|
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
|
||||||
|
{
|
||||||
|
// Groq provides x-groq-tokens header (format: "n;<prompt_tokens>,n;<completion_tokens>")
|
||||||
|
if (headers.TryGetValues("x-groq-tokens", out var values))
|
||||||
|
{
|
||||||
|
var tokenStr = values.FirstOrDefault();
|
||||||
|
if (!string.IsNullOrEmpty(tokenStr))
|
||||||
|
{
|
||||||
|
// Parse format: "n;123,n;45" where first is prompt, second is completion
|
||||||
|
var parts = tokenStr.Split(',');
|
||||||
|
if (parts.Length >= 2)
|
||||||
|
{
|
||||||
|
var inputPart = parts[0].Trim();
|
||||||
|
var outputPart = parts[1].Trim();
|
||||||
|
|
||||||
|
// Extract numbers after "n;"
|
||||||
|
if (inputPart.StartsWith("n;") && outputPart.StartsWith("n;"))
|
||||||
|
{
|
||||||
|
if (int.TryParse(inputPart[2..], out var input) &&
|
||||||
|
int.TryParse(outputPart[2..], out var output))
|
||||||
|
{
|
||||||
|
return (input, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try parsing from response body
|
||||||
|
if (!string.IsNullOrEmpty(responseBody))
|
||||||
|
{
|
||||||
|
// TODO: Parse usage from JSON body if headers aren't available
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? ExtractLatency(HttpResponseHeaders headers)
|
||||||
|
{
|
||||||
|
if (headers.TryGetValues("x-groq-response-time", out var values))
|
||||||
|
{
|
||||||
|
if (int.TryParse(values.FirstOrDefault(), out var latency))
|
||||||
|
{
|
||||||
|
return latency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Providers/IPricingProvider.cs
Normal file
18
Providers/IPricingProvider.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for fetching model pricing information.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IPricingProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches pricing info for a specific model.
|
||||||
|
/// </summary>
|
||||||
|
Task<ModelInfo?> GetModelInfoAsync(string modelId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches all available models with pricing.
|
||||||
|
/// </summary>
|
||||||
|
Task<Dictionary<string, ModelInfo>> GetAllModelsAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
25
Providers/ITokenExtractor.cs
Normal file
25
Providers/ITokenExtractor.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for extracting token usage from provider responses.
|
||||||
|
/// </summary>
|
||||||
|
internal interface ITokenExtractor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts token usage from response headers and/or body.
|
||||||
|
/// Returns (inputTokens, outputTokens) or null if unavailable.
|
||||||
|
/// </summary>
|
||||||
|
(int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the latency from response headers (in ms).
|
||||||
|
/// </summary>
|
||||||
|
int? ExtractLatency(HttpResponseHeaders headers);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the provider name for display purposes.
|
||||||
|
/// </summary>
|
||||||
|
string ProviderName { get; }
|
||||||
|
}
|
||||||
39
Providers/OllamaTokenExtractor.cs
Normal file
39
Providers/OllamaTokenExtractor.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Token extractor for Ollama responses.
|
||||||
|
/// Ollama doesn't provide official token counts, so we estimate.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OllamaTokenExtractor : ITokenExtractor
|
||||||
|
{
|
||||||
|
public string ProviderName => "Ollama";
|
||||||
|
|
||||||
|
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
|
||||||
|
{
|
||||||
|
// Ollama doesn't provide token headers
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? ExtractLatency(HttpResponseHeaders headers)
|
||||||
|
{
|
||||||
|
// Ollama doesn't provide latency headers
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Estimates token count from text length (rough approximation).
|
||||||
|
/// Assumes ~4 characters per token on average.
|
||||||
|
/// </summary>
|
||||||
|
public static int EstimateTokens(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rough estimate: 4 characters per token
|
||||||
|
return text.Length / 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Providers/OpenRouterProvider.cs
Normal file
40
Providers/OpenRouterProvider.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pricing provider for OpenRouter API.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OpenRouterProvider : IPricingProvider
|
||||||
|
{
|
||||||
|
private const string ModelsUrl = "https://openrouter.ai/api/v1/models";
|
||||||
|
private static readonly HttpClient Http = new();
|
||||||
|
private Dictionary<string, ModelInfo>? _models;
|
||||||
|
|
||||||
|
static OpenRouterProvider()
|
||||||
|
{
|
||||||
|
OpenRouterHeaders.ApplyTo(Http);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<string, ModelInfo>> GetAllModelsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_models != null) return _models;
|
||||||
|
|
||||||
|
var response = await Http.GetAsync(ModelsUrl, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
var result = JsonSerializer.Deserialize(json, AppJsonContext.Default.ModelsResponse);
|
||||||
|
|
||||||
|
_models = result?.Data?.ToDictionary(m => m.Id) ?? [];
|
||||||
|
return _models;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ModelInfo?> GetModelInfoAsync(string modelId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var models = await GetAllModelsAsync(ct);
|
||||||
|
return models.GetValueOrDefault(modelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Providers/OpenRouterTokenExtractor.cs
Normal file
42
Providers/OpenRouterTokenExtractor.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Token extractor for OpenRouter responses.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OpenRouterTokenExtractor : ITokenExtractor
|
||||||
|
{
|
||||||
|
public string ProviderName => "OpenRouter";
|
||||||
|
|
||||||
|
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
|
||||||
|
{
|
||||||
|
// OpenRouter provides x-total-tokens header
|
||||||
|
if (headers.TryGetValues("x-total-tokens", out var values))
|
||||||
|
{
|
||||||
|
// Note: OpenRouter only provides total tokens, not split
|
||||||
|
// We'll estimate split based on typical ratios if needed
|
||||||
|
if (long.TryParse(values.FirstOrDefault(), out var total))
|
||||||
|
{
|
||||||
|
// For now, return total as output (placeholder until we have better splitting)
|
||||||
|
// In practice, you'd need to track input separately from the request
|
||||||
|
return (0, (int)total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? ExtractLatency(HttpResponseHeaders headers)
|
||||||
|
{
|
||||||
|
if (headers.TryGetValues("x-response-timing", out var values))
|
||||||
|
{
|
||||||
|
if (int.TryParse(values.FirstOrDefault(), out var latency))
|
||||||
|
{
|
||||||
|
return latency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Providers/ProviderFactory.cs
Normal file
70
Providers/ProviderFactory.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory for creating provider instances based on endpoint or provider name.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ProviderFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a token extractor based on the provider name.
|
||||||
|
/// </summary>
|
||||||
|
public static ITokenExtractor CreateTokenExtractor(string providerName)
|
||||||
|
{
|
||||||
|
return providerName.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"openrouter" => new OpenRouterTokenExtractor(),
|
||||||
|
"groq" => new GroqTokenExtractor(),
|
||||||
|
"ollama" => new OllamaTokenExtractor(),
|
||||||
|
_ => new GenericTokenExtractor()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a token extractor by auto-detecting from the endpoint URL.
|
||||||
|
/// </summary>
|
||||||
|
public static ITokenExtractor CreateTokenExtractorForEndpoint(string endpoint)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(endpoint))
|
||||||
|
{
|
||||||
|
return new GenericTokenExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = endpoint.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (url.Contains("openrouter"))
|
||||||
|
{
|
||||||
|
return new OpenRouterTokenExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.Contains("groq"))
|
||||||
|
{
|
||||||
|
return new GroqTokenExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.Contains("ollama") || url.Contains("localhost") || url.Contains("127.0.0.1"))
|
||||||
|
{
|
||||||
|
return new OllamaTokenExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GenericTokenExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pricing provider based on the provider name.
|
||||||
|
/// Only OpenRouter has a pricing API currently.
|
||||||
|
/// </summary>
|
||||||
|
public static IPricingProvider? CreatePricingProvider(string providerName)
|
||||||
|
{
|
||||||
|
return providerName.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"openrouter" => new OpenRouterProvider(),
|
||||||
|
_ => null // Other providers don't have pricing APIs yet
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if an endpoint is OpenRouter.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsOpenRouter(string endpoint) =>
|
||||||
|
!string.IsNullOrEmpty(endpoint) && endpoint.Contains("openrouter", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
129
README.md
129
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`
|
- **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,85 +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 |
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
AnchorCli/
|
|
||||||
├── Program.cs # Entry point + REPL loop + AI client setup
|
|
||||||
├── AnchorConfig.cs # JSON file-based configuration (~APPDATA~\anchor\config.json)
|
|
||||||
├── ContextCompactor.cs # Conversation history compression
|
|
||||||
├── AppJsonContext.cs # Source-generated JSON context (AOT)
|
|
||||||
├── 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/
|
|
||||||
│ ├── ExitCommand.cs # /exit command
|
|
||||||
│ ├── HelpCommand.cs # /help command
|
|
||||||
│ ├── ClearCommand.cs # /clear command
|
|
||||||
│ ├── StatusCommand.cs # /status command
|
|
||||||
│ └── CompactCommand.cs # /compact command
|
|
||||||
├── OpenRouter/
|
|
||||||
│ └── PricingProvider.cs # Fetch model pricing from OpenRouter
|
|
||||||
└── SetupTui.cs # Interactive setup TUI
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
||||||
@@ -145,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.
|
||||||
|
|||||||
172
ReplLoop.cs
Normal file
172
ReplLoop.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Spectre.Console;
|
||||||
|
using AnchorCli.Commands;
|
||||||
|
using AnchorCli.Tools;
|
||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
private readonly ChatSession _session;
|
||||||
|
private readonly TokenTracker _tokenTracker;
|
||||||
|
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,
|
||||||
|
SessionManager sessionManager)
|
||||||
|
{
|
||||||
|
_session = session;
|
||||||
|
_tokenTracker = tokenTracker;
|
||||||
|
_commandDispatcher = commandDispatcher;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_streamer = new ResponseStreamer(session);
|
||||||
|
_usageDisplayer = new UsageDisplayer(tokenTracker);
|
||||||
|
_compactionService = new ContextCompactionService(session.Compactor, session.History, tokenTracker);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunAsync()
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]");
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
CancellationTokenSource? responseCts = null;
|
||||||
|
|
||||||
|
Console.CancelKeyPress += (_, e) =>
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
responseCts?.Cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
AnsiConsole.Markup("[grey]❯ [/]");
|
||||||
|
string input = InputProcessor.ReadLine("Type your message, or use [bold]/help[/] to see commands.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(input)) continue;
|
||||||
|
|
||||||
|
if (await _commandDispatcher.TryExecuteAsync(input, default)) continue;
|
||||||
|
|
||||||
|
_session.History.Add(new Microsoft.Extensions.AI.ChatMessage(Microsoft.Extensions.AI.ChatRole.User, input));
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
responseCts?.Dispose();
|
||||||
|
responseCts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessTurnAsync(responseCts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
HandleCancellation();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
DisplayError(ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
responseCts?.Dispose();
|
||||||
|
responseCts = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
SetupTui.cs
89
SetupTui.cs
@@ -27,16 +27,93 @@ internal static class SetupTui
|
|||||||
|
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
// ── Provider ────────────────────────────────────────────────────
|
||||||
|
var providers = new List<(string Value, string Description)>
|
||||||
|
{
|
||||||
|
("openrouter", "default, pricing support"),
|
||||||
|
("groq", "high-speed inference"),
|
||||||
|
("ollama", "local, no auth required"),
|
||||||
|
("openai", "official OpenAI API"),
|
||||||
|
("custom", "generic OpenAI-compatible endpoint")
|
||||||
|
};
|
||||||
|
|
||||||
|
string currentProvider = config.Provider ?? "openrouter";
|
||||||
|
AnsiConsole.MarkupLine($" Current provider: [cyan]{Markup.Escape(currentProvider)}[/]");
|
||||||
|
|
||||||
|
var selectedProviderChoice = AnsiConsole.Prompt(
|
||||||
|
new SelectionPrompt<(string Value, string Description)>()
|
||||||
|
.Title(" Select a provider:")
|
||||||
|
.UseConverter(p => p.Value + (string.IsNullOrEmpty(p.Description) ? "" : $" [dim]({p.Description})[/]"))
|
||||||
|
.AddChoices(providers));
|
||||||
|
|
||||||
|
config.Provider = selectedProviderChoice.Value;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (config.Provider == "custom")
|
||||||
|
{
|
||||||
|
string customEndpoint = AnsiConsole.Prompt(
|
||||||
|
new TextPrompt<string>(" Enter endpoint URL:")
|
||||||
|
.DefaultValue(config.Endpoint)
|
||||||
|
.AllowEmpty());
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(customEndpoint))
|
||||||
|
{
|
||||||
|
config.Endpoint = customEndpoint.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
config.Endpoint = config.Provider.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"openrouter" => "https://openrouter.ai/api/v1",
|
||||||
|
"groq" => "https://api.groq.com/openai/v1",
|
||||||
|
"ollama" => "http://localhost:11434/v1",
|
||||||
|
"openai" => "https://api.openai.com/v1",
|
||||||
|
_ => config.Endpoint
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
// ── Model ─────────────────────────────────────────────────────
|
// ── Model ─────────────────────────────────────────────────────
|
||||||
AnsiConsole.MarkupLine($" Current model: [cyan]{Markup.Escape(config.Model)}[/]");
|
AnsiConsole.MarkupLine($" Current model: [cyan]{Markup.Escape(config.Model)}[/]");
|
||||||
|
|
||||||
var models = new List<(string Value, string Description)>
|
var models = config.Provider.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
("qwen/qwen3.5-397b-a17b", "smart, expensive"),
|
"groq" => new List<(string Value, string Description)>
|
||||||
("qwen/qwen3.5-35b-a3b", "cheapest"),
|
{
|
||||||
("qwen/qwen3.5-27b", "fast"),
|
("llama-3.3-70b-versatile", "fast, powerful"),
|
||||||
("qwen/qwen3.5-122b-a10b", "smart"),
|
("llama-3.1-8b-instant", "very fast"),
|
||||||
("Custom...", "")
|
("mixtral-8x7b-32768", "sparse MoE"),
|
||||||
|
("gemma2-9b-it", "Google's Gemma"),
|
||||||
|
("Custom...", "")
|
||||||
|
},
|
||||||
|
"ollama" => new List<(string Value, string Description)>
|
||||||
|
{
|
||||||
|
("llama3.2", "Meta's Llama 3.2"),
|
||||||
|
("qwen2.5", "Alibaba Qwen"),
|
||||||
|
("mistral", "Mistral AI"),
|
||||||
|
("codellama", "code-focused"),
|
||||||
|
("Custom...", "")
|
||||||
|
},
|
||||||
|
"openai" => new List<(string Value, string Description)>
|
||||||
|
{
|
||||||
|
("gpt-4o", "most capable"),
|
||||||
|
("gpt-4o-mini", "fast, affordable"),
|
||||||
|
("o1-preview", "reasoning model"),
|
||||||
|
("Custom...", "")
|
||||||
|
},
|
||||||
|
_ => new List<(string Value, string Description)>
|
||||||
|
{
|
||||||
|
("qwen/qwen3.5-397b-a17b", "smart, expensive"),
|
||||||
|
("qwen/qwen3.5-122b-a10b", "faster"),
|
||||||
|
("qwen/qwen3.5-27b", "fast"),
|
||||||
|
("qwen/qwen3.5-flash-02-23", "cloud, fast"),
|
||||||
|
("qwen/qwen3.5-plus-02-15", "cloud, smart"),
|
||||||
|
("Custom...", "")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
string selectedModel = AnsiConsole.Prompt(
|
string selectedModel = AnsiConsole.Prompt(
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
ToolRegistry.cs
Normal file
30
ToolRegistry.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using AnchorCli.Tools;
|
||||||
|
|
||||||
|
namespace AnchorCli;
|
||||||
|
|
||||||
|
internal static class ToolRegistry
|
||||||
|
{
|
||||||
|
public static List<AITool> GetTools()
|
||||||
|
{
|
||||||
|
var jsonOptions = AppJsonContext.Default.Options;
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
AIFunctionFactory.Create(FileTools.ReadFile, name: "read_file", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(FileTools.Grep, name: "grep", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(FileTools.ListDir, name: "list_dir", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.ReplaceLines, name: "replace_lines", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.DeleteRange, name: "delete_range", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.BatchEdit, name: "batch_edit", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.Delete, name: "delete_file", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(FileTools.FindFiles, name: "find_files", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(FileTools.GetFileInfo, name: "get_file_info", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.WriteToFile, name: "write_to_file", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(CommandTool.ExecuteCommand, name: "execute_command", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(EditTools.MoveFile, name: "rename_file", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(DirTools.RenameDir, name: "rename_dir", serializerOptions: jsonOptions),
|
||||||
|
AIFunctionFactory.Create(DirTools.CreateDir, name: "create_dir", 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.
|
||||||
@@ -52,29 +61,20 @@ internal static partial class EditTools
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Description("Replace a range of lines in a file, identified by Hashline anchors. Both the line number and hash must match the current file state.")]
|
[Description("Replace a range of lines. Both line number and hash in the line:hash anchor must match.")]
|
||||||
public static string ReplaceLines(
|
public static string ReplaceLines(
|
||||||
[Description("Path to the file.")] string path,
|
[Description("Path to the file.")] string path,
|
||||||
[Description("line:hash anchor of the first line to replace (e.g. '5:a3'). Both the line number AND hash must match.")] string startAnchor,
|
[Description("First line's line:hash anchor (e.g. '5:a3').")] string startAnchor,
|
||||||
[Description("line:hash anchor of the last line to replace (e.g. '7:0e'). Use the same as startAnchor to replace a single line.")] string endAnchor,
|
[Description("Last line's line:hash anchor. Use same as startAnchor for a single line.")] string endAnchor,
|
||||||
[Description("New lines to insert in place of the replaced range. Each element becomes one line in the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] string[] newLines)
|
[Description("Raw source code to insert. Do NOT include 'lineNumber:hash|' prefixes.")] string[] newLines)
|
||||||
{
|
{
|
||||||
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");
|
|
||||||
/*Log($" New content (first 5 lines):");
|
|
||||||
foreach (var line in newLines.Take(5))
|
|
||||||
{
|
|
||||||
Log($" + {line}");
|
|
||||||
}
|
|
||||||
if (newLines.Length > 5)
|
|
||||||
{
|
|
||||||
Log($" ... and {newLines.Length - 5} more 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
|
||||||
{
|
{
|
||||||
@@ -82,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]);
|
||||||
@@ -94,66 +94,20 @@ 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 new lines immediately after the line identified by a Hashline anchor.")]
|
|
||||||
public static string InsertAfter(
|
|
||||||
[Description("Path to the file.")] string path,
|
|
||||||
[Description("line:hash anchor of the line to insert after (e.g. '3:0e'). Both the line number AND hash must match.")] string anchor,
|
|
||||||
[Description("Lines to insert after the anchor line. Each element becomes one line in the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] 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]}");
|
|
||||||
/*Log($" New content (first 5 lines):");
|
|
||||||
foreach (var line in newLines.Take(5))
|
|
||||||
{
|
|
||||||
Log($" + {line}");
|
|
||||||
}
|
|
||||||
if (newLines.Length > 5)
|
|
||||||
{
|
|
||||||
Log($" ... and {newLines.Length - 5} more lines");
|
|
||||||
}*/
|
|
||||||
|
|
||||||
if (!File.Exists(path))
|
[Description("Delete a range of lines.")]
|
||||||
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}";
|
|
||||||
|
|
||||||
// Log the anchor line we're inserting after
|
|
||||||
//Log($" Inserting after line {idx + 1}: {lines[idx]}");
|
|
||||||
|
|
||||||
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 from a file, identified by Hashline anchors.")]
|
|
||||||
public static string DeleteRange(
|
public static string DeleteRange(
|
||||||
[Description("Path to the file.")] string path,
|
[Description("Path to the file.")] string path,
|
||||||
[Description("line:hash anchor of the first line to delete (e.g. '4:7c'). Both the line number AND hash must match.")] string startAnchor,
|
[Description("First line's line:hash anchor (e.g. '4:7c').")] string startAnchor,
|
||||||
[Description("line:hash anchor of the last line to delete (e.g. '6:19'). Both the line number AND hash must match.")] 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}";
|
||||||
@@ -175,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 empty file, or a file with initial content. Creates missing parent directories automatically. If the agent doesn't succeed with initial content, they can also create an empty file first and add the content using AppendToFile.")]
|
|
||||||
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 content lines. If omitted, creates an empty file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] 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 from the disk.")]
|
[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. Can move a file to a new directory (which will be created if it doesn't exist).")]
|
|
||||||
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 the end of a file without reading it first. Creates the file if it doesn't exist.")]
|
|
||||||
public static string AppendToFile(
|
|
||||||
[Description("Path to the file to append to.")] string path,
|
|
||||||
[Description("Lines to append to the end of the file. IMPORTANT: Write raw source code only. Do NOT include 'lineNumber:hash|' prefixes — those are display-only metadata from ReadFile, not part of the actual file content.")] 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.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,20 @@ internal static class FileTools
|
|||||||
{
|
{
|
||||||
public static Action<string> Log { get; set; } = Console.WriteLine;
|
public static Action<string> Log { get; set; } = Console.WriteLine;
|
||||||
|
|
||||||
[Description("Read a file and return its lines tagged with Hashline anchors in the format lineNumber:hash|content. Optionally restrict to a line window.")]
|
/// <summary>
|
||||||
|
/// Optional callback invoked after each successful ReadFile call, with the resolved path.
|
||||||
|
/// Set by ReplLoop to trigger deduplication compaction while the tool loop is still active.
|
||||||
|
/// </summary>
|
||||||
|
public static Action<string>? OnFileRead { get; set; }
|
||||||
|
|
||||||
|
[Description("Read a file. Max 200 lines per call. Returns lines with line:hash| anchors. IMPORTANT: Call GrepFile first (pattern: 'public|class|func|interface|enum|def') to get a structural outline and target startLine/endLine before calling this.")]
|
||||||
public static string ReadFile(
|
public static string ReadFile(
|
||||||
[Description("Path to the file to read. Can be relative to the working directory or absolute.")] string path,
|
[Description("Path to the file.")] string path,
|
||||||
[Description("First line to return, 1-indexed inclusive. Defaults to 1.")] int startLine = 1,
|
[Description("First line to return (inclusive). Defaults to 1.")] int startLine = 1,
|
||||||
[Description("Last line to return, 1-indexed inclusive. Use 0 for end of file. 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}");
|
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}";
|
||||||
@@ -33,7 +39,16 @@ internal static class FileTools
|
|||||||
if (lines.Length == 0)
|
if (lines.Length == 0)
|
||||||
return $"(empty file: {path})";
|
return $"(empty file: {path})";
|
||||||
|
|
||||||
return HashlineEncoder.Encode(lines, startLine, endLine);
|
int actualEnd = endLine <= 0 ? lines.Length : Math.Min(endLine, lines.Length);
|
||||||
|
int start = Math.Max(1, startLine);
|
||||||
|
if (actualEnd - start + 1 > 200)
|
||||||
|
{
|
||||||
|
return $"ERROR: File too large to read at once ({lines.Length} lines). Provide startLine and endLine to read a chunk of max 200 lines. Use GrepFile to get an outline (grep 'public') and find the line numbers.";
|
||||||
|
}
|
||||||
|
|
||||||
|
string result = HashlineEncoder.Encode(lines, startLine, endLine);
|
||||||
|
OnFileRead?.Invoke(path);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -41,61 +56,13 @@ internal static class FileTools
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Description("Search a file for lines matching a regex pattern. Returns only matching lines, already tagged with Hashline anchors so you can reference them in edit operations immediately.")]
|
|
||||||
public static string GrepFile(
|
|
||||||
[Description("Path to the file to search.")] string path,
|
|
||||||
[Description("Regular expression pattern to search for.")] string pattern)
|
|
||||||
{
|
|
||||||
path = ResolvePath(path);
|
|
||||||
Log($"Searching file: {path}");
|
|
||||||
|
|
||||||
if (!File.Exists(path))
|
[Description("List files and subdirectories.")]
|
||||||
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 the files and subdirectories in a directory.")]
|
|
||||||
public static string ListDir(
|
public static string ListDir(
|
||||||
[Description("Path to the directory to list. Defaults to the current working 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}";
|
||||||
@@ -122,13 +89,13 @@ internal static class FileTools
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Description("Search for files matching a glob/wildcard pattern (e.g., '*.cs', 'src/**/*.js'). Returns full paths of matching files.")]
|
[Description("Find files matching a glob pattern (e.g. '*.cs', '**/*.json').")]
|
||||||
public static string FindFiles(
|
public static string FindFiles(
|
||||||
[Description("Path to start the search (directory).")] string path,
|
[Description("Directory to start search.")] string path,
|
||||||
[Description("Glob pattern to match files (e.g., '*.cs', '**/*.json'). Supports * and ** wildcards.")] 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}";
|
||||||
@@ -159,75 +126,136 @@ internal static class FileTools
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Description("Search for a regex pattern across all files in a directory tree. Returns matches with file:line:hash|content format.")]
|
|
||||||
public static string GrepRecursive(
|
[Description("Consolidated grep operation for single file or recursive directory search.")]
|
||||||
[Description("Path to the directory to search recursively.")] string path,
|
public static string Grep(
|
||||||
[Description("Regular expression pattern to search for.")] string pattern,
|
[Description("Directory to search (for recursive mode) or file path (for file mode).")] string path,
|
||||||
[Description("Optional glob pattern to filter which files to search (e.g., '*.cs'). Defaults to all files.")] string? filePattern = null)
|
[Description("Regex pattern.")] string pattern,
|
||||||
|
[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'.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,12 +292,12 @@ internal static class FileTools
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Description("Get detailed information about a file (size, permissions, last modified, type, etc.).")]
|
[Description("Get detailed file info (size, last modified, etc).")]
|
||||||
public static string GetFileInfo(
|
public static string GetFileInfo(
|
||||||
[Description("Path to the file to get information about.")] 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
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*
|
||||||
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)*
|
||||||
Reference in New Issue
Block a user