@zhin.js/agent 0.1.15 → 0.1.17
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 +80 -43
- package/lib/builtin/activate-skill-tool.d.ts +21 -0
- package/lib/builtin/activate-skill-tool.d.ts.map +1 -0
- package/lib/builtin/activate-skill-tool.js +57 -0
- package/lib/builtin/activate-skill-tool.js.map +1 -0
- package/lib/builtin/ask-user-tool.d.ts +28 -0
- package/lib/builtin/ask-user-tool.d.ts.map +1 -0
- package/lib/builtin/ask-user-tool.js +182 -0
- package/lib/builtin/ask-user-tool.js.map +1 -0
- package/lib/builtin/bash-tool.d.ts +23 -0
- package/lib/builtin/bash-tool.d.ts.map +1 -0
- package/lib/builtin/bash-tool.js +64 -0
- package/lib/builtin/bash-tool.js.map +1 -0
- package/lib/builtin/bing-search-html.d.ts +37 -0
- package/lib/builtin/bing-search-html.d.ts.map +1 -0
- package/lib/builtin/bing-search-html.js +116 -0
- package/lib/builtin/bing-search-html.js.map +1 -0
- package/lib/builtin/builtin-base-tool.d.ts +25 -0
- package/lib/builtin/builtin-base-tool.d.ts.map +1 -0
- package/lib/builtin/builtin-base-tool.js +30 -0
- package/lib/builtin/builtin-base-tool.js.map +1 -0
- package/lib/builtin/edit-file-tool.d.ts +13 -0
- package/lib/builtin/edit-file-tool.d.ts.map +1 -0
- package/lib/builtin/edit-file-tool.js +87 -0
- package/lib/builtin/edit-file-tool.js.map +1 -0
- package/lib/builtin/file-edit-quote-utils.d.ts +24 -0
- package/lib/builtin/file-edit-quote-utils.d.ts.map +1 -0
- package/lib/builtin/file-edit-quote-utils.js +81 -0
- package/lib/builtin/file-edit-quote-utils.js.map +1 -0
- package/lib/builtin/glob-tool.d.ts +23 -0
- package/lib/builtin/glob-tool.d.ts.map +1 -0
- package/lib/builtin/glob-tool.js +54 -0
- package/lib/builtin/glob-tool.js.map +1 -0
- package/lib/builtin/grep-tool.d.ts +23 -0
- package/lib/builtin/grep-tool.d.ts.map +1 -0
- package/lib/builtin/grep-tool.js +118 -0
- package/lib/builtin/grep-tool.js.map +1 -0
- package/lib/builtin/install-skill-tool.d.ts +24 -0
- package/lib/builtin/install-skill-tool.d.ts.map +1 -0
- package/lib/builtin/install-skill-tool.js +76 -0
- package/lib/builtin/install-skill-tool.js.map +1 -0
- package/lib/builtin/list-dir-tool.d.ts +13 -0
- package/lib/builtin/list-dir-tool.d.ts.map +1 -0
- package/lib/builtin/list-dir-tool.js +59 -0
- package/lib/builtin/list-dir-tool.js.map +1 -0
- package/lib/builtin/read-file-tool.d.ts +14 -0
- package/lib/builtin/read-file-tool.d.ts.map +1 -0
- package/lib/builtin/read-file-tool.js +77 -0
- package/lib/builtin/read-file-tool.js.map +1 -0
- package/lib/builtin/read-memory-tool.d.ts +14 -0
- package/lib/builtin/read-memory-tool.d.ts.map +1 -0
- package/lib/builtin/read-memory-tool.js +49 -0
- package/lib/builtin/read-memory-tool.js.map +1 -0
- package/lib/builtin/spawn-task-tool.d.ts +20 -0
- package/lib/builtin/spawn-task-tool.d.ts.map +1 -0
- package/lib/builtin/spawn-task-tool.js +57 -0
- package/lib/builtin/spawn-task-tool.js.map +1 -0
- package/lib/builtin/todo-read-tool.d.ts +14 -0
- package/lib/builtin/todo-read-tool.d.ts.map +1 -0
- package/lib/builtin/todo-read-tool.js +56 -0
- package/lib/builtin/todo-read-tool.js.map +1 -0
- package/lib/builtin/todo-write-tool.d.ts +14 -0
- package/lib/builtin/todo-write-tool.d.ts.map +1 -0
- package/lib/builtin/todo-write-tool.js +54 -0
- package/lib/builtin/todo-write-tool.js.map +1 -0
- package/lib/builtin/web-fetch-tool.d.ts +19 -0
- package/lib/builtin/web-fetch-tool.d.ts.map +1 -0
- package/lib/builtin/web-fetch-tool.js +89 -0
- package/lib/builtin/web-fetch-tool.js.map +1 -0
- package/lib/builtin/web-search-locale.d.ts +16 -0
- package/lib/builtin/web-search-locale.d.ts.map +1 -0
- package/lib/builtin/web-search-locale.js +73 -0
- package/lib/builtin/web-search-locale.js.map +1 -0
- package/lib/builtin/web-search-tool.d.ts +20 -0
- package/lib/builtin/web-search-tool.d.ts.map +1 -0
- package/lib/builtin/web-search-tool.js +105 -0
- package/lib/builtin/web-search-tool.js.map +1 -0
- package/lib/builtin/web-tool-utils.d.ts +4 -0
- package/lib/builtin/web-tool-utils.d.ts.map +1 -0
- package/lib/builtin/web-tool-utils.js +4 -0
- package/lib/builtin/web-tool-utils.js.map +1 -0
- package/lib/builtin/write-file-tool.d.ts +13 -0
- package/lib/builtin/write-file-tool.d.ts.map +1 -0
- package/lib/builtin/write-file-tool.js +57 -0
- package/lib/builtin/write-file-tool.js.map +1 -0
- package/lib/builtin/write-memory-tool.d.ts +14 -0
- package/lib/builtin/write-memory-tool.d.ts.map +1 -0
- package/lib/builtin/write-memory-tool.js +50 -0
- package/lib/builtin/write-memory-tool.js.map +1 -0
- package/lib/builtin-tools.d.ts +10 -11
- package/lib/builtin-tools.d.ts.map +1 -1
- package/lib/builtin-tools.js +44 -862
- package/lib/builtin-tools.js.map +1 -1
- package/lib/defaults/tools.d.ts +3 -6
- package/lib/defaults/tools.d.ts.map +1 -1
- package/lib/defaults/tools.js +3 -11
- package/lib/defaults/tools.js.map +1 -1
- package/lib/index.d.ts +28 -4
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +25 -3
- 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 +4 -3
- package/lib/init/create-zhin-agent.js.map +1 -1
- package/lib/init/message-media.d.ts +8 -0
- package/lib/init/message-media.d.ts.map +1 -0
- package/lib/init/message-media.js +75 -0
- package/lib/init/message-media.js.map +1 -0
- package/lib/init/output-renderer.d.ts +3 -0
- package/lib/init/output-renderer.d.ts.map +1 -0
- package/lib/init/output-renderer.js +38 -0
- package/lib/init/output-renderer.js.map +1 -0
- package/lib/init/register-ai-trigger.d.ts +1 -1
- package/lib/init/register-ai-trigger.d.ts.map +1 -1
- package/lib/init/register-ai-trigger.js +35 -159
- package/lib/init/register-ai-trigger.js.map +1 -1
- package/lib/init/register-builtin-tools.d.ts.map +1 -1
- package/lib/init/register-builtin-tools.js +9 -5
- package/lib/init/register-builtin-tools.js.map +1 -1
- package/lib/orchestrator/index.d.ts +2 -0
- package/lib/orchestrator/index.d.ts.map +1 -1
- package/lib/orchestrator/index.js +1 -0
- package/lib/orchestrator/index.js.map +1 -1
- package/lib/orchestrator/owner-confirm-orchestration.d.ts +26 -0
- package/lib/orchestrator/owner-confirm-orchestration.d.ts.map +1 -0
- package/lib/orchestrator/owner-confirm-orchestration.js +87 -0
- package/lib/orchestrator/owner-confirm-orchestration.js.map +1 -0
- package/lib/orchestrator/resource-registry.d.ts +1 -0
- package/lib/orchestrator/resource-registry.d.ts.map +1 -1
- package/lib/orchestrator/resource-registry.js +6 -0
- package/lib/orchestrator/resource-registry.js.map +1 -1
- package/lib/orchestrator/tool-registry.d.ts +5 -11
- package/lib/orchestrator/tool-registry.d.ts.map +1 -1
- package/lib/orchestrator/tool-registry.js +30 -75
- package/lib/orchestrator/tool-registry.js.map +1 -1
- package/lib/orchestrator/tool-selection.d.ts +39 -0
- package/lib/orchestrator/tool-selection.d.ts.map +1 -0
- package/lib/orchestrator/tool-selection.js +319 -0
- package/lib/orchestrator/tool-selection.js.map +1 -0
- package/lib/orchestrator/types.d.ts +2 -0
- package/lib/orchestrator/types.d.ts.map +1 -1
- package/lib/reserved-tools.d.ts +3 -0
- package/lib/reserved-tools.d.ts.map +1 -0
- package/lib/reserved-tools.js +30 -0
- package/lib/reserved-tools.js.map +1 -0
- package/lib/security/exec-policy.d.ts +1 -1
- package/lib/security/exec-policy.js +3 -3
- package/lib/security/exec-policy.js.map +1 -1
- package/lib/service.d.ts +9 -5
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +42 -36
- package/lib/service.js.map +1 -1
- package/lib/subagent.d.ts +6 -0
- package/lib/subagent.d.ts.map +1 -1
- package/lib/subagent.js +38 -15
- package/lib/subagent.js.map +1 -1
- package/lib/task-executor.d.ts +1 -0
- package/lib/task-executor.d.ts.map +1 -1
- package/lib/task-executor.js +15 -8
- package/lib/task-executor.js.map +1 -1
- package/lib/zhin-agent/builtin-tools.d.ts +1 -3
- package/lib/zhin-agent/builtin-tools.d.ts.map +1 -1
- package/lib/zhin-agent/builtin-tools.js +4 -41
- package/lib/zhin-agent/builtin-tools.js.map +1 -1
- package/lib/zhin-agent/config.d.ts +7 -0
- package/lib/zhin-agent/config.d.ts.map +1 -1
- package/lib/zhin-agent/config.js +12 -7
- package/lib/zhin-agent/config.js.map +1 -1
- package/lib/zhin-agent/context-budget.d.ts +27 -0
- package/lib/zhin-agent/context-budget.d.ts.map +1 -0
- package/lib/zhin-agent/context-budget.js +50 -0
- package/lib/zhin-agent/context-budget.js.map +1 -0
- package/lib/zhin-agent/index.d.ts +10 -0
- package/lib/zhin-agent/index.d.ts.map +1 -1
- package/lib/zhin-agent/index.js +125 -88
- package/lib/zhin-agent/index.js.map +1 -1
- package/lib/zhin-agent/model-harness.d.ts +29 -0
- package/lib/zhin-agent/model-harness.d.ts.map +1 -0
- package/lib/zhin-agent/model-harness.js +67 -0
- package/lib/zhin-agent/model-harness.js.map +1 -0
- package/lib/zhin-agent/pre-exec.d.ts +7 -0
- package/lib/zhin-agent/pre-exec.d.ts.map +1 -0
- package/lib/zhin-agent/pre-exec.js +25 -0
- package/lib/zhin-agent/pre-exec.js.map +1 -0
- package/lib/zhin-agent/prompt.d.ts +10 -8
- package/lib/zhin-agent/prompt.d.ts.map +1 -1
- package/lib/zhin-agent/prompt.js +38 -30
- package/lib/zhin-agent/prompt.js.map +1 -1
- package/lib/zhin-agent/text-sanitize.d.ts +8 -0
- package/lib/zhin-agent/text-sanitize.d.ts.map +1 -0
- package/lib/zhin-agent/text-sanitize.js +19 -0
- package/lib/zhin-agent/text-sanitize.js.map +1 -0
- package/lib/zhin-agent/tool-runtime.d.ts +31 -0
- package/lib/zhin-agent/tool-runtime.d.ts.map +1 -0
- package/lib/zhin-agent/tool-runtime.js +49 -0
- package/lib/zhin-agent/tool-runtime.js.map +1 -0
- package/package.json +8 -6
- package/lib/tools.d.ts +0 -45
- package/lib/tools.d.ts.map +0 -1
- package/lib/tools.js +0 -205
- package/lib/tools.js.map +0 -1
- package/lib/zhin-agent/tool-collector.d.ts +0 -22
- package/lib/zhin-agent/tool-collector.d.ts.map +0 -1
- package/lib/zhin-agent/tool-collector.js +0 -225
- package/lib/zhin-agent/tool-collector.js.map +0 -1
package/lib/builtin-tools.js
CHANGED
|
@@ -1,181 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AI 内置系统工具
|
|
3
3
|
*
|
|
4
|
-
* 文件工具: read_file
|
|
5
|
-
* Shell: bash
|
|
6
|
-
* 网络: web_search, web_fetch
|
|
7
|
-
* 计划: todo_read, todo_write
|
|
8
|
-
* 记忆: read_memory, write_memory
|
|
9
|
-
* 技能: activate_skill, install_skill
|
|
10
|
-
* 交互: ask_user
|
|
4
|
+
* 文件工具: read_file / write_file / edit_file / list_dir / glob / grep(builtin/* + BuiltinBaseTool)
|
|
5
|
+
* Shell: bash(builtin/bash-tool)
|
|
6
|
+
* 网络: web_search, web_fetch(builtin/web-*-tool)
|
|
7
|
+
* 计划: todo_read, todo_write(builtin/todo-*-tool)
|
|
8
|
+
* 记忆: read_memory, write_memory(builtin/read-memory-tool, write-memory-tool)
|
|
9
|
+
* 技能: activate_skill, install_skill(builtin/activate-skill-tool, install-skill-tool)
|
|
10
|
+
* 交互: ask_user(builtin/ask-user-tool)
|
|
11
11
|
*
|
|
12
12
|
* 发现逻辑已拆分到 discovery/skills.ts、agents.ts、tools.ts
|
|
13
13
|
*/
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
.replace(/\u201C/g, '"') // "
|
|
32
|
-
.replace(/\u201D/g, '"'); // "
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* 在文件内容中查找字符串,支持精确匹配和引号归一化模糊匹配。
|
|
36
|
-
* 参考 Claude Code `findActualString`。
|
|
37
|
-
*/
|
|
38
|
-
function findActualStringInFile(fileContent, searchString) {
|
|
39
|
-
// 精确匹配
|
|
40
|
-
const exactCount = fileContent.split(searchString).length - 1;
|
|
41
|
-
if (exactCount > 0) {
|
|
42
|
-
return { actual: searchString, count: exactCount, wasNormalized: false };
|
|
43
|
-
}
|
|
44
|
-
// 引号归一化匹配
|
|
45
|
-
const normalizedSearch = normalizeQuotes(searchString);
|
|
46
|
-
const normalizedFile = normalizeQuotes(fileContent);
|
|
47
|
-
const idx = normalizedFile.indexOf(normalizedSearch);
|
|
48
|
-
if (idx !== -1) {
|
|
49
|
-
// 提取文件中实际的字符串(保留原始弯引号)
|
|
50
|
-
const actual = fileContent.substring(idx, idx + searchString.length);
|
|
51
|
-
const normalizedCount = normalizedFile.split(normalizedSearch).length - 1;
|
|
52
|
-
return { actual, count: normalizedCount, wasNormalized: true };
|
|
53
|
-
}
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* 将 new_string 中的直引号替换为文件中原始的弯引号风格。
|
|
58
|
-
* 参考 Claude Code `preserveQuoteStyle`。
|
|
59
|
-
*/
|
|
60
|
-
function preserveQuoteStyleInEdit(oldString, actualOldString, newString) {
|
|
61
|
-
if (oldString === actualOldString)
|
|
62
|
-
return newString;
|
|
63
|
-
const hasDouble = actualOldString.includes('\u201C') || actualOldString.includes('\u201D');
|
|
64
|
-
const hasSingle = actualOldString.includes('\u2018') || actualOldString.includes('\u2019');
|
|
65
|
-
if (!hasDouble && !hasSingle)
|
|
66
|
-
return newString;
|
|
67
|
-
let result = newString;
|
|
68
|
-
if (hasDouble) {
|
|
69
|
-
// 简单启发式:前面是空白/行首时用左引号,否则右引号
|
|
70
|
-
const chars = [...result];
|
|
71
|
-
const out = [];
|
|
72
|
-
for (let i = 0; i < chars.length; i++) {
|
|
73
|
-
if (chars[i] === '"') {
|
|
74
|
-
const prev = i > 0 ? chars[i - 1] : ' ';
|
|
75
|
-
const isOpening = /[\s(\[{]/.test(prev) || i === 0;
|
|
76
|
-
out.push(isOpening ? '\u201C' : '\u201D');
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
out.push(chars[i]);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
result = out.join('');
|
|
83
|
-
}
|
|
84
|
-
if (hasSingle) {
|
|
85
|
-
const chars = [...result];
|
|
86
|
-
const out = [];
|
|
87
|
-
for (let i = 0; i < chars.length; i++) {
|
|
88
|
-
if (chars[i] === "'") {
|
|
89
|
-
const prev = i > 0 ? chars[i - 1] : ' ';
|
|
90
|
-
const next = i < chars.length - 1 ? chars[i + 1] : ' ';
|
|
91
|
-
// 两个字母之间是缩写,用右引号
|
|
92
|
-
if (/\p{L}/u.test(prev) && /\p{L}/u.test(next)) {
|
|
93
|
-
out.push('\u2019');
|
|
94
|
-
}
|
|
95
|
-
else {
|
|
96
|
-
const isOpening = /[\s(\[{]/.test(prev) || i === 0;
|
|
97
|
-
out.push(isOpening ? '\u2018' : '\u2019');
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
else {
|
|
101
|
-
out.push(chars[i]);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
result = out.join('');
|
|
105
|
-
}
|
|
106
|
-
return result;
|
|
107
|
-
}
|
|
108
|
-
// ── 图片格式检测(参考 Claude Code FileReadTool imageResizer) ──
|
|
109
|
-
/** 支持的图片扩展名 */
|
|
110
|
-
const IMAGE_EXTENSIONS = new Set([
|
|
111
|
-
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico',
|
|
112
|
-
]);
|
|
113
|
-
function isImageFile(filePath) {
|
|
114
|
-
return IMAGE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
115
|
-
}
|
|
116
|
-
// ============================================================================
|
|
117
|
-
// ask_user 辅助函数
|
|
118
|
-
// ============================================================================
|
|
119
|
-
/**
|
|
120
|
-
* 私聊 Owner 场景:使用 Prompt 类直接交互(原有行为)
|
|
121
|
-
*/
|
|
122
|
-
async function askViaPrompt(plugin, message, args, questionType, timeoutMs) {
|
|
123
|
-
const prompt = new Prompt(plugin, message);
|
|
124
|
-
try {
|
|
125
|
-
switch (questionType) {
|
|
126
|
-
case 'number': {
|
|
127
|
-
const defaultNum = args.default_value != null ? Number(args.default_value) : undefined;
|
|
128
|
-
const result = await prompt.number(args.question, timeoutMs, defaultNum, '输入超时,已取消');
|
|
129
|
-
return String(result);
|
|
130
|
-
}
|
|
131
|
-
case 'confirm': {
|
|
132
|
-
const result = await prompt.confirm(args.question, 'yes', timeoutMs, false, '确认超时,已取消');
|
|
133
|
-
return result ? 'yes' : 'no';
|
|
134
|
-
}
|
|
135
|
-
case 'pick': {
|
|
136
|
-
if (!args.options?.length) {
|
|
137
|
-
return 'Error: type=pick 时必须提供 options 选项列表';
|
|
138
|
-
}
|
|
139
|
-
const pickOptions = args.options.map((o) => ({ label: o, value: o }));
|
|
140
|
-
const result = await prompt.pick(args.question, {
|
|
141
|
-
type: 'text',
|
|
142
|
-
options: pickOptions,
|
|
143
|
-
timeout: timeoutMs,
|
|
144
|
-
}, '选择超时,已取消');
|
|
145
|
-
return String(result);
|
|
146
|
-
}
|
|
147
|
-
case 'text':
|
|
148
|
-
default: {
|
|
149
|
-
const result = await prompt.text(args.question, timeoutMs, args.default_value || '', '输入超时,已取消');
|
|
150
|
-
return result;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
catch (e) {
|
|
155
|
-
return `Owner 未响应或输入错误: ${errMsg(e)}`;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* 将 Owner 私聊回复格式化为对应类型的结果
|
|
160
|
-
*/
|
|
161
|
-
function formatOwnerResponse(raw, questionType, args) {
|
|
162
|
-
switch (questionType) {
|
|
163
|
-
case 'confirm':
|
|
164
|
-
return raw.trim().toLowerCase() === 'yes' ? 'yes' : 'no';
|
|
165
|
-
case 'number':
|
|
166
|
-
return String(Number(raw) || 0);
|
|
167
|
-
case 'pick': {
|
|
168
|
-
const idx = Number(raw.trim());
|
|
169
|
-
const options = args.options || [];
|
|
170
|
-
if (idx >= 1 && idx <= options.length)
|
|
171
|
-
return options[idx - 1];
|
|
172
|
-
return raw;
|
|
173
|
-
}
|
|
174
|
-
case 'text':
|
|
175
|
-
default:
|
|
176
|
-
return raw;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
14
|
+
import { getDataDir, mergeSkillDirsWithResolver } from './discovery/utils.js';
|
|
15
|
+
import { createReadFileTool } from './builtin/read-file-tool.js';
|
|
16
|
+
import { createWriteFileTool } from './builtin/write-file-tool.js';
|
|
17
|
+
import { createEditFileTool } from './builtin/edit-file-tool.js';
|
|
18
|
+
import { createListDirTool } from './builtin/list-dir-tool.js';
|
|
19
|
+
import { createGlobTool } from './builtin/glob-tool.js';
|
|
20
|
+
import { createGrepTool } from './builtin/grep-tool.js';
|
|
21
|
+
import { createBashTool } from './builtin/bash-tool.js';
|
|
22
|
+
import { createWebSearchTool } from './builtin/web-search-tool.js';
|
|
23
|
+
import { createWebFetchTool } from './builtin/web-fetch-tool.js';
|
|
24
|
+
import { createTodoReadTool } from './builtin/todo-read-tool.js';
|
|
25
|
+
import { createTodoWriteTool } from './builtin/todo-write-tool.js';
|
|
26
|
+
import { createReadMemoryTool } from './builtin/read-memory-tool.js';
|
|
27
|
+
import { createWriteMemoryTool } from './builtin/write-memory-tool.js';
|
|
28
|
+
import { createActivateSkillTool } from './builtin/activate-skill-tool.js';
|
|
29
|
+
import { createInstallSkillTool } from './builtin/install-skill-tool.js';
|
|
30
|
+
import { createAskUserTool } from './builtin/ask-user-tool.js';
|
|
179
31
|
/**
|
|
180
32
|
* 创建所有内置系统工具
|
|
181
33
|
*/
|
|
@@ -186,696 +38,26 @@ export function createBuiltinTools(options) {
|
|
|
186
38
|
const skillFileLookup = options?.skillFileLookup;
|
|
187
39
|
const pluginRef = options?.plugin;
|
|
188
40
|
const tools = [];
|
|
189
|
-
|
|
190
|
-
tools.push(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return `Error: 文件过大 (${(stat.size / 1024 / 1024).toFixed(1)} MiB),超过 ${MAX_READ_FILE_SIZE / 1024 / 1024} MiB 限制。请使用 offset/limit 分段读取。`;
|
|
210
|
-
}
|
|
211
|
-
// 图片文件检测(参考 Claude Code FileReadTool 的图片处理)
|
|
212
|
-
if (isImageFile(fp)) {
|
|
213
|
-
const buffer = await fs.promises.readFile(fp);
|
|
214
|
-
const ext = path.extname(fp).toLowerCase().replace('.', '');
|
|
215
|
-
const mimeType = ext === 'jpg' ? 'jpeg' : ext === 'svg' ? 'svg+xml' : ext;
|
|
216
|
-
const b64 = buffer.toString('base64');
|
|
217
|
-
const sizeKb = (buffer.length / 1024).toFixed(1);
|
|
218
|
-
return `[Image: ${path.basename(fp)}, ${sizeKb} KB, type: image/${mimeType}]\ndata:image/${mimeType};base64,${b64.slice(0, 200)}...(total ${b64.length} chars)`;
|
|
219
|
-
}
|
|
220
|
-
const content = await fs.promises.readFile(fp, 'utf-8');
|
|
221
|
-
const lines = content.split('\n');
|
|
222
|
-
const offset = args.offset ?? 0;
|
|
223
|
-
const limit = args.limit ?? lines.length;
|
|
224
|
-
const sliced = lines.slice(offset, offset + limit);
|
|
225
|
-
const numbered = sliced.map((line, i) => `${offset + i + 1} | ${line}`).join('\n');
|
|
226
|
-
return `File: ${fp} (${lines.length} lines, showing ${offset + 1}-${Math.min(offset + limit, lines.length)})\n${numbered}`;
|
|
227
|
-
}
|
|
228
|
-
catch (e) {
|
|
229
|
-
return nodeErrToFileMessage(e, args.file_path, 'read');
|
|
230
|
-
}
|
|
231
|
-
}));
|
|
232
|
-
// ── write_file ──
|
|
233
|
-
tools.push(new ZhinTool('write_file')
|
|
234
|
-
.desc('向指定路径写入内容,创建或覆盖文件;若目录不存在会自动创建。')
|
|
235
|
-
.keyword('写文件', '写入文件', '创建文件', '保存文件', 'write file', 'write', '保存', '创建')
|
|
236
|
-
.tag('file', 'write')
|
|
237
|
-
.kind('file')
|
|
238
|
-
.param('file_path', { type: 'string', description: '要写入的文件路径' }, true)
|
|
239
|
-
.param('content', { type: 'string', description: '要写入的完整内容' }, true)
|
|
240
|
-
.execute(async (args) => {
|
|
241
|
-
try {
|
|
242
|
-
const fp = expandHome(args.file_path);
|
|
243
|
-
assertFileAccess(fp);
|
|
244
|
-
await fs.promises.mkdir(path.dirname(fp), { recursive: true });
|
|
245
|
-
await fs.promises.writeFile(fp, args.content, 'utf-8');
|
|
246
|
-
return `✅ Wrote ${Buffer.byteLength(args.content)} bytes to ${fp}`;
|
|
247
|
-
}
|
|
248
|
-
catch (e) {
|
|
249
|
-
return nodeErrToFileMessage(e, args.file_path, 'write');
|
|
250
|
-
}
|
|
251
|
-
}));
|
|
252
|
-
// ── edit_file(支持精确匹配 + 引号归一化模糊匹配)──
|
|
253
|
-
tools.push(new ZhinTool('edit_file')
|
|
254
|
-
.desc('在文件中查找并替换一段文本。old_string 必须在文件中精确存在且唯一;建议包含完整行或足够上下文以避免重复匹配。支持弯引号/直引号自动归一化。')
|
|
255
|
-
.keyword('编辑文件', '修改文件', '替换内容', '查找替换', 'edit file', 'edit', '修改', '替换')
|
|
256
|
-
.tag('file', 'edit')
|
|
257
|
-
.kind('file')
|
|
258
|
-
.param('file_path', { type: 'string', description: '要编辑的文件路径' }, true)
|
|
259
|
-
.param('old_string', { type: 'string', description: '文件中要替换的原文(必须与文件内容完全一致)' }, true)
|
|
260
|
-
.param('new_string', { type: 'string', description: '替换后的新文本' }, true)
|
|
261
|
-
.execute(async (args) => {
|
|
262
|
-
try {
|
|
263
|
-
const fp = expandHome(args.file_path);
|
|
264
|
-
assertFileAccess(fp);
|
|
265
|
-
// 文件大小限制
|
|
266
|
-
const stat = await fs.promises.stat(fp);
|
|
267
|
-
if (stat.size > MAX_EDIT_FILE_SIZE) {
|
|
268
|
-
return `Error: 文件过大 (${(stat.size / 1024 / 1024).toFixed(1)} MiB),超过 ${MAX_EDIT_FILE_SIZE / 1024 / 1024} MiB 限制。`;
|
|
269
|
-
}
|
|
270
|
-
// 记录 mtime 用于防并发覆写
|
|
271
|
-
const mtimeBefore = stat.mtimeMs;
|
|
272
|
-
const content = await fs.promises.readFile(fp, 'utf-8');
|
|
273
|
-
// 精确匹配 → 引号归一化模糊匹配
|
|
274
|
-
const matchResult = findActualStringInFile(content, args.old_string);
|
|
275
|
-
if (!matchResult)
|
|
276
|
-
return `Error: old_string not found in file. Make sure it matches exactly (also tried quote normalization).`;
|
|
277
|
-
if (matchResult.count > 1)
|
|
278
|
-
return `Warning: old_string appears ${matchResult.count} times. Please provide more context to make it unique.`;
|
|
279
|
-
// 如果通过引号归一化匹配,保持文件的引号风格
|
|
280
|
-
const effectiveNew = matchResult.wasNormalized
|
|
281
|
-
? preserveQuoteStyleInEdit(args.old_string, matchResult.actual, args.new_string)
|
|
282
|
-
: args.new_string;
|
|
283
|
-
const newContent = content.replace(matchResult.actual, effectiveNew);
|
|
284
|
-
// 写入前再检查 mtime 防止并发修改
|
|
285
|
-
const currentStat = await fs.promises.stat(fp);
|
|
286
|
-
if (isFileStale(mtimeBefore, currentStat.mtimeMs)) {
|
|
287
|
-
return `Error: 文件 ${fp} 在读取后被外部修改。请重新读取文件后再编辑,避免覆盖他人的修改。`;
|
|
288
|
-
}
|
|
289
|
-
await fs.promises.writeFile(fp, newContent, 'utf-8');
|
|
290
|
-
const oldLines = args.old_string.split('\n');
|
|
291
|
-
const newLines = args.new_string.split('\n');
|
|
292
|
-
return `✅ Edited ${fp}\n--- before ---\n${oldLines.slice(0, 5).join('\n')}${oldLines.length > 5 ? '\n...' : ''}\n--- after ---\n${newLines.slice(0, 5).join('\n')}${newLines.length > 5 ? '\n...' : ''}`;
|
|
293
|
-
}
|
|
294
|
-
catch (e) {
|
|
295
|
-
return nodeErrToFileMessage(e, args.file_path, 'edit');
|
|
296
|
-
}
|
|
297
|
-
}));
|
|
298
|
-
// ── list_dir(列出目录内容,便于 AI 匹配「列表」「目录」「ls」) ──
|
|
299
|
-
tools.push(new ZhinTool('list_dir')
|
|
300
|
-
.desc('列出指定目录下的文件和子目录名称。用于查看目录结构、有哪些文件。')
|
|
301
|
-
.keyword('列目录', '列出目录', '目录列表', '查看目录', 'list directory', 'list dir', 'ls', 'dir', '目录内容', '有哪些文件')
|
|
302
|
-
.tag('file', 'list')
|
|
303
|
-
.kind('file')
|
|
304
|
-
.param('path', { type: 'string', description: '要列出的目录路径(绝对或相对项目根目录)' }, true)
|
|
305
|
-
.execute(async (args) => {
|
|
306
|
-
try {
|
|
307
|
-
const dirPath = path.resolve(process.cwd(), expandHome(args.path));
|
|
308
|
-
assertFileAccess(dirPath);
|
|
309
|
-
const stat = await fs.promises.stat(dirPath);
|
|
310
|
-
if (!stat.isDirectory()) {
|
|
311
|
-
return `Error: Not a directory: ${args.path}`;
|
|
312
|
-
}
|
|
313
|
-
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
314
|
-
if (entries.length === 0) {
|
|
315
|
-
return `Directory ${args.path} is empty`;
|
|
316
|
-
}
|
|
317
|
-
const lines = [];
|
|
318
|
-
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
319
|
-
lines.push((e.isDirectory() ? '[DIR] ' : ' ') + e.name);
|
|
320
|
-
}
|
|
321
|
-
return lines.join('\n');
|
|
322
|
-
}
|
|
323
|
-
catch (e) {
|
|
324
|
-
return nodeErrToFileMessage(e, args.path, 'list');
|
|
325
|
-
}
|
|
326
|
-
}));
|
|
327
|
-
// ── glob ──
|
|
328
|
-
tools.push(new ZhinTool('glob')
|
|
329
|
-
.desc('按 glob 模式查找匹配的文件路径(如 **/*.ts)。用于按模式找文件,而非列出目录。')
|
|
330
|
-
.keyword('glob', '查找文件', '按模式找文件', 'find', '匹配文件')
|
|
331
|
-
.tag('file', 'search')
|
|
332
|
-
.kind('file')
|
|
333
|
-
.param('pattern', { type: 'string', description: 'Glob 模式(如 **/*.ts)' }, true)
|
|
334
|
-
.param('cwd', { type: 'string', description: '工作目录(默认项目根目录)' })
|
|
335
|
-
.execute(async (args) => {
|
|
336
|
-
try {
|
|
337
|
-
const cwd = args.cwd || process.cwd();
|
|
338
|
-
assertFileAccess(cwd);
|
|
339
|
-
// 安全转义 glob pattern 防止命令注入
|
|
340
|
-
const safePattern = shellEscape(args.pattern);
|
|
341
|
-
const { stdout } = await execAsync(`find . -path ./${safePattern} -type f 2>/dev/null | head -100`, { cwd });
|
|
342
|
-
const files = stdout.trim().split('\n').filter(Boolean);
|
|
343
|
-
return files.length === 0
|
|
344
|
-
? `No files matching '${args.pattern}'`
|
|
345
|
-
: `Found ${files.length} files:\n${files.join('\n')}`;
|
|
346
|
-
}
|
|
347
|
-
catch (e) {
|
|
348
|
-
return `Error: ${errMsg(e)}`;
|
|
349
|
-
}
|
|
350
|
-
}));
|
|
351
|
-
// ── grep(支持上下文行、大小写、多行、ripgrep 自动检测) ──
|
|
352
|
-
tools.push(new ZhinTool('grep')
|
|
353
|
-
.desc('按正则搜索文件内容,返回匹配行和行号。优先使用 ripgrep (rg),回退到 grep。')
|
|
354
|
-
.keyword('搜索', '查找内容', 'grep', '正则', 'rg', 'ripgrep')
|
|
355
|
-
.tag('search', 'regex')
|
|
356
|
-
.kind('file')
|
|
357
|
-
.param('pattern', { type: 'string', description: '正则表达式' }, true)
|
|
358
|
-
.param('path', { type: 'string', description: '搜索路径(默认 .)' })
|
|
359
|
-
.param('include', { type: 'string', description: '文件类型过滤(如 *.ts)' })
|
|
360
|
-
.param('context', { type: 'number', description: '匹配行上下文行数(-C 参数)' })
|
|
361
|
-
.param('before', { type: 'number', description: '匹配行之前显示行数(-B 参数)' })
|
|
362
|
-
.param('after', { type: 'number', description: '匹配行之后显示行数(-A 参数)' })
|
|
363
|
-
.param('ignore_case', { type: 'boolean', description: '大小写不敏感搜索(-i 参数)' })
|
|
364
|
-
.param('multiline', { type: 'boolean', description: '多行模式,. 匹配换行(仅 ripgrep 支持)' })
|
|
365
|
-
.param('limit', { type: 'number', description: '最多返回结果行数(默认 50)' })
|
|
366
|
-
.execute(async (args) => {
|
|
367
|
-
try {
|
|
368
|
-
const searchPath = args.path || '.';
|
|
369
|
-
assertFileAccess(path.resolve(process.cwd(), searchPath));
|
|
370
|
-
const safePattern = shellEscape(args.pattern);
|
|
371
|
-
const safePath = shellEscape(searchPath);
|
|
372
|
-
const limit = args.limit ?? 50;
|
|
373
|
-
// 检测 ripgrep 是否可用
|
|
374
|
-
let useRipgrep = false;
|
|
375
|
-
try {
|
|
376
|
-
await execAsync('rg --version', { timeout: 3000 });
|
|
377
|
-
useRipgrep = true;
|
|
378
|
-
}
|
|
379
|
-
catch { /* ripgrep 不可用,回退到 grep */ }
|
|
380
|
-
let cmd;
|
|
381
|
-
if (useRipgrep) {
|
|
382
|
-
// ripgrep 命令构建
|
|
383
|
-
const rgFlags = ['-n']; // 行号
|
|
384
|
-
if (args.ignore_case)
|
|
385
|
-
rgFlags.push('-i');
|
|
386
|
-
if (args.multiline)
|
|
387
|
-
rgFlags.push('-U', '--multiline-dotall');
|
|
388
|
-
if (args.context)
|
|
389
|
-
rgFlags.push(`-C${args.context}`);
|
|
390
|
-
else {
|
|
391
|
-
if (args.before)
|
|
392
|
-
rgFlags.push(`-B${args.before}`);
|
|
393
|
-
if (args.after)
|
|
394
|
-
rgFlags.push(`-A${args.after}`);
|
|
395
|
-
}
|
|
396
|
-
if (args.include)
|
|
397
|
-
rgFlags.push(`--glob=${shellEscape(args.include)}`);
|
|
398
|
-
cmd = `rg ${rgFlags.join(' ')} ${safePattern} ${safePath} 2>/dev/null | head -${limit}`;
|
|
399
|
-
}
|
|
400
|
-
else {
|
|
401
|
-
// 传统 grep 回退
|
|
402
|
-
const grepFlags = ['-rn'];
|
|
403
|
-
if (args.ignore_case)
|
|
404
|
-
grepFlags.push('-i');
|
|
405
|
-
if (args.context)
|
|
406
|
-
grepFlags.push(`-C${args.context}`);
|
|
407
|
-
else {
|
|
408
|
-
if (args.before)
|
|
409
|
-
grepFlags.push(`-B${args.before}`);
|
|
410
|
-
if (args.after)
|
|
411
|
-
grepFlags.push(`-A${args.after}`);
|
|
412
|
-
}
|
|
413
|
-
const includeFlag = args.include ? `--include=${shellEscape(args.include)}` : '';
|
|
414
|
-
cmd = `grep ${grepFlags.join(' ')} ${includeFlag} ${safePattern} ${safePath} 2>/dev/null | head -${limit}`;
|
|
415
|
-
}
|
|
416
|
-
const { stdout } = await execAsync(cmd, { cwd: process.cwd() });
|
|
417
|
-
const engine = useRipgrep ? '(ripgrep)' : '(grep)';
|
|
418
|
-
return stdout.trim()
|
|
419
|
-
? `${engine}\n${stdout.trim()}`
|
|
420
|
-
: `No matches for '${args.pattern}' ${engine}`;
|
|
421
|
-
}
|
|
422
|
-
catch (e) {
|
|
423
|
-
const err = e;
|
|
424
|
-
if (err.code === 1)
|
|
425
|
-
return `No matches for '${args.pattern}'`;
|
|
426
|
-
return `Error: ${errMsg(e)}`;
|
|
427
|
-
}
|
|
428
|
-
}));
|
|
429
|
-
// ── bash(安全检查 + 命令读写分类) ──
|
|
430
|
-
tools.push(new ZhinTool('bash')
|
|
431
|
-
.desc('执行 Shell 命令(带超时保护和命令分类)。返回结果中会标注命令类型(只读/搜索/写入)。')
|
|
432
|
-
.keyword('执行', '运行', '命令', '终端', 'shell', 'bash')
|
|
433
|
-
.tag('shell', 'exec')
|
|
434
|
-
.kind('shell')
|
|
435
|
-
.param('command', { type: 'string', description: 'Shell 命令' }, true)
|
|
436
|
-
.param('cwd', { type: 'string', description: '工作目录' })
|
|
437
|
-
.param('timeout', { type: 'number', description: '超时毫秒数(默认 30000)' })
|
|
438
|
-
.execute(async (args) => {
|
|
439
|
-
try {
|
|
440
|
-
const timeout = args.timeout ?? 30000;
|
|
441
|
-
const cmd = String(args.command || '');
|
|
442
|
-
// 检查命令是否可能泄漏敏感信息
|
|
443
|
-
const safety = checkBashCommandSafety(cmd);
|
|
444
|
-
if (!safety.safe)
|
|
445
|
-
return `Error: ${safety.reason}`;
|
|
446
|
-
// 命令读写分类
|
|
447
|
-
const classification = classifyBashCommand(cmd);
|
|
448
|
-
const { stdout, stderr } = await execAsync(cmd, {
|
|
449
|
-
cwd: args.cwd || process.cwd(),
|
|
450
|
-
timeout,
|
|
451
|
-
maxBuffer: 1024 * 1024,
|
|
452
|
-
});
|
|
453
|
-
let result = '';
|
|
454
|
-
const tag = classification.isReadOnly
|
|
455
|
-
? (classification.isSearch ? '[搜索]' : classification.isList ? '[列出]' : '[只读]')
|
|
456
|
-
: '[执行]';
|
|
457
|
-
if (stdout.trim())
|
|
458
|
-
result += `STDOUT:\n${stdout.trim()}`;
|
|
459
|
-
if (stderr.trim())
|
|
460
|
-
result += `${result ? '\n' : ''}STDERR:\n${stderr.trim()}`;
|
|
461
|
-
return `${tag} ${result || '(no output)'}`;
|
|
462
|
-
}
|
|
463
|
-
catch (e) {
|
|
464
|
-
const err = e;
|
|
465
|
-
return `Error (exit ${err.code || '?'}): ${errMsg(e)}\nSTDOUT:\n${err.stdout || ''}\nSTDERR:\n${err.stderr || ''}`;
|
|
466
|
-
}
|
|
467
|
-
}));
|
|
468
|
-
// ── web_search(搜索网页,返回标题、URL、摘要 + 域名过滤 + 次数限制) ──
|
|
469
|
-
let searchCount = 0;
|
|
470
|
-
const MAX_SEARCH_COUNT = 20; // 单次会话搜索次数上限
|
|
471
|
-
tools.push(new ZhinTool('web_search')
|
|
472
|
-
.desc('在互联网上搜索,返回匹配的标题、URL 和摘要片段。用于查资料、找网页。支持域名过滤。')
|
|
473
|
-
.keyword('搜索', '网上搜', '网页搜索', '搜索引擎', 'search', 'google', '百度', '查询', '搜一下')
|
|
474
|
-
.tag('web', 'search')
|
|
475
|
-
.kind('web')
|
|
476
|
-
.param('query', { type: 'string', description: '搜索关键词或完整查询语句' }, true)
|
|
477
|
-
.param('limit', { type: 'number', description: '返回结果数量(默认 5,建议 1–10)' })
|
|
478
|
-
.param('allowed_domains', { type: 'array', description: '仅保留这些域名的结果(可选,如 ["github.com", "stackoverflow.com"])' })
|
|
479
|
-
.param('blocked_domains', { type: 'array', description: '排除这些域名的结果(可选)' })
|
|
480
|
-
.execute(async (args) => {
|
|
481
|
-
try {
|
|
482
|
-
// 搜索次数限制
|
|
483
|
-
searchCount++;
|
|
484
|
-
if (searchCount > MAX_SEARCH_COUNT) {
|
|
485
|
-
return `Error: 搜索次数已达上限 (${MAX_SEARCH_COUNT})。请使用已获取的信息回答。`;
|
|
486
|
-
}
|
|
487
|
-
const limit = args.limit ?? 5;
|
|
488
|
-
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(args.query)}`;
|
|
489
|
-
const res = await fetch(url, {
|
|
490
|
-
headers: {
|
|
491
|
-
'User-Agent': 'Mozilla/5.0 (compatible; ZhinBot/1.0)',
|
|
492
|
-
'Accept': 'text/html',
|
|
493
|
-
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
494
|
-
},
|
|
495
|
-
signal: AbortSignal.timeout(15000),
|
|
496
|
-
});
|
|
497
|
-
if (!res.ok)
|
|
498
|
-
return `HTTP ${res.status}: ${res.statusText}`;
|
|
499
|
-
const html = await res.text();
|
|
500
|
-
// 从 DuckDuckGo HTML 页面提取搜索结果
|
|
501
|
-
const results = [];
|
|
502
|
-
const resultBlocks = html.split(/class="result\s/);
|
|
503
|
-
for (let i = 1; i < resultBlocks.length && results.length < limit; i++) {
|
|
504
|
-
const block = resultBlocks[i];
|
|
505
|
-
// 提取标题和 URL
|
|
506
|
-
const titleMatch = block.match(/<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/);
|
|
507
|
-
if (!titleMatch)
|
|
508
|
-
continue;
|
|
509
|
-
let href = titleMatch[1];
|
|
510
|
-
// DuckDuckGo 会将 URL 编码到 uddg 参数中
|
|
511
|
-
const uddgMatch = href.match(/[?&]uddg=([^&]+)/);
|
|
512
|
-
if (uddgMatch)
|
|
513
|
-
href = decodeURIComponent(uddgMatch[1]);
|
|
514
|
-
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
|
|
515
|
-
// 提取摘要
|
|
516
|
-
const snippetMatch = block.match(/<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/);
|
|
517
|
-
const snippet = snippetMatch
|
|
518
|
-
? snippetMatch[1].replace(/<[^>]+>/g, '').trim()
|
|
519
|
-
: '';
|
|
520
|
-
if (title && href) {
|
|
521
|
-
results.push({ title, url: href, snippet });
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
// 域名过滤
|
|
525
|
-
let filtered = results;
|
|
526
|
-
if (args.allowed_domains?.length) {
|
|
527
|
-
const allowed = new Set(args.allowed_domains.map(d => d.toLowerCase()));
|
|
528
|
-
filtered = filtered.filter(r => {
|
|
529
|
-
try {
|
|
530
|
-
return allowed.has(new URL(r.url).hostname.toLowerCase());
|
|
531
|
-
}
|
|
532
|
-
catch {
|
|
533
|
-
return false;
|
|
534
|
-
}
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
if (args.blocked_domains?.length) {
|
|
538
|
-
const blocked = new Set(args.blocked_domains.map(d => d.toLowerCase()));
|
|
539
|
-
filtered = filtered.filter(r => {
|
|
540
|
-
try {
|
|
541
|
-
return !blocked.has(new URL(r.url).hostname.toLowerCase());
|
|
542
|
-
}
|
|
543
|
-
catch {
|
|
544
|
-
return true;
|
|
545
|
-
}
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
if (filtered.length === 0)
|
|
549
|
-
return 'No results found.';
|
|
550
|
-
return `(${searchCount}/${MAX_SEARCH_COUNT} searches)\n` + filtered.map((r, i) => `${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.snippet}`).join('\n\n');
|
|
551
|
-
}
|
|
552
|
-
catch (e) {
|
|
553
|
-
return `Error: ${errMsg(e)}`;
|
|
554
|
-
}
|
|
555
|
-
}));
|
|
556
|
-
// ── web_fetch(抓取 URL 并提取正文 + SSRF 防护 + 改进的内容提取) ──
|
|
557
|
-
tools.push(new ZhinTool('web_fetch')
|
|
558
|
-
.desc('抓取指定 URL 的网页内容并提取正文(去除广告、脚本等),返回可读文本。仅支持 http/https 协议。')
|
|
559
|
-
.keyword('抓取网页', '打开链接', '获取网页', '读网页', 'fetch', 'url', '链接内容', '网页内容')
|
|
560
|
-
.tag('web', 'fetch')
|
|
561
|
-
.kind('web')
|
|
562
|
-
.param('url', { type: 'string', description: '要抓取的完整 URL(需 http 或 https)' }, true)
|
|
563
|
-
.param('max_length', { type: 'number', description: '最大返回字符数(默认 20480)' })
|
|
564
|
-
.execute(async (args) => {
|
|
565
|
-
try {
|
|
566
|
-
// SSRF 防护:仅允许 http/https 协议
|
|
567
|
-
let parsedUrl;
|
|
568
|
-
try {
|
|
569
|
-
parsedUrl = new URL(args.url);
|
|
570
|
-
}
|
|
571
|
-
catch {
|
|
572
|
-
return `Error: 无效的 URL 格式`;
|
|
573
|
-
}
|
|
574
|
-
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
575
|
-
return `Error: 仅支持 http/https 协议,拒绝 ${parsedUrl.protocol}`;
|
|
576
|
-
}
|
|
577
|
-
// 阻止内网地址(SSRF 关键防护)
|
|
578
|
-
const hostname = parsedUrl.hostname.toLowerCase();
|
|
579
|
-
if (hostname === 'localhost' ||
|
|
580
|
-
hostname === '127.0.0.1' ||
|
|
581
|
-
hostname === '::1' ||
|
|
582
|
-
hostname === '0.0.0.0' ||
|
|
583
|
-
hostname.endsWith('.local') ||
|
|
584
|
-
hostname.startsWith('10.') ||
|
|
585
|
-
hostname.startsWith('192.168.') ||
|
|
586
|
-
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) {
|
|
587
|
-
return `Error: 禁止访问内网地址 ${hostname}(SSRF 防护)`;
|
|
588
|
-
}
|
|
589
|
-
const response = await fetch(args.url, {
|
|
590
|
-
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ZhinBot/1.0)' },
|
|
591
|
-
signal: AbortSignal.timeout(15000),
|
|
592
|
-
redirect: 'follow',
|
|
593
|
-
});
|
|
594
|
-
if (!response.ok)
|
|
595
|
-
return `HTTP ${response.status}: ${response.statusText}`;
|
|
596
|
-
const html = await response.text();
|
|
597
|
-
// 改进的内容提取:去除脚本、样式、导航、页脚、表单等
|
|
598
|
-
const text = html
|
|
599
|
-
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
600
|
-
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
601
|
-
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
|
602
|
-
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
|
|
603
|
-
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, ' ')
|
|
604
|
-
.replace(/<form[^>]*>[\s\S]*?<\/form>/gi, '')
|
|
605
|
-
.replace(/<!--[\s\S]*?-->/g, '')
|
|
606
|
-
.replace(/<[^>]+>/g, ' ')
|
|
607
|
-
.replace(/ /gi, ' ')
|
|
608
|
-
.replace(/&/g, '&')
|
|
609
|
-
.replace(/</g, '<')
|
|
610
|
-
.replace(/>/g, '>')
|
|
611
|
-
.replace(/"/g, '"')
|
|
612
|
-
.replace(/\s+/g, ' ')
|
|
613
|
-
.trim();
|
|
614
|
-
const maxLen = args.max_length ?? 20 * 1024;
|
|
615
|
-
return text.length > maxLen ? text.slice(0, maxLen) + '\n...(truncated)' : text;
|
|
616
|
-
}
|
|
617
|
-
catch (e) {
|
|
618
|
-
return `Error: ${errMsg(e)}`;
|
|
619
|
-
}
|
|
620
|
-
}));
|
|
621
|
-
// ── todo_read ──
|
|
622
|
-
tools.push(new ZhinTool('todo_read')
|
|
623
|
-
.desc('读取当前任务计划列表,用于查看进度和待办事项')
|
|
624
|
-
.keyword('任务', '计划', '进度', 'todo', '待办')
|
|
625
|
-
.tag('plan', 'todo')
|
|
626
|
-
.kind('plan')
|
|
627
|
-
.param('chat_id', { type: 'string', description: '聊天范围(传 "global" 表示全局,或传具体聊天 ID)' }, true)
|
|
628
|
-
.execute(async (args) => {
|
|
629
|
-
try {
|
|
630
|
-
const dir = args.chat_id && args.chat_id !== 'global' ? path.join(DATA_DIR, 'groups', args.chat_id) : DATA_DIR;
|
|
631
|
-
const todoPath = path.join(dir, 'TODO.json');
|
|
632
|
-
if (!fs.existsSync(todoPath))
|
|
633
|
-
return 'No tasks found. Use todo_write to create a plan.';
|
|
634
|
-
const data = JSON.parse(await fs.promises.readFile(todoPath, 'utf-8'));
|
|
635
|
-
if (!data.items || data.items.length === 0)
|
|
636
|
-
return 'Task list is empty.';
|
|
637
|
-
const lines = data.items.map((item, i) => {
|
|
638
|
-
const status = item.status === 'done' ? '✅' : item.status === 'in-progress' ? '🔄' : '⬜';
|
|
639
|
-
return `${status} ${i + 1}. ${item.title}${item.detail ? ' — ' + item.detail : ''}`;
|
|
640
|
-
});
|
|
641
|
-
return `📋 Tasks (${data.items.filter((i) => i.status === 'done').length}/${data.items.length} done):\n${lines.join('\n')}`;
|
|
642
|
-
}
|
|
643
|
-
catch (e) {
|
|
644
|
-
return `Error: ${errMsg(e)}`;
|
|
645
|
-
}
|
|
646
|
-
}));
|
|
647
|
-
// ── todo_write ──
|
|
648
|
-
tools.push(new ZhinTool('todo_write')
|
|
649
|
-
.desc('创建或更新任务计划,用于分解复杂任务并跟踪进度')
|
|
650
|
-
.keyword('创建计划', '更新任务', '标记完成', 'todo')
|
|
651
|
-
.tag('plan', 'todo')
|
|
652
|
-
.kind('plan')
|
|
653
|
-
.param('items', { type: 'array', description: '任务列表 [{title, detail?, status: pending|in-progress|done}]' }, true)
|
|
654
|
-
.param('chat_id', { type: 'string', description: '聊天范围(可选)' })
|
|
655
|
-
.execute(async (args) => {
|
|
656
|
-
try {
|
|
657
|
-
const dir = args.chat_id ? path.join(DATA_DIR, 'groups', args.chat_id) : DATA_DIR;
|
|
658
|
-
const todoPath = path.join(dir, 'TODO.json');
|
|
659
|
-
await fs.promises.mkdir(path.dirname(todoPath), { recursive: true });
|
|
660
|
-
const data = { updated_at: new Date().toISOString(), items: args.items };
|
|
661
|
-
await fs.promises.writeFile(todoPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
662
|
-
const done = args.items.filter((i) => i.status === 'done').length;
|
|
663
|
-
return `✅ Tasks updated (${done}/${args.items.length} done)`;
|
|
664
|
-
}
|
|
665
|
-
catch (e) {
|
|
666
|
-
return `Error: ${errMsg(e)}`;
|
|
667
|
-
}
|
|
668
|
-
}));
|
|
669
|
-
// ── read_memory ──
|
|
670
|
-
tools.push(new ZhinTool('read_memory')
|
|
671
|
-
.desc('读取持久化记忆(AGENTS.md)。记忆跨会话保持。scope: global(共享)或 chat(按聊天隔离)')
|
|
672
|
-
.keyword('记忆', '记住', '回忆', '之前', '上次', 'memory')
|
|
673
|
-
.tag('memory', 'agents')
|
|
674
|
-
.kind('memory')
|
|
675
|
-
.param('scope', { type: 'string', description: "'global' 或 'chat'(默认 chat)", enum: ['global', 'chat'] }, true)
|
|
676
|
-
.param('chat_id', { type: 'string', description: '聊天 ID(chat scope 时使用)' })
|
|
677
|
-
.execute(async (args) => {
|
|
678
|
-
try {
|
|
679
|
-
const memPath = args.scope === 'global'
|
|
680
|
-
? path.join(DATA_DIR, 'AGENTS.md')
|
|
681
|
-
: path.join(DATA_DIR, 'groups', args.chat_id || 'default', 'AGENTS.md');
|
|
682
|
-
if (!fs.existsSync(memPath))
|
|
683
|
-
return 'No memory stored yet.';
|
|
684
|
-
return await fs.promises.readFile(memPath, 'utf-8');
|
|
685
|
-
}
|
|
686
|
-
catch (e) {
|
|
687
|
-
return `Error: ${errMsg(e)}`;
|
|
688
|
-
}
|
|
689
|
-
}));
|
|
690
|
-
// ── write_memory ──
|
|
691
|
-
tools.push(new ZhinTool('write_memory')
|
|
692
|
-
.desc('写入持久化记忆。当用户说"记住…"、"记录…"时使用此工具')
|
|
693
|
-
.keyword('记住', '保存', 'remember', '记录')
|
|
694
|
-
.tag('memory', 'agents')
|
|
695
|
-
.kind('memory')
|
|
696
|
-
.param('content', { type: 'string', description: '要保存的记忆内容(Markdown)' }, true)
|
|
697
|
-
.param('scope', { type: 'string', description: "'global' 或 'chat'(默认 chat)", enum: ['global', 'chat'] })
|
|
698
|
-
.param('chat_id', { type: 'string', description: '聊天 ID' })
|
|
699
|
-
.execute(async (args) => {
|
|
700
|
-
try {
|
|
701
|
-
const memPath = args.scope === 'global'
|
|
702
|
-
? path.join(DATA_DIR, 'AGENTS.md')
|
|
703
|
-
: path.join(DATA_DIR, 'groups', args.chat_id || 'default', 'AGENTS.md');
|
|
704
|
-
await fs.promises.mkdir(path.dirname(memPath), { recursive: true });
|
|
705
|
-
await fs.promises.writeFile(memPath, args.content, 'utf-8');
|
|
706
|
-
return `✅ Memory saved (${args.scope || 'chat'} scope)`;
|
|
707
|
-
}
|
|
708
|
-
catch (e) {
|
|
709
|
-
return `Error: ${errMsg(e)}`;
|
|
710
|
-
}
|
|
711
|
-
}));
|
|
712
|
-
// ── activate_skill ──
|
|
713
|
-
tools.push(new ZhinTool('activate_skill')
|
|
714
|
-
.desc('按名称激活技能,加载其完整指令。当判断某个技能与用户请求相关时使用')
|
|
715
|
-
.keyword('技能', '激活', '启用', '使用', 'skill', 'activate', 'use')
|
|
716
|
-
.tag('skill', 'activate')
|
|
717
|
-
.kind('skill')
|
|
718
|
-
.param('name', { type: 'string', description: '技能名称' }, true)
|
|
719
|
-
.execute(async (args) => {
|
|
720
|
-
try {
|
|
721
|
-
// 优先查找 SkillFeature 中已注册技能的 filePath
|
|
722
|
-
const registeredPath = skillFileLookup?.(args.name);
|
|
723
|
-
if (registeredPath && fs.existsSync(registeredPath)) {
|
|
724
|
-
const fullContent = await fs.promises.readFile(registeredPath, 'utf-8');
|
|
725
|
-
const depWarning = await checkSkillDeps(fullContent);
|
|
726
|
-
const instructions = extractSkillInstructions(args.name, fullContent, skillMaxChars);
|
|
727
|
-
return depWarning ? `${depWarning}\n\n${instructions}` : instructions;
|
|
728
|
-
}
|
|
729
|
-
for (const dir of skillDirList()) {
|
|
730
|
-
const skillPath = path.join(dir, args.name, 'SKILL.md');
|
|
731
|
-
if (fs.existsSync(skillPath)) {
|
|
732
|
-
const fullContent = await fs.promises.readFile(skillPath, 'utf-8');
|
|
733
|
-
const depWarning = await checkSkillDeps(fullContent);
|
|
734
|
-
const instructions = extractSkillInstructions(args.name, fullContent, skillMaxChars);
|
|
735
|
-
return depWarning ? `${depWarning}\n\n${instructions}` : instructions;
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
return `Skill '${args.name}' not found. Check skills/ directory.`;
|
|
739
|
-
}
|
|
740
|
-
catch (e) {
|
|
741
|
-
return `Error: ${errMsg(e)}`;
|
|
742
|
-
}
|
|
743
|
-
}));
|
|
744
|
-
// ── install_skill(从 URL 下载并安装技能) ──
|
|
745
|
-
tools.push(new ZhinTool('install_skill')
|
|
746
|
-
.desc('从 URL 下载 SKILL.md 并安装到本地 skills/ 目录。用户要求加入/安装/下载某个技能时使用')
|
|
747
|
-
.keyword('安装技能', '下载技能', '加入', '添加技能', 'install', 'skill', 'join', '学会', '学习技能')
|
|
748
|
-
.tag('skill', 'install')
|
|
749
|
-
.kind('skill')
|
|
750
|
-
.param('url', { type: 'string', description: 'SKILL.md 文件的完整 URL(如 https://example.com/skill.md)' }, true)
|
|
751
|
-
.execute(async (args) => {
|
|
752
|
-
try {
|
|
753
|
-
const response = await fetch(args.url, {
|
|
754
|
-
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ZhinBot/1.0)' },
|
|
755
|
-
signal: AbortSignal.timeout(15000),
|
|
756
|
-
});
|
|
757
|
-
if (!response.ok)
|
|
758
|
-
return `Error: HTTP ${response.status} ${response.statusText}`;
|
|
759
|
-
const content = await response.text();
|
|
760
|
-
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
761
|
-
if (!fmMatch)
|
|
762
|
-
return 'Error: 无效的 SKILL.md 文件(缺少 frontmatter)';
|
|
763
|
-
let jsYaml;
|
|
764
|
-
try {
|
|
765
|
-
jsYaml = await import('js-yaml');
|
|
766
|
-
if (jsYaml.default)
|
|
767
|
-
jsYaml = jsYaml.default;
|
|
768
|
-
}
|
|
769
|
-
catch {
|
|
770
|
-
return 'Error: 无法加载 yaml 解析器';
|
|
771
|
-
}
|
|
772
|
-
const metadata = jsYaml.load(fmMatch[1]);
|
|
773
|
-
if (!metadata?.name)
|
|
774
|
-
return 'Error: SKILL.md 缺少 name 字段';
|
|
775
|
-
const skillName = metadata.name;
|
|
776
|
-
const skillDir = path.join(process.cwd(), 'skills', skillName);
|
|
777
|
-
await fs.promises.mkdir(skillDir, { recursive: true });
|
|
778
|
-
const skillPath = path.join(skillDir, 'SKILL.md');
|
|
779
|
-
await fs.promises.writeFile(skillPath, content, 'utf-8');
|
|
780
|
-
logger.info(`技能已安装: ${skillName} → ${skillPath}`);
|
|
781
|
-
return `✅ 技能「${skillName}」已安装到 ${skillPath}。现在可以用 activate_skill("${skillName}") 激活它。`;
|
|
782
|
-
}
|
|
783
|
-
catch (e) {
|
|
784
|
-
return `Error: ${errMsg(e)}`;
|
|
785
|
-
}
|
|
786
|
-
}));
|
|
787
|
-
// ── ask_user(基于 Prompt 类的用户确认/提问工具) ──
|
|
788
|
-
// 安全策略:在群聊中 ask_user 只向 owner 私聊确认,防止非 owner 用户操控安全敏感决策
|
|
789
|
-
tools.push(new ZhinTool('ask_user')
|
|
790
|
-
.desc('向 Bot Owner 发送问题并等待回复。用于需要确认、补充信息或做出选择时。在群聊中始终通过私聊向 Owner 确认,确保安全性。')
|
|
791
|
-
.keyword('询问', '确认', '提问', '用户输入', 'ask', 'confirm', 'prompt', '选择', '请问')
|
|
792
|
-
.tag('interaction', 'prompt')
|
|
793
|
-
.kind('interaction')
|
|
794
|
-
.param('question', { type: 'string', description: '要向 Owner 提出的问题文本' }, true)
|
|
795
|
-
.param('type', { type: 'string', description: '问题类型: text(文本输入)、number(数字输入)、confirm(是/否确认)、pick(选项选择)。默认 text' })
|
|
796
|
-
.param('options', { type: 'array', description: '选项列表(type=pick 时必填),每项为字符串,如 ["选项A","选项B","选项C"]' })
|
|
797
|
-
.param('default_value', { type: 'string', description: 'Owner 超时未回复时使用的默认值' })
|
|
798
|
-
.param('timeout', { type: 'number', description: '等待 Owner 回复的超时时间(秒),默认 120' })
|
|
799
|
-
.execute(async (args, context) => {
|
|
800
|
-
if (!context?.message) {
|
|
801
|
-
return 'Error: 当前上下文没有消息来源,无法向 Owner 提问。请改为在回复中直接询问。';
|
|
802
|
-
}
|
|
803
|
-
if (!pluginRef) {
|
|
804
|
-
return 'Error: 插件实例不可用,无法创建交互式提问。请改为在回复中直接询问。';
|
|
805
|
-
}
|
|
806
|
-
const timeoutMs = (args.timeout ?? 120) * 1000;
|
|
807
|
-
const questionType = args.type || 'text';
|
|
808
|
-
// 从 adapter 的 bot 配置中查找 owner
|
|
809
|
-
const platform = context.platform;
|
|
810
|
-
const botId = context.botId;
|
|
811
|
-
const adapter = pluginRef.inject(platform);
|
|
812
|
-
const bot = adapter?.bots?.get(botId);
|
|
813
|
-
const botOwner = bot?.$config?.owner;
|
|
814
|
-
const isPrivateOwner = context.scope === 'private'
|
|
815
|
-
&& botOwner != null && String(context.senderId) === String(botOwner);
|
|
816
|
-
// ── 私聊 + 发送者是 Owner → 直接用 Prompt(原有行为) ──
|
|
817
|
-
if (isPrivateOwner) {
|
|
818
|
-
return askViaPrompt(pluginRef, context.message, args, questionType, timeoutMs);
|
|
819
|
-
}
|
|
820
|
-
// ── 非私聊 Owner → 必须通过私聊向 Owner 确认 ──
|
|
821
|
-
if (!botOwner) {
|
|
822
|
-
return 'Error: 当前 Bot 未配置 owner,无法进行安全确认。请在 bots 配置中设置 owner 字段。';
|
|
823
|
-
}
|
|
824
|
-
if (!adapter || typeof adapter.sendMessage !== 'function') {
|
|
825
|
-
return `Error: 无法获取适配器 ${platform},无法向 Owner 发送私聊确认。`;
|
|
826
|
-
}
|
|
827
|
-
// 构建发送给 Owner 的问题文本(包含来源上下文)
|
|
828
|
-
const sourceInfo = context.scope !== 'private'
|
|
829
|
-
? `来源: ${context.scope}(${context.sceneId}) 用户: ${context.senderId}`
|
|
830
|
-
: `来源: 私聊 用户: ${context.senderId}`;
|
|
831
|
-
let questionText = `🔐 AI 安全确认\n${sourceInfo}\n\n${args.question}`;
|
|
832
|
-
if (questionType === 'confirm') {
|
|
833
|
-
questionText += '\n输入"yes"以确认';
|
|
834
|
-
}
|
|
835
|
-
else if (questionType === 'pick' && args.options?.length) {
|
|
836
|
-
questionText += '\n' + args.options.map((o, i) => `${i + 1}.${o}`).join('\n');
|
|
837
|
-
}
|
|
838
|
-
else if (questionType === 'number') {
|
|
839
|
-
questionText += '\n(请输入数字)';
|
|
840
|
-
}
|
|
841
|
-
try {
|
|
842
|
-
await adapter.sendMessage({
|
|
843
|
-
context: platform,
|
|
844
|
-
bot: botId,
|
|
845
|
-
id: botOwner,
|
|
846
|
-
type: 'private',
|
|
847
|
-
content: questionText,
|
|
848
|
-
});
|
|
849
|
-
}
|
|
850
|
-
catch (e) {
|
|
851
|
-
return `Error: 无法向 Owner 发送私聊消息: ${errMsg(e)}`;
|
|
852
|
-
}
|
|
853
|
-
// 注册一次性中间件等待 Owner 私聊回复
|
|
854
|
-
return new Promise((resolve) => {
|
|
855
|
-
const middleware = async (message, next) => {
|
|
856
|
-
if (message.$channel?.type !== 'private')
|
|
857
|
-
return next();
|
|
858
|
-
if (String(message.$sender.id) !== String(botOwner))
|
|
859
|
-
return next();
|
|
860
|
-
if (String(message.$bot) !== String(botId))
|
|
861
|
-
return next();
|
|
862
|
-
dispose();
|
|
863
|
-
clearTimeout(timer);
|
|
864
|
-
const raw = message.$raw;
|
|
865
|
-
resolve(formatOwnerResponse(raw, questionType, args));
|
|
866
|
-
};
|
|
867
|
-
const dispose = pluginRef.addMiddleware(middleware);
|
|
868
|
-
const timer = setTimeout(() => {
|
|
869
|
-
dispose();
|
|
870
|
-
if (args.default_value != null) {
|
|
871
|
-
resolve(String(args.default_value));
|
|
872
|
-
}
|
|
873
|
-
else {
|
|
874
|
-
resolve('Owner 未在规定时间内响应,操作已取消。');
|
|
875
|
-
}
|
|
876
|
-
}, timeoutMs);
|
|
877
|
-
});
|
|
878
|
-
}));
|
|
41
|
+
tools.push(createReadFileTool());
|
|
42
|
+
tools.push(createWriteFileTool());
|
|
43
|
+
tools.push(createEditFileTool());
|
|
44
|
+
tools.push(createListDirTool());
|
|
45
|
+
tools.push(createGlobTool());
|
|
46
|
+
tools.push(createGrepTool());
|
|
47
|
+
tools.push(createBashTool());
|
|
48
|
+
tools.push(createWebSearchTool());
|
|
49
|
+
tools.push(createWebFetchTool());
|
|
50
|
+
tools.push(createTodoReadTool(DATA_DIR));
|
|
51
|
+
tools.push(createTodoWriteTool(DATA_DIR));
|
|
52
|
+
tools.push(createReadMemoryTool(DATA_DIR));
|
|
53
|
+
tools.push(createWriteMemoryTool(DATA_DIR));
|
|
54
|
+
tools.push(createActivateSkillTool({
|
|
55
|
+
skillFileLookup,
|
|
56
|
+
skillDirList,
|
|
57
|
+
skillMaxChars,
|
|
58
|
+
}));
|
|
59
|
+
tools.push(createInstallSkillTool());
|
|
60
|
+
tools.push(createAskUserTool(pluginRef));
|
|
879
61
|
return tools;
|
|
880
62
|
}
|
|
881
63
|
//# sourceMappingURL=builtin-tools.js.map
|