1
0

feat: Introduce Hashline encoding/validation, implement core REPL and chat session logic, and establish initial project structure with a license and editor configuration.

This commit is contained in:
2026-03-04 14:54:36 +01:00
parent 928ca8c454
commit 31cf7cb4c1
12 changed files with 492 additions and 327 deletions

12
.editorconfig Normal file
View 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

View File

@@ -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'))">

59
ChatSession.cs Normal file
View File

@@ -0,0 +1,59 @@
using Microsoft.Extensions.AI;
namespace AnchorCli;
internal sealed class ChatSession
{
private readonly IChatClient _agent;
public ContextCompactor Compactor { get; }
public List<ChatMessage> History { get; }
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 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}}
""")
};
}
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;
}
}
}

View File

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

View File

@@ -83,6 +83,13 @@ internal static class HashlineValidator
/// <summary> /// <summary>
/// Validates both a start and end anchor, and ensures start &lt;= end. /// Validates both a start and end anchor, and ensures start &lt;= 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,

90
IMPROVEME.md Normal file
View File

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

21
LICENSE Normal file
View 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.

View File

@@ -35,6 +35,8 @@ internal sealed class TokenTracker
RequestCount++; RequestCount++;
} }
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.
/// Triggers at min(75% of model context, 150K tokens). /// Triggers at min(75% of model context, 150K tokens).
@@ -44,8 +46,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;
} }

View File

@@ -112,322 +112,25 @@ DirTools.Log =
FileTools.Log = FileTools.Log =
EditTools.Log = ToolLog; EditTools.Log = ToolLog;
// ── Collect all tool methods ──────────────────────────────────────────── // ── Instantiate Core Components ──────────────────────────────────────────
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);
var session = new ChatSession(innerClient);
if (modelInfo != null) if (modelInfo != null)
{
tokenTracker.ContextLength = modelInfo.ContextLength; 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(); var commandRegistry = new CommandRegistry();
commandRegistry.Register(new ExitCommand()); commandRegistry.Register(new ExitCommand());
commandRegistry.Register(new HelpCommand(commandRegistry)); commandRegistry.Register(new HelpCommand(commandRegistry));
commandRegistry.Register(new ClearCommand()); commandRegistry.Register(new ClearCommand());
commandRegistry.Register(new StatusCommand(model, endpoint)); commandRegistry.Register(new StatusCommand(model, endpoint));
commandRegistry.Register(new CompactCommand(compactor, history)); commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
commandRegistry.Register(new SetupCommand()); commandRegistry.Register(new SetupCommand());
var commandDispatcher = new CommandDispatcher(commandRegistry); var commandDispatcher = new CommandDispatcher(commandRegistry);
// ── Run Repl ────────────────────────────────────────────────────────────
// ── REPL ──────────────────────────────────────────────────────────────── var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
AnsiConsole.MarkupLine("[dim]Type your message, or use [bold]/help[/] to see commands.[/]"); await repl.RunAsync();
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;
}
}

251
ReplLoop.cs Normal file
View File

@@ -0,0 +1,251 @@
using Microsoft.Extensions.AI;
using OpenAI;
using Spectre.Console;
using AnchorCli.OpenRouter;
using AnchorCli.Commands;
using AnchorCli.Tools;
namespace AnchorCli;
internal sealed class ReplLoop
{
private readonly ChatSession _session;
private readonly TokenTracker _tokenTracker;
private readonly CommandDispatcher _commandDispatcher;
public ReplLoop(ChatSession session, TokenTracker tokenTracker, CommandDispatcher commandDispatcher)
{
_session = session;
_tokenTracker = tokenTracker;
_commandDispatcher = commandDispatcher;
}
public async Task RunAsync()
{
AnsiConsole.MarkupLine("[dim]Type your message, or use [bold]/help[/] to see commands.[/]");
AnsiConsole.MarkupLine("[dim]Press [bold]Ctrl+C[/] to cancel the current response.[/]");
AnsiConsole.WriteLine();
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;
if (await _commandDispatcher.TryExecuteAsync(input, default)) continue;
_session.History.Add(new ChatMessage(ChatRole.User, input));
int turnStartIndex = _session.History.Count;
AnsiConsole.WriteLine();
responseCts = new CancellationTokenSource();
string fullResponse = "";
try
{
await using var stream = _session
.GetStreamingResponseAsync(responseCts.Token)
.GetAsyncEnumerator(responseCts.Token);
string? firstChunk = null;
int respIn = 0, respOut = 0;
void CaptureUsage(ChatResponseUpdate update)
{
if (update.RawRepresentation is OpenAI.Chat.StreamingChatCompletionUpdate raw
&& raw.Usage != null)
{
respIn += raw.Usage.InputTokenCount;
respOut += raw.Usage.OutputTokenCount;
}
}
object consoleLock = new();
using var spinnerCts = CancellationTokenSource.CreateLinkedTokenSource(responseCts.Token);
bool showSpinner = true;
CommandTool.PauseSpinner = () =>
{
lock (consoleLock)
{
showSpinner = false;
Console.Write("\r" + new string(' ', 40) + "\r");
}
};
CommandTool.ResumeSpinner = () =>
{
lock (consoleLock)
{
showSpinner = true;
}
};
var spinnerTask = Task.Run(async () =>
{
var frames = Spinner.Known.BouncingBar.Frames;
var interval = Spinner.Known.BouncingBar.Interval;
int i = 0;
Console.Write("\x1b[?25l");
try
{
while (!spinnerCts.Token.IsCancellationRequested)
{
lock (consoleLock)
{
if (showSpinner && !spinnerCts.Token.IsCancellationRequested)
{
var frame = frames[i % frames.Count];
Console.Write($"\r\x1b[38;5;69m{frame}\x1b[0m Thinking...");
i++;
}
}
try { await Task.Delay(interval, spinnerCts.Token); } catch { }
}
}
finally
{
lock (consoleLock)
{
if (showSpinner)
Console.Write("\r" + new string(' ', 40) + "\r");
Console.Write("\x1b[?25h");
}
}
});
try
{
while (await stream.MoveNextAsync())
{
responseCts.Token.ThrowIfCancellationRequested();
CaptureUsage(stream.Current);
if (!string.IsNullOrEmpty(stream.Current.Text))
{
firstChunk = stream.Current.Text;
fullResponse = firstChunk;
break;
}
}
}
finally
{
spinnerCts.Cancel();
await Task.WhenAny(spinnerTask);
CommandTool.PauseSpinner = null;
CommandTool.ResumeSpinner = null;
}
if (firstChunk != null)
{
AnsiConsole.Markup(Markup.Escape(firstChunk));
}
while (await stream.MoveNextAsync())
{
responseCts.Token.ThrowIfCancellationRequested();
CaptureUsage(stream.Current);
var text = stream.Current.Text;
if (!string.IsNullOrEmpty(text))
{
AnsiConsole.Markup(Markup.Escape(text));
}
fullResponse += text;
}
if (respIn > 0 || respOut > 0)
{
_tokenTracker.AddUsage(respIn, respOut);
var cost = _tokenTracker.CalculateCost(respIn, respOut);
var ctxPct = _tokenTracker.ContextUsagePercent;
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine(
$"[dim grey] {TokenTracker.FormatTokens(respIn)}↑ {TokenTracker.FormatTokens(respOut)}↓" +
$" {TokenTracker.FormatCost(cost)}" +
(ctxPct >= 0 ? $" ctx:{ctxPct:F0}%" : "") +
$" │ session: {TokenTracker.FormatCost(_tokenTracker.SessionCost)}[/]");
}
else
{
AnsiConsole.WriteLine();
}
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
AnsiConsole.WriteLine();
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
int compactedResults = ContextCompactor.CompactStaleToolResults(_session.History, turnStartIndex);
if (compactedResults > 0)
{
AnsiConsole.MarkupLine(
$"[dim grey] ♻ Compacted {compactedResults} stale tool result(s) from previous turns[/]");
}
if (_tokenTracker.ShouldCompact())
{
var pct = _tokenTracker.ContextUsagePercent;
AnsiConsole.MarkupLine(
$"[yellow]⚠ Context at {pct:F0}% — compacting conversation history...[/]");
bool compacted = await AnsiConsole.Status()
.Spinner(Spinner.Known.BouncingBar)
.SpinnerStyle(Style.Parse("yellow"))
.StartAsync("Compacting context...", async ctx =>
await _session.Compactor.TryCompactAsync(_session.History, default));
if (compacted)
{
AnsiConsole.MarkupLine(
$"[green]✓ Context compacted ({_session.History.Count} messages remaining)[/]");
}
else
{
AnsiConsole.MarkupLine(
"[dim grey] (compaction skipped — not enough history to compress)[/]");
}
AnsiConsole.WriteLine();
}
}
catch (OperationCanceledException)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[dim grey] ✗ Cancelled[/]");
AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim")));
AnsiConsole.WriteLine();
if (!string.IsNullOrEmpty(fullResponse))
{
_session.History.Add(new ChatMessage(ChatRole.Assistant, fullResponse));
}
_session.History.Add(new ChatMessage(ChatRole.User,
"[Response cancelled by user. Acknowledge briefly and wait for the next instruction. Do not repeat what was already said.]"));
}
catch (Exception ex)
{
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;
}
}
}
}

34
ToolRegistry.cs Normal file
View File

@@ -0,0 +1,34 @@
using Microsoft.Extensions.AI;
using AnchorCli.Tools;
namespace AnchorCli;
internal static class ToolRegistry
{
public static List<AITool> GetTools()
{
var jsonOptions = AppJsonContext.Default.Options;
return 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),
};
}
}

View File

@@ -64,15 +64,7 @@ internal static partial class EditTools
Log($"REPLACE_LINES: {path}"); Log($"REPLACE_LINES: {path}");
Log($" Range: {startAnchor} -> {endAnchor}"); Log($" Range: {startAnchor} -> {endAnchor}");
Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {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}";
@@ -109,15 +101,7 @@ internal static partial class EditTools
Log($"INSERT_AFTER: {path}"); Log($"INSERT_AFTER: {path}");
Log($" Anchor: {anchor}"); Log($" Anchor: {anchor}");
Log($" Inserting {newLines.Length} lines after line {anchor.Split(':')[0]}"); 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)) if (!File.Exists(path))
return $"ERROR: File not found: {path}"; return $"ERROR: File not found: {path}";
@@ -129,8 +113,7 @@ internal static partial class EditTools
if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error)) if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error))
return $"ERROR: {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); var result = new List<string>(lines.Length + newLines.Length);
result.AddRange(lines[..(idx + 1)]); result.AddRange(lines[..(idx + 1)]);