- 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:
@@ -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`
|
||||
|
||||
@@ -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 요약 단계로 넘기도록 보강했습니다.
|
||||
|
||||
@@ -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)
|
||||
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ (경고 0 / 오류 0)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user