@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/LICENSE +21 -0
- package/README.md +227 -0
- package/bin/huicai.js +578 -0
- package/package.json +50 -0
- package/src/agent.js +348 -0
- package/src/api.js +326 -0
- package/src/colors.js +22 -0
- package/src/commands.js +266 -0
- package/src/compaction.js +140 -0
- package/src/config.js +64 -0
- package/src/context.js +144 -0
- package/src/device.js +88 -0
- package/src/executors.js +391 -0
- package/src/ide.js +53 -0
- package/src/input.js +118 -0
- package/src/markup-filter.js +85 -0
- package/src/mcp.js +207 -0
- package/src/memory.js +110 -0
- package/src/permissions.js +82 -0
- package/src/render.js +55 -0
- package/src/repl.js +203 -0
- package/src/session.js +140 -0
- package/src/settings.js +0 -0
- package/src/skills.js +237 -0
- package/src/subagent.js +81 -0
- package/src/todo.js +75 -0
- package/src/tools.js +132 -0
- package/src/tui/app.js +468 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// 模型「文本化工具调用」防泄漏过滤器(与平台侧 huicai-agent 的 ToolMarkupFilter 同款状态机)。
|
|
2
|
+
//
|
|
3
|
+
// 部分模型(如 DeepSeek 系)在长上下文/上下文压缩后会格式退化:不走 function calling 特殊通道,
|
|
4
|
+
// 而把工具调用以纯文本标记写进正文(如 <||DSML||tool_calls>…、<|tool▁calls▁begin|>…)。
|
|
5
|
+
// 这种「调用」不会出现在 tool_calls 里、也不会被执行;若照原样显示,终端/扩展面板会出现成片协议乱码,
|
|
6
|
+
// 且答非所问。CLI 直连原始模型(平台 /v1 为透明代理,不代过滤),须在客户端做同样的拦截。
|
|
7
|
+
//
|
|
8
|
+
// 流式状态机:正常文本原样放行;遇疑似起始符 <| / <| 先持有,在确认窗口内命中工具标记特征
|
|
9
|
+
// (dsml / tool_call / tool▁call / function_call…)→ 进入吞噬模式,本轮剩余内容全部丢弃
|
|
10
|
+
// (此类输出一旦开始,其后即整段协议块,无用户价值);窗口内未命中 → 原样放行
|
|
11
|
+
// (不误杀正文中的 <|x|> 字面量)。每轮模型输出使用独立实例。
|
|
12
|
+
|
|
13
|
+
/** 起始符确认窗口:起始符后至多累计这么多字符仍未命中特征则放行。 */
|
|
14
|
+
const CONFIRM_WINDOW = 48;
|
|
15
|
+
|
|
16
|
+
/** 工具标记特征(起始符后窗口内匹配):DSML / tool_calls / tool▁calls / function_call 等变体。 */
|
|
17
|
+
const MARKUP_RE = /dsml|tool[_▁]?call|function[_▁]?call/i;
|
|
18
|
+
|
|
19
|
+
/** 找到最早的疑似起始符(<| 或 <|)下标,无则 -1。 */
|
|
20
|
+
function markerStart(s) {
|
|
21
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
22
|
+
if (s[i] === '<' && (s[i + 1] === '|' || s[i + 1] === '|')) return i;
|
|
23
|
+
}
|
|
24
|
+
return -1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class MarkupFilter {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.held = '';
|
|
30
|
+
this.swallowing = false;
|
|
31
|
+
/** 本轮是否检出过文本化工具标记。 */
|
|
32
|
+
this.sawMarkup = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 喂入一段增量,返回可安全下发的文本(可能为空串)。 */
|
|
36
|
+
feed(delta) {
|
|
37
|
+
if (!delta || this.swallowing) return '';
|
|
38
|
+
this.held += delta;
|
|
39
|
+
let out = '';
|
|
40
|
+
for (;;) {
|
|
41
|
+
const idx = markerStart(this.held);
|
|
42
|
+
if (idx < 0) {
|
|
43
|
+
// 无疑似起始:保留可能被增量边界截断的尾部 '<',其余放行
|
|
44
|
+
const keep = this.held.endsWith('<') ? 1 : 0;
|
|
45
|
+
out += this.held.slice(0, this.held.length - keep);
|
|
46
|
+
this.held = keep ? '<' : '';
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
out += this.held.slice(0, idx);
|
|
50
|
+
this.held = this.held.slice(idx);
|
|
51
|
+
// held 现以 <| 或 <| 开头:在确认窗口内判定
|
|
52
|
+
if (MARKUP_RE.test(this.held.slice(0, CONFIRM_WINDOW))) {
|
|
53
|
+
this.swallowing = true;
|
|
54
|
+
this.sawMarkup = true;
|
|
55
|
+
this.held = '';
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
if (this.held.length < CONFIRM_WINDOW) return out; // 证据不足:持有等待更多增量
|
|
59
|
+
// 窗口已满仍非标记:放行起始两字符,继续扫描其后内容
|
|
60
|
+
out += this.held.slice(0, 2);
|
|
61
|
+
this.held = this.held.slice(2);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 流结束:放行仍在持有的未确认内容(吞噬中则丢弃)。 */
|
|
66
|
+
flush() {
|
|
67
|
+
if (this.swallowing) {
|
|
68
|
+
this.held = '';
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
const rest = this.held;
|
|
72
|
+
this.held = '';
|
|
73
|
+
return rest;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 一次性清洗全量文本(等价于流式 feed+flush)。
|
|
79
|
+
* @returns {{text:string, sawMarkup:boolean}} text 为清洗后的安全文本(null/空入参返回空串)
|
|
80
|
+
*/
|
|
81
|
+
export function stripMarkup(content) {
|
|
82
|
+
if (!content) return { text: '', sawMarkup: false };
|
|
83
|
+
const f = new MarkupFilter();
|
|
84
|
+
return { text: f.feed(content) + f.flush(), sawMarkup: f.sawMarkup };
|
|
85
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// 本地 MCP 客户端(零依赖):按 ~/.huicai/mcp.json 启动本地 MCP 服务(stdio 传输,JSON-RPC 2.0,
|
|
2
|
+
// 换行分隔),握手后拉取其工具,暴露给 Agent 循环调用。配置形状为通用 mcpServers 格式:
|
|
3
|
+
// { "mcpServers": { "<名>": { "command": "npx", "args": ["-y","@modelcontextprotocol/server-filesystem","<路径>"], "env": {} } } }
|
|
4
|
+
// 未配置该文件时完全无副作用(返回空工具集)。
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { CONFIG_DIR } from './config.js';
|
|
10
|
+
import { c } from './colors.js';
|
|
11
|
+
|
|
12
|
+
export const MCP_CONFIG_PATH = path.join(CONFIG_DIR, 'mcp.json');
|
|
13
|
+
|
|
14
|
+
const INIT_TIMEOUT = 20_000;
|
|
15
|
+
const CALL_TIMEOUT = 120_000;
|
|
16
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
17
|
+
|
|
18
|
+
/** 单个 MCP 服务的 stdio JSON-RPC 客户端。 */
|
|
19
|
+
class McpStdioClient {
|
|
20
|
+
constructor(name, spec) {
|
|
21
|
+
this.name = name;
|
|
22
|
+
this.spec = spec;
|
|
23
|
+
this.child = null;
|
|
24
|
+
this.buffer = '';
|
|
25
|
+
this.nextId = 1;
|
|
26
|
+
this.pending = new Map(); // id -> {resolve, reject, timer}
|
|
27
|
+
this.closed = false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async start() {
|
|
31
|
+
const env = { ...process.env, ...(this.spec.env || {}) };
|
|
32
|
+
this.child = spawn(this.spec.command, this.spec.args || [], {
|
|
33
|
+
env,
|
|
34
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
+
});
|
|
36
|
+
this.child.on('error', (e) => this._failAll(new Error(`MCP[${this.name}] 进程错误:${e.message}`)));
|
|
37
|
+
this.child.on('exit', () => {
|
|
38
|
+
this.closed = true;
|
|
39
|
+
this._failAll(new Error(`MCP[${this.name}] 进程已退出`));
|
|
40
|
+
});
|
|
41
|
+
this.child.stdout.setEncoding('utf8');
|
|
42
|
+
this.child.stdout.on('data', (chunk) => this._onData(chunk));
|
|
43
|
+
// stderr 仅调试用,静默丢弃避免污染 TUI
|
|
44
|
+
this.child.stderr.on('data', () => {});
|
|
45
|
+
|
|
46
|
+
// 握手:initialize -> notifications/initialized
|
|
47
|
+
await this._request('initialize', {
|
|
48
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
49
|
+
capabilities: {},
|
|
50
|
+
clientInfo: { name: 'huicai-cli', version: '0.2.0' },
|
|
51
|
+
}, INIT_TIMEOUT);
|
|
52
|
+
this._notify('notifications/initialized', {});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async listTools() {
|
|
56
|
+
const res = await this._request('tools/list', {}, INIT_TIMEOUT);
|
|
57
|
+
return Array.isArray(res?.tools) ? res.tools : [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async callTool(toolName, args) {
|
|
61
|
+
const res = await this._request('tools/call', { name: toolName, arguments: args || {} }, CALL_TIMEOUT);
|
|
62
|
+
// 结果内容为 content 数组(text/其它);拼接 text 回填模型
|
|
63
|
+
const parts = Array.isArray(res?.content) ? res.content : [];
|
|
64
|
+
const text = parts
|
|
65
|
+
.map((p) => (p?.type === 'text' ? p.text : p?.text || JSON.stringify(p)))
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.join('\n');
|
|
68
|
+
if (res?.isError) return `工具报错:${text || '(无详情)'}`;
|
|
69
|
+
return text || '(无输出)';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
close() {
|
|
73
|
+
this.closed = true;
|
|
74
|
+
try {
|
|
75
|
+
this.child?.kill('SIGTERM');
|
|
76
|
+
} catch {
|
|
77
|
+
/* ignore */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_onData(chunk) {
|
|
82
|
+
this.buffer += chunk;
|
|
83
|
+
let idx;
|
|
84
|
+
while ((idx = this.buffer.indexOf('\n')) >= 0) {
|
|
85
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
86
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
87
|
+
if (!line) continue;
|
|
88
|
+
let msg;
|
|
89
|
+
try {
|
|
90
|
+
msg = JSON.parse(line);
|
|
91
|
+
} catch {
|
|
92
|
+
continue; // 非 JSON 行(部分服务打印日志到 stdout)忽略
|
|
93
|
+
}
|
|
94
|
+
if (msg.id != null && this.pending.has(msg.id)) {
|
|
95
|
+
const { resolve, reject, timer } = this.pending.get(msg.id);
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
this.pending.delete(msg.id);
|
|
98
|
+
if (msg.error) reject(new Error(msg.error.message || 'MCP 错误'));
|
|
99
|
+
else resolve(msg.result);
|
|
100
|
+
}
|
|
101
|
+
// 服务端通知(无匹配 id)忽略
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_request(method, params, timeoutMs) {
|
|
106
|
+
if (this.closed) return Promise.reject(new Error(`MCP[${this.name}] 已关闭`));
|
|
107
|
+
const id = this.nextId++;
|
|
108
|
+
const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
this.pending.delete(id);
|
|
112
|
+
reject(new Error(`MCP[${this.name}] ${method} 超时`));
|
|
113
|
+
}, timeoutMs);
|
|
114
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
115
|
+
try {
|
|
116
|
+
this.child.stdin.write(payload);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
this.pending.delete(id);
|
|
120
|
+
reject(e);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_notify(method, params) {
|
|
126
|
+
try {
|
|
127
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
128
|
+
} catch {
|
|
129
|
+
/* ignore */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_failAll(err) {
|
|
134
|
+
for (const { reject, timer } of this.pending.values()) {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
reject(err);
|
|
137
|
+
}
|
|
138
|
+
this.pending.clear();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 规整为合法 OpenAI 函数名。 */
|
|
143
|
+
function sanitize(name) {
|
|
144
|
+
const s = String(name).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
145
|
+
return s.length > 60 ? s.slice(0, 60) : s;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readConfig() {
|
|
149
|
+
try {
|
|
150
|
+
const j = JSON.parse(fs.readFileSync(MCP_CONFIG_PATH, 'utf8'));
|
|
151
|
+
return j?.mcpServers && typeof j.mcpServers === 'object' ? j.mcpServers : {};
|
|
152
|
+
} catch {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 启动所有已配置的 MCP 服务并汇集其工具。
|
|
159
|
+
* @returns {Promise<{tools:{spec:object,run:Function,readOnly:boolean}[], clients:McpStdioClient[]}>}
|
|
160
|
+
*/
|
|
161
|
+
export async function loadMcpTools() {
|
|
162
|
+
const servers = readConfig();
|
|
163
|
+
const names = Object.keys(servers);
|
|
164
|
+
const tools = [];
|
|
165
|
+
const clients = [];
|
|
166
|
+
if (names.length === 0) return { tools, clients };
|
|
167
|
+
|
|
168
|
+
for (const name of names) {
|
|
169
|
+
const spec = servers[name] || {};
|
|
170
|
+
if (!spec.command) continue;
|
|
171
|
+
const client = new McpStdioClient(name, spec);
|
|
172
|
+
try {
|
|
173
|
+
await client.start();
|
|
174
|
+
const mcpTools = await client.listTools();
|
|
175
|
+
clients.push(client);
|
|
176
|
+
for (const t of mcpTools) {
|
|
177
|
+
const realName = t.name;
|
|
178
|
+
const fnName = sanitize(`mcp__${name}__${realName}`);
|
|
179
|
+
const readOnly = t.annotations?.readOnlyHint === true;
|
|
180
|
+
tools.push({
|
|
181
|
+
readOnly,
|
|
182
|
+
spec: {
|
|
183
|
+
type: 'function',
|
|
184
|
+
function: {
|
|
185
|
+
name: fnName,
|
|
186
|
+
description: `[MCP:${name}] ${t.description || realName}`,
|
|
187
|
+
parameters: t.inputSchema && typeof t.inputSchema === 'object'
|
|
188
|
+
? t.inputSchema
|
|
189
|
+
: { type: 'object', properties: {} },
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
run: (args) => client.callTool(realName, args),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
process.stdout.write(c.gray(` · MCP[${name}] 已连接,工具 ${mcpTools.length} 个`) + '\n');
|
|
196
|
+
} catch (e) {
|
|
197
|
+
client.close();
|
|
198
|
+
process.stdout.write(c.yellow(` · MCP[${name}] 加载失败,已跳过:${e.message}`) + '\n');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { tools, clients };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 关闭全部 MCP 子进程。 */
|
|
205
|
+
export function closeMcp(clients) {
|
|
206
|
+
for (const c2 of clients || []) c2.close();
|
|
207
|
+
}
|
package/src/memory.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// 长期记忆(跨会话):按项目目录归属,存于 ~/.huicai/memory/<hash>.md。
|
|
2
|
+
// 模型经内置 memory_write 工具主动记录「值得跨会话记住的事实」(用户偏好、项目结论、约定等),
|
|
3
|
+
// 每次新会话由 context.js 注入系统提示词——即「长期记忆」;「短期记忆」= 会话消息 + 非破坏式上下文压缩摘要。
|
|
4
|
+
// 开关:config.memoryEnabled(默认开,配置 memory:false 或环境变量 HUICAI_MEMORY=0 关闭)。
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { CONFIG_DIR } from './config.js';
|
|
10
|
+
|
|
11
|
+
export const MEMORY_DIR = path.join(CONFIG_DIR, 'memory');
|
|
12
|
+
const MAX_MEMORY_CHARS = 6000; // 注入系统提示词的上限(超出保留最新条目)
|
|
13
|
+
const MAX_ENTRY_CHARS = 1000;
|
|
14
|
+
|
|
15
|
+
export function memoryPath(cwd) {
|
|
16
|
+
const h = crypto.createHash('sha1').update(String(cwd || '')).digest('hex').slice(0, 12);
|
|
17
|
+
return path.join(MEMORY_DIR, h + '.md');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 读取该项目的长期记忆(超限保尾部=最新条目)。无则空串。 */
|
|
21
|
+
export function loadMemory(cwd) {
|
|
22
|
+
try {
|
|
23
|
+
const s = fs.readFileSync(memoryPath(cwd), 'utf8');
|
|
24
|
+
if (s.length <= MAX_MEMORY_CHARS) return s.trim();
|
|
25
|
+
const tail = s.slice(-MAX_MEMORY_CHARS);
|
|
26
|
+
const nl = tail.indexOf('\n');
|
|
27
|
+
return ('…(较早记忆已省略)\n' + (nl >= 0 ? tail.slice(nl + 1) : tail)).trim();
|
|
28
|
+
} catch {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 追加一条记忆(带日期,best-effort)。返回是否成功。 */
|
|
34
|
+
export function appendMemory(cwd, content) {
|
|
35
|
+
const t = String(content || '').trim();
|
|
36
|
+
if (!t) return false;
|
|
37
|
+
try {
|
|
38
|
+
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|
39
|
+
const entry = `- [${new Date().toISOString().slice(0, 10)}] ${t.slice(0, MAX_ENTRY_CHARS)}\n`;
|
|
40
|
+
const p = memoryPath(cwd);
|
|
41
|
+
if (!fs.existsSync(p)) {
|
|
42
|
+
fs.writeFileSync(p, `# 长期记忆(${cwd})\n\n${entry}`, { mode: 0o600 });
|
|
43
|
+
} else {
|
|
44
|
+
fs.appendFileSync(p, entry);
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 整体重写记忆(模型做记忆整理时用)。 */
|
|
53
|
+
export function replaceMemory(cwd, content) {
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|
56
|
+
fs.writeFileSync(memoryPath(cwd), String(content || '').slice(0, MAX_MEMORY_CHARS * 2), { mode: 0o600 });
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function clearMemory(cwd) {
|
|
64
|
+
try {
|
|
65
|
+
fs.unlinkSync(memoryPath(cwd));
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 构造 memory_write 内置工具(顶层注入;记忆是机制本身,自动放行)。
|
|
74
|
+
* @param {{cwd:string, emit?:(text:string)=>void}} p
|
|
75
|
+
*/
|
|
76
|
+
export function makeMemoryTool({ cwd, emit }) {
|
|
77
|
+
const spec = {
|
|
78
|
+
type: 'function',
|
|
79
|
+
function: {
|
|
80
|
+
name: 'memory_write',
|
|
81
|
+
description:
|
|
82
|
+
'把值得跨会话记住的信息写入本项目的长期记忆(用户偏好、项目关键事实/约定、踩坑结论等)。'
|
|
83
|
+
+ '新会话会自动注入这些记忆。仅记「以后仍然有用」的稳定信息;不要记一次性细节或大段代码。'
|
|
84
|
+
+ 'mode=append 追加一条(默认);mode=replace 用整理后的全文替换(做记忆瘦身时用)。',
|
|
85
|
+
parameters: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
content: { type: 'string', description: '要记住的内容(简洁的一条事实/偏好/结论)' },
|
|
89
|
+
mode: { type: 'string', enum: ['append', 'replace'], description: '追加或整体替换(默认 append)' },
|
|
90
|
+
},
|
|
91
|
+
required: ['content'],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
const run = async (args) => {
|
|
96
|
+
const content = String(args?.content || '').trim();
|
|
97
|
+
if (!content) return 'memory_write 需要非空 content';
|
|
98
|
+
const ok = args?.mode === 'replace' ? replaceMemory(cwd, content) : appendMemory(cwd, content);
|
|
99
|
+
if (ok) {
|
|
100
|
+
try {
|
|
101
|
+
emit?.(content);
|
|
102
|
+
} catch {
|
|
103
|
+
/* ignore */
|
|
104
|
+
}
|
|
105
|
+
return `已写入长期记忆(${args?.mode === 'replace' ? '整体替换' : '追加'})。`;
|
|
106
|
+
}
|
|
107
|
+
return '长期记忆写入失败(磁盘不可写?),可忽略并继续任务。';
|
|
108
|
+
};
|
|
109
|
+
return { readOnly: true, spec, run };
|
|
110
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// 权限/审批:只读工具自动放行;写/执行需授权(y 一次 / a 本会话该工具 / A 永久写入项目设置 / 否可附原因)。
|
|
2
|
+
// 另支持 settings.json 允许规则(全局 ~/.huicai + 项目 .huicai,见 settings.js)。
|
|
3
|
+
// yolo 模式全放行;非交互(无 ask)且非 yolo 时拒绝写/执行并把原因回灌模型,让它调整策略。
|
|
4
|
+
|
|
5
|
+
import { c } from './colors.js';
|
|
6
|
+
import { toolLabel, renderEditDiff, indent } from './render.js';
|
|
7
|
+
import { loadAllowRules, matchAllowRules, suggestRule, addProjectAllowRule, riskWarning } from './settings.js';
|
|
8
|
+
|
|
9
|
+
export function createPermissions({ yolo = false, ask = null, cwd = null } = {}) {
|
|
10
|
+
const sessionAllowed = new Set();
|
|
11
|
+
let autoAll = !!yolo;
|
|
12
|
+
// 仅在给定 cwd 时读取 settings 允许规则(测试等场景不受用户本机全局配置影响)
|
|
13
|
+
let rules = cwd ? loadAllowRules(cwd) : [];
|
|
14
|
+
|
|
15
|
+
// readOnly 由调用方判定(内置工具查 READONLY_TOOLS;MCP 工具看 readOnlyHint;子智能体只读)
|
|
16
|
+
async function request(name, args, _cwd, readOnly = false) {
|
|
17
|
+
if (readOnly) return { allow: true };
|
|
18
|
+
if (autoAll || sessionAllowed.has(name)) return { allow: true };
|
|
19
|
+
if (rules.length && matchAllowRules(rules, name, args)) return { allow: true };
|
|
20
|
+
if (!ask) {
|
|
21
|
+
return { allow: false, reason: '当前为非交互模式,未授权写/执行操作(加 --yolo 可自动放行)。' };
|
|
22
|
+
}
|
|
23
|
+
printPreview(name, args);
|
|
24
|
+
const raw = (await ask(
|
|
25
|
+
`${c.yellow('允许执行?')} [y]是 · [a]本会话该工具 · [A]永久允许(写入项目设置) · [N]否(可附原因): `,
|
|
26
|
+
)) || '';
|
|
27
|
+
const answer = raw.trim();
|
|
28
|
+
if (answer === 'a') {
|
|
29
|
+
sessionAllowed.add(name);
|
|
30
|
+
return { allow: true };
|
|
31
|
+
}
|
|
32
|
+
if (answer === 'A') {
|
|
33
|
+
sessionAllowed.add(name);
|
|
34
|
+
if (cwd) {
|
|
35
|
+
const rule = suggestRule(name, args);
|
|
36
|
+
try {
|
|
37
|
+
const p = addProjectAllowRule(cwd, rule);
|
|
38
|
+
rules = loadAllowRules(cwd);
|
|
39
|
+
process.stdout.write(c.gray(` 已写入规则 ${rule} → ${p}`) + '\n');
|
|
40
|
+
} catch (e) {
|
|
41
|
+
process.stdout.write(c.yellow(` 规则写入失败(本会话仍生效):${e.message}`) + '\n');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { allow: true };
|
|
45
|
+
}
|
|
46
|
+
const lower = answer.toLowerCase();
|
|
47
|
+
if (lower === 'y' || lower === 'yes') return { allow: true };
|
|
48
|
+
if (answer === '' || lower === 'n' || lower === 'no') {
|
|
49
|
+
return { allow: false, reason: '用户拒绝了该操作。' };
|
|
50
|
+
}
|
|
51
|
+
return { allow: false, reason: `用户拒绝并说明:${answer}` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
request,
|
|
56
|
+
setYolo(v) {
|
|
57
|
+
autoAll = !!v;
|
|
58
|
+
},
|
|
59
|
+
isYolo() {
|
|
60
|
+
return autoAll;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function printPreview(name, args) {
|
|
66
|
+
process.stdout.write('\n' + c.bold(c.yellow('● 需要授权:')) + toolLabel(name, args) + '\n');
|
|
67
|
+
const risk = riskWarning(name, args);
|
|
68
|
+
if (risk) process.stdout.write(' ' + c.bold(c.red('⚠ ' + risk)) + '\n');
|
|
69
|
+
if (name === 'run_bash') {
|
|
70
|
+
process.stdout.write(c.gray(' $ ') + String(args.command ?? '') + '\n');
|
|
71
|
+
} else if (name === 'write_file') {
|
|
72
|
+
const preview = String(args.content ?? '')
|
|
73
|
+
.split('\n')
|
|
74
|
+
.slice(0, 12)
|
|
75
|
+
.join('\n');
|
|
76
|
+
process.stdout.write(c.gray(` 将写入 ${args.path}:`) + '\n' + indent(preview) + '\n');
|
|
77
|
+
} else if (name === 'edit_file') {
|
|
78
|
+
process.stdout.write(c.gray(` 编辑 ${args.path}:`) + '\n' + indent(renderEditDiff(args.old_string, args.new_string)) + '\n');
|
|
79
|
+
} else if (name === 'fetch_url') {
|
|
80
|
+
process.stdout.write(c.gray(' GET ') + String(args.url ?? '') + '\n');
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// 终端渲染小工具:工具调用标签、编辑 diff。
|
|
2
|
+
|
|
3
|
+
import { c } from './colors.js';
|
|
4
|
+
|
|
5
|
+
/** 把一次工具调用渲染成简洁的一行标签。 */
|
|
6
|
+
export function toolLabel(name, args = {}) {
|
|
7
|
+
switch (name) {
|
|
8
|
+
case 'read_file':
|
|
9
|
+
return `read_file ${args.path ?? ''}`;
|
|
10
|
+
case 'list_files':
|
|
11
|
+
return `list_files ${args.path ?? '.'}`;
|
|
12
|
+
case 'glob':
|
|
13
|
+
return `glob ${args.pattern ?? ''}`;
|
|
14
|
+
case 'grep':
|
|
15
|
+
return `grep ${JSON.stringify(args.pattern ?? '')}${args.path ? ' in ' + args.path : ''}`;
|
|
16
|
+
case 'write_file':
|
|
17
|
+
return `write_file ${args.path ?? ''}`;
|
|
18
|
+
case 'edit_file':
|
|
19
|
+
return `edit_file ${args.path ?? ''}`;
|
|
20
|
+
case 'run_bash':
|
|
21
|
+
return `run_bash: ${String(args.command ?? '').split('\n')[0]}`;
|
|
22
|
+
case 'fetch_url':
|
|
23
|
+
return `fetch_url ${args.url ?? ''}`;
|
|
24
|
+
case 'todo_write':
|
|
25
|
+
return 'todo_write(更新任务清单)';
|
|
26
|
+
case 'dispatch_agent': {
|
|
27
|
+
const o = String(args.objective ?? '').split('\n')[0];
|
|
28
|
+
return `dispatch_agent: ${o.length > 60 ? o.slice(0, 60) + '…' : o}`;
|
|
29
|
+
}
|
|
30
|
+
default:
|
|
31
|
+
return `${name}(${Object.keys(args).join(', ')})`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** edit_file 的简易 diff:old 红、new 绿。 */
|
|
36
|
+
export function renderEditDiff(oldStr, newStr) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const l of String(oldStr ?? '').split('\n')) out.push(c.red('- ' + l));
|
|
39
|
+
for (const l of String(newStr ?? '').split('\n')) out.push(c.green('+ ' + l));
|
|
40
|
+
return out.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function indent(s, pad = ' ') {
|
|
44
|
+
return String(s)
|
|
45
|
+
.split('\n')
|
|
46
|
+
.map((l) => pad + l)
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 结果摘要(首行 + 行数提示),避免把整段工具输出刷屏。 */
|
|
51
|
+
export function summarize(text, maxLines = 6) {
|
|
52
|
+
const lines = String(text).split('\n');
|
|
53
|
+
if (lines.length <= maxLines) return text;
|
|
54
|
+
return lines.slice(0, maxLines).join('\n') + c.gray(`\n …(共 ${lines.length} 行)`);
|
|
55
|
+
}
|