Commit 2ad005c75ec2

Vincent Demeester <vincent@sbr.pm>
2026-02-06 18:14:40
feat(pi): add handoff extension for session context transfer
Added handoff.ts extension for transferring context between sessions. Features: - /handoff <goal> generates focused prompt from conversation history - Uses LLM to extract: context, decisions, files, next task - Shows draft in editor for review/editing before creating new session - Creates new session with parent tracking ai-storage Integration: - Saves to ~/.local/share/ai/sessions/YYYY-MM/handoff-TIMESTAMP.md - Includes: goal, original session link, generated prompt, metadata - Provides traceability between related sessions - Searchable handoff history Usage: /handoff now implement this for teams as well /handoff execute phase one of the plan /handoff check other places that need this fix Benefits: - Never lose context when switching tasks - Clean task transitions with focused prompts - Searchable history of all handoffs - Links back to original conversation Source: https://github.com/badlogic/pi-mono Future: org-todos integration for creating follow-up tasks
1 parent e8fb21e
Changed files (1)
dots
pi
agent
extensions
dots/pi/agent/extensions/handoff.ts
@@ -0,0 +1,230 @@
+/**
+ * Handoff extension - transfer context to a new focused session
+ *
+ * Instead of compacting (which is lossy), handoff extracts what matters
+ * for your next task and creates a new session with a generated prompt.
+ *
+ * Usage:
+ *   /handoff now implement this for teams as well
+ *   /handoff execute phase one of the plan
+ *   /handoff check other places that need this fix
+ *
+ * The generated prompt appears as a draft in the editor for review/editing.
+ */
+
+import { complete, type Message } from "@mariozechner/pi-ai";
+import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
+import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
+import { promises as fs } from "node:fs";
+import * as path from "node:path";
+
+const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
+
+1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
+2. Lists any relevant files that were discussed or modified
+3. Clearly states the next task based on the user's goal
+4. Is self-contained - the new thread should be able to proceed without the old conversation
+
+Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
+
+Example output format:
+## Context
+We've been working on X. Key decisions:
+- Decision 1
+- Decision 2
+
+Files involved:
+- path/to/file1.ts
+- path/to/file2.ts
+
+## Task
+[Clear description of what to do next based on user's goal]`;
+
+// =============================================================================
+// Helper Functions
+// =============================================================================
+
+function getYearMonth(): string {
+	const now = new Date();
+	const year = now.getFullYear();
+	const month = String(now.getMonth() + 1).padStart(2, "0");
+	return `${year}-${month}`;
+}
+
+function formatTimestamp(date: Date): string {
+	return date.toISOString().replace("T", " ").slice(0, 19);
+}
+
+async function saveHandoffToStorage(data: {
+	goal: string;
+	originalSession: string;
+	generatedPrompt: string;
+	model: string;
+	entryCount: number;
+}): Promise<string> {
+	const home = process.env.HOME || process.env.USERPROFILE || "";
+	const yearMonth = getYearMonth();
+	const reviewsDir = path.join(home, ".local", "share", "ai", "sessions", yearMonth);
+	
+	// Ensure directory exists
+	await fs.mkdir(reviewsDir, { recursive: true });
+	
+	const timestamp = Date.now();
+	const filename = `handoff-${timestamp}.md`;
+	const filepath = path.join(reviewsDir, filename);
+	
+	const goalSummary = data.goal.slice(0, 60);
+	const now = new Date();
+	
+	const markdown = `# Handoff: ${goalSummary}
+
+**Created:** ${formatTimestamp(now)}
+**Original Session:** ${data.originalSession}
+**Goal:** ${data.goal}
+
+## Generated Prompt
+
+${data.generatedPrompt}
+
+## Metadata
+
+- Model: ${data.model}
+- Original session entries: ${data.entryCount}
+- Timestamp: ${timestamp}
+
+## Follow-up Actions
+
+- [ ] Create new session with this prompt
+- [ ] Review and refine context
+- [ ] Link related TODOs
+`;
+
+	await fs.writeFile(filepath, markdown, "utf-8");
+	return filepath;
+}
+
+export default function (pi: ExtensionAPI) {
+	pi.registerCommand("handoff", {
+		description: "Transfer context to a new focused session",
+		handler: async (args, ctx) => {
+			if (!ctx.hasUI) {
+				ctx.ui.notify("handoff requires interactive mode", "error");
+				return;
+			}
+
+			if (!ctx.model) {
+				ctx.ui.notify("No model selected", "error");
+				return;
+			}
+
+			const goal = args.trim();
+			if (!goal) {
+				ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
+				return;
+			}
+
+			// Gather conversation context from current branch
+			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", "error");
+				return;
+			}
+
+			// Convert to LLM format and serialize
+			const llmMessages = convertToLlm(messages);
+			const conversationText = serializeConversation(llmMessages);
+			const currentSessionFile = ctx.sessionManager.getSessionFile();
+
+			// Generate the handoff prompt with loader UI
+			const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
+				const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
+				loader.onAbort = () => done(null);
+
+				const doGenerate = async () => {
+					const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
+
+					const userMessage: Message = {
+						role: "user",
+						content: [
+							{
+								type: "text",
+								text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
+							},
+						],
+						timestamp: Date.now(),
+					};
+
+					const response = await complete(
+						ctx.model!,
+						{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
+						{ apiKey, signal: loader.signal },
+					);
+
+					if (response.stopReason === "aborted") {
+						return null;
+					}
+
+					return response.content
+						.filter((c): c is { type: "text"; text: string } => c.type === "text")
+						.map((c) => c.text)
+						.join("\n");
+				};
+
+				doGenerate()
+					.then(done)
+					.catch((err) => {
+						console.error("Handoff generation failed:", err);
+						done(null);
+					});
+
+				return loader;
+			});
+
+			if (result === null) {
+				ctx.ui.notify("Cancelled", "info");
+				return;
+			}
+
+			// Save handoff to ai-storage
+			try {
+				const handoffPath = await saveHandoffToStorage({
+					goal,
+					originalSession: currentSessionFile,
+					generatedPrompt: result,
+					model: ctx.model.id,
+					entryCount: messages.length,
+				});
+				
+				ctx.ui.notify(`Handoff saved: ${path.basename(handoffPath)}`, "success");
+			} catch (error: any) {
+				ctx.ui.notify(`Warning: Failed to save handoff: ${error.message}`, "warning");
+			}
+
+			// Let user edit the generated prompt
+			const editedPrompt = await ctx.ui.editor("Edit handoff prompt", result);
+
+			if (editedPrompt === undefined) {
+				ctx.ui.notify("Cancelled", "info");
+				return;
+			}
+
+			// Create new session with parent tracking
+			const newSessionResult = await ctx.newSession({
+				parentSession: currentSessionFile,
+			});
+
+			if (newSessionResult.cancelled) {
+				ctx.ui.notify("New session cancelled", "info");
+				return;
+			}
+
+			// Set the edited prompt in the main editor for submission
+			ctx.ui.setEditorText(editedPrompt);
+			ctx.ui.notify("Handoff ready. Submit when ready.", "info");
+		},
+	});
+}