"""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})"