import { existsSync, mkdirSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { Message } from "./types"; // Sessions de conversation : fonctionnalité propre à l'application, indépendante de l'agent (mode) utilisé. // Une session = la conversation (format de messages neutre) + le mode et le modèle utilisés ; elle est stockée // dans /.maia/.sessions/.json. export const MAIA_DIR = ".maia"; export const SESSIONS_DIR = ".sessions"; const FORMAT_VERSION = 1; const MAX_NAME_LENGTH = 100; // ───────────── Dossiers ./.maia et ./.maia/.sessions ───────────── export interface MaiaHome { dir: string; sessionsDir: string; /** true si ./.maia n'existait pas et vient d'être créé. */ created: boolean; } /** Crée ./.maia et ./.maia/.sessions dans `cwd` s'ils n'existent pas. Lève une erreur si c'est impossible. */ export function ensureMaiaHome(cwd: string): MaiaHome { const dir = join(cwd, MAIA_DIR); const created = !existsSync(dir); const sessionsDir = join(dir, SESSIONS_DIR); mkdirSync(sessionsDir, { recursive: true }); return { dir, sessionsDir, created }; } // ───────────── Noms ───────────── const pad = (n: number): string => String(n).padStart(2, "0"); /** Nom par défaut : session_{date}_{heure}_{minutes}_{secondes}, en heure locale. Ex. session_2026-10-13_01_04_05. */ export function defaultSessionName(date: Date = new Date()): string { const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; return `session_${day}_${pad(date.getHours())}_${pad(date.getMinutes())}_${pad(date.getSeconds())}`; } /** Retire les espaces et les guillemets (simples ou doubles) qui entourent un nom saisi par l'utilisateur. */ export function normalizeSessionName(raw: string): string { let name = raw.trim(); const quoted = /^(["'])([\s\S]*)\1$/.exec(name); if (quoted) name = (quoted[2] ?? "").trim(); return name; } /** Retourne un message d'erreur, ou null si le nom est utilisable comme nom de fichier. */ export function validateSessionName(name: string): string | null { if (!name) return "Le nom de la session est vide."; if (name.length > MAX_NAME_LENGTH) return `Nom trop long (maximum ${MAX_NAME_LENGTH} caractères).`; if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) { return 'Caractères interdits dans un nom de session : \\ / : * ? " < > |'; } if (name.startsWith(".")) return "Un nom de session ne peut pas commencer par un point."; if (name.endsWith(".")) return "Un nom de session ne peut pas se terminer par un point."; return null; } // ───────────── Format de fichier ───────────── export interface SavedSession { version: number; /** Nom de la session (le nom du fichier fait foi à la lecture). */ name: string; createdAt: string; updatedAt: string; /** Mode (agent) utilisé : claudecode, copilot… */ mode: string; /** Modèle utilisé, ex. "sapaicore:anthropic--claude-4.5-sonnet". */ model: string; /** Dossier de lancement au moment de la sauvegarde. */ cwd: string; messages: Message[]; /** Contexte en attente (ex. sortie d'une commande « ! » pas encore envoyée au modèle). */ pendingContext: string[]; } export interface SessionSummary { name: string; createdAt: string; updatedAt: string; mode: string; model: string; /** Nombre d'échanges (messages saisis par l'utilisateur, hors résultats d'outils). */ turns: number; } /** Nombre de messages « utilisateur » contenant du texte (un résultat d'outil n'est pas un échange). */ export function countTurns(messages: Message[]): number { return messages.filter((m) => m.role === "user" && m.content.some((b) => b.type === "text")).length; } function isSavedSession(v: unknown): v is SavedSession { if (typeof v !== "object" || v === null) return false; const s = v as Record; return ( typeof s.mode === "string" && typeof s.model === "string" && Array.isArray(s.messages) && s.messages.every( (m) => typeof m === "object" && m !== null && ((m as Message).role === "user" || (m as Message).role === "assistant") && Array.isArray((m as Message).content), ) && (s.pendingContext === undefined || Array.isArray(s.pendingContext)) ); } // ───────────── Stockage ───────────── export class SessionStore { readonly dir: string; constructor(dir: string) { this.dir = dir; } private file(name: string): string { return join(this.dir, `${name}.json`); } /** Noms des sessions enregistrées (sans l'extension), triés. */ names(): string[] { try { return readdirSync(this.dir) .filter((f) => f.endsWith(".json")) .map((f) => f.slice(0, -".json".length)) .sort(); } catch { return []; } } /** Nom exact, sinon insensible à la casse ; undefined si aucune session ne porte ce nom. */ find(name: string): string | undefined { const names = this.names(); return names.find((n) => n === name) ?? names.find((n) => n.toLowerCase() === name.toLowerCase()); } /** Noms qui commencent par `prefix` (insensible à la casse) : suggestions quand un nom est introuvable. */ suggest(prefix: string): string[] { const p = prefix.toLowerCase(); return this.names().filter((n) => n.toLowerCase().includes(p)).slice(0, 5); } /** Lit une session par son nom de fichier. Lève une erreur claire si le fichier est illisible ou invalide. */ async read(name: string): Promise { const path = this.file(name); let raw: string; try { raw = await readFile(path, "utf8"); } catch (err) { throw new Error(`Impossible de lire la session « ${name} » : ${(err as Error).message}`); } let json: unknown; try { json = JSON.parse(raw); } catch (err) { throw new Error(`La session « ${name} » est corrompue (JSON invalide : ${(err as Error).message})`); } if (!isSavedSession(json)) throw new Error(`La session « ${name} » n'a pas le format attendu (${path})`); const now = new Date().toISOString(); return { ...json, name, // le nom du fichier fait foi createdAt: typeof json.createdAt === "string" ? json.createdAt : now, updatedAt: typeof json.updatedAt === "string" ? json.updatedAt : now, cwd: typeof json.cwd === "string" ? json.cwd : "", pendingContext: Array.isArray(json.pendingContext) ? json.pendingContext : [], }; } /** Toutes les sessions lisibles, la plus récente d'abord ; `unreadable` liste les fichiers invalides. */ async list(): Promise<{ sessions: SessionSummary[]; unreadable: string[] }> { const sessions: SessionSummary[] = []; const unreadable: string[] = []; for (const name of this.names()) { try { const s = await this.read(name); sessions.push({ name, createdAt: s.createdAt, updatedAt: s.updatedAt, mode: s.mode, model: s.model, turns: countTurns(s.messages), }); } catch { unreadable.push(name); } } sessions.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0)); return { sessions, unreadable }; } /** Écriture atomique (fichier temporaire puis renommage). Synchrone : utilisable à la sortie du processus. */ writeSync(data: SavedSession): void { mkdirSync(this.dir, { recursive: true }); const path = this.file(data.name); const tmp = `${path}.tmp-${process.pid}`; writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8"); renameSync(tmp, path); } renameFileSync(from: string, to: string): void { renameSync(this.file(from), this.file(to)); } removeSync(name: string): void { try { unlinkSync(this.file(name)); } catch { // déjà absent } } } // ───────────── Session courante ───────────── export interface SessionState { messages: Message[]; pendingContext: string[]; } export interface SessionMeta { mode: string; model: string; cwd: string; } export type RenameResult = { ok: true; name: string; changed: boolean } | { ok: false; error: string }; export interface SaveResult { /** Nom sous lequel la session est enregistrée (peut différer de `name` si un homonyme existait). */ name: string; path: string; /** false si rien n'avait changé depuis la dernière sauvegarde. */ written: boolean; } /** Suit le nom de la session courante et la sauvegarde. Indépendant de l'agent : ne connaît que messages, mode et modèle. */ export class SessionManager { name: string; createdAt: Date; /** Nom du fichier de cette session s'il existe déjà (sauvegardée ou chargée). */ private savedName: string | undefined; private lastKey: string | undefined; readonly store: SessionStore; private readonly now: () => Date; constructor(store: SessionStore, now: () => Date = () => new Date()) { this.store = store; this.now = now; this.createdAt = this.now(); this.name = defaultSessionName(this.createdAt); } /** Une session vide (aucun message) n'est jamais sauvegardée. */ isEmpty(state: SessionState): boolean { return state.messages.length === 0; } /** Vrai si `name` est déjà pris par UNE AUTRE session que la courante. */ private clashes(name: string): boolean { const existing = this.store.find(name); return existing !== undefined && existing.toLowerCase() !== this.savedName?.toLowerCase(); } /** Renomme la session courante (effectif à la sauvegarde, qui déplace l'ancien fichier). */ rename(raw: string): RenameResult { const name = normalizeSessionName(raw); const invalid = validateSessionName(name); if (invalid) return { ok: false, error: invalid }; if (name === this.name) return { ok: true, name, changed: false }; if (this.clashes(name)) { return { ok: false, error: `Une session nommée « ${this.store.find(name)} » existe déjà : choisissez un autre nom.` }; } this.name = name; return { ok: true, name, changed: true }; } /** Sauvegarde la session courante ; null si elle est vide. Lève une erreur en cas d'échec d'écriture. */ save(state: SessionState, meta: SessionMeta): SaveResult | null { if (this.isEmpty(state)) return null; let target = this.name; if (this.clashes(target)) { let i = 2; while (this.clashes(`${target}_${i}`)) i++; target = `${target}_${i}`; } const key = `${target}|${state.messages.length}|${state.pendingContext.join("\n").length}|${meta.mode}|${meta.model}`; const path = join(this.store.dir, `${target}.json`); if (key === this.lastKey && this.savedName === target) return { name: target, path, written: false }; const old = this.savedName; // Changement de casse seul : un renommage de fichier (sur un système de fichiers insensible à la casse, // écrire puis supprimer l'ancien nom effacerait la session) if (old && old !== target && old.toLowerCase() === target.toLowerCase()) this.store.renameFileSync(old, target); this.store.writeSync({ version: FORMAT_VERSION, name: target, createdAt: this.createdAt.toISOString(), updatedAt: this.now().toISOString(), mode: meta.mode, model: meta.model, cwd: meta.cwd, messages: state.messages, pendingContext: state.pendingContext, }); // Session renommée : l'ancien fichier est supprimé (c'est la même session, pas une copie) if (old && old.toLowerCase() !== target.toLowerCase()) this.store.removeSync(old); this.name = target; this.savedName = target; this.lastKey = key; return { name: target, path, written: true }; } /** La session chargée devient la session courante (son nom est conservé). */ restore(saved: SavedSession): void { this.name = saved.name; this.savedName = saved.name; this.createdAt = new Date(saved.createdAt); this.lastKey = undefined; } /** Nouvelle session : nouveau nom par défaut, rien de sauvegardé pour l'instant. */ startNew(): void { this.createdAt = this.now(); this.name = defaultSessionName(this.createdAt); this.savedName = undefined; this.lastKey = undefined; } }