llm-api-gateway-cli 1.0.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/.env.example +10 -0
- package/README.md +1127 -0
- package/cli-agent.js +666 -0
- package/cli-anthropic.js +236 -0
- package/cli-claude-code.js +317 -0
- package/cli-openai.js +212 -0
- package/completions/_llm-api-gateway-cli +65 -0
- package/completions/llm-api-gateway-cli.bash +64 -0
- package/completions/llm-api-gateway-cli.fish +43 -0
- package/images/chat.png +0 -0
- package/images/settings.png +0 -0
- package/images/task.png +0 -0
- package/lib/agent.js +607 -0
- package/lib/commands.js +468 -0
- package/lib/common.js +196 -0
- package/lib/config.js +70 -0
- package/lib/configcmd.js +230 -0
- package/lib/hub.js +1494 -0
- package/lib/jsonstore.js +49 -0
- package/lib/mcp.js +375 -0
- package/lib/memory.js +109 -0
- package/lib/plandoc.js +178 -0
- package/lib/pricing.js +52 -0
- package/lib/runner.js +234 -0
- package/lib/runstore.js +96 -0
- package/lib/secrets.js +198 -0
- package/lib/sessionstore.js +269 -0
- package/lib/settings.js +517 -0
- package/lib/tasksession.js +594 -0
- package/lib/taskstore.js +740 -0
- package/lib/tools.js +927 -0
- package/package.json +55 -0
- package/public/app.js +1055 -0
- package/public/index.html +167 -0
- package/public/manual.css +215 -0
- package/public/manual.html +381 -0
- package/public/manual.js +186 -0
- package/public/models.js +121 -0
- package/public/render.js +250 -0
- package/public/styles.css +955 -0
- package/public/task-slash.js +493 -0
- package/public/task.css +739 -0
- package/public/task.html +220 -0
- package/public/task.js +3127 -0
- package/public/theme.js +91 -0
- package/public/tint.js +261 -0
- package/scripts/install.ps1 +537 -0
- package/scripts/install.sh +510 -0
- package/server.js +14 -0
- package/task-server.js +15 -0
package/lib/tools.js
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模式五 · 任务模式的工具集
|
|
3
|
+
*
|
|
4
|
+
* 安全边界:所有文件工具都只能作用于会话选定的工作目录之内。
|
|
5
|
+
* 1. 先用 path.resolve 做字符串层面的越界检查(挡住 ../../ 这类);
|
|
6
|
+
* 2. 再用 realpath 复核,挡住「工作目录内的符号链接指向外部」这种绕过。
|
|
7
|
+
* 写入类工具需要用户批准,未批准时 executeTool 直接拒绝。
|
|
8
|
+
*
|
|
9
|
+
* 工具分三档(M3 扩展后):
|
|
10
|
+
* · 只读:list_dir / read_file / search_files / glob / grep —— 计划模式也能用;
|
|
11
|
+
* · 写入:apply_patch / write_file —— 必须批准;
|
|
12
|
+
* · 命令:bash —— 必须批准**且**由宿主显式开启(默认关闭),安全面从路径级升到命令级。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, statSync, readdirSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
|
16
|
+
import { spawnSync, spawn } from 'node:child_process';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
// MCP 工具是**动态**来源(清单按密钥从网关取),见 lib/mcp.js 的模块说明
|
|
21
|
+
import {
|
|
22
|
+
mcpToolSpecs,
|
|
23
|
+
isMcpTool,
|
|
24
|
+
describeMcpCall,
|
|
25
|
+
callMcpTool,
|
|
26
|
+
mcpPreview,
|
|
27
|
+
setMcpReserved,
|
|
28
|
+
} from './mcp.js';
|
|
29
|
+
|
|
30
|
+
const MAX_READ_BYTES = 200 * 1024;
|
|
31
|
+
const MAX_WRITE_BYTES = 512 * 1024;
|
|
32
|
+
const MAX_BROWSE_ENTRIES = 2000;
|
|
33
|
+
const MAX_SEARCH_RESULTS = 60;
|
|
34
|
+
const MAX_SEARCH_FILES = 4000;
|
|
35
|
+
const MAX_SEARCH_FILE_BYTES = 512 * 1024;
|
|
36
|
+
const MAX_GLOB_RESULTS = 200;
|
|
37
|
+
const MAX_PREVIEW_CHARS = 8000;
|
|
38
|
+
const BASH_TIMEOUT_MS = 30 * 1000;
|
|
39
|
+
const MAX_BASH_OUTPUT = 20000;
|
|
40
|
+
|
|
41
|
+
// 搜索时跳过的目录:这些要么是依赖、要么是版本库元数据,翻进去没有意义还很慢
|
|
42
|
+
const SKIP_DIRS = new Set([
|
|
43
|
+
'node_modules', '.git', '.svn', '.hg', '.idea', '.vscode', 'dist', 'build', 'out',
|
|
44
|
+
'target', 'coverage', '.next', '.nuxt', '.cache', '.venv', 'venv', '__pycache__', 'vendor',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/* ---------- bash 开关:默认关闭 ---------- */
|
|
48
|
+
|
|
49
|
+
let bashEnabled = false;
|
|
50
|
+
|
|
51
|
+
/** 由宿主显式开启(CLI 的 --allow-bash / Web 侧同理)。默认关闭是刻意的:命令级权限不该默默打开 */
|
|
52
|
+
export function setBashEnabled(v) {
|
|
53
|
+
bashEnabled = Boolean(v);
|
|
54
|
+
}
|
|
55
|
+
export const isBashEnabled = () => bashEnabled;
|
|
56
|
+
|
|
57
|
+
export const TOOLS = [
|
|
58
|
+
{
|
|
59
|
+
type: 'function',
|
|
60
|
+
function: {
|
|
61
|
+
name: 'list_dir',
|
|
62
|
+
description: '列出工作目录内某个目录的文件与子目录。path 为相对工作目录的路径,省略表示工作目录根。',
|
|
63
|
+
parameters: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: { path: { type: 'string', description: '相对工作目录的目录路径,如 "src" 或 "."' } },
|
|
66
|
+
required: [],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: 'function',
|
|
72
|
+
function: {
|
|
73
|
+
name: 'read_file',
|
|
74
|
+
description: '读取工作目录内一个文本文件的完整内容。只能读文本文件,大小上限 200KB。',
|
|
75
|
+
parameters: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: { path: { type: 'string', description: '相对工作目录的文件路径,如 "README.md"' } },
|
|
78
|
+
required: ['path'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: 'function',
|
|
84
|
+
function: {
|
|
85
|
+
name: 'search_files',
|
|
86
|
+
description: '在工作目录内按关键字搜索文件内容(不区分大小写),返回 文件:行号: 内容。适合先定位再读取。',
|
|
87
|
+
parameters: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
query: { type: 'string', description: '要搜索的关键字' },
|
|
91
|
+
path: { type: 'string', description: '相对工作目录的起始目录,省略为工作目录根' },
|
|
92
|
+
},
|
|
93
|
+
required: ['query'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: 'function',
|
|
99
|
+
function: {
|
|
100
|
+
name: 'glob',
|
|
101
|
+
description:
|
|
102
|
+
'按文件名模式查找文件(不是搜内容)。支持 * / ** / ? 与 {a,b},例如 "**/*.test.mjs"、"src/*.js"。' +
|
|
103
|
+
'不含 / 的模式会当成 "**/模式" 处理,所以 "*.md" 能匹配到任意层级。',
|
|
104
|
+
parameters: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
pattern: { type: 'string', description: '文件名模式,如 "**/*.mjs"' },
|
|
108
|
+
path: { type: 'string', description: '相对工作目录的起始目录,省略为工作目录根' },
|
|
109
|
+
},
|
|
110
|
+
required: ['pattern'],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
type: 'function',
|
|
116
|
+
function: {
|
|
117
|
+
name: 'grep',
|
|
118
|
+
description: '在工作目录内按**正则表达式**搜索文件内容,返回 文件:行号: 内容。比 search_files 更精确(它只做关键字包含)。',
|
|
119
|
+
parameters: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {
|
|
122
|
+
pattern: { type: 'string', description: '正则表达式,如 "function\\s+\\w+\\("' },
|
|
123
|
+
path: { type: 'string', description: '相对工作目录的起始目录,省略为工作目录根' },
|
|
124
|
+
ignore_case: { type: 'boolean', description: '是否忽略大小写,默认 false' },
|
|
125
|
+
},
|
|
126
|
+
required: ['pattern'],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
type: 'function',
|
|
132
|
+
function: {
|
|
133
|
+
name: 'apply_patch',
|
|
134
|
+
description:
|
|
135
|
+
'对工作目录内**已存在**的文件做增量编辑:给出若干「查找 → 替换」片段,只改动匹配到的部分,' +
|
|
136
|
+
'不必重写整个文件。每处 find 必须能唯一匹配,否则整次修改都不会落盘。新建文件请用 write_file。执行前需要用户批准。',
|
|
137
|
+
parameters: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
path: { type: 'string', description: '相对工作目录的文件路径(必须已存在)' },
|
|
141
|
+
edits: {
|
|
142
|
+
type: 'array',
|
|
143
|
+
description: '要应用的修改列表,按顺序执行',
|
|
144
|
+
items: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
find: { type: 'string', description: '要被替换的原文(必须唯一出现)' },
|
|
148
|
+
replace: { type: 'string', description: '替换成的新内容,可以是空串(表示删除)' },
|
|
149
|
+
all: { type: 'boolean', description: '允许 find 出现多次并全部替换,默认 false' },
|
|
150
|
+
},
|
|
151
|
+
required: ['find'],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
required: ['path', 'edits'],
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
type: 'function',
|
|
161
|
+
function: {
|
|
162
|
+
name: 'write_file',
|
|
163
|
+
description:
|
|
164
|
+
'把内容写入工作目录内的文件(存在则整体覆盖,不存在则新建,父目录会自动创建)。' +
|
|
165
|
+
'这是整体覆盖而非增量修改,务必先 read_file 拿到完整内容再写;只改几处请优先用 apply_patch。执行前需要用户批准。',
|
|
166
|
+
parameters: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
properties: {
|
|
169
|
+
path: { type: 'string', description: '相对工作目录的文件路径' },
|
|
170
|
+
content: { type: 'string', description: '要写入的完整文件内容' },
|
|
171
|
+
},
|
|
172
|
+
required: ['path', 'content'],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
type: 'function',
|
|
178
|
+
function: {
|
|
179
|
+
name: 'bash',
|
|
180
|
+
description:
|
|
181
|
+
'在工作目录内执行一条 shell 命令并返回输出。默认关闭,只有宿主显式开启后才可用;' +
|
|
182
|
+
'有超时与输出截断,每次执行都需要用户批准(自动模式下不再逐个确认)。' +
|
|
183
|
+
(process.platform === 'win32'
|
|
184
|
+
? '本机是 Windows:命令经 cmd.exe 执行,只有 cmd 内建命令与 PATH 里的程序可用 —— '
|
|
185
|
+
+ '不要用 tail / head / grep / sed / cat 这类 Unix 命令,'
|
|
186
|
+
+ '需要截断或筛选请改用程序自带参数(如 pytest -q、--maxfail=1、node -e)。'
|
|
187
|
+
: '本机是类 Unix:命令经 /bin/sh 执行。'),
|
|
188
|
+
parameters: {
|
|
189
|
+
type: 'object',
|
|
190
|
+
properties: {
|
|
191
|
+
command: { type: 'string', description: '要执行的命令' },
|
|
192
|
+
timeout_ms: { type: 'number', description: `超时毫秒数,默认 ${BASH_TIMEOUT_MS}` },
|
|
193
|
+
},
|
|
194
|
+
required: ['command'],
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
// 内置工具名登记给 MCP 层:远端工具与内置同名时必须让位(内置带工作目录沙箱语义)。
|
|
201
|
+
// 放在这里而不是 mcp.js 里,是因为内置清单的**唯一出处**就是本文件的 TOOLS。
|
|
202
|
+
setMcpReserved(TOOLS.map((t) => t.function.name));
|
|
203
|
+
|
|
204
|
+
const WRITE_TOOLS = new Set(['write_file', 'apply_patch', 'bash']);
|
|
205
|
+
/**
|
|
206
|
+
* **要人工批准**的工具:只有会改动本机文件的这三支。
|
|
207
|
+
*
|
|
208
|
+
* 这是**唯一**的审批闸门。远端 MCP 调用**不**走它——理由是一个真实的踩坑:
|
|
209
|
+
* 原先 MCP 也算「写入类」,于是非交互 `-p` 模式(默认拒绝写入)下**每一次 MCP 调用
|
|
210
|
+
* 都要 y/N**,实际表现是「所有地图/搜索工具全部被拒」,任务一行工具都跑不出来。
|
|
211
|
+
* 而想绕过就只能加 `--yes`,那又会把**任意文件写入**一起放开——用一个更大的权限
|
|
212
|
+
* 去换一个更小的功能,这个交换不成立。所以 MCP 改成直接执行(每次调用照样通过
|
|
213
|
+
* `tool_call` 事件打出来,用户看得见),审批闸门只留给真正改本机文件的工具。
|
|
214
|
+
*/
|
|
215
|
+
export const isFileWriteTool = (name) => WRITE_TOOLS.has(name);
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* **计划模式下不给模型**的工具:本机写入 + 远端 MCP。
|
|
219
|
+
*
|
|
220
|
+
* 注意它和上面的 `isFileWriteTool` **不是一回事**,别合并:
|
|
221
|
+
* 「计划模式只做本地只读调研」是产品边界(MCP 会出网、有副作用、要花钱),
|
|
222
|
+
* 与「执行前要不要人工批准」无关。合成一个谓词的话,改任一边都会静默改掉另一边。
|
|
223
|
+
*/
|
|
224
|
+
export const isWriteTool = (name) => WRITE_TOOLS.has(name) || isMcpTool(name);
|
|
225
|
+
|
|
226
|
+
/** 当前可用工具:bash 没开启时直接从清单里摘掉,模型就不会去试 */
|
|
227
|
+
export function availableTools() {
|
|
228
|
+
const builtins = bashEnabled ? TOOLS : TOOLS.filter((t) => t.function.name !== 'bash');
|
|
229
|
+
// MCP 工具与内置常量合并。重名以内置为准:内置工具带沙箱语义,
|
|
230
|
+
// 不能被远端同名工具顶掉(mcpToolSpecs 的 reserved 参数就是为此传入)。
|
|
231
|
+
return [...builtins, ...mcpToolSpecs(new Set(TOOLS.map((t) => t.function.name)))];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 给 UI 用的一句话描述 */
|
|
235
|
+
export function describeCall(name, args = {}) {
|
|
236
|
+
switch (name) {
|
|
237
|
+
case 'list_dir':
|
|
238
|
+
return `列出目录 ${args.path || '.'}`;
|
|
239
|
+
case 'read_file':
|
|
240
|
+
return `读取文件 ${args.path || '?'}`;
|
|
241
|
+
case 'search_files':
|
|
242
|
+
return `搜索 “${args.query || '?'}”${args.path ? ` @ ${args.path}` : ''}`;
|
|
243
|
+
case 'glob':
|
|
244
|
+
return `按模式查找 ${args.pattern || '?'}${args.path ? ` @ ${args.path}` : ''}`;
|
|
245
|
+
case 'grep':
|
|
246
|
+
return `正则搜索 ${args.pattern || '?'}${args.path ? ` @ ${args.path}` : ''}`;
|
|
247
|
+
case 'apply_patch': {
|
|
248
|
+
const n = Array.isArray(args.edits) ? args.edits.length : 0;
|
|
249
|
+
return `修改文件 ${args.path || '?'}(${n} 处)`;
|
|
250
|
+
}
|
|
251
|
+
case 'write_file':
|
|
252
|
+
return `写入文件 ${args.path || '?'}`;
|
|
253
|
+
case 'bash':
|
|
254
|
+
return `执行命令 ${String(args.command || '?').slice(0, 80)}`;
|
|
255
|
+
default:
|
|
256
|
+
// MCP 工具走专用文案(带服务器与参数摘要),未知名字保持原样
|
|
257
|
+
return isMcpTool(name) ? describeMcpCall(name, args) : `调用 ${name}`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------- 工作目录校验与沙箱 ----------
|
|
262
|
+
|
|
263
|
+
export function checkWorkDir(dir) {
|
|
264
|
+
if (typeof dir !== 'string' || !dir.trim()) {
|
|
265
|
+
throw Object.assign(new Error('未指定工作目录'), { status: 400 });
|
|
266
|
+
}
|
|
267
|
+
const abs = path.resolve(dir.trim());
|
|
268
|
+
if (!existsSync(abs)) throw Object.assign(new Error(`工作目录不存在:${abs}`), { status: 400 });
|
|
269
|
+
if (!statSync(abs).isDirectory()) throw Object.assign(new Error(`工作目录不是目录:${abs}`), { status: 400 });
|
|
270
|
+
return abs;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function resolveInRoot(root, p) {
|
|
274
|
+
const raw = typeof p === 'string' && p.trim() ? p.trim() : '.';
|
|
275
|
+
const abs = path.resolve(root, raw);
|
|
276
|
+
const rel = path.relative(root, abs);
|
|
277
|
+
if (rel !== '' && (rel.startsWith('..') || path.isAbsolute(rel))) {
|
|
278
|
+
throw new Error(`路径越界:${raw} 不在工作目录内`);
|
|
279
|
+
}
|
|
280
|
+
return abs;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 符号链接复核:不存在的路径回退到最近的存在祖先,再 realpath 比对 */
|
|
284
|
+
function assertNoEscape(root, abs) {
|
|
285
|
+
let realRoot;
|
|
286
|
+
try {
|
|
287
|
+
realRoot = realpathSync(root);
|
|
288
|
+
} catch {
|
|
289
|
+
return; // 工作目录本身取不到 realpath 时,字符串检查已经生效,放过
|
|
290
|
+
}
|
|
291
|
+
let probe = abs;
|
|
292
|
+
while (!existsSync(probe)) {
|
|
293
|
+
const parent = path.dirname(probe);
|
|
294
|
+
if (parent === probe) break;
|
|
295
|
+
probe = parent;
|
|
296
|
+
}
|
|
297
|
+
let real;
|
|
298
|
+
try {
|
|
299
|
+
real = realpathSync(probe);
|
|
300
|
+
} catch {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const rel = path.relative(realRoot, real);
|
|
304
|
+
if (rel !== '' && (rel.startsWith('..') || path.isAbsolute(rel))) {
|
|
305
|
+
throw new Error('路径越界:该路径经符号链接指向工作目录之外');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 在工作目录内安全写文件 —— 供**服务端自己决定内容**的写入复用同一套沙箱
|
|
311
|
+
* (`/api/init` 生成项目记忆文件)。模型发起的写入不走这里:那条路必须经过用户审批。
|
|
312
|
+
*
|
|
313
|
+
* 越界检查与工具走同一条(`resolveInRoot` + `assertNoEscape`),不做任何额外放行。
|
|
314
|
+
*
|
|
315
|
+
* @returns {{path:string, rel:string, bytes:number, overwrote:boolean}}
|
|
316
|
+
*/
|
|
317
|
+
export function writeInsideRoot(root, rel, text, { overwrite = false } = {}) {
|
|
318
|
+
const absRoot = checkWorkDir(root);
|
|
319
|
+
const abs = resolveInRoot(absRoot, rel);
|
|
320
|
+
assertNoEscape(absRoot, abs);
|
|
321
|
+
const existed = existsSync(abs);
|
|
322
|
+
if (existed && !overwrite) {
|
|
323
|
+
throw Object.assign(new Error(`文件已存在:${relTo(absRoot, abs)}(要覆盖请显式加 --force)`), { status: 409 });
|
|
324
|
+
}
|
|
325
|
+
const parent = path.dirname(abs);
|
|
326
|
+
if (!existsSync(parent)) {
|
|
327
|
+
throw Object.assign(new Error(`上级目录不存在:${relTo(absRoot, parent)}`), { status: 400 });
|
|
328
|
+
}
|
|
329
|
+
const payload = String(text ?? '');
|
|
330
|
+
writeFileSync(abs, payload, 'utf8');
|
|
331
|
+
return { path: abs, rel: relTo(absRoot, abs), bytes: Buffer.byteLength(payload, 'utf8'), overwrote: existed };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const relTo = (root, abs) => path.relative(root, abs).split(path.sep).join('/') || '.';
|
|
335
|
+
|
|
336
|
+
function looksBinary(buf) {
|
|
337
|
+
const n = Math.min(buf.length, 8192);
|
|
338
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const fmtSize = (n) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
|
343
|
+
|
|
344
|
+
/** 真实行数:末尾换行不算多出一行("a\nb\n" 是 2 行,不是 3 行) */
|
|
345
|
+
function countLines(text) {
|
|
346
|
+
if (!text) return 0;
|
|
347
|
+
const n = text.split('\n').length;
|
|
348
|
+
return text.endsWith('\n') ? n - 1 : n;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* ---------- 增量编辑(apply_patch 的核心,纯函数,便于单测) ---------- */
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 按 edits 依次修改文本。任何一处 find 找不到或(未开 all 时)不唯一,就整体失败,
|
|
355
|
+
* 由调用方保证「不落盘」—— 宁可什么都不改,也不要改一半。
|
|
356
|
+
*
|
|
357
|
+
* @returns {{text:string, applied:number}}
|
|
358
|
+
*/
|
|
359
|
+
export function applyEdits(text, edits) {
|
|
360
|
+
if (!Array.isArray(edits) || !edits.length) throw new Error('edits 不能为空,至少要给一处修改');
|
|
361
|
+
let out = text;
|
|
362
|
+
let applied = 0;
|
|
363
|
+
for (let i = 0; i < edits.length; i++) {
|
|
364
|
+
const e = edits[i];
|
|
365
|
+
const find = typeof e?.find === 'string' ? e.find : typeof e?.old_string === 'string' ? e.old_string : null;
|
|
366
|
+
if (!find) throw new Error(`第 ${i + 1} 处修改缺少 find(要替换的原文)`);
|
|
367
|
+
const replace = typeof e?.replace === 'string' ? e.replace : typeof e?.new_string === 'string' ? e.new_string : '';
|
|
368
|
+
const all = e?.all === true;
|
|
369
|
+
const parts = out.split(find);
|
|
370
|
+
const hits = parts.length - 1;
|
|
371
|
+
if (hits === 0) throw new Error(`第 ${i + 1} 处修改:在工作目录的文件里找不到要替换的原文(find 片段不匹配,注意缩进与换行)`);
|
|
372
|
+
if (hits > 1 && !all) {
|
|
373
|
+
throw new Error(`第 ${i + 1} 处修改:find 片段出现了 ${hits} 次,不唯一 —— 请给出更长的上下文,或显式加 all: true 表示全部替换`);
|
|
374
|
+
}
|
|
375
|
+
// 未开 all 时上面已保证 hits === 1,两种情况都是「把匹配到的片段换成 replace」
|
|
376
|
+
out = parts.join(replace);
|
|
377
|
+
applied++;
|
|
378
|
+
}
|
|
379
|
+
return { text: out, applied };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* ---------- 具体工具实现 ---------- */
|
|
383
|
+
|
|
384
|
+
function toolListDir(root, p) {
|
|
385
|
+
const abs = resolveInRoot(root, p);
|
|
386
|
+
assertNoEscape(root, abs);
|
|
387
|
+
if (!existsSync(abs)) throw new Error(`目录不存在:${relTo(root, abs)}`);
|
|
388
|
+
if (!statSync(abs).isDirectory()) throw new Error(`${relTo(root, abs)} 不是目录,请用 read_file`);
|
|
389
|
+
|
|
390
|
+
const items = [];
|
|
391
|
+
for (const name of readdirSync(abs)) {
|
|
392
|
+
let st;
|
|
393
|
+
try {
|
|
394
|
+
st = statSync(path.join(abs, name));
|
|
395
|
+
} catch {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
items.push({ name, dir: st.isDirectory(), size: st.isDirectory() ? 0 : st.size });
|
|
399
|
+
}
|
|
400
|
+
items.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name, 'zh') : a.dir ? -1 : 1));
|
|
401
|
+
|
|
402
|
+
const shown = items.slice(0, 400);
|
|
403
|
+
const lines = shown.map((it) => (it.dir ? `${it.name}/` : `${it.name} (${fmtSize(it.size)})`));
|
|
404
|
+
const more = items.length > shown.length ? `\n…(另有 ${items.length - shown.length} 项未显示)` : '';
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
summary: `列出 ${relTo(root, abs)}:${items.length} 项`,
|
|
408
|
+
content: lines.length ? lines.join('\n') + more : '(空目录)',
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function toolReadFile(root, p) {
|
|
413
|
+
const abs = resolveInRoot(root, p);
|
|
414
|
+
assertNoEscape(root, abs);
|
|
415
|
+
if (!existsSync(abs)) throw new Error(`文件不存在:${relTo(root, abs)}`);
|
|
416
|
+
const st = statSync(abs);
|
|
417
|
+
if (st.isDirectory()) throw new Error(`${relTo(root, abs)} 是目录,请用 list_dir`);
|
|
418
|
+
if (st.size > MAX_READ_BYTES) {
|
|
419
|
+
throw new Error(`文件过大(${fmtSize(st.size)}),超过 ${fmtSize(MAX_READ_BYTES)} 上限,无法整体读取`);
|
|
420
|
+
}
|
|
421
|
+
const buf = readFileSync(abs);
|
|
422
|
+
if (looksBinary(buf)) throw new Error(`${relTo(root, abs)} 看起来是二进制文件,已拒绝读取`);
|
|
423
|
+
|
|
424
|
+
// 只把文件原文交给模型(不带行号),避免模型把行号一起写回文件
|
|
425
|
+
const text = buf.toString('utf8');
|
|
426
|
+
return {
|
|
427
|
+
ok: true,
|
|
428
|
+
summary: `读取 ${relTo(root, abs)}:${countLines(text)} 行 / ${fmtSize(st.size)}`,
|
|
429
|
+
content: text,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function* walk(dir, root, budget) {
|
|
434
|
+
let names;
|
|
435
|
+
try {
|
|
436
|
+
names = readdirSync(dir);
|
|
437
|
+
} catch {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
for (const name of names) {
|
|
441
|
+
if (budget.files >= MAX_SEARCH_FILES) return;
|
|
442
|
+
const full = path.join(dir, name);
|
|
443
|
+
let st;
|
|
444
|
+
try {
|
|
445
|
+
st = statSync(full);
|
|
446
|
+
} catch {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (st.isDirectory()) {
|
|
450
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
451
|
+
yield* walk(full, root, budget);
|
|
452
|
+
} else if (st.isFile()) {
|
|
453
|
+
budget.files++;
|
|
454
|
+
if (st.size > MAX_SEARCH_FILE_BYTES) continue;
|
|
455
|
+
yield { full, size: st.size };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function toolSearchFiles(root, query, p) {
|
|
461
|
+
const q = typeof query === 'string' ? query.trim() : '';
|
|
462
|
+
if (!q) throw new Error('query 不能为空');
|
|
463
|
+
const base = resolveInRoot(root, p);
|
|
464
|
+
assertNoEscape(root, base);
|
|
465
|
+
if (!existsSync(base)) throw new Error(`目录不存在:${relTo(root, base)}`);
|
|
466
|
+
|
|
467
|
+
const needle = q.toLowerCase();
|
|
468
|
+
const budget = { files: 0 };
|
|
469
|
+
const hits = [];
|
|
470
|
+
let scanned = 0;
|
|
471
|
+
|
|
472
|
+
for (const file of walk(base, root, budget)) {
|
|
473
|
+
let buf;
|
|
474
|
+
try {
|
|
475
|
+
buf = readFileSync(file.full);
|
|
476
|
+
} catch {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (looksBinary(buf)) continue;
|
|
480
|
+
scanned++;
|
|
481
|
+
const text = buf.toString('utf8');
|
|
482
|
+
const lines = text.split('\n');
|
|
483
|
+
for (let i = 0; i < lines.length; i++) {
|
|
484
|
+
if (lines[i].toLowerCase().includes(needle)) {
|
|
485
|
+
hits.push(`${relTo(root, file.full)}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
|
|
486
|
+
if (hits.length >= MAX_SEARCH_RESULTS) break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (hits.length >= MAX_SEARCH_RESULTS) break;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
ok: true,
|
|
494
|
+
summary: hits.length ? `搜索 “${q}”:命中 ${hits.length} 处于 ${scanned} 个文件` : `搜索 “${q}”:无命中(已扫描 ${scanned} 个文件)`,
|
|
495
|
+
content: hits.length
|
|
496
|
+
? hits.join('\n') + (hits.length >= MAX_SEARCH_RESULTS ? `\n…(已达到 ${MAX_SEARCH_RESULTS} 条上限,请缩小范围)` : '')
|
|
497
|
+
: '无命中。可以换关键字,或用 list_dir 先看目录结构。',
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/* ---------- glob ---------- */
|
|
502
|
+
|
|
503
|
+
/** 把 glob 编译成正则:支持 *、**、?、{a,b};不含斜杠的模式按 “**” 加 “/” 前缀处理 */
|
|
504
|
+
export function globToRegExp(pattern) {
|
|
505
|
+
let s = String(pattern || '').replace(/\\/g, '/').trim();
|
|
506
|
+
if (!s) throw new Error('pattern 不能为空');
|
|
507
|
+
if (!s.includes('/')) s = `**/${s}`;
|
|
508
|
+
let re = '';
|
|
509
|
+
for (let i = 0; i < s.length; i++) {
|
|
510
|
+
const c = s[i];
|
|
511
|
+
if (c === '*') {
|
|
512
|
+
if (s[i + 1] === '*') {
|
|
513
|
+
i++;
|
|
514
|
+
if (s[i + 1] === '/') {
|
|
515
|
+
i++;
|
|
516
|
+
re += '(?:[^/]+/)*'; // **/ 匹配任意层级(含零层)
|
|
517
|
+
} else {
|
|
518
|
+
re += '.*';
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
re += '[^/]*';
|
|
522
|
+
}
|
|
523
|
+
} else if (c === '?') {
|
|
524
|
+
re += '[^/]';
|
|
525
|
+
} else if (c === '{') {
|
|
526
|
+
const close = s.indexOf('}', i);
|
|
527
|
+
if (close === -1) {
|
|
528
|
+
re += '\\{';
|
|
529
|
+
} else {
|
|
530
|
+
const alts = s
|
|
531
|
+
.slice(i + 1, close)
|
|
532
|
+
.split(',')
|
|
533
|
+
.map((x) => x.replace(/[.+^${}()|[\]\\]/g, '\\$&'));
|
|
534
|
+
re += `(?:${alts.join('|')})`;
|
|
535
|
+
i = close;
|
|
536
|
+
}
|
|
537
|
+
} else if ('.+^$()|[]\\'.includes(c)) {
|
|
538
|
+
re += `\\${c}`;
|
|
539
|
+
} else {
|
|
540
|
+
re += c;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return new RegExp(`^${re}$`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function toolGlob(root, pattern, p) {
|
|
547
|
+
const re = globToRegExp(pattern);
|
|
548
|
+
const base = resolveInRoot(root, p);
|
|
549
|
+
assertNoEscape(root, base);
|
|
550
|
+
if (!existsSync(base)) throw new Error(`目录不存在:${relTo(root, base)}`);
|
|
551
|
+
|
|
552
|
+
const budget = { files: 0 };
|
|
553
|
+
const hits = [];
|
|
554
|
+
for (const file of walk(base, root, budget)) {
|
|
555
|
+
const rel = relTo(root, file.full);
|
|
556
|
+
if (re.test(rel)) {
|
|
557
|
+
hits.push(`${rel} (${fmtSize(file.size)})`);
|
|
558
|
+
if (hits.length >= MAX_GLOB_RESULTS) break;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
hits.sort();
|
|
562
|
+
return {
|
|
563
|
+
ok: true,
|
|
564
|
+
summary: hits.length ? `匹配 “${pattern}”:${hits.length} 个文件` : `匹配 “${pattern}”:没有文件命中`,
|
|
565
|
+
content: hits.length
|
|
566
|
+
? hits.join('\n') + (hits.length >= MAX_GLOB_RESULTS ? `\n…(已达到 ${MAX_GLOB_RESULTS} 条上限,请缩小范围)` : '')
|
|
567
|
+
: '没有命中的文件。换个模式(如 **/*.js)或用 list_dir 先看目录结构。',
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/* ---------- grep ---------- */
|
|
572
|
+
|
|
573
|
+
function toolGrep(root, pattern, p, ignoreCase) {
|
|
574
|
+
const src = typeof pattern === 'string' ? pattern : '';
|
|
575
|
+
if (!src.trim()) throw new Error('pattern 不能为空');
|
|
576
|
+
let re;
|
|
577
|
+
try {
|
|
578
|
+
re = new RegExp(src, ignoreCase ? 'i' : '');
|
|
579
|
+
} catch (e) {
|
|
580
|
+
throw new Error(`正则表达式非法:${e?.message || e}`);
|
|
581
|
+
}
|
|
582
|
+
const base = resolveInRoot(root, p);
|
|
583
|
+
assertNoEscape(root, base);
|
|
584
|
+
if (!existsSync(base)) throw new Error(`目录不存在:${relTo(root, base)}`);
|
|
585
|
+
|
|
586
|
+
const budget = { files: 0 };
|
|
587
|
+
const hits = [];
|
|
588
|
+
let scanned = 0;
|
|
589
|
+
for (const file of walk(base, root, budget)) {
|
|
590
|
+
let buf;
|
|
591
|
+
try {
|
|
592
|
+
buf = readFileSync(file.full);
|
|
593
|
+
} catch {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (looksBinary(buf)) continue;
|
|
597
|
+
scanned++;
|
|
598
|
+
const lines = buf.toString('utf8').split('\n');
|
|
599
|
+
for (let i = 0; i < lines.length; i++) {
|
|
600
|
+
re.lastIndex = 0;
|
|
601
|
+
if (re.test(lines[i])) {
|
|
602
|
+
hits.push(`${relTo(root, file.full)}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
|
|
603
|
+
if (hits.length >= MAX_SEARCH_RESULTS) break;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (hits.length >= MAX_SEARCH_RESULTS) break;
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
ok: true,
|
|
610
|
+
summary: hits.length ? `正则 “${src}”:命中 ${hits.length} 处于 ${scanned} 个文件` : `正则 “${src}”:无命中(已扫描 ${scanned} 个文件)`,
|
|
611
|
+
content: hits.length
|
|
612
|
+
? hits.join('\n') + (hits.length >= MAX_SEARCH_RESULTS ? `\n…(已达到 ${MAX_SEARCH_RESULTS} 条上限,请缩小范围)` : '')
|
|
613
|
+
: '无命中。可以放宽正则,或先用 glob 找到候选文件。',
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* ---------- 写入 ---------- */
|
|
618
|
+
|
|
619
|
+
function toolWriteFile(root, p, content) {
|
|
620
|
+
if (typeof content !== 'string') throw new Error('content 必须是字符串');
|
|
621
|
+
if (Buffer.byteLength(content) > MAX_WRITE_BYTES) {
|
|
622
|
+
throw new Error(`内容过大(${fmtSize(Buffer.byteLength(content))}),超过 ${fmtSize(MAX_WRITE_BYTES)} 上限`);
|
|
623
|
+
}
|
|
624
|
+
const abs = resolveInRoot(root, p);
|
|
625
|
+
assertNoEscape(root, abs);
|
|
626
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) throw new Error(`${relTo(root, abs)} 是目录,不能写入`);
|
|
627
|
+
|
|
628
|
+
const existed = existsSync(abs);
|
|
629
|
+
const before = existed ? readFileSync(abs, 'utf8') : '';
|
|
630
|
+
mkdirSync(path.dirname(abs), { recursive: true });
|
|
631
|
+
writeFileSync(abs, content, 'utf8');
|
|
632
|
+
|
|
633
|
+
let delta = '';
|
|
634
|
+
if (existed && before !== content) {
|
|
635
|
+
const b = countLines(before);
|
|
636
|
+
const a = countLines(content);
|
|
637
|
+
delta = `(${b} 行 → ${a} 行)`;
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
ok: true,
|
|
641
|
+
summary: `${existed ? '已更新' : '已新建'} ${relTo(root, abs)} ${delta}`.trim(),
|
|
642
|
+
content: `写入成功:${relTo(root, abs)},${Buffer.byteLength(content)} 字节。`,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** 读文件原文用于计算预览(走同一套沙箱,不另开一条读路径) */
|
|
647
|
+
function readTextInRoot(root, p) {
|
|
648
|
+
const abs = resolveInRoot(root, p);
|
|
649
|
+
assertNoEscape(root, abs);
|
|
650
|
+
if (!existsSync(abs)) return { abs, text: null };
|
|
651
|
+
const st = statSync(abs);
|
|
652
|
+
if (!st.isDirectory() && st.size <= MAX_READ_BYTES) {
|
|
653
|
+
const buf = readFileSync(abs);
|
|
654
|
+
if (!looksBinary(buf)) return { abs, text: buf.toString('utf8') };
|
|
655
|
+
}
|
|
656
|
+
return { abs, text: null };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function toolApplyPatch(root, p, edits) {
|
|
660
|
+
const { abs, text } = readTextInRoot(root, p);
|
|
661
|
+
if (text === null) throw new Error(`${relTo(root, abs)} 不存在或不可读 —— apply_patch 只能修改已存在的文本文件,新建请用 write_file`);
|
|
662
|
+
const { text: next, applied } = applyEdits(text, edits);
|
|
663
|
+
if (next === text) {
|
|
664
|
+
return { ok: true, summary: `${relTo(root, abs)} 无需改动(替换结果与原文一致)`, content: `未发生变化:${relTo(root, abs)}` };
|
|
665
|
+
}
|
|
666
|
+
if (Buffer.byteLength(next) > MAX_WRITE_BYTES) {
|
|
667
|
+
throw new Error(`修改后内容过大(${fmtSize(Buffer.byteLength(next))}),超过 ${fmtSize(MAX_WRITE_BYTES)} 上限`);
|
|
668
|
+
}
|
|
669
|
+
writeFileSync(abs, next, 'utf8');
|
|
670
|
+
const b = countLines(text);
|
|
671
|
+
const a = countLines(next);
|
|
672
|
+
return {
|
|
673
|
+
ok: true,
|
|
674
|
+
summary: `已修改 ${relTo(root, abs)}(${applied} 处,${b} 行 → ${a} 行)`,
|
|
675
|
+
content: `修改成功:${relTo(root, abs)},应用 ${applied} 处替换,${Buffer.byteLength(next)} 字节。`,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/* ---------- bash ---------- */
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 杀掉整棵进程树。
|
|
683
|
+
* Windows 上 shell 只是个 cmd.exe 中转壳,真正的命令是它的子进程 —— 只杀壳子
|
|
684
|
+
* 会留下「已经报告被终止、其实还在跑」的孤儿(实测:超时 300ms 报「已终止」,
|
|
685
|
+
* 但那句 node 睡满 5 秒才退,期间还占着工作目录)。所以必须按树杀。
|
|
686
|
+
*/
|
|
687
|
+
function killTree(child) {
|
|
688
|
+
if (!child || !child.pid) return;
|
|
689
|
+
if (process.platform === 'win32') {
|
|
690
|
+
try {
|
|
691
|
+
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
692
|
+
return;
|
|
693
|
+
} catch {
|
|
694
|
+
/* 落到下面的兜底 */
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
child.kill('SIGKILL');
|
|
699
|
+
} catch {
|
|
700
|
+
/* 已经结束了 */
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* 跑一条 shell 命令,带超时与整树终止。
|
|
706
|
+
* 用异步 spawn 而不是 spawnSync:spawnSync 超时后我们手里已经没有活着的壳子了,
|
|
707
|
+
* 想按树杀也找不到父进程,只能干看着孤儿继续跑。
|
|
708
|
+
*/
|
|
709
|
+
function runShell(root, cmd, timeout) {
|
|
710
|
+
return new Promise((resolve) => {
|
|
711
|
+
let child;
|
|
712
|
+
try {
|
|
713
|
+
child = spawn(cmd, { cwd: root, shell: true, windowsHide: true });
|
|
714
|
+
} catch (e) {
|
|
715
|
+
resolve({ stdout: '', stderr: '', status: null, timedOut: false, error: e });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const CAP = 4 * 1024 * 1024;
|
|
719
|
+
let stdout = '';
|
|
720
|
+
let stderr = '';
|
|
721
|
+
let timedOut = false;
|
|
722
|
+
let settled = false;
|
|
723
|
+
|
|
724
|
+
const timer = setTimeout(() => {
|
|
725
|
+
timedOut = true;
|
|
726
|
+
killTree(child);
|
|
727
|
+
}, timeout);
|
|
728
|
+
|
|
729
|
+
const onData = (buf, which) => {
|
|
730
|
+
const s = String(buf);
|
|
731
|
+
if (which === 'out') {
|
|
732
|
+
if (stdout.length < CAP) stdout += s;
|
|
733
|
+
} else if (stderr.length < CAP) {
|
|
734
|
+
stderr += s;
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
child.stdout?.on('data', (d) => onData(d, 'out'));
|
|
738
|
+
child.stderr?.on('data', (d) => onData(d, 'err'));
|
|
739
|
+
|
|
740
|
+
const finish = (code, error) => {
|
|
741
|
+
if (settled) return;
|
|
742
|
+
settled = true;
|
|
743
|
+
clearTimeout(timer);
|
|
744
|
+
resolve({ stdout, stderr, status: code, timedOut, error: error || null });
|
|
745
|
+
};
|
|
746
|
+
child.on('error', (e) => finish(null, e));
|
|
747
|
+
child.on('close', (code) => finish(code, null));
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function toolBash(root, command, timeoutMs) {
|
|
752
|
+
if (!bashEnabled) throw new Error('bash 工具未启用(默认关闭,需宿主显式开启)');
|
|
753
|
+
const cmd = typeof command === 'string' ? command.trim() : '';
|
|
754
|
+
if (!cmd) throw new Error('command 不能为空');
|
|
755
|
+
const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, 120000) : BASH_TIMEOUT_MS;
|
|
756
|
+
|
|
757
|
+
const r = await runShell(root, cmd, timeout);
|
|
758
|
+
if (r.error) throw new Error(`命令执行失败:${r.error.message}`);
|
|
759
|
+
if (r.timedOut) throw new Error(`命令超时(超过 ${timeout} ms)已被终止`);
|
|
760
|
+
const parts = [];
|
|
761
|
+
if (r.stdout) parts.push(String(r.stdout));
|
|
762
|
+
if (r.stderr) parts.push(`[stderr]\n${String(r.stderr)}`);
|
|
763
|
+
let out = parts.join('\n').trim();
|
|
764
|
+
if (!out) out = '(无输出)';
|
|
765
|
+
if (out.length > MAX_BASH_OUTPUT) out = `${out.slice(0, MAX_BASH_OUTPUT)}\n…(输出已截断)`;
|
|
766
|
+
const code = r.status ?? 0;
|
|
767
|
+
return {
|
|
768
|
+
ok: code === 0,
|
|
769
|
+
summary: `命令退出码 ${code}:${cmd.slice(0, 60)}`,
|
|
770
|
+
content: `$ ${cmd}\n${out}`,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* 执行一个工具调用。写入类工具必须显式 allowWrite 才放行。
|
|
776
|
+
* 返回 { ok, summary, content };失败时抛错由调用方转成 tool 结果。
|
|
777
|
+
*
|
|
778
|
+
* `mcp`:MCP 工具要带上的上下文 `{ planNode, sessionId }`(本机工具用不到)。
|
|
779
|
+
* 由调用方(agent 的主循环)从**上一轮对话响应头**取到——见 mcp.js 的说明。
|
|
780
|
+
*/
|
|
781
|
+
export async function executeTool(name, args, root, { allowWrite = false, mcp = null } = {}) {
|
|
782
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
783
|
+
// 只有**改本机文件**的工具需要批准;MCP 调用不走这道闸门(见 isFileWriteTool 的说明)
|
|
784
|
+
if (isFileWriteTool(name) && !allowWrite) throw new Error('写入操作未获用户批准,已拒绝执行');
|
|
785
|
+
// MCP 工具不在本机执行:转给网关,由它统一限流、写审计、转发第三方
|
|
786
|
+
if (isMcpTool(name)) return callMcpTool(name, a, mcp);
|
|
787
|
+
switch (name) {
|
|
788
|
+
case 'list_dir':
|
|
789
|
+
return toolListDir(root, a.path);
|
|
790
|
+
case 'read_file':
|
|
791
|
+
return toolReadFile(root, a.path);
|
|
792
|
+
case 'search_files':
|
|
793
|
+
return toolSearchFiles(root, a.query, a.path);
|
|
794
|
+
case 'glob':
|
|
795
|
+
return toolGlob(root, a.pattern, a.path);
|
|
796
|
+
case 'grep':
|
|
797
|
+
return toolGrep(root, a.pattern, a.path, a.ignore_case === true);
|
|
798
|
+
case 'apply_patch':
|
|
799
|
+
return toolApplyPatch(root, a.path, a.edits);
|
|
800
|
+
case 'write_file':
|
|
801
|
+
return toolWriteFile(root, a.path, a.content);
|
|
802
|
+
case 'bash':
|
|
803
|
+
return toolBash(root, a.command, a.timeout_ms);
|
|
804
|
+
default:
|
|
805
|
+
throw new Error(`未知工具:${name}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* 写入批准弹窗需要的预览。
|
|
811
|
+
* `old` 是改动前的原文(截断),供审批界面渲染行级 diff —— 覆盖已有文件时,
|
|
812
|
+
* 只看新内容根本判断不出改了什么,这是审批闸门的关键一环。
|
|
813
|
+
*/
|
|
814
|
+
export function writePreview(root, args = {}) {
|
|
815
|
+
const name = args.__tool || 'write_file';
|
|
816
|
+
|
|
817
|
+
// MCP 工具没有本地文件可预览:给「服务器 · 工具 + 参数 JSON」,
|
|
818
|
+
// 让批准弹窗照样说明「这次要干什么」(复用既有面板,不改前端渲染)
|
|
819
|
+
if (isMcpTool(name)) return mcpPreview(name, args);
|
|
820
|
+
|
|
821
|
+
if (name === 'bash') {
|
|
822
|
+
const command = typeof args.command === 'string' ? args.command : '';
|
|
823
|
+
return { path: `$ ${command}`, exists: false, bytes: Buffer.byteLength(command), lines: 1, content: command, old: null, shell: true };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const isPatch = name === 'apply_patch';
|
|
827
|
+
let target = args.path || '?';
|
|
828
|
+
let exists = false;
|
|
829
|
+
let old = null;
|
|
830
|
+
let content = '';
|
|
831
|
+
let note = '';
|
|
832
|
+
|
|
833
|
+
if (isPatch) {
|
|
834
|
+
try {
|
|
835
|
+
const { abs, text } = readTextInRoot(root, args.path);
|
|
836
|
+
target = relTo(root, abs);
|
|
837
|
+
old = text;
|
|
838
|
+
exists = text !== null;
|
|
839
|
+
if (text === null) {
|
|
840
|
+
note = '(文件不存在或不可读,执行时会失败 —— 新建文件请用 write_file)';
|
|
841
|
+
} else {
|
|
842
|
+
try {
|
|
843
|
+
content = applyEdits(text, args.edits).text;
|
|
844
|
+
} catch (e) {
|
|
845
|
+
content = text;
|
|
846
|
+
note = `(无法应用修改:${e?.message || e})`;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
} catch (e) {
|
|
850
|
+
note = `(路径非法:${e?.message || e})`;
|
|
851
|
+
}
|
|
852
|
+
} else {
|
|
853
|
+
content = typeof args.content === 'string' ? args.content : '';
|
|
854
|
+
try {
|
|
855
|
+
const { abs } = readTextInRoot(root, args.path);
|
|
856
|
+
exists = existsSync(abs) && !statSync(abs).isDirectory();
|
|
857
|
+
target = relTo(root, abs);
|
|
858
|
+
old = exists ? readTextInRoot(root, args.path).text : null;
|
|
859
|
+
} catch {
|
|
860
|
+
/* 路径非法就按原样展示,执行时会被沙箱拦下 */
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const clip = (s) => (s && s.length > MAX_PREVIEW_CHARS ? `${s.slice(0, MAX_PREVIEW_CHARS)}\n…(预览截断)` : s);
|
|
865
|
+
return {
|
|
866
|
+
path: target,
|
|
867
|
+
exists,
|
|
868
|
+
bytes: Buffer.byteLength(content),
|
|
869
|
+
lines: countLines(content),
|
|
870
|
+
content: clip(content) || '',
|
|
871
|
+
old: clip(old),
|
|
872
|
+
patch: isPatch,
|
|
873
|
+
note,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ---------- 目录浏览(给「选择工作目录」用,不受沙箱限制) ----------
|
|
878
|
+
|
|
879
|
+
export function browseDir(absPath) {
|
|
880
|
+
const abs = path.resolve(String(absPath || ''));
|
|
881
|
+
if (!existsSync(abs)) throw Object.assign(new Error(`目录不存在:${abs}`), { status: 404 });
|
|
882
|
+
if (!statSync(abs).isDirectory()) throw Object.assign(new Error(`不是目录:${abs}`), { status: 400 });
|
|
883
|
+
|
|
884
|
+
const entries = [];
|
|
885
|
+
for (const name of readdirSync(abs)) {
|
|
886
|
+
let st;
|
|
887
|
+
try {
|
|
888
|
+
st = statSync(path.join(abs, name));
|
|
889
|
+
} catch {
|
|
890
|
+
continue; // 权限不足等,跳过
|
|
891
|
+
}
|
|
892
|
+
entries.push({ name, type: st.isDirectory() ? 'dir' : 'file', size: st.isDirectory() ? null : st.size });
|
|
893
|
+
}
|
|
894
|
+
entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name, 'zh') : a.type === 'dir' ? -1 : 1));
|
|
895
|
+
|
|
896
|
+
const parent = path.dirname(abs);
|
|
897
|
+
return {
|
|
898
|
+
path: abs,
|
|
899
|
+
parent: parent === abs ? null : parent,
|
|
900
|
+
entries: entries.slice(0, MAX_BROWSE_ENTRIES),
|
|
901
|
+
truncated: entries.length > MAX_BROWSE_ENTRIES,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** 可用于起点选择的根目录:盘符 / 主目录 / 当前项目 */
|
|
906
|
+
export function listRoots() {
|
|
907
|
+
const roots = [];
|
|
908
|
+
const add = (p, label) => {
|
|
909
|
+
try {
|
|
910
|
+
if (p && existsSync(p) && statSync(p).isDirectory()) roots.push({ path: p, label });
|
|
911
|
+
} catch {
|
|
912
|
+
/* 忽略无权限的盘符 */
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
add(process.cwd(), '当前项目目录');
|
|
916
|
+
add(os.homedir(), '用户主目录');
|
|
917
|
+
if (process.platform === 'win32') {
|
|
918
|
+
for (let c = 65; c <= 90; c++) {
|
|
919
|
+
const letter = String.fromCharCode(c);
|
|
920
|
+
add(`${letter}:\\`, `${letter}: 盘`);
|
|
921
|
+
}
|
|
922
|
+
} else {
|
|
923
|
+
for (const d of ['/', '/home', '/mnt', '/media', '/opt', '/srv', '/tmp']) add(d, d);
|
|
924
|
+
}
|
|
925
|
+
const seen = new Set();
|
|
926
|
+
return roots.filter((r) => (seen.has(r.path) ? false : (seen.add(r.path), true)));
|
|
927
|
+
}
|