diff --git a/.pi/extensions/goal-driven-loop/index.ts b/.pi/extensions/goal-driven-loop/index.ts new file mode 100644 index 0000000..f91eadb --- /dev/null +++ b/.pi/extensions/goal-driven-loop/index.ts @@ -0,0 +1,137 @@ +/** + * 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"); + } +}