llm-api-gateway-cli 1.0.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/.env.example +10 -0
- package/README.md +1127 -0
- package/cli-agent.js +666 -0
- package/cli-anthropic.js +236 -0
- package/cli-claude-code.js +317 -0
- package/cli-openai.js +212 -0
- package/completions/_llm-api-gateway-cli +65 -0
- package/completions/llm-api-gateway-cli.bash +64 -0
- package/completions/llm-api-gateway-cli.fish +43 -0
- package/images/chat.png +0 -0
- package/images/settings.png +0 -0
- package/images/task.png +0 -0
- package/lib/agent.js +607 -0
- package/lib/commands.js +468 -0
- package/lib/common.js +196 -0
- package/lib/config.js +70 -0
- package/lib/configcmd.js +230 -0
- package/lib/hub.js +1494 -0
- package/lib/jsonstore.js +49 -0
- package/lib/mcp.js +375 -0
- package/lib/memory.js +109 -0
- package/lib/plandoc.js +178 -0
- package/lib/pricing.js +52 -0
- package/lib/runner.js +234 -0
- package/lib/runstore.js +96 -0
- package/lib/secrets.js +198 -0
- package/lib/sessionstore.js +269 -0
- package/lib/settings.js +517 -0
- package/lib/tasksession.js +594 -0
- package/lib/taskstore.js +740 -0
- package/lib/tools.js +927 -0
- package/package.json +55 -0
- package/public/app.js +1055 -0
- package/public/index.html +167 -0
- package/public/manual.css +215 -0
- package/public/manual.html +381 -0
- package/public/manual.js +186 -0
- package/public/models.js +121 -0
- package/public/render.js +250 -0
- package/public/styles.css +955 -0
- package/public/task-slash.js +493 -0
- package/public/task.css +739 -0
- package/public/task.html +220 -0
- package/public/task.js +3127 -0
- package/public/theme.js +91 -0
- package/public/tint.js +261 -0
- package/scripts/install.ps1 +537 -0
- package/scripts/install.sh +510 -0
- package/server.js +14 -0
- package/task-server.js +15 -0
package/lib/jsonstore.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON 原子写入(taskstore / runstore / sessionstore 共用)
|
|
3
|
+
*
|
|
4
|
+
* 为什么要单独抽一份:runstore 与 sessionstore 都需要「先写 .tmp 再 rename」,
|
|
5
|
+
* 而原来的写入函数是 runstore 的模块私有函数,sessionstore 根本拿不到,
|
|
6
|
+
* 只能各抄一遍 —— 那就等于把 Windows 上一次踩过的坑(rename 偶发 EPERM)再踩一次。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
/** 同步睡一会儿(rename 重试用)。Atomics.wait 不占 CPU,比忙等好 */
|
|
12
|
+
function sleepSync(ms) {
|
|
13
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 先写 .tmp 再 rename —— 进程被杀也不会留下半截 JSON。
|
|
18
|
+
*
|
|
19
|
+
* Windows 上 rename 覆盖已存在的目标会偶发 EPERM(杀软或索引器短暂占用文件句柄),
|
|
20
|
+
* 短时间连续写时尤其容易撞上,所以重试几次;实在不行退回直接覆盖写,
|
|
21
|
+
* 宁可牺牲这一轮的原子性,也不能让保存整个失败。
|
|
22
|
+
*
|
|
23
|
+
* @param {string} file
|
|
24
|
+
* @param {*} value
|
|
25
|
+
* @param {{pretty?: boolean}} opts pretty=true 时按 2 空格缩进(便于人工查看)
|
|
26
|
+
*/
|
|
27
|
+
export function writeJsonAtomic(file, value, { pretty = false } = {}) {
|
|
28
|
+
const tmp = `${file}.tmp`;
|
|
29
|
+
const text = pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value);
|
|
30
|
+
writeFileSync(tmp, text, 'utf8');
|
|
31
|
+
for (let i = 0; i < 5; i++) {
|
|
32
|
+
try {
|
|
33
|
+
renameSync(tmp, file);
|
|
34
|
+
return;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e.code !== 'EPERM' && e.code !== 'EACCES' && e.code !== 'EBUSY') throw e;
|
|
37
|
+
sleepSync(5 * (i + 1));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(file, text, 'utf8');
|
|
42
|
+
} finally {
|
|
43
|
+
try {
|
|
44
|
+
unlinkSync(tmp);
|
|
45
|
+
} catch {
|
|
46
|
+
/* 忽略 */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP 工具源(客户端自助执行)
|
|
3
|
+
*
|
|
4
|
+
* 网关的 MCP 有两种「谁执行工具」的分工,各有一个说不通的地方:
|
|
5
|
+
* · `loop` —— 网关代执行:调用链全在网关里跑完,客户端只拿到最终回答。
|
|
6
|
+
* 代价是那几轮**没有真流式**(网关必须整读响应才能判断是不是工具轮),
|
|
7
|
+
* 而且工具调用**绕过客户端**:工具卡片、人工批准、计划模式的只读约束全部失效。
|
|
8
|
+
* · `inject` —— 只把工具定义注入「网关 → 上游」那一跳:客户端会收到 `tool_calls`,
|
|
9
|
+
* 但**看不到清单**(名字、参数 schema 都只在那一跳里),也没有执行通道。
|
|
10
|
+
*
|
|
11
|
+
* 本模块走第三条路,把「拿清单」和「执行」两件事交给 CLI:
|
|
12
|
+
* GET /v1/mcp/tools → 本密钥绑定范围内的工具(名字 + schema)
|
|
13
|
+
* → 当成 CLI 自己的工具声明给模型(同名会抑制网关注入,模型只看到一份定义)
|
|
14
|
+
* → 模型调用时 POST /v1/mcp/call,由网关转发到第三方(限流与审计仍在网关侧统一做)
|
|
15
|
+
* 于是流式照常、工具卡片与批准流程照常,而出网那一跳仍归网关管。
|
|
16
|
+
*
|
|
17
|
+
* 三条硬约束(都体现在实现里,改动时别丢):
|
|
18
|
+
* 1. **best-effort**:网关没开 MCP / 网络不通 / 密钥无效时静默降级成「没有这些工具」。
|
|
19
|
+
* 绝不能因为 MCP 把一次正常对话搞挂——这是本模块唯一不可让步的性质。
|
|
20
|
+
* 2. **同步读缓存**:`availableTools()` 是同步函数且在请求路径上被调用,
|
|
21
|
+
* 所以清单只能提前刷新进内存,不能在请求路径上 await。
|
|
22
|
+
* 3. **不覆盖内置工具**:同名以内置为准。内置工具带沙箱语义,不能被远端工具顶掉
|
|
23
|
+
* (`mcpToolSpecs(reserved)` 的 reserved 就是为此传入的)。
|
|
24
|
+
*/
|
|
25
|
+
import { sessionBaggage } from './common.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 「这次调用属于哪个任务节点」的头。
|
|
29
|
+
*
|
|
30
|
+
* 由**网关**在对话响应里回带(`X-DSH-Plan-Node: log<账本行 id>`),CLI 原样回带在
|
|
31
|
+
* `/v1/mcp/call` 上,网关据此把这次工具调用**精确**挂到触发它的那一步。
|
|
32
|
+
* 没有它,网关只能靠会话标识挂到任务级(或干脆不挂)——同任务多步并发时,
|
|
33
|
+
* 想知道"这个工具是哪一步调的"就只能按时间猜,而那是编造。
|
|
34
|
+
*
|
|
35
|
+
* 只回带**形状合法**的值:这个值来自响应头(外部输入),网关那边也会校验,
|
|
36
|
+
* 但掺进一个畸形值没有任何好处,不如根本不发(网关随即退回会话级口径)。
|
|
37
|
+
*/
|
|
38
|
+
const PLAN_NODE_HEADER = 'x-dsh-plan-node';
|
|
39
|
+
const PLAN_NODE_RE = /^log[0-9]+$/;
|
|
40
|
+
|
|
41
|
+
/** 清单缓存:一次会话内复用,过期或显式刷新时重取 */
|
|
42
|
+
const DEFAULT_TTL_MS = 60 * 1000;
|
|
43
|
+
/** 拉清单的超时。必须短:它在会话开场路径上,网关不可达时不能让人干等 */
|
|
44
|
+
const FETCH_TIMEOUT_MS = 4000;
|
|
45
|
+
/**
|
|
46
|
+
* 失败后的冷却。
|
|
47
|
+
*
|
|
48
|
+
* 为什么需要:CLI 现在**启动时就会先拉一次**(横幅要显示真实工具清单),紧接着第一轮
|
|
49
|
+
* 对话开场还会走同一段逻辑。没有冷却的话,网关没开 MCP / 断网时,一次对话要白等两个
|
|
50
|
+
* 4 秒超时 —— 而这两个请求注定都失败。冷却期内状态与文案照旧(仍报同一个原因),
|
|
51
|
+
* 只是不再重复出网;超时后会自然重试,网关修好了不必重启 CLI。
|
|
52
|
+
*/
|
|
53
|
+
const FAIL_TTL_MS = 30 * 1000;
|
|
54
|
+
|
|
55
|
+
let conn = { baseUrl: '', key: '', fetchImpl: null, ttlMs: DEFAULT_TTL_MS };
|
|
56
|
+
let cache = emptyCache();
|
|
57
|
+
let inflight = null;
|
|
58
|
+
|
|
59
|
+
/** 排障开关:GATEWAY_MCP_DEBUG=1 时把清单/调用打到 stderr(默认完全闭嘴)。
|
|
60
|
+
* 「CLI 到底有没有请求工具接口」必须能自己看出来,不能只靠翻网关日志推断。 */
|
|
61
|
+
const debugOn = () => Boolean(process.env.GATEWAY_MCP_DEBUG);
|
|
62
|
+
function debug(...parts) {
|
|
63
|
+
if (debugOn()) console.error('[mcp]', ...parts);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function emptyCache() {
|
|
67
|
+
return {
|
|
68
|
+
tools: [], servers: [], skipped: [], mode: '', enabled: false,
|
|
69
|
+
at: 0, failedAt: 0,
|
|
70
|
+
// 真正向网关发出过请求的时间戳(0 = 一次都没发)。
|
|
71
|
+
// 「本密钥到底有没有去问过工具清单」必须能从 CLI 自己看出来:
|
|
72
|
+
// 只看 loaded/error 区分不了「压根没发请求」(GATEWAY_MCP=off / 密钥配置不全 / 密钥被污染)
|
|
73
|
+
// 与「发了但失败」(网关 404 / 断网),而这两种情况的下一步完全不同。
|
|
74
|
+
attemptedAt: 0,
|
|
75
|
+
error: '', loaded: false,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 记下网关地址与密钥(每次 runAgent 用当次 cfg 覆盖,避免跨会话串味) */
|
|
80
|
+
export function configureMcp({ baseUrl, key, fetchImpl, ttlMs } = {}) {
|
|
81
|
+
conn = {
|
|
82
|
+
// 两端去空白:从文件/环境变量里读来的值常常带换行或尾随空格
|
|
83
|
+
baseUrl: String(baseUrl || '').trim().replace(/\/+$/, ''),
|
|
84
|
+
key: String(key || '').trim(),
|
|
85
|
+
fetchImpl: fetchImpl || conn.fetchImpl,
|
|
86
|
+
ttlMs: Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : DEFAULT_TTL_MS,
|
|
87
|
+
};
|
|
88
|
+
if (cache.baseUrl !== conn.baseUrl || cache.key !== conn.key) {
|
|
89
|
+
// 换了网关或换了密钥:旧清单可能属于另一把密钥的绑定范围,必须作废
|
|
90
|
+
cache = emptyCache();
|
|
91
|
+
}
|
|
92
|
+
cache.baseUrl = conn.baseUrl;
|
|
93
|
+
cache.key = conn.key;
|
|
94
|
+
return conn;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 复位(测试与「换会话」用) */
|
|
98
|
+
export function resetMcpTools() {
|
|
99
|
+
cache = emptyCache();
|
|
100
|
+
inflight = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const pickFetch = () => (typeof conn.fetchImpl === 'function' ? conn.fetchImpl : globalThis.fetch);
|
|
104
|
+
|
|
105
|
+
function timeoutSignal() {
|
|
106
|
+
// 老 Node 没有 AbortSignal.timeout:退化成不带超时,而不是抛错
|
|
107
|
+
return typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
108
|
+
? AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
109
|
+
: undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 刷新工具清单(best-effort,**永不抛**)。
|
|
114
|
+
* 失败时保留上一次的清单并记下原因:陈旧的清单通常还能用,而清空会让工具
|
|
115
|
+
* 在会话中途无声消失——那比一个 404 更难排查。
|
|
116
|
+
*/
|
|
117
|
+
export async function refreshMcpTools({ force = false, baseUrl, key } = {}) {
|
|
118
|
+
if (baseUrl || key) configureMcp({ baseUrl, key });
|
|
119
|
+
// GATEWAY_MCP=off:客户端完全不参与 MCP —— 不拉清单、不声明工具、不执行,
|
|
120
|
+
// 工具的注入与执行全交给网关(后台把该密钥的绑定模式设成 loop)。
|
|
121
|
+
// 这条路 CLI「不需要知道 tools」,代价是那几轮没有真流式、且绕过 CLI 的批准。
|
|
122
|
+
if (String(process.env.GATEWAY_MCP || '').toLowerCase() === 'off') {
|
|
123
|
+
const kept = { baseUrl: cache.baseUrl, key: cache.key };
|
|
124
|
+
cache = { ...emptyCache(), ...kept };
|
|
125
|
+
cache.error = '已按 GATEWAY_MCP=off 关闭(MCP 交给网关处理)';
|
|
126
|
+
debug('GATEWAY_MCP=off:不拉清单、不声明 MCP 工具');
|
|
127
|
+
return cache;
|
|
128
|
+
}
|
|
129
|
+
if (!conn.baseUrl || !conn.key) {
|
|
130
|
+
// attemptedAt 归零:这两个早退分支要说清「压根没发请求」,别把上一轮的记录留在这儿
|
|
131
|
+
cache = { ...cache, error: '未配置网关地址或密钥', attemptedAt: 0 };
|
|
132
|
+
return cache;
|
|
133
|
+
}
|
|
134
|
+
// 环境变量/配置文件被污染时(混进中文、换行、多行输出),Node 的 fetch 只会抛
|
|
135
|
+
// 「Cannot convert argument to a ByteString」——完全指不到病根。而 CLI 是静默降级,
|
|
136
|
+
// 用户最终只看到「工具莫名没了」。所以在发请求之前先拦下来说清楚。
|
|
137
|
+
const badChar = [...conn.key].find((ch) => ch.charCodeAt(0) > 255);
|
|
138
|
+
if (badChar) {
|
|
139
|
+
cache = {
|
|
140
|
+
...cache,
|
|
141
|
+
error: '密钥里有非 ASCII 字符(' + JSON.stringify(badChar)
|
|
142
|
+
+ '),多半是环境变量/配置文件被污染:值应当是纯 ASCII 的网关密钥',
|
|
143
|
+
loaded: false,
|
|
144
|
+
attemptedAt: 0, // 同上:这是「没发出去」,不是「发了失败」
|
|
145
|
+
};
|
|
146
|
+
debug('密钥含非 ASCII 字符,已放弃请求网关(检查存放密钥的环境变量是否混入了别的输出)');
|
|
147
|
+
return cache;
|
|
148
|
+
}
|
|
149
|
+
// 两种情况都不再出网:① 拉成功过且在 TTL 内;② 刚失败过(冷却,见 FAIL_TTL_MS)。
|
|
150
|
+
// 注意冷却只挡「重复出网」,不挡 force —— 显式刷新永远真的发请求。
|
|
151
|
+
const fresh = cache.loaded && Date.now() - cache.at < conn.ttlMs;
|
|
152
|
+
const cooled = !cache.loaded && cache.failedAt > 0 && Date.now() - cache.failedAt < FAIL_TTL_MS;
|
|
153
|
+
if (!force && (fresh || cooled)) {
|
|
154
|
+
debug(cooled
|
|
155
|
+
? '上次拉清单失败在 ' + Math.round((Date.now() - cache.failedAt) / 1000) + 's 前,冷却期内不重试(原因照旧:' + cache.error + ')'
|
|
156
|
+
: '清单命中缓存(' + Math.round((Date.now() - cache.at) / 1000) + 's 前拉的),本次不请求网关');
|
|
157
|
+
return cache;
|
|
158
|
+
}
|
|
159
|
+
if (inflight) return inflight; // 并发请求合并成一次
|
|
160
|
+
|
|
161
|
+
inflight = (async () => {
|
|
162
|
+
try {
|
|
163
|
+
const f = pickFetch();
|
|
164
|
+
if (typeof f !== 'function') throw new Error('当前 Node 没有 fetch(需要 Node 18+)');
|
|
165
|
+
cache = { ...cache, attemptedAt: Date.now() }; // 记下「真的发过」——成功/失败分支都会继承
|
|
166
|
+
const resp = await f(`${conn.baseUrl}/v1/mcp/tools`, {
|
|
167
|
+
method: 'GET',
|
|
168
|
+
headers: { authorization: `Bearer ${conn.key}` },
|
|
169
|
+
signal: timeoutSignal(),
|
|
170
|
+
});
|
|
171
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
172
|
+
const data = await resp.json();
|
|
173
|
+
const tools = Array.isArray(data?.tools) ? data.tools : [];
|
|
174
|
+
cache = {
|
|
175
|
+
...cache,
|
|
176
|
+
tools,
|
|
177
|
+
servers: Array.isArray(data?.servers) ? data.servers : [],
|
|
178
|
+
skipped: Array.isArray(data?.skipped) ? data.skipped : [],
|
|
179
|
+
mode: String(data?.mode || ''),
|
|
180
|
+
enabled: data?.enabled !== false,
|
|
181
|
+
at: Date.now(),
|
|
182
|
+
failedAt: 0, // 成功一次就把冷却清掉,别让旧失败影响后面
|
|
183
|
+
error: '',
|
|
184
|
+
loaded: true,
|
|
185
|
+
};
|
|
186
|
+
debug('GET ' + conn.baseUrl + '/v1/mcp/tools → HTTP ' + resp.status + ' · ' + tools.length
|
|
187
|
+
+ ' 个工具 · 服务器 ' + cache.servers.length + ' 台 · 模式 ' + (cache.mode || '未报'));
|
|
188
|
+
} catch (e) {
|
|
189
|
+
// 这里刻意只记不抛:网关没开 MCP、断网、密钥错,都不该影响对话本身
|
|
190
|
+
cache = { ...cache, error: e?.message || String(e), at: Date.now(), failedAt: Date.now(), loaded: cache.loaded };
|
|
191
|
+
debug('GET ' + conn.baseUrl + '/v1/mcp/tools 失败:' + cache.error + '(本次不启用 MCP 工具,对话照常)');
|
|
192
|
+
} finally {
|
|
193
|
+
inflight = null;
|
|
194
|
+
}
|
|
195
|
+
return cache;
|
|
196
|
+
})();
|
|
197
|
+
return inflight;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 内置工具名(由 `lib/tools.js` 在模块加载时登记)。
|
|
202
|
+
*
|
|
203
|
+
* 远端工具与内置**同名时必须让位**:内置工具带工作目录沙箱语义,
|
|
204
|
+
* 被同名远端工具顶掉,等于把「受限的本机读文件」换成「任意远端调用」。
|
|
205
|
+
* 这不是洁癖,是权限边界——而且撞名时**报错**比默默换实现更难发现,
|
|
206
|
+
* 所以这里在做任何分派之前就把它们剔除干净。
|
|
207
|
+
*/
|
|
208
|
+
let reservedNames = new Set();
|
|
209
|
+
|
|
210
|
+
export function setMcpReserved(names) {
|
|
211
|
+
reservedNames = names instanceof Set ? new Set(names) : new Set(names || []);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 真正可用的 MCP 工具(已剔除与内置同名的) */
|
|
215
|
+
const usableTools = () => cache.tools.filter((t) => t && t.name && !reservedNames.has(t.name));
|
|
216
|
+
|
|
217
|
+
/** 工具名 → 清单条目(同步;未加载/不存在/与内置同名都返回 null) */
|
|
218
|
+
export function mcpToolInfo(name) {
|
|
219
|
+
const n = String(name || '');
|
|
220
|
+
return usableTools().find((t) => t.name === n) || null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const isMcpTool = (name) => Boolean(mcpToolInfo(name));
|
|
224
|
+
|
|
225
|
+
export const mcpToolCount = () => usableTools().length;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 给 UI 列清单用(`/mcp` 命令与启动横幅):可用工具的简要信息。
|
|
229
|
+
* 与 `availableTools()` 同一份来源、同一套剔除规则,所以「页面/终端上看到的」
|
|
230
|
+
* 一定就是「声明给模型的」,不会各说各的。
|
|
231
|
+
*/
|
|
232
|
+
export function mcpToolList() {
|
|
233
|
+
return usableTools().map((t) => ({
|
|
234
|
+
name: t.name,
|
|
235
|
+
server: t.server || '',
|
|
236
|
+
tool: t.tool || t.name,
|
|
237
|
+
description: t.description || '',
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 转成 CLI 内置工具的同一形状(OpenAI function spec)。
|
|
243
|
+
* `reserved` 是内置工具名集合:同名的一律丢掉,内置优先(约束 3)。
|
|
244
|
+
*/
|
|
245
|
+
export function mcpToolSpecs(reserved) {
|
|
246
|
+
const taken = reserved instanceof Set ? reserved : new Set(reserved || []);
|
|
247
|
+
return usableTools()
|
|
248
|
+
.filter((t) => !taken.has(t.name))
|
|
249
|
+
.map((t) => ({
|
|
250
|
+
type: 'function',
|
|
251
|
+
function: {
|
|
252
|
+
name: t.name,
|
|
253
|
+
description: `[MCP·${t.server || '远端'}] ${t.description || ''}`.trim(),
|
|
254
|
+
parameters: t.input_schema && typeof t.input_schema === 'object'
|
|
255
|
+
? t.input_schema
|
|
256
|
+
: { type: 'object', properties: {} },
|
|
257
|
+
},
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 参数摘要:审批弹窗与工具卡片都要一眼看出「这次要查什么」 */
|
|
262
|
+
function argBrief(args = {}) {
|
|
263
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
264
|
+
const first = Object.values(a).find((v) => typeof v === 'string' && v.trim());
|
|
265
|
+
const text = first || JSON.stringify(a);
|
|
266
|
+
return String(text || '').replace(/\s+/g, ' ').slice(0, 60);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 工具卡片上的一行说明 */
|
|
270
|
+
export function describeMcpCall(name, args = {}) {
|
|
271
|
+
const info = mcpToolInfo(name);
|
|
272
|
+
const where = info?.server ? `${info.server} · ` : '';
|
|
273
|
+
const what = info?.tool || name;
|
|
274
|
+
const brief = argBrief(args);
|
|
275
|
+
return `MCP ${where}${what}${brief ? `(${brief})` : ''}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 审批弹窗的预览。MCP 工具没有本地文件,返回一个「文件式预览」形状但内容是参数 JSON——
|
|
280
|
+
* 复用既有的批准面板,不为了一个新工具类别去改前端渲染。
|
|
281
|
+
*
|
|
282
|
+
* **注意:它现在不在批准路径上了。** MCP 调用不再走人工审批闸门(见 tools.js 的
|
|
283
|
+
* `isFileWriteTool`:逐个批准会让调研类任务的十几次地图查询全被拒,而放开只能靠 `--yes`,
|
|
284
|
+
* 那会把任意文件写入一起放开)。所以 `writePreview` 里分发到这里的那个分支目前不可达;
|
|
285
|
+
* 保留它是为了不改动既有测试,也为了万一将来需要重新收紧时有个现成的面板。
|
|
286
|
+
* 那句「结果会进入对话上下文(不可信输入)」的提醒**依然成立**,它现在写在 README 里。
|
|
287
|
+
*/
|
|
288
|
+
export function mcpPreview(name, args = {}) {
|
|
289
|
+
const info = mcpToolInfo(name);
|
|
290
|
+
const body = JSON.stringify(args && typeof args === 'object' ? args : {}, null, 2);
|
|
291
|
+
return {
|
|
292
|
+
path: `${info?.server || 'MCP'} · ${info?.tool || name}`,
|
|
293
|
+
exists: false,
|
|
294
|
+
bytes: Buffer.byteLength(body),
|
|
295
|
+
lines: body.split('\n').length,
|
|
296
|
+
content: body,
|
|
297
|
+
old: null,
|
|
298
|
+
patch: false,
|
|
299
|
+
note: '远端 MCP 工具调用:由网关转发给第三方服务器,结果会进入对话上下文(不可信输入)。',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 会话开场/排障用的一行状态 */
|
|
304
|
+
export function mcpStatusText() {
|
|
305
|
+
const usable = usableTools();
|
|
306
|
+
if (!cache.loaded && !cache.error) return 'MCP:未加载';
|
|
307
|
+
if (cache.error) return `MCP:不可用(${cache.error})`;
|
|
308
|
+
if (!usable.length) return 'MCP:本密钥未绑定任何可用工具';
|
|
309
|
+
const names = cache.servers.map((s) => `${s.name}(${s.tool_count})`).join('、');
|
|
310
|
+
return `MCP:${usable.length} 个工具 · ${names}${cache.mode ? ` · 模式 ${cache.mode}` : ''}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 对话请求要带的 MCP 头。
|
|
315
|
+
*
|
|
316
|
+
* 只有一种情况需要干预:网关那边绑定模式是 `loop`,而 CLI 这边也拿到了工具
|
|
317
|
+
* ——两边都会执行同一个工具,等于**一次提问跑两遍第三方**(地图类会白烧配额)。
|
|
318
|
+
* 这时让网关这一轮别插手;`off` 只作用于本次请求,不改后台配置,也不影响
|
|
319
|
+
* 别的客户端。模式是 `inject`/`off` 时 CLI 执行才是唯一执行方,不需要这个头。
|
|
320
|
+
*/
|
|
321
|
+
export function mcpHeadersForChat() {
|
|
322
|
+
if (cache.loaded && cache.mode === 'loop' && usableTools().length) {
|
|
323
|
+
return { 'x-mcp-tools': 'off' };
|
|
324
|
+
}
|
|
325
|
+
return {};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 执行一个 MCP 工具(交给网关转发)。
|
|
330
|
+
* 返回 CLI 统一的 `{ok, summary, content}`;**工具自身报错也算成功返回**——
|
|
331
|
+
* 把那句错误文本回灌给模型,它才能换个查法把活干完。
|
|
332
|
+
* 只有传输层/协议层失败(网关 401、超时、5xx)才抛,由 safeExec 转成失败结果。
|
|
333
|
+
*
|
|
334
|
+
* `ctx.planNode` / `ctx.sessionId`:这次调用属于哪一步、哪个会话(见上面
|
|
335
|
+
* PLAN_NODE_HEADER 的说明)。两者都是**尽力而为**:拿不到就不发,
|
|
336
|
+
* 网关会退回它自己的口径,功能不受影响,只是挂接精度差一档。
|
|
337
|
+
*/
|
|
338
|
+
export async function callMcpTool(name, args = {}, ctx = {}) {
|
|
339
|
+
const f = pickFetch();
|
|
340
|
+
if (typeof f !== 'function') throw new Error('当前 Node 没有 fetch(需要 Node 18+)');
|
|
341
|
+
if (!conn.baseUrl || !conn.key) throw new Error('未配置网关地址或密钥,无法调用 MCP 工具');
|
|
342
|
+
const headers = { 'content-type': 'application/json', authorization: `Bearer ${conn.key}` };
|
|
343
|
+
const planNode = String(ctx?.planNode ?? '');
|
|
344
|
+
if (PLAN_NODE_RE.test(planNode)) headers[PLAN_NODE_HEADER] = planNode;
|
|
345
|
+
const baggage = sessionBaggage(ctx?.sessionId);
|
|
346
|
+
if (baggage) headers.baggage = baggage;
|
|
347
|
+
debug('POST ' + conn.baseUrl + '/v1/mcp/call ' + name
|
|
348
|
+
+ (planNode ? ' @' + planNode : '')
|
|
349
|
+
+ (baggage ? ' [' + baggage + ']' : ''));
|
|
350
|
+
const resp = await f(`${conn.baseUrl}/v1/mcp/call`, {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers,
|
|
353
|
+
body: JSON.stringify({ name, arguments: args && typeof args === 'object' ? args : {} }),
|
|
354
|
+
signal: timeoutSignal(),
|
|
355
|
+
});
|
|
356
|
+
if (!resp.ok) {
|
|
357
|
+
let detail = `HTTP ${resp.status}`;
|
|
358
|
+
try {
|
|
359
|
+
const body = await resp.json();
|
|
360
|
+
if (body?.detail) detail = `${detail}: ${body.detail}`;
|
|
361
|
+
} catch {
|
|
362
|
+
/* 非 JSON 错误体:保留状态码就够了 */
|
|
363
|
+
}
|
|
364
|
+
throw new Error(detail);
|
|
365
|
+
}
|
|
366
|
+
const data = await resp.json();
|
|
367
|
+
const text = String(data?.text ?? '');
|
|
368
|
+
const failed = data?.is_error === true || data?.ok === false;
|
|
369
|
+
const firstLine = text.split('\n').find((l) => l.trim()) || '';
|
|
370
|
+
return {
|
|
371
|
+
ok: !failed,
|
|
372
|
+
summary: `${data?.server ? `${data.server} · ` : ''}${data?.tool || name} → ${firstLine.slice(0, 80) || (failed ? '失败' : '完成')}`,
|
|
373
|
+
content: text || (failed ? '(工具没有返回内容)' : '(空结果)'),
|
|
374
|
+
};
|
|
375
|
+
}
|
package/lib/memory.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 项目记忆(AGENTS.md / CLAUDE.md)与工作目录初始化
|
|
3
|
+
*
|
|
4
|
+
* E 组指令的地基:`/instructions` 看这个工作目录注入给模型的是什么,`/init` 生成一份骨架。
|
|
5
|
+
*
|
|
6
|
+
* 为什么单独成文件:这些文件**既被 CLI 启动时读取**(注入 system prompt,见 cli-agent.js),
|
|
7
|
+
* **又被 Web 任务页的指令读写**(`/api/memory`、`/api/init`)。两处各写一遍文件名数组
|
|
8
|
+
* 和长度上限,迟早漂移成"CLI 认 CLAUDE.md、网页只认 AGENTS.md"这种鬼故事。
|
|
9
|
+
*
|
|
10
|
+
* 读的一半没有副作用;写的一半**必然绕过审批**(内容是服务端自己定的,不是模型提议的),
|
|
11
|
+
* 所以它只走 tools.js 里那套工作目录沙箱检查(`writeInsideRoot`),不做任何额外放行。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { writeInsideRoot } from './tools.js';
|
|
18
|
+
|
|
19
|
+
/** 约定:工作目录里这两个文件都算项目记忆,靠前的优先(与 cli-agent 的启动注入一致) */
|
|
20
|
+
export const MEMORY_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
21
|
+
|
|
22
|
+
/** 注入上限:超过就截断。别把上下文全吃光 —— 4KB 足够写清"怎么跑测试"这类事 */
|
|
23
|
+
export const MEMORY_MAX_CHARS = 4096;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 读一个工作目录里的项目记忆。
|
|
27
|
+
* 不做任何路径解析魔法:文件名固定,位置固定在目录根部(这也是模型的约定)。
|
|
28
|
+
*
|
|
29
|
+
* @returns {{workDir:string, files:Array<object>, text:string, limit:number, hasAny:boolean}}
|
|
30
|
+
*/
|
|
31
|
+
export function readMemory(workDir) {
|
|
32
|
+
const files = [];
|
|
33
|
+
for (const name of MEMORY_FILES) {
|
|
34
|
+
const file = path.join(workDir, name);
|
|
35
|
+
if (!existsSync(file)) {
|
|
36
|
+
files.push({ name, path: file, exists: false });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const raw = readFileSync(file, 'utf8');
|
|
41
|
+
files.push({
|
|
42
|
+
name,
|
|
43
|
+
path: file,
|
|
44
|
+
exists: true,
|
|
45
|
+
bytes: Buffer.byteLength(raw, 'utf8'),
|
|
46
|
+
chars: raw.length,
|
|
47
|
+
truncated: raw.length > MEMORY_MAX_CHARS,
|
|
48
|
+
content: raw.length > MEMORY_MAX_CHARS ? `${raw.slice(0, MEMORY_MAX_CHARS)}\n…(项目记忆过长已截断)` : raw,
|
|
49
|
+
});
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// 读不到就当没有:记忆文件不该让整个接口失败(权限、编码问题都可能)
|
|
52
|
+
files.push({ name, path: file, exists: false, error: e.message });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const text = files
|
|
56
|
+
.filter((f) => f.content)
|
|
57
|
+
.map((f) => `# 项目记忆:${f.name}\n${f.content}`)
|
|
58
|
+
.join('\n\n');
|
|
59
|
+
return { workDir, files, text, limit: MEMORY_MAX_CHARS, hasAny: files.some((f) => f.exists) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const INIT_FILE = 'AGENTS.md';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `/init` 的骨架内容。
|
|
66
|
+
* 刻意写得"像一张待填的表"而不是一段范文:让人(和模型)一眼看出哪些格子还空着。
|
|
67
|
+
*/
|
|
68
|
+
export const INIT_TEMPLATE = `# 项目约定(AGENTS.md)
|
|
69
|
+
|
|
70
|
+
> 这个文件由 \`/init\` 生成,请把**这个项目自己的**规矩写在下面:
|
|
71
|
+
> Agent 每次开工前都会先读它,写得越具体,越少来回问。
|
|
72
|
+
|
|
73
|
+
## 这是什么
|
|
74
|
+
(一句话:这个目录/仓库是做什么的)
|
|
75
|
+
|
|
76
|
+
## 怎么跑
|
|
77
|
+
- 安装:
|
|
78
|
+
- 启动:
|
|
79
|
+
- 测试:
|
|
80
|
+
- 构建:
|
|
81
|
+
|
|
82
|
+
## 约定
|
|
83
|
+
- 动手前先读相关文件,不要凭猜测改
|
|
84
|
+
- 改动尽量小、聚焦当前任务;不顺手重构无关代码
|
|
85
|
+
- 提交信息一句话说清「改了什么、为什么」
|
|
86
|
+
- 不要动:(生成物、密钥、别人的实验目录……)
|
|
87
|
+
|
|
88
|
+
## 目录速览
|
|
89
|
+
(用页面上的 \`/files\` 或 /files 看目录,把关键路径写在这里)
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 生成项目记忆文件(默认 AGENTS.md)。
|
|
94
|
+
* 已存在且没给 force 时**不覆盖**,只回报"已存在"—— 覆盖别人的约定是破坏性操作。
|
|
95
|
+
*
|
|
96
|
+
* @returns {{path:string, rel:string, bytes:number, written:boolean, existed:boolean}}
|
|
97
|
+
*/
|
|
98
|
+
export function initInstructions({ workDir, force = false, template = INIT_TEMPLATE, name = INIT_FILE } = {}) {
|
|
99
|
+
if (!MEMORY_FILES.includes(name)) {
|
|
100
|
+
throw Object.assign(new Error(`只允许初始化项目记忆文件:${MEMORY_FILES.join(' / ')}`), { status: 400 });
|
|
101
|
+
}
|
|
102
|
+
const target = path.join(workDir, name);
|
|
103
|
+
if (existsSync(target) && !force) {
|
|
104
|
+
return { path: target, rel: name, bytes: 0, written: false, existed: true };
|
|
105
|
+
}
|
|
106
|
+
const text = template.endsWith('\n') ? template : `${template}\n`;
|
|
107
|
+
const r = writeInsideRoot(workDir, name, text, { overwrite: true });
|
|
108
|
+
return { path: r.path, rel: r.rel, bytes: r.bytes, written: true, existed: r.overwrote };
|
|
109
|
+
}
|