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 string ManagedMemoryDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "AxCopilot", "memory"); private const string MemoryFileName = "AXMEMORY.md"; private const string LocalMemoryFileName = "AXMEMORY.local.md"; private const int MaxIncludeDepth = 5; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true, }; private readonly List _entries = new(); private readonly List _instructionDocuments = 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 IReadOnlyList InstructionDocuments { get { lock (_lock) return _instructionDocuments.ToList(); } } public string? GetWritableInstructionPath(string scope, string? workFolder) { scope = (scope ?? "").Trim().ToLowerInvariant(); var projectRoot = ResolveProjectRoot(workFolder); return scope switch { "managed" => Path.Combine(ManagedMemoryDir, MemoryFileName), "user" => Path.Combine(MemoryDir, MemoryFileName), "project" => string.IsNullOrWhiteSpace(projectRoot) ? null : Path.Combine(projectRoot, MemoryFileName), "local" => string.IsNullOrWhiteSpace(projectRoot) ? null : Path.Combine(projectRoot, LocalMemoryFileName), _ => null }; } public (bool Changed, string Path, string Message) SaveInstruction(string scope, string content, string? workFolder) { lock (_lock) { var path = GetWritableInstructionPath(scope, workFolder); if (string.IsNullOrWhiteSpace(path)) return (false, "", "해당 scope에 저장할 경로를 결정할 수 없습니다."); try { Directory.CreateDirectory(Path.GetDirectoryName(path)!); var existing = File.Exists(path) ? File.ReadAllText(path) : ""; if (existing.Contains(content, StringComparison.OrdinalIgnoreCase)) return (false, path, "이미 같은 내용이 메모리 파일에 있습니다."); var sb = new StringBuilder(); if (!string.IsNullOrWhiteSpace(existing)) { sb.Append(existing.TrimEnd()); sb.AppendLine(); sb.AppendLine(); } sb.AppendLine($"- {content.Trim()}"); File.WriteAllText(path, sb.ToString()); Load(workFolder); return (true, path, "메모리 파일에 지시를 추가했습니다."); } catch (Exception ex) { LogService.Warn($"메모리 파일 저장 실패 ({path}): {ex.Message}"); return (false, path, $"메모리 파일 저장 실패: {ex.Message}"); } } } public (bool Changed, string Path, string Message) DeleteInstruction(string scope, string query, string? workFolder) { lock (_lock) { var path = GetWritableInstructionPath(scope, workFolder); if (string.IsNullOrWhiteSpace(path)) return (false, "", "해당 scope에 저장된 메모리 파일 경로를 찾을 수 없습니다."); if (!File.Exists(path)) return (false, path, "메모리 파일이 아직 없습니다."); try { var lines = File.ReadAllLines(path).ToList(); var filtered = lines.Where(line => !line.Contains(query, StringComparison.OrdinalIgnoreCase)).ToList(); if (filtered.Count == lines.Count) return (false, path, "삭제할 일치 항목을 찾지 못했습니다."); File.WriteAllLines(path, filtered); Load(workFolder); return (true, path, "메모리 파일에서 일치 항목을 삭제했습니다."); } catch (Exception ex) { LogService.Warn($"메모리 파일 삭제 실패 ({path}): {ex.Message}"); return (false, path, $"메모리 파일 삭제 실패: {ex.Message}"); } } } /// 작업 폴더별 메모리 + 전역 메모리를 로드합니다. public void Load(string? workFolder) { lock (_lock) { _entries.Clear(); _instructionDocuments.Clear(); _currentWorkFolder = workFolder; // 전역 메모리 var globalPath = GetFilePath(null); LoadFromFile(globalPath); // 폴더별 메모리 if (!string.IsNullOrEmpty(workFolder)) { var folderPath = GetFilePath(workFolder); LoadFromFile(folderPath); } LoadInstructionDocuments(workFolder); NormalizeInstructionDocuments(); } } /// 새 메모리를 추가합니다. 유사한 내용이 있으면 병합합니다. 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 void LoadInstructionDocuments(string? workFolder) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var projectRoot = ResolveProjectRoot(workFolder); AddInstructionFileIfExists(Path.Combine(ManagedMemoryDir, MemoryFileName), "managed", "관리형 메모리", seen, projectRoot); AddInstructionRuleFilesIfExists(Path.Combine(ManagedMemoryDir, "rules"), "managed", "관리형 메모리", seen, projectRoot); AddInstructionFileIfExists(Path.Combine(MemoryDir, MemoryFileName), "user", "사용자 메모리", seen, projectRoot); AddInstructionRuleFilesIfExists(Path.Combine(MemoryDir, "rules"), "user", "사용자 메모리", seen, projectRoot); if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder)) return; var normalizedProjectRoot = projectRoot ?? workFolder; foreach (var directory in EnumerateDirectoryChain(normalizedProjectRoot, workFolder)) { AddInstructionFileIfExists(Path.Combine(directory, MemoryFileName), "project", "프로젝트 메모리", seen, normalizedProjectRoot); AddInstructionFileIfExists(Path.Combine(directory, ".ax", MemoryFileName), "project", "프로젝트 메모리", seen, normalizedProjectRoot); AddInstructionRuleFilesIfExists(Path.Combine(directory, ".ax", "rules"), "project", "프로젝트 메모리", seen, normalizedProjectRoot); AddInstructionFileIfExists(Path.Combine(directory, LocalMemoryFileName), "local", "로컬 메모리", seen, normalizedProjectRoot); } } private void AddInstructionRuleFilesIfExists(string rulesDirectory, string layer, string label, HashSet seen, string? projectRoot) { try { if (!Directory.Exists(rulesDirectory)) return; foreach (var file in Directory.GetFiles(rulesDirectory, "*.md").OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) AddInstructionFileIfExists(file, layer, label, seen, projectRoot); } catch (Exception ex) { LogService.Warn($"메모리 rules 로드 실패 ({rulesDirectory}): {ex.Message}"); } } private void AddInstructionFileIfExists(string path, string layer, string label, HashSet seen, string? projectRoot) { try { if (!File.Exists(path)) return; var fullPath = Path.GetFullPath(path); if (!seen.Add(fullPath)) return; var content = ExpandInstructionIncludes(fullPath, projectRoot, new HashSet(StringComparer.OrdinalIgnoreCase), 0); if (string.IsNullOrWhiteSpace(content)) return; var frontMatter = ParseFrontMatter(content); if (!frontMatter.Enabled) return; if (frontMatter.Paths.Count > 0 && !ShouldApplyToCurrentWorkFolder(frontMatter.Paths, projectRoot, _currentWorkFolder)) return; _instructionDocuments.Add(new MemoryInstructionDocument { Layer = layer, Label = label, Path = fullPath, Content = frontMatter.Content.Trim(), Paths = frontMatter.Paths, Description = frontMatter.Description, Tags = frontMatter.Tags, Enabled = frontMatter.Enabled, LoadOrder = _instructionDocuments.Count }); } catch (Exception ex) { LogService.Warn($"메모리 문서 로드 실패 ({path}): {ex.Message}"); } } private void NormalizeInstructionDocuments() { if (_instructionDocuments.Count <= 1) return; var seenNormalized = new HashSet(StringComparer.OrdinalIgnoreCase); var selected = new List(); foreach (var doc in _instructionDocuments.OrderByDescending(d => d.LoadOrder)) { var normalized = NormalizeInstructionContent(doc.Content); if (!string.IsNullOrWhiteSpace(normalized) && !seenNormalized.Add(normalized)) continue; selected.Add(doc); } selected = selected .OrderBy(d => GetLayerRank(d.Layer)) .ThenBy(d => d.LoadOrder) .ToList(); for (var i = 0; i < selected.Count; i++) { selected[i].Priority = i + 1; } _instructionDocuments.Clear(); _instructionDocuments.AddRange(selected); } private static string NormalizeInstructionContent(string content) { if (string.IsNullOrWhiteSpace(content)) return ""; var normalizedLines = content .Replace("\r\n", "\n") .Split('\n') .Select(line => string.Join(" ", line.Split(' ', '\t').Where(part => part.Length > 0)).Trim()) .Where(line => line.Length > 0); return string.Join("\n", normalizedLines).Trim(); } private static int GetLayerRank(string layer) => layer switch { "managed" => 0, "user" => 1, "project" => 2, "local" => 3, _ => 9 }; private static IEnumerable EnumerateDirectoryChain(string projectRoot, string workFolder) { var current = Path.GetFullPath(workFolder); var normalizedRoot = Path.GetFullPath(projectRoot); var stack = new Stack(); while (!string.IsNullOrWhiteSpace(current)) { stack.Push(current); if (string.Equals(current, normalizedRoot, StringComparison.OrdinalIgnoreCase)) break; var parent = Directory.GetParent(current)?.FullName; if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, current, StringComparison.OrdinalIgnoreCase)) break; current = parent; } while (stack.Count > 0) yield return stack.Pop(); } private static string? ResolveProjectRoot(string? workFolder) { if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder)) return null; var current = Path.GetFullPath(workFolder); while (!string.IsNullOrWhiteSpace(current)) { if (Directory.Exists(Path.Combine(current, ".git"))) return current; if (File.Exists(Path.Combine(current, ".git")) || File.Exists(Path.Combine(current, ".sln")) || Directory.EnumerateFiles(current, "*.sln", SearchOption.TopDirectoryOnly).Any() || Directory.EnumerateFiles(current, "*.csproj", SearchOption.TopDirectoryOnly).Any() || File.Exists(Path.Combine(current, "package.json")) || File.Exists(Path.Combine(current, "pyproject.toml")) || File.Exists(Path.Combine(current, "go.mod")) || File.Exists(Path.Combine(current, "Cargo.toml"))) { return current; } var parent = Directory.GetParent(current)?.FullName; if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, current, StringComparison.OrdinalIgnoreCase)) break; current = parent; } return Path.GetFullPath(workFolder); } private string ExpandInstructionIncludes(string path, string? projectRoot, HashSet visited, int depth) { try { if (depth > MaxIncludeDepth) return ""; var fullPath = Path.GetFullPath(path); if (!visited.Add(fullPath)) return ""; if (!File.Exists(fullPath)) return ""; var lines = File.ReadAllLines(fullPath); var sb = new StringBuilder(); var inCodeBlock = false; foreach (var originalLine in lines) { var line = originalLine; var trimmed = line.Trim(); if (trimmed.StartsWith("```", StringComparison.Ordinal)) { inCodeBlock = !inCodeBlock; sb.AppendLine(line); continue; } if (!inCodeBlock && trimmed.StartsWith("@", StringComparison.Ordinal) && trimmed.Length > 1) { var resolution = ResolveIncludePath(fullPath, trimmed, projectRoot, IsExternalMemoryIncludeAllowed()); LogIncludeAudit(fullPath, trimmed, resolution); if (!string.IsNullOrWhiteSpace(resolution.ResolvedPath)) { var included = ExpandInstructionIncludes(resolution.ResolvedPath, projectRoot, visited, depth + 1); if (!string.IsNullOrWhiteSpace(included)) { sb.AppendLine(included.TrimEnd()); continue; } } } sb.AppendLine(line); } visited.Remove(fullPath); return sb.ToString().Trim(); } catch (Exception ex) { LogService.Warn($"메모리 include 확장 실패 ({path}): {ex.Message}"); return ""; } } private static MemoryIncludeResolution ResolveIncludePath(string currentFile, string includeDirective, string? projectRoot, bool allowExternal) { var target = includeDirective[1..].Trim(); if (string.IsNullOrWhiteSpace(target)) return MemoryIncludeResolution.CreateFailure("", "빈 include 지시문"); var hashIndex = target.IndexOf('#'); if (hashIndex >= 0) target = target[..hashIndex]; if (string.IsNullOrWhiteSpace(target)) return MemoryIncludeResolution.CreateFailure("", "주석만 포함된 include 지시문"); string resolved; var externalCandidate = false; if (target.StartsWith("~/", StringComparison.Ordinal)) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); resolved = Path.Combine(home, target[2..]); externalCandidate = true; } else if (Path.IsPathRooted(target)) { resolved = target; externalCandidate = true; } else { var baseDir = Path.GetDirectoryName(currentFile) ?? ""; var relative = target.StartsWith("./", StringComparison.Ordinal) ? target[2..] : target; resolved = Path.Combine(baseDir, relative); } resolved = Path.GetFullPath(resolved); var ext = Path.GetExtension(resolved); if (!string.IsNullOrWhiteSpace(ext) && !new[] { ".md", ".txt", ".cs", ".json", ".yml", ".yaml", ".xml", ".props", ".targets" } .Contains(ext, StringComparer.OrdinalIgnoreCase)) return MemoryIncludeResolution.CreateFailure(resolved, $"지원하지 않는 확장자 {ext}"); if (!allowExternal) { if (externalCandidate) return MemoryIncludeResolution.CreateFailure(resolved, "외부 include가 비활성화되어 있습니다"); if (!string.IsNullOrWhiteSpace(projectRoot) && !IsSubPathOf(projectRoot, resolved)) return MemoryIncludeResolution.CreateFailure(resolved, "프로젝트 바깥 경로는 허용되지 않습니다"); } return File.Exists(resolved) ? MemoryIncludeResolution.CreateSuccess(resolved) : MemoryIncludeResolution.CreateFailure(resolved, "포함할 파일을 찾지 못했습니다"); } private static bool IsSubPathOf(string baseDirectory, string candidatePath) { try { var basePath = Path.GetFullPath(baseDirectory) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var fullCandidate = Path.GetFullPath(candidatePath); return fullCandidate.StartsWith(basePath, StringComparison.OrdinalIgnoreCase) || string.Equals(fullCandidate.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), StringComparison.OrdinalIgnoreCase); } catch { return false; } } private static bool IsExternalMemoryIncludeAllowed() { try { var app = System.Windows.Application.Current as App; return app?.SettingsService?.Settings.Llm.AllowExternalMemoryIncludes ?? false; } catch { return false; } } private static void LogIncludeAudit(string sourcePath, string directive, MemoryIncludeResolution resolution) { try { var app = System.Windows.Application.Current as App; if (app?.SettingsService?.Settings.Llm.EnableAuditLog != true) return; AuditLogService.Log(new AuditEntry { ConversationId = "", Tab = "Memory", Action = "MemoryInclude", ToolName = "memory", Parameters = $"{sourcePath} <= {directive}", Result = resolution.Success ? $"허용: {resolution.ResolvedPath}" : $"차단: {resolution.Reason}", FilePath = resolution.ResolvedPath, Success = resolution.Success, }); } catch { // 감사 로그 실패는 메모리 로드에 영향 주지 않음 } } public string? ReadInstructionFile(string scope, string? workFolder) { lock (_lock) { var path = GetWritableInstructionPath(scope, workFolder); if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return null; try { return File.ReadAllText(path); } catch (Exception ex) { LogService.Warn($"메모리 파일 읽기 실패 ({path}): {ex.Message}"); return null; } } } private static (string Content, List Paths, string Description, List Tags, bool Enabled) ParseFrontMatter(string content) { var lines = content.Replace("\r\n", "\n").Split('\n').ToList(); if (lines.Count < 3 || !string.Equals(lines[0].Trim(), "---", StringComparison.Ordinal)) return (content, new List(), "", new List(), true); var endIndex = -1; for (var i = 1; i < lines.Count; i++) { if (string.Equals(lines[i].Trim(), "---", StringComparison.Ordinal)) { endIndex = i; break; } } if (endIndex < 1) return (content, new List(), "", new List(), true); var paths = new List(); var tags = new List(); var description = ""; var enabled = true; var inPaths = false; var inTags = false; for (var i = 1; i < endIndex; i++) { var line = lines[i].Trim(); if (line.StartsWith("description:", StringComparison.OrdinalIgnoreCase)) { description = line["description:".Length..].Trim().Trim('"'); inPaths = false; inTags = false; continue; } if (line.StartsWith("enabled:", StringComparison.OrdinalIgnoreCase)) { var raw = line["enabled:".Length..].Trim().Trim('"'); enabled = !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase) && !string.Equals(raw, "0", StringComparison.OrdinalIgnoreCase) && !string.Equals(raw, "off", StringComparison.OrdinalIgnoreCase); inPaths = false; inTags = false; continue; } if (line.StartsWith("paths:", StringComparison.OrdinalIgnoreCase)) { inPaths = true; inTags = false; continue; } if (line.StartsWith("tags:", StringComparison.OrdinalIgnoreCase)) { inTags = true; inPaths = false; continue; } if (inPaths) { if (line.StartsWith("-", StringComparison.Ordinal)) { var pattern = line[1..].Trim().Trim('"'); if (!string.IsNullOrWhiteSpace(pattern)) paths.Add(pattern); continue; } if (line.Length > 0) inPaths = false; } if (inTags) { if (line.StartsWith("-", StringComparison.Ordinal)) { var tag = line[1..].Trim().Trim('"'); if (!string.IsNullOrWhiteSpace(tag)) tags.Add(tag); continue; } if (line.Length > 0) inTags = false; } } var stripped = string.Join("\n", lines.Skip(endIndex + 1)).Trim(); return (stripped, paths, description, tags, enabled); } private static bool ShouldApplyToCurrentWorkFolder(IReadOnlyList patterns, string? projectRoot, string? currentWorkFolder) { if (patterns.Count == 0) return true; if (string.IsNullOrWhiteSpace(projectRoot) || string.IsNullOrWhiteSpace(currentWorkFolder)) return false; var relative = Path.GetRelativePath(projectRoot, currentWorkFolder).Replace('\\', '/'); if (string.Equals(relative, ".", StringComparison.Ordinal)) relative = ""; return patterns.Any(pattern => GlobMatches(relative, pattern)); } private static bool GlobMatches(string relativePath, string pattern) { var normalizedPattern = (pattern ?? "").Replace('\\', '/').Trim(); var normalizedPath = (relativePath ?? "").Replace('\\', '/').Trim('/'); if (string.IsNullOrWhiteSpace(normalizedPattern)) return false; var regex = "^" + System.Text.RegularExpressions.Regex.Escape(normalizedPattern) .Replace(@"\*\*", "§§DOUBLESTAR§§") .Replace(@"\*", "[^/]*") .Replace(@"\?", "[^/]") .Replace("§§DOUBLESTAR§§", ".*") + "$"; if (System.Text.RegularExpressions.Regex.IsMatch(normalizedPath, regex, System.Text.RegularExpressions.RegexOptions.IgnoreCase)) return true; if (!string.IsNullOrEmpty(normalizedPath)) normalizedPath += "/"; return System.Text.RegularExpressions.Regex.IsMatch(normalizedPath, regex, System.Text.RegularExpressions.RegexOptions.IgnoreCase); } /// 텍스트를 토큰으로 분리합니다. 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; } } /// CLAUDE.md 스타일의 계층형 메모리 문서. public class MemoryInstructionDocument { [JsonPropertyName("layer")] public string Layer { get; set; } = "project"; [JsonPropertyName("label")] public string Label { get; set; } = "프로젝트 메모리"; [JsonPropertyName("path")] public string Path { get; set; } = ""; [JsonPropertyName("content")] public string Content { get; set; } = ""; [JsonPropertyName("paths")] public List Paths { get; set; } = new(); [JsonPropertyName("description")] public string Description { get; set; } = ""; [JsonPropertyName("tags")] public List Tags { get; set; } = new(); [JsonPropertyName("enabled")] public bool Enabled { get; set; } = true; [JsonPropertyName("loadOrder")] public int LoadOrder { get; set; } [JsonPropertyName("priority")] public int Priority { get; set; } } internal sealed class MemoryIncludeResolution { public string? ResolvedPath { get; init; } public string Reason { get; init; } = ""; public bool Success { get; init; } public static MemoryIncludeResolution CreateSuccess(string path) => new() { ResolvedPath = path, Success = true, Reason = "허용" }; public static MemoryIncludeResolution CreateFailure(string? path, string reason) => new() { ResolvedPath = string.IsNullOrWhiteSpace(path) ? null : path, Success = false, Reason = reason }; }