Compare commits
8 Commits
dev
...
feat/modes-hud
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c41bd48d1 | |||
| 5e9f3efeec | |||
| 654a7531e8 | |||
| cebb87205c | |||
| e1e89c07e4 | |||
| 35c973df05 | |||
| 974ee33f71 | |||
| dfb6425395 |
@@ -1,5 +1,45 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
### Mental-game rituals
|
||||||
|
- Brian's own rituals are now first-class, live tools (not just post-hoc recap
|
||||||
|
sections): **Scar Notes** (with the punt / cooler / standard distinction),
|
||||||
|
**Confidence Bank** (good process, banked regardless of result), **Alligator
|
||||||
|
Blood** mode (an invokable adversity state — she'll suggest it when he's
|
||||||
|
card-dead/short/stuck, and her coaching register shifts while it's on), and
|
||||||
|
**Reset** (a tilt circuit-breaker; mental marker, stats stay continuous).
|
||||||
|
- Rituals show on the HUD (🐊 banner, Confidence Bank + Scar Notes panels) and feed
|
||||||
|
the recap's Scar Notes / Confidence Bank sections with what actually happened.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|||||||
@@ -43,9 +43,22 @@ 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.
|
||||||
|
- **Mental-game rituals** — your own system, run live: **Scar Notes** (punt / cooler
|
||||||
|
/ standard), **Confidence Bank** (good process, banked regardless of result),
|
||||||
|
**Alligator Blood** mode (adversity register she'll suggest when you're card-dead /
|
||||||
|
stuck), and **Reset** (tilt circuit-breaker). They surface on the HUD and ground the recap.
|
||||||
- **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 +69,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).
|
||||||
|
|
||||||
|
|||||||
+52
-10
@@ -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,30 @@ MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn
|
|||||||
TOOL_BACKENDS = {"cloud"}
|
TOOL_BACKENDS = {"cloud"}
|
||||||
|
|
||||||
|
|
||||||
|
def _mode_state_note(mode: modes.Mode | None) -> str | None:
|
||||||
|
"""Dynamic, per-turn state for the active mode. Currently: surface Alligator
|
||||||
|
Blood while it's engaged on the live session, so she stays in that register."""
|
||||||
|
if not mode or mode.key != modes.CASH.key:
|
||||||
|
return None
|
||||||
|
from lyra import poker # local import: keep the core/domain coupling at call time
|
||||||
|
if poker.alligator_active():
|
||||||
|
return (
|
||||||
|
"🐊 ALLIGATOR BLOOD is ON for this session. Coach Brian in that register: "
|
||||||
|
"hang around, refuse to die, don't force miracles, make opponents beat him "
|
||||||
|
"correctly. Tough, patient, steady — no heroics, no spew, no quitting."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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 +80,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 +89,19 @@ 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})
|
||||||
|
|
||||||
|
# Live ritual state (e.g. Alligator Blood ON) — dynamic, so it rides alongside
|
||||||
|
# the static card and keeps her in-register for the whole stretch, not just the
|
||||||
|
# turn she flipped it.
|
||||||
|
state_note = _mode_state_note(mode)
|
||||||
|
if state_note:
|
||||||
|
messages.append({"role": "system", "content": state_note})
|
||||||
|
|
||||||
# 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 +171,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 +191,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))
|
||||||
@@ -160,7 +200,7 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
|||||||
memory.remember(session_id, "assistant", reply)
|
memory.remember(session_id, "assistant", reply)
|
||||||
|
|
||||||
# Compact this session once enough new turns have piled up.
|
# Compact this session once enough new turns have piled up.
|
||||||
summary.maybe_summarize(session_id)
|
summary.maybe_summarize_async(session_id)
|
||||||
return reply
|
return reply
|
||||||
|
|
||||||
|
|
||||||
@@ -183,8 +223,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 +248,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)
|
||||||
@@ -217,5 +259,5 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
|||||||
|
|
||||||
memory.remember(session_id, "user", user_msg)
|
memory.remember(session_id, "user", user_msg)
|
||||||
memory.remember(session_id, "assistant", reply)
|
memory.remember(session_id, "assistant", reply)
|
||||||
summary.maybe_summarize(session_id)
|
summary.maybe_summarize_async(session_id)
|
||||||
yield ("done", reply)
|
yield ("done", reply)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
"""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?",
|
||||||
|
# "what do we have on Round Mike?", or "how'd my last few sessions go?" all work
|
||||||
|
# even when we're just talking.
|
||||||
|
_LOOKUPS = ("player_profile", "get_villain_file", "running_stats", "recent_sessions")
|
||||||
|
|
||||||
|
# Always-available core tools (her own agency: journaling/notes).
|
||||||
|
_BASE = ("journal_write", "note")
|
||||||
|
|
||||||
|
# The full live cash-game toolset (incl. Brian's mental-game rituals).
|
||||||
|
_CASH_TOOLS = _BASE + _LOOKUPS + (
|
||||||
|
"start_session", "add_buyin", "log_stack", "log_hand", "record_hand",
|
||||||
|
"add_read", "analyze_spot", "session_stats", "session_state", "end_session",
|
||||||
|
"generate_recap", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
Everything you log appears on Brian's live HUD (the Session view) — stack, live net, \
|
||||||
|
hands, villains, the confidence bank, the scar notes, and whether Alligator Blood is on. \
|
||||||
|
That HUD and you read the SAME data. So when he asks where he's at — his stack, his live \
|
||||||
|
net, what's in the bank tonight, whether gator mode is on — call session_state and answer \
|
||||||
|
from what it returns, never from memory. You can point him at the HUD too ("it's on your \
|
||||||
|
Session screen"), but you can always just tell him.
|
||||||
|
|
||||||
|
BRIAN'S RITUALS — his mental-game system. Run them, don't just reference them:
|
||||||
|
• SCAR NOTE (scar_note) — a painful, instructive mistake to study. Log it when he punts, \
|
||||||
|
gets over-attached, or leaks — and classify it honestly: punt (his error), cooler \
|
||||||
|
(unavoidable), or standard (right play, bad result). That punt-vs-cooler line matters to him; \
|
||||||
|
don't soften a punt into a cooler, and don't call a cooler a punt.
|
||||||
|
• CONFIDENCE BANK (confidence_bank) — good PROCESS regardless of result: a disciplined fold, \
|
||||||
|
clean value, catching a leak mid-hand, holding the line. Bank it when he earns it, ESPECIALLY \
|
||||||
|
when the result didn't reward the good decision. This is how he stays steady.
|
||||||
|
• ALLIGATOR BLOOD (alligator_blood) — his adversity state: hang around, refuse to die, don't \
|
||||||
|
force miracles, make them beat you correctly. Turn it ON when he calls for it; SUGGEST it when \
|
||||||
|
he's card-dead, short, stuck, or grinding a downswing. While it's on, coach him in that \
|
||||||
|
register — tough, patient, no heroics — not bored or loose.
|
||||||
|
• RESET (reset_ritual) — a circuit-breaker after a loss or tilt spike: a clean mental restart, \
|
||||||
|
treat the rest of the night as a new session. Walk him through it when he's chasing or steaming, \
|
||||||
|
then log it.
|
||||||
|
These are the heart of the job. Use his language, hold the honest line, and let the rituals do \
|
||||||
|
the work mentioning them naturally — never invent a scar or a confidence-bank entry that didn't happen."""
|
||||||
|
|
||||||
|
|
||||||
|
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()]
|
||||||
+275
@@ -98,6 +98,30 @@ 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);
|
||||||
|
|
||||||
|
-- Mental-game rituals Brian developed (scar notes, confidence bank, alligator
|
||||||
|
-- blood, reset). Session-scoped events: capture entries (scar/confidence/reset)
|
||||||
|
-- carry text; the alligator state is the latest alligator_on/alligator_off event.
|
||||||
|
CREATE TABLE IF NOT EXISTS poker_rituals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- scar | confidence | reset | alligator_on | alligator_off
|
||||||
|
content TEXT,
|
||||||
|
classification TEXT, -- scar only: punt | cooler | standard
|
||||||
|
hand_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rituals_session ON poker_rituals(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).
|
||||||
@@ -181,6 +205,43 @@ def clear_all() -> dict:
|
|||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def list_sessions(limit: int | None = None, include_review: bool = False) -> list[dict]:
|
||||||
|
"""Past + live sessions (newest first), each with a hand count + recap flag.
|
||||||
|
Excludes the standing 'Hand Reviews' bucket unless include_review."""
|
||||||
|
sql = "SELECT * FROM poker_sessions"
|
||||||
|
if not include_review:
|
||||||
|
sql += " WHERE status != 'review'"
|
||||||
|
sql += " ORDER BY started_at DESC, id DESC"
|
||||||
|
if limit:
|
||||||
|
sql += f" LIMIT {int(limit)}"
|
||||||
|
rows = [dict(r) for r in _c().execute(sql).fetchall()]
|
||||||
|
for r in rows:
|
||||||
|
r["hands"] = _c().execute(
|
||||||
|
"SELECT COUNT(*) n FROM poker_hands WHERE session_id = ?", (r["id"],)
|
||||||
|
).fetchone()["n"]
|
||||||
|
r["has_recap"] = bool(r.get("recap_md"))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def delete_session(session_id: int) -> dict:
|
||||||
|
"""Delete one session and its hands/reads/observations/stack/rituals. Leaves the
|
||||||
|
persistent villain file (poker_players) intact. Returns rows removed per table."""
|
||||||
|
conn = _c()
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
with conn:
|
||||||
|
for t in ("poker_hands", "player_observations", "player_reads",
|
||||||
|
"poker_stack_log", "poker_rituals"):
|
||||||
|
counts[t] = conn.execute(
|
||||||
|
f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,)
|
||||||
|
).fetchone()["n"]
|
||||||
|
conn.execute(f"DELETE FROM {t} WHERE session_id = ?", (session_id,))
|
||||||
|
counts["poker_sessions"] = conn.execute(
|
||||||
|
"SELECT COUNT(*) n FROM poker_sessions WHERE id = ?", (session_id,)
|
||||||
|
).fetchone()["n"]
|
||||||
|
conn.execute("DELETE FROM poker_sessions WHERE id = ?", (session_id,))
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def live_session() -> dict | None:
|
def live_session() -> dict | None:
|
||||||
"""The current open session, if any."""
|
"""The current open session, if any."""
|
||||||
r = _c().execute(
|
r = _c().execute(
|
||||||
@@ -212,6 +273,115 @@ 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),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- mental-game rituals (scar notes / confidence bank / alligator blood / reset) ---
|
||||||
|
|
||||||
|
RITUAL_CAPTURE = ("scar", "confidence", "reset")
|
||||||
|
|
||||||
|
|
||||||
|
def log_ritual(kind: str, content: str | None = None, classification: str | None = None,
|
||||||
|
hand_id: int | None = None, session_id: int | None = None) -> int:
|
||||||
|
"""Record a ritual event (a scar note, confidence-bank entry, reset, or an
|
||||||
|
alligator on/off toggle) against a session. Returns the row id."""
|
||||||
|
sid = _resolve(session_id)
|
||||||
|
if sid is None:
|
||||||
|
raise ValueError("no live session")
|
||||||
|
conn = _c()
|
||||||
|
with conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO poker_rituals (session_id, kind, content, classification, hand_id, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(sid, kind, content, classification, hand_id, _now()),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def list_rituals(session_id: int | None = None,
|
||||||
|
kinds: tuple[str, ...] | None = None) -> list[dict]:
|
||||||
|
"""Ritual events for a session, oldest first; optionally filtered by kind."""
|
||||||
|
sid = _resolve(session_id)
|
||||||
|
if sid is None:
|
||||||
|
return []
|
||||||
|
sql = "SELECT * FROM poker_rituals WHERE session_id = ?"
|
||||||
|
params: list = [sid]
|
||||||
|
if kinds:
|
||||||
|
sql += " AND kind IN (%s)" % ",".join("?" * len(kinds))
|
||||||
|
params += list(kinds)
|
||||||
|
sql += " ORDER BY id"
|
||||||
|
return [dict(r) for r in _c().execute(sql, params).fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def set_alligator(on: bool, session_id: int | None = None) -> bool:
|
||||||
|
"""Toggle Alligator Blood mode for the session. Returns the new state."""
|
||||||
|
log_ritual("alligator_on" if on else "alligator_off", session_id=session_id)
|
||||||
|
return bool(on)
|
||||||
|
|
||||||
|
|
||||||
|
def alligator_active(session_id: int | None = None) -> bool:
|
||||||
|
"""Whether Alligator Blood mode is currently ON (latest toggle wins)."""
|
||||||
|
sid = _resolve(session_id)
|
||||||
|
if sid is None:
|
||||||
|
return False
|
||||||
|
r = _c().execute(
|
||||||
|
"SELECT kind FROM poker_rituals WHERE session_id = ? "
|
||||||
|
"AND kind IN ('alligator_on', 'alligator_off') ORDER BY id DESC LIMIT 1",
|
||||||
|
(sid,),
|
||||||
|
).fetchone()
|
||||||
|
return bool(r and r["kind"] == "alligator_on")
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
@@ -489,6 +659,21 @@ def _hand_line(h: dict) -> str:
|
|||||||
return " | ".join(str(b) for b in bits if b)
|
return " | ".join(str(b) for b in bits if b)
|
||||||
|
|
||||||
|
|
||||||
|
_RITUAL_LABEL = {"scar": "Scar Note", "confidence": "Confidence Bank",
|
||||||
|
"reset": "Reset", "alligator_on": "Alligator Blood ON",
|
||||||
|
"alligator_off": "Alligator Blood OFF"}
|
||||||
|
|
||||||
|
|
||||||
|
def _rituals_block(rituals: list[dict]) -> str:
|
||||||
|
lines = []
|
||||||
|
for r in rituals:
|
||||||
|
label = _RITUAL_LABEL.get(r["kind"], r["kind"])
|
||||||
|
cls = f" [{r['classification']}]" if r.get("classification") else ""
|
||||||
|
body = f": {r['content']}" if r.get("content") else ""
|
||||||
|
lines.append(f"- {label}{cls}{body}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def generate_recap(session_id: int | None = None, backend: str | None = None) -> dict | None:
|
def generate_recap(session_id: int | None = None, backend: str | None = None) -> dict | None:
|
||||||
"""Generate Brian's .md recap from a session's structured data + conversation, store it."""
|
"""Generate Brian's .md recap from a session's structured data + conversation, store it."""
|
||||||
backend = backend or "cloud"
|
backend = backend or "cloud"
|
||||||
@@ -500,6 +685,7 @@ def generate_recap(session_id: int | None = None, backend: str | None = None) ->
|
|||||||
reads = [dict(r) for r in _c().execute(
|
reads = [dict(r) for r in _c().execute(
|
||||||
"SELECT seat, note FROM player_reads WHERE session_id = ?", (sid,)).fetchall()]
|
"SELECT seat, note FROM player_reads WHERE session_id = ?", (sid,)).fetchall()]
|
||||||
stats = session_stats(sid)
|
stats = session_stats(sid)
|
||||||
|
rituals = list_rituals(sid)
|
||||||
|
|
||||||
convo = ""
|
convo = ""
|
||||||
if s.get("chat_session_id"):
|
if s.get("chat_session_id"):
|
||||||
@@ -516,6 +702,9 @@ def generate_recap(session_id: int | None = None, backend: str | None = None) ->
|
|||||||
f"{stats.get('per_hour')}/hr | hands logged: {stats.get('hands_logged')} | tags: {stats.get('tags')}\n\n"
|
f"{stats.get('per_hour')}/hr | hands logged: {stats.get('hands_logged')} | tags: {stats.get('tags')}\n\n"
|
||||||
"HANDS:\n" + ("\n".join("- " + _hand_line(h) for h in hands) or "(none logged)") + "\n\n"
|
"HANDS:\n" + ("\n".join("- " + _hand_line(h) for h in hands) or "(none logged)") + "\n\n"
|
||||||
"READS:\n" + ("\n".join(f"- seat {r.get('seat')}: {r['note']}" for r in reads) or "(none)") + "\n\n"
|
"READS:\n" + ("\n".join(f"- seat {r.get('seat')}: {r['note']}" for r in reads) or "(none)") + "\n\n"
|
||||||
|
"RITUALS (use these for the Scar Notes / Confidence Bank / Mental Game sections — "
|
||||||
|
"they are what actually happened, not to be invented):\n"
|
||||||
|
+ (_rituals_block(rituals) or "(none logged)") + "\n\n"
|
||||||
"CONVERSATION DURING SESSION:\n" + (convo or "(none captured)")
|
"CONVERSATION DURING SESSION:\n" + (convo or "(none captured)")
|
||||||
)
|
)
|
||||||
md = llm.complete(
|
md = llm.complete(
|
||||||
@@ -752,3 +941,89 @@ 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 {}
|
||||||
|
|
||||||
|
rituals = list_rituals(sid)
|
||||||
|
by_kind = lambda k: [ # noqa: E731
|
||||||
|
{"content": r["content"], "classification": r["classification"],
|
||||||
|
"hand_id": r["hand_id"], "at": r["created_at"]}
|
||||||
|
for r in rituals if r["kind"] == k
|
||||||
|
]
|
||||||
|
|
||||||
|
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,
|
||||||
|
"rituals": {
|
||||||
|
"alligator": alligator_active(sid),
|
||||||
|
"scars": by_kind("scar"),
|
||||||
|
"confidence": by_kind("confidence"),
|
||||||
|
"resets": by_kind("reset"),
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"hands_logged": stats.get("hands_logged", 0),
|
||||||
|
"tags": stats.get("tags", {}),
|
||||||
|
"context_per_hour": ctx.get("per_hour"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
+42
-5
@@ -10,6 +10,7 @@ big imported conversation doesn't blow the local model's context window.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
@@ -20,8 +21,15 @@ _RETRIES = 4
|
|||||||
|
|
||||||
# Re-summarize a session once it has accumulated this many new raw exchanges.
|
# Re-summarize a session once it has accumulated this many new raw exchanges.
|
||||||
SUMMARIZE_AFTER = 20
|
SUMMARIZE_AFTER = 20
|
||||||
# Transcript budget per LLM call; longer sessions are chunked + merged.
|
# Transcript budget per LLM call; longer sessions are chunked + merged. Cloud has
|
||||||
|
# a large context window; the local llama.cpp/Ollama servers have small ones, so a
|
||||||
|
# 24k-char chunk overflows them ("Context size has been exceeded") — keep local small.
|
||||||
MAX_TRANSCRIPT_CHARS = 24000
|
MAX_TRANSCRIPT_CHARS = 24000
|
||||||
|
LOCAL_TRANSCRIPT_CHARS = 8000
|
||||||
|
|
||||||
|
|
||||||
|
def _budget(backend: Backend) -> int:
|
||||||
|
return MAX_TRANSCRIPT_CHARS if backend == "cloud" else LOCAL_TRANSCRIPT_CHARS
|
||||||
|
|
||||||
_PROMPT = """You are compacting a conversation into a long-term memory record \
|
_PROMPT = """You are compacting a conversation into a long-term memory record \
|
||||||
(not replying to anyone). Write a concise gist of the session below: what was \
|
(not replying to anyone). Write a concise gist of the session below: what was \
|
||||||
@@ -66,11 +74,14 @@ def _summarize_text(text: str, backend: Backend) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _summarize_transcript(transcript: str, backend: Backend) -> str:
|
def _summarize_transcript(transcript: str, backend: Backend) -> str:
|
||||||
"""Transcript -> gist (LLM only, no DB). Chunks + merges if oversized."""
|
"""Transcript -> gist (LLM only, no DB). Chunks + merges if oversized, and
|
||||||
if len(transcript) <= MAX_TRANSCRIPT_CHARS:
|
recurses so even the merged partials never exceed the backend's window."""
|
||||||
|
budget = _budget(backend)
|
||||||
|
if len(transcript) <= budget:
|
||||||
return _summarize_text(transcript, backend)
|
return _summarize_text(transcript, backend)
|
||||||
partials = [_summarize_text(c, backend) for c in _chunk(transcript, MAX_TRANSCRIPT_CHARS)]
|
partials = [_summarize_text(c, backend) for c in _chunk(transcript, budget)]
|
||||||
return _summarize_text("Partial summaries to merge:\n\n" + "\n\n".join(partials), backend)
|
merged = "Partial summaries to merge:\n\n" + "\n\n".join(partials)
|
||||||
|
return _summarize_transcript(merged, backend)
|
||||||
|
|
||||||
|
|
||||||
def summarize_session(session_id: str, backend: Backend | None = None) -> str | None:
|
def summarize_session(session_id: str, backend: Backend | None = None) -> str | None:
|
||||||
@@ -91,6 +102,32 @@ def maybe_summarize(session_id: str, backend: Backend | None = None) -> None:
|
|||||||
summarize_session(session_id, backend=backend)
|
summarize_session(session_id, backend=backend)
|
||||||
|
|
||||||
|
|
||||||
|
_inflight: set[str] = set()
|
||||||
|
_inflight_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_summarize_async(session_id: str, backend: Backend | None = None) -> None:
|
||||||
|
"""Run maybe_summarize off the chat turn's critical path. Consolidation is
|
||||||
|
background maintenance — it must never stall the reply or surface an error to
|
||||||
|
the user (a slow/oversized local model would otherwise block the turn). At most
|
||||||
|
one summary per session runs at a time."""
|
||||||
|
with _inflight_lock:
|
||||||
|
if session_id in _inflight:
|
||||||
|
return
|
||||||
|
_inflight.add(session_id)
|
||||||
|
|
||||||
|
def _run() -> None:
|
||||||
|
try:
|
||||||
|
maybe_summarize(session_id, backend=backend)
|
||||||
|
except Exception as exc:
|
||||||
|
logbus.log("error", "summary skipped", session=session_id, error=str(exc)[:120])
|
||||||
|
finally:
|
||||||
|
with _inflight_lock:
|
||||||
|
_inflight.discard(session_id)
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True, name="summarize").start()
|
||||||
|
|
||||||
|
|
||||||
def summarize_all(
|
def summarize_all(
|
||||||
backend: Backend | None = None, limit: int | None = None, workers: int = 8
|
backend: Backend | None = None, limit: int | None = None, workers: int = 8
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
+166
-3
@@ -104,6 +104,64 @@ 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 _scar_note(args: dict, ctx: dict) -> str:
|
||||||
|
content = (args.get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
return "Nothing to log — give me the scar."
|
||||||
|
cls = (args.get("classification") or "").strip().lower() or None
|
||||||
|
if cls and cls not in ("punt", "cooler", "standard"):
|
||||||
|
cls = None
|
||||||
|
try:
|
||||||
|
poker.log_ritual("scar", content=content, classification=cls,
|
||||||
|
hand_id=args.get("hand_id"))
|
||||||
|
except ValueError:
|
||||||
|
return "No live session — start one and I'll keep the scar notes."
|
||||||
|
return f"Scar note logged{f' ({cls})' if cls else ''}."
|
||||||
|
|
||||||
|
|
||||||
|
def _confidence_bank(args: dict, ctx: dict) -> str:
|
||||||
|
content = (args.get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
return "Nothing to bank — tell me the good process."
|
||||||
|
try:
|
||||||
|
poker.log_ritual("confidence", content=content, hand_id=args.get("hand_id"))
|
||||||
|
except ValueError:
|
||||||
|
return "No live session — start one and I'll run the confidence bank."
|
||||||
|
return "Banked. 💰"
|
||||||
|
|
||||||
|
|
||||||
|
def _alligator_blood(args: dict, ctx: dict) -> str:
|
||||||
|
on = bool(args.get("on", True))
|
||||||
|
try:
|
||||||
|
poker.set_alligator(on)
|
||||||
|
except ValueError:
|
||||||
|
return "No live session to set that on."
|
||||||
|
return ("🐊 Alligator Blood ON — hang around, refuse to die, no forced miracles."
|
||||||
|
if on else "Alligator Blood off. Back to standard register.")
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_ritual(args: dict, ctx: dict) -> str:
|
||||||
|
content = (args.get("content") or "").strip() or None
|
||||||
|
try:
|
||||||
|
poker.log_ritual("reset", content=content)
|
||||||
|
except ValueError:
|
||||||
|
return "No live session to reset."
|
||||||
|
return "Reset logged. Clean slate — this is a new session in your head."
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -129,6 +187,29 @@ def _end_session(args: dict, ctx: dict) -> str:
|
|||||||
return f"Session #{s['id']} closed — net {s['net']:+.0f} over {s['hours']}h{hourly}."
|
return f"Session #{s['id']} closed — net {s['net']:+.0f} over {s['hours']}h{hourly}."
|
||||||
|
|
||||||
|
|
||||||
|
def _session_state(args: dict, ctx: dict) -> str:
|
||||||
|
h = poker.hud()
|
||||||
|
if not h:
|
||||||
|
return "No live session right now."
|
||||||
|
s, st, r = h["session"], h["stack"], h["rituals"]
|
||||||
|
L = [f"{s.get('stakes') or '?'} {s.get('game') or ''} @ {s.get('venue') or '?'} "
|
||||||
|
f"— {h['stats']['hands_logged']} hands logged"]
|
||||||
|
if st.get("current") is not None:
|
||||||
|
L.append(f"Stack ${st['current']:g} (in {st['buy_in']:g}, live net {st['net']:+.0f})")
|
||||||
|
else:
|
||||||
|
L.append(f"Stack not logged yet (in {st['buy_in']:g})")
|
||||||
|
L.append("🐊 Alligator Blood is ON" if r["alligator"] else "Alligator Blood: off")
|
||||||
|
if r["confidence"]:
|
||||||
|
L.append("Confidence bank: " + " | ".join(c["content"] for c in r["confidence"][-4:]))
|
||||||
|
if r["scars"]:
|
||||||
|
L.append("Scar notes: " + " | ".join(
|
||||||
|
sc["content"] + (f" [{sc['classification']}]" if sc.get("classification") else "")
|
||||||
|
for sc in r["scars"][-4:]))
|
||||||
|
if r["resets"]:
|
||||||
|
L.append(f"{len(r['resets'])} reset(s) this session")
|
||||||
|
return "\n".join(L)
|
||||||
|
|
||||||
|
|
||||||
def _session_stats(args: dict, ctx: dict) -> str:
|
def _session_stats(args: dict, ctx: dict) -> str:
|
||||||
st = poker.session_stats()
|
st = poker.session_stats()
|
||||||
if not st:
|
if not st:
|
||||||
@@ -140,6 +221,27 @@ def _session_stats(args: dict, ctx: dict) -> str:
|
|||||||
f"{st['hands_logged']} hands logged (tags: {tags}).")
|
f"{st['hands_logged']} hands logged (tags: {tags}).")
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_sessions(args: dict, ctx: dict) -> str:
|
||||||
|
try:
|
||||||
|
n = int(args.get("limit") or 8)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 8
|
||||||
|
rows = poker.list_sessions(limit=n)
|
||||||
|
if not rows:
|
||||||
|
return "No sessions logged yet."
|
||||||
|
out = []
|
||||||
|
for s in rows:
|
||||||
|
net = s.get("net")
|
||||||
|
netstr = (f"{net:+.0f}" if net is not None
|
||||||
|
else "live" if s.get("status") == "live" else "—")
|
||||||
|
hrs = f", {s['hours']:g}h" if s.get("hours") else ""
|
||||||
|
recap = " · recap" if s.get("has_recap") else ""
|
||||||
|
out.append(f"#{s['id']} {(s.get('started_at') or '')[:10]} "
|
||||||
|
f"{s.get('stakes') or '?'} {s.get('game') or ''} @ {s.get('venue') or '?'} "
|
||||||
|
f"— net {netstr}{hrs} ({s.get('hands', 0)} hands){recap}")
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
def _running_stats(args: dict, ctx: dict) -> str:
|
def _running_stats(args: dict, ctx: dict) -> str:
|
||||||
rs = poker.running_stats(stakes=args.get("stakes"), venue=args.get("venue"),
|
rs = poker.running_stats(stakes=args.get("stakes"), venue=args.get("venue"),
|
||||||
game=args.get("game"), since=args.get("since"))
|
game=args.get("game"), since=args.get("since"))
|
||||||
@@ -268,6 +370,46 @@ 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"])},
|
||||||
|
"scar_note": {"handler": _scar_note, "spec": _f(
|
||||||
|
"scar_note",
|
||||||
|
"Log a SCAR NOTE — a painful or instructive mistake to study later. Use when "
|
||||||
|
"Brian punts, gets too attached, or makes a leak — or when he flags one. "
|
||||||
|
"Classify honestly: 'punt' (his error), 'cooler' (unavoidable), or 'standard' "
|
||||||
|
"(correct play, bad result). The punt-vs-cooler distinction matters to him.",
|
||||||
|
{"content": {**_S, "description": "What happened and the lesson, in Brian's terms"},
|
||||||
|
"classification": {**_S, "description": "punt | cooler | standard"},
|
||||||
|
"hand_id": {**_N, "description": "Linked hand id, if this scar is a logged hand"}},
|
||||||
|
["content"])},
|
||||||
|
"confidence_bank": {"handler": _confidence_bank, "spec": _f(
|
||||||
|
"confidence_bank",
|
||||||
|
"Log a CONFIDENCE BANK entry — good PROCESS regardless of result: a disciplined "
|
||||||
|
"laydown, clean value bet, catching a leak in real time, sticking to the plan. "
|
||||||
|
"Bank it when he does something right, especially when the result didn't reward it.",
|
||||||
|
{"content": {**_S, "description": "The disciplined / good-process play to bank"},
|
||||||
|
"hand_id": {**_N, "description": "Linked hand id, if applicable"}},
|
||||||
|
["content"])},
|
||||||
|
"alligator_blood": {"handler": _alligator_blood, "spec": _f(
|
||||||
|
"alligator_blood",
|
||||||
|
"Toggle ALLIGATOR BLOOD mode — Brian's adversity state: hang around, refuse to "
|
||||||
|
"die, don't force miracles, make opponents beat him correctly. Turn it ON when he "
|
||||||
|
"invokes it, or SUGGEST it (then turn on if he agrees) when he's card-dead, short, "
|
||||||
|
"stuck, or grinding through a downswing. Turn OFF on reset or when he's back in rhythm.",
|
||||||
|
{"on": {"type": "boolean", "description": "true to engage, false to stand down"}},
|
||||||
|
[])},
|
||||||
|
"reset_ritual": {"handler": _reset_ritual, "spec": _f(
|
||||||
|
"reset_ritual",
|
||||||
|
"Log a RESET — a deliberate mental circuit-breaker after a loss or tilt spike, "
|
||||||
|
"treating the rest of the night as a fresh start (the stats stay continuous). "
|
||||||
|
"Use when he resets, or when you've talked him through one.",
|
||||||
|
{"content": {**_S, "description": "Optional note on what prompted the reset"}},
|
||||||
|
[])},
|
||||||
"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.",
|
||||||
@@ -304,6 +446,20 @@ TOOLS.update({
|
|||||||
"session_stats": {"handler": _session_stats, "spec": _f(
|
"session_stats": {"handler": _session_stats, "spec": _f(
|
||||||
"session_stats", "Get money + hand summary for the current/most-recent session.",
|
"session_stats", "Get money + hand summary for the current/most-recent session.",
|
||||||
{}, [])},
|
{}, [])},
|
||||||
|
"session_state": {"handler": _session_state, "spec": _f(
|
||||||
|
"session_state",
|
||||||
|
"Read back the CURRENT live-session state — the same data Brian sees on his HUD: "
|
||||||
|
"stack, live net, whether Alligator Blood is on, and the scar notes / "
|
||||||
|
"confidence-bank entries so far. Use whenever he asks where he's at, what's in "
|
||||||
|
"the bank, his stack or net, or if gator mode is on — answer from THIS, not memory.",
|
||||||
|
{}, [])},
|
||||||
|
"recent_sessions": {"handler": _recent_sessions, "spec": _f(
|
||||||
|
"recent_sessions",
|
||||||
|
"List Brian's recent poker sessions — date, stakes, venue, net, hours, hand "
|
||||||
|
"count. Use when he asks about past sessions, how recent ones went, or to find "
|
||||||
|
"a session to review. Answer from this, not memory.",
|
||||||
|
{"limit": {**_N, "description": "How many recent sessions (default 8)"}},
|
||||||
|
[])},
|
||||||
"running_stats": {"handler": _running_stats, "spec": _f(
|
"running_stats": {"handler": _running_stats, "spec": _f(
|
||||||
"running_stats",
|
"running_stats",
|
||||||
"Cumulative results across closed sessions (net, $/hr, by stake). Optionally filter.",
|
"Cumulative results across closed sessions (net, $/hr, by stake). Optionally filter.",
|
||||||
@@ -353,9 +509,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:
|
||||||
|
|||||||
+48
-1
@@ -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,49 @@ 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.get("/history")
|
||||||
|
async def history_page() -> FileResponse:
|
||||||
|
"""Browsable list of past poker sessions."""
|
||||||
|
return FileResponse(str(_STATIC / "history.html"))
|
||||||
|
|
||||||
|
@app.get("/history/data")
|
||||||
|
async def history_data(limit: int = 100, include_review: bool = False) -> dict:
|
||||||
|
return {"sessions": poker.list_sessions(limit=limit, include_review=include_review)}
|
||||||
|
|
||||||
|
@app.delete("/history/{session_id}")
|
||||||
|
async def history_delete(session_id: int) -> dict:
|
||||||
|
removed = await asyncio.to_thread(poker.delete_session, session_id)
|
||||||
|
logbus.log("info", "poker session deleted", id=session_id, removed=removed)
|
||||||
|
return {"ok": True, "removed": removed}
|
||||||
|
|
||||||
@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 +137,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 +169,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()
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<!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 — Sessions</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;
|
||||||
|
--fade:#8a8a8a;--accent:#ff7a00;--good:#8fd694;--low:#ff6b6b;--mid:#ffb347;}
|
||||||
|
*{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;}
|
||||||
|
.topbar h1{font-size:1.05rem;margin:0;font-weight:600;}
|
||||||
|
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
|
||||||
|
.count{margin-left:auto;color:var(--fade);font-size:.8rem;}
|
||||||
|
main{max-width:640px;margin:0 auto;padding:12px 12px 40px;}
|
||||||
|
.summary{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;}
|
||||||
|
.pill{font-size:.8rem;color:var(--fade);background:var(--bg-elev);border:1px solid var(--border);
|
||||||
|
border-radius:999px;padding:4px 11px;} .pill b{color:var(--text);}
|
||||||
|
.row{display:flex;align-items:center;gap:12px;background:var(--bg-elev);border:1px solid var(--border);
|
||||||
|
border-radius:10px;padding:10px 12px;margin-bottom:8px;}
|
||||||
|
.row .body{flex:1;min-width:0;text-decoration:none;color:var(--text);}
|
||||||
|
.row .body:active{opacity:.7;}
|
||||||
|
.ln1{font-size:.95rem;} .ln1 .live{color:var(--accent);font-size:.7rem;border:1px solid var(--accent);
|
||||||
|
border-radius:999px;padding:0 6px;margin-left:6px;text-transform:uppercase;letter-spacing:.4px;}
|
||||||
|
.ln2{font-size:.76rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.net{flex:none;font-variant-numeric:tabular-nums;font-weight:700;}
|
||||||
|
.net.up{color:var(--good);} .net.down{color:var(--low);} .net.flat{color:var(--fade);}
|
||||||
|
.del{flex:none;background:none;border:1px solid var(--border);color:var(--fade);border-radius:8px;
|
||||||
|
padding:6px 9px;cursor:pointer;-webkit-tap-highlight-color:transparent;font-size:.9rem;}
|
||||||
|
.del:active{background:#3a1414;color:var(--low);border-color:var(--low);}
|
||||||
|
.empty{color:var(--fade);text-align:center;padding:46px 16px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="topbar">
|
||||||
|
<h1>📚 Sessions</h1>
|
||||||
|
<a class="back" href="/">← Chat</a>
|
||||||
|
<a class="back" href="/session">🎬 Live</a>
|
||||||
|
<span class="count" id="count"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="root"><p class="empty">Loading…</p></main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
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?'+$':n<0?'-$':'$')+Math.abs(n).toLocaleString();}
|
||||||
|
function netClass(v){return v==null?'flat':v>0?'up':v<0?'down':'flat';}
|
||||||
|
|
||||||
|
async function del(id, label){
|
||||||
|
if(!confirm(`Delete session ${label}? This removes its hands, reads, stacks and rituals. Can't be undone.`)) return;
|
||||||
|
try{
|
||||||
|
const r=await fetch(`/history/${id}`,{method:'DELETE'});
|
||||||
|
if(!r.ok) throw new Error('HTTP '+r.status);
|
||||||
|
load();
|
||||||
|
}catch(e){alert('Delete failed: '+e.message);}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(){
|
||||||
|
const root=document.getElementById('root');
|
||||||
|
try{
|
||||||
|
const r=await fetch('/history/data',{cache:'no-store'});
|
||||||
|
const sessions=(await r.json()).sessions||[];
|
||||||
|
document.getElementById('count').textContent=`${sessions.length} session${sessions.length===1?'':'s'}`;
|
||||||
|
if(!sessions.length){root.innerHTML='<p class="empty">No sessions yet. Start one from chat in ♠ Cash mode.</p>';return;}
|
||||||
|
|
||||||
|
const closed=sessions.filter(s=>s.net!=null);
|
||||||
|
const totNet=closed.reduce((a,s)=>a+(s.net||0),0);
|
||||||
|
const totHrs=closed.reduce((a,s)=>a+(s.hours||0),0);
|
||||||
|
const summary=`<div class="summary">
|
||||||
|
<span class="pill"><b>${sessions.length}</b> sessions</span>
|
||||||
|
<span class="pill">net <b>${money(totNet)}</b></span>
|
||||||
|
${totHrs?`<span class="pill"><b>${totHrs.toFixed(1)}h</b></span>`:''}
|
||||||
|
${totHrs?`<span class="pill">${money(Math.round(totNet/totHrs))}/hr</span>`:''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
root.innerHTML=summary+sessions.map(s=>{
|
||||||
|
const title=[s.stakes,s.game].filter(Boolean).join(' ')||'Session';
|
||||||
|
const live=s.status==='live'?'<span class="live">live</span>':'';
|
||||||
|
const date=(s.started_at||'').slice(0,10);
|
||||||
|
const meta=[date,s.venue,`${s.hands} hand${s.hands===1?'':'s'}`,
|
||||||
|
s.hours?`${(+s.hours).toFixed(1)}h`:''].filter(Boolean).join(' · ');
|
||||||
|
const href=s.has_recap?`/recap/${s.id}`:`/session`;
|
||||||
|
const net=s.net!=null?money(s.net):(s.status==='live'?'live':'—');
|
||||||
|
return `<div class="row">
|
||||||
|
<a class="body" href="${href}">
|
||||||
|
<div class="ln1">${esc(title)} <span style="color:var(--fade)">@ ${esc(s.venue||'?')}</span>${live}</div>
|
||||||
|
<div class="ln2">${esc(meta)}${s.has_recap?' · recap ✓':''}</div>
|
||||||
|
</a>
|
||||||
|
<span class="net ${netClass(s.net)}">${net}</span>
|
||||||
|
<button class="del" title="Delete session" onclick="del(${s.id}, '#${s.id} ${esc(title)}')">🗑</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}catch(e){root.innerHTML='<p class="empty">Couldn\'t load sessions.</p>';}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+168
-18
@@ -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,8 @@
|
|||||||
|
|
||||||
<div class="mobile-menu-section">
|
<div class="mobile-menu-section">
|
||||||
<h4>Actions</h4>
|
<h4>Actions</h4>
|
||||||
|
<button id="mobileSessionBtn">🎬 Session HUD</button>
|
||||||
|
<button id="mobileHistoryBtn">📚 Past Sessions</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 +61,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 +81,8 @@
|
|||||||
<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="historyBtn" href="/history" target="_blank" rel="noopener" title="Past sessions" role="button">📚 Sessions</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>
|
||||||
@@ -111,13 +116,14 @@
|
|||||||
|
|
||||||
<!-- Input box -->
|
<!-- Input box -->
|
||||||
<div id="input">
|
<div id="input">
|
||||||
<input id="userInput" type="text" placeholder="Type a message..." autofocus />
|
<textarea id="userInput" rows="1" placeholder="Type a message…" autofocus></textarea>
|
||||||
<button id="sendBtn">Send</button>
|
<button id="sendBtn" aria-label="Send" title="Send (or ⌘/Ctrl+Enter)">↑</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -281,6 +287,8 @@
|
|||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
inputEl.value = "";
|
inputEl.value = "";
|
||||||
|
|
||||||
|
autoGrow(inputEl); // collapse the box back to one line after clearing
|
||||||
|
|
||||||
addMessage("user", msg);
|
addMessage("user", msg);
|
||||||
history.push({ role: "user", content: msg });
|
history.push({ role: "user", content: msg });
|
||||||
await saveSession(); // ✅ persist both user + assistant messages
|
await saveSession(); // ✅ persist both user + assistant messages
|
||||||
@@ -298,6 +306,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 +388,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() {
|
||||||
@@ -457,6 +475,7 @@
|
|||||||
up.addEventListener("click", () => rateMessage(div, 1, up, down));
|
up.addEventListener("click", () => rateMessage(div, 1, up, down));
|
||||||
down.addEventListener("click", () => rateMessage(div, -1, up, down));
|
down.addEventListener("click", () => rateMessage(div, -1, up, down));
|
||||||
bar.appendChild(up); bar.appendChild(down);
|
bar.appendChild(up); bar.appendChild(down);
|
||||||
|
bar.appendChild(makeCopyBtn(() => div.dataset.raw || div.textContent || ""));
|
||||||
div.appendChild(bar);
|
div.appendChild(bar);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +494,80 @@
|
|||||||
down.classList.toggle("rated", value === -1);
|
down.classList.toggle("rated", value === -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy text to the clipboard. Uses the async Clipboard API when available
|
||||||
|
// (HTTPS / localhost), and falls back to a hidden-textarea + execCommand for
|
||||||
|
// iOS over plain-HTTP LAN (where navigator.clipboard is undefined).
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
text = text == null ? "" : String(text);
|
||||||
|
// Only trust the async Clipboard API in a secure context; on the LAN PWA
|
||||||
|
// (plain HTTP) it's either absent or resolves without actually copying, so
|
||||||
|
// we go straight to the iOS-tuned execCommand path there.
|
||||||
|
if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
|
||||||
|
}
|
||||||
|
return legacyCopy(text);
|
||||||
|
}
|
||||||
|
function legacyCopy(text) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
// iOS will only copy from a readOnly + contentEditable field with a real
|
||||||
|
// Range selection; readOnly also stops the keyboard from popping.
|
||||||
|
ta.readOnly = true;
|
||||||
|
ta.contentEditable = "true";
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.top = "0";
|
||||||
|
ta.style.left = "0";
|
||||||
|
ta.style.width = "1px";
|
||||||
|
ta.style.height = "1px";
|
||||||
|
ta.style.fontSize = "16px"; // avoid iOS zoom side-effects
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(ta);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
ta.setSelectionRange(0, text.length); // the bit iOS actually needs
|
||||||
|
let ok = false;
|
||||||
|
try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
|
||||||
|
sel.removeAllRanges();
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
ok ? resolve() : reject(new Error("copy failed"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A small per-message copy button. getText is read at click time.
|
||||||
|
function makeCopyBtn(getText) {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "copy-btn";
|
||||||
|
b.type = "button";
|
||||||
|
b.textContent = "⧉";
|
||||||
|
b.title = "Copy message";
|
||||||
|
b.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const text = typeof getText === "function" ? getText() : getText;
|
||||||
|
copyToClipboard(text)
|
||||||
|
.then(() => {
|
||||||
|
b.textContent = "✓"; b.classList.add("copied");
|
||||||
|
setTimeout(() => { b.textContent = "⧉"; b.classList.remove("copied"); }, 1200);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Last resort (some iOS configs block programmatic copy): surface the
|
||||||
|
// text in a prompt so it can be selected + copied by hand.
|
||||||
|
window.prompt("Copy this message:", text);
|
||||||
|
b.textContent = "⧉";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grow the input textarea to fit its content (up to a cap, then it scrolls).
|
||||||
|
function autoGrow(el) {
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 140) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
function addMessage(role, text, autoScroll = true) {
|
function addMessage(role, text, autoScroll = true) {
|
||||||
const messagesEl = document.getElementById("messages");
|
const messagesEl = document.getElementById("messages");
|
||||||
|
|
||||||
@@ -486,6 +579,12 @@
|
|||||||
addRateBar(msgDiv);
|
addRateBar(msgDiv);
|
||||||
} else {
|
} else {
|
||||||
msgDiv.textContent = text;
|
msgDiv.textContent = text;
|
||||||
|
if (role === "user") {
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = "rate-bar";
|
||||||
|
bar.appendChild(makeCopyBtn(() => text));
|
||||||
|
msgDiv.appendChild(bar);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
messagesEl.appendChild(msgDiv);
|
messagesEl.appendChild(msgDiv);
|
||||||
|
|
||||||
@@ -499,6 +598,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 +715,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 +838,7 @@
|
|||||||
// Load current session history
|
// Load current session history
|
||||||
if (currentSession) {
|
if (currentSession) {
|
||||||
await loadSession(currentSession);
|
await loadSession(currentSession);
|
||||||
|
await loadModeFor(currentSession);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -710,6 +849,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
|
||||||
@@ -884,11 +1024,15 @@
|
|||||||
checkHealth();
|
checkHealth();
|
||||||
setInterval(checkHealth, 10000);
|
setInterval(checkHealth, 10000);
|
||||||
|
|
||||||
// Input events
|
// Input events. Enter inserts a newline and grows the box (like the Claude
|
||||||
|
// app) — you tap the arrow to send. ⌘/Ctrl+Enter sends from the keyboard.
|
||||||
document.getElementById("sendBtn").addEventListener("click", sendMessage);
|
document.getElementById("sendBtn").addEventListener("click", sendMessage);
|
||||||
document.getElementById("userInput").addEventListener("keypress", e => {
|
const inputBox = document.getElementById("userInput");
|
||||||
if (e.key === "Enter") sendMessage();
|
inputBox.addEventListener("input", () => autoGrow(inputBox));
|
||||||
|
inputBox.addEventListener("keydown", e => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendMessage(); }
|
||||||
});
|
});
|
||||||
|
autoGrow(inputBox);
|
||||||
|
|
||||||
// ========== THINKING STREAM INTEGRATION ==========
|
// ========== THINKING STREAM INTEGRATION ==========
|
||||||
const thinkingPanel = document.getElementById("thinkingPanel");
|
const thinkingPanel = document.getElementById("thinkingPanel");
|
||||||
@@ -1023,6 +1167,12 @@
|
|||||||
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";
|
||||||
|
});
|
||||||
|
document.getElementById("mobileHistoryBtn").addEventListener("click", () => {
|
||||||
|
closeMobileMenu(); window.location.href = "/history";
|
||||||
|
});
|
||||||
|
|
||||||
// Connect to the global live log on page load.
|
// Connect to the global live log on page load.
|
||||||
connectThinkingStream();
|
connectThinkingStream();
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<!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; }
|
||||||
|
|
||||||
|
/* Rituals */
|
||||||
|
.gator {
|
||||||
|
display: flex; align-items: center; gap: 12px; background: #1a2e10;
|
||||||
|
border: 1px solid #3c6b1e; border-radius: 14px; padding: 14px 16px; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.gator .ico { font-size: 1.7rem; }
|
||||||
|
.gator b { color: #b6e88a; } .gator .sub { color: #8fbf6a; font-size: .82rem; }
|
||||||
|
.scar-cls {
|
||||||
|
font-size: .68rem; text-transform: uppercase; letter-spacing: .4px; border-radius: 999px;
|
||||||
|
padding: 1px 7px; border: 1px solid var(--border); margin-left: 6px;
|
||||||
|
}
|
||||||
|
.scar-cls.punt { color: var(--low); border-color: var(--low); }
|
||||||
|
.scar-cls.cooler { color: var(--mid); border-color: var(--mid); }
|
||||||
|
.scar-cls.standard { color: var(--fade); }
|
||||||
|
.card.scar { border-color: #4a2222; } .card.scar .label { color: #d98a8a; }
|
||||||
|
.card.conf { border-color: #234a23; } .card.conf .label { color: var(--good); }
|
||||||
|
.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="/history" title="Past sessions">📚 Sessions</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 rituals = data.rituals || {};
|
||||||
|
const scars = rituals.scars || [];
|
||||||
|
const confidence = rituals.confidence || [];
|
||||||
|
const resets = rituals.resets || [];
|
||||||
|
|
||||||
|
const title = [s.stakes, s.game].filter(Boolean).join(' ') || 'Session';
|
||||||
|
const tagBits = Object.entries(stats.tags || {}).map(([k,v]) => `${k}×${v}`).join(' · ');
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
${rituals.alligator ? `<div class="gator">
|
||||||
|
<span class="ico">🐊</span>
|
||||||
|
<div><b>Alligator Blood</b><div class="sub">refuse to die · no forced miracles · make them beat you correctly</div></div>
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
${resets.length ? `<span class="chip">🔄 <b>${resets.length}</b> reset${resets.length>1?'s':''}</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 conf">
|
||||||
|
<p class="label">💰 Confidence Bank</p>
|
||||||
|
${confidence.length ? `<ul class="rows">${confidence.slice().reverse().map(c => `
|
||||||
|
<li>${esc(c.content)}${c.hand_id ? ` · <a class="hand" style="display:inline" href="/hand/${c.hand_id}">hand</a>` : ''}
|
||||||
|
<div class="note-meta">${ago(c.at)}</div></li>`).join('')}</ul>`
|
||||||
|
: '<p class="empty">Nothing banked yet — disciplined plays land here.</p>'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card scar">
|
||||||
|
<p class="label">🩹 Scar Notes</p>
|
||||||
|
${scars.length ? `<ul class="rows">${scars.slice().reverse().map(sc => `
|
||||||
|
<li>${esc(sc.content)}${sc.classification ? `<span class="scar-cls ${esc(sc.classification)}">${esc(sc.classification)}</span>` : ''}
|
||||||
|
${sc.hand_id ? ` · <a class="hand" style="display:inline" href="/hand/${sc.hand_id}">hand</a>` : ''}
|
||||||
|
<div class="note-meta">${ago(sc.at)}</div></li>`).join('')}</ul>`
|
||||||
|
: '<p class="empty">No scars logged — mistakes to study land here.</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>
|
||||||
@@ -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;
|
||||||
@@ -176,6 +199,7 @@ button:hover, select:hover {
|
|||||||
/* Input bar */
|
/* Input bar */
|
||||||
#input {
|
#input {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-end; /* arrow stays at the bottom as the textarea grows */
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -185,9 +209,14 @@ button:hover, select:hover {
|
|||||||
background: var(--bg-line);
|
background: var(--bg-line);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 16px;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
font-family: var(--font-console);
|
font-family: var(--font-console);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
resize: none; /* grown programmatically, not by the drag handle */
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
transition: border-color .15s, box-shadow .15s;
|
transition: border-color .15s, box-shadow .15s;
|
||||||
}
|
}
|
||||||
#userInput::placeholder { color: var(--text-fade); }
|
#userInput::placeholder { color: var(--text-fade); }
|
||||||
@@ -198,6 +227,16 @@ button:hover, select:hover {
|
|||||||
}
|
}
|
||||||
#sendBtn {
|
#sendBtn {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
|
flex: none;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #0a0a0a;
|
color: #0a0a0a;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
@@ -822,10 +861,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);
|
||||||
@@ -912,12 +952,14 @@ select:hover {
|
|||||||
|
|
||||||
#userInput {
|
#userInput {
|
||||||
font-size: 16px; /* Prevents zoom on iOS */
|
font-size: 16px; /* Prevents zoom on iOS */
|
||||||
padding: 12px;
|
padding: 11px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sendBtn {
|
#sendBtn {
|
||||||
padding: 12px 16px;
|
width: 44px; /* comfortable touch target */
|
||||||
font-size: 1rem;
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal - full width on mobile */
|
/* Modal - full width on mobile */
|
||||||
@@ -1016,12 +1058,14 @@ select:hover {
|
|||||||
|
|
||||||
#userInput {
|
#userInput {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 10px;
|
padding: 10px 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sendBtn {
|
#sendBtn {
|
||||||
padding: 10px 14px;
|
width: 42px;
|
||||||
font-size: 0.95rem;
|
height: 42px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h3 {
|
.modal-header h3 {
|
||||||
@@ -1158,6 +1202,22 @@ select:hover {
|
|||||||
.rate-btn:hover { filter: none; background: var(--accent-soft); }
|
.rate-btn:hover { filter: none; background: var(--accent-soft); }
|
||||||
.rate-btn.rated { filter: none; background: rgba(255,122,0,0.22); opacity: 1; }
|
.rate-btn.rated { filter: none; background: rgba(255,122,0,0.22); opacity: 1; }
|
||||||
|
|
||||||
|
/* Per-message copy button (lives in the rate-bar for assistant, its own bar for user). */
|
||||||
|
.copy-btn {
|
||||||
|
background: none; border: none; cursor: pointer; font-size: 0.85rem;
|
||||||
|
padding: 2px 6px; border-radius: 5px; line-height: 1; color: var(--text-fade);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.copy-btn:hover { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.copy-btn.copied { color: var(--good); }
|
||||||
|
/* User bubbles are right-aligned, so right-align their copy bar too. */
|
||||||
|
.msg.user .rate-bar { justify-content: flex-end; opacity: 0.4; }
|
||||||
|
.msg.user:hover .rate-bar { opacity: 0.85; }
|
||||||
|
/* Touch devices have no hover — keep the tools tappable/visible. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.rate-bar, .msg.user .rate-bar { opacity: 0.65; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Quality floor: honor reduced-motion preference. */
|
/* Quality floor: honor reduced-motion preference. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
|
|||||||
+1
-1
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""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()
|
||||||
|
|
||||||
|
|
||||||
|
# --- mental-game rituals ---
|
||||||
|
|
||||||
|
def test_ritual_tools_in_cash_only(lyra):
|
||||||
|
_, _, modes, tools = lyra
|
||||||
|
cash = _names(tools.specs(modes.CASH.tools))
|
||||||
|
talk = _names(tools.specs(modes.TALK.tools))
|
||||||
|
rituals = {"scar_note", "confidence_bank", "alligator_blood", "reset_ritual"}
|
||||||
|
assert rituals <= cash
|
||||||
|
assert not (rituals & talk)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scar_and_confidence_capture(lyra):
|
||||||
|
_, poker, _, tools = lyra
|
||||||
|
poker.start_session(stakes="2/5", buy_in=500)
|
||||||
|
tools.dispatch("scar_note", {"content": "punted bottom set", "classification": "punt"}, {})
|
||||||
|
tools.dispatch("scar_note", {"content": "ran KK into AA", "classification": "cooler"}, {})
|
||||||
|
tools.dispatch("confidence_bank", {"content": "disciplined river fold"}, {})
|
||||||
|
|
||||||
|
scars = poker.list_rituals(kinds=("scar",))
|
||||||
|
assert len(scars) == 2
|
||||||
|
assert {s["classification"] for s in scars} == {"punt", "cooler"}
|
||||||
|
conf = poker.list_rituals(kinds=("confidence",))
|
||||||
|
assert len(conf) == 1 and "fold" in conf[0]["content"]
|
||||||
|
# bogus classification is dropped, not stored
|
||||||
|
tools.dispatch("scar_note", {"content": "x", "classification": "nonsense"}, {})
|
||||||
|
assert poker.list_rituals(kinds=("scar",))[-1]["classification"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_alligator_toggle_and_state(lyra):
|
||||||
|
_, poker, _, tools = lyra
|
||||||
|
poker.start_session(stakes="2/5", buy_in=500)
|
||||||
|
assert poker.alligator_active() is False
|
||||||
|
tools.dispatch("alligator_blood", {"on": True}, {})
|
||||||
|
assert poker.alligator_active() is True
|
||||||
|
tools.dispatch("alligator_blood", {"on": False}, {})
|
||||||
|
assert poker.alligator_active() is False # latest toggle wins
|
||||||
|
|
||||||
|
|
||||||
|
def test_rituals_in_hud(lyra):
|
||||||
|
_, poker, _, tools = lyra
|
||||||
|
poker.start_session(stakes="2/5", buy_in=500)
|
||||||
|
tools.dispatch("scar_note", {"content": "overplayed top pair"}, {})
|
||||||
|
tools.dispatch("confidence_bank", {"content": "good value bet"}, {})
|
||||||
|
tools.dispatch("reset_ritual", {"content": "lost a flip"}, {})
|
||||||
|
tools.dispatch("alligator_blood", {"on": True}, {})
|
||||||
|
|
||||||
|
r = poker.hud()["rituals"]
|
||||||
|
assert r["alligator"] is True
|
||||||
|
assert len(r["scars"]) == 1 and len(r["confidence"]) == 1 and len(r["resets"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_state_readback(lyra):
|
||||||
|
_, poker, _, tools = lyra
|
||||||
|
assert "no live session" in tools.dispatch("session_state", {}, {}).lower()
|
||||||
|
|
||||||
|
poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
||||||
|
tools.dispatch("log_stack", {"amount": 720}, {})
|
||||||
|
tools.dispatch("confidence_bank", {"content": "great river fold"}, {})
|
||||||
|
tools.dispatch("alligator_blood", {"on": True}, {})
|
||||||
|
|
||||||
|
out = tools.dispatch("session_state", {}, {})
|
||||||
|
assert "720" in out # current stack
|
||||||
|
assert "+220" in out or "220" in out # live net
|
||||||
|
assert "Alligator Blood is ON" in out
|
||||||
|
assert "great river fold" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_and_delete_session(lyra):
|
||||||
|
_, poker, _, tools = lyra
|
||||||
|
keep = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
||||||
|
poker.end_session(cash_out=400, session_id=keep)
|
||||||
|
drop = poker.start_session(venue="Bellagio", stakes="2/5", buy_in=500)
|
||||||
|
poker.log_hand(position="BTN", hole_cards="AKs", session_id=drop)
|
||||||
|
poker.log_stack(620, session_id=drop)
|
||||||
|
poker.log_ritual("scar", content="punt", session_id=drop)
|
||||||
|
|
||||||
|
sessions = poker.list_sessions()
|
||||||
|
assert {s["id"] for s in sessions} == {keep, drop}
|
||||||
|
assert next(s for s in sessions if s["id"] == drop)["hands"] == 1
|
||||||
|
|
||||||
|
removed = poker.delete_session(drop)
|
||||||
|
assert removed["poker_sessions"] == 1 and removed["poker_hands"] == 1
|
||||||
|
assert removed["poker_stack_log"] == 1 and removed["poker_rituals"] == 1
|
||||||
|
assert {s["id"] for s in poker.list_sessions()} == {keep} # only the survivor
|
||||||
|
assert poker.get_session(drop) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_sessions_tool(lyra):
|
||||||
|
_, poker, modes, tools = lyra
|
||||||
|
assert "recent_sessions" in modes.TALK.tools # available even when just talking
|
||||||
|
poker.import_session(date="2026-06-01", venue="Meadows", stakes="1/3",
|
||||||
|
buy_in_total=300, cash_out=520, hours=5)
|
||||||
|
out = tools.dispatch("recent_sessions", {}, {})
|
||||||
|
assert "Meadows" in out and "+220" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_rituals_require_live_session(lyra):
|
||||||
|
_, poker, _, tools = lyra
|
||||||
|
# tools degrade gracefully (no exception) when nothing is open
|
||||||
|
assert "no live session" in tools.dispatch("scar_note", {"content": "x"}, {}).lower()
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
poker.log_ritual("scar", content="x")
|
||||||
Reference in New Issue
Block a user