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
+30
View File
@@ -1,5 +1,35 @@
# Changelog # 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 ## 0.2.0 — first working system
The leap from "chat + memory baseline" to a working, persistent companion with a The leap from "chat + memory baseline" to a working, persistent companion with a
+14 -3
View File
@@ -43,9 +43,18 @@ reflected), and keeps a permanent **journal**.
## Poker copilot ## 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: Talk to her during a session; she drives tools behind the scenes:
- **Session tracking** — `start_session`, `add_buyin`, `end_session` → net, hours, $/hr. - **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 - **Hand histories** — vomit rough shorthand ("AKs btn, 3bet, flop A72…"), she
reconstructs a structured, **replayable** hand (unknown cards = `x`, never invented). reconstructs a structured, **replayable** hand (unknown cards = `x`, never invented).
- **Villain file** — named opponents auto-build persistent dossiers; basic stats - **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`) ## Web app (served by `lyra-web`, default `:7078`)
`/` chat (Markdown, model picker, 👍/👎 rating) · `/logs` live activity · `/self` `/` chat (Markdown, model picker, 👍/👎 rating, **Talk/Cash mode switcher**) ·
read-her-mind (mood, drives, reflections) · `/journal` her thoughts · `/hands` `/session` **live session HUD** (stack + sparkline, hands, villains, notes; mobile
recorded hands → `/hand/{id}` replayer · `/recap/{id}` session writeup (+ `.md` export). 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)` 👍/👎 ratings on replies and thoughts are stored as `(context, content, rating)`
a fine-tune / preference dataset built passively (`/ratings/export` → JSONL). a fine-tune / preference dataset built passively (`/ratings/export` → JSONL).
+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 __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 import tools as toolkit
from lyra.llm import Backend, Message from lyra.llm import Backend, Message
@@ -24,6 +24,15 @@ MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn
TOOL_BACKENDS = {"cloud"} 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: 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] 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) 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) 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.""" """Assemble the full, tiered message list for one turn."""
messages: list[Message] = [{"role": "system", "content": persona.system_prompt()}] 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. # 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())}) 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). # When she is: current time + the gap since Brian last spoke (she has no clock).
messages.append(_now_note()) 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, 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 # Tool loop: offer Lyra her tools (scoped to the mode); if she calls one, run it
# result back so she can continue, until she returns a normal text reply. # and feed the result back so she can continue, until she returns a text reply.
tool_specs = toolkit.specs() if backend in TOOL_BACKENDS else None tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None
ctx = {"session_id": session_id, "backend": backend} ctx = {"session_id": session_id, "backend": backend}
reply = "" reply = ""
for _ in range(MAX_TOOL_ROUNDS): 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) result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80]) 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}) messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
_maybe_switch_mode(session_id, tc["name"])
if not reply: if not reply:
reply = "(I got tangled using my tools there — say that again?)" reply = "(I got tangled using my tools there — say that again?)"
logbus.log("info", "reply", session=session_id, chars=len(reply)) 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, model=model, embed=cfg.embed_backend,
) )
messages = build_messages(session_id, user_msg) mode = modes.get(memory.get_session_mode(session_id))
tool_specs = toolkit.specs() if backend in TOOL_BACKENDS else None 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} ctx = {"session_id": session_id, "backend": backend}
parts: list[str] = [] parts: list[str] = []
for _ in range(MAX_TOOL_ROUNDS): 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) result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80]) 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}) messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
_maybe_switch_mode(session_id, tc["name"])
yield ("tool", tc["name"]) yield ("tool", tc["name"])
reply = "".join(parts) 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 ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT, name TEXT,
mode TEXT, -- conversation mode (see lyra/modes.py); NULL = default
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@@ -131,6 +132,12 @@ def _connection() -> sqlite3.Connection:
_conn.execute("PRAGMA busy_timeout=5000") _conn.execute("PRAGMA busy_timeout=5000")
_conn.execute("PRAGMA journal_mode=WAL") _conn.execute("PRAGMA journal_mode=WAL")
_conn.executescript(SCHEMA) _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 _conn_path = cfg.db_path
return _conn 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)) 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]: def list_sessions() -> list[dict]:
"""All known sessions (named rows + any session that has exchanges), newest first.""" """All known sessions (named rows + any session that has exchanges), newest first."""
conn = _connection() 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 created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_pobs_player ON player_observations(player_id); 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). # 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"]) ).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, def end_session(cash_out: float, mood: str | None = None,
session_id: int | None = None) -> dict: session_id: int | None = None) -> dict:
"""Close a session: record cashout, compute net + hours. Returns the row.""" """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, "per_hour": round(net / hours, 2) if hours else None,
"by_stake": by_stake, "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}." 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: 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, "")} fields = {k: args.get(k) for k in poker._HAND_FIELDS if args.get(k) not in (None, "")}
hid = poker.log_hand(**fields) hid = poker.log_hand(**fields)
@@ -268,6 +281,13 @@ TOOLS.update({
"add_buyin": {"handler": _add_buyin, "spec": _f( "add_buyin": {"handler": _add_buyin, "spec": _f(
"add_buyin", "Record a rebuy / additional buy-in in the live session.", "add_buyin", "Record a rebuy / additional buy-in in the live session.",
{"amount": {**_N, "description": "Amount added"}}, ["amount"])}, {"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": {"handler": _log_hand, "spec": _f(
"log_hand", "log_hand",
"Log a hand in the live session. All fields optional — capture whatever Brian gives you, even terse.", "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]: def specs(allow=None) -> list[dict]:
"""OpenAI-format tool definitions to offer the model.""" """OpenAI-format tool definitions to offer the model.
return [t["spec"] for t in TOOLS.values()]
`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: 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.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles 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 from lyra.llm import Backend
@@ -85,6 +85,34 @@ def create_app() -> FastAPI:
gist = await asyncio.to_thread(summary.summarize_session, session_id) gist = await asyncio.to_thread(summary.summarize_session, session_id)
return {"ok": gist is not None, "summary": gist} 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") @app.post("/v1/chat/completions")
async def chat_completions(request: Request) -> dict: async def chat_completions(request: Request) -> dict:
body = await request.json() body = await request.json()
@@ -94,6 +122,8 @@ def create_app() -> FastAPI:
model_override = body.get("model") or None model_override = body.get("model") or None
memory.ensure_session(session_id) memory.ensure_session(session_id)
if body.get("mode"):
memory.set_session_mode(session_id, body["mode"])
try: try:
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend, model_override) reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend, model_override)
except Exception as exc: except Exception as exc:
@@ -124,6 +154,8 @@ def create_app() -> FastAPI:
user_msg = _last_user_message(body.get("messages", [])) user_msg = _last_user_message(body.get("messages", []))
model_override = body.get("model") or None model_override = body.get("model") or None
memory.ensure_session(session_id) memory.ensure_session(session_id)
if body.get("mode"):
memory.set_session_mode(session_id, body["mode"])
async def gen(): async def gen():
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
+71 -13
View File
@@ -25,8 +25,8 @@
<div class="mobile-menu-section"> <div class="mobile-menu-section">
<h4>Mode</h4> <h4>Mode</h4>
<select id="mobileMode"> <select id="mobileMode">
<option value="standard">Standard</option> <option value="conversation">💬 Talk</option>
<option value="cortex">Cortex</option> <option value="poker_cash">♠ Cash</option>
</select> </select>
</div> </div>
@@ -39,6 +39,7 @@
<div class="mobile-menu-section"> <div class="mobile-menu-section">
<h4>Actions</h4> <h4>Actions</h4>
<button id="mobileSessionBtn">🎬 Session HUD</button>
<button id="mobileThinkingStreamBtn">📜 Live Log (inline)</button> <button id="mobileThinkingStreamBtn">📜 Live Log (inline)</button>
<button id="mobileFullLogBtn">⛶ Full Log</button> <button id="mobileFullLogBtn">⛶ Full Log</button>
<button id="mobileJournalBtn">📔 Journal</button> <button id="mobileJournalBtn">📔 Journal</button>
@@ -59,10 +60,11 @@
</button> </button>
<span class="brand">Lyra</span> <span class="brand">Lyra</span>
<span class="brand-dot" id="brandDot" title="Relay status"></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> <label for="mode">Mode:</label>
<select id="mode"> <select id="mode">
<option value="standard">Standard</option> <option value="conversation">💬 Talk</option>
<option value="cortex">Cortex</option> <option value="poker_cash">♠ Cash</option>
</select> </select>
<button id="settingsBtn" style="margin-left: auto;">⚙ Settings</button> <button id="settingsBtn" style="margin-left: auto;">⚙ Settings</button>
<div id="theme-toggle"> <div id="theme-toggle">
@@ -78,6 +80,7 @@
<button id="renameSessionBtn">✏️ Rename</button> <button id="renameSessionBtn">✏️ Rename</button>
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</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="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="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> <a id="handsBtn" href="/hands" target="_blank" rel="noopener" title="Recorded poker hands" role="button">🃏 Hands</a>
</div> </div>
@@ -118,6 +121,7 @@
<!-- Bottom tab bar (mobile only; hides while the keyboard is open) --> <!-- Bottom tab bar (mobile only; hides while the keyboard is open) -->
<nav id="tabbar" aria-label="Primary navigation"> <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 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="/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> <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> <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). // Which chat backend to use (local Ollama vs cloud OpenAI).
let backend = localStorage.getItem("standardModeBackend") || "local"; 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 = { const body = {
mode: mode, mode: mode,
messages: history, messages: history,
@@ -376,6 +384,12 @@
finalizeAssistantBubble(div, full || "(no reply)"); finalizeAssistantBubble(div, full || "(no reply)");
history.push({ role: "assistant", content: full || "(no reply)" }); history.push({ role: "assistant", content: full || "(no reply)" });
await saveSession(); 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() { 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() { async function checkHealth() {
try { try {
const resp = await fetch(API_URL.replace("/v1/chat/completions", "/_health")); const resp = await fetch(API_URL.replace("/v1/chat/completions", "/_health"));
@@ -578,19 +630,20 @@
mobileMenuOverlay.addEventListener("click", closeMobileMenu); mobileMenuOverlay.addEventListener("click", closeMobileMenu);
document.getElementById("moreTab").addEventListener("click", toggleMobileMenu); 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 mobileMode = document.getElementById("mobileMode");
const desktopMode = document.getElementById("mode"); const desktopMode = document.getElementById("mode");
const modeBadge = document.getElementById("modeBadge");
// Sync mode selection desktopMode.addEventListener("change", (e) => chooseMode(e.target.value));
mobileMode.addEventListener("change", (e) => { mobileMode.addEventListener("change", (e) => { closeMobileMenu(); chooseMode(e.target.value); });
desktopMode.value = e.target.value; modeBadge.addEventListener("click", () =>
desktopMode.dispatchEvent(new Event("change")); chooseMode(desktopMode.value === "poker_cash" ? "conversation" : "poker_cash"));
});
desktopMode.addEventListener("change", (e) => { // Reflect the last-used mode immediately; the per-session value loads once
mobileMode.value = e.target.value; // the current session is known (below).
}); applyMode(localStorage.getItem("lyraMode") || "conversation");
// Mobile theme toggle // Mobile theme toggle
document.getElementById("mobileToggleThemeBtn").addEventListener("click", () => { document.getElementById("mobileToggleThemeBtn").addEventListener("click", () => {
@@ -700,6 +753,7 @@
// Load current session history // Load current session history
if (currentSession) { if (currentSession) {
await loadSession(currentSession); await loadSession(currentSession);
await loadModeFor(currentSession);
} }
})(); })();
@@ -710,6 +764,7 @@
localStorage.setItem("currentSession", currentSession); localStorage.setItem("currentSession", currentSession);
addMessage("system", `Switched to session: ${getSessionName(currentSession)}`); addMessage("system", `Switched to session: ${getSessionName(currentSession)}`);
await loadSession(currentSession); await loadSession(currentSession);
await loadModeFor(currentSession);
}); });
// Create new session // Create new session
@@ -1023,6 +1078,9 @@
document.getElementById("mobileJournalBtn").addEventListener("click", () => { document.getElementById("mobileJournalBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/journal"; closeMobileMenu(); window.location.href = "/journal";
}); });
document.getElementById("mobileSessionBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/session";
});
// Connect to the global live log on page load. // Connect to the global live log on page load.
connectThinkingStream(); 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); 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 { label, select, button {
font-family: var(--font-console); font-family: var(--font-console);
font-size: 0.9rem; font-size: 0.9rem;
@@ -822,10 +845,11 @@ select:hover {
gap: 12px; gap: 12px;
} }
/* Mobile header is [≡] Lyra [●] — hide everything else. */ /* Mobile header is [≡] Lyra [♠ Cash] [●] — hide everything else. */
#model-select > *:not(.hamburger-menu):not(.brand):not(.brand-dot) { #model-select > *:not(.hamburger-menu):not(.brand):not(.brand-dot):not(.mode-badge) {
display: none; display: none;
} }
.mode-badge { display: inline-flex; margin-left: 4px; }
.brand { .brand {
display: block; display: block;
font-family: var(--font-console); font-family: var(--font-console);
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "lyra" name = "lyra"
version = "0.2.0" version = "0.3.0"
description = "Persistent, autonomous AI assistant" description = "Persistent, autonomous AI assistant"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+108
View File
@@ -0,0 +1,108 @@
"""Conversation modes: tool gating, mode persistence, stack tracking + HUD."""
from __future__ import annotations
import importlib
import pytest
@pytest.fixture
def lyra(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
import lyra.memory as memory
importlib.reload(memory)
import lyra.poker as poker
importlib.reload(poker)
import lyra.modes as modes
importlib.reload(modes)
import lyra.tools as tools
importlib.reload(tools)
return memory, poker, modes, tools
def _names(specs):
return {s["function"]["name"] for s in specs}
def test_tool_gating_by_mode(lyra):
_, _, modes, tools = lyra
talk = _names(tools.specs(modes.TALK.tools))
cash = _names(tools.specs(modes.CASH.tools))
# Cash is the full live toolset.
assert {"log_hand", "log_stack", "analyze_spot", "end_session"} <= cash
# Talk hides the live write tools...
assert "log_hand" not in talk and "log_stack" not in talk
# ...but keeps her agency + read-only lookups + the session entry point.
assert {"journal_write", "note", "player_profile", "start_session"} <= talk
# No allow-list = every registered tool.
assert _names(tools.specs()) == set(tools.TOOLS)
def test_every_mode_tool_exists(lyra):
_, _, modes, tools = lyra
for mode in modes.MODES.values():
assert set(mode.tools) <= set(tools.TOOLS), f"{mode.key} references unknown tools"
def test_mode_resolution_and_persistence(lyra):
memory, _, modes, _ = lyra
assert modes.get(None).key == modes.DEFAULT
assert modes.get("nonsense").key == modes.DEFAULT
assert modes.get("poker_cash") is modes.CASH
memory.ensure_session("s1")
assert memory.get_session_mode("s1") is None # unset -> caller applies default
memory.set_session_mode("s1", "poker_cash")
assert memory.get_session_mode("s1") == "poker_cash"
# set on an unknown session creates the row
memory.set_session_mode("s2", "conversation")
assert memory.get_session_mode("s2") == "conversation"
def test_stack_log_and_live_net(lyra):
_, poker, _, _ = lyra
poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
assert poker.current_stack() is None # nothing logged yet
st = poker.log_stack(700)
assert st["current"] == 700 and st["net"] == 200 # up 200 on a 500 buy-in
poker.log_stack(350)
assert poker.current_stack() == 350
assert poker.stack_state()["net"] == -150
assert len(poker.stack_log()) == 2
def test_log_stack_requires_live_session(lyra):
_, poker, _, _ = lyra
with pytest.raises(ValueError):
poker.log_stack(300)
def test_hud_bundle(lyra):
_, poker, _, _ = lyra
assert poker.hud() is None # no session -> nothing to show
sid = poker.start_session(venue="Meadows", stakes="2/5", game="NLH", buy_in=500)
poker.log_stack(620)
poker.log_hand(position="BTN", hole_cards="AKs", result=120, tag="confidence")
poker.add_read(note="3bets light from the SB", name="Round Mike", seat="SB")
hud = poker.hud()
assert hud["session"]["id"] == sid and hud["session"]["stakes"] == "2/5"
assert hud["stack"]["current"] == 620 and hud["stack"]["net"] == 120
assert len(hud["stack"]["log"]) == 1
assert len(hud["hands"]) == 1 and hud["hands"][0]["hole_cards"] == "AKs"
assert any(v["name"] == "Round Mike" for v in hud["villains"])
assert hud["stats"]["hands_logged"] == 1
def test_log_stack_tool_handler(lyra):
_, poker, _, tools = lyra
poker.start_session(stakes="1/3", buy_in=300)
out = tools.dispatch("log_stack", {"amount": 450}, {})
assert "450" in out and "150" in out # confirms stack + live net
# graceful when there's no number
assert "number" in tools.dispatch("log_stack", {}, {}).lower()
Generated
+1 -1
View File
@@ -278,7 +278,7 @@ wheels = [
[[package]] [[package]]
name = "lyra" name = "lyra"
version = "0.2.0" version = "0.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },