4 Commits
Author SHA1 Message Date
ange 9e66af7bf9 v0.7.0 : délégation directe par l'outil deleguer, subagent mis de côté
- consignes, rappel et refus renvoient vers l'outil MCP deleguer
- skill deleguer réécrite pour l'appel direct
- subagent déplacé dans docs/agent-delegue.md, réglage modele_subagent retiré
2026-10-31 10:57:24 +01:00
ange d4737812c1 v0.6.1 : barres d'usage colorées en emoji dans la ligne d'état 2026-10-31 10:46:00 +01:00
ange 2edbe71367 v0.6.0 : ligne d'état unifiée, délégation imposée et retrait des types
- fusion du bandeau et de l'ancienne ligne d'état en une seule ligne d'état
- refus de la lecture des longs fichiers et enregistrement des longues sorties shell
- ajout du réglage « imposer », actif par défaut, sans blocage si MIAM ne répond pas
- suppression du fichier types/index.d.ts
2026-10-31 10:39:33 +01:00
angeandClaude Opus 5.5 e4da4f9b87 v0.5.0 : bandeau de gains, usage Claude et incitation à déléguer
- Bandeau au-dessus du prompt affichant le gain net en vert ou rouge
- Pastilles 🟢/🔴 dans les notifications et la ligne d'état
- Notification du net de session à la fin du subagent
- Affichage de l'usage Claude sur 5 h et hebdomadaire
- Incitation à déléguer via consigne système et rappel après lecture
- Option `incitation` pour désactiver la consigne et le rappel
- Renommage de register.ts en register.tsx

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:00:50 +01:00
17 changed files with 778 additions and 366 deletions

No files matched your search

+6 -6
View File
@@ -24,10 +24,11 @@ Ce dépôt est le marketplace A2M Systèmes. Actuellement, il contient le plugin
Le plugin `miam` fournit :
- 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) ;
- 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
@@ -109,13 +110,12 @@ plugins/
.claude-plugin/
plugin.json # Métadonnées et version du plugin
.mcp.json # Configuration du serveur MCP
agents/
delegue.md # Définition du subagent de délégation
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
*.test.ts # Tests unitaires
usage.ts # Usage Claude 5 h et hebdo
*.test.ts(x) # Tests unitaires
skills/
deleguer/ # Skill pour déclencher la délégation
SKILL.md
@@ -1,6 +1,6 @@
---
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
effort: low
color: orange
+1 -1
View File
@@ -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.
## 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
`delegations.jsonl` ne contient pas aujourd'hui l'identifiant de la demande (`core/delegation.py`, `journal_entry`) :
+15 -9
View File
@@ -1,14 +1,26 @@
{
"name": "miam",
"displayName": "MIAM",
"version": "0.4.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",
"version": "0.7.0",
"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": {
"name": "Ange-Marie MAURIN"
},
"license": "MIT",
"keywords": ["miam", "llama.cpp", "local", "delegation", "mcp"],
"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
},
"jeton": {
"type": "string",
"title": "Jeton du serveur MCP de MIAM",
@@ -24,13 +36,6 @@
"min": 1,
"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": {
"type": "string",
"title": "Preset MIAM par défaut",
@@ -39,3 +44,4 @@
}
}
}
+26 -13
View File
@@ -6,7 +6,6 @@ Plugin Claude Code pour déléguer des tâches à un modèle local géré par MI
## 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.
- 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.
## Prérequis
@@ -36,27 +35,43 @@ 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. |
| 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. |
| 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
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.
## 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.
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
Après chaque délégation réussie, une notification (`MIAM : ~6,1k tokens évités (5,2k lus, 900 écrits)`) et une ligne
d'état sous le prompt (`MIAM ▸ 2 délégation(s) · ~6,1k tokens évités · pilotage 1,5k · corrections 600 · net ~4k
(cumul ~35k)`),
dans le terminal comme dans VS Code. `/miam-economies` donne le détail de la session et le cumul ; `/miam-economies raz`
Après chaque délégation réussie, une notification (`🟢 MIAM : ~6,1k tokens évités (5,2k lus, 900 écrits)`) et la mise à
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`
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
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
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), à déduire.
- **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.
- **Net** = évités − pilotage − corrections.
@@ -72,15 +87,14 @@ MIAM teste lui-même les presets, en travaux de fond (page Benchmarks) : le plug
.claude-plugin/
plugin.json # manifeste : nom, version, réglages (jeton, port, modèle, preset)
.mcp.json # serveur MCP miam (http://127.0.0.1:<port>/mcp)
agents/
delegue.md # subagent miam:delegue
skills/
deleguer/SKILL.md # quand et comment déléguer
hooks/
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é
*.test.ts # 35 tests : claude plugin test plugins/miam
usage.ts # usage Claude 5 h et hebdo : calcul pur, testé
*.test.ts(x) # tests : claude plugin test plugins/miam
LICENSE
README.md
```
@@ -90,7 +104,6 @@ README.md
- 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
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
+50
View File
@@ -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)
})
+50
View File
@@ -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`
}
+13 -5
View File
@@ -1,6 +1,6 @@
import { expect, test } from 'claude-code/testing'
import { court, cout, estimer, ligne, lireResultat, VIDE } from './economies'
import { court, cout, estimer, ligne, pastille, lireResultat, VIDE } from './economies'
const REPONSE = { ok: true, demande_id: 'dem-1', tokens_entree: 2049, tokens_sortie: 892, reponse: 'x'.repeat(400) }
@@ -34,10 +34,18 @@ test('affichage', () => {
expect(court(-350)).toBe('-350')
const cumul = { delegations: 9, evites: 40000, pilotage: 5000, corrections: 0 }
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))
.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))
.toBe('MIAM ▸ 1 délégation(s) · ~900 tokens évités (cumul ~35k)')
expect(ligne(VIDE, cumul)).toBe('MIAM ▸ cumul ~35k tokens évités en 9 délégation(s)')
.toBe('🟢 MIAM net ~900 (cumul ~35k)')
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(['🟢 ', '🔴 ', ''])
})
+10 -9
View File
@@ -112,16 +112,17 @@ export function court(n: number): string {
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 {
const couts = [
session.pilotage ? ` · pilotage ${court(session.pilotage)}` : '',
session.corrections ? ` · corrections ${court(session.corrections)}` : '',
].join('')
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)`
if (session.delegations) {
return `${pastille(net(session))}MIAM net ~${court(net(session))} (cumul ~${court(net(cumul))})`
}
return cumul.delegations ? `${pastille(net(cumul))}MIAM cumul ~${court(net(cumul))}` : ''
}
// Résultat de `deleguer` tel que Claude Code le rend : le texte JSON de MIAM.
+1 -1
View File
@@ -1 +1 @@
{ "modules": ["./register.ts"] }
{ "modules": ["./register.tsx"] }
-115
View File
@@ -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],
])
})
+220
View File
@@ -0,0 +1,220 @@
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()
})
-188
View File
@@ -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)
})
}
+286
View File
@@ -0,0 +1,286 @@
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, retirer, VIDE,
} from './economies'
import type { Evenement, Totaux } from './economies'
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)$/
// 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
}
}
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
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
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.',
'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 (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 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] }
}
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
}
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)
await afficher($, ecran, { delegations: 1, evites: est.evites })
$.ui.toast(`${pastille(net(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)])
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)
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)
})
}
+35
View File
@@ -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 %')
})
+43
View File
@@ -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}`
}
+21 -18
View File
@@ -1,12 +1,13 @@
---
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.
---
# Déléguer à MIAM
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
@@ -20,27 +21,29 @@ délégation.
## Comment
Lance le subagent `miam:delegue` (outil Agent, `subagent_type: "miam:delegue"`, `model: "${user_config.modele_subagent}"`).
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
rend un compte rendu court.
Appelle toi-même l'outil MCP `deleguer` du serveur `plugin:miam:miam`, chargé au besoin par ToolSearch. Pas de subagent : son
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.
Son brief doit se suffire à lui-même :
- la tâche, précise (signature, langue, format exact, cas limites) ;
- les fichiers en **chemins absolus** (MIAM les lit : ne les lis pas toi-même avant) ;
- le format (`texte`, `code`, `json`) et le fichier de `sortie` éventuel (dire si on peut l'écraser) ;
- comment vérifier (tests à lancer, clés JSON attendues) ;
- si besoin : un seuil de qualité (0 à 1, défaut 0,8), un preset imposé, ou « garder le modèle chargé ». Par défaut,
MIAM remplace le modèle chargé s'il est sous le seuil pour cette tâche (20 à 40 s).
- `type` : `code` (fonction, tests, script), `texte` (traduction, résumé court, notes de version, docstrings, commit),
`rapide` (lire beaucoup pour rendre peu). MIAM choisit le modèle, `seuil` vaut 0,8 par défaut ;
- `consigne` : autonome et précise (signature, langue, format exact, cas limites) ;
- `fichiers` : **chemins absolus**, `chemin:debut-fin` pour une partie. MIAM les lit : ne les lis pas avant ;
- `format` : `json` pour des faits extraits, `code` pour du code, sinon `texte` ;
- réponse longue : `sortie` + `retour="apercu"`, pour ne pas la relire ;
- `reflexion` : `off` par défaut, `bas`/`moyen`/`haut` si la tâche demande du raisonnement.
Plusieurs tâches indépendantes : plusieurs subagents, MIAM les met en file (une délégation à la fois sur le GPU) ; des
types différents peuvent recharger un modèle entre deux tâches.
Réponse « en file » : `suivre(demande_id, attente_s=45)` jusqu'au résultat, `annuler(demande_id)` pour abandonner.
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.
## Ensuite
Relis le compte rendu : un résultat « raté » ou « partiel », tu le reprends toi-même. Code produit : relu, `ruff check`,
`ruff format --check` et tests verts avant de le garder.
Vérifie sans tout relire : `json_valide` et clés pour du JSON ; ruff et tests pour du code ; `tronque` vrai = réponse
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` →
Reconnect sur `plugin:miam:miam`) et faire la tâche toi-même si elle est urgente.
MIAM injoignable (outil absent, connexion refusée) : le signaler à l'utilisateur (lancer MIAM et son serveur MCP, puis
`/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`.