Mise à jour du repo

This commit is contained in:
Arnaud 2026-10-28 01:55:28 +01:00
parent 9a27e5c7e0
commit 74b9604b5c
20 changed files with 383 additions and 969 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ dist
*.log *.log
.env .env
.maia .maia
VERSIONS.md

View file

@ -73,7 +73,7 @@ Option de lancement : `--model <spec>` (ex. `litellm:gpt-4o`).
## Interface (Ink) ## Interface (Ink)
L'interface est une application React rendue par [Ink](https://github.com/vadimdemedes/ink) (Ink 7 : React ≥ 19.2 ; vérifiez la version de Node qu'il exige). L'interface est une application React rendue par [Ink](https://github.com/vadimdemedes/ink) (Ink 8 : React ≥ 19.2 ; vérifiez la version de Node qu'il exige).
Le CLI garde une façade impérative (`InkUI` : `info`, `select`, `readInput`, `confirm`, `withSpinner`…) au-dessus d'un `UIStore` sans dépendance à React, Le CLI garde une façade impérative (`InkUI` : `info`, `select`, `readInput`, `confirm`, `withSpinner`…) au-dessus d'un `UIStore` sans dépendance à React,
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.
@ -96,14 +96,14 @@ Les couleurs sont dans `packages/ui/src/colors.ts`. Une fois la réponse validé
Barre de contexte : sous la zone de saisie, en bas à droite, une barre de carrés ■ (de gauche à droite) montre la taille du contexte de la conversation par rapport à la taille maximale du modèle, à l'échelle **1 carré = 10 000 tokens** (200 000 tokens → 20 carrés), avec le libellé « 45.7k / 200k · 23% ». Les carrés sont verts, jaunes à partir de 60 %, rouges à partir de 85 %. Si la barre ne tient pas dans le terminal (très grands contextes), l'échelle est agrandie par paliers de 10 000 et indiquée (`1■ = 20k`). Barre de contexte : sous la zone de saisie, en bas à droite, une barre de carrés ■ (de gauche à droite) montre la taille du contexte de la conversation par rapport à la taille maximale du modèle, à l'échelle **1 carré = 10 000 tokens** (200 000 tokens → 20 carrés), avec le libellé « 45.7k / 200k · 23% ». Les carrés sont verts, jaunes à partir de 60 %, rouges à partir de 85 %. Si la barre ne tient pas dans le terminal (très grands contextes), l'échelle est agrandie par paliers de 10 000 et indiquée (`1■ = 20k`).
- **Taille maximale** : après le choix du modèle (au démarrage, avec `--model`/config, et avec `/model`), MAIA demande au LLM « Taille maximale du contexte pour ce modèle » (réponse en tokens, lue par `parseTokenCount` : `128000`, `128 000 tokens`, `200k`, `1,5 million`…). La valeur est annoncée (INFO) ; une réponse par modèle et par exécution. Si la réponse est illisible, ou si la question échoue ou dépasse 60 s (Ctrl+C l'abandonne), un avertissement s'affiche et la barre est masquée. Un LLM peut se tromper sur sa propre limite : la valeur affichée en INFO permet de la vérifier. - **Taille maximale** : après le choix du modèle (au démarrage, avec `--model`/config, et avec `/model`), MAIA demande au LLM « Taille maximale du contexte pour ce modèle » (réponse en tokens, lue par `parseTokenCount` : `128000`, `128 000 tokens`, `200k`, `1,5 million`…). La valeur est annoncée (INFO) ; une réponse par modèle et par exécution. Si la réponse est illisible, ou si la question échoue ou dépasse 60 s (Ctrl+C l'abandonne), un avertissement s'affiche et la barre est masquée. Un LLM peut se tromper sur sa propre limite : la valeur affichée en INFO permet de la vérifier.
- **Contexte utilisé** = tokens de tout ce que la session enverrait au modèle : prompt système (MAIA.md…), définition des outils, messages (texte, appels d'outils et leurs résultats, +4 tokens de structure par message) et contexte en attente (résultats de commandes `!`, fichiers `@`). Le calcul est **local et déterministe**, avec [js-tiktoken](https://github.com/dqbd/tiktoken) (vocabulaire `o200k_base`) ; la consommation renvoyée par le serveur n'est plus utilisée, car elle faisait varier la valeur. C'est une **somme** des comptes de chaque élément : la valeur ne fait qu'augmenter quand la conversation s'enrichit, et ne diminue que si le contexte est réduit (`/clear`, `/compact`, tour interrompu, autre session). Le comptage est refait **avant chaque saisie** de l'utilisateur (et après chaque réponse du LLM) ; le compte de chaque message est mémorisé, donc le recalcul reste instantané. Pour les modèles qui n'utilisent pas ce vocabulaire (Claude, Mistral…), la valeur est une bonne approximation, pas le compte exact du serveur. - **Contexte utilisé** = tokens de tout ce que la session enverrait au modèle : prompt système (MAIA.md…), définition des outils, messages (texte, appels d'outils et leurs résultats, +4 tokens de structure par message) et contexte en attente (résultats de commandes `!`, fichiers `@`). Quand le serveur renvoie `usage.total_tokens` dans sa réponse, **cette valeur sert de référence** après chaque réponse du modèle ; ce qui s'ajoute ensuite (résultats d'outils, nouvelle demande, contexte en attente) est compté localement et s'additionne à elle. **Sans `total_tokens`**, le calcul est entièrement **local et déterministe**, avec [js-tiktoken](https://github.com/dqbd/tiktoken) (vocabulaire `o200k_base`). La valeur du serveur n'est plus utilisée dès que la conversation est effacée, rechargée ou ramenée en arrière (retour au comptage local). Le comptage local est une **somme** des comptes de chaque élément : la valeur ne fait qu'augmenter quand la conversation s'enrichit, et ne diminue que si le contexte est réduit (`/clear`, `/compact`, tour interrompu, autre session). Le comptage est refait **avant chaque saisie** de l'utilisateur, après chaque réponse du LLM et chaque lot de résultats d'outils, et **pendant** la réponse du modèle (au plus 4 fois par seconde, avec le texte déjà reçu) ; la valeur est affichée à 100 tokens près (`123.5k`) ; le compte de chaque message est mémorisé, donc le recalcul reste instantané. Pour les modèles qui n'utilisent pas ce vocabulaire (Claude, Mistral…), la valeur est une bonne approximation, pas le compte exact du serveur.
La zone de saisie est encadrée par une bordure en pointillés (┈) **au-dessus et au-dessous** seulement, en `greenBright`, sans bords gauche ni droit. La zone de saisie est encadrée par une bordure en pointillés (┈) **au-dessus et au-dessous** seulement, en `greenBright`, sans bords gauche ni droit.
À sa gauche, le spinner `arrow3` de [cli-spinners](https://github.com/sindresorhus/cli-spinners) s'anime en continu. Pendant l'attente de la réponse après validation de la saisie, « En réflexion » s'affiche avec le spinner `dots10` à sa gauche, en `yellowBright` (même spinner pour les autres attentes : chargement des modèles, exécution d'une commande `!`). À sa gauche, le spinner `arrow3` de [cli-spinners](https://github.com/sindresorhus/cli-spinners) s'anime en continu. Pendant l'attente de la réponse après validation de la saisie, « En réflexion » s'affiche avec le spinner `dots10` à sa gauche, en `yellowBright` (même spinner pour les autres attentes : chargement des modèles, exécution d'une commande `!`).
| Contexte | Touches | | Contexte | Touches |
| --- | --- | | --- | --- |
| Saisie | Entrée : envoyer · ←/→, Ctrl+A/E : se déplacer · ↑/↓ : historique · Ctrl+U/W : effacer · Ctrl+C ou Ctrl+D (saisie vide) : quitter · le collage multi-lignes est conservé | | Saisie | Entrée : envoyer · Alt+Entrée ou Ctrl+J : saut de ligne (sans envoyer) ; Maj+Entrée aussi, mais seulement sur un terminal compatible avec le protocole clavier kitty (Kitty, WezTerm, Ghostty, iTerm2…) : ailleurs il est identique à Entrée et envoie le prompt · ←/→ : se déplacer · Début (Home) ou Ctrl+A : début du prompt · Fin (End) ou Ctrl+E : fin du prompt · ↑/↓ : historique · Ctrl+U/W : effacer · Ctrl+C ou Ctrl+D (saisie vide) : quitter · le collage multi-lignes est conservé |
| Liste (`/model`, `/load`, choix initial) | ↑/↓ (ou j/k) · Entrée : valider · Échap : annuler | | Liste (`/model`, `/load`, choix initial) | ↑/↓ (ou j/k) · Entrée : valider · Échap : annuler |
| Confirmation d'une action (Write, Edit, Bash…) | `o` : autoriser · `n` ou Échap : refuser · Entrée seule est ignorée (réponse explicite exigée) | | Confirmation d'une action (Write, Edit, Bash…) | `o` : autoriser · `n` ou Échap : refuser · Entrée seule est ignorée (réponse explicite exigée) |
| Pendant un tour de l'agent | Ctrl+C : interrompt la requête et restaure la conversation avant cette demande | | Pendant un tour de l'agent | Ctrl+C : interrompt la requête et restaure la conversation avant cette demande |
@ -499,7 +499,7 @@ maia stop <id> # arrêter (sous-agents compris)
## Reste à faire ## Reste à faire
- Provider LiteLLM : validation sur un vrai serveur (streaming SSE et appels d'outils testés seulement sur des flux simulés) ; compatibilité de `@inkjs/ui` avec Ink 7 à confirmer à l'usage (l'affichage des badges a été vu fonctionner par l'utilisateur). - Provider LiteLLM : validation sur un vrai serveur (streaming SSE et appels d'outils testés seulement sur des flux simulés) ; compatibilité de `@inkjs/ui` avec Ink 8 à confirmer à l'usage (l'affichage des badges a été vu fonctionner par l'utilisateur).
- Agent : hooks ; MCP : OAuth, ressources/prompts, outils MCP pour les sous-agents ; sous-agents : `reasoning_effort` du modèle non utilisé (l'effort borne les étapes), pas de sous-agent imbriqué. - Agent : hooks ; MCP : OAuth, ressources/prompts, outils MCP pour les sous-agents ; sous-agents : `reasoning_effort` du modèle non utilisé (l'effort borne les étapes), pas de sous-agent imbriqué.
- Éventuellement rétablir sous `.maia` ce que supprimait l'abandon de `.claude` : variables d'environnement et slash-commandes personnalisées (le code des commandes existe encore dans `packages/agent/src/commands.ts`, il n'est plus appelé). - Éventuellement rétablir sous `.maia` ce que supprimait l'abandon de `.claude` : variables d'environnement et slash-commandes personnalisées (le code des commandes existe encore dans `packages/agent/src/commands.ts`, il n'est plus appelé).
- `@fichier` dans le corps d'une commande personnalisée ; autocomplétion des chemins `@` à la saisie. - `@fichier` dans le corps d'une commande personnalisée ; autocomplétion des chemins `@` à la saisie.
@ -562,3 +562,10 @@ Journal des évolutions, dans l'ordre :
48. **Section `# Constraints`** des agents (facultative, après `# Missions`) : limites et conditions d'intervention de l'utilisateur ou d'un tiers ; incluse dans le prompt du sous-agent avec la consigne de s'arrêter et de le signaler dans son résumé ; modèle `.ressources/AGENT.md` et exemple `code_reviewer` mis à jour. 48. **Section `# Constraints`** des agents (facultative, après `# Missions`) : limites et conditions d'intervention de l'utilisateur ou d'un tiers ; incluse dans le prompt du sous-agent avec la consigne de s'arrêter et de le signaler dans son résumé ; modèle `.ressources/AGENT.md` et exemple `code_reviewer` mis à jour.
49. **Workflows autonomes** : `maia run AGENT.md` (agent parent sans interface, sous-agents de son dossier `.maia/agents` + globaux, modèle hérité sauf `model`), `--detach`, registre global `~/.maia/workflows`, états non exécuté / en exécution / en attente / terminé / en erreur, autorisations `wait` (défaut) / `deny` / `allow`, outil `RequestIntervention`, commandes `workflows`, `workflow`, `logs`, `result`, `approve`, `reply`, `stop` (texte ou `--json`), `/workflows` en session, `DefaultModel` dans `settings.json` ; exemple `examples/agents/release_check`. 49. **Workflows autonomes** : `maia run AGENT.md` (agent parent sans interface, sous-agents de son dossier `.maia/agents` + globaux, modèle hérité sauf `model`), `--detach`, registre global `~/.maia/workflows`, états non exécuté / en exécution / en attente / terminé / en erreur, autorisations `wait` (défaut) / `deny` / `allow`, outil `RequestIntervention`, commandes `workflows`, `workflow`, `logs`, `result`, `approve`, `reply`, `stop` (texte ou `--json`), `/workflows` en session, `DefaultModel` dans `settings.json` ; exemple `examples/agents/release_check`.
50. **Guide des agents autonomes** : `GUIDE_AUTONOMOUS.md` (préparer les agents, agent parent, sous-agents, lancement, autorisations sans interface, supervision, interventions, scripts / tâches planifiées / services, registre, limites, dépannage). 50. **Guide des agents autonomes** : `GUIDE_AUTONOMOUS.md` (préparer les agents, agent parent, sous-agents, lancement, autorisations sans interface, supervision, interventions, scripts / tâches planifiées / services, registre, limites, dépannage).
51. **Barre de contexte qui semblait figée** : au-delà de 100k, la valeur n'était affichée qu'au millier près (`123k`) et un carré vaut 10 000 tokens, donc plusieurs étapes passaient sans changement visible ; affichage à 100 tokens près (`123.5k`, `1.049M`) et mise à jour en direct pendant la réponse du modèle (toutes les 250 ms au plus).
52. **Touches Début / Fin** (Home / End) dans la zone de saisie : curseur au début ou à la fin du prompt (comme Ctrl+A / Ctrl+E).
53. **Maj+Entrée** dans la zone de saisie : saut de ligne, le prompt n'est pas envoyé (Alt+Entrée et Ctrl+J aussi, pour les terminaux qui n'envoient pas Maj+Entrée distinctement ; la séquence « ESC O M » envoyée par certains terminaux, qui s'affichait « OM », est aussi reconnue).
54. **Maj+Entrée avec Ink 8** : Ink 8 ne transmet plus que « Entrée » pour Maj+Entrée sur certains terminaux (le journal des touches montre `\r` + `return`, sans `shift`). Maia demande donc le protocole clavier kitty (`kittyKeyboard: { mode: "auto" }`), qui rend Maj+Entrée distinct sur les terminaux compatibles (Kitty, WezTerm, Ghostty, iTerm2, Windows Terminal récent…). Ailleurs (constaté sur le terminal de l'auteur : `\r` + `return` pour Entrée comme pour Maj+Entrée), les deux touches sont indiscernables : utiliser Alt+Entrée ou Ctrl+J, qui fonctionnent partout.
55. **Astuce Konsole (KDE)** : Konsole n'envoie pas Maj+Entrée distinctement. Pour l'obtenir : Configuration → Modifier le profil courant → Clavier → Modifier, ajouter la ligne `key Return+Shift : "\n"`, valider puis rouvrir l'onglet. Maj+Entrée envoie alors un saut de ligne brut (comme Ctrl+J), que Maia traite comme un saut de ligne. (Syntaxe non testée sur Konsole : en cas d'échec, `MAIA_DEBUG_KEYS=1` montre ce qui est reçu.)
56. **Diagnostic des touches** : `MAIA_DEBUG_KEYS=1 pnpm maia` écrit chaque touche reçue (texte + drapeaux Ink) dans `./.maia-keys.log`, pour comprendre pourquoi une touche (Maj+Entrée…) n'est pas reconnue.
57. **Barre de contexte alimentée par `usage.total_tokens`** quand le serveur le renvoie (le total compte aussi les tokens de raisonnement, donc il peut dépasser ce que le modèle relira) ; sinon comptage local comme avant. Entre deux réponses, l'évolution est comptée localement.

View file

@ -25,13 +25,13 @@ Hors périmètre actuel : hooks, slash-commandes personnalisées (code présent
| Langage | TypeScript 5.6+, mode `strict` + `noUncheckedIndexedAccess`, ESM (`"type": "module"`) | | Langage | TypeScript 5.6+, mode `strict` + `noUncheckedIndexedAccess`, ESM (`"type": "module"`) |
| Exécution | Node ≥ 20 (`engines`), `tsx` en développement | | Exécution | Node ≥ 20 (`engines`), `tsx` en développement |
| Gestionnaire de paquets | `pnpm@12.14.0` (champ `packageManager`), workspaces `packages/*` | | Gestionnaire de paquets | `pnpm@12.14.0` (champ `packageManager`), workspaces `packages/*` |
| Build | `tsup` (un seul bundle ESM, cible `node20`, shebang `#!/usr/bin/env node`) | | Build | `tsup` ^9 (un seul bundle ESM, cible `node20`, shebang `#!/usr/bin/env node`) |
| Tests | `vitest` ^2.1 (`vitest run` à la racine) | | Tests | `vitest` ^2.1 (`vitest run` à la racine) |
| Interface | `ink` ^7.0.3, `react` ^19.2, `@inkjs/ui` ^2 (badges), `cli-spinners` | | Interface | `ink` ^8.0.0, `react` ^19.3.1, `@inkjs/ui` ^2 (badges), `cli-spinners` ^3.4 |
| Markdown | `markdansi` ^0.2 (isolé dans un composant) | | Markdown | `markdansi` ^0.3.7 (isolé dans un composant) |
| Comptage de tokens | `js-tiktoken` ^1.0.21, vocabulaire `o200k_base` (`js-tiktoken/lite`) | | Comptage de tokens | `js-tiktoken` ^1.0.21, vocabulaire `o200k_base` (`js-tiktoken/lite`) |
| HTML → Markdown | `turndown` ^7.2 | | HTML → Markdown | `turndown` ^7.2.4 |
| Fichiers | `ignore` (`.gitignore`), `picomatch` (motifs glob) | | Fichiers | `ignore` ^7 (`.gitignore`), `picomatch` ^4 (motifs glob) |
`tsconfig.base.json` : `target ES2022`, `module ESNext`, `moduleResolution Bundler`, `jsx react-jsx`, `noEmit`. `tsconfig.base.json` : `target ES2022`, `module ESNext`, `moduleResolution Bundler`, `jsx react-jsx`, `noEmit`.
@ -419,7 +419,7 @@ Par défaut `session_AAAA-MM-JJ_HH_MM_SS` (heure locale). Un nom saisi est norma
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. 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 n'est plus utilisée (elle mélangeait deux comptes différents et faisait varier la valeur). `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 `!`. Si le flux contient `usage.total_tokens` (`StreamEvent` « usage », champ optionnel `totalTokens`), l'agent le mémorise après chaque réponse dans `session.serverContext` (`total`, comptage local au même instant, message ancre) : `sessionContextTokens` renvoie alors `total + (local actuel − local mémorisé)`. La référence cesse d'être valable si le message ancre n'est plus à sa place (`/clear`, session rechargée, retour en arrière) : comptage local seul (`localContextTokens`). Sans `total_tokens` dans la réponse : comportement inchangé. Le total du serveur compte aussi les tokens de raisonnement.
### 13.2 Taille maximale ### 13.2 Taille maximale
@ -427,7 +427,7 @@ Après le choix d'un modèle (démarrage, `/model`), MAIA pose au LLM la questio
### 13.3 Barre (`ui/src/context-model.ts`, `context-bar.tsx`) ### 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 : `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 (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. 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.
@ -512,7 +512,7 @@ Image 32 × 32 en **couleurs réelles** (sans pixelisation ni réduction de pale
| Contexte | Touches | | Contexte | Touches |
| --- | --- | | --- | --- |
| 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 · 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 | | Pendant un tour | Ctrl+C : interrompt et restaure la conversation d'avant la demande |
@ -545,7 +545,7 @@ La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie activ
## 18. Limites connues et pistes ## 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 7 à confirmer à l'usage. - **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. - 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. - Comptage `o200k_base` : approximation pour les modèles non OpenAI.
- Compactage automatique contrôlé aux frontières de tour uniquement (§ 14.3). - Compactage automatique contrôlé aux frontières de tour uniquement (§ 14.3).

View file

@ -15,8 +15,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"tsup": "^8.3.0", "tsup": "^9.0.0",
"tsx": "^4.19.0", "tsx": "^4.23.15",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vitest": "^2.1.0" "vitest": "^2.1.0"
} }

View file

@ -9,12 +9,12 @@
}, },
"dependencies": { "dependencies": {
"@maia/core": "workspace:*", "@maia/core": "workspace:*",
"ignore": "^6.0.2", "ignore": "^7.0.14",
"picomatch": "^3.0.1", "picomatch": "^4.0.7",
"turndown": "^7.2.0" "turndown": "^7.2.4"
}, },
"devDependencies": { "devDependencies": {
"@types/picomatch": "^3.0.1", "@types/picomatch": "^4.0.0",
"@types/turndown": "^5.0.5" "@types/turndown": "^5.0.5"
} }
} }

View file

@ -68,7 +68,13 @@ export async function loadOrchestrator(file: string, fallbackCwd: string): Promi
try { try {
raw = await readFile(path, "utf8"); raw = await readFile(path, "utf8");
} catch (err) { } catch (err) {
throw new Error(`Agent parent introuvable : ${path} (${(err as Error).message})`); const code = (err as NodeJS.ErrnoException).code;
const hint =
code === "ENOENT"
? ` — les chemins relatifs partent du dossier où la commande est lancée (${resolve(fallbackCwd)}). ` +
"Créez l'agent avec /createAgent dans MAIA, ou copiez un exemple : cp -r examples/agents/release_check ./.maia/agents/"
: "";
throw new Error(`Agent parent introuvable : ${path} (${(err as Error).message})${hint}`);
} }
const folder = basename(dirname(path)); const folder = basename(dirname(path));
const { agentsDir, cwd } = orchestratorLayout(path, fallbackCwd); const { agentsDir, cwd } = orchestratorLayout(path, fallbackCwd);

View file

@ -15,15 +15,15 @@
"@maia/agent": "workspace:*", "@maia/agent": "workspace:*",
"@maia/providers": "workspace:*", "@maia/providers": "workspace:*",
"@maia/ui": "workspace:*", "@maia/ui": "workspace:*",
"cli-spinners": "^2.9.2", "cli-spinners": "^3.4.0",
"ignore": "^6.0.2", "ignore": "^7.0.14",
"ink": "^7.0.3", "ink": "^8.0.0",
"picomatch": "^3.0.1", "picomatch": "^4.0.7",
"react": "^19.2.0", "react": "^19.3.1",
"turndown": "^7.2.0" "turndown": "^7.2.4"
}, },
"devDependencies": { "devDependencies": {
"@types/picomatch": "^3.0.1", "@types/picomatch": "^4.0.0",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/turndown": "^5.0.5" "@types/turndown": "^5.0.5"
} }

View file

@ -155,6 +155,51 @@ describe("taille du contexte", () => {
}; };
} }
function withTotal(total: number | undefined): LLMProvider {
return {
id: "fake",
model: "fake",
capabilities: { tools: true },
async *chat(): AsyncIterable<StreamEvent> {
yield { type: "text_delta", text: "réponse" };
yield { type: "usage", inputTokens: 100, outputTokens: 5, ...(total === undefined ? {} : { totalTokens: total }) };
yield { type: "done", stopReason: "end_turn" };
},
};
}
it("usage.total_tokens du serveur : la barre de contexte l'utilise, puis ajoute l'évolution locale", async () => {
const reported: number[] = [];
const session = createSession({
provider: withTotal(5000),
init: { systemPrompt: "prompt système", tools: [echo] },
ui: { ...silentUI, onContextUsage: (n) => reported.push(n) },
cwd: process.cwd(),
});
await runTurn(session, "bonjour");
expect(reported.at(-1)).toBe(5000);
expect(sessionContextTokens(session)).toBe(5000);
// une pièce jointe (commande « ! ») pèse en plus du total annoncé
session.pendingContext.push("sortie de commande ".repeat(50));
expect(sessionContextTokens(session)).toBeGreaterThan(5000);
// conversation effacée : le total n'est plus valable, retour au comptage local
session.messages.length = 0;
session.pendingContext.length = 0;
expect(sessionContextTokens(session)).toBeLessThan(500);
});
it("sans total_tokens : comptage local comme avant", async () => {
const session = createSession({
provider: withTotal(undefined),
init: { systemPrompt: "prompt système", tools: [echo] },
ui: silentUI,
cwd: process.cwd(),
});
await runTurn(session, "bonjour");
expect(session.serverContext).toBeUndefined();
expect(sessionContextTokens(session)).toBeLessThan(500);
});
it("ne fait que croître quand la conversation s'enrichit (aucune variation due au serveur)", async () => { it("ne fait que croître quand la conversation s'enrichit (aucune variation due au serveur)", async () => {
const reported: number[] = []; const reported: number[] = [];
const session = createSession({ const session = createSession({
@ -169,7 +214,8 @@ describe("taille du contexte", () => {
beforePrompts.push(sessionContextTokens(session)); // valeur affichée avant la saisie suivante beforePrompts.push(sessionContextTokens(session)); // valeur affichée avant la saisie suivante
} }
for (let i = 1; i < beforePrompts.length; i++) expect(beforePrompts[i]!).toBeGreaterThan(beforePrompts[i - 1]!); for (let i = 1; i < beforePrompts.length; i++) expect(beforePrompts[i]!).toBeGreaterThan(beforePrompts[i - 1]!);
for (let i = 1; i < reported.length; i++) expect(reported[i]!).toBeGreaterThan(reported[i - 1]!); // pendant la réponse, l'estimation en direct peut égaler le comptage final : jamais de recul
for (let i = 1; i < reported.length; i++) expect(reported[i]!).toBeGreaterThanOrEqual(reported[i - 1]!);
expect(reported.at(-1)).toBe(beforePrompts.at(-1)); expect(reported.at(-1)).toBe(beforePrompts.at(-1));
}); });
@ -182,14 +228,46 @@ describe("taille du contexte", () => {
cwd: process.cwd(), cwd: process.cwd(),
}); });
await runTurn(session, "go"); await runTurn(session, "go");
// mise à jour à chaque étape : demande, réponse avec appel d'outil, résultat de l'outil, réponse finale // mise à jour à chaque étape (demande, réponse avec appel d'outil, résultat de l'outil, réponse finale) et en direct
expect(seen).toHaveLength(4); expect(seen.length).toBeGreaterThanOrEqual(4);
expect(seen[0]).toBeGreaterThan(0); expect(seen[0]).toBeGreaterThan(0);
for (let i = 1; i < seen.length; i++) expect(seen[i]!).toBeGreaterThan(seen[i - 1]!); for (let i = 1; i < seen.length; i++) expect(seen[i]!).toBeGreaterThanOrEqual(seen[i - 1]!);
expect(seen.at(-1)).toBe(sessionContextTokens(session)); expect(seen.at(-1)).toBe(sessionContextTokens(session));
expect(seen.at(-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("pendant une longue réponse, la barre avance avant la fin (au plus toutes les 250 ms) et rejoint le comptage final", async () => {
const seen: { at: number; n: number }[] = [];
let streaming = false;
const slow: LLMProvider = {
id: "fake",
model: "fake",
capabilities: { tools: true },
async *chat(): AsyncIterable<StreamEvent> {
streaming = true;
for (let i = 0; i < 12; i++) {
yield { type: "text_delta", text: `morceau ${i} de la réponse, avec du texte en plus. `.repeat(20) };
await new Promise((r) => setTimeout(r, 60));
}
streaming = false;
yield { type: "done", stopReason: "end_turn" };
},
};
const session = createSession({
provider: slow,
init: { systemPrompt: "s", tools: [] },
ui: { ...silentUI, onContextUsage: (n) => void seen.push({ at: Date.now(), n: streaming ? -n : n }) },
cwd: process.cwd(),
});
await runTurn(session, "go");
const during = seen.filter((s) => s.n < 0).map((s) => -s.n);
expect(during.length).toBeGreaterThanOrEqual(2); // ≈ 720 ms de flux → plusieurs mises à jour
expect(during.length).toBeLessThan(12); // limitées dans le temps, pas une par morceau
for (let i = 1; i < during.length; i++) expect(during[i]!).toBeGreaterThan(during[i - 1]!);
expect(seen.at(-1)!.n).toBe(sessionContextTokens(session));
expect(during.at(-1)!).toBeLessThanOrEqual(sessionContextTokens(session));
});
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", () => {
const session = createSession({ provider: chatty(), init: { systemPrompt: "t", tools: [] }, ui: silentUI, cwd: "." }); const session = createSession({ provider: chatty(), init: { systemPrompt: "t", tools: [] }, ui: silentUI, cwd: "." });
const base = sessionContextTokens(session); const base = sessionContextTokens(session);

View file

@ -1,4 +1,4 @@
import { countContextTokens } from "./context"; import { countContextTokens, countTextTokens } from "./context";
import { runToolCallsDetailed, type ToolUseBlock } from "./tool-runner"; import { runToolCallsDetailed, type ToolUseBlock } from "./tool-runner";
import type { import type {
AgentSkill, AgentSkill,
@ -28,6 +28,12 @@ export interface AgentSession {
startContext?: string; startContext?: string;
/** Contexte ajouté par l'utilisateur hors conversation (ex. résultat d'une commande "!"), joint à sa prochaine demande. */ /** Contexte ajouté par l'utilisateur hors conversation (ex. résultat d'une commande "!"), joint à sa prochaine demande. */
pendingContext: string[]; pendingContext: string[];
/**
* Dernière taille de contexte annoncée par le serveur (`usage.total_tokens`) : `total` tokens quand le comptage local
* donnait `local`. Valable tant que `messages[index]` est encore le message `anchor` (changement de session, effacement
* ou retour en arrière de la conversation : on repasse au comptage local).
*/
serverContext?: { total: number; local: number; anchor: Message; index: number };
ui: AgentUI; ui: AgentUI;
cwd: string; cwd: string;
} }
@ -61,6 +67,9 @@ 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). */
/** done : réponse finale · max_iterations : limite atteinte · stopped : l'utilisateur a répondu « stop » (rien n'est annulé). */ /** done : réponse finale · max_iterations : limite atteinte · stopped : l'utilisateur a répondu « stop » (rien n'est annulé). */
/** Intervalle minimal entre deux mises à jour de la barre de contexte pendant la réponse du modèle. */
export const LIVE_CONTEXT_INTERVAL_MS = 250;
export type TurnEnd = "done" | "max_iterations" | "stopped"; export type TurnEnd = "done" | "max_iterations" | "stopped";
@ -107,6 +116,21 @@ export async function runTurn(
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
let reportedOutput = 0; // tokens de sortie annoncés par le serveur let reportedOutput = 0; // tokens de sortie annoncés par le serveur
let reportedTotal: number | undefined; // usage.total_tokens annoncé par le serveur
// Pendant la réponse du modèle, la barre de contexte avance avec le texte reçu (au plus toutes les LIVE_CONTEXT_INTERVAL_MS)
// Calculé à la première mise à jour seulement : sans interface qui l'affiche, aucun comptage (le premier charge le vocabulaire)
let baseTokens: number | null = null;
let streamedTools = 0;
let lastLive = 0;
const live = (force = false): void => {
if (!ui.onContextUsage) return;
const now = Date.now();
if (!force && now - lastLive < LIVE_CONTEXT_INTERVAL_MS) return;
lastLive = now;
baseTokens ??= sessionContextTokens(session) + 4; // + structure du message assistant à venir
ui.onContextUsage(baseTokens + countTextTokens(text) + streamedTools);
};
ui.onThinking(true); ui.onThinking(true);
let first = true; let first = true;
@ -126,14 +150,18 @@ export async function runTurn(
if (ev.type === "text_delta") { if (ev.type === "text_delta") {
text += ev.text; text += ev.text;
ui.onText(ev.text); ui.onText(ev.text);
live();
} else if (ev.type === "tool_use") { } else if (ev.type === "tool_use") {
assistant.push({ type: "tool_use", id: ev.id, name: ev.name, input: ev.input }); assistant.push({ type: "tool_use", id: ev.id, name: ev.name, input: ev.input });
if (ui.onContextUsage) streamedTools += countTextTokens(ev.name + JSON.stringify(ev.input));
live(true);
} else if (ev.type === "usage") { } else if (ev.type === "usage") {
reportedOutput = ev.outputTokens; reportedOutput = ev.outputTokens;
reportedTotal = ev.totalTokens;
} else if (ev.type === "done" && ev.stopReason === "max_tokens") { } else if (ev.type === "done" && ev.stopReason === "max_tokens") {
truncated = true; truncated = true;
} }
// La consommation du serveur ne sert pas à la taille du contexte (comptée localement), seulement à repérer une coupure. // La consommation du serveur sert à repérer une coupure et, si elle donne total_tokens, de référence à la barre de contexte.
// "done" : la fin du flux suffit, les appels d'outils décident de la suite. // "done" : la fin du flux suffit, les appels d'outils décident de la suite.
} }
} finally { } finally {
@ -144,7 +172,17 @@ export async function runTurn(
ui.onTextEnd(); ui.onTextEnd();
assistant.unshift({ type: "text", text }); assistant.unshift({ type: "text", text });
} }
session.messages.push({ role: "assistant", content: assistant }); const assistantMessage: Message = { role: "assistant", content: assistant };
session.messages.push(assistantMessage);
if (reportedTotal !== undefined) {
// Le serveur donne la taille réelle du contexte (requête + réponse) : le comptage local ne sert plus qu'aux écarts suivants
session.serverContext = {
total: reportedTotal,
local: localContextTokens(session),
anchor: assistantMessage,
index: session.messages.length - 1,
};
}
ui.onContextUsage?.(sessionContextTokens(session)); ui.onContextUsage?.(sessionContextTokens(session));
// Certains serveurs (LiteLLM derrière Bedrock/Anthropic…) « réparent » un appel d'outil coupé par la limite de sortie et // Certains serveurs (LiteLLM derrière Bedrock/Anthropic…) « réparent » un appel d'outil coupé par la limite de sortie et
// annoncent « tool_calls » au lieu de « length » : une sortie qui tombe pile sur une limite usuelle (4096…) est suspecte. // annoncent « tool_calls » au lieu de « length » : une sortie qui tombe pile sur une limite usuelle (4096…) est suspecte.
@ -173,10 +211,24 @@ export async function runTurn(
} }
/** /**
* Taille du contexte de la session en cours, comptée avec js-tiktoken : prompt système, outils, messages, plus le * Taille du contexte de la session en cours : la valeur annoncée par le serveur (`usage.total_tokens`) à la dernière
* contexte en attente (résultats de commandes « ! », fichiers cités) qui sera joint à la prochaine demande. * réponse, plus l'évolution comptée localement depuis ; sans valeur du serveur (ou si elle n'est plus valable), comptage
* local seul (voir localContextTokens).
*/ */
export function sessionContextTokens( export function sessionContextTokens(
session: Pick<AgentSession, "system" | "messages" | "tools" | "pendingContext" | "serverContext">,
): number {
const local = localContextTokens(session);
const sc = session.serverContext;
if (sc && session.messages[sc.index] === sc.anchor) return Math.max(0, sc.total + (local - sc.local));
return local;
}
/**
* Taille du contexte comptée avec js-tiktoken : prompt système, outils, messages, plus le contexte en attente
* (résultats de commandes « ! », fichiers cités) qui sera joint à la prochaine demande.
*/
export function localContextTokens(
session: Pick<AgentSession, "system" | "messages" | "tools" | "pendingContext">, session: Pick<AgentSession, "system" | "messages" | "tools" | "pendingContext">,
): number { ): number {
return countContextTokens({ return countContextTokens({

View file

@ -40,8 +40,10 @@ export type StreamEvent =
* Consommation de la requête, quand le serveur la renvoie (avant "done"). * Consommation de la requête, quand le serveur la renvoie (avant "done").
* inputTokens : tout ce qui a été envoyé · outputTokens : ce que la réponse ajoute à la conversation * inputTokens : tout ce qui a été envoyé · outputTokens : ce que la réponse ajoute à la conversation
* (hors tokens de raisonnement, qui ne sont pas renvoyés au modèle au tour suivant). * (hors tokens de raisonnement, qui ne sont pas renvoyés au modèle au tour suivant).
* totalTokens : `usage.total_tokens` tel que renvoyé par le serveur (absent si le serveur ne le donne pas) : il sert de
* référence à la barre de contexte ; sans lui, le contexte est compté localement.
*/ */
| { type: "usage"; inputTokens: number; outputTokens: number } | { type: "usage"; inputTokens: number; outputTokens: number; totalTokens?: number }
| { type: "done"; stopReason: StopReason }; | { type: "done"; stopReason: StopReason };
export interface LLMProvider { export interface LLMProvider {

View file

@ -124,11 +124,19 @@ describe("format OpenAI", () => {
"data: [DONE]\n\n"; "data: [DONE]\n\n";
expect(await collect(parseSse(chunks(raw)))).toEqual([ expect(await collect(parseSse(chunks(raw)))).toEqual([
{ type: "text_delta", text: "ok" }, { type: "text_delta", text: "ok" },
{ type: "usage", inputTokens: 1200, outputTokens: 30 }, { type: "usage", inputTokens: 1200, outputTokens: 30, totalTokens: 1230 },
{ type: "done", stopReason: "end_turn" }, { type: "done", stopReason: "end_turn" },
]); ]);
}); });
it("streaming : sans total_tokens dans la consommation, l'évènement n'a pas de totalTokens", async () => {
const raw =
sse({ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] }) +
sse({ choices: [], usage: { prompt_tokens: 10, completion_tokens: 2 } });
const ev = await collect(parseSse(chunks(raw)));
expect(ev).toContainEqual({ type: "usage", inputTokens: 10, outputTokens: 2 });
});
it("streaming : les tokens de raisonnement ne comptent pas dans le contexte conservé", async () => { it("streaming : les tokens de raisonnement ne comptent pas dans le contexte conservé", async () => {
const raw = const raw =
sse({ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] }) + sse({ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] }) +
@ -137,7 +145,7 @@ describe("format OpenAI", () => {
usage: { prompt_tokens: 1000, completion_tokens: 900, total_tokens: 1900, completion_tokens_details: { reasoning_tokens: 850 } }, usage: { prompt_tokens: 1000, completion_tokens: 900, total_tokens: 1900, completion_tokens_details: { reasoning_tokens: 850 } },
}); });
const ev = await collect(parseSse(chunks(raw))); const ev = await collect(parseSse(chunks(raw)));
expect(ev).toContainEqual({ type: "usage", inputTokens: 1000, outputTokens: 50 }); expect(ev).toContainEqual({ type: "usage", inputTokens: 1000, outputTokens: 50, totalTokens: 1900 });
}); });
it("erreur renvoyée dans le flux", async () => { it("erreur renvoyée dans le flux", async () => {

View file

@ -69,7 +69,7 @@ export async function* parseSse(body: AsyncIterable<Uint8Array>): AsyncGenerator
let finish: string | null | undefined; let finish: string | null | undefined;
let lastIndex: number | undefined; // index du dernier appel d'outil reçu let lastIndex: number | undefined; // index du dernier appel d'outil reçu
// objet plutôt que variable : l'assignation se fait dans un générateur imbriqué (TypeScript ne la verrait pas) // objet plutôt que variable : l'assignation se fait dans un générateur imbriqué (TypeScript ne la verrait pas)
const seen: { usage: { inputTokens: number; outputTokens: number } | null } = { usage: null }; const seen: { usage: { inputTokens: number; outputTokens: number; totalTokens?: number } | null } = { usage: null };
let buffer = ""; let buffer = "";
function* handleLine(line: string): Generator<StreamEvent> { function* handleLine(line: string): Generator<StreamEvent> {
@ -89,7 +89,12 @@ export async function* parseSse(body: AsyncIterable<Uint8Array>): AsyncGenerator
const completion = chunk.usage.completion_tokens ?? Math.max(0, (chunk.usage.total_tokens ?? input) - input); const completion = chunk.usage.completion_tokens ?? Math.max(0, (chunk.usage.total_tokens ?? input) - input);
// Les tokens de raisonnement sont facturés comme sortie mais ne font pas partie de la conversation renvoyée au tour suivant // Les tokens de raisonnement sont facturés comme sortie mais ne font pas partie de la conversation renvoyée au tour suivant
const output = Math.max(0, completion - (chunk.usage.completion_tokens_details?.reasoning_tokens ?? 0)); const output = Math.max(0, completion - (chunk.usage.completion_tokens_details?.reasoning_tokens ?? 0));
seen.usage = { inputTokens: input, outputTokens: output }; const total = chunk.usage.total_tokens;
seen.usage = {
inputTokens: input,
outputTokens: output,
...(typeof total === "number" && total > 0 ? { totalTokens: total } : {}),
};
} }
const choice = chunk.choices?.[0]; const choice = chunk.choices?.[0];
if (!choice) return; if (!choice) return;

View file

@ -10,10 +10,10 @@
"dependencies": { "dependencies": {
"@inkjs/ui": "^2.0.0", "@inkjs/ui": "^2.0.0",
"@maia/core": "workspace:*", "@maia/core": "workspace:*",
"cli-spinners": "^2.9.2", "cli-spinners": "^3.4.0",
"ink": "^7.0.3", "ink": "^8.0.0",
"markdansi": "^0.2.0", "markdansi": "^0.3.7",
"react": "^19.2.0" "react": "^19.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"

View file

@ -60,7 +60,8 @@ function CompactBanner({ title, columns }: { title: string; columns: number }) {
*/ */
export function Banner({ title }: { title: string }) { export function Banner({ title }: { title: string }) {
const { stdout } = useStdout(); const { stdout } = useStdout();
const columns = stdout?.columns ?? 80; // Ink 8 type stdout en WritableStream générique : `columns` n'existe que sur un vrai TTY, d'où le cast.
const columns = (stdout as { columns?: number } | undefined)?.columns ?? 80;
const titleLines = useMemo( const titleLines = useMemo(
() => (canRenderTitle(title) ? pixelsToRuns(buildTitleGrid(title)) : null), () => (canRenderTitle(title) ? pixelsToRuns(buildTitleGrid(title)) : null),
[title], [title],

View file

@ -1,4 +1,5 @@
// L'import de React reste nécessaire si un outil compile le JSX en mode « classique » (React.createElement) // L'import de React reste nécessaire si un outil compile le JSX en mode « classique » (React.createElement)
import { appendFileSync } from "node:fs";
import React, { useEffect, useRef, useState, useSyncExternalStore } from "react"; import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
import cliSpinners from "cli-spinners"; import cliSpinners from "cli-spinners";
import { Badge } from "@inkjs/ui"; import { Badge } from "@inkjs/ui";
@ -224,6 +225,28 @@ function InputFrame({ color, children }: { color: string; children: React.ReactN
const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter"; const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter";
/** Diagnostic des touches (MAIA_DEBUG_KEYS=1) : ajoute « input » et les drapeaux de `key` à ./.maia-keys.log. */
function logKey(input: string, key: object): void {
try {
const flags = Object.entries(key)
.filter(([, v]) => v === true)
.map(([k]) => k);
appendFileSync(".maia-keys.log", `${JSON.stringify({ input, flags })}\n`);
} catch {
/* diagnostic seulement */
}
}
/** Insère du texte au curseur. */
function insertText(s: EditState, text: string): EditState {
return {
value: s.value.slice(0, s.cursor) + text + s.value.slice(s.cursor),
cursor: s.cursor + text.length,
hist: null,
draft: s.draft,
};
}
function InputPrompt({ prompt }: { prompt: InputPromptState }) { function InputPrompt({ prompt }: { prompt: InputPromptState }) {
const initial: EditState = { value: "", cursor: 0, hist: null, draft: "" }; const initial: EditState = { value: "", cursor: 0, hist: null, draft: "" };
const [view, setView] = useState<EditState>(initial); const [view, setView] = useState<EditState>(initial);
@ -236,15 +259,25 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) {
useInput((input, key) => { useInput((input, key) => {
const s = ref.current; const s = ref.current;
if (process.env.MAIA_DEBUG_KEYS) logKey(input, key);
if (key.ctrl && input === "c") return prompt.resolve(null); if (key.ctrl && input === "c") return prompt.resolve(null);
if (key.ctrl && input === "d" && s.value === "") return prompt.resolve(null); if (key.ctrl && input === "d" && s.value === "") return prompt.resolve(null);
// Maj+Entrée (ou Alt+Entrée) : saut de ligne dans le prompt, pas de validation. Ctrl+J arrive comme un « \n » brut
// et est inséré par le chemin de saisie normal plus bas.
// Certains terminaux envoient Maj+Entrée comme la séquence « ESC O M » (Entrée du pavé numérique) : Ink en retire
// l'ESC et livre le texte « OM » en un seul bloc (une frappe normale arrive toujours caractère par caractère).
if (input === "OM" || input === "\x1bOM") return apply(insertText(s, "\n"));
// Ctrl+J : saut de ligne brut, quelle que soit la façon dont Ink le qualifie.
if (input === "\n") return apply(insertText(s, "\n"));
if (key.return && (key.shift || key.meta)) return apply(insertText(s, "\n"));
if (key.return) return prompt.resolve(s.value.trim()); if (key.return) return prompt.resolve(s.value.trim());
if (key.leftArrow) return apply({ ...s, cursor: Math.max(0, s.cursor - 1) }); if (key.leftArrow) return apply({ ...s, cursor: Math.max(0, s.cursor - 1) });
if (key.rightArrow) return apply({ ...s, cursor: Math.min(s.value.length, s.cursor + 1) }); if (key.rightArrow) return apply({ ...s, cursor: Math.min(s.value.length, s.cursor + 1) });
if (key.ctrl && input === "a") return apply({ ...s, cursor: 0 }); // Début / fin du prompt (tout le texte saisi, même sur plusieurs lignes) : Origine / Fin, ou Ctrl+A / Ctrl+E
if (key.ctrl && input === "e") return apply({ ...s, cursor: s.value.length }); if (key.home || (key.ctrl && input === "a")) return apply({ ...s, cursor: 0 });
if (key.end || (key.ctrl && input === "e")) return apply({ ...s, cursor: s.value.length });
if (key.ctrl && input === "u") return apply({ ...s, value: s.value.slice(s.cursor), cursor: 0, hist: null }); if (key.ctrl && input === "u") return apply({ ...s, value: s.value.slice(s.cursor), cursor: 0, hist: null });
if (key.ctrl && input === "w") { if (key.ctrl && input === "w") {
const before = s.value.slice(0, s.cursor).replace(/\S+\s*$/, ""); const before = s.value.slice(0, s.cursor).replace(/\S+\s*$/, "");
@ -279,13 +312,7 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) {
if (key.ctrl || key.meta || key.tab || key.escape || !input) return; if (key.ctrl || key.meta || key.tab || key.escape || !input) return;
// Collage : une seule chaîne, éventuellement multi-lignes // Collage : une seule chaîne, éventuellement multi-lignes
const text = input.replace(/\r\n?/g, "\n"); apply(insertText(s, input.replace(/\r\n?/g, "\n")));
apply({
value: s.value.slice(0, s.cursor) + text + s.value.slice(s.cursor),
cursor: s.cursor + text.length,
hist: null,
draft: s.draft,
});
}); });
const frame = useSpinnerFrame(INPUT_SPINNER); const frame = useSpinnerFrame(INPUT_SPINNER);

View file

@ -8,6 +8,7 @@ const EMPTY_COLOR = "#555555";
export function ContextBar({ used, max }: { used: number; max: number | null }) { export function ContextBar({ used, max }: { used: number; max: number | null }) {
const { stdout } = useStdout(); const { stdout } = useStdout();
// Ink 8 type stdout en WritableStream générique : `columns` n'existe que sur un vrai TTY, d'où le cast.
if (max === null) { if (max === null) {
// Taille maximale inconnue : pas de carrés, seulement le contexte utilisé // Taille maximale inconnue : pas de carrés, seulement le contexte utilisé
return ( return (
@ -16,7 +17,7 @@ export function ContextBar({ used, max }: { used: number; max: number | null })
</Box> </Box>
); );
} }
const columns = stdout?.columns ?? 80; const columns = (stdout as { columns?: number } | undefined)?.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}% `;
// Marge d'1 colonne de chaque côté ; le libellé passe avant les carrés // Marge d'1 colonne de chaque côté ; le libellé passe avant les carrés

View file

@ -41,7 +41,10 @@ describe("formatTokens", () => {
[12_345, "12.3k"], [12_345, "12.3k"],
[128_000, "128k"], [128_000, "128k"],
[200_000, "200k"], [200_000, "200k"],
[1_048_576, "1.05M"], [123_456, "123.5k"],
[123_400, "123.4k"],
[999_999, "1M"],
[1_048_576, "1.049M"],
[1_000_000, "1M"], [1_000_000, "1M"],
])("%d → %s", (n, expected) => expect(formatTokens(n)).toBe(expected)); ])("%d → %s", (n, expected) => expect(formatTokens(n)).toBe(expected));
}); });

View file

@ -36,12 +36,12 @@ export function contextBarModel(used: number, max: number, maxSquares = Infinity
return { total, filled, tokensPerSquare, ratio, level }; return { total, filled, tokensPerSquare, ratio, level };
} }
/** 950 → "950" · 12 345 → "12.3k" · 200 000 → "200k" · 1 048 576 → "1.05M". */ /**
* 950 → "950" · 12 345 → "12.3k" · 123 456 → "123.5k" · 200 000 → "200k" · 1 048 576 → "1.049M".
* Précision de 100 tokens jusqu'au million (puis 1 000) : la valeur affichée bouge à chaque étape, même au-delà de 100k.
*/
export function formatTokens(n: number): string { export function formatTokens(n: number): string {
if (n < 1_000) return String(Math.round(n)); if (n < 1_000) return String(Math.round(n));
if (n < 1_000_000) { if (n < 999_950) return `${Math.round(n / 100) / 10}k`;
const k = n / 1_000; return `${Math.round(n / 1_000) / 1_000}M`;
return `${k >= 100 ? Math.round(k) : Math.round(k * 10) / 10}k`;
}
return `${Math.round((n / 1_000_000) * 100) / 100}M`;
} }

View file

@ -15,7 +15,11 @@ export class InkUI implements AgentUI {
private start(): UIStore { private start(): UIStore {
// exitOnCtrlC: false : Ctrl+C est géré par les composants (annuler une saisie, interrompre un tour) // exitOnCtrlC: false : Ctrl+C est géré par les composants (annuler une saisie, interrompre un tour)
if (!this.app) this.app = render(<App store={this.store} />, { exitOnCtrlC: false }); if (!this.app) this.app = render(<App store={this.store} />, {
exitOnCtrlC: false,
// Protocole clavier « kitty » (négocié, ignoré si le terminal ne le gère pas) : distingue Maj+Entrée d'Entrée.
kittyKeyboard: { mode: "auto" },
});
return this.store; return this.store;
} }

File diff suppressed because it is too large Load diff