@huicai/cli 2026.710.97950 → 2026.711.0
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/bin/huicai.js +93 -2
- package/package.json +2 -2
- package/src/advisor.js +46 -0
- package/src/agent.js +138 -5
- package/src/api.js +33 -0
- package/src/autofix.js +90 -0
- package/src/checkpoints.js +99 -0
- package/src/clipboard.js +34 -0
- package/src/codeindex.js +229 -0
- package/src/colors.js +28 -3
- package/src/commands.js +963 -8
- package/src/compaction.js +6 -2
- package/src/config.js +21 -1
- package/src/context-report.js +119 -0
- package/src/context.js +8 -0
- package/src/cron.js +64 -0
- package/src/daemon.js +62 -0
- package/src/diffview.js +115 -0
- package/src/doctor.js +72 -0
- package/src/forks.js +59 -0
- package/src/gitflow.js +140 -0
- package/src/hooks.js +207 -0
- package/src/insights.js +102 -0
- package/src/kbsync.js +141 -0
- package/src/memory.js +51 -0
- package/src/permissions.js +40 -2
- package/src/render.js +4 -0
- package/src/repl.js +69 -3
- package/src/scan.js +84 -0
- package/src/schedule.js +48 -0
- package/src/settings.js +0 -0
- package/src/subagent.js +126 -25
- package/src/tools.js +19 -4
- package/src/transcript.js +94 -0
- package/src/tui/app.js +103 -3
- package/src/watch.js +74 -0
package/src/permissions.js
CHANGED
|
@@ -4,16 +4,37 @@
|
|
|
4
4
|
|
|
5
5
|
import { c } from './colors.js';
|
|
6
6
|
import { toolLabel, renderEditDiff, indent } from './render.js';
|
|
7
|
-
import { loadAllowRules, matchAllowRules, suggestRule, addProjectAllowRule, riskWarning } from './settings.js';
|
|
7
|
+
import { loadAllowRules, loadDenyRules, matchAllowRules, suggestRule, addProjectAllowRule, riskWarning } from './settings.js';
|
|
8
|
+
|
|
9
|
+
// 计划模式下写/执行类工具被拒绝时的统一说明(REPL 与 TUI 共用同一文案)。
|
|
10
|
+
export const PLAN_DENY_REASON =
|
|
11
|
+
'当前处于计划模式:只做只读调研,不执行任何改动。请先产出完整方案交用户确认;'
|
|
12
|
+
+ '用户 /plan 关闭计划模式后再执行改动。';
|
|
13
|
+
|
|
14
|
+
// 沙箱模式下 run_bash / fetch_url 被拒绝时的统一说明。
|
|
15
|
+
export const SANDBOX_DENY_REASON =
|
|
16
|
+
'当前处于沙箱模式:禁止执行 shell 命令(run_bash)与联网(fetch_url)。'
|
|
17
|
+
+ '可继续读写本地文件;如需执行/联网,请用户 /sandbox off 关闭沙箱。';
|
|
8
18
|
|
|
9
19
|
export function createPermissions({ yolo = false, ask = null, cwd = null } = {}) {
|
|
10
20
|
const sessionAllowed = new Set();
|
|
11
21
|
let autoAll = !!yolo;
|
|
12
|
-
|
|
22
|
+
let planMode = false;
|
|
23
|
+
let sandbox = false;
|
|
24
|
+
// 仅在给定 cwd 时读取 settings 规则(测试等场景不受用户本机全局配置影响)
|
|
13
25
|
let rules = cwd ? loadAllowRules(cwd) : [];
|
|
26
|
+
let denyRules = cwd ? loadDenyRules(cwd) : [];
|
|
14
27
|
|
|
15
28
|
// readOnly 由调用方判定(内置工具查 READONLY_TOOLS;MCP 工具看 readOnlyHint;子智能体只读)
|
|
16
29
|
async function request(name, args, _cwd, readOnly = false) {
|
|
30
|
+
// 拒绝规则优先于一切(含只读;可用于挡住读取密钥文件等)
|
|
31
|
+
if (denyRules.length && matchAllowRules(denyRules, name, args)) {
|
|
32
|
+
return { allow: false, reason: '该操作被拒绝规则阻止(/permissions 查看或移除)。' };
|
|
33
|
+
}
|
|
34
|
+
// 计划模式:只放行只读,写/执行一律拦下并回灌原因(模型据此改为产出方案)
|
|
35
|
+
if (planMode && !readOnly) return { allow: false, reason: PLAN_DENY_REASON };
|
|
36
|
+
// 沙箱模式:禁 shell 与联网(仍可读写本地文件)
|
|
37
|
+
if (sandbox && (name === 'run_bash' || name === 'fetch_url')) return { allow: false, reason: SANDBOX_DENY_REASON };
|
|
17
38
|
if (readOnly) return { allow: true };
|
|
18
39
|
if (autoAll || sessionAllowed.has(name)) return { allow: true };
|
|
19
40
|
if (rules.length && matchAllowRules(rules, name, args)) return { allow: true };
|
|
@@ -59,6 +80,23 @@ export function createPermissions({ yolo = false, ask = null, cwd = null } = {})
|
|
|
59
80
|
isYolo() {
|
|
60
81
|
return autoAll;
|
|
61
82
|
},
|
|
83
|
+
setPlanMode(v) {
|
|
84
|
+
planMode = !!v;
|
|
85
|
+
},
|
|
86
|
+
isPlanMode() {
|
|
87
|
+
return planMode;
|
|
88
|
+
},
|
|
89
|
+
setSandbox(v) {
|
|
90
|
+
sandbox = !!v;
|
|
91
|
+
},
|
|
92
|
+
isSandbox() {
|
|
93
|
+
return sandbox;
|
|
94
|
+
},
|
|
95
|
+
/** 规则文件被 /permissions 修改后重新加载(本会话即时生效)。 */
|
|
96
|
+
reloadRules() {
|
|
97
|
+
rules = cwd ? loadAllowRules(cwd) : [];
|
|
98
|
+
denyRules = cwd ? loadDenyRules(cwd) : [];
|
|
99
|
+
},
|
|
62
100
|
};
|
|
63
101
|
}
|
|
64
102
|
|
package/src/render.js
CHANGED
|
@@ -13,6 +13,8 @@ export function toolLabel(name, args = {}) {
|
|
|
13
13
|
return `glob ${args.pattern ?? ''}`;
|
|
14
14
|
case 'grep':
|
|
15
15
|
return `grep ${JSON.stringify(args.pattern ?? '')}${args.path ? ' in ' + args.path : ''}`;
|
|
16
|
+
case 'search_code':
|
|
17
|
+
return `search_code ${JSON.stringify(args.query ?? '')}`;
|
|
16
18
|
case 'write_file':
|
|
17
19
|
return `write_file ${args.path ?? ''}`;
|
|
18
20
|
case 'edit_file':
|
|
@@ -27,6 +29,8 @@ export function toolLabel(name, args = {}) {
|
|
|
27
29
|
const o = String(args.objective ?? '').split('\n')[0];
|
|
28
30
|
return `dispatch_agent: ${o.length > 60 ? o.slice(0, 60) + '…' : o}`;
|
|
29
31
|
}
|
|
32
|
+
case 'dispatch_agents':
|
|
33
|
+
return `dispatch_agents(并行 ${Array.isArray(args.tasks) ? args.tasks.length : '?'} 个子任务)`;
|
|
30
34
|
default:
|
|
31
35
|
return `${name}(${Object.keys(args).join(', ')})`;
|
|
32
36
|
}
|
package/src/repl.js
CHANGED
|
@@ -6,9 +6,15 @@ import { c } from './colors.js';
|
|
|
6
6
|
import { createPermissions } from './permissions.js';
|
|
7
7
|
import { runTurn } from './agent.js';
|
|
8
8
|
import { createSession, saveSession, turnCount } from './session.js';
|
|
9
|
+
import { createCheckpoints } from './checkpoints.js';
|
|
10
|
+
import { buildSystemPrompt } from './context.js';
|
|
11
|
+
import { runVerifyLoop } from './autofix.js';
|
|
9
12
|
import { loadMcpTools, closeMcp } from './mcp.js';
|
|
10
|
-
import { makeSubagentTool } from './subagent.js';
|
|
13
|
+
import { makeSubagentTool, makeDispatchAgentsTool } from './subagent.js';
|
|
11
14
|
import { makeConsultTool } from './consult.js';
|
|
15
|
+
import { makeAdvisorTool } from './advisor.js';
|
|
16
|
+
import { makeRecallTool, readMemoryRaw } from './memory.js';
|
|
17
|
+
import { detectScanners, makeScanTool } from './scan.js';
|
|
12
18
|
import { runSlash } from './commands.js';
|
|
13
19
|
import { expandAtMentions, buildUserContent, runBangCommand, loadHistory, appendHistory } from './input.js';
|
|
14
20
|
import { makeSkillTools } from './skills.js';
|
|
@@ -26,9 +32,18 @@ export async function buildExtraTools(config, cwd) {
|
|
|
26
32
|
const extraTools = [...makeSkillTools(cwd), ...mcp.tools];
|
|
27
33
|
const dispatch = makeSubagentTool({ config, cwd, extraTools: mcp.tools, depth: 0 });
|
|
28
34
|
if (dispatch) extraTools.push(dispatch);
|
|
35
|
+
// 多智能体编排:并行批量子智能体(一次调用→N 并行→聚合)
|
|
36
|
+
const dispatchBatch = makeDispatchAgentsTool({ config, cwd, extraTools: mcp.tools, depth: 0 });
|
|
37
|
+
if (dispatchBatch) extraTools.push(dispatchBatch);
|
|
29
38
|
// 混合模式:配置了 --consult 平台伪模型时,暴露 consult_platform 工具(本机驱动 + 平台编排咨询)
|
|
30
39
|
const consult = makeConsultTool({ config });
|
|
31
40
|
if (consult) extraTools.push(consult);
|
|
41
|
+
// 会决策:设置了顾问模型 → ask_advisor;有长期记忆 → recall_memory(跨会话经验主动召回)
|
|
42
|
+
const advisor = makeAdvisorTool({ config });
|
|
43
|
+
if (advisor) extraTools.push(advisor);
|
|
44
|
+
if (config.memoryEnabled !== false && readMemoryRaw(cwd)) extraTools.push(makeRecallTool({ cwd }));
|
|
45
|
+
// 提质:检测到可用扫描器 → security_scan(真扫描,供安全审查/提交前自检)
|
|
46
|
+
if (detectScanners(cwd).length) extraTools.push(makeScanTool({ cwd }));
|
|
32
47
|
return { extraTools, clients: mcp.clients };
|
|
33
48
|
}
|
|
34
49
|
|
|
@@ -51,10 +66,12 @@ export async function startRepl({ config, cwd, yolo, initialPrompt, session, ver
|
|
|
51
66
|
historySize: 200,
|
|
52
67
|
});
|
|
53
68
|
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
54
|
-
|
|
69
|
+
let permissions = createPermissions({ yolo, ask, cwd }); // /cd 换目录时会重建
|
|
55
70
|
const usage = { prompt: 0, completion: 0 };
|
|
56
71
|
const todoState = { list: [] };
|
|
72
|
+
const checkpoints = createCheckpoints(); // /rewind 检查点(本会话内存态)
|
|
57
73
|
let currentAbort = null;
|
|
74
|
+
let loopTimer = null; // /loop 定时器
|
|
58
75
|
// ctx 持有可变的 session(/clear 会替换为新会话),各处统一读 ctx.session
|
|
59
76
|
const ctx = { config, cwd, permissions, usage, session, version };
|
|
60
77
|
|
|
@@ -65,12 +82,14 @@ export async function startRepl({ config, cwd, yolo, initialPrompt, session, ver
|
|
|
65
82
|
|
|
66
83
|
banner(config, cwd, permissions, version);
|
|
67
84
|
echoResumed(session);
|
|
68
|
-
|
|
85
|
+
let { extraTools, clients } = await buildExtraTools(config, cwd); // 连接 MCP(若已配置);/cd 换目录时会重建
|
|
69
86
|
|
|
70
87
|
const doTurn = async (text) => {
|
|
71
88
|
const { text: expanded, mentions, images } = await expandAtMentions(text, cwd, config);
|
|
72
89
|
if (mentions.length) process.stdout.write(c.gray(' · 已展开引用:' + mentions.join('、')
|
|
73
90
|
+ (images.length ? `(含 ${images.length} 张图片)` : '')) + '\n');
|
|
91
|
+
// 回合前记录检查点(对话位置 + 本回合文件原文经 fileJournal 累积),供 /rewind 回滚
|
|
92
|
+
const journal = checkpoints.snapshot({ session: ctx.session, label: text });
|
|
74
93
|
ctx.session.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
|
|
75
94
|
currentAbort = new AbortController();
|
|
76
95
|
if (!ctx.session.compaction) ctx.session.compaction = {};
|
|
@@ -84,6 +103,7 @@ export async function startRepl({ config, cwd, yolo, initialPrompt, session, ver
|
|
|
84
103
|
extraTools,
|
|
85
104
|
todoState,
|
|
86
105
|
compaction: ctx.session.compaction,
|
|
106
|
+
fileJournal: journal,
|
|
87
107
|
});
|
|
88
108
|
if (r?.usage) {
|
|
89
109
|
usage.prompt += r.usage.prompt;
|
|
@@ -92,12 +112,14 @@ export async function startRepl({ config, cwd, yolo, initialPrompt, session, ver
|
|
|
92
112
|
if (r?.lastPromptTokens) ctx.session.lastPromptTokens = r.lastPromptTokens;
|
|
93
113
|
currentAbort = null;
|
|
94
114
|
saveSession(ctx.session); // 每轮结束自动保存,可随时 --resume 恢复
|
|
115
|
+
return r;
|
|
95
116
|
};
|
|
96
117
|
|
|
97
118
|
const cmdCtx = () => ({
|
|
98
119
|
...ctx,
|
|
99
120
|
session: ctx.session,
|
|
100
121
|
extraTools,
|
|
122
|
+
checkpoints,
|
|
101
123
|
print: (t) => process.stdout.write(String(t).endsWith('\n') ? String(t) : String(t) + '\n'),
|
|
102
124
|
});
|
|
103
125
|
|
|
@@ -133,13 +155,57 @@ export async function startRepl({ config, cwd, yolo, initialPrompt, session, ver
|
|
|
133
155
|
if (r.action === 'clear') {
|
|
134
156
|
ctx.session = createSession({ cwd, model: config.model, messages: [ctx.session.messages[0]] });
|
|
135
157
|
todoState.list = [];
|
|
158
|
+
checkpoints.list.length = 0;
|
|
136
159
|
process.stdout.write(c.gray('已开启新会话(旧会话已保存,可用 huicai --resume 恢复)。\n'));
|
|
137
160
|
}
|
|
161
|
+
if (r.action === 'refresh') echoResumed(ctx.session); // /rewind 回退对话后重新回显,帮助对齐
|
|
162
|
+
if (r.action === 'cd') {
|
|
163
|
+
// 切换工作目录:重建权限/工具/会话(在新目录开启新会话,旧会话已保存)
|
|
164
|
+
cwd = r.cwd;
|
|
165
|
+
ctx.cwd = cwd;
|
|
166
|
+
permissions = createPermissions({ yolo: permissions.isYolo(), ask, cwd });
|
|
167
|
+
ctx.permissions = permissions;
|
|
168
|
+
closeMcp(clients);
|
|
169
|
+
({ extraTools, clients } = await buildExtraTools(config, cwd));
|
|
170
|
+
ctx.session = createSession({
|
|
171
|
+
cwd, model: config.model,
|
|
172
|
+
messages: [{ role: 'system', content: buildSystemPrompt(cwd, { memory: config.memoryEnabled !== false }) }],
|
|
173
|
+
});
|
|
174
|
+
todoState.list = [];
|
|
175
|
+
checkpoints.list.length = 0;
|
|
176
|
+
config.sessionId = ctx.session.id;
|
|
177
|
+
process.stdout.write(c.gray('已切换到 ' + cwd + '(新会话,旧会话已保存)。\n'));
|
|
178
|
+
}
|
|
179
|
+
if (r.action === 'autofix') {
|
|
180
|
+
// 修到通过闭环:跑验证命令,失败自动让智能体修复后重跑
|
|
181
|
+
await runVerifyLoop({
|
|
182
|
+
command: r.command,
|
|
183
|
+
maxIters: r.maxIters,
|
|
184
|
+
cwd,
|
|
185
|
+
runTurnOnce: (p) => doTurn(p),
|
|
186
|
+
emit: (t) => process.stdout.write(c.gray(t) + '\n'),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (r.action === 'loop') {
|
|
190
|
+
if (loopTimer) {
|
|
191
|
+
clearInterval(loopTimer);
|
|
192
|
+
loopTimer = null;
|
|
193
|
+
}
|
|
194
|
+
if (r.stop) {
|
|
195
|
+
process.stdout.write(c.gray('已停止循环。\n'));
|
|
196
|
+
} else {
|
|
197
|
+
process.stdout.write(c.gray(`已开始循环:每 ${r.seconds}s 运行「${r.prompt}」(/loop off 停止)。\n`));
|
|
198
|
+
loopTimer = setInterval(() => {
|
|
199
|
+
if (!currentAbort) void doTurn(r.prompt);
|
|
200
|
+
}, r.seconds * 1000);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
138
203
|
if (r.prompt) await doTurn(r.prompt);
|
|
139
204
|
continue;
|
|
140
205
|
}
|
|
141
206
|
await doTurn(input);
|
|
142
207
|
}
|
|
208
|
+
if (loopTimer) clearInterval(loopTimer);
|
|
143
209
|
rl.close();
|
|
144
210
|
closeMcp(clients);
|
|
145
211
|
process.stdout.write(c.gray('\n再见。\n'));
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// 真扫描工具集成(提质):自动检测并运行本项目适用、且本机已安装的安全/质量扫描器
|
|
2
|
+
// (依赖漏洞 / SAST / 密钥泄漏),把「发现」交给智能体去修——从「发提示词」变「真扫描 + 修复」。
|
|
3
|
+
// 暴露为 security_scan 工具(智能体可在安全审查/提交前自检时调用)+ /scan 命令。
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import { captureCommand } from './autofix.js';
|
|
9
|
+
|
|
10
|
+
function has(cwd, file) {
|
|
11
|
+
try {
|
|
12
|
+
return fs.existsSync(path.join(cwd, file));
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 命令是否可用(本机已安装)。 */
|
|
19
|
+
function cmdExists(cmd) {
|
|
20
|
+
try {
|
|
21
|
+
execSync(process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`, { stdio: 'ignore' });
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 检测本项目适用且已安装的扫描器。返回 [{name, command, why}]。 */
|
|
29
|
+
export function detectScanners(cwd) {
|
|
30
|
+
const s = [];
|
|
31
|
+
if (has(cwd, 'package.json') && cmdExists('npm')) {
|
|
32
|
+
s.push({ name: 'npm audit', command: 'npm audit --audit-level=high', why: 'Node 依赖漏洞' });
|
|
33
|
+
}
|
|
34
|
+
if ((has(cwd, 'requirements.txt') || has(cwd, 'pyproject.toml')) && cmdExists('pip-audit')) {
|
|
35
|
+
s.push({ name: 'pip-audit', command: 'pip-audit', why: 'Python 依赖漏洞' });
|
|
36
|
+
}
|
|
37
|
+
if (has(cwd, 'go.mod') && cmdExists('govulncheck')) {
|
|
38
|
+
s.push({ name: 'govulncheck', command: 'govulncheck ./...', why: 'Go 漏洞' });
|
|
39
|
+
}
|
|
40
|
+
if (has(cwd, 'Cargo.toml') && cmdExists('cargo-audit')) {
|
|
41
|
+
s.push({ name: 'cargo audit', command: 'cargo audit', why: 'Rust 依赖漏洞' });
|
|
42
|
+
}
|
|
43
|
+
if (cmdExists('semgrep')) {
|
|
44
|
+
s.push({ name: 'semgrep', command: 'semgrep --config auto --error -q .', why: '代码静态安全(SAST)' });
|
|
45
|
+
}
|
|
46
|
+
if (cmdExists('gitleaks')) {
|
|
47
|
+
s.push({ name: 'gitleaks', command: 'gitleaks detect --no-banner -r /dev/stdout', why: '密钥泄漏' });
|
|
48
|
+
}
|
|
49
|
+
if (cmdExists('trivy')) {
|
|
50
|
+
s.push({ name: 'trivy fs', command: 'trivy fs --scanners vuln,secret -q .', why: '依赖漏洞+密钥' });
|
|
51
|
+
}
|
|
52
|
+
return s;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 依次运行扫描器(各限时 3 分钟)。返回 [{name, why, code, output}](code!==0 视为有发现/高危)。 */
|
|
56
|
+
export async function runScanners(cwd, scanners) {
|
|
57
|
+
const results = [];
|
|
58
|
+
for (const sc of scanners) {
|
|
59
|
+
const { code, output } = await captureCommand(cwd, sc.command, 180000);
|
|
60
|
+
results.push({ name: sc.name, why: sc.why, command: sc.command, code, output });
|
|
61
|
+
}
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 构造 security_scan 工具(智能体可调用):自动检测+运行,返回发现。 */
|
|
66
|
+
export function makeScanTool({ cwd }) {
|
|
67
|
+
const spec = {
|
|
68
|
+
type: 'function',
|
|
69
|
+
function: {
|
|
70
|
+
name: 'security_scan',
|
|
71
|
+
description:
|
|
72
|
+
'自动检测并运行本项目适用的安全/质量扫描器(npm audit / semgrep / trivy / gitleaks / 依赖体检等,仅跑本机已装的),返回发现的问题。'
|
|
73
|
+
+ '用于安全审查或提交前自检;发现问题后你应逐条修复再重扫确认。',
|
|
74
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const run = async () => {
|
|
78
|
+
const scanners = detectScanners(cwd);
|
|
79
|
+
if (!scanners.length) return '未检测到可用的扫描器(可安装 semgrep/trivy/gitleaks,或项目含 package.json 等)。';
|
|
80
|
+
const results = await runScanners(cwd, scanners);
|
|
81
|
+
return results.map((r) => `## ${r.name}(${r.code === 0 ? '无高危' : '有发现'})\n${r.output.slice(0, 4000)}`).join('\n\n');
|
|
82
|
+
};
|
|
83
|
+
return { readOnly: true, spec, run };
|
|
84
|
+
}
|
package/src/schedule.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// /schedule:管理定时任务(持久化到 ~/.huicai/schedule.json),并生成交给系统 cron 运行的 crontab 行。
|
|
2
|
+
// 不自带调度器——把定时职责交给成熟的系统 cron;本模块负责登记 + 生成可粘贴的 crontab 条目。
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
|
|
8
|
+
export const SCHEDULE_FILE = path.join(os.homedir(), '.huicai', 'schedule.json');
|
|
9
|
+
|
|
10
|
+
function read() {
|
|
11
|
+
try {
|
|
12
|
+
const j = JSON.parse(fs.readFileSync(SCHEDULE_FILE, 'utf8'));
|
|
13
|
+
return Array.isArray(j) ? j : [];
|
|
14
|
+
} catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function write(list) {
|
|
20
|
+
fs.mkdirSync(path.dirname(SCHEDULE_FILE), { recursive: true });
|
|
21
|
+
fs.writeFileSync(SCHEDULE_FILE, JSON.stringify(list, null, 2) + '\n', { mode: 0o600 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function listTasks() {
|
|
25
|
+
return read();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 添加定时任务。@param {{cron:string, prompt:string, cwd:string}} t */
|
|
29
|
+
export function addTask({ cron, prompt, cwd }) {
|
|
30
|
+
const list = read();
|
|
31
|
+
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 4);
|
|
32
|
+
list.push({ id, cron, prompt, cwd, created: Date.now() });
|
|
33
|
+
write(list);
|
|
34
|
+
return { id };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function removeTask(id) {
|
|
38
|
+
const list = read();
|
|
39
|
+
const next = list.filter((t) => t.id !== id);
|
|
40
|
+
write(next);
|
|
41
|
+
return next.length < list.length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 生成一条 crontab 行(供 crontab -e 粘贴)。 */
|
|
45
|
+
export function crontabLine(t, bin = 'huicai') {
|
|
46
|
+
return `${t.cron} cd ${JSON.stringify(t.cwd)} && ${bin} -p ${JSON.stringify(t.prompt)} --yolo --plain `
|
|
47
|
+
+ `>> ${JSON.stringify(path.join(os.homedir(), '.huicai', 'schedule-' + t.id + '.log'))} 2>&1`;
|
|
48
|
+
}
|
package/src/settings.js
CHANGED
|
Binary file
|
package/src/subagent.js
CHANGED
|
@@ -2,11 +2,81 @@
|
|
|
2
2
|
// 一个内置工具 dispatch_agent,把明确子任务交给「隔离上下文 + 只读工具」的子智能体自主完成,只回传结论。
|
|
3
3
|
// 让主线程上下文保持精简,适合并行/发散的调研、检索、分析类子任务。
|
|
4
4
|
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
5
8
|
import { runTurn } from './agent.js';
|
|
6
9
|
import { createPermissions } from './permissions.js';
|
|
10
|
+
import { parseFrontmatter } from './skills.js';
|
|
7
11
|
|
|
8
12
|
const MAX_DEPTH = 2; // 顶层 depth=0;到达上限不再提供 dispatch_agent,防无限递归
|
|
9
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 加载已定义的子智能体人格:<cwd>/.huicai/agents/*.md 与 ~/.huicai/agents/*.md
|
|
16
|
+
* (frontmatter name/description + 正文=该子智能体的系统提示词)。项目级优先于全局同名。
|
|
17
|
+
* @returns {{name:string, description:string, prompt:string}[]}
|
|
18
|
+
*/
|
|
19
|
+
export function loadAgents(cwd) {
|
|
20
|
+
const dirs = [
|
|
21
|
+
cwd ? path.join(cwd, '.huicai', 'agents') : null,
|
|
22
|
+
path.join(os.homedir(), '.huicai', 'agents'),
|
|
23
|
+
].filter(Boolean);
|
|
24
|
+
const byName = new Map();
|
|
25
|
+
for (const dir of dirs) {
|
|
26
|
+
let files;
|
|
27
|
+
try {
|
|
28
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
|
|
29
|
+
} catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
for (const f of files) {
|
|
33
|
+
const base = f.replace(/\.md$/, '').trim();
|
|
34
|
+
if (!base || byName.has(base)) continue;
|
|
35
|
+
try {
|
|
36
|
+
const raw = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
37
|
+
const fm = parseFrontmatter(raw);
|
|
38
|
+
byName.set(base, { name: (fm.name || base).trim(), description: fm.description || '', prompt: fm.body || raw });
|
|
39
|
+
} catch {
|
|
40
|
+
/* 跳过坏文件 */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...byName.values()];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 有序有限并发 map(并发≤limit,结果按输入下标回填)。 */
|
|
48
|
+
async function mapLimit(items, limit, fn) {
|
|
49
|
+
const results = new Array(items.length);
|
|
50
|
+
let next = 0;
|
|
51
|
+
const worker = async () => {
|
|
52
|
+
while (next < items.length) {
|
|
53
|
+
const i = next++;
|
|
54
|
+
results[i] = await fn(items[i], i);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 跑一个子智能体子任务(隔离上下文 + 只读工具),返回结论文本。dispatch_agent 与 dispatch_agents 共用。 */
|
|
62
|
+
async function dispatchSubtask({ config, cwd, readOnlyExtras, depth, objective, context, agentDef }) {
|
|
63
|
+
if (!objective) return '未提供子任务目标 objective';
|
|
64
|
+
const sys = agentDef
|
|
65
|
+
? agentDef.prompt.trim() + '\n\n工作目录:' + cwd + (context && String(context).trim() ? '\n\n## 背景\n' + context : '')
|
|
66
|
+
: subagentPrompt(cwd, context);
|
|
67
|
+
const messages = [{ role: 'system', content: sys }, { role: 'user', content: objective }];
|
|
68
|
+
const permissions = createPermissions({ yolo: false, ask: null });
|
|
69
|
+
const nested = makeSubagentTool({ config, cwd, extraTools: readOnlyExtras, depth: depth + 1 });
|
|
70
|
+
const subExtra = nested ? [...readOnlyExtras, nested] : readOnlyExtras;
|
|
71
|
+
const r = await runTurn({
|
|
72
|
+
config, messages, permissions, cwd, extraTools: subExtra,
|
|
73
|
+
depth: depth + 1, maxIters: 50, io: { write: () => {} },
|
|
74
|
+
});
|
|
75
|
+
if (r?.error) return `子智能体执行失败:${r.error}`;
|
|
76
|
+
const last = [...messages].reverse().find((m) => m.role === 'assistant' && m.content);
|
|
77
|
+
return last ? last.content : '(子智能体未产出结论)';
|
|
78
|
+
}
|
|
79
|
+
|
|
10
80
|
/**
|
|
11
81
|
* 构造 dispatch_agent 工具。达到深度上限返回 null(不暴露委派)。
|
|
12
82
|
* @param {object} p
|
|
@@ -18,6 +88,7 @@ const MAX_DEPTH = 2; // 顶层 depth=0;到达上限不再提供 dispatch_agent
|
|
|
18
88
|
export function makeSubagentTool({ config, cwd, extraTools = [], depth = 0 }) {
|
|
19
89
|
if (depth >= MAX_DEPTH) return null;
|
|
20
90
|
|
|
91
|
+
const agents = loadAgents(cwd); // 已定义的子智能体人格(可选指定)
|
|
21
92
|
const spec = {
|
|
22
93
|
type: 'function',
|
|
23
94
|
function: {
|
|
@@ -30,6 +101,14 @@ export function makeSubagentTool({ config, cwd, extraTools = [], depth = 0 }) {
|
|
|
30
101
|
properties: {
|
|
31
102
|
objective: { type: 'string', description: '要子智能体完成的单一、明确、自足的子任务' },
|
|
32
103
|
context: { type: 'string', description: '可选:完成该子任务所需的背景信息' },
|
|
104
|
+
...(agents.length ? {
|
|
105
|
+
agent: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
enum: agents.map((a) => a.name),
|
|
108
|
+
description: '可选:使用某个已定义的子智能体人格 — '
|
|
109
|
+
+ agents.map((a) => a.name + (a.description ? `(${a.description.slice(0, 40)})` : '')).join(';'),
|
|
110
|
+
},
|
|
111
|
+
} : {}),
|
|
33
112
|
},
|
|
34
113
|
required: ['objective'],
|
|
35
114
|
},
|
|
@@ -40,35 +119,57 @@ export function makeSubagentTool({ config, cwd, extraTools = [], depth = 0 }) {
|
|
|
40
119
|
const readOnlyExtras = extraTools.filter((t) => t.readOnly);
|
|
41
120
|
|
|
42
121
|
const run = async (args) => {
|
|
43
|
-
const
|
|
44
|
-
|
|
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 });
|
|
122
|
+
const agentDef = args?.agent ? agents.find((a) => a.name === args.agent) : null;
|
|
123
|
+
return dispatchSubtask({ config, cwd, readOnlyExtras, depth, objective: String(args?.objective || '').trim(), context: args?.context, agentDef });
|
|
124
|
+
};
|
|
52
125
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const subExtra = nested ? [...readOnlyExtras, nested] : readOnlyExtras;
|
|
126
|
+
return { readOnly: true, spec, run };
|
|
127
|
+
}
|
|
56
128
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
129
|
+
/**
|
|
130
|
+
* 构造 dispatch_agents(并行批量)工具:一次调用把多个独立子任务并行委派给多个子智能体,聚合返回。
|
|
131
|
+
* 是多智能体编排的核心——把一个大目标拆成可并行子任务同时推进,显著提速。达深度上限返回 null。
|
|
132
|
+
*/
|
|
133
|
+
export function makeDispatchAgentsTool({ config, cwd, extraTools = [], depth = 0 }) {
|
|
134
|
+
if (depth >= MAX_DEPTH) return null;
|
|
135
|
+
const agents = loadAgents(cwd);
|
|
136
|
+
const readOnlyExtras = extraTools.filter((t) => t.readOnly);
|
|
137
|
+
const taskProps = {
|
|
138
|
+
objective: { type: 'string', description: '单一、明确、自足的子任务' },
|
|
139
|
+
context: { type: 'string', description: '可选:完成该子任务所需背景' },
|
|
140
|
+
...(agents.length ? { agent: { type: 'string', enum: agents.map((a) => a.name), description: '可选:指定子智能体人格' } } : {}),
|
|
141
|
+
};
|
|
142
|
+
const spec = {
|
|
143
|
+
type: 'function',
|
|
144
|
+
function: {
|
|
145
|
+
name: 'dispatch_agents',
|
|
146
|
+
description:
|
|
147
|
+
'一次性把多个**独立、可并行**的子任务同时委派给多个子智能体(隔离上下文、只读工具),并行执行后聚合各自结论。'
|
|
148
|
+
+ '适合把一个较大目标拆成 2-6 个可并行的调研/检索/分析子任务同时推进,显著减少串行等待。每个子任务需自足。',
|
|
149
|
+
parameters: {
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: {
|
|
152
|
+
tasks: {
|
|
153
|
+
type: 'array',
|
|
154
|
+
description: '并行子任务数组(建议 2-6 个,上限 8)',
|
|
155
|
+
items: { type: 'object', properties: taskProps, required: ['objective'] },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
required: ['tasks'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
const run = async (args) => {
|
|
163
|
+
const tasks = Array.isArray(args?.tasks) ? args.tasks.slice(0, 8) : [];
|
|
164
|
+
if (!tasks.length) return '未提供 tasks(需要一个子任务数组)';
|
|
165
|
+
const results = await mapLimit(tasks, 4, async (t) => {
|
|
166
|
+
const agentDef = t?.agent ? agents.find((a) => a.name === t.agent) : null;
|
|
167
|
+
const out = await dispatchSubtask({ config, cwd, readOnlyExtras, depth, objective: String(t?.objective || '').trim(), context: t?.context, agentDef });
|
|
168
|
+
return { objective: String(t?.objective || '').replace(/\s+/g, ' ').slice(0, 80), agent: t?.agent, out };
|
|
66
169
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return last ? last.content : '(子智能体未产出结论)';
|
|
170
|
+
return results.map((r, i) =>
|
|
171
|
+
`【子任务 ${i + 1}${r.agent ? ' · ' + r.agent : ''}】${r.objective}\n${r.out}`).join('\n\n---\n\n');
|
|
70
172
|
};
|
|
71
|
-
|
|
72
173
|
return { readOnly: true, spec, run };
|
|
73
174
|
}
|
|
74
175
|
|
package/src/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 本机工具的 OpenAI 函数规格(随请求透传给平台模型)+ 名称→执行器分发。
|
|
2
2
|
|
|
3
3
|
import * as ex from './executors.js';
|
|
4
|
+
import { searchCode } from './codeindex.js';
|
|
4
5
|
|
|
5
6
|
const obj = (properties, required) => ({ type: 'object', properties, required: required || [] });
|
|
6
7
|
const str = (description) => ({ type: 'string', description });
|
|
@@ -38,6 +39,19 @@ export const TOOL_SPECS = [
|
|
|
38
39
|
parameters: obj({ pattern: str('glob 模式') }, ['pattern']),
|
|
39
40
|
},
|
|
40
41
|
},
|
|
42
|
+
{
|
|
43
|
+
type: 'function',
|
|
44
|
+
function: {
|
|
45
|
+
name: 'search_code',
|
|
46
|
+
description:
|
|
47
|
+
'按「概念/功能」检索最相关的代码块(BM25 相关性排序,比 grep 更懂「哪段代码在做这件事」)。'
|
|
48
|
+
+ '适合「登录逻辑在哪」「哪里处理支付回调」这类找相关实现;要找精确字符串仍用 grep。返回 文件:行范围 + 代码块。',
|
|
49
|
+
parameters: obj(
|
|
50
|
+
{ query: str('要找的功能/概念(自然语言或关键词组合,多写几个相关词更准)'), k: int('可选:返回块数,默认 8,上限 20') },
|
|
51
|
+
['query'],
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
},
|
|
41
55
|
{
|
|
42
56
|
type: 'function',
|
|
43
57
|
function: {
|
|
@@ -107,25 +121,26 @@ export const TOOL_SPECS = [
|
|
|
107
121
|
];
|
|
108
122
|
|
|
109
123
|
/** 只读工具(默认自动放行,无需审批)。fetch_url 涉及外发请求,需用户授权。 */
|
|
110
|
-
export const READONLY_TOOLS = new Set(['read_file', 'list_files', 'glob', 'grep']);
|
|
124
|
+
export const READONLY_TOOLS = new Set(['read_file', 'list_files', 'glob', 'grep', 'search_code']);
|
|
111
125
|
|
|
112
126
|
const EXECUTORS = {
|
|
113
127
|
read_file: ex.readFile,
|
|
114
128
|
list_files: ex.listFiles,
|
|
115
129
|
glob: ex.glob,
|
|
116
130
|
grep: ex.grep,
|
|
131
|
+
search_code: searchCode,
|
|
117
132
|
write_file: ex.writeFile,
|
|
118
133
|
edit_file: ex.editFile,
|
|
119
134
|
run_bash: ex.runBash,
|
|
120
135
|
fetch_url: ex.fetchUrl,
|
|
121
136
|
};
|
|
122
137
|
|
|
123
|
-
/**
|
|
124
|
-
export async function runTool(name, args, cwd) {
|
|
138
|
+
/** 执行一个本机工具,返回文本结果(回填给模型)。config 传入供个别工具用(如 search_code 的向量重排)。抛错转为可读文本,保证循环不中断。 */
|
|
139
|
+
export async function runTool(name, args, cwd, config) {
|
|
125
140
|
const fn = EXECUTORS[name];
|
|
126
141
|
if (!fn) return `未知工具:${name}`;
|
|
127
142
|
try {
|
|
128
|
-
return await fn(cwd, args || {});
|
|
143
|
+
return await fn(cwd, args || {}, config);
|
|
129
144
|
} catch (e) {
|
|
130
145
|
return `工具执行失败:${e.message}`;
|
|
131
146
|
}
|