Commit 2ad005c75ec2
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");
+ },
+ });
+}