import asyncio import contextlib import os import pty import re import shlex import signal import subprocess import time from collections import deque from pathlib import Path from wisper import WisperBus, WisperEvent ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]") BRAILLE_SPINNER_RE = re.compile(r"[\u2800-\u28ff]") SPINNER_ONLY_RE = re.compile(r"^[\s|/\\\-]+$") BOX_DRAWING_ONLY_RE = re.compile(r"^[\s\u2500-\u257f]+$") THINKING_LINE_RE = re.compile( r"\b(?:agent|nanobot|napbot)\b(?:\s+is)?\s+thinking\b", re.IGNORECASE, ) USER_ECHO_LINE_RE = re.compile(r"^(?:you|user)\s*:", re.IGNORECASE) TOOL_STREAM_LINE_RE = re.compile( r"^(?:tool(?:\s+call|\s+output)?|calling\s+tool|running\s+tool|executing\s+tool)\b", re.IGNORECASE, ) LEADING_NON_WORD_RE = re.compile(r"^[^\w]+") WHITESPACE_RE = re.compile(r"\s+") AGENT_OUTPUT_PREFIX_RE = re.compile( r"^(?:nanobot|napbot)\b\s*[:>\-]?\s*", re.IGNORECASE ) EMOJI_RE = re.compile( "[" # Common emoji and pictograph blocks. "\U0001f1e6-\U0001f1ff" "\U0001f300-\U0001f5ff" "\U0001f600-\U0001f64f" "\U0001f680-\U0001f6ff" "\U0001f700-\U0001f77f" "\U0001f780-\U0001f7ff" "\U0001f800-\U0001f8ff" "\U0001f900-\U0001f9ff" "\U0001fa00-\U0001faff" "\u2600-\u26ff" "\u2700-\u27bf" "\ufe0f" "\u200d" "]" ) def _clean_output(text: str) -> str: cleaned = ANSI_ESCAPE_RE.sub("", text) cleaned = BRAILLE_SPINNER_RE.sub(" ", cleaned) cleaned = CONTROL_CHAR_RE.sub("", cleaned) return cleaned.replace("\r", "\n") def _resolve_nanobot_command_and_workdir() -> tuple[str, Path]: command_override = os.getenv("NANOBOT_COMMAND") workdir_override = os.getenv("NANOBOT_WORKDIR") if workdir_override: default_workdir = Path(workdir_override).expanduser() else: default_workdir = Path.home() if command_override: return command_override, default_workdir nanobot_dir = Path.home() / "nanobot" nanobot_python_candidates = [ nanobot_dir / ".venv" / "bin" / "python", nanobot_dir / "venv" / "bin" / "python", ] for nanobot_venv_python in nanobot_python_candidates: if nanobot_venv_python.exists(): if not workdir_override: default_workdir = nanobot_dir return ( f"{nanobot_venv_python} -m nanobot agent --no-markdown", default_workdir, ) return "nanobot agent --no-markdown", default_workdir def _infer_venv_root(command_parts: list[str], workdir: Path) -> Path | None: if not command_parts: return None binary = Path(command_parts[0]).expanduser() if ( binary.is_absolute() and binary.name.startswith("python") and binary.parent.name == "bin" ): return binary.parent.parent for candidate in (workdir / ".venv", workdir / "venv"): if (candidate / "bin" / "python").exists(): return candidate return None def _build_process_env( command_parts: list[str], workdir: Path ) -> tuple[dict[str, str], Path | None]: env = os.environ.copy() env.pop("PYTHONHOME", None) venv_root = _infer_venv_root(command_parts, workdir) if not venv_root: return env, None venv_bin = str(venv_root / "bin") path_entries = [entry for entry in env.get("PATH", "").split(os.pathsep) if entry] path_entries = [entry for entry in path_entries if entry != venv_bin] path_entries.insert(0, venv_bin) env["PATH"] = os.pathsep.join(path_entries) env["VIRTUAL_ENV"] = str(venv_root) return env, venv_root class NanobotTUIProcess: def __init__(self, bus: WisperBus, command: str, workdir: Path) -> None: self._bus = bus self._command = command self._workdir = workdir self._process: subprocess.Popen[bytes] | None = None self._master_fd: int | None = None self._read_task: asyncio.Task[None] | None = None self._pending_output = "" self._suppress_noisy_ui = os.getenv( "NANOBOT_SUPPRESS_NOISY_UI", "1" ).strip() not in { "0", "false", "False", "no", "off", } self._dedup_window_s = max( 0.2, float(os.getenv("NANOBOT_OUTPUT_DEDUP_WINDOW_S", "1.5")) ) self._recent_lines: deque[tuple[str, float]] = deque() self._last_tts_line = "" @property def running(self) -> bool: return self._process is not None and self._process.poll() is None async def start(self) -> None: if self.running: await self._bus.publish( WisperEvent(role="system", text="Nanobot TUI is already running.") ) return command_parts = [ os.path.expandvars(os.path.expanduser(part)) for part in shlex.split(self._command) ] if not command_parts: await self._bus.publish( WisperEvent(role="system", text="NANOBOT_COMMAND is empty.") ) return if not self._workdir.exists(): await self._bus.publish( WisperEvent( role="system", text=f"NANOBOT_WORKDIR does not exist: {self._workdir}", ) ) return master_fd, slave_fd = pty.openpty() child_env, child_venv_root = _build_process_env( command_parts=command_parts, workdir=self._workdir ) try: self._process = subprocess.Popen( command_parts, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, cwd=str(self._workdir), start_new_session=True, env=child_env, ) except FileNotFoundError as exc: os.close(master_fd) os.close(slave_fd) await self._bus.publish( WisperEvent( role="system", text=( "Could not start Nanobot process " f"(command='{command_parts[0]}', workdir='{self._workdir}'): {exc}. " "Check NANOBOT_COMMAND and NANOBOT_WORKDIR." ), ) ) return except Exception as exc: os.close(master_fd) os.close(slave_fd) await self._bus.publish( WisperEvent(role="system", text=f"Failed to spawn TUI process: {exc}") ) return os.close(slave_fd) os.set_blocking(master_fd, False) self._master_fd = master_fd self._read_task = asyncio.create_task( self._read_output(), name="nanobot-tui-reader" ) await self._bus.publish( WisperEvent( role="system", text=f"Spawned Nanobot TUI with command: {' '.join(command_parts)}", ) ) if child_venv_root: await self._bus.publish( WisperEvent( role="system", text=f"Nanobot runtime venv: {child_venv_root}", ) ) async def send(self, text: str) -> None: if not self.running or self._master_fd is None: await self._bus.publish( WisperEvent( role="system", text="Nanobot TUI is not running. Click spawn first." ) ) return message = text.rstrip("\n") + "\n" try: os.write(self._master_fd, message.encode()) except OSError as exc: await self._bus.publish( WisperEvent(role="system", text=f"Failed to write to TUI: {exc}") ) async def stop(self) -> None: if self._read_task: self._read_task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._read_task self._read_task = None if self.running and self._process: try: os.killpg(self._process.pid, signal.SIGTERM) except ProcessLookupError: pass except Exception: self._process.terminate() try: self._process.wait(timeout=3) except Exception: self._process.kill() self._process.wait(timeout=1) if self._master_fd is not None: try: os.close(self._master_fd) except OSError: pass self._master_fd = None self._process = None self._pending_output = "" self._recent_lines.clear() self._last_tts_line = "" await self._bus.publish(WisperEvent(role="system", text="Stopped Nanobot TUI.")) async def _read_output(self) -> None: if self._master_fd is None: return while self.running: if not await self._wait_for_fd_readable(): break try: chunk = os.read(self._master_fd, 4096) except BlockingIOError: continue except OSError: break if not chunk: if not self.running: break await asyncio.sleep(0.01) continue text = _clean_output(chunk.decode(errors="ignore")) if not text.strip(): continue displayable, tts_publishable, saw_thinking = self._consume_output_chunk( text ) if saw_thinking: await self._bus.publish( WisperEvent(role="agent-state", text="thinking") ) if displayable: await self._bus.publish(WisperEvent(role="nanobot", text=displayable)) if tts_publishable: await self._bus.publish( WisperEvent(role="nanobot-tts", text=tts_publishable) ) trailing_display, trailing_tts, _ = self._consume_output_chunk("\n") if trailing_display: await self._bus.publish(WisperEvent(role="nanobot", text=trailing_display)) if trailing_tts: await self._bus.publish(WisperEvent(role="nanobot-tts", text=trailing_tts)) if self._process is not None: exit_code = self._process.poll() await self._bus.publish( WisperEvent( role="system", text=f"Nanobot TUI exited (code={exit_code})." ) ) def _consume_output_chunk(self, text: str) -> tuple[str, str, bool]: """Return (displayable, tts_publishable, saw_thinking).""" self._pending_output += text lines = self._pending_output.split("\n") self._pending_output = lines.pop() if len(self._pending_output) > 1024: lines.append(self._pending_output) self._pending_output = "" kept_lines: list[str] = [] tts_lines: list[str] = [] saw_thinking = False for line in lines: normalized = self._normalize_line(line) if not normalized: continue if self._suppress_noisy_ui and self._is_noisy_ui_line(normalized): # Detect thinking lines even though they are filtered from display. candidate = LEADING_NON_WORD_RE.sub("", normalized) if THINKING_LINE_RE.search(candidate): saw_thinking = True continue if normalized != self._last_tts_line: tts_lines.append(normalized) self._last_tts_line = normalized if self._is_recent_duplicate(normalized): continue kept_lines.append(normalized) return "\n".join(kept_lines).strip(), "\n".join(tts_lines).strip(), saw_thinking def _normalize_line(self, line: str) -> str: without_emoji = EMOJI_RE.sub(" ", line) normalized = WHITESPACE_RE.sub(" ", without_emoji).strip() # Strip leading "nanobot:" prefix that the TUI echoes in its own output, # since the frontend already labels lines with the role name and TTS # should not read the agent's own name aloud. normalized = AGENT_OUTPUT_PREFIX_RE.sub("", normalized) return normalized def _is_noisy_ui_line(self, line: str) -> bool: if SPINNER_ONLY_RE.fullmatch(line): return True if BOX_DRAWING_ONLY_RE.fullmatch(line): return True candidate = LEADING_NON_WORD_RE.sub("", line) if THINKING_LINE_RE.search(candidate): return True if TOOL_STREAM_LINE_RE.match(candidate): return True if USER_ECHO_LINE_RE.match(candidate): return True return False async def _wait_for_fd_readable(self) -> bool: if self._master_fd is None: return False loop = asyncio.get_running_loop() ready: asyncio.Future[None] = loop.create_future() def _mark_ready() -> None: if not ready.done(): ready.set_result(None) try: loop.add_reader(self._master_fd, _mark_ready) except (AttributeError, NotImplementedError, OSError, ValueError): await asyncio.sleep(0.01) return True try: await ready return True finally: with contextlib.suppress(Exception): loop.remove_reader(self._master_fd) def _is_recent_duplicate(self, line: str) -> bool: now = time.monotonic() normalized = line.lower() while ( self._recent_lines and (now - self._recent_lines[0][1]) > self._dedup_window_s ): self._recent_lines.popleft() for previous, _timestamp in self._recent_lines: if previous == normalized: return True self._recent_lines.append((normalized, now)) return False class SuperTonicGateway: def __init__(self) -> None: self.bus = WisperBus() self._lock = asyncio.Lock() self._tui: NanobotTUIProcess | None = None async def subscribe(self) -> asyncio.Queue[WisperEvent]: return await self.bus.subscribe() async def unsubscribe(self, queue: asyncio.Queue[WisperEvent]) -> None: await self.bus.unsubscribe(queue) async def spawn_tui(self) -> None: async with self._lock: if self._tui and self._tui.running: await self.bus.publish( WisperEvent(role="system", text="Nanobot TUI is already running.") ) return command, workdir = _resolve_nanobot_command_and_workdir() self._tui = NanobotTUIProcess( bus=self.bus, command=command, workdir=workdir ) await self._tui.start() async def send_user_message(self, text: str) -> None: message = text.strip() if not message: return await self.bus.publish(WisperEvent(role="user", text=message)) async with self._lock: if not self._tui: await self.bus.publish( WisperEvent( role="system", text="Nanobot TUI is not running. Click spawn first.", ) ) return await self._tui.send(message) async def stop_tui(self) -> None: async with self._lock: if self._tui: await self._tui.stop() async def shutdown(self) -> None: await self.stop_tui()