diff --git a/lyra/poker.py b/lyra/poker.py new file mode 100644 index 0000000..ab1f8b8 --- /dev/null +++ b/lyra/poker.py @@ -0,0 +1,315 @@ +"""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, + } diff --git a/lyra/tools.py b/lyra/tools.py index 8eb507a..d88a50b 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -11,7 +11,7 @@ from __future__ import annotations import json -from lyra import logbus, memory +from lyra import logbus, memory, poker def _journal_write(args: dict, ctx: dict) -> str: @@ -83,6 +83,163 @@ TOOLS: dict[str, dict] = { } +# --- 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"), + ) + 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 _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'"}}, + [])}, + "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()] diff --git a/tests/test_poker.py b/tests/test_poker.py new file mode 100644 index 0000000..514a6cd --- /dev/null +++ b/tests/test_poker.py @@ -0,0 +1,74 @@ +"""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_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