AX Agent 메모리 구조 3차 강화: @include 지원과 프로젝트 루트 판단 개선

- 계층형 메모리 문서에 @include 확장을 추가해 상대 경로, 홈 경로, 절대 경로 텍스트 파일을 최대 5단계까지 재귀적으로 펼치도록 구현함

- 코드 블록 내부 include 무시, 순환 참조 차단, 비텍스트 파일 제외 규칙을 적용해 안전한 최소 규칙으로 정리함

- 프로젝트 루트 판단을 .git, .sln, *.csproj, package.json, pyproject.toml, go.mod, Cargo.toml 마커 기반으로 강화해 project/local 메모리 탐색과 저장 경로를 더 정확히 맞춤

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
This commit is contained in:
2026-04-06 23:56:19 +09:00
parent 80682552f4
commit ae765fb543
3 changed files with 151 additions and 5 deletions

View File

@@ -21,6 +21,7 @@ public class AgentMemoryService
"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()
{
@@ -48,12 +49,13 @@ public class AgentMemoryService
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(workFolder) ? null : Path.Combine(Path.GetFullPath(workFolder), MemoryFileName),
"local" => string.IsNullOrWhiteSpace(workFolder) ? null : Path.Combine(Path.GetFullPath(workFolder), LocalMemoryFileName),
"project" => string.IsNullOrWhiteSpace(projectRoot) ? null : Path.Combine(projectRoot, MemoryFileName),
"local" => string.IsNullOrWhiteSpace(projectRoot) ? null : Path.Combine(projectRoot, LocalMemoryFileName),
_ => null
};
}
@@ -361,7 +363,8 @@ public class AgentMemoryService
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
return;
foreach (var directory in EnumerateDirectoryChain(workFolder))
var projectRoot = ResolveProjectRoot(workFolder) ?? workFolder;
foreach (var directory in EnumerateDirectoryChain(projectRoot, workFolder))
{
AddInstructionFileIfExists(Path.Combine(directory, MemoryFileName), "project", "프로젝트 메모리", seen);
AddInstructionFileIfExists(Path.Combine(directory, ".ax", MemoryFileName), "project", "프로젝트 메모리", seen);
@@ -397,7 +400,7 @@ public class AgentMemoryService
if (!seen.Add(fullPath))
return;
var content = File.ReadAllText(fullPath);
var content = ExpandInstructionIncludes(fullPath, new HashSet<string>(StringComparer.OrdinalIgnoreCase), 0);
if (string.IsNullOrWhiteSpace(content))
return;
@@ -415,14 +418,17 @@ public class AgentMemoryService
}
}
private static IEnumerable<string> EnumerateDirectoryChain(string workFolder)
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;
@@ -433,6 +439,133 @@ public class AgentMemoryService
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 static string ExpandInstructionIncludes(string path, 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 includePath = ResolveIncludePath(fullPath, trimmed);
if (!string.IsNullOrWhiteSpace(includePath))
{
var included = ExpandInstructionIncludes(includePath, 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 string? ResolveIncludePath(string currentFile, string includeDirective)
{
var target = includeDirective[1..].Trim();
if (string.IsNullOrWhiteSpace(target))
return null;
var hashIndex = target.IndexOf('#');
if (hashIndex >= 0)
target = target[..hashIndex];
if (string.IsNullOrWhiteSpace(target))
return null;
string resolved;
if (target.StartsWith("~/", StringComparison.Ordinal))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
resolved = Path.Combine(home, target[2..]);
}
else if (Path.IsPathRooted(target))
{
resolved = target;
}
else
{
var baseDir = Path.GetDirectoryName(currentFile) ?? "";
var relative = target.StartsWith("./", StringComparison.Ordinal) ? target[2..] : target;
resolved = Path.Combine(baseDir, relative);
}
var ext = Path.GetExtension(resolved);
if (!string.IsNullOrWhiteSpace(ext) &&
!new[] { ".md", ".txt", ".cs", ".json", ".yml", ".yaml", ".xml", ".props", ".targets" }
.Contains(ext, StringComparer.OrdinalIgnoreCase))
return null;
return File.Exists(resolved) ? resolved : null;
}
/// <summary>텍스트를 토큰으로 분리합니다.</summary>
private static HashSet<string> Tokenize(string text)
{