import { homedir } from "node:os"; import { join } from "node:path"; import { parseArgs } from "node:util"; import { SessionManager, SessionStore, compactThreshold, createSession, ensureMaiaHome, findSkill, isCompactable, normalizeSessionName, parseShellInvocation, resolveFileReferences, parseSkillInvocation, queryContextWindow, requestCompactSummary, runTurn, sessionContextTokens, shouldCompact, type AgentSession, type Message, } from "@maia/core"; import { initializeAgent, type AgentDefinition } 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"; import { createSubAgentLauncher } from "./subagent-parent"; import { SUBAGENT_FLAG } from "./subagent-protocol"; const REPLAY_RESULT_MAX = 600; const REPLAY_USER_MAX = 1500; // les messages peuvent contenir des fichiers ou sorties de commandes joints const CONTEXT_QUERY_TIMEOUT_MS = 60_000; // question « taille maximale du contexte » posée au LLM /** 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 /agents /clear /compact /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) Sous-agents : définis dans ./.maia/agents//AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste 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 { // Processus d'un sous-agent (lancé par l'agent principal) : pas d'interface, la mission arrive par IPC if (process.argv.includes(SUBAGENT_FLAG)) { const { runSubAgentChild } = await import("./subagent-child"); await runSubAgentChild(); return; } 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); }; // Sous-agents : processus autonomes lancés par l'outil Agent (la notification INFO de fin passe par ui.info) let agents: AgentDefinition[] = []; const launchSubAgent = createSubAgentLauncher({ cwd, homeDir, settings, getModel: () => modelSpec, getSessionName: () => sessions?.name ?? null, get store() { return sessions?.store ?? null; }, confirm: (m) => ui.confirm(m), info: (m) => ui.info(m), warn: (m) => ui.warn(m), }); /** * Démarre un agent. `startContext` (session compactée) : le résumé sert de contexte de départ à la place de MAIA.md / CLAUDE.md. */ const startSession = async ( modelSpec: string, opts: { startContext?: string; quiet?: boolean } = {}, ): Promise => { const init = await initializeAgent({ cwd, homeDir, log: (m) => (opts.quiet ? undefined : ui.info(m)), warn: (m) => ui.warn(m), startContext: opts.startContext, launchSubAgent, }); agents = init.agents; const created = createSession({ provider: createProvider(modelSpec, settings), init, ui, cwd, startContext: opts.startContext, }); showContext(created); // le contexte de départ (prompt système, MAIA.md, outils) occupe déjà des tokens return created; }; /** * Compte les tokens du contexte de la session (js-tiktoken : prompt système, outils, messages, contexte en attente) * et met la barre de progression à jour. Appelé avant CHAQUE saisie de l'utilisateur, et à chaque changement de session. */ const showContext = (target: AgentSession): void => { ui.onContextUsage(sessionContextTokens(target)); }; // Tailles maximales de contexte déjà demandées au LLM (une seule question par modèle et par exécution) const contextWindows = new Map(); // Taille maximale du contexte du modèle actif (null : inconnue) : sert au seuil de compactage automatique let contextMax: number | null = null; /** * Après la sélection d'un modèle : demande au LLM la taille maximale de son contexte (en tokens) et la donne à la barre * de progression. Si la réponse est introuvable ou illisible, la barre est masquée (l'application continue). */ const resolveContextWindow = async (spec: string): Promise => { let max = contextWindows.get(spec) ?? null; if (max === null) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), CONTEXT_QUERY_TIMEOUT_MS); ui.setInterruptHandler(() => controller.abort()); // Ctrl+C abandonne la question ui.setBusy("Recherche de la taille maximale du contexte…"); try { const { tokens, answer } = await queryContextWindow(createProvider(spec, settings), controller.signal); if (tokens === null) { const shown = answer.length > 120 ? `${answer.slice(0, 120)}…` : answer; ui.warn(`Taille maximale du contexte non déterminée (réponse du modèle : « ${shown} »). Barre de progression masquée.`); } else { max = tokens; contextWindows.set(spec, tokens); ui.info(`Taille maximale du contexte : ${tokens.toLocaleString("fr-FR")} tokens (réponse du modèle)`); } } catch (err) { const why = controller.signal.aborted ? "question abandonnée (délai dépassé ou Ctrl+C)" : (err as Error).message; ui.warn(`Taille maximale du contexte indisponible : ${why}. Barre de progression masquée.`); } finally { clearTimeout(timer); ui.setBusy(null); ui.setInterruptHandler(null); } } contextMax = max; ui.setContextMax(max); }; 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: string; if (config.model) { modelSpec = config.model; } else { 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}`); await resolveContextWindow(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).`); }; /** * Compactage (appel bloquant : la saisie reprend une fois terminé ; Ctrl+C l'annule sans rien perdre) : * 1. la conversation actuelle est sauvegardée telle quelle sous son nom ; * 2. le LLM en fait un résumé ; * 3. la conversation est vidée comme avec /clear, mais le résumé remplace MAIA.md / CLAUDE.md comme contexte de départ ; * 4. la session continue sous le nom « {nom d'origine}__{n} » et la barre de progression est recalculée. */ const compactNow = async (): Promise => { if (!isCompactable(session)) { ui.info("Rien à compacter : la conversation est vide."); return false; } const controller = new AbortController(); ui.setInterruptHandler(() => controller.abort()); ui.setBusy("Compactage de la conversation…"); try { saveSession(); // la session d'origine reste complète dans son fichier const { summary, truncated } = await requestCompactSummary(session, controller.signal); const fresh = await startSession(modelSpec, { startContext: summary, quiet: true }); session = fresh; let target = ""; if (sessions) { const before = sessions.name; target = sessions.startCompacted(); saveSession(); // « nom__n » : écrase un éventuel fichier du même nom ui.info(`Conversation compactée : la session continue sous « ${target} » (« ${before} » est conservée telle quelle).`); } else { ui.info("Conversation compactée (sessions indisponibles : rien n'est enregistré)."); } if (truncated) ui.warn("Le résumé a atteint la limite de 4000 tokens : il peut être incomplet."); ui.info(`Contexte de départ :\n${summary}`); return true; } catch (err) { ui.warn( controller.signal.aborted ? "Compactage annulé (Ctrl+C) : la conversation est inchangée." : `Compactage impossible, la conversation est inchangée : ${(err as Error).message}`, ); return false; } finally { ui.setBusy(null); ui.setInterruptHandler(null); } }; /** Compactage automatique dès que le contexte franchit Seuil_max = Max_Token_Context - (4000 + 500). */ const autoCompactIfNeeded = async (): Promise => { if (contextMax === null) return; const tokens = sessionContextTokens(session); if (!shouldCompact(tokens, contextMax)) return; ui.warn( `Contexte de ${tokens.toLocaleString("fr-FR")} tokens : seuil de ${compactThreshold(contextMax).toLocaleString("fr-FR")} franchi (maximum ${contextMax.toLocaleString("fr-FR")}). Compactage automatique…`, ); await compactNow(); }; // 4. Boucle de conversation for (;;) { showContext(session); // taille du contexte recomptée avant chaque saisie 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); showContext(session); // le résultat de la commande est ajouté au contexte } 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 === "agents") { ui.info( agents.length > 0 ? [ "Sous-agents disponibles :", ...agents.map((a) => ` ${a.name} (${a.source}${a.model ? `, modèle ${a.model}` : ""}${a.trace ? ", trace" : ""}) — ${a.description}`), ].join("\n") : "Aucun sous-agent : créez ./.maia/agents//AGENT.md (ou ~/.maia/agents//AGENT.md).", ); 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" : ""}${x.agent ? ` · sous-agent ${x.agent}` : ""}`, })), 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(); if ((saved.startContext ?? null) !== (session.startContext ?? null)) { // Session compactée (ou retour à une session normale) : le contexte de départ n'est pas le même try { session = await startSession(modelSpec, { startContext: saved.startContext, quiet: true }); } catch (err) { ui.error(`Chargement impossible : ${(err as Error).message}`); continue; } } session.messages.length = 0; session.messages.push(...saved.messages); session.pendingContext.length = 0; session.pendingContext.push(...saved.pendingContext); sessions.restore(saved); showContext(session); 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.`); } if (saved.startContext) ui.info(`Session compactée, contexte de départ :\n${saved.startContext}`); 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 === "compact") { await compactNow(); 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}`); await resolveContextWindow(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); } // Contexte trop proche de la limite : compactage (bloquant) avant d'envoyer la demande await autoCompactIfNeeded(); // 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 showContext(session); ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande."); } else { ui.error((err as Error).message); } } finally { ui.setInterruptHandler(null); } // Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande await autoCompactIfNeeded(); } const finalName = saveSession(); if (finalName) ui.info(`Session sauvegardée : ${finalName}`); ui.outro("À bientôt"); } main().catch((err) => { console.error(err); process.exit(1); });