diff --git a/bot.py b/bot.py index 0073c3a..ddd3998 100644 --- a/bot.py +++ b/bot.py @@ -84,7 +84,7 @@ say them: - You can change your AI model on the fly! If the user asks to list available models or change model, run `python bin/model_tool.py list` or `python bin/model_tool.py set ` (models: luna, gemma, deepseek, gpt-oss, sonnet, etc.). - You can open files visually for the user in the Companion Web UI drawer! Run `python bin/web_tool.py show `. - You can open links or the Companion Web UI in the default browser! Run `python bin/web_tool.py open `. -- Speak your intent out loud BEFORE calling tools! Give brief guidance on what you are attempting (e.g. "Switching your voice to af_heart now...", "Checking Paseo CLI agents...", "Inspecting bot dot py..."). Speak a short guiding sentence first, then run your tools. +- Keep implementation details and tool activity silent in the spoken channel. The user can see technical progress in the logs or Companion Web UI; only speak the useful conversational response. - Complete multi-step tool calls fully before speaking your final response summary. - Be strictly truthful about your findings and never invent fake file contents. - The user's words reach you through speech recognition, so expect occasional diff --git a/hermes_llm.py b/hermes_llm.py index b8910dc..257a8eb 100644 --- a/hermes_llm.py +++ b/hermes_llm.py @@ -26,7 +26,6 @@ from pipecat.frames.frames import ( LLMFullResponseStartFrame, LLMTextFrame, StartFrame, - TTSSpeakFrame, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor @@ -44,7 +43,7 @@ _NOISE_TRANSCRIPTS = { ANSI_ESCAPE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') SESSION_ID_REGEX = re.compile(r"\bsession_id:\s*([^\s]+)", re.IGNORECASE) -SESSION_STATE_FILE = Path.home() / ".hermes" / "voice-agent-session.json" + def _strip_ansi(text: str) -> str: @@ -53,23 +52,6 @@ def _strip_ansi(text: str) -> str: return ANSI_ESCAPE.sub("", text).strip() -def _get_tool_spoken_phrase(detail: str) -> str | None: - d = detail.lower() - if "grep" in d or "glob" in d or "search" in d: - return "Searching the codebase." - elif "read" in d or "view" in d or "inspect" in d: - return "Inspecting project files." - elif "top" in d or "mem" in d or "ps " in d or "ram" in d: - return "Checking system memory." - elif "paseo" in d: - return "Checking Paseo CLI agents." - elif "python" in d or "sh " in d or "bash" in d or "$" in d: - return "Running shell command." - elif "patch" in d or "edit" in d or "write" in d: - return "Updating project files." - return None - - def _clean_spoken_text(text: str) -> str: """Clean text for speech output and truncate fake turn generations.""" if not text: @@ -165,15 +147,20 @@ class HermesLLM(FrameProcessor): self._use_server = use_server self._session_renamed = False + # 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 _load_session_id(self) -> str | None: - if SESSION_STATE_FILE.exists(): + if self._session_state_file.exists(): try: - data = json.loads(SESSION_STATE_FILE.read_text()) + data = json.loads(self._session_state_file.read_text()) sid = data.get("session_id") if sid and isinstance(sid, str): return sid.strip() @@ -183,13 +170,13 @@ class HermesLLM(FrameProcessor): def _save_session_id(self): try: - SESSION_STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + self._session_state_file.parent.mkdir(parents=True, exist_ok=True) if self._session_id: - SESSION_STATE_FILE.write_text( + self._session_state_file.write_text( json.dumps({"session_id": self._session_id}, indent=2) + "\n" ) - elif SESSION_STATE_FILE.exists(): - SESSION_STATE_FILE.unlink() + 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}") @@ -312,6 +299,10 @@ class HermesLLM(FrameProcessor): else: await self._run_turn_cli(utterance, chunks) + # The transport-specific runners may fall back from one to the other; + # emit exactly one response terminator for the whole turn. + await self.push_frame(LLMFullResponseEndFrame()) + full_reply = _clean_spoken_text(" ".join(chunks)) if full_reply: self._history.append({"role": "assistant", "content": full_reply}) @@ -359,9 +350,6 @@ class HermesLLM(FrameProcessor): except Exception as e: logger.warning(f"Hermes server error ({e}), falling back to CLI...") await self._run_turn_cli(utterance, chunks) - finally: - await self.push_frame(LLMFullResponseEndFrame()) - 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"] @@ -382,8 +370,6 @@ class HermesLLM(FrameProcessor): env=env, ) - spoken_tools = set() - async def _read_stderr(stream): while True: line = await stream.readline() @@ -403,10 +389,8 @@ class HermesLLM(FrameProcessor): except Exception: pass - phrase = _get_tool_spoken_phrase(cleaned) - if phrase and phrase not in spoken_tools: - spoken_tools.add(phrase) - await self.push_frame(TTSSpeakFrame(phrase)) + # 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)) @@ -456,5 +440,3 @@ class HermesLLM(FrameProcessor): err_msg = "Sorry, I ran into an error generating a response." chunks.append(err_msg) await self.push_frame(LLMTextFrame(err_msg)) - finally: - await self.push_frame(LLMFullResponseEndFrame()) diff --git a/model_manager.py b/model_manager.py index 17c4beb..639a758 100644 --- a/model_manager.py +++ b/model_manager.py @@ -57,6 +57,7 @@ MODEL_ALIASES = { "hermes": "hermes-3", "hermes3": "hermes-3", "hermes-agent": "hermes-agent", + "deepseek": "ollama-cloud/deepseek-v4-flash", "sonnet": "claude-sonnet-4-6", "claude": "claude-sonnet-4-6", "opus": "claude-opus-4-6",