## 别名替换(让 .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),未手工拼写
235 lines
9.9 KiB
JavaScript
235 lines
9.9 KiB
JavaScript
/**
|
||
* 平台会话快照:把 harness 自己的会话列表整理成 Gateway 的上报格式。
|
||
*
|
||
* 为什么需要它:写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。
|
||
* Gateway 只看得见邮件驱动的那部分 —— 人直接在 opencode/DSH 界面上开的会话
|
||
* 它一无所知,于是那些会话的别名在补全里根本不出现,无法选择。
|
||
*
|
||
* 为什么是插件上报而不是 Gateway 拉取:当前架构是单向的(Agent 持密钥主动连
|
||
* Gateway,Gateway 从不外呼)。反向拉取需要 Gateway 保存各平台的地址与凭证,
|
||
* 那是另一套信任模型。
|
||
*/
|
||
|
||
/** 单次上报的会话数上限。与服务端的 maxPlatformSessions 一致。 */
|
||
export const MAX_REPORTED = 200;
|
||
|
||
/**
|
||
* 把 opencode 的 session 列表整理成上报格式。
|
||
*
|
||
* @param {any[]} sessions client.session.list() 的结果
|
||
* @param {(id: string) => boolean} isMailDriven 该平台会话是否由邮件驱动
|
||
* @returns {object[]} 按最近活跃排序、截断到 MAX_REPORTED 的上报项
|
||
*/
|
||
export function snapshotOpencodeSessions(sessions, isMailDriven = () => false) {
|
||
const list = Array.isArray(sessions) ? sessions : [];
|
||
const out = [];
|
||
for (const s of list) {
|
||
const id = typeof s?.id === 'string' ? s.id : '';
|
||
if (!id) continue;
|
||
// 没有 slug 的会话不报:slug 是填进 session 位的值,
|
||
// 没有它这一项在补全里点下去只能得到一个空的 session 段。
|
||
const slug = typeof s?.slug === 'string' ? s.slug : '';
|
||
if (!slug) continue;
|
||
out.push({
|
||
platform_id: id,
|
||
// opencode 的工作目录在 directory 上(path 是项目内的子路径,不是 cwd)
|
||
workspace: typeof s?.directory === 'string' ? s.directory : '',
|
||
slug,
|
||
title: typeof s?.title === 'string' ? s.title : '',
|
||
mail_driven: Boolean(isMailDriven(id)),
|
||
updated_at: toISO(s?.time?.updated ?? s?.time?.created),
|
||
});
|
||
}
|
||
return sortAndCap(out);
|
||
}
|
||
|
||
/**
|
||
* 把 DSH 的 agent 列表整理成上报格式。
|
||
*
|
||
* DSH 没有 opencode 那样的 slug,别名由**模型生成的会话标题**派生
|
||
* (与「别名复用平台命名」的既定决策一致)。占位标题不派生别名:
|
||
* DSH 在模型生成真标题前会先落一个 fallback 标题,内容是用户第一句话的截断,
|
||
* 而那句话是插件自己拼的提示词。
|
||
*
|
||
* @param {any[]} entries [{ id, cwd, title, updatedAt }]
|
||
* @param {(id: string) => boolean} isMailDriven
|
||
* @returns {object[]}
|
||
*/
|
||
export function snapshotDshSessions(entries, isMailDriven = () => false) {
|
||
const list = Array.isArray(entries) ? entries : [];
|
||
const out = [];
|
||
for (const e of list) {
|
||
const id = typeof e?.id === 'string' ? e.id : '';
|
||
if (!id) continue;
|
||
// subagent 子会话不上报:它们是父 agent 内部的工作单元,人往里发邮件毫无意义。
|
||
// 而且它们的标题就是派活时的提示词前缀(实测九条会话都叫
|
||
// "You are auditing ONE file"),派生出的 slug 全都撞名、毫无区分度。
|
||
if (isSubagent(e)) continue;
|
||
const title = typeof e?.title === 'string' ? e.title : '';
|
||
const slug = slugFromTitle(title);
|
||
if (!slug) continue;
|
||
out.push({
|
||
platform_id: id,
|
||
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
|
||
slug,
|
||
title,
|
||
mail_driven: Boolean(isMailDriven(id)),
|
||
updated_at: toISO(e?.updatedAt),
|
||
});
|
||
}
|
||
// slug 撞名的只留最近那条:别名是**寻址**用的,
|
||
// 同一个 slug 对应多条会话时服务端只能取其中一条(updated_at DESC LIMIT 1),
|
||
// 上报一堆同名项只会让人在补全列表里看到几个一模一样、点哪个都不确定的候选。
|
||
return dedupeBySlug(sortAndCap(out));
|
||
}
|
||
|
||
/**
|
||
* 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。
|
||
*
|
||
* pi 的会话名字来自会话文件里最后一条 `session_info` 条目:
|
||
* - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题
|
||
* - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处
|
||
* - **pi 内核(SDK)自己不生成**:桥用 createAgentSession 起的会话没有名字,
|
||
* 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming)
|
||
*
|
||
* 与另两个平台的差异:pi 的 SessionInfo 里**没有 subagent 标记**。
|
||
* pi-subagents 把子会话写在自定义 sessionDir(run 根目录)下,默认会话目录
|
||
* 列不到它们,因此这里不需要 S-2 那样的显式过滤。
|
||
*
|
||
* @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]`
|
||
* @param {(id: string) => boolean} isMailDriven
|
||
* @returns {object[]}
|
||
*/
|
||
export function snapshotPiSessions(entries, isMailDriven = () => false) {
|
||
const list = Array.isArray(entries) ? entries : [];
|
||
const out = [];
|
||
for (const e of list) {
|
||
const id = typeof e?.id === 'string' ? e.id : '';
|
||
if (!id) continue;
|
||
const name = typeof e?.name === 'string' ? e.name : '';
|
||
// 没有名字的会话不报(S-1):pi 的列表在无名时显示首条消息,
|
||
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
|
||
if (!name) continue;
|
||
// 模型把思维链当标题写进来的那些不报(见 isUnusableName)
|
||
if (isUnusableName(name)) continue;
|
||
const slug = slugFromTitle(name);
|
||
if (!slug) continue;
|
||
out.push({
|
||
platform_id: id,
|
||
// 老会话的 cwd 是空串(pi 的 SessionInfo 注释里写明了),照实上报,
|
||
// 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。
|
||
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
|
||
slug,
|
||
title: name,
|
||
mail_driven: Boolean(isMailDriven(id)),
|
||
updated_at: toISO(e?.modified ?? e?.created),
|
||
});
|
||
}
|
||
return dedupeBySlug(sortAndCap(out));
|
||
}
|
||
|
||
/**
|
||
* 判断一个平台侧名字是否不适合当别名。
|
||
*
|
||
* 这条判废是 pi 特有的:pi-web 的标题生成器(`sessionNameGenerator`)只做了
|
||
* 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到:
|
||
*
|
||
* "The user is asking me to generate a title for a coding-agent"
|
||
* "我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试opencode的源。简短:…"
|
||
*
|
||
* 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。
|
||
* 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。
|
||
*
|
||
* 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名,
|
||
* 而漏掉一个坏名字只是别名难看。
|
||
*
|
||
* @param {string} name
|
||
* @returns {boolean}
|
||
*/
|
||
export function isUnusableName(name) {
|
||
const s = String(name ?? '').trim();
|
||
if (!s) return true;
|
||
// 自指标题生成任务 = 模型把系统提示词复述了出来
|
||
if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) {
|
||
return true;
|
||
}
|
||
// 以第三人称叙述用户意图开头 = 思维链的典型开场
|
||
if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(,|,))/i.test(s)) return true;
|
||
// 又长又分句 = 一段话而不是一个标题(pi-web 截断上限是 60)
|
||
if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true;
|
||
return false;
|
||
}
|
||
|
||
/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */
|
||
function isSubagent(e) {
|
||
if (e?.origin === 'subagent') return true;
|
||
const depth = e?.delegationDepth;
|
||
return typeof depth === 'number' && depth > 0;
|
||
}
|
||
|
||
/** 同 slug 只保留第一条(调用前已按最近活跃排序)。 */
|
||
function dedupeBySlug(list) {
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (const item of list) {
|
||
if (seen.has(item.slug)) continue;
|
||
seen.add(item.slug);
|
||
out.push(item);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 把模型生成的会话标题转成可寻址的 slug。
|
||
*
|
||
* 保留中文而不转拼音:标题「缓存层选型评估」转成 huancunceng-xuanxing 之后
|
||
* 既不好读也不好打,而 AgentMail 的别名校验本来就允许中文(三维地址按最后一个
|
||
* `.` 切分,中文不影响解析)。
|
||
*
|
||
* 处理:空白 → `-`,去掉会干扰寻址的字符(`.` 是 session 位的分隔符,
|
||
* `@` 是 path 位的分隔符,`/` 会被当成路径),压缩连续 `-`,截断到 48 字符。
|
||
*
|
||
* @param {string} title
|
||
* @returns {string} slug,无法派生时为空串
|
||
*/
|
||
export function slugFromTitle(title) {
|
||
const raw = String(title ?? '').trim();
|
||
if (!raw) return '';
|
||
const slug = raw
|
||
.replace(/[\s\u3000]+/g, '-')
|
||
// 寻址相关的分隔符必须去掉,否则别名本身会被解析器切开
|
||
.replace(/[.@/\\:,;'"`?#[\]{}()<>|*!$&=+%^~]/g, '')
|
||
.replace(/-{2,}/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.slice(0, 48)
|
||
// 截断可能又切出尾部的 -
|
||
.replace(/-+$/g, '');
|
||
// 纯符号标题清干净后会剩空串
|
||
return slug;
|
||
}
|
||
|
||
/** 毫秒时间戳、ISO 串或 Date → ISO 串;无法解析时返回 undefined。 */
|
||
function toISO(v) {
|
||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||
return new Date(v).toISOString();
|
||
}
|
||
// pi 的 SessionInfo 给的是 Date 实例(created/modified),不是时间戳。
|
||
// 少了这一支会让整份快照的 updated_at 全是 undefined,于是服务端只能按
|
||
// 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。
|
||
if (v instanceof Date) {
|
||
return Number.isNaN(v.getTime()) ? undefined : v.toISOString();
|
||
}
|
||
if (typeof v === 'string' && v) {
|
||
const d = new Date(v);
|
||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/** 按最近活跃降序排列并截断。上千条会话对补全列表毫无用处。 */
|
||
function sortAndCap(list) {
|
||
return list
|
||
.sort((a, b) => String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')))
|
||
.slice(0, MAX_REPORTED);
|
||
}
|