1
0
Files
AnchorCli/ChatSession.cs

154 lines
6.3 KiB
C#

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