diff --git a/README.md b/README.md
index 33f8565..4702f6f 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,9 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
- compact 직후 첫 응답을 별도로 추적하는 post-compaction 흐름을 추가해, 압축 다음 턴의 응답 횟수와 사용 토큰을 현재 대화 기준으로 누적 집계하도록 보강했습니다.
- AX Agent 하단 컨텍스트 카드 hover에는 이제 `compact 후 첫 응답 대기 중`, `compact 이후 응답 수`, `compact 이후 사용 토큰`까지 함께 표시됩니다.
+- 업데이트: 2026-04-05 00:01 (KST)
+- 일별 사용 통계에 `service:model` 기준 토큰 집계를 추가해 일반 사용량과 compact 이후 사용량을 모델 단위로 나눠 기록하도록 보강했습니다.
+- `microcompact_boundary`, `session_memory_compaction`, `collapsed_boundary` 메시지는 일반 AI 답변과 다른 전용 압축 카드로 렌더링하고, compact 관련 실행 로그는 얇은 compact pill로 분리해 노이즈를 줄였습니다.
- 업데이트: 2026-04-04 23:32 (KST)
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 23979d2..7a23912 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -3,6 +3,8 @@
- Document update: 2026-04-04 23:47 (KST) - Added AX-side session-memory compaction, collapse/snip trimming, and per-conversation compaction usage tracking so compact now records staged reductions instead of only the last before/after snapshot.
- Document update: 2026-04-04 23:47 (KST) - The AX Agent context tooltip now shows session totals for compaction count, saved tokens, session-memory passes, microcompact boundaries, and snipped messages for the current conversation.
- Document update: 2026-04-04 23:47 (KST) - Added a post-compaction flag and conversation-level follow-up usage counters so AX can distinguish the first response after compaction and report its prompt/completion token cost separately.
+- Document update: 2026-04-05 00:01 (KST) - Added per-model daily token aggregation (`service:model`) plus post-compaction model usage buckets so AX can compare normal usage and compact-follow-up usage more like claw-code's compaction accounting.
+- Document update: 2026-04-05 00:01 (KST) - Compact boundary messages now render as dedicated compact cards, and compact-related thinking events render as lightweight pills so the timeline distinguishes compression boundaries from ordinary assistant replies and general execution logs.
- Document update: 2026-04-04 23:32 (KST) - Upgraded ContextCondenser from a two-step path to a three-step path (tool-result truncate -> microcompact -> summarize) so long sessions can strip older execution/tool chatter before invoking the LLM summary stage.
- Document update: 2026-04-04 23:32 (KST) - Added AX-side microcompact boundaries that compress older tool results, execution metadata, and oversized messages into lighter boundary summaries, bringing the compact flow closer to the staged approach used in claude-code.
diff --git a/src/AxCopilot/Models/DailyUsageStats.cs b/src/AxCopilot/Models/DailyUsageStats.cs
index 91d96a1..f79d6b6 100644
--- a/src/AxCopilot/Models/DailyUsageStats.cs
+++ b/src/AxCopilot/Models/DailyUsageStats.cs
@@ -39,4 +39,20 @@ public class DailyUsageStats
/// 일별 완료 토큰 수.
[JsonPropertyName("completionTokens")]
public long CompletionTokens { get; set; }
+
+ /// 모델별 프롬프트 토큰 수. key = service:model
+ [JsonPropertyName("modelPromptTokens")]
+ public Dictionary ModelPromptTokens { get; set; } = new();
+
+ /// 모델별 완료 토큰 수. key = service:model
+ [JsonPropertyName("modelCompletionTokens")]
+ public Dictionary ModelCompletionTokens { get; set; } = new();
+
+ /// 모델별 compact 이후 프롬프트 토큰 수. key = service:model
+ [JsonPropertyName("postCompactionPromptTokens")]
+ public Dictionary PostCompactionPromptTokens { get; set; } = new();
+
+ /// 모델별 compact 이후 완료 토큰 수. key = service:model
+ [JsonPropertyName("postCompactionCompletionTokens")]
+ public Dictionary PostCompactionCompletionTokens { get; set; } = new();
}
diff --git a/src/AxCopilot/Services/UsageStatisticsService.cs b/src/AxCopilot/Services/UsageStatisticsService.cs
index 1b6b35f..67815b9 100644
--- a/src/AxCopilot/Services/UsageStatisticsService.cs
+++ b/src/AxCopilot/Services/UsageStatisticsService.cs
@@ -70,7 +70,7 @@ internal static class UsageStatisticsService
}
/// 토큰 사용량을 기록합니다.
- public static void RecordTokens(int promptTokens, int completionTokens)
+ public static void RecordTokens(int promptTokens, int completionTokens, string? service = null, string? model = null, bool isPostCompaction = false)
{
if (promptTokens <= 0 && completionTokens <= 0) return;
EnsureInitialized();
@@ -79,6 +79,23 @@ internal static class UsageStatisticsService
_today.PromptTokens += promptTokens;
_today.CompletionTokens += completionTokens;
_today.TotalTokens += promptTokens + completionTokens;
+
+ var key = BuildModelUsageKey(service, model);
+ if (!string.IsNullOrWhiteSpace(key))
+ {
+ _today.ModelPromptTokens.TryGetValue(key, out var modelPrompt);
+ _today.ModelCompletionTokens.TryGetValue(key, out var modelCompletion);
+ _today.ModelPromptTokens[key] = modelPrompt + promptTokens;
+ _today.ModelCompletionTokens[key] = modelCompletion + completionTokens;
+
+ if (isPostCompaction)
+ {
+ _today.PostCompactionPromptTokens.TryGetValue(key, out var postPrompt);
+ _today.PostCompactionCompletionTokens.TryGetValue(key, out var postCompletion);
+ _today.PostCompactionPromptTokens[key] = postPrompt + promptTokens;
+ _today.PostCompactionCompletionTokens[key] = postCompletion + completionTokens;
+ }
+ }
}
_ = SaveTodayAsync();
}
@@ -205,8 +222,21 @@ internal static class UsageStatisticsService
TotalTokens = _today.TotalTokens,
PromptTokens = _today.PromptTokens,
CompletionTokens = _today.CompletionTokens,
+ ModelPromptTokens = new Dictionary(_today.ModelPromptTokens),
+ ModelCompletionTokens = new Dictionary(_today.ModelCompletionTokens),
+ PostCompactionPromptTokens = new Dictionary(_today.PostCompactionPromptTokens),
+ PostCompactionCompletionTokens = new Dictionary(_today.PostCompactionCompletionTokens),
};
private static string GetFilePath(string dateStr)
=> Path.Combine(_statsDir, $"{dateStr}.json");
+
+ private static string BuildModelUsageKey(string? service, string? model)
+ {
+ var normalizedService = (service ?? "").Trim().ToLowerInvariant();
+ var normalizedModel = (model ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(normalizedService) || string.IsNullOrWhiteSpace(normalizedModel))
+ return "";
+ return $"{normalizedService}:{normalizedModel}";
+ }
}
diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs
index ada096f..caa7605 100644
--- a/src/AxCopilot/Views/ChatWindow.xaml.cs
+++ b/src/AxCopilot/Views/ChatWindow.xaml.cs
@@ -4136,6 +4136,135 @@ public partial class ChatWindow : Window
};
}
+ private static bool IsCompactionMetaMessage(ChatMessage? message)
+ {
+ var kind = message?.MetaKind ?? "";
+ return kind.Equals("microcompact_boundary", StringComparison.OrdinalIgnoreCase)
+ || kind.Equals("session_memory_compaction", StringComparison.OrdinalIgnoreCase)
+ || kind.Equals("collapsed_boundary", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private Border CreateCompactionMetaCard(ChatMessage message, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush)
+ {
+ var icon = "\uE9CE";
+ var title = message.MetaKind switch
+ {
+ "session_memory_compaction" => "세션 메모리 압축",
+ "collapsed_boundary" => "압축 경계 병합",
+ _ => "Microcompact 경계",
+ };
+
+ var wrapper = new Border
+ {
+ Background = hintBg,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(12),
+ Padding = new Thickness(12, 10, 12, 10),
+ Margin = new Thickness(10, 4, 150, 4),
+ MaxWidth = GetMessageMaxWidth(),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ };
+
+ var stack = new StackPanel();
+ var header = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Margin = new Thickness(0, 0, 0, 6),
+ };
+ header.Children.Add(new TextBlock
+ {
+ Text = icon,
+ FontFamily = new FontFamily("Segoe MDL2 Assets"),
+ FontSize = 11,
+ Foreground = accentBrush,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ header.Children.Add(new TextBlock
+ {
+ Text = title,
+ FontSize = 11,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = primaryText,
+ Margin = new Thickness(6, 0, 0, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ stack.Children.Add(header);
+
+ var lines = (message.Content ?? "")
+ .Replace("\r\n", "\n")
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .Select(line => line.Trim())
+ .Where(line => !string.IsNullOrWhiteSpace(line))
+ .ToList();
+
+ foreach (var line in lines)
+ {
+ var isHeaderLine = line.StartsWith("[", StringComparison.Ordinal);
+ stack.Children.Add(new TextBlock
+ {
+ Text = isHeaderLine ? line.Trim('[', ']') : line,
+ FontSize = isHeaderLine ? 10.5 : 10.5,
+ FontWeight = isHeaderLine ? FontWeights.SemiBold : FontWeights.Normal,
+ Foreground = isHeaderLine ? primaryText : secondaryText,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = isHeaderLine ? new Thickness(0, 0, 0, 3) : new Thickness(0, 0, 0, 2),
+ });
+ }
+
+ if (!string.IsNullOrWhiteSpace(message.MetaRunId))
+ {
+ stack.Children.Add(new TextBlock
+ {
+ Text = $"run {message.MetaRunId}",
+ FontSize = 9.5,
+ Foreground = secondaryText,
+ Opacity = 0.7,
+ Margin = new Thickness(0, 6, 0, 0),
+ });
+ }
+
+ wrapper.Child = stack;
+ return wrapper;
+ }
+
+ private Border CreateCompactEventPill(string summary, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush)
+ {
+ return new Border
+ {
+ Background = hintBg,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(999),
+ Padding = new Thickness(10, 5, 10, 5),
+ Margin = new Thickness(10, 4, 150, 4),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Child = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Children =
+ {
+ new TextBlock
+ {
+ Text = "\uE9CE",
+ FontFamily = new FontFamily("Segoe MDL2 Assets"),
+ FontSize = 10,
+ Foreground = accentBrush,
+ VerticalAlignment = VerticalAlignment.Center,
+ },
+ new TextBlock
+ {
+ Text = summary,
+ FontSize = 10.5,
+ Foreground = secondaryText,
+ Margin = new Thickness(6, 0, 0, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ }
+ }
+ }
+ };
+ }
+
private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null)
{
var isUser = role == "user";
@@ -4227,6 +4356,14 @@ public partial class ChatWindow : Window
}
else
{
+ if (message != null && IsCompactionMetaMessage(message))
+ {
+ var compactCard = CreateCompactionMetaCard(message, primaryText, secondaryText, hintBg, borderBrush, accentBrush);
+ if (animate) ApplyMessageEntryAnimation(compactCard);
+ MessagePanel.Children.Add(compactCard);
+ return;
+ }
+
// 어시스턴트: 좌측 정렬, 정돈된 카드
var container = new StackPanel
{
@@ -10149,6 +10286,22 @@ public partial class ChatWindow : Window
return;
}
+ if (evt.Type == AgentEventType.Thinking &&
+ ContainsAny(evt.Summary ?? "", "컨텍스트 압축", "microcompact", "session memory", "compact"))
+ {
+ var compactPrimaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
+ var compactSecondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ var compactHintBg = TryFindResource("HintBackground") as Brush
+ ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
+ var compactBorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
+ var compactAccentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
+ var pill = CreateCompactEventPill(string.IsNullOrWhiteSpace(evt.Summary) ? "컨텍스트 압축" : evt.Summary, compactPrimaryText, compactSecondaryText, compactHintBg, compactBorderBrush, compactAccentBrush);
+ pill.Opacity = 0;
+ pill.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(160)));
+ MessagePanel.Children.Add(pill);
+ return;
+ }
+
// simple 모드: ToolCall은 건너뜀 (ToolResult만 한 줄로 표시)
if (logLevel == "simple" && evt.Type == AgentEventType.ToolCall)
return;
@@ -10940,6 +11093,7 @@ public partial class ChatWindow : Window
var usage = _llm.LastTokenUsage;
// 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용
var isAgentTab = _activeTab is "Cowork" or "Code";
+ var (usageService, usageModel) = _llm.GetCurrentModelInfo();
var displayInput = isAgentTab && _agentCumulativeInputTokens > 0
? _agentCumulativeInputTokens
: usage?.PromptTokens ?? 0;
@@ -10951,7 +11105,7 @@ public partial class ChatWindow : Window
if (displayInput > 0 || displayOutput > 0)
{
UpdateStatusTokens(displayInput, displayOutput);
- Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput);
+ Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput, usageService, usageModel, wasPostCompactionResponse);
ConsumePostCompactionUsageIfNeeded(displayInput, displayOutput);
}
string tokenText;