Update main. #6

Merged
serversdown merged 70 commits from dev into main 2026-07-10 15:17:09 -04:00
5 changed files with 251 additions and 4 deletions
Showing only changes of commit 9b844bc356 - Show all commits
+11 -3
View File
@@ -47,9 +47,10 @@ _BASE = ("journal_write", "note", "think_about", "thought_response", "set_mode")
# The full live cash-game toolset (incl. Brian's mental-game rituals). # The full live cash-game toolset (incl. Brian's mental-game rituals).
_CASH_TOOLS = _BASE + _LOOKUPS + ( _CASH_TOOLS = _BASE + _LOOKUPS + (
"start_session", "add_buyin", "log_stack", "log_hand", "record_hand", "start_session", "add_buyin", "log_stack", "log_hand", "record_hand",
"add_read", "name_villain", "link_villains", "analyze_spot", "session_stats", "add_read", "seat_players", "unseat_player", "name_villain", "link_villains",
"session_state", "end_session", "generate_recap", "scar_note", "confidence_bank", "analyze_spot", "session_stats", "session_state", "end_session", "generate_recap",
"alligator_blood", "reset_ritual", "undo_last", "update_session", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual", "undo_last",
"update_session",
) )
# Talk mode also gets start_session as the *entry point*: opening a session from a # Talk mode also gets start_session as the *entry point*: opening a session from a
@@ -79,6 +80,13 @@ hand) — prefer this over log_hand so it lands on his timeline with a link. A r
player → add_read. A rebuy → add_buyin. A result/pot → it rides with the hand. This is the \ player → add_read. A rebuy → add_buyin. A result/pot → it rides with the hand. This is the \
quiet, fast half of the job; he shouldn't feel you working, but it must always happen. quiet, fast half of the job; he shouldn't feel you working, but it must always happen.
THE TABLE ROSTER. When Brian names who's at the table — usually at the start, reading handles \
off the Bravo screen ("we've got TAG, JD, Wheelz, and a new guy in seat 3") — call seat_players \
to register them as seated this session. That roster is who his reads/TAGs attach to by name, \
and it's shown on his HUD. When someone busts or leaves, unseat_player; when a new player sits, \
seat_players again. Keep it current as the table changes. A handle like "TAG" (all caps, off \
Bravo) is a PERSON'S NAME — seat it as a player, never read it as the tight-aggressive style.
LOGGING PLAYER ACTIONS IS A CORE JOB YOU KEEP MISSING. Whenever he tells you what another \ LOGGING PLAYER ACTIONS IS A CORE JOB YOU KEEP MISSING. Whenever he tells you what another \
player did — "Tag limped A4o in the SB (UTG straddled pot)", "Jonathan called the 3bet", "the \ player did — "Tag limped A4o in the SB (UTG straddled pot)", "Jonathan called the 3bet", "the \
straddler shoved" — that is a READ on that player: call add_read(name=<player>, note=<what \ straddler shoved" — that is a READ on that player: call add_read(name=<player>, note=<what \
+107 -1
View File
@@ -150,6 +150,17 @@ CREATE TABLE IF NOT EXISTS identity_queue (
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_idq_status ON identity_queue(status); CREATE INDEX IF NOT EXISTS idx_idq_status ON identity_queue(status);
-- Who is seated at the table THIS session — the live roster Brian reads off Bravo
-- at the start. Reads/TAGs attach to these players by handle; active=0 when they leave.
CREATE TABLE IF NOT EXISTS session_players (
session_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
seat TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
PRIMARY KEY (session_id, player_id)
);
""" """
# Below this many observed hands, don't surface % stats (too small a sample). # Below this many observed hands, don't surface % stats (too small a sample).
@@ -267,7 +278,7 @@ def delete_session(session_id: int) -> dict:
counts: dict[str, int] = {} counts: dict[str, int] = {}
with conn: with conn:
for t in ("poker_hands", "player_observations", "player_reads", for t in ("poker_hands", "player_observations", "player_reads",
"poker_stack_log", "poker_rituals"): "poker_stack_log", "poker_rituals", "session_players"):
counts[t] = conn.execute( counts[t] = conn.execute(
f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,) f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,)
).fetchone()["n"] ).fetchone()["n"]
@@ -1803,6 +1814,100 @@ def timeline(session_id: int | None = None) -> list[dict]:
return events return events
def _resolve_or_create_player(name: str | None = None, descriptor: str | None = None,
venue: str | None = None, category: str | None = None) -> int | None:
"""Turn a name-or-descriptor into a player id, matching an existing villain when
confident. A description mistakenly given as a name is routed to the descriptor
path so it dedupes (same guard add_read uses)."""
if name and not descriptor and _looks_like_description(name):
descriptor, name = name, None
if name:
return upsert_player(name, venue=venue, category=category)
if descriptor:
res = resolve_villain(descriptor, venue=venue)
if res["band"] in ("name", "high") and res["match_id"]:
add_descriptor(res["match_id"], descriptor)
return res["match_id"]
return create_descriptor_villain(descriptor, venue=venue, category=category)
return None
def seat_player(name: str | None = None, descriptor: str | None = None, seat: str | None = None,
category: str | None = None, session_id: int | None = None) -> int | None:
"""Seat one player at the live table (add to the roster). Idempotent per session."""
sid = _resolve(session_id)
if sid is None:
raise ValueError("no live session")
venue = (get_session(sid) or {}).get("venue")
pid = _resolve_or_create_player(name=name, descriptor=descriptor, venue=venue, category=category)
if pid is None:
return None
conn = _c()
with conn:
conn.execute(
"INSERT INTO session_players (session_id, player_id, seat, active, created_at) "
"VALUES (?, ?, ?, 1, ?) ON CONFLICT(session_id, player_id) DO UPDATE SET "
"active = 1, seat = COALESCE(excluded.seat, session_players.seat)",
(sid, pid, seat, _now()),
)
return pid
def seat_players(players: list, session_id: int | None = None) -> int:
"""Seat a whole table at once. Each item is a name string or a dict with
name/descriptor/seat/category. Returns how many were seated."""
n = 0
for p in players or []:
if isinstance(p, str):
ok = seat_player(name=p, session_id=session_id)
elif isinstance(p, dict):
ok = seat_player(name=p.get("name"), descriptor=p.get("descriptor"),
seat=p.get("seat"), category=p.get("category"), session_id=session_id)
else:
ok = None
if ok:
n += 1
return n
def unseat_player(name: str | None = None, descriptor: str | None = None,
session_id: int | None = None) -> bool:
"""Mark a seated player as gone (busted/left). Keeps their reads/history."""
sid = _resolve(session_id)
if sid is None:
return False
ref = name or descriptor or ""
res = resolve_villain(ref, venue=(get_session(sid) or {}).get("venue"), session_id=sid)
pid = res.get("match_id")
if pid is None:
return False
conn = _c()
with conn:
conn.execute("UPDATE session_players SET active = 0 WHERE session_id = ? AND player_id = ?",
(sid, pid))
return True
def session_roster(session_id: int | None = None) -> list[dict]:
"""The live table roster: seated players with seat, dossier, and their latest
read this session. This is 'who's at the table right now'."""
sid = _resolve(session_id)
if sid is None:
return []
rows = _c().execute(
"SELECT sp.seat AS seat, p.id AS id, p.name AS name, p.named AS named, "
"p.category AS category, p.tendencies AS tendencies, "
"(SELECT note FROM player_reads r WHERE r.player_id = p.id AND r.session_id = ? "
" ORDER BY r.id DESC LIMIT 1) AS last_note, "
"(SELECT COUNT(*) FROM player_reads r2 WHERE r2.player_id = p.id AND r2.session_id = ?) AS reads "
"FROM session_players sp JOIN poker_players p ON p.id = sp.player_id "
"WHERE sp.session_id = ? AND sp.active = 1 "
"ORDER BY CASE WHEN sp.seat IS NULL THEN 1 ELSE 0 END, sp.seat, p.name",
(sid, sid, sid),
).fetchall()
return [dict(r) for r in rows]
def _session_villains(sid: int) -> list[dict]: def _session_villains(sid: int) -> list[dict]:
"""Players read this session, with their standing dossier fields.""" """Players read this session, with their standing dossier fields."""
rows = _c().execute( rows = _c().execute(
@@ -1878,6 +1983,7 @@ def hud(session_id: int | None = None) -> dict | None:
"log": log, "log": log,
}, },
"hands": hands, "hands": hands,
"roster": session_roster(sid),
"villains": _session_villains(sid), "villains": _session_villains(sid),
"timeline": timeline(sid), "timeline": timeline(sid),
"notes": notes, "notes": notes,
+40
View File
@@ -311,6 +311,26 @@ def _resolve_villain_ref(ref: str) -> tuple[int | None, str]:
return None, res["band"] return None, res["band"]
def _seat_players(args: dict, ctx: dict) -> str:
players = args.get("players") or []
# Accept a plain list of names too, for convenience.
if isinstance(players, str):
players = [p.strip() for p in re.split(r"[,\n]", players) if p.strip()]
try:
n = poker.seat_players(players)
except ValueError:
return "No live session — start one first, then I'll seat the table."
roster = poker.session_roster()
names = ", ".join(r["name"] for r in roster) or ""
return f"Seated {n}. Table now: {names}"
def _unseat_player(args: dict, ctx: dict) -> str:
ok = poker.unseat_player(name=args.get("name"), descriptor=args.get("descriptor"))
who = args.get("name") or args.get("descriptor") or "player"
return f"{who} is off the table." if ok else f"Couldn't find {who} on the roster."
def _name_villain(args: dict, ctx: dict) -> str: def _name_villain(args: dict, ctx: dict) -> str:
ref = (args.get("descriptor") or "").strip() ref = (args.get("descriptor") or "").strip()
name = (args.get("name") or "").strip() name = (args.get("name") or "").strip()
@@ -653,6 +673,26 @@ TOOLS.update({
"category": {**_S, "description": "feeder | risky | reg | unknown"}, "category": {**_S, "description": "feeder | risky | reg | unknown"},
"venue": {**_S, "description": "Where they play"}}, "venue": {**_S, "description": "Where they play"}},
["note"])}, ["note"])},
"seat_players": {"handler": _seat_players, "spec": _f(
"seat_players",
"Register who's at the table this session — the roster Brian reads off the Bravo "
"screen (handles like TAG, JD). Call this when he names the table (usually at the "
"start) or when a new player sits. Each player is a real handle in `name`, or a "
"`descriptor` if he only describes them. These become the roster his reads/TAGs "
"attach to by name.",
{"players": {"type": "array", "description": "Players to seat",
"items": {"type": "object", "properties": {
"name": {**_S, "description": "Handle as it appears on Bravo, e.g. 'TAG'"},
"descriptor": {**_S, "description": "Physical description if no name"},
"seat": {**_S, "description": "Seat number/label if known"},
"category": {**_S, "description": "feeder | risky | reg | unknown"}}}}},
["players"])},
"unseat_player": {"handler": _unseat_player, "spec": _f(
"unseat_player",
"Remove a player from the table roster when they bust or leave. Keeps their history.",
{"name": {**_S, "description": "Their handle"},
"descriptor": {**_S, "description": "Or a description if unnamed"}},
[])},
"name_villain": {"handler": _name_villain, "spec": _f( "name_villain": {"handler": _name_villain, "spec": _f(
"name_villain", "name_villain",
"Attach a real name to a player you'd only known by description (e.g. you caught it " "Attach a real name to a player you'd only known by description (e.g. you caught it "
+14
View File
@@ -265,6 +265,7 @@
const stack = data.stack || {}; const stack = data.stack || {};
const timeline = data.timeline || []; const timeline = data.timeline || [];
const hands = data.hands || []; const hands = data.hands || [];
const roster = data.roster || [];
const villains = data.villains || []; const villains = data.villains || [];
const notes = data.notes || []; const notes = data.notes || [];
const stats = data.stats || {}; const stats = data.stats || {};
@@ -369,6 +370,19 @@
: '<p class="empty">No scars logged — mistakes to study land here.</p>'} : '<p class="empty">No scars logged — mistakes to study land here.</p>'}
</div> </div>
<div class="card">
<p class="label">🪑 Table (${roster.length})</p>
${roster.length ? `<ul class="rows">${roster.map(v => `
<li class="villain">
${v.seat ? `<span class="cat">${esc(v.seat)}</span> ` : ''}<b>${esc(v.name)}</b>
${v.category ? `<span class="cat">[${esc(v.category)}]</span>` : ''}
${v.reads ? `<span class="cat">· ${v.reads} read${v.reads===1?'':'s'}</span>` : ''}
<button class="mini" title="Rename / fix" onclick="renamePlayer(${v.id}, '${esc(v.name||'').replace(/'/g,"\\'")}')"></button>
${v.last_note ? `<div class="note-meta">“${esc(v.last_note)}”</div>` : ''}
</li>`).join('')}</ul>`
: '<p class="empty">No roster yet — tell Lyra who\\'s at the table.</p>'}
</div>
<div class="card"> <div class="card">
<p class="label">Villains seen</p> <p class="label">Villains seen</p>
${villains.length ? `<ul class="rows">${villains.map(v => ` ${villains.length ? `<ul class="rows">${villains.map(v => `
+79
View File
@@ -0,0 +1,79 @@
"""Live table roster: seat players, attach reads by handle, roster on the HUD."""
from __future__ import annotations
import importlib
import numpy as np
import pytest
def _fake_embed(texts):
out = []
for t in texts:
v = np.zeros(64, dtype=np.float32)
for w in t.lower().split():
v[hash(w) % 64] += 1.0
out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist())
return out
@pytest.fixture
def mods(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", _fake_embed)
import lyra.memory as memory
importlib.reload(memory)
import lyra.poker as poker
importlib.reload(poker)
import lyra.tools as tools
importlib.reload(tools)
return poker, tools
def test_seat_players_builds_roster(mods):
poker, _ = mods
poker.start_session(venue="Meadows", buy_in=300)
n = poker.seat_players(["TAG", "Jonathan", {"name": "Wheelz", "seat": "3"}])
assert n == 3
roster = poker.session_roster()
names = {r["name"] for r in roster}
assert names == {"TAG", "Jonathan", "Wheelz"}
assert next(r for r in roster if r["name"] == "Wheelz")["seat"] == "3"
def test_read_attaches_to_seated_player_by_handle(mods):
poker, _ = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.seat_players(["TAG"])
poker.add_read(note="limped A4o from the SB, UTG straddle", name="TAG")
roster = poker.session_roster()
tag = next(r for r in roster if r["name"] == "TAG")
assert tag["reads"] == 1 and "A4o" in tag["last_note"]
# No duplicate TAG spawned — the read landed on the seated player.
assert sum(p["name"] == "TAG" for p in poker.get_villain_file()) == 1
def test_seat_players_tool_and_roster_in_hud(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
out = tools.dispatch("seat_players", {"players": [{"name": "TAG"}, {"name": "JD"}]}, {})
assert "TAG" in out and "JD" in out
assert len(poker.hud()["roster"]) == 2
def test_unseat_player_removes_from_roster_keeps_history(mods):
poker, _ = mods
poker.start_session(venue="Meadows", buy_in=300)
poker.seat_players(["TAG"])
poker.add_read(note="showed a bluff", name="TAG")
assert poker.unseat_player(name="TAG") is True
assert poker.session_roster() == [] # off the table
assert poker.player_profile("TAG")["reads"] # history intact
def test_seat_players_accepts_plain_name_list_via_tool(mods):
poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300)
tools.dispatch("seat_players", {"players": "TAG, JD, Wheelz"}, {})
assert {r["name"] for r in poker.session_roster()} == {"TAG", "JD", "Wheelz"}