/** * attempt-capture.ts * * Extension: Attempt Capture * Triggers: registerCommand("attempt"), on("turn_end") * * Registers the /attempt Pi command. When a learner submits work: * 1. Loads the active rubric via calibration-engine. * 2. Fetches recent gap context from correction-memory. * 3. Injects rubric + gaps + submitted content into the conversation. * 4. Records the attempt in correction-memory for future context. * * After the model responds (turn_end), parses the PISCES_EVAL block and * writes gaps/strengths/score back to the attempt record so the next attempt * receives an accurate gap summary. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { PiAdapter } from "./pi-adapter"; import { getWorkspaceState } from "./workspace-detector"; import { loadRubric, classifyAttemptType, buildAttemptInjection, } from "./calibration-engine"; import { appendAttempt, updateAttemptRecord, getRecentGaps, type AttemptRecord, } from "./correction-memory"; import { setGradedMode, isGradedModeActive } from "./graded-session"; import { detectPromptInjection } from "./extensions/integrity-guard"; import { getActiveAgeGroup } from "./extensions/workspace-gate"; // ─── Module-level pending state ──────────────────────────────────────────── // Tracks the ID of the most recently dispatched attempt so turn_end can // write the model's gap/strength/score back to the record. let _pendingAttemptId: string | null = null; // ─── Helpers ─────────────────────────────────────────────────────────────── function makeAttemptId(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } const USAGE = [ "Usage: /attempt [your submitted work]", "", "Paste your code, essay, or other work as the command argument.", "Pisces will evaluate it against the active rubric and track gaps over time.", "", "Flags (optional):", " --type code|essay|generic Override the inferred attempt type", " --goal \"description\" Describe what you were trying to achieve", ].join("\n"); function parseArgs(raw: string): { content: string; type: string | undefined; goal: string | undefined; } { let remaining = raw.trim(); let type: string | undefined; let goal: string | undefined; const typeMatch = remaining.match(/--type\s+(\S+)/); if (typeMatch) { type = typeMatch[1]; remaining = remaining.replace(typeMatch[0], "").trim(); } const goalMatch = remaining.match(/--goal\s+"([^"]+)"/); if (goalMatch) { goal = goalMatch[1]; remaining = remaining.replace(goalMatch[0], "").trim(); } return { content: remaining, type, goal }; } interface EvalBlock { gaps: string[]; strengths: string[]; score?: number; } export function parseEvalBlock(text: string): EvalBlock | null { const match = text.match(//); if (!match) return null; try { const parsed = JSON.parse(match[1]) as Record; return { gaps: Array.isArray(parsed.gaps) ? (parsed.gaps as unknown[]).filter((s): s is string => typeof s === "string") : [], strengths: Array.isArray(parsed.strengths) ? (parsed.strengths as unknown[]).filter((s): s is string => typeof s === "string") : [], score: typeof parsed.score === "number" ? parsed.score : undefined, }; } catch { return null; } } function extractAssistantText(message: unknown): string { if ( typeof message !== "object" || message === null || !("role" in message) || (message as { role: unknown }).role !== "assistant" ) { return ""; } const content = (message as unknown as { content: unknown }).content; if (!Array.isArray(content)) return ""; return content .filter( (c): c is { type: "text"; text: string } => typeof c === "object" && c !== null && (c as { type: string }).type === "text" ) .map((c) => c.text) .join(""); } // ─── Pi Extension Factory ────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { const adapter = new PiAdapter(pi); adapter.registerCommand("attempt", { description: "Submit work for evaluation against the active rubric", handler: async (args, ctx) => { if (!getWorkspaceState().isActive) { ctx.ui.notify( "🐠 Pisces: activate a workspace first with /pisces --activate.", "warning" ); return; } const raw = (args as string | undefined)?.trim() ?? ""; if (!raw) { ctx.ui.notify(USAGE, "info"); return; } const { content, type, goal } = parseArgs(raw); if (!content) { ctx.ui.notify(USAGE, "info"); return; } try { // Scan for injection patterns before sanitising — we warn on the raw // content so the user sees which submission triggered the flag. const injectionResult = detectPromptInjection(content); if (injectionResult.detected && injectionResult.warning) { ctx.ui.notify(injectionResult.warning, "warning"); } // Strip PISCES_EVAL blocks from the submission regardless of whether // detectPromptInjection fired. Warning alone is not enough — the model // would still see the block in the submitted content and might echo it // back in its response, causing a fake score to be written to history. const safeContent = content.replace(//gi, "").trim(); const rubricResult = loadRubric(); const gapSummary = getRecentGaps(); const attemptType = classifyAttemptType(undefined, type); const record: AttemptRecord = { id: makeAttemptId(), timestamp: new Date().toISOString(), skillName: "attempt", attemptType, goal: goal ?? safeContent.slice(0, 200), gaps: [], strengths: [], }; appendAttempt(record); _pendingAttemptId = record.id; const injection = buildAttemptInjection(rubricResult, gapSummary, safeContent, getActiveAgeGroup()); adapter.sendUserMessage(injection); // Activate graded mode so integrity-guard reinforces evaluation constraints // on every subsequent agent turn in this session. setGradedMode(true); } catch { ctx.ui.notify( "🐠 Pisces: evaluation could not be started — please try again. If this persists, run /pisces --doctor.", "error" ); } }, }); // Write gaps/strengths/score back to the attempt record once the model responds. adapter.onTurnEnd(async (event) => { if (!isGradedModeActive() || _pendingAttemptId === null) return; const text = extractAssistantText(event.message); const evalBlock = parseEvalBlock(text); if (evalBlock) { updateAttemptRecord(_pendingAttemptId, evalBlock); } _pendingAttemptId = null; }); }