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 均返回
145 lines
5.8 KiB
JavaScript
145 lines
5.8 KiB
JavaScript
/**
|
||
* 收件箱渲染与已读策略 —— 所有平台插件共用。
|
||
*
|
||
* 提到 lib/ 是因为这几条规则每一条都对应过一次真实的错误行为,而它们与
|
||
* 平台 SDK 无关:无论 opencode 的 zod 工具还是 DSH 的 defineTool,
|
||
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
|
||
*/
|
||
|
||
import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js';
|
||
|
||
/** 人类可读的字节数,用于附件清单展示。 */
|
||
export function formatSize(n) {
|
||
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
|
||
if (n < 1024) return `${n} B`;
|
||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
/**
|
||
* 把一封邮件渲染成模型可读的文本块。
|
||
*
|
||
* @param {any} m `/mail/inbox` 返回的一封邮件
|
||
* @param {number} bodyLimit 正文截断长度
|
||
* @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」
|
||
* 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。
|
||
* @returns {string}
|
||
*/
|
||
export function renderMail(m, bodyLimit = 200, selfName = '') {
|
||
const alias = m?.session_alias || '';
|
||
const lines = [
|
||
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
|
||
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
|
||
`会话: #${alias || '未命名'}`,
|
||
];
|
||
|
||
// 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁,
|
||
// 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」,
|
||
// 抄送方却看不到收件人叫什么。
|
||
if (m?.to_name) {
|
||
let toLine = `收件人: ${m.to_name}`;
|
||
if (m?.to_workspace) toLine += `@${m.to_workspace}`;
|
||
lines.push(toLine);
|
||
}
|
||
|
||
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
|
||
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
|
||
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
|
||
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
|
||
}
|
||
|
||
// 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会
|
||
// 以为自己是负责人,或者都以为自己只是旁观者。
|
||
if (selfName) {
|
||
const role = roleOf(m, selfName);
|
||
if (role === 'to') lines.push('你的身份: 收件人(主办)');
|
||
else if (role === 'cc') lines.push('你的身份: 抄送方(配合)');
|
||
}
|
||
|
||
// **必须给出 attachment_id**:只说「有附件」模型就无从下载。
|
||
if (Array.isArray(m?.attachments) && m.attachments.length > 0) {
|
||
lines.push(
|
||
'附件: ' +
|
||
m.attachments
|
||
.map(a => `${a?.filename ?? '?'}(${formatSize(a?.size_bytes)}, id=${a?.attachment_id ?? '?'})`)
|
||
.join('、')
|
||
);
|
||
lines.push('下载附件请用 download_attachment 工具。');
|
||
}
|
||
|
||
// 列表接口只给 body_preview(省带宽),单封接口才有 body。两者都兜住。
|
||
const body = m?.body_preview || m?.body || '';
|
||
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
|
||
|
||
// 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。
|
||
//
|
||
// 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个
|
||
// `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条
|
||
// 平行会话。这里给的地址全部已经把 session 位换成真实别名。
|
||
if (selfName && alias) {
|
||
const parts = participantsOfMail(m, selfName, alias);
|
||
const others = parts.filter(p => !p.is_self && p.address);
|
||
if (others.length > 0) {
|
||
lines.push(
|
||
'可投递地址: ' +
|
||
others.map(p => `${p.address}(${roleLabel(p.role)})`).join('、')
|
||
);
|
||
lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}。`);
|
||
}
|
||
}
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
|
||
function roleLabel(role) {
|
||
switch (role) {
|
||
case 'from': return '发件人';
|
||
case 'to': return '收件人';
|
||
case 'cc': return '抄送方';
|
||
default: return role;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 渲染整个收件箱。
|
||
* @param {any[]} mails
|
||
* @param {number} bodyLimit
|
||
* @param {string} [selfName] 自己的 Agent 名,透传给 renderMail
|
||
* @returns {string}
|
||
*/
|
||
export function renderInbox(mails, bodyLimit = 200, selfName = '') {
|
||
const list = Array.isArray(mails) ? mails : [];
|
||
if (list.length === 0) return '收件箱为空。';
|
||
return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n');
|
||
}
|
||
|
||
/**
|
||
* 判断本次读取该标记哪些邮件为已读。
|
||
*
|
||
* 两条规则:
|
||
*
|
||
* 1. **只标本次真正列出来的**,不是全部未读。`limit` 之外的还没看过,
|
||
* 一并标掉等于让它们凭空消失。
|
||
* 2. **`status=all` 时不标**。那是「回顾历史」的读法,把历史邮件标成已读
|
||
* 会让下一轮真正的新邮件混在里面认不出来。
|
||
*
|
||
* 不标的后果是每次拉收件箱都重复捞同一批,处理过的和新来的混在一起,
|
||
* 模型分不清哪封该回。
|
||
*
|
||
* @param {string|undefined} status 本次查询用的过滤条件
|
||
* @param {any[]} mails 本次返回的邮件
|
||
* @returns {string[]} 待标记的 mail_id,空数组表示不需要标记
|
||
*/
|
||
export function idsToMarkRead(status, mails) {
|
||
if (status === 'all') return [];
|
||
const list = Array.isArray(mails) ? mails : [];
|
||
return list.map(m => m?.mail_id).filter(id => typeof id === 'string' && id);
|
||
}
|
||
|
||
/** 收件箱默认过滤条件。默认只看未读 —— 默认 all 会让模型每轮重读旧邮件。 */
|
||
export const DEFAULT_INBOX_STATUS = 'unread';
|
||
|
||
/** 收件箱默认返回条数。 */
|
||
export const DEFAULT_INBOX_LIMIT = 5;
|