minimal-agent 0.1.9 → 0.3.0
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/README.md +383 -122
- package/package.json +19 -12
- package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
- package/plugins/ralph-wiggum/commands/ralph-loop.md +6 -16
- package/plugins/ralph-wiggum/plugin.js +205 -0
- package/plugins/ralph-wiggum/src/goalState.js +260 -0
- package/plugins/ralph-wiggum/src/sentinels.js +21 -0
- package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
- package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
- package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
- package/plugins/workflow-runner/commands/workflow.md +15 -0
- package/plugins/workflow-runner/commands/workflows.md +8 -0
- package/plugins/workflow-runner/plugin.js +36 -0
- package/plugins/workflow-runner/src/expressions.js +369 -0
- package/plugins/workflow-runner/src/index.js +174 -0
- package/plugins/workflow-runner/src/loader.js +183 -0
- package/plugins/workflow-runner/src/runner.js +290 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
- package/plugins/workflow-runner/src/stepExecutors/tool.js +35 -0
- package/plugins/workflow-runner/src/types.js +59 -0
- package/plugins/workflow-runner/src/workflowState.js +46 -0
- package/skills/image-gen-openrouter/SKILL.md +121 -0
- package/skills/subtitle-srt/SKILL.md +134 -0
- package/skills/tts-zh/SKILL.md +137 -0
- package/skills/video-compose/SKILL.md +139 -0
- package/src/bootstrap/cwdArg.js +22 -0
- package/src/bootstrap/workingDir.js +31 -0
- package/src/cli/configWizard.js +272 -0
- package/src/cli/print.js +192 -0
- package/src/config/configFile.js +78 -0
- package/src/config.js +118 -0
- package/src/context/compact.js +357 -0
- package/src/context/microCompactLite.js +151 -0
- package/src/context/persistContext.js +109 -0
- package/src/context/reactiveCompact.js +121 -0
- package/src/context/sessionPath.js +58 -0
- package/src/context/snipCompact.js +112 -0
- package/src/context/tokenCounter.js +66 -0
- package/src/llm/client.js +182 -0
- package/src/loop.js +230 -0
- package/src/main.js +116 -0
- package/src/plugin-sdk.js +24 -0
- package/src/plugins/commandRouter.js +169 -0
- package/src/plugins/hookEngine.js +258 -0
- package/src/plugins/pluginApi.js +23 -0
- package/src/plugins/pluginLoader.js +71 -0
- package/src/plugins/pluginRunner.js +65 -0
- package/src/plugins/transcript.js +171 -0
- package/src/prompts/projectInstructions.js +48 -0
- package/src/prompts/skillList.js +126 -0
- package/src/prompts/system.js +155 -0
- package/src/session/runTurn.js +41 -0
- package/src/session/sessionState.js +19 -0
- package/src/tools/bash/bash.js +352 -0
- package/src/tools/bash/semantics.js +85 -0
- package/src/tools/bash/warnings.js +98 -0
- package/src/tools/edit/edit.js +253 -0
- package/src/tools/edit/multi-edit.js +155 -0
- package/src/tools/glob/glob.js +97 -0
- package/src/tools/grep/grep.js +185 -0
- package/src/tools/grep/rgPath.js +173 -0
- package/src/tools/index.js +94 -0
- package/src/tools/read/read.js +209 -0
- package/src/tools/shared/fileState.js +61 -0
- package/src/tools/shared/fileUtils.js +281 -0
- package/src/tools/shared/schemas.js +16 -0
- package/src/tools/types.js +21 -0
- package/src/tools/webbrowser/browser.js +55 -0
- package/src/tools/webbrowser/webbrowser.js +194 -0
- package/src/tools/webfetch/preapproved.js +267 -0
- package/src/tools/webfetch/webfetch.js +317 -0
- package/src/tools/websearch/websearch.js +161 -0
- package/src/tools/write/write.js +125 -0
- package/src/types/turndown.d.ts +23 -0
- package/src/types.js +16 -0
- package/src/ui/App.js +37 -0
- package/src/ui/InputBox.js +240 -0
- package/src/ui/MessageList.js +28 -0
- package/src/ui/Root.js +70 -0
- package/src/ui/StatusLine.js +41 -0
- package/src/ui/ToolStatus.js +11 -0
- package/src/ui/hooks/useChat.js +234 -0
- package/src/ui/hooks/usePasteHandler.js +137 -0
- package/src/ui/hooks/useTextBuffer.js +55 -0
- package/src/ui/hooks/useTokenUsage.js +30 -0
- package/src/ui/textBuffer.js +217 -0
- package/src/utils/packageRoot.js +37 -0
- package/src/utils/resourcePaths.js +49 -0
- package/src/utils/zodToJson.js +29 -0
- package/workflows/book-review-short.yaml +99 -0
- package/workflows/e2e-write-greet.yaml +27 -0
- package/workflows/schema.json +74 -0
- package/workflows/youtube-shorts.yaml +171 -0
- package/dist/main.js +0 -5936
- package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/grep.ts —— Grep 工具(基于 ripgrep 的内容搜索)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 对应 kakadeai 主仓库的 GrepTool。
|
|
6
|
+
* 实现策略:spawn 系统 `rg` 命令。
|
|
7
|
+
* 没装 ripgrep 时给"教学型"错误:
|
|
8
|
+
* - macOS: brew install ripgrep
|
|
9
|
+
* - Windows: scoop install ripgrep
|
|
10
|
+
* - Ubuntu: sudo apt install ripgrep
|
|
11
|
+
*
|
|
12
|
+
* 为何不用纯 Node 实现?
|
|
13
|
+
* 递归遍历 + 正则 + glob 排除 + 字符编码处理 = 几百行;
|
|
14
|
+
* 最小化原则下复用 rg 子进程更划算。
|
|
15
|
+
* ============================================================
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { resolve } from 'node:path';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { getWorkingDir } from '../../bootstrap/workingDir.js';
|
|
21
|
+
import { resolveRgPath } from './rgPath.js';
|
|
22
|
+
import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
|
|
23
|
+
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
24
|
+
// ---------------- 1. Zod 输入 schema ----------------
|
|
25
|
+
const inputSchema = z.object({
|
|
26
|
+
pattern: z.string().min(1).describe('正则表达式(ripgrep 兼容语法)'),
|
|
27
|
+
path: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('搜索的根目录或文件(默认当前工作目录)'),
|
|
31
|
+
glob: z
|
|
32
|
+
.string()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe('文件名 glob 过滤,如 "*.ts"'),
|
|
35
|
+
type: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe('rg 的文件类型快捷名,如 "py"、"rust"、"js"'),
|
|
39
|
+
output_mode: z
|
|
40
|
+
.enum(['content', 'files_with_matches', 'count'])
|
|
41
|
+
.optional()
|
|
42
|
+
.describe('输出模式:content=匹配行;files_with_matches=只列文件;count=每文件计数'),
|
|
43
|
+
'-i': z.boolean().optional().describe('忽略大小写'),
|
|
44
|
+
'-n': z.boolean().optional().describe('显示行号(仅 content 模式)'),
|
|
45
|
+
'-A': z.number().int().min(0).optional().describe('匹配后展示几行上下文'),
|
|
46
|
+
'-B': z.number().int().min(0).optional().describe('匹配前展示几行上下文'),
|
|
47
|
+
'-C': z.number().int().min(0).optional().describe('匹配前后各展示几行(覆盖 -A/-B)'),
|
|
48
|
+
head_limit: z
|
|
49
|
+
.number()
|
|
50
|
+
.int()
|
|
51
|
+
.positive()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe('输出最多保留前 N 行(防止结果过大)'),
|
|
54
|
+
});
|
|
55
|
+
// ---------------- 2. JSON Schema(由 Zod 自动派生) ----------------
|
|
56
|
+
const parameters = toToolParameters(inputSchema);
|
|
57
|
+
// ---------------- 3. Description(搬自 kakadeai/tools/GrepTool/prompt.ts) ----------------
|
|
58
|
+
const description = `A powerful search tool built on ripgrep.
|
|
59
|
+
|
|
60
|
+
Usage:
|
|
61
|
+
- ALWAYS use Grep for content search tasks. Do NOT invoke \`grep\` or \`rg\` directly via Bash.
|
|
62
|
+
- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
|
|
63
|
+
- Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
|
|
64
|
+
- Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
|
|
65
|
+
- Pattern syntax: Uses ripgrep (not classic grep)`;
|
|
66
|
+
// ---------------- 4. call() ----------------
|
|
67
|
+
async function call(input, signal) {
|
|
68
|
+
const args = [];
|
|
69
|
+
// 输出模式(默认 files_with_matches)
|
|
70
|
+
const mode = input.output_mode ?? 'files_with_matches';
|
|
71
|
+
if (mode === 'files_with_matches')
|
|
72
|
+
args.push('-l');
|
|
73
|
+
else if (mode === 'count')
|
|
74
|
+
args.push('--count-matches');
|
|
75
|
+
// mode === 'content' 是 rg 默认行为,不加 flag
|
|
76
|
+
if (input['-i'])
|
|
77
|
+
args.push('-i');
|
|
78
|
+
if (mode === 'content' && input['-n'])
|
|
79
|
+
args.push('-n');
|
|
80
|
+
// 上下文行数:-C 优先,否则各加 -A/-B
|
|
81
|
+
if (typeof input['-C'] === 'number') {
|
|
82
|
+
args.push('-C', String(input['-C']));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
if (typeof input['-A'] === 'number')
|
|
86
|
+
args.push('-A', String(input['-A']));
|
|
87
|
+
if (typeof input['-B'] === 'number')
|
|
88
|
+
args.push('-B', String(input['-B']));
|
|
89
|
+
}
|
|
90
|
+
if (input.glob)
|
|
91
|
+
args.push('--glob', input.glob);
|
|
92
|
+
if (input.type)
|
|
93
|
+
args.push('--type', input.type);
|
|
94
|
+
// 安全:限制单文件输出大小(防止超大行)
|
|
95
|
+
args.push('--max-columns', '500');
|
|
96
|
+
args.push('--max-columns-preview');
|
|
97
|
+
// 按修改时间降序排列(新文件/新匹配在前,与 Glob 的旧在前形成互补锁定模板)
|
|
98
|
+
args.push('--sort', 'modified');
|
|
99
|
+
// pattern 必须用 -e 显式标记,否则以 - 开头的 pattern 会被当成 flag
|
|
100
|
+
args.push('-e', input.pattern);
|
|
101
|
+
// 搜索路径
|
|
102
|
+
args.push(input.path ? resolve(input.path) : '.');
|
|
103
|
+
// ---- 找 rg ----
|
|
104
|
+
// 优先级:MINIMAL_AGENT_RIPGREP_PATH → 系统 PATH → Claude Code 自带的 rg
|
|
105
|
+
const rgPath = await resolveRgPath();
|
|
106
|
+
if (!rgPath) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: '找不到 ripgrep(rg)。可以三选一:\n' +
|
|
110
|
+
' ① 装 Claude Code(自带 rg):npm i -g @anthropic-ai/claude-code\n' +
|
|
111
|
+
' ② 系统装 ripgrep:\n' +
|
|
112
|
+
' macOS: brew install ripgrep\n' +
|
|
113
|
+
' Windows: scoop install ripgrep\n' +
|
|
114
|
+
' Ubuntu: sudo apt install ripgrep\n' +
|
|
115
|
+
' ③ 在 .env 设置 MINIMAL_AGENT_RIPGREP_PATH=/绝对/路径/到/rg',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// ---- spawn rg ----
|
|
119
|
+
let stdout = '';
|
|
120
|
+
let stderr = '';
|
|
121
|
+
let exitCode = null;
|
|
122
|
+
try {
|
|
123
|
+
await new Promise((resolveP, rejectP) => {
|
|
124
|
+
const child = spawn(rgPath, args, {
|
|
125
|
+
cwd: getWorkingDir(),
|
|
126
|
+
signal,
|
|
127
|
+
windowsHide: true,
|
|
128
|
+
});
|
|
129
|
+
child.stdout.setEncoding('utf8');
|
|
130
|
+
child.stderr.setEncoding('utf8');
|
|
131
|
+
child.stdout.on('data', (chunk) => (stdout += chunk));
|
|
132
|
+
child.stderr.on('data', (chunk) => (stderr += chunk));
|
|
133
|
+
child.on('error', (e) => rejectP(e));
|
|
134
|
+
child.on('close', (code) => {
|
|
135
|
+
exitCode = code;
|
|
136
|
+
resolveP();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
const err = e;
|
|
142
|
+
return { ok: false, error: `执行 rg 失败(${rgPath}):${err.message}` };
|
|
143
|
+
}
|
|
144
|
+
// rg 的退出码语义:
|
|
145
|
+
// 0 = 找到匹配
|
|
146
|
+
// 1 = 没找到匹配(不算错)
|
|
147
|
+
// 2 = 真正的错误
|
|
148
|
+
if (exitCode === 1) {
|
|
149
|
+
return { ok: true, content: `(no matches for pattern: ${input.pattern})` };
|
|
150
|
+
}
|
|
151
|
+
if (exitCode !== 0) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
error: `rg 退出码 ${exitCode}:${stderr.slice(0, 500) || '(无 stderr)'}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// ---- head_limit + 大小兜底 ----
|
|
158
|
+
let content = stdout;
|
|
159
|
+
if (input.head_limit && input.head_limit > 0) {
|
|
160
|
+
const lines = content.split('\n');
|
|
161
|
+
if (lines.length > input.head_limit) {
|
|
162
|
+
content =
|
|
163
|
+
lines.slice(0, input.head_limit).join('\n') +
|
|
164
|
+
`\n... (已截断到 ${input.head_limit} 行 / 共 ${lines.length} 行,保留最新匹配)`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// 超长截断:保留头部(最新结果),丢弃尾部(旧结果)
|
|
168
|
+
if (content.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
|
|
169
|
+
content =
|
|
170
|
+
content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS) +
|
|
171
|
+
`\n\n... (输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,已截断尾部旧结果)`;
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, content };
|
|
174
|
+
}
|
|
175
|
+
// ---------------- 5. 导出 ----------------
|
|
176
|
+
export const grepTool = {
|
|
177
|
+
name: 'Grep',
|
|
178
|
+
description,
|
|
179
|
+
inputSchema,
|
|
180
|
+
parameters,
|
|
181
|
+
isReadOnly: true,
|
|
182
|
+
isConcurrencySafe: true,
|
|
183
|
+
maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
|
|
184
|
+
call,
|
|
185
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/rgPath.ts —— 解析 ripgrep 可执行文件路径
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 策略(按优先级):
|
|
6
|
+
* ① MINIMAL_AGENT_RIPGREP_PATH 环境变量(手动指定,调试时最高优先)
|
|
7
|
+
* ② 项目自带的 vendored rg —— vendor/ripgrep/<arch>-<platform>/rg(.exe)
|
|
8
|
+
* 这是默认路径!开箱即用,不依赖系统装没装。
|
|
9
|
+
* ③ 系统 PATH 上的 rg (用户自己装的 brew/scoop/apt 版本)
|
|
10
|
+
* ④ Claude Code 全局 npm 包自带的 rg(兜底)
|
|
11
|
+
*
|
|
12
|
+
* 借鉴:kakadeai/utils/ripgrep.ts 的 getRipgrepConfig,但砍掉了:
|
|
13
|
+
* - 嵌入式 Bun 模式(我们不用 bun build 打包)
|
|
14
|
+
* - 单线程 EAGAIN 重试(最小化版用不到)
|
|
15
|
+
*
|
|
16
|
+
* vendor/ripgrep 6 个平台二进制(arm64/x64 × win32/darwin/linux)从
|
|
17
|
+
* Claude Code 自带的同一份原料复制而来,许可证 MIT/Unlicense,可自由分发。
|
|
18
|
+
* macOS 上首次跑可能需要 codesign(见 codesignIfNeeded 注释)。
|
|
19
|
+
*
|
|
20
|
+
* 用法:
|
|
21
|
+
* const rg = await resolveRgPath();
|
|
22
|
+
* if (!rg) → 返回友好错误
|
|
23
|
+
* spawn(rg, args)
|
|
24
|
+
* ============================================================
|
|
25
|
+
*/
|
|
26
|
+
import { spawn } from 'node:child_process';
|
|
27
|
+
import { chmodSync, existsSync } from 'node:fs';
|
|
28
|
+
import { resolve } from 'node:path';
|
|
29
|
+
import { findPackageRoot } from '../../utils/packageRoot.js';
|
|
30
|
+
let cached;
|
|
31
|
+
/**
|
|
32
|
+
* 解析一次 rg 路径,结果缓存(同一进程内复用)。
|
|
33
|
+
* 返回 null 表示找不到。
|
|
34
|
+
*/
|
|
35
|
+
export async function resolveRgPath() {
|
|
36
|
+
if (cached !== undefined)
|
|
37
|
+
return cached;
|
|
38
|
+
cached = await detect();
|
|
39
|
+
return cached;
|
|
40
|
+
}
|
|
41
|
+
/** 仅供测试重置缓存 */
|
|
42
|
+
export function _resetRgCache() {
|
|
43
|
+
cached = undefined;
|
|
44
|
+
}
|
|
45
|
+
async function detect() {
|
|
46
|
+
// ① 显式 env 覆盖
|
|
47
|
+
const fromEnv = process.env.MINIMAL_AGENT_RIPGREP_PATH;
|
|
48
|
+
if (fromEnv && existsSync(fromEnv))
|
|
49
|
+
return fromEnv;
|
|
50
|
+
// ② 项目自带的 vendored rg(默认路径!开箱即用)
|
|
51
|
+
const vendored = vendoredRgPath();
|
|
52
|
+
if (vendored && existsSync(vendored)) {
|
|
53
|
+
ensureExecutable(vendored);
|
|
54
|
+
return vendored;
|
|
55
|
+
}
|
|
56
|
+
// ③ PATH 上的 rg —— 直接试 spawn rg --version
|
|
57
|
+
if (await trySpawn('rg'))
|
|
58
|
+
return 'rg';
|
|
59
|
+
// ④ Claude Code 全局 npm 包自带的 rg
|
|
60
|
+
for (const candidate of claudeCodeCandidates()) {
|
|
61
|
+
if (existsSync(candidate)) {
|
|
62
|
+
ensureExecutable(candidate);
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 项目内 vendor 路径:vendor/ripgrep/<arch>-<platform>/rg(.exe)
|
|
70
|
+
* 路径以本文件为锚点向上找,不依赖 cwd(用户在任何目录跑 minimal-agent 都行)。
|
|
71
|
+
*/
|
|
72
|
+
function vendoredRgPath() {
|
|
73
|
+
try {
|
|
74
|
+
const projectRoot = findPackageRoot(import.meta.url);
|
|
75
|
+
return resolve(projectRoot, 'vendor', 'ripgrep', subdir(), exeName());
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function subdir() {
|
|
82
|
+
return `${process.arch}-${process.platform}`;
|
|
83
|
+
}
|
|
84
|
+
function exeName() {
|
|
85
|
+
return process.platform === 'win32' ? 'rg.exe' : 'rg';
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Unix 上 git/tarball/cp 偶尔会丢可执行位。Windows 不需要。
|
|
89
|
+
* 失败也忽略(最坏情况下后面 spawn 会报权限错,给出明确信号)。
|
|
90
|
+
*/
|
|
91
|
+
function ensureExecutable(path) {
|
|
92
|
+
if (process.platform === 'win32')
|
|
93
|
+
return;
|
|
94
|
+
try {
|
|
95
|
+
chmodSync(path, 0o755);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* ignore */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 试着 spawn 一下,看 --version 能不能跑通。
|
|
103
|
+
* 注意:直接传 'rg'(不是绝对路径)让 OS 解析 PATH,
|
|
104
|
+
* 在 Windows 上配合 NoDefaultCurrentDirectoryInExePath 防 PATH 劫持。
|
|
105
|
+
*/
|
|
106
|
+
function trySpawn(cmd) {
|
|
107
|
+
return new Promise((resolveP) => {
|
|
108
|
+
let settled = false;
|
|
109
|
+
const done = (ok) => {
|
|
110
|
+
if (settled)
|
|
111
|
+
return;
|
|
112
|
+
settled = true;
|
|
113
|
+
resolveP(ok);
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
const child = spawn(cmd, ['--version'], {
|
|
117
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
118
|
+
windowsHide: true,
|
|
119
|
+
});
|
|
120
|
+
let stdout = '';
|
|
121
|
+
child.stdout.setEncoding('utf8');
|
|
122
|
+
child.stdout.on('data', (c) => (stdout += c));
|
|
123
|
+
child.on('error', () => done(false));
|
|
124
|
+
child.on('close', (code) => done(code === 0 && stdout.startsWith('ripgrep ')));
|
|
125
|
+
// 兜底超时:5 秒
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
try {
|
|
128
|
+
child.kill();
|
|
129
|
+
}
|
|
130
|
+
catch { }
|
|
131
|
+
done(false);
|
|
132
|
+
}, 5_000);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
done(false);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 枚举 Claude Code 自带 rg 在常见安装路径下的位置。
|
|
141
|
+
* 不存在的路径不会被实际访问 —— 调用方用 existsSync 过滤。
|
|
142
|
+
*/
|
|
143
|
+
function claudeCodeCandidates() {
|
|
144
|
+
const arch = process.arch; // 'x64' | 'arm64' | ...
|
|
145
|
+
const platform = process.platform; // 'win32' | 'darwin' | 'linux' | ...
|
|
146
|
+
const subdir = `${arch}-${platform}`;
|
|
147
|
+
const exe = platform === 'win32' ? 'rg.exe' : 'rg';
|
|
148
|
+
// 各种 npm global 安装路径
|
|
149
|
+
const npmRoots = [];
|
|
150
|
+
if (platform === 'win32') {
|
|
151
|
+
// npm 默认 prefix: %APPDATA%/npm
|
|
152
|
+
if (process.env.APPDATA) {
|
|
153
|
+
npmRoots.push(resolve(process.env.APPDATA, 'npm', 'node_modules'));
|
|
154
|
+
}
|
|
155
|
+
// 也试试 user profile/AppData/Roaming(Bash on Windows 偶尔 APPDATA 没设)
|
|
156
|
+
if (process.env.USERPROFILE) {
|
|
157
|
+
npmRoots.push(resolve(process.env.USERPROFILE, 'AppData', 'Roaming', 'npm', 'node_modules'));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Unix:常见的 npm global prefix
|
|
162
|
+
const home = process.env.HOME ?? '';
|
|
163
|
+
if (home) {
|
|
164
|
+
npmRoots.push(resolve(home, '.npm-global', 'lib', 'node_modules'));
|
|
165
|
+
npmRoots.push(resolve(home, '.npm', 'lib', 'node_modules'));
|
|
166
|
+
npmRoots.push(resolve(home, 'node_modules'));
|
|
167
|
+
}
|
|
168
|
+
npmRoots.push('/usr/local/lib/node_modules');
|
|
169
|
+
npmRoots.push('/usr/lib/node_modules');
|
|
170
|
+
npmRoots.push('/opt/homebrew/lib/node_modules');
|
|
171
|
+
}
|
|
172
|
+
return npmRoots.map((root) => resolve(root, '@anthropic-ai', 'claude-code', 'vendor', 'ripgrep', subdir, exe));
|
|
173
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/index.ts —— 工具注册表 + 执行入口
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 这里做两件事:
|
|
6
|
+
* 1. 把所有工具集中起来导出一个 ALL_TOOLS 数组(loop.ts 会用)
|
|
7
|
+
* 2. 实现 executeTool(name, rawArgs):
|
|
8
|
+
* a. 按 name 查表
|
|
9
|
+
* b. 用 Zod safeParse 校验 arguments(LLM 给的是 JSON 字符串!)
|
|
10
|
+
* c. 调用 tool.call(),统一包装错误
|
|
11
|
+
*
|
|
12
|
+
* 这层是"防线":模型给的参数永远不可信,必须 Zod 校验后才进 call()。
|
|
13
|
+
* ============================================================
|
|
14
|
+
*/
|
|
15
|
+
import { bashTool } from './bash/bash.js';
|
|
16
|
+
import { editTool } from './edit/edit.js';
|
|
17
|
+
import { multiEditTool } from './edit/multi-edit.js';
|
|
18
|
+
import { globTool } from './glob/glob.js';
|
|
19
|
+
import { grepTool } from './grep/grep.js';
|
|
20
|
+
import { readTool } from './read/read.js';
|
|
21
|
+
import { webfetchTool } from './webfetch/webfetch.js';
|
|
22
|
+
import { webbrowserTool } from './webbrowser/webbrowser.js';
|
|
23
|
+
import { webSearchTool } from './websearch/websearch.js';
|
|
24
|
+
import { writeTool } from './write/write.js';
|
|
25
|
+
/** 所有内置工具(顺序 = 给 LLM 呈现的顺序,不影响代码执行,但会影响模型的优先级直觉) */
|
|
26
|
+
export const ALL_TOOLS = [
|
|
27
|
+
readTool,
|
|
28
|
+
editTool,
|
|
29
|
+
multiEditTool,
|
|
30
|
+
writeTool,
|
|
31
|
+
globTool,
|
|
32
|
+
grepTool,
|
|
33
|
+
webSearchTool,
|
|
34
|
+
webfetchTool,
|
|
35
|
+
webbrowserTool,
|
|
36
|
+
bashTool,
|
|
37
|
+
];
|
|
38
|
+
/** 按名字查表 */
|
|
39
|
+
export function getToolByName(name) {
|
|
40
|
+
return ALL_TOOLS.find((t) => t.name === name);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 执行一次工具调用。
|
|
44
|
+
*
|
|
45
|
+
* @param name 工具名(来自 LLM 的 tool_call.function.name)
|
|
46
|
+
* @param rawArguments LLM 给的参数 —— 注意这是 JSON 字符串,不是对象
|
|
47
|
+
* @param signal 支持中断
|
|
48
|
+
*
|
|
49
|
+
* 返回 ToolResult:失败时不抛异常,把 error 写在结果里(便于把"工具失败"
|
|
50
|
+
* 当成普通 tool_result 喂回 LLM,让模型自己重试或换思路)。
|
|
51
|
+
*/
|
|
52
|
+
export async function executeTool(name, rawArguments, signal) {
|
|
53
|
+
// 1. 找工具
|
|
54
|
+
const tool = getToolByName(name);
|
|
55
|
+
if (!tool) {
|
|
56
|
+
const available = ALL_TOOLS.map((t) => t.name).join(', ');
|
|
57
|
+
return { ok: false, error: `未知工具 "${name}"。可用工具:${available}` };
|
|
58
|
+
}
|
|
59
|
+
// 2. 解析 JSON
|
|
60
|
+
let parsedJson;
|
|
61
|
+
try {
|
|
62
|
+
parsedJson = rawArguments.length === 0 ? {} : JSON.parse(rawArguments);
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: `工具参数不是合法 JSON:${e.message}\n收到的字符串:${rawArguments.slice(0, 200)}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// 3. Zod 校验
|
|
71
|
+
const checked = tool.inputSchema.safeParse(parsedJson);
|
|
72
|
+
if (!checked.success) {
|
|
73
|
+
const issues = checked.error.issues
|
|
74
|
+
.map((i) => ` - ${i.path.join('.') || '<root>'}: ${i.message}`)
|
|
75
|
+
.join('\n');
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: `参数校验失败(请检查参数名/类型):\n${issues}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// 4. 调用 call(),捕获意外异常
|
|
82
|
+
try {
|
|
83
|
+
return await tool.call(checked.data, signal);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: `工具执行抛出异常:${e.message}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// 导出每个工具,方便测试单独 import
|
|
93
|
+
export { readTool, editTool, writeTool, globTool, grepTool, webSearchTool, webfetchTool, webbrowserTool, bashTool };
|
|
94
|
+
export { multiEditTool } from './edit/multi-edit.js';
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/tools/read.ts —— Read 工具(读文件)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 对应 kakadeai 主仓库的 FileReadTool。
|
|
6
|
+
* prompt 文本和 Zod schema 与 kakadeai 一致,让"模型可见接口"完全对齐;
|
|
7
|
+
* call() 实现用最简单的 fs/promises,剥离了 UI/权限/PDF/Notebook/图片支持。
|
|
8
|
+
*
|
|
9
|
+
* 输入:
|
|
10
|
+
* file_path 必填,绝对路径(建议)或相对项目根
|
|
11
|
+
* offset 可选,1-indexed 起始行号
|
|
12
|
+
* limit 可选,最大读多少行(默认 2000)
|
|
13
|
+
*
|
|
14
|
+
* 输出:
|
|
15
|
+
* 带行号前缀的文本(格式:"${line}\t${content}"),与 Read 工具的 prompt 约定一致
|
|
16
|
+
* ============================================================
|
|
17
|
+
*/
|
|
18
|
+
import { createReadStream } from 'node:fs';
|
|
19
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
20
|
+
import { createInterface } from 'node:readline';
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
import { DEFAULT_MAX_RESULT_SIZE_CHARS, MAX_FILE_SIZE_BYTES, MAX_LINES_TO_READ } from '../types.js';
|
|
23
|
+
import { toToolParameters } from '../../utils/zodToJson.js';
|
|
24
|
+
import { hasBinaryExtension, isBlockedDevicePath, resolveToolPath } from '../shared/fileUtils.js';
|
|
25
|
+
import { filePathField } from '../shared/schemas.js';
|
|
26
|
+
import { recordRead } from '../shared/fileState.js';
|
|
27
|
+
// ---------------- 1. Zod 输入 schema ----------------
|
|
28
|
+
const inputSchema = z.object({
|
|
29
|
+
file_path: filePathField('读取'),
|
|
30
|
+
offset: z
|
|
31
|
+
.number()
|
|
32
|
+
.int()
|
|
33
|
+
.positive()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('起始行号(1-indexed);不填则从文件开头读'),
|
|
36
|
+
limit: z
|
|
37
|
+
.number()
|
|
38
|
+
.int()
|
|
39
|
+
.positive()
|
|
40
|
+
.optional()
|
|
41
|
+
.describe(`最多读多少行;不填则用默认值 ${MAX_LINES_TO_READ}`),
|
|
42
|
+
});
|
|
43
|
+
// ---------------- 2. JSON Schema(给 LLM 看) ----------------
|
|
44
|
+
//
|
|
45
|
+
// 与上面的 Zod schema 保持一致;改动时两边都要改。
|
|
46
|
+
// ---------------- 2. JSON Schema(由 Zod 自动派生) ----------------
|
|
47
|
+
const parameters = toToolParameters(inputSchema);
|
|
48
|
+
// ---------------- 3. Description(给 LLM 看的工具说明) ----------------
|
|
49
|
+
//
|
|
50
|
+
// 这段文本直接搬自 kakadeai/tools/FileReadTool/prompt.ts 的 DESCRIPTION,
|
|
51
|
+
// 只是去掉了和 PDF/Notebook/图片相关的能力描述(最小版不支持)。
|
|
52
|
+
const description = `Reads a file from the local filesystem. You can access any file directly by using this tool.
|
|
53
|
+
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
- The file_path parameter is preferably an absolute path. Relative paths are accepted and will be resolved against the agent's working directory (locked at startup via \`-d <dir>\` or process cwd).
|
|
57
|
+
- By default, it reads up to ${MAX_LINES_TO_READ} lines starting from the beginning of the file
|
|
58
|
+
- You can optionally specify a line offset and limit (especially handy for long files)
|
|
59
|
+
- Results are returned using cat -n format, with line numbers starting at 1
|
|
60
|
+
- This tool can only read text files, not directories. To read a directory, use the Glob tool.
|
|
61
|
+
- If you read a file that exists but has empty contents you will receive a warning in place of file contents.
|
|
62
|
+
- This tool cannot read binary files. Files whose extensions are on the binary blocklist (images: .png/.jpg/.gif/.webp/..., documents: .pdf/.docx/.xlsx/..., executables: .exe/.dll/.so/..., archives: .zip/.tar/.gz/..., and others) will be rejected with a clear error. If the file is actually text despite the extension (e.g., a misnamed log), rename it or use Bash \`cat\` to read it directly — do not retry Read with the same path.`;
|
|
63
|
+
// ---------------- 4. call() —— 真正干活 ----------------
|
|
64
|
+
const STREAM_THRESHOLD = 1024 * 1024;
|
|
65
|
+
async function call(input) {
|
|
66
|
+
const offset = input.offset ?? 1;
|
|
67
|
+
const limit = input.limit ?? MAX_LINES_TO_READ;
|
|
68
|
+
// 4a. 路径规范化 + 安全校验(替换原来的手动 resolve + validatePath)
|
|
69
|
+
const pathResult = resolveToolPath(input.file_path);
|
|
70
|
+
if (!pathResult.ok)
|
|
71
|
+
return pathResult;
|
|
72
|
+
const filePath = pathResult.resolvedPath;
|
|
73
|
+
// 4b. 设备文件双重拦截(虽然 validateAndResolvePath 已包含,但双重保险)
|
|
74
|
+
if (isBlockedDevicePath(filePath)) {
|
|
75
|
+
return { ok: false, error: `不允许读取设备文件:${filePath}。该路径可能产生无限输出或阻塞进程。` };
|
|
76
|
+
}
|
|
77
|
+
// 4b'. 二进制扩展名黑名单:在 stat 之前快速短路,避免把 .png/.exe/.pdf 等塞回 LLM
|
|
78
|
+
if (hasBinaryExtension(filePath)) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: `不支持读取二进制文件:${filePath}(扩展名命中二进制黑名单)。若该文件实际为文本,可改后缀或用 Bash cat 旁路。`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// 4c. 文件存在性 / 大小检查
|
|
85
|
+
let st;
|
|
86
|
+
try {
|
|
87
|
+
st = await stat(filePath);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
error: `读取失败:${filePath} 不存在或无法访问(${e.message})`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (!st.isFile()) {
|
|
96
|
+
return { ok: false, error: `${filePath} 不是文件(可能是目录)。要列目录用 Glob 工具。` };
|
|
97
|
+
}
|
|
98
|
+
if (st.size > MAX_FILE_SIZE_BYTES) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: `文件过大(${st.size} 字节 > ${MAX_FILE_SIZE_BYTES})。请用 offset/limit 分段读。`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// 4d. 根据文件大小选择读取策略
|
|
105
|
+
let numbered;
|
|
106
|
+
let totalLines;
|
|
107
|
+
if (st.size <= STREAM_THRESHOLD) {
|
|
108
|
+
// 小文件:原有 readFile 路径
|
|
109
|
+
const result = await readSmallFile(filePath, offset, limit);
|
|
110
|
+
numbered = result.numbered;
|
|
111
|
+
totalLines = result.totalLines;
|
|
112
|
+
if (result.isEmpty) {
|
|
113
|
+
recordRead(filePath);
|
|
114
|
+
return { ok: true, content: '<file is empty>' };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// 大文件(>1MB 且 <= MAX_FILE_SIZE_BYTES):流式读取
|
|
119
|
+
const result = await readLargeFileStream(filePath, offset, limit);
|
|
120
|
+
numbered = result.numbered;
|
|
121
|
+
totalLines = result.totalLines;
|
|
122
|
+
}
|
|
123
|
+
// 4e. 空文件处理
|
|
124
|
+
if (totalLines === 0 || !numbered) {
|
|
125
|
+
recordRead(filePath);
|
|
126
|
+
return { ok: true, content: '<file is empty>' };
|
|
127
|
+
}
|
|
128
|
+
// 4f. 大小兜底 + 自动分段提示
|
|
129
|
+
let content = numbered;
|
|
130
|
+
if (content.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
|
|
131
|
+
content =
|
|
132
|
+
content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS) +
|
|
133
|
+
`\n\n... (输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,已截断)`;
|
|
134
|
+
}
|
|
135
|
+
const startIdx = Math.max(0, offset - 1);
|
|
136
|
+
const endIdx = Math.min(totalLines, startIdx + limit);
|
|
137
|
+
if (endIdx < totalLines) {
|
|
138
|
+
const nextOffset = endIdx + 1;
|
|
139
|
+
const returnedLines = content.split('\n').filter(l => l.trim()).length;
|
|
140
|
+
content += `\n\n... (本次返回 ${returnedLines} 行 / 文件共 ${totalLines} 行;用 offset=${nextOffset} 继续读)`;
|
|
141
|
+
}
|
|
142
|
+
if (st.size > 100 * 1024 && offset === 1) {
|
|
143
|
+
content += `\n\n⚠️ 注意:这是一个大文件(${(st.size / 1024).toFixed(1)} KB)。建议用 offset/limit 分段读取,例如先读关键部分(imports、exports、函数签名)。`;
|
|
144
|
+
}
|
|
145
|
+
recordRead(filePath);
|
|
146
|
+
return { ok: true, content };
|
|
147
|
+
}
|
|
148
|
+
async function readSmallFile(filePath, offset, limit) {
|
|
149
|
+
const raw = await readFile(filePath, 'utf8');
|
|
150
|
+
if (raw.length === 0) {
|
|
151
|
+
return { numbered: '', totalLines: 0, isEmpty: true };
|
|
152
|
+
}
|
|
153
|
+
const allLines = raw.split('\n');
|
|
154
|
+
const totalLines = allLines.length;
|
|
155
|
+
const startIdx = Math.max(0, offset - 1);
|
|
156
|
+
const endIdx = Math.min(totalLines, startIdx + limit);
|
|
157
|
+
const slice = allLines.slice(startIdx, endIdx);
|
|
158
|
+
const numbered = slice
|
|
159
|
+
.map((line, i) => `${(startIdx + i + 1).toString()}\t${line}`)
|
|
160
|
+
.join('\n');
|
|
161
|
+
return { numbered, totalLines, isEmpty: false };
|
|
162
|
+
}
|
|
163
|
+
async function readLargeFileStream(filePath, offset, limit) {
|
|
164
|
+
return new Promise((resolvePromise, reject) => {
|
|
165
|
+
const lines = [];
|
|
166
|
+
let currentLine = 0;
|
|
167
|
+
const startIdx = Math.max(0, offset - 1);
|
|
168
|
+
const endLine = startIdx + limit;
|
|
169
|
+
const input = createReadStream(filePath, { encoding: 'utf8' });
|
|
170
|
+
const rl = createInterface({
|
|
171
|
+
input,
|
|
172
|
+
crlfDelay: Infinity,
|
|
173
|
+
});
|
|
174
|
+
input.on('error', (err) => {
|
|
175
|
+
reject(err);
|
|
176
|
+
});
|
|
177
|
+
rl.on('line', (line) => {
|
|
178
|
+
currentLine++;
|
|
179
|
+
if (currentLine > endLine) {
|
|
180
|
+
rl.close();
|
|
181
|
+
rl.removeAllListeners();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (currentLine >= offset) {
|
|
185
|
+
lines.push(`${currentLine}\t${line}`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
rl.on('close', () => {
|
|
189
|
+
resolvePromise({
|
|
190
|
+
numbered: lines.join('\n'),
|
|
191
|
+
totalLines: currentLine,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
rl.on('error', (err) => {
|
|
195
|
+
reject(err);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
// ---------------- 5. 导出工具对象 ----------------
|
|
200
|
+
export const readTool = {
|
|
201
|
+
name: 'Read',
|
|
202
|
+
description,
|
|
203
|
+
inputSchema,
|
|
204
|
+
parameters,
|
|
205
|
+
isReadOnly: true,
|
|
206
|
+
isConcurrencySafe: true,
|
|
207
|
+
maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
|
|
208
|
+
call,
|
|
209
|
+
};
|