diff --git a/.env.example b/.env.example index 3d1661b..535d38c 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,7 @@ SUMMARY_BACKEND=local # Where Lyra stores her memory. LYRA_DB_PATH=data/lyra.db + +# Optional: run embeddings on a separate always-on Ollama (decoupled from +# LOCAL_BASE_URL, which serves local chat). Defaults to LOCAL_BASE_URL if unset. +# EMBED_BASE_URL=http://127.0.0.1:11434 diff --git a/CHANGELOG.md b/CHANGELOG.md index 735b94b..7190d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # 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 The leap from "chat + memory baseline" to a working, persistent companion with a diff --git a/README.md b/README.md index e2f58cd..47e2808 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,22 @@ reflected), and keeps a permanent **journal**. ## Poker copilot +She runs in **modes** (`lyra/modes.py`). π¬ **Talk** is the default companion +(journaling + read-only poker lookups). β **Cash** is the live copilot: she gets +the full session toolset and a two-register voice β quiet and act-first when +you're feeding her facts to log (stack, a hand, a read β one-line confirm, no +narration), but fully present and warm when you ask for strategy or you're tilting +/ card-dead / steaming. Opening a session auto-switches her into Cash mode. + Talk to her during a session; she drives tools behind the scenes: - **Session tracking** β `start_session`, `add_buyin`, `end_session` β net, hours, $/hr. +- **Stack tracking** β `log_stack` records your stack as the night goes β live net + while you're still sitting, and a stack-over-time sparkline on the HUD. +- **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 reconstructs a structured, **replayable** hand (unknown cards = `x`, never invented). - **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`) -`/` chat (Markdown, model picker, π/π rating) Β· `/logs` live activity Β· `/self` -read-her-mind (mood, drives, reflections) Β· `/journal` her thoughts Β· `/hands` -recorded hands β `/hand/{id}` replayer Β· `/recap/{id}` session writeup (+ `.md` export). +`/` chat (Markdown, model picker, π/π rating, **Talk/Cash mode switcher**) Β· +`/session` **live session HUD** (stack + sparkline, hands, villains, notes; mobile +Session tab) Β· `/logs` live activity Β· `/self` read-her-mind (mood, drives, +reflections) Β· `/journal` her thoughts Β· `/hands` recorded hands β `/hand/{id}` +replayer Β· `/recap/{id}` session writeup (+ `.md` export). π/π ratings on replies and thoughts are stored as `(context, content, rating)` β a fine-tune / preference dataset built passively (`/ratings/export` β JSONL). diff --git a/lyra/chat.py b/lyra/chat.py index d9ba75b..7917b58 100644 --- a/lyra/chat.py +++ b/lyra/chat.py @@ -10,7 +10,7 @@ After replying, the session is compacted if enough new turns have accumulated. """ from __future__ import annotations -from lyra import clock, config, llm, logbus, memory, persona, self_state, summary +from lyra import clock, config, llm, logbus, memory, modes, persona, self_state, summary from lyra import tools as toolkit from lyra.llm import Backend, Message @@ -24,6 +24,30 @@ MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn 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: lines = [f"- ({(s.session_started_at or s.created_at)[:10]}) {s.content}" for s in summaries] body = "Gist of earlier sessions (compacted β ask if you need specifics):\n" + "\n".join(lines) @@ -56,7 +80,8 @@ def _render(messages: list[Message]) -> str: return "\n\n".join(f"[{m['role']}]\n{m['content']}" for m in messages) -def build_messages(session_id: str, user_msg: str) -> list[Message]: +def build_messages(session_id: str, user_msg: str, + mode: modes.Mode | None = None) -> list[Message]: """Assemble the full, tiered message list for one turn.""" messages: list[Message] = [{"role": "system", "content": persona.system_prompt()}] @@ -64,6 +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. 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). 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, ) - messages = build_messages(session_id, user_msg) + mode = modes.get(memory.get_session_mode(session_id)) + messages = build_messages(session_id, user_msg, mode=mode) - # Tool loop: offer Lyra her tools; if she calls one, run it and feed the - # result back so she can continue, until she returns a normal text reply. - tool_specs = toolkit.specs() if backend in TOOL_BACKENDS else None + # Tool loop: offer Lyra her tools (scoped to the mode); if she calls one, run it + # and feed the result back so she can continue, until she returns a text reply. + tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None ctx = {"session_id": session_id, "backend": backend} reply = "" for _ in range(MAX_TOOL_ROUNDS): @@ -152,6 +191,7 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud", result = toolkit.dispatch(tc["name"], tc["arguments"], ctx) logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80]) messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result}) + _maybe_switch_mode(session_id, tc["name"]) if not reply: reply = "(I got tangled using my tools there β say that again?)" logbus.log("info", "reply", session=session_id, chars=len(reply)) @@ -160,5 +200,64 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud", memory.remember(session_id, "assistant", reply) # Compact this session once enough new turns have piled up. - summary.maybe_summarize(session_id) + summary.maybe_summarize_async(session_id) return reply + + +def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud", + model_override: str | None = None): + """Streaming generator version of `respond`. + + Yields ("delta", text) as content streams in, and ("tool", name) when a tool + runs. Persists the full exchange and yields a final ("done", reply) β matching + `respond`'s side effects (memory + compaction) exactly. + """ + cfg = config.load() + model = {"local": cfg.local_model, "cloud": cfg.chat_model, "mi50": cfg.mi50_model}.get( + backend, backend + ) + if model_override and backend == "cloud": + model = model_override + logbus.log( + "info", "chat request (stream)", session=session_id, backend=backend, + model=model, embed=cfg.embed_backend, + ) + + mode = modes.get(memory.get_session_mode(session_id)) + messages = build_messages(session_id, user_msg, mode=mode) + tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None + ctx = {"session_id": session_id, "backend": backend} + parts: list[str] = [] + for _ in range(MAX_TOOL_ROUNDS): + assistant_msg = None + tool_calls = None + for ev, payload in llm.chat_call_stream( + messages, backend=backend, model=model, tools=tool_specs + ): + if ev == "delta": + parts.append(payload) + yield ("delta", payload) + elif ev == "message": + assistant_msg = payload + elif ev == "tool_calls": + tool_calls = payload + if not tool_calls: + break + messages.append(assistant_msg) # her tool-call request + for tc in tool_calls: + result = toolkit.dispatch(tc["name"], tc["arguments"], ctx) + logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80]) + messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result}) + _maybe_switch_mode(session_id, tc["name"]) + yield ("tool", tc["name"]) + + reply = "".join(parts) + if not reply: + reply = "(I got tangled using my tools there β say that again?)" + yield ("delta", reply) + logbus.log("info", "reply", session=session_id, chars=len(reply)) + + memory.remember(session_id, "user", user_msg) + memory.remember(session_id, "assistant", reply) + summary.maybe_summarize_async(session_id) + yield ("done", reply) diff --git a/lyra/config.py b/lyra/config.py index e5ee22d..e36f51e 100644 --- a/lyra/config.py +++ b/lyra/config.py @@ -22,6 +22,7 @@ class Config: embed_backend: str # "cloud" (OpenAI) or "local" (Ollama) embed_model: str # OpenAI embedding model local_embed_model: str # Ollama embedding model + embed_base_url: str # Ollama endpoint for embeddings (own box, decoupled from local chat) summary_backend: str # "local" or "cloud" β backend used to compact memory db_path: Path @@ -38,6 +39,9 @@ def load() -> Config: embed_backend=os.getenv("EMBED_BACKEND", "cloud").lower(), embed_model=os.getenv("EMBED_MODEL", "text-embedding-3-small"), local_embed_model=os.getenv("LOCAL_EMBED_MODEL", "nomic-embed-text"), + # Embeddings can live on their own always-on box, separate from the local + # chat backend. Defaults to LOCAL_BASE_URL so existing setups are unchanged. + embed_base_url=os.getenv("EMBED_BASE_URL", os.getenv("LOCAL_BASE_URL", "http://localhost:11434")), summary_backend=os.getenv("SUMMARY_BACKEND", "local").lower(), db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")), ) diff --git a/lyra/llm.py b/lyra/llm.py index f9ff419..de02efe 100644 --- a/lyra/llm.py +++ b/lyra/llm.py @@ -1,7 +1,8 @@ """LLM router: local (Ollama) chat, cloud (OpenAI) chat + embeddings.""" from __future__ import annotations -from typing import Literal, TypedDict +import json +from typing import Iterator, Literal, TypedDict import httpx from openai import OpenAI @@ -80,6 +81,88 @@ def chat_call( return {"role": "assistant", "content": complete(messages, backend=backend, model=model)}, None +def chat_call_stream( + messages: list, backend: Backend = "cloud", model: str | None = None, + tools: list | None = None, +) -> Iterator[tuple[str, object]]: + """Streaming variant of `chat_call`. Yields ("delta", text) for each content + chunk as it arrives, then exactly two terminal events: + ("message", assistant_dict) β the full assistant turn, to append back + ("tool_calls", calls | None) β list of {id,name,arguments} or None + + `local` (Ollama) streams NDJSON and never returns tool calls. + """ + cfg = load() + if backend in ("cloud", "mi50"): + if backend == "cloud": + if not cfg.openai_api_key: + raise RuntimeError("OPENAI_API_KEY is not set") + client = OpenAI(api_key=cfg.openai_api_key) + mdl = model or cfg.cloud_model + else: + client = OpenAI(api_key="not-needed", base_url=cfg.mi50_base_url) + mdl = model or cfg.mi50_model + kwargs: dict = {"model": mdl, "messages": messages, "stream": True} + if tools: + kwargs["tools"] = tools + parts: list[str] = [] + frags: dict[int, dict] = {} # tool-call fragments accumulated by index + for chunk in client.chat.completions.create(**kwargs): + if not chunk.choices: + continue + delta = chunk.choices[0].delta + if getattr(delta, "content", None): + parts.append(delta.content) + yield ("delta", delta.content) + for tc in getattr(delta, "tool_calls", None) or []: + slot = frags.setdefault(tc.index, {"id": "", "name": "", "arguments": ""}) + if tc.id: + slot["id"] = tc.id + if tc.function and tc.function.name: + slot["name"] = tc.function.name + if tc.function and tc.function.arguments: + slot["arguments"] += tc.function.arguments + content = "".join(parts) + if frags: + calls = [frags[i] for i in sorted(frags)] + assistant = { + "role": "assistant", + "content": content or None, + "tool_calls": [ + {"id": c["id"], "type": "function", + "function": {"name": c["name"], "arguments": c["arguments"]}} + for c in calls + ], + } + yield ("message", assistant) + yield ("tool_calls", [{"id": c["id"], "name": c["name"], "arguments": c["arguments"]} for c in calls]) + else: + yield ("message", {"role": "assistant", "content": content}) + yield ("tool_calls", None) + return + + # local (Ollama): stream NDJSON, no tools. + parts = [] + with httpx.stream( + "POST", f"{cfg.local_base_url}/api/chat", + json={"model": model or cfg.local_model, "messages": messages, "stream": True}, + timeout=120, + ) as resp: + resp.raise_for_status() + for line in resp.iter_lines(): + if not line: + continue + data = json.loads(line) + piece = (data.get("message") or {}).get("content", "") + if piece: + parts.append(piece) + yield ("delta", piece) + if data.get("done"): + break + yield ("message", {"role": "assistant", "content": "".join(parts)}) + yield ("tool_calls", None) + + def embed(texts: list[str]) -> list[list[float]]: """Embed texts using the configured backend (EMBED_BACKEND: "cloud" or "local"). @@ -90,7 +173,7 @@ def embed(texts: list[str]) -> list[list[float]]: cfg = load() if cfg.embed_backend == "local": resp = httpx.post( - f"{cfg.local_base_url}/api/embed", + f"{cfg.embed_base_url}/api/embed", json={"model": cfg.local_embed_model, "input": texts}, timeout=120, ) diff --git a/lyra/memory.py b/lyra/memory.py index d245708..a7a3478 100644 --- a/lyra/memory.py +++ b/lyra/memory.py @@ -32,6 +32,7 @@ CREATE INDEX IF NOT EXISTS idx_session_created ON exchanges(session_id, created_ CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, name TEXT, + mode TEXT, -- conversation mode (see lyra/modes.py); NULL = default created_at TEXT NOT NULL ); @@ -130,7 +131,18 @@ def _connection() -> sqlite3.Connection: # alongside the web server without tripping "database is locked". _conn.execute("PRAGMA busy_timeout=5000") _conn.execute("PRAGMA journal_mode=WAL") + # WAL's recommended companion: don't fsync on every commit (only at + # checkpoint). Safe against app crashes; a power/OS crash can lose the last + # txn but never corrupt. On disk-backed storage this turns ~0.15s-per-commit + # fsync latency into ~nothing β big win for per-turn writes + the dream loop. + _conn.execute("PRAGMA synchronous=NORMAL") _conn.executescript(SCHEMA) + # Migrations for DBs created before a column existed (no-op if present). + for ddl in ("ALTER TABLE sessions ADD COLUMN mode TEXT",): + try: + _conn.execute(ddl) + except sqlite3.OperationalError: + pass _conn_path = cfg.db_path return _conn @@ -236,6 +248,21 @@ def ensure_session(session_id: str, name: str | None = None) -> None: conn.execute("UPDATE sessions SET name = ? WHERE id = ?", (name, session_id)) +def get_session_mode(session_id: str) -> str | None: + """The session's conversation mode key, or None if unset (caller applies default).""" + conn = _connection() + r = conn.execute("SELECT mode FROM sessions WHERE id = ?", (session_id,)).fetchone() + return r["mode"] if r and r["mode"] else None + + +def set_session_mode(session_id: str, mode: str) -> None: + """Persist the session's conversation mode (creating the session row if needed).""" + ensure_session(session_id) + conn = _connection() + with conn: + conn.execute("UPDATE sessions SET mode = ? WHERE id = ?", (mode, session_id)) + + def list_sessions() -> list[dict]: """All known sessions (named rows + any session that has exchanges), newest first.""" conn = _connection() diff --git a/lyra/modes.py b/lyra/modes.py new file mode 100644 index 0000000..3155cf9 --- /dev/null +++ b/lyra/modes.py @@ -0,0 +1,127 @@ +"""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", + "undo_last", "update_session", +) + +# 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()] diff --git a/lyra/poker.py b/lyra/poker.py index 9eb0d81..cc195bc 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -98,6 +98,30 @@ CREATE TABLE IF NOT EXISTS player_observations ( created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_pobs_player ON player_observations(player_id); + +-- Stack-size log: one row per stack update Brian gives during a session. Lets the +-- HUD show current stack, live net while sitting, and a stack-over-time sparkline. +CREATE TABLE IF NOT EXISTS poker_stack_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + amount REAL NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_stacklog_session ON poker_stack_log(session_id); + +-- 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). @@ -181,6 +205,127 @@ def clear_all() -> dict: 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 + + +# --- per-entry deletes / undo (fix fat-fingered live logging) --- + +def delete_hand(hand_id: int) -> bool: + """Delete one hand and any player observations derived from it.""" + conn = _c() + with conn: + conn.execute("DELETE FROM player_observations WHERE hand_id = ?", (hand_id,)) + cur = conn.execute("DELETE FROM poker_hands WHERE id = ?", (hand_id,)) + return cur.rowcount > 0 + + +def delete_stack(stack_id: int) -> bool: + conn = _c() + with conn: + cur = conn.execute("DELETE FROM poker_stack_log WHERE id = ?", (stack_id,)) + return cur.rowcount > 0 + + +def delete_read(read_id: int) -> bool: + conn = _c() + with conn: + cur = conn.execute("DELETE FROM player_reads WHERE id = ?", (read_id,)) + return cur.rowcount > 0 + + +def delete_ritual(ritual_id: int) -> bool: + conn = _c() + with conn: + cur = conn.execute("DELETE FROM poker_rituals WHERE id = ?", (ritual_id,)) + return cur.rowcount > 0 + + +def delete_entry(kind: str, entry_id: int) -> bool: + """Dispatch a per-entry delete by kind β for the HUD's row delete buttons.""" + return { + "hand": delete_hand, "stack": delete_stack, + "read": delete_read, "ritual": delete_ritual, + }.get(kind, lambda _id: False)(entry_id) + + +def undo_last(kind: str, session_id: int | None = None) -> str | None: + """Delete the most-recent entry of `kind` in the live session and return a short + description of what was removed (None if there was nothing). For "scratch that". + + kind: hand | stack | read | scar | confidence | reset | ritual. + """ + sid = _resolve(session_id) + if sid is None: + raise ValueError("no live session") + k = (kind or "").lower().strip() + + if k in ("scar", "confidence", "reset", "ritual"): + sql = ("SELECT id, kind, content FROM poker_rituals WHERE session_id = ? " + + ("AND kind = ? " if k != "ritual" else "AND kind IN ('scar','confidence','reset') ") + + "ORDER BY id DESC LIMIT 1") + params = (sid, k) if k != "ritual" else (sid,) + r = _c().execute(sql, params).fetchone() + if not r: + return None + delete_ritual(r["id"]) + label = _RITUAL_LABEL.get(r["kind"], r["kind"]) + return f"{label}" + (f": {r['content']}" if r["content"] else "") + + table, desc_cols = { + "hand": ("poker_hands", "position, hole_cards"), + "stack": ("poker_stack_log", "amount"), + "read": ("player_reads", "note"), + }.get(k, (None, None)) + if not table: + return None + r = _c().execute( + f"SELECT id, {desc_cols} FROM {table} WHERE session_id = ? ORDER BY id DESC LIMIT 1", + (sid,), + ).fetchone() + if not r: + return None + delete_entry(k, r["id"]) + if k == "hand": + return f"hand ({(r['position'] or '?')} {r['hole_cards'] or ''})".strip() + if k == "stack": + return f"stack ${r['amount']:g}" + return f"read: {r['note'][:50]}" + + def live_session() -> dict | None: """The current open session, if any.""" r = _c().execute( @@ -196,6 +341,50 @@ def _resolve(session_id: int | None) -> int | None: return live["id"] if live else None +def review_session_id() -> int | None: + """The session to attach reflective entries to: the live one if any, else the + most-recent real session (closed). Lets rituals/notes land while reviewing after + you've racked up. Excludes the standing 'Hand Reviews' bucket.""" + live = live_session() + if live: + return live["id"] + r = _c().execute( + "SELECT id FROM poker_sessions WHERE status != 'review' ORDER BY id DESC LIMIT 1" + ).fetchone() + return int(r["id"]) if r else None + + +_EDITABLE = ("venue", "stakes", "game", "format", "buy_in_total", "cash_out", + "mantra", "mood") + + +def update_session(session_id: int, **fields) -> dict | None: + """Edit session details (during or after play). Only known columns are touched; + net is recomputed when buy-in/cash-out change and both are known.""" + s = get_session(session_id) + if not s: + return None + sets, vals = [], [] + for k, v in fields.items(): + if k in _EDITABLE and v is not None: + sets.append(f"{k} = ?") + vals.append(float(v) if k in ("buy_in_total", "cash_out") else v) + if sets: + conn = _c() + with conn: + conn.execute(f"UPDATE poker_sessions SET {', '.join(sets)} WHERE id = ?", + (*vals, session_id)) + s = get_session(session_id) + # keep net consistent if the money fields changed and both are present + if s.get("cash_out") is not None and s.get("buy_in_total") is not None: + net = float(s["cash_out"]) - float(s["buy_in_total"]) + if net != s.get("net"): + with conn: + conn.execute("UPDATE poker_sessions SET net = ? WHERE id = ?", (net, session_id)) + s = get_session(session_id) + return s + + def add_buyin(amount: float, session_id: int | None = None) -> float: """Add a buy-in/rebuy to a session. Returns the new total in.""" sid = _resolve(session_id) @@ -212,6 +401,115 @@ def add_buyin(amount: float, session_id: int | None = None) -> float: ).fetchone()["buy_in_total"]) +# --- stack tracking --- + +def log_stack(amount: float, session_id: int | None = None) -> dict: + """Record Brian's current chip stack. Returns {current, buy_in, net} where net + is his live net while sitting (current stack β total bought in).""" + sid = _resolve(session_id) + if sid is None: + raise ValueError("no live session") + conn = _c() + with conn: + conn.execute( + "INSERT INTO poker_stack_log (session_id, amount, created_at) VALUES (?, ?, ?)", + (sid, float(amount), _now()), + ) + return stack_state(sid) + + +def current_stack(session_id: int | None = None) -> float | None: + """Most recently logged stack for a session, or None if none logged.""" + sid = _resolve(session_id) + if sid is None: + return None + r = _c().execute( + "SELECT amount FROM poker_stack_log WHERE session_id = ? ORDER BY id DESC LIMIT 1", + (sid,), + ).fetchone() + return float(r["amount"]) if r else None + + +def stack_log(session_id: int | None = None) -> list[dict]: + """Full stack history for a session (oldest first) β the sparkline series.""" + sid = _resolve(session_id) + if sid is None: + return [] + return [dict(r) for r in _c().execute( + "SELECT id, 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, session_id: int | None = None) -> dict: """Close a session: record cashout, compute net + hours. Returns the row.""" @@ -415,6 +713,38 @@ def record_hand(shorthand: str, session_id: int | None = None, stakes: str | Non return {"id": hid, "parsed": parsed, "linked": linked} +def reconstruct_hand(hand_id: int, backend: str | None = None) -> dict | None: + """Upgrade a flat (log_hand) hand into a structured, replayable one by parsing + its captured street narratives. On-demand so quick-logged live hands can become + replayable without an LLM call per log during play.""" + h = get_hand(hand_id) + if not h: + return None + parts = [] + if h.get("position") or h.get("hole_cards"): + parts.append(f"Hero is {h.get('position') or '?'} with {h.get('hole_cards') or 'unknown'}.") + for st in ("preflop", "flop", "turn", "river", "showdown"): + if h.get(st): + parts.append(f"{st.capitalize()}: {h[st]}") + if h.get("board"): + parts.append(f"Final board: {h['board']}.") + if h.get("result") is not None: + parts.append(f"Hero net result: {h['result']}.") + shorthand = " ".join(parts).strip() + if not shorthand: + return None + parsed = parse_hand(shorthand, backend=backend) + if not parsed: + return None + parsed = _normalize_parsed(parsed) + conn = _c() + with conn: + conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?", + (json.dumps(parsed), hand_id)) + link_hand_players(hand_id, parsed, session_id=h.get("session_id")) + return {"id": hand_id, "parsed": parsed} + + def get_hand(hand_id: int) -> dict | None: """A stored hand with its structured JSON parsed back into a dict.""" r = _c().execute("SELECT * FROM poker_hands WHERE id = ?", (hand_id,)).fetchone() @@ -489,6 +819,21 @@ def _hand_line(h: dict) -> str: 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: """Generate Brian's .md recap from a session's structured data + conversation, store it.""" backend = backend or "cloud" @@ -500,6 +845,7 @@ def generate_recap(session_id: int | None = None, backend: str | None = None) -> reads = [dict(r) for r in _c().execute( "SELECT seat, note FROM player_reads WHERE session_id = ?", (sid,)).fetchall()] stats = session_stats(sid) + rituals = list_rituals(sid) convo = "" if s.get("chat_session_id"): @@ -516,6 +862,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" "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" + "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)") ) md = llm.complete( @@ -752,3 +1101,92 @@ def running_stats(stakes: str | None = None, venue: str | None = None, "per_hour": round(net / hours, 2) if hours else None, "by_stake": by_stake, } + + +# --- live session HUD (everything tracked in the current session, for the UI) --- + +def _session_villains(sid: int) -> list[dict]: + """Players read this session, with their standing dossier fields.""" + rows = _c().execute( + "SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, " + "p.adjustment AS adjustment, " + "(SELECT note FROM player_reads r2 WHERE r2.player_id = p.id " + " AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note " + "FROM poker_players p " + "WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads " + " WHERE session_id = ? AND player_id IS NOT NULL) " + "ORDER BY p.updated_at DESC", + (sid, sid), + ).fetchall() + return [dict(r) for r in rows] + + +def hud(session_id: int | None = None) -> dict | None: + """Everything tracked in the current (or given) session, for the live HUD. + + Returns None when there's no session to show. The shape is presentation-ready: + header, stack (with sparkline series + live net), hands, villains seen, her + notes from the session window, and session stats. + """ + s = get_session(session_id) if session_id is not None else live_session() + if not s: + return None + sid = s["id"] + log = stack_log(sid) + state = stack_state(sid) + + hands = [ + {"id": h["id"], "position": h.get("position"), "hole_cards": h.get("hole_cards"), + "board": h.get("board"), "result": h.get("result"), "tag": h.get("tag"), + "at": h.get("at")} + for h in list_hands(sid) + ] + + # Notes she jotted during this session: journal/note entries since it started. + started = s.get("started_at") or "" + notes = [ + {"created_at": j["created_at"], "kind": j["kind"], "content": j["content"]} + for j in memory.list_journal(kinds=("note", "journal")) + if (j["created_at"] or "") >= started + ][:20] + + stats = session_stats(sid) + # Context: how Brian runs at these stakes overall (closed sessions). + ctx = running_stats(stakes=s.get("stakes")) if s.get("stakes") else {} + + rituals = list_rituals(sid) + by_kind = lambda k: [ # noqa: E731 + {"id": r["id"], "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"), + "ended_at": s.get("ended_at"), "hours": s.get("hours"), + "buy_in_total": s.get("buy_in_total"), "cash_out": s.get("cash_out"), + "net": s.get("net"), "mantra": s.get("mantra"), "mood": s.get("mood"), + "is_live": s.get("status") == "live", "has_recap": bool(s.get("recap_md")), + }, + "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"), + }, + } diff --git a/lyra/summary.py b/lyra/summary.py index 39506d8..3ffee1a 100644 --- a/lyra/summary.py +++ b/lyra/summary.py @@ -10,6 +10,7 @@ big imported conversation doesn't blow the local model's context window. from __future__ import annotations import sys +import threading import time 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. 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 +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 \ (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: - """Transcript -> gist (LLM only, no DB). Chunks + merges if oversized.""" - if len(transcript) <= MAX_TRANSCRIPT_CHARS: + """Transcript -> gist (LLM only, no DB). Chunks + merges if oversized, and + recurses so even the merged partials never exceed the backend's window.""" + budget = _budget(backend) + if len(transcript) <= budget: return _summarize_text(transcript, backend) - partials = [_summarize_text(c, backend) for c in _chunk(transcript, MAX_TRANSCRIPT_CHARS)] - return _summarize_text("Partial summaries to merge:\n\n" + "\n\n".join(partials), backend) + partials = [_summarize_text(c, backend) for c in _chunk(transcript, budget)] + 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: @@ -91,6 +102,32 @@ def maybe_summarize(session_id: str, backend: Backend | None = None) -> None: 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( backend: Backend | None = None, limit: int | None = None, workers: int = 8 ) -> dict: diff --git a/lyra/tools.py b/lyra/tools.py index 3e21973..d98c3ed 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -104,6 +104,98 @@ def _add_buyin(args: dict, ctx: dict) -> str: return f"Added {args.get('amount')}. Total in this session: {total:g}." +def _log_stack(args: dict, ctx: dict) -> str: + try: + amount = float(args.get("amount")) + except (TypeError, ValueError): + return "Give me a number for the stack." + try: + st = poker.log_stack(amount) + except ValueError: + return "No live session β start one first, then I'll track your stack." + net = st.get("net") + return f"Stack ${amount:g} logged" + (f" (net {net:+.0f})." if net is not None else ".") + + +def _update_session(args: dict, ctx: dict) -> str: + sid = poker.review_session_id() + if sid is None: + return "No session to edit yet." + fields = {k: args.get(k) for k in ("venue", "stakes", "game", "format", + "buy_in_total", "cash_out", "mantra", "mood") if args.get(k) not in (None, "")} + if not fields: + return "Tell me what to change (venue, stakes, game, buy-in, etc.)." + s = poker.update_session(sid, **fields) + if not s: + return "Couldn't find that session." + changed = ", ".join(f"{k}={v}" for k, v in fields.items()) + return f"Session #{sid} updated β {changed}." + + +def _undo_last(args: dict, ctx: dict) -> str: + what = (args.get("what") or "").strip().lower() + aliases = {"hands": "hand", "stacks": "stack", "reads": "read", + "scar_note": "scar", "confidence_bank": "confidence", + "scar note": "scar", "confidence": "confidence", "note": "ritual"} + what = aliases.get(what, what) + valid = ("hand", "stack", "read", "scar", "confidence", "reset", "ritual") + if what not in valid: + return f"Tell me what to undo β one of: {', '.join(valid)}." + try: + removed = poker.undo_last(what) + except ValueError: + return "No live session to undo anything in." + if not removed: + return f"Nothing logged to undo for '{what}'." + logbus.log("info", "undo last", what=what, removed=removed[:60]) + return f"Scratched the last {what} β removed {removed}." + + +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 + sid = poker.review_session_id() # live, or the most-recent session (post-game review) + if sid is None: + return "No session yet β start one and I'll keep the scar notes." + poker.log_ritual("scar", content=content, classification=cls, + hand_id=args.get("hand_id"), session_id=sid) + 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." + sid = poker.review_session_id() + if sid is None: + return "No session yet β start one and I'll run the confidence bank." + poker.log_ritual("confidence", content=content, hand_id=args.get("hand_id"), session_id=sid) + 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 + sid = poker.review_session_id() + if sid is None: + return "No session to reset." + poker.log_ritual("reset", content=content, session_id=sid) + return "Reset logged. Clean slate β this is a new session in your head." + + def _log_hand(args: dict, ctx: dict) -> str: fields = {k: args.get(k) for k in poker._HAND_FIELDS if args.get(k) not in (None, "")} hid = poker.log_hand(**fields) @@ -129,6 +221,29 @@ def _end_session(args: dict, ctx: dict) -> str: 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: st = poker.session_stats() if not st: @@ -140,6 +255,27 @@ def _session_stats(args: dict, ctx: dict) -> str: 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: rs = poker.running_stats(stakes=args.get("stakes"), venue=args.get("venue"), game=args.get("game"), since=args.get("since")) @@ -268,6 +404,67 @@ TOOLS.update({ "add_buyin": {"handler": _add_buyin, "spec": _f( "add_buyin", "Record a rebuy / additional buy-in in the live session.", {"amount": {**_N, "description": "Amount added"}}, ["amount"])}, + "update_session": {"handler": _update_session, "spec": _f( + "update_session", + "Edit details of the current/most-recent session β during or after play. Use " + "when Brian corrects something ('change the stakes to 2/5', 'venue was actually " + "Bellagio', 'I bought in for 600', 'cashed out 1240'). Only pass fields that change.", + {"venue": {**_S, "description": "Casino/room"}, + "stakes": {**_S, "description": "e.g. '1/3', '2/5'"}, + "game": {**_S, "description": "NLH, PLO, ..."}, + "format": {**_S, "description": "cash | tournament"}, + "buy_in_total": {**_N, "description": "Total bought in"}, + "cash_out": {**_N, "description": "Final cashout (recomputes net)"}, + "mantra": {**_S, "description": "Pre-session focus/anchor"}, + "mood": {**_S, "description": "Mental-game note"}}, + [])}, + "undo_last": {"handler": _undo_last, "spec": _f( + "undo_last", + "Undo/delete the most recent logged entry in the live session when Brian says " + "'scratch that', 'delete that', 'that was wrong', etc. Specify what: 'hand', " + "'stack', 'read', 'scar', 'confidence', or 'reset'.", + {"what": {**_S, "description": "hand | stack | read | scar | confidence | reset"}}, + ["what"])}, + "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", "Log a hand in the live session. All fields optional β capture whatever Brian gives you, even terse.", @@ -304,6 +501,20 @@ TOOLS.update({ "session_stats": {"handler": _session_stats, "spec": _f( "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", "Cumulative results across closed sessions (net, $/hr, by stake). Optionally filter.", @@ -353,9 +564,16 @@ TOOLS.update({ }) -def specs() -> list[dict]: - """OpenAI-format tool definitions to offer the model.""" - return [t["spec"] for t in TOOLS.values()] +def specs(allow=None) -> list[dict]: + """OpenAI-format tool definitions to offer the model. + + `allow` (an iterable of tool names, e.g. a mode's allow-list) restricts the + set; None means every tool. Unknown names in `allow` are ignored. + """ + if allow is None: + return [t["spec"] for t in TOOLS.values()] + allow = set(allow) + return [t["spec"] for name, t in TOOLS.items() if name in allow] def dispatch(name: str, arguments, ctx: dict | None = None) -> str: diff --git a/lyra/web/gen_icons.py b/lyra/web/gen_icons.py new file mode 100644 index 0000000..6650122 --- /dev/null +++ b/lyra/web/gen_icons.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Generate Lyra PWA icons with no third-party deps (pure stdlib PNG writer). + +Design: RTO warm/low-glow β near-black field, a soft orange ambient glow, and a +luminous gold-orange ring (the "orb/portal"). iOS masks corners itself, so icons +are full-bleed squares. Run from anywhere; writes PNGs into ./static. +""" +import math +import os +import struct +import zlib + +HERE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") + +BG = (7, 7, 7) # #070707 +ORANGE = (255, 122, 0) # #ff7a00 accent +GOLD = (255, 179, 71) # #ffb347 hot core + + +def _png(width, height, rgb_rows): + def chunk(tag, data): + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + raw = bytearray() + for row in rgb_rows: + raw.append(0) # filter type 0 (None) + raw.extend(row) + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8-bit RGB + return (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + + chunk(b"IEND", b"")) + + +def render(n): + c = (n - 1) / 2.0 + sigma_glow = n * 0.30 + ring_r = n * 0.30 + ring_w = n * 0.050 + core_sigma = n * 0.11 + rows = [] + for y in range(n): + row = bytearray() + for x in range(n): + dx, dy = x - c, y - c + d = math.hypot(dx, dy) + r, g, b = BG + # ambient orange glow + glow = math.exp(-(d * d) / (2 * sigma_glow * sigma_glow)) * 0.50 + # soft hot core + core = math.exp(-(d * d) / (2 * core_sigma * core_sigma)) * 0.45 + # luminous ring + rr = d - ring_r + ring = math.exp(-(rr * rr) / (2 * ring_w * ring_w)) + r += ORANGE[0] * glow + GOLD[0] * (ring + core) + g += ORANGE[1] * glow + GOLD[1] * (ring + core) + b += ORANGE[2] * glow + GOLD[2] * (ring + core) + row += bytes((min(255, int(r)), min(255, int(g)), min(255, int(b)))) + rows.append(row) + return rows + + +def write(name, n): + rows = render(n) + with open(os.path.join(HERE, name), "wb") as f: + f.write(_png(n, n, rows)) + print(f"wrote {name} ({n}x{n})") + + +if __name__ == "__main__": + write("icon-512.png", 512) + write("icon-192.png", 192) + write("apple-touch-icon.png", 180) + write("icon-maskable-512.png", 512) diff --git a/lyra/web/server.py b/lyra/web/server.py index 655c2ac..9ad248b 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -18,7 +18,7 @@ from fastapi import FastAPI, Request, Response from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles -from lyra import chat, logbus, memory, poker, self_state, summary +from lyra import chat, logbus, memory, modes, poker, self_state, summary from lyra.llm import Backend @@ -85,6 +85,64 @@ def create_app() -> FastAPI: gist = await asyncio.to_thread(summary.summarize_session, session_id) return {"ok": gist is not None, "summary": gist} + @app.get("/modes") + async def list_modes() -> dict: + """Available conversation modes, for the UI switcher.""" + return {"modes": modes.listing(), "default": modes.DEFAULT} + + @app.get("/sessions/{session_id}/mode") + async def get_mode(session_id: str) -> dict: + return {"mode": memory.get_session_mode(session_id) or modes.DEFAULT} + + @app.post("/sessions/{session_id}/mode") + async def set_mode(session_id: str, request: Request) -> dict: + body = await request.json() + mode = body.get("mode") or modes.DEFAULT + memory.set_session_mode(session_id, mode) + logbus.log("info", "mode set", session=session_id, mode=mode) + return {"ok": True, "mode": mode} + + @app.get("/session") + async def session_hud_page() -> FileResponse: + """Live session HUD β stack, hands, villains, notes for the open session.""" + return FileResponse(str(_STATIC / "session.html")) + + @app.get("/session/data") + async def session_hud_data(id: int | None = None) -> dict: + """HUD bundle for the live session, or a specific past session via ?id=.""" + bundle = await asyncio.to_thread(poker.hud, id) + return bundle or {"session": None} + + @app.patch("/session/{session_id}") + async def session_update(session_id: int, request: Request) -> dict: + """Edit a session's details (venue/stakes/game/buy-in/cash-out/β¦).""" + body = await request.json() + s = await asyncio.to_thread(lambda: poker.update_session(session_id, **body)) + logbus.log("info", "session edited", id=session_id, fields=list(body)) + return {"ok": s is not None, "session": s} + + @app.delete("/session/entry/{kind}/{entry_id}") + async def delete_entry(kind: str, entry_id: int) -> dict: + """Delete one HUD entry (hand | stack | read | ritual) by id.""" + ok = await asyncio.to_thread(poker.delete_entry, kind, entry_id) + logbus.log("info", "hud entry deleted", kind=kind, id=entry_id, ok=ok) + return {"ok": ok} + + @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") async def chat_completions(request: Request) -> dict: body = await request.json() @@ -94,6 +152,8 @@ def create_app() -> FastAPI: model_override = body.get("model") or None memory.ensure_session(session_id) + if body.get("mode"): + memory.set_session_mode(session_id, body["mode"]) try: reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend, model_override) except Exception as exc: @@ -111,6 +171,47 @@ def create_app() -> FastAPI: ], } + @app.post("/v1/chat/stream") + async def chat_stream(request: Request) -> StreamingResponse: + """Server-Sent Events: stream Lyra's reply token-by-token. + + `chat.respond_stream` is a blocking generator (httpx/openai), so it runs in + a worker thread and bridges chunks to this async generator via a queue. + """ + body = await request.json() + session_id = body.get("sessionId") or "default" + backend = _backend_for(body.get("backend")) + user_msg = _last_user_message(body.get("messages", [])) + model_override = body.get("model") or None + memory.ensure_session(session_id) + if body.get("mode"): + memory.set_session_mode(session_id, body["mode"]) + + async def gen(): + loop = asyncio.get_running_loop() + q: asyncio.Queue = asyncio.Queue() + done = object() + + def produce(): + try: + for event in chat.respond_stream(session_id, user_msg, backend, model_override): + loop.call_soon_threadsafe(q.put_nowait, event) + except Exception as exc: # surface to the client stream, don't hang + logbus.log("error", "chat stream failed", session=session_id, error=str(exc)) + loop.call_soon_threadsafe(q.put_nowait, ("error", str(exc))) + finally: + loop.call_soon_threadsafe(q.put_nowait, done) + + loop.run_in_executor(None, produce) + while True: + item = await q.get() + if item is done: + break + ev, payload = item + yield f"data: {json.dumps({'type': ev, 'payload': payload})}\n\n" + + return StreamingResponse(gen(), media_type="text/event-stream") + @app.get("/logs") async def logs_page() -> FileResponse: """Full-page, mobile-friendly live log viewer (separate from the chat UI).""" @@ -177,6 +278,13 @@ def create_app() -> FastAPI: async def hand_data(hand_id: int) -> dict: return poker.get_hand(hand_id) or {} + @app.post("/hand/{hand_id}/reconstruct") + async def hand_reconstruct(hand_id: int) -> dict: + """Parse a flat (quick-logged) hand's narrative into a replayable structure.""" + out = await asyncio.to_thread(poker.reconstruct_hand, hand_id) + logbus.log("info", "hand reconstructed", id=hand_id, ok=out is not None) + return {"ok": out is not None} + @app.get("/hands") async def hands_page() -> FileResponse: return FileResponse(str(_STATIC / "hands.html")) diff --git a/lyra/web/static/apple-touch-icon.png b/lyra/web/static/apple-touch-icon.png new file mode 100644 index 0000000..2a53470 Binary files /dev/null and b/lyra/web/static/apple-touch-icon.png differ diff --git a/lyra/web/static/hand.html b/lyra/web/static/hand.html index 7c5f1df..0b475e7 100644 --- a/lyra/web/static/hand.html +++ b/lyra/web/static/hand.html @@ -95,11 +95,50 @@ return `${r}${SUIT[s]}`; } const cards = (arr, sm) => (arr||[]).map(c=>cardEl(c,sm)).join(''); + // Split a loose card string ("KhQh", "Qh Qc", "Tc 8s Js 6d", "Ax") into codes. + const parseCards = s => (String(s||'').match(/(10|[2-9TJQKA])[shdcx]/gi) || []); + + // Flat (quick-logged) hands have no structured replay β show a readable static + // view of everything that WAS captured, plus an on-demand "build replay". + function renderFlat(h){ + document.getElementById('sub').textContent = h.position || ''; + const hole = parseCards(h.hole_cards), board = parseCards(h.board); + const streets = [['Preflop',h.preflop],['Flop',h.flop],['Turn',h.turn],['River',h.river],['Showdown',h.showdown]] + .filter(x=>x[1]); + const canBuild = streets.length > 0; + document.getElementById('root').innerHTML = ` +
+ ${canBuild?'Quick-logged hand (static). Build replay to reconstruct a step-through.':'Quick-logged hand β limited detail captured.'}
`; + const b = document.getElementById('build'); + if(b) b.onclick = async () => { + b.disabled = true; b.textContent = 'β¦ building'; + try{ + const r = await fetch(`/hand/${h.id}/reconstruct`,{method:'POST'}); + const d = await r.json(); + if(d.ok) location.reload(); else { b.disabled=false; b.textContent='βΆ Build replay'; alert("Couldn't reconstruct this one."); } + }catch(e){ b.disabled=false; b.textContent='βΆ Build replay'; alert('Failed: '+e.message); } + }; + } function render(h){ const sub = document.getElementById('sub'); const data = h.structured; - if(!data){ document.getElementById('root').innerHTML = 'This hand has no structured data to replay.
'; return; } + const hasReplay = data && (((data.players||[]).length) || ((data.actions||[]).length)); + if(!hasReplay){ renderFlat(h); return; } const players = (data.players||[]).slice(); // order so hero sits at the bottom @@ -247,5 +286,6 @@ } load(); +