docs: 插件适配指南 + 共用模块提取(为接入更多平台做准备)
两次适配(opencode、DeepSeek Harness)里的方法与坑此前散落在提交信息和 代码注释里,接第三个平台时要重新翻。这次固化成文档,并把与平台 SDK 无关的 逻辑提到共用模块。 ## docs/PLUGIN-GUIDE.md 八节:职责边界、必须实现的六件事、会话命名回写、平台会话快照上报、 平台差异对照表、踩过的坑(按排查成本降序)、新平台适配清单、共用模块清单。 三条设计原则贯穿全文,后面每一节都是它们的推论: 1. **平台原生信号才是真相来源**,不要求模型「记得」调工具 —— 因此不提供 request_permission(改挂权限钩子)、不要求模型主动回信(改在「一轮结束」 的平台信号上自动转发) 2. **插件代劳的转发不消耗配额** —— 因此这两类转发带 relay + relay_key 3. **平台命名优先** —— 因此创建会话时不传占位标题(那会掐掉平台自己的命名机制) 「踩过的坑」一节按排查成本排序,头一条是花了一下午的 followup() 参数形状。 ## 共用模块提取 `lib/inbox-format.js`(新):收件箱渲染与已读策略。三条规则各对应一次错误行为, 而它们与平台 SDK 无关: - 附件必须带 attachment_id(只说「有附件」模型无从下载) - 抄送人要显示(不显示模型以为是私信,回信时漏掉其他参与方) - 只标本次列出的、status=all 时不标(limit 之外的还没看过;把历史邮件标成已读 会让下一轮的新邮件混在里面认不出来) 顺带修好两处不一致:DSH 的 read_inbox 此前**完全没有标记已读**(每轮重复捞同一批), 且默认 status=all(同上);附件大小两边一个显示字节数一个显示 KB/MB。 `lib/workspace.js`:提到两侧共用。签名从 (workspace, fallbackKey) 改为 (workspace, fallback) —— 各平台的兜底不同:opencode 有插件启动时的 directory, DSH 只能落到 ~/.dsh/mail-sessions/<会话>(mailSessionFallback)。 opencode 侧此前是内联的三行判断,没有「目录不存在时不创建」与「拒绝相对路径」 这两条保护。 ## deploy/check-shared-libs.sh `lib/` 与 `test/` 下的共用文件必须逐字节相同,纳入 install.sh 门禁。 一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边 标了已读、在 DSH 那边没标,而两处代码看起来都「对」。这类分叉没有测试能发现, 只能靠 diff。 ## 文档同步 - PLAN.md §7.7 从「待做」改为已完成,补 7.7.1(工作目录归属)与 7.7.2(平台会话快照)两节,记录根因而非只记改法 - API.md 加「心跳与平台会话快照」章节;SSE 章节补 new_mail 与 permission_decision 的 payload 说明(to_workspace 的语义、relay_key 的用途) - PHASE7-REMAINING.md 移除已完成的 7.7,新增「每平台可用模型范围」的进展 (repo 层已就绪,handler/插件/前端待做) - README 文档索引与项目结构 验证:两插件共 136 个测试通过,同源校验通过,Go/前端全绿; 端到端发信 → DSH 用新的 read_inbox 渲染读取 → 自动回信 213 字节。
This commit is contained in:
90
plugins/opencode-mail-bridge/lib/inbox-format.js
Normal file
90
plugins/opencode-mail-bridge/lib/inbox-format.js
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 收件箱渲染与已读策略 —— 所有平台插件共用。
|
||||
*
|
||||
* 提到 lib/ 是因为这几条规则每一条都对应过一次真实的错误行为,而它们与
|
||||
* 平台 SDK 无关:无论 opencode 的 zod 工具还是 DSH 的 defineTool,
|
||||
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
|
||||
*/
|
||||
|
||||
/** 人类可读的字节数,用于附件清单展示。 */
|
||||
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 正文截断长度
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderMail(m, bodyLimit = 200) {
|
||||
const lines = [
|
||||
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
|
||||
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
|
||||
`会话: #${m?.session_alias || '未命名'}`,
|
||||
];
|
||||
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
|
||||
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
|
||||
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
|
||||
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
|
||||
}
|
||||
// **必须给出 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)}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染整个收件箱。
|
||||
* @param {any[]} mails
|
||||
* @param {number} bodyLimit
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderInbox(mails, bodyLimit = 200) {
|
||||
const list = Array.isArray(mails) ? mails : [];
|
||||
if (list.length === 0) return '收件箱为空。';
|
||||
return list.map(m => renderMail(m, bodyLimit)).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;
|
||||
Reference in New Issue
Block a user