@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 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
- const wantTui = flags.tui || (!flags.plain && process.stdout.isTTY && process.stdin.isTTY);
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.710.97949",
4
- "versionDisplay": "2026.07.10.97949",
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 = 60;
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: config.model,
162
- messages: correction ? [...modelMessages, { role: 'user', content: correction }] : modelMessages,
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
- const settled = await Promise.all(
323
- batch.map(async (item) => {
324
- const perm = await permissions.request(item.name, item.args, cwd, true);
325
- if (!perm.allow) return { item, denied: perm.reason || '未授权,未执行。' };
326
- emitStart(item);
327
- return { item, result: await execOne(item) };
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
- const result = await execOne(cur);
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
+ }