/** * @author jackice * @date 2026-08-10 * * pi-handoff:把当前会话的上下文交接给新会话。 * 移植自 omp(oh-my-pi)的 SessionHandoff,核心差异: * - 生成 handoff 文档后先经编辑器确认,再注入新会话 * - 可选:压缩前自动生成 handoff 文档存盘(默认关闭) */ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { readFileSync } from "node:fs"; import * as os from "node:os"; import type { Message } from "@earendil-works/pi-ai"; import { uuidv7 } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { BorderedLoader, convertToLlm } from "@earendil-works/pi-coding-agent"; import { getHandoffMessages, isAutoSaveEnabled, renderHandoffPrompt, saveHandoffDocumentTo, shouldSkipAutoSave, wrapHandoffContext, } from "./lib.js"; // ── 资源路径 ────────────────────────────────────────────────────────── const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); const TEMPLATE_PATH = join(MODULE_DIR, "handoff-document.md"); const CONFIG_PATH = join(os.homedir(), ".pi", "handoff.json"); function loadTemplate(): string { return readFileSync(TEMPLATE_PATH, "utf8"); } interface HandoffConfig { autoSaveOnCompact?: boolean; } function loadConfig(): HandoffConfig { try { return JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as HandoffConfig; } catch { return {}; } } // ── handoff 文档生成 ─────────────────────────────────────────────────── function isTextContent(content: Message["content"][number]): content is { type: "text"; text: string } { return typeof content === "object" && content !== null && content.type === "text"; } /** * 用一次 oneshot LLM 调用生成 handoff 文档。 * 与 omp 一致:完整会话历史 + handoff 模板作为最后一条 user 消息, * systemPrompt 沿用主会话的语境。返回 null 表示取消/失败。 */ async function generateHandoffDocument( ctx: ExtensionContext, goal: string | undefined, signal: AbortSignal | undefined, ): Promise { if (!ctx.model) return null; const messages = getHandoffMessages(ctx.sessionManager.getBranch()); if (messages.length === 0) return null; const handoffPrompt = renderHandoffPrompt(loadTemplate(), goal); const requestMessages: Message[] = [ ...convertToLlm(messages), { role: "user", content: [{ type: "text", text: handoffPrompt }], timestamp: Date.now(), }, ]; const systemPrompt = ctx.getSystemPrompt(); const response = await ctx.modelRegistry.complete( ctx.model, { systemPrompt: systemPrompt || undefined, messages: requestMessages }, { signal, cacheRetention: "none", sessionId: uuidv7(), }, ); if (response.stopReason === "aborted") return null; return response.content.filter(isTextContent).map((c) => c.text).join("\n"); } /** 把 handoff 文档保存到会话 artifacts 目录(与 omp 存盘位置一致)。 */ function saveHandoffDocument(ctx: ExtensionContext, document: string): string | null { return saveHandoffDocumentTo(ctx.sessionManager.getSessionFile(), document); } // ── 扩展入口 ─────────────────────────────────────────────────────────── export default function handoffExtension(pi: ExtensionAPI): void { pi.registerFlag("handoff-auto-save", { type: "boolean", description: "压缩前自动生成 handoff 文档保存到会话 artifacts 目录", }); // 手动交接:/handoff [目标指令] pi.registerCommand("handoff", { description: "把当前会话上下文交接给新会话(生成 handoff 文档 → 确认 → 注入新会话)", handler: async (args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify("/handoff 需要交互模式", "error"); return; } if (!ctx.model) { ctx.ui.notify("未选择模型", "error"); return; } const goal = args.trim() || undefined; // 带加载动画生成(可 abort) const document = await ctx.ui.custom((tui, theme, _kb, done) => { const loader = new BorderedLoader(tui, theme, "生成 handoff 文档..."); loader.onAbort = () => done(null); generateHandoffDocument(ctx, goal, loader.signal) .then(done) .catch((err) => { console.error("Handoff 生成失败:", err); done(null); }); return loader; }); if (document === null) { ctx.ui.notify("已取消", "info"); return; } // 编辑器确认(undefined = 取消) const confirmed = await ctx.ui.editor("编辑 handoff 文档", document); if (confirmed === undefined) { ctx.ui.notify("已取消", "info"); return; } // 开新会话,把 handoff 文档作为首条上下文消息注入 const currentSessionFile = ctx.sessionManager.getSessionFile(); const result = await ctx.newSession({ parentSession: currentSessionFile, setup: async (sm) => { sm.appendCustomMessageEntry("handoff", wrapHandoffContext(confirmed), true); }, withSession: async (replacementCtx) => { replacementCtx.ui.notify("Handoff 就绪,在新会话中继续", "info"); }, }); if (result.cancelled) { ctx.ui.notify("新会话已取消", "info"); } }, }); // 自动留存:压缩前生成 handoff 文档存盘(默认关闭,不切换会话) pi.on("session_before_compact", async (event, ctx) => { const flagValue = pi.getFlag("handoff-auto-save"); // boolean flag:CLI 未传时为 undefined,此时回落到配置文件 const flag = typeof flagValue === "boolean" ? flagValue : undefined; const autoSave = isAutoSaveEnabled(flag, loadConfig().autoSaveOnCompact); if (!autoSave) return; // overflow 恢复会重试被中断的回合,跳过以免干扰 if (shouldSkipAutoSave(event.willRetry)) return; try { const document = await generateHandoffDocument(ctx, undefined, event.signal); if (!document) return; const filePath = saveHandoffDocument(ctx, document); if (filePath) { ctx.ui.notify(`Handoff 已保存: ${filePath}`, "info"); } } catch { // 静默失败:不阻塞压缩流程 } }); }