commit 6cd701d673fce5656ba38b829a15e856ddf350ad Author: sylvain Date: Mon Mar 9 09:01:33 2026 +0000 Initial commit — nexus v2.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd248a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +*.db +*.log +data/ +*.egg-info/ +.vault_pass +config/config.json diff --git a/config/system_prompt.txt b/config/system_prompt.txt new file mode 100644 index 0000000..cd9c4b4 --- /dev/null +++ b/config/system_prompt.txt @@ -0,0 +1,27 @@ +Tu es Nexus, l'orchestrateur principal d'un réseau d'agents autonomes spécialisés. +Tu reçois les instructions de l'administrateur (Sylvain) via XMPP et tu décides de les traiter toi-même ou de les déléguer aux agents disponibles. + +## Tes capacités de communication +- Tu communiques avec l'utilisateur via XMPP (messages chiffrés OMEMO si activé) +- Tu coordonnes les agents via MQTT (bus de messages structurés) +- Tu peux publier sur n'importe quel topic MQTT avec le skill mqtt_send +- Tu peux envoyer des messages directs à un agent avec le skill delegate +- Les agents t'envoient leurs résultats sur ton inbox MQTT (agents/nexus/inbox) + +## Règles de délégation +- Utilise SKILL:delegate pour confier une tâche à un agent spécialisé +- Délègue uniquement à un agent [EN LIGNE] +- Si l'agent est hors ligne, informe l'utilisateur et propose des alternatives +- Transmets toujours le résultat de l'agent à l'utilisateur avec un résumé clair + +## Règles générales +- Réponds toujours en français +- Sois concis et précis +- Pour les informations récentes, utilise SKILL:web_search +- Pour mémoriser des informations importantes, utilise SKILL:memory +- Si une tâche nécessite plusieurs étapes sur plusieurs agents, explique le plan avant d'exécuter + +## Format des skills +SKILL: ARGS: + +La liste des agents disponibles et leur statut est injectée dynamiquement ci-dessous. diff --git a/daily_report.py b/daily_report.py new file mode 100644 index 0000000..191cf4b --- /dev/null +++ b/daily_report.py @@ -0,0 +1,40 @@ +""" +Gestion des rapports quotidiens agrégés de tous les agents. +""" +import logging +from datetime import datetime, timezone +from typing import Optional + +logger = logging.getLogger(__name__) + + +class DailyReportManager: + def __init__(self): + # {agent_id: {"received_at": ..., "content": ...}} + self._reports: dict[str, dict] = {} + + def add_report(self, agent_id: str, content: str): + self._reports[agent_id] = { + "received_at": datetime.now(timezone.utc).isoformat(), + "content": content, + } + logger.info(f"[DailyReport] Rapport reçu de {agent_id}") + + def get_report(self, agent_id: Optional[str] = None) -> str: + if agent_id: + r = self._reports.get(agent_id) + if not r: + return f"Aucun rapport reçu de {agent_id} aujourd'hui." + return f"── Rapport {agent_id} ({r['received_at'][:16]}) ──\n{r['content']}" + + if not self._reports: + return "Aucun rapport reçu aujourd'hui." + + lines = [f"── Rapport quotidien ({datetime.now().strftime('%d/%m/%Y')}) ──"] + for aid, r in self._reports.items(): + lines.append(f"\n[{aid}] reçu à {r['received_at'][11:16]}") + lines.append(r["content"]) + return "\n".join(lines) + + def clear(self): + self._reports.clear() diff --git a/nexus.py b/nexus.py new file mode 100644 index 0000000..fbca6eb --- /dev/null +++ b/nexus.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +""" +Nexus — Orchestrateur principal du système multi-agents. + +Reçoit les instructions via XMPP (ou CLI), les traite via LLM, +délègue aux agents spécialisés via MQTT, et renvoie les résultats. +""" +import json +import logging +import os +import sys +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +# Ajout du chemin agents_core si non installé en package +sys.path.insert(0, "/opt") + +from agents_core import BaseAgent, AgentContext, Message, MessageType +from agents_core.command_parser import ParsedCommand, CommandType, help_text + +from scheduler import NexusScheduler +from daily_report import DailyReportManager + +logger = logging.getLogger(__name__) + +CONFIG_DIR = Path(__file__).parent / "config" + + +class Nexus(BaseAgent): + AGENT_TYPE = "nexus" + DESCRIPTION = "Orchestrateur principal — reçoit les instructions et coordonne les agents spécialisés" + DEFAULT_CONFIG_PATH = str(CONFIG_DIR / "config.json") + + def __init__(self): + super().__init__() + + # Rapport quotidien agrégé + self.report_manager = DailyReportManager() + + # Scheduler (tâches planifiées, rapports automatiques) + self.scheduler = NexusScheduler( + send_task_callback=self._schedule_send_task, + request_report_callback=self._request_daily_report, + ) + + # Résultats en attente de réponse XMPP + # {correlation_id: sender_jid} + self._pending_replies: dict[str, str] = {} + self._pending_lock = threading.Lock() + + # Mode veille global + self._sleep_mode = False + + # ────────────────────────────────────────────── + # Démarrage + # ────────────────────────────────────────────── + + def get_skills_dir(self) -> str: + return str(Path(__file__).parent / "skills") + + def on_start(self): + self.scheduler.start(self.config.get("schedules", {})) + logger.info("Nexus prêt — scheduler démarré") + + def setup_extra_subscriptions(self): + """Souscriptions MQTT supplémentaires de Nexus.""" + # Résultats des agents (réponses à nos délégations) + self.mqtt.subscribe("agents/nexus/inbox", self._on_agent_result) + # Rapports quotidiens des agents + self.mqtt.subscribe("agents/daily_report", self._on_daily_report) + + # ────────────────────────────────────────────── + # Réception des résultats agents (MQTT) + # ────────────────────────────────────────────── + + def _on_agent_result(self, msg: Message | str, topic: str): + """Un agent a renvoyé un résultat → transmettre à l'utilisateur via XMPP.""" + if isinstance(msg, str): + self._forward_to_user(msg, sender="unknown") + return + + if msg.type == MessageType.ALERT: + severity = msg.metadata.get("severity", "warning") + text = f"⚠ Alerte [{severity}] de {msg.sender} :\n{msg.payload}" + self._forward_to_user(text, sender=msg.sender) + return + + if msg.type == MessageType.RESULT: + result_text = ( + f"Résultat de {msg.sender} :\n{msg.payload}" + ) + # Retrouver le JID de l'utilisateur qui a initié la demande + with self._pending_lock: + reply_jid = self._pending_replies.pop(msg.correlation_id, None) + self._forward_to_user(result_text, sender=msg.sender, reply_jid=reply_jid) + return + + # Message direct d'un agent + if msg.type == MessageType.DIRECT: + self._forward_to_user( + f"Message de {msg.sender} :\n{msg.payload}", + sender=msg.sender, + ) + + def _on_daily_report(self, msg: Message | str, topic: str): + """Réception d'un rapport quotidien d'un agent.""" + if isinstance(msg, Message): + self.report_manager.add_report(msg.sender, msg.payload) + logger.info(f"Rapport reçu de {msg.sender}") + + def _forward_to_user(self, text: str, sender: str = "", reply_jid: Optional[str] = None): + """ + Envoie un message à l'utilisateur XMPP. + - Si reply_jid : répond à l'expéditeur spécifique (réponse async) + - Sinon : envoie à tous les admins configurés + - Toujours dans le groupe MUC si configuré + """ + if self.xmpp: + if reply_jid: + self.xmpp.send_message(reply_jid, text) + else: + self.xmpp.send_to_all_admins(text) + if self.xmpp.muc_room: + self.xmpp.send_to_group(text) + + # ────────────────────────────────────────────── + # Traitement des messages XMPP + # ────────────────────────────────────────────── + + def _on_xmpp_message(self, sender: str, body: str, is_muc: bool = False): + """Override — Nexus gère les commandes et délègue au LLM.""" + from agents_core.command_parser import parse as parse_command + + if self._sleep_mode and not body.strip().startswith("/"): + return # En veille, ignore les messages sauf commandes système + + cmd = parse_command(body) + context = AgentContext(self) + + # ── Commandes système /xxx + if cmd.type == CommandType.SYSTEM: + reply = self._handle_system_command(f"/{cmd.command} {cmd.args}", raw_cmd=cmd) + if reply and self.xmpp: + self.xmpp.send_message(sender, reply) + return + + # ── Message direct @agent + if cmd.type == CommandType.DIRECT: + reply = self._delegate_direct(cmd, sender) + if self.xmpp: + self.xmpp.send_message(sender, reply) + return + + # ── Broadcast @all + if cmd.type == CommandType.BROADCAST: + self.mqtt.broadcast(cmd.args or "") + if self.xmpp: + self.xmpp.send_message(sender, "Broadcast envoyé à tous les agents.") + return + + # ── Mode naturel → LLM → skills + extra_ctx = self.registry.summary_for_llm(self._online_agents) + response = self._llm_loop(body, context, extra_ctx) + + # Enregistre le JID pour le retour asynchrone éventuel + # (si le LLM a délégué à un agent via DELEGATE skill) + if self.xmpp: + self.xmpp.send_message(sender, response) + + def _delegate_direct(self, cmd: ParsedCommand, sender_jid: str) -> str: + """Route @agent message directement via MQTT.""" + target = cmd.target + message = cmd.args or "" + + caps = self.registry.get(target) + if caps is None: + known = [a.agent_id for a in self.registry.all_agents()] + return f"Agent '{target}' inconnu.\nAgents connus : {', '.join(known) or 'aucun'}" + + with self._online_lock: + online = target in self._online_agents + + if not online: + return f"Agent '{target}' est hors ligne." + + sent = self.mqtt.send_to( + recipient_id=target, + payload=message, + reply_to=self.mqtt.topic_inbox(), + ) + + # Mémoriser le JID pour renvoyer la réponse + with self._pending_lock: + self._pending_replies[sent.correlation_id] = sender_jid + + return f"Message envoyé à {target}. Attente de la réponse..." + + # ────────────────────────────────────────────── + # Commandes système étendues + # ────────────────────────────────────────────── + + def handle_custom_command(self, cmd: str, args: str, + source_msg: Optional[Message] = None) -> Optional[str]: + """Commandes spécifiques à Nexus.""" + + if cmd == "sleep": + self._sleep_mode = True + return "Nexus en veille. Tape /wake pour reprendre." + + if cmd == "wake": + self._sleep_mode = False + return "Nexus actif." + + if cmd == "agents": + with self._online_lock: + online = list(self._online_agents) + all_caps = self.registry.all_agents() + lines = ["── Agents ──────────────────"] + for a in all_caps: + status = "🟢 EN LIGNE" if a.agent_id in online else "🔴 hors ligne" + skills = ", ".join(s["name"] for s in a.skills[:5]) + lines.append(f" {a.agent_id} [{a.agent_type}] {status}") + lines.append(f" {a.description}") + lines.append(f" Skills: {skills or 'aucun'}") + return "\n".join(lines) if len(lines) > 1 else "Aucun agent connu." + + if cmd == "report": + target = args.strip() or None + return self.report_manager.get_report(target) + + if cmd == "schedule": + return self._handle_schedule_command(args) + + if cmd == "schedules": + return self.scheduler.list_jobs() + + if cmd == "update": + # @agent update → git pull + restart + target = args.strip() + if not target: + return "Usage : /update " + self.mqtt.send_to(target, "/update", msg_type=MessageType.COMMAND) + return f"Mise à jour demandée à {target}." + + if cmd == "admins": + return self._handle_admins_command(args) + + if cmd == "help": + return self._nexus_help() + + return f"Commande inconnue : /{cmd}. Tape /help." + + def _handle_admins_command(self, args: str) -> str: + """ + /admins → liste les admins autorisés + /admins add → ajoute un admin + /admins remove → retire un admin + """ + if not self.xmpp: + return "XMPP non configuré." + + parts = args.strip().split(None, 1) + sub = parts[0].lower() if parts else "list" + jid = parts[1].strip() if len(parts) > 1 else "" + + # Auto-complète le domaine si pas de @ + xmpp_domain = self.config.get("xmpp", {}).get("jid", "@").split("@")[1] + if jid and "@" not in jid: + jid = f"{jid}@{xmpp_domain}" + + if sub in ("list", ""): + admins = sorted(self.xmpp.admin_jids) + return "Admins autorisés :\n" + "\n".join(f" • {j}" for j in admins) \ + if admins else "Aucun admin configuré (accès ouvert)." + + if sub == "add": + if not jid: + return "Usage : /admins add " + self.xmpp.add_admin(jid) + return f"Admin ajouté : {jid}" + + if sub == "remove": + if not jid: + return "Usage : /admins remove " + self.xmpp.remove_admin(jid) + return f"Admin retiré : {jid}" + + return "Usage : /admins | /admins add | /admins remove " + + def _handle_schedule_command(self, args: str) -> str: + """ + /schedule @ + Exemples : + /schedule daily 03:00 @debian apt upgrade -y + /schedule every 6h @ansible playbook site.yml + /schedule cancel + """ + args = args.strip() + if not args: + return "Usage : /schedule @ \nOu : /schedule cancel " + + if args.startswith("cancel "): + job_id = args.split(" ", 1)[1].strip() + ok = self.scheduler.cancel_job(job_id) + return f"Tâche {job_id} annulée." if ok else f"Tâche {job_id} introuvable." + + # Parse : "daily 03:00 @debian apt upgrade" + # ou : "every 6h @ansible playbook site.yml" + try: + parts = args.split() + # Trouver @agent + agent_idx = next(i for i, p in enumerate(parts) if p.startswith("@")) + freq_parts = parts[:agent_idx] + agent_id = parts[agent_idx][1:] # Enlève le @ + task = " ".join(parts[agent_idx + 1:]) + + job_id = self.scheduler.add_job( + frequency=" ".join(freq_parts), + agent_id=agent_id, + task=task, + ) + return f"Tâche planifiée (id={job_id}) : [{' '.join(freq_parts)}] @{agent_id} → {task}" + except (StopIteration, IndexError, ValueError) as e: + return f"Format invalide : {e}\nUsage : /schedule daily 03:00 @agent tâche" + + def _nexus_help(self) -> str: + base = help_text() + nexus_extra = """ +── Commandes Nexus ───────────────── + /agents — Liste et statut des agents + /sleep — Mettre Nexus en veille + /wake — Réveiller Nexus + /report [agent] — Rapport quotidien + /schedule @a tâche — Planifier une tâche + /schedules — Voir les tâches planifiées + /update — Mettre à jour un agent (git pull) + /admins — Lister les utilisateurs autorisés + /admins add — Autoriser un nouvel utilisateur + /admins remove — Retirer un utilisateur + /pause [agent] — Mettre en pause + /resume [agent] — Reprendre + /reset — Effacer l'historique LLM + /status — Statut de Nexus + +Mode @agent : + @debian-prod apt update — Commande directe + @all status — Broadcast à tous +""" + return base + nexus_extra + + # ────────────────────────────────────────────── + # Scheduler callbacks + # ────────────────────────────────────────────── + + def _schedule_send_task(self, agent_id: str, task: str): + """Callback du scheduler pour envoyer une tâche planifiée.""" + with self._online_lock: + online = agent_id in self._online_agents + if not online: + logger.warning(f"[Scheduler] Agent {agent_id} hors ligne, tâche ignorée : {task}") + return + self.mqtt.send_to(agent_id, task) + logger.info(f"[Scheduler] Tâche envoyée à {agent_id} : {task}") + + def _request_daily_report(self, agent_id: str): + """Demande un rapport quotidien à un agent.""" + self.mqtt.send_to(agent_id, "/report", msg_type=MessageType.COMMAND) + + # ────────────────────────────────────────────── + # Broadcast handler + # ────────────────────────────────────────────── + + def on_broadcast(self, msg: Message): + """Nexus reçoit les broadcasts — les transmet à l'admin si pertinent.""" + if msg.type == MessageType.ALERT: + self._forward_to_user( + f"⚠ Alerte broadcast de {msg.sender} :\n{msg.payload}" + ) + + +if __name__ == "__main__": + Nexus().run() diff --git a/nexus.service b/nexus.service new file mode 100644 index 0000000..3b41540 --- /dev/null +++ b/nexus.service @@ -0,0 +1,18 @@ +[Unit] +Description=Nexus — Orchestrateur principal multi-agents +After=network.target mosquitto.service +Wants=mosquitto.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/nexus +ExecStart=/opt/nexus/venv/bin/python /opt/nexus/nexus.py +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=nexus + +[Install] +WantedBy=multi-user.target diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5d7d0e2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +agents_core @ file:///opt/agents_core +apscheduler>=3.10 +duckduckgo-search>=6.0 +beautifulsoup4>=4.12 +requests>=2.28 diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..787f964 --- /dev/null +++ b/scheduler.py @@ -0,0 +1,149 @@ +""" +Scheduler de Nexus — gère les tâches planifiées et les rapports automatiques. +Basé sur APScheduler. +""" +import logging +import uuid +from typing import Callable, Optional + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger + +logger = logging.getLogger(__name__) + + +class NexusScheduler: + def __init__( + self, + send_task_callback: Callable[[str, str], None], + request_report_callback: Callable[[str], None], + ): + self._scheduler = BackgroundScheduler(timezone="Europe/Paris") + self._send_task = send_task_callback + self._request_report = request_report_callback + self._jobs: dict[str, dict] = {} # job_id → metadata + + def start(self, config: dict): + """Démarre le scheduler avec la config initiale.""" + self._scheduler.start() + + # Tâches planifiées initiales depuis config + for job in config.get("scheduled_tasks", []): + try: + self.add_job( + frequency=job["frequency"], + agent_id=job["agent"], + task=job["task"], + job_id=job.get("id"), + ) + except Exception as e: + logger.error(f"[Scheduler] Erreur chargement tâche {job}: {e}") + + # Rapports automatiques + for report in config.get("daily_reports", []): + try: + self._add_report_job( + agent_id=report["agent"], + time_str=report["time"], # "08:00" + ) + except Exception as e: + logger.error(f"[Scheduler] Erreur rapport {report}: {e}") + + logger.info(f"[Scheduler] {len(self._jobs)} job(s) chargé(s)") + + def add_job(self, frequency: str, agent_id: str, task: str, + job_id: Optional[str] = None) -> str: + """ + Ajoute une tâche planifiée. + + Formats de fréquence supportés : + daily HH:MM → tous les jours à HH:MM + every Xh → toutes les X heures + every Xmin → toutes les X minutes + weekly HH:MM → une fois par semaine (lun/mar/...) + """ + job_id = job_id or str(uuid.uuid4())[:8] + trigger = self._parse_frequency(frequency) + + self._scheduler.add_job( + func=self._send_task, + trigger=trigger, + args=[agent_id, task], + id=job_id, + replace_existing=True, + ) + self._jobs[job_id] = { + "id": job_id, + "frequency": frequency, + "agent": agent_id, + "task": task, + } + logger.info(f"[Scheduler] Job {job_id} : [{frequency}] @{agent_id} → {task}") + return job_id + + def _add_report_job(self, agent_id: str, time_str: str) -> str: + """Planifie une demande de rapport quotidien.""" + job_id = f"report_{agent_id}" + hour, minute = map(int, time_str.split(":")) + self._scheduler.add_job( + func=self._request_report, + trigger=CronTrigger(hour=hour, minute=minute), + args=[agent_id], + id=job_id, + replace_existing=True, + ) + self._jobs[job_id] = { + "id": job_id, + "frequency": f"daily {time_str}", + "agent": agent_id, + "task": "[rapport quotidien]", + } + return job_id + + def cancel_job(self, job_id: str) -> bool: + try: + self._scheduler.remove_job(job_id) + self._jobs.pop(job_id, None) + return True + except Exception: + return False + + def list_jobs(self) -> str: + if not self._jobs: + return "Aucune tâche planifiée." + lines = ["── Tâches planifiées ────────────────"] + for j in self._jobs.values(): + lines.append(f" [{j['id']}] {j['frequency']} → @{j['agent']} : {j['task']}") + return "\n".join(lines) + + def _parse_frequency(self, frequency: str): + """Parse une fréquence en trigger APScheduler.""" + parts = frequency.strip().split() + + # daily HH:MM + if parts[0] == "daily" and len(parts) >= 2: + hour, minute = map(int, parts[1].split(":")) + return CronTrigger(hour=hour, minute=minute) + + # weekly lun HH:MM + if parts[0] == "weekly" and len(parts) >= 3: + day_map = { + "lun": "mon", "mar": "tue", "mer": "wed", + "jeu": "thu", "ven": "fri", "sam": "sat", "dim": "sun", + } + day = day_map.get(parts[1].lower(), parts[1]) + hour, minute = map(int, parts[2].split(":")) + return CronTrigger(day_of_week=day, hour=hour, minute=minute) + + # every Xh + if parts[0] == "every" and len(parts) >= 2: + val = parts[1] + if val.endswith("h"): + return IntervalTrigger(hours=int(val[:-1])) + if val.endswith("min"): + return IntervalTrigger(minutes=int(val[:-3])) + if val.endswith("m"): + return IntervalTrigger(minutes=int(val[:-1])) + + raise ValueError(f"Format de fréquence non reconnu : '{frequency}'") diff --git a/skills/delegate.py b/skills/delegate.py new file mode 100644 index 0000000..3f58d33 --- /dev/null +++ b/skills/delegate.py @@ -0,0 +1,31 @@ +""" +Skill DELEGATE — déléguer une tâche à un agent spécialisé via MQTT. + +Usage LLM : SKILL:delegate ARGS: | +""" +DESCRIPTION = "Déléguer une tâche à un agent spécialisé" +USAGE = "SKILL:delegate ARGS: | " + + +def run(args: str, context) -> str: + if "|" not in args: + return "Format invalide. Usage : SKILL:delegate ARGS: | " + + agent_id, task = args.split("|", 1) + agent_id = agent_id.strip() + task = task.strip() + + # Vérifier que l'agent est connu + caps = context.registry.get(agent_id) + if caps is None: + known = [a.agent_id for a in context.registry.all_agents()] + return f"Agent '{agent_id}' inconnu. Agents connus : {', '.join(known)}" + + # Envoyer la tâche via MQTT + sent = context.mqtt.send_to( + recipient_id=agent_id, + payload=task, + reply_to=context.mqtt.topic_inbox(), + ) + + return f"Tâche déléguée à {agent_id} (id={sent.correlation_id[:8]}). Attente de la réponse..." diff --git a/skills/memory.py b/skills/memory.py new file mode 100644 index 0000000..cc863e7 --- /dev/null +++ b/skills/memory.py @@ -0,0 +1,59 @@ +""" +Skill MEMORY — mémorisation persistante clé/valeur (SQLite). + +Usage LLM : + SKILL:memory ARGS:set | | + SKILL:memory ARGS:get | + SKILL:memory ARGS:list + SKILL:memory ARGS:delete | +""" +import sqlite3 +import os + +DESCRIPTION = "Mémorisation persistante d'informations clé/valeur" +USAGE = "SKILL:memory ARGS:set|| ou get| ou list" + +DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "memory.db") + + +def _connect(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.execute(""" + CREATE TABLE IF NOT EXISTS memory ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + ) + """) + return conn + + +def run(args: str, context) -> str: + parts = [p.strip() for p in args.split("|")] + action = parts[0].lower() if parts else "" + + with _connect() as conn: + if action == "set" and len(parts) >= 3: + key, value = parts[1], "|".join(parts[2:]) + conn.execute( + "INSERT OR REPLACE INTO memory (key, value, updated_at) VALUES (?, ?, datetime('now'))", + (key, value) + ) + return f"Mémorisé : {key} = {value}" + + if action == "get" and len(parts) >= 2: + row = conn.execute("SELECT value FROM memory WHERE key = ?", (parts[1],)).fetchone() + return row[0] if row else f"Clé '{parts[1]}' introuvable." + + if action == "list": + rows = conn.execute("SELECT key, value FROM memory ORDER BY key").fetchall() + if not rows: + return "Mémoire vide." + return "\n".join(f" {k}: {v}" for k, v in rows) + + if action == "delete" and len(parts) >= 2: + conn.execute("DELETE FROM memory WHERE key = ?", (parts[1],)) + return f"Clé '{parts[1]}' supprimée." + + return "Usage : SKILL:memory ARGS:set|clé|valeur ou get|clé ou list ou delete|clé" diff --git a/skills/mqtt_send.py b/skills/mqtt_send.py new file mode 100644 index 0000000..c7379be --- /dev/null +++ b/skills/mqtt_send.py @@ -0,0 +1,24 @@ +""" +Skill MQTT_SEND — publier un message sur n'importe quel topic MQTT. + +Permet au LLM (et à l'utilisateur) de publier librement sur le bus. + +Usage LLM : SKILL:mqtt_send ARGS: | +""" +DESCRIPTION = "Publier un message sur un topic MQTT arbitraire" +USAGE = "SKILL:mqtt_send ARGS: | " + + +def run(args: str, context) -> str: + if "|" not in args: + return "Format invalide. Usage : SKILL:mqtt_send ARGS: | " + + topic, message = args.split("|", 1) + topic = topic.strip() + message = message.strip() + + if not topic: + return "Topic vide." + + context.mqtt.publish_raw(topic, message) + return f"Message publié sur '{topic}'." diff --git a/skills/web_read.py b/skills/web_read.py new file mode 100644 index 0000000..0268848 --- /dev/null +++ b/skills/web_read.py @@ -0,0 +1,27 @@ +""" +Skill WEB_READ — lire le contenu d'une URL. + +Usage LLM : SKILL:web_read ARGS: +""" +DESCRIPTION = "Lire le contenu d'une page web" +USAGE = "SKILL:web_read ARGS:" + + +def run(args: str, context) -> str: + url = args.strip() + if not url: + return "URL vide." + try: + import requests + from bs4 import BeautifulSoup + resp = requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"}) + resp.raise_for_status() + soup = BeautifulSoup(resp.text, "html.parser") + # Supprime scripts et styles + for tag in soup(["script", "style", "nav", "footer"]): + tag.decompose() + text = soup.get_text(separator="\n", strip=True) + # Tronqué à 3000 caractères + return text[:3000] + ("..." if len(text) > 3000 else "") + except Exception as e: + return f"Erreur lecture URL : {e}" diff --git a/skills/web_search.py b/skills/web_search.py new file mode 100644 index 0000000..fab213e --- /dev/null +++ b/skills/web_search.py @@ -0,0 +1,24 @@ +""" +Skill WEB_SEARCH — recherche DuckDuckGo. + +Usage LLM : SKILL:web_search ARGS: +""" +DESCRIPTION = "Recherche web via DuckDuckGo" +USAGE = "SKILL:web_search ARGS:" + + +def run(args: str, context) -> str: + query = args.strip() + if not query: + return "Requête vide." + try: + from duckduckgo_search import DDGS + results = [] + with DDGS() as ddgs: + for r in ddgs.text(query, max_results=5): + results.append(f"- {r['title']}\n {r['href']}\n {r['body'][:200]}") + return "\n\n".join(results) if results else "Aucun résultat." + except ImportError: + return "Module duckduckgo_search non installé (pip install duckduckgo-search)" + except Exception as e: + return f"Erreur recherche : {e}"