diff --git a/README.md b/README.md index 3a22741..4df3972 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ systemctl enable --now nexus | 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 | | `memory` | Mémoire clé/valeur SQLite persistante | | `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 H ### Administration ``` /admins — Lister les JIDs autorisés -/admins add — Autoriser un utilisateur -/admins remove — Retirer un utilisateur +/admins add — Autoriser un utilisateur (persistant) +/admins remove — Retirer un utilisateur (persistant) /update — Demande git pull + restart à un agent /update all — Met à jour tous les agents /report [agent] — Rapport quotidien @@ -89,6 +89,21 @@ Fréquences : daily HH:MM | once HH:MM | every Xh | every Xmin | weekly H /sleep / /wake — Mettre Nexus en veille / réveiller ``` +### APIs externes (one-shot) +``` +/claude-apikey — Enregistrer la clé API Anthropic +/claude-models — Lister les modèles Claude disponibles +/claude-model — Définir le modèle par défaut (ex: claude-opus-4-6) +/claude — Appel one-shot à l'API Anthropic + +/mammouth-apikey — Enregistrer la clé API Mammouth +/mammouth-models — Lister les modèles Mammouth disponibles +/mammouth-model — Définir le modèle par défaut (ex: gpt-4.1) +/mammouth — 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 ``` @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 }, "use_omemo": 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" } + } } ``` diff --git a/config/system_prompt.txt b/config/system_prompt.txt index 83eba5e..5f45944 100644 --- a/config/system_prompt.txt +++ b/config/system_prompt.txt @@ -25,4 +25,22 @@ SKILL:delegate ARGS: | - Sois concis et précis - 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 + --- diff --git a/nexus.py b/nexus.py index 196596d..64d6120 100644 --- a/nexus.py +++ b/nexus.py @@ -342,6 +342,30 @@ class Nexus(BaseAgent): if cmd == "admins": 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": return self._nexus_help() @@ -611,6 +635,20 @@ class Nexus(BaseAgent): except Exception as 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: """ /admins → liste les admins autorisés @@ -638,12 +676,14 @@ class Nexus(BaseAgent): if not jid: return "Usage : /admins add " self.xmpp.add_admin(jid) + self._save_admins_to_config() return f"Admin ajouté : {jid}" if sub == "remove": if not jid: return "Usage : /admins remove " self.xmpp.remove_admin(jid) + self._save_admins_to_config() return f"Admin retiré : {jid}" return "Usage : /admins | /admins add | /admins remove " @@ -684,6 +724,131 @@ class Nexus(BaseAgent): except (StopIteration, IndexError, ValueError) as e: 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 " + 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 " + 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 " + + 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 ") + 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} " + + key, model = self._get_api_config(service) + if not key: + return f"❌ Clé API {service} non configurée. Utilise /{service}-apikey " + + 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: base = help_text() nexus_extra = """ @@ -713,6 +878,14 @@ class Nexus(BaseAgent): /resume [agent] — Reprendre /reset — Effacer l'historique LLM /status — Statut de Nexus + /claude-apikey — Enregistrer la clé API Anthropic + /claude-models — Lister les modèles Claude disponibles + /claude-model — Définir le modèle Claude par défaut + /claude — Appel one-shot Claude (API Anthropic) + /mammouth-apikey — Enregistrer la clé API Mammouth + /mammouth-models — Lister les modèles Mammouth disponibles + /mammouth-model — Définir le modèle Mammouth par défaut + /mammouth — Appel one-shot Mammouth Mode @agent : @debian-prod apt update — Commande directe diff --git a/skills/delegate.py b/skills/delegate.py index f45133f..ab660a5 100644 --- a/skills/delegate.py +++ b/skills/delegate.py @@ -6,6 +6,7 @@ Usage LLM : SKILL:delegate ARGS: | """ import threading import uuid +from datetime import datetime DESCRIPTION = "Déléguer une tâche à un agent spécialisé et retourner son résultat" USAGE = "SKILL:delegate ARGS: | " @@ -13,6 +14,21 @@ USAGE = "SKILL:delegate ARGS: | " 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: if "|" not in args: return "Format invalide. Usage : SKILL:delegate ARGS: | " @@ -27,6 +43,12 @@ def run(args: str, context) -> str: known = [a.agent_id for a in context.registry.all_agents()] 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 corr_id = str(uuid.uuid4()) reply_topic = context.mqtt.topic_results(corr_id) diff --git a/skills/script.py b/skills/script.py index 112307a..ce58ee8 100644 --- a/skills/script.py +++ b/skills/script.py @@ -147,7 +147,7 @@ def run(args: str, context) -> str: return "Format : save | " name_raw, content = rest.split("|", 1) name = _safe_name(name_raw) - content = content.strip().replace("\\n", "\n") + content = content.strip().replace("\\n", "\n").replace('\\"', '"').replace("\\'", "'") if not name: return "Nom de script invalide."