diff --git a/README.md b/README.md index e3d2603..104e354 100644 --- a/README.md +++ b/README.md @@ -1390,3 +1390,6 @@ MIT License - 업데이트: 2026-04-06 23:49 (KST) - AX Agent 메모리 구조 강화를 시작했습니다. [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)에 `관리형 / 사용자 / 프로젝트 / 로컬` 계층형 메모리 문서 로더를 추가해 `AXMEMORY.md`, `AXMEMORY.local.md`, `.ax/rules/*.md` 계열 파일을 현재 작업 폴더까지 발견하고 로드합니다. - [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 시스템 프롬프트 메모리 섹션도 계층형 메모리 + 기존 학습 메모리를 함께 조립하도록 바꿨습니다. 이제 AX는 `claw-code`처럼 지속 메모리를 단순 전역/폴더 저장이 아니라 계층형 지시문 + 학습형 메모리의 조합으로 주입합니다. +- 업데이트: 2026-04-06 23:57 (KST) + - [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs)를 확장해 계층형 메모리 관리 액션을 추가했습니다. 이제 `/memory`는 기존 학습 메모리 `save/search/list/delete` 외에 `save_scope`, `delete_scope`를 통해 `managed / user / project / local` 메모리 파일을 직접 다룰 수 있습니다. + - [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs) 에는 계층형 메모리 파일의 실제 저장/삭제 경로를 결정하고 내용을 append/remove 하는 로직을 추가했습니다. AX 메모리 구조가 이제 `읽기 전용 계층`이 아니라 `학습 메모리 + 계층형 메모리 파일`을 함께 관리하는 형태로 한 단계 더 올라왔습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5bab6c7..09bad00 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -5134,3 +5134,13 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - 시스템 프롬프트의 메모리 섹션을 `메모리 계층` + `학습 메모리` 2단 구조로 재편했다. - 이전에는 학습 메모리만 단순 나열했지만, 이제 `claw-code`처럼 계층형 메모리 파일을 우선순위 순서로 조립하고 그 아래에 학습형 메모리를 추가한다. - 로드 전에 `Count`를 먼저 확인해 다른 작업 폴더 메모리가 누락될 수 있던 경로도 함께 바로잡았다. + +## 2026-04-06 23:57 (KST) + +- [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs) + - `save_scope`, `delete_scope` 액션을 추가해 계층형 메모리 파일도 도구 레벨에서 직접 다룰 수 있게 확장했다. + - `search`, `list`는 기존 학습 메모리뿐 아니라 계층형 메모리 문서(`MemoryInstructionDocument`)도 함께 보여주도록 바꿨다. + - 도구 실행마다 현재 `workFolder` 기준으로 `memoryService.Load(...)`를 먼저 호출해, 작업 폴더 컨텍스트에 맞는 메모리 계층이 반영되도록 정리했다. +- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs) + - `GetWritableInstructionPath(...)`, `SaveInstruction(...)`, `DeleteInstruction(...)`를 추가해 `managed / user / project / local` scope별 메모리 파일에 실제 내용을 append/remove 할 수 있게 했다. + - 1차 목표는 `claw-code`처럼 계층형 메모리를 “로드만 하는 구조”에서 “도구로 관리할 수 있는 구조”로 올리는 것이며, 현재는 bullet line append/remove 기반의 안전한 최소 구현을 적용했다. diff --git a/src/AxCopilot/Services/Agent/MemoryTool.cs b/src/AxCopilot/Services/Agent/MemoryTool.cs index 53393a9..af4c6ce 100644 --- a/src/AxCopilot/Services/Agent/MemoryTool.cs +++ b/src/AxCopilot/Services/Agent/MemoryTool.cs @@ -14,18 +14,22 @@ public class MemoryTool : IAgentTool public string Description => "프로젝트 규칙, 사용자 선호도, 학습 내용을 저장하고 검색합니다.\n" + "대화 간 지속되는 메모리로, 새 대화에서도 이전에 학습한 내용을 활용할 수 있습니다.\n" + - "- action=\"save\": 새 메모리 저장 (type, content 필수)\n" + + "- action=\"save\": 학습 메모리 저장 (type, content 필수)\n" + + "- action=\"save_scope\": 계층형 메모리 파일에 저장 (scope, content 필수)\n" + "- action=\"search\": 관련 메모리 검색 (query 필수)\n" + - "- action=\"list\": 현재 메모리 전체 목록\n" + - "- action=\"delete\": 메모리 삭제 (id 필수)\n" + + "- action=\"list\": 현재 메모리 전체 목록 + 계층형 메모리 파일 목록\n" + + "- action=\"delete\": 학습 메모리 삭제 (id 필수)\n" + + "- action=\"delete_scope\": 계층형 메모리 파일에서 삭제 (scope, query 필수)\n" + + "scope 종류: managed | user | project | local\n" + "type 종류: rule(프로젝트 규칙), preference(사용자 선호), fact(사실), correction(실수 교정)"; public ToolParameterSchema Parameters => new() { Properties = new() { - ["action"] = new() { Type = "string", Description = "save | search | list | delete" }, + ["action"] = new() { Type = "string", Description = "save | save_scope | search | list | delete | delete_scope" }, ["type"] = new() { Type = "string", Description = "메모리 유형: rule | preference | fact | correction. save 시 필수." }, + ["scope"] = new() { Type = "string", Description = "계층형 메모리 대상: managed | user | project | local. save_scope/delete_scope 시 필수." }, ["content"] = new() { Type = "string", Description = "저장할 내용. save 시 필수." }, ["query"] = new() { Type = "string", Description = "검색 쿼리. search 시 필수." }, ["id"] = new() { Type = "string", Description = "메모리 ID. delete 시 필수." }, @@ -44,6 +48,8 @@ public class MemoryTool : IAgentTool if (memoryService == null) return Task.FromResult(ToolResult.Fail("메모리 서비스를 사용할 수 없습니다.")); + memoryService.Load(context.WorkFolder); + if (!args.TryGetProperty("action", out var actionEl)) return Task.FromResult(ToolResult.Fail("action이 필요합니다.")); var action = actionEl.GetString() ?? ""; @@ -51,10 +57,12 @@ public class MemoryTool : IAgentTool return Task.FromResult(action switch { "save" => ExecuteSave(args, memoryService, context), + "save_scope" => ExecuteSaveScope(args, memoryService, context), "search" => ExecuteSearch(args, memoryService), "list" => ExecuteList(memoryService), "delete" => ExecuteDelete(args, memoryService), - _ => ToolResult.Fail($"알 수 없는 액션: {action}. save | search | list | delete 중 선택하세요."), + "delete_scope" => ExecuteDeleteScope(args, memoryService, context), + _ => ToolResult.Fail($"알 수 없는 액션: {action}. save | save_scope | search | list | delete | delete_scope 중 선택하세요."), }); } @@ -82,29 +90,58 @@ public class MemoryTool : IAgentTool return ToolResult.Fail("query가 필요합니다."); var results = svc.GetRelevant(query, 10); - if (results.Count == 0) + var docs = svc.InstructionDocuments + .Where(d => d.Content.Contains(query, StringComparison.OrdinalIgnoreCase) + || d.Path.Contains(query, StringComparison.OrdinalIgnoreCase)) + .Take(8) + .ToList(); + + if (results.Count == 0 && docs.Count == 0) return ToolResult.Ok("관련 메모리가 없습니다."); var sb = new StringBuilder(); - sb.AppendLine($"관련 메모리 {results.Count}개:"); - foreach (var e in results) - sb.AppendLine($" [{e.Type}] {e.Content} (사용 {e.UseCount}회, ID: {e.Id})"); + if (docs.Count > 0) + { + sb.AppendLine($"계층형 메모리 {docs.Count}개:"); + foreach (var doc in docs) + sb.AppendLine($" [{doc.Label}] {doc.Path}"); + sb.AppendLine(); + } + + if (results.Count > 0) + { + sb.AppendLine($"학습 메모리 {results.Count}개:"); + foreach (var e in results) + sb.AppendLine($" [{e.Type}] {e.Content} (사용 {e.UseCount}회, ID: {e.Id})"); + } return ToolResult.Ok(sb.ToString()); } private static ToolResult ExecuteList(AgentMemoryService svc) { var all = svc.All; - if (all.Count == 0) + var docs = svc.InstructionDocuments; + if (all.Count == 0 && docs.Count == 0) return ToolResult.Ok("저장된 메모리가 없습니다."); var sb = new StringBuilder(); - sb.AppendLine($"전체 메모리 {all.Count}개:"); - foreach (var group in all.GroupBy(e => e.Type)) + if (docs.Count > 0) { - sb.AppendLine($"\n[{group.Key}]"); - foreach (var e in group.OrderByDescending(e => e.UseCount)) - sb.AppendLine($" • {e.Content} (사용 {e.UseCount}회, ID: {e.Id})"); + sb.AppendLine($"계층형 메모리 파일 {docs.Count}개:"); + foreach (var doc in docs) + sb.AppendLine($" • [{doc.Label}] {doc.Path}"); + sb.AppendLine(); + } + + if (all.Count > 0) + { + sb.AppendLine($"학습 메모리 {all.Count}개:"); + foreach (var group in all.GroupBy(e => e.Type)) + { + sb.AppendLine($"\n[{group.Key}]"); + foreach (var e in group.OrderByDescending(e => e.UseCount)) + sb.AppendLine($" • {e.Content} (사용 {e.UseCount}회, ID: {e.Id})"); + } } return ToolResult.Ok(sb.ToString()); } @@ -119,4 +156,34 @@ public class MemoryTool : IAgentTool ? ToolResult.Ok($"메모리 삭제됨 (ID: {id})") : ToolResult.Fail($"해당 ID의 메모리를 찾을 수 없습니다: {id}"); } + + private static ToolResult ExecuteSaveScope(JsonElement args, AgentMemoryService svc, AgentContext context) + { + var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : ""; + var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : ""; + if (string.IsNullOrWhiteSpace(scope)) + return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요."); + if (string.IsNullOrWhiteSpace(content)) + return ToolResult.Fail("content가 필요합니다."); + + var result = svc.SaveInstruction(scope, content, context.WorkFolder); + return result.Path.Length == 0 + ? ToolResult.Fail(result.Message) + : ToolResult.Ok($"{result.Message}\n경로: {result.Path}"); + } + + private static ToolResult ExecuteDeleteScope(JsonElement args, AgentMemoryService svc, AgentContext context) + { + var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : ""; + var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : ""; + if (string.IsNullOrWhiteSpace(scope)) + return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요."); + if (string.IsNullOrWhiteSpace(query)) + return ToolResult.Fail("query가 필요합니다."); + + var result = svc.DeleteInstruction(scope, query, context.WorkFolder); + return result.Changed + ? ToolResult.Ok($"{result.Message}\n경로: {result.Path}") + : ToolResult.Fail(result.Message); + } } diff --git a/src/AxCopilot/Services/AgentMemoryService.cs b/src/AxCopilot/Services/AgentMemoryService.cs index 00a0ea4..a0ea826 100644 --- a/src/AxCopilot/Services/AgentMemoryService.cs +++ b/src/AxCopilot/Services/AgentMemoryService.cs @@ -45,6 +45,84 @@ public class AgentMemoryService get { lock (_lock) return _instructionDocuments.ToList(); } } + public string? GetWritableInstructionPath(string scope, string? workFolder) + { + scope = (scope ?? "").Trim().ToLowerInvariant(); + return scope switch + { + "managed" => Path.Combine(ManagedMemoryDir, MemoryFileName), + "user" => Path.Combine(MemoryDir, MemoryFileName), + "project" => string.IsNullOrWhiteSpace(workFolder) ? null : Path.Combine(Path.GetFullPath(workFolder), MemoryFileName), + "local" => string.IsNullOrWhiteSpace(workFolder) ? null : Path.Combine(Path.GetFullPath(workFolder), 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) {