## 别名替换(让 .new 邮件可寻址)
repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调
notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new
FormatAddress(name,path,session) 空 path 也必须留 @ 与 .
## Agent 侧寻址发现(五个只读端点)
handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做
repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)
repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空
## 共用模块(三插件逐字节相同)
lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
renderParticipants/renderContacts/renderThread
lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
- selfName 参数(兼容旧调用不传的情况)
check-shared-libs.sh 纳入 addressing + discovery
## 插件侧
opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)
dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)
## 测试
repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
→ dsh 用 session_participants 取到地址 → send_mail 给 opencode
→ 地址取自工具返回值(.crisp-planet),未手工拼写
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;
|