feat: session modes (Talk/Cash) + live session HUD

Lyra now switches register based on what she's doing at the table instead of
being a wishy-washy companion mid-session.

Modes (lyra/modes.py):
- Talk (default companion) + Cash (live cash copilot); a mode = prompt card +
  tool allow-list. Tool gating via tools.specs(allow=).
- Two-register Cash voice: act-first one-line logging when fed facts; full warm
  companion voice for strategy / tilt / mental game.
- mode persisted per chat session (new sessions.mode column); auto-switch into
  Cash when start_session fires; UI forces cloud backend in Cash (tools only
  fire there).

Stack tracking + HUD:
- log_stack tool + poker_stack_log table; live net while sitting (stack - buy-in).
- poker.hud() bundle; /session HUD page (stack sparkline, hands, villains, notes,
  stats) polling /session/data every 5s; Talk/Cash switcher + Session nav.

Endpoints: /session, /session/data, GET/POST /sessions/{id}/mode, /modes.
tests/test_modes.py (gating, mode roundtrip, stack/HUD); 36 tests green. v0.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 05:28:15 +00:00
parent d9f5055ec1
commit dfb6425395
14 changed files with 829 additions and 32 deletions
+33 -1
View File
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from lyra import chat, logbus, memory, poker, self_state, summary
from lyra import chat, logbus, memory, modes, poker, self_state, summary
from lyra.llm import Backend
@@ -85,6 +85,34 @@ def create_app() -> FastAPI:
gist = await asyncio.to_thread(summary.summarize_session, session_id)
return {"ok": gist is not None, "summary": gist}
@app.get("/modes")
async def list_modes() -> dict:
"""Available conversation modes, for the UI switcher."""
return {"modes": modes.listing(), "default": modes.DEFAULT}
@app.get("/sessions/{session_id}/mode")
async def get_mode(session_id: str) -> dict:
return {"mode": memory.get_session_mode(session_id) or modes.DEFAULT}
@app.post("/sessions/{session_id}/mode")
async def set_mode(session_id: str, request: Request) -> dict:
body = await request.json()
mode = body.get("mode") or modes.DEFAULT
memory.set_session_mode(session_id, mode)
logbus.log("info", "mode set", session=session_id, mode=mode)
return {"ok": True, "mode": mode}
@app.get("/session")
async def session_hud_page() -> FileResponse:
"""Live session HUD — stack, hands, villains, notes for the open session."""
return FileResponse(str(_STATIC / "session.html"))
@app.get("/session/data")
async def session_hud_data() -> dict:
"""The current live session's HUD bundle (or {session: None} if none open)."""
bundle = await asyncio.to_thread(poker.hud)
return bundle or {"session": None}
@app.post("/v1/chat/completions")
async def chat_completions(request: Request) -> dict:
body = await request.json()
@@ -94,6 +122,8 @@ def create_app() -> FastAPI:
model_override = body.get("model") or None
memory.ensure_session(session_id)
if body.get("mode"):
memory.set_session_mode(session_id, body["mode"])
try:
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend, model_override)
except Exception as exc:
@@ -124,6 +154,8 @@ def create_app() -> FastAPI:
user_msg = _last_user_message(body.get("messages", []))
model_override = body.get("model") or None
memory.ensure_session(session_id)
if body.get("mode"):
memory.set_session_mode(session_id, body["mode"])
async def gen():
loop = asyncio.get_running_loop()