@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
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|