MagicCode/packages/cli/src/index.ts

705 lines
28 KiB
TypeScript

import { homedir } from "node:os";
import { join } from "node:path";
import { parseArgs } from "node:util";
import {
SessionManager,
SessionStore,
compactThreshold,
createSession,
ensureMaiaHome,
findSkill,
EMERGENCY_TOOL_RESULT_CHARS,
MAX_ITERATIONS,
isCompactable,
isContextTooLongError,
normalizeSessionName,
DEFAULT_AGENT_TASK,
parseAgentInvocation,
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<string, string>();
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 <spec> 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 <nom> /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/<nom>/AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste
&nom [tâche] : vous lancez vous-même un sous-agent (& seul : liste) ; son résumé est ajouté au contexte
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<void> {
// 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<void> {
// 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<string | null> => {
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<AgentSession> => {
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<string, number>();
// 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<void> => {
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 (opts: { emergency?: boolean } = {}): Promise<boolean> => {
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,
// Urgence (le serveur a refusé le contexte) : la demande de résumé doit elle-même tenir dans la fenêtre
opts.emergency ? { shrinkToolResults: EMERGENCY_TOOL_RESULT_CHARS } : {},
);
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<void> => {
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;
}
// Lancement direct d'un sous-agent : une saisie qui commence par « & » (&nom [tâche])
const agentCall = parseAgentInvocation(input);
if (agentCall) {
if (!agentCall.name) {
ui.info(
agents.length > 0
? ["Sous-agents disponibles :", ...agents.map((a) => ` &${a.name} — ${a.description}`)].join("\n")
: "Aucun sous-agent : créez ./.maia/agents/<nom>/AGENT.md (ou ~/.maia/agents/<nom>/AGENT.md).",
);
continue;
}
const def = agents.find((a) => a.name.toLowerCase() === agentCall.name.toLowerCase());
if (!def) {
const names = agents.map((a) => `&${a.name}`).join(", ") || "aucun";
ui.error(`Sous-agent inconnu : &${agentCall.name} (disponibles : ${names})`);
continue;
}
const task = agentCall.task || DEFAULT_AGENT_TASK;
const controller = new AbortController();
ui.setInterruptHandler(() => controller.abort());
ui.setBusy(`Sous-agent « ${def.name} » au travail…`);
try {
const summary = await launchSubAgent({ agent: def, task, signal: controller.signal });
// Le résumé s'affiche et rejoint la conversation principale (joint à la prochaine demande, comme le résultat d'un « ! »)
ui.onText(summary);
ui.onTextEnd();
session.pendingContext.push(`Résumé du sous-agent « ${def.name} » (lancé par l'utilisateur) pour la tâche : ${task}\n\n${summary}`);
ui.info("Résumé ajouté au contexte : il sera joint à votre prochaine demande.");
} catch (err) {
if (controller.signal.aborted) ui.warn(`Sous-agent « ${def.name} » interrompu (Ctrl+C).`);
else ui.error((err as Error).message);
} finally {
ui.setBusy(null);
ui.setInterruptHandler(null);
}
showContext(session);
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/<nom>/AGENT.md (ou ~/.maia/agents/<nom>/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 <nouveau nom>)`);
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
let mark = session.messages.length;
let lastBackup = session.messages.at(-1); // runTurn peut compléter ce message (reprise après la limite d'itérations)
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());
let attempt = prompt;
let retried = false;
for (;;) {
try {
await runTurn(session, attempt, controller.signal, {
maxIterations: settings.MaxIterations,
onLimit: (n) => ui.confirm(`${n} étapes effectuées sans réponse finale. Continuer pour ${settings.MaxIterations ?? MAX_ITERATIONS} étapes de plus ?`),
});
break;
} catch (err) {
if (controller.signal.aborted) {
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
if (mark > 0 && lastBackup) session.messages[mark - 1] = lastBackup;
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.");
break;
}
if (!retried && isContextTooLongError(err)) {
// Le serveur refuse le contexte (notre comptage local sous-estime) : compactage, puis reprise de la demande une seule fois
retried = true;
ui.warn("Le serveur indique que le contexte est trop long : compactage automatique, puis reprise de la demande.");
const compacted = await compactNow({ emergency: true });
ui.setInterruptHandler(() => controller.abort()); // compactNow a retiré le gestionnaire Ctrl+C
if (compacted) {
mark = 0;
lastBackup = undefined; // nouvelle conversation (résumé comme contexte de départ)
attempt =
"Le contexte a été compacté parce qu'il dépassait la fenêtre du modèle. Le résumé de départ indique ce qui est déjà fait. " +
`Reprends là où tu t'es arrêté et termine la demande suivante :\n\n${prompt}`;
continue;
}
}
ui.error((err as Error).message);
break;
}
}
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);
});