/** * 会话命名的双向一致(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' }; }