infinicode 2.8.66 → 2.8.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,7 @@ import hashlib
14
14
  import hmac
15
15
  import time
16
16
  from datetime import datetime, timedelta
17
- from typing import Optional, List
17
+ from typing import Optional, List
18
18
  from contextlib import asynccontextmanager
19
19
 
20
20
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Query, Header
@@ -230,6 +230,20 @@ class PipelineEventPayload(BaseModel):
230
230
  session_id: Optional[str] = None
231
231
  source: str = "robot"
232
232
  details: Optional[dict] = None
233
+
234
+ class TranscriptTurnPayload(BaseModel):
235
+ role: str
236
+ text: str
237
+ speaker: Optional[str] = None
238
+ is_final: bool = True
239
+ sequence: Optional[int] = None
240
+ source: str = "voice_agent"
241
+ metadata: Optional[dict] = None
242
+
243
+ class VoiceCallRequest(BaseModel):
244
+ character_preset_id: str
245
+ voice_stack_id: Optional[str] = None
246
+ client_name: str = "RoboPark Operator"
233
247
 
234
248
  class SupervisorStatusReport(BaseModel):
235
249
  services: List[ServiceStatus] = []
@@ -471,6 +485,23 @@ async def init_db():
471
485
  );
472
486
  CREATE INDEX IF NOT EXISTS idx_pipeline_robot_time
473
487
  ON pipeline_events(robot_id, timestamp DESC);
488
+
489
+ CREATE TABLE IF NOT EXISTS session_transcripts (
490
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
491
+ session_id TEXT NOT NULL,
492
+ robot_id TEXT,
493
+ role TEXT NOT NULL,
494
+ speaker TEXT,
495
+ text TEXT NOT NULL,
496
+ is_final INTEGER NOT NULL DEFAULT 1,
497
+ sequence INTEGER,
498
+ source TEXT NOT NULL DEFAULT 'voice_agent',
499
+ metadata TEXT,
500
+ timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
501
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
502
+ );
503
+ CREATE INDEX IF NOT EXISTS idx_transcript_session_time
504
+ ON session_transcripts(session_id, timestamp, id);
474
505
 
475
506
  -- Scheduler-managed voice stacks (STT/LLM/TTS configuration)
476
507
  CREATE TABLE IF NOT EXISTS voice_stacks (
@@ -558,7 +589,11 @@ async def init_db():
558
589
  # started_at at INSERT time; existing rows on an upgraded DB will
559
590
  # have NULL here until touched, so readers should fall back to
560
591
  # started_at (see the /status endpoint below).
561
- ("sessions", "last_activity_at", "TEXT"),
592
+ ("sessions", "last_activity_at", "TEXT"),
593
+ ("sessions", "character_id", "TEXT"),
594
+ ("sessions", "voice_stack_id", "TEXT"),
595
+ ("sessions", "initiated_by", "TEXT"),
596
+ ("sessions", "event_token_hash", "TEXT"),
562
597
  ):
563
598
  try:
564
599
  await db.execute(f"ALTER TABLE {_table} ADD COLUMN {_column} {_coldef}")
@@ -1238,14 +1273,104 @@ class WebClientSessionRequest(BaseModel):
1238
1273
  """Request body for web client session"""
1239
1274
  client_name: Optional[str] = "Web User"
1240
1275
 
1241
- class WebClientSessionResponse(BaseModel):
1276
+ class WebClientSessionResponse(BaseModel):
1242
1277
  """Response for web client session"""
1243
1278
  session_id: str
1244
1279
  server_id: str
1245
1280
  server_url: str
1246
1281
  room_name: str
1247
1282
  api_key: str
1248
- api_secret: str
1283
+ api_secret: str
1284
+
1285
+ async def _select_available_server(db):
1286
+ async with db.execute("""
1287
+ SELECT s.*,
1288
+ (SELECT COUNT(*) FROM sessions WHERE server_id = s.id AND ended_at IS NULL) AS active
1289
+ FROM livekit_servers s
1290
+ WHERE s.status = 'online'
1291
+ ORDER BY active ASC
1292
+ LIMIT 1
1293
+ """) as cursor:
1294
+ server = await cursor.fetchone()
1295
+ if not server:
1296
+ raise HTTPException(503, "No available servers")
1297
+ if server["active"] >= server["max_sessions"]:
1298
+ raise HTTPException(503, "All servers at capacity")
1299
+ return server
1300
+
1301
+ @app.post("/api/sessions/voice-call")
1302
+ async def create_voice_call(payload: VoiceCallRequest):
1303
+ """Create a browser voice call using a frozen character and voice stack.
1304
+
1305
+ Unlike the legacy web-client endpoint this never exposes LiveKit API
1306
+ credentials. The event token only authorizes transcript writes for this
1307
+ session and is stored as a hash.
1308
+ """
1309
+ async with aiosqlite.connect(DB_PATH) as db:
1310
+ db.row_factory = aiosqlite.Row
1311
+ async with db.execute(
1312
+ "SELECT * FROM character_presets WHERE id = ?", (payload.character_preset_id,)
1313
+ ) as cursor:
1314
+ character = await cursor.fetchone()
1315
+ if not character:
1316
+ raise HTTPException(404, "Character preset not found")
1317
+ stack_id = payload.voice_stack_id or character["voice_stack_id"]
1318
+ if not stack_id:
1319
+ raise HTTPException(422, "Character has no voice stack")
1320
+ async with db.execute("SELECT id FROM voice_stacks WHERE id = ?", (stack_id,)) as cursor:
1321
+ if not await cursor.fetchone():
1322
+ raise HTTPException(404, "Voice stack not found")
1323
+ server = await _select_available_server(db)
1324
+ now = datetime.utcnow()
1325
+ nonce = secrets.token_urlsafe(8).replace("-", "").replace("_", "")
1326
+ session_id = f"voice_{now.strftime('%Y%m%d%H%M%S')}_{nonce}"
1327
+ room_name = f"robopark-voice-{nonce}"
1328
+ event_token = secrets.token_urlsafe(32)
1329
+ await db.execute("""
1330
+ INSERT INTO sessions
1331
+ (id, robot_id, server_id, room_name, started_at, last_activity_at,
1332
+ character_id, voice_stack_id, initiated_by, event_token_hash)
1333
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1334
+ """, (
1335
+ session_id, "operator-call", server["id"], room_name,
1336
+ now.isoformat(), now.isoformat(), character["id"], stack_id,
1337
+ (payload.client_name or "RoboPark Operator")[:80], _hash(event_token),
1338
+ ))
1339
+ await db.commit()
1340
+
1341
+ try:
1342
+ from livekit.api import AccessToken, VideoGrants
1343
+ except ImportError:
1344
+ raise HTTPException(503, "livekit-api not installed on scheduler")
1345
+ identity = f"operator:{nonce}"
1346
+ token = (
1347
+ AccessToken(server["api_key"] or "devkey", server["api_secret"] or "secret")
1348
+ .with_identity(identity)
1349
+ .with_name((payload.client_name or "RoboPark Operator")[:80])
1350
+ .with_ttl(timedelta(hours=2))
1351
+ .with_grants(VideoGrants(
1352
+ room=room_name, room_join=True, can_publish=True,
1353
+ can_subscribe=True, can_publish_data=True,
1354
+ ))
1355
+ .to_jwt()
1356
+ )
1357
+ await _log_history(
1358
+ "session", "session", session_id, "voice_call_started",
1359
+ (payload.client_name or "operator")[:64],
1360
+ f"character={character['id']}, voice_stack={stack_id}",
1361
+ )
1362
+ await broadcast("voice_call_started", {"session_id": session_id, "character_id": character["id"]})
1363
+ return {
1364
+ "session_id": session_id,
1365
+ "server_id": server["id"],
1366
+ "server_url": server["url"],
1367
+ "room_name": room_name,
1368
+ "identity": identity,
1369
+ "token": token,
1370
+ "event_token": event_token,
1371
+ "character": {"id": character["id"], "name": character["name"], "img": character["img"]},
1372
+ "voice_stack_id": stack_id,
1373
+ }
1249
1374
 
1250
1375
  @app.post("/api/sessions/web-client", response_model=WebClientSessionResponse)
1251
1376
  async def create_web_client_session(req: Optional[WebClientSessionRequest] = None):
@@ -1333,17 +1458,18 @@ def _session_latency_ms(started_at: Optional[str], joined_at: Optional[str]) ->
1333
1458
  except Exception:
1334
1459
  return None
1335
1460
 
1336
- @app.post("/api/sessions/{session_id}/joined")
1337
- async def session_joined(session_id: str,
1338
- authorization: Optional[str] = Header(default=None)):
1461
+ @app.post("/api/sessions/{session_id}/joined")
1462
+ async def session_joined(session_id: str,
1463
+ authorization: Optional[str] = Header(default=None),
1464
+ session_token: Optional[str] = Header(default=None, alias="X-RoboPark-Session-Token")):
1339
1465
  """Mark the moment a robot successfully joined the LiveKit room for a session.
1340
1466
 
1341
1467
  Used to compute session latency = joined_at - started_at (the session row's
1342
1468
  started_at is set when the session is requested). Requires a valid
1343
1469
  enrolled-device Bearer token. Idempotent: the first join wins; later calls
1344
1470
  leave the original join time in place."""
1345
- if not await _authorize_fleet(authorization):
1346
- raise HTTPException(401, "Invalid or missing device token")
1471
+ if not await _session_event_authorized(session_id, session_token, authorization, None):
1472
+ raise HTTPException(401, "Invalid or missing device token")
1347
1473
  now = datetime.utcnow().isoformat()
1348
1474
  async with aiosqlite.connect(DB_PATH) as db:
1349
1475
  db.row_factory = aiosqlite.Row
@@ -1497,17 +1623,160 @@ async def get_session_token(session_id: str):
1497
1623
 
1498
1624
  return {"token": token, "room_name": room_name}
1499
1625
 
1500
- @app.get("/api/sessions")
1501
- async def list_sessions(active_only: bool = False, limit: int = 50):
1502
- """List sessions"""
1503
- async with aiosqlite.connect(DB_PATH) as db:
1504
- db.row_factory = aiosqlite.Row
1505
- if active_only:
1506
- query = "SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC"
1507
- else:
1508
- query = f"SELECT * FROM sessions ORDER BY started_at DESC LIMIT {limit}"
1509
- async with db.execute(query) as cursor:
1510
- return [dict(row) for row in await cursor.fetchall()]
1626
+ @app.get("/api/sessions")
1627
+ async def list_sessions(active_only: bool = False, limit: int = 50):
1628
+ """List enriched sessions for the operator history and live views."""
1629
+ limit = max(1, min(limit, 1000))
1630
+ async with aiosqlite.connect(DB_PATH) as db:
1631
+ db.row_factory = aiosqlite.Row
1632
+ base = """SELECT s.*, s.id AS session_id,
1633
+ cp.name AS character_name, cp.img AS character_img,
1634
+ CASE WHEN s.joined_at IS NOT NULL THEN
1635
+ CAST((julianday(s.joined_at) - julianday(s.started_at)) * 86400000 AS INTEGER)
1636
+ END AS avg_latency_ms,
1637
+ (SELECT COUNT(*) FROM session_transcripts t
1638
+ WHERE t.session_id = s.id AND t.is_final = 1) AS transcript_count,
1639
+ (SELECT COUNT(*) FROM pipeline_events p
1640
+ WHERE p.session_id = s.id AND p.status IN ('failed','blocked')) AS error_count,
1641
+ (SELECT COUNT(*) FROM pipeline_events p
1642
+ WHERE p.session_id = s.id) AS pipeline_event_count
1643
+ FROM sessions s
1644
+ LEFT JOIN character_presets cp ON cp.id = COALESCE(
1645
+ s.character_id,
1646
+ (SELECT character_id FROM devices d WHERE d.id = s.robot_id),
1647
+ (SELECT character_id FROM robots r WHERE r.id = s.robot_id)
1648
+ )"""
1649
+ if active_only:
1650
+ query = base + " WHERE s.ended_at IS NULL ORDER BY s.started_at DESC"
1651
+ else:
1652
+ query = base + " ORDER BY s.started_at DESC LIMIT ?"
1653
+ async with db.execute(query, () if active_only else (limit,)) as cursor:
1654
+ return [dict(row) for row in await cursor.fetchall()]
1655
+
1656
+ async def _session_event_authorized(session_id: str, session_token: Optional[str],
1657
+ authorization: Optional[str], agent_token: Optional[str]) -> bool:
1658
+ if await _authorize_fleet(authorization):
1659
+ return True
1660
+ if ROBOPARK_AGENT_TOKEN and agent_token and hmac.compare_digest(agent_token.strip(), ROBOPARK_AGENT_TOKEN):
1661
+ return True
1662
+ if not session_token:
1663
+ return False
1664
+ async with aiosqlite.connect(DB_PATH) as db:
1665
+ async with db.execute("SELECT event_token_hash FROM sessions WHERE id = ?", (session_id,)) as cursor:
1666
+ row = await cursor.fetchone()
1667
+ return bool(row and row[0] and hmac.compare_digest(row[0], _hash(session_token.strip())))
1668
+
1669
+ @app.post("/api/sessions/{session_id}/transcript")
1670
+ async def append_session_transcript(
1671
+ session_id: str,
1672
+ payload: TranscriptTurnPayload,
1673
+ authorization: Optional[str] = Header(default=None),
1674
+ agent_token: Optional[str] = Header(default=None, alias="X-RoboPark-Agent-Token"),
1675
+ session_token: Optional[str] = Header(default=None, alias="X-RoboPark-Session-Token"),
1676
+ ):
1677
+ if not await _session_event_authorized(session_id, session_token, authorization, agent_token):
1678
+ raise HTTPException(401, "Invalid or missing session event token")
1679
+ role = (payload.role or "").strip().lower()
1680
+ if role not in {"user", "assistant", "system", "tool"}:
1681
+ raise HTTPException(422, "role must be user, assistant, system, or tool")
1682
+ text = (payload.text or "").strip()
1683
+ if not text:
1684
+ raise HTTPException(422, "text is required")
1685
+ if len(text) > 20000:
1686
+ raise HTTPException(422, "text exceeds 20000 characters")
1687
+ now = datetime.utcnow().isoformat()
1688
+ async with aiosqlite.connect(DB_PATH) as db:
1689
+ db.row_factory = aiosqlite.Row
1690
+ async with db.execute("SELECT robot_id, ended_at FROM sessions WHERE id = ?", (session_id,)) as cursor:
1691
+ session = await cursor.fetchone()
1692
+ if not session:
1693
+ raise HTTPException(404, "Session not found")
1694
+ cur = await db.execute("""
1695
+ INSERT INTO session_transcripts
1696
+ (session_id, robot_id, role, speaker, text, is_final, sequence, source, metadata, timestamp)
1697
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1698
+ """, (
1699
+ session_id, session["robot_id"], role, (payload.speaker or "")[:120] or None,
1700
+ text, int(payload.is_final), payload.sequence, (payload.source or "unknown")[:40],
1701
+ json.dumps(payload.metadata or {}, separators=(",", ":"))[:8000], now,
1702
+ ))
1703
+ if not session["ended_at"]:
1704
+ await db.execute("UPDATE sessions SET last_activity_at = ? WHERE id = ?", (now, session_id))
1705
+ await db.commit()
1706
+ await broadcast("transcript_turn", {"session_id": session_id, "role": role, "is_final": payload.is_final})
1707
+ return {"status": "ok", "id": cur.lastrowid, "timestamp": now}
1708
+
1709
+ @app.post("/api/sessions/by-room/{room_name}/transcript")
1710
+ async def append_room_transcript(
1711
+ room_name: str,
1712
+ payload: TranscriptTurnPayload,
1713
+ authorization: Optional[str] = Header(default=None),
1714
+ agent_token: Optional[str] = Header(default=None, alias="X-RoboPark-Agent-Token"),
1715
+ ):
1716
+ """Persist a ROBOVOICE turn without coupling the worker to session ID formats."""
1717
+ async with aiosqlite.connect(DB_PATH) as db:
1718
+ async with db.execute(
1719
+ "SELECT id FROM sessions WHERE room_name = ? ORDER BY started_at DESC LIMIT 1",
1720
+ (room_name,),
1721
+ ) as cursor:
1722
+ row = await cursor.fetchone()
1723
+ if not row:
1724
+ raise HTTPException(404, "No session bound to this room")
1725
+ return await append_session_transcript(
1726
+ row[0], payload, authorization=authorization, agent_token=agent_token, session_token=None
1727
+ )
1728
+
1729
+ @app.get("/api/sessions/{session_id}/detail")
1730
+ async def session_detail(session_id: str):
1731
+ async with aiosqlite.connect(DB_PATH) as db:
1732
+ db.row_factory = aiosqlite.Row
1733
+ async with db.execute("""
1734
+ SELECT s.*, s.id AS session_id, cp.name AS character_name, cp.img AS character_img,
1735
+ vs.name AS voice_stack_name
1736
+ FROM sessions s
1737
+ LEFT JOIN character_presets cp ON cp.id = COALESCE(
1738
+ s.character_id,
1739
+ (SELECT character_id FROM devices d WHERE d.id = s.robot_id),
1740
+ (SELECT character_id FROM robots r WHERE r.id = s.robot_id)
1741
+ )
1742
+ LEFT JOIN voice_stacks vs ON vs.id = COALESCE(s.voice_stack_id, cp.voice_stack_id)
1743
+ WHERE s.id = ?
1744
+ """, (session_id,)) as cursor:
1745
+ session = await cursor.fetchone()
1746
+ if not session:
1747
+ raise HTTPException(404, "Session not found")
1748
+ async with db.execute(
1749
+ "SELECT * FROM session_transcripts WHERE session_id = ? ORDER BY COALESCE(sequence, id), timestamp, id",
1750
+ (session_id,),
1751
+ ) as cursor:
1752
+ transcript = [dict(r) for r in await cursor.fetchall()]
1753
+ async with db.execute(
1754
+ "SELECT * FROM pipeline_events WHERE session_id = ? ORDER BY timestamp, id", (session_id,)
1755
+ ) as cursor:
1756
+ pipeline = [dict(r) for r in await cursor.fetchall()]
1757
+ for turn in transcript:
1758
+ try:
1759
+ turn["metadata"] = json.loads(turn.get("metadata") or "{}")
1760
+ except Exception:
1761
+ turn["metadata"] = {}
1762
+ turn["is_final"] = bool(turn.get("is_final"))
1763
+ for event in pipeline:
1764
+ try:
1765
+ event["details"] = json.loads(event.get("details") or "{}")
1766
+ except Exception:
1767
+ event["details"] = {}
1768
+ data = dict(session)
1769
+ data.pop("event_token_hash", None)
1770
+ data["latency_ms"] = _session_latency_ms(data.get("started_at"), data.get("joined_at"))
1771
+ data["transcript"] = transcript
1772
+ data["pipeline_events"] = pipeline
1773
+ data["summary"] = {
1774
+ "turns": len([t for t in transcript if t["is_final"]]),
1775
+ "user_turns": len([t for t in transcript if t["is_final"] and t["role"] == "user"]),
1776
+ "assistant_turns": len([t for t in transcript if t["is_final"] and t["role"] == "assistant"]),
1777
+ "pipeline_errors": len([e for e in pipeline if e["status"] in {"failed", "blocked"}]),
1778
+ }
1779
+ return data
1511
1780
 
1512
1781
  @app.get("/api/sessions/stats")
1513
1782
  async def session_stats():
@@ -2168,7 +2437,17 @@ async def preview_agent(robot_id: str, authorization: Optional[str] = Header(def
2168
2437
  """The ROBOT polls this. While an operator preview is active it returns a
2169
2438
  PUBLISH token + room so the Pi opens its cam and joins; otherwise
2170
2439
  {active:false} so the Pi stops publishing. Enrolled-device auth."""
2171
- if not await _authorize_fleet(authorization):
2440
+ fleet_authorized = await _authorize_fleet(authorization)
2441
+ event_authorized = False
2442
+ if session_token:
2443
+ async with aiosqlite.connect(DB_PATH) as auth_db:
2444
+ async with auth_db.execute("SELECT event_token_hash FROM sessions WHERE id = ?", (session_id,)) as c:
2445
+ auth_row = await c.fetchone()
2446
+ event_authorized = bool(
2447
+ auth_row and auth_row[0]
2448
+ and hmac.compare_digest(auth_row[0], _hash(session_token.strip()))
2449
+ )
2450
+ if not fleet_authorized and not event_authorized:
2172
2451
  raise HTTPException(401, "Invalid or missing device token")
2173
2452
  async with aiosqlite.connect(DB_PATH) as db:
2174
2453
  db.row_factory = aiosqlite.Row
@@ -3640,7 +3919,7 @@ async def _auto_elevenlabs_voice(robot_name: Optional[str], current: Optional[st
3640
3919
  return current
3641
3920
 
3642
3921
 
3643
- async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
3922
+ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
3644
3923
  """Public helper to resolve the effective voice config for a robot id."""
3645
3924
  async with aiosqlite.connect(DB_PATH) as db:
3646
3925
  db.row_factory = aiosqlite.Row
@@ -3692,14 +3971,48 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
3692
3971
  if stack_dict:
3693
3972
  stack_dict["tts_voice"] = await _auto_elevenlabs_voice(character_name or character_id, stack_dict.get("tts_voice"))
3694
3973
  voice_stack = VoiceStack(**stack_dict) if stack_dict else None
3695
- return RobotVoiceConfig(
3974
+ return RobotVoiceConfig(
3696
3975
  character_id=character_id,
3697
3976
  character_name=character_name,
3698
3977
  system_prompt=system_prompt,
3699
3978
  voice_stack_id=voice_stack.id if voice_stack else None,
3700
3979
  voice_stack=voice_stack,
3701
- greeting_phrases=greeting_phrases,
3702
- )
3980
+ greeting_phrases=greeting_phrases,
3981
+ )
3982
+
3983
+ async def _resolve_session_voice_config(session_row) -> RobotVoiceConfig:
3984
+ """Resolve the immutable character/stack snapshot selected for a web call."""
3985
+ character_id = session_row["character_id"]
3986
+ stack_id = session_row["voice_stack_id"]
3987
+ if not character_id:
3988
+ return await _resolve_robot_voice_config(session_row["robot_id"])
3989
+ async with aiosqlite.connect(DB_PATH) as db:
3990
+ db.row_factory = aiosqlite.Row
3991
+ async with db.execute(
3992
+ "SELECT name, system_prompt, voice_stack_id FROM character_presets WHERE id = ?",
3993
+ (character_id,),
3994
+ ) as cursor:
3995
+ character = await cursor.fetchone()
3996
+ stack_id = stack_id or (character["voice_stack_id"] if character else None)
3997
+ stack = None
3998
+ if stack_id:
3999
+ async with db.execute("SELECT * FROM voice_stacks WHERE id = ?", (stack_id,)) as cursor:
4000
+ stack_row = await cursor.fetchone()
4001
+ if stack_row:
4002
+ stack_data = dict(stack_row)
4003
+ stack_data["tts_voice"] = await _auto_elevenlabs_voice(
4004
+ character["name"] if character else character_id,
4005
+ stack_data.get("tts_voice"),
4006
+ )
4007
+ stack = VoiceStack(**stack_data)
4008
+ return RobotVoiceConfig(
4009
+ character_id=character_id,
4010
+ character_name=character["name"] if character else character_id,
4011
+ system_prompt=character["system_prompt"] if character else None,
4012
+ voice_stack_id=stack.id if stack else stack_id,
4013
+ voice_stack=stack,
4014
+ greeting_phrases=[],
4015
+ )
3703
4016
 
3704
4017
 
3705
4018
  @app.get("/api/voice-stacks", response_model=List[VoiceStack])
@@ -4186,22 +4499,24 @@ async def update_robot_voice_config(robot_id: str, payload: RobotVoiceConfigUpda
4186
4499
 
4187
4500
 
4188
4501
  @app.get("/api/devices/by-room/{room_name}/voice-config", response_model=RobotVoiceConfig)
4189
- async def get_device_voice_config_by_room(room_name: str):
4502
+ async def get_device_voice_config_by_room(room_name: str):
4190
4503
  """ROBOVOICE agent calls this when it joins a room to fetch per-robot config."""
4191
4504
  async with aiosqlite.connect(DB_PATH) as db:
4192
4505
  db.row_factory = aiosqlite.Row
4193
- async with db.execute(
4194
- "SELECT robot_id FROM sessions WHERE room_name = ? ORDER BY started_at DESC LIMIT 1",
4195
- (room_name,),
4196
- ) as c:
4197
- session = await c.fetchone()
4506
+ async with db.execute(
4507
+ "SELECT robot_id, character_id, voice_stack_id FROM sessions WHERE room_name = ? ORDER BY started_at DESC LIMIT 1",
4508
+ (room_name,),
4509
+ ) as c:
4510
+ session = await c.fetchone()
4198
4511
  if not session:
4199
4512
  if room_name.startswith("robopark-"):
4200
4513
  device_id = room_name[len("robopark-"):]
4201
4514
  if device_id:
4202
4515
  return await _resolve_robot_voice_config(device_id)
4203
- raise HTTPException(404, "No session bound to this room")
4204
- return await _resolve_robot_voice_config(session["robot_id"])
4516
+ raise HTTPException(404, "No session bound to this room")
4517
+ if session["character_id"]:
4518
+ return await _resolve_session_voice_config(session)
4519
+ return await _resolve_robot_voice_config(session["robot_id"])
4205
4520
 
4206
4521
 
4207
4522
  # =============================================================================
@@ -4213,18 +4528,45 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
4213
4528
 
4214
4529
  Returns trigger_count, sessions_total, avg_latency_ms, drop_rate, drops and
4215
4530
  an end_reason breakdown; None if the robot does not exist."""
4216
- async with db.execute(
4217
- "SELECT trigger_count, name, status, current_session_id, connected_server_id FROM robots WHERE id = ?", (robot_id,)
4218
- ) as c:
4531
+ async with db.execute(
4532
+ "SELECT trigger_count, name, status, current_session_id, connected_server_id, character_id FROM robots WHERE id = ?", (robot_id,)
4533
+ ) as c:
4219
4534
  robot = await c.fetchone()
4220
4535
  if not robot:
4221
4536
  return None
4222
4537
  trigger_count = robot["trigger_count"] or 0
4223
4538
 
4224
- async with db.execute(
4225
- "SELECT status, production_mode, last_heartbeat FROM devices WHERE id = ?", (robot_id,)
4226
- ) as c:
4227
- device = await c.fetchone()
4539
+ async with db.execute(
4540
+ """SELECT status, production_mode, last_heartbeat, character_id,
4541
+ video_device, audio_device, audio_output_device,
4542
+ device_inventory, supervisor_status, supervisor_status_at
4543
+ FROM devices WHERE id = ?""",
4544
+ (robot_id,),
4545
+ ) as c:
4546
+ device = await c.fetchone()
4547
+
4548
+ character_id = (device["character_id"] if device else None) or robot["character_id"]
4549
+ character_name = None
4550
+ character_img = None
4551
+ if character_id:
4552
+ async with db.execute(
4553
+ "SELECT name, img FROM character_presets WHERE id = ?", (character_id,)
4554
+ ) as c:
4555
+ character = await c.fetchone()
4556
+ if character:
4557
+ character_name = character["name"]
4558
+ character_img = character["img"]
4559
+
4560
+ def parse_json_field(value, fallback):
4561
+ if not value:
4562
+ return fallback
4563
+ try:
4564
+ return json.loads(value)
4565
+ except (TypeError, ValueError, json.JSONDecodeError):
4566
+ return fallback
4567
+
4568
+ device_inventory = parse_json_field(device["device_inventory"], {}) if device else {}
4569
+ supervisor_status = parse_json_field(device["supervisor_status"], []) if device else []
4228
4570
  session = None
4229
4571
  if robot["current_session_id"]:
4230
4572
  async with db.execute(
@@ -4319,7 +4661,10 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
4319
4661
 
4320
4662
  return {
4321
4663
  "robot_id": robot_id,
4322
- "name": robot["name"],
4664
+ "name": robot["name"],
4665
+ "character_id": character_id,
4666
+ "character_name": character_name,
4667
+ "character_img": character_img,
4323
4668
  "lan_ip": lan_ip,
4324
4669
  "vision_port": DEFAULT_VISION_PORT,
4325
4670
  "trigger_count": trigger_count,
@@ -4335,7 +4680,13 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
4335
4680
  "session_age_seconds": session_age,
4336
4681
  "session_activity_age_seconds": activity_age,
4337
4682
  "heartbeat_age_seconds": heartbeat_age,
4338
- "device_status": device["status"] if device else None,
4683
+ "device_status": device["status"] if device else None,
4684
+ "video_device": device["video_device"] if device else None,
4685
+ "audio_device": device["audio_device"] if device else None,
4686
+ "audio_output_device": device["audio_output_device"] if device else None,
4687
+ "device_inventory": device_inventory,
4688
+ "supervisor_status": supervisor_status,
4689
+ "supervisor_status_at": device["supervisor_status_at"] if device else None,
4339
4690
  "production_mode": bool(device["production_mode"]) if device else False,
4340
4691
  "readiness_code": readiness_code,
4341
4692
  "readiness_message": readiness_message,