Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce4af8963 | ||
|
|
7d3885a735 | ||
|
|
7841bcd89f | ||
|
|
8f78710401 | ||
|
|
831efa644a | ||
|
|
9e66af7bf9 | ||
|
|
d4737812c1 | ||
|
|
2edbe71367 | ||
|
|
e4da4f9b87 |
No files matched your search
@@ -24,10 +24,11 @@ Ce dépôt est le marketplace A2M Systèmes. Actuellement, il contient le plugin
|
|||||||
|
|
||||||
Le plugin `miam` fournit :
|
Le plugin `miam` fournit :
|
||||||
- le serveur MCP `miam` (outils `etat`, `modeles`, `presets`, `demarrer`, `deleguer`, `suivre`…) ;
|
- le serveur MCP `miam` (outils `etat`, `modeles`, `presets`, `demarrer`, `deleguer`, `suivre`…) ;
|
||||||
- le subagent `miam:delegue`, qui délègue en laissant MIAM choisir le modèle, vérifie le résultat et rend un compte rendu court ;
|
|
||||||
- la skill `deleguer` (quand et comment déléguer) ;
|
- la skill `deleguer` (quand et comment déléguer) ;
|
||||||
- un compteur de tokens économisés (notification, ligne d'état, `/miam-economies`), net du pilotage et des
|
- un compteur de tokens économisés (notification, ligne d'état, `/miam-economies`), net du pilotage et des
|
||||||
corrections, sans coût en tokens.
|
corrections, en vert ou en rouge selon le signe du gain ;
|
||||||
|
- une seule ligne d'état avec le gain net et l'usage Claude sur 5 h et sur la semaine ;
|
||||||
|
- une incitation à déléguer (consigne système et rappel après une grosse lecture), désactivable.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -109,13 +110,12 @@ plugins/
|
|||||||
.claude-plugin/
|
.claude-plugin/
|
||||||
plugin.json # Métadonnées et version du plugin
|
plugin.json # Métadonnées et version du plugin
|
||||||
.mcp.json # Configuration du serveur MCP
|
.mcp.json # Configuration du serveur MCP
|
||||||
agents/
|
|
||||||
delegue.md # Définition du subagent de délégation
|
|
||||||
hooks/
|
hooks/
|
||||||
hooks.json # Liste des hooks
|
hooks.json # Liste des hooks
|
||||||
register.ts # Enregistrement des hooks (compteur de tokens)
|
register.tsx # Enregistrement des hooks (compteur, ligne d'état, incitation)
|
||||||
economies.ts # Calcul des économies de tokens
|
economies.ts # Calcul des économies de tokens
|
||||||
*.test.ts # Tests unitaires
|
usage.ts # Usage Claude 5 h et hebdo
|
||||||
|
*.test.ts(x) # Tests unitaires
|
||||||
skills/
|
skills/
|
||||||
deleguer/ # Skill pour déclencher la délégation
|
deleguer/ # Skill pour déclencher la délégation
|
||||||
SKILL.md
|
SKILL.md
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# À faire pour la prochaine version du plugin
|
||||||
|
|
||||||
|
Notes issues de la session MIAM du 2026-11-01. Rien n'est encore appliqué.
|
||||||
|
|
||||||
|
## 1. Autoriser le repli sur un subagent Haiku (demande de l'utilisateur)
|
||||||
|
|
||||||
|
`plugins/miam/skills/deleguer/SKILL.md`, ligne 24, dit « Pas de subagent ». Vérifier aussi le texte équivalent injecté
|
||||||
|
par les hooks et la documentation (`docs/agent-delegue.md`, `plugins/miam/README.md`).
|
||||||
|
|
||||||
|
Nouvelle règle, ordre de délégation :
|
||||||
|
|
||||||
|
1. MIAM d'abord, pour les tâches de son champ.
|
||||||
|
2. Subagent `model: "haiku"`, sans demander, pour une tâche longue et simple si MIAM est arrêté ou mal adapté : lire un
|
||||||
|
gros document ou une longue sortie pour en tirer peu, recherche large dans le code, tests répétitifs.
|
||||||
|
3. Claude principal pour ce qui demande le contexte de la conversation, pour la sécurité et pour la dépense.
|
||||||
|
|
||||||
|
Pas de subagent pour une petite tâche : un `grep` ou une lecture directe coûte moins cher qu'un agent qui repart de zéro.
|
||||||
|
|
||||||
|
À décider : le coût d'un subagent Haiku doit-il entrer dans les événements `pilotage` et dans le cumul, et comment le
|
||||||
|
distinguer d'un pilotage de MIAM ?
|
||||||
|
|
||||||
|
## 2. Vérifier avant de déléguer, dresser le bilan après
|
||||||
|
|
||||||
|
À ajouter au skill `deleguer` :
|
||||||
|
|
||||||
|
- **Faisable ?** OpenCode ne voit que le code commité et n'a pas de `.venv` utilisable : ne pas lui demander les tests.
|
||||||
|
- **Le preset tient ?** Appeler `modeles` avant d'imposer un preset.
|
||||||
|
- **Rentable ?** Pas de délégation si la consigne dépasse un tiers de la sortie attendue, ou si une recherche suffit.
|
||||||
|
- **Indépendant ?** Ne pas importer un module pas encore livré : MIAM peut tourner depuis le dépôt qu'il modifie.
|
||||||
|
- **Après un lot :** bilan des délégations, sans attendre la demande : coût, résultat, faute, règle ajoutée.
|
||||||
|
|
||||||
|
## 3. Rendre le cumul fiable (PH2-H10 de MIAM)
|
||||||
|
|
||||||
|
Le cumul mesure le volume, pas la justesse. Sur la session, 124 625 tokens évités sur 139 707 étaient fictifs.
|
||||||
|
|
||||||
|
- Ne compter aucun gain pour une réponse vide ou tronquée.
|
||||||
|
- Pouvoir marquer une délégation fausse comme rejetée, pour annuler son gain et compter sa vérification.
|
||||||
|
- Ne pas compter comme évitée une lecture que Claude n'aurait pas faite, comme une relecture imposée au démarrage.
|
||||||
|
|
||||||
|
## 4. Imposer les règles par des hooks
|
||||||
|
|
||||||
|
La mémoire et les skills sont du texte que Claude peut ignorer : la règle « un seul suivi » était notée et il y a eu plus
|
||||||
|
de 15 `suivre`. Ne garder en texte que ce qui demande du jugement ; ce qui se mesure passe dans `tool.call`, à côté de
|
||||||
|
`contrainte.ts`, en fonctions pures testées.
|
||||||
|
|
||||||
|
À vérifier d'abord : `tool.call` peut-il refuser un appel avant son exécution ? Les rappels actuels arrivent après coup.
|
||||||
|
|
||||||
|
| Contrôle | Déclencheur | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| Suivi | `suivre` à moins de N secondes du précédent, ou Nᵉ appel sur la même demande | Refus, avec le délai à respecter |
|
||||||
|
| OpenCode et tests | `deleguer(moteur="opencode")` dont la consigne demande de lancer pytest | Refus, avec la raison |
|
||||||
|
| Preset | `deleguer(preset=…)` sans `modeles` dans la session | Refus |
|
||||||
|
| Rentabilité | Consigne de plus de N caractères | Rappel, le seuil reste à régler |
|
||||||
|
| Sortie longue | Lecture ou commande non filtrée | Rappel avant l'appel si possible, sinon après |
|
||||||
|
| Fin de lot | Fin de tour après des délégations | Injection du bilan mesuré : coût, échecs, reprises |
|
||||||
|
|
||||||
|
Hors de portée d'un hook, à garder en texte : juger si une réponse est juste, ou si un `grep` suffisait.
|
||||||
|
Chaque refus doit dire quoi faire à la place et se compter, pour régler les seuils sur des chiffres réels.
|
||||||
|
|
||||||
|
## 5. Relecture de démarrage
|
||||||
|
|
||||||
|
Si elle vient du plugin, la relecture de `plan/Plan.md` et `plan/tasks-P2.md` à chaque session coûte 62 000 tokens lus et
|
||||||
|
a donné une réponse vide puis une réponse fausse. La remplacer par une consigne ciblée sur la demande en cours, ou par une
|
||||||
|
recherche.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: delegue
|
name: delegue
|
||||||
description: Délègue à un modèle local géré par MIAM une tâche autonome et vérifiable, pour alléger l'agent principal — extraire des faits d'un log ou d'une sortie pytest/ruff, résumer un document long ou répondre à des questions dessus, traduire FR↔EN, docstrings, notes de version, message de commit, fonction pure et ses tests, tests répétitifs à partir d'un modèle. Lui donner la tâche, les chemins absolus des fichiers, le format voulu (texte, code, json), le fichier de sortie éventuel et comment vérifier. Pas d'architecture, de débogage multi-fichiers, de concurrence, de sécurité, ni de secrets ou données personnelles.
|
description: À utiliser de façon proactive. Délègue à un modèle local géré par MIAM une tâche autonome et vérifiable, pour alléger l'agent principal — extraire des faits d'un log ou d'une sortie pytest/ruff, résumer un document long ou répondre à des questions dessus, traduire FR↔EN, docstrings, notes de version, message de commit, fonction pure et ses tests, tests répétitifs à partir d'un modèle. Lui donner la tâche, les chemins absolus des fichiers, le format voulu (texte, code, json), le fichier de sortie éventuel et comment vérifier. Pas d'architecture, de débogage multi-fichiers, de concurrence, de sécurité, ni de secrets ou données personnelles.
|
||||||
model: haiku
|
model: haiku
|
||||||
effort: low
|
effort: low
|
||||||
color: orange
|
color: orange
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
# Forcer la délégation par les hooks
|
||||||
|
|
||||||
|
État au 2026-11-02, base de travail du plugin. But : que Claude pilote et que MIAM, l'API ou Haiku écrivent.
|
||||||
|
Claude découpe, confie, contrôle par des machines, et n'écrit lui-même que ce qui ne peut pas être confié.
|
||||||
|
|
||||||
|
## 1. Constat
|
||||||
|
|
||||||
|
**MIAM (v2.5.2-dev, phase 2)** sait déjà router : modèle local (27B, 15,5 Go, un seul à la fois), API OpenRouter
|
||||||
|
(plafond 2 $/jour, ~0,0135 $ dépensés le 2026-11-02), OpenCode, et Haiku côté Claude. `moteur="auto"` choisit entre
|
||||||
|
local et API. Les moyens existent ; c'est Claude qui ne les utilise pas spontanément.
|
||||||
|
|
||||||
|
**Journal** : 185 délégations (165 ok, 10 refusées, 3 erreurs, 3 annulées, 4 rejetées), dont 9 API et 5 OpenCode.
|
||||||
|
Par jour : 103 (30/10), 52, 19, 12 (02/11, journée en cours). La baisse n'est pas rapportée au volume de travail : elle
|
||||||
|
alerte, elle ne prouve rien.
|
||||||
|
|
||||||
|
**Session du 2026-11-02, tâche PH2-D1** : seule la lecture imposée par le hook de démarrage a été déléguée. 8 tests
|
||||||
|
répétitifs écrits à la main alors que leur modèle était dans le fichier. Les délégations suivantes ont toutes suivi un
|
||||||
|
rappel de l'utilisateur.
|
||||||
|
|
||||||
|
### Pourquoi les hooks actuels n'ont pas suffi
|
||||||
|
|
||||||
|
| Mécanisme actuel | Ce qui a échappé |
|
||||||
|
|---|---|
|
||||||
|
| Refus de `Read` si fichier ≥ 20 000 caractères sans `limit` ≤ 400 | Contourné légalement : plusieurs plages de 75 à 240 lignes sur `instances.py`, `allow_large` sur des lignes géantes de `tasks-P2.md` |
|
||||||
|
| Retenue des sorties shell ≥ 20 000 caractères | Ne voit que la taille d'un appel, pas le cumul |
|
||||||
|
| Rappel texte (1 toutes les 10 min), consigne `prompt.compose`, mémoire | Coût nul pour Claude au moment de décider : ignorés |
|
||||||
|
| `Edit`, `Write`, `NotebookEdit` | **Aucun contrôle** : seule la comptabilité des corrections |
|
||||||
|
| Suivi « ce tour a délégué » | **Non présent** pour l'agent principal |
|
||||||
|
| Hooks de fin de tour et de soumission | **Non présents** |
|
||||||
|
|
||||||
|
Conclusion : une consigne perd face à la facilité. Il faut qu'écrire soi-même soit plus lent que déléguer, à l'instant
|
||||||
|
où l'écriture est tentée.
|
||||||
|
|
||||||
|
### Frictions côté délégation
|
||||||
|
|
||||||
|
- `deleguer` sans `moteur` échoue si aucun modèle n'est chargé ; il faut penser à `moteur="auto"`.
|
||||||
|
- Le bench occupe le GPU ; `auto` local peut le préempter (corrigé pour les refus, `093f110`, pas pour le reste).
|
||||||
|
- `plugin-claude-miam` n'est pas autorisé pour l'API, et la config affiche `projets: null` pour OpenRouter (à éclaircir).
|
||||||
|
- Un fichier hors projet (scratchpad) fait échouer le routage API.
|
||||||
|
- Rien n'oriente vers Haiku quand local et API sont indisponibles.
|
||||||
|
- Pas de contrôle automatique du résultat : Claude relit tout, ce qui annule une partie du gain.
|
||||||
|
|
||||||
|
## 2. Principes
|
||||||
|
|
||||||
|
1. **Refuser, pas rappeler.** Un `deny` au moment de l'écriture, avec l'appel prêt à copier dans le message.
|
||||||
|
2. **Budget cumulé, pas seuil par appel.** Compter lignes écrites et caractères lus par tour et par fichier.
|
||||||
|
3. **Découper avant d'écrire.** Pas de code sans liste de bouts.
|
||||||
|
4. **La machine juge, Claude arbitre.** Tests, ruff et contrat décident ; Claude ne lit qu'en cas d'échec.
|
||||||
|
5. **Une sortie de secours chère et visible.** Exception motivée, journalisée, affichée.
|
||||||
|
6. **Ne jamais bloquer le travail.** Si aucun exécutant ne répond, la règle s'ouvre et le signale.
|
||||||
|
|
||||||
|
## 3. Pistes de hooks
|
||||||
|
|
||||||
|
Seuils proposés, à régler sur les mesures.
|
||||||
|
|
||||||
|
| # | Hook | Déclencheur | Action | Priorité |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | **Portillon tests** | `Write`/`Edit` de l'agent principal sur `tests/**`, `*.test.*` ajoutant > 10 lignes | `deny` + appel `deleguer` prêt | P0 |
|
||||||
|
| 2 | **Budget d'écriture** | Cumul > 30 lignes de code ajoutées par l'agent principal depuis la dernière délégation réussie | `deny` jusqu'à une délégation ou une exception | P0 |
|
||||||
|
| 3 | **Portillon commit** | `git commit` sans message issu de `deleguer` dans le tour | `deny` | P0 |
|
||||||
|
| 4 | **Écritures par shell** | `Bash`/`PowerShell` avec `Set-Content`, `Out-File`, `>`, `sed -i`, Python écrivant un fichier | Comptés comme le hook 2 ; sinon le contournement est immédiat | P0 |
|
||||||
|
| 5 | **Budget de lecture** | Cumul par tour > 40 000 caractères, ou > 400 lignes par fichier, `allow_large` et `Grep` compris | `deny` + « confie la question » | P1 |
|
||||||
|
| 6 | **Portillon découpage** | Premier `Edit`/`Write` de code d'un tour sans plan | `deny` : écrire `.claude/miam-plan.json` (section 4) | P1 |
|
||||||
|
| 7 | **Sélecteur d'exécutant** | Tout `deny` ci-dessus | Message calculé : local si prêt et sans bench ; API si projet autorisé ; sinon subagent Haiku | P1 |
|
||||||
|
| 8 | **Bilan de fin de tour** | Fin de tour de l'agent principal | Injecte lignes écrites par Claude et par délégation ; bloque la fin si un bout du plan reste « délégable » non traité | P2 |
|
||||||
|
| 9 | **Compteur visible** | Ligne d'état | `délégué 82 % · exceptions 1` pour le tour et la session | P2 |
|
||||||
|
| 10 | **Docs et docstrings** | `*.md` hors `plan/`, docstrings ajoutées en lot | Comme le hook 1 | P2 |
|
||||||
|
| 11 | **Attente unique** | `deleguer` en file (`ok: null` avec `reprise`) ; commande `/attendre` lancée sans `run_in_background` | Injecte « lance `reprise` en arrière-plan » ; `deny` sur l'appel au premier plan | P0 |
|
||||||
|
| 12 | **Pas de suivi pendant l'attente** | `suivre` sur une demande attendue, `sleep` ou boucle d'attente en `Bash` | `deny` : « tu seras réveillé à la fin » ; permis si `fin` vaut `besoin_claude` | P0 |
|
||||||
|
| 13 | **Confiance au verdict** | `Read` complet du fichier `sortie` après `fin: "valide"` | `deny` hors chemins sensibles : aperçu ou `offset`/`limit` | P1 |
|
||||||
|
|
||||||
|
**Hors comptage** : fichiers écrits par MIAM (déjà mesurés en corrections), `plan/**` et journal de suivi,
|
||||||
|
modifications ≤ 3 lignes, subagents (`agentId` défini), config locale.
|
||||||
|
|
||||||
|
**Exception** : outil `exception(raison, fichiers, lignes)` avec motifs fermés (`concurrence`, `securite`,
|
||||||
|
`debogage-multi-fichiers`, `secret`). Elle ouvre un budget limité aux fichiers cités, est journalisée et apparaît dans le
|
||||||
|
bilan. Pour la concurrence, liste de chemins sensibles à définir plutôt qu'un jugement de Claude.
|
||||||
|
|
||||||
|
**Repli** : si aucun des trois exécutants ne répond, règle ouverte pour le tour et toast. Réutiliser `joignable`.
|
||||||
|
|
||||||
|
**Options** : garder `imposer` et `incitation`, ajouter `budget_ecriture`, `budget_lecture`, `chemins_sensibles`.
|
||||||
|
|
||||||
|
### À vérifier avant de coder
|
||||||
|
|
||||||
|
- Existence d'un événement de fin de tour de l'agent principal capable de relancer Claude (`turn.complete` n'est
|
||||||
|
utilisé que pour les subagents).
|
||||||
|
- Capacité de `tool.call` à lire le contenu de `Write`/`Edit` avant exécution pour compter les lignes.
|
||||||
|
- Possibilité de déclarer `exception` : outil MCP côté MIAM, ou fichier lu par le hook.
|
||||||
|
|
||||||
|
## 4. Découper en bouts atomiques
|
||||||
|
|
||||||
|
Plan attendu par le hook 6 :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "tache": "PH2-D1", "bouts": [
|
||||||
|
{ "id": 1, "quoi": "tests start_all", "fichier": "tests/x.py", "moteur": "auto", "modele": "tests/x.py:613-637",
|
||||||
|
"contrat": "4 instances, ordre d, b, c, erreurs par nom", "controle": "pytest -k start_all" },
|
||||||
|
{ "id": 2, "quoi": "ordre des réservations dans start_instance", "moteur": "claude", "motif": "concurrence" }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Un bout délégable : **une** fonction ou un groupe de tests, **un** fichier de sortie, ≤ 80 lignes, signature donnée,
|
||||||
|
exemple à imiter en plage de lignes, contrôle exécutable. Consigne en anglais, langue de sortie précisée. Modules
|
||||||
|
joints, car le 27B invente champs et valeurs.
|
||||||
|
|
||||||
|
Relecture de D1 avec ce découpage :
|
||||||
|
|
||||||
|
| Bout | Exécutant possible |
|
||||||
|
|---|---|
|
||||||
|
| Choix du port réservé, messages d'erreur, `start_all`, `stop_all` à partir de leur contrat | Délégable, contrôlé par tests |
|
||||||
|
| 8 tests, tests du view model | Délégable, modèles présents |
|
||||||
|
| Boutons du dashboard, branchement du tray | Délégable, patron existant |
|
||||||
|
| Lignes de suivi, message de commit | Délégable |
|
||||||
|
| Absence d'`await` entre contrôle et réservation dans `start_instance` | Claude (concurrence) |
|
||||||
|
| Inventaire des fichiers à modifier | Délégable ; Claude lit le résultat |
|
||||||
|
|
||||||
|
Claude n'aurait gardé que le découpage, le point de concurrence et l'intégration.
|
||||||
|
|
||||||
|
## 5. Faire confiance aux retours
|
||||||
|
|
||||||
|
Contrôle par niveaux, du moins cher au plus cher :
|
||||||
|
|
||||||
|
1. **Automatique** : `format="code"`, `sortie=`, `retour="apercu"` ; pytest, ruff, `ruff format --check`.
|
||||||
|
2. **Test rouge d'abord** : le test échoue sans le code, passe avec.
|
||||||
|
3. **Taille du diff** : conforme au bout annoncé.
|
||||||
|
4. **Lecture par Claude** : seulement si un contrôle échoue, ou en zone sensible.
|
||||||
|
|
||||||
|
En cas d'échec, renvoyer la sortie du test à l'exécutant (2 essais) avant toute reprise par Claude.
|
||||||
|
|
||||||
|
**Pièges connus du 27B à garder en contrôle** : docstrings omises, valeurs inventées ; ajouter docstrings et champs
|
||||||
|
aux contrôles plutôt qu'à la relecture.
|
||||||
|
|
||||||
|
**Risque accepté** : des erreurs passeront les tests. La parade est de meilleurs contrats, avec la revue par Claude
|
||||||
|
réservée aux zones sensibles et au diff final.
|
||||||
|
|
||||||
|
## 5 bis. Contrôle automatique des réponses par MIAM
|
||||||
|
|
||||||
|
Livré dans MIAM (PH2-H11), sauf `commande` et `perimetre` ; contrat exact en section 11. **But** : Claude reçoit un verdict plutôt que le code ; MIAM juge, retente, escalade.
|
||||||
|
|
||||||
|
**Contrat de la demande** : nouveau paramètre optionnel `controle` sur `deleguer`.
|
||||||
|
|
||||||
|
| contrôle | s'applique à | ce qu'il vérifie |
|
||||||
|
|---|---|---|
|
||||||
|
| `format` | tous | JSON valide, schéma si fourni ; Python via `ast.parse`, TypeScript via transpile uniquement |
|
||||||
|
| `complet` | tous | non vide, non tronqué, symboles demandés présents (fonctions, nombre de tests) |
|
||||||
|
| `symboles` | code | chaque identifiant importé ou appelé existe dans les modules joints, pour attraper les champs inventés (faiblesse connue du 27B) |
|
||||||
|
| `docstrings` | code | fonctions publiques documentées (faiblesse connue du 27B) |
|
||||||
|
| `lint` | code | `ruff check` et `ruff format --check`, ou l'équivalent du projet |
|
||||||
|
| `commande` | code | commande de test lancée dans un clone à l'intérieur du bac à sable AppContainer existant, sans réseau, commandes limitées à celles déclarées par projet |
|
||||||
|
| `perimetre` | code | seuls les fichiers déclarés changent |
|
||||||
|
| `citations` | extraction, résumé | toute valeur, nombre ou identifiant cité doit se retrouver dans les fichiers source |
|
||||||
|
| `traduction` | traduction | placeholders, code et nombres conservés, ratio de longueur plausible |
|
||||||
|
|
||||||
|
**Défauts par type** : `format` + `complet` toujours ; code ajoute `symboles`, `docstrings`, `lint` ; extraction ajoute `citations` ; `commande` seulement si fourni.
|
||||||
|
|
||||||
|
**Boucle** : en cas d'échec, MIAM renvoie l'échec exact au modèle, 2 tentatives maximum, puis escalade local → API → rend la main à Claude avec statut `echec_controle`, sans accepter silencieusement.
|
||||||
|
|
||||||
|
**Réponse à Claude** : champs `verdict` (`valide`, `echec_controle`, `non_controle`), `controles` (nom, résultat, message court), `essais`, `moteur_final`, `sortie` (chemin de fichier), stats de diff ; pas de corps de code quand `valide` et `retour="apercu"`.
|
||||||
|
|
||||||
|
**Application** : rien n'est écrit dans l'arbre de travail avant que tous les contrôles passent ; le résultat reste dans le clone ou un fichier temporaire ; puis écrit comme `sortie` déclarée.
|
||||||
|
|
||||||
|
**Sécurité** : la sortie du modèle est non fiable ; elle n'est jamais exécutée hors du bac à sable ; seules les commandes déclarées ; aucun secret dans le clone.
|
||||||
|
|
||||||
|
**Mesure** : le journal reçoit `controles`, `essais`, `verdict` ; `echec_controle` ne compte aucun gain ; le taux de réussite au premier essai par modèle alimente le choix de modèle (`type`/`seuil`) et le taux de réparation ; le temps passé dans les contrôles est suivi.
|
||||||
|
|
||||||
|
**Limites** : des tests écrits par le même modèle peuvent valider un comportement faux : exiger d'abord un test rouge (le test doit échouer sans le code) ou un test écrit séparément ; `citations` prouve la présence, pas le sens correct ; `commande` coûte du temps ; passer les contrôles ne remplace pas la revue de Claude sur les zones sensibles (concurrence, sécurité).
|
||||||
|
|
||||||
|
**Découpage en bouts** :
|
||||||
|
|
||||||
|
1. Contrôles purs (`format`, `complet`, `symboles`, `docstrings`, `citations`, `traduction`) avec leurs tests.
|
||||||
|
2. Contrôles outillés (`lint`, `perimetre`) avec leurs tests.
|
||||||
|
3. Boucle de retentative et escalade local → API → `echec_controle`.
|
||||||
|
4. Contrôle `commande` dans le bac à sable, sur clone, sans réseau.
|
||||||
|
5. Champs de réponse (`verdict`, `controles`, `essais`, `moteur_final`, `sortie`, stats de diff) et écriture différée.
|
||||||
|
6. Journal (`controles`, `essais`, `verdict`, temps) et plugin (le hook lit `verdict` et saute la lecture quand `valide`).
|
||||||
|
|
||||||
|
### Reprise de la main sans suivi répété
|
||||||
|
|
||||||
|
Livré dans MIAM (PH2-H11), sauf le chien de garde. `miam-attendre` est devenu la commande `curl` donnée dans `reprise` : aucun exécutable à installer. Contrat exact en section 11.
|
||||||
|
|
||||||
|
Problème : `suivre` plafonne chaque attente à `call_wait_s` (45 s), donc une tâche longue fait revenir Claude toutes les 45 s, et chaque retour coûte des tokens.
|
||||||
|
|
||||||
|
Principe : le temps n'est pas une limite, le seul coût est les tours de Claude. MIAM exécute contrôles, corrections et escalade seul. Claude est réveillé une fois, à un état terminal.
|
||||||
|
|
||||||
|
**Attente unique** : Claude lance une fois, en arrière-plan via `Bash` avec `run_in_background`, une petite commande `miam-attendre <demande_id>`. Elle bloque sur une route HTTP locale authentifiée de MIAM (long polling), imprime une ligne JSON, sort. Claude Code réveille Claude quand une commande d'arrière-plan se termine : aucun token pendant l'attente. Claude termine son tour ou continue autre chose.
|
||||||
|
|
||||||
|
**États terminaux** :
|
||||||
|
|
||||||
|
| état | sens | ce que fait Claude |
|
||||||
|
|---|---|---|
|
||||||
|
| `valide` | tous les contrôles passés, fichier écrit | intègre, sans lecture |
|
||||||
|
| `echec_controle` | tentatives et escalade épuisées ; le rapport donne le dernier échec | reprend la main ou re-découpe |
|
||||||
|
| `besoin_claude` | décision réservée à Claude : permission, plafond de dépense API atteint, instruction ambiguë | répond puis attend à nouveau |
|
||||||
|
| `annule` | demande annulée | rien |
|
||||||
|
| `perdue` | MIAM redémarré ou injoignable | renvoie ou fait lui-même |
|
||||||
|
|
||||||
|
**Pas de limite de durée, mais pas d'attente sans fin** : pas de timeout total. Un chien de garde détecte l'absence de progrès : aucun token généré, aucun contrôle terminé et aucun changement de tentative pendant `mcp.sans_progres_s` (proposé 600 s) donne `besoin_claude` avec l'étape bloquée. Les tentatives sont bornées (2 par moteur, puis escalade), donc la boucle se termine toujours. `miam-attendre` sort en `perdue` après plusieurs échecs de contact avec MIAM.
|
||||||
|
|
||||||
|
**Hook du plugin** : tant que `miam-attendre` tourne pour une demande, `suivre` sur cette demande est refusé avec le message « tu seras réveillé à la fin » ; idem pour les boucles de `sleep` en `Bash`. Le hook part du `demande_id` renvoyé en « en_file ».
|
||||||
|
|
||||||
|
**Réponse de `deleguer`** : si non terminé dans `call_wait_s`, la réponse donne `demande_id`, `etat` et `reprise` : la commande exacte à lancer.
|
||||||
|
|
||||||
|
**Autres clients** : un client sans commandes d'arrière-plan (OpenCode) garde `suivre`, avec un `attente_s` long unique, plus plafonné à 45 s, dans la limite du timeout du client MCP, à vérifier.
|
||||||
|
|
||||||
|
**Coût attendu** : un lancement et un réveil par délégation, indépendamment de la durée.
|
||||||
|
|
||||||
|
**Bouts** :
|
||||||
|
|
||||||
|
1. États terminaux et chien de garde en logique pure, avec tests.
|
||||||
|
2. Route de long polling authentifiée.
|
||||||
|
3. Commande `miam-attendre`, avec tests.
|
||||||
|
4. Champ `reprise` dans `deleguer`.
|
||||||
|
5. Hook du plugin refusant `suivre`.
|
||||||
|
|
||||||
|
## 6. Évolutions côté MIAM
|
||||||
|
|
||||||
|
| Évolution | Effet |
|
||||||
|
|---|---|
|
||||||
|
| `moteur="auto"` par défaut, repli explicite si tout est indisponible | Supprime l'erreur « aucun modèle chargé » |
|
||||||
|
| `routage` qui peut répondre « subagent Haiku » | Échelle complète local → API → Haiku |
|
||||||
|
| Délégation courte pendant un bench : API plutôt que préemption | Ne coupe plus les tests |
|
||||||
|
| Projet du plugin autorisé pour l'API ; clarifier `projets: null` | Lève le refus rencontré |
|
||||||
|
| `deleguer(..., controle="pytest -k x", essais=2)` : exécute et renvoie l'échec au modèle | Niveau 1 sans Claude, détail en 5 bis |
|
||||||
|
| `lot=[...]` : plusieurs bouts en un appel | Moins de tours de pilotage |
|
||||||
|
| Écriture directe dans le dépôt avec diff court en retour | Claude ne reçoit pas le code |
|
||||||
|
|
||||||
|
## 7. Limites
|
||||||
|
|
||||||
|
- Un hook contrôle des appels, pas le raisonnement : Claude peut toujours rédiger puis découper artificiellement.
|
||||||
|
- Chaque délégation coûte une consigne et un suivi : sous ~10 lignes, écrire peut être moins cher, d'où les seuils.
|
||||||
|
- Des seuils trop stricts multiplieront les exceptions : les suivre dès le début.
|
||||||
|
- Le hook 6 ajoute un tour au départ de chaque tâche ; il doit rester sans objet pour une correction minime.
|
||||||
|
|
||||||
|
## 8. Ordre de travail
|
||||||
|
|
||||||
|
Chaque lot est découpé en bouts confiés à MIAM : le plugin sert de premier terrain d'essai.
|
||||||
|
|
||||||
|
1. **Lot 1 (P0)** : compteurs par tour, hooks 1, 2, 3, 4, exception, repli. Vérifier d'abord les points de la section 3.
|
||||||
|
2. **Lot 2 (MIAM)** : `auto` par défaut, API pendant le bench, projet du plugin.
|
||||||
|
3. **Lot 3 (P1)** : budget de lecture, plan, sélecteur d'exécutant.
|
||||||
|
4. **Lot 4** : `controle=`, `lot=`, écriture directe.
|
||||||
|
5. **Lot 5 (P2)** : bilan, compteur, documentation.
|
||||||
|
|
||||||
|
**Mesures** par session : part des lignes écrites par délégation, net de tokens, taux de reprise, nombre d'exceptions par
|
||||||
|
motif, refus jugés à tort. Objectif proposé après le lot 1 : ≥ 70 % des lignes hors zones sensibles.
|
||||||
|
|
||||||
|
## 9. Décisions attendues
|
||||||
|
|
||||||
|
- Seuils de départ : 10 lignes pour les tests, 30 pour le code, ou plus strict.
|
||||||
|
- Mode : refus immédiat, ou une semaine en mesure seule pour régler les seuils (recommandé : refus immédiat sur les
|
||||||
|
tests et le commit, mesure sur le reste).
|
||||||
|
- Liste des chemins sensibles exemptés.
|
||||||
|
- Part acceptable de Haiku dans l'échelle, à comparer au coût de l'API.
|
||||||
|
|
||||||
|
## 10. Version cible : plugin v0.9.0
|
||||||
|
|
||||||
|
**But** : une version dont les gains de tokens sont réels et mesurés, parce que Claude délègue par défaut.
|
||||||
|
|
||||||
|
**Valeurs retenues par défaut** (issues de la section 9, modifiables) : 10 lignes pour les tests, 30 lignes pour le code, refus immédiat sur tous les hooks P0, budget de lecture de 40 000 caractères par tour et 400 lignes par fichier ; chemins sensibles : `src/miam/services/mcp.py`, `src/miam/services/instances.py`, `src/miam/core/aio.py` plus tout ce qui correspond à `*sandbox*`, liste à confirmer.
|
||||||
|
|
||||||
|
**Périmètre plugin** (checklist) : compteurs de tour (lignes écrites par l'agent principal, caractères lus, dernière délégation réussie) ; hooks 1, 2, 3, 4 (section 3) ; hook 5 budget de lecture ; hook 6 plan de découpage ; hook 7 sélecteur d'exécutant ; `exception` avec raisons fermées ; repli quand aucun exécutant ne répond ; refus de `suivre` pendant `miam-attendre` ; rapport de fin de tour et compteur visible (hooks 8, 9) ; skill `deleguer` mis à jour pour enseigner le découpage, `moteur="auto"` et `reprise`.
|
||||||
|
|
||||||
|
**Prérequis MIAM** (PH2-H11, même livraison) : `moteur="auto"` par défaut ; API au lieu de la préemption pendant un benchmark ; projet plugin autorisé pour l'API ; `controle` avec contrôles purs et `lint` ; boucle de correction et escalade ; états terminaux, `miam-attendre`, `reprise`, chien de garde `mcp.sans_progres_s` ; `verdict`. `lot=`, écritures directes et `commande` en sandbox peuvent suivre plus tard sans bloquer la v0.9.0.
|
||||||
|
|
||||||
|
**Vérifications préalables** (bloquantes) : les trois points de « À vérifier avant de coder » en section 3 ; durée maximale de blocage tolérée par un client MCP. Fait le 03/11/2026 : une commande en arrière-plan de 11 min réveille bien Claude à sa sortie, sans token pendant l'attente.
|
||||||
|
|
||||||
|
**Critères d'acceptation** (checklist, chacun testable) : une édition de test de l'agent principal de plus de 10 lignes est refusée avec un appel prêt ; une écriture via le shell est comptée ; un `git commit` sans message délégué est refusé ; une tâche découpée en plusieurs morceaux lisant le même fichier dépasse le budget de lecture et est refusée ; tous les exécutants étant indisponibles, le travail continue et un toast le signale ; une délégation longue coûte un lancement et un réveil, sans appel à `suivre` ; un `echec_controle` rend la main avec le dernier échec ; les résultats `valide` sont intégrés sans que Claude lise le corps du code ; les exceptions apparaissent dans le rapport ; les sous-agents ne sont pas soumis aux budgets ; tous les tests du plugin passent.
|
||||||
|
|
||||||
|
**Mesure du gain** : gain réel = tokens Claude effectivement consommés, lus depuis `session.usage`, pas les `evites` estimés. Protocole : rejouer des tâches comparables (une fonction pure avec tests, une tâche UI multi-fichiers, une extraction de document) sur v0.8.0 et v0.9.0, mêmes prompts ; enregistrer tokens Claude totaux, coût cloud, durée, corrections, exceptions, actions refusées à tort, bugs trouvés ensuite. Comparer aussi `net` (`evites - pilotage - corrections`) à la différence mesurée pour calibrer l'estimation. Version valide seulement si les tokens Claude mesurés baissent sans hausse des bugs post-merge ; la cible est fixée après la première comparaison, aucun chiffre décidé maintenant.
|
||||||
|
|
||||||
|
**Ordre des bouts** :
|
||||||
|
1. vérifications ;
|
||||||
|
2. compteurs avec tests ;
|
||||||
|
3. hooks P0 et exception ;
|
||||||
|
4. prérequis MIAM ;
|
||||||
|
5. budget de lecture, plan, sélecteur ;
|
||||||
|
6. rapport de fin de tour et compteur ;
|
||||||
|
7. skill et docs ;
|
||||||
|
8. mesure puis release v0.9.0.
|
||||||
|
|
||||||
|
Chaque bout suit la section 4 et est délégué.
|
||||||
|
|
||||||
|
## 11. Contrat livré par MIAM (PH2-H11) et travail restant côté plugin
|
||||||
|
|
||||||
|
État au 03/11/2026, code de MIAM non commité, tests verts. Le MIAM installé doit être reconstruit pour l'exposer.
|
||||||
|
|
||||||
|
### Ce que MIAM accepte
|
||||||
|
|
||||||
|
`deleguer(..., controle=[...], attendus=[...])` :
|
||||||
|
|
||||||
|
- `controle` : liste parmi `syntaxe`, `complet`, `symboles`, `docstrings`, `citations`, `traduction`, `lint`. `[]` désactive les contrôles.
|
||||||
|
- Par défaut : `format="code"` avec une `sortie` en `.py` donne `syntaxe`, `complet`, `symboles`, `docstrings`, `lint` ; tout le reste donne `complet`.
|
||||||
|
- `attendus` : noms qui doivent figurer dans la réponse (fonctions, classes), vérifiés par `complet`.
|
||||||
|
- `lint` : `ruff check --select F,E9` seulement (vraies erreurs, pas le style) ; ignoré si ruff est introuvable (cas de l'exe).
|
||||||
|
- `symboles` et `citations` comparent la réponse aux fichiers joints : joindre les modules utilisés.
|
||||||
|
- Tentatives : `mcp.controle_essais` (2) par moteur ; une demande locale passe ensuite à l'API.
|
||||||
|
|
||||||
|
Écarts avec la section 5 bis : `format` s'appelle `syntaxe` (Python seulement) ; `commande` et `perimetre` ne sont pas faits ; il n'y a ni `moteur_final` ni statistiques de diff.
|
||||||
|
|
||||||
|
### Ce que MIAM renvoie
|
||||||
|
|
||||||
|
| cas | réponse | `fin` (route `/attendre`) |
|
||||||
|
|---|---|---|
|
||||||
|
| terminé, contrôles passés | `ok: true`, `verdict: "valide"`, `controles: [{nom, ok, message?}]`, `essais`, `sortie` si demandée | `valide` |
|
||||||
|
| terminé sans contrôle | `ok: true`, `verdict: "non_controle"` | `termine` |
|
||||||
|
| contrôles échoués partout | `ok: false`, `erreur: "echec_controle : nom : message ; …"` | `echec_controle` |
|
||||||
|
| annulé | `ok: false`, `etat: "annulee"` | `annule` |
|
||||||
|
| demande inconnue (MIAM redémarré) | `ok: false`, sans `etat` | `perdue` |
|
||||||
|
| permission OpenCode, autre cas en attente | `ok: null` | `besoin_claude` |
|
||||||
|
| pas fini dans `call_wait_s` (45 s) | `ok: null`, `demande_id`, `etat`, `reprise`, `message` | (sans objet) |
|
||||||
|
|
||||||
|
`sortie` n'est écrite qu'après le verdict `valide`. Les tentatives rejetées sont journalisées dans `delegations.jsonl` avec `statut: "rejete"`, sans gain.
|
||||||
|
|
||||||
|
### Attente unique
|
||||||
|
|
||||||
|
`reprise` vaut exactement :
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -s -H @"<logs>/mcp/attente.hdr" "http://127.0.0.1:<port>/attendre?demande_id=<id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `attente.hdr` contient l'en-tête `Authorization`, écrit par MIAM au démarrage du serveur MCP : aucun jeton dans la commande ni dans la conversation.
|
||||||
|
- `GET /attendre` répond une seule fois, à l'état final : la même réponse que `suivre`, plus `fin`. Chaque attente interne dure au plus `ATTENTE_ROUTE_S` (3600 s) et reprend en boucle tant que la demande n'est pas finie.
|
||||||
|
- À lancer avec `Bash` et `run_in_background: true` (Git Bash sous Windows : la commande est en syntaxe POSIX). Vérifié le 03/11/2026 sur le vrai serveur HTTP.
|
||||||
|
|
||||||
|
### Encore manquant côté MIAM
|
||||||
|
|
||||||
|
- Chien de garde `mcp.sans_progres_s` : pas encore fait, car le modèle répond sans flux et rien ne mesure l'avancement. La seule limite reste `timeout_execution_s`, multiplié par le nombre de tentatives. Une très longue tâche peut donc finir en expiration (`ok: false`, `fin: "besoin_claude"` ou `perdue`).
|
||||||
|
- Contrôles `commande` (tests en bac à sable) et `perimetre`.
|
||||||
|
- `moteur="auto"` par défaut ; API au lieu de la préemption pendant un benchmark.
|
||||||
|
|
||||||
|
### À coder dans le plugin (v0.9.0), par bouts
|
||||||
|
|
||||||
|
Fait le 03/11/2026 (bouts 1 à 7, `hooks/attente.ts` et `register.tsx`, tests sur réponses figées), avec deux écarts :
|
||||||
|
le réveil est lu par un hook `prompt.submit`, qui reconnaît la notification à l'id de la commande d'arrière-plan relevé
|
||||||
|
au lancement et lit lui-même la sortie (une ligne JSON) ; PowerShell est refusé pour `reprise`, en syntaxe POSIX.
|
||||||
|
Pas encore essayé sur le vrai serveur.
|
||||||
|
|
||||||
|
1. **Hook PostToolUse sur `deleguer`** : si `ok` est `null` et qu'il y a une `reprise`, ajouter au contexte « lance `reprise` une fois avec `run_in_background`, puis termine ton tour ou fais autre chose », et noter `demande_id` comme en attente dans l'état du tour.
|
||||||
|
2. **Hook PreToolUse sur `Bash`** : reconnaître une commande qui contient `/attendre?demande_id=` ; sans `run_in_background`, la refuser (« lance-la en arrière-plan ») ; avec, passer la demande à « attendue ».
|
||||||
|
3. **Hook PreToolUse sur `suivre`** : refuser si la demande est « attendue », avec « tu seras réveillé à la fin ». Refuser aussi les `sleep` et les boucles d'attente en `Bash` pendant une attente. Laisser passer `suivre` si `fin` vaut `besoin_claude` (permission à donner).
|
||||||
|
4. **Hook sur la sortie de la commande d'arrière-plan** (ou PostToolUse sur la notification) : lire `fin` et injecter la conduite :
|
||||||
|
- `valide` : intégrer sans relire le corps, ne pas `Read` la `sortie` en entier ;
|
||||||
|
- `termine` : relire ;
|
||||||
|
- `echec_controle` : reprendre la main ou redécouper, avec le dernier motif ;
|
||||||
|
- `besoin_claude` : répondre (`autoriser`), puis relancer `reprise` ;
|
||||||
|
- `annule` : rien ;
|
||||||
|
- `perdue` : renvoyer la demande ou faire soi-même.
|
||||||
|
5. **Hook PreToolUse sur `Read`** : après `valide`, refuser une lecture complète du fichier `sortie` (aperçu ou `offset`/`limit` seulement), sauf chemin sensible.
|
||||||
|
6. **Skill `deleguer`** : enseigner `controle` et `attendus`, joindre les modules pour `symboles`, lancer `reprise` en arrière-plan, et le tableau `fin` ci-dessus.
|
||||||
|
7. **Compteurs** : `essais`, nombre de `echec_controle` et attentes uniques par session, pour la mesure de la section 10.
|
||||||
|
|
||||||
|
Tests de chaque hook sur des réponses figées tirées du tableau ci-dessus ; les autres hooks (sections 3 et 10) suivent l'ordre de la section 10.
|
||||||
@@ -4,7 +4,7 @@ Destinataire : l'agent Claude Code qui travaille dans le dépôt MIAM. Le côté
|
|||||||
`plugin-claude-miam`, schéma 1) ; ce document décrit ce que MIAM doit recevoir, stocker et restituer.
|
`plugin-claude-miam`, schéma 1) ; ce document décrit ce que MIAM doit recevoir, stocker et restituer.
|
||||||
|
|
||||||
## But
|
## But
|
||||||
Le mod estime, sans aucun coût en tokens, ce que chaque délégation évite à Claude et le coût du pilotage (consommation du subagent `miam:delegue`). MIAM connaît les tokens du modèle local mais pas ce qui se passe côté Claude : ces événements complètent son bilan.
|
Le mod estime, sans aucun coût en tokens, ce que chaque délégation évite à Claude et le coût du pilotage (consommation d'un subagent qui délègue ; le subagent `miam:delegue` est mis de côté, voir `agent-delegue.md`). MIAM connaît les tokens du modèle local mais pas ce qui se passe côté Claude : ces événements complètent son bilan.
|
||||||
|
|
||||||
## Prérequis dans MIAM : `demande_id` dans le journal
|
## Prérequis dans MIAM : `demande_id` dans le journal
|
||||||
`delegations.jsonl` ne contient pas aujourd'hui l'identifiant de la demande (`core/delegation.py`, `journal_entry`) :
|
`delegations.jsonl` ne contient pas aujourd'hui l'identifiant de la demande (`core/delegation.py`, `journal_entry`) :
|
||||||
@@ -81,11 +81,11 @@ quand `suivre` rend son résultat.
|
|||||||
| `date` | `string` | ISO 8601 de la génération de l'événement. |
|
| `date` | `string` | ISO 8601 de la génération de l'événement. |
|
||||||
| `client` | `string` | Valeur `"claude-code"`. |
|
| `client` | `string` | Valeur `"claude-code"`. |
|
||||||
| `session_id` | `string` | Identifiant de la session Claude Code. |
|
| `session_id` | `string` | Identifiant de la session Claude Code. |
|
||||||
| `agent_id` | `string` | Id du subagent (toujours présent : l'agent principal n'a pas de pilotage). |
|
| `agent_id` | `string` | Id du subagent, ou `"principal"` pour le suivi d'OpenCode par l'agent principal. |
|
||||||
| `turn_id` | `string` | Id du tour du subagent (un tour = une exécution du subagent). |
|
| `turn_id` | `string` | Id du tour du subagent (un tour = une exécution du subagent), ou `tool_use_id` de l'appel pour le suivi d'OpenCode. |
|
||||||
| `demande_ids` | `string[]` | Les `demande_id` des délégations réussies de ce tour (au moins un). |
|
| `demande_ids` | `string[]` | Les `demande_id` des délégations réussies de ce tour (au moins un). |
|
||||||
| `modele` | `string` | Modèle Claude du subagent, tel que l'API le nomme (ex. `claude-haiku-5-5`). |
|
| `modele` | `string` | Modèle Claude du subagent, tel que l'API le nomme (ex. `claude-haiku-5-5`), ou `"opencode"` pour le suivi d'OpenCode. |
|
||||||
| `usage` | `object` | Les 4 compteurs de l'API pour ce tour (entiers) : `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`. |
|
| `usage` | `object` | Les 4 compteurs de l'API pour ce tour (entiers) : `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`. Pour le suivi d'OpenCode, usage réel de la requête de l'agent principal qui lit le résultat d'un appel de suivi (`deleguer`, `suivre`, `autoriser`, `appliquer`, `rejeter`, `annuler`, relecture du diff), contexte relu compris, au prorata des outils lancés par la requête précédente. |
|
||||||
| `cout` | `integer` | Coût du tour en tokens Claude, à déduire des `evites` des délégations couvertes. |
|
| `cout` | `integer` | Coût du tour en tokens Claude, à déduire des `evites` des délégations couvertes. |
|
||||||
|
|
||||||
Le pilotage arrive à la fin du tour du subagent, donc après ses événements `delegation` (sauf renvoi : l'ordre
|
Le pilotage arrive à la fin du tour du subagent, donc après ses événements `delegation` (sauf renvoi : l'ordre
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "miam",
|
"name": "miam",
|
||||||
"displayName": "MIAM",
|
"displayName": "MIAM",
|
||||||
"version": "0.4.0",
|
"version": "0.9.0",
|
||||||
"description": "Délégation de tâches à un modèle local géré par MIAM (Mon IA Manager) : subagent, règles de délégation, compteur de tokens économisés",
|
"description": "Délégation de tâches à un modèle local géré par MIAM (Mon IA Manager) : règles de délégation, gains en tokens et usage Claude (5 h, hebdo) en couleur",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ange-Marie MAURIN"
|
"name": "Ange-Marie MAURIN"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": ["miam", "llama.cpp", "local", "delegation", "mcp"],
|
"keywords": ["miam", "llama.cpp", "local", "delegation", "mcp"],
|
||||||
"userConfig": {
|
"userConfig": {
|
||||||
|
"incitation": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Inciter Claude à déléguer",
|
||||||
|
"description": "Ajoute au prompt système une consigne de délégation et un rappel après une grosse lecture",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"imposer": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Imposer la délégation",
|
||||||
|
"description": "Refuse à l'agent principal la lecture entière d'un long fichier et retient les longues sorties Bash/PowerShell, tant que MIAM répond",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"chemins_sensibles": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Chemins sensibles",
|
||||||
|
"description": "Fichiers relus en entier même après un verdict valide de MIAM (concurrence, sécurité), séparés par des virgules ; « * » pour n'importe quels caractères",
|
||||||
|
"default": "src/miam/services/mcp.py, src/miam/services/instances.py, src/miam/core/aio.py, *sandbox*"
|
||||||
|
},
|
||||||
"jeton": {
|
"jeton": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Jeton du serveur MCP de MIAM",
|
"title": "Jeton du serveur MCP de MIAM",
|
||||||
@@ -24,13 +42,6 @@
|
|||||||
"min": 1,
|
"min": 1,
|
||||||
"max": 65535
|
"max": 65535
|
||||||
},
|
},
|
||||||
"modele_subagent": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Modèle Claude du subagent",
|
|
||||||
"description": "Modèle qui pilote MIAM pour l'agent principal (rédige la consigne, attend, vérifie)",
|
|
||||||
"options": ["haiku", "sonnet", "opus", "fable"],
|
|
||||||
"default": "haiku"
|
|
||||||
},
|
|
||||||
"preset_defaut": {
|
"preset_defaut": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Preset MIAM par défaut",
|
"title": "Preset MIAM par défaut",
|
||||||
@@ -39,3 +50,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+40
-14
@@ -6,8 +6,7 @@ Plugin Claude Code pour déléguer des tâches à un modèle local géré par MI
|
|||||||
|
|
||||||
## Contenu
|
## Contenu
|
||||||
- Serveur MCP `miam` (http://127.0.0.1:{port}/mcp, jeton Bearer) : point d'entrée pour la communication avec le serveur MCP de MIAM.
|
- Serveur MCP `miam` (http://127.0.0.1:{port}/mcp, jeton Bearer) : point d'entrée pour la communication avec le serveur MCP de MIAM.
|
||||||
- Subagent `miam:delegue` : agent dédié qui pilote la délégation des tâches vers MIAM.
|
- Skill `deleguer` : règles, pour l'agent principal, sur quand et comment déléguer une tâche à MIAM, y compris à OpenCode (permissions, puis `appliquer` ou `rejeter` le diff).
|
||||||
- Skill `deleguer` : règles, pour l'agent principal, sur quand et comment déléguer une tâche à MIAM.
|
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
MIAM 2.5.1 ou plus (`type`, `seuil` et l'outil `modeles`, champs `offload` à partir de 2.5.2) lancé avec son serveur MCP démarré (interrupteur du dashboard, menu « Serveur MCP » du tray, ou `--auto-start-mcp`).
|
MIAM 2.5.1 ou plus (`type`, `seuil` et l'outil `modeles`, champs `offload` à partir de 2.5.2) lancé avec son serveur MCP démarré (interrupteur du dashboard, menu « Serveur MCP » du tray, ou `--auto-start-mcp`).
|
||||||
@@ -36,27 +35,54 @@ Puis : à l'activation, Claude Code demande les réglages (voir section suivante
|
|||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| jeton | (vide) | Jeton Bearer du serveur MCP de MIAM, rangé dans le coffre d'identifiants. |
|
| jeton | (vide) | Jeton Bearer du serveur MCP de MIAM, rangé dans le coffre d'identifiants. |
|
||||||
| port | 8090 | Port du serveur MCP de MIAM sur 127.0.0.1. |
|
| port | 8090 | Port du serveur MCP de MIAM sur 127.0.0.1. |
|
||||||
| modele_subagent | haiku | Modèle Claude qui pilote MIAM pour l'agent principal (rédige la consigne, attend, vérifie). |
|
| incitation | oui | Ajoute au prompt système une consigne de délégation (quelques centaines de tokens, en cache) et un rappel après une grosse lecture. |
|
||||||
|
| chemins_sensibles | `src/miam/services/mcp.py`, `src/miam/services/instances.py`, `src/miam/core/aio.py`, `*sandbox*` | Fichiers relus en entier même après un verdict `valide` (concurrence, sécurité), séparés par des virgules. |
|
||||||
| preset_defaut | pre-16312f8b | Preset MIAM par défaut, démarré quand aucun modèle n'est chargé et que MIAM n'a aucun test pour le type de la tâche. |
|
| preset_defaut | pre-16312f8b | Preset MIAM par défaut, démarré quand aucun modèle n'est chargé et que MIAM n'a aucun test pour le type de la tâche. |
|
||||||
|
|
||||||
## Utilisation
|
## Utilisation
|
||||||
L'agent principal lance le subagent miam:delegue ; celui-ci vérifie que MIAM répond (etat), classe la tâche, délègue avec `type` (`code`, `texte`, `rapide`) et `seuil` (0,8 par défaut), vérifie, et rend un compte rendu court avec le choix de MIAM.
|
L'agent principal appelle directement l'outil `deleguer`, avec `type` (`code`, `texte`, `rapide`) et `seuil` (0,8 par défaut), puis vérifie le résultat. Le subagent `miam:delegue` est mis de côté : son pilotage rendait les gains négatifs. Sa définition est gardée dans `docs/agent-delegue.md` du dépôt.
|
||||||
|
|
||||||
MIAM choisit le modèle d'après ses tests de qualité : il garde le modèle chargé s'il atteint le seuil, sinon il charge le meilleur preset, quitte à remplacer le modèle chargé (20 à 40 s) ; si aucun n'atteint le seuil, il prend le meilleur et le signale. L'agent principal peut imposer un preset ou demander de garder le modèle chargé. Contrat : `docs/protocole-agents.md` du dépôt MIAM.
|
MIAM choisit le modèle d'après ses tests de qualité : il garde le modèle chargé s'il atteint le seuil, sinon il charge le meilleur preset, quitte à remplacer le modèle chargé (20 à 40 s) ; si aucun n'atteint le seuil, il prend le meilleur et le signale. L'agent principal peut imposer un preset ou demander de garder le modèle chargé. Contrat : `docs/protocole-agents.md` du dépôt MIAM.
|
||||||
|
|
||||||
|
## Incitation à déléguer
|
||||||
|
|
||||||
|
Avec `incitation` (par défaut), l'agent principal reçoit une courte consigne dans son prompt système : déléguer par réflexe ce qui s'y prête, sans demander. Après une lecture de plus de 20 000 caractères (`Read`, `Bash`, `PowerShell`), il reçoit aussi un rappel, au plus toutes les 10 minutes et invisible pour l'utilisateur. Les subagents ne reçoivent ni l'une ni l'autre.
|
||||||
|
|
||||||
|
## Délégation imposée
|
||||||
|
|
||||||
|
Avec `imposer` (par défaut), et tant que MIAM répond, l'agent principal ne peut plus consommer d'usage sur de longues lectures :
|
||||||
|
- `Read` d'un fichier de 20 000 octets ou plus est refusé avant la lecture, sauf plage de 400 lignes au plus (`offset`, `limit`), image, PDF ou fichier écrit par une délégation ;
|
||||||
|
- une sortie `Bash` ou `PowerShell` de 20 000 caractères ou plus est enregistrée dans `.claude/miam-sorties/` du projet (dossier ignoré par git) et retenue : le modèle reçoit le chemin, à passer à `deleguer`. Une commande en erreur garde sa sortie et déclenche le rappel.
|
||||||
|
|
||||||
|
## Attente unique
|
||||||
|
|
||||||
|
Une délégation pas finie en 45 s renvoie `reprise`, une commande `curl` qui bloque sur MIAM jusqu'à l'état final. Le plugin dit à l'agent principal de la lancer une fois en arrière-plan (Bash, `run_in_background`), puis :
|
||||||
|
- refuse de la lancer au premier plan ou avec PowerShell ;
|
||||||
|
- refuse `suivre` et les `sleep` tant que la demande est attendue (`suivre` reste permis quand MIAM attend une décision, `fin: besoin_claude`) ;
|
||||||
|
- au réveil, lit lui-même la sortie de la commande et donne le résultat et la conduite selon `fin` (`valide`, `termine`, `echec_controle`, `besoin_claude`, `annule`, `perdue`) : une délégation coûte un lancement et un réveil ;
|
||||||
|
- après un verdict `valide`, refuse la lecture entière du fichier `sortie` (plage de 400 lignes au plus), sauf chemin sensible.
|
||||||
|
|
||||||
|
`/miam-economies` compte les attentes uniques, les essais et les échecs de contrôle de la session. Avec `imposer` désactivé, rien n'est refusé.
|
||||||
|
|
||||||
|
Les subagents ne sont pas concernés. Si MIAM ne répond pas, ou si la sortie ne peut pas être enregistrée, rien n'est bloqué. Si MIAM répond mais que la délégation échoue (`/mcp` à reconnecter, par exemple), la lecture par plages reste possible.
|
||||||
|
|
||||||
|
## Ligne d'état : gains et usage Claude
|
||||||
|
|
||||||
|
Une seule ligne, sans coût en tokens (`🟢 MIAM net ~4k (cumul ~35k)🟢 · 5 h 🟩🟩⬛⬛⬛ 42 % (1 h 53) · hebdo 🟩🟩🟩⬛⬛ 53 % (3 j 20 h)`) :
|
||||||
|
- le gain net, 🟢 s'il est positif, 🔴 s'il est négatif ;
|
||||||
|
- l'usage Claude sur 5 h et sur la semaine : barre, pourcentage et temps avant la remise à zéro. La barre est verte, orange à partir de 70 %, rouge à partir de 90 %. Ces chiffres viennent de la dernière réponse de l'API : ils n'apparaissent qu'après un premier échange, et seulement avec un abonnement.
|
||||||
|
|
||||||
## Tokens économisés
|
## Tokens économisés
|
||||||
|
|
||||||
Après chaque délégation réussie, une notification (`MIAM : ~6,1k tokens évités (5,2k lus, 900 écrits)`) et une ligne
|
Après chaque délégation réussie, une notification (`🟢 MIAM : ~6,1k tokens évités (5,2k lus, 900 écrits)`) et la mise à
|
||||||
d'état sous le prompt (`MIAM ▸ 2 délégation(s) · ~6,1k tokens évités · pilotage 1,5k · corrections 600 · net ~4k
|
jour de la ligne d'état, dans le terminal comme dans VS Code. `/miam-economies` donne le détail de la session et le cumul ; `/miam-economies raz`
|
||||||
(cumul ~35k)`),
|
|
||||||
dans le terminal comme dans VS Code. `/miam-economies` donne le détail de la session et le cumul ; `/miam-economies raz`
|
|
||||||
remet les compteurs à zéro. Le calcul se fait localement dans Claude Code : il ne coûte aucun token.
|
remet les compteurs à zéro. Le calcul se fait localement dans Claude Code : il ne coûte aucun token.
|
||||||
Chaque estimation est aussi envoyée à MIAM (`POST /economies`, toutes les 5 s, hors des hooks ; gardée et renvoyée si
|
Chaque estimation est aussi envoyée à MIAM (`POST /economies`, toutes les 5 s, hors des hooks ; gardée et renvoyée si
|
||||||
MIAM ne répond pas) : contrat et modèle de données dans [docs/token-summary-mod.md](../../docs/token-summary-mod.md).
|
MIAM ne répond pas) : contrat et modèle de données dans [docs/token-summary-mod.md](../../docs/token-summary-mod.md).
|
||||||
|
|
||||||
- **Évités** : les fichiers lus et la réponse écrite par le modèle local (chiffres de MIAM), moins la consigne et ce que
|
- **Évités** : les fichiers lus et la réponse écrite par le modèle local (chiffres de MIAM), moins la consigne et ce que
|
||||||
Claude relit (estimés à 4 caractères par token).
|
Claude relit (estimés à 4 caractères par token).
|
||||||
- **Pilotage** : la consommation réelle du subagent `miam:delegue` (lecture en cache comptée au dixième), à déduire.
|
- **Pilotage** : la consommation réelle d'un subagent qui délègue (lecture en cache comptée au dixième), ou l'usage réel des requêtes de l'agent principal qui suivent OpenCode, contexte relu et relecture du diff compris (lecture en cache au dixième), à déduire. Le gain d'OpenCode vaut les tokens du cloud moins la consigne ; un diff rejeté n'évite rien.
|
||||||
- **Corrections** : ce que l'agent principal relit (`Read`) ou retouche (`Edit`, `Write`, `NotebookEdit`, `Bash`) dans
|
- **Corrections** : ce que l'agent principal relit (`Read`) ou retouche (`Edit`, `Write`, `NotebookEdit`, `Bash`) dans
|
||||||
un fichier écrit par une délégation (`sortie`), à déduire aussi. Celles du subagent sont déjà dans le pilotage.
|
un fichier écrit par une délégation (`sortie`), à déduire aussi. Celles du subagent sont déjà dans le pilotage.
|
||||||
- **Net** = évités − pilotage − corrections.
|
- **Net** = évités − pilotage − corrections.
|
||||||
@@ -72,15 +98,16 @@ MIAM teste lui-même les presets, en travaux de fond (page Benchmarks) : le plug
|
|||||||
.claude-plugin/
|
.claude-plugin/
|
||||||
plugin.json # manifeste : nom, version, réglages (jeton, port, modèle, preset)
|
plugin.json # manifeste : nom, version, réglages (jeton, port, modèle, preset)
|
||||||
.mcp.json # serveur MCP miam (http://127.0.0.1:<port>/mcp)
|
.mcp.json # serveur MCP miam (http://127.0.0.1:<port>/mcp)
|
||||||
agents/
|
|
||||||
delegue.md # subagent miam:delegue
|
|
||||||
skills/
|
skills/
|
||||||
deleguer/SKILL.md # quand et comment déléguer
|
deleguer/SKILL.md # quand et comment déléguer
|
||||||
hooks/
|
hooks/
|
||||||
hooks.json
|
hooks.json
|
||||||
register.ts # compteur de tokens économisés (ligne d'état, notifications, /miam-economies)
|
register.tsx # compteur de tokens, ligne d'état, incitation, /miam-economies
|
||||||
economies.ts # calcul pur, testé
|
economies.ts # calcul pur, testé
|
||||||
*.test.ts # 35 tests : claude plugin test plugins/miam
|
attente.ts # attente unique et conduite selon `fin` : calcul pur, testé
|
||||||
|
contrainte.ts # seuils des lectures et sorties longues
|
||||||
|
usage.ts # usage Claude 5 h et hebdo : calcul pur, testé
|
||||||
|
*.test.ts(x) # tests : claude plugin test plugins/miam
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
```
|
```
|
||||||
@@ -90,7 +117,6 @@ README.md
|
|||||||
- deleguer ne transmet que du texte (pas d'image).
|
- deleguer ne transmet que du texte (pas d'image).
|
||||||
- Un `.mcp.json` de projet qui déclare déjà le serveur `miam` (celui de ce dépôt, celui de MIAM) fait doublon avec le
|
- Un `.mcp.json` de projet qui déclare déjà le serveur `miam` (celui de ce dépôt, celui de MIAM) fait doublon avec le
|
||||||
plugin : le retirer une fois le plugin installé.
|
plugin : le retirer une fois le plugin installé.
|
||||||
- Le modèle du subagent réglé par modele_subagent s'applique quand il est lancé via la skill deleguer (sinon : haiku, défaut du fichier de l'agent).
|
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { expect, test } from 'claude-code/testing'
|
||||||
|
|
||||||
|
import {
|
||||||
|
conduite, demandeAttendue, estSommeil, fichierSortie, finDe, lectureEntiere, lireFin, sensible,
|
||||||
|
} from './attente'
|
||||||
|
|
||||||
|
const REPRISE = 'curl -s -H @"C:/logs/mcp/attente.hdr" "http://127.0.0.1:8090/attendre?demande_id=dem-1a2b"'
|
||||||
|
|
||||||
|
test('la commande reprise désigne sa demande, une autre commande aucune', () => {
|
||||||
|
expect(demandeAttendue(REPRISE)).toBe('dem-1a2b')
|
||||||
|
expect(demandeAttendue('curl -s http://127.0.0.1:8090/etat')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sleep, Start-Sleep et timeout /t sont des attentes, pas un mot qui les contient', () => {
|
||||||
|
expect(['sleep 30', 'while true; do sleep 5; done', 'Start-Sleep -Seconds 10', 'timeout /t 20'].map(estSommeil))
|
||||||
|
.toEqual([true, true, true, true])
|
||||||
|
expect(['ls asleep', 'grep sleeper src', 'pytest -k timeout'].map(estSommeil)).toEqual([false, false, false])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lireFin prend la dernière ligne JSON avec un fin connu', () => {
|
||||||
|
const sortie = 'bruit\n{"fin":"bizarre"}\n{"ok":true,"demande_id":"dem-1","verdict":"valide","fin":"valide"}\n'
|
||||||
|
expect(lireFin(sortie)).toMatchObject({ fin: 'valide', demande_id: 'dem-1' })
|
||||||
|
expect(lireFin('curl: (7) Failed to connect')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('finDe : seulement ce que MIAM a contrôlé', () => {
|
||||||
|
expect(finDe({ ok: true, verdict: 'valide' })).toBe('valide')
|
||||||
|
expect(finDe({ ok: true, verdict: 'non_controle' })).toBe('termine')
|
||||||
|
expect(finDe({ ok: true })).toBeUndefined() // ancien MIAM, sans contrôle
|
||||||
|
expect(finDe({ ok: false, erreur: 'echec_controle : lint : F401' })).toBe('echec_controle')
|
||||||
|
expect(finDe({ ok: false, erreur: 'aucun preset testé' })).toBeUndefined()
|
||||||
|
expect(finDe({ ok: null })).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('conduite : une ligne par fin, avec ce qui sert à agir', () => {
|
||||||
|
expect(conduite({ fin: 'valide', demande_id: 'd', essais: 2, sortie: 'E:/p/x.py' }))
|
||||||
|
.toContain('résultat écrit dans E:/p/x.py')
|
||||||
|
expect(conduite({ fin: 'echec_controle', erreur: 'echec_controle : lint : F401' })).toContain('lint : F401')
|
||||||
|
expect(conduite({ fin: 'echec_controle', controles: [{ nom: 'symboles', ok: false, message: 'champ x' }] }))
|
||||||
|
.toContain('symboles : champ x')
|
||||||
|
expect(conduite({ fin: 'besoin_claude' }, REPRISE)).toContain(REPRISE)
|
||||||
|
for (const fin of ['termine', 'annule', 'perdue'] as const) {
|
||||||
|
expect(conduite({ fin }).length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lecture entière sans limit bornée ; chemins sensibles par fin de chemin ou motif', () => {
|
||||||
|
expect([{}, { limit: 2000 }, { offset: 10, limit: 50 }].map(lectureEntiere)).toEqual([true, true, false])
|
||||||
|
expect(sensible('E:\\dev\\miam\\src\\miam\\services\\mcp.py')).toBe(true)
|
||||||
|
expect(sensible('E:/dev/miam/src/miam/infra/sandbox_shell.py')).toBe(true)
|
||||||
|
expect(sensible('E:/dev/miam/src/miam/services/mcp_card.py')).toBe(false)
|
||||||
|
expect(sensible('E:/p/a.py', ['*.py'])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fichier de sortie nommé par la notification', () => {
|
||||||
|
const n = '<task-notification><task-id>b7</task-id><output-file>C:\\Temp\\b7.output</output-file>' +
|
||||||
|
'<status>completed</status></task-notification>'
|
||||||
|
expect(fichierSortie(n)).toBe('C:\\Temp\\b7.output')
|
||||||
|
expect(fichierSortie('rien')).toBeUndefined()
|
||||||
|
})
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Attente unique d'une délégation (contrat de MIAM : docs/hooks-delegation.md, section 11).
|
||||||
|
//
|
||||||
|
// Pas finie dans `call_wait_s`, une délégation renvoie `reprise`, une commande curl qui bloque sur GET /attendre jusqu'à
|
||||||
|
// l'état final. Lancée une fois en arrière-plan, elle réveille Claude à sa sortie : aucun `suivre` ni `sleep` entre-temps.
|
||||||
|
// Calcul pur, testé ; register.tsx branche ces fonctions sur les hooks.
|
||||||
|
|
||||||
|
import { PLAGE_MAX } from './contrainte'
|
||||||
|
|
||||||
|
export type Fin = 'valide' | 'termine' | 'echec_controle' | 'besoin_claude' | 'annule' | 'perdue'
|
||||||
|
|
||||||
|
export type Controle = { nom: string; ok: boolean; message?: string }
|
||||||
|
|
||||||
|
// Réponse de GET /attendre : celle de `suivre`, plus `fin`.
|
||||||
|
export type Final = {
|
||||||
|
fin: Fin
|
||||||
|
demande_id?: string
|
||||||
|
ok?: boolean | null
|
||||||
|
verdict?: string
|
||||||
|
controles?: Controle[]
|
||||||
|
essais?: number
|
||||||
|
sortie?: string
|
||||||
|
erreur?: string
|
||||||
|
[cle: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const FINS: readonly string[] = ['valide', 'termine', 'echec_controle', 'besoin_claude', 'annule', 'perdue']
|
||||||
|
|
||||||
|
// Demande attendue par une commande : l'id après `/attendre?demande_id=`.
|
||||||
|
export function demandeAttendue(commande: string): string | undefined {
|
||||||
|
return /\/attendre\?demande_id=([\w-]+)/.exec(commande)?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commande d'attente active : `sleep`, `Start-Sleep`, `timeout /t`, seuls ou dans une boucle.
|
||||||
|
export const estSommeil = (commande: string) => /(^|[\s;&|(])(sleep|start-sleep|timeout\s+\/t)\b/i.test(commande)
|
||||||
|
|
||||||
|
// Dernière ligne JSON de la sortie de curl, si elle porte un `fin` connu.
|
||||||
|
export function lireFin(sortie: string): Final | undefined {
|
||||||
|
const lignes = sortie.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
|
||||||
|
for (const ligne of lignes.reverse()) {
|
||||||
|
try {
|
||||||
|
const r = JSON.parse(ligne) as Partial<Final>
|
||||||
|
if (r && typeof r === 'object' && typeof r.fin === 'string' && FINS.includes(r.fin)) {
|
||||||
|
return r as Final
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ligne qui n'est pas du JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fin d'une réponse directe de `deleguer` ou `suivre` (sans passer par /attendre), seulement quand MIAM a contrôlé :
|
||||||
|
// un refus ordinaire (`ok: false`, aucun preset…) ou un résultat sans `verdict` (ancien MIAM) n'en a pas.
|
||||||
|
export function finDe(r: { ok?: unknown; verdict?: unknown; erreur?: unknown }): Fin | undefined {
|
||||||
|
if (r.ok === true && typeof r.verdict === 'string') {
|
||||||
|
return r.verdict === 'valide' ? 'valide' : 'termine'
|
||||||
|
}
|
||||||
|
if (r.ok === false && typeof r.erreur === 'string' && r.erreur.startsWith('echec_controle')) {
|
||||||
|
return 'echec_controle'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const echecs = (r: Final) => (r.controles ?? []).filter(c => !c.ok).map(c => `${c.nom}${c.message ? ` : ${c.message}` : ''}`)
|
||||||
|
|
||||||
|
// Ce que Claude lit au réveil : le résultat utile et la conduite à tenir, pour qu'il n'ait pas à ouvrir la sortie.
|
||||||
|
export function conduite(r: Final, reprise?: string): string {
|
||||||
|
const id = r.demande_id ? ` ${r.demande_id}` : ''
|
||||||
|
const essais = r.essais ? `, ${r.essais} essai(s)` : ''
|
||||||
|
switch (r.fin) {
|
||||||
|
case 'valide':
|
||||||
|
return `MIAM${id} : verdict valide, contrôles passés${essais}` +
|
||||||
|
(r.sortie ? `, résultat écrit dans ${r.sortie}` : '') +
|
||||||
|
`. Intègre-le sans relire son corps (aperçu ou \`offset\`/\`limit\` si besoin) ; ruff et les tests du projet ` +
|
||||||
|
`suffisent comme vérification.`
|
||||||
|
case 'termine':
|
||||||
|
return `MIAM${id} : terminé sans contrôle automatique. Vérifie le résultat avant de l'intégrer.`
|
||||||
|
case 'echec_controle':
|
||||||
|
return `MIAM${id} : échec des contrôles après toutes les tentatives (${r.erreur ?? echecs(r).join(' ; ')}). ` +
|
||||||
|
`Reprends la tâche toi-même ou redécoupe-la en bouts plus petits, avec ce motif dans la consigne.`
|
||||||
|
case 'besoin_claude':
|
||||||
|
return `MIAM${id} attend une décision de ta part (permission OpenCode, plafond de dépense, consigne ambiguë) : ` +
|
||||||
|
`\`suivre\` pour la voir, réponds (\`autoriser\` pour une permission), puis relance ` +
|
||||||
|
(reprise ? `\`${reprise}\`` : 'la commande `reprise`') + ' en arrière-plan.'
|
||||||
|
case 'annule':
|
||||||
|
return `MIAM${id} : demande annulée, rien à faire.`
|
||||||
|
case 'perdue':
|
||||||
|
return `MIAM${id} : demande perdue (MIAM redémarré ou injoignable). Renvoie-la, ou fais la tâche toi-même.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const consigneReprise = (reprise: string) =>
|
||||||
|
`Délégation en cours. Lance une seule fois, avec Bash et \`run_in_background: true\` : \`${reprise}\`. ` +
|
||||||
|
"Puis termine ton tour ou fais autre chose : tu seras réveillé à la fin. Pas de `suivre` ni de `sleep` d'ici là."
|
||||||
|
|
||||||
|
export const REFUS_PREMIER_PLAN = "Attente refusée par le plugin MIAM : lance cette commande en arrière-plan, avec " +
|
||||||
|
"Bash et `run_in_background: true`. Elle bloque jusqu'à la fin de la délégation, et tu seras réveillé à sa sortie."
|
||||||
|
export const REFUS_POWERSHELL = "Attente refusée par le plugin MIAM : la commande `reprise` est en syntaxe POSIX. " +
|
||||||
|
'Lance-la telle quelle avec Bash et `run_in_background: true`.'
|
||||||
|
export const REFUS_SUIVI = 'Suivi refusé par le plugin MIAM : cette délégation est attendue en arrière-plan, tu ' +
|
||||||
|
'seras réveillé à la fin. Termine ton tour ou fais autre chose.'
|
||||||
|
export const REFUS_SOMMEIL = "Attente refusée par le plugin MIAM : une délégation est attendue en arrière-plan et tu " +
|
||||||
|
'seras réveillé à la fin. Pas de `sleep` ni de boucle : termine ton tour ou fais autre chose.'
|
||||||
|
export const REFUS_LECTURE_VALIDE = 'Lecture refusée par le plugin MIAM : ce fichier a passé les contrôles de MIAM ' +
|
||||||
|
`(verdict valide). Lis-en seulement la partie utile (\`offset\` et \`limit\` de ${PLAGE_MAX} lignes au plus), ou ` +
|
||||||
|
'vérifie-le par ruff et les tests.'
|
||||||
|
|
||||||
|
// Lecture entière d'un fichier : pas de `limit` borné.
|
||||||
|
export const lectureEntiere = (args: { limit?: unknown }) =>
|
||||||
|
!(typeof args.limit === 'number' && args.limit > 0 && args.limit <= PLAGE_MAX)
|
||||||
|
|
||||||
|
// Chemin sensible (concurrence, sécurité) : relu en entier même après un verdict valide. `*` vaut n'importe quels
|
||||||
|
// caractères ; un motif sans `*` désigne la fin du chemin.
|
||||||
|
export const SENSIBLES = ['src/miam/services/mcp.py', 'src/miam/services/instances.py', 'src/miam/core/aio.py', '*sandbox*']
|
||||||
|
|
||||||
|
export function sensible(chemin: string, motifs: readonly string[] = SENSIBLES): boolean {
|
||||||
|
const p = chemin.replace(/\\/g, '/').toLowerCase()
|
||||||
|
return motifs.some(m => {
|
||||||
|
const motif = m.replace(/\\/g, '/').toLowerCase()
|
||||||
|
if (!motif.includes('*')) {
|
||||||
|
return p === motif || p.endsWith(`/${motif}`)
|
||||||
|
}
|
||||||
|
const re = new RegExp(`(^|/)${motif.split('*').map(s => s.replace(/[.+?^${}()|[\]]/g, '\\$&')).join('.*')}$`)
|
||||||
|
return re.test(p)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification de fin d'une commande d'arrière-plan : son fichier de sortie, si elle le nomme.
|
||||||
|
export const fichierSortie = (notification: string) =>
|
||||||
|
/<output-file>\s*([^<]+?)\s*<\/output-file>/.exec(notification)?.[1]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { expect, test } from 'claude-code/testing'
|
||||||
|
|
||||||
|
import { lectureTropLongue, nomSortie, PLAGE_MAX, SEUIL, sortieTropLongue } from './contrainte'
|
||||||
|
|
||||||
|
test('frontière de taille', () => {
|
||||||
|
expect(lectureTropLongue({ file_path: 'a.txt' }, 19999)).toBe(false)
|
||||||
|
expect(lectureTropLongue({ file_path: 'a.txt' }, 20000)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('extensions image et PDF', () => {
|
||||||
|
expect(lectureTropLongue({ file_path: 'A.PNG' }, 20000)).toBe(false)
|
||||||
|
expect(lectureTropLongue({ file_path: 'x.jpg' }, 20000)).toBe(false)
|
||||||
|
expect(lectureTropLongue({ file_path: 'x.JPEG' }, 20000)).toBe(false)
|
||||||
|
expect(lectureTropLongue({ file_path: 'x.gif' }, 20000)).toBe(false)
|
||||||
|
expect(lectureTropLongue({ file_path: 'x.webp' }, 20000)).toBe(false)
|
||||||
|
expect(lectureTropLongue({ file_path: 'x.pdf' }, 20000)).toBe(false)
|
||||||
|
expect(lectureTropLongue({ file_path: 'x.md' }, 20000)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('chemin non chaîne', () => {
|
||||||
|
expect(lectureTropLongue({ file_path: 42 }, 20000)).toBe(false)
|
||||||
|
expect(lectureTropLongue({}, 20000)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('limites de plage', () => {
|
||||||
|
expect(lectureTropLongue({ file_path: 'a.txt', limit: 400 }, 20000)).toBe(false)
|
||||||
|
expect(lectureTropLongue({ file_path: 'a.txt', limit: 401 }, 20000)).toBe(true)
|
||||||
|
expect(lectureTropLongue({ file_path: 'a.txt', limit: 0 }, 20000)).toBe(true)
|
||||||
|
expect(lectureTropLongue({ file_path: 'a.txt', limit: 1 }, 20000)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('limit texte', () => {
|
||||||
|
expect(lectureTropLongue({ file_path: 'a.txt', limit: '10' }, 20000)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sortie trop longue', () => {
|
||||||
|
expect(sortieTropLongue('x'.repeat(19999))).toBe(false)
|
||||||
|
expect(sortieTropLongue('x'.repeat(20000))).toBe(true)
|
||||||
|
expect(sortieTropLongue(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('nom de sortie', () => {
|
||||||
|
expect(nomSortie('toolu_01-AB')).toBe('sortie-toolu_01-AB.txt')
|
||||||
|
expect(nomSortie('a/b:c')).toBe('sortie-a_b_c.txt')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('constantes', () => {
|
||||||
|
expect(SEUIL).toBe(20000)
|
||||||
|
expect(PLAGE_MAX).toBe(400)
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Seuils et bornes pour la détection des sorties trop volumineuses.
|
||||||
|
|
||||||
|
export const SEUIL = 20000
|
||||||
|
export const PLAGE_MAX = 400
|
||||||
|
|
||||||
|
// Extensions considérées comme binaires (images et PDF), insensibles à la casse.
|
||||||
|
const EXTENSIONS_BINAIRES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf'])
|
||||||
|
|
||||||
|
// Vérifie si le chemin pointe vers un fichier binaire (image ou PDF).
|
||||||
|
function estBinaire(chemin: string): boolean {
|
||||||
|
// Isoler le nom de fichier (après le dernier séparateur de dossier).
|
||||||
|
const nom = chemin.split(/[/\\]/).pop() ?? ''
|
||||||
|
// Extraire l'extension (après le dernier point du nom de fichier).
|
||||||
|
const point = nom.lastIndexOf('.')
|
||||||
|
if (point === -1 || point === nom.length - 1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return EXTENSIONS_BINAIRES.has(nom.slice(point + 1).toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie si la lecture est bornée (limit est un nombre strictement positif et ≤ PLAGE_MAX).
|
||||||
|
function estBornee(limit: unknown): boolean {
|
||||||
|
return typeof limit === 'number' && limit > 0 && limit <= PLAGE_MAX
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lectureTropLongue(args: { file_path?: unknown; limit?: unknown }, taille: number): boolean {
|
||||||
|
// Condition 1 : taille au-delà du seuil.
|
||||||
|
if (taille < SEUIL) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition 2 : chemin de fichier texte (pas une image ni un PDF).
|
||||||
|
const chemin = args.file_path
|
||||||
|
if (typeof chemin !== 'string' || estBinaire(chemin)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition 3 : lecture non bornée.
|
||||||
|
return !estBornee(args.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortieTropLongue(texte: string | undefined): boolean {
|
||||||
|
return texte !== undefined && texte.length >= SEUIL
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nomSortie(toolUseId: string): string {
|
||||||
|
// Remplacer chaque caractère non alphanumérique (sauf _ et -) par un underscore.
|
||||||
|
const idNettoye = toolUseId.replace(/[^A-Za-z0-9_-]/g, '_')
|
||||||
|
return `sortie-${idNettoye}.txt`
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from 'claude-code/testing'
|
import { expect, test } from 'claude-code/testing'
|
||||||
|
|
||||||
import { court, cout, estimer, ligne, lireResultat, VIDE } from './economies'
|
import { court, cout, estimer, evenementDelegation, ligne, pastille, lireResultat, repartir, VIDE } from './economies'
|
||||||
|
|
||||||
const REPONSE = { ok: true, demande_id: 'dem-1', tokens_entree: 2049, tokens_sortie: 892, reponse: 'x'.repeat(400) }
|
const REPONSE = { ok: true, demande_id: 'dem-1', tokens_entree: 2049, tokens_sortie: 892, reponse: 'x'.repeat(400) }
|
||||||
|
|
||||||
@@ -34,10 +34,69 @@ test('affichage', () => {
|
|||||||
expect(court(-350)).toBe('-350')
|
expect(court(-350)).toBe('-350')
|
||||||
const cumul = { delegations: 9, evites: 40000, pilotage: 5000, corrections: 0 }
|
const cumul = { delegations: 9, evites: 40000, pilotage: 5000, corrections: 0 }
|
||||||
expect(ligne({ delegations: 2, evites: 6100, pilotage: 1500, corrections: 0 }, cumul))
|
expect(ligne({ delegations: 2, evites: 6100, pilotage: 1500, corrections: 0 }, cumul))
|
||||||
.toBe('MIAM ▸ 2 délégation(s) · ~6,1k tokens évités · pilotage 1,5k · net ~4,6k (cumul ~35k)')
|
.toBe('🟢 MIAM net ~4,6k (cumul ~35k)🟢')
|
||||||
expect(ligne({ delegations: 2, evites: 6100, pilotage: 1500, corrections: 600 }, cumul))
|
expect(ligne({ delegations: 2, evites: 6100, pilotage: 1500, corrections: 600 }, cumul))
|
||||||
.toBe('MIAM ▸ 2 délégation(s) · ~6,1k tokens évités · pilotage 1,5k · corrections 600 · net ~4k (cumul ~35k)')
|
.toBe('🟢 MIAM net ~4k (cumul ~35k)🟢')
|
||||||
expect(ligne({ delegations: 1, evites: 900, pilotage: 0, corrections: 0 }, cumul))
|
expect(ligne({ delegations: 1, evites: 900, pilotage: 0, corrections: 0 }, cumul))
|
||||||
.toBe('MIAM ▸ 1 délégation(s) · ~900 tokens évités (cumul ~35k)')
|
.toBe('🟢 MIAM net ~900 (cumul ~35k)🟢')
|
||||||
expect(ligne(VIDE, cumul)).toBe('MIAM ▸ cumul ~35k tokens évités en 9 délégation(s)')
|
expect(ligne(VIDE, cumul)).toBe('🟢 MIAM cumul ~35k')
|
||||||
|
expect(ligne(VIDE, VIDE)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rouge si le gain est négatif, vert s’il est positif, rien à zéro', () => {
|
||||||
|
const perte = { delegations: 1, evites: 500, pilotage: 2000, corrections: 0 }
|
||||||
|
expect(ligne(perte, perte)).toBe('🔴 MIAM net ~-1,5k (cumul ~-1,5k)🔴')
|
||||||
|
expect(ligne(VIDE, perte)).toBe('🔴 MIAM cumul ~-1,5k')
|
||||||
|
expect([1, -1, 0].map(pastille)).toEqual(['🟢 ', '🔴 ', ''])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('estimer : tokens_cloud_entree défini, parSubagent false', () => {
|
||||||
|
const consigne = 'c'.repeat(400)
|
||||||
|
const rep = { ok: true, tokens_cloud_entree: 500, tokens_cloud_sortie: 100 }
|
||||||
|
expect(estimer(consigne, 'complet', rep, false)).toEqual({
|
||||||
|
lus: 500,
|
||||||
|
ecrits: 100,
|
||||||
|
consigne: 100,
|
||||||
|
rendus: 0,
|
||||||
|
evites: 500 + 100 - 100
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('estimer : tokens_cloud_entree défini, parSubagent true', () => {
|
||||||
|
const consigne = 'c'.repeat(400)
|
||||||
|
const rep = { ok: true, tokens_cloud_entree: 500, tokens_cloud_sortie: 100 }
|
||||||
|
expect(estimer(consigne, 'complet', rep, true)).toEqual({
|
||||||
|
lus: 500,
|
||||||
|
ecrits: 100,
|
||||||
|
consigne: 100,
|
||||||
|
rendus: 0,
|
||||||
|
evites: 500 + 100
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('repartir : usage réel partagé entre les appels de la requête précédente', () => {
|
||||||
|
const u = { input_tokens: 100, output_tokens: 40, cache_read_input_tokens: 1000, cache_creation_input_tokens: 0 }
|
||||||
|
const suivis = [
|
||||||
|
{ demandeId: 'a', toolUseId: 'u1' }, { demandeId: 'b', toolUseId: 'u2' }, { demandeId: 'a', toolUseId: 'u3' },
|
||||||
|
]
|
||||||
|
expect(repartir(suivis, 4, u)).toEqual([
|
||||||
|
{ demandeId: 'a', toolUseId: 'u1', usage: { input_tokens: 50, output_tokens: 20, cache_read_input_tokens: 500, cache_creation_input_tokens: 0 } },
|
||||||
|
{ demandeId: 'b', toolUseId: 'u2', usage: { input_tokens: 25, output_tokens: 10, cache_read_input_tokens: 250, cache_creation_input_tokens: 0 } },
|
||||||
|
])
|
||||||
|
expect(repartir(suivis.slice(0, 1), 0, u)[0]?.usage).toEqual(u) // compte d'appels inconnu : tout pour le suivi
|
||||||
|
expect(repartir([], 2, u)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('evenementDelegation : remplit tokens depuis le cloud', () => {
|
||||||
|
const a = { sessionId: 's', retour: undefined, maintenant: 0 }
|
||||||
|
const rep = { ok: true, tokens_cloud_entree: 300, tokens_cloud_sortie: 50 }
|
||||||
|
const est = estimer('c'.repeat(400), 'complet', rep, false)
|
||||||
|
const evt = evenementDelegation(a, { ...rep, demande_id: 'dem-o' }, est)
|
||||||
|
expect(evt.tokens_entree).toBe(300)
|
||||||
|
expect(evt.tokens_sortie).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lireResultat : un résultat OpenCode est lu, pas une demande en file', () => {
|
||||||
|
expect(lireResultat('{"ok":true,"demande_id":"d","tokens_cloud_entree":10}')?.tokens_cloud_entree).toBe(10)
|
||||||
|
expect(lireResultat('{"ok":null,"demande_id":"d","tokens_cloud_entree":10}')).toBeUndefined()
|
||||||
})
|
})
|
||||||
@@ -5,13 +5,19 @@
|
|||||||
// modèle local ; la consigne et l'aperçu sont estimés à 4 caractères par token.
|
// modèle local ; la consigne et l'aperçu sont estimés à 4 caractères par token.
|
||||||
// Appelé depuis le subagent miam:delegue, la consigne et la lecture de la réponse sont à sa charge : elles comptent
|
// Appelé depuis le subagent miam:delegue, la consigne et la lecture de la réponse sont à sa charge : elles comptent
|
||||||
// dans le pilotage (sa consommation réelle, mesurée à la fin de son tour), pas ici.
|
// dans le pilotage (sa consommation réelle, mesurée à la fin de son tour), pas ici.
|
||||||
|
// Délégué à OpenCode, le travail est fait par le cloud : les tokens évités sont ceux du cloud moins la consigne (même
|
||||||
|
// méthode que MIAM, core/gains.py). Claude paie en plus les requêtes qui lui servent à suivre la tâche, contexte relu
|
||||||
|
// compris : c'est le pilotage, mesuré sur l'usage réel de ces requêtes (voir `repartir`).
|
||||||
|
|
||||||
export type Delegation = {
|
export type Delegation = {
|
||||||
demande_id?: string
|
demande_id?: string
|
||||||
tokens_entree?: number
|
tokens_entree?: number
|
||||||
tokens_sortie?: number
|
tokens_sortie?: number
|
||||||
|
tokens_cloud_entree?: number // OpenCode, à la place de tokens_entree et tokens_sortie
|
||||||
|
tokens_cloud_sortie?: number
|
||||||
reponse?: string
|
reponse?: string
|
||||||
sortie?: string // fichier écrit par MIAM (chemin absolu) : ce que Claude y reprend est une correction
|
sortie?: string // fichier écrit par MIAM (chemin absolu) : ce que Claude y reprend est une correction
|
||||||
|
diff?: string // diff d'OpenCode (chemin absolu) : sa relecture compte dans le pilotage
|
||||||
}
|
}
|
||||||
|
|
||||||
// consigne et rendus : estimés, déduits seulement pour un appel direct (par le subagent, ils sont dans le pilotage).
|
// consigne et rendus : estimés, déduits seulement pour un appel direct (par le subagent, ils sont dans le pilotage).
|
||||||
@@ -35,6 +41,11 @@ const tokens = (texte: string) => Math.ceil(texte.length / 4)
|
|||||||
|
|
||||||
export function estimer(consigne: string, retour: string | undefined, r: Delegation, parSubagent: boolean): Estimation {
|
export function estimer(consigne: string, retour: string | undefined, r: Delegation, parSubagent: boolean): Estimation {
|
||||||
const redige = tokens(consigne)
|
const redige = tokens(consigne)
|
||||||
|
if (r.tokens_cloud_entree !== undefined) {
|
||||||
|
const lus = r.tokens_cloud_entree
|
||||||
|
const ecrits = r.tokens_cloud_sortie ?? 0
|
||||||
|
return { lus, ecrits, consigne: redige, rendus: 0, evites: lus + ecrits - (parSubagent ? 0 : redige) }
|
||||||
|
}
|
||||||
const lus = Math.max(0, (r.tokens_entree ?? 0) - redige) // fichiers (et consigne de format) lus par le modèle local
|
const lus = Math.max(0, (r.tokens_entree ?? 0) - redige) // fichiers (et consigne de format) lus par le modèle local
|
||||||
const ecrits = r.tokens_sortie ?? 0
|
const ecrits = r.tokens_sortie ?? 0
|
||||||
const rendus = retour === 'apercu' ? tokens(r.reponse ?? '') : ecrits
|
const rendus = retour === 'apercu' ? tokens(r.reponse ?? '') : ecrits
|
||||||
@@ -46,6 +57,30 @@ export function estimer(consigne: string, retour: string | undefined, r: Delegat
|
|||||||
export const cout = (u: Usage) =>
|
export const cout = (u: Usage) =>
|
||||||
u.input_tokens + u.cache_creation_input_tokens + u.output_tokens + Math.round(u.cache_read_input_tokens / 10)
|
u.input_tokens + u.cache_creation_input_tokens + u.output_tokens + Math.round(u.cache_read_input_tokens / 10)
|
||||||
|
|
||||||
|
// Un appel de l'agent principal pour suivre OpenCode (outil de MIAM, relecture du diff).
|
||||||
|
export type Suivi = { demandeId: string; toolUseId: string }
|
||||||
|
|
||||||
|
// Pilotage réel : la requête qui suit des appels de suivi n'existe que pour lire leurs résultats ; son usage (contexte
|
||||||
|
// relu compris) revient à leurs délégations, au prorata quand l'étape précédente a aussi lancé d'autres outils.
|
||||||
|
export function repartir(suivis: readonly Suivi[], appels: number, u: Usage): (Suivi & { usage: Usage })[] {
|
||||||
|
const nombre = Math.max(appels, suivis.length)
|
||||||
|
const parDemande = new Map<string, Suivi[]>()
|
||||||
|
for (const s of suivis) {
|
||||||
|
parDemande.set(s.demandeId, [...(parDemande.get(s.demandeId) ?? []), s])
|
||||||
|
}
|
||||||
|
return [...parDemande.values()].map(liste => {
|
||||||
|
const part = (n: number) => Math.round((n * liste.length) / nombre)
|
||||||
|
return {
|
||||||
|
...liste[0]!,
|
||||||
|
usage: {
|
||||||
|
input_tokens: part(u.input_tokens), output_tokens: part(u.output_tokens),
|
||||||
|
cache_read_input_tokens: part(u.cache_read_input_tokens),
|
||||||
|
cache_creation_input_tokens: part(u.cache_creation_input_tokens),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// `t` peut venir d'une version précédente du mod (cumul gardé sans `corrections`).
|
// `t` peut venir d'une version précédente du mod (cumul gardé sans `corrections`).
|
||||||
export const ajouter = (t: Partial<Totaux>, d: Partial<Totaux>): Totaux => ({
|
export const ajouter = (t: Partial<Totaux>, d: Partial<Totaux>): Totaux => ({
|
||||||
delegations: (t.delegations ?? 0) + (d.delegations ?? 0),
|
delegations: (t.delegations ?? 0) + (d.delegations ?? 0),
|
||||||
@@ -112,23 +147,25 @@ export function court(n: number): string {
|
|||||||
return (n < 0 ? '-' : '') + s.replace('.', ',').replace(',0', '')
|
return (n < 0 ? '-' : '') + s.replace('.', ',').replace(',0', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signe d'un gain : les notifications et la ligne d'état n'acceptent que du texte.
|
||||||
|
export function pastille(gain: number): string {
|
||||||
|
return gain > 0 ? '🟢 ' : gain < 0 ? '🔴 ' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gain pour la ligne d'état ; vide tant que rien n'a été délégué.
|
||||||
export function ligne(session: Totaux, cumul: Totaux): string {
|
export function ligne(session: Totaux, cumul: Totaux): string {
|
||||||
const couts = [
|
if (session.delegations) {
|
||||||
session.pilotage ? ` · pilotage ${court(session.pilotage)}` : '',
|
return `${pastille(net(session))}MIAM net ~${court(net(session))} (cumul ~${court(net(cumul))})${pastille(net(cumul)).trimEnd()}`
|
||||||
session.corrections ? ` · corrections ${court(session.corrections)}` : '',
|
}
|
||||||
].join('')
|
return cumul.delegations ? `${pastille(net(cumul))}MIAM cumul ~${court(net(cumul))}` : ''
|
||||||
const detail = couts ? `${couts} · net ~${court(net(session))}` : ''
|
|
||||||
return session.delegations
|
|
||||||
? `MIAM ▸ ${session.delegations} délégation(s) · ~${court(session.evites)} tokens évités${detail}` +
|
|
||||||
` (cumul ~${court(net(cumul))})`
|
|
||||||
: `MIAM ▸ cumul ~${court(net(cumul))} tokens évités en ${cumul.delegations} délégation(s)`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Résultat de `deleguer` tel que Claude Code le rend : le texte JSON de MIAM.
|
// Résultat de `deleguer` tel que Claude Code le rend : le texte JSON de MIAM.
|
||||||
export function lireResultat(texte: unknown): Delegation | undefined {
|
export function lireResultat(texte: unknown): Delegation | undefined {
|
||||||
try {
|
try {
|
||||||
const r = typeof texte === 'string' ? JSON.parse(texte) : texte
|
const r = typeof texte === 'string' ? JSON.parse(texte) : texte
|
||||||
return r && typeof r === 'object' && (r as { ok?: unknown }).ok === true && 'tokens_entree' in r
|
return r && typeof r === 'object' && (r as { ok?: unknown }).ok === true &&
|
||||||
|
('tokens_entree' in r || 'tokens_cloud_entree' in r)
|
||||||
? (r as Delegation)
|
? (r as Delegation)
|
||||||
: undefined
|
: undefined
|
||||||
} catch {
|
} catch {
|
||||||
@@ -201,7 +238,8 @@ export function evenementDelegation(
|
|||||||
schema: SCHEMA, type: 'delegation', id: `${CLIENT}:${r.demande_id}`, date: iso(a.maintenant), client: CLIENT,
|
schema: SCHEMA, type: 'delegation', id: `${CLIENT}:${r.demande_id}`, date: iso(a.maintenant), client: CLIENT,
|
||||||
session_id: a.sessionId, demande_id: r.demande_id, agent_id: a.agentId ?? null,
|
session_id: a.sessionId, demande_id: r.demande_id, agent_id: a.agentId ?? null,
|
||||||
par_subagent: a.agentId !== undefined, retour: a.retour === 'apercu' ? 'apercu' : 'complet',
|
par_subagent: a.agentId !== undefined, retour: a.retour === 'apercu' ? 'apercu' : 'complet',
|
||||||
tokens_entree: r.tokens_entree ?? 0, tokens_sortie: r.tokens_sortie ?? 0,
|
tokens_entree: r.tokens_entree ?? r.tokens_cloud_entree ?? 0,
|
||||||
|
tokens_sortie: r.tokens_sortie ?? r.tokens_cloud_sortie ?? 0,
|
||||||
estimation: { ...est, methode: 'caracteres/4' },
|
estimation: { ...est, methode: 'caracteres/4' },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{ "modules": ["./register.ts"] }
|
{ "modules": ["./register.tsx"] }
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { expect, test } from 'claude-code/testing'
|
|
||||||
|
|
||||||
const OUTIL = 'mcp__plugin_miam_miam__deleguer'
|
|
||||||
const SUIVRE = 'mcp__plugin_miam_miam__suivre'
|
|
||||||
|
|
||||||
type Envoi = { url: string; auth?: string; ids: string[] }
|
|
||||||
|
|
||||||
const attendre = async (fini: () => boolean) => {
|
|
||||||
for (let i = 0; i < 100 && !fini(); i++) {
|
|
||||||
await new Promise(r => setTimeout(r, 5))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test('une délégation du subagent est comptée, son pilotage aussi, et MIAM les reçoit hors des hooks',
|
|
||||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
|
||||||
const statuts: (string | undefined)[] = []
|
|
||||||
const toasts: string[] = []
|
|
||||||
const envois: Envoi[] = []
|
|
||||||
const store = new Map<string, unknown>()
|
|
||||||
let tic: () => void = () => {}
|
|
||||||
on('store.get', (_, e) => ({ value: store.get(e.key) }))
|
|
||||||
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
|
|
||||||
on('ui.status', (_, e) => (statuts.push(e.text), { value: undefined }))
|
|
||||||
on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined }))
|
|
||||||
on('session.id', () => ({ value: 's1' }))
|
|
||||||
on('clock.now', () => ({ value: 0 }))
|
|
||||||
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
|
||||||
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
||||||
// Chaque période de la minuterie attend que le test la déclenche.
|
|
||||||
on('clock.every', () => new Promise(r => (tic = () => r({ value: undefined }))))
|
|
||||||
on('http.fetch', (_, e) => {
|
|
||||||
const corps = JSON.parse(e.init?.body ?? '{}') as { evenements: { id: string }[] }
|
|
||||||
envois.push({ url: e.url, auth: e.init?.headers?.Authorization, ids: corps.evenements.map(ev => ev.id) })
|
|
||||||
const status = envois.length === 1 ? 503 : 200 // MIAM indisponible la première fois
|
|
||||||
return { value: { status, ok: status === 200, headers: {}, text: '' } }
|
|
||||||
})
|
|
||||||
on('tool.call', (_, e) =>
|
|
||||||
e.tool === OUTIL
|
|
||||||
? { result: {}, text: '{"ok":null,"demande_id":"dem-1","etat":"en_file"}' }
|
|
||||||
: { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-1', tokens_entree: 4100, tokens_sortie: 900 }) },
|
|
||||||
)
|
|
||||||
on('turn.complete', (_, e) => ({ text: e.answer }))
|
|
||||||
|
|
||||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
|
||||||
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), agentId: 'a1' } as never)
|
|
||||||
expect(toasts).toEqual([]) // en file : rien encore
|
|
||||||
await $.tool.call({ tool: SUIVRE, demande_id: 'dem-1', agentId: 'a1' } as never)
|
|
||||||
await $.tool.call({ tool: SUIVRE, demande_id: 'dem-1', agentId: 'a1' } as never) // relu : pas recompté
|
|
||||||
expect(toasts).toEqual(['MIAM : ~4,9k tokens évités (4k lus, 900 écrits)'])
|
|
||||||
expect(envois).toEqual([]) // le hook n'envoie rien lui-même
|
|
||||||
|
|
||||||
tic() // 1er envoi : 503, la délégation reste en attente
|
|
||||||
await attendre(() => envois.length === 1)
|
|
||||||
await $.turn.complete({
|
|
||||||
turnId: 't', answer: 'compte rendu', durationMs: 1, isAborted: false, reason: 'answer', agentId: 'a1',
|
|
||||||
usage: {
|
|
||||||
model: 'haiku', input_tokens: 800, output_tokens: 200, cache_read_input_tokens: 10000,
|
|
||||||
cache_creation_input_tokens: 0,
|
|
||||||
},
|
|
||||||
} as never)
|
|
||||||
expect(statuts.at(-1)).toBe('MIAM ▸ 1 délégation(s) · ~4,9k tokens évités · pilotage 2k · net ~2,9k (cumul ~2,9k)')
|
|
||||||
expect(store.get('cumul')).toEqual({ delegations: 1, evites: 4900, pilotage: 2000, corrections: 0 })
|
|
||||||
|
|
||||||
await attendre(() => false) // laisse finir le 1er envoi et demander la période suivante
|
|
||||||
tic() // 2e envoi : délégation + pilotage, acceptés
|
|
||||||
await attendre(() => envois.length === 2 && (store.get('a_envoyer') as unknown[]).length === 0)
|
|
||||||
expect(envois.map(v => v.ids)).toEqual([['claude-code:dem-1'], ['claude-code:dem-1', 'claude-code:s1:a1:t']])
|
|
||||||
expect(envois[0]).toMatchObject({ url: 'http://127.0.0.1:8090/economies', auth: 'Bearer x' })
|
|
||||||
expect(store.get('a_envoyer')).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('les reprises par l’agent principal d’un fichier écrit par MIAM sont déduites, pas celles du subagent',
|
|
||||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
|
||||||
const statuts: (string | undefined)[] = []
|
|
||||||
const store = new Map<string, unknown>()
|
|
||||||
on('store.get', (_, e) => ({ value: store.get(e.key) }))
|
|
||||||
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
|
|
||||||
on('ui.status', (_, e) => (statuts.push(e.text), { value: undefined }))
|
|
||||||
on('ui.toast', () => ({ value: undefined }))
|
|
||||||
on('session.id', () => ({ value: 's1' }))
|
|
||||||
on('clock.now', () => ({ value: 0 }))
|
|
||||||
on('tool.call', (_, e) =>
|
|
||||||
e.tool === OUTIL
|
|
||||||
? {
|
|
||||||
result: {},
|
|
||||||
text: JSON.stringify({
|
|
||||||
ok: true, demande_id: 'dem-7', tokens_entree: 600, tokens_sortie: 2000, sortie: 'E:\\p\\tests\\test_x.py',
|
|
||||||
reponse: 'a'.repeat(40),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
: { result: {}, text: e.tool === 'Read' ? 'r'.repeat(400) : 'ok' },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Appel direct, retour apercu : 600 - 100 lus + 2000 écrits - 100 (consigne) - 10 (aperçu) = 2390
|
|
||||||
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), retour: 'apercu' } as never)
|
|
||||||
await $.tool.call({ tool: 'Read', file_path: 'e:/p/tests/test_x.py', tool_use_id: 'tu-1' } as never) // 100 lus
|
|
||||||
await $.tool.call({
|
|
||||||
tool: 'Edit', file_path: 'E:\\p\\tests\\test_x.py', old_string: 'o'.repeat(40), new_string: 'n'.repeat(80),
|
|
||||||
tool_use_id: 'tu-2',
|
|
||||||
} as never) // 30 écrits
|
|
||||||
await $.tool.call({ tool: 'Edit', file_path: 'E:\\p\\autre.py', old_string: 'o', new_string: 'n' } as never)
|
|
||||||
await $.tool.call({
|
|
||||||
tool: 'Edit', file_path: 'E:\\p\\tests\\test_x.py', old_string: 'o'.repeat(400), new_string: 'n',
|
|
||||||
agentId: 'a9',
|
|
||||||
} as never) // par un subagent : dans son pilotage
|
|
||||||
|
|
||||||
expect(statuts.at(-1)).toBe('MIAM ▸ 1 délégation(s) · ~2,4k tokens évités · corrections 130 · net ~2,3k (cumul ~2,3k)')
|
|
||||||
expect(store.get('cumul')).toEqual({ delegations: 1, evites: 2390, pilotage: 0, corrections: 130 })
|
|
||||||
const attente = store.get('a_envoyer') as { type: string; id: string; cout?: number }[]
|
|
||||||
expect(attente.map(ev => [ev.type, ev.id, ev.cout])).toEqual([
|
|
||||||
['delegation', 'claude-code:dem-7', undefined],
|
|
||||||
['correction', 'claude-code:s1:tu-1', 100],
|
|
||||||
['correction', 'claude-code:s1:tu-2', 30],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import { expect, test } from 'claude-code/testing'
|
||||||
|
|
||||||
|
const OUTIL = 'mcp__plugin_miam_miam__deleguer'
|
||||||
|
const SUIVRE = 'mcp__plugin_miam_miam__suivre'
|
||||||
|
|
||||||
|
type Envoi = { url: string; auth?: string; ids: string[] }
|
||||||
|
|
||||||
|
const attendre = async (fini: () => boolean) => {
|
||||||
|
for (let i = 0; i < 100 && !fini(); i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('une délégation du subagent est comptée, son pilotage aussi, et MIAM les reçoit hors des hooks',
|
||||||
|
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
const statuts: (string | undefined)[] = []
|
||||||
|
const toasts: string[] = []
|
||||||
|
const envois: Envoi[] = []
|
||||||
|
const store = new Map<string, unknown>()
|
||||||
|
let tic: () => void = () => {}
|
||||||
|
on('store.get', (_, e) => ({ value: store.get(e.key) }))
|
||||||
|
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
|
||||||
|
on('ui.status', (_, e) => (statuts.push(e.text), { value: undefined }))
|
||||||
|
on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined }))
|
||||||
|
on('session.id', () => ({ value: 's1' }))
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||||
|
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
||||||
|
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||||
|
// Chaque période de la minuterie attend que le test la déclenche.
|
||||||
|
on('clock.every', () => new Promise(r => (tic = () => r({ value: undefined }))))
|
||||||
|
on('http.fetch', (_, e) => {
|
||||||
|
const corps = JSON.parse(e.init?.body ?? '{}') as { evenements: { id: string }[] }
|
||||||
|
envois.push({ url: e.url, auth: e.init?.headers?.Authorization, ids: corps.evenements.map(ev => ev.id) })
|
||||||
|
const status = envois.length === 1 ? 503 : 200 // MIAM indisponible la première fois
|
||||||
|
return { value: { status, ok: status === 200, headers: {}, text: '' } }
|
||||||
|
})
|
||||||
|
on('tool.call', (_, e) =>
|
||||||
|
e.tool === OUTIL
|
||||||
|
? { result: {}, text: '{"ok":null,"demande_id":"dem-1","etat":"en_file"}' }
|
||||||
|
: { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-1', tokens_entree: 4100, tokens_sortie: 900 }) },
|
||||||
|
)
|
||||||
|
on('turn.complete', (_, e) => ({ text: e.answer }))
|
||||||
|
|
||||||
|
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||||
|
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), agentId: 'a1' } as never)
|
||||||
|
expect(toasts).toEqual([]) // en file : rien encore
|
||||||
|
await $.tool.call({ tool: SUIVRE, demande_id: 'dem-1', agentId: 'a1' } as never)
|
||||||
|
await $.tool.call({ tool: SUIVRE, demande_id: 'dem-1', agentId: 'a1' } as never) // relu : pas recompté
|
||||||
|
expect(toasts).toEqual(['🟢 MIAM : ~4,9k tokens évités (4k lus, 900 écrits)'])
|
||||||
|
expect(envois).toEqual([]) // le hook n'envoie rien lui-même
|
||||||
|
|
||||||
|
tic() // 1er envoi : 503, la délégation reste en attente
|
||||||
|
await attendre(() => envois.length === 1)
|
||||||
|
await $.turn.complete({
|
||||||
|
turnId: 't', answer: 'compte rendu', durationMs: 1, isAborted: false, reason: 'answer', agentId: 'a1',
|
||||||
|
usage: {
|
||||||
|
model: 'haiku', input_tokens: 800, output_tokens: 200, cache_read_input_tokens: 10000,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
expect(statuts.at(-1)).toBe('🟢 MIAM net ~2,9k (cumul ~2,9k)🟢')
|
||||||
|
expect(store.get('cumul')).toEqual({ delegations: 1, evites: 4900, pilotage: 2000, corrections: 0 })
|
||||||
|
|
||||||
|
await attendre(() => false) // laisse finir le 1er envoi et demander la période suivante
|
||||||
|
tic() // 2e envoi : délégation + pilotage, acceptés
|
||||||
|
await attendre(() => envois.length === 2 && (store.get('a_envoyer') as unknown[]).length === 0)
|
||||||
|
expect(envois.map(v => v.ids)).toEqual([['claude-code:dem-1'], ['claude-code:dem-1', 'claude-code:s1:a1:t']])
|
||||||
|
expect(envois[0]).toMatchObject({ url: 'http://127.0.0.1:8090/economies', auth: 'Bearer x' })
|
||||||
|
expect(store.get('a_envoyer')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('les reprises par l’agent principal d’un fichier écrit par MIAM sont déduites, pas celles du subagent',
|
||||||
|
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
const statuts: (string | undefined)[] = []
|
||||||
|
const store = new Map<string, unknown>()
|
||||||
|
on('store.get', (_, e) => ({ value: store.get(e.key) }))
|
||||||
|
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
|
||||||
|
on('ui.status', (_, e) => (statuts.push(e.text), { value: undefined }))
|
||||||
|
on('ui.toast', () => ({ value: undefined }))
|
||||||
|
on('session.id', () => ({ value: 's1' }))
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('tool.call', (_, e) =>
|
||||||
|
e.tool === OUTIL
|
||||||
|
? {
|
||||||
|
result: {},
|
||||||
|
text: JSON.stringify({
|
||||||
|
ok: true, demande_id: 'dem-7', tokens_entree: 600, tokens_sortie: 2000, sortie: 'E:\\p\\tests\\test_x.py',
|
||||||
|
reponse: 'a'.repeat(40),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: { result: {}, text: e.tool === 'Read' ? 'r'.repeat(400) : 'ok' },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Appel direct, retour apercu : 600 - 100 lus + 2000 écrits - 100 (consigne) - 10 (aperçu) = 2390
|
||||||
|
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), retour: 'apercu' } as never)
|
||||||
|
await $.tool.call({ tool: 'Read', file_path: 'e:/p/tests/test_x.py', tool_use_id: 'tu-1' } as never) // 100 lus
|
||||||
|
await $.tool.call({
|
||||||
|
tool: 'Edit', file_path: 'E:\\p\\tests\\test_x.py', old_string: 'o'.repeat(40), new_string: 'n'.repeat(80),
|
||||||
|
tool_use_id: 'tu-2',
|
||||||
|
} as never) // 30 écrits
|
||||||
|
await $.tool.call({ tool: 'Edit', file_path: 'E:\\p\\autre.py', old_string: 'o', new_string: 'n' } as never)
|
||||||
|
await $.tool.call({
|
||||||
|
tool: 'Edit', file_path: 'E:\\p\\tests\\test_x.py', old_string: 'o'.repeat(400), new_string: 'n',
|
||||||
|
agentId: 'a9',
|
||||||
|
} as never) // par un subagent : dans son pilotage
|
||||||
|
|
||||||
|
expect(statuts.at(-1)).toBe('🟢 MIAM net ~2,3k (cumul ~2,3k)🟢')
|
||||||
|
expect(store.get('cumul')).toEqual({ delegations: 1, evites: 2390, pilotage: 0, corrections: 130 })
|
||||||
|
const attente = store.get('a_envoyer') as { type: string; id: string; cout?: number }[]
|
||||||
|
expect(attente.map(ev => [ev.type, ev.id, ev.cout])).toEqual([
|
||||||
|
['delegation', 'claude-code:dem-7', undefined],
|
||||||
|
['correction', 'claude-code:s1:tu-1', 100],
|
||||||
|
['correction', 'claude-code:s1:tu-2', 30],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
const LIMITES = [{ kind: 'five_hour', percentUsed: 95 }, { kind: 'seven_day', percentUsed: 18 }]
|
||||||
|
|
||||||
|
test('la ligne d’état réunit le gain et l’usage, et suit les nouvelles limites', { options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
const statuts: (string | undefined)[] = []
|
||||||
|
const store = new Map<string, unknown>([['cumul', { delegations: 1, evites: 500, pilotage: 2000, corrections: 0 }]])
|
||||||
|
on('store.get', (_, e) => ({ value: store.get(e.key) }))
|
||||||
|
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
|
||||||
|
on('ui.status', (_, e) => (statuts.push(e.text), { value: undefined }))
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('clock.every', () => new Promise(() => {}))
|
||||||
|
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||||
|
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
||||||
|
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: LIMITES } }))
|
||||||
|
on('session.measure', () => ({ changed: [] }))
|
||||||
|
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||||
|
expect(statuts).toEqual(['🔴 MIAM cumul ~-1,5k · 5 h 🟥🟥🟥🟥🟥 95 % · hebdo 🟩⬛⬛⬛⬛ 18 %'])
|
||||||
|
|
||||||
|
await $.session.measure({ rateLimits: [{ kind: 'five_hour', percentUsed: 75 }] } as never)
|
||||||
|
await $.session.measure({ rateLimits: [{ kind: 'five_hour', percentUsed: 75 }] } as never) // même texte : pas d'envoi
|
||||||
|
expect(statuts.slice(1)).toEqual(['🔴 MIAM cumul ~-1,5k · 5 h 🟧🟧🟧🟧⬛ 75 %'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('un long fichier est refusé à l’agent principal, sauf plage, subagent ou MIAM injoignable',
|
||||||
|
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
let miamOuvert = true
|
||||||
|
let lectures = 0
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('fs.stat', () => ({ value: { kind: 'file', size: 50000, mtimeMs: 0, isLink: false } }))
|
||||||
|
on('http.fetch', () => {
|
||||||
|
if (!miamOuvert) {
|
||||||
|
throw new Error('connexion refusée')
|
||||||
|
}
|
||||||
|
return { value: { status: 405, ok: false, headers: {}, text: '' } }
|
||||||
|
})
|
||||||
|
on('tool.call', () => (lectures += 1, { result: {}, text: 'ok' }))
|
||||||
|
const lire = (extra = {}) => $.tool.call({ tool: 'Read', file_path: 'long.log', ...extra } as never)
|
||||||
|
|
||||||
|
expect((await lire()).deny).toContain('`deleguer`')
|
||||||
|
expect(lectures).toBe(0)
|
||||||
|
expect((await lire({ limit: 400 })).deny).toBeUndefined()
|
||||||
|
expect((await lire({ agentId: 'a1' })).deny).toBeUndefined()
|
||||||
|
miamOuvert = false
|
||||||
|
expect((await lire()).deny).toBeUndefined()
|
||||||
|
expect(lectures).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('une longue sortie shell est enregistrée et retenue, pas une erreur ni une sortie courte',
|
||||||
|
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
const fichiers = new Map<string, string>()
|
||||||
|
let reponse: { result: object; text: string; isError?: true } = { result: {}, text: 'x'.repeat(20000) }
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('fs.write', (_, e) => (fichiers.set(e.path, e.text), { value: undefined }))
|
||||||
|
on('http.fetch', () => ({ value: { status: 405, ok: false, headers: {}, text: '' } }))
|
||||||
|
on('tool.call', () => reponse)
|
||||||
|
const lancer = () => $.tool.call({ tool: 'PowerShell', command: 'pytest', tool_use_id: 'a/b' } as never)
|
||||||
|
|
||||||
|
expect((await lancer()).deny).toContain('.claude/miam-sorties/sortie-a_b.txt')
|
||||||
|
expect([...fichiers].map(([c, t]) => [c.split(/[\\/]/).pop(), t.length])).toEqual([['.gitignore', 2], ['sortie-a_b.txt', 20000]])
|
||||||
|
reponse = { result: {}, text: 'court' }
|
||||||
|
expect((await lancer()).deny).toBeUndefined()
|
||||||
|
reponse = { result: {}, text: 'x'.repeat(20000), isError: true }
|
||||||
|
expect((await lancer()).deny).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('imposer=false : rien n’est refusé', { options: { jeton: 'x', imposer: false } }, async ($, on) => {
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('tool.call', () => ({ result: {}, text: 'x'.repeat(20000) }))
|
||||||
|
expect((await $.tool.call({ tool: 'Read', file_path: 'long.log' } as never)).deny).toBeUndefined()
|
||||||
|
expect((await $.tool.call({ tool: 'Bash', command: 'ls' } as never)).deny).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('une grosse lecture de l’agent principal vaut un rappel, pas deux de suite, et jamais à un subagent',
|
||||||
|
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
let t = 0
|
||||||
|
on('clock.now', () => ({ value: t }))
|
||||||
|
on('tool.call', () => ({ result: {}, text: 'x'.repeat(20000) }))
|
||||||
|
const lire = (agentId?: string) => $.tool.call({ tool: 'Read', file_path: 'a.log', agentId } as never)
|
||||||
|
|
||||||
|
expect((await lire('a1')).context).toBeUndefined()
|
||||||
|
expect((await lire()).context?.[0]).toContain('`deleguer`')
|
||||||
|
expect((await lire()).context).toBeUndefined()
|
||||||
|
t = 600000
|
||||||
|
expect((await lire()).context).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('la consigne va à l’agent principal seulement', { options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
on('prompt.compose', () => ({ sections: [] }))
|
||||||
|
const composer = (tools: string[]) =>
|
||||||
|
$.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools, outputStyle: null, traits: [] })
|
||||||
|
expect((await composer(['Agent', 'Read'])).sections.map(s => s.id)).toEqual(['miam:delegation'])
|
||||||
|
expect((await composer(['Read'])).sections).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('incitation=false : ni consigne ni rappel', { options: { jeton: 'x', incitation: false } }, async ($, on) => {
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('prompt.compose', () => ({ sections: [] }))
|
||||||
|
on('tool.call', () => ({ result: {}, text: 'x'.repeat(20000) }))
|
||||||
|
const composition = await $.prompt.compose({
|
||||||
|
model: 'm', promptModel: 'm', surfaces: [], tools: ['Agent'], outputStyle: null, traits: [],
|
||||||
|
})
|
||||||
|
expect(composition.sections).toEqual([])
|
||||||
|
expect((await $.tool.call({ tool: 'Read', file_path: 'a.log' } as never)).context).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
const REJETER = 'mcp__plugin_miam_miam__rejeter'
|
||||||
|
const USAGE = { model: 'opus', input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10000, cache_creation_input_tokens: 0 }
|
||||||
|
const COUT = 100 + 50 + 1000 // lecture en cache au dixième
|
||||||
|
|
||||||
|
// Requêtes du modèle : chacune répond par `outils` appels, `usage` null simule une requête perdue.
|
||||||
|
let reponse: { outils: number; usage: typeof USAGE | null } = { outils: 1, usage: USAGE }
|
||||||
|
const reponseModele = async function* () {
|
||||||
|
return {
|
||||||
|
turnId: 't', index: 0, answer: '', toolUses: Array.from({ length: reponse.outils }, () => ({ name: 'x', input: {} })),
|
||||||
|
stopReason: 'tool_use', usage: reponse.usage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const requete = async ($: any, r: typeof reponse = { outils: 1, usage: USAGE }) => {
|
||||||
|
reponse = r
|
||||||
|
for await (const _ of $.turn.step({ turnId: 't', index: 0, model: 'm', messageCount: 1 })) {
|
||||||
|
// le flux doit être lu jusqu'au bout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('une délégation OpenCode est comptée avec son pilotage',
|
||||||
|
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
const toasts: string[] = []
|
||||||
|
const store = new Map<string, unknown>()
|
||||||
|
on('store.get', (_, e) => ({ value: store.get(e.key) }))
|
||||||
|
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
|
||||||
|
on('ui.status', (_, e) => ({ value: undefined }))
|
||||||
|
on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined }))
|
||||||
|
on('session.id', () => ({ value: 's1' }))
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||||
|
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
||||||
|
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||||
|
on('clock.every', () => new Promise(() => {}))
|
||||||
|
on('turn.step', reponseModele)
|
||||||
|
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
|
||||||
|
on('tool.call', (_, e) =>
|
||||||
|
e.tool === OUTIL
|
||||||
|
? { result: {}, text: '{"ok":null,"demande_id":"dem-o","etat":"en_file"}' }
|
||||||
|
: e.tool === SUIVRE
|
||||||
|
? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o', tokens_cloud_entree: 9000, tokens_cloud_sortie: 1000, resume: 'r', diff: 'E:/x/d.diff' }) }
|
||||||
|
: { result: {}, text: '{}' },
|
||||||
|
)
|
||||||
|
|
||||||
|
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||||
|
await requete($) // écrit l'appel à deleguer : cette requête aurait existé sans délégation
|
||||||
|
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), moteur: 'opencode', tool_use_id: 'u1' } as never)
|
||||||
|
await requete($)
|
||||||
|
await $.tool.call({ tool: SUIVRE, demande_id: 'dem-o', tool_use_id: 'u2' } as never)
|
||||||
|
await requete($, { outils: 0, usage: USAGE })
|
||||||
|
|
||||||
|
expect(toasts.length).toBe(1)
|
||||||
|
expect(toasts[0]).toContain('tokens évités par OpenCode')
|
||||||
|
expect(toasts[0]).toContain('~9,9k')
|
||||||
|
|
||||||
|
const cumul = store.get('cumul') as { delegations: number; evites: number; corrections: number; pilotage: number }
|
||||||
|
expect(cumul.delegations).toBe(1)
|
||||||
|
expect(cumul.evites).toBe(9900)
|
||||||
|
expect(cumul.corrections).toBe(0)
|
||||||
|
expect(cumul.pilotage).toBe(2 * COUT)
|
||||||
|
|
||||||
|
const a_envoyer = store.get('a_envoyer') as { type: string; agent_id?: string; demande_ids?: string[] }[]
|
||||||
|
expect(a_envoyer.length).toBe(3)
|
||||||
|
const pilotages = a_envoyer.filter(ev => ev.type === 'pilotage')
|
||||||
|
expect(pilotages.length).toBe(2)
|
||||||
|
pilotages.forEach(ev => {
|
||||||
|
expect(ev.agent_id).toBe('principal')
|
||||||
|
expect(ev.demande_ids).toEqual(['dem-o'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('un diff rejeté retire le gain',
|
||||||
|
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
const toasts: string[] = []
|
||||||
|
const store = new Map<string, unknown>()
|
||||||
|
on('store.get', (_, e) => ({ value: store.get(e.key) }))
|
||||||
|
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
|
||||||
|
on('ui.status', (_, e) => ({ value: undefined }))
|
||||||
|
on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined }))
|
||||||
|
on('session.id', () => ({ value: 's1' }))
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||||
|
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
||||||
|
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||||
|
on('clock.every', () => new Promise(() => {}))
|
||||||
|
on('turn.step', reponseModele)
|
||||||
|
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
|
||||||
|
on('tool.call', (_, e) =>
|
||||||
|
e.tool === OUTIL
|
||||||
|
? { result: {}, text: '{"ok":null,"demande_id":"dem-o","etat":"en_file"}' }
|
||||||
|
: e.tool === SUIVRE
|
||||||
|
? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o', tokens_cloud_entree: 9000, tokens_cloud_sortie: 1000, resume: 'r', diff: 'E:/x/d.diff' }) }
|
||||||
|
: e.tool === REJETER
|
||||||
|
? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o' }) }
|
||||||
|
: { result: {}, text: '{}' },
|
||||||
|
)
|
||||||
|
|
||||||
|
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||||
|
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), moteur: 'opencode', tool_use_id: 'u1' } as never)
|
||||||
|
await requete($)
|
||||||
|
await $.tool.call({ tool: SUIVRE, demande_id: 'dem-o', tool_use_id: 'u2' } as never)
|
||||||
|
await requete($)
|
||||||
|
await $.tool.call({ tool: REJETER, demande_id: 'dem-o', tool_use_id: 'u3' } as never)
|
||||||
|
await requete($, { outils: 0, usage: USAGE })
|
||||||
|
|
||||||
|
const cumul = store.get('cumul') as { delegations: number; evites: number; pilotage: number }
|
||||||
|
expect(cumul.delegations).toBe(0)
|
||||||
|
expect(cumul.evites).toBe(0)
|
||||||
|
expect(cumul.pilotage).toBe(3 * COUT) // le pilotage d'un diff rejeté reste dû
|
||||||
|
|
||||||
|
expect(toasts.at(-1)).toContain('rejeté')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('une délégation locale reste sans pilotage',
|
||||||
|
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
const toasts: string[] = []
|
||||||
|
const store = new Map<string, unknown>()
|
||||||
|
on('store.get', (_, e) => ({ value: store.get(e.key) }))
|
||||||
|
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
|
||||||
|
on('ui.status', (_, e) => ({ value: undefined }))
|
||||||
|
on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined }))
|
||||||
|
on('session.id', () => ({ value: 's1' }))
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||||
|
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
||||||
|
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||||
|
on('clock.every', () => new Promise(() => {}))
|
||||||
|
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
|
||||||
|
on('tool.call', (_, e) =>
|
||||||
|
e.tool === OUTIL
|
||||||
|
? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-l', tokens_entree: 200, tokens_sortie: 50 }) }
|
||||||
|
: { result: {}, text: '{}' },
|
||||||
|
)
|
||||||
|
|
||||||
|
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||||
|
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), tool_use_id: 'u1' } as never)
|
||||||
|
|
||||||
|
const cumul = store.get('cumul') as { pilotage: number }
|
||||||
|
expect(cumul.pilotage).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pilotage réel : prorata, relecture du diff, requête perdue, et rien pour une requête sans suivi',
|
||||||
|
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
const store = new Map<string, unknown>()
|
||||||
|
on('store.get', (_, e) => ({ value: store.get(e.key) }))
|
||||||
|
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
|
||||||
|
on('ui.status', () => ({ value: undefined }))
|
||||||
|
on('ui.toast', () => ({ value: undefined }))
|
||||||
|
on('session.id', () => ({ value: 's1' }))
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||||
|
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
||||||
|
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||||
|
on('clock.every', () => new Promise(() => {}))
|
||||||
|
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
|
||||||
|
on('turn.step', reponseModele)
|
||||||
|
on('tool.call', (_, e) =>
|
||||||
|
e.tool === OUTIL
|
||||||
|
? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o', tokens_cloud_entree: 900, tokens_cloud_sortie: 100, diff: 'E:/x/d.diff' }) }
|
||||||
|
: { result: {}, text: 'contenu' },
|
||||||
|
)
|
||||||
|
const pilotage = () => (store.get('cumul') as { pilotage: number }).pilotage
|
||||||
|
|
||||||
|
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||||
|
await requete($, { outils: 2, usage: USAGE }) // deux outils : deleguer et un autre
|
||||||
|
await $.tool.call({ tool: OUTIL, consigne: 'c', moteur: 'opencode', tool_use_id: 'u1' } as never)
|
||||||
|
await requete($, { outils: 1, usage: null }) // perdue : l'imputation attend la suivante
|
||||||
|
expect(pilotage()).toBe(0)
|
||||||
|
await requete($) // moitié de la requête : l'autre outil n'est pas du pilotage
|
||||||
|
expect(pilotage()).toBe(50 + 25 + 500)
|
||||||
|
|
||||||
|
await $.tool.call({ tool: 'Read', file_path: 'E:/x/d.diff', tool_use_id: 'u2' } as never)
|
||||||
|
await requete($)
|
||||||
|
expect(pilotage()).toBe(50 + 25 + 500 + COUT)
|
||||||
|
expect((store.get('cumul') as { corrections: number }).corrections).toBe(0)
|
||||||
|
|
||||||
|
await requete($) // sans appel de suivi : rien
|
||||||
|
expect(pilotage()).toBe(50 + 25 + 500 + COUT)
|
||||||
|
})
|
||||||
|
|
||||||
|
const BASH = 'Bash'
|
||||||
|
const REPRISE_CMD = 'curl -s -H @"C:/logs/mcp/attente.hdr" "http://127.0.0.1:8090/attendre?demande_id=dem-a"'
|
||||||
|
const NOTIF = '<task-notification><task-id>b9</task-id><output-file>C:/Temp/b9.output</output-file>' +
|
||||||
|
'<status>completed</status></task-notification>'
|
||||||
|
|
||||||
|
// Monte un faux moteur pour l'attente unique ; `finale` est la ligne que la commande reprise écrit à sa sortie.
|
||||||
|
function moteurAttente(on: any, finale: Record<string, unknown>) {
|
||||||
|
const vu = { toasts: [] as string[], contextes: [] as (readonly string[] | undefined)[], appels: [] as string[] }
|
||||||
|
const store = new Map<string, unknown>()
|
||||||
|
const lus: string[] = []
|
||||||
|
on('store.get', (_: unknown, e: { key: string }) => ({ value: store.get(e.key) }))
|
||||||
|
on('store.set', (_: unknown, e: { key: string; value: unknown }) => (store.set(e.key, e.value), { value: undefined }))
|
||||||
|
on('ui.status', () => ({ value: undefined }))
|
||||||
|
on('ui.toast', (_: unknown, e: { text: string }) => (vu.toasts.push(e.text), { value: undefined }))
|
||||||
|
on('session.id', () => ({ value: 's1' }))
|
||||||
|
on('clock.now', () => ({ value: 0 }))
|
||||||
|
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||||
|
on('session.start', (_: unknown, e: { cwd: string }) => ({ cwd: e.cwd }))
|
||||||
|
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||||
|
on('clock.every', () => new Promise(() => {}))
|
||||||
|
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
|
||||||
|
on('fs.stat', () => ({ value: { kind: 'file', size: 100 } }))
|
||||||
|
on('fs.read', (_: unknown, e: { path: string }) => { // chemin tel que le moteur le normalise
|
||||||
|
lus.push(e.path)
|
||||||
|
return { value: /b9\.output$/.test(e.path) ? `${JSON.stringify(finale)}\n` : '' }
|
||||||
|
})
|
||||||
|
on('prompt.submit', (_: unknown, e: { text: string; context?: readonly string[] }) =>
|
||||||
|
(vu.contextes.push(e.context), { text: e.text, context: e.context }))
|
||||||
|
on('tool.call', (_: unknown, e: { tool: string }) => {
|
||||||
|
vu.appels.push(e.tool)
|
||||||
|
return e.tool === OUTIL
|
||||||
|
? { result: {}, text: JSON.stringify({ ok: null, demande_id: 'dem-a', etat: 'en_cours', reprise: REPRISE_CMD }) }
|
||||||
|
: e.tool === BASH
|
||||||
|
? { result: { stdout: '', stderr: '', interrupted: false, backgroundTaskId: 'b9' }, text: 'lancée en arrière-plan' }
|
||||||
|
: { result: {}, text: 'contenu' }
|
||||||
|
})
|
||||||
|
return { vu, store, lus }
|
||||||
|
}
|
||||||
|
|
||||||
|
const reveil = ($: any) => $.prompt.submit({ text: NOTIF, wait: false, origin: { kind: 'task-notification' } })
|
||||||
|
|
||||||
|
test('attente unique : reprise en arrière-plan, ni suivre ni sleep, réveil lu par le plugin, résultat valide non relu',
|
||||||
|
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
const { vu, store, lus } = moteurAttente(on, {
|
||||||
|
ok: true, demande_id: 'dem-a', tokens_entree: 2100, tokens_sortie: 400, verdict: 'valide', essais: 2,
|
||||||
|
controles: [{ nom: 'lint', ok: true }], sortie: 'E:/p/x.py', fin: 'valide',
|
||||||
|
})
|
||||||
|
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||||
|
|
||||||
|
const delegue = await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400) } as never)
|
||||||
|
expect(delegue.context?.join('\n')).toContain('run_in_background: true')
|
||||||
|
expect(delegue.context?.join('\n')).toContain(REPRISE_CMD)
|
||||||
|
|
||||||
|
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD } as never)).deny).toContain('arrière-plan')
|
||||||
|
expect((await $.tool.call({ tool: 'PowerShell', command: REPRISE_CMD, run_in_background: true } as never)).deny)
|
||||||
|
.toContain('Bash')
|
||||||
|
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)).deny)
|
||||||
|
.toBeUndefined()
|
||||||
|
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toContain('réveillé')
|
||||||
|
expect((await $.tool.call({ tool: BASH, command: 'sleep 60' } as never)).deny).toContain('sleep')
|
||||||
|
expect(vu.appels.filter(t => t === SUIVRE)).toEqual([]) // refusé avant d'atteindre MIAM
|
||||||
|
|
||||||
|
await reveil($)
|
||||||
|
expect(lus.some(p => /b9\.output$/.test(p))).toBe(true) // la sortie nommée par la notification
|
||||||
|
const lu = vu.contextes.at(-1)?.join('\n') ?? ''
|
||||||
|
expect(lu).toContain('inutile de')
|
||||||
|
expect(lu).toContain('verdict valide')
|
||||||
|
expect(lu).toContain('E:/p/x.py')
|
||||||
|
expect(vu.toasts.at(-1)).toContain('tokens évités')
|
||||||
|
expect((store.get('cumul') as { delegations: number }).delegations).toBe(1)
|
||||||
|
|
||||||
|
expect((await $.tool.call({ tool: 'Read', file_path: 'E:/p/x.py' } as never)).deny).toContain('verdict valide')
|
||||||
|
expect((await $.tool.call({ tool: 'Read', file_path: 'E:/p/x.py', offset: 1, limit: 40 } as never)).deny)
|
||||||
|
.toBeUndefined()
|
||||||
|
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined() // fini
|
||||||
|
expect((await $.tool.call({ tool: BASH, command: 'sleep 1' } as never)).deny).toBeUndefined()
|
||||||
|
|
||||||
|
const bilan = await $.command.run({ command: 'miam-economies', args: '' } as never)
|
||||||
|
expect((bilan as { text: string }).text).toContain('1 attente(s) unique(s), 2 essai(s), 0 échec(s)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('attente unique : besoin_claude rouvre suivre, echec_controle compte sans gain, un sous-agent n’est pas freiné',
|
||||||
|
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||||
|
const finale: Record<string, unknown> = { ok: null, demande_id: 'dem-a', fin: 'besoin_claude' }
|
||||||
|
const { vu, store } = moteurAttente(on, finale)
|
||||||
|
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||||
|
await $.tool.call({ tool: OUTIL, consigne: 'c' } as never)
|
||||||
|
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
|
||||||
|
expect((await $.tool.call({ tool: BASH, command: 'sleep 5', agentId: 'a1' } as never)).deny).toBeUndefined()
|
||||||
|
|
||||||
|
await reveil($)
|
||||||
|
expect(vu.contextes.at(-1)?.join('\n')).toContain(REPRISE_CMD) // relancer après avoir répondu
|
||||||
|
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined()
|
||||||
|
|
||||||
|
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
|
||||||
|
Object.assign(finale, { ok: false, fin: 'echec_controle', erreur: 'echec_controle : symboles : champ x inventé' })
|
||||||
|
await reveil($)
|
||||||
|
expect(vu.contextes.at(-1)?.join('\n')).toContain('symboles : champ x inventé')
|
||||||
|
expect(store.get('cumul')).toBeUndefined() // aucun gain compté
|
||||||
|
const bilan = await $.command.run({ command: 'miam-economies', args: '' } as never)
|
||||||
|
expect((bilan as { text: string }).text).toContain('2 attente(s) unique(s), 0 essai(s), 1 échec(s)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('imposer=false : l’attente est suivie mais rien n’est refusé', { options: { jeton: 'x', imposer: false } },
|
||||||
|
async ($, on) => {
|
||||||
|
moteurAttente(on, {})
|
||||||
|
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||||
|
await $.tool.call({ tool: OUTIL, consigne: 'c' } as never)
|
||||||
|
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD } as never)).deny).toBeUndefined()
|
||||||
|
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
|
||||||
|
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined()
|
||||||
|
})
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import type { EngineInterface, Register } from 'claude-code'
|
|
||||||
|
|
||||||
import {
|
|
||||||
aGarder, ajouter, cheminNormal, cible, court, cout, estimer, evenementCorrection, evenementDelegation,
|
|
||||||
evenementPilotage, ligne, lireResultat, lot, mesurerCorrection, net, retirer, VIDE,
|
|
||||||
} from './economies'
|
|
||||||
import type { Evenement, Totaux } from './economies'
|
|
||||||
|
|
||||||
// Outils de MIAM : serveur du plugin (mcp__plugin_miam_miam__…) ou serveur de projet nommé miam (mcp__miam__…).
|
|
||||||
const OUTIL = /^mcp__(?:plugin_miam_)?miam__(deleguer|suivre)$/
|
|
||||||
// Outils par lesquels l'agent principal relit ou retouche un fichier écrit par une délégation.
|
|
||||||
const REPRISE = /^(Read|Edit|Write|NotebookEdit|Bash)$/
|
|
||||||
const CUMUL = 'cumul'
|
|
||||||
const ATTENTE = 'a_envoyer' // événements que MIAM n'a pas encore reçus (fermé, ancienne version sans la route…)
|
|
||||||
const PERIODE_MS = 5000 // envoi à MIAM hors des hooks : une délégation n'attend jamais MIAM
|
|
||||||
|
|
||||||
type Demande = { consigne: string; retour?: string; agentId?: string }
|
|
||||||
type Miam = { url: string; jeton: string }
|
|
||||||
|
|
||||||
// Ajoute au cumul (gardé d'une session à l'autre) et met à jour la ligne d'état ; renvoie les totaux de la session.
|
|
||||||
async function afficher($: EngineInterface, session: Totaux, ajout: Partial<Totaux>): Promise<Totaux> {
|
|
||||||
const suite = ajouter(session, ajout)
|
|
||||||
const cumul = ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, ajout)
|
|
||||||
await $.store.set(CUMUL, cumul)
|
|
||||||
$.ui.status(ligne(suite, cumul))
|
|
||||||
return suite
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mettreEnAttente($: EngineInterface, nouveaux: Evenement[]): Promise<void> {
|
|
||||||
await $.store.set(ATTENTE, lot(((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? [], nouveaux))
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /economies de MIAM avec tout ce qui attend ; ne lève jamais : sans MIAM, l'attente reste pour le prochain tour.
|
|
||||||
async function envoyer($: EngineInterface, miam: Miam): Promise<void> {
|
|
||||||
const partis = ((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? []
|
|
||||||
if (!partis.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await $.http.fetch(miam.url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${miam.jeton}` },
|
|
||||||
body: JSON.stringify({ evenements: partis }),
|
|
||||||
})
|
|
||||||
if (!aGarder(r.status)) {
|
|
||||||
await $.store.set(ATTENTE, retirer(((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? [], partis))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// MIAM fermé ou injoignable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const register: Register = (on, options) => {
|
|
||||||
const miam: Miam = { url: `http://127.0.0.1:${options.port ?? 8090}/economies`, jeton: String(options.jeton ?? '') }
|
|
||||||
let session: Totaux = VIDE
|
|
||||||
const enFile = new Map<string, Demande>() // demande_id → délégation renvoyée « en file », comptée à son résultat
|
|
||||||
const comptees = new Set<string>() // demande_id déjà comptées (suivre peut renvoyer deux fois un résultat)
|
|
||||||
const subagents = new Map<string, string[]>() // boucles qui ont délégué → leurs demande_id ; leur tour = pilotage
|
|
||||||
const sorties = new Map<string, string>() // fichier écrit par une délégation (chemin normalisé) → demande_id
|
|
||||||
let reprises = 0 // id de secours d'une correction sans tool_use_id
|
|
||||||
let envoiEnCours = false
|
|
||||||
|
|
||||||
on('session.start', async ($, e, next) => {
|
|
||||||
const cumul = ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, {})
|
|
||||||
if (cumul.delegations) {
|
|
||||||
$.ui.status(ligne(VIDE, cumul))
|
|
||||||
}
|
|
||||||
await $.command.register({
|
|
||||||
name: 'miam-economies',
|
|
||||||
description: 'Tokens évités grâce à MIAM : session et cumul',
|
|
||||||
argumentHint: '[raz]',
|
|
||||||
})
|
|
||||||
$.clock.every(PERIODE_MS, () => {
|
|
||||||
if (!envoiEnCours) {
|
|
||||||
envoiEnCours = true
|
|
||||||
void envoyer($, miam).finally(() => (envoiEnCours = false))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return next(e)
|
|
||||||
})
|
|
||||||
|
|
||||||
on('command.run', { command: 'miam-economies' }, async ($, e) => {
|
|
||||||
if (e.args.trim() === 'raz') {
|
|
||||||
await $.store.set(CUMUL, VIDE)
|
|
||||||
session = VIDE
|
|
||||||
$.ui.status(undefined)
|
|
||||||
return { text: 'Compteurs MIAM remis à zéro (les envois à MIAM ne sont pas touchés).' }
|
|
||||||
}
|
|
||||||
const cumul = ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, {})
|
|
||||||
const attente = ((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? []
|
|
||||||
return {
|
|
||||||
text: [
|
|
||||||
`Session : ${session.delegations} délégation(s), ~${court(session.evites)} tokens évités, pilotage ` +
|
|
||||||
`${court(session.pilotage)}, corrections ${court(session.corrections)}, net ~${court(net(session))}.`,
|
|
||||||
`Cumul : ${cumul.delegations} délégation(s), ~${court(cumul.evites)} tokens évités, pilotage ` +
|
|
||||||
`${court(cumul.pilotage)}, corrections ${court(cumul.corrections)}, net ~${court(net(cumul))}.`,
|
|
||||||
attente.length ? `${attente.length} événement(s) en attente d'envoi à MIAM.` : 'Tout est envoyé à MIAM.',
|
|
||||||
'Estimation : fichiers lus + réponse écrite par le modèle local, moins la consigne et ce que Claude relit ; ' +
|
|
||||||
'pilotage = consommation du subagent (lecture en cache comptée au dixième) ; corrections = ce que ' +
|
|
||||||
"l'agent principal relit ou retouche dans les fichiers écrits par MIAM.",
|
|
||||||
].join('\n'),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Un seul hook tool.call (le validateur n'en accepte qu'un sans filtre) : délégations, puis reprises.
|
|
||||||
on('tool.call', async ($, e, next) => {
|
|
||||||
const outil = OUTIL.exec(e.tool)?.[1]
|
|
||||||
if (!outil) {
|
|
||||||
// Corrections : l'agent principal relit ou retouche une sortie de MIAM ; ce coût se déduit du gain. Celles du
|
|
||||||
// subagent sont déjà dans son pilotage.
|
|
||||||
if (!sorties.size || e.agentId !== undefined || !REPRISE.test(e.tool)) {
|
|
||||||
return next(e)
|
|
||||||
}
|
|
||||||
const ran = await next(e)
|
|
||||||
const args = e as unknown as Record<string, unknown>
|
|
||||||
const demandeId = cible(e.tool, args, sorties)
|
|
||||||
if (!demandeId || ran.deny !== undefined) {
|
|
||||||
return ran
|
|
||||||
}
|
|
||||||
const correction = mesurerCorrection(e.tool, args, typeof ran.text === 'string' ? ran.text : undefined)
|
|
||||||
if (!correction.lus && !correction.ecrits) {
|
|
||||||
return ran
|
|
||||||
}
|
|
||||||
session = await afficher($, session, { corrections: correction.lus + correction.ecrits })
|
|
||||||
reprises += 1
|
|
||||||
await mettreEnAttente($, [evenementCorrection({
|
|
||||||
sessionId: await $.session.id(), demandeId, outil: e.tool, toolUseId: e.tool_use_id ?? `reprise-${reprises}`,
|
|
||||||
maintenant: await $.clock.now(),
|
|
||||||
}, correction)])
|
|
||||||
return ran
|
|
||||||
}
|
|
||||||
const ran = await next(e)
|
|
||||||
if (ran.deny !== undefined || ran.isError) {
|
|
||||||
return ran
|
|
||||||
}
|
|
||||||
const args = e as unknown as { consigne?: string; retour?: string; demande_id?: string }
|
|
||||||
let texte: unknown
|
|
||||||
try {
|
|
||||||
texte = typeof ran.text === 'string' ? JSON.parse(ran.text) : ran.result
|
|
||||||
} catch {
|
|
||||||
return ran
|
|
||||||
}
|
|
||||||
const brut = texte as { ok?: unknown; demande_id?: string }
|
|
||||||
const demande: Demande | undefined =
|
|
||||||
outil === 'deleguer'
|
|
||||||
? { consigne: args.consigne ?? '', retour: args.retour, agentId: e.agentId }
|
|
||||||
: enFile.get(args.demande_id ?? '')
|
|
||||||
if (!demande) {
|
|
||||||
return ran
|
|
||||||
}
|
|
||||||
if (brut?.ok == null && brut?.demande_id) {
|
|
||||||
enFile.set(brut.demande_id, demande) // en file : compté quand suivre rendra le résultat
|
|
||||||
return ran
|
|
||||||
}
|
|
||||||
const r = lireResultat(texte)
|
|
||||||
if (!r?.demande_id || comptees.has(r.demande_id)) {
|
|
||||||
return ran
|
|
||||||
}
|
|
||||||
comptees.add(r.demande_id)
|
|
||||||
enFile.delete(r.demande_id)
|
|
||||||
if (r.sortie) {
|
|
||||||
sorties.set(cheminNormal(r.sortie), r.demande_id)
|
|
||||||
}
|
|
||||||
if (demande.agentId) {
|
|
||||||
subagents.set(demande.agentId, [...(subagents.get(demande.agentId) ?? []), r.demande_id])
|
|
||||||
}
|
|
||||||
const est = estimer(demande.consigne, demande.retour, r, demande.agentId !== undefined)
|
|
||||||
session = await afficher($, session, { delegations: 1, evites: est.evites })
|
|
||||||
$.ui.toast(`MIAM : ~${court(est.evites)} tokens évités (${court(est.lus)} lus, ${court(est.ecrits)} écrits)`)
|
|
||||||
const contexte = { sessionId: await $.session.id(), agentId: demande.agentId, retour: demande.retour }
|
|
||||||
await mettreEnAttente($, [evenementDelegation({ ...contexte, maintenant: await $.clock.now() },
|
|
||||||
{ ...r, demande_id: r.demande_id }, est)])
|
|
||||||
return ran
|
|
||||||
})
|
|
||||||
|
|
||||||
on('turn.complete', async ($, e, next) => {
|
|
||||||
const demandeIds = e.agentId ? subagents.get(e.agentId) : undefined
|
|
||||||
if (e.agentId && e.usage && demandeIds) {
|
|
||||||
subagents.delete(e.agentId)
|
|
||||||
session = await afficher($, session, { pilotage: cout(e.usage) })
|
|
||||||
await mettreEnAttente($, [evenementPilotage({
|
|
||||||
sessionId: await $.session.id(), agentId: e.agentId, turnId: e.turnId, maintenant: await $.clock.now(),
|
|
||||||
demandeIds,
|
|
||||||
}, e.usage)])
|
|
||||||
}
|
|
||||||
return next(e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
|
||||||
|
|
||||||
|
import {
|
||||||
|
aGarder, ajouter, cheminNormal, cible, court, cout, estimer, evenementCorrection, evenementDelegation,
|
||||||
|
evenementPilotage, ligne, lireResultat, lot, mesurerCorrection, net, pastille, repartir, retirer, VIDE,
|
||||||
|
} from './economies'
|
||||||
|
import type { Evenement, Suivi, Totaux } from './economies'
|
||||||
|
import {
|
||||||
|
conduite, consigneReprise, demandeAttendue, estSommeil, fichierSortie, finDe, lectureEntiere, lireFin,
|
||||||
|
REFUS_LECTURE_VALIDE, REFUS_POWERSHELL, REFUS_PREMIER_PLAN, REFUS_SOMMEIL, REFUS_SUIVI, sensible, SENSIBLES,
|
||||||
|
} from './attente'
|
||||||
|
import type { Fin } from './attente'
|
||||||
|
import { lectureTropLongue, nomSortie, PLAGE_MAX, sortieTropLongue } from './contrainte'
|
||||||
|
import { fenetres, ligneFenetre } from './usage'
|
||||||
|
|
||||||
|
// Outils de MIAM : serveur du plugin (mcp__plugin_miam_miam__…) ou serveur de projet nommé miam (mcp__miam__…).
|
||||||
|
const OUTIL = /^mcp__(?:plugin_miam_)?miam__(deleguer|suivre|autoriser|appliquer|rejeter|annuler)$/
|
||||||
|
const PRINCIPAL = 'principal' // agent_id du pilotage d'OpenCode par l'agent principal
|
||||||
|
// Outils par lesquels l'agent principal relit ou retouche un fichier écrit par une délégation.
|
||||||
|
const REPRISE = /^(Read|Edit|Write|NotebookEdit|Bash|PowerShell)$/
|
||||||
|
const SHELL = /^(Bash|PowerShell)$/ // sorties retenues quand elles sont longues
|
||||||
|
const CUMUL = 'cumul'
|
||||||
|
const ATTENTE = 'a_envoyer' // événements que MIAM n'a pas encore reçus (fermé, ancienne version sans la route…)
|
||||||
|
const PERIODE_MS = 5000 // envoi à MIAM hors des hooks : une délégation n'attend jamais MIAM
|
||||||
|
const LECTURE = /^(Read|Bash|PowerShell)$/ // lectures de l'agent principal qu'un rappel peut viser
|
||||||
|
const SEUIL_LECTURE = 20000 // caractères, soit environ 5k tokens
|
||||||
|
const DOSSIER_SORTIES = '.claude/miam-sorties'
|
||||||
|
const PAUSE_RAPPEL_MS = 600000 // au plus un rappel toutes les 10 minutes
|
||||||
|
const BARRE = 5 // largeur des barres d'usage de la ligne d'état
|
||||||
|
|
||||||
|
// Lu par le modèle à chaque requête de l'agent principal : court, et seulement les cas où déléguer est le bon réflexe.
|
||||||
|
const CONSIGNE = {
|
||||||
|
id: 'miam:delegation',
|
||||||
|
scope: 'session',
|
||||||
|
text: `# Délégation à MIAM
|
||||||
|
Un modèle local (MIAM) fait sans consommer l'usage Claude les tâches autonomes et vérifiables. Par réflexe, avant de les faire toi-même, appelle toi-même l'outil MCP \`deleguer\` de MIAM (sans subagent, qui coûte plus cher), sans demander : tirer peu d'un long log, d'une longue sortie de tests ou d'un long document ; traduction, résumé, notes de version, docstrings, message de commit ; fonction pure et ses tests, tests répétitifs. Ne lis pas le fichier avant : MIAM le lit. Fais-le toi-même seulement pour l'architecture, le débogage multi-fichiers, la concurrence, la sécurité, ce qui demande le contexte de la conversation, les secrets, ou si MIAM est injoignable.`,
|
||||||
|
} as const
|
||||||
|
const RAPPEL = "Cette sortie est longue. Si tu n'en as besoin que d'une petite partie, la prochaine fois confie-la à " +
|
||||||
|
"l'outil MCP `deleguer` de MIAM (il lit les fichiers sans consommer l'usage Claude) au lieu de la lire toi-même."
|
||||||
|
|
||||||
|
const REFUS_LECTURE = `Lecture refusée par le plugin MIAM : ce fichier est long. Appelle l'outil MCP \`deleguer\` de MIAM avec ce chemin et ce que tu cherches, ou lis seulement la partie utile (\`offset\` et \`limit\` de ${PLAGE_MAX} lignes au plus). Ne découpe pas le fichier pour le lire en entier.`
|
||||||
|
const refusSortie = (chemin: string) => `Sortie longue retenue par le plugin MIAM, enregistrée dans ${chemin}. La commande a bien tourné : ne la relance pas pour lire sa sortie. Appelle l'outil MCP \`deleguer\` de MIAM avec ce fichier et ce que tu cherches, ou lis seulement la partie utile avec \`offset\` et \`limit\`.`
|
||||||
|
|
||||||
|
type Demande = { consigne: string; retour?: string; agentId?: string }
|
||||||
|
type Miam = { url: string; jeton: string }
|
||||||
|
type Ecran = { session: Totaux; limites: readonly SessionRateLimit[]; affichee?: string } // `affichee` : dernier texte
|
||||||
|
|
||||||
|
// Une seule ligne d'état : le gain, puis l'usage Claude.
|
||||||
|
async function rafraichir($: EngineInterface, ecran: Ecran): Promise<void> {
|
||||||
|
const cumul = ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, {})
|
||||||
|
const usage = fenetres(ecran.limites, await $.clock.now()).map(f => ligneFenetre(f, BARRE))
|
||||||
|
const texte = [ligne(ecran.session, cumul), ...usage].filter(Boolean).join(' · ') || undefined
|
||||||
|
if (texte !== ecran.affichee) {
|
||||||
|
ecran.affichee = texte
|
||||||
|
$.ui.status(texte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajoute à la session et au cumul (gardé d'une session à l'autre), puis met à jour la ligne d'état.
|
||||||
|
async function afficher($: EngineInterface, ecran: Ecran, ajout: Partial<Totaux>): Promise<void> {
|
||||||
|
ecran.session = ajouter(ecran.session, ajout)
|
||||||
|
await $.store.set(CUMUL, ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, ajout))
|
||||||
|
await rafraichir($, ecran)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrai si MIAM répond, quel que soit le statut : sans lui, rien n'est bloqué.
|
||||||
|
async function joignable($: EngineInterface, miam: Miam): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await $.http.fetch(miam.url, { method: 'GET' })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enregistre une sortie pour que MIAM la lise ; renvoie son chemin, ou rien si l'écriture échoue.
|
||||||
|
async function garderSortie($: EngineInterface, dossier: string, nom: string, texte: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
await $.fs.write(`${dossier}/.gitignore`, '*\n')
|
||||||
|
await $.fs.write(`${dossier}/${nom}`, texte)
|
||||||
|
return `${dossier}/${nom}`
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mettreEnAttente($: EngineInterface, nouveaux: Evenement[]): Promise<void> {
|
||||||
|
await $.store.set(ATTENTE, lot(((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? [], nouveaux))
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /economies de MIAM avec tout ce qui attend ; ne lève jamais : sans MIAM, l'attente reste pour le prochain tour.
|
||||||
|
async function envoyer($: EngineInterface, miam: Miam): Promise<void> {
|
||||||
|
const partis = ((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? []
|
||||||
|
if (!partis.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await $.http.fetch(miam.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${miam.jeton}` },
|
||||||
|
body: JSON.stringify({ evenements: partis }),
|
||||||
|
})
|
||||||
|
if (!aGarder(r.status)) {
|
||||||
|
await $.store.set(ATTENTE, retirer(((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? [], partis))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// MIAM fermé ou injoignable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// État des délégations de la session, partagé par les hooks et les fonctions ci-dessous.
|
||||||
|
type Delegations = {
|
||||||
|
ecran: Ecran
|
||||||
|
enFile: Map<string, Demande>
|
||||||
|
comptees: Set<string>
|
||||||
|
subagents: Map<string, string[]>
|
||||||
|
sorties: Map<string, string>
|
||||||
|
opencode: Map<string, { pilotage: number; evites: number }>
|
||||||
|
attentes: Map<string, 'reprise' | 'attendue' | 'besoin_claude'>
|
||||||
|
commandesReprise: Map<string, string>
|
||||||
|
finies: Set<string>
|
||||||
|
valides: Set<string>
|
||||||
|
mesure: { attentes: number; essais: number; echecs: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
// État final d'une délégation (réponse directe, ou sortie de /attendre au réveil) : gain, compteurs, et la conduite
|
||||||
|
// à tenir, que Claude lit à la place de la réponse. Rien quand MIAM n'a rien contrôlé et que rien n'a été attendu.
|
||||||
|
async function terminer($: EngineInterface, d: Delegations, r: Record<string, unknown>, demande: Demande | undefined):
|
||||||
|
Promise<string | undefined> {
|
||||||
|
const id = typeof r.demande_id === 'string' ? r.demande_id : undefined
|
||||||
|
const fin = (typeof r.fin === 'string' ? r.fin : finDe(r)) as Fin | undefined
|
||||||
|
if (id && fin === 'besoin_claude') {
|
||||||
|
d.attentes.set(id, 'besoin_claude')
|
||||||
|
} else if (id && fin) {
|
||||||
|
d.attentes.delete(id)
|
||||||
|
if (!d.finies.has(id)) {
|
||||||
|
d.finies.add(id)
|
||||||
|
d.mesure.echecs += fin === 'echec_controle' ? 1 : 0
|
||||||
|
d.mesure.essais += typeof r.essais === 'number' ? r.essais : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fin === 'valide' && typeof r.sortie === 'string') {
|
||||||
|
d.valides.add(cheminNormal(r.sortie))
|
||||||
|
}
|
||||||
|
if (demande) {
|
||||||
|
await compter($, d, r, demande)
|
||||||
|
}
|
||||||
|
return fin ? conduite({ ...r, fin }, id ? d.commandesReprise.get(id) : undefined) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gain d'une délégation finie, compté une fois (suivre peut renvoyer deux fois un résultat).
|
||||||
|
async function compter($: EngineInterface, d: Delegations, texte: unknown, demande: Demande): Promise<void> {
|
||||||
|
const r = lireResultat(texte)
|
||||||
|
if (!r?.demande_id || d.comptees.has(r.demande_id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.comptees.add(r.demande_id)
|
||||||
|
d.enFile.delete(r.demande_id)
|
||||||
|
const fichier = r.sortie ?? r.diff
|
||||||
|
if (fichier) {
|
||||||
|
d.sorties.set(cheminNormal(fichier), r.demande_id)
|
||||||
|
}
|
||||||
|
if (demande.agentId) {
|
||||||
|
d.subagents.set(demande.agentId, [...(d.subagents.get(demande.agentId) ?? []), r.demande_id])
|
||||||
|
}
|
||||||
|
const est = estimer(demande.consigne, demande.retour, r, demande.agentId !== undefined)
|
||||||
|
await afficher($, d.ecran, { delegations: 1, evites: est.evites })
|
||||||
|
const pilote = d.opencode.get(r.demande_id)
|
||||||
|
if (pilote) {
|
||||||
|
pilote.evites = est.evites
|
||||||
|
$.ui.toast(`${pastille(net(d.ecran.session))}MIAM : ~${court(est.evites)} tokens évités par OpenCode ` +
|
||||||
|
`(${court(est.lus)} lus, ${court(est.ecrits)} écrits, pilotage ${court(pilote.pilotage)})`)
|
||||||
|
} else {
|
||||||
|
$.ui.toast(`${pastille(net(d.ecran.session))}MIAM : ~${court(est.evites)} tokens évités (${court(est.lus)} lus, ` +
|
||||||
|
`${court(est.ecrits)} écrits)`)
|
||||||
|
}
|
||||||
|
const contexte = { sessionId: await $.session.id(), agentId: demande.agentId, retour: demande.retour }
|
||||||
|
await mettreEnAttente($, [evenementDelegation({ ...contexte, maintenant: await $.clock.now() },
|
||||||
|
{ ...r, demande_id: r.demande_id }, est)])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const register: Register = (on, options) => {
|
||||||
|
const miam: Miam = { url: `http://127.0.0.1:${options.port ?? 8090}/economies`, jeton: String(options.jeton ?? '') }
|
||||||
|
const ecran: Ecran = { session: VIDE, limites: [] }
|
||||||
|
const enFile = new Map<string, Demande>() // demande_id → délégation renvoyée « en file », comptée à son résultat
|
||||||
|
const comptees = new Set<string>() // demande_id déjà comptées (suivre peut renvoyer deux fois un résultat)
|
||||||
|
const subagents = new Map<string, string[]>() // boucles qui ont délégué → leurs demande_id ; leur tour = pilotage
|
||||||
|
const sorties = new Map<string, string>() // fichier écrit par une délégation (chemin normalisé) → demande_id
|
||||||
|
const opencode = new Map<string, { pilotage: number; evites: number }>() // délégations à OpenCode de cette session
|
||||||
|
let suivis: Suivi[] = [] // appels de suivi d'OpenCode à imputer à la prochaine requête
|
||||||
|
let appels = 0 // outils lancés par la dernière requête de l'agent principal
|
||||||
|
// Attente unique : demande → « reprise » reçue, « attendue » (curl en arrière-plan) ou « besoin_claude ».
|
||||||
|
const attentes = new Map<string, 'reprise' | 'attendue' | 'besoin_claude'>()
|
||||||
|
const commandesReprise = new Map<string, string>() // demande_id → sa commande `reprise`
|
||||||
|
const taches = new Map<string, string>() // id de la commande d'arrière-plan → demande_id attendue
|
||||||
|
const finies = new Set<string>() // demande_id dont l'état final est déjà pris en compte
|
||||||
|
const valides = new Set<string>() // fichiers `sortie` au verdict valide (chemin normalisé)
|
||||||
|
const mesure = { attentes: 0, essais: 0, echecs: 0 } // pour la mesure de la v0.9.0, par session
|
||||||
|
const d: Delegations = {
|
||||||
|
ecran, enFile, comptees, subagents, sorties, opencode, attentes, commandesReprise, finies, valides, mesure,
|
||||||
|
}
|
||||||
|
let reprises = 0 // id de secours d'une correction sans tool_use_id
|
||||||
|
let envoiEnCours = false
|
||||||
|
let dernierRappel = -Infinity // date du dernier rappel envoyé au modèle
|
||||||
|
const incitation = options.incitation !== false
|
||||||
|
const imposer = options.imposer !== false
|
||||||
|
const motifsSensibles = typeof options.chemins_sensibles === 'string'
|
||||||
|
? options.chemins_sensibles.split(/[,;]/).map(m => m.trim()).filter(Boolean)
|
||||||
|
: SENSIBLES
|
||||||
|
let dossierSorties = DOSSIER_SORTIES
|
||||||
|
|
||||||
|
on('session.start', async ($, e, next) => {
|
||||||
|
dossierSorties = `${e.cwd.replace(/[\\/]+$/, '')}/${DOSSIER_SORTIES}`
|
||||||
|
ecran.limites = (await $.session.usage()).rateLimits
|
||||||
|
await rafraichir($, ecran)
|
||||||
|
await $.command.register({
|
||||||
|
name: 'miam-economies',
|
||||||
|
description: 'Tokens évités grâce à MIAM : session et cumul',
|
||||||
|
argumentHint: '[raz]',
|
||||||
|
})
|
||||||
|
$.clock.every(PERIODE_MS, () => {
|
||||||
|
void rafraichir($, ecran) // le temps restant avance même sans échange
|
||||||
|
if (!envoiEnCours) {
|
||||||
|
envoiEnCours = true
|
||||||
|
void envoyer($, miam).finally(() => (envoiEnCours = false))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return next(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
on('command.run', { command: 'miam-economies' }, async ($, e) => {
|
||||||
|
if (e.args.trim() === 'raz') {
|
||||||
|
await $.store.set(CUMUL, VIDE)
|
||||||
|
ecran.session = VIDE
|
||||||
|
await rafraichir($, ecran)
|
||||||
|
return { text: 'Compteurs MIAM remis à zéro (les envois à MIAM ne sont pas touchés).' }
|
||||||
|
}
|
||||||
|
const cumul = ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, {})
|
||||||
|
const attente = ((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? []
|
||||||
|
return {
|
||||||
|
text: [
|
||||||
|
`Session : ${ecran.session.delegations} délégation(s), ~${court(ecran.session.evites)} tokens évités, pilotage ` +
|
||||||
|
`${court(ecran.session.pilotage)}, corrections ${court(ecran.session.corrections)}, net ~${court(net(ecran.session))}.`,
|
||||||
|
`Cumul : ${cumul.delegations} délégation(s), ~${court(cumul.evites)} tokens évités, pilotage ` +
|
||||||
|
`${court(cumul.pilotage)}, corrections ${court(cumul.corrections)}, net ~${court(net(cumul))}.`,
|
||||||
|
attente.length ? `${attente.length} événement(s) en attente d'envoi à MIAM.` : 'Tout est envoyé à MIAM.',
|
||||||
|
`Contrôles de la session : ${mesure.attentes} attente(s) unique(s), ${mesure.essais} essai(s), ` +
|
||||||
|
`${mesure.echecs} échec(s) de contrôle.`,
|
||||||
|
'Estimation : fichiers lus + réponse écrite par le modèle local, moins la consigne et ce que Claude relit ; ' +
|
||||||
|
'OpenCode : tokens du cloud moins la consigne. Pilotage = consommation du subagent (lecture en cache comptée ' +
|
||||||
|
'au dixième) et, pour OpenCode, usage réel des requêtes de suivi, contexte relu compris ; corrections = ce que ' +
|
||||||
|
"l'agent principal relit ou retouche dans les fichiers écrits par MIAM.",
|
||||||
|
].join('\n'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Un seul hook tool.call (le validateur n'en accepte qu'un sans filtre) : délégations, puis reprises.
|
||||||
|
on('tool.call', async ($, e, next) => {
|
||||||
|
const outil = OUTIL.exec(e.tool)?.[1]
|
||||||
|
if (!outil) {
|
||||||
|
// Attente unique : la commande `reprise` part une fois en arrière-plan, et rien ne guette la fin à sa place.
|
||||||
|
if (e.agentId === undefined && SHELL.test(e.tool)) {
|
||||||
|
const shell = e as unknown as { command?: unknown; run_in_background?: unknown }
|
||||||
|
const commande = String(shell.command ?? '')
|
||||||
|
const attendue = demandeAttendue(commande)
|
||||||
|
if (attendue) {
|
||||||
|
if (imposer && e.tool !== 'Bash') {
|
||||||
|
return { deny: REFUS_POWERSHELL }
|
||||||
|
}
|
||||||
|
if (imposer && shell.run_in_background !== true) {
|
||||||
|
return { deny: REFUS_PREMIER_PLAN }
|
||||||
|
}
|
||||||
|
const ran = await next(e)
|
||||||
|
if (ran.deny === undefined && !ran.isError) {
|
||||||
|
attentes.set(attendue, 'attendue')
|
||||||
|
mesure.attentes += 1
|
||||||
|
const tache = (ran.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId
|
||||||
|
if (typeof tache === 'string') {
|
||||||
|
taches.set(tache, attendue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ran
|
||||||
|
}
|
||||||
|
if (imposer && estSommeil(commande) && [...attentes.values()].includes('attendue')) {
|
||||||
|
return { deny: REFUS_SOMMEIL }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Corrections : l'agent principal relit ou retouche une sortie de MIAM ; ce coût se déduit du gain. Celles du
|
||||||
|
// subagent sont déjà dans son pilotage.
|
||||||
|
if (e.agentId !== undefined || !REPRISE.test(e.tool)) {
|
||||||
|
return next(e)
|
||||||
|
}
|
||||||
|
const args = e as unknown as Record<string, unknown>
|
||||||
|
const demandeId = sorties.size ? cible(e.tool, args, sorties) : undefined
|
||||||
|
// Un résultat au verdict valide ne se relit pas en entier, sauf sur un chemin sensible.
|
||||||
|
const chemin = String(args.file_path ?? '')
|
||||||
|
if (imposer && e.tool === 'Read' && valides.has(cheminNormal(chemin)) && lectureEntiere(args) &&
|
||||||
|
!sensible(chemin, motifsSensibles)) {
|
||||||
|
return { deny: REFUS_LECTURE_VALIDE }
|
||||||
|
}
|
||||||
|
// Un long fichier ne se lit pas en entier, sauf s'il vient de MIAM : le refus part avant la lecture.
|
||||||
|
if (imposer && e.tool === 'Read' && !demandeId) {
|
||||||
|
const stat = await $.fs.stat(String(args.file_path), { resolve: false }).catch(() => undefined)
|
||||||
|
if (stat?.kind === 'file' && lectureTropLongue(args, stat.size) && await joignable($, miam)) {
|
||||||
|
return { deny: REFUS_LECTURE }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ran = await next(e)
|
||||||
|
if (ran.deny !== undefined) {
|
||||||
|
return ran
|
||||||
|
}
|
||||||
|
if (!demandeId) {
|
||||||
|
// Une longue sortie est enregistrée puis retenue : le modèle ne la lit pas. Une commande en échec garde la sienne.
|
||||||
|
if (imposer && SHELL.test(e.tool) && !ran.isError && sortieTropLongue(ran.text) && await joignable($, miam)) {
|
||||||
|
const chemin = await garderSortie($, dossierSorties, nomSortie(e.tool_use_id ?? `reprise-${await $.clock.now()}`),
|
||||||
|
ran.text ?? '')
|
||||||
|
if (chemin) {
|
||||||
|
return { deny: refusSortie(chemin) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Une grosse lecture qu'une délégation aurait pu éviter : rappel au modèle, invisible pour l'utilisateur.
|
||||||
|
const maintenant = await $.clock.now()
|
||||||
|
if (!incitation || !LECTURE.test(e.tool) || ran.isError || (ran.text?.length ?? 0) < SEUIL_LECTURE ||
|
||||||
|
maintenant - dernierRappel < PAUSE_RAPPEL_MS) {
|
||||||
|
return ran
|
||||||
|
}
|
||||||
|
dernierRappel = maintenant
|
||||||
|
return { ...ran, context: [...(ran.context ?? []), RAPPEL] }
|
||||||
|
}
|
||||||
|
if (opencode.has(demandeId)) {
|
||||||
|
suivis.push({ demandeId, toolUseId: e.tool_use_id ?? `reprise-${++reprises}` }) // relecture du diff : pilotage
|
||||||
|
return ran
|
||||||
|
}
|
||||||
|
const correction = mesurerCorrection(e.tool, args, typeof ran.text === 'string' ? ran.text : undefined)
|
||||||
|
if (!correction.lus && !correction.ecrits) {
|
||||||
|
return ran
|
||||||
|
}
|
||||||
|
await afficher($, ecran, { corrections: correction.lus + correction.ecrits })
|
||||||
|
reprises += 1
|
||||||
|
await mettreEnAttente($, [evenementCorrection({
|
||||||
|
sessionId: await $.session.id(), demandeId, outil: e.tool, toolUseId: e.tool_use_id ?? `reprise-${reprises}`,
|
||||||
|
maintenant: await $.clock.now(),
|
||||||
|
}, correction)])
|
||||||
|
return ran
|
||||||
|
}
|
||||||
|
// Délégation attendue en arrière-plan : le réveil viendra seul. Une décision demandée (besoin_claude) reste ouverte.
|
||||||
|
const demandeSuivie = (e as unknown as { demande_id?: string }).demande_id ?? ''
|
||||||
|
if (imposer && outil === 'suivre' && e.agentId === undefined && attentes.get(demandeSuivie) === 'attendue') {
|
||||||
|
return { deny: REFUS_SUIVI }
|
||||||
|
}
|
||||||
|
const ran = await next(e)
|
||||||
|
if (ran.deny !== undefined || ran.isError) {
|
||||||
|
return ran
|
||||||
|
}
|
||||||
|
const args = e as unknown as { consigne?: string; retour?: string; demande_id?: string; moteur?: string }
|
||||||
|
let texte: unknown
|
||||||
|
try {
|
||||||
|
texte = typeof ran.text === 'string' ? JSON.parse(ran.text) : ran.result
|
||||||
|
} catch {
|
||||||
|
return ran
|
||||||
|
}
|
||||||
|
const brut = texte as { ok?: unknown; demande_id?: string }
|
||||||
|
const id = args.demande_id ?? brut?.demande_id
|
||||||
|
if (outil === 'deleguer' && args.moteur === 'opencode' && id) {
|
||||||
|
opencode.set(id, { pilotage: 0, evites: 0 })
|
||||||
|
}
|
||||||
|
const suivi = id ? opencode.get(id) : undefined
|
||||||
|
// Pilotage d'OpenCode par l'agent principal, compté à la requête qui lira ce résultat (turn.step) ; celui d'un
|
||||||
|
// subagent se mesure à la fin de son tour.
|
||||||
|
if (id && suivi && e.agentId === undefined) {
|
||||||
|
suivis.push({ demandeId: id, toolUseId: e.tool_use_id ?? `suivi-${++reprises}` })
|
||||||
|
}
|
||||||
|
// Un diff rejeté n'évite rien (MIAM fait de même dans son bilan).
|
||||||
|
if (outil === 'rejeter' && brut?.ok === true && suivi?.evites) {
|
||||||
|
await afficher($, ecran, { delegations: -1, evites: -suivi.evites })
|
||||||
|
$.ui.toast(`MIAM : diff OpenCode rejeté, ~${court(suivi.evites)} tokens retirés du gain`)
|
||||||
|
suivi.evites = 0
|
||||||
|
}
|
||||||
|
if (outil !== 'deleguer' && outil !== 'suivre') {
|
||||||
|
return ran
|
||||||
|
}
|
||||||
|
const demande: Demande | undefined =
|
||||||
|
outil === 'deleguer'
|
||||||
|
? { consigne: args.consigne ?? '', retour: args.retour, agentId: e.agentId }
|
||||||
|
: enFile.get(args.demande_id ?? '')
|
||||||
|
if (!demande) {
|
||||||
|
return ran
|
||||||
|
}
|
||||||
|
if (brut?.ok == null && brut?.demande_id) {
|
||||||
|
enFile.set(brut.demande_id, demande) // en file : compté au résultat (suivre, ou réveil de l'attente)
|
||||||
|
const reprise = (brut as { reprise?: unknown }).reprise
|
||||||
|
if (typeof reprise === 'string' && e.agentId === undefined) {
|
||||||
|
attentes.set(brut.demande_id, 'reprise')
|
||||||
|
commandesReprise.set(brut.demande_id, reprise)
|
||||||
|
return { ...ran, context: [...(ran.context ?? []), consigneReprise(reprise)] }
|
||||||
|
}
|
||||||
|
return ran
|
||||||
|
}
|
||||||
|
const conseil = await terminer($, d, texte as Record<string, unknown>, demande)
|
||||||
|
return conseil && e.agentId === undefined ? { ...ran, context: [...(ran.context ?? []), conseil] } : ran
|
||||||
|
})
|
||||||
|
|
||||||
|
// Réveil : la commande `reprise` est finie. Le plugin lit sa sortie (une ligne JSON avec `fin`) et donne à Claude le
|
||||||
|
// résultat et la conduite, pour qu'il n'ait ni à ouvrir cette sortie ni à appeler `suivre`.
|
||||||
|
on('prompt.submit', async ($, e, next) => {
|
||||||
|
// Reconnue à l'id de la commande d'arrière-plan relevé au lancement, ou à son enveloppe qui cite la route.
|
||||||
|
if (!attentes.size) {
|
||||||
|
return next(e)
|
||||||
|
}
|
||||||
|
const tache = [...taches].find(([t]) => e.text.includes(t))
|
||||||
|
const id = tache?.[1] ?? (e.text.includes('<task-notification') ? demandeAttendue(e.text) : undefined)
|
||||||
|
if (!id || attentes.get(id) !== 'attendue') {
|
||||||
|
return next(e)
|
||||||
|
}
|
||||||
|
if (tache) {
|
||||||
|
taches.delete(tache[0])
|
||||||
|
}
|
||||||
|
const chemin = fichierSortie(e.text)
|
||||||
|
const final = chemin ? lireFin(await $.fs.read(chemin).catch(() => '')) : undefined
|
||||||
|
if (!final) {
|
||||||
|
attentes.delete(id) // sortie illisible : Claude la lit lui-même, et `suivre` redevient possible
|
||||||
|
return next(e)
|
||||||
|
}
|
||||||
|
const conseil = await terminer($, d, { demande_id: id, ...final }, enFile.get(id))
|
||||||
|
const lu = `Sortie de l'attente lue par le plugin MIAM, inutile de l'ouvrir. ${conseil}`
|
||||||
|
return next({ ...e, context: [...(e.context ?? []), lu] })
|
||||||
|
})
|
||||||
|
|
||||||
|
on('turn.step', async function* ($, e, next) {
|
||||||
|
if (e.agentId !== undefined) {
|
||||||
|
return yield* next(e)
|
||||||
|
}
|
||||||
|
const aImputer = suivis
|
||||||
|
suivis = []
|
||||||
|
const r = yield* next(e)
|
||||||
|
if (!r.usage) {
|
||||||
|
suivis = [...aImputer, ...suivis] // requête perdue : la suivante relira ces résultats
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
const nombre = appels
|
||||||
|
appels = r.toolUses.length
|
||||||
|
for (const part of repartir(aImputer, nombre, r.usage)) {
|
||||||
|
const ev = evenementPilotage({
|
||||||
|
sessionId: await $.session.id(), agentId: PRINCIPAL, turnId: part.toolUseId, maintenant: await $.clock.now(),
|
||||||
|
demandeIds: [part.demandeId],
|
||||||
|
}, { ...part.usage, model: r.usage.model })
|
||||||
|
const pilote = opencode.get(part.demandeId)
|
||||||
|
if (pilote) {
|
||||||
|
pilote.pilotage += ev.cout
|
||||||
|
}
|
||||||
|
await afficher($, ecran, { pilotage: ev.cout })
|
||||||
|
await mettreEnAttente($, [ev])
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
|
||||||
|
on('turn.complete', async ($, e, next) => {
|
||||||
|
const demandeIds = e.agentId ? subagents.get(e.agentId) : undefined
|
||||||
|
if (e.agentId && e.usage && demandeIds) {
|
||||||
|
subagents.delete(e.agentId)
|
||||||
|
await afficher($, ecran, { pilotage: cout(e.usage) })
|
||||||
|
$.ui.toast(`${pastille(net(ecran.session))}MIAM : net ~${court(net(ecran.session))} sur la session ` +
|
||||||
|
`(pilotage ${court(ecran.session.pilotage)})`)
|
||||||
|
await mettreEnAttente($, [evenementPilotage({
|
||||||
|
sessionId: await $.session.id(), agentId: e.agentId, turnId: e.turnId, maintenant: await $.clock.now(),
|
||||||
|
demandeIds,
|
||||||
|
}, e.usage)])
|
||||||
|
}
|
||||||
|
return next(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
on('prompt.compose', async ($, e, next) => {
|
||||||
|
const composition = await next(e)
|
||||||
|
// L'outil Agent n'est offert qu'à l'agent principal : pas de consigne pour les subagents.
|
||||||
|
return incitation && e.tools.includes('Agent')
|
||||||
|
? { ...composition, sections: [...composition.sections, CONSIGNE] }
|
||||||
|
: composition
|
||||||
|
})
|
||||||
|
|
||||||
|
on('session.measure', async ($, e, next) => {
|
||||||
|
ecran.limites = e.rateLimits
|
||||||
|
await rafraichir($, ecran)
|
||||||
|
return next(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { expect, test } from 'claude-code/testing'
|
||||||
|
|
||||||
|
import { barre, duree, fenetres, ligneFenetre } from './usage'
|
||||||
|
|
||||||
|
const T0 = Date.parse('2026-10-31T12:00:00Z')
|
||||||
|
|
||||||
|
test('durée avant la remise à zéro', () => {
|
||||||
|
expect(duree(12 * 60000)).toBe('12 min')
|
||||||
|
expect(duree(130 * 60000)).toBe('2 h 10')
|
||||||
|
expect(duree(76 * 3600000)).toBe('3 j 4 h')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fenêtres 5 h puis hebdo, sans les autres ni une remise à zéro passée', () => {
|
||||||
|
expect(fenetres([
|
||||||
|
{ kind: 'seven_day', percentUsed: 18, resetsAt: '2026-10-31T11:00:00Z' },
|
||||||
|
{ kind: 'spend_limit', percentUsed: 3 },
|
||||||
|
{ kind: 'five_hour', percentUsed: 42.5, resetsAt: '2026-10-31T14:10:00Z' },
|
||||||
|
], T0)).toEqual([{ nom: '5 h', pourcent: 42.5, reste: '2 h 10' }, { nom: 'hebdo', pourcent: 18 }])
|
||||||
|
expect(fenetres([], T0)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('la barre reste dans sa largeur, même hors de 0 à 100', () => {
|
||||||
|
expect(barre(50, 10)).toBe('🟩🟩🟩🟩🟩⬛⬛⬛⬛⬛')
|
||||||
|
expect(barre(130, 4)).toBe('🟥🟥🟥🟥')
|
||||||
|
expect(barre(-5, 4)).toBe('⬛⬛⬛⬛')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('couleur selon le remplissage', () => {
|
||||||
|
expect([69.9, 70, 89.9, 90].map(p => barre(p, 1))).toEqual(['🟩', '🟧', '🟧', '🟥'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ligne d’une fenêtre', () => {
|
||||||
|
expect(ligneFenetre({ nom: '5 h', pourcent: 42.5, reste: '2 h 10' }, 4)).toBe('5 h 🟩🟩⬛⬛ 43 % (2 h 10)')
|
||||||
|
expect(ligneFenetre({ nom: 'hebdo', pourcent: 18 }, 4)).toBe('hebdo 🟩⬛⬛⬛ 18 %')
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Usage de Claude sur 5 h et sur la semaine, tel que l'affiche la ligne d'état (calcul pur, testé).
|
||||||
|
|
||||||
|
import type { SessionRateLimit } from 'claude-code'
|
||||||
|
|
||||||
|
export type Fenetre = { nom: string; pourcent: number; reste?: string }
|
||||||
|
|
||||||
|
const NOMS: readonly (readonly [string, string])[] = [['five_hour', '5 h'], ['seven_day', 'hebdo']]
|
||||||
|
const ALERTE = 70 // % à partir duquel la barre passe en orange
|
||||||
|
const CRITIQUE = 90 // puis en rouge
|
||||||
|
|
||||||
|
export function duree(ms: number): string {
|
||||||
|
const minutes = Math.ceil(ms / 60000)
|
||||||
|
const heures = Math.floor(minutes / 60)
|
||||||
|
const jours = Math.floor(heures / 24)
|
||||||
|
if (jours) {
|
||||||
|
return `${jours} j ${heures % 24} h`
|
||||||
|
}
|
||||||
|
return heures ? `${heures} h ${String(minutes % 60).padStart(2, '0')}` : `${minutes} min`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fenêtres connues, dans l'ordre d'affichage ; `reste` : temps avant la remise à zéro, absent si elle est passée.
|
||||||
|
export function fenetres(limites: readonly SessionRateLimit[], maintenant: number): Fenetre[] {
|
||||||
|
return NOMS.flatMap(([kind, nom]) => {
|
||||||
|
const limite = limites.find(l => l.kind === kind)
|
||||||
|
if (!limite) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const ms = limite.resetsAt ? Date.parse(limite.resetsAt) - maintenant : NaN
|
||||||
|
return [{ nom, pourcent: limite.percentUsed, ...(ms > 0 ? { reste: duree(ms) } : {}) }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Barre en carrés emoji : la ligne d'état n'accepte que du texte, mais eux gardent leur couleur.
|
||||||
|
export function barre(pourcent: number, largeur: number): string {
|
||||||
|
const pleins = Math.round((Math.min(100, Math.max(0, pourcent)) / 100) * largeur)
|
||||||
|
const plein = pourcent >= CRITIQUE ? '🟥' : pourcent >= ALERTE ? '🟧' : '🟩'
|
||||||
|
return plein.repeat(pleins) + '⬛'.repeat(largeur - pleins)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ligneFenetre(f: Fenetre, largeur: number): string {
|
||||||
|
const reste = f.reste ? ` (${f.reste})` : ''
|
||||||
|
return `${f.nom} ${barre(f.pourcent, largeur)} ${Math.round(f.pourcent)} %${reste}`
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: deleguer
|
name: deleguer
|
||||||
description: Quand et comment confier une tâche au modèle local de MIAM pour économiser des tokens. À consulter avant de lire un long log, une longue sortie pytest/ruff ou un long document pour en tirer peu, et avant d'écrire une traduction, des docstrings, des notes de version, un message de commit, une fonction pure et ses tests, ou des tests répétitifs.
|
description: À utiliser par réflexe, sans attendre qu'on le demande, pour confier une tâche au modèle local de MIAM et économiser l'usage Claude. À consulter avant de lire un long log, une longue sortie pytest/ruff ou un long document pour en tirer peu, et avant d'écrire une traduction, des docstrings, des notes de version, un message de commit, une fonction pure et ses tests, ou des tests répétitifs. À consulter aussi pour confier à OpenCode, agent cloud, une tâche de code autonome et vérifiable qui touche plusieurs fichiers d'un projet autorisé.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Déléguer à MIAM
|
# Déléguer à MIAM
|
||||||
|
|
||||||
MIAM fait tourner un modèle local (llama.cpp, carte graphique du poste). Lui confier les tâches autonomes et
|
MIAM fait tourner un modèle local (llama.cpp, carte graphique du poste). Lui confier les tâches autonomes et
|
||||||
vérifiables économise les tokens de Claude.
|
vérifiables économise les tokens de Claude. C'est le réflexe par défaut pour ce qui figure sous « Oui » : délègue sans
|
||||||
|
demander, et ne le fais toi-même que si MIAM est injoignable.
|
||||||
|
|
||||||
## Quoi déléguer
|
## Quoi déléguer
|
||||||
|
|
||||||
@@ -20,27 +21,82 @@ délégation.
|
|||||||
|
|
||||||
## Comment
|
## Comment
|
||||||
|
|
||||||
Lance le subagent `miam:delegue` (outil Agent, `subagent_type: "miam:delegue"`, `model: "${user_config.modele_subagent}"`).
|
Appelle toi-même l'outil MCP `deleguer` du serveur `plugin:miam:miam`, chargé au besoin par ToolSearch. Pas de subagent : son
|
||||||
Il vérifie que MIAM répond, indique le type de la tâche pour que MIAM choisisse le modèle, délègue, attend, vérifie et
|
pilotage coûte plus de tokens que la délégation n'en économise. Le modèle local ne voit que la consigne et les fichiers.
|
||||||
rend un compte rendu court.
|
|
||||||
|
|
||||||
Son brief doit se suffire à lui-même :
|
- `type` : `code` (fonction, tests, script), `texte` (traduction, résumé court, notes de version, docstrings, commit),
|
||||||
- la tâche, précise (signature, langue, format exact, cas limites) ;
|
`rapide` (lire beaucoup pour rendre peu). MIAM choisit le modèle, `seuil` vaut 0,8 par défaut ;
|
||||||
- les fichiers en **chemins absolus** (MIAM les lit : ne les lis pas toi-même avant) ;
|
- `consigne` : autonome et précise (signature, langue, format exact, cas limites) ;
|
||||||
- le format (`texte`, `code`, `json`) et le fichier de `sortie` éventuel (dire si on peut l'écraser) ;
|
- `fichiers` : **chemins absolus**, `chemin:debut-fin` pour une partie. MIAM les lit : ne les lis pas avant ;
|
||||||
- comment vérifier (tests à lancer, clés JSON attendues) ;
|
- `format` : `json` pour des faits extraits, `code` pour du code, sinon `texte` ;
|
||||||
- si besoin : un seuil de qualité (0 à 1, défaut 0,8), un preset imposé, ou « garder le modèle chargé ». Par défaut,
|
- réponse longue : `sortie` + `retour="apercu"`, pour ne pas la relire ;
|
||||||
MIAM remplace le modèle chargé s'il est sous le seuil pour cette tâche (20 à 40 s).
|
- `reflexion` : `off` par défaut, `bas`/`moyen`/`haut` si la tâche demande du raisonnement ;
|
||||||
|
- `controle` : contrôles de MIAM parmi `syntaxe`, `complet`, `symboles`, `docstrings`, `citations`, `traduction`, `lint`
|
||||||
|
(`[]` pour aucun). Par défaut, du code avec une `sortie` `.py` passe `syntaxe`, `complet`, `symboles`, `docstrings` et
|
||||||
|
`lint` ; le reste passe `complet` ;
|
||||||
|
- `attendus` : noms qui doivent figurer dans la réponse (fonctions, classes), vérifiés par `complet` ;
|
||||||
|
- `symboles` et `citations` comparent la réponse aux `fichiers` : joins les modules que le code utilise.
|
||||||
|
|
||||||
Plusieurs tâches indépendantes : plusieurs subagents, MIAM les met en file (une délégation à la fois sur le GPU) ; des
|
MIAM retente seul en cas d'échec (2 essais, puis l'API pour une demande locale) et n'écrit la `sortie` qu'après un
|
||||||
types différents peuvent recharger un modèle entre deux tâches.
|
verdict `valide`.
|
||||||
|
|
||||||
|
**Réponse « en file »** (`ok: null` avec `reprise`) : lance la commande `reprise` **une seule fois**, telle quelle, avec
|
||||||
|
Bash et `run_in_background: true`, puis termine ton tour ou fais autre chose. Tu es réveillé à la fin, et le plugin te
|
||||||
|
donne alors le résultat : inutile d'ouvrir la sortie de la commande. Ni `suivre` ni `sleep` d'ici là (le plugin les
|
||||||
|
refuse). Au réveil, selon `fin` :
|
||||||
|
|
||||||
|
| `fin` | Conduite |
|
||||||
|
|---|---|
|
||||||
|
| `valide` | contrôles passés : intègre sans relire le corps (aperçu ou `offset`/`limit`), ruff et tests du projet ensuite |
|
||||||
|
| `termine` | pas de contrôle automatique : vérifie avant d'intégrer |
|
||||||
|
| `echec_controle` | reprends la main, ou redécoupe en bouts plus petits avec le motif dans la consigne |
|
||||||
|
| `besoin_claude` | `suivre` pour voir la décision attendue, réponds (`autoriser`), puis relance `reprise` en arrière-plan |
|
||||||
|
| `annule` | rien |
|
||||||
|
| `perdue` | MIAM redémarré ou injoignable : renvoie la demande ou fais-la toi-même |
|
||||||
|
|
||||||
|
Sans `reprise` (ancien MIAM) : `suivre(demande_id, attente_s=45)` jusqu'au résultat. `annuler(demande_id)` abandonne.
|
||||||
|
|
||||||
|
Refus de `deleguer` : `aucun preset testé` → `demarrer(preset="${user_config.preset_defaut}")`, `suivre`, puis le même
|
||||||
|
`deleguer` ; contexte dépassé → `modeles`, puis `preset` = le mieux noté dont le `contexte` suffit, sans `offload` si
|
||||||
|
possible ; autre refus → le dire à l'utilisateur.
|
||||||
|
|
||||||
|
## OpenCode (`moteur="opencode"`)
|
||||||
|
|
||||||
|
Agent cloud qui lit, modifie et teste dans un clone du projet. Le code part chez un fournisseur : c'est toujours un
|
||||||
|
choix explicite, jamais le moteur par défaut.
|
||||||
|
|
||||||
|
**Quand** : une tâche de code bornée que le modèle local ne peut pas faire seul parce qu'elle demande d'explorer le
|
||||||
|
projet, de toucher plusieurs fichiers ou de lancer les tests (refactorisation mécanique, correction localisée, tests à
|
||||||
|
compléter), avec un résultat vérifiable, dans un projet autorisé par la config de MIAM. **Pas** : ce qu'un simple
|
||||||
|
`deleguer` local suffit à faire, la décision d'architecture, la sécurité, le code qui touche à des secrets. Projet
|
||||||
|
refusé : le dire à l'utilisateur, ne pas contourner.
|
||||||
|
|
||||||
|
Le clone part de l'état commité : les modifications en cours en sont absentes. Commite-les ou signale-le avant.
|
||||||
|
|
||||||
|
1. `deleguer(moteur="opencode", racine=<projet>, consigne=…)`, `modele` facultatif. Réponse « en file » : c'est normal,
|
||||||
|
la tâche dure ;
|
||||||
|
2. `suivre(demande_id, attente_s=45)` jusqu'au résultat, sans appels rapprochés : chaque appel coûte des tokens. `phase`
|
||||||
|
et `dernier_outil` montrent l'avancement ;
|
||||||
|
3. `etat: "permission"` : réponds vite, sans réponse à temps c'est un refus. Pour chaque entrée de `permissions` (`action`,
|
||||||
|
`ressources`, `expire_dans_s`), `autoriser(demande_id, permission_id, reponse)` : `oui` si l'action sert la tâche et
|
||||||
|
reste dans le clone, `toujours` pour la même action répétée (vaut pour cette délégation seulement), `non` sinon :
|
||||||
|
accès hors du clone, réseau, suppression, secrets. Un doute sérieux : `non`, et dis-le à l'utilisateur ;
|
||||||
|
4. résultat : `resume`, `fichiers`, `tests`, `diff` (chemin). Lis `fichiers` et `tests`, puis le diff (par `offset` et
|
||||||
|
`limit` s'il est long). Il n'est pas dans la réponse pour économiser tes tokens ;
|
||||||
|
5. **décide toujours** : `appliquer(demande_id)` s'il fait ce qui était demandé, sinon `rejeter(demande_id)`. Rien n'arrive
|
||||||
|
dans l'arbre de travail avant `appliquer`, et un clone non tranché reste sur le disque. `appliquer` refusé (arbre
|
||||||
|
changé entre-temps) : la réponse contient l'erreur, adapte à la main ou rejette ;
|
||||||
|
6. après `appliquer`, lance ruff et les tests du projet.
|
||||||
|
|
||||||
|
`annuler(demande_id)` interrompt la tâche. Quota atteint : pas de bascule automatique, dis-le à l'utilisateur et choisis.
|
||||||
|
Ce suivi est du pilotage, déduit du gain ; un diff rejeté n'évite rien.
|
||||||
|
|
||||||
## Ensuite
|
## Ensuite
|
||||||
|
|
||||||
Relis le compte rendu : un résultat « raté » ou « partiel », tu le reprends toi-même. Code produit : relu, `ruff check`,
|
Vérifie sans tout relire : `json_valide` et clés pour du JSON ; ruff et tests pour du code ; `tronque` vrai = réponse
|
||||||
`ruff format --check` et tests verts avant de le garder.
|
coupée. Un échec : une seule nouvelle délégation avec une consigne corrigée, puis reprends la tâche toi-même.
|
||||||
|
|
||||||
MIAM injoignable (le compte rendu le dit) : le signaler à l'utilisateur (lancer MIAM et son serveur MCP, puis `/mcp` →
|
MIAM injoignable (outil absent, connexion refusée) : le signaler à l'utilisateur (lancer MIAM et son serveur MCP, puis
|
||||||
Reconnect sur `plugin:miam:miam`) et faire la tâche toi-même si elle est urgente.
|
`/mcp` → Reconnect sur `plugin:miam:miam`) et faire la tâche toi-même si elle est urgente.
|
||||||
|
|
||||||
Bilan des délégations : outil `bilan` de MIAM. Notes et caractéristiques des presets : outil `modeles`.
|
Bilan des délégations : outil `bilan` de MIAM. Notes et caractéristiques des presets : outil `modeles`.
|
||||||
Reference in new issue
Block a user