Upload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
"""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
|
||||
|
||||
Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from . import config
|
||||
|
||||
_MAX_RELAY_HOPS = 16
|
||||
|
||||
|
||||
class ServerError(Exception):
|
||||
"""Raised when the server responds with an error payload or unreachable."""
|
||||
|
||||
|
||||
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."""
|
||||
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()),
|
||||
)
|
||||
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,
|
||||
) -> str:
|
||||
"""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).
|
||||
"""
|
||||
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
|
||||
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 str(payload.get("text") or "")
|
||||
raise ServerError(str(payload.get("error") or "unknown server response"))
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user