@huicai/cli 2026.710.97949 → 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 +168 -15
- 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 -24
- 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/commands.js
CHANGED
|
@@ -7,11 +7,37 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import { execSync } from 'node:child_process';
|
|
10
|
-
import { saveConfig, DEFAULT_MODEL } from './config.js';
|
|
11
|
-
import { listModels, fetchUsage } from './api.js';
|
|
12
|
-
import { listSessions, relTime, turnCount } from './session.js';
|
|
13
|
-
import { compactNow } from './compaction.js';
|
|
14
|
-
import {
|
|
10
|
+
import { saveConfig, DEFAULT_MODEL, CONFIG_PATH } from './config.js';
|
|
11
|
+
import { listModels, fetchUsage, streamChat } from './api.js';
|
|
12
|
+
import { listSessions, relTime, turnCount, saveSession, createSession } from './session.js';
|
|
13
|
+
import { compactNow, buildModelMessages } from './compaction.js';
|
|
14
|
+
import { computeInsights, formatInsights } from './insights.js';
|
|
15
|
+
import { setColorMode, setAccent, getAccent, ACCENTS } from './colors.js';
|
|
16
|
+
import { detectVerifyCommand } from './autofix.js';
|
|
17
|
+
import { loadAllowRules, describeRules, parseRule, addProjectRule, removeProjectRule, GLOBAL_SETTINGS_PATH, projectSettingsPath } from './settings.js';
|
|
18
|
+
import { contextReport } from './context-report.js';
|
|
19
|
+
import { renderDiff } from './diffview.js';
|
|
20
|
+
import { loadHooks, formatHooks } from './hooks.js';
|
|
21
|
+
import { copyToClipboard } from './clipboard.js';
|
|
22
|
+
import { toMarkdown, recapConversation } from './transcript.js';
|
|
23
|
+
import { runDoctor } from './doctor.js';
|
|
24
|
+
import { TOOL_SPECS } from './tools.js';
|
|
25
|
+
import { makeTodoTool } from './todo.js';
|
|
26
|
+
import { makeMemoryTool } from './memory.js';
|
|
27
|
+
|
|
28
|
+
const PLATFORM_MODEL_RE = /^(agent|workflow|datahuman)-/;
|
|
29
|
+
|
|
30
|
+
// /add-dir:在系统提示词末尾维护「附加可访问目录」块(幂等:先剥离旧块再按 session.extraDirs 重建)。
|
|
31
|
+
const EXTRA_DIRS_MARK = '\n\n# 附加可访问目录(可用 read_file/list_files/grep 以绝对路径访问;写入仍限主目录)\n';
|
|
32
|
+
function applyExtraDirs(session) {
|
|
33
|
+
const sys = session.messages && session.messages[0];
|
|
34
|
+
if (!sys || sys.role !== 'system' || typeof sys.content !== 'string') return;
|
|
35
|
+
const idx = sys.content.indexOf(EXTRA_DIRS_MARK);
|
|
36
|
+
let content = idx >= 0 ? sys.content.slice(0, idx) : sys.content;
|
|
37
|
+
const dirs = session.extraDirs || [];
|
|
38
|
+
if (dirs.length) content += EXTRA_DIRS_MARK + dirs.map((d) => '- ' + d).join('\n') + '\n';
|
|
39
|
+
sys.content = content;
|
|
40
|
+
}
|
|
15
41
|
|
|
16
42
|
/** /init 的智能体式提示词:让模型实际调研项目后生成/完善 HUICAI.md(而非套静态模板)。 */
|
|
17
43
|
export const INIT_PROMPT =
|
|
@@ -152,10 +178,14 @@ const BUILTINS = [
|
|
|
152
178
|
},
|
|
153
179
|
},
|
|
154
180
|
{
|
|
155
|
-
name: 'cost', desc: '查看本次运行累计 token
|
|
181
|
+
name: 'cost', desc: '查看本次运行累计 token 用量(及预算进度)',
|
|
156
182
|
run(ctx) {
|
|
183
|
+
const used = ctx.usage.prompt + ctx.usage.completion;
|
|
184
|
+
const b = Number(ctx.config.budgetTokens) || 0;
|
|
157
185
|
ctx.print(`token 用量:输入 ${ctx.usage.prompt} / 输出 ${ctx.usage.completion}`
|
|
158
|
-
+ (ctx.session.lastPromptTokens ? ` · 当前上下文 ~${ctx.session.lastPromptTokens} tok` : '')
|
|
186
|
+
+ (ctx.session.lastPromptTokens ? ` · 当前上下文 ~${ctx.session.lastPromptTokens} tok` : '')
|
|
187
|
+
+ (b > 0 ? `\n预算:${used.toLocaleString()} / ${b.toLocaleString()} tokens(${Math.round((used / b) * 100)}%)`
|
|
188
|
+
+ (used >= b ? ' ⚠ 已超预算' : used >= b * 0.8 ? ' ⚠ 接近预算' : '') : ''));
|
|
159
189
|
},
|
|
160
190
|
},
|
|
161
191
|
{
|
|
@@ -181,7 +211,9 @@ const BUILTINS = [
|
|
|
181
211
|
`目录 ${ctx.cwd}${branch ? `(git:${branch})` : ''}`,
|
|
182
212
|
`会话 ${s.id} · ${turnCount(s.messages)} 轮${s.lastPromptTokens ? ` · 上下文 ~${s.lastPromptTokens} tok` : ''}`,
|
|
183
213
|
`token 输入 ${ctx.usage.prompt} / 输出 ${ctx.usage.completion}`,
|
|
184
|
-
`审批 ${ctx.permissions?.isYolo?.() ? '⚠ yolo 自动放行' : '写/执行需授权'}
|
|
214
|
+
`审批 ${ctx.permissions?.isYolo?.() ? '⚠ yolo 自动放行' : '写/执行需授权'}`
|
|
215
|
+
+ ` · 允许规则 ${rules.length} 条${ctx.config.planMode ? ' · 📋 计划模式(只读)' : ''}`
|
|
216
|
+
+ `${ctx.config.sandbox ? ' · 🧪 沙箱' : ''}${ctx.config.goal ? ' · 🎯 目标已设' : ''}`,
|
|
185
217
|
`MCP ${mcpNames.length ? mcpNames.join(', ') : '(未配置)'}`,
|
|
186
218
|
];
|
|
187
219
|
ctx.print(lines.join('\n'));
|
|
@@ -260,6 +292,929 @@ const BUILTINS = [
|
|
|
260
292
|
.join('\n'));
|
|
261
293
|
},
|
|
262
294
|
},
|
|
295
|
+
{
|
|
296
|
+
name: 'context', desc: '可视化当前上下文占用(各类别 token 估算)',
|
|
297
|
+
run(ctx) {
|
|
298
|
+
const platform = PLATFORM_MODEL_RE.test(String(ctx.config.model || ''));
|
|
299
|
+
let specs = [];
|
|
300
|
+
if (!platform) {
|
|
301
|
+
try {
|
|
302
|
+
const todo = makeTodoTool({ state: { list: [] }, emit() {} }).spec;
|
|
303
|
+
const mem = ctx.config.memoryEnabled !== false ? makeMemoryTool({ cwd: ctx.cwd, emit() {} }).spec : null;
|
|
304
|
+
specs = [...TOOL_SPECS, todo, ...(mem ? [mem] : []),
|
|
305
|
+
...((ctx.extraTools || []).map((t) => t.spec).filter(Boolean))];
|
|
306
|
+
} catch {
|
|
307
|
+
specs = TOOL_SPECS;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
ctx.print(contextReport({
|
|
311
|
+
messages: ctx.session.messages,
|
|
312
|
+
compaction: ctx.session.compaction || {},
|
|
313
|
+
specs,
|
|
314
|
+
lastPromptTokens: ctx.session.lastPromptTokens || 0,
|
|
315
|
+
autoCompactTokens: Number(ctx.config.autoCompactTokens) || 0,
|
|
316
|
+
platform,
|
|
317
|
+
}));
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: 'permissions', args: '[allow|deny <规则> | remove <规则>]', desc: '查看/增删工具权限规则(allow/deny)',
|
|
322
|
+
run(ctx, arg) {
|
|
323
|
+
const parts = String(arg || '').trim().split(/\s+/).filter(Boolean);
|
|
324
|
+
const sub = (parts.shift() || '').toLowerCase();
|
|
325
|
+
const rule = parts.join(' ').trim();
|
|
326
|
+
if (!sub) {
|
|
327
|
+
const d = describeRules(ctx.cwd);
|
|
328
|
+
const fmt = (arr) => (arr.length ? arr.map((r) => ' ' + r).join('\n') : ' (无)');
|
|
329
|
+
ctx.print(
|
|
330
|
+
'权限规则(deny 优先于 allow/yolo,对只读工具也生效)\n'
|
|
331
|
+
+ ' 全局 ~/.huicai/settings.json\n'
|
|
332
|
+
+ ' 允许:\n' + fmt(d.global.allow) + '\n 拒绝:\n' + fmt(d.global.deny) + '\n'
|
|
333
|
+
+ ' 项目 .huicai/settings.json\n'
|
|
334
|
+
+ ' 允许:\n' + fmt(d.project.allow) + '\n 拒绝:\n' + fmt(d.project.deny) + '\n'
|
|
335
|
+
+ ' 用法:/permissions allow <规则> · /permissions deny <规则> · /permissions remove <规则>\n'
|
|
336
|
+
+ ' 规则语法:`工具名` 或 `工具名(模式*)`,如 run_bash(git *)、edit_file、read_file(**/.env*)',
|
|
337
|
+
);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (sub === 'allow' || sub === 'deny') {
|
|
341
|
+
if (!rule || !parseRule(rule)) {
|
|
342
|
+
ctx.print('规则无效。示例:run_bash(git *)、edit_file、fetch_url(https://a.com*)');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const p = addProjectRule(ctx.cwd, sub, rule);
|
|
347
|
+
ctx.permissions?.reloadRules?.();
|
|
348
|
+
ctx.print(`已添加${sub === 'deny' ? '拒绝' : '允许'}规则:${rule} → ${p}`);
|
|
349
|
+
} catch (e) {
|
|
350
|
+
ctx.print('写入失败:' + e.message);
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
355
|
+
if (!rule) {
|
|
356
|
+
ctx.print('用法:/permissions remove <规则原文>(仅移除项目设置里的规则)');
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const r = removeProjectRule(ctx.cwd, rule);
|
|
360
|
+
ctx.permissions?.reloadRules?.();
|
|
361
|
+
ctx.print(r.removed ? `已移除规则:${rule}` : `未在项目设置中找到该规则:${rule}`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
ctx.print('未知子命令。用法:/permissions [allow|deny|remove] <规则>');
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: 'diff', desc: '查看未提交改动(git)与本会话智能体改过的文件',
|
|
369
|
+
run(ctx) {
|
|
370
|
+
ctx.print(renderDiff(ctx.cwd, ctx.session.messages));
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
name: 'pr', args: '[commit|address]', desc: '自动提交并开 PR(生成规范 commit→建分支→push→gh 开 PR);address=按 review 评论自动改',
|
|
375
|
+
async run(ctx, arg) {
|
|
376
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
377
|
+
if (a === 'address' || a === 'comments') {
|
|
378
|
+
return { prompt: '请处理当前分支/PR 的 review 评论:用 `gh pr view --comments`(无 PR 则先 `gh pr status`)读取评论,'
|
|
379
|
+
+ '逐条理解并修改代码解决,然后 git add/commit(规范信息)+ git push;完成后逐条说明如何处理的。若未装 gh 或无 PR,先告知。' };
|
|
380
|
+
}
|
|
381
|
+
const { runPrFlow } = await import('./gitflow.js');
|
|
382
|
+
try {
|
|
383
|
+
const r = await runPrFlow({ cwd: ctx.cwd, config: ctx.config, emit: (t) => ctx.print(t), push: a !== 'commit', openPr: a !== 'commit' });
|
|
384
|
+
if (!r.ok) {
|
|
385
|
+
ctx.print('✗ ' + r.error);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
ctx.print(r.pr ? '✅ 已开 PR:' + r.pr : '✅ 完成(分支 ' + r.branch + ')' + (r.note ? ':' + r.note : ''));
|
|
389
|
+
} catch (e) {
|
|
390
|
+
ctx.print('PR 流程出错:' + e.message);
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
name: 'plan', args: '[on|off|approve]', desc: '计划模式:只读调研出方案→/plan approve 批准并分步执行',
|
|
396
|
+
run(ctx, arg) {
|
|
397
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
398
|
+
if (a === 'approve' || a === 'go') {
|
|
399
|
+
ctx.config.planMode = false;
|
|
400
|
+
ctx.permissions?.setPlanMode?.(false);
|
|
401
|
+
ctx.print('计划已批准,开始分步执行。');
|
|
402
|
+
return { prompt: '计划已获批准。现在请按你刚才制定的计划(todo 列表)逐步执行:'
|
|
403
|
+
+ '每完成一步用 todo_write 更新状态并简要自检;遇到与计划不符及时调整并说明;'
|
|
404
|
+
+ '全部完成后总结改了什么、以及如何验证(可用 /verify 让我自动跑验证)。' };
|
|
405
|
+
}
|
|
406
|
+
const next = a === 'on' ? true : a === 'off' ? false : !ctx.config.planMode;
|
|
407
|
+
ctx.config.planMode = next;
|
|
408
|
+
ctx.permissions?.setPlanMode?.(next);
|
|
409
|
+
ctx.print(next
|
|
410
|
+
? '计划模式:开。只做只读调研并用 todo_write 产出分步方案;写/执行会被拦截。'
|
|
411
|
+
+ '\n 方案满意后用 /plan approve 批准并自动分步执行;或 /plan off 手动执行。'
|
|
412
|
+
: '计划模式:关。恢复正常执行(写/执行仍按权限审批)。');
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
name: 'rewind', args: '[N [chat|code|both]]', desc: '检查点回滚:无参列出;/rewind N 回退对话,加 code/both 一并回滚文件',
|
|
417
|
+
run(ctx, arg) {
|
|
418
|
+
const cps = ctx.checkpoints;
|
|
419
|
+
if (!cps || !cps.list.length) {
|
|
420
|
+
ctx.print('暂无检查点(每个用户回合前自动记录,发生对话后可用)。');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const parts = String(arg || '').trim().split(/\s+/).filter(Boolean);
|
|
424
|
+
if (!parts.length) {
|
|
425
|
+
const lines = ['检查点(回滚到某个回合之前)'];
|
|
426
|
+
for (const cp of cps.list) {
|
|
427
|
+
lines.push(` #${cp.id} ${relTime(cp.time)} ${cp.label}`
|
|
428
|
+
+ (cp.journal.length ? ` · 改动 ${cp.journal.length} 处` : ''));
|
|
429
|
+
}
|
|
430
|
+
lines.push(' 用法:/rewind N(仅回退对话,安全)· /rewind N code(回滚文件)· /rewind N both');
|
|
431
|
+
lines.push(' 提示:code/both 会覆盖当前文件,建议先 /diff 查看。');
|
|
432
|
+
ctx.print(lines.join('\n'));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const id = Number(parts[0]);
|
|
436
|
+
if (!Number.isFinite(id)) {
|
|
437
|
+
ctx.print('用法:/rewind N [chat|code|both]');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const mode = ['chat', 'code', 'both'].includes(parts[1]) ? parts[1] : 'chat';
|
|
441
|
+
const s = cps.restore(id, mode, { session: ctx.session, cwd: ctx.cwd });
|
|
442
|
+
if (!s.ok) {
|
|
443
|
+
ctx.print(s.error);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const bits = [];
|
|
447
|
+
if (s.conversation) bits.push('对话已回退到该回合之前');
|
|
448
|
+
if (s.filesRestored.length) bits.push(`恢复文件 ${s.filesRestored.length} 个`);
|
|
449
|
+
if (s.filesDeleted.length) bits.push(`删除新建文件 ${s.filesDeleted.length} 个`);
|
|
450
|
+
if (s.filesSkipped.length) bits.push(`跳过(过大) ${s.filesSkipped.length} 个`);
|
|
451
|
+
if (s.filesFailed.length) bits.push(`失败 ${s.filesFailed.length} 个`);
|
|
452
|
+
try {
|
|
453
|
+
saveSession(ctx.session);
|
|
454
|
+
} catch { /* best-effort */ }
|
|
455
|
+
ctx.print('已回滚到 #' + id + ':' + (bits.join(';') || '无变化')
|
|
456
|
+
+ (s.filesFailed.length ? '\n 失败:' + s.filesFailed.join(';') : ''));
|
|
457
|
+
if (s.conversation) return { action: 'refresh' };
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
name: 'hooks', desc: '查看已配置的工具事件钩子(Pre/PostToolUse/UserPromptSubmit/Stop)',
|
|
462
|
+
run(ctx) {
|
|
463
|
+
ctx.print(formatHooks(loadHooks(ctx.cwd)));
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
name: 'copy', args: '[N]', desc: '复制助手回复到剪贴板(默认最后一条;N=倒数第 N 条)',
|
|
468
|
+
async run(ctx, arg) {
|
|
469
|
+
const replies = ctx.session.messages.filter(
|
|
470
|
+
(m) => m.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
|
|
471
|
+
if (!replies.length) {
|
|
472
|
+
ctx.print('还没有可复制的助手回复。');
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const n = Math.max(1, parseInt(arg, 10) || 1);
|
|
476
|
+
const msg = replies[replies.length - n];
|
|
477
|
+
if (!msg) {
|
|
478
|
+
ctx.print(`只有 ${replies.length} 条助手回复。`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const r = await copyToClipboard(msg.content);
|
|
482
|
+
ctx.print(r.ok
|
|
483
|
+
? `已复制${n > 1 ? `倒数第 ${n} 条` : '最后一条'}回复到剪贴板(${msg.content.length} 字)`
|
|
484
|
+
: `复制失败(未找到 ${r.tool})。以下为该回复内容:\n\n${msg.content}`);
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: 'export', args: '[文件路径]', desc: '导出当前对话为 Markdown 文件',
|
|
489
|
+
run(ctx, arg) {
|
|
490
|
+
const md = toMarkdown(ctx.session);
|
|
491
|
+
const name = String(arg || '').trim()
|
|
492
|
+
|| `huicai-对话-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.md`;
|
|
493
|
+
const abs = path.isAbsolute(name) ? name : path.join(ctx.cwd, name);
|
|
494
|
+
try {
|
|
495
|
+
fs.writeFileSync(abs, md, 'utf8');
|
|
496
|
+
ctx.print('已导出对话到 ' + abs);
|
|
497
|
+
} catch (e) {
|
|
498
|
+
ctx.print('导出失败:' + e.message);
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
name: 'rename', args: '<新标题>', desc: '重命名当前会话',
|
|
504
|
+
run(ctx, arg) {
|
|
505
|
+
const t = String(arg || '').trim();
|
|
506
|
+
if (!t) {
|
|
507
|
+
ctx.print('当前标题:' + (ctx.session.title || '(未命名)') + '\n 用法:/rename <新标题>');
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
ctx.session.title = t.slice(0, 80);
|
|
511
|
+
try {
|
|
512
|
+
saveSession(ctx.session);
|
|
513
|
+
} catch { /* best-effort */ }
|
|
514
|
+
ctx.print('会话已重命名为:' + ctx.session.title);
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
name: 'recap', desc: '用一句话总结当前对话进展(直连原始模型,不写入对话)',
|
|
519
|
+
async run(ctx) {
|
|
520
|
+
if (!ctx.session.messages.some((m) => m.role !== 'system')) {
|
|
521
|
+
ctx.print('对话还没有内容。');
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
ctx.print('小结:' + await recapConversation(ctx.session.messages, ctx.config));
|
|
526
|
+
} catch (e) {
|
|
527
|
+
ctx.print('生成小结失败:' + e.message);
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
name: 'distill', desc: '从当前对话提炼项目知识,写入长期记忆(供后续会话自动加载)',
|
|
533
|
+
async run(ctx) {
|
|
534
|
+
if (ctx.config.memoryEnabled === false) {
|
|
535
|
+
ctx.print('长期记忆已关闭(config.memory=false)。');
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (!ctx.session.messages.some((m) => m.role !== 'system')) {
|
|
539
|
+
ctx.print('对话还没有内容。');
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const { distillMemory } = await import('./transcript.js');
|
|
543
|
+
const { loadMemory, appendMemory } = await import('./memory.js');
|
|
544
|
+
try {
|
|
545
|
+
const facts = await distillMemory(ctx.session.messages, ctx.config, loadMemory(ctx.cwd));
|
|
546
|
+
if (!facts.length) {
|
|
547
|
+
ctx.print('未提炼出值得长期记住的新知识。');
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
let n = 0;
|
|
551
|
+
for (const f of facts) if (appendMemory(ctx.cwd, f)) n++;
|
|
552
|
+
ctx.print(`已写入 ${n} 条项目知识到长期记忆:\n` + facts.map((f) => ' · ' + f).join('\n'));
|
|
553
|
+
} catch (e) {
|
|
554
|
+
ctx.print('提炼失败:' + e.message);
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
name: 'doctor', desc: '本机环境体检(登录/网络/模型/git/工作目录/钩子)',
|
|
560
|
+
async run(ctx) {
|
|
561
|
+
try {
|
|
562
|
+
ctx.print(await runDoctor(ctx));
|
|
563
|
+
} catch (e) {
|
|
564
|
+
ctx.print('体检失败:' + e.message);
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
name: 'config', desc: '查看配置文件路径与当前生效配置(密钥脱敏)',
|
|
570
|
+
run(ctx) {
|
|
571
|
+
const cfg = ctx.config;
|
|
572
|
+
const redact = (k) => (k ? k.slice(0, 4) + '…' + k.slice(-4) + `(${k.length}位)` : '(未设置)');
|
|
573
|
+
const ac = Number(cfg.autoCompactTokens);
|
|
574
|
+
ctx.print([
|
|
575
|
+
'配置',
|
|
576
|
+
' 文件 ' + CONFIG_PATH,
|
|
577
|
+
' 平台 ' + cfg.baseUrl,
|
|
578
|
+
' 密钥 ' + redact(cfg.apiKey),
|
|
579
|
+
' 模型 ' + cfg.model,
|
|
580
|
+
' 推理强度 ' + (cfg.reasoningEffort || 'off'),
|
|
581
|
+
' 自动压缩 ' + (ac < 0 ? '关闭' : ac > 0 ? ac.toLocaleString() + ' tokens' : '默认(80k)'),
|
|
582
|
+
' 记忆 ' + (cfg.memoryEnabled === false ? '关' : '开'),
|
|
583
|
+
' Markdown ' + (cfg.markdown === false ? '关' : '开'),
|
|
584
|
+
' 全局设置 ' + GLOBAL_SETTINGS_PATH,
|
|
585
|
+
' 项目设置 ' + projectSettingsPath(ctx.cwd),
|
|
586
|
+
' (多数项有对应斜杠命令直接改:/model /effort /autocompact /permissions …)',
|
|
587
|
+
].join('\n'));
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: 'effort', args: '[minimal|low|medium|high|off]', desc: '设置推理强度(reasoning_effort 透传;模型不支持则忽略)',
|
|
592
|
+
run(ctx, arg) {
|
|
593
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
594
|
+
if (!a) {
|
|
595
|
+
ctx.print('当前推理强度:' + (ctx.config.reasoningEffort || 'off')
|
|
596
|
+
+ '\n 用法:/effort minimal|low|medium|high|off');
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (!['minimal', 'low', 'medium', 'high', 'off'].includes(a)) {
|
|
600
|
+
ctx.print('取值:minimal | low | medium | high | off');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
ctx.config.reasoningEffort = a === 'off' ? '' : a;
|
|
604
|
+
saveConfig({ reasoningEffort: ctx.config.reasoningEffort });
|
|
605
|
+
ctx.print('推理强度:' + (ctx.config.reasoningEffort || 'off') + '(下一条消息起生效;模型不支持则被忽略)');
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
name: 'autocompact', args: '[N|off|status]', desc: '自动压缩上下文的触发阈值(token 数;off 关闭)',
|
|
610
|
+
run(ctx, arg) {
|
|
611
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
612
|
+
const ac = Number(ctx.config.autoCompactTokens) || 0;
|
|
613
|
+
if (!a || a === 'status') {
|
|
614
|
+
ctx.print('自动压缩:' + (ac < 0 ? '已关闭' : ac > 0 ? `阈值 ${ac.toLocaleString()} tokens` : '默认阈值 80,000 tokens')
|
|
615
|
+
+ '\n /autocompact <N> 设定 · /autocompact off 关闭 · /autocompact status 查看');
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (a === 'off') {
|
|
619
|
+
ctx.config.autoCompactTokens = -1;
|
|
620
|
+
saveConfig({ autoCompactTokens: -1 });
|
|
621
|
+
ctx.print('已关闭自动压缩(仍可 /compact 手动压缩)。');
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const n = parseInt(a, 10);
|
|
625
|
+
if (!Number.isFinite(n) || n < 1000) {
|
|
626
|
+
ctx.print('请给 ≥1000 的 token 阈值,或 off / status。');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
ctx.config.autoCompactTokens = n;
|
|
630
|
+
saveConfig({ autoCompactTokens: n });
|
|
631
|
+
ctx.print('自动压缩阈值设为 ' + n.toLocaleString() + ' tokens(字符阈值同步联动)。');
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
name: 'review', desc: '审查当前未提交改动(找 bug 与可简化处)',
|
|
636
|
+
run() {
|
|
637
|
+
return { prompt: '请审查当前工作区的未提交改动:先用 run_bash 跑 `git diff`(及 `git status`)查看改了什么,'
|
|
638
|
+
+ '聚焦正确性 bug、边界与错误处理、可复用/可简化处、安全隐患。若本地有 code-review 技能可 load_skill 参照其方法。'
|
|
639
|
+
+ '逐条给出:问题、定位(文件:行)、修改建议,按严重度排序;不确定的地方说明。' };
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
name: 'security-review', desc: '对当前改动做安全审查',
|
|
644
|
+
run() {
|
|
645
|
+
return { prompt: '请对当前工作区改动做安全审查:注入(SQL/命令/路径穿越)、越权、密钥/凭据泄漏、SSRF、不安全依赖、'
|
|
646
|
+
+ '反序列化、XSS 等。先 run_bash 跑 `git diff` 查看改动。若本地有 security-audit 技能可 load_skill 参照。'
|
|
647
|
+
+ '给出风险点、可利用场景、影响与修复建议,按严重度排序。' };
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: 'scan', args: '[fix]', desc: '安全/质量扫描(自动选可用的 npm audit/semgrep/trivy/gitleaks 等);fix=让智能体逐条修',
|
|
652
|
+
async run(ctx, arg) {
|
|
653
|
+
const { detectScanners, runScanners } = await import('./scan.js');
|
|
654
|
+
const scanners = detectScanners(ctx.cwd);
|
|
655
|
+
if (!scanners.length) {
|
|
656
|
+
ctx.print('未检测到可用扫描器。可安装 semgrep / trivy / gitleaks,或项目含 package.json(npm audit)等。');
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (String(arg || '').trim().toLowerCase() === 'fix') {
|
|
660
|
+
return { prompt: '请对本项目做安全/质量扫描并修复:依次运行并理解这些扫描器的输出——'
|
|
661
|
+
+ scanners.map((s) => '`' + s.command + '`(' + s.why + ')').join('、')
|
|
662
|
+
+ ';对发现的问题逐个修复(升级/更换依赖、修代码、移除硬编码密钥等),修完重跑对应扫描确认清零;最后汇总修了什么。' };
|
|
663
|
+
}
|
|
664
|
+
ctx.print('运行扫描器:' + scanners.map((s) => s.name).join(', ') + '(可能较慢)…');
|
|
665
|
+
const results = await runScanners(ctx.cwd, scanners);
|
|
666
|
+
for (const r of results) {
|
|
667
|
+
ctx.print(`\n── ${r.name}(${r.code === 0 ? '✅ 无高危' : '⚠ 有发现'})──`);
|
|
668
|
+
ctx.print(r.output.split('\n').slice(0, 20).join('\n'));
|
|
669
|
+
}
|
|
670
|
+
ctx.print('\n(/scan fix 让智能体逐条修复)');
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
name: 'cover', desc: '覆盖率驱动测试生成:跑覆盖率→定位未覆盖的改动→补测试→复测',
|
|
675
|
+
run() {
|
|
676
|
+
return { prompt: '请做覆盖率驱动的测试补全:'
|
|
677
|
+
+ '\n1) 跑测试覆盖率(按项目选:npm test -- --coverage / jest --coverage / pytest --cov / go test -cover / mvn test 等);'
|
|
678
|
+
+ '\n2) 重点看**最近改动**且覆盖率低或未覆盖的函数与分支;'
|
|
679
|
+
+ '\n3) 为这些薄弱处补写有意义的测试(覆盖正常/边界/错误路径),遵循项目既有测试风格与框架;'
|
|
680
|
+
+ '\n4) 重跑测试与覆盖率,确认通过且覆盖率提升;最后汇报补了哪些测试、覆盖率变化。' };
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
name: 'verify', args: '[验证命令]', desc: '修到通过:跑测试/构建,失败自动定位修复并重跑,直到通过或达上限',
|
|
685
|
+
run(ctx, arg) {
|
|
686
|
+
let command = String(arg || '').trim();
|
|
687
|
+
if (!command) {
|
|
688
|
+
command = detectVerifyCommand(ctx.cwd) || '';
|
|
689
|
+
if (!command) {
|
|
690
|
+
ctx.print('未能自动探测验证命令。用法:/verify <命令>(如 /verify npm test)');
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
ctx.print('自动探测到验证命令:' + command);
|
|
694
|
+
}
|
|
695
|
+
const maxIters = Math.min(10, Math.max(1, Number(ctx.config.verifyMaxIters) || 5));
|
|
696
|
+
return { action: 'autofix', command, maxIters };
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
name: 'simplify', desc: '审查改动的可复用/简化/抽象层级并直接清理(只清理不找 bug)',
|
|
701
|
+
run() {
|
|
702
|
+
return { prompt: '请只做质量清理(不找 bug):审查最近改动的复用(是否重复造轮子、可否复用现有工具/函数)、简化(是否有更直接的写法)、'
|
|
703
|
+
+ '效率、以及抽象层级是否合适;把确定的改进直接用 edit_file 应用,保持与项目既有风格一致,改后跑测试/构建自检。'
|
|
704
|
+
+ '若本地有 refactor-safely 技能可 load_skill 参照。最后说明改了什么、为什么。' };
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
name: 'insights', desc: '本机会话使用洞察(数量/轮数/活跃度/按项目·模型分布)',
|
|
709
|
+
run(ctx) {
|
|
710
|
+
ctx.print(formatInsights(computeInsights(listSessions(null), { cwd: ctx.cwd })));
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
name: 'goal', args: '[目标 | clear]', desc: '设定目标(每轮注入,模型停止前自检是否达成;clear 清除)',
|
|
715
|
+
run(ctx, arg) {
|
|
716
|
+
const a = String(arg || '').trim();
|
|
717
|
+
if (!a) {
|
|
718
|
+
ctx.print(ctx.config.goal
|
|
719
|
+
? '当前目标:' + ctx.config.goal + '\n /goal <新目标> 修改 · /goal clear 清除'
|
|
720
|
+
: '未设定目标。用法:/goal <目标>(模型会在停止前自检是否达成)');
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (a === 'clear') {
|
|
724
|
+
ctx.config.goal = '';
|
|
725
|
+
ctx.print('已清除目标。');
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
ctx.config.goal = a;
|
|
729
|
+
ctx.print('已设定目标:' + a + '(每轮注入,模型将在停止前自检是否达成)');
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
name: 'btw', args: '<问题>', desc: '问一个旁支问题(带当前上下文,但不写入对话、不调用工具)',
|
|
734
|
+
async run(ctx, arg) {
|
|
735
|
+
const q = String(arg || '').trim();
|
|
736
|
+
if (!q) {
|
|
737
|
+
ctx.print('用法:/btw <问题>(快速旁支问答,不影响主对话)');
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const model = PLATFORM_MODEL_RE.test(String(ctx.config.model || ''))
|
|
741
|
+
? (ctx.config.compactModel || DEFAULT_MODEL) : ctx.config.model;
|
|
742
|
+
try {
|
|
743
|
+
const view = buildModelMessages(ctx.session.messages, ctx.session.compaction || {});
|
|
744
|
+
const r = await streamChat({
|
|
745
|
+
baseUrl: ctx.config.baseUrl,
|
|
746
|
+
apiKey: ctx.config.apiKey,
|
|
747
|
+
model,
|
|
748
|
+
messages: [...view, { role: 'user', content: '(这是一个旁支问题,请结合上文直接简答,不要调用工具、不要展开无关内容)\n' + q }],
|
|
749
|
+
});
|
|
750
|
+
ctx.print('↪ ' + ((r.content || '(无回复)').trim()));
|
|
751
|
+
} catch (e) {
|
|
752
|
+
ctx.print('旁支问答失败:' + e.message);
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
name: 'ide', desc: '查看 IDE 集成状态(编辑器上下文与内联审阅)',
|
|
758
|
+
run(ctx) {
|
|
759
|
+
const active = !!process.env.HUICAI_IDE_PORT;
|
|
760
|
+
const file = process.env.HUICAI_IDE_FILE;
|
|
761
|
+
const sel = process.env.HUICAI_IDE_SELECTION;
|
|
762
|
+
const diag = process.env.HUICAI_IDE_DIAGNOSTICS;
|
|
763
|
+
const lines = ['IDE 集成'];
|
|
764
|
+
lines.push(' 内联审阅 ' + (active ? '已连接(write/edit 交编辑器做 diff 审阅)' : '未连接(CLI 直接写盘)'));
|
|
765
|
+
lines.push(' 客户端 ' + (ctx.config.client || 'cli'));
|
|
766
|
+
lines.push(' 当前文件 ' + (file || '(无)'));
|
|
767
|
+
lines.push(' 选区 ' + (sel ? sel.length + ' 字' : '(无)'));
|
|
768
|
+
lines.push(' 诊断 ' + (diag ? '有(来自 Problems 面板)' : '(无)'));
|
|
769
|
+
if (!active) lines.push(' (在 VS Code/IDEA 的 huicai 面板或其启动的终端里运行可启用内联审阅与编辑器上下文)');
|
|
770
|
+
ctx.print(lines.join('\n'));
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
name: 'branch', args: '[标题]', desc: '把当前对话另存为一个可 --resume 的分支副本(当前会话继续)',
|
|
775
|
+
run(ctx, arg) {
|
|
776
|
+
const src = ctx.session;
|
|
777
|
+
if (!src.messages.some((m) => m.role !== 'system')) {
|
|
778
|
+
ctx.print('当前对话为空,无需分支。');
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const copy = createSession({ cwd: ctx.cwd, model: src.model, messages: src.messages.map((m) => ({ ...m })) });
|
|
782
|
+
try {
|
|
783
|
+
copy.compaction = JSON.parse(JSON.stringify(src.compaction || {}));
|
|
784
|
+
} catch {
|
|
785
|
+
copy.compaction = {};
|
|
786
|
+
}
|
|
787
|
+
copy.title = ((String(arg || '').trim() || src.title || '对话').slice(0, 70)) + '(分支)';
|
|
788
|
+
copy.lastPromptTokens = src.lastPromptTokens || 0;
|
|
789
|
+
try {
|
|
790
|
+
saveSession(copy);
|
|
791
|
+
saveSession(src);
|
|
792
|
+
} catch { /* best-effort */ }
|
|
793
|
+
ctx.print(`已保存分支副本「${copy.title}」(${copy.id})。\n 当前会话继续;分支可用 huicai --resume 恢复到此刻状态。`);
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: 'theme', args: '[default|mono]', desc: '输出主题:default 彩色 / mono 无色',
|
|
798
|
+
run(ctx, arg) {
|
|
799
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
800
|
+
if (!a) {
|
|
801
|
+
ctx.print('当前主题:' + (ctx.config.theme || 'default')
|
|
802
|
+
+ '\n /theme default | mono(终端配色由终端掌控;mono=关闭颜色,便于复制/管道)');
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (!['default', 'mono'].includes(a)) {
|
|
806
|
+
ctx.print('取值:default | mono');
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
ctx.config.theme = a;
|
|
810
|
+
setColorMode(a);
|
|
811
|
+
saveConfig({ theme: a });
|
|
812
|
+
ctx.print('主题:' + a + (a === 'mono' ? '(已关闭颜色)' : '(彩色)'));
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
name: 'color', args: '[' + ACCENTS.join('|') + ']', desc: '设置强调色(提示符/高亮)',
|
|
817
|
+
run(ctx, arg) {
|
|
818
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
819
|
+
if (!a) {
|
|
820
|
+
ctx.print('当前强调色:' + getAccent() + '\n 可选:' + ACCENTS.join(' / '));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (!setAccent(a)) {
|
|
824
|
+
ctx.print('可选:' + ACCENTS.join(' / '));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
ctx.config.accentColor = a;
|
|
828
|
+
saveConfig({ accentColor: a });
|
|
829
|
+
ctx.print('强调色:' + a);
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
name: 'tui', args: '[auto|rich|plain]', desc: '交互渲染器偏好(下次启动生效)',
|
|
834
|
+
run(ctx, arg) {
|
|
835
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
836
|
+
if (!a) {
|
|
837
|
+
ctx.print('当前渲染器偏好:' + (ctx.config.tui || 'auto')
|
|
838
|
+
+ '\n auto(TTY 下富 TUI)| rich | plain(下次启动生效;本次可加 --tui/--plain 覆盖)');
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (!['auto', 'rich', 'plain'].includes(a)) {
|
|
842
|
+
ctx.print('取值:auto | rich | plain');
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
ctx.config.tui = a;
|
|
846
|
+
saveConfig({ tui: a });
|
|
847
|
+
ctx.print('渲染器偏好:' + a + '(下次启动生效)');
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
name: 'advisor', args: '[模型id|off]', desc: '设置顾问模型(更强模型):智能体遇难题可 ask_advisor 咨询第二意见',
|
|
852
|
+
async run(ctx, arg) {
|
|
853
|
+
const a = String(arg || '').trim();
|
|
854
|
+
if (!a) {
|
|
855
|
+
ctx.print(ctx.config.advisorModel
|
|
856
|
+
? '当前顾问模型:' + ctx.config.advisorModel + '(智能体可用 ask_advisor 咨询)'
|
|
857
|
+
: '未设置顾问模型。用法:/advisor <模型id>(设一个更强的模型;/models 看可用)· /advisor off 关闭');
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const v = a === 'off' ? '' : a;
|
|
861
|
+
ctx.config.advisorModel = v;
|
|
862
|
+
saveConfig({ advisorModel: v });
|
|
863
|
+
// 即时生效:重建 ask_advisor 工具进 extraTools(无需重开会话)
|
|
864
|
+
if (Array.isArray(ctx.extraTools)) {
|
|
865
|
+
const i = ctx.extraTools.findIndex((t) => t.spec?.function?.name === 'ask_advisor');
|
|
866
|
+
if (i >= 0) ctx.extraTools.splice(i, 1);
|
|
867
|
+
if (v) {
|
|
868
|
+
const { makeAdvisorTool } = await import('./advisor.js');
|
|
869
|
+
const t = makeAdvisorTool({ config: ctx.config });
|
|
870
|
+
if (t) ctx.extraTools.push(t);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
ctx.print(v ? '顾问模型已设为 ' + v + '(ask_advisor 工具已可用)' : '已关闭顾问模型。');
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
{
|
|
877
|
+
name: 'automodel', args: '[强模型id|off]', desc: '按任务自动选模型:复杂回合自动升配到该强模型,其余用当前模型',
|
|
878
|
+
run(ctx, arg) {
|
|
879
|
+
const a = String(arg || '').trim();
|
|
880
|
+
if (!a) {
|
|
881
|
+
ctx.print(ctx.config.autoModelStrong
|
|
882
|
+
? '自动升配:开(复杂回合→' + ctx.config.autoModelStrong + ',其余→' + ctx.config.model + ')'
|
|
883
|
+
: '未开启。用法:/automodel <强模型id>(复杂回合自动用它;简单回合仍用当前便宜模型)· /models 看可用');
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const v = a.toLowerCase() === 'off' ? '' : a;
|
|
887
|
+
ctx.config.autoModelStrong = v;
|
|
888
|
+
saveConfig({ autoModelStrong: v });
|
|
889
|
+
ctx.print(v ? '按任务自动选模型:开(复杂回合→' + v + ',其余→' + ctx.config.model + ')' : '已关闭自动升配。');
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
name: 'embed', args: '[嵌入模型id|off]', desc: 'search_code 向量语义重排:设一个嵌入模型走 /v1/embeddings 重排(空/off=纯 BM25)',
|
|
894
|
+
run(ctx, arg) {
|
|
895
|
+
const a = String(arg || '').trim();
|
|
896
|
+
if (!a) {
|
|
897
|
+
ctx.print(ctx.config.embeddingModel
|
|
898
|
+
? 'search_code 向量重排:开(嵌入模型 ' + ctx.config.embeddingModel + ')'
|
|
899
|
+
: '未启用向量重排(纯 BM25,离线可用)。用法:/embed <嵌入模型id>(需平台有该嵌入模型)· /embed off');
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const v = a.toLowerCase() === 'off' ? '' : a;
|
|
903
|
+
ctx.config.embeddingModel = v;
|
|
904
|
+
saveConfig({ embeddingModel: v });
|
|
905
|
+
ctx.print(v
|
|
906
|
+
? 'search_code 向量语义重排:开(嵌入模型 ' + v + ';下次检索生效,BM25 召回后按向量相似度重排)'
|
|
907
|
+
: '已关闭向量重排(回到纯 BM25)。');
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
name: 'budget', args: '[token数|off|status]', desc: '设置本项目 token 预算(软提醒;/cost 看进度)',
|
|
912
|
+
run(ctx, arg) {
|
|
913
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
914
|
+
const used = (ctx.usage?.prompt || 0) + (ctx.usage?.completion || 0);
|
|
915
|
+
if (!a || a === 'status') {
|
|
916
|
+
const b = Number(ctx.config.budgetTokens) || 0;
|
|
917
|
+
ctx.print(b > 0
|
|
918
|
+
? `token 预算:${used.toLocaleString()} / ${b.toLocaleString()}(${Math.round((used / b) * 100)}%)`
|
|
919
|
+
: `未设预算(本次已用 ${used.toLocaleString()} tokens)。/budget <token数> 设定`);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (a === 'off') {
|
|
923
|
+
ctx.config.budgetTokens = 0;
|
|
924
|
+
saveConfig({ budgetTokens: 0 });
|
|
925
|
+
ctx.print('已取消预算。');
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const n = parseInt(a, 10);
|
|
929
|
+
if (!Number.isFinite(n) || n < 1000) {
|
|
930
|
+
ctx.print('请给 ≥1000 的 token 数,或 off / status。');
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
ctx.config.budgetTokens = n;
|
|
934
|
+
saveConfig({ budgetTokens: n });
|
|
935
|
+
ctx.print('token 预算设为 ' + n.toLocaleString() + '(软提醒;/cost 显示进度)。');
|
|
936
|
+
},
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
name: 'debug', args: '[on|off]', desc: '调试输出(每轮打印模型/耗时/token)',
|
|
940
|
+
run(ctx, arg) {
|
|
941
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
942
|
+
const next = a === 'on' ? true : a === 'off' ? false : !ctx.config.debug;
|
|
943
|
+
ctx.config.debug = next;
|
|
944
|
+
saveConfig({ debug: next });
|
|
945
|
+
ctx.print('调试输出:' + (next ? '开(下一条消息起每轮打印 [debug] 行)' : '关'));
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
name: 'sandbox', args: '[on|off]', desc: '沙箱模式:禁 shell 与联网(仍可读写本地文件)',
|
|
950
|
+
run(ctx, arg) {
|
|
951
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
952
|
+
const next = a === 'on' ? true : a === 'off' ? false : !ctx.config.sandbox;
|
|
953
|
+
ctx.config.sandbox = next;
|
|
954
|
+
ctx.permissions?.setSandbox?.(next);
|
|
955
|
+
ctx.print(next
|
|
956
|
+
? '沙箱模式:开。run_bash 与 fetch_url 将被拒绝;本地文件读写照常。'
|
|
957
|
+
: '沙箱模式:关(写/执行仍按权限审批)。');
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
{
|
|
961
|
+
name: 'reload-skills', desc: '从磁盘重新加载技能(新增/改名技能生效;改正文已实时生效)',
|
|
962
|
+
async run(ctx) {
|
|
963
|
+
const { makeSkillTools, loadLocalSkills } = await import('./skills.js');
|
|
964
|
+
if (Array.isArray(ctx.extraTools)) {
|
|
965
|
+
for (let i = ctx.extraTools.length - 1; i >= 0; i--) {
|
|
966
|
+
const n = ctx.extraTools[i]?.spec?.function?.name;
|
|
967
|
+
if (n === 'load_skill' || n === 'read_skill_file') ctx.extraTools.splice(i, 1);
|
|
968
|
+
}
|
|
969
|
+
ctx.extraTools.push(...makeSkillTools(ctx.cwd));
|
|
970
|
+
}
|
|
971
|
+
const skills = loadLocalSkills(ctx.cwd);
|
|
972
|
+
ctx.print(skills.length
|
|
973
|
+
? `已重载 ${skills.length} 个技能:${skills.map((s) => s.name).join(', ')}`
|
|
974
|
+
+ '\n (load_skill 现可载入其中任意一个;系统提示词内的技能目录将在下次新会话刷新)'
|
|
975
|
+
: '未发现本地技能(.huicai/skills 或 ~/.huicai/skills)。');
|
|
976
|
+
},
|
|
977
|
+
},
|
|
978
|
+
{
|
|
979
|
+
name: 'add-dir', args: '[目录 | list | remove <目录>]', desc: '添加可读/搜索的附加目录(写入仍限主目录)',
|
|
980
|
+
run(ctx, arg) {
|
|
981
|
+
const s = ctx.session;
|
|
982
|
+
if (!Array.isArray(s.extraDirs)) s.extraDirs = [];
|
|
983
|
+
const parts = String(arg || '').trim().split(/\s+/).filter(Boolean);
|
|
984
|
+
const sub = (parts[0] || '').toLowerCase();
|
|
985
|
+
if (!arg || sub === 'list') {
|
|
986
|
+
ctx.print(s.extraDirs.length
|
|
987
|
+
? '附加目录:\n' + s.extraDirs.map((d) => ' ' + d).join('\n')
|
|
988
|
+
+ '\n /add-dir <目录> 添加 · /add-dir remove <目录> 移除'
|
|
989
|
+
: '暂无附加目录。用法:/add-dir <目录>(可读/搜索,写仍限主目录)');
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
993
|
+
const d = path.resolve(ctx.cwd, parts.slice(1).join(' '));
|
|
994
|
+
const before = s.extraDirs.length;
|
|
995
|
+
s.extraDirs = s.extraDirs.filter((x) => x !== d);
|
|
996
|
+
applyExtraDirs(s);
|
|
997
|
+
try { saveSession(s); } catch { /* best-effort */ }
|
|
998
|
+
ctx.print(s.extraDirs.length < before ? '已移除:' + d : '未找到该附加目录:' + d);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
const d = path.resolve(ctx.cwd, String(arg).trim());
|
|
1002
|
+
let ok = false;
|
|
1003
|
+
try { ok = fs.statSync(d).isDirectory(); } catch { /* not a dir */ }
|
|
1004
|
+
if (!ok) {
|
|
1005
|
+
ctx.print('目录不存在:' + d);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (d === ctx.cwd) {
|
|
1009
|
+
ctx.print('这已是主工作目录。');
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (!s.extraDirs.includes(d)) s.extraDirs.push(d);
|
|
1013
|
+
applyExtraDirs(s);
|
|
1014
|
+
try { saveSession(s); } catch { /* best-effort */ }
|
|
1015
|
+
ctx.print('已添加可访问目录:' + d
|
|
1016
|
+
+ '\n (模型可用 read_file/list_files/grep 以绝对路径访问;写入仍限主目录)');
|
|
1017
|
+
},
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
name: 'agents', desc: '列出可用子智能体人格(.huicai/agents/*.md;dispatch_agent 可指定)',
|
|
1021
|
+
async run(ctx) {
|
|
1022
|
+
const { loadAgents } = await import('./subagent.js');
|
|
1023
|
+
const agents = loadAgents(ctx.cwd);
|
|
1024
|
+
ctx.print(agents.length
|
|
1025
|
+
? '子智能体人格(dispatch_agent 的 agent 参数可指定):\n'
|
|
1026
|
+
+ agents.map((a) => ` ${a.name}${a.description ? ' — ' + a.description.replace(/\s+/g, ' ').slice(0, 60) : ''}`).join('\n')
|
|
1027
|
+
: '未定义子智能体人格。\n 在 .huicai/agents/<名字>.md 写 frontmatter(name/description)+正文(人格提示词)即可;\n'
|
|
1028
|
+
+ ' dispatch_agent 默认用通用只读子智能体(无需定义也能用)。');
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
name: 'orchestrate', args: '<目标>', desc: '把一个目标自动分解为并行子任务、多智能体协作完成',
|
|
1033
|
+
run(ctx, arg) {
|
|
1034
|
+
const goal = String(arg || '').trim();
|
|
1035
|
+
if (!goal) {
|
|
1036
|
+
ctx.print('用法:/orchestrate <较大的目标>(我会分解为并行子任务,用 dispatch_agents 并行推进后综合)');
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
return { prompt: `目标:${goal}\n\n请作为编排者按以下方式完成:`
|
|
1040
|
+
+ '\n1) 先把目标分解为若干**独立、可并行**的子任务(研究/检索/分析/定位);'
|
|
1041
|
+
+ '\n2) 用 dispatch_agents 一次性并行委派这些子任务(每个自足,可指定合适的子智能体人格);'
|
|
1042
|
+
+ '\n3) 汇总各子智能体的结论,形成整体方案或结果;'
|
|
1043
|
+
+ '\n4) 若需落地改动,再据此逐步执行并自检(可用 /verify 让我自动跑验证)。'
|
|
1044
|
+
+ '\n优先用 dispatch_agents 并行,减少串行等待。' };
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
name: 'batch', args: '<大改动目标>', desc: '把一个大改动拆成独立子任务,在隔离 git worktree 中并行推进',
|
|
1049
|
+
run(ctx, arg) {
|
|
1050
|
+
const goal = String(arg || '').trim();
|
|
1051
|
+
if (!goal) {
|
|
1052
|
+
ctx.print('用法:/batch <一个可拆分的大改动目标>(拆成互不冲突的子任务,各在隔离 worktree 中落地)');
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
return { prompt: `目标(需拆分并行落地):${goal}\n\n请这样做:`
|
|
1056
|
+
+ '\n1) 先把目标拆成若干**相互独立、可并行**的改动子任务(尽量互不冲突的文件/模块);'
|
|
1057
|
+
+ '\n2) 对每个子任务,用 `git worktree add -b huicai-batch/<名> <路径>` 建隔离工作区,在其中完成该子任务的改动并 commit(不影响主工作区);'
|
|
1058
|
+
+ '\n3) 全部完成后,汇总每个 worktree 的分支与改动;需要时可对每个分支执行 `huicai pr` 开 PR。'
|
|
1059
|
+
+ '\n(并行调研可用 dispatch_agents;改动务必隔离在各自 worktree,避免互相踩踏。完成后可 `git worktree remove` 清理。)' };
|
|
1060
|
+
},
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
name: 'fork', args: '<后台任务提示词>', desc: '派生后台智能体(继承当前对话副本,--yolo 自动放行,输出写日志)',
|
|
1064
|
+
async run(ctx, arg) {
|
|
1065
|
+
const prompt = String(arg || '').trim();
|
|
1066
|
+
if (!prompt) {
|
|
1067
|
+
ctx.print('用法:/fork <让后台智能体做什么>(以当前对话副本为上下文,后台运行,--yolo 自动放行)');
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
// 先另存对话副本,避免后台进程与前台写同一会话文件
|
|
1071
|
+
const copy = createSession({ cwd: ctx.cwd, model: ctx.session.model, messages: ctx.session.messages.map((m) => ({ ...m })) });
|
|
1072
|
+
try { copy.compaction = JSON.parse(JSON.stringify(ctx.session.compaction || {})); } catch { copy.compaction = {}; }
|
|
1073
|
+
copy.title = '(fork) ' + (ctx.session.title || '');
|
|
1074
|
+
try { saveSession(copy); } catch { /* best-effort */ }
|
|
1075
|
+
const { forkAgent } = await import('./forks.js');
|
|
1076
|
+
let rec;
|
|
1077
|
+
try {
|
|
1078
|
+
rec = forkAgent({ prompt, cwd: ctx.cwd, sessionId: copy.id });
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
ctx.print('无法派生后台任务:' + e.message);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
ctx.print(`已派生后台智能体 pid ${rec.pid}(--yolo 自动放行)`
|
|
1084
|
+
+ `\n 上下文:当前对话副本 ${copy.id}\n 日志:${rec.log}\n /tasks 查看 · /tasks kill ${rec.pid} 终止`);
|
|
1085
|
+
},
|
|
1086
|
+
},
|
|
1087
|
+
{
|
|
1088
|
+
name: 'tasks', args: '[kill <pid>]', desc: '查看/终止本会话派生的后台任务(/fork)',
|
|
1089
|
+
async run(ctx, arg) {
|
|
1090
|
+
const { listForks, killFork } = await import('./forks.js');
|
|
1091
|
+
const parts = String(arg || '').trim().split(/\s+/).filter(Boolean);
|
|
1092
|
+
if (parts[0] === 'kill') {
|
|
1093
|
+
const pid = Number(parts[1]);
|
|
1094
|
+
if (!Number.isFinite(pid)) {
|
|
1095
|
+
ctx.print('用法:/tasks kill <pid>');
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const r = killFork(pid);
|
|
1099
|
+
ctx.print(r.ok ? '已终止 pid ' + pid : '终止失败:' + (r.reason || ''));
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
const list = listForks();
|
|
1103
|
+
if (!list.length) {
|
|
1104
|
+
ctx.print('本会话暂无后台任务(/fork <提示词> 派生)。');
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
ctx.print('后台任务:\n' + list.map((f) =>
|
|
1108
|
+
` pid ${f.pid} · ${f.alive ? '运行中' : '已结束'} · ${relTime(f.started)} · ${f.prompt}\n 日志 ${f.log}`,
|
|
1109
|
+
).join('\n') + '\n /tasks kill <pid> 终止');
|
|
1110
|
+
},
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
name: 'schedule', args: '[list | add "<cron>" "<提示词>" | remove <id>]', desc: '管理定时任务(生成 crontab 行交系统 cron 运行)',
|
|
1114
|
+
async run(ctx, arg) {
|
|
1115
|
+
const s = await import('./schedule.js');
|
|
1116
|
+
const raw = String(arg || '').trim();
|
|
1117
|
+
const bin = 'huicai';
|
|
1118
|
+
if (!raw || raw === 'list') {
|
|
1119
|
+
const list = s.listTasks();
|
|
1120
|
+
if (!list.length) {
|
|
1121
|
+
ctx.print('暂无定时任务。\n 添加:/schedule add "*/30 * * * *" "跑测试并把结果写入 report.md"'
|
|
1122
|
+
+ '\n (随后把打印的 crontab 行加入 crontab -e)');
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
ctx.print('定时任务:\n' + list.map((t) =>
|
|
1126
|
+
` #${t.id} ${t.cron} ${t.prompt.replace(/\s+/g, ' ').slice(0, 40)}\n crontab: ${s.crontabLine(t, bin)}`,
|
|
1127
|
+
).join('\n') + '\n /schedule remove <id> 删除');
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (/^add\b/.test(raw)) {
|
|
1131
|
+
const quoted = raw.match(/"([^"]*)"/g);
|
|
1132
|
+
if (!quoted || quoted.length < 2) {
|
|
1133
|
+
ctx.print('用法:/schedule add "<cron>" "<提示词>"(cron 与提示词都要加引号)');
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const cron = quoted[0].slice(1, -1).trim();
|
|
1137
|
+
const prompt = quoted[1].slice(1, -1).trim();
|
|
1138
|
+
if (cron.split(/\s+/).length !== 5) {
|
|
1139
|
+
ctx.print('cron 需 5 个字段,如 "*/30 * * * *"(分 时 日 月 周)');
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
const { id } = s.addTask({ cron, prompt, cwd: ctx.cwd });
|
|
1143
|
+
ctx.print('已添加定时任务 #' + id + '\n 加入 crontab(crontab -e):\n ' + s.crontabLine({ id, cron, prompt, cwd: ctx.cwd }, bin));
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (/^(remove|rm)\b/.test(raw)) {
|
|
1147
|
+
const id = raw.split(/\s+/)[1];
|
|
1148
|
+
ctx.print(s.removeTask(id) ? '已删除定时任务 ' + id : '未找到定时任务 ' + id);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
ctx.print('用法:/schedule list · /schedule add "<cron>" "<提示词>" · /schedule remove <id>');
|
|
1152
|
+
},
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
name: 'cd', args: '<目录>', desc: '切换工作目录(在新目录开启新会话;IDE 中请用「打开文件夹」)',
|
|
1156
|
+
run(ctx, arg) {
|
|
1157
|
+
const d = String(arg || '').trim();
|
|
1158
|
+
if (!d) {
|
|
1159
|
+
ctx.print('当前工作目录:' + ctx.cwd + '\n 用法:/cd <目录>(将在新目录开启新会话,旧会话已保存)');
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
const abs = path.resolve(ctx.cwd, d);
|
|
1163
|
+
let ok = false;
|
|
1164
|
+
try { ok = fs.statSync(abs).isDirectory(); } catch { /* not a dir */ }
|
|
1165
|
+
if (!ok) {
|
|
1166
|
+
ctx.print('目录不存在:' + abs);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
if (abs === ctx.cwd) {
|
|
1170
|
+
ctx.print('已在该目录。');
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
return { action: 'cd', cwd: abs };
|
|
1174
|
+
},
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
name: 'loop', args: '<秒> <提示词> | off', desc: '每隔 N 秒自动重跑一个提示词(off 停止)',
|
|
1178
|
+
run(ctx, arg) {
|
|
1179
|
+
const raw = String(arg || '').trim();
|
|
1180
|
+
if (!raw) {
|
|
1181
|
+
ctx.print('用法:/loop <秒> <提示词>(如 /loop 300 检查构建是否通过)· /loop off 停止');
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (raw === 'off') return { action: 'loop', stop: true };
|
|
1185
|
+
const m = /^(\d+)\s+([\s\S]+)$/.exec(raw);
|
|
1186
|
+
if (!m) {
|
|
1187
|
+
ctx.print('用法:/loop <秒> <提示词> · /loop off');
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
return { action: 'loop', seconds: Math.max(10, parseInt(m[1], 10)), prompt: m[2].trim() };
|
|
1191
|
+
},
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
name: 'statusline', args: '<shell命令> | off', desc: '自定义 TUI 底栏(运行该命令,展示其 stdout)',
|
|
1195
|
+
run(ctx, arg) {
|
|
1196
|
+
const raw = String(arg || '').trim();
|
|
1197
|
+
if (!raw) {
|
|
1198
|
+
ctx.print('当前状态栏命令:' + (ctx.config.statusLine || '(默认)')
|
|
1199
|
+
+ '\n /statusline <shell命令>(stdout 显示在 TUI 底栏)· /statusline off 恢复默认');
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
const v = raw === 'off' ? '' : raw;
|
|
1203
|
+
ctx.config.statusLine = v;
|
|
1204
|
+
saveConfig({ statusLine: v });
|
|
1205
|
+
ctx.print(v ? '状态栏命令已设置(TUI 底栏生效):' + v : '已恢复默认状态栏。');
|
|
1206
|
+
},
|
|
1207
|
+
},
|
|
1208
|
+
{
|
|
1209
|
+
name: 'background', desc: '保存当前会话并给出在别处继续/后台运行的方式',
|
|
1210
|
+
run(ctx) {
|
|
1211
|
+
try { saveSession(ctx.session); } catch { /* best-effort */ }
|
|
1212
|
+
ctx.print(`会话已保存(${ctx.session.id})。`
|
|
1213
|
+
+ `\n · 在别处继续:huicai resume ${ctx.session.id}`
|
|
1214
|
+
+ '\n · 后台跑一个任务:/fork <提示词>(派生后台智能体,日志可查)'
|
|
1215
|
+
+ '\n · 定时运行:/schedule add "<cron>" "<提示词>"');
|
|
1216
|
+
},
|
|
1217
|
+
},
|
|
263
1218
|
{
|
|
264
1219
|
name: 'exit', desc: '退出',
|
|
265
1220
|
run() {
|