Files
MailUI4Agents/plugins/zcode-mail-bridge/lib/bounded.js
JianFeeeee e0e6f86d94 feat(zcode): AgentMail 的 ZCode 插件 —— MCP 工具面 + 官方宿主启动验证
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 均返回
2026-09-12 13:47:51 +08:00

209 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 有界容器 —— 给插件里那些「只增不减」的映射表兜底。
*
* # 为什么需要它
*
* 桥是**常驻进程**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]();
}
}