using System.IO; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace AxCopilot.Services; /// /// 에이전트 메모리 서비스. /// 작업 폴더별 + 전역 메모리를 관리하며, 대화 간 지속적 컨텍스트를 유지합니다. /// 저장소: %APPDATA%\AxCopilot\memory\{hash}.dat (암호화) /// public class AgentMemoryService { private static readonly string MemoryDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "memory"); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true, }; private readonly List _entries = new(); private readonly object _lock = new(); private string? _currentWorkFolder; /// 현재 로드된 메모리 항목 수. public int Count { get { lock (_lock) return _entries.Count; } } /// 모든 메모리 항목 (읽기 전용). public IReadOnlyList All { get { lock (_lock) return _entries.ToList(); } } /// 작업 폴더별 메모리 + 전역 메모리를 로드합니다. public void Load(string? workFolder) { lock (_lock) { _entries.Clear(); _currentWorkFolder = workFolder; // 전역 메모리 var globalPath = GetFilePath(null); LoadFromFile(globalPath); // 폴더별 메모리 if (!string.IsNullOrEmpty(workFolder)) { var folderPath = GetFilePath(workFolder); LoadFromFile(folderPath); } } } /// 새 메모리를 추가합니다. 유사한 내용이 있으면 병합합니다. public MemoryEntry Add(string type, string content, string? source = null, string? workFolder = null) { lock (_lock) { // 중복 검사: 동일 타입 + 유사 내용 (80% 이상 키워드 겹침) var existing = _entries.FirstOrDefault(e => e.Type == type && CalculateSimilarity(e.Content, content) > 0.8); if (existing != null) { existing.LastUsedAt = DateTime.Now; existing.UseCount++; Save(); return existing; } var entry = new MemoryEntry { Id = Guid.NewGuid().ToString("N")[..12], Type = type, Content = content, Source = source ?? "", CreatedAt = DateTime.Now, LastUsedAt = DateTime.Now, UseCount = 1, Relevance = 1.0, WorkFolder = workFolder, }; _entries.Add(entry); Prune(); Save(); return entry; } } /// 쿼리와 관련된 메모리를 검색합니다. public List GetRelevant(string query, int maxCount = 10) { if (string.IsNullOrWhiteSpace(query)) return new(); lock (_lock) { var queryTokens = Tokenize(query); var scored = _entries .Select(e => { var entryTokens = Tokenize(e.Content); var overlap = queryTokens.Intersect(entryTokens, StringComparer.OrdinalIgnoreCase).Count(); var score = queryTokens.Count > 0 ? (double)overlap / queryTokens.Count : 0; // 사용 빈도와 최근 사용 가중치 적용 score += Math.Min(e.UseCount * 0.05, 0.3); if ((DateTime.Now - e.LastUsedAt).TotalDays < 7) score += 0.1; return (Entry: e, Score: score); }) .Where(x => x.Score > 0.1) .OrderByDescending(x => x.Score) .Take(maxCount) .Select(x => { x.Entry.Relevance = x.Score; return x.Entry; }) .ToList(); return scored; } } /// 메모리 사용 기록을 갱신합니다. public void Touch(string id) { lock (_lock) { var entry = _entries.FirstOrDefault(e => e.Id == id); if (entry != null) { entry.UseCount++; entry.LastUsedAt = DateTime.Now; Save(); } } } /// 메모리를 삭제합니다. public bool Remove(string id) { lock (_lock) { var removed = _entries.RemoveAll(e => e.Id == id) > 0; if (removed) Save(); return removed; } } /// 모든 메모리를 삭제합니다. public void Clear() { lock (_lock) { _entries.Clear(); Save(); } } /// 오래되고 사용되지 않는 메모리를 정리합니다. private void Prune() { var maxEntries = 100; try { var app = System.Windows.Application.Current as App; maxEntries = app?.SettingsService?.Settings.Llm.MaxMemoryEntries ?? 100; } catch { } if (_entries.Count <= maxEntries) return; // 점수 기반 정리: 오래되고 사용 안 되는 것부터 var toRemove = _entries .OrderBy(e => e.UseCount) .ThenBy(e => e.LastUsedAt) .Take(_entries.Count - maxEntries) .ToList(); foreach (var entry in toRemove) _entries.Remove(entry); } /// 메모리를 암호화하여 파일에 저장합니다. private void Save() { Directory.CreateDirectory(MemoryDir); // 전역 메모리 var globalEntries = _entries.Where(e => e.WorkFolder == null).ToList(); SaveToFile(GetFilePath(null), globalEntries); // 폴더별 메모리 if (!string.IsNullOrEmpty(_currentWorkFolder)) { var folderEntries = _entries.Where(e => e.WorkFolder != null).ToList(); SaveToFile(GetFilePath(_currentWorkFolder), folderEntries); } } private void SaveToFile(string path, List entries) { try { var json = JsonSerializer.Serialize(entries, JsonOptions); var encrypted = CryptoService.PortableEncrypt(json); var tempPath = path + ".tmp"; File.WriteAllText(tempPath, encrypted); File.Move(tempPath, path, overwrite: true); } catch (Exception ex) { LogService.Warn($"메모리 저장 실패: {ex.Message}"); } } private void LoadFromFile(string path) { if (!File.Exists(path)) return; try { var encrypted = File.ReadAllText(path); var json = CryptoService.PortableDecrypt(encrypted); if (string.IsNullOrEmpty(json)) return; var entries = JsonSerializer.Deserialize>(json, JsonOptions); if (entries != null) _entries.AddRange(entries); } catch (Exception ex) { LogService.Warn($"메모리 로드 실패 ({path}): {ex.Message}"); } } private static string GetFilePath(string? workFolder) { if (string.IsNullOrEmpty(workFolder)) return Path.Combine(MemoryDir, "_global.dat"); // 폴더 경로를 해시하여 파일명 생성 var hash = Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes(workFolder.ToLowerInvariant())))[..16]; return Path.Combine(MemoryDir, $"{hash}.dat"); } /// 두 문자열의 키워드 유사도를 계산합니다 (0~1). private static double CalculateSimilarity(string a, string b) { var tokensA = Tokenize(a); var tokensB = Tokenize(b); if (tokensA.Count == 0 || tokensB.Count == 0) return 0; var intersection = tokensA.Intersect(tokensB, StringComparer.OrdinalIgnoreCase).Count(); var union = tokensA.Union(tokensB, StringComparer.OrdinalIgnoreCase).Count(); return union > 0 ? (double)intersection / union : 0; } /// 텍스트를 토큰으로 분리합니다. private static HashSet Tokenize(string text) { var tokens = new HashSet(StringComparer.OrdinalIgnoreCase); var sb = new StringBuilder(); foreach (var c in text) { if (char.IsLetterOrDigit(c) || c == '_') sb.Append(c); else if (sb.Length > 1) // 1글자 토큰 제외 { tokens.Add(sb.ToString()); sb.Clear(); } else sb.Clear(); } if (sb.Length > 1) tokens.Add(sb.ToString()); return tokens; } } /// 에이전트 메모리 항목. public class MemoryEntry { [JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("type")] public string Type { get; set; } = "fact"; // rule | preference | fact | correction [JsonPropertyName("content")] public string Content { get; set; } = ""; [JsonPropertyName("source")] public string Source { get; set; } = ""; [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } [JsonPropertyName("lastUsedAt")] public DateTime LastUsedAt { get; set; } [JsonPropertyName("useCount")] public int UseCount { get; set; } [JsonPropertyName("relevance")] public double Relevance { get; set; } [JsonPropertyName("workFolder")] public string? WorkFolder { get; set; } }