|
1 | | -import fs from 'fs'; |
2 | | -import path from 'path'; |
3 | 1 | import { DynamicStructuredTool, type StructuredToolInterface } from '@langchain/core/tools'; |
4 | 2 | import { z } from 'zod'; |
5 | 3 | import { LoggerService } from '../../../Core/LoggerService'; |
6 | | -import { createTextContent, createErrorResult, createSuccessResult, MCPToolResult } from 'scorpio.ai'; |
| 4 | +import { createTextContent, createErrorResult, createSuccessResult, MCPToolResult, formatWalkTree, DEFAULT_WALK_MAX_DEPTH, DEFAULT_WALK_LIMIT } from 'scorpio.ai'; |
7 | 5 | import { checkDir } from '../utils'; |
8 | 6 | import { loadPrompt } from '../../../Core/PromptLoader'; |
9 | 7 |
|
10 | 8 | const logger = LoggerService.getLogger('Tools/FileSystem/operations/ls.ts'); |
11 | 9 |
|
12 | | -const IGNORE_PATTERNS = [ |
| 10 | +const IGNORE_PATTERNS = new Set([ |
13 | 11 | 'node_modules', '__pycache__', '.git', 'dist', 'build', |
14 | 12 | 'target', 'vendor', 'bin', 'obj', '.idea', '.vscode', |
15 | 13 | '.zig-cache', 'zig-out', '.coverage', 'coverage', |
16 | 14 | 'tmp', 'temp', '.cache', 'cache', 'logs', |
17 | 15 | '.venv', 'venv', 'env', |
18 | | -]; |
| 16 | +]); |
19 | 17 |
|
20 | | -const LIMIT = 100; |
21 | | - |
22 | | -interface TreeNode { |
23 | | - name: string; |
24 | | - isDir: boolean; |
25 | | - children: TreeNode[]; |
26 | | -} |
27 | | - |
28 | | -/** 以树形文本列出目录结构,自动忽略常见构建/依赖目录 */ |
| 18 | +/** 列出目录内容(扁平相对路径;目录加 `/` 后缀;自动忽略常见构建/依赖目录) */ |
29 | 19 | export function createLsTool(): StructuredToolInterface { |
30 | 20 | return new DynamicStructuredTool({ |
31 | 21 | name: 'ls', |
32 | 22 | description: loadPrompt('tools/fs/ls.txt'), |
33 | 23 | schema: z.object({ |
34 | 24 | dirPath: z.string().describe('Absolute path of the directory to list'), |
| 25 | + maxDepth: z.number().int().positive().optional().default(DEFAULT_WALK_MAX_DEPTH).describe(`Max recursion depth (1 = direct children only). Default ${DEFAULT_WALK_MAX_DEPTH}`), |
| 26 | + limit: z.number().int().positive().optional().default(DEFAULT_WALK_LIMIT).describe(`Stop after this many entries (files + directories). Default ${DEFAULT_WALK_LIMIT}`), |
35 | 27 | ignore: z.array(z.string()).optional().describe('Additional directory/file names to ignore'), |
36 | 28 | }) as any, |
37 | | - func: async ({ dirPath, ignore = [] }: any): Promise<MCPToolResult> => { |
| 29 | + func: async ({ dirPath, maxDepth = DEFAULT_WALK_MAX_DEPTH, limit = DEFAULT_WALK_LIMIT, ignore = [] }: any): Promise<MCPToolResult> => { |
38 | 30 | try { |
39 | 31 | const abs = checkDir(dirPath); |
40 | | - const extraIgnore: string[] = ignore ?? []; |
41 | | - const shouldIgnore = (name: string) => |
42 | | - IGNORE_PATTERNS.includes(name) || extraIgnore.includes(name); |
43 | | - |
44 | | - let fileCount = 0; |
45 | | - let dirCount = 0; |
46 | | - |
47 | | - function walk(dir: string): TreeNode[] { |
48 | | - let entries: fs.Dirent[]; |
49 | | - try { |
50 | | - entries = fs.readdirSync(dir, { withFileTypes: true }); |
51 | | - } catch { return []; } |
52 | | - |
53 | | - // 排序:目录在前,文件在后,各自按名称排序 |
54 | | - entries.sort((a, b) => { |
55 | | - const aDir = a.isDirectory() ? 0 : 1; |
56 | | - const bDir = b.isDirectory() ? 0 : 1; |
57 | | - if (aDir !== bDir) return aDir - bDir; |
58 | | - return a.name.localeCompare(b.name); |
59 | | - }); |
60 | | - |
61 | | - const nodes: TreeNode[] = []; |
62 | | - for (const entry of entries) { |
63 | | - if (fileCount >= LIMIT) break; |
64 | | - if (shouldIgnore(entry.name)) continue; |
65 | | - |
66 | | - if (entry.isDirectory()) { |
67 | | - dirCount++; |
68 | | - const children = fileCount < LIMIT |
69 | | - ? walk(path.join(dir, entry.name)) |
70 | | - : []; |
71 | | - nodes.push({ name: entry.name, isDir: true, children }); |
72 | | - } else { |
73 | | - fileCount++; |
74 | | - nodes.push({ name: entry.name, isDir: false, children: [] }); |
75 | | - } |
76 | | - } |
77 | | - return nodes; |
78 | | - } |
79 | | - |
80 | | - const tree = walk(abs); |
81 | | - |
82 | | - // 渲染树形结构 |
83 | | - const lines: string[] = [`${abs}/`]; |
84 | | - |
85 | | - function render(nodes: TreeNode[], prefix: string) { |
86 | | - for (let i = 0; i < nodes.length; i++) { |
87 | | - const node = nodes[i]; |
88 | | - const isLast = i === nodes.length - 1; |
89 | | - const connector = isLast ? '└── ' : '├── '; |
90 | | - const label = node.isDir ? `${node.name}/` : node.name; |
91 | | - lines.push(`${prefix}${connector}${label}`); |
92 | | - |
93 | | - if (node.isDir && node.children.length > 0) { |
94 | | - const childPrefix = prefix + (isLast ? ' ' : '│ '); |
95 | | - render(node.children, childPrefix); |
96 | | - } |
97 | | - } |
98 | | - } |
99 | | - |
100 | | - render(tree, ''); |
101 | | - |
102 | | - // 汇总信息 |
103 | | - const parts: string[] = []; |
104 | | - parts.push(`${dirCount} director${dirCount === 1 ? 'y' : 'ies'}`); |
105 | | - parts.push(`${fileCount} file${fileCount === 1 ? '' : 's'}`); |
106 | | - if (fileCount >= LIMIT) parts.push('truncated'); |
107 | | - lines.push('', parts.join(', ')); |
108 | | - |
109 | | - return createSuccessResult(createTextContent(lines.join('\n'))); |
| 32 | + const ignoreSet = new Set<string>([...IGNORE_PATTERNS, ...(ignore ?? [])]); |
| 33 | + return createSuccessResult(createTextContent(formatWalkTree(abs, { maxDepth, limit, ignore: ignoreSet }))); |
110 | 34 | } catch (e: any) { |
111 | 35 | logger.error(`ls ${dirPath}: ${e.message}`); |
112 | 36 | return createErrorResult(e.message); |
|
0 commit comments