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