mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 08:00:59 +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
|
* - After each agent response, prompts to execute the plan or continue planning
|
||||||
* - Shows "plan" indicator in footer when active
|
* - Shows "plan" indicator in footer when active
|
||||||
* - Extracts todo list from plan and tracks progress during execution
|
* - 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:
|
* Usage:
|
||||||
* 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/
|
* 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Todo item with unique ID
|
// Todo item
|
||||||
interface TodoItem {
|
interface TodoItem {
|
||||||
id: string;
|
|
||||||
text: string;
|
text: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
}
|
// Keywords extracted for matching
|
||||||
|
keywords: string[];
|
||||||
// Generate a short unique ID
|
|
||||||
function generateId(): string {
|
|
||||||
return Math.random().toString(36).substring(2, 8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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[] {
|
function extractTodoItems(message: string): TodoItem[] {
|
||||||
const items: TodoItem[] = [];
|
const items: TodoItem[] = [];
|
||||||
|
|
@ -154,7 +198,11 @@ function extractTodoItems(message: string): TodoItem[] {
|
||||||
let text = match[2].trim();
|
let text = match[2].trim();
|
||||||
text = text.replace(/\*{1,2}$/, "").trim();
|
text = text.replace(/\*{1,2}$/, "").trim();
|
||||||
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
|
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();
|
let text = match[1].trim();
|
||||||
text = text.replace(/\*{1,2}$/, "").trim();
|
text = text.replace(/\*{1,2}$/, "").trim();
|
||||||
if (text.length > 10 && !text.startsWith("`")) {
|
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[] {
|
function matchScore(todoKeywords: string[], actionText: string): number {
|
||||||
const pattern = /\[DONE:([a-z0-9]+)\]/gi;
|
if (todoKeywords.length === 0) return 0;
|
||||||
const ids: string[] = [];
|
|
||||||
for (const match of text.matchAll(pattern)) {
|
const actionLower = actionText.toLowerCase();
|
||||||
ids.push(match[1].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) {
|
export default function planModeHook(pi: HookAPI) {
|
||||||
|
|
@ -259,9 +350,9 @@ export default function planModeHook(pi: HookAPI) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const todoList = todoItems
|
const todoList = todoItems
|
||||||
.map((item) => {
|
.map((item, i) => {
|
||||||
const checkbox = item.completed ? "✓" : "○";
|
const checkbox = item.completed ? "✓" : "○";
|
||||||
return `[${item.id}] ${checkbox} ${item.text}`;
|
return `${i + 1}. ${checkbox} ${item.text}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.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
|
// Inject plan mode context
|
||||||
pi.on("before_agent_start", async () => {
|
pi.on("before_agent_start", async () => {
|
||||||
if (!planModeEnabled && !executionMode) return;
|
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) {
|
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 {
|
return {
|
||||||
message: {
|
message: {
|
||||||
customType: "plan-execution-context",
|
customType: "plan-execution-context",
|
||||||
content: `[EXECUTING PLAN]
|
content: `[EXECUTING PLAN - ${completed}/${todoItems.length} complete]
|
||||||
You have a plan with ${todoItems.length} steps. After completing each step, output [DONE:id] to mark it complete.
|
Continue executing the plan step by step.`,
|
||||||
|
|
||||||
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}]).`,
|
|
||||||
display: false,
|
display: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// After agent finishes in plan mode
|
// After agent finishes
|
||||||
pi.on("agent_end", async (event, ctx) => {
|
pi.on("agent_end", async (event, ctx) => {
|
||||||
// Check for done tags in the final message
|
// Check if all complete
|
||||||
if (executionMode && todoItems.length > 0) {
|
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);
|
const allComplete = todoItems.every((t) => t.completed);
|
||||||
if (allComplete) {
|
if (allComplete) {
|
||||||
// Show final completed list in chat
|
// 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;
|
executionMode = false;
|
||||||
const _completedItems = [...todoItems]; // Keep for reference
|
|
||||||
todoItems = [];
|
todoItems = [];
|
||||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
||||||
updateStatus(ctx);
|
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;
|
const hasTodos = todoItems.length > 0;
|
||||||
|
|
||||||
// Show todo list in chat with IDs
|
// Show todo list in chat (no IDs, just numbered)
|
||||||
if (hasTodos) {
|
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(
|
pi.sendMessage(
|
||||||
{
|
{
|
||||||
customType: "plan-todo-list",
|
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);
|
updateStatus(ctx);
|
||||||
|
|
||||||
const execMessage = hasTodos
|
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.";
|
: "Execute the plan you just created. Proceed step by step.";
|
||||||
|
|
||||||
pi.sendMessage(
|
pi.sendMessage(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue