"""SuperTonic Gateway — nanobot integration for the web UI. Connects to the already-running nanobot process via a Unix domain socket. nanobot must be started separately (e.g. ``nanobot gateway``) with the API channel enabled in its config. Wire protocol (newline-delimited JSON) --------------------------------------- Client → nanobot:: {"type": "message", "content": "hello", "chat_id": "web"} {"type": "ping"} {"type": "ui-response", "request_id": "", "value": "Option A", "chat_id": "web"} {"type": "command", "command": "reset", "chat_id": "web"} nanobot → client:: {"type": "message", "content": "Hi!", "chat_id": "web", "is_progress": false} {"type": "agent_state", "state": "thinking", "chat_id": "web"} {"type": "toast", "kind": "text"|"image", "content": "...", "title": "...", "duration_ms": 5000} {"type": "choice", "request_id": "", "question": "...", "choices": ["A", "B"], "title": "...", "chat_id": "web"} {"type": "pong"} {"type": "error", "error": "..."} The public ``SuperTonicGateway`` interface (``spawn_tui``, ``send_user_message``, ``stop_tui``, ``shutdown``) is unchanged so ``app.py`` and ``voice_rtc.py`` require no modification. """ from __future__ import annotations import asyncio import json import os from pathlib import Path from wisper import WisperBus, WisperEvent # Default path — must match nanobot's channels.api.socket_path config value. DEFAULT_SOCKET_PATH = Path.home() / ".nanobot" / "api.sock" # --------------------------------------------------------------------------- # NanobotApiProcess — connects to the running nanobot via its Unix socket # --------------------------------------------------------------------------- class NanobotApiProcess: """Connects to the running nanobot process via its Unix domain socket. Lifecycle --------- ``start()`` — opens a connection to nanobot's API socket. ``send()`` — writes a user message over the socket. ``stop()`` — closes the connection. """ def __init__(self, bus: WisperBus, socket_path: Path) -> None: self._bus = bus self._socket_path = socket_path self._reader: asyncio.StreamReader | None = None self._writer: asyncio.StreamWriter | None = None self._read_task: asyncio.Task | None = None @property def running(self) -> bool: return ( self._writer is not None and not self._writer.is_closing() and self._read_task is not None and not self._read_task.done() ) async def start(self) -> None: if self.running: await self._bus.publish( WisperEvent(role="system", text="Already connected to nanobot.") ) return if not self._socket_path.exists(): await self._bus.publish( WisperEvent( role="system", text=( f"Nanobot API socket not found at {self._socket_path}. " "Make sure nanobot is running with the API channel enabled " "(set channels.api.enabled = true in ~/.nanobot/config.json, " "then run: nanobot gateway)." ), ) ) return try: self._reader, self._writer = await asyncio.open_unix_connection( path=str(self._socket_path) ) except OSError as exc: await self._bus.publish( WisperEvent( role="system", text=f"Could not connect to nanobot API socket: {exc}", ) ) return self._read_task = asyncio.create_task(self._read_loop(), name="nanobot-api-reader") await self._bus.publish(WisperEvent(role="system", text="Connected to nanobot.")) async def send(self, text: str) -> None: if not self.running or self._writer is None: await self._bus.publish( WisperEvent( role="system", text="Not connected to nanobot. Click spawn first.", ) ) return payload = json.dumps({"type": "message", "content": text, "chat_id": "web"}) + "\n" try: self._writer.write(payload.encode()) await self._writer.drain() except OSError as exc: await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}")) await self._cleanup() async def send_ui_response(self, request_id: str, value: str) -> None: """Forward a ui-response (choice selection) back to nanobot.""" if not self.running or self._writer is None: return payload = ( json.dumps( {"type": "ui-response", "request_id": request_id, "value": value, "chat_id": "web"} ) + "\n" ) try: self._writer.write(payload.encode()) await self._writer.drain() except OSError as exc: await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}")) await self._cleanup() async def send_command(self, command: str) -> None: """Send a command (e.g. 'reset') to nanobot.""" if not self.running or self._writer is None: await self._bus.publish( WisperEvent( role="system", text="Not connected to nanobot. Click spawn first.", ) ) return payload = json.dumps({"type": "command", "command": command, "chat_id": "web"}) + "\n" try: self._writer.write(payload.encode()) await self._writer.drain() except OSError as exc: await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}")) await self._cleanup() async def stop(self) -> None: await self._cleanup() await self._bus.publish(WisperEvent(role="system", text="Disconnected from nanobot.")) # ------------------------------------------------------------------ # Internal # ------------------------------------------------------------------ async def _cleanup(self) -> None: if self._read_task and not self._read_task.done(): self._read_task.cancel() try: await self._read_task except asyncio.CancelledError: pass self._read_task = None if self._writer: try: self._writer.close() await self._writer.wait_closed() except OSError: pass self._writer = None self._reader = None async def _read_loop(self) -> None: """Read newline-delimited JSON from nanobot and publish WisperEvents.""" assert self._reader is not None try: while True: try: line = await self._reader.readline() except OSError: break if not line: break # EOF — nanobot closed the connection await self._handle_line(line) finally: await self._bus.publish( WisperEvent(role="system", text="Nanobot closed the connection.") ) # Clear writer so running → False self._writer = None self._reader = None async def _handle_line(self, line: bytes) -> None: raw = line.decode(errors="replace").strip() if not raw: return try: obj = json.loads(raw) except json.JSONDecodeError: await self._bus.publish( WisperEvent(role="system", text=f"Malformed response from nanobot: {raw[:200]}") ) return msg_type = str(obj.get("type", "")) if msg_type == "message": content = str(obj.get("content", "")) is_progress = bool(obj.get("is_progress", False)) if is_progress: # Intermediate tool-call hint — show in UI, skip TTS await self._bus.publish(WisperEvent(role="nanobot-progress", text=content)) else: # Final answer — display + TTS await self._bus.publish(WisperEvent(role="nanobot", text=content)) await self._bus.publish(WisperEvent(role="nanobot-tts", text=content)) elif msg_type == "agent_state": state = str(obj.get("state", "")) await self._bus.publish(WisperEvent(role="agent-state", text=state)) elif msg_type == "toast": # Forward the full toast payload as JSON so the frontend can render it. await self._bus.publish(WisperEvent(role="toast", text=json.dumps(obj))) elif msg_type == "choice": # Forward the full choice payload as JSON so the frontend can render it. await self._bus.publish(WisperEvent(role="choice", text=json.dumps(obj))) elif msg_type == "pong": pass # keepalive, ignore elif msg_type == "error": await self._bus.publish( WisperEvent(role="system", text=f"Nanobot error: {obj.get('error', '')}") ) # --------------------------------------------------------------------------- # SuperTonicGateway — public interface (unchanged from original) # --------------------------------------------------------------------------- class SuperTonicGateway: def __init__(self) -> None: self.bus = WisperBus() self._lock = asyncio.Lock() self._process: NanobotApiProcess | None = None socket_path = Path(os.getenv("NANOBOT_API_SOCKET", str(DEFAULT_SOCKET_PATH))).expanduser() self._socket_path = socket_path 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: """Connect to nanobot (name kept for API compatibility with app.py).""" async with self._lock: if self._process and self._process.running: await self.bus.publish( WisperEvent(role="system", text="Already connected to nanobot.") ) return self._process = NanobotApiProcess(bus=self.bus, socket_path=self._socket_path) await self._process.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._process: await self.bus.publish( WisperEvent( role="system", text="Not connected to nanobot. Click spawn first.", ) ) return await self._process.send(message) async def send_ui_response(self, request_id: str, value: str) -> None: """Forward a choice selection back to nanobot.""" async with self._lock: if self._process: await self._process.send_ui_response(request_id, value) async def send_command(self, command: str) -> None: """Send a command (e.g. 'reset') to nanobot.""" async with self._lock: if self._process: await self._process.send_command(command) async def stop_tui(self) -> None: """Disconnect from nanobot (name kept for API compatibility with app.py).""" async with self._lock: if self._process: await self._process.stop() self._process = None async def shutdown(self) -> None: await self.stop_tui()