Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e66af7bf9 | ||
|
|
d4737812c1 | ||
|
|
2edbe71367 |
No files matched your search
@@ -24,11 +24,10 @@ 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, en vert ou en rouge selon le signe du gain ;
|
||||
- un bandeau avec le gain net et l'usage Claude sur 5 h et sur la semaine ;
|
||||
- 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
|
||||
@@ -111,16 +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.tsx # Enregistrement des hooks (compteur, bandeau, incitation)
|
||||
register.tsx # Enregistrement des hooks (compteur, ligne d'état, incitation)
|
||||
economies.ts # Calcul des économies de tokens
|
||||
usage.ts # Usage Claude 5 h et hebdo
|
||||
*.test.ts(x) # Tests unitaires
|
||||
types/
|
||||
index.d.ts # Valeurs du bandeau
|
||||
skills/
|
||||
deleguer/ # Skill pour déclencher la délégation
|
||||
SKILL.md
|
||||
|
||||
File renamed without changes.
@@ -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`) :
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "miam",
|
||||
"displayName": "MIAM",
|
||||
"version": "0.5.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, gains en tokens et usage Claude (5 h, hebdo) en couleur",
|
||||
"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",
|
||||
"types": "./types/index.d.ts",
|
||||
"keywords": ["miam", "llama.cpp", "local", "delegation", "mcp"],
|
||||
"userConfig": {
|
||||
"incitation": {
|
||||
@@ -16,6 +15,12 @@
|
||||
"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",
|
||||
@@ -31,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",
|
||||
|
||||
+18
-19
@@ -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,38 +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`), 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.
|
||||
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.
|
||||
|
||||
## Bandeau : gains et usage Claude
|
||||
## Délégation imposée
|
||||
|
||||
Au-dessus du prompt, sans coût en tokens :
|
||||
- le gain net, en vert s'il est positif, en rouge s'il est négatif (`MIAM net ~4k (cumul ~35k)`) ;
|
||||
- l'usage Claude sur 5 h et sur la semaine : barre, pourcentage et temps avant la remise à zéro, en vert, en orange à partir de 70 %, en 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.
|
||||
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. À la fin du subagent, une seconde notification donne le net, pilotage déduit (`🔴 MIAM : net ~-1,5k sur la session (pilotage 2k)`). Notifications et ligne d'état n'acceptent que du texte : la couleur y passe par 🟢 (gain positif) ou 🔴 (gain négatif). `/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.
|
||||
@@ -83,18 +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.tsx # compteur de tokens, bandeau, incitation, /miam-economies
|
||||
register.tsx # compteur de tokens, ligne d'état, incitation, /miam-economies
|
||||
economies.ts # calcul pur, testé
|
||||
usage.ts # usage Claude 5 h et hebdo : calcul pur, testé
|
||||
*.test.ts(x) # tests : claude plugin test plugins/miam
|
||||
types/
|
||||
index.d.ts # valeurs du bandeau ($.state)
|
||||
LICENSE
|
||||
README.md
|
||||
```
|
||||
@@ -104,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
|
||||
|
||||
|
||||
@@ -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 { court, cout, estimer, ligne, pastille, teinte, 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,18 +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 ▸ 1 délégation(s) · ~500 tokens évités · pilotage 2k · net ~-1,5k (cumul ~-1,5k)')
|
||||
expect(ligne(VIDE, perte)).toBe('🔴 MIAM ▸ cumul ~-1,5k tokens évités en 1 délégation(s)')
|
||||
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(['🟢 ', '🔴 ', ''])
|
||||
expect([1, -1, 0].map(teinte)).toEqual(['success', 'error', undefined])
|
||||
})
|
||||
@@ -112,25 +112,17 @@ export function court(n: number): string {
|
||||
return (n < 0 ? '-' : '') + s.replace('.', ',').replace(',0', '')
|
||||
}
|
||||
|
||||
// Signe d'un gain : la pastille pour les textes bruts (toast, ligne d'état), la teinte pour le bandeau.
|
||||
// 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 ? '🔴 ' : ''
|
||||
}
|
||||
|
||||
export function teinte(gain: number): 'success' | 'error' | undefined {
|
||||
return gain > 0 ? 'success' : gain < 0 ? 'error' : undefined
|
||||
}
|
||||
|
||||
// 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
|
||||
? `${pastille(net(session))}MIAM ▸ ${session.delegations} délégation(s) · ~${court(session.evites)} tokens évités${detail}` +
|
||||
` (cumul ~${court(net(cumul))})`
|
||||
: `${pastille(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.
|
||||
|
||||
@@ -59,7 +59,7 @@ test('une délégation du subagent est comptée, son pilotage aussi, et MIAM les
|
||||
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(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
|
||||
@@ -105,7 +105,7 @@ test('les reprises par l’agent principal d’un fichier écrit par MIAM sont d
|
||||
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(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([
|
||||
@@ -115,38 +115,77 @@ test('les reprises par l’agent principal d’un fichier écrit par MIAM sont d
|
||||
])
|
||||
})
|
||||
|
||||
const BANDEAU = {
|
||||
plugin: 'miam',
|
||||
component: 'AbovePrompt',
|
||||
props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 80, scroll: { offset: 0, bodyRows: 10 } },
|
||||
} as const
|
||||
|
||||
const LIMITES = [{ kind: 'five_hour', percentUsed: 95 }, { kind: 'seven_day', percentUsed: 18 }]
|
||||
|
||||
test('le bandeau montre le gain en couleur et l’usage 5 h et hebdo, sur chaque surface',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
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', () => ({ 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 } }))
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
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 %'])
|
||||
|
||||
for (const surface of ['terminal', 'desktop', 'vscode', 'mobile'] as const) {
|
||||
const ui = await $.ui.mount({ ...BANDEAU, surface })
|
||||
const gain = await ui.find({ type: 'Text', text: /MIAM cumul/ })
|
||||
expect(gain?.text).toBe('MIAM cumul ~-1,5k')
|
||||
expect(gain?.props.color).toBe('error')
|
||||
expect((await ui.find({ type: 'Text', text: /^5 h/ }))?.props.color).toBe('error')
|
||||
expect((await ui.find({ type: 'Text', text: /^hebdo/ }))?.props.color).toBe('success')
|
||||
await ui.unmount()
|
||||
}
|
||||
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
|
||||
@@ -155,7 +194,7 @@ test('une grosse lecture de l’agent principal vaut un rappel, pas deux de suit
|
||||
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('miam:delegue')
|
||||
expect((await lire()).context?.[0]).toContain('`deleguer`')
|
||||
expect((await lire()).context).toBeUndefined()
|
||||
t = 600000
|
||||
expect((await lire()).context).toHaveLength(1)
|
||||
|
||||
@@ -1,49 +1,81 @@
|
||||
import { atom, read, update } from 'claude-code'
|
||||
import type { EngineInterface, Register } from 'claude-code'
|
||||
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, teinte, VIDE,
|
||||
evenementPilotage, ligne, lireResultat, lot, mesurerCorrection, net, pastille, retirer, VIDE,
|
||||
} from './economies'
|
||||
import type { Evenement, Totaux } from './economies'
|
||||
import { fenetres, ligneFenetre, teinteUsage } from './usage'
|
||||
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)$/
|
||||
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)$/ // lectures de l'agent principal qu'un rappel peut viser
|
||||
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 LARGE = 70 // colonnes à partir desquelles les fenêtres tiennent sur une ligne
|
||||
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, lance le subagent \`miam:delegue\`, 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.`,
|
||||
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 à " +
|
||||
"`miam:delegue` (MIAM lit les fichiers sans consommer l'usage Claude) au lieu de la lire toi-même."
|
||||
"l'outil MCP `deleguer` de MIAM (il lit les fichiers sans consommer l'usage Claude) au lieu de la lire toi-même."
|
||||
|
||||
const gains = atom({ plugin: 'miam', key: 'gains' } as const, null, { shape: 'gains-1' })
|
||||
const limites = atom({ plugin: 'miam', key: 'limites' } as const, [], { shape: 'limites-1' })
|
||||
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
|
||||
|
||||
// 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))
|
||||
await update($, gains, () => ({ session: net(suite), cumul: net(cumul), aDelegue: suite.delegations > 0 }))
|
||||
return suite
|
||||
// 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> {
|
||||
@@ -72,7 +104,7 @@ async function envoyer($: EngineInterface, miam: Miam): Promise<void> {
|
||||
|
||||
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 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
|
||||
@@ -81,21 +113,20 @@ export const register: Register = (on, options) => {
|
||||
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) => {
|
||||
const cumul = ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, {})
|
||||
if (cumul.delegations) {
|
||||
$.ui.status(ligne(VIDE, cumul))
|
||||
await update($, gains, () => ({ session: 0, cumul: net(cumul), aDelegue: false }))
|
||||
}
|
||||
const usage = await $.session.usage()
|
||||
await update($, limites, () => usage.rateLimits)
|
||||
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))
|
||||
@@ -107,17 +138,16 @@ export const register: Register = (on, options) => {
|
||||
on('command.run', { command: 'miam-economies' }, async ($, e) => {
|
||||
if (e.args.trim() === 'raz') {
|
||||
await $.store.set(CUMUL, VIDE)
|
||||
session = VIDE
|
||||
$.ui.status(undefined)
|
||||
await update($, gains, () => null)
|
||||
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 : ${session.delegations} délégation(s), ~${court(session.evites)} tokens évités, pilotage ` +
|
||||
`${court(session.pilotage)}, corrections ${court(session.corrections)}, net ~${court(net(session))}.`,
|
||||
`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.',
|
||||
@@ -137,13 +167,28 @@ export const register: Register = (on, options) => {
|
||||
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
|
||||
}
|
||||
const args = e as unknown as Record<string, unknown>
|
||||
const demandeId = sorties.size ? cible(e.tool, args, sorties) : undefined
|
||||
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 ||
|
||||
@@ -157,7 +202,7 @@ export const register: Register = (on, options) => {
|
||||
if (!correction.lus && !correction.ecrits) {
|
||||
return ran
|
||||
}
|
||||
session = await afficher($, session, { corrections: correction.lus + correction.ecrits })
|
||||
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}`,
|
||||
@@ -201,8 +246,8 @@ export const register: Register = (on, options) => {
|
||||
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(`${pastille(net(session))}MIAM : ~${court(est.evites)} tokens évités (${court(est.lus)} lus, ` +
|
||||
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() },
|
||||
@@ -214,9 +259,9 @@ export const register: Register = (on, options) => {
|
||||
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) })
|
||||
$.ui.toast(`${pastille(net(session))}MIAM : net ~${court(net(session))} sur la session ` +
|
||||
`(pilotage ${court(session.pilotage)})`)
|
||||
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,
|
||||
@@ -227,39 +272,15 @@ export const register: Register = (on, options) => {
|
||||
|
||||
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, dont miam:delegue.
|
||||
// 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) => {
|
||||
await update($, limites, () => e.rateLimits)
|
||||
ecran.limites = e.rateLimits
|
||||
await rafraichir($, ecran)
|
||||
return next(e)
|
||||
})
|
||||
|
||||
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
||||
const g = await read($, gains)
|
||||
const usage = fenetres(await read($, limites), await $.clock.now())
|
||||
if (e.props.hasSurvey || (!g && !usage.length)) {
|
||||
return next(e)
|
||||
}
|
||||
const { Box, Text } = $.ui.resolve(e)
|
||||
const estLarge = e.props.bodyColumns >= LARGE
|
||||
const gain = g?.aDelegue ? g.session : g?.cumul ?? 0
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{g && (
|
||||
<Text color={teinte(gain)}>
|
||||
{g.aDelegue ? `MIAM net ~${court(g.session)} (cumul ~${court(g.cumul)})` : `MIAM cumul ~${court(g.cumul)}`}
|
||||
</Text>
|
||||
)}
|
||||
<Box flexDirection={estLarge ? 'row' : 'column'}>
|
||||
{usage.map(f => (
|
||||
<Text color={teinteUsage(f.pourcent)}>{`${ligneFenetre(f, estLarge ? 8 : 6)} `}</Text>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from 'claude-code/testing'
|
||||
|
||||
import { barre, duree, fenetres, ligneFenetre, teinteUsage } from './usage'
|
||||
import { barre, duree, fenetres, ligneFenetre } from './usage'
|
||||
|
||||
const T0 = Date.parse('2026-10-31T12:00:00Z')
|
||||
|
||||
@@ -20,16 +20,16 @@ test('fenêtres 5 h puis hebdo, sans les autres ni une remise à zéro passée',
|
||||
})
|
||||
|
||||
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('░░░░')
|
||||
expect(barre(50, 10)).toBe('🟩🟩🟩🟩🟩⬛⬛⬛⬛⬛')
|
||||
expect(barre(130, 4)).toBe('🟥🟥🟥🟥')
|
||||
expect(barre(-5, 4)).toBe('⬛⬛⬛⬛')
|
||||
})
|
||||
|
||||
test('teinte selon le remplissage', () => {
|
||||
expect([69.9, 70, 89.9, 90].map(teinteUsage)).toEqual(['success', 'warning', 'warning', 'error'])
|
||||
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 %')
|
||||
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 %')
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
// Usage de Claude sur 5 h et sur la semaine, tel que l'affiche le bandeau (calcul pur, testé).
|
||||
// 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'
|
||||
|
||||
@@ -30,16 +30,14 @@ export function fenetres(limites: readonly SessionRateLimit[], maintenant: numbe
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
return '█'.repeat(pleins) + '░'.repeat(largeur - pleins)
|
||||
}
|
||||
|
||||
export function teinteUsage(pourcent: number): 'success' | 'warning' | 'error' {
|
||||
return pourcent >= CRITIQUE ? 'error' : pourcent >= ALERTE ? 'warning' : 'success'
|
||||
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.padEnd(5)} ${barre(f.pourcent, largeur)} ${Math.round(f.pourcent)} %${reste}`
|
||||
const reste = f.reste ? ` (${f.reste})` : ''
|
||||
return `${f.nom} ${barre(f.pourcent, largeur)} ${Math.round(f.pourcent)} %${reste}`
|
||||
}
|
||||
@@ -21,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`.
|
||||
Vendored
-11
@@ -1,11 +0,0 @@
|
||||
// Gain net en tokens, pour le bandeau ; `aDelegue` : au moins une délégation dans la session.
|
||||
export type Gains = { session: number; cumul: number; aDelegue: boolean }
|
||||
|
||||
declare module 'claude-code' {
|
||||
interface PluginState {
|
||||
miam: {
|
||||
gains: Shaped<Gains | null>
|
||||
limites: Shaped<SessionRateLimit[]>
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user