- AgentMemoryService에 @include 성공/차단 감사 로그(MemoryInclude) 기록 추가 - Cowork/Code 하단 폴더 바에 메모리 규칙/학습 메모리 상태 요약 표시 추가 - 설정의 외부 메모리 include 안내 문구를 감사 로그 기준으로 정리 - dotnet build 검증 완료 (경고 0 / 오류 0)
968 lines
34 KiB
C#
968 lines
34 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 const int MaxIncludeDepth = 5;
|
|
|
|
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();
|
|
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}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
NormalizeInstructionDocuments();
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
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<string> 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<string> 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<string>(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<string>(StringComparer.OrdinalIgnoreCase);
|
|
var selected = new List<MemoryInstructionDocument>();
|
|
|
|
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<string> EnumerateDirectoryChain(string projectRoot, string workFolder)
|
|
{
|
|
var current = Path.GetFullPath(workFolder);
|
|
var normalizedRoot = Path.GetFullPath(projectRoot);
|
|
var stack = new Stack<string>();
|
|
|
|
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<string> 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<string> Paths, string Description, List<string> 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<string>(), "", new List<string>(), 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<string>(), "", new List<string>(), true);
|
|
|
|
var paths = new List<string>();
|
|
var tags = new List<string>();
|
|
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<string> 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);
|
|
}
|
|
|
|
/// <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; } = "";
|
|
|
|
[JsonPropertyName("paths")]
|
|
public List<string> Paths { get; set; } = new();
|
|
|
|
[JsonPropertyName("description")]
|
|
public string Description { get; set; } = "";
|
|
|
|
[JsonPropertyName("tags")]
|
|
public List<string> 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
|
|
};
|
|
}
|