import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import { metaDir } from "./compat.js"; import type { SessionCheckpointRecord } from "./session-schema.js"; import { SessionCheckpointRecordSchema } from "./session-schema.js"; import { normalizeCheckpointScope, type CheckpointQuality } from "./session-engine.js"; const FALLBACK_SUMMARY = "Checkpoint captured current task state without repo-state details."; export function classifyCheckpointQuality(record: SessionCheckpointRecord): CheckpointQuality { const isFallback = record.summary === FALLBACK_SUMMARY; const hasContent = record.decisions.length > 0 || record.openLoops.length > 0 || record.nextActions.length > 0; return isFallback && !hasContent ? "minimal" : "rich"; } export interface SessionCheckpointQuery { sessionId?: string; scope?: string; limit?: number; } function ensureDir(dir: string): string { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return dir; } function sortNewestFirst(records: SessionCheckpointRecord[]): SessionCheckpointRecord[] { return [...records].sort((a, b) => { const timeDiff = Date.parse(b.updatedAt) - Date.parse(a.updatedAt); if (timeDiff !== 0) return timeDiff; return b.checkpointId.localeCompare(a.checkpointId); }); } export interface CheckpointGcOptions { dryRun?: boolean; now?: Date; /** Keep N newest rich checkpoints per resolvedScope (default: 5) */ keepPerScope?: number; /** Keep 1 checkpoint per sessionId if younger than this many hours (default: 48) */ sessionTtlHours?: number; /** Delete minimal checkpoints older than this many days (default: 7) */ minimalTtlDays?: number; /** Archive rich checkpoints older than this many days (default: 30) */ archiveAfterDays?: number; /** Hard-delete archived files older than this many days (default: 90) */ archiveTtlDays?: number; } export interface CheckpointGcFileAction { file: string; checkpointId: string; resolvedScope: string; quality: CheckpointQuality; updatedAt: string; action: "keep" | "archive" | "delete"; reason: string; } export interface CheckpointGcResult { kept: number; archived: number; deleted: number; errors: string[]; actions: CheckpointGcFileAction[]; } export class SessionCheckpointStore { constructor(private readonly dir = resolve(metaDir(import.meta), "../data/session-checkpoints")) {} get dataDir(): string { return ensureDir(this.dir); } async save(record: SessionCheckpointRecord): Promise { const parsed = SessionCheckpointRecordSchema.parse(record); const timestampToken = parsed.updatedAt.replace(/[:.]/g, "-"); const path = join(this.dataDir, `${timestampToken}-${parsed.checkpointId}.json`); try { const suffix = `-${parsed.checkpointId}.json`; for (const name of readdirSync(this.dataDir).filter((item) => item.endsWith(suffix))) { const existingPath = join(this.dataDir, name); if (existingPath !== path) unlinkSync(existingPath); } writeFileSync(path, JSON.stringify(parsed, null, 2) + "\n"); } catch (err) { console.error("[recallnest] Failed to save session checkpoint:", err instanceof Error ? err.message : String(err)); } return parsed; } async listRecent(query: SessionCheckpointQuery = {}): Promise { const { sessionId, scope, limit = 20 } = query; const files = readdirSync(this.dataDir) .filter((name) => name.endsWith(".json")) .map((name) => join(this.dataDir, name)); const normalizedQueryScope = scope ? normalizeCheckpointScope(scope) : undefined; const items: SessionCheckpointRecord[] = []; for (const path of files) { try { const parsed = SessionCheckpointRecordSchema.parse( JSON.parse(readFileSync(path, "utf-8")), ); if (sessionId && parsed.sessionId !== sessionId) continue; if (normalizedQueryScope) { const normalizedRecordScope = normalizeCheckpointScope(parsed.resolvedScope ?? parsed.scope ?? ""); if (normalizedRecordScope !== normalizedQueryScope) continue; } items.push(parsed); } catch { // Skip corrupt checkpoint files. } } return sortNewestFirst(items).slice(0, limit); } async getLatest(query: SessionCheckpointQuery = {}): Promise { // Look at recent checkpoints and prefer rich ones over minimal/fallback ones const recent = await this.listRecent({ ...query, limit: 5 }); if (recent.length === 0) return null; const rich = recent.find((r) => classifyCheckpointQuality(r) === "rich"); return rich || recent[0]; } private get archiveDir(): string { return ensureDir(resolve(this.dir, "../archive/session-checkpoints")); } gc(options: CheckpointGcOptions = {}): CheckpointGcResult { const { dryRun = false, now = new Date(), keepPerScope = 5, sessionTtlHours = 48, minimalTtlDays = 7, archiveAfterDays = 30, archiveTtlDays = 90, } = options; const nowMs = now.getTime(); const DAY_MS = 86_400_000; const HOUR_MS = 3_600_000; const errors: string[] = []; const actions: CheckpointGcFileAction[] = []; // --- Phase 1: Load and classify all checkpoint files --- const dir = this.dataDir; const files = readdirSync(dir).filter((name) => name.endsWith(".json")); interface ParsedEntry { path: string; record: SessionCheckpointRecord; quality: CheckpointQuality; ageMs: number; scope: string; } const entries: ParsedEntry[] = []; for (const name of files) { const path = join(dir, name); try { const record = SessionCheckpointRecordSchema.parse(JSON.parse(readFileSync(path, "utf-8"))); const quality = classifyCheckpointQuality(record); const scope = normalizeCheckpointScope(record.resolvedScope ?? record.scope ?? `session:${record.sessionId}`); const ageMs = nowMs - Date.parse(record.updatedAt); entries.push({ path, record, quality, ageMs, scope }); } catch (err) { errors.push(`parse error: ${name}: ${err instanceof Error ? err.message : String(err)}`); } } // --- Phase 2: Build protected set --- const protectedPaths = new Set(); const protectedReasons = new Map(); // Rule 1: Latest N rich per scope const byScope = new Map(); for (const entry of entries) { const list = byScope.get(entry.scope) ?? []; list.push(entry); byScope.set(entry.scope, list); } for (const [scope, list] of byScope) { const richSorted = list .filter((e) => e.quality === "rich") .sort((a, b) => a.ageMs - b.ageMs); // newest first (smallest age) for (const entry of richSorted.slice(0, keepPerScope)) { protectedPaths.add(entry.path); protectedReasons.set(entry.path, `latest ${keepPerScope} rich for scope ${scope}`); } } // Rule 2: Latest 1 per session within sessionTtlHours const bySession = new Map(); for (const entry of entries) { const list = bySession.get(entry.record.sessionId) ?? []; list.push(entry); bySession.set(entry.record.sessionId, list); } for (const [, list] of bySession) { const newest = list.sort((a, b) => a.ageMs - b.ageMs)[0]; if (newest && newest.ageMs < sessionTtlHours * HOUR_MS) { if (!protectedPaths.has(newest.path)) { protectedPaths.add(newest.path); protectedReasons.set(newest.path, `latest checkpoint for session within ${sessionTtlHours}h`); } } } // --- Phase 3: Decide action for each entry --- for (const entry of entries) { const ageDays = entry.ageMs / DAY_MS; let action: "keep" | "archive" | "delete"; let reason: string; if (protectedPaths.has(entry.path)) { action = "keep"; reason = protectedReasons.get(entry.path) ?? "protected"; } else if (entry.quality === "minimal" && ageDays > minimalTtlDays) { action = "delete"; reason = `minimal checkpoint older than ${minimalTtlDays} days (${Math.floor(ageDays)}d)`; } else if (entry.quality === "rich" && ageDays > archiveAfterDays) { action = "archive"; reason = `rich checkpoint older than ${archiveAfterDays} days (${Math.floor(ageDays)}d), not in latest ${keepPerScope} per scope`; } else { action = "keep"; reason = `${entry.quality} checkpoint within retention window (${Math.floor(ageDays)}d)`; } actions.push({ file: basename(entry.path), checkpointId: entry.record.checkpointId, resolvedScope: entry.scope, quality: entry.quality, updatedAt: entry.record.updatedAt, action, reason, }); } // --- Phase 4: Execute actions (unless dry-run) --- let kept = 0; let archived = 0; let deleted = 0; for (const item of actions) { if (item.action === "keep") { kept++; continue; } if (dryRun) { if (item.action === "archive") archived++; else deleted++; continue; } const srcPath = join(dir, item.file); try { if (item.action === "archive") { renameSync(srcPath, join(this.archiveDir, item.file)); archived++; } else { unlinkSync(srcPath); deleted++; } } catch (err) { errors.push(`${item.action} failed: ${item.file}: ${err instanceof Error ? err.message : String(err)}`); kept++; // count as kept since we couldn't move/delete it } } // --- Phase 5: Sweep archived directory for expired files --- const archDir = this.archiveDir; const archivedFiles = readdirSync(archDir).filter((name) => name.endsWith(".json")); for (const name of archivedFiles) { const path = join(archDir, name); try { const mtime = statSync(path).mtimeMs; const ageDays = (nowMs - mtime) / DAY_MS; if (ageDays > archiveTtlDays) { if (!dryRun) unlinkSync(path); deleted++; actions.push({ file: `archive/${name}`, checkpointId: name.replace(/.*-([0-9a-f-]{36})\.json$/, "$1"), resolvedScope: "archived", quality: "rich", updatedAt: new Date(mtime).toISOString(), action: "delete", reason: `archived file older than ${archiveTtlDays} days (${Math.floor(ageDays)}d)`, }); } } catch (err) { errors.push(`archive sweep: ${name}: ${err instanceof Error ? err.message : String(err)}`); } } return { kept, archived, deleted, errors, actions }; } }