@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.
@@ -0,0 +1,94 @@
1
+ // 对话转录:导出为 Markdown(/export)与一句话小结(/recap)。
2
+ // recap 直连原始模型(伪模型会触发平台编排+计费,故回退 DEFAULT_MODEL),不写入对话。
3
+
4
+ import { streamChat } from './api.js';
5
+ import { DEFAULT_MODEL } from './config.js';
6
+
7
+ const PSEUDO = /^(agent|workflow|datahuman)-/;
8
+
9
+ /** 提取消息文本(字符串或多模态数组)。 */
10
+ export function textOf(content) {
11
+ if (typeof content === 'string') return content;
12
+ if (Array.isArray(content)) {
13
+ return content.map((p) => (p?.type === 'text' ? p.text : p?.type === 'image_url' ? '[图片]' : '')).join('\n');
14
+ }
15
+ return '';
16
+ }
17
+
18
+ /** 会话 → Markdown 文本(跳过 system;工具调用/结果折叠展示)。 */
19
+ export function toMarkdown(session) {
20
+ const s = session || {};
21
+ const lines = [
22
+ '# HuiCai 对话记录',
23
+ '',
24
+ `- 会话:${s.id || ''}`,
25
+ `- 模型:${s.model || ''}`,
26
+ `- 导出时间:${new Date().toLocaleString()}`,
27
+ '',
28
+ '---',
29
+ '',
30
+ ];
31
+ for (const m of s.messages || []) {
32
+ if (m.role === 'system') continue;
33
+ if (m.role === 'user') {
34
+ lines.push('### 🧑 用户', '', textOf(m.content).trim() || '(空)', '');
35
+ } else if (m.role === 'assistant') {
36
+ const text = textOf(m.content).trim();
37
+ if (text) lines.push('### 🤖 助手', '', text, '');
38
+ for (const tc of m.tool_calls || []) {
39
+ const argsStr = String(tc.function?.arguments || '').replace(/\s+/g, ' ').slice(0, 200);
40
+ lines.push(`> 🔧 \`${tc.function?.name}(${argsStr})\``, '');
41
+ }
42
+ } else if (m.role === 'tool') {
43
+ const out = String(m.content || '').slice(0, 1500);
44
+ lines.push('> 工具结果:', '', '```', out, '```', '');
45
+ }
46
+ }
47
+ return lines.join('\n');
48
+ }
49
+
50
+ /** 从对话提炼「值得跨会话长期记住」的项目知识(直连原始模型)。返回事实数组(可能为空)。 */
51
+ export async function distillMemory(messages, config, existingMemory = '') {
52
+ const model = PSEUDO.test(String(config.model || '')) ? (config.compactModel || DEFAULT_MODEL) : config.model;
53
+ const transcript = (messages || [])
54
+ .filter((m) => m.role !== 'system')
55
+ .map((m) => `[${m.role}] ${textOf(m.content).replace(/\s+/g, ' ').slice(0, 600)}`)
56
+ .join('\n')
57
+ .slice(0, 16000);
58
+ if (!transcript.trim()) return [];
59
+ const sys = '你是项目知识提炼器。从下面这段对话/执行记录中,提炼出**值得跨会话长期记住**的稳定事实/约定/结论/踩坑'
60
+ + '(如构建与测试命令、架构决定、代码约定、易错点、外部依赖要点)。要求:每条一行,以「- 」开头,简洁具体;'
61
+ + '只输出真正长期有用的,不要一次性细节、不要大段代码;若与「已有记忆」重复则跳过;最多 5 条;没有则输出「(无)」。';
62
+ const user = (existingMemory ? '【已有记忆(勿重复)】\n' + existingMemory + '\n\n' : '') + '【本次记录】\n' + transcript;
63
+ const r = await streamChat({
64
+ baseUrl: config.baseUrl, apiKey: config.apiKey, model,
65
+ messages: [{ role: 'system', content: sys }, { role: 'user', content: user }],
66
+ });
67
+ const text = (r.content || '').trim();
68
+ if (!text || /^[((]无[))]$/.test(text)) return [];
69
+ return text.split('\n')
70
+ .map((l) => l.replace(/^[-*]\s*/, '').trim())
71
+ .filter((l) => l && !/^[((]无[))]$/.test(l))
72
+ .slice(0, 5);
73
+ }
74
+
75
+ /** 一句话小结当前对话(直连原始模型;不落对话)。返回一行文本。 */
76
+ export async function recapConversation(messages, config) {
77
+ const model = PSEUDO.test(String(config.model || '')) ? (config.compactModel || DEFAULT_MODEL) : config.model;
78
+ const transcript = (messages || [])
79
+ .filter((m) => m.role !== 'system')
80
+ .map((m) => `[${m.role}] ${textOf(m.content).replace(/\s+/g, ' ').slice(0, 500)}`)
81
+ .join('\n')
82
+ .slice(0, 12000);
83
+ if (!transcript.trim()) return '(对话尚无内容)';
84
+ const r = await streamChat({
85
+ baseUrl: config.baseUrl,
86
+ apiKey: config.apiKey,
87
+ model,
88
+ messages: [
89
+ { role: 'system', content: '用一句话(不超过40个汉字)概括这段对话到目前为止在做什么、进展如何。只输出这一句话,不要引号、不要多余说明。' },
90
+ { role: 'user', content: transcript },
91
+ ],
92
+ });
93
+ return (r.content || '').trim().split('\n')[0] || '(无法生成小结)';
94
+ }
package/src/tui/app.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // 仅在 TTY 且已安装 ink 时启用;未安装时 bin 回退到零依赖 readline REPL。
12
12
 
13
13
  import React, { useState, useRef, useReducer, useEffect, useMemo } from 'react';
14
+ import { execSync } from 'node:child_process';
14
15
  import { render, Box, Text, Static, useInput, useApp } from 'ink';
15
16
  import { runTurn } from '../agent.js';
16
17
  import { fetchUsage } from '../api.js';
@@ -18,12 +19,15 @@ import { toolLabel } from '../render.js';
18
19
  import { renderMarkdown, looksLikeMarkdown } from '../markdown.js';
19
20
  import { renderWelcome } from '../banner.js';
20
21
  import { createSession, saveSession } from '../session.js';
22
+ import { createCheckpoints } from '../checkpoints.js';
21
23
  import { buildExtraTools } from '../repl.js';
22
24
  import { closeMcp } from '../mcp.js';
23
25
  import { runSlash, listCommands } from '../commands.js';
24
26
  import { usageStatus, fmtReset } from '../usage.js';
25
27
  import { expandAtMentions, buildUserContent, runBangCommand, loadHistory, appendHistory } from '../input.js';
26
- import { loadAllowRules, matchAllowRules, suggestRule, addProjectAllowRule, riskWarning } from '../settings.js';
28
+ import { loadAllowRules, loadDenyRules, matchAllowRules, suggestRule, addProjectAllowRule, riskWarning } from '../settings.js';
29
+ import { PLAN_DENY_REASON, SANDBOX_DENY_REASON } from '../permissions.js';
30
+ import { runVerifyLoop } from '../autofix.js';
27
31
 
28
32
  const h = React.createElement;
29
33
 
@@ -55,6 +59,10 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
55
59
  const pendingRef = useRef(null); // 授权中:{name,args,resolve}
56
60
  const sessionAllow = useRef(new Set());
57
61
  const rulesRef = useRef(loadAllowRules(cwd)); // settings.json 允许规则
62
+ const denyRef = useRef(loadDenyRules(cwd)); // settings.json 拒绝规则
63
+ const planRef = useRef(false); // 计划模式(只读门控)
64
+ const sandboxRef = useRef(false); // 沙箱模式(禁 shell+联网)
65
+ const checkpointsRef = useRef(createCheckpoints()); // /rewind 检查点
58
66
  const yoloRef = useRef(!!yolo);
59
67
  const startedRef = useRef(false);
60
68
  const statusRef = useRef('idle');
@@ -76,6 +84,9 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
76
84
  const [ctxTok, setCtxTok] = useState(0);
77
85
  const [todos, setTodos] = useState([]);
78
86
  const [queueLen, setQueueLen] = useState(0);
87
+ const [planOn, setPlanOn] = useState(false); // 计划模式(底栏指示)
88
+ const [statusLineOut, setStatusLineOut] = useState(''); // /statusline 自定义底栏输出
89
+ const loopRef = useRef(null); // /loop 定时器
79
90
  const [spin, setSpin] = useState(0);
80
91
  const [win, setWin] = useState(null); // Token 用量窗口(5小时/7天)最新快照
81
92
  const winRef = useRef(null); // 同上,供异步路径同步读取(发起回合前的用尽拦截)
@@ -125,6 +136,30 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
125
136
  // eslint-disable-next-line react-hooks/exhaustive-deps
126
137
  }, []);
127
138
 
139
+ // /statusline:定期运行自定义命令,其 stdout 首行显示在底栏
140
+ useEffect(() => {
141
+ if (!config.statusLine) {
142
+ setStatusLineOut('');
143
+ return undefined;
144
+ }
145
+ const run = () => {
146
+ try {
147
+ const out = execSync(config.statusLine, { cwd, timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] })
148
+ .toString().split('\n')[0].slice(0, 80);
149
+ setStatusLineOut(out);
150
+ } catch {
151
+ setStatusLineOut('');
152
+ }
153
+ };
154
+ run();
155
+ const t = setInterval(run, 5000);
156
+ return () => clearInterval(t);
157
+ // eslint-disable-next-line react-hooks/exhaustive-deps
158
+ }, [config.statusLine]);
159
+
160
+ // /loop 定时器卸载清理
161
+ useEffect(() => () => { if (loopRef.current) clearInterval(loopRef.current); }, []);
162
+
128
163
  const thinkRef = useRef(''); // 思考过程(reasoning)流式缓冲,仅动态区展示不入定稿记录
129
164
  const events = {
130
165
  onDelta: (t) => {
@@ -155,9 +190,18 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
155
190
  },
156
191
  };
157
192
 
158
- // ink 版权限:只读/yolo/本会话/settings 规则放行;否则弹提示等待按键(y/a/A/n)
193
+ // ink 版权限:deny 优先拦截 → 计划模式拦写/执行 → 只读/yolo/本会话/settings 规则放行;否则弹提示等待按键(y/a/A/n)
159
194
  const permissions = {
160
195
  request: (name, args, _cwd, readOnly) => {
196
+ if (denyRef.current.length && matchAllowRules(denyRef.current, name, args)) {
197
+ return Promise.resolve({ allow: false, reason: '该操作被拒绝规则阻止(/permissions 查看或移除)。' });
198
+ }
199
+ if (planRef.current && !readOnly) {
200
+ return Promise.resolve({ allow: false, reason: PLAN_DENY_REASON });
201
+ }
202
+ if (sandboxRef.current && (name === 'run_bash' || name === 'fetch_url')) {
203
+ return Promise.resolve({ allow: false, reason: SANDBOX_DENY_REASON });
204
+ }
161
205
  if (readOnly || yoloRef.current || sessionAllow.current.has(name)
162
206
  || matchAllowRules(rulesRef.current, name, args)) {
163
207
  return Promise.resolve({ allow: true });
@@ -171,6 +215,19 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
171
215
  setYolo: (v) => {
172
216
  yoloRef.current = !!v;
173
217
  },
218
+ setPlanMode: (v) => {
219
+ planRef.current = !!v;
220
+ setPlanOn(!!v);
221
+ },
222
+ isPlanMode: () => planRef.current,
223
+ setSandbox: (v) => {
224
+ sandboxRef.current = !!v;
225
+ },
226
+ isSandbox: () => sandboxRef.current,
227
+ reloadRules: () => {
228
+ rulesRef.current = loadAllowRules(cwd);
229
+ denyRef.current = loadDenyRules(cwd);
230
+ },
174
231
  };
175
232
 
176
233
  const pullQueued = () => {
@@ -188,6 +245,8 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
188
245
  return;
189
246
  }
190
247
  const { text: expanded, images } = await expandAtMentions(text, cwd, config);
248
+ // 回合前记录检查点(供 /rewind),journal 由 runTurn 累积本回合文件原文
249
+ const journal = checkpointsRef.current.snapshot({ session: sessionRef.current, label: text });
191
250
  sessionRef.current.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
192
251
  setBusy(true);
193
252
  abortRef.current = new AbortController();
@@ -203,6 +262,7 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
203
262
  todoState: todoStateRef.current,
204
263
  compaction: sessionRef.current.compaction,
205
264
  pullQueued,
265
+ fileJournal: journal,
206
266
  });
207
267
  abortRef.current = null;
208
268
  flushLive();
@@ -219,6 +279,7 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
219
279
  }
220
280
  setBusy(false);
221
281
  void refreshUsage(); // 回合结束刷新用量窗口(含被 429 用尽的情形 → 更新右下角提示 + 拦截下一回合)
282
+ return r;
222
283
  };
223
284
 
224
285
  const doExit = () => {
@@ -235,6 +296,7 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
235
296
  permissions,
236
297
  extraTools,
237
298
  session: sessionRef.current,
299
+ checkpoints: checkpointsRef.current,
238
300
  print: (t) => addLog('info', String(t)),
239
301
  };
240
302
  const r = await runSlash(line, ctx);
@@ -242,6 +304,7 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
242
304
  if (r.action === 'clear') {
243
305
  sessionRef.current = createSession({ cwd, model: config.model, messages: [sessionRef.current.messages[0]] });
244
306
  todoStateRef.current.list = [];
307
+ checkpointsRef.current.list.length = 0;
245
308
  setTodos([]);
246
309
  setCtxTok(0);
247
310
  logRef.current = [];
@@ -249,6 +312,42 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
249
312
  setStaticKey((k) => k + 1); // 重挂 Static:复位其内部已渲染索引,新会话记录从头正常渲染
250
313
  addLog('info', '已开启新会话(旧会话仍可 --resume 恢复)');
251
314
  }
315
+ if (r.action === 'refresh') {
316
+ // /rewind 回退对话:会话已被截断,复位派生的动态状态(记录条已由命令打印,保留在滚动区)
317
+ todoStateRef.current.list = [];
318
+ setTodos([]);
319
+ setCtxTok(0);
320
+ }
321
+ if (r.action === 'autofix') {
322
+ await runVerifyLoop({
323
+ command: r.command,
324
+ maxIters: r.maxIters,
325
+ cwd,
326
+ runTurnOnce: (p) => runUserTurn(p),
327
+ emit: (t) => addLog('info', t),
328
+ });
329
+ }
330
+ if (r.action === 'cd') {
331
+ // 富 TUI 内切换目录需重建整套渲染上下文,代价高:引导到普通模式
332
+ addLog('info', '富 TUI 暂不支持 /cd 切换目录;请用 huicai --plain(普通模式)使用,或退出后到目标目录重开。');
333
+ }
334
+ if (r.action === 'loop') {
335
+ if (loopRef.current) {
336
+ clearInterval(loopRef.current);
337
+ loopRef.current = null;
338
+ }
339
+ if (r.stop) {
340
+ addLog('info', '已停止循环。');
341
+ } else {
342
+ addLog('info', `已开始循环:每 ${r.seconds}s 运行「${r.prompt}」(/loop off 停止)`);
343
+ loopRef.current = setInterval(() => {
344
+ if (statusRef.current !== 'busy') {
345
+ addLog('user', r.prompt);
346
+ void runUserTurn(r.prompt);
347
+ }
348
+ }, r.seconds * 1000);
349
+ }
350
+ }
252
351
  if (r.prompt) {
253
352
  if (statusRef.current === 'busy') {
254
353
  queueRef.current.push(r.prompt);
@@ -519,7 +618,8 @@ export function App({ config, cwd, session: initialSession, extraTools, yolo, in
519
618
  Box,
520
619
  { justifyContent: 'space-between' },
521
620
  h(Text, { color: 'gray' },
522
- `${cwdName} · ${config.model}${yoloRef.current ? ' · ⚠ yolo' : ''}`
621
+ (statusLineOut ? statusLineOut + ' · ' : '')
622
+ + `${cwdName} · ${config.model}${yoloRef.current ? ' · ⚠ yolo' : ''}${planOn ? ' · 📋 计划' : ''}`
523
623
  + (queueLen ? ` · 排队 ${queueLen}` : '')
524
624
  + ' · /help'),
525
625
  uWarn ? h(Text, { color: uStat.level === 'exhausted' ? 'red' : 'yellow', bold: true }, uWarn) : h(Text, null, ''),
package/src/watch.js ADDED
@@ -0,0 +1,74 @@
1
+ // huicai watch:监听工作目录文件变更,防抖后派生一个智能体任务(响应式自动化)。
2
+ // 例:保存代码即自动跑测试/审查。默认任务可自定义(-p),文件名可用 glob 过滤(--glob "*.js")。
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { huicaiBin } from './forks.js';
8
+
9
+ const IGNORE = new Set([
10
+ '.git', 'node_modules', 'dist', 'build', 'target', 'out', '.next', '.nuxt',
11
+ '.venv', 'venv', '__pycache__', '.idea', '.gradle', 'coverage', '.cache',
12
+ ]);
13
+
14
+ function patToRegex(pattern) {
15
+ return new RegExp('^' + String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
16
+ }
17
+
18
+ /**
19
+ * 启动文件监听。
20
+ * @param {{cwd:string, prompt:string, pattern?:string, debounceMs?:number, log?:(s:string)=>void, spawnImpl?:Function}} p
21
+ */
22
+ export async function runWatch({ cwd, prompt, pattern = null, debounceMs = 1500, log = (s) => process.stdout.write(s + '\n'), spawnImpl }) {
23
+ const bin = huicaiBin();
24
+ if (!bin && !spawnImpl) throw new Error('无法定位 huicai 可执行文件(watch 需在 CLI 环境运行)');
25
+ const re = pattern ? patToRegex(pattern) : null;
26
+ let timer = null;
27
+ let running = false;
28
+ const changed = new Set();
29
+
30
+ const doSpawn = spawnImpl || ((fullPrompt) => {
31
+ const child = spawn(process.execPath, [bin, '-p', fullPrompt, '--yolo', '--plain'], { cwd, stdio: 'inherit', env: process.env });
32
+ return child;
33
+ });
34
+
35
+ const trigger = () => {
36
+ if (running) return;
37
+ running = true;
38
+ const files = [...changed];
39
+ changed.clear();
40
+ log(`\n▶ 变更 ${files.length} 文件 → 触发智能体:${prompt}`);
41
+ const fullPrompt = prompt + (files.length ? `\n(刚变更的文件:${files.slice(0, 20).join(', ')})` : '');
42
+ const child = doSpawn(fullPrompt);
43
+ const done = () => {
44
+ running = false;
45
+ if (changed.size) schedule();
46
+ };
47
+ if (child && child.on) child.on('close', done);
48
+ else done();
49
+ };
50
+ const schedule = () => {
51
+ if (timer) clearTimeout(timer);
52
+ timer = setTimeout(trigger, debounceMs);
53
+ };
54
+
55
+ const watcher = fs.watch(cwd, { recursive: true }, (event, filename) => {
56
+ if (!filename) return;
57
+ const rel = String(filename);
58
+ const top = rel.split(path.sep)[0];
59
+ if (IGNORE.has(top)) return;
60
+ if (re && !re.test(path.basename(rel))) return;
61
+ changed.add(rel);
62
+ schedule();
63
+ });
64
+
65
+ log(`huicai 监听已启动:${cwd}${pattern ? ` (${pattern})` : ''} → 变更即跑「${prompt}」· Ctrl-C 退出`);
66
+ await new Promise((resolve) => {
67
+ process.on('SIGINT', () => {
68
+ watcher.close();
69
+ if (timer) clearTimeout(timer);
70
+ log('\n监听已停止。');
71
+ resolve();
72
+ });
73
+ });
74
+ }