344 lines
12 KiB
TypeScript
344 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, 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 <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;
|
|
/** 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<string, unknown>;
|
|
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<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,
|
|
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;
|
|
}
|
|
}
|