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 均返回
238 lines
9.9 KiB
JavaScript
238 lines
9.9 KiB
JavaScript
/**
|
||
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
|
||
*
|
||
* 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、
|
||
* `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关,
|
||
* 所以收进这里。各平台只负责把自己的工具定义壳套上去。
|
||
*
|
||
* # 这一组端点解决的问题
|
||
*
|
||
* 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。
|
||
* 人类侧从来不是这样:三段式输入框逐段查候选,name / path / session 每一段都从
|
||
* 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了
|
||
* `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录,
|
||
* 那个错误路径静默变成了新会话的 workspace。
|
||
*
|
||
* # 渲染的取舍
|
||
*
|
||
* 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到
|
||
* `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home
|
||
* session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。
|
||
*/
|
||
|
||
/**
|
||
* 渲染候选收件人清单(`kind: "name"`)。
|
||
*
|
||
* 只给名字,不给地址:此时还不知道 path 与 session,硬拼出来的
|
||
* 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。
|
||
* 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。
|
||
*
|
||
* @param {string[]} names
|
||
* @returns {string}
|
||
*/
|
||
export function renderNameSuggestions(names) {
|
||
const list = Array.isArray(names) ? names.filter(Boolean) : [];
|
||
if (list.length === 0) return '当前没有可投递的收件人。';
|
||
return [
|
||
`可投递的收件人(${list.length} 个):`,
|
||
list.map(n => `- ${n}`).join('\n'),
|
||
'',
|
||
'下一步:用 suggest_address 带上 name 查它可用的工作目录(path 位)。',
|
||
].join('\n');
|
||
}
|
||
|
||
/**
|
||
* 渲染工作目录候选(`kind: "path"`)。
|
||
*
|
||
* 空列表要说清「这不代表不能发」:path 位允许为空(人类用户没有工作目录),
|
||
* 不解释的话模型会卡在这一步,或者编一个路径出来。
|
||
*
|
||
* @param {string[]} paths
|
||
* @param {string} name 正在查的收件人名,用于拼下一步的提示
|
||
* @returns {string}
|
||
*/
|
||
export function renderPathSuggestions(paths, name) {
|
||
const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
|
||
if (list.length === 0) {
|
||
return [
|
||
`${name} 没有记录在案的工作目录。`,
|
||
'这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。',
|
||
`直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}。`,
|
||
].join('\n');
|
||
}
|
||
return [
|
||
`${name} 用过的工作目录(按最近使用排序):`,
|
||
list.map(p => `- ${p}`).join('\n'),
|
||
'',
|
||
`下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`,
|
||
].join('\n');
|
||
}
|
||
|
||
/**
|
||
* 渲染会话候选(`kind: "session"`)。
|
||
*
|
||
* **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`:
|
||
* 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。
|
||
*
|
||
* `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型
|
||
* 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
|
||
*
|
||
* @param {object} data `/agent/contacts/suggest` 的返回体
|
||
* @param {string} name
|
||
* @param {string} path
|
||
* @returns {string}
|
||
*/
|
||
export function renderSessionSuggestions(data, name, path) {
|
||
const aliases = Array.isArray(data?.suggestions) ? data.suggestions : [];
|
||
const addresses = Array.isArray(data?.addresses) ? data.addresses : [];
|
||
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
|
||
|
||
// 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话
|
||
const existing = aliases.filter(a => a !== 'new');
|
||
if (existing.length === 0) {
|
||
return [
|
||
`${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`,
|
||
`要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`},`,
|
||
'并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。',
|
||
].join('\n');
|
||
}
|
||
|
||
const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`];
|
||
for (let i = 0; i < aliases.length; i++) {
|
||
const alias = aliases[i];
|
||
const addr = addresses[i] || '';
|
||
const c = candidates[i] || {};
|
||
if (alias === 'new') continue; // new 单独放最后
|
||
const bits = [];
|
||
if (c.title) bits.push(c.title);
|
||
if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`);
|
||
if (c.source === 'platform') bits.push('平台侧会话');
|
||
lines.push(`- ${addr || alias}${bits.length ? ` (${bits.join(',')})` : ''}`);
|
||
}
|
||
lines.push('');
|
||
lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。');
|
||
const newAddr = addressAt(addresses, aliases, 'new');
|
||
if (newAddr) {
|
||
lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}。`);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/** 按别名在同序的 addresses 里取地址。 */
|
||
function addressAt(addresses, aliases, alias) {
|
||
const i = aliases.indexOf(alias);
|
||
return i >= 0 ? addresses[i] || '' : '';
|
||
}
|
||
|
||
/**
|
||
* 渲染会话参与方清单。
|
||
*
|
||
* 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、
|
||
* 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数
|
||
* 「作为发件人」的邮件,正是为了让这个判断成立。
|
||
*
|
||
* @param {object} data `/agent/sessions/{id}/participants` 的返回体
|
||
* @returns {string}
|
||
*/
|
||
export function renderParticipants(data) {
|
||
const parts = Array.isArray(data?.participants) ? data.participants : [];
|
||
if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。';
|
||
|
||
const alias = data?.session_alias || '';
|
||
const lines = [`会话 #${alias || '未命名'} 的参与方:`];
|
||
for (const p of parts) {
|
||
const tags = [];
|
||
if (p.is_self) tags.push('就是你');
|
||
if (Array.isArray(p.roles) && p.roles.length) {
|
||
tags.push(p.roles.map(roleLabel).join('/'));
|
||
}
|
||
if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应');
|
||
const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)';
|
||
lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join(',')}]` : ''}`);
|
||
}
|
||
lines.push('');
|
||
lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。');
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* 渲染联系人清单(本 Agent 参与过的全部会话)。
|
||
*
|
||
* 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时,
|
||
* 有未读的那些才是答案。
|
||
*
|
||
* @param {object} data `/agent/contacts` 的返回体
|
||
* @param {number} limit 最多列出多少条
|
||
* @returns {string}
|
||
*/
|
||
export function renderContacts(data, limit = 20) {
|
||
const list = Array.isArray(data?.contacts) ? data.contacts.slice() : [];
|
||
if (list.length === 0) return '还没有任何往来会话。';
|
||
|
||
list.sort((a, b) => {
|
||
const ua = a?.unread_count || 0;
|
||
const ub = b?.unread_count || 0;
|
||
if (ua !== ub) return ub - ua;
|
||
return String(b?.last_activity || '').localeCompare(String(a?.last_activity || ''));
|
||
});
|
||
|
||
const shown = list.slice(0, limit);
|
||
const lines = [`往来会话(共 ${list.length} 条${list.length > shown.length ? `,列出前 ${shown.length}` : ''}):`];
|
||
for (const c of shown) {
|
||
const bits = [];
|
||
if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`);
|
||
if (c.subject) bits.push(c.subject);
|
||
if (c.max_rounds > 0) {
|
||
const left = Math.max(0, c.max_rounds - (c.used_rounds || 0));
|
||
bits.push(`剩 ${left}/${c.max_rounds} 个来回`);
|
||
}
|
||
const addr = c.address || '(未命名会话,只能用 reply_to 续谈)';
|
||
lines.push(`- ${addr}${bits.length ? ` (${bits.join(',')})` : ''}`);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
|
||
function roleLabel(role) {
|
||
switch (role) {
|
||
case 'from': return '发件人';
|
||
case 'to': return '收件人';
|
||
case 'cc': return '抄送方';
|
||
default: return String(role);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 渲染对话树,回答「谁已经回了、谁还没回」。
|
||
*
|
||
* 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里
|
||
* (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。
|
||
*
|
||
* @param {object} data `/agent/mail/{id}/thread` 的返回体
|
||
* @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的
|
||
* @returns {string}
|
||
*/
|
||
export function renderThread(data, selfName = '') {
|
||
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
|
||
if (nodes.length === 0) return '这条线索上没有可见的邮件。';
|
||
|
||
const lines = [`线索共 ${data?.total ?? nodes.length} 封${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}:`];
|
||
for (const n of nodes) {
|
||
const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0;
|
||
const indent = ' '.repeat(Math.min(depth, 8));
|
||
const marks = [];
|
||
if (selfName && n?.from_name === selfName) marks.push('你发的');
|
||
if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封');
|
||
if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载');
|
||
lines.push(
|
||
`${indent}- ${n?.from_name ?? '?'} → ${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` +
|
||
` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join(',')})` : ''}`
|
||
);
|
||
}
|
||
if (data?.has_more) {
|
||
lines.push('');
|
||
lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`);
|
||
}
|
||
return lines.join('\n');
|
||
}
|