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
This commit is contained in:
ange committed 2026-10-31 10:39:33 +01:00
1 parent e4da4f9b87
commit 2edbe71367
12 files changed
+302 -154

No files matched your search

+2 -4
View File
@@ -28,7 +28,7 @@ Le plugin `miam` fournit :
- 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
@@ -115,12 +115,10 @@ plugins/
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
+7 -2
View File
@@ -1,13 +1,12 @@
{
"name": "miam",
"displayName": "MIAM",
"version": "0.5.0",
"version": "0.6.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": {
@@ -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",
+16 -12
View File
@@ -47,20 +47,26 @@ MIAM choisit le modèle d'après ses tests de qualité : il garde le modèle cha
## 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 à `miam:delegue`. 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, précédés de 🟠 à partir de 70 % et de 🔴 à 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. À la fin du subagent, une seconde notification donne le net, pilotage déduit (`🔴 MIAM : net ~-1,5k sur la session (pilotage 2k)`). `/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).
@@ -89,12 +95,10 @@ 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
```
+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`
}
+8 -8
View File
@@ -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])
})
+6 -14
View File
@@ -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.
+68 -29
View File
@@ -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('miam:delegue')
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
+85 -64
View File
@@ -1,24 +1,26 @@
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 = 6 // 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 = {
@@ -30,20 +32,50 @@ Un modèle local (MIAM) fait sans consommer l'usage Claude les tâches autonomes
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' })
const REFUS_LECTURE = `Lecture refusée par le plugin MIAM : ce fichier est long. Lance le subagent \`miam:delegue\` 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. Lance le subagent \`miam:delegue\` 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,
@@ -234,32 +279,8 @@ export const register: Register = (on, options) => {
})
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>
)
})
}
+4 -4
View File
@@ -1,6 +1,6 @@
import { expect, test } from 'claude-code/testing'
import { barre, duree, fenetres, ligneFenetre, teinteUsage } from './usage'
import { alerte, barre, duree, fenetres, ligneFenetre } from './usage'
const T0 = Date.parse('2026-10-31T12:00:00Z')
@@ -25,11 +25,11 @@ test('la barre reste dans sa largeur, même hors de 0 à 100', () => {
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('alerte selon le remplissage', () => {
expect([69.9, 70, 89.9, 90].map(alerte)).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: '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 %')
})
+6 -6
View File
@@ -1,11 +1,11 @@
// 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'
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 ALERTE = 70 // % à partir duquel la fenêtre passe en orange
const CRITIQUE = 90 // puis en rouge
export function duree(ms: number): string {
@@ -35,11 +35,11 @@ export function barre(pourcent: number, largeur: number): string {
return '█'.repeat(pleins) + '░'.repeat(largeur - pleins)
}
export function teinteUsage(pourcent: number): 'success' | 'warning' | 'error' {
return pourcent >= CRITIQUE ? 'error' : pourcent >= ALERTE ? 'warning' : 'success'
export function alerte(pourcent: number): string {
return pourcent >= CRITIQUE ? '🔴 ' : pourcent >= ALERTE ? '🟠 ' : ''
}
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 `${alerte(f.pourcent)}${f.nom} ${barre(f.pourcent, largeur)} ${Math.round(f.pourcent)} %${reste}`
}
-11
View File
@@ -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[]>
}
}
}