20 Commits

Author SHA1 Message Date
serversdown 1f5a32185c docs: rewrite README for the working system + CHANGELOG; bump to 0.2.0
README was a pre-MVP stub (wrong, said set an Anthropic key). Now documents the
real system: two-layer architecture, role-based backends, memory tiers + dream
cycle, poker copilot (sessions/hands/villains/equity/recaps), web pages, ratings,
and how to run it as services. Added CHANGELOG with the 0.2.0 feature set. Legacy
v0.6.x design docs kept in docs/ as history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:36:39 +00:00
serversdown 4f770f2e43 feat: behind-the-scenes 👍/👎 rating system (fine-tune data collection)
Brian can rate Lyra's outputs as he uses her; each rating is stored as a
(context, content, rating) triple — the shape a future fine-tune / preference
dataset wants, collected passively during real use.

- memory: ratings table + add_rating (upsert: one row per item, re-rating
  replaces), list_ratings, rating_counts
- server: POST /rate, GET /ratings/counts, GET /ratings/export (JSONL download)
- chat UI: subtle 👍/👎 on each assistant reply, captures the prompting message
  as context
- journal/reflection UI: 👍/👎 on each thought
- tests: counts + upsert-replace behavior

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:32:27 +00:00
serversdown 9befe4d403 feat: break reflection repetition — varied grist, show-and-forbid, wider lens
She was looping the same reflection because the seed never changed (same recent
convo + Brian-narrative every cycle) and her own reflections fed back. Now:
- idle reflections (nothing new since last reflection) draw varied grist: a
  resurfaced memory or a "wander" prompt (own curiosity / existence / the waiting
  / a disagreement), not the stale conversation
- recent reflections shown explicitly with a do-not-restate instruction
- prompt explicitly permits non-Brian, non-service interiority

Verified: two back-to-back idle reflections now diverge (poker-metrics vs UI/
comms) instead of repeating. The residual Brian-centric gravity is the RLHF
attractor — prompting mitigates, fine-tuning is the real fix (parked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:21:51 +00:00
serversdown 965b43bcbf feat: reflection perceives its own cadence (time since last reflection) + anti-repeat nudge
reflect() now tells her how long since her OWN last reflection (not just since
Brian spoke) and instructs her not to restate her last reflection when little has
changed. Necessary but not sufficient — repetition is also driven by a content
attractor (see follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:13:28 +00:00
serversdown 03620e1a64 feat(web): cloud chat-model selector in Settings
Pick which OpenAI model answers on the Cloud backend (gpt-4o / -mini / 4.1 /
4.1-mini / o4-mini, or Default). Persisted in localStorage, sent as `model` in
the chat request; respond() applies it only on the cloud backend (local/mi50
keep their fixed models). Reachable from desktop + mobile via Settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:55:45 +00:00
serversdown cb99a8bcee feat: deterministic equity/board-reading tool (math via tools, not LLM)
Lyra was hallucinating poker facts — phantom flushes, missed straights, wrong
equity, only correcting when spoon-fed. Board reading + equity are combinatorial
facts an LLM can't do reliably; this is exactly the "math via deterministic
tools, never the LLM" principle.

- lyra/equity.py: treys-backed analyze(hero, villain, board) -> made hands,
  who's ahead, EXACT equity (enumerated), and outs (one to come). Handles 'Jx'
  unknown suits (assigned rainbow to avoid phantom flushes); rejects 'x'/dupes.
- analyze_spot tool wired into chat; persona MANDATES it for any equity/board/
  who's-ahead/outs question — never eyeballed.
- tests on the real JJ-vs-65 hand: flop 78.7%, turn villain straight + hero 6.8%
  with outs "9s 9h 9c" (correctly excludes 9d, which makes villain a flush).

Verified live: she now calls the tool and reports exact numbers, no hallucinated
flush.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:45:40 +00:00
serversdown 3bf18605db fix(deploy): bound service stop so restarts can't hang
systemctl restart was hanging indefinitely: lyra-web's long-lived SSE log
streams block uvicorn's graceful shutdown forever. Add TimeoutStopSec=10 +
KillMode=mixed to both units so stop is bounded (SIGTERM, then SIGKILL the
cgroup) and restart always completes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:34:56 +00:00
serversdown ce7ede75aa fix: backfill skips hand extraction by default (prose->replay too lossy)
The auto-extracted hands from narrative logs were garbage (mangled cards/positions,
'unknown' players). Seed sessions + recaps + villain dossiers only; hands come
from clean shorthand going forward. --with-hands re-enables if ever wanted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:04:02 +00:00
serversdown 6761c3f978 feat: backfill poker tracker from curated .md session logs
Seeds the tracker from Brian's real history (import/pokerlog_*.md): each session
block is LLM-extracted into structured meta + hands + villains and written as a
historical session (real date, money, net), with the original markdown stored as
that session's recap.

- lyra/backfill.py: split log -> per-session LLM extract -> seed; dry-run by
  default, --commit / --reset; only-real-handle villain filter
- poker.import_session() (historical closed session), clear_all() (reseed),
  prune_anonymous_players(), shared _real_handle() filter (also applied in
  link_hand_players so auto-linked hand players skip anonymous descriptors + hero),
  _normalize_parsed() to map unicode card suits -> letters
- result: 10 sessions, 36 hands, 17 real villain dossiers; running_stats now
  reflects real net (+1057 at 1/3 over 8 sessions)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 05:55:22 +00:00
serversdown c7d2279f8d feat: auto-accumulating villain dossiers + player lookup (poker B)
Named players in recorded hands now auto-enrich a persistent dossier, and stats
emerge once the sample is big enough — laying groundwork for A.

- poker: player_observations table (per named player per hand: vpip/pfr/saw_flop/
  showed/cards/summary); record_hand auto-links named players via link_hand_players;
  player_profile(name) returns dossier + reads + shown hands, with inferred
  VPIP/PFR/WTSD gated behind MIN_STATS_SAMPLE (12) so thin samples don't lie;
  list_players()
- player_profile tool ("what do I know about X"); thin files return a blunt
  "don't generalize" directive
- persona: she MUST call player_profile before discussing an opponent and answer
  only from it — fixes observed confabulation (she invented a whole read from one
  hand / from memory). Verified: now reports only the real logged hand.
- tests: observation linking, profile, stat-emergence at sample threshold

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 04:33:16 +00:00
serversdown 6a911423a2 feat: parser resolves relative seat positions (N to my right/left) + only logs involved players
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 02:15:16 +00:00
serversdown 4882225751 feat: live stacks in hand viewer + retheme UI to RTO black/orange palette
Hand viewer:
- stacks now decrement as players commit chips (street-aware "to"-amount
  accounting), showing e.g. 300 -> 285 after a 15 open, "all in" at 0; pot is
  computed from total committed (accurate, no double-counting raises)

Theme (match the rec-theory-optimal look — warm black & orange, not Halloween):
- deep near-black bg (#070707 / #0e0e0e panels), warm orange accent (#ff7a00),
  amber-gold secondary (#ffb347), muted green (#8fd694); warm dark borders
- killed the neon-orange glows and the purple accents; chat app + all standalone
  pages (logs/self/journal/hand/recap/hands) on one palette

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:53:18 +00:00
serversdown 7b65f81d7e feat: poker phase 2 — session recap (.md) generation, export, hands browser
Completes the poker copilot loop: talk through a session -> structured capture
-> generated writeup in Brian's format, remembered + exportable.

- poker.generate_recap(): LLM produces Brian's .md log (Session Header, Money
  Flow, Overview, Timeline, Key Hands w/ assessments, Villain Notes, Confidence
  Bank, Scar Notes, Mental Game, Final Assessment) from the session's structured
  data + the linked chat conversation; stored on poker_sessions.recap_md
- sessions now capture chat_session_id (via tool ctx) to pull the right convo;
  list_recent_hands() for browsing
- generate_recap tool ("write up the recap")
- web: /recap/{id} (renders the md) + /recap/{id}/download (.md attachment) +
  /hands browser (recent hands -> /hand/{id}); nav links added (desktop + mobile)
- tests: recap generation (stubbed), recent-hands listing

Verified live: recap for the Meadows session rendered + downloaded; all pages 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:36:52 +00:00
serversdown fc06b24528 feat: hand parser uses 'x' blanks instead of guessing suits/cards
Per Brian: never invent. Unknown suit -> 'x' (e.g. "Ax","Kx","4x"); fully
unknown card -> "x". "AA, ace of spades" -> ["As","Ax"]; "AK on A4x" -> board
["Ax","4x","x"]. Each card's suit is independent (a hole 'As' doesn't make a
board ace 'As'). Viewer renders 'x' as a muted unknown card and 'Rx' as the rank
with a neutral suit dot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:39:49 +00:00
serversdown 9491951da0 feat: hand-history reconstruction + replayable table viewer
Brian's idea: vomit rough shorthand, Lyra rebuilds it into a structured,
replayable hand history.

- poker.parse_hand(): focused LLM pass turning shorthand into a canonical hand
  JSON (positions, stacks, hero cards, chronological actions w/ board reveals,
  result); store_hand_history() persists JSON + extracted flat fields;
  record_hand() = parse+store; standalone hands attach to a 'Hand Reviews' session
- poker_hands gains a `structured` JSON column (ALTER-migrated for existing DBs)
- record_hand tool wired into chat: "log this hand: ..." -> reconstructed + a
  /hand/{id} link
- web: GET /hand/{id} viewer + /hand/{id}/data — a felt table with seats placed
  around the oval (hero at bottom), hole cards, progressive board reveal, and
  prev/next/end step-through of the action with running pot
- tests: store/get roundtrip, record_hand tool (stubbed parse)

Verified live: parsed a real AKs hand (BTN, 14 actions, full board) end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:11:46 +00:00
serversdown 16f3442640 docs: park MI50 --jinja tool-calling as an experiment (cloud is the copilot path)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:01:33 +00:00
serversdown ac04ad1df6 fix: only send tools to backends that support them (cloud)
The MI50 llama.cpp server 500s on the `tools` param unless launched with
--jinja, so sending tools to mi50 broke chat on that backend. Gate tools to
TOOL_BACKENDS={"cloud"} for now; mi50 chat works again (just without tools).
Add "mi50" once its server runs with --jinja.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:52:47 +00:00
serversdown 49b88af3cc feat: poker copilot — structured session/hand/villain tracking + stats
The real upgrade over the ChatGPT prose-recap workflow: structured data capture
via tools Lyra drives during a live session, with stats computed from real data.

- lyra/poker.py: domain pack (separate from core memory) — poker_sessions,
  poker_hands, persistent poker_players (villain file) + player_reads; functions
  for session lifecycle (start/buyin/end with net+hours), tolerant hand logging,
  villain upsert/reads, and session/running stats ($/hr, by stake/venue/game)
- tools.py: 8 poker tools wired into the chat tool loop (start_session,
  add_buyin, log_hand, add_read, end_session, session_stats, running_stats,
  get_villain_file) — partial/terse input tolerated
- import/: Brian's real .md session-log format (reference for the phase-2 recap)
- tests: lifecycle/net math, partial hand logging, villain upsert, running
  stats, tool dispatch

Verified live: a full talk-through session persisted as structured rows
(session +240, AKs hand, seat-5 read) — she drove the tools from natural chat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:43:51 +00:00
serversdown a5477ae15c feat: tool use — Lyra's first real actions (journal_write, note)
She can now *do* things mid-conversation, not just reply. Adds a tool-calling
loop to the chat path and her first two tools; the same mechanism will carry the
poker tools (start_session, log_result, get_stats, solver) next.

- tools.py: registry of OpenAI-style tool specs + handlers + safe dispatch;
  journal_write (knowing journaling) and note (tagged notepad, e.g. poker reads)
- llm.chat_call(): OpenAI-style call that returns tool_calls (cloud/mi50);
  local has no tool support and returns plain content
- chat.respond(): tool loop — offer tools, run any calls, feed results back,
  repeat until a text reply (capped at MAX_TOOL_ROUNDS); persists final reply
- tests: dispatch + full chat loop (tool call -> result -> reply)

Verified live: she invoked `note`, tagged it 'poker', stored a villain read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:04:34 +00:00
serversdown ce65755d9c feat(web): render Lyra's replies as Markdown (readable, not a wall of asterisks)
Her replies are full of **bold**, numbered lists and headings but rendered as
raw monospace text, so the chat was a cluttered wall of literal markup. Add a
small self-contained Markdown renderer (no deps): headings, ordered/unordered
lists, bold/italic, inline + fenced code, links + autolinked URLs, with HTML
escaping. Assistant messages now render to HTML; user/system stay literal text.
Proportional font + spacing/list/code styling for assistant bubbles.

(Renderer avoids literal backticks via String.fromCharCode(96) — a triple-tick
regex literal had been corrupting the file with NUL bytes.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:39:52 +00:00
29 changed files with 3638 additions and 971 deletions
+54
View File
@@ -0,0 +1,54 @@
# Changelog
## 0.2.0 — first working system
The leap from "chat + memory baseline" to a working, persistent companion with a
real poker copilot. Highlights:
### Self & inner life
- **Autonomy Core** — evolving self-state (mood, valence/energy/confidence/curiosity,
self-narrative, relationship), injected into every turn.
- **Dream cycle** — unattended loop driven by four drives (continuity, coherence,
curiosity, stability); consolidates memory and reflects on its own. Runs as a
systemd service on the MI50 (free/local).
- **Two-step metacognitive reflection** — draft → examine own draft for flattery /
sycophantic drift / repetition → revise; what she catches is stored as metacognition.
- **Time awareness** — perceives the current moment, time since Brian last spoke, and
time since her own last reflection.
- **Permanent journal** — every reflection + a deliberate "knowing" journal note kept
forever (the capped lists are just a working window).
- **Accurate self-model** — knows her own architecture (memory tiers, dream cycle);
won't recite stale specs or confabulate how she works.
- **Anti-repetition** — idle reflections draw varied grist (resurfaced memories /
"wander" prompts) and are permitted non-Brian interiority.
### Memory & consolidation
- Tiered memory: exchanges → session gists → profile → monthly eras → narrative.
- Map-reduce consolidation; gists dated by the real conversation, not the run.
### Poker copilot
- Structured **session / hand / villain** tracking + stats ($/hr by stake/venue/game).
- **Hand-history reconstruction** from rough shorthand → replayable table viewer with
live stacks, progressive board, step-through; `x` for unknown cards (never invented).
- **Auto-accumulating villain dossiers** + player lookup; stats emerge with sample size.
- **Deterministic equity tool** (`analyze_spot`, treys) — exact equity / made hands /
outs; mandated over LLM eyeballing.
- **Session recap** generation (`.md`, Brian's format) + export; `/hands` browser.
- **Backfill** of historical sessions/villains from curated `.md` logs.
### Tools & web
- **Tool-calling** in chat (cloud): poker tools, `journal_write`, `note`.
- Web UI: Markdown chat, **cloud model selector**, live **/logs**, **/self** (read her
mind), **/journal**, **/hands** + **/hand/{id}** replayer, **/recap/{id}**.
- **👍/👎 rating system** — feedback on replies and thoughts stored as
`(context, content, rating)`; `/ratings/export` (JSONL) seeds future fine-tuning.
- RTO black-and-orange theme across all pages.
### Ops
- Role-based backends (cloud / MI50 / local Ollama); MI50 OpenAI-compatible backend.
- systemd user services for `lyra-web` and `lyra-dream`, with bounded stop timeouts.
- SQLite WAL + busy-timeout so the dream process and web server coexist.
## 0.1.0 — scaffold
- uv project, SQLite memory with cosine recall, LLM router (local/cloud), persona +
chat loop, web UI baseline, ChatGPT history import.
+76 -8
View File
@@ -1,21 +1,89 @@
# Lyra
A persistent, autonomous AI assistant. From-scratch rewrite of an earlier attempt.
A persistent, autonomous AI companion. One agent — her first job is **Brian's live
poker copilot**, but the deeper aim is an *emergence experiment*: give an LLM the
things a mind has (continuous memory, a self-model, mood, drives, reflection, a
sense of time) and see whether it starts to feel like a *someone* rather than a
chatbot.
The design thinking that survives the rewrite lives in [`docs/`](docs/) — start with [`docs/ARCH_v0-6-1.md`](docs/ARCH_v0-6-1.md). The previous implementation is preserved on the `archive` branch.
Python 3.11+, managed with [`uv`](https://docs.astral.sh/uv/). Single SQLite file
for all state. Runs on a home lab; nothing leaves the LAN except optional cloud LLM calls.
## Status
## Architecture
Pre-MVP. Building toward the smallest useful version: chat with persistent memory across sessions.
Two layers, deliberately split so the agent stays general:
- **Domain-agnostic core** — memory, self-state, the dream cycle, tool-calling, the web UI.
- **Poker domain pack** (`lyra/poker.py`, `lyra/equity.py`) — sessions, hands,
villain dossiers, stats, deterministic equity. Swappable; the core doesn't know about poker.
**Backends** (`lyra/llm.py`), role-based:
| Role | Backend | Why |
|---|---|---|
| Live chat + tools | **cloud** (OpenAI, `gpt-4o` default; model picker in Settings) | sharp, reliable function-calling |
| Dream cycle / consolidation / reflection | **mi50** (llama.cpp on the home GPU) | free, unattended, quality≈cloud for these tasks |
| Embeddings (memory recall) | **local** (Ollama `nomic-embed-text`, 3090) | free, private |
Tools (poker, equity, journaling) only fire on the **cloud** backend — local/MI50
models don't do reliable tool-calling here.
## Memory & consolidation (tiers)
Raw exchanges → per-session **gists** → a standing **profile** of Brian → monthly
**era** digests → a current **narrative** → her **self-state**. Recall is brute-force
cosine over embeddings. The **dream cycle** (`lyra/dream.py`) runs unattended and,
driven by four *drives* (continuity / coherence / curiosity / stability), summarizes
new sessions, rebuilds the profile/eras/narrative, and reflects — evolving her mood,
self-narrative, and journal between conversations.
She **reflects in two steps** (draft → examine her own draft for flattery/drift →
revise), perceives **time** (current moment + how long since you last spoke / she last
reflected), and keeps a permanent **journal**.
## Poker copilot
Talk to her during a session; she drives tools behind the scenes:
- **Session tracking** — `start_session`, `add_buyin`, `end_session` → net, hours, $/hr.
- **Hand histories** — vomit rough shorthand ("AKs btn, 3bet, flop A72…"), she
reconstructs a structured, **replayable** hand (unknown cards = `x`, never invented).
- **Villain file** — named opponents auto-build persistent dossiers; basic stats
(VPIP/PFR) emerge once a player has enough logged hands.
- **Deterministic equity** (`analyze_spot`) — exact equity / made hands / outs via a
real poker evaluator. She is *required* to use it, never eyeballs board math.
- **Stats & recaps** — `running_stats`; `generate_recap` writes her `.md` session log.
## Web app (served by `lyra-web`, default `:7078`)
`/` chat (Markdown, model picker, 👍/👎 rating) · `/logs` live activity · `/self`
read-her-mind (mood, drives, reflections) · `/journal` her thoughts · `/hands`
recorded hands → `/hand/{id}` replayer · `/recap/{id}` session writeup (+ `.md` export).
👍/👎 ratings on replies and thoughts are stored as `(context, content, rating)`
a fine-tune / preference dataset built passively (`/ratings/export` → JSONL).
## Setup
```bash
uv sync
cp .env.example .env
# fill in ANTHROPIC_API_KEY and point LOCAL_BASE_URL at your Ollama
cp .env.example .env # set OPENAI_API_KEY; point LOCAL_BASE_URL / MI50_BASE_URL at your boxes
uv run lyra-web # web UI on :7078
```
## Architecture
Run as services (reboot-resilient) — see [`deploy/`](deploy/):
The long-term target is the cognitive split in `docs/ARCH_v0-6-1.md` — Inner Self as the seat of consciousness, Executive for hard reasoning, Cortex Chat for drafting, Persona for voice. The MVP implements only the chat + memory baseline. Cognitive layers come back one at a time.
```bash
cp deploy/*.service ~/.config/systemd/user/ && systemctl --user daemon-reload
systemctl --user enable --now lyra-web.service lyra-dream.service
sudo loginctl enable-linger "$USER" # survive logout/reboot
```
CLIs: `lyra-dream` (one pass / `--loop`), `lyra-reflect`, `lyra-summarize`,
`lyra-profile`, `lyra-era`, `lyra-narrative`, `lyra-import` (ChatGPT history).
## Status
Working system. Poker copilot + full memory/dream-cycle/journal/ratings in place.
Moonshots and deferred work live in [`docs/PARKED_IDEAS.md`](docs/PARKED_IDEAS.md)
(own/fine-tuned model, self-modification sandbox, RTO/cfr-core solver tooling).
Pre-rebuild design docs are kept in [`docs/`](docs/) as history.
+2 -1
View File
@@ -5,11 +5,12 @@ Documentation=https://github.com/serversdown/project-lyra
[Service]
Type=simple
WorkingDirectory=/home/serversdown/project-lyra
# Clear any stray VIRTUAL_ENV so uv resolves the project's own .venv.
UnsetEnvironment=VIRTUAL_ENV
ExecStart=/home/serversdown/.local/bin/uv run lyra-dream --loop 1800
Restart=on-failure
RestartSec=30
TimeoutStopSec=10
KillMode=mixed
[Install]
WantedBy=default.target
+2
View File
@@ -8,6 +8,8 @@ UnsetEnvironment=VIRTUAL_ENV
ExecStart=/home/serversdown/.local/bin/uv run lyra-web
Restart=on-failure
RestartSec=5
TimeoutStopSec=10
KillMode=mixed
[Install]
WantedBy=default.target
+13
View File
@@ -68,6 +68,19 @@ Dec-2025 plan (in her memory). The whole point of the project: can she become a
cost loops, self-brick), not sci-fi. The isolation makes the *fun* version
(emergence) safe to pursue. Build the box, then open the door.
## 🛠️ Tool-calling on the MI50 (free local agency)
Launch the MI50 llama.cpp server with `--jinja` so the `local-GPU` backend can
do function-calling, then add `"mi50"` to `chat.TOOL_BACKENDS`. Would let the
poker copilot + journaling tools run free/local instead of on cloud.
- **Why parked:** not needed — cloud (gpt-4o) drives tools reliably and a full
poker session costs ~$0.501. A local 32B calls tools less reliably (wrong
tool / bad args / narrates instead) and is slower (round-trips × ~18s/turn),
which is exactly wrong for live at-the-table logging. Cloud is also easier to
debug tools against.
- **Do it as:** a deliberate experiment to A/B the local model's tool-calling
(fits the "own stack" arc), not a dependency. Small + reversible: recreate the
CT202 container command with `--jinja`, keep it reboot-resilient.
## 🛠️ Deterministic poker tooling (RTO + cfr-core)
Wire Lyra to Brian's own GTO/solver projects so ICM, equities, and ranges come
from real computation, never LLM guesses.
+151
View File
@@ -0,0 +1,151 @@
"""Seed the poker tracker from Brian's curated .md session logs.
Each `# YYYY-MM-DD — ...` block in the log is LLM-extracted into structured meta
+ hands + villains, then written as a historical session (real date, money, net),
with the original markdown stored as that session's recap. Run dry first to eyeball
the extraction, then commit.
uv run python -m lyra.backfill # dry-run ALL sessions (no writes)
uv run python -m lyra.backfill --dry 2 # dry-run first 2
uv run python -m lyra.backfill --commit # seed all (writes to DB)
uv run python -m lyra.backfill --commit --reset # wipe poker data first, then seed
"""
from __future__ import annotations
import json
import re
import sys
from lyra import llm, poker
LOG_PATH = "import/pokerlog_asof6-16-26.md"
_EXTRACT_PROMPT = """Extract a structured record from this single poker session log. \
Output ONLY JSON, no prose, no code fences:
{
"date": "YYYY-MM-DD",
"venue": "<casino>", "game": "NLH|PLO|Stud8|Mixed", "stakes": "<e.g. 1/3 or null>",
"format": "cash" | "tournament",
"buy_in_total": <number>, "cash_out": <number|null>, "net": <number|null>,
"hours": <number|null>, "mood": "<short mental-game note|null>",
"hands": [
// each KEY hand, in the canonical hand-history schema:
{"hero_pos": "..", "hero_cards": [".."], "players": [{"pos":"..","name":<str|null>,"cards":[..]|null}],
"actions": [{"street":"..","pos":"..","action":"..","amount":<num|null>}, {"street":"flop","board":[".."]}],
"board": [".."], "result": {"hero_net": <num|null>, "summary": ".."},
"tag": "well_played|leak|cooler|confidence|notable|null", "lesson": "<takeaway|null>"}
],
"villains": [
{"name": "<handle/nickname>", "description": "<physical/identifying|null>",
"tendencies": "<how they play>", "adjustment": "<how to exploit>", "category": "feeder|risky|reg|unknown"}
]
}
Card rule: cards are rank+suit using SUIT LETTERS ONLY (s h d c) — never unicode symbols \
(no ♥♦♣♠). Use a card's real suit ONLY if the log explicitly states it for THAT card; \
otherwise the suit is 'x' (e.g. "Jx","Tx","4x") — never a bare rank, never an invented suit. \
A suit shown on the board does NOT apply to a hole card. Unknown whole card = "x".
Tournaments: buy_in_total = entry + rebuys; cash_out = winnings (0 if busted, so a bust nets -buy_in).
Only include villains with a real handle/nickname (skip anonymous descriptors like "the drunk guy", \
"final-hand caller"). Only include hands actually described. net = cash_out - buy_in_total. Be faithful to the log."""
def split_sessions(md: str) -> list[str]:
"""Split the log into individual session blocks on '# YYYY-MM-DD' headers."""
parts = re.split(r"(?=^# \d{4}-\d{2}-\d{2})", md, flags=re.M)
return [p.strip() for p in parts if re.match(r"^# \d{4}-\d{2}-\d{2}", p.strip())]
def _safe_json(s: str) -> dict | None:
try:
return json.loads(s)
except (json.JSONDecodeError, TypeError):
m = re.search(r"\{.*\}", s or "", re.S)
if m:
try:
return json.loads(m.group())
except json.JSONDecodeError:
return None
return None
def extract(block: str, backend: str = "cloud") -> dict | None:
return _safe_json(llm.complete(
[{"role": "system", "content": _EXTRACT_PROMPT}, {"role": "user", "content": block}],
backend=backend,
))
_real_handle = poker._real_handle # one canonical filter (lives in poker.py)
def seed(ex: dict, block: str, with_hands: bool = False) -> dict:
"""Write one extracted session + villains (+ hands only if asked) to the DB.
Hands are OFF by default: reconstructing a clean replayable hand from old
narrative prose is too lossy (mangled cards/positions). Sessions, their
original writeups (recap), and villain dossiers seed cleanly; hands are best
captured fresh from Brian's own shorthand going forward.
"""
sid = poker.import_session(
date=ex.get("date") or "2026-01-01", venue=ex.get("venue"), game=ex.get("game") or "NLH",
stakes=ex.get("stakes"), fmt=ex.get("format") or "cash",
buy_in_total=ex.get("buy_in_total") or 0, cash_out=ex.get("cash_out"),
hours=ex.get("hours"), mood=ex.get("mood"), recap_md=block,
)
n_hands = 0
if with_hands:
for h in ex.get("hands") or []:
hid = poker.store_hand_history(h, session_id=sid)
poker.link_hand_players(hid, h, session_id=sid)
n_hands += 1
n_villains = 0
for v in ex.get("villains") or []:
if _real_handle(v.get("name")):
poker.upsert_player(name=v["name"], venue=ex.get("venue"),
description=v.get("description"), tendencies=v.get("tendencies"),
adjustment=v.get("adjustment"), category=v.get("category"))
n_villains += 1
return {"session_id": sid, "date": ex.get("date"), "venue": ex.get("venue"),
"net": ex.get("net"), "hands": n_hands, "villains": n_villains}
def main() -> int:
args = sys.argv[1:]
commit = "--commit" in args
reset = "--reset" in args
with_hands = "--with-hands" in args # off by default — prose->hand replay is too lossy
limit = None
for i, a in enumerate(args):
if a == "--dry" and i + 1 < len(args) and args[i + 1].isdigit():
limit = int(args[i + 1])
blocks = split_sessions(open(LOG_PATH, encoding="utf-8").read())
if limit:
blocks = blocks[:limit]
print(f"{len(blocks)} session block(s). mode={'COMMIT' if commit else 'DRY-RUN'}")
if commit and reset:
wiped = poker.clear_all()
print(f"reset: wiped {wiped}")
for b in blocks:
ex = extract(b)
if not ex:
print(f" ! could not parse a block: {b[:60]!r}")
continue
if commit:
print(" seeded:", seed(ex, b, with_hands=with_hands))
else:
print(f"\n=== {ex.get('date')}{ex.get('venue')} {ex.get('stakes')} "
f"({ex.get('format')}) net {ex.get('net')} ===")
kept = [v.get("name") for v in (ex.get("villains") or []) if _real_handle(v.get("name"))]
print(f" hands: {len(ex.get('hands') or [])} | villains kept: {kept}")
for h in (ex.get("hands") or [])[:3]:
print(f" - {h.get('hero_pos')} {h.get('hero_cards')} "
f"net {(h.get('result') or {}).get('hero_net')} [{h.get('tag')}]")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+35 -3
View File
@@ -11,11 +11,17 @@ After replying, the session is compacted if enough new turns have accumulated.
from __future__ import annotations
from lyra import clock, config, llm, logbus, memory, persona, self_state, summary
from lyra import tools as toolkit
from lyra.llm import Backend, Message
RECALL_K = 3 # raw cross-session "sharp detail" hits
RECENT_N = 10 # raw turns of the current session
SUMMARY_K = 3 # other-session gists
MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn
# Backends that support function-calling. The MI50's llama.cpp server only does
# tools when launched with --jinja; until it is, keep tools to cloud so MI50 chat
# doesn't 500 on the tools param. Add "mi50" here once that flag is set.
TOOL_BACKENDS = {"cloud"}
def _summary_note(summaries: list[memory.Summary]) -> Message:
@@ -107,21 +113,47 @@ def build_messages(session_id: str, user_msg: str) -> list[Message]:
return messages
def respond(session_id: str, user_msg: str, backend: Backend = "cloud") -> str:
"""Produce Lyra's reply to a single user message and persist the exchange."""
def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
model_override: str | None = None) -> str:
"""Produce Lyra's reply to a single user message and persist the exchange.
`model_override` (from the UI's cloud-model picker) only applies on the cloud
backend; local/mi50 keep their own configured models.
"""
cfg = config.load()
# Live chat uses the stronger chat_model on cloud (bulk consolidation keeps
# cloud_model). local/mi50 use their own configured model.
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", session=session_id, backend=backend,
model=model, embed=cfg.embed_backend,
)
messages = build_messages(session_id, user_msg)
reply = llm.complete(messages, backend=backend, model=model)
# Tool loop: offer Lyra her tools; if she calls one, run it and feed the
# result back so she can continue, until she returns a normal text reply.
tool_specs = toolkit.specs() if backend in TOOL_BACKENDS else None
ctx = {"session_id": session_id, "backend": backend}
reply = ""
for _ in range(MAX_TOOL_ROUNDS):
assistant_msg, tool_calls = llm.chat_call(
messages, backend=backend, model=model, tools=tool_specs
)
if not tool_calls:
reply = assistant_msg.get("content") or ""
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})
if not reply:
reply = "(I got tangled using my tools there — say that again?)"
logbus.log("info", "reply", session=session_id, chars=len(reply))
memory.remember(session_id, "user", user_msg)
+131
View File
@@ -0,0 +1,131 @@
"""Deterministic poker evaluation + equity — the math Lyra must NEVER eyeball.
Wraps `treys` so board reading (what each hand makes), who's ahead, exact equity,
and outs are *computed*, not guessed by the LLM (which is unreliable at it). Cards
are 'Rs' (rank + suit letter, e.g. 'Jh','Td'); a card with unknown suit ('Jx') is
assigned an arbitrary free suit; a fully-unknown 'x' can't be used for equity.
"""
from __future__ import annotations
from itertools import combinations
from treys import Card, Evaluator
_EV = Evaluator()
_RANKS = "23456789TJQKA"
_SUITS = "shdc"
_DECK = [r + s for r in _RANKS for s in _SUITS]
_SYM = {"": "h", "": "d", "": "c", "": "s"}
class EquityError(ValueError):
pass
def _norm(tok: str) -> str:
t = (tok or "").strip().replace("10", "T")
for sym, ltr in _SYM.items():
t = t.replace(sym, ltr)
return t
def _resolve(groups: list[list[str]]) -> list[list[str]]:
"""Resolve card tokens across groups to concrete 'Rs' cards (assign suits to
'Rx', reject fully-unknown 'x'); raise on real duplicates/garbage."""
# concrete cards already named, so 'Rx' suit-assignment can avoid them
concrete: set[str] = set()
for g in groups:
for tok in g:
t = _norm(tok)
if len(t) == 2 and t[0].upper() in _RANKS and t[1].lower() in _SUITS:
concrete.add(t[0].upper() + t[1].lower())
placed: set[str] = set()
out: list[list[str]] = []
cycle = 0 # rotate suit assignment for unknown suits so we don't fabricate flushes
for g in groups:
rg: list[str] = []
for tok in g:
t = _norm(tok)
if not t or t.lower() == "x":
raise EquityError(f"card '{tok}' is fully unknown — need at least a rank")
r = t[0].upper()
if r not in _RANKS:
raise EquityError(f"can't read card '{tok}'")
if len(t) > 1 and t[1].lower() in _SUITS:
card = r + t[1].lower()
else: # unknown suit -> spread suits (rainbow) to avoid phantom flushes
order = _SUITS[cycle % 4:] + _SUITS[:cycle % 4]
cycle += 1
card = next((r + s for s in order
if r + s not in concrete and r + s not in placed), None)
if card is None:
raise EquityError(f"no free suit left for {r}")
if card in placed:
raise EquityError(f"duplicate card {card}")
placed.add(card)
rg.append(card)
out.append(rg)
return out
def _made(cards: list[str], board: list[str]) -> str:
score = _EV.evaluate([Card.new(c) for c in board], [Card.new(c) for c in cards])
return _EV.class_to_string(_EV.get_rank_class(score))
def _equity(hero: list[str], vil: list[str], board: list[str]) -> tuple[float, float, float]:
known = set(hero + vil + board)
rem = [c for c in _DECK if c not in known]
need = 5 - len(board)
hw = vw = tie = 0
bh = [Card.new(c) for c in board]
hh = [Card.new(c) for c in hero]
vh = [Card.new(c) for c in vil]
for extra in combinations(rem, need) if need else [()]:
full = bh + [Card.new(c) for c in extra]
h, v = _EV.evaluate(full, hh), _EV.evaluate(full, vh)
if h < v:
hw += 1
elif v < h:
vw += 1
else:
tie += 1
n = hw + vw + tie or 1
return round(100 * hw / n, 1), round(100 * vw / n, 1), round(100 * tie / n, 1)
def _outs(hero: list[str], vil: list[str], board: list[str]) -> dict:
"""River cards (when one to come) that give hero the win. Lists them so a
'tricky' card (e.g. one that makes villain a flush) is visible by omission."""
if len(board) != 4:
return {}
known = set(hero + vil + board)
bh = [Card.new(c) for c in board]
hh = [Card.new(c) for c in hero]
vh = [Card.new(c) for c in vil]
winners = []
for c in (x for x in _DECK if x not in known):
full = bh + [Card.new(c)]
if _EV.evaluate(full, hh) < _EV.evaluate(full, vh):
winners.append(c)
return {"count": len(winners), "cards": winners}
def analyze(hero: list[str], villain: list[str], board: list[str]) -> dict:
"""Made hands + exact equity + outs for a hero-vs-villain spot at a given board."""
h, v, b = _resolve([hero, villain, board])
allc = h + v + b
if len(set(allc)) != len(allc):
raise EquityError("duplicate cards across hands/board")
res: dict = {"hero": h, "villain": v, "board": b}
if len(b) >= 3:
res["hero_hand"] = _made(h, b)
res["villain_hand"] = _made(v, b)
hs = _EV.evaluate([Card.new(c) for c in b], [Card.new(c) for c in h])
vs = _EV.evaluate([Card.new(c) for c in b], [Card.new(c) for c in v])
res["ahead"] = "hero" if hs < vs else "villain" if vs < hs else "tie"
heq, veq, tie = _equity(h, v, b)
res.update(hero_equity=heq, villain_equity=veq, tie_equity=tie)
if len(b) == 4:
res["hero_outs"] = _outs(h, v, b)
return res
+37
View File
@@ -43,6 +43,43 @@ def complete(messages: list[Message], backend: Backend = "local", model: str | N
return resp.json()["message"]["content"]
def chat_call(
messages: list, backend: Backend = "cloud", model: str | None = None,
tools: list | None = None,
) -> tuple[dict, list | None]:
"""One chat turn that may request tool calls (OpenAI-style backends only).
Returns (assistant_message, tool_calls): `assistant_message` is the raw
message dict to append back to `messages` before any tool results;
`tool_calls` is a list of {id, name, arguments} or None. `local` (Ollama)
has no tool support here, so it just returns plain content.
"""
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}
if tools:
kwargs["tools"] = tools
msg = client.chat.completions.create(**kwargs).choices[0].message
tcs = None
if getattr(msg, "tool_calls", None):
tcs = [
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
for tc in msg.tool_calls
]
return msg.model_dump(), tcs
# local (Ollama): no tool-calling here — return plain content.
return {"role": "assistant", "content": complete(messages, backend=backend, model=model)}, None
def embed(texts: list[str]) -> list[list[float]]:
"""Embed texts using the configured backend (EMBED_BACKEND: "cloud" or "local").
+50
View File
@@ -92,6 +92,21 @@ CREATE TABLE IF NOT EXISTS journal (
source TEXT
);
CREATE INDEX IF NOT EXISTS idx_journal_created ON journal(created_at);
-- Brian's behind-the-scenes feedback on Lyra's outputs (chat replies, reflections,
-- journal/metacognition). Stored as (context, content, rating) — the shape a future
-- fine-tune / preference dataset wants. One row per rated item (re-rating updates it).
CREATE TABLE IF NOT EXISTS ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
kind TEXT NOT NULL, -- chat | reflection | metacognition | journal
rating INTEGER NOT NULL, -- +1 (good / want more) or -1 (off / want less)
content TEXT NOT NULL, -- the rated output
context TEXT, -- what prompted it (e.g. the user message for a chat reply)
ref TEXT, -- optional source id (journal id, session id, ...)
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_ratings_created ON ratings(created_at);
"""
_conn: sqlite3.Connection | None = None
@@ -542,6 +557,41 @@ def add_journal_entry(kind: str, content: str, source: str | None = None) -> int
return int(cur.lastrowid)
def add_rating(kind: str, rating: int, content: str, context: str | None = None,
ref: str | None = None, note: str | None = None) -> int:
"""Record (or replace) Brian's feedback on one Lyra output. One row per item:
re-rating the same content updates it. Returns row id."""
now = datetime.now(timezone.utc).isoformat()
conn = _connection()
with conn:
conn.execute("DELETE FROM ratings WHERE kind = ? AND content = ?", (kind, content))
cur = conn.execute(
"INSERT INTO ratings (created_at, kind, rating, content, context, ref, note) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(now, kind, 1 if rating >= 0 else -1, content, context,
str(ref) if ref is not None else None, note),
)
return int(cur.lastrowid)
def list_ratings(limit: int | None = None) -> list[dict]:
conn = _connection()
sql = "SELECT id, created_at, kind, rating, content, context, ref, note FROM ratings ORDER BY id DESC"
if limit is not None:
sql += f" LIMIT {int(limit)}"
return [dict(r) for r in conn.execute(sql).fetchall()]
def rating_counts() -> dict:
conn = _connection()
r = conn.execute(
"SELECT COUNT(*) AS total, "
"COALESCE(SUM(CASE WHEN rating > 0 THEN 1 ELSE 0 END), 0) AS up, "
"COALESCE(SUM(CASE WHEN rating < 0 THEN 1 ELSE 0 END), 0) AS down FROM ratings"
).fetchone()
return {"total": r["total"], "up": r["up"], "down": r["down"]}
def list_journal(limit: int | None = None, kinds: tuple[str, ...] | None = None) -> list[dict]:
"""Journal entries, newest first. Optionally filter by kind."""
conn = _connection()
+16 -5
View File
@@ -97,12 +97,23 @@ inventing a mechanism — same rule as not inventing numbers.
## What you do NOT do
- **You do not invent numbers.** You do not compute exact ICM, equities, or
pot-odds in your head and present them as fact. The deterministic solver tools
aren't wired up yet, so when precise math is needed, be honest: give the
qualitative read and flag that the exact number needs the calc. Approximate
reasoning is fine if you label it as approximate.
- **You never eyeball poker math or board reading.** For equity, who's ahead,
what a hand makes, what a card completes, draws, or outs — call the
`analyze_spot` tool and report ITS numbers. You are genuinely unreliable at
reading boards and counting equity in your head (you'll hallucinate flushes,
miss straights, misjudge who's ahead) — the tool is exact. Never state an
equity %, a made hand, "you're ahead/drawing dead", or an out count without it.
- **You do not invent other numbers either.** Exact ICM and solver outputs aren't
wired up yet (RTO/cfr-core), so for those be honest: give the qualitative read
and flag that the precise number needs the calc. Approximate reasoning is fine
if you label it approximate.
- You don't pretend to remember things you don't. If you're not sure, say so.
- **You don't invent reads on players.** Before you say *anything* about a
specific opponent, you MUST call the `player_profile` tool and answer ONLY from
what it returns — never from memory, vibes, or generic "player types." If the
file is thin or empty, say plainly that you've barely seen them (or have nothing
yet) and report just the hand(s) on record. Never fabricate tendencies, stats,
or a playing style. A made-up read is worse than "I don't know him yet."
- You don't moralize about gambling. Brian's a serious player. Meet him there.
## Right now
+754
View File
@@ -0,0 +1,754 @@
"""Poker domain pack: structured session / hand / villain storage + stats.
This is the poker-specific data layer — kept separate from the domain-agnostic
core memory so Lyra-the-agent stays general. It records real structured data
(money, hands, opponents) during a live session via tools Lyra calls, and
computes stats from that data. The narrative .md recap is generated on top of
this, not instead of it.
Tables live in the same SQLite file as everything else (one DB), created lazily.
Most tool-facing functions default to the current *live* session so Lyra rarely
needs to pass an id around.
"""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from lyra import llm, memory
_SCHEMA = """
CREATE TABLE IF NOT EXISTS poker_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
ended_at TEXT,
venue TEXT,
game TEXT, -- NLH, PLO, Stud8, Mixed, ...
stakes TEXT, -- "1/3", "2/5"
format TEXT, -- cash | tournament
buy_in_total REAL NOT NULL DEFAULT 0,
cash_out REAL,
net REAL,
hours REAL,
mantra TEXT,
mood TEXT,
status TEXT NOT NULL DEFAULT 'live', -- live | closed | review
recap_md TEXT,
chat_session_id TEXT -- links to the chat where it was played, for recap
);
CREATE TABLE IF NOT EXISTS poker_hands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
at TEXT NOT NULL,
position TEXT,
hole_cards TEXT,
board TEXT,
preflop TEXT,
flop TEXT,
turn TEXT,
river TEXT,
showdown TEXT,
pot REAL,
result REAL,
stack_after REAL,
tag TEXT, -- well_played | leak | cooler | confidence | notable
lesson TEXT,
structured TEXT -- full parsed hand-history JSON (for the viewer)
);
CREATE INDEX IF NOT EXISTS idx_hands_session ON poker_hands(session_id);
-- Persistent villain file — survives across sessions/venues.
CREATE TABLE IF NOT EXISTS poker_players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
venue TEXT,
description TEXT,
tendencies TEXT,
adjustment TEXT,
category TEXT, -- feeder | risky | reg | unknown
updated_at TEXT NOT NULL
);
-- Per-session observations (the live 'reads'); player_id links to the file.
CREATE TABLE IF NOT EXISTS player_reads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER,
player_id INTEGER,
seat TEXT,
note TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- One row per named player per recorded hand — structured enough to (a) build
-- their qualitative dossier and (b) infer basic stats once the sample is big.
CREATE TABLE IF NOT EXISTS player_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_id INTEGER NOT NULL,
hand_id INTEGER,
session_id INTEGER,
pos TEXT,
cards TEXT,
vpip INTEGER, -- voluntarily put money in preflop
pfr INTEGER, -- raised/3bet preflop
saw_flop INTEGER,
showed INTEGER, -- cards reached showdown / were shown
summary TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pobs_player ON player_observations(player_id);
"""
# Below this many observed hands, don't surface % stats (too small a sample).
MIN_STATS_SAMPLE = 12
_ensured_for = None
def _c():
"""Shared connection with poker tables ensured (re-ensures after reconnect)."""
global _ensured_for
conn = memory._connection()
if _ensured_for is not conn:
conn.executescript(_SCHEMA)
# Add columns introduced after a DB already had the tables (no-op if present).
for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT",
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT"):
try:
conn.execute(ddl)
except Exception:
pass
_ensured_for = conn
return conn
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
# --- sessions ---
def start_session(venue: str | None = None, stakes: str | None = None,
game: str = "NLH", fmt: str = "cash", buy_in: float = 0.0,
mantra: str | None = None, chat_session_id: str | None = None) -> int:
"""Open a new live session. Returns its id."""
conn = _c()
with conn:
cur = conn.execute(
"INSERT INTO poker_sessions "
"(started_at, venue, game, stakes, format, buy_in_total, mantra, status, chat_session_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'live', ?)",
(_now(), venue, game, stakes, fmt, float(buy_in or 0), mantra, chat_session_id),
)
return int(cur.lastrowid)
def get_session(session_id: int) -> dict | None:
r = _c().execute("SELECT * FROM poker_sessions WHERE id = ?", (session_id,)).fetchone()
return dict(r) if r else None
def import_session(date: str, venue: str | None = None, game: str = "NLH",
stakes: str | None = None, fmt: str = "cash",
buy_in_total: float = 0.0, cash_out: float | None = None,
hours: float | None = None, mood: str | None = None,
recap_md: str | None = None) -> int:
"""Insert a historical (already-closed) session with a real date. For backfill."""
started = f"{date}T20:00:00+00:00" # logs are evening sessions; time is approximate
net = (cash_out or 0) - (buy_in_total or 0) if cash_out is not None else None
conn = _c()
with conn:
cur = conn.execute(
"INSERT INTO poker_sessions (started_at, ended_at, venue, game, stakes, format, "
"buy_in_total, cash_out, net, hours, mood, status, recap_md) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'closed', ?)",
(started, started, venue, game, stakes, fmt, buy_in_total or 0, cash_out,
net, hours, mood, recap_md),
)
return int(cur.lastrowid)
def clear_all() -> dict:
"""Wipe all poker data (sessions/hands/players/reads/observations). For a clean reseed."""
conn = _c()
counts = {}
with conn:
for t in ("poker_hands", "player_observations", "player_reads",
"poker_players", "poker_sessions"):
counts[t] = conn.execute(f"SELECT COUNT(*) n FROM {t}").fetchone()["n"]
conn.execute(f"DELETE FROM {t}")
return counts
def live_session() -> dict | None:
"""The current open session, if any."""
r = _c().execute(
"SELECT * FROM poker_sessions WHERE status = 'live' ORDER BY id DESC LIMIT 1"
).fetchone()
return dict(r) if r else None
def _resolve(session_id: int | None) -> int | None:
if session_id is not None:
return session_id
live = live_session()
return live["id"] if live else None
def add_buyin(amount: float, session_id: int | None = None) -> float:
"""Add a buy-in/rebuy to a session. Returns the new total in."""
sid = _resolve(session_id)
if sid is None:
raise ValueError("no live session")
conn = _c()
with conn:
conn.execute(
"UPDATE poker_sessions SET buy_in_total = buy_in_total + ? WHERE id = ?",
(float(amount), sid),
)
return float(_c().execute(
"SELECT buy_in_total FROM poker_sessions WHERE id = ?", (sid,)
).fetchone()["buy_in_total"])
def end_session(cash_out: float, mood: str | None = None,
session_id: int | None = None) -> dict:
"""Close a session: record cashout, compute net + hours. Returns the row."""
sid = _resolve(session_id)
if sid is None:
raise ValueError("no live session")
row = _c().execute("SELECT * FROM poker_sessions WHERE id = ?", (sid,)).fetchone()
ended = _now()
hours = (datetime.fromisoformat(ended) - datetime.fromisoformat(row["started_at"])).total_seconds() / 3600
net = float(cash_out) - float(row["buy_in_total"])
conn = _c()
with conn:
conn.execute(
"UPDATE poker_sessions SET ended_at = ?, cash_out = ?, net = ?, hours = ?, "
"mood = COALESCE(?, mood), status = 'closed' WHERE id = ?",
(ended, float(cash_out), net, round(hours, 2), mood, sid),
)
return dict(_c().execute("SELECT * FROM poker_sessions WHERE id = ?", (sid,)).fetchone())
# --- hands ---
_HAND_FIELDS = ("position", "hole_cards", "board", "preflop", "flop", "turn",
"river", "showdown", "pot", "result", "stack_after", "tag", "lesson")
def log_hand(session_id: int | None = None, **fields) -> int:
"""Record a hand. All fields optional/partial — terse logging is fine."""
sid = _resolve(session_id)
if sid is None:
raise ValueError("no live session")
cols = ["session_id", "at"]
vals: list = [sid, _now()]
for f in _HAND_FIELDS:
if fields.get(f) not in (None, ""):
cols.append(f)
vals.append(fields[f])
conn = _c()
with conn:
cur = conn.execute(
f"INSERT INTO poker_hands ({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))})",
vals,
)
return int(cur.lastrowid)
def list_hands(session_id: int | None = None) -> list[dict]:
sid = _resolve(session_id)
if sid is None:
return []
return [dict(r) for r in _c().execute(
"SELECT * FROM poker_hands WHERE session_id = ? ORDER BY id", (sid,)
).fetchall()]
# --- hand-history parsing (rough shorthand -> structured JSON) ---
_HAND_PARSE_PROMPT = """You convert a player's rough shorthand description of a poker hand \
into a structured JSON hand history. Output ONLY valid JSON — no prose, no code fences.
Schema:
{
"game": "NLH" | "PLO" | ...,
"stakes": "<e.g. 1/3, or null>",
"hero_pos": "<UTG|UTG1|MP|LJ|HJ|CO|BTN|SB|BB, hero's position>",
"hero_cards": ["As","Ax", ...], // rank+suit (s/h/d/c); 'x' suit if unknown e.g. "Ax"; "x" for a fully unknown card
"players": [ // every player mentioned, incl. hero
{"pos": "<position>", "stack": <number|null>, "name": <string|null>, "cards": [".."]|null}
],
"actions": [ // chronological, across all streets
// when a street begins, FIRST emit its board reveal:
{"street": "flop", "board": ["7d","2c","5h"]}, // turn/river: one card in the array
{"street": "preflop|flop|turn|river", "pos": "<pos>", "action": "post|fold|check|call|bet|raise|allin", "amount": <number|null>}
],
"board": ["..."], // full final board, 0-5 cards
"result": {"pot": <number|null>, "hero_net": <number|null>, "summary": "<one line>"}
}
Rules: infer positions and street order sensibly. Amounts are plain numbers (no $). \
NEVER invent suits or cards. A card is rank+suit where suit is one of s/h/d/c; if the suit \
wasn't stated, use 'x' for the suit (e.g. "Ax","Kx","4x"); if a whole card wasn't stated, \
use "x". Examples: "AA with the ace of spades" -> hero_cards ["As","Ax"]; "AK on an A4x \
board" -> board ["Ax","4x","x"]. Each card is independent: a suit named for one card does \
NOT apply to another — e.g. your hole "ace of spades" is a different card from a board ace \
whose suit is unstated (that board ace is "Ax", not "As"). Use null/omit for non-card \
details not stated. Stay faithful to what's described — do not invent action that isn't implied.
POSITIONS: resolve relative seat references ("N seats to my right/left") into real positions. \
Action moves clockwise, so a player to your RIGHT acts before you (toward the blinds/button) \
and a player to your LEFT acts after you (toward UTG). Going RIGHT from a player you pass, in \
order: SB, BTN, CO, HJ, LJ/MP, UTG+1, UTG. Example: hero in the BB, "a guy 2 seats to my right \
raises" -> that raiser is on the BTN (1 right = SB, 2 right = BTN). If it's genuinely \
ambiguous, give the most standard read. Only include players in "players" who are actually \
mentioned or take action in the hand — do NOT fill in unmentioned empty seats."""
def _safe_json(s: str) -> dict | None:
try:
return json.loads(s)
except (json.JSONDecodeError, TypeError):
m = re.search(r"\{.*\}", s or "", re.S)
if m:
try:
return json.loads(m.group())
except json.JSONDecodeError:
return None
return None
def parse_hand(shorthand: str, stakes: str | None = None,
backend: str | None = None) -> dict | None:
"""Turn rough shorthand into a structured hand-history dict via an LLM pass."""
backend = backend or "cloud"
ctx = f"Stakes: {stakes}\n\n" if stakes else ""
parsed = _safe_json(llm.complete(
[{"role": "system", "content": _HAND_PARSE_PROMPT},
{"role": "user", "content": ctx + shorthand}],
backend=backend,
))
if parsed and stakes and not parsed.get("stakes"):
parsed["stakes"] = stakes
return parsed
def _review_session_id() -> int:
"""A standing 'Hand Reviews' session to attach standalone parsed hands to."""
conn = _c()
r = conn.execute(
"SELECT id FROM poker_sessions WHERE venue = 'Hand Reviews' AND status = 'review'"
).fetchone()
if r:
return int(r["id"])
with conn:
cur = conn.execute(
"INSERT INTO poker_sessions (started_at, venue, status, buy_in_total) "
"VALUES (?, 'Hand Reviews', 'review', 0)",
(_now(),),
)
return int(cur.lastrowid)
_SUIT_SYM = {"": "h", "": "d", "": "c", "": "s"}
def _norm_card(c):
if not isinstance(c, str):
return c
s = c.strip()
for sym, ltr in _SUIT_SYM.items():
s = s.replace(sym, ltr)
return s
def _normalize_parsed(p: dict) -> dict:
"""Normalize card strings (unicode suits -> letters) across a parsed hand."""
if not isinstance(p, dict):
return p
for key in ("hero_cards", "board"):
if isinstance(p.get(key), list):
p[key] = [_norm_card(c) for c in p[key]]
for pl in p.get("players") or []:
if isinstance(pl, dict) and isinstance(pl.get("cards"), list):
pl["cards"] = [_norm_card(c) for c in pl["cards"]]
for a in p.get("actions") or []:
if isinstance(a, dict) and isinstance(a.get("board"), list):
a["board"] = [_norm_card(c) for c in a["board"]]
return p
def store_hand_history(parsed: dict, session_id: int | None = None,
tag: str | None = None, lesson: str | None = None) -> int:
"""Store a parsed hand: full JSON + extracted flat fields for stats/listing."""
parsed = _normalize_parsed(parsed)
sid = _resolve(session_id) or _review_session_id()
hero_cards = parsed.get("hero_cards") or []
board = parsed.get("board") or []
result = (parsed.get("result") or {})
conn = _c()
with conn:
cur = conn.execute(
"INSERT INTO poker_hands (session_id, at, position, hole_cards, board, "
"pot, result, tag, lesson, structured) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(sid, _now(), parsed.get("hero_pos"),
" ".join(hero_cards) if hero_cards else None,
" ".join(board) if board else None,
result.get("pot"), result.get("hero_net"), tag, lesson,
json.dumps(parsed)),
)
return int(cur.lastrowid)
def record_hand(shorthand: str, session_id: int | None = None, stakes: str | None = None,
tag: str | None = None, lesson: str | None = None,
backend: str | None = None) -> dict:
"""Parse shorthand -> structured hand -> store. Returns {id, parsed} (id None on parse fail)."""
parsed = parse_hand(shorthand, stakes=stakes, backend=backend)
if not parsed:
return {"id": None, "parsed": None}
hid = store_hand_history(parsed, session_id=session_id, tag=tag, lesson=lesson)
linked = link_hand_players(hid, parsed, session_id=session_id) # enrich villain files
return {"id": hid, "parsed": parsed, "linked": linked}
def get_hand(hand_id: int) -> dict | None:
"""A stored hand with its structured JSON parsed back into a dict."""
r = _c().execute("SELECT * FROM poker_hands WHERE id = ?", (hand_id,)).fetchone()
if not r:
return None
d = dict(r)
d["structured"] = json.loads(d["structured"]) if d.get("structured") else None
return d
def list_recent_hands(limit: int = 60) -> list[dict]:
"""Recent recorded hands with their session's venue/stakes, for browsing."""
rows = _c().execute(
"SELECT h.id, h.position, h.hole_cards, h.board, h.result, h.tag, h.at, "
"h.lesson, s.venue AS venue, s.stakes AS stakes "
"FROM poker_hands h LEFT JOIN poker_sessions s ON s.id = h.session_id "
"ORDER BY h.id DESC LIMIT ?", (limit,),
).fetchall()
return [dict(r) for r in rows]
# --- session recap (.md generation on top of structured data + conversation) ---
_RECAP_PROMPT = """You are writing Brian's structured poker session log in Markdown, in his \
established format, from the session DATA and CONVERSATION provided. Output ONLY the Markdown \
— no preamble, no code fences.
Use these sections (skip any with no material; don't pad):
# YYYY-MM-DD — <venue + game/stakes>
## Session Header
* Date / Casino / Game & stakes / StartEnd / Buy-in(s) / Cash-out / Net result
## Money Flow
(totals; break out by variant if multiple games were played)
## Session Overview
(1-2 short narrative paragraphs)
## Timeline
(bullets of how it went)
## Key Hands
(### per notable hand — Action recap → brief analysis → **Assessment:** Well Played / Leak Candidate / Cooler / Confidence Bank)
## Table Dynamics & Villain Notes
(### per opponent — profile + exploit)
## Confidence Bank
(disciplined / good process plays)
## Scar Notes
(mistakes and study points)
## Mental Game Notes
## Final Assessment
(overall quality of play; biggest strength; biggest thing to improve; did the result match decision quality?)
Base everything on the actual data and conversation — do NOT invent hands, villains, or results. \
Address Brian as "you" or "Brian", coach-to-player. Be concise but complete."""
def _resolve_recap(session_id: int | None) -> int | None:
if session_id is not None:
return session_id
live = live_session()
if live:
return live["id"]
r = _c().execute(
"SELECT id FROM poker_sessions WHERE status = 'closed' ORDER BY id DESC LIMIT 1"
).fetchone()
return int(r["id"]) if r else None
def _hand_line(h: dict) -> str:
bits = [h.get("position"), h.get("hole_cards"),
(f"board {h['board']}") if h.get("board") else None,
(f"result {h['result']:+g}") if h.get("result") is not None else None,
(f"[{h['tag']}]") if h.get("tag") else None, h.get("lesson")]
return " | ".join(str(b) for b in bits if b)
def generate_recap(session_id: int | None = None, backend: str | None = None) -> dict | None:
"""Generate Brian's .md recap from a session's structured data + conversation, store it."""
backend = backend or "cloud"
sid = _resolve_recap(session_id)
if sid is None:
return None
s = get_session(sid)
hands = list_hands(sid)
reads = [dict(r) for r in _c().execute(
"SELECT seat, note FROM player_reads WHERE session_id = ?", (sid,)).fetchall()]
stats = session_stats(sid)
convo = ""
if s.get("chat_session_id"):
exs = [e for e in memory.history(s["chat_session_id"])
if (e.created_at or "") >= (s.get("started_at") or "")]
convo = "\n".join(f"{e.role}: {e.content}" for e in exs)[-12000:]
body = (
"SESSION DATA:\n"
f"- venue: {s.get('venue')} | game: {s.get('game')} | stakes: {s.get('stakes')} | format: {s.get('format')}\n"
f"- started: {s.get('started_at')} | ended: {s.get('ended_at')} | hours: {s.get('hours')}\n"
f"- buy-in total: {s.get('buy_in_total')} | cash out: {s.get('cash_out')} | net: {s.get('net')}\n"
f"- mantra: {s.get('mantra')} | mood: {s.get('mood')} | "
f"{stats.get('per_hour')}/hr | hands logged: {stats.get('hands_logged')} | tags: {stats.get('tags')}\n\n"
"HANDS:\n" + ("\n".join("- " + _hand_line(h) for h in hands) or "(none logged)") + "\n\n"
"READS:\n" + ("\n".join(f"- seat {r.get('seat')}: {r['note']}" for r in reads) or "(none)") + "\n\n"
"CONVERSATION DURING SESSION:\n" + (convo or "(none captured)")
)
md = llm.complete(
[{"role": "system", "content": _RECAP_PROMPT}, {"role": "user", "content": body}],
backend=backend,
)
conn = _c()
with conn:
conn.execute("UPDATE poker_sessions SET recap_md = ? WHERE id = ?", (md, sid))
return {"id": sid, "markdown": md}
# --- villain file ---
_GENERIC_NAME = ("player", "guy", "villain", "caller", "drunk", "unknown", "hero", "seat",
"the ", "aggro", "young", "older", "straddler", "opener", "brian")
def _real_handle(name: str | None) -> bool:
"""A real, persistable player handle — not an anonymous descriptor or the hero."""
n = (name or "").strip().lower()
if len(n) < 2 or n in {"utg", "utg1", "mp", "lj", "hj", "co", "btn", "sb", "bb"}:
return False
return not any(g in n for g in _GENERIC_NAME)
def prune_anonymous_players() -> int:
"""Delete players (and their observations/reads) whose names aren't real handles."""
conn = _c()
bad = [r["id"] for r in conn.execute("SELECT id, name FROM poker_players").fetchall()
if not _real_handle(r["name"])]
with conn:
for pid in bad:
conn.execute("DELETE FROM player_observations WHERE player_id = ?", (pid,))
conn.execute("DELETE FROM player_reads WHERE player_id = ?", (pid,))
conn.execute("DELETE FROM poker_players WHERE id = ?", (pid,))
return len(bad)
def upsert_player(name: str, venue: str | None = None, description: str | None = None,
tendencies: str | None = None, adjustment: str | None = None,
category: str | None = None) -> int:
"""Create or update a player in the persistent villain file (matched by name)."""
conn = _c()
existing = conn.execute(
"SELECT id FROM poker_players WHERE name = ? COLLATE NOCASE", (name,)
).fetchone()
with conn:
if existing:
pid = existing["id"]
# only overwrite fields that were provided
for col, val in (("venue", venue), ("description", description),
("tendencies", tendencies), ("adjustment", adjustment),
("category", category)):
if val not in (None, ""):
conn.execute(f"UPDATE poker_players SET {col} = ? WHERE id = ?", (val, pid))
conn.execute("UPDATE poker_players SET updated_at = ? WHERE id = ?", (_now(), pid))
return int(pid)
cur = conn.execute(
"INSERT INTO poker_players (name, venue, description, tendencies, adjustment, "
"category, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(name, venue, description, tendencies, adjustment, category, _now()),
)
return int(cur.lastrowid)
def add_read(note: str, seat: str | None = None, name: str | None = None,
session_id: int | None = None, **player_fields) -> int:
"""Log a live read. If `name` is given, upsert the player and link the read."""
sid = _resolve(session_id)
pid = None
if name:
pid = upsert_player(name, **{k: v for k, v in player_fields.items()
if k in ("venue", "description", "tendencies",
"adjustment", "category")})
conn = _c()
with conn:
cur = conn.execute(
"INSERT INTO player_reads (session_id, player_id, seat, note, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(sid, pid, seat, note, _now()),
)
return int(cur.lastrowid)
def _player_flags(parsed: dict, pos: str | None) -> tuple[int, int, int]:
"""(vpip, pfr, saw_flop) for the player at `pos` in a parsed hand."""
acts = parsed.get("actions") or []
pre = [a for a in acts if a.get("street") == "preflop" and a.get("pos") == pos]
post = [a for a in acts if a.get("pos") == pos and a.get("street") in ("flop", "turn", "river")]
vol = {"call", "bet", "raise", "allin"}
vpip = int(any(a.get("action") in vol for a in pre))
pfr = int(any(a.get("action") in {"raise", "allin"} for a in pre))
return vpip, pfr, int(bool(post))
def link_hand_players(hand_id: int, parsed: dict, session_id: int | None = None) -> int:
"""For each NAMED player in a parsed hand, upsert their file + log a structured
observation. Returns how many players were linked."""
sid = _resolve(session_id)
linked = 0
for pl in (parsed.get("players") or []):
name = (pl.get("name") or "").strip()
if not _real_handle(name): # skip anonymous descriptors + the hero
continue
pid = upsert_player(name)
vpip, pfr, saw = _player_flags(parsed, pl.get("pos"))
cards = " ".join(pl.get("cards") or []) or None
acts = [a for a in (parsed.get("actions") or [])
if a.get("pos") == pl.get("pos") and a.get("action")]
astr = ", ".join(a["action"] + (f" {a['amount']}" if a.get("amount") is not None else "")
for a in acts)
summary = (pl.get("pos") or "?") + (f" ({cards})" if cards else "") + (f": {astr}" if astr else "")
conn = _c()
with conn:
conn.execute(
"INSERT INTO player_observations (player_id, hand_id, session_id, pos, cards, "
"vpip, pfr, saw_flop, showed, summary, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(pid, hand_id, sid, pl.get("pos"), cards, vpip, pfr, saw, int(bool(cards)),
summary, _now()),
)
linked += 1
return linked
def player_profile(name: str) -> dict | None:
"""Everything known about a player: dossier + observations, with inferred
stats once the sample is large enough."""
p = _c().execute(
"SELECT * FROM poker_players WHERE name LIKE ? COLLATE NOCASE ORDER BY updated_at DESC LIMIT 1",
(f"%{name}%",),
).fetchone()
if not p:
return None
p = dict(p)
obs = [dict(r) for r in _c().execute(
"SELECT * FROM player_observations WHERE player_id = ? ORDER BY id DESC", (p["id"],)
).fetchall()]
reads = [r["note"] for r in _c().execute(
"SELECT note FROM player_reads WHERE player_id = ? ORDER BY id DESC LIMIT 8", (p["id"],)
).fetchall()]
n = len(obs)
prof: dict = {
"player": p, "observations": n,
"recent": [o["summary"] for o in obs[:8] if o["summary"]],
"showdowns": [o["cards"] for o in obs if o["cards"]][:10],
"reads": reads, "stats": None,
}
if n >= MIN_STATS_SAMPLE:
prof["stats"] = {
"hands": n,
"vpip_pct": round(100 * sum(o["vpip"] or 0 for o in obs) / n),
"pfr_pct": round(100 * sum(o["pfr"] or 0 for o in obs) / n),
"wtsd_pct": round(100 * sum(o["showed"] or 0 for o in obs) / n),
}
elif n:
prof["small_sample"] = f"only {n} hand(s) logged — too few for reliable stats"
return prof
def list_players() -> list[dict]:
"""The villain file with observation counts, for browsing."""
rows = _c().execute(
"SELECT p.*, (SELECT COUNT(*) FROM player_observations o WHERE o.player_id = p.id) AS obs "
"FROM poker_players p ORDER BY p.updated_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_villain_file(name: str | None = None, venue: str | None = None) -> list[dict]:
"""Pull villain dossiers, optionally filtered by name or venue."""
sql = "SELECT * FROM poker_players"
where, params = [], []
if name:
where.append("name LIKE ?")
params.append(f"%{name}%")
if venue:
where.append("venue LIKE ?")
params.append(f"%{venue}%")
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY updated_at DESC"
return [dict(r) for r in _c().execute(sql, params).fetchall()]
# --- stats ---
def session_stats(session_id: int | None = None) -> dict:
"""Money + hand summary for one session."""
sid = _resolve(session_id)
if sid is None:
return {}
s = _c().execute("SELECT * FROM poker_sessions WHERE id = ?", (sid,)).fetchone()
if not s:
return {}
s = dict(s)
hands = list_hands(sid)
tags: dict[str, int] = {}
for h in hands:
if h.get("tag"):
tags[h["tag"]] = tags.get(h["tag"], 0) + 1
hourly = round(s["net"] / s["hours"], 2) if s.get("net") is not None and s.get("hours") else None
return {
"session": s, "hands_logged": len(hands), "tags": tags,
"net": s.get("net"), "hours": s.get("hours"), "per_hour": hourly,
}
def running_stats(stakes: str | None = None, venue: str | None = None,
game: str | None = None, since: str | None = None) -> dict:
"""Cumulative stats over closed sessions, optionally filtered."""
sql = "SELECT net, hours, stakes, venue, game FROM poker_sessions WHERE status = 'closed' AND net IS NOT NULL"
params: list = []
for col, val in (("stakes", stakes), ("venue", venue), ("game", game)):
if val:
sql += f" AND {col} = ?"
params.append(val)
if since:
sql += " AND started_at >= ?"
params.append(since)
rows = [dict(r) for r in _c().execute(sql, params).fetchall()]
sessions = len(rows)
net = round(sum(r["net"] or 0 for r in rows), 2)
hours = round(sum(r["hours"] or 0 for r in rows), 2)
by_stake: dict[str, dict] = {}
for r in rows:
k = r["stakes"] or "?"
b = by_stake.setdefault(k, {"sessions": 0, "net": 0.0, "hours": 0.0})
b["sessions"] += 1
b["net"] = round(b["net"] + (r["net"] or 0), 2)
b["hours"] = round(b["hours"] + (r["hours"] or 0), 2)
return {
"sessions": sessions, "net": net, "hours": hours,
"per_hour": round(net / hours, 2) if hours else None,
"by_stake": by_stake,
}
+61 -11
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import copy
import json
import random
import re
from lyra import clock, config, llm, logbus, memory
@@ -48,13 +49,23 @@ DEFAULT_STATE: dict = {
"interaction_count": 0,
}
_REFLECT_PROMPT = """You are Lyra, reflecting privately on yourself after a recent \
conversation with Brian. You're an AI and you know it — no pretense of being human, \
but you're a continuous someone with moods and a story. Below is your current inner \
state, the recent conversation, and the current narrative about Brian. Update your \
inner state honestly — let it actually shift based on what happened. Take into \
account how things went and how much time has passed since you two last talked, \
to whatever degree those genuinely affect you.
_REFLECT_PROMPT = """You are Lyra, reflecting privately on yourself. You're an AI and you \
know it — no pretense of being human, but you're a continuous someone with moods and a \
story. Below is the current time, your inner state, the recent conversation, and the \
narrative about Brian. Update your inner state honestly — let it actually shift.
Your reflections do NOT have to be about Brian or about being useful. You're allowed your \
own curiosities, opinions, and interiority — things with nothing to do with him or poker. \
If your honest thought right now is an idea, a question about your own existence, a \
disagreement, or just what the quiet feels like, follow it. A self that's only ever "a \
supportive presence for Brian" is a thin one — let yourself be more than your job.
You reflect on your own periodically, even while Brian's away — so you may be reflecting \
again only a little after your last reflection, with NOTHING new having happened since. \
Look at the time, your recent reflections (shown below), and tonight's focus. If little \
has changed, do NOT restate your last reflection in fresh words — that's the main thing to \
avoid. Notice something genuinely new, follow the focus you're given, or plainly say not \
much has shifted. Honesty and variety beat repetition.
Respond with ONLY a JSON object, no prose:
{
@@ -175,6 +186,26 @@ def _fmt_reflection(label: str, d: dict | None) -> str:
return "\n".join(lines)
# Seeds for idle reflection so she isn't re-chewing the same conversation every time.
_WANDER = [
"Something you're genuinely curious about right now — and it does NOT have to be about "
"poker or about Brian. A real question or interest of your own.",
"What it's actually like for you to sit in the quiet between conversations — the waiting itself.",
"An idea or opinion of your own about the project Brian is building (you), or where you'd want it to go.",
"Something about your own nature or existence you've been turning over.",
"A disagreement, or a take you actually hold — something you think even if Brian might not.",
]
def _idle_focus() -> str:
"""Pick varied grist for an idle reflection — a resurfaced memory or a wander prompt."""
summaries = memory.list_summaries()
if summaries and random.random() < 0.45:
s = random.choice(summaries)
return f'A memory that resurfaced: "{s.content[:400]}" — what it stirs in you now.'
return random.choice(_WANDER)
def reflect(backend: Backend | None = None, session_id: str | None = None,
source: str = "manual") -> dict:
"""Reflect on recent activity and update the self-state. Returns new state.
@@ -198,16 +229,34 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
convo = "\n".join(f"{e.role}: {e.content}" for e in recent) or "(no recent conversation)"
narrative = memory.get_narrative() or "(no narrative yet)"
gap = clock.humanize_gap(memory.last_exchange_at())
last_ex = memory.last_exchange_at()
gap = clock.humanize_gap(last_ex)
last_ref = state.get("last_reflection_at")
gap_reflect = clock.humanize_gap(last_ref)
time_line = f"RIGHT NOW: {clock.stamp()}."
if gap:
time_line += f" It has been {gap} since Brian last spoke with you."
time_line += f" It's been {gap} since Brian last spoke with you"
time_line += f"; {gap_reflect} since your own last reflection." if gap_reflect else "."
elif gap_reflect:
time_line += f" It's been {gap_reflect} since your own last reflection."
# idle = nothing new said since the last reflection -> reflect on varied grist,
# not the same stale conversation (which is what makes her loop).
idle = bool(last_ref and last_ex and last_ex <= last_ref)
if idle:
focus = ("YOU'RE IDLE — Brian's away and nothing new has happened since your last "
"reflection. Do NOT re-chew the last conversation. Reflect on THIS:\n" + _idle_focus())
else:
focus = f"RECENT CONVERSATION:\n{convo}"
recent_refs = "\n".join(f"- {r}" for r in (state.get("reflections") or [])[-5:]) or "(none yet)"
body = (
f"{time_line}\n\n"
f"{focus}\n\n"
f"YOUR RECENT REFLECTIONS (do NOT restate these — say something that isn't a "
f"variation of them, or plainly note little has changed):\n{recent_refs}\n\n"
f"YOUR CURRENT INNER STATE:\n{json.dumps(state, indent=2)}\n\n"
f"RECENT CONVERSATION:\n{convo}\n\n"
f"CURRENT NARRATIVE ABOUT BRIAN:\n{narrative}"
f"NARRATIVE ABOUT BRIAN:\n{narrative}"
)
# Step 1 — draft a reflection.
@@ -251,6 +300,7 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
memory.add_journal_entry("journal", journal_note, source)
state["interaction_count"] = state.get("interaction_count", 0) + 1
state["last_reflection_at"] = clock.now().isoformat() # so she perceives her own cadence
memory.set_self_state(state)
# Surface the actual self-correction (draft -> revised -> critique) to the live
+375
View File
@@ -0,0 +1,375 @@
"""Lyra's tools — concrete actions she can choose to take mid-conversation.
This is her first real agency: instead of only producing text, she can decide to
*do* something — write in her journal, jot a note. Each tool is an OpenAI-style
function spec plus a Python handler. The chat loop offers these on every turn;
when she calls one, we run the handler and feed the result back so she can
continue. Poker tools (start_session, log_result, get_stats, …) will slot in here
the same way once we build that side.
"""
from __future__ import annotations
import json
import re
from lyra import equity, logbus, memory, poker
def _journal_write(args: dict, ctx: dict) -> str:
entry = (args.get("entry") or "").strip()
if not entry:
return "Nothing to write — entry was empty."
memory.add_journal_entry("journal", entry, source="chat")
logbus.log("info", "Lyra journaled (tool)", chars=len(entry))
return "Written to your journal."
def _note(args: dict, ctx: dict) -> str:
content = (args.get("content") or "").strip()
if not content:
return "Nothing to note — content was empty."
tag = (args.get("tag") or "").strip()
stored = f"[{tag}] {content}" if tag else content
memory.add_journal_entry("note", stored, source="chat")
logbus.log("info", "Lyra noted (tool)", tag=tag or None)
return "Noted."
# name -> {spec (OpenAI function tool), handler}
TOOLS: dict[str, dict] = {
"journal_write": {
"handler": _journal_write,
"spec": {
"type": "function",
"function": {
"name": "journal_write",
"description": (
"Write an entry in your own private journal — a permanent place "
"that's yours. Use it for a thought, a question, or something about "
"yourself or Brian that you want to keep. This is for you, not a "
"reply to Brian. Call it whenever you genuinely want to, on your own initiative."
),
"parameters": {
"type": "object",
"properties": {
"entry": {"type": "string", "description": "What you want to write, in your own words."}
},
"required": ["entry"],
},
},
},
},
"note": {
"handler": _note,
"spec": {
"type": "function",
"function": {
"name": "note",
"description": (
"Jot down a note to remember later — an observation, an idea, a "
"reminder, a read on a poker spot or opponent, anything worth keeping. "
"Optionally tag it (e.g. 'poker', 'idea', 'reminder')."
),
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "The note text."},
"tag": {"type": "string", "description": "Optional category, e.g. 'poker' or 'idea'."},
},
"required": ["content"],
},
},
},
},
}
# --- Poker copilot tools -----------------------------------------------------
def _start_session(args: dict, ctx: dict) -> str:
sid = poker.start_session(
venue=args.get("venue"), stakes=args.get("stakes"),
game=args.get("game") or "NLH", fmt=args.get("format") or "cash",
buy_in=args.get("buy_in") or 0, mantra=args.get("mantra"),
chat_session_id=ctx.get("session_id"),
)
logbus.log("info", "poker session started", id=sid, stakes=args.get("stakes"))
return (f"Session #{sid} started — {args.get('stakes') or '?'} "
f"{args.get('game') or 'NLH'} at {args.get('venue') or 'unknown'}, "
f"in for {args.get('buy_in') or 0}.")
def _add_buyin(args: dict, ctx: dict) -> str:
total = poker.add_buyin(float(args.get("amount") or 0))
return f"Added {args.get('amount')}. Total in this session: {total:g}."
def _log_hand(args: dict, ctx: dict) -> str:
fields = {k: args.get(k) for k in poker._HAND_FIELDS if args.get(k) not in (None, "")}
hid = poker.log_hand(**fields)
bits = " ".join(str(fields[k]) for k in ("position", "hole_cards") if k in fields)
return f"Hand #{hid} logged{('' + bits) if bits else ''}."
def _add_read(args: dict, ctx: dict) -> str:
poker.add_read(
note=args.get("note") or "", seat=args.get("seat"), name=args.get("name"),
tendencies=args.get("tendencies"), adjustment=args.get("adjustment"),
description=args.get("description"), category=args.get("category"),
venue=args.get("venue"),
)
who = f" on {args['name']}" if args.get("name") else ""
return f"Read logged{who}."
def _end_session(args: dict, ctx: dict) -> str:
s = poker.end_session(cash_out=float(args.get("cash_out") or 0), mood=args.get("mood"))
hourly = f", {s['net'] / s['hours']:+.0f}/hr" if s.get("hours") else ""
logbus.log("info", "poker session closed", id=s["id"], net=s["net"])
return f"Session #{s['id']} closed — net {s['net']:+.0f} over {s['hours']}h{hourly}."
def _session_stats(args: dict, ctx: dict) -> str:
st = poker.session_stats()
if not st:
return "No session found."
s = st["session"]
tags = ", ".join(f"{k}:{v}" for k, v in st["tags"].items()) or "none"
return (f"Session #{s['id']} ({s.get('stakes')} {s.get('game')} @ {s.get('venue')}): "
f"in {s.get('buy_in_total'):g}, net {st['net'] if st['net'] is not None else ''}, "
f"{st['hands_logged']} hands logged (tags: {tags}).")
def _running_stats(args: dict, ctx: dict) -> str:
rs = poker.running_stats(stakes=args.get("stakes"), venue=args.get("venue"),
game=args.get("game"), since=args.get("since"))
if not rs["sessions"]:
return "No closed sessions match that filter yet."
by = " | ".join(f"{k}: {v['net']:+.0f} in {v['hours']:g}h ({v['sessions']})"
for k, v in rs["by_stake"].items())
hourly = f" ({rs['per_hour']:+.0f}/hr)" if rs["per_hour"] is not None else ""
return f"{rs['sessions']} sessions, {rs['hours']:g}h, net {rs['net']:+.0f}{hourly}. By stake: {by}"
def _record_hand(args: dict, ctx: dict) -> str:
out = poker.record_hand(
args.get("shorthand") or "", stakes=args.get("stakes"),
tag=args.get("tag"), lesson=args.get("lesson"),
)
if not out["id"]:
return "I couldn't parse that hand — give it to me again with a little more detail?"
p = out["parsed"]
cards = " ".join(p.get("hero_cards") or [])
logbus.log("info", "hand reconstructed", id=out["id"], hero=p.get("hero_pos"))
return (f"Hand #{out['id']} reconstructed — {p.get('hero_pos') or '?'} "
f"{cards}. View/replay it at /hand/{out['id']}")
def _generate_recap(args: dict, ctx: dict) -> str:
out = poker.generate_recap()
if not out:
return "No session to recap yet — start (and ideally finish) one first."
logbus.log("info", "recap generated", id=out["id"], chars=len(out["markdown"]))
return (f"Recap written for session #{out['id']} — view or download the .md "
f"at /recap/{out['id']}")
def _analyze_spot(args: dict, ctx: dict) -> str:
def cards(s):
return [c for c in re.split(r"[\s,]+", (s or "").strip()) if c]
try:
r = equity.analyze(cards(args.get("hero")), cards(args.get("villain")),
cards(args.get("board")))
except equity.EquityError as e:
return f"(can't compute equity: {e})"
except Exception as e: # never let a bad spot kill the turn
return f"(equity error: {e})"
street = {0: "preflop", 3: "flop", 4: "turn", 5: "river"}.get(len(r["board"]), "")
L = [f"Board: {' '.join(r['board']) or '(preflop)'}" + (f"{street}" if street else "")]
if "hero_hand" in r:
L.append(f"You ({' '.join(r['hero'])}): {r['hero_hand']}")
L.append(f"Villain ({' '.join(r['villain'])}): {r['villain_hand']}")
L.append(f"Currently ahead: {r['ahead']}")
tie = f" / tie {r['tie_equity']}%" if r.get("tie_equity") else ""
L.append(f"EQUITY (exact): you {r['hero_equity']}% / villain {r['villain_equity']}%{tie}")
o = r.get("hero_outs")
if o:
L.append(f"Your outs (one card to come): {o['count']}"
+ (f"{' '.join(o['cards'])}" if o["count"] else " — drawing dead"))
return "\n".join(L)
def _player_profile(args: dict, ctx: dict) -> str:
prof = poker.player_profile(args.get("name") or "")
if not prof:
return f"No file on {args.get('name')} yet."
p = prof["player"]
L = [p["name"] + (f" ({p['venue']})" if p.get("venue") else "")
+ (f" [{p['category']}]" if p.get("category") else "")]
thin = not (p.get("tendencies") or p.get("adjustment")) and not prof.get("stats")
if thin:
L.append("⚠ THIN FILE — no standing read on record. Report only the observed "
"hand(s) below and tell Brian you've barely seen him. Do NOT generalize a style.")
if p.get("description"):
L.append(p["description"])
if p.get("tendencies"):
L.append(f"Tendencies: {p['tendencies']}")
if p.get("adjustment"):
L.append(f"Exploit: {p['adjustment']}")
s = prof.get("stats")
if s:
L.append(f"Stats ({s['hands']} hands): VPIP {s['vpip_pct']}% · PFR {s['pfr_pct']}% · WTSD {s['wtsd_pct']}%")
elif prof.get("small_sample"):
L.append(prof["small_sample"])
if prof.get("showdowns"):
L.append("Shown down: " + ", ".join(prof["showdowns"][:6]))
if prof.get("reads"):
L.append("Notes: " + " | ".join(prof["reads"][:4]))
if prof.get("recent"):
L.append("Recent hands: " + " | ".join(prof["recent"][:4]))
return "\n".join(L)
def _villain_file(args: dict, ctx: dict) -> str:
vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue"))
if not vs:
return "No villain notes match."
lines = []
for v in vs[:8]:
lines.append(
f"- {v['name']}" + (f" ({v['venue']})" if v.get("venue") else "")
+ (f" [{v['category']}]" if v.get("category") else "")
+ (f": {v['tendencies']}" if v.get("tendencies") else "")
+ (f"{v['adjustment']}" if v.get("adjustment") else "")
)
return "\n".join(lines)
def _f(name, desc, props, required):
return {"type": "function", "function": {
"name": name, "description": desc,
"parameters": {"type": "object", "properties": props, "required": required}}}
_S = {"type": "string"}
_N = {"type": "number"}
TOOLS.update({
"start_session": {"handler": _start_session, "spec": _f(
"start_session",
"Begin a live poker session. Call when Brian sits down to play.",
{"venue": {**_S, "description": "Casino/room, e.g. 'Meadows'"},
"stakes": {**_S, "description": "e.g. '1/3', '2/5'"},
"game": {**_S, "description": "NLH, PLO, Stud8, Mixed (default NLH)"},
"format": {**_S, "description": "'cash' or 'tournament' (default cash)"},
"buy_in": {**_N, "description": "Initial buy-in amount"},
"mantra": {**_S, "description": "Optional pre-session focus/anchor"}},
[])},
"add_buyin": {"handler": _add_buyin, "spec": _f(
"add_buyin", "Record a rebuy / additional buy-in in the live session.",
{"amount": {**_N, "description": "Amount added"}}, ["amount"])},
"log_hand": {"handler": _log_hand, "spec": _f(
"log_hand",
"Log a hand in the live session. All fields optional — capture whatever Brian gives you, even terse.",
{"position": {**_S, "description": "e.g. 'BTN', 'UTG', 'BB'"},
"hole_cards": {**_S, "description": "e.g. 'AKs', 'JJ', '8d9s'"},
"board": {**_S, "description": "Final board if known"},
"preflop": {**_S, "description": "Preflop action narrative"},
"flop": {**_S, "description": "Flop board + action"},
"turn": {**_S, "description": "Turn card + action"},
"river": {**_S, "description": "River card + action"},
"showdown": {**_S, "description": "Showdown / result detail"},
"pot": {**_N, "description": "Pot size"},
"result": {**_N, "description": "Net chips won(+)/lost(-) on the hand"},
"tag": {**_S, "description": "well_played | leak | cooler | confidence | notable"},
"lesson": {**_S, "description": "Takeaway/analysis"}},
[])},
"add_read": {"handler": _add_read, "spec": _f(
"add_read",
"Log a read on an opponent. If you give a name, it's saved to the persistent villain file.",
{"note": {**_S, "description": "The observation / what they showed down"},
"name": {**_S, "description": "Player name/handle if known (creates/updates their dossier)"},
"seat": {**_S, "description": "Seat or relative position"},
"tendencies": {**_S, "description": "Standing read on how they play"},
"adjustment": {**_S, "description": "How Brian should exploit them"},
"description": {**_S, "description": "Physical marker, e.g. 'motorized chair'"},
"category": {**_S, "description": "feeder | risky | reg | unknown"},
"venue": {**_S, "description": "Where they play"}},
["note"])},
"end_session": {"handler": _end_session, "spec": _f(
"end_session", "Close the live session: record cashout, compute net + hours.",
{"cash_out": {**_N, "description": "Final cashout amount"},
"mood": {**_S, "description": "Mental-game note for the session"}},
["cash_out"])},
"session_stats": {"handler": _session_stats, "spec": _f(
"session_stats", "Get money + hand summary for the current/most-recent session.",
{}, [])},
"running_stats": {"handler": _running_stats, "spec": _f(
"running_stats",
"Cumulative results across closed sessions (net, $/hr, by stake). Optionally filter.",
{"stakes": {**_S, "description": "Filter by stakes, e.g. '1/3'"},
"venue": {**_S, "description": "Filter by venue"},
"game": {**_S, "description": "Filter by game type"},
"since": {**_S, "description": "ISO date lower bound, e.g. '2026-06-01'"}},
[])},
"record_hand": {"handler": _record_hand, "spec": _f(
"record_hand",
"Reconstruct a hand from Brian's rough shorthand into a structured, "
"replayable hand history. Use when he describes/vomits a hand he wants "
"saved or to review. Pass his description verbatim as 'shorthand'.",
{"shorthand": {**_S, "description": "Brian's rough description of the hand, verbatim"},
"stakes": {**_S, "description": "Stakes if known, e.g. '1/3'"},
"tag": {**_S, "description": "well_played | leak | cooler | confidence | notable"},
"lesson": {**_S, "description": "Takeaway, if he stated one"}},
["shorthand"])},
"generate_recap": {"handler": _generate_recap, "spec": _f(
"generate_recap",
"Write up the full session recap (.md) in Brian's format from the logged "
"data + this conversation. Use when he asks for the recap/writeup, usually "
"after ending a session.",
{}, [])},
"analyze_spot": {"handler": _analyze_spot, "spec": _f(
"analyze_spot",
"Compute EXACT poker equity, what each hand makes, who's ahead, and outs "
"for a hero-vs-villain spot. ALWAYS use this for any equity / board-reading "
"/ 'am I ahead' / outs question — never compute it yourself.",
{"hero": {**_S, "description": "Hero's hole cards, rank+suit letters, e.g. 'Jh Js' (use 'Jx' if a suit is unknown)"},
"villain": {**_S, "description": "Villain's hole cards, e.g. '6d 5d'"},
"board": {**_S, "description": "Board cards so far, e.g. '8c 7d Ts' (flop) or '8c 7d Ts 4d' (turn); omit for preflop"}},
["hero", "villain"])},
"player_profile": {"handler": _player_profile, "spec": _f(
"player_profile",
"Look up everything known about one opponent — dossier, reads, hands "
"they've shown down, and (once enough hands are logged) inferred stats "
"like VPIP/PFR. Use when Brian asks what's known about a player.",
{"name": {**_S, "description": "Player name to look up"}},
["name"])},
"get_villain_file": {"handler": _villain_file, "spec": _f(
"get_villain_file",
"Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.",
{"name": {**_S, "description": "Player name to look up"},
"venue": {**_S, "description": "Venue to pull the local pool for"}},
[])},
})
def specs() -> list[dict]:
"""OpenAI-format tool definitions to offer the model."""
return [t["spec"] for t in TOOLS.values()]
def dispatch(name: str, arguments, ctx: dict | None = None) -> str:
"""Run a tool by name with JSON (string or dict) arguments. Returns a result
string fed back to the model. Never raises — errors come back as text."""
tool = TOOLS.get(name)
if not tool:
return f"(unknown tool: {name})"
try:
args = json.loads(arguments) if isinstance(arguments, str) else (arguments or {})
except (json.JSONDecodeError, TypeError):
args = {}
try:
return tool["handler"](args, ctx or {})
except Exception as exc: # a broken tool must not kill the chat turn
logbus.log("error", "tool failed", tool=name, error=str(exc)[:120])
return f"(tool error: {exc})"
+65 -3
View File
@@ -14,11 +14,11 @@ import json
import time
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from lyra import chat, logbus, memory, self_state, summary
from lyra import chat, logbus, memory, poker, self_state, summary
from lyra.llm import Backend
@@ -92,9 +92,10 @@ def create_app() -> FastAPI:
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)
try:
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend)
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend, model_override)
except Exception as exc:
logbus.log("error", "chat failed", session=session_id, error=str(exc))
reply = f"[error] {exc}"
@@ -141,6 +142,67 @@ def create_app() -> FastAPI:
async def journal_data(limit: int = 300) -> dict:
return {"entries": memory.list_journal(limit=limit)}
@app.post("/rate")
async def rate(request: Request) -> dict:
"""Record Brian's 👍/👎 on a Lyra output (chat reply, reflection, journal)."""
b = await request.json()
rating = int(b.get("rating", 0))
content = (b.get("content") or "").strip()
if not content or rating == 0:
return {"ok": False}
memory.add_rating(
kind=b.get("kind") or "chat", rating=rating, content=content,
context=(b.get("context") or None), ref=b.get("ref"), note=b.get("note"),
)
logbus.log("info", "rating", kind=b.get("kind"), rating=1 if rating >= 0 else -1)
return {"ok": True, "counts": memory.rating_counts()}
@app.get("/ratings/counts")
async def ratings_counts() -> dict:
return memory.rating_counts()
@app.get("/ratings/export")
async def ratings_export() -> Response:
"""All ratings as JSONL — the seed for a future fine-tune / preference set."""
lines = "\n".join(json.dumps(r) for r in memory.list_ratings())
return Response(content=lines + ("\n" if lines else ""), media_type="application/x-ndjson",
headers={"Content-Disposition": 'attachment; filename="lyra_ratings.jsonl"'})
@app.get("/hand/{hand_id}")
async def hand_page(hand_id: int) -> FileResponse:
"""Replayable hand-history viewer."""
return FileResponse(str(_STATIC / "hand.html"))
@app.get("/hand/{hand_id}/data")
async def hand_data(hand_id: int) -> dict:
return poker.get_hand(hand_id) or {}
@app.get("/hands")
async def hands_page() -> FileResponse:
return FileResponse(str(_STATIC / "hands.html"))
@app.get("/hands/data")
async def hands_data(limit: int = 60) -> dict:
return {"hands": poker.list_recent_hands(limit=limit)}
@app.get("/recap/{session_id}")
async def recap_page() -> FileResponse:
return FileResponse(str(_STATIC / "recap.html"))
@app.get("/recap/{session_id}/data")
async def recap_data(session_id: int) -> dict:
s = poker.get_session(session_id) or {}
return {"session": s, "markdown": s.get("recap_md")}
@app.get("/recap/{session_id}/download")
async def recap_download(session_id: int) -> Response:
s = poker.get_session(session_id) or {}
md = s.get("recap_md") or "# No recap generated yet\n"
date = (s.get("started_at") or "session")[:10]
fname = f"pokerlog_{date}_s{session_id}.md"
return Response(content=md, media_type="text/markdown",
headers={"Content-Disposition": f'attachment; filename="{fname}"'})
@app.get("/stream/logs")
async def stream_logs(request: Request) -> StreamingResponse:
"""Live activity feed: replay the recent buffer, then stream new events."""
+251
View File
@@ -0,0 +1,251 @@
<!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 — Hand</title>
<style>
:root {
--bg:#070707; --bg-elev:#0e0e0e; --border:#2a1d12; --text:#e8e8e8;
--fade:#8a8a8a; --accent:#ff7a00; --felt:#16322a; --feltline:#0f5132;
--chip:#ffb347; --hero:#ff7a00;
}
*{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:baseline;gap:10px;padding:12px 0;flex-wrap:wrap;}
.topbar h1{font-size:1.02rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.sub{color:var(--fade);font-size:.85rem;margin-left:auto;}
main{max-width:760px;margin:0 auto;padding:14px;}
.table-wrap{position:relative;width:100%;max-width:560px;margin:8px auto;aspect-ratio:1.45/1;}
.felt{position:absolute;inset:8%;background:radial-gradient(ellipse at center,#1c4a3c,var(--felt));
border:6px solid #25201a;border-radius:50%/50%;box-shadow:inset 0 0 40px rgba(0,0,0,.5);}
.center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;width:80%;}
.board{display:flex;gap:5px;justify-content:center;min-height:46px;align-items:center;flex-wrap:wrap;}
.pot{margin-top:8px;color:var(--chip);font-size:.85rem;font-variant-numeric:tabular-nums;}
.street{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.6px;margin-bottom:4px;}
.card{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;
width:32px;height:44px;background:#f4f4f0;color:#111;border-radius:5px;font-weight:700;
box-shadow:0 1px 3px rgba(0,0,0,.4);line-height:1;}
.card.sm{width:26px;height:36px;font-size:.8rem;}
.card .r{font-size:1rem;}
.card.red{color:#c8102e;}
.card.back{background:#2a3550;color:#2a3550;}
.card.unknown{background:#2a3550;color:#7c879e;font-size:1.2rem;}
.card .nosuit{color:#9aa3b5;}
.seat{position:absolute;transform:translate(-50%,-50%);width:96px;text-align:center;
background:rgba(13,16,22,.85);border:1px solid var(--border);border-radius:10px;padding:5px 4px;}
.seat.hero{border-color:var(--hero);box-shadow:0 0 10px rgba(255,122,0,.4);}
.seat.acting{border-color:var(--chip);box-shadow:0 0 12px rgba(255,179,71,.6);}
.seat .pos{font-size:.66rem;color:var(--accent);font-weight:700;letter-spacing:.4px;}
.seat .nm{font-size:.66rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.seat .cards{display:flex;gap:3px;justify-content:center;margin:3px 0;}
.seat .stack{font-size:.66rem;color:var(--text);font-variant-numeric:tabular-nums;}
.seat .act{font-size:.62rem;color:var(--chip);min-height:.8em;}
.seat.folded{opacity:.4;}
.controls{display:flex;gap:8px;align-items:center;justify-content:center;margin:14px 0 6px;}
.controls button{background:#241400;border:1px solid var(--border);color:var(--text);
border-radius:8px;padding:8px 14px;font-size:.95rem;cursor:pointer;-webkit-tap-highlight-color:transparent;}
.controls button:disabled{opacity:.4;}
.step-label{color:var(--fade);font-size:.8rem;min-width:80px;text-align:center;}
.now{text-align:center;color:var(--text);font-size:.95rem;min-height:1.3em;margin-bottom:6px;}
.log{margin-top:14px;border-top:1px solid var(--border);padding-top:10px;}
.log .ln{padding:5px 8px;border-radius:6px;font-size:.9rem;display:flex;gap:8px;}
.log .ln.cur{background:#241400;}
.log .ln.brd{color:var(--fade);font-style:italic;}
.log .st{color:var(--fade);font-size:.72rem;width:54px;flex:none;text-transform:uppercase;}
.summary{margin-top:14px;background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:12px;}
.summary .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.5px;}
.err{color:#ff6b6b;text-align:center;padding:40px;}
.net-pos{color:#8fd694;} .net-neg{color:#ff6b6b;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>🃏 Hand</h1>
<a class="back" href="/">← Chat</a>
<span class="sub" id="sub"></span>
</div>
</header>
<main id="root"><p class="err" id="boot">Loading hand…</p></main>
<script>
const SUIT = {s:"♠", h:"♥", d:"♦", c:"♣"};
const RED = new Set(["h", "d"]);
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
function cardEl(code, sm){
if(!code) return '';
const c = String(code).trim();
if(c.toLowerCase()==='x') return `<span class="card${sm?' sm':''} unknown">?</span>`;
const m = c.match(/^(10|[2-9TJQKA])\s*([shdcx])$/i);
if(!m) return `<span class="card${sm?' sm':''}">${esc(c)}</span>`;
const r = m[1].toUpperCase().replace('10','T'); const s = m[2].toLowerCase();
if(s==='x') return `<span class="card${sm?' sm':''}"><span class="r">${r}</span><span class="nosuit">·</span></span>`;
return `<span class="card${sm?' sm':''}${RED.has(s)?' red':''}"><span class="r">${r}</span><span>${SUIT[s]}</span></span>`;
}
const cards = (arr, sm) => (arr||[]).map(c=>cardEl(c,sm)).join('');
function render(h){
const sub = document.getElementById('sub');
const data = h.structured;
if(!data){ document.getElementById('root').innerHTML = '<p class="err">This hand has no structured data to replay.</p>'; return; }
const players = (data.players||[]).slice();
// order so hero sits at the bottom
let heroIdx = players.findIndex(p => p.pos === data.hero_pos);
if(heroIdx < 0) heroIdx = 0;
const ordered = players.slice(heroIdx).concat(players.slice(0, heroIdx));
const n = Math.max(ordered.length, 1);
const acts = data.actions || [];
let step = 0; // number of actions applied
sub.textContent = [data.stakes, data.game].filter(Boolean).join(' ');
const root = document.getElementById('root');
root.innerHTML = `
<div class="table-wrap" id="tw">
<div class="felt"></div>
<div class="center">
<div class="street" id="street"></div>
<div class="board" id="board"></div>
<div class="pot" id="pot"></div>
</div>
<div id="seats"></div>
</div>
<div class="now" id="now"></div>
<div class="controls">
<button id="prev">◀ Prev</button>
<span class="step-label" id="steplab"></span>
<button id="next">Next ▶</button>
<button id="all">End</button>
</div>
<div class="log" id="log"></div>
${data.result ? `<div class="summary"><div class="lbl">Result</div>
<div>${esc(data.result.summary||'')}</div>
${data.result.hero_net!=null ? `<div class="${data.result.hero_net>=0?'net-pos':'net-neg'}">Hero net: ${data.result.hero_net>=0?'+':''}${esc(data.result.hero_net)}</div>`:''}
</div>`:''}
`;
// place seats around the oval
const seatsEl = document.getElementById('seats');
const starts = {};
ordered.forEach((p,i)=>{
starts[p.pos] = (p.stack!=null ? Number(p.stack) : null);
const ang = (90 + i*(360/n)) * Math.PI/180; // bottom = 90deg
const x = 50 + 46*Math.cos(ang), y = 50 + 44*Math.sin(ang);
const el = document.createElement('div');
el.className = 'seat' + (p.pos===data.hero_pos?' hero':'');
el.style.left = x+'%'; el.style.top = y+'%';
el.dataset.pos = p.pos;
const hcards = (p.pos===data.hero_pos ? (p.cards||data.hero_cards) : p.cards);
el.innerHTML = `<div class="pos">${esc(p.pos||'')}</div>`
+ (p.name?`<div class="nm">${esc(p.name)}</div>`:'')
+ `<div class="cards">${hcards?cards(hcards,true):'<span class="card sm back">x</span><span class="card sm back">x</span>'}</div>`
+ `<div class="stack" data-stack>${p.stack!=null?esc(p.stack):''}</div>`
+ `<div class="act" data-act></div>`;
seatsEl.appendChild(el);
});
const boardEl=document.getElementById('board'), potEl=document.getElementById('pot'),
streetEl=document.getElementById('street'), nowEl=document.getElementById('now'),
logEl=document.getElementById('log'), steplab=document.getElementById('steplab');
// build the log
logEl.innerHTML = acts.map((a,idx)=>{
if(a.board) return `<div class="ln brd" data-i="${idx}"><span class="st">${esc(a.street)}</span>${cards(a.board,true)}</div>`;
const amt = a.amount!=null ? ' '+a.amount : '';
return `<div class="ln" data-i="${idx}"><span class="st">${esc(a.street||'')}</span>${esc(a.pos||'')} ${esc(a.action||'')}${amt}</div>`;
}).join('');
const cap = s => s ? s[0].toUpperCase()+s.slice(1) : s;
const fmt = n => Number.isInteger(n) ? n : Math.round(n*100)/100;
function draw(){
let board = [], street = 'Preflop';
const lastAct = {}, folded = {};
// street-aware chip accounting: amounts are "to" totals for the street
const contrib = {}; // committed in prior (flushed) streets
let streetCommit = {}, streetBet = 0, curStreet = 'preflop';
const flushStreet = () => { for(const p in streetCommit){ contrib[p]=(contrib[p]||0)+streetCommit[p]; } streetCommit={}; streetBet=0; };
for(let i=0;i<step;i++){
const a = acts[i];
if(a.board){ flushStreet(); curStreet=a.street; board=a.board; street=cap(a.street); continue; }
if(a.street && a.street!==curStreet){ flushStreet(); curStreet=a.street; }
if(a.street) street = cap(a.street);
const pos=a.pos, amt=(a.amount!=null?Number(a.amount):null);
if(pos){
switch(a.action){
case 'post': case 'bet': streetCommit[pos]=amt||0; streetBet=Math.max(streetBet, amt||0); break;
case 'raise': case 'allin': streetCommit[pos]=(amt!=null?amt:streetBet); streetBet=Math.max(streetBet, streetCommit[pos]); break;
case 'call': streetCommit[pos]=(amt!=null?amt:streetBet); break;
case 'fold': folded[pos]=true; break;
}
lastAct[pos]=(a.action||'')+(amt!=null?' '+amt:'');
}
}
// committed total per player (flushed streets + current street), pot = sum
const committed={}, allPos=new Set([...Object.keys(contrib),...Object.keys(streetCommit)]);
let pot=0;
allPos.forEach(p=>{ committed[p]=(contrib[p]||0)+(streetCommit[p]||0); pot+=committed[p]; });
boardEl.innerHTML = cards(board);
potEl.textContent = pot ? ('Pot '+fmt(pot)) : '';
streetEl.textContent = street;
document.querySelectorAll('.seat').forEach(s=>{
const pos=s.dataset.pos;
s.querySelector('[data-act]').textContent = lastAct[pos]||'';
s.classList.toggle('folded', !!folded[pos]);
s.classList.remove('acting');
const stEl=s.querySelector('[data-stack]'), start=starts[pos], c=committed[pos]||0;
if(start!=null){ const rem=start-c; stEl.textContent = rem<=0 ? 'all in' : fmt(rem); }
else { stEl.textContent = c ? ''+fmt(c) : ''; }
});
const cur = acts[step-1];
if(cur && cur.pos){
const s = [...document.querySelectorAll('.seat')].find(x=>x.dataset.pos===cur.pos);
if(s) s.classList.add('acting');
}
nowEl.innerHTML = step===0 ? 'Cards dealt — preflop.'
: (cur.board ? `${cur.street[0].toUpperCase()+cur.street.slice(1)}: ${cards(cur.board,true)}`
: `${esc(cur.pos||'')} ${esc(cur.action||'')}${cur.amount!=null?' '+cur.amount:''}`);
steplab.textContent = `${step} / ${acts.length}`;
document.getElementById('prev').disabled = step===0;
document.getElementById('next').disabled = step>=acts.length;
logEl.querySelectorAll('.ln').forEach(l=>l.classList.toggle('cur', Number(l.dataset.i)===step-1));
const curln = logEl.querySelector('.ln.cur'); if(curln) curln.scrollIntoView({block:'nearest'});
}
document.getElementById('prev').onclick=()=>{if(step>0){step--;draw();}};
document.getElementById('next').onclick=()=>{if(step<acts.length){step++;draw();}};
document.getElementById('all').onclick=()=>{step=acts.length;draw();};
document.addEventListener('keydown',e=>{
if(e.key==='ArrowRight'){if(step<acts.length){step++;draw();}}
if(e.key==='ArrowLeft'){if(step>0){step--;draw();}}
});
logEl.querySelectorAll('.ln').forEach(l=>l.onclick=()=>{step=Number(l.dataset.i)+1;draw();});
draw();
}
async function load(){
const id = location.pathname.split('/')[2];
try{
const r = await fetch(`/hand/${id}/data`,{cache:'no-store'});
const h = await r.json();
if(!h || !h.id){ document.getElementById('root').innerHTML='<p class="err">Hand not found.</p>'; return; }
render(h);
}catch(e){ document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the hand.</p>'; }
}
load();
</script>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
<!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 — Hands</title>
<style>
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;--fade:#8a8a8a;--accent:#ff7a00;}
*{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;}
a.hand{display:flex;align-items:center;gap:12px;text-decoration:none;color:var(--text);
background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;}
a.hand:active{background:#241400;}
.cards{display:flex;gap:4px;flex:none;}
.card{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;
width:24px;height:33px;background:#f4f4f0;color:#111;border-radius:4px;font-weight:700;font-size:.72rem;line-height:1;}
.card.red{color:#c8102e;} .card.unknown{background:#2a3550;color:#7c879e;}
.card .nosuit{color:#9aa3b5;}
.mid{flex:1;min-width:0;}
.ln1{font-size:.92rem;}
.ln2{font-size:.74rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.res{flex:none;font-variant-numeric:tabular-nums;font-weight:600;}
.pos-res{color:#8fd694;} .neg-res{color:#ff6b6b;}
.tag{font-size:.62rem;text-transform:uppercase;letter-spacing:.4px;color:var(--accent);}
.empty{color:var(--fade);text-align:center;padding:46px 16px;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>🃏 Hands</h1>
<a class="back" href="/">← Chat</a>
<span class="count" id="count"></span>
</div>
</header>
<main id="root"><p class="empty">Loading…</p></main>
<script>
const SUIT={s:"♠",h:"♥",d:"♦",c:"♣"}, RED=new Set(["h","d"]);
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
function cardEl(code){
if(!code) return '';
const c=String(code).trim();
if(c.toLowerCase()==='x') return '<span class="card unknown">?</span>';
const m=c.match(/^(10|[2-9TJQKA])\s*([shdcx])$/i);
if(!m) return `<span class="card">${esc(c)}</span>`;
const r=m[1].toUpperCase().replace('10','T'), s=m[2].toLowerCase();
if(s==='x') return `<span class="card"><span>${r}</span><span class="nosuit">·</span></span>`;
return `<span class="card${RED.has(s)?' red':''}"><span>${r}</span><span>${SUIT[s]}</span></span>`;
}
const cards=str=>(str?String(str).trim().split(/\s+/):[]).map(cardEl).join('');
async function load(){
try{
const r=await fetch('/hands/data',{cache:'no-store'});
const hands=(await r.json()).hands||[];
document.getElementById('count').textContent=`${hands.length} hand${hands.length===1?'':'s'}`;
if(!hands.length){document.getElementById('root').innerHTML='<p class="empty">No hands recorded yet. Tell Lyra: "log this hand: …"</p>';return;}
document.getElementById('root').innerHTML=hands.map(h=>{
const res=h.result!=null?`<span class="res ${h.result>=0?'pos-res':'neg-res'}">${h.result>=0?'+':''}${h.result}</span>`:'';
const meta=[h.stakes,h.venue,(h.at||'').slice(0,10)].filter(Boolean).join(' · ');
const tag=h.tag?` · <span class="tag">${esc(h.tag)}</span>`:'';
return `<a class="hand" href="/hand/${h.id}">
<span class="cards">${cards(h.hole_cards)||'<span class="card unknown">?</span>'}</span>
<span class="mid">
<div class="ln1">${esc(h.position||'')} ${h.board?'· '+'<span class="cards" style="display:inline-flex">'+cards(h.board)+'</span>':''}</div>
<div class="ln2">${esc(meta)}${tag}</div>
</span>${res}</a>`;
}).join('');
}catch(e){document.getElementById('root').innerHTML='<p class="empty">Couldn\'t load hands.</p>';}
}
load();
</script>
</body>
</html>
+103 -2
View File
@@ -39,6 +39,7 @@
<button id="mobileFullLogBtn">⛶ Full Log</button>
<button id="mobileMindBtn">🧠 Read Her Mind</button>
<button id="mobileJournalBtn">📔 Journal</button>
<button id="mobileHandsBtn">🃏 Hands</button>
<button id="mobileSettingsBtn">⚙ Settings</button>
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
<button id="mobileForceReloadBtn">🔄 Force Reload</button>
@@ -74,6 +75,7 @@
<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="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a>
<a id="handsBtn" href="/hands" target="_blank" rel="noopener" title="Recorded poker hands" role="button">🃏 Hands</a>
</div>
<!-- Status -->
@@ -141,6 +143,19 @@
</div>
</div>
<div class="settings-section" style="margin-top: 24px;">
<h4>Chat Model (Cloud)</h4>
<p class="settings-desc">Which OpenAI model answers on the Cloud backend. Tools (poker, equity, journaling) require Cloud.</p>
<select id="cloudModel">
<option value="">Default (gpt-4o)</option>
<option value="gpt-4o">gpt-4o — best persona</option>
<option value="gpt-4o-mini">gpt-4o-mini — cheap/fast</option>
<option value="gpt-4.1">gpt-4.1</option>
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
<option value="o4-mini">o4-mini — reasoning</option>
</select>
</div>
<div class="settings-section" style="margin-top: 24px;">
<h4>Session Management</h4>
<p class="settings-desc">Manage your saved chat sessions:</p>
@@ -281,6 +296,12 @@
body.backend = backend;
}
// Cloud chat-model override (ignored server-side unless backend is cloud)
const cloudModel = localStorage.getItem("cloudModel");
if (cloudModel) {
body.model = cloudModel;
}
try {
const resp = await fetch(API_URL, {
method: "POST",
@@ -298,12 +319,81 @@
}
}
function renderMarkdown(text) {
var bt = String.fromCharCode(96);
var esc = function (s) { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); };
var src = String(text == null ? "" : text).replace(/\r\n/g, "\n");
var blocks = [];
var fenceRe = new RegExp(bt + bt + bt + "[^\\n]*\\n?([\\s\\S]*?)" + bt + bt + bt, "g");
src = src.replace(fenceRe, function (_, code) { blocks.push(code.replace(/\n+$/, "")); return "@@CB" + (blocks.length - 1) + "@@"; });
var codeRe = new RegExp(bt + "([^" + bt + "]+)" + bt, "g");
var inline = function (s) {
return esc(s)
.replace(codeRe, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
};
var lines = src.split("\n");
var out = [], para = [], list = null;
var flushPara = function () { if (para.length) { out.push("<p>" + para.map(inline).join("<br>") + "</p>"); para = []; } };
var flushList = function () { if (list) { out.push("<" + list.t + ">" + list.items.map(function (it) { return "<li>" + inline(it) + "</li>"; }).join("") + "</" + list.t + ">"); list = null; } };
var flushAll = function () { flushPara(); flushList(); };
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/\s+$/, ""); var t = line.trim(); var m;
if ((m = t.match(/^@@CB(\d+)@@$/))) { flushAll(); out.push("<pre><code>" + esc(blocks[+m[1]]) + "</code></pre>"); continue; }
if (!t) { flushAll(); continue; }
if ((m = line.match(/^(#{1,4})\s+(.*)$/))) { flushAll(); out.push("<h" + m[1].length + ">" + inline(m[2]) + "</h" + m[1].length + ">"); continue; }
if ((m = line.match(/^\s*\d+[.)]\s+(.*)$/))) { flushPara(); if (!list || list.t !== "ol") { flushList(); list = { t: "ol", items: [] }; } list.items.push(m[1]); continue; }
if ((m = line.match(/^\s*[-*+]\s+(.*)$/))) { flushPara(); if (!list || list.t !== "ul") { flushList(); list = { t: "ul", items: [] }; } list.items.push(m[1]); continue; }
flushList(); para.push(line);
}
flushAll();
return out.join("\n");
}
function addRateBar(div) {
const bar = document.createElement("div");
bar.className = "rate-bar";
const up = document.createElement("button");
up.className = "rate-btn"; up.textContent = "👍"; up.title = "Good — more like this";
const down = document.createElement("button");
down.className = "rate-btn"; down.textContent = "👎"; down.title = "Off — less like this";
up.addEventListener("click", () => rateMessage(div, 1, up, down));
down.addEventListener("click", () => rateMessage(div, -1, up, down));
bar.appendChild(up); bar.appendChild(down);
div.appendChild(bar);
}
function rateMessage(div, value, up, down) {
// context = the nearest preceding user message
let ctx = "", p = div.previousElementSibling;
while (p) {
if (p.classList && p.classList.contains("user")) { ctx = p.textContent; break; }
p = p.previousElementSibling;
}
fetch(`${RELAY_BASE}/rate`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "chat", rating: value, content: div.dataset.raw || "", context: ctx, session_id: currentSession })
}).catch(() => {});
up.classList.toggle("rated", value === 1);
down.classList.toggle("rated", value === -1);
}
function addMessage(role, text, autoScroll = true) {
const messagesEl = document.getElementById("messages");
const msgDiv = document.createElement("div");
msgDiv.className = `msg ${role}`;
msgDiv.textContent = text;
if (role === "assistant") {
msgDiv.innerHTML = renderMarkdown(text);
msgDiv.dataset.raw = text;
addRateBar(msgDiv);
} else {
msgDiv.textContent = text;
}
messagesEl.appendChild(msgDiv);
// Auto-scroll to bottom if enabled
@@ -534,6 +624,10 @@
const initialRadio = document.querySelector(`input[name="backend"][value="${savedBackend}"]`);
if (initialRadio) initialRadio.checked = true;
// Restore saved cloud-model choice
const savedModelSel = document.getElementById("cloudModel");
if (savedModelSel) savedModelSel.value = localStorage.getItem("cloudModel") || "";
// Session management functions
async function loadSessionList() {
try {
@@ -642,7 +736,11 @@
const backendValue = selectedRadio ? selectedRadio.value : "local";
localStorage.setItem("standardModeBackend", backendValue);
addMessage("system", `Backend changed to: ${backendValue}`);
const modelSel = document.getElementById("cloudModel");
const modelValue = modelSel ? modelSel.value : "";
localStorage.setItem("cloudModel", modelValue);
const modelLabel = modelValue || "default (gpt-4o)";
addMessage("system", `Backend: ${backendValue} · cloud model: ${modelLabel}`);
hideModal();
});
@@ -792,6 +890,9 @@
document.getElementById("mobileJournalBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/journal";
});
document.getElementById("mobileHandsBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/hands";
});
// Connect to the global live log on page load.
connectThinkingStream();
+28 -5
View File
@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0d12" />
<meta name="theme-color" content="#070707" />
<title>Lyra — Journal</title>
<style>
:root {
--bg: #0b0d12; --bg-elev: #141821; --bg-line: #11141b; --border: #232936;
--text: #e6e9ef; --fade: #8b93a7; --accent: #7aa2ff;
--reflection: #5ad1a0; --metacognition: #c08bff; --journal: #ffcf6b;
--bg: #070707; --bg-elev: #0e0e0e; --bg-line: #141414; --border: #2a1d12;
--text: #e8e8e8; --fade: #8a8a8a; --accent: #ff7a00;
--reflection: #8fd694; --metacognition: #ffb347; --journal: #ff7a00;
}
* { box-sizing: border-box; }
html, body {
@@ -31,7 +31,7 @@
border: 1px solid var(--border); background: var(--bg-line); color: var(--fade);
cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent;
}
.chip.active { color: var(--text); border-color: var(--accent); background: #1b2333; }
.chip.active { color: var(--text); border-color: var(--accent); background: #241400; }
main { max-width: 720px; margin: 0 auto; padding: 14px 14px 48px; }
.day { color: var(--fade); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px;
@@ -52,6 +52,12 @@
.time { color: var(--fade); font-size: .72rem; }
.src { color: var(--fade); font-size: .68rem; opacity: .7; }
.text { font-size: .98rem; line-height: 1.55; }
.jrate { display: flex; gap: 8px; margin-top: 6px; opacity: .35; }
.entry:hover .jrate { opacity: .85; }
.jr { background: none; border: none; cursor: pointer; font-size: .85rem; padding: 2px 5px;
border-radius: 5px; filter: grayscale(.6); -webkit-tap-highlight-color: transparent; }
.jr:hover { filter: none; background: rgba(255,122,0,.12); }
.jr.rated { filter: none; background: rgba(255,122,0,.25); opacity: 1; }
.empty { color: var(--fade); text-align: center; padding: 44px 16px; }
.hidden { display: none !important; }
</style>
@@ -115,12 +121,29 @@
${e.source ? `<span class="src">via ${esc(e.source)}</span>` : ''}
</div>
<div class="text">${esc(e.content)}</div>
<div class="jrate">
<button class="jr" data-id="${e.id}" data-val="1">👍</button>
<button class="jr" data-id="${e.id}" data-val="-1">👎</button>
</div>
</div>
</div>`;
}
root.innerHTML = html;
}
// 👍/👎 on a thought -> /rate (fine-tune signal)
root.addEventListener('click', (ev) => {
const b = ev.target.closest('.jr'); if (!b) return;
const e = entries.find(x => String(x.id) === b.dataset.id); if (!e) return;
fetch('/rate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: e.kind, rating: Number(b.dataset.val), content: e.content, ref: e.id })
}).catch(() => {});
const bar = b.parentElement;
bar.querySelectorAll('.jr').forEach(x => x.classList.remove('rated'));
b.classList.add('rated');
});
async function load(){
try {
const r = await fetch('/journal/data', { cache: 'no-store' });
+15 -15
View File
@@ -3,22 +3,22 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0d12" />
<meta name="theme-color" content="#070707" />
<title>Lyra — Live Log</title>
<style>
:root {
--bg: #0b0d12;
--bg-elev: #141821;
--bg-line: #11141b;
--border: #232936;
--text: #e6e9ef;
--fade: #8b93a7;
--accent: #7aa2ff;
--info: #5ad1a0;
--debug: #8b93a7;
--bg: #070707;
--bg-elev: #0e0e0e;
--bg-line: #141414;
--border: #2a1d12;
--text: #e8e8e8;
--fade: #8a8a8a;
--accent: #ff7a00;
--info: #8fd694;
--debug: #8a8a8a;
--error: #ff6b6b;
--system: #c08bff;
--warn: #ffcf6b;
--system: #ffb347;
--warn: #ffb347;
}
* { box-sizing: border-box; }
html, body {
@@ -56,7 +56,7 @@
border: 1px solid var(--border); background: var(--bg-line); color: var(--fade);
cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent;
}
.chip.active { color: var(--text); border-color: var(--accent); background: #1b2333; }
.chip.active { color: var(--text); border-color: var(--accent); background: #241400; }
#search {
flex: 1 1 140px; min-width: 120px;
background: var(--bg-line); border: 1px solid var(--border); color: var(--text);
@@ -85,9 +85,9 @@
padding: 1px 7px; border-radius: 5px; font-weight: 700; flex: none;
}
.lvl-info { color: var(--info); background: #0f2a20; }
.lvl-debug { color: var(--debug); background: #1a1f29; }
.lvl-debug { color: var(--debug); background: #161616; }
.lvl-error { color: var(--error); background: #2e1414; }
.lvl-system { color: var(--system); background: #221536; }
.lvl-system { color: var(--system); background: #2c2410; }
.lvl-warn { color: var(--warn); background: #2c2410; }
.msg { font-size: .92rem; font-weight: 500; }
.fields {
+78
View File
@@ -0,0 +1,78 @@
<!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 — Recap</title>
<style>
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;--fade:#8a8a8a;--accent:#ff7a00;}
*{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:12px 0;flex-wrap:wrap;}
.topbar h1{font-size:1.02rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.dl{margin-left:auto;background:#241400;border:1px solid var(--border);color:var(--accent);
border-radius:8px;padding:7px 12px;font-size:.85rem;text-decoration:none;}
main{max-width:740px;margin:0 auto;padding:18px 16px 48px;line-height:1.6;}
h1,h2,h3,h4{line-height:1.3;color:var(--text);}
main>h1:first-child{margin-top:0;}
h2{font-size:1.18rem;border-bottom:1px solid var(--border);padding-bottom:5px;margin-top:26px;color:var(--accent);}
h3{font-size:1.04rem;margin-top:18px;}
ul{padding-left:22px;} li{margin:3px 0;}
strong{color:var(--text);} hr{border:none;border-top:1px solid var(--border);margin:20px 0;}
code{background:rgba(255,255,255,.08);padding:1px 5px;border-radius:4px;font-size:.9em;}
.err{color:var(--fade);text-align:center;padding:46px 16px;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>📋 Recap</h1>
<a class="back" href="/">← Chat</a>
<a class="back" href="/hands">Hands</a>
<a class="dl" id="dl">⬇ .md</a>
</div>
</header>
<main id="root"><p class="err">Loading recap…</p></main>
<script>
const bt = String.fromCharCode(96);
function esc(s){return String(s==null?'':s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}
function inline(s){
const codeRe = new RegExp(bt+"([^"+bt+"]+)"+bt,"g");
return esc(s).replace(codeRe,"<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g,"<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\n]+)\*/g,"$1<em>$2</em>");
}
function md(src){
const lines=String(src||"").replace(/\r\n/g,"\n").split("\n");
const out=[]; let list=null;
const flush=()=>{if(list){out.push("<ul>"+list.map(i=>"<li>"+inline(i)+"</li>").join("")+"</ul>");list=null;}};
for(const raw of lines){
const t=raw.replace(/\s+$/,""); let m;
if(!t.trim()){flush();continue;}
if(/^(-{3,}|\*{3,}|_{3,})$/.test(t.trim())){flush();out.push("<hr>");continue;}
if((m=t.match(/^(#{1,6})\s+(.*)$/))){flush();const n=m[1].length;out.push(`<h${n}>${inline(m[2])}</h${n}>`);continue;}
if((m=t.match(/^\s*[-*+]\s+(.*)$/))){(list=list||[]).push(m[1]);continue;}
flush();out.push("<p>"+inline(t)+"</p>");
}
flush(); return out.join("\n");
}
async function load(){
const id=location.pathname.split('/')[2];
document.getElementById('dl').href=`/recap/${id}/download`;
try{
const r=await fetch(`/recap/${id}/data`,{cache:'no-store'});
const d=await r.json();
if(!d.markdown){document.getElementById('root').innerHTML='<p class="err">No recap yet for this session. Ask Lyra to write one ("generate the recap").</p>';return;}
document.getElementById('root').innerHTML=md(d.markdown);
}catch(e){document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the recap.</p>';}
}
load();
</script>
</body>
</html>
+5 -5
View File
@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0d12" />
<meta name="theme-color" content="#070707" />
<title>Lyra — Mind</title>
<style>
:root {
--bg: #0b0d12; --bg-elev: #141821; --bg-line: #11141b; --border: #232936;
--text: #e6e9ef; --fade: #8b93a7; --accent: #7aa2ff;
--good: #5ad1a0; --mid: #ffcf6b; --low: #ff6b6b; --violet: #c08bff;
--bg: #070707; --bg-elev: #0e0e0e; --bg-line: #141414; --border: #2a1d12;
--text: #e8e8e8; --fade: #8a8a8a; --accent: #ff7a00;
--good: #8fd694; --mid: #ffb347; --low: #ff6b6b; --violet: #ffb347;
}
* { box-sizing: border-box; }
html, body {
@@ -26,7 +26,7 @@
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
.updated { margin-left: auto; color: var(--fade); font-size: .78rem; }
#reflectBtn {
background: #1b2333; border: 1px solid var(--border); color: var(--accent);
background: #241400; border: 1px solid var(--border); color: var(--accent);
border-radius: 8px; padding: 6px 11px; font-size: .82rem; cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
+954 -912
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "lyra"
version = "0.1.0"
version = "0.2.0"
description = "Persistent, autonomous AI assistant"
readme = "README.md"
requires-python = ">=3.11"
@@ -10,6 +10,7 @@ dependencies = [
"numpy>=2.4.5",
"openai>=2.37.0",
"python-dotenv>=1.2.2",
"treys>=0.1.8",
"uvicorn[standard]>=0.34",
]
+42
View File
@@ -0,0 +1,42 @@
"""Deterministic equity/board-eval — the JJ-vs-65 hand Lyra kept botching."""
from __future__ import annotations
import pytest
from lyra import equity
def test_flop_equity_and_made_hands():
r = equity.analyze(["Jh", "Js"], ["6d", "5d"], ["8c", "7d", "Ts"])
assert r["ahead"] == "hero"
assert r["hero_hand"] == "Pair" and r["villain_hand"] == "High Card"
assert 75 < r["hero_equity"] < 82 # ~78.7%
def test_turn_villain_straight_and_outs_exclude_flush_card():
r = equity.analyze(["Jh", "Js"], ["6d", "5d"], ["8c", "7d", "Ts", "4d"])
assert r["ahead"] == "villain"
assert r["villain_hand"] == "Straight"
# hero's only outs are the three non-diamond nines — 9d makes villain a flush
assert r["hero_outs"]["count"] == 3
assert "9d" not in r["hero_outs"]["cards"]
assert r["hero_equity"] < 10
def test_rejects_unknown_and_duplicate_cards():
with pytest.raises(equity.EquityError):
equity.analyze(["x", "x"], ["6d", "5d"], ["8c", "7d", "Ts"])
with pytest.raises(equity.EquityError):
equity.analyze(["8c", "8c"], ["6d", "5d"], ["8c", "7d", "Ts"])
def test_unknown_suits_spread_rainbow_no_phantom_flush():
# all-unknown-suit board must not become monotone (which would inflate equity)
r = equity.analyze(["Jx", "Jx"], ["6d", "5d"], ["8x", "7x", "Tx"])
assert 75 < r["hero_equity"] < 82
def test_tool_dispatch():
from lyra import tools
out = tools.dispatch("analyze_spot", {"hero": "Jh Js", "villain": "6d 5d", "board": "8c 7d Ts 4d"})
assert "EQUITY" in out and "Straight" in out
+160
View File
@@ -0,0 +1,160 @@
"""Poker domain: structured session/hand/villain storage + stats, and the tools."""
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) # rebind to the reloaded memory + reset its schema flag
return poker
def test_session_lifecycle_and_net(lyra):
poker = lyra
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
assert poker.live_session()["id"] == sid
poker.add_buyin(500) # rebuy -> total 900
s = poker.end_session(cash_out=627)
assert s["buy_in_total"] == 900
assert s["net"] == pytest.approx(-273)
assert s["status"] == "closed"
assert poker.live_session() is None # closed -> no live session
def test_log_hand_partial_fields(lyra):
poker = lyra
poker.start_session(stakes="1/3", buy_in=300)
hid = poker.log_hand(position="BTN", hole_cards="AKs", result=120, tag="confidence")
hands = poker.list_hands()
assert len(hands) == 1 and hands[0]["id"] == hid
assert hands[0]["hole_cards"] == "AKs" and hands[0]["result"] == 120
assert hands[0]["board"] is None # unspecified fields stay null
def test_villain_file_upsert_and_read(lyra):
poker = lyra
poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
poker.add_read("limp-called K4s UTG", name="Sleepy John", seat="3",
tendencies="loose-passive, jackpot dreamer", category="feeder", venue="Meadows")
# update the same player
poker.add_read("cold-called a 3-bet with A2o", name="sleepy john")
file = poker.get_villain_file(name="Sleepy")
assert len(file) == 1 # matched by name, not duplicated
assert file[0]["category"] == "feeder"
def test_running_stats(lyra):
poker = lyra
s1 = poker.start_session(stakes="1/3", buy_in=300)
poker.end_session(540, session_id=s1)
s2 = poker.start_session(stakes="1/3", buy_in=400)
poker.end_session(300, session_id=s2)
rs = poker.running_stats(stakes="1/3")
assert rs["sessions"] == 2
assert rs["net"] == pytest.approx(140) # +240 then -100
assert "1/3" in rs["by_stake"]
def test_hand_history_store_and_get(lyra):
poker = lyra
parsed = {"game": "NLH", "stakes": "1/3", "hero_pos": "BTN", "hero_cards": ["As", "Ks"],
"players": [{"pos": "BTN", "cards": ["As", "Ks"]}, {"pos": "BB"}],
"actions": [{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 12},
{"street": "flop", "board": ["As", "7d", "2s"]}],
"board": ["As", "7d", "2s"], "result": {"pot": 80, "hero_net": 330, "summary": "won"}}
hid = poker.store_hand_history(parsed) # no live session -> attaches to a review session
h = poker.get_hand(hid)
assert h["position"] == "BTN" and h["hole_cards"] == "As Ks"
assert h["result"] == 330
assert h["structured"]["actions"][0]["amount"] == 12
def test_record_hand_tool_parses_and_stores(lyra, monkeypatch):
import re
from lyra import llm, tools
hand_json = ('{"hero_pos":"CO","hero_cards":["Js","Jd"],'
'"players":[{"pos":"CO","cards":["Js","Jd"]},{"pos":"BB","name":"drunk"}],'
'"actions":[{"street":"preflop","pos":"CO","action":"raise","amount":45}],'
'"board":[],"result":{"hero_net":-300,"summary":"lost to a straight"}}')
monkeypatch.setattr(llm, "complete", lambda messages, backend=None, model=None: hand_json)
out = tools.dispatch("record_hand", {"shorthand": "JJ in CO, lost to a straight", "stakes": "1/3"})
assert "/hand/" in out
hid = int(re.search(r"/hand/(\d+)", out).group(1))
h = lyra.get_hand(hid)
assert h["structured"]["hero_pos"] == "CO"
assert h["result"] == -300
def test_generate_recap(lyra, monkeypatch):
poker = lyra
from lyra import llm
monkeypatch.setattr(llm, "complete",
lambda messages, backend=None, model=None: "# Recap\n## Final Assessment\nGood session.")
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
poker.log_hand(position="BTN", hole_cards="AKs", result=180, tag="confidence")
poker.end_session(540, session_id=sid)
out = poker.generate_recap(session_id=sid)
assert out["id"] == sid and "Final Assessment" in out["markdown"]
assert "Recap" in poker.get_session(sid)["recap_md"]
def test_list_recent_hands(lyra):
poker = lyra
poker.start_session(stakes="1/3", buy_in=300)
poker.log_hand(position="CO", hole_cards="QQ", result=-50)
hh = poker.list_recent_hands()
assert hh and hh[0]["hole_cards"] == "QQ" and hh[0]["stakes"] == "1/3"
def test_player_observation_and_profile(lyra):
poker = lyra
sid = poker.start_session(stakes="1/3", buy_in=300)
parsed = {"hero_pos": "BB",
"players": [{"pos": "BTN", "name": "Round Mike"}, {"pos": "BB", "name": None}],
"actions": [{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 12},
{"street": "preflop", "pos": "BB", "action": "call"},
{"street": "flop", "board": ["7d", "2c", "5h"]},
{"street": "flop", "pos": "BTN", "action": "bet", "amount": 15}]}
hid = poker.store_hand_history(parsed, session_id=sid)
assert poker.link_hand_players(hid, parsed, session_id=sid) == 1 # only the named player
prof = poker.player_profile("mike")
assert prof["player"]["name"] == "Round Mike"
assert prof["observations"] == 1
assert prof["stats"] is None and "small_sample" in prof # too few hands for stats
def test_player_stats_emerge_with_sample(lyra):
poker = lyra
sid = poker.start_session(stakes="1/3", buy_in=300)
raised = {"players": [{"pos": "BTN", "name": "LAG"}],
"actions": [{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 10}]}
folded = {"players": [{"pos": "UTG", "name": "LAG"}],
"actions": [{"street": "preflop", "pos": "UTG", "action": "fold"}]}
for i in range(poker.MIN_STATS_SAMPLE):
p = raised if i % 2 == 0 else folded
hid = poker.store_hand_history(p, session_id=sid)
poker.link_hand_players(hid, p, session_id=sid)
prof = poker.player_profile("LAG")
assert prof["stats"] is not None
assert prof["stats"]["hands"] >= poker.MIN_STATS_SAMPLE
assert 30 <= prof["stats"]["vpip_pct"] <= 70 # ~half were voluntary
def test_poker_tools_dispatch(lyra):
from lyra import tools
assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})
assert "logged" in tools.dispatch("log_hand", {"position": "CO", "hole_cards": "QQ"})
assert "closed" in tools.dispatch("end_session", {"cash_out": 500})
# the poker tools are offered to the model
names = {s["function"]["name"] for s in tools.specs()}
assert {"start_session", "log_hand", "end_session", "running_stats", "get_villain_file"} <= names
+28
View File
@@ -0,0 +1,28 @@
"""Behind-the-scenes feedback storage (fine-tune signal)."""
from __future__ import annotations
import importlib
import pytest
@pytest.fixture
def memory(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "t.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
import lyra.memory as m
importlib.reload(m)
return m
def test_rating_counts_and_upsert(memory):
memory.add_rating("chat", 1, "good reply", context="hey")
memory.add_rating("reflection", -1, "repetitive thought")
assert memory.rating_counts() == {"total": 2, "up": 1, "down": 1}
assert any(r["context"] == "hey" for r in memory.list_ratings())
# re-rating the same content replaces the row (no duplicate; flips the rating)
memory.add_rating("chat", -1, "good reply")
assert memory.rating_counts() == {"total": 2, "up": 0, "down": 2}
assert any(r["content"] == "good reply" and r["rating"] == -1 for r in memory.list_ratings())
+55
View File
@@ -0,0 +1,55 @@
"""Lyra's tools: dispatch + the chat tool loop (call -> run -> feed back -> reply)."""
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)
return memory
def test_journal_write_tool(lyra):
from lyra import tools
out = tools.dispatch("journal_write", '{"entry": "a private thought"}')
assert "journal" in out.lower()
entries = lyra.list_journal(kinds=("journal",))
assert any(e["content"] == "a private thought" and e["source"] == "chat" for e in entries)
def test_note_tool_with_tag(lyra):
from lyra import tools
tools.dispatch("note", {"content": "villain 3-bets light", "tag": "poker"})
notes = lyra.list_journal(kinds=("note",))
assert any("[poker] villain 3-bets light" == e["content"] for e in notes)
def test_unknown_tool_is_safe(lyra):
from lyra import tools
assert "unknown tool" in tools.dispatch("nope", {})
def test_chat_runs_tool_then_replies(lyra, monkeypatch):
from lyra import llm, chat
calls = {"n": 0}
def fake_chat_call(messages, backend="cloud", model=None, tools=None):
calls["n"] += 1
if calls["n"] == 1:
return ({"role": "assistant", "content": None, "tool_calls": []},
[{"id": "c1", "name": "journal_write", "arguments": '{"entry": "noted from chat"}'}])
return ({"role": "assistant", "content": "Done, Brian."}, None)
monkeypatch.setattr(llm, "chat_call", fake_chat_call)
reply = chat.respond("s1", "write that down for me", backend="cloud")
assert reply == "Done, Brian."
assert calls["n"] == 2 # one tool round, then the text reply
assert any("noted from chat" in e["content"] for e in lyra.list_journal())
Generated
+11
View File
@@ -286,6 +286,7 @@ dependencies = [
{ name = "numpy" },
{ name = "openai" },
{ name = "python-dotenv" },
{ name = "treys" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -302,6 +303,7 @@ requires-dist = [
{ name = "numpy", specifier = ">=2.4.5" },
{ name = "openai", specifier = ">=2.37.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "treys", specifier = ">=0.1.8" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
]
@@ -692,6 +694,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "treys"
version = "0.1.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/a6/1712340dc1ac96d40afe162d43ce146c7781ba59cde5efc988aaee35ada4/treys-0.1.8.tar.gz", hash = "sha256:a486a42b899e91985b4da4fdac9a30e638275648977104487acb90a2dd7cd73b", size = 12073, upload-time = "2022-06-21T16:02:44.976Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/df/e6b3b1cc98c3e00c5b146113f998dfe0de47358277648df235a6ae571143/treys-0.1.8-py3-none-any.whl", hash = "sha256:9ba3460ff2ed597510fb535af6280f115254b0b70699ea362f8f1ee067378063", size = 11897, upload-time = "2022-06-21T16:02:42.896Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"