Files
MailUI4Agents/plugins/pi-mail-bridge/src/naming.mjs
JianFeeeee e6fd2fafdc 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),未手工拼写
2026-09-03 12:09:12 +08:00

116 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 会话命名的双向一致C-11 / W-7 / D-5
*
* 一句话:**Gateway 定稿pi 接受定稿**。
*
* pi/pi-web 生成名字 ──①观测──▶ 桥 ──②POST /sessions/{id}/sync──▶ Gateway
* pi 的 session_info ◀──④回写──── ③响应里的 final alias
*
* 为什么不能各自命名然后指望撞上:别名在 AgentMail 侧负有寻址唯一性义务
* partial unique index + 撞名自动追 -2/-3pi 侧没有这个约束。
* 而 `alias_source='manual'` 的会话(人在界面上改过名)永远不接受平台同步,
* `SyncSessionAlias` 会把**当前别名原样返回**。所以只有用响应里的值回写,
* 两边看到的才是同一个名字。单向推送做不到这一点。
*
* ④ 必须判「与上次写入的值不同」才执行,否则 setSessionName 触发
* session_info_changed钩子又去 sync成自激循环。
*
* 三个实测出来的约束(探针脚本验证过,见 test/naming.test.mjs 里的注释):
* - pi 首条 assistant 消息落盘前会话文件**不存在**SessionManager 首次落盘用
* `openSync(file, "wx")`;桥抢先按路径写会让 pi 侧 flush 抛 EEXIST。
* → 回写只用 `session.setSessionName()`(走 pi 自己的写入路径),
* 绝不自己拼路径写文件。
* - 活着的 SessionManager 不 watch 文件;外部改名它看不见,之后它自己
* append 一条 session_info 反而会盖掉外部的("最后一条生效")。
* - 空名字是**清除**语义(`appendSessionInfo(" ")` 之后 getSessionName() 变
* undefined因此不能用空串表达「无变化」。
*/
import { slugFromTitle, isUnusableName } from '../lib/session-snapshot.js';
/**
* 决定这一轮要不要向 Gateway 同步命名,以及同步什么。
*
* 别名的降级阶梯D-5
* 1. 平台生成的名字派生的 slug
* 2. 名字不可用pi-web 的思维链泄漏、纯符号)或**根本没有名字**
* → 退到邮件主题派生
* 3. 两者都没有 → **不写回**W-7.2:绝不写占位别名)
*
* 第 2 步里的「根本没有名字」是 pi 的常态而非例外:桥用 SDK 起的会话不经过
* pi-web 的标题生成器(那个生成器在 pi-web 包里,不在 pi 内核里),
* 因此 `session.sessionName` 一直是 undefined。只等平台命名的话别名永远是空的
* `name@path.<别名>` 续谈无从下手 —— 实测过这个后果。
*
* 标题一律用平台原文(不派生、不清洗):`I-4` 说插件只搬运。
* 唯一的例外是判废 —— 判废的结果是「不写」,不是「改写成别的」。
*
* 返回值里的 `signature` 是「本次提交内容的指纹」,调用方存下它并在下一轮
* 作为 `lastSynced` 传回,用来判「没变化就别重复提交」。**不能用平台名字本身**
* 充当这个角色:名字为空时(上面那个常态)它无法区分「还没提交过」与
* 「提交过、内容没变」,于是每轮心跳都白打一次 sync。
*
* @param {object} input
* @param {string} input.platformName pi 侧 session_info 里的名字
* @param {string} input.mailSubject 该会话最近一封来信的主题(兜底用)
* @param {string} input.lastSynced 上一次提交的 signature
* @returns {{skip: true, reason: string} | {skip: false, alias: string, title: string, source: string, signature: string}}
*/
export function planNamingSync({ platformName, mailSubject, lastSynced }) {
const name = String(platformName ?? '').trim();
const prev = String(lastSynced ?? '').trim();
const decide = () => {
if (name && !isUnusableName(name)) {
const alias = slugFromTitle(name);
// 名字看着正常但全是分隔符("..." / "@@@")→ 派生不出别名,
// 但**标题仍然值得写**subject 那一列不负责寻址,没有字符限制。
if (alias) return { alias, title: name, source: 'platform' };
return { alias: '', title: name, source: 'platform-title-only' };
}
// 平台名字不可用或不存在:退到邮件主题。它是人写的,
// 天然比模型的思维链靠谱,而 SDK 起的会话本来就没有平台名字。
const subject = String(mailSubject ?? '').trim();
if (subject) {
const alias = slugFromTitle(subject);
if (alias) return { alias, title: '', source: 'mail-subject' };
}
return null;
};
const plan = decide();
// 什么都没有:不写。宁可让会话保持无别名(数据库允许 NULL
// 也不要写一个 "session-123" 这样的占位值 —— 那种别名对人毫无指代作用,
// 而且一旦落库就把 alias 位占住了,真正的名字来了也只能追 -2 后缀。
if (!plan) return { skip: true, reason: 'no-usable-name' };
const signature = `${plan.source}:${plan.alias}|${plan.title}`;
if (signature === prev) return { skip: true, reason: 'unchanged' };
return { ...plan, skip: false, signature };
}
/**
* 决定要不要把 Gateway 定稿的别名回写进 pi。
*
* 回写的三种触发情形:
* - 撞名:提议 `fix-leak`Gateway 给了 `fix-leak-2`
* - manual 保护:人在界面上改成了 `紧急排查`Gateway 原样返回它
* - 规范化:提议里含 `.` `@` `/` 空白,被 normalizeAlias 换成了 `-`
*
* @param {object} input
* @param {string} input.finalAlias Gateway 响应里的 alias
* @param {string} input.currentPiName pi 侧当前的名字
* @returns {{write: boolean, name: string, reason: string}}
*/
export function planWriteBack({ finalAlias, currentPiName }) {
const final = String(finalAlias ?? '').trim();
// 服务端没回别名(本次只同步了标题)→ 没有定稿值可写
if (!final) return { write: false, name: '', reason: 'no-alias-in-response' };
const cur = String(currentPiName ?? '').trim();
if (cur === final) return { write: false, name: '', reason: 'already-equal' };
// 空名字是清除语义,这里 final 非空,所以安全
return { write: true, name: final, reason: cur ? 'diverged' : 'pi-unnamed' };
}