@huicai/cli 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/colors.js ADDED
@@ -0,0 +1,22 @@
1
+ // 极简 ANSI 颜色工具(零依赖)。尊重 NO_COLOR 与非 TTY(管道/重定向时自动关闭颜色)。
2
+
3
+ const enabled = process.env.NO_COLOR == null && process.env.TERM !== 'dumb' && process.stdout.isTTY;
4
+
5
+ const wrap = (code) => (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : String(s));
6
+
7
+ export const c = {
8
+ reset: '\x1b[0m',
9
+ bold: wrap('1'),
10
+ dim: wrap('2'),
11
+ italic: wrap('3'),
12
+ underline: wrap('4'),
13
+ red: wrap('31'),
14
+ green: wrap('32'),
15
+ yellow: wrap('33'),
16
+ blue: wrap('34'),
17
+ magenta: wrap('35'),
18
+ cyan: wrap('36'),
19
+ gray: wrap('90'),
20
+ };
21
+
22
+ export const colorEnabled = enabled;
@@ -0,0 +1,266 @@
1
+ // 斜杠命令注册表:REPL 与 ink TUI 共用同一套实现(消除两处各自为政的命令清单)。
2
+ // 另支持自定义命令:
3
+ // <cwd>/.huicai/commands/*.md 与 ~/.huicai/commands/*.md,文件名即命令名,
4
+ // 内容为提示词模板,$ARGUMENTS 占位符替换为命令参数;可选 frontmatter 提供 description。
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import { execSync } from 'node:child_process';
10
+ import { saveConfig } from './config.js';
11
+ import { listModels } from './api.js';
12
+ import { listSessions, relTime, turnCount } from './session.js';
13
+ import { compactNow } from './compaction.js';
14
+ import { loadAllowRules } from './settings.js';
15
+
16
+ /** /init 的智能体式提示词:让模型实际调研项目后生成/完善 HUICAI.md(而非套静态模板)。 */
17
+ export const INIT_PROMPT =
18
+ '请为当前项目生成或完善 HUICAI.md(给终端智能体看的项目说明):'
19
+ + '先用 list_files/glob/read_file 快速调研项目(技术栈、目录结构、构建/测试/运行命令、代码约定),'
20
+ + '再用 write_file 创建或更新 HUICAI.md(若已存在则保留人工撰写的内容,增量完善)。'
21
+ + '内容包含:项目简介、关键目录、常用命令(构建/测试/运行)、代码风格与注意事项。'
22
+ + '控制在 100 行以内,用中文。';
23
+
24
+ /**
25
+ * 内置命令表。run(ctx, arg) 返回:
26
+ * undefined —— 已处理(纯输出)
27
+ * {action:'exit'} —— 退出
28
+ * {action:'clear'} —— 调用方开启新会话
29
+ * {prompt:string} —— 作为用户消息发起一个回合
30
+ * ctx:{version, config, cwd, usage, permissions, extraTools, session, setSession, print}
31
+ */
32
+ const BUILTINS = [
33
+ {
34
+ name: 'help', desc: '显示可用命令(含自定义命令)',
35
+ run(ctx) {
36
+ const rows = BUILTINS.map((b) => [`/${b.name}${b.args ? ' ' + b.args : ''}`, b.desc]);
37
+ for (const c of loadCustomCommands(ctx.cwd)) rows.push([`/${c.name}`, `${c.desc}(自定义)`]);
38
+ rows.push(['! <命令>', '直接执行 shell 命令并把输出加入上下文']);
39
+ rows.push(['@<文件路径>', '在消息中引用文件内容(自动展开给模型)']);
40
+ const w = Math.min(24, Math.max(...rows.map(([k]) => k.length)) + 2);
41
+ ctx.print(rows.map(([k, v]) => ' ' + k.padEnd(w) + v).join('\n'));
42
+ },
43
+ },
44
+ {
45
+ name: 'model', args: '[名称]', desc: '查看或切换模型(带参数切换并保存)',
46
+ run(ctx, arg) {
47
+ if (arg) {
48
+ ctx.config.model = arg;
49
+ saveConfig({ model: arg });
50
+ ctx.print('已切换模型:' + arg);
51
+ } else {
52
+ ctx.print('当前模型:' + (ctx.config.model || '(未设置)'));
53
+ }
54
+ },
55
+ },
56
+ {
57
+ name: 'models', desc: '列出平台可用模型',
58
+ async run(ctx) {
59
+ try {
60
+ const m = await listModels(ctx.config.baseUrl, ctx.config.apiKey);
61
+ ctx.print(m.map((x) => ' ' + x.id).join('\n') || '(无)');
62
+ } catch (e) {
63
+ ctx.print('拉取失败:' + e.message);
64
+ }
65
+ },
66
+ },
67
+ {
68
+ name: 'clear', desc: '开启新会话(旧会话仍可 --resume 恢复)',
69
+ run() {
70
+ return { action: 'clear' };
71
+ },
72
+ },
73
+ {
74
+ name: 'compact', desc: '立即压缩上下文(摘要独立存储,不影响页面与会话原文)',
75
+ async run(ctx) {
76
+ if (!ctx.session.compaction) ctx.session.compaction = {};
77
+ const n = await compactNow(ctx.session.messages, ctx.config, { state: ctx.session.compaction });
78
+ ctx.print(n > 0
79
+ ? `已压缩:${n} 条较早消息已摘要(原对话完整保留,仅发给模型的上下文变小)`
80
+ : '暂无可压缩的中段历史(会话较短或摘要失败)');
81
+ },
82
+ },
83
+ {
84
+ name: 'memory', args: '[clear]', desc: '查看本项目长期记忆(clear 清空)',
85
+ async run(ctx, arg) {
86
+ const { loadMemory, clearMemory, memoryPath } = await import('./memory.js');
87
+ if (arg === 'clear') {
88
+ ctx.print(clearMemory(ctx.cwd) ? '已清空本项目长期记忆。' : '本项目暂无长期记忆。');
89
+ return;
90
+ }
91
+ const mem = loadMemory(ctx.cwd);
92
+ ctx.print(mem
93
+ ? mem + `\n(文件:${memoryPath(ctx.cwd)} · /memory clear 清空)`
94
+ : '本项目暂无长期记忆(模型会经 memory_write 自动记录值得跨会话记住的信息;配置 memory:false 可关闭)。');
95
+ },
96
+ },
97
+ {
98
+ name: 'cost', desc: '查看本次运行累计 token 用量',
99
+ run(ctx) {
100
+ ctx.print(`token 用量:输入 ${ctx.usage.prompt} / 输出 ${ctx.usage.completion}`
101
+ + (ctx.session.lastPromptTokens ? ` · 当前上下文 ~${ctx.session.lastPromptTokens} tok` : ''));
102
+ },
103
+ },
104
+ {
105
+ name: 'status', desc: '显示运行状态(版本/模型/目录/会话/MCP/权限规则)',
106
+ run(ctx) {
107
+ const s = ctx.session;
108
+ const mcpNames = [...new Set(
109
+ (ctx.extraTools || [])
110
+ .map((t) => t.spec?.function?.name || '')
111
+ .filter((n) => n.startsWith('mcp__'))
112
+ .map((n) => n.split('__')[1]),
113
+ )];
114
+ let branch = '';
115
+ try {
116
+ branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: ctx.cwd, stdio: ['ignore', 'pipe', 'ignore'] })
117
+ .toString().trim();
118
+ } catch { /* 非 git 仓库 */ }
119
+ const rules = loadAllowRules(ctx.cwd);
120
+ const lines = [
121
+ `版本 huicai ${ctx.version || ''}`,
122
+ `模型 ${ctx.config.model}`,
123
+ `平台 ${ctx.config.baseUrl}`,
124
+ `目录 ${ctx.cwd}${branch ? `(git:${branch})` : ''}`,
125
+ `会话 ${s.id} · ${turnCount(s.messages)} 轮${s.lastPromptTokens ? ` · 上下文 ~${s.lastPromptTokens} tok` : ''}`,
126
+ `token 输入 ${ctx.usage.prompt} / 输出 ${ctx.usage.completion}`,
127
+ `审批 ${ctx.permissions?.isYolo?.() ? '⚠ yolo 自动放行' : '写/执行需授权'} · 允许规则 ${rules.length} 条`,
128
+ `MCP ${mcpNames.length ? mcpNames.join(', ') : '(未配置)'}`,
129
+ ];
130
+ ctx.print(lines.join('\n'));
131
+ },
132
+ },
133
+ {
134
+ name: 'sessions', desc: '列出当前目录的历史会话',
135
+ run(ctx) {
136
+ const list = listSessions(ctx.cwd);
137
+ if (!list.length) {
138
+ ctx.print('该目录暂无历史会话。');
139
+ return;
140
+ }
141
+ const lines = list.slice(0, 15).map((s) => {
142
+ const mark = s.id === ctx.session.id ? ' ← 当前' : '';
143
+ return ` ${relTime(s.updatedAt).padStart(8)} ${s.title} ${s.turns}轮 · ${s.id}${mark}`;
144
+ });
145
+ lines.push('(退出后 huicai --resume 可选择恢复)');
146
+ ctx.print(lines.join('\n'));
147
+ },
148
+ },
149
+ {
150
+ name: 'yolo', desc: '切换「自动放行写/执行」模式',
151
+ run(ctx) {
152
+ const on = !ctx.permissions.isYolo();
153
+ ctx.permissions.setYolo(on);
154
+ ctx.print('自动放行(yolo):' + (on ? '开(谨慎)' : '关'));
155
+ },
156
+ },
157
+ {
158
+ name: 'cwd', desc: '显示当前工作目录',
159
+ run(ctx) {
160
+ ctx.print(ctx.cwd);
161
+ },
162
+ },
163
+ {
164
+ name: 'init', desc: '让智能体调研项目并生成/完善 HUICAI.md',
165
+ run() {
166
+ return { prompt: INIT_PROMPT };
167
+ },
168
+ },
169
+ {
170
+ name: 'skills', desc: '列出本地技能(Agent Skills;pull/push 用终端命令 huicai skills)',
171
+ async run(ctx) {
172
+ const { loadLocalSkills } = await import('./skills.js');
173
+ const skills = loadLocalSkills(ctx.cwd);
174
+ if (!skills.length) {
175
+ ctx.print('暂无本地技能(.huicai/skills 或 ~/.huicai/skills)。可用 huicai skills pull 从平台拉取。');
176
+ return;
177
+ }
178
+ ctx.print(skills.map((s) =>
179
+ ` ${s.name}${s.version ? ' v' + s.version : ''}(${s.scope === 'project' ? '项目' : '全局'}`
180
+ + `${s.files.length ? ` · ${s.files.length} 附件` : ''})${s.description ? ' ' + s.description.slice(0, 60) : ''}`,
181
+ ).join('\n') + '\n(模型会按描述经 load_skill 按需载入;本会话启动时已注入目录)');
182
+ },
183
+ },
184
+ {
185
+ name: 'mcp', desc: '列出已连接的 MCP 工具',
186
+ run(ctx) {
187
+ const mcpTools = (ctx.extraTools || []).filter((t) => (t.spec?.function?.name || '').startsWith('mcp__'));
188
+ if (!mcpTools.length) {
189
+ ctx.print('未连接 MCP 服务。配置 ~/.huicai/mcp.json(通用 mcpServers 格式)后重启生效。');
190
+ return;
191
+ }
192
+ ctx.print(mcpTools
193
+ .map((t) => ` ${t.spec.function.name}${t.readOnly ? '(只读)' : ''}`)
194
+ .join('\n'));
195
+ },
196
+ },
197
+ {
198
+ name: 'exit', desc: '退出',
199
+ run() {
200
+ return { action: 'exit' };
201
+ },
202
+ },
203
+ ];
204
+
205
+ /** 读取自定义命令:项目 .huicai/commands/*.md 优先于全局同名。 */
206
+ export function loadCustomCommands(cwd) {
207
+ const dirs = [
208
+ cwd ? path.join(cwd, '.huicai', 'commands') : null,
209
+ path.join(os.homedir(), '.huicai', 'commands'),
210
+ ].filter(Boolean);
211
+ const byName = new Map();
212
+ for (const dir of dirs) {
213
+ let files;
214
+ try {
215
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
216
+ } catch {
217
+ continue;
218
+ }
219
+ for (const f of files) {
220
+ const name = f.replace(/\.md$/, '').trim();
221
+ if (!name || byName.has(name) || BUILTINS.some((b) => b.name === name)) continue;
222
+ try {
223
+ const raw = fs.readFileSync(path.join(dir, f), 'utf8');
224
+ const { desc, body } = parseCommandFile(raw);
225
+ byName.set(name, { name, desc: desc || '自定义命令', template: body });
226
+ } catch {
227
+ /* 跳过坏文件 */
228
+ }
229
+ }
230
+ }
231
+ return [...byName.values()];
232
+ }
233
+
234
+ function parseCommandFile(raw) {
235
+ const m = /^---\n([\s\S]*?)\n---\n?/.exec(raw);
236
+ if (!m) return { desc: '', body: raw.trim() };
237
+ const fm = m[1];
238
+ const dm = /^description:\s*(.+)$/m.exec(fm);
239
+ return { desc: dm ? dm[1].trim() : '', body: raw.slice(m[0].length).trim() };
240
+ }
241
+
242
+ /**
243
+ * 处理一条以 / 开头的输入。返回 {handled, action?, prompt?}。
244
+ * 未知命令也算 handled(打印提示),避免误当普通消息发给模型。
245
+ */
246
+ export async function runSlash(input, ctx) {
247
+ const parts = input.slice(1).split(/\s+/);
248
+ const cmd = (parts[0] || '').trim();
249
+ const arg = parts.slice(1).join(' ').trim();
250
+ if (cmd === 'quit') return { handled: true, action: 'exit' }; // /quit 别名
251
+
252
+ const builtin = BUILTINS.find((b) => b.name === cmd);
253
+ if (builtin) {
254
+ const r = await builtin.run(ctx, arg);
255
+ return { handled: true, ...(r || {}) };
256
+ }
257
+ const custom = loadCustomCommands(ctx.cwd).find((c) => c.name === cmd);
258
+ if (custom) {
259
+ let prompt = custom.template;
260
+ if (prompt.includes('$ARGUMENTS')) prompt = prompt.replaceAll('$ARGUMENTS', arg);
261
+ else if (arg) prompt = prompt + '\n\n' + arg;
262
+ return { handled: true, prompt };
263
+ }
264
+ ctx.print('未知命令。/help 查看可用命令。');
265
+ return { handled: true };
266
+ }
@@ -0,0 +1,140 @@
1
+ // 上下文压缩(非破坏式):不再删除/改写原始消息——摘要独立存放在 state(随会话持久化),
2
+ // 仅在「发给模型」时用 buildModelMessages() 组装视图:system + 摘要占位 + 未压缩的近期消息。
3
+ // 因此 UI 已显示的对话、落盘会话、分享页/扩展/CLI 的正文都保持完整,压缩只影响送入模型的上下文。
4
+ //
5
+ // state 形状:{ upTo: number, summary: string }
6
+ // messages[1..upTo) 已被摘要覆盖(0 号是 system);messages[upTo..] 原样发送。
7
+ // 触发条件(满足其一):有效上下文字符超阈值;上一轮真实 prompt_tokens 超限。compactNow 手动强制。
8
+ // 切割边界永远取 user 消息下标,天然不拆 assistant(tool_calls) 与其 tool 结果的配对。
9
+
10
+ import { streamChat } from './api.js';
11
+ import { DEFAULT_MODEL } from './config.js';
12
+ import { stripMarkup } from './markup-filter.js';
13
+
14
+ const DEFAULT_THRESHOLD_CHARS = 48000; // ≈ 16k tokens
15
+ const DEFAULT_AUTOCOMPACT_TOKENS = 80000; // 上一轮 prompt_tokens 超此值触发(多数模型 128k 窗口的 ~60%)
16
+ const KEEP_RECENT_USER_TURNS = 3;
17
+
18
+ function totalChars(messages) {
19
+ let n = 0;
20
+ for (const m of messages) {
21
+ if (m.content) n += String(m.content).length;
22
+ if (m.tool_calls) n += JSON.stringify(m.tool_calls).length;
23
+ }
24
+ return n;
25
+ }
26
+
27
+ /** 组装实际发给模型的消息视图(原 messages 不动)。state 为空/无摘要时原样返回。
28
+ * 首条消息(通常是 system)永不被摘要覆盖,始终原样保留。 */
29
+ export function buildModelMessages(messages, state) {
30
+ if (!state || !state.summary || !(state.upTo > 1) || state.upTo > messages.length) return messages;
31
+ return [
32
+ ...messages.slice(0, 1),
33
+ {
34
+ role: 'user',
35
+ content: '【上下文压缩】以下是此前多轮对话与工具执行的摘要(原始对话仍完整保留在会话中,仅为节省上下文而摘要):\n'
36
+ + state.summary,
37
+ },
38
+ ...messages.slice(state.upTo),
39
+ ];
40
+ }
41
+
42
+ /**
43
+ * 若超阈值则把较早回合摘要进 state(原地更新 state,不动 messages)。
44
+ * @param {any[]} messages 完整消息(含已被摘要覆盖的部分,永不被修改)
45
+ * @param {object} config baseUrl/apiKey/model/autoCompactTokens
46
+ * @param {object} [opts] {state, thresholdChars, keepRecent, force, lastPromptTokens}
47
+ * @returns {Promise<number>} 本次新增被摘要覆盖的消息条数(0=未压缩)
48
+ */
49
+ export async function compactIfNeeded(messages, config, opts = {}) {
50
+ const state = opts.state || {};
51
+ const threshold = opts.thresholdChars || DEFAULT_THRESHOLD_CHARS;
52
+ const keep = opts.keepRecent || KEEP_RECENT_USER_TURNS;
53
+ if (!messages || messages.length < 6) return 0;
54
+
55
+ const effective = buildModelMessages(messages, state);
56
+ if (!opts.force) {
57
+ const tokenLimit = Number(config?.autoCompactTokens) || DEFAULT_AUTOCOMPACT_TOKENS;
58
+ const overTokens = Number(opts.lastPromptTokens) > tokenLimit;
59
+ const overChars = totalChars(effective) > threshold;
60
+ if (!overTokens && !overChars) return 0;
61
+ }
62
+
63
+ // 候选切点:位于当前 upTo 之后的 user 消息下标;保留最近 keep 个用户轮
64
+ const from = Math.max(Number(state.upTo) || 1, 1);
65
+ const userIdx = [];
66
+ for (let i = from; i < messages.length; i++) if (messages[i].role === 'user') userIdx.push(i);
67
+ if (userIdx.length <= keep) return 0; // 新增部分轮数太少,无可压
68
+ const newUpTo = userIdx[userIdx.length - keep];
69
+ if (newUpTo - from < 2) return 0;
70
+
71
+ const slice = messages.slice(from, newUpTo);
72
+ let summary;
73
+ try {
74
+ summary = await summarize(slice, config, state.summary);
75
+ } catch {
76
+ return 0; // 摘要失败:不压缩(宁可上下文长,也不丢历史)
77
+ }
78
+ if (!summary || !summary.trim()) return 0;
79
+
80
+ state.summary = summary.trim();
81
+ state.upTo = newUpTo;
82
+ return newUpTo - from;
83
+ }
84
+
85
+ /** 手动强制压缩(/compact):忽略阈值。返回本次被摘要覆盖的消息条数。 */
86
+ export function compactNow(messages, config, opts = {}) {
87
+ return compactIfNeeded(messages, config, { ...opts, force: true });
88
+ }
89
+
90
+ // 伪模型(agent-/workflow-/datahuman-)在服务端走完整编排(RAG/工具/计费)。
91
+ // 摘要是纯内部辅助调用,必须与用户的业务对话彻底分离:绝不能用伪模型跑摘要,
92
+ // 否则每次压缩都会额外触发一次智能体执行(副作用+计费),且人格会污染摘要内容。
93
+ const PSEUDO_MODEL_RE = /^(agent|workflow|datahuman)-/;
94
+
95
+ /** 摘要专用模型:显式 compactModel 优先;当前模型为伪模型时回退内置默认原始模型。 */
96
+ function summaryModel(config) {
97
+ if (config?.compactModel) return config.compactModel;
98
+ const m = config?.model || '';
99
+ return PSEUDO_MODEL_RE.test(m) ? DEFAULT_MODEL : m;
100
+ }
101
+
102
+ async function summarize(slice, config, prevSummary) {
103
+ const parts = [];
104
+ if (prevSummary && prevSummary.trim()) parts.push('【先前的摘要(需并入新摘要)】\n' + prevSummary.trim());
105
+ parts.push(renderForSummary(slice));
106
+ const r = await streamChat({
107
+ baseUrl: config.baseUrl,
108
+ apiKey: config.apiKey,
109
+ model: summaryModel(config),
110
+ messages: [
111
+ {
112
+ role: 'system',
113
+ content:
114
+ '你是对话/执行记录压缩器。把下面的记录(含可能存在的先前摘要)压缩成一份简洁但信息完整的中文摘要:'
115
+ + '保留已完成的关键步骤、工具得到的重要事实/数据/结论、涉及的文件路径、尚未解决的问题与下一步线索。'
116
+ + '只依据记录、不要臆造、不要寒暄。',
117
+ },
118
+ { role: 'user', content: parts.join('\n\n') },
119
+ ],
120
+ }); // 无 handlers:非流式收集
121
+ // 摘要会注入后续上下文:清洗文本化工具标记,防止协议乱码经摘要回流、继续诱发模型格式退化
122
+ return stripMarkup(r.content).text;
123
+ }
124
+
125
+ function renderForSummary(messages) {
126
+ const lines = [];
127
+ for (const m of messages) {
128
+ if (m.role === 'assistant' && m.tool_calls?.length) {
129
+ const calls = m.tool_calls
130
+ .map((tc) => `${tc.function?.name}(${String(tc.function?.arguments || '').slice(0, 200)})`)
131
+ .join(' ');
132
+ lines.push(`[助手调用工具] ${calls}${m.content ? ' | ' + m.content : ''}`);
133
+ } else if (m.role === 'tool') {
134
+ lines.push(`[工具结果] ${String(m.content || '').slice(0, 800)}`);
135
+ } else if (m.content) {
136
+ lines.push(`[${m.role}] ${m.content}`);
137
+ }
138
+ }
139
+ return lines.join('\n');
140
+ }
package/src/config.js ADDED
@@ -0,0 +1,64 @@
1
+ // 配置读写:~/.huicai/config.json(含 baseUrl / apiKey / model),环境变量优先覆盖。
2
+ // apiKey 敏感:写入时置文件权限 0600。
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+
8
+ export const CONFIG_DIR = path.join(os.homedir(), '.huicai');
9
+ export const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
10
+
11
+ /** 平台默认地址(内置,用户无需输入网关地址;可用 HUICAI_BASE_URL / --base 覆盖,供自建/联调)。 */
12
+ export const DEFAULT_BASE_URL = 'https://huicai-ai.sjht.com';
13
+
14
+ /** 内置默认模型(用户未设置时用它;登录时若账号有它则优先,否则回退到账号的第一个原始模型)。 */
15
+ export const DEFAULT_MODEL = 'deepseek-v4-flash';
16
+
17
+ function readFileConfig() {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ /**
26
+ * 解析生效配置:环境变量 > 配置文件 > 缺省。
27
+ * @returns {{baseUrl:string, apiKey:string, model:string}}
28
+ */
29
+ export function loadConfig() {
30
+ const file = readFileConfig();
31
+ const cfg = {
32
+ // 网关地址内置默认,用户无需输入;仍可用环境变量/配置文件覆盖(自建或联调)
33
+ baseUrl: (process.env.HUICAI_BASE_URL || file.baseUrl || DEFAULT_BASE_URL).trim(),
34
+ apiKey: (process.env.HUICAI_API_KEY || file.apiKey || '').trim(),
35
+ model: (process.env.HUICAI_MODEL || file.model || DEFAULT_MODEL).trim(),
36
+ // 单回合最大工具轮数(0/缺省 = 用内置默认 60);可被 --max-turns 覆盖
37
+ maxTurns: Number(process.env.HUICAI_MAX_TURNS || file.maxTurns) || 0,
38
+ // 自动压缩触发的 prompt_tokens 上限(缺省用 compaction 内置默认 80000)
39
+ autoCompactTokens: Number(file.autoCompactTokens) || 0,
40
+ // 压缩摘要专用模型(缺省:当前模型;当前为伪模型时 compaction 内部回退 DEFAULT_MODEL——摘要绝不经智能体编排)
41
+ compactModel: (process.env.HUICAI_COMPACT_MODEL || file.compactModel || '').trim(),
42
+ // 长期记忆(memory_write + 系统提示词注入):默认开;配置 memory:false 或 HUICAI_MEMORY=0 关闭
43
+ memoryEnabled: process.env.HUICAI_MEMORY === '0' ? false : file.memory !== false,
44
+ // 思考过程流式展示:默认开;配置 showReasoning:false 或 HUICAI_REASONING=0 关闭
45
+ showReasoning: process.env.HUICAI_REASONING === '0' ? false : file.showReasoning !== false,
46
+ // 客户端来源标识(对话落库审计用):默认 cli;VSCode 扩展进程内置 HUICAI_CLIENT=vscode
47
+ client: (process.env.HUICAI_CLIENT || 'cli').trim().toLowerCase(),
48
+ };
49
+ cfg.baseUrl = cfg.baseUrl.replace(/\/+$/, ''); // 去尾斜杠
50
+ return cfg;
51
+ }
52
+
53
+ /** 合并写入配置文件(0600),返回合并结果。 */
54
+ export function saveConfig(partial) {
55
+ const merged = { ...readFileConfig(), ...partial };
56
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
57
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
58
+ try {
59
+ fs.chmodSync(CONFIG_PATH, 0o600);
60
+ } catch {
61
+ // 非 POSIX 忽略
62
+ }
63
+ return merged;
64
+ }
package/src/context.js ADDED
@@ -0,0 +1,144 @@
1
+ // 组装系统提示词:人格 + 工作准则 + 运行环境(cwd/OS/git/顶层文件)
2
+ // + 项目说明文件(HUICAI.md/AGENTS.md/CLAUDE.md)+ 全局用户说明(~/.huicai/HUICAI.md)。
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { execSync } from 'node:child_process';
8
+ import { loadMemory } from './memory.js';
9
+ import { skillCatalog } from './skills.js';
10
+
11
+ function git(cwd) {
12
+ const run = (cmd) => execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
13
+ try {
14
+ const branch = run('git rev-parse --abbrev-ref HEAD');
15
+ let status = '';
16
+ try {
17
+ status = run('git status --short');
18
+ } catch {
19
+ /* ignore */
20
+ }
21
+ return { branch, status };
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function topLevel(cwd) {
28
+ try {
29
+ return fs
30
+ .readdirSync(cwd, { withFileTypes: true })
31
+ .filter((e) => e.name !== '.git' && e.name !== 'node_modules')
32
+ .slice(0, 60)
33
+ .map((e) => (e.isDirectory() ? e.name + '/' : e.name))
34
+ .join(' ');
35
+ } catch {
36
+ return '';
37
+ }
38
+ }
39
+
40
+ function projectDoc(cwd) {
41
+ for (const name of ['HUICAI.md', 'AGENTS.md', 'CLAUDE.md']) {
42
+ try {
43
+ const s = fs.readFileSync(path.join(cwd, name), 'utf8');
44
+ if (s.trim()) return { name, content: s.slice(0, 8000) };
45
+ } catch {
46
+ /* next */
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /** 全局用户说明(~/.huicai/HUICAI.md):跨项目的个人偏好/约定。 */
53
+ function globalDoc() {
54
+ try {
55
+ const s = fs.readFileSync(path.join(os.homedir(), '.huicai', 'HUICAI.md'), 'utf8');
56
+ return s.trim() ? s.slice(0, 4000) : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ const GUIDELINES = [
63
+ '- 先了解现状再动手:并行使用 read_file/glob/grep/list_files 收集信息(一轮可同时调多个只读工具),不要臆测文件内容。',
64
+ '- read_file 输出的「行号→」前缀仅供定位,不是文件内容;写 edit_file 的 old_string/new_string 时绝不能包含行号前缀。',
65
+ '- 多步骤任务先用 todo_write 列出计划,并随进度更新状态(同一时刻至多一项 in_progress);单步小事不必用。',
66
+ '- 改代码优先用 edit_file 做最小精确改动;新建文件才用 write_file。遵循项目既有风格与命名。',
67
+ '- 用 run_bash 执行构建/测试/git 等;改动后尽量运行测试或构建自检,失败要定位修复而不是忽略。',
68
+ '- 需要在线资料(文档/API 说明)可用 fetch_url;带明确目的使用,不要漫游。',
69
+ '- 破坏性或不可逆操作(删除、强推、重置等)前先说明意图;写/执行类工具会请用户授权,被拒绝时据反馈调整方案。',
70
+ '- 不要把密钥、令牌等敏感值回显到输出或写进代码。',
71
+ '- 简洁直接,用中文回答;完成后用一两句话总结改了什么、如何验证的。',
72
+ ];
73
+
74
+ /**
75
+ * 组装系统提示词。
76
+ * @param {string} cwd 工作目录
77
+ * @param {{persona?:string, append?:string}} [opts]
78
+ * persona 非空时以该智能体人格领衔(--agent 模式),但保留本机工具/环境说明;
79
+ * append 追加在末尾(--append-system-prompt)。
80
+ */
81
+ export function buildSystemPrompt(cwd, opts = {}) {
82
+ const g = git(cwd);
83
+ const doc = projectDoc(cwd);
84
+ const gdoc = globalDoc();
85
+ let p = '';
86
+ if (opts.persona && opts.persona.trim()) {
87
+ p += '# 你的角色设定\n' + opts.persona.trim() + '\n\n';
88
+ p += '# 运行方式\n';
89
+ p += '你现在运行在用户终端里,可通过工具在用户本机的项目里读写文件、搜索、执行 shell 命令来真正完成任务。请在保持上述角色设定的前提下遵循以下准则:\n';
90
+ } else {
91
+ p += '你是 huicai,一个运行在用户终端里的专业自治编码与自动化智能体。\n';
92
+ p += '你能通过工具在用户本机的项目里读写文件、搜索、执行 shell 命令,从而真正完成任务,而不仅是给建议。\n\n';
93
+ p += '# 工作准则\n';
94
+ }
95
+ p += GUIDELINES.join('\n') + '\n\n';
96
+ p += '# 运行环境\n';
97
+ p += `- 工作目录: ${cwd}\n`;
98
+ p += `- 操作系统: ${os.platform()} ${os.release()} (${os.arch()})\n`;
99
+ p += `- 日期: ${new Date().toISOString().slice(0, 10)}\n`;
100
+ if (g) {
101
+ p += `- Git 分支: ${g.branch}\n`;
102
+ if (g.status) {
103
+ const lines = g.status.split('\n').slice(0, 30).map((l) => ' ' + l).join('\n');
104
+ p += `- 未提交改动:\n${lines}\n`;
105
+ }
106
+ }
107
+ const top = topLevel(cwd);
108
+ if (top) p += `- 顶层条目: ${top}\n`;
109
+ const editor = editorContext();
110
+ if (editor) p += '\n# 编辑器上下文(来自 IDE 集成)\n' + editor;
111
+ if (gdoc) p += `\n# 全局用户说明(~/.huicai/HUICAI.md)\n${gdoc}\n`;
112
+ if (doc) p += `\n# 项目说明(${doc.name})\n${doc.content}\n`;
113
+ // 本地技能目录(渐进披露:仅名称+描述常驻,正文经 load_skill 按需载入)
114
+ const skills = skillCatalog(cwd);
115
+ if (skills) p += `\n# 可用技能(本地 Agent Skills)\n${skills}\n`;
116
+ if (opts.memory !== false) {
117
+ const mem = loadMemory(cwd);
118
+ if (mem) {
119
+ p += `\n# 长期记忆(此前会话经 memory_write 记录,跨会话生效)\n${mem}\n`;
120
+ p += '(发现新的、值得跨会话记住的稳定事实/偏好/结论时,用 memory_write 记录。)\n';
121
+ }
122
+ }
123
+ if (opts.append && String(opts.append).trim()) p += '\n# 附加指示\n' + String(opts.append).trim() + '\n';
124
+ return p;
125
+ }
126
+
127
+ /** VS Code 等 IDE 扩展经环境变量注入的当前编辑上下文:正在编辑的文件 + 选中片段 + 诊断(Problems)。 */
128
+ function editorContext() {
129
+ const file = process.env.HUICAI_IDE_FILE;
130
+ const selection = process.env.HUICAI_IDE_SELECTION;
131
+ const diagnostics = process.env.HUICAI_IDE_DIAGNOSTICS;
132
+ if (!file && !selection && !diagnostics) return '';
133
+ let s = '';
134
+ if (file) s += `- 用户当前正在编辑的文件: ${file}\n`;
135
+ if (selection) {
136
+ const clipped = selection.length > 4000 ? selection.slice(0, 4000) + '\n…(选区已截断)' : selection;
137
+ s += '- 用户当前选中的代码片段:\n```\n' + clipped + '\n```\n';
138
+ }
139
+ if (diagnostics) {
140
+ const clipped = diagnostics.length > 4000 ? diagnostics.slice(0, 4000) + '\n…(诊断已截断)' : diagnostics;
141
+ s += '- 编辑器诊断(错误/警告,来自 Problems 面板):\n```\n' + clipped + '\n```\n';
142
+ }
143
+ return s;
144
+ }