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. /// Number of tool results that were compacted. public static int CompactStaleToolResults(List history) { int compacted = 0; int userTurnsSeen = 0; Dictionary filesRead = new(StringComparer.OrdinalIgnoreCase); // Walk backwards: index 0 is system prompt, so we stop at 1. for (int i = history.Count - 1; i >= 1; i--) { var msg = history[i]; if (msg.Role == ChatRole.User) { userTurnsSeen++; } else if (msg.Role == ChatRole.Assistant) { // Find all FunctionCalls in this assistant message to map call IDs to file paths var calls = msg.Contents.OfType().ToList(); // If the very next message is a System/Tool role containing FunctionResults if (i + 1 < history.Count && history[i + 1].Role == ChatRole.Tool) { var resultMsg = history[i + 1]; foreach (var content in resultMsg.Contents) { if (content is FunctionResultContent frc && frc.Result is string resultStr && resultStr.Length >= MinResultLength && HashlinePattern.IsMatch(resultStr)) { // Find the corresponding function call to check its name/arguments var call = calls.FirstOrDefault(c => c.CallId == frc.CallId); if (call?.Name == "ReadFile" && call.Arguments != null && call.Arguments.TryGetValue("path", out var pathObj)) { string filePath = pathObj?.ToString() ?? ""; bool shouldRedact = false; string reason = ""; // Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one. if (filesRead.TryGetValue(filePath, out int count) && count >= 3) { shouldRedact = true; reason = "deduplication — you read this file 5 or more times later"; } // Rule 2: TTL. If this was read 2 or more user turns ago, redact it. else if (userTurnsSeen >= 2) { shouldRedact = true; reason = "expired — read over 2 turns ago"; } if (shouldRedact) { int lineCount = resultStr.Count(c => c == '\n'); frc.Result = $"[File content: {lineCount} lines redacted for {reason}. Re-read the file if you need fresh anchors.]"; compacted++; } else { // Keep it, but mark that we've seen it so older reads of the same file are redacted. filesRead[filePath] = filesRead.GetValueOrDefault(filePath) + 1; } } } } } } } 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(); }