/** * correction-memory.ts * * Reads and writes the per-workspace attempt history at * ~/.pisces/attempt-history.json. * * Provides: * - appendAttempt() — validated write with silent drop on policy violation * - getRecentGaps() — compact gap summary for injection into calibration context */ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { validateWrite, validateInject } from "./memory-policy"; // ─── Types ───────────────────────────────────────────────────────────────── export interface AttemptRecord { /** Short ID — timestamp-based base36 for easy sorting */ id: string; /** ISO 8601 timestamp */ timestamp: string; /** Skill that triggered the attempt (e.g. "attempt") */ skillName: string; /** Classified attempt type (e.g. "code", "essay", "generic") */ attemptType: string; /** What the learner was trying to achieve (truncated to 200 chars for storage) */ goal?: string; /** Specific gaps identified — populated after model grades (initially empty) */ gaps: string[]; /** Strengths identified — populated after model grades (initially empty) */ strengths: string[]; /** 0–100 composite score — set after model grades (optional at capture time) */ score?: number; } // ─── Storage ─────────────────────────────────────────────────────────────── const HISTORY_DIR = path.join(os.homedir(), ".pisces"); const HISTORY_FILE = path.join(HISTORY_DIR, "attempt-history.json"); // Keep bounded to avoid unbounded growth const MAX_RECORDS = 200; // Gap summary budget (proxy for ≈150 tokens at ~4 chars/token) const DEFAULT_MAX_CHARS = 600; function load(): AttemptRecord[] { try { if (!fs.existsSync(HISTORY_FILE)) return []; return JSON.parse(fs.readFileSync(HISTORY_FILE, "utf-8")) as AttemptRecord[]; } catch { return []; } } function save(records: AttemptRecord[]): void { try { fs.mkdirSync(HISTORY_DIR, { recursive: true }); fs.writeFileSync(HISTORY_FILE, JSON.stringify(records.slice(-MAX_RECORDS), null, 2)); } catch { // Non-fatal — history is valuable but never critical } } // ─── Public API ──────────────────────────────────────────────────────────── /** * Appends an attempt record to history after policy validation. * Silently drops the record if the policy check fails. */ export function appendAttempt(record: AttemptRecord): void { if (!validateWrite(record).allowed) return; const records = load(); records.push(record); save(records); } /** * Updates gaps, strengths, and score on an existing record by ID. * Called by the turn_end write-back path after the model grades the attempt. * No-ops silently if the record is not found. */ export function updateAttemptRecord( id: string, updates: { gaps?: string[]; strengths?: string[]; score?: number } ): void { const records = load(); const idx = records.findIndex((r) => r.id === id); if (idx === -1) return; records[idx] = { ...records[idx], ...updates }; save(records); } /** * Returns a compact, deduplicated summary of unresolved gaps from recent * attempts, truncated to `maxChars` to fit within a context window budget. * Returns an empty string if there are no gaps or the summary fails policy. */ export function getRecentGaps(maxChars = DEFAULT_MAX_CHARS): string { const records = load(); if (records.length === 0) return ""; // Collect all gaps newest-first, then deduplicate const gaps: string[] = []; for (let i = records.length - 1; i >= 0; i--) { for (const gap of records[i].gaps) { gaps.push(gap); } } if (gaps.length === 0) return ""; const unique = [...new Set(gaps)]; // Build summary within budget const lines: string[] = []; let used = 0; const header = "Recent gaps to address:\n"; used += header.length; for (const gap of unique) { const line = `- ${gap}`; if (used + line.length + 1 > maxChars) break; lines.push(line); used += line.length + 1; } if (lines.length === 0) return ""; const summary = header + lines.join("\n"); return validateInject(summary).allowed ? summary : ""; }