/** * goal-driven-loop extension for Pi. * * Wires together trigger detection, state injection, and timed * auto-defaults to eliminate unnecessary user interaction. */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { join } from "node:path"; import { existsSync, unlinkSync } from "node:fs"; import { detectTrigger, isCoarseGrained } from "./trigger-detector.js"; import { readGoalState, buildInjectionText, buildContinuationPrompt, type GoalState, } from "./state-injector.js"; import { promptStaleState, notifyCoarseGrainedDelegation, notifyMidLoopUpgrade, notifyStagnation, shouldNotifyStagnation, type UIContext, } from "./ui-prompts.js"; const CONTEXT_TENSION_THRESHOLD = 0.8; const STAGNATION_KEYWORDS = [ "found", "fixed", "implemented", "added", "refactored", "verified", ]; const ABANDON_KEYWORDS = ["abandon", "cancel", "不要了", "放弃", "停"]; function buildUIContext(ctx: ExtensionContext): UIContext { return { hasUI: ctx.hasUI, select: ctx.ui.select.bind(ctx.ui), notify: ctx.ui.notify.bind(ctx.ui), confirm: ctx.ui.confirm.bind(ctx.ui), }; } function handleAbandon(ctx: ExtensionContext): void { const goalPath = join(ctx.cwd, ".agents", "goal", "GOAL.md"); try { if (existsSync(goalPath)) { unlinkSync(goalPath); } } catch { // best effort } if (ctx.hasUI) { ctx.ui.notify("目标已放弃", "info"); } } /** * Check if the progress log shows stagnation (dual signal). * Stagnation = criteria passing count unchanged for 3 iterations * AND no progress keywords in the last 3 iterations. */ function detectStagnation(state: GoalState): boolean { // Signal 1: We can't compare passing counts across iterations // from the file alone without history. Instead, check if the // last 3 iteration headers exist but have no progress keywords. // For a more robust check, the skill should track passing count // history in GOAL.md. For now, we rely on the skill's own // stagnation detection and the extension's stagnation counter. return false; // Placeholder — skill-side detection is authoritative } export default function (pi: ExtensionAPI) { let consecutiveStagnations = 0; // ---- Trigger detection on user input ---- pi.on("input", async (event, ctx) => { if (event.source === "extension") { return { action: "continue" }; } // Check for explicit abandon first const lowerText = event.text.toLowerCase(); const isAbandon = ABANDON_KEYWORDS.some((kw) => lowerText.includes(kw.toLowerCase()) ); if (isAbandon) { const state = readGoalState(ctx.cwd); if (state) { handleAbandon(ctx); return { action: "continue" }; } } // Try to detect a goal-setting trigger const match = detectTrigger(event.text); if (!match) { return { action: "continue" }; } // Trigger detected — check stale state const existingState = readGoalState(ctx.cwd); if (existingState && existingState.status === "in-progress") { const ui = buildUIContext(ctx); const choice = await promptStaleState(ui); if (choice === "abandon") { handleAbandon(ctx); return { action: "continue" }; } if (choice === "replace") { // Let the skill handle replacement on the next turn return { action: "continue" }; } // "continue" or undefined → let the skill resume } // Granularity check if (isCoarseGrained(match.goalText)) { const ui = buildUIContext(ctx); notifyCoarseGrainedDelegation(ui, match.goalText); // Check if comet is available (has openspec directory) const hasOpenspec = existsSync(join(ctx.cwd, "openspec")); if (hasOpenspec) { pi.sendUserMessage(`/skill:comet-open ${match.goalText}`, { deliverAs: "followUp", }); } else { // Fallback: inject multi-stage mode instruction pi.sendMessage( { customType: "goal-driven-loop-coarse-fallback", content: `Coarse-grained goal detected but comet unavailable. Running in multi-stage mode. Split the goal into phases and verify each one:\n\n${match.goalText}`, display: true, }, { triggerTurn: true, deliverAs: "followUp" } ); } return { action: "continue" }; } // Fine-grained — let the skill handle it return { action: "continue" }; }); // ---- State injection on every agent start ---- pi.on("before_agent_start", async (event, ctx) => { const state = readGoalState(ctx.cwd); if (!state) { return; } // Context tension check const usage = ctx.getContextUsage(); if ( usage && usage.contextWindow > 0 && usage.tokens / usage.contextWindow > CONTEXT_TENSION_THRESHOLD ) { const continuationMsg = buildContinuationPrompt(state); return { systemPrompt: event.systemPrompt + "\n\n" + continuationMsg + "\n\nContext is tight. Finalize .agents/goal/GOAL.md updates before completing this turn.", }; } // Stagnation auto-recovery injection if (state.status === "blocked") { consecutiveStagnations++; if (shouldNotifyStagnation(consecutiveStagnations)) { const ui = buildUIContext(ctx); notifyStagnation( ui, state.goal, state.passedCriteria, state.totalCriteria, state.iteration ); } else { // Auto-inject strategy-change prompt return { systemPrompt: event.systemPrompt + "\n\n" + buildInjectionText(state) + "\n\n[goal-driven-loop STAGNATION RECOVERY] Previous approach not making progress. Try: a different tool/library, refactor before retry, or split the failing criterion into sub-steps.", }; } } // Normal goal state injection if (state.status !== "blocked") { consecutiveStagnations = 0; } const injection = buildInjectionText(state); return { systemPrompt: event.systemPrompt + "\n\n" + injection, }; }); }