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:
@@ -1393,3 +1393,6 @@ MIT License
|
|||||||
- 업데이트: 2026-04-06 23:57 (KST)
|
- 업데이트: 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` 메모리 파일을 직접 다룰 수 있습니다.
|
- [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 메모리 구조가 이제 `읽기 전용 계층`이 아니라 `학습 메모리 + 계층형 메모리 파일`을 함께 관리하는 형태로 한 단계 더 올라왔습니다.
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs) 에는 계층형 메모리 파일의 실제 저장/삭제 경로를 결정하고 내용을 append/remove 하는 로직을 추가했습니다. AX 메모리 구조가 이제 `읽기 전용 계층`이 아니라 `학습 메모리 + 계층형 메모리 파일`을 함께 관리하는 형태로 한 단계 더 올라왔습니다.
|
||||||
|
- 업데이트: 2026-04-07 00:06 (KST)
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)에 `@include` 확장을 추가했습니다. 이제 `AXMEMORY.md` 안에서 `@./docs/architecture.md`, `@~/shared/rules.md`, 절대 경로 include를 사용할 수 있고, 텍스트 파일만 최대 5단계까지 재귀적으로 펼칩니다.
|
||||||
|
- 같은 파일에서 프로젝트 루트 판단도 강화했습니다. 이제 단순 현재 작업 폴더가 아니라 `.git`, `.sln`, `*.csproj`, `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml` 같은 마커를 보고 프로젝트 루트를 먼저 잡은 뒤, 그 루트부터 현재 작업 디렉토리까지의 메모리 계층을 조립합니다.
|
||||||
|
|||||||
@@ -5144,3 +5144,13 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
|
|||||||
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
- `GetWritableInstructionPath(...)`, `SaveInstruction(...)`, `DeleteInstruction(...)`를 추가해 `managed / user / project / local` scope별 메모리 파일에 실제 내용을 append/remove 할 수 있게 했다.
|
- `GetWritableInstructionPath(...)`, `SaveInstruction(...)`, `DeleteInstruction(...)`를 추가해 `managed / user / project / local` scope별 메모리 파일에 실제 내용을 append/remove 할 수 있게 했다.
|
||||||
- 1차 목표는 `claw-code`처럼 계층형 메모리를 “로드만 하는 구조”에서 “도구로 관리할 수 있는 구조”로 올리는 것이며, 현재는 bullet line append/remove 기반의 안전한 최소 구현을 적용했다.
|
- 1차 목표는 `claw-code`처럼 계층형 메모리를 “로드만 하는 구조”에서 “도구로 관리할 수 있는 구조”로 올리는 것이며, 현재는 bullet line append/remove 기반의 안전한 최소 구현을 적용했다.
|
||||||
|
|
||||||
|
## 2026-04-07 00:06 (KST)
|
||||||
|
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- 계층형 메모리 파일에 `@include` 확장을 추가했다.
|
||||||
|
- 지원 형식은 `@filename`, `@./relative/path`, `@~/path/in/home`, 절대 경로이며, `#fragment`는 제거 후 해석한다.
|
||||||
|
- 비텍스트 확장자는 무시하고, 코드 블록 내부 include는 처리하지 않으며, 순환 참조는 차단하고 최대 5단계까지만 재귀 확장한다.
|
||||||
|
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
|
||||||
|
- 프로젝트 루트 판단을 단순 `workFolder` 기준에서 `.git`, `.sln`, `*.csproj`, `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml` 마커 기반으로 강화했다.
|
||||||
|
- 이제 계층형 메모리 탐색은 프로젝트 루트부터 현재 작업 디렉토리까지 진행되고, `project/local` 메모리 파일 쓰기 경로도 같은 루트 판단을 사용한다.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class AgentMemoryService
|
|||||||
"AxCopilot", "memory");
|
"AxCopilot", "memory");
|
||||||
private const string MemoryFileName = "AXMEMORY.md";
|
private const string MemoryFileName = "AXMEMORY.md";
|
||||||
private const string LocalMemoryFileName = "AXMEMORY.local.md";
|
private const string LocalMemoryFileName = "AXMEMORY.local.md";
|
||||||
|
private const int MaxIncludeDepth = 5;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -48,12 +49,13 @@ public class AgentMemoryService
|
|||||||
public string? GetWritableInstructionPath(string scope, string? workFolder)
|
public string? GetWritableInstructionPath(string scope, string? workFolder)
|
||||||
{
|
{
|
||||||
scope = (scope ?? "").Trim().ToLowerInvariant();
|
scope = (scope ?? "").Trim().ToLowerInvariant();
|
||||||
|
var projectRoot = ResolveProjectRoot(workFolder);
|
||||||
return scope switch
|
return scope switch
|
||||||
{
|
{
|
||||||
"managed" => Path.Combine(ManagedMemoryDir, MemoryFileName),
|
"managed" => Path.Combine(ManagedMemoryDir, MemoryFileName),
|
||||||
"user" => Path.Combine(MemoryDir, MemoryFileName),
|
"user" => Path.Combine(MemoryDir, MemoryFileName),
|
||||||
"project" => string.IsNullOrWhiteSpace(workFolder) ? null : Path.Combine(Path.GetFullPath(workFolder), MemoryFileName),
|
"project" => string.IsNullOrWhiteSpace(projectRoot) ? null : Path.Combine(projectRoot, MemoryFileName),
|
||||||
"local" => string.IsNullOrWhiteSpace(workFolder) ? null : Path.Combine(Path.GetFullPath(workFolder), LocalMemoryFileName),
|
"local" => string.IsNullOrWhiteSpace(projectRoot) ? null : Path.Combine(projectRoot, LocalMemoryFileName),
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -361,7 +363,8 @@ public class AgentMemoryService
|
|||||||
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
|
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
|
||||||
return;
|
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, MemoryFileName), "project", "프로젝트 메모리", seen);
|
||||||
AddInstructionFileIfExists(Path.Combine(directory, ".ax", MemoryFileName), "project", "프로젝트 메모리", seen);
|
AddInstructionFileIfExists(Path.Combine(directory, ".ax", MemoryFileName), "project", "프로젝트 메모리", seen);
|
||||||
@@ -397,7 +400,7 @@ public class AgentMemoryService
|
|||||||
if (!seen.Add(fullPath))
|
if (!seen.Add(fullPath))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var content = File.ReadAllText(fullPath);
|
var content = ExpandInstructionIncludes(fullPath, new HashSet<string>(StringComparer.OrdinalIgnoreCase), 0);
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
return;
|
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 current = Path.GetFullPath(workFolder);
|
||||||
|
var normalizedRoot = Path.GetFullPath(projectRoot);
|
||||||
var stack = new Stack<string>();
|
var stack = new Stack<string>();
|
||||||
|
|
||||||
while (!string.IsNullOrWhiteSpace(current))
|
while (!string.IsNullOrWhiteSpace(current))
|
||||||
{
|
{
|
||||||
stack.Push(current);
|
stack.Push(current);
|
||||||
|
if (string.Equals(current, normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||||
|
break;
|
||||||
var parent = Directory.GetParent(current)?.FullName;
|
var parent = Directory.GetParent(current)?.FullName;
|
||||||
if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, current, StringComparison.OrdinalIgnoreCase))
|
if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, current, StringComparison.OrdinalIgnoreCase))
|
||||||
break;
|
break;
|
||||||
@@ -433,6 +439,133 @@ public class AgentMemoryService
|
|||||||
yield return stack.Pop();
|
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>
|
/// <summary>텍스트를 토큰으로 분리합니다.</summary>
|
||||||
private static HashSet<string> Tokenize(string text)
|
private static HashSet<string> Tokenize(string text)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user