@huicai/cli 2026.711.0 → 2026.711.24617

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.
Files changed (49) hide show
  1. package/LICENSE +43 -21
  2. package/package.json +4 -3
  3. package/src/advisor.js +1 -46
  4. package/src/agent.js +1 -518
  5. package/src/api.js +1 -359
  6. package/src/autofix.js +1 -90
  7. package/src/banner.js +1 -83
  8. package/src/checkpoints.js +1 -99
  9. package/src/clipboard.js +1 -34
  10. package/src/codeindex.js +1 -229
  11. package/src/colors.js +1 -47
  12. package/src/commands.js +1 -1294
  13. package/src/compaction.js +1 -144
  14. package/src/config.js +1 -86
  15. package/src/consult.js +1 -65
  16. package/src/context-report.js +1 -119
  17. package/src/context.js +1 -152
  18. package/src/cron.js +1 -64
  19. package/src/daemon.js +1 -62
  20. package/src/device.js +1 -88
  21. package/src/diffview.js +1 -115
  22. package/src/doctor.js +1 -72
  23. package/src/executors.js +1 -391
  24. package/src/forks.js +1 -59
  25. package/src/gitflow.js +1 -140
  26. package/src/hooks.js +1 -207
  27. package/src/ide.js +1 -53
  28. package/src/input.js +1 -182
  29. package/src/insights.js +1 -102
  30. package/src/kbsync.js +1 -141
  31. package/src/markdown.js +0 -0
  32. package/src/markup-filter.js +1 -85
  33. package/src/mcp.js +1 -207
  34. package/src/memory.js +1 -161
  35. package/src/permissions.js +1 -120
  36. package/src/render.js +1 -138
  37. package/src/repl.js +1 -271
  38. package/src/scan.js +1 -84
  39. package/src/schedule.js +1 -48
  40. package/src/session.js +1 -140
  41. package/src/settings.js +0 -0
  42. package/src/skills.js +1 -248
  43. package/src/subagent.js +1 -183
  44. package/src/todo.js +1 -75
  45. package/src/tools.js +1 -147
  46. package/src/transcript.js +1 -94
  47. package/src/tui/app.js +1 -666
  48. package/src/usage.js +1 -43
  49. package/src/watch.js +1 -74
package/src/hooks.js CHANGED
@@ -1,207 +1 @@
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
- }
1
+ import{spawnSync as t}from"node:child_process";import{loadRawSettings as o}from"./settings.js";export const HOOK_EVENTS=["SessionStart","UserPromptSubmit","PreToolUse","PostToolUse","Stop"];const r=new Set(["UserPromptSubmit","PreToolUse"]);function e(t){const o=String(t||"").trim();if(!o.startsWith("{"))return null;try{return JSON.parse(o)}catch{return null}}function s(t,o){const r=[];if(!t||"object"!=typeof t)return r;for(const e of HOOK_EVENTS){const s=t[e];if(Array.isArray(s))for(const t of s){const s="string"==typeof t?t:t?.command;"string"==typeof s&&s.trim()&&r.push({event:e,matcher:t&&"string"==typeof t.matcher&&t.matcher.trim()?t.matcher.trim():null,command:s.trim(),timeout:Number(t?.timeout)>0?Number(t.timeout):3e4,source:o})}}return r}export function loadHooks(t){const{global:r,project:e}=o(t),n=(c=r,"1"===process.env.HUICAI_PROJECT_HOOKS||!0===c?.hooks?.trustProject);var c;const i=[...s(r.hooks,"global")],u=s(e.hooks,"project");let m=0;n?i.push(...u):m=u.length;const a=Object.fromEntries(HOOK_EVENTS.map(t=>[t,[]]));for(const t of i)a[t.event].push(t);return{byEvent:a,all:i,projectIgnored:m,projectTrusted:n}}export function hookMatches(t,o){if(!t.matcher)return!0;if(!o)return!0;try{return new RegExp(t.matcher).test(o)}catch{return!1}}function n(t){return(t=String(t||"")).length>8e3?t.slice(0,8e3)+"\n…(钩子输出已截断)":t}function c(o,r,e){const s="win32"===process.platform?t(process.env.COMSPEC||"cmd.exe",["/d","/s","/c",o.command],{cwd:r.HUICAI_CWD,env:r,input:e,encoding:"utf8",timeout:o.timeout,maxBuffer:4194304,windowsVerbatimArguments:!0}):t(process.env.SHELL||"/bin/sh",["-c",o.command],{cwd:r.HUICAI_CWD,env:r,input:e,encoding:"utf8",timeout:o.timeout,maxBuffer:4194304}),c=n(s.stdout),i=n(s.stderr),u=s.error&&"ETIMEDOUT"===s.error.code;return{stdout:c,stderr:i,status:u?124:null==s.status?1:s.status,timedOut:u}}export function runHooks(t,o,s){const n=o.cwd,i=(s?.byEvent?.[t]||loadHooks(n).byEvent[t]||[]).filter(t=>hookMatches(t,o.toolName)),u={block:!1,reason:"",outputs:[],injectContext:"",modifiedArgs:null};if(!i.length)return u;const m={...process.env,HUICAI_EVENT:t,HUICAI_CWD:n||process.cwd(),HUICAI_TOOL_NAME:o.toolName||"",HUICAI_TOOL_PATH:o.args&&null!=o.args.path?String(o.args.path):"",HUICAI_TOOL_ARGS:(()=>{try{return JSON.stringify(o.args||{})}catch{return"{}"}})()},a=(()=>{try{return JSON.stringify(o)}catch{return"{}"}})();for(const o of i){let s;try{s=c(o,m,a)}catch(t){s={stdout:"",stderr:String(t.message||t),status:1,timedOut:!1}}const n=e(s.stdout);if(n){if("string"==typeof n.context&&n.context.trim()&&(u.injectContext+=(u.injectContext?"\n":"")+n.context.trim()),n.modifiedArgs&&"object"==typeof n.modifiedArgs&&(u.modifiedArgs={...u.modifiedArgs||{},...n.modifiedArgs}),"block"===n.decision&&r.has(t)){u.block=!0,u.reason=String(n.reason||"钩子决定阻断该操作");break}continue}if((s.stdout||s.stderr||"").trim()&&u.outputs.push({command:o.command,stdout:s.stdout.trim()||s.stderr.trim()}),0!==s.status&&r.has(t)){u.block=!0,u.reason=s.timedOut?`钩子超时(${o.command})`:s.stderr.trim()||s.stdout.trim()||`钩子以退出码 ${s.status} 阻断(${o.command})`;break}}return u}export function formatHooks(t){const o=[];if(t.all.length)for(const r of HOOK_EVENTS){const e=t.byEvent[r];if(e.length){o.push(`${r}`);for(const t of e){const r=t.matcher?`[${t.matcher}] `:"";o.push(` · ${r}${t.command}${"global"===t.source?"":"(项目)"}`)}}}else o.push('未配置钩子。可在 ~/.huicai/settings.json 或 项目 .huicai/settings.json 的 "hooks" 中添加。');return t.projectIgnored>0&&(o.push(""),o.push(`⚠ 已忽略 ${t.projectIgnored} 条项目级钩子(未信任本项目)。`),o.push(" 如信任本项目,设 HUICAI_PROJECT_HOOKS=1,或全局设置 hooks.trustProject=true。")),o.push(""),o.push("事件:SessionStart(会话开始,注入上下文)· UserPromptSubmit(提交前,注入/阻断)· PreToolUse(工具前,阻断/改参数)· PostToolUse(工具后,采输出)· Stop(回合结束)。"),o.push('结构化控制:钩子可把 JSON 打到 stdout —— {"decision":"block","reason":..} 阻断 / {"context":".."} 注入上下文 / {"modifiedArgs":{..}} 改工具参数。'),o.join("\n")}
package/src/ide.js CHANGED
@@ -1,53 +1 @@
1
- // IDE 桥接客户端:当运行在 huicai VS Code 扩展启动的终端里(扩展经环境变量注入本地服务端口+令牌)时,
2
- // 把 write_file/edit_file 的「拟写入内容」发给扩展做「内联 diff 审阅 + 应用」,而非 CLI 直接落盘。
3
- // 未在 IDE 环境(无 HUICAI_IDE_PORT)则本模块不介入,CLI 照常直接写文件。
4
-
5
- import http from 'node:http';
6
-
7
- const APPLY_TIMEOUT = 120_000; // 用户审阅可能较久
8
-
9
- /** 是否处于 IDE 集成环境(由扩展注入端口)。 */
10
- export function isActive() {
11
- return !!process.env.HUICAI_IDE_PORT;
12
- }
13
-
14
- /**
15
- * 把拟写入内容提交给 IDE 扩展做内联 diff 审阅。
16
- * @returns {Promise<{decided:boolean, applied?:boolean, reason?:string}>}
17
- * decided=false 表示传输失败(扩展未响应)——调用方应回退为直接写盘。
18
- */
19
- export function proposeWrite(absPath, content, cwd) {
20
- return new Promise((resolve) => {
21
- const port = process.env.HUICAI_IDE_PORT;
22
- const token = process.env.HUICAI_IDE_TOKEN || '';
23
- const body = JSON.stringify({ path: absPath, content, cwd, token });
24
- const req = http.request(
25
- {
26
- host: '127.0.0.1',
27
- port,
28
- path: '/apply',
29
- method: 'POST',
30
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
31
- },
32
- (res) => {
33
- let d = '';
34
- res.on('data', (c) => (d += c));
35
- res.on('end', () => {
36
- try {
37
- const j = JSON.parse(d);
38
- resolve({ decided: true, applied: !!j.applied, reason: j.reason });
39
- } catch {
40
- resolve({ decided: false });
41
- }
42
- });
43
- },
44
- );
45
- req.on('error', () => resolve({ decided: false }));
46
- req.setTimeout(APPLY_TIMEOUT, () => {
47
- req.destroy();
48
- resolve({ decided: false });
49
- });
50
- req.write(body);
51
- req.end();
52
- });
53
- }
1
+ import e from"node:http";export function isActive(){return!!process.env.HUICAI_IDE_PORT}export function proposeWrite(t,n,o){return new Promise(r=>{const d=process.env.HUICAI_IDE_PORT,p=process.env.HUICAI_IDE_TOKEN||"",s=JSON.stringify({path:t,content:n,cwd:o,token:p}),i=e.request({host:"127.0.0.1",port:d,path:"/apply",method:"POST",headers:{"Content-Type":"application/json","Content-Length":Buffer.byteLength(s)}},e=>{let t="";e.on("data",e=>t+=e),e.on("end",()=>{try{const e=JSON.parse(t);r({decided:!0,applied:!!e.applied,reason:e.reason})}catch{r({decided:!1})}})});i.on("error",()=>r({decided:!1})),i.setTimeout(12e4,()=>{i.destroy(),r({decided:!1})}),i.write(s),i.end()})}
package/src/input.js CHANGED
@@ -1,182 +1 @@
1
- // 输入预处理与历史:
2
- // - @文件 引用:消息中的 @路径 自动展开为文件内容(附在消息末尾),目录则展开为清单;
3
- // - !命令 直通:以 ! 开头的输入直接在本机执行 shell 命令,输出加入对话上下文(不消耗模型调用);
4
- // - 输入历史:按项目目录持久化到 ~/.huicai/history.json,供 REPL/TUI 上下键翻阅。
5
-
6
- import fs from 'node:fs';
7
- import path from 'node:path';
8
- import os from 'node:os';
9
- import { CONFIG_DIR } from './config.js';
10
- import { runBash } from './executors.js';
11
- import { extractDocument } from './api.js';
12
-
13
- const MAX_MENTIONS = 5;
14
- const MENTION_FILE_CHARS = 12_000;
15
- const HISTORY_PATH = path.join(CONFIG_DIR, 'history.json');
16
- const HISTORY_CAP = 1000;
17
-
18
- /** 图片扩展名 → 作为多模态 image_url 附加。 */
19
- const IMAGE_MIME = {
20
- png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
21
- webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml',
22
- };
23
- /** 文档扩展名 → 经平台 /v1/files/extract 解析为纯文本(与 VSCode 扩展附件同源)。 */
24
- const DOC_EXT = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'odt', 'ods', 'odp', 'csv']);
25
- const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
26
- const MAX_DOC_BYTES = 20 * 1024 * 1024;
27
-
28
- /**
29
- * 展开消息中的 @文件/@目录 引用:原文保留,内容以引用块追加在末尾(模型可见全文)。
30
- * @returns {{text:string, mentions:string[]}}
31
- */
32
- export async function expandAtMentions(text, cwd, config = null) {
33
- const re = /(^|[\s("'`(【,,::])@([~A-Za-z0-9_./\\-]+)/g;
34
- const seen = new Set();
35
- const candidates = [];
36
- let m;
37
- while ((m = re.exec(text)) && candidates.length < MAX_MENTIONS) {
38
- const raw = m[2].replace(/[.,;:。,;]+$/, ''); // 去句尾标点
39
- if (raw && !seen.has(raw)) {
40
- seen.add(raw);
41
- candidates.push(raw);
42
- }
43
- }
44
- const blocks = [];
45
- const images = []; // 多模态图片附件 {label, dataUri}
46
- const mentions = [];
47
- for (const raw of candidates) {
48
- const abs = raw.startsWith('~') ? path.join(os.homedir(), raw.slice(1)) : path.resolve(cwd, raw);
49
- let st;
50
- try {
51
- st = fs.statSync(abs);
52
- } catch {
53
- continue; // 不存在的路径按普通文本处理
54
- }
55
- mentions.push(raw);
56
- if (st.isDirectory()) {
57
- try {
58
- const entries = fs.readdirSync(abs).slice(0, 100).join('\n');
59
- blocks.push(`【引用目录 @${raw}】\n${entries || '(空目录)'}`);
60
- } catch {
61
- /* ignore */
62
- }
63
- continue;
64
- }
65
- const ext = path.extname(abs).slice(1).toLowerCase();
66
- // 图片:作为多模态 image_url 附加(需当前模型支持视觉,或走平台伪模型的服务端识图)
67
- if (IMAGE_MIME[ext]) {
68
- if (st.size > MAX_IMAGE_BYTES) {
69
- blocks.push(`【图片 @${raw}】(超过 ${Math.round(MAX_IMAGE_BYTES / 1048576)}MB,未附加)`);
70
- } else {
71
- try {
72
- const b64 = fs.readFileSync(abs).toString('base64');
73
- images.push({ label: raw, dataUri: `data:${IMAGE_MIME[ext]};base64,${b64}` });
74
- blocks.push(`【已附加图片 @${raw}】`);
75
- } catch {
76
- /* ignore */
77
- }
78
- }
79
- continue;
80
- }
81
- // 文档(PDF/Word/Excel/PPT/RTF…):经平台 /v1/files/extract 解析为纯文本
82
- if (DOC_EXT.has(ext)) {
83
- if (!config || !config.baseUrl || !config.apiKey) {
84
- blocks.push(`【文档 @${raw}】(需登录后才能解析为文本)`);
85
- } else if (st.size > MAX_DOC_BYTES) {
86
- blocks.push(`【文档 @${raw}】(超过 ${Math.round(MAX_DOC_BYTES / 1048576)}MB,未解析)`);
87
- } else {
88
- try {
89
- const b64 = fs.readFileSync(abs).toString('base64');
90
- const r = await extractDocument(config.baseUrl, config.apiKey,
91
- { filename: path.basename(abs), data: b64, maxChars: 60_000 });
92
- const meta = `${r.pages ? r.pages + '页·' : ''}${r.chars ?? 0}字${r.truncated ? '·已截断' : ''}`;
93
- blocks.push(`【引用文档 @${raw}(${meta})】\n${r.text || '(空)'}`);
94
- } catch (e) {
95
- blocks.push(`【文档 @${raw}】(解析失败:${e.message})`);
96
- }
97
- }
98
- continue;
99
- }
100
- // 其余:文本文件展开,二进制跳过
101
- if (st.size > 512 * 1024) {
102
- blocks.push(`【引用文件 @${raw}】\n(文件过大:${Math.round(st.size / 1024)}KB,未展开;可让我用 read_file 分段读取)`);
103
- continue;
104
- }
105
- try {
106
- const buf = fs.readFileSync(abs);
107
- if (isBinary(buf)) {
108
- blocks.push(`【引用文件 @${raw}】\n(二进制文件,未展开)`);
109
- continue;
110
- }
111
- let content = buf.toString('utf8');
112
- if (content.length > MENTION_FILE_CHARS) content = content.slice(0, MENTION_FILE_CHARS) + '\n…(已截断)';
113
- blocks.push(`【引用文件 @${raw}】\n\`\`\`\n${content}\n\`\`\``);
114
- } catch {
115
- /* ignore */
116
- }
117
- }
118
- const outText = blocks.length ? text + '\n\n' + blocks.join('\n\n') : text;
119
- return { text: outText, mentions, images };
120
- }
121
-
122
- function isBinary(buf) {
123
- const n = Math.min(buf.length, 4096);
124
- for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
125
- return false;
126
- }
127
-
128
- /** 组装用户消息 content:有图片附件则用 OpenAI 多模态数组,否则纯文本字符串。 */
129
- export function buildUserContent(text, images) {
130
- if (!images || !images.length) return text;
131
- return [
132
- { type: 'text', text },
133
- ...images.map((i) => ({ type: 'image_url', image_url: { url: i.dataUri } })),
134
- ];
135
- }
136
-
137
- /**
138
- * 执行 !命令 直通:在 cwd 下跑 shell 命令,返回展示文本与写入对话的记录文本。
139
- * @returns {Promise<{output:string, record:string}>}
140
- */
141
- export async function runBangCommand(command, cwd) {
142
- const output = await runBash(cwd, { command });
143
- const capped = output.length > 8000 ? output.slice(0, 8000) + '\n…(已截断)' : output;
144
- return {
145
- output,
146
- record: `我在终端直接执行了命令:$ ${command}\n${capped}`,
147
- };
148
- }
149
-
150
- function readHistoryFile() {
151
- try {
152
- const j = JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf8'));
153
- return Array.isArray(j?.entries) ? j.entries : [];
154
- } catch {
155
- return [];
156
- }
157
- }
158
-
159
- /** 读取某目录的输入历史(旧→新)。 */
160
- export function loadHistory(cwd, limit = 200) {
161
- return readHistoryFile()
162
- .filter((e) => e && e.cwd === cwd && typeof e.line === 'string')
163
- .slice(-limit)
164
- .map((e) => e.line);
165
- }
166
-
167
- /** 追加一条输入历史(连续重复不记)。best-effort,失败静默。 */
168
- export function appendHistory(cwd, line) {
169
- const t = String(line || '').trim();
170
- if (!t) return;
171
- try {
172
- let entries = readHistoryFile();
173
- const last = [...entries].reverse().find((e) => e.cwd === cwd);
174
- if (last && last.line === t) return;
175
- entries.push({ cwd, line: t, ts: Date.now() });
176
- if (entries.length > HISTORY_CAP) entries = entries.slice(-HISTORY_CAP);
177
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
178
- fs.writeFileSync(HISTORY_PATH, JSON.stringify({ entries }), { mode: 0o600 });
179
- } catch {
180
- /* ignore */
181
- }
182
- }
1
+ import e from"node:fs";import t from"node:path";import n from"node:os";import{CONFIG_DIR as r}from"./config.js";import{runBash as i}from"./executors.js";import{extractDocument as s}from"./api.js";const o=t.join(r,"history.json"),a={png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",webp:"image/webp",bmp:"image/bmp",svg:"image/svg+xml"},c=new Set(["pdf","doc","docx","xls","xlsx","ppt","pptx","rtf","odt","ods","odp","csv"]),p=8388608,l=20971520;export async function expandAtMentions(r,i,o=null){const f=/(^|[\s("'`(【,,::])@([~A-Za-z0-9_./\\-]+)/g,m=new Set,d=[];let h;for(;(h=f.exec(r))&&d.length<5;){const e=h[2].replace(/[.,;:。,;]+$/,"");e&&!m.has(e)&&(m.add(e),d.push(e))}const g=[],y=[],$=[];for(const r of d){const f=r.startsWith("~")?t.join(n.homedir(),r.slice(1)):t.resolve(i,r);let m;try{m=e.statSync(f)}catch{continue}if($.push(r),m.isDirectory()){try{const t=e.readdirSync(f).slice(0,100).join("\n");g.push(`【引用目录 @${r}】\n${t||"(空目录)"}`)}catch{}continue}const d=t.extname(f).slice(1).toLowerCase();if(a[d])if(m.size>p)g.push(`【图片 @${r}】(超过 ${Math.round(8)}MB,未附加)`);else try{const t=e.readFileSync(f).toString("base64");y.push({label:r,dataUri:`data:${a[d]};base64,${t}`}),g.push(`【已附加图片 @${r}】`)}catch{}else if(c.has(d))if(o&&o.baseUrl&&o.apiKey)if(m.size>l)g.push(`【文档 @${r}】(超过 ${Math.round(20)}MB,未解析)`);else try{const n=e.readFileSync(f).toString("base64"),i=await s(o.baseUrl,o.apiKey,{filename:t.basename(f),data:n,maxChars:6e4}),a=`${i.pages?i.pages+"页·":""}${i.chars??0}字${i.truncated?"·已截断":""}`;g.push(`【引用文档 @${r}(${a})】\n${i.text||"(空)"}`)}catch(e){g.push(`【文档 @${r}】(解析失败:${e.message})`)}else g.push(`【文档 @${r}】(需登录后才能解析为文本)`);else if(m.size>524288)g.push(`【引用文件 @${r}】\n(文件过大:${Math.round(m.size/1024)}KB,未展开;可让我用 read_file 分段读取)`);else try{const t=e.readFileSync(f);if(u(t)){g.push(`【引用文件 @${r}】\n(二进制文件,未展开)`);continue}let n=t.toString("utf8");n.length>12e3&&(n=n.slice(0,12e3)+"\n…(已截断)"),g.push(`【引用文件 @${r}】\n\`\`\`\n${n}\n\`\`\``)}catch{}}return{text:g.length?r+"\n\n"+g.join("\n\n"):r,mentions:$,images:y}}function u(e){const t=Math.min(e.length,4096);for(let n=0;n<t;n++)if(0===e[n])return!0;return!1}export function buildUserContent(e,t){return t&&t.length?[{type:"text",text:e},...t.map(e=>({type:"image_url",image_url:{url:e.dataUri}}))]:e}export async function runBangCommand(e,t){const n=await i(t,{command:e}),r=n.length>8e3?n.slice(0,8e3)+"\n…(已截断)":n;return{output:n,record:`我在终端直接执行了命令:$ ${e}\n${r}`}}function f(){try{const t=JSON.parse(e.readFileSync(o,"utf8"));return Array.isArray(t?.entries)?t.entries:[]}catch{return[]}}export function loadHistory(e,t=200){return f().filter(t=>t&&t.cwd===e&&"string"==typeof t.line).slice(-t).map(e=>e.line)}export function appendHistory(t,n){const i=String(n||"").trim();if(i)try{let n=f();const s=[...n].reverse().find(e=>e.cwd===t);if(s&&s.line===i)return;n.push({cwd:t,line:i,ts:Date.now()}),n.length>1e3&&(n=n.slice(-1e3)),e.mkdirSync(r,{recursive:!0}),e.writeFileSync(o,JSON.stringify({entries:n}),{mode:384})}catch{}}
package/src/insights.js CHANGED
@@ -1,102 +1 @@
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
- }
1
+ import t from"node:path";import{c as e}from"./colors.js";import{relTime as n}from"./session.js";const s=864e5;function o(t,e){return[...t.entries()].sort((t,e)=>e[1]-t[1]).slice(0,e)}export function computeInsights(e,n={}){const r=n.now||Date.now(),l=Array.isArray(e)?e:[],a=new Map,c=new Map;let u=0,i=0,d=0,m=0,h=null,g=0;for(const e of l){const n=Number(e.turns)||0;u+=n;const o=e.cwd?t.basename(e.cwd)||e.cwd:"(未知)";a.set(o,(a.get(o)||0)+1);const l=e.model||"(未知)";c.set(l,(c.get(l)||0)+1);const p=r-(e.updatedAt||0);p<=s&&i++,p<=7*s&&d++,p<=30*s&&m++,(!h||n>h.turns)&&(h={turns:n,title:e.title||""}),(e.updatedAt||0)>g&&(g=e.updatedAt||0)}const p=new Array(14).fill(0),f=new Date(r);f.setHours(0,0,0,0);for(const t of l){const e=new Date(t.updatedAt||0);e.setHours(0,0,0,0);const n=Math.floor((f.getTime()-e.getTime())/s);n>=0&&n<14&&p[13-n]++}const $=n.cwd?t.basename(n.cwd)||n.cwd:null,w=$?{name:$,sessions:l.filter(t=>t.cwd===n.cwd).length,turns:l.filter(t=>t.cwd===n.cwd).reduce((t,e)=>t+(Number(e.turns)||0),0)}:null;return{total:l.length,totalTurns:u,avgTurns:l.length?u/l.length:0,last24:i,last7:d,last30:m,mostRecent:g,longest:h,byProject:o(a,5),byModel:o(c,5),currentProject:w,daily:p}}export function formatInsights(t){if(!t.total)return"本机还没有历史会话(对话发生后即可 /insights 查看统计)。";const s=[e.bold("会话洞察")+e.gray("(本机 ~/.huicai/sessions)")];return s.push(` 总计 ${t.total} 个会话 · ${t.totalTurns} 轮对话`+(t.mostRecent?` · 最近活跃 ${n(t.mostRecent)}`:"")),s.push(` 近况 24h ${t.last24} 个 · 7天 ${t.last7} 个 · 30天 ${t.last30} 个`),t.daily&&t.daily.some(t=>t>0)&&s.push(` 近14天 ${function(t){const e=Math.max(1,...t);return t.map(t=>0===t?"·":"▁▂▃▄▅▆▇█"[Math.min(7,Math.floor(t/e*7))]).join("")}(t.daily)} ${e.gray("(峰值 "+Math.max(...t.daily)+"/天)")}`),s.push(` 平均 ${t.avgTurns.toFixed(1)} 轮/会话`+(t.longest?` · 最长 ${t.longest.turns} 轮${t.longest.title?"("+t.longest.title.slice(0,24)+")":""}`:"")),t.byProject.length&&s.push(" 按项目 "+t.byProject.map(([t,e])=>`${t} ${e}`).join(" · ")),t.byModel.length&&s.push(" 按模型 "+t.byModel.map(([t,e])=>`${t.length>20?t.slice(0,20)+"…":t} ${e}`).join(" · ")),t.currentProject&&s.push(e.gray(` 当前项目 ${t.currentProject.name}:${t.currentProject.sessions} 个会话 · ${t.currentProject.turns} 轮`)),s.join("\n")}