foliko 2.0.21 → 2.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +56 -56
- package/.lintstagedrc +7 -7
- package/.prettierignore +29 -29
- package/.prettierrc +11 -11
- package/Dockerfile +63 -63
- package/install.ps1 +129 -129
- package/install.sh +121 -121
- package/package.json +3 -5
- package/plugins/core/skill-manager/index.js +34 -23
- package/plugins/core/sub-agent/index.js +103 -14
- package/plugins/core/workflow/context.js +941 -941
- package/plugins/core/workflow/engine.js +66 -66
- package/plugins/core/workflow/js-runner.js +318 -318
- package/plugins/core/workflow/json-runner.js +323 -323
- package/plugins/core/workflow/stages/choice.js +74 -74
- package/plugins/core/workflow/stages/each.js +123 -123
- package/plugins/core/workflow/stages/parallel.js +69 -69
- package/plugins/executors/extension/extension-registry.js +72 -1
- package/plugins/executors/extension/index.js +68 -9
- package/plugins/io/file-system/index.js +377 -153
- package/skills/find-skills/SKILL.md +133 -133
- package/src/agent/chat.js +207 -222
- package/src/agent/sub.js +29 -26
- package/src/agent/tool-loop.js +648 -0
- package/src/llm/provider.js +12 -28
- package/src/plugin/base.js +17 -14
- package/src/plugin/manager.js +19 -0
- package/src/tool/router.js +2 -2
- package/src/utils/message-validator.js +186 -0
- package/tests/core/chat-tool.test.js +187 -0
- package/tests/core/disable-thinking.test.js +64 -0
- package/tests/core/edit-file.test.js +194 -0
- package/tests/core/ext-call-empty-args.test.js +136 -0
- package/tests/core/reasoning-content.test.js +129 -0
- package/tests/core/sanitize-for-llm.test.js +152 -0
- package/tests/core/search.test.js +212 -0
- package/tests/core/skill-input-schema.test.js +150 -0
- package/tests/core/strip-stale-tool-calls.test.js +154 -0
- package/tests/core/sub-agent-parse.test.js +247 -0
- package/tests/core/sub-agent-skills.test.js +197 -0
- package/tests/core/tool-loop.test.js +208 -0
- package/weixin_o9cq80zgZqKPA2-s59PN43GdDy1w@im.wechat.jsonl +0 -76
|
@@ -19,14 +19,18 @@ class FileSystemPlugin extends Plugin {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
install(framework) {
|
|
22
|
+
// ★ 必须先调 super.install(framework),否则 this._framework 未设置,
|
|
23
|
+
// this.tool.register() 会静默失败(Plugin base class 的 tool 访问器检查 this._framework)
|
|
24
|
+
super.install(framework);
|
|
22
25
|
const { z } = require('zod')
|
|
23
26
|
const fs = require('fs')
|
|
24
27
|
const path = require('path')
|
|
25
28
|
const { exec } = require('child_process')
|
|
26
29
|
|
|
27
30
|
const validatePath = (filePath, allowOutsideCwd = false) => {
|
|
28
|
-
const resolved = path.resolve(filePath)
|
|
29
31
|
const cwd = framework?.getCwd?.() ?? process.cwd()
|
|
32
|
+
// ★ 相对路径基于 framework 的 cwd 解析(不是 process.cwd())
|
|
33
|
+
const resolved = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(cwd, filePath)
|
|
30
34
|
|
|
31
35
|
if (allowOutsideCwd) {
|
|
32
36
|
return { valid: true, resolved }
|
|
@@ -39,6 +43,52 @@ class FileSystemPlugin extends Plugin {
|
|
|
39
43
|
return { valid: true, resolved }
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
this.tool.register({
|
|
47
|
+
name: 'chat',
|
|
48
|
+
description: '与 AI 进行对话(通过临时子 Agent)。返回 LLM 的 message 文本。',
|
|
49
|
+
inputSchema: z.object({
|
|
50
|
+
message: z.string().describe('要发送的消息'),
|
|
51
|
+
systemPrompt: z.string().optional().describe('系统提示语'),
|
|
52
|
+
sessionId: z.string().optional().describe('隔离的会话 ID(默认自动生成,避免污染主对话)'),
|
|
53
|
+
}),
|
|
54
|
+
execute: async (args, framework) => {
|
|
55
|
+
try {
|
|
56
|
+
const { message, systemPrompt, sessionId } = args;
|
|
57
|
+
// ★ 跟随主 agent 的 LLM 配置
|
|
58
|
+
const aiPlugin = framework.pluginManager?.get('ai');
|
|
59
|
+
const llmConfig = aiPlugin?.getConfig?.() || {};
|
|
60
|
+
|
|
61
|
+
// ★ 唯一名字:避免与同名 sub-agent 冲突
|
|
62
|
+
const uniqueName = `${this.name}-chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
63
|
+
|
|
64
|
+
const agent = framework.createSubAgent({
|
|
65
|
+
name: uniqueName,
|
|
66
|
+
systemPrompt: systemPrompt || `你是一个文件系统操作助手,提供文件和目录的读取、写入、编辑、搜索等功能。请根据用户的请求执行相应的操作,并返回结果。`,
|
|
67
|
+
model: llmConfig.model,
|
|
68
|
+
provider: llmConfig.provider,
|
|
69
|
+
apiKey: llmConfig.apiKey,
|
|
70
|
+
baseURL: llmConfig.baseURL,
|
|
71
|
+
hidden: true,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ★ 隔离 sessionId:chat 走独立 session,不污染主对话历史
|
|
75
|
+
const response = await agent.chat(message, {
|
|
76
|
+
sessionId: sessionId || `chat-${Date.now()}`,
|
|
77
|
+
maxTokens: 2000,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ★ 只返回 message 文本(不是整个对象含 messages 数组)
|
|
81
|
+
return {
|
|
82
|
+
success: true,
|
|
83
|
+
data: response?.message || '',
|
|
84
|
+
metadata: { steps: response?.steps || 0, agent: uniqueName },
|
|
85
|
+
};
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return { success: false, error: err.message };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
42
92
|
framework.registerTool({
|
|
43
93
|
name: 'read_directory',
|
|
44
94
|
description: '读取目录内容,列出目录中的文件和子目录',
|
|
@@ -293,22 +343,146 @@ class FileSystemPlugin extends Plugin {
|
|
|
293
343
|
}
|
|
294
344
|
})
|
|
295
345
|
|
|
346
|
+
// ★ 用 @frsource/frs-replace 重写:基于 String.prototype.replace 的纯文本替换
|
|
347
|
+
// 支持字面量匹配和正则匹配(useRegex=true),保持原 API 兼容
|
|
348
|
+
// 注意:丢失了原 editDiff 的"智能模糊匹配"能力(Unicode 引号、破折号容错)
|
|
349
|
+
// 如果 oldText 找不到,直接报错(不像 String.replace 静默不生效)
|
|
350
|
+
let frsReplacePromise = null;
|
|
351
|
+
const getFrsReplace = () => {
|
|
352
|
+
if (!frsReplacePromise) {
|
|
353
|
+
// frs-replace 是 ESM 包(package.json type: module),用 dynamic import
|
|
354
|
+
frsReplacePromise = import('@frsource/frs-replace');
|
|
355
|
+
}
|
|
356
|
+
return frsReplacePromise;
|
|
357
|
+
};
|
|
358
|
+
|
|
296
359
|
const editToolDef = {
|
|
297
360
|
name: 'edit',
|
|
298
|
-
description:
|
|
361
|
+
description: '编辑文件:用 frs-replace 替换文本。支持字面量匹配和正则匹配(useRegex=true),支持多个不重叠的连续替换。',
|
|
299
362
|
inputSchema: z.object({
|
|
300
363
|
path: z.string().describe('要编辑的文件路径'),
|
|
301
364
|
edits: z.array(z.object({
|
|
302
|
-
oldText: z.string().min(1).describe('
|
|
303
|
-
newText: z.string().describe('替换后的新文本')
|
|
304
|
-
|
|
365
|
+
oldText: z.string().min(1).describe('要替换的原文(字面量或正则模式)'),
|
|
366
|
+
newText: z.string().describe('替换后的新文本'),
|
|
367
|
+
useRegex: z.boolean().optional().describe('是否将 oldText 视为正则(默认 false)'),
|
|
368
|
+
allOccurrences: z.boolean().optional().describe('是否替换所有出现位置(默认 true),false 时只替换第一个'),
|
|
369
|
+
})).min(1).describe('替换操作数组,按顺序应用'),
|
|
305
370
|
}),
|
|
306
371
|
execute: async (args, framework) => {
|
|
307
372
|
const { path: filePath, edits } = args;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
373
|
+
if (!filePath) return { success: false, error: 'path 是必填参数' };
|
|
374
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
375
|
+
return { success: false, error: 'edits 必须是非空数组' };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 解析路径(相对路径基于 framework.getCwd())
|
|
379
|
+
const pathCheck = validatePath(filePath);
|
|
380
|
+
if (!pathCheck.valid) {
|
|
381
|
+
return { success: false, error: pathCheck.error };
|
|
382
|
+
}
|
|
383
|
+
const resolvedPath = pathCheck.resolved;
|
|
384
|
+
|
|
385
|
+
// 1. 读文件
|
|
386
|
+
let content;
|
|
387
|
+
try {
|
|
388
|
+
content = await fs.promises.readFile(resolvedPath, 'utf8');
|
|
389
|
+
} catch (e) {
|
|
390
|
+
return { success: false, error: `读取文件失败: ${e.message}` };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// 2. 动态加载 frs-replace
|
|
394
|
+
let sync;
|
|
395
|
+
try {
|
|
396
|
+
const mod = await getFrsReplace();
|
|
397
|
+
sync = mod.sync;
|
|
398
|
+
} catch (e) {
|
|
399
|
+
return { success: false, error: `frs-replace 加载失败: ${e.message}` };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 3. 逐个应用 edit(按顺序)
|
|
403
|
+
const originalContent = content;
|
|
404
|
+
const appliedEdits = [];
|
|
405
|
+
for (let i = 0; i < edits.length; i++) {
|
|
406
|
+
const edit = edits[i];
|
|
407
|
+
const { oldText, newText, useRegex = false, allOccurrences = true } = edit;
|
|
408
|
+
|
|
409
|
+
if (typeof oldText !== 'string' || oldText.length === 0) {
|
|
410
|
+
return { success: false, error: `edits[${i}].oldText 不能为空字符串` };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 构造 needle:统一用 RegExp(用 allOccurrences 控 g 标志)
|
|
414
|
+
// - 字面量:转义正则元字符 + 加 g 标志
|
|
415
|
+
// - 正则:用户写的 + 加 g 标志(如果 allOccurrences)
|
|
416
|
+
// 这样 allOccurrences 在两种模式下都生效
|
|
417
|
+
let needle;
|
|
418
|
+
try {
|
|
419
|
+
if (useRegex) {
|
|
420
|
+
needle = allOccurrences
|
|
421
|
+
? new RegExp(oldText, 'g')
|
|
422
|
+
: new RegExp(oldText); // 不带 g 标志 → 只替换第一个
|
|
423
|
+
} else {
|
|
424
|
+
// 字面量:转义所有正则元字符
|
|
425
|
+
const escaped = oldText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
426
|
+
needle = allOccurrences
|
|
427
|
+
? new RegExp(escaped, 'g')
|
|
428
|
+
: new RegExp(escaped);
|
|
429
|
+
}
|
|
430
|
+
} catch (e) {
|
|
431
|
+
return { success: false, error: `edits[${i}] 无效的正则: ${e.message}` };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 用 frs-replace 在内存中替换
|
|
435
|
+
let result;
|
|
436
|
+
try {
|
|
437
|
+
result = sync({
|
|
438
|
+
content,
|
|
439
|
+
needle,
|
|
440
|
+
replacement: newText,
|
|
441
|
+
});
|
|
442
|
+
} catch (e) {
|
|
443
|
+
return { success: false, error: `edits[${i}] 替换失败: ${e.message}` };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// frs-replace content 模式返回 [["", newContent]]([path, content])
|
|
447
|
+
// 当 needle 是字面量且找不到时,String.replace 不报错,返回原 content
|
|
448
|
+
const newContent = result?.[0]?.[1];
|
|
449
|
+
if (typeof newContent !== 'string') {
|
|
450
|
+
return { success: false, error: `edits[${i}] 替换返回无效结果` };
|
|
451
|
+
}
|
|
452
|
+
if (newContent === content) {
|
|
453
|
+
// ★ 关键改进:原 editDiff 会模糊匹配;frs-replace 静默不生效
|
|
454
|
+
// 现在主动检测并报错,避免 LLM 误以为成功
|
|
455
|
+
return {
|
|
456
|
+
success: false,
|
|
457
|
+
error: `edits[${i}] 替换未生效:在文件中找不到 "${oldText.slice(0, 80)}${oldText.length > 80 ? '...' : ''}"${useRegex ? '(作为正则)' : '(字面量)'}`,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
content = newContent;
|
|
461
|
+
appliedEdits.push({ index: i, applied: true });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 4. 写回(原子操作:先写 .tmp 再 rename)
|
|
465
|
+
const tmpPath = resolvedPath + '.tmp';
|
|
466
|
+
try {
|
|
467
|
+
await fs.promises.writeFile(tmpPath, content, 'utf8');
|
|
468
|
+
await fs.promises.rename(tmpPath, resolvedPath);
|
|
469
|
+
} catch (e) {
|
|
470
|
+
// 清理 .tmp
|
|
471
|
+
try { await fs.promises.unlink(tmpPath); } catch { /* ignore */ }
|
|
472
|
+
return { success: false, error: `写入文件失败: ${e.message}` };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
success: true,
|
|
477
|
+
data: {
|
|
478
|
+
path: resolvedPath,
|
|
479
|
+
appliedEdits: appliedEdits.length,
|
|
480
|
+
totalEdits: edits.length,
|
|
481
|
+
contentChanged: content !== originalContent,
|
|
482
|
+
contentLength: { before: originalContent.length, after: content.length },
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
},
|
|
312
486
|
};
|
|
313
487
|
|
|
314
488
|
framework.registerTool(editToolDef);
|
|
@@ -320,186 +494,236 @@ class FileSystemPlugin extends Plugin {
|
|
|
320
494
|
});
|
|
321
495
|
|
|
322
496
|
framework.registerTool({
|
|
323
|
-
name: '
|
|
324
|
-
description: '
|
|
497
|
+
name: 'search',
|
|
498
|
+
description: '在文件或目录中搜索文本。支持 glob 模式、.gitignore 感知、异步 IO、大小写敏感、正则、大文件保护。',
|
|
325
499
|
inputSchema: z.object({
|
|
326
500
|
pattern: z.string().describe('搜索模式(关键词或正则表达式)'),
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
501
|
+
// ★ 新增:glob 模式
|
|
502
|
+
glob: z.string().optional().describe('文件 glob 模式,如 "**/*.js"、"src/**/test*";默认 "**" 匹配所有文件'),
|
|
503
|
+
path: z.string().optional().describe('搜索目录路径(默认 cwd)'),
|
|
504
|
+
file: z.string().optional().describe('搜索指定文件(与 path/glob 二选一)'),
|
|
505
|
+
fileType: z.string().optional().describe('文件扩展名过滤,如 ".js"、".py"(已废弃,建议用 glob)'),
|
|
330
506
|
maxResults: z.number().optional().describe('最大结果数,默认 100'),
|
|
331
507
|
maxResultsPerFile: z.number().optional().describe('每个文件最大结果数,默认 50'),
|
|
332
508
|
contextLines: z.number().optional().describe('匹配行的上下文行数,默认 0'),
|
|
333
509
|
caseSensitive: z.boolean().optional().describe('是否大小写敏感,默认 false'),
|
|
334
510
|
useRegex: z.boolean().optional().describe('是否使用正则表达式,默认 false'),
|
|
335
|
-
excludeDirs: z.array(z.string()).optional().describe('
|
|
511
|
+
excludeDirs: z.array(z.string()).optional().describe('额外排除的 glob 模式(叠加在 .gitignore 之上)'),
|
|
512
|
+
maxFileSizeKB: z.number().optional().describe('单文件最大 KB(跳过超大文件),默认 1024'),
|
|
513
|
+
gitignore: z.boolean().optional().describe('是否尊重 .gitignore(默认 true)'),
|
|
514
|
+
followSymlinks: z.boolean().optional().describe('是否跟随符号链接,默认 false'),
|
|
336
515
|
}),
|
|
337
516
|
execute: async (args, framework) => {
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
const targetFile = args.file
|
|
341
|
-
const fileType = args.fileType
|
|
342
|
-
const maxResults = args.maxResults || 100
|
|
343
|
-
const maxResultsPerFile = args.maxResultsPerFile || 50
|
|
344
|
-
const contextLines = args.contextLines || 0
|
|
345
|
-
const caseSensitive = args.caseSensitive === true
|
|
346
|
-
const useRegex = args.useRegex === true
|
|
347
|
-
const excludeDirs = args.excludeDirs || ['node_modules', '.git', 'dist', 'build', '.claude']
|
|
348
|
-
|
|
517
|
+
const cwd = framework?.getCwd?.() ?? process.cwd();
|
|
518
|
+
const pattern = args.pattern;
|
|
349
519
|
if (!pattern || typeof pattern !== 'string' || pattern.trim() === '') {
|
|
350
|
-
return { success: false, error: 'pattern 是必填参数,不能为空' }
|
|
520
|
+
return { success: false, error: 'pattern 是必填参数,不能为空' };
|
|
351
521
|
}
|
|
352
522
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
523
|
+
const maxResults = args.maxResults || 100;
|
|
524
|
+
const maxResultsPerFile = args.maxResultsPerFile || 50;
|
|
525
|
+
const maxFileSize = (args.maxFileSizeKB || 1024) * 1024;
|
|
526
|
+
const contextLines = args.contextLines || 0;
|
|
527
|
+
const caseSensitive = args.caseSensitive === true;
|
|
528
|
+
const useRegex = args.useRegex === true;
|
|
529
|
+
const fileType = args.fileType;
|
|
356
530
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const binaryExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.ico', '.exe', '.dll', '.so', '.zip', '.tar', '.gz', '.rar', '.7z', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.mp3', '.mp4', '.avi', '.mkv', '.wav', '.flac']
|
|
369
|
-
|
|
370
|
-
const searchInContent = (content, filePath) => {
|
|
371
|
-
const matches = []
|
|
372
|
-
const lines = content.split('\n')
|
|
373
|
-
let fileResultsCount = 0
|
|
374
|
-
|
|
375
|
-
for (let i = 0; i < lines.length; i++) {
|
|
376
|
-
if (fileResultsCount >= maxResultsPerFile) break
|
|
377
|
-
const line = lines[i]
|
|
378
|
-
regex.lastIndex = 0
|
|
379
|
-
|
|
380
|
-
if (regex.test(line)) {
|
|
381
|
-
const lineMatches = []
|
|
382
|
-
regex.lastIndex = 0
|
|
383
|
-
let match
|
|
384
|
-
while ((match = regex.exec(line)) !== null) {
|
|
385
|
-
lineMatches.push({
|
|
386
|
-
column: match.index,
|
|
387
|
-
length: match[0].length,
|
|
388
|
-
text: match[0]
|
|
389
|
-
})
|
|
390
|
-
if (!regex.global) break
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const matchInfo = {
|
|
394
|
-
file: filePath,
|
|
395
|
-
line: i + 1,
|
|
396
|
-
content: line.substring(0, 300),
|
|
397
|
-
matches: lineMatches
|
|
398
|
-
}
|
|
531
|
+
// 1. 构造正则
|
|
532
|
+
let regex;
|
|
533
|
+
try {
|
|
534
|
+
const flags = caseSensitive ? 'g' : 'gi';
|
|
535
|
+
const source = useRegex
|
|
536
|
+
? pattern
|
|
537
|
+
: pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
538
|
+
regex = new RegExp(source, flags);
|
|
539
|
+
} catch (e) {
|
|
540
|
+
return { success: false, error: `无效的正则表达式: ${e.message}` };
|
|
541
|
+
}
|
|
399
542
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
543
|
+
// 2. 二进制扩展名过滤
|
|
544
|
+
const binaryExtensions = new Set([
|
|
545
|
+
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.ico',
|
|
546
|
+
'.exe', '.dll', '.so', '.zip', '.tar', '.gz', '.rar', '.7z',
|
|
547
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
548
|
+
'.mp3', '.mp4', '.avi', '.mkv', '.wav', '.flac', '.ico', '.so'
|
|
549
|
+
]);
|
|
550
|
+
|
|
551
|
+
// 3. 内容搜索(保留原逻辑)
|
|
552
|
+
const searchInContent = (content, filePath) => {
|
|
553
|
+
const matches = [];
|
|
554
|
+
const lines = content.split('\n');
|
|
555
|
+
let fileResultsCount = 0;
|
|
556
|
+
|
|
557
|
+
for (let i = 0; i < lines.length; i++) {
|
|
558
|
+
if (fileResultsCount >= maxResultsPerFile) break;
|
|
559
|
+
const line = lines[i];
|
|
560
|
+
regex.lastIndex = 0;
|
|
561
|
+
|
|
562
|
+
if (regex.test(line)) {
|
|
563
|
+
const lineMatches = [];
|
|
564
|
+
regex.lastIndex = 0;
|
|
565
|
+
let match;
|
|
566
|
+
while ((match = regex.exec(line)) !== null) {
|
|
567
|
+
lineMatches.push({ column: match.index, length: match[0].length, text: match[0] });
|
|
568
|
+
if (!regex.global) break;
|
|
569
|
+
}
|
|
408
570
|
|
|
409
|
-
|
|
410
|
-
|
|
571
|
+
const matchInfo = {
|
|
572
|
+
file: filePath,
|
|
573
|
+
line: i + 1,
|
|
574
|
+
content: line.substring(0, 300),
|
|
575
|
+
matches: lineMatches,
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
if (contextLines > 0) {
|
|
579
|
+
const start = Math.max(0, i - contextLines);
|
|
580
|
+
const end = Math.min(lines.length, i + contextLines + 1);
|
|
581
|
+
matchInfo.context = lines.slice(start, end).map((l, idx) => ({
|
|
582
|
+
line: start + idx + 1,
|
|
583
|
+
content: l,
|
|
584
|
+
}));
|
|
411
585
|
}
|
|
412
|
-
}
|
|
413
586
|
|
|
414
|
-
|
|
587
|
+
matches.push(matchInfo);
|
|
588
|
+
fileResultsCount++;
|
|
589
|
+
}
|
|
415
590
|
}
|
|
591
|
+
return matches;
|
|
592
|
+
};
|
|
416
593
|
|
|
417
|
-
|
|
418
|
-
|
|
594
|
+
// 4. 收集文件列表
|
|
595
|
+
let fileList;
|
|
596
|
+
try {
|
|
597
|
+
if (args.file) {
|
|
598
|
+
// 单文件模式
|
|
599
|
+
const fullPath = path.isAbsolute(args.file) ? args.file : path.resolve(cwd, args.file);
|
|
419
600
|
if (!fs.existsSync(fullPath)) {
|
|
420
|
-
return { success: false, error: `文件不存在: ${
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const ext = path.extname(fullPath).toLowerCase()
|
|
424
|
-
if (binaryExtensions.includes(ext)) {
|
|
425
|
-
return { success: false, error: `不支持搜索二进制文件: ${ext}` }
|
|
601
|
+
return { success: false, error: `文件不存在: ${args.file}` };
|
|
426
602
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const content = fs.readFileSync(fullPath, 'utf8')
|
|
430
|
-
const fileMatches = searchInContent(content, targetFile)
|
|
431
|
-
results.push(...fileMatches)
|
|
432
|
-
} catch (e) {
|
|
433
|
-
return { success: false, error: `读取文件失败: ${e.message}` }
|
|
603
|
+
if (!fs.statSync(fullPath).isFile()) {
|
|
604
|
+
return { success: false, error: `不是文件: ${args.file}` };
|
|
434
605
|
}
|
|
606
|
+
fileList = [path.relative(cwd, fullPath)];
|
|
435
607
|
} else {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
608
|
+
// ★ tinyglobby 替代手写递归
|
|
609
|
+
const { glob } = require('tinyglobby');
|
|
610
|
+
const searchDir = args.path ? path.resolve(cwd, args.path) : cwd;
|
|
611
|
+
|
|
612
|
+
// 构造 ignore 列表
|
|
613
|
+
const ignorePatterns = [
|
|
614
|
+
...(args.excludeDirs || []),
|
|
615
|
+
// 默认忽略这些
|
|
616
|
+
'**/.git/**',
|
|
617
|
+
'**/node_modules/**',
|
|
618
|
+
'**/dist/**',
|
|
619
|
+
'**/build/**',
|
|
620
|
+
'**/.foliko/**',
|
|
621
|
+
'**/.claude/**',
|
|
622
|
+
'**/coverage/**',
|
|
623
|
+
];
|
|
624
|
+
|
|
625
|
+
// ★ 读 .gitignore(如果启用)
|
|
626
|
+
if (args.gitignore !== false) {
|
|
627
|
+
const giPath = path.join(searchDir, '.gitignore');
|
|
628
|
+
if (fs.existsSync(giPath)) {
|
|
629
|
+
try {
|
|
630
|
+
const giContent = fs.readFileSync(giPath, 'utf8');
|
|
631
|
+
for (const line of giContent.split('\n')) {
|
|
632
|
+
const trimmed = line.trim();
|
|
633
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
634
|
+
// gitignore pattern → glob pattern 转换
|
|
635
|
+
let pattern = trimmed;
|
|
636
|
+
// 末尾的 / 表示目录
|
|
637
|
+
if (pattern.endsWith('/')) pattern = `**/${pattern}**`;
|
|
638
|
+
// ! 表示取反,跳过
|
|
639
|
+
if (pattern.startsWith('!')) continue;
|
|
640
|
+
// ** 等直接用,glob 兼容
|
|
641
|
+
if (!pattern.includes('*') && !pattern.includes('?')) {
|
|
642
|
+
// 简单文件名/目录名 → 加 ** 通配
|
|
643
|
+
pattern = pattern.includes('/')
|
|
644
|
+
? `**/${pattern}`
|
|
645
|
+
: `**/${pattern}/**`;
|
|
472
646
|
}
|
|
473
|
-
|
|
647
|
+
ignorePatterns.push(pattern);
|
|
474
648
|
}
|
|
649
|
+
} catch (e) {
|
|
650
|
+
// 忽略 .gitignore 读取错误
|
|
475
651
|
}
|
|
476
652
|
}
|
|
477
653
|
}
|
|
478
654
|
|
|
479
|
-
|
|
480
|
-
|
|
655
|
+
// fileType 兼容旧 API
|
|
656
|
+
let globPattern = args.glob || '**';
|
|
657
|
+
if (!args.glob && fileType) {
|
|
658
|
+
globPattern = `**/*.${fileType.replace(/^\./, '')}`;
|
|
659
|
+
}
|
|
481
660
|
|
|
482
|
-
|
|
483
|
-
|
|
661
|
+
fileList = await glob(globPattern, {
|
|
662
|
+
cwd: searchDir,
|
|
663
|
+
absolute: false,
|
|
664
|
+
onlyFiles: true,
|
|
665
|
+
dot: false,
|
|
666
|
+
followSymbolicLinks: args.followSymlinks === true,
|
|
667
|
+
ignore: ignorePatterns,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
} catch (e) {
|
|
671
|
+
return { success: false, error: `文件枚举失败: ${e.message}` };
|
|
672
|
+
}
|
|
484
673
|
|
|
674
|
+
if (!fileList || fileList.length === 0) {
|
|
485
675
|
return {
|
|
486
676
|
success: true,
|
|
487
|
-
data:
|
|
488
|
-
metadata: {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
677
|
+
data: [],
|
|
678
|
+
metadata: { pattern, filesScanned: 0, filesWithMatches: 0, totalMatches: 0, searchPath: args.file || (args.path || cwd) },
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// 5. 异步读取并搜索(带大小保护)
|
|
683
|
+
const results = [];
|
|
684
|
+
let filesWithMatches = 0;
|
|
685
|
+
let filesScanned = 0;
|
|
686
|
+
|
|
687
|
+
for (const file of fileList) {
|
|
688
|
+
if (results.length >= maxResults) break;
|
|
689
|
+
const fullPath = path.isAbsolute(file) ? file : path.join(cwd, file);
|
|
690
|
+
const ext = path.extname(fullPath).toLowerCase();
|
|
691
|
+
if (binaryExtensions.has(ext)) continue;
|
|
692
|
+
|
|
693
|
+
let stat;
|
|
694
|
+
try { stat = await fs.promises.stat(fullPath); } catch { continue; }
|
|
695
|
+
if (stat.size > maxFileSize) continue;
|
|
696
|
+
filesScanned++;
|
|
697
|
+
|
|
698
|
+
let content;
|
|
699
|
+
try { content = await fs.promises.readFile(fullPath, 'utf8'); } catch { continue; }
|
|
700
|
+
|
|
701
|
+
const fileMatches = searchInContent(content, file);
|
|
702
|
+
if (fileMatches.length > 0) {
|
|
703
|
+
filesWithMatches++;
|
|
704
|
+
for (const m of fileMatches) {
|
|
705
|
+
if (results.length >= maxResults) break;
|
|
706
|
+
results.push(m);
|
|
496
707
|
}
|
|
497
708
|
}
|
|
498
|
-
} catch (error) {
|
|
499
|
-
return { success: false, error: error.message }
|
|
500
709
|
}
|
|
501
|
-
|
|
502
|
-
|
|
710
|
+
|
|
711
|
+
const totalMatches = results.reduce((sum, r) => sum + (r.matches?.length || 1), 0);
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
success: true,
|
|
715
|
+
data: results,
|
|
716
|
+
metadata: {
|
|
717
|
+
pattern,
|
|
718
|
+
filesScanned,
|
|
719
|
+
filesWithMatches,
|
|
720
|
+
totalMatches,
|
|
721
|
+
searchPath: args.file || (args.path || cwd),
|
|
722
|
+
glob: args.glob,
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
},
|
|
726
|
+
});
|
|
503
727
|
|
|
504
728
|
framework.registerTool({
|
|
505
729
|
name: 'execute_command',
|