"""HTTP client for Bolt's desk API (ai/desk_api.py on the server). Protocol is identical to desk_client/bolt_desk.py in the main tmn-api repo — this pet is just another desk client, so it gets the exact same brain, memory, tools, and persona as Discord chat and the Linux voice client: text -> POST /desk/converse [server may relay a shell command back to run on THIS machine] ... -> POST /desk/tool_result (repeat until the server sends a reply) reply <- returned to caller A reply can also carry a voice (`voice_id`/`voice_name`), which is how the server's `speak_as` marker reaches us: Bolt searched the ElevenLabs voice library, picked one, and tagged the reply with it — the client is what actually speaks in it. See `Reply` and controller._apply_voice. Kept dependency-free beyond `requests` so it's easy to unit test with mocks. """ from __future__ import annotations import json import subprocess from pathlib import Path from typing import Callable, NamedTuple, Optional import requests from . import config, sudo_askpass _MAX_RELAY_HOPS = 16 class ServerError(Exception): """Raised when the server responds with an error payload or unreachable.""" class Reply(NamedTuple): """One final reply from the desk API. *voice_id* is set only when the server tagged this reply with a `speak_as` voice; *voice_name* is the human-readable name that came with it (may be empty even when the id isn't). Both empty means "say it in the usual voice".""" text: str voice_id: str = "" voice_name: str = "" # True when the sentences were already spoken as they streamed in. The # text is still carried — the follow-up rule needs to see whether the # answer ended on a question — it just must not be read out again. spoken: bool = False def _headers() -> dict: return {"X-Desk-Api-Key": config.API_KEY} def check_health(timeout: float = 10.0) -> dict: response = requests.get(f"{config.SERVER_URL}/desk/health", headers=_headers(), timeout=timeout) response.raise_for_status() return response.json() def run_local_command(command: str, timeout: int = None) -> str: """Execute a command relayed by the server, exactly as bolt_desk.py does — "full desktop control" for things like "open firefox" or "how full is my disk". Runs as the current desktop user. See README security notes. `sudo` gets special handling: the pet has no terminal, so sudo would sit waiting on a tty that nobody is looking at. With SUDO_ASKPASS_PROMPT on, bare `sudo` becomes `sudo -A` and the password is collected in a desktop dialog you have to answer — which also gives those commands a longer timeout, since a human has to notice the window and type.""" env = None if config.SUDO_ASKPASS_PROMPT and sudo_askpass.needs_password_prompt(command): helper = sudo_askpass.find_helper() if helper: env = sudo_askpass.environment(helper) command = sudo_askpass.add_askpass_flag(command) timeout = timeout or config.SUDO_COMMAND_TIMEOUT_SECONDS timeout = timeout or config.COMMAND_TIMEOUT_SECONDS try: completed = subprocess.run( command, shell=True, capture_output=True, text=True, timeout=timeout, cwd=str(Path.home()), env=env, ) output = (completed.stdout or "") + (completed.stderr or "") return f"[exit {completed.returncode}]\n{output}"[:6000] except subprocess.TimeoutExpired: return f"[command timed out after {timeout}s]" except Exception as exc: return f"[command failed: {exc}]" def converse( text: str, on_command: Callable[[str], str] = run_local_command, timeout: float = 120.0, on_say: Optional[Callable[[str], None]] = None, ) -> Reply: """Send one turn of conversation to the desk API, relaying any commands the server sends back until it produces a final reply. *on_command* is injectable for tests; defaults to actually running the command locally (matching bolt_desk.py's behavior). *on_say* is called with a short holding line ("give me a sec") when the server sends one alongside a command. It is the difference between silence and an answer while a tool runs: the model's acknowledgement used to be discarded server-side, so the whole round trip was dead air and the model then repeated itself in the final reply. Optional, so an older pet against a newer server simply stays quiet as before. """ headers = _headers() try: response = requests.post( f"{config.SERVER_URL}/desk/converse", json={"session_id": config.SESSION_ID, "text": text}, headers=headers, timeout=timeout, ) payload = response.json() except Exception as exc: raise ServerError(f"couldn't reach the server: {exc}") from exc for _ in range(_MAX_RELAY_HOPS): if payload.get("type") != "command": break holding = str(payload.get("say") or "").strip() if holding and on_say is not None: # Spoken *before* the command runs — that is the whole point. try: on_say(holding) except Exception: pass # a failed acknowledgement must not cost the tool call output = on_command(str(payload.get("command") or "")) try: response = requests.post( f"{config.SERVER_URL}/desk/tool_result", json={ "session_id": config.SESSION_ID, "token": payload.get("token"), "output": output, }, headers=headers, timeout=180, ) payload = response.json() except Exception as exc: raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc if payload.get("type") == "reply": return Reply( text=str(payload.get("text") or ""), voice_id=str(payload.get("voice_id") or ""), voice_name=str(payload.get("voice_name") or ""), ) raise ServerError(str(payload.get("error") or "unknown server response")) def converse_stream( text: str, on_say: Callable[[str], None], on_command: Callable[[str], str] = run_local_command, timeout: float = 180.0, ) -> Reply: """Same turn as converse(), but speaking each sentence as it arrives. Without this the pet waits out the *entire* model call before a single word is heard; with it the wait is time-to-first-sentence, which on a multi-sentence answer is most of the difference. Falls back by raising ServerError before anything has been spoken — the caller then retries the ordinary path and the user never finds out. Once a sentence *has* been spoken there is no going back, so late failures end the turn with whatever was said rather than repeating it. """ spoke_anything = False try: response = requests.post( f"{config.SERVER_URL}/desk/converse_stream", json={"session_id": config.SESSION_ID, "text": text}, headers=_headers(), timeout=timeout, stream=True, ) response.raise_for_status() for raw in response.iter_lines(decode_unicode=True): if not raw: continue try: event = json.loads(raw) except (TypeError, ValueError): continue kind = str(event.get("type") or "") if kind == "say": line = str(event.get("text") or "").strip() if line: spoke_anything = True on_say(line) elif kind == "command": # The tool loop is request/response, so the rest of the turn # finishes through the ordinary relay rather than inside the # stream — one protocol for tools, not two. response.close() return _finish_relay(event, on_command, on_say) elif kind == "reply": return Reply( text=str(event.get("text") or ""), voice_id=str(event.get("voice_id") or ""), voice_name=str(event.get("voice_name") or ""), spoken=bool(event.get("already_spoken")), ) elif kind == "error": raise ServerError(str(event.get("error") or "stream failed")) except ServerError: raise except Exception as exc: if spoke_anything: # Half a reply is out loud already; ending quietly beats saying it # all again through the fallback path. return Reply(text="", spoken=True) raise ServerError(f"streaming failed: {exc}") from exc raise ServerError("stream ended without a reply") def _finish_relay(event: dict, on_command, on_say) -> Reply: """Run the tool the stream handed over, then continue the classic relay.""" payload = dict(event) headers = _headers() for _ in range(_MAX_RELAY_HOPS): if payload.get("type") != "command": break holding = str(payload.get("say") or "").strip() if holding and on_say is not None: try: on_say(holding) except Exception: pass output = on_command(str(payload.get("command") or "")) try: response = requests.post( f"{config.SERVER_URL}/desk/tool_result", json={"session_id": config.SESSION_ID, "token": payload.get("token"), "output": output}, headers=headers, timeout=180, ) payload = response.json() except Exception as exc: raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc if payload.get("type") == "reply": return Reply( text=str(payload.get("text") or ""), voice_id=str(payload.get("voice_id") or ""), voice_name=str(payload.get("voice_name") or ""), ) raise ServerError(str(payload.get("error") or "unknown server response")) def list_outbox_files(timeout: float = 15.0) -> list: """Files the server has queued for this session via its deliver_files tool (e.g. "send me that report" during a conversation) — each entry has id/name/size. Downloading one (download_outbox_file) dequeues it server-side, so a file is only ever handed out once.""" try: response = requests.get( f"{config.SERVER_URL}/desk/files", params={"session_id": config.SESSION_ID}, headers=_headers(), timeout=timeout, ) response.raise_for_status() return list(response.json().get("files") or []) except Exception as exc: raise ServerError(f"couldn't list delivered files: {exc}") from exc def download_outbox_file(file_id: str, timeout: float = 60.0) -> bytes: """Fetches and dequeues one file listed by list_outbox_files().""" try: response = requests.get( f"{config.SERVER_URL}/desk/files/{file_id}", params={"session_id": config.SESSION_ID}, headers=_headers(), timeout=timeout, ) response.raise_for_status() return response.content except Exception as exc: raise ServerError(f"couldn't download delivered file {file_id!r}: {exc}") from exc def report_status(timeout: float = 15.0) -> Optional[str]: """Heartbeat — lets the desk API attach a pending spoken announcement (proactive nudges, reminders fired since the last heartbeat) that the pet can speak unprompted, exactly like the phone/desk clients. A pet has no battery/GPS to report, so the device-status fields (battery/is_charging/latitude/longitude/address, all optional server-side) are simply omitted. Returns the announcement text to speak, or None if there's nothing pending. """ try: response = requests.post( f"{config.SERVER_URL}/desk/report_status", json={"session_id": config.SESSION_ID}, headers=_headers(), timeout=timeout, ) response.raise_for_status() data = response.json() except Exception as exc: raise ServerError(f"heartbeat failed: {exc}") from exc reply = data.get("reply") return str(reply) if reply else None