Files
project-lyra/lyra/tools.py
T
serversdown 35c973df05 feat: session_state read tool so she can see the HUD
She could write everything the HUD shows but not read most of it back (stack,
live net, alligator state, scar/confidence entries) — so "what's my live net?"
or "what's in my confidence bank?" was a memory guess.

- session_state tool returns the same bundle the HUD renders, as a readable
  summary; added to the Cash toolset.
- Cash card tells her the HUD exists, that she and it share the same data, and to
  answer where-am-I questions from session_state, never memory.
- test_modes.py +1; 42 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:16:40 +00:00

511 lines
24 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
import re
from lyra import equity, 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"),
chat_session_id=ctx.get("session_id"),
)
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_stack(args: dict, ctx: dict) -> str:
try:
amount = float(args.get("amount"))
except (TypeError, ValueError):
return "Give me a number for the stack."
try:
st = poker.log_stack(amount)
except ValueError:
return "No live session — start one first, then I'll track your stack."
net = st.get("net")
return f"Stack ${amount:g} logged" + (f" (net {net:+.0f})." if net is not None else ".")
def _scar_note(args: dict, ctx: dict) -> str:
content = (args.get("content") or "").strip()
if not content:
return "Nothing to log — give me the scar."
cls = (args.get("classification") or "").strip().lower() or None
if cls and cls not in ("punt", "cooler", "standard"):
cls = None
try:
poker.log_ritual("scar", content=content, classification=cls,
hand_id=args.get("hand_id"))
except ValueError:
return "No live session — start one and I'll keep the scar notes."
return f"Scar note logged{f' ({cls})' if cls else ''}."
def _confidence_bank(args: dict, ctx: dict) -> str:
content = (args.get("content") or "").strip()
if not content:
return "Nothing to bank — tell me the good process."
try:
poker.log_ritual("confidence", content=content, hand_id=args.get("hand_id"))
except ValueError:
return "No live session — start one and I'll run the confidence bank."
return "Banked. 💰"
def _alligator_blood(args: dict, ctx: dict) -> str:
on = bool(args.get("on", True))
try:
poker.set_alligator(on)
except ValueError:
return "No live session to set that on."
return ("🐊 Alligator Blood ON — hang around, refuse to die, no forced miracles."
if on else "Alligator Blood off. Back to standard register.")
def _reset_ritual(args: dict, ctx: dict) -> str:
content = (args.get("content") or "").strip() or None
try:
poker.log_ritual("reset", content=content)
except ValueError:
return "No live session to reset."
return "Reset logged. Clean slate — this is a new session in your head."
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_state(args: dict, ctx: dict) -> str:
h = poker.hud()
if not h:
return "No live session right now."
s, st, r = h["session"], h["stack"], h["rituals"]
L = [f"{s.get('stakes') or '?'} {s.get('game') or ''} @ {s.get('venue') or '?'} "
f"{h['stats']['hands_logged']} hands logged"]
if st.get("current") is not None:
L.append(f"Stack ${st['current']:g} (in {st['buy_in']:g}, live net {st['net']:+.0f})")
else:
L.append(f"Stack not logged yet (in {st['buy_in']:g})")
L.append("🐊 Alligator Blood is ON" if r["alligator"] else "Alligator Blood: off")
if r["confidence"]:
L.append("Confidence bank: " + " | ".join(c["content"] for c in r["confidence"][-4:]))
if r["scars"]:
L.append("Scar notes: " + " | ".join(
sc["content"] + (f" [{sc['classification']}]" if sc.get("classification") else "")
for sc in r["scars"][-4:]))
if r["resets"]:
L.append(f"{len(r['resets'])} reset(s) this session")
return "\n".join(L)
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 _record_hand(args: dict, ctx: dict) -> str:
out = poker.record_hand(
args.get("shorthand") or "", stakes=args.get("stakes"),
tag=args.get("tag"), lesson=args.get("lesson"),
)
if not out["id"]:
return "I couldn't parse that hand — give it to me again with a little more detail?"
p = out["parsed"]
cards = " ".join(p.get("hero_cards") or [])
logbus.log("info", "hand reconstructed", id=out["id"], hero=p.get("hero_pos"))
return (f"Hand #{out['id']} reconstructed — {p.get('hero_pos') or '?'} "
f"{cards}. View/replay it at /hand/{out['id']}")
def _generate_recap(args: dict, ctx: dict) -> str:
out = poker.generate_recap()
if not out:
return "No session to recap yet — start (and ideally finish) one first."
logbus.log("info", "recap generated", id=out["id"], chars=len(out["markdown"]))
return (f"Recap written for session #{out['id']} — view or download the .md "
f"at /recap/{out['id']}")
def _analyze_spot(args: dict, ctx: dict) -> str:
def cards(s):
return [c for c in re.split(r"[\s,]+", (s or "").strip()) if c]
try:
r = equity.analyze(cards(args.get("hero")), cards(args.get("villain")),
cards(args.get("board")))
except equity.EquityError as e:
return f"(can't compute equity: {e})"
except Exception as e: # never let a bad spot kill the turn
return f"(equity error: {e})"
street = {0: "preflop", 3: "flop", 4: "turn", 5: "river"}.get(len(r["board"]), "")
L = [f"Board: {' '.join(r['board']) or '(preflop)'}" + (f"{street}" if street else "")]
if "hero_hand" in r:
L.append(f"You ({' '.join(r['hero'])}): {r['hero_hand']}")
L.append(f"Villain ({' '.join(r['villain'])}): {r['villain_hand']}")
L.append(f"Currently ahead: {r['ahead']}")
tie = f" / tie {r['tie_equity']}%" if r.get("tie_equity") else ""
L.append(f"EQUITY (exact): you {r['hero_equity']}% / villain {r['villain_equity']}%{tie}")
o = r.get("hero_outs")
if o:
L.append(f"Your outs (one card to come): {o['count']}"
+ (f"{' '.join(o['cards'])}" if o["count"] else " — drawing dead"))
return "\n".join(L)
def _player_profile(args: dict, ctx: dict) -> str:
prof = poker.player_profile(args.get("name") or "")
if not prof:
return f"No file on {args.get('name')} yet."
p = prof["player"]
L = [p["name"] + (f" ({p['venue']})" if p.get("venue") else "")
+ (f" [{p['category']}]" if p.get("category") else "")]
thin = not (p.get("tendencies") or p.get("adjustment")) and not prof.get("stats")
if thin:
L.append("⚠ THIN FILE — no standing read on record. Report only the observed "
"hand(s) below and tell Brian you've barely seen him. Do NOT generalize a style.")
if p.get("description"):
L.append(p["description"])
if p.get("tendencies"):
L.append(f"Tendencies: {p['tendencies']}")
if p.get("adjustment"):
L.append(f"Exploit: {p['adjustment']}")
s = prof.get("stats")
if s:
L.append(f"Stats ({s['hands']} hands): VPIP {s['vpip_pct']}% · PFR {s['pfr_pct']}% · WTSD {s['wtsd_pct']}%")
elif prof.get("small_sample"):
L.append(prof["small_sample"])
if prof.get("showdowns"):
L.append("Shown down: " + ", ".join(prof["showdowns"][:6]))
if prof.get("reads"):
L.append("Notes: " + " | ".join(prof["reads"][:4]))
if prof.get("recent"):
L.append("Recent hands: " + " | ".join(prof["recent"][:4]))
return "\n".join(L)
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_stack": {"handler": _log_stack, "spec": _f(
"log_stack",
"Record Brian's CURRENT total chip stack in the live session. Call whenever "
"he states his stack ('I'm at 350', 'down to 220', 'stacked off to 900'). "
"Tracks his stack over time and his live net while he's still sitting.",
{"amount": {**_N, "description": "Current total chip stack, in dollars"}},
["amount"])},
"scar_note": {"handler": _scar_note, "spec": _f(
"scar_note",
"Log a SCAR NOTE — a painful or instructive mistake to study later. Use when "
"Brian punts, gets too attached, or makes a leak — or when he flags one. "
"Classify honestly: 'punt' (his error), 'cooler' (unavoidable), or 'standard' "
"(correct play, bad result). The punt-vs-cooler distinction matters to him.",
{"content": {**_S, "description": "What happened and the lesson, in Brian's terms"},
"classification": {**_S, "description": "punt | cooler | standard"},
"hand_id": {**_N, "description": "Linked hand id, if this scar is a logged hand"}},
["content"])},
"confidence_bank": {"handler": _confidence_bank, "spec": _f(
"confidence_bank",
"Log a CONFIDENCE BANK entry — good PROCESS regardless of result: a disciplined "
"laydown, clean value bet, catching a leak in real time, sticking to the plan. "
"Bank it when he does something right, especially when the result didn't reward it.",
{"content": {**_S, "description": "The disciplined / good-process play to bank"},
"hand_id": {**_N, "description": "Linked hand id, if applicable"}},
["content"])},
"alligator_blood": {"handler": _alligator_blood, "spec": _f(
"alligator_blood",
"Toggle ALLIGATOR BLOOD mode — Brian's adversity state: hang around, refuse to "
"die, don't force miracles, make opponents beat him correctly. Turn it ON when he "
"invokes it, or SUGGEST it (then turn on if he agrees) when he's card-dead, short, "
"stuck, or grinding through a downswing. Turn OFF on reset or when he's back in rhythm.",
{"on": {"type": "boolean", "description": "true to engage, false to stand down"}},
[])},
"reset_ritual": {"handler": _reset_ritual, "spec": _f(
"reset_ritual",
"Log a RESET — a deliberate mental circuit-breaker after a loss or tilt spike, "
"treating the rest of the night as a fresh start (the stats stay continuous). "
"Use when he resets, or when you've talked him through one.",
{"content": {**_S, "description": "Optional note on what prompted the reset"}},
[])},
"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.",
{}, [])},
"session_state": {"handler": _session_state, "spec": _f(
"session_state",
"Read back the CURRENT live-session state — the same data Brian sees on his HUD: "
"stack, live net, whether Alligator Blood is on, and the scar notes / "
"confidence-bank entries so far. Use whenever he asks where he's at, what's in "
"the bank, his stack or net, or if gator mode is on — answer from THIS, not memory.",
{}, [])},
"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'"}},
[])},
"record_hand": {"handler": _record_hand, "spec": _f(
"record_hand",
"Reconstruct a hand from Brian's rough shorthand into a structured, "
"replayable hand history. Use when he describes/vomits a hand he wants "
"saved or to review. Pass his description verbatim as 'shorthand'.",
{"shorthand": {**_S, "description": "Brian's rough description of the hand, verbatim"},
"stakes": {**_S, "description": "Stakes if known, e.g. '1/3'"},
"tag": {**_S, "description": "well_played | leak | cooler | confidence | notable"},
"lesson": {**_S, "description": "Takeaway, if he stated one"}},
["shorthand"])},
"generate_recap": {"handler": _generate_recap, "spec": _f(
"generate_recap",
"Write up the full session recap (.md) in Brian's format from the logged "
"data + this conversation. Use when he asks for the recap/writeup, usually "
"after ending a session.",
{}, [])},
"analyze_spot": {"handler": _analyze_spot, "spec": _f(
"analyze_spot",
"Compute EXACT poker equity, what each hand makes, who's ahead, and outs "
"for a hero-vs-villain spot. ALWAYS use this for any equity / board-reading "
"/ 'am I ahead' / outs question — never compute it yourself.",
{"hero": {**_S, "description": "Hero's hole cards, rank+suit letters, e.g. 'Jh Js' (use 'Jx' if a suit is unknown)"},
"villain": {**_S, "description": "Villain's hole cards, e.g. '6d 5d'"},
"board": {**_S, "description": "Board cards so far, e.g. '8c 7d Ts' (flop) or '8c 7d Ts 4d' (turn); omit for preflop"}},
["hero", "villain"])},
"player_profile": {"handler": _player_profile, "spec": _f(
"player_profile",
"Look up everything known about one opponent — dossier, reads, hands "
"they've shown down, and (once enough hands are logged) inferred stats "
"like VPIP/PFR. Use when Brian asks what's known about a player.",
{"name": {**_S, "description": "Player name to look up"}},
["name"])},
"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(allow=None) -> list[dict]:
"""OpenAI-format tool definitions to offer the model.
`allow` (an iterable of tool names, e.g. a mode's allow-list) restricts the
set; None means every tool. Unknown names in `allow` are ignored.
"""
if allow is None:
return [t["spec"] for t in TOOLS.values()]
allow = set(allow)
return [t["spec"] for name, t in TOOLS.items() if name in allow]
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})"