- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 claude-code 동등 품질 기준으로 재정렬함 - OpenAI/vLLM 경로의 오래된 tool history를 평탄화하고 최근 이력만 구조화해 컨텍스트 직렬화를 경량화함 - AX Agent UI를 테마 기준으로 재구성하고 플랜 승인/오버레이/이벤트 렌더링/명령 입력 상호작용을 개선함 - 파일 후보 제안, 반복 경로 정체 복구, LSP 보강, 문서·PPT 처리 개선, 설정/서비스 인터페이스 정리를 함께 반영함 - README.md 및 docs/DEVELOPMENT.md를 작업 시점별로 갱신함 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
339 lines
16 KiB
C#
339 lines
16 KiB
C#
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>
|
|
/// 작업 폴더의 디렉토리 트리 구조를 생성하는 도구.
|
|
/// LLM이 프로젝트 전체 구조를 파악하고 적절한 파일을 찾을 수 있도록 돕습니다.
|
|
/// </summary>
|
|
public class FolderMapTool : IAgentTool
|
|
{
|
|
public string Name => "folder_map";
|
|
public string Description =>
|
|
"Generate a directory tree map of the work folder or a specified subfolder. " +
|
|
"Shows folders and files in a tree structure. Use this only when the user explicitly asks for folder contents, directory structure, or workspace layout. " +
|
|
"Do not use it to locate a specific file when glob/grep or a targeted read would work. " +
|
|
"Supports sorting, size filtering, date filtering, and multi-extension filtering.";
|
|
|
|
public ToolParameterSchema Parameters => new()
|
|
{
|
|
Properties = new()
|
|
{
|
|
["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." },
|
|
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 2." },
|
|
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
|
|
["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." },
|
|
["extensions"] = new()
|
|
{
|
|
Type = "array",
|
|
Description = "Filter by multiple extensions, e.g. [\".cs\", \".json\"]. Takes precedence over 'pattern' if both are provided.",
|
|
Items = new ToolProperty { Type = "string" },
|
|
},
|
|
["sort_by"] = new() { Type = "string", Description = "Sort files/dirs within each level: 'name' (default), 'size' (descending), 'modified' (newest first)." },
|
|
["show_dir_sizes"] = new() { Type = "boolean", Description = "If true, show the total size of each directory in parentheses. Default: false." },
|
|
["modified_after"] = new() { Type = "string", Description = "ISO date string (e.g. '2024-01-01'). Only show files modified after this date." },
|
|
["max_file_size"] = new() { Type = "string", Description = "Only show files smaller than this size, e.g. '1MB', '500KB', '2048B'." },
|
|
},
|
|
Required = []
|
|
};
|
|
|
|
// 무시할 디렉토리 (빌드 산출물, 패키지 캐시 등)
|
|
private static readonly HashSet<string> IgnoredDirs = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
|
|
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
|
|
"packages", ".nuget", "TestResults", "coverage", ".next",
|
|
"target", ".gradle", ".cargo",
|
|
};
|
|
|
|
private const int MaxEntries = 500;
|
|
|
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
|
{
|
|
// ── path ──────────────────────────────────────────────────────────
|
|
var subPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
|
|
|
|
// ── depth ─────────────────────────────────────────────────────────
|
|
var depth = 2;
|
|
if (args.SafeTryGetProperty("depth", out var d))
|
|
{
|
|
if (d.ValueKind == JsonValueKind.Number) depth = d.GetInt32();
|
|
else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.SafeGetString(), out var dv)) depth = dv;
|
|
}
|
|
if (depth < 1) depth = 1;
|
|
var maxDepth = Math.Min(depth, 10);
|
|
|
|
// ── include_files ─────────────────────────────────────────────────
|
|
var includeFiles = true;
|
|
if (args.SafeTryGetProperty("include_files", out var inc))
|
|
{
|
|
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)
|
|
includeFiles = inc.GetBoolean();
|
|
else
|
|
includeFiles = !string.Equals(inc.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// ── extensions / pattern ──────────────────────────────────────────
|
|
HashSet<string>? extSet = null;
|
|
if (args.SafeTryGetProperty("extensions", out var extsEl) && extsEl.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var list = extsEl.EnumerateArray()
|
|
.Select(e => e.SafeGetString() ?? "")
|
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
|
.Select(s => s.StartsWith('.') ? s : "." + s)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
if (list.Count > 0) extSet = list;
|
|
}
|
|
// Fall back to single pattern if extensions not provided
|
|
string extFilter = "";
|
|
if (extSet == null)
|
|
extFilter = args.SafeTryGetProperty("pattern", out var pat) ? pat.SafeGetString() ?? "" : "";
|
|
|
|
// ── sort_by ───────────────────────────────────────────────────────
|
|
var sortBy = args.SafeTryGetProperty("sort_by", out var sb2) ? sb2.SafeGetString() ?? "name" : "name";
|
|
if (sortBy != "size" && sortBy != "modified") sortBy = "name";
|
|
|
|
// ── show_dir_sizes ────────────────────────────────────────────────
|
|
var showDirSizes = false;
|
|
if (args.SafeTryGetProperty("show_dir_sizes", out var sds))
|
|
{
|
|
if (sds.ValueKind == JsonValueKind.True || sds.ValueKind == JsonValueKind.False)
|
|
showDirSizes = sds.GetBoolean();
|
|
else
|
|
showDirSizes = string.Equals(sds.SafeGetString(), "true", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// ── modified_after ────────────────────────────────────────────────
|
|
DateTime? modifiedAfter = null;
|
|
if (args.SafeTryGetProperty("modified_after", out var maEl) && maEl.ValueKind == JsonValueKind.String)
|
|
{
|
|
if (DateTime.TryParse(maEl.SafeGetString(), out var mdt))
|
|
modifiedAfter = mdt;
|
|
}
|
|
|
|
// ── max_file_size ─────────────────────────────────────────────────
|
|
long? maxFileSizeBytes = null;
|
|
if (args.SafeTryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String)
|
|
maxFileSizeBytes = ParseSizeString(mfsEl.SafeGetString() ?? "");
|
|
|
|
// ── resolve base directory ────────────────────────────────────────
|
|
var baseDir = string.IsNullOrEmpty(subPath)
|
|
? context.WorkFolder
|
|
: FileReadTool.ResolvePath(subPath, context.WorkFolder);
|
|
|
|
if (string.IsNullOrEmpty(baseDir) || !Directory.Exists(baseDir))
|
|
return Task.FromResult(ToolResult.Fail($"디렉토리가 존재하지 않습니다: {baseDir}"));
|
|
|
|
if (!context.IsPathAllowed(baseDir))
|
|
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {baseDir}"));
|
|
|
|
try
|
|
{
|
|
var options = new TreeOptions(maxDepth, includeFiles, extFilter, extSet,
|
|
sortBy, showDirSizes, modifiedAfter, maxFileSizeBytes);
|
|
|
|
var sb = new StringBuilder();
|
|
var dirName = Path.GetFileName(baseDir);
|
|
if (string.IsNullOrEmpty(dirName)) dirName = baseDir;
|
|
|
|
long rootTotalSize = 0;
|
|
sb.Append($"{dirName}/");
|
|
|
|
int entryCount = 0;
|
|
int totalFiles = 0;
|
|
int totalDirs = 0;
|
|
BuildTree(sb, baseDir, "", 0, options, context,
|
|
ref entryCount, ref totalFiles, ref totalDirs, ref rootTotalSize);
|
|
|
|
if (showDirSizes)
|
|
sb.Insert(sb.ToString().IndexOf('/') + 1, $" ({FormatSize(rootTotalSize)})");
|
|
|
|
sb.AppendLine(); // newline after root
|
|
|
|
if (entryCount >= MaxEntries)
|
|
sb.AppendLine($"\n... ({MaxEntries} entry limit reached; adjust depth or filters)");
|
|
|
|
var summary = $"Folder map complete — {totalFiles} files, {totalDirs} dirs, {FormatSize(rootTotalSize)} total (depth {maxDepth})";
|
|
return Task.FromResult(ToolResult.Ok($"{summary}\n\n{sb}"));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Task.FromResult(ToolResult.Fail($"폴더 맵 생성 실패: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
// ─── Tree builder ────────────────────────────────────────────────────
|
|
|
|
private static void BuildTree(
|
|
StringBuilder sb, string dir, string prefix, int currentDepth,
|
|
TreeOptions opts, AgentContext context,
|
|
ref int entryCount, ref int totalFiles, ref int totalDirs, ref long accumSize)
|
|
{
|
|
if (currentDepth >= opts.MaxDepth || entryCount >= MaxEntries) return;
|
|
|
|
// ── Collect subdirectories ────────────────────────────────────────
|
|
List<DirectoryInfo> subDirs;
|
|
try
|
|
{
|
|
subDirs = new DirectoryInfo(dir).GetDirectories()
|
|
.Where(d => !d.Attributes.HasFlag(FileAttributes.Hidden)
|
|
&& !IgnoredDirs.Contains(d.Name))
|
|
.ToList();
|
|
}
|
|
catch { return; }
|
|
|
|
// ── Collect files ─────────────────────────────────────────────────
|
|
List<FileInfo> files = [];
|
|
if (opts.IncludeFiles)
|
|
{
|
|
try
|
|
{
|
|
files = new DirectoryInfo(dir).GetFiles()
|
|
.Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden))
|
|
.Where(f => MatchesExtension(f, opts.ExtFilter, opts.ExtSet))
|
|
.Where(f => opts.ModifiedAfter == null || f.LastWriteTime > opts.ModifiedAfter.Value)
|
|
.Where(f => opts.MaxFileSizeBytes == null || f.Length <= opts.MaxFileSizeBytes.Value)
|
|
.ToList();
|
|
}
|
|
catch { /* ignore */ }
|
|
}
|
|
|
|
// ── Sort ──────────────────────────────────────────────────────────
|
|
subDirs = opts.SortBy == "modified"
|
|
? subDirs.OrderByDescending(d => d.LastWriteTime).ToList()
|
|
: subDirs.OrderBy(d => d.Name).ToList();
|
|
|
|
files = opts.SortBy switch
|
|
{
|
|
"size" => files.OrderByDescending(f => f.Length).ToList(),
|
|
"modified" => files.OrderByDescending(f => f.LastWriteTime).ToList(),
|
|
_ => files.OrderBy(f => f.Name).ToList(),
|
|
};
|
|
|
|
var totalItems = subDirs.Count + files.Count;
|
|
var index = 0;
|
|
|
|
// ── Render subdirectories ─────────────────────────────────────────
|
|
foreach (var sub in subDirs)
|
|
{
|
|
if (entryCount >= MaxEntries) break;
|
|
index++;
|
|
var isLast = index == totalItems;
|
|
var connector = isLast ? "└── " : "├── ";
|
|
var childPrefix = isLast ? " " : "│ ";
|
|
|
|
long subSize = 0;
|
|
int subFiles = 0, subDirsCount = 0;
|
|
_ = subFiles; _ = subDirsCount; // suppress unused warnings (reserved for future use)
|
|
|
|
if (context.IsPathAllowed(sub.FullName))
|
|
{
|
|
// We always recurse to gather sizes; count only when rendered
|
|
if (opts.ShowDirSizes)
|
|
{
|
|
// Pre-compute directory size (best-effort, no error propagation)
|
|
try { subSize = ComputeDirSize(sub.FullName); } catch { }
|
|
}
|
|
|
|
totalDirs++;
|
|
entryCount++;
|
|
|
|
var dirLabel = opts.ShowDirSizes
|
|
? $"{sub.Name}/ ({FormatSize(subSize)})"
|
|
: $"{sub.Name}/";
|
|
sb.AppendLine($"{prefix}{connector}{dirLabel}");
|
|
|
|
BuildTree(sb, sub.FullName, prefix + childPrefix, currentDepth + 1, opts, context,
|
|
ref entryCount, ref totalFiles, ref totalDirs, ref subSize);
|
|
|
|
accumSize += subSize;
|
|
}
|
|
else
|
|
{
|
|
totalDirs++;
|
|
entryCount++;
|
|
sb.AppendLine($"{prefix}{connector}{sub.Name}/ [access denied]");
|
|
}
|
|
}
|
|
|
|
// ── Render files ──────────────────────────────────────────────────
|
|
foreach (var file in files)
|
|
{
|
|
if (entryCount >= MaxEntries) break;
|
|
index++;
|
|
var isLast = index == totalItems;
|
|
var connector = isLast ? "└── " : "├── ";
|
|
|
|
string annotation = opts.SortBy switch
|
|
{
|
|
"modified" => $"({file.LastWriteTime:yyyy-MM-dd}, {FormatSize(file.Length)})",
|
|
"size" => $"({FormatSize(file.Length)})",
|
|
_ => $"({FormatSize(file.Length)})",
|
|
};
|
|
|
|
sb.AppendLine($"{prefix}{connector}{file.Name} {annotation}");
|
|
accumSize += file.Length;
|
|
totalFiles++;
|
|
entryCount++;
|
|
}
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
private static bool MatchesExtension(FileInfo f, string extFilter, HashSet<string>? extSet)
|
|
{
|
|
if (extSet != null) return extSet.Contains(f.Extension);
|
|
if (!string.IsNullOrEmpty(extFilter))
|
|
return f.Extension.Equals(extFilter, StringComparison.OrdinalIgnoreCase);
|
|
return true;
|
|
}
|
|
|
|
private static long ComputeDirSize(string dir)
|
|
{
|
|
long total = 0;
|
|
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
|
{
|
|
try { total += new FileInfo(f).Length; } catch { }
|
|
}
|
|
return total;
|
|
}
|
|
|
|
private static long? ParseSizeString(string s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s)) return null;
|
|
s = s.Trim();
|
|
if (s.EndsWith("GB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var gb))
|
|
return (long)(gb * 1024 * 1024 * 1024);
|
|
if (s.EndsWith("MB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var mb))
|
|
return (long)(mb * 1024 * 1024);
|
|
if (s.EndsWith("KB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var kb))
|
|
return (long)(kb * 1024);
|
|
if (s.EndsWith("B", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^1], out var b))
|
|
return (long)b;
|
|
if (long.TryParse(s, out var raw)) return raw;
|
|
return null;
|
|
}
|
|
|
|
private static string FormatSize(long bytes) => bytes switch
|
|
{
|
|
< 1024 => $"{bytes} B",
|
|
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
|
|
< 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB",
|
|
_ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F2} GB",
|
|
};
|
|
|
|
// ─── Options record ────────────────────────────────────────────────────
|
|
|
|
private sealed record TreeOptions(
|
|
int MaxDepth,
|
|
bool IncludeFiles,
|
|
string ExtFilter,
|
|
HashSet<string>? ExtSet,
|
|
string SortBy,
|
|
bool ShowDirSizes,
|
|
DateTime? ModifiedAfter,
|
|
long? MaxFileSizeBytes);
|
|
}
|