feat: polish life os cards and voice stack

This commit is contained in:
kacper 2026-03-24 08:54:47 -04:00
parent 66362c7176
commit 0edf8c3fef
21 changed files with 3681 additions and 502 deletions

View file

@ -28,10 +28,11 @@ nanobot -> client notifications::
from __future__ import annotations
import asyncio
import contextlib
import json
import os
from pathlib import Path
from typing import Any
from typing import Any, Awaitable, Callable
from wisper import WisperBus, WisperEvent
@ -56,13 +57,21 @@ def _jsonrpc_notification(method: str, params: dict[str, Any] | None = None) ->
class NanobotApiProcess:
"""Connects to the running nanobot process via its Unix domain socket."""
def __init__(self, bus: WisperBus, socket_path: Path) -> None:
def __init__(
self,
bus: WisperBus,
socket_path: Path,
on_disconnect: Callable[[], Awaitable[None]] | None = None,
) -> None:
self._bus = bus
self._socket_path = socket_path
self._on_disconnect = on_disconnect
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._read_task: asyncio.Task | None = None
self._socket_inode: int | None = None
self._streaming_partial_response = False
self._closing = False
@property
def running(self) -> bool:
@ -88,6 +97,8 @@ class NanobotApiProcess:
await self._bus.publish(WisperEvent(role="system", text="Already connected to nanobot."))
return
self._closing = False
self._streaming_partial_response = False
if not self._socket_path.exists():
await self._bus.publish(
WisperEvent(
@ -172,6 +183,7 @@ class NanobotApiProcess:
await self._bus.publish(WisperEvent(role="system", text="Disconnected from nanobot."))
async def _cleanup(self) -> None:
self._closing = True
if self._read_task and not self._read_task.done():
self._read_task.cancel()
try:
@ -189,6 +201,7 @@ class NanobotApiProcess:
self._writer = None
self._reader = None
self._socket_inode = None
self._streaming_partial_response = False
async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
assert self._writer is not None
@ -207,9 +220,19 @@ class NanobotApiProcess:
break
await self._handle_line(line)
finally:
await self._bus.publish(WisperEvent(role="system", text="Nanobot closed the connection."))
should_notify_disconnect = not self._closing
self._streaming_partial_response = False
self._writer = None
self._reader = None
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",
)
async def _handle_line(self, line: bytes) -> None:
raw = line.decode(errors="replace").strip()
@ -245,12 +268,21 @@ class NanobotApiProcess:
content = str(params.get("content", ""))
is_progress = bool(params.get("is_progress", False))
is_tool_hint = bool(params.get("is_tool_hint", False))
is_partial = bool(params.get("is_partial", False))
if is_progress:
if is_partial:
self._streaming_partial_response = True
await self._bus.publish(WisperEvent(role="nanobot-tts-partial", text=content))
return
role = "nanobot-tool" if is_tool_hint else "nanobot-progress"
await self._bus.publish(WisperEvent(role=role, text=content))
else:
await self._bus.publish(WisperEvent(role="nanobot", text=content))
await self._bus.publish(WisperEvent(role="nanobot-tts", text=content))
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))
elif method == "agent_state":
state = str(params.get("state", ""))
await self._bus.publish(WisperEvent(role="agent-state", text=state))
@ -263,9 +295,52 @@ class SuperTonicGateway:
self.bus = WisperBus()
self._lock = asyncio.Lock()
self._process: NanobotApiProcess | None = None
self._reconnect_task: asyncio.Task[None] | None = None
self._shutdown = False
socket_path = Path(os.getenv("NANOBOT_API_SOCKET", str(DEFAULT_SOCKET_PATH))).expanduser()
self._socket_path = socket_path
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
async def subscribe(self) -> asyncio.Queue[WisperEvent]:
return await self.bus.subscribe()
@ -274,10 +349,16 @@ class SuperTonicGateway:
async def connect_nanobot(self) -> None:
async with self._lock:
self._shutdown = False
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)
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()
await self._process.start()
async def _ensure_connected_process(self) -> NanobotApiProcess:
@ -285,7 +366,12 @@ class SuperTonicGateway:
return self._process
if self._process:
await self._process.stop()
self._process = NanobotApiProcess(bus=self.bus, socket_path=self._socket_path)
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()
await self._process.start()
if not self._process.running or not self._process.matches_current_socket():
raise RuntimeError("Not connected to nanobot.")
@ -312,6 +398,12 @@ class SuperTonicGateway:
async def disconnect_nanobot(self) -> None:
async with self._lock:
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
if self._process:
await self._process.stop()
self._process = None