@zhin.js/agent 0.0.17 → 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.
Files changed (61) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +14 -8
  3. package/lib/builtin-tools.d.ts +5 -137
  4. package/lib/builtin-tools.d.ts.map +1 -1
  5. package/lib/builtin-tools.js +321 -732
  6. package/lib/builtin-tools.js.map +1 -1
  7. package/lib/discover-agents.d.ts +28 -0
  8. package/lib/discover-agents.d.ts.map +1 -0
  9. package/lib/discover-agents.js +116 -0
  10. package/lib/discover-agents.js.map +1 -0
  11. package/lib/discover-skills.d.ts +49 -0
  12. package/lib/discover-skills.d.ts.map +1 -0
  13. package/lib/discover-skills.js +297 -0
  14. package/lib/discover-skills.js.map +1 -0
  15. package/lib/discover-tools.d.ts +56 -0
  16. package/lib/discover-tools.d.ts.map +1 -0
  17. package/lib/discover-tools.js +263 -0
  18. package/lib/discover-tools.js.map +1 -0
  19. package/lib/discovery-utils.d.ts +27 -0
  20. package/lib/discovery-utils.d.ts.map +1 -0
  21. package/lib/discovery-utils.js +96 -0
  22. package/lib/discovery-utils.js.map +1 -0
  23. package/lib/file-policy.d.ts +41 -4
  24. package/lib/file-policy.d.ts.map +1 -1
  25. package/lib/file-policy.js +126 -4
  26. package/lib/file-policy.js.map +1 -1
  27. package/lib/index.d.ts +1 -1
  28. package/lib/index.d.ts.map +1 -1
  29. package/lib/index.js +1 -1
  30. package/lib/index.js.map +1 -1
  31. package/lib/init/create-zhin-agent.d.ts.map +1 -1
  32. package/lib/init/create-zhin-agent.js +3 -1
  33. package/lib/init/create-zhin-agent.js.map +1 -1
  34. package/lib/init/register-builtin-tools.d.ts.map +1 -1
  35. package/lib/init/register-builtin-tools.js +51 -54
  36. package/lib/init/register-builtin-tools.js.map +1 -1
  37. package/lib/zhin-agent/config.js +1 -1
  38. package/lib/zhin-agent/config.js.map +1 -1
  39. package/lib/zhin-agent/exec-policy.d.ts +48 -2
  40. package/lib/zhin-agent/exec-policy.d.ts.map +1 -1
  41. package/lib/zhin-agent/exec-policy.js +184 -23
  42. package/lib/zhin-agent/exec-policy.js.map +1 -1
  43. package/lib/zhin-agent/prompt.d.ts +14 -0
  44. package/lib/zhin-agent/prompt.d.ts.map +1 -1
  45. package/lib/zhin-agent/prompt.js +192 -45
  46. package/lib/zhin-agent/prompt.js.map +1 -1
  47. package/package.json +3 -3
  48. package/src/builtin-tools.ts +333 -835
  49. package/src/discover-agents.ts +138 -0
  50. package/src/discover-skills.ts +325 -0
  51. package/src/discover-tools.ts +302 -0
  52. package/src/discovery-utils.ts +96 -0
  53. package/src/file-policy.ts +152 -4
  54. package/src/index.ts +5 -1
  55. package/src/init/create-zhin-agent.ts +3 -1
  56. package/src/init/register-builtin-tools.ts +51 -62
  57. package/src/zhin-agent/config.ts +1 -1
  58. package/src/zhin-agent/exec-policy.ts +229 -24
  59. package/src/zhin-agent/prompt.ts +209 -47
  60. package/tests/exec-policy.test.ts +355 -0
  61. package/tests/file-policy.test.ts +189 -1
@@ -1,118 +1,140 @@
1
1
  /**
2
2
  * AI 内置系统工具
3
3
  *
4
- * 借鉴 OpenClaw/MicroClaw 的实用工具设计,为 ZhinAgent 提供:
5
- *
6
4
  * 文件工具: read_file, write_file, edit_file, list_dir, glob, grep
7
5
  * Shell: bash
8
6
  * 网络: web_search, web_fetch
9
7
  * 计划: todo_read, todo_write
10
8
  * 记忆: read_memory, write_memory (AGENTS.md)
11
9
  * 技能: activate_skill, install_skill
12
- * 会话: session_status, compact_session
13
- * 技能发现: 工作区 skills/ 目录自动扫描
14
- * 引导文件: SOUL.md, TOOLS.md, AGENTS.md 自动加载
10
+ * 交互: ask_user(基于 Prompt 类的用户确认/提问工具)
11
+ *
12
+ * 发现逻辑已拆分到 discover-skills.ts / discover-agents.ts / discover-tools.ts
15
13
  */
16
14
 
17
15
  import * as fs from 'fs';
18
- import * as os from 'os';
19
16
  import * as path from 'path';
20
17
  import { exec } from 'child_process';
21
18
  import { promisify } from 'util';
22
- import { Logger, type PropertySchema, type Plugin } from '@zhin.js/core';
19
+ import { Logger, Prompt, type Plugin, type PropertySchema } from '@zhin.js/core';
23
20
  import { ZhinTool } from '@zhin.js/core';
24
- import { assertFileAccess, checkBashCommandSafety, shellEscape } from './file-policy.js';
25
-
26
- // 从新模块中 re-export 向后兼容的函数
27
- export { loadSoulPersona, loadToolsGuide, loadAgentsMemory } from './bootstrap.js';
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';
26
+ import {
27
+ errMsg, expandHome, getDataDir, mergeSkillDirsWithResolver, nodeErrToFileMessage,
28
+ } from './discovery-utils.js';
29
+ import { checkSkillDeps, extractSkillInstructions } from './discover-skills.js';
28
30
 
29
31
  const execAsync = promisify(exec);
30
32
  const logger = new Logger(null, 'builtin-tools');
31
33
 
32
- function errMsg(e: unknown): string {
33
- return e instanceof Error ? e.message : String(e);
34
- }
34
+ // ── 引号归一化 + 模糊匹配(参考 Claude Code FileEditTool/utils.ts) ──
35
35
 
36
- /**
37
- * 获取数据目录路径
38
- */
39
- function getDataDir(): string {
40
- const dir = path.join(process.cwd(), 'data');
41
- fs.mkdirSync(dir, { recursive: true });
42
- return dir;
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
43
  }
44
44
 
45
- /** Workspace / ~/.zhin / data 下 skills 根目录(与 activate_skill 扫描顺序一致的前缀) */
46
- function buildStandardSkillDirs(): string[] {
47
- return [
48
- path.join(process.cwd(), 'skills'),
49
- path.join(os.homedir(), '.zhin', 'skills'),
50
- path.join(getDataDir(), 'skills'),
51
- ];
45
+ interface FuzzyMatchResult {
46
+ /** 文件中实际匹配到的字符串 */
47
+ actual: string;
48
+ /** 匹配次数 */
49
+ count: number;
50
+ /** 是否通过引号归一化匹配 */
51
+ wasNormalized: boolean;
52
52
  }
53
53
 
54
54
  /**
55
- * 从根插件树收集:根插件与**直接子插件**包目录下的 `skills/`(其下为 `<name>/SKILL.md`)
55
+ * 在文件内容中查找字符串,支持精确匹配和引号归一化模糊匹配。
56
+ * 参考 Claude Code `findActualString`。
56
57
  */
57
- export function collectPluginSkillSearchRoots(root: Plugin | null | undefined): string[] {
58
- if (!root) return [];
59
- const dirs: string[] = [];
60
- const push = (d: string) => {
61
- if (d && !dirs.includes(d)) dirs.push(d);
62
- };
63
- const fromPlugin = (p: Plugin) => {
64
- if (!p?.filePath) return;
65
- const dir = path.dirname(p.filePath);
66
- push(path.join(dir, 'skills'));
67
- // Also check package root when filePath is under src/ or lib/
68
- const dirName = path.basename(dir);
69
- if (dirName === 'src' || dirName === 'lib') {
70
- push(path.join(path.dirname(dir), 'skills'));
71
- }
72
- };
73
- fromPlugin(root);
74
- for (const child of root.children || []) {
75
- fromPlugin(child);
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 };
76
74
  }
77
- return dirs;
75
+
76
+ return null;
78
77
  }
79
78
 
80
79
  /**
81
- * 技能发现与 activate_skill 查找共用:标准目录 + 已加载插件包 skills/
80
+ * new_string 中的直引号替换为文件中原始的弯引号风格。
81
+ * 参考 Claude Code `preserveQuoteStyle`。
82
82
  */
83
- export function getSkillSearchDirectories(root?: Plugin | null): string[] {
84
- const list = [...buildStandardSkillDirs()];
85
- for (const d of collectPluginSkillSearchRoots(root ?? undefined)) {
86
- if (!list.includes(d)) list.push(d);
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('');
87
105
  }
88
- return list;
89
- }
90
-
91
- function mergeSkillDirsWithResolver(resolver?: () => string[]): string[] {
92
- const list = [...buildStandardSkillDirs()];
93
- for (const d of resolver?.() ?? []) {
94
- if (d && !list.includes(d)) list.push(d);
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('');
95
125
  }
96
- return list;
126
+ return result;
97
127
  }
98
128
 
99
- /** 展开路径中的 ~ 为实际 home 目录 */
100
- function expandHome(p: string): string {
101
- if (p === '~') return os.homedir();
102
- if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
103
- return p;
104
- }
129
+ // ── 图片格式检测(参考 Claude Code FileReadTool imageResizer) ──
105
130
 
106
- /** Node 文件错误转为 miniclawd 风格的结构化短句,便于模型区分并重试 */
107
- function nodeErrToFileMessage(err: unknown, filePath: string, kind: 'read' | 'write' | 'edit' | 'list'): string {
108
- const e = err as NodeJS.ErrnoException;
109
- if (e?.code === 'ENOENT') {
110
- if (kind === 'list') return `Error: Directory not found: ${filePath}`;
111
- return `Error: File not found: ${filePath}`;
112
- }
113
- if (e?.code === 'EACCES') return `Error: Permission denied: ${filePath}`;
114
- const action = kind === 'read' ? 'reading file' : kind === 'write' ? 'writing file' : kind === 'edit' ? 'editing file' : 'listing directory';
115
- return `Error ${action}: ${e?.message ?? String(err)}`;
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());
116
138
  }
117
139
 
118
140
  // ============================================================================
@@ -120,6 +142,8 @@ function nodeErrToFileMessage(err: unknown, filePath: string, kind: 'read' | 'wr
120
142
  // ============================================================================
121
143
 
122
144
  export interface BuiltinToolsOptions {
145
+ /** 插件实例,用于 ask_user 工具创建 Prompt 交互 */
146
+ plugin?: Plugin;
123
147
  /** Max chars for skill instruction extraction (model-size-aware) */
124
148
  skillInstructionMaxChars?: number;
125
149
  /**
@@ -141,13 +165,14 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
141
165
  const skillMaxChars = options?.skillInstructionMaxChars ?? 4000;
142
166
  const skillDirList = () => mergeSkillDirsWithResolver(options?.pluginSkillRootsResolver);
143
167
  const skillFileLookup = options?.skillFileLookup;
168
+ const pluginRef = options?.plugin;
144
169
 
145
170
  const tools: ZhinTool[] = [];
146
171
 
147
- // ── read_file(清晰描述 + 强关键词) ──
172
+ // ── read_file(清晰描述 + 强关键词 + 图片检测 + 安全防护) ──
148
173
  tools.push(
149
174
  new ZhinTool('read_file')
150
- .desc('读取指定路径的文件内容。用于查看、打开或读取任意文本文件。')
175
+ .desc('读取指定路径的文件内容。用于查看、打开或读取任意文本文件。图片文件返回 Base64 数据。')
151
176
  .keyword('读文件', '读取文件', '查看文件', '打开文件', '文件内容', 'read file', 'read', 'cat', '查看', '打开')
152
177
  .tag('file', 'read')
153
178
  .kind('file')
@@ -157,8 +182,27 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
157
182
  .execute(async (args) => {
158
183
  try {
159
184
  const fp = expandHome(args.file_path);
185
+ // 设备路径拦截(参考 Claude Code BLOCKED_DEVICE_PATHS)
186
+ if (isBlockedDevicePath(fp)) {
187
+ return `Error: 禁止读取设备文件 ${fp}(会导致进程挂起或注入攻击)`;
188
+ }
160
189
  assertFileAccess(fp);
161
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
+
162
206
  const content = await fs.promises.readFile(fp, 'utf-8');
163
207
  const lines = content.split('\n');
164
208
  const offset = args.offset ?? 0;
@@ -194,10 +238,10 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
194
238
  }),
195
239
  );
196
240
 
197
- // ── edit_file(old_text 必须精确匹配) ──
241
+ // ── edit_file(支持精确匹配 + 引号归一化模糊匹配)──
198
242
  tools.push(
199
243
  new ZhinTool('edit_file')
200
- .desc('在文件中查找并替换一段文本。old_string 必须在文件中精确存在且唯一;建议包含完整行或足够上下文以避免重复匹配。')
244
+ .desc('在文件中查找并替换一段文本。old_string 必须在文件中精确存在且唯一;建议包含完整行或足够上下文以避免重复匹配。支持弯引号/直引号自动归一化。')
201
245
  .keyword('编辑文件', '修改文件', '替换内容', '查找替换', 'edit file', 'edit', '修改', '替换')
202
246
  .tag('file', 'edit')
203
247
  .kind('file')
@@ -208,11 +252,32 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
208
252
  try {
209
253
  const fp = expandHome(args.file_path);
210
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;
211
262
  const content = await fs.promises.readFile(fp, 'utf-8');
212
- const count = content.split(args.old_string).length - 1;
213
- if (count === 0) return `Error: old_string not found in file. Make sure it matches exactly.`;
214
- if (count > 1) return `Warning: old_string appears ${count} times. Please provide more context to make it unique.`;
215
- const newContent = content.replace(args.old_string, args.new_string);
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
+ }
216
281
  await fs.promises.writeFile(fp, newContent, 'utf-8');
217
282
 
218
283
  const oldLines = args.old_string.split('\n');
@@ -284,29 +349,68 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
284
349
  }),
285
350
  );
286
351
 
287
- // ── grep ──
352
+ // ── grep(支持上下文行、大小写、多行、ripgrep 自动检测) ──
288
353
  tools.push(
289
354
  new ZhinTool('grep')
290
- .desc('按正则搜索文件内容,返回匹配行和行号')
291
- .keyword('搜索', '查找内容', 'grep', '正则')
355
+ .desc('按正则搜索文件内容,返回匹配行和行号。优先使用 ripgrep (rg),回退到 grep。')
356
+ .keyword('搜索', '查找内容', 'grep', '正则', 'rg', 'ripgrep')
292
357
  .tag('search', 'regex')
293
358
  .kind('file')
294
359
  .param('pattern', { type: 'string', description: '正则表达式' }, true)
295
360
  .param('path', { type: 'string', description: '搜索路径(默认 .)' })
296
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)' })
297
368
  .execute(async (args) => {
298
369
  try {
299
370
  const searchPath = args.path || '.';
300
371
  assertFileAccess(path.resolve(process.cwd(), searchPath));
301
- // 安全转义 pattern 和 include 参数防止命令注入
302
372
  const safePattern = shellEscape(args.pattern);
303
373
  const safePath = shellEscape(searchPath);
304
- const includeFlag = args.include ? `--include=${shellEscape(args.include)}` : '';
305
- const { stdout } = await execAsync(
306
- `grep -rn ${includeFlag} ${safePattern} ${safePath} 2>/dev/null | head -50`,
307
- { cwd: process.cwd() },
308
- );
309
- return stdout.trim() || `No matches for '${args.pattern}'`;
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}`;
310
414
  } catch (e: unknown) {
311
415
  const err = e as { code?: number; message?: string };
312
416
  if (err.code === 1) return `No matches for '${args.pattern}'`;
@@ -315,10 +419,10 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
315
419
  }),
316
420
  );
317
421
 
318
- // ── bash ──
422
+ // ── bash(安全检查 + 命令读写分类) ──
319
423
  tools.push(
320
424
  new ZhinTool('bash')
321
- .desc('执行 Shell 命令(带超时保护)')
425
+ .desc('执行 Shell 命令(带超时保护和命令分类)。返回结果中会标注命令类型(只读/搜索/写入)。')
322
426
  .keyword('执行', '运行', '命令', '终端', 'shell', 'bash')
323
427
  .tag('shell', 'exec')
324
428
  .kind('shell')
@@ -332,15 +436,20 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
332
436
  // 检查命令是否可能泄漏敏感信息
333
437
  const safety = checkBashCommandSafety(cmd);
334
438
  if (!safety.safe) return `Error: ${safety.reason}`;
439
+ // 命令读写分类
440
+ const classification = classifyBashCommand(cmd);
335
441
  const { stdout, stderr } = await execAsync(cmd, {
336
442
  cwd: args.cwd || process.cwd(),
337
443
  timeout,
338
444
  maxBuffer: 1024 * 1024,
339
445
  });
340
446
  let result = '';
447
+ const tag = classification.isReadOnly
448
+ ? (classification.isSearch ? '[搜索]' : classification.isList ? '[列出]' : '[只读]')
449
+ : '[执行]';
341
450
  if (stdout.trim()) result += `STDOUT:\n${stdout.trim()}`;
342
451
  if (stderr.trim()) result += `${result ? '\n' : ''}STDERR:\n${stderr.trim()}`;
343
- return result || '(no output)';
452
+ return `${tag} ${result || '(no output)'}`;
344
453
  } catch (e: unknown) {
345
454
  const err = e as { code?: number; message?: string; stdout?: string; stderr?: string };
346
455
  return `Error (exit ${err.code || '?'}): ${errMsg(e)}\nSTDOUT:\n${err.stdout || ''}\nSTDERR:\n${err.stderr || ''}`;
@@ -348,17 +457,27 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
348
457
  }),
349
458
  );
350
459
 
351
- // ── web_search(搜索网页,返回标题、URL、摘要) ──
460
+ // ── web_search(搜索网页,返回标题、URL、摘要 + 域名过滤 + 次数限制) ──
461
+ let searchCount = 0;
462
+ const MAX_SEARCH_COUNT = 20; // 单次会话搜索次数上限
352
463
  tools.push(
353
464
  new ZhinTool('web_search')
354
- .desc('在互联网上搜索,返回匹配的标题、URL 和摘要片段。用于查资料、找网页。')
465
+ .desc('在互联网上搜索,返回匹配的标题、URL 和摘要片段。用于查资料、找网页。支持域名过滤。')
355
466
  .keyword('搜索', '网上搜', '网页搜索', '搜索引擎', 'search', 'google', '百度', '查询', '搜一下')
356
467
  .tag('web', 'search')
357
468
  .kind('web')
358
469
  .param('query', { type: 'string', description: '搜索关键词或完整查询语句' }, true)
359
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)
360
473
  .execute(async (args) => {
361
474
  try {
475
+ // 搜索次数限制
476
+ searchCount++;
477
+ if (searchCount > MAX_SEARCH_COUNT) {
478
+ return `Error: 搜索次数已达上限 (${MAX_SEARCH_COUNT})。请使用已获取的信息回答。`;
479
+ }
480
+
362
481
  const limit = args.limit ?? 5;
363
482
  const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(args.query)}`;
364
483
  const res = await fetch(url, {
@@ -401,8 +520,23 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
401
520
  }
402
521
  }
403
522
 
404
- if (results.length === 0) return 'No results found.';
405
- return results.map((r, i) =>
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) =>
406
540
  `${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.snippet}`,
407
541
  ).join('\n\n');
408
542
  } catch (e: unknown) {
@@ -411,29 +545,67 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
411
545
  }),
412
546
  );
413
547
 
414
- // ── web_fetch(抓取 URL 并提取正文) ──
548
+ // ── web_fetch(抓取 URL 并提取正文 + SSRF 防护 + 改进的内容提取) ──
415
549
  tools.push(
416
550
  new ZhinTool('web_fetch')
417
- .desc('抓取指定 URL 的网页内容并提取正文(去除广告等),返回可读文本。用于读文章、获取网页内容。')
551
+ .desc('抓取指定 URL 的网页内容并提取正文(去除广告、脚本等),返回可读文本。仅支持 http/https 协议。')
418
552
  .keyword('抓取网页', '打开链接', '获取网页', '读网页', 'fetch', 'url', '链接内容', '网页内容')
419
553
  .tag('web', 'fetch')
420
554
  .kind('web')
421
555
  .param('url', { type: 'string', description: '要抓取的完整 URL(需 http 或 https)' }, true)
556
+ .param('max_length', { type: 'number', description: '最大返回字符数(默认 20480)' })
422
557
  .execute(async (args) => {
423
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
+
424
584
  const response = await fetch(args.url, {
425
585
  headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ZhinBot/1.0)' },
426
586
  signal: AbortSignal.timeout(15000),
587
+ redirect: 'follow',
427
588
  });
428
589
  if (!response.ok) return `HTTP ${response.status}: ${response.statusText}`;
429
590
  const html = await response.text();
591
+ // 改进的内容提取:去除脚本、样式、导航、页脚、表单等
430
592
  const text = html
431
593
  .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
432
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, '')
433
600
  .replace(/<[^>]+>/g, ' ')
601
+ .replace(/&nbsp;/gi, ' ')
602
+ .replace(/&amp;/g, '&')
603
+ .replace(/&lt;/g, '<')
604
+ .replace(/&gt;/g, '>')
605
+ .replace(/&quot;/g, '"')
434
606
  .replace(/\s+/g, ' ')
435
607
  .trim();
436
- const maxLen = 20 * 1024;
608
+ const maxLen = args.max_length ?? 20 * 1024;
437
609
  return text.length > maxLen ? text.slice(0, maxLen) + '\n...(truncated)' : text;
438
610
  } catch (e: unknown) {
439
611
  return `Error: ${errMsg(e)}`;
@@ -555,7 +727,6 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
555
727
  const instructions = extractSkillInstructions(args.name, fullContent, skillMaxChars);
556
728
  return depWarning ? `${depWarning}\n\n${instructions}` : instructions;
557
729
  }
558
- // 回退到目录扫描(与 discoverWorkspaceSkills 顺序一致)
559
730
  for (const dir of skillDirList()) {
560
731
  const skillPath = path.join(dir, args.name, 'SKILL.md');
561
732
  if (fs.existsSync(skillPath)) {
@@ -617,738 +788,65 @@ export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
617
788
  }),
618
789
  );
619
790
 
620
- return tools;
621
- }
622
-
623
- /**
624
- * 检查技能声明的依赖是否在环境中可用;若有缺失返回提示文案,否则返回空字符串
625
- */
626
- async function checkSkillDeps(content: string): Promise<string> {
627
- const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
628
- if (!fmMatch) return '';
629
- let jsYaml: any;
630
- try {
631
- jsYaml = await import('js-yaml');
632
- if (jsYaml.default) jsYaml = jsYaml.default;
633
- } catch {
634
- return '';
635
- }
636
- const metadata = jsYaml.load(fmMatch[1]);
637
- if (!metadata) return '';
638
- const compat = metadata.compatibility || {};
639
- const deps = compat.deps || metadata.deps;
640
- if (!deps || !Array.isArray(deps)) return '';
641
- const missing: string[] = [];
642
- for (const dep of deps) {
643
- try {
644
- await execAsync(`which ${dep} 2>/dev/null`);
645
- } catch {
646
- missing.push(dep);
647
- }
648
- }
649
- if (missing.length === 0) return '';
650
- return `⚠️ 当前环境缺少以下依赖,请先安装后再使用本技能:${missing.join(', ')}`;
651
- }
652
-
653
- /**
654
- * 从 SKILL.md 全文中提取精简的执行指令
655
- * 只保留 frontmatter(工具列表)和执行规则,去掉示例、测试场景等冗余内容
656
- * 这样可以大幅减少 token 占用,让小模型能有足够空间继续调用工具
657
- */
658
- function extractSkillInstructions(name: string, content: string, maxBodyLen: number = 4000): string {
659
- const lines: string[] = [];
660
- lines.push(`Skill '${name}' activated. 请立即根据以下指导执行工具调用:`);
661
- lines.push('');
662
-
663
- const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
664
- if (fmMatch) {
665
- const fmContent = fmMatch[1];
666
- const toolsMatch = fmContent.match(/tools:\s*\n((?:\s+-\s+.+\n?)+)/);
667
- if (toolsMatch) {
668
- lines.push('## 可用工具');
669
- lines.push(toolsMatch[0].trim());
670
- lines.push('');
671
- }
672
- }
673
-
674
- const bodyAfterFm = fmMatch && fmMatch.index !== undefined
675
- ? content.slice(fmMatch.index + fmMatch[0].length).replace(/^\s+/, '')
676
- : content;
677
-
678
- // Priority: "## 快速操作" / "## Quick Actions" summary for small models
679
- const quickActionsMatch = bodyAfterFm.match(/## (?:快速操作|Quick\s*Actions)[\s\S]*?(?=\n## [^\s]|$)/i);
680
- if (quickActionsMatch && maxBodyLen <= 2000) {
681
- lines.push(quickActionsMatch[0].trim());
682
- lines.push('');
683
- lines.push('## 立即行动');
684
- lines.push('根据上面的指导,立即调用工具完成用户请求。禁止重复调用 activate_skill,禁止用文本描述代替实际工具调用。');
685
- return lines.join('\n');
686
- }
687
-
688
- const rulesMatch = content.match(/## 执行规则[\s\S]*?(?=\n## [^\s]|$)/);
689
- const workflowMatch = content.match(/## (?:Workflow|Instructions|使用说明)[\s\S]*?(?=\n## [^\s]|$)/);
690
-
691
- if (rulesMatch) {
692
- lines.push(rulesMatch[0].trim());
693
- lines.push('');
694
- } else if (workflowMatch) {
695
- lines.push(workflowMatch[0].trim());
696
- lines.push('');
697
- } else if (bodyAfterFm.trim()) {
698
- const firstH2 = bodyAfterFm.match(/\n## [^\s]/);
699
- const intro = firstH2 ? bodyAfterFm.slice(0, firstH2.index).trim() : bodyAfterFm.trim();
700
-
701
- const quickStartMatch = bodyAfterFm.match(/## (?:快速开始|Quick\s*Start|Getting\s*Started)[\s\S]*?(?=\n## [^\s]|$)/i);
702
- const authMatch = bodyAfterFm.match(/## (?:认证|Authentication|Auth)[\s\S]*?(?=\n## [^\s]|$)/i);
703
-
704
- if (quickStartMatch || (intro.length < 200 && bodyAfterFm.length > intro.length)) {
705
- lines.push('## 指导');
706
- lines.push(intro);
707
- lines.push('');
708
- const extra: string[] = [];
709
- if (quickStartMatch) extra.push(quickStartMatch[0].trim());
710
- if (authMatch) extra.push(authMatch[0].trim());
711
- if (extra.length > 0) {
712
- const joined = extra.join('\n\n');
713
- lines.push(joined.length > maxBodyLen ? joined.slice(0, maxBodyLen) + '\n...(truncated)' : joined);
714
- } else {
715
- const rest = bodyAfterFm.slice(intro.length).trim();
716
- lines.push(rest.length > maxBodyLen ? rest.slice(0, maxBodyLen) + '\n...(truncated)' : rest);
717
- }
718
- lines.push('');
719
- } else if (intro) {
720
- lines.push('## 指导');
721
- lines.push(intro);
722
- lines.push('');
723
- }
724
- }
725
-
726
- lines.push('## 立即行动');
727
- lines.push('根据上面的指导,立即调用工具完成用户请求。禁止重复调用 activate_skill,禁止用文本描述代替实际工具调用。');
728
-
729
- return lines.join('\n');
730
- }
731
-
732
- // ============================================================================
733
- // 技能发现
734
- // ============================================================================
735
-
736
- export interface SkillMeta {
737
- name: string;
738
- description: string;
739
- keywords?: string[];
740
- tags?: string[];
741
- /** SKILL.md frontmatter 中声明的关联工具名列表 */
742
- toolNames?: string[];
743
- filePath: string;
744
- /** 是否常驻注入 system prompt(frontmatter always: true) */
745
- always?: boolean;
746
- /** 当前环境是否满足依赖(bins/env) */
747
- available?: boolean;
748
- /** 缺失的依赖描述(如 "CLI: ffmpeg", "ENV: API_KEY") */
749
- requiresMissing?: string[];
750
- }
751
-
752
- /**
753
- * 扫描技能目录,发现 SKILL.md 技能文件
754
- * 加载顺序:Workspace(cwd/skills)> Local(~/.zhin/skills)> data/skills > 已加载插件包 skills/,同名先发现者优先
755
- * 支持平台/依赖兼容性过滤。内置技能由 create-zhin 在创建项目时写入 skills/summarize 等。
756
- *
757
- * @param root 根插件(可选):用于追加插件包内 `skills/` 扫描,与 `activate_skill` 查找路径一致
758
- */
759
- export async function discoverWorkspaceSkills(root?: Plugin | null): Promise<SkillMeta[]> {
760
- const skills: SkillMeta[] = [];
761
- const seenNames = new Set<string>();
762
- const dataDir = getDataDir();
763
- const skillDirs = getSkillSearchDirectories(root ?? undefined);
764
-
765
- // 确保 data/skills 目录存在
766
- const defaultSkillDir = path.join(dataDir, 'skills');
767
- if (!fs.existsSync(defaultSkillDir)) {
768
- fs.mkdirSync(defaultSkillDir, { recursive: true });
769
- logger.debug(`Created skill directory: ${defaultSkillDir}`);
770
- }
771
-
772
- for (const skillsDir of skillDirs) {
773
- if (!fs.existsSync(skillsDir)) continue;
774
-
775
- let entries: fs.Dirent[];
776
- try {
777
- entries = await fs.promises.readdir(skillsDir, { withFileTypes: true });
778
- } catch {
779
- continue;
780
- }
781
-
782
- for (const entry of entries) {
783
- if (!entry.isDirectory()) continue;
784
- const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md');
785
- if (!fs.existsSync(skillMdPath)) continue;
786
-
787
- try {
788
- const content = await fs.promises.readFile(skillMdPath, 'utf-8');
789
- // 改进的 frontmatter 正则:支持多种换行符、可选的尾部空白
790
- const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
791
- if (!match) {
792
- logger.debug(`Skill文件 ${skillMdPath} 没有有效的frontmatter格式`);
793
- continue;
794
- }
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';
795
815
 
796
- let jsYaml: any;
797
816
  try {
798
- jsYaml = await import('js-yaml');
799
- if (jsYaml.default) jsYaml = jsYaml.default;
800
- } catch (e) {
801
- logger.warn(`Unable to import js-yaml module: ${e}`);
802
- continue;
803
- }
804
-
805
- const metadata = jsYaml.load(match[1]);
806
- if (!metadata || !metadata.name || !metadata.description) {
807
- logger.debug(`Skill文件 ${skillMdPath} 缺少必需的 name/description 字段`);
808
- continue;
809
- }
810
-
811
- // 平台兼容检查
812
- const compat = metadata.compatibility || {};
813
- if (compat.os && Array.isArray(compat.os)) {
814
- const currentOs = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux';
815
- if (!compat.os.includes(currentOs)) {
816
- logger.debug(`Skipping skill '${metadata.name}' (unsupported OS)`);
817
- continue;
818
- }
819
- }
820
-
821
- // 依赖检查:支持 metadata.requires.bins / requires.env 或 compat.deps / metadata.deps
822
- const requiresBins: string[] = metadata.requires?.bins || compat.deps || metadata.deps || [];
823
- const requiresEnv: string[] = metadata.requires?.env || [];
824
- const binsToCheck = Array.isArray(requiresBins) ? requiresBins : [];
825
- const envToCheck = Array.isArray(requiresEnv) ? requiresEnv : [];
826
- const requiresMissing: string[] = [];
827
- for (const bin of binsToCheck) {
828
- try {
829
- await execAsync(`which ${bin} 2>/dev/null`);
830
- } catch {
831
- requiresMissing.push(`CLI: ${bin}`);
832
- }
833
- }
834
- for (const envKey of envToCheck) {
835
- if (!process.env[envKey]) {
836
- requiresMissing.push(`ENV: ${envKey}`);
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
+ }
837
844
  }
845
+ } catch (e: unknown) {
846
+ return `用户未响应或输入错误: ${errMsg(e)}`;
838
847
  }
839
- const available = requiresMissing.length === 0;
840
-
841
- if (seenNames.has(metadata.name)) {
842
- logger.debug(`Skill '${metadata.name}' 已由先序目录加载,跳过: ${skillMdPath}`);
843
- continue;
844
- }
845
- seenNames.add(metadata.name);
846
-
847
- skills.push({
848
- name: metadata.name,
849
- description: metadata.description,
850
- keywords: metadata.keywords || [],
851
- tags: [...(metadata.tags || []), 'workspace-skill'],
852
- toolNames: Array.isArray(metadata.tools) ? metadata.tools : [],
853
- filePath: skillMdPath,
854
- always: Boolean(metadata.always),
855
- available,
856
- requiresMissing: requiresMissing.length > 0 ? requiresMissing : undefined,
857
- });
858
- logger.debug(`Skill发现成功: ${metadata.name}, tools: ${JSON.stringify(metadata.tools || [])}`);
859
- } catch (e) {
860
- logger.warn(`Failed to parse SKILL.md in ${skillMdPath}:`, e);
861
- }
862
- }
863
- }
864
-
865
- if (skills.length > 0) {
866
- logger.info(`发现 ${skills.length} 个工作区技能: ${skills.map(s => `${s.name}(tools:${(s.toolNames || []).join(',')})`).join(', ')}`);
867
- }
868
-
869
- return skills;
870
- }
871
-
872
- /**
873
- * 获取 frontmatter 中 always: true 的技能名列表(用于常驻注入 system prompt)
874
- */
875
- export function getAlwaysSkillNames(skills: SkillMeta[]): string[] {
876
- return skills.filter(s => s.always && s.available).map(s => s.name);
877
- }
878
-
879
- /**
880
- * 去除 frontmatter,返回正文
881
- */
882
- function stripFrontmatter(content: string): string {
883
- const match = content.match(/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/);
884
- if (match) {
885
- return content.slice(match[0].length).trim();
886
- }
887
- return content.trim();
888
- }
889
-
890
- /**
891
- * 加载 always 技能的正文内容并拼接为「Active Skills」段
892
- */
893
- export async function loadAlwaysSkillsContent(skills: SkillMeta[]): Promise<string> {
894
- const always = skills.filter(s => s.always && s.available);
895
- if (always.length === 0) return '';
896
- const parts: string[] = [];
897
- for (const s of always) {
898
- try {
899
- const content = await fs.promises.readFile(s.filePath, 'utf-8');
900
- const body = stripFrontmatter(content);
901
- parts.push(`### Skill: ${s.name}\n\n${body}`);
902
- } catch (e) {
903
- logger.warn(`Failed to load always skill ${s.name}: ${(e as Error).message}`);
904
- }
905
- }
906
- return parts.join('\n\n---\n\n');
907
- }
908
-
909
- /** 转义 XML 特殊字符 */
910
- function escapeXml(s: string): string {
911
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
912
- }
913
-
914
- /**
915
- * 构建技能列表的 XML 摘要,供 model 区分可用/不可用及缺失依赖
916
- */
917
- export function buildSkillsSummaryXML(skills: SkillMeta[]): string {
918
- if (skills.length === 0) return '';
919
- const lines = ['<skills>'];
920
- for (const s of skills) {
921
- const available = s.available !== false;
922
- lines.push(` <skill available="${available}">`);
923
- lines.push(` <name>${escapeXml(s.name)}</name>`);
924
- lines.push(` <description>${escapeXml(s.description)}</description>`);
925
- lines.push(` <location>${escapeXml(s.filePath)}</location>`);
926
- if (!available && s.requiresMissing && s.requiresMissing.length > 0) {
927
- lines.push(` <requires>${escapeXml(s.requiresMissing.join(', '))}</requires>`);
928
- }
929
- lines.push(' </skill>');
930
- }
931
- lines.push('</skills>');
932
- return lines.join('\n');
933
- }
934
-
935
- // ============================================================================
936
- // Agent 预设发现(*.agent.md 文件)
937
- // ============================================================================
938
-
939
- /**
940
- * Agent 预设元数据(从 *.agent.md frontmatter 解析)
941
- */
942
- export interface AgentMeta {
943
- name: string;
944
- description: string;
945
- keywords?: string[];
946
- tags?: string[];
947
- /** frontmatter 中声明的工具名列表 */
948
- toolNames?: string[];
949
- /** *.agent.md 文件的绝对路径 */
950
- filePath: string;
951
- /** 首选模型名 */
952
- model?: string;
953
- /** 首选 Provider 名 */
954
- provider?: string;
955
- /** 最大工具调用迭代次数 */
956
- maxIterations?: number;
957
- }
958
-
959
- /**
960
- * 扫描 agents/ 目录,发现 *.agent.md 文件
961
- * 加载顺序与 skills 一致:Workspace > ~/.zhin > data > 插件包
962
- * 同名先发现者优先
963
- */
964
- export async function discoverWorkspaceAgents(root?: Plugin | null): Promise<AgentMeta[]> {
965
- const agents: AgentMeta[] = [];
966
- const seenNames = new Set<string>();
967
-
968
- // 构建扫描目录:标准目录的 agents/ + 插件包的 agents/
969
- const agentDirs: string[] = [
970
- path.join(process.cwd(), 'agents'),
971
- path.join(os.homedir(), '.zhin', 'agents'),
972
- path.join(getDataDir(), 'agents'),
973
- ];
974
- if (root) {
975
- const addPluginDir = (p: Plugin) => {
976
- if (!p?.filePath) return;
977
- const dir = path.dirname(p.filePath);
978
- const d = path.join(dir, 'agents');
979
- if (!agentDirs.includes(d)) agentDirs.push(d);
980
- // Also check package root when filePath is under src/ or lib/
981
- const dirName = path.basename(dir);
982
- if (dirName === 'src' || dirName === 'lib') {
983
- const d2 = path.join(path.dirname(dir), 'agents');
984
- if (!agentDirs.includes(d2)) agentDirs.push(d2);
985
- }
986
- };
987
- addPluginDir(root);
988
- for (const child of root.children || []) {
989
- addPluginDir(child);
990
- }
991
- }
992
-
993
- for (const agentsDir of agentDirs) {
994
- if (!fs.existsSync(agentsDir)) continue;
995
-
996
- let entries: fs.Dirent[];
997
- try {
998
- entries = await fs.promises.readdir(agentsDir, { withFileTypes: true });
999
- } catch {
1000
- continue;
1001
- }
1002
-
1003
- for (const entry of entries) {
1004
- // 支持两种结构:
1005
- // 1. agents/<name>.agent.md(扁平文件)
1006
- // 2. agents/<name>/<name>.agent.md(目录结构)
1007
- let agentMdPath: string | undefined;
1008
- if (entry.isFile() && entry.name.endsWith('.agent.md')) {
1009
- agentMdPath = path.join(agentsDir, entry.name);
1010
- } else if (entry.isDirectory()) {
1011
- const nested = path.join(agentsDir, entry.name, `${entry.name}.agent.md`);
1012
- if (fs.existsSync(nested)) {
1013
- agentMdPath = nested;
1014
- }
1015
- }
1016
- if (!agentMdPath) continue;
1017
-
1018
- try {
1019
- const content = await fs.promises.readFile(agentMdPath, 'utf-8');
1020
- const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
1021
- if (!match) {
1022
- logger.debug(`Agent文件 ${agentMdPath} 没有有效的frontmatter格式`);
1023
- continue;
1024
- }
1025
-
1026
- let jsYaml: any;
1027
- try {
1028
- jsYaml = await import('js-yaml');
1029
- if (jsYaml.default) jsYaml = jsYaml.default;
1030
- } catch (e) {
1031
- logger.warn(`Unable to import js-yaml module: ${e}`);
1032
- continue;
1033
- }
1034
-
1035
- const metadata = jsYaml.load(match[1]);
1036
- if (!metadata || !metadata.name || !metadata.description) {
1037
- logger.debug(`Agent文件 ${agentMdPath} 缺少必需的 name/description 字段`);
1038
- continue;
1039
- }
1040
-
1041
- if (seenNames.has(metadata.name)) {
1042
- logger.debug(`Agent '${metadata.name}' 已由先序目录加载,跳过: ${agentMdPath}`);
1043
- continue;
1044
- }
1045
- seenNames.add(metadata.name);
1046
-
1047
- agents.push({
1048
- name: metadata.name,
1049
- description: metadata.description,
1050
- keywords: metadata.keywords || [],
1051
- tags: metadata.tags || [],
1052
- toolNames: Array.isArray(metadata.tools) ? metadata.tools : [],
1053
- filePath: agentMdPath,
1054
- model: metadata.model,
1055
- provider: metadata.provider,
1056
- maxIterations: typeof metadata.maxIterations === 'number' ? metadata.maxIterations : undefined,
1057
- });
1058
- logger.debug(`Agent发现成功: ${metadata.name}`);
1059
- } catch (e) {
1060
- logger.warn(`Failed to parse agent.md in ${agentMdPath}:`, e);
1061
- }
1062
- }
1063
- }
1064
-
1065
- if (agents.length > 0) {
1066
- logger.info(`发现 ${agents.length} 个工作区 Agent 预设: ${agents.map(a => a.name).join(', ')}`);
1067
- }
1068
-
1069
- return agents;
1070
- }
1071
-
1072
- // ============================================================================
1073
- // Tool 发现(*.tool.md 文件)
1074
- // ============================================================================
1075
-
1076
- /**
1077
- * 简写参数定义(*.tool.md frontmatter 中使用)
1078
- */
1079
- export interface ToolParamShorthand {
1080
- type: string;
1081
- description?: string;
1082
- required?: boolean;
1083
- enum?: string[];
1084
- default?: any;
1085
- }
1086
-
1087
- /**
1088
- * Tool 元数据(从 *.tool.md frontmatter 解析)
1089
- */
1090
- export interface ToolMeta {
1091
- name: string;
1092
- description: string;
1093
- /** 简写参数定义(frontmatter 格式) */
1094
- parameters?: Record<string, ToolParamShorthand>;
1095
- /** 命令配置 */
1096
- command?: {
1097
- pattern?: string;
1098
- alias?: string[];
1099
- examples?: string[];
1100
- };
1101
- /** 支持的平台列表 */
1102
- platforms?: string[];
1103
- /** 支持的场景列表 */
1104
- scopes?: string[];
1105
- /** 权限级别 */
1106
- permissionLevel?: string;
1107
- /** 标签 */
1108
- tags?: string[];
1109
- /** 触发关键词 */
1110
- keywords?: string[];
1111
- /** 工具分类 */
1112
- kind?: string;
1113
- /** 是否隐藏 */
1114
- hidden?: boolean;
1115
- /** handler 文件路径(相对于 .tool.md) */
1116
- handler?: string;
1117
- /** *.tool.md 文件的绝对路径 */
1118
- filePath: string;
1119
- /** body 内容(无 handler 时作为 prompt 模板) */
1120
- templateBody?: string;
1121
- }
1122
-
1123
- /**
1124
- * 从根插件树收集:根插件与直接子插件包目录下的 `tools/`
1125
- */
1126
- export function collectPluginToolSearchRoots(root: Plugin | null | undefined): string[] {
1127
- if (!root) return [];
1128
- const dirs: string[] = [];
1129
- const push = (d: string) => {
1130
- if (d && !dirs.includes(d)) dirs.push(d);
1131
- };
1132
- const fromPlugin = (p: Plugin) => {
1133
- if (!p?.filePath) return;
1134
- const dir = path.dirname(p.filePath);
1135
- push(path.join(dir, 'tools'));
1136
- // Also check package root when filePath is under src/ or lib/
1137
- const dirName = path.basename(dir);
1138
- if (dirName === 'src' || dirName === 'lib') {
1139
- push(path.join(path.dirname(dir), 'tools'));
1140
- }
1141
- };
1142
- fromPlugin(root);
1143
- for (const child of root.children || []) {
1144
- fromPlugin(child);
1145
- }
1146
- return dirs;
1147
- }
1148
-
1149
- /**
1150
- * 获取所有 tool 搜索目录(标准目录 + 插件包 tools/)
1151
- */
1152
- export function getToolSearchDirectories(root?: Plugin | null): string[] {
1153
- const list = [
1154
- path.join(process.cwd(), 'tools'),
1155
- path.join(os.homedir(), '.zhin', 'tools'),
1156
- path.join(getDataDir(), 'tools'),
1157
- ];
1158
- for (const d of collectPluginToolSearchRoots(root ?? undefined)) {
1159
- if (!list.includes(d)) list.push(d);
1160
- }
1161
- return list;
1162
- }
1163
-
1164
- /**
1165
- * 将简写参数定义转换为 ToolParametersSchema
1166
- */
1167
- function shorthandToSchema(params: Record<string, ToolParamShorthand>): import('@zhin.js/core').ToolParametersSchema {
1168
- const properties: Record<string, any> = {};
1169
- const required: string[] = [];
1170
- for (const [key, param] of Object.entries(params)) {
1171
- properties[key] = {
1172
- type: param.type || 'string',
1173
- description: param.description || key,
1174
- };
1175
- if (param.enum) properties[key].enum = param.enum;
1176
- if (param.default !== undefined) properties[key].default = param.default;
1177
- if (param.required) required.push(key);
1178
- }
1179
- return { type: 'object', properties, required: required.length > 0 ? required : undefined };
1180
- }
1181
-
1182
- /**
1183
- * 加载 handler 文件(动态 import)
1184
- * @returns execute 函数, 或 undefined(加载失败)
1185
- */
1186
- async function loadToolHandler(handlerPath: string, toolMdPath: string): Promise<((args: any, context?: any) => any) | undefined> {
1187
- const resolved = path.resolve(path.dirname(toolMdPath), handlerPath);
1188
- if (!fs.existsSync(resolved)) {
1189
- logger.warn(`Tool handler 文件不存在: ${resolved}`);
1190
- return undefined;
1191
- }
1192
- try {
1193
- const fileUrl = `file://${resolved}?t=${Date.now()}`;
1194
- const mod = await import(fileUrl);
1195
- const fn = mod.default || mod;
1196
- if (typeof fn !== 'function') {
1197
- logger.warn(`Tool handler 未导出函数: ${resolved}`);
1198
- return undefined;
1199
- }
1200
- return fn;
1201
- } catch (e) {
1202
- logger.warn(`Tool handler 加载失败 (${resolved}): ${errMsg(e)}`);
1203
- return undefined;
1204
- }
1205
- }
1206
-
1207
- /**
1208
- * 从 body 构建 prompt 模板执行函数
1209
- */
1210
- function buildTemplateExecute(body: string): (args: Record<string, any>) => string {
1211
- return (args: Record<string, any>) => body.replace(/\{\{(\w+)\}\}/g, (_, k) => {
1212
- const val = args[k];
1213
- return val !== undefined && val !== null ? String(val) : '';
1214
- });
1215
- }
1216
-
1217
- /**
1218
- * 扫描 tools/ 目录,发现 *.tool.md 文件
1219
- * 加载顺序与 skills/agents 一致:Workspace > ~/.zhin > data > 插件包
1220
- * 同名先发现者优先
1221
- */
1222
- export async function discoverWorkspaceTools(root?: Plugin | null): Promise<ToolMeta[]> {
1223
- const tools: ToolMeta[] = [];
1224
- const seenNames = new Set<string>();
1225
- const toolDirs = getToolSearchDirectories(root);
1226
-
1227
- for (const toolsDir of toolDirs) {
1228
- if (!fs.existsSync(toolsDir)) continue;
1229
-
1230
- let entries: fs.Dirent[];
1231
- try {
1232
- entries = await fs.promises.readdir(toolsDir, { withFileTypes: true });
1233
- } catch {
1234
- continue;
1235
- }
1236
-
1237
- for (const entry of entries) {
1238
- // 支持两种结构:
1239
- // 1. tools/<name>.tool.md(扁平文件)
1240
- // 2. tools/<name>/<name>.tool.md(目录结构,允许放 handler.ts)
1241
- let toolMdPath: string | undefined;
1242
- if (entry.isFile() && entry.name.endsWith('.tool.md')) {
1243
- toolMdPath = path.join(toolsDir, entry.name);
1244
- } else if (entry.isDirectory()) {
1245
- const nested = path.join(toolsDir, entry.name, `${entry.name}.tool.md`);
1246
- if (fs.existsSync(nested)) {
1247
- toolMdPath = nested;
1248
- }
1249
- }
1250
- if (!toolMdPath) continue;
1251
-
1252
- try {
1253
- const content = await fs.promises.readFile(toolMdPath, 'utf-8');
1254
- const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
1255
- if (!match) {
1256
- logger.debug(`Tool文件 ${toolMdPath} 没有有效的frontmatter格式`);
1257
- continue;
1258
- }
1259
-
1260
- let jsYaml: any;
1261
- try {
1262
- jsYaml = await import('js-yaml');
1263
- if (jsYaml.default) jsYaml = jsYaml.default;
1264
- } catch (e) {
1265
- logger.warn(`Unable to import js-yaml module: ${e}`);
1266
- continue;
1267
- }
1268
-
1269
- const metadata = jsYaml.load(match[1]);
1270
- if (!metadata || !metadata.name || !metadata.description) {
1271
- logger.debug(`Tool文件 ${toolMdPath} 缺少必需的 name/description 字段`);
1272
- continue;
1273
- }
1274
-
1275
- if (seenNames.has(metadata.name)) {
1276
- logger.debug(`Tool '${metadata.name}' 已由先序目录加载,跳过: ${toolMdPath}`);
1277
- continue;
1278
- }
1279
- seenNames.add(metadata.name);
1280
-
1281
- // 提取 body(frontmatter 之后的内容)
1282
- const body = content.replace(/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, '').trim();
1283
-
1284
- tools.push({
1285
- name: metadata.name,
1286
- description: metadata.description,
1287
- parameters: metadata.parameters || undefined,
1288
- command: metadata.command || undefined,
1289
- platforms: metadata.platforms,
1290
- scopes: metadata.scopes,
1291
- permissionLevel: metadata.permissionLevel,
1292
- tags: metadata.tags || [],
1293
- keywords: metadata.keywords || [],
1294
- kind: metadata.kind,
1295
- hidden: metadata.hidden,
1296
- handler: metadata.handler,
1297
- filePath: toolMdPath,
1298
- templateBody: !metadata.handler && body ? body : undefined,
1299
- });
1300
- logger.debug(`Tool发现成功: ${metadata.name}`);
1301
- } catch (e) {
1302
- logger.warn(`Failed to parse tool.md in ${toolMdPath}:`, e);
1303
- }
1304
- }
1305
- }
1306
-
1307
- if (tools.length > 0) {
1308
- logger.info(`发现 ${tools.length} 个工作区 Tool: ${tools.map(t => t.name).join(', ')}`);
1309
- }
848
+ }),
849
+ );
1310
850
 
1311
851
  return tools;
1312
852
  }
1313
-
1314
- /**
1315
- * 将 ToolMeta 转换为 Tool 对象(包含 execute 函数)
1316
- */
1317
- export async function buildToolFromMeta(meta: ToolMeta): Promise<import('@zhin.js/core').Tool | null> {
1318
- // 构建 execute 函数
1319
- let execute: ((args: any, context?: any) => any) | undefined;
1320
-
1321
- if (meta.handler) {
1322
- execute = await loadToolHandler(meta.handler, meta.filePath);
1323
- if (!execute) return null;
1324
- } else if (meta.templateBody) {
1325
- execute = buildTemplateExecute(meta.templateBody);
1326
- } else {
1327
- logger.warn(`Tool '${meta.name}' 既没有 handler 也没有模板 body,跳过`);
1328
- return null;
1329
- }
1330
-
1331
- // 构建参数 schema
1332
- const parameters = meta.parameters
1333
- ? shorthandToSchema(meta.parameters)
1334
- : { type: 'object' as const, properties: {} };
1335
-
1336
- return {
1337
- name: meta.name,
1338
- description: meta.description,
1339
- parameters,
1340
- execute,
1341
- tags: meta.tags,
1342
- keywords: meta.keywords,
1343
- platforms: meta.platforms,
1344
- scopes: meta.scopes as any,
1345
- permissionLevel: meta.permissionLevel as any,
1346
- hidden: meta.hidden,
1347
- kind: meta.kind,
1348
- command: meta.command ? {
1349
- pattern: meta.command.pattern,
1350
- alias: meta.command.alias,
1351
- examples: meta.command.examples,
1352
- } : undefined,
1353
- };
1354
- }