"""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 — the console is just another desk client, so it gets the exact same brain, memory, tools, and persona as Discord chat and the other desk clients: text -> POST /desk/converse (or /desk/converse_stream) [server may relay a command back to run on THIS machine] ... -> POST /desk/tool_result (repeat until the server sends a reply) reply <- returned to caller Kept dependency-free beyond `requests` so it's easy to unit test with mocks. """ from __future__ import annotations import json import os import subprocess from typing import Callable, NamedTuple, Optional import requests from . import config _MAX_RELAY_HOPS = 16 _MAX_OUTPUT_CHARS = 6000 class ServerError(Exception): """Raised when the server responds with an error payload or unreachable.""" class Reply(NamedTuple): """One final reply from the desk API.""" text: str # True when the text already reached the screen sentence by sentence while # it streamed in — the caller must not print it a second time. streamed: 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: Optional[int] = None) -> str: """Execute a relayed shell command as the current user, in the console's working directory (see /cd). Output is captured for the server, so stdin is closed rather than inherited — a command waiting on input would otherwise hang on a prompt nobody can see. `sudo` still works: it asks on the terminal itself, not on stdin.""" timeout = timeout or config.COMMAND_TIMEOUT_SECONDS try: completed = subprocess.run( command, shell=True, capture_output=True, text=True, errors="replace", stdin=subprocess.DEVNULL, timeout=timeout, cwd=os.getcwd(), # From the window (pythonw, no console) every cmd.exe would # otherwise flash a console window. Output is captured either way. creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) output = (completed.stdout or "") + (completed.stderr or "") return f"[exit {completed.returncode}]\n{output}"[:_MAX_OUTPUT_CHARS] except subprocess.TimeoutExpired: return f"[command timed out after {timeout}s]" except Exception as exc: return f"[command failed: {exc}]" def _reply_from(payload: dict, streamed: bool = False) -> Reply: if payload.get("type") == "reply": return Reply(text=str(payload.get("text") or ""), streamed=streamed) raise ServerError(str(payload.get("error") or "unknown server response")) def _relay(payload: dict, on_command, on_say) -> dict: """Run every command the server hands back, posting each output to /desk/tool_result, until the payload is no longer a command.""" 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: # Shown *before* the command runs — that's the point of it. 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 return payload 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, relaying any commands the server sends back until it produces a final reply. *on_say* receives the short holding line ("give me a sec") the server can send alongside a command.""" 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 return _reply_from(_relay(payload, on_command, on_say)) 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 handing each sentence to *on_say* as it arrives, so the wait is time-to-first-sentence. Raises ServerError if the stream fails before anything was shown — the caller then retries with converse(). Once a sentence *has* been shown, a late failure ends the turn with what was said rather than repeating it. """ shown_anything = False said = [] 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: shown_anything = True said.append(line) 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 the stream. response.close() return _reply_from(_relay(event, on_command, on_say)) elif kind == "reply": if event.get("already_spoken"): return _reply_from(event, streamed=True) return _unsaid(_reply_from(event), said) elif kind == "error": raise ServerError(str(event.get("error") or "stream failed")) except ServerError: raise except Exception as exc: if shown_anything: return Reply(text="", streamed=True) raise ServerError(f"streaming failed: {exc}") from exc raise ServerError("stream ended without a reply") def _unsaid(reply: Reply, said: list) -> Reply: """The part of *reply* not already shown as streamed sentences. Seen live: the server streams the sentences, then sends the whole text again in the final reply *without* `already_spoken`, so everything showed twice. Compared ignoring whitespace, since the stream splits on sentences and drops the line breaks the full text keeps.""" if not said: return reply target = len("".join("".join(said).split())) # non-whitespace characters shown seen, cut = 0, None for index, char in enumerate(reply.text): if not char.isspace(): seen += 1 if seen == target: cut = index + 1 break if cut is None or "".join(reply.text[:cut].split()) != "".join("".join(said).split()): return reply # a different text, not a repeat: show all of it rest = reply.text[cut:].strip() return Reply(text=rest, streamed=not rest) def list_chats(timeout: float = 15.0) -> list: """This user's conversations across every client (console, pet, phone), newest first — each {session_id, label, last_message, last_active}. A chat only appears once it's been used, so a just-minted one isn't here.""" try: response = requests.get( f"{config.SERVER_URL}/desk/chats", headers=_headers(), timeout=timeout, ) response.raise_for_status() return list(response.json().get("chats") or []) except Exception as exc: raise ServerError(f"couldn't list chats: {exc}") from exc def create_chat(timeout: float = 15.0) -> str: """Mint a brand-new, never-used session id on the server. Bolt's memory is scoped to the user, not the session, so a new chat is fresh history, not a memory reset.""" try: response = requests.post( f"{config.SERVER_URL}/desk/chats", headers=_headers(), timeout=timeout, ) response.raise_for_status() session_id = str(response.json().get("session_id") or "") except Exception as exc: raise ServerError(f"couldn't create a chat: {exc}") from exc if not session_id: raise ServerError("the server didn't return a session_id for the new chat") return session_id def list_outbox_files(timeout: float = 15.0) -> list: """Files the server has queued for this session via its deliver_files tool — each entry has id/name/size. Downloading one 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