Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 | 1x 1x 1x 1x 1x 1x 1x 14x 1x 14x 14x 14x 1x 1x 14x 14x 1x 1x 14x 1x 11x 11x 8x 8x 7x 8x 3x 1x 4x 1x 3x 3x 3x 3x 3x 1x 20x 20x 18x 2x 2x 16x 16x 2x 2x 14x 14x 14x 14x 14x 1x 14x 14x 14x 14x 14x 14x 14x 14x 13x 13x 1x 20x 6x 4x 4x 4x 2x 4x | /**
* 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";
// ─── 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(/<!--\s*PISCES_EVAL\s+([\s\S]*?)\s*-->/);
if (!match) return null;
try {
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
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;
Iif (!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);
Iif (!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(/<!--\s*PISCES_EVAL[\s\S]*?-->/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);
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;
});
}
|