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>
This commit is contained in:
angeandClaude Opus 5.5 committed 2026-10-31 10:00:50 +01:00
1 parent 5363b86a16
commit e4da4f9b87
13 files changed
+309 -30

No files matched your search

+8 -3
View File
@@ -27,7 +27,9 @@ Le plugin `miam` fournit :
- 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 ;
- un bandeau 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
@@ -113,9 +115,12 @@ plugins/
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, bandeau, 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
types/
index.d.ts # Valeurs du bandeau
skills/
deleguer/ # Skill pour déclencher la délégation
SKILL.md
+10 -2
View File
@@ -1,14 +1,21 @@
{
"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.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",
"author": {
"name": "Ange-Marie MAURIN"
},
"license": "MIT",
"types": "./types/index.d.ts",
"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
},
"jeton": {
"type": "string",
"title": "Jeton du serveur MCP de MIAM",
@@ -39,3 +46,4 @@
}
}
}
+19 -5
View File
@@ -37,6 +37,7 @@ 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
@@ -44,12 +45,22 @@ L'agent principal lance le subagent miam:delegue ; celui-ci vérifie que MIAM r
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.
## Bandeau : gains et usage Claude
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.
## 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
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`
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`
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).
@@ -78,9 +89,12 @@ 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, bandeau, 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
types/
index.d.ts # valeurs du bandeau ($.state)
LICENSE
README.md
```
+1 -1
View File
@@ -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
+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, teinte, 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 ▸ 2 délégation(s) · ~6,1k tokens évités · pilotage 1,5k · 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 ▸ 2 délégation(s) · ~6,1k tokens évités · pilotage 1,5k · corrections 600 · 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 ▸ 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)')
})
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([1, -1, 0].map(pastille)).toEqual(['🟢 ', '🔴 ', ''])
expect([1, -1, 0].map(teinte)).toEqual(['success', 'error', undefined])
})
+11 -2
View File
@@ -112,6 +112,15 @@ 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.
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
}
export function ligne(session: Totaux, cumul: Totaux): string {
const couts = [
session.pilotage ? ` · pilotage ${court(session.pilotage)}` : '',
@@ -119,9 +128,9 @@ export function ligne(session: Totaux, cumul: Totaux): string {
].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}` +
? `${pastille(net(session))}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)`
: `${pastille(net(cumul))}MIAM ▸ cumul ~${court(net(cumul))} tokens évités en ${cumul.delegations} délégation(s)`
}
// Résultat de `deleguer` tel que Claude Code le rend : le texte JSON de MIAM.
+1 -1
View File
@@ -1 +1 @@
{ "modules": ["./register.ts"] }
{ "modules": ["./register.tsx"] }
@@ -26,6 +26,7 @@ test('une délégation du subagent est comptée, son pilotage aussi, et MIAM les
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) => {
@@ -46,7 +47,7 @@ test('une délégation du subagent est comptée, son pilotage aussi, et MIAM les
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(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
@@ -58,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 ▸ 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
@@ -104,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 ▸ 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([
@@ -113,3 +114,68 @@ test('les reprises par l’agent principal d’un fichier écrit par MIAM sont d
['correction', 'claude-code:s1:tu-2', 30],
])
})
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 })
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()
}
})
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('miam:delegue')
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()
})
@@ -1,10 +1,12 @@
import { atom, read, update } from 'claude-code'
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,
evenementPilotage, ligne, lireResultat, lot, mesurerCorrection, net, pastille, retirer, teinte, VIDE,
} from './economies'
import type { Evenement, Totaux } from './economies'
import { fenetres, ligneFenetre, teinteUsage } 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)$/
@@ -13,6 +15,23 @@ 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
const LECTURE = /^(Read|Bash)$/ // lectures de l'agent principal qu'un rappel peut viser
const SEUIL_LECTURE = 20000 // caractères, soit environ 5k tokens
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
// 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.`,
} 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."
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' })
type Demande = { consigne: string; retour?: string; agentId?: string }
type Miam = { url: string; jeton: string }
@@ -23,6 +42,7 @@ async function afficher($: EngineInterface, session: Totaux, ajout: Partial<Tota
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
}
@@ -59,12 +79,17 @@ export const register: Register = (on, options) => {
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
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)
await $.command.register({
name: 'miam-economies',
description: 'Tokens évités grâce à MIAM : session et cumul',
@@ -84,6 +109,7 @@ export const register: Register = (on, options) => {
await $.store.set(CUMUL, VIDE)
session = VIDE
$.ui.status(undefined)
await update($, gains, () => null)
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) ?? {}, {})
@@ -108,15 +134,25 @@ export const register: Register = (on, options) => {
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)) {
if (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) {
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 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
@@ -166,7 +202,8 @@ export const register: Register = (on, options) => {
}
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)`)
$.ui.toast(`${pastille(net(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)])
@@ -178,6 +215,8 @@ export const register: Register = (on, options) => {
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 mettreEnAttente($, [evenementPilotage({
sessionId: await $.session.id(), agentId: e.agentId, turnId: e.turnId, maintenant: await $.clock.now(),
demandeIds,
@@ -185,4 +224,42 @@ export const register: Register = (on, options) => {
}
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, dont miam:delegue.
return incitation && e.tools.includes('Agent')
? { ...composition, sections: [...composition.sections, CONSIGNE] }
: composition
})
on('session.measure', async ($, e, next) => {
await update($, limites, () => e.rateLimits)
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>
)
})
}
+35
View File
@@ -0,0 +1,35 @@
import { expect, test } from 'claude-code/testing'
import { barre, duree, fenetres, ligneFenetre, teinteUsage } 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('teinte selon le remplissage', () => {
expect([69.9, 70, 89.9, 90].map(teinteUsage)).toEqual(['success', 'warning', 'warning', 'error'])
})
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 %')
})
+45
View File
@@ -0,0 +1,45 @@
// Usage de Claude sur 5 h et sur la semaine, tel que l'affiche le bandeau (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) } : {}) }]
})
}
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'
}
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}`
}
+3 -2
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
+11
View File
@@ -0,0 +1,11 @@
// 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[]>
}
}
}