MagicCode/SPECS_TECH.md

698 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# MAIA — Spécifications techniques
Document de référence des fonctionnalités **implémentées** à ce jour. Il décrit le comportement attendu, les contrats entre modules, les formats de fichiers et les constantes. Le `README.md` reste le guide d'utilisation ; ce document est destiné à qui développe ou maintient MAIA.
> État de vérification : les suites vitest, le typecheck, Ink et un vrai serveur LiteLLM n'ont jamais été exécutés dans l'environnement de développement (voir § 19). Les comportements décrits ici sont ceux du code ; ils sont couverts par des tests écrits mais à lancer après `pnpm install`.
---
## 1. Objet et périmètre
MAIA est un assistant IA en ligne de commande (CLI) :
- **un seul agent** (MAIA) ; il n'y a ni mode ni extension ;
- **accès aux LLM uniquement via LiteLLM**, au format OpenAI (`chat/completions`, streaming, appels d'outils) ;
- interface de terminal **Ink** (React) ;
- l'agent lit, cherche, modifie des fichiers et exécute des commandes dans le **dossier de lancement** grâce à des outils, soumis à des permissions ;
- sessions persistantes, skills, outils personnalisés, exécution parallèle des outils, compactage du contexte.
Hors périmètre actuel : hooks, slash-commandes personnalisées (code présent mais non appelé), autocomplétion des chemins `@`, listes à choix multiples (voir § 18).
## 2. Pile technique
| Élément | Choix |
| --- | --- |
| Langage | TypeScript 7 (`typescript` ^7.0.2), mode `strict` + `noUncheckedIndexedAccess`, ESM (`"type": "module"`) |
| Exécution | Node ≥ 20 (`engines`), `tsx` en développement |
| Gestionnaire de paquets | `pnpm@12.14.0` (champ `packageManager`), workspaces `packages/*` |
| Build | `tsup` ^9 (un seul bundle ESM, cible `node20`, shebang `#!/usr/bin/env node`) |
| Tests | `vitest` ^5.0.5 (`vitest run` à la racine) |
| Interface | `ink` ^8.0.0, `react` ^19.3.1, `@inkjs/ui` ^2 (badges), `cli-spinners` ^3.4 |
| Markdown | `markdansi` ^0.3.7 (isolé dans un composant) |
| Comptage de tokens | `js-tiktoken` ^1.0.21, vocabulaire `o200k_base` (`js-tiktoken/lite`) |
| HTML → Markdown | `turndown` ^7.2.4 |
| Fichiers | `ignore` ^7 (`.gitignore`), `picomatch` ^4 (motifs glob) |
`tsconfig.base.json` : `target ES2022`, `module ESNext`, `moduleResolution Bundler`, `jsx react-jsx`, `noEmit`.
## 3. Architecture
### 3.1 Paquets
| Paquet | Rôle | Dépend de |
| --- | --- | --- |
| `@maia/core` | Contrats (`LLMProvider`, `Tool`, `AgentUI`…), boucle agent, exécuteur d'outils, sessions, commande `!`, références `@`, invocation `§`, comptage de contexte, compactage, résumé final de sous-agent | — (+ `js-tiktoken`) |
| `@maia/providers` | Adaptateur LiteLLM (format OpenAI, SSE), liste des modèles, `settings.json` utilisateur | `core` |
| `@maia/ui` | Interface Ink : store sans React, composants, bannière, barre de contexte, rendu markdown | `core` |
| `@maia/agent` | L'agent MAIA : initialisation, `MAIA.md`/`CLAUDE.md`, skills, outils intégrés, outils personnalisés, permissions, définitions et outil des sous-agents | `core` |
| `@maia/cli` | Point d'entrée : arguments, configuration, boucle de conversation, commandes `/…` | tous |
Règle de dépendance : **`core` n'importe jamais `ui`, `providers` ni `agent`**. L'interface est injectée sous forme du contrat `AgentUI`.
### 3.2 Flux général
```
saisie ─► cli (commandes / ! @ §) ─► runTurn(core)
│ ┌───────────────────────────┐
├─►│ provider.chat (LiteLLM) │ bloquant
│ └───────────────────────────┘
└─► runToolCalls (1 = avant-plan, ≥ 2 = parallèle)
└─► Tool.execute (agent)
AgentUI (ui) ◄── événements : texte, appels, résultats, contexte
```
## 4. Contrats (`packages/core/src/types.ts`)
### 4.1 Messages (format neutre, indépendant des providers)
```ts
type ContentBlock =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
| { type: "tool_result"; toolUseId: string; content: string; isError?: boolean };
interface Message { role: "user" | "assistant"; content: ContentBlock[] }
```
### 4.2 `LLMProvider`
`{ id, model, capabilities: { tools }, chat(req, signal?) → AsyncIterable<StreamEvent> }`.
`ChatRequest` = `{ system, messages, tools: ToolSpec[], maxTokens? }`. `StreamEvent` :
| Événement | Sens |
| --- | --- |
| `text_delta { text }` | morceau de texte de la réponse |
| `tool_use { id, name, input }` | appel d'outil complet (émis à la fin du flux) |
| `usage { inputTokens, outputTokens }` | consommation renvoyée par le serveur (ignorée pour la barre de contexte, voir § 13) |
| `done { stopReason }` | `end_turn` · `tool_use` · `max_tokens` · `other` |
### 4.3 `Tool`
```ts
interface Tool {
spec: { name; description; inputSchema /* JSON Schema */ };
permission: "auto" | "ask"; // permission par défaut de l'outil
describe?(input): string; // résumé court (UI, confirmation)
lockKey?(input, { cwd }): string | undefined; // exclusion mutuelle (voir § 6.3)
execute(input, ctx: ToolContext): Promise<string>; // ctx = { cwd, model?, signal?, env? }
}
```
Un outil **renvoie une chaîne** ; une exception est convertie en résultat d'erreur (`isError: true`) par l'exécuteur.
### 4.4 `AgentUI`
`onThinking(bool)`, `onText`, `onTextEnd`, `onToolCall(name, summary)`, `onToolResult(name, output, isError)`, `confirm(message) → Promise<boolean>`, et les optionnels `onContextUsage?(tokens)` et `onToolsRunning?({ names, done } | null)`.
### 4.5 Initialisation de l'agent
`InitContext` = `{ cwd, homeDir, log, warn, startContext? }`. `ModeInitResult` = `{ systemPrompt, tools, commands?, skills?, permissions?, permissionSources?, env? }`.
`AgentSkill` = `{ name, description, invoke(args) → string | Promise<string> }`.
## 5. Démarrage et configuration (`packages/cli`)
### 5.1 Ligne de commande
`maia [--model <spec>] [-h|--help]`. Un **TTY** est obligatoire (sinon message d'erreur, code de sortie 1).
### 5.2 Dossier de lancement
Base de **tous** les chemins relatifs (outils, `MAIA.md`, `.maia/…`, permissions). Résolu une fois au démarrage : `MAIA_CWD` > `INIT_CWD` (pnpm/npm) > `process.cwd()`. Il est affiché (« Dossier de travail : … »).
### 5.3 Résolution du modèle
Priorité : option `--model` > variable `MAIA_MODEL` > `./.maia/config.json` > `~/.maia/config.json` (propriété `model`). **Pas de défaut** : sans valeur, la liste des modèles LiteLLM est proposée. Forme acceptée : `litellm:<nom>` ou `<nom>` (le nom peut contenir `:` ou `/`).
### 5.4 Séquence de démarrage
1. Lecture de `~/.maia/settings.json` (voir § 7.3) ; erreur explicite avec exemple si absent/invalide, arrêt du programme.
2. Résolution de la configuration et du modèle (choix interactif si nécessaire).
3. Création de `./.maia` et `./.maia/.sessions` (échec → sessions désactivées, avertissement).
4. `initializeAgent` (contexte, skills, outils, permissions), création de la session d'agent.
5. Question « taille maximale du contexte » au LLM (§ 13.2).
6. Boucle de conversation (§ 5.5).
### 5.5 Boucle de conversation
À chaque itération : **comptage du contexte et mise à jour de la barre** → saisie (`readInput`) → traitement dans cet ordre :
1. `!commande` (§ 11.2) ;
2. `§skill` (§ 11.3) ;
3. commandes `/…` (§ 5.6) ;
4. sinon : prompt libre. Les références `@fichier` de la saisie sont résolues et ajoutées au contexte en attente (§ 11.4) ;
5. compactage automatique éventuel (§ 14.3) ;
6. `runTurn` avec un `AbortController` ; **Ctrl+C** interrompt la requête, restaure `messages` et `pendingContext` d'avant la demande et recompte le contexte ;
7. compactage automatique éventuel après le tour.
Sortie : `/exit`, Ctrl+D (saisie vide), Ctrl+C (saisie), SIGTERM, SIGHUP. La session est sauvegardée (écriture synchrone sur `exit`).
### 5.6 Commandes slash
| Commande | Effet |
| --- | --- |
| `/help` | aide (commandes, skills) |
| `/permissions` | règles chargées, par fichier (badges ALLOW / ASK / DENY) |
| `/model` | recharge la liste, change de modèle, conversation conservée ; redemande la taille de contexte |
| `/models` | affiche les modèles de LiteLLM (`listLiteLLMModels`, requête à chaque appel) via `formatModelList` (`cli/src/models-list.ts`) : un par ligne, actif marqué ●, URL interrogée ; erreur réseau/HTTP affichée avec le chemin du settings.json ; ne change rien |
| `/clear` | nouvel agent : MAIA.md / CLAUDE.md, skills, outils et permissions rechargés ; messages supprimés ; la session garde son nom |
| `/compact` | compactage (§ 14) |
| `/rename <nom>` | renomme la session courante |
| `/load [nom]` | recharge une session (liste de choix si sans argument) |
| `/delete` | supprime une ou plusieurs sessions du disque : sélection multiple (Espace coche/décoche, Suppr valide), confirmation, session courante exclue |
| `/new` | nouvelle session (nouveau contexte), l'ancienne est sauvegardée |
| `/exit` | quitte |
Toute autre commande : « Commande inconnue ». (Les slash-commandes personnalisées d'un agent sont prévues par le contrat `SlashCommand` mais l'agent MAIA n'en fournit plus.)
## 6. Boucle agent et exécution des outils (`core/src/agent.ts`, `tool-runner.ts`)
### 6.1 `AgentSession`
`{ provider, system, tools: Map<string, Tool>, commands, skills, permissions?, permissionSources, env?, messages, startContext?, pendingContext: string[], ui, cwd }`.
### 6.2 `runTurn(session, userText, signal?)`
1. Le `pendingContext` est vidé et **joint** au texte utilisateur (`contexte\n\ntexte`) en **un seul message** `user` (certains providers exigent l'alternance user/assistant).
2. Boucle d'au plus **50 itérations** par série (`MAX_ITERATIONS`, réglable par `MaxIterations`) :
- appel `provider.chat({ system, messages, tools })` (**bloquant** : le raisonnement du LLM n'est jamais mis en tâche de fond, pour préserver l'intégrité du contexte) ;
- le texte est diffusé via `ui.onText` ; le message `assistant` (texte + `tool_use`) est ajouté ; `ui.onContextUsage(sessionContextTokens(session))` ;
- sans appel d'outil → fin du tour ; sinon `runToolCalls`, ajout d'un message `user` portant les `tool_result`, itération suivante.
3. À la limite : si l'appelant fournit `onLimit` (le CLI pose la question « Continuer ? (o/n) »), `true` ajoute une série d'itérations ; sinon (ou refus) message d'erreur « Arrêt : plus de N itérations » et retour `"max_iterations"`. Une demande suivante est fusionnée dans le dernier message « utilisateur » (résultats d'outils) pour respecter l'alternance ; si Ctrl+C l'annule, le CLI restaure ce message.
4. **Stop** : `AgentUI.ask?(message): Promise<"yes"|"no"|"stop">` (repli sur `confirm`). Réponse « stop » à une confirmation d'outil → `prepare` renvoie `stopped` ; `runToolCallsDetailed` (`runToolCalls` en est la version qui ne renvoie que les résultats) fournit un résultat par appel (l'API exige un `tool_result` pour chaque `tool_use`) : `STOP_FOR_MODEL` pour l'appel visé, `NOT_RUN_FOR_MODEL` pour les suivants ; en parallèle, les outils déjà lancés finissent. `runTurn` pousse ces résultats puis retourne `"stopped"` **sans rappeler le LLM et sans rien annuler** ; la saisie suivante est fusionnée dans ce dernier message utilisateur (même mécanisme que la limite d'itérations). Le CLI affiche « Itération interrompue (stop) » ; Ctrl+C reste un rollback.
Les événements `usage` du provider sont ignorés par le cœur.
### 6.3 `runToolCalls` (exécuteur)
Les résultats sont **toujours renvoyés au LLM dans l'ordre des appels**.
**1 appel (ou 0) — avant-plan.** Séquence : annonce (`onToolCall`) → permission → confirmation éventuelle → exécution → `onToolResult`.
**2 appels ou plus — parallèle.**
1. *Phase 1 (séquentielle)* : pour chaque appel, autorisation (outil connu, règles de permissions, confirmation utilisateur **une par une**) ; chaque outil autorisé **démarre immédiatement** en arrière-plan.
2. *Phase 2* : `ui.onToolsRunning({ names, done })` à chaque fin d'outil, puis attente de **toutes** les tâches (`Promise.all`), puis `onToolsRunning(null)`.
3. *Phase 3* : affichage (`onToolCall` + `onToolResult`) et construction des `tool_result`, dans l'ordre des appels.
Règles :
- une exception, un refus ou un outil inconnu **n'annule pas** les autres outils ;
- au plus **8 outils simultanés** (`MAX_PARALLEL_TOOLS`, file FIFO `Limiter`) ;
- `Tool.lockKey` : deux appels de même clé s'exécutent **l'un après l'autre dans l'ordre demandé** (`KeyedQueue`). Clés actuelles : `file:<chemin absolu>` pour Read, Write, Edit ; `todowrite` pour TodoWrite. Une clé qui lève une exception est ignorée ;
- ce sont des **tâches asynchrones Node**, pas des threads du système (les processus enfants lancés par Bash ou les outils personnalisés sont, eux, réellement parallèles).
**Autorisation d'un appel** (`prepare`) :
| Cas | Résultat renvoyé au LLM |
| --- | --- |
| Outil inconnu | `Outil inconnu : <nom>` (erreur) |
| Règle `deny` | `Action refusée par les règles de permissions (…). Ne réessaie pas telle quelle.` |
| `ask` (règle) ou, sans règle applicable, permission `ask` de l'outil, et refus utilisateur | `L'utilisateur a refusé cette action.` |
## 7. Accès aux LLM : LiteLLM (`packages/providers`)
### 7.1 Requête
`POST {LiteLLM_URL}{OpenAI_EndPoint}` (jointure sans doubler ni oublier le `/`), en-têtes `Content-Type: application/json`, `Accept: text/event-stream`, `Authorization: Bearer <clé>` si une clé est définie.
Corps (`buildChatBody`) : `{ model, messages, stream: true, stream_options: { include_usage: true }, [max_tokens], [tools] }`.
Correspondance du format neutre vers OpenAI :
| Format MAIA | Format OpenAI |
| --- | --- |
| `system` | message `role: "system"` en tête |
| `assistant` texte + `tool_use` | `role: "assistant"`, `content` (ou `null`), `tool_calls[{ id, type: "function", function: { name, arguments: JSON } }]` |
| `user` avec `tool_result` | un message `role: "tool"` (`tool_call_id`) **par résultat**, placés **avant** le texte utilisateur éventuel (`role: "user"`) |
| `ToolSpec` | `tools[{ type: "function", function: { name, description, parameters } }]` |
### 7.2 Réponse (SSE)
`parseSse` traite les lignes `data:` ; `[DONE]` et le JSON invalide sont ignorés ; un champ `error` lève `Erreur du serveur LiteLLM : …`. Le texte est émis au fil de l'eau ; les appels d'outils sont **accumulés par `index`** (nom et arguments concaténés) et émis **à la fin**, triés par index (arguments JSON invalides → objet vide, l'outil renverra une erreur explicite ; identifiant manquant → `call_N`). Le morceau `usage` (liste `choices` vide) devient l'événement `usage` (les tokens de raisonnement sont retirés de `outputTokens`).
`stopReason` : `tool_calls`/`function_call` → `tool_use` ; `length` → `max_tokens` ; `stop` → `tool_use` si des appels existent, sinon `end_turn` ; autre → `tool_use` ou `other`.
Assemblage tolérant : un fragment d'arguments **sans identifiant ni nom** sur un index inconnu est rattaché à l'appel précédent ; un **nouvel identifiant** sur un index déjà pris crée un nouvel appel (comportements observés chez certains fournisseurs derrière LiteLLM). Si le serveur termine par `length`, ou si un appel d'outil arrive avec une sortie de **exactement** 1024/2048/4096/8192/16384/32768/65536 tokens (serveur qui « répare » l'appel coupé et annonce `tool_calls`), le cœur avertit l'utilisateur (« Réponse coupée par la limite de tokens de sortie… »). Diagnostic : `MAIA_DEBUG_LLM=1` (ou un chemin de fichier) enregistre le flux SSE brut (défaut : `maia-llm-debug.log` dans le dossier temporaire).
Erreurs : serveur injoignable (`LiteLLM injoignable (url) : …`), HTTP non 2xx (500 premiers caractères du corps), réponse sans corps. Une annulation (`AbortSignal`) est relancée telle quelle.
### 7.3 `~/.maia/settings.json`
| Clé | Rôle |
| --- | --- |
| `LiteLLM_URL` (obligatoire) | URL du serveur |
| `OpenAI_EndPoint` (obligatoire) | suffixe de la requête de chat, ex. `/v1/chat/completions` |
| `LiteLLM_Models` (obligatoire) | suffixe de la liste des modèles, ex. `/v1/models` |
| `LiteLLM_API_Key` (facultatif) | clé API ; sinon variable `LITELLM_API_KEY` |
| `MaxIterations` (facultatif) | entier positif : itérations par série avant arrêt / question « Continuer ? » (défaut 50) |
| `MaxOutputTokens` (facultatif) | entier positif : `max_tokens` envoyé à chaque requête (sauf si la requête fixe sa propre valeur, ex. le résumé de compactage à 4000) |
Ce fichier est dans le dossier de l'**utilisateur** (pas celui de lancement) ; il peut contenir un secret. La même propriété `permissions` y est lue (§ 9).
### 7.4 Liste des modèles
`GET {LiteLLM_URL}{LiteLLM_Models}` ; les noms sont lus dans `data[].id` (sinon `model_name`), dédoublonnés et triés ; valeur de choix `litellm:<nom>`. Les erreurs sont non bloquantes (avertissement) ; sans modèle disponible, message d'erreur.
## 8. Outils intégrés (`packages/agent/src/tools`)
Ordre de déclaration : Read, Glob, Grep, Write, Edit, Bash, TodoWrite, WebFetch, CurrentModel, puis ListModels (si l'application fournit `listModels` dans le contexte d'initialisation : session principale, sous-agents, agent parent des workflows via `extraTools`). Les chemins relatifs sont résolus depuis le dossier de lancement ; `~` et `~/…` désignent le dossier personnel ; un chemin complet est utilisé tel quel.
| Outil | Entrée | Comportement | Permission par défaut | `lockKey` |
| --- | --- | --- | --- | --- |
| **Read** | `path`, `offset?` (1), `limit?` (2000) | contenu avec numéros de ligne (6 colonnes + tabulation) | auto | fichier |
| **Write** | `path`, `content` | crée ou écrase le fichier (crée les dossiers parents) | ask | fichier |
| **Edit** | `path`, `old_string`, `new_string` | remplacement unique : erreur si `old_string` est absent ou présent plusieurs fois | ask | fichier |
| **Glob** | `pattern`, `path?` | fichiers correspondants, **100 résultats maximum**, JavaScript pur | auto | — |
| **Grep** | `pattern` (regex JS), `path?`, `glob?`, `output_mode?` (`files_with_matches` · `content` · `count`), `-i`, `-n`, `-A`, `-B`, `-C`, `head_limit?` (250 ; 0 = illimité) | ignore `.git`, `node_modules`, fichiers du `.gitignore`, binaires ; fichiers > 2 Mo ignorés ; lignes coupées à 500 caractères | auto | — |
| **Bash** | `command` | exécute via `runShell` dans le dossier de lancement avec l'environnement de l'agent ; timeout **2 min** ; la commande tourne dans son propre groupe de processus : timeout et Ctrl+C arrêtent aussi ses sous-processus (SIGTERM puis SIGKILL après 1 s) ; stdout et stderr fusionnés dans l'ordre d'arrivée ; sortie coupée à **30 000** caractères ; dernière ligne `[code de sortie : N]`, `[délai dépassé (120 s)]`, `[interrompu (Ctrl+C)]` ou `[échec du lancement de la commande]` | ask | — |
| **TodoWrite** | liste de tâches (`pending` · `in_progress` · `completed`, forme `activeForm`) | remplace la liste à chaque appel ; rendu ☐ ▶ ☑ ; avertissement si plusieurs tâches « en cours » | auto | `todowrite` |
| **WebFetch** | `url`, `max_length?` | voir ci-dessous | ask | — |
| **CurrentModel** | aucune | renvoie le modèle LLM actif (lu à l'appel) | auto | — |
| **ListModels** | aucune | modèles disponibles auprès de LiteLLM (`listLiteLLMModels`, requête à chaque appel), modèle de la session signalé ; erreur réseau renvoyée au modèle ; nom réservé (outil personnalisé homonyme ignoré) | auto | — |
**WebFetch.** URL http(s) uniquement. HTML → Markdown via `turndown` (titres `atx`, blocs de code `fenced`, puces `-`, listes à puce « - a » et numérotées « 1. a » par règle `listItem` personnalisée ; balises `head`, `script`, `style`, `noscript`, `iframe`, `svg`, `template` supprimées) ; JSON mis en forme ; texte brut. Limites : **5 Mo**, **30 s**, 5 redirections, `max_length` par défaut **20 000** caractères (plafond **100 000**). Une redirection vers un **autre domaine** n'est pas suivie : l'URL est renvoyée au modèle, qui doit la rappeler (nouvelle confirmation). L'en-tête de résultat contient l'URL, le type, le titre et la mention « Contenu externe non fiable… ». Aucun JavaScript exécuté, ni PDF, ni images.
Pour toute entrée invalide, l'outil lève une exception renvoyée comme erreur au modèle, qui liste les paramètres reçus (ex. `Paramètre "content" manquant ou invalide (…). Paramètres reçus : path (string).`). **Write** accepte aussi `contents`, `text`, `file_text`, `file_content`, `data` ou `body` à la place de `content`, et un tableau de chaînes (joint par des sauts de ligne).
## 9. Permissions (`agent/src/permissions.ts`, `settings.ts`)
### 9.1 Sources
`~/.maia/settings.json` (utilisateur) et `./.maia/settings.json` (dossier de lancement) — **pas** le dossier parent. Propriété `permissions` : `{ allow?: string[], ask?: string[], deny?: string[] }`. Les listes des deux fichiers s'additionnent. JSON invalide ou règle mal formée (`Outil` ou `Outil(motif)`, `Outil` = `[A-Za-z0-9_.-]+`) : avertissement, élément ignoré. Les dossiers `.claude` ne sont plus lus.
### 9.2 Évaluation
**deny, puis ask, puis allow** : la première famille qui correspond décide, quelle que soit la précision des règles. **Sans règle applicable : `ask`**, pour tous les outils (y compris ceux dont la permission par défaut est `auto`). Le verdict de la politique **remplace** la permission par défaut de l'outil quand il existe.
### 9.3 Motifs par outil
| Outil | Le motif porte sur |
| --- | --- |
| `Bash` | la commande : `*` = suite quelconque ; `cmd *` couvre `cmd` avec ou sans arguments (pas `cmdx`) ; sans `*` : commande exacte |
| `Read`, `Write`, `Edit`, `Glob`, `Grep` | le chemin (relatif au dossier de lancement, motifs glob) |
| `WebFetch` | `domain:hôte` exact ou avec `*` (le domaine nu ne couvre pas ses sous-domaines) |
| `Skill` | le nom du skill |
| autres outils (dont personnalisés) | une valeur texte de l'entrée (`*` accepté) |
### 9.4 Sécurité des commandes Bash composées
- `deny` et `ask` s'appliquent dès qu'**une** partie de la commande correspond (`&&`, `;`, `|`, substitutions `$(…)`, backticks, parenthèses, redirections — découpe fine) ;
- `allow` ne s'applique que si **toutes** les parties sont couvertes ;
- un `allow` ne couvre **jamais** une commande avec substitution (`$(…)`, backticks, `<(…)`, `>(…)`) : elle repasse en `ask` ;
- **Redirections de sortie** : `extractRedirections` (guillemets et `\\` respectés ; préfixes `2`, `&` ; `>>`, `>|`, `>&fichier` ; `2>&1`, `>&2`, `>&-` = duplications sans fichier) donne la commande sans redirections et les fichiers visés. Règles `Redirect` (sans motif : toutes ; `Redirect(chemin)` : motif de chemin relatif au dossier de lancement, comme `Write`). `allow` : la commande dépurée doit être couverte (règles `Bash`) ET chaque cible couverte par un `Redirect` allow ; une cible dynamique (`$ ` * ? [ ] { }`) n'est couverte que par `Redirect` sans motif ; analyse incertaine (guillemet non fermé, cible absente, `>(`) → `ask`. `deny` / `ask` : une cible qui correspond à un `Redirect(motif)` suffit, quelle que soit la commande autorisée. Sans règle `Redirect` : toute redirection vers un fichier repasse en `ask`.
Les `deny` sont du « meilleur effort », pas un bac à sable. Les commandes `!` et les références `@` saisies par l'utilisateur **ne passent pas** par ces règles.
Au démarrage : message INFO (fichiers chargés, nombre de règles par fichier) ou « Aucune règle de permission définie : chaque utilisation d'un outil demande une confirmation ».
## 10. Outils personnalisés (`agent/src/custom-tools.ts`)
### 10.1 Chargement
`./.maia/tools/<dossier>/TOOL.json` (`tool.json` accepté) et `~/.maia/tools/<dossier>/TOOL.json` (utilisateur) — pas le dossier parent ; à nom égal, l'outil du projet l'emporte (celui de l'utilisateur est ignoré sans avertissement). Chargement alphabétique au démarrage, rechargé par `/clear` et `/new`. Le journal liste les outils valides sous « Outils personnalisés chargés : » (un par ligne).
### 10.2 Définition
```json
{ "name": "Addition", "description": "…",
"command": { "parameters": [ { "name": "param1", "type": "number", "default": "0", "description": "…" } ],
"run": "node ./test.js {{param1}}" } }
```
| Champ | Règle |
| --- | --- |
| `description` | **obligatoire** |
| `name` | facultatif (nom du dossier par défaut) ; `^[A-Za-z0-9_-]{1,64}$` ; ne peut pas reprendre un nom intégré (Read, Bash… et `Skill`) ni un doublon |
| `parameters[].name` | identifiant `^[A-Za-z_][A-Za-z0-9_]*$` |
| `parameters[].type` | `"number"` ou `"string"` |
| `parameters[].default` | valeur utilisée si le LLM n'en fournit pas ; sans `default`, le paramètre est **obligatoire** |
| `run` | ligne de commande ; `{{param}}` remplacé par la valeur ; `./` = dossier de l'outil, qui est aussi le dossier d'exécution |
Le `inputSchema` exposé au LLM est dérivé des paramètres (`number` → `number`, `string` → `string`). Une virgule en trop avant `]` ou `}` est tolérée. Une définition invalide (description absente, type inconnu, `{{x}}` non déclaré, JSON cassé…) donne un **WARN** et l'outil est ignoré, les autres restent chargés.
### 10.3 Exécution
- **Sans shell** (`execFile`) : la ligne `run` est découpée en arguments (guillemets simples ou doubles) ; chaque valeur envoyée par le LLM reste **un seul argument** (aucune injection : `; rm -rf /` ou `$(cmd)` sont reçus littéralement) ;
- le programme (premier mot) ne peut pas être un `{{paramètre}}` ; pas de tubes, redirections ni variables (appeler un script `sh ./script.sh {{p}}` pour cela) ; sous Windows, les `.cmd`/`.bat` ne sont pas lancés directement ;
- timeout **2 min**, sortie limitée à **30 000** caractères ; code de sortie non nul → erreur renvoyée au LLM avec la sortie d'erreur du script ;
- variables d'environnement ajoutées : `MAIA_CWD` (dossier de lancement) et `MAIA_TOOL_DIR` ;
- permission **`ask`** par défaut ; les règles de permissions s'appliquent avec le nom exact de l'outil (`"Addition"`, `"Addition(5)"`) ;
- les outils personnalisés font partie des outils du contexte (ils sont dans `tools` envoyé au LLM et comptés dans le contexte).
Exemple fourni : `examples/tools/addition/` (`TOOL.json` + `test.js`).
## 11. Contexte, skills, commandes utilisateur
### 11.1 Contexte de départ (`agent/src/memory.ts`, `init.ts`)
`MAIA.md` dans le dossier de lancement, sinon `CLAUDE.md` ; seul ce dossier est regardé. INFO si chargé, WARN si aucun. Le contenu est ajouté au **prompt système** sous `# Instructions (<chemin>)`.
Prompt système (en français) : identité MAIA, aide à la programmation en CLI, réponses concises dans la langue de l'utilisateur, lire avant de modifier, préférer Glob/Grep à Bash, TodoWrite pour ≥ 3 étapes, contenu WebFetch non fiable, rappel du dossier de travail ; puis le contexte (`MAIA.md` / `CLAUDE.md` ou résumé compacté), puis la section des skills.
### 11.2 Commande `!` (`core/src/shell.ts`, `cli/src/user-shell.ts`)
| Saisie | Commande |
| --- | --- |
| `!ls -la` | tout ce qui suit `!` |
| `!'ls -la'` | texte entre guillemets simples |
| `!'ls' -la src` | texte entre guillemets + ce qui suit : `ls -la src` |
| `!'echo "l\'ami"'` | `\'` = guillemet simple littéral |
| `!'ls -la` | erreur (guillemet non fermé), rien d'exécuté |
| `!` | aide |
Exécution (`runShell`) : shell du système, dossier de lancement, environnement de l'agent, timeout **120 s**, capture limitée à 1 000 000 de caractères, stdout et stderr dans l'ordre d'arrivée. La commande tourne dans **son propre groupe de processus** (hors Windows) : timeout ou Ctrl+C arrêtent toute l'arborescence (`SIGTERM` puis `SIGKILL` après 1 s ; `taskkill /T /F` sous Windows). `runShell` ne rejette jamais ; le statut est décrit par `describeShellStatus` (interrompu, échec de lancement, délai dépassé, signal, code de sortie).
Sans confirmation (c'est l'utilisateur qui la saisit). Le résultat est affiché (aperçu jusqu'à 4000 caractères) et ajouté au **contexte en attente** (jusqu'à 30 000 caractères, milieu tronqué), joint à la prochaine demande. Les programmes interactifs ne sont pas pris en charge.
### 11.3 Skills et `§`
Emplacements : `~/.maia/skills`, `<parent>/.maia/skills`, `<lancement>/.maia/skills` ; le plus spécifique l'emporte en cas de même nom. Un skill = dossier `<nom>/SKILL.md` (ou `skill.md`) avec un en-tête YAML : `description` (obligatoire), `name` (facultatif, doit égaler le nom du dossier, qui fait foi).
Le prompt système contient la liste (nom + description) ; le contenu est chargé à la demande par l'outil `Skill` (créé s'il y a des skills), ou lancé directement par `§nom [arguments]` (contrat `AgentSkill.invoke`, `$ARGUMENTS` / `ARGUMENTS:`). `§` seul liste les skills ; un skill inconnu donne une erreur avec la liste des disponibles.
### 11.4 Références `@fichier` (`core/src/fileref.ts`)
Le contenu des fichiers cités est injecté dans le contexte de la demande (le texte de la saisie n'est pas modifié).
- Formes : `@chemin`, `@ chemin` (avec espace), `@'chemin avec espaces'` (`\'` = guillemet littéral), `@~/…`, `@/chemin/complet` ; un dossier injecte la liste de ses entrées (**200 maximum**).
- Un `@mot` qui ne désigne aucun fichier reste du texte (`@mention`, `jean@exemple.fr`) ; un avertissement n'apparaît que si le mot ressemble à un chemin.
- Limites : **2 Mo** par fichier, **50 000** caractères injectés par fichier (milieu tronqué), **150 000** par saisie ; binaires ignorés ; doublons injectés une fois.
- Le contenu est présenté au modèle comme une donnée, pas comme des instructions. Sans confirmation ni règle de permission.
- Les références ne sont résolues que pour la saisie de l'utilisateur (pas dans le corps d'un prompt de skill).
## 12. Sessions (`core/src/sessions.ts`)
### 12.1 Stockage
`./.maia/.sessions/<nom>.json`, créé avec `./.maia` au lancement. Écriture **atomique** (fichier temporaire `.tmp-<pid>` puis renommage). `.maia/` est ajouté au `.gitignore` du dépôt.
```ts
interface SavedSession {
version: 1; name: string; createdAt: string; updatedAt: string; // ISO 8601
model: string; cwd: string;
messages: Message[]; pendingContext: string[];
startContext?: string; // résumé compacté servant de contexte de départ (§ 14)
}
```
À la lecture, le **nom du fichier fait foi**, les champs manquants reçoivent des valeurs par défaut ; un fichier illisible ou au mauvais format lève une erreur claire (ignoré avec avertissement dans les listes).
### 12.2 Noms
Par défaut `session_AAAA-MM-JJ_HH_MM_SS` (heure locale). Un nom saisi est normalisé (espaces et guillemets externes retirés) et validé : non vide, ≤ 100 caractères, sans `\ / : * ? " < > |` ni caractère de contrôle, ne commence ni ne finit par `.`. Une session déjà existante sous un autre fichier fait ajouter un suffixe `_2`, `_3`… à l'enregistrement (jamais d'écrasement par inadvertance). Insensible à la casse pour la recherche (`find`) ; `suggest` propose des noms proches.
### 12.3 Cycle de vie
- sauvegarde à la sortie et avant tout changement de session ; **une session sans message ni `startContext` n'est jamais enregistrée** ; sauvegarde ignorée si rien n'a changé ;
- `/rename` : effectif à la sauvegarde, l'ancien fichier est supprimé (même session) ; changement de casse seul = renommage de fichier ;
- `/load` : sauvegarde la session quittée (message d'avertissement), recharge `messages` et `pendingContext`, **conserve le modèle courant**, reconstruit l'agent si `startContext` diffère, recompte le contexte et réaffiche l'historique (résultats d'outils tronqués à 600 caractères, messages utilisateur à 1500) ;
- `/delete` : `UIStore.multiSelect(message, choices, actionLabel)` (invite `kind: "multiselect"` ; Espace coche/décoche, `key.delete` valide si au moins une case est cochée, Échap annule) → `ui.confirm` → `SessionStore.remove(noms)` (`unlink` ; absent = supprimé ; autres erreurs listées sans interrompre). La session courante (`SessionManager.currentFile`) est exclue de la liste ; les fichiers illisibles y figurent. Pas de suppression en cascade (traces de sous-agents, `xxx__n`).
- `/new` : sauvegarde, nouvel agent (contexte rechargé), nouveau nom.
## 13. Taille du contexte et barre de progression
### 13.1 Comptage (`core/src/context.ts`)
`countContextTokens({ system, messages, tools?, pending? })` = somme de :
- le prompt système ;
- la définition des outils (`JSON.stringify(tools)`) ;
- pour chaque message : **4 tokens** de structure + le compte de chacun de ses blocs (texte ; `tool_use` = nom + arguments JSON ; `tool_result` = contenu) ;
- chaque texte du contexte en attente.
Tokenizer : `js-tiktoken/lite`, vocabulaire **`o200k_base`**, `encode(texte, [], [])` (un texte qui ressemble à un jeton spécial est compté comme du texte). Les comptes de blocs sont mémorisés (`WeakMap`) ; le prompt système et les outils ont une mémoire de la dernière valeur. Le calcul est **local, déterministe, et strictement additif** : ajouter un message ne peut qu'augmenter le total. La valeur ne diminue que si le contexte est réduit (`/clear`, `/compact`, tour interrompu, autre session). Pour les modèles qui n'utilisent pas ce vocabulaire, c'est une approximation.
`sessionContextTokens(session)` applique ce calcul à la session. Il est exécuté **avant chaque saisie**, après chaque réponse du LLM, au démarrage d'un agent et après chaque commande `!`. La consommation renvoyée par le serveur (`usage`, y compris `total_tokens`) n'est **jamais** utilisée pour la taille du contexte : seul js-tiktoken compte. Elle sert uniquement à repérer une réponse coupée par la limite de sortie.
### 13.2 Taille maximale
Après le choix d'un modèle (démarrage, `/model`), MAIA pose au LLM la question « Taille maximale du contexte pour ce modèle, en tokens ? Réponds uniquement par un nombre entier… » (un appel par modèle et par exécution, timeout **60 s**, Ctrl+C l'abandonne). `parseTokenCount` extrait la valeur (`128000`, `128 000 tokens`, `131,072`, `1.000.000`, `200k`, `1M`, `1,5 million`, `2 millions`) ; plage plausible **1 000 – 100 000 000** ; une valeur avec unité l'emporte sur une valeur plus grande sans unité ; sinon la plus grande. La valeur est annoncée (INFO). Réponse illisible, erreur ou délai : avertissement, **barre masquée**, et le compactage automatique est désactivé (`/compact` reste disponible).
### 13.3 Barre (`ui/src/context-model.ts`, `context-bar.tsx`)
Sous la zone de saisie, en bas à droite : carrés ■ de gauche à droite, **1 carré = 10 000 tokens** (200 000 → 20 carrés), libellé `45.7k / 200k · 23%`. Au moins 1 carré rempli dès que le contexte n'est pas vide. Couleurs : vert, **jaune dès 60 %**, **rouge dès 85 %** ; carrés vides gris. Si la barre dépasse la largeur du terminal, l'échelle grandit par paliers de 10 000 et est indiquée (`1■ = 20k`). Format des nombres (précision 100 tokens jusqu'au million, puis 1 000) : `950`, `12.3k`, `123.5k`, `200k`, `1.049M`. Pendant le flux d'une réponse, `runTurn` envoie une estimation en direct (contexte avant l'appel + texte reçu + appels d'outils reçus), au plus toutes les `LIVE_CONTEXT_INTERVAL_MS` (250 ms) ; la valeur exacte suit à la fin de la réponse. Elle ne recule jamais.
Pendant un tour, `runTurn` appelle `onContextUsage` après l'ajout de la demande, après chaque réponse du LLM et après chaque série de résultats d'outils ; la barre est affichée en permanence en bas de l'écran (saisie, itérations, outils, confirmations, listes) ; taille maximale inconnue : « N tokens · taille maximale inconnue », sans carrés.
## 14. Compactage (`core/src/compact.ts` + `cli/src/index.ts`)
### 14.1 Principe
Résumer toute la conversation, vider le contexte comme `/clear`, et utiliser le résumé **à la place** de `MAIA.md` / `CLAUDE.md` comme contexte de départ. L'appel est **bloquant** (pas de tâche de fond).
### 14.2 Déroulement (`compactNow`)
1. Rien à compacter (aucun message ni contexte en attente) → information, fin.
2. La session courante est sauvegardée telle quelle sous son nom.
3. `requestCompactSummary` : le LLM reçoit la conversation, plus un dernier message `user` = contexte en attente + prompt de résumé :
```
Faire un résumé compacté de nos échanges. Retourne le résultat sous le format suivant :
## Contexte du projet
- [Description brève du projet]
## Objectif actuel
- [Ce qu'on cherche à faire]
## Fichiers pertinents
- [Liste des fichiers importants]
## Décisions/Conclusions
- [Points clés établis]
## Prochaines étapes
- [Ce qui reste à faire]
```
Si le dernier message de la conversation est déjà `user`, le prompt lui est ajouté (alternance respectée). Le prompt système reçoit « n'appelle aucun outil » ; les outils restent déclarés (l'historique contient des appels d'outils) mais les `tool_use` reçus sont ignorés. `maxTokens` = **4000**. Un résumé vide → erreur ; arrêt par `max_tokens` → avertissement « résumé peut être incomplet ».
4. Nouvel agent (`startSession` avec `startContext = résumé`) : prompt système sans `MAIA.md`/`CLAUDE.md`, section « Contexte de départ : résumé compacté de la conversation précédente », outils/skills/permissions rechargés, `messages` vides, `pendingContext` vide.
5. Persistance (§ 14.4), puis affichage du résumé et du nouveau nom ; le contexte est recompté et la barre mise à jour.
En cas d'erreur ou de Ctrl+C **avant** le remplacement, la conversation est inchangée.
### 14.3 Déclenchement
- **Manuel** : `/compact`.
- **Automatique** : `Seuil_max = Max_Token_Context − (4000 + 500)` (4000 = tokens du résumé, 500 = marge d'erreur). Déclenché quand `contexte > Seuil_max` (strictement), si le maximum est connu et si le seuil est positif. Vérifié **avant l'envoi de chaque demande et après chaque tour** (pas au milieu d'un tour : un tour très long avec de nombreux outils peut dépasser le seuil avant d'être compacté). Un échec affiche un avertissement et la conversation continue.
### 14.4 Nom et persistance
`nextCompactedName(nom)` : `xxx` → `xxx__1` → `xxx__2` → … → `xxx__1000` → **`xxx__1`** (le fichier existant est écrasé). Un suffixe n'est un numéro de compactage que si `1 ≤ n ≤ 1000` (`xxx__0`, `xxx__1001`, `a__b` → suffixe ajouté). L'origine est tronquée si le nom dépasserait 100 caractères. La numérotation découle du nom de la session courante (renommer `xxx__2` en `foo` donne `foo__1`).
Le fichier d'avant compactage reste intact. Le fichier `xxx__n` contient `startContext` (le résumé) et la suite de la conversation ; il est écrit **immédiatement** (une session avec `startContext` n'est pas « vide »). `/load xxx__n` reconstruit l'agent avec ce contexte de départ et affiche le résumé.
### 14.5 Contexte refusé par le serveur
`isContextTooLongError(err)` reconnaît les erreurs « input is too long », « prompt is too long », « context_length_exceeded », « maximum context length »… Dans la boucle de `cli/src/index.ts`, hors interruption et une seule fois par demande : avertissement, `compactNow({ emergency: true })` (la demande de résumé utilise `shrinkToolResults(messages, EMERGENCY_TOOL_RESULT_CHARS = 1500)` pour tenir dans la fenêtre ; la session d'origine est sauvegardée avant), puis la demande est relancée avec une consigne de reprise sur la conversation compactée. Si le compactage échoue, l'erreur d'origine est affichée. Non couvert : sous-agents.
## 15. Interface (`packages/ui`)
### 15.1 Architecture
`InkUI` (façade impérative : `info`, `warn`, `error`, `select`, `confirm`, `readInput`, `withSpinner`, `setBusy`, `showUser`, `onShell`, `showPermissions`, `setContextMax`, `setInterruptHandler`, et le contrat `AgentUI`) au-dessus d'un **`UIStore`** sans dépendance à React (testable) ; les composants React affichent l'état du store (`useSyncExternalStore`). Le journal garde les éléments par entrée ; aperçu des résultats d'outils **600** caractères, des sorties `!` **4000**.
### 15.2 Journal et badges (`@inkjs/ui`)
| Badge | Usage | Couleur du texte |
| --- | --- | --- |
| `INFO` | information | bleu clair `#7EC8FF` |
| `WARN` | avertissement | par défaut |
| `FAIL` | erreur | `#DC8ADD` |
| `ASK` | question (liste, confirmation) | gras |
Une ligne vide sépare chaque message. Une fois la réponse validée, la question est remplacée par `◆ Question : choix`.
### 15.3 Réponses de l'agent
Rendu markdown par `markdansi` (`render(markdown, { width })`) — **tout est dans `markdown.tsx`** (retrait : supprimer le fichier, la dépendance, et remplacer `<Markdown />` par `<Text>`). Le texte en cours de streaming reste brut ; la mise en forme apparaît à la fin de la réponse. `MAIA_NO_MARKDOWN=1` désactive le rendu ; un échec de `markdansi` retombe sur le texte brut.
### 15.4 Bannière
Image 32 × 32 en **couleurs réelles** (sans pixelisation ni réduction de palette), rendue en demi-blocs `▀▄█` (32 colonnes × 16 lignes), à gauche du titre « Maia » en grands caractères pixel (police 5 × 7) avec ombre et dégradé arc-en-ciel ; deux textes (vert : « Assistant IA en ligne de commande, propulsé par Ink et vos modèles de langage. » ; rose : « Un seul agent, plusieurs modèles »). Version complète à partir de **58 colonnes** ; entre 25 et 57 : sans le masque ; en dessous : cadre à double bordure compact, titre centré. Données du masque générées par `packages/ui/scripts/make-mask.py` (Pillow) depuis `assets/mask.png` vers `src/mask-data.ts`.
### 15.5 Saisie, attente, listes
- Zone de saisie : bordure en pointillés `┈` **au-dessus et au-dessous** seulement, `greenBright`, spinner `arrow3` à gauche, texte d'aide « Votre demande — /help : aide · § : skills · Ctrl+C : quitter » ; collage multi-lignes conservé ; historique ↑/↓.
- Attente après validation : « En réflexion » + spinner `dots10` en `yellowBright` (aussi pour chargement des modèles, exécution `!`, compactage, outils parallèles « Outils en parallèle : n/total terminés (…) »).
- Listes de choix : fenêtre de 10 lignes, ↑/↓ (ou j/k), Entrée valide, Échap annule.
- Confirmation d'action : `o` autorise, `n` ou Échap refuse, **Entrée seule est ignorée**.
| Contexte | Touches |
| --- | --- |
| Saisie | Entrée : envoyer · Alt+Entrée (`key.return` + `key.meta`) ou Ctrl+J ; Maj+Entrée (`key.shift`) seulement si le terminal gère le protocole kitty (`kittyKeyboard: { mode: "auto" }`), sinon il est identique à Entrée ; (« \n » brut) ou la séquence `ESC O M` (livrée par Ink comme le bloc « OM ») : saut de ligne · ←/→ : déplacement · Home / Ctrl+A : début du prompt · End / Ctrl+E : fin du prompt (`key.home` / `key.end` d'Ink) · ↑/↓ : historique · Ctrl+U/W : effacer · Ctrl+C ou Ctrl+D (saisie vide) : quitter |
| Pendant un tour | Ctrl+C : interrompt et restaure la conversation d'avant la demande |
La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie active, grisée « Traitement en cours » pendant un tour (masquée à l'arrêt du programme), ou demande d'autorisation `ASK … (o/n/s=stop)` (réponse `o`/`y`, `n`/Échap/Ctrl+C, ou `s` = stop ; `s` n'est proposé que par `ask`, pas par `confirm`). Les listes de choix (`/model`, `/load`) remplacent la zone le temps du choix. Au-dessus, une ligne d'état est toujours réservée (spinner « En réflexion », outils en cours ; vide sinon) : la zone de saisie garde la même position. La barre de contexte est toujours en dessous.
## 16. Fichiers et variables
| Chemin / variable | Rôle |
| --- | --- |
| `~/.maia/settings.json` | LiteLLM (§ 7.3) + permissions utilisateur |
| `~/.maia/config.json` | `model` par défaut de l'utilisateur |
| `~/.maia/skills/` | skills de l'utilisateur |
| `./.maia/config.json` | `model` du projet |
| `./.maia/settings.json` | permissions du projet |
| `./.maia/skills/` · `<parent>/.maia/skills/` | skills |
| `./.maia/tools/<nom>/TOOL.json`, `~/.maia/tools/<nom>/TOOL.json` | outils personnalisés (projet, utilisateur) |
| `./.maia/agents/<nom>/AGENT.md` · `~/.maia/agents/<nom>/AGENT.md` | sous-agents |
| `<racine de MAIA>/.ressources/AGENT.md` | modèle d'agent de `/createAgent` (ressource privée de MAIA ; `MAIA_RESSOURCES` : autre dossier) |
| `./.maia/.sessions/<nom>.json` | sessions (et traces de sous-agents `<session>-<agent>.json`) |
| `./MAIA.md` sinon `./CLAUDE.md` | contexte de départ |
| `MAIA_MODEL`, `MAIA_CWD`, `INIT_CWD`, `LITELLM_API_KEY`, `MAIA_NO_MARKDOWN`, `MAIA_RESSOURCES` | variables d'environnement |
## 17. Sécurité
- **Confirmation par défaut** de tout outil sans règle (`ask`) ; ordre deny > ask > allow ; analyse fine des commandes Bash composées (§ 9.4).
- **Outils personnalisés sans shell** : pas d'injection par les paramètres (§ 10.3).
- **WebFetch** : contenu signalé non fiable, pas de redirection inter-domaines suivie, limites de taille et de durée.
- Contenus injectés (`@fichier`, sorties `!`, WebFetch) présentés au modèle comme des **données** et non des instructions.
- `settings.json` utilisateur peut contenir une clé API : ne pas le versionner. Les `settings.json` d'un dépôt tiers peuvent contenir des règles `allow` larges : à relire.
- Les `deny` ne remplacent pas un bac à sable.
## 18. Limites connues et pistes
- **Provider LiteLLM non validé sur un vrai serveur** (SSE et appels d'outils testés sur des flux simulés) ; compatibilité `@inkjs/ui` / Ink 8 à confirmer à l'usage.
- La taille de contexte est obtenue en **interrogeant le LLM** (peut être fausse) ; la route `/model/info` de LiteLLM (`max_input_tokens`) ou un réglage manuel dans `settings.json` serait plus fiable.
- Comptage `o200k_base` : approximation pour les modèles non OpenAI.
- Compactage automatique contrôlé aux frontières de tour uniquement (§ 14.3).
- Parallélisme : tâches asynchrones, 8 simultanées, confirmations une par une.
- Sous-agents : l'effort borne les étapes (pas de `reasoning_effort` envoyé au modèle) ; pas d'imbrication ; une trace de sous-agent rechargée avec `/load` reprend la conversation dans l'agent principal (prompt système différent) ; chaque sous-agent recharge ses outils/skills/permissions depuis le disque (une modification en cours de session n'est pas vue par l'agent principal avant `/clear`).
- Non réalisés : hooks, OAuth MCP, variables d'environnement et slash-commandes personnalisées sous `.maia` (code de `commands.ts` présent mais non appelé), `@fichier` dans le corps d'un prompt de skill, autocomplétion des chemins `@`, listes à cocher, icônes Nerd Fonts.
## 19. Tests
Suites vitest (fichiers `*.test.ts`) : workflows autonomes (`core/src/workflow.test.ts` : registre, demandes, états ; `cli/src/workflow-run.test.ts` : modes wait / deny / allow, approve, RequestIntervention + reply, stop, sous-agent en échec, agents disponibles, commandes texte et JSON), serveurs MCP (`mcp.test.ts` : `mcp.json`, expansion `${VAR}`, règles `Mcp(...)`, faux serveur HTTP en JSON, SSE et ancien transport SSE, filtrage des outils, statuts OK/FAIL, aucune connexion si désactivé), sous-agents (`agents.test.ts` : en-tête, sections, chargement, modèle, outil, initialisation restreinte ; `core/src/subagent.test.ts` : résumé final, budget, trace ; `cli/src/subagent-parent.test.ts` : lanceur avec faux processus), boucle agent et permissions (`agent.test.ts`), exécuteur d'outils parallèle (`tool-runner.test.ts` : durée, ordre, confirmations séquentielles, refus, `lockKey`, plafond de 8), comptage et taille de contexte (`context.test.ts`), compactage et noms `__n` (`compact.test.ts`), sessions, `!` (`shell.test.ts`), `@` (`fileref.test.ts`), skills, outils (chemins, Glob/Grep, TodoWrite, WebFetch, CurrentModel), outils personnalisés (31 tests dont exécution réelle de scripts), initialisation de l'agent, provider LiteLLM (corps de requête, SSE), interface (store, barre de contexte, pixels, couleurs, spinner, markdown), dossier de lancement.
Commandes : `pnpm install`, `pnpm test`, `pnpm typecheck`. Les suites ont été écrites et vérifiées par un lanceur de test minimal dans l'environnement de développement (sans vitest, Ink ni LiteLLM réels) ; leur exécution complète est à faire dans l'environnement du projet.
## 20. Sous-agents (`agent/src/agents.ts`, `yaml-header.ts`, `subagent-tool.ts`, `cli/src/subagent-*.ts`, `core/src/subagent.ts`)
### 20.1 Définition (`AGENT.md`)
Dossiers : `~/.maia/agents` puis `./.maia/agents` (le projet remplace l'utilisateur à nom égal ; un seul passage si les deux sont le même dossier). Un dossier = un agent, fichier `AGENT.md`. Nom d'agent : lettres, chiffres, `_ . -`.
En-tête YAML (sous-ensemble : clés simples, listes `- a` ou `[a, b]`, table imbriquée `permissions`, commentaires `#`, clés insensibles à la casse) :
| Clé | Rôle | Défaut |
| --- | --- | --- |
| `name` | doit être le nom du dossier (sinon agent rejeté) | nom du dossier (avertissement) |
| `description` | obligatoire ; sert au LLM de l'agent principal à choisir | — |
| `model` | identifiant LiteLLM exact ou fragment | modèle de l'agent principal |
| `tools` | liste (virgules ou YAML) d'outils | tous, sauf `Agent` |
| `permissions` | `allow` / `ask` / `deny` : listes de règles | aucune règle ajoutée |
| `effort` | `low` / `medium` / `high` | `medium` |
| `skills` | liste de skills | tous (`[]` : aucun) |
| `omitMd` | ne pas ajouter `MAIA.md` / `CLAUDE.md` | `false` |
| `trace` | enregistrer la conversation | `false` |
Corps : trois sections obligatoires et une facultative (`Constraints` / `Contraintes`, après Missions : `AgentDefinition.constraints`), repérées par leurs titres Markdown (niveau libre, casse et accents ignorés, précision finale entre parenthèses ignorée : « Constraints (optionnelle) » ; « Instructions system(e) », « Missions », « Constraints », « Output ») ; une section s'arrête au titre suivant de même niveau ou plus haut, ou à une autre section reconnue ; les titres dans un bloc de code sont ignorés. Un agent invalide (nom, description, section manquante) est ignoré avec un avertissement précis. Journal au démarrage : « Agents chargés : » (un par ligne) ; commande `/agents`.
### 20.1b Création : `/createAgent` et le modèle `.ressources/AGENT.md`
`/createAgent [nom]` (cli) : `ui.select` du niveau (« Projet » → `./.maia/agents`, « Global » → `~/.maia/agents`), nom en argument ou saisi (vide / Ctrl+C : annulation), `validateAgentName` (lettres, chiffres, `_ . -`, 64 caractères, pas de point initial), puis `createAgentFromTemplate` (agent) : lecture du modèle, `applyAgentName` (remplace uniquement la ligne `name:` de l'en-tête), création du dossier et écriture avec l'option `wx` (jamais d'écrasement : erreur « existe déjà »). Le modèle est cherché par `findResource` (cli/src/resources.ts) : `$MAIA_RESSOURCES`, sinon `<racine de MAIA>/.ressources` (3 niveaux au-dessus de `packages/cli/dist` ou `src`), sinon à côté du programme. Le modèle est un `AGENT.md` valide tel quel (name, description, effort, omitMd, trace actifs ; model, tools, permissions, skills en exemples `#propriété:` à décommenter) avec une explication par propriété en commentaires `#` et, dans le corps, des commentaires HTML par section.
Lecture de l'en-tête : les commentaires de fin de ligne (` # …`, hors guillemets, `#` précédé d'un espace) sont retirés (`stripComment`) ; les commentaires HTML `<!-- … -->` des sections sont retirés avant l'envoi au modèle. Le nouvel agent est chargé au prochain démarrage, `/clear` ou `/new`.
### 20.2 Lancement
Outil `Agent` (permission `ask`, règles `Agent` ou `Agent(nom)`), ajouté à l'agent principal quand au moins un agent existe ; sa description et le prompt système listent les agents. Paramètres : `agent` (énumération des noms), `task`. L'appel est bloquant et retourne « Résumé du sous-agent … » ; plusieurs appels `Agent` dans la même réponse passent par l'exécuteur parallèle (§ 6).
Processus : `child_process.fork(process.argv[1], ["--subagent"], { cwd, stdio: [ignore, pipe, pipe, ipc] })` (les `execArgv` sont hérités : `tsx` en développement). Protocole IPC (`subagent-protocol.ts`) :
| Sens | Message |
| --- | --- |
| parent → enfant | `start {spec}` (définition, tâche, dossier, modèle résolu) · `confirm-result {id, ok}` · `abort` |
| enfant → parent | `confirm {id, message}` · `log {level, message}` · `done {ok, summary, messages, toolCalls}` |
Le modèle est résolu par le parent (`resolveSubAgentModel` : identifiant exact, sinon nom contenant le fragment, sinon modèle du parent + avertissement ; liste LiteLLM chargée une fois, à la demande).
### 20.3 Dans le processus enfant
1. `initializeAgent({ subAgent })` recharge outils intégrés, outils personnalisés, skills et permissions depuis le disque, puis applique la définition : `tools` (insensible à la casse ; jamais `Agent`), `skills` (l'outil `Skill` n'existe que s'il reste des skills), permissions = règles héritées + règles de l'agent (union : un `deny`/`ask` hérité n'est jamais levé), contexte `MAIA.md`/`CLAUDE.md` sauf `omitMd`. Seuls les avertissements propres à la définition sont remontés (outil ou skill introuvable).
2. Prompt système : identité du sous-agent, autonomie (personne ne peut répondre), dossier de travail, indication d'effort, sections Instructions system / Missions / Constraints (si présente, suivie de la consigne : devant un cas prévu ou un interdit, ne pas agir, s'arrêter et le signaler dans le résumé) / Output, consigne « la réponse finale respecte Output et suffit à elle-même », contexte du projet.
3. `runTurn(session, task, signal, { maxIterations })` : 10 / 25 / 50 itérations selon l'effort. `runTurn` retourne `"done"` ou `"max_iterations"`.
4. Résumé = texte de la dernière réponse sans appel d'outil ; sinon (budget épuisé, réponse vide) `requestFinalSummary` ajoute un message demandant le résumé selon `Output` (outils déclarés mais inutilisables, 4000 tokens) et l'inscrit dans la conversation.
5. `done` est envoyé, le processus se termine. Perte du canal IPC : le processus s'arrête.
Confirmations : l'interface de l'enfant n'affiche rien ; `confirm` est relayé au parent, qui pose la question (`[sous-agent nom] Autoriser … ?`) ; `InkUI.confirm` sérialise désormais toutes les questions (outils parallèles, plusieurs sous-agents).
### 20.3b Lancement direct par l'utilisateur (`&`)
`parseAgentInvocation(input)` (core) : uniquement si la saisie **commence** par `&` ; `&nom tâche` ou `& nom tâche`, `&` seul = liste. Le CLI trouve l'agent (nom insensible à la casse), appelle directement le lanceur (pas d'outil `Agent`, donc pas de confirmation d'outil ; spinner « Sous-agent … au travail », Ctrl+C interrompt), affiche le résumé et l'ajoute à `pendingContext` (joint à la demande suivante, comme `!`). Sans tâche : `DEFAULT_AGENT_TASK`. Agent inconnu : erreur avec la liste.
### 20.4 Fin, erreurs, interruption
- Fin normale : INFO « Sous-agent « x » terminé en N s (M appels d'outil). » (+ nom du fichier de trace) ; le résumé est le `tool_result`.
- Échec (`ok: false`, processus arrêté sans résultat, lancement impossible) : avertissement, erreur d'outil renvoyée au LLM (avec la fin de la sortie d'erreur du processus).
- Interruption (Ctrl+C) ou délai de 30 min : message `abort` (l'enfant annule sa requête et refuse les confirmations en attente), `SIGKILL` après 5 s si le processus ne s'arrête pas.
### 20.5 Trace de session
Si `trace: true` (même en cas d'échec, s'il y a des messages) : `saveSubAgentTrace` écrit un `SavedSession` dans `./.maia/.sessions/` nommé `{session parente}-{agent}` (`subAgentSessionName` : `_2`, `_3`… si pris), avec les champs optionnels `agent` et `parentSession`. `/load` affiche « sous-agent x » dans la liste. Sans `trace`, rien n'est écrit.
## 22. Serveurs MCP (`agent/src/mcp*.ts`)
**Fichiers** : `mcp-config.ts` (lecture de `~/.maia/mcp.json`, `expandEnv`), `mcp-rules.ts` (règles `Mcp(...)`, noms d'outils, sélection des serveurs : fonctions pures), `mcp-client.ts` (client JSON-RPC 2.0 minimal, transports Streamable HTTP et HTTP+SSE, analyseur SSE), `mcp.ts` (`setupMcp`, `createMcpTool`). Aucune dépendance ajoutée (client écrit à la main sur `fetch`).
**Déroulement** (`initializeAgent`, après le chargement des permissions) :
1. `loadMcpConfig(homeDir, env)` : `~/.maia/mcp.json` uniquement (ni projet, ni parent). Serveur invalide (clé hors `[A-Za-z0-9_-]` ou contenant `__`, `url` absente ou non http(s), `type` ≠ http/sse) : avertissement, ignoré. `enabled:false` : ignoré. `${VAR}` développé dans `url` et `headers`.
2. `selectMcpServers(rules, clés)` (le serveur `*` désigne tous les serveurs : `Mcp(*)`, `Mcp(*,outil)`) : un serveur est **activé** s'il a une règle allow `Mcp(clé…)` et aucune règle deny `Mcp(clé)` / `Mcp(clé,*)`. Règle sur une clé inconnue : avertissement. Aucune connexion avant ce tri.
3. Connexion **en parallèle** des serveurs activés (`initialize`, `notifications/initialized`, `tools/list` paginé). Échec (réseau, HTTP, délai, JSON-RPC) : statut `fail` + avertissement, les autres serveurs et MAIA continuent.
4. Outils : `mcp__<clé>__<outil>` (nom nettoyé en `[A-Za-z0-9_-]`, ≤ 64 caractères, doublons ignorés avec avertissement), `permission: "ask"` ; retirés : `excludeTools`, hors `includeTools`, règle deny `Mcp(clé,outil)`.
**Permissions** (`permissions.ts`) : une règle `Mcp(clé[,motif])` s'applique à tout appel d'outil `mcp__clé__outil` (allow = sans confirmation, ask, deny). L'ancienne forme `mcp__clé__outil(motif de valeur)` reste valide.
**Client** : `Mcp-Session-Id` mémorisé et renvoyé, `MCP-Protocol-Version: 2025-03-26`, `Accept: application/json, text/event-stream` ; réponse JSON ou flux SSE lue jusqu'à l'id attendu. Transport `sse` : `GET url`, évènement `endpoint`, POST vers cet endpoint, réponses lues sur le flux. Délai `timeout` (s) par requête ; pour `sse`, jusqu'à la réception de `endpoint`. Résultat d'outil : parties `text` concaténées (autres types signalés), `isError` → exception, tronqué à 100 000 caractères.
**Interface** : `/mcp` → `UIStore.showMcp` (`LogKind "mcp"`) : badge `OK` vert / `FAIL` rouge + nom ; cause de l'échec en grisé. `AgentInitResult.mcpServers` (états) et `close()` (fermeture des connexions : session suivante, `/exit`, Ctrl+D, SIGTERM/SIGHUP ; Streamable HTTP : `DELETE` de la session, SSE : fermeture du flux ; au plus 3 s en cas de signal).
**Limites** : pas d'OAuth ; outils seulement ; les sous-agents (processus enfants) n'ont pas les outils MCP ; connexions refaites à chaque redémarrage de session (`/clear`, `/new`, `/load`, `/model`, `/compact`) ; pas de reconnexion automatique en cours de session ; notifications serveur (`tools/list_changed`) ignorées.
## 23. Workflows autonomes (`core/src/workflow.ts`, `agent/src/orchestrator.ts`, `cli/src/workflow-run.ts`, `cli/src/workflow-cli.ts`)
**Entrée** : `cli/src/index.ts` aiguille `maia run|workflows|workflow|logs|result|approve|reply|stop` vers `runWorkflowCli(args)` avant toute interface (pas d'Ink, pas de TTY requis) ; `run` se termine par `process.exit(code)`.
**Agent parent** (`loadOrchestrator`) : `AGENT.md` lu par `parseAgentFile` (nom = dossier), plus `timeout` (`parseDuration` : ms, s, m, min, h, d ; nombre seul = minutes) et `permissionMode` (`wait|deny|allow`). `orchestratorLayout(file)` : dossier des agents = parent du dossier de l'agent ; s'il se termine par `.maia/agents`, dossier de travail = le projet au-dessus, sinon le dossier de lancement.
`loadOrchestratedAgents` : `loadAgentsFrom([~/.maia/agents, dossier du parent])` (le second l'emporte), sans le parent. `mentionedAgents` : agents dont le nom apparaît dans les sections du parent → état initial `pending`.
`initializeOrchestrator` : contexte MAIA.md / CLAUDE.md (sauf `omitMd`), skills (filtre `skills`), outils intégrés + personnalisés + MCP (filtre `tools`, sans jamais retirer `Agent` ni les outils du workflow), `Agent`, `RequestIntervention` ; permissions = settings.json + règles de l'agent ; `RequestIntervention` toujours autorisé. Prompt : `buildOrchestratorPrompt` (rôle d'orchestrateur, parallèle / séquence, intervention par l'outil et jamais par une question en texte, sections, résultat final = Output). Itérations : `EFFORT_ITERATIONS[effort]`.
**Exécution** (`runWorkflow(opts, deps)`, sans exception : l'échec est dans le résultat et le registre) : réglages → parent → modèle (`--model` > `model` > `DefaultModel` > premier modèle LiteLLM ; fragment résolu par `resolveSubAgentModel`) → workflow créé (ou repris : `--workflow-id` en mode détaché) → lanceur de sous-agents habituel (`createSubAgentLauncher`, délai porté au maximum : c'est le `timeout` du workflow qui borne) avec `onAgentState` (états) et `onTranscript` (traces) → `runTurn` puis `finalAssistantText` / `requestFinalSummary` → `result.md`. Échec avant le démarrage (fichier, modèle) : workflow enregistré `error`.
Interface sans affichage : `onToolCall` / erreurs d'outils → journal ; `confirm` → `askPermission(agent, message)` : `allow` / `deny` immédiats ; `wait` → `createRequest` (`permission`), agent `waiting`, interrogation du fichier de réponse toutes les `pollMs` (1 s). Les confirmations des sous-agents arrivent par l'IPC existant, avec le nom de l'agent (`LauncherDeps.confirm(message, agent)`). Elles restent posées une à la fois.
Arrêt : `maia stop` dépose `stop` ; le workflow le voit (même intervalle) et annule le signal du tour (sous-agents interrompus par le lanceur) ; `runTurn` vérifie le signal avant chaque appel au modèle. Délai `timeout` : même mécanisme. Code 130 (arrêt) / 1 (délai, erreur).
`AgentBook` : un `AgentRecord` par agent (`runs`, `active` pour les lancements parallèles du même agent, `waits`) ; `running`/`waiting` selon les attentes, `done`/`error` quand le dernier lancement finit ; à la fin du workflow, tout agent encore actif passe `error`.
**Registre** (`WorkflowRegistry(homeDir)`, `~/.maia/workflows/<id>/`, id `wf_AAAAMMJJ_HHMMSS_xxxx`) : `workflow.json`, `agents.json` (écriture atomique : fichier temporaire + renommage), `events.jsonl` (ajouts), `requests/rN.json` + `rN.answer.json` (réponse unique, écrite par `approve` / `reply`), `stop`, `traces/<agent>[-n].json`, `result.md`, `run.log`. Un seul écrivain par fichier. `find(ref)` : id exact, `last`, morceau unique, nom (le plus récent). `deriveWorkflowState` : `done`/`error` définitifs ; processus absent (`process.kill(pid, 0)`, EPERM = vivant) → `error` ; un agent `waiting` → `waiting` ; sinon `running`.
**`--detach`** : le workflow est créé par la commande (id affiché tout de suite), puis `spawn(process.execPath, [...execArgv, argv[1], "run", …, "--workflow-id", id, "--quiet"], { detached, stdio → run.log })` + `unref()`.
**Commandes** : texte (couleurs si TTY et sans `NO_COLOR`) ou `--json` (`state` = code stable `pending|running|waiting|done|error`, `label` = libellé français). `workflow --watch` : rafraîchi toutes les 2 s jusqu'à la fin ; `logs -f` : toutes les secondes. Codes : 0 ; 2 = mauvaise utilisation ou référence introuvable.
**Limites** : pas d'API HTTP (fichiers + ligne de commande) ; un seul niveau de sous-agents ; confirmations sérialisées (une demande en attente à la fois par processus) ; pas de nettoyage automatique des anciens workflows ; un workflow détaché s'arrête si la machine redémarre (état `en erreur`, processus disparu).
## 24. Annexe — constantes
| Constante | Valeur | Lieu |
| --- | --- | --- |
| Itérations par série d'un tour (réglable : `MaxIterations`) | 50 | `agent.ts` |
| Outils simultanés | 8 | `tool-runner.ts` |
| Timeout `!` / Bash / outil personnalisé | 120 s | `shell.ts`, `bash.ts`, `custom-tools.ts` |
| Sortie Bash / outil personnalisé | 30 000 caractères | `bash.ts`, `custom-tools.ts` |
| Contexte `!` joint à la demande | 30 000 caractères | `shell.ts` |
| Capture `!` | 1 000 000 caractères | `shell.ts` |
| `@fichier` : fichier / par fichier / par saisie / entrées de dossier | 2 Mo / 50 000 / 150 000 / 200 | `fileref.ts` |
| Read : lignes par défaut | 2000 | `read.ts` |
| Glob : résultats | 100 | `glob.ts` |
| Grep : fichier max, ligne max, `head_limit` | 2 Mo, 500 caractères, 250 | `grep.ts` |
| WebFetch : octets / durée / redirections / caractères (défaut, plafond) | 5 Mo / 30 s / 5 / 20 000, 100 000 | `webfetch.ts` |
| Tokens de structure par message | 4 | `context.ts` |
| Échelle de la barre | 10 000 tokens par carré ; jaune ≥ 60 %, rouge ≥ 85 % | `context-model.ts` |
| Plage valide d'une taille de contexte | 1 000 – 100 000 000 | `context.ts` |
| Question de taille de contexte : délai | 60 s | `cli/src/index.ts` |
| Tokens du résumé / marge / seuil | 4000 / 500 / max − 4500 | `compact.ts` |
| Compactages numérotés | 1 – 1000 (cyclique) | `sessions.ts` |
| Nom de session : longueur maximale | 100 | `sessions.ts` |
| Largeur minimale de la bannière complète | 58 colonnes | `banner.tsx` |
| Itérations d'un sous-agent (low / medium / high) | 10 / 25 / 50 | `agents.ts` |
| Délai maximal d'un sous-agent / délai avant `SIGKILL` | 30 min / 5 s | `subagent-parent.ts` |
| Tokens du résumé final d'un sous-agent | 4000 | `core/src/subagent.ts` |
| Aperçu d'un résultat d'outil / d'une sortie `!` | 600 / 4000 caractères | `store.ts` |
| Workflows : intervalle de lecture des réponses / de l'arrêt · rafraîchissement `--watch` / `logs -f` | 1 s · 2 s / 1 s | `workflow-run.ts`, `workflow-cli.ts` |
| MCP : délai par défaut / résultat d'outil / longueur d'un nom d'outil | 30 s / 100 000 caractères / 64 | `mcp-config.ts`, `mcp-client.ts`, `mcp-rules.ts` |