## 别名替换(让 .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),未手工拼写
116 lines
6.1 KiB
JavaScript
116 lines
6.1 KiB
JavaScript
/**
|
||
* 会话命名的双向一致(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/-3),pi 侧没有这个约束。
|
||
* 而 `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' };
|
||
}
|