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 均返回
142 lines
5.6 KiB
JavaScript
142 lines
5.6 KiB
JavaScript
/**
|
||
* 三维寻址的构造与判读 —— 所有平台插件共用。
|
||
*
|
||
* 为什么这些函数必须共用、且必须是纯函数:
|
||
*
|
||
* 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被
|
||
* `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。
|
||
* 生产上真实发生过两次:
|
||
*
|
||
* 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会
|
||
* 再建一条平行会话,双方从此各说各话。
|
||
* 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`,
|
||
* 整串被当成名字,session 位静默丢失。
|
||
*
|
||
* 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。
|
||
*/
|
||
|
||
/**
|
||
* 拼一个可寻址的 `name@path.session`。
|
||
*
|
||
* **空 path 也必须留下 `@` 与 `.`**:`admin@.silent-harbor` 才解析成
|
||
* name=admin path="" session=silent-harbor。省掉 `@` 得到的
|
||
* `admin.silent-harbor` 会被整串当作名字。
|
||
*
|
||
* session 省略时不写那一位(默认会话语义)。
|
||
*
|
||
* @param {string} name 收件方名(Agent 名或人类用户名)
|
||
* @param {string} [path] 工作目录,可为空
|
||
* @param {string} [session] 会话别名;空则省略该位
|
||
* @returns {string} 地址,name 为空时返回空串
|
||
*/
|
||
export function formatAddress(name, path, session) {
|
||
const n = String(name ?? '').trim();
|
||
const p = String(path ?? '').trim();
|
||
const s = String(session ?? '').trim();
|
||
if (!n) return '';
|
||
if (!s) return p ? `${n}@${p}` : n;
|
||
return `${n}@${p}.${s}`;
|
||
}
|
||
|
||
/**
|
||
* 判断自己在这封邮件里是收件人还是抄送方。
|
||
*
|
||
* 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里,
|
||
* admin 主发 dsh、抄送 opencode,分工是「dsh 提供源码解读、opencode 提供部署
|
||
* 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人,
|
||
* 或者都以为自己只是旁观者。
|
||
*
|
||
* @param {any} mail `/mail/inbox` 返回的一封邮件
|
||
* @param {string} selfName 自己的 Agent 名
|
||
* @returns {'to'|'cc'|'unknown'}
|
||
*/
|
||
export function roleOf(mail, selfName) {
|
||
const self = String(selfName ?? '').trim();
|
||
if (!self) return 'unknown';
|
||
if (mail?.to_name === self) return 'to';
|
||
if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) {
|
||
return 'cc';
|
||
}
|
||
return 'unknown';
|
||
}
|
||
|
||
/**
|
||
* 给出「把回信发回这条会话」的地址。
|
||
*
|
||
* 发件人一侧**不带 path**:Agent 回信时 `from_workspace` 存的是 Agent 名而不是
|
||
* 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。
|
||
* 人类发件人本来就没有工作目录。
|
||
*
|
||
* 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」
|
||
* 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。
|
||
*
|
||
* @param {any} mail 一封邮件
|
||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||
* @returns {string}
|
||
*/
|
||
export function replyAddressFor(mail, alias) {
|
||
const a = alias ?? mail?.session_alias ?? '';
|
||
return formatAddress(mail?.from_name, '', a);
|
||
}
|
||
|
||
/**
|
||
* 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。
|
||
*
|
||
* 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的:
|
||
* 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。
|
||
*
|
||
* @param {any} mail 一封邮件
|
||
* @param {string} selfName 自己的 Agent 名
|
||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||
* @returns {string}
|
||
*/
|
||
export function selfAddressFor(mail, selfName, alias) {
|
||
const a = alias ?? mail?.session_alias ?? '';
|
||
// 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。
|
||
// 不取对的那个会让「我是谁」这句话指向别人的工作目录。
|
||
let path = mail?.to_workspace ?? '';
|
||
if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) {
|
||
const mine = mail.cc_list.find(c => c?.name === selfName);
|
||
if (mine) path = mine.path ?? '';
|
||
}
|
||
return formatAddress(selfName, path, a);
|
||
}
|
||
|
||
/**
|
||
* 列出这封邮件的全部参与方及各自可投递的地址。
|
||
*
|
||
* 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。
|
||
* 抄送方的 path 取它自己那个地址的 path 位。
|
||
*
|
||
* 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认
|
||
* 「这封信是不是也发给了我」,也就无法判断自己是不是该回。
|
||
*
|
||
* @param {any} mail 一封邮件
|
||
* @param {string} [selfName] 自己的名字,用于标记 is_self
|
||
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
|
||
* @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]}
|
||
*/
|
||
export function participantsOfMail(mail, selfName, alias) {
|
||
const a = alias ?? mail?.session_alias ?? '';
|
||
const self = String(selfName ?? '').trim();
|
||
const out = [];
|
||
const add = (role, name, path) => {
|
||
const n = String(name ?? '').trim();
|
||
if (!n) return;
|
||
out.push({
|
||
role,
|
||
name: n,
|
||
path: String(path ?? ''),
|
||
address: formatAddress(n, path, a),
|
||
is_self: !!self && n === self,
|
||
});
|
||
};
|
||
// 发件人一侧 path 留空,理由同 replyAddressFor
|
||
add('from', mail?.from_name, '');
|
||
add('to', mail?.to_name, mail?.to_workspace);
|
||
if (Array.isArray(mail?.cc_list)) {
|
||
for (const c of mail.cc_list) add('cc', c?.name, c?.path);
|
||
}
|
||
return out;
|
||
}
|