diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index 8bd4fd08..b809139f 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -11,7 +11,7 @@ * - After each agent response, prompts to execute the plan or continue planning * - Shows "plan" indicator in footer when active * - Extracts todo list from plan and tracks progress during execution - * - Agent marks steps complete by outputting [DONE:id] tags + * - Uses smart matching to track progress (no ugly IDs shown to user) * * Usage: * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/ @@ -130,20 +130,64 @@ function isSafeCommand(command: string): boolean { return true; } -// Todo item with unique ID +// Todo item interface TodoItem { - id: string; text: string; completed: boolean; -} - -// Generate a short unique ID -function generateId(): string { - return Math.random().toString(36).substring(2, 8); + // Keywords extracted for matching + keywords: string[]; } /** - * Extract todo items from assistant message and assign IDs. + * Extract significant keywords from text for matching. + */ +function extractKeywords(text: string): string[] { + // Remove common words and extract significant terms + const stopWords = new Set([ + "the", + "a", + "an", + "to", + "for", + "of", + "in", + "on", + "at", + "by", + "with", + "using", + "and", + "or", + "use", + "run", + "execute", + "create", + "make", + "do", + "then", + "next", + "step", + "first", + "second", + "third", + "finally", + "it", + "its", + "this", + "that", + "from", + "into", + ]); + + return text + .toLowerCase() + .replace(/[^a-z0-9/._-]/g, " ") + .split(/\s+/) + .filter((w) => w.length > 2 && !stopWords.has(w)); +} + +/** + * Extract todo items from assistant message. */ function extractTodoItems(message: string): TodoItem[] { const items: TodoItem[] = []; @@ -154,7 +198,11 @@ function extractTodoItems(message: string): TodoItem[] { let text = match[2].trim(); text = text.replace(/\*{1,2}$/, "").trim(); if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) { - items.push({ id: generateId(), text, completed: false }); + items.push({ + text, + completed: false, + keywords: extractKeywords(text), + }); } } @@ -165,7 +213,11 @@ function extractTodoItems(message: string): TodoItem[] { let text = match[1].trim(); text = text.replace(/\*{1,2}$/, "").trim(); if (text.length > 10 && !text.startsWith("`")) { - items.push({ id: generateId(), text, completed: false }); + items.push({ + text, + completed: false, + keywords: extractKeywords(text), + }); } } } @@ -174,15 +226,54 @@ function extractTodoItems(message: string): TodoItem[] { } /** - * Find [DONE:id] tags in text and return the IDs. + * Calculate similarity between tool action and todo item. + * Returns a score from 0 to 1. */ -function findDoneTags(text: string): string[] { - const pattern = /\[DONE:([a-z0-9]+)\]/gi; - const ids: string[] = []; - for (const match of text.matchAll(pattern)) { - ids.push(match[1].toLowerCase()); +function matchScore(todoKeywords: string[], actionText: string): number { + if (todoKeywords.length === 0) return 0; + + const actionLower = actionText.toLowerCase(); + let matches = 0; + + for (const keyword of todoKeywords) { + if (actionLower.includes(keyword)) { + matches++; + } } - return ids; + + return matches / todoKeywords.length; +} + +/** + * Find the best matching uncompleted todo for a tool action. + * Uses keyword matching with a preference for sequential order. + */ +function findBestMatch(todos: TodoItem[], toolName: string, input: Record): number { + // Build action text from tool name and input + let actionText = toolName; + if (input.path) actionText += ` ${input.path}`; + if (input.command) actionText += ` ${input.command}`; + if (input.content) actionText += ` ${String(input.content).slice(0, 100)}`; + + let bestIdx = -1; + let bestScore = 0.3; // Minimum threshold + + for (let i = 0; i < todos.length; i++) { + if (todos[i].completed) continue; + + const score = matchScore(todos[i].keywords, actionText); + + // Bonus for being the first uncompleted item (sequential preference) + const isFirstUncompleted = !todos.slice(0, i).some((t) => !t.completed); + const adjustedScore = isFirstUncompleted ? score + 0.1 : score; + + if (adjustedScore > bestScore) { + bestScore = adjustedScore; + bestIdx = i; + } + } + + return bestIdx; } export default function planModeHook(pi: HookAPI) { @@ -259,9 +350,9 @@ export default function planModeHook(pi: HookAPI) { } const todoList = todoItems - .map((item) => { + .map((item, i) => { const checkbox = item.completed ? "✓" : "○"; - return `[${item.id}] ${checkbox} ${item.text}`; + return `${i + 1}. ${checkbox} ${item.text}`; }) .join("\n"); @@ -291,6 +382,19 @@ export default function planModeHook(pi: HookAPI) { } }); + // Track progress via tool results + pi.on("tool_result", async (event, ctx) => { + if (!executionMode || todoItems.length === 0) return; + if (event.isError) return; + + // Find best matching todo item + const matchIdx = findBestMatch(todoItems, event.toolName, event.input); + if (matchIdx >= 0 && !todoItems[matchIdx].completed) { + todoItems[matchIdx].completed = true; + updateStatus(ctx); + } + }); + // Inject plan mode context pi.on("before_agent_start", async () => { if (!planModeEnabled && !executionMode) return; @@ -320,46 +424,22 @@ Do NOT attempt to make changes - just describe what you would do.`, } if (executionMode && todoItems.length > 0) { - const todoList = todoItems.map((t) => `- [${t.id}] ${t.completed ? "☑" : "☐"} ${t.text}`).join("\n"); + const completed = todoItems.filter((t) => t.completed).length; return { message: { customType: "plan-execution-context", - content: `[EXECUTING PLAN] -You have a plan with ${todoItems.length} steps. After completing each step, output [DONE:id] to mark it complete. - -Current plan status: -${todoList} - -IMPORTANT: After completing each step, output [DONE:id] where id is the step's ID (e.g., [DONE:${todoItems.find((t) => !t.completed)?.id || todoItems[0].id}]).`, + content: `[EXECUTING PLAN - ${completed}/${todoItems.length} complete] +Continue executing the plan step by step.`, display: false, }, }; } }); - // After agent finishes in plan mode + // After agent finishes pi.on("agent_end", async (event, ctx) => { - // Check for done tags in the final message + // Check if all complete if (executionMode && todoItems.length > 0) { - const messages = event.messages; - const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); - if (lastAssistant && Array.isArray(lastAssistant.content)) { - const textContent = lastAssistant.content - .filter((block): block is { type: "text"; text: string } => block.type === "text") - .map((block) => block.text) - .join("\n"); - - const doneIds = findDoneTags(textContent); - for (const id of doneIds) { - const item = todoItems.find((t) => t.id === id); - if (item && !item.completed) { - item.completed = true; - } - } - updateStatus(ctx); - } - - // Check if all complete const allComplete = todoItems.every((t) => t.completed); if (allComplete) { // Show final completed list in chat @@ -374,7 +454,6 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I ); executionMode = false; - const _completedItems = [...todoItems]; // Keep for reference todoItems = []; pi.setActiveTools(NORMAL_MODE_TOOLS); updateStatus(ctx); @@ -404,9 +483,9 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I const hasTodos = todoItems.length > 0; - // Show todo list in chat with IDs + // Show todo list in chat (no IDs, just numbered) if (hasTodos) { - const todoListText = todoItems.map((t) => `☐ [${t.id}] ${t.text}`).join("\n"); + const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n"); pi.sendMessage( { customType: "plan-todo-list", @@ -430,7 +509,7 @@ IMPORTANT: After completing each step, output [DONE:id] where id is the step's I updateStatus(ctx); const execMessage = hasTodos - ? `Execute the plan. After completing each step, output [DONE:id] where id is the step's ID. Start with step [${todoItems[0].id}]: ${todoItems[0].text}` + ? `Execute the plan step by step. Start with: ${todoItems[0].text}` : "Execute the plan you just created. Proceed step by step."; pi.sendMessage(