Project Files
src / storage.ts
import { mkdirSync, existsSync, readFileSync, appendFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { ChatMessage } from "./llm";
import type { QuoteAudit } from "./verifier";
export type SessionMeta = {
type: "meta";
id: string;
notion: string;
sujet: string;
systemPrompt: string;
createdAt: number;
// Ordre des 5 cartes pédagogiques pour cette session. Décidé une fois
// à la création de session (cf. session.ts pickCardOrder). Permet au
// serveur de dire au modĂšle quelle carte jouer Ă chaque tour sans
// jamais lui laisser le choix â plus d'ambiguĂŻtĂ© sur le compteur.
// Optionnel pour compat avec les sessions créées avant cette feature
// (resume les synthétise alors à partir de l'historique).
cardOrder?: string[];
};
export type TurnRecord = {
type: "turn";
role: "user" | "assistant";
content: string;
t: number;
attempts?: Array<{ attempt: number; realFaulty: number; stateOk?: boolean; skeletonOk?: boolean }>;
matched?: number;
totalQuotes?: number;
faulty?: number;
audits?: QuoteAudit[];
// Diagnostic d'état (B9b) : si à la fin de la boucle retry le tour
// viole encore la progression des cartes, on consigne pour l'admin.
// undefined si tout est bon.
stateViolation?: { kind: string; summary: string };
// Si le label « OpĂ©ration N/5 â carte » a Ă©tĂ© patchĂ© cĂŽtĂ© serveur
// aprĂšs Ă©chec du retry (modĂšle tĂȘtu), trace du remplacement effectuĂ©.
labelPatch?: { from?: string; to?: string };
// Diagnostic pédagogique du squelette (B19) : citations mal placées
// sémantiquement, auteurs répétés. Persisté pour audit /admin.
skeletonViolations?: Array<{ kind: string; summary: string }>;
};
export type SessionRecord = SessionMeta | TurnRecord;
const DEFAULT_DIR = join(homedir(), ".livre-heros-bac", "sessions");
let baseDir = DEFAULT_DIR;
export function setStorageDir(dir: string): void {
baseDir = dir;
mkdirSync(baseDir, { recursive: true });
}
export function getStorageDir(): string {
mkdirSync(baseDir, { recursive: true });
return baseDir;
}
function fileFor(id: string): string {
// Sanity check : empĂȘche les ../ etc., et borne la longueur (Q3) â un
// client pouvait envoyer un id de 10K chars valides au charset, ce qui
// créait des paths absurdes sur disque. randomUUID-truncated fait 16
// chars, on autorise jusqu'Ă 64 pour la marge.
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(id)) throw new Error("invalid session id");
return join(getStorageDir(), `${id}.jsonl`);
}
export function sessionExists(id: string): boolean {
try { return existsSync(fileFor(id)); } catch { return false; }
}
export function writeMeta(meta: SessionMeta): void {
appendFileSync(fileFor(meta.id), JSON.stringify(meta) + "\n");
}
export function appendTurn(id: string, turn: TurnRecord): void {
appendFileSync(fileFor(id), JSON.stringify(turn) + "\n");
}
export type LoadedSession = {
meta: SessionMeta;
messages: ChatMessage[];
turns: TurnRecord[];
};
export function loadSession(id: string): LoadedSession | null {
const path = fileFor(id);
if (!existsSync(path)) return null;
const raw = readFileSync(path, "utf8");
let meta: SessionMeta | null = null;
const turns: TurnRecord[] = [];
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
let parsed: SessionRecord;
try { parsed = JSON.parse(line) as SessionRecord; } catch { continue; }
if (parsed.type === "meta") meta = parsed;
else if (parsed.type === "turn") turns.push(parsed);
}
if (!meta) return null;
const messages: ChatMessage[] = [{ role: "system", content: meta.systemPrompt }];
for (const t of turns) messages.push({ role: t.role, content: t.content });
return { meta, messages, turns };
}
export type SessionSummary = {
id: string;
notion: string;
sujet: string;
createdAt: number;
lastTurnAt: number;
turnCount: number;
};
export function listSessions(maxAgeMs = 24 * 3600 * 1000): SessionSummary[] {
const dir = getStorageDir();
const out: SessionSummary[] = [];
const now = Date.now();
for (const f of readdirSync(dir)) {
if (!f.endsWith(".jsonl")) continue;
const id = f.slice(0, -".jsonl".length);
try {
const loaded = loadSession(id);
if (!loaded) continue;
const lastTurn = loaded.turns[loaded.turns.length - 1];
const lastTurnAt = lastTurn ? lastTurn.t : loaded.meta.createdAt;
if (now - lastTurnAt > maxAgeMs) continue;
out.push({
id,
notion: loaded.meta.notion,
sujet: loaded.meta.sujet,
createdAt: loaded.meta.createdAt,
lastTurnAt,
turnCount: loaded.turns.length,
});
} catch { /* skip */ }
}
return out.sort((a, b) => b.lastTurnAt - a.lastTurnAt);
}