diff --git a/packages/handoff/package.json b/packages/handoff/package.json new file mode 100644 index 0000000000000000000000000000000000000000..75ce5cc99926bb4270a20d2b807c2a11d17fab5e --- /dev/null +++ b/packages/handoff/package.json @@ -0,0 +1,23 @@ +{ + "name": "@amolith/pi-handoff", + "version": "0.1.0", + "description": "Context-preserving session handoffs for Pi", + "keywords": [ + "pi-package" + ], + "pi": { + "extensions": [ + "./src/index.ts" + ] + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@mariozechner/pi-ai": "*", + "@mariozechner/pi-coding-agent": "*", + "@mariozechner/pi-tui": "*", + "@sinclair/typebox": "*" + }, + "type": "module" +} diff --git a/packages/handoff/package.json.license b/packages/handoff/package.json.license new file mode 100644 index 0000000000000000000000000000000000000000..3dbb1e29808ff6ce1e89aa3211dbfa6c8aa5ef0e --- /dev/null +++ b/packages/handoff/package.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Amolith + +SPDX-License-Identifier: CC0-1.0 diff --git a/packages/handoff/src/index.ts b/packages/handoff/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..30c912f227f5631949ee153740b478522430c0f0 --- /dev/null +++ b/packages/handoff/src/index.ts @@ -0,0 +1,897 @@ +// SPDX-FileCopyrightText: Amolith +// SPDX-FileCopyrightText: Petr Baudis +// +// SPDX-License-Identifier: MIT + +import { complete, type Message, type Tool } from "@mariozechner/pi-ai"; +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, + SessionEntry, +} from "@mariozechner/pi-coding-agent"; +import { BorderedLoader, SessionManager, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; +import { Key, matchesKey } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const STATUS_KEY = "handoff"; +const COUNTDOWN_SECONDS = 10; + +/** + * Model used for handoff extraction calls. Set PI_HANDOFF_MODEL env var + * as "provider/modelId" (e.g. "anthropic/claude-haiku-4-5") to use a + * different model for extraction than the session's current model. + */ +const HANDOFF_MODEL_OVERRIDE = process.env.PI_HANDOFF_MODEL; + +const SYSTEM_PROMPT = `You're helping transfer context between coding sessions. The next session starts fresh with no memory of this conversation, so extract what matters. + +Consider these questions: +- What was just done or implemented? +- What decisions were made and why? +- What technical details were discovered (APIs, methods, patterns)? +- What constraints, limitations, or caveats were found? +- What patterns or approaches are being followed? +- What is still unfinished or unresolved? +- What open questions or risks are worth flagging? + +Rules: +- Be concrete: prefer file paths, specific decisions, and actual commands over vague summaries. +- Only include files from the provided candidate list. +- Only include skills from the provided loaded skills list. +- If something wasn't explicitly discussed, don't include it.`; + +const QUERY_SYSTEM_PROMPT = `You answer questions about a prior pi session. + +Rules: +- Use only facts from the provided conversation. +- Prefer concrete outputs: file paths, decisions, TODOs, errors. +- If not present, say explicitly: "Not found in provided session.". +- Keep answer concise.`; + +/** + * Tool definition passed to the extraction LLM call. Models produce + * structured tool-call arguments far more reliably than free-form JSON + * in a text response, so we use a single tool instead of responseFormat. + */ +const HANDOFF_EXTRACTION_TOOL: Tool = { + name: "extract_handoff_context", + description: "Extract handoff context from the conversation for a new session.", + parameters: Type.Object({ + relevantFiles: Type.Array(Type.String(), { + description: "File paths relevant to continuing this work, chosen from the provided candidate list", + }), + skillsInUse: Type.Array(Type.String(), { + description: "Skills relevant to the goal, chosen from the provided list of loaded skills", + }), + context: Type.String({ + description: + "Key context: what was done, decisions made, technical details discovered, constraints, and patterns being followed", + }), + openItems: Type.Array(Type.String(), { + description: "Unfinished work, open questions, known risks, and things to watch out for", + }), + }), +}; + +type PendingAutoSubmit = { + ctx: ExtensionContext; + sessionFile: string | undefined; + interval: ReturnType; + unsubscribeInput: () => void; +}; + +type PendingHandoff = { + prompt: string; + parentSession: string | undefined; + newModel: string | undefined; +}; + +function isEditableInput(data: string): boolean { + if (!data) return false; + if (data.length === 1) { + const code = data.charCodeAt(0); + if (code >= 32 && code !== 127) return true; + if (code === 8 || code === 13) return true; + } + + if (data === "\n" || data === "\r") return true; + if (data === "\x7f") return true; + + if (data.length > 1 && !data.startsWith("\x1b")) return true; + + return false; +} + +function statusLine(ctx: ExtensionContext, seconds: number): string { + const accent = ctx.ui.theme.fg("accent", `handoff auto-submit in ${seconds}s`); + const hint = ctx.ui.theme.fg("dim", "(type to edit, Esc to cancel)"); + return `${accent} ${hint}`; +} + +function getSessionsRoot(sessionFile: string | undefined): string | undefined { + if (!sessionFile) return undefined; + const normalized = sessionFile.replace(/\\/g, "/"); + const marker = "/sessions/"; + const idx = normalized.indexOf(marker); + if (idx === -1) { + return path.dirname(path.resolve(sessionFile)); + } + return normalized.slice(0, idx + marker.length - 1); +} + +function getFallbackSessionsRoot(): string | undefined { + const configuredDir = process.env.PI_CODING_AGENT_DIR; + const candidate = configuredDir + ? path.resolve(configuredDir, "sessions") + : path.resolve(os.homedir(), ".pi", "agent", "sessions"); + return fs.existsSync(candidate) ? candidate : undefined; +} + +function normalizeSessionPath(sessionPath: string, sessionsRoot: string | undefined): string { + if (path.isAbsolute(sessionPath)) return path.resolve(sessionPath); + if (sessionsRoot) return path.resolve(sessionsRoot, sessionPath); + return path.resolve(sessionPath); +} + +function sessionPathAllowed(candidate: string, sessionsRoot: string | undefined): boolean { + if (!sessionsRoot) return true; + const root = path.resolve(sessionsRoot); + const resolved = path.resolve(candidate); + return resolved === root || resolved.startsWith(`${root}${path.sep}`); +} + +/** + * Build a candidate file set from two sources: + * 1. Primary: actual tool calls (read, write, edit, create) in the session + * 2. Secondary: file-like patterns in the conversation text (catches files + * that were discussed but never opened) + */ +function extractCandidateFiles(entries: SessionEntry[], conversationText: string): Set { + const files = new Set(); + const fileToolNames = new Set(["read", "write", "edit", "create"]); + + // Primary: files from actual tool calls + for (const entry of entries) { + if (entry.type !== "message") continue; + const msg = entry.message; + if (msg.role !== "assistant") continue; + + for (const block of msg.content) { + if (typeof block !== "object" || block === null || block.type !== "toolCall") continue; + if (!fileToolNames.has(block.name)) continue; + + const args = block.arguments as Record; + const filePath = + typeof args.path === "string" ? args.path : typeof args.file === "string" ? args.file : undefined; + if (!filePath) continue; + if (filePath.endsWith("/SKILL.md")) continue; + + files.add(filePath); + } + } + + // Secondary: file-like patterns from conversation text + const filePattern = /(?:^|\s)([a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+)(?:\s|$|[,;:\)])/gm; + let match; + while ((match = filePattern.exec(conversationText)) !== null) { + const candidate = match[1]; + if (candidate && !candidate.startsWith(".") && candidate.length > 2) { + files.add(candidate); + } + } + + return files; +} + +/** + * Extract skill names that were actually loaded during the conversation. + * Looks for read() tool calls targeting SKILL.md files and derives the + * skill name from the parent directory (the convention for pi skills). + */ +function extractLoadedSkills(entries: SessionEntry[]): string[] { + const skills = new Set(); + for (const entry of entries) { + if (entry.type !== "message") continue; + const msg = entry.message; + if (msg.role !== "assistant") continue; + + for (const block of msg.content) { + if (typeof block !== "object" || block === null || block.type !== "toolCall") continue; + + // read() calls where the path ends in SKILL.md + if (block.name !== "read") continue; + const args = block.arguments as Record; + const filePath = typeof args.path === "string" ? args.path : undefined; + if (!filePath?.endsWith("/SKILL.md")) continue; + + // Skill name is the parent directory name: + // .../skills/backing-up-with-keld/SKILL.md → backing-up-with-keld + const parent = path.basename(path.dirname(filePath)); + if (parent && parent !== "skills") { + skills.add(parent); + } + } + } + return [...skills].sort(); +} + +/** + * Resolve the model to use for handoff extraction calls. Uses the + * PI_HANDOFF_MODEL env var if set, otherwise falls back to the session model. + */ +function resolveExtractionModel(ctx: { + model: ExtensionContext["model"]; + modelRegistry: ExtensionContext["modelRegistry"]; +}): ExtensionContext["model"] { + if (!HANDOFF_MODEL_OVERRIDE) return ctx.model; + const slashIdx = HANDOFF_MODEL_OVERRIDE.indexOf("/"); + if (slashIdx <= 0) return ctx.model; + const provider = HANDOFF_MODEL_OVERRIDE.slice(0, slashIdx); + const modelId = HANDOFF_MODEL_OVERRIDE.slice(slashIdx + 1); + return ctx.modelRegistry.find(provider, modelId) ?? ctx.model; +} + +type HandoffExtraction = { + files: string[]; + skills: string[]; + context: string; + openItems: string[]; +}; + +/** + * Run the extraction LLM call and return structured context, or null if + * aborted or the model didn't produce a valid tool call. + */ +async function extractHandoffContext( + model: NonNullable, + apiKey: string, + headers: Record | undefined, + conversationText: string, + goal: string, + candidateFiles: string[], + loadedSkills: string[], + signal?: AbortSignal, +): Promise { + const filesContext = + candidateFiles.length > 0 + ? `\n\n## Candidate Files\n\nThese files were touched or mentioned during the session. Return only the ones relevant to the goal.\n\n${candidateFiles.map((f) => `- ${f}`).join("\n")}` + : ""; + + const skillsContext = + loadedSkills.length > 0 + ? `\n\n## Skills Loaded During This Session\n\n${loadedSkills.map((s) => `- ${s}`).join("\n")}\n\nReturn only the skills from this list that are relevant to the goal.` + : ""; + + const userMessage: Message = { + role: "user", + content: [ + { + type: "text", + text: `## Conversation\n\n${conversationText}\n\n## Goal for Next Session\n\n${goal}${filesContext}${skillsContext}`, + }, + ], + timestamp: Date.now(), + }; + + const response = await complete( + model, + { + systemPrompt: SYSTEM_PROMPT, + messages: [userMessage], + tools: [HANDOFF_EXTRACTION_TOOL], + }, + { apiKey, headers, signal }, + ); + + if (response.stopReason === "aborted") return null; + + const toolCall = response.content.find((c) => c.type === "toolCall" && c.name === "extract_handoff_context"); + + if (!toolCall || toolCall.type !== "toolCall") { + console.error("Model did not call extract_handoff_context:", response.content); + return null; + } + + const args = toolCall.arguments as Record; + + if ( + !Array.isArray(args.relevantFiles) || + !Array.isArray(args.skillsInUse) || + typeof args.context !== "string" || + !Array.isArray(args.openItems) + ) { + console.error("Unexpected tool call arguments shape:", args); + return null; + } + + const candidateSet = new Set(candidateFiles); + const loadedSkillSet = new Set(loadedSkills); + + return { + files: (args.relevantFiles as string[]).filter((f) => typeof f === "string" && candidateSet.has(f)), + skills: (args.skillsInUse as string[]).filter((s) => typeof s === "string" && loadedSkillSet.has(s)), + context: args.context as string, + openItems: (args.openItems as string[]).filter((item) => typeof item === "string"), + }; +} + +/** + * Assemble the handoff draft. The user's goal goes last so it has the + * most weight in the new session (recency bias). + */ +function assembleHandoffDraft(result: HandoffExtraction, goal: string, parentSessionFile: string | undefined): string { + const parentBlock = parentSessionFile + ? `**Parent session:** \`${parentSessionFile}\`\n\nUse the \`session_query\` tool with this path if you need details from the prior thread.\n\n` + : ""; + + const filesSection = result.files.length > 0 ? `## Files\n\n${result.files.map((f) => `- ${f}`).join("\n")}\n\n` : ""; + + const skillsSection = + result.skills.length > 0 ? `## Skills in Use\n\n${result.skills.map((s) => `- ${s}`).join("\n")}\n\n` : ""; + + const contextSection = `## Context\n\n${result.context}\n\n`; + + const openItemsSection = + result.openItems.length > 0 ? `## Open Items\n\n${result.openItems.map((item) => `- ${item}`).join("\n")}\n\n` : ""; + + return `${parentBlock}${filesSection}${skillsSection}${contextSection}${openItemsSection}${goal}`.trim(); +} + +export default function (pi: ExtensionAPI) { + let pending: PendingAutoSubmit | null = null; + let pendingHandoff: PendingHandoff | null = null; + let handoffTimestamp: number | null = null; + + const clearPending = (ctx?: ExtensionContext, notify?: string) => { + if (!pending) return; + + clearInterval(pending.interval); + pending.unsubscribeInput(); + pending.ctx.ui.setStatus(STATUS_KEY, undefined); + + const local = pending; + pending = null; + + if (notify && ctx) { + ctx.ui.notify(notify, "info"); + } else if (notify) { + local.ctx.ui.notify(notify, "info"); + } + }; + + const autoSubmitDraft = () => { + if (!pending) return; + + const active = pending; + const currentSession = active.ctx.sessionManager.getSessionFile(); + if (active.sessionFile && currentSession !== active.sessionFile) { + clearPending(undefined); + return; + } + + const draft = active.ctx.ui.getEditorText().trim(); + clearPending(undefined); + + if (!draft) { + active.ctx.ui.notify("Handoff draft is empty", "warning"); + return; + } + + active.ctx.ui.setEditorText(""); + + try { + if (active.ctx.isIdle()) { + pi.sendUserMessage(draft); + } else { + pi.sendUserMessage(draft, { deliverAs: "followUp" }); + } + } catch { + pi.sendUserMessage(draft); + } + }; + + const startCountdown = (ctx: ExtensionContext) => { + clearPending(ctx); + + let seconds = COUNTDOWN_SECONDS; + ctx.ui.setStatus(STATUS_KEY, statusLine(ctx, seconds)); + + const unsubscribeInput = ctx.ui.onTerminalInput((data) => { + if (matchesKey(data, Key.escape)) { + clearPending(ctx, "Handoff auto-submit cancelled"); + return { consume: true }; + } + + if (isEditableInput(data)) { + clearPending(ctx, "Handoff auto-submit stopped (editing)"); + } + + return undefined; + }); + + const interval = setInterval(() => { + if (!pending) return; + + seconds -= 1; + if (seconds <= 0) { + autoSubmitDraft(); + return; + } + + ctx.ui.setStatus(STATUS_KEY, statusLine(ctx, seconds)); + }, 1000); + + pending = { + ctx, + sessionFile: ctx.sessionManager.getSessionFile(), + interval, + unsubscribeInput, + }; + }; + + pi.on("session_before_switch", (_event, ctx) => { + if (pending) clearPending(ctx); + }); + + pi.on("session_switch", (_event, ctx) => { + if (pending) clearPending(ctx); + // A proper session switch (e.g. /new) fully resets agent state, + // so clear the context filter to avoid hiding new messages. + handoffTimestamp = null; + }); + + pi.on("session_before_fork", (_event, ctx) => { + if (pending) clearPending(ctx); + }); + + pi.on("session_fork", (_event, ctx) => { + if (pending) clearPending(ctx); + }); + + pi.on("session_before_tree", (_event, ctx) => { + if (pending) clearPending(ctx); + }); + + pi.on("session_tree", (_event, ctx) => { + if (pending) clearPending(ctx); + }); + + pi.on("session_shutdown", (_event, ctx) => { + if (pending) clearPending(ctx); + }); + + // --- Tool-path handoff coordination --- + // + // The /handoff command has ExtensionCommandContext with ctx.newSession() + // which does a full agent reset. The handoff tool only gets + // ExtensionContext, which lacks newSession(). So the tool stores a + // pending handoff and these handlers complete it: + // + // 1. agent_end: after the agent loop finishes, switch sessions and + // send the handoff prompt in the next macrotask + // 2. context: filter pre-handoff messages since the low-level session + // switch doesn't clear agent.state.messages + // 3. session_switch (above): clears the filter on proper switches + + pi.on("agent_end", (_event, ctx) => { + if (!pendingHandoff) return; + + const { prompt, parentSession, newModel } = pendingHandoff; + pendingHandoff = null; + + handoffTimestamp = Date.now(); + (ctx.sessionManager as any).newSession({ parentSession }); + + setTimeout(async () => { + if (newModel) { + const slashIdx = newModel.indexOf("/"); + if (slashIdx > 0) { + const provider = newModel.slice(0, slashIdx); + const modelId = newModel.slice(slashIdx + 1); + const model = ctx.modelRegistry.find(provider, modelId); + if (model) await pi.setModel(model); + } + } + pi.sendUserMessage(prompt); + }, 0); + }); + + pi.on("context", (event) => { + if (handoffTimestamp === null) return; + + const newMessages = (event as any).messages.filter((m: any) => m.timestamp >= handoffTimestamp); + if (newMessages.length > 0) { + return { messages: newMessages }; + } + }); + + pi.registerTool({ + name: "session_query", + label: (params) => `Session Query: ${params.question}`, + description: + "Query a prior pi session file. Use when handoff prompt references a parent session and you need details.", + parameters: Type.Object({ + sessionPath: Type.String({ + description: + "Session .jsonl path. Absolute path, or relative to sessions root (e.g. 2026-02-16/foo/session.jsonl)", + }), + question: Type.String({ description: "Question about that session" }), + }), + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const currentSessionFile = ctx.sessionManager.getSessionFile(); + const sessionsRoot = getSessionsRoot(currentSessionFile) ?? getFallbackSessionsRoot(); + const resolvedPath = normalizeSessionPath(params.sessionPath, sessionsRoot); + + const error = (text: string) => ({ + content: [{ type: "text" as const, text }], + details: { error: true }, + }); + + const cancelled = () => ({ + content: [{ type: "text" as const, text: "Session query cancelled." }], + details: { cancelled: true }, + }); + + if (signal.aborted) { + return cancelled(); + } + + if (!resolvedPath.endsWith(".jsonl")) { + return error(`Invalid session path (expected .jsonl): ${params.sessionPath}`); + } + + if (!sessionPathAllowed(resolvedPath, sessionsRoot)) { + return error(`Session path outside allowed sessions directory: ${params.sessionPath}`); + } + + if (!fs.existsSync(resolvedPath)) { + return error(`Session file not found: ${resolvedPath}`); + } + + let fileStats: fs.Stats; + try { + fileStats = fs.statSync(resolvedPath); + } catch (err) { + return error(`Failed to stat session file: ${String(err)}`); + } + + if (!fileStats.isFile()) { + return error(`Session path is not a file: ${resolvedPath}`); + } + + onUpdate?.({ + content: [{ type: "text", text: `Querying: ${resolvedPath}` }], + details: { status: "loading", sessionPath: resolvedPath }, + }); + + let sessionManager: SessionManager; + try { + sessionManager = SessionManager.open(resolvedPath); + } catch (err) { + return error(`Failed to open session: ${String(err)}`); + } + + const branch = sessionManager.getBranch(); + const messages = branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message); + + if (messages.length === 0) { + return { + content: [{ type: "text" as const, text: "Session has no messages." }], + details: { empty: true, sessionPath: resolvedPath }, + }; + } + + if (!ctx.model) { + return error("No model selected for session query."); + } + + const conversationText = serializeConversation(convertToLlm(messages)); + try { + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); + if (!auth.ok) { + return error(`Failed to get API key: ${auth.error}`); + } + const userMessage: Message = { + role: "user", + content: [ + { + type: "text", + text: `## Session\n\n${conversationText}\n\n## Question\n\n${params.question}`, + }, + ], + timestamp: Date.now(), + }; + + const response = await complete( + ctx.model, + { systemPrompt: QUERY_SYSTEM_PROMPT, messages: [userMessage] }, + { apiKey: auth.apiKey, headers: auth.headers, signal }, + ); + + if (response.stopReason === "aborted") { + return cancelled(); + } + + const answer = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n") + .trim(); + + return { + content: [{ type: "text" as const, text: answer || "No answer generated." }], + details: { + sessionPath: resolvedPath, + question: params.question, + messageCount: messages.length, + }, + }; + } catch (err) { + if (signal.aborted) { + return cancelled(); + } + if (err instanceof Error && err.name === "AbortError") { + return cancelled(); + } + return error(`Session query failed: ${String(err)}`); + } + }, + }); + + pi.registerTool({ + name: "handoff", + label: "Handoff", + description: + "Transfer context to a new session. Use when the user explicitly asks for a handoff or when the context window is nearly full. Provide a goal describing what the new session should focus on.", + parameters: Type.Object({ + goal: Type.String({ + description: "The goal/task for the new session", + }), + model: Type.Optional( + Type.String({ + description: "Model for the new session as provider/modelId (e.g. 'anthropic/claude-haiku-4-5')", + }), + ), + }), + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + if (!ctx.model) { + return { + content: [{ type: "text" as const, text: "No model selected." }], + }; + } + + const branch = ctx.sessionManager.getBranch(); + const messages = branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message); + + if (messages.length === 0) { + return { + content: [ + { + type: "text" as const, + text: "No conversation to hand off.", + }, + ], + }; + } + + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const candidateFiles = [...extractCandidateFiles(branch, conversationText)]; + const loadedSkills = extractLoadedSkills(branch); + + const extractionModel = resolveExtractionModel(ctx) ?? ctx.model; + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel); + if (!auth.ok) { + return { + content: [ + { + type: "text" as const, + text: `Failed to get API key: ${auth.error}`, + }, + ], + }; + } + + let result: HandoffExtraction | null; + try { + result = await extractHandoffContext( + extractionModel, + auth.apiKey, + auth.headers, + conversationText, + params.goal, + candidateFiles, + loadedSkills, + signal, + ); + } catch (err) { + if (signal.aborted) { + return { + content: [ + { + type: "text" as const, + text: "Handoff cancelled.", + }, + ], + }; + } + return { + content: [ + { + type: "text" as const, + text: `Handoff extraction failed: ${String(err)}`, + }, + ], + }; + } + + if (!result) { + return { + content: [ + { + type: "text" as const, + text: "Handoff extraction failed or was cancelled.", + }, + ], + }; + } + + const currentSessionFile = ctx.sessionManager.getSessionFile(); + const prompt = assembleHandoffDraft(result, params.goal, currentSessionFile); + + pendingHandoff = { + prompt, + parentSession: currentSessionFile, + newModel: params.model, + }; + + return { + content: [ + { + type: "text" as const, + text: "Handoff prepared. The session will switch after this turn completes.", + }, + ], + }; + }, + }); + + pi.registerCommand("handoff", { + description: "Transfer context to a new session (-model provider/modelId)", + handler: async (args: string, ctx: ExtensionCommandContext) => { + if (!ctx.hasUI) { + ctx.ui.notify("/handoff requires interactive mode", "error"); + return; + } + + if (!ctx.model) { + ctx.ui.notify("No model selected", "error"); + return; + } + + // Parse optional -model flag from args + let remaining = args; + let newSessionModel: string | undefined; + + const modelMatch = remaining.match(/(?:^|\s)-model\s+(\S+)/); + if (modelMatch) { + newSessionModel = modelMatch[1]; + remaining = remaining.replace(modelMatch[0], " "); + } + + let goal = remaining.trim(); + if (!goal) { + const entered = await ctx.ui.input("handoff goal", "What should the new thread do?"); + if (!entered?.trim()) { + ctx.ui.notify("Handoff cancelled", "info"); + return; + } + goal = entered.trim(); + } + + const branch = ctx.sessionManager.getBranch(); + const messages = branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message); + + if (messages.length === 0) { + ctx.ui.notify("No conversation to hand off", "warning"); + return; + } + + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const currentSessionFile = ctx.sessionManager.getSessionFile(); + const candidateFiles = [...extractCandidateFiles(branch, conversationText)]; + const loadedSkills = extractLoadedSkills(branch); + const extractionModel = resolveExtractionModel(ctx) ?? ctx.model; + + const result = await ctx.ui.custom((tui, theme, _kb, done) => { + const loader = new BorderedLoader(tui, theme, "Extracting handoff context..."); + loader.onAbort = () => done(null); + + const run = async () => { + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel); + if (!auth.ok) { + throw new Error(`Failed to get API key: ${auth.error}`); + } + + return extractHandoffContext( + extractionModel, + auth.apiKey, + auth.headers, + conversationText, + goal, + candidateFiles, + loadedSkills, + loader.signal, + ); + }; + + run() + .then(done) + .catch((err) => { + console.error("handoff generation failed", err); + done(null); + }); + + return loader; + }); + + if (!result) { + ctx.ui.notify("Handoff cancelled", "info"); + return; + } + + const prefillDraft = assembleHandoffDraft(result, goal, currentSessionFile); + + const editedPrompt = await ctx.ui.editor("Edit handoff draft", prefillDraft); + if (editedPrompt === undefined) { + ctx.ui.notify("Handoff cancelled", "info"); + return; + } + + const next = await ctx.newSession({ + parentSession: currentSessionFile, + }); + + if (next.cancelled) { + ctx.ui.notify("New session cancelled", "info"); + return; + } + + // Apply -model if specified + if (newSessionModel) { + const slashIdx = newSessionModel.indexOf("/"); + if (slashIdx > 0) { + const provider = newSessionModel.slice(0, slashIdx); + const modelId = newSessionModel.slice(slashIdx + 1); + const model = ctx.modelRegistry.find(provider, modelId); + if (model) { + await pi.setModel(model); + } else { + ctx.ui.notify(`Unknown model: ${newSessionModel}`, "warning"); + } + } else { + ctx.ui.notify(`Invalid model format "${newSessionModel}", expected provider/modelId`, "warning"); + } + } + + const newSessionFile = ctx.sessionManager.getSessionFile(); + if (newSessionFile) { + ctx.ui.notify(`Switched to new session: ${newSessionFile}`, "info"); + } + + ctx.ui.setEditorText(editedPrompt); + startCountdown(ctx); + }, + }); +} diff --git a/packages/handoff/tsconfig.json b/packages/handoff/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..0c91d6284869132f30fd7f951e89ce4b9c7d9d54 --- /dev/null +++ b/packages/handoff/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/packages/handoff/tsconfig.json.license b/packages/handoff/tsconfig.json.license new file mode 100644 index 0000000000000000000000000000000000000000..3dbb1e29808ff6ce1e89aa3211dbfa6c8aa5ef0e --- /dev/null +++ b/packages/handoff/tsconfig.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: Amolith + +SPDX-License-Identifier: CC0-1.0