/** * Interactive `/coach`: review-phase digest in, strategy artifact out (COACH-09, 12–13, 15–18, 20–22). * * Stronger review class (COACH-12): Pi thinking `high`, plus the Magpie `coach` model * pin when set (`/models`). Restored after accept or abort. Not an `@ultra` prefix. * * Occasions (COACH-20): scout | steer | close — one handler; scheduler sets the frame. */ import { coreRoot, goalPaths } from "../core/paths.ts"; import type { Evidence } from "../runtime/evidence.ts"; import { compileDigest, type CompiledDigest } from "../runtime/coach/digest.ts"; import { closeGateDecision, countFactEstablished, OCCASION_QUESTIONS, offTrackFromDigest, steerGateDecision, type CoachOccasion, } from "../runtime/coach/occasion.ts"; import { harvestFollowed, hasScoutYield, siteActionsWithoutCandidateYield, } from "../runtime/coach/rescue.ts"; import { assertStrategyArtifact, renderStrategyArtifact, StrategyArtifactError, STRATEGY_JSON_EXAMPLE, STRATEGY_REQUIRED_HINT, type StrategyArtifact, } from "../runtime/coach/strategy.ts"; import { readMetrics } from "../optimize/recorder.ts"; import type { CustomSessionMessage, ExtensionAPI, ExtensionContext } from "../pi-api.ts"; import { capabilityCoordinator } from "./pi-capabilities.ts"; import { PLAN_MODE_DISABLED_TOOLS } from "./pi-plan-mode.ts"; import { assistantText, isAssistantMessage } from "./pi-plan-todos.ts"; import { firstOperatorGoal, isOperatorGoalText, MAGPIE_CHAT_OBJECTIVE } from "./pi-operator-goal.ts"; import { modelKey, type MagpieModelHost } from "./pi-models.ts"; export const COACH_COMMAND = "coach"; export const COACH_CHECKPOINT_ENTRY = "coach-checkpoint"; export const COACH_CONTEXT_TYPE = "coach-review-context"; export const COACH_REVIEW_THINKING = "high" as const; export const COACH_DISABLED_TOOLS = PLAN_MODE_DISABLED_TOOLS; const COACH_RETRY_MAX = 2; function coachInstructions(occasion: CoachOccasion): string { const question = OCCASION_QUESTIONS[occasion]; const decisionHint = occasion === "scout" ? "Prefer decision=continue with a trial loop." : occasion === "steer" ? "Prefer decision=continue or decision=patch for the remainder of harvest. Do not close the job." : "Prefer decision=accept (omit loop), decision=extend (cheapest increment), or decision=halt."; return ( `[COACH REVIEW]\n` + `occasion=${occasion}\n` + `Question: ${question}\n` + `${decisionHint}\n` + "Phase: review. Mutations are off. Output one JSON object only, exactly this shape " + "(unknown keys are dropped and the artifact is rejected as empty):\n" + STRATEGY_JSON_EXAMPLE + "\n" + STRATEGY_REQUIRED_HINT + "\nDo not rewrite qualification criteria, grants, send, or follow policy. Do not skip approval. " + "Coach is a guideline generator, not a second planner. The loop is a trial, not a lock: " + "doNot may name wasted routes already in this digest; do not forbid untested pools. " + "If yield counts are zero, say so and demand recording — do not lock a pool. " + "This turn uses a stronger thinking class; it is restored after the artifact.\n" + "Efficiency is waste-reduction (peek instead of losing the list), not inventing a new campaign. " + "Put unknown in remember reasons when a required field is missing so later gates can see it.\n\n" + "Trajectory digest (not the session transcript):\n" ); } export interface CoachCheckpointData { at: string; artifact: StrategyArtifact; rendered: string; digestBytes: number; artifactBytes: number; phase: "review"; occasion?: CoachOccasion; replacedPrevious?: boolean; } export interface PlanSlice { objective?: string; criteria?: readonly string[]; } export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export interface CoachThinking { get(): string | undefined; set(level: ThinkingLevel): void; } export type ReportStatus = "success" | "blocked" | "failed"; export interface CloseReport { status: ReportStatus; summary: string; } export type CloseConsiderResult = | { action: "pass" } | { action: "started" } | { action: "halted" } | { action: "none" }; export interface CoachHandle { enabled(): boolean; injection(): string | undefined; hasArtifact(): boolean; startReview(ctx: ExtensionContext, occasion?: CoachOccasion): Promise; onArtifact(handler: (ctx: ExtensionContext) => void): void; setPlanSlice(slice: PlanSlice): void; setScoutEpoch(iso: string | undefined): void; hasScoutYield(): Promise; considerRescue(ctx: ExtensionContext): Promise<"none" | "started" | "halted">; considerSteer(ctx: ExtensionContext): Promise<"none" | "started" | "halted">; considerClose(ctx: ExtensionContext, report: CloseReport): Promise; considerParentScout(ctx: ExtensionContext): Promise<"none" | "started">; lastDecision(): StrategyArtifact["decision"] | undefined; } export interface CoachBindOptions { evidence: Evidence; objective: string | (() => string); criteria?: readonly string[] | (() => readonly string[]); thinking?: CoachThinking; models?: MagpieModelHost; parentCalibration?: boolean | (() => boolean); } function restoreCheckpoint( entries: ReadonlyArray<{ customType?: string; data?: unknown }>, ): CoachCheckpointData | undefined { const found = [...entries].reverse().find((entry) => entry.customType === COACH_CHECKPOINT_ENTRY); const data = found?.data; if (!data || typeof data !== "object") return undefined; const record = data as Partial; if (!record.artifact || !record.rendered || !record.at) return undefined; return record as CoachCheckpointData; } function resolveBoundText(value: string | (() => string) | undefined): string { const raw = typeof value === "function" ? value() : value; return raw?.trim() ?? ""; } function resolveBoundList( value: readonly string[] | (() => readonly string[]) | undefined, ): string[] { const raw = typeof value === "function" ? value() : value; return [...(raw ?? [])]; } function thinkingControl(pi: ExtensionAPI, options: CoachBindOptions): CoachThinking | undefined { if (options.thinking) return options.thinking; if (typeof pi.setThinkingLevel !== "function") return undefined; return { get: () => pi.thinkingLevel ?? pi.getThinkingLevel?.(), set: (level) => pi.setThinkingLevel?.(level), }; } async function compileSessionDigest( options: CoachBindOptions, checkpoint: CoachCheckpointData | undefined, ctx: ExtensionContext | undefined, planSlice: PlanSlice, occasion: CoachOccasion, report?: CloseReport, ): Promise { const events = (await options.evidence.ledger.read?.()) ?? []; await options.evidence.metrics.flush(); let metrics: Awaited> = []; const goalId = options.evidence.goal?.goalId; const root = options.evidence.goal?.root ?? coreRoot(); if (goalId) { metrics = await readMetrics(goalPaths(root, goalId).metricsFile); } const since = checkpoint ? events.filter((event) => { const ts = Date.parse(event.ts); const at = Date.parse(checkpoint.at); return !Number.isFinite(ts) || !Number.isFinite(at) || ts >= at; }) : events; const boundObjective = resolveBoundText(options.objective); const goalText = (planSlice.objective && isOperatorGoalText(planSlice.objective) ? planSlice.objective : undefined) || firstOperatorGoal(ctx?.sessionManager?.getEntries?.() ?? []) || (isOperatorGoalText(boundObjective) ? boundObjective : undefined) || boundObjective || MAGPIE_CHAT_OBJECTIVE; const criteria = planSlice.criteria?.length ? [...planSlice.criteria] : resolveBoundList(options.criteria); return compileDigest( { events, metrics, goalText, criteria, checkpoint: checkpoint ? { at: checkpoint.at } : undefined, previousArtifact: checkpoint ? { summary: checkpoint.artifact.summary, followed: harvestFollowed(since) } : undefined, reportSummary: report?.summary, reportStatus: report?.status, occasion, }, occasion, ); } export function bindCoach(pi: ExtensionAPI, options: CoachBindOptions): CoachHandle { const capabilities = capabilityCoordinator(pi); const thinking = thinkingControl(pi, options); let reviewing = false; let digestJson = ""; let activeOccasion: CoachOccasion = "scout"; let latest: CoachCheckpointData | undefined; let artifactListener: ((ctx: ExtensionContext) => void) | undefined; let lastAttemptText = ""; let rejectCount = 0; let planSlice: PlanSlice = {}; let scoutEpoch: string | undefined; let savedThinking: string | undefined; let coachHeld = false; let rescueInFlight = false; let emptyRescues = 0; let halted = false; let pendingClose: { resolve: (result: CloseConsiderResult) => void } | undefined; function persist(data: CoachCheckpointData): void { latest = data; pi.appendEntry?.(COACH_CHECKPOINT_ENTRY, data); } function requestReviewClass(): boolean { if (!thinking) return false; savedThinking = thinking.get() ?? "medium"; thinking.set(COACH_REVIEW_THINKING); return true; } function restoreReviewClass(): void { if (!thinking || savedThinking === undefined) return; const previous = savedThinking as ThinkingLevel; savedThinking = undefined; thinking.set(previous); } async function requestReviewModel(ctx: ExtensionContext): Promise { if (!options.models) return undefined; const error = await options.models.enter("coach", ctx); if (error) return error; coachHeld = true; return undefined; } async function restoreReviewModel(ctx: ExtensionContext): Promise { if (!coachHeld) return; coachHeld = false; await options.models?.leave(ctx); } function enableReview(ctx: ExtensionContext): void { reviewing = true; lastAttemptText = ""; rejectCount = 0; capabilities.constrain("coach", { disable: COACH_DISABLED_TOOLS }); ctx.ui.setStatus?.("coach", "coach review"); } async function disableReview( ctx: ExtensionContext, notify = false, reason: "accept" | "abort" | "session" = "session", ): Promise { const wasRescue = rescueInFlight; reviewing = false; digestJson = ""; lastAttemptText = ""; rejectCount = 0; rescueInFlight = false; capabilities.release("coach"); restoreReviewClass(); await restoreReviewModel(ctx); if (reason === "abort" && wasRescue) emptyRescues += 1; if (reason === "accept") { emptyRescues = 0; halted = false; } ctx.ui.setStatus?.("coach", latest ? "coached" : undefined); if (notify) ctx.ui.notify("Coach review ended. Browser tools restored."); } function injection(): string | undefined { if (reviewing && digestJson) { return `${coachInstructions(activeOccasion)}${digestJson}`; } if (!latest) return undefined; if (latest.artifact.decision === "accept") { return `${latest.rendered}\nDecision accept: deliverable stands. Do not start a new harvest loop.\n`; } const trial = "\nThis STRATEGY is a trial loop. Try it until it is falsified. Record candidate_accepted, " + "candidate_rejected, or candidate_duplicate after every candidate. Put unknown in the reason " + "when a required field is missing. Clicks that return ok are not progress. If the named route " + "is falsified, stop — do not invent a second plan. Magpie will run /coach again. Do not treat " + "Do-not as covering lists the scout never tried.\n"; const swap = latest.replacedPrevious ? "\nA new guideline replaces the previous one. Finish the current entity before switching loops.\n" : trial; return `${latest.rendered}${swap}`; } async function handleCoach( _args: string, ctx: ExtensionContext, occasion: CoachOccasion = "scout", report?: CloseReport, ): Promise { if (reviewing) { ctx.ui.notify("Coach review is already running."); return false; } if (!requestReviewClass()) { ctx.ui.notify( "Coach needs Pi thinking-level control for a stronger review class (COACH-12).", "error", ); return false; } const modelError = await requestReviewModel(ctx); if (modelError) { restoreReviewClass(); ctx.ui.notify(modelError, "error"); return false; } const previous = latest; let compiled: CompiledDigest; try { compiled = await compileSessionDigest(options, previous, ctx, planSlice, occasion, report); } catch (error) { restoreReviewClass(); await restoreReviewModel(ctx); ctx.ui.notify(`Could not compile digest: ${error instanceof Error ? error.message : String(error)}`, "error"); return false; } activeOccasion = occasion; digestJson = compiled.json; enableReview(ctx); const modelId = (await options.models?.resolved("coach")) ?? undefined; const using = modelId && modelKey(ctx.model) === modelId ? modelId : undefined; ctx.ui.notify( using ? `Coach review (${occasion}): mutations off. Digest only. Model ${using} + thinking high.` : `Coach review (${occasion}): mutations off. Digest only. Thinking high for this turn.`, ); const content = `${coachInstructions(occasion)}${compiled.json}`; if (pi.sendUserMessage) { pi.sendUserMessage(content, { deliverAs: "followUp" }); } else { ctx.ui.notify("Coach review on. Send the digest as the next message."); } return true; } async function acceptArtifact(text: string, ctx: ExtensionContext): Promise { if (!reviewing) return false; if (text === lastAttemptText) return false; lastAttemptText = text; try { const artifact = assertStrategyArtifact(text); if (!artifact.occasion) artifact.occasion = activeOccasion; if (!artifact.decision) { artifact.decision = activeOccasion === "close" ? "extend" : activeOccasion === "steer" ? "patch" : "continue"; } const rendered = renderStrategyArtifact(artifact); persist({ at: new Date().toISOString(), artifact, rendered, digestBytes: digestJson.length, artifactBytes: JSON.stringify(artifact).length, phase: "review", occasion: activeOccasion, replacedPrevious: Boolean(latest), }); await options.evidence.facts.mergeGoalFacts({ strategyArtifact: artifact }); await disableReview(ctx, false, "accept"); pi.sendMessage?.( { customType: "coach-artifact", content: rendered, display: true, }, { triggerTurn: false }, ); ctx.ui.notify("Strategy artifact saved. Harvest sees the guideline, not the digest."); if (pendingClose) { const decision = artifact.decision; if (decision === "accept") pendingClose.resolve({ action: "pass" }); else if (decision === "halt") { halted = true; pendingClose.resolve({ action: "halted" }); } else pendingClose.resolve({ action: "started" }); pendingClose = undefined; } artifactListener?.(ctx); return true; } catch (error) { if (error instanceof StrategyArtifactError) { ctx.ui.notify(`Coach output rejected (${error.code}): ${error.message}`, "warning"); rejectCount += 1; if (rejectCount <= COACH_RETRY_MAX && pi.sendUserMessage) { pi.sendUserMessage( `${coachInstructions(activeOccasion)}Previous output was rejected (${error.code}): ${error.message}\n` + `Output one JSON object only:\n${STRATEGY_JSON_EXAMPLE}`, { deliverAs: "followUp" }, ); } else if (rejectCount > COACH_RETRY_MAX) { await disableReview(ctx, true, "abort"); if (pendingClose) { pendingClose.resolve({ action: "halted" }); pendingClose = undefined; } } return false; } throw error; } } async function considerSteer(ctx: ExtensionContext): Promise<"none" | "started" | "halted"> { if (halted) return "none"; if (reviewing || !latest) return "none"; let compiled: CompiledDigest; try { compiled = await compileSessionDigest(options, latest, ctx, planSlice, "steer"); } catch { return "none"; } const events = (await options.evidence.ledger.read?.()) ?? []; const since = events.filter((event) => { const ts = Date.parse(event.ts); const at = Date.parse(latest!.at); return !Number.isFinite(ts) || !Number.isFinite(at) || ts >= at; }); const offTrack = offTrackFromDigest(compiled.digest, countFactEstablished(since)); const decision = steerGateDecision({ siteActionsWithoutCandidateYield: siteActionsWithoutCandidateYield(since), navigationCycles: compiled.digest.navigationCycles.length, lostPlace: compiled.digest.lostPlace, wallMs: compiled.digest.wallMs, emptyRescues, offTrack, }); if (decision === "halt") { halted = true; ctx.ui.notify( "Harvest produced no yield after a steer coach. Stopping for the operator.", "warning", ); return "halted"; } if (decision === "review") { rescueInFlight = true; const started = await handleCoach("", ctx, "steer"); if (!started) rescueInFlight = false; return started ? "started" : "none"; } return "none"; } async function considerClose(ctx: ExtensionContext, report: CloseReport): Promise { if (halted) return { action: "halted" }; if (reviewing) return { action: "none" }; let compiled: CompiledDigest; try { compiled = await compileSessionDigest(options, latest, ctx, planSlice, "close", report); } catch { return { action: "pass" }; } const events = (await options.evidence.ledger.read?.()) ?? []; const offTrack = offTrackFromDigest(compiled.digest, countFactEstablished(events)); const gate = closeGateDecision({ reportStatus: report.status, accepted: compiled.digest.counts.accepted, criteriaCount: compiled.digest.criteria.length, offTrack, }); if (gate === "pass") return { action: "pass" }; return await new Promise((resolve) => { pendingClose = { resolve }; void (async () => { rescueInFlight = true; const started = await handleCoach("", ctx, "close", report); if (!started) { rescueInFlight = false; pendingClose = undefined; resolve({ action: "pass" }); } })(); }); } async function considerParentScout(ctx: ExtensionContext): Promise<"none" | "started"> { if (reviewing || latest) return "none"; const enabled = typeof options.parentCalibration === "function" ? options.parentCalibration() : Boolean(options.parentCalibration); if (!enabled) return "none"; const events = (await options.evidence.ledger.read?.()) ?? []; if (!hasScoutYield(events, scoutEpoch)) return "none"; const started = await handleCoach("", ctx, "scout"); return started ? "started" : "none"; } pi.registerCommand(COACH_COMMAND, { description: "Review-phase coach: digest in, strategy guideline out (mutations off)", handler: async (args, ctx) => { const occasion = args.trim() === "steer" || args.trim() === "close" ? (args.trim() as CoachOccasion) : "scout"; await handleCoach(args, ctx, occasion); }, }); pi.on("context", (_event: unknown) => { if (!reviewing || !digestJson) return undefined; return { messages: [{ role: "user", content: `${coachInstructions(activeOccasion)}${digestJson}` }], }; }); pi.on("before_agent_start", () => { const content = injection(); if (!content) return undefined; return { message: { customType: reviewing ? COACH_CONTEXT_TYPE : "coach-harvest-context", content, display: false, } satisfies CustomSessionMessage, }; }); pi.on("turn_end", async (event: unknown, ctxUnknown: unknown) => { const ctx = ctxUnknown as ExtensionContext; if (reviewing) { const message = (event as { message?: unknown })?.message; if (!isAssistantMessage(message)) return; await acceptArtifact(assistantText(message), ctx); return; } await considerParentScout(ctx); if (latest && !reviewing) await considerSteer(ctx); }); pi.on("agent_end", async (event: unknown, ctxUnknown: unknown) => { const ctx = ctxUnknown as ExtensionContext; if (reviewing) { const messages = Array.isArray((event as { messages?: unknown[] })?.messages) ? (event as { messages: unknown[] }).messages : []; const lastAssistant = [...messages].reverse().find(isAssistantMessage); if (lastAssistant) await acceptArtifact(assistantText(lastAssistant), ctx); return; } await considerParentScout(ctx); if (latest && !reviewing) await considerSteer(ctx); }); pi.on("session_start", (_event: unknown, ctxUnknown: unknown) => { const ctx = ctxUnknown as ExtensionContext; const restored = restoreCheckpoint(ctx?.sessionManager?.getEntries?.() ?? []); if (restored) latest = restored; reviewing = false; digestJson = ""; coachHeld = false; capabilities.release("coach"); restoreReviewClass(); ctx.ui.setStatus?.("coach", latest ? "coached" : undefined); }); return { enabled: () => reviewing, injection, hasArtifact: () => Boolean(latest), startReview: (ctx, occasion = "scout") => handleCoach("", ctx, occasion), onArtifact(handler) { artifactListener = handler; }, setPlanSlice(slice) { planSlice = { ...planSlice, ...slice }; }, setScoutEpoch(iso) { scoutEpoch = iso; }, async hasScoutYield() { const events = (await options.evidence.ledger.read?.()) ?? []; return hasScoutYield(events, scoutEpoch); }, considerRescue: considerSteer, considerSteer, considerClose, considerParentScout, lastDecision: () => latest?.artifact.decision, }; }