2026-10-11 00:11:20 +00:00
|
|
|
import { homedir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { parseArgs } from "node:util";
|
|
|
|
|
import {
|
2026-10-12 23:23:59 +00:00
|
|
|
SessionManager,
|
|
|
|
|
SessionStore,
|
2026-10-18 23:02:44 +00:00
|
|
|
compactThreshold,
|
2026-10-11 00:11:20 +00:00
|
|
|
createSession,
|
2026-10-12 23:23:59 +00:00
|
|
|
ensureMaiaHome,
|
2026-10-11 00:11:20 +00:00
|
|
|
findSkill,
|
2026-10-18 23:02:44 +00:00
|
|
|
isCompactable,
|
2026-10-12 23:23:59 +00:00
|
|
|
normalizeSessionName,
|
2026-10-12 07:16:26 +00:00
|
|
|
parseShellInvocation,
|
2026-10-12 23:23:59 +00:00
|
|
|
resolveFileReferences,
|
2026-10-11 00:11:20 +00:00
|
|
|
parseSkillInvocation,
|
2026-10-15 22:54:08 +00:00
|
|
|
queryContextWindow,
|
2026-10-18 23:02:44 +00:00
|
|
|
requestCompactSummary,
|
2026-10-11 00:11:20 +00:00
|
|
|
runTurn,
|
2026-10-18 23:02:44 +00:00
|
|
|
sessionContextTokens,
|
|
|
|
|
shouldCompact,
|
2026-10-11 00:11:20 +00:00
|
|
|
type AgentSession,
|
2026-10-12 23:23:59 +00:00
|
|
|
type Message,
|
2026-10-14 23:12:15 +00:00
|
|
|
} from "@maia/core";
|
2026-10-20 00:06:51 +00:00
|
|
|
import { initializeAgent, type AgentDefinition } from "@maia/agent";
|
2026-10-14 23:12:15 +00:00
|
|
|
import { createProvider, loadModelChoices, loadSettings, settingsPath } from "@maia/providers";
|
|
|
|
|
import { InkUI } from "@maia/ui";
|
2026-10-11 00:11:20 +00:00
|
|
|
import { resolveConfig } from "./config";
|
|
|
|
|
import { resolveLaunchDir } from "./launch-dir";
|
2026-10-12 07:16:26 +00:00
|
|
|
import { runUserShell } from "./user-shell";
|
2026-10-20 00:06:51 +00:00
|
|
|
import { createSubAgentLauncher } from "./subagent-parent";
|
|
|
|
|
import { SUBAGENT_FLAG } from "./subagent-protocol";
|
2026-10-11 00:11:20 +00:00
|
|
|
|
2026-10-12 23:23:59 +00:00
|
|
|
const REPLAY_RESULT_MAX = 600;
|
|
|
|
|
const REPLAY_USER_MAX = 1500; // les messages peuvent contenir des fichiers ou sorties de commandes joints
|
2026-10-15 22:54:08 +00:00
|
|
|
const CONTEXT_QUERY_TIMEOUT_MS = 60_000; // question « taille maximale du contexte » posée au LLM
|
2026-10-12 23:23:59 +00:00
|
|
|
|
|
|
|
|
/** 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 23:12:15 +00:00
|
|
|
const HELP = `MAIA — assistant IA en ligne de commande
|
2026-10-11 00:11:20 +00:00
|
|
|
|
2026-10-14 23:12:15 +00:00
|
|
|
Usage : maia [options]
|
|
|
|
|
--model <spec> Modèle de la session, ex. litellm:gpt-4o
|
2026-10-11 00:11:20 +00:00
|
|
|
Sans cette option, la liste des modèles disponibles est proposée au démarrage.
|
|
|
|
|
-h, --help Affiche cette aide
|
|
|
|
|
|
2026-10-20 00:06:51 +00:00
|
|
|
Commandes en session : /help /model /permissions /agents /clear /compact /exit
|
2026-10-12 23:23:59 +00:00
|
|
|
Sessions : /rename <nom> /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions)
|
2026-10-11 22:41:17 +00:00
|
|
|
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)
|
2026-10-20 00:06:51 +00:00
|
|
|
Sous-agents : définis dans ./.maia/agents/<nom>/AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste
|
2026-10-12 23:23:59 +00:00
|
|
|
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)`;
|
2026-10-11 00:11:20 +00:00
|
|
|
|
|
|
|
|
async function main(): Promise<void> {
|
2026-10-20 00:06:51 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-10-11 00:11:20 +00:00
|
|
|
const { values } = parseArgs({
|
|
|
|
|
options: {
|
|
|
|
|
model: { type: "string" },
|
|
|
|
|
help: { type: "boolean", short: "h" },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (values.help) {
|
|
|
|
|
console.log(HELP);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-11 22:41:17 +00:00
|
|
|
if (!process.stdin.isTTY) {
|
2026-10-14 23:12:15 +00:00
|
|
|
console.error("MAIA nécessite un terminal interactif (l'entrée standard n'est pas un TTY).");
|
2026-10-11 22:41:17 +00:00
|
|
|
process.exitCode = 1;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ui = new InkUI();
|
|
|
|
|
try {
|
2026-10-14 23:12:15 +00:00
|
|
|
await run(ui, { model: values.model });
|
2026-10-11 22:41:17 +00:00
|
|
|
} finally {
|
|
|
|
|
await ui.close(); // restaure le terminal et affiche les derniers messages
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-11 00:11:20 +00:00
|
|
|
|
2026-10-14 23:12:15 +00:00
|
|
|
async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
2026-10-11 00:11:20 +00:00
|
|
|
// 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();
|
|
|
|
|
|
2026-10-14 23:12:15 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-10-11 00:11:20 +00:00
|
|
|
|
|
|
|
|
// 2. Configuration
|
|
|
|
|
const config = await resolveConfig({
|
|
|
|
|
cwd,
|
|
|
|
|
homeDir,
|
2026-10-11 22:41:17 +00:00
|
|
|
flags,
|
2026-10-11 00:11:20 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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…",
|
2026-10-14 23:12:15 +00:00
|
|
|
() => loadModelChoices(settings),
|
2026-10-11 00:11:20 +00:00
|
|
|
"Modèles chargés",
|
|
|
|
|
);
|
|
|
|
|
for (const e of errors) ui.warn(e);
|
|
|
|
|
if (choices.length === 0) {
|
|
|
|
|
ui.error(
|
2026-10-14 23:12:15 +00:00
|
|
|
`Aucun modèle disponible auprès de LiteLLM. Vérifiez LiteLLM_URL et LiteLLM_Models dans ${settingsPath(homeDir)}.`,
|
2026-10-11 00:11:20 +00:00
|
|
|
);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return ui.select("Modèle", choices, current);
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-20 00:06:51 +00:00
|
|
|
// 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),
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-18 23:02:44 +00:00
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-10-19 23:45:23 +00:00
|
|
|
opts: { startContext?: string; quiet?: boolean } = {},
|
2026-10-18 23:02:44 +00:00
|
|
|
): Promise<AgentSession> => {
|
2026-10-14 23:12:15 +00:00
|
|
|
const init = await initializeAgent({
|
2026-10-11 00:11:20 +00:00
|
|
|
cwd,
|
|
|
|
|
homeDir,
|
2026-10-18 23:02:44 +00:00
|
|
|
log: (m) => (opts.quiet ? undefined : ui.info(m)),
|
2026-10-11 00:11:20 +00:00
|
|
|
warn: (m) => ui.warn(m),
|
2026-10-18 23:02:44 +00:00
|
|
|
startContext: opts.startContext,
|
2026-10-20 00:06:51 +00:00
|
|
|
launchSubAgent,
|
2026-10-11 00:11:20 +00:00
|
|
|
});
|
2026-10-20 00:06:51 +00:00
|
|
|
agents = init.agents;
|
2026-10-18 23:02:44 +00:00
|
|
|
const created = createSession({
|
|
|
|
|
provider: createProvider(modelSpec, settings),
|
|
|
|
|
init,
|
|
|
|
|
ui,
|
|
|
|
|
cwd,
|
|
|
|
|
startContext: opts.startContext,
|
|
|
|
|
});
|
2026-10-19 23:45:23 +00:00
|
|
|
showContext(created); // le contexte de départ (prompt système, MAIA.md, outils) occupe déjà des tokens
|
2026-10-15 22:54:08 +00:00
|
|
|
return created;
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-19 23:45:23 +00:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-10-18 23:02:44 +00:00
|
|
|
const showContext = (target: AgentSession): void => {
|
2026-10-19 23:45:23 +00:00
|
|
|
ui.onContextUsage(sessionContextTokens(target));
|
2026-10-15 22:54:08 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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>();
|
2026-10-18 23:02:44 +00:00
|
|
|
// Taille maximale du contexte du modèle actif (null : inconnue) : sert au seuil de compactage automatique
|
|
|
|
|
let contextMax: number | null = null;
|
2026-10-15 22:54:08 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-18 23:02:44 +00:00
|
|
|
contextMax = max;
|
2026-10-15 22:54:08 +00:00
|
|
|
ui.setContextMax(max);
|
2026-10-11 00:11:20 +00:00
|
|
|
};
|
|
|
|
|
|
2026-10-12 23:23:59 +00:00
|
|
|
ui.intro("Maia");
|
2026-10-11 00:11:20 +00:00
|
|
|
// Dossier de lancement : base de tous les chemins relatifs (fichiers lus, créés, configuration)
|
|
|
|
|
ui.info(`Dossier de travail : ${cwd}`);
|
|
|
|
|
|
2026-10-12 23:23:59 +00:00
|
|
|
// 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})`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 00:11:20 +00:00
|
|
|
// 3. Modèle de la session : explicite (flag, variable d'environnement, config) sinon choisi par l'utilisateur
|
2026-10-15 22:54:08 +00:00
|
|
|
let modelSpec: string;
|
|
|
|
|
if (config.model) {
|
|
|
|
|
modelSpec = config.model;
|
|
|
|
|
} else {
|
2026-10-11 00:11:20 +00:00
|
|
|
const picked = await pickModel();
|
|
|
|
|
if (!picked) {
|
|
|
|
|
ui.outro("Aucun modèle choisi");
|
|
|
|
|
process.exitCode = 1;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
modelSpec = picked;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let session: AgentSession;
|
|
|
|
|
try {
|
2026-10-14 23:12:15 +00:00
|
|
|
session = await startSession(modelSpec);
|
2026-10-11 00:11:20 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
ui.error((err as Error).message);
|
|
|
|
|
process.exitCode = 1;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-14 23:12:15 +00:00
|
|
|
ui.info(`Modèle : ${modelSpec}`);
|
2026-10-15 22:54:08 +00:00
|
|
|
await resolveContextWindow(modelSpec);
|
2026-10-11 00:11:20 +00:00
|
|
|
|
2026-10-12 23:23:59 +00:00
|
|
|
// 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 {
|
2026-10-14 23:12:15 +00:00
|
|
|
const r = sessions.save(session, { model: modelSpec, cwd });
|
2026-10-12 23:23:59 +00:00
|
|
|
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).`);
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-18 23:02:44 +00:00
|
|
|
/**
|
|
|
|
|
* 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<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);
|
2026-10-19 23:45:23 +00:00
|
|
|
const fresh = await startSession(modelSpec, { startContext: summary, quiet: true });
|
2026-10-18 23:02:44 +00:00
|
|
|
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();
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-11 00:11:20 +00:00
|
|
|
// 4. Boucle de conversation
|
|
|
|
|
for (;;) {
|
2026-10-19 23:45:23 +00:00
|
|
|
showContext(session); // taille du contexte recomptée avant chaque saisie
|
2026-10-11 00:11:20 +00:00
|
|
|
const input = await ui.readInput();
|
|
|
|
|
if (input === null || input === "/exit") break;
|
|
|
|
|
if (!input) continue;
|
|
|
|
|
|
2026-10-12 07:16:26 +00:00
|
|
|
// Commande shell directe (commune à tous les agents) : !commande
|
|
|
|
|
const shellCall = parseShellInvocation(input);
|
|
|
|
|
if (shellCall) {
|
|
|
|
|
if ("error" in shellCall) ui.error(shellCall.error);
|
2026-10-18 23:02:44 +00:00
|
|
|
else {
|
|
|
|
|
await runUserShell(ui, session, shellCall.command);
|
|
|
|
|
showContext(session); // le résultat de la commande est ajouté au contexte
|
|
|
|
|
}
|
2026-10-12 07:16:26 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 00:11:20 +00:00
|
|
|
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")
|
2026-10-14 23:12:15 +00:00
|
|
|
: "Aucun skill disponible disponible.",
|
2026-10-11 00:11:20 +00:00
|
|
|
);
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-10-15 21:49:58 +00:00
|
|
|
if (name === "permissions") {
|
|
|
|
|
ui.showPermissions(session.permissionSources);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-10-20 00:06:51 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-10-12 23:23:59 +00:00
|
|
|
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();
|
2026-10-15 20:43:50 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-10-12 23:23:59 +00:00
|
|
|
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,
|
2026-10-20 00:06:51 +00:00
|
|
|
hint: `${x.updatedAt.slice(0, 16).replace("T", " ")} · ${x.turns} échange${x.turns > 1 ? "s" : ""}${x.agent ? ` · sous-agent ${x.agent}` : ""}`,
|
2026-10-12 23:23:59 +00:00
|
|
|
})),
|
|
|
|
|
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();
|
2026-10-18 23:02:44 +00:00
|
|
|
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 {
|
2026-10-19 23:45:23 +00:00
|
|
|
session = await startSession(modelSpec, { startContext: saved.startContext, quiet: true });
|
2026-10-18 23:02:44 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
ui.error(`Chargement impossible : ${(err as Error).message}`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-12 23:23:59 +00:00
|
|
|
session.messages.length = 0;
|
|
|
|
|
session.messages.push(...saved.messages);
|
|
|
|
|
session.pendingContext.length = 0;
|
|
|
|
|
session.pendingContext.push(...saved.pendingContext);
|
|
|
|
|
sessions.restore(saved);
|
2026-10-19 23:45:23 +00:00
|
|
|
showContext(session);
|
2026-10-14 23:12:15 +00:00
|
|
|
ui.info(`Session chargée : ${saved.name} (${saved.messages.length} messages · modèle ${modelSpec})`);
|
2026-10-12 23:23:59 +00:00
|
|
|
if (session.pendingContext.length > 0) {
|
|
|
|
|
ui.info(`Contexte en attente (commandes « ! ») : ${session.pendingContext.length} élément(s), joint(s) à votre prochaine demande.`);
|
|
|
|
|
}
|
2026-10-18 23:02:44 +00:00
|
|
|
if (saved.startContext) ui.info(`Session compactée, contexte de départ :\n${saved.startContext}`);
|
2026-10-12 23:23:59 +00:00
|
|
|
replayMessages(ui, session, saved.messages);
|
|
|
|
|
ui.info("— fin de la session rechargée, vous pouvez continuer —");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-10-11 00:11:20 +00:00
|
|
|
if (name === "clear") {
|
2026-10-15 20:43:50 +00:00
|
|
|
// 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}`);
|
|
|
|
|
}
|
2026-10-11 00:11:20 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-10-18 23:02:44 +00:00
|
|
|
if (name === "compact") {
|
|
|
|
|
await compactNow();
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-10-11 00:11:20 +00:00
|
|
|
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 {
|
2026-10-14 23:12:15 +00:00
|
|
|
session.provider = createProvider(choice, settings);
|
2026-10-11 00:11:20 +00:00
|
|
|
modelSpec = choice;
|
|
|
|
|
ui.info(`Modèle actif : ${modelSpec}`);
|
2026-10-15 22:54:08 +00:00
|
|
|
await resolveContextWindow(modelSpec);
|
2026-10-11 00:11:20 +00:00
|
|
|
} 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;
|
|
|
|
|
}
|
2026-10-12 07:16:26 +00:00
|
|
|
prompt = custom.prompt(args);
|
2026-10-11 00:11:20 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-18 23:02:44 +00:00
|
|
|
// Contexte trop proche de la limite : compactage (bloquant) avant d'envoyer la demande
|
|
|
|
|
await autoCompactIfNeeded();
|
|
|
|
|
|
2026-10-11 22:41:17 +00:00
|
|
|
// Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation
|
|
|
|
|
const mark = session.messages.length;
|
2026-10-12 07:16:26 +00:00
|
|
|
const contextBackup = [...session.pendingContext];
|
2026-10-12 23:23:59 +00:00
|
|
|
|
|
|
|
|
// 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(", ")}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-11 22:41:17 +00:00
|
|
|
const controller = new AbortController();
|
|
|
|
|
ui.setInterruptHandler(() => controller.abort());
|
2026-10-11 00:11:20 +00:00
|
|
|
try {
|
2026-10-11 22:41:17 +00:00
|
|
|
await runTurn(session, prompt, controller.signal);
|
2026-10-11 00:11:20 +00:00
|
|
|
} catch (err) {
|
2026-10-11 22:41:17 +00:00
|
|
|
if (controller.signal.aborted) {
|
|
|
|
|
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
|
2026-10-12 07:16:26 +00:00
|
|
|
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
|
2026-10-19 23:45:23 +00:00
|
|
|
showContext(session);
|
2026-10-11 22:41:17 +00:00
|
|
|
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
|
|
|
|
|
} else {
|
|
|
|
|
ui.error((err as Error).message);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
ui.setInterruptHandler(null);
|
2026-10-11 00:11:20 +00:00
|
|
|
}
|
2026-10-18 23:02:44 +00:00
|
|
|
// Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande
|
|
|
|
|
await autoCompactIfNeeded();
|
2026-10-11 00:11:20 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-12 23:23:59 +00:00
|
|
|
const finalName = saveSession();
|
|
|
|
|
if (finalName) ui.info(`Session sauvegardée : ${finalName}`);
|
2026-10-11 00:11:20 +00:00
|
|
|
ui.outro("À bientôt");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
main().catch((err) => {
|
|
|
|
|
console.error(err);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
});
|