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
+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();