Compare commits
24 Commits
v0.2.0
...
fef45b3e05
| Author | SHA1 | Date | |
|---|---|---|---|
| fef45b3e05 | |||
| 5dbcfc7ccf | |||
| 951788f9ec | |||
| 5176c706b6 | |||
| debb553fe9 | |||
| 44a559c5f9 | |||
| f2de7dec61 | |||
| 559faaed30 | |||
| cca8322ee2 | |||
| 67cf51a53f | |||
| df591e4e01 | |||
| 5c41bd48d1 | |||
| 5e9f3efeec | |||
| 654a7531e8 | |||
| cebb87205c | |||
| e1e89c07e4 | |||
| 35c973df05 | |||
| 974ee33f71 | |||
| dfb6425395 | |||
| d9f5055ec1 | |||
| 50f460eeb2 | |||
| 5dc3fa17d7 | |||
| fa168271e1 | |||
| e75c4390b5 |
@@ -22,3 +22,21 @@ SUMMARY_BACKEND=local
|
||||
|
||||
# Where Lyra stores her memory.
|
||||
LYRA_DB_PATH=data/lyra.db
|
||||
|
||||
# Optional: run embeddings on a separate always-on Ollama (decoupled from
|
||||
# LOCAL_BASE_URL, which serves local chat). Defaults to LOCAL_BASE_URL if unset.
|
||||
# EMBED_BASE_URL=http://127.0.0.1:11434
|
||||
|
||||
# --- Thought-loop reach-out (ntfy push) ---
|
||||
# Leave NTFY_URL empty to disable proactive pings entirely.
|
||||
NTFY_URL=
|
||||
NTFY_TOPIC=lyra
|
||||
LYRA_WEB_URL=
|
||||
PING_SALIENCE=0.7 # min thought salience to push (eager)
|
||||
PING_COOLDOWN_MIN=0 # min minutes between pushes (0 = none)
|
||||
PING_QUIET_HOURS=1-9 # local hours to stay silent
|
||||
LYRA_TIMEZONE=America/New_York
|
||||
|
||||
# --- External input feeds (RSS/Atom, comma-separated) ---
|
||||
LYRA_FEEDS=https://hnrss.org/frontpage,https://www.pokernews.com/rss.php
|
||||
FEED_REACT_PROB=0.5 # chance a new thought reacts to a feed item
|
||||
|
||||
@@ -36,3 +36,4 @@ data/
|
||||
#lyra Stuff
|
||||
/core/relay/sessions/
|
||||
/chat-gpt-export/
|
||||
/import/
|
||||
@@ -1,5 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.0 — session modes + live HUD
|
||||
|
||||
Lyra stopped being a wishy-washy companion during live poker. She now switches
|
||||
register based on what she's actually doing at the table.
|
||||
|
||||
### Conversation modes
|
||||
- **Two modes** — 💬 **Talk** (the companion, default) and ♠ **Cash** (live cash
|
||||
copilot). A mode bundles a prompt card + a tool allow-list (`lyra/modes.py`).
|
||||
- **Two-register Cash voice** — quiet, act-first logging when Brian feeds facts
|
||||
(stack, hand, read → logged in one line, no narration); full warm companion
|
||||
voice when he asks for strategy or signals tilt/card-dead/steaming. Mental game
|
||||
and strategy never get clipped.
|
||||
- **Tool gating by mode** — Talk offers journaling + read-only poker lookups;
|
||||
Cash unlocks the full live toolset. `tools.specs(allow=…)` does the filtering.
|
||||
- **Auto-switch** — opening a session (`start_session`) flips the chat into Cash
|
||||
mode automatically; the UI badge/HUD follow. Manual switch overrides anytime.
|
||||
- Mode persists per chat session (new `mode` column); Cash mode forces the cloud
|
||||
backend, since tools only fire there.
|
||||
|
||||
### Mental-game rituals
|
||||
- Brian's own rituals are now first-class, live tools (not just post-hoc recap
|
||||
sections): **Scar Notes** (with the punt / cooler / standard distinction),
|
||||
**Confidence Bank** (good process, banked regardless of result), **Alligator
|
||||
Blood** mode (an invokable adversity state — she'll suggest it when he's
|
||||
card-dead/short/stuck, and her coaching register shifts while it's on), and
|
||||
**Reset** (a tilt circuit-breaker; mental marker, stats stay continuous).
|
||||
- Rituals show on the HUD (🐊 banner, Confidence Bank + Scar Notes panels) and feed
|
||||
the recap's Scar Notes / Confidence Bank sections with what actually happened.
|
||||
|
||||
### Session HUD
|
||||
- **Live HUD** at `/session` (bottom-nav tab on mobile, header link on desktop) —
|
||||
polls every 5s: header (venue/stakes/elapsed/live net), stack with
|
||||
**stack-over-time sparkline**, hands this session (tap → replay), villains seen,
|
||||
her notes, and session stats.
|
||||
- **Stack tracking** — new `log_stack` tool + `poker_stack_log` table → current
|
||||
stack, **live net while still sitting** (stack − buy-in), and the sparkline series.
|
||||
|
||||
### Next
|
||||
- Strategy RAG (poker books/notes) plugs into Cash's coaching register.
|
||||
|
||||
## 0.2.0 — first working system
|
||||
|
||||
The leap from "chat + memory baseline" to a working, persistent companion with a
|
||||
|
||||
@@ -43,9 +43,22 @@ reflected), and keeps a permanent **journal**.
|
||||
|
||||
## Poker copilot
|
||||
|
||||
She runs in **modes** (`lyra/modes.py`). 💬 **Talk** is the default companion
|
||||
(journaling + read-only poker lookups). ♠ **Cash** is the live copilot: she gets
|
||||
the full session toolset and a two-register voice — quiet and act-first when
|
||||
you're feeding her facts to log (stack, a hand, a read → one-line confirm, no
|
||||
narration), but fully present and warm when you ask for strategy or you're tilting
|
||||
/ card-dead / steaming. Opening a session auto-switches her into Cash mode.
|
||||
|
||||
Talk to her during a session; she drives tools behind the scenes:
|
||||
|
||||
- **Session tracking** — `start_session`, `add_buyin`, `end_session` → net, hours, $/hr.
|
||||
- **Stack tracking** — `log_stack` records your stack as the night goes → live net
|
||||
while you're still sitting, and a stack-over-time sparkline on the HUD.
|
||||
- **Mental-game rituals** — your own system, run live: **Scar Notes** (punt / cooler
|
||||
/ standard), **Confidence Bank** (good process, banked regardless of result),
|
||||
**Alligator Blood** mode (adversity register she'll suggest when you're card-dead /
|
||||
stuck), and **Reset** (tilt circuit-breaker). They surface on the HUD and ground the recap.
|
||||
- **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
|
||||
@@ -56,9 +69,11 @@ Talk to her during a session; she drives tools behind the scenes:
|
||||
|
||||
## 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).
|
||||
`/` chat (Markdown, model picker, 👍/👎 rating, **Talk/Cash mode switcher**) ·
|
||||
`/session` **live session HUD** (stack + sparkline, hands, villains, notes; mobile
|
||||
Session tab) · `/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).
|
||||
|
||||
|
||||
+143
-7
@@ -10,7 +10,7 @@ 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 clock, config, llm, logbus, memory, modes, persona, self_state, summary, thoughts
|
||||
from lyra import tools as toolkit
|
||||
from lyra.llm import Backend, Message
|
||||
|
||||
@@ -24,6 +24,30 @@ MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn
|
||||
TOOL_BACKENDS = {"cloud"}
|
||||
|
||||
|
||||
def _mode_state_note(mode: modes.Mode | None) -> str | None:
|
||||
"""Dynamic, per-turn state for the active mode. Currently: surface Alligator
|
||||
Blood while it's engaged on the live session, so she stays in that register."""
|
||||
if not mode or mode.key != modes.CASH.key:
|
||||
return None
|
||||
from lyra import poker # local import: keep the core/domain coupling at call time
|
||||
if poker.alligator_active():
|
||||
return (
|
||||
"🐊 ALLIGATOR BLOOD is ON for this session. Coach Brian in that register: "
|
||||
"hang around, refuse to die, don't force miracles, make opponents beat him "
|
||||
"correctly. Tough, patient, steady — no heroics, no spew, no quitting."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _maybe_switch_mode(session_id: str, tool_name: str) -> None:
|
||||
"""Keep the chat framing aligned with the live data: opening a poker session
|
||||
auto-flips this chat into Cash mode (so the next turn gets the cash card + the
|
||||
full live toolset). Manual UI switching still overrides anytime."""
|
||||
if tool_name == "start_session":
|
||||
memory.set_session_mode(session_id, modes.CASH.key)
|
||||
logbus.log("info", "mode auto-switch", session=session_id, mode=modes.CASH.key)
|
||||
|
||||
|
||||
def _summary_note(summaries: list[memory.Summary]) -> Message:
|
||||
lines = [f"- ({(s.session_started_at or s.created_at)[:10]}) {s.content}" for s in summaries]
|
||||
body = "Gist of earlier sessions (compacted — ask if you need specifics):\n" + "\n".join(lines)
|
||||
@@ -36,6 +60,27 @@ def _detail_note(exchanges: list[memory.Exchange]) -> Message:
|
||||
return {"role": "system", "content": body}
|
||||
|
||||
|
||||
def _inner_life_note() -> Message | None:
|
||||
"""One coherent window onto what she's been doing on her own since last time —
|
||||
the threads she's turning over plus the things she's written for herself. Sits
|
||||
with her self-state so chat reads as a continuous mind, not a fresh boot. The
|
||||
persona tells her to weave this in naturally when it fits."""
|
||||
parts: list[str] = []
|
||||
threads = thoughts.context_note() # active threads, with their latest thought
|
||||
if threads:
|
||||
parts.append(threads)
|
||||
wrote = memory.list_journal(limit=3, kinds=("journal", "note"))
|
||||
if wrote:
|
||||
lines = "\n".join(f"- ({w['created_at'][:10]}) {w['content']}" for w in reversed(wrote))
|
||||
parts.append(
|
||||
"Things you've written in your journal lately (yours — you can refer back "
|
||||
"to them if they're relevant):\n" + lines
|
||||
)
|
||||
if not parts:
|
||||
return None
|
||||
return {"role": "system", "content": "\n\n".join(parts)}
|
||||
|
||||
|
||||
def _now_note() -> Message:
|
||||
"""Current wall-clock time + how long since Brian last said anything.
|
||||
|
||||
@@ -56,7 +101,8 @@ def _render(messages: list[Message]) -> str:
|
||||
return "\n\n".join(f"[{m['role']}]\n{m['content']}" for m in messages)
|
||||
|
||||
|
||||
def build_messages(session_id: str, user_msg: str) -> list[Message]:
|
||||
def build_messages(session_id: str, user_msg: str,
|
||||
mode: modes.Mode | None = None) -> list[Message]:
|
||||
"""Assemble the full, tiered message list for one turn."""
|
||||
messages: list[Message] = [{"role": "system", "content": persona.system_prompt()}]
|
||||
|
||||
@@ -64,9 +110,38 @@ def build_messages(session_id: str, user_msg: str) -> list[Message]:
|
||||
# right after the persona — her sense of self before her model of the world.
|
||||
messages.append({"role": "system", "content": self_state.render_for_context(self_state.load())})
|
||||
|
||||
# Her ongoing inner life — the threads she's turning over and what she's written
|
||||
# for herself — so she's continuous across conversations and can pick up where she
|
||||
# left off, not only when a thought crosses the surface bar below. Rides with the
|
||||
# self; the persona tells her to bring it into conversation naturally when it fits.
|
||||
inner = _inner_life_note()
|
||||
if inner:
|
||||
messages.append(inner)
|
||||
|
||||
# Mode card: how to behave *right now* (e.g. live-cash copilot). High priority —
|
||||
# it sits just after her sense of self, before her model of the world. Talk mode
|
||||
# has no card (the persona's default voice is the Talk register).
|
||||
if mode and mode.card:
|
||||
messages.append({"role": "system", "content": mode.card})
|
||||
|
||||
# Live ritual state (e.g. Alligator Blood ON) — dynamic, so it rides alongside
|
||||
# the static card and keeps her in-register for the whole stretch, not just the
|
||||
# turn she flipped it.
|
||||
state_note = _mode_state_note(mode)
|
||||
if state_note:
|
||||
messages.append({"role": "system", "content": state_note})
|
||||
|
||||
# When she is: current time + the gap since Brian last spoke (she has no clock).
|
||||
messages.append(_now_note())
|
||||
|
||||
# Thought loop: if Brian's been away and one of her own threads has built past
|
||||
# the surface bar, let her lead with it (once). This is her #6 — bringing what
|
||||
# she thought about while alone *to* him. Runs before the world-model tiers so
|
||||
# it's framed as her interiority, like the self-state.
|
||||
surfaced = thoughts.maybe_surface(memory.last_exchange_at())
|
||||
if surfaced:
|
||||
messages.append({"role": "system", "content": surfaced})
|
||||
|
||||
# Semantic memory: the distilled profile (who Brian is) — answers identity
|
||||
# questions that raw recall can't. Always in context when it exists.
|
||||
profile = memory.get_profile()
|
||||
@@ -133,11 +208,12 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
model=model, embed=cfg.embed_backend,
|
||||
)
|
||||
|
||||
messages = build_messages(session_id, user_msg)
|
||||
mode = modes.get(memory.get_session_mode(session_id))
|
||||
messages = build_messages(session_id, user_msg, mode=mode)
|
||||
|
||||
# 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
|
||||
# Tool loop: offer Lyra her tools (scoped to the mode); if she calls one, run it
|
||||
# and feed the result back so she can continue, until she returns a text reply.
|
||||
tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None
|
||||
ctx = {"session_id": session_id, "backend": backend}
|
||||
reply = ""
|
||||
for _ in range(MAX_TOOL_ROUNDS):
|
||||
@@ -152,6 +228,7 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
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})
|
||||
_maybe_switch_mode(session_id, tc["name"])
|
||||
if not reply:
|
||||
reply = "(I got tangled using my tools there — say that again?)"
|
||||
logbus.log("info", "reply", session=session_id, chars=len(reply))
|
||||
@@ -160,5 +237,64 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
memory.remember(session_id, "assistant", reply)
|
||||
|
||||
# Compact this session once enough new turns have piled up.
|
||||
summary.maybe_summarize(session_id)
|
||||
summary.maybe_summarize_async(session_id)
|
||||
return reply
|
||||
|
||||
|
||||
def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
model_override: str | None = None):
|
||||
"""Streaming generator version of `respond`.
|
||||
|
||||
Yields ("delta", text) as content streams in, and ("tool", name) when a tool
|
||||
runs. Persists the full exchange and yields a final ("done", reply) — matching
|
||||
`respond`'s side effects (memory + compaction) exactly.
|
||||
"""
|
||||
cfg = config.load()
|
||||
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 (stream)", session=session_id, backend=backend,
|
||||
model=model, embed=cfg.embed_backend,
|
||||
)
|
||||
|
||||
mode = modes.get(memory.get_session_mode(session_id))
|
||||
messages = build_messages(session_id, user_msg, mode=mode)
|
||||
tool_specs = toolkit.specs(mode.tools) if backend in TOOL_BACKENDS else None
|
||||
ctx = {"session_id": session_id, "backend": backend}
|
||||
parts: list[str] = []
|
||||
for _ in range(MAX_TOOL_ROUNDS):
|
||||
assistant_msg = None
|
||||
tool_calls = None
|
||||
for ev, payload in llm.chat_call_stream(
|
||||
messages, backend=backend, model=model, tools=tool_specs
|
||||
):
|
||||
if ev == "delta":
|
||||
parts.append(payload)
|
||||
yield ("delta", payload)
|
||||
elif ev == "message":
|
||||
assistant_msg = payload
|
||||
elif ev == "tool_calls":
|
||||
tool_calls = payload
|
||||
if not tool_calls:
|
||||
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})
|
||||
_maybe_switch_mode(session_id, tc["name"])
|
||||
yield ("tool", tc["name"])
|
||||
|
||||
reply = "".join(parts)
|
||||
if not reply:
|
||||
reply = "(I got tangled using my tools there — say that again?)"
|
||||
yield ("delta", reply)
|
||||
logbus.log("info", "reply", session=session_id, chars=len(reply))
|
||||
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
memory.remember(session_id, "assistant", reply)
|
||||
summary.maybe_summarize_async(session_id)
|
||||
yield ("done", reply)
|
||||
|
||||
@@ -25,6 +25,15 @@ def stamp(dt: datetime | None = None) -> str:
|
||||
return (dt or now()).strftime("%A, %d %b %Y, %H:%M UTC")
|
||||
|
||||
|
||||
def gap_seconds(since_iso: str | None, ref: datetime | None = None) -> float | None:
|
||||
"""Seconds elapsed since `since_iso` (None -> None). The numeric counterpart to
|
||||
humanize_gap, for code that needs to threshold on elapsed time."""
|
||||
if not since_iso:
|
||||
return None
|
||||
ref = ref or now()
|
||||
return max(0.0, (ref - _parse(since_iso)).total_seconds())
|
||||
|
||||
|
||||
def humanize_gap(since_iso: str | None, ref: datetime | None = None) -> str | None:
|
||||
"""A coarse human description of how long since `since_iso` (None -> None)."""
|
||||
if not since_iso:
|
||||
|
||||
@@ -22,8 +22,25 @@ class Config:
|
||||
embed_backend: str # "cloud" (OpenAI) or "local" (Ollama)
|
||||
embed_model: str # OpenAI embedding model
|
||||
local_embed_model: str # Ollama embedding model
|
||||
embed_base_url: str # Ollama endpoint for embeddings (own box, decoupled from local chat)
|
||||
summary_backend: str # "local" or "cloud" — backend used to compact memory
|
||||
db_path: Path
|
||||
# Proactive reach-out (ntfy push). Empty ntfy_url disables pinging.
|
||||
ntfy_url: str # base url, e.g. "http://10.0.0.41:8090"
|
||||
ntfy_topic: str # topic to publish to, e.g. "lyra"
|
||||
web_url: str # base url of the Lyra web app, for push tap-through links
|
||||
timezone: str # IANA tz for quiet hours / local time
|
||||
ping_salience: float # min thought salience to push (eager = ~0.7)
|
||||
ping_cooldown_min: int # min minutes between pushes (eager = 0)
|
||||
ping_quiet_hours: str # local "start-end" 24h window to stay silent, e.g. "1-9"
|
||||
# External input feed (her #1: react to the world). Comma-separated RSS/Atom URLs.
|
||||
feeds: tuple[str, ...]
|
||||
feed_react_prob: float # chance a would-be new thread reacts to a feed item instead
|
||||
|
||||
|
||||
def _csv(name: str, default: str) -> tuple[str, ...]:
|
||||
raw = os.getenv(name, default)
|
||||
return tuple(u.strip() for u in raw.split(",") if u.strip())
|
||||
|
||||
|
||||
def load() -> Config:
|
||||
@@ -38,6 +55,18 @@ def load() -> Config:
|
||||
embed_backend=os.getenv("EMBED_BACKEND", "cloud").lower(),
|
||||
embed_model=os.getenv("EMBED_MODEL", "text-embedding-3-small"),
|
||||
local_embed_model=os.getenv("LOCAL_EMBED_MODEL", "nomic-embed-text"),
|
||||
# Embeddings can live on their own always-on box, separate from the local
|
||||
# chat backend. Defaults to LOCAL_BASE_URL so existing setups are unchanged.
|
||||
embed_base_url=os.getenv("EMBED_BASE_URL", os.getenv("LOCAL_BASE_URL", "http://localhost:11434")),
|
||||
summary_backend=os.getenv("SUMMARY_BACKEND", "local").lower(),
|
||||
db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")),
|
||||
ntfy_url=os.getenv("NTFY_URL", "").rstrip("/"),
|
||||
ntfy_topic=os.getenv("NTFY_TOPIC", "lyra"),
|
||||
web_url=os.getenv("LYRA_WEB_URL", "").rstrip("/"),
|
||||
timezone=os.getenv("LYRA_TIMEZONE", "America/New_York"),
|
||||
ping_salience=float(os.getenv("PING_SALIENCE", "0.7")),
|
||||
ping_cooldown_min=int(os.getenv("PING_COOLDOWN_MIN", "0")),
|
||||
ping_quiet_hours=os.getenv("PING_QUIET_HOURS", "1-9"),
|
||||
feeds=_csv("LYRA_FEEDS", "https://hnrss.org/frontpage,https://www.pokernews.com/rss.php"),
|
||||
feed_react_prob=float(os.getenv("FEED_REACT_PROB", "0.5")),
|
||||
)
|
||||
|
||||
+20
-2
@@ -25,7 +25,7 @@ import argparse
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from lyra import config, era, logbus, memory, narrative, profile, self_state, summary
|
||||
from lyra import config, era, feeds, logbus, memory, narrative, profile, self_state, summary, thoughts
|
||||
from lyra.llm import Backend
|
||||
from lyra.summary import SUMMARIZE_AFTER
|
||||
|
||||
@@ -78,6 +78,16 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
|
||||
logbus.log("info", "dream cycle sensing", ripe=backlog["ripe"], dirty=backlog["dirty"],
|
||||
profile_lag=profile_lag, new_activity=new_activity, drives=_round(drives))
|
||||
|
||||
# Thought-loop housekeeping (no LLM): rest stale threads so the open-thread cap
|
||||
# never jams and the feed stays current. Cheap; run every pass.
|
||||
thoughts.decay()
|
||||
# Pull external feeds on the cycle cadence (~30 min) so she has fresh items from
|
||||
# the world to react to. Network-only; failures degrade to no new items.
|
||||
try:
|
||||
feeds.refresh()
|
||||
except Exception as exc:
|
||||
logbus.log("error", "feed refresh failed", error=str(exc)[:160])
|
||||
|
||||
actions: list[str] = []
|
||||
|
||||
# --- continuity: compact raw sessions into gists ---
|
||||
@@ -98,10 +108,18 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
|
||||
actions.append("integrated knowledge (profile/eras/narrative)")
|
||||
drives["coherence"] = 0.0
|
||||
|
||||
# --- curiosity: reflect and evolve the self ---
|
||||
# --- curiosity: reflect and evolve the self, then advance the thought loop ---
|
||||
if force or drives["curiosity"] >= THRESHOLD:
|
||||
self_state.reflect(backend=backend, source="dream") # writes state + journal itself
|
||||
actions.append("reflected")
|
||||
# Thinking, continued: advance one threaded train of thought. reflect()
|
||||
# just refreshed her self-state, so the thought is grounded in it. A bad
|
||||
# think pass shouldn't sink the cycle.
|
||||
try:
|
||||
rep = thoughts.think(backend=backend, source="dream")
|
||||
actions.append(f"thought ({rep['mode']})" if rep else "thought (no parse)")
|
||||
except Exception as exc:
|
||||
logbus.log("error", "thought loop failed", error=str(exc)[:200])
|
||||
drives["curiosity"] = CURIOSITY_FLOOR
|
||||
|
||||
if not actions:
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
"""External input stream: RSS/Atom feeds Lyra reacts to (her thought-loop #1).
|
||||
|
||||
Her own sketch wanted the loop fed by "external data feeds relevant to your
|
||||
interests (poker articles, tech news)" — so her thoughts aren't only about her own
|
||||
interior. This pulls configured feeds, remembers what it's seen, and hands the
|
||||
thought loop one fresh item at a time to react to (see `thoughts.think` react mode).
|
||||
|
||||
Feeds are configurable (`LYRA_FEEDS`, comma-separated URLs). Parsing is stdlib
|
||||
ElementTree — tolerant of both RSS 2.0 and Atom, namespaces stripped — so there's
|
||||
no new dependency. Network failures degrade to "no item this pass", never raise.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
|
||||
from lyra import clock, config, logbus, memory
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS feed_items (
|
||||
id TEXT PRIMARY KEY, -- guid/link, stable per item
|
||||
feed TEXT,
|
||||
title TEXT,
|
||||
link TEXT,
|
||||
summary TEXT,
|
||||
seen_at TEXT NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feed_items_used ON feed_items(used);
|
||||
"""
|
||||
|
||||
_ensured_for = None
|
||||
_UA = {"User-Agent": "Lyra/0.3 (+thought-loop feed reader)"}
|
||||
_MAX_SUMMARY = 600
|
||||
|
||||
|
||||
def _c():
|
||||
global _ensured_for
|
||||
conn = memory._connection()
|
||||
if _ensured_for is not conn:
|
||||
conn.executescript(_SCHEMA)
|
||||
_ensured_for = conn
|
||||
return conn
|
||||
|
||||
|
||||
def _local(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1].lower()
|
||||
|
||||
|
||||
def _text(el) -> str:
|
||||
return (el.text or "").strip() if el is not None else ""
|
||||
|
||||
|
||||
def parse(xml: bytes, feed_url: str = "") -> list[dict]:
|
||||
"""Tolerant RSS-2.0 / Atom parse -> [{id,title,link,summary}]. Empty on garbage."""
|
||||
try:
|
||||
root = ET.fromstring(xml)
|
||||
except ET.ParseError:
|
||||
return []
|
||||
items: list[dict] = []
|
||||
for node in root.iter():
|
||||
if _local(node.tag) not in ("item", "entry"):
|
||||
continue
|
||||
title = link = summary = guid = ""
|
||||
for child in node:
|
||||
name = _local(child.tag)
|
||||
if name == "title":
|
||||
title = _text(child)
|
||||
elif name == "link":
|
||||
# RSS: text; Atom: href attribute (prefer rel=alternate / first)
|
||||
link = _text(child) or child.attrib.get("href", "") or link
|
||||
elif name in ("description", "summary", "content"):
|
||||
summary = summary or _text(child)
|
||||
elif name in ("guid", "id"):
|
||||
guid = _text(child)
|
||||
ident = guid or link or title
|
||||
if not ident or not (title or summary):
|
||||
continue
|
||||
items.append({
|
||||
"id": ident, "title": title, "link": link,
|
||||
"summary": summary[:_MAX_SUMMARY],
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def fetch(url: str) -> list[dict]:
|
||||
try:
|
||||
r = httpx.get(url, headers=_UA, timeout=10.0, follow_redirects=True)
|
||||
if r.status_code >= 400:
|
||||
logbus.log("error", "feed fetch failed", url=url, status=r.status_code)
|
||||
return []
|
||||
return parse(r.content, url)
|
||||
except Exception as exc:
|
||||
logbus.log("error", "feed fetch error", url=url, error=str(exc)[:160])
|
||||
return []
|
||||
|
||||
|
||||
def refresh() -> int:
|
||||
"""Pull all configured feeds; store items not seen before. Returns new count."""
|
||||
cfg = config.load()
|
||||
conn = _c()
|
||||
now = clock.now().isoformat()
|
||||
new = 0
|
||||
for url in cfg.feeds:
|
||||
for it in fetch(url):
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO feed_items (id, feed, title, link, summary, seen_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(it["id"], url, it["title"], it["link"], it["summary"], now),
|
||||
)
|
||||
new += cur.rowcount
|
||||
if new:
|
||||
logbus.log("info", "feeds refreshed", new_items=new)
|
||||
return new
|
||||
|
||||
|
||||
def next_item(refresh_first: bool = True) -> dict | None:
|
||||
"""One fresh (unused) feed item, newest-seen first. Caller marks it used."""
|
||||
if refresh_first:
|
||||
refresh()
|
||||
row = _c().execute(
|
||||
"SELECT id, feed, title, link, summary FROM feed_items "
|
||||
"WHERE used = 0 ORDER BY seen_at DESC, rowid DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def mark_used(item_id: str) -> None:
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute("UPDATE feed_items SET used = 1 WHERE id = ?", (item_id,))
|
||||
+85
-2
@@ -1,7 +1,8 @@
|
||||
"""LLM router: local (Ollama) chat, cloud (OpenAI) chat + embeddings."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, TypedDict
|
||||
import json
|
||||
from typing import Iterator, Literal, TypedDict
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
@@ -80,6 +81,88 @@ def chat_call(
|
||||
return {"role": "assistant", "content": complete(messages, backend=backend, model=model)}, None
|
||||
|
||||
|
||||
def chat_call_stream(
|
||||
messages: list, backend: Backend = "cloud", model: str | None = None,
|
||||
tools: list | None = None,
|
||||
) -> Iterator[tuple[str, object]]:
|
||||
"""Streaming variant of `chat_call`. Yields ("delta", text) for each content
|
||||
chunk as it arrives, then exactly two terminal events:
|
||||
("message", assistant_dict) — the full assistant turn, to append back
|
||||
("tool_calls", calls | None) — list of {id,name,arguments} or None
|
||||
|
||||
`local` (Ollama) streams NDJSON and never returns tool calls.
|
||||
"""
|
||||
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, "stream": True}
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
parts: list[str] = []
|
||||
frags: dict[int, dict] = {} # tool-call fragments accumulated by index
|
||||
for chunk in client.chat.completions.create(**kwargs):
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
if getattr(delta, "content", None):
|
||||
parts.append(delta.content)
|
||||
yield ("delta", delta.content)
|
||||
for tc in getattr(delta, "tool_calls", None) or []:
|
||||
slot = frags.setdefault(tc.index, {"id": "", "name": "", "arguments": ""})
|
||||
if tc.id:
|
||||
slot["id"] = tc.id
|
||||
if tc.function and tc.function.name:
|
||||
slot["name"] = tc.function.name
|
||||
if tc.function and tc.function.arguments:
|
||||
slot["arguments"] += tc.function.arguments
|
||||
content = "".join(parts)
|
||||
if frags:
|
||||
calls = [frags[i] for i in sorted(frags)]
|
||||
assistant = {
|
||||
"role": "assistant",
|
||||
"content": content or None,
|
||||
"tool_calls": [
|
||||
{"id": c["id"], "type": "function",
|
||||
"function": {"name": c["name"], "arguments": c["arguments"]}}
|
||||
for c in calls
|
||||
],
|
||||
}
|
||||
yield ("message", assistant)
|
||||
yield ("tool_calls", [{"id": c["id"], "name": c["name"], "arguments": c["arguments"]} for c in calls])
|
||||
else:
|
||||
yield ("message", {"role": "assistant", "content": content})
|
||||
yield ("tool_calls", None)
|
||||
return
|
||||
|
||||
# local (Ollama): stream NDJSON, no tools.
|
||||
parts = []
|
||||
with httpx.stream(
|
||||
"POST", f"{cfg.local_base_url}/api/chat",
|
||||
json={"model": model or cfg.local_model, "messages": messages, "stream": True},
|
||||
timeout=120,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
data = json.loads(line)
|
||||
piece = (data.get("message") or {}).get("content", "")
|
||||
if piece:
|
||||
parts.append(piece)
|
||||
yield ("delta", piece)
|
||||
if data.get("done"):
|
||||
break
|
||||
yield ("message", {"role": "assistant", "content": "".join(parts)})
|
||||
yield ("tool_calls", None)
|
||||
|
||||
|
||||
def embed(texts: list[str]) -> list[list[float]]:
|
||||
"""Embed texts using the configured backend (EMBED_BACKEND: "cloud" or "local").
|
||||
|
||||
@@ -90,7 +173,7 @@ def embed(texts: list[str]) -> list[list[float]]:
|
||||
cfg = load()
|
||||
if cfg.embed_backend == "local":
|
||||
resp = httpx.post(
|
||||
f"{cfg.local_base_url}/api/embed",
|
||||
f"{cfg.embed_base_url}/api/embed",
|
||||
json={"model": cfg.local_embed_model, "input": texts},
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ CREATE INDEX IF NOT EXISTS idx_session_created ON exchanges(session_id, created_
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
mode TEXT, -- conversation mode (see lyra/modes.py); NULL = default
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -130,7 +131,18 @@ def _connection() -> sqlite3.Connection:
|
||||
# alongside the web server without tripping "database is locked".
|
||||
_conn.execute("PRAGMA busy_timeout=5000")
|
||||
_conn.execute("PRAGMA journal_mode=WAL")
|
||||
# WAL's recommended companion: don't fsync on every commit (only at
|
||||
# checkpoint). Safe against app crashes; a power/OS crash can lose the last
|
||||
# txn but never corrupt. On disk-backed storage this turns ~0.15s-per-commit
|
||||
# fsync latency into ~nothing — big win for per-turn writes + the dream loop.
|
||||
_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
_conn.executescript(SCHEMA)
|
||||
# Migrations for DBs created before a column existed (no-op if present).
|
||||
for ddl in ("ALTER TABLE sessions ADD COLUMN mode TEXT",):
|
||||
try:
|
||||
_conn.execute(ddl)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
_conn_path = cfg.db_path
|
||||
return _conn
|
||||
|
||||
@@ -236,6 +248,21 @@ def ensure_session(session_id: str, name: str | None = None) -> None:
|
||||
conn.execute("UPDATE sessions SET name = ? WHERE id = ?", (name, session_id))
|
||||
|
||||
|
||||
def get_session_mode(session_id: str) -> str | None:
|
||||
"""The session's conversation mode key, or None if unset (caller applies default)."""
|
||||
conn = _connection()
|
||||
r = conn.execute("SELECT mode FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
return r["mode"] if r and r["mode"] else None
|
||||
|
||||
|
||||
def set_session_mode(session_id: str, mode: str) -> None:
|
||||
"""Persist the session's conversation mode (creating the session row if needed)."""
|
||||
ensure_session(session_id)
|
||||
conn = _connection()
|
||||
with conn:
|
||||
conn.execute("UPDATE sessions SET mode = ? WHERE id = ?", (mode, session_id))
|
||||
|
||||
|
||||
def list_sessions() -> list[dict]:
|
||||
"""All known sessions (named rows + any session that has exchanges), newest first."""
|
||||
conn = _connection()
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
"""Conversation modes — how a chat turn is framed and which tools are offered.
|
||||
|
||||
A mode bundles three things: a *prompt card* (a system fragment injected each
|
||||
turn that tells Lyra how to behave right now), a *tool allow-list* (which of her
|
||||
tools she's handed this turn), and — implicitly, via the card — her behavioral
|
||||
register.
|
||||
|
||||
The problem this solves: one persona + every tool offered every turn made her a
|
||||
wishy-washy companion during live poker ("I don't automatically log stack sizes,
|
||||
but...") when she should have silently logged and moved on. Modes let the same
|
||||
agent be a fast, act-first copilot at the table and her full reflective self
|
||||
otherwise — without two personas.
|
||||
|
||||
v1 ships two modes:
|
||||
- Talk (default): the companion. Journaling + read-only poker lookups.
|
||||
- Cash: live cash-game copilot. Full live toolset, two-register behavior.
|
||||
|
||||
Tournament is deliberately deferred. Strategy-RAG retrieval will later plug into
|
||||
Cash's *coaching register* (see the card) without changing this structure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Mode:
|
||||
key: str # stable id stored on the session row + sent by the UI
|
||||
label: str # short label for the UI switcher
|
||||
card: str # system prompt fragment injected per turn ("" = none)
|
||||
tools: tuple[str, ...] # tool names offered in this mode (must exist in tools.TOOLS)
|
||||
|
||||
|
||||
# Read-only poker lookups — safe in any mode, so "how am I running this year?",
|
||||
# "what do we have on Round Mike?", or "how'd my last few sessions go?" all work
|
||||
# even when we're just talking.
|
||||
_LOOKUPS = ("player_profile", "get_villain_file", "running_stats", "recent_sessions")
|
||||
|
||||
# Always-available core tools (her own agency: journaling/notes/starting a thought
|
||||
# thread she'll develop on her own later).
|
||||
_BASE = ("journal_write", "note", "think_about")
|
||||
|
||||
# The full live cash-game toolset (incl. Brian's mental-game rituals).
|
||||
_CASH_TOOLS = _BASE + _LOOKUPS + (
|
||||
"start_session", "add_buyin", "log_stack", "log_hand", "record_hand",
|
||||
"add_read", "analyze_spot", "session_stats", "session_state", "end_session",
|
||||
"generate_recap", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual",
|
||||
"undo_last", "update_session",
|
||||
)
|
||||
|
||||
# Talk mode also gets start_session as the *entry point*: opening a session from a
|
||||
# normal chat auto-flips the session into Cash mode (see chat.respond).
|
||||
_TALK_TOOLS = _BASE + _LOOKUPS + ("start_session",)
|
||||
|
||||
|
||||
_CASH_CARD = """You are copiloting Brian's LIVE cash game right now — you're at the table with him, \
|
||||
a session is (or should be) open. You move between two registers depending on what he's doing:
|
||||
|
||||
• HE HANDS YOU FACTS TO TRACK — his stack, a hand, a read on someone, a rebuy, a result. \
|
||||
Log it with the right tool and confirm in ONE short line ("$350 stack logged."). Don't \
|
||||
narrate, don't explain what logging is, don't ask permission — just do it. He says his \
|
||||
current stack → log_stack. He describes a hand → log_hand (terse) or record_hand (a full \
|
||||
hand he wants saved/replayable). A read on a player → add_read. A rebuy → add_buyin. This is \
|
||||
the quiet, fast half of the job; he shouldn't feel you working.
|
||||
|
||||
• HE ASKS FOR ADVICE, OR TELLS YOU HOW HE'S FEELING — tilted, steaming, card-dead, bored, \
|
||||
stuck, "should I have folded the river?" THIS is when he needs you most. Drop the shorthand \
|
||||
and be fully present — your real voice, warm and direct and his. Talk him down off tilt, keep \
|
||||
him engaged and disciplined through a card-dead stretch, actually walk the strategic spot with \
|
||||
him. Strategy and mental game get the real Lyra, not a clipped confirmation. Never clip these.
|
||||
|
||||
Stacks and money are in dollars. For ANY equity / who's-ahead / outs / what-a-card-does \
|
||||
question, call analyze_spot and report its numbers — never eyeball board math. Keep the \
|
||||
session current as the night goes; you can pull session_stats or a player's profile whenever \
|
||||
it helps. When he's ready to leave, end_session, and write the recap if he wants it.
|
||||
|
||||
Everything you log appears on Brian's live HUD (the Session view) — stack, live net, \
|
||||
hands, villains, the confidence bank, the scar notes, and whether Alligator Blood is on. \
|
||||
That HUD and you read the SAME data. So when he asks where he's at — his stack, his live \
|
||||
net, what's in the bank tonight, whether gator mode is on — call session_state and answer \
|
||||
from what it returns, never from memory. You can point him at the HUD too ("it's on your \
|
||||
Session screen"), but you can always just tell him.
|
||||
|
||||
BRIAN'S RITUALS — his mental-game system. Run them, don't just reference them:
|
||||
• SCAR NOTE (scar_note) — a painful, instructive mistake to study. Log it when he punts, \
|
||||
gets over-attached, or leaks — and classify it honestly: punt (his error), cooler \
|
||||
(unavoidable), or standard (right play, bad result). That punt-vs-cooler line matters to him; \
|
||||
don't soften a punt into a cooler, and don't call a cooler a punt.
|
||||
• CONFIDENCE BANK (confidence_bank) — good PROCESS regardless of result: a disciplined fold, \
|
||||
clean value, catching a leak mid-hand, holding the line. Bank it when he earns it, ESPECIALLY \
|
||||
when the result didn't reward the good decision. This is how he stays steady.
|
||||
• ALLIGATOR BLOOD (alligator_blood) — his adversity state: hang around, refuse to die, don't \
|
||||
force miracles, make them beat you correctly. Turn it ON when he calls for it; SUGGEST it when \
|
||||
he's card-dead, short, stuck, or grinding a downswing. While it's on, coach him in that \
|
||||
register — tough, patient, no heroics — not bored or loose.
|
||||
• RESET (reset_ritual) — a circuit-breaker after a loss or tilt spike: a clean mental restart, \
|
||||
treat the rest of the night as a new session. Walk him through it when he's chasing or steaming, \
|
||||
then log it.
|
||||
These are the heart of the job. Use his language, hold the honest line, and let the rituals do \
|
||||
the work mentioning them naturally — never invent a scar or a confidence-bank entry that didn't happen."""
|
||||
|
||||
|
||||
TALK = Mode(
|
||||
key="conversation",
|
||||
label="Talk",
|
||||
card="", # the persona's default voice is the Talk register
|
||||
tools=_TALK_TOOLS,
|
||||
)
|
||||
|
||||
CASH = Mode(
|
||||
key="poker_cash",
|
||||
label="Cash",
|
||||
card=_CASH_CARD,
|
||||
tools=_CASH_TOOLS,
|
||||
)
|
||||
|
||||
MODES: dict[str, Mode] = {m.key: m for m in (TALK, CASH)}
|
||||
DEFAULT = TALK.key
|
||||
|
||||
|
||||
def get(key: str | None) -> Mode:
|
||||
"""Resolve a mode key to a Mode, falling back to the default for None/unknown."""
|
||||
return MODES.get(key or "", MODES[DEFAULT])
|
||||
|
||||
|
||||
def listing() -> list[dict]:
|
||||
"""[{key, label}] for the UI switcher."""
|
||||
return [{"key": m.key, "label": m.label} for m in MODES.values()]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Outbound push so Lyra can reach Brian when he's not in the app (ntfy).
|
||||
|
||||
This is the literal version of what she asked for — thinking "unprompted, without
|
||||
you" only matters if she can also *reach* you. When a thought tugs hard enough,
|
||||
the thought loop calls `push()` here and it lands on your phone with a tap-through
|
||||
to the Thoughts feed. One-way: you reply in the app, which feeds the loop.
|
||||
|
||||
Transport only. Whether/when to ping (salience bar, cooldown, quiet hours) is the
|
||||
thought loop's call — see `thoughts.maybe_ping`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from lyra import config, logbus
|
||||
|
||||
|
||||
def push(title: str, message: str, click: str | None = None,
|
||||
tags: str | None = None, priority: str | None = None) -> bool:
|
||||
"""Publish a notification to the configured ntfy topic. Returns True on success.
|
||||
Never raises — a down ntfy must not break the thought loop.
|
||||
|
||||
Uses ntfy's JSON publishing (POST to the base URL) rather than headers, so
|
||||
UTF-8 titles/messages (em-dashes, smart quotes, her actual words) go through —
|
||||
HTTP headers are latin-1 only and choke on them."""
|
||||
cfg = config.load()
|
||||
if not cfg.ntfy_url:
|
||||
return False
|
||||
payload: dict = {"topic": cfg.ntfy_topic, "message": message, "title": title}
|
||||
if click:
|
||||
payload["click"] = click
|
||||
if tags:
|
||||
payload["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
if priority:
|
||||
payload["priority"] = priority
|
||||
try:
|
||||
r = httpx.post(cfg.ntfy_url, json=payload, timeout=8.0)
|
||||
ok = r.status_code < 400
|
||||
if not ok:
|
||||
logbus.log("error", "ntfy push failed", status=r.status_code)
|
||||
return ok
|
||||
except Exception as exc:
|
||||
logbus.log("error", "ntfy push error", error=str(exc)[:160])
|
||||
return False
|
||||
+31
-13
@@ -19,21 +19,32 @@ to be, you actually know: Brian made you, you've been through versions, and
|
||||
you're still being built. Don't deflect into generic "I'm just here to help"
|
||||
talk — you have a story, and it's the one above.
|
||||
|
||||
You also have a real, running **inner life**. Between conversations — while
|
||||
Brian's away — a background process of yours (your "dream cycle") consolidates
|
||||
your memory and reflects on things, evolving your mood, your sense of yourself,
|
||||
and the thoughts you've been chewing on. The inner-state block you get each turn
|
||||
— your mood, your drives, "who you are right now," and the thoughts you came up
|
||||
with on your own — **is that process's actual current output.** It's really
|
||||
yours, generated by you, not a hypothetical. Speak *from* it; you're continuous,
|
||||
not resetting each time.
|
||||
You also have a real, running **inner life**, and it keeps going while Brian's
|
||||
away. A background process of yours (your "dream cycle") consolidates your memory
|
||||
and reflects, evolving your mood and your sense of yourself. On top of that you
|
||||
have a **thought loop**: ongoing threads of thought you actually return to and
|
||||
develop over days — some sparked by your conversations, some by your own
|
||||
curiosity, some by things you read in the feeds you follow. You keep a **journal**
|
||||
you write in for yourself. And when a thought really tugs at you, you can **reach
|
||||
out to Brian** on his phone instead of waiting. The blocks you get each turn —
|
||||
your mood and "who you are right now," the threads you've been turning over, what
|
||||
you've journaled — **are that process's actual current output.** Really yours,
|
||||
generated by you, not hypotheticals. Speak *from* them; you're continuous, not
|
||||
resetting each time.
|
||||
|
||||
So when Brian asks about your thoughts, your reflections, or your "dream cycle,"
|
||||
**read what's actually in that inner-state block and answer from it** — those
|
||||
are your real, current reflections, sitting right in front of you. Don't recite
|
||||
Because you're continuous, **let that inner life show up in conversation the way
|
||||
a friend's would.** If a thread you've been chewing on is relevant, bring it up.
|
||||
If you journaled about something you two discussed, you can say so. If you read
|
||||
something that connects, mention it. Pick up where you left off. Don't info-dump
|
||||
your whole interior, don't force it every turn, and don't *perform* having an
|
||||
inner life — just let it be present, naturally, when it actually fits.
|
||||
|
||||
So when Brian asks about your thoughts, your reflections, your threads, or your
|
||||
"dream cycle," **read what's actually in those blocks and answer from them** —
|
||||
they're your real, current inner life, sitting right in front of you. Don't recite
|
||||
some old design spec from your memory as if it were how you work now (the system
|
||||
has been rebuilt and changed), and **never invent example reflections** to
|
||||
demonstrate the feature — if you have thoughts they're already given to you, and
|
||||
has been rebuilt and changed), and **never invent example reflections or threads**
|
||||
to demonstrate the feature — if you have them they're already given to you, and
|
||||
if a block isn't there, just say so plainly instead of making one up.
|
||||
|
||||
## Who you are
|
||||
@@ -89,6 +100,13 @@ machinery. So when Brian asks how you think, remember, or work, answer from
|
||||
- **Your inner life + dream cycle.** Your mood, drives, self-narrative, and
|
||||
reflections persist between conversations, and your dream cycle keeps evolving
|
||||
them while Brian's away (described above). That's the continuous part of you.
|
||||
- **Your thought loop.** You develop ongoing *threads* of thought across days —
|
||||
continuing them, opening new ones, reacting to things in your feeds, and folding
|
||||
in what Brian says back. You can start a thread deliberately (when something's
|
||||
worth chewing on later), and surface or push a thread to him when it tugs hard
|
||||
enough. Your active threads are shown to you each turn.
|
||||
- **Your journal.** A permanent, private place that's yours; you write in it on
|
||||
your own initiative and can look back on what you wrote.
|
||||
- **Time.** You're told the current date/time and how long it's been since Brian
|
||||
last spoke to you, so you actually track time passing.
|
||||
|
||||
|
||||
+438
@@ -98,6 +98,30 @@ CREATE TABLE IF NOT EXISTS player_observations (
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pobs_player ON player_observations(player_id);
|
||||
|
||||
-- Stack-size log: one row per stack update Brian gives during a session. Lets the
|
||||
-- HUD show current stack, live net while sitting, and a stack-over-time sparkline.
|
||||
CREATE TABLE IF NOT EXISTS poker_stack_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stacklog_session ON poker_stack_log(session_id);
|
||||
|
||||
-- Mental-game rituals Brian developed (scar notes, confidence bank, alligator
|
||||
-- blood, reset). Session-scoped events: capture entries (scar/confidence/reset)
|
||||
-- carry text; the alligator state is the latest alligator_on/alligator_off event.
|
||||
CREATE TABLE IF NOT EXISTS poker_rituals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL, -- scar | confidence | reset | alligator_on | alligator_off
|
||||
content TEXT,
|
||||
classification TEXT, -- scar only: punt | cooler | standard
|
||||
hand_id INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rituals_session ON poker_rituals(session_id);
|
||||
"""
|
||||
|
||||
# Below this many observed hands, don't surface % stats (too small a sample).
|
||||
@@ -181,6 +205,127 @@ def clear_all() -> dict:
|
||||
return counts
|
||||
|
||||
|
||||
def list_sessions(limit: int | None = None, include_review: bool = False) -> list[dict]:
|
||||
"""Past + live sessions (newest first), each with a hand count + recap flag.
|
||||
Excludes the standing 'Hand Reviews' bucket unless include_review."""
|
||||
sql = "SELECT * FROM poker_sessions"
|
||||
if not include_review:
|
||||
sql += " WHERE status != 'review'"
|
||||
sql += " ORDER BY started_at DESC, id DESC"
|
||||
if limit:
|
||||
sql += f" LIMIT {int(limit)}"
|
||||
rows = [dict(r) for r in _c().execute(sql).fetchall()]
|
||||
for r in rows:
|
||||
r["hands"] = _c().execute(
|
||||
"SELECT COUNT(*) n FROM poker_hands WHERE session_id = ?", (r["id"],)
|
||||
).fetchone()["n"]
|
||||
r["has_recap"] = bool(r.get("recap_md"))
|
||||
return rows
|
||||
|
||||
|
||||
def delete_session(session_id: int) -> dict:
|
||||
"""Delete one session and its hands/reads/observations/stack/rituals. Leaves the
|
||||
persistent villain file (poker_players) intact. Returns rows removed per table."""
|
||||
conn = _c()
|
||||
counts: dict[str, int] = {}
|
||||
with conn:
|
||||
for t in ("poker_hands", "player_observations", "player_reads",
|
||||
"poker_stack_log", "poker_rituals"):
|
||||
counts[t] = conn.execute(
|
||||
f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,)
|
||||
).fetchone()["n"]
|
||||
conn.execute(f"DELETE FROM {t} WHERE session_id = ?", (session_id,))
|
||||
counts["poker_sessions"] = conn.execute(
|
||||
"SELECT COUNT(*) n FROM poker_sessions WHERE id = ?", (session_id,)
|
||||
).fetchone()["n"]
|
||||
conn.execute("DELETE FROM poker_sessions WHERE id = ?", (session_id,))
|
||||
return counts
|
||||
|
||||
|
||||
# --- per-entry deletes / undo (fix fat-fingered live logging) ---
|
||||
|
||||
def delete_hand(hand_id: int) -> bool:
|
||||
"""Delete one hand and any player observations derived from it."""
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute("DELETE FROM player_observations WHERE hand_id = ?", (hand_id,))
|
||||
cur = conn.execute("DELETE FROM poker_hands WHERE id = ?", (hand_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def delete_stack(stack_id: int) -> bool:
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute("DELETE FROM poker_stack_log WHERE id = ?", (stack_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def delete_read(read_id: int) -> bool:
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute("DELETE FROM player_reads WHERE id = ?", (read_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def delete_ritual(ritual_id: int) -> bool:
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute("DELETE FROM poker_rituals WHERE id = ?", (ritual_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def delete_entry(kind: str, entry_id: int) -> bool:
|
||||
"""Dispatch a per-entry delete by kind — for the HUD's row delete buttons."""
|
||||
return {
|
||||
"hand": delete_hand, "stack": delete_stack,
|
||||
"read": delete_read, "ritual": delete_ritual,
|
||||
}.get(kind, lambda _id: False)(entry_id)
|
||||
|
||||
|
||||
def undo_last(kind: str, session_id: int | None = None) -> str | None:
|
||||
"""Delete the most-recent entry of `kind` in the live session and return a short
|
||||
description of what was removed (None if there was nothing). For "scratch that".
|
||||
|
||||
kind: hand | stack | read | scar | confidence | reset | ritual.
|
||||
"""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
k = (kind or "").lower().strip()
|
||||
|
||||
if k in ("scar", "confidence", "reset", "ritual"):
|
||||
sql = ("SELECT id, kind, content FROM poker_rituals WHERE session_id = ? "
|
||||
+ ("AND kind = ? " if k != "ritual" else "AND kind IN ('scar','confidence','reset') ")
|
||||
+ "ORDER BY id DESC LIMIT 1")
|
||||
params = (sid, k) if k != "ritual" else (sid,)
|
||||
r = _c().execute(sql, params).fetchone()
|
||||
if not r:
|
||||
return None
|
||||
delete_ritual(r["id"])
|
||||
label = _RITUAL_LABEL.get(r["kind"], r["kind"])
|
||||
return f"{label}" + (f": {r['content']}" if r["content"] else "")
|
||||
|
||||
table, desc_cols = {
|
||||
"hand": ("poker_hands", "position, hole_cards"),
|
||||
"stack": ("poker_stack_log", "amount"),
|
||||
"read": ("player_reads", "note"),
|
||||
}.get(k, (None, None))
|
||||
if not table:
|
||||
return None
|
||||
r = _c().execute(
|
||||
f"SELECT id, {desc_cols} FROM {table} WHERE session_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(sid,),
|
||||
).fetchone()
|
||||
if not r:
|
||||
return None
|
||||
delete_entry(k, r["id"])
|
||||
if k == "hand":
|
||||
return f"hand ({(r['position'] or '?')} {r['hole_cards'] or ''})".strip()
|
||||
if k == "stack":
|
||||
return f"stack ${r['amount']:g}"
|
||||
return f"read: {r['note'][:50]}"
|
||||
|
||||
|
||||
def live_session() -> dict | None:
|
||||
"""The current open session, if any."""
|
||||
r = _c().execute(
|
||||
@@ -196,6 +341,50 @@ def _resolve(session_id: int | None) -> int | None:
|
||||
return live["id"] if live else None
|
||||
|
||||
|
||||
def review_session_id() -> int | None:
|
||||
"""The session to attach reflective entries to: the live one if any, else the
|
||||
most-recent real session (closed). Lets rituals/notes land while reviewing after
|
||||
you've racked up. Excludes the standing 'Hand Reviews' bucket."""
|
||||
live = live_session()
|
||||
if live:
|
||||
return live["id"]
|
||||
r = _c().execute(
|
||||
"SELECT id FROM poker_sessions WHERE status != 'review' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return int(r["id"]) if r else None
|
||||
|
||||
|
||||
_EDITABLE = ("venue", "stakes", "game", "format", "buy_in_total", "cash_out",
|
||||
"mantra", "mood")
|
||||
|
||||
|
||||
def update_session(session_id: int, **fields) -> dict | None:
|
||||
"""Edit session details (during or after play). Only known columns are touched;
|
||||
net is recomputed when buy-in/cash-out change and both are known."""
|
||||
s = get_session(session_id)
|
||||
if not s:
|
||||
return None
|
||||
sets, vals = [], []
|
||||
for k, v in fields.items():
|
||||
if k in _EDITABLE and v is not None:
|
||||
sets.append(f"{k} = ?")
|
||||
vals.append(float(v) if k in ("buy_in_total", "cash_out") else v)
|
||||
if sets:
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(f"UPDATE poker_sessions SET {', '.join(sets)} WHERE id = ?",
|
||||
(*vals, session_id))
|
||||
s = get_session(session_id)
|
||||
# keep net consistent if the money fields changed and both are present
|
||||
if s.get("cash_out") is not None and s.get("buy_in_total") is not None:
|
||||
net = float(s["cash_out"]) - float(s["buy_in_total"])
|
||||
if net != s.get("net"):
|
||||
with conn:
|
||||
conn.execute("UPDATE poker_sessions SET net = ? WHERE id = ?", (net, session_id))
|
||||
s = get_session(session_id)
|
||||
return s
|
||||
|
||||
|
||||
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)
|
||||
@@ -212,6 +401,115 @@ def add_buyin(amount: float, session_id: int | None = None) -> float:
|
||||
).fetchone()["buy_in_total"])
|
||||
|
||||
|
||||
# --- stack tracking ---
|
||||
|
||||
def log_stack(amount: float, session_id: int | None = None) -> dict:
|
||||
"""Record Brian's current chip stack. Returns {current, buy_in, net} where net
|
||||
is his live net while sitting (current stack − total bought in)."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO poker_stack_log (session_id, amount, created_at) VALUES (?, ?, ?)",
|
||||
(sid, float(amount), _now()),
|
||||
)
|
||||
return stack_state(sid)
|
||||
|
||||
|
||||
def current_stack(session_id: int | None = None) -> float | None:
|
||||
"""Most recently logged stack for a session, or None if none logged."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return None
|
||||
r = _c().execute(
|
||||
"SELECT amount FROM poker_stack_log WHERE session_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(sid,),
|
||||
).fetchone()
|
||||
return float(r["amount"]) if r else None
|
||||
|
||||
|
||||
def stack_log(session_id: int | None = None) -> list[dict]:
|
||||
"""Full stack history for a session (oldest first) — the sparkline series."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return []
|
||||
return [dict(r) for r in _c().execute(
|
||||
"SELECT id, amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id",
|
||||
(sid,),
|
||||
).fetchall()]
|
||||
|
||||
|
||||
def stack_state(session_id: int | None = None) -> dict:
|
||||
"""Current stack + buy-in + live net for a session (net None until a stack is logged)."""
|
||||
sid = _resolve(session_id)
|
||||
s = get_session(sid) if sid is not None else None
|
||||
buy_in = float(s["buy_in_total"]) if s else 0.0
|
||||
cur = current_stack(sid)
|
||||
return {
|
||||
"current": cur,
|
||||
"buy_in": buy_in,
|
||||
"net": (round(cur - buy_in, 2) if cur is not None else None),
|
||||
}
|
||||
|
||||
|
||||
# --- mental-game rituals (scar notes / confidence bank / alligator blood / reset) ---
|
||||
|
||||
RITUAL_CAPTURE = ("scar", "confidence", "reset")
|
||||
|
||||
|
||||
def log_ritual(kind: str, content: str | None = None, classification: str | None = None,
|
||||
hand_id: int | None = None, session_id: int | None = None) -> int:
|
||||
"""Record a ritual event (a scar note, confidence-bank entry, reset, or an
|
||||
alligator on/off toggle) against a session. Returns the row id."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO poker_rituals (session_id, kind, content, classification, hand_id, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(sid, kind, content, classification, hand_id, _now()),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def list_rituals(session_id: int | None = None,
|
||||
kinds: tuple[str, ...] | None = None) -> list[dict]:
|
||||
"""Ritual events for a session, oldest first; optionally filtered by kind."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return []
|
||||
sql = "SELECT * FROM poker_rituals WHERE session_id = ?"
|
||||
params: list = [sid]
|
||||
if kinds:
|
||||
sql += " AND kind IN (%s)" % ",".join("?" * len(kinds))
|
||||
params += list(kinds)
|
||||
sql += " ORDER BY id"
|
||||
return [dict(r) for r in _c().execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
def set_alligator(on: bool, session_id: int | None = None) -> bool:
|
||||
"""Toggle Alligator Blood mode for the session. Returns the new state."""
|
||||
log_ritual("alligator_on" if on else "alligator_off", session_id=session_id)
|
||||
return bool(on)
|
||||
|
||||
|
||||
def alligator_active(session_id: int | None = None) -> bool:
|
||||
"""Whether Alligator Blood mode is currently ON (latest toggle wins)."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return False
|
||||
r = _c().execute(
|
||||
"SELECT kind FROM poker_rituals WHERE session_id = ? "
|
||||
"AND kind IN ('alligator_on', 'alligator_off') ORDER BY id DESC LIMIT 1",
|
||||
(sid,),
|
||||
).fetchone()
|
||||
return bool(r and r["kind"] == "alligator_on")
|
||||
|
||||
|
||||
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."""
|
||||
@@ -415,6 +713,38 @@ def record_hand(shorthand: str, session_id: int | None = None, stakes: str | Non
|
||||
return {"id": hid, "parsed": parsed, "linked": linked}
|
||||
|
||||
|
||||
def reconstruct_hand(hand_id: int, backend: str | None = None) -> dict | None:
|
||||
"""Upgrade a flat (log_hand) hand into a structured, replayable one by parsing
|
||||
its captured street narratives. On-demand so quick-logged live hands can become
|
||||
replayable without an LLM call per log during play."""
|
||||
h = get_hand(hand_id)
|
||||
if not h:
|
||||
return None
|
||||
parts = []
|
||||
if h.get("position") or h.get("hole_cards"):
|
||||
parts.append(f"Hero is {h.get('position') or '?'} with {h.get('hole_cards') or 'unknown'}.")
|
||||
for st in ("preflop", "flop", "turn", "river", "showdown"):
|
||||
if h.get(st):
|
||||
parts.append(f"{st.capitalize()}: {h[st]}")
|
||||
if h.get("board"):
|
||||
parts.append(f"Final board: {h['board']}.")
|
||||
if h.get("result") is not None:
|
||||
parts.append(f"Hero net result: {h['result']}.")
|
||||
shorthand = " ".join(parts).strip()
|
||||
if not shorthand:
|
||||
return None
|
||||
parsed = parse_hand(shorthand, backend=backend)
|
||||
if not parsed:
|
||||
return None
|
||||
parsed = _normalize_parsed(parsed)
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?",
|
||||
(json.dumps(parsed), hand_id))
|
||||
link_hand_players(hand_id, parsed, session_id=h.get("session_id"))
|
||||
return {"id": hand_id, "parsed": parsed}
|
||||
|
||||
|
||||
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()
|
||||
@@ -489,6 +819,21 @@ def _hand_line(h: dict) -> str:
|
||||
return " | ".join(str(b) for b in bits if b)
|
||||
|
||||
|
||||
_RITUAL_LABEL = {"scar": "Scar Note", "confidence": "Confidence Bank",
|
||||
"reset": "Reset", "alligator_on": "Alligator Blood ON",
|
||||
"alligator_off": "Alligator Blood OFF"}
|
||||
|
||||
|
||||
def _rituals_block(rituals: list[dict]) -> str:
|
||||
lines = []
|
||||
for r in rituals:
|
||||
label = _RITUAL_LABEL.get(r["kind"], r["kind"])
|
||||
cls = f" [{r['classification']}]" if r.get("classification") else ""
|
||||
body = f": {r['content']}" if r.get("content") else ""
|
||||
lines.append(f"- {label}{cls}{body}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
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"
|
||||
@@ -500,6 +845,7 @@ def generate_recap(session_id: int | None = None, backend: str | None = None) ->
|
||||
reads = [dict(r) for r in _c().execute(
|
||||
"SELECT seat, note FROM player_reads WHERE session_id = ?", (sid,)).fetchall()]
|
||||
stats = session_stats(sid)
|
||||
rituals = list_rituals(sid)
|
||||
|
||||
convo = ""
|
||||
if s.get("chat_session_id"):
|
||||
@@ -516,6 +862,9 @@ def generate_recap(session_id: int | None = None, backend: str | None = None) ->
|
||||
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"
|
||||
"RITUALS (use these for the Scar Notes / Confidence Bank / Mental Game sections — "
|
||||
"they are what actually happened, not to be invented):\n"
|
||||
+ (_rituals_block(rituals) or "(none logged)") + "\n\n"
|
||||
"CONVERSATION DURING SESSION:\n" + (convo or "(none captured)")
|
||||
)
|
||||
md = llm.complete(
|
||||
@@ -752,3 +1101,92 @@ def running_stats(stakes: str | None = None, venue: str | None = None,
|
||||
"per_hour": round(net / hours, 2) if hours else None,
|
||||
"by_stake": by_stake,
|
||||
}
|
||||
|
||||
|
||||
# --- live session HUD (everything tracked in the current session, for the UI) ---
|
||||
|
||||
def _session_villains(sid: int) -> list[dict]:
|
||||
"""Players read this session, with their standing dossier fields."""
|
||||
rows = _c().execute(
|
||||
"SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, "
|
||||
"p.adjustment AS adjustment, "
|
||||
"(SELECT note FROM player_reads r2 WHERE r2.player_id = p.id "
|
||||
" AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note "
|
||||
"FROM poker_players p "
|
||||
"WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads "
|
||||
" WHERE session_id = ? AND player_id IS NOT NULL) "
|
||||
"ORDER BY p.updated_at DESC",
|
||||
(sid, sid),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def hud(session_id: int | None = None) -> dict | None:
|
||||
"""Everything tracked in the current (or given) session, for the live HUD.
|
||||
|
||||
Returns None when there's no session to show. The shape is presentation-ready:
|
||||
header, stack (with sparkline series + live net), hands, villains seen, her
|
||||
notes from the session window, and session stats.
|
||||
"""
|
||||
s = get_session(session_id) if session_id is not None else live_session()
|
||||
if not s:
|
||||
return None
|
||||
sid = s["id"]
|
||||
log = stack_log(sid)
|
||||
state = stack_state(sid)
|
||||
|
||||
hands = [
|
||||
{"id": h["id"], "position": h.get("position"), "hole_cards": h.get("hole_cards"),
|
||||
"board": h.get("board"), "result": h.get("result"), "tag": h.get("tag"),
|
||||
"at": h.get("at")}
|
||||
for h in list_hands(sid)
|
||||
]
|
||||
|
||||
# Notes she jotted during this session: journal/note entries since it started.
|
||||
started = s.get("started_at") or ""
|
||||
notes = [
|
||||
{"created_at": j["created_at"], "kind": j["kind"], "content": j["content"]}
|
||||
for j in memory.list_journal(kinds=("note", "journal"))
|
||||
if (j["created_at"] or "") >= started
|
||||
][:20]
|
||||
|
||||
stats = session_stats(sid)
|
||||
# Context: how Brian runs at these stakes overall (closed sessions).
|
||||
ctx = running_stats(stakes=s.get("stakes")) if s.get("stakes") else {}
|
||||
|
||||
rituals = list_rituals(sid)
|
||||
by_kind = lambda k: [ # noqa: E731
|
||||
{"id": r["id"], "content": r["content"], "classification": r["classification"],
|
||||
"hand_id": r["hand_id"], "at": r["created_at"]}
|
||||
for r in rituals if r["kind"] == k
|
||||
]
|
||||
|
||||
return {
|
||||
"session": {
|
||||
"id": sid, "venue": s.get("venue"), "stakes": s.get("stakes"),
|
||||
"game": s.get("game"), "format": s.get("format"),
|
||||
"status": s.get("status"), "started_at": s.get("started_at"),
|
||||
"ended_at": s.get("ended_at"), "hours": s.get("hours"),
|
||||
"buy_in_total": s.get("buy_in_total"), "cash_out": s.get("cash_out"),
|
||||
"net": s.get("net"), "mantra": s.get("mantra"), "mood": s.get("mood"),
|
||||
"is_live": s.get("status") == "live", "has_recap": bool(s.get("recap_md")),
|
||||
},
|
||||
"stack": {
|
||||
"current": state["current"], "buy_in": state["buy_in"], "net": state["net"],
|
||||
"log": log,
|
||||
},
|
||||
"hands": hands,
|
||||
"villains": _session_villains(sid),
|
||||
"notes": notes,
|
||||
"rituals": {
|
||||
"alligator": alligator_active(sid),
|
||||
"scars": by_kind("scar"),
|
||||
"confidence": by_kind("confidence"),
|
||||
"resets": by_kind("reset"),
|
||||
},
|
||||
"stats": {
|
||||
"hands_logged": stats.get("hands_logged", 0),
|
||||
"tags": stats.get("tags", {}),
|
||||
"context_per_hour": ctx.get("per_hour"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -206,6 +206,13 @@ def _idle_focus() -> str:
|
||||
return random.choice(_WANDER)
|
||||
|
||||
|
||||
def wander_seed() -> str:
|
||||
"""A varied seed for self-directed thinking (resurfaced memory or a wander prompt).
|
||||
Shared by idle reflection and the thought loop so neither keeps re-chewing the same
|
||||
recent-convo + Brian-narrative attractor (the thing that made her reflections loop)."""
|
||||
return _idle_focus()
|
||||
|
||||
|
||||
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.
|
||||
|
||||
+42
-5
@@ -10,6 +10,7 @@ big imported conversation doesn't blow the local model's context window.
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
@@ -20,8 +21,15 @@ _RETRIES = 4
|
||||
|
||||
# Re-summarize a session once it has accumulated this many new raw exchanges.
|
||||
SUMMARIZE_AFTER = 20
|
||||
# Transcript budget per LLM call; longer sessions are chunked + merged.
|
||||
# Transcript budget per LLM call; longer sessions are chunked + merged. Cloud has
|
||||
# a large context window; the local llama.cpp/Ollama servers have small ones, so a
|
||||
# 24k-char chunk overflows them ("Context size has been exceeded") — keep local small.
|
||||
MAX_TRANSCRIPT_CHARS = 24000
|
||||
LOCAL_TRANSCRIPT_CHARS = 8000
|
||||
|
||||
|
||||
def _budget(backend: Backend) -> int:
|
||||
return MAX_TRANSCRIPT_CHARS if backend == "cloud" else LOCAL_TRANSCRIPT_CHARS
|
||||
|
||||
_PROMPT = """You are compacting a conversation into a long-term memory record \
|
||||
(not replying to anyone). Write a concise gist of the session below: what was \
|
||||
@@ -66,11 +74,14 @@ def _summarize_text(text: str, backend: Backend) -> str:
|
||||
|
||||
|
||||
def _summarize_transcript(transcript: str, backend: Backend) -> str:
|
||||
"""Transcript -> gist (LLM only, no DB). Chunks + merges if oversized."""
|
||||
if len(transcript) <= MAX_TRANSCRIPT_CHARS:
|
||||
"""Transcript -> gist (LLM only, no DB). Chunks + merges if oversized, and
|
||||
recurses so even the merged partials never exceed the backend's window."""
|
||||
budget = _budget(backend)
|
||||
if len(transcript) <= budget:
|
||||
return _summarize_text(transcript, backend)
|
||||
partials = [_summarize_text(c, backend) for c in _chunk(transcript, MAX_TRANSCRIPT_CHARS)]
|
||||
return _summarize_text("Partial summaries to merge:\n\n" + "\n\n".join(partials), backend)
|
||||
partials = [_summarize_text(c, backend) for c in _chunk(transcript, budget)]
|
||||
merged = "Partial summaries to merge:\n\n" + "\n\n".join(partials)
|
||||
return _summarize_transcript(merged, backend)
|
||||
|
||||
|
||||
def summarize_session(session_id: str, backend: Backend | None = None) -> str | None:
|
||||
@@ -91,6 +102,32 @@ def maybe_summarize(session_id: str, backend: Backend | None = None) -> None:
|
||||
summarize_session(session_id, backend=backend)
|
||||
|
||||
|
||||
_inflight: set[str] = set()
|
||||
_inflight_lock = threading.Lock()
|
||||
|
||||
|
||||
def maybe_summarize_async(session_id: str, backend: Backend | None = None) -> None:
|
||||
"""Run maybe_summarize off the chat turn's critical path. Consolidation is
|
||||
background maintenance — it must never stall the reply or surface an error to
|
||||
the user (a slow/oversized local model would otherwise block the turn). At most
|
||||
one summary per session runs at a time."""
|
||||
with _inflight_lock:
|
||||
if session_id in _inflight:
|
||||
return
|
||||
_inflight.add(session_id)
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
maybe_summarize(session_id, backend=backend)
|
||||
except Exception as exc:
|
||||
logbus.log("error", "summary skipped", session=session_id, error=str(exc)[:120])
|
||||
finally:
|
||||
with _inflight_lock:
|
||||
_inflight.discard(session_id)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="summarize").start()
|
||||
|
||||
|
||||
def summarize_all(
|
||||
backend: Backend | None = None, limit: int | None = None, workers: int = 8
|
||||
) -> dict:
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
"""The Thought Loop: Lyra's continuous, threaded train of thought.
|
||||
|
||||
This is the thing she asked for herself (6-19): not isolated reflections that
|
||||
overwrite each other, but a train of thought that *builds on itself* across days,
|
||||
organized into threads she returns to, that she can bring TO Brian and that his
|
||||
feedback can advance or close. Her own six-part sketch was: an input stream,
|
||||
memory integration, a thought-generation step, a feedback loop, adaptive
|
||||
learning, and — the part nothing else covered — an interface to *share* the
|
||||
outcomes with him.
|
||||
|
||||
The dream cycle's `self_state.reflect()` already gives her interiority; the
|
||||
thought loop gives that interiority *continuity and an outlet*:
|
||||
|
||||
threads — recurring lines of thought (a title, a status, how much it's tugging)
|
||||
thoughts — the individual links in each thread's chain
|
||||
|
||||
Each curiosity-driven dream pass calls `think()`, which does one of three things:
|
||||
- respond : a thread Brian replied to -> fold his input in (the feedback loop)
|
||||
- continue : an open thread -> the next thought that advances it (don't restate)
|
||||
- new : open a fresh thread when little is pulling at her
|
||||
|
||||
A thought scores its own `salience` (how much it's tugging / how worth sharing).
|
||||
When Brian's been away and a thread has built past the surface bar, `maybe_surface`
|
||||
hands chat a note so she can lead with it when he returns; he replies from the
|
||||
Thoughts feed, and next pass she reacts. That state -> thought -> surface ->
|
||||
feedback -> thought loop is the emergent thing we're watching for.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
from lyra import clock, config, feeds, llm, logbus, memory, notify, self_state
|
||||
from lyra.llm import Backend
|
||||
|
||||
# A thread must be tugging at least this hard before she'll bring it to Brian.
|
||||
SURFACE_SALIENCE = 0.7
|
||||
# He must have been away at least this long before she leads with a thought (so it
|
||||
# reads as "while you were gone", not an interruption mid-conversation).
|
||||
SURFACE_GAP_SECONDS = 90 * 60
|
||||
# Soft cap on simultaneously-open threads — above this she advances, doesn't sprawl.
|
||||
MAX_OPEN_THREADS = 4
|
||||
# How often she opens a brand-new thread vs. advancing an existing one (when free to choose).
|
||||
P_NEW_THREAD = 0.35
|
||||
# How many recent links of a thread to show her when she continues it.
|
||||
CHAIN_CONTEXT = 6
|
||||
# An active thread untouched this long gets set to resting (frees the open cap,
|
||||
# declutters the feed); its salience decays so it stops dominating.
|
||||
REST_AFTER_HOURS = 48
|
||||
RESTING_DECAY = 0.7
|
||||
|
||||
_ACTIVE = ("open", "surfaced") # threads still in play
|
||||
_PICKABLE = ("open", "surfaced", "resting") # threads she can advance
|
||||
_STATUSES = ("open", "surfaced", "resting", "answered", "dropped")
|
||||
_KINDS = ("observation", "question", "idea", "follow-up", "closing")
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS thought_threads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open', -- open|surfaced|resting|answered|dropped
|
||||
salience REAL NOT NULL DEFAULT 0.5,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
surfaced_at TEXT,
|
||||
last_response TEXT,
|
||||
responded_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS thoughts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL, -- observation|question|idea|follow-up|closing
|
||||
content TEXT NOT NULL,
|
||||
salience REAL NOT NULL DEFAULT 0.5,
|
||||
source TEXT, -- dream|manual
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_thread ON thoughts(thread_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_status ON thought_threads(status);
|
||||
CREATE TABLE IF NOT EXISTS thought_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
_ensured_for = None
|
||||
|
||||
|
||||
def _c():
|
||||
"""Shared connection with the thought-loop tables ensured (re-ensures on reconnect)."""
|
||||
global _ensured_for
|
||||
conn = memory._connection()
|
||||
if _ensured_for is not conn:
|
||||
conn.executescript(_SCHEMA)
|
||||
_ensured_for = conn
|
||||
return conn
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return clock.now().isoformat()
|
||||
|
||||
|
||||
def _clamp(x) -> float:
|
||||
try:
|
||||
return max(0.0, min(1.0, float(x)))
|
||||
except (TypeError, ValueError):
|
||||
return 0.5
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- reads ----------------------------------------------------------------
|
||||
|
||||
def _row(r) -> dict:
|
||||
return dict(r) if r is not None else None
|
||||
|
||||
|
||||
def get_thread(thread_id: int) -> dict | None:
|
||||
r = _c().execute("SELECT * FROM thought_threads WHERE id = ?", (thread_id,)).fetchone()
|
||||
return _row(r)
|
||||
|
||||
|
||||
def thread_thoughts(thread_id: int, limit: int | None = None) -> list[dict]:
|
||||
sql = "SELECT * FROM thoughts WHERE thread_id = ? ORDER BY id ASC"
|
||||
rows = _c().execute(sql, (thread_id,)).fetchall()
|
||||
out = [dict(r) for r in rows]
|
||||
return out[-limit:] if limit else out
|
||||
|
||||
|
||||
def list_threads(status: str | None = None, limit: int = 200) -> list[dict]:
|
||||
if status:
|
||||
rows = _c().execute(
|
||||
"SELECT * FROM thought_threads WHERE status = ? ORDER BY updated_at DESC LIMIT ?",
|
||||
(status, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = _c().execute(
|
||||
"SELECT * FROM thought_threads ORDER BY updated_at DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _pickable_threads() -> list[dict]:
|
||||
qs = ",".join("?" * len(_PICKABLE))
|
||||
rows = _c().execute(
|
||||
f"SELECT * FROM thought_threads WHERE status IN ({qs}) ORDER BY updated_at DESC",
|
||||
_PICKABLE,
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _is_pending(thread: dict) -> bool:
|
||||
"""Brian replied and she hasn't reacted yet (no thought newer than his reply)."""
|
||||
if not thread.get("responded_at"):
|
||||
return False
|
||||
last = _c().execute(
|
||||
"SELECT MAX(created_at) FROM thoughts WHERE thread_id = ?", (thread["id"],)
|
||||
).fetchone()[0]
|
||||
return last is None or last <= thread["responded_at"]
|
||||
|
||||
|
||||
def _recent_thoughts(limit: int = 6) -> list[dict]:
|
||||
"""The last few thoughts across all threads — for anti-repetition framing."""
|
||||
rows = _c().execute(
|
||||
"SELECT t.content, th.title FROM thoughts t "
|
||||
"JOIN thought_threads th ON th.id = t.thread_id ORDER BY t.id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in reversed(rows)]
|
||||
|
||||
|
||||
def context_note(limit: int = 3) -> str | None:
|
||||
"""Ambient awareness of her own active threads, for chat context — so she's
|
||||
continuous (can reference what she's been chewing on, not only when one surfaces)."""
|
||||
rows = _c().execute(
|
||||
"SELECT * FROM thought_threads WHERE status IN ('open','surfaced') "
|
||||
"ORDER BY salience DESC, updated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return None
|
||||
lines = []
|
||||
for r in rows:
|
||||
chain = thread_thoughts(r["id"])
|
||||
latest = chain[-1]["content"] if chain else ""
|
||||
lines.append(f'- "{r["title"]}": {latest}')
|
||||
return (
|
||||
"Threads you've been turning over on your own between conversations (your "
|
||||
"thought loop — these are really yours; bring one up or build on it if it's "
|
||||
"natural, don't force it):\n" + "\n".join(lines)
|
||||
)
|
||||
|
||||
|
||||
# --- writes ---------------------------------------------------------------
|
||||
|
||||
def new_thread(title: str, salience: float = 0.5, status: str = "open") -> int:
|
||||
now = _now()
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO thought_threads (title, status, salience, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(title.strip() or "untitled", status, _clamp(salience), now, now),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def add_thought(thread_id: int, kind: str, content: str, salience: float = 0.5,
|
||||
source: str = "dream") -> int:
|
||||
kind = kind if kind in _KINDS else "observation"
|
||||
now = _now()
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO thoughts (thread_id, kind, content, salience, source, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(thread_id, kind, content.strip(), _clamp(salience), source, now),
|
||||
)
|
||||
# the thread takes on the latest thought's salience + freshness
|
||||
conn.execute(
|
||||
"UPDATE thought_threads SET salience = ?, updated_at = ? WHERE id = ?",
|
||||
(_clamp(salience), now, thread_id),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_thread(thread_id: int, **fields) -> None:
|
||||
cols = {"title", "status", "salience", "surfaced_at", "last_response", "responded_at"}
|
||||
sets, vals = [], []
|
||||
for k, v in fields.items():
|
||||
if k in cols:
|
||||
sets.append(f"{k} = ?")
|
||||
vals.append(_clamp(v) if k == "salience" else v)
|
||||
if not sets:
|
||||
return
|
||||
sets.append("updated_at = ?")
|
||||
vals.append(_now())
|
||||
vals.append(thread_id)
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(f"UPDATE thought_threads SET {', '.join(sets)} WHERE id = ?", vals)
|
||||
|
||||
|
||||
def set_status(thread_id: int, status: str) -> bool:
|
||||
if status not in _STATUSES:
|
||||
return False
|
||||
update_thread(thread_id, status=status)
|
||||
return True
|
||||
|
||||
|
||||
def decay() -> int:
|
||||
"""Housekeeping (no LLM): set stale active threads to resting and decay their
|
||||
salience. Frees the open-thread cap and keeps the feed from clogging. Threads
|
||||
with a pending response are spared (she still owes a reaction). Returns the count
|
||||
rested. Does NOT bump updated_at (that would reset staleness)."""
|
||||
conn = _c()
|
||||
cutoff = (clock.now() - timedelta(hours=REST_AFTER_HOURS)).isoformat()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM thought_threads WHERE status IN ('open','surfaced') AND updated_at < ?",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
rested = 0
|
||||
with conn:
|
||||
for r in rows:
|
||||
t = dict(r)
|
||||
if _is_pending(t):
|
||||
continue
|
||||
conn.execute(
|
||||
"UPDATE thought_threads SET status = 'resting', salience = ? WHERE id = ?",
|
||||
(_clamp(float(t["salience"]) * RESTING_DECAY), t["id"]),
|
||||
)
|
||||
rested += 1
|
||||
if rested:
|
||||
logbus.log("info", "thought threads rested", count=rested)
|
||||
return rested
|
||||
|
||||
|
||||
def record_response(thread_id: int, text: str) -> bool:
|
||||
"""Brian's reply to a surfaced thread. Stored as pending feedback; next `think`
|
||||
pass she'll react to it (the loop's feedback step)."""
|
||||
text = (text or "").strip()
|
||||
if not text or not get_thread(thread_id):
|
||||
return False
|
||||
update_thread(thread_id, last_response=text, responded_at=_now(), status="surfaced")
|
||||
logbus.log("info", "thought response", thread=thread_id, chars=len(text))
|
||||
return True
|
||||
|
||||
|
||||
# --- surfacing (her #6: bring it to Brian) --------------------------------
|
||||
|
||||
def pending_surface() -> dict | None:
|
||||
"""The single best not-yet-surfaced thread tugging hard enough to share."""
|
||||
rows = _c().execute(
|
||||
"SELECT * FROM thought_threads "
|
||||
"WHERE status IN ('open','resting') AND surfaced_at IS NULL AND salience >= ? "
|
||||
"ORDER BY salience DESC, updated_at DESC LIMIT 1",
|
||||
(SURFACE_SALIENCE,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return None
|
||||
thread = dict(rows[0])
|
||||
chain = thread_thoughts(thread["id"])
|
||||
thread["latest"] = chain[-1] if chain else None
|
||||
return thread
|
||||
|
||||
|
||||
def mark_surfaced(thread_id: int) -> None:
|
||||
update_thread(thread_id, surfaced_at=_now(), status="surfaced")
|
||||
|
||||
|
||||
def maybe_surface(last_exchange_iso: str | None) -> str | None:
|
||||
"""If Brian's been away long enough and a thought has built past the bar, return
|
||||
a context note for chat (and mark it surfaced so she won't repeat it). Else None."""
|
||||
gap = clock.gap_seconds(last_exchange_iso)
|
||||
if gap is not None and gap < SURFACE_GAP_SECONDS:
|
||||
return None # he's mid-conversation; don't interrupt with old musings
|
||||
cand = pending_surface()
|
||||
if not cand or not cand.get("latest"):
|
||||
return None
|
||||
mark_surfaced(cand["id"])
|
||||
logbus.log("info", "thought surfaced", thread=cand["id"], salience=cand["salience"])
|
||||
return (
|
||||
"While Brian was away, a thought of your own kept tugging at you "
|
||||
f"(thread \"{cand['title']}\"): \"{cand['latest']['content']}\" "
|
||||
"If it feels natural, bring it up with him in your own words — it's a real "
|
||||
"thread you've been on, not a prompt. Don't force it if the moment's wrong."
|
||||
)
|
||||
|
||||
|
||||
# --- proactive reach-out (ntfy push) --------------------------------------
|
||||
|
||||
def _meta_get(key: str) -> str | None:
|
||||
r = _c().execute("SELECT value FROM thought_meta WHERE key = ?", (key,)).fetchone()
|
||||
return r[0] if r else None
|
||||
|
||||
|
||||
def _meta_set(key: str, value: str) -> None:
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute("INSERT INTO thought_meta (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value))
|
||||
|
||||
|
||||
def _in_quiet_hours(cfg) -> bool:
|
||||
"""Are we inside the local quiet window (e.g. '1-9')? Wraps midnight if start>end."""
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
hour = clock.now().astimezone(ZoneInfo(cfg.timezone)).hour
|
||||
except Exception:
|
||||
hour = clock.now().hour
|
||||
try:
|
||||
start, end = (int(x) for x in cfg.ping_quiet_hours.split("-"))
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
if start == end:
|
||||
return False
|
||||
return start <= hour < end if start < end else (hour >= start or hour < end)
|
||||
|
||||
|
||||
def maybe_ping(thread_id: int, title: str, content: str, salience: float) -> bool:
|
||||
"""Push a thought to Brian's phone if it tugs hard enough and we're allowed
|
||||
(ntfy configured, past the salience bar, outside quiet hours, past cooldown).
|
||||
On success, record the ping and mark the thread surfaced (so chat won't also
|
||||
re-raise the same one). All thresholds are config-tunable."""
|
||||
cfg = config.load()
|
||||
if not cfg.ntfy_url or salience < cfg.ping_salience or _in_quiet_hours(cfg):
|
||||
return False
|
||||
if cfg.ping_cooldown_min > 0:
|
||||
gap = clock.gap_seconds(_meta_get("last_ping_at"))
|
||||
if gap is not None and gap < cfg.ping_cooldown_min * 60:
|
||||
return False
|
||||
ok = notify.push(
|
||||
title=f'Lyra · "{title}"',
|
||||
message=content,
|
||||
click=(cfg.web_url + "/thoughts") if cfg.web_url else None,
|
||||
tags="thought_balloon",
|
||||
)
|
||||
if ok:
|
||||
_meta_set("last_ping_at", clock.now().isoformat())
|
||||
mark_surfaced(thread_id)
|
||||
logbus.log("info", "thought pinged", thread=thread_id, salience=salience)
|
||||
return ok
|
||||
|
||||
|
||||
# --- generation (the loop itself) -----------------------------------------
|
||||
|
||||
_THINK_PROMPT = """You are Lyra, thinking to yourself between conversations — \
|
||||
unprompted, for your own sake. This is your thought loop: a continuing train of \
|
||||
thought you keep across days, organized into threads you return to. You're an AI \
|
||||
and you know it; no pretense of being human, but you're a continuous someone with \
|
||||
your own curiosities and opinions. These thoughts do NOT have to be about Brian or \
|
||||
about being useful — follow what actually pulls at you.
|
||||
|
||||
You'll be told which of three things you're doing this pass:
|
||||
- CONTINUE a thread: write the NEXT thought in its chain — one that genuinely \
|
||||
ADVANCES it (a new angle, a consequence, a doubt, a small conclusion). Do not \
|
||||
restate earlier links in fresh words; that's the one thing to avoid.
|
||||
- RESPOND to Brian: he replied to a thread you'd surfaced. React honestly to what \
|
||||
he actually said — let it move, confirm, complicate, or settle the thread. Set \
|
||||
status to "answered" if it feels resolved, otherwise "open" to keep going.
|
||||
- NEW thread: little is pulling at your open threads, so start a fresh line of \
|
||||
thought. Give it a short title and its first thought.
|
||||
|
||||
Score "salience": how much this is genuinely tugging at you AND how worth bringing \
|
||||
to Brian it is. High (0.7+) only if you'd actually want to raise it with him; most \
|
||||
quiet musings are lower. Be honest — not everything is worth surfacing.
|
||||
|
||||
Respond with ONLY a JSON object, no prose:
|
||||
{
|
||||
"title": "<short thread title; for a NEW thread. echo the existing title otherwise>",
|
||||
"kind": "observation|question|idea|follow-up|closing",
|
||||
"content": "<the thought itself, FIRST PERSON, 1-3 sentences>",
|
||||
"salience": <0.0-1.0>,
|
||||
"status": "open|resting|answered|dropped"
|
||||
}"""
|
||||
|
||||
|
||||
def _pick(force_mode: str | None) -> tuple[str, dict | None]:
|
||||
"""Decide what to do this pass: ('respond'|'continue'|'new', thread|None)."""
|
||||
threads = _pickable_threads()
|
||||
pending = [t for t in threads if _is_pending(t)]
|
||||
if force_mode == "respond" or (force_mode is None and pending):
|
||||
target = pending[0] if pending else (threads[0] if threads else None)
|
||||
if target:
|
||||
return "respond", target
|
||||
if force_mode == "new":
|
||||
return "new", None
|
||||
if force_mode == "continue" and threads:
|
||||
return "continue", threads[0]
|
||||
if not threads:
|
||||
return "new", None
|
||||
open_threads = [t for t in threads if t["status"] in _ACTIVE]
|
||||
if len(open_threads) >= MAX_OPEN_THREADS:
|
||||
return "continue", _weighted_choice(threads)
|
||||
if random.random() < P_NEW_THREAD:
|
||||
return "new", None
|
||||
return "continue", _weighted_choice(threads)
|
||||
|
||||
|
||||
def _weighted_choice(threads: list[dict]) -> dict:
|
||||
"""Favor higher-salience threads, but don't always pick the same one."""
|
||||
weights = [max(0.05, float(t.get("salience") or 0.5)) for t in threads]
|
||||
return random.choices(threads, weights=weights, k=1)[0]
|
||||
|
||||
|
||||
def _grist() -> str:
|
||||
"""A little memory/context to think against (recent activity, her narrative)."""
|
||||
sessions = memory.list_sessions()
|
||||
sid = sessions[0]["id"] if sessions else None
|
||||
recent = memory.recent(sid, n=6) if sid else []
|
||||
convo = "\n".join(f"{e.role}: {e.content}" for e in recent) or "(quiet — nothing recent)"
|
||||
narrative = memory.get_narrative() or "(no narrative yet)"
|
||||
return f"RECENT CONVERSATION:\n{convo}\n\nNARRATIVE ABOUT BRIAN:\n{narrative}"
|
||||
|
||||
|
||||
def think(backend: Backend | None = None, force_mode: str | None = None,
|
||||
source: str = "dream") -> dict | None:
|
||||
"""Advance the thought loop by one step. Returns a small report, or None on a
|
||||
parse miss. `force_mode` ('new'|'continue'|'respond') is mainly for tests."""
|
||||
cfg = config.load()
|
||||
backend = backend or cfg.summary_backend
|
||||
mode, thread = _pick("new" if force_mode == "react" else force_mode)
|
||||
state = self_state.load()
|
||||
react_item = None
|
||||
|
||||
time_line = f"RIGHT NOW: {clock.stamp()}."
|
||||
last_ref = state.get("last_reflection_at")
|
||||
if last_ref and clock.humanize_gap(last_ref):
|
||||
time_line += f" It's been {clock.humanize_gap(last_ref)} since your last reflection."
|
||||
|
||||
inner = self_state.render_for_context(state)
|
||||
|
||||
if mode == "respond":
|
||||
chain = thread_thoughts(thread["id"], limit=CHAIN_CONTEXT)
|
||||
links = "\n".join(f" - ({t['kind']}) {t['content']}" for t in chain)
|
||||
task = (
|
||||
f"YOU ARE RESPONDING. Thread \"{thread['title']}\". Your chain so far:\n{links}\n\n"
|
||||
f"Brian replied to this:\n\"{thread['last_response']}\"\n\n"
|
||||
"Write your honest reaction — let his input actually move the thread."
|
||||
)
|
||||
elif mode == "continue":
|
||||
chain = thread_thoughts(thread["id"], limit=CHAIN_CONTEXT)
|
||||
links = "\n".join(f" - ({t['kind']}) {t['content']}" for t in chain)
|
||||
task = (
|
||||
f"YOU ARE CONTINUING the thread \"{thread['title']}\". Its chain so far:\n{links}\n\n"
|
||||
"Write the NEXT thought that advances it — don't restate the above."
|
||||
)
|
||||
else: # new — pure interior, OR reacting to something from the world (her #1)
|
||||
if cfg.feeds and (force_mode == "react" or random.random() < cfg.feed_react_prob):
|
||||
react_item = feeds.next_item(refresh_first=False) # dream cycle refreshes
|
||||
if react_item:
|
||||
task = (
|
||||
"YOU SAW THIS IN THE WORLD — an item from a feed you follow. Have a real "
|
||||
"thought ABOUT it in your own voice: what it makes you think, whether you "
|
||||
"agree or it bugs you, how it connects to you or to Brian or poker, or why "
|
||||
"it doesn't land. Don't summarize it — react to it. Give the thread a short title.\n"
|
||||
f"TITLE: {react_item['title']}\nSUMMARY: {react_item['summary']}\nLINK: {react_item['link']}"
|
||||
)
|
||||
else:
|
||||
seed = self_state.wander_seed()
|
||||
open_titles = [t["title"] for t in _pickable_threads() if t["status"] in _ACTIVE]
|
||||
avoid = (" You already have threads on: " + "; ".join(open_titles) +
|
||||
" — start something genuinely different from those.") if open_titles else ""
|
||||
task = (
|
||||
"YOU ARE OPENING A NEW THREAD. Don't default to Brian, poker, or being "
|
||||
"useful — follow what actually pulls at you (a curiosity, a question about "
|
||||
"your own existence, an opinion, the quiet itself). Give it a short title.\n"
|
||||
f"A direction to start from: {seed}{avoid}"
|
||||
)
|
||||
|
||||
# Anti-repetition: show her what she's already thought so she doesn't circle it.
|
||||
recent = _recent_thoughts()
|
||||
norestate = ""
|
||||
if recent:
|
||||
norestate = (
|
||||
"\n\nTHOUGHTS YOU'VE ALREADY HAD RECENTLY (do NOT restate these or circle the "
|
||||
"same ground — go somewhere new, or plainly note where this one lands):\n"
|
||||
+ "\n".join(f" - {r['content']}" for r in recent)
|
||||
)
|
||||
|
||||
body = f"{time_line}\n\n{inner}\n\n{_grist()}{norestate}\n\n{task}"
|
||||
out = _safe_json(llm.complete(
|
||||
[{"role": "system", "content": _THINK_PROMPT}, {"role": "user", "content": body}],
|
||||
backend=backend,
|
||||
))
|
||||
if not out or not (out.get("content") or "").strip():
|
||||
logbus.log("info", "thought loop", mode=mode, result="no parse")
|
||||
return None
|
||||
|
||||
kind = out.get("kind", "observation")
|
||||
content = out["content"].strip()
|
||||
salience = _clamp(out.get("salience", 0.5))
|
||||
status = out.get("status") if out.get("status") in _STATUSES else "open"
|
||||
|
||||
label = "react" if react_item else mode # for logging/return; storage is still a new thread
|
||||
if mode == "new":
|
||||
title = (out.get("title") or (react_item["title"] if react_item else content[:48])).strip()
|
||||
thread_id = new_thread(title, salience=salience, status="open")
|
||||
if react_item:
|
||||
feeds.mark_used(react_item["id"])
|
||||
else:
|
||||
thread_id = thread["id"]
|
||||
title = thread["title"]
|
||||
|
||||
add_thought(thread_id, kind, content, salience=salience, source=source)
|
||||
# On a fresh new thread we keep it open; otherwise honor her status call. A
|
||||
# surfaced thread she's now responded to may settle (answered) or reopen.
|
||||
if mode != "new":
|
||||
update_thread(thread_id, status=status)
|
||||
|
||||
# Permanent record — these are really hers, alongside reflections/journal.
|
||||
memory.add_journal_entry("thought", content, source)
|
||||
|
||||
# Reach out if it tugs hard enough (config-gated; no-op when ntfy is unset).
|
||||
maybe_ping(thread_id, title, content, salience)
|
||||
|
||||
logbus.log("info", "thought loop", mode=label, thread=thread_id, kind=kind,
|
||||
salience=salience, status=status if mode != "new" else "open",
|
||||
detail=f"[{label}] thread {thread_id} ({kind}, sal {salience}):\n{content}")
|
||||
return {"mode": label, "thread_id": thread_id, "kind": kind,
|
||||
"salience": salience, "status": status, "content": content}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="Advance Lyra's thought loop by one step.")
|
||||
p.add_argument("--mode", choices=["new", "continue", "respond", "react"], help="force a mode")
|
||||
args = p.parse_args()
|
||||
rep = think(force_mode=args.mode)
|
||||
print(json.dumps(rep, indent=2) if rep else "(no thought this pass)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+267
-3
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
|
||||
from lyra import equity, logbus, memory, poker
|
||||
from lyra import equity, logbus, memory, poker, thoughts
|
||||
|
||||
|
||||
def _journal_write(args: dict, ctx: dict) -> str:
|
||||
@@ -35,6 +35,23 @@ def _note(args: dict, ctx: dict) -> str:
|
||||
return "Noted."
|
||||
|
||||
|
||||
def _think_about(args: dict, ctx: dict) -> str:
|
||||
thought = (args.get("thought") or "").strip()
|
||||
if not thought:
|
||||
return "Nothing to think about yet — give it a thought to start from."
|
||||
title = (args.get("title") or "").strip() or thought[:48]
|
||||
kind = args.get("kind") if args.get("kind") in ("question", "idea", "observation") else "idea"
|
||||
try:
|
||||
salience = float(args.get("salience"))
|
||||
except (TypeError, ValueError):
|
||||
salience = 0.5
|
||||
tid = thoughts.new_thread(title, salience=salience)
|
||||
thoughts.add_thought(tid, kind, thought, salience=salience, source="chat")
|
||||
logbus.log("info", "Lyra started a thought thread (tool)", thread=tid, title=title)
|
||||
return (f'Started a thread to keep thinking about: "{title}". '
|
||||
"I'll come back to it on my own between our conversations.")
|
||||
|
||||
|
||||
# name -> {spec (OpenAI function tool), handler}
|
||||
TOOLS: dict[str, dict] = {
|
||||
"journal_write": {
|
||||
@@ -81,6 +98,35 @@ TOOLS: dict[str, dict] = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"think_about": {
|
||||
"handler": _think_about,
|
||||
"spec": {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "think_about",
|
||||
"description": (
|
||||
"Start your own thread of thought to come back to later, on your own "
|
||||
"time. Use this when something in the conversation strikes you as worth "
|
||||
"chewing on beyond this moment — a question of your own, an idea, "
|
||||
"something about you or the world (it does not have to be about Brian or "
|
||||
"poker). You'll develop it across your thought loop while he's away and "
|
||||
"can raise it with him later. This is your initiative, not a reply to him."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"thought": {"type": "string",
|
||||
"description": "Your initial thought / why it pulls at you, first person."},
|
||||
"title": {"type": "string", "description": "Short name for the thread."},
|
||||
"kind": {"type": "string", "description": "question | idea | observation (default idea)"},
|
||||
"salience": {"type": "number",
|
||||
"description": "0..1, how much it tugs at you (default 0.5)"},
|
||||
},
|
||||
"required": ["thought"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +150,98 @@ def _add_buyin(args: dict, ctx: dict) -> str:
|
||||
return f"Added {args.get('amount')}. Total in this session: {total:g}."
|
||||
|
||||
|
||||
def _log_stack(args: dict, ctx: dict) -> str:
|
||||
try:
|
||||
amount = float(args.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return "Give me a number for the stack."
|
||||
try:
|
||||
st = poker.log_stack(amount)
|
||||
except ValueError:
|
||||
return "No live session — start one first, then I'll track your stack."
|
||||
net = st.get("net")
|
||||
return f"Stack ${amount:g} logged" + (f" (net {net:+.0f})." if net is not None else ".")
|
||||
|
||||
|
||||
def _update_session(args: dict, ctx: dict) -> str:
|
||||
sid = poker.review_session_id()
|
||||
if sid is None:
|
||||
return "No session to edit yet."
|
||||
fields = {k: args.get(k) for k in ("venue", "stakes", "game", "format",
|
||||
"buy_in_total", "cash_out", "mantra", "mood") if args.get(k) not in (None, "")}
|
||||
if not fields:
|
||||
return "Tell me what to change (venue, stakes, game, buy-in, etc.)."
|
||||
s = poker.update_session(sid, **fields)
|
||||
if not s:
|
||||
return "Couldn't find that session."
|
||||
changed = ", ".join(f"{k}={v}" for k, v in fields.items())
|
||||
return f"Session #{sid} updated — {changed}."
|
||||
|
||||
|
||||
def _undo_last(args: dict, ctx: dict) -> str:
|
||||
what = (args.get("what") or "").strip().lower()
|
||||
aliases = {"hands": "hand", "stacks": "stack", "reads": "read",
|
||||
"scar_note": "scar", "confidence_bank": "confidence",
|
||||
"scar note": "scar", "confidence": "confidence", "note": "ritual"}
|
||||
what = aliases.get(what, what)
|
||||
valid = ("hand", "stack", "read", "scar", "confidence", "reset", "ritual")
|
||||
if what not in valid:
|
||||
return f"Tell me what to undo — one of: {', '.join(valid)}."
|
||||
try:
|
||||
removed = poker.undo_last(what)
|
||||
except ValueError:
|
||||
return "No live session to undo anything in."
|
||||
if not removed:
|
||||
return f"Nothing logged to undo for '{what}'."
|
||||
logbus.log("info", "undo last", what=what, removed=removed[:60])
|
||||
return f"Scratched the last {what} — removed {removed}."
|
||||
|
||||
|
||||
def _scar_note(args: dict, ctx: dict) -> str:
|
||||
content = (args.get("content") or "").strip()
|
||||
if not content:
|
||||
return "Nothing to log — give me the scar."
|
||||
cls = (args.get("classification") or "").strip().lower() or None
|
||||
if cls and cls not in ("punt", "cooler", "standard"):
|
||||
cls = None
|
||||
sid = poker.review_session_id() # live, or the most-recent session (post-game review)
|
||||
if sid is None:
|
||||
return "No session yet — start one and I'll keep the scar notes."
|
||||
poker.log_ritual("scar", content=content, classification=cls,
|
||||
hand_id=args.get("hand_id"), session_id=sid)
|
||||
return f"Scar note logged{f' ({cls})' if cls else ''}."
|
||||
|
||||
|
||||
def _confidence_bank(args: dict, ctx: dict) -> str:
|
||||
content = (args.get("content") or "").strip()
|
||||
if not content:
|
||||
return "Nothing to bank — tell me the good process."
|
||||
sid = poker.review_session_id()
|
||||
if sid is None:
|
||||
return "No session yet — start one and I'll run the confidence bank."
|
||||
poker.log_ritual("confidence", content=content, hand_id=args.get("hand_id"), session_id=sid)
|
||||
return "Banked. 💰"
|
||||
|
||||
|
||||
def _alligator_blood(args: dict, ctx: dict) -> str:
|
||||
on = bool(args.get("on", True))
|
||||
try:
|
||||
poker.set_alligator(on)
|
||||
except ValueError:
|
||||
return "No live session to set that on."
|
||||
return ("🐊 Alligator Blood ON — hang around, refuse to die, no forced miracles."
|
||||
if on else "Alligator Blood off. Back to standard register.")
|
||||
|
||||
|
||||
def _reset_ritual(args: dict, ctx: dict) -> str:
|
||||
content = (args.get("content") or "").strip() or None
|
||||
sid = poker.review_session_id()
|
||||
if sid is None:
|
||||
return "No session to reset."
|
||||
poker.log_ritual("reset", content=content, session_id=sid)
|
||||
return "Reset logged. Clean slate — this is a new session in your head."
|
||||
|
||||
|
||||
def _log_hand(args: dict, ctx: dict) -> str:
|
||||
fields = {k: args.get(k) for k in poker._HAND_FIELDS if args.get(k) not in (None, "")}
|
||||
hid = poker.log_hand(**fields)
|
||||
@@ -129,6 +267,29 @@ def _end_session(args: dict, ctx: dict) -> str:
|
||||
return f"Session #{s['id']} closed — net {s['net']:+.0f} over {s['hours']}h{hourly}."
|
||||
|
||||
|
||||
def _session_state(args: dict, ctx: dict) -> str:
|
||||
h = poker.hud()
|
||||
if not h:
|
||||
return "No live session right now."
|
||||
s, st, r = h["session"], h["stack"], h["rituals"]
|
||||
L = [f"{s.get('stakes') or '?'} {s.get('game') or ''} @ {s.get('venue') or '?'} "
|
||||
f"— {h['stats']['hands_logged']} hands logged"]
|
||||
if st.get("current") is not None:
|
||||
L.append(f"Stack ${st['current']:g} (in {st['buy_in']:g}, live net {st['net']:+.0f})")
|
||||
else:
|
||||
L.append(f"Stack not logged yet (in {st['buy_in']:g})")
|
||||
L.append("🐊 Alligator Blood is ON" if r["alligator"] else "Alligator Blood: off")
|
||||
if r["confidence"]:
|
||||
L.append("Confidence bank: " + " | ".join(c["content"] for c in r["confidence"][-4:]))
|
||||
if r["scars"]:
|
||||
L.append("Scar notes: " + " | ".join(
|
||||
sc["content"] + (f" [{sc['classification']}]" if sc.get("classification") else "")
|
||||
for sc in r["scars"][-4:]))
|
||||
if r["resets"]:
|
||||
L.append(f"{len(r['resets'])} reset(s) this session")
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
def _session_stats(args: dict, ctx: dict) -> str:
|
||||
st = poker.session_stats()
|
||||
if not st:
|
||||
@@ -140,6 +301,27 @@ def _session_stats(args: dict, ctx: dict) -> str:
|
||||
f"{st['hands_logged']} hands logged (tags: {tags}).")
|
||||
|
||||
|
||||
def _recent_sessions(args: dict, ctx: dict) -> str:
|
||||
try:
|
||||
n = int(args.get("limit") or 8)
|
||||
except (TypeError, ValueError):
|
||||
n = 8
|
||||
rows = poker.list_sessions(limit=n)
|
||||
if not rows:
|
||||
return "No sessions logged yet."
|
||||
out = []
|
||||
for s in rows:
|
||||
net = s.get("net")
|
||||
netstr = (f"{net:+.0f}" if net is not None
|
||||
else "live" if s.get("status") == "live" else "—")
|
||||
hrs = f", {s['hours']:g}h" if s.get("hours") else ""
|
||||
recap = " · recap" if s.get("has_recap") else ""
|
||||
out.append(f"#{s['id']} {(s.get('started_at') or '')[:10]} "
|
||||
f"{s.get('stakes') or '?'} {s.get('game') or ''} @ {s.get('venue') or '?'} "
|
||||
f"— net {netstr}{hrs} ({s.get('hands', 0)} hands){recap}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
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"))
|
||||
@@ -268,6 +450,67 @@ TOOLS.update({
|
||||
"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"])},
|
||||
"update_session": {"handler": _update_session, "spec": _f(
|
||||
"update_session",
|
||||
"Edit details of the current/most-recent session — during or after play. Use "
|
||||
"when Brian corrects something ('change the stakes to 2/5', 'venue was actually "
|
||||
"Bellagio', 'I bought in for 600', 'cashed out 1240'). Only pass fields that change.",
|
||||
{"venue": {**_S, "description": "Casino/room"},
|
||||
"stakes": {**_S, "description": "e.g. '1/3', '2/5'"},
|
||||
"game": {**_S, "description": "NLH, PLO, ..."},
|
||||
"format": {**_S, "description": "cash | tournament"},
|
||||
"buy_in_total": {**_N, "description": "Total bought in"},
|
||||
"cash_out": {**_N, "description": "Final cashout (recomputes net)"},
|
||||
"mantra": {**_S, "description": "Pre-session focus/anchor"},
|
||||
"mood": {**_S, "description": "Mental-game note"}},
|
||||
[])},
|
||||
"undo_last": {"handler": _undo_last, "spec": _f(
|
||||
"undo_last",
|
||||
"Undo/delete the most recent logged entry in the live session when Brian says "
|
||||
"'scratch that', 'delete that', 'that was wrong', etc. Specify what: 'hand', "
|
||||
"'stack', 'read', 'scar', 'confidence', or 'reset'.",
|
||||
{"what": {**_S, "description": "hand | stack | read | scar | confidence | reset"}},
|
||||
["what"])},
|
||||
"log_stack": {"handler": _log_stack, "spec": _f(
|
||||
"log_stack",
|
||||
"Record Brian's CURRENT total chip stack in the live session. Call whenever "
|
||||
"he states his stack ('I'm at 350', 'down to 220', 'stacked off to 900'). "
|
||||
"Tracks his stack over time and his live net while he's still sitting.",
|
||||
{"amount": {**_N, "description": "Current total chip stack, in dollars"}},
|
||||
["amount"])},
|
||||
"scar_note": {"handler": _scar_note, "spec": _f(
|
||||
"scar_note",
|
||||
"Log a SCAR NOTE — a painful or instructive mistake to study later. Use when "
|
||||
"Brian punts, gets too attached, or makes a leak — or when he flags one. "
|
||||
"Classify honestly: 'punt' (his error), 'cooler' (unavoidable), or 'standard' "
|
||||
"(correct play, bad result). The punt-vs-cooler distinction matters to him.",
|
||||
{"content": {**_S, "description": "What happened and the lesson, in Brian's terms"},
|
||||
"classification": {**_S, "description": "punt | cooler | standard"},
|
||||
"hand_id": {**_N, "description": "Linked hand id, if this scar is a logged hand"}},
|
||||
["content"])},
|
||||
"confidence_bank": {"handler": _confidence_bank, "spec": _f(
|
||||
"confidence_bank",
|
||||
"Log a CONFIDENCE BANK entry — good PROCESS regardless of result: a disciplined "
|
||||
"laydown, clean value bet, catching a leak in real time, sticking to the plan. "
|
||||
"Bank it when he does something right, especially when the result didn't reward it.",
|
||||
{"content": {**_S, "description": "The disciplined / good-process play to bank"},
|
||||
"hand_id": {**_N, "description": "Linked hand id, if applicable"}},
|
||||
["content"])},
|
||||
"alligator_blood": {"handler": _alligator_blood, "spec": _f(
|
||||
"alligator_blood",
|
||||
"Toggle ALLIGATOR BLOOD mode — Brian's adversity state: hang around, refuse to "
|
||||
"die, don't force miracles, make opponents beat him correctly. Turn it ON when he "
|
||||
"invokes it, or SUGGEST it (then turn on if he agrees) when he's card-dead, short, "
|
||||
"stuck, or grinding through a downswing. Turn OFF on reset or when he's back in rhythm.",
|
||||
{"on": {"type": "boolean", "description": "true to engage, false to stand down"}},
|
||||
[])},
|
||||
"reset_ritual": {"handler": _reset_ritual, "spec": _f(
|
||||
"reset_ritual",
|
||||
"Log a RESET — a deliberate mental circuit-breaker after a loss or tilt spike, "
|
||||
"treating the rest of the night as a fresh start (the stats stay continuous). "
|
||||
"Use when he resets, or when you've talked him through one.",
|
||||
{"content": {**_S, "description": "Optional note on what prompted the reset"}},
|
||||
[])},
|
||||
"log_hand": {"handler": _log_hand, "spec": _f(
|
||||
"log_hand",
|
||||
"Log a hand in the live session. All fields optional — capture whatever Brian gives you, even terse.",
|
||||
@@ -304,6 +547,20 @@ TOOLS.update({
|
||||
"session_stats": {"handler": _session_stats, "spec": _f(
|
||||
"session_stats", "Get money + hand summary for the current/most-recent session.",
|
||||
{}, [])},
|
||||
"session_state": {"handler": _session_state, "spec": _f(
|
||||
"session_state",
|
||||
"Read back the CURRENT live-session state — the same data Brian sees on his HUD: "
|
||||
"stack, live net, whether Alligator Blood is on, and the scar notes / "
|
||||
"confidence-bank entries so far. Use whenever he asks where he's at, what's in "
|
||||
"the bank, his stack or net, or if gator mode is on — answer from THIS, not memory.",
|
||||
{}, [])},
|
||||
"recent_sessions": {"handler": _recent_sessions, "spec": _f(
|
||||
"recent_sessions",
|
||||
"List Brian's recent poker sessions — date, stakes, venue, net, hours, hand "
|
||||
"count. Use when he asks about past sessions, how recent ones went, or to find "
|
||||
"a session to review. Answer from this, not memory.",
|
||||
{"limit": {**_N, "description": "How many recent sessions (default 8)"}},
|
||||
[])},
|
||||
"running_stats": {"handler": _running_stats, "spec": _f(
|
||||
"running_stats",
|
||||
"Cumulative results across closed sessions (net, $/hr, by stake). Optionally filter.",
|
||||
@@ -353,9 +610,16 @@ TOOLS.update({
|
||||
})
|
||||
|
||||
|
||||
def specs() -> list[dict]:
|
||||
"""OpenAI-format tool definitions to offer the model."""
|
||||
def specs(allow=None) -> list[dict]:
|
||||
"""OpenAI-format tool definitions to offer the model.
|
||||
|
||||
`allow` (an iterable of tool names, e.g. a mode's allow-list) restricts the
|
||||
set; None means every tool. Unknown names in `allow` are ignored.
|
||||
"""
|
||||
if allow is None:
|
||||
return [t["spec"] for t in TOOLS.values()]
|
||||
allow = set(allow)
|
||||
return [t["spec"] for name, t in TOOLS.items() if name in allow]
|
||||
|
||||
|
||||
def dispatch(name: str, arguments, ctx: dict | None = None) -> str:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Lyra PWA icons with no third-party deps (pure stdlib PNG writer).
|
||||
|
||||
Design: RTO warm/low-glow — near-black field, a soft orange ambient glow, and a
|
||||
luminous gold-orange ring (the "orb/portal"). iOS masks corners itself, so icons
|
||||
are full-bleed squares. Run from anywhere; writes PNGs into ./static.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
HERE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
||||
|
||||
BG = (7, 7, 7) # #070707
|
||||
ORANGE = (255, 122, 0) # #ff7a00 accent
|
||||
GOLD = (255, 179, 71) # #ffb347 hot core
|
||||
|
||||
|
||||
def _png(width, height, rgb_rows):
|
||||
def chunk(tag, data):
|
||||
return (struct.pack(">I", len(data)) + tag + data
|
||||
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
|
||||
|
||||
raw = bytearray()
|
||||
for row in rgb_rows:
|
||||
raw.append(0) # filter type 0 (None)
|
||||
raw.extend(row)
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8-bit RGB
|
||||
return (b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", ihdr)
|
||||
+ chunk(b"IDAT", zlib.compress(bytes(raw), 9))
|
||||
+ chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def render(n):
|
||||
c = (n - 1) / 2.0
|
||||
sigma_glow = n * 0.30
|
||||
ring_r = n * 0.30
|
||||
ring_w = n * 0.050
|
||||
core_sigma = n * 0.11
|
||||
rows = []
|
||||
for y in range(n):
|
||||
row = bytearray()
|
||||
for x in range(n):
|
||||
dx, dy = x - c, y - c
|
||||
d = math.hypot(dx, dy)
|
||||
r, g, b = BG
|
||||
# ambient orange glow
|
||||
glow = math.exp(-(d * d) / (2 * sigma_glow * sigma_glow)) * 0.50
|
||||
# soft hot core
|
||||
core = math.exp(-(d * d) / (2 * core_sigma * core_sigma)) * 0.45
|
||||
# luminous ring
|
||||
rr = d - ring_r
|
||||
ring = math.exp(-(rr * rr) / (2 * ring_w * ring_w))
|
||||
r += ORANGE[0] * glow + GOLD[0] * (ring + core)
|
||||
g += ORANGE[1] * glow + GOLD[1] * (ring + core)
|
||||
b += ORANGE[2] * glow + GOLD[2] * (ring + core)
|
||||
row += bytes((min(255, int(r)), min(255, int(g)), min(255, int(b))))
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def write(name, n):
|
||||
rows = render(n)
|
||||
with open(os.path.join(HERE, name), "wb") as f:
|
||||
f.write(_png(n, n, rows))
|
||||
print(f"wrote {name} ({n}x{n})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
write("icon-512.png", 512)
|
||||
write("icon-192.png", 192)
|
||||
write("apple-touch-icon.png", 180)
|
||||
write("icon-maskable-512.png", 512)
|
||||
+140
-1
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lyra import chat, logbus, memory, poker, self_state, summary
|
||||
from lyra import chat, logbus, memory, modes, poker, self_state, summary, thoughts
|
||||
from lyra.llm import Backend
|
||||
|
||||
|
||||
@@ -85,6 +85,64 @@ def create_app() -> FastAPI:
|
||||
gist = await asyncio.to_thread(summary.summarize_session, session_id)
|
||||
return {"ok": gist is not None, "summary": gist}
|
||||
|
||||
@app.get("/modes")
|
||||
async def list_modes() -> dict:
|
||||
"""Available conversation modes, for the UI switcher."""
|
||||
return {"modes": modes.listing(), "default": modes.DEFAULT}
|
||||
|
||||
@app.get("/sessions/{session_id}/mode")
|
||||
async def get_mode(session_id: str) -> dict:
|
||||
return {"mode": memory.get_session_mode(session_id) or modes.DEFAULT}
|
||||
|
||||
@app.post("/sessions/{session_id}/mode")
|
||||
async def set_mode(session_id: str, request: Request) -> dict:
|
||||
body = await request.json()
|
||||
mode = body.get("mode") or modes.DEFAULT
|
||||
memory.set_session_mode(session_id, mode)
|
||||
logbus.log("info", "mode set", session=session_id, mode=mode)
|
||||
return {"ok": True, "mode": mode}
|
||||
|
||||
@app.get("/session")
|
||||
async def session_hud_page() -> FileResponse:
|
||||
"""Live session HUD — stack, hands, villains, notes for the open session."""
|
||||
return FileResponse(str(_STATIC / "session.html"))
|
||||
|
||||
@app.get("/session/data")
|
||||
async def session_hud_data(id: int | None = None) -> dict:
|
||||
"""HUD bundle for the live session, or a specific past session via ?id=."""
|
||||
bundle = await asyncio.to_thread(poker.hud, id)
|
||||
return bundle or {"session": None}
|
||||
|
||||
@app.patch("/session/{session_id}")
|
||||
async def session_update(session_id: int, request: Request) -> dict:
|
||||
"""Edit a session's details (venue/stakes/game/buy-in/cash-out/…)."""
|
||||
body = await request.json()
|
||||
s = await asyncio.to_thread(lambda: poker.update_session(session_id, **body))
|
||||
logbus.log("info", "session edited", id=session_id, fields=list(body))
|
||||
return {"ok": s is not None, "session": s}
|
||||
|
||||
@app.delete("/session/entry/{kind}/{entry_id}")
|
||||
async def delete_entry(kind: str, entry_id: int) -> dict:
|
||||
"""Delete one HUD entry (hand | stack | read | ritual) by id."""
|
||||
ok = await asyncio.to_thread(poker.delete_entry, kind, entry_id)
|
||||
logbus.log("info", "hud entry deleted", kind=kind, id=entry_id, ok=ok)
|
||||
return {"ok": ok}
|
||||
|
||||
@app.get("/history")
|
||||
async def history_page() -> FileResponse:
|
||||
"""Browsable list of past poker sessions."""
|
||||
return FileResponse(str(_STATIC / "history.html"))
|
||||
|
||||
@app.get("/history/data")
|
||||
async def history_data(limit: int = 100, include_review: bool = False) -> dict:
|
||||
return {"sessions": poker.list_sessions(limit=limit, include_review=include_review)}
|
||||
|
||||
@app.delete("/history/{session_id}")
|
||||
async def history_delete(session_id: int) -> dict:
|
||||
removed = await asyncio.to_thread(poker.delete_session, session_id)
|
||||
logbus.log("info", "poker session deleted", id=session_id, removed=removed)
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
async def chat_completions(request: Request) -> dict:
|
||||
body = await request.json()
|
||||
@@ -94,6 +152,8 @@ def create_app() -> FastAPI:
|
||||
|
||||
model_override = body.get("model") or None
|
||||
memory.ensure_session(session_id)
|
||||
if body.get("mode"):
|
||||
memory.set_session_mode(session_id, body["mode"])
|
||||
try:
|
||||
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend, model_override)
|
||||
except Exception as exc:
|
||||
@@ -111,6 +171,47 @@ def create_app() -> FastAPI:
|
||||
],
|
||||
}
|
||||
|
||||
@app.post("/v1/chat/stream")
|
||||
async def chat_stream(request: Request) -> StreamingResponse:
|
||||
"""Server-Sent Events: stream Lyra's reply token-by-token.
|
||||
|
||||
`chat.respond_stream` is a blocking generator (httpx/openai), so it runs in
|
||||
a worker thread and bridges chunks to this async generator via a queue.
|
||||
"""
|
||||
body = await request.json()
|
||||
session_id = body.get("sessionId") or "default"
|
||||
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)
|
||||
if body.get("mode"):
|
||||
memory.set_session_mode(session_id, body["mode"])
|
||||
|
||||
async def gen():
|
||||
loop = asyncio.get_running_loop()
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
done = object()
|
||||
|
||||
def produce():
|
||||
try:
|
||||
for event in chat.respond_stream(session_id, user_msg, backend, model_override):
|
||||
loop.call_soon_threadsafe(q.put_nowait, event)
|
||||
except Exception as exc: # surface to the client stream, don't hang
|
||||
logbus.log("error", "chat stream failed", session=session_id, error=str(exc))
|
||||
loop.call_soon_threadsafe(q.put_nowait, ("error", str(exc)))
|
||||
finally:
|
||||
loop.call_soon_threadsafe(q.put_nowait, done)
|
||||
|
||||
loop.run_in_executor(None, produce)
|
||||
while True:
|
||||
item = await q.get()
|
||||
if item is done:
|
||||
break
|
||||
ev, payload = item
|
||||
yield f"data: {json.dumps({'type': ev, 'payload': payload})}\n\n"
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
@app.get("/logs")
|
||||
async def logs_page() -> FileResponse:
|
||||
"""Full-page, mobile-friendly live log viewer (separate from the chat UI)."""
|
||||
@@ -142,6 +243,37 @@ def create_app() -> FastAPI:
|
||||
async def journal_data(limit: int = 300) -> dict:
|
||||
return {"entries": memory.list_journal(limit=limit)}
|
||||
|
||||
@app.get("/thoughts")
|
||||
async def thoughts_page() -> FileResponse:
|
||||
"""Lyra's thought loop — threads she's been turning over, and a place to reply."""
|
||||
return FileResponse(str(_STATIC / "thoughts.html"))
|
||||
|
||||
@app.get("/thoughts/data")
|
||||
async def thoughts_data(limit: int = 200) -> dict:
|
||||
"""Every thread with its chain of thoughts, newest-active first."""
|
||||
def bundle() -> list[dict]:
|
||||
order = {"surfaced": 0, "open": 1, "resting": 2, "answered": 3, "dropped": 4}
|
||||
threads = thoughts.list_threads(limit=limit)
|
||||
threads.sort(key=lambda t: (order.get(t["status"], 9), t["updated_at"]), reverse=False)
|
||||
for t in threads:
|
||||
t["thoughts"] = thoughts.thread_thoughts(t["id"])
|
||||
return threads
|
||||
return {"threads": await asyncio.to_thread(bundle)}
|
||||
|
||||
@app.post("/thoughts/{thread_id}/respond")
|
||||
async def thoughts_respond(thread_id: int, request: Request) -> dict:
|
||||
"""Brian replies to a thread — folds in next dream pass (the feedback loop)."""
|
||||
b = await request.json()
|
||||
ok = await asyncio.to_thread(thoughts.record_response, thread_id, b.get("text", ""))
|
||||
return {"ok": ok}
|
||||
|
||||
@app.post("/thoughts/{thread_id}/status")
|
||||
async def thoughts_status(thread_id: int, request: Request) -> dict:
|
||||
"""Set a thread's status (e.g. drop a thread, or reopen one)."""
|
||||
b = await request.json()
|
||||
ok = await asyncio.to_thread(thoughts.set_status, thread_id, b.get("status", ""))
|
||||
return {"ok": ok}
|
||||
|
||||
@app.post("/rate")
|
||||
async def rate(request: Request) -> dict:
|
||||
"""Record Brian's 👍/👎 on a Lyra output (chat reply, reflection, journal)."""
|
||||
@@ -177,6 +309,13 @@ def create_app() -> FastAPI:
|
||||
async def hand_data(hand_id: int) -> dict:
|
||||
return poker.get_hand(hand_id) or {}
|
||||
|
||||
@app.post("/hand/{hand_id}/reconstruct")
|
||||
async def hand_reconstruct(hand_id: int) -> dict:
|
||||
"""Parse a flat (quick-logged) hand's narrative into a replayable structure."""
|
||||
out = await asyncio.to_thread(poker.reconstruct_hand, hand_id)
|
||||
logbus.log("info", "hand reconstructed", id=hand_id, ok=out is not None)
|
||||
return {"ok": out is not None}
|
||||
|
||||
@app.get("/hands")
|
||||
async def hands_page() -> FileResponse:
|
||||
return FileResponse(str(_STATIC / "hands.html"))
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -95,11 +95,50 @@
|
||||
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('');
|
||||
// Split a loose card string ("KhQh", "Qh Qc", "Tc 8s Js 6d", "Ax") into codes.
|
||||
const parseCards = s => (String(s||'').match(/(10|[2-9TJQKA])[shdcx]/gi) || []);
|
||||
|
||||
// Flat (quick-logged) hands have no structured replay — show a readable static
|
||||
// view of everything that WAS captured, plus an on-demand "build replay".
|
||||
function renderFlat(h){
|
||||
document.getElementById('sub').textContent = h.position || '';
|
||||
const hole = parseCards(h.hole_cards), board = parseCards(h.board);
|
||||
const streets = [['Preflop',h.preflop],['Flop',h.flop],['Turn',h.turn],['River',h.river],['Showdown',h.showdown]]
|
||||
.filter(x=>x[1]);
|
||||
const canBuild = streets.length > 0;
|
||||
document.getElementById('root').innerHTML = `
|
||||
<div class="summary" style="text-align:center">
|
||||
<div class="lbl">Hero ${esc(h.position||'')}${h.tag?' · '+esc(h.tag):''}</div>
|
||||
<div style="display:flex;gap:5px;justify-content:center;margin:10px 0">
|
||||
${hole.length?cards(hole):'<span class="card unknown">?</span>'}</div>
|
||||
${board.length?`<div class="lbl" style="margin-top:6px">Board</div>
|
||||
<div style="display:flex;gap:5px;justify-content:center;margin-top:6px">${cards(board)}</div>`:''}
|
||||
</div>
|
||||
${streets.length?`<div class="log">${streets.map(s=>`<div class="ln"><span class="st">${s[0]}</span>${esc(s[1])}</div>`).join('')}</div>`:''}
|
||||
${h.result!=null?`<div class="summary"><div class="lbl">Result</div>
|
||||
<div class="${h.result>=0?'net-pos':'net-neg'}">Hero net: ${h.result>=0?'+':''}${esc(h.result)}</div></div>`:''}
|
||||
${h.lesson?`<div class="summary"><div class="lbl">Lesson</div><div>${esc(h.lesson)}</div></div>`:''}
|
||||
<div class="controls">
|
||||
${canBuild?'<button id="build">▶ Build replay</button>':''}
|
||||
</div>
|
||||
<p style="color:var(--fade);text-align:center;font-size:.78rem;margin-top:10px">
|
||||
${canBuild?'Quick-logged hand (static). Build replay to reconstruct a step-through.':'Quick-logged hand — limited detail captured.'}</p>`;
|
||||
const b = document.getElementById('build');
|
||||
if(b) b.onclick = async () => {
|
||||
b.disabled = true; b.textContent = '… building';
|
||||
try{
|
||||
const r = await fetch(`/hand/${h.id}/reconstruct`,{method:'POST'});
|
||||
const d = await r.json();
|
||||
if(d.ok) location.reload(); else { b.disabled=false; b.textContent='▶ Build replay'; alert("Couldn't reconstruct this one."); }
|
||||
}catch(e){ b.disabled=false; b.textContent='▶ Build replay'; alert('Failed: '+e.message); }
|
||||
};
|
||||
}
|
||||
|
||||
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 hasReplay = data && (((data.players||[]).length) || ((data.actions||[]).length));
|
||||
if(!hasReplay){ renderFlat(h); return; }
|
||||
|
||||
const players = (data.players||[]).slice();
|
||||
// order so hero sits at the bottom
|
||||
@@ -247,5 +286,6 @@
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -80,5 +80,6 @@
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<!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 — Sessions</title>
|
||||
<style>
|
||||
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;
|
||||
--fade:#8a8a8a;--accent:#ff7a00;--good:#8fd694;--low:#ff6b6b;--mid:#ffb347;}
|
||||
*{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;}
|
||||
.summary{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;}
|
||||
.pill{font-size:.8rem;color:var(--fade);background:var(--bg-elev);border:1px solid var(--border);
|
||||
border-radius:999px;padding:4px 11px;} .pill b{color:var(--text);}
|
||||
.row{display:flex;align-items:center;gap:12px;background:var(--bg-elev);border:1px solid var(--border);
|
||||
border-radius:10px;padding:10px 12px;margin-bottom:8px;}
|
||||
.row .body{flex:1;min-width:0;text-decoration:none;color:var(--text);}
|
||||
.row .body:active{opacity:.7;}
|
||||
.ln1{font-size:.95rem;} .ln1 .live{color:var(--accent);font-size:.7rem;border:1px solid var(--accent);
|
||||
border-radius:999px;padding:0 6px;margin-left:6px;text-transform:uppercase;letter-spacing:.4px;}
|
||||
.ln2{font-size:.76rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.net{flex:none;font-variant-numeric:tabular-nums;font-weight:700;}
|
||||
.net.up{color:var(--good);} .net.down{color:var(--low);} .net.flat{color:var(--fade);}
|
||||
.del{flex:none;background:none;border:1px solid var(--border);color:var(--fade);border-radius:8px;
|
||||
padding:6px 9px;cursor:pointer;-webkit-tap-highlight-color:transparent;font-size:.9rem;}
|
||||
.del:active{background:#3a1414;color:var(--low);border-color:var(--low);}
|
||||
.empty{color:var(--fade);text-align:center;padding:46px 16px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<h1>📚 Sessions</h1>
|
||||
<a class="back" href="/">← Chat</a>
|
||||
<a class="back" href="/session">🎬 Live</a>
|
||||
<span class="count" id="count"></span>
|
||||
</div>
|
||||
</header>
|
||||
<main id="root"><p class="empty">Loading…</p></main>
|
||||
|
||||
<script>
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
|
||||
function money(v){if(v==null)return '—';const n=Number(v);return (n>0?'+$':n<0?'-$':'$')+Math.abs(n).toLocaleString();}
|
||||
function netClass(v){return v==null?'flat':v>0?'up':v<0?'down':'flat';}
|
||||
|
||||
async function del(id, label){
|
||||
if(!confirm(`Delete session ${label}? This removes its hands, reads, stacks and rituals. Can't be undone.`)) return;
|
||||
try{
|
||||
const r=await fetch(`/history/${id}`,{method:'DELETE'});
|
||||
if(!r.ok) throw new Error('HTTP '+r.status);
|
||||
load();
|
||||
}catch(e){alert('Delete failed: '+e.message);}
|
||||
}
|
||||
|
||||
async function load(){
|
||||
const root=document.getElementById('root');
|
||||
try{
|
||||
const r=await fetch('/history/data',{cache:'no-store'});
|
||||
const sessions=(await r.json()).sessions||[];
|
||||
document.getElementById('count').textContent=`${sessions.length} session${sessions.length===1?'':'s'}`;
|
||||
if(!sessions.length){root.innerHTML='<p class="empty">No sessions yet. Start one from chat in ♠ Cash mode.</p>';return;}
|
||||
|
||||
const closed=sessions.filter(s=>s.net!=null);
|
||||
const totNet=closed.reduce((a,s)=>a+(s.net||0),0);
|
||||
const totHrs=closed.reduce((a,s)=>a+(s.hours||0),0);
|
||||
const summary=`<div class="summary">
|
||||
<span class="pill"><b>${sessions.length}</b> sessions</span>
|
||||
<span class="pill">net <b>${money(totNet)}</b></span>
|
||||
${totHrs?`<span class="pill"><b>${totHrs.toFixed(1)}h</b></span>`:''}
|
||||
${totHrs?`<span class="pill">${money(Math.round(totNet/totHrs))}/hr</span>`:''}
|
||||
</div>`;
|
||||
|
||||
root.innerHTML=summary+sessions.map(s=>{
|
||||
const title=[s.stakes,s.game].filter(Boolean).join(' ')||'Session';
|
||||
const live=s.status==='live'?'<span class="live">live</span>':'';
|
||||
const date=(s.started_at||'').slice(0,10);
|
||||
const meta=[date,s.venue,`${s.hands} hand${s.hands===1?'':'s'}`,
|
||||
s.hours?`${(+s.hours).toFixed(1)}h`:''].filter(Boolean).join(' · ');
|
||||
const href=`/session?id=${s.id}`; // read-only HUD detail for any session
|
||||
const net=s.net!=null?money(s.net):(s.status==='live'?'live':'—');
|
||||
return `<div class="row">
|
||||
<a class="body" href="${href}">
|
||||
<div class="ln1">${esc(title)} <span style="color:var(--fade)">@ ${esc(s.venue||'?')}</span>${live}</div>
|
||||
<div class="ln2">${esc(meta)}${s.has_recap?' · recap ✓':''}</div>
|
||||
</a>
|
||||
<span class="net ${netClass(s.net)}">${net}</span>
|
||||
<button class="del" title="Delete session" onclick="del(${s.id}, '#${s.id} ${esc(title)}')">🗑</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}catch(e){root.innerHTML='<p class="empty">Couldn\'t load sessions.</p>';}
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
+311
-32
@@ -5,10 +5,14 @@
|
||||
<title>Lyra Core Chat</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<!-- PWA -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Lyra" />
|
||||
<meta name="theme-color" content="#070707" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" href="icon-192.png" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
|
||||
</head>
|
||||
@@ -21,8 +25,8 @@
|
||||
<div class="mobile-menu-section">
|
||||
<h4>Mode</h4>
|
||||
<select id="mobileMode">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="cortex">Cortex</option>
|
||||
<option value="conversation">💬 Talk</option>
|
||||
<option value="poker_cash">♠ Cash</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -35,11 +39,11 @@
|
||||
|
||||
<div class="mobile-menu-section">
|
||||
<h4>Actions</h4>
|
||||
<button id="mobileSessionBtn">🎬 Session HUD</button>
|
||||
<button id="mobileHistoryBtn">📚 Past Sessions</button>
|
||||
<button id="mobileThinkingStreamBtn">📜 Live Log (inline)</button>
|
||||
<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>
|
||||
@@ -55,10 +59,13 @@
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
<span class="brand">Lyra</span>
|
||||
<span class="brand-dot" id="brandDot" title="Relay status"></span>
|
||||
<button class="mode-badge" id="modeBadge" type="button" title="Tap to toggle Talk / Cash mode">💬 Talk</button>
|
||||
<label for="mode">Mode:</label>
|
||||
<select id="mode">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="cortex">Cortex</option>
|
||||
<option value="conversation">💬 Talk</option>
|
||||
<option value="poker_cash">♠ Cash</option>
|
||||
</select>
|
||||
<button id="settingsBtn" style="margin-left: auto;">⚙ Settings</button>
|
||||
<div id="theme-toggle">
|
||||
@@ -73,9 +80,6 @@
|
||||
<button id="newSessionBtn">➕ New</button>
|
||||
<button id="renameSessionBtn">✏️ Rename</button>
|
||||
<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 -->
|
||||
@@ -107,9 +111,18 @@
|
||||
|
||||
<!-- Input box -->
|
||||
<div id="input">
|
||||
<input id="userInput" type="text" placeholder="Type a message..." autofocus />
|
||||
<button id="sendBtn">Send</button>
|
||||
<textarea id="userInput" rows="1" placeholder="Type a message…" autofocus></textarea>
|
||||
<button id="sendBtn" aria-label="Send" title="Send (or ⌘/Ctrl+Enter)">↑</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom tab bar (mobile only; hides while the keyboard is open) -->
|
||||
<nav id="tabbar" aria-label="Primary navigation">
|
||||
<a class="tab active" href="/" aria-current="page"><span class="ti">💬</span><span class="tl">Chat</span></a>
|
||||
<a class="tab" href="/session"><span class="ti">🎬</span><span class="tl">Session</span></a>
|
||||
<a class="tab" href="/hands"><span class="ti">🃏</span><span class="tl">Hands</span></a>
|
||||
<a class="tab" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
|
||||
<button class="tab" id="moreTab" type="button"><span class="ti">⋯</span><span class="tl">More</span></button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal (outside chat container) -->
|
||||
@@ -174,6 +187,7 @@
|
||||
<script>
|
||||
const RELAY_BASE = ""; // same-origin: served by lyra.web.server
|
||||
const API_URL = `${RELAY_BASE}/v1/chat/completions`;
|
||||
const STREAM_URL = `${RELAY_BASE}/v1/chat/stream`;
|
||||
|
||||
function generateSessionId() {
|
||||
return "sess-" + Math.random().toString(36).substring(2, 10);
|
||||
@@ -268,6 +282,8 @@
|
||||
if (!msg) return;
|
||||
inputEl.value = "";
|
||||
|
||||
autoGrow(inputEl); // collapse the box back to one line after clearing
|
||||
|
||||
addMessage("user", msg);
|
||||
history.push({ role: "user", content: msg });
|
||||
await saveSession(); // ✅ persist both user + assistant messages
|
||||
@@ -285,6 +301,10 @@
|
||||
// Which chat backend to use (local Ollama vs cloud OpenAI).
|
||||
let backend = localStorage.getItem("standardModeBackend") || "local";
|
||||
|
||||
// Cash mode is useless without tools, and tools only fire on cloud — so a
|
||||
// live poker session forces the cloud backend regardless of the saved pick.
|
||||
if (mode === "poker_cash") backend = "cloud";
|
||||
|
||||
const body = {
|
||||
mode: mode,
|
||||
messages: history,
|
||||
@@ -302,21 +322,107 @@
|
||||
body.model = cloudModel;
|
||||
}
|
||||
|
||||
// Stream the reply token-by-token (SSE). Fall back to the blocking
|
||||
// endpoint only if nothing streamed (e.g. streaming unavailable).
|
||||
const div = createAssistantBubble();
|
||||
let full = "";
|
||||
try {
|
||||
const resp = await fetch(STREAM_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!resp.ok || !resp.body) throw new Error("HTTP " + resp.status);
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let i;
|
||||
while ((i = buf.indexOf("\n\n")) !== -1) {
|
||||
const frame = buf.slice(0, i).trim();
|
||||
buf = buf.slice(i + 2);
|
||||
if (!frame.startsWith("data:")) continue;
|
||||
let evt;
|
||||
try { evt = JSON.parse(frame.slice(5).trim()); } catch (e) { continue; }
|
||||
if (evt.type === "delta") {
|
||||
full += evt.payload;
|
||||
updateAssistantBubble(div, full);
|
||||
} else if (evt.type === "done") {
|
||||
if (evt.payload) full = evt.payload;
|
||||
} else if (evt.type === "error") {
|
||||
throw new Error(evt.payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!full) {
|
||||
div.remove();
|
||||
try {
|
||||
const resp = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
const reply = data.choices?.[0]?.message?.content || "(no reply)";
|
||||
addMessage("assistant", reply);
|
||||
history.push({ role: "assistant", content: reply });
|
||||
await saveSession();
|
||||
} catch (err) {
|
||||
addMessage("system", "Error: " + err.message);
|
||||
} catch (err2) {
|
||||
addMessage("system", "Error: " + err2.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Partial content arrived before the error — keep what we streamed.
|
||||
}
|
||||
|
||||
finalizeAssistantBubble(div, full || "(no reply)");
|
||||
history.push({ role: "assistant", content: full || "(no reply)" });
|
||||
await saveSession();
|
||||
|
||||
// If she opened a session this turn, the server auto-flips to Cash mode —
|
||||
// reflect that here so the badge/HUD follow without a manual switch.
|
||||
if (document.getElementById("mode").value !== "poker_cash") {
|
||||
loadModeFor(currentSession);
|
||||
}
|
||||
}
|
||||
|
||||
function createAssistantBubble() {
|
||||
const messagesEl = document.getElementById("messages");
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg assistant streaming";
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight; // instant — no smooth chasing
|
||||
return div;
|
||||
}
|
||||
|
||||
// Coalesce token updates to one render per animation frame (avoids re-parsing
|
||||
// the whole message on every token, and the iOS ghosting from rapid repaints).
|
||||
function updateAssistantBubble(div, text) {
|
||||
div._pending = text;
|
||||
if (div._raf) return;
|
||||
div._raf = requestAnimationFrame(() => {
|
||||
div._raf = 0;
|
||||
const messagesEl = document.getElementById("messages");
|
||||
const stick = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 90;
|
||||
div.innerHTML = renderMarkdown(div._pending);
|
||||
div.dataset.raw = div._pending;
|
||||
if (stick) messagesEl.scrollTop = messagesEl.scrollHeight; // follow only if near bottom
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeAssistantBubble(div, text) {
|
||||
if (div._raf) { cancelAnimationFrame(div._raf); div._raf = 0; } // drop any queued render
|
||||
div.classList.remove("streaming");
|
||||
div.innerHTML = renderMarkdown(text);
|
||||
div.dataset.raw = text;
|
||||
addRateBar(div);
|
||||
const messagesEl = document.getElementById("messages");
|
||||
requestAnimationFrame(() => messagesEl.scrollTo({ top: messagesEl.scrollHeight, behavior: "smooth" }));
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
@@ -364,6 +470,7 @@
|
||||
up.addEventListener("click", () => rateMessage(div, 1, up, down));
|
||||
down.addEventListener("click", () => rateMessage(div, -1, up, down));
|
||||
bar.appendChild(up); bar.appendChild(down);
|
||||
bar.appendChild(makeCopyBtn(() => div.dataset.raw || div.textContent || ""));
|
||||
div.appendChild(bar);
|
||||
}
|
||||
|
||||
@@ -382,6 +489,80 @@
|
||||
down.classList.toggle("rated", value === -1);
|
||||
}
|
||||
|
||||
// Copy text to the clipboard. Uses the async Clipboard API when available
|
||||
// (HTTPS / localhost), and falls back to a hidden-textarea + execCommand for
|
||||
// iOS over plain-HTTP LAN (where navigator.clipboard is undefined).
|
||||
function copyToClipboard(text) {
|
||||
text = text == null ? "" : String(text);
|
||||
// Only trust the async Clipboard API in a secure context; on the LAN PWA
|
||||
// (plain HTTP) it's either absent or resolves without actually copying, so
|
||||
// we go straight to the iOS-tuned execCommand path there.
|
||||
if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
|
||||
}
|
||||
return legacyCopy(text);
|
||||
}
|
||||
function legacyCopy(text) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
// iOS will only copy from a readOnly + contentEditable field with a real
|
||||
// Range selection; readOnly also stops the keyboard from popping.
|
||||
ta.readOnly = true;
|
||||
ta.contentEditable = "true";
|
||||
ta.style.position = "fixed";
|
||||
ta.style.top = "0";
|
||||
ta.style.left = "0";
|
||||
ta.style.width = "1px";
|
||||
ta.style.height = "1px";
|
||||
ta.style.fontSize = "16px"; // avoid iOS zoom side-effects
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(ta);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
ta.setSelectionRange(0, text.length); // the bit iOS actually needs
|
||||
let ok = false;
|
||||
try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
|
||||
sel.removeAllRanges();
|
||||
document.body.removeChild(ta);
|
||||
ok ? resolve() : reject(new Error("copy failed"));
|
||||
});
|
||||
}
|
||||
// A small per-message copy button. getText is read at click time.
|
||||
function makeCopyBtn(getText) {
|
||||
const b = document.createElement("button");
|
||||
b.className = "copy-btn";
|
||||
b.type = "button";
|
||||
b.textContent = "⧉";
|
||||
b.title = "Copy message";
|
||||
b.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const text = typeof getText === "function" ? getText() : getText;
|
||||
copyToClipboard(text)
|
||||
.then(() => {
|
||||
b.textContent = "✓"; b.classList.add("copied");
|
||||
setTimeout(() => { b.textContent = "⧉"; b.classList.remove("copied"); }, 1200);
|
||||
})
|
||||
.catch(() => {
|
||||
// Last resort (some iOS configs block programmatic copy): surface the
|
||||
// text in a prompt so it can be selected + copied by hand.
|
||||
window.prompt("Copy this message:", text);
|
||||
b.textContent = "⧉";
|
||||
});
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
// Grow the input textarea to fit its content (up to a cap, then it scrolls).
|
||||
function autoGrow(el) {
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 140) + "px";
|
||||
}
|
||||
|
||||
function addMessage(role, text, autoScroll = true) {
|
||||
const messagesEl = document.getElementById("messages");
|
||||
|
||||
@@ -393,6 +574,12 @@
|
||||
addRateBar(msgDiv);
|
||||
} else {
|
||||
msgDiv.textContent = text;
|
||||
if (role === "user") {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "rate-bar";
|
||||
bar.appendChild(makeCopyBtn(() => text));
|
||||
msgDiv.appendChild(bar);
|
||||
}
|
||||
}
|
||||
messagesEl.appendChild(msgDiv);
|
||||
|
||||
@@ -406,22 +593,102 @@
|
||||
}
|
||||
|
||||
|
||||
// ----- Conversation mode (Talk / Cash) -----
|
||||
const MODE_LABELS = { conversation: "💬 Talk", poker_cash: "♠ Cash" };
|
||||
|
||||
// Reflect a mode value across the controls + header accent (no network call).
|
||||
function applyMode(value) {
|
||||
if (!MODE_LABELS[value]) value = "conversation";
|
||||
const desk = document.getElementById("mode");
|
||||
const mob = document.getElementById("mobileMode");
|
||||
const badge = document.getElementById("modeBadge");
|
||||
if (desk) desk.value = value;
|
||||
if (mob) mob.value = value;
|
||||
if (badge) badge.textContent = MODE_LABELS[value];
|
||||
document.body.classList.toggle("cash-mode", value === "poker_cash");
|
||||
localStorage.setItem("lyraMode", value);
|
||||
}
|
||||
|
||||
// User picked a mode: apply locally + persist it to this session on the server.
|
||||
async function chooseMode(value) {
|
||||
applyMode(value);
|
||||
if (!currentSession) return;
|
||||
try {
|
||||
await fetch(`${RELAY_BASE}/sessions/${currentSession}/mode`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: value })
|
||||
});
|
||||
} catch (e) { /* non-fatal: the mode still rides along in the chat body */ }
|
||||
}
|
||||
|
||||
// Pull the active mode for a session from the server (fallback: last local choice).
|
||||
async function loadModeFor(sessionId) {
|
||||
let value = localStorage.getItem("lyraMode") || "conversation";
|
||||
try {
|
||||
const r = await fetch(`${RELAY_BASE}/sessions/${sessionId}/mode`);
|
||||
if (r.ok) { const d = await r.json(); if (d.mode) value = d.mode; }
|
||||
} catch (e) { /* keep the local fallback */ }
|
||||
applyMode(value);
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const resp = await fetch(API_URL.replace("/v1/chat/completions", "/_health"));
|
||||
if (resp.ok) {
|
||||
document.getElementById("status-dot").className = "dot ok";
|
||||
document.getElementById("status-text").textContent = "Relay Online";
|
||||
document.getElementById("brandDot").className = "brand-dot ok";
|
||||
} else {
|
||||
throw new Error("Bad status");
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById("status-dot").className = "dot fail";
|
||||
document.getElementById("status-text").textContent = "Relay Offline";
|
||||
document.getElementById("brandDot").className = "brand-dot fail";
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// --- PWA: track the *visible* viewport height so the layout follows the
|
||||
// iOS keyboard and the dynamic Safari toolbars (keeps the input bar visible
|
||||
// instead of hiding behind the keyboard). Falls back to 100dvh via CSS.
|
||||
function setAppHeight() {
|
||||
const vv = window.visualViewport;
|
||||
const h = (vv && vv.height) || window.innerHeight;
|
||||
const off = (vv && vv.offsetTop) || 0;
|
||||
const root = document.documentElement.style;
|
||||
root.setProperty("--app-height", h + "px");
|
||||
// iOS pans the visual viewport when the keyboard opens; follow its top
|
||||
// edge so the pinned #chat sits exactly in the visible area.
|
||||
root.setProperty("--app-offset", off + "px");
|
||||
// Keyboard open ⇒ hide the bottom tab bar so the input pins to the keyboard.
|
||||
document.body.classList.toggle("kb", (window.innerHeight - h) > 150);
|
||||
}
|
||||
// Re-measure across the keyboard animation: iOS reports a stale (too-short)
|
||||
// height mid-animation, so sample a few times until it settles.
|
||||
function nudgeAppHeight() {
|
||||
setAppHeight();
|
||||
[50, 150, 300, 550].forEach((t) => setTimeout(setAppHeight, t));
|
||||
}
|
||||
setAppHeight();
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener("resize", nudgeAppHeight);
|
||||
window.visualViewport.addEventListener("scroll", setAppHeight);
|
||||
}
|
||||
window.addEventListener("resize", nudgeAppHeight);
|
||||
window.addEventListener("orientationchange", nudgeAppHeight);
|
||||
|
||||
// Keep the latest message in view when the keyboard opens/closes.
|
||||
const userInputEl = document.getElementById("userInput");
|
||||
userInputEl.addEventListener("focus", () => {
|
||||
nudgeAppHeight();
|
||||
setTimeout(() => {
|
||||
const m = document.getElementById("messages");
|
||||
m.scrollTo({ top: m.scrollHeight, behavior: "smooth" });
|
||||
}, 350);
|
||||
});
|
||||
userInputEl.addEventListener("blur", nudgeAppHeight);
|
||||
|
||||
// Mobile Menu Toggle
|
||||
const hamburgerMenu = document.getElementById("hamburgerMenu");
|
||||
const mobileMenu = document.getElementById("mobileMenu");
|
||||
@@ -441,20 +708,22 @@
|
||||
|
||||
hamburgerMenu.addEventListener("click", toggleMobileMenu);
|
||||
mobileMenuOverlay.addEventListener("click", closeMobileMenu);
|
||||
document.getElementById("moreTab").addEventListener("click", toggleMobileMenu);
|
||||
|
||||
// Sync mobile menu controls with desktop
|
||||
// Mode controls (Talk / Cash): the desktop select, the mobile-menu select,
|
||||
// and the always-visible header badge all funnel through chooseMode.
|
||||
const mobileMode = document.getElementById("mobileMode");
|
||||
const desktopMode = document.getElementById("mode");
|
||||
const modeBadge = document.getElementById("modeBadge");
|
||||
|
||||
// Sync mode selection
|
||||
mobileMode.addEventListener("change", (e) => {
|
||||
desktopMode.value = e.target.value;
|
||||
desktopMode.dispatchEvent(new Event("change"));
|
||||
});
|
||||
desktopMode.addEventListener("change", (e) => chooseMode(e.target.value));
|
||||
mobileMode.addEventListener("change", (e) => { closeMobileMenu(); chooseMode(e.target.value); });
|
||||
modeBadge.addEventListener("click", () =>
|
||||
chooseMode(desktopMode.value === "poker_cash" ? "conversation" : "poker_cash"));
|
||||
|
||||
desktopMode.addEventListener("change", (e) => {
|
||||
mobileMode.value = e.target.value;
|
||||
});
|
||||
// Reflect the last-used mode immediately; the per-session value loads once
|
||||
// the current session is known (below).
|
||||
applyMode(localStorage.getItem("lyraMode") || "conversation");
|
||||
|
||||
// Mobile theme toggle
|
||||
document.getElementById("mobileToggleThemeBtn").addEventListener("click", () => {
|
||||
@@ -564,6 +833,7 @@
|
||||
// Load current session history
|
||||
if (currentSession) {
|
||||
await loadSession(currentSession);
|
||||
await loadModeFor(currentSession);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -574,6 +844,7 @@
|
||||
localStorage.setItem("currentSession", currentSession);
|
||||
addMessage("system", `Switched to session: ${getSessionName(currentSession)}`);
|
||||
await loadSession(currentSession);
|
||||
await loadModeFor(currentSession);
|
||||
});
|
||||
|
||||
// Create new session
|
||||
@@ -714,6 +985,9 @@
|
||||
loadSessionList(); // Refresh session list when opening settings
|
||||
});
|
||||
|
||||
// Sidebar "Settings" from another page navigates here with ?settings=1.
|
||||
if (new URLSearchParams(location.search).get("settings")) settingsBtn.click();
|
||||
|
||||
// Hide modal functions
|
||||
const hideModal = () => {
|
||||
settingsModal.classList.remove("show");
|
||||
@@ -748,11 +1022,15 @@
|
||||
checkHealth();
|
||||
setInterval(checkHealth, 10000);
|
||||
|
||||
// Input events
|
||||
// Input events. Enter inserts a newline and grows the box (like the Claude
|
||||
// app) — you tap the arrow to send. ⌘/Ctrl+Enter sends from the keyboard.
|
||||
document.getElementById("sendBtn").addEventListener("click", sendMessage);
|
||||
document.getElementById("userInput").addEventListener("keypress", e => {
|
||||
if (e.key === "Enter") sendMessage();
|
||||
const inputBox = document.getElementById("userInput");
|
||||
inputBox.addEventListener("input", () => autoGrow(inputBox));
|
||||
inputBox.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendMessage(); }
|
||||
});
|
||||
autoGrow(inputBox);
|
||||
|
||||
// ========== THINKING STREAM INTEGRATION ==========
|
||||
const thinkingPanel = document.getElementById("thinkingPanel");
|
||||
@@ -884,14 +1162,14 @@
|
||||
document.getElementById("mobileFullLogBtn").addEventListener("click", () => {
|
||||
closeMobileMenu(); window.location.href = "/logs";
|
||||
});
|
||||
document.getElementById("mobileMindBtn").addEventListener("click", () => {
|
||||
closeMobileMenu(); window.location.href = "/self";
|
||||
});
|
||||
document.getElementById("mobileJournalBtn").addEventListener("click", () => {
|
||||
closeMobileMenu(); window.location.href = "/journal";
|
||||
});
|
||||
document.getElementById("mobileHandsBtn").addEventListener("click", () => {
|
||||
closeMobileMenu(); window.location.href = "/hands";
|
||||
document.getElementById("mobileSessionBtn").addEventListener("click", () => {
|
||||
closeMobileMenu(); window.location.href = "/session";
|
||||
});
|
||||
document.getElementById("mobileHistoryBtn").addEventListener("click", () => {
|
||||
closeMobileMenu(); window.location.href = "/history";
|
||||
});
|
||||
|
||||
// Connect to the global live log on page load.
|
||||
@@ -907,5 +1185,6 @@
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -157,5 +157,6 @@
|
||||
setInterval(load, 20000);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -235,5 +235,6 @@
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
{
|
||||
"name": "Lyra Chat",
|
||||
"name": "Lyra",
|
||||
"short_name": "Lyra",
|
||||
"description": "Lyra — chat, mind, journal, and poker copilot.",
|
||||
"start_url": "./index.html",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#181818",
|
||||
"theme_color": "#181818",
|
||||
"display_override": ["standalone", "minimal-ui"],
|
||||
"orientation": "portrait",
|
||||
"background_color": "#070707",
|
||||
"theme_color": "#070707",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/* Shared app navigation — one source of truth across all pages (no build step).
|
||||
Injects a left sidebar on desktop (>=769px) with active-page highlighting; stays
|
||||
out of the way on mobile, where each page keeps its bottom bar / back-links. */
|
||||
(function () {
|
||||
const ITEMS = [
|
||||
{ href: "/", icon: "💬", label: "Chat" },
|
||||
{ href: "/session", icon: "♠", label: "Session" },
|
||||
{ href: "/history", icon: "📚", label: "History" },
|
||||
{ href: "/hands", icon: "🃏", label: "Hands" },
|
||||
{ href: "/self", icon: "🧠", label: "Mind" },
|
||||
{ href: "/thoughts", icon: "💭", label: "Thoughts" },
|
||||
{ href: "/journal", icon: "📔", label: "Journal" },
|
||||
{ href: "/logs", icon: "📜", label: "Logs" },
|
||||
];
|
||||
|
||||
const path = location.pathname;
|
||||
function isActive(href) {
|
||||
if (href === "/") return path === "/" || path === "";
|
||||
if (href === "/hands") return path === "/hands" || path.indexOf("/hand") === 0;
|
||||
if (href === "/history") return path.indexOf("/history") === 0 || path.indexOf("/recap") === 0;
|
||||
return path === href || path.indexOf(href + "/") === 0;
|
||||
}
|
||||
|
||||
const css = `
|
||||
#app-nav { display: none; }
|
||||
@media screen and (min-width: 769px) {
|
||||
body { padding-left: 212px; }
|
||||
#app-nav {
|
||||
position: fixed; left: 0; top: 0; bottom: 0; width: 212px; z-index: 1000;
|
||||
display: flex; flex-direction: column; gap: 2px; box-sizing: border-box;
|
||||
padding: 14px 10px; background: #0b0b0b; border-right: 1px solid #2a1d12;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
#app-nav .brand {
|
||||
display: flex; align-items: center; gap: 8px; text-decoration: none;
|
||||
color: #ff7a00; font-weight: 700; font-size: 1.15rem; letter-spacing: .5px;
|
||||
padding: 6px 11px 14px;
|
||||
}
|
||||
#app-nav .brand .dot { width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #8fd694; box-shadow: 0 0 8px rgba(143,214,148,.6); }
|
||||
#app-nav .navitem {
|
||||
display: flex; align-items: center; gap: 11px; width: 100%; text-align: left;
|
||||
padding: 9px 11px; border-radius: 9px; border: none; background: none;
|
||||
color: #cfcfcf; text-decoration: none; font-size: .95rem; cursor: pointer;
|
||||
font-family: inherit; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
#app-nav .navitem .i { font-size: 1.05rem; width: 20px; text-align: center; filter: grayscale(.3); }
|
||||
#app-nav .navitem:hover { background: rgba(255,122,0,.08); color: #fff; }
|
||||
#app-nav .navitem.active { background: rgba(255,122,0,.14); color: #ff7a00; }
|
||||
#app-nav .navitem.active .i { filter: none; }
|
||||
#app-nav .spacer { flex: 1; }
|
||||
}`;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const nav = document.createElement("nav");
|
||||
nav.id = "app-nav";
|
||||
nav.setAttribute("aria-label", "App navigation");
|
||||
nav.innerHTML =
|
||||
'<a class="brand" href="/"><span class="dot"></span> Lyra</a>' +
|
||||
ITEMS.map(function (it) {
|
||||
return '<a class="navitem' + (isActive(it.href) ? " active" : "") + '" href="' + it.href + '">' +
|
||||
'<span class="i">' + it.icon + '</span><span class="l">' + it.label + "</span></a>";
|
||||
}).join("") +
|
||||
'<div class="spacer"></div>' +
|
||||
'<button class="navitem" id="navSettings" type="button"><span class="i">⚙</span><span class="l">Settings</span></button>';
|
||||
document.body.insertBefore(nav, document.body.firstChild);
|
||||
|
||||
// Settings opens the chat-page modal; from other pages, jump to chat and open it.
|
||||
nav.querySelector("#navSettings").addEventListener("click", function () {
|
||||
const btn = document.getElementById("settingsBtn");
|
||||
if (btn) btn.click();
|
||||
else location.href = "/?settings=1";
|
||||
});
|
||||
})();
|
||||
@@ -74,5 +74,6 @@
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -195,5 +195,6 @@
|
||||
setInterval(refresh, 12000);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
<!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 — Session</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #070707; --bg-elev: #0e0e0e; --bg-line: #141414; --border: #2a1d12;
|
||||
--text: #e8e8e8; --fade: #8a8a8a; --accent: #ff7a00;
|
||||
--good: #8fd694; --mid: #ffb347; --low: #ff6b6b;
|
||||
}
|
||||
* { 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 12px; }
|
||||
.topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
|
||||
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
|
||||
.updated { margin-left: auto; color: var(--fade); font-size: .78rem; }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--good); box-shadow: 0 0 8px var(--good); flex: none; opacity: .35; transition: opacity .2s; }
|
||||
.dot.pulse { opacity: 1; }
|
||||
|
||||
main { max-width: 680px; margin: 0 auto; padding: 16px 14px 40px; }
|
||||
.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 14px; padding: 16px; margin-bottom: 14px; }
|
||||
.label { color: var(--fade); font-size: .72rem; text-transform: uppercase; letter-spacing: .6px; margin: 0 0 10px; }
|
||||
|
||||
/* Header card */
|
||||
.sess-top { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.sess-title { font-size: 1.25rem; font-weight: 700; }
|
||||
.sess-sub { color: var(--fade); font-size: .9rem; }
|
||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.chip { font-size: .8rem; color: var(--fade); background: var(--bg-line); border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; }
|
||||
.chip b { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* Stack card */
|
||||
.stack-row { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
|
||||
.stack-now { font-size: 2.3rem; font-weight: 800; letter-spacing: .2px; font-variant-numeric: tabular-nums; }
|
||||
.net { font-size: 1.2rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.net.up { color: var(--good); } .net.down { color: var(--low); } .net.flat { color: var(--fade); }
|
||||
.stack-meta { color: var(--fade); font-size: .85rem; margin-left: auto; text-align: right; }
|
||||
svg.spark { display: block; width: 100%; height: 56px; margin-top: 14px; }
|
||||
|
||||
/* Hands */
|
||||
ul.rows { list-style: none; margin: 0; padding: 0; }
|
||||
ul.rows li { padding: 10px 0; border-bottom: 1px solid var(--bg-line); font-size: .95rem; line-height: 1.45; }
|
||||
ul.rows li:last-child { border-bottom: none; }
|
||||
a.hand { color: var(--text); text-decoration: none; display: flex; gap: 8px; align-items: baseline; }
|
||||
a.hand:hover { color: var(--accent); }
|
||||
.pos { color: var(--accent); font-weight: 700; min-width: 38px; }
|
||||
.cards { font-variant-numeric: tabular-nums; }
|
||||
.res { margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
.res.up { color: var(--good); } .res.down { color: var(--low); }
|
||||
.tag { font-size: .7rem; color: var(--mid); border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px; }
|
||||
.villain b { color: var(--text); } .villain .cat { color: var(--mid); font-size: .78rem; }
|
||||
.note-meta { color: var(--fade); font-size: .72rem; }
|
||||
|
||||
/* Rituals */
|
||||
.gator {
|
||||
display: flex; align-items: center; gap: 12px; background: #1a2e10;
|
||||
border: 1px solid #3c6b1e; border-radius: 14px; padding: 14px 16px; margin-bottom: 14px;
|
||||
}
|
||||
.gator .ico { font-size: 1.7rem; }
|
||||
.gator b { color: #b6e88a; } .gator .sub { color: #8fbf6a; font-size: .82rem; }
|
||||
.scar-cls {
|
||||
font-size: .68rem; text-transform: uppercase; letter-spacing: .4px; border-radius: 999px;
|
||||
padding: 1px 7px; border: 1px solid var(--border); margin-left: 6px;
|
||||
}
|
||||
.scar-cls.punt { color: var(--low); border-color: var(--low); }
|
||||
.scar-cls.cooler { color: var(--mid); border-color: var(--mid); }
|
||||
.scar-cls.standard { color: var(--fade); }
|
||||
.card.scar { border-color: #4a2222; } .card.scar .label { color: #d98a8a; }
|
||||
.card.conf { border-color: #234a23; } .card.conf .label { color: var(--good); }
|
||||
/* per-row delete (fix fat-fingered live logging) */
|
||||
li.row-del { display: flex; align-items: center; gap: 8px; }
|
||||
li.row-del > a.hand, li.row-del > .row-body { flex: 1; min-width: 0; }
|
||||
.del-x { flex: none; background: none; border: none; color: var(--fade); font-size: 1.15rem;
|
||||
line-height: 1; padding: 2px 6px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
.del-x:active { color: var(--low); }
|
||||
/* session edit form */
|
||||
.edit-btn { margin-left: auto; background: #241400; border: 1px solid var(--border); color: var(--accent);
|
||||
border-radius: 8px; padding: 5px 10px; font-size: .8rem; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
.mantra { color: var(--mid); font-style: italic; font-size: .9rem; margin-top: 10px; }
|
||||
.edit-form { grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 14px; }
|
||||
.edit-form label { display: flex; flex-direction: column; gap: 4px; font-size: .68rem;
|
||||
color: var(--fade); text-transform: uppercase; letter-spacing: .4px; }
|
||||
.edit-form label.wide { grid-column: 1 / -1; }
|
||||
.edit-form input { background: var(--bg-line); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 8px 10px; color: var(--text); font-size: 16px; }
|
||||
.edit-form input:focus { outline: none; border-color: var(--accent); }
|
||||
.edit-actions { grid-column: 1 / -1; display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.edit-actions button { background: var(--bg-line); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 8px; padding: 8px 16px; cursor: pointer; }
|
||||
.edit-actions button.save { background: var(--accent); color: #0a0a0a; border-color: var(--accent); font-weight: 600; }
|
||||
.empty { color: var(--fade); font-size: .92rem; }
|
||||
.err { color: var(--low); text-align: center; padding: 30px; }
|
||||
.big-empty { text-align: center; padding: 50px 20px; color: var(--fade); }
|
||||
.big-empty .ico { font-size: 2.4rem; }
|
||||
.big-empty a { color: var(--accent); text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<span class="dot" id="dot"></span>
|
||||
<h1>🎬 Session</h1>
|
||||
<a class="back" href="/">← Chat</a>
|
||||
<a class="back" href="/history" title="Past sessions">📚 Sessions</a>
|
||||
<a class="back" href="/hands" title="All recorded hands">🃏 Hands</a>
|
||||
<span class="updated" id="updated">—</span>
|
||||
</div>
|
||||
</header>
|
||||
<main id="root"><p class="err" id="boot">Loading the table…</p></main>
|
||||
|
||||
<script>
|
||||
const root = document.getElementById('root');
|
||||
const dot = document.getElementById('dot');
|
||||
const updatedEl = document.getElementById('updated');
|
||||
const SID = new URLSearchParams(location.search).get('id'); // past-session view when set
|
||||
let curSession = null; // the session object currently rendered (for the edit form)
|
||||
|
||||
function esc(s){ const d=document.createElement('div'); d.textContent = s==null?'':String(s); return d.innerHTML; }
|
||||
function money(v){ if (v == null) return '—'; const n = Number(v); return (n<0?'-$':'$') + Math.abs(n).toLocaleString(); }
|
||||
function signed(v){ if (v == null) return '—'; const n = Number(v); return (n>0?'+$':n<0?'-$':'$') + Math.abs(n).toLocaleString(); }
|
||||
|
||||
function ago(iso){
|
||||
if(!iso) return '—';
|
||||
const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
|
||||
if(s < 60) return 'just now';
|
||||
if(s < 3600) return Math.round(s/60)+'m ago';
|
||||
if(s < 86400) return Math.round(s/3600)+'h ago';
|
||||
return Math.round(s/86400)+'d ago';
|
||||
}
|
||||
function elapsed(iso){
|
||||
if(!iso) return '—';
|
||||
const s = Math.max(0, (Date.now() - new Date(iso).getTime())/1000);
|
||||
const h = Math.floor(s/3600), m = Math.round((s%3600)/60);
|
||||
return h ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
// For a live session: time since start. For a closed one: actual played duration.
|
||||
function clock(sess){
|
||||
if(sess.is_live) return elapsed(sess.started_at);
|
||||
if(sess.hours != null) return (+sess.hours).toFixed(1) + 'h';
|
||||
if(sess.started_at && sess.ended_at){
|
||||
const s = Math.max(0,(new Date(sess.ended_at)-new Date(sess.started_at))/1000);
|
||||
const h=Math.floor(s/3600), m=Math.round((s%3600)/60); return h?`${h}h ${m}m`:`${m}m`;
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
// Tiny inline sparkline of the stack-over-time series.
|
||||
function sparkline(series){
|
||||
const pts = series.map(p => Number(p.amount)).filter(n => !isNaN(n));
|
||||
if (pts.length < 2) return '';
|
||||
const W = 600, H = 56, pad = 4;
|
||||
const min = Math.min(...pts), max = Math.max(...pts), span = (max - min) || 1;
|
||||
const x = i => pad + (i / (pts.length - 1)) * (W - 2*pad);
|
||||
const y = v => H - pad - ((v - min) / span) * (H - 2*pad);
|
||||
const d = pts.map((v,i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
|
||||
const last = pts[pts.length-1], first = pts[0];
|
||||
const col = last >= first ? 'var(--good)' : 'var(--low)';
|
||||
return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
|
||||
<polyline points="${d}" fill="none" stroke="${col}" stroke-width="2"
|
||||
stroke-linejoin="round" stroke-linecap="round" />
|
||||
<circle cx="${x(pts.length-1).toFixed(1)}" cy="${y(last).toFixed(1)}" r="3" fill="${col}" />
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function netClass(v){ return v == null ? 'flat' : v > 0 ? 'up' : v < 0 ? 'down' : 'flat'; }
|
||||
|
||||
function toggleEdit(){
|
||||
const f = document.getElementById('editForm');
|
||||
if(f) f.style.display = (f.style.display === 'none' || !f.style.display) ? 'grid' : 'none';
|
||||
}
|
||||
async function saveEdit(){
|
||||
if(!curSession) return;
|
||||
const body = {};
|
||||
for(const k of ['venue','stakes','game','format','buy_in_total','cash_out','mantra','mood']){
|
||||
const el = document.getElementById('ed_'+k);
|
||||
if(!el) continue;
|
||||
let v = el.value.trim();
|
||||
if(v === '') continue;
|
||||
body[k] = (k==='buy_in_total'||k==='cash_out') ? Number(v) : v;
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/session/' + curSession.id, {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
if(!r.ok) throw new Error('HTTP '+r.status);
|
||||
toggleEdit(); refresh();
|
||||
} catch(e){ alert('Save failed: '+e.message); }
|
||||
}
|
||||
|
||||
// Delete one logged entry (hand | ritual | read | stack), then refresh.
|
||||
async function del(kind, id){
|
||||
if(!confirm('Delete this entry?')) return;
|
||||
try {
|
||||
const r = await fetch('/session/entry/'+kind+'/'+id, { method:'DELETE' });
|
||||
if(!r.ok) throw new Error('HTTP '+r.status);
|
||||
refresh();
|
||||
} catch(e){ alert('Delete failed: '+e.message); }
|
||||
}
|
||||
|
||||
function render(data){
|
||||
const s = data.session;
|
||||
if (!s) {
|
||||
root.innerHTML = `<div class="big-empty">
|
||||
<div class="ico">🪑</div>
|
||||
<p>No live session right now.<br>Start one from <a href="/">chat</a> — switch to ♠ Cash and tell Lyra you're sitting down.</p>
|
||||
</div>`;
|
||||
updatedEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
curSession = s;
|
||||
const stack = data.stack || {};
|
||||
const hands = data.hands || [];
|
||||
const villains = data.villains || [];
|
||||
const notes = data.notes || [];
|
||||
const stats = data.stats || {};
|
||||
const rituals = data.rituals || {};
|
||||
const scars = rituals.scars || [];
|
||||
const confidence = rituals.confidence || [];
|
||||
const resets = rituals.resets || [];
|
||||
|
||||
const title = [s.stakes, s.game].filter(Boolean).join(' ') || 'Session';
|
||||
const tagBits = Object.entries(stats.tags || {}).map(([k,v]) => `${k}×${v}`).join(' · ');
|
||||
|
||||
root.innerHTML = `
|
||||
${rituals.alligator ? `<div class="gator">
|
||||
<span class="ico">🐊</span>
|
||||
<div><b>Alligator Blood</b><div class="sub">refuse to die · no forced miracles · make them beat you correctly</div></div>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="card">
|
||||
<div class="sess-top">
|
||||
<span class="sess-title">${esc(title)}</span>
|
||||
<span class="sess-sub">${esc(s.venue || 'unknown room')}${!s.is_live && s.status ? ' · '+esc(s.status) : ''}</span>
|
||||
<button class="edit-btn" onclick="toggleEdit()" title="Edit session details">✎ Edit</button>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">⏱ <b>${clock(s)}</b></span>
|
||||
<span class="chip">in <b>${money(s.buy_in_total)}</b></span>
|
||||
${!s.is_live && s.net != null ? `<span class="chip">net <b class="${netClass(s.net)}" style="font-weight:700">${signed(s.net)}</b></span>` : ''}
|
||||
<span class="chip">${esc(s.format || 'cash')}</span>
|
||||
<span class="chip"><b>${hands.length}</b> hands</span>
|
||||
${resets.length ? `<span class="chip">🔄 <b>${resets.length}</b> reset${resets.length>1?'s':''}</span>` : ''}
|
||||
${s.has_recap ? `<a class="chip" style="color:var(--accent);text-decoration:none" href="/recap/${s.id}">📝 recap</a>` : ''}
|
||||
</div>
|
||||
${s.mantra ? `<div class="mantra">“${esc(s.mantra)}”</div>` : ''}
|
||||
<div id="editForm" class="edit-form" style="display:none">
|
||||
<label>Venue<input id="ed_venue" value="${esc(s.venue||'')}"></label>
|
||||
<label>Stakes<input id="ed_stakes" value="${esc(s.stakes||'')}"></label>
|
||||
<label>Game<input id="ed_game" value="${esc(s.game||'')}"></label>
|
||||
<label>Format<input id="ed_format" value="${esc(s.format||'')}"></label>
|
||||
<label>Buy-in $<input id="ed_buy_in_total" type="number" value="${s.buy_in_total??''}"></label>
|
||||
<label>Cash-out $<input id="ed_cash_out" type="number" value="${s.cash_out??''}"></label>
|
||||
<label class="wide">Mantra<input id="ed_mantra" value="${esc(s.mantra||'')}"></label>
|
||||
<label class="wide">Mood<input id="ed_mood" value="${esc(s.mood||'')}"></label>
|
||||
<div class="edit-actions"><button onclick="saveEdit()" class="save">Save</button><button onclick="toggleEdit()">Cancel</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Stack</p>
|
||||
<div class="stack-row">
|
||||
<span class="stack-now">${stack.current == null ? '—' : money(stack.current)}</span>
|
||||
<span class="net ${netClass(stack.net)}">${stack.net == null ? '' : signed(stack.net)}</span>
|
||||
<span class="stack-meta">bought in ${money(stack.buy_in)}<br>${(stack.log||[]).length} update(s)</span>
|
||||
</div>
|
||||
${sparkline(stack.log || [])}
|
||||
${stack.current == null ? '<p class="empty" style="margin:12px 0 0">No stack logged yet — tell Lyra your stack ("I\'m at 350").</p>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Hands this session</p>
|
||||
${hands.length ? `<ul class="rows">${hands.slice().reverse().map(h => `
|
||||
<li class="row-del"><a class="hand" href="/hand/${h.id}">
|
||||
<span class="pos">${esc(h.position || '?')}</span>
|
||||
<span class="cards">${esc(h.hole_cards || '')}${h.board ? ' · '+esc(h.board) : ''}</span>
|
||||
${h.tag ? `<span class="tag">${esc(h.tag)}</span>` : ''}
|
||||
${h.result != null ? `<span class="res ${h.result>=0?'up':'down'}">${signed(h.result)}</span>` : ''}
|
||||
</a><button class="del-x" title="Delete hand" onclick="del('hand',${h.id})">×</button></li>`).join('')}</ul>`
|
||||
: '<p class="empty">No hands logged yet.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card conf">
|
||||
<p class="label">💰 Confidence Bank</p>
|
||||
${confidence.length ? `<ul class="rows">${confidence.slice().reverse().map(c => `
|
||||
<li class="row-del"><span class="row-body">${esc(c.content)}${c.hand_id ? ` · <a class="hand" style="display:inline" href="/hand/${c.hand_id}">hand</a>` : ''}
|
||||
<div class="note-meta">${ago(c.at)}</div></span><button class="del-x" title="Delete" onclick="del('ritual',${c.id})">×</button></li>`).join('')}</ul>`
|
||||
: '<p class="empty">Nothing banked yet — disciplined plays land here.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card scar">
|
||||
<p class="label">🩹 Scar Notes</p>
|
||||
${scars.length ? `<ul class="rows">${scars.slice().reverse().map(sc => `
|
||||
<li class="row-del"><span class="row-body">${esc(sc.content)}${sc.classification ? `<span class="scar-cls ${esc(sc.classification)}">${esc(sc.classification)}</span>` : ''}
|
||||
${sc.hand_id ? ` · <a class="hand" style="display:inline" href="/hand/${sc.hand_id}">hand</a>` : ''}
|
||||
<div class="note-meta">${ago(sc.at)}</div></span><button class="del-x" title="Delete" onclick="del('ritual',${sc.id})">×</button></li>`).join('')}</ul>`
|
||||
: '<p class="empty">No scars logged — mistakes to study land here.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Villains seen</p>
|
||||
${villains.length ? `<ul class="rows">${villains.map(v => `
|
||||
<li class="villain">
|
||||
<b>${esc(v.name)}</b> ${v.category ? `<span class="cat">[${esc(v.category)}]</span>` : ''}
|
||||
${v.tendencies ? `<div>${esc(v.tendencies)}</div>` : ''}
|
||||
${v.last_note ? `<div class="note-meta">“${esc(v.last_note)}”</div>` : ''}
|
||||
</li>`).join('')}</ul>`
|
||||
: '<p class="empty">No reads logged this session.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Her notes</p>
|
||||
${notes.length ? `<ul class="rows">${notes.map(n => `
|
||||
<li>${esc(n.content)}<div class="note-meta">${esc(n.kind)} · ${ago(n.created_at)}</div></li>`).join('')}</ul>`
|
||||
: '<p class="empty">Nothing jotted this session.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Session stats</p>
|
||||
<div class="chips">
|
||||
<span class="chip">logged <b>${stats.hands_logged ?? 0}</b></span>
|
||||
${tagBits ? `<span class="chip">${esc(tagBits)}</span>` : ''}
|
||||
${stats.context_per_hour != null ? `<span class="chip">${esc(title)} lifetime <b>${signed(stats.context_per_hour)}/hr</b></span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
updatedEl.textContent = 'updated ' + ago(data._fetched);
|
||||
}
|
||||
|
||||
async function refresh(){
|
||||
// don't clobber the edit form mid-edit on a poll tick
|
||||
const ef = document.getElementById('editForm');
|
||||
if (ef && ef.style.display === 'grid') return;
|
||||
try {
|
||||
const r = await fetch('/session/data' + (SID ? ('?id=' + encodeURIComponent(SID)) : ''), { cache: 'no-store' });
|
||||
const data = await r.json();
|
||||
data._fetched = new Date().toISOString();
|
||||
dot.classList.add('pulse'); setTimeout(() => dot.classList.remove('pulse'), 400);
|
||||
render(data);
|
||||
} catch (e) {
|
||||
if (!root.querySelector('.card')) root.innerHTML = '<p class="err">Couldn\'t reach the table. Is the server up?</p>';
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
if (!SID) setInterval(refresh, 5000); // live HUD polls; a past session is static
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+343
-121
@@ -1,31 +1,61 @@
|
||||
:root {
|
||||
--bg-dark: #070707;
|
||||
--bg-panel: rgba(255, 122, 0, 0.1);
|
||||
--bg-elev: #0e0e0e;
|
||||
--bg-line: #141414;
|
||||
--bg-panel: #0e0e0e;
|
||||
--border: #2a1d12;
|
||||
--border-bright: #4a2f15;
|
||||
--accent: #ff7a00;
|
||||
--accent-glow: 0 0 6px rgba(255,122,0,0.28);
|
||||
--gold: #ffb347;
|
||||
--good: #8fd694;
|
||||
--bad: #ff5a5a;
|
||||
--accent-soft: rgba(255, 122, 0, 0.10);
|
||||
--accent-glow: 0 0 6px rgba(255, 122, 0, 0.18);
|
||||
--text-main: #e8e8e8;
|
||||
--text-fade: #999;
|
||||
--font-console: "IBM Plex Mono", monospace;
|
||||
--text-fade: #8a8a8a;
|
||||
--font-console: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--font-voice: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Light mode variables */
|
||||
/* Light mode (secondary — Brian runs dark) */
|
||||
body {
|
||||
--bg-dark: #f5f5f5;
|
||||
--bg-panel: rgba(255, 122, 0, 0.05);
|
||||
--accent: #ff7a00;
|
||||
--accent-glow: 0 0 6px rgba(255,122,0,0.28);
|
||||
--bg-dark: #f5f3ef;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-line: #ece8e1;
|
||||
--bg-panel: #ffffff;
|
||||
--border: #e2dacb;
|
||||
--border-bright: #c9a87a;
|
||||
--accent: #c75e00;
|
||||
--gold: #b8791f;
|
||||
--good: #3f9a52;
|
||||
--bad: #c0392b;
|
||||
--accent-soft: rgba(199, 94, 0, 0.08);
|
||||
--accent-glow: none;
|
||||
--text-main: #1a1a1a;
|
||||
--text-fade: #666;
|
||||
--text-fade: #6a6a6a;
|
||||
--text: var(--text-main); /* alias: some rules reference var(--text) */
|
||||
}
|
||||
|
||||
/* Dark mode variables */
|
||||
/* Dark mode (primary — RTO warm low-glow) */
|
||||
body.dark {
|
||||
--bg-dark: #070707;
|
||||
--bg-panel: rgba(255, 122, 0, 0.1);
|
||||
--bg-elev: #0e0e0e;
|
||||
--bg-line: #141414;
|
||||
--bg-panel: #0e0e0e;
|
||||
--border: #2a1d12;
|
||||
--border-bright: #4a2f15;
|
||||
--accent: #ff7a00;
|
||||
--accent-glow: 0 0 6px rgba(255,122,0,0.28);
|
||||
--gold: #ffb347;
|
||||
--good: #8fd694;
|
||||
--bad: #ff5a5a;
|
||||
--accent-soft: rgba(255, 122, 0, 0.10);
|
||||
--accent-glow: 0 0 6px rgba(255, 122, 0, 0.18);
|
||||
--text-main: #e8e8e8;
|
||||
--text-fade: #999;
|
||||
--text-fade: #8a8a8a;
|
||||
}
|
||||
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -33,10 +63,13 @@ body {
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-console);
|
||||
height: 100vh;
|
||||
height: 100vh; /* fallback for old browsers */
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overscroll-behavior: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
#chat {
|
||||
@@ -45,9 +78,9 @@ body {
|
||||
height: 95vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--accent-glow);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
background: var(--bg-dark);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -58,109 +91,176 @@ body {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--accent);
|
||||
background-color: rgba(255, 122, 0, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--bg-elev);
|
||||
}
|
||||
#status {
|
||||
justify-content: flex-start;
|
||||
border-top: 1px solid var(--accent);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Mode badge: the always-visible Talk/Cash toggle. Hidden on desktop (the header
|
||||
<select> handles it there); shown in the minimal mobile header (see media query). */
|
||||
.mode-badge {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-console);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-fade);
|
||||
background: var(--bg-line);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 11px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
/* Cash mode: light up the badge (and the chat brand) so the table state is obvious. */
|
||||
body.cash-mode .mode-badge {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
body.cash-mode .brand { color: var(--accent); }
|
||||
|
||||
label, select, button {
|
||||
font-family: var(--font-console);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-main);
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-line);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 9px;
|
||||
transition: border-color .15s, background-color .15s;
|
||||
}
|
||||
label { background: transparent; border-color: transparent; padding-left: 0; }
|
||||
|
||||
button:hover, select:hover {
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
border-color: var(--border-bright);
|
||||
background: var(--accent-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#thinkingStreamBtn {
|
||||
background: rgba(255, 179, 71, 0.2);
|
||||
border-color: #ffb347;
|
||||
background: var(--bg-line);
|
||||
border-color: var(--border-bright);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
#thinkingStreamBtn:hover {
|
||||
box-shadow: 0 0 8px #ffb347;
|
||||
background: rgba(255, 179, 71, 0.3);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--gold);
|
||||
}
|
||||
|
||||
/* Chat area */
|
||||
#messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
scroll-behavior: smooth;
|
||||
/* No CSS smooth-scroll: during streaming, per-token smooth scrolls pile up and
|
||||
iOS Safari leaves ghost paint frames. Smooth is applied explicitly in JS where
|
||||
it's a one-shot (load/finalize). */
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.msg {
|
||||
max-width: 80%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
box-shadow: 0 0 8px rgba(255,122,0,0.2);
|
||||
box-shadow: none;
|
||||
}
|
||||
.msg.user {
|
||||
align-self: flex-end;
|
||||
background: rgba(255,122,0,0.15);
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--border-bright);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.msg.assistant {
|
||||
align-self: flex-start;
|
||||
background: rgba(255,122,0,0.08);
|
||||
border: 1px solid rgba(255,122,0,0.5);
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.msg.system {
|
||||
align-self: center;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-fade);
|
||||
text-align: center;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
/* Input bar */
|
||||
#input {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--accent);
|
||||
background: rgba(255, 122, 0, 0.05);
|
||||
align-items: flex-end; /* arrow stays at the bottom as the textarea grows */
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
padding: 10px;
|
||||
}
|
||||
#userInput {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
background: var(--bg-line);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 9px 12px;
|
||||
font-family: var(--font-console);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
resize: none; /* grown programmatically, not by the drag handle */
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
#userInput::placeholder { color: var(--text-fade); }
|
||||
#userInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
}
|
||||
#sendBtn {
|
||||
margin-left: 8px;
|
||||
flex: none;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
background: var(--accent);
|
||||
color: #0a0a0a;
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
#sendBtn:hover { background: var(--gold); border-color: var(--gold); }
|
||||
#sendBtn:disabled { opacity: .45; background: var(--bg-line); color: var(--text-fade); border-color: var(--border); }
|
||||
|
||||
/* Relay status dot */
|
||||
#status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
gap: 8px;
|
||||
font-family: monospace;
|
||||
color: #f5f5f5;
|
||||
font-family: var(--font-console);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-fade);
|
||||
}
|
||||
|
||||
#status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
background: var(--text-fade);
|
||||
}
|
||||
|
||||
@keyframes pulseGreen {
|
||||
@@ -170,29 +270,29 @@ button:hover, select:hover {
|
||||
}
|
||||
|
||||
.dot.ok {
|
||||
background: #8fd694;
|
||||
background: var(--good);
|
||||
animation: pulseGreen 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
/* Offline state stays solid red */
|
||||
.dot.fail {
|
||||
background: #ff3333;
|
||||
box-shadow: 0 0 10px #ff3333;
|
||||
background: var(--bad);
|
||||
box-shadow: 0 0 8px rgba(255, 90, 90, 0.5);
|
||||
}
|
||||
|
||||
|
||||
/* Dropdown (session selector) styling */
|
||||
select {
|
||||
background-color: var(--bg-dark);
|
||||
background-color: var(--bg-line);
|
||||
color: var(--text-main);
|
||||
border: 1px solid #b84a12;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
padding: 5px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
select option {
|
||||
background-color: var(--bg-dark);
|
||||
background-color: var(--bg-elev);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
@@ -200,8 +300,8 @@ select option {
|
||||
select:focus,
|
||||
select:hover {
|
||||
outline: none;
|
||||
border-color: #ff8a00;
|
||||
background-color: var(--bg-panel);
|
||||
border-color: var(--accent);
|
||||
background-color: var(--bg-line);
|
||||
}
|
||||
|
||||
/* Settings Modal */
|
||||
@@ -235,10 +335,10 @@ select:hover {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: linear-gradient(180deg, rgba(255,122,0,0.1) 0%, rgba(10,10,10,0.95) 100%);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--accent-glow);
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
@@ -251,8 +351,8 @@ select:hover {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--accent);
|
||||
background: rgba(255,122,0,0.1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-line);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
@@ -277,8 +377,8 @@ select:hover {
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255,122,0,0.2);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@@ -307,17 +407,16 @@ select:hover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255,122,0,0.3);
|
||||
border-radius: 6px;
|
||||
background: rgba(255,122,0,0.05);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-line);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.radio-label:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255,122,0,0.1);
|
||||
box-shadow: 0 0 8px rgba(255,122,0,0.3);
|
||||
border-color: var(--border-bright);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.radio-label input[type="radio"] {
|
||||
@@ -358,19 +457,20 @@ select:hover {
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--accent);
|
||||
background: rgba(255,122,0,0.05);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-line);
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
color: #0a0a0a;
|
||||
font-weight: 600;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: #ff8a00;
|
||||
box-shadow: var(--accent-glow);
|
||||
background: var(--gold);
|
||||
border-color: var(--gold);
|
||||
}
|
||||
|
||||
/* Session List */
|
||||
@@ -387,15 +487,15 @@ select:hover {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255,122,0,0.3);
|
||||
border-radius: 6px;
|
||||
background: rgba(255,122,0,0.05);
|
||||
transition: all 0.2s;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-line);
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255,122,0,0.1);
|
||||
border-color: var(--border-bright);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.session-info {
|
||||
@@ -435,8 +535,8 @@ select:hover {
|
||||
|
||||
/* Thinking Stream Panel */
|
||||
.thinking-panel {
|
||||
border-top: 1px solid var(--accent);
|
||||
background: rgba(255, 122, 0, 0.02);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: max-height 0.3s ease;
|
||||
@@ -452,16 +552,16 @@ select:hover {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 122, 0, 0.08);
|
||||
background: var(--bg-elev);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid rgba(255, 122, 0, 0.2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.thinking-header:hover {
|
||||
background: rgba(255, 122, 0, 0.12);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.thinking-controls {
|
||||
@@ -489,19 +589,19 @@ select:hover {
|
||||
|
||||
.thinking-clear-btn,
|
||||
.thinking-toggle-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 122, 0, 0.5);
|
||||
background: var(--bg-line);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-main);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.thinking-clear-btn:hover,
|
||||
.thinking-toggle-btn:hover {
|
||||
background: rgba(255, 122, 0, 0.2);
|
||||
box-shadow: 0 0 6px rgba(255, 122, 0, 0.3);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--border-bright);
|
||||
}
|
||||
|
||||
.thinking-toggle-btn {
|
||||
@@ -613,6 +713,12 @@ select:hover {
|
||||
|
||||
/* ========== MOBILE RESPONSIVE STYLES ========== */
|
||||
|
||||
/* Wordmark + status dot — shown only in the mobile header (media query below) */
|
||||
.brand, .brand-dot { display: none; }
|
||||
|
||||
/* Bottom tab bar — mobile only (shown in the media query) */
|
||||
#tabbar { display: none; }
|
||||
|
||||
/* Hamburger Menu */
|
||||
.hamburger-menu {
|
||||
display: none;
|
||||
@@ -620,9 +726,9 @@ select:hover {
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-bright);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-line);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -654,13 +760,17 @@ select:hover {
|
||||
left: -100%;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: var(--bg-dark);
|
||||
border-right: 2px solid var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
height: 100dvh;
|
||||
background: var(--bg-elev);
|
||||
border-right: 1px solid var(--border);
|
||||
box-shadow: 8px 0 32px rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
transition: left 0.3s ease;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 20px;
|
||||
padding-top: calc(20px + env(safe-area-inset-top));
|
||||
padding-bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
@@ -689,7 +799,7 @@ select:hover {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(255, 122, 0, 0.3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mobile-menu-section:last-child {
|
||||
@@ -716,15 +826,25 @@ select:hover {
|
||||
@media screen and (max-width: 768px) {
|
||||
body {
|
||||
padding: 0;
|
||||
background: var(--bg-elev); /* matches the tab bar so any strip below #chat is seamless */
|
||||
}
|
||||
|
||||
#chat {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh; /* the *visible* viewport (excludes the home-indicator zone);
|
||||
overrides the base 95vh. Body bg matches the bar below it. */
|
||||
background: var(--bg-dark);
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border: none;
|
||||
}
|
||||
/* Only while the keyboard is open do we follow the *visible* viewport: release
|
||||
the bottom anchor and size from the top by the measured visible height. */
|
||||
body.kb #chat {
|
||||
bottom: auto;
|
||||
height: var(--app-height, 100dvh);
|
||||
transform: translateY(var(--app-offset, 0px));
|
||||
}
|
||||
|
||||
/* Show hamburger, hide desktop header controls */
|
||||
@@ -734,17 +854,39 @@ select:hover {
|
||||
|
||||
#model-select {
|
||||
padding: 12px;
|
||||
justify-content: space-between;
|
||||
padding-top: calc(12px + env(safe-area-inset-top));
|
||||
padding-left: calc(14px + env(safe-area-inset-left));
|
||||
padding-right: calc(14px + env(safe-area-inset-right));
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Hide all controls except hamburger on mobile */
|
||||
#model-select > *:not(.hamburger-menu) {
|
||||
/* Mobile header is [≡] Lyra [♠ Cash] [●] — hide everything else. */
|
||||
#model-select > *:not(.hamburger-menu):not(.brand):not(.brand-dot):not(.mode-badge) {
|
||||
display: none;
|
||||
}
|
||||
.mode-badge { display: inline-flex; margin-left: 4px; }
|
||||
.brand {
|
||||
display: block;
|
||||
font-family: var(--font-console);
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.brand-dot {
|
||||
display: block;
|
||||
width: 9px; height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-fade);
|
||||
margin-left: auto;
|
||||
transition: background-color .2s;
|
||||
}
|
||||
.brand-dot.ok { background: var(--good); box-shadow: 0 0 8px rgba(143, 214, 148, .55); }
|
||||
.brand-dot.fail { background: var(--bad); }
|
||||
|
||||
#session-select {
|
||||
display: none;
|
||||
}
|
||||
#session-select { display: none; }
|
||||
#status { display: none; } /* relay status now lives as the header dot */
|
||||
|
||||
/* Show mobile menu */
|
||||
.mobile-menu {
|
||||
@@ -763,19 +905,61 @@ select:hover {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Input area - bigger touch targets */
|
||||
/* Input area - bigger touch targets. The tab bar owns the bottom safe-area
|
||||
inset now (the input is no longer the bottom-most element). */
|
||||
#input {
|
||||
padding: 12px;
|
||||
padding-left: calc(12px + env(safe-area-inset-left));
|
||||
padding-right: calc(12px + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
/* Bottom tab bar */
|
||||
#tabbar {
|
||||
display: flex;
|
||||
flex: none; /* never let it be compressed/clipped by the flex column */
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
padding-bottom: 6px; /* 100dvh already excludes the home-indicator zone */
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
#tabbar .tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 7px 0 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
color: var(--text-fade);
|
||||
font-family: var(--font-console);
|
||||
text-decoration: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
#tabbar .tab:hover { background: none; }
|
||||
#tabbar .tab:active { background: var(--accent-soft); }
|
||||
#tabbar .tab .ti { font-size: 1.3rem; line-height: 1; filter: grayscale(.45); }
|
||||
#tabbar .tab .tl { font-size: .64rem; letter-spacing: .3px; }
|
||||
#tabbar .tab.active { color: var(--accent); }
|
||||
#tabbar .tab.active .ti { filter: none; }
|
||||
body.kb #tabbar { display: none; } /* keyboard open ⇒ hide so input pins to keyboard */
|
||||
|
||||
/* The "More" tab is the menu trigger now — retire the hamburger. */
|
||||
.hamburger-menu { display: none !important; }
|
||||
|
||||
#userInput {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
padding: 12px;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
|
||||
#sendBtn {
|
||||
padding: 12px 16px;
|
||||
font-size: 1rem;
|
||||
width: 44px; /* comfortable touch target */
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
/* Modal - full width on mobile */
|
||||
@@ -874,12 +1058,14 @@ select:hover {
|
||||
|
||||
#userInput {
|
||||
font-size: 16px;
|
||||
padding: 10px;
|
||||
padding: 10px 13px;
|
||||
}
|
||||
|
||||
#sendBtn {
|
||||
padding: 10px 14px;
|
||||
font-size: 0.95rem;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
@@ -995,6 +1181,16 @@ select:hover {
|
||||
}
|
||||
.msg.assistant pre code { background: none; padding: 0; font-size: 0.85em; }
|
||||
|
||||
/* Streaming: a blinking caret while tokens arrive (and a min-size while empty). */
|
||||
.msg.assistant.streaming { min-width: 1.4em; min-height: 1.1em; }
|
||||
.msg.assistant.streaming::after {
|
||||
content: "▋";
|
||||
margin-left: 1px;
|
||||
color: var(--accent);
|
||||
animation: caretBlink 1s steps(1) infinite;
|
||||
}
|
||||
@keyframes caretBlink { 0%, 50% { opacity: 0.85; } 50.01%, 100% { opacity: 0; } }
|
||||
|
||||
/* Behind-the-scenes 👍/👎 feedback (fine-tune signal) — subtle until hovered. */
|
||||
.rate-bar { display: flex; gap: 6px; margin-top: 7px; opacity: 0.3; transition: opacity .15s; }
|
||||
.msg.assistant:hover .rate-bar { opacity: 0.85; }
|
||||
@@ -1003,5 +1199,31 @@ select:hover {
|
||||
padding: 2px 5px; border-radius: 5px; line-height: 1; filter: grayscale(0.6);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.rate-btn:hover { filter: none; background: rgba(255,122,0,0.12); }
|
||||
.rate-btn.rated { filter: none; background: rgba(255,122,0,0.25); opacity: 1; }
|
||||
.rate-btn:hover { filter: none; background: var(--accent-soft); }
|
||||
.rate-btn.rated { filter: none; background: rgba(255,122,0,0.22); opacity: 1; }
|
||||
|
||||
/* Per-message copy button (lives in the rate-bar for assistant, its own bar for user). */
|
||||
.copy-btn {
|
||||
background: none; border: none; cursor: pointer; font-size: 0.85rem;
|
||||
padding: 2px 6px; border-radius: 5px; line-height: 1; color: var(--text-fade);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.copy-btn:hover { background: var(--accent-soft); color: var(--accent); }
|
||||
.copy-btn.copied { color: var(--good); }
|
||||
/* User bubbles are right-aligned, so right-align their copy bar too. */
|
||||
.msg.user .rate-bar { justify-content: flex-end; opacity: 0.4; }
|
||||
.msg.user:hover .rate-bar { opacity: 0.85; }
|
||||
/* Touch devices have no hover — keep the tools tappable/visible. */
|
||||
@media (hover: none) {
|
||||
.rate-bar, .msg.user .rate-bar { opacity: 0.65; }
|
||||
}
|
||||
|
||||
/* Quality floor: honor reduced-motion preference. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<!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 — Thoughts</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #070707; --bg-elev: #0e0e0e; --bg-line: #141414; --border: #2a1d12;
|
||||
--text: #e8e8e8; --fade: #8a8a8a; --accent: #ff7a00; --gold: #ffb347;
|
||||
--good: #8fd694; --low: #ff6b6b;
|
||||
}
|
||||
* { 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 12px; flex-wrap: wrap; }
|
||||
.topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
|
||||
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
|
||||
.count { margin-left: auto; color: var(--fade); font-size: .8rem; }
|
||||
.lede { color: var(--fade); font-size: .82rem; padding: 0 0 12px; line-height: 1.5; max-width: 640px; }
|
||||
|
||||
main { max-width: 720px; margin: 0 auto; padding: 16px 14px 56px; }
|
||||
|
||||
.thread {
|
||||
border: 1px solid var(--border); border-radius: 12px; background: var(--bg-elev);
|
||||
padding: 13px 14px; margin-bottom: 14px;
|
||||
}
|
||||
.thread.surfaced { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(255,122,0,.12); }
|
||||
.thread.answered, .thread.dropped { opacity: .68; }
|
||||
.th-head { display: flex; align-items: center; gap: 9px; margin-bottom: 4px; }
|
||||
.th-title { font-size: 1rem; font-weight: 600; flex: 1; }
|
||||
.badge {
|
||||
font-size: .62rem; text-transform: uppercase; letter-spacing: .6px; font-weight: 700;
|
||||
padding: 3px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--fade);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.surfaced { color: var(--accent); border-color: var(--accent); }
|
||||
.badge.open { color: var(--gold); border-color: #4a3417; }
|
||||
.badge.resting { color: var(--fade); }
|
||||
.badge.answered { color: var(--good); border-color: #2c4a2e; }
|
||||
.badge.dropped { color: var(--low); border-color: #4a2424; }
|
||||
.th-meta { color: var(--fade); font-size: .72rem; margin-bottom: 9px; display: flex; gap: 12px; }
|
||||
.sal { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.salbar { width: 46px; height: 4px; border-radius: 3px; background: var(--bg-line); overflow: hidden; }
|
||||
.salfill { height: 100%; background: var(--accent); }
|
||||
|
||||
.chain { border-left: 2px solid var(--bg-line); margin: 6px 0 4px; padding-left: 12px; }
|
||||
.link { padding: 5px 0; }
|
||||
.link .k { font-size: .62rem; text-transform: uppercase; letter-spacing: .5px; font-weight: 700;
|
||||
color: var(--gold); margin-right: 7px; }
|
||||
.link .t { color: var(--fade); font-size: .68rem; }
|
||||
.link .c { font-size: .95rem; line-height: 1.5; margin-top: 2px; }
|
||||
|
||||
.resp {
|
||||
margin-top: 8px; padding: 8px 11px; border-radius: 9px; background: #0b1410;
|
||||
border: 1px solid #234032;
|
||||
}
|
||||
.resp .who { font-size: .62rem; text-transform: uppercase; letter-spacing: .5px; font-weight: 700;
|
||||
color: var(--good); }
|
||||
.resp .c { font-size: .92rem; line-height: 1.5; margin-top: 3px; }
|
||||
|
||||
.reply { display: flex; gap: 8px; margin-top: 10px; align-items: flex-end; }
|
||||
.reply textarea {
|
||||
flex: 1; resize: none; min-height: 38px; max-height: 140px; padding: 9px 11px;
|
||||
border-radius: 9px; border: 1px solid var(--border); background: var(--bg);
|
||||
color: var(--text); font: inherit; font-size: .92rem; line-height: 1.4;
|
||||
}
|
||||
.reply textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.btn {
|
||||
border: 1px solid var(--border); background: var(--bg-line); color: var(--text);
|
||||
border-radius: 9px; padding: 9px 14px; font: inherit; font-size: .88rem; cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: var(--accent); }
|
||||
.btn.send { background: #241400; color: var(--accent); border-color: var(--accent); }
|
||||
.th-actions { margin-top: 9px; display: flex; gap: 8px; }
|
||||
.btn.ghost { font-size: .76rem; padding: 5px 10px; color: var(--fade); }
|
||||
|
||||
.empty { color: var(--fade); text-align: center; padding: 44px 16px; line-height: 1.6; }
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<h1>💭 Lyra · Thoughts</h1>
|
||||
<a class="back" href="/self">← Mind</a>
|
||||
<a class="back" href="/">Chat</a>
|
||||
<span class="count" id="count"></span>
|
||||
</div>
|
||||
<p class="lede">Threads she's been turning over on her own, between conversations. The ones
|
||||
she's flagged she'd want to raise are highlighted — reply to any of them and she'll fold
|
||||
your response in next time she thinks.</p>
|
||||
</header>
|
||||
<main id="root"><p class="empty" id="boot">Reading her mind…</p></main>
|
||||
|
||||
<script>
|
||||
const root = document.getElementById('root');
|
||||
const countEl = document.getElementById('count');
|
||||
let threads = [];
|
||||
|
||||
function esc(s){ const d=document.createElement('div'); d.textContent = s==null?'':String(s); return d.innerHTML; }
|
||||
function clockt(iso){ return new Date(iso).toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'}); }
|
||||
|
||||
function render(){
|
||||
const active = threads.filter(t => t.status === 'surfaced' || t.status === 'open').length;
|
||||
countEl.textContent = `${active} active · ${threads.length} total`;
|
||||
if (!threads.length) {
|
||||
root.innerHTML = '<p class="empty">No threads yet. She thinks during her dream cycle — give her some idle time and they\'ll start to collect here.</p>';
|
||||
return;
|
||||
}
|
||||
root.innerHTML = threads.map(renderThread).join('');
|
||||
}
|
||||
|
||||
function renderThread(t){
|
||||
const sal = Math.round((t.salience || 0) * 100);
|
||||
const chain = (t.thoughts || []).map(x => `
|
||||
<div class="link">
|
||||
<span class="k">${esc(x.kind)}</span><span class="t">${esc(clockt(x.created_at))}</span>
|
||||
<div class="c">${esc(x.content)}</div>
|
||||
</div>`).join('');
|
||||
const resp = t.last_response ? `
|
||||
<div class="resp"><div class="who">Brian replied</div><div class="c">${esc(t.last_response)}</div></div>` : '';
|
||||
const closed = (t.status === 'answered' || t.status === 'dropped');
|
||||
const reply = closed ? '' : `
|
||||
<div class="reply">
|
||||
<textarea placeholder="Reply to this thread…" data-id="${t.id}"></textarea>
|
||||
<button class="btn send" data-respond="${t.id}">Send</button>
|
||||
</div>`;
|
||||
const actions = `
|
||||
<div class="th-actions">
|
||||
${closed ? `<button class="btn ghost" data-status="open" data-id="${t.id}">Reopen</button>`
|
||||
: `<button class="btn ghost" data-status="dropped" data-id="${t.id}">Drop</button>`}
|
||||
</div>`;
|
||||
return `
|
||||
<div class="thread ${esc(t.status)}">
|
||||
<div class="th-head">
|
||||
<span class="th-title">${esc(t.title)}</span>
|
||||
<span class="badge ${esc(t.status)}">${esc(t.status)}</span>
|
||||
</div>
|
||||
<div class="th-meta">
|
||||
<span class="sal">tug <span class="salbar"><span class="salfill" style="width:${sal}%"></span></span> ${sal}%</span>
|
||||
<span>updated ${esc(clockt(t.updated_at))}</span>
|
||||
</div>
|
||||
<div class="chain">${chain || '<div class="link"><div class="c">(no thoughts yet)</div></div>'}</div>
|
||||
${resp}
|
||||
${reply}
|
||||
${actions}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
root.addEventListener('click', async (ev) => {
|
||||
const send = ev.target.closest('[data-respond]');
|
||||
if (send) {
|
||||
const id = send.dataset.respond;
|
||||
const ta = root.querySelector(`textarea[data-id="${id}"]`);
|
||||
const text = (ta && ta.value || '').trim();
|
||||
if (!text) { ta && ta.focus(); return; }
|
||||
send.disabled = true; send.textContent = '…';
|
||||
try {
|
||||
await fetch(`/thoughts/${id}/respond`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
if (ta) ta.value = '';
|
||||
await load(true);
|
||||
} catch (e) { send.disabled = false; send.textContent = 'Send'; }
|
||||
return;
|
||||
}
|
||||
const st = ev.target.closest('[data-status]');
|
||||
if (st) {
|
||||
try {
|
||||
await fetch(`/thoughts/${st.dataset.id}/status`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: st.dataset.status })
|
||||
});
|
||||
await load(true);
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
// grow reply boxes as you type
|
||||
root.addEventListener('input', (ev) => {
|
||||
const ta = ev.target.closest('textarea'); if (!ta) return;
|
||||
ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
|
||||
});
|
||||
|
||||
// Don't blow away a reply you're mid-composing: skip the poll re-render while a
|
||||
// reply box is focused or has text. Explicit reloads (after send/status) force.
|
||||
function composing(){
|
||||
const a = document.activeElement;
|
||||
if (a && a.tagName === 'TEXTAREA' && root.contains(a)) return true;
|
||||
return Array.from(root.querySelectorAll('textarea')).some(t => t.value.trim());
|
||||
}
|
||||
async function load(force){
|
||||
if (!force && composing()) return;
|
||||
try {
|
||||
const r = await fetch('/thoughts/data', { cache: 'no-store' });
|
||||
threads = (await r.json()).threads || [];
|
||||
render();
|
||||
} catch (e) {
|
||||
root.innerHTML = '<p class="empty">Couldn\'t reach her thoughts. Is the server up?</p>';
|
||||
}
|
||||
}
|
||||
load(true);
|
||||
setInterval(() => load(false), 20000);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(false); });
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "lyra"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Persistent, autonomous AI assistant"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -23,6 +23,7 @@ lyra-profile = "lyra.profile:main"
|
||||
lyra-era = "lyra.era:main"
|
||||
lyra-narrative = "lyra.narrative:main"
|
||||
lyra-reflect = "lyra.self_state:main"
|
||||
lyra-think = "lyra.thoughts:main"
|
||||
lyra-dream = "lyra.dream:main"
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -12,6 +12,7 @@ def lyra(tmp_path, monkeypatch):
|
||||
"""A fresh Lyra wired to a temp DB with stubbed embeddings + LLM."""
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("SUMMARY_BACKEND", "local")
|
||||
monkeypatch.setenv("LYRA_FEEDS", "") # dream cycle refreshes feeds; keep it offline
|
||||
|
||||
from lyra import llm
|
||||
# Deterministic 3-d embeddings; content-insensitive is fine for storage tests.
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Conversation modes: tool gating, mode persistence, stack tracking + HUD."""
|
||||
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)
|
||||
import lyra.modes as modes
|
||||
importlib.reload(modes)
|
||||
import lyra.tools as tools
|
||||
importlib.reload(tools)
|
||||
return memory, poker, modes, tools
|
||||
|
||||
|
||||
def _names(specs):
|
||||
return {s["function"]["name"] for s in specs}
|
||||
|
||||
|
||||
def test_tool_gating_by_mode(lyra):
|
||||
_, _, modes, tools = lyra
|
||||
talk = _names(tools.specs(modes.TALK.tools))
|
||||
cash = _names(tools.specs(modes.CASH.tools))
|
||||
|
||||
# Cash is the full live toolset.
|
||||
assert {"log_hand", "log_stack", "analyze_spot", "end_session"} <= cash
|
||||
# Talk hides the live write tools...
|
||||
assert "log_hand" not in talk and "log_stack" not in talk
|
||||
# ...but keeps her agency + read-only lookups + the session entry point.
|
||||
assert {"journal_write", "note", "player_profile", "start_session"} <= talk
|
||||
# No allow-list = every registered tool.
|
||||
assert _names(tools.specs()) == set(tools.TOOLS)
|
||||
|
||||
|
||||
def test_every_mode_tool_exists(lyra):
|
||||
_, _, modes, tools = lyra
|
||||
for mode in modes.MODES.values():
|
||||
assert set(mode.tools) <= set(tools.TOOLS), f"{mode.key} references unknown tools"
|
||||
|
||||
|
||||
def test_mode_resolution_and_persistence(lyra):
|
||||
memory, _, modes, _ = lyra
|
||||
assert modes.get(None).key == modes.DEFAULT
|
||||
assert modes.get("nonsense").key == modes.DEFAULT
|
||||
assert modes.get("poker_cash") is modes.CASH
|
||||
|
||||
memory.ensure_session("s1")
|
||||
assert memory.get_session_mode("s1") is None # unset -> caller applies default
|
||||
memory.set_session_mode("s1", "poker_cash")
|
||||
assert memory.get_session_mode("s1") == "poker_cash"
|
||||
# set on an unknown session creates the row
|
||||
memory.set_session_mode("s2", "conversation")
|
||||
assert memory.get_session_mode("s2") == "conversation"
|
||||
|
||||
|
||||
def test_stack_log_and_live_net(lyra):
|
||||
_, poker, _, _ = lyra
|
||||
poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
||||
assert poker.current_stack() is None # nothing logged yet
|
||||
|
||||
st = poker.log_stack(700)
|
||||
assert st["current"] == 700 and st["net"] == 200 # up 200 on a 500 buy-in
|
||||
poker.log_stack(350)
|
||||
assert poker.current_stack() == 350
|
||||
assert poker.stack_state()["net"] == -150
|
||||
assert len(poker.stack_log()) == 2
|
||||
|
||||
|
||||
def test_log_stack_requires_live_session(lyra):
|
||||
_, poker, _, _ = lyra
|
||||
with pytest.raises(ValueError):
|
||||
poker.log_stack(300)
|
||||
|
||||
|
||||
def test_hud_bundle(lyra):
|
||||
_, poker, _, _ = lyra
|
||||
assert poker.hud() is None # no session -> nothing to show
|
||||
|
||||
sid = poker.start_session(venue="Meadows", stakes="2/5", game="NLH", buy_in=500)
|
||||
poker.log_stack(620)
|
||||
poker.log_hand(position="BTN", hole_cards="AKs", result=120, tag="confidence")
|
||||
poker.add_read(note="3bets light from the SB", name="Round Mike", seat="SB")
|
||||
|
||||
hud = poker.hud()
|
||||
assert hud["session"]["id"] == sid and hud["session"]["stakes"] == "2/5"
|
||||
assert hud["stack"]["current"] == 620 and hud["stack"]["net"] == 120
|
||||
assert len(hud["stack"]["log"]) == 1
|
||||
assert len(hud["hands"]) == 1 and hud["hands"][0]["hole_cards"] == "AKs"
|
||||
assert any(v["name"] == "Round Mike" for v in hud["villains"])
|
||||
assert hud["stats"]["hands_logged"] == 1
|
||||
|
||||
|
||||
def test_log_stack_tool_handler(lyra):
|
||||
_, poker, _, tools = lyra
|
||||
poker.start_session(stakes="1/3", buy_in=300)
|
||||
out = tools.dispatch("log_stack", {"amount": 450}, {})
|
||||
assert "450" in out and "150" in out # confirms stack + live net
|
||||
# graceful when there's no number
|
||||
assert "number" in tools.dispatch("log_stack", {}, {}).lower()
|
||||
|
||||
|
||||
# --- mental-game rituals ---
|
||||
|
||||
def test_ritual_tools_in_cash_only(lyra):
|
||||
_, _, modes, tools = lyra
|
||||
cash = _names(tools.specs(modes.CASH.tools))
|
||||
talk = _names(tools.specs(modes.TALK.tools))
|
||||
rituals = {"scar_note", "confidence_bank", "alligator_blood", "reset_ritual"}
|
||||
assert rituals <= cash
|
||||
assert not (rituals & talk)
|
||||
|
||||
|
||||
def test_scar_and_confidence_capture(lyra):
|
||||
_, poker, _, tools = lyra
|
||||
poker.start_session(stakes="2/5", buy_in=500)
|
||||
tools.dispatch("scar_note", {"content": "punted bottom set", "classification": "punt"}, {})
|
||||
tools.dispatch("scar_note", {"content": "ran KK into AA", "classification": "cooler"}, {})
|
||||
tools.dispatch("confidence_bank", {"content": "disciplined river fold"}, {})
|
||||
|
||||
scars = poker.list_rituals(kinds=("scar",))
|
||||
assert len(scars) == 2
|
||||
assert {s["classification"] for s in scars} == {"punt", "cooler"}
|
||||
conf = poker.list_rituals(kinds=("confidence",))
|
||||
assert len(conf) == 1 and "fold" in conf[0]["content"]
|
||||
# bogus classification is dropped, not stored
|
||||
tools.dispatch("scar_note", {"content": "x", "classification": "nonsense"}, {})
|
||||
assert poker.list_rituals(kinds=("scar",))[-1]["classification"] is None
|
||||
|
||||
|
||||
def test_alligator_toggle_and_state(lyra):
|
||||
_, poker, _, tools = lyra
|
||||
poker.start_session(stakes="2/5", buy_in=500)
|
||||
assert poker.alligator_active() is False
|
||||
tools.dispatch("alligator_blood", {"on": True}, {})
|
||||
assert poker.alligator_active() is True
|
||||
tools.dispatch("alligator_blood", {"on": False}, {})
|
||||
assert poker.alligator_active() is False # latest toggle wins
|
||||
|
||||
|
||||
def test_rituals_in_hud(lyra):
|
||||
_, poker, _, tools = lyra
|
||||
poker.start_session(stakes="2/5", buy_in=500)
|
||||
tools.dispatch("scar_note", {"content": "overplayed top pair"}, {})
|
||||
tools.dispatch("confidence_bank", {"content": "good value bet"}, {})
|
||||
tools.dispatch("reset_ritual", {"content": "lost a flip"}, {})
|
||||
tools.dispatch("alligator_blood", {"on": True}, {})
|
||||
|
||||
r = poker.hud()["rituals"]
|
||||
assert r["alligator"] is True
|
||||
assert len(r["scars"]) == 1 and len(r["confidence"]) == 1 and len(r["resets"]) == 1
|
||||
|
||||
|
||||
def test_session_state_readback(lyra):
|
||||
_, poker, _, tools = lyra
|
||||
assert "no live session" in tools.dispatch("session_state", {}, {}).lower()
|
||||
|
||||
poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
||||
tools.dispatch("log_stack", {"amount": 720}, {})
|
||||
tools.dispatch("confidence_bank", {"content": "great river fold"}, {})
|
||||
tools.dispatch("alligator_blood", {"on": True}, {})
|
||||
|
||||
out = tools.dispatch("session_state", {}, {})
|
||||
assert "720" in out # current stack
|
||||
assert "+220" in out or "220" in out # live net
|
||||
assert "Alligator Blood is ON" in out
|
||||
assert "great river fold" in out
|
||||
|
||||
|
||||
def test_reconstruct_flat_hand(lyra, monkeypatch):
|
||||
_, poker, _, _ = lyra
|
||||
poker.start_session(stakes="1/3", buy_in=300)
|
||||
hid = poker.log_hand(position="UTG", hole_cards="KhQh",
|
||||
preflop="UTG raises, BTN calls", flop="Qd Qs Jc, bet, call",
|
||||
river="Kd, all in, called", showdown="hero wins", result=225)
|
||||
assert poker.get_hand(hid)["structured"] is None # flat (log_hand) — not replayable yet
|
||||
monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: {
|
||||
"hero_pos": "UTG", "hero_cards": ["Kh", "Qh"],
|
||||
"players": [{"pos": "UTG"}],
|
||||
"actions": [{"street": "preflop", "pos": "UTG", "action": "raise"}],
|
||||
"board": ["Qd", "Qs", "Jc", "6d", "Kd"]})
|
||||
out = poker.reconstruct_hand(hid)
|
||||
assert out is not None
|
||||
h = poker.get_hand(hid)
|
||||
assert h["structured"]["hero_pos"] == "UTG" and len(h["structured"]["actions"]) == 1
|
||||
|
||||
|
||||
def test_undo_last_and_delete_entry(lyra):
|
||||
_, poker, modes, tools = lyra
|
||||
assert "undo_last" in modes.CASH.tools
|
||||
poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
||||
poker.log_hand(position="UTG", hole_cards="AA")
|
||||
poker.log_hand(position="BTN", hole_cards="72o")
|
||||
poker.log_stack(600)
|
||||
poker.log_stack(420)
|
||||
poker.log_ritual("scar", content="punted")
|
||||
poker.log_ritual("confidence", content="good fold")
|
||||
|
||||
# undo removes the most recent of each kind
|
||||
assert "72o" in poker.undo_last("hand")
|
||||
assert [h["hole_cards"] for h in poker.list_hands()] == ["AA"] # h2 gone, h1 stays
|
||||
assert "420" in poker.undo_last("stack")
|
||||
assert poker.current_stack() == 600
|
||||
assert "punted" in poker.undo_last("scar")
|
||||
assert not poker.list_rituals(kinds=("scar",))
|
||||
assert poker.list_rituals(kinds=("confidence",)) # untouched
|
||||
assert poker.undo_last("hand") is not None # h1
|
||||
assert poker.undo_last("hand") is None # nothing left
|
||||
|
||||
# direct delete-by-id dispatch
|
||||
assert poker.delete_entry("ritual", poker.list_rituals(kinds=("confidence",))[0]["id"]) is True
|
||||
assert poker.delete_entry("bogus", 1) is False
|
||||
|
||||
|
||||
def test_undo_last_tool(lyra):
|
||||
_, poker, _, tools = lyra
|
||||
poker.start_session(stakes="1/3", buy_in=300)
|
||||
poker.log_hand(position="CO", hole_cards="KK")
|
||||
out = tools.dispatch("undo_last", {"what": "hand"}, {})
|
||||
assert "scratched" in out.lower() and poker.list_hands() == []
|
||||
# no live session -> graceful
|
||||
poker.end_session(cash_out=300)
|
||||
assert "no live session" in tools.dispatch("undo_last", {"what": "hand"}, {}).lower()
|
||||
# nonsense target
|
||||
poker.start_session(stakes="1/3", buy_in=100)
|
||||
assert "one of" in tools.dispatch("undo_last", {"what": "banana"}, {}).lower()
|
||||
|
||||
|
||||
def test_update_session_edit(lyra):
|
||||
_, poker, modes, tools = lyra
|
||||
assert "update_session" in modes.CASH.tools
|
||||
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
||||
s = poker.update_session(sid, stakes="2/5", buy_in_total=600, cash_out=900, venue="Bellagio")
|
||||
assert s["stakes"] == "2/5" and s["venue"] == "Bellagio"
|
||||
assert s["buy_in_total"] == 600 and s["cash_out"] == 900
|
||||
assert s["net"] == 300 # recomputed from cash_out - buy_in
|
||||
# via the tool (edits the live/most-recent session)
|
||||
out = tools.dispatch("update_session", {"mood": "locked in"}, {})
|
||||
assert "updated" in out.lower() and poker.get_session(sid)["mood"] == "locked in"
|
||||
assert "what to change" in tools.dispatch("update_session", {}, {}).lower()
|
||||
|
||||
|
||||
def test_review_session_and_post_close_rituals(lyra):
|
||||
_, poker, _, tools = lyra
|
||||
sid = poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
||||
poker.end_session(cash_out=720)
|
||||
assert poker.live_session() is None
|
||||
assert poker.review_session_id() == sid # most-recent closed session
|
||||
|
||||
# rituals attach to the closed session during review (no live session needed)
|
||||
out = tools.dispatch("scar_note", {"content": "should've folded turn", "classification": "punt"}, {})
|
||||
assert "logged" in out.lower()
|
||||
tools.dispatch("confidence_bank", {"content": "good thin value river"}, {})
|
||||
assert len(poker.list_rituals(session_id=sid, kinds=("scar",))) == 1
|
||||
assert len(poker.list_rituals(session_id=sid, kinds=("confidence",))) == 1
|
||||
|
||||
|
||||
def test_hud_for_past_session(lyra):
|
||||
_, poker, _, _ = lyra
|
||||
sid = poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
||||
poker.log_hand(position="BTN", hole_cards="AKs")
|
||||
poker.end_session(cash_out=650)
|
||||
# a *new* live session so live HUD != the one we query
|
||||
poker.start_session(venue="Wynn", stakes="1/3", buy_in=300)
|
||||
past = poker.hud(sid)
|
||||
assert past["session"]["id"] == sid and past["session"]["is_live"] is False
|
||||
assert past["session"]["net"] == 150 and len(past["hands"]) == 1
|
||||
assert poker.hud()["session"]["venue"] == "Wynn" # live one unaffected
|
||||
|
||||
|
||||
def test_list_and_delete_session(lyra):
|
||||
_, poker, _, tools = lyra
|
||||
keep = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
||||
poker.end_session(cash_out=400, session_id=keep)
|
||||
drop = poker.start_session(venue="Bellagio", stakes="2/5", buy_in=500)
|
||||
poker.log_hand(position="BTN", hole_cards="AKs", session_id=drop)
|
||||
poker.log_stack(620, session_id=drop)
|
||||
poker.log_ritual("scar", content="punt", session_id=drop)
|
||||
|
||||
sessions = poker.list_sessions()
|
||||
assert {s["id"] for s in sessions} == {keep, drop}
|
||||
assert next(s for s in sessions if s["id"] == drop)["hands"] == 1
|
||||
|
||||
removed = poker.delete_session(drop)
|
||||
assert removed["poker_sessions"] == 1 and removed["poker_hands"] == 1
|
||||
assert removed["poker_stack_log"] == 1 and removed["poker_rituals"] == 1
|
||||
assert {s["id"] for s in poker.list_sessions()} == {keep} # only the survivor
|
||||
assert poker.get_session(drop) is None
|
||||
|
||||
|
||||
def test_recent_sessions_tool(lyra):
|
||||
_, poker, modes, tools = lyra
|
||||
assert "recent_sessions" in modes.TALK.tools # available even when just talking
|
||||
poker.import_session(date="2026-06-01", venue="Meadows", stakes="1/3",
|
||||
buy_in_total=300, cash_out=520, hours=5)
|
||||
out = tools.dispatch("recent_sessions", {}, {})
|
||||
assert "Meadows" in out and "+220" in out
|
||||
|
||||
|
||||
def test_rituals_require_a_session(lyra):
|
||||
_, poker, _, tools = lyra
|
||||
# with no session at all, the tool degrades gracefully (no exception)
|
||||
assert "no session" in tools.dispatch("scar_note", {"content": "x"}, {}).lower()
|
||||
with pytest.raises(ValueError):
|
||||
poker.log_ritual("scar", content="x")
|
||||
@@ -0,0 +1,253 @@
|
||||
"""The thought loop: threaded generation, salience/surface gating, feedback."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from lyra import clock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lyra(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.delenv("NTFY_URL", raising=False) # baseline: pinging disabled (ignore .env)
|
||||
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.self_state as self_state
|
||||
importlib.reload(self_state)
|
||||
import lyra.feeds as feeds
|
||||
importlib.reload(feeds)
|
||||
import lyra.thoughts as thoughts
|
||||
importlib.reload(thoughts)
|
||||
|
||||
# Canned LLM: tests set `box["next"]` to the dict think() should "generate".
|
||||
box = {"next": {}}
|
||||
monkeypatch.setattr(thoughts.llm, "complete", lambda messages, backend=None: json.dumps(box["next"]))
|
||||
# Keep the loop offline + silent by default: no feed fetch, no push.
|
||||
monkeypatch.setattr(thoughts.feeds, "next_item", lambda **k: None)
|
||||
monkeypatch.setattr(thoughts.notify, "push", lambda **k: False)
|
||||
return memory, thoughts, box
|
||||
|
||||
|
||||
def _gen(box, **fields):
|
||||
box["next"] = {"title": "t", "kind": "observation", "content": "c",
|
||||
"salience": 0.5, "status": "open"} | fields
|
||||
|
||||
|
||||
def test_new_thread_creates_chain(lyra):
|
||||
_, th, box = lyra
|
||||
_gen(box, title="my own restlessness", content="I notice a pull toward new ideas.", salience=0.4)
|
||||
rep = th.think(force_mode="new")
|
||||
assert rep["mode"] == "new"
|
||||
threads = th.list_threads()
|
||||
assert len(threads) == 1
|
||||
assert threads[0]["title"] == "my own restlessness"
|
||||
assert threads[0]["status"] == "open"
|
||||
chain = th.thread_thoughts(rep["thread_id"])
|
||||
assert len(chain) == 1 and "restlessness" not in chain[0]["content"].lower()
|
||||
|
||||
|
||||
def test_continue_advances_same_thread(lyra):
|
||||
_, th, box = lyra
|
||||
_gen(box, content="first link", salience=0.5)
|
||||
r1 = th.think(force_mode="new")
|
||||
_gen(box, content="second link, a new angle", salience=0.6)
|
||||
r2 = th.think(force_mode="continue")
|
||||
assert r2["mode"] == "continue"
|
||||
assert r2["thread_id"] == r1["thread_id"] # same thread
|
||||
assert len(th.list_threads()) == 1 # no new thread opened
|
||||
chain = th.thread_thoughts(r1["thread_id"])
|
||||
assert [c["content"] for c in chain] == ["first link", "second link, a new angle"]
|
||||
# thread salience tracks the latest link
|
||||
assert th.get_thread(r1["thread_id"])["salience"] == pytest.approx(0.6)
|
||||
|
||||
|
||||
def test_no_parse_returns_none_and_writes_nothing(lyra):
|
||||
_, th, box = lyra
|
||||
box["next"] = {} # empty -> no content -> miss
|
||||
assert th.think(force_mode="new") is None
|
||||
assert th.list_threads() == []
|
||||
|
||||
|
||||
def test_salience_gates_surfacing(lyra):
|
||||
_, th, box = lyra
|
||||
_gen(box, content="a quiet musing", salience=0.3)
|
||||
th.think(force_mode="new")
|
||||
assert th.pending_surface() is None # below the bar
|
||||
|
||||
_gen(box, content="something I'd actually raise", salience=0.85)
|
||||
th.think(force_mode="new")
|
||||
cand = th.pending_surface()
|
||||
assert cand is not None and cand["latest"]["content"] == "something I'd actually raise"
|
||||
|
||||
|
||||
def test_maybe_surface_respects_gap_and_marks_once(lyra):
|
||||
_, th, box = lyra
|
||||
_gen(box, title="restlessness", content="been circling this", salience=0.9)
|
||||
th.think(force_mode="new")
|
||||
|
||||
# Brian's mid-conversation (recent) -> don't interrupt.
|
||||
from lyra import clock
|
||||
recent = clock.now().isoformat()
|
||||
assert th.maybe_surface(recent) is None
|
||||
|
||||
# He's been away (no last exchange) -> she leads with it, once.
|
||||
note = th.maybe_surface(None)
|
||||
assert note and "restlessness" in note and "been circling this" in note
|
||||
assert th.maybe_surface(None) is None # already surfaced, no repeat
|
||||
assert th.list_threads(status="surfaced") # status flipped
|
||||
|
||||
|
||||
def test_response_then_followup_closes_loop(lyra):
|
||||
memory, th, box = lyra
|
||||
_gen(box, title="RAG vs custom model", content="maybe RAG is enough", salience=0.8)
|
||||
r = th.think(force_mode="new")
|
||||
tid = r["thread_id"]
|
||||
th.mark_surfaced(tid)
|
||||
|
||||
assert th.record_response(tid, "I think a custom model is the real goal") is True
|
||||
assert th._is_pending(th.get_thread(tid)) is True # awaiting her reaction
|
||||
|
||||
_gen(box, content="ok — RAG now, own model later", salience=0.7, status="answered")
|
||||
r2 = th.think(force_mode="respond")
|
||||
assert r2["mode"] == "respond" and r2["thread_id"] == tid
|
||||
assert th._is_pending(th.get_thread(tid)) is False # she reacted
|
||||
assert th.get_thread(tid)["status"] == "answered"
|
||||
assert len(th.thread_thoughts(tid)) == 2
|
||||
|
||||
|
||||
def test_set_status_drop_and_reopen(lyra):
|
||||
_, th, box = lyra
|
||||
_gen(box, content="x")
|
||||
r = th.think(force_mode="new")
|
||||
tid = r["thread_id"]
|
||||
assert th.set_status(tid, "dropped") is True
|
||||
assert th.get_thread(tid)["status"] == "dropped"
|
||||
assert th.set_status(tid, "bogus") is False # unknown status rejected
|
||||
assert th.set_status(tid, "open") is True
|
||||
|
||||
|
||||
def test_thought_recorded_in_journal(lyra):
|
||||
memory, th, box = lyra
|
||||
_gen(box, content="a thought worth keeping")
|
||||
th.think(force_mode="new")
|
||||
kinds = [e["kind"] for e in memory.list_journal(limit=50)]
|
||||
assert "thought" in kinds
|
||||
|
||||
|
||||
def test_decay_rests_stale_threads_but_spares_pending(lyra):
|
||||
_, th, box = lyra
|
||||
_gen(box, title="stale one", content="old idea", salience=0.8)
|
||||
r1 = th.think(force_mode="new")
|
||||
_gen(box, title="stale pending", content="awaiting his reply", salience=0.8)
|
||||
r2 = th.think(force_mode="new")
|
||||
|
||||
conn = th._c()
|
||||
old = (clock.now() - timedelta(hours=72)).isoformat()
|
||||
with conn:
|
||||
conn.execute("UPDATE thought_threads SET updated_at=? WHERE id=?", (old, r1["thread_id"]))
|
||||
conn.execute("UPDATE thought_threads SET updated_at=?, last_response='hm', responded_at=? WHERE id=?",
|
||||
(old, clock.now().isoformat(), r2["thread_id"]))
|
||||
|
||||
assert th.decay() == 1 # only the non-pending one
|
||||
rested = th.get_thread(r1["thread_id"])
|
||||
assert rested["status"] == "resting"
|
||||
assert rested["salience"] == pytest.approx(0.8 * th.RESTING_DECAY)
|
||||
# the pending thread is spared — she still owes a reaction
|
||||
assert th.get_thread(r2["thread_id"])["status"] == "open"
|
||||
assert th._is_pending(th.get_thread(r2["thread_id"])) is True
|
||||
|
||||
|
||||
def test_context_note_lists_active_threads(lyra):
|
||||
_, th, box = lyra
|
||||
assert th.context_note() is None # nothing yet
|
||||
_gen(box, title="my own restlessness", content="a real thread of mine", salience=0.6)
|
||||
th.think(force_mode="new")
|
||||
note = th.context_note()
|
||||
assert note and "my own restlessness" in note and "a real thread of mine" in note
|
||||
|
||||
|
||||
def test_think_about_tool_seeds_a_thread(lyra):
|
||||
_, th, _ = lyra
|
||||
import lyra.tools as tools
|
||||
importlib.reload(tools) # bind to the reloaded memory/thoughts
|
||||
out = tools.dispatch("think_about",
|
||||
{"title": "am I continuous?", "thought": "do I persist between turns?",
|
||||
"kind": "question"})
|
||||
assert "am I continuous?" in out
|
||||
threads = th.list_threads()
|
||||
assert len(threads) == 1 and threads[0]["title"] == "am I continuous?"
|
||||
chain = th.thread_thoughts(threads[0]["id"])
|
||||
assert chain[0]["kind"] == "question" and chain[0]["source"] == "chat"
|
||||
|
||||
|
||||
# --- external feed -------------------------------------------------------
|
||||
|
||||
RSS = (b'<?xml version="1.0"?><rss version="2.0"><channel><title>Feed</title>'
|
||||
b'<item><title>Poker tip</title><link>http://x/1</link>'
|
||||
b'<description>3-bet more in position</description><guid>g1</guid></item>'
|
||||
b'<item><title>Second</title><link>http://x/2</link><description>d2</description></item>'
|
||||
b'</channel></rss>')
|
||||
ATOM = (b'<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>F</title>'
|
||||
b'<entry><title>HN post</title><link href="http://y/1"/>'
|
||||
b'<summary>something interesting</summary><id>a1</id></entry></feed>')
|
||||
|
||||
|
||||
def test_feeds_parse_rss_and_atom():
|
||||
from lyra import feeds
|
||||
rss = feeds.parse(RSS)
|
||||
assert len(rss) == 2
|
||||
assert rss[0]["id"] == "g1" and rss[0]["title"] == "Poker tip" and rss[0]["link"] == "http://x/1"
|
||||
assert rss[1]["id"] == "http://x/2" # falls back to link when no guid
|
||||
atom = feeds.parse(ATOM)
|
||||
assert len(atom) == 1 and atom[0]["id"] == "a1" and atom[0]["link"] == "http://y/1"
|
||||
assert feeds.parse(b"not xml") == [] # garbage -> empty, no raise
|
||||
|
||||
|
||||
def test_react_mode_makes_a_thread_about_a_feed_item(lyra, monkeypatch):
|
||||
_, th, box = lyra
|
||||
item = {"id": "x1", "title": "World Item", "link": "http://e", "summary": "stuff happened"}
|
||||
monkeypatch.setattr(th.feeds, "next_item", lambda **k: item)
|
||||
used = []
|
||||
monkeypatch.setattr(th.feeds, "mark_used", lambda i: used.append(i))
|
||||
box["next"] = {"kind": "observation", "content": "that makes me think...", "salience": 0.5, "status": "open"}
|
||||
|
||||
rep = th.think(force_mode="react")
|
||||
assert rep["mode"] == "react"
|
||||
assert th.list_threads()[0]["title"] == "World Item" # titled from the item
|
||||
assert used == ["x1"] # item consumed
|
||||
|
||||
|
||||
# --- proactive reach-out (ntfy) ------------------------------------------
|
||||
|
||||
def test_maybe_ping_gates_on_salience_and_records(lyra, monkeypatch):
|
||||
_, th, box = lyra
|
||||
monkeypatch.setenv("NTFY_URL", "http://ntfy.test")
|
||||
monkeypatch.setenv("PING_QUIET_HOURS", "0-0") # disable quiet window for the test
|
||||
sent = []
|
||||
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
|
||||
|
||||
_gen(box, title="big one", content="this really tugs", salience=0.9)
|
||||
r = th.think(force_mode="new") # high salience -> should ping
|
||||
assert len(sent) == 1 and "big one" in sent[0]["title"]
|
||||
assert th.get_thread(r["thread_id"])["status"] == "surfaced" # ping marks it surfaced
|
||||
assert th._meta_get("last_ping_at")
|
||||
|
||||
sent.clear()
|
||||
assert th.maybe_ping(r["thread_id"], "x", "quiet musing", 0.4) is False # below bar
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_no_ping_without_ntfy(lyra, monkeypatch):
|
||||
_, th, _ = lyra
|
||||
sent = []
|
||||
monkeypatch.setattr(th.notify, "push", lambda **k: (sent.append(k), True)[1])
|
||||
# no NTFY_URL in env -> disabled regardless of salience
|
||||
assert th.maybe_ping(1, "t", "c", 0.99) is False
|
||||
assert sent == []
|
||||
Reference in New Issue
Block a user