@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/src/gitflow.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Git/PR 全自动化:暂存改动 → LLM 生成规范 commit 信息 → 若在主分支先建分支 → 提交 → push → gh 开 PR。
|
|
2
|
+
// 每步优雅降级:非 git 仓库/无 remote/未装 gh 都给清晰结果而非报错。绝不 force-push、绝不直接提交到 main。
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import { streamChat } from './api.js';
|
|
9
|
+
import { DEFAULT_MODEL } from './config.js';
|
|
10
|
+
|
|
11
|
+
const PSEUDO = /^(agent|workflow|datahuman)-/;
|
|
12
|
+
const DEFAULT_BRANCHES = new Set(['main', 'master', 'develop', 'dev']);
|
|
13
|
+
|
|
14
|
+
function git(cwd, args) {
|
|
15
|
+
return execSync('git ' + args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
16
|
+
}
|
|
17
|
+
function gitQuiet(cwd, args) {
|
|
18
|
+
try {
|
|
19
|
+
return git(cwd, args);
|
|
20
|
+
} catch {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 仓库状态:是否 git 仓库、当前分支、是否有改动、是否有 remote、是否装了 gh。 */
|
|
26
|
+
export function gitInfo(cwd) {
|
|
27
|
+
const info = { isRepo: false, branch: '', dirty: false, hasRemote: false, hasGh: false };
|
|
28
|
+
try {
|
|
29
|
+
info.isRepo = git(cwd, 'rev-parse --is-inside-work-tree').trim() === 'true';
|
|
30
|
+
} catch {
|
|
31
|
+
return info;
|
|
32
|
+
}
|
|
33
|
+
info.branch = gitQuiet(cwd, 'rev-parse --abbrev-ref HEAD').trim();
|
|
34
|
+
info.dirty = gitQuiet(cwd, 'status --porcelain').trim().length > 0;
|
|
35
|
+
info.hasRemote = gitQuiet(cwd, 'remote').trim().length > 0;
|
|
36
|
+
try {
|
|
37
|
+
execSync('gh --version', { stdio: 'ignore' });
|
|
38
|
+
info.hasGh = true;
|
|
39
|
+
} catch { /* gh 未安装 */ }
|
|
40
|
+
return info;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 由 git diff 生成一条 Conventional Commits 提交信息(直连原始模型,避开伪模型编排)。 */
|
|
44
|
+
export async function generateCommitMessage(cwd, config) {
|
|
45
|
+
let diff = gitQuiet(cwd, 'diff --staged');
|
|
46
|
+
if (!diff.trim()) diff = gitQuiet(cwd, 'diff');
|
|
47
|
+
diff = diff.slice(0, 12000);
|
|
48
|
+
if (!diff.trim()) return 'chore: update';
|
|
49
|
+
const model = PSEUDO.test(String(config.model || '')) ? (config.compactModel || DEFAULT_MODEL) : config.model;
|
|
50
|
+
try {
|
|
51
|
+
const r = await streamChat({
|
|
52
|
+
baseUrl: config.baseUrl, apiKey: config.apiKey, model,
|
|
53
|
+
messages: [
|
|
54
|
+
{ role: 'system', content: '你是提交信息生成器。根据 git diff 生成一条规范的 Conventional Commits 提交信息:'
|
|
55
|
+
+ '第一行 `<type>(<scope>): <简述>`(简述≤50字,type 用 feat/fix/refactor/docs/test/chore/perf/style 之一);'
|
|
56
|
+
+ '改动较大可空行后加 2-4 条正文要点。只输出提交信息本身,不要解释、不要代码块围栏。' },
|
|
57
|
+
{ role: 'user', content: diff },
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
const msg = (r.content || '').trim().replace(/^```[a-z]*\n?|\n?```$/g, '').trim();
|
|
61
|
+
return msg || 'chore: update';
|
|
62
|
+
} catch {
|
|
63
|
+
return 'chore: update';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function slug(msg) {
|
|
68
|
+
return String(msg).split('\n')[0]
|
|
69
|
+
.replace(/^[a-z]+(\([^)]*\))?!?:\s*/i, '')
|
|
70
|
+
.replace(/[^a-zA-Z0-9一-鿿]+/g, '-')
|
|
71
|
+
.replace(/^-+|-+$/g, '')
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.slice(0, 32) || 'change';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 执行 PR 全流程。
|
|
78
|
+
* @param {{cwd, config, emit?:(t:string)=>void, push?:boolean, openPr?:boolean}} p
|
|
79
|
+
* @returns {Promise<{ok, error?, branch?, pr?, note?, committed?, pushed?}>}
|
|
80
|
+
*/
|
|
81
|
+
export async function runPrFlow({ cwd, config, emit = () => {}, push = true, openPr = true }) {
|
|
82
|
+
const info = gitInfo(cwd);
|
|
83
|
+
if (!info.isRepo) return { ok: false, error: '当前目录不是 git 仓库' };
|
|
84
|
+
const onDefault = DEFAULT_BRANCHES.has(info.branch);
|
|
85
|
+
|
|
86
|
+
if (info.dirty) {
|
|
87
|
+
git(cwd, 'add -A');
|
|
88
|
+
emit('已暂存全部改动');
|
|
89
|
+
}
|
|
90
|
+
const stagedStat = gitQuiet(cwd, 'diff --staged --stat').trim();
|
|
91
|
+
|
|
92
|
+
let msg = '';
|
|
93
|
+
if (stagedStat) {
|
|
94
|
+
emit('正在生成提交信息…');
|
|
95
|
+
msg = await generateCommitMessage(cwd, config);
|
|
96
|
+
emit('提交信息:' + msg.split('\n')[0]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 在主分支上:先建分支,绝不直接提交到 main/master
|
|
100
|
+
let target = info.branch;
|
|
101
|
+
if (onDefault) {
|
|
102
|
+
target = 'huicai/' + slug(msg || 'change') + '-' + Date.now().toString(36).slice(-4);
|
|
103
|
+
git(cwd, `checkout -b ${target}`);
|
|
104
|
+
emit('已从 ' + info.branch + ' 创建分支 ' + target);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let committed = false;
|
|
108
|
+
if (stagedStat) {
|
|
109
|
+
const f = path.join(os.tmpdir(), 'huicai-commit-' + Date.now() + '.txt');
|
|
110
|
+
fs.writeFileSync(f, msg);
|
|
111
|
+
try {
|
|
112
|
+
git(cwd, `commit -F "${f}"`);
|
|
113
|
+
committed = true;
|
|
114
|
+
emit('已提交');
|
|
115
|
+
} finally {
|
|
116
|
+
try { fs.unlinkSync(f); } catch { /* ignore */ }
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
emit('无新暂存改动,跳过提交');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!push) return { ok: true, branch: target, committed };
|
|
123
|
+
if (!info.hasRemote) return { ok: true, branch: target, committed, note: '无 remote,未推送' };
|
|
124
|
+
try {
|
|
125
|
+
git(cwd, `push -u origin ${target}`);
|
|
126
|
+
emit('已推送 origin/' + target);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return { ok: false, error: 'push 失败:' + String(e.message).split('\n')[0], branch: target };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!openPr) return { ok: true, branch: target, committed, pushed: true };
|
|
132
|
+
if (!info.hasGh) return { ok: true, branch: target, committed, pushed: true, note: '未装 gh CLI,PR 请手动开' };
|
|
133
|
+
try {
|
|
134
|
+
const out = execSync('gh pr create --fill', { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
135
|
+
emit('已开 PR:' + out);
|
|
136
|
+
return { ok: true, branch: target, committed, pr: out };
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return { ok: true, branch: target, committed, pushed: true, note: 'gh pr create 失败(可手动开):' + String(e.message).split('\n')[0] };
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// 工具事件钩子(对标 Claude Code hooks):在关键节点跑用户自定义 shell 命令。
|
|
2
|
+
// 配置在 settings.json(全局 ~/.huicai + 项目 .huicai):
|
|
3
|
+
// { "hooks": {
|
|
4
|
+
// "UserPromptSubmit": [{ "command": "..." }],
|
|
5
|
+
// "PreToolUse": [{ "matcher": "write_file|edit_file", "command": "..." }],
|
|
6
|
+
// "PostToolUse": [{ "matcher": "run_bash", "command": "npm test" }],
|
|
7
|
+
// "Stop": [{ "command": "..." }]
|
|
8
|
+
// } }
|
|
9
|
+
// matcher 为工具名正则(仅 Pre/PostToolUse 有意义;缺省匹配全部工具)。
|
|
10
|
+
// 命令通过环境变量 + stdin(JSON) 拿到上下文:
|
|
11
|
+
// HUICAI_EVENT / HUICAI_CWD / HUICAI_TOOL_NAME / HUICAI_TOOL_PATH / HUICAI_TOOL_ARGS(JSON)。
|
|
12
|
+
// 阻断语义:PreToolUse / UserPromptSubmit 的钩子以「非零退出码」表示阻断(stderr/stdout 作为原因回灌);
|
|
13
|
+
// PostToolUse / Stop / SessionStart 不阻断,仅采集输出(stdout 会附加到工具结果/提示,供模型看到)。
|
|
14
|
+
// 结构化控制(进阶):钩子若把 JSON 打到 stdout,则按结构化决策处理(优先于退出码):
|
|
15
|
+
// {"decision":"block","reason":"..."} 阻断该操作(仅阻断类事件)
|
|
16
|
+
// {"context":"..."} 把文本作为附加上下文注入模型
|
|
17
|
+
// {"modifiedArgs":{...}} (PreToolUse) 覆盖该工具的调用参数
|
|
18
|
+
// 多个字段可同时出现。非 JSON 输出仍按「纯文本 + 退出码」旧语义处理(向后兼容)。
|
|
19
|
+
// SessionStart 事件:本会话首个用户回合前触发一次,常用于注入项目上下文(context 字段)。
|
|
20
|
+
//
|
|
21
|
+
// 安全:全局设置里的钩子始终生效(用户本机配置,可信);项目 .huicai/settings.json 里的钩子默认**不执行**
|
|
22
|
+
// (克隆来的仓库可能植入恶意命令),需显式信任:环境变量 HUICAI_PROJECT_HOOKS=1,
|
|
23
|
+
// 或全局设置 { "hooks": { "trustProject": true } }。/hooks 会提示被忽略的项目钩子数量。
|
|
24
|
+
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import { loadRawSettings } from './settings.js';
|
|
27
|
+
|
|
28
|
+
export const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'];
|
|
29
|
+
const BLOCKING_EVENTS = new Set(['UserPromptSubmit', 'PreToolUse']);
|
|
30
|
+
|
|
31
|
+
/** 解析钩子 stdout 为结构化控制 JSON(非 JSON 返回 null)。 */
|
|
32
|
+
function parseHookJson(stdout) {
|
|
33
|
+
const t = String(stdout || '').trim();
|
|
34
|
+
if (!t.startsWith('{')) return null;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(t);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
42
|
+
const MAX_HOOK_OUTPUT = 8_000;
|
|
43
|
+
|
|
44
|
+
/** 项目级钩子是否被信任执行。 */
|
|
45
|
+
function projectHooksTrusted(globalSettings) {
|
|
46
|
+
if (process.env.HUICAI_PROJECT_HOOKS === '1') return true;
|
|
47
|
+
return globalSettings?.hooks?.trustProject === true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeHooks(rawHooks, source) {
|
|
51
|
+
const out = [];
|
|
52
|
+
if (!rawHooks || typeof rawHooks !== 'object') return out;
|
|
53
|
+
for (const event of HOOK_EVENTS) {
|
|
54
|
+
const arr = rawHooks[event];
|
|
55
|
+
if (!Array.isArray(arr)) continue;
|
|
56
|
+
for (const h of arr) {
|
|
57
|
+
const command = typeof h === 'string' ? h : h?.command;
|
|
58
|
+
if (typeof command !== 'string' || !command.trim()) continue;
|
|
59
|
+
out.push({
|
|
60
|
+
event,
|
|
61
|
+
matcher: h && typeof h.matcher === 'string' && h.matcher.trim() ? h.matcher.trim() : null,
|
|
62
|
+
command: command.trim(),
|
|
63
|
+
timeout: Number(h?.timeout) > 0 ? Number(h.timeout) : DEFAULT_TIMEOUT,
|
|
64
|
+
source,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 加载生效钩子。
|
|
73
|
+
* @returns {{ byEvent: Record<string, object[]>, all: object[], projectIgnored: number, projectTrusted: boolean }}
|
|
74
|
+
*/
|
|
75
|
+
export function loadHooks(cwd) {
|
|
76
|
+
const { global, project } = loadRawSettings(cwd);
|
|
77
|
+
const trusted = projectHooksTrusted(global);
|
|
78
|
+
const all = [...normalizeHooks(global.hooks, 'global')];
|
|
79
|
+
const projectHooks = normalizeHooks(project.hooks, 'project');
|
|
80
|
+
let projectIgnored = 0;
|
|
81
|
+
if (trusted) all.push(...projectHooks);
|
|
82
|
+
else projectIgnored = projectHooks.length;
|
|
83
|
+
|
|
84
|
+
const byEvent = Object.fromEntries(HOOK_EVENTS.map((e) => [e, []]));
|
|
85
|
+
for (const h of all) byEvent[h.event].push(h);
|
|
86
|
+
return { byEvent, all, projectIgnored, projectTrusted: trusted };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 该钩子是否匹配此工具名(无 matcher 或非工具事件恒真)。 */
|
|
90
|
+
export function hookMatches(hook, toolName) {
|
|
91
|
+
if (!hook.matcher) return true;
|
|
92
|
+
if (!toolName) return true;
|
|
93
|
+
try {
|
|
94
|
+
return new RegExp(hook.matcher).test(toolName);
|
|
95
|
+
} catch {
|
|
96
|
+
return false; // 非法正则:视为不匹配,避免误伤全部
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function clip(s) {
|
|
101
|
+
s = String(s || '');
|
|
102
|
+
return s.length > MAX_HOOK_OUTPUT ? s.slice(0, MAX_HOOK_OUTPUT) + '\n…(钩子输出已截断)' : s;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function runOne(hook, env, input) {
|
|
106
|
+
const isWin = process.platform === 'win32';
|
|
107
|
+
const r = isWin
|
|
108
|
+
? spawnSync(process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', hook.command],
|
|
109
|
+
{ cwd: env.HUICAI_CWD, env, input, encoding: 'utf8', timeout: hook.timeout, maxBuffer: 4 * 1024 * 1024, windowsVerbatimArguments: true })
|
|
110
|
+
: spawnSync(process.env.SHELL || '/bin/sh', ['-c', hook.command],
|
|
111
|
+
{ cwd: env.HUICAI_CWD, env, input, encoding: 'utf8', timeout: hook.timeout, maxBuffer: 4 * 1024 * 1024 });
|
|
112
|
+
const stdout = clip(r.stdout);
|
|
113
|
+
const stderr = clip(r.stderr);
|
|
114
|
+
const timedOut = r.error && r.error.code === 'ETIMEDOUT';
|
|
115
|
+
const status = timedOut ? 124 : (r.status == null ? 1 : r.status);
|
|
116
|
+
return { stdout, stderr, status, timedOut };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 触发某事件的全部匹配钩子(同步、有序)。
|
|
121
|
+
* @param {string} event
|
|
122
|
+
* @param {{cwd:string, toolName?:string, args?:object, prompt?:string, result?:string}} payload
|
|
123
|
+
* @param {{byEvent?:object}} [loaded] 预加载的钩子(省略则按 cwd 现读)
|
|
124
|
+
* @returns {{ block:boolean, reason:string, outputs:{command:string,stdout:string}[] }}
|
|
125
|
+
*/
|
|
126
|
+
export function runHooks(event, payload, loaded) {
|
|
127
|
+
const cwd = payload.cwd;
|
|
128
|
+
const hooks = (loaded?.byEvent?.[event]) || loadHooks(cwd).byEvent[event] || [];
|
|
129
|
+
const matching = hooks.filter((h) => hookMatches(h, payload.toolName));
|
|
130
|
+
const result = { block: false, reason: '', outputs: [], injectContext: '', modifiedArgs: null };
|
|
131
|
+
if (!matching.length) return result;
|
|
132
|
+
|
|
133
|
+
const env = {
|
|
134
|
+
...process.env,
|
|
135
|
+
HUICAI_EVENT: event,
|
|
136
|
+
HUICAI_CWD: cwd || process.cwd(),
|
|
137
|
+
HUICAI_TOOL_NAME: payload.toolName || '',
|
|
138
|
+
HUICAI_TOOL_PATH: payload.args && payload.args.path != null ? String(payload.args.path) : '',
|
|
139
|
+
HUICAI_TOOL_ARGS: (() => { try { return JSON.stringify(payload.args || {}); } catch { return '{}'; } })(),
|
|
140
|
+
};
|
|
141
|
+
const input = (() => { try { return JSON.stringify(payload); } catch { return '{}'; } })();
|
|
142
|
+
|
|
143
|
+
for (const hook of matching) {
|
|
144
|
+
let r;
|
|
145
|
+
try {
|
|
146
|
+
r = runOne(hook, env, input);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
r = { stdout: '', stderr: String(e.message || e), status: 1, timedOut: false };
|
|
149
|
+
}
|
|
150
|
+
const json = parseHookJson(r.stdout);
|
|
151
|
+
if (json) {
|
|
152
|
+
// 结构化控制(JSON 输出优先于退出码)
|
|
153
|
+
if (typeof json.context === 'string' && json.context.trim()) {
|
|
154
|
+
result.injectContext += (result.injectContext ? '\n' : '') + json.context.trim();
|
|
155
|
+
}
|
|
156
|
+
if (json.modifiedArgs && typeof json.modifiedArgs === 'object') {
|
|
157
|
+
result.modifiedArgs = { ...(result.modifiedArgs || {}), ...json.modifiedArgs };
|
|
158
|
+
}
|
|
159
|
+
if (json.decision === 'block' && BLOCKING_EVENTS.has(event)) {
|
|
160
|
+
result.block = true;
|
|
161
|
+
result.reason = String(json.reason || '钩子决定阻断该操作');
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
continue; // JSON 钩子不把原文当普通输出
|
|
165
|
+
}
|
|
166
|
+
// 纯文本钩子(向后兼容):输出采集 + 非零退出码阻断
|
|
167
|
+
const text = (r.stdout || r.stderr || '').trim();
|
|
168
|
+
if (text) result.outputs.push({ command: hook.command, stdout: r.stdout.trim() || r.stderr.trim() });
|
|
169
|
+
if (r.status !== 0 && BLOCKING_EVENTS.has(event)) {
|
|
170
|
+
result.block = true;
|
|
171
|
+
result.reason = (r.timedOut ? `钩子超时(${hook.command})` : (r.stderr.trim() || r.stdout.trim()
|
|
172
|
+
|| `钩子以退出码 ${r.status} 阻断(${hook.command})`));
|
|
173
|
+
break; // 一旦阻断即停止后续钩子
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** /hooks 列表展示:把已加载钩子渲染为可读文本。 */
|
|
180
|
+
export function formatHooks(loaded) {
|
|
181
|
+
const lines = [];
|
|
182
|
+
const total = loaded.all.length;
|
|
183
|
+
if (!total) {
|
|
184
|
+
lines.push('未配置钩子。可在 ~/.huicai/settings.json 或 项目 .huicai/settings.json 的 "hooks" 中添加。');
|
|
185
|
+
} else {
|
|
186
|
+
for (const event of HOOK_EVENTS) {
|
|
187
|
+
const arr = loaded.byEvent[event];
|
|
188
|
+
if (!arr.length) continue;
|
|
189
|
+
lines.push(`${event}`);
|
|
190
|
+
for (const h of arr) {
|
|
191
|
+
const m = h.matcher ? `[${h.matcher}] ` : '';
|
|
192
|
+
lines.push(` · ${m}${h.command}${h.source === 'global' ? '' : '(项目)'}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (loaded.projectIgnored > 0) {
|
|
197
|
+
lines.push('');
|
|
198
|
+
lines.push(`⚠ 已忽略 ${loaded.projectIgnored} 条项目级钩子(未信任本项目)。`);
|
|
199
|
+
lines.push(' 如信任本项目,设 HUICAI_PROJECT_HOOKS=1,或全局设置 hooks.trustProject=true。');
|
|
200
|
+
}
|
|
201
|
+
lines.push('');
|
|
202
|
+
lines.push('事件:SessionStart(会话开始,注入上下文)· UserPromptSubmit(提交前,注入/阻断)'
|
|
203
|
+
+ '· PreToolUse(工具前,阻断/改参数)· PostToolUse(工具后,采输出)· Stop(回合结束)。');
|
|
204
|
+
lines.push('结构化控制:钩子可把 JSON 打到 stdout —— {"decision":"block","reason":..} 阻断 / '
|
|
205
|
+
+ '{"context":".."} 注入上下文 / {"modifiedArgs":{..}} 改工具参数。');
|
|
206
|
+
return lines.join('\n');
|
|
207
|
+
}
|
package/src/insights.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// /insights:从本机会话(~/.huicai/sessions)聚合使用洞察——数量/轮数/活跃度/按项目·模型分布。
|
|
2
|
+
// 纯元数据统计(listSessions 已给出 turns/model/cwd/updatedAt),不再逐个读全文。
|
|
3
|
+
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { c } from './colors.js';
|
|
6
|
+
import { relTime } from './session.js';
|
|
7
|
+
|
|
8
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
function topN(counts, n) {
|
|
11
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, n);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 由会话列表计算洞察数据。@param {{cwd?:string, now?:number}} [opts] */
|
|
15
|
+
export function computeInsights(sessions, opts = {}) {
|
|
16
|
+
const now = opts.now || Date.now();
|
|
17
|
+
const list = Array.isArray(sessions) ? sessions : [];
|
|
18
|
+
const byProject = new Map();
|
|
19
|
+
const byModel = new Map();
|
|
20
|
+
let totalTurns = 0;
|
|
21
|
+
let last24 = 0;
|
|
22
|
+
let last7 = 0;
|
|
23
|
+
let last30 = 0;
|
|
24
|
+
let longest = null;
|
|
25
|
+
let mostRecent = 0;
|
|
26
|
+
|
|
27
|
+
for (const s of list) {
|
|
28
|
+
const turns = Number(s.turns) || 0;
|
|
29
|
+
totalTurns += turns;
|
|
30
|
+
const proj = s.cwd ? (path.basename(s.cwd) || s.cwd) : '(未知)';
|
|
31
|
+
byProject.set(proj, (byProject.get(proj) || 0) + 1);
|
|
32
|
+
const model = s.model || '(未知)';
|
|
33
|
+
byModel.set(model, (byModel.get(model) || 0) + 1);
|
|
34
|
+
const age = now - (s.updatedAt || 0);
|
|
35
|
+
if (age <= DAY) last24++;
|
|
36
|
+
if (age <= 7 * DAY) last7++;
|
|
37
|
+
if (age <= 30 * DAY) last30++;
|
|
38
|
+
if (!longest || turns > longest.turns) longest = { turns, title: s.title || '' };
|
|
39
|
+
if ((s.updatedAt || 0) > mostRecent) mostRecent = s.updatedAt || 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 近 14 天每日活跃(会话更新计数)
|
|
43
|
+
const DAYS = 14;
|
|
44
|
+
const daily = new Array(DAYS).fill(0);
|
|
45
|
+
const startToday = new Date(now);
|
|
46
|
+
startToday.setHours(0, 0, 0, 0);
|
|
47
|
+
for (const s of list) {
|
|
48
|
+
const su = new Date(s.updatedAt || 0);
|
|
49
|
+
su.setHours(0, 0, 0, 0);
|
|
50
|
+
const ago = Math.floor((startToday.getTime() - su.getTime()) / DAY);
|
|
51
|
+
if (ago >= 0 && ago < DAYS) daily[DAYS - 1 - ago]++;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const cwdProj = opts.cwd ? (path.basename(opts.cwd) || opts.cwd) : null;
|
|
55
|
+
const currentProject = cwdProj
|
|
56
|
+
? { name: cwdProj, sessions: list.filter((s) => s.cwd === opts.cwd).length,
|
|
57
|
+
turns: list.filter((s) => s.cwd === opts.cwd).reduce((n, s) => n + (Number(s.turns) || 0), 0) }
|
|
58
|
+
: null;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
total: list.length,
|
|
62
|
+
totalTurns,
|
|
63
|
+
avgTurns: list.length ? totalTurns / list.length : 0,
|
|
64
|
+
last24, last7, last30,
|
|
65
|
+
mostRecent,
|
|
66
|
+
longest,
|
|
67
|
+
byProject: topN(byProject, 5),
|
|
68
|
+
byModel: topN(byModel, 5),
|
|
69
|
+
currentProject,
|
|
70
|
+
daily,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SPARK = '▁▂▃▄▅▆▇█';
|
|
75
|
+
function sparkline(arr) {
|
|
76
|
+
const max = Math.max(1, ...arr);
|
|
77
|
+
return arr.map((v) => (v === 0 ? '·' : SPARK[Math.min(7, Math.floor((v / max) * 7))])).join('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 渲染洞察报告文本。 */
|
|
81
|
+
export function formatInsights(d) {
|
|
82
|
+
if (!d.total) return '本机还没有历史会话(对话发生后即可 /insights 查看统计)。';
|
|
83
|
+
const lines = [c.bold('会话洞察') + c.gray('(本机 ~/.huicai/sessions)')];
|
|
84
|
+
lines.push(` 总计 ${d.total} 个会话 · ${d.totalTurns} 轮对话`
|
|
85
|
+
+ (d.mostRecent ? ` · 最近活跃 ${relTime(d.mostRecent)}` : ''));
|
|
86
|
+
lines.push(` 近况 24h ${d.last24} 个 · 7天 ${d.last7} 个 · 30天 ${d.last30} 个`);
|
|
87
|
+
if (d.daily && d.daily.some((x) => x > 0)) {
|
|
88
|
+
lines.push(` 近14天 ${sparkline(d.daily)} ${c.gray('(峰值 ' + Math.max(...d.daily) + '/天)')}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push(` 平均 ${d.avgTurns.toFixed(1)} 轮/会话`
|
|
91
|
+
+ (d.longest ? ` · 最长 ${d.longest.turns} 轮${d.longest.title ? '(' + d.longest.title.slice(0, 24) + ')' : ''}` : ''));
|
|
92
|
+
if (d.byProject.length) {
|
|
93
|
+
lines.push(' 按项目 ' + d.byProject.map(([k, v]) => `${k} ${v}`).join(' · '));
|
|
94
|
+
}
|
|
95
|
+
if (d.byModel.length) {
|
|
96
|
+
lines.push(' 按模型 ' + d.byModel.map(([k, v]) => `${k.length > 20 ? k.slice(0, 20) + '…' : k} ${v}`).join(' · '));
|
|
97
|
+
}
|
|
98
|
+
if (d.currentProject) {
|
|
99
|
+
lines.push(c.gray(` 当前项目 ${d.currentProject.name}:${d.currentProject.sessions} 个会话 · ${d.currentProject.turns} 轮`));
|
|
100
|
+
}
|
|
101
|
+
return lines.join('\n');
|
|
102
|
+
}
|
package/src/kbsync.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// huicai kb-sync:配合 git diff 检测变更文件,增量同步到平台知识库,让 RAG 随代码保鲜。
|
|
2
|
+
// 检测策略:默认对比工作区与 HEAD(已跟踪改动 + 未跟踪新文件);给定 ref 则 git diff --name-status <ref>。
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { syncKnowledge } from './api.js';
|
|
7
|
+
import { IGNORE, MAX_FILE_BYTES } from './codeindex.js';
|
|
8
|
+
|
|
9
|
+
const BATCH = 20; // 每批提交的文件数(服务端逐文件分块向量化,分批避免单请求过大/超时)
|
|
10
|
+
|
|
11
|
+
function git(cwd, args) {
|
|
12
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isGitRepo(cwd) {
|
|
16
|
+
try {
|
|
17
|
+
git(cwd, ['rev-parse', '--is-inside-work-tree']);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 路径是否落在被忽略目录下(node_modules/.git/dist…),避免把依赖/产物灌进知识库。 */
|
|
25
|
+
function inIgnoredDir(rel) {
|
|
26
|
+
return rel.split('/').some((seg) => IGNORE.has(seg));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 空字节嗅探:判定二进制(不入库)。 */
|
|
30
|
+
function isBinary(buf) {
|
|
31
|
+
const n = Math.min(buf.length, 4096);
|
|
32
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 用 git 检测变更文件。
|
|
38
|
+
* - 指定 base(ref/范围,如 origin/main、HEAD~3、abc123):git diff --name-status <base>
|
|
39
|
+
* - 不指定:工作区相对 HEAD 的改动(已跟踪 A/M/D/R)+ 未跟踪新文件(视为新增)
|
|
40
|
+
* @returns {{changed:string[], deleted:string[]}}
|
|
41
|
+
*/
|
|
42
|
+
export function detectChanges(cwd, base) {
|
|
43
|
+
const changed = new Set();
|
|
44
|
+
const deleted = new Set();
|
|
45
|
+
const parse = (out) => {
|
|
46
|
+
for (const line of out.split('\n')) {
|
|
47
|
+
if (!line.trim()) continue;
|
|
48
|
+
const parts = line.split('\t');
|
|
49
|
+
const code = parts[0][0];
|
|
50
|
+
if (code === 'D') {
|
|
51
|
+
deleted.add(parts[1]);
|
|
52
|
+
} else if (code === 'R' || code === 'C') {
|
|
53
|
+
// Rxxx old new:重命名——旧路径删档、新路径入档(复制 C 只入新档)
|
|
54
|
+
const oldP = parts[1];
|
|
55
|
+
const newP = parts[2] || parts[1];
|
|
56
|
+
if (code === 'R') deleted.add(oldP);
|
|
57
|
+
changed.add(newP);
|
|
58
|
+
} else {
|
|
59
|
+
// A/M/T…:取末列为路径
|
|
60
|
+
changed.add(parts[parts.length - 1]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
if (base) {
|
|
65
|
+
parse(git(cwd, ['diff', '--name-status', base]));
|
|
66
|
+
} else {
|
|
67
|
+
parse(git(cwd, ['diff', '--name-status', 'HEAD']));
|
|
68
|
+
const others = git(cwd, ['ls-files', '--others', '--exclude-standard']);
|
|
69
|
+
for (const p of others.split('\n')) if (p.trim()) changed.add(p.trim());
|
|
70
|
+
}
|
|
71
|
+
// 删除后又重新出现的以变更为准
|
|
72
|
+
for (const p of changed) deleted.delete(p);
|
|
73
|
+
return { changed: [...changed], deleted: [...deleted] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 把检测到的变更组装为同步条目(读取内容、过滤忽略目录/二进制/超大文件)。
|
|
78
|
+
* @returns {{files:Array<{path,content?,deleted?}>, skipped:number, skips:string[]}}
|
|
79
|
+
*/
|
|
80
|
+
export function buildEntries(cwd, changed, deleted, emit = () => {}) {
|
|
81
|
+
const files = [];
|
|
82
|
+
let skipped = 0;
|
|
83
|
+
for (const rel of changed) {
|
|
84
|
+
if (inIgnoredDir(rel)) { skipped++; continue; }
|
|
85
|
+
const abs = path.join(cwd, rel);
|
|
86
|
+
let st;
|
|
87
|
+
try {
|
|
88
|
+
st = fs.statSync(abs);
|
|
89
|
+
} catch {
|
|
90
|
+
skipped++; // 已不在(diff 后又被删)
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!st.isFile()) { skipped++; continue; }
|
|
94
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
95
|
+
emit(`跳过(过大 ${(st.size / 1024).toFixed(0)}KB):${rel}`);
|
|
96
|
+
skipped++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const buf = fs.readFileSync(abs);
|
|
100
|
+
if (isBinary(buf)) { skipped++; continue; }
|
|
101
|
+
files.push({ path: rel, content: buf.toString('utf8'), deleted: false });
|
|
102
|
+
}
|
|
103
|
+
for (const rel of deleted) {
|
|
104
|
+
if (inIgnoredDir(rel)) continue;
|
|
105
|
+
files.push({ path: rel, deleted: true });
|
|
106
|
+
}
|
|
107
|
+
return { files, skipped };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 执行 kb-sync:检测变更 → 组装条目 → 分批调用平台增量同步。
|
|
112
|
+
* @param {{cwd:string, config:{baseUrl:string,apiKey:string}, kbId:string|number, base?:string, dry?:boolean, emit?:(t:string)=>void}} opts
|
|
113
|
+
* @returns {Promise<{ok:boolean, empty?:boolean, dry?:boolean, added:number, removed:number, skipped:number, accepted?:number, message:string, preview?:any[]}>}
|
|
114
|
+
*/
|
|
115
|
+
export async function runKbSync({ cwd, config, kbId, base, dry = false, emit = () => {} }) {
|
|
116
|
+
if (!isGitRepo(cwd)) throw new Error('当前目录不是 git 仓库;kb-sync 依赖 git diff 检测变更');
|
|
117
|
+
const { changed, deleted } = detectChanges(cwd, base);
|
|
118
|
+
const { files, skipped } = buildEntries(cwd, changed, deleted, emit);
|
|
119
|
+
|
|
120
|
+
const added = files.filter((f) => !f.deleted).length;
|
|
121
|
+
const removed = files.filter((f) => f.deleted).length;
|
|
122
|
+
if (!files.length) {
|
|
123
|
+
return { ok: true, empty: true, added: 0, removed: 0, skipped, message: base ? `相对 ${base} 无可同步的变更文件` : '工作区无可同步的变更文件' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
emit(`检测到 ${added} 个新增/修改、${removed} 个删除${skipped ? `(跳过 ${skipped})` : ''}${base ? `(相对 ${base})` : ''}`);
|
|
127
|
+
if (dry) {
|
|
128
|
+
return { ok: true, dry: true, added, removed, skipped, message: '试运行(未提交)', preview: files.map((f) => ({ path: f.path, deleted: !!f.deleted })) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let accepted = 0;
|
|
132
|
+
const msgs = [];
|
|
133
|
+
for (let i = 0; i < files.length; i += BATCH) {
|
|
134
|
+
const batch = files.slice(i, i + BATCH);
|
|
135
|
+
if (files.length > BATCH) emit(`同步中… ${Math.min(i + BATCH, files.length)}/${files.length}`);
|
|
136
|
+
const r = await syncKnowledge(config.baseUrl, config.apiKey, kbId, batch);
|
|
137
|
+
accepted += r?.accepted || 0;
|
|
138
|
+
if (r?.message) msgs.push(r.message);
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, added, removed, skipped, accepted, message: msgs.join(';') || '同步完成' };
|
|
141
|
+
}
|
package/src/memory.js
CHANGED
|
@@ -7,6 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
9
|
import { CONFIG_DIR } from './config.js';
|
|
10
|
+
import { tokenize } from './codeindex.js';
|
|
10
11
|
|
|
11
12
|
export const MEMORY_DIR = path.join(CONFIG_DIR, 'memory');
|
|
12
13
|
const MAX_MEMORY_CHARS = 6000; // 注入系统提示词的上限(超出保留最新条目)
|
|
@@ -69,6 +70,56 @@ export function clearMemory(cwd) {
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/** 读取完整长期记忆原文(不截断),供 recall_memory 按相关性检索。 */
|
|
74
|
+
export function readMemoryRaw(cwd) {
|
|
75
|
+
try {
|
|
76
|
+
return fs.readFileSync(memoryPath(cwd), 'utf8');
|
|
77
|
+
} catch {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 构造 recall_memory 工具(跨会话经验主动召回):在本项目长期记忆里按关键词检索相关条目,
|
|
84
|
+
* 连超出常驻注入预算(6k)的历史也能查到。CJK 感知分词(复用 codeindex.tokenize)。
|
|
85
|
+
*/
|
|
86
|
+
export function makeRecallTool({ cwd }) {
|
|
87
|
+
const spec = {
|
|
88
|
+
type: 'function',
|
|
89
|
+
function: {
|
|
90
|
+
name: 'recall_memory',
|
|
91
|
+
description:
|
|
92
|
+
'在本项目的长期记忆里按关键词检索相关的过往结论/踩坑/约定(连超出常驻注入预算的历史也能查到)。'
|
|
93
|
+
+ '开始一个任务前,若怀疑「以前处理过类似问题」,可先 recall 一下,少走弯路。',
|
|
94
|
+
parameters: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: { query: { type: 'string', description: '关键词/概念' } },
|
|
97
|
+
required: ['query'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const run = async (args) => {
|
|
102
|
+
const q = String(args?.query || '').trim();
|
|
103
|
+
if (!q) return '缺少 query';
|
|
104
|
+
const entries = readMemoryRaw(cwd).split('\n').map((l) => l.trim()).filter((l) => l.startsWith('- '));
|
|
105
|
+
if (!entries.length) return '本项目暂无可检索的长期记忆。';
|
|
106
|
+
const qt = new Set(tokenize(q));
|
|
107
|
+
if (!qt.size) return '查询词无有效 token。';
|
|
108
|
+
const scored = entries
|
|
109
|
+
.map((e) => {
|
|
110
|
+
let s = 0;
|
|
111
|
+
for (const t of new Set(tokenize(e))) if (qt.has(t)) s++;
|
|
112
|
+
return { e, s };
|
|
113
|
+
})
|
|
114
|
+
.filter((x) => x.s > 0)
|
|
115
|
+
.sort((a, b) => b.s - a.s)
|
|
116
|
+
.slice(0, 6);
|
|
117
|
+
if (!scored.length) return `未在长期记忆中找到与「${q}」相关的条目。`;
|
|
118
|
+
return '相关的历史记忆:\n' + scored.map((x) => x.e).join('\n');
|
|
119
|
+
};
|
|
120
|
+
return { readOnly: true, spec, run };
|
|
121
|
+
}
|
|
122
|
+
|
|
72
123
|
/**
|
|
73
124
|
* 构造 memory_write 内置工具(顶层注入;记忆是机制本身,自动放行)。
|
|
74
125
|
* @param {{cwd:string, emit?:(text:string)=>void}} p
|