@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
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -7,10 +7,13 @@ Zhin AI Agent 组合层:在 `@zhin.js/core` 的类型与 Provider 之上,提
|
|
|
7
7
|
- 🤖 **Agent 循环**:`Agent` / `createAgent`,支持工具调用、迭代与事件
|
|
8
8
|
- 📝 **会话管理**:`SessionManager`、内存/数据库会话、`SessionManager.generateId`
|
|
9
9
|
- 🧠 **ZhinAgent**:与 Zhin 消息流集成的智能体(SOUL/TOOLS/AGENTS、工具收集、执行策略)
|
|
10
|
+
- �️ **6 层 Bash 安全**:`ExecPolicy` 纵深防御(危险黑名单、环境变量剥离、wrapper 剥离、复合命令拆分、只读放行、交互式审批)
|
|
11
|
+
- 📂 **文件访问安全**:`FilePolicy` 路径检查、设备路径拦截、命令读写分类
|
|
12
|
+
- 📋 **10 段系统提示词**:`PromptBuilder` 结构化 prompt(Identity、System、Tasks、Actions、Tools、Communication、Skills、Active Skills、Memory、Bootstrap)
|
|
10
13
|
- 🔌 **框架挂载**:`initAgentModule()` 注册 `ctx.ai`、定时任务、DB 模型等
|
|
11
14
|
- 📦 **上下文与记忆**:`ContextManager`、`ConversationMemory`、`UserProfileStore`
|
|
12
15
|
- ⏰ **跟进与定时**:`FollowUpManager`、`PersistentCronEngine`、cron 工具
|
|
13
|
-
- 🔧 **内置工具**:
|
|
16
|
+
- 🔧 **内置工具**:bash、read_file、write_file、ask_user、web_search、chat_history 等
|
|
14
17
|
- 📐 **会话压缩**:`compactSession`、token 估算、总结与裁剪
|
|
15
18
|
- 🪝 **Hook 系统**:`registerAIHook`、`triggerAIHook` 等
|
|
16
19
|
|
|
@@ -76,7 +79,9 @@ const result = await agent.run('你好')
|
|
|
76
79
|
| 初始化 | `initAgentModule` |
|
|
77
80
|
| Agent | `Agent`, `createAgent`, `formatToolTitle` |
|
|
78
81
|
| 服务与会话 | `AIService`, `SessionManager`, `MemorySessionManager`, `DatabaseSessionManager`, `createMemorySessionManager`, `createDatabaseSessionManager` |
|
|
79
|
-
| ZhinAgent | `ZhinAgent`,以及 config / exec-policy / tool-collector / prompt / builtin-tools 等子模块 |
|
|
82
|
+
| ZhinAgent | `ZhinAgent`,以及 config / exec-policy / file-policy / tool-collector / prompt / builtin-tools 等子模块 |
|
|
83
|
+
| 安全策略 | `checkExecPolicy`, `applyExecPolicyToTools`, `isDangerousCommand`, `stripEnvVarPrefix`, `stripSafeWrappers`, `splitCompoundCommand`, `extractCommandName`, `ExecPolicyResult`, `checkFileAccess`, `classifyBashCommand`, `isBlockedDevicePath` |
|
|
84
|
+
| 提示词构建 | `buildRichSystemPrompt`, `buildEnhancedPersona`, `buildUserMessageWithHistory`, `buildContextHint` |
|
|
80
85
|
| 上下文与记忆 | `ContextManager`, `createContextManager`, `ConversationMemory`, `UserProfileStore` |
|
|
81
86
|
| 跟进与定时 | `FollowUpManager`, `PersistentCronEngine`, `createCronTools`, `setCronManager`, `getCronManager` |
|
|
82
87
|
| 压缩与 Bootstrap | `compactSession`, `estimateTokens`, `loadBootstrapFiles`, `loadSoulPersona`, `loadToolsGuide`, `loadAgentsMemory` |
|
|
@@ -190,8 +195,9 @@ src/
|
|
|
190
195
|
├── hooks.ts
|
|
191
196
|
├── output.ts
|
|
192
197
|
├── tools.ts
|
|
193
|
-
├── builtin-tools.ts
|
|
198
|
+
├── builtin-tools.ts # 内置工具(bash、read_file、ask_user 等)
|
|
194
199
|
├── tone-detector.ts
|
|
200
|
+
├── file-policy.ts # 文件访问安全(路径检查、设备拦截、命令分类)
|
|
195
201
|
├── init.ts # initAgentModule 精简入口(委托子模块)
|
|
196
202
|
├── init/ # init 子模块(从 init.ts 拆分)
|
|
197
203
|
│ ├── shared-refs.ts
|
|
@@ -207,11 +213,11 @@ src/
|
|
|
207
213
|
│ └── register-builtin-tools.ts
|
|
208
214
|
└── zhin-agent/ # ZhinAgent 及子模块
|
|
209
215
|
├── index.ts # ZhinAgent 主类
|
|
210
|
-
├── config.ts
|
|
211
|
-
├── exec-policy.ts
|
|
212
|
-
├── tool-collector.ts
|
|
213
|
-
├── prompt.ts
|
|
214
|
-
└── builtin-tools.ts
|
|
216
|
+
├── config.ts # 配置与常量(ModelSizeHint、KEYWORD_TRIGGERS 等)
|
|
217
|
+
├── exec-policy.ts # Bash 执行安全(6 层纵深防御)
|
|
218
|
+
├── tool-collector.ts # 工具收集与过滤
|
|
219
|
+
├── prompt.ts # 系统提示词构建器(10 段结构化架构)
|
|
220
|
+
└── builtin-tools.ts # ZhinAgent 专用内置工具
|
|
215
221
|
```
|
|
216
222
|
|
|
217
223
|
### 构建
|
package/lib/builtin-tools.d.ts
CHANGED
|
@@ -7,11 +7,15 @@
|
|
|
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
|
*/
|
|
14
|
+
import { type Plugin } from '@zhin.js/core';
|
|
13
15
|
import { ZhinTool } from '@zhin.js/core';
|
|
14
16
|
export interface BuiltinToolsOptions {
|
|
17
|
+
/** 插件实例,用于 ask_user 工具创建 Prompt 交互 */
|
|
18
|
+
plugin?: Plugin;
|
|
15
19
|
/** Max chars for skill instruction extraction (model-size-aware) */
|
|
16
20
|
skillInstructionMaxChars?: number;
|
|
17
21
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builtin-tools.d.ts","sourceRoot":"","sources":["../src/builtin-tools.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"builtin-tools.d.ts","sourceRoot":"","sources":["../src/builtin-tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,EAAkB,KAAK,MAAM,EAAuB,MAAM,eAAe,CAAC;AACjF,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AA4HzC,MAAM,WAAW,mBAAmB;IAClC,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC;;OAEG;IACH,wBAAwB,CAAC,EAAE,MAAM,MAAM,EAAE,CAAC;IAC1C;;;OAGG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CACxD;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,CAAC,EAAE,mBAAmB,GAAG,QAAQ,EAAE,CAirB5E"}
|
package/lib/builtin-tools.js
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
|
*/
|
|
@@ -14,13 +15,104 @@ import * as fs from 'fs';
|
|
|
14
15
|
import * as path from 'path';
|
|
15
16
|
import { exec } from 'child_process';
|
|
16
17
|
import { promisify } from 'util';
|
|
17
|
-
import { Logger } from '@zhin.js/core';
|
|
18
|
+
import { Logger, Prompt } from '@zhin.js/core';
|
|
18
19
|
import { ZhinTool } from '@zhin.js/core';
|
|
19
|
-
import { assertFileAccess, checkBashCommandSafety, shellEscape } from './file-policy.js';
|
|
20
|
+
import { assertFileAccess, checkBashCommandSafety, shellEscape, isBlockedDevicePath, MAX_READ_FILE_SIZE, MAX_EDIT_FILE_SIZE, classifyBashCommand, isFileStale, } from './file-policy.js';
|
|
20
21
|
import { errMsg, expandHome, getDataDir, mergeSkillDirsWithResolver, nodeErrToFileMessage, } from './discovery-utils.js';
|
|
21
22
|
import { checkSkillDeps, extractSkillInstructions } from './discover-skills.js';
|
|
22
23
|
const execAsync = promisify(exec);
|
|
23
24
|
const logger = new Logger(null, 'builtin-tools');
|
|
25
|
+
// ── 引号归一化 + 模糊匹配(参考 Claude Code FileEditTool/utils.ts) ──
|
|
26
|
+
/** 将弯引号归一化为直引号 */
|
|
27
|
+
function normalizeQuotes(str) {
|
|
28
|
+
return str
|
|
29
|
+
.replace(/\u2018/g, "'") // '
|
|
30
|
+
.replace(/\u2019/g, "'") // '
|
|
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
|
+
}
|
|
24
116
|
/**
|
|
25
117
|
* 创建所有内置系统工具
|
|
26
118
|
*/
|
|
@@ -29,10 +121,11 @@ export function createBuiltinTools(options) {
|
|
|
29
121
|
const skillMaxChars = options?.skillInstructionMaxChars ?? 4000;
|
|
30
122
|
const skillDirList = () => mergeSkillDirsWithResolver(options?.pluginSkillRootsResolver);
|
|
31
123
|
const skillFileLookup = options?.skillFileLookup;
|
|
124
|
+
const pluginRef = options?.plugin;
|
|
32
125
|
const tools = [];
|
|
33
|
-
// ── read_file(清晰描述 +
|
|
126
|
+
// ── read_file(清晰描述 + 强关键词 + 图片检测 + 安全防护) ──
|
|
34
127
|
tools.push(new ZhinTool('read_file')
|
|
35
|
-
.desc('
|
|
128
|
+
.desc('读取指定路径的文件内容。用于查看、打开或读取任意文本文件。图片文件返回 Base64 数据。')
|
|
36
129
|
.keyword('读文件', '读取文件', '查看文件', '打开文件', '文件内容', 'read file', 'read', 'cat', '查看', '打开')
|
|
37
130
|
.tag('file', 'read')
|
|
38
131
|
.kind('file')
|
|
@@ -42,8 +135,25 @@ export function createBuiltinTools(options) {
|
|
|
42
135
|
.execute(async (args) => {
|
|
43
136
|
try {
|
|
44
137
|
const fp = expandHome(args.file_path);
|
|
138
|
+
// 设备路径拦截(参考 Claude Code BLOCKED_DEVICE_PATHS)
|
|
139
|
+
if (isBlockedDevicePath(fp)) {
|
|
140
|
+
return `Error: 禁止读取设备文件 ${fp}(会导致进程挂起或注入攻击)`;
|
|
141
|
+
}
|
|
45
142
|
assertFileAccess(fp);
|
|
46
143
|
const stat = await fs.promises.stat(fp);
|
|
144
|
+
// 文件大小限制(参考 Claude Code MAX_EDIT_FILE_SIZE)
|
|
145
|
+
if (stat.size > MAX_READ_FILE_SIZE) {
|
|
146
|
+
return `Error: 文件过大 (${(stat.size / 1024 / 1024).toFixed(1)} MiB),超过 ${MAX_READ_FILE_SIZE / 1024 / 1024} MiB 限制。请使用 offset/limit 分段读取。`;
|
|
147
|
+
}
|
|
148
|
+
// 图片文件检测(参考 Claude Code FileReadTool 的图片处理)
|
|
149
|
+
if (isImageFile(fp)) {
|
|
150
|
+
const buffer = await fs.promises.readFile(fp);
|
|
151
|
+
const ext = path.extname(fp).toLowerCase().replace('.', '');
|
|
152
|
+
const mimeType = ext === 'jpg' ? 'jpeg' : ext === 'svg' ? 'svg+xml' : ext;
|
|
153
|
+
const b64 = buffer.toString('base64');
|
|
154
|
+
const sizeKb = (buffer.length / 1024).toFixed(1);
|
|
155
|
+
return `[Image: ${path.basename(fp)}, ${sizeKb} KB, type: image/${mimeType}]\ndata:image/${mimeType};base64,${b64.slice(0, 200)}...(total ${b64.length} chars)`;
|
|
156
|
+
}
|
|
47
157
|
const content = await fs.promises.readFile(fp, 'utf-8');
|
|
48
158
|
const lines = content.split('\n');
|
|
49
159
|
const offset = args.offset ?? 0;
|
|
@@ -76,9 +186,9 @@ export function createBuiltinTools(options) {
|
|
|
76
186
|
return nodeErrToFileMessage(e, args.file_path, 'write');
|
|
77
187
|
}
|
|
78
188
|
}));
|
|
79
|
-
// ── edit_file
|
|
189
|
+
// ── edit_file(支持精确匹配 + 引号归一化模糊匹配)──
|
|
80
190
|
tools.push(new ZhinTool('edit_file')
|
|
81
|
-
.desc('在文件中查找并替换一段文本。old_string
|
|
191
|
+
.desc('在文件中查找并替换一段文本。old_string 必须在文件中精确存在且唯一;建议包含完整行或足够上下文以避免重复匹配。支持弯引号/直引号自动归一化。')
|
|
82
192
|
.keyword('编辑文件', '修改文件', '替换内容', '查找替换', 'edit file', 'edit', '修改', '替换')
|
|
83
193
|
.tag('file', 'edit')
|
|
84
194
|
.kind('file')
|
|
@@ -89,13 +199,30 @@ export function createBuiltinTools(options) {
|
|
|
89
199
|
try {
|
|
90
200
|
const fp = expandHome(args.file_path);
|
|
91
201
|
assertFileAccess(fp);
|
|
202
|
+
// 文件大小限制
|
|
203
|
+
const stat = await fs.promises.stat(fp);
|
|
204
|
+
if (stat.size > MAX_EDIT_FILE_SIZE) {
|
|
205
|
+
return `Error: 文件过大 (${(stat.size / 1024 / 1024).toFixed(1)} MiB),超过 ${MAX_EDIT_FILE_SIZE / 1024 / 1024} MiB 限制。`;
|
|
206
|
+
}
|
|
207
|
+
// 记录 mtime 用于防并发覆写
|
|
208
|
+
const mtimeBefore = stat.mtimeMs;
|
|
92
209
|
const content = await fs.promises.readFile(fp, 'utf-8');
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
210
|
+
// 精确匹配 → 引号归一化模糊匹配
|
|
211
|
+
const matchResult = findActualStringInFile(content, args.old_string);
|
|
212
|
+
if (!matchResult)
|
|
213
|
+
return `Error: old_string not found in file. Make sure it matches exactly (also tried quote normalization).`;
|
|
214
|
+
if (matchResult.count > 1)
|
|
215
|
+
return `Warning: old_string appears ${matchResult.count} times. Please provide more context to make it unique.`;
|
|
216
|
+
// 如果通过引号归一化匹配,保持文件的引号风格
|
|
217
|
+
const effectiveNew = matchResult.wasNormalized
|
|
218
|
+
? preserveQuoteStyleInEdit(args.old_string, matchResult.actual, args.new_string)
|
|
219
|
+
: args.new_string;
|
|
220
|
+
const newContent = content.replace(matchResult.actual, effectiveNew);
|
|
221
|
+
// 写入前再检查 mtime 防止并发修改
|
|
222
|
+
const currentStat = await fs.promises.stat(fp);
|
|
223
|
+
if (isFileStale(mtimeBefore, currentStat.mtimeMs)) {
|
|
224
|
+
return `Error: 文件 ${fp} 在读取后被外部修改。请重新读取文件后再编辑,避免覆盖他人的修改。`;
|
|
225
|
+
}
|
|
99
226
|
await fs.promises.writeFile(fp, newContent, 'utf-8');
|
|
100
227
|
const oldLines = args.old_string.split('\n');
|
|
101
228
|
const newLines = args.new_string.split('\n');
|
|
@@ -158,25 +285,76 @@ export function createBuiltinTools(options) {
|
|
|
158
285
|
return `Error: ${errMsg(e)}`;
|
|
159
286
|
}
|
|
160
287
|
}));
|
|
161
|
-
// ── grep ──
|
|
288
|
+
// ── grep(支持上下文行、大小写、多行、ripgrep 自动检测) ──
|
|
162
289
|
tools.push(new ZhinTool('grep')
|
|
163
|
-
.desc('
|
|
164
|
-
.keyword('搜索', '查找内容', 'grep', '正则')
|
|
290
|
+
.desc('按正则搜索文件内容,返回匹配行和行号。优先使用 ripgrep (rg),回退到 grep。')
|
|
291
|
+
.keyword('搜索', '查找内容', 'grep', '正则', 'rg', 'ripgrep')
|
|
165
292
|
.tag('search', 'regex')
|
|
166
293
|
.kind('file')
|
|
167
294
|
.param('pattern', { type: 'string', description: '正则表达式' }, true)
|
|
168
295
|
.param('path', { type: 'string', description: '搜索路径(默认 .)' })
|
|
169
296
|
.param('include', { type: 'string', description: '文件类型过滤(如 *.ts)' })
|
|
297
|
+
.param('context', { type: 'number', description: '匹配行上下文行数(-C 参数)' })
|
|
298
|
+
.param('before', { type: 'number', description: '匹配行之前显示行数(-B 参数)' })
|
|
299
|
+
.param('after', { type: 'number', description: '匹配行之后显示行数(-A 参数)' })
|
|
300
|
+
.param('ignore_case', { type: 'boolean', description: '大小写不敏感搜索(-i 参数)' })
|
|
301
|
+
.param('multiline', { type: 'boolean', description: '多行模式,. 匹配换行(仅 ripgrep 支持)' })
|
|
302
|
+
.param('limit', { type: 'number', description: '最多返回结果行数(默认 50)' })
|
|
170
303
|
.execute(async (args) => {
|
|
171
304
|
try {
|
|
172
305
|
const searchPath = args.path || '.';
|
|
173
306
|
assertFileAccess(path.resolve(process.cwd(), searchPath));
|
|
174
|
-
// 安全转义 pattern 和 include 参数防止命令注入
|
|
175
307
|
const safePattern = shellEscape(args.pattern);
|
|
176
308
|
const safePath = shellEscape(searchPath);
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
309
|
+
const limit = args.limit ?? 50;
|
|
310
|
+
// 检测 ripgrep 是否可用
|
|
311
|
+
let useRipgrep = false;
|
|
312
|
+
try {
|
|
313
|
+
await execAsync('rg --version', { timeout: 3000 });
|
|
314
|
+
useRipgrep = true;
|
|
315
|
+
}
|
|
316
|
+
catch { /* ripgrep 不可用,回退到 grep */ }
|
|
317
|
+
let cmd;
|
|
318
|
+
if (useRipgrep) {
|
|
319
|
+
// ripgrep 命令构建
|
|
320
|
+
const rgFlags = ['-n']; // 行号
|
|
321
|
+
if (args.ignore_case)
|
|
322
|
+
rgFlags.push('-i');
|
|
323
|
+
if (args.multiline)
|
|
324
|
+
rgFlags.push('-U', '--multiline-dotall');
|
|
325
|
+
if (args.context)
|
|
326
|
+
rgFlags.push(`-C${args.context}`);
|
|
327
|
+
else {
|
|
328
|
+
if (args.before)
|
|
329
|
+
rgFlags.push(`-B${args.before}`);
|
|
330
|
+
if (args.after)
|
|
331
|
+
rgFlags.push(`-A${args.after}`);
|
|
332
|
+
}
|
|
333
|
+
if (args.include)
|
|
334
|
+
rgFlags.push(`--glob=${shellEscape(args.include)}`);
|
|
335
|
+
cmd = `rg ${rgFlags.join(' ')} ${safePattern} ${safePath} 2>/dev/null | head -${limit}`;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
// 传统 grep 回退
|
|
339
|
+
const grepFlags = ['-rn'];
|
|
340
|
+
if (args.ignore_case)
|
|
341
|
+
grepFlags.push('-i');
|
|
342
|
+
if (args.context)
|
|
343
|
+
grepFlags.push(`-C${args.context}`);
|
|
344
|
+
else {
|
|
345
|
+
if (args.before)
|
|
346
|
+
grepFlags.push(`-B${args.before}`);
|
|
347
|
+
if (args.after)
|
|
348
|
+
grepFlags.push(`-A${args.after}`);
|
|
349
|
+
}
|
|
350
|
+
const includeFlag = args.include ? `--include=${shellEscape(args.include)}` : '';
|
|
351
|
+
cmd = `grep ${grepFlags.join(' ')} ${includeFlag} ${safePattern} ${safePath} 2>/dev/null | head -${limit}`;
|
|
352
|
+
}
|
|
353
|
+
const { stdout } = await execAsync(cmd, { cwd: process.cwd() });
|
|
354
|
+
const engine = useRipgrep ? '(ripgrep)' : '(grep)';
|
|
355
|
+
return stdout.trim()
|
|
356
|
+
? `${engine}\n${stdout.trim()}`
|
|
357
|
+
: `No matches for '${args.pattern}' ${engine}`;
|
|
180
358
|
}
|
|
181
359
|
catch (e) {
|
|
182
360
|
const err = e;
|
|
@@ -185,9 +363,9 @@ export function createBuiltinTools(options) {
|
|
|
185
363
|
return `Error: ${errMsg(e)}`;
|
|
186
364
|
}
|
|
187
365
|
}));
|
|
188
|
-
// ── bash ──
|
|
366
|
+
// ── bash(安全检查 + 命令读写分类) ──
|
|
189
367
|
tools.push(new ZhinTool('bash')
|
|
190
|
-
.desc('执行 Shell
|
|
368
|
+
.desc('执行 Shell 命令(带超时保护和命令分类)。返回结果中会标注命令类型(只读/搜索/写入)。')
|
|
191
369
|
.keyword('执行', '运行', '命令', '终端', 'shell', 'bash')
|
|
192
370
|
.tag('shell', 'exec')
|
|
193
371
|
.kind('shell')
|
|
@@ -202,33 +380,47 @@ export function createBuiltinTools(options) {
|
|
|
202
380
|
const safety = checkBashCommandSafety(cmd);
|
|
203
381
|
if (!safety.safe)
|
|
204
382
|
return `Error: ${safety.reason}`;
|
|
383
|
+
// 命令读写分类
|
|
384
|
+
const classification = classifyBashCommand(cmd);
|
|
205
385
|
const { stdout, stderr } = await execAsync(cmd, {
|
|
206
386
|
cwd: args.cwd || process.cwd(),
|
|
207
387
|
timeout,
|
|
208
388
|
maxBuffer: 1024 * 1024,
|
|
209
389
|
});
|
|
210
390
|
let result = '';
|
|
391
|
+
const tag = classification.isReadOnly
|
|
392
|
+
? (classification.isSearch ? '[搜索]' : classification.isList ? '[列出]' : '[只读]')
|
|
393
|
+
: '[执行]';
|
|
211
394
|
if (stdout.trim())
|
|
212
395
|
result += `STDOUT:\n${stdout.trim()}`;
|
|
213
396
|
if (stderr.trim())
|
|
214
397
|
result += `${result ? '\n' : ''}STDERR:\n${stderr.trim()}`;
|
|
215
|
-
return result || '(no output)'
|
|
398
|
+
return `${tag} ${result || '(no output)'}`;
|
|
216
399
|
}
|
|
217
400
|
catch (e) {
|
|
218
401
|
const err = e;
|
|
219
402
|
return `Error (exit ${err.code || '?'}): ${errMsg(e)}\nSTDOUT:\n${err.stdout || ''}\nSTDERR:\n${err.stderr || ''}`;
|
|
220
403
|
}
|
|
221
404
|
}));
|
|
222
|
-
// ── web_search(搜索网页,返回标题、URL
|
|
405
|
+
// ── web_search(搜索网页,返回标题、URL、摘要 + 域名过滤 + 次数限制) ──
|
|
406
|
+
let searchCount = 0;
|
|
407
|
+
const MAX_SEARCH_COUNT = 20; // 单次会话搜索次数上限
|
|
223
408
|
tools.push(new ZhinTool('web_search')
|
|
224
|
-
.desc('在互联网上搜索,返回匹配的标题、URL
|
|
409
|
+
.desc('在互联网上搜索,返回匹配的标题、URL 和摘要片段。用于查资料、找网页。支持域名过滤。')
|
|
225
410
|
.keyword('搜索', '网上搜', '网页搜索', '搜索引擎', 'search', 'google', '百度', '查询', '搜一下')
|
|
226
411
|
.tag('web', 'search')
|
|
227
412
|
.kind('web')
|
|
228
413
|
.param('query', { type: 'string', description: '搜索关键词或完整查询语句' }, true)
|
|
229
414
|
.param('limit', { type: 'number', description: '返回结果数量(默认 5,建议 1–10)' })
|
|
415
|
+
.param('allowed_domains', { type: 'array', description: '仅保留这些域名的结果(可选,如 ["github.com", "stackoverflow.com"])' })
|
|
416
|
+
.param('blocked_domains', { type: 'array', description: '排除这些域名的结果(可选)' })
|
|
230
417
|
.execute(async (args) => {
|
|
231
418
|
try {
|
|
419
|
+
// 搜索次数限制
|
|
420
|
+
searchCount++;
|
|
421
|
+
if (searchCount > MAX_SEARCH_COUNT) {
|
|
422
|
+
return `Error: 搜索次数已达上限 (${MAX_SEARCH_COUNT})。请使用已获取的信息回答。`;
|
|
423
|
+
}
|
|
232
424
|
const limit = args.limit ?? 5;
|
|
233
425
|
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(args.query)}`;
|
|
234
426
|
const res = await fetch(url, {
|
|
@@ -266,37 +458,97 @@ export function createBuiltinTools(options) {
|
|
|
266
458
|
results.push({ title, url: href, snippet });
|
|
267
459
|
}
|
|
268
460
|
}
|
|
269
|
-
|
|
461
|
+
// 域名过滤
|
|
462
|
+
let filtered = results;
|
|
463
|
+
if (args.allowed_domains?.length) {
|
|
464
|
+
const allowed = new Set(args.allowed_domains.map(d => d.toLowerCase()));
|
|
465
|
+
filtered = filtered.filter(r => {
|
|
466
|
+
try {
|
|
467
|
+
return allowed.has(new URL(r.url).hostname.toLowerCase());
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (args.blocked_domains?.length) {
|
|
475
|
+
const blocked = new Set(args.blocked_domains.map(d => d.toLowerCase()));
|
|
476
|
+
filtered = filtered.filter(r => {
|
|
477
|
+
try {
|
|
478
|
+
return !blocked.has(new URL(r.url).hostname.toLowerCase());
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
if (filtered.length === 0)
|
|
270
486
|
return 'No results found.';
|
|
271
|
-
return
|
|
487
|
+
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');
|
|
272
488
|
}
|
|
273
489
|
catch (e) {
|
|
274
490
|
return `Error: ${errMsg(e)}`;
|
|
275
491
|
}
|
|
276
492
|
}));
|
|
277
|
-
// ── web_fetch(抓取 URL
|
|
493
|
+
// ── web_fetch(抓取 URL 并提取正文 + SSRF 防护 + 改进的内容提取) ──
|
|
278
494
|
tools.push(new ZhinTool('web_fetch')
|
|
279
|
-
.desc('抓取指定 URL
|
|
495
|
+
.desc('抓取指定 URL 的网页内容并提取正文(去除广告、脚本等),返回可读文本。仅支持 http/https 协议。')
|
|
280
496
|
.keyword('抓取网页', '打开链接', '获取网页', '读网页', 'fetch', 'url', '链接内容', '网页内容')
|
|
281
497
|
.tag('web', 'fetch')
|
|
282
498
|
.kind('web')
|
|
283
499
|
.param('url', { type: 'string', description: '要抓取的完整 URL(需 http 或 https)' }, true)
|
|
500
|
+
.param('max_length', { type: 'number', description: '最大返回字符数(默认 20480)' })
|
|
284
501
|
.execute(async (args) => {
|
|
285
502
|
try {
|
|
503
|
+
// SSRF 防护:仅允许 http/https 协议
|
|
504
|
+
let parsedUrl;
|
|
505
|
+
try {
|
|
506
|
+
parsedUrl = new URL(args.url);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return `Error: 无效的 URL 格式`;
|
|
510
|
+
}
|
|
511
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
512
|
+
return `Error: 仅支持 http/https 协议,拒绝 ${parsedUrl.protocol}`;
|
|
513
|
+
}
|
|
514
|
+
// 阻止内网地址(SSRF 关键防护)
|
|
515
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
516
|
+
if (hostname === 'localhost' ||
|
|
517
|
+
hostname === '127.0.0.1' ||
|
|
518
|
+
hostname === '::1' ||
|
|
519
|
+
hostname === '0.0.0.0' ||
|
|
520
|
+
hostname.endsWith('.local') ||
|
|
521
|
+
hostname.startsWith('10.') ||
|
|
522
|
+
hostname.startsWith('192.168.') ||
|
|
523
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) {
|
|
524
|
+
return `Error: 禁止访问内网地址 ${hostname}(SSRF 防护)`;
|
|
525
|
+
}
|
|
286
526
|
const response = await fetch(args.url, {
|
|
287
527
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ZhinBot/1.0)' },
|
|
288
528
|
signal: AbortSignal.timeout(15000),
|
|
529
|
+
redirect: 'follow',
|
|
289
530
|
});
|
|
290
531
|
if (!response.ok)
|
|
291
532
|
return `HTTP ${response.status}: ${response.statusText}`;
|
|
292
533
|
const html = await response.text();
|
|
534
|
+
// 改进的内容提取:去除脚本、样式、导航、页脚、表单等
|
|
293
535
|
const text = html
|
|
294
536
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
295
537
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
538
|
+
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
|
539
|
+
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
|
|
540
|
+
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, ' ')
|
|
541
|
+
.replace(/<form[^>]*>[\s\S]*?<\/form>/gi, '')
|
|
542
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
296
543
|
.replace(/<[^>]+>/g, ' ')
|
|
544
|
+
.replace(/ /gi, ' ')
|
|
545
|
+
.replace(/&/g, '&')
|
|
546
|
+
.replace(/</g, '<')
|
|
547
|
+
.replace(/>/g, '>')
|
|
548
|
+
.replace(/"/g, '"')
|
|
297
549
|
.replace(/\s+/g, ' ')
|
|
298
550
|
.trim();
|
|
299
|
-
const maxLen = 20 * 1024;
|
|
551
|
+
const maxLen = args.max_length ?? 20 * 1024;
|
|
300
552
|
return text.length > maxLen ? text.slice(0, maxLen) + '\n...(truncated)' : text;
|
|
301
553
|
}
|
|
302
554
|
catch (e) {
|
|
@@ -469,6 +721,62 @@ export function createBuiltinTools(options) {
|
|
|
469
721
|
return `Error: ${errMsg(e)}`;
|
|
470
722
|
}
|
|
471
723
|
}));
|
|
724
|
+
// ── ask_user(基于 Prompt 类的用户确认/提问工具) ──
|
|
725
|
+
tools.push(new ZhinTool('ask_user')
|
|
726
|
+
.desc('向用户发送问题,等待用户在聊天中回复。用于需要用户确认、补充信息或做出选择时。支持文本输入、数字输入、是/否确认、选项选择。')
|
|
727
|
+
.keyword('询问', '确认', '提问', '用户输入', 'ask', 'confirm', 'prompt', '选择', '请问')
|
|
728
|
+
.tag('interaction', 'prompt')
|
|
729
|
+
.kind('interaction')
|
|
730
|
+
.param('question', { type: 'string', description: '要向用户提出的问题文本' }, true)
|
|
731
|
+
.param('type', { type: 'string', description: '问题类型: text(文本输入)、number(数字输入)、confirm(是/否确认)、pick(选项选择)。默认 text' })
|
|
732
|
+
.param('options', { type: 'array', description: '选项列表(type=pick 时必填),每项为字符串,如 ["选项A","选项B","选项C"]' })
|
|
733
|
+
.param('default_value', { type: 'string', description: '用户超时未回复时使用的默认值' })
|
|
734
|
+
.param('timeout', { type: 'number', description: '等待用户回复的超时时间(秒),默认 120' })
|
|
735
|
+
.execute(async (args, context) => {
|
|
736
|
+
// 无消息上下文时无法使用(如子任务场景)
|
|
737
|
+
if (!context?.message) {
|
|
738
|
+
return 'Error: 当前上下文没有消息来源,无法向用户提问。请改为在回复中直接询问。';
|
|
739
|
+
}
|
|
740
|
+
if (!pluginRef) {
|
|
741
|
+
return 'Error: 插件实例不可用,无法创建交互式提问。请改为在回复中直接询问。';
|
|
742
|
+
}
|
|
743
|
+
const prompt = new Prompt(pluginRef, context.message);
|
|
744
|
+
const timeoutMs = (args.timeout ?? 120) * 1000;
|
|
745
|
+
const questionType = args.type || 'text';
|
|
746
|
+
try {
|
|
747
|
+
switch (questionType) {
|
|
748
|
+
case 'number': {
|
|
749
|
+
const defaultNum = args.default_value != null ? Number(args.default_value) : undefined;
|
|
750
|
+
const result = await prompt.number(args.question, timeoutMs, defaultNum, '输入超时,已取消');
|
|
751
|
+
return String(result);
|
|
752
|
+
}
|
|
753
|
+
case 'confirm': {
|
|
754
|
+
const result = await prompt.confirm(args.question, 'yes', timeoutMs, false, '确认超时,已取消');
|
|
755
|
+
return result ? 'yes' : 'no';
|
|
756
|
+
}
|
|
757
|
+
case 'pick': {
|
|
758
|
+
if (!args.options?.length) {
|
|
759
|
+
return 'Error: type=pick 时必须提供 options 选项列表';
|
|
760
|
+
}
|
|
761
|
+
const pickOptions = args.options.map((o) => ({ label: o, value: o }));
|
|
762
|
+
const result = await prompt.pick(args.question, {
|
|
763
|
+
type: 'text',
|
|
764
|
+
options: pickOptions,
|
|
765
|
+
timeout: timeoutMs,
|
|
766
|
+
}, '选择超时,已取消');
|
|
767
|
+
return String(result);
|
|
768
|
+
}
|
|
769
|
+
case 'text':
|
|
770
|
+
default: {
|
|
771
|
+
const result = await prompt.text(args.question, timeoutMs, args.default_value || '', '输入超时,已取消');
|
|
772
|
+
return result;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
catch (e) {
|
|
777
|
+
return `用户未响应或输入错误: ${errMsg(e)}`;
|
|
778
|
+
}
|
|
779
|
+
}));
|
|
472
780
|
return tools;
|
|
473
781
|
}
|
|
474
782
|
//# sourceMappingURL=builtin-tools.js.map
|