Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce4af8963 | ||
|
|
7d3885a735 | ||
|
|
7841bcd89f | ||
|
|
8f78710401 | ||
|
|
831efa644a | ||
|
|
9e66af7bf9 | ||
|
|
d4737812c1 | ||
|
|
2edbe71367 | ||
|
|
e4da4f9b87 | ||
|
|
5363b86a16 | ||
|
|
1f59453b84 | ||
|
|
93139773e9 | ||
|
|
ddbef5b81e | ||
|
|
ee10926124 | ||
|
|
497ad4fad8 |
No files matched your search
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "miam-local",
|
||||
"name": "a2m-plugins",
|
||||
"owner": {
|
||||
"name": "A2M Systèmes"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Plugins Claude Code pour MIAM (Mon IA Manager)"
|
||||
"description": "Plugins Claude Code A2M Systèmes"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "miam",
|
||||
"source": "./plugins/miam",
|
||||
"description": "Délégation de tâches à des modèles locaux gérés par MIAM"
|
||||
"description": "Plugin Claude Code pour MIAM (Mon IA Manager)"
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-13
@@ -12,24 +12,12 @@
|
||||
# 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 -------------------------------------------------------------------
|
||||
# --- Journaux ---------------------------------------------------------------------------------------------------
|
||||
*.log
|
||||
!plugins/miam/banc/fichiers/*.log
|
||||
|
||||
# --- Système et éditeurs ----------------------------------------------------------------------------------------
|
||||
.DS_Store
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1,110 +1,133 @@
|
||||
# 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.
|
||||
# Plugins Claude Code — A2M Systèmes
|
||||
|
||||
## 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.
|
||||
Ce dépôt est le marketplace A2M Systèmes. Actuellement, il contient le plugin `miam`, MIAM est un orchestrateur de LLMs locaux avec un mcp pour opencode et claude code.
|
||||
|
||||
## 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.
|
||||
## Sommaire
|
||||
|
||||
- [Plugins Claude Code — A2M Systèmes](#plugins-claude-code--a2m-systèmes)
|
||||
- [Sommaire](#sommaire)
|
||||
- [Plugins disponibles](#plugins-disponibles)
|
||||
- [Installation](#installation)
|
||||
- [1. Ajouter la marketplace](#1-ajouter-la-marketplace)
|
||||
- [2. Installer un plugin](#2-installer-un-plugin)
|
||||
- [Dépôt privé](#dépôt-privé)
|
||||
- [Mises à jour](#mises-à-jour)
|
||||
- [Ajouter un plugin au dépôt](#ajouter-un-plugin-au-dépôt)
|
||||
- [Structure du dépôt](#structure-du-dépôt)
|
||||
- [Licence](#licence)
|
||||
|
||||
## Plugins disponibles
|
||||
|
||||
| Plugin | Description | Documentation |
|
||||
| :--- | :--- | :--- |
|
||||
| `miam` | Délègue des tâches à un modèle local géré par MIAM (Mon IA Manager, orchestrateur llama.cpp), via son serveur MCP. | [plugins/miam/README.md](plugins/miam/README.md) |
|
||||
|
||||
Le plugin `miam` fournit :
|
||||
- le serveur MCP `miam` (outils `etat`, `modeles`, `presets`, `demarrer`, `deleguer`, `suivre`…) ;
|
||||
- la skill `deleguer` (quand et comment déléguer) ;
|
||||
- un compteur de tokens économisés (notification, ligne d'état, `/miam-economies`), net du pilotage et des
|
||||
corrections, en vert ou en rouge selon le signe du gain ;
|
||||
- une seule ligne d'état avec le gain net et l'usage Claude sur 5 h et sur la semaine ;
|
||||
- une incitation à déléguer (consigne système et rappel après une grosse lecture), désactivable.
|
||||
|
||||
## Installation
|
||||
Depuis le dépôt Git (Gitea), dans Claude Code :
|
||||
|
||||
### 1. Ajouter la marketplace
|
||||
|
||||
Dans VS Code, utilisez la commande `/plugins`, puis l'onglet « Marketplaces ». Collez l'URL suivante et cliquez sur « Add » :
|
||||
|
||||
```text
|
||||
/plugin marketplace add https://<gitea>/<utilisateur>/plugin-claude-miam.git
|
||||
/plugin install miam@miam-local
|
||||
https://a2msystemes-git.duckdns.org/ange/plugin-claude-miam.git
|
||||
```
|
||||
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`.
|
||||
Dans le terminal, la commande équivalente est :
|
||||
|
||||
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.
|
||||
```text
|
||||
/plugin marketplace add https://a2msystemes-git.duckdns.org/ange/plugin-claude-miam.git
|
||||
```
|
||||
|
||||
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`.
|
||||
**Attention :** L'URL doit impérativement se terminer par `.git`. Sans cette extension, Claude Code interprète l'adresse comme un fichier `marketplace.json` et échoue avec l'erreur « expected object, received string ».
|
||||
|
||||
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 »).
|
||||
### 2. Installer un plugin
|
||||
|
||||
## Réglages
|
||||
Dans VS Code, allez dans l'onglet « Plugins » et cliquez sur « Install ».
|
||||
|
||||
| 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. |
|
||||
Dans le terminal, utilisez la commande suivante :
|
||||
|
||||
## 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.
|
||||
```text
|
||||
/plugin install miam@a2m-plugins
|
||||
```
|
||||
|
||||
## Tokens économisés
|
||||
La portée conseillée est « Install for you » (tous les projets du poste).
|
||||
|
||||
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).
|
||||
### Dépôt privé
|
||||
|
||||
- **É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`).
|
||||
Ce dépôt est privé sur Gitea. Pour le cloner, Claude Code utilise les identifiants Git configurés sur votre poste.
|
||||
- **HTTPS** : Les identifiants sont gérés par le gestionnaire d'identifiants de Git.
|
||||
- **SSH** : L'hôte doit être présent dans `known_hosts` et la clé doit être utilisable sans saisie de phrase de passe (agent SSH).
|
||||
|
||||
## 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.
|
||||
### Mises à jour
|
||||
|
||||
Le champ `version` du fichier `plugin.json` de chaque plugin fige la version installée. Pour mettre à jour :
|
||||
1. Augmentez la version dans le fichier `plugin.json` du plugin.
|
||||
2. Poussez les modifications sur Gitea.
|
||||
3. Exécutez l'une des commandes suivantes :
|
||||
- Dans VS Code : onglet « Marketplaces » → « Update marketplace ».
|
||||
- Dans le terminal : `/plugin marketplace update a2m-plugins`.
|
||||
|
||||
## Ajouter un plugin au dépôt
|
||||
|
||||
Pour ajouter un nouveau plugin au catalogue :
|
||||
|
||||
1. Créez un dossier `plugins/<nom>` à la racine du dépôt.
|
||||
2. Ajoutez un fichier `.claude-plugin/plugin.json` avec le nom en kebab-case, la version et la description.
|
||||
3. Ajoutez les composants nécessaires (dossiers `skills/`, `agents/`, `hooks/`, fichier `.mcp.json`, etc.).
|
||||
4. Ajoutez un fichier `README.md` et un fichier `LICENSE` (la licence est propre à chaque plugin ; il n'y en a pas à
|
||||
la racine), et une ligne au tableau de la section [Licence](#licence).
|
||||
5. Ajoutez une entrée dans la liste `plugins` du catalogue `.claude-plugin/marketplace.json` :
|
||||
```json
|
||||
{
|
||||
"name": "<nom-en-kebab-case>",
|
||||
"source": "./plugins/<nom>",
|
||||
"description": "Description courte du plugin"
|
||||
}
|
||||
```
|
||||
6. Vérifiez la validité du catalogue et du plugin avec :
|
||||
```text
|
||||
claude plugin validate .
|
||||
claude plugin validate ./plugins/<nom>
|
||||
```
|
||||
|
||||
## Structure du dépôt
|
||||
|
||||
```text
|
||||
.claude-plugin/
|
||||
marketplace.json
|
||||
marketplace.json # Catalogue central des plugins
|
||||
docs/
|
||||
token-summary-mod.md # Contrat technique pour le compteur de tokens
|
||||
plugins/
|
||||
miam/
|
||||
miam/ # Plugin actuel
|
||||
.claude-plugin/
|
||||
plugin.json
|
||||
.mcp.json
|
||||
agents/
|
||||
delegue.md
|
||||
plugin.json # Métadonnées et version du plugin
|
||||
.mcp.json # Configuration du serveur MCP
|
||||
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
|
||||
hooks.json # Liste des hooks
|
||||
register.tsx # Enregistrement des hooks (compteur, ligne d'état, incitation)
|
||||
economies.ts # Calcul des économies de tokens
|
||||
usage.ts # Usage Claude 5 h et hebdo
|
||||
*.test.ts(x) # Tests unitaires
|
||||
skills/
|
||||
deleguer/
|
||||
deleguer/ # Skill pour déclencher la délégation
|
||||
SKILL.md
|
||||
banc/
|
||||
SKILL.md
|
||||
banc/
|
||||
banc_miam.py
|
||||
mcp_http.py
|
||||
taches.py
|
||||
verifier_controles.py
|
||||
fichiers/
|
||||
LICENSE # Licence du plugin (MIT)
|
||||
README.md # Documentation du plugin
|
||||
README.md # Ce fichier
|
||||
```
|
||||
|
||||
## 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.
|
||||
Chaque plugin a sa propre licence, dans son dossier (`plugins/<nom>/LICENSE`) : elle s'installe avec lui.
|
||||
|
||||
| Plugin | Licence |
|
||||
| :--- | :--- |
|
||||
| `miam` | MIT, voir [plugins/miam/LICENSE](plugins/miam/LICENSE) |
|
||||
@@ -0,0 +1,64 @@
|
||||
# À faire pour la prochaine version du plugin
|
||||
|
||||
Notes issues de la session MIAM du 2026-11-01. Rien n'est encore appliqué.
|
||||
|
||||
## 1. Autoriser le repli sur un subagent Haiku (demande de l'utilisateur)
|
||||
|
||||
`plugins/miam/skills/deleguer/SKILL.md`, ligne 24, dit « Pas de subagent ». Vérifier aussi le texte équivalent injecté
|
||||
par les hooks et la documentation (`docs/agent-delegue.md`, `plugins/miam/README.md`).
|
||||
|
||||
Nouvelle règle, ordre de délégation :
|
||||
|
||||
1. MIAM d'abord, pour les tâches de son champ.
|
||||
2. Subagent `model: "haiku"`, sans demander, pour une tâche longue et simple si MIAM est arrêté ou mal adapté : lire un
|
||||
gros document ou une longue sortie pour en tirer peu, recherche large dans le code, tests répétitifs.
|
||||
3. Claude principal pour ce qui demande le contexte de la conversation, pour la sécurité et pour la dépense.
|
||||
|
||||
Pas de subagent pour une petite tâche : un `grep` ou une lecture directe coûte moins cher qu'un agent qui repart de zéro.
|
||||
|
||||
À décider : le coût d'un subagent Haiku doit-il entrer dans les événements `pilotage` et dans le cumul, et comment le
|
||||
distinguer d'un pilotage de MIAM ?
|
||||
|
||||
## 2. Vérifier avant de déléguer, dresser le bilan après
|
||||
|
||||
À ajouter au skill `deleguer` :
|
||||
|
||||
- **Faisable ?** OpenCode ne voit que le code commité et n'a pas de `.venv` utilisable : ne pas lui demander les tests.
|
||||
- **Le preset tient ?** Appeler `modeles` avant d'imposer un preset.
|
||||
- **Rentable ?** Pas de délégation si la consigne dépasse un tiers de la sortie attendue, ou si une recherche suffit.
|
||||
- **Indépendant ?** Ne pas importer un module pas encore livré : MIAM peut tourner depuis le dépôt qu'il modifie.
|
||||
- **Après un lot :** bilan des délégations, sans attendre la demande : coût, résultat, faute, règle ajoutée.
|
||||
|
||||
## 3. Rendre le cumul fiable (PH2-H10 de MIAM)
|
||||
|
||||
Le cumul mesure le volume, pas la justesse. Sur la session, 124 625 tokens évités sur 139 707 étaient fictifs.
|
||||
|
||||
- Ne compter aucun gain pour une réponse vide ou tronquée.
|
||||
- Pouvoir marquer une délégation fausse comme rejetée, pour annuler son gain et compter sa vérification.
|
||||
- Ne pas compter comme évitée une lecture que Claude n'aurait pas faite, comme une relecture imposée au démarrage.
|
||||
|
||||
## 4. Imposer les règles par des hooks
|
||||
|
||||
La mémoire et les skills sont du texte que Claude peut ignorer : la règle « un seul suivi » était notée et il y a eu plus
|
||||
de 15 `suivre`. Ne garder en texte que ce qui demande du jugement ; ce qui se mesure passe dans `tool.call`, à côté de
|
||||
`contrainte.ts`, en fonctions pures testées.
|
||||
|
||||
À vérifier d'abord : `tool.call` peut-il refuser un appel avant son exécution ? Les rappels actuels arrivent après coup.
|
||||
|
||||
| Contrôle | Déclencheur | Action |
|
||||
|---|---|---|
|
||||
| Suivi | `suivre` à moins de N secondes du précédent, ou Nᵉ appel sur la même demande | Refus, avec le délai à respecter |
|
||||
| OpenCode et tests | `deleguer(moteur="opencode")` dont la consigne demande de lancer pytest | Refus, avec la raison |
|
||||
| Preset | `deleguer(preset=…)` sans `modeles` dans la session | Refus |
|
||||
| Rentabilité | Consigne de plus de N caractères | Rappel, le seuil reste à régler |
|
||||
| Sortie longue | Lecture ou commande non filtrée | Rappel avant l'appel si possible, sinon après |
|
||||
| Fin de lot | Fin de tour après des délégations | Injection du bilan mesuré : coût, échecs, reprises |
|
||||
|
||||
Hors de portée d'un hook, à garder en texte : juger si une réponse est juste, ou si un `grep` suffisait.
|
||||
Chaque refus doit dire quoi faire à la place et se compter, pour régler les seuils sur des chiffres réels.
|
||||
|
||||
## 5. Relecture de démarrage
|
||||
|
||||
Si elle vient du plugin, la relecture de `plan/Plan.md` et `plan/tasks-P2.md` à chaque session coûte 62 000 tokens lus et
|
||||
a donné une réponse vide puis une réponse fausse. La remplacer par une consigne ciblée sur la demande en cours, ou par une
|
||||
recherche.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: delegue
|
||||
description: Délègue à un modèle local géré par MIAM une tâche autonome et vérifiable, pour alléger l'agent principal — extraire des faits d'un log ou d'une sortie pytest/ruff, résumer un document long ou répondre à des questions dessus, traduire FR↔EN, docstrings, notes de version, message de commit, fonction pure et ses tests, tests répétitifs à partir d'un modèle. Lui donner la tâche, les chemins absolus des fichiers, le format voulu (texte, code, json), le fichier de sortie éventuel et comment vérifier. Pas d'architecture, de débogage multi-fichiers, de concurrence, de sécurité, ni de secrets ou données personnelles.
|
||||
description: À utiliser de façon proactive. Délègue à un modèle local géré par MIAM une tâche autonome et vérifiable, pour alléger l'agent principal — extraire des faits d'un log ou d'une sortie pytest/ruff, résumer un document long ou répondre à des questions dessus, traduire FR↔EN, docstrings, notes de version, message de commit, fonction pure et ses tests, tests répétitifs à partir d'un modèle. Lui donner la tâche, les chemins absolus des fichiers, le format voulu (texte, code, json), le fichier de sortie éventuel et comment vérifier. Pas d'architecture, de débogage multi-fichiers, de concurrence, de sécurité, ni de secrets ou données personnelles.
|
||||
model: haiku
|
||||
effort: low
|
||||
color: orange
|
||||
@@ -19,21 +19,20 @@ Appelle `etat`. Si l'outil est absent ou échoue (connexion refusée, 401, serve
|
||||
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é ?
|
||||
## 2. Quel modèle ?
|
||||
|
||||
- **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é.
|
||||
MIAM choisit le modèle d'après ses propres tests de qualité. Ne choisis pas à sa place, et n'appelle pas `demarrer` avant
|
||||
de déléguer. Classe 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).
|
||||
|
||||
Puis, selon le brief :
|
||||
- **cas général** : `type` + `seuil` (celui du brief, sinon `0.8`). MIAM garde le modèle chargé s'il atteint le seuil,
|
||||
sinon il charge le meilleur preset, y compris s'il faut remplacer le modèle chargé ;
|
||||
- **preset imposé** : `preset` (id ou nom), prioritaire sur `type` ;
|
||||
- **« garder le modèle chargé »** : ni `type` ni `preset`. Si la catégorie figure dans `sous_seuil` de l'instance
|
||||
(`etat`), délègue quand même et signale-le dans le compte rendu.
|
||||
|
||||
## 3. Déléguer
|
||||
|
||||
@@ -41,6 +40,7 @@ faire la tâche à la place du modèle local.
|
||||
ce qu'il ne faut pas faire), les cas limites. Une consigne vague donne un résultat brouillon.
|
||||
|
||||
Appelle `deleguer` :
|
||||
- `type`, `seuil` ou `preset` : voir la section 2 ;
|
||||
- `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
|
||||
@@ -49,7 +49,16 @@ Appelle `deleguer` :
|
||||
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)`.
|
||||
Réponse « en file » (`ok` absent ou nul, avec `demande_id`) : `suivre(demande_id, attente_s=45)` jusqu'au résultat. Pour
|
||||
abandonner : `annuler(demande_id)`. Un changement de modèle prend 20 à 40 s de plus : `choix.demarrage` est le
|
||||
`demande_id` du chargement, la délégation passe juste après ; si elle échoue, `suivre(choix.demarrage)` en donne la cause.
|
||||
|
||||
Refus de `deleguer` :
|
||||
- `aucun preset testé pour « … »` (aucun modèle chargé, MIAM n'a pas de note pour ce type) : `demarrer(preset=
|
||||
"${user_config.preset_defaut}")`, `suivre` jusqu'à la fin, puis refais le même `deleguer` ;
|
||||
- contexte dépassé : `modeles`, puis refais le `deleguer` avec `preset` = le mieux noté dont le `contexte` suffit, sans
|
||||
`offload` si possible (une partie du modèle tourne alors sur le CPU, donc lentement) ;
|
||||
- autre refus (Unreal ouvert, `seuil` hors limites, preset inconnu) : rends-le tel quel.
|
||||
|
||||
## 4. Vérifier
|
||||
|
||||
@@ -67,6 +76,7 @@ rends l'échec, n'essaie pas de corriger toi-même un gros résultat.
|
||||
|
||||
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`) ;
|
||||
- modèle utilisé, durée, tokens lus/écrits (champs de la réponse de `deleguer`) ; `choix.raison`, et
|
||||
`choix.avertissement` s'il existe, tel quel ;
|
||||
- le résultat : en entier s'il est court, sinon le chemin de `sortie` et ce qu'il contient ;
|
||||
- limites ou points à relire.
|
||||
@@ -0,0 +1,353 @@
|
||||
# Forcer la délégation par les hooks
|
||||
|
||||
État au 2026-11-02, base de travail du plugin. But : que Claude pilote et que MIAM, l'API ou Haiku écrivent.
|
||||
Claude découpe, confie, contrôle par des machines, et n'écrit lui-même que ce qui ne peut pas être confié.
|
||||
|
||||
## 1. Constat
|
||||
|
||||
**MIAM (v2.5.2-dev, phase 2)** sait déjà router : modèle local (27B, 15,5 Go, un seul à la fois), API OpenRouter
|
||||
(plafond 2 $/jour, ~0,0135 $ dépensés le 2026-11-02), OpenCode, et Haiku côté Claude. `moteur="auto"` choisit entre
|
||||
local et API. Les moyens existent ; c'est Claude qui ne les utilise pas spontanément.
|
||||
|
||||
**Journal** : 185 délégations (165 ok, 10 refusées, 3 erreurs, 3 annulées, 4 rejetées), dont 9 API et 5 OpenCode.
|
||||
Par jour : 103 (30/10), 52, 19, 12 (02/11, journée en cours). La baisse n'est pas rapportée au volume de travail : elle
|
||||
alerte, elle ne prouve rien.
|
||||
|
||||
**Session du 2026-11-02, tâche PH2-D1** : seule la lecture imposée par le hook de démarrage a été déléguée. 8 tests
|
||||
répétitifs écrits à la main alors que leur modèle était dans le fichier. Les délégations suivantes ont toutes suivi un
|
||||
rappel de l'utilisateur.
|
||||
|
||||
### Pourquoi les hooks actuels n'ont pas suffi
|
||||
|
||||
| Mécanisme actuel | Ce qui a échappé |
|
||||
|---|---|
|
||||
| Refus de `Read` si fichier ≥ 20 000 caractères sans `limit` ≤ 400 | Contourné légalement : plusieurs plages de 75 à 240 lignes sur `instances.py`, `allow_large` sur des lignes géantes de `tasks-P2.md` |
|
||||
| Retenue des sorties shell ≥ 20 000 caractères | Ne voit que la taille d'un appel, pas le cumul |
|
||||
| Rappel texte (1 toutes les 10 min), consigne `prompt.compose`, mémoire | Coût nul pour Claude au moment de décider : ignorés |
|
||||
| `Edit`, `Write`, `NotebookEdit` | **Aucun contrôle** : seule la comptabilité des corrections |
|
||||
| Suivi « ce tour a délégué » | **Non présent** pour l'agent principal |
|
||||
| Hooks de fin de tour et de soumission | **Non présents** |
|
||||
|
||||
Conclusion : une consigne perd face à la facilité. Il faut qu'écrire soi-même soit plus lent que déléguer, à l'instant
|
||||
où l'écriture est tentée.
|
||||
|
||||
### Frictions côté délégation
|
||||
|
||||
- `deleguer` sans `moteur` échoue si aucun modèle n'est chargé ; il faut penser à `moteur="auto"`.
|
||||
- Le bench occupe le GPU ; `auto` local peut le préempter (corrigé pour les refus, `093f110`, pas pour le reste).
|
||||
- `plugin-claude-miam` n'est pas autorisé pour l'API, et la config affiche `projets: null` pour OpenRouter (à éclaircir).
|
||||
- Un fichier hors projet (scratchpad) fait échouer le routage API.
|
||||
- Rien n'oriente vers Haiku quand local et API sont indisponibles.
|
||||
- Pas de contrôle automatique du résultat : Claude relit tout, ce qui annule une partie du gain.
|
||||
|
||||
## 2. Principes
|
||||
|
||||
1. **Refuser, pas rappeler.** Un `deny` au moment de l'écriture, avec l'appel prêt à copier dans le message.
|
||||
2. **Budget cumulé, pas seuil par appel.** Compter lignes écrites et caractères lus par tour et par fichier.
|
||||
3. **Découper avant d'écrire.** Pas de code sans liste de bouts.
|
||||
4. **La machine juge, Claude arbitre.** Tests, ruff et contrat décident ; Claude ne lit qu'en cas d'échec.
|
||||
5. **Une sortie de secours chère et visible.** Exception motivée, journalisée, affichée.
|
||||
6. **Ne jamais bloquer le travail.** Si aucun exécutant ne répond, la règle s'ouvre et le signale.
|
||||
|
||||
## 3. Pistes de hooks
|
||||
|
||||
Seuils proposés, à régler sur les mesures.
|
||||
|
||||
| # | Hook | Déclencheur | Action | Priorité |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Portillon tests** | `Write`/`Edit` de l'agent principal sur `tests/**`, `*.test.*` ajoutant > 10 lignes | `deny` + appel `deleguer` prêt | P0 |
|
||||
| 2 | **Budget d'écriture** | Cumul > 30 lignes de code ajoutées par l'agent principal depuis la dernière délégation réussie | `deny` jusqu'à une délégation ou une exception | P0 |
|
||||
| 3 | **Portillon commit** | `git commit` sans message issu de `deleguer` dans le tour | `deny` | P0 |
|
||||
| 4 | **Écritures par shell** | `Bash`/`PowerShell` avec `Set-Content`, `Out-File`, `>`, `sed -i`, Python écrivant un fichier | Comptés comme le hook 2 ; sinon le contournement est immédiat | P0 |
|
||||
| 5 | **Budget de lecture** | Cumul par tour > 40 000 caractères, ou > 400 lignes par fichier, `allow_large` et `Grep` compris | `deny` + « confie la question » | P1 |
|
||||
| 6 | **Portillon découpage** | Premier `Edit`/`Write` de code d'un tour sans plan | `deny` : écrire `.claude/miam-plan.json` (section 4) | P1 |
|
||||
| 7 | **Sélecteur d'exécutant** | Tout `deny` ci-dessus | Message calculé : local si prêt et sans bench ; API si projet autorisé ; sinon subagent Haiku | P1 |
|
||||
| 8 | **Bilan de fin de tour** | Fin de tour de l'agent principal | Injecte lignes écrites par Claude et par délégation ; bloque la fin si un bout du plan reste « délégable » non traité | P2 |
|
||||
| 9 | **Compteur visible** | Ligne d'état | `délégué 82 % · exceptions 1` pour le tour et la session | P2 |
|
||||
| 10 | **Docs et docstrings** | `*.md` hors `plan/`, docstrings ajoutées en lot | Comme le hook 1 | P2 |
|
||||
| 11 | **Attente unique** | `deleguer` en file (`ok: null` avec `reprise`) ; commande `/attendre` lancée sans `run_in_background` | Injecte « lance `reprise` en arrière-plan » ; `deny` sur l'appel au premier plan | P0 |
|
||||
| 12 | **Pas de suivi pendant l'attente** | `suivre` sur une demande attendue, `sleep` ou boucle d'attente en `Bash` | `deny` : « tu seras réveillé à la fin » ; permis si `fin` vaut `besoin_claude` | P0 |
|
||||
| 13 | **Confiance au verdict** | `Read` complet du fichier `sortie` après `fin: "valide"` | `deny` hors chemins sensibles : aperçu ou `offset`/`limit` | P1 |
|
||||
|
||||
**Hors comptage** : fichiers écrits par MIAM (déjà mesurés en corrections), `plan/**` et journal de suivi,
|
||||
modifications ≤ 3 lignes, subagents (`agentId` défini), config locale.
|
||||
|
||||
**Exception** : outil `exception(raison, fichiers, lignes)` avec motifs fermés (`concurrence`, `securite`,
|
||||
`debogage-multi-fichiers`, `secret`). Elle ouvre un budget limité aux fichiers cités, est journalisée et apparaît dans le
|
||||
bilan. Pour la concurrence, liste de chemins sensibles à définir plutôt qu'un jugement de Claude.
|
||||
|
||||
**Repli** : si aucun des trois exécutants ne répond, règle ouverte pour le tour et toast. Réutiliser `joignable`.
|
||||
|
||||
**Options** : garder `imposer` et `incitation`, ajouter `budget_ecriture`, `budget_lecture`, `chemins_sensibles`.
|
||||
|
||||
### À vérifier avant de coder
|
||||
|
||||
- Existence d'un événement de fin de tour de l'agent principal capable de relancer Claude (`turn.complete` n'est
|
||||
utilisé que pour les subagents).
|
||||
- Capacité de `tool.call` à lire le contenu de `Write`/`Edit` avant exécution pour compter les lignes.
|
||||
- Possibilité de déclarer `exception` : outil MCP côté MIAM, ou fichier lu par le hook.
|
||||
|
||||
## 4. Découper en bouts atomiques
|
||||
|
||||
Plan attendu par le hook 6 :
|
||||
|
||||
```json
|
||||
{ "tache": "PH2-D1", "bouts": [
|
||||
{ "id": 1, "quoi": "tests start_all", "fichier": "tests/x.py", "moteur": "auto", "modele": "tests/x.py:613-637",
|
||||
"contrat": "4 instances, ordre d, b, c, erreurs par nom", "controle": "pytest -k start_all" },
|
||||
{ "id": 2, "quoi": "ordre des réservations dans start_instance", "moteur": "claude", "motif": "concurrence" }
|
||||
] }
|
||||
```
|
||||
|
||||
Un bout délégable : **une** fonction ou un groupe de tests, **un** fichier de sortie, ≤ 80 lignes, signature donnée,
|
||||
exemple à imiter en plage de lignes, contrôle exécutable. Consigne en anglais, langue de sortie précisée. Modules
|
||||
joints, car le 27B invente champs et valeurs.
|
||||
|
||||
Relecture de D1 avec ce découpage :
|
||||
|
||||
| Bout | Exécutant possible |
|
||||
|---|---|
|
||||
| Choix du port réservé, messages d'erreur, `start_all`, `stop_all` à partir de leur contrat | Délégable, contrôlé par tests |
|
||||
| 8 tests, tests du view model | Délégable, modèles présents |
|
||||
| Boutons du dashboard, branchement du tray | Délégable, patron existant |
|
||||
| Lignes de suivi, message de commit | Délégable |
|
||||
| Absence d'`await` entre contrôle et réservation dans `start_instance` | Claude (concurrence) |
|
||||
| Inventaire des fichiers à modifier | Délégable ; Claude lit le résultat |
|
||||
|
||||
Claude n'aurait gardé que le découpage, le point de concurrence et l'intégration.
|
||||
|
||||
## 5. Faire confiance aux retours
|
||||
|
||||
Contrôle par niveaux, du moins cher au plus cher :
|
||||
|
||||
1. **Automatique** : `format="code"`, `sortie=`, `retour="apercu"` ; pytest, ruff, `ruff format --check`.
|
||||
2. **Test rouge d'abord** : le test échoue sans le code, passe avec.
|
||||
3. **Taille du diff** : conforme au bout annoncé.
|
||||
4. **Lecture par Claude** : seulement si un contrôle échoue, ou en zone sensible.
|
||||
|
||||
En cas d'échec, renvoyer la sortie du test à l'exécutant (2 essais) avant toute reprise par Claude.
|
||||
|
||||
**Pièges connus du 27B à garder en contrôle** : docstrings omises, valeurs inventées ; ajouter docstrings et champs
|
||||
aux contrôles plutôt qu'à la relecture.
|
||||
|
||||
**Risque accepté** : des erreurs passeront les tests. La parade est de meilleurs contrats, avec la revue par Claude
|
||||
réservée aux zones sensibles et au diff final.
|
||||
|
||||
## 5 bis. Contrôle automatique des réponses par MIAM
|
||||
|
||||
Livré dans MIAM (PH2-H11), sauf `commande` et `perimetre` ; contrat exact en section 11. **But** : Claude reçoit un verdict plutôt que le code ; MIAM juge, retente, escalade.
|
||||
|
||||
**Contrat de la demande** : nouveau paramètre optionnel `controle` sur `deleguer`.
|
||||
|
||||
| contrôle | s'applique à | ce qu'il vérifie |
|
||||
|---|---|---|
|
||||
| `format` | tous | JSON valide, schéma si fourni ; Python via `ast.parse`, TypeScript via transpile uniquement |
|
||||
| `complet` | tous | non vide, non tronqué, symboles demandés présents (fonctions, nombre de tests) |
|
||||
| `symboles` | code | chaque identifiant importé ou appelé existe dans les modules joints, pour attraper les champs inventés (faiblesse connue du 27B) |
|
||||
| `docstrings` | code | fonctions publiques documentées (faiblesse connue du 27B) |
|
||||
| `lint` | code | `ruff check` et `ruff format --check`, ou l'équivalent du projet |
|
||||
| `commande` | code | commande de test lancée dans un clone à l'intérieur du bac à sable AppContainer existant, sans réseau, commandes limitées à celles déclarées par projet |
|
||||
| `perimetre` | code | seuls les fichiers déclarés changent |
|
||||
| `citations` | extraction, résumé | toute valeur, nombre ou identifiant cité doit se retrouver dans les fichiers source |
|
||||
| `traduction` | traduction | placeholders, code et nombres conservés, ratio de longueur plausible |
|
||||
|
||||
**Défauts par type** : `format` + `complet` toujours ; code ajoute `symboles`, `docstrings`, `lint` ; extraction ajoute `citations` ; `commande` seulement si fourni.
|
||||
|
||||
**Boucle** : en cas d'échec, MIAM renvoie l'échec exact au modèle, 2 tentatives maximum, puis escalade local → API → rend la main à Claude avec statut `echec_controle`, sans accepter silencieusement.
|
||||
|
||||
**Réponse à Claude** : champs `verdict` (`valide`, `echec_controle`, `non_controle`), `controles` (nom, résultat, message court), `essais`, `moteur_final`, `sortie` (chemin de fichier), stats de diff ; pas de corps de code quand `valide` et `retour="apercu"`.
|
||||
|
||||
**Application** : rien n'est écrit dans l'arbre de travail avant que tous les contrôles passent ; le résultat reste dans le clone ou un fichier temporaire ; puis écrit comme `sortie` déclarée.
|
||||
|
||||
**Sécurité** : la sortie du modèle est non fiable ; elle n'est jamais exécutée hors du bac à sable ; seules les commandes déclarées ; aucun secret dans le clone.
|
||||
|
||||
**Mesure** : le journal reçoit `controles`, `essais`, `verdict` ; `echec_controle` ne compte aucun gain ; le taux de réussite au premier essai par modèle alimente le choix de modèle (`type`/`seuil`) et le taux de réparation ; le temps passé dans les contrôles est suivi.
|
||||
|
||||
**Limites** : des tests écrits par le même modèle peuvent valider un comportement faux : exiger d'abord un test rouge (le test doit échouer sans le code) ou un test écrit séparément ; `citations` prouve la présence, pas le sens correct ; `commande` coûte du temps ; passer les contrôles ne remplace pas la revue de Claude sur les zones sensibles (concurrence, sécurité).
|
||||
|
||||
**Découpage en bouts** :
|
||||
|
||||
1. Contrôles purs (`format`, `complet`, `symboles`, `docstrings`, `citations`, `traduction`) avec leurs tests.
|
||||
2. Contrôles outillés (`lint`, `perimetre`) avec leurs tests.
|
||||
3. Boucle de retentative et escalade local → API → `echec_controle`.
|
||||
4. Contrôle `commande` dans le bac à sable, sur clone, sans réseau.
|
||||
5. Champs de réponse (`verdict`, `controles`, `essais`, `moteur_final`, `sortie`, stats de diff) et écriture différée.
|
||||
6. Journal (`controles`, `essais`, `verdict`, temps) et plugin (le hook lit `verdict` et saute la lecture quand `valide`).
|
||||
|
||||
### Reprise de la main sans suivi répété
|
||||
|
||||
Livré dans MIAM (PH2-H11), sauf le chien de garde. `miam-attendre` est devenu la commande `curl` donnée dans `reprise` : aucun exécutable à installer. Contrat exact en section 11.
|
||||
|
||||
Problème : `suivre` plafonne chaque attente à `call_wait_s` (45 s), donc une tâche longue fait revenir Claude toutes les 45 s, et chaque retour coûte des tokens.
|
||||
|
||||
Principe : le temps n'est pas une limite, le seul coût est les tours de Claude. MIAM exécute contrôles, corrections et escalade seul. Claude est réveillé une fois, à un état terminal.
|
||||
|
||||
**Attente unique** : Claude lance une fois, en arrière-plan via `Bash` avec `run_in_background`, une petite commande `miam-attendre <demande_id>`. Elle bloque sur une route HTTP locale authentifiée de MIAM (long polling), imprime une ligne JSON, sort. Claude Code réveille Claude quand une commande d'arrière-plan se termine : aucun token pendant l'attente. Claude termine son tour ou continue autre chose.
|
||||
|
||||
**États terminaux** :
|
||||
|
||||
| état | sens | ce que fait Claude |
|
||||
|---|---|---|
|
||||
| `valide` | tous les contrôles passés, fichier écrit | intègre, sans lecture |
|
||||
| `echec_controle` | tentatives et escalade épuisées ; le rapport donne le dernier échec | reprend la main ou re-découpe |
|
||||
| `besoin_claude` | décision réservée à Claude : permission, plafond de dépense API atteint, instruction ambiguë | répond puis attend à nouveau |
|
||||
| `annule` | demande annulée | rien |
|
||||
| `perdue` | MIAM redémarré ou injoignable | renvoie ou fait lui-même |
|
||||
|
||||
**Pas de limite de durée, mais pas d'attente sans fin** : pas de timeout total. Un chien de garde détecte l'absence de progrès : aucun token généré, aucun contrôle terminé et aucun changement de tentative pendant `mcp.sans_progres_s` (proposé 600 s) donne `besoin_claude` avec l'étape bloquée. Les tentatives sont bornées (2 par moteur, puis escalade), donc la boucle se termine toujours. `miam-attendre` sort en `perdue` après plusieurs échecs de contact avec MIAM.
|
||||
|
||||
**Hook du plugin** : tant que `miam-attendre` tourne pour une demande, `suivre` sur cette demande est refusé avec le message « tu seras réveillé à la fin » ; idem pour les boucles de `sleep` en `Bash`. Le hook part du `demande_id` renvoyé en « en_file ».
|
||||
|
||||
**Réponse de `deleguer`** : si non terminé dans `call_wait_s`, la réponse donne `demande_id`, `etat` et `reprise` : la commande exacte à lancer.
|
||||
|
||||
**Autres clients** : un client sans commandes d'arrière-plan (OpenCode) garde `suivre`, avec un `attente_s` long unique, plus plafonné à 45 s, dans la limite du timeout du client MCP, à vérifier.
|
||||
|
||||
**Coût attendu** : un lancement et un réveil par délégation, indépendamment de la durée.
|
||||
|
||||
**Bouts** :
|
||||
|
||||
1. États terminaux et chien de garde en logique pure, avec tests.
|
||||
2. Route de long polling authentifiée.
|
||||
3. Commande `miam-attendre`, avec tests.
|
||||
4. Champ `reprise` dans `deleguer`.
|
||||
5. Hook du plugin refusant `suivre`.
|
||||
|
||||
## 6. Évolutions côté MIAM
|
||||
|
||||
| Évolution | Effet |
|
||||
|---|---|
|
||||
| `moteur="auto"` par défaut, repli explicite si tout est indisponible | Supprime l'erreur « aucun modèle chargé » |
|
||||
| `routage` qui peut répondre « subagent Haiku » | Échelle complète local → API → Haiku |
|
||||
| Délégation courte pendant un bench : API plutôt que préemption | Ne coupe plus les tests |
|
||||
| Projet du plugin autorisé pour l'API ; clarifier `projets: null` | Lève le refus rencontré |
|
||||
| `deleguer(..., controle="pytest -k x", essais=2)` : exécute et renvoie l'échec au modèle | Niveau 1 sans Claude, détail en 5 bis |
|
||||
| `lot=[...]` : plusieurs bouts en un appel | Moins de tours de pilotage |
|
||||
| Écriture directe dans le dépôt avec diff court en retour | Claude ne reçoit pas le code |
|
||||
|
||||
## 7. Limites
|
||||
|
||||
- Un hook contrôle des appels, pas le raisonnement : Claude peut toujours rédiger puis découper artificiellement.
|
||||
- Chaque délégation coûte une consigne et un suivi : sous ~10 lignes, écrire peut être moins cher, d'où les seuils.
|
||||
- Des seuils trop stricts multiplieront les exceptions : les suivre dès le début.
|
||||
- Le hook 6 ajoute un tour au départ de chaque tâche ; il doit rester sans objet pour une correction minime.
|
||||
|
||||
## 8. Ordre de travail
|
||||
|
||||
Chaque lot est découpé en bouts confiés à MIAM : le plugin sert de premier terrain d'essai.
|
||||
|
||||
1. **Lot 1 (P0)** : compteurs par tour, hooks 1, 2, 3, 4, exception, repli. Vérifier d'abord les points de la section 3.
|
||||
2. **Lot 2 (MIAM)** : `auto` par défaut, API pendant le bench, projet du plugin.
|
||||
3. **Lot 3 (P1)** : budget de lecture, plan, sélecteur d'exécutant.
|
||||
4. **Lot 4** : `controle=`, `lot=`, écriture directe.
|
||||
5. **Lot 5 (P2)** : bilan, compteur, documentation.
|
||||
|
||||
**Mesures** par session : part des lignes écrites par délégation, net de tokens, taux de reprise, nombre d'exceptions par
|
||||
motif, refus jugés à tort. Objectif proposé après le lot 1 : ≥ 70 % des lignes hors zones sensibles.
|
||||
|
||||
## 9. Décisions attendues
|
||||
|
||||
- Seuils de départ : 10 lignes pour les tests, 30 pour le code, ou plus strict.
|
||||
- Mode : refus immédiat, ou une semaine en mesure seule pour régler les seuils (recommandé : refus immédiat sur les
|
||||
tests et le commit, mesure sur le reste).
|
||||
- Liste des chemins sensibles exemptés.
|
||||
- Part acceptable de Haiku dans l'échelle, à comparer au coût de l'API.
|
||||
|
||||
## 10. Version cible : plugin v0.9.0
|
||||
|
||||
**But** : une version dont les gains de tokens sont réels et mesurés, parce que Claude délègue par défaut.
|
||||
|
||||
**Valeurs retenues par défaut** (issues de la section 9, modifiables) : 10 lignes pour les tests, 30 lignes pour le code, refus immédiat sur tous les hooks P0, budget de lecture de 40 000 caractères par tour et 400 lignes par fichier ; chemins sensibles : `src/miam/services/mcp.py`, `src/miam/services/instances.py`, `src/miam/core/aio.py` plus tout ce qui correspond à `*sandbox*`, liste à confirmer.
|
||||
|
||||
**Périmètre plugin** (checklist) : compteurs de tour (lignes écrites par l'agent principal, caractères lus, dernière délégation réussie) ; hooks 1, 2, 3, 4 (section 3) ; hook 5 budget de lecture ; hook 6 plan de découpage ; hook 7 sélecteur d'exécutant ; `exception` avec raisons fermées ; repli quand aucun exécutant ne répond ; refus de `suivre` pendant `miam-attendre` ; rapport de fin de tour et compteur visible (hooks 8, 9) ; skill `deleguer` mis à jour pour enseigner le découpage, `moteur="auto"` et `reprise`.
|
||||
|
||||
**Prérequis MIAM** (PH2-H11, même livraison) : `moteur="auto"` par défaut ; API au lieu de la préemption pendant un benchmark ; projet plugin autorisé pour l'API ; `controle` avec contrôles purs et `lint` ; boucle de correction et escalade ; états terminaux, `miam-attendre`, `reprise`, chien de garde `mcp.sans_progres_s` ; `verdict`. `lot=`, écritures directes et `commande` en sandbox peuvent suivre plus tard sans bloquer la v0.9.0.
|
||||
|
||||
**Vérifications préalables** (bloquantes) : les trois points de « À vérifier avant de coder » en section 3 ; durée maximale de blocage tolérée par un client MCP. Fait le 03/11/2026 : une commande en arrière-plan de 11 min réveille bien Claude à sa sortie, sans token pendant l'attente.
|
||||
|
||||
**Critères d'acceptation** (checklist, chacun testable) : une édition de test de l'agent principal de plus de 10 lignes est refusée avec un appel prêt ; une écriture via le shell est comptée ; un `git commit` sans message délégué est refusé ; une tâche découpée en plusieurs morceaux lisant le même fichier dépasse le budget de lecture et est refusée ; tous les exécutants étant indisponibles, le travail continue et un toast le signale ; une délégation longue coûte un lancement et un réveil, sans appel à `suivre` ; un `echec_controle` rend la main avec le dernier échec ; les résultats `valide` sont intégrés sans que Claude lise le corps du code ; les exceptions apparaissent dans le rapport ; les sous-agents ne sont pas soumis aux budgets ; tous les tests du plugin passent.
|
||||
|
||||
**Mesure du gain** : gain réel = tokens Claude effectivement consommés, lus depuis `session.usage`, pas les `evites` estimés. Protocole : rejouer des tâches comparables (une fonction pure avec tests, une tâche UI multi-fichiers, une extraction de document) sur v0.8.0 et v0.9.0, mêmes prompts ; enregistrer tokens Claude totaux, coût cloud, durée, corrections, exceptions, actions refusées à tort, bugs trouvés ensuite. Comparer aussi `net` (`evites - pilotage - corrections`) à la différence mesurée pour calibrer l'estimation. Version valide seulement si les tokens Claude mesurés baissent sans hausse des bugs post-merge ; la cible est fixée après la première comparaison, aucun chiffre décidé maintenant.
|
||||
|
||||
**Ordre des bouts** :
|
||||
1. vérifications ;
|
||||
2. compteurs avec tests ;
|
||||
3. hooks P0 et exception ;
|
||||
4. prérequis MIAM ;
|
||||
5. budget de lecture, plan, sélecteur ;
|
||||
6. rapport de fin de tour et compteur ;
|
||||
7. skill et docs ;
|
||||
8. mesure puis release v0.9.0.
|
||||
|
||||
Chaque bout suit la section 4 et est délégué.
|
||||
|
||||
## 11. Contrat livré par MIAM (PH2-H11) et travail restant côté plugin
|
||||
|
||||
État au 03/11/2026, code de MIAM non commité, tests verts. Le MIAM installé doit être reconstruit pour l'exposer.
|
||||
|
||||
### Ce que MIAM accepte
|
||||
|
||||
`deleguer(..., controle=[...], attendus=[...])` :
|
||||
|
||||
- `controle` : liste parmi `syntaxe`, `complet`, `symboles`, `docstrings`, `citations`, `traduction`, `lint`. `[]` désactive les contrôles.
|
||||
- Par défaut : `format="code"` avec une `sortie` en `.py` donne `syntaxe`, `complet`, `symboles`, `docstrings`, `lint` ; tout le reste donne `complet`.
|
||||
- `attendus` : noms qui doivent figurer dans la réponse (fonctions, classes), vérifiés par `complet`.
|
||||
- `lint` : `ruff check --select F,E9` seulement (vraies erreurs, pas le style) ; ignoré si ruff est introuvable (cas de l'exe).
|
||||
- `symboles` et `citations` comparent la réponse aux fichiers joints : joindre les modules utilisés.
|
||||
- Tentatives : `mcp.controle_essais` (2) par moteur ; une demande locale passe ensuite à l'API.
|
||||
|
||||
Écarts avec la section 5 bis : `format` s'appelle `syntaxe` (Python seulement) ; `commande` et `perimetre` ne sont pas faits ; il n'y a ni `moteur_final` ni statistiques de diff.
|
||||
|
||||
### Ce que MIAM renvoie
|
||||
|
||||
| cas | réponse | `fin` (route `/attendre`) |
|
||||
|---|---|---|
|
||||
| terminé, contrôles passés | `ok: true`, `verdict: "valide"`, `controles: [{nom, ok, message?}]`, `essais`, `sortie` si demandée | `valide` |
|
||||
| terminé sans contrôle | `ok: true`, `verdict: "non_controle"` | `termine` |
|
||||
| contrôles échoués partout | `ok: false`, `erreur: "echec_controle : nom : message ; …"` | `echec_controle` |
|
||||
| annulé | `ok: false`, `etat: "annulee"` | `annule` |
|
||||
| demande inconnue (MIAM redémarré) | `ok: false`, sans `etat` | `perdue` |
|
||||
| permission OpenCode, autre cas en attente | `ok: null` | `besoin_claude` |
|
||||
| pas fini dans `call_wait_s` (45 s) | `ok: null`, `demande_id`, `etat`, `reprise`, `message` | (sans objet) |
|
||||
|
||||
`sortie` n'est écrite qu'après le verdict `valide`. Les tentatives rejetées sont journalisées dans `delegations.jsonl` avec `statut: "rejete"`, sans gain.
|
||||
|
||||
### Attente unique
|
||||
|
||||
`reprise` vaut exactement :
|
||||
|
||||
```
|
||||
curl -s -H @"<logs>/mcp/attente.hdr" "http://127.0.0.1:<port>/attendre?demande_id=<id>"
|
||||
```
|
||||
|
||||
- `attente.hdr` contient l'en-tête `Authorization`, écrit par MIAM au démarrage du serveur MCP : aucun jeton dans la commande ni dans la conversation.
|
||||
- `GET /attendre` répond une seule fois, à l'état final : la même réponse que `suivre`, plus `fin`. Chaque attente interne dure au plus `ATTENTE_ROUTE_S` (3600 s) et reprend en boucle tant que la demande n'est pas finie.
|
||||
- À lancer avec `Bash` et `run_in_background: true` (Git Bash sous Windows : la commande est en syntaxe POSIX). Vérifié le 03/11/2026 sur le vrai serveur HTTP.
|
||||
|
||||
### Encore manquant côté MIAM
|
||||
|
||||
- Chien de garde `mcp.sans_progres_s` : pas encore fait, car le modèle répond sans flux et rien ne mesure l'avancement. La seule limite reste `timeout_execution_s`, multiplié par le nombre de tentatives. Une très longue tâche peut donc finir en expiration (`ok: false`, `fin: "besoin_claude"` ou `perdue`).
|
||||
- Contrôles `commande` (tests en bac à sable) et `perimetre`.
|
||||
- `moteur="auto"` par défaut ; API au lieu de la préemption pendant un benchmark.
|
||||
|
||||
### À coder dans le plugin (v0.9.0), par bouts
|
||||
|
||||
Fait le 03/11/2026 (bouts 1 à 7, `hooks/attente.ts` et `register.tsx`, tests sur réponses figées), avec deux écarts :
|
||||
le réveil est lu par un hook `prompt.submit`, qui reconnaît la notification à l'id de la commande d'arrière-plan relevé
|
||||
au lancement et lit lui-même la sortie (une ligne JSON) ; PowerShell est refusé pour `reprise`, en syntaxe POSIX.
|
||||
Pas encore essayé sur le vrai serveur.
|
||||
|
||||
1. **Hook PostToolUse sur `deleguer`** : si `ok` est `null` et qu'il y a une `reprise`, ajouter au contexte « lance `reprise` une fois avec `run_in_background`, puis termine ton tour ou fais autre chose », et noter `demande_id` comme en attente dans l'état du tour.
|
||||
2. **Hook PreToolUse sur `Bash`** : reconnaître une commande qui contient `/attendre?demande_id=` ; sans `run_in_background`, la refuser (« lance-la en arrière-plan ») ; avec, passer la demande à « attendue ».
|
||||
3. **Hook PreToolUse sur `suivre`** : refuser si la demande est « attendue », avec « tu seras réveillé à la fin ». Refuser aussi les `sleep` et les boucles d'attente en `Bash` pendant une attente. Laisser passer `suivre` si `fin` vaut `besoin_claude` (permission à donner).
|
||||
4. **Hook sur la sortie de la commande d'arrière-plan** (ou PostToolUse sur la notification) : lire `fin` et injecter la conduite :
|
||||
- `valide` : intégrer sans relire le corps, ne pas `Read` la `sortie` en entier ;
|
||||
- `termine` : relire ;
|
||||
- `echec_controle` : reprendre la main ou redécouper, avec le dernier motif ;
|
||||
- `besoin_claude` : répondre (`autoriser`), puis relancer `reprise` ;
|
||||
- `annule` : rien ;
|
||||
- `perdue` : renvoyer la demande ou faire soi-même.
|
||||
5. **Hook PreToolUse sur `Read`** : après `valide`, refuser une lecture complète du fichier `sortie` (aperçu ou `offset`/`limit` seulement), sauf chemin sensible.
|
||||
6. **Skill `deleguer`** : enseigner `controle` et `attendus`, joindre les modules pour `symboles`, lancer `reprise` en arrière-plan, et le tableau `fin` ci-dessus.
|
||||
7. **Compteurs** : `essais`, nombre de `echec_controle` et attentes uniques par session, pour la mesure de la section 10.
|
||||
|
||||
Tests de chaque hook sur des réponses figées tirées du tableau ci-dessus ; les autres hooks (sections 3 et 10) suivent l'ordre de la section 10.
|
||||
@@ -4,7 +4,7 @@ Destinataire : l'agent Claude Code qui travaille dans le dépôt MIAM. Le côté
|
||||
`plugin-claude-miam`, schéma 1) ; ce document décrit ce que MIAM doit recevoir, stocker et restituer.
|
||||
|
||||
## But
|
||||
Le mod estime, sans aucun coût en tokens, ce que chaque délégation évite à Claude et le coût du pilotage (consommation du subagent `miam:delegue`). MIAM connaît les tokens du modèle local mais pas ce qui se passe côté Claude : ces événements complètent son bilan.
|
||||
Le mod estime, sans aucun coût en tokens, ce que chaque délégation évite à Claude et le coût du pilotage (consommation d'un subagent qui délègue ; le subagent `miam:delegue` est mis de côté, voir `agent-delegue.md`). MIAM connaît les tokens du modèle local mais pas ce qui se passe côté Claude : ces événements complètent son bilan.
|
||||
|
||||
## Prérequis dans MIAM : `demande_id` dans le journal
|
||||
`delegations.jsonl` ne contient pas aujourd'hui l'identifiant de la demande (`core/delegation.py`, `journal_entry`) :
|
||||
@@ -81,11 +81,11 @@ quand `suivre` rend son résultat.
|
||||
| `date` | `string` | ISO 8601 de la génération de l'événement. |
|
||||
| `client` | `string` | Valeur `"claude-code"`. |
|
||||
| `session_id` | `string` | Identifiant de la session Claude Code. |
|
||||
| `agent_id` | `string` | Id du subagent (toujours présent : l'agent principal n'a pas de pilotage). |
|
||||
| `turn_id` | `string` | Id du tour du subagent (un tour = une exécution du subagent). |
|
||||
| `agent_id` | `string` | Id du subagent, ou `"principal"` pour le suivi d'OpenCode par l'agent principal. |
|
||||
| `turn_id` | `string` | Id du tour du subagent (un tour = une exécution du subagent), ou `tool_use_id` de l'appel pour le suivi d'OpenCode. |
|
||||
| `demande_ids` | `string[]` | Les `demande_id` des délégations réussies de ce tour (au moins un). |
|
||||
| `modele` | `string` | Modèle Claude du subagent, tel que l'API le nomme (ex. `claude-haiku-5-5`). |
|
||||
| `usage` | `object` | Les 4 compteurs de l'API pour ce tour (entiers) : `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`. |
|
||||
| `modele` | `string` | Modèle Claude du subagent, tel que l'API le nomme (ex. `claude-haiku-5-5`), ou `"opencode"` pour le suivi d'OpenCode. |
|
||||
| `usage` | `object` | Les 4 compteurs de l'API pour ce tour (entiers) : `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`. Pour le suivi d'OpenCode, usage réel de la requête de l'agent principal qui lit le résultat d'un appel de suivi (`deleguer`, `suivre`, `autoriser`, `appliquer`, `rejeter`, `annuler`, relecture du diff), contexte relu compris, au prorata des outils lancés par la requête précédente. |
|
||||
| `cout` | `integer` | Coût du tour en tokens Claude, à déduire des `evites` des délégations couvertes. |
|
||||
|
||||
Le pilotage arrive à la fin du tour du subagent, donc après ses événements `delegation` (sauf renvoi : l'ordre
|
||||
@@ -120,7 +120,7 @@ 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.
|
||||
local sur la tâche : c'est la donnée la plus utile pour classer les presets avec les tests de MIAM.
|
||||
|
||||
**Calcul du coût (fonction `cout`) :**
|
||||
`input_tokens + cache_creation_input_tokens + output_tokens + Math.round(cache_read_input_tokens / 10)`.
|
||||
@@ -190,7 +190,7 @@ tous les ordres.
|
||||
`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).
|
||||
format (utile au choix du modèle par MIAM).
|
||||
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).
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
{
|
||||
"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",
|
||||
"version": "0.9.0",
|
||||
"description": "Délégation de tâches à un modèle local géré par MIAM (Mon IA Manager) : règles de délégation, gains en tokens et usage Claude (5 h, hebdo) en couleur",
|
||||
"author": {
|
||||
"name": "Ange-Marie MAURIN"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": ["miam", "llama.cpp", "local", "delegation", "mcp"],
|
||||
"userConfig": {
|
||||
"incitation": {
|
||||
"type": "boolean",
|
||||
"title": "Inciter Claude à déléguer",
|
||||
"description": "Ajoute au prompt système une consigne de délégation et un rappel après une grosse lecture",
|
||||
"default": true
|
||||
},
|
||||
"imposer": {
|
||||
"type": "boolean",
|
||||
"title": "Imposer la délégation",
|
||||
"description": "Refuse à l'agent principal la lecture entière d'un long fichier et retient les longues sorties Bash/PowerShell, tant que MIAM répond",
|
||||
"default": true
|
||||
},
|
||||
"chemins_sensibles": {
|
||||
"type": "string",
|
||||
"title": "Chemins sensibles",
|
||||
"description": "Fichiers relus en entier même après un verdict valide de MIAM (concurrence, sécurité), séparés par des virgules ; « * » pour n'importe quels caractères",
|
||||
"default": "src/miam/services/mcp.py, src/miam/services/instances.py, src/miam/core/aio.py, *sandbox*"
|
||||
},
|
||||
"jeton": {
|
||||
"type": "string",
|
||||
"title": "Jeton du serveur MCP de MIAM",
|
||||
@@ -24,18 +42,12 @@
|
||||
"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)",
|
||||
"description": "Preset démarré quand aucun modèle n'est chargé et que MIAM n'a aucun test pour le type de la tâche (id ou nom, voir l'outil modeles)",
|
||||
"default": "pre-16312f8b"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ange-Marie MAURIN
|
||||
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
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# miam
|
||||
|
||||
[← Tous les plugins](../../README.md)
|
||||
|
||||
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.
|
||||
- Skill `deleguer` : règles, pour l'agent principal, sur quand et comment déléguer une tâche à MIAM, y compris à OpenCode (permissions, puis `appliquer` ou `rejeter` le diff).
|
||||
|
||||
## Prérequis
|
||||
MIAM 2.5.1 ou plus (`type`, `seuil` et l'outil `modeles`, champs `offload` à partir de 2.5.2) lancé avec son serveur MCP démarré (interrupteur du dashboard, menu « Serveur MCP » du tray, ou `--auto-start-mcp`).
|
||||
|
||||
## Installation
|
||||
Le plugin fait partie de la marketplace `a2m-plugins` (voir le [README du dépôt](../../README.md#installation) pour
|
||||
l'ajout de la marketplace, les dépôts privés et les mises à jour).
|
||||
|
||||
- **VS Code** : `/plugins` → onglet « Marketplaces » : ajouter
|
||||
`https://a2msystemes-git.duckdns.org/ange/plugin-claude-miam.git` (avec `.git`), puis onglet « Plugins » → `miam` →
|
||||
Install.
|
||||
- **Terminal** :
|
||||
```text
|
||||
/plugin marketplace add https://a2msystemes-git.duckdns.org/ange/plugin-claude-miam.git
|
||||
/plugin install miam@a2m-plugins
|
||||
```
|
||||
|
||||
Portée conseillée : « Install for you » (tous les projets du poste). Mises à jour : augmenter `version` dans
|
||||
`.claude-plugin/plugin.json` à chaque publication.
|
||||
|
||||
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. |
|
||||
| incitation | oui | Ajoute au prompt système une consigne de délégation (quelques centaines de tokens, en cache) et un rappel après une grosse lecture. |
|
||||
| chemins_sensibles | `src/miam/services/mcp.py`, `src/miam/services/instances.py`, `src/miam/core/aio.py`, `*sandbox*` | Fichiers relus en entier même après un verdict `valide` (concurrence, sécurité), séparés par des virgules. |
|
||||
| preset_defaut | pre-16312f8b | Preset MIAM par défaut, démarré quand aucun modèle n'est chargé et que MIAM n'a aucun test pour le type de la tâche. |
|
||||
|
||||
## Utilisation
|
||||
L'agent principal appelle directement l'outil `deleguer`, avec `type` (`code`, `texte`, `rapide`) et `seuil` (0,8 par défaut), puis vérifie le résultat. Le subagent `miam:delegue` est mis de côté : son pilotage rendait les gains négatifs. Sa définition est gardée dans `docs/agent-delegue.md` du dépôt.
|
||||
|
||||
MIAM choisit le modèle d'après ses tests de qualité : il garde le modèle chargé s'il atteint le seuil, sinon il charge le meilleur preset, quitte à remplacer le modèle chargé (20 à 40 s) ; si aucun n'atteint le seuil, il prend le meilleur et le signale. L'agent principal peut imposer un preset ou demander de garder le modèle chargé. Contrat : `docs/protocole-agents.md` du dépôt MIAM.
|
||||
|
||||
## Incitation à déléguer
|
||||
|
||||
Avec `incitation` (par défaut), l'agent principal reçoit une courte consigne dans son prompt système : déléguer par réflexe ce qui s'y prête, sans demander. Après une lecture de plus de 20 000 caractères (`Read`, `Bash`, `PowerShell`), il reçoit aussi un rappel, au plus toutes les 10 minutes et invisible pour l'utilisateur. Les subagents ne reçoivent ni l'une ni l'autre.
|
||||
|
||||
## Délégation imposée
|
||||
|
||||
Avec `imposer` (par défaut), et tant que MIAM répond, l'agent principal ne peut plus consommer d'usage sur de longues lectures :
|
||||
- `Read` d'un fichier de 20 000 octets ou plus est refusé avant la lecture, sauf plage de 400 lignes au plus (`offset`, `limit`), image, PDF ou fichier écrit par une délégation ;
|
||||
- une sortie `Bash` ou `PowerShell` de 20 000 caractères ou plus est enregistrée dans `.claude/miam-sorties/` du projet (dossier ignoré par git) et retenue : le modèle reçoit le chemin, à passer à `deleguer`. Une commande en erreur garde sa sortie et déclenche le rappel.
|
||||
|
||||
## Attente unique
|
||||
|
||||
Une délégation pas finie en 45 s renvoie `reprise`, une commande `curl` qui bloque sur MIAM jusqu'à l'état final. Le plugin dit à l'agent principal de la lancer une fois en arrière-plan (Bash, `run_in_background`), puis :
|
||||
- refuse de la lancer au premier plan ou avec PowerShell ;
|
||||
- refuse `suivre` et les `sleep` tant que la demande est attendue (`suivre` reste permis quand MIAM attend une décision, `fin: besoin_claude`) ;
|
||||
- au réveil, lit lui-même la sortie de la commande et donne le résultat et la conduite selon `fin` (`valide`, `termine`, `echec_controle`, `besoin_claude`, `annule`, `perdue`) : une délégation coûte un lancement et un réveil ;
|
||||
- après un verdict `valide`, refuse la lecture entière du fichier `sortie` (plage de 400 lignes au plus), sauf chemin sensible.
|
||||
|
||||
`/miam-economies` compte les attentes uniques, les essais et les échecs de contrôle de la session. Avec `imposer` désactivé, rien n'est refusé.
|
||||
|
||||
Les subagents ne sont pas concernés. Si MIAM ne répond pas, ou si la sortie ne peut pas être enregistrée, rien n'est bloqué. Si MIAM répond mais que la délégation échoue (`/mcp` à reconnecter, par exemple), la lecture par plages reste possible.
|
||||
|
||||
## Ligne d'état : gains et usage Claude
|
||||
|
||||
Une seule ligne, sans coût en tokens (`🟢 MIAM net ~4k (cumul ~35k)🟢 · 5 h 🟩🟩⬛⬛⬛ 42 % (1 h 53) · hebdo 🟩🟩🟩⬛⬛ 53 % (3 j 20 h)`) :
|
||||
- le gain net, 🟢 s'il est positif, 🔴 s'il est négatif ;
|
||||
- l'usage Claude sur 5 h et sur la semaine : barre, pourcentage et temps avant la remise à zéro. La barre est verte, orange à partir de 70 %, rouge à partir de 90 %. Ces chiffres viennent de la dernière réponse de l'API : ils n'apparaissent qu'après un premier échange, et seulement avec un abonnement.
|
||||
|
||||
## Tokens économisés
|
||||
|
||||
Après chaque délégation réussie, une notification (`🟢 MIAM : ~6,1k tokens évités (5,2k lus, 900 écrits)`) et la mise à
|
||||
jour de la ligne d'état, dans le terminal comme dans VS Code. `/miam-economies` donne le détail de la session et le cumul ; `/miam-economies raz`
|
||||
remet les compteurs à zéro. Le calcul se fait localement dans Claude Code : il ne coûte aucun token.
|
||||
Chaque estimation est aussi envoyée à MIAM (`POST /economies`, toutes les 5 s, hors des hooks ; gardée et renvoyée si
|
||||
MIAM ne répond pas) : contrat et modèle de données dans [docs/token-summary-mod.md](../../docs/token-summary-mod.md).
|
||||
|
||||
- **Évités** : les fichiers lus et la réponse écrite par le modèle local (chiffres de MIAM), moins la consigne et ce que
|
||||
Claude relit (estimés à 4 caractères par token).
|
||||
- **Pilotage** : la consommation réelle d'un subagent qui délègue (lecture en cache comptée au dixième), ou l'usage réel des requêtes de l'agent principal qui suivent OpenCode, contexte relu et relecture du diff compris (lecture en cache au dixième), à déduire. Le gain d'OpenCode vaut les tokens du cloud moins la consigne ; un diff rejeté n'évite rien.
|
||||
- **Corrections** : ce que l'agent principal relit (`Read`) ou retouche (`Edit`, `Write`, `NotebookEdit`, `Bash`) dans
|
||||
un fichier écrit par une délégation (`sortie`), à déduire aussi. Celles du subagent sont déjà dans le pilotage.
|
||||
- **Net** = évités − pilotage − corrections.
|
||||
- **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`).
|
||||
|
||||
## Tests des modèles
|
||||
MIAM teste lui-même les presets, en travaux de fond (page Benchmarks) : le plugin n'a plus de banc. L'ancien dossier de données du plugin (`banc/`, `rapport.md`, `choix.json`) n'est plus lu.
|
||||
|
||||
## Structure du plugin
|
||||
```text
|
||||
.claude-plugin/
|
||||
plugin.json # manifeste : nom, version, réglages (jeton, port, modèle, preset)
|
||||
.mcp.json # serveur MCP miam (http://127.0.0.1:<port>/mcp)
|
||||
skills/
|
||||
deleguer/SKILL.md # quand et comment déléguer
|
||||
hooks/
|
||||
hooks.json
|
||||
register.tsx # compteur de tokens, ligne d'état, incitation, /miam-economies
|
||||
economies.ts # calcul pur, testé
|
||||
attente.ts # attente unique et conduite selon `fin` : calcul pur, testé
|
||||
contrainte.ts # seuils des lectures et sorties longues
|
||||
usage.ts # usage Claude 5 h et hebdo : calcul pur, testé
|
||||
*.test.ts(x) # tests : claude plugin test plugins/miam
|
||||
LICENSE
|
||||
README.md
|
||||
```
|
||||
|
||||
## 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é.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT, voir [LICENSE](LICENSE).
|
||||
@@ -1,184 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,165 +0,0 @@
|
||||
## 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).
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
(.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">
|
||||
@@ -1,45 +0,0 @@
|
||||
============================= 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) =============
|
||||
@@ -1,92 +0,0 @@
|
||||
"""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]}"}
|
||||
@@ -1,372 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,109 +0,0 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from 'claude-code/testing'
|
||||
|
||||
import {
|
||||
conduite, demandeAttendue, estSommeil, fichierSortie, finDe, lectureEntiere, lireFin, sensible,
|
||||
} from './attente'
|
||||
|
||||
const REPRISE = 'curl -s -H @"C:/logs/mcp/attente.hdr" "http://127.0.0.1:8090/attendre?demande_id=dem-1a2b"'
|
||||
|
||||
test('la commande reprise désigne sa demande, une autre commande aucune', () => {
|
||||
expect(demandeAttendue(REPRISE)).toBe('dem-1a2b')
|
||||
expect(demandeAttendue('curl -s http://127.0.0.1:8090/etat')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('sleep, Start-Sleep et timeout /t sont des attentes, pas un mot qui les contient', () => {
|
||||
expect(['sleep 30', 'while true; do sleep 5; done', 'Start-Sleep -Seconds 10', 'timeout /t 20'].map(estSommeil))
|
||||
.toEqual([true, true, true, true])
|
||||
expect(['ls asleep', 'grep sleeper src', 'pytest -k timeout'].map(estSommeil)).toEqual([false, false, false])
|
||||
})
|
||||
|
||||
test('lireFin prend la dernière ligne JSON avec un fin connu', () => {
|
||||
const sortie = 'bruit\n{"fin":"bizarre"}\n{"ok":true,"demande_id":"dem-1","verdict":"valide","fin":"valide"}\n'
|
||||
expect(lireFin(sortie)).toMatchObject({ fin: 'valide', demande_id: 'dem-1' })
|
||||
expect(lireFin('curl: (7) Failed to connect')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('finDe : seulement ce que MIAM a contrôlé', () => {
|
||||
expect(finDe({ ok: true, verdict: 'valide' })).toBe('valide')
|
||||
expect(finDe({ ok: true, verdict: 'non_controle' })).toBe('termine')
|
||||
expect(finDe({ ok: true })).toBeUndefined() // ancien MIAM, sans contrôle
|
||||
expect(finDe({ ok: false, erreur: 'echec_controle : lint : F401' })).toBe('echec_controle')
|
||||
expect(finDe({ ok: false, erreur: 'aucun preset testé' })).toBeUndefined()
|
||||
expect(finDe({ ok: null })).toBeUndefined()
|
||||
})
|
||||
|
||||
test('conduite : une ligne par fin, avec ce qui sert à agir', () => {
|
||||
expect(conduite({ fin: 'valide', demande_id: 'd', essais: 2, sortie: 'E:/p/x.py' }))
|
||||
.toContain('résultat écrit dans E:/p/x.py')
|
||||
expect(conduite({ fin: 'echec_controle', erreur: 'echec_controle : lint : F401' })).toContain('lint : F401')
|
||||
expect(conduite({ fin: 'echec_controle', controles: [{ nom: 'symboles', ok: false, message: 'champ x' }] }))
|
||||
.toContain('symboles : champ x')
|
||||
expect(conduite({ fin: 'besoin_claude' }, REPRISE)).toContain(REPRISE)
|
||||
for (const fin of ['termine', 'annule', 'perdue'] as const) {
|
||||
expect(conduite({ fin }).length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('lecture entière sans limit bornée ; chemins sensibles par fin de chemin ou motif', () => {
|
||||
expect([{}, { limit: 2000 }, { offset: 10, limit: 50 }].map(lectureEntiere)).toEqual([true, true, false])
|
||||
expect(sensible('E:\\dev\\miam\\src\\miam\\services\\mcp.py')).toBe(true)
|
||||
expect(sensible('E:/dev/miam/src/miam/infra/sandbox_shell.py')).toBe(true)
|
||||
expect(sensible('E:/dev/miam/src/miam/services/mcp_card.py')).toBe(false)
|
||||
expect(sensible('E:/p/a.py', ['*.py'])).toBe(true)
|
||||
})
|
||||
|
||||
test('fichier de sortie nommé par la notification', () => {
|
||||
const n = '<task-notification><task-id>b7</task-id><output-file>C:\\Temp\\b7.output</output-file>' +
|
||||
'<status>completed</status></task-notification>'
|
||||
expect(fichierSortie(n)).toBe('C:\\Temp\\b7.output')
|
||||
expect(fichierSortie('rien')).toBeUndefined()
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
// Attente unique d'une délégation (contrat de MIAM : docs/hooks-delegation.md, section 11).
|
||||
//
|
||||
// Pas finie dans `call_wait_s`, une délégation renvoie `reprise`, une commande curl qui bloque sur GET /attendre jusqu'à
|
||||
// l'état final. Lancée une fois en arrière-plan, elle réveille Claude à sa sortie : aucun `suivre` ni `sleep` entre-temps.
|
||||
// Calcul pur, testé ; register.tsx branche ces fonctions sur les hooks.
|
||||
|
||||
import { PLAGE_MAX } from './contrainte'
|
||||
|
||||
export type Fin = 'valide' | 'termine' | 'echec_controle' | 'besoin_claude' | 'annule' | 'perdue'
|
||||
|
||||
export type Controle = { nom: string; ok: boolean; message?: string }
|
||||
|
||||
// Réponse de GET /attendre : celle de `suivre`, plus `fin`.
|
||||
export type Final = {
|
||||
fin: Fin
|
||||
demande_id?: string
|
||||
ok?: boolean | null
|
||||
verdict?: string
|
||||
controles?: Controle[]
|
||||
essais?: number
|
||||
sortie?: string
|
||||
erreur?: string
|
||||
[cle: string]: unknown
|
||||
}
|
||||
|
||||
const FINS: readonly string[] = ['valide', 'termine', 'echec_controle', 'besoin_claude', 'annule', 'perdue']
|
||||
|
||||
// Demande attendue par une commande : l'id après `/attendre?demande_id=`.
|
||||
export function demandeAttendue(commande: string): string | undefined {
|
||||
return /\/attendre\?demande_id=([\w-]+)/.exec(commande)?.[1]
|
||||
}
|
||||
|
||||
// Commande d'attente active : `sleep`, `Start-Sleep`, `timeout /t`, seuls ou dans une boucle.
|
||||
export const estSommeil = (commande: string) => /(^|[\s;&|(])(sleep|start-sleep|timeout\s+\/t)\b/i.test(commande)
|
||||
|
||||
// Dernière ligne JSON de la sortie de curl, si elle porte un `fin` connu.
|
||||
export function lireFin(sortie: string): Final | undefined {
|
||||
const lignes = sortie.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
|
||||
for (const ligne of lignes.reverse()) {
|
||||
try {
|
||||
const r = JSON.parse(ligne) as Partial<Final>
|
||||
if (r && typeof r === 'object' && typeof r.fin === 'string' && FINS.includes(r.fin)) {
|
||||
return r as Final
|
||||
}
|
||||
} catch {
|
||||
// ligne qui n'est pas du JSON
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Fin d'une réponse directe de `deleguer` ou `suivre` (sans passer par /attendre), seulement quand MIAM a contrôlé :
|
||||
// un refus ordinaire (`ok: false`, aucun preset…) ou un résultat sans `verdict` (ancien MIAM) n'en a pas.
|
||||
export function finDe(r: { ok?: unknown; verdict?: unknown; erreur?: unknown }): Fin | undefined {
|
||||
if (r.ok === true && typeof r.verdict === 'string') {
|
||||
return r.verdict === 'valide' ? 'valide' : 'termine'
|
||||
}
|
||||
if (r.ok === false && typeof r.erreur === 'string' && r.erreur.startsWith('echec_controle')) {
|
||||
return 'echec_controle'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const echecs = (r: Final) => (r.controles ?? []).filter(c => !c.ok).map(c => `${c.nom}${c.message ? ` : ${c.message}` : ''}`)
|
||||
|
||||
// Ce que Claude lit au réveil : le résultat utile et la conduite à tenir, pour qu'il n'ait pas à ouvrir la sortie.
|
||||
export function conduite(r: Final, reprise?: string): string {
|
||||
const id = r.demande_id ? ` ${r.demande_id}` : ''
|
||||
const essais = r.essais ? `, ${r.essais} essai(s)` : ''
|
||||
switch (r.fin) {
|
||||
case 'valide':
|
||||
return `MIAM${id} : verdict valide, contrôles passés${essais}` +
|
||||
(r.sortie ? `, résultat écrit dans ${r.sortie}` : '') +
|
||||
`. Intègre-le sans relire son corps (aperçu ou \`offset\`/\`limit\` si besoin) ; ruff et les tests du projet ` +
|
||||
`suffisent comme vérification.`
|
||||
case 'termine':
|
||||
return `MIAM${id} : terminé sans contrôle automatique. Vérifie le résultat avant de l'intégrer.`
|
||||
case 'echec_controle':
|
||||
return `MIAM${id} : échec des contrôles après toutes les tentatives (${r.erreur ?? echecs(r).join(' ; ')}). ` +
|
||||
`Reprends la tâche toi-même ou redécoupe-la en bouts plus petits, avec ce motif dans la consigne.`
|
||||
case 'besoin_claude':
|
||||
return `MIAM${id} attend une décision de ta part (permission OpenCode, plafond de dépense, consigne ambiguë) : ` +
|
||||
`\`suivre\` pour la voir, réponds (\`autoriser\` pour une permission), puis relance ` +
|
||||
(reprise ? `\`${reprise}\`` : 'la commande `reprise`') + ' en arrière-plan.'
|
||||
case 'annule':
|
||||
return `MIAM${id} : demande annulée, rien à faire.`
|
||||
case 'perdue':
|
||||
return `MIAM${id} : demande perdue (MIAM redémarré ou injoignable). Renvoie-la, ou fais la tâche toi-même.`
|
||||
}
|
||||
}
|
||||
|
||||
export const consigneReprise = (reprise: string) =>
|
||||
`Délégation en cours. Lance une seule fois, avec Bash et \`run_in_background: true\` : \`${reprise}\`. ` +
|
||||
"Puis termine ton tour ou fais autre chose : tu seras réveillé à la fin. Pas de `suivre` ni de `sleep` d'ici là."
|
||||
|
||||
export const REFUS_PREMIER_PLAN = "Attente refusée par le plugin MIAM : lance cette commande en arrière-plan, avec " +
|
||||
"Bash et `run_in_background: true`. Elle bloque jusqu'à la fin de la délégation, et tu seras réveillé à sa sortie."
|
||||
export const REFUS_POWERSHELL = "Attente refusée par le plugin MIAM : la commande `reprise` est en syntaxe POSIX. " +
|
||||
'Lance-la telle quelle avec Bash et `run_in_background: true`.'
|
||||
export const REFUS_SUIVI = 'Suivi refusé par le plugin MIAM : cette délégation est attendue en arrière-plan, tu ' +
|
||||
'seras réveillé à la fin. Termine ton tour ou fais autre chose.'
|
||||
export const REFUS_SOMMEIL = "Attente refusée par le plugin MIAM : une délégation est attendue en arrière-plan et tu " +
|
||||
'seras réveillé à la fin. Pas de `sleep` ni de boucle : termine ton tour ou fais autre chose.'
|
||||
export const REFUS_LECTURE_VALIDE = 'Lecture refusée par le plugin MIAM : ce fichier a passé les contrôles de MIAM ' +
|
||||
`(verdict valide). Lis-en seulement la partie utile (\`offset\` et \`limit\` de ${PLAGE_MAX} lignes au plus), ou ` +
|
||||
'vérifie-le par ruff et les tests.'
|
||||
|
||||
// Lecture entière d'un fichier : pas de `limit` borné.
|
||||
export const lectureEntiere = (args: { limit?: unknown }) =>
|
||||
!(typeof args.limit === 'number' && args.limit > 0 && args.limit <= PLAGE_MAX)
|
||||
|
||||
// Chemin sensible (concurrence, sécurité) : relu en entier même après un verdict valide. `*` vaut n'importe quels
|
||||
// caractères ; un motif sans `*` désigne la fin du chemin.
|
||||
export const SENSIBLES = ['src/miam/services/mcp.py', 'src/miam/services/instances.py', 'src/miam/core/aio.py', '*sandbox*']
|
||||
|
||||
export function sensible(chemin: string, motifs: readonly string[] = SENSIBLES): boolean {
|
||||
const p = chemin.replace(/\\/g, '/').toLowerCase()
|
||||
return motifs.some(m => {
|
||||
const motif = m.replace(/\\/g, '/').toLowerCase()
|
||||
if (!motif.includes('*')) {
|
||||
return p === motif || p.endsWith(`/${motif}`)
|
||||
}
|
||||
const re = new RegExp(`(^|/)${motif.split('*').map(s => s.replace(/[.+?^${}()|[\]]/g, '\\$&')).join('.*')}$`)
|
||||
return re.test(p)
|
||||
})
|
||||
}
|
||||
|
||||
// Notification de fin d'une commande d'arrière-plan : son fichier de sortie, si elle le nomme.
|
||||
export const fichierSortie = (notification: string) =>
|
||||
/<output-file>\s*([^<]+?)\s*<\/output-file>/.exec(notification)?.[1]
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from 'claude-code/testing'
|
||||
|
||||
import { lectureTropLongue, nomSortie, PLAGE_MAX, SEUIL, sortieTropLongue } from './contrainte'
|
||||
|
||||
test('frontière de taille', () => {
|
||||
expect(lectureTropLongue({ file_path: 'a.txt' }, 19999)).toBe(false)
|
||||
expect(lectureTropLongue({ file_path: 'a.txt' }, 20000)).toBe(true)
|
||||
})
|
||||
|
||||
test('extensions image et PDF', () => {
|
||||
expect(lectureTropLongue({ file_path: 'A.PNG' }, 20000)).toBe(false)
|
||||
expect(lectureTropLongue({ file_path: 'x.jpg' }, 20000)).toBe(false)
|
||||
expect(lectureTropLongue({ file_path: 'x.JPEG' }, 20000)).toBe(false)
|
||||
expect(lectureTropLongue({ file_path: 'x.gif' }, 20000)).toBe(false)
|
||||
expect(lectureTropLongue({ file_path: 'x.webp' }, 20000)).toBe(false)
|
||||
expect(lectureTropLongue({ file_path: 'x.pdf' }, 20000)).toBe(false)
|
||||
expect(lectureTropLongue({ file_path: 'x.md' }, 20000)).toBe(true)
|
||||
})
|
||||
|
||||
test('chemin non chaîne', () => {
|
||||
expect(lectureTropLongue({ file_path: 42 }, 20000)).toBe(false)
|
||||
expect(lectureTropLongue({}, 20000)).toBe(false)
|
||||
})
|
||||
|
||||
test('limites de plage', () => {
|
||||
expect(lectureTropLongue({ file_path: 'a.txt', limit: 400 }, 20000)).toBe(false)
|
||||
expect(lectureTropLongue({ file_path: 'a.txt', limit: 401 }, 20000)).toBe(true)
|
||||
expect(lectureTropLongue({ file_path: 'a.txt', limit: 0 }, 20000)).toBe(true)
|
||||
expect(lectureTropLongue({ file_path: 'a.txt', limit: 1 }, 20000)).toBe(false)
|
||||
})
|
||||
|
||||
test('limit texte', () => {
|
||||
expect(lectureTropLongue({ file_path: 'a.txt', limit: '10' }, 20000)).toBe(true)
|
||||
})
|
||||
|
||||
test('sortie trop longue', () => {
|
||||
expect(sortieTropLongue('x'.repeat(19999))).toBe(false)
|
||||
expect(sortieTropLongue('x'.repeat(20000))).toBe(true)
|
||||
expect(sortieTropLongue(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test('nom de sortie', () => {
|
||||
expect(nomSortie('toolu_01-AB')).toBe('sortie-toolu_01-AB.txt')
|
||||
expect(nomSortie('a/b:c')).toBe('sortie-a_b_c.txt')
|
||||
})
|
||||
|
||||
test('constantes', () => {
|
||||
expect(SEUIL).toBe(20000)
|
||||
expect(PLAGE_MAX).toBe(400)
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// Seuils et bornes pour la détection des sorties trop volumineuses.
|
||||
|
||||
export const SEUIL = 20000
|
||||
export const PLAGE_MAX = 400
|
||||
|
||||
// Extensions considérées comme binaires (images et PDF), insensibles à la casse.
|
||||
const EXTENSIONS_BINAIRES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf'])
|
||||
|
||||
// Vérifie si le chemin pointe vers un fichier binaire (image ou PDF).
|
||||
function estBinaire(chemin: string): boolean {
|
||||
// Isoler le nom de fichier (après le dernier séparateur de dossier).
|
||||
const nom = chemin.split(/[/\\]/).pop() ?? ''
|
||||
// Extraire l'extension (après le dernier point du nom de fichier).
|
||||
const point = nom.lastIndexOf('.')
|
||||
if (point === -1 || point === nom.length - 1) {
|
||||
return false
|
||||
}
|
||||
return EXTENSIONS_BINAIRES.has(nom.slice(point + 1).toLowerCase())
|
||||
}
|
||||
|
||||
// Vérifie si la lecture est bornée (limit est un nombre strictement positif et ≤ PLAGE_MAX).
|
||||
function estBornee(limit: unknown): boolean {
|
||||
return typeof limit === 'number' && limit > 0 && limit <= PLAGE_MAX
|
||||
}
|
||||
|
||||
export function lectureTropLongue(args: { file_path?: unknown; limit?: unknown }, taille: number): boolean {
|
||||
// Condition 1 : taille au-delà du seuil.
|
||||
if (taille < SEUIL) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Condition 2 : chemin de fichier texte (pas une image ni un PDF).
|
||||
const chemin = args.file_path
|
||||
if (typeof chemin !== 'string' || estBinaire(chemin)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Condition 3 : lecture non bornée.
|
||||
return !estBornee(args.limit)
|
||||
}
|
||||
|
||||
export function sortieTropLongue(texte: string | undefined): boolean {
|
||||
return texte !== undefined && texte.length >= SEUIL
|
||||
}
|
||||
|
||||
export function nomSortie(toolUseId: string): string {
|
||||
// Remplacer chaque caractère non alphanumérique (sauf _ et -) par un underscore.
|
||||
const idNettoye = toolUseId.replace(/[^A-Za-z0-9_-]/g, '_')
|
||||
return `sortie-${idNettoye}.txt`
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from 'claude-code/testing'
|
||||
|
||||
import { court, cout, estimer, ligne, lireResultat, VIDE } from './economies'
|
||||
import { court, cout, estimer, evenementDelegation, ligne, pastille, lireResultat, repartir, VIDE } from './economies'
|
||||
|
||||
const REPONSE = { ok: true, demande_id: 'dem-1', tokens_entree: 2049, tokens_sortie: 892, reponse: 'x'.repeat(400) }
|
||||
|
||||
@@ -34,10 +34,69 @@ test('affichage', () => {
|
||||
expect(court(-350)).toBe('-350')
|
||||
const cumul = { delegations: 9, evites: 40000, pilotage: 5000, corrections: 0 }
|
||||
expect(ligne({ delegations: 2, evites: 6100, pilotage: 1500, corrections: 0 }, cumul))
|
||||
.toBe('MIAM ▸ 2 délégation(s) · ~6,1k tokens évités · pilotage 1,5k · net ~4,6k (cumul ~35k)')
|
||||
.toBe('🟢 MIAM net ~4,6k (cumul ~35k)🟢')
|
||||
expect(ligne({ delegations: 2, evites: 6100, pilotage: 1500, corrections: 600 }, cumul))
|
||||
.toBe('MIAM ▸ 2 délégation(s) · ~6,1k tokens évités · pilotage 1,5k · corrections 600 · net ~4k (cumul ~35k)')
|
||||
.toBe('🟢 MIAM net ~4k (cumul ~35k)🟢')
|
||||
expect(ligne({ delegations: 1, evites: 900, pilotage: 0, corrections: 0 }, cumul))
|
||||
.toBe('MIAM ▸ 1 délégation(s) · ~900 tokens évités (cumul ~35k)')
|
||||
expect(ligne(VIDE, cumul)).toBe('MIAM ▸ cumul ~35k tokens évités en 9 délégation(s)')
|
||||
.toBe('🟢 MIAM net ~900 (cumul ~35k)🟢')
|
||||
expect(ligne(VIDE, cumul)).toBe('🟢 MIAM cumul ~35k')
|
||||
expect(ligne(VIDE, VIDE)).toBe('')
|
||||
})
|
||||
|
||||
test('rouge si le gain est négatif, vert s’il est positif, rien à zéro', () => {
|
||||
const perte = { delegations: 1, evites: 500, pilotage: 2000, corrections: 0 }
|
||||
expect(ligne(perte, perte)).toBe('🔴 MIAM net ~-1,5k (cumul ~-1,5k)🔴')
|
||||
expect(ligne(VIDE, perte)).toBe('🔴 MIAM cumul ~-1,5k')
|
||||
expect([1, -1, 0].map(pastille)).toEqual(['🟢 ', '🔴 ', ''])
|
||||
})
|
||||
|
||||
test('estimer : tokens_cloud_entree défini, parSubagent false', () => {
|
||||
const consigne = 'c'.repeat(400)
|
||||
const rep = { ok: true, tokens_cloud_entree: 500, tokens_cloud_sortie: 100 }
|
||||
expect(estimer(consigne, 'complet', rep, false)).toEqual({
|
||||
lus: 500,
|
||||
ecrits: 100,
|
||||
consigne: 100,
|
||||
rendus: 0,
|
||||
evites: 500 + 100 - 100
|
||||
})
|
||||
})
|
||||
|
||||
test('estimer : tokens_cloud_entree défini, parSubagent true', () => {
|
||||
const consigne = 'c'.repeat(400)
|
||||
const rep = { ok: true, tokens_cloud_entree: 500, tokens_cloud_sortie: 100 }
|
||||
expect(estimer(consigne, 'complet', rep, true)).toEqual({
|
||||
lus: 500,
|
||||
ecrits: 100,
|
||||
consigne: 100,
|
||||
rendus: 0,
|
||||
evites: 500 + 100
|
||||
})
|
||||
})
|
||||
|
||||
test('repartir : usage réel partagé entre les appels de la requête précédente', () => {
|
||||
const u = { input_tokens: 100, output_tokens: 40, cache_read_input_tokens: 1000, cache_creation_input_tokens: 0 }
|
||||
const suivis = [
|
||||
{ demandeId: 'a', toolUseId: 'u1' }, { demandeId: 'b', toolUseId: 'u2' }, { demandeId: 'a', toolUseId: 'u3' },
|
||||
]
|
||||
expect(repartir(suivis, 4, u)).toEqual([
|
||||
{ demandeId: 'a', toolUseId: 'u1', usage: { input_tokens: 50, output_tokens: 20, cache_read_input_tokens: 500, cache_creation_input_tokens: 0 } },
|
||||
{ demandeId: 'b', toolUseId: 'u2', usage: { input_tokens: 25, output_tokens: 10, cache_read_input_tokens: 250, cache_creation_input_tokens: 0 } },
|
||||
])
|
||||
expect(repartir(suivis.slice(0, 1), 0, u)[0]?.usage).toEqual(u) // compte d'appels inconnu : tout pour le suivi
|
||||
expect(repartir([], 2, u)).toEqual([])
|
||||
})
|
||||
|
||||
test('evenementDelegation : remplit tokens depuis le cloud', () => {
|
||||
const a = { sessionId: 's', retour: undefined, maintenant: 0 }
|
||||
const rep = { ok: true, tokens_cloud_entree: 300, tokens_cloud_sortie: 50 }
|
||||
const est = estimer('c'.repeat(400), 'complet', rep, false)
|
||||
const evt = evenementDelegation(a, { ...rep, demande_id: 'dem-o' }, est)
|
||||
expect(evt.tokens_entree).toBe(300)
|
||||
expect(evt.tokens_sortie).toBe(50)
|
||||
})
|
||||
|
||||
test('lireResultat : un résultat OpenCode est lu, pas une demande en file', () => {
|
||||
expect(lireResultat('{"ok":true,"demande_id":"d","tokens_cloud_entree":10}')?.tokens_cloud_entree).toBe(10)
|
||||
expect(lireResultat('{"ok":null,"demande_id":"d","tokens_cloud_entree":10}')).toBeUndefined()
|
||||
})
|
||||
@@ -5,13 +5,19 @@
|
||||
// modèle local ; la consigne et l'aperçu sont estimés à 4 caractères par token.
|
||||
// Appelé depuis le subagent miam:delegue, la consigne et la lecture de la réponse sont à sa charge : elles comptent
|
||||
// dans le pilotage (sa consommation réelle, mesurée à la fin de son tour), pas ici.
|
||||
// Délégué à OpenCode, le travail est fait par le cloud : les tokens évités sont ceux du cloud moins la consigne (même
|
||||
// méthode que MIAM, core/gains.py). Claude paie en plus les requêtes qui lui servent à suivre la tâche, contexte relu
|
||||
// compris : c'est le pilotage, mesuré sur l'usage réel de ces requêtes (voir `repartir`).
|
||||
|
||||
export type Delegation = {
|
||||
demande_id?: string
|
||||
tokens_entree?: number
|
||||
tokens_sortie?: number
|
||||
tokens_cloud_entree?: number // OpenCode, à la place de tokens_entree et tokens_sortie
|
||||
tokens_cloud_sortie?: number
|
||||
reponse?: string
|
||||
sortie?: string // fichier écrit par MIAM (chemin absolu) : ce que Claude y reprend est une correction
|
||||
diff?: string // diff d'OpenCode (chemin absolu) : sa relecture compte dans le pilotage
|
||||
}
|
||||
|
||||
// consigne et rendus : estimés, déduits seulement pour un appel direct (par le subagent, ils sont dans le pilotage).
|
||||
@@ -35,6 +41,11 @@ const tokens = (texte: string) => Math.ceil(texte.length / 4)
|
||||
|
||||
export function estimer(consigne: string, retour: string | undefined, r: Delegation, parSubagent: boolean): Estimation {
|
||||
const redige = tokens(consigne)
|
||||
if (r.tokens_cloud_entree !== undefined) {
|
||||
const lus = r.tokens_cloud_entree
|
||||
const ecrits = r.tokens_cloud_sortie ?? 0
|
||||
return { lus, ecrits, consigne: redige, rendus: 0, evites: lus + ecrits - (parSubagent ? 0 : redige) }
|
||||
}
|
||||
const lus = Math.max(0, (r.tokens_entree ?? 0) - redige) // fichiers (et consigne de format) lus par le modèle local
|
||||
const ecrits = r.tokens_sortie ?? 0
|
||||
const rendus = retour === 'apercu' ? tokens(r.reponse ?? '') : ecrits
|
||||
@@ -46,6 +57,30 @@ export function estimer(consigne: string, retour: string | undefined, r: Delegat
|
||||
export const cout = (u: Usage) =>
|
||||
u.input_tokens + u.cache_creation_input_tokens + u.output_tokens + Math.round(u.cache_read_input_tokens / 10)
|
||||
|
||||
// Un appel de l'agent principal pour suivre OpenCode (outil de MIAM, relecture du diff).
|
||||
export type Suivi = { demandeId: string; toolUseId: string }
|
||||
|
||||
// Pilotage réel : la requête qui suit des appels de suivi n'existe que pour lire leurs résultats ; son usage (contexte
|
||||
// relu compris) revient à leurs délégations, au prorata quand l'étape précédente a aussi lancé d'autres outils.
|
||||
export function repartir(suivis: readonly Suivi[], appels: number, u: Usage): (Suivi & { usage: Usage })[] {
|
||||
const nombre = Math.max(appels, suivis.length)
|
||||
const parDemande = new Map<string, Suivi[]>()
|
||||
for (const s of suivis) {
|
||||
parDemande.set(s.demandeId, [...(parDemande.get(s.demandeId) ?? []), s])
|
||||
}
|
||||
return [...parDemande.values()].map(liste => {
|
||||
const part = (n: number) => Math.round((n * liste.length) / nombre)
|
||||
return {
|
||||
...liste[0]!,
|
||||
usage: {
|
||||
input_tokens: part(u.input_tokens), output_tokens: part(u.output_tokens),
|
||||
cache_read_input_tokens: part(u.cache_read_input_tokens),
|
||||
cache_creation_input_tokens: part(u.cache_creation_input_tokens),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// `t` peut venir d'une version précédente du mod (cumul gardé sans `corrections`).
|
||||
export const ajouter = (t: Partial<Totaux>, d: Partial<Totaux>): Totaux => ({
|
||||
delegations: (t.delegations ?? 0) + (d.delegations ?? 0),
|
||||
@@ -112,23 +147,25 @@ export function court(n: number): string {
|
||||
return (n < 0 ? '-' : '') + s.replace('.', ',').replace(',0', '')
|
||||
}
|
||||
|
||||
// Signe d'un gain : les notifications et la ligne d'état n'acceptent que du texte.
|
||||
export function pastille(gain: number): string {
|
||||
return gain > 0 ? '🟢 ' : gain < 0 ? '🔴 ' : ''
|
||||
}
|
||||
|
||||
// Gain pour la ligne d'état ; vide tant que rien n'a été délégué.
|
||||
export function ligne(session: Totaux, cumul: Totaux): string {
|
||||
const couts = [
|
||||
session.pilotage ? ` · pilotage ${court(session.pilotage)}` : '',
|
||||
session.corrections ? ` · corrections ${court(session.corrections)}` : '',
|
||||
].join('')
|
||||
const detail = couts ? `${couts} · net ~${court(net(session))}` : ''
|
||||
return session.delegations
|
||||
? `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)`
|
||||
if (session.delegations) {
|
||||
return `${pastille(net(session))}MIAM net ~${court(net(session))} (cumul ~${court(net(cumul))})${pastille(net(cumul)).trimEnd()}`
|
||||
}
|
||||
return cumul.delegations ? `${pastille(net(cumul))}MIAM cumul ~${court(net(cumul))}` : ''
|
||||
}
|
||||
|
||||
// Résultat de `deleguer` tel que Claude Code le rend : le texte JSON de MIAM.
|
||||
export function lireResultat(texte: unknown): Delegation | undefined {
|
||||
try {
|
||||
const r = typeof texte === 'string' ? JSON.parse(texte) : texte
|
||||
return r && typeof r === 'object' && (r as { ok?: unknown }).ok === true && 'tokens_entree' in r
|
||||
return r && typeof r === 'object' && (r as { ok?: unknown }).ok === true &&
|
||||
('tokens_entree' in r || 'tokens_cloud_entree' in r)
|
||||
? (r as Delegation)
|
||||
: undefined
|
||||
} catch {
|
||||
@@ -201,7 +238,8 @@ export function evenementDelegation(
|
||||
schema: SCHEMA, type: 'delegation', id: `${CLIENT}:${r.demande_id}`, date: iso(a.maintenant), client: CLIENT,
|
||||
session_id: a.sessionId, demande_id: r.demande_id, agent_id: a.agentId ?? null,
|
||||
par_subagent: a.agentId !== undefined, retour: a.retour === 'apercu' ? 'apercu' : 'complet',
|
||||
tokens_entree: r.tokens_entree ?? 0, tokens_sortie: r.tokens_sortie ?? 0,
|
||||
tokens_entree: r.tokens_entree ?? r.tokens_cloud_entree ?? 0,
|
||||
tokens_sortie: r.tokens_sortie ?? r.tokens_cloud_sortie ?? 0,
|
||||
estimation: { ...est, methode: 'caracteres/4' },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "modules": ["./register.ts"] }
|
||||
{ "modules": ["./register.tsx"] }
|
||||
@@ -1,115 +0,0 @@
|
||||
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],
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,512 @@
|
||||
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 }))
|
||||
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||
// Chaque période de la minuterie attend que le test la déclenche.
|
||||
on('clock.every', () => new Promise(r => (tic = () => r({ value: undefined }))))
|
||||
on('http.fetch', (_, e) => {
|
||||
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 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 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],
|
||||
])
|
||||
})
|
||||
|
||||
const LIMITES = [{ kind: 'five_hour', percentUsed: 95 }, { kind: 'seven_day', percentUsed: 18 }]
|
||||
|
||||
test('la ligne d’état réunit le gain et l’usage, et suit les nouvelles limites', { options: { jeton: 'x' } }, async ($, on) => {
|
||||
const statuts: (string | undefined)[] = []
|
||||
const store = new Map<string, unknown>([['cumul', { delegations: 1, evites: 500, pilotage: 2000, corrections: 0 }]])
|
||||
on('store.get', (_, e) => ({ value: store.get(e.key) }))
|
||||
on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined }))
|
||||
on('ui.status', (_, e) => (statuts.push(e.text), { value: undefined }))
|
||||
on('clock.now', () => ({ value: 0 }))
|
||||
on('clock.every', () => new Promise(() => {}))
|
||||
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
||||
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: LIMITES } }))
|
||||
on('session.measure', () => ({ changed: [] }))
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
expect(statuts).toEqual(['🔴 MIAM cumul ~-1,5k · 5 h 🟥🟥🟥🟥🟥 95 % · hebdo 🟩⬛⬛⬛⬛ 18 %'])
|
||||
|
||||
await $.session.measure({ rateLimits: [{ kind: 'five_hour', percentUsed: 75 }] } as never)
|
||||
await $.session.measure({ rateLimits: [{ kind: 'five_hour', percentUsed: 75 }] } as never) // même texte : pas d'envoi
|
||||
expect(statuts.slice(1)).toEqual(['🔴 MIAM cumul ~-1,5k · 5 h 🟧🟧🟧🟧⬛ 75 %'])
|
||||
})
|
||||
|
||||
test('un long fichier est refusé à l’agent principal, sauf plage, subagent ou MIAM injoignable',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
let miamOuvert = true
|
||||
let lectures = 0
|
||||
on('clock.now', () => ({ value: 0 }))
|
||||
on('fs.stat', () => ({ value: { kind: 'file', size: 50000, mtimeMs: 0, isLink: false } }))
|
||||
on('http.fetch', () => {
|
||||
if (!miamOuvert) {
|
||||
throw new Error('connexion refusée')
|
||||
}
|
||||
return { value: { status: 405, ok: false, headers: {}, text: '' } }
|
||||
})
|
||||
on('tool.call', () => (lectures += 1, { result: {}, text: 'ok' }))
|
||||
const lire = (extra = {}) => $.tool.call({ tool: 'Read', file_path: 'long.log', ...extra } as never)
|
||||
|
||||
expect((await lire()).deny).toContain('`deleguer`')
|
||||
expect(lectures).toBe(0)
|
||||
expect((await lire({ limit: 400 })).deny).toBeUndefined()
|
||||
expect((await lire({ agentId: 'a1' })).deny).toBeUndefined()
|
||||
miamOuvert = false
|
||||
expect((await lire()).deny).toBeUndefined()
|
||||
expect(lectures).toBe(3)
|
||||
})
|
||||
|
||||
test('une longue sortie shell est enregistrée et retenue, pas une erreur ni une sortie courte',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
const fichiers = new Map<string, string>()
|
||||
let reponse: { result: object; text: string; isError?: true } = { result: {}, text: 'x'.repeat(20000) }
|
||||
on('clock.now', () => ({ value: 0 }))
|
||||
on('fs.write', (_, e) => (fichiers.set(e.path, e.text), { value: undefined }))
|
||||
on('http.fetch', () => ({ value: { status: 405, ok: false, headers: {}, text: '' } }))
|
||||
on('tool.call', () => reponse)
|
||||
const lancer = () => $.tool.call({ tool: 'PowerShell', command: 'pytest', tool_use_id: 'a/b' } as never)
|
||||
|
||||
expect((await lancer()).deny).toContain('.claude/miam-sorties/sortie-a_b.txt')
|
||||
expect([...fichiers].map(([c, t]) => [c.split(/[\\/]/).pop(), t.length])).toEqual([['.gitignore', 2], ['sortie-a_b.txt', 20000]])
|
||||
reponse = { result: {}, text: 'court' }
|
||||
expect((await lancer()).deny).toBeUndefined()
|
||||
reponse = { result: {}, text: 'x'.repeat(20000), isError: true }
|
||||
expect((await lancer()).deny).toBeUndefined()
|
||||
})
|
||||
|
||||
test('imposer=false : rien n’est refusé', { options: { jeton: 'x', imposer: false } }, async ($, on) => {
|
||||
on('clock.now', () => ({ value: 0 }))
|
||||
on('tool.call', () => ({ result: {}, text: 'x'.repeat(20000) }))
|
||||
expect((await $.tool.call({ tool: 'Read', file_path: 'long.log' } as never)).deny).toBeUndefined()
|
||||
expect((await $.tool.call({ tool: 'Bash', command: 'ls' } as never)).deny).toBeUndefined()
|
||||
})
|
||||
|
||||
test('une grosse lecture de l’agent principal vaut un rappel, pas deux de suite, et jamais à un subagent',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
let t = 0
|
||||
on('clock.now', () => ({ value: t }))
|
||||
on('tool.call', () => ({ result: {}, text: 'x'.repeat(20000) }))
|
||||
const lire = (agentId?: string) => $.tool.call({ tool: 'Read', file_path: 'a.log', agentId } as never)
|
||||
|
||||
expect((await lire('a1')).context).toBeUndefined()
|
||||
expect((await lire()).context?.[0]).toContain('`deleguer`')
|
||||
expect((await lire()).context).toBeUndefined()
|
||||
t = 600000
|
||||
expect((await lire()).context).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('la consigne va à l’agent principal seulement', { options: { jeton: 'x' } }, async ($, on) => {
|
||||
on('prompt.compose', () => ({ sections: [] }))
|
||||
const composer = (tools: string[]) =>
|
||||
$.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools, outputStyle: null, traits: [] })
|
||||
expect((await composer(['Agent', 'Read'])).sections.map(s => s.id)).toEqual(['miam:delegation'])
|
||||
expect((await composer(['Read'])).sections).toEqual([])
|
||||
})
|
||||
|
||||
test('incitation=false : ni consigne ni rappel', { options: { jeton: 'x', incitation: false } }, async ($, on) => {
|
||||
on('clock.now', () => ({ value: 0 }))
|
||||
on('prompt.compose', () => ({ sections: [] }))
|
||||
on('tool.call', () => ({ result: {}, text: 'x'.repeat(20000) }))
|
||||
const composition = await $.prompt.compose({
|
||||
model: 'm', promptModel: 'm', surfaces: [], tools: ['Agent'], outputStyle: null, traits: [],
|
||||
})
|
||||
expect(composition.sections).toEqual([])
|
||||
expect((await $.tool.call({ tool: 'Read', file_path: 'a.log' } as never)).context).toBeUndefined()
|
||||
})
|
||||
|
||||
const REJETER = 'mcp__plugin_miam_miam__rejeter'
|
||||
const USAGE = { model: 'opus', input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10000, cache_creation_input_tokens: 0 }
|
||||
const COUT = 100 + 50 + 1000 // lecture en cache au dixième
|
||||
|
||||
// Requêtes du modèle : chacune répond par `outils` appels, `usage` null simule une requête perdue.
|
||||
let reponse: { outils: number; usage: typeof USAGE | null } = { outils: 1, usage: USAGE }
|
||||
const reponseModele = async function* () {
|
||||
return {
|
||||
turnId: 't', index: 0, answer: '', toolUses: Array.from({ length: reponse.outils }, () => ({ name: 'x', input: {} })),
|
||||
stopReason: 'tool_use', usage: reponse.usage,
|
||||
}
|
||||
}
|
||||
const requete = async ($: any, r: typeof reponse = { outils: 1, usage: USAGE }) => {
|
||||
reponse = r
|
||||
for await (const _ of $.turn.step({ turnId: 't', index: 0, model: 'm', messageCount: 1 })) {
|
||||
// le flux doit être lu jusqu'au bout
|
||||
}
|
||||
}
|
||||
|
||||
test('une délégation OpenCode est comptée avec son pilotage',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
const toasts: string[] = []
|
||||
const store = new Map<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) => ({ value: undefined }))
|
||||
on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined }))
|
||||
on('session.id', () => ({ value: 's1' }))
|
||||
on('clock.now', () => ({ value: 0 }))
|
||||
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
||||
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||
on('clock.every', () => new Promise(() => {}))
|
||||
on('turn.step', reponseModele)
|
||||
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
|
||||
on('tool.call', (_, e) =>
|
||||
e.tool === OUTIL
|
||||
? { result: {}, text: '{"ok":null,"demande_id":"dem-o","etat":"en_file"}' }
|
||||
: e.tool === SUIVRE
|
||||
? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o', tokens_cloud_entree: 9000, tokens_cloud_sortie: 1000, resume: 'r', diff: 'E:/x/d.diff' }) }
|
||||
: { result: {}, text: '{}' },
|
||||
)
|
||||
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
await requete($) // écrit l'appel à deleguer : cette requête aurait existé sans délégation
|
||||
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), moteur: 'opencode', tool_use_id: 'u1' } as never)
|
||||
await requete($)
|
||||
await $.tool.call({ tool: SUIVRE, demande_id: 'dem-o', tool_use_id: 'u2' } as never)
|
||||
await requete($, { outils: 0, usage: USAGE })
|
||||
|
||||
expect(toasts.length).toBe(1)
|
||||
expect(toasts[0]).toContain('tokens évités par OpenCode')
|
||||
expect(toasts[0]).toContain('~9,9k')
|
||||
|
||||
const cumul = store.get('cumul') as { delegations: number; evites: number; corrections: number; pilotage: number }
|
||||
expect(cumul.delegations).toBe(1)
|
||||
expect(cumul.evites).toBe(9900)
|
||||
expect(cumul.corrections).toBe(0)
|
||||
expect(cumul.pilotage).toBe(2 * COUT)
|
||||
|
||||
const a_envoyer = store.get('a_envoyer') as { type: string; agent_id?: string; demande_ids?: string[] }[]
|
||||
expect(a_envoyer.length).toBe(3)
|
||||
const pilotages = a_envoyer.filter(ev => ev.type === 'pilotage')
|
||||
expect(pilotages.length).toBe(2)
|
||||
pilotages.forEach(ev => {
|
||||
expect(ev.agent_id).toBe('principal')
|
||||
expect(ev.demande_ids).toEqual(['dem-o'])
|
||||
})
|
||||
})
|
||||
|
||||
test('un diff rejeté retire le gain',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
const toasts: string[] = []
|
||||
const store = new Map<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) => ({ value: undefined }))
|
||||
on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined }))
|
||||
on('session.id', () => ({ value: 's1' }))
|
||||
on('clock.now', () => ({ value: 0 }))
|
||||
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
||||
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||
on('clock.every', () => new Promise(() => {}))
|
||||
on('turn.step', reponseModele)
|
||||
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
|
||||
on('tool.call', (_, e) =>
|
||||
e.tool === OUTIL
|
||||
? { result: {}, text: '{"ok":null,"demande_id":"dem-o","etat":"en_file"}' }
|
||||
: e.tool === SUIVRE
|
||||
? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o', tokens_cloud_entree: 9000, tokens_cloud_sortie: 1000, resume: 'r', diff: 'E:/x/d.diff' }) }
|
||||
: e.tool === REJETER
|
||||
? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o' }) }
|
||||
: { result: {}, text: '{}' },
|
||||
)
|
||||
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), moteur: 'opencode', tool_use_id: 'u1' } as never)
|
||||
await requete($)
|
||||
await $.tool.call({ tool: SUIVRE, demande_id: 'dem-o', tool_use_id: 'u2' } as never)
|
||||
await requete($)
|
||||
await $.tool.call({ tool: REJETER, demande_id: 'dem-o', tool_use_id: 'u3' } as never)
|
||||
await requete($, { outils: 0, usage: USAGE })
|
||||
|
||||
const cumul = store.get('cumul') as { delegations: number; evites: number; pilotage: number }
|
||||
expect(cumul.delegations).toBe(0)
|
||||
expect(cumul.evites).toBe(0)
|
||||
expect(cumul.pilotage).toBe(3 * COUT) // le pilotage d'un diff rejeté reste dû
|
||||
|
||||
expect(toasts.at(-1)).toContain('rejeté')
|
||||
})
|
||||
|
||||
test('une délégation locale reste sans pilotage',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
const toasts: string[] = []
|
||||
const store = new Map<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) => ({ value: undefined }))
|
||||
on('ui.toast', (_, e) => (toasts.push(e.text), { value: undefined }))
|
||||
on('session.id', () => ({ value: 's1' }))
|
||||
on('clock.now', () => ({ value: 0 }))
|
||||
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
||||
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||
on('clock.every', () => new Promise(() => {}))
|
||||
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
|
||||
on('tool.call', (_, e) =>
|
||||
e.tool === OUTIL
|
||||
? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-l', tokens_entree: 200, tokens_sortie: 50 }) }
|
||||
: { result: {}, text: '{}' },
|
||||
)
|
||||
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400), tool_use_id: 'u1' } as never)
|
||||
|
||||
const cumul = store.get('cumul') as { pilotage: number }
|
||||
expect(cumul.pilotage).toBe(0)
|
||||
})
|
||||
|
||||
test('pilotage réel : prorata, relecture du diff, requête perdue, et rien pour une requête sans suivi',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
const store = new Map<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', () => ({ value: undefined }))
|
||||
on('ui.toast', () => ({ value: undefined }))
|
||||
on('session.id', () => ({ value: 's1' }))
|
||||
on('clock.now', () => ({ value: 0 }))
|
||||
on('command.register', () => ({ value: { command: 'miam-economies' } }))
|
||||
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
||||
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||
on('clock.every', () => new Promise(() => {}))
|
||||
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
|
||||
on('turn.step', reponseModele)
|
||||
on('tool.call', (_, e) =>
|
||||
e.tool === OUTIL
|
||||
? { result: {}, text: JSON.stringify({ ok: true, demande_id: 'dem-o', tokens_cloud_entree: 900, tokens_cloud_sortie: 100, diff: 'E:/x/d.diff' }) }
|
||||
: { result: {}, text: 'contenu' },
|
||||
)
|
||||
const pilotage = () => (store.get('cumul') as { pilotage: number }).pilotage
|
||||
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
await requete($, { outils: 2, usage: USAGE }) // deux outils : deleguer et un autre
|
||||
await $.tool.call({ tool: OUTIL, consigne: 'c', moteur: 'opencode', tool_use_id: 'u1' } as never)
|
||||
await requete($, { outils: 1, usage: null }) // perdue : l'imputation attend la suivante
|
||||
expect(pilotage()).toBe(0)
|
||||
await requete($) // moitié de la requête : l'autre outil n'est pas du pilotage
|
||||
expect(pilotage()).toBe(50 + 25 + 500)
|
||||
|
||||
await $.tool.call({ tool: 'Read', file_path: 'E:/x/d.diff', tool_use_id: 'u2' } as never)
|
||||
await requete($)
|
||||
expect(pilotage()).toBe(50 + 25 + 500 + COUT)
|
||||
expect((store.get('cumul') as { corrections: number }).corrections).toBe(0)
|
||||
|
||||
await requete($) // sans appel de suivi : rien
|
||||
expect(pilotage()).toBe(50 + 25 + 500 + COUT)
|
||||
})
|
||||
|
||||
const BASH = 'Bash'
|
||||
const REPRISE_CMD = 'curl -s -H @"C:/logs/mcp/attente.hdr" "http://127.0.0.1:8090/attendre?demande_id=dem-a"'
|
||||
const NOTIF = '<task-notification><task-id>b9</task-id><output-file>C:/Temp/b9.output</output-file>' +
|
||||
'<status>completed</status></task-notification>'
|
||||
|
||||
// Monte un faux moteur pour l'attente unique ; `finale` est la ligne que la commande reprise écrit à sa sortie.
|
||||
function moteurAttente(on: any, finale: Record<string, unknown>) {
|
||||
const vu = { toasts: [] as string[], contextes: [] as (readonly string[] | undefined)[], appels: [] as string[] }
|
||||
const store = new Map<string, unknown>()
|
||||
const lus: string[] = []
|
||||
on('store.get', (_: unknown, e: { key: string }) => ({ value: store.get(e.key) }))
|
||||
on('store.set', (_: unknown, e: { key: string; value: unknown }) => (store.set(e.key, e.value), { value: undefined }))
|
||||
on('ui.status', () => ({ value: undefined }))
|
||||
on('ui.toast', (_: unknown, e: { text: string }) => (vu.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', (_: unknown, e: { cwd: string }) => ({ cwd: e.cwd }))
|
||||
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
|
||||
on('clock.every', () => new Promise(() => {}))
|
||||
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
|
||||
on('fs.stat', () => ({ value: { kind: 'file', size: 100 } }))
|
||||
on('fs.read', (_: unknown, e: { path: string }) => { // chemin tel que le moteur le normalise
|
||||
lus.push(e.path)
|
||||
return { value: /b9\.output$/.test(e.path) ? `${JSON.stringify(finale)}\n` : '' }
|
||||
})
|
||||
on('prompt.submit', (_: unknown, e: { text: string; context?: readonly string[] }) =>
|
||||
(vu.contextes.push(e.context), { text: e.text, context: e.context }))
|
||||
on('tool.call', (_: unknown, e: { tool: string }) => {
|
||||
vu.appels.push(e.tool)
|
||||
return e.tool === OUTIL
|
||||
? { result: {}, text: JSON.stringify({ ok: null, demande_id: 'dem-a', etat: 'en_cours', reprise: REPRISE_CMD }) }
|
||||
: e.tool === BASH
|
||||
? { result: { stdout: '', stderr: '', interrupted: false, backgroundTaskId: 'b9' }, text: 'lancée en arrière-plan' }
|
||||
: { result: {}, text: 'contenu' }
|
||||
})
|
||||
return { vu, store, lus }
|
||||
}
|
||||
|
||||
const reveil = ($: any) => $.prompt.submit({ text: NOTIF, wait: false, origin: { kind: 'task-notification' } })
|
||||
|
||||
test('attente unique : reprise en arrière-plan, ni suivre ni sleep, réveil lu par le plugin, résultat valide non relu',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
const { vu, store, lus } = moteurAttente(on, {
|
||||
ok: true, demande_id: 'dem-a', tokens_entree: 2100, tokens_sortie: 400, verdict: 'valide', essais: 2,
|
||||
controles: [{ nom: 'lint', ok: true }], sortie: 'E:/p/x.py', fin: 'valide',
|
||||
})
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
|
||||
const delegue = await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400) } as never)
|
||||
expect(delegue.context?.join('\n')).toContain('run_in_background: true')
|
||||
expect(delegue.context?.join('\n')).toContain(REPRISE_CMD)
|
||||
|
||||
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD } as never)).deny).toContain('arrière-plan')
|
||||
expect((await $.tool.call({ tool: 'PowerShell', command: REPRISE_CMD, run_in_background: true } as never)).deny)
|
||||
.toContain('Bash')
|
||||
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)).deny)
|
||||
.toBeUndefined()
|
||||
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toContain('réveillé')
|
||||
expect((await $.tool.call({ tool: BASH, command: 'sleep 60' } as never)).deny).toContain('sleep')
|
||||
expect(vu.appels.filter(t => t === SUIVRE)).toEqual([]) // refusé avant d'atteindre MIAM
|
||||
|
||||
await reveil($)
|
||||
expect(lus.some(p => /b9\.output$/.test(p))).toBe(true) // la sortie nommée par la notification
|
||||
const lu = vu.contextes.at(-1)?.join('\n') ?? ''
|
||||
expect(lu).toContain('inutile de')
|
||||
expect(lu).toContain('verdict valide')
|
||||
expect(lu).toContain('E:/p/x.py')
|
||||
expect(vu.toasts.at(-1)).toContain('tokens évités')
|
||||
expect((store.get('cumul') as { delegations: number }).delegations).toBe(1)
|
||||
|
||||
expect((await $.tool.call({ tool: 'Read', file_path: 'E:/p/x.py' } as never)).deny).toContain('verdict valide')
|
||||
expect((await $.tool.call({ tool: 'Read', file_path: 'E:/p/x.py', offset: 1, limit: 40 } as never)).deny)
|
||||
.toBeUndefined()
|
||||
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined() // fini
|
||||
expect((await $.tool.call({ tool: BASH, command: 'sleep 1' } as never)).deny).toBeUndefined()
|
||||
|
||||
const bilan = await $.command.run({ command: 'miam-economies', args: '' } as never)
|
||||
expect((bilan as { text: string }).text).toContain('1 attente(s) unique(s), 2 essai(s), 0 échec(s)')
|
||||
})
|
||||
|
||||
test('attente unique : besoin_claude rouvre suivre, echec_controle compte sans gain, un sous-agent n’est pas freiné',
|
||||
{ options: { jeton: 'x' } }, async ($, on) => {
|
||||
const finale: Record<string, unknown> = { ok: null, demande_id: 'dem-a', fin: 'besoin_claude' }
|
||||
const { vu, store } = moteurAttente(on, finale)
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
await $.tool.call({ tool: OUTIL, consigne: 'c' } as never)
|
||||
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
|
||||
expect((await $.tool.call({ tool: BASH, command: 'sleep 5', agentId: 'a1' } as never)).deny).toBeUndefined()
|
||||
|
||||
await reveil($)
|
||||
expect(vu.contextes.at(-1)?.join('\n')).toContain(REPRISE_CMD) // relancer après avoir répondu
|
||||
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined()
|
||||
|
||||
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
|
||||
Object.assign(finale, { ok: false, fin: 'echec_controle', erreur: 'echec_controle : symboles : champ x inventé' })
|
||||
await reveil($)
|
||||
expect(vu.contextes.at(-1)?.join('\n')).toContain('symboles : champ x inventé')
|
||||
expect(store.get('cumul')).toBeUndefined() // aucun gain compté
|
||||
const bilan = await $.command.run({ command: 'miam-economies', args: '' } as never)
|
||||
expect((bilan as { text: string }).text).toContain('2 attente(s) unique(s), 0 essai(s), 1 échec(s)')
|
||||
})
|
||||
|
||||
test('imposer=false : l’attente est suivie mais rien n’est refusé', { options: { jeton: 'x', imposer: false } },
|
||||
async ($, on) => {
|
||||
moteurAttente(on, {})
|
||||
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
|
||||
await $.tool.call({ tool: OUTIL, consigne: 'c' } as never)
|
||||
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD } as never)).deny).toBeUndefined()
|
||||
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
|
||||
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined()
|
||||
})
|
||||
@@ -1,188 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
|
||||
|
||||
import {
|
||||
aGarder, ajouter, cheminNormal, cible, court, cout, estimer, evenementCorrection, evenementDelegation,
|
||||
evenementPilotage, ligne, lireResultat, lot, mesurerCorrection, net, pastille, repartir, retirer, VIDE,
|
||||
} from './economies'
|
||||
import type { Evenement, Suivi, Totaux } from './economies'
|
||||
import {
|
||||
conduite, consigneReprise, demandeAttendue, estSommeil, fichierSortie, finDe, lectureEntiere, lireFin,
|
||||
REFUS_LECTURE_VALIDE, REFUS_POWERSHELL, REFUS_PREMIER_PLAN, REFUS_SOMMEIL, REFUS_SUIVI, sensible, SENSIBLES,
|
||||
} from './attente'
|
||||
import type { Fin } from './attente'
|
||||
import { lectureTropLongue, nomSortie, PLAGE_MAX, sortieTropLongue } from './contrainte'
|
||||
import { fenetres, ligneFenetre } from './usage'
|
||||
|
||||
// Outils de MIAM : serveur du plugin (mcp__plugin_miam_miam__…) ou serveur de projet nommé miam (mcp__miam__…).
|
||||
const OUTIL = /^mcp__(?:plugin_miam_)?miam__(deleguer|suivre|autoriser|appliquer|rejeter|annuler)$/
|
||||
const PRINCIPAL = 'principal' // agent_id du pilotage d'OpenCode par l'agent principal
|
||||
// Outils par lesquels l'agent principal relit ou retouche un fichier écrit par une délégation.
|
||||
const REPRISE = /^(Read|Edit|Write|NotebookEdit|Bash|PowerShell)$/
|
||||
const SHELL = /^(Bash|PowerShell)$/ // sorties retenues quand elles sont longues
|
||||
const CUMUL = 'cumul'
|
||||
const ATTENTE = 'a_envoyer' // événements que MIAM n'a pas encore reçus (fermé, ancienne version sans la route…)
|
||||
const PERIODE_MS = 5000 // envoi à MIAM hors des hooks : une délégation n'attend jamais MIAM
|
||||
const LECTURE = /^(Read|Bash|PowerShell)$/ // lectures de l'agent principal qu'un rappel peut viser
|
||||
const SEUIL_LECTURE = 20000 // caractères, soit environ 5k tokens
|
||||
const DOSSIER_SORTIES = '.claude/miam-sorties'
|
||||
const PAUSE_RAPPEL_MS = 600000 // au plus un rappel toutes les 10 minutes
|
||||
const BARRE = 5 // largeur des barres d'usage de la ligne d'état
|
||||
|
||||
// Lu par le modèle à chaque requête de l'agent principal : court, et seulement les cas où déléguer est le bon réflexe.
|
||||
const CONSIGNE = {
|
||||
id: 'miam:delegation',
|
||||
scope: 'session',
|
||||
text: `# Délégation à MIAM
|
||||
Un modèle local (MIAM) fait sans consommer l'usage Claude les tâches autonomes et vérifiables. Par réflexe, avant de les faire toi-même, appelle toi-même l'outil MCP \`deleguer\` de MIAM (sans subagent, qui coûte plus cher), sans demander : tirer peu d'un long log, d'une longue sortie de tests ou d'un long document ; traduction, résumé, notes de version, docstrings, message de commit ; fonction pure et ses tests, tests répétitifs. Ne lis pas le fichier avant : MIAM le lit. Fais-le toi-même seulement pour l'architecture, le débogage multi-fichiers, la concurrence, la sécurité, ce qui demande le contexte de la conversation, les secrets, ou si MIAM est injoignable.`,
|
||||
} as const
|
||||
const RAPPEL = "Cette sortie est longue. Si tu n'en as besoin que d'une petite partie, la prochaine fois confie-la à " +
|
||||
"l'outil MCP `deleguer` de MIAM (il lit les fichiers sans consommer l'usage Claude) au lieu de la lire toi-même."
|
||||
|
||||
const REFUS_LECTURE = `Lecture refusée par le plugin MIAM : ce fichier est long. Appelle l'outil MCP \`deleguer\` de MIAM avec ce chemin et ce que tu cherches, ou lis seulement la partie utile (\`offset\` et \`limit\` de ${PLAGE_MAX} lignes au plus). Ne découpe pas le fichier pour le lire en entier.`
|
||||
const refusSortie = (chemin: string) => `Sortie longue retenue par le plugin MIAM, enregistrée dans ${chemin}. La commande a bien tourné : ne la relance pas pour lire sa sortie. Appelle l'outil MCP \`deleguer\` de MIAM avec ce fichier et ce que tu cherches, ou lis seulement la partie utile avec \`offset\` et \`limit\`.`
|
||||
|
||||
type Demande = { consigne: string; retour?: string; agentId?: string }
|
||||
type Miam = { url: string; jeton: string }
|
||||
type Ecran = { session: Totaux; limites: readonly SessionRateLimit[]; affichee?: string } // `affichee` : dernier texte
|
||||
|
||||
// Une seule ligne d'état : le gain, puis l'usage Claude.
|
||||
async function rafraichir($: EngineInterface, ecran: Ecran): Promise<void> {
|
||||
const cumul = ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, {})
|
||||
const usage = fenetres(ecran.limites, await $.clock.now()).map(f => ligneFenetre(f, BARRE))
|
||||
const texte = [ligne(ecran.session, cumul), ...usage].filter(Boolean).join(' · ') || undefined
|
||||
if (texte !== ecran.affichee) {
|
||||
ecran.affichee = texte
|
||||
$.ui.status(texte)
|
||||
}
|
||||
}
|
||||
|
||||
// Ajoute à la session et au cumul (gardé d'une session à l'autre), puis met à jour la ligne d'état.
|
||||
async function afficher($: EngineInterface, ecran: Ecran, ajout: Partial<Totaux>): Promise<void> {
|
||||
ecran.session = ajouter(ecran.session, ajout)
|
||||
await $.store.set(CUMUL, ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, ajout))
|
||||
await rafraichir($, ecran)
|
||||
}
|
||||
|
||||
// Vrai si MIAM répond, quel que soit le statut : sans lui, rien n'est bloqué.
|
||||
async function joignable($: EngineInterface, miam: Miam): Promise<boolean> {
|
||||
try {
|
||||
await $.http.fetch(miam.url, { method: 'GET' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Enregistre une sortie pour que MIAM la lise ; renvoie son chemin, ou rien si l'écriture échoue.
|
||||
async function garderSortie($: EngineInterface, dossier: string, nom: string, texte: string): Promise<string | undefined> {
|
||||
try {
|
||||
await $.fs.write(`${dossier}/.gitignore`, '*\n')
|
||||
await $.fs.write(`${dossier}/${nom}`, texte)
|
||||
return `${dossier}/${nom}`
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function mettreEnAttente($: EngineInterface, nouveaux: Evenement[]): Promise<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// État des délégations de la session, partagé par les hooks et les fonctions ci-dessous.
|
||||
type Delegations = {
|
||||
ecran: Ecran
|
||||
enFile: Map<string, Demande>
|
||||
comptees: Set<string>
|
||||
subagents: Map<string, string[]>
|
||||
sorties: Map<string, string>
|
||||
opencode: Map<string, { pilotage: number; evites: number }>
|
||||
attentes: Map<string, 'reprise' | 'attendue' | 'besoin_claude'>
|
||||
commandesReprise: Map<string, string>
|
||||
finies: Set<string>
|
||||
valides: Set<string>
|
||||
mesure: { attentes: number; essais: number; echecs: number }
|
||||
}
|
||||
|
||||
// État final d'une délégation (réponse directe, ou sortie de /attendre au réveil) : gain, compteurs, et la conduite
|
||||
// à tenir, que Claude lit à la place de la réponse. Rien quand MIAM n'a rien contrôlé et que rien n'a été attendu.
|
||||
async function terminer($: EngineInterface, d: Delegations, r: Record<string, unknown>, demande: Demande | undefined):
|
||||
Promise<string | undefined> {
|
||||
const id = typeof r.demande_id === 'string' ? r.demande_id : undefined
|
||||
const fin = (typeof r.fin === 'string' ? r.fin : finDe(r)) as Fin | undefined
|
||||
if (id && fin === 'besoin_claude') {
|
||||
d.attentes.set(id, 'besoin_claude')
|
||||
} else if (id && fin) {
|
||||
d.attentes.delete(id)
|
||||
if (!d.finies.has(id)) {
|
||||
d.finies.add(id)
|
||||
d.mesure.echecs += fin === 'echec_controle' ? 1 : 0
|
||||
d.mesure.essais += typeof r.essais === 'number' ? r.essais : 0
|
||||
}
|
||||
}
|
||||
if (fin === 'valide' && typeof r.sortie === 'string') {
|
||||
d.valides.add(cheminNormal(r.sortie))
|
||||
}
|
||||
if (demande) {
|
||||
await compter($, d, r, demande)
|
||||
}
|
||||
return fin ? conduite({ ...r, fin }, id ? d.commandesReprise.get(id) : undefined) : undefined
|
||||
}
|
||||
|
||||
// Gain d'une délégation finie, compté une fois (suivre peut renvoyer deux fois un résultat).
|
||||
async function compter($: EngineInterface, d: Delegations, texte: unknown, demande: Demande): Promise<void> {
|
||||
const r = lireResultat(texte)
|
||||
if (!r?.demande_id || d.comptees.has(r.demande_id)) {
|
||||
return
|
||||
}
|
||||
d.comptees.add(r.demande_id)
|
||||
d.enFile.delete(r.demande_id)
|
||||
const fichier = r.sortie ?? r.diff
|
||||
if (fichier) {
|
||||
d.sorties.set(cheminNormal(fichier), r.demande_id)
|
||||
}
|
||||
if (demande.agentId) {
|
||||
d.subagents.set(demande.agentId, [...(d.subagents.get(demande.agentId) ?? []), r.demande_id])
|
||||
}
|
||||
const est = estimer(demande.consigne, demande.retour, r, demande.agentId !== undefined)
|
||||
await afficher($, d.ecran, { delegations: 1, evites: est.evites })
|
||||
const pilote = d.opencode.get(r.demande_id)
|
||||
if (pilote) {
|
||||
pilote.evites = est.evites
|
||||
$.ui.toast(`${pastille(net(d.ecran.session))}MIAM : ~${court(est.evites)} tokens évités par OpenCode ` +
|
||||
`(${court(est.lus)} lus, ${court(est.ecrits)} écrits, pilotage ${court(pilote.pilotage)})`)
|
||||
} else {
|
||||
$.ui.toast(`${pastille(net(d.ecran.session))}MIAM : ~${court(est.evites)} tokens évités (${court(est.lus)} lus, ` +
|
||||
`${court(est.ecrits)} écrits)`)
|
||||
}
|
||||
const contexte = { sessionId: await $.session.id(), agentId: demande.agentId, retour: demande.retour }
|
||||
await mettreEnAttente($, [evenementDelegation({ ...contexte, maintenant: await $.clock.now() },
|
||||
{ ...r, demande_id: r.demande_id }, est)])
|
||||
}
|
||||
|
||||
|
||||
export const register: Register = (on, options) => {
|
||||
const miam: Miam = { url: `http://127.0.0.1:${options.port ?? 8090}/economies`, jeton: String(options.jeton ?? '') }
|
||||
const ecran: Ecran = { session: VIDE, limites: [] }
|
||||
const enFile = new Map<string, Demande>() // demande_id → délégation renvoyée « en file », comptée à son résultat
|
||||
const comptees = new Set<string>() // demande_id déjà comptées (suivre peut renvoyer deux fois un résultat)
|
||||
const subagents = new Map<string, string[]>() // boucles qui ont délégué → leurs demande_id ; leur tour = pilotage
|
||||
const sorties = new Map<string, string>() // fichier écrit par une délégation (chemin normalisé) → demande_id
|
||||
const opencode = new Map<string, { pilotage: number; evites: number }>() // délégations à OpenCode de cette session
|
||||
let suivis: Suivi[] = [] // appels de suivi d'OpenCode à imputer à la prochaine requête
|
||||
let appels = 0 // outils lancés par la dernière requête de l'agent principal
|
||||
// Attente unique : demande → « reprise » reçue, « attendue » (curl en arrière-plan) ou « besoin_claude ».
|
||||
const attentes = new Map<string, 'reprise' | 'attendue' | 'besoin_claude'>()
|
||||
const commandesReprise = new Map<string, string>() // demande_id → sa commande `reprise`
|
||||
const taches = new Map<string, string>() // id de la commande d'arrière-plan → demande_id attendue
|
||||
const finies = new Set<string>() // demande_id dont l'état final est déjà pris en compte
|
||||
const valides = new Set<string>() // fichiers `sortie` au verdict valide (chemin normalisé)
|
||||
const mesure = { attentes: 0, essais: 0, echecs: 0 } // pour la mesure de la v0.9.0, par session
|
||||
const d: Delegations = {
|
||||
ecran, enFile, comptees, subagents, sorties, opencode, attentes, commandesReprise, finies, valides, mesure,
|
||||
}
|
||||
let reprises = 0 // id de secours d'une correction sans tool_use_id
|
||||
let envoiEnCours = false
|
||||
let dernierRappel = -Infinity // date du dernier rappel envoyé au modèle
|
||||
const incitation = options.incitation !== false
|
||||
const imposer = options.imposer !== false
|
||||
const motifsSensibles = typeof options.chemins_sensibles === 'string'
|
||||
? options.chemins_sensibles.split(/[,;]/).map(m => m.trim()).filter(Boolean)
|
||||
: SENSIBLES
|
||||
let dossierSorties = DOSSIER_SORTIES
|
||||
|
||||
on('session.start', async ($, e, next) => {
|
||||
dossierSorties = `${e.cwd.replace(/[\\/]+$/, '')}/${DOSSIER_SORTIES}`
|
||||
ecran.limites = (await $.session.usage()).rateLimits
|
||||
await rafraichir($, ecran)
|
||||
await $.command.register({
|
||||
name: 'miam-economies',
|
||||
description: 'Tokens évités grâce à MIAM : session et cumul',
|
||||
argumentHint: '[raz]',
|
||||
})
|
||||
$.clock.every(PERIODE_MS, () => {
|
||||
void rafraichir($, ecran) // le temps restant avance même sans échange
|
||||
if (!envoiEnCours) {
|
||||
envoiEnCours = true
|
||||
void envoyer($, miam).finally(() => (envoiEnCours = false))
|
||||
}
|
||||
})
|
||||
return next(e)
|
||||
})
|
||||
|
||||
on('command.run', { command: 'miam-economies' }, async ($, e) => {
|
||||
if (e.args.trim() === 'raz') {
|
||||
await $.store.set(CUMUL, VIDE)
|
||||
ecran.session = VIDE
|
||||
await rafraichir($, ecran)
|
||||
return { text: 'Compteurs MIAM remis à zéro (les envois à MIAM ne sont pas touchés).' }
|
||||
}
|
||||
const cumul = ajouter(((await $.store.get(CUMUL)) as Partial<Totaux> | undefined) ?? {}, {})
|
||||
const attente = ((await $.store.get(ATTENTE)) as Evenement[] | undefined) ?? []
|
||||
return {
|
||||
text: [
|
||||
`Session : ${ecran.session.delegations} délégation(s), ~${court(ecran.session.evites)} tokens évités, pilotage ` +
|
||||
`${court(ecran.session.pilotage)}, corrections ${court(ecran.session.corrections)}, net ~${court(net(ecran.session))}.`,
|
||||
`Cumul : ${cumul.delegations} délégation(s), ~${court(cumul.evites)} tokens évités, pilotage ` +
|
||||
`${court(cumul.pilotage)}, corrections ${court(cumul.corrections)}, net ~${court(net(cumul))}.`,
|
||||
attente.length ? `${attente.length} événement(s) en attente d'envoi à MIAM.` : 'Tout est envoyé à MIAM.',
|
||||
`Contrôles de la session : ${mesure.attentes} attente(s) unique(s), ${mesure.essais} essai(s), ` +
|
||||
`${mesure.echecs} échec(s) de contrôle.`,
|
||||
'Estimation : fichiers lus + réponse écrite par le modèle local, moins la consigne et ce que Claude relit ; ' +
|
||||
'OpenCode : tokens du cloud moins la consigne. Pilotage = consommation du subagent (lecture en cache comptée ' +
|
||||
'au dixième) et, pour OpenCode, usage réel des requêtes de suivi, contexte relu compris ; corrections = ce que ' +
|
||||
"l'agent principal relit ou retouche dans les fichiers écrits par MIAM.",
|
||||
].join('\n'),
|
||||
}
|
||||
})
|
||||
|
||||
// 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) {
|
||||
// Attente unique : la commande `reprise` part une fois en arrière-plan, et rien ne guette la fin à sa place.
|
||||
if (e.agentId === undefined && SHELL.test(e.tool)) {
|
||||
const shell = e as unknown as { command?: unknown; run_in_background?: unknown }
|
||||
const commande = String(shell.command ?? '')
|
||||
const attendue = demandeAttendue(commande)
|
||||
if (attendue) {
|
||||
if (imposer && e.tool !== 'Bash') {
|
||||
return { deny: REFUS_POWERSHELL }
|
||||
}
|
||||
if (imposer && shell.run_in_background !== true) {
|
||||
return { deny: REFUS_PREMIER_PLAN }
|
||||
}
|
||||
const ran = await next(e)
|
||||
if (ran.deny === undefined && !ran.isError) {
|
||||
attentes.set(attendue, 'attendue')
|
||||
mesure.attentes += 1
|
||||
const tache = (ran.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId
|
||||
if (typeof tache === 'string') {
|
||||
taches.set(tache, attendue)
|
||||
}
|
||||
}
|
||||
return ran
|
||||
}
|
||||
if (imposer && estSommeil(commande) && [...attentes.values()].includes('attendue')) {
|
||||
return { deny: REFUS_SOMMEIL }
|
||||
}
|
||||
}
|
||||
// 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 (e.agentId !== undefined || !REPRISE.test(e.tool)) {
|
||||
return next(e)
|
||||
}
|
||||
const args = e as unknown as Record<string, unknown>
|
||||
const demandeId = sorties.size ? cible(e.tool, args, sorties) : undefined
|
||||
// Un résultat au verdict valide ne se relit pas en entier, sauf sur un chemin sensible.
|
||||
const chemin = String(args.file_path ?? '')
|
||||
if (imposer && e.tool === 'Read' && valides.has(cheminNormal(chemin)) && lectureEntiere(args) &&
|
||||
!sensible(chemin, motifsSensibles)) {
|
||||
return { deny: REFUS_LECTURE_VALIDE }
|
||||
}
|
||||
// Un long fichier ne se lit pas en entier, sauf s'il vient de MIAM : le refus part avant la lecture.
|
||||
if (imposer && e.tool === 'Read' && !demandeId) {
|
||||
const stat = await $.fs.stat(String(args.file_path), { resolve: false }).catch(() => undefined)
|
||||
if (stat?.kind === 'file' && lectureTropLongue(args, stat.size) && await joignable($, miam)) {
|
||||
return { deny: REFUS_LECTURE }
|
||||
}
|
||||
}
|
||||
const ran = await next(e)
|
||||
if (ran.deny !== undefined) {
|
||||
return ran
|
||||
}
|
||||
if (!demandeId) {
|
||||
// Une longue sortie est enregistrée puis retenue : le modèle ne la lit pas. Une commande en échec garde la sienne.
|
||||
if (imposer && SHELL.test(e.tool) && !ran.isError && sortieTropLongue(ran.text) && await joignable($, miam)) {
|
||||
const chemin = await garderSortie($, dossierSorties, nomSortie(e.tool_use_id ?? `reprise-${await $.clock.now()}`),
|
||||
ran.text ?? '')
|
||||
if (chemin) {
|
||||
return { deny: refusSortie(chemin) }
|
||||
}
|
||||
}
|
||||
// Une grosse lecture qu'une délégation aurait pu éviter : rappel au modèle, invisible pour l'utilisateur.
|
||||
const maintenant = await $.clock.now()
|
||||
if (!incitation || !LECTURE.test(e.tool) || ran.isError || (ran.text?.length ?? 0) < SEUIL_LECTURE ||
|
||||
maintenant - dernierRappel < PAUSE_RAPPEL_MS) {
|
||||
return ran
|
||||
}
|
||||
dernierRappel = maintenant
|
||||
return { ...ran, context: [...(ran.context ?? []), RAPPEL] }
|
||||
}
|
||||
if (opencode.has(demandeId)) {
|
||||
suivis.push({ demandeId, toolUseId: e.tool_use_id ?? `reprise-${++reprises}` }) // relecture du diff : pilotage
|
||||
return ran
|
||||
}
|
||||
const correction = mesurerCorrection(e.tool, args, typeof ran.text === 'string' ? ran.text : undefined)
|
||||
if (!correction.lus && !correction.ecrits) {
|
||||
return ran
|
||||
}
|
||||
await afficher($, ecran, { corrections: correction.lus + correction.ecrits })
|
||||
reprises += 1
|
||||
await mettreEnAttente($, [evenementCorrection({
|
||||
sessionId: await $.session.id(), demandeId, outil: e.tool, toolUseId: e.tool_use_id ?? `reprise-${reprises}`,
|
||||
maintenant: await $.clock.now(),
|
||||
}, correction)])
|
||||
return ran
|
||||
}
|
||||
// Délégation attendue en arrière-plan : le réveil viendra seul. Une décision demandée (besoin_claude) reste ouverte.
|
||||
const demandeSuivie = (e as unknown as { demande_id?: string }).demande_id ?? ''
|
||||
if (imposer && outil === 'suivre' && e.agentId === undefined && attentes.get(demandeSuivie) === 'attendue') {
|
||||
return { deny: REFUS_SUIVI }
|
||||
}
|
||||
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; moteur?: 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 id = args.demande_id ?? brut?.demande_id
|
||||
if (outil === 'deleguer' && args.moteur === 'opencode' && id) {
|
||||
opencode.set(id, { pilotage: 0, evites: 0 })
|
||||
}
|
||||
const suivi = id ? opencode.get(id) : undefined
|
||||
// Pilotage d'OpenCode par l'agent principal, compté à la requête qui lira ce résultat (turn.step) ; celui d'un
|
||||
// subagent se mesure à la fin de son tour.
|
||||
if (id && suivi && e.agentId === undefined) {
|
||||
suivis.push({ demandeId: id, toolUseId: e.tool_use_id ?? `suivi-${++reprises}` })
|
||||
}
|
||||
// Un diff rejeté n'évite rien (MIAM fait de même dans son bilan).
|
||||
if (outil === 'rejeter' && brut?.ok === true && suivi?.evites) {
|
||||
await afficher($, ecran, { delegations: -1, evites: -suivi.evites })
|
||||
$.ui.toast(`MIAM : diff OpenCode rejeté, ~${court(suivi.evites)} tokens retirés du gain`)
|
||||
suivi.evites = 0
|
||||
}
|
||||
if (outil !== 'deleguer' && outil !== 'suivre') {
|
||||
return ran
|
||||
}
|
||||
const demande: Demande | undefined =
|
||||
outil === 'deleguer'
|
||||
? { consigne: args.consigne ?? '', retour: args.retour, agentId: e.agentId }
|
||||
: 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é au résultat (suivre, ou réveil de l'attente)
|
||||
const reprise = (brut as { reprise?: unknown }).reprise
|
||||
if (typeof reprise === 'string' && e.agentId === undefined) {
|
||||
attentes.set(brut.demande_id, 'reprise')
|
||||
commandesReprise.set(brut.demande_id, reprise)
|
||||
return { ...ran, context: [...(ran.context ?? []), consigneReprise(reprise)] }
|
||||
}
|
||||
return ran
|
||||
}
|
||||
const conseil = await terminer($, d, texte as Record<string, unknown>, demande)
|
||||
return conseil && e.agentId === undefined ? { ...ran, context: [...(ran.context ?? []), conseil] } : ran
|
||||
})
|
||||
|
||||
// Réveil : la commande `reprise` est finie. Le plugin lit sa sortie (une ligne JSON avec `fin`) et donne à Claude le
|
||||
// résultat et la conduite, pour qu'il n'ait ni à ouvrir cette sortie ni à appeler `suivre`.
|
||||
on('prompt.submit', async ($, e, next) => {
|
||||
// Reconnue à l'id de la commande d'arrière-plan relevé au lancement, ou à son enveloppe qui cite la route.
|
||||
if (!attentes.size) {
|
||||
return next(e)
|
||||
}
|
||||
const tache = [...taches].find(([t]) => e.text.includes(t))
|
||||
const id = tache?.[1] ?? (e.text.includes('<task-notification') ? demandeAttendue(e.text) : undefined)
|
||||
if (!id || attentes.get(id) !== 'attendue') {
|
||||
return next(e)
|
||||
}
|
||||
if (tache) {
|
||||
taches.delete(tache[0])
|
||||
}
|
||||
const chemin = fichierSortie(e.text)
|
||||
const final = chemin ? lireFin(await $.fs.read(chemin).catch(() => '')) : undefined
|
||||
if (!final) {
|
||||
attentes.delete(id) // sortie illisible : Claude la lit lui-même, et `suivre` redevient possible
|
||||
return next(e)
|
||||
}
|
||||
const conseil = await terminer($, d, { demande_id: id, ...final }, enFile.get(id))
|
||||
const lu = `Sortie de l'attente lue par le plugin MIAM, inutile de l'ouvrir. ${conseil}`
|
||||
return next({ ...e, context: [...(e.context ?? []), lu] })
|
||||
})
|
||||
|
||||
on('turn.step', async function* ($, e, next) {
|
||||
if (e.agentId !== undefined) {
|
||||
return yield* next(e)
|
||||
}
|
||||
const aImputer = suivis
|
||||
suivis = []
|
||||
const r = yield* next(e)
|
||||
if (!r.usage) {
|
||||
suivis = [...aImputer, ...suivis] // requête perdue : la suivante relira ces résultats
|
||||
return r
|
||||
}
|
||||
const nombre = appels
|
||||
appels = r.toolUses.length
|
||||
for (const part of repartir(aImputer, nombre, r.usage)) {
|
||||
const ev = evenementPilotage({
|
||||
sessionId: await $.session.id(), agentId: PRINCIPAL, turnId: part.toolUseId, maintenant: await $.clock.now(),
|
||||
demandeIds: [part.demandeId],
|
||||
}, { ...part.usage, model: r.usage.model })
|
||||
const pilote = opencode.get(part.demandeId)
|
||||
if (pilote) {
|
||||
pilote.pilotage += ev.cout
|
||||
}
|
||||
await afficher($, ecran, { pilotage: ev.cout })
|
||||
await mettreEnAttente($, [ev])
|
||||
}
|
||||
return r
|
||||
})
|
||||
|
||||
on('turn.complete', async ($, e, next) => {
|
||||
const demandeIds = e.agentId ? subagents.get(e.agentId) : undefined
|
||||
if (e.agentId && e.usage && demandeIds) {
|
||||
subagents.delete(e.agentId)
|
||||
await afficher($, ecran, { pilotage: cout(e.usage) })
|
||||
$.ui.toast(`${pastille(net(ecran.session))}MIAM : net ~${court(net(ecran.session))} sur la session ` +
|
||||
`(pilotage ${court(ecran.session.pilotage)})`)
|
||||
await mettreEnAttente($, [evenementPilotage({
|
||||
sessionId: await $.session.id(), agentId: e.agentId, turnId: e.turnId, maintenant: await $.clock.now(),
|
||||
demandeIds,
|
||||
}, e.usage)])
|
||||
}
|
||||
return next(e)
|
||||
})
|
||||
|
||||
on('prompt.compose', async ($, e, next) => {
|
||||
const composition = await next(e)
|
||||
// L'outil Agent n'est offert qu'à l'agent principal : pas de consigne pour les subagents.
|
||||
return incitation && e.tools.includes('Agent')
|
||||
? { ...composition, sections: [...composition.sections, CONSIGNE] }
|
||||
: composition
|
||||
})
|
||||
|
||||
on('session.measure', async ($, e, next) => {
|
||||
ecran.limites = e.rateLimits
|
||||
await rafraichir($, ecran)
|
||||
return next(e)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from 'claude-code/testing'
|
||||
|
||||
import { barre, duree, fenetres, ligneFenetre } from './usage'
|
||||
|
||||
const T0 = Date.parse('2026-10-31T12:00:00Z')
|
||||
|
||||
test('durée avant la remise à zéro', () => {
|
||||
expect(duree(12 * 60000)).toBe('12 min')
|
||||
expect(duree(130 * 60000)).toBe('2 h 10')
|
||||
expect(duree(76 * 3600000)).toBe('3 j 4 h')
|
||||
})
|
||||
|
||||
test('fenêtres 5 h puis hebdo, sans les autres ni une remise à zéro passée', () => {
|
||||
expect(fenetres([
|
||||
{ kind: 'seven_day', percentUsed: 18, resetsAt: '2026-10-31T11:00:00Z' },
|
||||
{ kind: 'spend_limit', percentUsed: 3 },
|
||||
{ kind: 'five_hour', percentUsed: 42.5, resetsAt: '2026-10-31T14:10:00Z' },
|
||||
], T0)).toEqual([{ nom: '5 h', pourcent: 42.5, reste: '2 h 10' }, { nom: 'hebdo', pourcent: 18 }])
|
||||
expect(fenetres([], T0)).toEqual([])
|
||||
})
|
||||
|
||||
test('la barre reste dans sa largeur, même hors de 0 à 100', () => {
|
||||
expect(barre(50, 10)).toBe('🟩🟩🟩🟩🟩⬛⬛⬛⬛⬛')
|
||||
expect(barre(130, 4)).toBe('🟥🟥🟥🟥')
|
||||
expect(barre(-5, 4)).toBe('⬛⬛⬛⬛')
|
||||
})
|
||||
|
||||
test('couleur selon le remplissage', () => {
|
||||
expect([69.9, 70, 89.9, 90].map(p => barre(p, 1))).toEqual(['🟩', '🟧', '🟧', '🟥'])
|
||||
})
|
||||
|
||||
test('ligne d’une fenêtre', () => {
|
||||
expect(ligneFenetre({ nom: '5 h', pourcent: 42.5, reste: '2 h 10' }, 4)).toBe('5 h 🟩🟩⬛⬛ 43 % (2 h 10)')
|
||||
expect(ligneFenetre({ nom: 'hebdo', pourcent: 18 }, 4)).toBe('hebdo 🟩⬛⬛⬛ 18 %')
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
// Usage de Claude sur 5 h et sur la semaine, tel que l'affiche la ligne d'état (calcul pur, testé).
|
||||
|
||||
import type { SessionRateLimit } from 'claude-code'
|
||||
|
||||
export type Fenetre = { nom: string; pourcent: number; reste?: string }
|
||||
|
||||
const NOMS: readonly (readonly [string, string])[] = [['five_hour', '5 h'], ['seven_day', 'hebdo']]
|
||||
const ALERTE = 70 // % à partir duquel la barre passe en orange
|
||||
const CRITIQUE = 90 // puis en rouge
|
||||
|
||||
export function duree(ms: number): string {
|
||||
const minutes = Math.ceil(ms / 60000)
|
||||
const heures = Math.floor(minutes / 60)
|
||||
const jours = Math.floor(heures / 24)
|
||||
if (jours) {
|
||||
return `${jours} j ${heures % 24} h`
|
||||
}
|
||||
return heures ? `${heures} h ${String(minutes % 60).padStart(2, '0')}` : `${minutes} min`
|
||||
}
|
||||
|
||||
// Fenêtres connues, dans l'ordre d'affichage ; `reste` : temps avant la remise à zéro, absent si elle est passée.
|
||||
export function fenetres(limites: readonly SessionRateLimit[], maintenant: number): Fenetre[] {
|
||||
return NOMS.flatMap(([kind, nom]) => {
|
||||
const limite = limites.find(l => l.kind === kind)
|
||||
if (!limite) {
|
||||
return []
|
||||
}
|
||||
const ms = limite.resetsAt ? Date.parse(limite.resetsAt) - maintenant : NaN
|
||||
return [{ nom, pourcent: limite.percentUsed, ...(ms > 0 ? { reste: duree(ms) } : {}) }]
|
||||
})
|
||||
}
|
||||
|
||||
// Barre en carrés emoji : la ligne d'état n'accepte que du texte, mais eux gardent leur couleur.
|
||||
export function barre(pourcent: number, largeur: number): string {
|
||||
const pleins = Math.round((Math.min(100, Math.max(0, pourcent)) / 100) * largeur)
|
||||
const plein = pourcent >= CRITIQUE ? '🟥' : pourcent >= ALERTE ? '🟧' : '🟩'
|
||||
return plein.repeat(pleins) + '⬛'.repeat(largeur - pleins)
|
||||
}
|
||||
|
||||
export function ligneFenetre(f: Fenetre, largeur: number): string {
|
||||
const reste = f.reste ? ` (${f.reste})` : ''
|
||||
return `${f.nom} ${barre(f.pourcent, largeur)} ${Math.round(f.pourcent)} %${reste}`
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
name: deleguer
|
||||
description: Quand et comment confier une tâche au modèle local de MIAM pour économiser des tokens. À consulter avant de lire un long log, une longue sortie pytest/ruff ou un long document pour en tirer peu, et avant d'écrire une traduction, des docstrings, des notes de version, un message de commit, une fonction pure et ses tests, ou des tests répétitifs.
|
||||
description: À utiliser par réflexe, sans attendre qu'on le demande, pour confier une tâche au modèle local de MIAM et économiser l'usage Claude. À consulter avant de lire un long log, une longue sortie pytest/ruff ou un long document pour en tirer peu, et avant d'écrire une traduction, des docstrings, des notes de version, un message de commit, une fonction pure et ses tests, ou des tests répétitifs. À consulter aussi pour confier à OpenCode, agent cloud, une tâche de code autonome et vérifiable qui touche plusieurs fichiers d'un projet autorisé.
|
||||
---
|
||||
|
||||
# Déléguer à MIAM
|
||||
|
||||
MIAM fait tourner un modèle local (llama.cpp, carte graphique du poste). Lui confier les tâches autonomes et
|
||||
vérifiables économise les tokens de Claude.
|
||||
vérifiables économise les tokens de Claude. C'est le réflexe par défaut pour ce qui figure sous « Oui » : délègue sans
|
||||
demander, et ne le fais toi-même que si MIAM est injoignable.
|
||||
|
||||
## Quoi déléguer
|
||||
|
||||
@@ -20,24 +21,82 @@ 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.
|
||||
Appelle toi-même l'outil MCP `deleguer` du serveur `plugin:miam:miam`, chargé au besoin par ToolSearch. Pas de subagent : son
|
||||
pilotage coûte plus de tokens que la délégation n'en économise. Le modèle local ne voit que la consigne et les fichiers.
|
||||
|
||||
Son brief doit se suffire à lui-même :
|
||||
- la tâche, précise (signature, langue, format exact, cas limites) ;
|
||||
- les fichiers en **chemins absolus** (MIAM les lit : ne les lis pas toi-même avant) ;
|
||||
- le format (`texte`, `code`, `json`) et le fichier de `sortie` éventuel (dire si on peut l'écraser) ;
|
||||
- comment vérifier (tests à lancer, clés JSON attendues) ;
|
||||
- s'il peut remplacer le modèle déjà chargé (par défaut : non).
|
||||
- `type` : `code` (fonction, tests, script), `texte` (traduction, résumé court, notes de version, docstrings, commit),
|
||||
`rapide` (lire beaucoup pour rendre peu). MIAM choisit le modèle, `seuil` vaut 0,8 par défaut ;
|
||||
- `consigne` : autonome et précise (signature, langue, format exact, cas limites) ;
|
||||
- `fichiers` : **chemins absolus**, `chemin:debut-fin` pour une partie. MIAM les lit : ne les lis pas avant ;
|
||||
- `format` : `json` pour des faits extraits, `code` pour du code, sinon `texte` ;
|
||||
- réponse longue : `sortie` + `retour="apercu"`, pour ne pas la relire ;
|
||||
- `reflexion` : `off` par défaut, `bas`/`moyen`/`haut` si la tâche demande du raisonnement ;
|
||||
- `controle` : contrôles de MIAM parmi `syntaxe`, `complet`, `symboles`, `docstrings`, `citations`, `traduction`, `lint`
|
||||
(`[]` pour aucun). Par défaut, du code avec une `sortie` `.py` passe `syntaxe`, `complet`, `symboles`, `docstrings` et
|
||||
`lint` ; le reste passe `complet` ;
|
||||
- `attendus` : noms qui doivent figurer dans la réponse (fonctions, classes), vérifiés par `complet` ;
|
||||
- `symboles` et `citations` comparent la réponse aux `fichiers` : joins les modules que le code utilise.
|
||||
|
||||
Plusieurs tâches indépendantes : plusieurs subagents, MIAM les met en file (une délégation à la fois sur le GPU).
|
||||
MIAM retente seul en cas d'échec (2 essais, puis l'API pour une demande locale) et n'écrit la `sortie` qu'après un
|
||||
verdict `valide`.
|
||||
|
||||
**Réponse « en file »** (`ok: null` avec `reprise`) : lance la commande `reprise` **une seule fois**, telle quelle, avec
|
||||
Bash et `run_in_background: true`, puis termine ton tour ou fais autre chose. Tu es réveillé à la fin, et le plugin te
|
||||
donne alors le résultat : inutile d'ouvrir la sortie de la commande. Ni `suivre` ni `sleep` d'ici là (le plugin les
|
||||
refuse). Au réveil, selon `fin` :
|
||||
|
||||
| `fin` | Conduite |
|
||||
|---|---|
|
||||
| `valide` | contrôles passés : intègre sans relire le corps (aperçu ou `offset`/`limit`), ruff et tests du projet ensuite |
|
||||
| `termine` | pas de contrôle automatique : vérifie avant d'intégrer |
|
||||
| `echec_controle` | reprends la main, ou redécoupe en bouts plus petits avec le motif dans la consigne |
|
||||
| `besoin_claude` | `suivre` pour voir la décision attendue, réponds (`autoriser`), puis relance `reprise` en arrière-plan |
|
||||
| `annule` | rien |
|
||||
| `perdue` | MIAM redémarré ou injoignable : renvoie la demande ou fais-la toi-même |
|
||||
|
||||
Sans `reprise` (ancien MIAM) : `suivre(demande_id, attente_s=45)` jusqu'au résultat. `annuler(demande_id)` abandonne.
|
||||
|
||||
Refus de `deleguer` : `aucun preset testé` → `demarrer(preset="${user_config.preset_defaut}")`, `suivre`, puis le même
|
||||
`deleguer` ; contexte dépassé → `modeles`, puis `preset` = le mieux noté dont le `contexte` suffit, sans `offload` si
|
||||
possible ; autre refus → le dire à l'utilisateur.
|
||||
|
||||
## OpenCode (`moteur="opencode"`)
|
||||
|
||||
Agent cloud qui lit, modifie et teste dans un clone du projet. Le code part chez un fournisseur : c'est toujours un
|
||||
choix explicite, jamais le moteur par défaut.
|
||||
|
||||
**Quand** : une tâche de code bornée que le modèle local ne peut pas faire seul parce qu'elle demande d'explorer le
|
||||
projet, de toucher plusieurs fichiers ou de lancer les tests (refactorisation mécanique, correction localisée, tests à
|
||||
compléter), avec un résultat vérifiable, dans un projet autorisé par la config de MIAM. **Pas** : ce qu'un simple
|
||||
`deleguer` local suffit à faire, la décision d'architecture, la sécurité, le code qui touche à des secrets. Projet
|
||||
refusé : le dire à l'utilisateur, ne pas contourner.
|
||||
|
||||
Le clone part de l'état commité : les modifications en cours en sont absentes. Commite-les ou signale-le avant.
|
||||
|
||||
1. `deleguer(moteur="opencode", racine=<projet>, consigne=…)`, `modele` facultatif. Réponse « en file » : c'est normal,
|
||||
la tâche dure ;
|
||||
2. `suivre(demande_id, attente_s=45)` jusqu'au résultat, sans appels rapprochés : chaque appel coûte des tokens. `phase`
|
||||
et `dernier_outil` montrent l'avancement ;
|
||||
3. `etat: "permission"` : réponds vite, sans réponse à temps c'est un refus. Pour chaque entrée de `permissions` (`action`,
|
||||
`ressources`, `expire_dans_s`), `autoriser(demande_id, permission_id, reponse)` : `oui` si l'action sert la tâche et
|
||||
reste dans le clone, `toujours` pour la même action répétée (vaut pour cette délégation seulement), `non` sinon :
|
||||
accès hors du clone, réseau, suppression, secrets. Un doute sérieux : `non`, et dis-le à l'utilisateur ;
|
||||
4. résultat : `resume`, `fichiers`, `tests`, `diff` (chemin). Lis `fichiers` et `tests`, puis le diff (par `offset` et
|
||||
`limit` s'il est long). Il n'est pas dans la réponse pour économiser tes tokens ;
|
||||
5. **décide toujours** : `appliquer(demande_id)` s'il fait ce qui était demandé, sinon `rejeter(demande_id)`. Rien n'arrive
|
||||
dans l'arbre de travail avant `appliquer`, et un clone non tranché reste sur le disque. `appliquer` refusé (arbre
|
||||
changé entre-temps) : la réponse contient l'erreur, adapte à la main ou rejette ;
|
||||
6. après `appliquer`, lance ruff et les tests du projet.
|
||||
|
||||
`annuler(demande_id)` interrompt la tâche. Quota atteint : pas de bascule automatique, dis-le à l'utilisateur et choisis.
|
||||
Ce suivi est du pilotage, déduit du gain ; un diff rejeté n'évite rien.
|
||||
|
||||
## Ensuite
|
||||
|
||||
Relis le compte rendu : un résultat « raté » ou « partiel », tu le reprends toi-même. Code produit : relu, `ruff check`,
|
||||
`ruff format --check` et tests verts avant de le garder.
|
||||
Vérifie sans tout relire : `json_valide` et clés pour du JSON ; ruff et tests pour du code ; `tronque` vrai = réponse
|
||||
coupée. Un échec : une seule nouvelle délégation avec une consigne corrigée, puis reprends la tâche toi-même.
|
||||
|
||||
MIAM injoignable (le compte rendu le dit) : le signaler à l'utilisateur (lancer MIAM et son serveur MCP, puis `/mcp` →
|
||||
Reconnect sur `plugin:miam:miam`) et faire la tâche toi-même si elle est urgente.
|
||||
MIAM injoignable (outil absent, connexion refusée) : le signaler à l'utilisateur (lancer MIAM et son serveur MCP, puis
|
||||
`/mcp` → Reconnect sur `plugin:miam:miam`) et faire la tâche toi-même si elle est urgente.
|
||||
|
||||
Bilan des délégations : outil `bilan` de MIAM. Meilleur preset par catégorie : `/miam:banc`.
|
||||
Bilan des délégations : outil `bilan` de MIAM. Notes et caractéristiques des presets : outil `modeles`.
|
||||
Reference in new issue
Block a user