@zhin.js/agent 0.0.18 → 0.0.19
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/CHANGELOG.md +9 -0
- package/README.md +14 -8
- package/lib/builtin-tools.d.ts +4 -0
- package/lib/builtin-tools.d.ts.map +1 -1
- package/lib/builtin-tools.js +337 -29
- package/lib/builtin-tools.js.map +1 -1
- package/lib/file-policy.d.ts +41 -4
- package/lib/file-policy.d.ts.map +1 -1
- package/lib/file-policy.js +126 -4
- package/lib/file-policy.js.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/init/create-zhin-agent.d.ts.map +1 -1
- package/lib/init/create-zhin-agent.js +1 -0
- package/lib/init/create-zhin-agent.js.map +1 -1
- package/lib/init/register-builtin-tools.d.ts.map +1 -1
- package/lib/init/register-builtin-tools.js +1 -0
- package/lib/init/register-builtin-tools.js.map +1 -1
- package/lib/zhin-agent/config.js +1 -1
- package/lib/zhin-agent/config.js.map +1 -1
- package/lib/zhin-agent/exec-policy.d.ts +48 -2
- package/lib/zhin-agent/exec-policy.d.ts.map +1 -1
- package/lib/zhin-agent/exec-policy.js +184 -23
- package/lib/zhin-agent/exec-policy.js.map +1 -1
- package/lib/zhin-agent/prompt.d.ts +14 -0
- package/lib/zhin-agent/prompt.d.ts.map +1 -1
- package/lib/zhin-agent/prompt.js +192 -45
- package/lib/zhin-agent/prompt.js.map +1 -1
- package/package.json +3 -3
- package/src/builtin-tools.ts +351 -30
- package/src/file-policy.ts +152 -4
- package/src/index.ts +5 -1
- package/src/init/create-zhin-agent.ts +1 -0
- package/src/init/register-builtin-tools.ts +1 -0
- package/src/zhin-agent/config.ts +1 -1
- package/src/zhin-agent/exec-policy.ts +229 -24
- package/src/zhin-agent/prompt.ts +209 -47
- package/tests/exec-policy.test.ts +355 -0
- package/tests/file-policy.test.ts +189 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prompt.js","sourceRoot":"","sources":["../../src/zhin-agent/prompt.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"prompt.js","sourceRoot":"","sources":["../../src/zhin-agent/prompt.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/agent",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
4
4
|
"description": "Zhin AI Agent — session, ZhinAgent, init; composes @zhin.js/core providers and tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"js-yaml": "^4.1.0",
|
|
17
|
-
"@zhin.js/ai": "1.0.
|
|
18
|
-
"@zhin.js/core": "1.0.
|
|
17
|
+
"@zhin.js/ai": "1.0.17",
|
|
18
|
+
"@zhin.js/core": "1.0.56"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@types/js-yaml": "^4.0.9",
|
package/src/builtin-tools.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* 计划: todo_read, todo_write
|
|
8
8
|
* 记忆: read_memory, write_memory (AGENTS.md)
|
|
9
9
|
* 技能: activate_skill, install_skill
|
|
10
|
+
* 交互: ask_user(基于 Prompt 类的用户确认/提问工具)
|
|
10
11
|
*
|
|
11
12
|
* 发现逻辑已拆分到 discover-skills.ts / discover-agents.ts / discover-tools.ts
|
|
12
13
|
*/
|
|
@@ -15,9 +16,13 @@ import * as fs from 'fs';
|
|
|
15
16
|
import * as path from 'path';
|
|
16
17
|
import { exec } from 'child_process';
|
|
17
18
|
import { promisify } from 'util';
|
|
18
|
-
import { Logger, type PropertySchema } from '@zhin.js/core';
|
|
19
|
+
import { Logger, Prompt, type Plugin, type PropertySchema } from '@zhin.js/core';
|
|
19
20
|
import { ZhinTool } from '@zhin.js/core';
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
assertFileAccess, checkBashCommandSafety, shellEscape,
|
|
23
|
+
isBlockedDevicePath, MAX_READ_FILE_SIZE, MAX_EDIT_FILE_SIZE,
|
|
24
|
+
classifyBashCommand, getFileMtime, isFileStale,
|
|
25
|
+
} from './file-policy.js';
|
|
21
26
|
import {
|
|
22
27
|
errMsg, expandHome, getDataDir, mergeSkillDirsWithResolver, nodeErrToFileMessage,
|
|
23
28
|
} from './discovery-utils.js';
|
|
@@ -26,11 +31,119 @@ import { checkSkillDeps, extractSkillInstructions } from './discover-skills.js';
|
|
|
26
31
|
const execAsync = promisify(exec);
|
|
27
32
|
const logger = new Logger(null, 'builtin-tools');
|
|
28
33
|
|
|
34
|
+
// ── 引号归一化 + 模糊匹配(参考 Claude Code FileEditTool/utils.ts) ──
|
|
35
|
+
|
|
36
|
+
/** 将弯引号归一化为直引号 */
|
|
37
|
+
function normalizeQuotes(str: string): string {
|
|
38
|
+
return str
|
|
39
|
+
.replace(/\u2018/g, "'") // '
|
|
40
|
+
.replace(/\u2019/g, "'") // '
|
|
41
|
+
.replace(/\u201C/g, '"') // "
|
|
42
|
+
.replace(/\u201D/g, '"'); // "
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface FuzzyMatchResult {
|
|
46
|
+
/** 文件中实际匹配到的字符串 */
|
|
47
|
+
actual: string;
|
|
48
|
+
/** 匹配次数 */
|
|
49
|
+
count: number;
|
|
50
|
+
/** 是否通过引号归一化匹配 */
|
|
51
|
+
wasNormalized: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 在文件内容中查找字符串,支持精确匹配和引号归一化模糊匹配。
|
|
56
|
+
* 参考 Claude Code `findActualString`。
|
|
57
|
+
*/
|
|
58
|
+
function findActualStringInFile(fileContent: string, searchString: string): FuzzyMatchResult | null {
|
|
59
|
+
// 精确匹配
|
|
60
|
+
const exactCount = fileContent.split(searchString).length - 1;
|
|
61
|
+
if (exactCount > 0) {
|
|
62
|
+
return { actual: searchString, count: exactCount, wasNormalized: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 引号归一化匹配
|
|
66
|
+
const normalizedSearch = normalizeQuotes(searchString);
|
|
67
|
+
const normalizedFile = normalizeQuotes(fileContent);
|
|
68
|
+
const idx = normalizedFile.indexOf(normalizedSearch);
|
|
69
|
+
if (idx !== -1) {
|
|
70
|
+
// 提取文件中实际的字符串(保留原始弯引号)
|
|
71
|
+
const actual = fileContent.substring(idx, idx + searchString.length);
|
|
72
|
+
const normalizedCount = normalizedFile.split(normalizedSearch).length - 1;
|
|
73
|
+
return { actual, count: normalizedCount, wasNormalized: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 将 new_string 中的直引号替换为文件中原始的弯引号风格。
|
|
81
|
+
* 参考 Claude Code `preserveQuoteStyle`。
|
|
82
|
+
*/
|
|
83
|
+
function preserveQuoteStyleInEdit(oldString: string, actualOldString: string, newString: string): string {
|
|
84
|
+
if (oldString === actualOldString) return newString;
|
|
85
|
+
|
|
86
|
+
const hasDouble = actualOldString.includes('\u201C') || actualOldString.includes('\u201D');
|
|
87
|
+
const hasSingle = actualOldString.includes('\u2018') || actualOldString.includes('\u2019');
|
|
88
|
+
if (!hasDouble && !hasSingle) return newString;
|
|
89
|
+
|
|
90
|
+
let result = newString;
|
|
91
|
+
if (hasDouble) {
|
|
92
|
+
// 简单启发式:前面是空白/行首时用左引号,否则右引号
|
|
93
|
+
const chars = [...result];
|
|
94
|
+
const out: string[] = [];
|
|
95
|
+
for (let i = 0; i < chars.length; i++) {
|
|
96
|
+
if (chars[i] === '"') {
|
|
97
|
+
const prev = i > 0 ? chars[i - 1] : ' ';
|
|
98
|
+
const isOpening = /[\s(\[{]/.test(prev) || i === 0;
|
|
99
|
+
out.push(isOpening ? '\u201C' : '\u201D');
|
|
100
|
+
} else {
|
|
101
|
+
out.push(chars[i]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
result = out.join('');
|
|
105
|
+
}
|
|
106
|
+
if (hasSingle) {
|
|
107
|
+
const chars = [...result];
|
|
108
|
+
const out: string[] = [];
|
|
109
|
+
for (let i = 0; i < chars.length; i++) {
|
|
110
|
+
if (chars[i] === "'") {
|
|
111
|
+
const prev = i > 0 ? chars[i - 1] : ' ';
|
|
112
|
+
const next = i < chars.length - 1 ? chars[i + 1] : ' ';
|
|
113
|
+
// 两个字母之间是缩写,用右引号
|
|
114
|
+
if (/\p{L}/u.test(prev) && /\p{L}/u.test(next)) {
|
|
115
|
+
out.push('\u2019');
|
|
116
|
+
} else {
|
|
117
|
+
const isOpening = /[\s(\[{]/.test(prev) || i === 0;
|
|
118
|
+
out.push(isOpening ? '\u2018' : '\u2019');
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
out.push(chars[i]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
result = out.join('');
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 图片格式检测(参考 Claude Code FileReadTool imageResizer) ──
|
|
130
|
+
|
|
131
|
+
/** 支持的图片扩展名 */
|
|
132
|
+
const IMAGE_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
133
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico',
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
function isImageFile(filePath: string): boolean {
|
|
137
|
+
return IMAGE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
138
|
+
}
|
|
139
|
+
|
|
29
140
|
// ============================================================================
|
|
30
141
|
// 工具工厂函数
|
|
31
142
|
// ============================================================================
|
|
32
143
|
|
|
33
144
|
export interface BuiltinToolsOptions {
|
|
145
|
+
/** 插件实例,用于 ask_user 工具创建 Prompt 交互 */
|
|
146
|
+
plugin?: Plugin;
|
|
34
147
|
/** Max chars for skill instruction extraction (model-size-aware) */
|
|
35
148
|
skillInstructionMaxChars?: number;
|
|
36
149
|
/**
|
|
@@ -52,13 +165,14 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
|
52
165
|
const skillMaxChars = options?.skillInstructionMaxChars ?? 4000;
|
|
53
166
|
const skillDirList = () => mergeSkillDirsWithResolver(options?.pluginSkillRootsResolver);
|
|
54
167
|
const skillFileLookup = options?.skillFileLookup;
|
|
168
|
+
const pluginRef = options?.plugin;
|
|
55
169
|
|
|
56
170
|
const tools: ZhinTool[] = [];
|
|
57
171
|
|
|
58
|
-
// ── read_file(清晰描述 +
|
|
172
|
+
// ── read_file(清晰描述 + 强关键词 + 图片检测 + 安全防护) ──
|
|
59
173
|
tools.push(
|
|
60
174
|
new ZhinTool('read_file')
|
|
61
|
-
.desc('
|
|
175
|
+
.desc('读取指定路径的文件内容。用于查看、打开或读取任意文本文件。图片文件返回 Base64 数据。')
|
|
62
176
|
.keyword('读文件', '读取文件', '查看文件', '打开文件', '文件内容', 'read file', 'read', 'cat', '查看', '打开')
|
|
63
177
|
.tag('file', 'read')
|
|
64
178
|
.kind('file')
|
|
@@ -68,8 +182,27 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
|
68
182
|
.execute(async (args) => {
|
|
69
183
|
try {
|
|
70
184
|
const fp = expandHome(args.file_path);
|
|
185
|
+
// 设备路径拦截(参考 Claude Code BLOCKED_DEVICE_PATHS)
|
|
186
|
+
if (isBlockedDevicePath(fp)) {
|
|
187
|
+
return `Error: 禁止读取设备文件 ${fp}(会导致进程挂起或注入攻击)`;
|
|
188
|
+
}
|
|
71
189
|
assertFileAccess(fp);
|
|
72
190
|
const stat = await fs.promises.stat(fp);
|
|
191
|
+
// 文件大小限制(参考 Claude Code MAX_EDIT_FILE_SIZE)
|
|
192
|
+
if (stat.size > MAX_READ_FILE_SIZE) {
|
|
193
|
+
return `Error: 文件过大 (${(stat.size / 1024 / 1024).toFixed(1)} MiB),超过 ${MAX_READ_FILE_SIZE / 1024 / 1024} MiB 限制。请使用 offset/limit 分段读取。`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 图片文件检测(参考 Claude Code FileReadTool 的图片处理)
|
|
197
|
+
if (isImageFile(fp)) {
|
|
198
|
+
const buffer = await fs.promises.readFile(fp);
|
|
199
|
+
const ext = path.extname(fp).toLowerCase().replace('.', '');
|
|
200
|
+
const mimeType = ext === 'jpg' ? 'jpeg' : ext === 'svg' ? 'svg+xml' : ext;
|
|
201
|
+
const b64 = buffer.toString('base64');
|
|
202
|
+
const sizeKb = (buffer.length / 1024).toFixed(1);
|
|
203
|
+
return `[Image: ${path.basename(fp)}, ${sizeKb} KB, type: image/${mimeType}]\ndata:image/${mimeType};base64,${b64.slice(0, 200)}...(total ${b64.length} chars)`;
|
|
204
|
+
}
|
|
205
|
+
|
|
73
206
|
const content = await fs.promises.readFile(fp, 'utf-8');
|
|
74
207
|
const lines = content.split('\n');
|
|
75
208
|
const offset = args.offset ?? 0;
|
|
@@ -105,10 +238,10 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
|
105
238
|
}),
|
|
106
239
|
);
|
|
107
240
|
|
|
108
|
-
// ── edit_file
|
|
241
|
+
// ── edit_file(支持精确匹配 + 引号归一化模糊匹配)──
|
|
109
242
|
tools.push(
|
|
110
243
|
new ZhinTool('edit_file')
|
|
111
|
-
.desc('在文件中查找并替换一段文本。old_string
|
|
244
|
+
.desc('在文件中查找并替换一段文本。old_string 必须在文件中精确存在且唯一;建议包含完整行或足够上下文以避免重复匹配。支持弯引号/直引号自动归一化。')
|
|
112
245
|
.keyword('编辑文件', '修改文件', '替换内容', '查找替换', 'edit file', 'edit', '修改', '替换')
|
|
113
246
|
.tag('file', 'edit')
|
|
114
247
|
.kind('file')
|
|
@@ -119,11 +252,32 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
|
119
252
|
try {
|
|
120
253
|
const fp = expandHome(args.file_path);
|
|
121
254
|
assertFileAccess(fp);
|
|
255
|
+
// 文件大小限制
|
|
256
|
+
const stat = await fs.promises.stat(fp);
|
|
257
|
+
if (stat.size > MAX_EDIT_FILE_SIZE) {
|
|
258
|
+
return `Error: 文件过大 (${(stat.size / 1024 / 1024).toFixed(1)} MiB),超过 ${MAX_EDIT_FILE_SIZE / 1024 / 1024} MiB 限制。`;
|
|
259
|
+
}
|
|
260
|
+
// 记录 mtime 用于防并发覆写
|
|
261
|
+
const mtimeBefore = stat.mtimeMs;
|
|
122
262
|
const content = await fs.promises.readFile(fp, 'utf-8');
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
263
|
+
|
|
264
|
+
// 精确匹配 → 引号归一化模糊匹配
|
|
265
|
+
const matchResult = findActualStringInFile(content, args.old_string);
|
|
266
|
+
if (!matchResult) return `Error: old_string not found in file. Make sure it matches exactly (also tried quote normalization).`;
|
|
267
|
+
if (matchResult.count > 1) return `Warning: old_string appears ${matchResult.count} times. Please provide more context to make it unique.`;
|
|
268
|
+
|
|
269
|
+
// 如果通过引号归一化匹配,保持文件的引号风格
|
|
270
|
+
const effectiveNew = matchResult.wasNormalized
|
|
271
|
+
? preserveQuoteStyleInEdit(args.old_string, matchResult.actual, args.new_string)
|
|
272
|
+
: args.new_string;
|
|
273
|
+
|
|
274
|
+
const newContent = content.replace(matchResult.actual, effectiveNew);
|
|
275
|
+
|
|
276
|
+
// 写入前再检查 mtime 防止并发修改
|
|
277
|
+
const currentStat = await fs.promises.stat(fp);
|
|
278
|
+
if (isFileStale(mtimeBefore, currentStat.mtimeMs)) {
|
|
279
|
+
return `Error: 文件 ${fp} 在读取后被外部修改。请重新读取文件后再编辑,避免覆盖他人的修改。`;
|
|
280
|
+
}
|
|
127
281
|
await fs.promises.writeFile(fp, newContent, 'utf-8');
|
|
128
282
|
|
|
129
283
|
const oldLines = args.old_string.split('\n');
|
|
@@ -195,29 +349,68 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
|
195
349
|
}),
|
|
196
350
|
);
|
|
197
351
|
|
|
198
|
-
// ── grep ──
|
|
352
|
+
// ── grep(支持上下文行、大小写、多行、ripgrep 自动检测) ──
|
|
199
353
|
tools.push(
|
|
200
354
|
new ZhinTool('grep')
|
|
201
|
-
.desc('
|
|
202
|
-
.keyword('搜索', '查找内容', 'grep', '正则')
|
|
355
|
+
.desc('按正则搜索文件内容,返回匹配行和行号。优先使用 ripgrep (rg),回退到 grep。')
|
|
356
|
+
.keyword('搜索', '查找内容', 'grep', '正则', 'rg', 'ripgrep')
|
|
203
357
|
.tag('search', 'regex')
|
|
204
358
|
.kind('file')
|
|
205
359
|
.param('pattern', { type: 'string', description: '正则表达式' }, true)
|
|
206
360
|
.param('path', { type: 'string', description: '搜索路径(默认 .)' })
|
|
207
361
|
.param('include', { type: 'string', description: '文件类型过滤(如 *.ts)' })
|
|
362
|
+
.param('context', { type: 'number', description: '匹配行上下文行数(-C 参数)' })
|
|
363
|
+
.param('before', { type: 'number', description: '匹配行之前显示行数(-B 参数)' })
|
|
364
|
+
.param('after', { type: 'number', description: '匹配行之后显示行数(-A 参数)' })
|
|
365
|
+
.param('ignore_case', { type: 'boolean', description: '大小写不敏感搜索(-i 参数)' } as any)
|
|
366
|
+
.param('multiline', { type: 'boolean', description: '多行模式,. 匹配换行(仅 ripgrep 支持)' } as any)
|
|
367
|
+
.param('limit', { type: 'number', description: '最多返回结果行数(默认 50)' })
|
|
208
368
|
.execute(async (args) => {
|
|
209
369
|
try {
|
|
210
370
|
const searchPath = args.path || '.';
|
|
211
371
|
assertFileAccess(path.resolve(process.cwd(), searchPath));
|
|
212
|
-
// 安全转义 pattern 和 include 参数防止命令注入
|
|
213
372
|
const safePattern = shellEscape(args.pattern);
|
|
214
373
|
const safePath = shellEscape(searchPath);
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
374
|
+
const limit = args.limit ?? 50;
|
|
375
|
+
|
|
376
|
+
// 检测 ripgrep 是否可用
|
|
377
|
+
let useRipgrep = false;
|
|
378
|
+
try {
|
|
379
|
+
await execAsync('rg --version', { timeout: 3000 });
|
|
380
|
+
useRipgrep = true;
|
|
381
|
+
} catch { /* ripgrep 不可用,回退到 grep */ }
|
|
382
|
+
|
|
383
|
+
let cmd: string;
|
|
384
|
+
if (useRipgrep) {
|
|
385
|
+
// ripgrep 命令构建
|
|
386
|
+
const rgFlags: string[] = ['-n']; // 行号
|
|
387
|
+
if (args.ignore_case) rgFlags.push('-i');
|
|
388
|
+
if (args.multiline) rgFlags.push('-U', '--multiline-dotall');
|
|
389
|
+
if (args.context) rgFlags.push(`-C${args.context}`);
|
|
390
|
+
else {
|
|
391
|
+
if (args.before) rgFlags.push(`-B${args.before}`);
|
|
392
|
+
if (args.after) rgFlags.push(`-A${args.after}`);
|
|
393
|
+
}
|
|
394
|
+
if (args.include) rgFlags.push(`--glob=${shellEscape(args.include)}`);
|
|
395
|
+
cmd = `rg ${rgFlags.join(' ')} ${safePattern} ${safePath} 2>/dev/null | head -${limit}`;
|
|
396
|
+
} else {
|
|
397
|
+
// 传统 grep 回退
|
|
398
|
+
const grepFlags: string[] = ['-rn'];
|
|
399
|
+
if (args.ignore_case) grepFlags.push('-i');
|
|
400
|
+
if (args.context) grepFlags.push(`-C${args.context}`);
|
|
401
|
+
else {
|
|
402
|
+
if (args.before) grepFlags.push(`-B${args.before}`);
|
|
403
|
+
if (args.after) grepFlags.push(`-A${args.after}`);
|
|
404
|
+
}
|
|
405
|
+
const includeFlag = args.include ? `--include=${shellEscape(args.include)}` : '';
|
|
406
|
+
cmd = `grep ${grepFlags.join(' ')} ${includeFlag} ${safePattern} ${safePath} 2>/dev/null | head -${limit}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const { stdout } = await execAsync(cmd, { cwd: process.cwd() });
|
|
410
|
+
const engine = useRipgrep ? '(ripgrep)' : '(grep)';
|
|
411
|
+
return stdout.trim()
|
|
412
|
+
? `${engine}\n${stdout.trim()}`
|
|
413
|
+
: `No matches for '${args.pattern}' ${engine}`;
|
|
221
414
|
} catch (e: unknown) {
|
|
222
415
|
const err = e as { code?: number; message?: string };
|
|
223
416
|
if (err.code === 1) return `No matches for '${args.pattern}'`;
|
|
@@ -226,10 +419,10 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
|
226
419
|
}),
|
|
227
420
|
);
|
|
228
421
|
|
|
229
|
-
// ── bash ──
|
|
422
|
+
// ── bash(安全检查 + 命令读写分类) ──
|
|
230
423
|
tools.push(
|
|
231
424
|
new ZhinTool('bash')
|
|
232
|
-
.desc('执行 Shell
|
|
425
|
+
.desc('执行 Shell 命令(带超时保护和命令分类)。返回结果中会标注命令类型(只读/搜索/写入)。')
|
|
233
426
|
.keyword('执行', '运行', '命令', '终端', 'shell', 'bash')
|
|
234
427
|
.tag('shell', 'exec')
|
|
235
428
|
.kind('shell')
|
|
@@ -243,15 +436,20 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
|
243
436
|
// 检查命令是否可能泄漏敏感信息
|
|
244
437
|
const safety = checkBashCommandSafety(cmd);
|
|
245
438
|
if (!safety.safe) return `Error: ${safety.reason}`;
|
|
439
|
+
// 命令读写分类
|
|
440
|
+
const classification = classifyBashCommand(cmd);
|
|
246
441
|
const { stdout, stderr } = await execAsync(cmd, {
|
|
247
442
|
cwd: args.cwd || process.cwd(),
|
|
248
443
|
timeout,
|
|
249
444
|
maxBuffer: 1024 * 1024,
|
|
250
445
|
});
|
|
251
446
|
let result = '';
|
|
447
|
+
const tag = classification.isReadOnly
|
|
448
|
+
? (classification.isSearch ? '[搜索]' : classification.isList ? '[列出]' : '[只读]')
|
|
449
|
+
: '[执行]';
|
|
252
450
|
if (stdout.trim()) result += `STDOUT:\n${stdout.trim()}`;
|
|
253
451
|
if (stderr.trim()) result += `${result ? '\n' : ''}STDERR:\n${stderr.trim()}`;
|
|
254
|
-
return result || '(no output)'
|
|
452
|
+
return `${tag} ${result || '(no output)'}`;
|
|
255
453
|
} catch (e: unknown) {
|
|
256
454
|
const err = e as { code?: number; message?: string; stdout?: string; stderr?: string };
|
|
257
455
|
return `Error (exit ${err.code || '?'}): ${errMsg(e)}\nSTDOUT:\n${err.stdout || ''}\nSTDERR:\n${err.stderr || ''}`;
|
|
@@ -259,17 +457,27 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
|
259
457
|
}),
|
|
260
458
|
);
|
|
261
459
|
|
|
262
|
-
// ── web_search(搜索网页,返回标题、URL
|
|
460
|
+
// ── web_search(搜索网页,返回标题、URL、摘要 + 域名过滤 + 次数限制) ──
|
|
461
|
+
let searchCount = 0;
|
|
462
|
+
const MAX_SEARCH_COUNT = 20; // 单次会话搜索次数上限
|
|
263
463
|
tools.push(
|
|
264
464
|
new ZhinTool('web_search')
|
|
265
|
-
.desc('在互联网上搜索,返回匹配的标题、URL
|
|
465
|
+
.desc('在互联网上搜索,返回匹配的标题、URL 和摘要片段。用于查资料、找网页。支持域名过滤。')
|
|
266
466
|
.keyword('搜索', '网上搜', '网页搜索', '搜索引擎', 'search', 'google', '百度', '查询', '搜一下')
|
|
267
467
|
.tag('web', 'search')
|
|
268
468
|
.kind('web')
|
|
269
469
|
.param('query', { type: 'string', description: '搜索关键词或完整查询语句' }, true)
|
|
270
470
|
.param('limit', { type: 'number', description: '返回结果数量(默认 5,建议 1–10)' })
|
|
471
|
+
.param('allowed_domains', { type: 'array', description: '仅保留这些域名的结果(可选,如 ["github.com", "stackoverflow.com"])' } as any)
|
|
472
|
+
.param('blocked_domains', { type: 'array', description: '排除这些域名的结果(可选)' } as any)
|
|
271
473
|
.execute(async (args) => {
|
|
272
474
|
try {
|
|
475
|
+
// 搜索次数限制
|
|
476
|
+
searchCount++;
|
|
477
|
+
if (searchCount > MAX_SEARCH_COUNT) {
|
|
478
|
+
return `Error: 搜索次数已达上限 (${MAX_SEARCH_COUNT})。请使用已获取的信息回答。`;
|
|
479
|
+
}
|
|
480
|
+
|
|
273
481
|
const limit = args.limit ?? 5;
|
|
274
482
|
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(args.query)}`;
|
|
275
483
|
const res = await fetch(url, {
|
|
@@ -312,8 +520,23 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
|
312
520
|
}
|
|
313
521
|
}
|
|
314
522
|
|
|
315
|
-
|
|
316
|
-
|
|
523
|
+
// 域名过滤
|
|
524
|
+
let filtered = results;
|
|
525
|
+
if (args.allowed_domains?.length) {
|
|
526
|
+
const allowed = new Set((args.allowed_domains as string[]).map(d => d.toLowerCase()));
|
|
527
|
+
filtered = filtered.filter(r => {
|
|
528
|
+
try { return allowed.has(new URL(r.url).hostname.toLowerCase()); } catch { return false; }
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
if (args.blocked_domains?.length) {
|
|
532
|
+
const blocked = new Set((args.blocked_domains as string[]).map(d => d.toLowerCase()));
|
|
533
|
+
filtered = filtered.filter(r => {
|
|
534
|
+
try { return !blocked.has(new URL(r.url).hostname.toLowerCase()); } catch { return true; }
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (filtered.length === 0) return 'No results found.';
|
|
539
|
+
return `(${searchCount}/${MAX_SEARCH_COUNT} searches)\n` + filtered.map((r, i) =>
|
|
317
540
|
`${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.snippet}`,
|
|
318
541
|
).join('\n\n');
|
|
319
542
|
} catch (e: unknown) {
|
|
@@ -322,29 +545,67 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
|
322
545
|
}),
|
|
323
546
|
);
|
|
324
547
|
|
|
325
|
-
// ── web_fetch(抓取 URL
|
|
548
|
+
// ── web_fetch(抓取 URL 并提取正文 + SSRF 防护 + 改进的内容提取) ──
|
|
326
549
|
tools.push(
|
|
327
550
|
new ZhinTool('web_fetch')
|
|
328
|
-
.desc('抓取指定 URL
|
|
551
|
+
.desc('抓取指定 URL 的网页内容并提取正文(去除广告、脚本等),返回可读文本。仅支持 http/https 协议。')
|
|
329
552
|
.keyword('抓取网页', '打开链接', '获取网页', '读网页', 'fetch', 'url', '链接内容', '网页内容')
|
|
330
553
|
.tag('web', 'fetch')
|
|
331
554
|
.kind('web')
|
|
332
555
|
.param('url', { type: 'string', description: '要抓取的完整 URL(需 http 或 https)' }, true)
|
|
556
|
+
.param('max_length', { type: 'number', description: '最大返回字符数(默认 20480)' })
|
|
333
557
|
.execute(async (args) => {
|
|
334
558
|
try {
|
|
559
|
+
// SSRF 防护:仅允许 http/https 协议
|
|
560
|
+
let parsedUrl: URL;
|
|
561
|
+
try {
|
|
562
|
+
parsedUrl = new URL(args.url);
|
|
563
|
+
} catch {
|
|
564
|
+
return `Error: 无效的 URL 格式`;
|
|
565
|
+
}
|
|
566
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
567
|
+
return `Error: 仅支持 http/https 协议,拒绝 ${parsedUrl.protocol}`;
|
|
568
|
+
}
|
|
569
|
+
// 阻止内网地址(SSRF 关键防护)
|
|
570
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
571
|
+
if (
|
|
572
|
+
hostname === 'localhost' ||
|
|
573
|
+
hostname === '127.0.0.1' ||
|
|
574
|
+
hostname === '::1' ||
|
|
575
|
+
hostname === '0.0.0.0' ||
|
|
576
|
+
hostname.endsWith('.local') ||
|
|
577
|
+
hostname.startsWith('10.') ||
|
|
578
|
+
hostname.startsWith('192.168.') ||
|
|
579
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
|
|
580
|
+
) {
|
|
581
|
+
return `Error: 禁止访问内网地址 ${hostname}(SSRF 防护)`;
|
|
582
|
+
}
|
|
583
|
+
|
|
335
584
|
const response = await fetch(args.url, {
|
|
336
585
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ZhinBot/1.0)' },
|
|
337
586
|
signal: AbortSignal.timeout(15000),
|
|
587
|
+
redirect: 'follow',
|
|
338
588
|
});
|
|
339
589
|
if (!response.ok) return `HTTP ${response.status}: ${response.statusText}`;
|
|
340
590
|
const html = await response.text();
|
|
591
|
+
// 改进的内容提取:去除脚本、样式、导航、页脚、表单等
|
|
341
592
|
const text = html
|
|
342
593
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
343
594
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
595
|
+
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
|
596
|
+
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
|
|
597
|
+
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, ' ')
|
|
598
|
+
.replace(/<form[^>]*>[\s\S]*?<\/form>/gi, '')
|
|
599
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
344
600
|
.replace(/<[^>]+>/g, ' ')
|
|
601
|
+
.replace(/ /gi, ' ')
|
|
602
|
+
.replace(/&/g, '&')
|
|
603
|
+
.replace(/</g, '<')
|
|
604
|
+
.replace(/>/g, '>')
|
|
605
|
+
.replace(/"/g, '"')
|
|
345
606
|
.replace(/\s+/g, ' ')
|
|
346
607
|
.trim();
|
|
347
|
-
const maxLen = 20 * 1024;
|
|
608
|
+
const maxLen = args.max_length ?? 20 * 1024;
|
|
348
609
|
return text.length > maxLen ? text.slice(0, maxLen) + '\n...(truncated)' : text;
|
|
349
610
|
} catch (e: unknown) {
|
|
350
611
|
return `Error: ${errMsg(e)}`;
|
|
@@ -527,5 +788,65 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
|
527
788
|
}),
|
|
528
789
|
);
|
|
529
790
|
|
|
791
|
+
// ── ask_user(基于 Prompt 类的用户确认/提问工具) ──
|
|
792
|
+
tools.push(
|
|
793
|
+
new ZhinTool('ask_user')
|
|
794
|
+
.desc('向用户发送问题,等待用户在聊天中回复。用于需要用户确认、补充信息或做出选择时。支持文本输入、数字输入、是/否确认、选项选择。')
|
|
795
|
+
.keyword('询问', '确认', '提问', '用户输入', 'ask', 'confirm', 'prompt', '选择', '请问')
|
|
796
|
+
.tag('interaction', 'prompt')
|
|
797
|
+
.kind('interaction')
|
|
798
|
+
.param('question', { type: 'string', description: '要向用户提出的问题文本' }, true)
|
|
799
|
+
.param('type', { type: 'string', description: '问题类型: text(文本输入)、number(数字输入)、confirm(是/否确认)、pick(选项选择)。默认 text' })
|
|
800
|
+
.param('options', { type: 'array', description: '选项列表(type=pick 时必填),每项为字符串,如 ["选项A","选项B","选项C"]' })
|
|
801
|
+
.param('default_value', { type: 'string', description: '用户超时未回复时使用的默认值' })
|
|
802
|
+
.param('timeout', { type: 'number', description: '等待用户回复的超时时间(秒),默认 120' })
|
|
803
|
+
.execute(async (args, context) => {
|
|
804
|
+
// 无消息上下文时无法使用(如子任务场景)
|
|
805
|
+
if (!context?.message) {
|
|
806
|
+
return 'Error: 当前上下文没有消息来源,无法向用户提问。请改为在回复中直接询问。';
|
|
807
|
+
}
|
|
808
|
+
if (!pluginRef) {
|
|
809
|
+
return 'Error: 插件实例不可用,无法创建交互式提问。请改为在回复中直接询问。';
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const prompt = new Prompt(pluginRef, context.message);
|
|
813
|
+
const timeoutMs = (args.timeout ?? 120) * 1000;
|
|
814
|
+
const questionType = args.type || 'text';
|
|
815
|
+
|
|
816
|
+
try {
|
|
817
|
+
switch (questionType) {
|
|
818
|
+
case 'number': {
|
|
819
|
+
const defaultNum = args.default_value != null ? Number(args.default_value) : undefined;
|
|
820
|
+
const result = await prompt.number(args.question, timeoutMs, defaultNum, '输入超时,已取消');
|
|
821
|
+
return String(result);
|
|
822
|
+
}
|
|
823
|
+
case 'confirm': {
|
|
824
|
+
const result = await prompt.confirm(args.question, 'yes', timeoutMs, false, '确认超时,已取消');
|
|
825
|
+
return result ? 'yes' : 'no';
|
|
826
|
+
}
|
|
827
|
+
case 'pick': {
|
|
828
|
+
if (!args.options?.length) {
|
|
829
|
+
return 'Error: type=pick 时必须提供 options 选项列表';
|
|
830
|
+
}
|
|
831
|
+
const pickOptions = (args.options as string[]).map((o: string) => ({ label: o, value: o }));
|
|
832
|
+
const result = await prompt.pick(args.question, {
|
|
833
|
+
type: 'text' as const,
|
|
834
|
+
options: pickOptions,
|
|
835
|
+
timeout: timeoutMs,
|
|
836
|
+
}, '选择超时,已取消');
|
|
837
|
+
return String(result);
|
|
838
|
+
}
|
|
839
|
+
case 'text':
|
|
840
|
+
default: {
|
|
841
|
+
const result = await prompt.text(args.question, timeoutMs, args.default_value || '', '输入超时,已取消');
|
|
842
|
+
return result;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
} catch (e: unknown) {
|
|
846
|
+
return `用户未响应或输入错误: ${errMsg(e)}`;
|
|
847
|
+
}
|
|
848
|
+
}),
|
|
849
|
+
);
|
|
850
|
+
|
|
530
851
|
return tools;
|
|
531
852
|
}
|