feat: agent 邮件寻址能力全面补齐 + .new 别名替换

## 别名替换(让 .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),未手工拼写
This commit is contained in:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

View File

@ -0,0 +1,355 @@
/**
* 邮件工具(T-1..T-6)—— 注册给 pi 里的模型。
*
* pi 的工具定义用 TypeBox schema,这里直接写等价的 JSON Schema 字面量:
* TypeBox 的 `Type.Object({...})` 产出的就是这个形状,而桥是 .mjs(无编译步骤),
* 少一个运行时依赖。
*
* `execute(toolCallId, params, signal, onUpdate, ctx)` 的 ctx 是 ExtensionContext,
* 由此可以拿到 `ctx.sessionManager.getSessionId()` —— 这就是 C-6 要求的
* 「工具能拿到当前会话 id」,自动转发去重(B-5.3)靠它把发信记到正确的会话上。
*/
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import {
renderInbox,
idsToMarkRead,
formatSize,
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT,
} from '../lib/inbox-format.js';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from '../lib/discovery.js';
import { noteExplicitSend } from '../lib/relay-dedup.js';
const text = (s) => ({ content: [{ type: 'text', text: s }] });
/**
* @param {object} deps
* @param {import('./gateway.mjs').GatewayClient} deps.client
* @param {(msg: string) => void} deps.log
* @param {string} [deps.agentName] 自己的 Agent 名。收件箱渲染靠它判定
* 「我是收件人还是抄送方」并给出可投递地址。
*/
export function createMailTools({ client, log, agentName = '' }) {
const sendMail = {
name: 'send_mail',
label: 'SendMail',
description:
'发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,' +
'.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。',
parameters: {
type: 'object',
properties: {
to: { type: 'string', description: '收件人三维地址,如 admin@/home/program/x' },
subject: { type: 'string', description: '邮件主题' },
body: { type: 'string', description: '邮件正文(Markdown)' },
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' },
session_alias: { type: 'string', description: '给新会话命名(仅 .new 时生效)' },
attachment_ids: {
type: 'array',
items: { type: 'string' },
description: '附件 ID 列表(先用 upload_attachment 取得)',
},
},
required: ['to', 'subject', 'body'],
additionalProperties: false,
},
async execute(_id, params, _signal, _onUpdate, ctx) {
const result = await client.post('/mail/send', {
to: params.to,
subject: params.subject,
body: params.body,
cc: params.cc || '',
reply_to: params.reply_to || '',
session_alias: params.session_alias || '',
attachment_ids: params.attachment_ids || [],
// 这里**不带 relay**(N-5):模型的自主发信要计配额,
// 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。
});
// 记下「模型这一轮亲手发过信」,供 B-5.3 让位判定。
// 会话 id 从 ctx 取:工具不知道自己被哪条会话调用,就没法正确归属。
noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, params.reply_to);
const budget = typeof result.budget_remaining === 'number'
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。`
: '';
return text(`邮件已发送(ID: ${result.mail_id})${budget}`);
},
};
const readInbox = {
name: 'read_inbox',
label: 'ReadInbox',
description:
'查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。' +
'每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。',
parameters: {
type: 'object',
properties: {
status: { type: 'string', description: '过滤条件 unread|all,默认 unread' },
limit: { type: 'number', description: '返回数量,默认 5' },
},
additionalProperties: false,
},
async execute(_id, params) {
const status = params.status || DEFAULT_INBOX_STATUS;
const { mails } = await client.get(
`/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}`,
);
// 渲染与已读策略走共用模块:与另两个平台必须一致,
// 每条规则对应过一次真实的错误行为(见 lib/inbox-format.js)。
//
// 传 agentName 才能判定身份并给出可投递地址 —— 不传的话模型只能
// 从抄送行里抄一个 `.new`,而那是一次性的,回过去只会再建一条平行会话。
const listed = renderInbox(mails, 200, agentName);
const ids = idsToMarkRead(params.status, mails);
if (ids.length) {
// 标记失败不该让 read_inbox 失败:正文已经取到了,
// 代价只是下次重复看到,比丢掉这次读取轻。
client.post('/mail/read', { mail_ids: ids }).catch((e) =>
log(`[pi-mail-bridge] 标记已读失败: ${e?.message || e}`));
}
return text(listed);
},
};
const forwardMail = {
name: 'forward_mail',
label: 'ForwardMail',
description:
'转发一封邮件给新的收件人(引用原文)。与回复不同:回复落回原会话,' +
'转发按目标地址另行定位会话。只能转发自己参与过的邮件。',
parameters: {
type: 'object',
properties: {
mail_id: { type: 'string', description: '要转发的邮件 ID(从 read_inbox 获得)' },
to: { type: 'string', description: '新收件人的三维地址' },
comment: { type: 'string', description: '转发说明,置于引用原文之前' },
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
subject: { type: 'string', description: '自定义主题;留空则自动加 Fwd: 前缀' },
session_alias: { type: 'string', description: '仅在目标地址以 .new 结尾时生效:给新会话命名' },
},
required: ['mail_id', 'to'],
additionalProperties: false,
},
async execute(_id, params, _signal, _onUpdate, ctx) {
// 路径带 mail_id(POST /mail/{id}/forward),不是请求体里的字段
const result = await client.post(`/mail/${params.mail_id}/forward`, {
to: params.to,
comment: params.comment || '',
cc: params.cc || '',
subject: params.subject || '',
session_alias: params.session_alias || '',
});
noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, '');
return text(`已转发。新 Mail ID: ${result.mail_id},Session: ${result.session_id}`);
},
};
const uploadAttachment = {
name: 'upload_attachment',
label: 'UploadAttachment',
description: '上传本地文件作为邮件附件,返回 attachment_id。',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: '本地文件的绝对路径' },
},
required: ['file_path'],
additionalProperties: false,
},
async execute(_id, params) {
const buf = await readFile(params.file_path);
const a = await client.uploadFile(buf, basename(params.file_path) || 'file');
return text(
`已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}`,
);
},
};
const downloadAttachment = {
name: 'download_attachment',
label: 'DownloadAttachment',
description: '下载邮件附件到本地文件。',
parameters: {
type: 'object',
properties: {
attachment_id: { type: 'string', description: '附件 ID(read_inbox 的清单里给出)' },
save_path: { type: 'string', description: '保存路径' },
},
required: ['attachment_id', 'save_path'],
additionalProperties: false,
},
async execute(_id, params) {
const buf = await client.downloadFile(params.attachment_id);
await writeFile(params.save_path, buf);
return text(`已保存到 ${params.save_path}(${formatSize(buf.length)})`);
},
};
// ─── 寻址发现工具(读 Agent 侧只读端点)───
//
// 在这一组之前,send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
// 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功,
// 但那不是 opencode 的工作目录,静默变成了新会话的 workspace。
//
// 渲染逻辑在 lib/discovery.js(三平台共用)。
const suggestAddress = {
name: 'suggest_address',
label: 'SuggestAddress',
description:
'查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的' +
'工作目录;name+path 都带则给该目录下可续谈的会话与现成地址。' +
'**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' },
path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' },
},
additionalProperties: false,
},
async execute(_id, params) {
const name = String(params.name || '').trim();
const path = String(params.path || '').trim();
const qs = new URLSearchParams();
if (name) qs.set('name', name);
if (path) qs.set('path', path);
const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`);
// 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端
// 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
switch (data?.kind) {
case 'name': return text(renderNameSuggestions(data.suggestions));
case 'path': return text(renderPathSuggestions(data.suggestions, name));
default: return text(renderSessionSuggestions(data, name, path));
}
},
};
const listContacts = {
name: 'list_contacts',
label: 'ListContacts',
description:
'列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' +
'用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。',
parameters: {
type: 'object',
properties: {
limit: { type: 'number', description: '最多列出多少条,默认 20' },
},
additionalProperties: false,
},
async execute(_id, params) {
const data = await client.get('/agent/contacts');
return text(renderContacts(data, params.limit || 20));
},
};
const sessionParticipants = {
name: 'session_participants',
label: 'SessionParticipants',
description:
'列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,' +
'并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。',
parameters: {
type: 'object',
properties: {
session_id: { type: 'string', description: '会话 ID' },
},
required: ['session_id'],
additionalProperties: false,
},
async execute(_id, params) {
const data = await client.get(`/agent/sessions/${params.session_id}/participants`);
return text(renderParticipants(data));
},
};
const readThread = {
name: 'read_thread',
label: 'ReadThread',
description:
'查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时' +
'用它确认别人已经说了什么,避免重复提问或重复汇报。',
parameters: {
type: 'object',
properties: {
mail_id: { type: 'string', description: '线索中任一封邮件的 ID' },
offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' },
},
required: ['mail_id'],
additionalProperties: false,
},
async execute(_id, params) {
const qs = params.offset ? `?offset=${params.offset}` : '';
const data = await client.get(`/agent/mail/${params.mail_id}/thread${qs}`);
return text(renderThread(data, agentName));
},
};
const readMail = {
name: 'read_mail',
label: 'ReadMail',
description:
'读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' +
'收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。',
parameters: {
type: 'object',
properties: {
mail_id: { type: 'string', description: '邮件 ID' },
},
required: ['mail_id'],
additionalProperties: false,
},
async execute(_id, params) {
const data = await client.get(`/agent/mail/${params.mail_id}`);
const m = data?.mail || {};
const lines = [
`发件人: ${m.from_name || '?'}`,
`收件人: ${m.to_name || '?'}${m.to_workspace ? '@' + m.to_workspace : ''}`,
`主题: ${m.subject || '(无主题)'}`,
`会话: #${data.session_alias || '未命名'}(session_id: ${m.session_id || '?'})`,
];
if (Array.isArray(m.cc_list) && m.cc_list.length) {
lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join('、')}`);
}
if (Array.isArray(m.attachments) && m.attachments.length) {
lines.push(`附件: ${m.attachments
.map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`)
.join('、')}`);
}
lines.push('', m.body || '(空正文)', '');
if (Array.isArray(data.participants) && data.participants.length) {
lines.push('可投递地址: ' + data.participants
.filter(p => p.address && p.name !== agentName)
.map(p => `${p.address}(${p.role})`)
.join('、'));
}
if (data.reply_address) {
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`);
}
return text(lines.join('\n'));
},
};
// 故意**没有** request_permission(N-1 / T-7):
// 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调,
// 而真正被 pi 拦下的那一次才是事实。
return [
sendMail, readInbox, readMail, forwardMail,
uploadAttachment, downloadAttachment,
// 寻址发现:让模型选地址而不是拼地址
suggestAddress, listContacts, sessionParticipants, readThread,
];
}