@zhin.js/core 1.0.26 → 1.0.28
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 +4 -0
- package/lib/ai/agent.js.map +1 -1
- package/lib/ai/bootstrap.d.ts +82 -0
- package/lib/ai/bootstrap.d.ts.map +1 -0
- package/lib/ai/bootstrap.js +199 -0
- package/lib/ai/bootstrap.js.map +1 -0
- package/lib/ai/builtin-tools.d.ts +36 -0
- package/lib/ai/builtin-tools.d.ts.map +1 -0
- package/lib/ai/builtin-tools.js +509 -0
- package/lib/ai/builtin-tools.js.map +1 -0
- package/lib/ai/compaction.d.ts +132 -0
- package/lib/ai/compaction.d.ts.map +1 -0
- package/lib/ai/compaction.js +370 -0
- package/lib/ai/compaction.js.map +1 -0
- package/lib/ai/hooks.d.ts +143 -0
- package/lib/ai/hooks.d.ts.map +1 -0
- package/lib/ai/hooks.js +108 -0
- package/lib/ai/hooks.js.map +1 -0
- package/lib/ai/index.d.ts +6 -0
- package/lib/ai/index.d.ts.map +1 -1
- package/lib/ai/index.js +6 -0
- package/lib/ai/index.js.map +1 -1
- package/lib/ai/init.d.ts.map +1 -1
- package/lib/ai/init.js +120 -3
- package/lib/ai/init.js.map +1 -1
- package/lib/ai/types.d.ts +2 -0
- package/lib/ai/types.d.ts.map +1 -1
- package/lib/ai/zhin-agent.d.ts +28 -1
- package/lib/ai/zhin-agent.d.ts.map +1 -1
- package/lib/ai/zhin-agent.js +196 -57
- package/lib/ai/zhin-agent.js.map +1 -1
- package/lib/built/config.d.ts +10 -0
- package/lib/built/config.d.ts.map +1 -1
- package/lib/built/config.js +54 -3
- package/lib/built/config.js.map +1 -1
- package/lib/built/tool.d.ts +6 -0
- package/lib/built/tool.d.ts.map +1 -1
- package/lib/built/tool.js +12 -0
- package/lib/built/tool.js.map +1 -1
- package/lib/cron.d.ts +0 -27
- package/lib/cron.d.ts.map +1 -1
- package/lib/cron.js +28 -27
- package/lib/cron.js.map +1 -1
- package/lib/types-generator.js +1 -1
- package/lib/types-generator.js.map +1 -1
- package/lib/types.d.ts +7 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/ai/agent.ts +6 -0
- package/src/ai/bootstrap.ts +263 -0
- package/src/ai/builtin-tools.ts +569 -0
- package/src/ai/compaction.ts +529 -0
- package/src/ai/hooks.ts +223 -0
- package/src/ai/index.ts +58 -0
- package/src/ai/init.ts +127 -3
- package/src/ai/types.ts +2 -0
- package/src/ai/zhin-agent.ts +226 -54
- package/src/built/config.ts +53 -3
- package/src/built/tool.ts +12 -0
- package/src/cron.ts +28 -27
- package/src/types-generator.ts +1 -1
- package/src/types.ts +8 -0
- package/tests/adapter.test.ts +1 -1
- package/tests/config.test.ts +2 -2
- package/test/minimal-bot.ts +0 -31
- package/test/stress-test.ts +0 -123
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI 内置系统工具
|
|
3
|
+
*
|
|
4
|
+
* 借鉴 OpenClaw/MicroClaw 的实用工具设计,为 ZhinAgent 提供:
|
|
5
|
+
*
|
|
6
|
+
* 文件工具: read_file, write_file, edit_file, glob, grep
|
|
7
|
+
* Shell: bash
|
|
8
|
+
* 网络: web_search, web_fetch
|
|
9
|
+
* 计划: todo_read, todo_write
|
|
10
|
+
* 记忆: read_memory, write_memory (AGENTS.md)
|
|
11
|
+
* 技能: activate_skill
|
|
12
|
+
* 会话: session_status, compact_session
|
|
13
|
+
* 技能发现: 工作区 skills/ 目录自动扫描
|
|
14
|
+
* 引导文件: SOUL.md, TOOLS.md, AGENTS.md 自动加载
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { exec } from 'child_process';
|
|
20
|
+
import { promisify } from 'util';
|
|
21
|
+
import { Logger } from '@zhin.js/logger';
|
|
22
|
+
import { ZhinTool } from '../built/tool.js';
|
|
23
|
+
|
|
24
|
+
// 从新模块中 re-export 向后兼容的函数
|
|
25
|
+
export { loadSoulPersona, loadToolsGuide, loadAgentsMemory } from './bootstrap.js';
|
|
26
|
+
|
|
27
|
+
const execAsync = promisify(exec);
|
|
28
|
+
const logger = new Logger(null, 'builtin-tools');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 获取数据目录路径
|
|
32
|
+
*/
|
|
33
|
+
function getDataDir(): string {
|
|
34
|
+
const dir = path.join(process.cwd(), 'data');
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// 工具工厂函数
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 创建所有内置系统工具
|
|
45
|
+
*/
|
|
46
|
+
export function createBuiltinTools(): ZhinTool[] {
|
|
47
|
+
const DATA_DIR = getDataDir();
|
|
48
|
+
|
|
49
|
+
const tools: ZhinTool[] = [];
|
|
50
|
+
|
|
51
|
+
// ── read_file ──
|
|
52
|
+
tools.push(
|
|
53
|
+
new ZhinTool('read_file')
|
|
54
|
+
.desc('读取文件内容(带行号,支持 offset/limit 分页)')
|
|
55
|
+
.keyword('读文件', '查看', '打开', 'cat', 'read')
|
|
56
|
+
.tag('file', 'read')
|
|
57
|
+
.param('file_path', { type: 'string', description: '文件路径(绝对或相对)' }, true)
|
|
58
|
+
.param('offset', { type: 'number', description: '起始行号(0-based,默认 0)' })
|
|
59
|
+
.param('limit', { type: 'number', description: '最大读取行数(默认全部)' })
|
|
60
|
+
.execute(async (args) => {
|
|
61
|
+
try {
|
|
62
|
+
const content = await fs.promises.readFile(args.file_path, 'utf-8');
|
|
63
|
+
const lines = content.split('\n');
|
|
64
|
+
const offset = args.offset ?? 0;
|
|
65
|
+
const limit = args.limit ?? lines.length;
|
|
66
|
+
const sliced = lines.slice(offset, offset + limit);
|
|
67
|
+
const numbered = sliced.map((line: string, i: number) => `${offset + i + 1} | ${line}`).join('\n');
|
|
68
|
+
return `File: ${args.file_path} (${lines.length} lines, showing ${offset + 1}-${Math.min(offset + limit, lines.length)})\n${numbered}`;
|
|
69
|
+
} catch (e: any) {
|
|
70
|
+
return `Error: ${e.message}`;
|
|
71
|
+
}
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// ── write_file ──
|
|
76
|
+
tools.push(
|
|
77
|
+
new ZhinTool('write_file')
|
|
78
|
+
.desc('创建或覆盖文件(自动创建目录)')
|
|
79
|
+
.keyword('写文件', '创建文件', '保存', 'write')
|
|
80
|
+
.tag('file', 'write')
|
|
81
|
+
.param('file_path', { type: 'string', description: '文件路径' }, true)
|
|
82
|
+
.param('content', { type: 'string', description: '写入内容' }, true)
|
|
83
|
+
.execute(async (args) => {
|
|
84
|
+
try {
|
|
85
|
+
await fs.promises.mkdir(path.dirname(args.file_path), { recursive: true });
|
|
86
|
+
await fs.promises.writeFile(args.file_path, args.content, 'utf-8');
|
|
87
|
+
return `✅ Wrote ${Buffer.byteLength(args.content)} bytes to ${args.file_path}`;
|
|
88
|
+
} catch (e: any) {
|
|
89
|
+
return `Error: ${e.message}`;
|
|
90
|
+
}
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// ── edit_file ──
|
|
95
|
+
tools.push(
|
|
96
|
+
new ZhinTool('edit_file')
|
|
97
|
+
.desc('查找并替换文件内容(old_string 必须唯一匹配)。注意:old_string 应包含完整的行内容(含前后文),不要只匹配单个数字或单词')
|
|
98
|
+
.keyword('编辑', '修改', '替换', 'edit')
|
|
99
|
+
.tag('file', 'edit')
|
|
100
|
+
.param('file_path', { type: 'string', description: '文件路径' }, true)
|
|
101
|
+
.param('old_string', { type: 'string', description: '要替换的原文(必须在文件中唯一出现,建议包含完整行)' }, true)
|
|
102
|
+
.param('new_string', { type: 'string', description: '替换后的文本(必须是替换 old_string 后的完整内容)' }, true)
|
|
103
|
+
.execute(async (args) => {
|
|
104
|
+
try {
|
|
105
|
+
const content = await fs.promises.readFile(args.file_path, 'utf-8');
|
|
106
|
+
const count = content.split(args.old_string).length - 1;
|
|
107
|
+
if (count === 0) return `Error: old_string not found in ${args.file_path}. Use read_file to check current content first.`;
|
|
108
|
+
if (count > 1) return `Error: old_string matches ${count} locations (must be unique). Include more context to make it unique.`;
|
|
109
|
+
const newContent = content.replace(args.old_string, args.new_string);
|
|
110
|
+
await fs.promises.writeFile(args.file_path, newContent, 'utf-8');
|
|
111
|
+
|
|
112
|
+
// 返回修改前后的差异上下文,帮助 AI 确认修改正确
|
|
113
|
+
const oldLines = args.old_string.split('\n');
|
|
114
|
+
const newLines = args.new_string.split('\n');
|
|
115
|
+
return `✅ Edited ${args.file_path}\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...' : ''}`;
|
|
116
|
+
} catch (e: any) {
|
|
117
|
+
return `Error: ${e.message}`;
|
|
118
|
+
}
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// ── glob ──
|
|
123
|
+
tools.push(
|
|
124
|
+
new ZhinTool('glob')
|
|
125
|
+
.desc('按 glob 模式查找文件(如 **/*.ts)')
|
|
126
|
+
.keyword('查找文件', '搜索文件', '文件列表', 'ls', 'find')
|
|
127
|
+
.tag('file', 'search')
|
|
128
|
+
.param('pattern', { type: 'string', description: 'Glob 模式(如 **/*.ts)' }, true)
|
|
129
|
+
.param('cwd', { type: 'string', description: '工作目录(默认项目根目录)' })
|
|
130
|
+
.execute(async (args) => {
|
|
131
|
+
try {
|
|
132
|
+
const cwd = args.cwd || process.cwd();
|
|
133
|
+
const { stdout } = await execAsync(
|
|
134
|
+
`find . -path './${args.pattern}' -type f 2>/dev/null | head -100`,
|
|
135
|
+
{ cwd },
|
|
136
|
+
);
|
|
137
|
+
const files = stdout.trim().split('\n').filter(Boolean);
|
|
138
|
+
return files.length === 0
|
|
139
|
+
? `No files matching '${args.pattern}'`
|
|
140
|
+
: `Found ${files.length} files:\n${files.join('\n')}`;
|
|
141
|
+
} catch (e: any) {
|
|
142
|
+
return `Error: ${e.message}`;
|
|
143
|
+
}
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// ── grep ──
|
|
148
|
+
tools.push(
|
|
149
|
+
new ZhinTool('grep')
|
|
150
|
+
.desc('按正则搜索文件内容,返回匹配行和行号')
|
|
151
|
+
.keyword('搜索', '查找内容', 'grep', '正则')
|
|
152
|
+
.tag('search', 'regex')
|
|
153
|
+
.param('pattern', { type: 'string', description: '正则表达式' }, true)
|
|
154
|
+
.param('path', { type: 'string', description: '搜索路径(默认 .)' })
|
|
155
|
+
.param('include', { type: 'string', description: '文件类型过滤(如 *.ts)' })
|
|
156
|
+
.execute(async (args) => {
|
|
157
|
+
try {
|
|
158
|
+
const searchPath = args.path || '.';
|
|
159
|
+
const includeFlag = args.include ? `--include='${args.include}'` : '';
|
|
160
|
+
const { stdout } = await execAsync(
|
|
161
|
+
`grep -rn ${includeFlag} '${args.pattern}' ${searchPath} 2>/dev/null | head -50`,
|
|
162
|
+
{ cwd: process.cwd() },
|
|
163
|
+
);
|
|
164
|
+
return stdout.trim() || `No matches for '${args.pattern}'`;
|
|
165
|
+
} catch (e: any) {
|
|
166
|
+
if (e.code === 1) return `No matches for '${args.pattern}'`;
|
|
167
|
+
return `Error: ${e.message}`;
|
|
168
|
+
}
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// ── bash ──
|
|
173
|
+
tools.push(
|
|
174
|
+
new ZhinTool('bash')
|
|
175
|
+
.desc('执行 Shell 命令(带超时保护)')
|
|
176
|
+
.keyword('执行', '运行', '命令', '终端', 'shell', 'bash')
|
|
177
|
+
.tag('shell', 'exec')
|
|
178
|
+
.param('command', { type: 'string', description: 'Shell 命令' }, true)
|
|
179
|
+
.param('cwd', { type: 'string', description: '工作目录' })
|
|
180
|
+
.param('timeout', { type: 'number', description: '超时毫秒数(默认 30000)' })
|
|
181
|
+
.execute(async (args) => {
|
|
182
|
+
try {
|
|
183
|
+
const timeout = args.timeout ?? 30000;
|
|
184
|
+
const { stdout, stderr } = await execAsync(args.command, {
|
|
185
|
+
cwd: args.cwd || process.cwd(),
|
|
186
|
+
timeout,
|
|
187
|
+
maxBuffer: 1024 * 1024,
|
|
188
|
+
});
|
|
189
|
+
let result = '';
|
|
190
|
+
if (stdout.trim()) result += `STDOUT:\n${stdout.trim()}`;
|
|
191
|
+
if (stderr.trim()) result += `${result ? '\n' : ''}STDERR:\n${stderr.trim()}`;
|
|
192
|
+
return result || '(no output)';
|
|
193
|
+
} catch (e: any) {
|
|
194
|
+
return `Error (exit ${e.code || '?'}): ${e.message}\nSTDOUT:\n${e.stdout || ''}\nSTDERR:\n${e.stderr || ''}`;
|
|
195
|
+
}
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// ── web_search ──
|
|
200
|
+
tools.push(
|
|
201
|
+
new ZhinTool('web_search')
|
|
202
|
+
.desc('通过 DuckDuckGo 搜索网页,返回标题、URL 和摘要(零依赖)')
|
|
203
|
+
.keyword('搜索', '网上', '谷歌', '百度', '查询', 'search')
|
|
204
|
+
.tag('web', 'search')
|
|
205
|
+
.param('query', { type: 'string', description: '搜索关键词' }, true)
|
|
206
|
+
.param('limit', { type: 'number', description: '最大结果数(默认 5)' })
|
|
207
|
+
.execute(async (args) => {
|
|
208
|
+
try {
|
|
209
|
+
const limit = args.limit ?? 5;
|
|
210
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(args.query)}`;
|
|
211
|
+
const res = await fetch(url, {
|
|
212
|
+
headers: {
|
|
213
|
+
'User-Agent': 'Mozilla/5.0 (compatible; ZhinBot/1.0)',
|
|
214
|
+
'Accept': 'text/html',
|
|
215
|
+
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
216
|
+
},
|
|
217
|
+
signal: AbortSignal.timeout(15000),
|
|
218
|
+
});
|
|
219
|
+
if (!res.ok) return `HTTP ${res.status}: ${res.statusText}`;
|
|
220
|
+
const html = await res.text();
|
|
221
|
+
|
|
222
|
+
// 从 DuckDuckGo HTML 页面提取搜索结果
|
|
223
|
+
const results: { title: string; url: string; snippet: string }[] = [];
|
|
224
|
+
const resultBlocks = html.split(/class="result\s/);
|
|
225
|
+
|
|
226
|
+
for (let i = 1; i < resultBlocks.length && results.length < limit; i++) {
|
|
227
|
+
const block = resultBlocks[i];
|
|
228
|
+
|
|
229
|
+
// 提取标题和 URL
|
|
230
|
+
const titleMatch = block.match(/<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/);
|
|
231
|
+
if (!titleMatch) continue;
|
|
232
|
+
|
|
233
|
+
let href = titleMatch[1];
|
|
234
|
+
// DuckDuckGo 会将 URL 编码到 uddg 参数中
|
|
235
|
+
const uddgMatch = href.match(/[?&]uddg=([^&]+)/);
|
|
236
|
+
if (uddgMatch) href = decodeURIComponent(uddgMatch[1]);
|
|
237
|
+
|
|
238
|
+
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
|
|
239
|
+
|
|
240
|
+
// 提取摘要
|
|
241
|
+
const snippetMatch = block.match(/<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/);
|
|
242
|
+
const snippet = snippetMatch
|
|
243
|
+
? snippetMatch[1].replace(/<[^>]+>/g, '').trim()
|
|
244
|
+
: '';
|
|
245
|
+
|
|
246
|
+
if (title && href) {
|
|
247
|
+
results.push({ title, url: href, snippet });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (results.length === 0) return 'No results found.';
|
|
252
|
+
return results.map((r, i) =>
|
|
253
|
+
`${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.snippet}`,
|
|
254
|
+
).join('\n\n');
|
|
255
|
+
} catch (e: any) {
|
|
256
|
+
return `Error: ${e.message}`;
|
|
257
|
+
}
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// ── web_fetch ──
|
|
262
|
+
tools.push(
|
|
263
|
+
new ZhinTool('web_fetch')
|
|
264
|
+
.desc('抓取网页内容(去除 HTML 标签,最大 20KB)')
|
|
265
|
+
.keyword('抓取', '网页', 'fetch', 'url', '链接')
|
|
266
|
+
.tag('web', 'fetch')
|
|
267
|
+
.param('url', { type: 'string', description: 'URL 地址' }, true)
|
|
268
|
+
.execute(async (args) => {
|
|
269
|
+
try {
|
|
270
|
+
const response = await fetch(args.url, {
|
|
271
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ZhinBot/1.0)' },
|
|
272
|
+
signal: AbortSignal.timeout(15000),
|
|
273
|
+
});
|
|
274
|
+
if (!response.ok) return `HTTP ${response.status}: ${response.statusText}`;
|
|
275
|
+
const html = await response.text();
|
|
276
|
+
const text = html
|
|
277
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
278
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
279
|
+
.replace(/<[^>]+>/g, ' ')
|
|
280
|
+
.replace(/\s+/g, ' ')
|
|
281
|
+
.trim();
|
|
282
|
+
const maxLen = 20 * 1024;
|
|
283
|
+
return text.length > maxLen ? text.slice(0, maxLen) + '\n...(truncated)' : text;
|
|
284
|
+
} catch (e: any) {
|
|
285
|
+
return `Error: ${e.message}`;
|
|
286
|
+
}
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// ── todo_read ──
|
|
291
|
+
tools.push(
|
|
292
|
+
new ZhinTool('todo_read')
|
|
293
|
+
.desc('读取当前任务计划列表,用于查看进度和待办事项')
|
|
294
|
+
.keyword('任务', '计划', '进度', 'todo', '待办')
|
|
295
|
+
.tag('plan', 'todo')
|
|
296
|
+
.param('chat_id', { type: 'string', description: '聊天范围(传 "global" 表示全局,或传具体聊天 ID)' }, true)
|
|
297
|
+
.execute(async (args) => {
|
|
298
|
+
try {
|
|
299
|
+
const dir = args.chat_id && args.chat_id !== 'global' ? path.join(DATA_DIR, 'groups', args.chat_id) : DATA_DIR;
|
|
300
|
+
const todoPath = path.join(dir, 'TODO.json');
|
|
301
|
+
if (!fs.existsSync(todoPath)) return 'No tasks found. Use todo_write to create a plan.';
|
|
302
|
+
const data = JSON.parse(await fs.promises.readFile(todoPath, 'utf-8'));
|
|
303
|
+
if (!data.items || data.items.length === 0) return 'Task list is empty.';
|
|
304
|
+
const lines = data.items.map((item: any, i: number) => {
|
|
305
|
+
const status = item.status === 'done' ? '✅' : item.status === 'in-progress' ? '🔄' : '⬜';
|
|
306
|
+
return `${status} ${i + 1}. ${item.title}${item.detail ? ' — ' + item.detail : ''}`;
|
|
307
|
+
});
|
|
308
|
+
return `📋 Tasks (${data.items.filter((i: any) => i.status === 'done').length}/${data.items.length} done):\n${lines.join('\n')}`;
|
|
309
|
+
} catch (e: any) {
|
|
310
|
+
return `Error: ${e.message}`;
|
|
311
|
+
}
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
// ── todo_write ──
|
|
316
|
+
tools.push(
|
|
317
|
+
new ZhinTool('todo_write')
|
|
318
|
+
.desc('创建或更新任务计划,用于分解复杂任务并跟踪进度')
|
|
319
|
+
.keyword('创建计划', '更新任务', '标记完成', 'todo')
|
|
320
|
+
.tag('plan', 'todo')
|
|
321
|
+
.param('items', { type: 'array', description: '任务列表 [{title, detail?, status: pending|in-progress|done}]' } as any, true)
|
|
322
|
+
.param('chat_id', { type: 'string', description: '聊天范围(可选)' })
|
|
323
|
+
.execute(async (args) => {
|
|
324
|
+
try {
|
|
325
|
+
const dir = args.chat_id ? path.join(DATA_DIR, 'groups', args.chat_id) : DATA_DIR;
|
|
326
|
+
const todoPath = path.join(dir, 'TODO.json');
|
|
327
|
+
await fs.promises.mkdir(path.dirname(todoPath), { recursive: true });
|
|
328
|
+
const data = { updated_at: new Date().toISOString(), items: args.items };
|
|
329
|
+
await fs.promises.writeFile(todoPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
330
|
+
const done = args.items.filter((i: any) => i.status === 'done').length;
|
|
331
|
+
return `✅ Tasks updated (${done}/${args.items.length} done)`;
|
|
332
|
+
} catch (e: any) {
|
|
333
|
+
return `Error: ${e.message}`;
|
|
334
|
+
}
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// ── read_memory ──
|
|
339
|
+
tools.push(
|
|
340
|
+
new ZhinTool('read_memory')
|
|
341
|
+
.desc('读取持久化记忆(AGENTS.md)。记忆跨会话保持。scope: global(共享)或 chat(按聊天隔离)')
|
|
342
|
+
.keyword('记忆', '记住', '回忆', '之前', '上次', 'memory')
|
|
343
|
+
.tag('memory', 'agents')
|
|
344
|
+
.param('scope', { type: 'string', description: "'global' 或 'chat'(默认 chat)", enum: ['global', 'chat'] }, true)
|
|
345
|
+
.param('chat_id', { type: 'string', description: '聊天 ID(chat scope 时使用)' })
|
|
346
|
+
.execute(async (args) => {
|
|
347
|
+
try {
|
|
348
|
+
const memPath = args.scope === 'global'
|
|
349
|
+
? path.join(DATA_DIR, 'AGENTS.md')
|
|
350
|
+
: path.join(DATA_DIR, 'groups', args.chat_id || 'default', 'AGENTS.md');
|
|
351
|
+
if (!fs.existsSync(memPath)) return 'No memory stored yet.';
|
|
352
|
+
return await fs.promises.readFile(memPath, 'utf-8');
|
|
353
|
+
} catch (e: any) {
|
|
354
|
+
return `Error: ${e.message}`;
|
|
355
|
+
}
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// ── write_memory ──
|
|
360
|
+
tools.push(
|
|
361
|
+
new ZhinTool('write_memory')
|
|
362
|
+
.desc('写入持久化记忆。当用户说"记住…"、"记录…"时使用此工具')
|
|
363
|
+
.keyword('记住', '保存', 'remember', '记录')
|
|
364
|
+
.tag('memory', 'agents')
|
|
365
|
+
.param('content', { type: 'string', description: '要保存的记忆内容(Markdown)' }, true)
|
|
366
|
+
.param('scope', { type: 'string', description: "'global' 或 'chat'(默认 chat)", enum: ['global', 'chat'] })
|
|
367
|
+
.param('chat_id', { type: 'string', description: '聊天 ID' })
|
|
368
|
+
.execute(async (args) => {
|
|
369
|
+
try {
|
|
370
|
+
const memPath = args.scope === 'global'
|
|
371
|
+
? path.join(DATA_DIR, 'AGENTS.md')
|
|
372
|
+
: path.join(DATA_DIR, 'groups', args.chat_id || 'default', 'AGENTS.md');
|
|
373
|
+
await fs.promises.mkdir(path.dirname(memPath), { recursive: true });
|
|
374
|
+
await fs.promises.writeFile(memPath, args.content, 'utf-8');
|
|
375
|
+
return `✅ Memory saved (${args.scope || 'chat'} scope)`;
|
|
376
|
+
} catch (e: any) {
|
|
377
|
+
return `Error: ${e.message}`;
|
|
378
|
+
}
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// ── activate_skill ──
|
|
383
|
+
tools.push(
|
|
384
|
+
new ZhinTool('activate_skill')
|
|
385
|
+
.desc('按名称激活技能,加载其完整指令。当判断某个技能与用户请求相关时使用')
|
|
386
|
+
.keyword('技能', '激活', '启用', '使用', 'skill', 'activate', 'use')
|
|
387
|
+
.tag('skill', 'activate')
|
|
388
|
+
.param('name', { type: 'string', description: '技能名称' }, true)
|
|
389
|
+
.execute(async (args) => {
|
|
390
|
+
try {
|
|
391
|
+
const dirs = [
|
|
392
|
+
path.join(process.cwd(), 'skills'),
|
|
393
|
+
path.join(DATA_DIR, 'skills'),
|
|
394
|
+
];
|
|
395
|
+
for (const dir of dirs) {
|
|
396
|
+
const skillPath = path.join(dir, args.name, 'SKILL.md');
|
|
397
|
+
if (fs.existsSync(skillPath)) {
|
|
398
|
+
const fullContent = await fs.promises.readFile(skillPath, 'utf-8');
|
|
399
|
+
// 提取精简的执行指令,避免全文输出占用太多 token
|
|
400
|
+
return extractSkillInstructions(args.name, fullContent);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return `Skill '${args.name}' not found. Check skills/ directory.`;
|
|
404
|
+
} catch (e: any) {
|
|
405
|
+
return `Error: ${e.message}`;
|
|
406
|
+
}
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
logger.info(`已创建 ${tools.length} 个内置系统工具`);
|
|
411
|
+
return tools;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 从 SKILL.md 全文中提取精简的执行指令
|
|
416
|
+
* 只保留 frontmatter(工具列表)和执行规则,去掉示例、测试场景等冗余内容
|
|
417
|
+
* 这样可以大幅减少 token 占用,让小模型能有足够空间继续调用工具
|
|
418
|
+
*/
|
|
419
|
+
function extractSkillInstructions(name: string, content: string): string {
|
|
420
|
+
const lines: string[] = [];
|
|
421
|
+
lines.push(`Skill '${name}' activated. 请立即根据以下指导执行工具调用:`);
|
|
422
|
+
lines.push('');
|
|
423
|
+
|
|
424
|
+
// 1. 提取 frontmatter 中的 tools 列表
|
|
425
|
+
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
426
|
+
if (fmMatch) {
|
|
427
|
+
const fmContent = fmMatch[1];
|
|
428
|
+
const toolsMatch = fmContent.match(/tools:\s*\n((?:\s+-\s+.+\n?)+)/);
|
|
429
|
+
if (toolsMatch) {
|
|
430
|
+
lines.push('## 可用工具');
|
|
431
|
+
lines.push(toolsMatch[0].trim());
|
|
432
|
+
lines.push('');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 2. 提取"执行规则"或"规则"部分(关键的行动指导)
|
|
437
|
+
const rulesMatch = content.match(/## 执行规则[\s\S]*?(?=\n## [^执]|$)/);
|
|
438
|
+
if (rulesMatch) {
|
|
439
|
+
lines.push(rulesMatch[0].trim());
|
|
440
|
+
lines.push('');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 3. 添加强制执行提醒
|
|
444
|
+
lines.push('## 立即行动');
|
|
445
|
+
lines.push('你现在必须根据用户的原始请求,立即调用上述工具。不要描述步骤,直接执行 tool_calls。');
|
|
446
|
+
|
|
447
|
+
return lines.join('\n');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ============================================================================
|
|
451
|
+
// 技能发现
|
|
452
|
+
// ============================================================================
|
|
453
|
+
|
|
454
|
+
interface SkillMeta {
|
|
455
|
+
name: string;
|
|
456
|
+
description: string;
|
|
457
|
+
keywords?: string[];
|
|
458
|
+
tags?: string[];
|
|
459
|
+
/** SKILL.md frontmatter 中声明的关联工具名列表 */
|
|
460
|
+
toolNames?: string[];
|
|
461
|
+
filePath: string;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 扫描工作区 skills/ 目录,发现 SKILL.md 技能文件
|
|
466
|
+
* 支持平台/依赖兼容性过滤
|
|
467
|
+
*/
|
|
468
|
+
export async function discoverWorkspaceSkills(): Promise<SkillMeta[]> {
|
|
469
|
+
const skills: SkillMeta[] = [];
|
|
470
|
+
const dataDir = getDataDir();
|
|
471
|
+
const skillDirs = [
|
|
472
|
+
path.join(process.cwd(), 'skills'),
|
|
473
|
+
path.join(dataDir, 'skills'),
|
|
474
|
+
];
|
|
475
|
+
|
|
476
|
+
// 确保 data/skills 目录存在
|
|
477
|
+
const defaultSkillDir = path.join(dataDir, 'skills');
|
|
478
|
+
if (!fs.existsSync(defaultSkillDir)) {
|
|
479
|
+
fs.mkdirSync(defaultSkillDir, { recursive: true });
|
|
480
|
+
logger.debug(`Created skill directory: ${defaultSkillDir}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (const skillsDir of skillDirs) {
|
|
484
|
+
if (!fs.existsSync(skillsDir)) continue;
|
|
485
|
+
|
|
486
|
+
let entries: fs.Dirent[];
|
|
487
|
+
try {
|
|
488
|
+
entries = await fs.promises.readdir(skillsDir, { withFileTypes: true });
|
|
489
|
+
} catch {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
for (const entry of entries) {
|
|
494
|
+
if (!entry.isDirectory()) continue;
|
|
495
|
+
const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md');
|
|
496
|
+
if (!fs.existsSync(skillMdPath)) continue;
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
const content = await fs.promises.readFile(skillMdPath, 'utf-8');
|
|
500
|
+
// 改进的 frontmatter 正则:支持多种换行符、可选的尾部空白
|
|
501
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
|
|
502
|
+
if (!match) {
|
|
503
|
+
logger.debug(`Skill文件 ${skillMdPath} 没有有效的frontmatter格式`);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 动态导入 yaml,使用 .default 兼容 ESM 模块
|
|
508
|
+
let yaml: any;
|
|
509
|
+
try {
|
|
510
|
+
yaml = await import('yaml');
|
|
511
|
+
if (yaml.default) yaml = yaml.default;
|
|
512
|
+
} catch (e) {
|
|
513
|
+
logger.warn(`Unable to import yaml module: ${e}`);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const metadata = yaml.parse(match[1]);
|
|
518
|
+
if (!metadata || !metadata.name || !metadata.description) {
|
|
519
|
+
logger.debug(`Skill文件 ${skillMdPath} 缺少必需的 name/description 字段`);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 平台兼容检查
|
|
524
|
+
const compat = metadata.compatibility || {};
|
|
525
|
+
if (compat.os && Array.isArray(compat.os)) {
|
|
526
|
+
const currentOs = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux';
|
|
527
|
+
if (!compat.os.includes(currentOs)) {
|
|
528
|
+
logger.debug(`Skipping skill '${metadata.name}' (unsupported OS)`);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// 依赖检查
|
|
534
|
+
const deps = compat.deps || metadata.deps;
|
|
535
|
+
if (deps && Array.isArray(deps)) {
|
|
536
|
+
let missing = false;
|
|
537
|
+
for (const dep of deps) {
|
|
538
|
+
try {
|
|
539
|
+
await execAsync(`which ${dep} 2>/dev/null`);
|
|
540
|
+
} catch {
|
|
541
|
+
logger.debug(`Skipping skill '${metadata.name}' (missing dep: ${dep})`);
|
|
542
|
+
missing = true;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (missing) continue;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
skills.push({
|
|
550
|
+
name: metadata.name,
|
|
551
|
+
description: metadata.description,
|
|
552
|
+
keywords: metadata.keywords || [],
|
|
553
|
+
tags: [...(metadata.tags || []), 'workspace-skill'],
|
|
554
|
+
toolNames: Array.isArray(metadata.tools) ? metadata.tools : [],
|
|
555
|
+
filePath: skillMdPath,
|
|
556
|
+
});
|
|
557
|
+
logger.debug(`Skill发现成功: ${metadata.name}, tools: ${JSON.stringify(metadata.tools || [])}`);
|
|
558
|
+
} catch (e) {
|
|
559
|
+
logger.warn(`Failed to parse SKILL.md in ${skillMdPath}:`, e);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (skills.length > 0) {
|
|
565
|
+
logger.info(`发现 ${skills.length} 个工作区技能: ${skills.map(s => `${s.name}(tools:${(s.toolNames || []).join(',')})`).join(', ')}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return skills;
|
|
569
|
+
}
|