/** * goal-driven-loop extension for Pi. * * Wires together trigger detection, state injection, and timed * auto-defaults to eliminate unnecessary user interaction. */ import type { ExtensionAPI } 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, } from "./state-injector.js"; import { promptStaleState, notifyCoarseGrainedDelegation, notifyStagnation, shouldNotifyStagnation, type UIContext, } from "./ui-prompts.js"; const CONTEXT_TENSION_THRESHOLD = 0.8; const ABANDON_KEYWORDS = ["abandon", "cancel", "不要了", "放弃", "停"]; export default function (pi: ExtensionAPI) { // ---- 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); pi.sendUserMessage(`/skill:comet-open ${match.goalText}`, { 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.", }; } // Inject goal state const injection = buildInjectionText(state); return { systemPrompt: event.systemPrompt + "\n\n" + injection, }; }); } function buildUIContext(ctx: any): 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: any): 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"); } }