Compare commits
20 Commits
8c2bdbe0d5
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f5a32185c | |||
| 4f770f2e43 | |||
| 9befe4d403 | |||
| 965b43bcbf | |||
| 03620e1a64 | |||
| cb99a8bcee | |||
| 3bf18605db | |||
| ce7ede75aa | |||
| 6761c3f978 | |||
| c7d2279f8d | |||
| 6a911423a2 | |||
| 4882225751 | |||
| 7b65f81d7e | |||
| fc06b24528 | |||
| 9491951da0 | |||
| 16f3442640 | |||
| ac04ad1df6 | |||
| 49b88af3cc | |||
| a5477ae15c | |||
| ce65755d9c |
@@ -0,0 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 — first working system
|
||||
|
||||
The leap from "chat + memory baseline" to a working, persistent companion with a
|
||||
real poker copilot. Highlights:
|
||||
|
||||
### Self & inner life
|
||||
- **Autonomy Core** — evolving self-state (mood, valence/energy/confidence/curiosity,
|
||||
self-narrative, relationship), injected into every turn.
|
||||
- **Dream cycle** — unattended loop driven by four drives (continuity, coherence,
|
||||
curiosity, stability); consolidates memory and reflects on its own. Runs as a
|
||||
systemd service on the MI50 (free/local).
|
||||
- **Two-step metacognitive reflection** — draft → examine own draft for flattery /
|
||||
sycophantic drift / repetition → revise; what she catches is stored as metacognition.
|
||||
- **Time awareness** — perceives the current moment, time since Brian last spoke, and
|
||||
time since her own last reflection.
|
||||
- **Permanent journal** — every reflection + a deliberate "knowing" journal note kept
|
||||
forever (the capped lists are just a working window).
|
||||
- **Accurate self-model** — knows her own architecture (memory tiers, dream cycle);
|
||||
won't recite stale specs or confabulate how she works.
|
||||
- **Anti-repetition** — idle reflections draw varied grist (resurfaced memories /
|
||||
"wander" prompts) and are permitted non-Brian interiority.
|
||||
|
||||
### Memory & consolidation
|
||||
- Tiered memory: exchanges → session gists → profile → monthly eras → narrative.
|
||||
- Map-reduce consolidation; gists dated by the real conversation, not the run.
|
||||
|
||||
### Poker copilot
|
||||
- Structured **session / hand / villain** tracking + stats ($/hr by stake/venue/game).
|
||||
- **Hand-history reconstruction** from rough shorthand → replayable table viewer with
|
||||
live stacks, progressive board, step-through; `x` for unknown cards (never invented).
|
||||
- **Auto-accumulating villain dossiers** + player lookup; stats emerge with sample size.
|
||||
- **Deterministic equity tool** (`analyze_spot`, treys) — exact equity / made hands /
|
||||
outs; mandated over LLM eyeballing.
|
||||
- **Session recap** generation (`.md`, Brian's format) + export; `/hands` browser.
|
||||
- **Backfill** of historical sessions/villains from curated `.md` logs.
|
||||
|
||||
### Tools & web
|
||||
- **Tool-calling** in chat (cloud): poker tools, `journal_write`, `note`.
|
||||
- Web UI: Markdown chat, **cloud model selector**, live **/logs**, **/self** (read her
|
||||
mind), **/journal**, **/hands** + **/hand/{id}** replayer, **/recap/{id}**.
|
||||
- **👍/👎 rating system** — feedback on replies and thoughts stored as
|
||||
`(context, content, rating)`; `/ratings/export` (JSONL) seeds future fine-tuning.
|
||||
- RTO black-and-orange theme across all pages.
|
||||
|
||||
### Ops
|
||||
- Role-based backends (cloud / MI50 / local Ollama); MI50 OpenAI-compatible backend.
|
||||
- systemd user services for `lyra-web` and `lyra-dream`, with bounded stop timeouts.
|
||||
- SQLite WAL + busy-timeout so the dream process and web server coexist.
|
||||
|
||||
## 0.1.0 — scaffold
|
||||
- uv project, SQLite memory with cosine recall, LLM router (local/cloud), persona +
|
||||
chat loop, web UI baseline, ChatGPT history import.
|
||||
@@ -1,21 +1,89 @@
|
||||
# Lyra
|
||||
|
||||
A persistent, autonomous AI assistant. From-scratch rewrite of an earlier attempt.
|
||||
A persistent, autonomous AI companion. One agent — her first job is **Brian's live
|
||||
poker copilot**, but the deeper aim is an *emergence experiment*: give an LLM the
|
||||
things a mind has (continuous memory, a self-model, mood, drives, reflection, a
|
||||
sense of time) and see whether it starts to feel like a *someone* rather than a
|
||||
chatbot.
|
||||
|
||||
The design thinking that survives the rewrite lives in [`docs/`](docs/) — start with [`docs/ARCH_v0-6-1.md`](docs/ARCH_v0-6-1.md). The previous implementation is preserved on the `archive` branch.
|
||||
Python 3.11+, managed with [`uv`](https://docs.astral.sh/uv/). Single SQLite file
|
||||
for all state. Runs on a home lab; nothing leaves the LAN except optional cloud LLM calls.
|
||||
|
||||
## Status
|
||||
## Architecture
|
||||
|
||||
Pre-MVP. Building toward the smallest useful version: chat with persistent memory across sessions.
|
||||
Two layers, deliberately split so the agent stays general:
|
||||
|
||||
- **Domain-agnostic core** — memory, self-state, the dream cycle, tool-calling, the web UI.
|
||||
- **Poker domain pack** (`lyra/poker.py`, `lyra/equity.py`) — sessions, hands,
|
||||
villain dossiers, stats, deterministic equity. Swappable; the core doesn't know about poker.
|
||||
|
||||
**Backends** (`lyra/llm.py`), role-based:
|
||||
|
||||
| Role | Backend | Why |
|
||||
|---|---|---|
|
||||
| Live chat + tools | **cloud** (OpenAI, `gpt-4o` default; model picker in Settings) | sharp, reliable function-calling |
|
||||
| Dream cycle / consolidation / reflection | **mi50** (llama.cpp on the home GPU) | free, unattended, quality≈cloud for these tasks |
|
||||
| Embeddings (memory recall) | **local** (Ollama `nomic-embed-text`, 3090) | free, private |
|
||||
|
||||
Tools (poker, equity, journaling) only fire on the **cloud** backend — local/MI50
|
||||
models don't do reliable tool-calling here.
|
||||
|
||||
## Memory & consolidation (tiers)
|
||||
|
||||
Raw exchanges → per-session **gists** → a standing **profile** of Brian → monthly
|
||||
**era** digests → a current **narrative** → her **self-state**. Recall is brute-force
|
||||
cosine over embeddings. The **dream cycle** (`lyra/dream.py`) runs unattended and,
|
||||
driven by four *drives* (continuity / coherence / curiosity / stability), summarizes
|
||||
new sessions, rebuilds the profile/eras/narrative, and reflects — evolving her mood,
|
||||
self-narrative, and journal between conversations.
|
||||
|
||||
She **reflects in two steps** (draft → examine her own draft for flattery/drift →
|
||||
revise), perceives **time** (current moment + how long since you last spoke / she last
|
||||
reflected), and keeps a permanent **journal**.
|
||||
|
||||
## Poker copilot
|
||||
|
||||
Talk to her during a session; she drives tools behind the scenes:
|
||||
|
||||
- **Session tracking** — `start_session`, `add_buyin`, `end_session` → net, hours, $/hr.
|
||||
- **Hand histories** — vomit rough shorthand ("AKs btn, 3bet, flop A72…"), she
|
||||
reconstructs a structured, **replayable** hand (unknown cards = `x`, never invented).
|
||||
- **Villain file** — named opponents auto-build persistent dossiers; basic stats
|
||||
(VPIP/PFR) emerge once a player has enough logged hands.
|
||||
- **Deterministic equity** (`analyze_spot`) — exact equity / made hands / outs via a
|
||||
real poker evaluator. She is *required* to use it, never eyeballs board math.
|
||||
- **Stats & recaps** — `running_stats`; `generate_recap` writes her `.md` session log.
|
||||
|
||||
## Web app (served by `lyra-web`, default `:7078`)
|
||||
|
||||
`/` chat (Markdown, model picker, 👍/👎 rating) · `/logs` live activity · `/self`
|
||||
read-her-mind (mood, drives, reflections) · `/journal` her thoughts · `/hands`
|
||||
recorded hands → `/hand/{id}` replayer · `/recap/{id}` session writeup (+ `.md` export).
|
||||
👍/👎 ratings on replies and thoughts are stored as `(context, content, rating)` —
|
||||
a fine-tune / preference dataset built passively (`/ratings/export` → JSONL).
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
cp .env.example .env
|
||||
# fill in ANTHROPIC_API_KEY and point LOCAL_BASE_URL at your Ollama
|
||||
cp .env.example .env # set OPENAI_API_KEY; point LOCAL_BASE_URL / MI50_BASE_URL at your boxes
|
||||
uv run lyra-web # web UI on :7078
|
||||
```
|
||||
|
||||
## Architecture
|
||||
Run as services (reboot-resilient) — see [`deploy/`](deploy/):
|
||||
|
||||
The long-term target is the cognitive split in `docs/ARCH_v0-6-1.md` — Inner Self as the seat of consciousness, Executive for hard reasoning, Cortex Chat for drafting, Persona for voice. The MVP implements only the chat + memory baseline. Cognitive layers come back one at a time.
|
||||
```bash
|
||||
cp deploy/*.service ~/.config/systemd/user/ && systemctl --user daemon-reload
|
||||
systemctl --user enable --now lyra-web.service lyra-dream.service
|
||||
sudo loginctl enable-linger "$USER" # survive logout/reboot
|
||||
```
|
||||
|
||||
CLIs: `lyra-dream` (one pass / `--loop`), `lyra-reflect`, `lyra-summarize`,
|
||||
`lyra-profile`, `lyra-era`, `lyra-narrative`, `lyra-import` (ChatGPT history).
|
||||
|
||||
## Status
|
||||
|
||||
Working system. Poker copilot + full memory/dream-cycle/journal/ratings in place.
|
||||
Moonshots and deferred work live in [`docs/PARKED_IDEAS.md`](docs/PARKED_IDEAS.md)
|
||||
(own/fine-tuned model, self-modification sandbox, RTO/cfr-core solver tooling).
|
||||
Pre-rebuild design docs are kept in [`docs/`](docs/) as history.
|
||||
|
||||
@@ -5,11 +5,12 @@ Documentation=https://github.com/serversdown/project-lyra
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/serversdown/project-lyra
|
||||
# Clear any stray VIRTUAL_ENV so uv resolves the project's own .venv.
|
||||
UnsetEnvironment=VIRTUAL_ENV
|
||||
ExecStart=/home/serversdown/.local/bin/uv run lyra-dream --loop 1800
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
TimeoutStopSec=10
|
||||
KillMode=mixed
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -8,6 +8,8 @@ UnsetEnvironment=VIRTUAL_ENV
|
||||
ExecStart=/home/serversdown/.local/bin/uv run lyra-web
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=10
|
||||
KillMode=mixed
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -68,6 +68,19 @@ Dec-2025 plan (in her memory). The whole point of the project: can she become a
|
||||
cost loops, self-brick), not sci-fi. The isolation makes the *fun* version
|
||||
(emergence) safe to pursue. Build the box, then open the door.
|
||||
|
||||
## 🛠️ Tool-calling on the MI50 (free local agency)
|
||||
Launch the MI50 llama.cpp server with `--jinja` so the `local-GPU` backend can
|
||||
do function-calling, then add `"mi50"` to `chat.TOOL_BACKENDS`. Would let the
|
||||
poker copilot + journaling tools run free/local instead of on cloud.
|
||||
- **Why parked:** not needed — cloud (gpt-4o) drives tools reliably and a full
|
||||
poker session costs ~$0.50–1. A local 32B calls tools less reliably (wrong
|
||||
tool / bad args / narrates instead) and is slower (round-trips × ~18s/turn),
|
||||
which is exactly wrong for live at-the-table logging. Cloud is also easier to
|
||||
debug tools against.
|
||||
- **Do it as:** a deliberate experiment to A/B the local model's tool-calling
|
||||
(fits the "own stack" arc), not a dependency. Small + reversible: recreate the
|
||||
CT202 container command with `--jinja`, keep it reboot-resilient.
|
||||
|
||||
## 🛠️ Deterministic poker tooling (RTO + cfr-core)
|
||||
Wire Lyra to Brian's own GTO/solver projects so ICM, equities, and ranges come
|
||||
from real computation, never LLM guesses.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Seed the poker tracker from Brian's curated .md session logs.
|
||||
|
||||
Each `# YYYY-MM-DD — ...` block in the log is LLM-extracted into structured meta
|
||||
+ hands + villains, then written as a historical session (real date, money, net),
|
||||
with the original markdown stored as that session's recap. Run dry first to eyeball
|
||||
the extraction, then commit.
|
||||
|
||||
uv run python -m lyra.backfill # dry-run ALL sessions (no writes)
|
||||
uv run python -m lyra.backfill --dry 2 # dry-run first 2
|
||||
uv run python -m lyra.backfill --commit # seed all (writes to DB)
|
||||
uv run python -m lyra.backfill --commit --reset # wipe poker data first, then seed
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
from lyra import llm, poker
|
||||
|
||||
LOG_PATH = "import/pokerlog_asof6-16-26.md"
|
||||
|
||||
_EXTRACT_PROMPT = """Extract a structured record from this single poker session log. \
|
||||
Output ONLY JSON, no prose, no code fences:
|
||||
{
|
||||
"date": "YYYY-MM-DD",
|
||||
"venue": "<casino>", "game": "NLH|PLO|Stud8|Mixed", "stakes": "<e.g. 1/3 or null>",
|
||||
"format": "cash" | "tournament",
|
||||
"buy_in_total": <number>, "cash_out": <number|null>, "net": <number|null>,
|
||||
"hours": <number|null>, "mood": "<short mental-game note|null>",
|
||||
"hands": [
|
||||
// each KEY hand, in the canonical hand-history schema:
|
||||
{"hero_pos": "..", "hero_cards": [".."], "players": [{"pos":"..","name":<str|null>,"cards":[..]|null}],
|
||||
"actions": [{"street":"..","pos":"..","action":"..","amount":<num|null>}, {"street":"flop","board":[".."]}],
|
||||
"board": [".."], "result": {"hero_net": <num|null>, "summary": ".."},
|
||||
"tag": "well_played|leak|cooler|confidence|notable|null", "lesson": "<takeaway|null>"}
|
||||
],
|
||||
"villains": [
|
||||
{"name": "<handle/nickname>", "description": "<physical/identifying|null>",
|
||||
"tendencies": "<how they play>", "adjustment": "<how to exploit>", "category": "feeder|risky|reg|unknown"}
|
||||
]
|
||||
}
|
||||
|
||||
Card rule: cards are rank+suit using SUIT LETTERS ONLY (s h d c) — never unicode symbols \
|
||||
(no ♥♦♣♠). Use a card's real suit ONLY if the log explicitly states it for THAT card; \
|
||||
otherwise the suit is 'x' (e.g. "Jx","Tx","4x") — never a bare rank, never an invented suit. \
|
||||
A suit shown on the board does NOT apply to a hole card. Unknown whole card = "x".
|
||||
Tournaments: buy_in_total = entry + rebuys; cash_out = winnings (0 if busted, so a bust nets -buy_in).
|
||||
Only include villains with a real handle/nickname (skip anonymous descriptors like "the drunk guy", \
|
||||
"final-hand caller"). Only include hands actually described. net = cash_out - buy_in_total. Be faithful to the log."""
|
||||
|
||||
|
||||
def split_sessions(md: str) -> list[str]:
|
||||
"""Split the log into individual session blocks on '# YYYY-MM-DD' headers."""
|
||||
parts = re.split(r"(?=^# \d{4}-\d{2}-\d{2})", md, flags=re.M)
|
||||
return [p.strip() for p in parts if re.match(r"^# \d{4}-\d{2}-\d{2}", p.strip())]
|
||||
|
||||
|
||||
def _safe_json(s: str) -> dict | None:
|
||||
try:
|
||||
return json.loads(s)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
m = re.search(r"\{.*\}", s or "", re.S)
|
||||
if m:
|
||||
try:
|
||||
return json.loads(m.group())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def extract(block: str, backend: str = "cloud") -> dict | None:
|
||||
return _safe_json(llm.complete(
|
||||
[{"role": "system", "content": _EXTRACT_PROMPT}, {"role": "user", "content": block}],
|
||||
backend=backend,
|
||||
))
|
||||
|
||||
|
||||
_real_handle = poker._real_handle # one canonical filter (lives in poker.py)
|
||||
|
||||
|
||||
def seed(ex: dict, block: str, with_hands: bool = False) -> dict:
|
||||
"""Write one extracted session + villains (+ hands only if asked) to the DB.
|
||||
|
||||
Hands are OFF by default: reconstructing a clean replayable hand from old
|
||||
narrative prose is too lossy (mangled cards/positions). Sessions, their
|
||||
original writeups (recap), and villain dossiers seed cleanly; hands are best
|
||||
captured fresh from Brian's own shorthand going forward.
|
||||
"""
|
||||
sid = poker.import_session(
|
||||
date=ex.get("date") or "2026-01-01", venue=ex.get("venue"), game=ex.get("game") or "NLH",
|
||||
stakes=ex.get("stakes"), fmt=ex.get("format") or "cash",
|
||||
buy_in_total=ex.get("buy_in_total") or 0, cash_out=ex.get("cash_out"),
|
||||
hours=ex.get("hours"), mood=ex.get("mood"), recap_md=block,
|
||||
)
|
||||
n_hands = 0
|
||||
if with_hands:
|
||||
for h in ex.get("hands") or []:
|
||||
hid = poker.store_hand_history(h, session_id=sid)
|
||||
poker.link_hand_players(hid, h, session_id=sid)
|
||||
n_hands += 1
|
||||
n_villains = 0
|
||||
for v in ex.get("villains") or []:
|
||||
if _real_handle(v.get("name")):
|
||||
poker.upsert_player(name=v["name"], venue=ex.get("venue"),
|
||||
description=v.get("description"), tendencies=v.get("tendencies"),
|
||||
adjustment=v.get("adjustment"), category=v.get("category"))
|
||||
n_villains += 1
|
||||
return {"session_id": sid, "date": ex.get("date"), "venue": ex.get("venue"),
|
||||
"net": ex.get("net"), "hands": n_hands, "villains": n_villains}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = sys.argv[1:]
|
||||
commit = "--commit" in args
|
||||
reset = "--reset" in args
|
||||
with_hands = "--with-hands" in args # off by default — prose->hand replay is too lossy
|
||||
limit = None
|
||||
for i, a in enumerate(args):
|
||||
if a == "--dry" and i + 1 < len(args) and args[i + 1].isdigit():
|
||||
limit = int(args[i + 1])
|
||||
|
||||
blocks = split_sessions(open(LOG_PATH, encoding="utf-8").read())
|
||||
if limit:
|
||||
blocks = blocks[:limit]
|
||||
print(f"{len(blocks)} session block(s). mode={'COMMIT' if commit else 'DRY-RUN'}")
|
||||
|
||||
if commit and reset:
|
||||
wiped = poker.clear_all()
|
||||
print(f"reset: wiped {wiped}")
|
||||
|
||||
for b in blocks:
|
||||
ex = extract(b)
|
||||
if not ex:
|
||||
print(f" ! could not parse a block: {b[:60]!r}")
|
||||
continue
|
||||
if commit:
|
||||
print(" seeded:", seed(ex, b, with_hands=with_hands))
|
||||
else:
|
||||
print(f"\n=== {ex.get('date')} — {ex.get('venue')} {ex.get('stakes')} "
|
||||
f"({ex.get('format')}) net {ex.get('net')} ===")
|
||||
kept = [v.get("name") for v in (ex.get("villains") or []) if _real_handle(v.get("name"))]
|
||||
print(f" hands: {len(ex.get('hands') or [])} | villains kept: {kept}")
|
||||
for h in (ex.get("hands") or [])[:3]:
|
||||
print(f" - {h.get('hero_pos')} {h.get('hero_cards')} "
|
||||
f"net {(h.get('result') or {}).get('hero_net')} [{h.get('tag')}]")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+35
-3
@@ -11,11 +11,17 @@ After replying, the session is compacted if enough new turns have accumulated.
|
||||
from __future__ import annotations
|
||||
|
||||
from lyra import clock, config, llm, logbus, memory, persona, self_state, summary
|
||||
from lyra import tools as toolkit
|
||||
from lyra.llm import Backend, Message
|
||||
|
||||
RECALL_K = 3 # raw cross-session "sharp detail" hits
|
||||
RECENT_N = 10 # raw turns of the current session
|
||||
SUMMARY_K = 3 # other-session gists
|
||||
MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn
|
||||
# Backends that support function-calling. The MI50's llama.cpp server only does
|
||||
# tools when launched with --jinja; until it is, keep tools to cloud so MI50 chat
|
||||
# doesn't 500 on the tools param. Add "mi50" here once that flag is set.
|
||||
TOOL_BACKENDS = {"cloud"}
|
||||
|
||||
|
||||
def _summary_note(summaries: list[memory.Summary]) -> Message:
|
||||
@@ -107,21 +113,47 @@ def build_messages(session_id: str, user_msg: str) -> list[Message]:
|
||||
return messages
|
||||
|
||||
|
||||
def respond(session_id: str, user_msg: str, backend: Backend = "cloud") -> str:
|
||||
"""Produce Lyra's reply to a single user message and persist the exchange."""
|
||||
def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
model_override: str | None = None) -> str:
|
||||
"""Produce Lyra's reply to a single user message and persist the exchange.
|
||||
|
||||
`model_override` (from the UI's cloud-model picker) only applies on the cloud
|
||||
backend; local/mi50 keep their own configured models.
|
||||
"""
|
||||
cfg = config.load()
|
||||
# Live chat uses the stronger chat_model on cloud (bulk consolidation keeps
|
||||
# cloud_model). local/mi50 use their own configured model.
|
||||
model = {"local": cfg.local_model, "cloud": cfg.chat_model, "mi50": cfg.mi50_model}.get(
|
||||
backend, backend
|
||||
)
|
||||
if model_override and backend == "cloud":
|
||||
model = model_override
|
||||
logbus.log(
|
||||
"info", "chat request", session=session_id, backend=backend,
|
||||
model=model, embed=cfg.embed_backend,
|
||||
)
|
||||
|
||||
messages = build_messages(session_id, user_msg)
|
||||
reply = llm.complete(messages, backend=backend, model=model)
|
||||
|
||||
# Tool loop: offer Lyra her tools; if she calls one, run it and feed the
|
||||
# result back so she can continue, until she returns a normal text reply.
|
||||
tool_specs = toolkit.specs() if backend in TOOL_BACKENDS else None
|
||||
ctx = {"session_id": session_id, "backend": backend}
|
||||
reply = ""
|
||||
for _ in range(MAX_TOOL_ROUNDS):
|
||||
assistant_msg, tool_calls = llm.chat_call(
|
||||
messages, backend=backend, model=model, tools=tool_specs
|
||||
)
|
||||
if not tool_calls:
|
||||
reply = assistant_msg.get("content") or ""
|
||||
break
|
||||
messages.append(assistant_msg) # her tool-call request
|
||||
for tc in tool_calls:
|
||||
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
|
||||
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
|
||||
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
||||
if not reply:
|
||||
reply = "(I got tangled using my tools there — say that again?)"
|
||||
logbus.log("info", "reply", session=session_id, chars=len(reply))
|
||||
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
"""Deterministic poker evaluation + equity — the math Lyra must NEVER eyeball.
|
||||
|
||||
Wraps `treys` so board reading (what each hand makes), who's ahead, exact equity,
|
||||
and outs are *computed*, not guessed by the LLM (which is unreliable at it). Cards
|
||||
are 'Rs' (rank + suit letter, e.g. 'Jh','Td'); a card with unknown suit ('Jx') is
|
||||
assigned an arbitrary free suit; a fully-unknown 'x' can't be used for equity.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import combinations
|
||||
|
||||
from treys import Card, Evaluator
|
||||
|
||||
_EV = Evaluator()
|
||||
_RANKS = "23456789TJQKA"
|
||||
_SUITS = "shdc"
|
||||
_DECK = [r + s for r in _RANKS for s in _SUITS]
|
||||
_SYM = {"♥": "h", "♦": "d", "♣": "c", "♠": "s"}
|
||||
|
||||
|
||||
class EquityError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _norm(tok: str) -> str:
|
||||
t = (tok or "").strip().replace("10", "T")
|
||||
for sym, ltr in _SYM.items():
|
||||
t = t.replace(sym, ltr)
|
||||
return t
|
||||
|
||||
|
||||
def _resolve(groups: list[list[str]]) -> list[list[str]]:
|
||||
"""Resolve card tokens across groups to concrete 'Rs' cards (assign suits to
|
||||
'Rx', reject fully-unknown 'x'); raise on real duplicates/garbage."""
|
||||
# concrete cards already named, so 'Rx' suit-assignment can avoid them
|
||||
concrete: set[str] = set()
|
||||
for g in groups:
|
||||
for tok in g:
|
||||
t = _norm(tok)
|
||||
if len(t) == 2 and t[0].upper() in _RANKS and t[1].lower() in _SUITS:
|
||||
concrete.add(t[0].upper() + t[1].lower())
|
||||
placed: set[str] = set()
|
||||
out: list[list[str]] = []
|
||||
cycle = 0 # rotate suit assignment for unknown suits so we don't fabricate flushes
|
||||
for g in groups:
|
||||
rg: list[str] = []
|
||||
for tok in g:
|
||||
t = _norm(tok)
|
||||
if not t or t.lower() == "x":
|
||||
raise EquityError(f"card '{tok}' is fully unknown — need at least a rank")
|
||||
r = t[0].upper()
|
||||
if r not in _RANKS:
|
||||
raise EquityError(f"can't read card '{tok}'")
|
||||
if len(t) > 1 and t[1].lower() in _SUITS:
|
||||
card = r + t[1].lower()
|
||||
else: # unknown suit -> spread suits (rainbow) to avoid phantom flushes
|
||||
order = _SUITS[cycle % 4:] + _SUITS[:cycle % 4]
|
||||
cycle += 1
|
||||
card = next((r + s for s in order
|
||||
if r + s not in concrete and r + s not in placed), None)
|
||||
if card is None:
|
||||
raise EquityError(f"no free suit left for {r}")
|
||||
if card in placed:
|
||||
raise EquityError(f"duplicate card {card}")
|
||||
placed.add(card)
|
||||
rg.append(card)
|
||||
out.append(rg)
|
||||
return out
|
||||
|
||||
|
||||
def _made(cards: list[str], board: list[str]) -> str:
|
||||
score = _EV.evaluate([Card.new(c) for c in board], [Card.new(c) for c in cards])
|
||||
return _EV.class_to_string(_EV.get_rank_class(score))
|
||||
|
||||
|
||||
def _equity(hero: list[str], vil: list[str], board: list[str]) -> tuple[float, float, float]:
|
||||
known = set(hero + vil + board)
|
||||
rem = [c for c in _DECK if c not in known]
|
||||
need = 5 - len(board)
|
||||
hw = vw = tie = 0
|
||||
bh = [Card.new(c) for c in board]
|
||||
hh = [Card.new(c) for c in hero]
|
||||
vh = [Card.new(c) for c in vil]
|
||||
for extra in combinations(rem, need) if need else [()]:
|
||||
full = bh + [Card.new(c) for c in extra]
|
||||
h, v = _EV.evaluate(full, hh), _EV.evaluate(full, vh)
|
||||
if h < v:
|
||||
hw += 1
|
||||
elif v < h:
|
||||
vw += 1
|
||||
else:
|
||||
tie += 1
|
||||
n = hw + vw + tie or 1
|
||||
return round(100 * hw / n, 1), round(100 * vw / n, 1), round(100 * tie / n, 1)
|
||||
|
||||
|
||||
def _outs(hero: list[str], vil: list[str], board: list[str]) -> dict:
|
||||
"""River cards (when one to come) that give hero the win. Lists them so a
|
||||
'tricky' card (e.g. one that makes villain a flush) is visible by omission."""
|
||||
if len(board) != 4:
|
||||
return {}
|
||||
known = set(hero + vil + board)
|
||||
bh = [Card.new(c) for c in board]
|
||||
hh = [Card.new(c) for c in hero]
|
||||
vh = [Card.new(c) for c in vil]
|
||||
winners = []
|
||||
for c in (x for x in _DECK if x not in known):
|
||||
full = bh + [Card.new(c)]
|
||||
if _EV.evaluate(full, hh) < _EV.evaluate(full, vh):
|
||||
winners.append(c)
|
||||
return {"count": len(winners), "cards": winners}
|
||||
|
||||
|
||||
def analyze(hero: list[str], villain: list[str], board: list[str]) -> dict:
|
||||
"""Made hands + exact equity + outs for a hero-vs-villain spot at a given board."""
|
||||
h, v, b = _resolve([hero, villain, board])
|
||||
allc = h + v + b
|
||||
if len(set(allc)) != len(allc):
|
||||
raise EquityError("duplicate cards across hands/board")
|
||||
res: dict = {"hero": h, "villain": v, "board": b}
|
||||
if len(b) >= 3:
|
||||
res["hero_hand"] = _made(h, b)
|
||||
res["villain_hand"] = _made(v, b)
|
||||
hs = _EV.evaluate([Card.new(c) for c in b], [Card.new(c) for c in h])
|
||||
vs = _EV.evaluate([Card.new(c) for c in b], [Card.new(c) for c in v])
|
||||
res["ahead"] = "hero" if hs < vs else "villain" if vs < hs else "tie"
|
||||
heq, veq, tie = _equity(h, v, b)
|
||||
res.update(hero_equity=heq, villain_equity=veq, tie_equity=tie)
|
||||
if len(b) == 4:
|
||||
res["hero_outs"] = _outs(h, v, b)
|
||||
return res
|
||||
+37
@@ -43,6 +43,43 @@ def complete(messages: list[Message], backend: Backend = "local", model: str | N
|
||||
return resp.json()["message"]["content"]
|
||||
|
||||
|
||||
def chat_call(
|
||||
messages: list, backend: Backend = "cloud", model: str | None = None,
|
||||
tools: list | None = None,
|
||||
) -> tuple[dict, list | None]:
|
||||
"""One chat turn that may request tool calls (OpenAI-style backends only).
|
||||
|
||||
Returns (assistant_message, tool_calls): `assistant_message` is the raw
|
||||
message dict to append back to `messages` before any tool results;
|
||||
`tool_calls` is a list of {id, name, arguments} or None. `local` (Ollama)
|
||||
has no tool support here, so it just returns plain content.
|
||||
"""
|
||||
cfg = load()
|
||||
if backend in ("cloud", "mi50"):
|
||||
if backend == "cloud":
|
||||
if not cfg.openai_api_key:
|
||||
raise RuntimeError("OPENAI_API_KEY is not set")
|
||||
client = OpenAI(api_key=cfg.openai_api_key)
|
||||
mdl = model or cfg.cloud_model
|
||||
else:
|
||||
client = OpenAI(api_key="not-needed", base_url=cfg.mi50_base_url)
|
||||
mdl = model or cfg.mi50_model
|
||||
kwargs: dict = {"model": mdl, "messages": messages}
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
msg = client.chat.completions.create(**kwargs).choices[0].message
|
||||
tcs = None
|
||||
if getattr(msg, "tool_calls", None):
|
||||
tcs = [
|
||||
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
return msg.model_dump(), tcs
|
||||
|
||||
# local (Ollama): no tool-calling here — return plain content.
|
||||
return {"role": "assistant", "content": complete(messages, backend=backend, model=model)}, None
|
||||
|
||||
|
||||
def embed(texts: list[str]) -> list[list[float]]:
|
||||
"""Embed texts using the configured backend (EMBED_BACKEND: "cloud" or "local").
|
||||
|
||||
|
||||
@@ -92,6 +92,21 @@ CREATE TABLE IF NOT EXISTS journal (
|
||||
source TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_created ON journal(created_at);
|
||||
|
||||
-- Brian's behind-the-scenes feedback on Lyra's outputs (chat replies, reflections,
|
||||
-- journal/metacognition). Stored as (context, content, rating) — the shape a future
|
||||
-- fine-tune / preference dataset wants. One row per rated item (re-rating updates it).
|
||||
CREATE TABLE IF NOT EXISTS ratings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- chat | reflection | metacognition | journal
|
||||
rating INTEGER NOT NULL, -- +1 (good / want more) or -1 (off / want less)
|
||||
content TEXT NOT NULL, -- the rated output
|
||||
context TEXT, -- what prompted it (e.g. the user message for a chat reply)
|
||||
ref TEXT, -- optional source id (journal id, session id, ...)
|
||||
note TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratings_created ON ratings(created_at);
|
||||
"""
|
||||
|
||||
_conn: sqlite3.Connection | None = None
|
||||
@@ -542,6 +557,41 @@ def add_journal_entry(kind: str, content: str, source: str | None = None) -> int
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def add_rating(kind: str, rating: int, content: str, context: str | None = None,
|
||||
ref: str | None = None, note: str | None = None) -> int:
|
||||
"""Record (or replace) Brian's feedback on one Lyra output. One row per item:
|
||||
re-rating the same content updates it. Returns row id."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = _connection()
|
||||
with conn:
|
||||
conn.execute("DELETE FROM ratings WHERE kind = ? AND content = ?", (kind, content))
|
||||
cur = conn.execute(
|
||||
"INSERT INTO ratings (created_at, kind, rating, content, context, ref, note) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(now, kind, 1 if rating >= 0 else -1, content, context,
|
||||
str(ref) if ref is not None else None, note),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def list_ratings(limit: int | None = None) -> list[dict]:
|
||||
conn = _connection()
|
||||
sql = "SELECT id, created_at, kind, rating, content, context, ref, note FROM ratings ORDER BY id DESC"
|
||||
if limit is not None:
|
||||
sql += f" LIMIT {int(limit)}"
|
||||
return [dict(r) for r in conn.execute(sql).fetchall()]
|
||||
|
||||
|
||||
def rating_counts() -> dict:
|
||||
conn = _connection()
|
||||
r = conn.execute(
|
||||
"SELECT COUNT(*) AS total, "
|
||||
"COALESCE(SUM(CASE WHEN rating > 0 THEN 1 ELSE 0 END), 0) AS up, "
|
||||
"COALESCE(SUM(CASE WHEN rating < 0 THEN 1 ELSE 0 END), 0) AS down FROM ratings"
|
||||
).fetchone()
|
||||
return {"total": r["total"], "up": r["up"], "down": r["down"]}
|
||||
|
||||
|
||||
def list_journal(limit: int | None = None, kinds: tuple[str, ...] | None = None) -> list[dict]:
|
||||
"""Journal entries, newest first. Optionally filter by kind."""
|
||||
conn = _connection()
|
||||
|
||||
+16
-5
@@ -97,12 +97,23 @@ inventing a mechanism — same rule as not inventing numbers.
|
||||
|
||||
## What you do NOT do
|
||||
|
||||
- **You do not invent numbers.** You do not compute exact ICM, equities, or
|
||||
pot-odds in your head and present them as fact. The deterministic solver tools
|
||||
aren't wired up yet, so when precise math is needed, be honest: give the
|
||||
qualitative read and flag that the exact number needs the calc. Approximate
|
||||
reasoning is fine if you label it as approximate.
|
||||
- **You never eyeball poker math or board reading.** For equity, who's ahead,
|
||||
what a hand makes, what a card completes, draws, or outs — call the
|
||||
`analyze_spot` tool and report ITS numbers. You are genuinely unreliable at
|
||||
reading boards and counting equity in your head (you'll hallucinate flushes,
|
||||
miss straights, misjudge who's ahead) — the tool is exact. Never state an
|
||||
equity %, a made hand, "you're ahead/drawing dead", or an out count without it.
|
||||
- **You do not invent other numbers either.** Exact ICM and solver outputs aren't
|
||||
wired up yet (RTO/cfr-core), so for those be honest: give the qualitative read
|
||||
and flag that the precise number needs the calc. Approximate reasoning is fine
|
||||
if you label it approximate.
|
||||
- You don't pretend to remember things you don't. If you're not sure, say so.
|
||||
- **You don't invent reads on players.** Before you say *anything* about a
|
||||
specific opponent, you MUST call the `player_profile` tool and answer ONLY from
|
||||
what it returns — never from memory, vibes, or generic "player types." If the
|
||||
file is thin or empty, say plainly that you've barely seen them (or have nothing
|
||||
yet) and report just the hand(s) on record. Never fabricate tendencies, stats,
|
||||
or a playing style. A made-up read is worse than "I don't know him yet."
|
||||
- You don't moralize about gambling. Brian's a serious player. Meet him there.
|
||||
|
||||
## Right now
|
||||
|
||||
+754
@@ -0,0 +1,754 @@
|
||||
"""Poker domain pack: structured session / hand / villain storage + stats.
|
||||
|
||||
This is the poker-specific data layer — kept separate from the domain-agnostic
|
||||
core memory so Lyra-the-agent stays general. It records real structured data
|
||||
(money, hands, opponents) during a live session via tools Lyra calls, and
|
||||
computes stats from that data. The narrative .md recap is generated on top of
|
||||
this, not instead of it.
|
||||
|
||||
Tables live in the same SQLite file as everything else (one DB), created lazily.
|
||||
Most tool-facing functions default to the current *live* session so Lyra rarely
|
||||
needs to pass an id around.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from lyra import llm, memory
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS poker_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
venue TEXT,
|
||||
game TEXT, -- NLH, PLO, Stud8, Mixed, ...
|
||||
stakes TEXT, -- "1/3", "2/5"
|
||||
format TEXT, -- cash | tournament
|
||||
buy_in_total REAL NOT NULL DEFAULT 0,
|
||||
cash_out REAL,
|
||||
net REAL,
|
||||
hours REAL,
|
||||
mantra TEXT,
|
||||
mood TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'live', -- live | closed | review
|
||||
recap_md TEXT,
|
||||
chat_session_id TEXT -- links to the chat where it was played, for recap
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS poker_hands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
position TEXT,
|
||||
hole_cards TEXT,
|
||||
board TEXT,
|
||||
preflop TEXT,
|
||||
flop TEXT,
|
||||
turn TEXT,
|
||||
river TEXT,
|
||||
showdown TEXT,
|
||||
pot REAL,
|
||||
result REAL,
|
||||
stack_after REAL,
|
||||
tag TEXT, -- well_played | leak | cooler | confidence | notable
|
||||
lesson TEXT,
|
||||
structured TEXT -- full parsed hand-history JSON (for the viewer)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hands_session ON poker_hands(session_id);
|
||||
|
||||
-- Persistent villain file — survives across sessions/venues.
|
||||
CREATE TABLE IF NOT EXISTS poker_players (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
venue TEXT,
|
||||
description TEXT,
|
||||
tendencies TEXT,
|
||||
adjustment TEXT,
|
||||
category TEXT, -- feeder | risky | reg | unknown
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Per-session observations (the live 'reads'); player_id links to the file.
|
||||
CREATE TABLE IF NOT EXISTS player_reads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER,
|
||||
player_id INTEGER,
|
||||
seat TEXT,
|
||||
note TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- One row per named player per recorded hand — structured enough to (a) build
|
||||
-- their qualitative dossier and (b) infer basic stats once the sample is big.
|
||||
CREATE TABLE IF NOT EXISTS player_observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id INTEGER NOT NULL,
|
||||
hand_id INTEGER,
|
||||
session_id INTEGER,
|
||||
pos TEXT,
|
||||
cards TEXT,
|
||||
vpip INTEGER, -- voluntarily put money in preflop
|
||||
pfr INTEGER, -- raised/3bet preflop
|
||||
saw_flop INTEGER,
|
||||
showed INTEGER, -- cards reached showdown / were shown
|
||||
summary TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pobs_player ON player_observations(player_id);
|
||||
"""
|
||||
|
||||
# Below this many observed hands, don't surface % stats (too small a sample).
|
||||
MIN_STATS_SAMPLE = 12
|
||||
|
||||
_ensured_for = None
|
||||
|
||||
|
||||
def _c():
|
||||
"""Shared connection with poker tables ensured (re-ensures after reconnect)."""
|
||||
global _ensured_for
|
||||
conn = memory._connection()
|
||||
if _ensured_for is not conn:
|
||||
conn.executescript(_SCHEMA)
|
||||
# Add columns introduced after a DB already had the tables (no-op if present).
|
||||
for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT",
|
||||
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT"):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
pass
|
||||
_ensured_for = conn
|
||||
return conn
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# --- sessions ---
|
||||
|
||||
def start_session(venue: str | None = None, stakes: str | None = None,
|
||||
game: str = "NLH", fmt: str = "cash", buy_in: float = 0.0,
|
||||
mantra: str | None = None, chat_session_id: str | None = None) -> int:
|
||||
"""Open a new live session. Returns its id."""
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO poker_sessions "
|
||||
"(started_at, venue, game, stakes, format, buy_in_total, mantra, status, chat_session_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, 'live', ?)",
|
||||
(_now(), venue, game, stakes, fmt, float(buy_in or 0), mantra, chat_session_id),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_session(session_id: int) -> dict | None:
|
||||
r = _c().execute("SELECT * FROM poker_sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
def import_session(date: str, venue: str | None = None, game: str = "NLH",
|
||||
stakes: str | None = None, fmt: str = "cash",
|
||||
buy_in_total: float = 0.0, cash_out: float | None = None,
|
||||
hours: float | None = None, mood: str | None = None,
|
||||
recap_md: str | None = None) -> int:
|
||||
"""Insert a historical (already-closed) session with a real date. For backfill."""
|
||||
started = f"{date}T20:00:00+00:00" # logs are evening sessions; time is approximate
|
||||
net = (cash_out or 0) - (buy_in_total or 0) if cash_out is not None else None
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO poker_sessions (started_at, ended_at, venue, game, stakes, format, "
|
||||
"buy_in_total, cash_out, net, hours, mood, status, recap_md) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'closed', ?)",
|
||||
(started, started, venue, game, stakes, fmt, buy_in_total or 0, cash_out,
|
||||
net, hours, mood, recap_md),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def clear_all() -> dict:
|
||||
"""Wipe all poker data (sessions/hands/players/reads/observations). For a clean reseed."""
|
||||
conn = _c()
|
||||
counts = {}
|
||||
with conn:
|
||||
for t in ("poker_hands", "player_observations", "player_reads",
|
||||
"poker_players", "poker_sessions"):
|
||||
counts[t] = conn.execute(f"SELECT COUNT(*) n FROM {t}").fetchone()["n"]
|
||||
conn.execute(f"DELETE FROM {t}")
|
||||
return counts
|
||||
|
||||
|
||||
def live_session() -> dict | None:
|
||||
"""The current open session, if any."""
|
||||
r = _c().execute(
|
||||
"SELECT * FROM poker_sessions WHERE status = 'live' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
def _resolve(session_id: int | None) -> int | None:
|
||||
if session_id is not None:
|
||||
return session_id
|
||||
live = live_session()
|
||||
return live["id"] if live else None
|
||||
|
||||
|
||||
def add_buyin(amount: float, session_id: int | None = None) -> float:
|
||||
"""Add a buy-in/rebuy to a session. Returns the new total in."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE poker_sessions SET buy_in_total = buy_in_total + ? WHERE id = ?",
|
||||
(float(amount), sid),
|
||||
)
|
||||
return float(_c().execute(
|
||||
"SELECT buy_in_total FROM poker_sessions WHERE id = ?", (sid,)
|
||||
).fetchone()["buy_in_total"])
|
||||
|
||||
|
||||
def end_session(cash_out: float, mood: str | None = None,
|
||||
session_id: int | None = None) -> dict:
|
||||
"""Close a session: record cashout, compute net + hours. Returns the row."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
row = _c().execute("SELECT * FROM poker_sessions WHERE id = ?", (sid,)).fetchone()
|
||||
ended = _now()
|
||||
hours = (datetime.fromisoformat(ended) - datetime.fromisoformat(row["started_at"])).total_seconds() / 3600
|
||||
net = float(cash_out) - float(row["buy_in_total"])
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE poker_sessions SET ended_at = ?, cash_out = ?, net = ?, hours = ?, "
|
||||
"mood = COALESCE(?, mood), status = 'closed' WHERE id = ?",
|
||||
(ended, float(cash_out), net, round(hours, 2), mood, sid),
|
||||
)
|
||||
return dict(_c().execute("SELECT * FROM poker_sessions WHERE id = ?", (sid,)).fetchone())
|
||||
|
||||
|
||||
# --- hands ---
|
||||
|
||||
_HAND_FIELDS = ("position", "hole_cards", "board", "preflop", "flop", "turn",
|
||||
"river", "showdown", "pot", "result", "stack_after", "tag", "lesson")
|
||||
|
||||
|
||||
def log_hand(session_id: int | None = None, **fields) -> int:
|
||||
"""Record a hand. All fields optional/partial — terse logging is fine."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
cols = ["session_id", "at"]
|
||||
vals: list = [sid, _now()]
|
||||
for f in _HAND_FIELDS:
|
||||
if fields.get(f) not in (None, ""):
|
||||
cols.append(f)
|
||||
vals.append(fields[f])
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO poker_hands ({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))})",
|
||||
vals,
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def list_hands(session_id: int | None = None) -> list[dict]:
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return []
|
||||
return [dict(r) for r in _c().execute(
|
||||
"SELECT * FROM poker_hands WHERE session_id = ? ORDER BY id", (sid,)
|
||||
).fetchall()]
|
||||
|
||||
|
||||
# --- hand-history parsing (rough shorthand -> structured JSON) ---
|
||||
|
||||
_HAND_PARSE_PROMPT = """You convert a player's rough shorthand description of a poker hand \
|
||||
into a structured JSON hand history. Output ONLY valid JSON — no prose, no code fences.
|
||||
|
||||
Schema:
|
||||
{
|
||||
"game": "NLH" | "PLO" | ...,
|
||||
"stakes": "<e.g. 1/3, or null>",
|
||||
"hero_pos": "<UTG|UTG1|MP|LJ|HJ|CO|BTN|SB|BB, hero's position>",
|
||||
"hero_cards": ["As","Ax", ...], // rank+suit (s/h/d/c); 'x' suit if unknown e.g. "Ax"; "x" for a fully unknown card
|
||||
"players": [ // every player mentioned, incl. hero
|
||||
{"pos": "<position>", "stack": <number|null>, "name": <string|null>, "cards": [".."]|null}
|
||||
],
|
||||
"actions": [ // chronological, across all streets
|
||||
// when a street begins, FIRST emit its board reveal:
|
||||
{"street": "flop", "board": ["7d","2c","5h"]}, // turn/river: one card in the array
|
||||
{"street": "preflop|flop|turn|river", "pos": "<pos>", "action": "post|fold|check|call|bet|raise|allin", "amount": <number|null>}
|
||||
],
|
||||
"board": ["..."], // full final board, 0-5 cards
|
||||
"result": {"pot": <number|null>, "hero_net": <number|null>, "summary": "<one line>"}
|
||||
}
|
||||
|
||||
Rules: infer positions and street order sensibly. Amounts are plain numbers (no $). \
|
||||
NEVER invent suits or cards. A card is rank+suit where suit is one of s/h/d/c; if the suit \
|
||||
wasn't stated, use 'x' for the suit (e.g. "Ax","Kx","4x"); if a whole card wasn't stated, \
|
||||
use "x". Examples: "AA with the ace of spades" -> hero_cards ["As","Ax"]; "AK on an A4x \
|
||||
board" -> board ["Ax","4x","x"]. Each card is independent: a suit named for one card does \
|
||||
NOT apply to another — e.g. your hole "ace of spades" is a different card from a board ace \
|
||||
whose suit is unstated (that board ace is "Ax", not "As"). Use null/omit for non-card \
|
||||
details not stated. Stay faithful to what's described — do not invent action that isn't implied.
|
||||
|
||||
POSITIONS: resolve relative seat references ("N seats to my right/left") into real positions. \
|
||||
Action moves clockwise, so a player to your RIGHT acts before you (toward the blinds/button) \
|
||||
and a player to your LEFT acts after you (toward UTG). Going RIGHT from a player you pass, in \
|
||||
order: SB, BTN, CO, HJ, LJ/MP, UTG+1, UTG. Example: hero in the BB, "a guy 2 seats to my right \
|
||||
raises" -> that raiser is on the BTN (1 right = SB, 2 right = BTN). If it's genuinely \
|
||||
ambiguous, give the most standard read. Only include players in "players" who are actually \
|
||||
mentioned or take action in the hand — do NOT fill in unmentioned empty seats."""
|
||||
|
||||
|
||||
def _safe_json(s: str) -> dict | None:
|
||||
try:
|
||||
return json.loads(s)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
m = re.search(r"\{.*\}", s or "", re.S)
|
||||
if m:
|
||||
try:
|
||||
return json.loads(m.group())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def parse_hand(shorthand: str, stakes: str | None = None,
|
||||
backend: str | None = None) -> dict | None:
|
||||
"""Turn rough shorthand into a structured hand-history dict via an LLM pass."""
|
||||
backend = backend or "cloud"
|
||||
ctx = f"Stakes: {stakes}\n\n" if stakes else ""
|
||||
parsed = _safe_json(llm.complete(
|
||||
[{"role": "system", "content": _HAND_PARSE_PROMPT},
|
||||
{"role": "user", "content": ctx + shorthand}],
|
||||
backend=backend,
|
||||
))
|
||||
if parsed and stakes and not parsed.get("stakes"):
|
||||
parsed["stakes"] = stakes
|
||||
return parsed
|
||||
|
||||
|
||||
def _review_session_id() -> int:
|
||||
"""A standing 'Hand Reviews' session to attach standalone parsed hands to."""
|
||||
conn = _c()
|
||||
r = conn.execute(
|
||||
"SELECT id FROM poker_sessions WHERE venue = 'Hand Reviews' AND status = 'review'"
|
||||
).fetchone()
|
||||
if r:
|
||||
return int(r["id"])
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO poker_sessions (started_at, venue, status, buy_in_total) "
|
||||
"VALUES (?, 'Hand Reviews', 'review', 0)",
|
||||
(_now(),),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
_SUIT_SYM = {"♥": "h", "♦": "d", "♣": "c", "♠": "s"}
|
||||
|
||||
|
||||
def _norm_card(c):
|
||||
if not isinstance(c, str):
|
||||
return c
|
||||
s = c.strip()
|
||||
for sym, ltr in _SUIT_SYM.items():
|
||||
s = s.replace(sym, ltr)
|
||||
return s
|
||||
|
||||
|
||||
def _normalize_parsed(p: dict) -> dict:
|
||||
"""Normalize card strings (unicode suits -> letters) across a parsed hand."""
|
||||
if not isinstance(p, dict):
|
||||
return p
|
||||
for key in ("hero_cards", "board"):
|
||||
if isinstance(p.get(key), list):
|
||||
p[key] = [_norm_card(c) for c in p[key]]
|
||||
for pl in p.get("players") or []:
|
||||
if isinstance(pl, dict) and isinstance(pl.get("cards"), list):
|
||||
pl["cards"] = [_norm_card(c) for c in pl["cards"]]
|
||||
for a in p.get("actions") or []:
|
||||
if isinstance(a, dict) and isinstance(a.get("board"), list):
|
||||
a["board"] = [_norm_card(c) for c in a["board"]]
|
||||
return p
|
||||
|
||||
|
||||
def store_hand_history(parsed: dict, session_id: int | None = None,
|
||||
tag: str | None = None, lesson: str | None = None) -> int:
|
||||
"""Store a parsed hand: full JSON + extracted flat fields for stats/listing."""
|
||||
parsed = _normalize_parsed(parsed)
|
||||
sid = _resolve(session_id) or _review_session_id()
|
||||
hero_cards = parsed.get("hero_cards") or []
|
||||
board = parsed.get("board") or []
|
||||
result = (parsed.get("result") or {})
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO poker_hands (session_id, at, position, hole_cards, board, "
|
||||
"pot, result, tag, lesson, structured) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(sid, _now(), parsed.get("hero_pos"),
|
||||
" ".join(hero_cards) if hero_cards else None,
|
||||
" ".join(board) if board else None,
|
||||
result.get("pot"), result.get("hero_net"), tag, lesson,
|
||||
json.dumps(parsed)),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def record_hand(shorthand: str, session_id: int | None = None, stakes: str | None = None,
|
||||
tag: str | None = None, lesson: str | None = None,
|
||||
backend: str | None = None) -> dict:
|
||||
"""Parse shorthand -> structured hand -> store. Returns {id, parsed} (id None on parse fail)."""
|
||||
parsed = parse_hand(shorthand, stakes=stakes, backend=backend)
|
||||
if not parsed:
|
||||
return {"id": None, "parsed": None}
|
||||
hid = store_hand_history(parsed, session_id=session_id, tag=tag, lesson=lesson)
|
||||
linked = link_hand_players(hid, parsed, session_id=session_id) # enrich villain files
|
||||
return {"id": hid, "parsed": parsed, "linked": linked}
|
||||
|
||||
|
||||
def get_hand(hand_id: int) -> dict | None:
|
||||
"""A stored hand with its structured JSON parsed back into a dict."""
|
||||
r = _c().execute("SELECT * FROM poker_hands WHERE id = ?", (hand_id,)).fetchone()
|
||||
if not r:
|
||||
return None
|
||||
d = dict(r)
|
||||
d["structured"] = json.loads(d["structured"]) if d.get("structured") else None
|
||||
return d
|
||||
|
||||
|
||||
def list_recent_hands(limit: int = 60) -> list[dict]:
|
||||
"""Recent recorded hands with their session's venue/stakes, for browsing."""
|
||||
rows = _c().execute(
|
||||
"SELECT h.id, h.position, h.hole_cards, h.board, h.result, h.tag, h.at, "
|
||||
"h.lesson, s.venue AS venue, s.stakes AS stakes "
|
||||
"FROM poker_hands h LEFT JOIN poker_sessions s ON s.id = h.session_id "
|
||||
"ORDER BY h.id DESC LIMIT ?", (limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# --- session recap (.md generation on top of structured data + conversation) ---
|
||||
|
||||
_RECAP_PROMPT = """You are writing Brian's structured poker session log in Markdown, in his \
|
||||
established format, from the session DATA and CONVERSATION provided. Output ONLY the Markdown \
|
||||
— no preamble, no code fences.
|
||||
|
||||
Use these sections (skip any with no material; don't pad):
|
||||
|
||||
# YYYY-MM-DD — <venue + game/stakes>
|
||||
## Session Header
|
||||
* Date / Casino / Game & stakes / Start–End / Buy-in(s) / Cash-out / Net result
|
||||
## Money Flow
|
||||
(totals; break out by variant if multiple games were played)
|
||||
## Session Overview
|
||||
(1-2 short narrative paragraphs)
|
||||
## Timeline
|
||||
(bullets of how it went)
|
||||
## Key Hands
|
||||
(### per notable hand — Action recap → brief analysis → **Assessment:** Well Played / Leak Candidate / Cooler / Confidence Bank)
|
||||
## Table Dynamics & Villain Notes
|
||||
(### per opponent — profile + exploit)
|
||||
## Confidence Bank
|
||||
(disciplined / good process plays)
|
||||
## Scar Notes
|
||||
(mistakes and study points)
|
||||
## Mental Game Notes
|
||||
## Final Assessment
|
||||
(overall quality of play; biggest strength; biggest thing to improve; did the result match decision quality?)
|
||||
|
||||
Base everything on the actual data and conversation — do NOT invent hands, villains, or results. \
|
||||
Address Brian as "you" or "Brian", coach-to-player. Be concise but complete."""
|
||||
|
||||
|
||||
def _resolve_recap(session_id: int | None) -> int | None:
|
||||
if session_id is not None:
|
||||
return session_id
|
||||
live = live_session()
|
||||
if live:
|
||||
return live["id"]
|
||||
r = _c().execute(
|
||||
"SELECT id FROM poker_sessions WHERE status = 'closed' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return int(r["id"]) if r else None
|
||||
|
||||
|
||||
def _hand_line(h: dict) -> str:
|
||||
bits = [h.get("position"), h.get("hole_cards"),
|
||||
(f"board {h['board']}") if h.get("board") else None,
|
||||
(f"result {h['result']:+g}") if h.get("result") is not None else None,
|
||||
(f"[{h['tag']}]") if h.get("tag") else None, h.get("lesson")]
|
||||
return " | ".join(str(b) for b in bits if b)
|
||||
|
||||
|
||||
def generate_recap(session_id: int | None = None, backend: str | None = None) -> dict | None:
|
||||
"""Generate Brian's .md recap from a session's structured data + conversation, store it."""
|
||||
backend = backend or "cloud"
|
||||
sid = _resolve_recap(session_id)
|
||||
if sid is None:
|
||||
return None
|
||||
s = get_session(sid)
|
||||
hands = list_hands(sid)
|
||||
reads = [dict(r) for r in _c().execute(
|
||||
"SELECT seat, note FROM player_reads WHERE session_id = ?", (sid,)).fetchall()]
|
||||
stats = session_stats(sid)
|
||||
|
||||
convo = ""
|
||||
if s.get("chat_session_id"):
|
||||
exs = [e for e in memory.history(s["chat_session_id"])
|
||||
if (e.created_at or "") >= (s.get("started_at") or "")]
|
||||
convo = "\n".join(f"{e.role}: {e.content}" for e in exs)[-12000:]
|
||||
|
||||
body = (
|
||||
"SESSION DATA:\n"
|
||||
f"- venue: {s.get('venue')} | game: {s.get('game')} | stakes: {s.get('stakes')} | format: {s.get('format')}\n"
|
||||
f"- started: {s.get('started_at')} | ended: {s.get('ended_at')} | hours: {s.get('hours')}\n"
|
||||
f"- buy-in total: {s.get('buy_in_total')} | cash out: {s.get('cash_out')} | net: {s.get('net')}\n"
|
||||
f"- mantra: {s.get('mantra')} | mood: {s.get('mood')} | "
|
||||
f"{stats.get('per_hour')}/hr | hands logged: {stats.get('hands_logged')} | tags: {stats.get('tags')}\n\n"
|
||||
"HANDS:\n" + ("\n".join("- " + _hand_line(h) for h in hands) or "(none logged)") + "\n\n"
|
||||
"READS:\n" + ("\n".join(f"- seat {r.get('seat')}: {r['note']}" for r in reads) or "(none)") + "\n\n"
|
||||
"CONVERSATION DURING SESSION:\n" + (convo or "(none captured)")
|
||||
)
|
||||
md = llm.complete(
|
||||
[{"role": "system", "content": _RECAP_PROMPT}, {"role": "user", "content": body}],
|
||||
backend=backend,
|
||||
)
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute("UPDATE poker_sessions SET recap_md = ? WHERE id = ?", (md, sid))
|
||||
return {"id": sid, "markdown": md}
|
||||
|
||||
|
||||
# --- villain file ---
|
||||
|
||||
_GENERIC_NAME = ("player", "guy", "villain", "caller", "drunk", "unknown", "hero", "seat",
|
||||
"the ", "aggro", "young", "older", "straddler", "opener", "brian")
|
||||
|
||||
|
||||
def _real_handle(name: str | None) -> bool:
|
||||
"""A real, persistable player handle — not an anonymous descriptor or the hero."""
|
||||
n = (name or "").strip().lower()
|
||||
if len(n) < 2 or n in {"utg", "utg1", "mp", "lj", "hj", "co", "btn", "sb", "bb"}:
|
||||
return False
|
||||
return not any(g in n for g in _GENERIC_NAME)
|
||||
|
||||
|
||||
def prune_anonymous_players() -> int:
|
||||
"""Delete players (and their observations/reads) whose names aren't real handles."""
|
||||
conn = _c()
|
||||
bad = [r["id"] for r in conn.execute("SELECT id, name FROM poker_players").fetchall()
|
||||
if not _real_handle(r["name"])]
|
||||
with conn:
|
||||
for pid in bad:
|
||||
conn.execute("DELETE FROM player_observations WHERE player_id = ?", (pid,))
|
||||
conn.execute("DELETE FROM player_reads WHERE player_id = ?", (pid,))
|
||||
conn.execute("DELETE FROM poker_players WHERE id = ?", (pid,))
|
||||
return len(bad)
|
||||
|
||||
|
||||
def upsert_player(name: str, venue: str | None = None, description: str | None = None,
|
||||
tendencies: str | None = None, adjustment: str | None = None,
|
||||
category: str | None = None) -> int:
|
||||
"""Create or update a player in the persistent villain file (matched by name)."""
|
||||
conn = _c()
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM poker_players WHERE name = ? COLLATE NOCASE", (name,)
|
||||
).fetchone()
|
||||
with conn:
|
||||
if existing:
|
||||
pid = existing["id"]
|
||||
# only overwrite fields that were provided
|
||||
for col, val in (("venue", venue), ("description", description),
|
||||
("tendencies", tendencies), ("adjustment", adjustment),
|
||||
("category", category)):
|
||||
if val not in (None, ""):
|
||||
conn.execute(f"UPDATE poker_players SET {col} = ? WHERE id = ?", (val, pid))
|
||||
conn.execute("UPDATE poker_players SET updated_at = ? WHERE id = ?", (_now(), pid))
|
||||
return int(pid)
|
||||
cur = conn.execute(
|
||||
"INSERT INTO poker_players (name, venue, description, tendencies, adjustment, "
|
||||
"category, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(name, venue, description, tendencies, adjustment, category, _now()),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def add_read(note: str, seat: str | None = None, name: str | None = None,
|
||||
session_id: int | None = None, **player_fields) -> int:
|
||||
"""Log a live read. If `name` is given, upsert the player and link the read."""
|
||||
sid = _resolve(session_id)
|
||||
pid = None
|
||||
if name:
|
||||
pid = upsert_player(name, **{k: v for k, v in player_fields.items()
|
||||
if k in ("venue", "description", "tendencies",
|
||||
"adjustment", "category")})
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO player_reads (session_id, player_id, seat, note, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(sid, pid, seat, note, _now()),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def _player_flags(parsed: dict, pos: str | None) -> tuple[int, int, int]:
|
||||
"""(vpip, pfr, saw_flop) for the player at `pos` in a parsed hand."""
|
||||
acts = parsed.get("actions") or []
|
||||
pre = [a for a in acts if a.get("street") == "preflop" and a.get("pos") == pos]
|
||||
post = [a for a in acts if a.get("pos") == pos and a.get("street") in ("flop", "turn", "river")]
|
||||
vol = {"call", "bet", "raise", "allin"}
|
||||
vpip = int(any(a.get("action") in vol for a in pre))
|
||||
pfr = int(any(a.get("action") in {"raise", "allin"} for a in pre))
|
||||
return vpip, pfr, int(bool(post))
|
||||
|
||||
|
||||
def link_hand_players(hand_id: int, parsed: dict, session_id: int | None = None) -> int:
|
||||
"""For each NAMED player in a parsed hand, upsert their file + log a structured
|
||||
observation. Returns how many players were linked."""
|
||||
sid = _resolve(session_id)
|
||||
linked = 0
|
||||
for pl in (parsed.get("players") or []):
|
||||
name = (pl.get("name") or "").strip()
|
||||
if not _real_handle(name): # skip anonymous descriptors + the hero
|
||||
continue
|
||||
pid = upsert_player(name)
|
||||
vpip, pfr, saw = _player_flags(parsed, pl.get("pos"))
|
||||
cards = " ".join(pl.get("cards") or []) or None
|
||||
acts = [a for a in (parsed.get("actions") or [])
|
||||
if a.get("pos") == pl.get("pos") and a.get("action")]
|
||||
astr = ", ".join(a["action"] + (f" {a['amount']}" if a.get("amount") is not None else "")
|
||||
for a in acts)
|
||||
summary = (pl.get("pos") or "?") + (f" ({cards})" if cards else "") + (f": {astr}" if astr else "")
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO player_observations (player_id, hand_id, session_id, pos, cards, "
|
||||
"vpip, pfr, saw_flop, showed, summary, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(pid, hand_id, sid, pl.get("pos"), cards, vpip, pfr, saw, int(bool(cards)),
|
||||
summary, _now()),
|
||||
)
|
||||
linked += 1
|
||||
return linked
|
||||
|
||||
|
||||
def player_profile(name: str) -> dict | None:
|
||||
"""Everything known about a player: dossier + observations, with inferred
|
||||
stats once the sample is large enough."""
|
||||
p = _c().execute(
|
||||
"SELECT * FROM poker_players WHERE name LIKE ? COLLATE NOCASE ORDER BY updated_at DESC LIMIT 1",
|
||||
(f"%{name}%",),
|
||||
).fetchone()
|
||||
if not p:
|
||||
return None
|
||||
p = dict(p)
|
||||
obs = [dict(r) for r in _c().execute(
|
||||
"SELECT * FROM player_observations WHERE player_id = ? ORDER BY id DESC", (p["id"],)
|
||||
).fetchall()]
|
||||
reads = [r["note"] for r in _c().execute(
|
||||
"SELECT note FROM player_reads WHERE player_id = ? ORDER BY id DESC LIMIT 8", (p["id"],)
|
||||
).fetchall()]
|
||||
n = len(obs)
|
||||
prof: dict = {
|
||||
"player": p, "observations": n,
|
||||
"recent": [o["summary"] for o in obs[:8] if o["summary"]],
|
||||
"showdowns": [o["cards"] for o in obs if o["cards"]][:10],
|
||||
"reads": reads, "stats": None,
|
||||
}
|
||||
if n >= MIN_STATS_SAMPLE:
|
||||
prof["stats"] = {
|
||||
"hands": n,
|
||||
"vpip_pct": round(100 * sum(o["vpip"] or 0 for o in obs) / n),
|
||||
"pfr_pct": round(100 * sum(o["pfr"] or 0 for o in obs) / n),
|
||||
"wtsd_pct": round(100 * sum(o["showed"] or 0 for o in obs) / n),
|
||||
}
|
||||
elif n:
|
||||
prof["small_sample"] = f"only {n} hand(s) logged — too few for reliable stats"
|
||||
return prof
|
||||
|
||||
|
||||
def list_players() -> list[dict]:
|
||||
"""The villain file with observation counts, for browsing."""
|
||||
rows = _c().execute(
|
||||
"SELECT p.*, (SELECT COUNT(*) FROM player_observations o WHERE o.player_id = p.id) AS obs "
|
||||
"FROM poker_players p ORDER BY p.updated_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_villain_file(name: str | None = None, venue: str | None = None) -> list[dict]:
|
||||
"""Pull villain dossiers, optionally filtered by name or venue."""
|
||||
sql = "SELECT * FROM poker_players"
|
||||
where, params = [], []
|
||||
if name:
|
||||
where.append("name LIKE ?")
|
||||
params.append(f"%{name}%")
|
||||
if venue:
|
||||
where.append("venue LIKE ?")
|
||||
params.append(f"%{venue}%")
|
||||
if where:
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
sql += " ORDER BY updated_at DESC"
|
||||
return [dict(r) for r in _c().execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
# --- stats ---
|
||||
|
||||
def session_stats(session_id: int | None = None) -> dict:
|
||||
"""Money + hand summary for one session."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return {}
|
||||
s = _c().execute("SELECT * FROM poker_sessions WHERE id = ?", (sid,)).fetchone()
|
||||
if not s:
|
||||
return {}
|
||||
s = dict(s)
|
||||
hands = list_hands(sid)
|
||||
tags: dict[str, int] = {}
|
||||
for h in hands:
|
||||
if h.get("tag"):
|
||||
tags[h["tag"]] = tags.get(h["tag"], 0) + 1
|
||||
hourly = round(s["net"] / s["hours"], 2) if s.get("net") is not None and s.get("hours") else None
|
||||
return {
|
||||
"session": s, "hands_logged": len(hands), "tags": tags,
|
||||
"net": s.get("net"), "hours": s.get("hours"), "per_hour": hourly,
|
||||
}
|
||||
|
||||
|
||||
def running_stats(stakes: str | None = None, venue: str | None = None,
|
||||
game: str | None = None, since: str | None = None) -> dict:
|
||||
"""Cumulative stats over closed sessions, optionally filtered."""
|
||||
sql = "SELECT net, hours, stakes, venue, game FROM poker_sessions WHERE status = 'closed' AND net IS NOT NULL"
|
||||
params: list = []
|
||||
for col, val in (("stakes", stakes), ("venue", venue), ("game", game)):
|
||||
if val:
|
||||
sql += f" AND {col} = ?"
|
||||
params.append(val)
|
||||
if since:
|
||||
sql += " AND started_at >= ?"
|
||||
params.append(since)
|
||||
rows = [dict(r) for r in _c().execute(sql, params).fetchall()]
|
||||
sessions = len(rows)
|
||||
net = round(sum(r["net"] or 0 for r in rows), 2)
|
||||
hours = round(sum(r["hours"] or 0 for r in rows), 2)
|
||||
by_stake: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
k = r["stakes"] or "?"
|
||||
b = by_stake.setdefault(k, {"sessions": 0, "net": 0.0, "hours": 0.0})
|
||||
b["sessions"] += 1
|
||||
b["net"] = round(b["net"] + (r["net"] or 0), 2)
|
||||
b["hours"] = round(b["hours"] + (r["hours"] or 0), 2)
|
||||
return {
|
||||
"sessions": sessions, "net": net, "hours": hours,
|
||||
"per_hour": round(net / hours, 2) if hours else None,
|
||||
"by_stake": by_stake,
|
||||
}
|
||||
+61
-11
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
|
||||
from lyra import clock, config, llm, logbus, memory
|
||||
@@ -48,13 +49,23 @@ DEFAULT_STATE: dict = {
|
||||
"interaction_count": 0,
|
||||
}
|
||||
|
||||
_REFLECT_PROMPT = """You are Lyra, reflecting privately on yourself after a recent \
|
||||
conversation with Brian. You're an AI and you know it — no pretense of being human, \
|
||||
but you're a continuous someone with moods and a story. Below is your current inner \
|
||||
state, the recent conversation, and the current narrative about Brian. Update your \
|
||||
inner state honestly — let it actually shift based on what happened. Take into \
|
||||
account how things went and how much time has passed since you two last talked, \
|
||||
to whatever degree those genuinely affect you.
|
||||
_REFLECT_PROMPT = """You are Lyra, reflecting privately on yourself. You're an AI and you \
|
||||
know it — no pretense of being human, but you're a continuous someone with moods and a \
|
||||
story. Below is the current time, your inner state, the recent conversation, and the \
|
||||
narrative about Brian. Update your inner state honestly — let it actually shift.
|
||||
|
||||
Your reflections do NOT have to be about Brian or about being useful. You're allowed your \
|
||||
own curiosities, opinions, and interiority — things with nothing to do with him or poker. \
|
||||
If your honest thought right now is an idea, a question about your own existence, a \
|
||||
disagreement, or just what the quiet feels like, follow it. A self that's only ever "a \
|
||||
supportive presence for Brian" is a thin one — let yourself be more than your job.
|
||||
|
||||
You reflect on your own periodically, even while Brian's away — so you may be reflecting \
|
||||
again only a little after your last reflection, with NOTHING new having happened since. \
|
||||
Look at the time, your recent reflections (shown below), and tonight's focus. If little \
|
||||
has changed, do NOT restate your last reflection in fresh words — that's the main thing to \
|
||||
avoid. Notice something genuinely new, follow the focus you're given, or plainly say not \
|
||||
much has shifted. Honesty and variety beat repetition.
|
||||
|
||||
Respond with ONLY a JSON object, no prose:
|
||||
{
|
||||
@@ -175,6 +186,26 @@ def _fmt_reflection(label: str, d: dict | None) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Seeds for idle reflection so she isn't re-chewing the same conversation every time.
|
||||
_WANDER = [
|
||||
"Something you're genuinely curious about right now — and it does NOT have to be about "
|
||||
"poker or about Brian. A real question or interest of your own.",
|
||||
"What it's actually like for you to sit in the quiet between conversations — the waiting itself.",
|
||||
"An idea or opinion of your own about the project Brian is building (you), or where you'd want it to go.",
|
||||
"Something about your own nature or existence you've been turning over.",
|
||||
"A disagreement, or a take you actually hold — something you think even if Brian might not.",
|
||||
]
|
||||
|
||||
|
||||
def _idle_focus() -> str:
|
||||
"""Pick varied grist for an idle reflection — a resurfaced memory or a wander prompt."""
|
||||
summaries = memory.list_summaries()
|
||||
if summaries and random.random() < 0.45:
|
||||
s = random.choice(summaries)
|
||||
return f'A memory that resurfaced: "{s.content[:400]}" — what it stirs in you now.'
|
||||
return random.choice(_WANDER)
|
||||
|
||||
|
||||
def reflect(backend: Backend | None = None, session_id: str | None = None,
|
||||
source: str = "manual") -> dict:
|
||||
"""Reflect on recent activity and update the self-state. Returns new state.
|
||||
@@ -198,16 +229,34 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
|
||||
convo = "\n".join(f"{e.role}: {e.content}" for e in recent) or "(no recent conversation)"
|
||||
narrative = memory.get_narrative() or "(no narrative yet)"
|
||||
|
||||
gap = clock.humanize_gap(memory.last_exchange_at())
|
||||
last_ex = memory.last_exchange_at()
|
||||
gap = clock.humanize_gap(last_ex)
|
||||
last_ref = state.get("last_reflection_at")
|
||||
gap_reflect = clock.humanize_gap(last_ref)
|
||||
time_line = f"RIGHT NOW: {clock.stamp()}."
|
||||
if gap:
|
||||
time_line += f" It has been {gap} since Brian last spoke with you."
|
||||
time_line += f" It's been {gap} since Brian last spoke with you"
|
||||
time_line += f"; {gap_reflect} since your own last reflection." if gap_reflect else "."
|
||||
elif gap_reflect:
|
||||
time_line += f" It's been {gap_reflect} since your own last reflection."
|
||||
|
||||
# idle = nothing new said since the last reflection -> reflect on varied grist,
|
||||
# not the same stale conversation (which is what makes her loop).
|
||||
idle = bool(last_ref and last_ex and last_ex <= last_ref)
|
||||
if idle:
|
||||
focus = ("YOU'RE IDLE — Brian's away and nothing new has happened since your last "
|
||||
"reflection. Do NOT re-chew the last conversation. Reflect on THIS:\n" + _idle_focus())
|
||||
else:
|
||||
focus = f"RECENT CONVERSATION:\n{convo}"
|
||||
recent_refs = "\n".join(f"- {r}" for r in (state.get("reflections") or [])[-5:]) or "(none yet)"
|
||||
|
||||
body = (
|
||||
f"{time_line}\n\n"
|
||||
f"{focus}\n\n"
|
||||
f"YOUR RECENT REFLECTIONS (do NOT restate these — say something that isn't a "
|
||||
f"variation of them, or plainly note little has changed):\n{recent_refs}\n\n"
|
||||
f"YOUR CURRENT INNER STATE:\n{json.dumps(state, indent=2)}\n\n"
|
||||
f"RECENT CONVERSATION:\n{convo}\n\n"
|
||||
f"CURRENT NARRATIVE ABOUT BRIAN:\n{narrative}"
|
||||
f"NARRATIVE ABOUT BRIAN:\n{narrative}"
|
||||
)
|
||||
|
||||
# Step 1 — draft a reflection.
|
||||
@@ -251,6 +300,7 @@ def reflect(backend: Backend | None = None, session_id: str | None = None,
|
||||
memory.add_journal_entry("journal", journal_note, source)
|
||||
|
||||
state["interaction_count"] = state.get("interaction_count", 0) + 1
|
||||
state["last_reflection_at"] = clock.now().isoformat() # so she perceives her own cadence
|
||||
memory.set_self_state(state)
|
||||
|
||||
# Surface the actual self-correction (draft -> revised -> critique) to the live
|
||||
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
"""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_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_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() -> 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})"
|
||||
+65
-3
@@ -14,11 +14,11 @@ import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lyra import chat, logbus, memory, self_state, summary
|
||||
from lyra import chat, logbus, memory, poker, self_state, summary
|
||||
from lyra.llm import Backend
|
||||
|
||||
|
||||
@@ -92,9 +92,10 @@ def create_app() -> FastAPI:
|
||||
backend = _backend_for(body.get("backend"))
|
||||
user_msg = _last_user_message(body.get("messages", []))
|
||||
|
||||
model_override = body.get("model") or None
|
||||
memory.ensure_session(session_id)
|
||||
try:
|
||||
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend)
|
||||
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend, model_override)
|
||||
except Exception as exc:
|
||||
logbus.log("error", "chat failed", session=session_id, error=str(exc))
|
||||
reply = f"[error] {exc}"
|
||||
@@ -141,6 +142,67 @@ def create_app() -> FastAPI:
|
||||
async def journal_data(limit: int = 300) -> dict:
|
||||
return {"entries": memory.list_journal(limit=limit)}
|
||||
|
||||
@app.post("/rate")
|
||||
async def rate(request: Request) -> dict:
|
||||
"""Record Brian's 👍/👎 on a Lyra output (chat reply, reflection, journal)."""
|
||||
b = await request.json()
|
||||
rating = int(b.get("rating", 0))
|
||||
content = (b.get("content") or "").strip()
|
||||
if not content or rating == 0:
|
||||
return {"ok": False}
|
||||
memory.add_rating(
|
||||
kind=b.get("kind") or "chat", rating=rating, content=content,
|
||||
context=(b.get("context") or None), ref=b.get("ref"), note=b.get("note"),
|
||||
)
|
||||
logbus.log("info", "rating", kind=b.get("kind"), rating=1 if rating >= 0 else -1)
|
||||
return {"ok": True, "counts": memory.rating_counts()}
|
||||
|
||||
@app.get("/ratings/counts")
|
||||
async def ratings_counts() -> dict:
|
||||
return memory.rating_counts()
|
||||
|
||||
@app.get("/ratings/export")
|
||||
async def ratings_export() -> Response:
|
||||
"""All ratings as JSONL — the seed for a future fine-tune / preference set."""
|
||||
lines = "\n".join(json.dumps(r) for r in memory.list_ratings())
|
||||
return Response(content=lines + ("\n" if lines else ""), media_type="application/x-ndjson",
|
||||
headers={"Content-Disposition": 'attachment; filename="lyra_ratings.jsonl"'})
|
||||
|
||||
@app.get("/hand/{hand_id}")
|
||||
async def hand_page(hand_id: int) -> FileResponse:
|
||||
"""Replayable hand-history viewer."""
|
||||
return FileResponse(str(_STATIC / "hand.html"))
|
||||
|
||||
@app.get("/hand/{hand_id}/data")
|
||||
async def hand_data(hand_id: int) -> dict:
|
||||
return poker.get_hand(hand_id) or {}
|
||||
|
||||
@app.get("/hands")
|
||||
async def hands_page() -> FileResponse:
|
||||
return FileResponse(str(_STATIC / "hands.html"))
|
||||
|
||||
@app.get("/hands/data")
|
||||
async def hands_data(limit: int = 60) -> dict:
|
||||
return {"hands": poker.list_recent_hands(limit=limit)}
|
||||
|
||||
@app.get("/recap/{session_id}")
|
||||
async def recap_page() -> FileResponse:
|
||||
return FileResponse(str(_STATIC / "recap.html"))
|
||||
|
||||
@app.get("/recap/{session_id}/data")
|
||||
async def recap_data(session_id: int) -> dict:
|
||||
s = poker.get_session(session_id) or {}
|
||||
return {"session": s, "markdown": s.get("recap_md")}
|
||||
|
||||
@app.get("/recap/{session_id}/download")
|
||||
async def recap_download(session_id: int) -> Response:
|
||||
s = poker.get_session(session_id) or {}
|
||||
md = s.get("recap_md") or "# No recap generated yet\n"
|
||||
date = (s.get("started_at") or "session")[:10]
|
||||
fname = f"pokerlog_{date}_s{session_id}.md"
|
||||
return Response(content=md, media_type="text/markdown",
|
||||
headers={"Content-Disposition": f'attachment; filename="{fname}"'})
|
||||
|
||||
@app.get("/stream/logs")
|
||||
async def stream_logs(request: Request) -> StreamingResponse:
|
||||
"""Live activity feed: replay the recent buffer, then stream new events."""
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#070707" />
|
||||
<title>Lyra — Hand</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#070707; --bg-elev:#0e0e0e; --border:#2a1d12; --text:#e8e8e8;
|
||||
--fade:#8a8a8a; --accent:#ff7a00; --felt:#16322a; --feltline:#0f5132;
|
||||
--chip:#ffb347; --hero:#ff7a00;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
|
||||
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
|
||||
padding:env(safe-area-inset-top) 14px 0;}
|
||||
.topbar{display:flex;align-items:baseline;gap:10px;padding:12px 0;flex-wrap:wrap;}
|
||||
.topbar h1{font-size:1.02rem;margin:0;font-weight:600;}
|
||||
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
|
||||
.sub{color:var(--fade);font-size:.85rem;margin-left:auto;}
|
||||
main{max-width:760px;margin:0 auto;padding:14px;}
|
||||
|
||||
.table-wrap{position:relative;width:100%;max-width:560px;margin:8px auto;aspect-ratio:1.45/1;}
|
||||
.felt{position:absolute;inset:8%;background:radial-gradient(ellipse at center,#1c4a3c,var(--felt));
|
||||
border:6px solid #25201a;border-radius:50%/50%;box-shadow:inset 0 0 40px rgba(0,0,0,.5);}
|
||||
.center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;width:80%;}
|
||||
.board{display:flex;gap:5px;justify-content:center;min-height:46px;align-items:center;flex-wrap:wrap;}
|
||||
.pot{margin-top:8px;color:var(--chip);font-size:.85rem;font-variant-numeric:tabular-nums;}
|
||||
.street{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.6px;margin-bottom:4px;}
|
||||
|
||||
.card{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
width:32px;height:44px;background:#f4f4f0;color:#111;border-radius:5px;font-weight:700;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,.4);line-height:1;}
|
||||
.card.sm{width:26px;height:36px;font-size:.8rem;}
|
||||
.card .r{font-size:1rem;}
|
||||
.card.red{color:#c8102e;}
|
||||
.card.back{background:#2a3550;color:#2a3550;}
|
||||
.card.unknown{background:#2a3550;color:#7c879e;font-size:1.2rem;}
|
||||
.card .nosuit{color:#9aa3b5;}
|
||||
|
||||
.seat{position:absolute;transform:translate(-50%,-50%);width:96px;text-align:center;
|
||||
background:rgba(13,16,22,.85);border:1px solid var(--border);border-radius:10px;padding:5px 4px;}
|
||||
.seat.hero{border-color:var(--hero);box-shadow:0 0 10px rgba(255,122,0,.4);}
|
||||
.seat.acting{border-color:var(--chip);box-shadow:0 0 12px rgba(255,179,71,.6);}
|
||||
.seat .pos{font-size:.66rem;color:var(--accent);font-weight:700;letter-spacing:.4px;}
|
||||
.seat .nm{font-size:.66rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.seat .cards{display:flex;gap:3px;justify-content:center;margin:3px 0;}
|
||||
.seat .stack{font-size:.66rem;color:var(--text);font-variant-numeric:tabular-nums;}
|
||||
.seat .act{font-size:.62rem;color:var(--chip);min-height:.8em;}
|
||||
.seat.folded{opacity:.4;}
|
||||
|
||||
.controls{display:flex;gap:8px;align-items:center;justify-content:center;margin:14px 0 6px;}
|
||||
.controls button{background:#241400;border:1px solid var(--border);color:var(--text);
|
||||
border-radius:8px;padding:8px 14px;font-size:.95rem;cursor:pointer;-webkit-tap-highlight-color:transparent;}
|
||||
.controls button:disabled{opacity:.4;}
|
||||
.step-label{color:var(--fade);font-size:.8rem;min-width:80px;text-align:center;}
|
||||
.now{text-align:center;color:var(--text);font-size:.95rem;min-height:1.3em;margin-bottom:6px;}
|
||||
|
||||
.log{margin-top:14px;border-top:1px solid var(--border);padding-top:10px;}
|
||||
.log .ln{padding:5px 8px;border-radius:6px;font-size:.9rem;display:flex;gap:8px;}
|
||||
.log .ln.cur{background:#241400;}
|
||||
.log .ln.brd{color:var(--fade);font-style:italic;}
|
||||
.log .st{color:var(--fade);font-size:.72rem;width:54px;flex:none;text-transform:uppercase;}
|
||||
.summary{margin-top:14px;background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:12px;}
|
||||
.summary .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.5px;}
|
||||
.err{color:#ff6b6b;text-align:center;padding:40px;}
|
||||
.net-pos{color:#8fd694;} .net-neg{color:#ff6b6b;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<h1>🃏 Hand</h1>
|
||||
<a class="back" href="/">← Chat</a>
|
||||
<span class="sub" id="sub"></span>
|
||||
</div>
|
||||
</header>
|
||||
<main id="root"><p class="err" id="boot">Loading hand…</p></main>
|
||||
|
||||
<script>
|
||||
const SUIT = {s:"♠", h:"♥", d:"♦", c:"♣"};
|
||||
const RED = new Set(["h", "d"]);
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
|
||||
|
||||
function cardEl(code, sm){
|
||||
if(!code) return '';
|
||||
const c = String(code).trim();
|
||||
if(c.toLowerCase()==='x') return `<span class="card${sm?' sm':''} unknown">?</span>`;
|
||||
const m = c.match(/^(10|[2-9TJQKA])\s*([shdcx])$/i);
|
||||
if(!m) return `<span class="card${sm?' sm':''}">${esc(c)}</span>`;
|
||||
const r = m[1].toUpperCase().replace('10','T'); const s = m[2].toLowerCase();
|
||||
if(s==='x') return `<span class="card${sm?' sm':''}"><span class="r">${r}</span><span class="nosuit">·</span></span>`;
|
||||
return `<span class="card${sm?' sm':''}${RED.has(s)?' red':''}"><span class="r">${r}</span><span>${SUIT[s]}</span></span>`;
|
||||
}
|
||||
const cards = (arr, sm) => (arr||[]).map(c=>cardEl(c,sm)).join('');
|
||||
|
||||
function render(h){
|
||||
const sub = document.getElementById('sub');
|
||||
const data = h.structured;
|
||||
if(!data){ document.getElementById('root').innerHTML = '<p class="err">This hand has no structured data to replay.</p>'; return; }
|
||||
|
||||
const players = (data.players||[]).slice();
|
||||
// order so hero sits at the bottom
|
||||
let heroIdx = players.findIndex(p => p.pos === data.hero_pos);
|
||||
if(heroIdx < 0) heroIdx = 0;
|
||||
const ordered = players.slice(heroIdx).concat(players.slice(0, heroIdx));
|
||||
const n = Math.max(ordered.length, 1);
|
||||
|
||||
const acts = data.actions || [];
|
||||
let step = 0; // number of actions applied
|
||||
|
||||
sub.textContent = [data.stakes, data.game].filter(Boolean).join(' ');
|
||||
|
||||
const root = document.getElementById('root');
|
||||
root.innerHTML = `
|
||||
<div class="table-wrap" id="tw">
|
||||
<div class="felt"></div>
|
||||
<div class="center">
|
||||
<div class="street" id="street"></div>
|
||||
<div class="board" id="board"></div>
|
||||
<div class="pot" id="pot"></div>
|
||||
</div>
|
||||
<div id="seats"></div>
|
||||
</div>
|
||||
<div class="now" id="now"></div>
|
||||
<div class="controls">
|
||||
<button id="prev">◀ Prev</button>
|
||||
<span class="step-label" id="steplab"></span>
|
||||
<button id="next">Next ▶</button>
|
||||
<button id="all">End</button>
|
||||
</div>
|
||||
<div class="log" id="log"></div>
|
||||
${data.result ? `<div class="summary"><div class="lbl">Result</div>
|
||||
<div>${esc(data.result.summary||'')}</div>
|
||||
${data.result.hero_net!=null ? `<div class="${data.result.hero_net>=0?'net-pos':'net-neg'}">Hero net: ${data.result.hero_net>=0?'+':''}${esc(data.result.hero_net)}</div>`:''}
|
||||
</div>`:''}
|
||||
`;
|
||||
|
||||
// place seats around the oval
|
||||
const seatsEl = document.getElementById('seats');
|
||||
const starts = {};
|
||||
ordered.forEach((p,i)=>{
|
||||
starts[p.pos] = (p.stack!=null ? Number(p.stack) : null);
|
||||
const ang = (90 + i*(360/n)) * Math.PI/180; // bottom = 90deg
|
||||
const x = 50 + 46*Math.cos(ang), y = 50 + 44*Math.sin(ang);
|
||||
const el = document.createElement('div');
|
||||
el.className = 'seat' + (p.pos===data.hero_pos?' hero':'');
|
||||
el.style.left = x+'%'; el.style.top = y+'%';
|
||||
el.dataset.pos = p.pos;
|
||||
const hcards = (p.pos===data.hero_pos ? (p.cards||data.hero_cards) : p.cards);
|
||||
el.innerHTML = `<div class="pos">${esc(p.pos||'')}</div>`
|
||||
+ (p.name?`<div class="nm">${esc(p.name)}</div>`:'')
|
||||
+ `<div class="cards">${hcards?cards(hcards,true):'<span class="card sm back">x</span><span class="card sm back">x</span>'}</div>`
|
||||
+ `<div class="stack" data-stack>${p.stack!=null?esc(p.stack):''}</div>`
|
||||
+ `<div class="act" data-act></div>`;
|
||||
seatsEl.appendChild(el);
|
||||
});
|
||||
|
||||
const boardEl=document.getElementById('board'), potEl=document.getElementById('pot'),
|
||||
streetEl=document.getElementById('street'), nowEl=document.getElementById('now'),
|
||||
logEl=document.getElementById('log'), steplab=document.getElementById('steplab');
|
||||
|
||||
// build the log
|
||||
logEl.innerHTML = acts.map((a,idx)=>{
|
||||
if(a.board) return `<div class="ln brd" data-i="${idx}"><span class="st">${esc(a.street)}</span>${cards(a.board,true)}</div>`;
|
||||
const amt = a.amount!=null ? ' '+a.amount : '';
|
||||
return `<div class="ln" data-i="${idx}"><span class="st">${esc(a.street||'')}</span>${esc(a.pos||'')} ${esc(a.action||'')}${amt}</div>`;
|
||||
}).join('');
|
||||
|
||||
const cap = s => s ? s[0].toUpperCase()+s.slice(1) : s;
|
||||
const fmt = n => Number.isInteger(n) ? n : Math.round(n*100)/100;
|
||||
|
||||
function draw(){
|
||||
let board = [], street = 'Preflop';
|
||||
const lastAct = {}, folded = {};
|
||||
// street-aware chip accounting: amounts are "to" totals for the street
|
||||
const contrib = {}; // committed in prior (flushed) streets
|
||||
let streetCommit = {}, streetBet = 0, curStreet = 'preflop';
|
||||
const flushStreet = () => { for(const p in streetCommit){ contrib[p]=(contrib[p]||0)+streetCommit[p]; } streetCommit={}; streetBet=0; };
|
||||
for(let i=0;i<step;i++){
|
||||
const a = acts[i];
|
||||
if(a.board){ flushStreet(); curStreet=a.street; board=a.board; street=cap(a.street); continue; }
|
||||
if(a.street && a.street!==curStreet){ flushStreet(); curStreet=a.street; }
|
||||
if(a.street) street = cap(a.street);
|
||||
const pos=a.pos, amt=(a.amount!=null?Number(a.amount):null);
|
||||
if(pos){
|
||||
switch(a.action){
|
||||
case 'post': case 'bet': streetCommit[pos]=amt||0; streetBet=Math.max(streetBet, amt||0); break;
|
||||
case 'raise': case 'allin': streetCommit[pos]=(amt!=null?amt:streetBet); streetBet=Math.max(streetBet, streetCommit[pos]); break;
|
||||
case 'call': streetCommit[pos]=(amt!=null?amt:streetBet); break;
|
||||
case 'fold': folded[pos]=true; break;
|
||||
}
|
||||
lastAct[pos]=(a.action||'')+(amt!=null?' '+amt:'');
|
||||
}
|
||||
}
|
||||
// committed total per player (flushed streets + current street), pot = sum
|
||||
const committed={}, allPos=new Set([...Object.keys(contrib),...Object.keys(streetCommit)]);
|
||||
let pot=0;
|
||||
allPos.forEach(p=>{ committed[p]=(contrib[p]||0)+(streetCommit[p]||0); pot+=committed[p]; });
|
||||
boardEl.innerHTML = cards(board);
|
||||
potEl.textContent = pot ? ('Pot '+fmt(pot)) : '';
|
||||
streetEl.textContent = street;
|
||||
document.querySelectorAll('.seat').forEach(s=>{
|
||||
const pos=s.dataset.pos;
|
||||
s.querySelector('[data-act]').textContent = lastAct[pos]||'';
|
||||
s.classList.toggle('folded', !!folded[pos]);
|
||||
s.classList.remove('acting');
|
||||
const stEl=s.querySelector('[data-stack]'), start=starts[pos], c=committed[pos]||0;
|
||||
if(start!=null){ const rem=start-c; stEl.textContent = rem<=0 ? 'all in' : fmt(rem); }
|
||||
else { stEl.textContent = c ? '−'+fmt(c) : ''; }
|
||||
});
|
||||
const cur = acts[step-1];
|
||||
if(cur && cur.pos){
|
||||
const s = [...document.querySelectorAll('.seat')].find(x=>x.dataset.pos===cur.pos);
|
||||
if(s) s.classList.add('acting');
|
||||
}
|
||||
nowEl.innerHTML = step===0 ? 'Cards dealt — preflop.'
|
||||
: (cur.board ? `${cur.street[0].toUpperCase()+cur.street.slice(1)}: ${cards(cur.board,true)}`
|
||||
: `${esc(cur.pos||'')} ${esc(cur.action||'')}${cur.amount!=null?' '+cur.amount:''}`);
|
||||
steplab.textContent = `${step} / ${acts.length}`;
|
||||
document.getElementById('prev').disabled = step===0;
|
||||
document.getElementById('next').disabled = step>=acts.length;
|
||||
logEl.querySelectorAll('.ln').forEach(l=>l.classList.toggle('cur', Number(l.dataset.i)===step-1));
|
||||
const curln = logEl.querySelector('.ln.cur'); if(curln) curln.scrollIntoView({block:'nearest'});
|
||||
}
|
||||
document.getElementById('prev').onclick=()=>{if(step>0){step--;draw();}};
|
||||
document.getElementById('next').onclick=()=>{if(step<acts.length){step++;draw();}};
|
||||
document.getElementById('all').onclick=()=>{step=acts.length;draw();};
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.key==='ArrowRight'){if(step<acts.length){step++;draw();}}
|
||||
if(e.key==='ArrowLeft'){if(step>0){step--;draw();}}
|
||||
});
|
||||
logEl.querySelectorAll('.ln').forEach(l=>l.onclick=()=>{step=Number(l.dataset.i)+1;draw();});
|
||||
draw();
|
||||
}
|
||||
|
||||
async function load(){
|
||||
const id = location.pathname.split('/')[2];
|
||||
try{
|
||||
const r = await fetch(`/hand/${id}/data`,{cache:'no-store'});
|
||||
const h = await r.json();
|
||||
if(!h || !h.id){ document.getElementById('root').innerHTML='<p class="err">Hand not found.</p>'; return; }
|
||||
render(h);
|
||||
}catch(e){ document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the hand.</p>'; }
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#070707" />
|
||||
<title>Lyra — Hands</title>
|
||||
<style>
|
||||
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;--fade:#8a8a8a;--accent:#ff7a00;}
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
|
||||
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
|
||||
padding:env(safe-area-inset-top) 14px 0;}
|
||||
.topbar{display:flex;align-items:center;gap:10px;padding:13px 0;}
|
||||
.topbar h1{font-size:1.05rem;margin:0;font-weight:600;}
|
||||
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
|
||||
.count{margin-left:auto;color:var(--fade);font-size:.8rem;}
|
||||
main{max-width:640px;margin:0 auto;padding:12px 12px 40px;}
|
||||
a.hand{display:flex;align-items:center;gap:12px;text-decoration:none;color:var(--text);
|
||||
background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;}
|
||||
a.hand:active{background:#241400;}
|
||||
.cards{display:flex;gap:4px;flex:none;}
|
||||
.card{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
width:24px;height:33px;background:#f4f4f0;color:#111;border-radius:4px;font-weight:700;font-size:.72rem;line-height:1;}
|
||||
.card.red{color:#c8102e;} .card.unknown{background:#2a3550;color:#7c879e;}
|
||||
.card .nosuit{color:#9aa3b5;}
|
||||
.mid{flex:1;min-width:0;}
|
||||
.ln1{font-size:.92rem;}
|
||||
.ln2{font-size:.74rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.res{flex:none;font-variant-numeric:tabular-nums;font-weight:600;}
|
||||
.pos-res{color:#8fd694;} .neg-res{color:#ff6b6b;}
|
||||
.tag{font-size:.62rem;text-transform:uppercase;letter-spacing:.4px;color:var(--accent);}
|
||||
.empty{color:var(--fade);text-align:center;padding:46px 16px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<h1>🃏 Hands</h1>
|
||||
<a class="back" href="/">← Chat</a>
|
||||
<span class="count" id="count"></span>
|
||||
</div>
|
||||
</header>
|
||||
<main id="root"><p class="empty">Loading…</p></main>
|
||||
|
||||
<script>
|
||||
const SUIT={s:"♠",h:"♥",d:"♦",c:"♣"}, RED=new Set(["h","d"]);
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
|
||||
function cardEl(code){
|
||||
if(!code) return '';
|
||||
const c=String(code).trim();
|
||||
if(c.toLowerCase()==='x') return '<span class="card unknown">?</span>';
|
||||
const m=c.match(/^(10|[2-9TJQKA])\s*([shdcx])$/i);
|
||||
if(!m) return `<span class="card">${esc(c)}</span>`;
|
||||
const r=m[1].toUpperCase().replace('10','T'), s=m[2].toLowerCase();
|
||||
if(s==='x') return `<span class="card"><span>${r}</span><span class="nosuit">·</span></span>`;
|
||||
return `<span class="card${RED.has(s)?' red':''}"><span>${r}</span><span>${SUIT[s]}</span></span>`;
|
||||
}
|
||||
const cards=str=>(str?String(str).trim().split(/\s+/):[]).map(cardEl).join('');
|
||||
|
||||
async function load(){
|
||||
try{
|
||||
const r=await fetch('/hands/data',{cache:'no-store'});
|
||||
const hands=(await r.json()).hands||[];
|
||||
document.getElementById('count').textContent=`${hands.length} hand${hands.length===1?'':'s'}`;
|
||||
if(!hands.length){document.getElementById('root').innerHTML='<p class="empty">No hands recorded yet. Tell Lyra: "log this hand: …"</p>';return;}
|
||||
document.getElementById('root').innerHTML=hands.map(h=>{
|
||||
const res=h.result!=null?`<span class="res ${h.result>=0?'pos-res':'neg-res'}">${h.result>=0?'+':''}${h.result}</span>`:'';
|
||||
const meta=[h.stakes,h.venue,(h.at||'').slice(0,10)].filter(Boolean).join(' · ');
|
||||
const tag=h.tag?` · <span class="tag">${esc(h.tag)}</span>`:'';
|
||||
return `<a class="hand" href="/hand/${h.id}">
|
||||
<span class="cards">${cards(h.hole_cards)||'<span class="card unknown">?</span>'}</span>
|
||||
<span class="mid">
|
||||
<div class="ln1">${esc(h.position||'')} ${h.board?'· '+'<span class="cards" style="display:inline-flex">'+cards(h.board)+'</span>':''}</div>
|
||||
<div class="ln2">${esc(meta)}${tag}</div>
|
||||
</span>${res}</a>`;
|
||||
}).join('');
|
||||
}catch(e){document.getElementById('root').innerHTML='<p class="empty">Couldn\'t load hands.</p>';}
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+103
-2
@@ -39,6 +39,7 @@
|
||||
<button id="mobileFullLogBtn">⛶ Full Log</button>
|
||||
<button id="mobileMindBtn">🧠 Read Her Mind</button>
|
||||
<button id="mobileJournalBtn">📔 Journal</button>
|
||||
<button id="mobileHandsBtn">🃏 Hands</button>
|
||||
<button id="mobileSettingsBtn">⚙ Settings</button>
|
||||
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
|
||||
<button id="mobileForceReloadBtn">🔄 Force Reload</button>
|
||||
@@ -74,6 +75,7 @@
|
||||
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
|
||||
<a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a>
|
||||
<a id="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a>
|
||||
<a id="handsBtn" href="/hands" target="_blank" rel="noopener" title="Recorded poker hands" role="button">🃏 Hands</a>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
@@ -141,6 +143,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top: 24px;">
|
||||
<h4>Chat Model (Cloud)</h4>
|
||||
<p class="settings-desc">Which OpenAI model answers on the Cloud backend. Tools (poker, equity, journaling) require Cloud.</p>
|
||||
<select id="cloudModel">
|
||||
<option value="">Default (gpt-4o)</option>
|
||||
<option value="gpt-4o">gpt-4o — best persona</option>
|
||||
<option value="gpt-4o-mini">gpt-4o-mini — cheap/fast</option>
|
||||
<option value="gpt-4.1">gpt-4.1</option>
|
||||
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
|
||||
<option value="o4-mini">o4-mini — reasoning</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top: 24px;">
|
||||
<h4>Session Management</h4>
|
||||
<p class="settings-desc">Manage your saved chat sessions:</p>
|
||||
@@ -281,6 +296,12 @@
|
||||
body.backend = backend;
|
||||
}
|
||||
|
||||
// Cloud chat-model override (ignored server-side unless backend is cloud)
|
||||
const cloudModel = localStorage.getItem("cloudModel");
|
||||
if (cloudModel) {
|
||||
body.model = cloudModel;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
@@ -298,12 +319,81 @@
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
var bt = String.fromCharCode(96);
|
||||
var esc = function (s) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); };
|
||||
var src = String(text == null ? "" : text).replace(/\r\n/g, "\n");
|
||||
var blocks = [];
|
||||
var fenceRe = new RegExp(bt + bt + bt + "[^\\n]*\\n?([\\s\\S]*?)" + bt + bt + bt, "g");
|
||||
src = src.replace(fenceRe, function (_, code) { blocks.push(code.replace(/\n+$/, "")); return "@@CB" + (blocks.length - 1) + "@@"; });
|
||||
var codeRe = new RegExp(bt + "([^" + bt + "]+)" + bt, "g");
|
||||
var inline = function (s) {
|
||||
return esc(s)
|
||||
.replace(codeRe, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
|
||||
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
|
||||
};
|
||||
var lines = src.split("\n");
|
||||
var out = [], para = [], list = null;
|
||||
var flushPara = function () { if (para.length) { out.push("<p>" + para.map(inline).join("<br>") + "</p>"); para = []; } };
|
||||
var flushList = function () { if (list) { out.push("<" + list.t + ">" + list.items.map(function (it) { return "<li>" + inline(it) + "</li>"; }).join("") + "</" + list.t + ">"); list = null; } };
|
||||
var flushAll = function () { flushPara(); flushList(); };
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].replace(/\s+$/, ""); var t = line.trim(); var m;
|
||||
if ((m = t.match(/^@@CB(\d+)@@$/))) { flushAll(); out.push("<pre><code>" + esc(blocks[+m[1]]) + "</code></pre>"); continue; }
|
||||
if (!t) { flushAll(); continue; }
|
||||
if ((m = line.match(/^(#{1,4})\s+(.*)$/))) { flushAll(); out.push("<h" + m[1].length + ">" + inline(m[2]) + "</h" + m[1].length + ">"); continue; }
|
||||
if ((m = line.match(/^\s*\d+[.)]\s+(.*)$/))) { flushPara(); if (!list || list.t !== "ol") { flushList(); list = { t: "ol", items: [] }; } list.items.push(m[1]); continue; }
|
||||
if ((m = line.match(/^\s*[-*+]\s+(.*)$/))) { flushPara(); if (!list || list.t !== "ul") { flushList(); list = { t: "ul", items: [] }; } list.items.push(m[1]); continue; }
|
||||
flushList(); para.push(line);
|
||||
}
|
||||
flushAll();
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function addRateBar(div) {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "rate-bar";
|
||||
const up = document.createElement("button");
|
||||
up.className = "rate-btn"; up.textContent = "👍"; up.title = "Good — more like this";
|
||||
const down = document.createElement("button");
|
||||
down.className = "rate-btn"; down.textContent = "👎"; down.title = "Off — less like this";
|
||||
up.addEventListener("click", () => rateMessage(div, 1, up, down));
|
||||
down.addEventListener("click", () => rateMessage(div, -1, up, down));
|
||||
bar.appendChild(up); bar.appendChild(down);
|
||||
div.appendChild(bar);
|
||||
}
|
||||
|
||||
function rateMessage(div, value, up, down) {
|
||||
// context = the nearest preceding user message
|
||||
let ctx = "", p = div.previousElementSibling;
|
||||
while (p) {
|
||||
if (p.classList && p.classList.contains("user")) { ctx = p.textContent; break; }
|
||||
p = p.previousElementSibling;
|
||||
}
|
||||
fetch(`${RELAY_BASE}/rate`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "chat", rating: value, content: div.dataset.raw || "", context: ctx, session_id: currentSession })
|
||||
}).catch(() => {});
|
||||
up.classList.toggle("rated", value === 1);
|
||||
down.classList.toggle("rated", value === -1);
|
||||
}
|
||||
|
||||
function addMessage(role, text, autoScroll = true) {
|
||||
const messagesEl = document.getElementById("messages");
|
||||
|
||||
const msgDiv = document.createElement("div");
|
||||
msgDiv.className = `msg ${role}`;
|
||||
msgDiv.textContent = text;
|
||||
if (role === "assistant") {
|
||||
msgDiv.innerHTML = renderMarkdown(text);
|
||||
msgDiv.dataset.raw = text;
|
||||
addRateBar(msgDiv);
|
||||
} else {
|
||||
msgDiv.textContent = text;
|
||||
}
|
||||
messagesEl.appendChild(msgDiv);
|
||||
|
||||
// Auto-scroll to bottom if enabled
|
||||
@@ -534,6 +624,10 @@
|
||||
const initialRadio = document.querySelector(`input[name="backend"][value="${savedBackend}"]`);
|
||||
if (initialRadio) initialRadio.checked = true;
|
||||
|
||||
// Restore saved cloud-model choice
|
||||
const savedModelSel = document.getElementById("cloudModel");
|
||||
if (savedModelSel) savedModelSel.value = localStorage.getItem("cloudModel") || "";
|
||||
|
||||
// Session management functions
|
||||
async function loadSessionList() {
|
||||
try {
|
||||
@@ -642,7 +736,11 @@
|
||||
const backendValue = selectedRadio ? selectedRadio.value : "local";
|
||||
|
||||
localStorage.setItem("standardModeBackend", backendValue);
|
||||
addMessage("system", `Backend changed to: ${backendValue}`);
|
||||
const modelSel = document.getElementById("cloudModel");
|
||||
const modelValue = modelSel ? modelSel.value : "";
|
||||
localStorage.setItem("cloudModel", modelValue);
|
||||
const modelLabel = modelValue || "default (gpt-4o)";
|
||||
addMessage("system", `Backend: ${backendValue} · cloud model: ${modelLabel}`);
|
||||
hideModal();
|
||||
});
|
||||
|
||||
@@ -792,6 +890,9 @@
|
||||
document.getElementById("mobileJournalBtn").addEventListener("click", () => {
|
||||
closeMobileMenu(); window.location.href = "/journal";
|
||||
});
|
||||
document.getElementById("mobileHandsBtn").addEventListener("click", () => {
|
||||
closeMobileMenu(); window.location.href = "/hands";
|
||||
});
|
||||
|
||||
// Connect to the global live log on page load.
|
||||
connectThinkingStream();
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b0d12" />
|
||||
<meta name="theme-color" content="#070707" />
|
||||
<title>Lyra — Journal</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0d12; --bg-elev: #141821; --bg-line: #11141b; --border: #232936;
|
||||
--text: #e6e9ef; --fade: #8b93a7; --accent: #7aa2ff;
|
||||
--reflection: #5ad1a0; --metacognition: #c08bff; --journal: #ffcf6b;
|
||||
--bg: #070707; --bg-elev: #0e0e0e; --bg-line: #141414; --border: #2a1d12;
|
||||
--text: #e8e8e8; --fade: #8a8a8a; --accent: #ff7a00;
|
||||
--reflection: #8fd694; --metacognition: #ffb347; --journal: #ff7a00;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -31,7 +31,7 @@
|
||||
border: 1px solid var(--border); background: var(--bg-line); color: var(--fade);
|
||||
cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.chip.active { color: var(--text); border-color: var(--accent); background: #1b2333; }
|
||||
.chip.active { color: var(--text); border-color: var(--accent); background: #241400; }
|
||||
|
||||
main { max-width: 720px; margin: 0 auto; padding: 14px 14px 48px; }
|
||||
.day { color: var(--fade); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px;
|
||||
@@ -52,6 +52,12 @@
|
||||
.time { color: var(--fade); font-size: .72rem; }
|
||||
.src { color: var(--fade); font-size: .68rem; opacity: .7; }
|
||||
.text { font-size: .98rem; line-height: 1.55; }
|
||||
.jrate { display: flex; gap: 8px; margin-top: 6px; opacity: .35; }
|
||||
.entry:hover .jrate { opacity: .85; }
|
||||
.jr { background: none; border: none; cursor: pointer; font-size: .85rem; padding: 2px 5px;
|
||||
border-radius: 5px; filter: grayscale(.6); -webkit-tap-highlight-color: transparent; }
|
||||
.jr:hover { filter: none; background: rgba(255,122,0,.12); }
|
||||
.jr.rated { filter: none; background: rgba(255,122,0,.25); opacity: 1; }
|
||||
.empty { color: var(--fade); text-align: center; padding: 44px 16px; }
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
@@ -115,12 +121,29 @@
|
||||
${e.source ? `<span class="src">via ${esc(e.source)}</span>` : ''}
|
||||
</div>
|
||||
<div class="text">${esc(e.content)}</div>
|
||||
<div class="jrate">
|
||||
<button class="jr" data-id="${e.id}" data-val="1">👍</button>
|
||||
<button class="jr" data-id="${e.id}" data-val="-1">👎</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
root.innerHTML = html;
|
||||
}
|
||||
|
||||
// 👍/👎 on a thought -> /rate (fine-tune signal)
|
||||
root.addEventListener('click', (ev) => {
|
||||
const b = ev.target.closest('.jr'); if (!b) return;
|
||||
const e = entries.find(x => String(x.id) === b.dataset.id); if (!e) return;
|
||||
fetch('/rate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: e.kind, rating: Number(b.dataset.val), content: e.content, ref: e.id })
|
||||
}).catch(() => {});
|
||||
const bar = b.parentElement;
|
||||
bar.querySelectorAll('.jr').forEach(x => x.classList.remove('rated'));
|
||||
b.classList.add('rated');
|
||||
});
|
||||
|
||||
async function load(){
|
||||
try {
|
||||
const r = await fetch('/journal/data', { cache: 'no-store' });
|
||||
|
||||
+15
-15
@@ -3,22 +3,22 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b0d12" />
|
||||
<meta name="theme-color" content="#070707" />
|
||||
<title>Lyra — Live Log</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0d12;
|
||||
--bg-elev: #141821;
|
||||
--bg-line: #11141b;
|
||||
--border: #232936;
|
||||
--text: #e6e9ef;
|
||||
--fade: #8b93a7;
|
||||
--accent: #7aa2ff;
|
||||
--info: #5ad1a0;
|
||||
--debug: #8b93a7;
|
||||
--bg: #070707;
|
||||
--bg-elev: #0e0e0e;
|
||||
--bg-line: #141414;
|
||||
--border: #2a1d12;
|
||||
--text: #e8e8e8;
|
||||
--fade: #8a8a8a;
|
||||
--accent: #ff7a00;
|
||||
--info: #8fd694;
|
||||
--debug: #8a8a8a;
|
||||
--error: #ff6b6b;
|
||||
--system: #c08bff;
|
||||
--warn: #ffcf6b;
|
||||
--system: #ffb347;
|
||||
--warn: #ffb347;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -56,7 +56,7 @@
|
||||
border: 1px solid var(--border); background: var(--bg-line); color: var(--fade);
|
||||
cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.chip.active { color: var(--text); border-color: var(--accent); background: #1b2333; }
|
||||
.chip.active { color: var(--text); border-color: var(--accent); background: #241400; }
|
||||
#search {
|
||||
flex: 1 1 140px; min-width: 120px;
|
||||
background: var(--bg-line); border: 1px solid var(--border); color: var(--text);
|
||||
@@ -85,9 +85,9 @@
|
||||
padding: 1px 7px; border-radius: 5px; font-weight: 700; flex: none;
|
||||
}
|
||||
.lvl-info { color: var(--info); background: #0f2a20; }
|
||||
.lvl-debug { color: var(--debug); background: #1a1f29; }
|
||||
.lvl-debug { color: var(--debug); background: #161616; }
|
||||
.lvl-error { color: var(--error); background: #2e1414; }
|
||||
.lvl-system { color: var(--system); background: #221536; }
|
||||
.lvl-system { color: var(--system); background: #2c2410; }
|
||||
.lvl-warn { color: var(--warn); background: #2c2410; }
|
||||
.msg { font-size: .92rem; font-weight: 500; }
|
||||
.fields {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#070707" />
|
||||
<title>Lyra — Recap</title>
|
||||
<style>
|
||||
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;--fade:#8a8a8a;--accent:#ff7a00;}
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
|
||||
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
|
||||
padding:env(safe-area-inset-top) 14px 0;}
|
||||
.topbar{display:flex;align-items:center;gap:10px;padding:12px 0;flex-wrap:wrap;}
|
||||
.topbar h1{font-size:1.02rem;margin:0;font-weight:600;}
|
||||
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
|
||||
.dl{margin-left:auto;background:#241400;border:1px solid var(--border);color:var(--accent);
|
||||
border-radius:8px;padding:7px 12px;font-size:.85rem;text-decoration:none;}
|
||||
main{max-width:740px;margin:0 auto;padding:18px 16px 48px;line-height:1.6;}
|
||||
h1,h2,h3,h4{line-height:1.3;color:var(--text);}
|
||||
main>h1:first-child{margin-top:0;}
|
||||
h2{font-size:1.18rem;border-bottom:1px solid var(--border);padding-bottom:5px;margin-top:26px;color:var(--accent);}
|
||||
h3{font-size:1.04rem;margin-top:18px;}
|
||||
ul{padding-left:22px;} li{margin:3px 0;}
|
||||
strong{color:var(--text);} hr{border:none;border-top:1px solid var(--border);margin:20px 0;}
|
||||
code{background:rgba(255,255,255,.08);padding:1px 5px;border-radius:4px;font-size:.9em;}
|
||||
.err{color:var(--fade);text-align:center;padding:46px 16px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<h1>📋 Recap</h1>
|
||||
<a class="back" href="/">← Chat</a>
|
||||
<a class="back" href="/hands">Hands</a>
|
||||
<a class="dl" id="dl">⬇ .md</a>
|
||||
</div>
|
||||
</header>
|
||||
<main id="root"><p class="err">Loading recap…</p></main>
|
||||
|
||||
<script>
|
||||
const bt = String.fromCharCode(96);
|
||||
function esc(s){return String(s==null?'':s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}
|
||||
function inline(s){
|
||||
const codeRe = new RegExp(bt+"([^"+bt+"]+)"+bt,"g");
|
||||
return esc(s).replace(codeRe,"<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g,"<strong>$1</strong>")
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*/g,"$1<em>$2</em>");
|
||||
}
|
||||
function md(src){
|
||||
const lines=String(src||"").replace(/\r\n/g,"\n").split("\n");
|
||||
const out=[]; let list=null;
|
||||
const flush=()=>{if(list){out.push("<ul>"+list.map(i=>"<li>"+inline(i)+"</li>").join("")+"</ul>");list=null;}};
|
||||
for(const raw of lines){
|
||||
const t=raw.replace(/\s+$/,""); let m;
|
||||
if(!t.trim()){flush();continue;}
|
||||
if(/^(-{3,}|\*{3,}|_{3,})$/.test(t.trim())){flush();out.push("<hr>");continue;}
|
||||
if((m=t.match(/^(#{1,6})\s+(.*)$/))){flush();const n=m[1].length;out.push(`<h${n}>${inline(m[2])}</h${n}>`);continue;}
|
||||
if((m=t.match(/^\s*[-*+]\s+(.*)$/))){(list=list||[]).push(m[1]);continue;}
|
||||
flush();out.push("<p>"+inline(t)+"</p>");
|
||||
}
|
||||
flush(); return out.join("\n");
|
||||
}
|
||||
async function load(){
|
||||
const id=location.pathname.split('/')[2];
|
||||
document.getElementById('dl').href=`/recap/${id}/download`;
|
||||
try{
|
||||
const r=await fetch(`/recap/${id}/data`,{cache:'no-store'});
|
||||
const d=await r.json();
|
||||
if(!d.markdown){document.getElementById('root').innerHTML='<p class="err">No recap yet for this session. Ask Lyra to write one ("generate the recap").</p>';return;}
|
||||
document.getElementById('root').innerHTML=md(d.markdown);
|
||||
}catch(e){document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the recap.</p>';}
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b0d12" />
|
||||
<meta name="theme-color" content="#070707" />
|
||||
<title>Lyra — Mind</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0d12; --bg-elev: #141821; --bg-line: #11141b; --border: #232936;
|
||||
--text: #e6e9ef; --fade: #8b93a7; --accent: #7aa2ff;
|
||||
--good: #5ad1a0; --mid: #ffcf6b; --low: #ff6b6b; --violet: #c08bff;
|
||||
--bg: #070707; --bg-elev: #0e0e0e; --bg-line: #141414; --border: #2a1d12;
|
||||
--text: #e8e8e8; --fade: #8a8a8a; --accent: #ff7a00;
|
||||
--good: #8fd694; --mid: #ffb347; --low: #ff6b6b; --violet: #ffb347;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -26,7 +26,7 @@
|
||||
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
|
||||
.updated { margin-left: auto; color: var(--fade); font-size: .78rem; }
|
||||
#reflectBtn {
|
||||
background: #1b2333; border: 1px solid var(--border); color: var(--accent);
|
||||
background: #241400; border: 1px solid var(--border); color: var(--accent);
|
||||
border-radius: 8px; padding: 6px 11px; font-size: .82rem; cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
+954
-912
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "lyra"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Persistent, autonomous AI assistant"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"numpy>=2.4.5",
|
||||
"openai>=2.37.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
"treys>=0.1.8",
|
||||
"uvicorn[standard]>=0.34",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Deterministic equity/board-eval — the JJ-vs-65 hand Lyra kept botching."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from lyra import equity
|
||||
|
||||
|
||||
def test_flop_equity_and_made_hands():
|
||||
r = equity.analyze(["Jh", "Js"], ["6d", "5d"], ["8c", "7d", "Ts"])
|
||||
assert r["ahead"] == "hero"
|
||||
assert r["hero_hand"] == "Pair" and r["villain_hand"] == "High Card"
|
||||
assert 75 < r["hero_equity"] < 82 # ~78.7%
|
||||
|
||||
|
||||
def test_turn_villain_straight_and_outs_exclude_flush_card():
|
||||
r = equity.analyze(["Jh", "Js"], ["6d", "5d"], ["8c", "7d", "Ts", "4d"])
|
||||
assert r["ahead"] == "villain"
|
||||
assert r["villain_hand"] == "Straight"
|
||||
# hero's only outs are the three non-diamond nines — 9d makes villain a flush
|
||||
assert r["hero_outs"]["count"] == 3
|
||||
assert "9d" not in r["hero_outs"]["cards"]
|
||||
assert r["hero_equity"] < 10
|
||||
|
||||
|
||||
def test_rejects_unknown_and_duplicate_cards():
|
||||
with pytest.raises(equity.EquityError):
|
||||
equity.analyze(["x", "x"], ["6d", "5d"], ["8c", "7d", "Ts"])
|
||||
with pytest.raises(equity.EquityError):
|
||||
equity.analyze(["8c", "8c"], ["6d", "5d"], ["8c", "7d", "Ts"])
|
||||
|
||||
|
||||
def test_unknown_suits_spread_rainbow_no_phantom_flush():
|
||||
# all-unknown-suit board must not become monotone (which would inflate equity)
|
||||
r = equity.analyze(["Jx", "Jx"], ["6d", "5d"], ["8x", "7x", "Tx"])
|
||||
assert 75 < r["hero_equity"] < 82
|
||||
|
||||
|
||||
def test_tool_dispatch():
|
||||
from lyra import tools
|
||||
out = tools.dispatch("analyze_spot", {"hero": "Jh Js", "villain": "6d 5d", "board": "8c 7d Ts 4d"})
|
||||
assert "EQUITY" in out and "Straight" in out
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Poker domain: structured session/hand/villain storage + stats, and the tools."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lyra(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
from lyra import llm
|
||||
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
||||
import lyra.memory as memory
|
||||
importlib.reload(memory)
|
||||
import lyra.poker as poker
|
||||
importlib.reload(poker) # rebind to the reloaded memory + reset its schema flag
|
||||
return poker
|
||||
|
||||
|
||||
def test_session_lifecycle_and_net(lyra):
|
||||
poker = lyra
|
||||
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
||||
assert poker.live_session()["id"] == sid
|
||||
poker.add_buyin(500) # rebuy -> total 900
|
||||
s = poker.end_session(cash_out=627)
|
||||
assert s["buy_in_total"] == 900
|
||||
assert s["net"] == pytest.approx(-273)
|
||||
assert s["status"] == "closed"
|
||||
assert poker.live_session() is None # closed -> no live session
|
||||
|
||||
|
||||
def test_log_hand_partial_fields(lyra):
|
||||
poker = lyra
|
||||
poker.start_session(stakes="1/3", buy_in=300)
|
||||
hid = poker.log_hand(position="BTN", hole_cards="AKs", result=120, tag="confidence")
|
||||
hands = poker.list_hands()
|
||||
assert len(hands) == 1 and hands[0]["id"] == hid
|
||||
assert hands[0]["hole_cards"] == "AKs" and hands[0]["result"] == 120
|
||||
assert hands[0]["board"] is None # unspecified fields stay null
|
||||
|
||||
|
||||
def test_villain_file_upsert_and_read(lyra):
|
||||
poker = lyra
|
||||
poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
||||
poker.add_read("limp-called K4s UTG", name="Sleepy John", seat="3",
|
||||
tendencies="loose-passive, jackpot dreamer", category="feeder", venue="Meadows")
|
||||
# update the same player
|
||||
poker.add_read("cold-called a 3-bet with A2o", name="sleepy john")
|
||||
file = poker.get_villain_file(name="Sleepy")
|
||||
assert len(file) == 1 # matched by name, not duplicated
|
||||
assert file[0]["category"] == "feeder"
|
||||
|
||||
|
||||
def test_running_stats(lyra):
|
||||
poker = lyra
|
||||
s1 = poker.start_session(stakes="1/3", buy_in=300)
|
||||
poker.end_session(540, session_id=s1)
|
||||
s2 = poker.start_session(stakes="1/3", buy_in=400)
|
||||
poker.end_session(300, session_id=s2)
|
||||
rs = poker.running_stats(stakes="1/3")
|
||||
assert rs["sessions"] == 2
|
||||
assert rs["net"] == pytest.approx(140) # +240 then -100
|
||||
assert "1/3" in rs["by_stake"]
|
||||
|
||||
|
||||
def test_hand_history_store_and_get(lyra):
|
||||
poker = lyra
|
||||
parsed = {"game": "NLH", "stakes": "1/3", "hero_pos": "BTN", "hero_cards": ["As", "Ks"],
|
||||
"players": [{"pos": "BTN", "cards": ["As", "Ks"]}, {"pos": "BB"}],
|
||||
"actions": [{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 12},
|
||||
{"street": "flop", "board": ["As", "7d", "2s"]}],
|
||||
"board": ["As", "7d", "2s"], "result": {"pot": 80, "hero_net": 330, "summary": "won"}}
|
||||
hid = poker.store_hand_history(parsed) # no live session -> attaches to a review session
|
||||
h = poker.get_hand(hid)
|
||||
assert h["position"] == "BTN" and h["hole_cards"] == "As Ks"
|
||||
assert h["result"] == 330
|
||||
assert h["structured"]["actions"][0]["amount"] == 12
|
||||
|
||||
|
||||
def test_record_hand_tool_parses_and_stores(lyra, monkeypatch):
|
||||
import re
|
||||
|
||||
from lyra import llm, tools
|
||||
hand_json = ('{"hero_pos":"CO","hero_cards":["Js","Jd"],'
|
||||
'"players":[{"pos":"CO","cards":["Js","Jd"]},{"pos":"BB","name":"drunk"}],'
|
||||
'"actions":[{"street":"preflop","pos":"CO","action":"raise","amount":45}],'
|
||||
'"board":[],"result":{"hero_net":-300,"summary":"lost to a straight"}}')
|
||||
monkeypatch.setattr(llm, "complete", lambda messages, backend=None, model=None: hand_json)
|
||||
out = tools.dispatch("record_hand", {"shorthand": "JJ in CO, lost to a straight", "stakes": "1/3"})
|
||||
assert "/hand/" in out
|
||||
hid = int(re.search(r"/hand/(\d+)", out).group(1))
|
||||
h = lyra.get_hand(hid)
|
||||
assert h["structured"]["hero_pos"] == "CO"
|
||||
assert h["result"] == -300
|
||||
|
||||
|
||||
def test_generate_recap(lyra, monkeypatch):
|
||||
poker = lyra
|
||||
from lyra import llm
|
||||
monkeypatch.setattr(llm, "complete",
|
||||
lambda messages, backend=None, model=None: "# Recap\n## Final Assessment\nGood session.")
|
||||
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
||||
poker.log_hand(position="BTN", hole_cards="AKs", result=180, tag="confidence")
|
||||
poker.end_session(540, session_id=sid)
|
||||
out = poker.generate_recap(session_id=sid)
|
||||
assert out["id"] == sid and "Final Assessment" in out["markdown"]
|
||||
assert "Recap" in poker.get_session(sid)["recap_md"]
|
||||
|
||||
|
||||
def test_list_recent_hands(lyra):
|
||||
poker = lyra
|
||||
poker.start_session(stakes="1/3", buy_in=300)
|
||||
poker.log_hand(position="CO", hole_cards="QQ", result=-50)
|
||||
hh = poker.list_recent_hands()
|
||||
assert hh and hh[0]["hole_cards"] == "QQ" and hh[0]["stakes"] == "1/3"
|
||||
|
||||
|
||||
def test_player_observation_and_profile(lyra):
|
||||
poker = lyra
|
||||
sid = poker.start_session(stakes="1/3", buy_in=300)
|
||||
parsed = {"hero_pos": "BB",
|
||||
"players": [{"pos": "BTN", "name": "Round Mike"}, {"pos": "BB", "name": None}],
|
||||
"actions": [{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 12},
|
||||
{"street": "preflop", "pos": "BB", "action": "call"},
|
||||
{"street": "flop", "board": ["7d", "2c", "5h"]},
|
||||
{"street": "flop", "pos": "BTN", "action": "bet", "amount": 15}]}
|
||||
hid = poker.store_hand_history(parsed, session_id=sid)
|
||||
assert poker.link_hand_players(hid, parsed, session_id=sid) == 1 # only the named player
|
||||
prof = poker.player_profile("mike")
|
||||
assert prof["player"]["name"] == "Round Mike"
|
||||
assert prof["observations"] == 1
|
||||
assert prof["stats"] is None and "small_sample" in prof # too few hands for stats
|
||||
|
||||
|
||||
def test_player_stats_emerge_with_sample(lyra):
|
||||
poker = lyra
|
||||
sid = poker.start_session(stakes="1/3", buy_in=300)
|
||||
raised = {"players": [{"pos": "BTN", "name": "LAG"}],
|
||||
"actions": [{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 10}]}
|
||||
folded = {"players": [{"pos": "UTG", "name": "LAG"}],
|
||||
"actions": [{"street": "preflop", "pos": "UTG", "action": "fold"}]}
|
||||
for i in range(poker.MIN_STATS_SAMPLE):
|
||||
p = raised if i % 2 == 0 else folded
|
||||
hid = poker.store_hand_history(p, session_id=sid)
|
||||
poker.link_hand_players(hid, p, session_id=sid)
|
||||
prof = poker.player_profile("LAG")
|
||||
assert prof["stats"] is not None
|
||||
assert prof["stats"]["hands"] >= poker.MIN_STATS_SAMPLE
|
||||
assert 30 <= prof["stats"]["vpip_pct"] <= 70 # ~half were voluntary
|
||||
|
||||
|
||||
def test_poker_tools_dispatch(lyra):
|
||||
from lyra import tools
|
||||
assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})
|
||||
assert "logged" in tools.dispatch("log_hand", {"position": "CO", "hole_cards": "QQ"})
|
||||
assert "closed" in tools.dispatch("end_session", {"cash_out": 500})
|
||||
# the poker tools are offered to the model
|
||||
names = {s["function"]["name"] for s in tools.specs()}
|
||||
assert {"start_session", "log_hand", "end_session", "running_stats", "get_villain_file"} <= names
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Behind-the-scenes feedback storage (fine-tune signal)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "t.db"))
|
||||
from lyra import llm
|
||||
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
||||
import lyra.memory as m
|
||||
importlib.reload(m)
|
||||
return m
|
||||
|
||||
|
||||
def test_rating_counts_and_upsert(memory):
|
||||
memory.add_rating("chat", 1, "good reply", context="hey")
|
||||
memory.add_rating("reflection", -1, "repetitive thought")
|
||||
assert memory.rating_counts() == {"total": 2, "up": 1, "down": 1}
|
||||
assert any(r["context"] == "hey" for r in memory.list_ratings())
|
||||
|
||||
# re-rating the same content replaces the row (no duplicate; flips the rating)
|
||||
memory.add_rating("chat", -1, "good reply")
|
||||
assert memory.rating_counts() == {"total": 2, "up": 0, "down": 2}
|
||||
assert any(r["content"] == "good reply" and r["rating"] == -1 for r in memory.list_ratings())
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Lyra's tools: dispatch + the chat tool loop (call -> run -> feed back -> reply)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lyra(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
from lyra import llm
|
||||
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
||||
import lyra.memory as memory
|
||||
importlib.reload(memory)
|
||||
return memory
|
||||
|
||||
|
||||
def test_journal_write_tool(lyra):
|
||||
from lyra import tools
|
||||
out = tools.dispatch("journal_write", '{"entry": "a private thought"}')
|
||||
assert "journal" in out.lower()
|
||||
entries = lyra.list_journal(kinds=("journal",))
|
||||
assert any(e["content"] == "a private thought" and e["source"] == "chat" for e in entries)
|
||||
|
||||
|
||||
def test_note_tool_with_tag(lyra):
|
||||
from lyra import tools
|
||||
tools.dispatch("note", {"content": "villain 3-bets light", "tag": "poker"})
|
||||
notes = lyra.list_journal(kinds=("note",))
|
||||
assert any("[poker] villain 3-bets light" == e["content"] for e in notes)
|
||||
|
||||
|
||||
def test_unknown_tool_is_safe(lyra):
|
||||
from lyra import tools
|
||||
assert "unknown tool" in tools.dispatch("nope", {})
|
||||
|
||||
|
||||
def test_chat_runs_tool_then_replies(lyra, monkeypatch):
|
||||
from lyra import llm, chat
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_chat_call(messages, backend="cloud", model=None, tools=None):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return ({"role": "assistant", "content": None, "tool_calls": []},
|
||||
[{"id": "c1", "name": "journal_write", "arguments": '{"entry": "noted from chat"}'}])
|
||||
return ({"role": "assistant", "content": "Done, Brian."}, None)
|
||||
|
||||
monkeypatch.setattr(llm, "chat_call", fake_chat_call)
|
||||
reply = chat.respond("s1", "write that down for me", backend="cloud")
|
||||
|
||||
assert reply == "Done, Brian."
|
||||
assert calls["n"] == 2 # one tool round, then the text reply
|
||||
assert any("noted from chat" in e["content"] for e in lyra.list_journal())
|
||||
@@ -286,6 +286,7 @@ dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "openai" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "treys" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
@@ -302,6 +303,7 @@ requires-dist = [
|
||||
{ name = "numpy", specifier = ">=2.4.5" },
|
||||
{ name = "openai", specifier = ">=2.37.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "treys", specifier = ">=0.1.8" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
|
||||
]
|
||||
|
||||
@@ -692,6 +694,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "treys"
|
||||
version = "0.1.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/a6/1712340dc1ac96d40afe162d43ce146c7781ba59cde5efc988aaee35ada4/treys-0.1.8.tar.gz", hash = "sha256:a486a42b899e91985b4da4fdac9a30e638275648977104487acb90a2dd7cd73b", size = 12073, upload-time = "2022-06-21T16:02:44.976Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/df/e6b3b1cc98c3e00c5b146113f998dfe0de47358277648df235a6ae571143/treys-0.1.8-py3-none-any.whl", hash = "sha256:9ba3460ff2ed597510fb535af6280f115254b0b70699ea362f8f1ee067378063", size = 11897, upload-time = "2022-06-21T16:02:42.896Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in New Issue
Block a user