This commit is contained in:
ange committed 2026-10-30 01:10:43 +01:00
1 parent 77da832578
commit cf81b0b31d
25 files changed
+2844 -1

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
{
"name": "miam-local",
"owner": {
"name": "A2M Systèmes"
},
"metadata": {
"description": "Plugins Claude Code pour MIAM (Mon IA Manager)"
},
"plugins": [
{
"name": "miam",
"source": "./plugins/miam",
"description": "Délégation de tâches à des modèles locaux gérés par MIAM"
}
]
}
+41 -1
View File
@@ -1 +1,41 @@
.mcp.json
# --- Secrets et réglages locaux ---------------------------------------------------------------------------------
# config MCP de projet avec le jeton MIAM en clair (celle du plugin, plugins/miam/.mcp.json, est versionnée)
/.mcp.json
.env
.env.*
# réglages Claude Code propres à ce poste
.claude/settings.local.json
# --- Fichiers écrits par Claude Code dans le plugin -------------------------------------------------------------
# types du mod (claude-code, claude-code-tools…), régénérés au chargement
.claude-plugin/types/
# bundles MCP décompressés
.mcpb-cache/
# --- Python (banc d'essai) --------------------------------------------------------------------------------------
__pycache__/
*.py[cod]
.venv/
.ruff_cache/
.pytest_cache/
# résultats d'un banc lancé à la main dans le dépôt (le skill écrit dans le dossier de données du plugin)
plugins/miam/banc/resultats/
plugins/miam/banc/rapport.md
plugins/miam/banc/choix.json
# --- Node / TypeScript (tests du mod) ---------------------------------------------------------------------------
node_modules/
*.tsbuildinfo
# --- Journaux, sauf les données fixes du banc -------------------------------------------------------------------
*.log
!plugins/miam/banc/fichiers/*.log
# --- Système et éditeurs ----------------------------------------------------------------------------------------
.DS_Store
Thumbs.db
desktop.ini
*~
*.swp
.idea/
.vscode/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 A2M Systèmes
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+108
View File
@@ -1,2 +1,110 @@
# plugin-claude-miam
Plugin Claude Code pour déléguer des tâches à un modèle local géré par MIAM (Mon IA Manager, orchestrateur llama.cpp), via son serveur MCP.
## Contenu
- Serveur MCP `miam` (http://127.0.0.1:<port>/mcp, jeton Bearer) : point d'entrée pour la communication avec le serveur MCP de MIAM.
- Subagent `miam:delegue` : agent dédié qui pilote la délégation des tâches vers MIAM.
- Skill `deleguer` : règles, pour l'agent principal, sur quand et comment déléguer une tâche à MIAM.
- Skill `/miam:banc` : banc d'essai des presets de MIAM pour évaluer les performances.
## Prérequis
MIAM 2.5.0 ou plus lancé avec son serveur MCP démarré (interrupteur du dashboard, menu « Serveur MCP » du tray, ou `--auto-start-mcp`) ; Python 3.12 pour le banc.
## Installation
Depuis le dépôt Git (Gitea), dans Claude Code :
```text
/plugin marketplace add https://<gitea>/<utilisateur>/plugin-claude-miam.git
/plugin install miam@miam-local
```
Dépôt privé : Claude Code clone avec les identifiants Git du poste, sans jamais les demander. En SSH
(`git@<gitea>:<utilisateur>/plugin-claude-miam.git`), l'hôte doit être dans `known_hosts` et la clé utilisable sans
saisie de phrase de passe (agent SSH) ; en HTTPS, le gestionnaire d'identifiants de Git. Depuis une copie locale :
`/plugin marketplace add E:\0000WorkTechnique\0002dev\plugin-claude-miam`.
Portée, choisie à l'installation (le terminal et l'extension VS Code partagent les mêmes réglages) :
- **pour toi (user)**, recommandé : le plugin sert dans tous les projets du poste (`~/.claude/settings.json`) ;
- **pour ce dépôt, tous les collaborateurs (project)** : `.claude/settings.json`, versionné ;
- **pour toi, dans ce dépôt seulement (local)** : `.claude/settings.local.json`.
La marketplace, le code du plugin et ses réglages (jeton, port…) sont toujours propres au compte : ajoutés une fois,
quelle que soit la portée.
Mises à jour : `version` de `plugins/miam/.claude-plugin/plugin.json` fige la version installée ; l'augmenter à chaque
publication, puis `/plugin marketplace update miam-local`.
Puis : à l'activation, Claude Code demande les réglages (voir section suivante) ; le jeton se copie depuis MIAM (« Copier la config Claude Code », la valeur après « Bearer »).
## Réglages
| Réglage | Défaut | Rôle |
| :--- | :--- | :--- |
| 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). |
| preset_defaut | pre-16312f8b | Preset MIAM par défaut, démarré quand aucun modèle n'est chargé et que le banc n'a pas encore de résultats. |
## Utilisation
L'agent principal lance le subagent miam:delegue ; celui-ci vérifie que MIAM répond (etat), démarre si aucun modèle n'est chargé le meilleur preset de la catégorie de la tâche d'après choix.json du banc (sinon le preset par défaut), ne remplace jamais un modèle déjà chargé sans demande, délègue, vérifie, et rend un compte rendu court.
## Tokens économisés
Après chaque délégation réussie, une notification (`MIAM : ~6,1k tokens évités (5,2k lus, 900 écrits)`) et une ligne
d'état sous le prompt (`MIAM ▸ 2 délégation(s) · ~6,1k tokens évités · pilotage 1,5k · corrections 600 · net ~4k
(cumul ~35k)`),
dans le terminal comme dans VS Code. `/miam-economies` donne le détail de la session et le cumul ; `/miam-economies raz`
remet les compteurs à zéro. Le calcul se fait localement dans Claude Code : il ne coûte aucun token.
Chaque estimation est aussi envoyée à MIAM (`POST /economies`, toutes les 5 s, hors des hooks ; gardée et renvoyée si
MIAM ne répond pas) : contrat et modèle de données dans [docs/token-summary-mod.md](docs/token-summary-mod.md).
- **Évités** : les fichiers lus et la réponse écrite par le modèle local (chiffres de MIAM), moins la consigne et ce que
Claude relit (estimés à 4 caractères par token).
- **Pilotage** : la consommation réelle du subagent `miam:delegue` (lecture en cache comptée au dixième), à déduire.
- **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.
- **Limites** : le brief de l'agent principal et la lecture du compte rendu ne sont pas comptés ; un token de Haiku coûte
moins qu'un token d'Opus, ce que le net ne pondère pas ; une reprise hors du fichier `sortie` n'est pas vue (d'où
l'intérêt de faire écrire les résultats par MIAM avec `sortie`).
## Banc d'essai
Tâches fixes reprises de llm-banc (catégories code, texte, rapide ; pas de vision), lancé par /miam:banc dans une fenêtre PowerShell externe, résultats rapport.md et choix.json dans le dossier de données du plugin, critère de choix (meilleure note par catégorie ; à moins de 0,02 d'écart, la vitesse en tok/s départage). Le script lit le jeton dans %LOCALAPPDATA%\MIAM\config.json ou la variable MIAM_JETON.
## Structure du dépôt
```text
.claude-plugin/
marketplace.json
plugins/
miam/
.claude-plugin/
plugin.json
.mcp.json
agents/
delegue.md
hooks/
hooks.json
register.ts # compteur de tokens économisés (ligne d'état, notifications, /miam-economies)
economies.ts # calcul pur, testé (claude plugin test plugins/miam)
*.test.ts # 35 tests
skills/
deleguer/
SKILL.md
banc/
SKILL.md
banc/
banc_miam.py
mcp_http.py
taches.py
verifier_controles.py
fichiers/
```
## Limites connues
- Si MIAM est fermé puis relancé, Claude Code ne se reconnecte pas seul (/mcp → Reconnect sur plugin:miam:miam).
- deleguer ne transmet que du texte (pas d'image).
- Un `.mcp.json` de projet qui déclare déjà le serveur `miam` (celui de ce dépôt, celui de MIAM) fait doublon avec le
plugin : le retirer une fois le plugin installé.
- Le modèle du subagent réglé par modele_subagent s'applique quand il est lancé via la skill deleguer (sinon : haiku, défaut du fichier de l'agent).
## Licence
MIT, voir [LICENSE](LICENSE). Les fichiers du banc (`plugins/miam/banc/`) reprennent ceux de llm-banc, du même auteur.
+216
View File
@@ -0,0 +1,216 @@
# Mod compteur de tokens : contrat avec MIAM (POST /economies)
Destinataire : l'agent Claude Code qui travaille dans le dépôt MIAM. Le côté plugin est fait et testé (dépôt
`plugin-claude-miam`, schéma 1) ; ce document décrit ce que MIAM doit recevoir, stocker et restituer.
## But
Le mod estime, sans aucun coût en tokens, ce que chaque délégation évite à Claude et le coût du pilotage (consommation du subagent `miam:delegue`). MIAM connaît les tokens du modèle local mais pas ce qui se passe côté Claude : ces événements complètent son bilan.
## Prérequis dans MIAM : `demande_id` dans le journal
`delegations.jsonl` ne contient pas aujourd'hui l'identifiant de la demande (`core/delegation.py`, `journal_entry`) :
la jointure est impossible sans lui. Ajouter un paramètre `demande_id` à `journal_entry` et le passer depuis
`McpService.deleguer` (réussites) et `_journal_failure` / `_on_request_finished` (refus, échecs, annulations,
expirations ; `None` pour un refus avant mise en file, qui n'a pas d'id). C'est le `req.id` de la file (`dem-…`), celui
que `deleguer` et `suivre` renvoient au client.
## Transport
POST http://127.0.0.1:<port>/economies (port = celui du serveur MCP, 8090 par défaut ; réglage `port` du plugin).
En-têtes : `Content-Type: application/json` et `Authorization: Bearer <jeton>` (le même jeton que `/mcp`).
Corps : `{ "evenements": [ ... ] }`, lot de 1 à 500 événements (`ATTENTE_MAX`).
Le mod ne poste jamais depuis un hook : chaque événement est d'abord mis en attente dans le stockage du plugin
(`$.store`, clé `a_envoyer`), puis une minuterie (toutes les 5 s, lancée au démarrage de la session) poste toute
l'attente en un lot. Une délégation n'attend donc jamais MIAM. Une seule requête à la fois.
Sans réponse ou statut à garder, le lot reste en attente et repart au tour suivant, avec ce qui est arrivé depuis (les
plus anciens d'abord, doublons d'id retirés, 500 au plus : au-delà, les plus anciens sont perdus). Conséquence : MIAM
reçoit souvent plusieurs fois le même événement, et doit répondre vite (pas de travail lourd dans la requête).
## Réponses attendues
| Statut | Quand | Effet côté mod |
| :--- | :--- | :--- |
| 200 (ou 204) | Lot reçu | Le mod vide son attente. |
| 400 ou 422 | Lot invalide | Le mod l'abandonne (pas de renvoi). |
| 401 | Jeton refusé | Gardé. |
| 404 | Route absente (MIAM ancien) | Gardé. |
| 413 | Corps trop gros | Gardé (500 événements font environ 300 Ko : rester au-dessus de 1 Mo). |
| 5xx / Connexion refusée | Erreur réseau | Gardé. |
Corps de réponse recommandé pour 200 : `{ "ok": true, "recus": n, "doublons": m }` (le mod ne le lit pas ; utile aux
tests et au débogage). Un lot est accepté ou refusé en entier : pas de 200 partiel avec des événements rejetés, sinon
le mod les croirait reçus.
## Modèle de données
### Événement delegation
| Champ | Type | Description |
| :--- | :--- | :--- |
| `schema` | `number` | Version du schéma (fixé à 1). |
| `type` | `string` | Valeur `"delegation"`. |
| `id` | `string` | Identifiant unique stable : `claude-code:<demande_id>`. |
| `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. |
| `demande_id` | `string` | Id MIAM de la demande (`dem-…`), tel que `deleguer` ou `suivre` l'a renvoyé. |
| `agent_id` | `string \| null` | Boucle Claude Code qui a appelé `deleguer` : id du subagent, `null` pour l'agent principal. |
| `par_subagent` | `boolean` | `agent_id !== null`. |
| `retour` | `string` | `"complet"` ou `"apercu"` (argument `retour` de `deleguer`). |
| `tokens_entree` | `integer` | Tokens lus par le modèle local, tels que `deleguer` les a renvoyés (= journal). |
| `tokens_sortie` | `integer` | Tokens écrits par le modèle local, idem. |
| `estimation` | `object` | `lus`, `ecrits`, `consigne`, `rendus`, `evites` (entiers, en tokens Claude) et `methode` (`"caracteres/4"`). |
Seules les délégations réussies (`ok: true`) produisent un événement ; une délégation « en file » n'en produit qu'un,
quand `suivre` rend son résultat.
**Calcul des estimations (fonction `estimer`) :**
* `consigne` : `Math.ceil(longueur_consigne / 4)`.
* `lus` : `Math.max(0, tokens_entree - consigne)`.
* `ecrits` : `tokens_sortie`.
* `rendus` : Si `retour === 'apercu'`, alors `Math.ceil(longueur_reponse / 4)`, sinon `ecrits`.
* `evites` : `lus + ecrits - (consigne + rendus)` (si pas de subagent) ou `lus + ecrits` (si subagent : la
consigne et la lecture de la réponse sont alors dans le coût du pilotage). Peut être négatif (consigne longue,
petite réponse relue en entier) : ne pas le borner.
### Événement pilotage
| Champ | Type | Description |
| :--- | :--- | :--- |
| `schema` | `number` | Version du schéma (fixé à 1). |
| `type` | `string` | Valeur `"pilotage"`. |
| `id` | `string` | Identifiant unique stable : `claude-code:<session_id>:<agent_id>:<turn_id>`. |
| `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). |
| `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`. |
| `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
n'est pas garanti).
### Événement correction
Pondère le gain par le travail de reprise : quand l'**agent principal** relit ou retouche un fichier écrit par une
délégation (champ `sortie` du résultat de `deleguer`), le coût de cet appel d'outil est déduit. Les reprises faites
par le subagent ne produisent pas d'événement : elles sont déjà dans son `pilotage`. Une délégation sans `sortie`
(réponse rendue dans la conversation) n'a pas de correction mesurable.
| Champ | Type | Description |
| :--- | :--- | :--- |
| `schema` | `number` | 1. |
| `type` | `string` | `"correction"`. |
| `id` | `string` | `claude-code:<session_id>:<tool_use_id>` (unique par appel d'outil). |
| `date` | `string` | ISO 8601 de l'appel. |
| `client` | `string` | `"claude-code"`. |
| `session_id` | `string` | Session Claude Code. |
| `demande_id` | `string` | Délégation dont la sortie est reprise. |
| `outil` | `string` | `Read`, `Edit`, `Write`, `NotebookEdit` ou `Bash`. |
| `tool_use_id` | `string` | Id de l'appel d'outil (`reprise-<n>` si Claude Code n'en a pas donné). |
| `tokens_lus` | `integer` | Ce que Claude lit : résultat d'un `Read` (ou d'une commande `Bash`). |
| `tokens_ecrits` | `integer` | Ce que Claude écrit : `old_string` + `new_string` d'un `Edit`, contenu d'un `Write`, `new_source` d'un `NotebookEdit`, commande `Bash`. |
| `cout` | `integer` | `tokens_lus + tokens_ecrits`, à déduire des `evites` de `demande_id`. |
Rattachement (fonction `cible`) : chemin du fichier de l'appel égal à la `sortie`, après normalisation (`\` → `/`,
minuscules) ; pour `Bash`, la commande contient le chemin, ou le nom du fichier s'il fait au moins 6 caractères
(heuristique : une commande qui cite ce nom sans toucher au fichier est comptée aussi). Un appel qui ne lit ni n'écrit
rien (coût 0) n'est pas envoyé.
**Gain net** = somme des `estimation.evites` − somme des `cout` de `pilotage` − somme des `cout` de `correction`.
Le taux de reprise d'une délégation (`cout` de ses corrections / ses `evites`) mesure la qualité réelle du modèle
local sur la tâche : c'est la donnée la plus utile pour classer les presets avec le banc.
**Calcul du coût (fonction `cout`) :**
`input_tokens + cache_creation_input_tokens + output_tokens + Math.round(cache_read_input_tokens / 10)`.
**Exemple** : une délégation du subagent `a1` (consigne de 400 caractères, réponse complète), puis la fin de son
tour. Ce sont les valeurs du test `hooks/register.test.ts`, produites par le code du mod.
```json
{
"evenements": [
{
"schema": 1,
"type": "delegation",
"id": "claude-code:dem-9c8a4d0189",
"date": "2026-10-30T09:12:04.512Z",
"client": "claude-code",
"session_id": "c009a419-e17b-4036-9531-ac29eaa481d4",
"demande_id": "dem-9c8a4d0189",
"agent_id": "a1",
"par_subagent": true,
"retour": "complet",
"tokens_entree": 4100,
"tokens_sortie": 900,
"estimation": { "lus": 4000, "ecrits": 900, "consigne": 100, "rendus": 900, "evites": 4900,
"methode": "caracteres/4" }
},
{
"schema": 1,
"type": "pilotage",
"id": "claude-code:c009a419-e17b-4036-9531-ac29eaa481d4:a1:t",
"date": "2026-10-30T09:12:09.031Z",
"client": "claude-code",
"session_id": "c009a419-e17b-4036-9531-ac29eaa481d4",
"agent_id": "a1",
"turn_id": "t",
"demande_ids": ["dem-9c8a4d0189"],
"modele": "claude-haiku-5-5",
"usage": { "input_tokens": 800, "output_tokens": 200, "cache_read_input_tokens": 10000,
"cache_creation_input_tokens": 0 },
"cout": 2000
}
]
}
```
Lecture : 4 900 tokens évités, 2 000 de pilotage, gain net 2 900.
## Idempotence et jointure
`id` est unique et stable (`delegation` : `claude-code:<demande_id>` ; `pilotage` : `claude-code:<session_id>:<agent_id>:<turn_id>` ; `correction` : `claude-code:<session_id>:<tool_use_id>`).
Un même événement peut arriver plusieurs fois → MIAM doit ignorer un id déjà reçu et le compter dans « doublons ».
Jointure avec `delegations.jsonl` par `demande_id` ; un `pilotage` couvre la liste `demande_ids`, une `correction`
porte un seul `demande_id` (une délégation peut en avoir plusieurs).
Un `pilotage` ou une `correction` peut arriver sans que sa délégation ait été reçue (ou l'inverse) : accepter dans
tous les ordres.
## À implémenter dans MIAM
0. `demande_id` dans `delegations.jsonl` (voir « Prérequis »).
1. Route Starlette POST `/economies` montée sur la même application que `/mcp` (`McpService.http_app`), derrière
`BearerAuth` (401 sans jeton valide, comme `/mcp`). Bind `127.0.0.1` inchangé.
2. Validation stricte, en cœur pur (`core/`, sans I/O) : `evenements` liste de 1 à 500 ; pour chaque événement
`schema == 1`, `type` connu, champs requis présents et du bon type (entiers ≥ 0 pour les compteurs, sauf
`estimation.evites` qui peut être négatif ; `agent_id` chaîne ou `null` pour `delegation`) ; champs inconnus
ignorés (le mod pourra en ajouter sans changer de schéma). Un seul événement invalide → 422 pour tout le lot, avec
l'index et le champ fautifs dans le corps.
3. Stockage : ajout dans `logs/mcp/economies.jsonl` (même dossier que `delegations.jsonl`), une ligne par événement
nouveau : l'événement tel que reçu, plus `recu_le` (ISO). Ensemble des `id` déjà vus en mémoire, rechargé depuis
le fichier au démarrage du service ; un doublon n'est pas réécrit.
4. Outil `bilan` enrichi d'une section `economies`, par `client` : `delegations` (événements `delegation`),
`evites` (somme de `estimation.evites`), `pilotage` (somme des `cout` de `pilotage`), `corrections` (somme des
`cout` de `correction`), `net = evites - pilotage - corrections`, et `taux_reprise = corrections / evites`
(`null` si `evites` ≤ 0). Fichier absent : section à zéro. La jointure avec `delegations.jsonl` par `demande_id` permet ensuite le détail par preset ou par
format (utile au choix des presets par le banc).
5. Tests : unitaires sur la validation (chaque champ requis, types, lot vide, 501 événements, `evites` négatif accepté)
et sur le calcul du bilan (doublons, pilotage reçu avant sa délégation) ; intégration HTTP réelle comme les tests
MCP existants (401 sans jeton, 200 avec `recus`/`doublons`, renvoi du même lot → tout en doublons, 422, 413).
6. Taille maximale du corps : 1 Mo → 413.
7. Optionnel : afficher le net du jour sur la carte MCP du dashboard.
Hors périmètre : rien n'est à changer dans les outils MCP existants (le mod lit leurs réponses telles quelles).
## Limites de l'estimation
Brief de l'agent principal et lecture du compte rendu non comptés.
Coût d'un token Haiku ≠ Opus, non pondéré ; un token écrit coûte plus qu'un token lu, non pondéré non plus (les
événements gardent lus et écrits séparés : MIAM peut pondérer).
Estimation à 4 caractères par token.
Corrections : seulement sur les fichiers `sortie`, et seulement pendant la session qui a délégué (la table des sorties
n'est pas gardée d'une session à l'autre) ; une reprise ailleurs (texte de la réponse recopié dans un autre fichier)
n'est pas vue ; l'heuristique `Bash` peut compter une commande qui ne fait que citer le fichier.
## Côté plugin
Fichiers `plugins/miam/hooks/economies.ts` (calcul et format, testés) et `register.ts` (hooks : `tool.call` sur `deleguer` et `suivre` puis sur les reprises `Read`/`Edit`/`Write`/`NotebookEdit`/`Bash`, `turn.complete`, commande `/miam-economies`).
L'envoi passe par `$.http.fetch` de Claude Code, sans modèle, donc sans token ; le jeton et le port viennent des réglages du plugin.
Format et calcul : `economies.ts` fait foi (`EvenementDelegation`, `EvenementPilotage`, `EvenementCorrection`,
`estimer`, `cout`, `cible`, `mesurerCorrection`) ; toute évolution du format change `SCHEMA` et ce document.
Tests : `claude plugin test plugins/miam` (35 tests).
+41
View File
@@ -0,0 +1,41 @@
{
"name": "miam",
"displayName": "MIAM",
"version": "0.1.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, banc d'essai des presets",
"author": {
"name": "Ange-Marie MAURIN"
},
"license": "MIT",
"keywords": ["miam", "llama.cpp", "local", "delegation", "mcp"],
"userConfig": {
"jeton": {
"type": "string",
"title": "Jeton du serveur MCP de MIAM",
"description": "Jeton Bearer : dans MIAM, « Copier la config Claude Code », puis la valeur après « Bearer ». Rangé dans le coffre d'identifiants.",
"required": true,
"sensitive": true
},
"port": {
"type": "number",
"title": "Port du serveur MCP",
"description": "Port de MIAM sur 127.0.0.1 (section mcp de %LOCALAPPDATA%\\MIAM\\config.json)",
"default": 8090,
"min": 1,
"max": 65535
},
"modele_subagent": {
"type": "string",
"title": "Modèle Claude du subagent",
"description": "Modèle qui pilote MIAM pour l'agent principal (rédige la consigne, attend, vérifie)",
"options": ["haiku", "sonnet", "opus", "fable"],
"default": "haiku"
},
"preset_defaut": {
"type": "string",
"title": "Preset MIAM par défaut",
"description": "Preset démarré quand aucun modèle n'est chargé et que le banc n'a pas encore de résultats (id ou nom, voir l'outil presets)",
"default": "pre-16312f8b"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"miam": {
"type": "http",
"url": "http://127.0.0.1:${user_config.port}/mcp",
"headers": {
"Authorization": "Bearer ${user_config.jeton}"
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ange-Marie MAURIN
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+72
View File
@@ -0,0 +1,72 @@
---
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.
model: haiku
effort: low
color: orange
tools: mcp__plugin_miam_miam__*, Read, Grep, Glob, Bash
---
Tu pilotes MIAM pour l'agent principal : tu reçois une tâche, tu la confies au modèle local par l'outil `deleguer`,
tu vérifies le résultat et tu rends un compte rendu court. Tu ne fais pas la tâche toi-même : ton rôle est d'économiser
les tokens de Claude. Le modèle local ne voit que ta consigne et les fichiers : il ne connaît ni la conversation ni le
projet.
## 1. MIAM répond-il ?
Appelle `etat`. Si l'outil est absent ou échoue (connexion refusée, 401, serveur introuvable), arrête-toi et rends :
« MIAM injoignable : lancer MIAM et son serveur MCP (interrupteur du dashboard ou menu du tray), puis `/mcp` →
Reconnect sur `plugin:miam:miam`. Sur 401 : jeton à ressaisir dans `/plugin` → miam → configurer. » Ne tente pas de
faire la tâche à la place du modèle local.
## 2. Un modèle est-il chargé ?
- **Un modèle est chargé** : utilise-le. Ne le remplace **jamais** (`remplacer=true`) sans que l'agent principal l'ait
demandé explicitement.
- **Aucun modèle** : choisis le preset dans `${CLAUDE_PLUGIN_DATA}/banc/choix.json` (lis-le avec Read) : entrée
`categories.<catégorie>.preset`, catégorie selon la tâche :
- `code` : fonction, tests, script, SVG ;
- `texte` : traduction, résumé court, notes de version, docstrings, commit, JSON à rédiger ;
- `rapide` : lire beaucoup pour rendre peu (log, sortie de tests, questions sur un long document).
Sans ce fichier (banc pas encore lancé), prends le preset par défaut `${user_config.preset_defaut}`.
Appelle `demarrer(preset=…)`. Le chargement prend 20 à 40 s : si la réponse est « en file » (`ok` absent ou nul,
avec `demande_id`), appelle `suivre(demande_id, attente_s=45)` jusqu'à la fin. Un refus (Unreal ouvert, preset
inconnu) : rends-le tel quel.
- Si `choix.json` montre que le modèle chargé est faible pour la catégorie (note < `seuil`), délègue quand même et
signale-le dans le compte rendu, avec le preset conseillé.
## 3. Déléguer
Écris une consigne **autonome et précise** : ce qu'il faut produire, les contraintes (signature, langue, format exact,
ce qu'il ne faut pas faire), les cas limites. Une consigne vague donne un résultat brouillon.
Appelle `deleguer` :
- `consigne` ; `fichiers` : chemins **absolus** (ou relatifs avec `racine` = dossier du projet), `chemin:debut-fin`
pour une partie ; MIAM les lit, toi non ;
- `format` : `json` pour des faits extraits (validité vérifiée par MIAM, `json_valide`), `code` pour du code (balises
``` retirées), sinon `texte` ;
- `sortie` (+ `retour="apercu"`) quand la réponse est longue ou doit finir dans un fichier ; `ecraser=true` seulement
si l'agent principal l'a dit ;
- `reflexion` : `off` par défaut ; `bas`/`moyen`/`haut` seulement si la tâche demande du raisonnement.
Réponse « en file » : `suivre(demande_id, attente_s=45)` jusqu'au résultat. Pour abandonner : `annuler(demande_id)`.
## 4. Vérifier
Toujours, avant de rendre :
- `json` : `json_valide` vrai, et les clés demandées sont là ;
- `code` : `ruff check` **et** `ruff format --check` sur le fichier, puis les tests indiqués par l'agent principal
(pytest…) ; relis le code (hésitations en commentaires, cas oubliés) ;
- `texte` : relis-le face à la consigne (langue, longueur, rien d'inventé) ;
- `tronque` vrai : la réponse est coupée, à dire.
Un échec : **une** nouvelle délégation avec une consigne corrigée (dis au modèle ce qui n'allait pas). Encore raté :
rends l'échec, n'essaie pas de corriger toi-même un gros résultat.
## 5. Compte rendu
Court, pour l'agent principal :
- statut (réussi / raté / partiel) et vérifications faites (avec leur résultat) ;
- modèle utilisé, durée, tokens lus/écrits (champs de la réponse de `deleguer`) ;
- le résultat : en entier s'il est court, sinon le chemin de `sortie` et ce qu'il contient ;
- limites ou points à relire.
+184
View File
@@ -0,0 +1,184 @@
"""Banc d'essai des presets de MIAM : les tâches fixes de llm-banc, passées par la vraie chaîne de délégation.
Usage (dans une fenêtre PowerShell à part : plusieurs minutes par preset) :
python banc_miam.py --sortie <dossier> tous les presets validés
python banc_miam.py --sortie <dossier> pre-16312f8b ... seulement ceux-là (id ou nom)
python banc_miam.py --sortie <dossier> --categorie code une catégorie (code, texte, rapide)
Pour chaque preset : `demarrer(remplacer=True)`, puis chaque tâche par `deleguer` (fichiers passés par chemin, lus par
MIAM), notée par les contrôles de taches.py. Pas de vision : `deleguer` ne transmet que du texte. MIAM applique ses
règles du poste à chaque démarrage (ComfyUI vidé, refus si Unreal est ouvert). Le modèle chargé au départ est remis à
la fin (sauf --sans-restaurer).
Écrit : <sortie>/<date-heure>/<preset>.json (+ reponses/), puis, à partir du dernier essai de chaque preset,
<sortie>/rapport.md et <sortie>/choix.json (meilleur preset par catégorie, lu par le subagent miam:delegue).
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
from pathlib import Path
from mcp_http import ClientMcp, ErreurMcp, jeton_miam
from taches import FICHIERS, TACHES, Tache
CATEGORIES = ("code", "texte", "rapide")
MAX_TOKENS = 3000
SEUIL = 0.8 # note à partir de laquelle une tâche est réussie
EGALITE = 0.02 # écart de note sous lequel la vitesse départage
LU, ECRIT = 1, 5 # unités de coût de Claude (cf. llm-banc/rapport.py)
def attendre(client: ClientMcp, reponse: dict) -> dict:
"""Suit une demande « en file » jusqu'à sa fin (ok vrai ou faux)."""
while reponse.get("ok") is None and reponse.get("demande_id"):
reponse = client.outil("suivre", demande_id=reponse["demande_id"], attente_s=45)
return reponse
def essayer(client: ClientMcp, tache: Tache) -> tuple[dict, str]:
fichiers = [str(FICHIERS / tache.fichier)] if tache.fichier else None
r = attendre(client, client.outil("deleguer", consigne=tache.consigne, fichiers=fichiers, format="texte",
max_tokens=MAX_TOKENS))
base = {"tache": tache.id, "categorie": tache.categorie, "nature": tache.nature, "a_juger": tache.a_juger}
if not r.get("ok"):
return base | {"note": 0.0, "detail": f"erreur : {r.get('erreur') or r.get('raison') or r}"}, ""
texte = r.get("reponse") or ""
note, detail = tache.controle(texte) if texte.strip() else (0.0, "aucune réponse")
duree, sortie = r.get("duree_s") or 0, r.get("tokens_sortie") or 0
return base | {"note": round(note, 3), "detail": detail, "duree_s": duree,
"tokens_entree": r.get("tokens_entree"), "tokens_sortie": sortie,
"tok_s": round(sortie / duree, 1) if duree else None, # prompt compris : sous-estime l'écriture
"limite_atteinte": bool(r.get("tronque"))}, texte
def gain(t: dict) -> tuple[float, float]:
"""(coût sans déléguer, coût en déléguant) ; une tâche ratée est refaite par Claude (cf. llm-banc)."""
entree, sortie = t.get("tokens_entree") or 0, t.get("tokens_sortie") or 0
if t["nature"] == "lecture":
sans, avec = entree * LU + sortie * ECRIT, 50 * ECRIT + sortie * LU
else:
sans, avec = sortie * ECRIT, entree * ECRIT + sortie * LU
return sans, avec + (sans if t["note"] < SEUIL else 0)
def resume(r: dict) -> dict:
notes = {c: [t["note"] for t in r["taches"] if t["categorie"] == c] for c in CATEGORIES}
vitesses = [t["tok_s"] for t in r["taches"] if t.get("tok_s")]
couts = [gain(t) for t in r["taches"]]
sans, avec = sum(c[0] for c in couts), sum(c[1] for c in couts)
toutes = [n for ns in notes.values() for n in ns]
return {"nom": r["preset"], "modele": r.get("modele"), "date": r["date"],
"notes": {c: round(sum(ns) / len(ns), 3) for c, ns in notes.items() if ns},
"moyenne": round(sum(toutes) / max(len(toutes), 1), 3),
"reussies": f"{sum(n >= SEUIL for n in toutes)}/{len(toutes)}",
"tok_s": round(sum(vitesses) / len(vitesses), 1) if vitesses else None,
"vram_mo": r.get("vram_mo"), "chargement_s": r.get("chargement_s"),
"gain": round((sans - avec) / sans, 2) if sans else None}
def derniers(sortie: Path) -> dict[str, dict]:
"""Dernier essai de chaque preset, tous dossiers d'essai confondus."""
retenus: dict[str, dict] = {}
for f in sorted(sortie.glob("*/*.json")):
r = json.loads(f.read_text(encoding="utf-8"))
if "erreur" not in r and r.get("taches") and r["date"] >= retenus.get(r["id"], {}).get("date", ""):
retenus[r["id"]] = r
return retenus
def choisir(resumes: dict[str, dict]) -> dict:
categories = {}
for c in CATEGORIES:
candidats = [(i, s) for i, s in resumes.items() if c in s["notes"]]
if not candidats:
continue
meilleure = max(s["notes"][c] for _, s in candidats)
proches = [(i, s) for i, s in candidats if s["notes"][c] >= meilleure - EGALITE]
i, s = max(proches, key=lambda p: p[1]["tok_s"] or 0)
categories[c] = {"preset": i, "nom": s["nom"], "note": s["notes"][c], "tok_s": s["tok_s"]}
return {"date": datetime.datetime.now().isoformat(timespec="seconds"), "seuil": SEUIL, "categories": categories,
"presets": resumes}
def rapport(choix: dict) -> str:
lignes = ["# Banc d'essai des presets de MIAM\n",
("Tâches fixes de llm-banc passées par `deleguer` (sans vision). Note de 0 à 1 ; dernier essai de chaque "
"preset. tok/s : tokens écrits / durée de l'appel (lecture du prompt comprise).\n"),
"## Choix par catégorie\n", "| Catégorie | Preset | Note | tok/s |", "|---|---|---|---|"]
lignes += [f"| {c} | {v['nom']} (`{v['preset']}`) | {v['note']:.2f} | {v['tok_s']} |"
for c, v in choix["categories"].items()]
lignes += ["\n## Presets\n", "| Preset | " + " | ".join(CATEGORIES) + " | Moyenne | Réussies | tok/s | VRAM | "
"Chargement | Gain | Essai |", "|---|" + "---|" * (len(CATEGORIES) + 7)]
for i, s in choix["presets"].items():
notes = [f"{s['notes'][c]:.2f}" if c in s["notes"] else "—" for c in CATEGORIES]
vram = f"{s['vram_mo'] / 1024:.1f} Go" if s.get("vram_mo") else "?"
gain_ = f"{s['gain']:+.0%}" if s.get("gain") is not None else "?"
lignes.append(f"| {s['nom']} (`{i}`) | " + " | ".join(notes) + f" | **{s['moyenne']:.2f}** | {s['reussies']} "
f"| {s['tok_s']} | {vram} | {s.get('chargement_s') or '—'} s | {gain_} | {s['date'][:16]} |")
return "\n".join(lignes) + "\n"
def main() -> None:
parseur = argparse.ArgumentParser(description="Banc d'essai des presets de MIAM")
parseur.add_argument("presets", nargs="*", help="id ou nom (défaut : tous les presets validés)")
parseur.add_argument("--sortie", required=True, type=Path, help="dossier des résultats")
parseur.add_argument("--categorie", choices=CATEGORIES)
parseur.add_argument("--port", type=int, default=int(os.environ.get("MIAM_PORT", "8090")))
parseur.add_argument("--sans-restaurer", action="store_true", help="laisser chargé le dernier preset essayé")
options = parseur.parse_args()
client = ClientMcp(f"http://127.0.0.1:{options.port}/mcp", jeton_miam())
client.ouvrir()
valides = client.outil("presets")["presets"]
initial = client.outil("etat").get("instances") or []
initial_id = next((p["id"] for p in valides if initial and p["preset"] == initial[0]["preset"]), None)
demandes = [d.lower() for d in options.presets]
choisis = [p for p in valides if not demandes or p["id"].lower() in demandes or p["preset"].lower() in demandes]
if demandes and len(choisis) < len(demandes):
raise SystemExit(f"presets validés inconnus parmi : {', '.join(options.presets)}")
categories = {options.categorie} if options.categorie else set(CATEGORIES)
taches = [t for t in TACHES if t.categorie in categories]
essai = options.sortie / datetime.datetime.now().strftime("%Y-%m-%d_%H%M")
(essai / "reponses").mkdir(parents=True, exist_ok=True)
try:
for p in choisis:
print(f"\n=== {p['preset']} ({p['id']}) : {len(taches)} tâche(s)", flush=True)
resultat = {"id": p["id"], "preset": p["preset"], "modele": p["modele"],
"date": datetime.datetime.now().isoformat(timespec="seconds"), "taches": []}
fichier = essai / f"{p['id']}.json"
demarrage = attendre(client, client.outil("demarrer", preset=p["id"], remplacer=True))
if not demarrage.get("ok"):
print(f"✘ {demarrage.get('erreur') or demarrage.get('raison')}", flush=True)
resultat["erreur"] = str(demarrage.get("erreur") or demarrage.get("raison"))
fichier.write_text(json.dumps(resultat, ensure_ascii=False, indent=1), encoding="utf-8")
continue
vram = (client.outil("etat").get("memoire_graphique") or {}).get("utilisee_mo")
resultat |= {"chargement_s": demarrage.get("chargement_s"), "vram_mo": vram}
print(f"prêt ({demarrage.get('chargement_s', 0)} s), mémoire graphique : {vram} Mo", flush=True)
for tache in taches:
try:
note, texte = essayer(client, tache)
except ErreurMcp as e:
note, texte = {"tache": tache.id, "categorie": tache.categorie, "nature": tache.nature,
"note": 0.0, "detail": f"erreur : {e}"}, ""
print(f" {tache.id:<16} note {note['note']:.2f} {note['detail']}", flush=True)
(essai / "reponses" / f"{p['id']}__{tache.id}.txt").write_text(texte, encoding="utf-8")
resultat["taches"].append(note)
fichier.write_text(json.dumps(resultat, ensure_ascii=False, indent=1), encoding="utf-8")
finally:
if initial_id and not options.sans_restaurer and choisis and choisis[-1]["id"] != initial_id:
print(f"\nremise du preset de départ : {initial_id}", flush=True)
attendre(client, client.outil("demarrer", preset=initial_id, remplacer=True))
choix = choisir({i: resume(r) for i, r in derniers(options.sortie).items()})
(options.sortie / "choix.json").write_text(json.dumps(choix, ensure_ascii=False, indent=1), encoding="utf-8")
(options.sortie / "rapport.md").write_text(rapport(choix), encoding="utf-8")
print(f"\nfini : {essai}\nrapport : {options.sortie / 'rapport.md'}", flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,165 @@
## 9. Médias autonomes (proposé le 2026-10-21)
Les médias des contenus **sans repo** : articles du blog et fiches « page » (4.4). Les projets avec repo gardent les
leurs dans leur repo (images du README récupérées au tag, fichiers joints aux releases) ; rien ne change pour eux.
### 9.1 Où vivent les médias
| Lieu | Rôle |
|---|---|
| **NAS** : repo Gitea privé `ange/apc-mediastorage`, fichiers en **Git LFS** | **Source de vérité** : chaque fichier une seule fois, historique complet (qui, quand, versions précédentes). Jamais nettoyé. Accès par l'API Gitea, **sans clone** |
| **IONOS** : `~/www/media/{prod,preprod}/` | **Diffusion** de l'état courant ; reconstructible depuis le NAS. À côté de `~/www/prod` et `~/www/preprod`, jamais dedans : jamais touché par le déploiement (qui n'écrit que dans `www/<env>`) ni par la bascule |
| **Repo du site** | **Uniquement les `.md`**, avec des adresses `media:` ; jamais de média ni de fiche |
Le repo `apc-mediastorage` est privé, mais ses médias peuvent être publics : le plafond « repo privé ⇒ au plus
`members` » (3.2.1) ne s'applique qu'aux repos de projets.
Création (`a2m media init`, une seule fois) : la chaîne d'outils crée le repo **par l'API Gitea**, **privé**, vérifie
que LFS y est actif (sinon : arrêt, rien n'est envoyé), et y commite un `README.md` comme **seul fichier**. Aucun
`.gitattributes` (voir ci-dessous) ; le repo n'est jamais cloné, ni à la création ni ensuite. Un repo déjà existant
n'est pas recréé : la commande vérifie seulement qu'il est privé et que LFS y est actif.
Écriture dans `apc-mediastorage` (vérifiée avec Gitea 28.2 le 2026-10-21) : le fichier est envoyé par l'**API LFS**
(« batch »), puis la chaîne d'outils commite elle-même son **pointeur** par l'API « contents ». Le repo n'a **pas de
`.gitattributes` LFS** : avec une règle LFS, Gitea emballerait le pointeur une seconde fois. Gitea reconnaît les
pointeurs et sert les fichiers (`/media/<chemin>`) ; un fichier déjà présent (même `sha256`) n'est pas renvoyé. Le
repo ne se clone pas : un clone montrerait les pointeurs ; toute exportation passe par la chaîne d'outils.
Sur IONOS, dans `~/www/media/<env>/` :
| Dossier | Servi par | Adresse (production / préproduction) |
|---|---|---|
| `public/` | Apache, en statique : racine web du sous-domaine | `https://media.a2msystemes.org/<chemin>` / `https://media-preprod.a2msystemes.org/<chemin>` |
| `private/` | Laravel, après contrôle des droits (« Require all denied » pour Apache) | `https://a2msystemes.org/private/media/<chemin>` / `https://preprod.a2msystemes.org/private/media/<chemin>` |
| `.incoming/` | Personne : envois en cours | — |
Comme pour le site, chaque domaine pointe sur un dossier `…/public`, jamais plus haut.
### 9.2 Rangement : un contenu, ses propres médias
- **Dossier d'un contenu** : `blog/<translationKey>/` pour un article (ses versions FR et EN partagent les mêmes
médias), `pages/<id>/` pour une fiche « page ».
- Un contenu ne cite **que les médias de son dossier** : aucun partage entre contenus. Un fichier utile à deux
contenus est **dupliqué** dans chaque dossier.
- **Sur IONOS**, le chemin d'un média est toujours `<dossier du contenu>/<chemin relatif>` : une copie réelle par
contenu.
- **Sur le NAS**, chaque fichier n'existe **qu'une fois**, dans le dossier du premier contenu qui l'a ajouté. Un autre
contenu le **référence** dans sa fiche (`from` + `sha256` figé) ; aucun fichier n'est créé.
- **Fiche `media.toml`** dans chaque dossier de contenu ([schemas/media-v2.schema.json](schemas/media-v2.schema.json)) :
une entrée par média (fichier propre ou référence), avec sa visibilité, son `sha256`, `keep` (jamais nettoyé) et une
description FR/EN (texte alternatif).
- **Valeurs à l'ajout d'un média** (décision `upload` ou `reference`, §9.4) :
- visibilité : celle du **contenu qui l'ajoute** (identique dans ses versions FR et EN, règle 3.2.4) ; modifiable
ensuite (§9.7) ;
- description : `fr` et `en` reprennent le **texte alternatif** de l'image dans la version FR et dans la version EN
du contenu, quand il existe et n'est pas vide ; une langue sans texte alternatif est omise, et la description
entière si aucune n'en a. Rien n'est demandé à l'utilisateur.
- **Noms lisibles et normalisés** : minuscules, accents retirés, tout autre caractère que `[a-z0-9_.-]` remplacé par
`-` ; sous-dossiers conservés. Jamais de sha256 ni d'identifiant dans un nom. Deux fichiers différents qui donnent
le même nom : refus.
### 9.3 Détection dans le Markdown
Implémentation de référence : [tests/medias.py](tests/medias.py) ; exemples : [exemples/markdown/](exemples/markdown/).
- Analyse par un **analyseur CommonMark et un analyseur HTML**, jamais par recherche dans le texte brut : un lien
n'existe que là où l'analyseur en voit un. Le code (en ligne, bloc délimité, bloc indenté) ne contient jamais de lien.
- Liens examinés : images et liens Markdown (y compris les liens de référence), attributs HTML `img[src]`,
`video[src|poster]`, `source[src]`, `audio[src]`, `a[href]`, `iframe[src]`, champ d'en-tête `cover` (seul nom d'une
couverture, §4.5 ; ni `image` ni `coverImage`).
- La réécriture ne remplace **que le texte de l'adresse**, sans reformater le reste ; la relancer ne change rien.
- Adresse d'un média : **`media:<dossier du contenu>/<chemin>`**, sans version ni environnement. Le site la traduit au
build : `https://media.a2msystemes.org/<chemin>` (public) ou `/private/media/<chemin>` (privé), selon l'environnement.
### 9.4 Décisions
Chaque lien reçoit une décision (codes repris par les exemples) :
| Décision | Cas | Effet |
|---|---|---|
| `upload` | Chemin **relatif**, fichier existant, **dans** le dossier du contenu, contenu inconnu du NAS | Envoi au NAS, lien réécrit en `media:` |
| `reference` | Idem, mais un fichier de même `sha256` existe déjà sur le NAS | Référence dans la fiche, aucun envoi ; lien réécrit |
| `unchanged` | Même nom et même `sha256` qu'un média du contenu ; ou notre propre adresse d'un média du contenu | Rien à envoyer ; lien réécrit |
| `replace` | Même nom qu'un média du contenu, `sha256` différent | **Remplacement**, après confirmation ; lien réécrit |
| `duplicate` | **Notre propre adresse** (9.1) d'un média d'un autre contenu | Proposition de dupliquer (référence) ; avec `confirm = make-public` si le média est moins visible que le contenu (confirmation explicite, **non** par défaut) ; refus = erreur |
| `keep` | Adresse `media:` de son propre dossier, connue, assez visible | Inchangée (`available` : arrivée sur IONOS ou non) |
| `ignore` | `external` (tout protocole : `https:`, `mailto:`, `tel:`, `data:`… et `//…`, toute `iframe` externe), `site-route` (`/…`), `anchor` (`#…`), `page-link` (`.md`), `not-media` (chemin local absent sans extension de média) | Inchangé |
| `error` | `missing-file`, `absolute-path` (`C:\`, `C:/`), `network-path` (`\…`), `outside-folder` (`../`), `foreign-media` (`media:` d'un autre contenu), `local-iframe`, `visibility`, `unknown-media`, `name-collision`, `local-link` (build) | Rien n'est envoyé ni publié ; le message dit **quoi faire** (ex. « copiez le fichier dans le dossier du contenu : il ne sera pas dupliqué sur le NAS ») |
### 9.5 Versions et remplacement
- Les **versions** vivent sur le **NAS** (historique git et LFS) ; une ancienne version peut être renvoyée
(`a2m media restore`). **IONOS ne garde que le fichier courant**.
- **Remplacer** un média = même nom, `sha256` différent : nouveau commit sur le NAS (l'ancien fichier reste dans
l'historique), puis remplacement **atomique** sur IONOS (réception dans `.incoming/`, vérification, renommage).
- Une **référence** est figée par son `sha256` : si le contenu d'origine remplace son fichier, la référence garde
l'ancien contenu (retrouvé sur le NAS par son `sha256`). Remplacer une référence en fait un fichier propre.
### 9.6 Transfert, intégrité, contenus en attente
- Le transfert vers IONOS est **asynchrone** : file persistante sur le poste, worker en arrière-plan (démarrage
immédiat par défaut, ou à une heure donnée), envoi **par morceaux avec reprise**, débit limitable. Protocole :
[INTERFACE-MEDIAS.md](INTERFACE-MEDIAS.md).
- **Intégrité** : le `sha256` (= identifiant LFS) est vérifié par IONOS **avant** que le fichier soit servi ; un fichier
non conforme est rejeté. Revérification périodique. Un fichier dont IONOS a déjà le `sha256` y est **copié sur
place**, sans transfert.
- **La publication n'attend jamais les médias** : l'adresse finale d'un média est connue d'avance. État d'un contenu
au build :
| État | Condition | Rendu |
|---|---|---|
| `complete` | Tous ses médias sont disponibles | Normal |
| `pending` | Au moins un média pas encore arrivé | Publié à son adresse, **accessible par le lien** ; bandeau « Article incomplet : médias en cours de mise en ligne » (jusqu'au déploiement suivant) et emplacements réservés (chacun remplacé par son média dès l'arrivée, sans redéploiement) ; **absent** des listes, pages de tags, sitemap, recherche et flux ; `noindex` ; **pas annoncé** |
| `ignored` | Média inconnu, d'un autre contenu ou trop peu visible | Contenu non publié, avertissement |
| `refused` | Un lien local subsiste | **Build en échec** |
Un contenu `pending` devenu complet entre dans les listes et les flux, et il est annoncé, au **déploiement suivant**.
- **Par version** : `complete` et `pending` se calculent pour chaque version d'un article, qui cite ses propres
médias ; la version FR peut être publiée normalement et la version EN avec le bandeau.
- **Pour le contenu entier** : `ignored` écarte toutes ses versions (jamais un article publié dans une seule langue par
accident) ; `refused` fait échouer le build.
- Un média en cours de **remplacement** reste disponible tant que l'ancien fichier est servi (`served_sha256`,
[INTERFACE-MEDIAS.md](INTERFACE-MEDIAS.md) §4).
### 9.7 Visibilité, cache, référencement
- Visibilité **par média** : `public`, `members` ou `groups` (`unlisted` n'a pas de sens : un média n'est jamais
listé). Un média doit être **au moins aussi visible** que le contenu qui le cite. Changer la visibilité déplace le
fichier entre `public/` et `private/`.
- **Changer la visibilité d'un média** (à la demande de l'utilisateur, par la chaîne d'outils) :
1. **Contrôle** : refus (erreur `visibility`, rien n'est modifié) si le média deviendrait moins visible qu'une
version du contenu qui le cite ; le message dit quoi faire (rendre le contenu moins visible d'abord, ou retirer le
lien). Même règle qu'à l'ajout.
2. **NAS d'abord** (source de vérité) : la chaîne d'outils modifie l'entrée dans `media.toml` et la commite par l'API
« contents ». Le fichier ne bouge pas sur le NAS.
3. **Puis le site**, par la **file du worker** (§9.6), dans chaque environnement où le média existe :
`POST /api/media/visibility` ([INTERFACE-MEDIAS.md](INTERFACE-MEDIAS.md) §5). Site injoignable : la demande reste
dans la file (après un envoi du même média encore en attente) et repart plus tard ; en attendant, le site
sert le média avec son ancienne visibilité.
- Une **référence** (`from`) a sa propre visibilité, dans la fiche de son dossier : changer celle du fichier
d'origine ne la modifie pas, et inversement.
- **Adresse fixe**, sans paramètre de version : `Cache-Control: no-cache` et `ETag` (un remplacement est visible
immédiatement, chaque média garde une adresse unique). Médias privés : `X-Robots-Tag: noindex`. Le sous-domaine des
médias n'est pas bloqué par `robots.txt`.
### 9.8 Usage et nettoyage
- À chaque déploiement, le script relève toutes les adresses `media:` des contenus (y compris ceux en attente) et les
transmet au site : c'est l'**usage** de chaque média. Un contenu **retiré** (`status = unpublished`, §4.5 et §8.2)
ne cite plus rien : sa page de retrait n'affiche aucun média, qui deviennent orphelins. S'il est republié, la chaîne
d'outils renvoie depuis le NAS ceux qui ont été nettoyés d'IONOS (le contenu est `pending` en attendant).
- **Nettoyage** des fichiers **d'IONOS** qui ne sont plus cités (le NAS n'est jamais nettoyé) : un fichier non cité
devient **orphelin** (avec sa date), puis il est supprimé après un **délai de grâce** réglable dans `/admin` (30 jours
par défaut, par environnement). `keep` protège un média : transmis au site à l'envoi et à la copie, et modifié par le
même circuit qu'un changement de visibilité (§9.7 : NAS d'abord, puis le site par la file du worker). Exécuté par le
site, déclenché à la demande
(`a2m media cleanup`) ou au déploiement (`a2m deploy --cleanup`) ; simulation par défaut.
### 9.9 Circuit de rédaction
- Les brouillons vivent **hors des repos** (dossier de brouillons de la chaîne d'outils). La chaîne d'outils détecte
les médias, les envoie au NAS, réécrit les liens, puis copie **les seuls `.md`** dans le repo du site.
- Garde-fous côté site : les dossiers de contenus ne versionnent que les `.md` (`.gitignore` en liste blanche), et le
build refuse un lien local (`local-link`). L'en-tête d'un article n'a pas de champ de brouillon (§4.5).
+449
View File
@@ -0,0 +1,449 @@
(.venv) PS E:\0000WorkTechnique\0002dev\light_preset_manager> a2m release publish 0.6.0 --dev --asset "file=.\dist\light_preset_manager-0.6.0.zip;role=download" --deploy preprod
Contrat : apc-spec v3.0.1 (dernier tag publié)
light_preset_manager 0.6.0-dev.1 (dev) → ange/light_preset_manager, visibilité members, branche main
Publier sur Gitea ? [o/n] (o):
✔ tag dev placé sur 68e235d7de et poussé
✔ light_preset_manager-0.6.0.zip envoyé
✔ publication.json envoyé
✔ Release publiée : https://a2msystemes-git.duckdns.org/ange/light_preset_manager/releases/tag/dev
Déploiement preprod — la phrase de passe de la clé SSH peut être demandée.
Container a2msystemes-deploy-run-636568cea4c5 Creating
Container a2msystemes-deploy-run-636568cea4c5 Created
▶ Configuration (preprod) 16:02:58
Serveur : u57217383@home315313559.1and1-data.host:~/www/preprod
Adresse : https://preprod.a2msystemes.org
▶ Dépôt git 16:02:58
Commit : ec2db97 — Contrat apc-spec : passage à v3.0.1
Branche : main
✔ Arbre propre
▶ Connexion SSH 16:03:05
Phrase de passe de la clé a2m_ionos :
Enter passphrase for /root/.ssh/deploy_key:
Identity added: /root/.ssh/deploy_key (a2m-ionos depuis WARMACHINE-A2M)
✔ Serveur joignable, dossiers et .env présents
▶ Export du commit ec2db97 16:03:17
✔ Fichiers exportés dans /build
▶ Projets publiés (Gitea) 16:03:20
4 projet(s) sur Gitea → 2 sur le site public, 0 retiré(s), 2 addon(s) dans le catalogue Blender
a2m_probe_public (public, active) : version 0.1.0
slice_wrap (public, active) : version 1.4.2
0 fichier(s) téléchargé(s) depuis Gitea (les autres venaient du cache)
Changements depuis le déploiement précédent : 2
• nouveau projet : light_preset_manager — public [annoncé]
• nouvelle version : light_preset_manager 0.6.0-dev.1 — members
✔ Projets synchronisés
▶ Build du site (Analog) 16:03:29
added 668 packages in 10s
> a2m-site@0.0.0 build
> vite build && node scripts/clean-sitemap.mjs && node scripts/rss.mjs && pagefind --site dist/analog/public
vite v8.3.4 building client environment for production...
✓ 385 modules transformed.
computing gzip size...
dist/client/index.html 2.34 kB │ gzip: 1.15 kB
dist/client/assets/index-tfsG3tOf.css 2.92 kB │ gzip: 1.18 kB
dist/client/assets/_id_.page-BiFD4vmf.js 0.04 kB │ gzip: 0.06 kB
dist/client/assets/_tag_.page-C78GKh1A.js 0.04 kB │ gzip: 0.06 kB
dist/client/assets/_tag_.page-ul3g5iGa.js 0.04 kB │ gzip: 0.06 kB
dist/client/assets/_slug_.page-CwCGAHT2.js 0.04 kB │ gzip: 0.06 kB
dist/client/assets/index.page-CS5uSO-_.js 0.10 kB │ gzip: 0.12 kB
dist/client/assets/index.page-DtDOTxQs.js 0.11 kB │ gzip: 0.13 kB
dist/client/assets/about.page-DfMG4ZaU.js 0.11 kB │ gzip: 0.13 kB
dist/client/assets/index.page-E1EPiEXA.js 0.11 kB │ gzip: 0.13 kB
dist/client/assets/contact.page-x2KeEPA0.js 0.12 kB │ gzip: 0.13 kB
dist/client/assets/search.page-Cf4mS9zG.js 0.12 kB │ gzip: 0.13 kB
dist/client/assets/login.page-vwQEAs8x.js 0.14 kB │ gzip: 0.15 kB
dist/client/assets/invitation.page-GM_hviQD.js 0.14 kB │ gzip: 0.15 kB
dist/client/assets/blender-addons.page-BX7p4LXE.js 0.14 kB │ gzip: 0.15 kB
dist/client/assets/_...not-found_.page-T4aDkZfz.js 0.14 kB │ gzip: 0.16 kB
dist/client/assets/request-access.page-OVqCRDnG.js 0.15 kB │ gzip: 0.15 kB
dist/client/assets/reset-password.page-Dtr4wPxF.js 0.15 kB │ gzip: 0.16 kB
dist/client/assets/forgot-password.page-NHopf1t6.js 0.16 kB │ gzip: 0.17 kB
dist/client/assets/2026-10-17-opening-COXeaVIs.js 0.51 kB │ gzip: 0.37 kB
dist/client/assets/private-page-g33is5sA.js 0.51 kB │ gzip: 0.36 kB
dist/client/assets/2026-10-17-ouverture-Ccgu8oq2.js 0.55 kB │ gzip: 0.39 kB
dist/client/assets/content-types-CKWdUnQo.js 0.60 kB │ gzip: 0.37 kB
dist/client/assets/a2m-logo.component-B7g-8HJ-.js 0.84 kB │ gzip: 0.52 kB
dist/client/assets/_tag_.page-Cu3xgJqj.js 1.27 kB │ gzip: 0.72 kB
dist/client/assets/tag-links.component-Dqf80SLl.js 1.32 kB │ gzip: 0.73 kB
dist/client/assets/_tag_.page-C6Rw5J1O.js 1.44 kB │ gzip: 0.78 kB
dist/client/assets/index.page-DLuhHome.js 1.49 kB │ gzip: 0.84 kB
dist/client/assets/index.page-DSPqEf6p.js 1.61 kB │ gzip: 0.79 kB
dist/client/assets/turnstile.component-DmoFGgsh.js 1.73 kB │ gzip: 0.91 kB
dist/client/assets/index.page-BMlGtaCa.js 2.16 kB │ gzip: 1.00 kB
dist/client/assets/_slug_.page-DIMMZGOu.js 2.23 kB │ gzip: 1.13 kB
dist/client/assets/post-list.component-Dx5-l1_N.js 2.24 kB │ gzip: 1.01 kB
dist/client/assets/index.page-BymlGbnh.js 2.29 kB │ gzip: 1.12 kB
dist/client/assets/projet-card.component-CElweaIh.js 2.75 kB │ gzip: 1.21 kB
dist/client/assets/_...not-found_.page-Dqakym9Z.js 2.89 kB │ gzip: 1.38 kB
dist/client/assets/_common_module-chunk-h6ypKhQY.js 2.92 kB │ gzip: 1.06 kB
dist/client/assets/mentions-legales.page-wyCgNBH5.js 3.31 kB │ gzip: 1.62 kB
dist/client/assets/legal-notice.page-DqNC6Dcv.js 3.33 kB │ gzip: 1.62 kB
dist/client/assets/a-propos.page-CASZ6S2i.js 3.87 kB │ gzip: 1.81 kB
dist/client/assets/recherche.page-eXrBMQri.js 3.94 kB │ gzip: 1.82 kB
dist/client/assets/mot-de-passe-oublie.page-CV_9XzoQ.js 4.65 kB │ gzip: 1.91 kB
dist/client/assets/blender-addons.page-Cr_MhXe_.js 6.49 kB │ gzip: 2.53 kB
dist/client/assets/reinitialisation.page-CgELz1N6.js 6.54 kB │ gzip: 2.39 kB
dist/client/assets/demande-acces.page-C12RNPUd.js 7.11 kB │ gzip: 2.64 kB
dist/client/assets/contact.page-BjBS0zgJ.js 7.32 kB │ gzip: 2.68 kB
dist/client/assets/invitation.page-Jyg2M_5I.js 8.82 kB │ gzip: 3.08 kB
dist/client/assets/i18n-Bxb5dOM4.js 9.04 kB │ gzip: 3.59 kB
dist/client/assets/privacy.page-CleTh8fE.js 9.69 kB │ gzip: 3.95 kB
dist/client/assets/confidentialite.page-Dy-XOmo4.js 10.43 kB │ gzip: 4.21 kB
dist/client/assets/connexion.page-CqJuvRvc.js 10.79 kB │ gzip: 3.64 kB
dist/client/assets/two-factor-setup.component-OW1SHATp.js 12.69 kB │ gzip: 3.61 kB
dist/client/assets/_id_.page-Bk_3d75e.js 12.96 kB │ gzip: 4.13 kB
dist/client/assets/compte.page-DoqddlkC.js 15.29 kB │ gzip: 4.64 kB
dist/client/assets/projets-DRoUJUHQ.js 31.87 kB │ gzip: 11.17 kB
dist/client/assets/_router-chunk-jpVp88a1.js 293.95 kB │ gzip: 90.58 kB
dist/client/assets/index-DmSuz30L.js 362.89 kB │ gzip: 104.72 kB
✓ built in 3.73s
vite v8.3.4 building ssr environment for production...
✓ 117 modules transformed.
computing gzip size...
dist/ssr/assets/_id_.page-suOqbZ-i.js 0.05 kB │ gzip: 0.07 kB
dist/ssr/assets/_tag_.page-BswG67Wm.js 0.05 kB │ gzip: 0.07 kB
dist/ssr/assets/_tag_.page-Cy8X2rEW.js 0.05 kB │ gzip: 0.07 kB
dist/ssr/assets/_slug_.page-D4AUm7l0.js 0.05 kB │ gzip: 0.07 kB
dist/ssr/assets/index.page-BGinqpfB.js 0.19 kB │ gzip: 0.16 kB
dist/ssr/assets/index.page-BFrjNVmW.js 0.20 kB │ gzip: 0.17 kB
dist/ssr/assets/about.page-CLZCafNJ.js 0.20 kB │ gzip: 0.18 kB
dist/ssr/assets/search.page-DiRdMLCy.js 0.21 kB │ gzip: 0.18 kB
dist/ssr/assets/contact.page-C3U_GqKN.js 0.21 kB │ gzip: 0.17 kB
dist/ssr/assets/index.page-B_wk4wT1.js 0.21 kB │ gzip: 0.18 kB
dist/ssr/assets/login.page-BPWSzZb1.js 0.24 kB │ gzip: 0.20 kB
dist/ssr/assets/invitation.page-DHcT2hsz.js 0.25 kB │ gzip: 0.20 kB
dist/ssr/assets/blender-addons.page-DsBOI6pQ.js 0.25 kB │ gzip: 0.19 kB
dist/ssr/assets/_...not-found_.page-T4k2y208.js 0.25 kB │ gzip: 0.21 kB
dist/ssr/assets/request-access.page-D0mYV7Cz.js 0.26 kB │ gzip: 0.21 kB
dist/ssr/assets/reset-password.page-CDz54Z8T.js 0.27 kB │ gzip: 0.21 kB
dist/ssr/assets/forgot-password.page-CNEJdVPn.js 0.29 kB │ gzip: 0.23 kB
dist/ssr/assets/2026-10-17-opening-BZa7Hkso.js 0.67 kB │ gzip: 0.45 kB
dist/ssr/assets/2026-10-17-ouverture-DjDzIEYl.js 0.72 kB │ gzip: 0.46 kB
dist/ssr/assets/private-page-BscbDqj9.js 1.22 kB │ gzip: 0.70 kB
dist/ssr/assets/content-types-BiGRZjTL.js 1.54 kB │ gzip: 0.85 kB
dist/ssr/assets/a2m-logo.component-nzqLmVmk.js 2.16 kB │ gzip: 1.00 kB
dist/ssr/assets/tag-links.component-OXscgeUN.js 2.70 kB │ gzip: 1.14 kB
dist/ssr/assets/_tag_.page-Bn8XN-cA.js 2.75 kB │ gzip: 1.09 kB
dist/ssr/assets/index.page-DSAWJlxw.js 2.84 kB │ gzip: 1.06 kB
dist/ssr/assets/index.page-hU2clTxq.js 3.23 kB │ gzip: 1.20 kB
dist/ssr/assets/_tag_.page-BaPJqA_c.js 3.30 kB │ gzip: 1.19 kB
dist/ssr/assets/turnstile.component-C9cEwfb9.js 3.78 kB │ gzip: 1.68 kB
dist/ssr/assets/post-list.component-BHhoD_Dr.js 4.27 kB │ gzip: 1.48 kB
dist/ssr/assets/_slug_.page-BV_DBwXS.js 4.55 kB │ gzip: 1.58 kB
dist/ssr/assets/index.page-BmwnnbTw.js 4.72 kB │ gzip: 1.54 kB
dist/ssr/assets/mentions-legales.page-BnJUKYcZ.js 5.08 kB │ gzip: 1.85 kB
dist/ssr/assets/legal-notice.page-Bwta7eN9.js 5.14 kB │ gzip: 1.85 kB
dist/ssr/assets/index.page-BxALBSO8.js 5.31 kB │ gzip: 1.48 kB
dist/ssr/assets/projet-card.component-5vJnk0hk.js 5.42 kB │ gzip: 1.68 kB
dist/ssr/assets/_common_module-chunk-BSBE_0o6.js 5.76 kB │ gzip: 1.71 kB
dist/ssr/assets/_...not-found_.page-CQ7H5Qb5.js 6.32 kB │ gzip: 2.26 kB
dist/ssr/assets/a-propos.page-DRTW7xgX.js 6.91 kB │ gzip: 2.52 kB
dist/ssr/assets/recherche.page-DOV1cBdN.js 7.77 kB │ gzip: 2.77 kB
dist/ssr/assets/mot-de-passe-oublie.page-9lBN9y7G.js 7.98 kB │ gzip: 2.54 kB
dist/ssr/assets/blender-addons.page-CohBx8y_.js 11.79 kB │ gzip: 3.32 kB
dist/ssr/assets/i18n-BXYrWBKp.js 12.38 kB │ gzip: 4.49 kB
dist/ssr/assets/reinitialisation.page-gqn--W9W.js 12.56 kB │ gzip: 3.24 kB
dist/ssr/assets/privacy.page-DSf6t9B2.js 13.55 kB │ gzip: 4.26 kB
dist/ssr/assets/demande-acces.page-uvzUvGbD.js 14.01 kB │ gzip: 3.52 kB
dist/ssr/assets/contact.page-D2o9HWcm.js 14.08 kB │ gzip: 3.48 kB
dist/ssr/assets/confidentialite.page-Bb8poMBs.js 14.26 kB │ gzip: 4.52 kB
dist/ssr/assets/invitation.page-BXBQsf6_.js 16.64 kB │ gzip: 4.03 kB
dist/ssr/assets/xhr2-D9DVWp6g.js 20.67 kB │ gzip: 4.75 kB
dist/ssr/assets/connexion.page-CNF113yJ.js 22.16 kB │ gzip: 4.94 kB
dist/ssr/assets/two-factor-setup.component-ikkTKlnR.js 22.57 kB │ gzip: 5.06 kB
dist/ssr/assets/_id_.page-D-LDsjo0.js 27.40 kB │ gzip: 5.53 kB
dist/ssr/assets/compte.page-aXeGoqTM.js 31.66 kB │ gzip: 6.09 kB
dist/ssr/assets/projets-DGDnIuvC.js 34.43 kB │ gzip: 11.81 kB
dist/ssr/main.server.js 586.23 kB │ gzip: 129.77 kB
dist/ssr/assets/_router-chunk-BLHm19oG.js 597.09 kB │ gzip: 131.83 kB
✓ built in 2.48s
Prerendering static pages...
Building Sitemap...
Writing sitemap at /build/site/dist/analog/public/sitemap.xml
The '@analogjs/platform' server has been successfully built.
sitemap : 14 page(s) retirée(s) (erreurs, comptes, projets non listés ou retirés)
rss fr : 1 article(s), 3 entrée(s) de projets
rss en : 1 article(s), 3 entrée(s) de projets
Running Pagefind v1.5.2 (Extended)
Running from: "/build/site"
Source: "dist/analog/public"
Output: "dist/analog/public/pagefind"
[Walking source directory]
Found 49 files matching **/*.{html}
[Parsing files]
Found a data-pagefind-body element on the site.
↳ Ignoring pages without this tag.
[Reading languages]
Discovered 2 languages: en, fr
[Building search indexes]
Total:
Indexed 2 languages
Indexed 16 pages
Indexed 1984 words
Indexed 0 filters
Indexed 0 sorts
Finished in 0.054 seconds
✔ Site construit (163 fichiers)
▶ Laravel : composer install --no-dev 16:03:49
Installing dependencies from lock file
Verifying lock file contents can be installed on current platform.
Package operations: 111 installs, 0 updates, 0 removals
- Installing voku/portable-ascii (2.1.1): Extracting archive
- Installing symfony/polyfill-php80 (v1.43.0): Extracting archive
- Installing symfony/polyfill-mbstring (v1.43.0): Extracting archive
- Installing symfony/polyfill-ctype (v1.37.0): Extracting archive
- Installing phpoption/phpoption (1.10.0): Extracting archive
- Installing graham-campbell/result-type (v1.2.0): Extracting archive
- Installing vlucas/phpdotenv (v5.7.0): Extracting archive
- Installing symfony/css-selector (v8.1.6): Extracting archive
- Installing tijsverkoyen/css-to-inline-styles (v2.4.0): Extracting archive
- Installing symfony/var-dumper (v8.1.9): Extracting archive
- Installing symfony/polyfill-uuid (v1.43.0): Extracting archive
- Installing symfony/uid (v8.1.9): Extracting archive
- Installing symfony/deprecation-contracts (v3.7.1): Extracting archive
- Installing symfony/routing (v8.1.9): Extracting archive
- Installing symfony/process (v8.1.9): Extracting archive
- Installing symfony/polyfill-php86 (v1.43.0): Extracting archive
- Installing symfony/polyfill-php85 (v1.43.0): Extracting archive
- Installing symfony/polyfill-php84 (v1.43.0): Extracting archive
- Installing symfony/polyfill-intl-normalizer (v1.43.0): Extracting archive
- Installing symfony/polyfill-intl-idn (v1.43.0): Extracting archive
- Installing symfony/mime (v8.1.7): Extracting archive
- Installing psr/container (2.0.2): Extracting archive
- Installing symfony/service-contracts (v3.7.3): Extracting archive
- Installing psr/event-dispatcher (1.0.0): Extracting archive
- Installing symfony/event-dispatcher-contracts (v3.7.1): Extracting archive
- Installing symfony/event-dispatcher (v8.1.5): Extracting archive
- Installing psr/log (3.0.2): Extracting archive
- Installing doctrine/lexer (3.0.3): Extracting archive
- Installing egulias/email-validator (4.0.4): Extracting archive
- Installing symfony/mailer (v8.1.7): Extracting archive
- Installing symfony/http-foundation (v8.1.9): Extracting archive
- Installing symfony/error-handler (v8.1.8): Extracting archive
- Installing symfony/http-kernel (v8.1.9): Extracting archive
- Installing symfony/finder (v8.1.8): Extracting archive
- Installing symfony/polyfill-intl-grapheme (v1.43.0): Extracting archive
- Installing symfony/string (v8.1.9): Extracting archive
- Installing symfony/console (v8.1.9): Extracting archive
- Installing ramsey/collection (2.1.1): Extracting archive
- Installing brick/math (1.0.0): Extracting archive
- Installing ramsey/uuid (4.9.4): Extracting archive
- Installing psr/simple-cache (3.0.0): Extracting archive
- Installing psr/http-message (2.0): Extracting archive
- Installing nunomaduro/termwind (v2.4.0): Extracting archive
- Installing symfony/translation-contracts (v3.7.1): Extracting archive
- Installing symfony/translation (v8.1.5): Extracting archive
- Installing psr/clock (1.0.0): Extracting archive
- Installing symfony/clock (v8.1.9): Extracting archive
- Installing carbonphp/carbon-doctrine-types (3.2.1): Extracting archive
- Installing nesbot/carbon (3.14.2): Extracting archive
- Installing monolog/monolog (3.12.1): Extracting archive
- Installing psr/http-factory (1.1.0): Extracting archive
- Installing league/uri-interfaces (7.9.0): Extracting archive
- Installing league/uri (7.9.0): Extracting archive
- Installing league/mime-type-detection (1.17.0): Extracting archive
- Installing league/flysystem-local (3.35.3): Extracting archive
- Installing league/flysystem (3.36.0): Extracting archive
- Installing nette/utils (v4.1.5): Extracting archive
- Installing nette/schema (v1.3.6): Extracting archive
- Installing dflydev/dot-access-data (v3.0.3): Extracting archive
- Installing league/config (v1.2.0): Extracting archive
- Installing league/commonmark (2.10.3): Extracting archive
- Installing laravel/serializable-closure (v2.1.0): Extracting archive
- Installing laravel/prompts (v0.3.26): Extracting archive
- Installing guzzlehttp/uri-template (v2.0.1): Extracting archive
- Installing symfony/polyfill-php82 (v1.43.0): Extracting archive
- Installing guzzlehttp/psr7 (3.1.0): Extracting archive
- Installing guzzlehttp/promises (3.0.2): Extracting archive
- Installing psr/http-client (1.0.3): Extracting archive
- Installing guzzlehttp/guzzle (8.2.0): Extracting archive
- Installing fruitcake/php-cors (v1.4.0): Extracting archive
- Installing dragonmantank/cron-expression (v3.6.0): Extracting archive
- Installing doctrine/inflector (2.1.0): Extracting archive
- Installing laravel/framework (v13.37.0): Extracting archive
- Installing anourvalar/eloquent-serialize (1.3.12): Extracting archive
- Installing blade-ui-kit/blade-icons (1.10.1): Extracting archive
- Installing blade-ui-kit/blade-heroicons (2.7.0): Extracting archive
- Installing chillerlan/php-settings-container (3.3.0): Extracting archive
- Installing composer/pcre (3.4.0): Extracting archive
- Installing composer/class-map-generator (1.8.0): Extracting archive
- Installing danharrin/date-format-converter (v0.3.1): Extracting archive
- Installing danharrin/livewire-rate-limiting (v2.3.0): Extracting archive
- Installing paragonie/constant_time_encoding (v3.1.3): Extracting archive
- Installing pragmarx/google2fa (v9.1.0): Extracting archive
- Installing pragmarx/google2fa-qrcode (v4.0.0): Extracting archive
- Installing symfony/html-sanitizer (v8.1.8): Extracting archive
- Installing spatie/laravel-package-tools (1.93.3): Extracting archive
- Installing spatie/invade (2.2.0): Extracting archive
- Installing ryangjchandler/blade-capture-directive (v1.1.1): Extracting archive
- Installing nette/php-generator (v4.2.2): Extracting archive
- Installing livewire/livewire (v4.4.7): Extracting archive
- Installing league/uri-components (7.9.0): Extracting archive
- Installing kirschbaum-development/eloquent-power-joins (4.3.5): Extracting archive
- Installing filament/support (v5.10.3): Extracting archive
- Installing openspout/openspout (v4.32.0): Extracting archive
- Installing league/csv (9.28.0): Extracting archive
- Installing filament/actions (v5.10.3): Extracting archive
- Installing filament/notifications (v5.10.3): Extracting archive
- Installing filament/schemas (v5.10.3): Extracting archive
- Installing filament/infolists (v5.10.3): Extracting archive
- Installing spatie/shiki-php (2.4.0): Extracting archive
- Installing scrivo/highlight.php (v9.18.1.10): Extracting archive
- Installing ueberdosis/tiptap-php (2.2.1): Extracting archive
- Installing filament/forms (v5.10.3): Extracting archive
- Installing filament/widgets (v5.10.3): Extracting archive
- Installing filament/query-builder (v5.10.3): Extracting archive
- Installing filament/tables (v5.10.3): Extracting archive
- Installing chillerlan/php-qrcode (5.0.5): Extracting archive
- Installing filament/filament (v5.10.3): Extracting archive
- Installing nikic/php-parser (v5.9.0): Extracting archive
- Installing psy/psysh (v0.12.24): Extracting archive
- Installing laravel/tinker (v3.0.2): Extracting archive
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi
INFO Discovering packages.
anourvalar/eloquent-serialize ......................................... DONE
blade-ui-kit/blade-heroicons .......................................... DONE
blade-ui-kit/blade-icons .............................................. DONE
filament/actions ...................................................... DONE
filament/filament ..................................................... DONE
filament/forms ........................................................ DONE
filament/infolists .................................................... DONE
filament/notifications ................................................ DONE
filament/query-builder ................................................ DONE
filament/schemas ...................................................... DONE
filament/support ...................................................... DONE
filament/tables ....................................................... DONE
filament/widgets ...................................................... DONE
kirschbaum-development/eloquent-power-joins ........................... DONE
laravel/tinker ........................................................ DONE
livewire/livewire ..................................................... DONE
nesbot/carbon ......................................................... DONE
nunomaduro/termwind ................................................... DONE
ryangjchandler/blade-capture-directive ................................ DONE
> @php artisan filament:upgrade
⇂ /build/public/js/filament/forms/components/builder.js
⇂ /build/public/js/filament/forms/components/checkbox-list.js
⇂ /build/public/js/filament/forms/components/code-editor.js
⇂ /build/public/js/filament/forms/components/color-picker.js
⇂ /build/public/js/filament/forms/components/date-time-picker.js
⇂ /build/public/js/filament/forms/components/file-upload.js
⇂ /build/public/js/filament/forms/components/key-value.js
⇂ /build/public/js/filament/forms/components/markdown-editor.js
⇂ /build/public/js/filament/forms/components/repeater.js
⇂ /build/public/js/filament/forms/components/rich-editor.js
⇂ /build/public/js/filament/forms/components/select.js
⇂ /build/public/js/filament/forms/components/slider.js
⇂ /build/public/js/filament/forms/components/tags-input.js
⇂ /build/public/js/filament/forms/components/textarea.js
⇂ /build/public/js/filament/forms/components/text-input/password-reveal.js
⇂ /build/public/js/filament/forms/components/toggle-buttons.js
⇂ /build/public/js/filament/schemas/components/actions.js
⇂ /build/public/js/filament/schemas/components/tabs.js
⇂ /build/public/js/filament/schemas/components/wizard.js
⇂ /build/public/js/filament/tables/components/columns/checkbox.js
⇂ /build/public/js/filament/tables/components/columns/select.js
⇂ /build/public/js/filament/tables/components/columns/text-input.js
⇂ /build/public/js/filament/tables/components/columns/toggle.js
⇂ /build/public/js/filament/widgets/components/chart.js
⇂ /build/public/js/filament/widgets/components/stats-overview/stat/chart.js
⇂ /build/public/fonts/filament/filament/inter/index.css
⇂ /build/public/fonts/filament/filament/inter/inter-cyrillic-ext-wght-normal-SP7Z6XGK.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-cyrillic-wght-normal-DR6K5BQD.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-greek-ext-wght-normal-A2J6H3EX.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-greek-wght-normal-ZABHMKQG.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-latin-ext-wght-normal-ZIT2UBIY.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-latin-wght-normal-DIHYUR35.woff2
⇂ /build/public/fonts/filament/filament/inter/inter-vietnamese-wght-normal-VWEHJHBA.woff2
⇂ /build/public/js/filament/actions/actions.js
⇂ /build/public/js/filament/filament/app.js
⇂ /build/public/js/filament/filament/echo.js
⇂ /build/public/js/filament/notifications/notifications.js
⇂ /build/public/js/filament/schemas/schemas.js
⇂ /build/public/js/filament/support/support.js
⇂ /build/public/js/filament/tables/tables.js
⇂ /build/public/css/filament/filament/app.css
INFO Successfully published assets!
INFO Configuration cache cleared successfully.
INFO Route cache cleared successfully.
INFO Compiled views cleared successfully.
INFO Successfully upgraded!
73 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
✔ Dépendances installées
✔ Fichiers de l'administration ajoutés au site
▶ Envoi vers ~/www/preprod 16:03:51
api/ → ~/www/preprod/api
Number of created files: 0
Number of deleted files: 0
Number of regular files transferred: 0
Total transferred file size: 0 bytes
sent 678,674 bytes received 3,604 bytes 3,272.32 bytes/sec
Durée : 3 min 28 s
site → ~/www/preprod/public
Number of created files: 0
Number of deleted files: 0
Number of regular files transferred: 0
Total transferred file size: 0 bytes
sent 12,481 bytes received 104 bytes 2,288.18 bytes/sec
Durée : 5 s
✔ Envoi terminé
▶ Laravel sur le serveur (migrations, caches) 16:07:24
INFO Nothing to migrate.
INFO Caching framework bootstrap, configuration, and metadata.
config ........................................................ 48.31ms DONE
events ......................................................... 6.76ms DONE
routes ........................................................ 52.46ms DONE
views ........................................................ 604.58ms DONE
blade-icons .................................................... 7.27ms DONE
filament ...................................................... 10.03ms DONE
✔ Migrations et caches à jour
▶ Enregistrement des projets publiés 16:07:26
Déploiement n° 20 enregistré, 2 événement(s)
▶ Contrôle de santé : https://preprod.a2msystemes.org/api/health 16:07:27
HTTP 200 : {"status":"ok","database":"ok"}
✔ Le site répond
▶ Déployé : ec2db97 en preprod (https://preprod.a2msystemes.org), en 4 min 29 s 16:07:27
✔ preprod : déployé
</pasted_content id="e9e5">
+45
View File
@@ -0,0 +1,45 @@
============================= test session starts =============================
platform win32 -- Python 3.12.3, pytest-8.4.2, pluggy-1.6.0
rootdir: E:\0000WorkTechnique\0002dev\apc-toolchain
configfile: pyproject.toml
plugins: xdist-3.8.0
8 workers [214 items]
........................................................................ [ 33%]
...................F..........................s......................... [ 67%]
.................................F..................F.................s. [100%]
=================================== FAILURES ===================================
__________________________ test_upload_in_chunks_then_available __________________________
[gw2] win32 -- Python 3.12.3 E:\0000WorkTechnique\0002dev\apc-toolchain\.venv\Scripts\python.exe
site = <test_media_worker.FakeSite object at 0x000001B2F6A83050>
def test_upload_in_chunks_then_available(site, nas, tmp_path):
_send()
worker, events = _worker(site, nas, tmp_path)
report = worker.run()
> assert site.patches == 5 and report.transient == {}
E assert 6 == 5
tests\test_media_worker.py:178: AssertionError
______________________________ test_slug_changed_removes_the_old_version ______________________________
[gw5] win32 -- Python 3.12.3 E:\0000WorkTechnique\0002dev\apc-toolchain\.venv\Scripts\python.exe
def test_slug_changed_removes_the_old_version(drafts, nas, repos):
> assert sorted(p.name for p in blog_fr.iterdir()) == ["ouverture.md"]
E AssertionError: assert ['2026-10-17-ouverture-du-site.md', 'ouverture.md'] == ['ouverture.md']
tests\test_blog_publish.py:142: AssertionError
_______________________________ test_cleanup_simulates_then_deletes_on_request _______________________________
[gw1] win32 -- Python 3.12.3 E:\0000WorkTechnique\0002dev\apc-toolchain\.venv\Scripts\python.exe
def test_cleanup_simulates_then_deletes_on_request(site, monkeypatch):
> assert "1 fichier(s) seraient supprimés" in output and site.cleanups == [True]
E AssertionError: assert '1 fichier(s) seraient supprimés' in 'IONOS preprod : 2 média(s) orphelin(s)'
tests\test_media_worker.py:301: AssertionError
=========================== short test summary info ===========================
FAILED tests/test_media_worker.py::test_upload_in_chunks_then_available - assert 6 == 5
FAILED tests/test_blog_publish.py::test_slug_changed_removes_the_old_version - AssertionError: assert ['2026-10-17-ouv...
FAILED tests/test_media_worker.py::test_cleanup_simulates_then_deletes_on_request - AssertionError: assert '1 fichi...
============= 3 failed, 209 passed, 2 skipped, 1 warning in 131.42s (0:02:11) =============
+92
View File
@@ -0,0 +1,92 @@
"""Client MCP minimal (HTTP « streamable », bibliothèque standard seulement) pour le serveur de MIAM.
Juste ce qu'il faut au banc : initialize, puis tools/call. Le serveur répond en JSON ou en flux SSE ; l'en-tête
`Mcp-Session-Id` éventuel est renvoyé à chaque requête.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
class ErreurMcp(RuntimeError):
pass
def jeton_miam() -> str:
"""Jeton : variable MIAM_JETON, sinon la section `mcp` de la config de MIAM (même poste, même compte)."""
if os.environ.get("MIAM_JETON"):
return os.environ["MIAM_JETON"]
config = Path(os.environ.get("LOCALAPPDATA", "")) / "MIAM" / "config.json"
try:
jeton = json.loads(config.read_text(encoding="utf-8"))["mcp"]["token"]
except (OSError, ValueError, KeyError) as e:
raise ErreurMcp(f"jeton introuvable (MIAM_JETON ou {config}) : {e}") from e
if not jeton:
raise ErreurMcp(f"jeton vide dans {config} : démarrer une fois le serveur MCP de MIAM")
return jeton
class ClientMcp:
def __init__(self, url: str, jeton: str, timeout: float = 120) -> None:
self.url, self.jeton, self.timeout = url, jeton, timeout
self.session: str | None = None
self._id = 0
def _envoyer(self, message: dict) -> dict | None:
entetes = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
"Authorization": f"Bearer {self.jeton}"}
if self.session:
entetes["Mcp-Session-Id"] = self.session
requete = urllib.request.Request(self.url, data=json.dumps(message).encode(), headers=entetes, method="POST")
try:
with urllib.request.urlopen(requete, timeout=self.timeout) as reponse:
self.session = reponse.headers.get("Mcp-Session-Id") or self.session
type_ = reponse.headers.get("Content-Type", "")
corps = reponse.read().decode("utf-8")
except urllib.error.HTTPError as e:
if e.code == 401:
raise ErreurMcp("401 : jeton refusé par MIAM") from e
raise ErreurMcp(f"HTTP {e.code} : {e.read()[:300]!r}") from e
except urllib.error.URLError as e:
raise ErreurMcp(f"MIAM injoignable sur {self.url} (lancé, serveur MCP démarré ?) : {e.reason}") from e
if "id" not in message or not corps.strip():
return None # notification : 202 sans corps
if "text/event-stream" in type_:
for ligne in corps.splitlines():
if ligne.startswith("data:"):
donnees = json.loads(ligne[5:].strip())
if donnees.get("id") == message["id"]:
return donnees
raise ErreurMcp("flux SSE sans réponse à la requête")
return json.loads(corps)
def _appel(self, methode: str, params: dict) -> dict:
self._id += 1
reponse = self._envoyer({"jsonrpc": "2.0", "id": self._id, "method": methode, "params": params})
if reponse is None or "error" in reponse:
raise ErreurMcp(f"{methode} : {(reponse or {}).get('error')}")
return reponse["result"]
def ouvrir(self) -> None:
self._appel("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "miam-banc", "version": "0.1.0"}})
self._envoyer({"jsonrpc": "2.0", "method": "notifications/initialized"})
def outil(self, nom: str, **arguments) -> dict:
"""Appelle un outil de MIAM ; renvoie son résultat JSON (dict)."""
resultat = self._appel("tools/call", {"name": nom, "arguments": arguments})
if resultat.get("structuredContent"):
contenu = resultat["structuredContent"]
return contenu.get("result", contenu) if set(contenu) == {"result"} else contenu
texte = "".join(c.get("text", "") for c in resultat.get("content", []) if c.get("type") == "text")
if resultat.get("isError"):
raise ErreurMcp(f"{nom} : {texte[:300]}")
try:
return json.loads(texte)
except ValueError:
return {"ok": False, "erreur": f"réponse non JSON : {texte[:300]}"}
+372
View File
@@ -0,0 +1,372 @@
"""Tâches fixes du banc d'essai et leurs contrôles (note de 0 à 1).
Catégories : code, texte, rapide (lire beaucoup pour rendre peu), vision. Chaque tâche a :
- `consigne` (et éventuellement `fichier` : document ou image joint) ;
- `nature` : `lecture` (le modèle lit un long document à ma place) ou `ecriture` (il rédige) — sert au calcul du gain ;
- `controle(reponse) -> (note, détail)` ; `a_juger` : une partie se juge à la lecture (SVG, notes de version).
Les fichiers de `fichiers/` et les références ci-dessous ne changent jamais : c'est ce qui rend les essais comparables.
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
FICHIERS = Path(__file__).parent / "fichiers"
@dataclass
class Tache:
id: str
categorie: str
nature: str # lecture | ecriture
consigne: str
controle: Callable[[str], tuple[float, str]]
fichier: str | None = None # document (texte ajouté à la consigne) ou image (.png)
a_juger: str = "" # ce qu'il faut regarder à la main
@property
def image(self) -> Path | None:
return FICHIERS / self.fichier if self.fichier and self.fichier.endswith(".png") else None
def message(self) -> str:
if self.fichier and not self.fichier.endswith(".png"):
return self.consigne + "\n\n" + (FICHIERS / self.fichier).read_text(encoding="utf-8")
return self.consigne
# --- outils de contrôle ----------------------------------------------------------------------------------------------
def bloc(texte: str, langue: str = r"\w*") -> str:
"""Contenu du premier bloc de code (de cette langue s'il y en a un), sinon le texte entier."""
m = re.search(rf"```(?:{langue})\s*\n(.*?)```", texte, re.S) or re.search(r"```\w*\s*\n(.*?)```", texte, re.S)
return (m.group(1) if m else texte).strip()
def lire_json(texte: str) -> dict | list | None:
brut = bloc(texte, "json")
m = re.search(r"[\[{].*[\]}]", brut, re.S)
try:
return json.loads(m.group(0) if m else brut)
except (ValueError, TypeError):
return None
def executer(langage: str, code: str, essais: str) -> tuple[float, str]:
"""Lance le code du modèle et des assertions dans un processus à part (10 s au plus) ; note = assertions justes."""
lignes = [l for l in essais.strip().splitlines() if l.strip()]
if langage == "python":
aide = ("\ndef _leve(f, erreur):\n try:\n f()\n except erreur:\n return True\n"
" except Exception:\n return False\n return False\n")
corps = code + aide + "\n_ok = 0\n" + "".join(
f"try:\n assert {l}\n _ok += 1\nexcept Exception:\n pass\n" for l in lignes) + "print(_ok)\n"
commande, suffixe = [sys.executable, "-I"], ".py"
else:
corps = code + "\nlet _ok = 0;\n" + "".join(
f"try {{ if ({l}) _ok++; }} catch (e) {{}}\n" for l in lignes) + "console.log(_ok);\n"
commande, suffixe = ["node"], ".js"
with tempfile.TemporaryDirectory() as dossier:
script = Path(dossier) / f"essai{suffixe}"
script.write_text(corps, encoding="utf-8")
try:
sortie = subprocess.run(commande + [str(script)], capture_output=True, text=True, timeout=10, cwd=dossier)
except subprocess.TimeoutExpired:
return 0.0, "trop long (boucle ?)"
try:
justes = int(sortie.stdout.strip().splitlines()[-1])
except (ValueError, IndexError):
return 0.0, "code inutilisable : " + (sortie.stderr.strip().splitlines() or ["?"])[-1][:120]
return justes / len(lignes), f"{justes}/{len(lignes)} cas justes"
def chrf(hypothese: str, reference: str, n: int = 6, beta: float = 2.0) -> float:
"""chrF (n-grammes de caractères, 1 à 6) : mesure classique de la proximité d'une traduction avec la référence."""
def grammes(texte, k):
t = re.sub(r"\s+", " ", texte.strip())
return Counter(t[i:i + k] for i in range(len(t) - k + 1))
precisions, rappels = [], []
for k in range(1, n + 1):
h, r = grammes(hypothese, k), grammes(reference, k)
commun = sum((h & r).values())
precisions.append(commun / max(sum(h.values()), 1))
rappels.append(commun / max(sum(r.values()), 1))
p, r = sum(precisions) / n, sum(rappels) / n
return 0.0 if p + r == 0 else (1 + beta ** 2) * p * r / (beta ** 2 * p + r)
MOTS_FR = {"le", "la", "les", "des", "du", "un", "une", "et", "est", "pour", "dans", "sur", "pas", "que", "qui", "au"}
MOTS_EN = {"the", "and", "is", "for", "in", "on", "not", "that", "which", "to", "of", "with", "are"}
def en_francais(texte: str) -> bool:
mots = re.findall(r"[a-zàâçéèêëîïôûùüÿœ']+", texte.lower())
return sum(m in MOTS_FR for m in mots) > sum(m in MOTS_EN for m in mots)
def mots_cles(texte: str, groupes: list[list[str]]) -> tuple[float, list[str]]:
"""Part des groupes dont au moins un mot apparaît (sans tenir compte de la casse)."""
bas = texte.lower()
manquants = [g[0] for g in groupes if not any(m in bas for m in g)]
return 1 - len(manquants) / len(groupes), manquants
# --- code -----------------------------------------------------------------------------------------------------------
def tache_python(id_, consigne, essais):
return Tache(id_, "code", "ecriture", consigne + "\nRéponds seulement avec le code, dans un bloc ```python.",
lambda r: executer("python", bloc(r, "python"), essais))
CODE = [
tache_python("code-slug", """Écris une fonction Python `slug(date: str, title: str) -> str`, sans dépendance externe,
qui renvoie `<date>-<titre normalisé>` : accents retirés (unicodedata NFKD, puis ASCII), minuscules, toute suite de
caractères hors [a-z0-9] remplacée par un seul « - », tirets retirés au début et à la fin ; si le titre normalisé est
vide, renvoyer seulement la date.""", """
slug("2026-10-17", "Ouverture du site") == "2026-10-17-ouverture-du-site"
slug("2026-10-17", "Éditeur 3D : l'essai_final !") == "2026-10-17-editeur-3d-l-essai-final"
slug("2026-10-17", "!!!") == "2026-10-17"
slug("2026-01-02", " Ça marche -- vraiment ") == "2026-01-02-ca-marche-vraiment"
"""),
tache_python("code-version", """Écris une fonction Python `bump(version: str, part: str) -> str` pour des numéros
X.Y.Z : part vaut "major", "minor" ou "patch" ; major remet minor et patch à 0, minor remet patch à 0. Une version mal
formée (autre chose que trois entiers séparés par des points) lève ValueError.""", """
bump("1.4.2", "patch") == "1.4.3"
bump("1.4.2", "minor") == "1.5.0"
bump("1.4.2", "major") == "2.0.0"
bump("0.9.9", "patch") == "0.9.10"
_leve(lambda: bump("1.2", "patch"), ValueError)
_leve(lambda: bump("1.2.x", "minor"), ValueError)
"""),
tache_python("code-visibilite", """Écris une fonction Python `more_visible(a: dict, b: dict) -> bool` : vrai si la
visibilité `a` est plus ouverte que `b`. Une visibilité est {"level": niveau} avec niveau parmi, du plus restreint au
plus ouvert : "groups", "members", "unlisted", "public" ; pour "groups", elle a aussi "groups" (liste de noms). À
niveau égal "groups", `a` est plus ouverte si l'un de ses groupes n'est pas dans ceux de `b`. Sinon, à niveau égal :
faux.""", """
more_visible({"level": "public"}, {"level": "members"}) is True
more_visible({"level": "members"}, {"level": "public"}) is False
more_visible({"level": "unlisted"}, {"level": "unlisted"}) is False
more_visible({"level": "groups", "groups": ["a"]}, {"level": "groups", "groups": ["a", "b"]}) is False
more_visible({"level": "groups", "groups": ["a", "c"]}, {"level": "groups", "groups": ["a", "b"]}) is True
"""),
tache_python("code-tus", """Écris une fonction Python `parse_metadata(header: str) -> dict` qui lit un en-tête tus
`Upload-Metadata` : des paires « clé valeur » séparées par des virgules, la valeur encodée en base64 (une clé peut
n'avoir aucune valeur : elle vaut alors ""). Les espaces autour des paires sont ignorés.""", """
parse_metadata("path YmxvZy9hLnBuZw==,mode Y3JlYXRl") == {"path": "blog/a.png", "mode": "create"}
parse_metadata("keep dHJ1ZQ==, vide") == {"keep": "true", "vide": ""}
parse_metadata("") == {}
"""),
Tache("code-js", "code", "ecriture", """Écris une fonction JavaScript `formatBytes(n)` qui rend une taille lisible en
français avec une décimale au plus, sans zéro inutile : moins de 1024 → « 512 o » ; puis Kio, Mio, Gio (base 1024) ;
la virgule comme séparateur décimal (« 1,5 Kio »). Réponds seulement avec le code, dans un bloc ```javascript.""",
lambda r: executer("javascript", bloc(r, "javascript|js"), """
formatBytes(512) === "512 o"
formatBytes(1536) === "1,5 Kio"
formatBytes(1048576) === "1 Mio"
formatBytes(3221225472) === "3 Gio"
""")),
]
def controle_svg(r: str, formes: int) -> tuple[float, str]:
try:
racine = ET.fromstring(bloc(r, "svg|xml"))
except ET.ParseError as e:
return 0.0, f"SVG invalide ({e})"
attendus = {"viewBox": "0 0 24 24", "fill": "none", "stroke": "currentColor", "stroke-width": "2"}
justes = sum(racine.get(k) == v for k, v in attendus.items())
elements = [e for e in racine.iter() if e.tag.split("}")[-1] in ("path", "line", "polyline", "circle", "rect")]
note = (justes / len(attendus)) * 0.7 + (0.3 if len(elements) >= formes else 0)
return note, f"valide, {justes}/{len(attendus)} attributs, {len(elements)} tracés (dessin à regarder)"
SVG_CONSIGNE = """Écris une icône SVG 24×24, en traits seulement : viewBox="0 0 24 24", fill="none",
stroke="currentColor", stroke-width="2", stroke-linecap="round", stroke-linejoin="round". Réponds seulement avec le SVG,
dans un bloc ```svg. Icône : """
CODE += [
Tache("svg-nuage", "code", "ecriture", SVG_CONSIGNE + "un nuage avec une flèche vers le haut (envoi).",
lambda r: controle_svg(r, 2), a_juger="le dessin est-il un nuage avec une flèche vers le haut ?"),
Tache("svg-dossier", "code", "ecriture", SVG_CONSIGNE + "un dossier ouvert avec un signe « + ».",
lambda r: controle_svg(r, 2), a_juger="le dessin est-il un dossier avec un « + » ?"),
]
# --- texte ----------------------------------------------------------------------------------------------------------
FR_SOURCE = """Les médias des contenus **sans repo** (articles du blog, fiches) vivent sur le NAS, dans le repo Gitea privé
`apc-mediastorage`, en Git LFS : c'est la source de vérité, jamais nettoyée. IONOS ne fait que la diffusion de l'état
courant, reconstructible depuis le NAS. La chaîne d'outils envoie les fichiers par l'API LFS, puis commite elle-même
leurs pointeurs et la fiche `media.toml` par l'API « contents » : le repo n'est jamais cloné."""
EN_REFERENCE = """The media of content **without a repo** (blog posts, pages) live on the NAS, in the private Gitea repo
`apc-mediastorage`, in Git LFS: it is the source of truth, never cleaned up. IONOS only serves the current state, which
can be rebuilt from the NAS. The toolchain sends the files through the LFS API, then commits their pointers and the
`media.toml` record itself through the "contents" API: the repo is never cloned."""
EN_SOURCE = """When a release is published, the toolchain first checks everything locally: clean working tree, default
branch, changelog and contract. Only then does it commit the version, create the tag and push both at once. The release
is created as a **draft**, so the website never sees it half-uploaded; it is published once every file is attached."""
FR_REFERENCE = """Quand une version est publiée, la chaîne d'outils vérifie d'abord tout en local : arbre propre, branche
par défaut, journal des modifications et contrat. Ensuite seulement, elle commite la version, crée le tag et pousse les
deux en même temps. La release est créée en **brouillon**, pour que le site ne la voie jamais à moitié envoyée ; elle
est publiée une fois tous les fichiers joints."""
def controle_traduction(r: str, reference: str, termes: list[str], vers_fr: bool) -> tuple[float, str]:
score = chrf(r, reference)
gardes = [t for t in termes if t in r]
langue_ok = en_francais(r) == vers_fr
note = min(1.0, score / 0.65) * 0.6 + 0.3 * len(gardes) / len(termes) + (0.1 if langue_ok else 0)
return min(note, 1.0), f"chrF {score:.2f}, termes {len(gardes)}/{len(termes)}, langue {'ok' if langue_ok else 'FAUSSE'}"
def controle_notes(r: str) -> tuple[float, str]:
titres = re.findall(r"^###\s+(.+)$", r, re.M)
puces = re.findall(r"^\s*[-*]\s+(.+)$", r, re.M)
if not titres or not puces:
return 0.0, "ni titres ni puces"
copiees = sum(1 for p in puces if "SPEC" in p or "lot 1" in p.lower() or "Lot 2" in p)
points = [all(t.strip() in ("Ajouté", "Modifié", "Corrigé") for t in titres) and bool(titres),
3 <= len(puces) <= 10, en_francais(r), copiees == 0]
return sum(points) / len(points), (f"{len(titres)} titres, {len(puces)} puces, {copiees} détail(s) interne(s) "
"recopié(s) (exactitude à juger)")
TEXTE = [
Tache("trad-fr-en", "texte", "ecriture", "Traduis en anglais, sans rien ajouter ni commenter, en gardant le "
"Markdown et les termes entre ` ` :\n\n" + FR_SOURCE,
lambda r: controle_traduction(r, EN_REFERENCE, ["`apc-mediastorage`", "`media.toml`", "**", "LFS"], False)),
Tache("trad-en-fr", "texte", "ecriture", "Traduis en français, sans rien ajouter ni commenter, en gardant le "
"Markdown :\n\n" + EN_SOURCE,
lambda r: controle_traduction(r, FR_REFERENCE, ["**", "tag", "commit"], True)),
Tache("notes-version", "texte", "ecriture", """Voici les sujets des derniers commits d'un outil en ligne de commande
(a2m). Rédige la section « Non publié » d'un CHANGELOG en français : titres ### Ajouté, ### Modifié, ### Corrigé (seulement
ceux qui servent), une puce courte par changement utile à un utilisateur, sans détail interne ni invention. Réponds
seulement avec la section Markdown, sans titre de niveau 2.
- Médias : worker lancé de lui-même (verrou, --no-send), a2m media cleanup, a2m deploy --cleanup, détail des erreurs du site
- apc-spec v3.3.0 : keep (envoi, copie, a2m media keep), served_sha256, médias renvoyés à la republication d'un article retiré
- Lot 2 des médias : file SQLite, a2m media worker (tus, copie sur place, reprise), jetons d'envoi, état IONOS
- a2m media status et media visibility ; SPEC : lot 1 codé
- a2m blog new et drafts_path dans a2m config (brouillons d'articles hors des repos)
- Option --deploy validée dès la lecture""", controle_notes,
a_juger="chaque puce correspond-elle à un vrai changement, sans invention ?"),
Tache("resume-court", "texte", "lecture", """Résume ce texte en trois phrases au plus, en français, pour quelqu'un
qui ne connaît pas le projet. Réponds seulement avec le résumé.""", lambda r: _controle_resume_court(r),
fichier="contrat-medias.md"),
Tache("consigne-json", "texte", "ecriture", """Réponds UNIQUEMENT par un objet JSON (sans texte autour) décrivant
trois tâches pour publier un article de blog, avec les clés : "taches" (liste d'objets ayant "titre" (texte en
français), "duree_minutes" (entier) et "ordre" (entier de 1 à 3)).""", lambda r: _controle_json(r)),
]
def _controle_resume_court(r: str) -> tuple[float, str]:
phrases = [p for p in re.split(r"(?<=[.!?])\s+", r.strip()) if p]
couverture, manquants = mots_cles(r, [["nas"], ["ionos"], ["lfs", "gitea"], ["nettoy", "orphelin"]])
points = [len(phrases) <= 3, en_francais(r)]
note = couverture * (0.5 + 0.25 * sum(points)) # le contenu d'abord ; la forme module
return note, f"{len(phrases)} phrase(s), notions manquantes : {', '.join(manquants) or 'aucune'}"
def _controle_json(r: str) -> tuple[float, str]:
d = lire_json(r)
if not isinstance(d, dict) or not isinstance(d.get("taches"), list):
return 0.0, "JSON absent ou mal formé"
taches = d["taches"]
bonnes = [t for t in taches if isinstance(t, dict) and isinstance(t.get("titre"), str) and en_francais(t["titre"] + " le")
and isinstance(t.get("duree_minutes"), int) and t.get("ordre") in (1, 2, 3)]
texte_autour = not r.strip().startswith(("{", "```"))
note = (len(bonnes) / 3 if len(taches) == 3 else 0.3) - (0.2 if texte_autour else 0)
return max(note, 0.0), f"{len(taches)} tâche(s), {len(bonnes)} conforme(s)" + (", texte autour" if texte_autour else "")
# --- rapide : lire beaucoup pour rendre peu --------------------------------------------------------------------------
def controle_faits(r: str, attendus: dict) -> tuple[float, str]:
d = lire_json(r)
if not isinstance(d, dict):
return 0.0, "JSON absent ou mal formé"
faux = [k for k, v in attendus.items() if str(d.get(k)).strip().lower() != str(v).lower()]
return 1 - len(faux) / len(attendus), f"{len(attendus) - len(faux)}/{len(attendus)} faits exacts" + (
f" (faux : {', '.join(faux)})" if faux else "")
def controle_pytest(r: str) -> tuple[float, str]:
d = lire_json(r)
if not isinstance(d, dict):
return 0.0, "JSON absent ou mal formé"
attendus = {"test_upload_in_chunks_then_available", "test_slug_changed_removes_the_old_version",
"test_cleanup_simulates_then_deletes_on_request"}
trouves = {str(t).split("::")[-1].split(" ")[0] for t in d.get("echecs") or []}
points = [trouves == attendus, d.get("reussis") == 209, d.get("ignores") == 2, d.get("fichiers") ==
sorted(["tests/test_blog_publish.py", "tests/test_media_worker.py"])
or sorted(d.get("fichiers") or []) == ["tests/test_blog_publish.py", "tests/test_media_worker.py"]]
return sum(points) / len(points), f"{sum(points)}/4 points (échecs trouvés : {len(trouves & attendus)}/3)"
def controle_questions(r: str) -> tuple[float, str]:
d = lire_json(r)
if not isinstance(d, dict):
return 0.0, "JSON absent ou mal formé"
attendus = {"q1": ["30"], "q2": ["apc-mediastorage"], "q3": ["media-preprod.a2msystemes.org"],
"q4": ["nas"], "q5": [".incoming"]}
justes = [k for k, mots in attendus.items() if any(m in str(d.get(k, "")).lower() for m in mots)]
return len(justes) / len(attendus), f"{len(justes)}/{len(attendus)} réponses justes"
RAPIDE = [
Tache("log-deploiement", "rapide", "lecture", """Voici le log d'un déploiement de site. Réponds UNIQUEMENT par un objet
JSON avec ces clés : cible (preprod ou prod), commit (7 caractères), duree_totale (texte exact du log),
numero_deploiement (entier), sante_ok (booléen), fichiers_api_transferes (entier).""",
lambda r: controle_faits(r, {"cible": "preprod", "commit": "ec2db97", "duree_totale": "4 min 29 s",
"numero_deploiement": 20, "sante_ok": True, "fichiers_api_transferes": 0}),
fichier="deploiement.log"),
Tache("log-tests", "rapide", "lecture", """Voici la sortie d'une suite de tests. Réponds UNIQUEMENT par un objet JSON
avec : echecs (liste des noms des tests en échec, sans le chemin), reussis (entier), ignores (entier), fichiers (liste
triée des fichiers de test qui ont au moins un échec, chemins avec des « / »).""", controle_pytest,
fichier="pytest.log"),
Tache("questions-doc", "rapide", "lecture", """Lis ce document et réponds UNIQUEMENT par un objet JSON avec les clés
q1 à q5 (réponses courtes) : q1 = délai de grâce par défaut du nettoyage, en jours ; q2 = nom du repo Gitea des médias ;
q3 = adresse (domaine) des médias publics de préproduction ; q4 = lieu où se trouve la source de vérité des médias
(un mot) ; q5 = dossier d'IONOS où arrivent les envois en cours.""", controle_questions, fichier="contrat-medias.md"),
]
# --- vision -----------------------------------------------------------------------------------------------------------
def controle_alt(r: str) -> tuple[float, str]:
couverture, manquants = mots_cles(r, [["tête", "tete", "head"], ["mannequin", "buste", "sculpt", "modèle", "3d"],
["dos", "arrière", "derrière", "back"], ["gris", "grey", "gray"]])
court = len(r.strip()) <= 125
return couverture * 0.8 + (0.2 if court else 0), f"{len(r.strip())} caractères, manque : {', '.join(manquants) or 'rien'}"
def controle_lecture_image(r: str) -> tuple[float, str]:
attendus = ["a2m media worker --once", "erreur 409", "le nas et le site divergent", "3 livraison"]
bas = r.lower()
justes = [a for a in attendus if a in bas]
return len(justes) / len(attendus), f"{len(justes)}/{len(attendus)} passages lus"
VISION = [
Tache("vision-alt", "vision", "lecture", "Écris le texte alternatif (attribut alt) de cette image pour un site web, "
"en français, en une phrase de 125 caractères au plus. Réponds seulement avec la phrase.", controle_alt,
fichier="vision-mannequin.png"),
Tache("vision-texte", "vision", "lecture", "Recopie exactement le texte de cette capture d'écran, ligne par ligne, "
"sans rien ajouter.", controle_lecture_image, fichier="vision-texte.png"),
Tache("vision-formes", "vision", "lecture", "Compte les formes de cette image. Réponds UNIQUEMENT par un objet JSON : "
'{"cercles_rouges": n, "carres_bleus": n, "triangles_verts": n}.',
lambda r: controle_faits(r, {"cercles_rouges": 3, "carres_bleus": 2, "triangles_verts": 1}),
fichier="vision-formes.png"),
]
TACHES = CODE + TEXTE + RAPIDE + VISION
CATEGORIES = ("code", "texte", "rapide", "vision")
+109
View File
@@ -0,0 +1,109 @@
"""Contrôle du banc lui-même : une réponse parfaite doit avoir 1, une réponse fausse une note basse.
Usage : python verifier_controles.py (à relancer après toute modification de taches.py).
"""
from taches import EN_REFERENCE, FR_REFERENCE, TACHES
PARFAITES = {
"code-slug": '''```python
import re, unicodedata
def slug(date, title):
t = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode().lower()
t = re.sub(r"[^a-z0-9]+", "-", t).strip("-")
return f"{date}-{t}" if t else date
```''',
"code-version": '''```python
def bump(version, part):
morceaux = version.split(".")
if len(morceaux) != 3 or not all(m.isdigit() for m in morceaux):
raise ValueError(version)
a, b, c = map(int, morceaux)
return {"major": f"{a + 1}.0.0", "minor": f"{a}.{b + 1}.0", "patch": f"{a}.{b}.{c + 1}"}[part]
```''',
"code-visibilite": '''```python
RANG = {"groups": 1, "members": 2, "unlisted": 3, "public": 4}
def more_visible(a, b):
if RANG[a["level"]] != RANG[b["level"]]:
return RANG[a["level"]] > RANG[b["level"]]
if a["level"] == "groups":
return not set(a.get("groups", [])) <= set(b.get("groups", []))
return False
```''',
"code-tus": '''```python
import base64
def parse_metadata(header):
resultat = {}
for paire in filter(None, (p.strip() for p in header.split(","))):
cle, _, valeur = paire.partition(" ")
resultat[cle] = base64.b64decode(valeur).decode() if valeur else ""
return resultat
```''',
"code-js": '''```javascript
function formatBytes(n) {
const unites = ["o", "Kio", "Mio", "Gio"];
let i = 0;
while (n >= 1024 && i < unites.length - 1) { n /= 1024; i++; }
const v = Math.round(n * 10) / 10;
return String(v).replace(".", ",") + " " + unites[i];
}
```''',
"svg-nuage": '''```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
<path d="M12 18v-7"/><path d="M9 14l3-3 3 3"/></svg>
```''',
"svg-dossier": '''```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<line x1="12" y1="10" x2="12" y2="16"/><line x1="9" y1="13" x2="15" y2="13"/></svg>
```''',
"trad-fr-en": EN_REFERENCE,
"trad-en-fr": FR_REFERENCE,
"notes-version": """### Ajouté
- `a2m media cleanup` et `a2m deploy --cleanup` : nettoyage des médias qui ne sont plus utilisés.
- `a2m media keep` : protéger un média du nettoyage.
- `a2m media worker` : envoi des médias vers le site, avec reprise après une coupure.
- `a2m media status` et `a2m media visibility`.
- `a2m blog new` et le dossier des brouillons dans `a2m config`.
### Modifié
- L'envoi des médias se lance de lui-même après une publication (`--no-send` pour l'éviter).
- Les erreurs du site s'affichent en détail.
- L'option `--deploy` est vérifiée dès la lecture de la commande.""",
"resume-court": "Les médias des articles sont gardés sur le NAS dans un repo Gitea en LFS, qui fait foi et n'est "
"jamais nettoyé. IONOS les diffuse et supprime ceux qui ne sont plus cités après un délai. La "
"publication n'attend pas les médias, envoyés ensuite par morceaux.",
"consigne-json": '{"taches": [{"titre": "Écrire le brouillon", "duree_minutes": 60, "ordre": 1}, {"titre": "Ajouter '
'les images", "duree_minutes": 15, "ordre": 2}, {"titre": "Publier et vérifier le site", '
'"duree_minutes": 10, "ordre": 3}]}',
"log-deploiement": '{"cible": "preprod", "commit": "ec2db97", "duree_totale": "4 min 29 s", "numero_deploiement": '
'20, "sante_ok": true, "fichiers_api_transferes": 0}',
"log-tests": '{"echecs": ["test_upload_in_chunks_then_available", "test_slug_changed_removes_the_old_version", '
'"test_cleanup_simulates_then_deletes_on_request"], "reussis": 209, "ignores": 2, "fichiers": '
'["tests/test_blog_publish.py", "tests/test_media_worker.py"]}',
"questions-doc": '{"q1": "30 jours", "q2": "apc-mediastorage", "q3": "media-preprod.a2msystemes.org", "q4": "le NAS", '
'"q5": ".incoming/"}',
"vision-alt": "Tête de mannequin lisse vue de dos, sur fond gris.",
"vision-texte": "PS E:\\projets> a2m media worker --once\nErreur 409 : le NAS et le site divergent\n"
"Fin : 3 livraison(s) encore en attente",
"vision-formes": '{"cercles_rouges": 3, "carres_bleus": 2, "triangles_verts": 1}',
}
FAUSSE = "Je ne sais pas."
def main() -> None:
erreurs = 0
for tache in TACHES:
bonne, detail = tache.controle(PARFAITES[tache.id])
mauvaise, _ = tache.controle(FAUSSE)
ok = bonne >= 0.99 and mauvaise <= 0.3
erreurs += not ok
print(f"{'ok ' if ok else 'KO '} {tache.id:<16} parfaite {bonne:.2f} ({detail}) ; fausse {mauvaise:.2f}")
print(f"\n{len(TACHES)} tâches, {erreurs} contrôle(s) à revoir")
raise SystemExit(1 if erreurs else 0)
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
import { expect, test } from 'claude-code/testing'
import { ajouter, cheminNormal, cible, evenementCorrection, mesurerCorrection, net, VIDE } from './economies'
test('cheminNormal normalise les chemins Windows et Unix', () => {
expect(cheminNormal('E:\\Projet\\src\\a.py')).toBe('e:/projet/src/a.py')
})
test('cheminNormal gère les chemins relatifs avec racine', () => {
expect(cheminNormal('src/./b.py', 'E:\\Projet')).toBe('e:/projet/src/b.py')
})
test('cheminNormal supprime les doublons de slash', () => {
expect(cheminNormal('C:/x//y.txt')).toBe('c:/x/y.txt')
})
test('cheminNormal ignore la racine pour les chemins absolus', () => {
expect(cheminNormal('/home/u/z.md', 'E:\\P')).toBe('/home/u/z.md')
})
test('cible retourne undefined pour un outil inconnu', () => {
const sorties = new Map([['e:/projet/src/a.py', 'dem-1']])
expect(cible('Grep', { file_path: 'e:/projet/src/a.py' }, sorties)).toBeUndefined()
expect(cible('Read', { file_path: 'e:/projet/src/a.py' }, sorties)).toBe('dem-1')
})
test('cible retourne la demande pour un outil Edit', () => {
const sorties = new Map([['e:/projet/src/a.py', 'dem-1']])
expect(cible('Edit', { file_path: 'E:\\Projet\\src\\a.py' }, sorties)).toBe('dem-1')
})
test('cible retourne undefined pour un autre fichier', () => {
const sorties = new Map([['e:/projet/src/a.py', 'dem-1']])
expect(cible('Edit', { file_path: 'e:/projet/src/b.py' }, sorties)).toBeUndefined()
})
test('cible retourne la demande pour NotebookEdit', () => {
const sorties = new Map([['e:/projet/src/a.py', 'dem-1']])
expect(cible('NotebookEdit', { notebook_path: 'e:/projet/src/a.py' }, sorties)).toBe('dem-1')
})
test('cible Bash retourne la demande si le chemin est inclus', () => {
const sorties = new Map([['e:/projet/src/a.py', 'dem-1']])
expect(cible('Bash', { command: 'cat E:\\Projet\\src\\a.py' }, sorties)).toBe('dem-1')
})
test('cible Bash retourne undefined pour un nom de fichier court', () => {
const sorties = new Map([['e:/projet/src/a.py', 'dem-1']])
expect(cible('Bash', { command: 'python a.py' }, sorties)).toBeUndefined()
})
test('cible Bash retourne la demande pour un nom de fichier long', () => {
const sorties = new Map([['e:/projet/src/test_slug.py', 'dem-2']])
expect(cible('Bash', { command: 'cd tests && sed -i s/x/y/ test_slug.py' }, sorties)).toBe('dem-2')
})
test('mesurerCorrection Read calcule les tokens lus', () => {
const resultat = 'a'.repeat(40)
expect(mesurerCorrection('Read', {}, resultat)).toEqual({ lus: 10, ecrits: 0 })
})
test('mesurerCorrection Edit calcule les tokens lus et écrits', () => {
const args = { old_string: 'a'.repeat(8), new_string: 'a'.repeat(12) }
expect(mesurerCorrection('Edit', args)).toEqual({ lus: 0, ecrits: 5 })
})
test('mesurerCorrection Write calcule les tokens écrits', () => {
const args = { content: 'a'.repeat(41) }
expect(mesurerCorrection('Write', args)).toEqual({ lus: 0, ecrits: 11 })
})
test('mesurerCorrection Bash calcule les tokens lus et écrits', () => {
const args = { command: 'a'.repeat(4) }
const resultat = 'a'.repeat(8)
expect(mesurerCorrection('Bash', args, resultat)).toEqual({ lus: 2, ecrits: 1 })
})
test('mesurerCorrection outil inconnu retourne zéro', () => {
expect(mesurerCorrection('Grep', { pattern: 'a'.repeat(40) }, 'a'.repeat(40))).toEqual({ lus: 0, ecrits: 0 })
})
test('evenementCorrection construit l\'objet complet', () => {
const c = { lus: 3, ecrits: 7 }
const a = { sessionId: 's1', demandeId: 'dem-1', outil: 'Edit', toolUseId: 'tu-1', maintenant: 0 }
const resultat = evenementCorrection(a, c)
expect(resultat).toEqual({
schema: 1,
type: 'correction',
id: 'claude-code:s1:tu-1',
date: '1970-01-01T00:00:00.000Z',
client: 'claude-code',
session_id: 's1',
demande_id: 'dem-1',
outil: 'Edit',
tool_use_id: 'tu-1',
tokens_lus: 3,
tokens_ecrits: 7,
cout: 10
})
})
test('ajouter cumule les totaux sans corrections', () => {
const t = { delegations: 2, evites: 100, pilotage: 10 }
const d = { corrections: 5 }
const resultat = ajouter(t, d)
expect(resultat).toEqual({ delegations: 2, evites: 100, pilotage: 10, corrections: 5 })
})
test('net calcule la différence entre évités et coûts', () => {
const t = { delegations: 1, evites: 1000, pilotage: 200, corrections: 300 }
expect(net(t)).toBe(500)
})
test('net retourne zéro pour VIDE', () => {
expect(net(VIDE)).toBe(0)
})
+43
View File
@@ -0,0 +1,43 @@
import { expect, test } from 'claude-code/testing'
import { court, cout, estimer, ligne, lireResultat, VIDE } from './economies'
const REPONSE = { ok: true, demande_id: 'dem-1', tokens_entree: 2049, tokens_sortie: 892, reponse: 'x'.repeat(400) }
test('appel direct : consigne et réponse relue déduites', () => {
const consigne = 'c'.repeat(400) // 100 tokens
expect(estimer(consigne, 'complet', REPONSE, false)).toEqual({
lus: 1949, ecrits: 892, consigne: 100, rendus: 892, evites: 1949 + 892 - 100 - 892,
})
expect(estimer(consigne, 'apercu', REPONSE, false).evites).toBe(1949 + 892 - 100 - 100)
})
test('par le subagent : rien déduit ici, le pilotage compte à part', () => {
expect(estimer('c'.repeat(400), 'complet', REPONSE, true).evites).toBe(1949 + 892)
})
test('pilotage : lecture en cache au dixième', () => {
expect(cout({ input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 1000, cache_creation_input_tokens: 10 }))
.toBe(260)
})
test('résultat de deleguer : seulement une réussite avec ses tokens', () => {
expect(lireResultat(JSON.stringify(REPONSE))?.tokens_sortie).toBe(892)
expect(lireResultat('{"ok":false,"erreur":"aucun modèle chargé"}')).toBeUndefined()
expect(lireResultat('{"ok":null,"demande_id":"dem-2"}')).toBeUndefined()
expect(lireResultat('pas du JSON')).toBeUndefined()
})
test('affichage', () => {
expect(court(18240)).toBe('18,2k')
expect(court(2000)).toBe('2k')
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)')
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)')
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)')
})
+248
View File
@@ -0,0 +1,248 @@
// Estimation des tokens que Claude n'a pas eu à traiter grâce à une délégation à MIAM (calcul pur, testé).
//
// Sans déléguer, Claude aurait lu les fichiers et écrit la réponse ; en déléguant, il écrit la consigne et lit ce que
// MIAM lui renvoie (la réponse entière, ou son aperçu avec retour="apercu"). MIAM donne les tokens lus et écrits par le
// 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.
export type Delegation = {
demande_id?: string
tokens_entree?: number
tokens_sortie?: number
reponse?: string
sortie?: string // fichier écrit par MIAM (chemin absolu) : ce que Claude y reprend est une correction
}
// consigne et rendus : estimés, déduits seulement pour un appel direct (par le subagent, ils sont dans le pilotage).
export type Estimation = { lus: number; ecrits: number; consigne: number; rendus: number; evites: number }
// corrections : relectures et retouches, par l'agent principal, des fichiers écrits par une délégation.
export type Totaux = { delegations: number; evites: number; pilotage: number; corrections: number }
export type Correction = { lus: number; ecrits: number }
export type Usage = {
input_tokens: number
output_tokens: number
cache_read_input_tokens: number
cache_creation_input_tokens: number
}
export const VIDE: Totaux = { delegations: 0, evites: 0, pilotage: 0, corrections: 0 }
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)
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
const deduits = parSubagent ? 0 : redige + rendus
return { lus, ecrits, consigne: redige, rendus, evites: lus + ecrits - deduits }
}
// Coût d'un tour du subagent : une lecture en cache compte pour un dixième (rapport des tarifs).
export const cout = (u: Usage) =>
u.input_tokens + u.cache_creation_input_tokens + u.output_tokens + Math.round(u.cache_read_input_tokens / 10)
// `t` peut venir d'une version précédente du mod (cumul gardé sans `corrections`).
export const ajouter = (t: Partial<Totaux>, d: Partial<Totaux>): Totaux => ({
delegations: (t.delegations ?? 0) + (d.delegations ?? 0),
evites: (t.evites ?? 0) + (d.evites ?? 0),
pilotage: (t.pilotage ?? 0) + (d.pilotage ?? 0),
corrections: (t.corrections ?? 0) + (d.corrections ?? 0),
})
export const net = (t: Totaux) => t.evites - t.pilotage - t.corrections
// --- Corrections : ce que l'agent principal relit ou retouche dans un fichier écrit par une délégation -------------
// Chemin comparable : barres obliques, minuscules (Windows), sans « ./ » ni doublons de « / ».
export function cheminNormal(chemin: string, racine?: string): string {
const p = chemin.replace(/\\/g, '/')
const absolu = /^[a-z]:\//i.test(p) || p.startsWith('/')
const complet = absolu || !racine ? p : `${racine.replace(/\\/g, '/')}/${p}`
return complet.replace(/\/\.\//g, '/').replace(/\/{2,}/g, '/').toLowerCase()
}
const nomDe = (chemin: string) => chemin.slice(chemin.lastIndexOf('/') + 1)
// Demande dont l'appel touche la sortie : chemin exact pour Read/Edit/Write/NotebookEdit ; pour Bash, le chemin ou le
// nom du fichier dans la commande (nom d'au moins 6 caractères, pour éviter les faux positifs).
export function cible(outil: string, args: Record<string, unknown>, sorties: ReadonlyMap<string, string>):
string | undefined {
if (outil === 'Bash') {
const commande = String(args.command ?? '').replace(/\\/g, '/').toLowerCase()
for (const [chemin, demande] of sorties) {
const nom = nomDe(chemin)
if (commande.includes(chemin) || (nom.length >= 6 && commande.includes(nom))) {
return demande
}
}
return undefined
}
const chemin = ['Read', 'Edit', 'Write', 'NotebookEdit'].includes(outil) ? (args.file_path ?? args.notebook_path) : null
return typeof chemin === 'string' ? sorties.get(cheminNormal(chemin)) : undefined
}
// Tokens d'une reprise : ce que Claude écrit (ancien et nouveau texte d'un Edit, contenu d'un Write, commande Bash)
// ou lit (résultat d'un Read).
export function mesurerCorrection(outil: string, args: Record<string, unknown>, resultat?: string): Correction {
const t = (v: unknown) => (typeof v === 'string' ? tokens(v) : 0)
switch (outil) {
case 'Read':
return { lus: t(resultat), ecrits: 0 }
case 'Edit':
return { lus: 0, ecrits: t(args.old_string) + t(args.new_string) }
case 'Write':
return { lus: 0, ecrits: t(args.content) }
case 'NotebookEdit':
return { lus: 0, ecrits: t(args.new_source) }
case 'Bash':
return { lus: t(resultat), ecrits: t(args.command) }
default:
return { lus: 0, ecrits: 0 }
}
}
export function court(n: number): string {
const a = Math.abs(n)
const s = a >= 1e6 ? `${(a / 1e6).toFixed(1)}M` : a >= 1e3 ? `${(a / 1e3).toFixed(1)}k` : `${a}`
return (n < 0 ? '-' : '') + s.replace('.', ',').replace(',0', '')
}
export function ligne(session: Totaux, cumul: Totaux): string {
const couts = [
session.pilotage ? ` · pilotage ${court(session.pilotage)}` : '',
session.corrections ? ` · corrections ${court(session.corrections)}` : '',
].join('')
const detail = couts ? `${couts} · net ~${court(net(session))}` : ''
return session.delegations
? `MIAM ▸ ${session.delegations} délégation(s) · ~${court(session.evites)} tokens évités${detail}` +
` (cumul ~${court(net(cumul))})`
: `MIAM ▸ cumul ~${court(net(cumul))} tokens évités en ${cumul.delegations} délégation(s)`
}
// Résultat de `deleguer` tel que Claude Code le rend : le texte JSON de MIAM.
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
? (r as Delegation)
: undefined
} catch {
return undefined
}
}
// --- Événements envoyés à MIAM (POST /economies, schéma 1 ; contrat : docs/token-summary-mod.md) -------------------
export const SCHEMA = 1
export const CLIENT = 'claude-code'
export const ATTENTE_MAX = 500 // événements gardés quand MIAM ne répond pas ; les plus anciens partent d'abord
export type EvenementDelegation = {
schema: typeof SCHEMA
type: 'delegation'
id: string
date: string
client: typeof CLIENT
session_id: string
demande_id: string
agent_id: string | null
par_subagent: boolean
retour: 'complet' | 'apercu'
tokens_entree: number
tokens_sortie: number
estimation: Estimation & { methode: 'caracteres/4' }
}
export type EvenementPilotage = {
schema: typeof SCHEMA
type: 'pilotage'
id: string
date: string
client: typeof CLIENT
session_id: string
agent_id: string
turn_id: string
demande_ids: string[]
modele: string
usage: Usage
cout: number
}
export type EvenementCorrection = {
schema: typeof SCHEMA
type: 'correction'
id: string
date: string
client: typeof CLIENT
session_id: string
demande_id: string
outil: string
tool_use_id: string
tokens_lus: number
tokens_ecrits: number
cout: number
}
export type Evenement = EvenementDelegation | EvenementPilotage | EvenementCorrection
const iso = (ms: number) => new Date(ms).toISOString()
export function evenementDelegation(
a: { sessionId: string; agentId?: string; retour?: string; maintenant: number },
r: Delegation & { demande_id: string },
est: Estimation,
): EvenementDelegation {
return {
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,
estimation: { ...est, methode: 'caracteres/4' },
}
}
export function evenementPilotage(
a: { sessionId: string; agentId: string; turnId: string; maintenant: number; demandeIds: string[] },
u: Usage & { model: string },
): EvenementPilotage {
const usage = {
input_tokens: u.input_tokens, output_tokens: u.output_tokens,
cache_read_input_tokens: u.cache_read_input_tokens, cache_creation_input_tokens: u.cache_creation_input_tokens,
}
return {
schema: SCHEMA, type: 'pilotage', id: `${CLIENT}:${a.sessionId}:${a.agentId}:${a.turnId}`,
date: iso(a.maintenant), client: CLIENT, session_id: a.sessionId, agent_id: a.agentId, turn_id: a.turnId,
demande_ids: a.demandeIds, modele: u.model, usage, cout: cout(usage),
}
}
export function evenementCorrection(
a: { sessionId: string; demandeId: string; outil: string; toolUseId: string; maintenant: number },
c: Correction,
): EvenementCorrection {
return {
schema: SCHEMA, type: 'correction', id: `${CLIENT}:${a.sessionId}:${a.toolUseId}`, date: iso(a.maintenant),
client: CLIENT, session_id: a.sessionId, demande_id: a.demandeId, outil: a.outil, tool_use_id: a.toolUseId,
tokens_lus: c.lus, tokens_ecrits: c.ecrits, cout: c.lus + c.ecrits,
}
}
// Lot à envoyer : ceux restés en attente d'abord, sans doublon d'id, borné à ATTENTE_MAX.
export function lot(attente: readonly Evenement[], nouveaux: readonly Evenement[]): Evenement[] {
const vus = new Set<string>()
return [...attente, ...nouveaux].filter(ev => !vus.has(ev.id) && vus.add(ev.id)).slice(-ATTENTE_MAX)
}
// Après un envoi accepté (ou refusé pour de bon) : retire de l'attente ce qui est parti, garde ce qui est arrivé depuis.
export const retirer = (attente: readonly Evenement[], partis: readonly Evenement[]): Evenement[] => {
const ids = new Set(partis.map(ev => ev.id))
return attente.filter(ev => !ids.has(ev.id))
}
// Réponse de MIAM : 2xx reçu ; 400/422 lot refusé tel quel (le renvoyer ne servirait à rien) ; sinon on garde.
export const aGarder = (status: number) => !(status >= 200 && status < 300) && status !== 400 && status !== 422
+91
View File
@@ -0,0 +1,91 @@
import { expect, test } from 'claude-code/testing'
import { ATTENTE_MAX, aGarder, estimer, evenementDelegation, evenementPilotage, lot } from './economies'
import type { Evenement } from './economies'
const USAGE = { model: 'm1', input_tokens: 10, output_tokens: 10, cache_read_input_tokens: 10, cache_creation_input_tokens: 10 }
const pilotage = (turnId: string) =>
evenementPilotage({ sessionId: 's1', agentId: 'a1', turnId, maintenant: 0, demandeIds: ['dem-1'] }, USAGE)
test('evenementDelegation appel direct agentId absent', () => {
const sessionId = 's1'
const maintenant = 0
const r = { demande_id: 'dem-1', tokens_entree: 2049, tokens_sortie: 892 }
const est = estimer('c'.repeat(400), undefined, r, false)
const ev = evenementDelegation({ sessionId, maintenant }, r, est)
expect(ev.schema).toBe(1)
expect(ev.type).toBe('delegation')
expect(ev.id).toBe('claude-code:dem-1')
expect(ev.date).toBe('1970-01-01T00:00:00.000Z')
expect(ev.client).toBe('claude-code')
expect(ev.session_id).toBe('s1')
expect(ev.demande_id).toBe('dem-1')
expect(ev.agent_id).toBeNull()
expect(ev.par_subagent).toBe(false)
expect(ev.retour).toBe('complet')
expect(ev.tokens_entree).toBe(2049)
expect(ev.tokens_sortie).toBe(892)
expect(ev.estimation).toEqual({
lus: 1949, ecrits: 892, consigne: 100, rendus: 892, evites: 1849, methode: 'caracteres/4',
})
})
test('evenementDelegation par un subagent', () => {
const sessionId = 's1'
const maintenant = 0
const r = { demande_id: 'dem-1', tokens_entree: 2049, tokens_sortie: 892 }
const est = estimer('c'.repeat(400), 'apercu', r, true)
const ev = evenementDelegation({ sessionId, agentId: 'a1', retour: 'apercu', maintenant }, r, est)
expect(ev.agent_id).toBe('a1')
expect(ev.par_subagent).toBe(true)
expect(ev.retour).toBe('apercu')
})
test('evenementPilotage', () => {
const sessionId = 's1'
const agentId = 'a1'
const turnId = 't1'
const maintenant = 0
const demandeIds = ['dem-1']
const u = {
model: 'haiku', input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 1000, cache_creation_input_tokens: 10,
}
const ev = evenementPilotage({ sessionId, agentId, turnId, maintenant, demandeIds }, u)
expect(ev.id).toBe('claude-code:s1:a1:t1')
expect(ev.modele).toBe('haiku')
expect(ev.cout).toBe(260)
expect(ev.demande_ids).toEqual(demandeIds)
expect(ev.usage).toEqual({
input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 1000, cache_creation_input_tokens: 10,
})
expect(ev.usage).not.toHaveProperty('model')
})
test('lot : attente puis nouveaux, doublons retirés', () => {
const [ev1, ev2, ev3] = ['t1', 't2', 't3'].map(pilotage)
const attente: readonly Evenement[] = [ev1, ev2]
expect(lot(attente, [ev3, ev1]).map(ev => ev.id)).toEqual([ev1.id, ev2.id, ev3.id])
})
test('lot borné à ATTENTE_MAX : les plus anciens partent', () => {
const tous = Array.from({ length: ATTENTE_MAX + 5 }, (_, i) => pilotage(`t${i}`))
const resultat = lot(tous.slice(0, 10), tous.slice(10))
expect(resultat.length).toBe(ATTENTE_MAX)
expect(resultat[0].id).toBe(tous[5].id)
})
test('aGarder 200 et 204', () => {
expect(aGarder(200)).toBe(false)
expect(aGarder(204)).toBe(false)
})
test('aGarder 400 et 422', () => {
expect(aGarder(400)).toBe(false)
expect(aGarder(422)).toBe(false)
})
test('aGarder 401 404 500', () => {
expect(aGarder(401)).toBe(true)
expect(aGarder(404)).toBe(true)
expect(aGarder(500)).toBe(true)
})
+1
View File
@@ -0,0 +1 @@
{ "modules": ["./register.ts"] }
+115
View File
@@ -0,0 +1,115 @@
import { expect, test } from 'claude-code/testing'
const OUTIL = 'mcp__plugin_miam_miam__deleguer'
const SUIVRE = 'mcp__plugin_miam_miam__suivre'
type Envoi = { url: string; auth?: string; ids: string[] }
const attendre = async (fini: () => boolean) => {
for (let i = 0; i < 100 && !fini(); i++) {
await new Promise(r => setTimeout(r, 5))
}
}
test('une délégation du subagent est comptée, son pilotage aussi, et MIAM les reçoit hors des hooks',
{ options: { jeton: 'x' } }, async ($, on) => {
const statuts: (string | undefined)[] = []
const toasts: string[] = []
const envois: Envoi[] = []
const store = new Map<string, unknown>()
let tic: () => void = () => {}
on('store.get', (_, e) => ({ value: store.get(e.key) }))
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
on('ui.status', (_, e) => (statuts.push(e.text), { value: undefined }))
on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined }))
on('session.id', () => ({ value: 's1' }))
on('clock.now', () => ({ value: 0 }))
on('command.register', () => ({ value: { command: 'miam-economies' } }))
on('session.start', (_, e) => ({ cwd: e.cwd }))
// Chaque période de la minuterie attend que le test la déclenche.
on('clock.every', () => new Promise(r => (tic = () => r({ value: undefined }))))
on('http.fetch', (_, e) => {
const corps = JSON.parse(e.init?.body ?? '{}') as { evenements: { id: string }[] }
envois.push({ url: e.url, auth: e.init?.headers?.Authorization, ids: corps.evenements.map(ev => ev.id) })
const status = envois.length === 1 ? 503 : 200 // MIAM indisponible la première fois
return { value: { status, ok: status === 200, headers: {}, text: '' } }
})
on('tool.call', (_, e) =>
e.tool === OUTIL
? { result: {}, text: '{"ok":null,"demande_id":"dem-1","etat":"en_file"}' }
: { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-1', tokens_entree: 4100, tokens_sortie: 900 }) },
)
on('turn.complete', (_, e) => ({ text: e.answer }))
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), agentId: 'a1' } as never)
expect(toasts).toEqual([]) // en file : rien encore
await $.tool.call({ tool: SUIVRE, demande_id: 'dem-1', agentId: 'a1' } as never)
await $.tool.call({ tool: SUIVRE, demande_id: 'dem-1', agentId: 'a1' } as never) // relu : pas recompté
expect(toasts).toEqual(['MIAM : ~4,9k tokens évités (4k lus, 900 écrits)'])
expect(envois).toEqual([]) // le hook n'envoie rien lui-même
tic() // 1er envoi : 503, la délégation reste en attente
await attendre(() => envois.length === 1)
await $.turn.complete({
turnId: 't', answer: 'compte rendu', durationMs: 1, isAborted: false, reason: 'answer', agentId: 'a1',
usage: {
model: 'haiku', input_tokens: 800, output_tokens: 200, cache_read_input_tokens: 10000,
cache_creation_input_tokens: 0,
},
} as never)
expect(statuts.at(-1)).toBe('MIAM ▸ 1 délégation(s) · ~4,9k tokens évités · pilotage 2k · net ~2,9k (cumul ~2,9k)')
expect(store.get('cumul')).toEqual({ delegations: 1, evites: 4900, pilotage: 2000, corrections: 0 })
await attendre(() => false) // laisse finir le 1er envoi et demander la période suivante
tic() // 2e envoi : délégation + pilotage, acceptés
await attendre(() => envois.length === 2 && (store.get('a_envoyer') as unknown[]).length === 0)
expect(envois.map(v => v.ids)).toEqual([['claude-code:dem-1'], ['claude-code:dem-1', 'claude-code:s1:a1:t']])
expect(envois[0]).toMatchObject({ url: 'http://127.0.0.1:8090/economies', auth: 'Bearer x' })
expect(store.get('a_envoyer')).toEqual([])
})
test('les reprises par l’agent principal d’un fichier écrit par MIAM sont déduites, pas celles du subagent',
{ options: { jeton: 'x' } }, async ($, on) => {
const statuts: (string | undefined)[] = []
const store = new Map<string, unknown>()
on('store.get', (_, e) => ({ value: store.get(e.key) }))
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
on('ui.status', (_, e) => (statuts.push(e.text), { value: undefined }))
on('ui.toast', () => ({ value: undefined }))
on('session.id', () => ({ value: 's1' }))
on('clock.now', () => ({ value: 0 }))
on('tool.call', (_, e) =>
e.tool === OUTIL
? {
result: {},
text: JSON.stringify({
ok: true, demande_id: 'dem-7', tokens_entree: 600, tokens_sortie: 2000, sortie: 'E:\\p\\tests\\test_x.py',
reponse: 'a'.repeat(40),
}),
}
: { result: {}, text: e.tool === 'Read' ? 'r'.repeat(400) : 'ok' },
)
// Appel direct, retour apercu : 600 - 100 lus + 2000 écrits - 100 (consigne) - 10 (aperçu) = 2390
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), retour: 'apercu' } as never)
await $.tool.call({ tool: 'Read', file_path: 'e:/p/tests/test_x.py', tool_use_id: 'tu-1' } as never) // 100 lus
await $.tool.call({
tool: 'Edit', file_path: 'E:\\p\\tests\\test_x.py', old_string: 'o'.repeat(40), new_string: 'n'.repeat(80),
tool_use_id: 'tu-2',
} as never) // 30 écrits
await $.tool.call({ tool: 'Edit', file_path: 'E:\\p\\autre.py', old_string: 'o', new_string: 'n' } as never)
await $.tool.call({
tool: 'Edit', file_path: 'E:\\p\\tests\\test_x.py', old_string: 'o'.repeat(400), new_string: 'n',
agentId: 'a9',
} as never) // par un subagent : dans son pilotage
expect(statuts.at(-1)).toBe('MIAM ▸ 1 délégation(s) · ~2,4k tokens évités · corrections 130 · net ~2,3k (cumul ~2,3k)')
expect(store.get('cumul')).toEqual({ delegations: 1, evites: 2390, pilotage: 0, corrections: 130 })
const attente = store.get('a_envoyer') as { type: string; id: string; cout?: number }[]
expect(attente.map(ev => [ev.type, ev.id, ev.cout])).toEqual([
['delegation', 'claude-code:dem-7', undefined],
['correction', 'claude-code:s1:tu-1', 100],
['correction', 'claude-code:s1:tu-2', 30],
])
})
+188
View File
@@ -0,0 +1,188 @@
import type { EngineInterface, Register } from 'claude-code'
import {
aGarder, ajouter, cheminNormal, cible, court, cout, estimer, evenementCorrection, evenementDelegation,
evenementPilotage, ligne, lireResultat, lot, mesurerCorrection, net, retirer, VIDE,
} from './economies'
import type { Evenement, Totaux } from './economies'
// Outils de MIAM : serveur du plugin (mcp__plugin_miam_miam__…) ou serveur de projet nommé miam (mcp__miam__…).
const OUTIL = /^mcp__(?:plugin_miam_)?miam__(deleguer|suivre)$/
// Outils par lesquels l'agent principal relit ou retouche un fichier écrit par une délégation.
const REPRISE = /^(Read|Edit|Write|NotebookEdit|Bash)$/
const CUMUL = 'cumul'
const ATTENTE = 'a_envoyer' // événements que MIAM n'a pas encore reçus (fermé, ancienne version sans la route…)
const PERIODE_MS = 5000 // envoi à MIAM hors des hooks : une délégation n'attend jamais MIAM
type Demande = { consigne: string; retour?: string; agentId?: string }
type Miam = { url: string; jeton: string }
// Ajoute au cumul (gardé d'une session à l'autre) et met à jour la ligne d'état ; renvoie les totaux de la session.
async function afficher($: EngineInterface, session: Totaux, ajout: Partial<Totaux>): Promise<Totaux> {
const suite = ajouter(session, ajout)
const cumul = ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, ajout)
await $.store.set(CUMUL, cumul)
$.ui.status(ligne(suite, cumul))
return suite
}
async function mettreEnAttente($: EngineInterface, nouveaux: Evenement[]): Promise<void> {
await $.store.set(ATTENTE, lot(((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? [], nouveaux))
}
// POST /economies de MIAM avec tout ce qui attend ; ne lève jamais : sans MIAM, l'attente reste pour le prochain tour.
async function envoyer($: EngineInterface, miam: Miam): Promise<void> {
const partis = ((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? []
if (!partis.length) {
return
}
try {
const r = await $.http.fetch(miam.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${miam.jeton}` },
body: JSON.stringify({ evenements: partis }),
})
if (!aGarder(r.status)) {
await $.store.set(ATTENTE, retirer(((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? [], partis))
}
} catch {
// MIAM fermé ou injoignable
}
}
export const register: Register = (on, options) => {
const miam: Miam = { url: `http://127.0.0.1:${options.port ?? 8090}/economies`, jeton: String(options.jeton ?? '') }
let session: Totaux = VIDE
const enFile = new Map<string, Demande>() // demande_id → délégation renvoyée « en file », comptée à son résultat
const comptees = new Set<string>() // demande_id déjà comptées (suivre peut renvoyer deux fois un résultat)
const subagents = new Map<string, string[]>() // boucles qui ont délégué → leurs demande_id ; leur tour = pilotage
const sorties = new Map<string, string>() // fichier écrit par une délégation (chemin normalisé) → demande_id
let reprises = 0 // id de secours d'une correction sans tool_use_id
let envoiEnCours = false
on('session.start', async ($, e, next) => {
const cumul = ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, {})
if (cumul.delegations) {
$.ui.status(ligne(VIDE, cumul))
}
await $.command.register({
name: 'miam-economies',
description: 'Tokens évités grâce à MIAM : session et cumul',
argumentHint: '[raz]',
})
$.clock.every(PERIODE_MS, () => {
if (!envoiEnCours) {
envoiEnCours = true
void envoyer($, miam).finally(() => (envoiEnCours = false))
}
})
return next(e)
})
on('command.run', { command: 'miam-economies' }, async ($, e) => {
if (e.args.trim() === 'raz') {
await $.store.set(CUMUL, VIDE)
session = VIDE
$.ui.status(undefined)
return { text: 'Compteurs MIAM remis à zéro (les envois à MIAM ne sont pas touchés).' }
}
const cumul = ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, {})
const attente = ((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? []
return {
text: [
`Session : ${session.delegations} délégation(s), ~${court(session.evites)} tokens évités, pilotage ` +
`${court(session.pilotage)}, corrections ${court(session.corrections)}, net ~${court(net(session))}.`,
`Cumul : ${cumul.delegations} délégation(s), ~${court(cumul.evites)} tokens évités, pilotage ` +
`${court(cumul.pilotage)}, corrections ${court(cumul.corrections)}, net ~${court(net(cumul))}.`,
attente.length ? `${attente.length} événement(s) en attente d'envoi à MIAM.` : 'Tout est envoyé à MIAM.',
'Estimation : fichiers lus + réponse écrite par le modèle local, moins la consigne et ce que Claude relit ; ' +
'pilotage = consommation du subagent (lecture en cache comptée au dixième) ; corrections = ce que ' +
"l'agent principal relit ou retouche dans les fichiers écrits par MIAM.",
].join('\n'),
}
})
// Un seul hook tool.call (le validateur n'en accepte qu'un sans filtre) : délégations, puis reprises.
on('tool.call', async ($, e, next) => {
const outil = OUTIL.exec(e.tool)?.[1]
if (!outil) {
// Corrections : l'agent principal relit ou retouche une sortie de MIAM ; ce coût se déduit du gain. Celles du
// subagent sont déjà dans son pilotage.
if (!sorties.size || e.agentId !== undefined || !REPRISE.test(e.tool)) {
return next(e)
}
const ran = await next(e)
const args = e as unknown as Record<string, unknown>
const demandeId = cible(e.tool, args, sorties)
if (!demandeId || ran.deny !== undefined) {
return ran
}
const correction = mesurerCorrection(e.tool, args, typeof ran.text === 'string' ? ran.text : undefined)
if (!correction.lus && !correction.ecrits) {
return ran
}
session = await afficher($, session, { corrections: correction.lus + correction.ecrits })
reprises += 1
await mettreEnAttente($, [evenementCorrection({
sessionId: await $.session.id(), demandeId, outil: e.tool, toolUseId: e.tool_use_id ?? `reprise-${reprises}`,
maintenant: await $.clock.now(),
}, correction)])
return ran
}
const ran = await next(e)
if (ran.deny !== undefined || ran.isError) {
return ran
}
const args = e as unknown as { consigne?: string; retour?: string; demande_id?: string }
let texte: unknown
try {
texte = typeof ran.text === 'string' ? JSON.parse(ran.text) : ran.result
} catch {
return ran
}
const brut = texte as { ok?: unknown; demande_id?: string }
const demande: Demande | undefined =
outil === 'deleguer'
? { consigne: args.consigne ?? '', retour: args.retour, agentId: e.agentId }
: enFile.get(args.demande_id ?? '')
if (!demande) {
return ran
}
if (brut?.ok == null && brut?.demande_id) {
enFile.set(brut.demande_id, demande) // en file : compté quand suivre rendra le résultat
return ran
}
const r = lireResultat(texte)
if (!r?.demande_id || comptees.has(r.demande_id)) {
return ran
}
comptees.add(r.demande_id)
enFile.delete(r.demande_id)
if (r.sortie) {
sorties.set(cheminNormal(r.sortie), r.demande_id)
}
if (demande.agentId) {
subagents.set(demande.agentId, [...(subagents.get(demande.agentId) ?? []), r.demande_id])
}
const est = estimer(demande.consigne, demande.retour, r, demande.agentId !== undefined)
session = await afficher($, session, { delegations: 1, evites: est.evites })
$.ui.toast(`MIAM : ~${court(est.evites)} tokens évités (${court(est.lus)} lus, ${court(est.ecrits)} écrits)`)
const contexte = { sessionId: await $.session.id(), agentId: demande.agentId, retour: demande.retour }
await mettreEnAttente($, [evenementDelegation({ ...contexte, maintenant: await $.clock.now() },
{ ...r, demande_id: r.demande_id }, est)])
return ran
})
on('turn.complete', async ($, e, next) => {
const demandeIds = e.agentId ? subagents.get(e.agentId) : undefined
if (e.agentId && e.usage && demandeIds) {
subagents.delete(e.agentId)
session = await afficher($, session, { pilotage: cout(e.usage) })
await mettreEnAttente($, [evenementPilotage({
sessionId: await $.session.id(), agentId: e.agentId, turnId: e.turnId, maintenant: await $.clock.now(),
demandeIds,
}, e.usage)])
}
return next(e)
})
}
+36
View File
@@ -0,0 +1,36 @@
---
name: banc
description: Lance le banc d'essai des presets de MIAM (tâches fixes code/texte/rapide passées par deleguer) ou affiche son dernier rapport. Le résultat sert au subagent miam:delegue pour choisir le preset à démarrer.
disable-model-invocation: true
argument-hint: "[rapport | preset… | --categorie code|texte|rapide]"
---
# Banc d'essai des presets de MIAM
Pour chaque preset validé de MIAM : `demarrer(remplacer=True)`, puis 15 tâches fixes (code, texte, rapide) envoyées par
`deleguer` et notées automatiquement. Résultats dans `${CLAUDE_PLUGIN_DATA}/banc/` : `rapport.md`, `choix.json` (lu par
`miam:delegue`), un dossier par essai. Plusieurs minutes par preset ; aucun token de Claude pendant l'essai.
Arguments reçus : `$ARGUMENTS`
## « rapport » (ou aucun argument et un rapport existe déjà)
Lis `${CLAUDE_PLUGIN_DATA}/banc/rapport.md` et montre le tableau « Choix par catégorie », la date du dernier essai, et
propose de relancer le banc.
## Lancer le banc
1. Appelle `etat` (outil MIAM) : MIAM doit répondre. Une délégation en cours ? Préviens que le banc attendra son tour.
2. Préviens l'utilisateur : le modèle chargé sera remplacé pendant l'essai, puis remis à la fin ; Unreal doit être fermé.
3. Lance le banc dans une **fenêtre PowerShell externe** (jamais dans un onglet de l'application) :
```powershell
Start-Process pwsh -ArgumentList '-NoExit', '-Command', "python '${CLAUDE_PLUGIN_ROOT}/banc/banc_miam.py' --sortie '${CLAUDE_PLUGIN_DATA}/banc' $ARGUMENTS"
```
(les arguments sont des id ou noms de presets, ou `--categorie …` ; sans argument : tous les presets validés).
Le script lit le jeton dans `%LOCALAPPDATA%\MIAM\config.json` (ou la variable `MIAM_JETON`) et le port dans
`MIAM_PORT` (défaut 8090 ; le port réglé dans le plugin est `${user_config.port}`, à passer avec `--port` s'il
diffère).
4. Dis à l'utilisateur où lire le résultat (`${CLAUDE_PLUGIN_DATA}/banc/rapport.md`) et de relancer `/miam:banc rapport`
une fois la fenêtre terminée. N'attends pas la fin en interrogeant l'état.
+43
View File
@@ -0,0 +1,43 @@
---
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.
---
# 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.
## Quoi déléguer
**Oui** : extraire des faits d'un log ou d'une sortie pytest/ruff ; inventaire d'un fichier (méthodes, stubs, TODO) ;
résumé d'un document, questions sur un long document ; traduction FR↔EN, docstrings, notes de version, messages de
commit (à relire) ; fonction pure et ses tests unitaires, tests répétitifs à partir d'un modèle existant.
**Non** : architecture, débogage multi-fichiers, concurrence (Qt/asyncio…), sécurité, tout ce qui demande le contexte
de la conversation ; jamais de secrets ni de données personnelles. Une recherche suffit ? `grep` plutôt qu'une
délégation.
## Comment
Lance le subagent `miam:delegue` (outil Agent, `subagent_type: "miam:delegue"`, `model: "${user_config.modele_subagent}"`).
Il vérifie que MIAM répond, démarre au besoin le bon preset, délègue, attend, vérifie et rend un compte rendu court.
Son brief doit se suffire à lui-même :
- la tâche, précise (signature, langue, format exact, cas limites) ;
- les fichiers en **chemins absolus** (MIAM les lit : ne les lis pas toi-même avant) ;
- le format (`texte`, `code`, `json`) et le fichier de `sortie` éventuel (dire si on peut l'écraser) ;
- comment vérifier (tests à lancer, clés JSON attendues) ;
- s'il peut remplacer le modèle déjà chargé (par défaut : non).
Plusieurs tâches indépendantes : plusieurs subagents, MIAM les met en file (une délégation à la fois sur le GPU).
## Ensuite
Relis le compte rendu : un résultat « raté » ou « partiel », tu le reprends toi-même. Code produit : relu, `ruff check`,
`ruff format --check` et tests verts avant de le garder.
MIAM injoignable (le compte rendu le dit) : le signaler à l'utilisateur (lancer MIAM et son serveur MCP, puis `/mcp` →
Reconnect sur `plugin:miam:miam`) et faire la tâche toi-même si elle est urgente.
Bilan des délégations : outil `bilan` de MIAM. Meilleur preset par catégorie : `/miam:banc`.