@huicai/cli 0.5.1

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/src/repl.js ADDED
@@ -0,0 +1,203 @@
1
+ // 交互式 REPL + 一次性(print)模式。用同一个 readline 处理「主输入」与「工具授权」(顺序进行,不冲突)。
2
+ // 斜杠命令走 commands.js 统一注册表(与 ink TUI 共用);支持 @文件 引用与 !命令 直通。
3
+
4
+ import readline from 'node:readline';
5
+ import { c } from './colors.js';
6
+ import { createPermissions } from './permissions.js';
7
+ import { runTurn } from './agent.js';
8
+ import { createSession, saveSession, turnCount } from './session.js';
9
+ import { loadMcpTools, closeMcp } from './mcp.js';
10
+ import { makeSubagentTool } from './subagent.js';
11
+ import { runSlash } from './commands.js';
12
+ import { expandAtMentions, runBangCommand, loadHistory, appendHistory } from './input.js';
13
+ import { makeSkillTools } from './skills.js';
14
+
15
+ /** 汇集动态工具:本地技能(load_skill)+ 本地 MCP 工具 + dispatch_agent 子智能体。返回 {extraTools, clients}。 */
16
+ export async function buildExtraTools(config, cwd) {
17
+ let mcp = { tools: [], clients: [] };
18
+ try {
19
+ mcp = await loadMcpTools();
20
+ } catch (e) {
21
+ process.stdout.write(c.yellow('MCP 初始化失败(忽略):' + e.message) + '\n');
22
+ }
23
+ // 本地技能工具(.huicai/skills 与 ~/.huicai/skills;无技能时为空数组,零开销)
24
+ const extraTools = [...makeSkillTools(cwd), ...mcp.tools];
25
+ const dispatch = makeSubagentTool({ config, cwd, extraTools: mcp.tools, depth: 0 });
26
+ if (dispatch) extraTools.push(dispatch);
27
+ return { extraTools, clients: mcp.clients };
28
+ }
29
+
30
+ function banner(config, cwd, permissions) {
31
+ process.stdout.write(c.bold(c.magenta('\n huicai')) + c.gray(' · 终端智能体') + '\n');
32
+ const platform = /^(agent|workflow|datahuman)-/.test(String(config.model || ''));
33
+ process.stdout.write(
34
+ c.gray(` ${platform ? '目标' : '模型'} ${config.model || '(未设置, /model 设置)'}`
35
+ + `${platform ? ' · 平台编排(分享页同款对话,无本机工具)' : ''} · 目录 ${cwd}`) + '\n',
36
+ );
37
+ process.stdout.write(
38
+ c.gray(
39
+ ` ${permissions.isYolo() ? '⚠ 自动放行(yolo) 已开 · ' : ''}` +
40
+ '/help 命令 · @文件 引用 · !命令 直通 shell · Ctrl+C 中断本回合',
41
+ ) + '\n',
42
+ );
43
+ }
44
+
45
+ export async function startRepl({ config, cwd, yolo, initialPrompt, session, version }) {
46
+ const rl = readline.createInterface({
47
+ input: process.stdin,
48
+ output: process.stdout,
49
+ history: loadHistory(cwd).reverse(), // readline 的 history 最近的在前
50
+ historySize: 200,
51
+ });
52
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
53
+ const permissions = createPermissions({ yolo, ask, cwd });
54
+ const usage = { prompt: 0, completion: 0 };
55
+ const todoState = { list: [] };
56
+ let currentAbort = null;
57
+ // ctx 持有可变的 session(/clear 会替换为新会话),各处统一读 ctx.session
58
+ const ctx = { config, cwd, permissions, usage, session, version };
59
+
60
+ rl.on('SIGINT', () => {
61
+ if (currentAbort) currentAbort.abort();
62
+ else process.stdout.write('\n' + c.gray('输入 /exit 退出。') + '\n');
63
+ });
64
+
65
+ banner(config, cwd, permissions);
66
+ echoResumed(session);
67
+ const { extraTools, clients } = await buildExtraTools(config, cwd); // 连接 MCP(若已配置)
68
+
69
+ const doTurn = async (text) => {
70
+ const { text: expanded, mentions } = expandAtMentions(text, cwd);
71
+ if (mentions.length) process.stdout.write(c.gray(' · 已展开引用:' + mentions.join('、')) + '\n');
72
+ ctx.session.messages.push({ role: 'user', content: expanded });
73
+ currentAbort = new AbortController();
74
+ if (!ctx.session.compaction) ctx.session.compaction = {};
75
+ config.sessionId = ctx.session.id; // 对话落库审计:当前会话号(/clear 换新会话后随之更新)
76
+ const r = await runTurn({
77
+ config,
78
+ messages: ctx.session.messages,
79
+ permissions,
80
+ cwd,
81
+ signal: currentAbort.signal,
82
+ extraTools,
83
+ todoState,
84
+ compaction: ctx.session.compaction,
85
+ });
86
+ if (r?.usage) {
87
+ usage.prompt += r.usage.prompt;
88
+ usage.completion += r.usage.completion;
89
+ }
90
+ if (r?.lastPromptTokens) ctx.session.lastPromptTokens = r.lastPromptTokens;
91
+ currentAbort = null;
92
+ saveSession(ctx.session); // 每轮结束自动保存,可随时 --resume 恢复
93
+ };
94
+
95
+ const cmdCtx = () => ({
96
+ ...ctx,
97
+ session: ctx.session,
98
+ extraTools,
99
+ print: (t) => process.stdout.write(String(t).endsWith('\n') ? String(t) : String(t) + '\n'),
100
+ });
101
+
102
+ if (initialPrompt) {
103
+ process.stdout.write(c.cyan('\nhuicai> ') + initialPrompt + '\n');
104
+ await doTurn(initialPrompt);
105
+ }
106
+
107
+ while (true) {
108
+ let input;
109
+ try {
110
+ input = (await ask(c.cyan('\nhuicai> '))).trim();
111
+ } catch {
112
+ break; // stdin 关闭
113
+ }
114
+ if (input === '') continue;
115
+ appendHistory(cwd, input);
116
+ if (input === '/exit' || input === '/quit') break;
117
+
118
+ if (input.startsWith('!') && input.length > 1) {
119
+ // shell 直通:本机执行,输出加入上下文(不消耗模型调用)
120
+ const command = input.slice(1).trim();
121
+ const { output, record } = await runBangCommand(command, cwd);
122
+ process.stdout.write(c.gray(output.split('\n').map((l) => ' ' + l).join('\n')) + '\n');
123
+ ctx.session.messages.push({ role: 'user', content: record });
124
+ saveSession(ctx.session);
125
+ continue;
126
+ }
127
+
128
+ if (input.startsWith('/')) {
129
+ const r = await runSlash(input, cmdCtx());
130
+ if (r.action === 'exit') break;
131
+ if (r.action === 'clear') {
132
+ ctx.session = createSession({ cwd, model: config.model, messages: [ctx.session.messages[0]] });
133
+ todoState.list = [];
134
+ process.stdout.write(c.gray('已开启新会话(旧会话已保存,可用 huicai --resume 恢复)。\n'));
135
+ }
136
+ if (r.prompt) await doTurn(r.prompt);
137
+ continue;
138
+ }
139
+ await doTurn(input);
140
+ }
141
+ rl.close();
142
+ closeMcp(clients);
143
+ process.stdout.write(c.gray('\n再见。\n'));
144
+ }
145
+
146
+ /** 恢复会话时的提示:标题 + 轮数 + 上一条助手回复摘要,帮用户续上下文。 */
147
+ function echoResumed(session) {
148
+ const turns = turnCount(session.messages);
149
+ if (turns === 0) return;
150
+ process.stdout.write(c.gray(` 已恢复会话「${session.title || ''}」(${turns} 轮)`) + '\n');
151
+ const lastAssistant = [...session.messages].reverse().find((m) => m.role === 'assistant' && m.content);
152
+ if (lastAssistant) {
153
+ const text = String(lastAssistant.content).trim();
154
+ const snippet = text.length > 400 ? text.slice(0, 400) + '…' : text;
155
+ process.stdout.write(c.gray(' 上次回复:') + snippet.replace(/\n/g, '\n ') + '\n');
156
+ }
157
+ }
158
+
159
+ /**
160
+ * 一次性(非交互)模式:跑一个指令即退出。无 ask → 写/执行需 --yolo 才放行。
161
+ * persist=true(配合 --continue/--resume)时把本轮追加保存回会话,供后续续接。
162
+ * outputFormat='json' 时静默过程输出,最后打印一行结构化 JSON(脚本/CI 集成)。
163
+ */
164
+ export async function runOnce({ config, cwd, yolo, prompt, session, persist, outputFormat, maxTurns }) {
165
+ if (!prompt) {
166
+ process.stderr.write('用法: huicai -p "你的指令"\n');
167
+ process.exit(1);
168
+ }
169
+ const json = outputFormat === 'json';
170
+ const permissions = createPermissions({ yolo, ask: null, cwd });
171
+ const { extraTools, clients } = await buildExtraTools(config, cwd);
172
+ const { text: expanded } = expandAtMentions(prompt, cwd);
173
+ session.messages.push({ role: 'user', content: expanded });
174
+ if (!session.compaction) session.compaction = {};
175
+ config.sessionId = session.id; // 对话落库审计:会话号
176
+ const r = await runTurn({
177
+ config,
178
+ messages: session.messages,
179
+ permissions,
180
+ cwd,
181
+ extraTools,
182
+ maxIters: maxTurns,
183
+ compaction: session.compaction,
184
+ io: json ? { write: () => {} } : undefined,
185
+ });
186
+ if (r?.lastPromptTokens) session.lastPromptTokens = r.lastPromptTokens;
187
+ if (persist) saveSession(session);
188
+ closeMcp(clients);
189
+ if (json) {
190
+ const lastAssistant = [...session.messages].reverse().find((m) => m.role === 'assistant' && m.content);
191
+ process.stdout.write(JSON.stringify({
192
+ type: 'result',
193
+ is_error: !!r?.error,
194
+ ...(r?.error ? { error: r.error } : {}),
195
+ result: lastAssistant ? String(lastAssistant.content) : '',
196
+ usage: { input_tokens: r?.usage?.prompt ?? 0, output_tokens: r?.usage?.completion ?? 0 },
197
+ session_id: session.id,
198
+ model: config.model,
199
+ num_turns: turnCount(session.messages),
200
+ }) + '\n');
201
+ }
202
+ if (r?.error) process.exit(1);
203
+ }
package/src/session.js ADDED
@@ -0,0 +1,140 @@
1
+ // 会话持久化:把对话(含系统提示词 + 各轮消息)落盘到 ~/.huicai/sessions/<id>.json,
2
+ // 支持列出、恢复、删除。按项目目录(cwd)归属,使 --continue/--resume 只看当前项目的会话。
3
+ // 所有磁盘操作 best-effort:失败仅告警,绝不让保存/读取异常中断对话本身。
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { CONFIG_DIR } from './config.js';
8
+
9
+ export const SESSIONS_DIR = path.join(CONFIG_DIR, 'sessions');
10
+
11
+ function ensureDir() {
12
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
13
+ }
14
+
15
+ function fileOf(id) {
16
+ return path.join(SESSIONS_DIR, id + '.json');
17
+ }
18
+
19
+ function genId() {
20
+ return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6);
21
+ }
22
+
23
+ /** 由首条用户消息派生标题(截断)。 */
24
+ function deriveTitle(messages) {
25
+ const firstUser = (messages || []).find((m) => m.role === 'user');
26
+ if (!firstUser || !firstUser.content) return '(空会话)';
27
+ return String(firstUser.content).replace(/\s+/g, ' ').trim().slice(0, 60) || '(空会话)';
28
+ }
29
+
30
+ /** 会话中的用户轮数(对话往返次数的近似)。 */
31
+ export function turnCount(messages) {
32
+ return (messages || []).filter((m) => m.role === 'user').length;
33
+ }
34
+
35
+ /**
36
+ * 新建会话对象(内存态);messages 需已含 system 消息。落盘发生在首次 saveSession。
37
+ */
38
+ export function createSession({ cwd, model, messages }) {
39
+ const now = Date.now();
40
+ return {
41
+ id: genId(),
42
+ cwd,
43
+ model,
44
+ createdAt: now,
45
+ updatedAt: now,
46
+ title: '',
47
+ messages: messages || [],
48
+ // 非破坏式上下文压缩状态(摘要独立存储,messages 永远保留全量原文)
49
+ compaction: {},
50
+ };
51
+ }
52
+
53
+ /** 保存会话(覆盖写,0600)。best-effort。 */
54
+ export function saveSession(session) {
55
+ if (!session || !session.id) return;
56
+ try {
57
+ ensureDir();
58
+ session.updatedAt = Date.now();
59
+ if (!session.title) session.title = deriveTitle(session.messages);
60
+ fs.writeFileSync(fileOf(session.id), JSON.stringify(session), { mode: 0o600 });
61
+ } catch (e) {
62
+ process.stderr.write(`(会话保存失败,不影响对话:${e.message})\n`);
63
+ }
64
+ }
65
+
66
+ /** 读取会话;不存在或损坏返回 null。 */
67
+ export function loadSession(id) {
68
+ if (!id) return null;
69
+ try {
70
+ const j = JSON.parse(fs.readFileSync(fileOf(id), 'utf8'));
71
+ if (!j || !Array.isArray(j.messages)) return null;
72
+ if (!j.id) j.id = id;
73
+ if (!j.compaction || typeof j.compaction !== 'object') j.compaction = {}; // 旧会话兼容
74
+ return j;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 列出会话(按更新时间倒序)。cwd 非空时仅列该项目目录的会话。
82
+ * @returns {{id,title,cwd,model,updatedAt,turns}[]}
83
+ */
84
+ export function listSessions(cwd) {
85
+ let files;
86
+ try {
87
+ ensureDir();
88
+ files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json'));
89
+ } catch {
90
+ return [];
91
+ }
92
+ const out = [];
93
+ for (const f of files) {
94
+ try {
95
+ const j = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
96
+ if (cwd && j.cwd !== cwd) continue;
97
+ out.push({
98
+ id: j.id || f.replace(/\.json$/, ''),
99
+ title: j.title || deriveTitle(j.messages),
100
+ cwd: j.cwd,
101
+ model: j.model,
102
+ updatedAt: j.updatedAt || 0,
103
+ turns: turnCount(j.messages),
104
+ });
105
+ } catch {
106
+ // 跳过损坏文件
107
+ }
108
+ }
109
+ out.sort((a, b) => b.updatedAt - a.updatedAt);
110
+ return out;
111
+ }
112
+
113
+ /** 当前项目最近一次会话的 id;无则 null。 */
114
+ export function mostRecent(cwd) {
115
+ const list = listSessions(cwd);
116
+ return list.length ? list[0].id : null;
117
+ }
118
+
119
+ /** 删除会话文件。 */
120
+ export function deleteSession(id) {
121
+ try {
122
+ fs.unlinkSync(fileOf(id));
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /** 相对时间(如「3 分钟前」)。 */
130
+ export function relTime(ts) {
131
+ if (!ts) return '未知时间';
132
+ const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
133
+ if (s < 60) return s + ' 秒前';
134
+ const m = Math.floor(s / 60);
135
+ if (m < 60) return m + ' 分钟前';
136
+ const h = Math.floor(m / 60);
137
+ if (h < 24) return h + ' 小时前';
138
+ const d = Math.floor(h / 24);
139
+ return d + ' 天前';
140
+ }
Binary file
package/src/skills.js ADDED
@@ -0,0 +1,237 @@
1
+ // 本地技能(Agent Skills):发现/读取 <cwd>/.huicai/skills 与 ~/.huicai/skills 下的技能包
2
+ // (<技能名>/SKILL.md + references/scripts 等文本附件),并提供两个动态工具:
3
+ // load_skill —— 按需载入技能正文(渐进披露:系统提示词只常驻「名称:描述」目录)
4
+ // read_skill_file —— 按需读取技能附件
5
+ // 与平台 skills 表经 /v1/skills 双向同步(huicai skills pull/push),平台即云端技能库。
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+
11
+ /** 附件允许的文本扩展名(与平台 SkillFile.TEXT_EXTS 对齐;二进制不收) */
12
+ const TEXT_EXTS = new Set([
13
+ '.md', '.markdown', '.txt', '.json', '.yaml', '.yml', '.py', '.js', '.mjs', '.cjs', '.ts',
14
+ '.sh', '.sql', '.csv', '.html', '.css', '.xml', '.toml',
15
+ ]);
16
+ const MAX_FILE_BYTES = 256 * 1024; // 单附件读取上限
17
+ const MAX_FILES = 50; // 单技能附件数上限
18
+ const MAX_BODY_CHARS = 64 * 1024; // 载入正文回填上限
19
+
20
+ /** 解析 SKILL.md 的 YAML frontmatter(零依赖轻量解析:仅顶层 key: value 的 name/description/version)。 */
21
+ export function parseFrontmatter(raw) {
22
+ const out = { name: '', description: '', version: '', body: (raw || '').trim() };
23
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(out.body);
24
+ if (!m) return out;
25
+ out.body = out.body.slice(m[0].length).trim();
26
+ for (const line of m[1].split(/\r?\n/)) {
27
+ const kv = /^(name|description|version)\s*:\s*(.*)$/.exec(line.trim());
28
+ if (!kv) continue;
29
+ out[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, '');
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /** 技能根目录(项目级优先于全局;不存在的目录由调用方容错)。 */
35
+ export function skillDirs(cwd) {
36
+ const dirs = [];
37
+ if (cwd) dirs.push(path.join(cwd, '.huicai', 'skills'));
38
+ dirs.push(path.join(os.homedir(), '.huicai', 'skills'));
39
+ return dirs;
40
+ }
41
+
42
+ /** 递归收集技能目录下 SKILL.md 之外的文本附件(相对路径,深度≤3,数量受限)。 */
43
+ function collectFiles(dir, base = '', depth = 0, out = []) {
44
+ if (depth > 3 || out.length >= MAX_FILES) return out;
45
+ let entries;
46
+ try {
47
+ entries = fs.readdirSync(dir, { withFileTypes: true });
48
+ } catch {
49
+ return out;
50
+ }
51
+ for (const e of entries) {
52
+ if (out.length >= MAX_FILES) break;
53
+ if (e.name.startsWith('.')) continue;
54
+ const rel = base ? `${base}/${e.name}` : e.name;
55
+ if (e.isDirectory()) {
56
+ collectFiles(path.join(dir, e.name), rel, depth + 1, out);
57
+ } else if (e.isFile() && rel !== 'SKILL.md' && TEXT_EXTS.has(path.extname(e.name).toLowerCase())) {
58
+ out.push(rel);
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * 发现本地技能(项目级同名覆盖全局)。
66
+ * @returns {{name:string,description:string,version:string,dir:string,scope:'project'|'global',files:string[]}[]}
67
+ */
68
+ export function loadLocalSkills(cwd) {
69
+ const byName = new Map();
70
+ const dirs = skillDirs(cwd);
71
+ dirs.forEach((base, i) => {
72
+ let entries;
73
+ try {
74
+ entries = fs.readdirSync(base, { withFileTypes: true });
75
+ } catch {
76
+ return;
77
+ }
78
+ for (const e of entries) {
79
+ if (!e.isDirectory()) continue;
80
+ const dir = path.join(base, e.name);
81
+ let raw;
82
+ try {
83
+ raw = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8');
84
+ } catch {
85
+ continue;
86
+ }
87
+ const fm = parseFrontmatter(raw);
88
+ const name = (fm.name || e.name).trim();
89
+ if (!name || byName.has(name)) continue; // 先扫项目级,同名时项目级生效
90
+ byName.set(name, {
91
+ name,
92
+ description: fm.description || '',
93
+ version: fm.version || '',
94
+ dir,
95
+ scope: i === 0 && dirs.length > 1 ? 'project' : 'global',
96
+ files: collectFiles(dir),
97
+ });
98
+ }
99
+ });
100
+ return [...byName.values()];
101
+ }
102
+
103
+ /** 读取技能正文(SKILL.md 全文);找不到返回 null。 */
104
+ export function readSkillBody(cwd, name) {
105
+ const skill = loadLocalSkills(cwd).find((s) => s.name === name);
106
+ if (!skill) return null;
107
+ try {
108
+ const raw = fs.readFileSync(path.join(skill.dir, 'SKILL.md'), 'utf8');
109
+ return { skill, raw: raw.length > MAX_BODY_CHARS ? raw.slice(0, MAX_BODY_CHARS) + '\n…(正文已截断)' : raw };
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /** 读取技能附件(限制在技能目录内,防路径穿越);返回文本或抛出可读错误。 */
116
+ export function readSkillFileContent(cwd, skillName, relPath) {
117
+ const skill = loadLocalSkills(cwd).find((s) => s.name === skillName);
118
+ if (!skill) throw new Error(`本地技能不存在:${skillName}`);
119
+ const cleaned = String(relPath || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
120
+ const abs = path.resolve(skill.dir, cleaned);
121
+ if (!abs.startsWith(path.resolve(skill.dir) + path.sep)) throw new Error(`路径越界:${relPath}`);
122
+ const stat = fs.statSync(abs);
123
+ if (stat.size > MAX_FILE_BYTES) throw new Error(`附件过大(>${MAX_FILE_BYTES / 1024}KB):${cleaned}`);
124
+ return fs.readFileSync(abs, 'utf8');
125
+ }
126
+
127
+ /** 技能目录文本(系统提示词注入);无技能返回空串。 */
128
+ export function skillCatalog(cwd) {
129
+ const skills = loadLocalSkills(cwd);
130
+ if (!skills.length) return '';
131
+ const lines = skills.map((s) => {
132
+ const desc = (s.description || '(无描述)').replace(/\s+/g, ' ').slice(0, 300);
133
+ return `- ${s.name}:${desc}`;
134
+ });
135
+ return '以下本地技能可用 load_skill(name) 载入其操作指引后遵循执行(描述含「何时使用」,据此判断是否载入;无关任务不要载入):\n'
136
+ + lines.join('\n');
137
+ }
138
+
139
+ /** 构造 load_skill / read_skill_file 动态工具(extraTools 形态:{spec,run,readOnly});无本地技能返回 []。 */
140
+ export function makeSkillTools(cwd) {
141
+ const skills = loadLocalSkills(cwd);
142
+ if (!skills.length) return [];
143
+ const tools = [
144
+ {
145
+ readOnly: true,
146
+ spec: {
147
+ type: 'function',
148
+ function: {
149
+ name: 'load_skill',
150
+ description: '载入一个本地技能的操作指引(系统提示词「可用技能」目录中的名称)。载入后严格遵循其流程执行。',
151
+ parameters: {
152
+ type: 'object',
153
+ properties: { name: { type: 'string', description: '技能名(见「可用技能」目录)' } },
154
+ required: ['name'],
155
+ },
156
+ },
157
+ },
158
+ run: async (args) => {
159
+ const name = String(args?.name || '').trim();
160
+ const r = readSkillBody(cwd, name);
161
+ if (!r) {
162
+ const names = loadLocalSkills(cwd).map((s) => s.name).join(', ');
163
+ return `本地技能不存在:${name}(可用:${names || '无'})`;
164
+ }
165
+ let text = r.raw;
166
+ if (r.skill.files.length) {
167
+ text += `\n\n---\n本技能附带 ${r.skill.files.length} 个资源文件,需要时用 read_skill_file(skill="${name}", path=...) 读取:\n`
168
+ + r.skill.files.map((f) => `- ${f}`).join('\n');
169
+ }
170
+ return text;
171
+ },
172
+ },
173
+ ];
174
+ if (skills.some((s) => s.files.length)) {
175
+ tools.push({
176
+ readOnly: true,
177
+ spec: {
178
+ type: 'function',
179
+ function: {
180
+ name: 'read_skill_file',
181
+ description: '读取本地技能附带的资源文件(先 load_skill 查看其文件清单,再按 path 读取)。',
182
+ parameters: {
183
+ type: 'object',
184
+ properties: {
185
+ skill: { type: 'string', description: '技能名' },
186
+ path: { type: 'string', description: '资源文件相对路径(技能载入结果中列出)' },
187
+ },
188
+ required: ['skill', 'path'],
189
+ },
190
+ },
191
+ },
192
+ run: async (args) => {
193
+ try {
194
+ const content = readSkillFileContent(cwd, String(args?.skill || '').trim(), args?.path);
195
+ return `文件 ${args?.path}:\n${content}`;
196
+ } catch (e) {
197
+ return `读取失败:${e.message}`;
198
+ }
199
+ },
200
+ });
201
+ }
202
+ return tools;
203
+ }
204
+
205
+ /** 把平台技能详情写入本地目录(pull 落盘):baseDir/<name>/SKILL.md + 附件。返回技能目录路径。 */
206
+ export function writeSkillToDir(baseDir, item) {
207
+ const name = String(item?.name || '').trim();
208
+ if (!name || !item?.content) throw new Error('技能数据不完整(缺 name 或 content)');
209
+ const dir = path.join(baseDir, name.replace(/[^a-zA-Z0-9_-]/g, '-'));
210
+ fs.mkdirSync(dir, { recursive: true });
211
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), item.content, 'utf8');
212
+ for (const f of item.files || []) {
213
+ const rel = String(f?.path || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
214
+ if (!rel || rel.includes('..')) continue; // 非法附件路径直接跳过
215
+ const abs = path.join(dir, rel);
216
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
217
+ fs.writeFileSync(abs, f.content ?? '', 'utf8');
218
+ }
219
+ return dir;
220
+ }
221
+
222
+ /** 读取一个本地技能目录为 push 载荷:{content, filename, files:[{path,content}]}。 */
223
+ export function readSkillDirForPush(dir) {
224
+ const skillMd = path.join(dir, 'SKILL.md');
225
+ const content = fs.readFileSync(skillMd, 'utf8');
226
+ const files = [];
227
+ for (const rel of collectFiles(dir)) {
228
+ try {
229
+ const abs = path.join(dir, rel);
230
+ if (fs.statSync(abs).size > MAX_FILE_BYTES) continue;
231
+ files.push({ path: rel, content: fs.readFileSync(abs, 'utf8') });
232
+ } catch {
233
+ /* 跳过读不了的附件 */
234
+ }
235
+ }
236
+ return { content, filename: path.basename(dir), files };
237
+ }
@@ -0,0 +1,81 @@
1
+ // 客户端子智能体(对应平台 dispatch_agent):
2
+ // 一个内置工具 dispatch_agent,把明确子任务交给「隔离上下文 + 只读工具」的子智能体自主完成,只回传结论。
3
+ // 让主线程上下文保持精简,适合并行/发散的调研、检索、分析类子任务。
4
+
5
+ import { runTurn } from './agent.js';
6
+ import { createPermissions } from './permissions.js';
7
+
8
+ const MAX_DEPTH = 2; // 顶层 depth=0;到达上限不再提供 dispatch_agent,防无限递归
9
+
10
+ /**
11
+ * 构造 dispatch_agent 工具。达到深度上限返回 null(不暴露委派)。
12
+ * @param {object} p
13
+ * @param {object} p.config 运行配置(闭包引用,读时取最新 model)
14
+ * @param {string} p.cwd 工作目录
15
+ * @param {{spec:object,run:Function,readOnly:boolean}[]} [p.extraTools] 可传给子智能体的外部工具(仅其只读子集会被采用,通常为 MCP 工具,不含 dispatch 自身)
16
+ * @param {number} p.depth 当前深度
17
+ */
18
+ export function makeSubagentTool({ config, cwd, extraTools = [], depth = 0 }) {
19
+ if (depth >= MAX_DEPTH) return null;
20
+
21
+ const spec = {
22
+ type: 'function',
23
+ function: {
24
+ name: 'dispatch_agent',
25
+ description:
26
+ '把一个明确、独立、自足的子任务委派给子智能体:它在隔离上下文中自主使用(只读)工具完成后只回传结论。'
27
+ + '适合可并行的调研/检索/分析类子任务,让主线程上下文保持精简。可多次调用以分解复杂任务。',
28
+ parameters: {
29
+ type: 'object',
30
+ properties: {
31
+ objective: { type: 'string', description: '要子智能体完成的单一、明确、自足的子任务' },
32
+ context: { type: 'string', description: '可选:完成该子任务所需的背景信息' },
33
+ },
34
+ required: ['objective'],
35
+ },
36
+ },
37
+ };
38
+
39
+ // 子智能体只用只读外部工具(如只读 MCP);内置读工具由 runTurn 默认提供,写/执行被其非交互权限拒绝
40
+ const readOnlyExtras = extraTools.filter((t) => t.readOnly);
41
+
42
+ const run = async (args) => {
43
+ const objective = String(args?.objective || '').trim();
44
+ if (!objective) return '未提供子任务目标 objective';
45
+
46
+ const messages = [
47
+ { role: 'system', content: subagentPrompt(cwd, args?.context) },
48
+ { role: 'user', content: objective },
49
+ ];
50
+ // 只读、非交互:读工具自动放行,写/执行被拒并把原因回灌(子智能体不产生副作用)
51
+ const permissions = createPermissions({ yolo: false, ask: null });
52
+
53
+ // 允许有限层级的再委派
54
+ const nested = makeSubagentTool({ config, cwd, extraTools: readOnlyExtras, depth: depth + 1 });
55
+ const subExtra = nested ? [...readOnlyExtras, nested] : readOnlyExtras;
56
+
57
+ const r = await runTurn({
58
+ config,
59
+ messages,
60
+ permissions,
61
+ cwd,
62
+ extraTools: subExtra,
63
+ depth: depth + 1,
64
+ io: { write: () => {} }, // 静默:子智能体过程不刷主终端
65
+ });
66
+ if (r?.error) return `子智能体执行失败:${r.error}`;
67
+ const last = [...messages].reverse().find((m) => m.role === 'assistant' && m.content);
68
+ return last ? last.content : '(子智能体未产出结论)';
69
+ };
70
+
71
+ return { readOnly: true, spec, run };
72
+ }
73
+
74
+ function subagentPrompt(cwd, context) {
75
+ let p =
76
+ '你是一个专注的子智能体,只负责完成主智能体委派的单一子任务。要求:聚焦目标;'
77
+ + '用可用的只读工具(读文件 / 搜索 / 列目录等)收集信息;直接输出结构清晰、可被主智能体直接复用的最终结论,'
78
+ + '不要寒暄,也不要复述任务本身。\n工作目录:' + cwd + '\n';
79
+ if (context && String(context).trim()) p += '\n## 主智能体提供的背景\n' + context + '\n';
80
+ return p;
81
+ }