diff --git a/docs/token-summary-mod.md b/docs/token-summary-mod.md index 43caf03..4f8325a 100644 --- a/docs/token-summary-mod.md +++ b/docs/token-summary-mod.md @@ -81,11 +81,11 @@ quand `suivre` rend son résultat. | `date` | `string` | ISO 8601 de la génération de l'événement. | | `client` | `string` | Valeur `"claude-code"`. | | `session_id` | `string` | Identifiant de la session Claude Code. | -| `agent_id` | `string` | Id du subagent (toujours présent : l'agent principal n'a pas de pilotage). | -| `turn_id` | `string` | Id du tour du subagent (un tour = une exécution du subagent). | +| `agent_id` | `string` | Id du subagent, ou `"principal"` pour le suivi d'OpenCode par l'agent principal. | +| `turn_id` | `string` | Id du tour du subagent (un tour = une exécution du subagent), ou `tool_use_id` de l'appel pour le suivi d'OpenCode. | | `demande_ids` | `string[]` | Les `demande_id` des délégations réussies de ce tour (au moins un). | -| `modele` | `string` | Modèle Claude du subagent, tel que l'API le nomme (ex. `claude-haiku-5-5`). | -| `usage` | `object` | Les 4 compteurs de l'API pour ce tour (entiers) : `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`. | +| `modele` | `string` | Modèle Claude du subagent, tel que l'API le nomme (ex. `claude-haiku-5-5`), ou `"opencode"` pour le suivi d'OpenCode. | +| `usage` | `object` | Les 4 compteurs de l'API pour ce tour (entiers) : `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`. Pour le suivi d'OpenCode, usage réel de la requête de l'agent principal qui lit le résultat d'un appel de suivi (`deleguer`, `suivre`, `autoriser`, `appliquer`, `rejeter`, `annuler`, relecture du diff), contexte relu compris, au prorata des outils lancés par la requête précédente. | | `cout` | `integer` | Coût du tour en tokens Claude, à déduire des `evites` des délégations couvertes. | Le pilotage arrive à la fin du tour du subagent, donc après ses événements `delegation` (sauf renvoi : l'ordre diff --git a/plugins/miam/.claude-plugin/plugin.json b/plugins/miam/.claude-plugin/plugin.json index 7b240a9..d795258 100644 --- a/plugins/miam/.claude-plugin/plugin.json +++ b/plugins/miam/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "miam", "displayName": "MIAM", - "version": "0.7.1", + "version": "0.8.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" diff --git a/plugins/miam/README.md b/plugins/miam/README.md index be6ef72..78ba7db 100644 --- a/plugins/miam/README.md +++ b/plugins/miam/README.md @@ -6,7 +6,7 @@ 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. -- Skill `deleguer` : règles, pour l'agent principal, sur quand et comment déléguer une tâche à MIAM. +- Skill `deleguer` : règles, pour l'agent principal, sur quand et comment déléguer une tâche à MIAM, y compris à OpenCode (permissions, puis `appliquer` ou `rejeter` le diff). ## Prérequis MIAM 2.5.1 ou plus (`type`, `seuil` et l'outil `modeles`, champs `offload` à partir de 2.5.2) lancé avec son serveur MCP démarré (interrupteur du dashboard, menu « Serveur MCP » du tray, ou `--auto-start-mcp`). @@ -71,7 +71,7 @@ MIAM ne répond pas) : contrat et modèle de données dans [docs/token-summary-m - **É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 d'un subagent qui délègue (lecture en cache comptée au dixième), à déduire. +- **Pilotage** : la consommation réelle d'un subagent qui délègue (lecture en cache comptée au dixième), ou l'usage réel des requêtes de l'agent principal qui suivent OpenCode, contexte relu et relecture du diff compris (lecture en cache au dixième), à déduire. Le gain d'OpenCode vaut les tokens du cloud moins la consigne ; un diff rejeté n'évite rien. - **Corrections** : ce que l'agent principal relit (`Read`) ou retouche (`Edit`, `Write`, `NotebookEdit`, `Bash`) dans 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. diff --git a/plugins/miam/hooks/economies.test.ts b/plugins/miam/hooks/economies.test.ts index aafd353..dc75d28 100644 --- a/plugins/miam/hooks/economies.test.ts +++ b/plugins/miam/hooks/economies.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'claude-code/testing' -import { court, cout, estimer, ligne, pastille, lireResultat, VIDE } from './economies' +import { court, cout, estimer, evenementDelegation, ligne, pastille, lireResultat, repartir, VIDE } from './economies' const REPONSE = { ok: true, demande_id: 'dem-1', tokens_entree: 2049, tokens_sortie: 892, reponse: 'x'.repeat(400) } @@ -49,3 +49,54 @@ test('rouge si le gain est négatif, vert s’il est positif, rien à zéro', () expect(ligne(VIDE, perte)).toBe('🔴 MIAM cumul ~-1,5k') expect([1, -1, 0].map(pastille)).toEqual(['🟢 ', '🔴 ', '']) }) + +test('estimer : tokens_cloud_entree défini, parSubagent false', () => { + const consigne = 'c'.repeat(400) + const rep = { ok: true, tokens_cloud_entree: 500, tokens_cloud_sortie: 100 } + expect(estimer(consigne, 'complet', rep, false)).toEqual({ + lus: 500, + ecrits: 100, + consigne: 100, + rendus: 0, + evites: 500 + 100 - 100 + }) +}) + +test('estimer : tokens_cloud_entree défini, parSubagent true', () => { + const consigne = 'c'.repeat(400) + const rep = { ok: true, tokens_cloud_entree: 500, tokens_cloud_sortie: 100 } + expect(estimer(consigne, 'complet', rep, true)).toEqual({ + lus: 500, + ecrits: 100, + consigne: 100, + rendus: 0, + evites: 500 + 100 + }) +}) + +test('repartir : usage réel partagé entre les appels de la requête précédente', () => { + const u = { input_tokens: 100, output_tokens: 40, cache_read_input_tokens: 1000, cache_creation_input_tokens: 0 } + const suivis = [ + { demandeId: 'a', toolUseId: 'u1' }, { demandeId: 'b', toolUseId: 'u2' }, { demandeId: 'a', toolUseId: 'u3' }, + ] + expect(repartir(suivis, 4, u)).toEqual([ + { demandeId: 'a', toolUseId: 'u1', usage: { input_tokens: 50, output_tokens: 20, cache_read_input_tokens: 500, cache_creation_input_tokens: 0 } }, + { demandeId: 'b', toolUseId: 'u2', usage: { input_tokens: 25, output_tokens: 10, cache_read_input_tokens: 250, cache_creation_input_tokens: 0 } }, + ]) + expect(repartir(suivis.slice(0, 1), 0, u)[0]?.usage).toEqual(u) // compte d'appels inconnu : tout pour le suivi + expect(repartir([], 2, u)).toEqual([]) +}) + +test('evenementDelegation : remplit tokens depuis le cloud', () => { + const a = { sessionId: 's', retour: undefined, maintenant: 0 } + const rep = { ok: true, tokens_cloud_entree: 300, tokens_cloud_sortie: 50 } + const est = estimer('c'.repeat(400), 'complet', rep, false) + const evt = evenementDelegation(a, { ...rep, demande_id: 'dem-o' }, est) + expect(evt.tokens_entree).toBe(300) + expect(evt.tokens_sortie).toBe(50) +}) + +test('lireResultat : un résultat OpenCode est lu, pas une demande en file', () => { + expect(lireResultat('{"ok":true,"demande_id":"d","tokens_cloud_entree":10}')?.tokens_cloud_entree).toBe(10) + expect(lireResultat('{"ok":null,"demande_id":"d","tokens_cloud_entree":10}')).toBeUndefined() +}) diff --git a/plugins/miam/hooks/economies.ts b/plugins/miam/hooks/economies.ts index be30f90..e44dedd 100644 --- a/plugins/miam/hooks/economies.ts +++ b/plugins/miam/hooks/economies.ts @@ -5,13 +5,19 @@ // modèle local ; la consigne et l'aperçu sont estimés à 4 caractères par token. // Appelé depuis le subagent miam:delegue, la consigne et la lecture de la réponse sont à sa charge : elles comptent // dans le pilotage (sa consommation réelle, mesurée à la fin de son tour), pas ici. +// Délégué à OpenCode, le travail est fait par le cloud : les tokens évités sont ceux du cloud moins la consigne (même +// méthode que MIAM, core/gains.py). Claude paie en plus les requêtes qui lui servent à suivre la tâche, contexte relu +// compris : c'est le pilotage, mesuré sur l'usage réel de ces requêtes (voir `repartir`). export type Delegation = { demande_id?: string tokens_entree?: number tokens_sortie?: number + tokens_cloud_entree?: number // OpenCode, à la place de tokens_entree et tokens_sortie + tokens_cloud_sortie?: number reponse?: string sortie?: string // fichier écrit par MIAM (chemin absolu) : ce que Claude y reprend est une correction + diff?: string // diff d'OpenCode (chemin absolu) : sa relecture compte dans le pilotage } // consigne et rendus : estimés, déduits seulement pour un appel direct (par le subagent, ils sont dans le pilotage). @@ -35,6 +41,11 @@ const tokens = (texte: string) => Math.ceil(texte.length / 4) export function estimer(consigne: string, retour: string | undefined, r: Delegation, parSubagent: boolean): Estimation { const redige = tokens(consigne) + if (r.tokens_cloud_entree !== undefined) { + const lus = r.tokens_cloud_entree + const ecrits = r.tokens_cloud_sortie ?? 0 + return { lus, ecrits, consigne: redige, rendus: 0, evites: lus + ecrits - (parSubagent ? 0 : redige) } + } const lus = Math.max(0, (r.tokens_entree ?? 0) - redige) // fichiers (et consigne de format) lus par le modèle local const ecrits = r.tokens_sortie ?? 0 const rendus = retour === 'apercu' ? tokens(r.reponse ?? '') : ecrits @@ -46,6 +57,30 @@ export function estimer(consigne: string, retour: string | undefined, r: Delegat export const cout = (u: Usage) => u.input_tokens + u.cache_creation_input_tokens + u.output_tokens + Math.round(u.cache_read_input_tokens / 10) +// Un appel de l'agent principal pour suivre OpenCode (outil de MIAM, relecture du diff). +export type Suivi = { demandeId: string; toolUseId: string } + +// Pilotage réel : la requête qui suit des appels de suivi n'existe que pour lire leurs résultats ; son usage (contexte +// relu compris) revient à leurs délégations, au prorata quand l'étape précédente a aussi lancé d'autres outils. +export function repartir(suivis: readonly Suivi[], appels: number, u: Usage): (Suivi & { usage: Usage })[] { + const nombre = Math.max(appels, suivis.length) + const parDemande = new Map() + for (const s of suivis) { + parDemande.set(s.demandeId, [...(parDemande.get(s.demandeId) ?? []), s]) + } + return [...parDemande.values()].map(liste => { + const part = (n: number) => Math.round((n * liste.length) / nombre) + return { + ...liste[0]!, + usage: { + input_tokens: part(u.input_tokens), output_tokens: part(u.output_tokens), + cache_read_input_tokens: part(u.cache_read_input_tokens), + cache_creation_input_tokens: part(u.cache_creation_input_tokens), + }, + } + }) +} + // `t` peut venir d'une version précédente du mod (cumul gardé sans `corrections`). export const ajouter = (t: Partial, d: Partial): Totaux => ({ delegations: (t.delegations ?? 0) + (d.delegations ?? 0), @@ -129,7 +164,8 @@ export function ligne(session: Totaux, cumul: Totaux): string { export function lireResultat(texte: unknown): Delegation | undefined { try { const r = typeof texte === 'string' ? JSON.parse(texte) : texte - return r && typeof r === 'object' && (r as { ok?: unknown }).ok === true && 'tokens_entree' in r + return r && typeof r === 'object' && (r as { ok?: unknown }).ok === true && + ('tokens_entree' in r || 'tokens_cloud_entree' in r) ? (r as Delegation) : undefined } catch { @@ -202,7 +238,8 @@ export function evenementDelegation( schema: SCHEMA, type: 'delegation', id: `${CLIENT}:${r.demande_id}`, date: iso(a.maintenant), client: CLIENT, session_id: a.sessionId, demande_id: r.demande_id, agent_id: a.agentId ?? null, par_subagent: a.agentId !== undefined, retour: a.retour === 'apercu' ? 'apercu' : 'complet', - tokens_entree: r.tokens_entree ?? 0, tokens_sortie: r.tokens_sortie ?? 0, + tokens_entree: r.tokens_entree ?? r.tokens_cloud_entree ?? 0, + tokens_sortie: r.tokens_sortie ?? r.tokens_cloud_sortie ?? 0, estimation: { ...est, methode: 'caracteres/4' }, } } diff --git a/plugins/miam/hooks/register.test.tsx b/plugins/miam/hooks/register.test.tsx index 21d3f5d..4cb305f 100644 --- a/plugins/miam/hooks/register.test.tsx +++ b/plugins/miam/hooks/register.test.tsx @@ -218,3 +218,182 @@ test('incitation=false : ni consigne ni rappel', { options: { jeton: 'x', incita expect(composition.sections).toEqual([]) expect((await $.tool.call({ tool: 'Read', file_path: 'a.log' } as never)).context).toBeUndefined() }) + +const REJETER = 'mcp__plugin_miam_miam__rejeter' +const USAGE = { model: 'opus', input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10000, cache_creation_input_tokens: 0 } +const COUT = 100 + 50 + 1000 // lecture en cache au dixième + +// Requêtes du modèle : chacune répond par `outils` appels, `usage` null simule une requête perdue. +let reponse: { outils: number; usage: typeof USAGE | null } = { outils: 1, usage: USAGE } +const reponseModele = async function* () { + return { + turnId: 't', index: 0, answer: '', toolUses: Array.from({ length: reponse.outils }, () => ({ name: 'x', input: {} })), + stopReason: 'tool_use', usage: reponse.usage, + } +} +const requete = async ($: any, r: typeof reponse = { outils: 1, usage: USAGE }) => { + reponse = r + for await (const _ of $.turn.step({ turnId: 't', index: 0, model: 'm', messageCount: 1 })) { + // le flux doit être lu jusqu'au bout + } +} + +test('une délégation OpenCode est comptée avec son pilotage', + { options: { jeton: 'x' } }, async ($, on) => { + const toasts: string[] = [] + const store = new Map() + on('store.get', (_, e) => ({ value: store.get(e.key) })) + on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined })) + on('ui.status', (_, e) => ({ value: undefined })) + on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined })) + on('session.id', () => ({ value: 's1' })) + on('clock.now', () => ({ value: 0 })) + on('command.register', () => ({ value: { command: 'miam-economies' } })) + on('session.start', (_, e) => ({ cwd: e.cwd })) + on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } })) + on('clock.every', () => new Promise(() => {})) + on('turn.step', reponseModele) + on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } })) + on('tool.call', (_, e) => + e.tool === OUTIL + ? { result: {}, text: '{"ok":null,"demande_id":"dem-o","etat":"en_file"}' } + : e.tool === SUIVRE + ? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o', tokens_cloud_entree: 9000, tokens_cloud_sortie: 1000, resume: 'r', diff: 'E:/x/d.diff' }) } + : { result: {}, text: '{}' }, + ) + + await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true }) + await requete($) // écrit l'appel à deleguer : cette requête aurait existé sans délégation + await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), moteur: 'opencode', tool_use_id: 'u1' } as never) + await requete($) + await $.tool.call({ tool: SUIVRE, demande_id: 'dem-o', tool_use_id: 'u2' } as never) + await requete($, { outils: 0, usage: USAGE }) + + expect(toasts.length).toBe(1) + expect(toasts[0]).toContain('tokens évités par OpenCode') + expect(toasts[0]).toContain('~9,9k') + + const cumul = store.get('cumul') as { delegations: number; evites: number; corrections: number; pilotage: number } + expect(cumul.delegations).toBe(1) + expect(cumul.evites).toBe(9900) + expect(cumul.corrections).toBe(0) + expect(cumul.pilotage).toBe(2 * COUT) + + const a_envoyer = store.get('a_envoyer') as { type: string; agent_id?: string; demande_ids?: string[] }[] + expect(a_envoyer.length).toBe(3) + const pilotages = a_envoyer.filter(ev => ev.type === 'pilotage') + expect(pilotages.length).toBe(2) + pilotages.forEach(ev => { + expect(ev.agent_id).toBe('principal') + expect(ev.demande_ids).toEqual(['dem-o']) + }) + }) + +test('un diff rejeté retire le gain', + { options: { jeton: 'x' } }, async ($, on) => { + const toasts: string[] = [] + const store = new Map() + on('store.get', (_, e) => ({ value: store.get(e.key) })) + on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined })) + on('ui.status', (_, e) => ({ value: undefined })) + on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined })) + on('session.id', () => ({ value: 's1' })) + on('clock.now', () => ({ value: 0 })) + on('command.register', () => ({ value: { command: 'miam-economies' } })) + on('session.start', (_, e) => ({ cwd: e.cwd })) + on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } })) + on('clock.every', () => new Promise(() => {})) + on('turn.step', reponseModele) + on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } })) + on('tool.call', (_, e) => + e.tool === OUTIL + ? { result: {}, text: '{"ok":null,"demande_id":"dem-o","etat":"en_file"}' } + : e.tool === SUIVRE + ? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o', tokens_cloud_entree: 9000, tokens_cloud_sortie: 1000, resume: 'r', diff: 'E:/x/d.diff' }) } + : e.tool === REJETER + ? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o' }) } + : { result: {}, text: '{}' }, + ) + + await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true }) + await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), moteur: 'opencode', tool_use_id: 'u1' } as never) + await requete($) + await $.tool.call({ tool: SUIVRE, demande_id: 'dem-o', tool_use_id: 'u2' } as never) + await requete($) + await $.tool.call({ tool: REJETER, demande_id: 'dem-o', tool_use_id: 'u3' } as never) + await requete($, { outils: 0, usage: USAGE }) + + const cumul = store.get('cumul') as { delegations: number; evites: number; pilotage: number } + expect(cumul.delegations).toBe(0) + expect(cumul.evites).toBe(0) + expect(cumul.pilotage).toBe(3 * COUT) // le pilotage d'un diff rejeté reste dû + + expect(toasts.at(-1)).toContain('rejeté') + }) + +test('une délégation locale reste sans pilotage', + { options: { jeton: 'x' } }, async ($, on) => { + const toasts: string[] = [] + const store = new Map() + on('store.get', (_, e) => ({ value: store.get(e.key) })) + on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined })) + on('ui.status', (_, e) => ({ value: undefined })) + on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined })) + on('session.id', () => ({ value: 's1' })) + on('clock.now', () => ({ value: 0 })) + on('command.register', () => ({ value: { command: 'miam-economies' } })) + on('session.start', (_, e) => ({ cwd: e.cwd })) + on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } })) + on('clock.every', () => new Promise(() => {})) + on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } })) + on('tool.call', (_, e) => + e.tool === OUTIL + ? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-l', tokens_entree: 200, tokens_sortie: 50 }) } + : { result: {}, text: '{}' }, + ) + + await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true }) + await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), tool_use_id: 'u1' } as never) + + const cumul = store.get('cumul') as { pilotage: number } + expect(cumul.pilotage).toBe(0) + }) + +test('pilotage réel : prorata, relecture du diff, requête perdue, et rien pour une requête sans suivi', + { options: { jeton: 'x' } }, async ($, on) => { + const store = new Map() + on('store.get', (_, e) => ({ value: store.get(e.key) })) + on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined })) + on('ui.status', () => ({ value: undefined })) + on('ui.toast', () => ({ value: undefined })) + on('session.id', () => ({ value: 's1' })) + on('clock.now', () => ({ value: 0 })) + on('command.register', () => ({ value: { command: 'miam-economies' } })) + on('session.start', (_, e) => ({ cwd: e.cwd })) + on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } })) + on('clock.every', () => new Promise(() => {})) + on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } })) + on('turn.step', reponseModele) + on('tool.call', (_, e) => + e.tool === OUTIL + ? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o', tokens_cloud_entree: 900, tokens_cloud_sortie: 100, diff: 'E:/x/d.diff' }) } + : { result: {}, text: 'contenu' }, + ) + const pilotage = () => (store.get('cumul') as { pilotage: number }).pilotage + + await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true }) + await requete($, { outils: 2, usage: USAGE }) // deux outils : deleguer et un autre + await $.tool.call({ tool: OUTIL, consigne: 'c', moteur: 'opencode', tool_use_id: 'u1' } as never) + await requete($, { outils: 1, usage: null }) // perdue : l'imputation attend la suivante + expect(pilotage()).toBe(0) + await requete($) // moitié de la requête : l'autre outil n'est pas du pilotage + expect(pilotage()).toBe(50 + 25 + 500) + + await $.tool.call({ tool: 'Read', file_path: 'E:/x/d.diff', tool_use_id: 'u2' } as never) + await requete($) + expect(pilotage()).toBe(50 + 25 + 500 + COUT) + expect((store.get('cumul') as { corrections: number }).corrections).toBe(0) + + await requete($) // sans appel de suivi : rien + expect(pilotage()).toBe(50 + 25 + 500 + COUT) + }) diff --git a/plugins/miam/hooks/register.tsx b/plugins/miam/hooks/register.tsx index 3b7e93d..f686545 100644 --- a/plugins/miam/hooks/register.tsx +++ b/plugins/miam/hooks/register.tsx @@ -2,14 +2,15 @@ import type { EngineInterface, Register, SessionRateLimit } from 'claude-code' import { aGarder, ajouter, cheminNormal, cible, court, cout, estimer, evenementCorrection, evenementDelegation, - evenementPilotage, ligne, lireResultat, lot, mesurerCorrection, net, pastille, retirer, VIDE, + evenementPilotage, ligne, lireResultat, lot, mesurerCorrection, net, pastille, repartir, retirer, VIDE, } from './economies' -import type { Evenement, Totaux } from './economies' +import type { Evenement, Suivi, Totaux } from './economies' import { lectureTropLongue, nomSortie, PLAGE_MAX, sortieTropLongue } from './contrainte' import { fenetres, ligneFenetre } from './usage' // Outils de MIAM : serveur du plugin (mcp__plugin_miam_miam__…) ou serveur de projet nommé miam (mcp__miam__…). -const OUTIL = /^mcp__(?:plugin_miam_)?miam__(deleguer|suivre)$/ +const OUTIL = /^mcp__(?:plugin_miam_)?miam__(deleguer|suivre|autoriser|appliquer|rejeter|annuler)$/ +const PRINCIPAL = 'principal' // agent_id du pilotage d'OpenCode par l'agent principal // Outils par lesquels l'agent principal relit ou retouche un fichier écrit par une délégation. const REPRISE = /^(Read|Edit|Write|NotebookEdit|Bash|PowerShell)$/ const SHELL = /^(Bash|PowerShell)$/ // sorties retenues quand elles sont longues @@ -109,6 +110,9 @@ export const register: Register = (on, options) => { const comptees = new Set() // demande_id déjà comptées (suivre peut renvoyer deux fois un résultat) const subagents = new Map() // boucles qui ont délégué → leurs demande_id ; leur tour = pilotage const sorties = new Map() // fichier écrit par une délégation (chemin normalisé) → demande_id + const opencode = new Map() // délégations à OpenCode de cette session + let suivis: Suivi[] = [] // appels de suivi d'OpenCode à imputer à la prochaine requête + let appels = 0 // outils lancés par la dernière requête de l'agent principal 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 @@ -152,7 +156,8 @@ export const register: Register = (on, options) => { `${court(cumul.pilotage)}, corrections ${court(cumul.corrections)}, net ~${court(net(cumul))}.`, attente.length ? `${attente.length} événement(s) en attente d'envoi à MIAM.` : 'Tout est envoyé à MIAM.', 'Estimation : fichiers lus + réponse écrite par le modèle local, moins la consigne et ce que Claude relit ; ' + - 'pilotage = consommation du subagent (lecture en cache comptée au dixième) ; corrections = ce que ' + + 'OpenCode : tokens du cloud moins la consigne. Pilotage = consommation du subagent (lecture en cache comptée ' + + 'au dixième) et, pour OpenCode, usage réel des requêtes de suivi, contexte relu compris ; corrections = ce que ' + "l'agent principal relit ou retouche dans les fichiers écrits par MIAM.", ].join('\n'), } @@ -198,6 +203,10 @@ export const register: Register = (on, options) => { dernierRappel = maintenant return { ...ran, context: [...(ran.context ?? []), RAPPEL] } } + if (opencode.has(demandeId)) { + suivis.push({ demandeId, toolUseId: e.tool_use_id ?? `reprise-${++reprises}` }) // relecture du diff : pilotage + return ran + } const correction = mesurerCorrection(e.tool, args, typeof ran.text === 'string' ? ran.text : undefined) if (!correction.lus && !correction.ecrits) { return ran @@ -214,7 +223,7 @@ export const register: Register = (on, options) => { if (ran.deny !== undefined || ran.isError) { return ran } - const args = e as unknown as { consigne?: string; retour?: string; demande_id?: string } + const args = e as unknown as { consigne?: string; retour?: string; demande_id?: string; moteur?: string } let texte: unknown try { texte = typeof ran.text === 'string' ? JSON.parse(ran.text) : ran.result @@ -222,6 +231,25 @@ export const register: Register = (on, options) => { return ran } const brut = texte as { ok?: unknown; demande_id?: string } + const id = args.demande_id ?? brut?.demande_id + if (outil === 'deleguer' && args.moteur === 'opencode' && id) { + opencode.set(id, { pilotage: 0, evites: 0 }) + } + const suivi = id ? opencode.get(id) : undefined + // Pilotage d'OpenCode par l'agent principal, compté à la requête qui lira ce résultat (turn.step) ; celui d'un + // subagent se mesure à la fin de son tour. + if (id && suivi && e.agentId === undefined) { + suivis.push({ demandeId: id, toolUseId: e.tool_use_id ?? `suivi-${++reprises}` }) + } + // Un diff rejeté n'évite rien (MIAM fait de même dans son bilan). + if (outil === 'rejeter' && brut?.ok === true && suivi?.evites) { + await afficher($, ecran, { delegations: -1, evites: -suivi.evites }) + $.ui.toast(`MIAM : diff OpenCode rejeté, ~${court(suivi.evites)} tokens retirés du gain`) + suivi.evites = 0 + } + if (outil !== 'deleguer' && outil !== 'suivre') { + return ran + } const demande: Demande | undefined = outil === 'deleguer' ? { consigne: args.consigne ?? '', retour: args.retour, agentId: e.agentId } @@ -239,22 +267,58 @@ export const register: Register = (on, options) => { } comptees.add(r.demande_id) enFile.delete(r.demande_id) - if (r.sortie) { - sorties.set(cheminNormal(r.sortie), r.demande_id) + const fichier = r.sortie ?? r.diff + if (fichier) { + sorties.set(cheminNormal(fichier), r.demande_id) } if (demande.agentId) { subagents.set(demande.agentId, [...(subagents.get(demande.agentId) ?? []), r.demande_id]) } const est = estimer(demande.consigne, demande.retour, r, demande.agentId !== undefined) await afficher($, ecran, { delegations: 1, evites: est.evites }) - $.ui.toast(`${pastille(net(ecran.session))}MIAM : ~${court(est.evites)} tokens évités (${court(est.lus)} lus, ` + - `${court(est.ecrits)} écrits)`) + const pilote = opencode.get(r.demande_id) + if (pilote) { + pilote.evites = est.evites + $.ui.toast(`${pastille(net(ecran.session))}MIAM : ~${court(est.evites)} tokens évités par OpenCode ` + + `(${court(est.lus)} lus, ${court(est.ecrits)} écrits, pilotage ${court(pilote.pilotage)})`) + } else { + $.ui.toast(`${pastille(net(ecran.session))}MIAM : ~${court(est.evites)} tokens évités (${court(est.lus)} lus, ` + + `${court(est.ecrits)} écrits)`) + } const contexte = { sessionId: await $.session.id(), agentId: demande.agentId, retour: demande.retour } await mettreEnAttente($, [evenementDelegation({ ...contexte, maintenant: await $.clock.now() }, { ...r, demande_id: r.demande_id }, est)]) return ran }) + on('turn.step', async function* ($, e, next) { + if (e.agentId !== undefined) { + return yield* next(e) + } + const aImputer = suivis + suivis = [] + const r = yield* next(e) + if (!r.usage) { + suivis = [...aImputer, ...suivis] // requête perdue : la suivante relira ces résultats + return r + } + const nombre = appels + appels = r.toolUses.length + for (const part of repartir(aImputer, nombre, r.usage)) { + const ev = evenementPilotage({ + sessionId: await $.session.id(), agentId: PRINCIPAL, turnId: part.toolUseId, maintenant: await $.clock.now(), + demandeIds: [part.demandeId], + }, { ...part.usage, model: r.usage.model }) + const pilote = opencode.get(part.demandeId) + if (pilote) { + pilote.pilotage += ev.cout + } + await afficher($, ecran, { pilotage: ev.cout }) + await mettreEnAttente($, [ev]) + } + return r + }) + on('turn.complete', async ($, e, next) => { const demandeIds = e.agentId ? subagents.get(e.agentId) : undefined if (e.agentId && e.usage && demandeIds) { diff --git a/plugins/miam/skills/deleguer/SKILL.md b/plugins/miam/skills/deleguer/SKILL.md index b6537f9..92253b0 100644 --- a/plugins/miam/skills/deleguer/SKILL.md +++ b/plugins/miam/skills/deleguer/SKILL.md @@ -1,6 +1,6 @@ --- name: deleguer -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. +description: À utiliser par réflexe, sans attendre qu'on le demande, pour confier une tâche au modèle local de MIAM et économiser l'usage Claude. À consulter avant de lire un long log, une longue sortie pytest/ruff ou un long document pour en tirer peu, et avant d'écrire une traduction, des docstrings, des notes de version, un message de commit, une fonction pure et ses tests, ou des tests répétitifs. À consulter aussi pour confier à OpenCode, agent cloud, une tâche de code autonome et vérifiable qui touche plusieurs fichiers d'un projet autorisé. --- # Déléguer à MIAM @@ -38,6 +38,37 @@ Refus de `deleguer` : `aucun preset testé` → `demarrer(preset="${user_config. `deleguer` ; contexte dépassé → `modeles`, puis `preset` = le mieux noté dont le `contexte` suffit, sans `offload` si possible ; autre refus → le dire à l'utilisateur. +## OpenCode (`moteur="opencode"`) + +Agent cloud qui lit, modifie et teste dans un clone du projet. Le code part chez un fournisseur : c'est toujours un +choix explicite, jamais le moteur par défaut. + +**Quand** : une tâche de code bornée que le modèle local ne peut pas faire seul parce qu'elle demande d'explorer le +projet, de toucher plusieurs fichiers ou de lancer les tests (refactorisation mécanique, correction localisée, tests à +compléter), avec un résultat vérifiable, dans un projet autorisé par la config de MIAM. **Pas** : ce qu'un simple +`deleguer` local suffit à faire, la décision d'architecture, la sécurité, le code qui touche à des secrets. Projet +refusé : le dire à l'utilisateur, ne pas contourner. + +Le clone part de l'état commité : les modifications en cours en sont absentes. Commite-les ou signale-le avant. + +1. `deleguer(moteur="opencode", racine=, consigne=…)`, `modele` facultatif. Réponse « en file » : c'est normal, + la tâche dure ; +2. `suivre(demande_id, attente_s=45)` jusqu'au résultat, sans appels rapprochés : chaque appel coûte des tokens. `phase` + et `dernier_outil` montrent l'avancement ; +3. `etat: "permission"` : réponds vite, sans réponse à temps c'est un refus. Pour chaque entrée de `permissions` (`action`, + `ressources`, `expire_dans_s`), `autoriser(demande_id, permission_id, reponse)` : `oui` si l'action sert la tâche et + reste dans le clone, `toujours` pour la même action répétée (vaut pour cette délégation seulement), `non` sinon : + accès hors du clone, réseau, suppression, secrets. Un doute sérieux : `non`, et dis-le à l'utilisateur ; +4. résultat : `resume`, `fichiers`, `tests`, `diff` (chemin). Lis `fichiers` et `tests`, puis le diff (par `offset` et + `limit` s'il est long). Il n'est pas dans la réponse pour économiser tes tokens ; +5. **décide toujours** : `appliquer(demande_id)` s'il fait ce qui était demandé, sinon `rejeter(demande_id)`. Rien n'arrive + dans l'arbre de travail avant `appliquer`, et un clone non tranché reste sur le disque. `appliquer` refusé (arbre + changé entre-temps) : la réponse contient l'erreur, adapte à la main ou rejette ; +6. après `appliquer`, lance ruff et les tests du projet. + +`annuler(demande_id)` interrompt la tâche. Quota atteint : pas de bascule automatique, dis-le à l'utilisateur et choisis. +Ce suivi est du pilotage, déduit du gain ; un diff rejeté n'évite rien. + ## Ensuite Vérifie sans tout relire : `json_valide` et clés pour du JSON ; ruff et tests pour du code ; `tronque` vrai = réponse