"""A Pipecat processor that puts Hermes in the LLM slot. Supports: 1. Hermes CLI (`hermes chat -q ... -Q`) with session tracking (`-r `). 2. Hermes Server / Gateway Daemon (`http://localhost:8642` or `http://localhost:4096`) if active. """ import asyncio import json import os import re import shutil from pathlib import Path import aiohttp from typing import Callable, Optional import time from loguru import logger import env_setup from pipecat.frames.frames import ( CancelFrame, EndFrame, Frame, InterruptionFrame, LLMContextFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMTextFrame, StartFrame, TextFrame, TTSSpeakFrame, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor _NOISE_TRANSCRIPTS = { "", ".", "thank you.", "thanks for watching!", "you", "bye.", "okay.", "[blank_audio]", "[silence]", } ANSI_ESCAPE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') SESSION_ID_REGEX = re.compile(r"\bsession_id:\s*([^\s]+)", re.IGNORECASE) def _strip_ansi(text: str) -> str: if not text: return "" return ANSI_ESCAPE.sub("", text).strip() def _clean_spoken_text(text: str) -> str: """Clean text for speech output and truncate fake turn generations.""" if not text: return "" cleaned_line = _strip_ansi(text).strip() # Filter out Hermes CLI session headers and status indicators if ( cleaned_line.startswith("↻") or "Resumed session" in cleaned_line or "session_id:" in cleaned_line.lower() ): return "" # Strip leading or inline role headers (e.g. "Assistant:") text = re.sub(r"(?i)\b(Assistant|assistant|Bot|bot):\s*", "", text) # Truncate if model hallucinates fake subsequent user turns for marker in ("\nUser:", "\nHuman:", "\nUser", "\nHuman"): if marker in text: text = text.split(marker)[0] # Remove markdown code blocks text = re.sub(r"```[\s\S]*?```", "", text) # Remove inline code ticks text = re.sub(r"`[^`]*`", "", text) # Remove markdown syntax characters text = re.sub(r"[\#\*\_\~]", "", text) # Flatten newlines into clear speech lines = [line.strip() for line in text.splitlines() if line.strip()] return " ".join(lines).strip() class StreamParser: """Parses streaming tokens, separating ... reasoning blocks from spoken text.""" def __init__(self): self.in_think = False def feed(self, chunk: str) -> tuple[str, str]: thinking = "" spoken = "" buf = chunk while buf: if not self.in_think: if "" in buf: parts = buf.split("", 1) spoken += parts[0] self.in_think = True buf = parts[1] else: spoken += buf buf = "" else: if "" in buf: parts = buf.split("", 1) thinking += parts[0] self.in_think = False buf = parts[1] else: thinking += buf buf = "" return thinking, spoken def find_hermes_cli() -> str | None: candidates = [ shutil.which("hermes"), os.path.expanduser("~/.hermes/bin/hermes"), os.path.expanduser("~/.local/bin/hermes"), "/opt/homebrew/bin/hermes", "/usr/local/bin/hermes", ] for candidate in candidates: if candidate and os.path.exists(candidate) and os.access(candidate, os.X_OK): return candidate return shutil.which("hermes") def get_hermes_api_key() -> str: env_file = Path.home() / ".hermes" / ".env" if env_file.exists(): try: with open(env_file, "r") as f: for line in f: if line.startswith("API_SERVER_KEY="): return line.split("=", 1)[1].strip().strip('"').strip("'") except Exception: pass return os.environ.get("API_SERVER_KEY", "") async def check_hermes_server_active(port: int = 9119) -> tuple[bool, str]: """Check if Hermes OpenAI gateway endpoint (v1/models) is responding with authorization.""" url_models = f"http://127.0.0.1:{port}/v1/models" key = get_hermes_api_key() headers = {"Authorization": f"Bearer {key}"} if key else {} try: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=1.0)) as session: async with session.get(url_models, headers=headers) as resp: if resp.status == 200: return True, f"Hermes Gateway API active on http://127.0.0.1:{port}/v1" elif resp.status == 401: return False, "Hermes Gateway API requires API_SERVER_KEY" except Exception: pass return False, "Hermes Gateway API server not active" async def ensure_hermes_server(port: int = 9119) -> tuple[bool, str]: """Ensure Hermes gateway server daemon or CLI binary is available.""" active, msg = await check_hermes_server_active(port) if active: return True, msg cli = find_hermes_cli() if not cli: return False, "Hermes CLI binary not found" return True, f"Hermes CLI ready ({cli})" def probe_hermes(model: str | None = None) -> tuple[bool, str]: cli = find_hermes_cli() if cli: m_str = f" with model {model}" if model else "" return True, f"Hermes available ({cli}){m_str}" return False, "Hermes CLI binary not found (install hermes or ensure it is in PATH)" class HermesLLM(FrameProcessor): """Runs user turns through Hermes CLI or Gateway API using persistent session tracking.""" def __init__( self, *, model: str | None = None, cwd: str | Path | None = None, port: int = 9119, session_name: str = "Voice Agent", observer=None, on_tool_event: Optional[Callable[[str], None]] = None, use_server: bool = False, keep_open: bool = True, **kwargs, ): super().__init__(**kwargs) self._model = model self._cwd = Path(cwd or Path(__file__).parent).expanduser().resolve() self._port = port self._session_name = session_name self._on_reply = observer self._on_tool_event = on_tool_event self._turn_task: asyncio.Task | None = None self._history: list[dict[str, str]] = [] self._cli_path = find_hermes_cli() or "hermes" self._use_server = use_server self._keep_open = keep_open self._session_renamed = False self._http_session: aiohttp.ClientSession | None = None self._proc: asyncio.subprocess.Process | None = None self._proc_lock = asyncio.Lock() self._stderr_task: asyncio.Task | None = None # Keep the conversation lineage with the workspace. A single global # session file can make two voice-agent workspaces resume each other's # Hermes conversations. self._session_state_file = self._cwd / ".hermes-voice-session.json" # Persisted session ID self._session_id: str | None = self._load_session_id() if self._session_id: logger.info(f"Loaded existing Hermes session ID: {self._session_id}") def reset_session(self): """Reset active session so a fresh session starts on the next turn.""" logger.info("Resetting active Hermes session state...") self._session_id = None self._session_renamed = False self._history.clear() if self._proc: asyncio.create_task(self._stop_persistent_proc()) try: if self._session_state_file.exists(): self._session_state_file.unlink() except Exception as e: logger.warning(f"Could not remove session file during reset: {e}") def _sync_disk_session(self): """Sync in-memory session ID with disk file state prior to each turn.""" disk_sid = self._load_session_id() if disk_sid != self._session_id: logger.info(f"Hermes session state updated from disk: {self._session_id} -> {disk_sid}") self._session_id = disk_sid self._session_renamed = False self._history.clear() if self._proc: asyncio.create_task(self._stop_persistent_proc()) async def _get_http_session(self) -> aiohttp.ClientSession: if self._http_session is None or self._http_session.closed: self._http_session = aiohttp.ClientSession() return self._http_session async def _close_http_session(self): if self._http_session and not self._http_session.closed: await self._http_session.close() self._http_session = None def _load_session_id(self) -> str | None: if self._session_state_file.exists(): try: data = json.loads(self._session_state_file.read_text()) sid = data.get("session_id") if sid and isinstance(sid, str): return sid.strip() except Exception as e: logger.debug(f"Could not load Hermes session state: {e}") return None def _save_session_id(self): try: self._session_state_file.parent.mkdir(parents=True, exist_ok=True) if self._session_id: self._session_state_file.write_text( json.dumps({"session_id": self._session_id}, indent=2) + "\n" ) elif self._session_state_file.exists(): self._session_state_file.unlink() except Exception as e: logger.warning(f"Could not save Hermes session state: {e}") def _remember_session_id(self, text: str): if not text: return match = SESSION_ID_REGEX.search(text) if match: new_sid = match.group(1).strip() if new_sid and new_sid != self._session_id: self._session_id = new_sid logger.info(f"Hermes active session tracking ID: {self._session_id}") self._save_session_id() if not self._session_renamed and self._session_name: asyncio.create_task(self._rename_session(new_sid)) async def _rename_session(self, session_id: str): self._session_renamed = True try: proc = await asyncio.create_subprocess_exec( self._cli_path, "sessions", "rename", session_id, self._session_name, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() logger.info(f"Renamed Hermes session {session_id} to '{self._session_name}'") except Exception as e: logger.debug(f"Could not rename Hermes session: {e}") async def _ensure_persistent_proc(self): async with self._proc_lock: ok, _ = await check_hermes_server_active(self._port) if ok: return cmd = [self._cli_path, "serve", "--port", str(self._port), "--skip-build"] try: env = {**os.environ, "PYTHONUNBUFFERED": "1", "FORCE_COLOR": "0", "NO_COLOR": "1"} self._proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=str(self._cwd), env=env, ) logger.info(f"Auto-started Hermes server (`hermes serve --port {self._port} --skip-build`, PID: {self._proc.pid})") for _ in range(50): ready, _ = await check_hermes_server_active(self._port) if ready: logger.info(f"Hermes server active and ready on port {self._port}.") break await asyncio.sleep(0.1) except Exception as e: logger.warning(f"Could not auto-start Hermes server daemon (`hermes serve --skip-build`): {e}") async def _stop_persistent_proc(self): async with self._proc_lock: proc = self._proc self._proc = None if self._stderr_task and not self._stderr_task.done(): self._stderr_task.cancel() self._stderr_task = None if proc and proc.returncode is None: try: if proc.stdin and not proc.stdin.is_closing(): proc.stdin.close() proc.terminate() await asyncio.wait_for(proc.wait(), timeout=1.5) except Exception: try: proc.kill() except Exception: pass logger.info("Persistent Hermes process terminated cleanly.") async def process_frame(self, frame: Frame, direction: FrameDirection): await super().process_frame(frame, direction) if isinstance(frame, StartFrame): await self.push_frame(frame, direction) available, reason = probe_hermes(self._model) logger.info(f"Hermes LLM engine initialized: {reason}") if self._keep_open: asyncio.create_task(self._ensure_persistent_proc()) elif isinstance(frame, (EndFrame, CancelFrame)): await self._cancel_turn() await self._stop_persistent_proc() await self._close_http_session() await self.push_frame(frame, direction) elif isinstance(frame, InterruptionFrame): await self._cancel_turn() await self.push_frame(frame, direction) elif isinstance(frame, LLMContextFrame): text = self._latest_user_text(frame.context) await self._maybe_start_turn(text) else: await self.push_frame(frame, direction) def start_turn_direct(self, text: str): utterance = text.strip() if not utterance: return asyncio.create_task(self._maybe_start_turn(utterance)) def _latest_user_text(self, context) -> str: if not context or not hasattr(context, "messages"): return "" for msg in reversed(context.messages): if isinstance(msg, dict) and msg.get("role") == "user": content = msg.get("content", "") if isinstance(content, str): return content elif isinstance(content, list): text_parts = [c.get("text", "") for c in content if isinstance(c, dict)] return " ".join(text_parts) return "" async def _maybe_start_turn(self, text: str): utterance = text.strip() if utterance.lower() in _NOISE_TRANSCRIPTS or len(utterance) < 2: logger.debug(f"Ignoring noise transcript: {utterance!r}") return await self._cancel_turn() logger.info(f"You: {utterance}") self._turn_task = asyncio.create_task(self._run_turn(utterance)) async def _cancel_turn(self): if not self._turn_task: return task, self._turn_task = self._turn_task, None if not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass def set_model(self, model_name: str): if model_name != self._model: logger.info(f"Hermes LLM model set to: {model_name}") self._model = model_name def _sync_disk_model(self): config_file = self._cwd / "model_settings.json" if config_file.exists(): try: data = json.loads(config_file.read_text()) if "model" in data and isinstance(data["model"], str) and data["model"].strip(): new_model = data["model"].strip() if new_model != self._model: logger.info(f"Hermes LLM switching active model: {self._model} -> {new_model}") self._model = new_model if self._proc: asyncio.create_task(self._stop_persistent_proc()) except Exception as e: logger.debug(f"Could not read model settings: {e}") async def _run_turn(self, utterance: str, suppress_output: bool = False): self._sync_disk_model() self._sync_disk_session() self._history.append({"role": "user", "content": utterance}) if not suppress_output: await self.push_frame(LLMFullResponseStartFrame()) chunks: list[str] = [] if self._keep_open: await self._ensure_persistent_proc() t0 = time.perf_counter() server_ok, _ = await check_hermes_server_active(self._port) mode_str = f"API ({self._port})" if (server_ok or self._use_server) else "CLI" try: import web_server web_server.broadcast_event("hermes_status", { "mode": mode_str, "is_api": server_ok or self._use_server, "port": self._port, }) except Exception: pass if server_ok or self._use_server: await self._run_turn_server(utterance, chunks) else: await self._run_turn_cli(utterance, chunks) t1 = time.perf_counter() total_ms = int((t1 - t0) * 1000) if not suppress_output: await self.push_frame(LLMFullResponseEndFrame()) logger.info(f"⏱ [PROFILING] Hermes LLM ({mode_str}): Turn completed in {total_ms}ms ({total_ms/1000:.2f}s)") try: import web_server web_server.broadcast_event("profiling", { "mode": mode_str, "total_ms": total_ms, "model": self._model or "default", }) except Exception: pass full_reply = _clean_spoken_text(" ".join(chunks)) if full_reply: self._history.append({"role": "assistant", "content": full_reply}) logger.info(f"Hermes LLM ({self._model or 'default'}): {full_reply}") if not suppress_output: try: import web_server web_server.broadcast_event("reply", {"text": full_reply}) except Exception: pass if self._on_reply: self._on_reply(full_reply) async def _run_turn_server(self, utterance: str, chunks: list[str]): """Run turn via Hermes OpenAI-compatible Gateway API using SSE streaming (stream: true).""" try: ok, _ = await check_hermes_server_active(self._port) if not ok: raise RuntimeError("Hermes Gateway API unavailable") key = get_hermes_api_key() headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} if key else {"Content-Type": "application/json"} url = f"http://127.0.0.1:{self._port}/v1/chat/completions" messages = [dict(m) for m in self._history[-10:]] if not messages or messages[-1].get("content") != utterance: messages.append({"role": "user", "content": utterance}) payload = { "model": "hermes-agent" if not self._model or self._model.lower() in ("default", "none", "") else self._model, "messages": messages, "stream": True, } session = await self._get_http_session() async with session.post(url, json=payload, headers=headers) as resp: if resp.status == 200: sentence_buffer = "" reasoning_buffer = "" parser = StreamParser() async for raw_line in resp.content: line = raw_line.decode("utf-8").strip() if not line or line.startswith(":"): continue if line == "data: [DONE]": break if line.startswith("data: "): try: data = json.loads(line[6:]) choices = data.get("choices", []) if choices: delta = choices[0].get("delta", {}) # Handle thinking/reasoning deltas immediately reasoning = delta.get("reasoning") or delta.get("thought") if reasoning: reasoning_buffer += str(reasoning) words = reasoning_buffer.strip().split() if "\n" in reasoning_buffer or any(p in reasoning_buffer for p in (".", "!", "?")) or len(words) >= 4: reasoning_phrase = reasoning_buffer.strip() reasoning_buffer = "" cleaned_reasoning = _clean_spoken_text(reasoning_phrase) if cleaned_reasoning: if not cleaned_reasoning.endswith((".", "!", "?")): cleaned_reasoning += "." chunks.append(cleaned_reasoning) # Push TTSSpeakFrame so Kokoro TTS synthesizes & plays audio IMMEDIATELY await self.push_frame(TTSSpeakFrame(cleaned_reasoning)) try: import web_server web_server.broadcast_event("thinking", {"text": str(reasoning)}) except Exception: pass # Handle tool call deltas tool_calls = delta.get("tool_calls") if tool_calls: for tc in tool_calls: fn = tc.get("function", {}) tool_name = fn.get("name", "tool") tool_args = fn.get("arguments", "") if self._on_tool_event: self._on_tool_event(f"Executing {tool_name}") try: import web_server web_server.broadcast_event("tool", {"name": tool_name, "detail": tool_args}) except Exception: pass content = delta.get("content", "") if content: think_text, spoken_text = parser.feed(content) if think_text: reasoning_buffer += think_text words = reasoning_buffer.strip().split() if "\n" in reasoning_buffer or any(p in reasoning_buffer for p in (".", "!", "?")) or len(words) >= 4: reasoning_phrase = reasoning_buffer.strip() reasoning_buffer = "" cleaned_reasoning = _clean_spoken_text(reasoning_phrase) if cleaned_reasoning: if not cleaned_reasoning.endswith((".", "!", "?")): cleaned_reasoning += "." chunks.append(cleaned_reasoning) # Push TTSSpeakFrame so Kokoro TTS synthesizes & plays audio IMMEDIATELY await self.push_frame(TTSSpeakFrame(cleaned_reasoning)) try: import web_server web_server.broadcast_event("thinking", {"text": think_text}) except Exception: pass if spoken_text: sentence_buffer += spoken_text while any(p in sentence_buffer for p in (".", "!", "?", "\n")): idxs = [sentence_buffer.find(p) for p in (".", "!", "?", "\n") if sentence_buffer.find(p) != -1] split_idx = min(idxs) + 1 sentence = sentence_buffer[:split_idx].strip() sentence_buffer = sentence_buffer[split_idx:] cleaned_sent = _clean_spoken_text(sentence) if cleaned_sent: if not cleaned_sent.endswith((".", "!", "?")): cleaned_sent += "." chunks.append(cleaned_sent) await self.push_frame(LLMTextFrame(cleaned_sent)) try: import web_server web_server.broadcast_event("partial_reply", {"text": cleaned_sent}) except Exception: pass except json.JSONDecodeError: pass if reasoning_buffer.strip(): cleaned_r_rem = _clean_spoken_text(reasoning_buffer) if cleaned_r_rem: if not cleaned_r_rem.endswith((".", "!", "?")): cleaned_r_rem += "." chunks.append(cleaned_r_rem) await self.push_frame(TTSSpeakFrame(cleaned_r_rem)) if sentence_buffer.strip(): cleaned_rem = _clean_spoken_text(sentence_buffer) if cleaned_rem: if not cleaned_rem.endswith((".", "!", "?")): cleaned_rem += "." chunks.append(cleaned_rem) await self.push_frame(LLMTextFrame(cleaned_rem)) try: import web_server web_server.broadcast_event("partial_reply", {"text": cleaned_rem}) except Exception: pass else: err_text = await resp.text() logger.error(f"Hermes Gateway API HTTP {resp.status}: {err_text}") raise RuntimeError(f"HTTP {resp.status}") except asyncio.CancelledError: logger.info("Hermes Gateway turn cancelled mid-response.") raise except Exception as e: logger.warning(f"Hermes Gateway error ({e}), falling back to CLI...") try: import web_server web_server.broadcast_event("hermes_status", { "mode": "CLI", "is_api": False, "port": self._port, }) except Exception: pass await self._run_turn_cli(utterance, chunks) async def _run_turn_cli(self, utterance: str, chunks: list[str]): """Run turn via Hermes CLI using persistent session tracking.""" cmd = [self._cli_path, "chat", "-q", utterance, "-Q", "--source", "voice", "--reasoning", "none"] if self._session_id: cmd.extend(["-r", self._session_id]) if self._model and self._model.lower() not in ("default", "none", ""): cmd.extend(["-m", self._model]) proc = None try: env = {**os.environ, "PYTHONUNBUFFERED": "1", "FORCE_COLOR": "0", "NO_COLOR": "1"} proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.DEVNULL, cwd=str(self._cwd), env=env, ) async def _read_stderr(stream): while True: line = await stream.readline() if not line: break decoded = line.decode("utf-8", errors="replace").strip() cleaned = _strip_ansi(decoded) # Extract session_id emitted on stderr self._remember_session_id(cleaned) if ( cleaned and cleaned not in ("[0m", "0m", "]") and not cleaned.startswith(">") and not cleaned.startswith("↻") and not "Resumed session" in cleaned and not cleaned.lower().startswith("session_id:") ): logger.info(f"Hermes Tool: {cleaned}") try: import web_server web_server.broadcast_event("tool", {"name": "Hermes CLI", "detail": cleaned}) except Exception: pass if self._on_tool_event: try: self._on_tool_event(cleaned) except Exception as exc: logger.debug(f"on_tool_event error: {exc}") # Keep tool progress visible in logs and the Companion Web UI, but # do not send implementation details through the spoken channel. stderr_task = asyncio.create_task(_read_stderr(proc.stderr)) while True: line = await proc.stdout.readline() if not line: break text_line = line.decode("utf-8", errors="replace") # Extract session_id if present in stdout as fallback self._remember_session_id(text_line) cleaned = _clean_spoken_text(text_line) if cleaned: chunks.append(cleaned) await self.push_frame(LLMTextFrame(cleaned)) try: import web_server web_server.broadcast_event("partial_reply", {"text": cleaned}) except Exception: pass await proc.wait() await stderr_task # If CLI failed due to an invalid session resume ID, clear session ID for next turn if proc.returncode != 0 and self._session_id and not chunks: logger.warning(f"Hermes CLI returned code {proc.returncode}, clearing session ID for retry...") self._session_id = None self._save_session_id() if not chunks: err_msg = "Done." chunks.append(err_msg) await self.push_frame(LLMTextFrame(err_msg)) except asyncio.CancelledError: logger.info("Hermes turn cancelled mid-response.") if proc and proc.returncode is None: try: proc.kill() except Exception: pass raise except Exception as e: logger.error(f"Hermes LLM CLI error: {e}") err_msg = "Sorry, I ran into an error generating a response." chunks.append(err_msg) await self.push_frame(LLMTextFrame(err_msg))