This repository has no description
0

Configure Feed

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

superconfig / tools / hermes-voice / hermes_voice.py
27 kB 610 lines
1"""hermes-voice: talk to Hermes through OpenAI's full-duplex gpt-live-1. 2 3The browser holds a WebRTC session with gpt-live-1, which listens and speaks at 4the same time and chats on its own. Whenever the user asks for real work, the 5voice model emits a client delegation; the page hands the recent transcript to 6this server, which starts it as a Hermes run on the API server (POST /v1/runs, 7one Hermes session id per conversation), follows the run's event stream and 8streams the reply back. The page then gives the answer to the voice model, 9which says it aloud. 10 11A run lives independently of any HTTP connection, so a turn survives the page 12going away. A newer delegation for the same conversation stops the previous 13run (the user corrected or replaced the request); hanging up just ends the 14call, and the run finishes whatever the user asked for. 15 16This server exists so neither secret reaches the browser: it exchanges the 17page's SDP offer for a Live session with the OpenAI key, and it calls Hermes 18with the API server's bearer token. That token is root-equivalent for the 19Hermes host, so whoever can use this server can drive the agent: every API 20route requires the page's own access token (--access-token-file), and serving 21beyond loopback without one is refused. 22 23Vendor contract: https://developers.openai.com/api/docs/guides/live-delegation 24""" 25 26import argparse 27import hmac 28import json 29import logging 30import os 31import re 32import socket 33import threading 34import time 35import urllib.error 36import urllib.request 37from http import HTTPStatus 38from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 39from pathlib import Path 40 41log = logging.getLogger("hermes-voice") 42 43OPENAI_BASE_URL = "https://api.openai.com/v1" 44DEFAULT_MODEL = "gpt-live-1" 45DEFAULT_VOICE = "marin" 46# gpt-live-1 voices as OpenAI's voice picker lists them: language, regional 47# influence, presentation and character, natural voices first, then the 48# generated ones. The docs' voice table and the openai SDK (3.28.0) list only 49# part of them; /v1/live/sessions accepts the German, French, Hindi, Japanese 50# and Korean voices as well, while an unknown name is refused with 403. 51BUILT_IN_VOICES = { 52 "gleam": "English, North American, female: warm and conversational", 53 "ripple": "English, Australian, male: warm and conversational", 54 "vesper": "English, British, male: clear and direct", 55 "willow": "English, Irish, female: expressive and bright", 56 "stone": "English, Irish, male: calm and precise", 57 "meridian": "English, North American, male: grounded and measured", 58 "bossa": "Portuguese, Brazilian, female: clear and direct", 59 "tempo": "Portuguese, Brazilian, male: grounded and measured", 60 "cedar": "English, North American, male: open and upbeat", 61 "marin": "English, North American, female: animated and earnest", 62 "nuri": "German, female: warm and animated", 63 "juni": "German, female: bright and expressive", 64 "flitz": "German, male: rich and playful", 65 "brise": "French, female: clear and conversational", 66 "sillage": "French, male: warm and grounded", 67 "nira": "Hindi, female: soft and thoughtful", 68 "harema": "Japanese, female: gentle and engaging", 69 "shida": "Japanese, male: quiet and grounded", 70 "noeul": "Korean, female: airy and expressive", 71 "quartz": "English, Australian, female, generated: warm and conversational", 72 "alloy": "English, North American, male, generated: balanced and clear", 73 "ash": "English, North American, male, generated: savvy and relaxed", 74 "ballad": "English, North American, male, generated: cheerful and candid", 75 "coral": "English, North American, female, generated: easygoing and versatile", 76 "echo": "English, North American, male, generated: bright and inquisitive", 77 "sage": "English, North American, female, generated: confident and optimistic", 78 "shimmer": "English, North American, female, generated: composed and direct", 79 "verse": "English, North American, male, generated: calm and affirming", 80 "beacon": "English, Filipino, male, generated: clear and personable", 81 "delta": "English, Southern U.S., female, generated: warm and easygoing", 82 "cinder": "English, Southern U.S., male, generated: steady and grounded", 83} 84# gpt-live-1 bills every second a session is open, silence included; the page 85# closes the session after this much inactivity. 86DEFAULT_IDLE_SECONDS = 90 87MAX_BODY = 256 * 1024 88# The run event stream sends a keepalive every 10s, so a read that stalls this 89# long means the API server is gone. 90EVENTS_READ_TIMEOUT = 60 91SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") 92# Hermes run ids are "run_<hex>", approval request ids hex. 93RUN_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$") 94 95# Frontend persona, adapted from Hermes's own (tools/voice_live.py): role, 96# style and a labelled delegation policy. The backend carries the real 97# instructions, tools and memory. 98PERSONA = """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. 99 100Backchannel policy: Use moderate backchannels. Acknowledge naturally without competing with the main response. 101 102Interruption policy: Stop speaking when the user interrupts. Listen to what they say. 103 104Delegation policy: 105Backend tools: 106- Hermes agent: a full AI agent with tools. It can run commands on lass's machines, read and edit files, manage the calendar, browse the web, search, and remember things across sessions. It is the one who actually does work and knows facts. 107 108Delegate to the backend when: 109- The user asks a question that needs facts, current information, or careful reasoning. 110- The user asks you to do, check, find, make, fix, run or remember anything. 111- A correction changes work already requested. 112 113Do not delegate to the backend when: 114- The user greets you, makes small talk, or asks you to repeat a result already provided. 115- You need a brief clarification to understand the request. 116 117Delegate 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. 118 119Approval policy: 120Hermes 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.""" 121 122# Ephemeral system prompt for every delegated Hermes turn (Hermes layers system 123# messages on top of its core prompt without storing them). 124BACKEND_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.""" 125 126 127def glossary_text(glossary): 128 """Glossary entries as prompt lines: `term` or `term (sounds like "…")`.""" 129 lines = [] 130 for entry in glossary: 131 if isinstance(entry, str): 132 lines.append(f"- {entry}") 133 else: 134 term = entry["term"] 135 spoken = entry.get("spoken") 136 lines.append(f'- {term} (sounds like "{spoken}")' if spoken else f"- {term}") 137 return "\n".join(lines) 138 139 140class Config: 141 def __init__(self, raw, openai_key, hermes_url, hermes_key): 142 self.model = raw.get("model", DEFAULT_MODEL) 143 self.voice = raw.get("voice", DEFAULT_VOICE) 144 self.idle_seconds = int(raw.get("idle_seconds", DEFAULT_IDLE_SECONDS)) 145 self.glossary = raw.get("glossary", []) 146 self.extra_instructions = raw.get("instructions", "").strip() 147 self.openai_key = openai_key 148 self.hermes_url = hermes_url.rstrip("/") 149 self.hermes_key = hermes_key 150 151 def live_instructions(self): 152 parts = [PERSONA] 153 if self.glossary: 154 parts.append( 155 "Technical terms the user says often. Understand them when spoken and " 156 "say them like this:\n" + glossary_text(self.glossary) 157 ) 158 if self.extra_instructions: 159 parts.append(self.extra_instructions) 160 return "\n\n".join(parts) 161 162 def backend_note(self): 163 if not self.glossary: 164 return BACKEND_NOTE 165 return ( 166 BACKEND_NOTE 167 + "\n\nTerms the transcript may contain as phonetic misspellings:\n" 168 + glossary_text(self.glossary) 169 ) 170 171 def voices(self): 172 """Voices the page may pick, name -> description: the built-ins plus 173 the configured one (which may be a custom voice id).""" 174 voices = dict(BUILT_IN_VOICES) 175 voices.setdefault(self.voice, "custom voice") 176 return voices 177 178 179def voice_param(voice): 180 # Custom voices are objects ({"id": "voice_…"}); built-ins are names. 181 if voice.startswith("voice_"): 182 return {"id": voice} 183 return voice 184 185 186def live_history(lines): 187 """Seed history for a new Live session from the page's transcript lines 188 (spoken dialogue only; Hermes answers were spoken through the voice).""" 189 items = [] 190 dialogue = [line for line in lines if line.get("speaker") in ("user", "assistant")] 191 for line in dialogue[-24:]: 192 text = str(line.get("text", "")).strip()[:1200] 193 if not text: 194 continue 195 if line["speaker"] == "assistant": 196 content = {"type": "output_text", "text": text} 197 else: 198 content = {"type": "input_text", "text": text} 199 items.append({"type": "message", "role": line["speaker"], "content": [content]}) 200 return items 201 202 203def create_live_session(config, sdp, history, voice): 204 session = { 205 "model": config.model, 206 "instructions": config.live_instructions(), 207 "audio": {"output": {"voice": voice_param(voice)}}, 208 "delegation": {"type": "client"}, 209 # The page is an untrusted frontend: it may only answer delegations and 210 # hang up, not rewrite the session. 211 "client": { 212 "data_channel": { 213 "allowed_client_events": [ 214 "session.commentary.append", 215 "session.thinking.append", 216 "session.close", 217 ] 218 } 219 }, 220 } 221 if history: 222 session["input"] = history 223 body = json.dumps( 224 {"session": session, "transport": {"type": "webrtc", "sdp": sdp}} 225 ).encode() 226 request = urllib.request.Request( 227 f"{OPENAI_BASE_URL}/live/sessions", 228 data=body, 229 method="POST", 230 headers={ 231 "Authorization": f"Bearer {config.openai_key}", 232 "Content-Type": "application/json", 233 }, 234 ) 235 with urllib.request.urlopen(request, timeout=30) as response: 236 answer = json.load(response) 237 return {"session_id": answer["session"]["id"], "sdp": answer["transport"]["sdp"]} 238 239 240def transcript_message(lines): 241 rendered = [] 242 for line in lines: 243 text = str(line.get("text", "")).strip() 244 if text: 245 who = "Assistant" if line.get("speaker") == "assistant" else "User" 246 rendered.append(f"{who}: {text}") 247 return "Spoken conversation since the last request, newest last:\n" + "\n".join( 248 rendered 249 ) 250 251 252def iter_sse(response): 253 """Yield (event, data) pairs from a text/event-stream response.""" 254 event, data = None, [] 255 for raw in response: 256 line = raw.decode("utf-8", "replace").rstrip("\r\n") 257 if not line: 258 if data: 259 yield event, "\n".join(data) 260 event, data = None, [] 261 elif line.startswith("event:"): 262 event = line[6:].strip() 263 elif line.startswith("data:"): 264 data.append(line[5:].lstrip()) 265 if data: 266 yield event, "\n".join(data) 267 268 269def hermes_request(config, method, path, payload=None, timeout=30): 270 """Open a request to the Hermes API server (the caller closes it).""" 271 request = urllib.request.Request( 272 f"{config.hermes_url}{path}", 273 data=None if payload is None else json.dumps(payload).encode(), 274 method=method, 275 headers={ 276 "Authorization": f"Bearer {config.hermes_key}", 277 "Content-Type": "application/json", 278 }, 279 ) 280 return urllib.request.urlopen(request, timeout=timeout) 281 282 283def hermes_call(config, method, path, payload=None): 284 with hermes_request(config, method, path, payload) as response: 285 return json.load(response) 286 287 288def hermes_start_run(config, hermes_session, lines): 289 """Start one Hermes turn as a run; return its run id.""" 290 # A plain string input: Hermes then loads the history of session_id itself. 291 return hermes_call( 292 config, 293 "POST", 294 "/v1/runs", 295 { 296 "input": transcript_message(lines), 297 "instructions": config.backend_note(), 298 "session_id": hermes_session, 299 }, 300 )["run_id"] 301 302 303def hermes_follow_run(config, run_id): 304 """Follow a run's events; yield NDJSON-ready dicts (tool, delta, approval, 305 done, error). Leaving early only detaches: the run keeps going.""" 306 with hermes_request( 307 config, "GET", f"/v1/runs/{run_id}/events", timeout=EVENTS_READ_TIMEOUT 308 ) as response: 309 for _, data in iter_sse(response): 310 payload = json.loads(data) 311 event = payload.get("event", "") 312 if event in ("tool.started", "tool.completed"): 313 status = "running" if event == "tool.started" else "completed" 314 if payload.get("error"): 315 status = "error" 316 yield {"type": "tool", "status": status, "label": payload.get("tool")} 317 elif event == "approval.request": 318 # The run blocks until POST /v1/runs/{run_id}/approval answers. 319 yield { 320 "type": "approval", 321 "run_id": run_id, 322 "request_id": payload.get("request_id"), 323 "command": payload.get("command"), 324 "description": payload.get("description"), 325 } 326 elif event == "run.completed": 327 # Only the final answer: message.delta also streams commentary. 328 yield {"type": "delta", "text": payload.get("output") or ""} 329 yield {"type": "done", "finish_reason": "stop"} 330 return 331 elif event in ("run.failed", "run.cancelled", "run.interrupted"): 332 status = event.removeprefix("run.") 333 error = payload.get("error") or f"Hermes run {status}" 334 yield {"type": "done", "finish_reason": status, "error": error} 335 return 336 yield {"type": "error", "message": "Hermes event stream ended early"} 337 338 339def hermes_approve(config, run_id, request_id, choice): 340 """Answer a pending approval of a Hermes run.""" 341 answer = {"choice": choice} 342 if request_id: 343 answer["request_id"] = request_id 344 return hermes_call(config, "POST", f"/v1/runs/{run_id}/approval", answer) 345 346 347# The active run per Hermes session, so a newer delegation can stop it. 348RUNS = {} 349RUNS_LOCK = threading.Lock() 350 351 352def take_run(hermes_session, run_id=None): 353 """Remove and return the session's run (only if it is run_id, when given).""" 354 with RUNS_LOCK: 355 if run_id is None or RUNS.get(hermes_session) == run_id: 356 return RUNS.pop(hermes_session, None) 357 return None 358 359 360def stop_run(config, run_id): 361 """POST /v1/runs/{run_id}/stop; failures are only logged.""" 362 try: 363 hermes_call(config, "POST", f"/v1/runs/{run_id}/stop") 364 log.info("stopped run %s", run_id) 365 except (urllib.error.URLError, OSError, ValueError) as error: 366 log.warning("stopping run %s failed: %s", run_id, error) 367 368 369class Handler(BaseHTTPRequestHandler): 370 server_version = "hermes-voice" 371 config: Config 372 static: Path 373 access_token: "str | None" = None 374 375 def authorized(self): 376 """Check the page's bearer token; answer 401 when it is wrong.""" 377 if self.access_token is None: 378 return True 379 header = self.headers.get("Authorization", "") 380 scheme, _, token = header.partition(" ") 381 if scheme == "Bearer" and hmac.compare_digest( 382 token.encode(), self.access_token.encode() 383 ): 384 return True 385 self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "access token required"}) 386 return False 387 388 def log_message(self, format, *args): 389 log.info("%s %s", self.address_string(), format % args) 390 391 def send_json(self, status, payload): 392 body = json.dumps(payload).encode() 393 self.send_response(status) 394 self.send_header("Content-Type", "application/json") 395 self.send_header("Content-Length", str(len(body))) 396 self.send_header("Cache-Control", "no-store") 397 self.end_headers() 398 self.wfile.write(body) 399 400 def read_json(self): 401 length = int(self.headers.get("Content-Length") or 0) 402 if length <= 0 or length > MAX_BODY: 403 raise ValueError("bad body length") 404 return json.loads(self.rfile.read(length)) 405 406 def do_GET(self): 407 if self.path == "/config": 408 if not self.authorized(): 409 return 410 self.send_json( 411 HTTPStatus.OK, 412 { 413 "model": self.config.model, 414 "voice": self.config.voice, 415 "voices": self.config.voices(), 416 "idle_seconds": self.config.idle_seconds, 417 }, 418 ) 419 return 420 names = { 421 "/": ("index.html", "text/html; charset=utf-8"), 422 "/app.js": ("app.js", "text/javascript; charset=utf-8"), 423 "/style.css": ("style.css", "text/css; charset=utf-8"), 424 } 425 if self.path not in names: 426 self.send_error(HTTPStatus.NOT_FOUND) 427 return 428 name, content_type = names[self.path] 429 body = (self.static / name).read_bytes() 430 self.send_response(HTTPStatus.OK) 431 self.send_header("Content-Type", content_type) 432 self.send_header("Content-Length", str(len(body))) 433 self.send_header("Cache-Control", "no-store") 434 self.end_headers() 435 self.wfile.write(body) 436 437 def do_POST(self): 438 if not self.authorized(): 439 return 440 try: 441 request = self.read_json() 442 except ValueError: 443 self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid JSON body"}) 444 return 445 if self.path == "/session": 446 self.handle_session(request) 447 elif self.path == "/delegate": 448 self.handle_delegate(request) 449 elif self.path == "/approve": 450 self.handle_approve(request) 451 else: 452 self.send_error(HTTPStatus.NOT_FOUND) 453 454 def handle_session(self, request): 455 # The vendor's SDP parser needs the offer byte-exact (trailing CRLF too). 456 sdp = request.get("sdp") 457 if not isinstance(sdp, str) or not sdp.strip(): 458 self.send_json(HTTPStatus.BAD_REQUEST, {"error": "sdp offer required"}) 459 return 460 try: 461 voice = request.get("voice") or self.config.voice 462 if voice not in self.config.voices(): 463 self.send_json(HTTPStatus.BAD_REQUEST, {"error": f"unknown voice {voice!r}"}) 464 return 465 result = create_live_session( 466 self.config, sdp, live_history(request.get("history") or []), voice 467 ) 468 except urllib.error.HTTPError as error: 469 detail = error.read().decode("utf-8", "replace")[:800] 470 log.warning("live session creation failed: %s %s", error.code, detail) 471 self.send_json( 472 HTTPStatus.BAD_GATEWAY, 473 {"error": f"OpenAI rejected the session ({error.code}): {detail}"}, 474 ) 475 return 476 except (urllib.error.URLError, OSError, KeyError, ValueError) as error: 477 log.warning("live session creation failed: %s", error) 478 self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"OpenAI: {error}"}) 479 return 480 self.send_json(HTTPStatus.OK, result) 481 482 def handle_delegate(self, request): 483 hermes_session = request.get("hermes_session") 484 lines = request.get("lines") 485 if not isinstance(hermes_session, str) or not SESSION_ID_RE.match(hermes_session): 486 self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid hermes_session"}) 487 return 488 if not isinstance(lines, list) or not lines: 489 self.send_json(HTTPStatus.BAD_REQUEST, {"error": "lines required"}) 490 return 491 # One run per conversation: a newer delegation (usually a correction) 492 # supersedes and stops the previous run. 493 previous = take_run(hermes_session) 494 if previous: 495 stop_run(self.config, previous) 496 try: 497 run_id = hermes_start_run(self.config, hermes_session, lines) 498 except urllib.error.HTTPError as error: 499 detail = error.read().decode("utf-8", "replace")[:400] 500 self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"Hermes {error.code}: {detail}"}) 501 return 502 except (urllib.error.URLError, OSError, ValueError, KeyError) as error: 503 self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"Hermes unreachable: {error}"}) 504 return 505 with RUNS_LOCK: 506 RUNS[hermes_session] = run_id 507 self.send_response(HTTPStatus.OK) 508 self.send_header("Content-Type", "application/x-ndjson") 509 self.send_header("Cache-Control", "no-store") 510 self.send_header("X-Accel-Buffering", "no") 511 self.end_headers() 512 try: 513 try: 514 started = time.monotonic() 515 tools = 0 516 for item in hermes_follow_run(self.config, run_id): 517 if item["type"] == "tool" and item["status"] == "running": 518 tools += 1 519 self.write_line(item) 520 log.info( 521 "hermes turn took %.1fs (%d tool calls)", time.monotonic() - started, tools 522 ) 523 except urllib.error.HTTPError as error: 524 detail = error.read().decode("utf-8", "replace")[:400] 525 self.write_line({"type": "error", "message": f"Hermes {error.code}: {detail}"}) 526 except (urllib.error.URLError, OSError, ValueError) as error: 527 self.write_line({"type": "error", "message": f"Hermes unreachable: {error}"}) 528 take_run(hermes_session, run_id) 529 except (BrokenPipeError, ConnectionResetError): 530 # The page went away; the run keeps going and stays registered, so 531 # a later delegation still supersedes it. 532 log.info("delegation for %s abandoned by the page", hermes_session) 533 534 def handle_approve(self, request): 535 run_id = request.get("run_id") 536 request_id = request.get("request_id") 537 choice = request.get("choice") 538 # No "always": a permanent allowlist entry is not a voice decision. 539 if ( 540 choice not in ("once", "session", "deny") 541 or not isinstance(run_id, str) 542 or not RUN_ID_RE.match(run_id) 543 or not ( 544 request_id is None 545 or (isinstance(request_id, str) and RUN_ID_RE.match(request_id)) 546 ) 547 ): 548 self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid approval answer"}) 549 return 550 try: 551 result = hermes_approve(self.config, run_id, request_id, choice) 552 except urllib.error.HTTPError as error: 553 detail = error.read().decode("utf-8", "replace")[:400] 554 self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"Hermes {error.code}: {detail}"}) 555 return 556 except (urllib.error.URLError, OSError, ValueError) as error: 557 self.send_json(HTTPStatus.BAD_GATEWAY, {"error": f"Hermes unreachable: {error}"}) 558 return 559 log.info("approval %s for run %s: %s", choice, run_id, result.get("resolved")) 560 self.send_json(HTTPStatus.OK, result) 561 562 def write_line(self, item): 563 self.wfile.write(json.dumps(item).encode() + b"\n") 564 self.wfile.flush() 565 566 567def main(): 568 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) 569 parser.add_argument("--listen", default="127.0.0.1:8790", help="host:port to serve on") 570 parser.add_argument("--hermes-url", required=True, help="Hermes API server base URL") 571 parser.add_argument("--hermes-key-file", required=True, help="file holding API_SERVER_KEY") 572 parser.add_argument("--openai-key-file", required=True, help="file holding the OpenAI API key") 573 parser.add_argument("--access-token-file", help="file holding the token the page must present") 574 parser.add_argument("--config", help="JSON file: voice, model, idle_seconds, glossary, instructions") 575 parser.add_argument( 576 "--static", 577 default=os.environ.get("HERMES_VOICE_STATIC"), 578 help="directory with index.html, app.js, style.css (default: $HERMES_VOICE_STATIC)", 579 ) 580 args = parser.parse_args() 581 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 582 583 host, _, port = args.listen.rpartition(":") 584 host = host.strip("[]") 585 if args.access_token_file is None and host not in ("127.0.0.1", "::1", "localhost"): 586 parser.error("--access-token-file is required when not listening on loopback") 587 588 raw = json.loads(Path(args.config).read_text()) if args.config else {} 589 config = Config( 590 raw, 591 openai_key=Path(args.openai_key_file).read_text().strip(), 592 hermes_url=args.hermes_url, 593 hermes_key=Path(args.hermes_key_file).read_text().strip(), 594 ) 595 Handler.config = config 596 Handler.static = Path(args.static) 597 if args.access_token_file: 598 Handler.access_token = Path(args.access_token_file).read_text().strip() 599 server_class = ThreadingHTTPServer 600 if ":" in host: 601 # "::" is dual-stack on Linux (bindv6only=0). 602 server_class = type("Server6", (ThreadingHTTPServer,), {"address_family": socket.AF_INET6}) 603 server = server_class((host, int(port)), Handler) 604 server.daemon_threads = True 605 log.info("serving on http://%s", args.listen) 606 server.serve_forever() 607 608 609if __name__ == "__main__": 610 main()