mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 21:03:19 +00:00
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:
parent
4ecf3f9422
commit
f6b728a6e5
1 changed files with 132 additions and 53 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue