@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/bin/huicai.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import readline from 'node:readline';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
|
-
import { c } from '../src/colors.js';
|
|
7
|
+
import { c, setColorMode, setAccent } from '../src/colors.js';
|
|
8
8
|
import { loadConfig, saveConfig, CONFIG_PATH, DEFAULT_MODEL } from '../src/config.js';
|
|
9
9
|
import { listModels, getAgentPersona, fetchUsage, listPlatformSkills, getPlatformSkill, pushPlatformSkill } from '../src/api.js';
|
|
10
10
|
import { deviceLogin } from '../src/device.js';
|
|
@@ -129,6 +129,10 @@ ${c.bold('huicai')} — 终端智能体,以 HuiCai_AI 平台为模型后端,
|
|
|
129
129
|
huicai models 列出可用模型
|
|
130
130
|
huicai usage 查看 Token 用量窗口(5小时/7天,按会员等级)
|
|
131
131
|
huicai skills 列出本地 + 平台技能(Agent Skills)
|
|
132
|
+
huicai daemon 调度守护:按 cron 真正运行 /schedule 登记的定时任务
|
|
133
|
+
huicai watch ["*.js"] [-p] 文件监听:变更即触发一个智能体任务(如保存即跑测试)
|
|
134
|
+
huicai pr [commit] 自动提交并开 PR(生成规范 commit→建分支→push→gh 开 PR)
|
|
135
|
+
huicai kb-sync <id> [ref] 配合 git diff 增量同步知识库,保鲜 RAG(--dry 试运行)
|
|
132
136
|
huicai skills pull [name] 平台技能 → 本地(默认 ~/.huicai/skills;--project 放入项目)
|
|
133
137
|
huicai skills push <name> 本地技能 → 平台(默认同名跳过;--overwrite 覆盖)
|
|
134
138
|
huicai help 显示本帮助
|
|
@@ -441,6 +445,8 @@ async function main() {
|
|
|
441
445
|
if (flags.key) config.apiKey = flags.key;
|
|
442
446
|
if (flags.model) config.model = flags.model;
|
|
443
447
|
if (Number.isFinite(flags.maxTurns) && flags.maxTurns > 0) config.maxTurns = flags.maxTurns;
|
|
448
|
+
setColorMode(config.theme); // /theme:无色/彩色
|
|
449
|
+
setAccent(config.accentColor); // /color:强调色
|
|
444
450
|
const cwd = flags.cwd ? path.resolve(flags.cwd) : process.cwd();
|
|
445
451
|
|
|
446
452
|
// 会话列表:纯本地,无需鉴权
|
|
@@ -464,6 +470,89 @@ async function main() {
|
|
|
464
470
|
return;
|
|
465
471
|
}
|
|
466
472
|
|
|
473
|
+
if (sub === 'daemon') {
|
|
474
|
+
// 内置调度守护:按 cron 真正运行 /schedule 登记的任务(Ctrl-C 退出)
|
|
475
|
+
const { runDaemon } = await import('../src/daemon.js');
|
|
476
|
+
try {
|
|
477
|
+
await runDaemon({});
|
|
478
|
+
} catch (e) {
|
|
479
|
+
process.stderr.write(c.red(e.message) + '\n');
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (sub === 'watch') {
|
|
486
|
+
// 文件监听:变更即触发一个智能体任务。用法:huicai watch ["*.js"] [-p "跑测试"]
|
|
487
|
+
const { runWatch } = await import('../src/watch.js');
|
|
488
|
+
const pattern = positional[1] && !positional[1].startsWith('-') ? positional[1] : null;
|
|
489
|
+
const prompt = (flags.prompt || '').trim()
|
|
490
|
+
|| '刚有文件变更。请审查这些改动是否有问题(bug/风格/遗漏),必要时跑相关测试,简要汇报;无需大改。';
|
|
491
|
+
try {
|
|
492
|
+
await runWatch({ cwd, prompt, pattern });
|
|
493
|
+
} catch (e) {
|
|
494
|
+
process.stderr.write(c.red(e.message) + '\n');
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (sub === 'pr') {
|
|
501
|
+
// 自动提交并开 PR:生成规范 commit → 建分支 → push → gh 开 PR(`huicai pr commit` 仅提交不推送)
|
|
502
|
+
const authProblem = validateAuth(config);
|
|
503
|
+
if (authProblem) {
|
|
504
|
+
process.stderr.write(authProblem + '\n');
|
|
505
|
+
process.exit(1);
|
|
506
|
+
}
|
|
507
|
+
const { runPrFlow } = await import('../src/gitflow.js');
|
|
508
|
+
const commitOnly = positional[1] === 'commit';
|
|
509
|
+
const r = await runPrFlow({ cwd, config, emit: (t) => process.stdout.write(c.gray(t) + '\n'), push: !commitOnly, openPr: !commitOnly });
|
|
510
|
+
if (!r.ok) {
|
|
511
|
+
process.stderr.write(c.red('✗ ' + r.error) + '\n');
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
process.stdout.write(c.green('✅ ') + (r.pr ? 'PR: ' + r.pr : '分支 ' + r.branch + (r.note ? '(' + r.note + ')' : '')) + '\n');
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (sub === 'kb-sync') {
|
|
519
|
+
// 配合 git diff 增量同步知识库:huicai kb-sync <知识库ID> [ref] [--dry]
|
|
520
|
+
// 无 ref → 同步工作区相对 HEAD 的改动 + 未跟踪新文件
|
|
521
|
+
// 给 ref → 同步相对该提交/分支的改动(如 origin/main、HEAD~3、abc123;适合 CI)
|
|
522
|
+
const kbId = positional[1] && !positional[1].startsWith('-') ? positional[1] : null;
|
|
523
|
+
if (!kbId) {
|
|
524
|
+
process.stderr.write(c.red('用法:huicai kb-sync <知识库ID> [ref] [--dry]') + '\n'
|
|
525
|
+
+ c.gray(' 例:huicai kb-sync 12 # 同步本地未提交改动到知识库 12') + '\n'
|
|
526
|
+
+ c.gray(' huicai kb-sync 12 origin/main # 同步相对 origin/main 的改动(CI 常用)') + '\n');
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
const authProblem = validateAuth(config);
|
|
530
|
+
if (authProblem) {
|
|
531
|
+
process.stderr.write(authProblem + '\n');
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
534
|
+
const base = positional[2] && !positional[2].startsWith('-') ? positional[2] : null;
|
|
535
|
+
const dry = positional.includes('--dry') || positional.includes('-n');
|
|
536
|
+
const { runKbSync } = await import('../src/kbsync.js');
|
|
537
|
+
try {
|
|
538
|
+
const r = await runKbSync({ cwd, config, kbId, base, dry, emit: (t) => process.stdout.write(c.gray(t) + '\n') });
|
|
539
|
+
if (r.empty) {
|
|
540
|
+
process.stdout.write(c.gray(r.message) + '\n');
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (r.dry) {
|
|
544
|
+
for (const f of r.preview) process.stdout.write((f.deleted ? c.red(' − ') : c.green(' + ')) + f.path + '\n');
|
|
545
|
+
process.stdout.write(c.gray(`试运行:${r.added} 新增/修改,${r.removed} 删除${r.skipped ? `,跳过 ${r.skipped}` : ''}(加 --dry 之外的调用即实际同步)`) + '\n');
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
process.stdout.write(c.green('✅ 知识库已保鲜:') + r.message + '\n');
|
|
549
|
+
} catch (e) {
|
|
550
|
+
process.stderr.write(c.red('✗ kb-sync 失败:' + e.message) + '\n');
|
|
551
|
+
process.exit(1);
|
|
552
|
+
}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
467
556
|
const problem = validateAuth(config);
|
|
468
557
|
if (problem) {
|
|
469
558
|
process.stderr.write(problem + '\n');
|
|
@@ -567,7 +656,9 @@ async function main() {
|
|
|
567
656
|
}
|
|
568
657
|
|
|
569
658
|
// 交互式:默认富 TUI(ink,需已安装依赖 + TTY);--plain 强制普通 REPL;TUI 不可用则自动回退
|
|
570
|
-
|
|
659
|
+
// 偏好顺序:命令行 --tui/--plain > 配置 tui(rich/plain) > auto(TTY 下用 TUI)
|
|
660
|
+
const wantTui = flags.tui || config.tui === 'rich'
|
|
661
|
+
|| (!flags.plain && config.tui !== 'plain' && process.stdout.isTTY && process.stdin.isTTY);
|
|
571
662
|
if (wantTui) {
|
|
572
663
|
try {
|
|
573
664
|
const { runInk } = await import('../src/tui/app.js');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huicai/cli",
|
|
3
|
-
"version": "2026.
|
|
4
|
-
"versionDisplay": "2026.07.
|
|
3
|
+
"version": "2026.711.0",
|
|
4
|
+
"versionDisplay": "2026.07.11",
|
|
5
5
|
"description": "在终端运行的智能体 CLI:以 HuiCai_AI 平台的 OpenAI 兼容 /v1 为模型后端,在用户本机执行文件/命令工具。",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
package/src/advisor.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Advisor 升配(会决策):设置了「顾问模型」(更强的模型)后,暴露 ask_advisor 工具——
|
|
2
|
+
// 智能体遇到难题/复杂决策/反复卡住时,可主动向更强模型请教独立第二意见,再据此决策。
|
|
3
|
+
// 仅当 config.advisorModel 非空时构造(否则返回 null,不暴露该工具)。
|
|
4
|
+
|
|
5
|
+
import { streamChat } from './api.js';
|
|
6
|
+
|
|
7
|
+
export function makeAdvisorTool({ config }) {
|
|
8
|
+
if (!config?.advisorModel) return null;
|
|
9
|
+
const spec = {
|
|
10
|
+
type: 'function',
|
|
11
|
+
function: {
|
|
12
|
+
name: 'ask_advisor',
|
|
13
|
+
description:
|
|
14
|
+
'遇到难题、复杂决策或反复卡住时,向一个更强的「技术顾问」模型请教独立的第二意见。'
|
|
15
|
+
+ '把问题背景、已知信息、已尝试过什么、待决策点写清楚(自足),它会给出判断/方案与关键风险。'
|
|
16
|
+
+ '用于真正的难点或高风险决策,不要滥用。',
|
|
17
|
+
parameters: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
question: { type: 'string', description: '自足的难题描述(含背景、已尝试、待决策点)' },
|
|
21
|
+
},
|
|
22
|
+
required: ['question'],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const run = async (args) => {
|
|
27
|
+
const q = String(args?.question || '').trim();
|
|
28
|
+
if (!q) return '缺少 question';
|
|
29
|
+
try {
|
|
30
|
+
const r = await streamChat({
|
|
31
|
+
baseUrl: config.baseUrl,
|
|
32
|
+
apiKey: config.apiKey,
|
|
33
|
+
model: config.advisorModel,
|
|
34
|
+
messages: [
|
|
35
|
+
{ role: 'system', content: '你是资深技术顾问,正被一个编码智能体咨询一个难题。基于它提供的信息,给出清晰、具体、可执行的判断或方案:'
|
|
36
|
+
+ '指出关键风险、推荐做法、以及为何。简洁直接,不要寒暄。' },
|
|
37
|
+
{ role: 'user', content: q },
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
return `【顾问(${config.advisorModel})意见】\n` + ((r.content || '(顾问无回复)').trim());
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return '咨询顾问失败:' + e.message;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
return { readOnly: true, spec, run };
|
|
46
|
+
}
|
package/src/agent.js
CHANGED
|
@@ -26,10 +26,12 @@ import { toolLabel, indent, renderFileDiff } from './render.js';
|
|
|
26
26
|
import { compactIfNeeded, buildModelMessages } from './compaction.js';
|
|
27
27
|
import { makeTodoTool, formatTodos, TODO_TOOL_NAME } from './todo.js';
|
|
28
28
|
import { makeMemoryTool } from './memory.js';
|
|
29
|
+
import { loadHooks, runHooks } from './hooks.js';
|
|
29
30
|
import * as ide from './ide.js';
|
|
30
31
|
import { c } from './colors.js';
|
|
31
32
|
|
|
32
|
-
const DEFAULT_MAX_ITERS =
|
|
33
|
+
const DEFAULT_MAX_ITERS = 120; // 中大型项目需更多工具轮(读多文件/多次检索/多处编辑);仍可 config.maxTurns 或 --max-turns 覆盖
|
|
34
|
+
const TOOL_CONCURRENCY = 8; // 并行只读工具的并发上限:大仓库里模型一次触发大量 grep/glob 时限并发,降争用、提稳定与总吞吐
|
|
33
35
|
const MAX_TOOL_RESULT_CHARS = 20_000;
|
|
34
36
|
const CAP_HEAD = 16_000;
|
|
35
37
|
const CAP_TAIL = 3_000;
|
|
@@ -43,6 +45,57 @@ const MARKUP_RETRY_PROMPT =
|
|
|
43
45
|
const MARKUP_FORCE_ANSWER_PROMPT =
|
|
44
46
|
'请停止调用任何工具。基于目前已获得的全部信息,直接用中文输出面向用户的完整最终答复,'
|
|
45
47
|
+ '不要输出任何工具调用或协议标记。';
|
|
48
|
+
// 预算收敛:临近工具轮数上限时注入,禁用工具并要求直接作答,保证中大型项目里到达上限也能给出干净的最终答复(而非被硬截断)。
|
|
49
|
+
const BUDGET_FINAL_PROMPT =
|
|
50
|
+
'已接近本回合的工具调用轮数上限。请不要再调用任何工具,基于目前已获得的信息,直接用中文输出面向用户的完整最终答复。';
|
|
51
|
+
// 自我纠错:短时间内多次工具失败时注入,打破「反复用同样会失败的调用」的死循环。
|
|
52
|
+
const SELF_CORRECT_NOTE =
|
|
53
|
+
'你刚才连续多次工具调用失败。请停下来重新审视:是不是文件路径/内容/前提假设错了?'
|
|
54
|
+
+ '先用 read_file / grep / list_files 核实现状,再换一种更可靠的方法,绝不要重复同样会失败的调用。';
|
|
55
|
+
// 判断一条工具结果是否为「失败」(用于自我纠错计数)。
|
|
56
|
+
function looksLikeToolFailure(result) {
|
|
57
|
+
const s = String(result);
|
|
58
|
+
return s.startsWith('工具执行失败') || s.startsWith('命令启动失败')
|
|
59
|
+
|| s.includes('不是合法 JSON') || s.includes('未找到要替换的内容')
|
|
60
|
+
|| s.includes('路径超出项目目录') || s.includes('是目录而非文件');
|
|
61
|
+
}
|
|
62
|
+
// 计划模式(/plan 开启):每轮追加到系统提示词末尾,令模型只读调研并产出方案,写/执行由权限层拦截。
|
|
63
|
+
const PLAN_MODE_NOTE =
|
|
64
|
+
'\n\n# 计划模式(当前开启)\n'
|
|
65
|
+
+ '你现在处于计划模式:只能用只读工具(read_file/glob/grep/list_files/fetch_url)调研,'
|
|
66
|
+
+ '不得做任何改动——write_file/edit_file/run_bash 等写或执行操作都会被拒绝,不要反复尝试。'
|
|
67
|
+
+ '请先充分理解需求与现状,然后产出一份清晰、分步的实施方案:涉及哪些文件、每步改动要点、如何验证、风险点,'
|
|
68
|
+
+ '用 todo_write 列出步骤,随后停下等待用户确认。用户关闭计划模式后你再执行。';
|
|
69
|
+
// 沙箱模式(/sandbox 开启):禁 shell 与联网。
|
|
70
|
+
const SANDBOX_MODE_NOTE =
|
|
71
|
+
'\n\n# 沙箱模式(当前开启)\n禁止执行 shell 命令(run_bash)与联网(fetch_url),调用会被拒绝,不要反复尝试。只做本地文件读写与分析。';
|
|
72
|
+
|
|
73
|
+
/** /goal 设定的目标:每轮追加到系统提示词末尾,令模型停止前自检是否达成。 */
|
|
74
|
+
function goalNote(goal) {
|
|
75
|
+
return '\n\n# 当前目标(用户设定)\n' + String(goal).trim()
|
|
76
|
+
+ '\n在本回合结束、停止作答前,检查该目标是否已达成:若未达成,应继续推进(补充调研/改动/验证),不要过早停止;达成后用一句话说明已达成。';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 从消息 content(字符串或多模态数组)提取纯文本,用于钩子上下文。 */
|
|
80
|
+
function contentText(content) {
|
|
81
|
+
if (typeof content === 'string') return content;
|
|
82
|
+
if (Array.isArray(content)) return content.filter((p) => p?.type === 'text').map((p) => p.text || '').join('\n');
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 有序的有限并发 map:并发不超过 limit,结果按输入下标回填(与 Promise.all 相同的顺序语义,但并发受限)。 */
|
|
87
|
+
async function mapLimit(items, limit, fn) {
|
|
88
|
+
const results = new Array(items.length);
|
|
89
|
+
let next = 0;
|
|
90
|
+
const worker = async () => {
|
|
91
|
+
while (next < items.length) {
|
|
92
|
+
const i = next++;
|
|
93
|
+
results[i] = await fn(items[i], i);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
46
99
|
|
|
47
100
|
/** 伪模型(平台编排)判定:agent-/workflow-/datahuman- 前缀。 */
|
|
48
101
|
export function isPlatformModel(model) {
|
|
@@ -78,7 +131,7 @@ export function capToolResult(text) {
|
|
|
78
131
|
*/
|
|
79
132
|
export async function runTurn({
|
|
80
133
|
config, messages, permissions, cwd, ask, signal,
|
|
81
|
-
extraTools = [], depth = 0, io, events, todoState, compaction, pullQueued, maxIters,
|
|
134
|
+
extraTools = [], depth = 0, io, events, todoState, compaction, pullQueued, maxIters, fileJournal,
|
|
82
135
|
}) {
|
|
83
136
|
const out = (io && io.write) || ((s) => process.stdout.write(s));
|
|
84
137
|
const ev = events || null; // 结构化事件(ink TUI / 扩展用);为空则走 out() 字符串输出(普通 REPL)
|
|
@@ -91,6 +144,19 @@ export async function runTurn({
|
|
|
91
144
|
let lastPromptTokens = 0;
|
|
92
145
|
const iterMax = Number(maxIters || config.maxTurns) || DEFAULT_MAX_ITERS;
|
|
93
146
|
const platformMode = isPlatformModel(config.model);
|
|
147
|
+
|
|
148
|
+
// 按任务自动选模型:非伪模型 + 配置了 strong 模型 + 本回合看起来复杂(长提示/难点关键词/计划模式)→ 升配
|
|
149
|
+
let effectiveModel = config.model;
|
|
150
|
+
if (depth === 0 && config.autoModelStrong && !platformMode) {
|
|
151
|
+
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
|
|
152
|
+
const t = contentText(lastUser?.content);
|
|
153
|
+
const hard = config.planMode || t.length > 400
|
|
154
|
+
|| /重构|架构|设计|复杂|排查|调试|debug|优化|性能|并发|死锁|重写|迁移|安全审查|根因/i.test(t);
|
|
155
|
+
if (hard && config.autoModelStrong !== config.model) {
|
|
156
|
+
effectiveModel = config.autoModelStrong;
|
|
157
|
+
if (config.debug) info(`[automodel] 复杂回合升配 → ${effectiveModel}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
94
160
|
const compState = compaction || {}; // 未传则本回合内独立生效(不持久化)
|
|
95
161
|
|
|
96
162
|
// 顶层注入 todo_write / memory_write(平台编排模式无本机工具,均不注入)
|
|
@@ -134,9 +200,36 @@ export async function runTurn({
|
|
|
134
200
|
|
|
135
201
|
let markupRetries = 0; // 「文本化工具调用」纠正次数(首次要求改走 tool_calls 通道,二次禁工具收敛)
|
|
136
202
|
let toolsDisabled = false;
|
|
203
|
+
let toolFailures = 0; // 工具失败计数(自我纠错:累计≥3 注入一次「换策略」提示)
|
|
137
204
|
let correction = null; // 待注入的纠正指令:仅随本轮请求发给模型,不进会话记录(与平台侧临时 convo 等价)
|
|
138
205
|
|
|
206
|
+
// 工具事件钩子:仅顶层原始模型加载(平台伪模型由服务端编排,无本机工具)
|
|
207
|
+
const hooks = (depth === 0 && !platformMode) ? loadHooks(cwd) : null;
|
|
208
|
+
const hooksActive = !!(hooks && hooks.all.length);
|
|
209
|
+
|
|
210
|
+
// SessionStart(会话首个用户回合触发一次)+ UserPromptSubmit 钩子:可阻断,或注入附加上下文
|
|
211
|
+
if (hooksActive) {
|
|
212
|
+
const parts = [];
|
|
213
|
+
if (messages.filter((m) => m.role === 'user').length <= 1) {
|
|
214
|
+
const ss = runHooks('SessionStart', { cwd }, hooks);
|
|
215
|
+
if (ss.injectContext) parts.push(ss.injectContext);
|
|
216
|
+
for (const o of ss.outputs) parts.push(o.stdout);
|
|
217
|
+
}
|
|
218
|
+
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
|
|
219
|
+
const r = runHooks('UserPromptSubmit', { cwd, prompt: contentText(lastUser?.content) }, hooks);
|
|
220
|
+
if (r.block) {
|
|
221
|
+
info('提交被 UserPromptSubmit 钩子阻断:' + r.reason);
|
|
222
|
+
return { error: '提交被钩子阻断:' + r.reason, usage: { prompt, completion }, lastPromptTokens };
|
|
223
|
+
}
|
|
224
|
+
if (r.injectContext) parts.push(r.injectContext);
|
|
225
|
+
for (const o of r.outputs) parts.push(o.stdout);
|
|
226
|
+
if (parts.length) correction = '【钩子提供的附加上下文】\n' + parts.join('\n');
|
|
227
|
+
}
|
|
228
|
+
|
|
139
229
|
for (let iter = 0; iter < iterMax; iter++) {
|
|
230
|
+
// 预算收敛:最后一轮禁用工具并提示直接作答,保证中大型项目里到达上限也能给出干净的最终答复(而非被硬截断)
|
|
231
|
+
const lastIter = iter === iterMax - 1;
|
|
232
|
+
if (lastIter && !platformMode && !toolsDisabled && !correction) correction = BUDGET_FINAL_PROMPT;
|
|
140
233
|
// 非破坏式上下文压缩:仅更新 compState(摘要独立存储),messages/页面内容不动
|
|
141
234
|
const compacted = await compactIfNeeded(messages, config, { state: compState, lastPromptTokens });
|
|
142
235
|
if (compacted > 0) info(`已压缩上下文(${compacted} 条较早消息已摘要,页面与会话记录不受影响)`);
|
|
@@ -152,15 +245,31 @@ export async function runTurn({
|
|
|
152
245
|
else out(t);
|
|
153
246
|
};
|
|
154
247
|
const modelMessages = buildModelMessages(messages, compState);
|
|
248
|
+
// 会话级临时指令(计划模式 / 目标)追加到系统提示词末尾(构造新数组,绝不改动原 messages/会话)
|
|
249
|
+
let requestMessages = modelMessages;
|
|
250
|
+
if (!platformMode && modelMessages.length && modelMessages[0]?.role === 'system') {
|
|
251
|
+
let extra = '';
|
|
252
|
+
if (config.planMode) extra += PLAN_MODE_NOTE;
|
|
253
|
+
if (config.sandbox) extra += SANDBOX_MODE_NOTE;
|
|
254
|
+
if (config.goal && String(config.goal).trim()) extra += goalNote(config.goal);
|
|
255
|
+
if (extra) {
|
|
256
|
+
requestMessages = [
|
|
257
|
+
{ ...modelMessages[0], content: String(modelMessages[0].content || '') + extra },
|
|
258
|
+
...modelMessages.slice(1),
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const reqStart = Date.now();
|
|
155
263
|
let res;
|
|
156
264
|
try {
|
|
157
265
|
res = await streamChat(
|
|
158
266
|
{
|
|
159
267
|
baseUrl: config.baseUrl,
|
|
160
268
|
apiKey: config.apiKey,
|
|
161
|
-
model:
|
|
162
|
-
messages: correction ? [...
|
|
163
|
-
tools: toolsDisabled ? undefined : specs,
|
|
269
|
+
model: effectiveModel,
|
|
270
|
+
messages: correction ? [...requestMessages, { role: 'user', content: correction }] : requestMessages,
|
|
271
|
+
tools: (toolsDisabled || lastIter) ? undefined : specs,
|
|
272
|
+
reasoningEffort: config.reasoningEffort, // 推理强度透传(/effort;模型不支持则忽略)
|
|
164
273
|
// 对话落库审计:来源与会话号(由 config 透传;扩展侧为 vscode)
|
|
165
274
|
client: config.client,
|
|
166
275
|
sessionId: config.sessionId,
|
|
@@ -198,6 +307,10 @@ export async function runTurn({
|
|
|
198
307
|
completion += res.usage.completion_tokens || 0;
|
|
199
308
|
lastPromptTokens = res.usage.prompt_tokens || lastPromptTokens;
|
|
200
309
|
}
|
|
310
|
+
if (config.debug) {
|
|
311
|
+
info(`[debug] 轮${iter + 1} · ${config.model} · in ${res.usage?.prompt_tokens || 0} / out ${res.usage?.completion_tokens || 0} tok`
|
|
312
|
+
+ ` · ${Date.now() - reqStart}ms · ${(res.toolCalls || []).length} 工具调用`);
|
|
313
|
+
}
|
|
201
314
|
|
|
202
315
|
// 全量内容按同一状态机二次清洗(与流式放行结果一致):协议标记绝不进入会话记录与后续上下文
|
|
203
316
|
const { text: cleanText, sawMarkup: sawInFull } = stripMarkup(res.content);
|
|
@@ -228,6 +341,11 @@ export async function runTurn({
|
|
|
228
341
|
messages.push({ role: 'user', content: queued });
|
|
229
342
|
continue;
|
|
230
343
|
}
|
|
344
|
+
// Stop 钩子:回合结束触发(不阻断,仅采集输出,如收尾自检/通知)
|
|
345
|
+
if (hooksActive) {
|
|
346
|
+
const st = runHooks('Stop', { cwd, result: cleanText }, hooks);
|
|
347
|
+
for (const o of st.outputs) info('钩子·Stop:' + shortLine(o.stdout, 160));
|
|
348
|
+
}
|
|
231
349
|
return { usage: { prompt, completion }, lastPromptTokens };
|
|
232
350
|
}
|
|
233
351
|
|
|
@@ -269,7 +387,7 @@ export async function runTurn({
|
|
|
269
387
|
if (item.argsError) return item.argsError;
|
|
270
388
|
let result;
|
|
271
389
|
try {
|
|
272
|
-
result = item.extra ? await item.extra.run(item.args) : await runTool(item.name, item.args, cwd);
|
|
390
|
+
result = item.extra ? await item.extra.run(item.args) : await runTool(item.name, item.args, cwd, config);
|
|
273
391
|
} catch (e) {
|
|
274
392
|
result = `工具执行失败:${e.message}`;
|
|
275
393
|
}
|
|
@@ -280,6 +398,7 @@ export async function runTurn({
|
|
|
280
398
|
else out(c.cyan(' ⏺ ') + toolLabel(item.name, item.args) + '\n');
|
|
281
399
|
};
|
|
282
400
|
const emitResult = (item, result) => {
|
|
401
|
+
if (looksLikeToolFailure(result)) toolFailures++;
|
|
283
402
|
if (ev) ev.onToolResult?.(item.name, String(result));
|
|
284
403
|
else out(c.gray(' ' + shortLine(result)) + '\n');
|
|
285
404
|
messages.push({ role: 'tool', tool_call_id: item.tc.id, content: String(result) });
|
|
@@ -319,14 +438,13 @@ export async function runTurn({
|
|
|
319
438
|
let j = i;
|
|
320
439
|
while (j < parsed.length && parsed[j].readOnly && !parsed[j].argsError) j++;
|
|
321
440
|
const batch = parsed.slice(i, j);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
);
|
|
441
|
+
// 只读段并行执行,但并发受限(大仓库里模型可能一次触发很多 grep/glob,无限并发会抢占磁盘/CPU 反而更慢);结果仍按原顺序回灌
|
|
442
|
+
const settled = await mapLimit(batch, TOOL_CONCURRENCY, async (item) => {
|
|
443
|
+
const perm = await permissions.request(item.name, item.args, cwd, true);
|
|
444
|
+
if (!perm.allow) return { item, denied: perm.reason || '未授权,未执行。' };
|
|
445
|
+
emitStart(item);
|
|
446
|
+
return { item, result: await execOne(item) };
|
|
447
|
+
});
|
|
330
448
|
for (const s of settled) {
|
|
331
449
|
if (s.denied != null) emitSkip(s.item, s.denied);
|
|
332
450
|
else emitResult(s.item, s.result);
|
|
@@ -344,16 +462,51 @@ export async function runTurn({
|
|
|
344
462
|
i++;
|
|
345
463
|
continue;
|
|
346
464
|
}
|
|
465
|
+
// PreToolUse 钩子:可阻断该工具(非零退出码),阻断原因回灌模型
|
|
466
|
+
if (hooksActive) {
|
|
467
|
+
const pre = runHooks('PreToolUse', { cwd, toolName: cur.name, args: cur.args }, hooks);
|
|
468
|
+
for (const o of pre.outputs) info('钩子·PreToolUse:' + shortLine(o.stdout, 120));
|
|
469
|
+
if (pre.block) {
|
|
470
|
+
emitSkip(cur, 'PreToolUse 钩子阻断:' + pre.reason);
|
|
471
|
+
i++;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (pre.modifiedArgs) {
|
|
475
|
+
cur.args = { ...cur.args, ...pre.modifiedArgs }; // 结构化钩子改写工具参数
|
|
476
|
+
info('钩子·PreToolUse 已修改工具参数');
|
|
477
|
+
}
|
|
478
|
+
}
|
|
347
479
|
emitStart(cur);
|
|
480
|
+
// 检查点文件日志:写入前记录原文与是否已存在(供 /rewind 回滚;过大文件仅标记不存原文)
|
|
348
481
|
const before = isFileWrite(cur.name) && cur.args?.path
|
|
349
482
|
? readSafe(path.resolve(cwd, String(cur.args.path)))
|
|
350
483
|
: null;
|
|
351
|
-
|
|
484
|
+
if (fileJournal && before != null && cur.args?.path) {
|
|
485
|
+
const existed = fs.existsSync(path.resolve(cwd, String(cur.args.path)));
|
|
486
|
+
fileJournal.push(before.length > 2 * 1024 * 1024
|
|
487
|
+
? { path: String(cur.args.path), tooLarge: true }
|
|
488
|
+
: { path: String(cur.args.path), before, existed });
|
|
489
|
+
}
|
|
490
|
+
let result = await execOne(cur);
|
|
491
|
+
// PostToolUse 钩子:不阻断,输出附加到工具结果(模型可见,如格式化/测试结果)
|
|
492
|
+
if (hooksActive) {
|
|
493
|
+
const post = runHooks('PostToolUse', { cwd, toolName: cur.name, args: cur.args, result: String(result) }, hooks);
|
|
494
|
+
if (post.outputs.length) {
|
|
495
|
+
for (const o of post.outputs) info('钩子·PostToolUse:' + shortLine(o.stdout, 120));
|
|
496
|
+
result = String(result) + '\n\n[PostToolUse 钩子输出]\n' + post.outputs.map((o) => o.stdout).join('\n');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
352
499
|
emitResult(cur, result);
|
|
353
500
|
if (before != null) emitFileDiff(cur, before);
|
|
354
501
|
i++;
|
|
355
502
|
}
|
|
356
503
|
|
|
504
|
+
// 自我纠错:本回合累计工具失败达阈值,注入一次「换策略」提示(打破反复失败的死循环)
|
|
505
|
+
if (toolFailures >= 3 && !toolsDisabled && !correction) {
|
|
506
|
+
correction = SELF_CORRECT_NOTE;
|
|
507
|
+
toolFailures = 0;
|
|
508
|
+
info('检测到连续工具失败,已提示智能体换一种更可靠的方法');
|
|
509
|
+
}
|
|
357
510
|
// 工具轮之间注入用户排队消息(协议合法:tool 配对已完整),模型下一轮即可看到
|
|
358
511
|
const queued = pullQueued?.();
|
|
359
512
|
if (queued) messages.push({ role: 'user', content: queued });
|
package/src/api.js
CHANGED
|
@@ -101,6 +101,8 @@ export async function streamChat(opts, handlers = {}, signal) {
|
|
|
101
101
|
payload.tool_choice = 'auto';
|
|
102
102
|
}
|
|
103
103
|
if (typeof opts.temperature === 'number') payload.temperature = opts.temperature;
|
|
104
|
+
// 推理强度:OpenAI 兼容字段 reasoning_effort(透传;模型不支持则平台/模型侧忽略)
|
|
105
|
+
if (opts.reasoningEffort && opts.reasoningEffort !== 'off') payload.reasoning_effort = opts.reasoningEffort;
|
|
104
106
|
// 对话落库审计标识:来源(cli/vscode) + 会话号(平台按此归并同会话多轮,供后台审计)
|
|
105
107
|
const chatHeaders = authHeaders(opts.apiKey);
|
|
106
108
|
if (opts.client) chatHeaders['X-Huicai-Client'] = opts.client;
|
|
@@ -211,6 +213,37 @@ async function readStream(res, handlers, signal, stallMs) {
|
|
|
211
213
|
return { content, toolCalls: toolCalls.filter(Boolean), finishReason, usage };
|
|
212
214
|
}
|
|
213
215
|
|
|
216
|
+
/** 文本向量化(POST /v1/embeddings,OpenAI 兼容)。返回向量数组(与 input 一一对应);失败抛出。 */
|
|
217
|
+
export async function embed(baseUrl, apiKey, model, input) {
|
|
218
|
+
const res = await fetch(`${baseUrl}/v1/embeddings`, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: authHeaders(apiKey),
|
|
221
|
+
body: JSON.stringify({ model, input: Array.isArray(input) ? input : [input] }),
|
|
222
|
+
signal: AbortSignal.timeout(30_000),
|
|
223
|
+
});
|
|
224
|
+
if (!res.ok) throw new Error((await readError(res)).message);
|
|
225
|
+
const j = await res.json();
|
|
226
|
+
return Array.isArray(j?.data) ? j.data.map((d) => d.embedding) : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 知识库代码增量同步(POST /v1/knowledge/{kbId}/sync)。
|
|
231
|
+
* 把一批变更文件(新增/修改/删除)提交给平台按路径 upsert,保鲜 RAG。
|
|
232
|
+
* @param {string|number} kbId 知识库ID
|
|
233
|
+
* @param {Array<{path:string, content?:string, deleted?:boolean}>} files 变更文件
|
|
234
|
+
* @returns {Promise<{accepted:number, skipped:number, truncated:boolean, message:string}>}
|
|
235
|
+
*/
|
|
236
|
+
export async function syncKnowledge(baseUrl, apiKey, kbId, files) {
|
|
237
|
+
const res = await fetch(`${baseUrl}/v1/knowledge/${encodeURIComponent(kbId)}/sync`, {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: authHeaders(apiKey),
|
|
240
|
+
body: JSON.stringify({ files }),
|
|
241
|
+
signal: AbortSignal.timeout(120_000),
|
|
242
|
+
});
|
|
243
|
+
if (!res.ok) throw new Error((await readError(res)).message);
|
|
244
|
+
return res.json();
|
|
245
|
+
}
|
|
246
|
+
|
|
214
247
|
/** 列出可用模型(含平台已发布智能体伪模型 agent-/workflow-/datahuman-)。 */
|
|
215
248
|
export async function listModels(baseUrl, apiKey) {
|
|
216
249
|
const res = await fetch(`${baseUrl}/v1/models`, {
|
package/src/autofix.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// 自主「修到通过」闭环(/verify 深化):跑验证命令 → 失败则让智能体定位修复 → 自动重跑,直到通过或达上限。
|
|
2
|
+
// 这是自动化+智能化的核心:把「跑测试、看报错、改代码、再跑」这个人类循环交给智能体自主完成。
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const CAP = 16000;
|
|
9
|
+
|
|
10
|
+
function clip(s) {
|
|
11
|
+
s = String(s);
|
|
12
|
+
return s.length > CAP ? s.slice(0, CAP) + '\n…(输出过长已截断)' : s;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 跑一条命令,返回 {code, output}(合并 stdout/stderr,截断)。超时整组击杀。 */
|
|
16
|
+
export function captureCommand(cwd, command, timeout = 180000) {
|
|
17
|
+
const isWin = process.platform === 'win32';
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
let out = '';
|
|
20
|
+
let killed = false;
|
|
21
|
+
const child = isWin
|
|
22
|
+
? spawn(process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', command], { cwd, windowsVerbatimArguments: true })
|
|
23
|
+
: spawn(process.env.SHELL || '/bin/sh', ['-c', command], { cwd, detached: true });
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
killed = true;
|
|
26
|
+
try {
|
|
27
|
+
if (!isWin && child.pid) process.kill(-child.pid, 'SIGKILL');
|
|
28
|
+
else child.kill('SIGKILL');
|
|
29
|
+
} catch { /* ignore */ }
|
|
30
|
+
}, timeout);
|
|
31
|
+
const append = (d) => { if (out.length < CAP * 2) out += d.toString(); };
|
|
32
|
+
child.stdout.on('data', append);
|
|
33
|
+
child.stderr.on('data', append);
|
|
34
|
+
child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, output: '命令启动失败:' + e.message }); });
|
|
35
|
+
child.on('close', (code) => { clearTimeout(timer); resolve({ code: killed ? 124 : (code == null ? 1 : code), output: clip(out) || '(无输出)' }); });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 自动探测验证命令(测试/构建)。返回命令字符串或 null。 */
|
|
40
|
+
export function detectVerifyCommand(cwd) {
|
|
41
|
+
const has = (f) => { try { return fs.existsSync(path.join(cwd, f)); } catch { return false; } };
|
|
42
|
+
try {
|
|
43
|
+
if (has('package.json')) {
|
|
44
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
45
|
+
if (pkg.scripts?.test && !/no test specified/i.test(pkg.scripts.test)) return 'npm test';
|
|
46
|
+
if (pkg.scripts?.build) return 'npm run build';
|
|
47
|
+
}
|
|
48
|
+
} catch { /* 坏 package.json 忽略 */ }
|
|
49
|
+
if (has('pyproject.toml') || has('pytest.ini') || has('tox.ini')) return 'pytest -q';
|
|
50
|
+
if (has('pom.xml')) return 'mvn -q test';
|
|
51
|
+
if (has('build.gradle') || has('build.gradle.kts')) return './gradlew test';
|
|
52
|
+
if (has('Cargo.toml')) return 'cargo test';
|
|
53
|
+
if (has('go.mod')) return 'go test ./...';
|
|
54
|
+
if (has('Makefile')) return 'make test';
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 修到通过循环。
|
|
60
|
+
* @param {object} p
|
|
61
|
+
* @param {string} p.command 验证命令
|
|
62
|
+
* @param {number} [p.maxIters] 最多修复轮数(默认 5)
|
|
63
|
+
* @param {string} p.cwd
|
|
64
|
+
* @param {(prompt:string)=>Promise<{aborted?:boolean}|void>} p.runTurnOnce 跑一个智能体回合(前端提供)
|
|
65
|
+
* @param {(text:string)=>void} p.emit 进度输出
|
|
66
|
+
* @returns {Promise<{passed:boolean, iters?:number, output?:string, aborted?:boolean}>}
|
|
67
|
+
*/
|
|
68
|
+
export async function runVerifyLoop({ command, maxIters = 5, cwd, runTurnOnce, emit }) {
|
|
69
|
+
for (let i = 0; i < maxIters; i++) {
|
|
70
|
+
emit(`▶ 验证 ${i + 1}/${maxIters}:${command}`);
|
|
71
|
+
const { code, output } = await captureCommand(cwd, command);
|
|
72
|
+
if (code === 0) {
|
|
73
|
+
emit(`✅ 验证通过(第 ${i + 1} 轮)。`);
|
|
74
|
+
return { passed: true, iters: i + 1 };
|
|
75
|
+
}
|
|
76
|
+
emit(`✗ 未通过(退出码 ${code})` + (i < maxIters - 1 ? ',让智能体定位修复后自动重跑…' : ''));
|
|
77
|
+
if (i === maxIters - 1) {
|
|
78
|
+
emit(`⚠ 已达上限 ${maxIters} 轮仍未通过。`);
|
|
79
|
+
return { passed: false, iters: i + 1, output };
|
|
80
|
+
}
|
|
81
|
+
const r = await runTurnOnce(
|
|
82
|
+
`自动验证:运行 \`${command}\` 失败(退出码 ${code})。输出如下:\n\n${output}\n\n`
|
|
83
|
+
+ '请定位根因并**修复代码/配置**。不要自己重跑该命令——我会自动重跑验证。本轮只做必要的修复。');
|
|
84
|
+
if (r && r.aborted) {
|
|
85
|
+
emit('已中断修复循环。');
|
|
86
|
+
return { passed: false, aborted: true, iters: i + 1 };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { passed: false };
|
|
90
|
+
}
|