MagicCode/packages/core/src/sessions.ts

337 lines
12 KiB
TypeScript

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, .
// Une session = la conversation (format de messages neutre) + le modèle utilisé ; elle est stockée
// dans <dossier de lancement>/.maia/.sessions/<nom>.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;
/** Modèle utilisé, ex. "litellm:gpt-4o". */
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;
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<string, unknown>;
return (
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<SavedSession> {
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,
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 {
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 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.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(),
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;
}
}