13 Commits

Author SHA1 Message Date
serversdown 5c41bd48d1 fix: consolidation no longer stalls or breaks the live chat turn
Two bugs surfacing in the log during live play:
- SUMMARY_BACKEND=mi50 (llama.cpp, 32B) was fed 24k-char chunks → "Context size
  has been exceeded". Chunk budget is now backend-aware: cloud 24k, local/mi50 8k,
  and the merge step recurses so merged partials never overflow either.
- maybe_summarize ran inline in the chat turn and retried 4× with backoff (~30s),
  stalling the reply and surfacing the error. It now runs in a background daemon
  thread, swallows errors (consolidation is best-effort maintenance), and dedupes
  so at most one summary per session runs at a time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 04:37:17 +00:00
serversdown 5e9f3efeec fix(web): copy button actually copies on iOS
The execCommand fallback returned true but copied nothing because the textarea
was readOnly=false. iOS only copies from a readOnly + contentEditable field with
a real Range selection + setSelectionRange — fixed that. Also skip the async
Clipboard API unless window.isSecureContext (on the plain-HTTP LAN PWA it could
resolve without copying, showing a false checkmark). If programmatic copy still
fails, fall back to a prompt() with the text so it can be copied by hand, and only
show the ✓ on real success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:46:33 +00:00
serversdown 654a7531e8 feat(web): multiline composer — Enter adds a newline, arrow sends
Stops fat-fingered early sends. The single-line input is now an auto-growing
textarea (Claude-app style): Enter inserts a newline and expands the box (up to a
cap, then it scrolls); you tap the ↑ arrow to send. ⌘/Ctrl+Enter still sends from
a hardware keyboard. Send button is now a round arrow; box resets to one line
after sending. Mobile button is a 42-44px touch target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:20:50 +00:00
serversdown cebb87205c feat(web): tap-to-copy button on every chat message
Each user and assistant message gets a copy button (⧉ → ✓) that puts the whole
message on the clipboard. Assistant copies the raw markdown (its dataset.raw);
user copies its text.

- copyToClipboard uses the async Clipboard API when available and falls back to a
  hidden-textarea + execCommand with an explicit iOS selection range, so it works
  on the iPhone PWA over plain-HTTP LAN (no secure context).
- Copy sits in the assistant rate-bar and in a right-aligned bar on user bubbles;
  tools stay visible on touch (@media hover:none) since there's no hover on iOS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 01:09:27 +00:00
serversdown e1e89c07e4 feat: poker session history — browse, delete, and Lyra lookup
Answers three gaps: no way to delete a single poker session (only clear_all),
no way to browse past sessions, and Lyra could only see aggregate stats.

- poker.list_sessions() (per-session summary + hand count + recap flag) and
  poker.delete_session() (removes a session + its hands/reads/observations/
  stacks/rituals; keeps the persistent villain file).
- /history page (date, stakes, venue, net, hours, recap link, per-row delete with
  confirm) + /history/data + DELETE /history/{id}. Nav links from chat + HUD.
- recent_sessions read tool, added to the shared lookups so Lyra can answer
  "how'd my last few sessions go?" in either mode.
- Delete is UI/CLI only — deliberately not a Lyra tool.
- test_modes.py +2 (list/delete, recent_sessions); 44 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:21:48 +00:00
serversdown 35c973df05 feat: session_state read tool so she can see the HUD
She could write everything the HUD shows but not read most of it back (stack,
live net, alligator state, scar/confidence entries) — so "what's my live net?"
or "what's in my confidence bank?" was a memory guess.

- session_state tool returns the same bundle the HUD renders, as a readable
  summary; added to the Cash toolset.
- Cash card tells her the HUD exists, that she and it share the same data, and to
  answer where-am-I questions from session_state, never memory.
- test_modes.py +1; 42 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:16:40 +00:00
serversdown 974ee33f71 feat: live mental-game rituals in Cash mode
Brian's own rituals (mined from his logs) become first-class, live tools instead
of post-hoc recap sections:

- Scar Note — instructive mistakes with the punt/cooler/standard distinction.
- Confidence Bank — good process, banked regardless of result.
- Alligator Blood — invokable adversity state; she suggests it when he's
  card-dead/short/stuck, and her coaching register shifts while it's on (live
  state injected into context per-turn via chat._mode_state_note).
- Reset — tilt circuit-breaker; mental marker only, stats stay continuous.

poker_rituals table + log_ritual/list_rituals/set_alligator/alligator_active;
4 tools added to the Cash toolset and taught in the mode card; HUD gains a 🐊
banner + Confidence Bank + Scar Notes panels; recap grounded via _rituals_block.
tests/test_modes.py +5 ritual tests; 41 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 06:24:28 +00:00
serversdown dfb6425395 feat: session modes (Talk/Cash) + live session HUD
Lyra now switches register based on what she's doing at the table instead of
being a wishy-washy companion mid-session.

Modes (lyra/modes.py):
- Talk (default companion) + Cash (live cash copilot); a mode = prompt card +
  tool allow-list. Tool gating via tools.specs(allow=).
- Two-register Cash voice: act-first one-line logging when fed facts; full warm
  companion voice for strategy / tilt / mental game.
- mode persisted per chat session (new sessions.mode column); auto-switch into
  Cash when start_session fires; UI forces cloud backend in Cash (tools only
  fire there).

Stack tracking + HUD:
- log_stack tool + poker_stack_log table; live net while sitting (stack - buy-in).
- poker.hud() bundle; /session HUD page (stack sparkline, hands, villains, notes,
  stats) polling /session/data every 5s; Talk/Cash switcher + Session nav.

Endpoints: /session, /session/data, GET/POST /sessions/{id}/mode, /modes.
tests/test_modes.py (gating, mode roundtrip, stack/HUD); 36 tests green. v0.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 05:28:15 +00:00
serversdown d9f5055ec1 chore: sync uv.lock to version 0.2.0
Lockfile caught up to the pyproject version bump from 1f5a321 (it wasn't
regenerated at the time). No dependency changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 05:11:50 +00:00
serversdown 50f460eeb2 feat(web): bottom tab bar navigation (M4)
- Mobile bottom tab bar: Chat · Hands · Mind · More. "More" opens the drawer
  for the long tail (Journal, Log, Settings, sessions); hamburger retired.
- Auto-hides while the keyboard is open (body.kb) so the input pins to the
  keyboard; mobile-only (desktop keeps its header nav).
- Removed now-redundant Mind/Hands from the drawer + their listeners.
- Bottom-fill fix: #chat uses 100dvh (the visible viewport) — 100vh/inset:0
  reach into the home-indicator zone iOS won't comfortably show, clipping the
  bar; dvh/svh exclude it. Tab bar is flex:none with a small fixed bottom
  padding (safe-area padding double-counts at dvh height), and the body bg
  matches the bar so any strip below #chat is seamless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 04:36:08 +00:00
serversdown 5dc3fa17d7 feat(web): stream chat replies token-by-token (M3)
- llm.chat_call_stream: streaming generator for all 3 backends (Ollama NDJSON,
  OpenAI/MI50 SSE), accumulating tool-call fragments by index.
- chat.respond_stream: mirrors respond()'s tool loop and persistence/compaction,
  yielding ("delta", text) / ("tool", name) / ("done", reply).
- POST /v1/chat/stream: SSE endpoint; blocking generator bridged to async via a
  worker thread + asyncio.Queue. Old completions endpoint kept as fallback.
- Client streams into a live bubble with a blinking caret; rAF-throttled render
  (no full re-parse per token) and instant scroll during stream — fixes iOS
  Safari ghosting from per-token smooth-scroll. Falls back to the blocking
  endpoint only if nothing streamed (no double-persist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:06:51 +00:00
serversdown fa168271e1 feat(web): iPhone PWA fixes (M1) + warm RTO redesign (M2)
M1 — PWA mechanics:
- Generate real app icons (apple-touch-icon + manifest 192/512/maskable)
  via pure-stdlib gen_icons.py; iOS uses apple-touch-icon, not manifest icons.
- viewport-fit=cover + env(safe-area-inset-*) on header/input/menu so content
  clears the notch and home indicator.
- Dynamic height pinned to the VisualViewport (height + offsetTop, re-measured
  across the keyboard animation) so the input stays above the iOS keyboard;
  100dvh fallback. Kills the squish/gap bugs in standalone mode.
- overscroll containment; flesh out manifest (scope, portrait, maskable).

M2 — visual redesign:
- Realign style.css to the warm low-glow RTO palette already used by the
  standalone pages (#0e0e0e panels, #2a1d12 borders); remove the neon
  saturated-orange borders and ~15 glow shadows.
- Reserve filled accent for one element (Send); glow only on status pulse +
  input focus. Flat warm message bubbles with tail corners.
- Reclaim the mobile header into [≡] Lyra · [status dot]; drop the redundant
  status bar (relay status now the header dot, updated in checkHealth).
- prefers-reduced-motion support; fix undefined var(--text); real light-mode tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:20:11 +00:00
serversdown e75c4390b5 chore: add /import/ to gitignore 2026-06-18 19:38:55 +00:00
24 changed files with 2325 additions and 197 deletions
+2 -1
View File
@@ -35,4 +35,5 @@ data/
#lyra Stuff #lyra Stuff
/core/relay/sessions/ /core/relay/sessions/
/chat-gpt-export/ /chat-gpt-export/
/import/
+40
View File
@@ -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
+18 -3
View File
@@ -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).
+106 -7
View File
@@ -10,7 +10,7 @@ After replying, the session is compacted if enough new turns have accumulated.
""" """
from __future__ import annotations from __future__ import annotations
from lyra import clock, config, llm, logbus, memory, persona, self_state, summary from lyra import clock, config, llm, logbus, memory, modes, persona, self_state, summary
from lyra import tools as toolkit from lyra import tools as toolkit
from lyra.llm import Backend, Message from lyra.llm import Backend, Message
@@ -24,6 +24,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,5 +200,64 @@ 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
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)
+84 -1
View File
@@ -1,7 +1,8 @@
"""LLM router: local (Ollama) chat, cloud (OpenAI) chat + embeddings.""" """LLM router: local (Ollama) chat, cloud (OpenAI) chat + embeddings."""
from __future__ import annotations from __future__ import annotations
from typing import Literal, TypedDict import json
from typing import Iterator, Literal, TypedDict
import httpx import httpx
from openai import OpenAI from openai import OpenAI
@@ -80,6 +81,88 @@ def chat_call(
return {"role": "assistant", "content": complete(messages, backend=backend, model=model)}, None 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]]: def embed(texts: list[str]) -> list[list[float]]:
"""Embed texts using the configured backend (EMBED_BACKEND: "cloud" or "local"). """Embed texts using the configured backend (EMBED_BACKEND: "cloud" or "local").
+22
View File
@@ -32,6 +32,7 @@ CREATE INDEX IF NOT EXISTS idx_session_created ON exchanges(session_id, created_
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT, name TEXT,
mode TEXT, -- conversation mode (see lyra/modes.py); NULL = default
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@@ -131,6 +132,12 @@ def _connection() -> sqlite3.Connection:
_conn.execute("PRAGMA busy_timeout=5000") _conn.execute("PRAGMA busy_timeout=5000")
_conn.execute("PRAGMA journal_mode=WAL") _conn.execute("PRAGMA journal_mode=WAL")
_conn.executescript(SCHEMA) _conn.executescript(SCHEMA)
# Migrations for DBs created before a column existed (no-op if present).
for ddl in ("ALTER TABLE sessions ADD COLUMN mode TEXT",):
try:
_conn.execute(ddl)
except sqlite3.OperationalError:
pass
_conn_path = cfg.db_path _conn_path = cfg.db_path
return _conn return _conn
@@ -236,6 +243,21 @@ def ensure_session(session_id: str, name: str | None = None) -> None:
conn.execute("UPDATE sessions SET name = ? WHERE id = ?", (name, session_id)) conn.execute("UPDATE sessions SET name = ? WHERE id = ?", (name, session_id))
def get_session_mode(session_id: str) -> str | None:
"""The session's conversation mode key, or None if unset (caller applies default)."""
conn = _connection()
r = conn.execute("SELECT mode FROM sessions WHERE id = ?", (session_id,)).fetchone()
return r["mode"] if r and r["mode"] else None
def set_session_mode(session_id: str, mode: str) -> None:
"""Persist the session's conversation mode (creating the session row if needed)."""
ensure_session(session_id)
conn = _connection()
with conn:
conn.execute("UPDATE sessions SET mode = ? WHERE id = ?", (mode, session_id))
def list_sessions() -> list[dict]: def list_sessions() -> list[dict]:
"""All known sessions (named rows + any session that has exchanges), newest first.""" """All known sessions (named rows + any session that has exchanges), newest first."""
conn = _connection() conn = _connection()
+126
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+75
View File
@@ -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)
+87 -1
View File
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from lyra import chat, logbus, memory, poker, self_state, summary from lyra import chat, logbus, memory, modes, poker, self_state, summary
from lyra.llm import Backend from lyra.llm import Backend
@@ -85,6 +85,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:
@@ -111,6 +156,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") @app.get("/logs")
async def logs_page() -> FileResponse: async def logs_page() -> FileResponse:
"""Full-page, mobile-friendly live log viewer (separate from the chat UI).""" """Full-page, mobile-friendly live log viewer (separate from the chat UI)."""
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+104
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+313 -33
View File
@@ -5,10 +5,14 @@
<title>Lyra Core Chat</title> <title>Lyra Core Chat</title>
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
<!-- PWA --> <!-- PWA -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Lyra" />
<meta name="theme-color" content="#070707" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="icon" type="image/png" href="icon-192.png" />
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
</head> </head>
@@ -21,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>
@@ -35,11 +39,11 @@
<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="mobileMindBtn">🧠 Read Her Mind</button>
<button id="mobileJournalBtn">📔 Journal</button> <button id="mobileJournalBtn">📔 Journal</button>
<button id="mobileHandsBtn">🃏 Hands</button>
<button id="mobileSettingsBtn">⚙ Settings</button> <button id="mobileSettingsBtn">⚙ Settings</button>
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button> <button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
<button id="mobileForceReloadBtn">🔄 Force Reload</button> <button id="mobileForceReloadBtn">🔄 Force Reload</button>
@@ -55,10 +59,13 @@
<span></span> <span></span>
<span></span> <span></span>
</button> </button>
<span class="brand">Lyra</span>
<span class="brand-dot" id="brandDot" title="Relay status"></span>
<button class="mode-badge" id="modeBadge" type="button" title="Tap to toggle Talk / Cash mode">💬 Talk</button>
<label for="mode">Mode:</label> <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">
@@ -74,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>
@@ -107,9 +116,18 @@
<!-- 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) -->
<nav id="tabbar" aria-label="Primary navigation">
<a class="tab active" href="/" aria-current="page"><span class="ti">💬</span><span class="tl">Chat</span></a>
<a class="tab" href="/session"><span class="ti">🎬</span><span class="tl">Session</span></a>
<a class="tab" href="/hands"><span class="ti">🃏</span><span class="tl">Hands</span></a>
<a class="tab" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
<button class="tab" id="moreTab" type="button"><span class="ti"></span><span class="tl">More</span></button>
</nav>
</div> </div>
<!-- Settings Modal (outside chat container) --> <!-- Settings Modal (outside chat container) -->
@@ -174,6 +192,7 @@
<script> <script>
const RELAY_BASE = ""; // same-origin: served by lyra.web.server const RELAY_BASE = ""; // same-origin: served by lyra.web.server
const API_URL = `${RELAY_BASE}/v1/chat/completions`; const API_URL = `${RELAY_BASE}/v1/chat/completions`;
const STREAM_URL = `${RELAY_BASE}/v1/chat/stream`;
function generateSessionId() { function generateSessionId() {
return "sess-" + Math.random().toString(36).substring(2, 10); return "sess-" + Math.random().toString(36).substring(2, 10);
@@ -268,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
@@ -285,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,
@@ -302,21 +327,107 @@
body.model = cloudModel; body.model = cloudModel;
} }
// Stream the reply token-by-token (SSE). Fall back to the blocking
// endpoint only if nothing streamed (e.g. streaming unavailable).
const div = createAssistantBubble();
let full = "";
try { try {
const resp = await fetch(API_URL, { const resp = await fetch(STREAM_URL, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
if (!resp.ok || !resp.body) throw new Error("HTTP " + resp.status);
const data = await resp.json(); const reader = resp.body.getReader();
const reply = data.choices?.[0]?.message?.content || "(no reply)"; const decoder = new TextDecoder();
addMessage("assistant", reply); let buf = "";
history.push({ role: "assistant", content: reply }); for (;;) {
await saveSession(); const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) !== -1) {
const frame = buf.slice(0, i).trim();
buf = buf.slice(i + 2);
if (!frame.startsWith("data:")) continue;
let evt;
try { evt = JSON.parse(frame.slice(5).trim()); } catch (e) { continue; }
if (evt.type === "delta") {
full += evt.payload;
updateAssistantBubble(div, full);
} else if (evt.type === "done") {
if (evt.payload) full = evt.payload;
} else if (evt.type === "error") {
throw new Error(evt.payload);
}
}
}
} catch (err) { } catch (err) {
addMessage("system", "Error: " + err.message); if (!full) {
div.remove();
try {
const resp = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const data = await resp.json();
const reply = data.choices?.[0]?.message?.content || "(no reply)";
addMessage("assistant", reply);
history.push({ role: "assistant", content: reply });
await saveSession();
} catch (err2) {
addMessage("system", "Error: " + err2.message);
}
return;
}
// Partial content arrived before the error — keep what we streamed.
} }
finalizeAssistantBubble(div, full || "(no reply)");
history.push({ role: "assistant", content: full || "(no reply)" });
await saveSession();
// If she opened a session this turn, the server auto-flips to Cash mode —
// reflect that here so the badge/HUD follow without a manual switch.
if (document.getElementById("mode").value !== "poker_cash") {
loadModeFor(currentSession);
}
}
function createAssistantBubble() {
const messagesEl = document.getElementById("messages");
const div = document.createElement("div");
div.className = "msg assistant streaming";
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight; // instant — no smooth chasing
return div;
}
// Coalesce token updates to one render per animation frame (avoids re-parsing
// the whole message on every token, and the iOS ghosting from rapid repaints).
function updateAssistantBubble(div, text) {
div._pending = text;
if (div._raf) return;
div._raf = requestAnimationFrame(() => {
div._raf = 0;
const messagesEl = document.getElementById("messages");
const stick = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 90;
div.innerHTML = renderMarkdown(div._pending);
div.dataset.raw = div._pending;
if (stick) messagesEl.scrollTop = messagesEl.scrollHeight; // follow only if near bottom
});
}
function finalizeAssistantBubble(div, text) {
if (div._raf) { cancelAnimationFrame(div._raf); div._raf = 0; } // drop any queued render
div.classList.remove("streaming");
div.innerHTML = renderMarkdown(text);
div.dataset.raw = text;
addRateBar(div);
const messagesEl = document.getElementById("messages");
requestAnimationFrame(() => messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: "smooth" }));
} }
function renderMarkdown(text) { function renderMarkdown(text) {
@@ -364,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);
} }
@@ -382,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");
@@ -393,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);
@@ -406,22 +598,102 @@
} }
// ----- 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"));
if (resp.ok) { if (resp.ok) {
document.getElementById("status-dot").className = "dot ok"; document.getElementById("status-dot").className = "dot ok";
document.getElementById("status-text").textContent = "Relay Online"; document.getElementById("status-text").textContent = "Relay Online";
document.getElementById("brandDot").className = "brand-dot ok";
} else { } else {
throw new Error("Bad status"); throw new Error("Bad status");
} }
} catch (err) { } catch (err) {
document.getElementById("status-dot").className = "dot fail"; document.getElementById("status-dot").className = "dot fail";
document.getElementById("status-text").textContent = "Relay Offline"; document.getElementById("status-text").textContent = "Relay Offline";
document.getElementById("brandDot").className = "brand-dot fail";
} }
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
// --- PWA: track the *visible* viewport height so the layout follows the
// iOS keyboard and the dynamic Safari toolbars (keeps the input bar visible
// instead of hiding behind the keyboard). Falls back to 100dvh via CSS.
function setAppHeight() {
const vv = window.visualViewport;
const h = (vv && vv.height) || window.innerHeight;
const off = (vv && vv.offsetTop) || 0;
const root = document.documentElement.style;
root.setProperty("--app-height", h + "px");
// iOS pans the visual viewport when the keyboard opens; follow its top
// edge so the pinned #chat sits exactly in the visible area.
root.setProperty("--app-offset", off + "px");
// Keyboard open ⇒ hide the bottom tab bar so the input pins to the keyboard.
document.body.classList.toggle("kb", (window.innerHeight - h) > 150);
}
// Re-measure across the keyboard animation: iOS reports a stale (too-short)
// height mid-animation, so sample a few times until it settles.
function nudgeAppHeight() {
setAppHeight();
[50, 150, 300, 550].forEach((t) => setTimeout(setAppHeight, t));
}
setAppHeight();
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", nudgeAppHeight);
window.visualViewport.addEventListener("scroll", setAppHeight);
}
window.addEventListener("resize", nudgeAppHeight);
window.addEventListener("orientationchange", nudgeAppHeight);
// Keep the latest message in view when the keyboard opens/closes.
const userInputEl = document.getElementById("userInput");
userInputEl.addEventListener("focus", () => {
nudgeAppHeight();
setTimeout(() => {
const m = document.getElementById("messages");
m.scrollTo({ top: m.scrollHeight, behavior: "smooth" });
}, 350);
});
userInputEl.addEventListener("blur", nudgeAppHeight);
// Mobile Menu Toggle // Mobile Menu Toggle
const hamburgerMenu = document.getElementById("hamburgerMenu"); const hamburgerMenu = document.getElementById("hamburgerMenu");
const mobileMenu = document.getElementById("mobileMenu"); const mobileMenu = document.getElementById("mobileMenu");
@@ -441,20 +713,22 @@
hamburgerMenu.addEventListener("click", toggleMobileMenu); hamburgerMenu.addEventListener("click", toggleMobileMenu);
mobileMenuOverlay.addEventListener("click", closeMobileMenu); mobileMenuOverlay.addEventListener("click", closeMobileMenu);
document.getElementById("moreTab").addEventListener("click", toggleMobileMenu);
// Sync mobile menu controls with desktop // Mode controls (Talk / Cash): the desktop select, the mobile-menu select,
// and the always-visible header badge all funnel through chooseMode.
const mobileMode = document.getElementById("mobileMode"); const 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", () => {
@@ -564,6 +838,7 @@
// Load current session history // Load current session history
if (currentSession) { if (currentSession) {
await loadSession(currentSession); await loadSession(currentSession);
await loadModeFor(currentSession);
} }
})(); })();
@@ -574,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
@@ -748,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");
@@ -884,14 +1164,14 @@
document.getElementById("mobileFullLogBtn").addEventListener("click", () => { document.getElementById("mobileFullLogBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/logs"; closeMobileMenu(); window.location.href = "/logs";
}); });
document.getElementById("mobileMindBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/self";
});
document.getElementById("mobileJournalBtn").addEventListener("click", () => { document.getElementById("mobileJournalBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/journal"; closeMobileMenu(); window.location.href = "/journal";
}); });
document.getElementById("mobileHandsBtn").addEventListener("click", () => { document.getElementById("mobileSessionBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/hands"; 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.
+33 -20
View File
@@ -1,20 +1,33 @@
{ {
"name": "Lyra Chat", "name": "Lyra",
"short_name": "Lyra", "short_name": "Lyra",
"start_url": "./index.html", "description": "Lyra — chat, mind, journal, and poker copilot.",
"display": "standalone", "start_url": "./index.html",
"background_color": "#181818", "scope": "./",
"theme_color": "#181818", "display": "standalone",
"icons": [ "display_override": ["standalone", "minimal-ui"],
{ "orientation": "portrait",
"src": "icon-192.png", "background_color": "#070707",
"sizes": "192x192", "theme_color": "#070707",
"type": "image/png" "categories": ["productivity", "utilities"],
}, "icons": [
{ {
"src": "icon-512.png", "src": "icon-192.png",
"sizes": "512x512", "sizes": "192x192",
"type": "image/png" "type": "image/png",
} "purpose": "any"
] },
} {
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+275
View File
@@ -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>
+343 -121
View File
@@ -1,31 +1,61 @@
:root { :root {
--bg-dark: #070707; --bg-dark: #070707;
--bg-panel: rgba(255, 122, 0, 0.1); --bg-elev: #0e0e0e;
--bg-line: #141414;
--bg-panel: #0e0e0e;
--border: #2a1d12;
--border-bright: #4a2f15;
--accent: #ff7a00; --accent: #ff7a00;
--accent-glow: 0 0 6px rgba(255,122,0,0.28); --gold: #ffb347;
--good: #8fd694;
--bad: #ff5a5a;
--accent-soft: rgba(255, 122, 0, 0.10);
--accent-glow: 0 0 6px rgba(255, 122, 0, 0.18);
--text-main: #e8e8e8; --text-main: #e8e8e8;
--text-fade: #999; --text-fade: #8a8a8a;
--font-console: "IBM Plex Mono", monospace; --font-console: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--font-voice: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
/* Light mode variables */ /* Light mode (secondary — Brian runs dark) */
body { body {
--bg-dark: #f5f5f5; --bg-dark: #f5f3ef;
--bg-panel: rgba(255, 122, 0, 0.05); --bg-elev: #ffffff;
--accent: #ff7a00; --bg-line: #ece8e1;
--accent-glow: 0 0 6px rgba(255,122,0,0.28); --bg-panel: #ffffff;
--border: #e2dacb;
--border-bright: #c9a87a;
--accent: #c75e00;
--gold: #b8791f;
--good: #3f9a52;
--bad: #c0392b;
--accent-soft: rgba(199, 94, 0, 0.08);
--accent-glow: none;
--text-main: #1a1a1a; --text-main: #1a1a1a;
--text-fade: #666; --text-fade: #6a6a6a;
--text: var(--text-main); /* alias: some rules reference var(--text) */
} }
/* Dark mode variables */ /* Dark mode (primary — RTO warm low-glow) */
body.dark { body.dark {
--bg-dark: #070707; --bg-dark: #070707;
--bg-panel: rgba(255, 122, 0, 0.1); --bg-elev: #0e0e0e;
--bg-line: #141414;
--bg-panel: #0e0e0e;
--border: #2a1d12;
--border-bright: #4a2f15;
--accent: #ff7a00; --accent: #ff7a00;
--accent-glow: 0 0 6px rgba(255,122,0,0.28); --gold: #ffb347;
--good: #8fd694;
--bad: #ff5a5a;
--accent-soft: rgba(255, 122, 0, 0.10);
--accent-glow: 0 0 6px rgba(255, 122, 0, 0.18);
--text-main: #e8e8e8; --text-main: #e8e8e8;
--text-fade: #999; --text-fade: #8a8a8a;
}
html {
overscroll-behavior: none;
} }
body { body {
@@ -33,10 +63,13 @@ body {
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text-main); color: var(--text-main);
font-family: var(--font-console); font-family: var(--font-console);
height: 100vh; height: 100vh; /* fallback for old browsers */
height: 100dvh;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
overscroll-behavior: none;
-webkit-tap-highlight-color: transparent;
} }
#chat { #chat {
@@ -45,9 +78,9 @@ body {
height: 95vh; height: 95vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 1px solid var(--accent); border: 1px solid var(--border);
border-radius: 10px; border-radius: 12px;
box-shadow: var(--accent-glow); box-shadow: none;
background: var(--bg-dark); background: var(--bg-dark);
overflow: hidden; overflow: hidden;
} }
@@ -58,109 +91,176 @@ body {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 12px; padding: 8px 12px;
border-bottom: 1px solid var(--accent); border-bottom: 1px solid var(--border);
background-color: rgba(255, 122, 0, 0.05); background-color: var(--bg-elev);
} }
#status { #status {
justify-content: flex-start; justify-content: flex-start;
border-top: 1px solid var(--accent); 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;
color: var(--text-main); color: var(--text-main);
background: transparent; background: var(--bg-line);
border: 1px solid var(--accent); border: 1px solid var(--border);
border-radius: 4px; border-radius: 6px;
padding: 4px 8px; padding: 5px 9px;
transition: border-color .15s, background-color .15s;
} }
label { background: transparent; border-color: transparent; padding-left: 0; }
button:hover, select:hover { button:hover, select:hover {
box-shadow: 0 0 8px var(--accent); border-color: var(--border-bright);
background: var(--accent-soft);
cursor: pointer; cursor: pointer;
} }
#thinkingStreamBtn { #thinkingStreamBtn {
background: rgba(255, 179, 71, 0.2); background: var(--bg-line);
border-color: #ffb347; border-color: var(--border-bright);
color: var(--gold);
} }
#thinkingStreamBtn:hover { #thinkingStreamBtn:hover {
box-shadow: 0 0 8px #ffb347; background: var(--accent-soft);
background: rgba(255, 179, 71, 0.3); border-color: var(--gold);
} }
/* Chat area */ /* Chat area */
#messages { #messages {
flex: 1; flex: 1;
min-height: 0;
padding: 16px; padding: 16px;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
scroll-behavior: smooth; /* No CSS smooth-scroll: during streaming, per-token smooth scrolls pile up and
iOS Safari leaves ghost paint frames. Smooth is applied explicitly in JS where
it's a one-shot (load/finalize). */
} }
/* Messages */ /* Messages */
.msg { .msg {
max-width: 80%; max-width: 80%;
padding: 10px 14px; padding: 10px 14px;
border-radius: 8px; border-radius: 12px;
line-height: 1.4; line-height: 1.4;
word-wrap: break-word; word-wrap: break-word;
box-shadow: 0 0 8px rgba(255,122,0,0.2); box-shadow: none;
} }
.msg.user { .msg.user {
align-self: flex-end; align-self: flex-end;
background: rgba(255,122,0,0.15); background: var(--accent-soft);
border: 1px solid var(--accent); border: 1px solid var(--border-bright);
border-bottom-right-radius: 4px;
} }
.msg.assistant { .msg.assistant {
align-self: flex-start; align-self: flex-start;
background: rgba(255,122,0,0.08); background: var(--bg-elev);
border: 1px solid rgba(255,122,0,0.5); border: 1px solid var(--border);
border-bottom-left-radius: 4px;
} }
.msg.system { .msg.system {
align-self: center; align-self: center;
font-size: 0.8rem; font-size: 0.78rem;
color: var(--text-fade); color: var(--text-fade);
text-align: center;
padding: 4px 10px;
} }
/* Input bar */ /* Input bar */
#input { #input {
display: flex; display: flex;
border-top: 1px solid var(--accent); align-items: flex-end; /* arrow stays at the bottom as the textarea grows */
background: rgba(255, 122, 0, 0.05); border-top: 1px solid var(--border);
background: var(--bg-elev);
padding: 10px; padding: 10px;
} }
#userInput { #userInput {
flex: 1; flex: 1;
background: transparent; background: var(--bg-line);
color: var(--text-main); color: var(--text-main);
border: 1px solid var(--accent); border: 1px solid var(--border);
border-radius: 4px; border-radius: 16px;
padding: 8px; padding: 9px 12px;
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;
}
#userInput::placeholder { color: var(--text-fade); }
#userInput:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--accent-glow);
} }
#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);
color: #0a0a0a;
border-color: var(--accent);
font-weight: 600;
} }
#sendBtn:hover { background: var(--gold); border-color: var(--gold); }
#sendBtn:disabled { opacity: .45; background: var(--bg-line); color: var(--text-fade); border-color: var(--border); }
/* Relay status dot */ /* Relay status dot */
#status { #status {
display: flex; display: flex;
align-items: center; align-items: center;
margin: 10px 0;
gap: 8px; gap: 8px;
font-family: monospace; font-family: var(--font-console);
color: #f5f5f5; font-size: 0.82rem;
color: var(--text-fade);
} }
#status-dot { #status-dot {
width: 10px; width: 9px;
height: 10px; height: 9px;
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
background: var(--text-fade);
} }
@keyframes pulseGreen { @keyframes pulseGreen {
@@ -170,29 +270,29 @@ button:hover, select:hover {
} }
.dot.ok { .dot.ok {
background: #8fd694; background: var(--good);
animation: pulseGreen 2s infinite ease-in-out; animation: pulseGreen 2s infinite ease-in-out;
} }
/* Offline state stays solid red */ /* Offline state stays solid red */
.dot.fail { .dot.fail {
background: #ff3333; background: var(--bad);
box-shadow: 0 0 10px #ff3333; box-shadow: 0 0 8px rgba(255, 90, 90, 0.5);
} }
/* Dropdown (session selector) styling */ /* Dropdown (session selector) styling */
select { select {
background-color: var(--bg-dark); background-color: var(--bg-line);
color: var(--text-main); color: var(--text-main);
border: 1px solid #b84a12; border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
padding: 4px 6px; padding: 5px 8px;
font-size: 14px; font-size: 14px;
} }
select option { select option {
background-color: var(--bg-dark); background-color: var(--bg-elev);
color: var(--text-main); color: var(--text-main);
} }
@@ -200,8 +300,8 @@ select option {
select:focus, select:focus,
select:hover { select:hover {
outline: none; outline: none;
border-color: #ff8a00; border-color: var(--accent);
background-color: var(--bg-panel); background-color: var(--bg-line);
} }
/* Settings Modal */ /* Settings Modal */
@@ -235,10 +335,10 @@ select:hover {
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: linear-gradient(180deg, rgba(255,122,0,0.1) 0%, rgba(10,10,10,0.95) 100%); background: var(--bg-elev);
border: 2px solid var(--accent); border: 1px solid var(--border);
border-radius: 12px; border-radius: 14px;
box-shadow: var(--accent-glow); box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
min-width: 400px; min-width: 400px;
max-width: 600px; max-width: 600px;
max-height: 80vh; max-height: 80vh;
@@ -251,8 +351,8 @@ select:hover {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 16px 20px; padding: 16px 20px;
border-bottom: 1px solid var(--accent); border-bottom: 1px solid var(--border);
background: rgba(255,122,0,0.1); background: var(--bg-line);
} }
.modal-header h3 { .modal-header h3 {
@@ -277,8 +377,8 @@ select:hover {
} }
.close-btn:hover { .close-btn:hover {
background: rgba(255,122,0,0.2); background: var(--accent-soft);
box-shadow: 0 0 8px var(--accent); color: var(--accent);
} }
.modal-body { .modal-body {
@@ -307,17 +407,16 @@ select:hover {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 12px; padding: 12px;
border: 1px solid rgba(255,122,0,0.3); border: 1px solid var(--border);
border-radius: 6px; border-radius: 8px;
background: rgba(255,122,0,0.05); background: var(--bg-line);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: border-color 0.15s, background-color 0.15s;
} }
.radio-label:hover { .radio-label:hover {
border-color: var(--accent); border-color: var(--border-bright);
background: rgba(255,122,0,0.1); background: var(--accent-soft);
box-shadow: 0 0 8px rgba(255,122,0,0.3);
} }
.radio-label input[type="radio"] { .radio-label input[type="radio"] {
@@ -358,19 +457,20 @@ select:hover {
justify-content: flex-end; justify-content: flex-end;
gap: 10px; gap: 10px;
padding: 16px 20px; padding: 16px 20px;
border-top: 1px solid var(--accent); border-top: 1px solid var(--border);
background: rgba(255,122,0,0.05); background: var(--bg-line);
} }
.primary-btn { .primary-btn {
background: var(--accent); background: var(--accent);
color: #000; color: #0a0a0a;
font-weight: bold; font-weight: 600;
border-color: var(--accent);
} }
.primary-btn:hover { .primary-btn:hover {
background: #ff8a00; background: var(--gold);
box-shadow: var(--accent-glow); border-color: var(--gold);
} }
/* Session List */ /* Session List */
@@ -387,15 +487,15 @@ select:hover {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 12px; padding: 12px;
border: 1px solid rgba(255,122,0,0.3); border: 1px solid var(--border);
border-radius: 6px; border-radius: 8px;
background: rgba(255,122,0,0.05); background: var(--bg-line);
transition: all 0.2s; transition: border-color 0.15s, background-color 0.15s;
} }
.session-item:hover { .session-item:hover {
border-color: var(--accent); border-color: var(--border-bright);
background: rgba(255,122,0,0.1); background: var(--accent-soft);
} }
.session-info { .session-info {
@@ -435,8 +535,8 @@ select:hover {
/* Thinking Stream Panel */ /* Thinking Stream Panel */
.thinking-panel { .thinking-panel {
border-top: 1px solid var(--accent); border-top: 1px solid var(--border);
background: rgba(255, 122, 0, 0.02); background: var(--bg-dark);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: max-height 0.3s ease; transition: max-height 0.3s ease;
@@ -452,16 +552,16 @@ select:hover {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 10px 12px; padding: 10px 12px;
background: rgba(255, 122, 0, 0.08); background: var(--bg-elev);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
border-bottom: 1px solid rgba(255, 122, 0, 0.2); border-bottom: 1px solid var(--border);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
} }
.thinking-header:hover { .thinking-header:hover {
background: rgba(255, 122, 0, 0.12); background: var(--accent-soft);
} }
.thinking-controls { .thinking-controls {
@@ -489,19 +589,19 @@ select:hover {
.thinking-clear-btn, .thinking-clear-btn,
.thinking-toggle-btn { .thinking-toggle-btn {
background: transparent; background: var(--bg-line);
border: 1px solid rgba(255, 122, 0, 0.5); border: 1px solid var(--border);
color: var(--text-main); color: var(--text-main);
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
} }
.thinking-clear-btn:hover, .thinking-clear-btn:hover,
.thinking-toggle-btn:hover { .thinking-toggle-btn:hover {
background: rgba(255, 122, 0, 0.2); background: var(--accent-soft);
box-shadow: 0 0 6px rgba(255, 122, 0, 0.3); border-color: var(--border-bright);
} }
.thinking-toggle-btn { .thinking-toggle-btn {
@@ -613,6 +713,12 @@ select:hover {
/* ========== MOBILE RESPONSIVE STYLES ========== */ /* ========== MOBILE RESPONSIVE STYLES ========== */
/* Wordmark + status dot — shown only in the mobile header (media query below) */
.brand, .brand-dot { display: none; }
/* Bottom tab bar — mobile only (shown in the media query) */
#tabbar { display: none; }
/* Hamburger Menu */ /* Hamburger Menu */
.hamburger-menu { .hamburger-menu {
display: none; display: none;
@@ -620,9 +726,9 @@ select:hover {
gap: 4px; gap: 4px;
cursor: pointer; cursor: pointer;
padding: 8px; padding: 8px;
border: 1px solid var(--accent); border: 1px solid var(--border-bright);
border-radius: 4px; border-radius: 8px;
background: transparent; background: var(--bg-line);
z-index: 100; z-index: 100;
} }
@@ -654,13 +760,17 @@ select:hover {
left: -100%; left: -100%;
width: 280px; width: 280px;
height: 100vh; height: 100vh;
background: var(--bg-dark); height: 100dvh;
border-right: 2px solid var(--accent); background: var(--bg-elev);
box-shadow: var(--accent-glow); border-right: 1px solid var(--border);
box-shadow: 8px 0 32px rgba(0, 0, 0, 0.5);
z-index: 999; z-index: 999;
transition: left 0.3s ease; transition: left 0.3s ease;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
padding: 20px; padding: 20px;
padding-top: calc(20px + env(safe-area-inset-top));
padding-bottom: calc(20px + env(safe-area-inset-bottom));
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
@@ -689,7 +799,7 @@ select:hover {
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
padding-bottom: 16px; padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 122, 0, 0.3); border-bottom: 1px solid var(--border);
} }
.mobile-menu-section:last-child { .mobile-menu-section:last-child {
@@ -716,15 +826,25 @@ select:hover {
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
body { body {
padding: 0; padding: 0;
background: var(--bg-elev); /* matches the tab bar so any strip below #chat is seamless */
} }
#chat { #chat {
position: fixed;
top: 0; left: 0; right: 0;
width: 100%; width: 100%;
max-width: 100%; height: 100dvh; /* the *visible* viewport (excludes the home-indicator zone);
height: 100vh; overrides the base 95vh. Body bg matches the bar below it. */
background: var(--bg-dark);
border-radius: 0; border-radius: 0;
border-left: none; border: none;
border-right: none; }
/* Only while the keyboard is open do we follow the *visible* viewport: release
the bottom anchor and size from the top by the measured visible height. */
body.kb #chat {
bottom: auto;
height: var(--app-height, 100dvh);
transform: translateY(var(--app-offset, 0px));
} }
/* Show hamburger, hide desktop header controls */ /* Show hamburger, hide desktop header controls */
@@ -734,17 +854,39 @@ select:hover {
#model-select { #model-select {
padding: 12px; padding: 12px;
justify-content: space-between; padding-top: calc(12px + env(safe-area-inset-top));
padding-left: calc(14px + env(safe-area-inset-left));
padding-right: calc(14px + env(safe-area-inset-right));
justify-content: flex-start;
gap: 12px;
} }
/* Hide all controls except hamburger on mobile */ /* Mobile header is [≡] Lyra [♠ Cash] [●] — hide everything else. */
#model-select > *:not(.hamburger-menu) { #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 {
display: block;
font-family: var(--font-console);
font-weight: 600;
font-size: 1.1rem;
color: var(--accent);
letter-spacing: 0.5px;
}
.brand-dot {
display: block;
width: 9px; height: 9px;
border-radius: 50%;
background: var(--text-fade);
margin-left: auto;
transition: background-color .2s;
}
.brand-dot.ok { background: var(--good); box-shadow: 0 0 8px rgba(143, 214, 148, .55); }
.brand-dot.fail { background: var(--bad); }
#session-select { #session-select { display: none; }
display: none; #status { display: none; } /* relay status now lives as the header dot */
}
/* Show mobile menu */ /* Show mobile menu */
.mobile-menu { .mobile-menu {
@@ -763,19 +905,61 @@ select:hover {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* Input area - bigger touch targets */ /* Input area - bigger touch targets. The tab bar owns the bottom safe-area
inset now (the input is no longer the bottom-most element). */
#input { #input {
padding: 12px; padding: 12px;
padding-left: calc(12px + env(safe-area-inset-left));
padding-right: calc(12px + env(safe-area-inset-right));
} }
/* Bottom tab bar */
#tabbar {
display: flex;
flex: none; /* never let it be compressed/clipped by the flex column */
border-top: 1px solid var(--border);
background: var(--bg-elev);
padding-bottom: 6px; /* 100dvh already excludes the home-indicator zone */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
#tabbar .tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
padding: 7px 0 5px;
background: none;
border: none;
border-radius: 0;
color: var(--text-fade);
font-family: var(--font-console);
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}
#tabbar .tab:hover { background: none; }
#tabbar .tab:active { background: var(--accent-soft); }
#tabbar .tab .ti { font-size: 1.3rem; line-height: 1; filter: grayscale(.45); }
#tabbar .tab .tl { font-size: .64rem; letter-spacing: .3px; }
#tabbar .tab.active { color: var(--accent); }
#tabbar .tab.active .ti { filter: none; }
body.kb #tabbar { display: none; } /* keyboard open ⇒ hide so input pins to keyboard */
/* The "More" tab is the menu trigger now — retire the hamburger. */
.hamburger-menu { display: none !important; }
#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 */
@@ -874,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 {
@@ -995,6 +1181,16 @@ select:hover {
} }
.msg.assistant pre code { background: none; padding: 0; font-size: 0.85em; } .msg.assistant pre code { background: none; padding: 0; font-size: 0.85em; }
/* Streaming: a blinking caret while tokens arrive (and a min-size while empty). */
.msg.assistant.streaming { min-width: 1.4em; min-height: 1.1em; }
.msg.assistant.streaming::after {
content: "▋";
margin-left: 1px;
color: var(--accent);
animation: caretBlink 1s steps(1) infinite;
}
@keyframes caretBlink { 0%, 50% { opacity: 0.85; } 50.01%, 100% { opacity: 0; } }
/* Behind-the-scenes 👍/👎 feedback (fine-tune signal) — subtle until hovered. */ /* Behind-the-scenes 👍/👎 feedback (fine-tune signal) — subtle until hovered. */
.rate-bar { display: flex; gap: 6px; margin-top: 7px; opacity: 0.3; transition: opacity .15s; } .rate-bar { display: flex; gap: 6px; margin-top: 7px; opacity: 0.3; transition: opacity .15s; }
.msg.assistant:hover .rate-bar { opacity: 0.85; } .msg.assistant:hover .rate-bar { opacity: 0.85; }
@@ -1003,5 +1199,31 @@ select:hover {
padding: 2px 5px; border-radius: 5px; line-height: 1; filter: grayscale(0.6); padding: 2px 5px; border-radius: 5px; line-height: 1; filter: grayscale(0.6);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.rate-btn:hover { filter: none; background: rgba(255,122,0,0.12); } .rate-btn:hover { filter: none; background: var(--accent-soft); }
.rate-btn.rated { filter: none; background: rgba(255,122,0,0.25); 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. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "lyra" name = "lyra"
version = "0.2.0" version = "0.3.0"
description = "Persistent, autonomous AI assistant" description = "Persistent, autonomous AI assistant"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+212
View File
@@ -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")
Generated
+1 -1
View File
@@ -278,7 +278,7 @@ wheels = [
[[package]] [[package]]
name = "lyra" name = "lyra"
version = "0.1.0" version = "0.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },