From d2f8e39d2b251d46617362195733727212475f1c Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 23:49:44 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=95=95=EC=B6=95=20=EA=B3=84=EC=B8=B5=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=EA=B3=BC=20=EC=84=B8=EC=85=98=20=EB=88=84=EC=A0=81=20=EA=B3=84?= =?UTF-8?q?=EC=B8=A1=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- AGENTS.md | 6 + README.md | 5 + docs/DEVELOPMENT.md | 7 +- src/AxCopilot/Models/ChatModels.cs | 36 ++ .../Services/Agent/ContextCondenser.cs | 337 +++++++++++++++++- src/AxCopilot/Views/ChatWindow.xaml.cs | 105 +++++- 6 files changed, 458 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0284e04..8b02717 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,6 +203,12 @@ if (!enabled) return ToolResult.Ok("비활성 상태입니다. 설정에서 활 - 모든 변경 후 `dotnet build` 실행 → **경고 0, 오류 0** 필수 - CS8603 (nullable) 경고 즉시 수정 +### 성능/실행속도 우선 원칙 +- 기능 구현 시 가능하면 **개발 단계부터 최적화와 실행 속도**를 함께 고려합니다. +- 동일 품질을 만족하는 구현안이 여러 개라면, **더 가볍고 빠르게 동작하는 구조**를 우선 채택합니다. +- UI/UX 개선, 에이전트 루프, 도구 실행, 컨텍스트 압축, 검색/필터링 기능은 특히 초기 구현부터 불필요한 반복 계산·과도한 렌더링·중복 I/O를 줄이는 방향으로 설계합니다. +- 단, 성능 최적화를 이유로 가독성이나 안정성을 과도하게 해치지 않으며, **동등 품질 + 유지보수 가능성**을 함께 만족하는 수준에서 최적화합니다. + ### 리소스 관리 - `IDisposable` 구현 객체는 반드시 해제 (PerformanceCounter, LspClientService 등) - P/Invoke 메모리: `Marshal.AllocHGlobal` 후 `finally`에서 `FreeHGlobal` diff --git a/README.md b/README.md index 57b8cf9..ab18344 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-04 23:47 (KST) +- AX Agent 컨텍스트 압축 경로에 `session memory compact`, `microcompact`, `collapse/snip` 단계를 추가해 오래된 요약·실행 로그·도구 결과를 LLM 요약 전에 더 세밀하게 줄이도록 보강했습니다. +- 현재 대화 기준 compact 누적 회수, 자동/수동 비중, 절감 토큰, session memory 적용 횟수, microcompact/snipped 메시지 수를 하단 컨텍스트 카드 hover에서 함께 확인할 수 있게 했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 + - 업데이트: 2026-04-04 23:32 (KST) - AX Agent 컨텍스트 압축 경로에 `microcompact` 성격의 선행 경량 압축 단계를 추가해, 오래된 실행 로그·도구 결과·긴 메시지를 먼저 경계 요약으로 줄인 뒤 LLM 요약 단계로 넘기도록 보강했습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 025dd8c..9400e9b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,4 +1,7 @@ -# AX Copilot - ?? ?? +# AX Copilot - 개발 문서 + +- 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: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. @@ -4084,4 +4087,4 @@ ow + toggle 시각 언어로 다시 정렬했다. - AX Agent 하단 컨텍스트 카드의 툴팁에 최근 compact 이력을 추가해 마지막 자동/수동 압축 시각, 압축 전후 토큰 수, 절감량을 이후에도 다시 확인할 수 있게 보강했다. - 수동 `/compact`와 전송 전 자동 컨텍스트 압축이 모두 같은 `RecordCompactionStats(...)` 경로를 사용하도록 맞춰, 압축 결과가 일회성 상태 메시지에만 머물지 않고 UI에서 재참조 가능하도록 정리했다. - 이번 라운드부터 `claude-code`의 compact 소스를 기준으로 `session memory compaction → microcompact → context collapse/snip → autocompact → post-compaction 계측` 흐름을 AX 기준과 비교 분석할 준비를 진행했다. -- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0) \ No newline at end of file +- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0) diff --git a/src/AxCopilot/Models/ChatModels.cs b/src/AxCopilot/Models/ChatModels.cs index c371aa0..bd9ecf7 100644 --- a/src/AxCopilot/Models/ChatModels.cs +++ b/src/AxCopilot/Models/ChatModels.cs @@ -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 diff --git a/src/AxCopilot/Services/Agent/ContextCondenser.cs b/src/AxCopilot/Services/Agent/ContextCondenser.cs index 1bb44d7..8c65555 100644 --- a/src/AxCopilot/Services/Agent/ContextCondenser.cs +++ b/src/AxCopilot/Services/Agent/ContextCondenser.cs @@ -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 AppliedStages { get; } = new(); + public int SavedTokens => Math.Max(0, BeforeTokens - AfterTokens); + public string StageSummary => AppliedStages.Count == 0 ? "없음" : string.Join(" -> ", AppliedStages); +} + /// /// 컨텍스트 윈도우 관리: 대화가 길어지면 이전 메시지를 요약하여 압축합니다. /// 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회 호출) /// 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; /// 요약 시 유지할 최근 메시지 수 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 CondenseWithStatsAsync( + List 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; } /// @@ -164,11 +270,11 @@ public static class ContextCondenser /// 오래된 실행 로그/도구 결과/비정상적으로 긴 메시지를 먼저 경량 경계 요약으로 묶습니다. /// claw-code의 microcompact처럼 LLM 호출 전에 토큰을 한 번 더 줄이는 단계입니다. /// - private static bool MicrocompactOlderMessages(List messages) + private static (int BoundaryCount, int SingleCount) MicrocompactOlderMessages(List 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(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 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()) + .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(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 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(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 { 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; } diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 0c150aa..0c1d28a 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -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 ?? ""; + } + /// 현재 하단 바 설정을 대화에 저장합니다. 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)