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