Files
AX-Copilot-Codex/src/AxCopilot/Services/AgentMemoryService.cs
lacvet 80682552f4
Some checks failed
Release Gate / gate (push) Has been cancelled
AX Agent 메모리 구조 2차 강화: 계층형 메모리 관리 도구 확장
- memory 도구에 save_scope, delete_scope 액션을 추가해 managed/user/project/local 메모리 파일을 직접 저장 및 삭제할 수 있게 확장함

- search, list 액션이 학습 메모리뿐 아니라 계층형 메모리 문서도 함께 보여주도록 개선함

- AgentMemoryService에 계층형 메모리 파일 쓰기/삭제 경로와 append/remove 로직을 추가해 메모리 계층을 실제로 관리 가능한 상태로 전환함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 23:46:23 +09:00

504 lines
18 KiB
C#

using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services;
/// <summary>
/// 에이전트 메모리 서비스.
/// 작업 폴더별 + 전역 메모리를 관리하며, 대화 간 지속적 컨텍스트를 유지합니다.
/// 저장소: %APPDATA%\AxCopilot\memory\{hash}.dat (암호화)
/// </summary>
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 static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private readonly List<MemoryEntry> _entries = new();
private readonly List<MemoryInstructionDocument> _instructionDocuments = new();
private readonly object _lock = new();
private string? _currentWorkFolder;
/// <summary>현재 로드된 메모리 항목 수.</summary>
public int Count { get { lock (_lock) return _entries.Count; } }
/// <summary>모든 메모리 항목 (읽기 전용).</summary>
public IReadOnlyList<MemoryEntry> All { get { lock (_lock) return _entries.ToList(); } }
/// <summary>현재 로드된 계층형 메모리 문서 (읽기 전용).</summary>
public IReadOnlyList<MemoryInstructionDocument> InstructionDocuments
{
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}");
}
}
}
/// <summary>작업 폴더별 메모리 + 전역 메모리를 로드합니다.</summary>
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);
}
}
/// <summary>새 메모리를 추가합니다. 유사한 내용이 있으면 병합합니다.</summary>
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;
}
}
/// <summary>쿼리와 관련된 메모리를 검색합니다.</summary>
public List<MemoryEntry> 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;
}
}
/// <summary>메모리 사용 기록을 갱신합니다.</summary>
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();
}
}
}
/// <summary>메모리를 삭제합니다.</summary>
public bool Remove(string id)
{
lock (_lock)
{
var removed = _entries.RemoveAll(e => e.Id == id) > 0;
if (removed) Save();
return removed;
}
}
/// <summary>모든 메모리를 삭제합니다.</summary>
public void Clear()
{
lock (_lock)
{
_entries.Clear();
Save();
}
}
/// <summary>오래되고 사용되지 않는 메모리를 정리합니다.</summary>
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);
}
/// <summary>메모리를 암호화하여 파일에 저장합니다.</summary>
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<MemoryEntry> 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<List<MemoryEntry>>(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");
}
/// <summary>두 문자열의 키워드 유사도를 계산합니다 (0~1).</summary>
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<string>(StringComparer.OrdinalIgnoreCase);
AddInstructionFileIfExists(Path.Combine(ManagedMemoryDir, MemoryFileName), "managed", "관리형 메모리", seen);
AddInstructionRuleFilesIfExists(Path.Combine(ManagedMemoryDir, "rules"), "managed", "관리형 메모리", seen);
AddInstructionFileIfExists(Path.Combine(MemoryDir, MemoryFileName), "user", "사용자 메모리", seen);
AddInstructionRuleFilesIfExists(Path.Combine(MemoryDir, "rules"), "user", "사용자 메모리", seen);
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
return;
foreach (var directory in EnumerateDirectoryChain(workFolder))
{
AddInstructionFileIfExists(Path.Combine(directory, MemoryFileName), "project", "프로젝트 메모리", seen);
AddInstructionFileIfExists(Path.Combine(directory, ".ax", MemoryFileName), "project", "프로젝트 메모리", seen);
AddInstructionRuleFilesIfExists(Path.Combine(directory, ".ax", "rules"), "project", "프로젝트 메모리", seen);
AddInstructionFileIfExists(Path.Combine(directory, LocalMemoryFileName), "local", "로컬 메모리", seen);
}
}
private void AddInstructionRuleFilesIfExists(string rulesDirectory, string layer, string label, HashSet<string> seen)
{
try
{
if (!Directory.Exists(rulesDirectory))
return;
foreach (var file in Directory.GetFiles(rulesDirectory, "*.md").OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
AddInstructionFileIfExists(file, layer, label, seen);
}
catch (Exception ex)
{
LogService.Warn($"메모리 rules 로드 실패 ({rulesDirectory}): {ex.Message}");
}
}
private void AddInstructionFileIfExists(string path, string layer, string label, HashSet<string> seen)
{
try
{
if (!File.Exists(path))
return;
var fullPath = Path.GetFullPath(path);
if (!seen.Add(fullPath))
return;
var content = File.ReadAllText(fullPath);
if (string.IsNullOrWhiteSpace(content))
return;
_instructionDocuments.Add(new MemoryInstructionDocument
{
Layer = layer,
Label = label,
Path = fullPath,
Content = content.Trim()
});
}
catch (Exception ex)
{
LogService.Warn($"메모리 문서 로드 실패 ({path}): {ex.Message}");
}
}
private static IEnumerable<string> EnumerateDirectoryChain(string workFolder)
{
var current = Path.GetFullPath(workFolder);
var stack = new Stack<string>();
while (!string.IsNullOrWhiteSpace(current))
{
stack.Push(current);
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();
}
/// <summary>텍스트를 토큰으로 분리합니다.</summary>
private static HashSet<string> Tokenize(string text)
{
var tokens = new HashSet<string>(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;
}
}
/// <summary>에이전트 메모리 항목.</summary>
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; }
}
/// <summary>CLAUDE.md 스타일의 계층형 메모리 문서.</summary>
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; } = "";
}