Initial commit to new repository
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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 to understand the project layout before reading or editing files.";
|
||||
|
||||
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: 3." },
|
||||
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
|
||||
["pattern"] = new() { Type = "string", Description = "File extension filter (e.g. '.cs', '.py'). Optional, shows all files if omitted." },
|
||||
},
|
||||
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)
|
||||
{
|
||||
var subPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
var depth = 3;
|
||||
if (args.TryGetProperty("depth", out var d))
|
||||
{
|
||||
if (d.ValueKind == JsonValueKind.Number) depth = d.GetInt32();
|
||||
else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.GetString(), out var dv)) depth = dv;
|
||||
}
|
||||
var depthStr = depth.ToString();
|
||||
var includeFiles = true;
|
||||
if (args.TryGetProperty("include_files", out var inc))
|
||||
{
|
||||
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)
|
||||
includeFiles = inc.GetBoolean();
|
||||
else
|
||||
includeFiles = !string.Equals(inc.GetString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
var extFilter = args.TryGetProperty("pattern", out var pat) ? pat.GetString() ?? "" : "";
|
||||
|
||||
if (!int.TryParse(depthStr, out var maxDepth) || maxDepth < 1)
|
||||
maxDepth = 3;
|
||||
maxDepth = Math.Min(maxDepth, 10);
|
||||
|
||||
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 sb = new StringBuilder();
|
||||
var dirName = Path.GetFileName(baseDir);
|
||||
if (string.IsNullOrEmpty(dirName)) dirName = baseDir;
|
||||
sb.AppendLine($"{dirName}/");
|
||||
|
||||
int entryCount = 0;
|
||||
BuildTree(sb, baseDir, "", 0, maxDepth, includeFiles, extFilter, context, ref entryCount);
|
||||
|
||||
if (entryCount >= MaxEntries)
|
||||
sb.AppendLine($"\n... ({MaxEntries}개 항목 제한 도달, depth 또는 pattern을 조정하세요)");
|
||||
|
||||
var summary = $"폴더 맵 생성 완료 ({entryCount}개 항목, 깊이 {maxDepth})";
|
||||
return Task.FromResult(ToolResult.Ok($"{summary}\n\n{sb}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"폴더 맵 생성 실패: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildTree(
|
||||
StringBuilder sb, string dir, string prefix, int currentDepth, int maxDepth,
|
||||
bool includeFiles, string extFilter, AgentContext context, ref int entryCount)
|
||||
{
|
||||
if (currentDepth >= maxDepth || entryCount >= MaxEntries) return;
|
||||
|
||||
// 하위 디렉토리
|
||||
List<DirectoryInfo> subDirs;
|
||||
try
|
||||
{
|
||||
subDirs = new DirectoryInfo(dir).GetDirectories()
|
||||
.Where(d => !d.Attributes.HasFlag(FileAttributes.Hidden)
|
||||
&& !IgnoredDirs.Contains(d.Name))
|
||||
.OrderBy(d => d.Name)
|
||||
.ToList();
|
||||
}
|
||||
catch { return; } // 접근 불가 디렉토리 무시
|
||||
|
||||
// 하위 파일
|
||||
List<FileInfo> files = [];
|
||||
if (includeFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
files = new DirectoryInfo(dir).GetFiles()
|
||||
.Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden)
|
||||
&& (string.IsNullOrEmpty(extFilter)
|
||||
|| f.Extension.Equals(extFilter, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(f => f.Name)
|
||||
.ToList();
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
var totalItems = subDirs.Count + files.Count;
|
||||
var index = 0;
|
||||
|
||||
// 디렉토리 출력
|
||||
foreach (var sub in subDirs)
|
||||
{
|
||||
if (entryCount >= MaxEntries) break;
|
||||
index++;
|
||||
var isLast = index == totalItems;
|
||||
var connector = isLast ? "└── " : "├── ";
|
||||
var childPrefix = isLast ? " " : "│ ";
|
||||
|
||||
sb.AppendLine($"{prefix}{connector}{sub.Name}/");
|
||||
entryCount++;
|
||||
|
||||
if (context.IsPathAllowed(sub.FullName))
|
||||
BuildTree(sb, sub.FullName, prefix + childPrefix, currentDepth + 1, maxDepth,
|
||||
includeFiles, extFilter, context, ref entryCount);
|
||||
}
|
||||
|
||||
// 파일 출력
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (entryCount >= MaxEntries) break;
|
||||
index++;
|
||||
var isLast = index == totalItems;
|
||||
var connector = isLast ? "└── " : "├── ";
|
||||
|
||||
var sizeStr = FormatSize(file.Length);
|
||||
sb.AppendLine($"{prefix}{connector}{file.Name} ({sizeStr})");
|
||||
entryCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes) => bytes switch
|
||||
{
|
||||
< 1024 => $"{bytes} B",
|
||||
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
|
||||
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user