Brian fires a quick message then locks his phone to go play the hand — which drops
the SSE stream, and on wake the UI re-fires via the blocking fallback. The prior
(session, message) + 20s window caught the near-simultaneous case but not a re-fire
minutes later.
Now the UI stamps each send with a unique turnId (crypto.randomUUID) and carries the
SAME id on both the stream and the fallback; the server dedupes on it. Bulletproof
regardless of how long he's away — lock for an hour, come back, still exactly one
execution and one log — and a genuinely new send gets a fresh id so nothing legit is
swallowed. Id-keyed turns keep a long (1h) window; requests without an id keep the
short (session, msg) window for near-simultaneous dupes.
Verified end-to-end: two POSTs with the same turnId → one reply, one persisted
exchange pair (the duplicate reused the owner's result). 9 dedup tests; suite 235.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chat only ever lived in SQLite's `exchanges` table (what was *said*); tool
calls were transient — logged to the in-memory ring buffer and gone at
end-of-turn. This adds a persistent record of what Lyra *did* and exports the
two merged into one transcript.
- memory: new `tool_events` table + `add_tool_event`/`tool_events` accessors;
`delete_session` cascades to it.
- chat: persist each tool call (name/args/result) right where it fires, in both
the non-stream and stream paths. Move the user `remember` to just after
assembly so its timestamp precedes mid-turn tool events — keeps the export in
true chronological order (and records when the message actually arrived).
- transcript: new module renders a session as Markdown (Brian/Lyra speech with
⚙ tool-call lines interleaved) or JSON (machine-readable event stream).
- server: GET /sessions/{id}/export?format=md|json (attachment download).
- ui: ⬇ Export button by the session selector (Markdown or JSON).
Doubles as the receipt for "did the tool actually fire?" — the thing that was
invisible when she'd reply about a hand without logging it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Poll /session/data on load, every 10s, and on foreground; show the
stack box only when session.is_live, hide it otherwise (logging with no
live session just errors). Hidden by default until confirmed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
iOS won't render interactive content in the bottom ~59px (home-indicator
zone) and underreports innerHeight by the top inset, so the bar can't be
pushed lower. Instead, keep the shell at 100dvh (no clipping) and paint
the strip below it the same color as the tab bar (html/body bg + a
lighter --bg-line bar + matched theme-color) so it reads as the bar
continuing to the edge rather than an empty gap. Cache-bust the
stylesheet so the PWA picks it up.
Tried the boolinator innerHeight-correction approach (adding the top
inset back via --actual-vh); it clipped the icons on-device, so reverted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
Brian's bottleneck is committing, not generating options, so a pros/cons dump makes
it worse. Decide mode's card: get the real decision crisp, weigh it against what HE
values + past regrets (pull running_stats/recent_sessions for poker/money calls),
MAKE the call with the one or two reasons that tip it, pressure-test it once, and
stand behind it — no "it's up to you." Read-only lookups, no live logging.
Sixth mode (Talk/Poker/Build/Explore/Study/Decide); added to UI selectors, labels,
badge-cycle. Suite 96 green, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The manual version of the architecture's `route` step: Brian points her at the
TYPE of work and her register + tools shift to match. Biggest single lever on the
'meh' problem (a mode card can demand decisive/technical/generative, countering
gpt-4o's default warm-vapor).
- modes.py: Build (heads-down engineering — decisive, concrete, tradeoffs, no
listicles), Explore (open brainstorming — generative, riffs + honest catch,
spawn threads, don't converge early), Study (poker review away from the table —
analytical, GTO-aware, teaching; read-only lookups + analyze_spot). Cash relabeled
Poker (key kept for compat).
- UI: mode selectors (desktop + mobile) get all five; badge taps now cycle modes.
- design: docs/COGNITION.md (the society-of-parts control-plane sketch).
- tests: presence + tool-gating for the new modes. Suite 85, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Chat page: add "💭 Thoughts" to the mobile slide-out menu (with /thoughts handler),
grouped with Journal. Thoughts was the one page mobile couldn't reach.
- nav.js: on mobile, secondary pages (Thoughts/Journal/Mind/Session/History/Hands/
Logs) now get a ☰ slide-in drawer with the full nav + Settings — matching the
desktop sidebar. Gated to pages without their own mobile menu, so the chat page's
tailored hamburger/tab-bar is left untouched. Shared ITEMS list = one source of truth.
Static-only (no server change). 77 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Her introspection (reflect/think) voice is now switchable live from the web
settings, read each cycle by the dream loop — so Brian can flip it off the 3090
before gaming without touching config or restarting.
- memory: runtime key/value settings table + get_setting/set_setting.
- self_state: INTROSPECTION_MODES (dolphin=local/dolphin3:8b, mi50=Qwen-32B,
off=paused) + introspection_target()/set_introspection_mode(); default "dolphin".
reflect() resolves from the live setting and SKIPS entirely when off.
- thoughts.think(): same resolution + skip-when-off.
- server: GET/POST /settings/introspection.
- index.html: "Inner Voice (introspection)" selector in Settings, applies instantly.
- tests: routing (dolphin/mi50), off-skip for think + reflect. Suite 77, ruff clean.
Default = Dolphin on the 3090 (richer voice). Flip to MI50 or Off in Settings
before gaming — that was the GPU-contention culprit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Desktop nav was scattered and inconsistent — the chat header was crammed with
cross-page links and each standalone page had its own ad-hoc, incomplete back-links
(e.g. /hands could only reach Chat). Now a single nav.js (one source of truth, no
build step) injects a left sidebar on desktop (>=769px) with active-page
highlighting across Chat/Session/History/Hands/Mind/Journal/Logs + Settings.
- nav.js: injects sidebar + its own CSS; body gets padding-left on desktop; hidden
on mobile (each page keeps its bottom bar / back-links there).
- Included on every page (index, session, history, hands, self, journal, logs,
recap, hand).
- Decluttered the chat header: removed the now-redundant cross-page links (kept the
chat-specific session selector + inline Live Log toggle).
- Sidebar Settings opens the chat modal, or navigates to /?settings=1 from elsewhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The execCommand fallback returned true but copied nothing because the textarea
was readOnly=false. iOS only copies from a readOnly + contentEditable field with
a real Range selection + setSelectionRange — fixed that. Also skip the async
Clipboard API unless window.isSecureContext (on the plain-HTTP LAN PWA it could
resolve without copying, showing a false checkmark). If programmatic copy still
fails, fall back to a prompt() with the text so it can be copied by hand, and only
show the ✓ on real success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stops fat-fingered early sends. The single-line input is now an auto-growing
textarea (Claude-app style): Enter inserts a newline and expands the box (up to a
cap, then it scrolls); you tap the ↑ arrow to send. ⌘/Ctrl+Enter still sends from
a hardware keyboard. Send button is now a round arrow; box resets to one line
after sending. Mobile button is a 42-44px touch target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each user and assistant message gets a copy button (⧉ → ✓) that puts the whole
message on the clipboard. Assistant copies the raw markdown (its dataset.raw);
user copies its text.
- copyToClipboard uses the async Clipboard API when available and falls back to a
hidden-textarea + execCommand with an explicit iOS selection range, so it works
on the iPhone PWA over plain-HTTP LAN (no secure context).
- Copy sits in the assistant rate-bar and in a right-aligned bar on user bubbles;
tools stay visible on touch (@media hover:none) since there's no hover on iOS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Answers three gaps: no way to delete a single poker session (only clear_all),
no way to browse past sessions, and Lyra could only see aggregate stats.
- poker.list_sessions() (per-session summary + hand count + recap flag) and
poker.delete_session() (removes a session + its hands/reads/observations/
stacks/rituals; keeps the persistent villain file).
- /history page (date, stakes, venue, net, hours, recap link, per-row delete with
confirm) + /history/data + DELETE /history/{id}. Nav links from chat + HUD.
- recent_sessions read tool, added to the shared lookups so Lyra can answer
"how'd my last few sessions go?" in either mode.
- Delete is UI/CLI only — deliberately not a Lyra tool.
- test_modes.py +2 (list/delete, recent_sessions); 44 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Mobile bottom tab bar: Chat · Hands · Mind · More. "More" opens the drawer
for the long tail (Journal, Log, Settings, sessions); hamburger retired.
- Auto-hides while the keyboard is open (body.kb) so the input pins to the
keyboard; mobile-only (desktop keeps its header nav).
- Removed now-redundant Mind/Hands from the drawer + their listeners.
- Bottom-fill fix: #chat uses 100dvh (the visible viewport) — 100vh/inset:0
reach into the home-indicator zone iOS won't comfortably show, clipping the
bar; dvh/svh exclude it. Tab bar is flex:none with a small fixed bottom
padding (safe-area padding double-counts at dvh height), and the body bg
matches the bar so any strip below #chat is seamless.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- llm.chat_call_stream: streaming generator for all 3 backends (Ollama NDJSON,
OpenAI/MI50 SSE), accumulating tool-call fragments by index.
- chat.respond_stream: mirrors respond()'s tool loop and persistence/compaction,
yielding ("delta", text) / ("tool", name) / ("done", reply).
- POST /v1/chat/stream: SSE endpoint; blocking generator bridged to async via a
worker thread + asyncio.Queue. Old completions endpoint kept as fallback.
- Client streams into a live bubble with a blinking caret; rAF-throttled render
(no full re-parse per token) and instant scroll during stream — fixes iOS
Safari ghosting from per-token smooth-scroll. Falls back to the blocking
endpoint only if nothing streamed (no double-persist).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M1 — PWA mechanics:
- Generate real app icons (apple-touch-icon + manifest 192/512/maskable)
via pure-stdlib gen_icons.py; iOS uses apple-touch-icon, not manifest icons.
- viewport-fit=cover + env(safe-area-inset-*) on header/input/menu so content
clears the notch and home indicator.
- Dynamic height pinned to the VisualViewport (height + offsetTop, re-measured
across the keyboard animation) so the input stays above the iOS keyboard;
100dvh fallback. Kills the squish/gap bugs in standalone mode.
- overscroll containment; flesh out manifest (scope, portrait, maskable).
M2 — visual redesign:
- Realign style.css to the warm low-glow RTO palette already used by the
standalone pages (#0e0e0e panels, #2a1d12 borders); remove the neon
saturated-orange borders and ~15 glow shadows.
- Reserve filled accent for one element (Send); glow only on status pulse +
input focus. Flat warm message bubbles with tail corners.
- Reclaim the mobile header into [≡] Lyra · [status dot]; drop the redundant
status bar (relay status now the header dot, updated in checkHealth).
- prefers-reduced-motion support; fix undefined var(--text); real light-mode tokens.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brian can rate Lyra's outputs as he uses her; each rating is stored as a
(context, content, rating) triple — the shape a future fine-tune / preference
dataset wants, collected passively during real use.
- memory: ratings table + add_rating (upsert: one row per item, re-rating
replaces), list_ratings, rating_counts
- server: POST /rate, GET /ratings/counts, GET /ratings/export (JSONL download)
- chat UI: subtle 👍/👎 on each assistant reply, captures the prompting message
as context
- journal/reflection UI: 👍/👎 on each thought
- tests: counts + upsert-replace behavior
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pick which OpenAI model answers on the Cloud backend (gpt-4o / -mini / 4.1 /
4.1-mini / o4-mini, or Default). Persisted in localStorage, sent as `model` in
the chat request; respond() applies it only on the cloud backend (local/mi50
keep their fixed models). Reachable from desktop + mobile via Settings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the poker copilot loop: talk through a session -> structured capture
-> generated writeup in Brian's format, remembered + exportable.
- poker.generate_recap(): LLM produces Brian's .md log (Session Header, Money
Flow, Overview, Timeline, Key Hands w/ assessments, Villain Notes, Confidence
Bank, Scar Notes, Mental Game, Final Assessment) from the session's structured
data + the linked chat conversation; stored on poker_sessions.recap_md
- sessions now capture chat_session_id (via tool ctx) to pull the right convo;
list_recent_hands() for browsing
- generate_recap tool ("write up the recap")
- web: /recap/{id} (renders the md) + /recap/{id}/download (.md attachment) +
/hands browser (recent hands -> /hand/{id}); nav links added (desktop + mobile)
- tests: recap generation (stubbed), recent-hands listing
Verified live: recap for the Meadows session rendered + downloaded; all pages 200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Her replies are full of **bold**, numbered lists and headings but rendered as
raw monospace text, so the chat was a cluttered wall of literal markup. Add a
small self-contained Markdown renderer (no deps): headings, ordered/unordered
lists, bold/italic, inline + fenced code, links + autolinked URLs, with HTML
escaping. Assistant messages now render to HTML; user/system stay literal text.
Proportional font + spacing/list/code styling for assistant bubbles.
(Renderer avoids literal backticks via String.fromCharCode(96) — a triple-tick
regex literal had been corrupting the file with NUL bytes.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full-page log, read-her-mind, and journal links were only in the desktop
header (hidden behind the hamburger on phones). Add them to the mobile slide-out
menu so the phone has the extended log, her self-state, and her journal too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
You couldn't see her actually correct herself — /self showed only the result.
Now:
- reflect() logs the draft, the revised/committed version, and the self-critique
to the live log as an expandable "view details" block
- POST /self/reflect runs a reflection in the web process so it lands in /logs
live (reflections normally run in the dream process, whose logs only go to
journald); "↻ Reflect now" button on /self triggers it, with a logs ↗ link
- log viewers relabel the expander "view full prompt" -> "view details" (it now
carries prompts and reflection diffs)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A pull-up-anytime view of Lyra's interiority, so her thoughts aren't buried in
a DB blob. Mobile-first, auto-refreshing every 12s (and on tab focus).
- GET /self serves the page; GET /self/state returns her self-state + the
timestamp it last changed
- shows: current mood + feeling meters (valence/energy/confidence/curiosity),
her drives as bars, her self-narrative, the relationship line, and the
reflections list (newest first), plus cycle/reflection counters and "last
cycle Xm ago"
- memory.self_state_updated_at(): when her mind last changed
- index.html: "🧠 Mind" button opens /self
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The inline log panel is cramped, especially on mobile. Add a standalone
mobile-first log page and serve the chat server under systemd like the dream
loop (the nohup process didn't survive cleanly).
- static/logs.html: full-page live log — level filter chips, text search,
pause/resume with buffering, autoscroll toggle, color-coded levels, and the
expandable "view full prompt" block (where the now-note is visible in context)
- server: GET /logs serves the page (FileResponse)
- index.html: "⛶ Full Log" button opens /logs in a new tab
- deploy/lyra-web.service: user service so the chat server is reboot-resilient
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MI50 box (CT202) runs an OpenAI-compatible llama.cpp server on
10.0.0.44:8080. Wire it in as a third backend:
- llm.complete gains backend="mi50" (OpenAI client pointed at MI50_BASE_URL)
- config: MI50_BASE_URL (default http://10.0.0.44:8080/v1) + MI50_MODEL
- chat.respond labels the model per backend; web _backend_for maps "mi50"
- UI backend selector adds "MI50 — local GPU"
Verified end-to-end: llm.complete(backend="mi50") returns from the live server.
See homelab-inference memory for the box topology.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "context built" event now carries the fully-rendered prompt (persona, gists,
recalled details, recent turns, the new message) plus a total char count. The
log panel renders it as a collapsed "view full prompt" block — clean by default,
one click to see exactly what hit the model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn the inert "Show Work" thinking panel into a real live activity log:
- lyra/logbus.py: thread-safe in-memory ring buffer other modules publish to
- chat.respond logs backend/model/embed per turn, recall counts, reply size;
web layer logs chat errors
- server: replace the keep-alive /stream/thinking stub with /stream/logs, an
SSE endpoint that replays the recent buffer then streams new events
- UI: repurpose the panel as a global "Live Log" — connects on load, renders
level/time/msg/fields, drops the old per-session localStorage + dead popup
Every turn now shows its backend + model in-app, so local-vs-cloud (free vs
paid) is visible at a glance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 1 — persona + persistent memory chat loop:
- lyra/persona.py + personas/lyra.md: editable identity/voice (friend-first,
honest, never invents poker math)
- lyra/chat.py: turn loop assembling persona + cross-session recall + recent
context, persisting both sides to SQLite
- lyra/session.py, lyra/__main__.py: session lifecycle + `lyra` REPL
Phase 1.25 — reuse the old web UI:
- vendored the prior single-page UI into lyra/web/static, repointed to
same-origin
- lyra/web/server.py (FastAPI): serves the UI and backs its endpoint contract
(/v1/chat/completions, session CRUD, health, inert thinking-stream) with the
new chat loop + memory; SQLite stays the single source of truth
- `lyra-web` console script
Local backends — test for free, no OpenAI key:
- llm.embed routes via EMBED_BACKEND (cloud=OpenAI, local=Ollama /api/embed)
- simplified UI backend selector to Local (Ollama) / Cloud (OpenAI), default local
- memory connection opened check_same_thread=False for the threaded server
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>