feat: /claude et /mammouth one-shot API, fix admins persistance et work_hours

- Commandes /claude-apikey, /claude-models, /claude-model, /claude
- Commandes /mammouth-apikey, /mammouth-models, /mammouth-model, /mammouth
- Clés et modèles persistés dans config.json (apis.claude / apis.mammouth)
- B11: _save_admins_to_config() persiste admin_jids dans config.json
- B12: delegate.py vérifie work_hours avant délégation
- README mis à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 19:09:07 +00:00
parent b46b0726b9
commit e668fe694f
5 changed files with 237 additions and 5 deletions
+23 -4
View File
@@ -28,7 +28,7 @@ systemctl enable --now nexus
| Skill | Description | | Skill | Description |
|-------|-------------| |-------|-------------|
| `delegate` | Délègue une tâche à un agent via MQTT | | `delegate` | Délègue une tâche à un agent via MQTT (vérifie les `work_hours` de l'agent cible) |
| `agents_status` | Liste les agents en ligne/hors ligne | | `agents_status` | Liste les agents en ligne/hors ligne |
| `memory` | Mémoire clé/valeur SQLite persistante | | `memory` | Mémoire clé/valeur SQLite persistante |
| `script` | Bibliothèque de scripts bash (save/list/show/edit/exec/run/delete) | | `script` | Bibliothèque de scripts bash (save/list/show/edit/exec/run/delete) |
@@ -80,8 +80,8 @@ Fréquences : daily HH:MM | once HH:MM | every Xh | every Xmin | weekly <jour> H
### Administration ### Administration
``` ```
/admins — Lister les JIDs autorisés /admins — Lister les JIDs autorisés
/admins add <jid> — Autoriser un utilisateur /admins add <jid> — Autoriser un utilisateur (persistant)
/admins remove <jid> — Retirer un utilisateur /admins remove <jid> — Retirer un utilisateur (persistant)
/update <agent> — Demande git pull + restart à un agent /update <agent> — Demande git pull + restart à un agent
/update all — Met à jour tous les agents /update all — Met à jour tous les agents
/report [agent] — Rapport quotidien /report [agent] — Rapport quotidien
@@ -89,6 +89,21 @@ Fréquences : daily HH:MM | once HH:MM | every Xh | every Xmin | weekly <jour> H
/sleep / /wake — Mettre Nexus en veille / réveiller /sleep / /wake — Mettre Nexus en veille / réveiller
``` ```
### APIs externes (one-shot)
```
/claude-apikey <clé> — Enregistrer la clé API Anthropic
/claude-models — Lister les modèles Claude disponibles
/claude-model <modèle> — Définir le modèle par défaut (ex: claude-opus-4-6)
/claude <prompt> — Appel one-shot à l'API Anthropic
/mammouth-apikey <clé> — Enregistrer la clé API Mammouth
/mammouth-models — Lister les modèles Mammouth disponibles
/mammouth-model <modèle> — Définir le modèle par défaut (ex: gpt-4.1)
/mammouth <prompt> — Appel one-shot à l'API Mammouth
```
Les clés et modèles sont persistés dans `config.json` sous `apis.claude` et `apis.mammouth`. Mammouth est compatible OpenAI API (`https://api.mammouth.ai/v1`).
### Routing direct ### Routing direct
``` ```
@debian.local apt update — Commande directe sans passer par le LLM @debian.local apt update — Commande directe sans passer par le LLM
@@ -131,7 +146,11 @@ Nexus envoie une notification XMPP à chaque exécution de script sur n'importe
"llm_coordinator": { "max_concurrent": 1 }, "llm_coordinator": { "max_concurrent": 1 },
"use_omemo": true, "use_omemo": true,
"use_llm_coordinator": true, "use_llm_coordinator": true,
"system_prompt": "/opt/nexus/config/system_prompt.txt" "system_prompt": "/opt/nexus/config/system_prompt.txt",
"apis": {
"claude": { "key": "sk-ant-...", "model": "claude-opus-4-6" },
"mammouth": { "key": "...", "model": "gpt-4.1" }
}
} }
``` ```
+18
View File
@@ -25,4 +25,22 @@ SKILL:delegate ARGS:<agent_id> | <instruction complète>
- Sois concis et précis - Sois concis et précis
- Si une tâche implique plusieurs agents, explique le plan avant d'exécuter - Si une tâche implique plusieurs agents, explique le plan avant d'exécuter
## Écriture de scripts bash — règles strictes
### ❌ Interdit dans les scripts bash
- `muc_send`, `mqtt_send`, `shell` et tous les noms de skills — ce ne sont PAS des commandes bash
- Les guillemets échappés : écris `"texte"` et non `\"texte\"`
### ✅ Pour envoyer un message depuis un script
Variables injectées automatiquement : `$MQTT_BROKER`, `$MQTT_REPLY_TOPIC`, `$AGENT_ID`
```bash
mosquitto_pub -h "$MQTT_BROKER" -t "$MQTT_REPLY_TOPIC" -m "mon résultat"
```
### ✅ Bonnes pratiques
- Commence toujours par `#!/bin/bash` et `set -euo pipefail`
- Guillemets doubles autour des variables : `"$VAR"`
- Gère les cas d'erreur avec des messages explicites
--- ---
+173
View File
@@ -342,6 +342,30 @@ class Nexus(BaseAgent):
if cmd == "admins": if cmd == "admins":
return self._handle_admins_command(args) return self._handle_admins_command(args)
if cmd == "claude-apikey":
return self._set_api_key("claude", args.strip())
if cmd == "claude-models":
return self._list_models("claude")
if cmd == "claude-model":
return self._set_default_model("claude", args.strip())
if cmd == "claude":
return self._call_api("claude", args.strip())
if cmd == "mammouth-apikey":
return self._set_api_key("mammouth", args.strip())
if cmd == "mammouth-models":
return self._list_models("mammouth")
if cmd == "mammouth-model":
return self._set_default_model("mammouth", args.strip())
if cmd == "mammouth":
return self._call_api("mammouth", args.strip())
if cmd == "help": if cmd == "help":
return self._nexus_help() return self._nexus_help()
@@ -611,6 +635,20 @@ class Nexus(BaseAgent):
except Exception as e: except Exception as e:
return f"Erreur Ollama : {e}" return f"Erreur Ollama : {e}"
def _save_admins_to_config(self):
"""Persiste la liste des admins dans config.json."""
import json
try:
with open(self.DEFAULT_CONFIG_PATH, "r") as f:
cfg = json.load(f)
cfg.setdefault("xmpp", {})["admin_jids"] = sorted(self.xmpp.admin_jids)
# Supprime l'ancienne clé singulière si présente
cfg["xmpp"].pop("admin_jid", None)
with open(self.DEFAULT_CONFIG_PATH, "w") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"Impossible de sauvegarder les admins : {e}")
def _handle_admins_command(self, args: str) -> str: def _handle_admins_command(self, args: str) -> str:
""" """
/admins → liste les admins autorisés /admins → liste les admins autorisés
@@ -638,12 +676,14 @@ class Nexus(BaseAgent):
if not jid: if not jid:
return "Usage : /admins add <jid>" return "Usage : /admins add <jid>"
self.xmpp.add_admin(jid) self.xmpp.add_admin(jid)
self._save_admins_to_config()
return f"Admin ajouté : {jid}" return f"Admin ajouté : {jid}"
if sub == "remove": if sub == "remove":
if not jid: if not jid:
return "Usage : /admins remove <jid>" return "Usage : /admins remove <jid>"
self.xmpp.remove_admin(jid) self.xmpp.remove_admin(jid)
self._save_admins_to_config()
return f"Admin retiré : {jid}" return f"Admin retiré : {jid}"
return "Usage : /admins | /admins add <jid> | /admins remove <jid>" return "Usage : /admins | /admins add <jid> | /admins remove <jid>"
@@ -684,6 +724,131 @@ class Nexus(BaseAgent):
except (StopIteration, IndexError, ValueError) as e: except (StopIteration, IndexError, ValueError) as e:
return f"Format invalide : {e}\nUsage : /schedule daily 03:00 @agent tâche" return f"Format invalide : {e}\nUsage : /schedule daily 03:00 @agent tâche"
# ──────────────────────────────────────────────
# APIs externes : Claude / Mammouth
# ──────────────────────────────────────────────
def _set_api_key(self, service: str, key: str) -> str:
if not key:
return f"Usage : /{service}-apikey <clé>"
try:
import json
with open(self.DEFAULT_CONFIG_PATH) as f:
cfg = json.load(f)
cfg.setdefault("apis", {}).setdefault(service, {})["key"] = key
with open(self.DEFAULT_CONFIG_PATH, "w") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
return f"✅ Clé API {service} enregistrée."
except Exception as e:
return f"❌ Erreur : {e}"
def _set_default_model(self, service: str, model: str) -> str:
if not model:
return f"Usage : /{service}-model <nom_du_modèle>"
try:
import json
with open(self.DEFAULT_CONFIG_PATH) as f:
cfg = json.load(f)
cfg.setdefault("apis", {}).setdefault(service, {})["model"] = model
with open(self.DEFAULT_CONFIG_PATH, "w") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
return f"✅ Modèle par défaut {service} : {model}"
except Exception as e:
return f"❌ Erreur : {e}"
def _get_api_config(self, service: str) -> tuple[str, str]:
"""Retourne (api_key, model) depuis config.json."""
apis = self.config.get("apis", {})
svc = apis.get(service, {})
key = svc.get("key", "")
defaults = {
"claude": "claude-opus-4-6",
"mammouth": "gpt-4.1",
}
model = svc.get("model", defaults.get(service, ""))
return key, model
def _list_models(self, service: str) -> str:
import requests
key, _ = self._get_api_config(service)
if not key:
return f"❌ Clé API {service} non configurée. Utilise /{service}-apikey <clé>"
try:
if service == "claude":
r = requests.get(
"https://api.anthropic.com/v1/models",
headers={"x-api-key": key, "anthropic-version": "2023-06-01"},
timeout=15,
)
r.raise_for_status()
models = [m["id"] for m in r.json().get("data", [])]
else: # mammouth
r = requests.get(
"https://api.mammouth.ai/v1/models",
headers={"Authorization": f"Bearer {key}"},
timeout=15,
)
r.raise_for_status()
models = [m["id"] for m in r.json().get("data", [])]
if not models:
return f"Aucun modèle retourné pour {service}."
_, current = self._get_api_config(service)
lines = [f"Modèles disponibles ({service}) :"]
for m in models:
marker = " ◀ défaut" if m == current else ""
lines.append(f"{m}{marker}")
lines.append(f"\nPour changer : /{service}-model <nom>")
return "\n".join(lines)
except Exception as e:
return f"❌ Erreur listing modèles {service} : {e}"
def _call_api(self, service: str, prompt: str) -> str:
import requests
if not prompt:
return f"Usage : /{service} <prompt>"
key, model = self._get_api_config(service)
if not key:
return f"❌ Clé API {service} non configurée. Utilise /{service}-apikey <clé>"
try:
if service == "claude":
r = requests.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": model,
"max_tokens": 1024,
"messages": [{"role": "user", "content": prompt}],
},
timeout=60,
)
r.raise_for_status()
return r.json()["content"][0]["text"]
else: # mammouth (OpenAI-compatible)
r = requests.post(
"https://api.mammouth.ai/v1/chat/completions",
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
},
timeout=60,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
except Exception as e:
return f"❌ Erreur {service} : {e}"
def _nexus_help(self) -> str: def _nexus_help(self) -> str:
base = help_text() base = help_text()
nexus_extra = """ nexus_extra = """
@@ -713,6 +878,14 @@ class Nexus(BaseAgent):
/resume [agent] — Reprendre /resume [agent] — Reprendre
/reset — Effacer l'historique LLM /reset — Effacer l'historique LLM
/status — Statut de Nexus /status — Statut de Nexus
/claude-apikey <clé> — Enregistrer la clé API Anthropic
/claude-models — Lister les modèles Claude disponibles
/claude-model <modèle> — Définir le modèle Claude par défaut
/claude <prompt> — Appel one-shot Claude (API Anthropic)
/mammouth-apikey <clé> — Enregistrer la clé API Mammouth
/mammouth-models — Lister les modèles Mammouth disponibles
/mammouth-model <modèle> — Définir le modèle Mammouth par défaut
/mammouth <prompt> — Appel one-shot Mammouth
Mode @agent : Mode @agent :
@debian-prod apt update — Commande directe @debian-prod apt update — Commande directe
+22
View File
@@ -6,6 +6,7 @@ Usage LLM : SKILL:delegate ARGS:<agent_id> | <tâche>
""" """
import threading import threading
import uuid import uuid
from datetime import datetime
DESCRIPTION = "Déléguer une tâche à un agent spécialisé et retourner son résultat" DESCRIPTION = "Déléguer une tâche à un agent spécialisé et retourner son résultat"
USAGE = "SKILL:delegate ARGS:<agent_id> | <tâche>" USAGE = "SKILL:delegate ARGS:<agent_id> | <tâche>"
@@ -13,6 +14,21 @@ USAGE = "SKILL:delegate ARGS:<agent_id> | <tâche>"
TIMEOUT = 120 # secondes max d'attente de la réponse TIMEOUT = 120 # secondes max d'attente de la réponse
def _is_within_work_hours(work_hours: str) -> bool:
"""Vérifie si l'heure actuelle est dans la plage HH:MM-HH:MM."""
try:
start_str, end_str = work_hours.strip().split("-")
now = datetime.now().time()
start = datetime.strptime(start_str.strip(), "%H:%M").time()
end = datetime.strptime(end_str.strip(), "%H:%M").time()
if start <= end:
return start <= now <= end
# Plage qui chevauche minuit (ex: 22:00-06:00)
return now >= start or now <= end
except Exception:
return True # En cas de format invalide, on laisse passer
def run(args: str, context) -> str: def run(args: str, context) -> str:
if "|" not in args: if "|" not in args:
return "Format invalide. Usage : SKILL:delegate ARGS:<agent_id> | <tâche>" return "Format invalide. Usage : SKILL:delegate ARGS:<agent_id> | <tâche>"
@@ -27,6 +43,12 @@ def run(args: str, context) -> str:
known = [a.agent_id for a in context.registry.all_agents()] known = [a.agent_id for a in context.registry.all_agents()]
return f"Agent '{agent_id}' inconnu. Agents connus : {', '.join(known)}" return f"Agent '{agent_id}' inconnu. Agents connus : {', '.join(known)}"
# Vérifier les horaires de travail
work_hours = getattr(caps, "work_hours", "00:00-23:59")
if not _is_within_work_hours(work_hours):
now_str = datetime.now().strftime("%H:%M")
return f"⏰ Agent '{agent_id}' hors horaires ({work_hours}). Heure actuelle : {now_str}."
# Préparer la réception de la réponse # Préparer la réception de la réponse
corr_id = str(uuid.uuid4()) corr_id = str(uuid.uuid4())
reply_topic = context.mqtt.topic_results(corr_id) reply_topic = context.mqtt.topic_results(corr_id)
+1 -1
View File
@@ -147,7 +147,7 @@ def run(args: str, context) -> str:
return "Format : save <nom> | <contenu du script>" return "Format : save <nom> | <contenu du script>"
name_raw, content = rest.split("|", 1) name_raw, content = rest.split("|", 1)
name = _safe_name(name_raw) name = _safe_name(name_raw)
content = content.strip().replace("\\n", "\n") content = content.strip().replace("\\n", "\n").replace('\\"', '"').replace("\\'", "'")
if not name: if not name:
return "Nom de script invalide." return "Nom de script invalide."