using Microsoft.Extensions.AI; using System.Text.RegularExpressions; using Spectre.Console; namespace AnchorCli; /// /// Compacts the chat history when context usage grows too large. /// Asks the same model to summarize the conversation, then replaces /// the history with [system prompt, summary, last N user/assistant turns]. /// internal sealed partial class ContextCompactor(IChatClient client) { /// Number of recent user+assistant turn pairs to keep verbatim. private const int KeepRecentTurns = 2; /// Minimum result length to consider for compaction. private const int MinResultLength = 300; /// Matches hashline-encoded output: "lineNumber:hash|content" private static readonly Regex HashlinePattern = MyRegex(); private readonly IChatClient _client = client; /// /// Compacts large hashline-encoded tool results from previous turns. /// 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). /// /// The chat history to compact in-place. /// /// Index of the first message added during the current turn. /// Messages before this index are from previous turns and eligible for compaction. /// /// Number of tool results that were compacted. public static int CompactStaleToolResults(List history, int currentTurnStartIndex) { int compacted = 0; for (int i = 0; i < currentTurnStartIndex && i < history.Count; i++) { var msg = history[i]; foreach (var content in msg.Contents) { if (content is FunctionResultContent frc && frc.Result is string resultStr && resultStr.Length >= MinResultLength && HashlinePattern.IsMatch(resultStr)) { // Count lines and replace with compact summary int lineCount = resultStr.Count(c => c == '\n'); frc.Result = $"[File content: {lineCount} lines — already consumed. Re-read the file if you need fresh anchors.]"; compacted++; } } } return compacted; } /// /// Compacts the history in-place via LLM summarization. Returns true if compaction was performed. /// public async Task TryCompactAsync( List history, CancellationToken ct = default) { // Need at least: system + some conversation to compact if (history.Count < 5) return false; // Split: system prompt (index 0) | middle (compactable) | tail (keep verbatim) var systemMsg = history[0]; // Find the cut point: keep the last N user+assistant pairs int keepFromIndex = FindKeepIndex(history); if (keepFromIndex <= 1) return false; // Nothing to compact // Extract the middle section to summarize var toSummarize = history.Skip(1).Take(keepFromIndex - 1).ToList(); var tail = history.Skip(keepFromIndex).ToList(); // Build a summarization prompt var summaryMessages = new List { new(ChatRole.System, """ You are a conversation summarizer. Summarize the following coding conversation between a user and an AI assistant. Focus on: - What files were read, created, or modified (and their paths) - What changes were made and why - Any decisions or preferences expressed by the user - Current state of the work (what's done, what's pending) 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; try { var response = await _client.GetResponseAsync(summaryMessages, cancellationToken: ct); summary = response.Text ?? "(summary unavailable)"; } catch { // 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; } /// /// Finds the index from which to keep messages verbatim (the last N turn pairs). /// private static int FindKeepIndex(List 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(); }