/**
* Issue #30 — checkpoint health diagnostics for existing session files.
*
* Read-only inspection of a session JSONL's persisted continuation
* checkpoints. Pre-fix sessions accumulated one FULL continuation prompt per
* auto-continue turn (~6.4K chars each); v2 checkpoints are bounded markers.
* The report quantifies what an offline repair with pi-goal-x-recover would
* save. This module never writes anything.
*/
import * as fs from "node:fs";
import { GOAL_EVENT_ENTRY } from "./goal-format.ts";
import { asRecord } from "./goal-record.ts";
/** Exact shape written by checkpointTriggerPrompt() (v2 markers only). */
const V2_MARKER_PATTERN =
/^$/;
/** Approximate persisted size of one repaired marker, used for projections. */
export const REPAIRED_MARKER_ESTIMATE_CHARS = "".length;
export interface CheckpointHealth {
total: number;
legacyFull: number;
v2Minimal: number;
totalContentChars: number;
recoverableChars: number;
largestCheckpointChars: number;
}
function emptyHealth(): CheckpointHealth {
return {
total: 0,
legacyFull: 0,
v2Minimal: 0,
totalContentChars: 0,
recoverableChars: 0,
largestCheckpointChars: 0,
};
}
function isCheckpointEntry(entry: unknown): { content: string; version: number | undefined } | null {
const raw = asRecord(entry);
if (!raw || raw.type !== "custom_message") return null;
if (raw.customType !== GOAL_EVENT_ENTRY) return null;
if (typeof raw.content !== "string") return null;
const details = asRecord(raw.details);
return { content: raw.content, version: typeof details?.version === "number" ? details.version : undefined };
}
/**
* Classify every pi-goal-event custom message in parsed session entries.
* v2 entries match the exact bounded marker shape AND carry details.version 2;
* everything else is legacy full-prompt payload that offline recovery can
* shrink.
*/
export function inspectCheckpointHealth(entries: readonly unknown[]): CheckpointHealth {
const health = emptyHealth();
for (const entry of entries) accumulateCheckpointHealth(health, entry);
return health;
}
function accumulateCheckpointHealth(health: CheckpointHealth, entry: unknown): void {
const checkpoint = isCheckpointEntry(entry);
if (!checkpoint) return;
health.total += 1;
health.totalContentChars += checkpoint.content.length;
if (checkpoint.content.length > health.largestCheckpointChars) {
health.largestCheckpointChars = checkpoint.content.length;
}
const isV2 = (checkpoint.version === 2 || checkpoint.version === 3) && V2_MARKER_PATTERN.test(checkpoint.content);
if (isV2) {
health.v2Minimal += 1;
} else {
health.legacyFull += 1;
health.recoverableChars += checkpoint.content.length;
}
}
/**
* Convenience wrapper: read a session JSONL file from disk (JSON per line,
* header included) and inspect its checkpoints. Returns null when the file
* does not exist or cannot be read/parsed at the line level — malformed lines
* are skipped, not fatal, mirroring the recovery CLI's tolerance.
*/
export function readSessionCheckpointHealth(sessionFile: string): CheckpointHealth | null {
let raw: string;
try {
raw = fs.readFileSync(sessionFile, "utf8");
} catch {
return null;
}
const health = emptyHealth();
// Parse and discard one entry at a time; do not retain the whole session object graph.
for (const rawLine of raw.split("\n")) {
const line = rawLine.trim();
if (line) try { accumulateCheckpointHealth(health, JSON.parse(line)); } catch { /* tolerate malformed lines */ }
}
return health;
}
/** Human-readable projection of post-recovery checkpoint content size. */
export function projectedContentCharsAfterRecovery(health: CheckpointHealth): number {
return health.v2Minimal * REPAIRED_MARKER_ESTIMATE_CHARS;
}
function formatChars(chars: number): string {
if (chars >= 1_000_000) return `${(chars / 1_000_000).toFixed(1)} MB`;
if (chars >= 1_000) return `${(chars / 1_000).toFixed(1)} KB`;
return `${chars} chars`;
}
/**
* Render the read-only report section. The command layer appends this to
* /goal-recovery output and /goal-status health when checkpoint data exists.
*/
export function formatCheckpointHealthReport(health: CheckpointHealth, sessionFile?: string): string {
const lines: string[] = [];
lines.push("Session checkpoints:");
lines.push(` total: ${health.total}`);
lines.push(` legacy full checkpoints: ${health.legacyFull}`);
lines.push(` v2 minimal markers: ${health.v2Minimal}`);
lines.push(` checkpoint content: ${formatChars(health.totalContentChars)}`);
lines.push(` projected content after recovery: ${formatChars(projectedContentCharsAfterRecovery(health))}`);
if (health.legacyFull > 0 && sessionFile) {
lines.push(" close Pi, then run:");
lines.push(` pi-goal-x-recover --session --dry-run`);
}
return lines.join("\n");
}