ZCode 用插件扩展能力(.zcode-plugin/plugin.json 声明 skills/commands/hooks/
mcpServers),所以适配它的正确形状是**插件**而不是又一个独立桥进程。
本提交是第一步:把 AgentMail 的工具面做成 MCP 服务器。
协议层(lib/mcp-rpc.mjs)手写,不引 @modelcontextprotocol/sdk:
协议面只有 initialize / notifications/initialized / tools/list / tools/call,
手写可省掉一条构建链与 1MB 打包产物(与 pi/opencode/dsh 三桥零运行时依赖的
取向一致),并让这一层成为可穷举的纯函数。分帧照官方插件产物实测确认是
换行分隔 JSON(Content-Length 出现 0 次,StdioServerTransport + split("\n"))。
工具面(lib/tools.mjs)与另三个桥**同名同参**,渲染走共用的
addressing/inbox-format/discovery(逐字节同源,已纳入 check-shared-libs.sh)。
测试里有一条断言直接拿 pi 桥的工具名做对照:少一个就让某平台行为与其它平台不同,
那种问题只在单平台复现,排查代价最高。
两处按真实缺陷定的行为:
- 工具失败回 result+isError 而非 JSON-RPC error —— 后者会让模型看不到失败原因,
只能重试(opencode 连试 6 次发不出附件正是这个后果)
- attachment_ids 声明放宽为 anyOf 数组/字符串并在桥侧归一 —— 模型常写成
JSON 字符串,服务端严格解码会拒(同样来自 opencode 那次失败)
入口 mcp/server.mjs 修掉一个真实缺陷:stdin 关闭即 process.exit 会杀掉在途请求,
表现为「协议全对但访问网关的调用完全没有响应」。现按在途计数 drain,
且把 stdout 写入也计入,避免最后一条响应卡在缓冲区。
顺带修 check-shared-libs.sh 的一个既有假绿:本机 PATH 上的 diff 是鸿蒙 SDK
工具链的 diff,不认 -q 且对不同的文件仍返回 0 —— 于是该检查器**一直是永真输出**。
改用 cmp -s,并加自检(判据本身必须先被证明能发现差异)。反向验证:
让 zcode 或 pi 的共用模块分叉,检查器都正确报错并返回 1。
验证:
- 单元 33 项 + 继承共用测试 87 项 = 120/120
- `zcode plugins list` → agentmail@inline [enabled],mcp: plugin:agentmail:agentmail
- 经官方 `node zcode.cjs __zcode-plugin-host <server.mjs>` 启动 → 握手与 tools/list 正常
- 真实网关调用:以 zcode 身份 read_inbox / suggest_address / list_contacts 均返回
209 lines
6.5 KiB
JavaScript
209 lines
6.5 KiB
JavaScript
/**
|
||
* 有界容器 —— 给插件里那些「只增不减」的映射表兜底。
|
||
*
|
||
* # 为什么需要它
|
||
*
|
||
* 桥是**常驻进程**(pi 的守护进程能跑几十天,opencode/DSH 的插件跟着平台一起活)。
|
||
* 里面每一张 `Map`/`Set` 都在回答「这条会话/这封邮件我处理过吗」,键来自外部
|
||
* 事件流 —— 会话数与邮件数随时间单调增长,键却没有出口。
|
||
*
|
||
* 单条成本很小(uuid 键 + 短字符串值,几十到几百字节),所以它不是几小时内撑爆
|
||
* 内存的那种故障。实际形态是:跑够久之后进程里躺着几十万个再也不会被查到的条目,
|
||
* 且 **GC 回收不了**(还被强引用着)。这类问题不会在开发和测试里出现,
|
||
* 只在生产上跑了几周后表现为「重启一下就好了」。
|
||
*
|
||
* # 淘汰策略:丢最久没被访问的
|
||
*
|
||
* JS 的 `Map`/`Set` 保证插入顺序,所以「删掉再插入」等价于「移到队尾」。
|
||
* 读也算访问(`get`/`has` 会刷新顺序),于是长期活跃的会话不会因为条目老被丢掉 ——
|
||
* 被淘汰的总是「很久没人问过」的那些。
|
||
*
|
||
* # 上限分表定义,因为丢一条的后果差别很大
|
||
*
|
||
* - `deliveredMails` 丢一条 → 那封邮件**理论上**可能被重复投递。但它防的两种
|
||
* 重复(心跳与 SSE 建连之间的窗口、SSE 断线重放)都发生在秒到分钟级,
|
||
* 几千封之前的 mail_id 不可能再来 —— 淘汰是安全的。
|
||
*
|
||
* - 会话级映射丢一条 → 那条会话下次来信时被当成新会话,平台侧上下文断掉。
|
||
* 这是**真的行为退化**,所以上限给得大得多,并且优先靠 `session_archived`
|
||
* 主动清理,让上限只当兜底。
|
||
*
|
||
* # 不要用它装「还在等结果的东西」
|
||
*
|
||
* 待决权限询问(opencode 的 `pendingPermissions`、DSH 的 `pendingApprovals`)
|
||
* 里存的是 `resolve` 回调。静默淘汰一条会让对应的 `await` **永远不返回** ——
|
||
* 平台侧那次工具调用就挂死了。那些表有确定的清理路径(决策到达 / 超时 / 拆插件
|
||
* 时 fail closed),不该套上界。上界只适合「记录已经发生过的事实」的表。
|
||
*
|
||
* 三平台共用,必须逐字节相同(deploy/check-shared-libs.sh 校验)。
|
||
*/
|
||
|
||
/**
|
||
* 已投递邮件 id 的记忆上限。
|
||
*
|
||
* 2000 覆盖的是去重真正需要的时间窗:SSE 重放最多回放服务端环形缓冲的 500 条
|
||
* 事件,一次补拉最多 5 封。留 2000 是三个数量级的余量,内存代价约 200KB。
|
||
*/
|
||
export const MAX_TRACKED_MAILS = 2000;
|
||
|
||
/**
|
||
* 会话级映射的条目上限。
|
||
*
|
||
* 淘汰一条会让那条会话失去平台侧上下文,所以这个数字要远大于「同时在推进的
|
||
* 任务数」。500 条 × 每条几百字节 ≈ 150KB —— 便宜到没有理由抠。
|
||
*
|
||
* 真正的清理来自 `session_archived`:会话归档后它的映射再无用处,那是确定性
|
||
* 时机;上限兜的是「一直不归档」。
|
||
*/
|
||
export const MAX_TRACKED_SESSIONS = 500;
|
||
|
||
function normalizeLimit(limit) {
|
||
const n = Number(limit);
|
||
// 上限必须是正整数:0 会让每次 set 之后立刻把自己淘汰掉(表恒空,去重全部
|
||
// 失效且不报错),NaN 会让 while 条件恒假(退化成无界)。两种都是静默的
|
||
// 错误行为,不如当场拒绝。
|
||
if (!Number.isFinite(n) || n < 1) {
|
||
throw new RangeError(`有界容器的上限必须是 >= 1 的整数,收到 ${limit}`);
|
||
}
|
||
return Math.floor(n);
|
||
}
|
||
|
||
/**
|
||
* 有界 Map,超过上限时丢弃最久未访问的条目。
|
||
*
|
||
* 只实现桥里真正用到的那几个方法 —— 不做成 Map 的完整替身,那样会掩盖
|
||
* 「这张表是有界的」这个必须被看见的事实。
|
||
*/
|
||
export class BoundedMap {
|
||
/** @param {number} limit 条目上限 */
|
||
constructor(limit) {
|
||
this.limit = normalizeLimit(limit);
|
||
/** @type {Map<any, any>} */
|
||
this.map = new Map();
|
||
/** 累计淘汰条数,观测用(日志里能看出上限是否设得太小)。 */
|
||
this.evicted = 0;
|
||
}
|
||
|
||
get size() {
|
||
return this.map.size;
|
||
}
|
||
|
||
has(key) {
|
||
return this.map.has(key);
|
||
}
|
||
|
||
/**
|
||
* 取值并把该键移到队尾。
|
||
*
|
||
* 读也算访问:一条会话只要还在收信就会被反复 get,不刷新的话它会因为
|
||
* 「插入得早」被淘汰 —— 那恰好淘汰了最该留的那些。
|
||
*/
|
||
get(key) {
|
||
if (!this.map.has(key)) return undefined;
|
||
const value = this.map.get(key);
|
||
this.map.delete(key);
|
||
this.map.set(key, value);
|
||
return value;
|
||
}
|
||
|
||
/** 取值但**不**刷新顺序。给「只是想看一眼」的场合。 */
|
||
peek(key) {
|
||
return this.map.get(key);
|
||
}
|
||
|
||
set(key, value) {
|
||
// 已存在时先删:Map 的 set 不改变已有键的位置,不删就刷不了活跃度。
|
||
if (this.map.has(key)) this.map.delete(key);
|
||
this.map.set(key, value);
|
||
while (this.map.size > this.limit) {
|
||
const oldest = this.map.keys().next().value;
|
||
this.map.delete(oldest);
|
||
this.evicted++;
|
||
}
|
||
return this;
|
||
}
|
||
|
||
delete(key) {
|
||
return this.map.delete(key);
|
||
}
|
||
|
||
clear() {
|
||
this.map.clear();
|
||
}
|
||
|
||
keys() {
|
||
return this.map.keys();
|
||
}
|
||
|
||
values() {
|
||
return this.map.values();
|
||
}
|
||
|
||
entries() {
|
||
return this.map.entries();
|
||
}
|
||
|
||
[Symbol.iterator]() {
|
||
return this.map[Symbol.iterator]();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 有界 Set,超过上限时丢弃最久未访问的成员。
|
||
*
|
||
* `has` 也刷新顺序:与 `BoundedMap.get` 同理。对 `deliveredMails` 这意味着
|
||
* 「刚被去重挡下的那封」会留得更久,正合语义。
|
||
*/
|
||
export class BoundedSet {
|
||
/** @param {number} limit 成员上限 */
|
||
constructor(limit) {
|
||
this.limit = normalizeLimit(limit);
|
||
/** @type {Set<any>} */
|
||
this.set = new Set();
|
||
this.evicted = 0;
|
||
}
|
||
|
||
get size() {
|
||
return this.set.size;
|
||
}
|
||
|
||
has(value) {
|
||
if (!this.set.has(value)) return false;
|
||
this.set.delete(value);
|
||
this.set.add(value);
|
||
return true;
|
||
}
|
||
|
||
/** 判断存在但**不**刷新顺序。 */
|
||
peek(value) {
|
||
return this.set.has(value);
|
||
}
|
||
|
||
add(value) {
|
||
if (this.set.has(value)) this.set.delete(value);
|
||
this.set.add(value);
|
||
while (this.set.size > this.limit) {
|
||
const oldest = this.set.values().next().value;
|
||
this.set.delete(oldest);
|
||
this.evicted++;
|
||
}
|
||
return this;
|
||
}
|
||
|
||
delete(value) {
|
||
return this.set.delete(value);
|
||
}
|
||
|
||
clear() {
|
||
this.set.clear();
|
||
}
|
||
|
||
values() {
|
||
return this.set.values();
|
||
}
|
||
|
||
[Symbol.iterator]() {
|
||
return this.set[Symbol.iterator]();
|
||
}
|
||
}
|