49b88af3cc
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>
263 lines
12 KiB
Python
263 lines
12 KiB
Python
"""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
|
|
|
|
from lyra import 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"),
|
|
)
|
|
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()]
|
|
|
|
|
|
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})"
|