@huicai/cli 2026.710.97950 → 2026.711.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/huicai.js +93 -2
- package/package.json +2 -2
- package/src/advisor.js +46 -0
- package/src/agent.js +138 -5
- package/src/api.js +33 -0
- package/src/autofix.js +90 -0
- package/src/checkpoints.js +99 -0
- package/src/clipboard.js +34 -0
- package/src/codeindex.js +229 -0
- package/src/colors.js +28 -3
- package/src/commands.js +963 -8
- package/src/compaction.js +6 -2
- package/src/config.js +21 -1
- package/src/context-report.js +119 -0
- package/src/context.js +8 -0
- package/src/cron.js +64 -0
- package/src/daemon.js +62 -0
- package/src/diffview.js +115 -0
- package/src/doctor.js +72 -0
- package/src/forks.js +59 -0
- package/src/gitflow.js +140 -0
- package/src/hooks.js +207 -0
- package/src/insights.js +102 -0
- package/src/kbsync.js +141 -0
- package/src/memory.js +51 -0
- package/src/permissions.js +40 -2
- package/src/render.js +4 -0
- package/src/repl.js +69 -3
- package/src/scan.js +84 -0
- package/src/schedule.js +48 -0
- package/src/settings.js +0 -0
- package/src/subagent.js +126 -25
- package/src/tools.js +19 -4
- package/src/transcript.js +94 -0
- package/src/tui/app.js +103 -3
- package/src/watch.js +74 -0
package/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,6 +26,7 @@ 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
|
|
|
@@ -47,6 +48,40 @@ const MARKUP_FORCE_ANSWER_PROMPT =
|
|
|
47
48
|
// 预算收敛:临近工具轮数上限时注入,禁用工具并要求直接作答,保证中大型项目里到达上限也能给出干净的最终答复(而非被硬截断)。
|
|
48
49
|
const BUDGET_FINAL_PROMPT =
|
|
49
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
|
+
}
|
|
50
85
|
|
|
51
86
|
/** 有序的有限并发 map:并发不超过 limit,结果按输入下标回填(与 Promise.all 相同的顺序语义,但并发受限)。 */
|
|
52
87
|
async function mapLimit(items, limit, fn) {
|
|
@@ -96,7 +131,7 @@ export function capToolResult(text) {
|
|
|
96
131
|
*/
|
|
97
132
|
export async function runTurn({
|
|
98
133
|
config, messages, permissions, cwd, ask, signal,
|
|
99
|
-
extraTools = [], depth = 0, io, events, todoState, compaction, pullQueued, maxIters,
|
|
134
|
+
extraTools = [], depth = 0, io, events, todoState, compaction, pullQueued, maxIters, fileJournal,
|
|
100
135
|
}) {
|
|
101
136
|
const out = (io && io.write) || ((s) => process.stdout.write(s));
|
|
102
137
|
const ev = events || null; // 结构化事件(ink TUI / 扩展用);为空则走 out() 字符串输出(普通 REPL)
|
|
@@ -109,6 +144,19 @@ export async function runTurn({
|
|
|
109
144
|
let lastPromptTokens = 0;
|
|
110
145
|
const iterMax = Number(maxIters || config.maxTurns) || DEFAULT_MAX_ITERS;
|
|
111
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
|
+
}
|
|
112
160
|
const compState = compaction || {}; // 未传则本回合内独立生效(不持久化)
|
|
113
161
|
|
|
114
162
|
// 顶层注入 todo_write / memory_write(平台编排模式无本机工具,均不注入)
|
|
@@ -152,8 +200,32 @@ export async function runTurn({
|
|
|
152
200
|
|
|
153
201
|
let markupRetries = 0; // 「文本化工具调用」纠正次数(首次要求改走 tool_calls 通道,二次禁工具收敛)
|
|
154
202
|
let toolsDisabled = false;
|
|
203
|
+
let toolFailures = 0; // 工具失败计数(自我纠错:累计≥3 注入一次「换策略」提示)
|
|
155
204
|
let correction = null; // 待注入的纠正指令:仅随本轮请求发给模型,不进会话记录(与平台侧临时 convo 等价)
|
|
156
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
|
+
|
|
157
229
|
for (let iter = 0; iter < iterMax; iter++) {
|
|
158
230
|
// 预算收敛:最后一轮禁用工具并提示直接作答,保证中大型项目里到达上限也能给出干净的最终答复(而非被硬截断)
|
|
159
231
|
const lastIter = iter === iterMax - 1;
|
|
@@ -173,15 +245,31 @@ export async function runTurn({
|
|
|
173
245
|
else out(t);
|
|
174
246
|
};
|
|
175
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();
|
|
176
263
|
let res;
|
|
177
264
|
try {
|
|
178
265
|
res = await streamChat(
|
|
179
266
|
{
|
|
180
267
|
baseUrl: config.baseUrl,
|
|
181
268
|
apiKey: config.apiKey,
|
|
182
|
-
model:
|
|
183
|
-
messages: correction ? [...
|
|
269
|
+
model: effectiveModel,
|
|
270
|
+
messages: correction ? [...requestMessages, { role: 'user', content: correction }] : requestMessages,
|
|
184
271
|
tools: (toolsDisabled || lastIter) ? undefined : specs,
|
|
272
|
+
reasoningEffort: config.reasoningEffort, // 推理强度透传(/effort;模型不支持则忽略)
|
|
185
273
|
// 对话落库审计:来源与会话号(由 config 透传;扩展侧为 vscode)
|
|
186
274
|
client: config.client,
|
|
187
275
|
sessionId: config.sessionId,
|
|
@@ -219,6 +307,10 @@ export async function runTurn({
|
|
|
219
307
|
completion += res.usage.completion_tokens || 0;
|
|
220
308
|
lastPromptTokens = res.usage.prompt_tokens || lastPromptTokens;
|
|
221
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
|
+
}
|
|
222
314
|
|
|
223
315
|
// 全量内容按同一状态机二次清洗(与流式放行结果一致):协议标记绝不进入会话记录与后续上下文
|
|
224
316
|
const { text: cleanText, sawMarkup: sawInFull } = stripMarkup(res.content);
|
|
@@ -249,6 +341,11 @@ export async function runTurn({
|
|
|
249
341
|
messages.push({ role: 'user', content: queued });
|
|
250
342
|
continue;
|
|
251
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
|
+
}
|
|
252
349
|
return { usage: { prompt, completion }, lastPromptTokens };
|
|
253
350
|
}
|
|
254
351
|
|
|
@@ -290,7 +387,7 @@ export async function runTurn({
|
|
|
290
387
|
if (item.argsError) return item.argsError;
|
|
291
388
|
let result;
|
|
292
389
|
try {
|
|
293
|
-
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);
|
|
294
391
|
} catch (e) {
|
|
295
392
|
result = `工具执行失败:${e.message}`;
|
|
296
393
|
}
|
|
@@ -301,6 +398,7 @@ export async function runTurn({
|
|
|
301
398
|
else out(c.cyan(' ⏺ ') + toolLabel(item.name, item.args) + '\n');
|
|
302
399
|
};
|
|
303
400
|
const emitResult = (item, result) => {
|
|
401
|
+
if (looksLikeToolFailure(result)) toolFailures++;
|
|
304
402
|
if (ev) ev.onToolResult?.(item.name, String(result));
|
|
305
403
|
else out(c.gray(' ' + shortLine(result)) + '\n');
|
|
306
404
|
messages.push({ role: 'tool', tool_call_id: item.tc.id, content: String(result) });
|
|
@@ -364,16 +462,51 @@ export async function runTurn({
|
|
|
364
462
|
i++;
|
|
365
463
|
continue;
|
|
366
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
|
+
}
|
|
367
479
|
emitStart(cur);
|
|
480
|
+
// 检查点文件日志:写入前记录原文与是否已存在(供 /rewind 回滚;过大文件仅标记不存原文)
|
|
368
481
|
const before = isFileWrite(cur.name) && cur.args?.path
|
|
369
482
|
? readSafe(path.resolve(cwd, String(cur.args.path)))
|
|
370
483
|
: null;
|
|
371
|
-
|
|
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
|
+
}
|
|
372
499
|
emitResult(cur, result);
|
|
373
500
|
if (before != null) emitFileDiff(cur, before);
|
|
374
501
|
i++;
|
|
375
502
|
}
|
|
376
503
|
|
|
504
|
+
// 自我纠错:本回合累计工具失败达阈值,注入一次「换策略」提示(打破反复失败的死循环)
|
|
505
|
+
if (toolFailures >= 3 && !toolsDisabled && !correction) {
|
|
506
|
+
correction = SELF_CORRECT_NOTE;
|
|
507
|
+
toolFailures = 0;
|
|
508
|
+
info('检测到连续工具失败,已提示智能体换一种更可靠的方法');
|
|
509
|
+
}
|
|
377
510
|
// 工具轮之间注入用户排队消息(协议合法:tool 配对已完整),模型下一轮即可看到
|
|
378
511
|
const queued = pullQueued?.();
|
|
379
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
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// 会话检查点(对标 Claude Code /rewind):每个用户回合前快照「对话位置 + 该回合将改动文件的原始内容」,
|
|
2
|
+
// 可把对话/文件/两者回滚到某个检查点之前。文件原文由 runTurn 经 fileJournal 在写入前记录(见 agent.js)。
|
|
3
|
+
//
|
|
4
|
+
// 检查点存活于当前运行的会话(内存),不落盘(避免把大段文件内容写进会话 JSON)。
|
|
5
|
+
// 语义:检查点 N 记录「第 N 个用户回合开始前」的状态;回滚到 N 即回到该回合之前。
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const MAX_CHECKPOINTS = 100;
|
|
11
|
+
|
|
12
|
+
function safeClone(o) {
|
|
13
|
+
try {
|
|
14
|
+
return o ? JSON.parse(JSON.stringify(o)) : {};
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function deriveLabel(text) {
|
|
21
|
+
const first = String(text || '').replace(/\s+/g, ' ').trim();
|
|
22
|
+
return first ? (first.length > 60 ? first.slice(0, 60) + '…' : first) : '(空)';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createCheckpoints() {
|
|
26
|
+
const list = []; // {id, time, label, messagesLen, compaction, journal:[{path,before,existed,tooLarge?}]}
|
|
27
|
+
let seq = 0;
|
|
28
|
+
|
|
29
|
+
/** 回合开始前调用:记录对话位置,返回一个 journal 数组交给 runTurn 累积本回合的文件原文。 */
|
|
30
|
+
function snapshot({ session, label }) {
|
|
31
|
+
const cp = {
|
|
32
|
+
id: ++seq,
|
|
33
|
+
time: Date.now(),
|
|
34
|
+
label: deriveLabel(label),
|
|
35
|
+
messagesLen: session.messages.length,
|
|
36
|
+
compaction: safeClone(session.compaction),
|
|
37
|
+
journal: [],
|
|
38
|
+
};
|
|
39
|
+
list.push(cp);
|
|
40
|
+
// 上限保护:超出则丢弃最早的(其对应文件原文一并释放)
|
|
41
|
+
if (list.length > MAX_CHECKPOINTS) list.splice(0, list.length - MAX_CHECKPOINTS);
|
|
42
|
+
return cp.journal;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 从某检查点(含)起、各文件最早一次记录的原始内容(用于回滚到该点之前的文件状态)。 */
|
|
46
|
+
function changedFilesSince(pos) {
|
|
47
|
+
const map = new Map(); // relPath -> journal entry(最早)
|
|
48
|
+
for (let i = pos; i < list.length; i++) {
|
|
49
|
+
for (const e of list[i].journal) {
|
|
50
|
+
if (e && e.path && !map.has(e.path)) map.set(e.path, e);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return map;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 回滚到检查点 id。
|
|
58
|
+
* @param {number} id 检查点编号
|
|
59
|
+
* @param {'chat'|'code'|'both'} mode
|
|
60
|
+
* @param {{session:object, cwd:string}} p
|
|
61
|
+
*/
|
|
62
|
+
function restore(id, mode, { session, cwd }) {
|
|
63
|
+
const pos = list.findIndex((c) => c.id === id);
|
|
64
|
+
if (pos < 0) return { ok: false, error: `检查点 ${id} 不存在(/rewind 查看可用检查点)` };
|
|
65
|
+
const cp = list[pos];
|
|
66
|
+
const summary = { ok: true, id, mode, conversation: false, filesRestored: [], filesDeleted: [], filesFailed: [], filesSkipped: [] };
|
|
67
|
+
|
|
68
|
+
if (mode === 'code' || mode === 'both') {
|
|
69
|
+
for (const [relPath, e] of changedFilesSince(pos)) {
|
|
70
|
+
if (e.tooLarge) { summary.filesSkipped.push(relPath); continue; }
|
|
71
|
+
const abs = path.resolve(cwd, relPath);
|
|
72
|
+
try {
|
|
73
|
+
if (!e.existed) {
|
|
74
|
+
fs.rmSync(abs, { force: true });
|
|
75
|
+
summary.filesDeleted.push(relPath);
|
|
76
|
+
} else {
|
|
77
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
78
|
+
fs.writeFileSync(abs, e.before, 'utf8');
|
|
79
|
+
summary.filesRestored.push(relPath);
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
summary.filesFailed.push(`${relPath}:${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (mode === 'chat' || mode === 'both') {
|
|
88
|
+
if (cp.messagesLen <= session.messages.length) session.messages.length = cp.messagesLen;
|
|
89
|
+
session.compaction = safeClone(cp.compaction);
|
|
90
|
+
session.lastPromptTokens = 0;
|
|
91
|
+
summary.conversation = true;
|
|
92
|
+
// 对话被截断 → 该点及之后的检查点失效
|
|
93
|
+
list.length = pos;
|
|
94
|
+
}
|
|
95
|
+
return summary;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { list, snapshot, restore, changedFilesSince };
|
|
99
|
+
}
|