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:
237
plugins/pi-mail-bridge/lib/discovery.js
Normal file
237
plugins/pi-mail-bridge/lib/discovery.js
Normal file
@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
|
||||
*
|
||||
* 三个 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');
|
||||
}
|
||||
Reference in New Issue
Block a user