Files
project-lyra/lyra/poker.py
T
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

316 lines
11 KiB
Python

"""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
from datetime import datetime, timezone
from lyra import 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
recap_md TEXT
);
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
);
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
);
"""
_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)
_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) -> 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) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'live')",
(_now(), venue, game, stakes, fmt, float(buy_in or 0), mantra),
)
return int(cur.lastrowid)
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()]
# --- villain file ---
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 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,
}