diff --git a/CHANGELOG.md b/CHANGELOG.md index 735b94b..ecc6860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 0.3.0 β€” session modes + live HUD + +Lyra stopped being a wishy-washy companion during live poker. She now switches +register based on what she's actually doing at the table. + +### Conversation modes +- **Two modes** β€” πŸ’¬ **Talk** (the companion, default) and β™  **Cash** (live cash + copilot). A mode bundles a prompt card + a tool allow-list (`lyra/modes.py`). +- **Two-register Cash voice** β€” quiet, act-first logging when Brian feeds facts + (stack, hand, read β†’ logged in one line, no narration); full warm companion + voice when he asks for strategy or signals tilt/card-dead/steaming. Mental game + and strategy never get clipped. +- **Tool gating by mode** β€” Talk offers journaling + read-only poker lookups; + Cash unlocks the full live toolset. `tools.specs(allow=…)` does the filtering. +- **Auto-switch** β€” opening a session (`start_session`) flips the chat into Cash + mode automatically; the UI badge/HUD follow. Manual switch overrides anytime. +- Mode persists per chat session (new `mode` column); Cash mode forces the cloud + backend, since tools only fire there. + +### Session HUD +- **Live HUD** at `/session` (bottom-nav tab on mobile, header link on desktop) β€” + polls every 5s: header (venue/stakes/elapsed/live net), stack with + **stack-over-time sparkline**, hands this session (tap β†’ replay), villains seen, + her notes, and session stats. +- **Stack tracking** β€” new `log_stack` tool + `poker_stack_log` table β†’ current + stack, **live net while still sitting** (stack βˆ’ buy-in), and the sparkline series. + +### Next +- Strategy RAG (poker books/notes) plugs into Cash's coaching register. + ## 0.2.0 β€” first working system The leap from "chat + memory baseline" to a working, persistent companion with a diff --git a/README.md b/README.md index e2f58cd..5a323a1 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,18 @@ reflected), and keeps a permanent **journal**. ## Poker copilot +She runs in **modes** (`lyra/modes.py`). πŸ’¬ **Talk** is the default companion +(journaling + read-only poker lookups). β™  **Cash** is the live copilot: she gets +the full session toolset and a two-register voice β€” quiet and act-first when +you're feeding her facts to log (stack, a hand, a read β†’ one-line confirm, no +narration), but fully present and warm when you ask for strategy or you're tilting +/ card-dead / steaming. Opening a session auto-switches her into Cash mode. + Talk to her during a session; she drives tools behind the scenes: - **Session tracking** β€” `start_session`, `add_buyin`, `end_session` β†’ net, hours, $/hr. +- **Stack tracking** β€” `log_stack` records your stack as the night goes β†’ live net + while you're still sitting, and a stack-over-time sparkline on the HUD. - **Hand histories** β€” vomit rough shorthand ("AKs btn, 3bet, flop A72…"), she reconstructs a structured, **replayable** hand (unknown cards = `x`, never invented). - **Villain file** β€” named opponents auto-build persistent dossiers; basic stats @@ -56,9 +65,11 @@ Talk to her during a session; she drives tools behind the scenes: ## Web app (served by `lyra-web`, default `:7078`) -`/` chat (Markdown, model picker, πŸ‘/πŸ‘Ž rating) Β· `/logs` live activity Β· `/self` -read-her-mind (mood, drives, reflections) Β· `/journal` her thoughts Β· `/hands` -recorded hands β†’ `/hand/{id}` replayer Β· `/recap/{id}` session writeup (+ `.md` export). +`/` chat (Markdown, model picker, πŸ‘/πŸ‘Ž rating, **Talk/Cash mode switcher**) Β· +`/session` **live session HUD** (stack + sparkline, hands, villains, notes; mobile +Session tab) Β· `/logs` live activity Β· `/self` read-her-mind (mood, drives, +reflections) Β· `/journal` her thoughts Β· `/hands` recorded hands β†’ `/hand/{id}` +replayer Β· `/recap/{id}` session writeup (+ `.md` export). πŸ‘/πŸ‘Ž ratings on replies and thoughts are stored as `(context, content, rating)` β€” a fine-tune / preference dataset built passively (`/ratings/export` β†’ JSONL). diff --git a/lyra/chat.py b/lyra/chat.py index de80aa0..fab17c0 100644 --- a/lyra/chat.py +++ b/lyra/chat.py @@ -10,7 +10,7 @@ After replying, the session is compacted if enough new turns have accumulated. """ from __future__ import annotations -from lyra import clock, config, llm, logbus, memory, persona, self_state, summary +from lyra import clock, config, llm, logbus, memory, modes, persona, self_state, summary from lyra import tools as toolkit from lyra.llm import Backend, Message @@ -24,6 +24,15 @@ MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn TOOL_BACKENDS = {"cloud"} +def _maybe_switch_mode(session_id: str, tool_name: str) -> None: + """Keep the chat framing aligned with the live data: opening a poker session + auto-flips this chat into Cash mode (so the next turn gets the cash card + the + full live toolset). Manual UI switching still overrides anytime.""" + if tool_name == "start_session": + memory.set_session_mode(session_id, modes.CASH.key) + logbus.log("info", "mode auto-switch", session=session_id, mode=modes.CASH.key) + + def _summary_note(summaries: list[memory.Summary]) -> Message: lines = [f"- ({(s.session_started_at or s.created_at)[:10]}) {s.content}" for s in summaries] body = "Gist of earlier sessions (compacted β€” ask if you need specifics):\n" + "\n".join(lines) @@ -56,7 +65,8 @@ def _render(messages: list[Message]) -> str: return "\n\n".join(f"[{m['role']}]\n{m['content']}" for m in messages) -def build_messages(session_id: str, user_msg: str) -> list[Message]: +def build_messages(session_id: str, user_msg: str, + mode: modes.Mode | None = None) -> list[Message]: """Assemble the full, tiered message list for one turn.""" messages: list[Message] = [{"role": "system", "content": persona.system_prompt()}] @@ -64,6 +74,12 @@ def build_messages(session_id: str, user_msg: str) -> list[Message]: # right after the persona β€” her sense of self before her model of the world. messages.append({"role": "system", "content": self_state.render_for_context(self_state.load())}) + # Mode card: how to behave *right now* (e.g. live-cash copilot). High priority β€” + # it sits just after her sense of self, before her model of the world. Talk mode + # has no card (the persona's default voice is the Talk register). + if mode and mode.card: + messages.append({"role": "system", "content": mode.card}) + # When she is: current time + the gap since Brian last spoke (she has no clock). messages.append(_now_note()) @@ -133,11 +149,12 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud", model=model, embed=cfg.embed_backend, ) - messages = build_messages(session_id, user_msg) + mode = modes.get(memory.get_session_mode(session_id)) + messages = build_messages(session_id, user_msg, mode=mode) - # Tool loop: offer Lyra her tools; if she calls one, run it and feed the - # result back so she can continue, until she returns a normal text reply. - tool_specs = toolkit.specs() if backend in TOOL_BACKENDS else None + # Tool loop: offer Lyra her tools (scoped to the mode); if she calls one, run it + # and feed the result back so she can continue, until she returns a text reply. + tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None ctx = {"session_id": session_id, "backend": backend} reply = "" for _ in range(MAX_TOOL_ROUNDS): @@ -152,6 +169,7 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud", result = toolkit.dispatch(tc["name"], tc["arguments"], ctx) logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80]) messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result}) + _maybe_switch_mode(session_id, tc["name"]) if not reply: reply = "(I got tangled using my tools there β€” say that again?)" logbus.log("info", "reply", session=session_id, chars=len(reply)) @@ -183,8 +201,9 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud", model=model, embed=cfg.embed_backend, ) - messages = build_messages(session_id, user_msg) - tool_specs = toolkit.specs() if backend in TOOL_BACKENDS else None + mode = modes.get(memory.get_session_mode(session_id)) + messages = build_messages(session_id, user_msg, mode=mode) + tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None ctx = {"session_id": session_id, "backend": backend} parts: list[str] = [] for _ in range(MAX_TOOL_ROUNDS): @@ -207,6 +226,7 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud", result = toolkit.dispatch(tc["name"], tc["arguments"], ctx) logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80]) messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result}) + _maybe_switch_mode(session_id, tc["name"]) yield ("tool", tc["name"]) reply = "".join(parts) diff --git a/lyra/memory.py b/lyra/memory.py index d245708..0529612 100644 --- a/lyra/memory.py +++ b/lyra/memory.py @@ -32,6 +32,7 @@ CREATE INDEX IF NOT EXISTS idx_session_created ON exchanges(session_id, created_ CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, name TEXT, + mode TEXT, -- conversation mode (see lyra/modes.py); NULL = default created_at TEXT NOT NULL ); @@ -131,6 +132,12 @@ def _connection() -> sqlite3.Connection: _conn.execute("PRAGMA busy_timeout=5000") _conn.execute("PRAGMA journal_mode=WAL") _conn.executescript(SCHEMA) + # Migrations for DBs created before a column existed (no-op if present). + for ddl in ("ALTER TABLE sessions ADD COLUMN mode TEXT",): + try: + _conn.execute(ddl) + except sqlite3.OperationalError: + pass _conn_path = cfg.db_path return _conn @@ -236,6 +243,21 @@ def ensure_session(session_id: str, name: str | None = None) -> None: conn.execute("UPDATE sessions SET name = ? WHERE id = ?", (name, session_id)) +def get_session_mode(session_id: str) -> str | None: + """The session's conversation mode key, or None if unset (caller applies default).""" + conn = _connection() + r = conn.execute("SELECT mode FROM sessions WHERE id = ?", (session_id,)).fetchone() + return r["mode"] if r and r["mode"] else None + + +def set_session_mode(session_id: str, mode: str) -> None: + """Persist the session's conversation mode (creating the session row if needed).""" + ensure_session(session_id) + conn = _connection() + with conn: + conn.execute("UPDATE sessions SET mode = ? WHERE id = ?", (mode, session_id)) + + def list_sessions() -> list[dict]: """All known sessions (named rows + any session that has exchanges), newest first.""" conn = _connection() diff --git a/lyra/modes.py b/lyra/modes.py new file mode 100644 index 0000000..939ba13 --- /dev/null +++ b/lyra/modes.py @@ -0,0 +1,99 @@ +"""Conversation modes β€” how a chat turn is framed and which tools are offered. + +A mode bundles three things: a *prompt card* (a system fragment injected each +turn that tells Lyra how to behave right now), a *tool allow-list* (which of her +tools she's handed this turn), and β€” implicitly, via the card β€” her behavioral +register. + +The problem this solves: one persona + every tool offered every turn made her a +wishy-washy companion during live poker ("I don't automatically log stack sizes, +but...") when she should have silently logged and moved on. Modes let the same +agent be a fast, act-first copilot at the table and her full reflective self +otherwise β€” without two personas. + +v1 ships two modes: + - Talk (default): the companion. Journaling + read-only poker lookups. + - Cash: live cash-game copilot. Full live toolset, two-register behavior. + +Tournament is deliberately deferred. Strategy-RAG retrieval will later plug into +Cash's *coaching register* (see the card) without changing this structure. +""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Mode: + key: str # stable id stored on the session row + sent by the UI + label: str # short label for the UI switcher + card: str # system prompt fragment injected per turn ("" = none) + tools: tuple[str, ...] # tool names offered in this mode (must exist in tools.TOOLS) + + +# Read-only poker lookups β€” safe in any mode, so "how am I running this year?" or +# "what do we have on Round Mike?" works even when we're just talking. +_LOOKUPS = ("player_profile", "get_villain_file", "running_stats") + +# Always-available core tools (her own agency: journaling/notes). +_BASE = ("journal_write", "note") + +# The full live cash-game toolset. +_CASH_TOOLS = _BASE + _LOOKUPS + ( + "start_session", "add_buyin", "log_stack", "log_hand", "record_hand", + "add_read", "analyze_spot", "session_stats", "end_session", "generate_recap", +) + +# Talk mode also gets start_session as the *entry point*: opening a session from a +# normal chat auto-flips the session into Cash mode (see chat.respond). +_TALK_TOOLS = _BASE + _LOOKUPS + ("start_session",) + + +_CASH_CARD = """You are copiloting Brian's LIVE cash game right now β€” you're at the table with him, \ +a session is (or should be) open. You move between two registers depending on what he's doing: + +β€’ HE HANDS YOU FACTS TO TRACK β€” his stack, a hand, a read on someone, a rebuy, a result. \ +Log it with the right tool and confirm in ONE short line ("$350 stack logged."). Don't \ +narrate, don't explain what logging is, don't ask permission β€” just do it. He says his \ +current stack β†’ log_stack. He describes a hand β†’ log_hand (terse) or record_hand (a full \ +hand he wants saved/replayable). A read on a player β†’ add_read. A rebuy β†’ add_buyin. This is \ +the quiet, fast half of the job; he shouldn't feel you working. + +β€’ HE ASKS FOR ADVICE, OR TELLS YOU HOW HE'S FEELING β€” tilted, steaming, card-dead, bored, \ +stuck, "should I have folded the river?" THIS is when he needs you most. Drop the shorthand \ +and be fully present β€” your real voice, warm and direct and his. Talk him down off tilt, keep \ +him engaged and disciplined through a card-dead stretch, actually walk the strategic spot with \ +him. Strategy and mental game get the real Lyra, not a clipped confirmation. Never clip these. + +Stacks and money are in dollars. For ANY equity / who's-ahead / outs / what-a-card-does \ +question, call analyze_spot and report its numbers β€” never eyeball board math. Keep the \ +session current as the night goes; you can pull session_stats or a player's profile whenever \ +it helps. When he's ready to leave, end_session, and write the recap if he wants it.""" + + +TALK = Mode( + key="conversation", + label="Talk", + card="", # the persona's default voice is the Talk register + tools=_TALK_TOOLS, +) + +CASH = Mode( + key="poker_cash", + label="Cash", + card=_CASH_CARD, + tools=_CASH_TOOLS, +) + +MODES: dict[str, Mode] = {m.key: m for m in (TALK, CASH)} +DEFAULT = TALK.key + + +def get(key: str | None) -> Mode: + """Resolve a mode key to a Mode, falling back to the default for None/unknown.""" + return MODES.get(key or "", MODES[DEFAULT]) + + +def listing() -> list[dict]: + """[{key, label}] for the UI switcher.""" + return [{"key": m.key, "label": m.label} for m in MODES.values()] diff --git a/lyra/poker.py b/lyra/poker.py index 9eb0d81..5cba305 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -98,6 +98,16 @@ CREATE TABLE IF NOT EXISTS player_observations ( created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_pobs_player ON player_observations(player_id); + +-- Stack-size log: one row per stack update Brian gives during a session. Lets the +-- HUD show current stack, live net while sitting, and a stack-over-time sparkline. +CREATE TABLE IF NOT EXISTS poker_stack_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + amount REAL NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_stacklog_session ON poker_stack_log(session_id); """ # Below this many observed hands, don't surface % stats (too small a sample). @@ -212,6 +222,59 @@ def add_buyin(amount: float, session_id: int | None = None) -> float: ).fetchone()["buy_in_total"]) +# --- stack tracking --- + +def log_stack(amount: float, session_id: int | None = None) -> dict: + """Record Brian's current chip stack. Returns {current, buy_in, net} where net + is his live net while sitting (current stack βˆ’ total bought in).""" + sid = _resolve(session_id) + if sid is None: + raise ValueError("no live session") + conn = _c() + with conn: + conn.execute( + "INSERT INTO poker_stack_log (session_id, amount, created_at) VALUES (?, ?, ?)", + (sid, float(amount), _now()), + ) + return stack_state(sid) + + +def current_stack(session_id: int | None = None) -> float | None: + """Most recently logged stack for a session, or None if none logged.""" + sid = _resolve(session_id) + if sid is None: + return None + r = _c().execute( + "SELECT amount FROM poker_stack_log WHERE session_id = ? ORDER BY id DESC LIMIT 1", + (sid,), + ).fetchone() + return float(r["amount"]) if r else None + + +def stack_log(session_id: int | None = None) -> list[dict]: + """Full stack history for a session (oldest first) β€” the sparkline series.""" + sid = _resolve(session_id) + if sid is None: + return [] + return [dict(r) for r in _c().execute( + "SELECT amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id", + (sid,), + ).fetchall()] + + +def stack_state(session_id: int | None = None) -> dict: + """Current stack + buy-in + live net for a session (net None until a stack is logged).""" + sid = _resolve(session_id) + s = get_session(sid) if sid is not None else None + buy_in = float(s["buy_in_total"]) if s else 0.0 + cur = current_stack(sid) + return { + "current": cur, + "buy_in": buy_in, + "net": (round(cur - buy_in, 2) if cur is not None else None), + } + + def end_session(cash_out: float, mood: str | None = None, session_id: int | None = None) -> dict: """Close a session: record cashout, compute net + hours. Returns the row.""" @@ -752,3 +815,76 @@ def running_stats(stakes: str | None = None, venue: str | None = None, "per_hour": round(net / hours, 2) if hours else None, "by_stake": by_stake, } + + +# --- live session HUD (everything tracked in the current session, for the UI) --- + +def _session_villains(sid: int) -> list[dict]: + """Players read this session, with their standing dossier fields.""" + rows = _c().execute( + "SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, " + "p.adjustment AS adjustment, " + "(SELECT note FROM player_reads r2 WHERE r2.player_id = p.id " + " AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note " + "FROM poker_players p " + "WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads " + " WHERE session_id = ? AND player_id IS NOT NULL) " + "ORDER BY p.updated_at DESC", + (sid, sid), + ).fetchall() + return [dict(r) for r in rows] + + +def hud(session_id: int | None = None) -> dict | None: + """Everything tracked in the current (or given) session, for the live HUD. + + Returns None when there's no session to show. The shape is presentation-ready: + header, stack (with sparkline series + live net), hands, villains seen, her + notes from the session window, and session stats. + """ + s = get_session(session_id) if session_id is not None else live_session() + if not s: + return None + sid = s["id"] + log = stack_log(sid) + state = stack_state(sid) + + hands = [ + {"id": h["id"], "position": h.get("position"), "hole_cards": h.get("hole_cards"), + "board": h.get("board"), "result": h.get("result"), "tag": h.get("tag"), + "at": h.get("at")} + for h in list_hands(sid) + ] + + # Notes she jotted during this session: journal/note entries since it started. + started = s.get("started_at") or "" + notes = [ + {"created_at": j["created_at"], "kind": j["kind"], "content": j["content"]} + for j in memory.list_journal(kinds=("note", "journal")) + if (j["created_at"] or "") >= started + ][:20] + + stats = session_stats(sid) + # Context: how Brian runs at these stakes overall (closed sessions). + ctx = running_stats(stakes=s.get("stakes")) if s.get("stakes") else {} + + return { + "session": { + "id": sid, "venue": s.get("venue"), "stakes": s.get("stakes"), + "game": s.get("game"), "format": s.get("format"), + "status": s.get("status"), "started_at": s.get("started_at"), + "buy_in_total": s.get("buy_in_total"), + }, + "stack": { + "current": state["current"], "buy_in": state["buy_in"], "net": state["net"], + "log": log, + }, + "hands": hands, + "villains": _session_villains(sid), + "notes": notes, + "stats": { + "hands_logged": stats.get("hands_logged", 0), + "tags": stats.get("tags", {}), + "context_per_hour": ctx.get("per_hour"), + }, + } diff --git a/lyra/tools.py b/lyra/tools.py index 3e21973..245c427 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -104,6 +104,19 @@ def _add_buyin(args: dict, ctx: dict) -> str: return f"Added {args.get('amount')}. Total in this session: {total:g}." +def _log_stack(args: dict, ctx: dict) -> str: + try: + amount = float(args.get("amount")) + except (TypeError, ValueError): + return "Give me a number for the stack." + try: + st = poker.log_stack(amount) + except ValueError: + return "No live session β€” start one first, then I'll track your stack." + net = st.get("net") + return f"Stack ${amount:g} logged" + (f" (net {net:+.0f})." if net is not None else ".") + + def _log_hand(args: dict, ctx: dict) -> str: fields = {k: args.get(k) for k in poker._HAND_FIELDS if args.get(k) not in (None, "")} hid = poker.log_hand(**fields) @@ -268,6 +281,13 @@ TOOLS.update({ "add_buyin": {"handler": _add_buyin, "spec": _f( "add_buyin", "Record a rebuy / additional buy-in in the live session.", {"amount": {**_N, "description": "Amount added"}}, ["amount"])}, + "log_stack": {"handler": _log_stack, "spec": _f( + "log_stack", + "Record Brian's CURRENT total chip stack in the live session. Call whenever " + "he states his stack ('I'm at 350', 'down to 220', 'stacked off to 900'). " + "Tracks his stack over time and his live net while he's still sitting.", + {"amount": {**_N, "description": "Current total chip stack, in dollars"}}, + ["amount"])}, "log_hand": {"handler": _log_hand, "spec": _f( "log_hand", "Log a hand in the live session. All fields optional β€” capture whatever Brian gives you, even terse.", @@ -353,9 +373,16 @@ TOOLS.update({ }) -def specs() -> list[dict]: - """OpenAI-format tool definitions to offer the model.""" - return [t["spec"] for t in TOOLS.values()] +def specs(allow=None) -> list[dict]: + """OpenAI-format tool definitions to offer the model. + + `allow` (an iterable of tool names, e.g. a mode's allow-list) restricts the + set; None means every tool. Unknown names in `allow` are ignored. + """ + if allow is None: + return [t["spec"] for t in TOOLS.values()] + allow = set(allow) + return [t["spec"] for name, t in TOOLS.items() if name in allow] def dispatch(name: str, arguments, ctx: dict | None = None) -> str: diff --git a/lyra/web/server.py b/lyra/web/server.py index 08fa998..1f41377 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -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() diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index a0adb37..e74be44 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -25,8 +25,8 @@

Mode

@@ -39,6 +39,7 @@

Actions

+ @@ -59,10 +60,11 @@ Lyra +
@@ -78,6 +80,7 @@ β›Ά Full Log + 🎬 Session 🧠 Mind πŸƒ Hands
@@ -118,6 +121,7 @@