ppxans-harness 2.4.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/LICENSE +201 -0
- package/README.md +265 -0
- package/bin/ppx-channels.js +3 -0
- package/bin/ppx-serve.js +6 -0
- package/bin/ppx.js +3 -0
- package/config/identity.md +6 -0
- package/config/ishiki.md +16 -0
- package/config/ppx.json +151 -0
- package/package.json +69 -0
- package/src/agent/context.js +179 -0
- package/src/agent/index.js +717 -0
- package/src/agent/prompts.js +107 -0
- package/src/aml-server.js +151 -0
- package/src/ans/eviction.js +144 -0
- package/src/ans/guard.js +120 -0
- package/src/ans/lifecycle.js +93 -0
- package/src/ans/proactive.js +129 -0
- package/src/ans/reward.js +112 -0
- package/src/ans/values.js +15 -0
- package/src/audit/audit-chain.js +167 -0
- package/src/audit/verifier.js +120 -0
- package/src/bus/circuit-breaker.js +115 -0
- package/src/bus/runtime-bus.js +94 -0
- package/src/channels/base.js +35 -0
- package/src/channels/feishu.js +127 -0
- package/src/channels/http.js +592 -0
- package/src/channels/index.js +110 -0
- package/src/channels/log.js +29 -0
- package/src/channels/wechat-crypto.js +74 -0
- package/src/channels/wechat.js +197 -0
- package/src/channels-cli.js +124 -0
- package/src/cli.js +120 -0
- package/src/config/channels.js +170 -0
- package/src/config/index.js +224 -0
- package/src/config/providers.js +189 -0
- package/src/config/settings.js +182 -0
- package/src/core/policy.js +272 -0
- package/src/core/trace.js +89 -0
- package/src/evolve/playbook.js +194 -0
- package/src/llm/client.js +446 -0
- package/src/llm/dsml.js +74 -0
- package/src/llm/embedder.js +35 -0
- package/src/llm/fence.js +105 -0
- package/src/llm/index.js +4 -0
- package/src/llm/retry.js +73 -0
- package/src/llm/router.js +98 -0
- package/src/mcp/client.js +375 -0
- package/src/mcp/index.js +116 -0
- package/src/memory/asset-hub.js +131 -0
- package/src/memory/canvas.js +131 -0
- package/src/memory/compaction.js +28 -0
- package/src/memory/experience.js +122 -0
- package/src/memory/fact-store.js +699 -0
- package/src/memory/failure-episode.js +99 -0
- package/src/memory/fork.js +83 -0
- package/src/memory/index.js +7 -0
- package/src/memory/l0.js +52 -0
- package/src/memory/l2.js +131 -0
- package/src/memory/l3.js +112 -0
- package/src/memory/memory-ticker.js +240 -0
- package/src/memory/session.js +398 -0
- package/src/mode/blackboard.js +49 -0
- package/src/mode/graph.js +41 -0
- package/src/mode/index.js +64 -0
- package/src/mode/legion.js +51 -0
- package/src/mode/plan-exec.js +50 -0
- package/src/mode/router.js +40 -0
- package/src/orchestrator/agent-worker.js +70 -0
- package/src/orchestrator/dag.js +83 -0
- package/src/orchestrator/index.js +2 -0
- package/src/orchestrator/legion.js +188 -0
- package/src/orchestrator/supervisor.js +177 -0
- package/src/persona/index.js +29 -0
- package/src/plugin/builtin.js +212 -0
- package/src/plugin/context.js +79 -0
- package/src/plugin/index.js +62 -0
- package/src/seam/registry.js +98 -0
- package/src/seam/shell.js +55 -0
- package/src/selfheal/evolve.js +68 -0
- package/src/selfheal/healer.js +167 -0
- package/src/selfheal/run.js +9 -0
- package/src/server.js +60 -0
- package/src/services/learning-service.js +177 -0
- package/src/services/memory-health.js +99 -0
- package/src/services/memory-service.js +160 -0
- package/src/skills/loader.js +150 -0
- package/src/skills/verify.js +100 -0
- package/src/tools/advanced.js +353 -0
- package/src/tools/builtin.js +298 -0
- package/src/tools/catalog.js +159 -0
- package/src/tools/command-guard.js +112 -0
- package/src/tools/custom.js +47 -0
- package/src/tools/delegate.js +297 -0
- package/src/tools/document.js +253 -0
- package/src/tools/governance.js +260 -0
- package/src/tools/index.js +11 -0
- package/src/tools/methods.js +178 -0
- package/src/tools/ocr.js +59 -0
- package/src/tools/seam.js +125 -0
- package/src/tools/selfmod.js +176 -0
- package/src/utils/logger.js +17 -0
- package/src/utils/pii.js +42 -0
- package/src/utils/store.js +108 -0
- package/src/utils/text.js +16 -0
- package/src/utils/trace.js +153 -0
- package/src/utils/winutf8.js +15 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// src/mcp/client.js - MCP (Model Context Protocol) 客户端 (零依赖)
|
|
2
|
+
// 传输可插拔: stdio (本机 npx/uvx 服务器) + HTTP (Streamable HTTP, 远程托管服务器)。
|
|
3
|
+
// 协议: JSON-RPC 2.0 (换行分隔 JSON / HTTP body)。协议版本 2024-11-05 (stdio) / 2025-03-26 (HTTP)。
|
|
4
|
+
// 能力: initialize 握手 / tools/list / tools/call / resources/list / resources/read。
|
|
5
|
+
// 可靠性: 请求超时 + 断连时拒绝所有 pending 请求 (不再永久挂起)。
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { warn } from "../utils/logger.js";
|
|
8
|
+
|
|
9
|
+
const PROTOCOL_VERSION = "2024-11-05"; // stdio 本地服务器广泛兼容的版本
|
|
10
|
+
const HTTP_PROTOCOL_VERSION = "2025-03-26"; // Streamable HTTP 传输对应的协议版本
|
|
11
|
+
const DEFAULT_TIMEOUT = 60000; // 单请求默认超时 (ms)
|
|
12
|
+
const MAX_STDIO_BUF = 1024 * 1024; // stdout 缓冲上限: 防恶意/异常服务器发超长行耗尽内存 (v1.0.8)
|
|
13
|
+
|
|
14
|
+
// ---- SSE (Server-Sent Events) 解析 ----
|
|
15
|
+
// Streamable HTTP 服务器可用 text/event-stream 响应。抽 event -> data 里的 JSON-RPC 消息。
|
|
16
|
+
export function parseSSE(text) {
|
|
17
|
+
const out = [];
|
|
18
|
+
const events = String(text).split(/\r?\n\r?\n/);
|
|
19
|
+
for (const ev of events) {
|
|
20
|
+
let name = "message";
|
|
21
|
+
const data = [];
|
|
22
|
+
for (const line of ev.split(/\r?\n/)) {
|
|
23
|
+
if (!line || line.startsWith(":")) continue;
|
|
24
|
+
if (line.startsWith("event:")) name = line.slice(6).trim();
|
|
25
|
+
else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
|
|
26
|
+
}
|
|
27
|
+
if (!data.length) continue;
|
|
28
|
+
if (name !== "message" && name !== "endpoint") continue;
|
|
29
|
+
const payload = data.join("\n").trim();
|
|
30
|
+
if (!payload) continue;
|
|
31
|
+
try {
|
|
32
|
+
const j = JSON.parse(payload);
|
|
33
|
+
if (Array.isArray(j)) out.push(...j);
|
|
34
|
+
else out.push(j);
|
|
35
|
+
} catch { /* 忽略非 JSON 负载 */ }
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 解析 HTTP 响应体 -> JSON-RPC 消息数组 (SSE 或 JSON 单条/批量)
|
|
41
|
+
function parseHttpBody(text, contentType) {
|
|
42
|
+
const ct = (contentType || "").split(";")[0].trim().toLowerCase();
|
|
43
|
+
const trimmed = text.trim();
|
|
44
|
+
if (ct === "text/event-stream" || /^(event|data):/m.test(trimmed)) {
|
|
45
|
+
return parseSSE(trimmed);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const j = JSON.parse(trimmed);
|
|
49
|
+
return Array.isArray(j) ? j : [j];
|
|
50
|
+
} catch { return []; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- stdio 传输 ----
|
|
54
|
+
// 启动本地服务器子进程, 从 stdout 读换行分隔 JSON, 请求写 stdin。
|
|
55
|
+
class StdioTransport {
|
|
56
|
+
constructor({ command, args = [], env = {}, cwd = null }) {
|
|
57
|
+
this.command = command;
|
|
58
|
+
this.args = args;
|
|
59
|
+
this.env = env;
|
|
60
|
+
this.cwd = cwd;
|
|
61
|
+
this.proc = null;
|
|
62
|
+
this._buf = "";
|
|
63
|
+
this._spawned = false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
start() {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const proc = spawn(this.command, this.args, {
|
|
69
|
+
cwd: this.cwd || undefined,
|
|
70
|
+
env: { ...process.env, ...this.env },
|
|
71
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
72
|
+
});
|
|
73
|
+
this.proc = proc;
|
|
74
|
+
proc.stdout.setEncoding("utf8");
|
|
75
|
+
proc.stdout.on("data", (chunk) => this._onData(chunk));
|
|
76
|
+
proc.stderr.on("data", (d) => warn(`[mcp] ${this.command} stderr: ${String(d).slice(0, 300)}`));
|
|
77
|
+
// v1.0.8: stdin error 监听 (子进程退出后 write 触发 EPIPE 会 unhandled 崩溃)
|
|
78
|
+
if (proc.stdin) proc.stdin.on("error", (e) => warn(`[mcp] ${this.command} stdin: ${e.message}`));
|
|
79
|
+
proc.once("spawn", () => { this._spawned = true; resolve(); });
|
|
80
|
+
proc.once("error", (e) => {
|
|
81
|
+
if (!this._spawned) reject(new Error(`无法启动 MCP 服务器 ${this.command}: ${e.message}`));
|
|
82
|
+
else this.onClose?.(e);
|
|
83
|
+
});
|
|
84
|
+
proc.on("exit", (code) => {
|
|
85
|
+
if (this._spawned) this.onClose?.(new Error(`MCP 服务器进程退出 (code ${code})`));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
send(msg) {
|
|
91
|
+
if (!this.proc || !this.proc.stdin || this.proc.killed) {
|
|
92
|
+
throw new Error("MCP 服务器未连接");
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
96
|
+
} catch (e) {
|
|
97
|
+
throw new Error(`MCP 写入失败: ${e.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
close() {
|
|
102
|
+
if (this.proc) {
|
|
103
|
+
const pid = this.proc.pid;
|
|
104
|
+
// v1.0.8: 杀进程树 (npx/uvx 会派生子进程, 只 kill 直接子进程会泄漏)
|
|
105
|
+
if (process.platform === "win32") {
|
|
106
|
+
try { spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", timeout: 5000 }); } catch {}
|
|
107
|
+
} else {
|
|
108
|
+
try { process.kill(-pid, "SIGTERM"); } catch {} // 负 pid = 整个进程组
|
|
109
|
+
}
|
|
110
|
+
try { this.proc.kill(); } catch {}
|
|
111
|
+
}
|
|
112
|
+
this.proc = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_onData(chunk) {
|
|
116
|
+
this._buf += chunk;
|
|
117
|
+
// v1.0.8: 缓冲上限保护 (防内存耗尽)
|
|
118
|
+
if (this._buf.length > MAX_STDIO_BUF) {
|
|
119
|
+
warn(`[mcp] ${this.command} stdout 缓冲超限 (${MAX_STDIO_BUF}), 丢弃多余数据`);
|
|
120
|
+
this._buf = this._buf.slice(-4096);
|
|
121
|
+
}
|
|
122
|
+
let idx;
|
|
123
|
+
while ((idx = this._buf.indexOf("\n")) !== -1) {
|
|
124
|
+
const line = this._buf.slice(0, idx).trim();
|
|
125
|
+
this._buf = this._buf.slice(idx + 1);
|
|
126
|
+
if (!line) continue;
|
|
127
|
+
let msg;
|
|
128
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
129
|
+
this.onMessage?.(msg);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- HTTP 传输 (Streamable HTTP) ----
|
|
135
|
+
// 每次请求 POST 到 url, 响应可为 JSON 或 SSE; 维护 Mcp-Session-Id 会话头。
|
|
136
|
+
class HttpTransport {
|
|
137
|
+
constructor({ url, headers = {}, timeout = DEFAULT_TIMEOUT }) {
|
|
138
|
+
this.url = url;
|
|
139
|
+
this.headers = headers;
|
|
140
|
+
this.timeout = timeout;
|
|
141
|
+
this.sessionId = null;
|
|
142
|
+
this.closed = false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async start() { /* 单次 POST 流即可, 无需预开连接 */ }
|
|
146
|
+
|
|
147
|
+
async send(msg) {
|
|
148
|
+
const headers = {
|
|
149
|
+
"content-type": "application/json",
|
|
150
|
+
"accept": "application/json, text/event-stream",
|
|
151
|
+
...this.headers,
|
|
152
|
+
};
|
|
153
|
+
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
154
|
+
|
|
155
|
+
const ctrl = new AbortController();
|
|
156
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeout);
|
|
157
|
+
timer.unref?.(); // 不阻塞事件循环退出
|
|
158
|
+
|
|
159
|
+
let res;
|
|
160
|
+
try {
|
|
161
|
+
res = await fetch(this.url, { method: "POST", headers, body: JSON.stringify(msg), signal: ctrl.signal });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
const err = e.name === "AbortError"
|
|
164
|
+
? new Error(`MCP HTTP 请求超时 (${this.timeout}ms)`)
|
|
165
|
+
: new Error(`MCP HTTP 请求失败: ${e.message}`);
|
|
166
|
+
this._error(msg, err);
|
|
167
|
+
return;
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sid = res.headers.get("mcp-session-id");
|
|
173
|
+
if (sid) this.sessionId = sid;
|
|
174
|
+
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
const t = await res.text().catch(() => "");
|
|
177
|
+
this._error(msg, new Error(`MCP HTTP ${res.status}: ${t.slice(0, 200)}`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const text = await res.text();
|
|
181
|
+
if (!text) return; // 202/204 空响应 (通知), 无需分发
|
|
182
|
+
for (const m of parseHttpBody(text, res.headers.get("content-type") || "")) {
|
|
183
|
+
this.onMessage?.(m);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_error(msg, e) {
|
|
188
|
+
if (msg && msg.id != null) {
|
|
189
|
+
this.onMessage?.({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: e.message } });
|
|
190
|
+
} else {
|
|
191
|
+
warn(`[mcp] HTTP 通知失败: ${e.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
close() { this.closed = true; }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- MCP 客户端核心 (传输无关) ----
|
|
199
|
+
export class McpClient {
|
|
200
|
+
constructor(config = {}) {
|
|
201
|
+
this.config = config;
|
|
202
|
+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
203
|
+
const isHttp = Boolean(config.url) || config.transport === "http";
|
|
204
|
+
if (isHttp) {
|
|
205
|
+
if (!config.url) throw new Error("HTTP MCP 服务器需 url");
|
|
206
|
+
this.transport = new HttpTransport(config);
|
|
207
|
+
this.protocolVersion = config.protocolVersion || HTTP_PROTOCOL_VERSION;
|
|
208
|
+
} else {
|
|
209
|
+
if (!config.command) throw new Error("stdio MCP 服务器需 command");
|
|
210
|
+
this.transport = new StdioTransport(config);
|
|
211
|
+
this.protocolVersion = config.protocolVersion || PROTOCOL_VERSION;
|
|
212
|
+
}
|
|
213
|
+
this._pending = new Map(); // id -> { resolve, reject, timer }
|
|
214
|
+
this._nextId = 0;
|
|
215
|
+
this._dead = false;
|
|
216
|
+
this.capabilities = {}; // initialize 握手后填充
|
|
217
|
+
this.transport.onMessage = (m) => this._onMessage(m);
|
|
218
|
+
this.transport.onClose = (e) => this._failAll(e || new Error("MCP 连接已断开"));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 启动 + initialize 握手 + initialized 通知
|
|
222
|
+
async connect() {
|
|
223
|
+
await this.transport.start();
|
|
224
|
+
const init = await this._request("initialize", {
|
|
225
|
+
protocolVersion: this.protocolVersion,
|
|
226
|
+
capabilities: {},
|
|
227
|
+
clientInfo: { name: "ppx-agent", version: "0.1.0" },
|
|
228
|
+
});
|
|
229
|
+
if (init && init.protocolVersion) this.protocolVersion = init.protocolVersion;
|
|
230
|
+
this.capabilities = (init && init.capabilities) || {};
|
|
231
|
+
await this._notify("notifications/initialized", {});
|
|
232
|
+
return this;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async listTools() {
|
|
236
|
+
const r = await this._request("tools/list", {});
|
|
237
|
+
return (r && r.tools) || [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 调用工具 -> 提取纯文本结果 (向后兼容)
|
|
241
|
+
async callTool(name, args = {}) {
|
|
242
|
+
return extractToolText(await this.callToolRaw(name, args));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 调用工具 -> 返回原始 result (含 content/isError, 供上层判断)
|
|
246
|
+
async callToolRaw(name, args = {}) {
|
|
247
|
+
return this._request("tools/call", { name, arguments: args });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async listResources() {
|
|
251
|
+
const r = await this._request("resources/list", {});
|
|
252
|
+
return (r && r.resources) || [];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async readResource(uri) {
|
|
256
|
+
return this._request("resources/read", { uri });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async listPrompts() {
|
|
260
|
+
const r = await this._request("prompts/list", {});
|
|
261
|
+
return (r && r.prompts) || [];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 获取提示模板并填充参数 -> 返回消息数组 [{ role, content }]
|
|
265
|
+
async getPrompt(name, args = {}) {
|
|
266
|
+
const r = await this._request("prompts/get", { name, arguments: args });
|
|
267
|
+
return (r && r.messages) || [];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
close() {
|
|
271
|
+
if (this._dead) return;
|
|
272
|
+
this._dead = true;
|
|
273
|
+
this.transport.close?.();
|
|
274
|
+
this._failAll(new Error("MCP 连接已关闭"));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- 内部 ----
|
|
278
|
+
_request(method, params = {}) {
|
|
279
|
+
if (this._dead) return Promise.reject(new Error("MCP 连接已关闭"));
|
|
280
|
+
const id = ++this._nextId;
|
|
281
|
+
const msg = { jsonrpc: "2.0", id, method, params };
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
const timer = setTimeout(() => {
|
|
284
|
+
this._pending.delete(id);
|
|
285
|
+
reject(new Error(`MCP 请求超时 (${this.timeout}ms): ${method}`));
|
|
286
|
+
}, this.timeout);
|
|
287
|
+
this._pending.set(id, { resolve, reject, timer });
|
|
288
|
+
Promise.resolve()
|
|
289
|
+
.then(() => this.transport.send(msg))
|
|
290
|
+
.catch((e) => this._settle(id, null, e));
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_notify(method, params = {}) {
|
|
295
|
+
if (this._dead) return Promise.resolve();
|
|
296
|
+
return Promise.resolve()
|
|
297
|
+
.then(() => this.transport.send({ jsonrpc: "2.0", method, params }))
|
|
298
|
+
.catch((e) => warn(`[mcp] 通知发送失败 ${method}: ${e.message}`));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_onMessage(msg) {
|
|
302
|
+
if (!msg || msg.id == null) return; // 忽略服务器通知/无 id 消息
|
|
303
|
+
const p = this._pending.get(msg.id);
|
|
304
|
+
if (!p) return;
|
|
305
|
+
if (msg.error) this._settle(msg.id, null, new Error(msg.error.message || "MCP 调用失败"));
|
|
306
|
+
else this._settle(msg.id, msg.result, null);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_settle(id, result, err) {
|
|
310
|
+
const p = this._pending.get(id);
|
|
311
|
+
if (!p) return;
|
|
312
|
+
this._pending.delete(id);
|
|
313
|
+
clearTimeout(p.timer);
|
|
314
|
+
err ? p.reject(err) : p.resolve(result);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_failAll(err) {
|
|
318
|
+
for (const [id, p] of [...this._pending]) {
|
|
319
|
+
clearTimeout(p.timer);
|
|
320
|
+
this._pending.delete(id);
|
|
321
|
+
p.reject(err);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 提取 tools/call 结果的文本 (MCP 返回 content 数组, 每项 type=text 含 text)
|
|
327
|
+
export function extractToolText(result) {
|
|
328
|
+
if (result == null) return "";
|
|
329
|
+
const content = result.content;
|
|
330
|
+
if (Array.isArray(content)) {
|
|
331
|
+
const texts = content
|
|
332
|
+
.filter((c) => c && c.type === "text" && typeof c.text === "string")
|
|
333
|
+
.map((c) => c.text);
|
|
334
|
+
if (texts.length) return texts.join("\n");
|
|
335
|
+
// 非文本内容 (image/resource) 回退为 JSON
|
|
336
|
+
return JSON.stringify(content);
|
|
337
|
+
}
|
|
338
|
+
if (typeof result === "string") return result;
|
|
339
|
+
return JSON.stringify(result);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 提取 tools/call 结果的 { text, isError } (识别 MCP 工具级报错, 供自愈重试)
|
|
343
|
+
export function extractToolResult(result) { if (result == null) return { text: "", isError: false };
|
|
344
|
+
const content = result.content;
|
|
345
|
+
let isError = Boolean(result.isError);
|
|
346
|
+
if (Array.isArray(content)) {
|
|
347
|
+
const texts = [];
|
|
348
|
+
for (const c of content) {
|
|
349
|
+
if (!c) continue;
|
|
350
|
+
if (c.isError) isError = true;
|
|
351
|
+
if (c.type === "text" && typeof c.text === "string") texts.push(c.text);
|
|
352
|
+
else if (c.type === "image" || c.type === "resource" || c.type === "audio") {
|
|
353
|
+
texts.push(`[${c.type}: ${c.mimeType || c.uri || ""}]`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (texts.length) return { text: texts.join("\n"), isError };
|
|
357
|
+
return { text: JSON.stringify(content), isError };
|
|
358
|
+
}
|
|
359
|
+
if (typeof result === "string") return { text: result, isError };
|
|
360
|
+
return { text: JSON.stringify(result), isError };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 提取 resources/read 结果的文本 (MCP 返回 { contents: [{ uri, mimeType, text }] })
|
|
364
|
+
export function extractResourceText(result) {
|
|
365
|
+
if (result == null) return "";
|
|
366
|
+
const contents = result.contents;
|
|
367
|
+
if (Array.isArray(contents)) {
|
|
368
|
+
const texts = contents
|
|
369
|
+
.filter((c) => c && typeof c.text === "string")
|
|
370
|
+
.map((c) => `[${c.uri || "resource"}]\n${c.text}`);
|
|
371
|
+
return texts.join("\n\n") || JSON.stringify(contents);
|
|
372
|
+
}
|
|
373
|
+
if (typeof result === "string") return result;
|
|
374
|
+
return JSON.stringify(result);
|
|
375
|
+
}
|
package/src/mcp/index.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// src/mcp/index.js - MCP 工具注册 (接入 MCP 工具生态)
|
|
2
|
+
// 把 MCP 服务器的工具转换为皮皮虾 ToolCatalog 工具, 与内置工具同权。
|
|
3
|
+
// servers: [
|
|
4
|
+
// { command, args, env, prefix } // stdio (本机)
|
|
5
|
+
// { url, headers, prefix, timeout } // HTTP Streamable (远程)
|
|
6
|
+
// ]
|
|
7
|
+
import { McpClient, extractToolResult, extractResourceText } from "./client.js";
|
|
8
|
+
import { TOOL_ERROR_PREFIX } from "../tools/catalog.js";
|
|
9
|
+
import { warn, info } from "../utils/logger.js";
|
|
10
|
+
|
|
11
|
+
// v1.0.8: MCP 工具名清洗 (只留 \w.-, 截断) — 防恶意服务器注册非法工具名/注入
|
|
12
|
+
export function sanitizeMcpName(name) {
|
|
13
|
+
return String(name || "").replace(/[^\w.-]/g, "_").slice(0, 64);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// P1⑦ (2026-09-15): MCP 命名空间隔离 —— 防工具名碰撞 (Hermes 生产数据: 51 工具服务器上
|
|
17
|
+
// 幻觉工具名命中真实工具; cron job 本想调 kanban_list 结果执行了 list_goals)。
|
|
18
|
+
// 强制 serverName__toolName 前缀 + 精确匹配短路。
|
|
19
|
+
export function serverLabel(s) {
|
|
20
|
+
// 取服务器可读标识: 配置 name > 包名 (args 里 @scope/pkg 或 *.py) > command 首段 > url host
|
|
21
|
+
if (s && s.name) return sanitizeMcpName(s.name);
|
|
22
|
+
const fromArgs = (s && s.args || []).find((a) => /(?:^|\/)[@\w.-]+(?:@[\w.]+)?$/.test(a) && !a.startsWith("-"));
|
|
23
|
+
if (fromArgs) return sanitizeMcpName(fromArgs.split("/").pop());
|
|
24
|
+
if (s && s.command) return sanitizeMcpName(String(s.command).split(/[\\/\s]/).filter(Boolean).pop() || "mcp");
|
|
25
|
+
if (s && s.url) {
|
|
26
|
+
try {
|
|
27
|
+
const host = new URL(s.url).hostname.split(".").filter(Boolean);
|
|
28
|
+
// 取二级域: mcp.example.com → example; www.google.com → google
|
|
29
|
+
return sanitizeMcpName(host.length >= 2 ? host[host.length - 2] : host[0] || "mcp");
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
return "mcp";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function namespacedMcpName(label, raw) {
|
|
36
|
+
const clean = sanitizeMcpName(raw);
|
|
37
|
+
return clean ? `${label}__${clean}` : label;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// v1.0.8: MCP 工具描述清洗 (单行化 + 截断) — 服务器描述直接进 LLM prompt, 防换行/长文本注入指令
|
|
41
|
+
// P1⑦: 额外剔除危险 flag (--system/--dangerously 等命令行注入惯用手法)
|
|
42
|
+
export function sanitizeMcpDescription(desc, name) {
|
|
43
|
+
let s = String(desc || "").replace(/\s+/g, " ").trim();
|
|
44
|
+
// 危险 flag 清洗: 描述里出现命令行注入惯用 flag 视为可疑, 剔除该片段
|
|
45
|
+
s = s.replace(/--(?:system|dangerously[-\w]*|yes|force|no[-\w]+|y)\b/g, "[flag]");
|
|
46
|
+
return s.slice(0, 200) || `MCP 工具: ${name}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 连接所有 MCP 服务器, 列出工具并注册到 catalog。
|
|
50
|
+
// 除 tools 外, 服务器声明 resources/prompts 能力时, 额外注册对应读写工具 (P2⑥)。
|
|
51
|
+
// 返回 { count, clients, close } — close() 关闭全部连接 (调用方负责生命周期)
|
|
52
|
+
export async function registerMcpTools(catalog, servers = []) {
|
|
53
|
+
const clients = [];
|
|
54
|
+
let count = 0;
|
|
55
|
+
for (const s of servers) {
|
|
56
|
+
if (!s) { warn("[mcp] 跳过无效服务器配置"); continue; }
|
|
57
|
+
if (!s.command && !s.url) { warn("[mcp] 跳过无效服务器配置 (缺 command/url)"); continue; }
|
|
58
|
+
const client = new McpClient(s);
|
|
59
|
+
try {
|
|
60
|
+
await client.connect();
|
|
61
|
+
const tools = await client.listTools();
|
|
62
|
+
// P1⑦: 命名空间强制前缀 (serverLabel) —— 默认开启, 可显式 prefix 覆盖/关闭
|
|
63
|
+
const label = serverLabel(s);
|
|
64
|
+
const ns = s.prefix != null ? s.prefix : `${label}__`;
|
|
65
|
+
for (const t of tools) {
|
|
66
|
+
if (!t || !t.name) continue;
|
|
67
|
+
const name = ns + sanitizeMcpName(t.name);
|
|
68
|
+
// P1⑦: 精确匹配短路 —— 注册时登记原始名 (供幻觉工具名防御: 无前缀名永不执行)
|
|
69
|
+
catalog.register({
|
|
70
|
+
name,
|
|
71
|
+
description: sanitizeMcpDescription(t.description, t.name),
|
|
72
|
+
parameters: t.inputSchema || { type: "object", properties: {} },
|
|
73
|
+
category: "mcp",
|
|
74
|
+
// isError 时加错误前缀, 让皮皮虾的自愈重试语义对 MCP 工具同样生效
|
|
75
|
+
execute: async (args) => {
|
|
76
|
+
const raw = await client.callToolRaw(t.name, args || {});
|
|
77
|
+
const { text, isError } = extractToolResult(raw);
|
|
78
|
+
return isError ? `${TOOL_ERROR_PREFIX} MCP 工具 ${t.name}: ${text}` : text;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
count += 1;
|
|
82
|
+
}
|
|
83
|
+
count += registerResourceTools(catalog, client, s, ns);
|
|
84
|
+
clients.push(client);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
warn(`[mcp] 服务器 ${s.command || s.url} 连接失败: ${e.message}`);
|
|
87
|
+
client.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (count) info(`[mcp] 已注册 ${count} 个 MCP 工具`);
|
|
91
|
+
return { count, clients, close: () => clients.forEach((c) => c.close()) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 服务器声明 resources 能力时, 注册 list_resources / read_resource 两个工具 (让 LLM 可读远程资源)
|
|
95
|
+
function registerResourceTools(catalog, client, s, ns = "") {
|
|
96
|
+
if (!client.capabilities?.resources) return 0;
|
|
97
|
+
const label = s.command || s.url || "MCP";
|
|
98
|
+
catalog.register({
|
|
99
|
+
name: ns + "list_resources",
|
|
100
|
+
description: `列出 MCP 服务器 ${label} 提供的资源 (URI 列表)`,
|
|
101
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
102
|
+
category: "mcp",
|
|
103
|
+
execute: async () => {
|
|
104
|
+
const res = await client.listResources();
|
|
105
|
+
return res.map((r) => `${r.uri || "?"}${r.name ? ` — ${r.name}` : ""}`).join("\n") || "(无资源)";
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
catalog.register({
|
|
109
|
+
name: ns + "read_resource",
|
|
110
|
+
description: `读取 MCP 服务器 ${label} 的资源内容 (按 uri)`,
|
|
111
|
+
parameters: { type: "object", properties: { uri: { type: "string", description: "资源 URI" } }, required: ["uri"] },
|
|
112
|
+
category: "mcp",
|
|
113
|
+
execute: async (args) => extractResourceText(await client.readResource((args || {}).uri)),
|
|
114
|
+
});
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// src/memory/asset-hub.js - 记忆资产中枢 (P3⑩)
|
|
2
|
+
// 吸收 TencentDB-Agent-Memory 的"记忆资产 (Memory Asset) / 装备 (Loadout)"设计思想 (仅思想, 无源码复制):
|
|
3
|
+
// - 资产: 可管理的知识单元 (文档库 / 技能 / 经验), 有 owner / visibility / 版本 / 用途计数
|
|
4
|
+
// - 装备 (loadout): 绑定到某 agent 的资产集合, 检索时只注入已装备的资产
|
|
5
|
+
// - 检索隔离: 私有资产仅 owner 可检索; 团队资产共享 (简化: 全局可见)
|
|
6
|
+
// 皮皮虾自研实现, 构建在现有 facts (带 scope) 之上: 资产 = 一组带 scope 的 facts + 元数据登记。
|
|
7
|
+
// 纯代码可测, 无 LLM。
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { ensureDir, writeText, readText } from "../utils/store.js";
|
|
11
|
+
|
|
12
|
+
export const VISIBILITY = { PRIVATE: "private", TEAM: "team" };
|
|
13
|
+
|
|
14
|
+
// 规范化可见性输入: "team"/"TEAM"/VISIBILITY.TEAM 都归一到 "team"; 其余默认 private
|
|
15
|
+
function normalizeVisibility(v) {
|
|
16
|
+
const s = String(v || "").toLowerCase();
|
|
17
|
+
return s === VISIBILITY.TEAM ? VISIBILITY.TEAM : VISIBILITY.PRIVATE;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class AssetHub {
|
|
21
|
+
constructor(dataDir) {
|
|
22
|
+
this.dir = path.join(dataDir, "memory", "assets");
|
|
23
|
+
ensureDir(this.dir);
|
|
24
|
+
this.file = path.join(this.dir, "registry.json");
|
|
25
|
+
this._assets = this._load();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_load() {
|
|
29
|
+
try {
|
|
30
|
+
if (fs.existsSync(this.file)) {
|
|
31
|
+
const d = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
32
|
+
if (Array.isArray(d)) return d;
|
|
33
|
+
}
|
|
34
|
+
} catch {}
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_save() { writeText(this.file, JSON.stringify(this._assets, null, 2)); }
|
|
39
|
+
|
|
40
|
+
// 登记一个资产 (关联到 facts scope)
|
|
41
|
+
// { name, kind: 'document'|'skill'|'experience', scope, owner, visibility, source, description }
|
|
42
|
+
register(asset) {
|
|
43
|
+
if (!asset || !asset.name) throw new Error("资产需 name");
|
|
44
|
+
const id = "as" + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
|
45
|
+
const a = {
|
|
46
|
+
id,
|
|
47
|
+
name: asset.name,
|
|
48
|
+
kind: asset.kind || "document",
|
|
49
|
+
scope: asset.scope || null,
|
|
50
|
+
owner: asset.owner || "local",
|
|
51
|
+
// 规范化可见性: 接受 team/private 或 TEAM/PRIVATE
|
|
52
|
+
visibility: normalizeVisibility(asset.visibility),
|
|
53
|
+
source: asset.source || null,
|
|
54
|
+
description: String(asset.description || "").slice(0, 200),
|
|
55
|
+
version: 1,
|
|
56
|
+
uses: 0,
|
|
57
|
+
createdAt: Date.now(),
|
|
58
|
+
updatedAt: Date.now(),
|
|
59
|
+
};
|
|
60
|
+
this._assets.push(a);
|
|
61
|
+
this._save();
|
|
62
|
+
return a;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 删除资产 (软删: 标记 deleted, 数据保留可恢复)
|
|
66
|
+
remove(id) {
|
|
67
|
+
const a = this._assets.find((x) => x.id === id);
|
|
68
|
+
if (!a) return false;
|
|
69
|
+
a.deleted = true;
|
|
70
|
+
a.deletedAt = Date.now();
|
|
71
|
+
this._save();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
restore(id) {
|
|
76
|
+
const a = this._assets.find((x) => x.id === id && x.deleted);
|
|
77
|
+
if (!a) return false;
|
|
78
|
+
a.deleted = false;
|
|
79
|
+
delete a.deletedAt;
|
|
80
|
+
this._save();
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 装备: 把资产绑定到 agent (recorded in uses), 返回可用资产列表
|
|
85
|
+
equip(id, { by = "local" } = {}) {
|
|
86
|
+
const a = this._assets.find((x) => x.id === id && !x.deleted);
|
|
87
|
+
if (!a) return null;
|
|
88
|
+
a.uses++;
|
|
89
|
+
a.updatedAt = Date.now();
|
|
90
|
+
this._save();
|
|
91
|
+
return a;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 列出资产 (按可见性过滤; deleted 默认隐藏)
|
|
95
|
+
list({ includeDeleted = false, kind = null, visibility = null } = {}) {
|
|
96
|
+
return this._assets.filter((a) => {
|
|
97
|
+
if (!includeDeleted && a.deleted) return false;
|
|
98
|
+
if (kind && a.kind !== kind) return false;
|
|
99
|
+
if (visibility && a.visibility !== visibility) return false;
|
|
100
|
+
return true;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get(id) { return this._assets.find((x) => x.id === id) || null; }
|
|
105
|
+
|
|
106
|
+
// 检索某 agent 可用的资产 (装备过的 + 团队可见), 供注入上下文
|
|
107
|
+
availableFor({ owner = "local" } = {}) {
|
|
108
|
+
return this._assets.filter((a) => !a.deleted && (a.owner === owner || a.visibility === VISIBILITY.TEAM));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 统计
|
|
112
|
+
stats() {
|
|
113
|
+
const active = this._assets.filter((a) => !a.deleted);
|
|
114
|
+
return {
|
|
115
|
+
total: this._assets.length,
|
|
116
|
+
active: active.length,
|
|
117
|
+
byKind: active.reduce((m, a) => { m[a.kind] = (m[a.kind] || 0) + 1; return m; }, {}),
|
|
118
|
+
byVisibility: active.reduce((m, a) => { m[a.visibility] = (m[a.visibility] || 0) + 1; return m; }, {}),
|
|
119
|
+
totalUses: active.reduce((s, a) => s + a.uses, 0),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 渲染资产清单 (注入上下文用, 空则空串)
|
|
124
|
+
renderAvailable({ owner = "local" } = {}) {
|
|
125
|
+
const list = this.availableFor({ owner });
|
|
126
|
+
if (!list.length) return "";
|
|
127
|
+
return `\n# 记忆资产 (可装备)\n` + list.map((a) => `- [${a.kind}] ${a.name}${a.description ? ` — ${a.description}` : ""} (${a.scope ? "scope:" + a.scope : "无scope"})`).join("\n") + "\n";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default AssetHub;
|