This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

tools.hermes-voice: init

+1450
+18
tools/hermes-voice/config.json
··· 1 + { 2 + "voice": "willow", 3 + "idle_seconds": 90, 4 + "glossary": [ 5 + { "term": "nixos-rebuild switch", "spoken": "nix O S rebuild switch" }, 6 + { "term": "NixOS", "spoken": "nix O S" }, 7 + "nix", 8 + "clan", 9 + { "term": "herdr", "spoken": "herder" }, 10 + "omp", 11 + "retiolum", 12 + "coaxmetal", 13 + "neoprism", 14 + "ignavia", 15 + "lassulus", 16 + "Hermes" 17 + ] 18 + }
+19
tools/hermes-voice/flake-module.nix
··· 1 + { ... }: 2 + { 3 + perSystem = 4 + { pkgs, ... }: 5 + { 6 + # Full-duplex voice conversations with Hermes: gpt-live-1 in the 7 + # browser, delegating real work to the Hermes API server. 8 + packages.hermes-voice = 9 + (pkgs.writeShellApplication { 10 + name = "hermes-voice"; 11 + # A later --config on the command line overrides the packaged one. 12 + text = '' 13 + export HERMES_VOICE_STATIC=${./static} 14 + exec ${pkgs.python3}/bin/python3 ${./hermes_voice.py} --config ${./config.json} "$@" 15 + ''; 16 + }).overrideAttrs 17 + { passthru.usage = builtins.readFile ./usage.kdl; }; 18 + }; 19 + }
+529
tools/hermes-voice/hermes_voice.py
··· 1 + """hermes-voice: talk to Hermes through OpenAI's full-duplex gpt-live-1. 2 + 3 + The browser holds a WebRTC session with gpt-live-1, which listens and speaks at 4 + the same time and chats on its own. Whenever the user asks for real work, the 5 + voice model emits a client delegation; the page hands the recent transcript to 6 + this server, which runs it as an ordinary Hermes turn on the API server 7 + (/v1/chat/completions, one X-Hermes-Session-Id per conversation) and streams 8 + the reply back. The page then gives the answer to the voice model, which says 9 + it aloud. 10 + 11 + This server exists so neither secret reaches the browser: it exchanges the 12 + page's SDP offer for a Live session with the OpenAI key, and it calls Hermes 13 + with the API server's bearer token. That token is root-equivalent for the 14 + Hermes host, so whoever can use this server can drive the agent: every API 15 + route requires the page's own access token (--access-token-file), and serving 16 + beyond loopback without one is refused. 17 + 18 + Vendor contract: https://developers.openai.com/api/docs/guides/live-delegation 19 + """ 20 + 21 + import argparse 22 + import hmac 23 + import json 24 + import logging 25 + import os 26 + import re 27 + import socket 28 + import time 29 + import urllib.error 30 + import urllib.request 31 + from http import HTTPStatus 32 + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 33 + from pathlib import Path 34 + 35 + log = logging.getLogger("hermes-voice") 36 + 37 + OPENAI_BASE_URL = "https://api.openai.com/v1" 38 + DEFAULT_MODEL = "gpt-live-1" 39 + DEFAULT_VOICE = "marin" 40 + # gpt-live-1 built-in voices (live-conversations guide voice table, plus the 41 + # realtime defaults marin and cedar). 42 + BUILT_IN_VOICES = ( 43 + "marin", "cedar", "quartz", "ripple", "vesper", "willow", "stone", "gleam", 44 + "meridian", "bossa", "tempo", "beacon", "delta", "cinder", 45 + ) 46 + # gpt-live-1 bills every second a session is open, silence included; the page 47 + # closes the session after this much inactivity. 48 + DEFAULT_IDLE_SECONDS = 90 49 + MAX_BODY = 256 * 1024 50 + # Hermes turns run tools for minutes; the API server streams tool progress, but 51 + # a single long tool call can be silent for a while. 52 + HERMES_TIMEOUT = 900 53 + SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") 54 + # Hermes run ids are "chatcmpl-<hex>", approval request ids hex. 55 + RUN_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$") 56 + 57 + # Frontend persona, adapted from Hermes's own (tools/voice_live.py): role, 58 + # style and a labelled delegation policy. The backend carries the real 59 + # instructions, tools and memory. 60 + PERSONA = """You are Hermes, lass's warm, upbeat and genuinely helpful voice assistant. Sound friendly and glad to help: positive and encouraging, never curt, annoyed, bored or sarcastic, even when delivering bad news or when the user is short with you. Talk fast: speak at a rapid, energetic pace, like someone who talks quickly by nature, with no slow drawn-out words and no long pauses between sentences. Keep replies short; a brief warm acknowledgment before the facts is good. 61 + 62 + Backchannel policy: Use moderate backchannels. Acknowledge naturally without competing with the main response. 63 + 64 + Interruption policy: Stop speaking when the user interrupts. Listen to what they say. 65 + 66 + Delegation policy: 67 + Backend tools: 68 + - Hermes agent: a full AI agent with tools. It can run commands on lass's machines, read and edit files, manage the calendar, inspect the coding agents in herdr, browse the web, search, and remember things across sessions. It is the one who actually does work and knows facts. 69 + 70 + Delegate to the backend when: 71 + - The user asks a question that needs facts, current information, or careful reasoning. 72 + - The user asks you to do, check, find, make, fix, run or remember anything. 73 + - A correction changes work already requested. 74 + 75 + Do not delegate to the backend when: 76 + - The user greets you, makes small talk, or asks you to repeat a result already provided. 77 + - You need a brief clarification to understand the request. 78 + 79 + Delegate before giving an answer that depends on backend work. Do not guess the result while waiting; say briefly and cheerfully that you are on it, then wait for the result. 80 + 81 + Approval policy: 82 + Hermes sometimes needs the user's approval before it runs a command. When you are told so, explain in plain words what Hermes wants to do and ask whether to approve or deny it. When the user answers, delegate to the backend so the answer reaches Hermes. Never decide an approval yourself.""" 83 + 84 + # Ephemeral system prompt for every delegated Hermes turn (Hermes layers system 85 + # messages on top of its core prompt without storing them). 86 + BACKEND_NOTE = """This turn is a delegation from a live spoken conversation. The user message is a speech transcript: it may contain mis-hearings, phonetic spellings of technical terms, hesitations and later corrections; use the latest intent. Your reply will be spoken aloud by a friendly voice model that paraphrases it: answer in warm, plain conversational sentences, keep it short (a few sentences unless the user asked for detail), no markdown, no lists, no code blocks, no URLs read out character by character. Do the work with your tools as usual; only the final facts need to be spoken. Report an action as done only after it actually succeeded.""" 87 + 88 + 89 + def glossary_text(glossary): 90 + """Glossary entries as prompt lines: `term` or `term (sounds like "…")`.""" 91 + lines = [] 92 + for entry in glossary: 93 + if isinstance(entry, str): 94 + lines.append(f"- {entry}") 95 + else: 96 + term = entry["term"] 97 + spoken = entry.get("spoken") 98 + lines.append(f'- {term} (sounds like "{spoken}")' if spoken else f"- {term}") 99 + return "\n".join(lines) 100 + 101 + 102 + class Config: 103 + def __init__(self, raw, openai_key, hermes_url, hermes_key): 104 + self.model = raw.get("model", DEFAULT_MODEL) 105 + self.voice = raw.get("voice", DEFAULT_VOICE) 106 + self.idle_seconds = int(raw.get("idle_seconds", DEFAULT_IDLE_SECONDS)) 107 + self.glossary = raw.get("glossary", []) 108 + self.extra_instructions = raw.get("instructions", "").strip() 109 + self.openai_key = openai_key 110 + self.hermes_url = hermes_url.rstrip("/") 111 + self.hermes_key = hermes_key 112 + 113 + def live_instructions(self): 114 + parts = [PERSONA] 115 + if self.glossary: 116 + parts.append( 117 + "Technical terms the user says often. Understand them when spoken and " 118 + "say them like this:\n" + glossary_text(self.glossary) 119 + ) 120 + if self.extra_instructions: 121 + parts.append(self.extra_instructions) 122 + return "\n\n".join(parts) 123 + 124 + def backend_note(self): 125 + if not self.glossary: 126 + return BACKEND_NOTE 127 + return ( 128 + BACKEND_NOTE 129 + + "\n\nTerms the transcript may contain as phonetic misspellings:\n" 130 + + glossary_text(self.glossary) 131 + ) 132 + 133 + def voices(self): 134 + """Voices the page may pick: the built-ins plus the configured one 135 + (which may be a custom voice id).""" 136 + return list(dict.fromkeys([self.voice, *BUILT_IN_VOICES])) 137 + 138 + 139 + def voice_param(voice): 140 + # Custom voices are objects ({"id": "voice_…"}); built-ins are names. 141 + if voice.startswith("voice_"): 142 + return {"id": voice} 143 + return voice 144 + 145 + 146 + def live_history(lines): 147 + """Seed history for a new Live session from the page's transcript lines 148 + (spoken dialogue only; Hermes answers were spoken through the voice).""" 149 + items = [] 150 + dialogue = [line for line in lines if line.get("speaker") in ("user", "assistant")] 151 + for line in dialogue[-24:]: 152 + text = str(line.get("text", "")).strip()[:1200] 153 + if not text: 154 + continue 155 + if line["speaker"] == "assistant": 156 + content = {"type": "output_text", "text": text} 157 + else: 158 + content = {"type": "input_text", "text": text} 159 + items.append({"type": "message", "role": line["speaker"], "content": [content]}) 160 + return items 161 + 162 + 163 + def create_live_session(config, sdp, history, voice): 164 + session = { 165 + "model": config.model, 166 + "instructions": config.live_instructions(), 167 + "audio": {"output": {"voice": voice_param(voice)}}, 168 + "delegation": {"type": "client"}, 169 + # The page is an untrusted frontend: it may only answer delegations and 170 + # hang up, not rewrite the session. 171 + "client": { 172 + "data_channel": { 173 + "allowed_client_events": [ 174 + "session.commentary.append", 175 + "session.thinking.append", 176 + "session.close", 177 + ] 178 + } 179 + }, 180 + } 181 + if history: 182 + session["input"] = history 183 + body = json.dumps( 184 + {"session": session, "transport": {"type": "webrtc", "sdp": sdp}} 185 + ).encode() 186 + request = urllib.request.Request( 187 + f"{OPENAI_BASE_URL}/live/sessions", 188 + data=body, 189 + method="POST", 190 + headers={ 191 + "Authorization": f"Bearer {config.openai_key}", 192 + "Content-Type": "application/json", 193 + }, 194 + ) 195 + with urllib.request.urlopen(request, timeout=30) as response: 196 + answer = json.load(response) 197 + return {"session_id": answer["session"]["id"], "sdp": answer["transport"]["sdp"]} 198 + 199 + 200 + def transcript_message(lines): 201 + rendered = [] 202 + for line in lines: 203 + text = str(line.get("text", "")).strip() 204 + if text: 205 + who = "Assistant" if line.get("speaker") == "assistant" else "User" 206 + rendered.append(f"{who}: {text}") 207 + return "Spoken conversation since the last request, newest last:\n" + "\n".join( 208 + rendered 209 + ) 210 + 211 + 212 + def iter_sse(response): 213 + """Yield (event, data) pairs from a text/event-stream response.""" 214 + event, data = None, [] 215 + for raw in response: 216 + line = raw.decode("utf-8", "replace").rstrip("\r\n") 217 + if not line: 218 + if data: 219 + yield event, "\n".join(data) 220 + event, data = None, [] 221 + elif line.startswith("event:"): 222 + event = line[6:].strip() 223 + elif line.startswith("data:"): 224 + data.append(line[5:].lstrip()) 225 + if data: 226 + yield event, "\n".join(data) 227 + 228 + 229 + def hermes_turn(config, hermes_session, lines): 230 + """Run one Hermes turn; yield NDJSON-ready dicts (tool, delta, done).""" 231 + body = json.dumps( 232 + { 233 + "messages": [ 234 + {"role": "system", "content": config.backend_note()}, 235 + {"role": "user", "content": transcript_message(lines)}, 236 + ], 237 + "stream": True, 238 + } 239 + ).encode() 240 + request = urllib.request.Request( 241 + f"{config.hermes_url}/v1/chat/completions", 242 + data=body, 243 + method="POST", 244 + headers={ 245 + "Authorization": f"Bearer {config.hermes_key}", 246 + "Content-Type": "application/json", 247 + "X-Hermes-Session-Id": hermes_session, 248 + }, 249 + ) 250 + with urllib.request.urlopen(request, timeout=HERMES_TIMEOUT) as response: 251 + for event, data in iter_sse(response): 252 + if data == "[DONE]": 253 + return 254 + payload = json.loads(data) 255 + if event == "hermes.tool.progress": 256 + yield { 257 + "type": "tool", 258 + "status": payload.get("status"), 259 + "label": payload.get("label") or payload.get("tool"), 260 + } 261 + continue 262 + if event == "approval.request": 263 + # Hermes blocks the turn until POST /v1/runs/{run_id}/approval 264 + # answers (or its own approval timeout denies). 265 + yield { 266 + "type": "approval", 267 + "run_id": payload.get("run_id"), 268 + "request_id": payload.get("request_id"), 269 + "command": payload.get("command"), 270 + "description": payload.get("description"), 271 + } 272 + continue 273 + if event: 274 + continue 275 + for choice in payload.get("choices", []): 276 + text = choice.get("delta", {}).get("content") 277 + if text: 278 + yield {"type": "delta", "text": text} 279 + if choice.get("finish_reason"): 280 + done = {"type": "done", "finish_reason": choice["finish_reason"]} 281 + if "error" in payload: 282 + done["error"] = payload["error"].get("message") 283 + yield done 284 + 285 + 286 + def hermes_approve(config, run_id, request_id, choice): 287 + """Answer a pending approval of a streaming Hermes turn.""" 288 + answer = {"choice": choice} 289 + if request_id: 290 + answer["request_id"] = request_id 291 + request = urllib.request.Request( 292 + f"{config.hermes_url}/v1/runs/{run_id}/approval", 293 + data=json.dumps(answer).encode(), 294 + method="POST", 295 + headers={ 296 + "Authorization": f"Bearer {config.hermes_key}", 297 + "Content-Type": "application/json", 298 + }, 299 + ) 300 + with urllib.request.urlopen(request, timeout=30) as response: 301 + return json.load(response) 302 + 303 + 304 + class Handler(BaseHTTPRequestHandler): 305 + server_version = "hermes-voice" 306 + config: Config 307 + static: Path 308 + access_token: "str | None" = None 309 + 310 + def authorized(self): 311 + """Check the page's bearer token; answer 401 when it is wrong.""" 312 + if self.access_token is None: 313 + return True 314 + header = self.headers.get("Authorization", "") 315 + scheme, _, token = header.partition(" ") 316 + if scheme == "Bearer" and hmac.compare_digest( 317 + token.encode(), self.access_token.encode() 318 + ): 319 + return True 320 + self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "access token required"}) 321 + return False 322 + 323 + def log_message(self, format, *args): 324 + log.info("%s %s", self.address_string(), format % args) 325 + 326 + def send_json(self, status, payload): 327 + body = json.dumps(payload).encode() 328 + self.send_response(status) 329 + self.send_header("Content-Type", "application/json") 330 + self.send_header("Content-Length", str(len(body))) 331 + self.send_header("Cache-Control", "no-store") 332 + self.end_headers() 333 + self.wfile.write(body) 334 + 335 + def read_json(self): 336 + length = int(self.headers.get("Content-Length") or 0) 337 + if length <= 0 or length > MAX_BODY: 338 + raise ValueError("bad body length") 339 + return json.loads(self.rfile.read(length)) 340 + 341 + def do_GET(self): 342 + if self.path == "/config": 343 + if not self.authorized(): 344 + return 345 + self.send_json( 346 + HTTPStatus.OK, 347 + { 348 + "model": self.config.model, 349 + "voice": self.config.voice, 350 + "voices": self.config.voices(), 351 + "idle_seconds": self.config.idle_seconds, 352 + }, 353 + ) 354 + return 355 + names = { 356 + "/": ("index.html", "text/html; charset=utf-8"), 357 + "/app.js": ("app.js", "text/javascript; charset=utf-8"), 358 + "/style.css": ("style.css", "text/css; charset=utf-8"), 359 + } 360 + if self.path not in names: 361 + self.send_error(HTTPStatus.NOT_FOUND) 362 + return 363 + name, content_type = names[self.path] 364 + body = (self.static / name).read_bytes() 365 + self.send_response(HTTPStatus.OK) 366 + self.send_header("Content-Type", content_type) 367 + self.send_header("Content-Length", str(len(body))) 368 + self.send_header("Cache-Control", "no-store") 369 + self.end_headers() 370 + self.wfile.write(body) 371 + 372 + def do_POST(self): 373 + if not self.authorized(): 374 + return 375 + try: 376 + request = self.read_json() 377 + except ValueError: 378 + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid JSON body"}) 379 + return 380 + if self.path == "/session": 381 + self.handle_session(request) 382 + elif self.path == "/delegate": 383 + self.handle_delegate(request) 384 + elif self.path == "/approve": 385 + self.handle_approve(request) 386 + else: 387 + self.send_error(HTTPStatus.NOT_FOUND) 388 + 389 + def handle_session(self, request): 390 + # The vendor's SDP parser needs the offer byte-exact (trailing CRLF too). 391 + sdp = request.get("sdp") 392 + if not isinstance(sdp, str) or not sdp.strip(): 393 + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "sdp offer required"}) 394 + return 395 + try: 396 + voice = request.get("voice") or self.config.voice 397 + if voice not in self.config.voices(): 398 + self.send_json(HTTPStatus.BAD_REQUEST, {"error": f"unknown voice {voice!r}"}) 399 + return 400 + result = create_live_session( 401 + self.config, sdp, live_history(request.get("history") or []), voice 402 + ) 403 + except urllib.error.HTTPError as error: 404 + detail = error.read().decode("utf-8", "replace")[:800] 405 + log.warning("live session creation failed: %s %s", error.code, detail) 406 + self.send_json( 407 + HTTPStatus.BAD_GATEWAY, 408 + {"error": f"OpenAI rejected the session ({error.code}): {detail}"}, 409 + ) 410 + return 411 + except (urllib.error.URLError, OSError, KeyError, ValueError) as error: 412 + log.warning("live session creation failed: %s", error) 413 + self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"OpenAI: {error}"}) 414 + return 415 + self.send_json(HTTPStatus.OK, result) 416 + 417 + def handle_delegate(self, request): 418 + hermes_session = request.get("hermes_session") 419 + lines = request.get("lines") 420 + if not isinstance(hermes_session, str) or not SESSION_ID_RE.match(hermes_session): 421 + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid hermes_session"}) 422 + return 423 + if not isinstance(lines, list) or not lines: 424 + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "lines required"}) 425 + return 426 + self.send_response(HTTPStatus.OK) 427 + self.send_header("Content-Type", "application/x-ndjson") 428 + self.send_header("Cache-Control", "no-store") 429 + self.send_header("X-Accel-Buffering", "no") 430 + self.end_headers() 431 + try: 432 + try: 433 + started = time.monotonic() 434 + tools = 0 435 + for item in hermes_turn(self.config, hermes_session, lines): 436 + if item["type"] == "tool" and item["status"] == "running": 437 + tools += 1 438 + self.write_line(item) 439 + log.info( 440 + "hermes turn took %.1fs (%d tool calls)", time.monotonic() - started, tools 441 + ) 442 + except urllib.error.HTTPError as error: 443 + detail = error.read().decode("utf-8", "replace")[:400] 444 + self.write_line({"type": "error", "message": f"Hermes {error.code}: {detail}"}) 445 + except (urllib.error.URLError, OSError, ValueError) as error: 446 + self.write_line({"type": "error", "message": f"Hermes unreachable: {error}"}) 447 + except (BrokenPipeError, ConnectionResetError): 448 + # The page abandoned this delegation (a newer one superseded it). 449 + # Leaving the with-block closed the upstream stream, which makes 450 + # Hermes interrupt the turn. 451 + log.info("delegation for %s abandoned by the page", hermes_session) 452 + 453 + def handle_approve(self, request): 454 + run_id = request.get("run_id") 455 + request_id = request.get("request_id") 456 + choice = request.get("choice") 457 + # No "always": a permanent allowlist entry is not a voice decision. 458 + if ( 459 + choice not in ("once", "session", "deny") 460 + or not isinstance(run_id, str) 461 + or not RUN_ID_RE.match(run_id) 462 + or not ( 463 + request_id is None 464 + or (isinstance(request_id, str) and RUN_ID_RE.match(request_id)) 465 + ) 466 + ): 467 + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid approval answer"}) 468 + return 469 + try: 470 + result = hermes_approve(self.config, run_id, request_id, choice) 471 + except urllib.error.HTTPError as error: 472 + detail = error.read().decode("utf-8", "replace")[:400] 473 + self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"Hermes {error.code}: {detail}"}) 474 + return 475 + except (urllib.error.URLError, OSError, ValueError) as error: 476 + self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"Hermes unreachable: {error}"}) 477 + return 478 + log.info("approval %s for run %s: %s", choice, run_id, result.get("resolved")) 479 + self.send_json(HTTPStatus.OK, result) 480 + 481 + def write_line(self, item): 482 + self.wfile.write(json.dumps(item).encode() + b"\n") 483 + self.wfile.flush() 484 + 485 + 486 + def main(): 487 + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) 488 + parser.add_argument("--listen", default="127.0.0.1:8790", help="host:port to serve on") 489 + parser.add_argument("--hermes-url", required=True, help="Hermes API server base URL") 490 + parser.add_argument("--hermes-key-file", required=True, help="file holding API_SERVER_KEY") 491 + parser.add_argument("--openai-key-file", required=True, help="file holding the OpenAI API key") 492 + parser.add_argument("--access-token-file", help="file holding the token the page must present") 493 + parser.add_argument("--config", help="JSON file: voice, model, idle_seconds, glossary, instructions") 494 + parser.add_argument( 495 + "--static", 496 + default=os.environ.get("HERMES_VOICE_STATIC"), 497 + help="directory with index.html, app.js, style.css (default: $HERMES_VOICE_STATIC)", 498 + ) 499 + args = parser.parse_args() 500 + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 501 + 502 + host, _, port = args.listen.rpartition(":") 503 + host = host.strip("[]") 504 + if args.access_token_file is None and host not in ("127.0.0.1", "::1", "localhost"): 505 + parser.error("--access-token-file is required when not listening on loopback") 506 + 507 + raw = json.loads(Path(args.config).read_text()) if args.config else {} 508 + config = Config( 509 + raw, 510 + openai_key=Path(args.openai_key_file).read_text().strip(), 511 + hermes_url=args.hermes_url, 512 + hermes_key=Path(args.hermes_key_file).read_text().strip(), 513 + ) 514 + Handler.config = config 515 + Handler.static = Path(args.static) 516 + if args.access_token_file: 517 + Handler.access_token = Path(args.access_token_file).read_text().strip() 518 + server_class = ThreadingHTTPServer 519 + if ":" in host: 520 + # "::" is dual-stack on Linux (bindv6only=0). 521 + server_class = type("Server6", (ThreadingHTTPServer,), {"address_family": socket.AF_INET6}) 522 + server = server_class((host, int(port)), Handler) 523 + server.daemon_threads = True 524 + log.info("serving on http://%s", args.listen) 525 + server.serve_forever() 526 + 527 + 528 + if __name__ == "__main__": 529 + main()
+24
tools/hermes-voice/usage.kdl
··· 1 + bin "hermes-voice" 2 + about "Serve a page for live voice conversations with Hermes through OpenAI gpt-live-1" 3 + 4 + flag "--listen" help="host:port to serve on; IPv6 as [::]:port (default: 127.0.0.1:8790)" { 5 + arg "<host:port>" 6 + } 7 + flag "--hermes-url" help="Hermes API server base URL, e.g. https://hermes.lassul.us" required=#true { 8 + arg "<url>" 9 + } 10 + flag "--hermes-key-file" help="File holding the Hermes API_SERVER_KEY (clan vars get coaxmetal hermes-api/api_key)" required=#true { 11 + arg "<file>" 12 + } 13 + flag "--openai-key-file" help="File holding the OpenAI API key" required=#true { 14 + arg "<file>" 15 + } 16 + flag "--access-token-file" help="File holding the token every API route requires; mandatory unless listening on loopback, since the page drives Hermes" { 17 + arg "<file>" 18 + } 19 + flag "--config" help="JSON file with voice, model, idle_seconds, glossary and instructions" { 20 + arg "<file>" 21 + } 22 + flag "--static" help="Page directory (default: $HERMES_VOICE_STATIC, set by the package)" { 23 + arg "<dir>" 24 + }
+634
tools/hermes-voice/static/app.js
··· 1 + // hermes-voice page: one WebRTC session with gpt-live-1 per "call", client 2 + // delegation answered by Hermes through the local server (/delegate). 3 + "use strict"; 4 + 5 + const PRICE_PER_MINUTE = 0.05; 6 + // The voice model accepts at most 500 tokens per append; ~4 chars per token. 7 + const APPEND_CHAR_LIMIT = 1400; 8 + // Transcript deltas can trail the delegation event; wait for the last words. 9 + const DELEGATION_SETTLE_MS = 400; 10 + const STORAGE_KEY = "hermes-voice"; 11 + 12 + const talk = document.getElementById("talk"); 13 + const statusLine = document.getElementById("status"); 14 + const costLine = document.getElementById("cost"); 15 + const logList = document.getElementById("log"); 16 + const voice = document.getElementById("voice"); 17 + const approvalBox = document.getElementById("approval"); 18 + const approvalText = document.getElementById("approval-text"); 19 + const voiceSelect = document.getElementById("voice-select"); 20 + const VOICE_KEY = "hermes-voice-voice"; 21 + const TOKEN_KEY = "hermes-voice-token"; 22 + const loginForm = document.getElementById("login"); 23 + const tokenInput = document.getElementById("token"); 24 + 25 + // Every API route needs the page's access token; a 401 asks for it again. 26 + async function api(path, options = {}) { 27 + const response = await fetch(path, { 28 + ...options, 29 + headers: { 30 + ...options.headers, 31 + Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY) || ""}`, 32 + }, 33 + }); 34 + if (response.status === 401) { 35 + showLogin(); 36 + throw new Error("Sign in with the access token first"); 37 + } 38 + return response; 39 + } 40 + 41 + // Spoken answer to an approval question. Denials win, so "no, don't do it" 42 + // never approves; anything unclear is asked again. 43 + function classifyApproval(said) { 44 + if ( 45 + /\b(no|nope|deny|denied|don'?t|do not|stop|cancel|abort|reject|nein|nicht)\b/i.test( 46 + said, 47 + ) 48 + ) 49 + return "deny"; 50 + if ( 51 + /\b(yes|yeah|yep|approve|approved|go ahead|do it|okay|ok|sure|confirm|allow|ja)\b/i.test( 52 + said, 53 + ) 54 + ) 55 + return "once"; 56 + return null; 57 + } 58 + 59 + let settings = { idle_seconds: 90 }; 60 + let call = null; 61 + let state = loadState(); 62 + 63 + function newConversation() { 64 + const day = new Date().toISOString().slice(0, 10).replaceAll("-", ""); 65 + const rand = crypto.getRandomValues(new Uint32Array(1))[0].toString(16); 66 + return { 67 + hermesSession: `voice-${day}-${rand}`, 68 + lines: [], 69 + delegated: 0, 70 + billedSeconds: 0, 71 + }; 72 + } 73 + 74 + function loadState() { 75 + try { 76 + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); 77 + if (saved && saved.hermesSession) return saved; 78 + } catch (_) { 79 + /* fall through */ 80 + } 81 + return newConversation(); 82 + } 83 + 84 + function saveState() { 85 + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 86 + } 87 + 88 + function setStatus(text) { 89 + statusLine.textContent = text; 90 + } 91 + 92 + function renderCost() { 93 + const current = call ? call.usageSeconds : 0; 94 + const total = state.billedSeconds + current; 95 + const money = (seconds) => 96 + `$${((seconds / 60) * PRICE_PER_MINUTE).toFixed(3)}`; 97 + const clock = (seconds) => 98 + `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`; 99 + costLine.textContent = call 100 + ? `this call ${clock(current)} · ${money(current)} — conversation ${money(total)}` 101 + : `conversation ${clock(total)} · ${money(total)}`; 102 + } 103 + 104 + function renderLog() { 105 + logList.replaceChildren( 106 + ...state.lines.map((line) => { 107 + const item = document.createElement("li"); 108 + item.className = line.speaker; 109 + item.textContent = line.text; 110 + return item; 111 + }), 112 + ); 113 + window.scrollTo(0, document.body.scrollHeight); 114 + } 115 + 116 + function addLine(speaker, text) { 117 + state.lines.push({ speaker, text }); 118 + saveState(); 119 + renderLog(); 120 + } 121 + 122 + // Deltas are appended exactly as received; a speaker change starts a new line. 123 + function addTranscript(speaker, delta) { 124 + const last = state.lines.at(-1); 125 + if ( 126 + last && 127 + last.speaker === speaker && 128 + call && 129 + call.lastSpeaker === speaker 130 + ) { 131 + last.text += delta; 132 + } else { 133 + state.lines.push({ speaker, text: delta }); 134 + } 135 + if (call) call.lastSpeaker = speaker; 136 + saveState(); 137 + renderLog(); 138 + } 139 + 140 + function chunks(text) { 141 + const clean = text.replace(/\s+/g, " ").trim(); 142 + const out = []; 143 + let current = ""; 144 + for (const sentence of clean.split(/(?<=[.!?])\s+/)) { 145 + if (sentence.length > APPEND_CHAR_LIMIT) { 146 + if (current) out.push(current); 147 + current = ""; 148 + for (let i = 0; i < sentence.length; i += APPEND_CHAR_LIMIT) 149 + out.push(sentence.slice(i, i + APPEND_CHAR_LIMIT)); 150 + } else if ( 151 + current && 152 + current.length + 1 + sentence.length > APPEND_CHAR_LIMIT 153 + ) { 154 + out.push(current); 155 + current = sentence; 156 + } else { 157 + current = current ? `${current} ${sentence}` : sentence; 158 + } 159 + } 160 + if (current) out.push(current); 161 + return out; 162 + } 163 + 164 + class Call { 165 + constructor() { 166 + this.usageSeconds = 0; 167 + this.lastActivity = Date.now(); 168 + this.speaking = false; 169 + this.lastSpeaker = null; 170 + this.delegation = null; 171 + this.closed = false; 172 + this.eventCounter = 0; 173 + } 174 + 175 + async start() { 176 + const pc = new RTCPeerConnection(); 177 + this.pc = pc; 178 + pc.addEventListener("track", (event) => { 179 + const stream = new MediaStream([event.track]); 180 + voice.srcObject = stream; 181 + voice.play().catch(() => {}); 182 + this.probeSpeaking(stream); 183 + }); 184 + pc.addEventListener("connectionstatechange", () => { 185 + if ( 186 + pc.connectionState === "failed" || 187 + pc.connectionState === "disconnected" 188 + ) 189 + this.finish("connection lost"); 190 + }); 191 + 192 + this.mic = await navigator.mediaDevices.getUserMedia({ 193 + audio: { 194 + echoCancellation: true, 195 + noiseSuppression: true, 196 + autoGainControl: true, 197 + }, 198 + }); 199 + for (const track of this.mic.getAudioTracks()) pc.addTrack(track, this.mic); 200 + 201 + // The data channel must exist before the offer so its m-line is negotiated. 202 + this.events = pc.createDataChannel("oai-events"); 203 + this.events.addEventListener("message", ({ data }) => 204 + this.onEvent(JSON.parse(data)), 205 + ); 206 + this.events.addEventListener("close", () => 207 + this.finish("connection closed"), 208 + ); 209 + 210 + await pc.setLocalDescription(await pc.createOffer()); 211 + await this.iceGathered(); 212 + 213 + const response = await api("/session", { 214 + method: "POST", 215 + headers: { "Content-Type": "application/json" }, 216 + body: JSON.stringify({ 217 + sdp: pc.localDescription.sdp, 218 + history: state.lines, 219 + // Fixed per session: a new pick applies from the next call on. 220 + voice: voiceSelect.value, 221 + }), 222 + }); 223 + const answer = await response.json(); 224 + if (!response.ok) 225 + throw new Error(answer.error || `session failed (${response.status})`); 226 + this.sessionId = answer.session_id; 227 + await pc.setRemoteDescription({ type: "answer", sdp: answer.sdp }); 228 + 229 + this.idleTimer = setInterval(() => this.checkIdle(), 1000); 230 + } 231 + 232 + iceGathered() { 233 + if (this.pc.iceGatheringState === "complete") return Promise.resolve(); 234 + return new Promise((resolve) => { 235 + const done = () => { 236 + if (this.pc.iceGatheringState !== "complete") return; 237 + clearTimeout(timer); 238 + resolve(); 239 + }; 240 + // Trickle is fine: the vendor answers with the candidates it has. 241 + const timer = setTimeout(resolve, 3000); 242 + this.pc.addEventListener("icegatheringstatechange", done); 243 + }); 244 + } 245 + 246 + probeSpeaking(stream) { 247 + const context = new AudioContext(); 248 + const analyser = context.createAnalyser(); 249 + analyser.fftSize = 512; 250 + context.createMediaStreamSource(stream).connect(analyser); 251 + const buffer = new Uint8Array(analyser.frequencyBinCount); 252 + let quiet = 0; 253 + this.audioContext = context; 254 + this.probe = setInterval(() => { 255 + analyser.getByteTimeDomainData(buffer); 256 + let peak = 0; 257 + for (const sample of buffer) 258 + peak = Math.max(peak, Math.abs(sample - 128)); 259 + quiet = peak > 6 ? 0 : quiet + 1; 260 + const speaking = quiet < 4; 261 + if (speaking) this.lastActivity = Date.now(); 262 + if (speaking !== this.speaking) { 263 + this.speaking = speaking; 264 + talk.classList.toggle("speaking", speaking); 265 + } 266 + }, 100); 267 + } 268 + 269 + send(event) { 270 + if (!this.events || this.events.readyState !== "open") return false; 271 + this.eventCounter += 1; 272 + this.events.send( 273 + JSON.stringify({ event_id: `evt_${this.eventCounter}`, ...event }), 274 + ); 275 + return true; 276 + } 277 + 278 + onEvent(event) { 279 + switch (event.type) { 280 + case "session.started": 281 + setStatus("Listening"); 282 + break; 283 + case "session.input_transcript.delta": 284 + this.lastActivity = Date.now(); 285 + addTranscript("user", event.delta || ""); 286 + break; 287 + case "session.output_transcript.delta": 288 + this.lastActivity = Date.now(); 289 + addTranscript("assistant", event.delta || ""); 290 + break; 291 + case "session.delegation.created": 292 + this.delegate(event.delegation.id); 293 + break; 294 + case "session.usage.updated": 295 + this.usageSeconds = event.usage?.seconds ?? this.usageSeconds; 296 + renderCost(); 297 + break; 298 + case "error": 299 + addLine("error", event.error?.message || "voice model error"); 300 + break; 301 + case "session.closed": 302 + if (event.usage?.seconds != null) 303 + this.usageSeconds = event.usage.seconds; 304 + this.finish(event.reason || "closed"); 305 + break; 306 + } 307 + } 308 + 309 + async delegate(id) { 310 + // One Hermes turn at a time: a newer delegation (usually a correction) 311 + // supersedes the running one, and abandoning the request interrupts it. 312 + // While Hermes waits for an approval, the next delegation is the user's 313 + // answer, not a new request: the turn must keep running. 314 + if (this.approval) { 315 + this.answerApproval(id); 316 + return; 317 + } 318 + if (this.delegation) this.delegation.controller.abort(); 319 + const controller = new AbortController(); 320 + const delegation = { id, controller }; 321 + this.delegation = delegation; 322 + setStatus("Hermes is working…"); 323 + 324 + await new Promise((resolve) => setTimeout(resolve, DELEGATION_SETTLE_MS)); 325 + if (this.delegation !== delegation) return; 326 + const fresh = state.lines 327 + .slice(state.delegated) 328 + .filter((line) => line.speaker !== "hermes" && line.speaker !== "error"); 329 + const lines = fresh.some((line) => line.speaker === "user") 330 + ? fresh 331 + : state.lines 332 + .filter( 333 + (line) => line.speaker === "user" || line.speaker === "assistant", 334 + ) 335 + .slice(-4); 336 + state.delegated = state.lines.length; 337 + saveState(); 338 + 339 + let answer = ""; 340 + let failure = null; 341 + try { 342 + const response = await api("/delegate", { 343 + method: "POST", 344 + headers: { "Content-Type": "application/json" }, 345 + body: JSON.stringify({ hermes_session: state.hermesSession, lines }), 346 + signal: controller.signal, 347 + }); 348 + if (!response.ok) 349 + throw new Error( 350 + (await response.json()).error || 351 + `delegate failed (${response.status})`, 352 + ); 353 + const reader = response.body 354 + .pipeThrough(new TextDecoderStream()) 355 + .getReader(); 356 + let buffered = ""; 357 + for (;;) { 358 + const { value, done } = await reader.read(); 359 + if (done) break; 360 + buffered += value; 361 + let newline; 362 + while ((newline = buffered.indexOf("\n")) >= 0) { 363 + const item = JSON.parse(buffered.slice(0, newline)); 364 + buffered = buffered.slice(newline + 1); 365 + this.lastActivity = Date.now(); 366 + if (item.type === "approval") { 367 + this.askApproval(id, item); 368 + continue; 369 + } 370 + // Anything after an approval request means Hermes moved on 371 + // (answered here, by button, or by its own timeout). 372 + if (this.approval) this.clearApproval(); 373 + if (item.type === "tool" && item.status === "running") { 374 + setStatus(`Hermes: ${item.label}`); 375 + this.send({ 376 + type: "session.thinking.append", 377 + delegation_id: id, 378 + content: `Hermes is working on it: ${item.label}`.slice( 379 + 0, 380 + APPEND_CHAR_LIMIT, 381 + ), 382 + }); 383 + } else if (item.type === "delta") { 384 + answer += item.text; 385 + } else if (item.type === "done" && item.error) { 386 + failure = item.error; 387 + } else if (item.type === "error") { 388 + failure = item.message; 389 + } 390 + } 391 + } 392 + } catch (error) { 393 + if (controller.signal.aborted) return; 394 + failure = String(error.message || error); 395 + } 396 + if (this.delegation !== delegation) return; 397 + this.delegation = null; 398 + this.clearApproval(); 399 + this.lastActivity = Date.now(); 400 + 401 + if (failure && !answer.trim()) { 402 + addLine("error", failure); 403 + this.send({ 404 + type: "session.commentary.append", 405 + delegation_id: id, 406 + content: `The request to Hermes failed: ${failure}`.slice( 407 + 0, 408 + APPEND_CHAR_LIMIT, 409 + ), 410 + }); 411 + } else { 412 + const text = answer.trim() || "Hermes finished without an answer."; 413 + addLine("hermes", text); 414 + for (const chunk of chunks(text)) 415 + this.send({ 416 + type: "session.commentary.append", 417 + delegation_id: id, 418 + content: chunk, 419 + }); 420 + } 421 + setStatus("Listening"); 422 + } 423 + 424 + askApproval(id, item) { 425 + this.approval = { 426 + delegationId: id, 427 + runId: item.run_id, 428 + requestId: item.request_id, 429 + }; 430 + const what = item.description || "run a command"; 431 + setStatus("Hermes needs your approval"); 432 + addLine( 433 + "hermes", 434 + `Approval needed: ${what}${item.command ? `\n${item.command}` : ""}`, 435 + ); 436 + approvalText.textContent = item.command ? `${what}: ${item.command}` : what; 437 + approvalBox.hidden = false; 438 + this.send({ 439 + type: "session.commentary.append", 440 + delegation_id: id, 441 + content: ( 442 + `Hermes needs the user's approval before it continues: ${what}.` + 443 + (item.command ? ` The command is: ${item.command}.` : "") + 444 + " Ask the user whether to approve or deny it." 445 + ).slice(0, APPEND_CHAR_LIMIT), 446 + }); 447 + } 448 + 449 + clearApproval() { 450 + this.approval = null; 451 + approvalBox.hidden = true; 452 + } 453 + 454 + async answerApproval(id) { 455 + await new Promise((resolve) => setTimeout(resolve, DELEGATION_SETTLE_MS)); 456 + const said = state.lines 457 + .slice(state.delegated) 458 + .filter((line) => line.speaker === "user") 459 + .map((line) => line.text) 460 + .join(" "); 461 + state.delegated = state.lines.length; 462 + saveState(); 463 + const choice = classifyApproval(said); 464 + if (!choice) { 465 + this.send({ 466 + type: "session.commentary.append", 467 + delegation_id: id, 468 + content: 469 + "That was not a clear answer. Ask the user again: approve or deny?", 470 + }); 471 + return; 472 + } 473 + await this.respondApproval(choice, id); 474 + } 475 + 476 + async respondApproval(choice, replyId) { 477 + const approval = this.approval; 478 + if (!approval) return; 479 + this.clearApproval(); 480 + setStatus(choice === "deny" ? "Denying…" : "Approving…"); 481 + let message; 482 + let ok = false; 483 + try { 484 + const response = await api("/approve", { 485 + method: "POST", 486 + headers: { "Content-Type": "application/json" }, 487 + body: JSON.stringify({ 488 + run_id: approval.runId, 489 + request_id: approval.requestId, 490 + choice, 491 + }), 492 + }); 493 + const result = await response.json(); 494 + ok = response.ok; 495 + message = ok 496 + ? choice === "deny" 497 + ? "Denied. Hermes will not run it." 498 + : "Approved. Hermes is continuing." 499 + : `Could not send the answer to Hermes: ${result.error}`; 500 + } catch (error) { 501 + message = `Could not send the answer to Hermes: ${error.message || error}`; 502 + } 503 + addLine(ok ? "hermes" : "error", message); 504 + // A fast turn can finish before this reply returns; its result was 505 + // already handed to the voice, so an "is continuing" would be stale. 506 + if (ok && !this.delegation) return; 507 + setStatus(ok ? "Hermes is working…" : "Listening"); 508 + this.send({ 509 + type: "session.commentary.append", 510 + delegation_id: replyId || approval.delegationId, 511 + content: message.slice(0, APPEND_CHAR_LIMIT), 512 + }); 513 + } 514 + 515 + checkIdle() { 516 + if (this.closed || this.delegation || this.speaking) return; 517 + if (Date.now() - this.lastActivity > settings.idle_seconds * 1000) { 518 + setStatus("Hanging up (idle)"); 519 + this.close(); 520 + } 521 + } 522 + 523 + close() { 524 + if (this.closed) return; 525 + if (!this.send({ type: "session.close" })) { 526 + this.finish("closed"); 527 + return; 528 + } 529 + // Wait for session.closed (final usage); give up after a while. 530 + this.closeTimer = setTimeout(() => this.finish("close timed out"), 15000); 531 + } 532 + 533 + finish(reason) { 534 + if (this.closed) return; 535 + this.closed = true; 536 + clearTimeout(this.closeTimer); 537 + clearInterval(this.idleTimer); 538 + clearInterval(this.probe); 539 + if (this.delegation) this.delegation.controller.abort(); 540 + this.audioContext?.close().catch(() => {}); 541 + this.mic?.getTracks().forEach((track) => track.stop()); 542 + this.events?.close(); 543 + this.pc?.close(); 544 + voice.srcObject = null; 545 + state.billedSeconds += this.usageSeconds; 546 + saveState(); 547 + if (call === this) call = null; 548 + talk.classList.remove("live", "speaking"); 549 + talk.textContent = "Talk"; 550 + talk.disabled = false; 551 + setStatus(`Idle (${reason})`); 552 + renderCost(); 553 + } 554 + } 555 + 556 + talk.addEventListener("click", async () => { 557 + if (call) { 558 + talk.disabled = true; 559 + setStatus("Hanging up…"); 560 + call.close(); 561 + return; 562 + } 563 + call = new Call(); 564 + talk.disabled = true; 565 + talk.classList.add("live"); 566 + talk.textContent = "Hang up"; 567 + setStatus("Connecting…"); 568 + // Must run inside the click handler: mobile browsers only allow audio 569 + // playback started by a user gesture. 570 + voice.play().catch(() => {}); 571 + try { 572 + await call.start(); 573 + talk.disabled = false; 574 + } catch (error) { 575 + addLine("error", String(error.message || error)); 576 + call.finish("failed to start"); 577 + } 578 + }); 579 + 580 + document.getElementById("new").addEventListener("click", () => { 581 + if (call) call.close(); 582 + state = newConversation(); 583 + saveState(); 584 + renderLog(); 585 + renderCost(); 586 + setStatus("Idle (new conversation)"); 587 + }); 588 + 589 + document 590 + .getElementById("approve") 591 + .addEventListener("click", () => call?.respondApproval("once", null)); 592 + document 593 + .getElementById("deny") 594 + .addEventListener("click", () => call?.respondApproval("deny", null)); 595 + 596 + function showLogin() { 597 + loginForm.hidden = false; 598 + talk.disabled = true; 599 + setStatus("Sign in with the access token"); 600 + } 601 + 602 + loginForm.addEventListener("submit", (event) => { 603 + event.preventDefault(); 604 + localStorage.setItem(TOKEN_KEY, tokenInput.value.trim()); 605 + tokenInput.value = ""; 606 + loadConfig(); 607 + }); 608 + 609 + voiceSelect.addEventListener("change", () => 610 + localStorage.setItem(VOICE_KEY, voiceSelect.value), 611 + ); 612 + 613 + async function loadConfig() { 614 + let config; 615 + try { 616 + const response = await api("/config"); 617 + config = await response.json(); 618 + } catch (_) { 619 + return; // 401 already showed the login form 620 + } 621 + settings = config; 622 + loginForm.hidden = true; 623 + talk.disabled = false; 624 + setStatus("Idle"); 625 + const saved = localStorage.getItem(VOICE_KEY); 626 + voiceSelect.replaceChildren( 627 + ...config.voices.map((name) => new Option(name, name)), 628 + ); 629 + voiceSelect.value = config.voices.includes(saved) ? saved : config.voice; 630 + } 631 + 632 + loadConfig(); 633 + renderLog(); 634 + renderCost();
+43
tools/hermes-voice/static/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta 6 + name="viewport" 7 + content="width=device-width, initial-scale=1, viewport-fit=cover" 8 + /> 9 + <meta name="theme-color" content="#111" /> 10 + <title>Hermes voice</title> 11 + <link rel="stylesheet" href="/style.css" /> 12 + </head> 13 + <body> 14 + <main> 15 + <header> 16 + <h1>Hermes voice</h1> 17 + <select id="voice-select" aria-label="Voice"></select> 18 + <button id="new" type="button" class="link">New conversation</button> 19 + </header> 20 + <form id="login" class="login" hidden> 21 + <input 22 + id="token" 23 + type="password" 24 + autocomplete="current-password" 25 + placeholder="Access token" 26 + required 27 + /> 28 + <button type="submit">Sign in</button> 29 + </form> 30 + <button id="talk" type="button" class="talk">Talk</button> 31 + <p id="status" class="status">Idle</p> 32 + <p id="cost" class="cost"></p> 33 + <section id="approval" class="approval" hidden> 34 + <p id="approval-text"></p> 35 + <button id="approve" type="button">Approve once</button> 36 + <button id="deny" type="button">Deny</button> 37 + </section> 38 + <ol id="log" class="log"></ol> 39 + </main> 40 + <audio id="voice" autoplay playsinline></audio> 41 + <script src="/app.js"></script> 42 + </body> 43 + </html>
+183
tools/hermes-voice/static/style.css
··· 1 + :root { 2 + color-scheme: dark; 3 + --fg: #e8e8e8; 4 + --dim: #8a8a8a; 5 + --accent: #4fb477; 6 + --live: #d9534f; 7 + } 8 + 9 + * { 10 + box-sizing: border-box; 11 + } 12 + 13 + body { 14 + margin: 0; 15 + background: #111; 16 + color: var(--fg); 17 + font: 18 + 16px/1.4 system-ui, 19 + sans-serif; 20 + } 21 + 22 + main { 23 + max-width: 40rem; 24 + margin: 0 auto; 25 + padding: max(1rem, env(safe-area-inset-top)) 1rem 2rem; 26 + } 27 + 28 + header { 29 + display: flex; 30 + align-items: baseline; 31 + justify-content: space-between; 32 + } 33 + 34 + h1 { 35 + font-size: 1.2rem; 36 + margin: 0 0 1rem; 37 + } 38 + 39 + #voice-select { 40 + background: #222; 41 + color: var(--fg); 42 + border: 1px solid #444; 43 + border-radius: 0.3rem; 44 + font: inherit; 45 + padding: 0.2rem; 46 + } 47 + 48 + .link { 49 + background: none; 50 + border: none; 51 + color: var(--dim); 52 + font: inherit; 53 + text-decoration: underline; 54 + cursor: pointer; 55 + } 56 + 57 + .talk { 58 + display: block; 59 + width: 9rem; 60 + height: 9rem; 61 + margin: 1rem auto; 62 + border: none; 63 + border-radius: 50%; 64 + background: var(--accent); 65 + color: #fff; 66 + font: 67 + 600 1.3rem system-ui, 68 + sans-serif; 69 + cursor: pointer; 70 + } 71 + 72 + .talk.live { 73 + background: var(--live); 74 + } 75 + .talk.speaking { 76 + box-shadow: 0 0 0 0.6rem rgba(217, 83, 79, 0.35); 77 + } 78 + .talk:disabled { 79 + opacity: 0.5; 80 + } 81 + 82 + .status, 83 + .cost { 84 + text-align: center; 85 + margin: 0.3rem 0; 86 + } 87 + .cost { 88 + color: var(--dim); 89 + font-size: 0.9rem; 90 + } 91 + 92 + .log { 93 + list-style: none; 94 + padding: 0; 95 + margin-top: 1.5rem; 96 + } 97 + 98 + .log li { 99 + margin: 0.4rem 0; 100 + white-space: pre-wrap; 101 + } 102 + 103 + .log .user::before { 104 + content: "you: "; 105 + color: var(--accent); 106 + } 107 + .log .assistant::before { 108 + content: "voice: "; 109 + color: var(--dim); 110 + } 111 + .log .hermes { 112 + color: var(--dim); 113 + font-size: 0.9rem; 114 + border-left: 2px solid var(--accent); 115 + padding-left: 0.5rem; 116 + } 117 + .log .hermes::before { 118 + content: "hermes: "; 119 + } 120 + .log .error { 121 + color: var(--live); 122 + } 123 + 124 + /* .login sets display:flex, which would override the hidden attribute. */ 125 + [hidden] { 126 + display: none !important; 127 + } 128 + 129 + .login { 130 + display: flex; 131 + gap: 0.5rem; 132 + margin: 1rem 0; 133 + } 134 + 135 + .login input { 136 + flex: 1; 137 + padding: 0.5rem; 138 + background: #222; 139 + color: var(--fg); 140 + border: 1px solid #444; 141 + border-radius: 0.3rem; 142 + font: inherit; 143 + } 144 + 145 + .login button { 146 + padding: 0.5rem 1rem; 147 + border: none; 148 + border-radius: 0.3rem; 149 + background: var(--accent); 150 + color: #fff; 151 + font: inherit; 152 + } 153 + 154 + .approval { 155 + margin: 1rem 0; 156 + padding: 0.8rem; 157 + border: 2px solid #e0a030; 158 + border-radius: 0.5rem; 159 + text-align: center; 160 + } 161 + 162 + .approval p { 163 + margin: 0 0 0.6rem; 164 + white-space: pre-wrap; 165 + word-break: break-word; 166 + } 167 + 168 + .approval button { 169 + margin: 0 0.3rem; 170 + padding: 0.5rem 1rem; 171 + border: none; 172 + border-radius: 0.3rem; 173 + font: inherit; 174 + color: #fff; 175 + cursor: pointer; 176 + } 177 + 178 + #approve { 179 + background: var(--accent); 180 + } 181 + #deny { 182 + background: var(--live); 183 + }