@zhin.js/core 1.0.32 → 1.0.34
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 +18 -0
- package/lib/ai/agent.d.ts.map +1 -1
- package/lib/ai/agent.js +15 -2
- package/lib/ai/agent.js.map +1 -1
- package/lib/ai/bootstrap.d.ts +11 -2
- package/lib/ai/bootstrap.d.ts.map +1 -1
- package/lib/ai/bootstrap.js +46 -2
- package/lib/ai/bootstrap.js.map +1 -1
- package/lib/ai/builtin-tools.d.ts +28 -6
- package/lib/ai/builtin-tools.d.ts.map +1 -1
- package/lib/ai/builtin-tools.js +265 -76
- package/lib/ai/builtin-tools.js.map +1 -1
- package/lib/ai/index.d.ts +9 -1
- package/lib/ai/index.d.ts.map +1 -1
- package/lib/ai/index.js +8 -0
- package/lib/ai/index.js.map +1 -1
- package/lib/ai/init.d.ts.map +1 -1
- package/lib/ai/init.js +84 -3
- package/lib/ai/init.js.map +1 -1
- package/lib/ai/providers/anthropic.d.ts +7 -0
- package/lib/ai/providers/anthropic.d.ts.map +1 -1
- package/lib/ai/providers/anthropic.js +3 -0
- package/lib/ai/providers/anthropic.js.map +1 -1
- package/lib/ai/providers/ollama.d.ts +10 -0
- package/lib/ai/providers/ollama.d.ts.map +1 -1
- package/lib/ai/providers/ollama.js +11 -3
- package/lib/ai/providers/ollama.js.map +1 -1
- package/lib/ai/providers/openai.d.ts +7 -0
- package/lib/ai/providers/openai.d.ts.map +1 -1
- package/lib/ai/providers/openai.js +3 -0
- package/lib/ai/providers/openai.js.map +1 -1
- package/lib/ai/service.d.ts +4 -0
- package/lib/ai/service.d.ts.map +1 -1
- package/lib/ai/service.js +7 -0
- package/lib/ai/service.js.map +1 -1
- package/lib/ai/subagent.d.ts +50 -0
- package/lib/ai/subagent.d.ts.map +1 -0
- package/lib/ai/subagent.js +144 -0
- package/lib/ai/subagent.js.map +1 -0
- package/lib/ai/types.d.ts +25 -5
- package/lib/ai/types.d.ts.map +1 -1
- package/lib/ai/zhin-agent-builtin-tools.d.ts +17 -0
- package/lib/ai/zhin-agent-builtin-tools.d.ts.map +1 -0
- package/lib/ai/zhin-agent-builtin-tools.js +220 -0
- package/lib/ai/zhin-agent-builtin-tools.js.map +1 -0
- package/lib/ai/zhin-agent-config.d.ts +54 -0
- package/lib/ai/zhin-agent-config.d.ts.map +1 -0
- package/lib/ai/zhin-agent-config.js +76 -0
- package/lib/ai/zhin-agent-config.js.map +1 -0
- package/lib/ai/zhin-agent-exec-policy.d.ts +20 -0
- package/lib/ai/zhin-agent-exec-policy.d.ts.map +1 -0
- package/lib/ai/zhin-agent-exec-policy.js +71 -0
- package/lib/ai/zhin-agent-exec-policy.js.map +1 -0
- package/lib/ai/zhin-agent-prompt.d.ts +21 -0
- package/lib/ai/zhin-agent-prompt.d.ts.map +1 -0
- package/lib/ai/zhin-agent-prompt.js +116 -0
- package/lib/ai/zhin-agent-prompt.js.map +1 -0
- package/lib/ai/zhin-agent-tool-collector.d.ts +22 -0
- package/lib/ai/zhin-agent-tool-collector.d.ts.map +1 -0
- package/lib/ai/zhin-agent-tool-collector.js +218 -0
- package/lib/ai/zhin-agent-tool-collector.js.map +1 -0
- package/lib/ai/zhin-agent.d.ts +11 -155
- package/lib/ai/zhin-agent.d.ts.map +1 -1
- package/lib/ai/zhin-agent.js +84 -684
- package/lib/ai/zhin-agent.js.map +1 -1
- package/lib/component.d.ts.map +1 -1
- package/lib/component.js +19 -19
- package/lib/component.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/scheduler/index.d.ts +10 -0
- package/lib/scheduler/index.d.ts.map +1 -0
- package/lib/scheduler/index.js +12 -0
- package/lib/scheduler/index.js.map +1 -0
- package/lib/scheduler/scheduler.d.ts +49 -0
- package/lib/scheduler/scheduler.d.ts.map +1 -0
- package/lib/scheduler/scheduler.js +352 -0
- package/lib/scheduler/scheduler.js.map +1 -0
- package/lib/scheduler/types.d.ts +71 -0
- package/lib/scheduler/types.d.ts.map +1 -0
- package/lib/scheduler/types.js +8 -0
- package/lib/scheduler/types.js.map +1 -0
- package/lib/tool-zod.d.ts +28 -0
- package/lib/tool-zod.d.ts.map +1 -0
- package/lib/tool-zod.js +98 -0
- package/lib/tool-zod.js.map +1 -0
- package/package.json +9 -4
- package/src/ai/agent.ts +15 -2
- package/src/ai/bootstrap.ts +48 -2
- package/src/ai/builtin-tools.ts +283 -75
- package/src/ai/index.ts +19 -1
- package/src/ai/init.ts +85 -3
- package/src/ai/providers/anthropic.ts +3 -0
- package/src/ai/providers/ollama.ts +13 -3
- package/src/ai/providers/openai.ts +3 -0
- package/src/ai/service.ts +8 -0
- package/src/ai/subagent.ts +209 -0
- package/src/ai/types.ts +29 -2
- package/src/ai/zhin-agent-builtin-tools.ts +247 -0
- package/src/ai/zhin-agent-config.ts +113 -0
- package/src/ai/zhin-agent-exec-policy.ts +78 -0
- package/src/ai/zhin-agent-prompt.ts +136 -0
- package/src/ai/zhin-agent-tool-collector.ts +243 -0
- package/src/ai/zhin-agent.ts +113 -791
- package/src/component.ts +29 -28
- package/src/index.ts +1 -0
- package/src/scheduler/index.ts +28 -0
- package/src/scheduler/scheduler.ts +372 -0
- package/src/scheduler/types.ts +74 -0
- package/src/tool-zod.ts +115 -0
- package/tests/ai/subagent.test.ts +270 -0
package/src/ai/agent.ts
CHANGED
|
@@ -32,6 +32,7 @@ export function formatToolTitle(name: string, args?: Record<string, any>): strin
|
|
|
32
32
|
case 'read_file': return a.file_path != null ? `read_file: ${a.file_path}` : name;
|
|
33
33
|
case 'write_file': return a.file_path != null ? `write_file: ${a.file_path}` : name;
|
|
34
34
|
case 'edit_file': return a.file_path != null ? `edit_file: ${a.file_path}` : name;
|
|
35
|
+
case 'list_dir': return a.path != null ? `list_dir: ${a.path}` : name;
|
|
35
36
|
case 'web_search': return a.query != null ? `web_search: ${String(a.query).slice(0, 40)}` : name;
|
|
36
37
|
case 'web_fetch': return a.url != null ? `web_fetch: ${String(a.url).slice(0, 50)}` : name;
|
|
37
38
|
default: {
|
|
@@ -286,10 +287,21 @@ export class Agent {
|
|
|
286
287
|
});
|
|
287
288
|
}
|
|
288
289
|
|
|
290
|
+
let args: Record<string, unknown>;
|
|
289
291
|
try {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
+
args = JSON.parse(toolCall.function.arguments);
|
|
293
|
+
} catch {
|
|
294
|
+
return JSON.stringify({
|
|
295
|
+
error: 'Invalid tool arguments JSON',
|
|
296
|
+
tool: toolCall.function.name,
|
|
297
|
+
hint: '请检查工具参数格式后重试。',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
logger.debug({ tool: toolCall.function.name, params: args }, 'Executing tool');
|
|
302
|
+
this.emit('tool_call', tool.name, args);
|
|
292
303
|
|
|
304
|
+
try {
|
|
293
305
|
// 带超时的工具执行
|
|
294
306
|
const result = await Promise.race([
|
|
295
307
|
tool.execute(args),
|
|
@@ -303,6 +315,7 @@ export class Agent {
|
|
|
303
315
|
} catch (error) {
|
|
304
316
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
305
317
|
logger.warn(`工具 ${toolCall.function.name} 执行失败: ${errorMsg}`);
|
|
318
|
+
logger.error({ tool: toolCall.function.name, params: args, err: error }, 'Tool execution failed');
|
|
306
319
|
// 向 AI 提供结构化的错误信息和恢复提示
|
|
307
320
|
return JSON.stringify({
|
|
308
321
|
error: errorMsg,
|
package/src/ai/bootstrap.ts
CHANGED
|
@@ -24,10 +24,10 @@ const logger = new Logger(null, 'Bootstrap');
|
|
|
24
24
|
// 常量
|
|
25
25
|
// ============================================================================
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/** 支持的引导文件名(顺序:SOUL → AGENTS → TOOLS) */
|
|
28
28
|
export const BOOTSTRAP_FILENAMES = [
|
|
29
|
-
'AGENTS.md',
|
|
30
29
|
'SOUL.md',
|
|
30
|
+
'AGENTS.md',
|
|
31
31
|
'TOOLS.md',
|
|
32
32
|
] as const;
|
|
33
33
|
|
|
@@ -104,6 +104,52 @@ function getDataDir(workspaceDir?: string): string {
|
|
|
104
104
|
return path.join(cwd, 'data');
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* 获取文件制长期记忆目录(data/memory),不存在则创建
|
|
109
|
+
*/
|
|
110
|
+
export function getMemoryDir(workspaceDir?: string): string {
|
|
111
|
+
const dir = path.join(getDataDir(workspaceDir), 'memory');
|
|
112
|
+
if (!fs.existsSync(dir)) {
|
|
113
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
114
|
+
}
|
|
115
|
+
return dir;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function todayDate(): string {
|
|
119
|
+
return new Date().toISOString().split('T')[0];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 读取文件制长期记忆 + 当日笔记,拼成注入 system prompt 的字符串(与 miniclawd 一致)
|
|
124
|
+
* 同步读取,供 buildRichSystemPrompt 等同步调用
|
|
125
|
+
*/
|
|
126
|
+
export function getFileMemoryContext(workspaceDir?: string): string {
|
|
127
|
+
const memoryDir = getMemoryDir(workspaceDir);
|
|
128
|
+
const parts: string[] = [];
|
|
129
|
+
|
|
130
|
+
const memoryFile = path.join(memoryDir, 'MEMORY.md');
|
|
131
|
+
if (fs.existsSync(memoryFile)) {
|
|
132
|
+
try {
|
|
133
|
+
const longTerm = fs.readFileSync(memoryFile, 'utf-8').trim();
|
|
134
|
+
if (longTerm) parts.push('## Long-term Memory\n' + longTerm);
|
|
135
|
+
} catch {
|
|
136
|
+
// ignore read errors
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const todayFile = path.join(memoryDir, `${todayDate()}.md`);
|
|
141
|
+
if (fs.existsSync(todayFile)) {
|
|
142
|
+
try {
|
|
143
|
+
const today = fs.readFileSync(todayFile, 'utf-8').trim();
|
|
144
|
+
if (today) parts.push("## Today's Notes\n" + today);
|
|
145
|
+
} catch {
|
|
146
|
+
// ignore read errors
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return parts.length > 0 ? parts.join('\n\n') : '';
|
|
151
|
+
}
|
|
152
|
+
|
|
107
153
|
/**
|
|
108
154
|
* 加载工作区引导文件
|
|
109
155
|
*
|
package/src/ai/builtin-tools.ts
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 借鉴 OpenClaw/MicroClaw 的实用工具设计,为 ZhinAgent 提供:
|
|
5
5
|
*
|
|
6
|
-
* 文件工具: read_file, write_file, edit_file, glob, grep
|
|
6
|
+
* 文件工具: read_file, write_file, edit_file, list_dir, glob, grep
|
|
7
7
|
* Shell: bash
|
|
8
8
|
* 网络: web_search, web_fetch
|
|
9
9
|
* 计划: todo_read, todo_write
|
|
10
10
|
* 记忆: read_memory, write_memory (AGENTS.md)
|
|
11
|
-
* 技能: activate_skill
|
|
11
|
+
* 技能: activate_skill, install_skill
|
|
12
12
|
* 会话: session_status, compact_session
|
|
13
13
|
* 技能发现: 工作区 skills/ 目录自动扫描
|
|
14
14
|
* 引导文件: SOUL.md, TOOLS.md, AGENTS.md 自动加载
|
|
@@ -37,39 +37,67 @@ function getDataDir(): string {
|
|
|
37
37
|
return dir;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** 展开路径中的 ~ 为实际 home 目录 */
|
|
41
|
+
function expandHome(p: string): string {
|
|
42
|
+
if (p === '~') return os.homedir();
|
|
43
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
|
|
44
|
+
return p;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 将 Node 文件错误转为 miniclawd 风格的结构化短句,便于模型区分并重试 */
|
|
48
|
+
function nodeErrToFileMessage(err: unknown, filePath: string, kind: 'read' | 'write' | 'edit' | 'list'): string {
|
|
49
|
+
const e = err as NodeJS.ErrnoException;
|
|
50
|
+
if (e?.code === 'ENOENT') {
|
|
51
|
+
if (kind === 'list') return `Error: Directory not found: ${filePath}`;
|
|
52
|
+
return `Error: File not found: ${filePath}`;
|
|
53
|
+
}
|
|
54
|
+
if (e?.code === 'EACCES') return `Error: Permission denied: ${filePath}`;
|
|
55
|
+
const action = kind === 'read' ? 'reading file' : kind === 'write' ? 'writing file' : kind === 'edit' ? 'editing file' : 'listing directory';
|
|
56
|
+
return `Error ${action}: ${e?.message ?? String(err)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
// ============================================================================
|
|
41
60
|
// 工具工厂函数
|
|
42
61
|
// ============================================================================
|
|
43
62
|
|
|
63
|
+
export interface BuiltinToolsOptions {
|
|
64
|
+
/** Max chars for skill instruction extraction (model-size-aware) */
|
|
65
|
+
skillInstructionMaxChars?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
44
68
|
/**
|
|
45
69
|
* 创建所有内置系统工具
|
|
46
70
|
*/
|
|
47
|
-
export function createBuiltinTools(): ZhinTool[] {
|
|
71
|
+
export function createBuiltinTools(options?: BuiltinToolsOptions): ZhinTool[] {
|
|
48
72
|
const DATA_DIR = getDataDir();
|
|
73
|
+
const skillMaxChars = options?.skillInstructionMaxChars ?? 4000;
|
|
49
74
|
|
|
50
75
|
const tools: ZhinTool[] = [];
|
|
51
76
|
|
|
52
|
-
// ── read_file ──
|
|
77
|
+
// ── read_file(清晰描述 + 强关键词) ──
|
|
53
78
|
tools.push(
|
|
54
79
|
new ZhinTool('read_file')
|
|
55
|
-
.desc('
|
|
56
|
-
.keyword('读文件', '
|
|
80
|
+
.desc('读取指定路径的文件内容。用于查看、打开或读取任意文本文件。')
|
|
81
|
+
.keyword('读文件', '读取文件', '查看文件', '打开文件', '文件内容', 'read file', 'read', 'cat', '查看', '打开')
|
|
57
82
|
.tag('file', 'read')
|
|
58
83
|
.kind('file')
|
|
59
|
-
.param('file_path', { type: 'string', description: '
|
|
60
|
-
.param('offset', { type: 'number', description: '起始行号(0-based
|
|
61
|
-
.param('limit', { type: 'number', description: '
|
|
84
|
+
.param('file_path', { type: 'string', description: '要读取的文件路径(绝对路径或相对项目根目录)' }, true)
|
|
85
|
+
.param('offset', { type: 'number', description: '起始行号(0-based,可选,默认从第 1 行开始)' })
|
|
86
|
+
.param('limit', { type: 'number', description: '最多读取行数(可选,默认全部)' })
|
|
62
87
|
.execute(async (args) => {
|
|
63
88
|
try {
|
|
64
|
-
const
|
|
89
|
+
const fp = expandHome(args.file_path);
|
|
90
|
+
const stat = await fs.promises.stat(fp);
|
|
91
|
+
if (!stat.isFile()) return `Error: Not a file: ${fp}`;
|
|
92
|
+
const content = await fs.promises.readFile(fp, 'utf-8');
|
|
65
93
|
const lines = content.split('\n');
|
|
66
94
|
const offset = args.offset ?? 0;
|
|
67
95
|
const limit = args.limit ?? lines.length;
|
|
68
96
|
const sliced = lines.slice(offset, offset + limit);
|
|
69
97
|
const numbered = sliced.map((line: string, i: number) => `${offset + i + 1} | ${line}`).join('\n');
|
|
70
|
-
return `File: ${
|
|
71
|
-
} catch (e:
|
|
72
|
-
return
|
|
98
|
+
return `File: ${fp} (${lines.length} lines, showing ${offset + 1}-${Math.min(offset + limit, lines.length)})\n${numbered}`;
|
|
99
|
+
} catch (e: unknown) {
|
|
100
|
+
return nodeErrToFileMessage(e, args.file_path, 'read');
|
|
73
101
|
}
|
|
74
102
|
}),
|
|
75
103
|
);
|
|
@@ -77,48 +105,79 @@ export function createBuiltinTools(): ZhinTool[] {
|
|
|
77
105
|
// ── write_file ──
|
|
78
106
|
tools.push(
|
|
79
107
|
new ZhinTool('write_file')
|
|
80
|
-
.desc('
|
|
81
|
-
.keyword('写文件', '创建文件', '
|
|
108
|
+
.desc('向指定路径写入内容,创建或覆盖文件;若目录不存在会自动创建。')
|
|
109
|
+
.keyword('写文件', '写入文件', '创建文件', '保存文件', 'write file', 'write', '保存', '创建')
|
|
82
110
|
.tag('file', 'write')
|
|
83
111
|
.kind('file')
|
|
84
|
-
.param('file_path', { type: 'string', description: '
|
|
85
|
-
.param('content', { type: 'string', description: '
|
|
112
|
+
.param('file_path', { type: 'string', description: '要写入的文件路径' }, true)
|
|
113
|
+
.param('content', { type: 'string', description: '要写入的完整内容' }, true)
|
|
86
114
|
.execute(async (args) => {
|
|
87
115
|
try {
|
|
88
|
-
|
|
89
|
-
await fs.promises.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
116
|
+
const fp = expandHome(args.file_path);
|
|
117
|
+
await fs.promises.mkdir(path.dirname(fp), { recursive: true });
|
|
118
|
+
await fs.promises.writeFile(fp, args.content, 'utf-8');
|
|
119
|
+
return `✅ Wrote ${Buffer.byteLength(args.content)} bytes to ${fp}`;
|
|
120
|
+
} catch (e: unknown) {
|
|
121
|
+
return nodeErrToFileMessage(e, args.file_path, 'write');
|
|
93
122
|
}
|
|
94
123
|
}),
|
|
95
124
|
);
|
|
96
125
|
|
|
97
|
-
// ── edit_file ──
|
|
126
|
+
// ── edit_file(old_text 必须精确匹配) ──
|
|
98
127
|
tools.push(
|
|
99
128
|
new ZhinTool('edit_file')
|
|
100
|
-
.desc('
|
|
101
|
-
.keyword('
|
|
129
|
+
.desc('在文件中查找并替换一段文本。old_string 必须在文件中精确存在且唯一;建议包含完整行或足够上下文以避免重复匹配。')
|
|
130
|
+
.keyword('编辑文件', '修改文件', '替换内容', '查找替换', 'edit file', 'edit', '修改', '替换')
|
|
102
131
|
.tag('file', 'edit')
|
|
103
132
|
.kind('file')
|
|
104
|
-
.param('file_path', { type: 'string', description: '
|
|
105
|
-
.param('old_string', { type: 'string', description: '
|
|
106
|
-
.param('new_string', { type: 'string', description: '
|
|
133
|
+
.param('file_path', { type: 'string', description: '要编辑的文件路径' }, true)
|
|
134
|
+
.param('old_string', { type: 'string', description: '文件中要替换的原文(必须与文件内容完全一致)' }, true)
|
|
135
|
+
.param('new_string', { type: 'string', description: '替换后的新文本' }, true)
|
|
107
136
|
.execute(async (args) => {
|
|
108
137
|
try {
|
|
109
|
-
const
|
|
138
|
+
const fp = expandHome(args.file_path);
|
|
139
|
+
const content = await fs.promises.readFile(fp, 'utf-8');
|
|
110
140
|
const count = content.split(args.old_string).length - 1;
|
|
111
|
-
if (count === 0) return `Error: old_string not found in
|
|
112
|
-
if (count > 1) return `
|
|
141
|
+
if (count === 0) return `Error: old_string not found in file. Make sure it matches exactly.`;
|
|
142
|
+
if (count > 1) return `Warning: old_string appears ${count} times. Please provide more context to make it unique.`;
|
|
113
143
|
const newContent = content.replace(args.old_string, args.new_string);
|
|
114
|
-
await fs.promises.writeFile(
|
|
144
|
+
await fs.promises.writeFile(fp, newContent, 'utf-8');
|
|
115
145
|
|
|
116
|
-
// 返回修改前后的差异上下文,帮助 AI 确认修改正确
|
|
117
146
|
const oldLines = args.old_string.split('\n');
|
|
118
147
|
const newLines = args.new_string.split('\n');
|
|
119
|
-
return `✅ Edited ${
|
|
120
|
-
} catch (e:
|
|
121
|
-
return
|
|
148
|
+
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...' : ''}`;
|
|
149
|
+
} catch (e: unknown) {
|
|
150
|
+
return nodeErrToFileMessage(e, args.file_path, 'edit');
|
|
151
|
+
}
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// ── list_dir(列出目录内容,便于 AI 匹配「列表」「目录」「ls」) ──
|
|
156
|
+
tools.push(
|
|
157
|
+
new ZhinTool('list_dir')
|
|
158
|
+
.desc('列出指定目录下的文件和子目录名称。用于查看目录结构、有哪些文件。')
|
|
159
|
+
.keyword('列目录', '列出目录', '目录列表', '查看目录', 'list directory', 'list dir', 'ls', 'dir', '目录内容', '有哪些文件')
|
|
160
|
+
.tag('file', 'list')
|
|
161
|
+
.kind('file')
|
|
162
|
+
.param('path', { type: 'string', description: '要列出的目录路径(绝对或相对项目根目录)' }, true)
|
|
163
|
+
.execute(async (args) => {
|
|
164
|
+
try {
|
|
165
|
+
const dirPath = path.resolve(process.cwd(), expandHome(args.path));
|
|
166
|
+
const stat = await fs.promises.stat(dirPath);
|
|
167
|
+
if (!stat.isDirectory()) {
|
|
168
|
+
return `Error: Not a directory: ${args.path}`;
|
|
169
|
+
}
|
|
170
|
+
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
171
|
+
if (entries.length === 0) {
|
|
172
|
+
return `Directory ${args.path} is empty`;
|
|
173
|
+
}
|
|
174
|
+
const lines: string[] = [];
|
|
175
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
176
|
+
lines.push((e.isDirectory() ? '[DIR] ' : ' ') + e.name);
|
|
177
|
+
}
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
} catch (e: unknown) {
|
|
180
|
+
return nodeErrToFileMessage(e, args.path, 'list');
|
|
122
181
|
}
|
|
123
182
|
}),
|
|
124
183
|
);
|
|
@@ -126,8 +185,8 @@ export function createBuiltinTools(): ZhinTool[] {
|
|
|
126
185
|
// ── glob ──
|
|
127
186
|
tools.push(
|
|
128
187
|
new ZhinTool('glob')
|
|
129
|
-
.desc('按 glob
|
|
130
|
-
.keyword('
|
|
188
|
+
.desc('按 glob 模式查找匹配的文件路径(如 **/*.ts)。用于按模式找文件,而非列出目录。')
|
|
189
|
+
.keyword('glob', '查找文件', '按模式找文件', 'find', '匹配文件')
|
|
131
190
|
.tag('file', 'search')
|
|
132
191
|
.kind('file')
|
|
133
192
|
.param('pattern', { type: 'string', description: 'Glob 模式(如 **/*.ts)' }, true)
|
|
@@ -203,14 +262,15 @@ export function createBuiltinTools(): ZhinTool[] {
|
|
|
203
262
|
}),
|
|
204
263
|
);
|
|
205
264
|
|
|
206
|
-
// ── web_search ──
|
|
265
|
+
// ── web_search(搜索网页,返回标题、URL、摘要) ──
|
|
207
266
|
tools.push(
|
|
208
267
|
new ZhinTool('web_search')
|
|
209
|
-
.desc('
|
|
210
|
-
.keyword('搜索', '
|
|
268
|
+
.desc('在互联网上搜索,返回匹配的标题、URL 和摘要片段。用于查资料、找网页。')
|
|
269
|
+
.keyword('搜索', '网上搜', '网页搜索', '搜索引擎', 'search', 'google', '百度', '查询', '搜一下')
|
|
211
270
|
.tag('web', 'search')
|
|
212
|
-
.
|
|
213
|
-
.param('
|
|
271
|
+
.kind('web')
|
|
272
|
+
.param('query', { type: 'string', description: '搜索关键词或完整查询语句' }, true)
|
|
273
|
+
.param('limit', { type: 'number', description: '返回结果数量(默认 5,建议 1–10)' })
|
|
214
274
|
.execute(async (args) => {
|
|
215
275
|
try {
|
|
216
276
|
const limit = args.limit ?? 5;
|
|
@@ -265,14 +325,14 @@ export function createBuiltinTools(): ZhinTool[] {
|
|
|
265
325
|
}),
|
|
266
326
|
);
|
|
267
327
|
|
|
268
|
-
// ── web_fetch ──
|
|
328
|
+
// ── web_fetch(抓取 URL 并提取正文) ──
|
|
269
329
|
tools.push(
|
|
270
330
|
new ZhinTool('web_fetch')
|
|
271
|
-
.desc('
|
|
272
|
-
.keyword('
|
|
331
|
+
.desc('抓取指定 URL 的网页内容并提取正文(去除广告等),返回可读文本。用于读文章、获取网页内容。')
|
|
332
|
+
.keyword('抓取网页', '打开链接', '获取网页', '读网页', 'fetch', 'url', '链接内容', '网页内容')
|
|
273
333
|
.tag('web', 'fetch')
|
|
274
334
|
.kind('web')
|
|
275
|
-
.param('url', { type: 'string', description: 'URL
|
|
335
|
+
.param('url', { type: 'string', description: '要抓取的完整 URL(需 http 或 https)' }, true)
|
|
276
336
|
.execute(async (args) => {
|
|
277
337
|
try {
|
|
278
338
|
const response = await fetch(args.url, {
|
|
@@ -413,7 +473,7 @@ export function createBuiltinTools(): ZhinTool[] {
|
|
|
413
473
|
const fullContent = await fs.promises.readFile(skillPath, 'utf-8');
|
|
414
474
|
// 5.3 可执行环境检查:若 SKILL 声明了 deps,再次检查;缺失则在返回内容中提示
|
|
415
475
|
const depWarning = await checkSkillDeps(fullContent);
|
|
416
|
-
const instructions = extractSkillInstructions(args.name, fullContent);
|
|
476
|
+
const instructions = extractSkillInstructions(args.name, fullContent, skillMaxChars);
|
|
417
477
|
return depWarning ? `${depWarning}\n\n${instructions}` : instructions;
|
|
418
478
|
}
|
|
419
479
|
}
|
|
@@ -424,6 +484,51 @@ export function createBuiltinTools(): ZhinTool[] {
|
|
|
424
484
|
}),
|
|
425
485
|
);
|
|
426
486
|
|
|
487
|
+
// ── install_skill(从 URL 下载并安装技能) ──
|
|
488
|
+
tools.push(
|
|
489
|
+
new ZhinTool('install_skill')
|
|
490
|
+
.desc('从 URL 下载 SKILL.md 并安装到本地 skills/ 目录。用户要求加入/安装/下载某个技能时使用')
|
|
491
|
+
.keyword('安装技能', '下载技能', '加入', '添加技能', 'install', 'skill', 'join', '学会', '学习技能')
|
|
492
|
+
.tag('skill', 'install')
|
|
493
|
+
.kind('skill')
|
|
494
|
+
.param('url', { type: 'string', description: 'SKILL.md 文件的完整 URL(如 https://example.com/skill.md)' }, true)
|
|
495
|
+
.execute(async (args) => {
|
|
496
|
+
try {
|
|
497
|
+
const response = await fetch(args.url, {
|
|
498
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ZhinBot/1.0)' },
|
|
499
|
+
signal: AbortSignal.timeout(15000),
|
|
500
|
+
});
|
|
501
|
+
if (!response.ok) return `Error: HTTP ${response.status} ${response.statusText}`;
|
|
502
|
+
const content = await response.text();
|
|
503
|
+
|
|
504
|
+
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
505
|
+
if (!fmMatch) return 'Error: 无效的 SKILL.md 文件(缺少 frontmatter)';
|
|
506
|
+
|
|
507
|
+
let yaml: any;
|
|
508
|
+
try {
|
|
509
|
+
yaml = await import('yaml');
|
|
510
|
+
if (yaml.default) yaml = yaml.default;
|
|
511
|
+
} catch {
|
|
512
|
+
return 'Error: 无法加载 yaml 解析器';
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const metadata = yaml.parse(fmMatch[1]);
|
|
516
|
+
if (!metadata?.name) return 'Error: SKILL.md 缺少 name 字段';
|
|
517
|
+
|
|
518
|
+
const skillName: string = metadata.name;
|
|
519
|
+
const skillDir = path.join(process.cwd(), 'skills', skillName);
|
|
520
|
+
await fs.promises.mkdir(skillDir, { recursive: true });
|
|
521
|
+
const skillPath = path.join(skillDir, 'SKILL.md');
|
|
522
|
+
await fs.promises.writeFile(skillPath, content, 'utf-8');
|
|
523
|
+
|
|
524
|
+
logger.info(`技能已安装: ${skillName} → ${skillPath}`);
|
|
525
|
+
return `✅ 技能「${skillName}」已安装到 ${skillPath}。现在可以用 activate_skill("${skillName}") 激活它。`;
|
|
526
|
+
} catch (e: any) {
|
|
527
|
+
return `Error: ${e.message}`;
|
|
528
|
+
}
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
|
|
427
532
|
logger.info(`已创建 ${tools.length} 个内置系统工具`);
|
|
428
533
|
return tools;
|
|
429
534
|
}
|
|
@@ -463,12 +568,11 @@ async function checkSkillDeps(content: string): Promise<string> {
|
|
|
463
568
|
* 只保留 frontmatter(工具列表)和执行规则,去掉示例、测试场景等冗余内容
|
|
464
569
|
* 这样可以大幅减少 token 占用,让小模型能有足够空间继续调用工具
|
|
465
570
|
*/
|
|
466
|
-
function extractSkillInstructions(name: string, content: string): string {
|
|
571
|
+
function extractSkillInstructions(name: string, content: string, maxBodyLen: number = 4000): string {
|
|
467
572
|
const lines: string[] = [];
|
|
468
573
|
lines.push(`Skill '${name}' activated. 请立即根据以下指导执行工具调用:`);
|
|
469
574
|
lines.push('');
|
|
470
575
|
|
|
471
|
-
// 1. 提取 frontmatter 中的 tools 列表
|
|
472
576
|
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
473
577
|
if (fmMatch) {
|
|
474
578
|
const fmContent = fmMatch[1];
|
|
@@ -480,12 +584,23 @@ function extractSkillInstructions(name: string, content: string): string {
|
|
|
480
584
|
}
|
|
481
585
|
}
|
|
482
586
|
|
|
483
|
-
// 2. 提取执行指导:优先 "## 执行规则",否则尝试 "## Workflow" / "## Instructions" / "## 使用说明",再否则用正文(去掉 frontmatter)
|
|
484
|
-
const rulesMatch = content.match(/## 执行规则[\s\S]*?(?=\n## [^\s]|$)/);
|
|
485
|
-
const workflowMatch = content.match(/## (?:Workflow|Instructions|使用说明)[\s\S]*?(?=\n## [^\s]|$)/);
|
|
486
587
|
const bodyAfterFm = fmMatch && fmMatch.index !== undefined
|
|
487
588
|
? content.slice(fmMatch.index + fmMatch[0].length).replace(/^\s+/, '')
|
|
488
589
|
: content;
|
|
590
|
+
|
|
591
|
+
// Priority: "## 快速操作" / "## Quick Actions" summary for small models
|
|
592
|
+
const quickActionsMatch = bodyAfterFm.match(/## (?:快速操作|Quick\s*Actions)[\s\S]*?(?=\n## [^\s]|$)/i);
|
|
593
|
+
if (quickActionsMatch && maxBodyLen <= 2000) {
|
|
594
|
+
lines.push(quickActionsMatch[0].trim());
|
|
595
|
+
lines.push('');
|
|
596
|
+
lines.push('## 立即行动');
|
|
597
|
+
lines.push('根据上面的指导,立即调用工具完成用户请求。禁止重复调用 activate_skill,禁止用文本描述代替实际工具调用。');
|
|
598
|
+
return lines.join('\n');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const rulesMatch = content.match(/## 执行规则[\s\S]*?(?=\n## [^\s]|$)/);
|
|
602
|
+
const workflowMatch = content.match(/## (?:Workflow|Instructions|使用说明)[\s\S]*?(?=\n## [^\s]|$)/);
|
|
603
|
+
|
|
489
604
|
if (rulesMatch) {
|
|
490
605
|
lines.push(rulesMatch[0].trim());
|
|
491
606
|
lines.push('');
|
|
@@ -493,19 +608,36 @@ function extractSkillInstructions(name: string, content: string): string {
|
|
|
493
608
|
lines.push(workflowMatch[0].trim());
|
|
494
609
|
lines.push('');
|
|
495
610
|
} else if (bodyAfterFm.trim()) {
|
|
496
|
-
// 无上述标题时使用正文(去除 frontmatter 后)作为指导
|
|
497
611
|
const firstH2 = bodyAfterFm.match(/\n## [^\s]/);
|
|
498
|
-
const
|
|
499
|
-
|
|
612
|
+
const intro = firstH2 ? bodyAfterFm.slice(0, firstH2.index).trim() : bodyAfterFm.trim();
|
|
613
|
+
|
|
614
|
+
const quickStartMatch = bodyAfterFm.match(/## (?:快速开始|Quick\s*Start|Getting\s*Started)[\s\S]*?(?=\n## [^\s]|$)/i);
|
|
615
|
+
const authMatch = bodyAfterFm.match(/## (?:认证|Authentication|Auth)[\s\S]*?(?=\n## [^\s]|$)/i);
|
|
616
|
+
|
|
617
|
+
if (quickStartMatch || (intro.length < 200 && bodyAfterFm.length > intro.length)) {
|
|
500
618
|
lines.push('## 指导');
|
|
501
|
-
lines.push(
|
|
619
|
+
lines.push(intro);
|
|
620
|
+
lines.push('');
|
|
621
|
+
const extra: string[] = [];
|
|
622
|
+
if (quickStartMatch) extra.push(quickStartMatch[0].trim());
|
|
623
|
+
if (authMatch) extra.push(authMatch[0].trim());
|
|
624
|
+
if (extra.length > 0) {
|
|
625
|
+
const joined = extra.join('\n\n');
|
|
626
|
+
lines.push(joined.length > maxBodyLen ? joined.slice(0, maxBodyLen) + '\n...(truncated)' : joined);
|
|
627
|
+
} else {
|
|
628
|
+
const rest = bodyAfterFm.slice(intro.length).trim();
|
|
629
|
+
lines.push(rest.length > maxBodyLen ? rest.slice(0, maxBodyLen) + '\n...(truncated)' : rest);
|
|
630
|
+
}
|
|
631
|
+
lines.push('');
|
|
632
|
+
} else if (intro) {
|
|
633
|
+
lines.push('## 指导');
|
|
634
|
+
lines.push(intro);
|
|
502
635
|
lines.push('');
|
|
503
636
|
}
|
|
504
637
|
}
|
|
505
638
|
|
|
506
|
-
// 3. 添加强制执行提醒
|
|
507
639
|
lines.push('## 立即行动');
|
|
508
|
-
lines.push('
|
|
640
|
+
lines.push('根据上面的指导,立即调用工具完成用户请求。禁止重复调用 activate_skill,禁止用文本描述代替实际工具调用。');
|
|
509
641
|
|
|
510
642
|
return lines.join('\n');
|
|
511
643
|
}
|
|
@@ -514,7 +646,7 @@ function extractSkillInstructions(name: string, content: string): string {
|
|
|
514
646
|
// 技能发现
|
|
515
647
|
// ============================================================================
|
|
516
648
|
|
|
517
|
-
interface SkillMeta {
|
|
649
|
+
export interface SkillMeta {
|
|
518
650
|
name: string;
|
|
519
651
|
description: string;
|
|
520
652
|
keywords?: string[];
|
|
@@ -522,12 +654,18 @@ interface SkillMeta {
|
|
|
522
654
|
/** SKILL.md frontmatter 中声明的关联工具名列表 */
|
|
523
655
|
toolNames?: string[];
|
|
524
656
|
filePath: string;
|
|
657
|
+
/** 是否常驻注入 system prompt(frontmatter always: true) */
|
|
658
|
+
always?: boolean;
|
|
659
|
+
/** 当前环境是否满足依赖(bins/env) */
|
|
660
|
+
available?: boolean;
|
|
661
|
+
/** 缺失的依赖描述(如 "CLI: ffmpeg", "ENV: API_KEY") */
|
|
662
|
+
requiresMissing?: string[];
|
|
525
663
|
}
|
|
526
664
|
|
|
527
665
|
/**
|
|
528
666
|
* 扫描技能目录,发现 SKILL.md 技能文件
|
|
529
|
-
* 加载顺序:Workspace(cwd/skills)> Local(~/.zhin/skills)> Bundled(data/skills
|
|
530
|
-
*
|
|
667
|
+
* 加载顺序:Workspace(cwd/skills)> Local(~/.zhin/skills)> Bundled(data/skills),同名先发现者优先
|
|
668
|
+
* 支持平台/依赖兼容性过滤。内置技能由 create-zhin 在创建项目时写入 skills/summarize 等。
|
|
531
669
|
*/
|
|
532
670
|
export async function discoverWorkspaceSkills(): Promise<SkillMeta[]> {
|
|
533
671
|
const skills: SkillMeta[] = [];
|
|
@@ -596,21 +734,25 @@ export async function discoverWorkspaceSkills(): Promise<SkillMeta[]> {
|
|
|
596
734
|
}
|
|
597
735
|
}
|
|
598
736
|
|
|
599
|
-
//
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
737
|
+
// 依赖检查:支持 metadata.requires.bins / requires.env 或 compat.deps / metadata.deps
|
|
738
|
+
const requiresBins: string[] = metadata.requires?.bins || compat.deps || metadata.deps || [];
|
|
739
|
+
const requiresEnv: string[] = metadata.requires?.env || [];
|
|
740
|
+
const binsToCheck = Array.isArray(requiresBins) ? requiresBins : [];
|
|
741
|
+
const envToCheck = Array.isArray(requiresEnv) ? requiresEnv : [];
|
|
742
|
+
const requiresMissing: string[] = [];
|
|
743
|
+
for (const bin of binsToCheck) {
|
|
744
|
+
try {
|
|
745
|
+
await execAsync(`which ${bin} 2>/dev/null`);
|
|
746
|
+
} catch {
|
|
747
|
+
requiresMissing.push(`CLI: ${bin}`);
|
|
611
748
|
}
|
|
612
|
-
if (missing) continue;
|
|
613
749
|
}
|
|
750
|
+
for (const envKey of envToCheck) {
|
|
751
|
+
if (!process.env[envKey]) {
|
|
752
|
+
requiresMissing.push(`ENV: ${envKey}`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const available = requiresMissing.length === 0;
|
|
614
756
|
|
|
615
757
|
if (seenNames.has(metadata.name)) {
|
|
616
758
|
logger.debug(`Skill '${metadata.name}' 已由先序目录加载,跳过: ${skillMdPath}`);
|
|
@@ -625,6 +767,9 @@ export async function discoverWorkspaceSkills(): Promise<SkillMeta[]> {
|
|
|
625
767
|
tags: [...(metadata.tags || []), 'workspace-skill'],
|
|
626
768
|
toolNames: Array.isArray(metadata.tools) ? metadata.tools : [],
|
|
627
769
|
filePath: skillMdPath,
|
|
770
|
+
always: Boolean(metadata.always),
|
|
771
|
+
available,
|
|
772
|
+
requiresMissing: requiresMissing.length > 0 ? requiresMissing : undefined,
|
|
628
773
|
});
|
|
629
774
|
logger.debug(`Skill发现成功: ${metadata.name}, tools: ${JSON.stringify(metadata.tools || [])}`);
|
|
630
775
|
} catch (e) {
|
|
@@ -639,3 +784,66 @@ export async function discoverWorkspaceSkills(): Promise<SkillMeta[]> {
|
|
|
639
784
|
|
|
640
785
|
return skills;
|
|
641
786
|
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* 获取 frontmatter 中 always: true 的技能名列表(用于常驻注入 system prompt)
|
|
790
|
+
*/
|
|
791
|
+
export function getAlwaysSkillNames(skills: SkillMeta[]): string[] {
|
|
792
|
+
return skills.filter(s => s.always && s.available).map(s => s.name);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* 去除 frontmatter,返回正文
|
|
797
|
+
*/
|
|
798
|
+
function stripFrontmatter(content: string): string {
|
|
799
|
+
const match = content.match(/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/);
|
|
800
|
+
if (match) {
|
|
801
|
+
return content.slice(match[0].length).trim();
|
|
802
|
+
}
|
|
803
|
+
return content.trim();
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* 加载 always 技能的正文内容并拼接为「Active Skills」段
|
|
808
|
+
*/
|
|
809
|
+
export async function loadAlwaysSkillsContent(skills: SkillMeta[]): Promise<string> {
|
|
810
|
+
const always = skills.filter(s => s.always && s.available);
|
|
811
|
+
if (always.length === 0) return '';
|
|
812
|
+
const parts: string[] = [];
|
|
813
|
+
for (const s of always) {
|
|
814
|
+
try {
|
|
815
|
+
const content = await fs.promises.readFile(s.filePath, 'utf-8');
|
|
816
|
+
const body = stripFrontmatter(content);
|
|
817
|
+
parts.push(`### Skill: ${s.name}\n\n${body}`);
|
|
818
|
+
} catch (e) {
|
|
819
|
+
logger.warn(`Failed to load always skill ${s.name}: ${(e as Error).message}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return parts.join('\n\n---\n\n');
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** 转义 XML 特殊字符 */
|
|
826
|
+
function escapeXml(s: string): string {
|
|
827
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* 构建技能列表的 XML 摘要,供 model 区分可用/不可用及缺失依赖
|
|
832
|
+
*/
|
|
833
|
+
export function buildSkillsSummaryXML(skills: SkillMeta[]): string {
|
|
834
|
+
if (skills.length === 0) return '';
|
|
835
|
+
const lines = ['<skills>'];
|
|
836
|
+
for (const s of skills) {
|
|
837
|
+
const available = s.available !== false;
|
|
838
|
+
lines.push(` <skill available="${available}">`);
|
|
839
|
+
lines.push(` <name>${escapeXml(s.name)}</name>`);
|
|
840
|
+
lines.push(` <description>${escapeXml(s.description)}</description>`);
|
|
841
|
+
lines.push(` <location>${escapeXml(s.filePath)}</location>`);
|
|
842
|
+
if (!available && s.requiresMissing && s.requiresMissing.length > 0) {
|
|
843
|
+
lines.push(` <requires>${escapeXml(s.requiresMissing.join(', '))}</requires>`);
|
|
844
|
+
}
|
|
845
|
+
lines.push(' </skill>');
|
|
846
|
+
}
|
|
847
|
+
lines.push('</skills>');
|
|
848
|
+
return lines.join('\n');
|
|
849
|
+
}
|