2026-03-12 09:25:15 -04:00
|
|
|
"""SuperTonic Gateway - nanobot integration for the web UI.
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-05 15:10:14 -05:00
|
|
|
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.
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
Wire protocol (newline-delimited JSON-RPC 2.0)
|
|
|
|
|
-----------------------------------------------
|
|
|
|
|
Client -> nanobot notifications::
|
|
|
|
|
|
|
|
|
|
{"jsonrpc": "2.0", "method": "message.send",
|
|
|
|
|
"params": {"content": "hello", "chat_id": "web", "metadata": {}}}
|
|
|
|
|
{"jsonrpc": "2.0", "method": "card.respond",
|
|
|
|
|
"params": {"card_id": "card_123", "value": "Option A"}}
|
|
|
|
|
{"jsonrpc": "2.0", "method": "command.execute",
|
|
|
|
|
"params": {"command": "reset", "chat_id": "web"}}
|
|
|
|
|
|
|
|
|
|
nanobot -> client notifications::
|
|
|
|
|
|
|
|
|
|
{"jsonrpc": "2.0", "method": "message",
|
|
|
|
|
"params": {"content": "Hi!", "chat_id": "web", "is_progress": false}}
|
|
|
|
|
{"jsonrpc": "2.0", "method": "agent_state",
|
|
|
|
|
"params": {"state": "thinking", "chat_id": "web"}}
|
|
|
|
|
{"jsonrpc": "2.0", "method": "card",
|
|
|
|
|
"params": {"id": "card_123", "kind": "text", "title": "Weather", "lane": "context"}}
|
2026-03-05 15:10:14 -05:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2026-03-24 08:54:47 -04:00
|
|
|
import contextlib
|
2026-03-05 15:10:14 -05:00
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
2026-03-24 08:54:47 -04:00
|
|
|
from typing import Any, Awaitable, Callable
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-05 15:10:14 -05:00
|
|
|
from wisper import WisperBus, WisperEvent
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-05 15:10:14 -05:00
|
|
|
DEFAULT_SOCKET_PATH = Path.home() / ".nanobot" / "api.sock"
|
2026-03-12 09:25:15 -04:00
|
|
|
_JSONRPC_VERSION = "2.0"
|
2026-02-28 22:12:04 -05:00
|
|
|
|
|
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
def _encode(obj: dict[str, Any]) -> bytes:
|
|
|
|
|
return (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")
|
2026-02-28 22:12:04 -05:00
|
|
|
|
|
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
def _jsonrpc_notification(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
|
|
|
payload: dict[str, Any] = {
|
|
|
|
|
"jsonrpc": _JSONRPC_VERSION,
|
|
|
|
|
"method": method,
|
|
|
|
|
}
|
|
|
|
|
if params is not None:
|
|
|
|
|
payload["params"] = params
|
|
|
|
|
return payload
|
|
|
|
|
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
class NanobotApiProcess:
|
|
|
|
|
"""Connects to the running nanobot process via its Unix domain socket."""
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-24 08:54:47 -04:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
bus: WisperBus,
|
|
|
|
|
socket_path: Path,
|
|
|
|
|
on_disconnect: Callable[[], Awaitable[None]] | None = None,
|
|
|
|
|
) -> None:
|
2026-02-28 22:12:04 -05:00
|
|
|
self._bus = bus
|
2026-03-05 15:10:14 -05:00
|
|
|
self._socket_path = socket_path
|
2026-03-24 08:54:47 -04:00
|
|
|
self._on_disconnect = on_disconnect
|
2026-03-05 15:10:14 -05:00
|
|
|
self._reader: asyncio.StreamReader | None = None
|
|
|
|
|
self._writer: asyncio.StreamWriter | None = None
|
|
|
|
|
self._read_task: asyncio.Task | None = None
|
2026-03-14 12:10:39 -04:00
|
|
|
self._socket_inode: int | None = None
|
2026-03-24 08:54:47 -04:00
|
|
|
self._streaming_partial_response = False
|
|
|
|
|
self._closing = False
|
2026-02-28 22:12:04 -05:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def running(self) -> bool:
|
2026-03-05 15:10:14 -05:00
|
|
|
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()
|
|
|
|
|
)
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-14 12:10:39 -04:00
|
|
|
def matches_current_socket(self) -> bool:
|
|
|
|
|
if self._socket_inode is None:
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
return self._socket_path.stat().st_ino == self._socket_inode
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return False
|
|
|
|
|
except OSError:
|
|
|
|
|
return False
|
|
|
|
|
|
2026-02-28 22:12:04 -05:00
|
|
|
async def start(self) -> None:
|
|
|
|
|
if self.running:
|
2026-03-12 09:25:15 -04:00
|
|
|
await self._bus.publish(WisperEvent(role="system", text="Already connected to nanobot."))
|
2026-02-28 22:12:04 -05:00
|
|
|
return
|
|
|
|
|
|
2026-03-24 08:54:47 -04:00
|
|
|
self._closing = False
|
|
|
|
|
self._streaming_partial_response = False
|
2026-03-05 15:10:14 -05:00
|
|
|
if not self._socket_path.exists():
|
2026-02-28 22:12:04 -05:00
|
|
|
await self._bus.publish(
|
|
|
|
|
WisperEvent(
|
|
|
|
|
role="system",
|
2026-03-05 15:10:14 -05:00
|
|
|
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)."
|
|
|
|
|
),
|
2026-02-28 22:12:04 -05:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
2026-03-05 15:10:14 -05:00
|
|
|
self._reader, self._writer = await asyncio.open_unix_connection(
|
|
|
|
|
path=str(self._socket_path)
|
2026-02-28 22:12:04 -05:00
|
|
|
)
|
2026-03-14 12:10:39 -04:00
|
|
|
self._socket_inode = self._socket_path.stat().st_ino
|
2026-03-05 15:10:14 -05:00
|
|
|
except OSError as exc:
|
2026-02-28 22:12:04 -05:00
|
|
|
await self._bus.publish(
|
2026-03-12 09:25:15 -04:00
|
|
|
WisperEvent(role="system", text=f"Could not connect to nanobot API socket: {exc}")
|
2026-02-28 22:12:04 -05:00
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
2026-03-05 15:10:14 -05:00
|
|
|
self._read_task = asyncio.create_task(self._read_loop(), name="nanobot-api-reader")
|
|
|
|
|
await self._bus.publish(WisperEvent(role="system", text="Connected to nanobot."))
|
|
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
async def send(self, text: str, metadata: dict[str, Any] | None = None) -> None:
|
2026-03-05 15:10:14 -05:00
|
|
|
if not self.running or self._writer is None:
|
2026-03-12 09:25:15 -04:00
|
|
|
await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
|
2026-03-14 12:10:39 -04:00
|
|
|
raise RuntimeError("Not connected to nanobot.")
|
2026-03-05 15:10:14 -05:00
|
|
|
try:
|
2026-03-12 09:25:15 -04:00
|
|
|
await self._send_notification(
|
|
|
|
|
"message.send",
|
|
|
|
|
{
|
|
|
|
|
"content": text,
|
|
|
|
|
"chat_id": "web",
|
|
|
|
|
"metadata": dict(metadata or {}),
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-03-05 15:10:14 -05:00
|
|
|
except OSError as exc:
|
|
|
|
|
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
|
|
|
|
|
await self._cleanup()
|
2026-03-14 12:10:39 -04:00
|
|
|
raise RuntimeError(f"Send failed: {exc}") from exc
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
async def send_card_response(self, card_id: str, value: str) -> None:
|
2026-03-05 15:10:14 -05:00
|
|
|
if not self.running or self._writer is None:
|
2026-03-14 12:10:39 -04:00
|
|
|
raise RuntimeError("Not connected to nanobot.")
|
2026-03-05 15:10:14 -05:00
|
|
|
try:
|
2026-03-12 09:25:15 -04:00
|
|
|
await self._send_notification(
|
|
|
|
|
"card.respond",
|
|
|
|
|
{
|
|
|
|
|
"card_id": card_id,
|
|
|
|
|
"value": value,
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-03-05 15:10:14 -05:00
|
|
|
except OSError as exc:
|
|
|
|
|
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
|
|
|
|
|
await self._cleanup()
|
2026-03-14 12:10:39 -04:00
|
|
|
raise RuntimeError(f"Send failed: {exc}") from exc
|
2026-03-05 15:10:14 -05:00
|
|
|
|
|
|
|
|
async def send_command(self, command: str) -> None:
|
|
|
|
|
if not self.running or self._writer is None:
|
2026-03-12 09:25:15 -04:00
|
|
|
await self._bus.publish(WisperEvent(role="system", text="Not connected to nanobot."))
|
2026-03-14 12:10:39 -04:00
|
|
|
raise RuntimeError("Not connected to nanobot.")
|
2026-02-28 22:12:04 -05:00
|
|
|
try:
|
2026-03-12 09:25:15 -04:00
|
|
|
await self._send_notification(
|
|
|
|
|
"command.execute",
|
|
|
|
|
{
|
|
|
|
|
"command": command,
|
|
|
|
|
"chat_id": "web",
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-02-28 22:12:04 -05:00
|
|
|
except OSError as exc:
|
2026-03-05 15:10:14 -05:00
|
|
|
await self._bus.publish(WisperEvent(role="system", text=f"Send failed: {exc}"))
|
|
|
|
|
await self._cleanup()
|
2026-03-14 12:10:39 -04:00
|
|
|
raise RuntimeError(f"Send failed: {exc}") from exc
|
2026-02-28 22:12:04 -05:00
|
|
|
|
|
|
|
|
async def stop(self) -> None:
|
2026-03-05 15:10:14 -05:00
|
|
|
await self._cleanup()
|
|
|
|
|
await self._bus.publish(WisperEvent(role="system", text="Disconnected from nanobot."))
|
|
|
|
|
|
|
|
|
|
async def _cleanup(self) -> None:
|
2026-03-24 08:54:47 -04:00
|
|
|
self._closing = True
|
2026-03-05 15:10:14 -05:00
|
|
|
if self._read_task and not self._read_task.done():
|
|
|
|
|
self._read_task.cancel()
|
2026-02-28 22:12:04 -05:00
|
|
|
try:
|
2026-03-05 15:10:14 -05:00
|
|
|
await self._read_task
|
|
|
|
|
except asyncio.CancelledError:
|
2026-02-28 22:12:04 -05:00
|
|
|
pass
|
2026-03-05 15:10:14 -05:00
|
|
|
self._read_task = None
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-05 15:10:14 -05:00
|
|
|
if self._writer:
|
2026-02-28 22:12:04 -05:00
|
|
|
try:
|
2026-03-05 15:10:14 -05:00
|
|
|
self._writer.close()
|
|
|
|
|
await self._writer.wait_closed()
|
2026-02-28 22:12:04 -05:00
|
|
|
except OSError:
|
|
|
|
|
pass
|
2026-03-05 15:10:14 -05:00
|
|
|
self._writer = None
|
|
|
|
|
self._reader = None
|
2026-03-14 12:10:39 -04:00
|
|
|
self._socket_inode = None
|
2026-03-24 08:54:47 -04:00
|
|
|
self._streaming_partial_response = False
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
|
|
|
assert self._writer is not None
|
|
|
|
|
self._writer.write(_encode(_jsonrpc_notification(method, params)))
|
|
|
|
|
await self._writer.drain()
|
|
|
|
|
|
2026-03-05 15:10:14 -05:00
|
|
|
async def _read_loop(self) -> None:
|
|
|
|
|
assert self._reader is not None
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
line = await self._reader.readline()
|
|
|
|
|
except OSError:
|
2026-03-04 08:20:42 -05:00
|
|
|
break
|
2026-03-05 15:10:14 -05:00
|
|
|
if not line:
|
2026-03-12 09:25:15 -04:00
|
|
|
break
|
2026-03-05 15:10:14 -05:00
|
|
|
await self._handle_line(line)
|
|
|
|
|
finally:
|
2026-03-24 08:54:47 -04:00
|
|
|
should_notify_disconnect = not self._closing
|
|
|
|
|
self._streaming_partial_response = False
|
2026-03-05 15:10:14 -05:00
|
|
|
self._writer = None
|
|
|
|
|
self._reader = None
|
2026-03-24 08:54:47 -04:00
|
|
|
if should_notify_disconnect:
|
|
|
|
|
await self._bus.publish(
|
|
|
|
|
WisperEvent(role="system", text="Nanobot closed the connection.")
|
|
|
|
|
)
|
|
|
|
|
if self._on_disconnect is not None:
|
|
|
|
|
asyncio.create_task(
|
|
|
|
|
self._on_disconnect(),
|
|
|
|
|
name="nanobot-api-reconnect-trigger",
|
|
|
|
|
)
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-05 15:10:14 -05:00
|
|
|
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:
|
2026-02-28 22:12:04 -05:00
|
|
|
await self._bus.publish(
|
2026-03-05 15:10:14 -05:00
|
|
|
WisperEvent(role="system", text=f"Malformed response from nanobot: {raw[:200]}")
|
2026-02-28 22:12:04 -05:00
|
|
|
)
|
2026-03-05 15:10:14 -05:00
|
|
|
return
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
if not isinstance(obj, dict) or obj.get("jsonrpc") != _JSONRPC_VERSION:
|
|
|
|
|
await self._bus.publish(
|
|
|
|
|
WisperEvent(role="system", text=f"Malformed response from nanobot: {raw[:200]}")
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if "method" not in obj:
|
|
|
|
|
error = obj.get("error")
|
|
|
|
|
if isinstance(error, dict):
|
|
|
|
|
message = str(error.get("message", "Unknown JSON-RPC error"))
|
|
|
|
|
await self._bus.publish(WisperEvent(role="system", text=f"Nanobot error: {message}"))
|
|
|
|
|
return
|
2026-03-04 08:20:42 -05:00
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
method = str(obj.get("method", "")).strip()
|
|
|
|
|
params = obj.get("params", {})
|
|
|
|
|
if params is None or not isinstance(params, dict):
|
|
|
|
|
params = {}
|
|
|
|
|
|
|
|
|
|
if method == "message":
|
|
|
|
|
content = str(params.get("content", ""))
|
|
|
|
|
is_progress = bool(params.get("is_progress", False))
|
|
|
|
|
is_tool_hint = bool(params.get("is_tool_hint", False))
|
2026-03-24 08:54:47 -04:00
|
|
|
is_partial = bool(params.get("is_partial", False))
|
2026-03-05 15:10:14 -05:00
|
|
|
if is_progress:
|
2026-03-24 08:54:47 -04:00
|
|
|
if is_partial:
|
|
|
|
|
self._streaming_partial_response = True
|
|
|
|
|
await self._bus.publish(WisperEvent(role="nanobot-tts-partial", text=content))
|
|
|
|
|
return
|
2026-03-12 09:25:15 -04:00
|
|
|
role = "nanobot-tool" if is_tool_hint else "nanobot-progress"
|
|
|
|
|
await self._bus.publish(WisperEvent(role=role, text=content))
|
2026-03-05 15:10:14 -05:00
|
|
|
else:
|
|
|
|
|
await self._bus.publish(WisperEvent(role="nanobot", text=content))
|
2026-03-24 08:54:47 -04:00
|
|
|
if self._streaming_partial_response:
|
|
|
|
|
self._streaming_partial_response = False
|
|
|
|
|
await self._bus.publish(WisperEvent(role="nanobot-tts-flush", text=""))
|
|
|
|
|
else:
|
|
|
|
|
await self._bus.publish(WisperEvent(role="nanobot-tts", text=content))
|
2026-03-12 09:25:15 -04:00
|
|
|
elif method == "agent_state":
|
|
|
|
|
state = str(params.get("state", ""))
|
2026-03-05 15:10:14 -05:00
|
|
|
await self._bus.publish(WisperEvent(role="agent-state", text=state))
|
2026-03-12 09:25:15 -04:00
|
|
|
elif method == "card":
|
|
|
|
|
await self._bus.publish(WisperEvent(role="card", text=json.dumps(params)))
|
2026-02-28 22:12:04 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class SuperTonicGateway:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.bus = WisperBus()
|
|
|
|
|
self._lock = asyncio.Lock()
|
2026-03-05 15:10:14 -05:00
|
|
|
self._process: NanobotApiProcess | None = None
|
2026-03-24 08:54:47 -04:00
|
|
|
self._reconnect_task: asyncio.Task[None] | None = None
|
|
|
|
|
self._shutdown = False
|
2026-03-05 15:10:14 -05:00
|
|
|
socket_path = Path(os.getenv("NANOBOT_API_SOCKET", str(DEFAULT_SOCKET_PATH))).expanduser()
|
|
|
|
|
self._socket_path = socket_path
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-24 08:54:47 -04:00
|
|
|
def _new_process(self) -> NanobotApiProcess:
|
|
|
|
|
return NanobotApiProcess(
|
|
|
|
|
bus=self.bus,
|
|
|
|
|
socket_path=self._socket_path,
|
|
|
|
|
on_disconnect=self._schedule_reconnect,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _schedule_reconnect(self) -> None:
|
|
|
|
|
async with self._lock:
|
|
|
|
|
if self._shutdown:
|
|
|
|
|
return
|
|
|
|
|
if self._process and self._process.running:
|
|
|
|
|
return
|
|
|
|
|
if self._reconnect_task and not self._reconnect_task.done():
|
|
|
|
|
return
|
|
|
|
|
self._reconnect_task = asyncio.create_task(
|
|
|
|
|
self._reconnect_loop(),
|
|
|
|
|
name="nanobot-api-reconnect",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _reconnect_loop(self) -> None:
|
|
|
|
|
delay_s = 0.5
|
|
|
|
|
try:
|
|
|
|
|
while not self._shutdown:
|
|
|
|
|
async with self._lock:
|
|
|
|
|
if self._process and self._process.running:
|
|
|
|
|
return
|
|
|
|
|
self._process = self._new_process()
|
|
|
|
|
await self._process.start()
|
|
|
|
|
if self._process.running:
|
|
|
|
|
return
|
|
|
|
|
await asyncio.sleep(delay_s)
|
|
|
|
|
delay_s = min(delay_s * 2.0, 5.0)
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
raise
|
|
|
|
|
finally:
|
|
|
|
|
async with self._lock:
|
|
|
|
|
current_task = asyncio.current_task()
|
|
|
|
|
if self._reconnect_task is current_task:
|
|
|
|
|
self._reconnect_task = None
|
|
|
|
|
|
2026-02-28 22:12:04 -05:00
|
|
|
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)
|
|
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
async def connect_nanobot(self) -> None:
|
2026-02-28 22:12:04 -05:00
|
|
|
async with self._lock:
|
2026-03-24 08:54:47 -04:00
|
|
|
self._shutdown = False
|
2026-03-05 15:10:14 -05:00
|
|
|
if self._process and self._process.running:
|
2026-03-12 09:25:15 -04:00
|
|
|
await self.bus.publish(WisperEvent(role="system", text="Already connected to nanobot."))
|
2026-02-28 22:12:04 -05:00
|
|
|
return
|
2026-03-24 08:54:47 -04:00
|
|
|
if self._reconnect_task and not self._reconnect_task.done():
|
|
|
|
|
self._reconnect_task.cancel()
|
|
|
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
|
|
|
await self._reconnect_task
|
|
|
|
|
self._reconnect_task = None
|
|
|
|
|
self._process = self._new_process()
|
2026-03-05 15:10:14 -05:00
|
|
|
await self._process.start()
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-14 12:10:39 -04:00
|
|
|
async def _ensure_connected_process(self) -> NanobotApiProcess:
|
|
|
|
|
if self._process and self._process.running and self._process.matches_current_socket():
|
|
|
|
|
return self._process
|
|
|
|
|
if self._process:
|
|
|
|
|
await self._process.stop()
|
2026-03-24 08:54:47 -04:00
|
|
|
if self._reconnect_task and not self._reconnect_task.done():
|
|
|
|
|
self._reconnect_task.cancel()
|
|
|
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
|
|
|
await self._reconnect_task
|
|
|
|
|
self._reconnect_task = None
|
|
|
|
|
self._process = self._new_process()
|
2026-03-14 12:10:39 -04:00
|
|
|
await self._process.start()
|
|
|
|
|
if not self._process.running or not self._process.matches_current_socket():
|
|
|
|
|
raise RuntimeError("Not connected to nanobot.")
|
|
|
|
|
return self._process
|
|
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
async def send_user_message(self, text: str, metadata: dict[str, Any] | None = None) -> None:
|
2026-02-28 22:12:04 -05:00
|
|
|
message = text.strip()
|
|
|
|
|
if not message:
|
|
|
|
|
return
|
|
|
|
|
await self.bus.publish(WisperEvent(role="user", text=message))
|
|
|
|
|
async with self._lock:
|
2026-03-14 12:10:39 -04:00
|
|
|
process = await self._ensure_connected_process()
|
|
|
|
|
await process.send(message, metadata=metadata)
|
2026-03-05 15:10:14 -05:00
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
async def send_card_response(self, card_id: str, value: str) -> None:
|
2026-03-05 15:10:14 -05:00
|
|
|
async with self._lock:
|
2026-03-14 12:10:39 -04:00
|
|
|
process = await self._ensure_connected_process()
|
|
|
|
|
await process.send_card_response(card_id, value)
|
2026-03-05 15:10:14 -05:00
|
|
|
|
|
|
|
|
async def send_command(self, command: str) -> None:
|
|
|
|
|
async with self._lock:
|
2026-03-14 12:10:39 -04:00
|
|
|
process = await self._ensure_connected_process()
|
|
|
|
|
await process.send_command(command)
|
2026-02-28 22:12:04 -05:00
|
|
|
|
2026-03-12 09:25:15 -04:00
|
|
|
async def disconnect_nanobot(self) -> None:
|
2026-02-28 22:12:04 -05:00
|
|
|
async with self._lock:
|
2026-03-24 08:54:47 -04:00
|
|
|
self._shutdown = True
|
|
|
|
|
if self._reconnect_task and not self._reconnect_task.done():
|
|
|
|
|
self._reconnect_task.cancel()
|
|
|
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
|
|
|
await self._reconnect_task
|
|
|
|
|
self._reconnect_task = None
|
2026-03-05 15:10:14 -05:00
|
|
|
if self._process:
|
|
|
|
|
await self._process.stop()
|
|
|
|
|
self._process = None
|
2026-02-28 22:12:04 -05:00
|
|
|
|
|
|
|
|
async def shutdown(self) -> None:
|
2026-03-12 09:25:15 -04:00
|
|
|
await self.disconnect_nanobot()
|