import { homedir } from "node:os"; import { join } from "node:path"; import { parseArgs } from "node:util"; import { SessionManager, SessionStore, createSession, ensureMaiaHome, findSkill, normalizeSessionName, parseShellInvocation, resolveFileReferences, parseSkillInvocation, runTurn, type AgentSession, type Message, } from "@maia/core"; import { initializeAgent } from "@maia/agent"; import { createProvider, loadModelChoices, loadSettings, settingsPath } from "@maia/providers"; import { InkUI } from "@maia/ui"; import { resolveConfig } from "./config"; import { resolveLaunchDir } from "./launch-dir"; import { runUserShell } from "./user-shell"; const REPLAY_RESULT_MAX = 600; const REPLAY_USER_MAX = 1500; // les messages peuvent contenir des fichiers ou sorties de commandes joints /** Réaffiche le contenu d'une session rechargée (échanges, appels d'outils, résultats tronqués). */ function replayMessages(ui: InkUI, session: AgentSession, messages: Message[]): void { const toolNames = new Map(); for (const m of messages) { for (const b of m.content) { if (b.type === "text") { if (!b.text.trim()) continue; if (m.role === "user") ui.showUser(b.text.length > REPLAY_USER_MAX ? `${b.text.slice(0, REPLAY_USER_MAX)}… (tronqué)` : b.text); else { ui.onText(b.text); ui.onTextEnd(); } } else if (b.type === "tool_use") { toolNames.set(b.id, b.name); const tool = session.tools.get(b.name); let summary: string; try { summary = tool?.describe?.(b.input) ?? JSON.stringify(b.input).slice(0, 120); } catch { summary = JSON.stringify(b.input).slice(0, 120); } ui.onToolCall(b.name, summary); } else { const out = b.content.length > REPLAY_RESULT_MAX ? `${b.content.slice(0, REPLAY_RESULT_MAX)}… (tronqué)` : b.content; ui.onToolResult(toolNames.get(b.toolUseId) ?? "outil", out, b.isError === true); } } } } const HELP = `MAIA — assistant IA en ligne de commande Usage : maia [options] --model Modèle de la session, ex. litellm:gpt-4o Sans cette option, la liste des modèles disponibles est proposée au démarrage. -h, --help Affiche cette aide Commandes en session : /help /model /permissions /clear /exit Sessions : /rename /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions) Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills) Commande shell : !commande ou !'commande avec paramètres' (exécutée directement ; résultat ajouté au contexte) Fichier référencé : @chemin/fichier ou @'chemin avec espaces' ou @ chemin/fichier (contenu injecté dans le contexte)`; async function main(): Promise { const { values } = parseArgs({ options: { model: { type: "string" }, help: { type: "boolean", short: "h" }, }, }); if (values.help) { console.log(HELP); return; } if (!process.stdin.isTTY) { console.error("MAIA nécessite un terminal interactif (l'entrée standard n'est pas un TTY)."); process.exitCode = 1; return; } const ui = new InkUI(); try { await run(ui, { model: values.model }); } finally { await ui.close(); // restaure le terminal et affiche les derniers messages } } async function run(ui: InkUI, flags: { model?: string }): Promise { // Dossier de lancement (voir launch-dir.ts) : base de tous les chemins relatifs const cwd = resolveLaunchDir(); try { process.chdir(cwd); } catch (err) { console.error(`Dossier de lancement inaccessible : ${cwd} (${(err as Error).message})`); process.exitCode = 1; return; } const homeDir = homedir(); // 0. Configuration de l'application : ~/.maia/settings.json (serveur LiteLLM) let settings; try { settings = await loadSettings(homeDir); } catch (err) { console.error((err as Error).message); process.exitCode = 1; return; } // 2. Configuration const config = await resolveConfig({ cwd, homeDir, flags, }); /** * Charge les modèles disponibles auprès de tous les providers, puis demande à l'utilisateur * d'en choisir un. Retourne null si rien n'est disponible ou si l'utilisateur annule. */ const pickModel = async (current?: string): Promise => { const { choices, errors } = await ui.withSpinner( "Chargement des modèles disponibles…", () => loadModelChoices(settings), "Modèles chargés", ); for (const e of errors) ui.warn(e); if (choices.length === 0) { ui.error( `Aucun modèle disponible auprès de LiteLLM. Vérifiez LiteLLM_URL et LiteLLM_Models dans ${settingsPath(homeDir)}.`, ); return null; } return ui.select("Modèle", choices, current); }; const startSession = async (modelSpec: string): Promise => { const init = await initializeAgent({ cwd, homeDir, log: (m) => ui.info(m), warn: (m) => ui.warn(m), }); return createSession({ provider: createProvider(modelSpec, settings), init, ui, cwd }); }; ui.intro("Maia"); // Dossier de lancement : base de tous les chemins relatifs (fichiers lus, créés, configuration) ui.info(`Dossier de travail : ${cwd}`); // Dossier ./.maia (créé s'il n'existe pas) et ./.maia/.sessions : sessions communes à tous les agents let sessions: SessionManager | null = null; try { const home = ensureMaiaHome(cwd); if (home.created) ui.info(`Dossier créé : ${home.dir}`); sessions = new SessionManager(new SessionStore(home.sessionsDir)); } catch (err) { ui.warn(`Sessions désactivées : impossible de créer ./.maia/.sessions (${(err as Error).message})`); } // 3. Modèle de la session : explicite (flag, variable d'environnement, config) sinon choisi par l'utilisateur let modelSpec = config.model; if (!modelSpec) { const picked = await pickModel(); if (!picked) { ui.outro("Aucun modèle choisi"); process.exitCode = 1; return; } modelSpec = picked; } let session: AgentSession; try { session = await startSession(modelSpec); } catch (err) { ui.error((err as Error).message); process.exitCode = 1; return; } ui.info(`Modèle : ${modelSpec}`); // Sauvegarde de la session courante (silencieuse si elle est vide ou inchangée). Retourne son nom si écrite. const saveSession = (): string | null => { if (!sessions) return null; try { const r = sessions.save(session, { model: modelSpec, cwd }); return r?.written ? r.name : null; } catch (err) { ui.warn(`Sauvegarde de la session impossible : ${(err as Error).message}`); return null; } }; // Filet de sécurité : sortie par signal ou fermeture du terminal (l'écriture est synchrone) const onExit = (): void => void saveSession(); const onSignal = (): void => { saveSession(); process.exit(0); }; process.on("exit", onExit); process.on("SIGTERM", onSignal); process.on("SIGHUP", onSignal); // Quitte la session courante pour une nouvelle : l'utilisateur est prévenu, la session est sauvegardée. const announceLeaving = (): void => { if (!sessions) return; if (sessions.isEmpty(session)) return; const saved = saveSession(); const name = saved ?? sessions.name; ui.warn(`Vous quittez la session « ${name} » : elle est sauvegardée (./.maia/.sessions/${name}.json).`); }; // 4. Boucle de conversation for (;;) { const input = await ui.readInput(); if (input === null || input === "/exit") break; if (!input) continue; // Commande shell directe (commune à tous les agents) : !commande const shellCall = parseShellInvocation(input); if (shellCall) { if ("error" in shellCall) ui.error(shellCall.error); else await runUserShell(ui, session, shellCall.command); continue; } let prompt = input; const skillCall = parseSkillInvocation(input); if (skillCall) { // Appel direct d'un skill de l'agent chargé : §nom [arguments] if (!skillCall.name) { ui.info( session.skills.length > 0 ? ["Skills disponibles :", ...session.skills.map((s) => ` §${s.name} — ${s.description}`)].join("\n") : "Aucun skill disponible disponible.", ); continue; } const skill = findSkill(session.skills, skillCall.name); if (!skill) { const names = session.skills.map((s) => `§${s.name}`).join(", ") || "aucun"; ui.error(`Skill inconnu : §${skillCall.name} (disponibles : ${names})`); continue; } try { prompt = await skill.invoke(skillCall.args); } catch (err) { ui.error(`Échec du lancement du skill §${skill.name} : ${(err as Error).message}`); continue; } ui.info(`Skill lancé : §${skill.name}`); } else if (input.startsWith("/")) { const [name = "", ...rest] = input.slice(1).split(" "); const args = rest.join(" "); if (name === "help") { const custom = session.commands.map( (c) => `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ""} — ${c.description}`, ); const skills = session.skills.map((s) => `§${s.name} — ${s.description}`); ui.info([HELP, ...custom, ...skills].join("\n")); continue; } if (name === "permissions") { ui.showPermissions(session.permissionSources); continue; } if (name === "rename") { if (!sessions) ui.error("Sessions indisponibles (dossier ./.maia non accessible)."); else if (!args.trim()) ui.info(`Session courante : ${sessions.name} (usage : /rename )`); else { const r = sessions.rename(args); if (!r.ok) ui.error(r.error); else ui.info(r.changed ? `Session renommée : ${r.name}` : `La session s'appelle déjà « ${r.name} »`); } continue; } if (name === "new") { if (!sessions) { ui.error("Sessions indisponibles (dossier ./.maia non accessible)."); continue; } announceLeaving(); try { session = await startSession(modelSpec); // contexte (MAIA.md / CLAUDE.md) et skills rechargés } catch (err) { ui.error(`Nouvelle session impossible : ${(err as Error).message}`); continue; } sessions.startNew(); ui.info(`Nouvelle session : ${sessions.name} (nouveau contexte)`); continue; } if (name === "load") { if (!sessions) { ui.error("Sessions indisponibles (dossier ./.maia non accessible)."); continue; } let target = args.trim(); if (!target) { const { sessions: list, unreadable } = await sessions.store.list(); for (const u of unreadable) ui.warn(`Session illisible ignorée : ${u}`); if (list.length === 0) { ui.info("Aucune session enregistrée."); continue; } const picked = await ui.select( "Sessions enregistrées", list.map((x) => ({ value: x.name, label: x.name, hint: `${x.updatedAt.slice(0, 16).replace("T", " ")} · ${x.turns} échange${x.turns > 1 ? "s" : ""}`, })), sessions.name, ); if (!picked) continue; target = picked; } const found = sessions.store.find(normalizeSessionName(target)); if (!found) { const near = sessions.store.suggest(normalizeSessionName(target)); ui.error(`Session introuvable : ${target}${near.length ? ` (proches : ${near.join(", ")})` : ""}`); continue; } if (found.toLowerCase() === sessions.name.toLowerCase() && !sessions.isEmpty(session)) { ui.info(`« ${found} » est déjà la session courante.`); continue; } let saved; try { saved = await sessions.store.read(found); } catch (err) { ui.error((err as Error).message); continue; } announceLeaving(); session.messages.length = 0; session.messages.push(...saved.messages); session.pendingContext.length = 0; session.pendingContext.push(...saved.pendingContext); sessions.restore(saved); ui.info(`Session chargée : ${saved.name} (${saved.messages.length} messages · modèle ${modelSpec})`); if (session.pendingContext.length > 0) { ui.info(`Contexte en attente (commandes « ! ») : ${session.pendingContext.length} élément(s), joint(s) à votre prochaine demande.`); } replayMessages(ui, session, saved.messages); ui.info("— fin de la session rechargée, vous pouvez continuer —"); continue; } if (name === "clear") { // Contexte supprimé et agent réinitialisé : MAIA.md / CLAUDE.md et les skills sont rechargés // comme au démarrage (la session garde son nom ; ce qui est effacé n'est pas sauvegardé). try { session = await startSession(modelSpec); ui.info("Contexte supprimé : nouvelle conversation"); } catch (err) { ui.error(`Réinitialisation impossible : ${(err as Error).message}`); } continue; } if (name === "model") { // Recharge la liste (les déploiements ont pu changer) ; la conversation en cours est conservée. const choice = await pickModel(modelSpec); if (choice && choice !== modelSpec) { try { session.provider = createProvider(choice, settings); modelSpec = choice; ui.info(`Modèle actif : ${modelSpec}`); } catch (err) { ui.error((err as Error).message); } } continue; } const custom = session.commands.find((c) => c.name === name); if (!custom) { ui.error(`Commande inconnue : /${name}`); continue; } prompt = custom.prompt(args); } // Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation const mark = session.messages.length; const contextBackup = [...session.pendingContext]; // Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que // l'utilisateur a tapé est joint à la demande. Ajouté APRÈS la sauvegarde : une demande interrompue // ne le conserve pas (on le re-citera en la relançant). const refs = await resolveFileReferences(input, { cwd: session.cwd }); for (const w of refs.warnings) ui.warn(w); if (refs.context) { session.pendingContext.push(refs.context); ui.info( `Fichiers ajoutés au contexte : ${refs.files .map((f) => `${f.path}${f.kind === "dir" ? "/" : ""}${f.truncated ? " (tronqué)" : ""}`) .join(", ")}`, ); } const controller = new AbortController(); ui.setInterruptHandler(() => controller.abort()); try { await runTurn(session, prompt, controller.signal); } catch (err) { if (controller.signal.aborted) { session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat) session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande."); } else { ui.error((err as Error).message); } } finally { ui.setInterruptHandler(null); } } const finalName = saveSession(); if (finalName) ui.info(`Session sauvegardée : ${finalName}`); ui.outro("À bientôt"); } main().catch((err) => { console.error(err); process.exit(1); });