컨텍스트 압축 계층 확장과 세션 누적 계측 보강
Some checks failed
Release Gate / gate (push) Has been cancelled

- claude-code compact 흐름을 참고해 AX에 session memory compact, microcompact, collapse/snip 단계를 추가하고 ContextCondenser를 단계별 결과 반환 구조로 확장함

- ChatConversation과 AX Agent 하단 컨텍스트 카드에 대화별 compact 누적 회수, 절감 토큰, session memory/microcompact/snip 집계를 반영함

- AGENTS.md에 개발 단계부터 최적화와 실행 속도를 우선 고려하는 작업 지침을 추가함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
This commit is contained in:
2026-04-04 23:49:44 +09:00
parent ac8e9f9686
commit d2f8e39d2b
6 changed files with 458 additions and 38 deletions

View File

@@ -139,6 +139,42 @@ public class ChatConversation
[JsonPropertyName("conversationSortMode")]
public string ConversationSortMode { get; set; } = "activity";
[JsonPropertyName("compactionCount")]
public int CompactionCount { get; set; }
[JsonPropertyName("automaticCompactionCount")]
public int AutomaticCompactionCount { get; set; }
[JsonPropertyName("manualCompactionCount")]
public int ManualCompactionCount { get; set; }
[JsonPropertyName("compactionSavedTokens")]
public int CompactionSavedTokens { get; set; }
[JsonPropertyName("sessionMemoryCompactionCount")]
public int SessionMemoryCompactionCount { get; set; }
[JsonPropertyName("microcompactBoundaryCount")]
public int MicrocompactBoundaryCount { get; set; }
[JsonPropertyName("snipCompactionCount")]
public int SnipCompactionCount { get; set; }
[JsonPropertyName("lastCompactionAt")]
public DateTime? LastCompactionAt { get; set; }
[JsonPropertyName("lastCompactionWasAutomatic")]
public bool LastCompactionWasAutomatic { get; set; }
[JsonPropertyName("lastCompactionBeforeTokens")]
public int? LastCompactionBeforeTokens { get; set; }
[JsonPropertyName("lastCompactionAfterTokens")]
public int? LastCompactionAfterTokens { get; set; }
[JsonPropertyName("lastCompactionStageSummary")]
public string? LastCompactionStageSummary { get; set; }
}
public class ChatAgentRunRecord

View File

@@ -2,14 +2,32 @@
namespace AxCopilot.Services.Agent;
public sealed class ContextCompactionResult
{
public bool Changed { get; set; }
public int BeforeTokens { get; set; }
public int AfterTokens { get; set; }
public bool SessionMemoryApplied { get; set; }
public int SessionMemoryMergedMessages { get; set; }
public int MicrocompactBoundaryCount { get; set; }
public int MicrocompactSingleCount { get; set; }
public int SnippedMessageCount { get; set; }
public int CollapsedBoundaryCount { get; set; }
public List<string> AppliedStages { get; } = new();
public int SavedTokens => Math.Max(0, BeforeTokens - AfterTokens);
public string StageSummary => AppliedStages.Count == 0 ? "없음" : string.Join(" -> ", AppliedStages);
}
/// <summary>
/// 컨텍스트 윈도우 관리: 대화가 길어지면 이전 메시지를 요약하여 압축합니다.
/// MemGPT/OpenHands의 LLMSummarizingCondenser 패턴을 경량 구현.
///
/// 3단계 압축 전략:
/// 5단계 압축 전략:
/// 1단계 — 도구 결과 자르기: 대용량 tool_result 출력을 핵심만 남기고 축약 (LLM 호출 없음)
/// 2단계 — microcompact: 오래된 실행 로그/도구 묶음을 경계 요약으로 치환 (LLM 호출 없음)
/// 3단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출)
/// 2단계 — session memory compact: 이전 경계/요약을 하나의 세션 메모로 통합
/// 3단계 — microcompact: 오래된 실행 로그/도구 묶음을 경계 요약으로 치환 (LLM 호출 없음)
/// 4단계 — collapse/snip: 긴 로그/도구 결과를 더 짧은 경계/앞뒤 일부로 절단
/// 5단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출)
/// </summary>
public static class ContextCondenser
{
@@ -17,6 +35,8 @@ public static class ContextCondenser
private const int MaxToolResultChars = 1500;
private const int MicrocompactSingleKeepChars = 480;
private const int MicrocompactGroupMinCount = 2;
private const int SnipKeepHeadChars = 220;
private const int SnipKeepTailChars = 140;
/// <summary>요약 시 유지할 최근 메시지 수</summary>
private const int RecentKeepCount = 6;
@@ -51,8 +71,22 @@ public static class ContextCondenser
bool force = false,
CancellationToken ct = default)
{
if (messages.Count < 6) return false;
if (!force && !proactiveEnabled) return false;
var result = await CondenseWithStatsAsync(messages, llm, maxOutputTokens, proactiveEnabled, triggerPercent, force, ct);
return result.Changed;
}
public static async Task<ContextCompactionResult> CondenseWithStatsAsync(
List<ChatMessage> messages,
LlmService llm,
int maxOutputTokens,
bool proactiveEnabled = true,
int triggerPercent = 80,
bool force = false,
CancellationToken ct = default)
{
var result = new ContextCompactionResult();
if (messages.Count < 6) return result;
if (!force && !proactiveEnabled) return result;
// 현재 모델의 입력 토큰 한도
var settings = llm.GetCurrentModelInfo();
@@ -62,34 +96,106 @@ public static class ContextCondenser
var threshold = (int)(effectiveMax * (percent / 100.0)); // 설정 임계치에서 압축 시작
var currentTokens = TokenEstimator.EstimateMessages(messages);
if (!force && currentTokens < threshold) return false;
result.BeforeTokens = currentTokens;
if (!force && currentTokens < threshold)
{
result.AfterTokens = currentTokens;
return result;
}
var originalTokens = currentTokens;
bool didCompress = false;
// ── 1단계: 도구 결과 축약 (LLM 호출 없음, 즉시 실행) ──
didCompress |= TruncateToolResults(messages);
if (TruncateToolResults(messages))
{
didCompress = true;
result.AppliedStages.Add("tool-result");
}
// 1단계 후 다시 추정
currentTokens = TokenEstimator.EstimateMessages(messages);
if (!force && currentTokens < threshold) return didCompress;
if (!force && currentTokens < threshold)
{
result.Changed = didCompress;
result.AfterTokens = currentTokens;
return result;
}
// ── 2단계: 오래된 실행/도구 묶음 경량 압축 ──
didCompress |= MicrocompactOlderMessages(messages);
// ── 2단계: 이전 경계/요약을 세션 메모로 통합 ──
var sessionMemoryMergedMessages = SessionMemoryCompactOlderMessages(messages);
if (sessionMemoryMergedMessages > 0)
{
didCompress = true;
result.SessionMemoryApplied = true;
result.SessionMemoryMergedMessages = sessionMemoryMergedMessages;
result.AppliedStages.Add("session-memory");
}
currentTokens = TokenEstimator.EstimateMessages(messages);
if (!force && currentTokens < threshold) return didCompress;
if (!force && currentTokens < threshold)
{
result.Changed = didCompress;
result.AfterTokens = currentTokens;
return result;
}
// ── 3단계: 이전 대화 LLM 요약 ──
didCompress |= await SummarizeOldMessagesAsync(messages, llm, ct);
// ── 3단계: 오래된 실행/도구 묶음 경량 압축 ──
var (boundaryCount, singleCount) = MicrocompactOlderMessages(messages);
if (boundaryCount > 0 || singleCount > 0)
{
didCompress = true;
result.MicrocompactBoundaryCount = boundaryCount;
result.MicrocompactSingleCount = singleCount;
result.AppliedStages.Add("microcompact");
}
currentTokens = TokenEstimator.EstimateMessages(messages);
if (!force && currentTokens < threshold)
{
result.Changed = didCompress;
result.AfterTokens = currentTokens;
return result;
}
// ── 4단계: collapse/snip ──
var (snippedCount, collapsedCount) = CollapseAndSnipOlderMessages(messages);
if (snippedCount > 0 || collapsedCount > 0)
{
didCompress = true;
result.SnippedMessageCount = snippedCount;
result.CollapsedBoundaryCount = collapsedCount;
result.AppliedStages.Add("snip");
}
currentTokens = TokenEstimator.EstimateMessages(messages);
if (!force && currentTokens < threshold)
{
result.Changed = didCompress;
result.AfterTokens = currentTokens;
return result;
}
// ── 5단계: 이전 대화 LLM 요약 ──
if (await SummarizeOldMessagesAsync(messages, llm, ct))
{
didCompress = true;
result.AppliedStages.Add("summary");
}
if (didCompress)
{
var afterTokens = TokenEstimator.EstimateMessages(messages);
LogService.Info($"Context Condenser: {originalTokens} → {afterTokens} 토큰 (절감 {originalTokens - afterTokens})");
result.Changed = true;
result.AfterTokens = afterTokens;
}
else
{
result.AfterTokens = currentTokens;
}
return didCompress;
return result;
}
/// <summary>
@@ -164,11 +270,11 @@ public static class ContextCondenser
/// 오래된 실행 로그/도구 결과/비정상적으로 긴 메시지를 먼저 경량 경계 요약으로 묶습니다.
/// claw-code의 microcompact처럼 LLM 호출 전에 토큰을 한 번 더 줄이는 단계입니다.
/// </summary>
private static bool MicrocompactOlderMessages(List<ChatMessage> messages)
private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List<ChatMessage> messages)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
if (nonSystemMessages.Count <= RecentKeepCount + 2) return false;
if (nonSystemMessages.Count <= RecentKeepCount + 2) return (0, 0);
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
@@ -176,6 +282,8 @@ public static class ContextCondenser
var rewritten = new List<ChatMessage>(oldMessages.Count);
bool changed = false;
int boundaryCount = 0;
int singleCount = 0;
for (int i = 0; i < oldMessages.Count;)
{
@@ -194,12 +302,14 @@ public static class ContextCondenser
{
rewritten.Add(BuildMicrocompactBoundary(group));
changed = true;
boundaryCount++;
}
else
{
var singleChanged = TryMicrocompactSingle(group[0], out var compacted);
rewritten.Add(singleChanged ? compacted : group[0]);
changed |= singleChanged;
if (singleChanged) singleCount++;
}
i = j;
@@ -210,6 +320,7 @@ public static class ContextCondenser
{
rewritten.Add(singleCompacted);
changed = true;
singleCount++;
}
else
{
@@ -219,12 +330,204 @@ public static class ContextCondenser
i++;
}
if (!changed) return false;
if (!changed) return (0, 0);
messages.Clear();
if (systemMsg != null) messages.Add(systemMsg);
messages.AddRange(rewritten);
messages.AddRange(recentMessages);
return (boundaryCount, singleCount);
}
private static int SessionMemoryCompactOlderMessages(List<ChatMessage> messages)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
if (nonSystemMessages.Count <= RecentKeepCount + 3) return 0;
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
var candidates = oldMessages
.Select((message, index) => new { message, index })
.Where(x => IsSessionMemoryCandidate(x.message))
.ToList();
if (candidates.Count < 2) return 0;
var attachedFiles = candidates
.SelectMany(x => x.message.AttachedFiles ?? Enumerable.Empty<string>())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(path =>
{
try { return System.IO.Path.GetFileName(path); }
catch { return path; }
})
.Where(name => !string.IsNullOrWhiteSpace(name))
.Take(5)
.ToList();
var merged = new ChatMessage
{
Role = "assistant",
Timestamp = candidates.Last().message.Timestamp,
MetaKind = "session_memory_compaction",
MetaRunId = candidates.Last().message.MetaRunId,
AttachedFiles = attachedFiles.Count > 0 ? attachedFiles : null,
Content = string.Join("\n", new[]
{
$"[세션 메모리 압축 - {candidates.Count}개 이전 경계 통합]",
"- 이전 요약, 실행 경계, 오래된 메타 로그를 하나의 세션 메모로 합쳤습니다.",
attachedFiles.Count > 0 ? $"- 관련 파일: {string.Join(", ", attachedFiles)}" : "- 관련 파일: 없음",
})
};
var rewritten = new List<ChatMessage>(oldMessages.Count - candidates.Count + 1);
bool inserted = false;
for (int i = 0; i < oldMessages.Count; i++)
{
if (candidates.Any(x => x.index == i))
{
if (!inserted)
{
rewritten.Add(merged);
inserted = true;
}
continue;
}
rewritten.Add(oldMessages[i]);
}
messages.Clear();
if (systemMsg != null) messages.Add(systemMsg);
messages.AddRange(rewritten);
messages.AddRange(recentMessages);
return candidates.Count;
}
private static bool IsSessionMemoryCandidate(ChatMessage message)
{
var content = message.Content ?? "";
if (string.IsNullOrWhiteSpace(content)) return false;
return string.Equals(message.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|| string.Equals(message.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|| content.StartsWith("[이전 대화 요약", StringComparison.Ordinal)
|| content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal)
|| content.StartsWith("[이전 도구 결과 축약", StringComparison.Ordinal)
|| content.StartsWith("[이전 도구 호출 묶음 축약", StringComparison.Ordinal)
|| content.StartsWith("[이전 실행 메타 축약", StringComparison.Ordinal);
}
private static (int SnippedCount, int CollapsedCount) CollapseAndSnipOlderMessages(List<ChatMessage> messages)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
if (nonSystemMessages.Count <= RecentKeepCount + 2) return (0, 0);
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
var rewritten = new List<ChatMessage>(oldMessages.Count);
int snippedCount = 0;
int collapsedCount = 0;
for (int i = 0; i < oldMessages.Count;)
{
var current = oldMessages[i];
if (IsBoundaryLike(current))
{
var group = new List<ChatMessage> { current };
int j = i + 1;
while (j < oldMessages.Count && IsBoundaryLike(oldMessages[j]))
{
group.Add(oldMessages[j]);
j++;
}
if (group.Count >= 2)
{
rewritten.Add(new ChatMessage
{
Role = "assistant",
Timestamp = group.Last().Timestamp,
MetaKind = "collapsed_boundary",
MetaRunId = group.Last().MetaRunId,
Content = $"[이전 압축 경계 병합 - {group.Count}개]\n- 이전 compact/snippet 경계를 하나로 합쳤습니다."
});
collapsedCount += group.Count;
i = j;
continue;
}
}
if (TrySnipMessage(current, out var snipped))
{
rewritten.Add(snipped);
snippedCount++;
}
else
{
rewritten.Add(current);
}
i++;
}
if (snippedCount == 0 && collapsedCount == 0) return (0, 0);
messages.Clear();
if (systemMsg != null) messages.Add(systemMsg);
messages.AddRange(rewritten);
messages.AddRange(recentMessages);
return (snippedCount, collapsedCount);
}
private static bool IsBoundaryLike(ChatMessage message)
{
var content = message.Content ?? "";
return string.Equals(message.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|| string.Equals(message.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|| content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal)
|| content.StartsWith("[세션 메모리 압축", StringComparison.Ordinal)
|| content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal);
}
private static bool TrySnipMessage(ChatMessage source, out ChatMessage snipped)
{
snipped = source;
var content = source.Content ?? "";
if (string.IsNullOrWhiteSpace(content)) return false;
if (source.Role == "system") return false;
if (content.Length < 900 && content.Split('\n').Length < 12) return false;
var normalized = content.Replace("\r\n", "\n");
var lines = normalized
.Split('\n')
.Select(line => line.TrimEnd())
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
if (lines.Count >= 6)
{
var headLines = lines.Take(3);
var tailLines = lines.TakeLast(2);
snipped = CloneWithContent(
source,
string.Join("\n", headLines) +
"\n...[snip: 중간 실행 로그/출력 축약]...\n" +
string.Join("\n", tailLines));
return true;
}
var head = normalized[..Math.Min(SnipKeepHeadChars, normalized.Length)];
var tail = normalized.Length > SnipKeepTailChars ? normalized[^SnipKeepTailChars..] : "";
snipped = CloneWithContent(
source,
head + "\n...[snip: 중간 내용 축약]...\n" + tail);
return true;
}

View File

@@ -99,6 +99,14 @@ public partial class ChatWindow : Window
private int? _lastCompactionAfterTokens;
private DateTime? _lastCompactionAt;
private bool _lastCompactionWasAutomatic;
private string _lastCompactionStageSummary = "";
private int _sessionCompactionCount;
private int _sessionAutomaticCompactionCount;
private int _sessionManualCompactionCount;
private int _sessionCompactionSavedTokens;
private int _sessionMemoryCompactionCount;
private int _sessionMicrocompactBoundaryCount;
private int _sessionSnipCompactionCount;
private void ApplyQuickActionVisual(Button button, bool active, string activeBg, string activeFg)
{
if (button?.Content is not string text)
@@ -1686,6 +1694,7 @@ public partial class ChatWindow : Window
}
// 대화별 설정 복원 (없으면 전역 기본값)
LoadConversationSettings();
LoadCompactionMetricsFromConversation();
UpdatePermissionUI();
UpdateDataUsageUI();
RefreshContextUsageVisual();
@@ -1711,6 +1720,25 @@ public partial class ChatWindow : Window
_selectedMood = conv?.Mood ?? llm.DefaultMood ?? "modern";
}
private void LoadCompactionMetricsFromConversation()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
_sessionCompactionCount = conv?.CompactionCount ?? 0;
_sessionAutomaticCompactionCount = conv?.AutomaticCompactionCount ?? 0;
_sessionManualCompactionCount = conv?.ManualCompactionCount ?? 0;
_sessionCompactionSavedTokens = conv?.CompactionSavedTokens ?? 0;
_sessionMemoryCompactionCount = conv?.SessionMemoryCompactionCount ?? 0;
_sessionMicrocompactBoundaryCount = conv?.MicrocompactBoundaryCount ?? 0;
_sessionSnipCompactionCount = conv?.SnipCompactionCount ?? 0;
_lastCompactionAt = conv?.LastCompactionAt;
_lastCompactionWasAutomatic = conv?.LastCompactionWasAutomatic ?? false;
_lastCompactionBeforeTokens = conv?.LastCompactionBeforeTokens;
_lastCompactionAfterTokens = conv?.LastCompactionAfterTokens;
_lastCompactionStageSummary = conv?.LastCompactionStageSummary ?? "";
}
/// <summary>현재 하단 바 설정을 대화에 저장합니다.</summary>
private void SaveConversationSettings()
{
@@ -6500,9 +6528,8 @@ public partial class ChatWindow : Window
ForceScrollToEnd();
var llm = _settings.Settings.Llm;
var beforeTokens = Services.TokenEstimator.EstimateMessages(conv.Messages);
var working = conv.Messages.ToList();
var condensed = await ContextCondenser.CondenseIfNeededAsync(
var compactResult = await ContextCondenser.CondenseWithStatsAsync(
working,
_llm,
llm.MaxContextTokens,
@@ -6510,10 +6537,11 @@ public partial class ChatWindow : Window
llm.ContextCompactTriggerPercent,
true,
CancellationToken.None);
var afterTokens = Services.TokenEstimator.EstimateMessages(working);
RecordCompactionStats(beforeTokens, afterTokens, wasAutomatic: false);
var beforeTokens = compactResult.BeforeTokens;
var afterTokens = compactResult.AfterTokens;
RecordCompactionStats(compactResult, wasAutomatic: false);
if (condensed)
if (compactResult.Changed)
{
lock (_convLock)
{
@@ -6521,8 +6549,8 @@ public partial class ChatWindow : Window
}
}
var assistantText = condensed
? $"컨텍스트 압축을 수행했습니다. 입력 토큰 추정치: {beforeTokens:N0} → {afterTokens:N0}"
var assistantText = compactResult.Changed
? $"컨텍스트 압축을 수행했습니다. 입력 토큰 추정치: {beforeTokens:N0} → {afterTokens:N0}\n- 단계: {compactResult.StageSummary}\n- 절감량: {compactResult.SavedTokens:N0} tokens"
: "현재 대화는 압축할 충분한 이전 컨텍스트가 없어 변경 없이 유지했습니다.";
var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantText };
@@ -6547,7 +6575,7 @@ public partial class ChatWindow : Window
ForceScrollToEnd();
if (StatusTokens != null)
StatusTokens.Text = $"컨텍스트 {Services.TokenEstimator.Format(beforeTokens)} → {Services.TokenEstimator.Format(afterTokens)}";
SetStatus(condensed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", spinning: false);
SetStatus(compactResult.Changed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", spinning: false);
RefreshContextUsageVisual();
RefreshConversationList();
UpdateTaskSummaryIndicators();
@@ -8081,8 +8109,7 @@ public partial class ChatWindow : Window
// ── 전송 전 컨텍스트 사전 압축 ──
{
var llm = _settings.Settings.Llm;
var beforeCompactTokens = Services.TokenEstimator.EstimateMessages(sendMessages);
var condensed = await ContextCondenser.CondenseIfNeededAsync(
var compactResult = await ContextCondenser.CondenseWithStatsAsync(
sendMessages,
_llm,
llm.MaxContextTokens,
@@ -8090,11 +8117,10 @@ public partial class ChatWindow : Window
llm.ContextCompactTriggerPercent,
false,
_streamCts!.Token);
if (condensed)
if (compactResult.Changed)
{
var afterCompactTokens = Services.TokenEstimator.EstimateMessages(sendMessages);
RecordCompactionStats(beforeCompactTokens, afterCompactTokens, wasAutomatic: true);
SetStatus("컨텍스트를 사전 정리했습니다", spinning: true);
RecordCompactionStats(compactResult, wasAutomatic: true);
SetStatus($"컨텍스트를 사전 정리했습니다 · {compactResult.BeforeTokens:N0} → {compactResult.AfterTokens:N0} tokens", spinning: true);
RefreshContextUsageVisual();
}
}
@@ -16497,7 +16523,14 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi
? $"\n최근 압축: {(_lastCompactionWasAutomatic ? "" : "")} · {_lastCompactionAt.Value:HH:mm:ss}\n" +
$"절감: {_lastCompactionBeforeTokens.Value:N0} → {_lastCompactionAfterTokens.Value:N0} tokens " +
$"(-{Math.Max(0, _lastCompactionBeforeTokens.Value - _lastCompactionAfterTokens.Value):N0}, " +
$"{Services.TokenEstimator.Format(_lastCompactionBeforeTokens.Value)} → {Services.TokenEstimator.Format(_lastCompactionAfterTokens.Value)})"
$"{Services.TokenEstimator.Format(_lastCompactionBeforeTokens.Value)} → {Services.TokenEstimator.Format(_lastCompactionAfterTokens.Value)})\n" +
$"단계: {(!string.IsNullOrWhiteSpace(_lastCompactionStageSummary) ? _lastCompactionStageSummary : "")}"
: "";
var compactSession = _sessionCompactionCount > 0
? $"\n세션 누적: {_sessionCompactionCount:N0}회 (자동 {_sessionAutomaticCompactionCount:N0} / 수동 {_sessionManualCompactionCount:N0})\n" +
$"세션 절감: {Services.TokenEstimator.Format(_sessionCompactionSavedTokens)} tokens\n" +
$"세션 메모리 {_sessionMemoryCompactionCount:N0}회 · 경계 {_sessionMicrocompactBoundaryCount:N0}건 · snip {_sessionSnipCompactionCount:N0}건"
: "";
TokenUsageCard.ToolTip =
@@ -16506,7 +16539,8 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi
$"간단 표기: {Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}\n" +
$"자동 압축 시작: {triggerPercent}%\n" +
$"현재 입력 초안 포함" +
compactHistory;
compactHistory +
compactSession;
UpdateCircularUsageArc(TokenUsageArc, usageRatio, 18, 18, 14);
PositionThresholdMarker(TokenUsageThresholdMarker, triggerRatio, 18, 18, 14, 3);
@@ -16555,12 +16589,45 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi
centerY + radius * Math.Sin(radians));
}
private void RecordCompactionStats(int beforeTokens, int afterTokens, bool wasAutomatic)
private void RecordCompactionStats(ContextCompactionResult result, bool wasAutomatic)
{
_lastCompactionBeforeTokens = Math.Max(0, beforeTokens);
_lastCompactionAfterTokens = Math.Max(0, afterTokens);
var beforeTokens = Math.Max(0, result.BeforeTokens);
var afterTokens = Math.Max(0, result.AfterTokens);
var savedTokens = Math.Max(0, beforeTokens - afterTokens);
_lastCompactionBeforeTokens = beforeTokens;
_lastCompactionAfterTokens = afterTokens;
_lastCompactionAt = DateTime.Now;
_lastCompactionWasAutomatic = wasAutomatic;
_lastCompactionStageSummary = result.StageSummary;
_sessionCompactionCount++;
_sessionCompactionSavedTokens += savedTokens;
_sessionMemoryCompactionCount += result.SessionMemoryApplied ? 1 : 0;
_sessionMicrocompactBoundaryCount += result.MicrocompactBoundaryCount;
_sessionSnipCompactionCount += result.SnippedMessageCount + result.CollapsedBoundaryCount;
if (wasAutomatic)
_sessionAutomaticCompactionCount++;
else
_sessionManualCompactionCount++;
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null)
return;
conv.CompactionCount = _sessionCompactionCount;
conv.AutomaticCompactionCount = _sessionAutomaticCompactionCount;
conv.ManualCompactionCount = _sessionManualCompactionCount;
conv.CompactionSavedTokens = _sessionCompactionSavedTokens;
conv.SessionMemoryCompactionCount = _sessionMemoryCompactionCount;
conv.MicrocompactBoundaryCount = _sessionMicrocompactBoundaryCount;
conv.SnipCompactionCount = _sessionSnipCompactionCount;
conv.LastCompactionAt = _lastCompactionAt;
conv.LastCompactionWasAutomatic = wasAutomatic;
conv.LastCompactionBeforeTokens = beforeTokens;
conv.LastCompactionAfterTokens = afterTokens;
conv.LastCompactionStageSummary = _lastCompactionStageSummary;
try { _storage.Save(conv); } catch { }
}
private void ScheduleGitBranchRefresh(int delayMs = 400)