"""Pont Alfred -> Hermes avec session persistante.""" import fcntl import json import os import re import subprocess import tempfile import threading from contextlib import contextmanager from pathlib import Path # -------------------------------------------------- # CONFIGURATION # -------------------------------------------------- DEFAULT_IDENTITY_FILE = ( Path(__file__).resolve().parent.parent / "config" / "identity.md" ) DEFAULT_SESSION_FILE = ( Path(__file__).resolve().parent.parent / "config" / "hermes_session.json" ) _REQUEST_LOCK = threading.Lock() _ID_PATTERN = re.compile( r"^[A-Za-z0-9_:.\-]{1,128}$" ) # -------------------------------------------------- # IDENTITÉ D'ALFRED # -------------------------------------------------- def _build_prompt(question: str) -> str: """Send only the real user utterance. Personality is configured in Hermes SOUL.md. The previous identity.md wrapper caused absent/unknown-tool decisions in both Spotify and Home Assistant. Never inject it into each user turn. """ return str(question or '').strip() def _toolsets_for(question: str) -> str | None: """Conservative domain routing to avoid huge MCP schemas on simple turns. Cross-domain, ambiguous, or generic requests retain Hermes' full toolset. Set HERMES_TOOLSET_ROUTING=off for instant rollback without code changes. """ if os.getenv('HERMES_TOOLSET_ROUTING', 'on').lower() not in ('on','1','true','yes'): return None q = str(question or '').casefold() domains = { 'spotify': (r'\bspotify\b|\bplaylist\b|\bmusique\b|\bmorceau\b|\bchanson\b|\balbum\b|\bspotify connect\b'), 'homeassistant': (r'\blumi[eè]res?\b|\blampes?\b|\bvolets?\b|\bdomotique\b|\bthermostat\b|\bchauffage\b|\bprises?\b|\bhome assistant\b|\ballume\b|\b[eé]teins?\b'), 'face_recognition': (r'\bface_recognition\b|\bidentify_people\b|\breconnaissance faciale\b'), # DONA_V7_RELEASE 'proxmox': (r'\bproxmox\b|\bcluster proxmox\b'), # DONA_V8_DYNAMIC 'wazuh': (r'\bwazuh\b'), } import re as _re matching = [domain for domain, pattern in domains.items() if _re.search(pattern, q)] # Combined or potentially compound queries keep full access to every MCP. if len(matching) != 1: return None if _re.search(r'\b(et aussi|en m[eê]me temps|puis v[eé]rifie|compare avec|cherche sur|envoie|par mail|gmail|calendrier|agenda|comfyui|document|paperless)\b', q): return None return {'spotify':'spotify', 'homeassistant':'homeassistant,home_assistant', 'face_recognition':'face_recognition', 'proxmox':'proxmox', 'wazuh':'wazuh'}[matching[0]] # -------------------------------------------------- # FICHIER DE SESSION # -------------------------------------------------- def _session_file() -> Path: return Path( os.getenv( "HERMES_SESSION_FILE", str(DEFAULT_SESSION_FILE) ) ).expanduser() def _read_session(path: Path) -> str | None: if not path.exists(): return None try: obj = json.loads( path.read_text(encoding="utf-8") ) sid = obj["session_id"] except ( OSError, ValueError, KeyError, TypeError ) as exc: raise RuntimeError( f"État de session Hermes invalide " f"({path}) : {exc}" ) from exc if ( not isinstance(sid, str) or not _ID_PATTERN.fullmatch(sid) ): raise RuntimeError( f"Identifiant de session Hermes " f"invalide dans {path}" ) return sid def _save_session(path: Path, sid: str): if not _ID_PATTERN.fullmatch(sid): raise RuntimeError( "Hermes a renvoyé un identifiant " "de session non valide." ) path.parent.mkdir( parents=True, exist_ok=True ) temp_path = None try: fd, temp_name = tempfile.mkstemp( prefix=".hermes_session_", suffix=".tmp", dir=path.parent ) temp_path = Path(temp_name) with os.fdopen( fd, "w", encoding="utf-8" ) as handle: json.dump( {"session_id": sid}, handle, indent=2 ) handle.write("\n") os.replace(temp_path, path) finally: if temp_path is not None: temp_path.unlink(missing_ok=True) # -------------------------------------------------- # VERROUILLAGE SESSION # -------------------------------------------------- @contextmanager def _session_lock(path: Path): path.parent.mkdir( parents=True, exist_ok=True ) lock_path = path.with_name( path.name + ".lock" ) fd = os.open( lock_path, os.O_CREAT | os.O_RDWR, 0o600 ) with os.fdopen(fd, "w") as handle: fcntl.flock( handle.fileno(), fcntl.LOCK_EX ) try: yield finally: fcntl.flock( handle.fileno(), fcntl.LOCK_UN ) # -------------------------------------------------- # CONFIGURATION HERMES # -------------------------------------------------- def _hermes_binary() -> str: return os.getenv( "HERMES_CLI", "/root/.local/bin/hermes" ) def _hermes_timeout() -> float: return max( 10.0, float( os.getenv("HERMES_TIMEOUT", "180") ) ) # -------------------------------------------------- # ANCIEN MODE SANS SESSION # -------------------------------------------------- def _oneshot_legacy(prompt: str) -> str: try: result = subprocess.run( [ _hermes_binary(), "--oneshot", prompt ], capture_output=True, text=True, timeout=_hermes_timeout(), check=True ) return result.stdout.strip() except subprocess.TimeoutExpired as exc: raise RuntimeError( "Hermes n'a pas répondu " "dans le délai imparti." ) from exc except subprocess.CalledProcessError as exc: details = ( exc.stderr or exc.stdout or "aucun détail" ).strip() raise RuntimeError( f"Hermes a échoué " f"(code {exc.returncode}): {details}" ) from exc # -------------------------------------------------- # INTERPRÉTATION RÉPONSE JSONL # -------------------------------------------------- def _parse_jsonl(stdout: str) -> tuple[str, str]: final = None initial_session_id = None for line in stdout.splitlines(): if not line.strip(): continue try: event = json.loads(line) except json.JSONDecodeError: continue if not isinstance(event, dict): continue if ( event.get("type") == "system" and event.get("subtype") == "init" ): initial_session_id = event.get( "session_id" ) if event.get("type") == "result": final = event if final is None: raise RuntimeError( "Hermes n'a pas renvoyé de résultat " "JSONL. Vérifie la prise en charge " "de --format stream-json." ) if final.get("exit_code", 0) != 0: raise RuntimeError( f"Hermes : " f"{final.get('error') or 'échec'}" ) sid = ( final.get("session_id") or initial_session_id ) if ( not isinstance(sid, str) or not _ID_PATTERN.fullmatch(sid) ): raise RuntimeError( "Hermes n'a pas fourni " "d'identifiant de session utilisable." ) answer = final.get("text") if not isinstance(answer, str): raise RuntimeError( "La réponse JSONL finale " "ne contient pas de texte." ) return answer.strip(), sid # -------------------------------------------------- # APPEL HERMES AVEC REPRISE DE SESSION # -------------------------------------------------- def _call_hermes( prompt: str, session_id: str | None ) -> tuple[str, str]: cmd = [ _hermes_binary(), "chat", "--oneshot", "--query-file", "-", "--format", "stream-json", "--source", "cli" ] route = _toolsets_for(prompt) if route: cmd.extend(["--toolsets", route]) if session_id: cmd.extend([ "--resume", session_id ]) try: process = subprocess.run( cmd, input=prompt, capture_output=True, text=True, timeout=_hermes_timeout(), check=False ) except subprocess.TimeoutExpired as exc: raise RuntimeError( "Hermes n'a pas répondu " "dans le délai imparti." ) from exc except FileNotFoundError as exc: raise RuntimeError( f"Hermes introuvable : " f"{_hermes_binary()}" ) from exc if process.returncode != 0: msg = ( process.stderr or process.stdout or "aucun détail" ).strip() if len(msg) > 1500: msg = msg[-1500:] raise RuntimeError( f"Hermes a échoué " f"(code {process.returncode}) : " f"{msg}" ) return _parse_jsonl(process.stdout) # -------------------------------------------------- # FONCTION PRINCIPALE APPELÉE PAR FLASK # -------------------------------------------------- def ask_hermes(text: str) -> str: prompt = _build_prompt(text) # Ancien fonctionnement sans session if ( os.getenv( "HERMES_SESSION_MODE", "persistent" ).lower() == "legacy" ): return _oneshot_legacy(prompt) path = _session_file() # Empêche les appels concurrents with _REQUEST_LOCK, _session_lock(path): # Recherche la session précédente current_sid = _read_session(path) # Envoie la question à Hermes answer, new_sid = _call_hermes( prompt, current_sid ) # Sauvegarde l'identifiant _save_session( path, new_sid ) print( f"[ALFRED] Session Hermes : " f"{new_sid} " f"({'reprise' if current_sid else 'création'})", flush=True ) return answer