/** * 平台会话快照:把 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)); } /** 判断一条会话是否为 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 串 → ISO 串;无法解析时返回 undefined。 */ function toISO(v) { if (typeof v === 'number' && Number.isFinite(v)) { return new Date(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); }