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
+28 -8
View File
@@ -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)
+22
View File
@@ -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()
+99
View File
@@ -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()]
+136
View File
@@ -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"),
},
}
+30 -3
View File
@@ -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:
+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()
+71 -13
View File
@@ -25,8 +25,8 @@
<div class="mobile-menu-section">
<h4>Mode</h4>
<select id="mobileMode">
<option value="standard">Standard</option>
<option value="cortex">Cortex</option>
<option value="conversation">💬 Talk</option>
<option value="poker_cash">♠ Cash</option>
</select>
</div>
@@ -39,6 +39,7 @@
<div class="mobile-menu-section">
<h4>Actions</h4>
<button id="mobileSessionBtn">🎬 Session HUD</button>
<button id="mobileThinkingStreamBtn">📜 Live Log (inline)</button>
<button id="mobileFullLogBtn">⛶ Full Log</button>
<button id="mobileJournalBtn">📔 Journal</button>
@@ -59,10 +60,11 @@
</button>
<span class="brand">Lyra</span>
<span class="brand-dot" id="brandDot" title="Relay status"></span>
<button class="mode-badge" id="modeBadge" type="button" title="Tap to toggle Talk / Cash mode">💬 Talk</button>
<label for="mode">Mode:</label>
<select id="mode">
<option value="standard">Standard</option>
<option value="cortex">Cortex</option>
<option value="conversation">💬 Talk</option>
<option value="poker_cash">♠ Cash</option>
</select>
<button id="settingsBtn" style="margin-left: auto;">⚙ Settings</button>
<div id="theme-toggle">
@@ -78,6 +80,7 @@
<button id="renameSessionBtn">✏️ Rename</button>
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
<a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a>
<a id="sessionBtn" href="/session" target="_blank" rel="noopener" title="Live session HUD" role="button">🎬 Session</a>
<a id="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a>
<a id="handsBtn" href="/hands" target="_blank" rel="noopener" title="Recorded poker hands" role="button">🃏 Hands</a>
</div>
@@ -118,6 +121,7 @@
<!-- Bottom tab bar (mobile only; hides while the keyboard is open) -->
<nav id="tabbar" aria-label="Primary navigation">
<a class="tab active" href="/" aria-current="page"><span class="ti">💬</span><span class="tl">Chat</span></a>
<a class="tab" href="/session"><span class="ti">🎬</span><span class="tl">Session</span></a>
<a class="tab" href="/hands"><span class="ti">🃏</span><span class="tl">Hands</span></a>
<a class="tab" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
<button class="tab" id="moreTab" type="button"><span class="ti"></span><span class="tl">More</span></button>
@@ -298,6 +302,10 @@
// Which chat backend to use (local Ollama vs cloud OpenAI).
let backend = localStorage.getItem("standardModeBackend") || "local";
// Cash mode is useless without tools, and tools only fire on cloud — so a
// live poker session forces the cloud backend regardless of the saved pick.
if (mode === "poker_cash") backend = "cloud";
const body = {
mode: mode,
messages: history,
@@ -376,6 +384,12 @@
finalizeAssistantBubble(div, full || "(no reply)");
history.push({ role: "assistant", content: full || "(no reply)" });
await saveSession();
// If she opened a session this turn, the server auto-flips to Cash mode —
// reflect that here so the badge/HUD follow without a manual switch.
if (document.getElementById("mode").value !== "poker_cash") {
loadModeFor(currentSession);
}
}
function createAssistantBubble() {
@@ -499,6 +513,44 @@
}
// ----- Conversation mode (Talk / Cash) -----
const MODE_LABELS = { conversation: "💬 Talk", poker_cash: "♠ Cash" };
// Reflect a mode value across the controls + header accent (no network call).
function applyMode(value) {
if (!MODE_LABELS[value]) value = "conversation";
const desk = document.getElementById("mode");
const mob = document.getElementById("mobileMode");
const badge = document.getElementById("modeBadge");
if (desk) desk.value = value;
if (mob) mob.value = value;
if (badge) badge.textContent = MODE_LABELS[value];
document.body.classList.toggle("cash-mode", value === "poker_cash");
localStorage.setItem("lyraMode", value);
}
// User picked a mode: apply locally + persist it to this session on the server.
async function chooseMode(value) {
applyMode(value);
if (!currentSession) return;
try {
await fetch(`${RELAY_BASE}/sessions/${currentSession}/mode`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: value })
});
} catch (e) { /* non-fatal: the mode still rides along in the chat body */ }
}
// Pull the active mode for a session from the server (fallback: last local choice).
async function loadModeFor(sessionId) {
let value = localStorage.getItem("lyraMode") || "conversation";
try {
const r = await fetch(`${RELAY_BASE}/sessions/${sessionId}/mode`);
if (r.ok) { const d = await r.json(); if (d.mode) value = d.mode; }
} catch (e) { /* keep the local fallback */ }
applyMode(value);
}
async function checkHealth() {
try {
const resp = await fetch(API_URL.replace("/v1/chat/completions", "/_health"));
@@ -578,19 +630,20 @@
mobileMenuOverlay.addEventListener("click", closeMobileMenu);
document.getElementById("moreTab").addEventListener("click", toggleMobileMenu);
// Sync mobile menu controls with desktop
// Mode controls (Talk / Cash): the desktop select, the mobile-menu select,
// and the always-visible header badge all funnel through chooseMode.
const mobileMode = document.getElementById("mobileMode");
const desktopMode = document.getElementById("mode");
const modeBadge = document.getElementById("modeBadge");
// Sync mode selection
mobileMode.addEventListener("change", (e) => {
desktopMode.value = e.target.value;
desktopMode.dispatchEvent(new Event("change"));
});
desktopMode.addEventListener("change", (e) => chooseMode(e.target.value));
mobileMode.addEventListener("change", (e) => { closeMobileMenu(); chooseMode(e.target.value); });
modeBadge.addEventListener("click", () =>
chooseMode(desktopMode.value === "poker_cash" ? "conversation" : "poker_cash"));
desktopMode.addEventListener("change", (e) => {
mobileMode.value = e.target.value;
});
// Reflect the last-used mode immediately; the per-session value loads once
// the current session is known (below).
applyMode(localStorage.getItem("lyraMode") || "conversation");
// Mobile theme toggle
document.getElementById("mobileToggleThemeBtn").addEventListener("click", () => {
@@ -700,6 +753,7 @@
// Load current session history
if (currentSession) {
await loadSession(currentSession);
await loadModeFor(currentSession);
}
})();
@@ -710,6 +764,7 @@
localStorage.setItem("currentSession", currentSession);
addMessage("system", `Switched to session: ${getSessionName(currentSession)}`);
await loadSession(currentSession);
await loadModeFor(currentSession);
});
// Create new session
@@ -1023,6 +1078,9 @@
document.getElementById("mobileJournalBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/journal";
});
document.getElementById("mobileSessionBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/session";
});
// Connect to the global live log on page load.
connectThinkingStream();
+230
View File
@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#070707" />
<title>Lyra — Session</title>
<style>
:root {
--bg: #070707; --bg-elev: #0e0e0e; --bg-line: #141414; --border: #2a1d12;
--text: #e8e8e8; --fade: #8a8a8a; --accent: #ff7a00;
--good: #8fd694; --mid: #ffb347; --low: #ff6b6b;
}
* { box-sizing: border-box; }
html, body {
margin: 0; min-height: 100%; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-text-size-adjust: 100%;
}
header {
position: sticky; top: 0; z-index: 10; background: var(--bg-elev);
border-bottom: 1px solid var(--border); padding: env(safe-area-inset-top) 14px 0;
}
.topbar { display: flex; align-items: center; gap: 10px; padding: 13px 0 12px; }
.topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
.updated { margin-left: auto; color: var(--fade); font-size: .78rem; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--good); box-shadow: 0 0 8px var(--good); flex: none; opacity: .35; transition: opacity .2s; }
.dot.pulse { opacity: 1; }
main { max-width: 680px; margin: 0 auto; padding: 16px 14px 40px; }
.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 14px; padding: 16px; margin-bottom: 14px; }
.label { color: var(--fade); font-size: .72rem; text-transform: uppercase; letter-spacing: .6px; margin: 0 0 10px; }
/* Header card */
.sess-top { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.sess-title { font-size: 1.25rem; font-weight: 700; }
.sess-sub { color: var(--fade); font-size: .9rem; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.chip { font-size: .8rem; color: var(--fade); background: var(--bg-line); border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; }
.chip b { color: var(--text); font-weight: 600; }
/* Stack card */
.stack-row { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
.stack-now { font-size: 2.3rem; font-weight: 800; letter-spacing: .2px; font-variant-numeric: tabular-nums; }
.net { font-size: 1.2rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.net.up { color: var(--good); } .net.down { color: var(--low); } .net.flat { color: var(--fade); }
.stack-meta { color: var(--fade); font-size: .85rem; margin-left: auto; text-align: right; }
svg.spark { display: block; width: 100%; height: 56px; margin-top: 14px; }
/* Hands */
ul.rows { list-style: none; margin: 0; padding: 0; }
ul.rows li { padding: 10px 0; border-bottom: 1px solid var(--bg-line); font-size: .95rem; line-height: 1.45; }
ul.rows li:last-child { border-bottom: none; }
a.hand { color: var(--text); text-decoration: none; display: flex; gap: 8px; align-items: baseline; }
a.hand:hover { color: var(--accent); }
.pos { color: var(--accent); font-weight: 700; min-width: 38px; }
.cards { font-variant-numeric: tabular-nums; }
.res { margin-left: auto; font-variant-numeric: tabular-nums; }
.res.up { color: var(--good); } .res.down { color: var(--low); }
.tag { font-size: .7rem; color: var(--mid); border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px; }
.villain b { color: var(--text); } .villain .cat { color: var(--mid); font-size: .78rem; }
.note-meta { color: var(--fade); font-size: .72rem; }
.empty { color: var(--fade); font-size: .92rem; }
.err { color: var(--low); text-align: center; padding: 30px; }
.big-empty { text-align: center; padding: 50px 20px; color: var(--fade); }
.big-empty .ico { font-size: 2.4rem; }
.big-empty a { color: var(--accent); text-decoration: none; }
</style>
</head>
<body>
<header>
<div class="topbar">
<span class="dot" id="dot"></span>
<h1>🎬 Session</h1>
<a class="back" href="/">← Chat</a>
<a class="back" href="/hands" title="All recorded hands">🃏 Hands</a>
<span class="updated" id="updated"></span>
</div>
</header>
<main id="root"><p class="err" id="boot">Loading the table…</p></main>
<script>
const root = document.getElementById('root');
const dot = document.getElementById('dot');
const updatedEl = document.getElementById('updated');
function esc(s){ const d=document.createElement('div'); d.textContent = s==null?'':String(s); return d.innerHTML; }
function money(v){ if (v == null) return '—'; const n = Number(v); return (n<0?'-$':'$') + Math.abs(n).toLocaleString(); }
function signed(v){ if (v == null) return '—'; const n = Number(v); return (n>0?'+$':n<0?'-$':'$') + Math.abs(n).toLocaleString(); }
function ago(iso){
if(!iso) return '—';
const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
if(s < 60) return 'just now';
if(s < 3600) return Math.round(s/60)+'m ago';
if(s < 86400) return Math.round(s/3600)+'h ago';
return Math.round(s/86400)+'d ago';
}
function elapsed(iso){
if(!iso) return '—';
const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
const h = Math.floor(s/3600), m = Math.round((s%3600)/60);
return h ? `${h}h ${m}m` : `${m}m`;
}
// Tiny inline sparkline of the stack-over-time series.
function sparkline(series){
const pts = series.map(p => Number(p.amount)).filter(n => !isNaN(n));
if (pts.length < 2) return '';
const W = 600, H = 56, pad = 4;
const min = Math.min(...pts), max = Math.max(...pts), span = (max - min) || 1;
const x = i => pad + (i / (pts.length - 1)) * (W - 2*pad);
const y = v => H - pad - ((v - min) / span) * (H - 2*pad);
const d = pts.map((v,i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
const last = pts[pts.length-1], first = pts[0];
const col = last >= first ? 'var(--good)' : 'var(--low)';
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
<polyline points="${d}" fill="none" stroke="${col}" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round" />
<circle cx="${x(pts.length-1).toFixed(1)}" cy="${y(last).toFixed(1)}" r="3" fill="${col}" />
</svg>`;
}
function netClass(v){ return v == null ? 'flat' : v > 0 ? 'up' : v < 0 ? 'down' : 'flat'; }
function render(data){
const s = data.session;
if (!s) {
root.innerHTML = `<div class="big-empty">
<div class="ico">🪑</div>
<p>No live session right now.<br>Start one from <a href="/">chat</a> — switch to ♠ Cash and tell Lyra you're sitting down.</p>
</div>`;
updatedEl.textContent = '';
return;
}
const stack = data.stack || {};
const hands = data.hands || [];
const villains = data.villains || [];
const notes = data.notes || [];
const stats = data.stats || {};
const title = [s.stakes, s.game].filter(Boolean).join(' ') || 'Session';
const tagBits = Object.entries(stats.tags || {}).map(([k,v]) => `${k}×${v}`).join(' · ');
root.innerHTML = `
<div class="card">
<div class="sess-top">
<span class="sess-title">${esc(title)}</span>
<span class="sess-sub">${esc(s.venue || 'unknown room')}${s.status && s.status!=='live' ? ' · '+esc(s.status) : ''}</span>
</div>
<div class="chips">
<span class="chip">⏱ <b>${elapsed(s.started_at)}</b></span>
<span class="chip">in <b>${money(s.buy_in_total)}</b></span>
<span class="chip">${esc(s.format || 'cash')}</span>
<span class="chip"><b>${hands.length}</b> hands</span>
</div>
</div>
<div class="card">
<p class="label">Stack</p>
<div class="stack-row">
<span class="stack-now">${stack.current == null ? '—' : money(stack.current)}</span>
<span class="net ${netClass(stack.net)}">${stack.net == null ? '' : signed(stack.net)}</span>
<span class="stack-meta">bought in ${money(stack.buy_in)}<br>${(stack.log||[]).length} update(s)</span>
</div>
${sparkline(stack.log || [])}
${stack.current == null ? '<p class="empty" style="margin:12px 0 0">No stack logged yet — tell Lyra your stack ("I\'m at 350").</p>' : ''}
</div>
<div class="card">
<p class="label">Hands this session</p>
${hands.length ? `<ul class="rows">${hands.slice().reverse().map(h => `
<li><a class="hand" href="/hand/${h.id}">
<span class="pos">${esc(h.position || '?')}</span>
<span class="cards">${esc(h.hole_cards || '')}${h.board ? ' · '+esc(h.board) : ''}</span>
${h.tag ? `<span class="tag">${esc(h.tag)}</span>` : ''}
${h.result != null ? `<span class="res ${h.result>=0?'up':'down'}">${signed(h.result)}</span>` : ''}
</a></li>`).join('')}</ul>`
: '<p class="empty">No hands logged yet.</p>'}
</div>
<div class="card">
<p class="label">Villains seen</p>
${villains.length ? `<ul class="rows">${villains.map(v => `
<li class="villain">
<b>${esc(v.name)}</b> ${v.category ? `<span class="cat">[${esc(v.category)}]</span>` : ''}
${v.tendencies ? `<div>${esc(v.tendencies)}</div>` : ''}
${v.last_note ? `<div class="note-meta">“${esc(v.last_note)}”</div>` : ''}
</li>`).join('')}</ul>`
: '<p class="empty">No reads logged this session.</p>'}
</div>
<div class="card">
<p class="label">Her notes</p>
${notes.length ? `<ul class="rows">${notes.map(n => `
<li>${esc(n.content)}<div class="note-meta">${esc(n.kind)} · ${ago(n.created_at)}</div></li>`).join('')}</ul>`
: '<p class="empty">Nothing jotted this session.</p>'}
</div>
<div class="card">
<p class="label">Session stats</p>
<div class="chips">
<span class="chip">logged <b>${stats.hands_logged ?? 0}</b></span>
${tagBits ? `<span class="chip">${esc(tagBits)}</span>` : ''}
${stats.context_per_hour != null ? `<span class="chip">${esc(title)} lifetime <b>${signed(stats.context_per_hour)}/hr</b></span>` : ''}
</div>
</div>
`;
updatedEl.textContent = 'updated ' + ago(data._fetched);
}
async function refresh(){
try {
const r = await fetch('/session/data', { cache: 'no-store' });
const data = await r.json();
data._fetched = new Date().toISOString();
dot.classList.add('pulse'); setTimeout(() => dot.classList.remove('pulse'), 400);
render(data);
} catch (e) {
if (!root.querySelector('.card')) root.innerHTML = '<p class="err">Couldn\'t reach the table. Is the server up?</p>';
}
}
refresh();
setInterval(refresh, 5000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
</script>
</body>
</html>
+26 -2
View File
@@ -99,6 +99,29 @@ body {
border-top: 1px solid var(--border);
}
/* Mode badge: the always-visible Talk/Cash toggle. Hidden on desktop (the header
<select> handles it there); shown in the minimal mobile header (see media query). */
.mode-badge {
display: none;
align-items: center;
gap: 4px;
font-family: var(--font-console);
font-size: 0.82rem;
color: var(--text-fade);
background: var(--bg-line);
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 11px;
-webkit-tap-highlight-color: transparent;
}
/* Cash mode: light up the badge (and the chat brand) so the table state is obvious. */
body.cash-mode .mode-badge {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-soft);
}
body.cash-mode .brand { color: var(--accent); }
label, select, button {
font-family: var(--font-console);
font-size: 0.9rem;
@@ -822,10 +845,11 @@ select:hover {
gap: 12px;
}
/* Mobile header is [≡] Lyra [●] — hide everything else. */
#model-select > *:not(.hamburger-menu):not(.brand):not(.brand-dot) {
/* Mobile header is [≡] Lyra [♠ Cash] [●] — hide everything else. */
#model-select > *:not(.hamburger-menu):not(.brand):not(.brand-dot):not(.mode-badge) {
display: none;
}
.mode-badge { display: inline-flex; margin-left: 4px; }
.brand {
display: block;
font-family: var(--font-console);