/** * dsh-plugin-branch-origin — 分支联系。 * * 给「从别的对话分叉出来的会话」标上来源:把它的标题改写成 * `⤷ 来源:<源会话标题>`,于是侧栏那一行自己就说明了它是从哪个对话分离的。 * * Host-side bundle. Zero dependencies, no model-facing tools, no build chain: * the whole plugin is this file plus one patch-layer row. * * 为什么需要插件(对着本机安装的 `@deepseek-ai/dsh-*` 0.2.0-rc.2 源码核过): * * - fork 确实建立了真实父子链:`SessionHeader.parentSession` * (`dsh-session/lib/types/types.d.ts:71`),另有 `isSeeded`,以及**只有** * 子智能体会话才有的 `origin: 'subagent'`。 * - 侧栏已经按谱系缩进(`SessionListEntry.depth`, * `dsh-api-session-controller/lib/types/client/sessions/lineage.d.ts:27`), * 官方 fork 菜单还会把子标题改成 `<源标题> (1)` * (`increasedForkTitle`,`dsh-api-session-controller/lib/client.js:3066`)。 * **但这两者都没有说"来自哪个对话"** —— 这正是本插件补的那一格。 * - `session/created` 对**每一个**新会话都会发,包括官方 GUI 造的 fork * (`dsh-session/lib/index.js:1782`),所以一个监听器覆盖所有 fork 入口。 * 监听必须带 `{ global: true }`,否则会被 Cordis 的上下文过滤挡掉。 * - 官方那次 `(1)` 改名发生在 `session/created` **之后**(它是第二个 RPC), * 所以本插件同时盯 `session/event` 里的 `session/title` 做收敛。fork 的播种 * 事件**不会**发布到 `session/event`(`dsh-session/lib/types/index.d.ts:126`), * 因此两个钩子缺一不可。 * - 写标题走 `ctx.sessionTitle.rename(session, title)`,落盘的是 * `source: { kind: 'user' }` 的 `session/title` 事件,会**钉住**标题。 * 在这里安全:first-prompt 标题提供方本来就跳过带父级的会话 * (`dsh-session-title/lib/index.js:386`),fork 子会话不会因此丢掉自动命名。 * * 纪律 —— **只标注,不接管**:只有当子会话标题仍然等于源标题、或等于源标题的 * 官方编号形式时,我们才动它。标题一旦是别的样子,说明别的工具或用户已经命名过, * 我们原样放过(`desiredTitle` 返回 `undefined`)。 * * @module dsh-plugin-branch-origin */ /** Every title this plugin writes carries this prefix. */ export const MARK = '⤷ 来源:' /** 官方 fork 的编号后缀:`<基名> (3)` 或全角 `<基名>(3)`。 */ const COUNTER = /^(.*?)[((](\d+)[))]$/u /** * 创建后等这么久再标注:让官方那次 `<源标题> (1)` 改名先落地, * 于是一次 fork 通常只产生**一次**标题写入,没有可见的闪变。 */ export const DEFAULT_SETTLE_MS = 600 /** 创建后再盯这么久的标题写入,用来收敛迟到的官方改名。 */ export const DEFAULT_GRACE_MS = 4000 /** * 拆掉官方 fork 的尾编号。` (1)` / `(1)` 都认;base 去掉尾随空白, * 这样 `"X (1)"` 的 base 是 `"X"` 而不是 `"X "`(否则永远比不中源标题)。 * @param title - 任意会话标题。 * @returns `{ base, digits }`;没有编号时返回 `undefined`。 */ export function splitCounter(title) { const match = COUNTER.exec(title) const rawBase = match?.[1] const digits = match?.[2] if (rawBase === undefined || digits === undefined) return undefined return { base: rawBase.replace(/\s+$/u, ''), digits } } /** * 抹掉官方编号后的"家族名"。 * @param title - 任意会话标题。 * @returns 去掉尾编号的标题。 */ export function baseOf(title) { return splitCounter(title)?.base ?? title } /** * 算出分叉会话应当携带的标题。 * * 规则只有一条:**子标题必须仍是源标题的机械派生形态**(原样继承,或官方那次 * `<源标题> (N)` 增号)—— 即两者抹掉尾编号后同名。这时我们只在它前面贴上来源 * 标记,官方序号原样保留,于是同一源会话的多个分叉在侧栏里仍然可分。 * * 任何别的样子(用户改的名、别的谱系插件钉的 `⑂1 …`)都说明有人已经命名过, * 返回 `undefined` 放过它 —— 本插件"只标注,不接管"。 * * @param parentTitle - 源会话的标题。 * @param childTitle - 子会话当前标题。 * @returns 标注后的标题;不该动手时返回 `undefined`。 */ export function desiredTitle(parentTitle, childTitle) { if (typeof childTitle !== 'string' || childTitle === '') return undefined if (childTitle.startsWith(MARK)) return undefined if (baseOf(childTitle) !== baseOf(parentTitle)) return undefined return `${MARK}${childTitle}` } export const name = 'dsh-plugin-branch-origin' /** 本 bundle 需要的服务;`sessionTitle` 自身依赖 `sessionProjections`。 */ export const inject = ['sessions', 'sessionTitle'] /** * 给此后创建的每一个分叉会话补上来源标签。 * @param ctx - 宿主插件上下文。 * @param config - 可选的 `{ settleMs, graceMs, labelSubagents }` 覆盖(patch 行不带配置)。 */ export function apply(ctx, config = {}) { const settleMs = Number.isFinite(config?.settleMs) ? Math.max(0, config.settleMs) : DEFAULT_SETTLE_MS const graceMs = Number.isFinite(config?.graceMs) ? Math.max(0, config.graceMs) : DEFAULT_GRACE_MS const labelSubagents = config?.labelSubagents === true /** 本进程创建、且仍在标注窗口内的会话:sessionId -> 窗口截止时间。 */ const watching = new Map() const timers = new Set() ctx.effect(() => () => { for (const timer of timers) clearTimeout(timer) timers.clear() watching.clear() }, 'branch-origin: pending label timers') const later = (run, delay) => { const timer = setTimeout(() => { timers.delete(timer) run() }, delay) if (typeof timer?.unref === 'function') timer.unref() timers.add(timer) } const warn = (message) => { const logger = ctx.logger if (typeof logger?.warn === 'function') logger.warn(`[branch-origin] ${message}`) else console.warn(`[branch-origin] ${message}`) } /** 判据:有父级,且不是子智能体会话。 */ const isFork = (session) => { const header = session?.header if (header?.parentSession === undefined) return false if (!labelSubagents && header.origin === 'subagent') return false return true } /** 读一个会话的标题:先走 live 折叠,冷会话再退到 sessionQuery。 */ const readTitle = async (sessionId) => { const live = ctx.get('sessions')?.get(sessionId) if (live !== undefined) { const snapshot = ctx.get('sessionTitle')?.get(live) if (snapshot?.title) return snapshot.title } const query = ctx.get('sessionQuery') if (typeof query?.readTitleSnapshots !== 'function') return undefined const results = await query.readTitleSnapshots([sessionId]) const first = Array.isArray(results) ? results[0] : undefined if (first?.status !== 'fulfilled') return undefined return first.value?.title?.title } /** 写标题:live 会话走 sessionTitle;否则退到会自行 resume 的 sessionController。 */ const writeTitle = async (session, title) => { const service = ctx.get('sessionTitle') if (service !== undefined) { try { service.rename(session, title) return 'sessionTitle' } catch { // 会话不是 live(或服务已释放)—— 退到官方 controller,它会先 resume。 } } const controller = ctx.get('sessionController') if (typeof controller?.rename === 'function') { await controller.rename({ sessionId: session.id, title }) return 'sessionController' } return undefined } const label = async (session) => { if (!isFork(session)) return if (!watching.has(session.id)) return try { const parentTitle = await readTitle(session.header.parentSession) if (typeof parentTitle !== 'string' || parentTitle === '') return const current = ctx.get('sessionTitle')?.get(session)?.title ?? '' const desired = desiredTitle(parentTitle, current) if (desired === undefined || desired === current) return const via = await writeTitle(session, desired) if (via === undefined) warn(`没有可用的标题写入通道,原样保留 "${current}"`) } catch (error) { warn(`标注 ${session.id} 失败:${error?.message ?? error}`) } } ctx.on( 'session/created', (session) => { if (!isFork(session)) return watching.set(session.id, Date.now() + settleMs + graceMs) later(() => void label(session), settleMs) later(() => watching.delete(session.id), settleMs + graceMs) }, { global: true }, ) ctx.on( 'session/event', (session, event) => { if (event?.type !== 'session/title') return const deadline = watching.get(session.id) if (deadline === undefined) return if (deadline < Date.now()) { watching.delete(session.id) return } void label(session) }, { global: true }, ) }