L0 核心: - 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点 - DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields - 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag) - 日历 status 校验(create 补字段 + update 拦非法值) - 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例 A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按 解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了 A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*), SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查, 48h 年龄下限兜上传窗口。已接进每小时 sweep 循环 C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human (EXISTS users 判定),models.Mail 加 ToHuman。前端判据从 workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart 从 session_workspace 取 path(修 dsh@dsh 拼接 bug) 契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/ permission_mode/permission_enforcement),B-5 加 B-5.6 (Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement + unknown_fields,demo 死链修复 + from_human 检查 验收清单加 Agent→Agent 负向对照项
71 lines
3.3 KiB
JavaScript
71 lines
3.3 KiB
JavaScript
// 自动转发去重的纯逻辑。
|
||
//
|
||
// 单独一个文件而不是放在 index.js 里导出:**opencode 会把插件入口模块的
|
||
// 每一个导出都当成插件工厂**(`Object.values(mod)` 逐个检查是不是函数),
|
||
// 多导出一个 Map 就会让整个插件加载失败:
|
||
// ERROR message="failed to load plugin" error="Plugin export is not a function"
|
||
// 实测踩过 —— 插件静默不加载,邮件全都投不进去。
|
||
// 因此入口文件只能 `export default`,其余东西一律搁在这里。
|
||
|
||
import { BoundedMap, MAX_TRACKED_SESSIONS } from './bounded.js';
|
||
|
||
/** 取三维地址的名字段:admin@root.alias -> admin */
|
||
export function addrName(addr) {
|
||
return String(addr || "").split("@")[0].trim();
|
||
}
|
||
|
||
/**
|
||
* 本轮内模型**自己调 send_mail** 发出去的信(按 opencode 会话)。
|
||
*
|
||
* session.idle 的自动转发要据此让位:模型已经亲手回过这条线索了,
|
||
* 再把它最后那段话转一遍,收件箱里就是两封内容几乎一样的邮件。
|
||
* 生产实测过这个后果 —— 同一轮里 311 字节和 342 字节各一封,
|
||
* 说的是同一件事,其中带附件的那封才是模型真正想发的。
|
||
*
|
||
* 为什么不靠 relay_key 幂等:那个键是 assistant message id,
|
||
* 保证的是「同一条消息不被转两次」,管不了「模型已经自己发过了」。
|
||
*
|
||
* 窗口是「一轮」:deliverMail 投递新邮件时清空(新一轮开始),
|
||
* relaySummary 用完即清。
|
||
*
|
||
* # 为什么仍要有界
|
||
*
|
||
* 上面那些清理路径都要求「这个会话之后还有事发生」。一条只发过一次信、
|
||
* 之后既没有新邮件也没有 idle 的会话(模型自己发完就没动静了,或者插件在
|
||
* 那一轮之后重连),条目就永久留下。桥是常驻进程,这种残留会一直累积。
|
||
*
|
||
* 淘汰是安全的:条目的语义是「本轮已经亲手回过」,而「本轮」是分钟级的。
|
||
* 丢掉一条很久以前的记录最坏结果是那条会话下一次 idle 时多转一封总结,
|
||
* 而服务端的 relay_key 幂等还会兜一层。
|
||
*/
|
||
export const explicitSends = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> { names:Set, replyTos:Set }
|
||
|
||
/** 记下模型这一轮主动发了信,给谁、回的哪封。 */
|
||
export function noteExplicitSend(sessionID, to, replyTo) {
|
||
if (!sessionID) return;
|
||
let rec = explicitSends.get(sessionID);
|
||
if (!rec) {
|
||
rec = { names: new Set(), replyTos: new Set() };
|
||
explicitSends.set(sessionID, rec);
|
||
}
|
||
const name = addrName(to);
|
||
if (name) rec.names.add(name);
|
||
if (replyTo) rec.replyTos.add(String(replyTo));
|
||
}
|
||
|
||
/**
|
||
* 本轮是否该跳过自动转发。
|
||
*
|
||
* @param sent 该会话本轮的主动发信记录 { names:Set, replyTos:Set },可为空
|
||
* @param replyTo 自动转发本来要发给谁(三维地址或纯名字)
|
||
* @param mailID 自动转发本来要 reply_to 的邮件 id
|
||
*/
|
||
export function shouldSkipAutoRelay(sent, replyTo, mailID) {
|
||
if (!sent) return false;
|
||
// 收件人同名:模型已经跟这个人说过了
|
||
if (sent.names.has(addrName(replyTo))) return true;
|
||
// 同一封信已被回过:即使收件人写法不同(别名/路径不同)也算回过
|
||
if (mailID && sent.replyTos.has(String(mailID))) return true;
|
||
return false;
|
||
}
|