update to 0.2.0 stable #2

Merged
serversdown merged 51 commits from dev into main 2026-06-18 15:39:46 -04:00
3 changed files with 547 additions and 1 deletions
Showing only changes of commit 49b88af3cc - Show all commits
+315
View File
@@ -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,
}
+158 -1
View File
@@ -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()]
+74
View File
@@ -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