@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/src/compaction.js CHANGED
@@ -48,13 +48,17 @@ export function buildModelMessages(messages, state) {
48
48
  */
49
49
  export async function compactIfNeeded(messages, config, opts = {}) {
50
50
  const state = opts.state || {};
51
- const threshold = opts.thresholdChars || DEFAULT_THRESHOLD_CHARS;
52
51
  const keep = opts.keepRecent || KEEP_RECENT_USER_TURNS;
53
52
  if (!messages || messages.length < 6) return 0;
54
53
 
54
+ // /autocompact 配置:负数=关闭自动压缩(仍可手动 force);正数=自定义 token 阈值(并按 ~3 字符/token 联动字符阈值)
55
+ const configuredTokens = Number(config?.autoCompactTokens);
56
+ if (!opts.force && Number.isFinite(configuredTokens) && configuredTokens < 0) return 0;
57
+ const tokenLimit = configuredTokens > 0 ? configuredTokens : DEFAULT_AUTOCOMPACT_TOKENS;
58
+ const threshold = opts.thresholdChars || (configuredTokens > 0 ? configuredTokens * 3 : DEFAULT_THRESHOLD_CHARS);
59
+
55
60
  const effective = buildModelMessages(messages, state);
56
61
  if (!opts.force) {
57
- const tokenLimit = Number(config?.autoCompactTokens) || DEFAULT_AUTOCOMPACT_TOKENS;
58
62
  const overTokens = Number(opts.lastPromptTokens) > tokenLimit;
59
63
  const overChars = totalChars(effective) > threshold;
60
64
  if (!overTokens && !overChars) return 0;
package/src/config.js CHANGED
@@ -35,8 +35,10 @@ export function loadConfig() {
35
35
  model: (process.env.HUICAI_MODEL || file.model || DEFAULT_MODEL).trim(),
36
36
  // 单回合最大工具轮数(0/缺省 = 用内置默认 60);可被 --max-turns 覆盖
37
37
  maxTurns: Number(process.env.HUICAI_MAX_TURNS || file.maxTurns) || 0,
38
- // 自动压缩触发的 prompt_tokens 上限(缺省用 compaction 内置默认 80000)
38
+ // 自动压缩触发的 prompt_tokens 上限(0=用 compaction 内置默认 80000;负=/autocompact off 关闭)
39
39
  autoCompactTokens: Number(file.autoCompactTokens) || 0,
40
+ // 推理强度(reasoning_effort):minimal|low|medium|high;空串=不透传(由模型默认)。模型不支持则被忽略。
41
+ reasoningEffort: (process.env.HUICAI_EFFORT || file.reasoningEffort || '').trim(),
40
42
  // 压缩摘要专用模型(缺省:当前模型;当前为伪模型时 compaction 内部回退 DEFAULT_MODEL——摘要绝不经智能体编排)
41
43
  compactModel: (process.env.HUICAI_COMPACT_MODEL || file.compactModel || '').trim(),
42
44
  // 长期记忆(memory_write + 系统提示词注入):默认开;配置 memory:false 或 HUICAI_MEMORY=0 关闭
@@ -47,6 +49,24 @@ export function loadConfig() {
47
49
  markdown: process.env.HUICAI_MARKDOWN === '0' ? false : file.markdown !== false,
48
50
  // 客户端来源标识(对话落库审计用):默认 cli;VSCode 扩展进程内置 HUICAI_CLIENT=vscode
49
51
  client: (process.env.HUICAI_CLIENT || 'cli').trim().toLowerCase(),
52
+ // 输出主题:default(彩色)| mono(无色)。终端自身掌控具体配色,这里主要控制开关。
53
+ theme: (process.env.HUICAI_THEME || file.theme || 'default').trim(),
54
+ // 强调色(提示符/高亮):cyan|green|magenta|blue|yellow|red
55
+ accentColor: (file.accentColor || 'cyan').trim(),
56
+ // 交互渲染器偏好(下次启动生效):auto(TTY 下富 TUI)| rich | plain
57
+ tui: (process.env.HUICAI_TUI || file.tui || 'auto').trim(),
58
+ // 调试输出:每轮打印模型/耗时/token 等(默认关)
59
+ debug: process.env.HUICAI_DEBUG === '1' ? true : file.debug === true,
60
+ // 自定义状态栏命令(TUI 底栏展示其 stdout;空=用默认)
61
+ statusLine: (file.statusLine || '').trim(),
62
+ // 顾问模型(更强的模型):设置后暴露 ask_advisor 工具,供智能体遇难题时咨询第二意见
63
+ advisorModel: (process.env.HUICAI_ADVISOR || file.advisorModel || '').trim(),
64
+ // 按任务自动选模型:设一个更强模型 → 复杂回合(长提示/难点关键词/计划模式)自动用它,其余用当前(便宜)模型
65
+ autoModelStrong: (process.env.HUICAI_AUTOMODEL || file.autoModelStrong || '').trim(),
66
+ // 本项目 token 预算(软提醒;0=不限)
67
+ budgetTokens: Number(file.budgetTokens) || 0,
68
+ // 语义检索用嵌入模型:设置后 search_code 在 BM25 召回上做向量重排(真语义);空=纯 BM25
69
+ embeddingModel: (process.env.HUICAI_EMBED_MODEL || file.embeddingModel || '').trim(),
50
70
  };
51
71
  cfg.baseUrl = cfg.baseUrl.replace(/\/+$/, ''); // 去尾斜杠
52
72
  return cfg;
@@ -0,0 +1,119 @@
1
+ // /context 上下文占用可视化:按类别估算「当前发给模型的视图」各占多少 token,并画条形图。
2
+ // 用 buildModelMessages 取真实模型视图(含非破坏式压缩摘要),故与实际发送口径一致。
3
+ // token 为估算(CJK≈1.6/字,其余≈1/4 字符);若有上一轮实测 prompt_tokens,则整体按实测缩放校准。
4
+
5
+ import { buildModelMessages } from './compaction.js';
6
+ import { c } from './colors.js';
7
+
8
+ const CJK_RE = /[぀-ヿ㐀-鿿가-힯豈-﫿＀-￯]/g;
9
+
10
+ /** 估算文本 token 数(中英混排启发式)。 */
11
+ export function estimateTokens(text) {
12
+ if (!text) return 0;
13
+ const s = String(text);
14
+ const cjk = (s.match(CJK_RE) || []).length;
15
+ const other = s.length - cjk;
16
+ return Math.ceil(cjk * 1.6 + other / 4);
17
+ }
18
+
19
+ function msgChars(m) {
20
+ let n = 0;
21
+ if (typeof m.content === 'string') n += m.content.length;
22
+ else if (Array.isArray(m.content)) {
23
+ for (const part of m.content) {
24
+ if (part?.type === 'text' && part.text) n += part.text.length;
25
+ else if (part?.type === 'image_url') n += 800; // 多模态图片按占位近似
26
+ }
27
+ }
28
+ return n;
29
+ }
30
+ function tokOf(m) {
31
+ if (typeof m.content === 'string') return estimateTokens(m.content);
32
+ if (Array.isArray(m.content)) {
33
+ let t = 0;
34
+ for (const part of m.content) {
35
+ if (part?.type === 'text' && part.text) t += estimateTokens(part.text);
36
+ else if (part?.type === 'image_url') t += 300;
37
+ }
38
+ return t;
39
+ }
40
+ return 0;
41
+ }
42
+
43
+ function fmtTok(n) {
44
+ n = Math.round(Number(n) || 0);
45
+ return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
46
+ }
47
+
48
+ function bar(pct, width = 14) {
49
+ const filled = Math.max(0, Math.min(width, Math.round(pct * width)));
50
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
51
+ }
52
+
53
+ /**
54
+ * 生成上下文占用报告文本。
55
+ * @param {object} p
56
+ * @param {any[]} p.messages 完整会话消息(含 system)
57
+ * @param {object} [p.compaction] 压缩状态 {upTo,summary}
58
+ * @param {object[]} [p.specs] 本回合会随请求发送的工具规格(估其常驻开销)
59
+ * @param {number} [p.lastPromptTokens] 上一轮实测 prompt_tokens(用于校准)
60
+ * @param {number} [p.autoCompactTokens] 自动压缩触发阈值
61
+ * @param {boolean} [p.platform] 平台伪模型(无本机工具/无压缩),仅粗统计
62
+ */
63
+ export function contextReport({ messages, compaction, specs, lastPromptTokens, autoCompactTokens, platform }) {
64
+ const view = platform ? messages : buildModelMessages(messages, compaction || {});
65
+ const hasSummary = !!(compaction && compaction.summary && compaction.upTo > 1);
66
+
67
+ const buckets = {
68
+ system: { label: '系统提示词', tok: 0 },
69
+ tools: { label: '工具定义', tok: 0 },
70
+ summary: { label: '压缩摘要', tok: 0 },
71
+ user: { label: '用户消息', tok: 0 },
72
+ assistant: { label: '助手回复', tok: 0 },
73
+ toolcall: { label: '工具调用', tok: 0 },
74
+ toolresult: { label: '工具结果', tok: 0 },
75
+ };
76
+
77
+ view.forEach((m, i) => {
78
+ if (i === 0 && m.role === 'system') { buckets.system.tok += tokOf(m); return; }
79
+ if (i === 1 && hasSummary && m.role === 'user') { buckets.summary.tok += tokOf(m); return; }
80
+ if (m.role === 'user') buckets.user.tok += tokOf(m);
81
+ else if (m.role === 'assistant') {
82
+ buckets.assistant.tok += tokOf(m);
83
+ if (m.tool_calls) buckets.toolcall.tok += estimateTokens(JSON.stringify(m.tool_calls));
84
+ } else if (m.role === 'tool') buckets.toolresult.tok += tokOf(m);
85
+ });
86
+
87
+ if (specs && specs.length) buckets.tools.tok = estimateTokens(JSON.stringify(specs));
88
+
89
+ const order = ['system', 'tools', 'summary', 'user', 'assistant', 'toolcall', 'toolresult'];
90
+ let total = order.reduce((s, k) => s + buckets[k].tok, 0);
91
+
92
+ // 有实测值则整体缩放校准(让分类合计对齐真实 prompt_tokens)
93
+ let scale = 1;
94
+ if (lastPromptTokens > 0 && total > 0) scale = lastPromptTokens / total;
95
+ const scaled = (k) => buckets[k].tok * scale;
96
+ const scaledTotal = lastPromptTokens > 0 ? lastPromptTokens : total;
97
+
98
+ const lines = [];
99
+ lines.push(c.bold('上下文占用') + c.gray('(当前发给模型的视图 · 估算)'));
100
+ for (const k of order) {
101
+ const t = scaled(k);
102
+ if (t < 1 && buckets[k].tok === 0) continue;
103
+ const pct = scaledTotal > 0 ? t / scaledTotal : 0;
104
+ lines.push(' ' + buckets[k].label.padEnd(6, ' ')
105
+ + ' ' + ('~' + fmtTok(t)).padStart(7)
106
+ + ' ' + c.cyan(bar(pct)) + ' ' + String(Math.round(pct * 100)).padStart(3) + '%');
107
+ }
108
+ lines.push(c.gray(' ' + '─'.repeat(34)));
109
+
110
+ const thr = Number(autoCompactTokens) || 80000;
111
+ const towardPct = Math.min(100, Math.round((scaledTotal / thr) * 100));
112
+ lines.push(' 合计   ' + ('~' + fmtTok(scaledTotal)).padStart(7)
113
+ + c.gray(` 自动压缩阈值 ${fmtTok(thr)}(${towardPct}%)`));
114
+ if (lastPromptTokens > 0) lines.push(c.gray(` 实测上一轮 prompt_tokens:${fmtTok(lastPromptTokens)}(分类按此校准)`));
115
+ else lines.push(c.gray(' (尚无实测值,纯估算;发生一轮对话后更准)'));
116
+ if (hasSummary) lines.push(c.gray(' 已启用上下文压缩:更早的历史以摘要形式计入,原文完整保留。'));
117
+ lines.push(c.gray(' /compact 立即压缩 · /context 再次查看'));
118
+ return lines.join('\n');
119
+ }
package/src/context.js CHANGED
@@ -7,6 +7,7 @@ import os from 'node:os';
7
7
  import { execSync } from 'node:child_process';
8
8
  import { loadMemory } from './memory.js';
9
9
  import { skillCatalog } from './skills.js';
10
+ import { buildRepoMap } from './codeindex.js';
10
11
 
11
12
  function git(cwd) {
12
13
  const run = (cmd) => execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
@@ -110,6 +111,13 @@ export function buildSystemPrompt(cwd, opts = {}) {
110
111
  if (editor) p += '\n# 编辑器上下文(来自 IDE 集成)\n' + editor;
111
112
  if (gdoc) p += `\n# 全局用户说明(~/.huicai/HUICAI.md)\n${gdoc}\n`;
112
113
  if (doc) p += `\n# 项目说明(${doc.name})\n${doc.content}\n`;
114
+ // 项目地图:关键文件与主要符号的紧凑概览,帮智能体一开局就建立全局观(用 search_code 按概念深入)
115
+ if (opts.repoMap !== false) {
116
+ try {
117
+ const repoMap = buildRepoMap(cwd);
118
+ if (repoMap) p += `\n# 项目地图(关键文件与主要符号;用 search_code 按概念检索相关实现)\n${repoMap}\n`;
119
+ } catch { /* 大仓库/权限问题:跳过地图,不影响对话 */ }
120
+ }
113
121
  // 本地技能目录(渐进披露:仅名称+描述常驻,正文经 load_skill 按需载入)
114
122
  const skills = skillCatalog(cwd);
115
123
  if (skills) p += `\n# 可用技能(本地 Agent Skills)\n${skills}\n`;
package/src/cron.js ADDED
@@ -0,0 +1,64 @@
1
+ // 极简 5 字段 cron 匹配器(分 时 日 月 周):支持 * / N / N-M / N-M/S / */S / 列表(A,B,C)。
2
+ // 周字段 0 与 7 均表示周日。日与周的「或/与」语义遵循标准 cron。
3
+
4
+ function matchPart(part, value, min, max) {
5
+ part = part.trim();
6
+ let step = 1;
7
+ let range = part;
8
+ const slash = part.indexOf('/');
9
+ if (slash >= 0) {
10
+ step = parseInt(part.slice(slash + 1), 10) || 1;
11
+ range = part.slice(0, slash);
12
+ }
13
+ let lo;
14
+ let hi;
15
+ if (range === '*') {
16
+ lo = min;
17
+ hi = max;
18
+ } else if (range.includes('-')) {
19
+ const [a, b] = range.split('-');
20
+ lo = parseInt(a, 10);
21
+ hi = parseInt(b, 10);
22
+ } else {
23
+ lo = parseInt(range, 10);
24
+ hi = slash >= 0 ? max : lo; // "N/S" 表示从 N 起步长 S 到 max
25
+ }
26
+ if (Number.isNaN(lo) || Number.isNaN(hi)) return false;
27
+ if (value < lo || value > hi) return false;
28
+ return ((value - lo) % step) === 0;
29
+ }
30
+
31
+ function matchField(expr, value, min, max) {
32
+ return String(expr).split(',').some((p) => matchPart(p, value, min, max));
33
+ }
34
+
35
+ /** date 是否匹配 cron 表达式。 */
36
+ export function cronMatches(expr, date = new Date()) {
37
+ const fields = String(expr).trim().split(/\s+/);
38
+ if (fields.length !== 5) return false;
39
+ const [minF, hourF, domF, monF, dowF] = fields;
40
+ const minute = date.getMinutes();
41
+ const hour = date.getHours();
42
+ const dom = date.getDate();
43
+ const mon = date.getMonth() + 1;
44
+ const dow = date.getDay(); // 0=周日
45
+
46
+ if (!matchField(minF, minute, 0, 59)) return false;
47
+ if (!matchField(hourF, hour, 0, 23)) return false;
48
+ if (!matchField(monF, mon, 1, 12)) return false;
49
+
50
+ const domStar = domF.trim() === '*';
51
+ const dowStar = dowF.trim() === '*';
52
+ const domOk = matchField(domF, dom, 1, 31);
53
+ const dowOk = matchField(dowF, dow, 0, 7) || (dow === 0 && matchField(dowF, 7, 0, 7));
54
+ // 标准 cron:日与周都非 * 时取「或」;否则取「与」
55
+ const dayOk = (domStar && dowStar) ? true
56
+ : (!domStar && !dowStar) ? (domOk || dowOk)
57
+ : (domStar ? dowOk : domOk);
58
+ return dayOk;
59
+ }
60
+
61
+ /** 校验一个 cron 表达式是否 5 字段(不校验字段合法性细节)。 */
62
+ export function isValidCron(expr) {
63
+ return String(expr).trim().split(/\s+/).length === 5;
64
+ }
package/src/daemon.js ADDED
@@ -0,0 +1,62 @@
1
+ // huicai daemon:内置调度守护——按 cron 真正运行 /schedule 登记的任务(不再依赖系统 cron)。
2
+ // 每分钟检查一次,对命中的任务派生 `huicai -p <prompt> --yolo --plain`(非交互自动放行),输出写日志。
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { listTasks } from './schedule.js';
9
+ import { cronMatches } from './cron.js';
10
+ import { huicaiBin } from './forks.js';
11
+
12
+ export const DAEMON_LOG_DIR = path.join(os.homedir(), '.huicai', 'daemon');
13
+
14
+ /**
15
+ * 运行调度守护。
16
+ * @param {{intervalMs?:number, log?:(s:string)=>void, once?:boolean, spawnImpl?:Function}} [opts]
17
+ * once=true 只跑一次(供测试/单次触发);spawnImpl 可注入(测试用)。
18
+ */
19
+ export async function runDaemon({ intervalMs = 60000, log = (s) => process.stdout.write(s + '\n'), once = false, spawnImpl } = {}) {
20
+ const bin = huicaiBin();
21
+ if (!bin && !spawnImpl) throw new Error('无法定位 huicai 可执行文件(daemon 需在 CLI 环境运行)');
22
+ fs.mkdirSync(DAEMON_LOG_DIR, { recursive: true });
23
+ const doSpawn = spawnImpl || ((task) => {
24
+ const out = fs.openSync(path.join(DAEMON_LOG_DIR, task.id + '.log'), 'a');
25
+ const child = spawn(process.execPath, [bin, '-p', task.prompt, '--yolo', '--plain'],
26
+ { cwd: task.cwd, detached: true, stdio: ['ignore', out, out], env: process.env });
27
+ child.unref();
28
+ return child.pid;
29
+ });
30
+ const lastRun = new Map(); // id -> 'YYYY-MM-DDTHH:MM',防同一分钟重复触发
31
+
32
+ const tick = () => {
33
+ const now = new Date();
34
+ const stamp = now.toISOString().slice(0, 16);
35
+ let fired = 0;
36
+ for (const t of listTasks()) {
37
+ if (!t.cron || !t.prompt) continue;
38
+ if (lastRun.get(t.id) === stamp) continue;
39
+ let due = false;
40
+ try { due = cronMatches(t.cron, now); } catch { due = false; }
41
+ if (!due) continue;
42
+ lastRun.set(t.id, stamp);
43
+ const pid = doSpawn(t);
44
+ fired++;
45
+ log(`[${stamp}] 触发 #${t.id}(pid ${pid}):${String(t.prompt).replace(/\s+/g, ' ').slice(0, 60)}`);
46
+ }
47
+ return fired;
48
+ };
49
+
50
+ const n = tick();
51
+ if (once) return { fired: n };
52
+ log(`huicai 调度守护已启动:每 ${Math.round(intervalMs / 1000)}s 检查 ~/.huicai/schedule.json(${listTasks().length} 个任务)· Ctrl-C 退出`);
53
+ const timer = setInterval(tick, intervalMs);
54
+ await new Promise((resolve) => {
55
+ process.on('SIGINT', () => {
56
+ clearInterval(timer);
57
+ log('\n调度守护已停止。');
58
+ resolve();
59
+ });
60
+ });
61
+ return { fired: n };
62
+ }
@@ -0,0 +1,115 @@
1
+ // /diff:显示未提交改动(git status + git diff,含未跟踪文件),并汇总本会话智能体改过的文件。
2
+ // 非 git 仓库时退化为「仅列出本会话改过的文件」(无基线无法给 diff,建议配合 /rewind)。
3
+
4
+ import { execSync } from 'node:child_process';
5
+ import { c } from './colors.js';
6
+
7
+ const MAX_DIFF_LINES = 500;
8
+
9
+ function git(cwd, args) {
10
+ return execSync(`git ${args}`, { cwd, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024 })
11
+ .toString();
12
+ }
13
+
14
+ function isRepo(cwd) {
15
+ try {
16
+ return git(cwd, 'rev-parse --is-inside-work-tree').trim() === 'true';
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /** 本会话中智能体写过/编辑过的文件(从 write_file/edit_file 的工具调用参数提取,去重保序)。 */
23
+ export function sessionEditedFiles(messages) {
24
+ const seen = new Set();
25
+ const out = [];
26
+ for (const m of messages || []) {
27
+ if (m.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue;
28
+ for (const tc of m.tool_calls) {
29
+ const name = tc.function?.name;
30
+ if (name !== 'write_file' && name !== 'edit_file') continue;
31
+ let p;
32
+ try {
33
+ p = JSON.parse(tc.function?.arguments || '{}').path;
34
+ } catch {
35
+ continue;
36
+ }
37
+ if (p && !seen.has(p)) {
38
+ seen.add(p);
39
+ out.push(p);
40
+ }
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** 给 unified diff 上色。 */
47
+ export function colorizeDiff(text) {
48
+ return String(text)
49
+ .split('\n')
50
+ .map((l) => {
51
+ if (l.startsWith('diff --git') || l.startsWith('index ') || l.startsWith('--- ') || l.startsWith('+++ ')) return c.bold(l);
52
+ if (l.startsWith('@@')) return c.cyan(l);
53
+ if (l.startsWith('+')) return c.green(l);
54
+ if (l.startsWith('-')) return c.red(l);
55
+ return l;
56
+ })
57
+ .join('\n');
58
+ }
59
+
60
+ function clip(text) {
61
+ const lines = String(text).split('\n');
62
+ if (lines.length <= MAX_DIFF_LINES) return text;
63
+ return lines.slice(0, MAX_DIFF_LINES).join('\n') + `\n…(diff 过长,仅显示前 ${MAX_DIFF_LINES} 行,用 git diff 查看完整)`;
64
+ }
65
+
66
+ /** 组装 /diff 输出文本。 */
67
+ export function renderDiff(cwd, messages) {
68
+ const edited = sessionEditedFiles(messages);
69
+ const lines = [];
70
+ if (edited.length) {
71
+ lines.push(c.bold(`本会话智能体改过的文件(${edited.length})`));
72
+ for (const f of edited) lines.push(' · ' + f);
73
+ lines.push('');
74
+ }
75
+
76
+ if (!isRepo(cwd)) {
77
+ if (!edited.length) return '当前目录无 git 仓库,且本会话尚无文件改动。';
78
+ lines.push(c.gray('(非 git 仓库,无法给出 diff;可用 /rewind 回滚本会话改动。)'));
79
+ return lines.join('\n');
80
+ }
81
+
82
+ let status = '';
83
+ try {
84
+ status = git(cwd, 'status --short').trim();
85
+ } catch { /* ignore */ }
86
+ if (!status) {
87
+ lines.push('工作区干净,无未提交改动。');
88
+ return lines.join('\n');
89
+ }
90
+ lines.push(c.bold('git 状态'));
91
+ lines.push(status.split('\n').map((l) => ' ' + l).join('\n'));
92
+
93
+ let diff = '';
94
+ try {
95
+ diff = git(cwd, 'diff --stat').trim();
96
+ } catch { /* ignore */ }
97
+ if (diff) {
98
+ lines.push('');
99
+ lines.push(c.bold('改动统计'));
100
+ lines.push(diff.split('\n').map((l) => ' ' + l).join('\n'));
101
+ }
102
+
103
+ let full = '';
104
+ try {
105
+ full = git(cwd, 'diff').trim(); // 已跟踪文件的未暂存改动
106
+ } catch { /* ignore */ }
107
+ if (full) {
108
+ lines.push('');
109
+ lines.push(c.bold('改动明细(未暂存)'));
110
+ lines.push(colorizeDiff(clip(full)));
111
+ }
112
+ lines.push('');
113
+ lines.push(c.gray('(未跟踪文件见上方 ?? 标记;已暂存改动用 git diff --staged 查看。)'));
114
+ return lines.join('\n');
115
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,72 @@
1
+ // /doctor:本机环境体检(对齐 VSCode diagnostics)——node/登录/平台连通/模型/git/工作目录/钩子。
2
+ // 网络类检查 best-effort,失败给出可读提示,绝不抛出中断命令。
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { execSync } from 'node:child_process';
7
+ import { c } from './colors.js';
8
+ import { CONFIG_PATH } from './config.js';
9
+ import { listModels } from './api.js';
10
+ import { loadHooks } from './hooks.js';
11
+
12
+ const OK = () => c.green('✓');
13
+ const NO = () => c.red('✗');
14
+ const DOT = () => c.yellow('•');
15
+
16
+ function isGitRepo(cwd) {
17
+ try {
18
+ return execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() === 'true';
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ function cwdWritable(cwd) {
25
+ try {
26
+ fs.accessSync(cwd, fs.constants.W_OK);
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /** 生成体检报告文本(async:含一次平台连通探测)。 */
34
+ export async function runDoctor(ctx) {
35
+ const { config, cwd } = ctx;
36
+ const lines = [c.bold('huicai 体检')];
37
+
38
+ const nodeMajor = Number(String(process.versions.node).split('.')[0]);
39
+ lines.push(` ${nodeMajor >= 18 ? OK() : NO()} Node ${process.version}${nodeMajor >= 18 ? '' : c.yellow('(需 ≥18)')}`);
40
+ lines.push(` ${DOT()} 配置文件 ${CONFIG_PATH}`);
41
+ lines.push(` ${DOT()} 工作目录 ${cwd}${cwdWritable(cwd) ? '' : c.yellow('(不可写)')}`);
42
+
43
+ const loggedIn = !!(config.apiKey && config.baseUrl);
44
+ lines.push(` ${loggedIn ? OK() : NO()} 登录 ${loggedIn ? '已登录 · ' + config.baseUrl : '未登录(/login 或 huicai login)'}`);
45
+
46
+ if (loggedIn) {
47
+ try {
48
+ const models = await listModels(config.baseUrl, config.apiKey);
49
+ lines.push(` ${OK()} 平台连通 · ${models.length} 个模型/智能体可用`);
50
+ const m = config.model;
51
+ const known = m === 'auto' || models.some((x) => x.id === m);
52
+ lines.push(` ${known ? OK() : NO()} 当前模型 ${m}${known ? '' : c.yellow('(不在账号可用列表,建议 /model auto)')}`);
53
+ } catch (e) {
54
+ lines.push(` ${NO()} 平台连通失败:${e.message}`);
55
+ }
56
+ }
57
+
58
+ const git = isGitRepo(cwd);
59
+ lines.push(` ${git ? OK() : DOT()} Git ${git ? '仓库' : c.yellow('当前目录非 git 仓库(/diff 与 /rewind code 在 git 下体验更佳)')}`);
60
+
61
+ try {
62
+ const hooks = loadHooks(cwd);
63
+ if (hooks.all.length) lines.push(` ${DOT()} 钩子 已配置 ${hooks.all.length} 个(/hooks 查看)`);
64
+ if (hooks.projectIgnored) lines.push(` ${DOT()} 钩子 ${hooks.projectIgnored} 个项目钩子未信任(HUICAI_PROJECT_HOOKS=1 启用)`);
65
+ } catch { /* 忽略 */ }
66
+
67
+ const mcpPath = path.join(process.env.HOME || process.env.USERPROFILE || '', '.huicai', 'mcp.json');
68
+ lines.push(` ${DOT()} MCP ${fs.existsSync(mcpPath) ? '已配置 ~/.huicai/mcp.json' : '未配置(可选)'}`);
69
+
70
+ lines.push(c.gray(' (体检只读,不改动任何东西)'));
71
+ return lines.join('\n');
72
+ }
package/src/forks.js ADDED
@@ -0,0 +1,59 @@
1
+ // /fork + /tasks:派生后台智能体(detached 子进程跑 `huicai resume <copyId> -p ... --yolo --plain`),
2
+ // 输出写日志文件,本进程内登记以便 /tasks 查看/终止。仅在能定位 huicai 可执行文件时可用(通常即 CLI)。
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+
9
+ const forks = []; // 本进程登记的后台任务 {pid, prompt, cwd, log, started}
10
+
11
+ /** 定位当前 huicai 可执行入口(CLI 下即 process.argv[1]);无法定位返回 null。 */
12
+ export function huicaiBin() {
13
+ const a = process.argv[1] || '';
14
+ return /huicai/.test(a) ? a : null;
15
+ }
16
+
17
+ /** 派生后台智能体。@param {{prompt:string, cwd:string, sessionId?:string}} p @returns 登记记录 */
18
+ export function forkAgent({ prompt, cwd, sessionId }) {
19
+ const bin = huicaiBin();
20
+ if (!bin) throw new Error('无法定位 huicai 可执行文件(请在 CLI 中使用 /fork)');
21
+ const logDir = path.join(os.homedir(), '.huicai', 'forks');
22
+ fs.mkdirSync(logDir, { recursive: true });
23
+ const logFile = path.join(logDir, Date.now().toString(36) + '.log');
24
+ const out = fs.openSync(logFile, 'a');
25
+ const args = [bin];
26
+ if (sessionId) args.push('resume', sessionId); // 继承对话副本
27
+ args.push('-p', prompt, '--yolo', '--plain'); // 非交互需 --yolo 自动放行
28
+ const child = spawn(process.execPath, args, { cwd, detached: true, stdio: ['ignore', out, out], env: process.env });
29
+ child.unref();
30
+ const rec = { pid: child.pid, prompt: prompt.replace(/\s+/g, ' ').slice(0, 80), cwd, log: logFile, started: Date.now() };
31
+ forks.push(rec);
32
+ return rec;
33
+ }
34
+
35
+ function isAlive(pid) {
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /** 列出本会话派生的后台任务(附存活状态)。 */
45
+ export function listForks() {
46
+ return forks.map((f) => ({ ...f, alive: isAlive(f.pid) }));
47
+ }
48
+
49
+ /** 终止某后台任务(仅限本会话派生的)。 */
50
+ export function killFork(pid) {
51
+ const rec = forks.find((f) => f.pid === pid);
52
+ if (!rec) return { ok: false, reason: '不是本会话派生的任务' };
53
+ try {
54
+ process.kill(pid, 'SIGTERM');
55
+ return { ok: true };
56
+ } catch (e) {
57
+ return { ok: false, reason: e.message };
58
+ }
59
+ }