refactor(plan-mode): use smart keyword matching instead of IDs

- Remove ugly [DONE:id] tags - users no longer see IDs
- Track progress via keyword matching on tool results
- Extract significant keywords from todo text
- Match tool name + input against todo keywords
- Sequential preference: first uncompleted item gets bonus score
- Much cleaner UX - progress tracked silently in background
This commit is contained in:
Helmut Januschka 2026-01-03 21:33:31 +01:00 committed by Mario Zechner
parent 4ecf3f9422
commit f6b728a6e5

View file

@ -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<string, unknown>): 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(