"""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 _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 _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"])}, "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'"}}, [])}, "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})"