Compare commits

...

2 commits

Author SHA1 Message Date
Arnaud
9c403acff4 Mise à jour: améliorations et ajouts de documentation 2026-10-21 00:47:36 +02:00
Arnaud
6ecf78a04c Mise à jour locale des fichiers de travail 2026-10-21 00:00:52 +02:00
20 changed files with 607 additions and 90 deletions

2
.gitignore vendored
View file

@ -3,5 +3,3 @@ dist
*.log *.log
.env .env
.maia .maia
HOW_To_Agents.md
resume.md

256
GUIDE_PERMISSIONS.md Normal file
View file

@ -0,0 +1,256 @@
# Guide des permissions de MAIA
MAIA demande une confirmation avant d'utiliser un outil sensible (Bash, Write, Edit, WebFetch, Agent…). Les **permissions** permettent d'autoriser, de demander ou d'interdire à l'avance des usages précis, pour ne plus répondre « o/n » à chaque `ls` — et pour bloquer définitivement ce qui ne doit jamais s'exécuter.
Les exemples de ce guide ont été vérifiés avec le moteur de permissions de MAIA (`packages/agent/src/permissions.ts`).
## 1. Où les définir
| Fichier | Portée |
| --- | --- |
| `~/.maia/settings.json` | toutes vos sessions (dossier de l'utilisateur) |
| `./.maia/settings.json` | le projet (dossier de lancement de MAIA) |
Les deux fichiers s'**additionnent** (les listes sont fusionnées). Le fichier du dossier parent n'est pas lu. `~/.maia/settings.json` contient déjà la configuration LiteLLM : ajoutez simplement la propriété `permissions`.
```json
{
"LiteLLM_URL": "http://localhost:4000",
"OpenAI_EndPoint": "/v1/chat/completions",
"LiteLLM_Models": "/v1/models",
"permissions": {
"allow": ["Bash(ls *)", "Read"],
"ask": ["Bash(git push *)"],
"deny": ["Bash(rm *)"]
}
}
```
Les trois listes sont facultatives. La commande `/permissions` affiche les règles chargées, par fichier (badges `ALLOW`, `ASK`, `DENY`). Une règle invalide est ignorée avec un avertissement au démarrage. Les règles sont lues au démarrage, à `/clear` et à `/new` : relancez ou tapez `/clear` après une modification.
## 2. Les trois familles et leur priorité
| Liste | Effet |
| --- | --- |
| `deny` | l'appel est **refusé** sans rien demander ; le modèle est prévenu de ne pas réessayer tel quel |
| `ask` | l'appel demande **toujours** une confirmation |
| `allow` | l'appel s'exécute **sans confirmation** |
Ordre d'évaluation : **`deny` > `ask` > `allow`**. La première famille qui correspond décide, quelle que soit la précision des règles : un `allow` très précis ne peut jamais lever un `deny` ou un `ask` plus large. Si **aucune** règle ne s'applique, MAIA demande une confirmation (c'est le comportement par défaut, pour tous les outils).
## 3. Syntaxe d'une règle
```
Outil
Outil(motif)
```
- `Outil` seul (ex. `"Read"`) : **tous** les usages de l'outil.
- `Outil(motif)` : seulement les usages qui correspondent au motif. Le nom de l'outil respecte la casse (`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `WebFetch`, `Skill`, `Agent`, outils personnalisés…).
- Chaque règle est une chaîne JSON : les guillemets doubles du motif doivent être échappés (`\"`).
Le contenu du motif dépend de l'outil :
| Outil | Le motif est… | Exemples |
| --- | --- | --- |
| `Bash` | une commande (voir § 4) | `Bash(ls *)`, `Bash(npm run test:*)` |
| `Read`, `Write`, `Edit`, `Glob`, `Grep` | un motif de **chemin** relatif au dossier de lancement (glob `*`, `**`) ; un dossier couvre son contenu | `Read(src/**)`, `Edit(docs)`, `Write(*.md)`, `Read(.env)` |
| `WebFetch` | `domain:nom.de.domaine` (le `*` est accepté) | `WebFetch(domain:docs.python.org)`, `WebFetch(domain:*.github.com)` |
| `Skill` | le nom du skill | `Skill(api-docs)` |
| `Agent` | le nom du sous-agent | `Agent(code_reviewer)` |
| outils personnalisés | un motif (`*`) comparé aux valeurs des paramètres | `addition(*)` |
Exemples hors Bash :
```json
"permissions": {
"allow": ["Read", "Glob", "Grep", "Edit(src/**)", "WebFetch(domain:docs.python.org)", "Agent(code_reviewer)"],
"deny": ["Read(.env)", "Write(.git/**)"]
}
```
## 4. Les règles `Bash`
### 4.1 Comment une commande est analysée
MAIA découpe la commande aux séparateurs `&&`, `||`, `;`, `|` et aux retours à la ligne, **sans tenir compte des guillemets**. Chaque morceau est une « sous-commande ».
- Pour **`allow`** : **tous** les morceaux doivent être couverts par une règle. Un seul morceau non couvert, et MAIA redemande confirmation.
- Pour **`deny` et `ask`** : **un seul** morceau qui correspond suffit. Le découpage est même plus fin : il coupe aussi aux `$(…)`, aux backticks, aux parenthèses et aux redirections, pour retrouver une commande cachée dans `echo $(rm -rf x)`.
- Une commande qui contient une substitution (`$(…)`, backticks, `<(…)`) ou une redirection (`>`, `>>`) n'est **jamais** autorisée automatiquement par un `allow` : MAIA demande toujours.
### 4.2 Écrire un motif
| Motif | Correspond à | Ne correspond pas à |
| --- | --- | --- |
| `ls` | `ls` exactement | `ls -la` |
| `ls *` | `ls`, `ls -la`, `ls src` | `lsof` |
| `npm run test:*` | tout ce qui **commence** par `npm run test:` (ancienne syntaxe « préfixe ») | `npm run build` |
| `git * --help` | `git commit --help`, `git log --help` | `git commit` |
| `git status` | `git status` exactement | `git status -s` |
Règle pratique : `commande *` autorise la commande avec ou sans arguments ; la commande seule la limite à elle-même. Les espaces comptent. Un `*` au milieu remplace n'importe quelle suite de caractères. `"Bash"` seul autorise **toute** commande : à éviter sauf dans un environnement jetable.
## 5. Exemple simple : autoriser `ls`
Pour ne plus confirmer `ls`, avec ou sans options :
```json
{
"permissions": {
"allow": ["Bash(ls *)"]
}
}
```
Résultats :
| Commande demandée par le modèle | Décision |
| --- | --- |
| `ls` | autorisée |
| `ls -la src` | autorisée |
| `lsof` | confirmation (autre commande) |
| `ls && rm -rf x` | confirmation (le morceau `rm -rf x` n'est pas couvert) |
| `ls > sortie.txt` | confirmation (redirection) |
| `ls $(pwd)` | confirmation (substitution) |
Pour autoriser plusieurs commandes de lecture :
```json
"allow": ["Bash(ls *)", "Bash(cat *)", "Bash(grep *)", "Bash(git status)", "Bash(git diff *)", "Bash(git log *)"]
```
Un tube entre commandes toutes autorisées passe : `cat a.txt | grep foo` est autorisé avec `Bash(cat *)` et `Bash(grep *)`.
## 6. Exemple complexe : `podman run … sh -c "… | grep …"`
Commande à autoriser :
```
podman run --rm alpine sh -c "echo 'hello world' | grep 'll'"
```
**Piège** : le `|` à l'intérieur des guillemets coupe quand même la commande en deux morceaux :
1. `podman run --rm alpine sh -c "echo 'hello world'`
2. `grep 'll'"`
Une règle qui reproduit la commande entière n'est donc **jamais** reconnue, et une règle `podman … sh -c *` seule ne couvre que le premier morceau : MAIA redemande confirmation. (Les deux cas ont été vérifiés.) Chaque morceau doit avoir sa règle :
```json
{
"permissions": {
"allow": [
"Bash(podman run --rm alpine sh -c *)",
"Bash(grep *)"
]
}
}
```
Avec ces deux règles, la commande ci-dessus est autorisée sans confirmation. Remarques :
- `Bash(podman run --rm alpine sh -c *)` limite l'image (`alpine`) et l'option `--rm` : `podman run --privileged …` ou une autre image redemandent confirmation.
- `Bash(grep *)` autorise `grep` partout, pas seulement dans ce conteneur. C'est le prix du découpage : si c'est trop large, ne l'autorisez pas et acceptez la confirmation pour cette commande.
- Dans un fichier JSON, écrivez la règle telle quelle ; il n'y a pas de guillemets à échapper tant que le motif n'en contient pas. Si le motif en contient : `"Bash(echo \"hello\")"`.
Les morceaux non couverts restent bloqués : avec les mêmes règles, `podman run --rm alpine sh -c "echo x | rm -rf /tmp/y"` demande confirmation (le morceau `rm -rf /tmp/y"` n'est pas autorisé).
## 7. Interdire une commande, même cachée dans un tube
Pour interdire `rm` quoi qu'il arrive :
```json
{
"permissions": {
"deny": ["Bash(rm *)", "Bash(* rm *)"]
}
}
```
Les deux règles sont complémentaires :
- `Bash(rm *)` : un morceau qui **commence** par `rm`.
- `Bash(* rm *)` : `rm` apparaît comme un mot **après** autre chose (`xargs rm`, `sudo rm`, `time rm`…).
Vérifications (avec ces deux règles) :
| Commande | Décision |
| --- | --- |
| `rm -rf build` | refusée |
| `ls && rm -rf build` | refusée |
| `echo ok > f; rm f` | refusée |
| `cat f \| (rm -f g)` | refusée |
| `echo $(rm -rf x)` | refusée |
| `ls \| xargs rm` | refusée (grâce à `* rm *`) |
| `podman run --rm alpine sh -c "echo x \| rm -rf /tmp/y"` | refusée : le découpage de `deny` entre aussi dans les guillemets |
Avec `Bash(rm *)` **seul**, `ls | xargs rm` n'est **pas** refusé (le morceau commence par `xargs`) : ajoutez `Bash(* rm *)`.
Un `deny` l'emporte toujours sur un `allow` : même avec `Bash(podman run --rm alpine sh -c *)` et `Bash(grep *)` autorisés, la commande contenant `| rm -rf …` est refusée si `Bash(rm *)` est dans `deny`.
Pour **demander confirmation** plutôt qu'interdire (ex. `git push`), utilisez `ask` :
```json
"ask": ["Bash(git push *)", "Bash(git reset --hard *)"]
```
Même avec `"Bash(git *)"` en `allow`, ces commandes redemandent confirmation.
## 8. Limites : `deny` n'est pas un bac à sable
Les règles analysent le **texte** de la commande. Elles ne peuvent pas tout voir :
- `find . -delete`, `git clean -fd`, `truncate`, `mv x /dev/null`, `python -c "import os; os.remove('f')"` ou `perl -e …` suppriment des fichiers **sans** écrire `rm`. Ajoutez les règles correspondantes si elles vous importent (`Bash(find * -delete*)`, `Bash(git clean *)`), ou n'autorisez que les commandes dont vous avez besoin (liste `allow` courte) et laissez le reste en confirmation.
- Une commande lancée depuis un script (`bash script.sh`) n'est pas analysée : le contenu du script est invisible.
- Les `deny` sur les outils de fichiers (`Read(.env)`, `Write(.git/**)`) ne concernent que ces outils : `Bash(cat .env)` est une autre règle, à ajouter si besoin.
Approche conseillée : peu de `allow` (lecture seule, tests, build), quelques `ask` (publication, écriture hors projet), des `deny` pour ce qui ne doit jamais arriver (`rm`, `sudo`, `curl … | sh`), et **laisser le défaut** (confirmation) pour tout le reste.
## 9. Sous-agents
Un sous-agent hérite des permissions de l'agent principal. Sa définition (`AGENT.md`) peut en **ajouter** :
```yaml
permissions:
allow: [Read, Grep]
deny: [Bash]
```
Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé par un sous-agent. Quand un sous-agent a besoin d'une confirmation, la question s'affiche dans l'interface principale, préfixée `[sous-agent nom]`.
## 10. Aide-mémoire
```json
{
"permissions": {
"allow": [
"Read", "Glob", "Grep",
"Edit(src/**)",
"Bash(ls *)", "Bash(git status)", "Bash(git diff *)",
"Bash(npm run test:*)",
"WebFetch(domain:docs.python.org)"
],
"ask": [
"Bash(git push *)"
],
"deny": [
"Bash(rm *)", "Bash(* rm *)",
"Bash(sudo *)", "Bash(* sudo *)",
"Read(.env)", "Write(.git/**)"
]
}
}
```
| Je veux… | Règle |
| --- | --- |
| autoriser `ls` et ses options | `allow: ["Bash(ls *)"]` |
| autoriser une commande exacte, sans options | `allow: ["Bash(git status)"]` |
| autoriser un préfixe de scripts npm | `allow: ["Bash(npm run test:*)"]` |
| autoriser une commande avec tube | une règle **par morceau** (§ 6) |
| toujours confirmer une commande | `ask: ["Bash(git push *)"]` |
| interdire une commande partout, tubes compris | `deny: ["Bash(rm *)", "Bash(* rm *)"]` |
| protéger un fichier | `deny: ["Read(.env)", "Write(.env)", "Edit(.env)"]` |
| limiter les sites web | `allow: ["WebFetch(domain:docs.python.org)"]` |

View file

@ -63,7 +63,7 @@ Spécifications techniques détaillées (contrats, formats, constantes) : [`SPEC
| `/compact` | résume la conversation et repart de ce résumé (voir « Compactage ») | | `/compact` | résume la conversation et repart de ce résumé (voir « Compactage ») |
| `/rename <nom>` · `/load [nom]` · `/new` | sessions (voir plus bas) | | `/rename <nom>` · `/load [nom]` · `/new` | sessions (voir plus bas) |
| `/exit` (ou Ctrl+D) | quitte (la session est sauvegardée) | | `/exit` (ou Ctrl+D) | quitte (la session est sauvegardée) |
| `!commande` · `@fichier` · `§skill` | commande shell, fichier injecté, skill (voir plus bas) | | `!commande` · `@fichier` · `§skill` · `&agent` | commande shell, fichier injecté, skill (voir plus bas) |
Option de lancement : `--model <spec>` (ex. `litellm:gpt-4o`). Option de lancement : `--model <spec>` (ex. `litellm:gpt-4o`).
@ -74,7 +74,7 @@ Le CLI garde une façade impérative (`InkUI` : `info`, `select`, `readInput`, `
ce qui rend la logique testable (`store.test.ts`). Les composants (saisie, liste, confirmation, spinner) sont écrits à la main ; seuls les badges viennent de [`@inkjs/ui`](https://github.com/vadimdemedes/ink-ui). ce qui rend la logique testable (`store.test.ts`). Les composants (saisie, liste, confirmation, spinner) sont écrits à la main ; seuls les badges viennent de [`@inkjs/ui`](https://github.com/vadimdemedes/ink-ui).
Un terminal interactif (TTY) est requis. Un terminal interactif (TTY) est requis.
Présentation : la bannière d'accueil reprend le style « MAIA » : image à gauche, affichée telle quelle en couleurs réelles (demi-blocs ▀▄█ : 32 × 32 pixels, soit 32 colonnes × 16 lignes ; sans pixelisation). Elle vient de `packages/ui/assets/mask.png` ; `python3 packages/ui/scripts/make-mask.py [taille]` (Pillow) régénère `src/mask-data.ts` à une autre taille, titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis deux textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles »). Terminal d'au moins 58 colonnes pour la version complète ; entre 25 et 57 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ; Présentation : la bannière d'accueil reprend le style « MAIA » : image à gauche, affichée telle quelle en couleurs réelles (demi-blocs ▀▄█ : 32 × 32 pixels, soit 32 colonnes × 16 lignes ; sans pixelisation). Elle vient de `packages/ui/assets/mask.png` ; `python3 packages/ui/scripts/make-mask.py [taille]` (Pillow) régénère `src/mask-data.ts` à une autre taille, titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis trois textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles », puis la signature « Arnaud VINCENT » en jaune et « ClaudeCode » en orange). Terminal d'au moins 58 colonnes pour la version complète ; entre 25 et 57 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ;
une ligne vide sépare chaque message. une ligne vide sépare chaque message.
Messages du journal, avec le composant `Badge` de `@inkjs/ui` : Messages du journal, avec le composant `Badge` de `@inkjs/ui` :
@ -187,6 +187,7 @@ Au démarrage, le fichier **`~/.maia/settings.json`** (dossier de l'utilisateur,
- `OpenAI_EndPoint` : suffixe ajouté à `LiteLLM_URL` pour envoyer une requête au format OpenAI. - `OpenAI_EndPoint` : suffixe ajouté à `LiteLLM_URL` pour envoyer une requête au format OpenAI.
- `LiteLLM_Models` : suffixe ajouté à `LiteLLM_URL` pour récupérer la liste des modèles disponibles. - `LiteLLM_Models` : suffixe ajouté à `LiteLLM_URL` pour récupérer la liste des modèles disponibles.
- Optionnel : `LiteLLM_API_Key` (ou variable d'environnement `LITELLM_API_KEY`), envoyée en `Authorization: Bearer …`. Ce fichier peut donc contenir un secret : ne pas le versionner. - Optionnel : `LiteLLM_API_Key` (ou variable d'environnement `LITELLM_API_KEY`), envoyée en `Authorization: Bearer …`. Ce fichier peut donc contenir un secret : ne pas le versionner.
- Optionnel : `MaxIterations` (entier, défaut 50) : étapes (appel du LLM puis des outils) par série avant que MAIA demande s'il faut continuer.
- Optionnel : `MaxOutputTokens` (entier, ex. `16000`) : `max_tokens` envoyé à chaque requête. Sans lui, le serveur applique sa limite par défaut, parfois courte : un long fichier écrit par l'outil `Write` peut être coupé (le paramètre `content` arrive alors absent). MAIA affiche un avertissement quand la réponse est coupée (finish_reason `length`, ou sortie qui tombe pile sur une limite usuelle 4096/8192… avec un appel d'outil : certains serveurs « réparent » alors l'appel et annoncent `tool_calls`). - Optionnel : `MaxOutputTokens` (entier, ex. `16000`) : `max_tokens` envoyé à chaque requête. Sans lui, le serveur applique sa limite par défaut, parfois courte : un long fichier écrit par l'outil `Write` peut être coupé (le paramètre `content` arrive alors absent). MAIA affiche un avertissement quand la réponse est coupée (finish_reason `length`, ou sortie qui tombe pile sur une limite usuelle 4096/8192… avec un appel d'outil : certains serveurs « réparent » alors l'appel et annoncent `tool_calls`).
Cas constaté avec `anthropic--claude-4.5-sonnet` : `completion_tokens: 4096` et `Write` reçu avec `path` seul → fixer par ex. `"MaxOutputTokens": 32000`. Cas constaté avec `anthropic--claude-4.5-sonnet` : `completion_tokens: 4096` et `Write` reçu avec `path` seul → fixer par ex. `"MaxOutputTokens": 32000`.
- Diagnostic : `MAIA_DEBUG_LLM=1 maia` enregistre le flux brut renvoyé par LiteLLM dans `maia-llm-debug.log` (dossier temporaire ; `MAIA_DEBUG_LLM=/chemin/fichier.log` pour choisir le fichier). - Diagnostic : `MAIA_DEBUG_LLM=1 maia` enregistre le flux brut renvoyé par LiteLLM dans `maia-llm-debug.log` (dossier temporaire ; `MAIA_DEBUG_LLM=/chemin/fichier.log` pour choisir le fichier).
@ -224,6 +225,8 @@ Conséquence : il n'y a plus de variables d'environnement venant de la configura
## Permissions ## Permissions
> Guide complet avec exemples (commande simple, commande avec tube, interdiction d'un `rm` caché) : [`GUIDE_PERMISSIONS.md`](GUIDE_PERMISSIONS.md).
Les permissions s'appliquent aux **outils** (intégrés ou non). Trois types de règles : Les permissions s'appliquent aux **outils** (intégrés ou non). Trois types de règles :
| Type | Effet | | Type | Effet |
@ -334,6 +337,8 @@ L'appel est **bloquant** (pas de tâche de fond). Ctrl+C l'annule sans rien modi
Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `startCompacted`), `packages/agent/src/init.ts` (`startContext`), orchestration dans `packages/cli/src/index.ts`. Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `startCompacted`), `packages/agent/src/init.ts` (`startContext`), orchestration dans `packages/cli/src/index.ts`.
**Contexte refusé par le serveur** : si LiteLLM répond « Input is too long » (ou équivalent), MAIA compacte automatiquement la conversation (résultats d'outils tronqués à 1 500 caractères dans la demande de résumé, pour qu'elle tienne), puis reprend la demande une seule fois. Si le compactage échoue aussi, l'erreur d'origine est affichée (utilisez alors `/new`). Les sous-agents n'ont pas ce mécanisme.
## Sous-agents (`./.maia/agents`) ## Sous-agents (`./.maia/agents`)
MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle. MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle.
@ -372,6 +377,7 @@ Pour chaque problème : catégorie (CRITICAL / HIGH / MEDIUM / LOW), description
- **Effort** : borne le nombre d'étapes du sous-agent (low 10, medium 25, high 50) et module son prompt. - **Effort** : borne le nombre d'étapes du sous-agent (low 10, medium 25, high 50) et module son prompt.
- **Modèle** : identifiant exact ou fragment (`sonnet` → premier modèle LiteLLM dont le nom contient « sonnet ») ; introuvable → modèle de l'agent principal, avec avertissement. - **Modèle** : identifiant exact ou fragment (`sonnet` → premier modèle LiteLLM dont le nom contient « sonnet ») ; introuvable → modèle de l'agent principal, avec avertissement.
- **Autorisations** : si le sous-agent doit demander une confirmation, la question s'affiche dans l'interface principale, préfixée `[sous-agent nom]` (une question à la fois). Ctrl+C interrompt aussi les sous-agents en cours. - **Autorisations** : si le sous-agent doit demander une confirmation, la question s'affiche dans l'interface principale, préfixée `[sous-agent nom]` (une question à la fois). Ctrl+C interrompt aussi les sous-agents en cours.
- **Lancement par l'utilisateur** : une saisie qui **commence** par `&` lance un sous-agent directement : `&code_reviewer relis src/a.ts` (le nom est reconnu sans tenir compte de la casse ; sans tâche, l'agent exécute sa mission telle que décrite). `&` seul liste les agents. Aucune confirmation de l'outil `Agent` n'est demandée (c'est vous qui lancez), mais les autorisations des outils du sous-agent suivent les règles habituelles. Le résumé s'affiche et est joint à votre prochaine demande, comme le résultat d'une commande `!`. Ctrl+C interrompt le sous-agent.
- **Fin de mission** : un message INFO est affiché dans l'interface principale (durée, nombre d'appels d'outils) ; un échec est signalé par un avertissement et renvoyé à l'agent principal comme erreur d'outil. Délai maximal : 30 minutes. - **Fin de mission** : un message INFO est affiché dans l'interface principale (durée, nombre d'appels d'outils) ; un échec est signalé par un avertissement et renvoyé à l'agent principal comme erreur d'outil. Délai maximal : 30 minutes.
- **Trace** (`trace: true`) : la conversation du sous-agent est enregistrée dans `./.maia/.sessions/` sous le nom `{session de l'agent principal}-{agent}`, ex. `session_2026-10-20_00_01_53-agent_1.json` (`_2`, `_3`… si le nom est pris). Sans `trace`, rien n'est enregistré. - **Trace** (`trace: true`) : la conversation du sous-agent est enregistrée dans `./.maia/.sessions/` sous le nom `{session de l'agent principal}-{agent}`, ex. `session_2026-10-20_00_01_53-agent_1.json` (`_2`, `_3`… si le nom est pris). Sans `trace`, rien n'est enregistré.
@ -423,3 +429,12 @@ Journal des évolutions, dans l'ordre :
31. **Coupure à 4096 tokens diagnostiquée** (log `MAIA_DEBUG_LLM`) : détection d'une sortie tombant sur une limite usuelle avec un appel d'outil, message d'erreur de `Write` qui conseille d'écrire en plusieurs morceaux. 31. **Coupure à 4096 tokens diagnostiquée** (log `MAIA_DEBUG_LLM`) : détection d'une sortie tombant sur une limite usuelle avec un appel d'outil, message d'erreur de `Write` qui conseille d'écrire en plusieurs morceaux.
32. **Outil Bash via `runShell`** : timeout et Ctrl+C arrêtent toute l'arborescence de processus (groupe de processus, SIGTERM puis SIGKILL) ; stdout et stderr fusionnés dans l'ordre d'arrivée, dernière ligne `[code de sortie : N]` ou état (délai dépassé, interrompu). 32. **Outil Bash via `runShell`** : timeout et Ctrl+C arrêtent toute l'arborescence de processus (groupe de processus, SIGTERM puis SIGKILL) ; stdout et stderr fusionnés dans l'ordre d'arrivée, dernière ligne `[code de sortie : N]` ou état (délai dépassé, interrompu).
33. **Sous-agents** : définitions `./.maia/agents/<nom>/AGENT.md` et `~/.maia/agents` (en-tête YAML : name, description, model, tools, permissions, effort, skills, omitMd, trace ; sections Instructions system / Missions / Output), outil `Agent`, processus autonome (`fork` + IPC), conversation vide, résumé final seul renvoyé au parent, confirmations relayées à l'interface principale, notification INFO de fin, trace optionnelle `session_…-agent_1.json`, commande `/agents`. 33. **Sous-agents** : définitions `./.maia/agents/<nom>/AGENT.md` et `~/.maia/agents` (en-tête YAML : name, description, model, tools, permissions, effort, skills, omitMd, trace ; sections Instructions system / Missions / Output), outil `Agent`, processus autonome (`fork` + IPC), conversation vide, résumé final seul renvoyé au parent, confirmations relayées à l'interface principale, notification INFO de fin, trace optionnelle `session_…-agent_1.json`, commande `/agents`.
34. **Compactage sur « Input is too long »** : réponse du serveur reconnue (`isContextTooLongError`), compactage d'urgence (résultats d'outils tronqués dans la demande de résumé), reprise de la demande une fois.
35. **Barre de contexte pendant le travail de l'agent** : taille recomptée à chaque itération (après la demande, chaque réponse du LLM et chaque série de résultats d'outils) et barre affichée pendant les itérations, plus seulement à la saisie.
36. **Barre de contexte toujours affichée** : en bas de l'écran en permanence (saisie, itérations, outils, confirmations, listes) ; si la taille maximale du contexte est inconnue, seul le nombre de tokens utilisés est montré.
37. **Zone de saisie toujours visible** : grisée pendant le travail de l'agent (« Traitement en cours — Ctrl+C : interrompre »), et c'est elle qui porte les demandes d'autorisation (`ASK … (o/n)`), dans le même cadre en pointillés.
38. **Ligne d'état réservée** au-dessus de la zone de saisie (« En réflexion… », outils en cours) : toujours présente, vide hors traitement, pour que la zone de saisie ne remonte ni ne descende.
39. **Limite d'itérations** : 50 par défaut, réglable avec `MaxIterations` dans `~/.maia/settings.json` ; à la limite, MAIA demande s'il faut continuer (o/n) pour une nouvelle série ; une demande tapée après un arrêt est fusionnée dans le dernier message utilisateur (alternance respectée).
40. **Guide des permissions** : `GUIDE_PERMISSIONS.md` (syntaxe, exemples simples et complexes, interdiction d'une commande cachée dans un tube, limites).
41. **Signature dans la bannière** : « Arnaud VINCENT (avec ClaudeCode) » sous « Un seul agent, plusieurs modèles » (ligne séparée par un espace ; nom en #EEF527, ClaudeCode en #F57D27).
42. **Lancement direct d'un sous-agent** : une saisie qui commence par `&` (`&Agent_1 tâche…`) exécute le sous-agent sans passer par le LLM ; son résumé est affiché et ajouté au contexte.

View file

@ -172,11 +172,11 @@ Toute autre commande : « Commande inconnue ». (Les slash-commandes personnalis
### 6.2 `runTurn(session, userText, signal?)` ### 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). 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 **25 itérations** (`MAX_ITERATIONS`) : 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) ; - 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))` ; - 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. - sans appel d'outil → fin du tour ; sinon `runToolCalls`, ajout d'un message `user` portant les `tool_result`, itération suivante.
3. Au-delà de 25 itérations : message d'erreur « Arrêt : plus de 25 itérations ». 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.
Les événements `usage` du provider sont ignorés par le cœur. Les événements `usage` du provider sont ignorés par le cœur.
@ -242,6 +242,7 @@ Erreurs : serveur injoignable (`LiteLLM injoignable (url) : …`), HTTP non 2xx
| `OpenAI_EndPoint` (obligatoire) | suffixe de la requête de chat, ex. `/v1/chat/completions` | | `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_Models` (obligatoire) | suffixe de la liste des modèles, ex. `/v1/models` |
| `LiteLLM_API_Key` (facultatif) | clé API ; sinon variable `LITELLM_API_KEY` | | `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) | | `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). 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).
@ -426,6 +427,8 @@ Après le choix d'un modèle (démarrage, `/model`), MAIA pose au LLM la questio
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 : `950`, `12.3k`, `200k`, `1.05M`. 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 : `950`, `12.3k`, `200k`, `1.05M`.
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. Compactage (`core/src/compact.ts` + `cli/src/index.ts`)
### 14.1 Principe ### 14.1 Principe
@ -469,6 +472,10 @@ En cas d'erreur ou de Ctrl+C **avant** le remplacement, la conversation est inch
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é. 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. Interface (`packages/ui`)
### 15.1 Architecture ### 15.1 Architecture
@ -506,6 +513,8 @@ Image 32 × 32 en **couleurs réelles** (sans pixelisation ni réduction de pale
| Saisie | Entrée : envoyer · ←/→, Ctrl+A/E : déplacement · ↑/↓ : historique · Ctrl+U/W : effacer · Ctrl+C ou Ctrl+D (saisie vide) : quitter | | Saisie | Entrée : envoyer · ←/→, Ctrl+A/E : déplacement · ↑/↓ : 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 | | 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)` (réponse `o`/`y` ou `n`/Échap/Ctrl+C). 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 ## 16. Fichiers et variables
| Chemin / variable | Rôle | | Chemin / variable | Rôle |
@ -592,6 +601,10 @@ Le modèle est résolu par le parent (`resolveSubAgentModel` : identifiant exact
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). 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 ### 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`. - 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`.
@ -606,7 +619,7 @@ Si `trace: true` (même en cas d'échec, s'il y a des messages) : `saveSubAgentT
| Constante | Valeur | Lieu | | Constante | Valeur | Lieu |
| --- | --- | --- | | --- | --- | --- |
| Itérations maximales d'un tour | 25 | `agent.ts` | | Itérations par série d'un tour (réglable : `MaxIterations`) | 50 | `agent.ts` |
| Outils simultanés | 8 | `tool-runner.ts` | | Outils simultanés | 8 | `tool-runner.ts` |
| Timeout `!` / Bash / outil personnalisé | 120 s | `shell.ts`, `bash.ts`, `custom-tools.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` | | Sortie Bash / outil personnalisé | 30 000 caractères | `bash.ts`, `custom-tools.ts` |

View file

@ -1,33 +0,0 @@
# Les Mystères de l'Univers Numérique
## Architecture Décentralisée et Résilience des Systèmes
Dans le vaste paysage technologique moderne, les systèmes décentralisés représentent une avancée majeure. Ils permettent une meilleure distribution des charges et une résilience accrue face aux défaillances.
## Innovation Quantique et Calcul de Demain
La révolution quantique approche. Les ordinateurs quantiques promettent de résoudre des problèmes complexes en temps polynomial, transformant complètement nos capacités de calcul actuelles.
## Sécurité Cryptographique et Protection des Données
La cryptographie post-quantique devient essentielle. Avec l'émergence des ordinateurs quantiques, les algorithmes classiques pourraient être compromis, d'où la nécessité de nouvelles approches.
## Intelligence Artificielle et Apprentissage Profond
Les réseaux de neurones profonds continuent d'évoluer. L'IA générative ouvre des perspectives inédites dans la création, l'automatisation et l'assistance intelligente.
## Durabilité Écologique du Développement Logiciel
La conscience écologique grandit dans l'industrie tech. Optimiser les algorithmes et réduire la consommation énergétique devient une priorité stratégique.
## Collaboration Distribuée et Télétravail Technologique
Les outils collaboratifs modernes permettent des équipes vraiment distribuées. Le télétravail en tech s'accompagne d'une infrastructure logicielle sophistiquée et intuitive.
## Automatisation DevOps et Intégration Continue
Le DevOps transforme la façon dont nous livrons les applications. L'intégration continue et le déploiement continu deviennent la norme industrielle incontournable.
## Microservices et Architecture Modulaire
L'approche microservices rompt avec les monolithes. Elle offre une flexibilité accrue, une évolutivité meilleure et une maintenance simplifiée pour les grandes applications.

View file

@ -8,8 +8,13 @@ import {
createSession, createSession,
ensureMaiaHome, ensureMaiaHome,
findSkill, findSkill,
EMERGENCY_TOOL_RESULT_CHARS,
MAX_ITERATIONS,
isCompactable, isCompactable,
isContextTooLongError,
normalizeSessionName, normalizeSessionName,
DEFAULT_AGENT_TASK,
parseAgentInvocation,
parseShellInvocation, parseShellInvocation,
resolveFileReferences, resolveFileReferences,
parseSkillInvocation, parseSkillInvocation,
@ -78,6 +83,7 @@ Commandes en session : /help /model /permissions /agents /clear /compact /
Sessions : /rename <nom> /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions) Sessions : /rename <nom> /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions)
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills) Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)
Sous-agents : définis dans ./.maia/agents/<nom>/AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste Sous-agents : définis dans ./.maia/agents/<nom>/AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste
&nom [tâche] : vous lancez vous-même un sous-agent (& seul : liste) ; son résumé est ajouté au contexte
Commande shell : !commande ou !'commande avec paramètres' (exécutée directement ; résultat ajouté au contexte) 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)`; Fichier référencé : @chemin/fichier ou @'chemin avec espaces' ou @ chemin/fichier (contenu injecté dans le contexte)`;
@ -327,7 +333,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
* 3. la conversation est vidée comme avec /clear, mais le résumé remplace MAIA.md / CLAUDE.md comme contexte de départ ; * 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. * 4. la session continue sous le nom « {nom d'origine}__{n} » et la barre de progression est recalculée.
*/ */
const compactNow = async (): Promise<boolean> => { const compactNow = async (opts: { emergency?: boolean } = {}): Promise<boolean> => {
if (!isCompactable(session)) { if (!isCompactable(session)) {
ui.info("Rien à compacter : la conversation est vide."); ui.info("Rien à compacter : la conversation est vide.");
return false; return false;
@ -337,7 +343,12 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
ui.setBusy("Compactage de la conversation…"); ui.setBusy("Compactage de la conversation…");
try { try {
saveSession(); // la session d'origine reste complète dans son fichier saveSession(); // la session d'origine reste complète dans son fichier
const { summary, truncated } = await requestCompactSummary(session, controller.signal); const { summary, truncated } = await requestCompactSummary(
session,
controller.signal,
// Urgence (le serveur a refusé le contexte) : la demande de résumé doit elle-même tenir dans la fenêtre
opts.emergency ? { shrinkToolResults: EMERGENCY_TOOL_RESULT_CHARS } : {},
);
const fresh = await startSession(modelSpec, { startContext: summary, quiet: true }); const fresh = await startSession(modelSpec, { startContext: summary, quiet: true });
session = fresh; session = fresh;
let target = ""; let target = "";
@ -394,6 +405,45 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
continue; continue;
} }
// Lancement direct d'un sous-agent : une saisie qui commence par « & » (&nom [tâche])
const agentCall = parseAgentInvocation(input);
if (agentCall) {
if (!agentCall.name) {
ui.info(
agents.length > 0
? ["Sous-agents disponibles :", ...agents.map((a) => ` &${a.name} — ${a.description}`)].join("\n")
: "Aucun sous-agent : créez ./.maia/agents/<nom>/AGENT.md (ou ~/.maia/agents/<nom>/AGENT.md).",
);
continue;
}
const def = agents.find((a) => a.name.toLowerCase() === agentCall.name.toLowerCase());
if (!def) {
const names = agents.map((a) => `&${a.name}`).join(", ") || "aucun";
ui.error(`Sous-agent inconnu : &${agentCall.name} (disponibles : ${names})`);
continue;
}
const task = agentCall.task || DEFAULT_AGENT_TASK;
const controller = new AbortController();
ui.setInterruptHandler(() => controller.abort());
ui.setBusy(`Sous-agent « ${def.name} » au travail…`);
try {
const summary = await launchSubAgent({ agent: def, task, signal: controller.signal });
// Le résumé s'affiche et rejoint la conversation principale (joint à la prochaine demande, comme le résultat d'un « ! »)
ui.onText(summary);
ui.onTextEnd();
session.pendingContext.push(`Résumé du sous-agent « ${def.name} » (lancé par l'utilisateur) pour la tâche : ${task}\n\n${summary}`);
ui.info("Résumé ajouté au contexte : il sera joint à votre prochaine demande.");
} catch (err) {
if (controller.signal.aborted) ui.warn(`Sous-agent « ${def.name} » interrompu (Ctrl+C).`);
else ui.error((err as Error).message);
} finally {
ui.setBusy(null);
ui.setInterruptHandler(null);
}
showContext(session);
continue;
}
let prompt = input; let prompt = input;
const skillCall = parseSkillInvocation(input); const skillCall = parseSkillInvocation(input);
@ -583,7 +633,8 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
await autoCompactIfNeeded(); await autoCompactIfNeeded();
// Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation // Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation
const mark = session.messages.length; let mark = session.messages.length;
let lastBackup = session.messages.at(-1); // runTurn peut compléter ce message (reprise après la limite d'itérations)
const contextBackup = [...session.pendingContext]; const contextBackup = [...session.pendingContext];
// Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que // Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que
@ -601,20 +652,44 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
} }
const controller = new AbortController(); const controller = new AbortController();
ui.setInterruptHandler(() => controller.abort()); ui.setInterruptHandler(() => controller.abort());
try { let attempt = prompt;
await runTurn(session, prompt, controller.signal); let retried = false;
} catch (err) { for (;;) {
if (controller.signal.aborted) { try {
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat) await runTurn(session, attempt, controller.signal, {
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu maxIterations: settings.MaxIterations,
showContext(session); onLimit: (n) => ui.confirm(`${n} étapes effectuées sans réponse finale. Continuer pour ${settings.MaxIterations ?? MAX_ITERATIONS} étapes de plus ?`),
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande."); });
} else { break;
} catch (err) {
if (controller.signal.aborted) {
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
if (mark > 0 && lastBackup) session.messages[mark - 1] = lastBackup;
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
showContext(session);
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
break;
}
if (!retried && isContextTooLongError(err)) {
// Le serveur refuse le contexte (notre comptage local sous-estime) : compactage, puis reprise de la demande une seule fois
retried = true;
ui.warn("Le serveur indique que le contexte est trop long : compactage automatique, puis reprise de la demande.");
const compacted = await compactNow({ emergency: true });
ui.setInterruptHandler(() => controller.abort()); // compactNow a retiré le gestionnaire Ctrl+C
if (compacted) {
mark = 0;
lastBackup = undefined; // nouvelle conversation (résumé comme contexte de départ)
attempt =
"Le contexte a été compacté parce qu'il dépassait la fenêtre du modèle. Le résumé de départ indique ce qui est déjà fait. " +
`Reprends là où tu t'es arrêté et termine la demande suivante :\n\n${prompt}`;
continue;
}
}
ui.error((err as Error).message); ui.error((err as Error).message);
break;
} }
} finally {
ui.setInterruptHandler(null);
} }
ui.setInterruptHandler(null);
// Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande // Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande
await autoCompactIfNeeded(); await autoCompactIfNeeded();
} }

View file

@ -182,11 +182,12 @@ describe("taille du contexte", () => {
cwd: process.cwd(), cwd: process.cwd(),
}); });
await runTurn(session, "go"); await runTurn(session, "go");
expect(seen).toHaveLength(2); // une mise à jour par réponse du LLM (appel d'outil, puis réponse finale) // mise à jour à chaque étape : demande, réponse avec appel d'outil, résultat de l'outil, réponse finale
expect(seen).toHaveLength(4);
expect(seen[0]).toBeGreaterThan(0); expect(seen[0]).toBeGreaterThan(0);
expect(seen[1]).toBeGreaterThan(seen[0]!); for (let i = 1; i < seen.length; i++) expect(seen[i]!).toBeGreaterThan(seen[i - 1]!);
expect(seen[1]).toBe(sessionContextTokens(session)); expect(seen.at(-1)).toBe(sessionContextTokens(session));
expect(seen[1]).toBeLessThan(100_000); // la consommation du serveur (≈ 990 000) est ignorée expect(seen.at(-1)!).toBeLessThan(100_000); // la consommation du serveur (≈ 990 000) est ignorée
}); });
it("le contexte en attente (commandes « ! ») est compté dans la taille de la session", () => { it("le contexte en attente (commandes « ! ») est compté dans la taille de la session", () => {

View file

@ -56,7 +56,8 @@ export function createSession(opts: {
}; };
} }
const MAX_ITERATIONS = 25; /** Nombre d'itérations par défaut avant de s'arrêter (ou de demander s'il faut continuer). */
export const MAX_ITERATIONS = 50;
/** Fin d'un tour : réponse finale du modèle, ou arrêt faute d'itérations (le dernier message est alors un résultat d'outil). */ /** Fin d'un tour : réponse finale du modèle, ou arrêt faute d'itérations (le dernier message est alors un résultat d'outil). */
export type TurnEnd = "done" | "max_iterations"; export type TurnEnd = "done" | "max_iterations";
@ -67,16 +68,38 @@ export async function runTurn(
session: AgentSession, session: AgentSession,
userText: string, userText: string,
signal?: AbortSignal, signal?: AbortSignal,
opts: { maxIterations?: number } = {}, opts: {
maxIterations?: number;
/**
* Appelée quand la limite d'itérations est atteinte sans réponse finale : true → continuer pour `maxIterations`
* itérations de plus, false → s'arrêter. Absente : arrêt avec un message d'erreur.
*/
onLimit?: (iterations: number) => Promise<boolean>;
} = {},
): Promise<TurnEnd> { ): Promise<TurnEnd> {
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS; const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
const { ui } = session; const { ui } = session;
// Le contexte en attente est joint à la demande (un seul message : certains providers exigent l'alternance user/assistant) // Le contexte en attente est joint à la demande (un seul message : certains providers exigent l'alternance user/assistant)
const context = session.pendingContext.splice(0); const context = session.pendingContext.splice(0);
const text = context.length > 0 ? `${context.join("\n\n")}\n\n${userText}` : userText; const text = context.length > 0 ? `${context.join("\n\n")}\n\n${userText}` : userText;
session.messages.push({ role: "user", content: [{ type: "text", text }] }); // Après un arrêt à la limite d'itérations, le dernier message est déjà un message « utilisateur » (résultats d'outils) :
// la nouvelle demande s'y ajoute (l'alternance user/assistant est exigée par certains serveurs)
const last = session.messages.at(-1);
if (last?.role === "user") {
session.messages[session.messages.length - 1] = { ...last, content: [...last.content, { type: "text", text }] };
} else {
session.messages.push({ role: "user", content: [{ type: "text", text }] });
}
ui.onContextUsage?.(sessionContextTokens(session)); // la demande (et le contexte joint) occupe déjà des tokens
for (let i = 0; i < maxIterations; i++) { let iterations = 0;
let limit = maxIterations;
for (;;) {
if (iterations >= limit) {
if (opts.onLimit && (await opts.onLimit(iterations))) limit += maxIterations;
else break;
}
iterations++;
const assistant: ContentBlock[] = []; const assistant: ContentBlock[] = [];
let text = ""; let text = "";
let truncated = false; // réponse coupée par la limite de tokens de sortie du modèle let truncated = false; // réponse coupée par la limite de tokens de sortie du modèle
@ -138,8 +161,9 @@ export async function runTurn(
// 1 appel : premier plan ; 2 appels ou plus : une tâche de fond par outil, on attend toutes les fins // 1 appel : premier plan ; 2 appels ou plus : une tâche de fond par outil, on attend toutes les fins
const results = await runToolCalls(session, calls, signal); const results = await runToolCalls(session, calls, signal);
session.messages.push({ role: "user", content: results }); session.messages.push({ role: "user", content: results });
ui.onContextUsage?.(sessionContextTokens(session)); // les résultats d'outils pèsent avant l'itération suivante
} }
session.ui.onToolResult("agent", `Arrêt : plus de ${maxIterations} itérations`, true); session.ui.onToolResult("agent", `Arrêt : plus de ${iterations} itérations`, true);
return "max_iterations"; return "max_iterations";
} }

View file

@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createSession } from "./agent"; import { createSession } from "./agent";
import { COMPACT_PROMPT, compactThreshold, isCompactable, requestCompactSummary, shouldCompact } from "./compact"; import { COMPACT_PROMPT, compactThreshold, isCompactable, isContextTooLongError, requestCompactSummary, shouldCompact, shrinkToolResults } from "./compact";
import { SessionManager, SessionStore, ensureMaiaHome, nextCompactedName } from "./sessions"; import { SessionManager, SessionStore, ensureMaiaHome, nextCompactedName } from "./sessions";
import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types"; import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types";
@ -190,3 +190,37 @@ describe("persistance d'une session compactée", () => {
expect(mgr.isEmpty({ messages: [], pendingContext: [], startContext: "r" })).toBe(false); expect(mgr.isEmpty({ messages: [], pendingContext: [], startContext: "r" })).toBe(false);
}); });
}); });
describe("contexte refusé par le serveur", () => {
it("reconnaît les messages « entrée trop longue » des principaux serveurs", () => {
const msg = 'Échec (HTTP 400). {"error":{"code":"UPSTREAM_LLM_ERROR","message":"{\\"message\\":\\"Input is too long for requested model.\\"}"}}';
expect(isContextTooLongError(new Error(msg))).toBe(true);
expect(isContextTooLongError(new Error("prompt is too long: 250000 tokens > 200000 maximum"))).toBe(true);
expect(isContextTooLongError(new Error("This model's maximum context length is 128000 tokens"))).toBe(true);
expect(isContextTooLongError(new Error("context_length_exceeded"))).toBe(true);
expect(isContextTooLongError(new Error("HTTP 401 Unauthorized"))).toBe(false);
});
it("shrinkToolResults : tronque les gros résultats d'outils sans toucher aux originaux", () => {
const big = "x".repeat(5000);
const original: Message[] = [
{ role: "user", content: [{ type: "tool_result", toolUseId: "1", content: big }, { type: "text", text: "garde" }] },
];
const out = shrinkToolResults(original, 100);
const block = out[0]!.content[0] as { content: string };
expect(block.content.length).toBeLessThan(200);
expect(block.content).toContain("tronqué");
expect(out[0]!.content[1]).toEqual({ type: "text", text: "garde" });
expect((original[0]!.content[0] as { content: string }).content).toBe(big);
});
it("requestCompactSummary en urgence : la requête de résumé contient des résultats tronqués", async () => {
const seen: ChatRequest[] = [];
const s = sessionWith(providerReplying([{ type: "text_delta", text: "## R" }, { type: "done", stopReason: "end_turn" }], seen), [
user("q"),
{ role: "assistant", content: [{ type: "tool_use", id: "1", name: "Read", input: {} }] },
{ role: "user", content: [{ type: "tool_result", toolUseId: "1", content: "y".repeat(9000) }] },
]);
await requestCompactSummary(s, undefined, { shrinkToolResults: 500 });
expect(JSON.stringify(seen[0]!.messages).length).toBeLessThan(3000);
expect((s.messages[2]!.content[0] as { content: string }).content.length).toBe(9000);
});
});

View file

@ -1,5 +1,5 @@
import type { AgentSession } from "./agent"; import type { AgentSession } from "./agent";
import type { Message } from "./types"; import type { ContentBlock, Message } from "./types";
// Compactage : résumer toute la conversation de la session, puis repartir d'un contexte vide dont le point de départ // Compactage : résumer toute la conversation de la session, puis repartir d'un contexte vide dont le point de départ
// est ce résumé (au lieu de MAIA.md / CLAUDE.md). Le cœur ne fait que demander et valider le résumé ; // est ce résumé (au lieu de MAIA.md / CLAUDE.md). Le cœur ne fait que demander et valider le résumé ;
@ -51,15 +51,41 @@ export function isCompactable(session: Pick<AgentSession, "messages" | "pendingC
return session.messages.length > 0 || session.pendingContext.length > 0; return session.messages.length > 0 || session.pendingContext.length > 0;
} }
/** Réponse du serveur « entrée trop longue » (HTTP 400 : Anthropic/Bedrock, OpenAI, LiteLLM…) : le contexte dépasse la fenêtre du modèle. */
export function isContextTooLongError(err: unknown): boolean {
const text = err instanceof Error ? err.message : String(err);
return /input is too long|prompt is too long|too long for requested model|context[_ ]length[_ ]exceeded|maximum context length|exceeds? the (maximum )?context|context window/i.test(text);
}
/** Nombre de caractères conservés par résultat d'outil quand le compactage est lancé en urgence (contexte déjà saturé). */
export const EMERGENCY_TOOL_RESULT_CHARS = 1500;
/** Copie des messages dont les résultats d'outils sont tronqués à `max` caractères (les originaux ne sont pas modifiés). */
export function shrinkToolResults(messages: Message[], max: number): Message[] {
return messages.map((m) => ({
...m,
content: m.content.map((b): ContentBlock =>
b.type === "tool_result" && b.content.length > max
? { ...b, content: `${b.content.slice(0, max)}… (tronqué pour le compactage)` }
: b,
),
}));
}
/** /**
* Demande au LLM le résumé compacté de la conversation (appel bloquant). La session n'est PAS modifiée : * Demande au LLM le résumé compacté de la conversation (appel bloquant). La session n'est PAS modifiée :
* c'est à l'appelant de bâtir la nouvelle session une fois le résumé obtenu (en cas d'erreur ou d'annulation, rien n'est perdu). * c'est à l'appelant de bâtir la nouvelle session une fois le résumé obtenu (en cas d'erreur ou d'annulation, rien n'est perdu).
* Lève une erreur si le résumé est vide. * Lève une erreur si le résumé est vide.
*/ */
export async function requestCompactSummary(session: AgentSession, signal?: AbortSignal): Promise<CompactSummary> { export async function requestCompactSummary(
session: AgentSession,
signal?: AbortSignal,
opts: { shrinkToolResults?: number } = {},
): Promise<CompactSummary> {
// Le contexte en attente (commandes « ! », fichiers cités) fait partie de la conversation à résumer // Le contexte en attente (commandes « ! », fichiers cités) fait partie de la conversation à résumer
const request = [...session.pendingContext, COMPACT_PROMPT].join("\n\n"); const request = [...session.pendingContext, COMPACT_PROMPT].join("\n\n");
const messages: Message[] = [...session.messages]; const messages: Message[] =
opts.shrinkToolResults !== undefined ? shrinkToolResults(session.messages, opts.shrinkToolResults) : [...session.messages];
const last = messages.at(-1); const last = messages.at(-1);
if (last?.role === "user") { if (last?.role === "user") {
// L'alternance user/assistant doit être respectée : on complète le dernier message // L'alternance user/assistant doit être respectée : on complète le dernier message

View file

@ -4,7 +4,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createSession, runTurn } from "./agent"; import { createSession, runTurn } from "./agent";
import { SessionStore, ensureMaiaHome, saveSubAgentTrace, subAgentSessionName } from "./sessions"; import { SessionStore, ensureMaiaHome, saveSubAgentTrace, subAgentSessionName } from "./sessions";
import { SUB_AGENT_SUMMARY_PROMPT, finalAssistantText, requestFinalSummary } from "./subagent"; import { SUB_AGENT_SUMMARY_PROMPT, finalAssistantText, parseAgentInvocation, requestFinalSummary } from "./subagent";
import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types"; import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types";
const ui: AgentUI = { const ui: AgentUI = {
@ -109,3 +109,44 @@ describe("trace d'un sous-agent", () => {
expect(again).toBe(`${name}_2`); expect(again).toBe(`${name}_2`);
}); });
}); });
describe("limite d'itérations : continuer et reprendre", () => {
const mk = (replies: StreamEvent[][]) =>
createSession({ provider: scripted(replies), init: { systemPrompt: "S", tools: [] }, ui, cwd: "." });
it("onLimit = true : le tour continue pour une nouvelle série d'itérations jusqu'à la réponse finale", async () => {
const s = mk([callTool, callTool, callTool, say("fin")]);
const asked: number[] = [];
const end = await runTurn(s, "q", undefined, { maxIterations: 2, onLimit: async (n) => (asked.push(n), true) });
expect(end).toBe("done");
expect(asked).toEqual([2]);
expect(finalAssistantText(s.messages)).toBe("fin");
});
it("onLimit = false : arrêt sur « max_iterations »", async () => {
const s = mk([callTool]);
expect(await runTurn(s, "q", undefined, { maxIterations: 2, onLimit: async () => false })).toBe("max_iterations");
});
it("nouvelle demande après un arrêt : fusionnée dans le dernier message utilisateur (alternance respectée)", async () => {
const s = mk([callTool, say("ok")]);
await runTurn(s, "q", undefined, { maxIterations: 1 });
expect(s.messages.at(-1)!.role).toBe("user");
await runTurn(s, "continue");
s.messages.forEach((m, i) => expect(m.role).toBe(i % 2 === 0 ? "user" : "assistant"));
const merged = s.messages[2]!;
expect(merged.content.map((b) => b.type)).toEqual(["tool_result", "text"]);
});
});
describe("lancement direct par « & »", () => {
it("« &nom tâche » et « & nom tâche »", () => {
expect(parseAgentInvocation("&Agent_1")).toEqual({ name: "Agent_1", task: "" });
expect(parseAgentInvocation("&Agent_1 relis src/a.ts\net tests")).toEqual({ name: "Agent_1", task: "relis src/a.ts\net tests" });
expect(parseAgentInvocation("& agent_1 fais X ")).toEqual({ name: "agent_1", task: "fais X" });
});
it("« & » seul : nom vide (liste) ; une saisie qui ne COMMENCE pas par & n'est pas un lancement", () => {
expect(parseAgentInvocation("&")).toEqual({ name: "", task: "" });
expect(parseAgentInvocation("lance &Agent_1")).toBeNull();
expect(parseAgentInvocation(" &Agent_1")).toBeNull();
expect(parseAgentInvocation("bonjour")).toBeNull();
});
});

View file

@ -54,3 +54,16 @@ export async function requestFinalSummary(session: AgentSession, signal?: AbortS
messages.push({ role: "assistant", content: [{ type: "text", text: summary }] }); messages.push({ role: "assistant", content: [{ type: "text", text: summary }] });
return summary; return summary;
} }
/** Tâche donnée au sous-agent quand l'utilisateur le lance sans préciser de tâche. */
export const DEFAULT_AGENT_TASK = "Exécute ta mission telle qu'elle est décrite dans tes instructions, puis rédige ton résumé final.";
/**
* Lancement direct d'un sous-agent par l'utilisateur : une saisie qui COMMENCE par « & ».
* « &nom tâche… » ou « & nom tâche… » ; « & » seul : name vide (liste des agents). Autre saisie : null.
*/
export function parseAgentInvocation(input: string): { name: string; task: string } | null {
if (!input.startsWith("&")) return null;
const m = /^&\s*(\S*)\s*([\s\S]*)$/.exec(input);
return { name: m?.[1] ?? "", task: (m?.[2] ?? "").trim() };
}

View file

@ -159,6 +159,18 @@ describe("MaxOutputTokens", () => {
await expect(loadSettings(await mk(',"MaxOutputTokens":"beaucoup"'))).rejects.toThrow("MaxOutputTokens"); await expect(loadSettings(await mk(',"MaxOutputTokens":"beaucoup"'))).rejects.toThrow("MaxOutputTokens");
await expect(loadSettings(await mk(',"MaxOutputTokens":0'))).rejects.toThrow("MaxOutputTokens"); await expect(loadSettings(await mk(',"MaxOutputTokens":0'))).rejects.toThrow("MaxOutputTokens");
}); });
it("MaxIterations : entier positif, absent par défaut", async () => {
const base = '"LiteLLM_URL":"http://x","OpenAI_EndPoint":"/c","LiteLLM_Models":"/m"';
const mk = async (extra: string): Promise<string> => {
const h = await mkdtemp(join(tmpdir(), "maia-"));
await mkdir(join(h, ".maia"));
await writeFile(join(h, ".maia", "settings.json"), `{${base}${extra}}`);
return h;
};
expect((await loadSettings(await mk(""))).MaxIterations).toBe(undefined);
expect((await loadSettings(await mk(',"MaxIterations":80'))).MaxIterations).toBe(80);
await expect(loadSettings(await mk(',"MaxIterations":-1'))).rejects.toThrow("MaxIterations");
});
it("max_tokens est ajouté à la requête quand il est fourni", () => { it("max_tokens est ajouté à la requête quand il est fourni", () => {
const body = buildChatBody("m", { system: "s", messages: [], tools: [], maxTokens: 16000 }); const body = buildChatBody("m", { system: "s", messages: [], tools: [], maxTokens: 16000 });
expect(body).toMatchObject({ max_tokens: 16000 }); expect(body).toMatchObject({ max_tokens: 16000 });

View file

@ -17,6 +17,8 @@ export interface MaiaSettings {
* applique sa limite par défaut, parfois courte : un long fichier écrit par l'outil Write peut alors être coupé. * applique sa limite par défaut, parfois courte : un long fichier écrit par l'outil Write peut alors être coupé.
*/ */
MaxOutputTokens?: number; MaxOutputTokens?: number;
/** Optionnel : nombre d'itérations (appel du LLM puis des outils) par demande avant de s'arrêter ou de demander s'il faut continuer. Défaut : 50. */
MaxIterations?: number;
} }
export const SETTINGS_EXAMPLE = `{ export const SETTINGS_EXAMPLE = `{
@ -56,12 +58,17 @@ export async function loadSettings(home: string = homedir()): Promise<MaiaSettin
if (json.MaxOutputTokens !== undefined && !(Number.isInteger(max) && max > 0)) { if (json.MaxOutputTokens !== undefined && !(Number.isInteger(max) && max > 0)) {
throw new Error(`${path} : MaxOutputTokens doit être un entier positif (reçu : ${JSON.stringify(json.MaxOutputTokens)})`); throw new Error(`${path} : MaxOutputTokens doit être un entier positif (reçu : ${JSON.stringify(json.MaxOutputTokens)})`);
} }
const iterations = Number(json.MaxIterations);
if (json.MaxIterations !== undefined && !(Number.isInteger(iterations) && iterations > 0)) {
throw new Error(`${path} : MaxIterations doit être un entier positif (reçu : ${JSON.stringify(json.MaxIterations)})`);
}
return { return {
LiteLLM_URL: (json.LiteLLM_URL as string).trim(), LiteLLM_URL: (json.LiteLLM_URL as string).trim(),
OpenAI_EndPoint: (json.OpenAI_EndPoint as string).trim(), OpenAI_EndPoint: (json.OpenAI_EndPoint as string).trim(),
LiteLLM_Models: (json.LiteLLM_Models as string).trim(), LiteLLM_Models: (json.LiteLLM_Models as string).trim(),
...(apiKey ? { LiteLLM_API_Key: apiKey } : {}), ...(apiKey ? { LiteLLM_API_Key: apiKey } : {}),
...(json.MaxOutputTokens !== undefined ? { MaxOutputTokens: max } : {}), ...(json.MaxOutputTokens !== undefined ? { MaxOutputTokens: max } : {}),
...(json.MaxIterations !== undefined ? { MaxIterations: iterations } : {}),
}; };
} }

View file

@ -13,6 +13,8 @@ import {
const GREEN = "#7BD74F"; const GREEN = "#7BD74F";
const PINK = "#E58AFF"; const PINK = "#E58AFF";
const YELLOW = "#EEF527";
const ORANGE = "#F57D27";
const GREEN_LINES = ["Assistant IA en ligne de commande,", "propulsé par Ink et vos modèles de langage."]; const GREEN_LINES = ["Assistant IA en ligne de commande,", "propulsé par Ink et vos modèles de langage."];
const PINK_LINES = ["Un seul agent, plusieurs modèles"]; const PINK_LINES = ["Un seul agent, plusieurs modèles"];
@ -85,6 +87,14 @@ export function Banner({ title }: { title: string }) {
</Text> </Text>
))} ))}
</Box> </Box>
<Box marginTop={1}>
<Text wrap="truncate">
<Text color={YELLOW}>Arnaud VINCENT</Text>
<Text> (avec </Text>
<Text color={ORANGE}>ClaudeCode</Text>
<Text>)</Text>
</Text>
</Box>
</Box> </Box>
); );

View file

@ -184,6 +184,15 @@ const DOTTED_BORDER = {
right: "┊", right: "┊",
} as const; } as const;
/** Zone de saisie (cadre en pointillés) : toujours visible ; sert aussi aux demandes d'autorisation et à l'attente. */
function InputFrame({ color, children }: { color: string; children: React.ReactNode }) {
return (
<Box marginTop={1} borderStyle={DOTTED_BORDER} borderColor={color} borderLeft={false} borderRight={false} paddingX={1}>
{children}
</Box>
);
}
const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter"; const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter";
function InputPrompt({ prompt }: { prompt: InputPromptState }) { function InputPrompt({ prompt }: { prompt: InputPromptState }) {
@ -254,14 +263,7 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) {
const { value, cursor } = view; const { value, cursor } = view;
const at = value[cursor] ?? " "; const at = value[cursor] ?? " ";
return ( return (
<Box <InputFrame color="greenBright">
marginTop={1}
borderStyle={DOTTED_BORDER}
borderColor="greenBright"
borderLeft={false}
borderRight={false}
paddingX={1}
>
<Text color={INPUT_COLOR} bold> <Text color={INPUT_COLOR} bold>
{`${frame} `} {`${frame} `}
</Text> </Text>
@ -278,7 +280,16 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) {
{value.slice(cursor + 1)} {value.slice(cursor + 1)}
</Text> </Text>
)} )}
</Box> </InputFrame>
);
}
/** Pendant le travail de l'agent : la zone de saisie reste là, grisée (la saisie reprend à la fin du tour). */
function WaitingInput() {
return (
<InputFrame color="gray">
<Text dimColor>{"… Traitement en cours — Ctrl+C : interrompre"}</Text>
</InputFrame>
); );
} }
@ -338,15 +349,16 @@ function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) {
else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve(false); else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve(false);
// Entrée seule est ignorée : une action sensible exige une réponse explicite. // Entrée seule est ignorée : une action sensible exige une réponse explicite.
}); });
// La demande d'autorisation prend la place de la zone de saisie
return ( return (
<Box marginTop={1}> <InputFrame color="yellow">
<Box marginRight={1} flexShrink={0}> <Box marginRight={1} flexShrink={0}>
<Badge color="green">ASK</Badge> <Badge color="green">ASK</Badge>
</Box> </Box>
<Text> <Text>
{prompt.message} <Text dimColor>(o/n)</Text> {prompt.message} <Text dimColor>(o/n)</Text>
</Text> </Text>
</Box> </InputFrame>
); );
} }
@ -356,7 +368,6 @@ function PromptView({ prompt, state }: { prompt: PromptState; state: UIState })
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<InputPrompt prompt={prompt} /> <InputPrompt prompt={prompt} />
{state.contextMax !== null ? <ContextBar used={state.contextUsed} max={state.contextMax} /> : null}
</Box> </Box>
); );
case "select": case "select":
@ -394,12 +405,13 @@ export function App({ store }: { store: UIStore }) {
<Text>{state.streaming}</Text> <Text>{state.streaming}</Text>
</Box> </Box>
) : null} ) : null}
{state.busy ? ( {/* Ligne d'état réservée : toujours présente (vide hors traitement) pour que la zone de saisie ne bouge jamais */}
<Box marginTop={1}> <Box marginTop={1} height={1}>
<Spinner label={state.busy} /> {state.busy ? <Spinner label={state.busy} /> : <Text> </Text>}
</Box> </Box>
) : null} {state.prompt ? <PromptView prompt={state.prompt} state={state} /> : state.closing ? null : <WaitingInput />}
{state.prompt ? <PromptView prompt={state.prompt} state={state} /> : null} {/* Toujours affichée, tout en bas : saisie, itérations, outils, confirmations, listes (taille maximale inconnue : tokens seuls) */}
<ContextBar used={state.contextUsed} max={state.contextMax} />
</> </>
); );
} }

View file

@ -6,8 +6,16 @@ import { contextBarModel, formatTokens } from "./context-model";
const COLORS = { ok: "green", warn: "yellow", danger: "red" } as const; const COLORS = { ok: "green", warn: "yellow", danger: "red" } as const;
const EMPTY_COLOR = "#555555"; const EMPTY_COLOR = "#555555";
export function ContextBar({ used, max }: { used: number; max: number }) { export function ContextBar({ used, max }: { used: number; max: number | null }) {
const { stdout } = useStdout(); const { stdout } = useStdout();
if (max === null) {
// Taille maximale inconnue : pas de carrés, seulement le contexte utilisé
return (
<Box justifyContent="flex-end" paddingRight={1}>
<Text dimColor>{`${formatTokens(used)} tokens · taille maximale inconnue`}</Text>
</Box>
);
}
const columns = stdout?.columns ?? 80; const columns = stdout?.columns ?? 80;
const pct = Math.round(Math.min(1, used / Math.max(1, max)) * 100); const pct = Math.round(Math.min(1, used / Math.max(1, max)) * 100);
const label = `${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `; const label = `${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `;

View file

@ -24,6 +24,7 @@ export class InkUI implements AgentUI {
const app = this.app; const app = this.app;
if (!app) return; if (!app) return;
this.app = null; this.app = null;
this.store.setClosing();
await new Promise((resolve) => setTimeout(resolve, 100)); // laisse Ink afficher les derniers messages await new Promise((resolve) => setTimeout(resolve, 100)); // laisse Ink afficher les derniers messages
const exited = app.waitUntilExit(); // à créer AVANT unmount() const exited = app.waitUntilExit(); // à créer AVANT unmount()
app.unmount(); app.unmount();

View file

@ -78,13 +78,15 @@ export interface UIState {
contextUsed: number; contextUsed: number;
/** Taille maximale du contexte du modèle, null tant qu'elle n'est pas connue (la barre est alors masquée). */ /** Taille maximale du contexte du modèle, null tant qu'elle n'est pas connue (la barre est alors masquée). */
contextMax: number | null; contextMax: number | null;
/** Arrêt du programme en cours : la zone de saisie grisée n'est plus affichée. */
closing: boolean;
} }
const RESULT_PREVIEW_MAX = 600; const RESULT_PREVIEW_MAX = 600;
const SHELL_PREVIEW_MAX = 4000; const SHELL_PREVIEW_MAX = 4000;
export class UIStore { export class UIStore {
private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null }; private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null, closing: false };
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private nextId = 1; private nextId = 1;
private history: string[] = []; private history: string[] = [];
@ -99,6 +101,10 @@ export class UIStore {
}; };
}; };
setClosing(): void {
this.commit({ closing: true });
}
private commit(patch: Partial<UIState>): void { private commit(patch: Partial<UIState>): void {
this.state = { ...this.state, ...patch }; this.state = { ...this.state, ...patch };
for (const l of this.listeners) l(); for (const l of this.listeners) l();

View file

@ -1,4 +1,2 @@
packages: packages:
- "packages/*" - "packages/*"
allowBuilds:
esbuild: true