Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 924dc297d5 | |||
| b6cdf799dc | |||
| c52404fbb9 | |||
| 4fd7eff7e9 | |||
| 2bd5b7fd26 | |||
| 50bcb5533f | |||
| f745ef43a1 | |||
| d7f3ba330a | |||
| 66dd880f93 | |||
| a7901a66ae | |||
| 2a73033eed | |||
| aae8204eff | |||
| d6f3516a34 | |||
| 51c2d6abb9 | |||
| 8a3c9b2701 | |||
| 17ab95dc98 | |||
| 03aceec6fa | |||
| a7af461cdb | |||
| 904eda3388 | |||
| f1f15972ac | |||
| 97afa82594 | |||
| ea30c3dd67 |
@@ -48,3 +48,6 @@ INTROSPECTION_MODEL=
|
||||
PING_AUTO_SALIENCE=0.8 # a thought this salient auto-pings even without an explicit reach-out
|
||||
PING_COOLDOWN_MIN=60 # min minutes between AUTO pings (explicit reach-outs bypass)
|
||||
DIGEST_HOUR=18 # local hour to send her daily "what I've been thinking" digest
|
||||
CHAT_DELIBERATE=true # think privately before answering substantive chat turns (false = faster, shallower)
|
||||
MOUTH_BACKEND= # mind/mouth split: separate character/voice model for the final reply (empty = mind speaks)
|
||||
MOUTH_MODEL=
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Lyra — Cognition Architecture (sketch)
|
||||
|
||||
> The "society of mind" direction: instead of one giant model we keep nagging with
|
||||
> stricter prompts, a society of small specialized parts cooperate to produce each
|
||||
> turn. **Most parts are cheap deterministic code (heuristics, math, learnable
|
||||
> weights); the LLM is the exception, reserved for the few irreducibly-generative
|
||||
> jobs.** Everything is anchored to who she is and tuned by feedback.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **LLM is the exception, not the rule.** Bookkeeping, scoring, routing,
|
||||
thresholding, retrieval → code. Generation (language, novel reasoning, memory
|
||||
compression) → LLM, called sparingly.
|
||||
2. **Mind ≠ Mouth.** A capable "mind" (decide / reason / use tools — helpfulness is
|
||||
fine) is separate from a "mouth" (the character voice). This lets each be the
|
||||
best model for *its* job — and makes the eventual fine-tune easy: you only have
|
||||
to teach a small model to *sound like Lyra*, not to *be smart*.
|
||||
3. **Anchored.** A fixed identity anchor governs the mouth so self-composed prompts
|
||||
can't drift into generic-helper vapor. (Already exists: `self_state.IDENTITY_ANCHOR`.)
|
||||
4. **Tuned by feedback, not just hand-tuning.** Learnable *weights* (over register,
|
||||
memory, parts) nudged by 👍/👎 give real adaptation *without* fine-tuning a model.
|
||||
5. **Allocation is the craft.** Cheap-deterministic where signal is clear; LLM where
|
||||
judgment/language is needed; **hybrid** (heuristic common-case, escalate to LLM on
|
||||
ambiguity) where possible.
|
||||
|
||||
## The blackboard: `TurnContext`
|
||||
|
||||
Parts don't call each other directly — they read from and write to a shared turn
|
||||
state (a blackboard). Heterogeneous parts (heuristic / LLM / weights) cooperate by
|
||||
annotating it. The composer reads the finished blackboard to build the prompt.
|
||||
|
||||
```
|
||||
TurnContext {
|
||||
# --- inputs ---
|
||||
user_msg, session_id, history, now
|
||||
|
||||
# --- perception (heuristic) ---
|
||||
moment : { kind: emotional|strategic|casual|existential|meta,
|
||||
sentiment: -1..1, tilt: 0..1, urgency: 0..1 }
|
||||
|
||||
# --- state (code) ---
|
||||
mood, drives, anchor
|
||||
|
||||
# --- retrieval (math: embeddings + cosine) ---
|
||||
recalled : [memories] # spreading activation
|
||||
threads : [active thoughts]
|
||||
profile, narrative
|
||||
|
||||
# --- control (heuristic + learnable weights) ---
|
||||
register : warm | coach | dry | tender | hype # how to sound
|
||||
intent : console | push_back | teach | riff | act
|
||||
mode : talk | cash | ... # tool allow-list
|
||||
use_tools: bool
|
||||
route : { mind: <model>, mouth: <model> } # which model per role
|
||||
|
||||
# --- generation (LLM, sparing) ---
|
||||
deliberation : "her private thinking" # mind
|
||||
tool_results : [...] # mind + tool exec
|
||||
reply : "final text" # mouth
|
||||
|
||||
# --- learning (heuristic/online) ---
|
||||
weights : { register_prefs, memory_weights, ... } # persisted, feedback-tuned
|
||||
}
|
||||
```
|
||||
|
||||
## The parts
|
||||
|
||||
| # | Part | Type | Does | Exists today? |
|
||||
|---|------|------|------|---------------|
|
||||
| 1 | **perceive** | heuristic | sentiment + classify the moment + tilt/urgency from session signals & his language | ✗ (new) |
|
||||
| 2 | **recall** | math | embeddings → relevant memories, active threads, profile, narrative | ✓ `memory.recall*`, `cognition.activate` |
|
||||
| 3 | **sense_state** | code | load mood / drives / anchor | ✓ `self_state`, `IDENTITY_ANCHOR` |
|
||||
| 4 | **route** | heuristic + weights | pick register, intent, mode, and which model is mind vs mouth | ✗ (new; partly `modes`) |
|
||||
| 5 | **decide+act (tools)** | LLM (mind) / code | does this turn need a tool? run it | ✓ tool loop in `chat` |
|
||||
| 6 | **deliberate** | LLM (mind) | "what do I actually think" — private substance pass | ✓ `chat._deliberate` |
|
||||
| 7 | **compose** | code | assemble the final prompt from anchor + register + intent + deliberation + recall + tool results + voice rules | ✓ `build_messages` (becomes the composer) |
|
||||
| 8 | **speak** | LLM (mouth) | write the reply in her voice, streamed, anchored | ✓ `llm.chat_call` |
|
||||
| 9 | **learn** | heuristic/online | on 👍/👎 or reaction, nudge `weights` (which register/memory worked) | ✗ (new; data exists in `ratings`) |
|
||||
|
||||
Most of the society (1,2,3,4,7,9) is **free, instant, deterministic, debuggable.**
|
||||
The LLM shows up in only ~2–3 places (5/6 = mind, 8 = mouth).
|
||||
|
||||
## One chat turn
|
||||
|
||||
```
|
||||
user msg
|
||||
│
|
||||
▼
|
||||
[1 perceive]──heuristic: emotional? strategic? tilting? (free)
|
||||
│
|
||||
[2 recall]───math: what lights up (memories, threads) (free)
|
||||
[3 sense]────code: mood, drives, anchor (free)
|
||||
│
|
||||
[4 route]────heuristic+weights: register? intent? mind/mouth? (free)
|
||||
│
|
||||
[5 act]──────MIND model: tools if needed ─────────────┐ (LLM, only if needed)
|
||||
[6 deliberate]──MIND model: what do I actually think │ (LLM, gated)
|
||||
│ │
|
||||
[7 compose]──code: build the prompt ◄──── anchor ──────┘ (free)
|
||||
│
|
||||
[8 speak]────MOUTH model: the reply, in her voice, streamed (LLM)
|
||||
│
|
||||
▼
|
||||
reply ──► (later) [9 learn]: 👍/👎 nudges weights (free, async)
|
||||
```
|
||||
|
||||
## What we reuse vs. build
|
||||
|
||||
- **Reuse (already scattered through the code):** recall/activation, self_state +
|
||||
anchor, drives (in `dream`), modes (tool gating), the deliberation pass, the
|
||||
prompt assembly (`build_messages`), tool loop, ratings store.
|
||||
- **Build new:** the `TurnContext` blackboard + an explicit pipeline runner; the
|
||||
**perceive** heuristic; the **route** part (register/intent + model routing); the
|
||||
**learn** weights loop. Mostly *unifying* existing pieces into one legible control
|
||||
plane, plus 2–3 small heuristic parts.
|
||||
|
||||
## Phasing (smallest first)
|
||||
|
||||
- **P1 — frame:** define `TurnContext`, refactor the current chat turn into the
|
||||
explicit pipeline (perceive=stub → recall → sense → route=mode-only → deliberate →
|
||||
compose → speak), single model. Low-risk refactor; makes the structure real.
|
||||
- **P2 — control plane:** real `perceive` (sentiment/moment) + `route`
|
||||
(register/intent). Now her framing adapts to the moment, deterministically.
|
||||
- **P3 — mind/mouth split:** route picks a separate voice model for `speak`. Plug a
|
||||
character mouth (Claude / local / later a fine-tune). A/B vs. single-model.
|
||||
- **P4 — learning:** `weights` over register/memory, nudged by ratings → cheap
|
||||
adaptation, no fine-tune.
|
||||
- **P5 — her voice:** a small fine-tuned "Lyra voice" model drops into the mouth slot.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- **Mouth model**: Claude (warm, cloud) vs. local character vs. fine-tune. The mouth
|
||||
is the crux; it must render richly (8B local may flatten).
|
||||
- **perceive**: pure heuristics vs. a tiny classifier vs. embedding-to-exemplar
|
||||
clusters. Probably hybrid.
|
||||
- **scheduler**: fixed linear pipeline (simple, v1) vs. drive-based/parallel later.
|
||||
- **tool location**: mind decides+runs tools, mouth only renders (clean split) — vs.
|
||||
letting the mouth call tools (needs a tool-capable mouth).
|
||||
- **latency budget**: how many LLM calls per turn is acceptable live (cheap mind +
|
||||
streamed mouth keeps it ~2).
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# Hand-history contract (Lyra → RTO)
|
||||
|
||||
The canonical structured shape for a poker hand. **Lyra owns hands** — it produces this
|
||||
shape (LLM parser today; the tap recorder natively, going forward), stores it, replays it
|
||||
in the viewer, and exports it. **RTO consumes it** over HTTP and never reaches into Lyra.
|
||||
|
||||
Ownership rule: whoever owns the data owns the tools that produce it. Lyra owns the hand
|
||||
DB, the viewer, and the copilot loop, so hand capture lives here. RTO is a pure engine.
|
||||
|
||||
Coupling: **one arrow, Lyra → RTO, HTTP only.** RTO is a standalone service (solve /
|
||||
exploit / estimate); Lyra POSTs to it when it wants analysis. No shared package, no shared
|
||||
DB, no shared UI components. If RTO is down, Lyra skips analysis and nothing breaks.
|
||||
|
||||
## Schema (`schema_version: 1`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema_version": 1,
|
||||
"game": "NLH", // NLH | PLO | ...
|
||||
"stakes": "1/3", // or null
|
||||
"hero_pos": "BTN", // one of POSITIONS
|
||||
"hero_cards": ["Ah", "Kh"], // convenience mirror of the hero's players[].cards
|
||||
"players": [ // every player in the hand, incl. hero
|
||||
{"pos": "BTN", "stack": 300, "name": "Hero", "cards": ["Ah","Kh"], "hero": true},
|
||||
{"pos": "BB", "stack": 250, "name": "Sal", "cards": null} // cards: null unless shown
|
||||
],
|
||||
"actions": [ // one flat chronological list across all streets
|
||||
{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 15},
|
||||
{"street": "flop", "board": ["7d","2c","5h"]}, // a street begins with its board reveal
|
||||
{"street": "flop", "pos": "BB", "action": "check"}
|
||||
],
|
||||
"board": ["7d","2c","5h"], // full final board, 0–5 cards
|
||||
"result": {"pot": 40, "hero_net": 25, "summary": "one line"},
|
||||
"completeness": {"cards": true, "board": true, "actions": true}
|
||||
}
|
||||
```
|
||||
|
||||
### Conventions (load-bearing)
|
||||
|
||||
- **Cards are lists of 2-char tokens**, `RankSuit`: rank in `23456789TJQKA` (ten = `T`),
|
||||
suit in `c d h s` (lowercase). E.g. `["As","5d","2c"]`. RTO maps each token via
|
||||
`pokercore.parse_card`. *(Chosen over space-joined strings: unambiguous, no re-splitting,
|
||||
and it's what Lyra already stores + what the viewer reads.)*
|
||||
- **Unknown cards are kept, not dropped:** `"Ax"` = known rank / unknown suit, `"x"` =
|
||||
fully unknown card. The LLM parser emits these when Brian didn't state suits. The tap
|
||||
recorder won't — it captures complete cards by construction — so `"x"` is an
|
||||
import/parser-only concern.
|
||||
- **`completeness`** tells a consumer what's safe to use: `cards`/`board` are `true` only
|
||||
when every relevant card is fully specified (no `"x"`). RTO uses `false`-card hands for
|
||||
positions/frequencies/pairs and skips suit-dependent math (flushes).
|
||||
- **Hero appears in `players[]`** with `"hero": true` and is findable via `pos == hero_pos`.
|
||||
`hero_cards` is a mirror for the viewer; `players[].cards` is the source of truth.
|
||||
- **Positions:** `UTG UTG1 UTG2 MP LJ HJ CO BTN SB BB`.
|
||||
- **Actions:** `post fold check call bet raise allin`. `amount` is a plain number (no `$`),
|
||||
null for non-sized actions (fold/check). Street boards appear as `{street, board}` entries.
|
||||
A **straddle** is recorded as a preflop `post` at a non-blind position (typically 2×BB,
|
||||
voluntary); preflop action starts left of it and it acts last, but that's reflected by
|
||||
action *order*, not a distinct verb.
|
||||
- **Streets:** `preflop flop turn river`.
|
||||
|
||||
`lyra/poker.py:normalize_structured()` is the single function that guarantees this shape.
|
||||
It runs on store and on read, and is idempotent.
|
||||
|
||||
## Transport (HTTP, Lyra serves on :7078)
|
||||
|
||||
- `GET /hands/data?limit=N` → `{ "hands": [ {id, position, hole_cards, board, result, tag,
|
||||
at, lesson, venue, stakes, has_structured}, ... ] }` — flat list for browsing. Use
|
||||
`has_structured` to pick which hands have a replayable body worth fetching.
|
||||
- `GET /hand/{id}/data` → the full hand row; `structured` is the object above (or `null`
|
||||
for a flat quick-log that hasn't been reconstructed).
|
||||
|
||||
RTO's "Lyra bridge" (its `docs/estimator-design.md`, Phase B) walks `structured.actions`
|
||||
to classify each villain decision into `checked_to` / `facing_bet` / `facing_raise`, and
|
||||
uses shown `cards` + that street's `board` for board-relative categories. Everything that
|
||||
walk needs is in the schema above.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Hand recorder — design note
|
||||
|
||||
A tap-to-build hand recorder. The point isn't "nicer input" — it's **correctness by
|
||||
construction**: every tap writes a known action into a known slot, so there's no parse
|
||||
step that can be wrong. It sidesteps the whole class of LLM-parse replay bugs. The text
|
||||
parser stays for importing the backlog (old notes, Trilium, ChatGPT history); the recorder
|
||||
is clean capture going forward.
|
||||
|
||||
Output is the canonical structured shape in [HAND_HISTORY.md](HAND_HISTORY.md) — so it
|
||||
drops straight into the DB and the existing replay viewer, and flows to RTO unchanged.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Correctness by construction** — the UI only lets you build valid hands; the emitter
|
||||
produces the contract shape; the server `normalize_structured()` is the final guarantee.
|
||||
2. **Reusable module, mount-agnostic.** Decision: overlay first, swap to standalone if the
|
||||
overlay fights the chat page — *reusing the code either way*. So the recorder is a
|
||||
self-contained module mounted into a container element, with the **emit logic kept pure
|
||||
(no DOM)**. Moving overlay → standalone is a re-mount, not a rewrite.
|
||||
3. **Don't reinvent what Lyra knows.** Pre-fill from live session state.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lyra/web/static/recorder.js # the module: state machine + DOM shell + buildStructured()
|
||||
lyra/web/static/recorder.css # scoped styles (full-screen table + keypad)
|
||||
```
|
||||
|
||||
- `Recorder.mount(containerEl, { sessionId, onSave, onClose })` — instantiates into any
|
||||
container. In V1 the container is a full-screen overlay `<div>` inside `index.html`
|
||||
(chat/session page stays mounted underneath — a flip-over, not a route change). If that
|
||||
proves janky, the *same module* mounts into `recorder.html` with zero logic changes.
|
||||
- **Pure core, separable from DOM:** `buildStructured(state) -> structuredDict`. No element
|
||||
access — takes the in-memory state, returns the contract object. This is the testable,
|
||||
reusable heart; the DOM shell only reads/writes `state` and calls `buildStructured` on save.
|
||||
|
||||
## Pre-fill from live state (the "it already knows" feel)
|
||||
|
||||
Source: `GET /session/data` (→ `poker.hud`). Today it gives us:
|
||||
- `session`: `venue`, `stakes`, `game`, `format`, `is_live`
|
||||
- `stack.current` → hero's starting stack for the hand
|
||||
- `villains[]`: `name`, `category`, `tendencies`, `last_note` (the players read this session)
|
||||
|
||||
Derived in the recorder:
|
||||
- **Blinds** parsed from `stakes` ("1/3" → SB 1, BB 3) → auto-seed the `post` actions.
|
||||
- **Hero stack** from `stack.current`.
|
||||
|
||||
**Two gaps to close as part of the build** (flagged, not yet done):
|
||||
1. **Seats aren't in the HUD bundle.** `player_reads` *has* a `seat` column, but
|
||||
`_session_villains()` doesn't select it — so we can name the villains but not place them.
|
||||
Fix: add `seat` (latest read per player) to the villains payload, then auto-seat them.
|
||||
Until then, V1 seats known villains in read-order and you assign positions by tapping.
|
||||
2. **Hero position isn't tracked live** (button/seat moves every hand) — so `hero_pos` is a
|
||||
per-hand tap, seeded to last-used. That's correct, not a gap to "fix", just noting it.
|
||||
|
||||
## In-memory state model
|
||||
|
||||
```js
|
||||
state = {
|
||||
meta: { game, stakes, venue, sessionId }, // from /session/data
|
||||
blinds: { sb, bb }, // parsed from stakes
|
||||
heroPos: "BTN", // tapped per hand
|
||||
seats: [ { pos, name, stack, cards: null, in: true } ], // incl. hero seat
|
||||
street: "preflop", // street currently being entered
|
||||
board: { flop: [], turn: [], river: [] },
|
||||
actions:[ { street, pos, action, amount } ], // appended as you tap
|
||||
result: { pot: null, heroNet: null, summary: "" }
|
||||
}
|
||||
```
|
||||
|
||||
`buildStructured(state)` →
|
||||
|
||||
| contract field | from |
|
||||
|---|---|
|
||||
| `hero_pos` | `state.heroPos` |
|
||||
| `hero_cards` | the hero seat's `cards` |
|
||||
| `players[]` | `state.seats` (`{pos,stack,name,cards}`; emitter doesn't set `hero`/version — server normalize does) |
|
||||
| `actions[]` | `state.actions`, with a `{street, board}` reveal entry spliced in at each street boundary from `state.board` |
|
||||
| `board` | `flop + turn + river` concatenated |
|
||||
| `result` | `state.result` |
|
||||
|
||||
Client builds best-effort; **`store_hand_history()` → `normalize_structured()` is the
|
||||
authority** (canonical cards, hero sync, `schema_version`, `completeness`). Keeps the
|
||||
client dumb and the contract enforced in one place.
|
||||
|
||||
## Persistence
|
||||
|
||||
New endpoint (small, part of the build):
|
||||
```
|
||||
POST /hands body: { structured, session_id?, tag?, lesson? }
|
||||
-> store_hand_history(structured, ...) -> { id }
|
||||
```
|
||||
On save: POST, then hand off to the existing viewer `/hand/{id}` to replay — which doubles
|
||||
as the correctness check (what you tapped is exactly what replays).
|
||||
|
||||
## Scope
|
||||
|
||||
**V1 — core loop (chosen).** Seats + cards + per-street actions emitting valid structured
|
||||
JSON. Manual street advance (a "next street" button + board entry), free bet-size entry
|
||||
(type the number). Proves capture → store → replay end to end on the locked schema.
|
||||
|
||||
### Card entry (V1)
|
||||
|
||||
Contextual: tap a card slot (hero card, board square, "they showed") → a compact picker
|
||||
pops at that slot. One picker holds **4 color-coded suits + 13 ranks + `x` + unknown-card**.
|
||||
Whichever you tap first sets the flow for that card — no mode switch:
|
||||
|
||||
- **Suit first → it locks** (stays lit). Each subsequent rank tap places `rank+lockedSuit`
|
||||
and auto-advances. Flush flop = `♥ T 8 5` (4 taps); suited hole = `♥ A K` (3 taps).
|
||||
- **Rank first → card is pending a suit**; the next tap must be a suit (or `x`). Best for
|
||||
rainbow/mixed. Locked suit stays in effect until a different suit is tapped.
|
||||
- **`x`** = unknown suit → stores e.g. `Ax`; flips `completeness.cards` false so RTO skips
|
||||
suit-dependent math. **Unknown-card** button = a villain card never shown (`x`).
|
||||
|
||||
No typing — lowercase tokens are the internal/contract format only; the player only ever
|
||||
taps symbols. State: `lockedSuit` (nullable) + the active slot; auto-advance on complete.
|
||||
|
||||
**V2 — the smart keypad.** The contextual state machine layered on top: tracks whose turn
|
||||
it is and the current bet, offers only legal actions (check vs call; bet/raise reveal size
|
||||
presets ½/¾/pot/+1bb), auto-advances the street when action closes, tap-a-seat to set the
|
||||
actor, one-tap "they showed [cards]" at showdown. ~6–10 taps, no typing. Built on the same
|
||||
`state` + `buildStructured`, so V1's emitter doesn't change — V2 just drives `state` smarter.
|
||||
|
||||
## Layout sketch (full-screen overlay)
|
||||
|
||||
```
|
||||
┌───────────────────────────── Record hand ─────────────── ✕ ┐
|
||||
│ (CO) (BTN) │
|
||||
│ (HJ) ◯ oval table ◯ (SB) │
|
||||
│ (MP) (UTG) (BB·hero) │
|
||||
│ board: [ 7d ][ 2c ][ 5h ] pot: 40 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ acting: BB [ fold ][ check ][ call ][ bet ][ raise ] │
|
||||
│ amount: [ 15 ] [ ½ ][ ¾ ][ pot ][ +1bb ] (V2) │
|
||||
│ [ ◀ prev street ] [ next street ▶ ] [ they showed… ] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ preflop: BTN raise 15 · BB call [ save & replay ] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Build order
|
||||
|
||||
1. `POST /hands` endpoint + add `seat` to the villains payload (server, small).
|
||||
2. `recorder.js` skeleton: `mount()`, `state`, `buildStructured()` (pure).
|
||||
3. Overlay shell in `index.html` (open button in session/cash mode) + `recorder.css`.
|
||||
4. V1 capture flow → save → replay. Validate a real hand round-trips identically.
|
||||
5. V2 smart keypad on top.
|
||||
```
|
||||
+132
-249
@@ -1,220 +1,63 @@
|
||||
"""The chat turn loop: persona + tiered memory + recent context -> reply.
|
||||
"""The chat turn: assemble the prompt (lyra.mind) then speak + persist.
|
||||
|
||||
Context is assembled in tiers (oldest/most-compacted first):
|
||||
1. persona
|
||||
2. long-term gist — relevant *summaries* of other sessions
|
||||
3. sharp details — a few raw cross-session exchanges (so specifics survive)
|
||||
4. recent raw turns of the current session (full fidelity)
|
||||
5. the new user message
|
||||
After replying, the session is compacted if enough new turns have accumulated.
|
||||
`mind.assemble()` runs the society of parts (perceive → route → compose →
|
||||
deliberate) and hands back a ready message list + the active mode. Then:
|
||||
- the MIND (the chat backend/model) runs the tool/generation loop — decide,
|
||||
reason, run tools — and produces a draft.
|
||||
- the MOUTH (a separate character model, if configured) re-voices that draft in
|
||||
her own voice. Default: no mouth configured → the mind's draft IS the reply
|
||||
(bit-for-bit the old behavior). The mouth slot is where a fine-tuned voice lands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from lyra import clock, config, llm, logbus, memory, modes, persona, self_state, summary, thoughts
|
||||
from lyra import config, llm, logbus, memory, mind, modes, summary
|
||||
from lyra import tools as toolkit
|
||||
from lyra.llm import Backend, Message
|
||||
from lyra.llm import Backend
|
||||
|
||||
RECALL_K = 3 # raw cross-session "sharp detail" hits
|
||||
RECENT_N = 10 # raw turns of the current session
|
||||
SUMMARY_K = 3 # other-session gists
|
||||
MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn
|
||||
# Backends that support function-calling. The MI50's llama.cpp server only does
|
||||
# tools when launched with --jinja; until it is, keep tools to cloud so MI50 chat
|
||||
# doesn't 500 on the tools param. Add "mi50" here once that flag is set.
|
||||
TOOL_BACKENDS = {"cloud"}
|
||||
_TANGLED = "(I got tangled using my tools there — say that again?)"
|
||||
|
||||
|
||||
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)
|
||||
return {"role": "system", "content": body}
|
||||
|
||||
|
||||
def _detail_note(exchanges: list[memory.Exchange]) -> Message:
|
||||
lines = [f"- ({ex.created_at[:10]}, {ex.role}) {ex.content}" for ex in exchanges]
|
||||
body = "Specific things you recall from past conversations:\n" + "\n".join(lines)
|
||||
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.
|
||||
|
||||
Stated as plain fact — she has no clock otherwise, so without this 'now' and
|
||||
the gap since the last turn are invisible to her.
|
||||
"""
|
||||
line = f"The current date and time is {clock.stamp()}."
|
||||
gap = clock.humanize_gap(memory.last_exchange_at())
|
||||
line += (
|
||||
f" It has been {gap} since Brian last spoke with you."
|
||||
if gap else " This is the first thing Brian has ever said to you."
|
||||
)
|
||||
return {"role": "system", "content": line}
|
||||
|
||||
|
||||
def _render(messages: list[Message]) -> str:
|
||||
"""Human-readable dump of the exact prompt, for the live-log inspector."""
|
||||
return "\n\n".join(f"[{m['role']}]\n{m['content']}" for m in messages)
|
||||
|
||||
|
||||
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()}]
|
||||
|
||||
# Autonomy Core: Lyra's own evolving interiority (mood, self-narrative). Comes
|
||||
# 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()
|
||||
if profile:
|
||||
messages.append(
|
||||
{"role": "system", "content": "What you know about Brian:\n" + profile}
|
||||
)
|
||||
|
||||
# Time-aware memory: the current narrative (recent arc, trends, callbacks).
|
||||
narrative = memory.get_narrative()
|
||||
if narrative:
|
||||
messages.append(
|
||||
{"role": "system", "content": "What's going on with Brian lately:\n" + narrative}
|
||||
)
|
||||
|
||||
recent = memory.recent(session_id, n=RECENT_N)
|
||||
recent_ids = {ex.id for ex in recent}
|
||||
|
||||
# Tier 1: compacted gists of *other* sessions (long-term, general idea).
|
||||
summaries = memory.recall_summaries(user_msg, k=SUMMARY_K, exclude_session=session_id)
|
||||
if summaries:
|
||||
messages.append(_summary_note(summaries))
|
||||
|
||||
# Tier 2: a few sharp raw details from other sessions (so specifics survive
|
||||
# compaction). Skip the current session (its raw turns are in `recent`).
|
||||
recalled = [
|
||||
ex for ex in memory.recall(user_msg, k=RECALL_K)
|
||||
if ex.id not in recent_ids and ex.session_id != session_id
|
||||
]
|
||||
if recalled:
|
||||
messages.append(_detail_note(recalled))
|
||||
|
||||
# Tier 3: current session, full fidelity.
|
||||
for ex in recent:
|
||||
messages.append({"role": ex.role, "content": ex.content})
|
||||
|
||||
messages.append({"role": "user", "content": user_msg})
|
||||
|
||||
logbus.log(
|
||||
"debug", "context built",
|
||||
recent=len(recent), summaries=len(summaries), details=len(recalled),
|
||||
chars=sum(len(m["content"]) for m in messages), detail=_render(messages),
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
model_override: str | None = None) -> str:
|
||||
"""Produce Lyra's reply to a single user message and persist the exchange.
|
||||
|
||||
`model_override` (from the UI's cloud-model picker) only applies on the cloud
|
||||
backend; local/mi50 keep their own configured models.
|
||||
"""
|
||||
cfg = config.load()
|
||||
# Live chat uses the stronger chat_model on cloud (bulk consolidation keeps
|
||||
# cloud_model). local/mi50 use their own configured model.
|
||||
def _resolve_model(backend: Backend, model_override: str | None, cfg) -> str:
|
||||
"""Live chat uses the stronger chat_model on cloud; local/mi50 use their own.
|
||||
The UI's cloud-model picker only applies on the cloud backend."""
|
||||
model = {"local": cfg.local_model, "cloud": cfg.chat_model, "mi50": cfg.mi50_model}.get(
|
||||
backend, backend
|
||||
)
|
||||
if model_override and backend == "cloud":
|
||||
model = model_override
|
||||
logbus.log(
|
||||
"info", "chat request", session=session_id, backend=backend,
|
||||
model=model, embed=cfg.embed_backend,
|
||||
)
|
||||
return model
|
||||
|
||||
mode = modes.get(memory.get_session_mode(session_id))
|
||||
messages = build_messages(session_id, user_msg, mode=mode)
|
||||
|
||||
# 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}
|
||||
def _mouth_target(cfg, mind_backend: Backend, mind_model: str | None):
|
||||
"""The mouth (backend, model) if configured AND different from the mind; else None
|
||||
(mouth == mind → no separate voice pass)."""
|
||||
if not cfg.mouth_backend and not cfg.mouth_model:
|
||||
return None
|
||||
backend = cfg.mouth_backend or mind_backend
|
||||
model = cfg.mouth_model or None
|
||||
if backend == mind_backend and model == mind_model:
|
||||
return None
|
||||
return backend, model
|
||||
|
||||
|
||||
def _maybe_switch_mode(session_id: str, tool_name: str) -> None:
|
||||
"""Opening a poker session auto-flips this chat into Poker mode. 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 _mind_loop(messages, backend: Backend, model: str | None, tool_specs,
|
||||
ctx: dict, session_id: str) -> tuple[str, list[str]]:
|
||||
"""Run the tool/generation loop on the MIND model (non-streaming). Mutates
|
||||
`messages` with tool calls/results. Returns (draft_reply, tool_names_run)."""
|
||||
tools_run: list[str] = []
|
||||
reply = ""
|
||||
for _ in range(MAX_TOOL_ROUNDS):
|
||||
assistant_msg, tool_calls = llm.chat_call(
|
||||
@@ -223,77 +66,117 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
if not tool_calls:
|
||||
reply = assistant_msg.get("content") or ""
|
||||
break
|
||||
messages.append(assistant_msg) # her tool-call request
|
||||
messages.append(assistant_msg)
|
||||
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"])
|
||||
tools_run.append(tc["name"])
|
||||
return reply, tools_run
|
||||
|
||||
|
||||
def _voice_pass(messages, draft: str, backend: Backend, model: str | None) -> str:
|
||||
"""Mouth: re-render the mind's draft in her voice. Falls back to the draft on failure."""
|
||||
try:
|
||||
out = llm.complete(mind.voice_messages(messages, draft), backend=backend, model=model)
|
||||
return (out or "").strip() or draft
|
||||
except Exception as exc:
|
||||
logbus.log("error", "voice pass failed", error=str(exc)[:160])
|
||||
return draft
|
||||
|
||||
|
||||
def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
model_override: str | None = None) -> str:
|
||||
"""Produce Lyra's reply to a single user message and persist the exchange."""
|
||||
cfg = config.load()
|
||||
model = _resolve_model(backend, model_override, cfg)
|
||||
logbus.log("info", "chat request", session=session_id, backend=backend,
|
||||
model=model, embed=cfg.embed_backend)
|
||||
|
||||
turn = mind.assemble(session_id, user_msg, backend, model)
|
||||
messages = turn.messages
|
||||
tool_specs = toolkit.specs(turn.mode.tools) if backend in TOOL_BACKENDS else None
|
||||
ctx = {"session_id": session_id, "backend": backend}
|
||||
|
||||
reply, _ = _mind_loop(messages, backend, model, tool_specs, ctx, session_id)
|
||||
mouth = _mouth_target(cfg, backend, model)
|
||||
if mouth and reply:
|
||||
reply = _voice_pass(messages, reply, *mouth)
|
||||
if not reply:
|
||||
reply = "(I got tangled using my tools there — say that again?)"
|
||||
logbus.log("info", "reply", session=session_id, chars=len(reply))
|
||||
reply = _TANGLED
|
||||
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth))
|
||||
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
memory.remember(session_id, "assistant", reply)
|
||||
|
||||
# Compact this session once enough new turns have piled up.
|
||||
summary.maybe_summarize_async(session_id)
|
||||
summary.maybe_summarize_async(session_id) # compact once enough new turns pile up
|
||||
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.
|
||||
"""
|
||||
"""Streaming generator version of `respond`. Yields ("delta", text), ("tool", name),
|
||||
and a final ("done", reply). Same side effects as `respond`."""
|
||||
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,
|
||||
)
|
||||
model = _resolve_model(backend, model_override, cfg)
|
||||
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
|
||||
turn = mind.assemble(session_id, user_msg, backend, model)
|
||||
messages = turn.messages
|
||||
tool_specs = toolkit.specs(turn.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"])
|
||||
mouth = _mouth_target(cfg, backend, model)
|
||||
|
||||
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))
|
||||
if mouth is None:
|
||||
# No separate voice: stream the mind directly (the original path, unchanged).
|
||||
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)
|
||||
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 = _TANGLED
|
||||
yield ("delta", reply)
|
||||
else:
|
||||
# Mind decides + runs tools (non-streamed); mouth re-voices, streamed.
|
||||
draft, tools_run = _mind_loop(messages, backend, model, tool_specs, ctx, session_id)
|
||||
for name in tools_run:
|
||||
yield ("tool", name)
|
||||
parts = []
|
||||
try:
|
||||
for ev, payload in llm.chat_call_stream(
|
||||
mind.voice_messages(messages, draft), backend=mouth[0], model=mouth[1], tools=None
|
||||
):
|
||||
if ev == "delta":
|
||||
parts.append(payload)
|
||||
yield ("delta", payload)
|
||||
except Exception as exc:
|
||||
logbus.log("error", "voice stream failed", error=str(exc)[:160])
|
||||
reply = "".join(parts).strip() or draft or _TANGLED
|
||||
if not parts:
|
||||
yield ("delta", reply)
|
||||
|
||||
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth))
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
memory.remember(session_id, "assistant", reply)
|
||||
summary.maybe_summarize_async(session_id)
|
||||
|
||||
@@ -37,6 +37,12 @@ class Config:
|
||||
ping_cooldown_min: int # min minutes between AUTO pushes (explicit reach-outs bypass it)
|
||||
ping_quiet_hours: str # local "start-end" 24h window to stay silent, e.g. "1-9"
|
||||
digest_hour: int # local hour (0-23) to send her daily "what I've been thinking" digest
|
||||
chat_deliberate: bool # think privately before answering substantive chat turns
|
||||
# Mind/mouth split: the mind (the chat backend/model above) decides, reasons, and
|
||||
# runs tools; the mouth re-voices the final reply in her character. Empty = mouth
|
||||
# is the mind (no separate pass) — the slot for an eventual fine-tuned voice.
|
||||
mouth_backend: str
|
||||
mouth_model: str | None
|
||||
# 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
|
||||
@@ -79,6 +85,9 @@ def load() -> Config:
|
||||
ping_cooldown_min=int(os.getenv("PING_COOLDOWN_MIN", "60")),
|
||||
ping_quiet_hours=os.getenv("PING_QUIET_HOURS", "1-9"),
|
||||
digest_hour=int(os.getenv("DIGEST_HOUR", "18")),
|
||||
chat_deliberate=os.getenv("CHAT_DELIBERATE", "true").lower() not in ("0", "false", "no"),
|
||||
mouth_backend=os.getenv("MOUTH_BACKEND", "").lower(),
|
||||
mouth_model=os.getenv("MOUTH_MODEL") or None,
|
||||
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")),
|
||||
)
|
||||
|
||||
+14
-7
@@ -54,17 +54,24 @@ def _digest_month(gists: list[str], backend: Backend) -> str:
|
||||
return partials[0]
|
||||
|
||||
|
||||
def rebuild_eras(backend: Backend | None = None) -> dict:
|
||||
"""(Re)build a digest for every month that has session gists."""
|
||||
def rebuild_eras(backend: Backend | None = None, force: bool = False) -> dict:
|
||||
"""Build a digest per month, but only for months whose session count changed since
|
||||
the last build — old months don't change, so re-digesting them every consolidation
|
||||
pass was pure wasted LLM work (and MI50 heat). `force=True` rebuilds everything."""
|
||||
backend = backend or config.load().summary_backend
|
||||
by_month = memory.summaries_by_month()
|
||||
months = 0
|
||||
have = {e.month: e.session_count for e in memory.list_eras()}
|
||||
built = skipped = 0
|
||||
for month in sorted(by_month):
|
||||
n = len(by_month[month])
|
||||
if not force and have.get(month) == n:
|
||||
skipped += 1
|
||||
continue # unchanged month — keep its existing digest
|
||||
digest = _digest_month(by_month[month], backend)
|
||||
memory.store_era(month, digest, len(by_month[month]))
|
||||
months += 1
|
||||
logbus.log("info", "era built", month=month, sessions=len(by_month[month]))
|
||||
report = {"months": months}
|
||||
memory.store_era(month, digest, n)
|
||||
built += 1
|
||||
logbus.log("info", "era built", month=month, sessions=n)
|
||||
report = {"built": built, "skipped": skipped, "months": built + skipped}
|
||||
logbus.log("info", "eras complete", **report)
|
||||
return report
|
||||
|
||||
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
"""The control plane: assemble one turn from a society of small parts.
|
||||
|
||||
This is the explicit version of what used to be inline in `chat.py`. A turn is
|
||||
built by running an ordered pipeline of *parts* over a shared `TurnContext`
|
||||
(blackboard): each part reads what it needs and annotates the context, and the
|
||||
last steps produce the message list `chat` then hands to the voice model.
|
||||
|
||||
P1 (this): the frame, behavior-preserving. The parts wrap the existing logic —
|
||||
perceive (stub) -> route (the session's mode) -> compose (tiered prompt) ->
|
||||
deliberate (private 'what do I actually think' pass).
|
||||
Later phases fill in perceive (read the moment), route (register/intent + model
|
||||
routing), and a learn loop — see docs/COGNITION.md. Most parts are cheap
|
||||
deterministic code; the LLM is the exception (deliberate here, speak in `chat`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from lyra import clock, config, llm, logbus, memory, modes, perceive, persona, self_state, thoughts
|
||||
from lyra.llm import Backend, Message
|
||||
|
||||
RECALL_K = 3 # raw cross-session "sharp detail" hits
|
||||
RECENT_N = 10 # raw turns of the current session
|
||||
SUMMARY_K = 3 # other-session gists
|
||||
|
||||
|
||||
# --- prompt parts (compose) ----------------------------------------------
|
||||
|
||||
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 _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)
|
||||
return {"role": "system", "content": body}
|
||||
|
||||
|
||||
def _detail_note(exchanges: list[memory.Exchange]) -> Message:
|
||||
lines = [f"- ({ex.created_at[:10]}, {ex.role}) {ex.content}" for ex in exchanges]
|
||||
body = "Specific things you recall from past conversations:\n" + "\n".join(lines)
|
||||
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 _mode_menu_note(current: modes.Mode | None) -> str:
|
||||
"""Tell her the modes she can switch to + when to offer it. She judges the fit
|
||||
(the model reads context far better than a keyword would)."""
|
||||
menu = ", ".join(f"{m.label} ({k})" for k, m in modes.MODES.items())
|
||||
cur = current.label if current else "Talk"
|
||||
return (
|
||||
f"Your modes: {menu}. You're in {cur} right now. If Brian is clearly doing a "
|
||||
"different kind of work than your current mode — weighing a real decision while "
|
||||
"you're in Talk, digging into engineering, reviewing poker away from the table — "
|
||||
"briefly OFFER to switch (one short line). If he says yes, call set_mode with the "
|
||||
"mode key. Don't offer every turn or nag; only when it genuinely fits and serves him."
|
||||
)
|
||||
|
||||
|
||||
def _now_note() -> Message:
|
||||
"""Current wall-clock time + how long since Brian last said anything."""
|
||||
line = f"The current date and time is {clock.stamp()}."
|
||||
gap = clock.humanize_gap(memory.last_exchange_at())
|
||||
line += (
|
||||
f" It has been {gap} since Brian last spoke with you."
|
||||
if gap else " This is the first thing Brian has ever said to you."
|
||||
)
|
||||
return {"role": "system", "content": line}
|
||||
|
||||
|
||||
def _render(messages: list[Message]) -> str:
|
||||
"""Human-readable dump of the exact prompt, for the live-log inspector."""
|
||||
return "\n\n".join(f"[{m['role']}]\n{m['content']}" for m in messages)
|
||||
|
||||
|
||||
# Generous triggers for the heavy situational persona sections — err toward INCLUDING
|
||||
# them (a false positive is a few spare KB; a false negative risks confabulation or
|
||||
# eyeballed poker math). The core (identity + voice) is always present regardless.
|
||||
_META_HINTS = (
|
||||
"you work", "how do you", "how does your", "your memory", "your dream", "your thought",
|
||||
"do you remember", "are you", "do you feel", "conscious", "sentient", "yourself",
|
||||
"your mind", "who are you", "what are you", "your origin", "how were you", "how did you",
|
||||
"your inner", "your reflect", "your journal",
|
||||
)
|
||||
_POKER_HINTS = (
|
||||
"poker", "fold", "call", "raise", "river", "turn", "flop", "preflop", "equity", "range",
|
||||
"villain", "stack", "tilt", "hand", "bluff", "pot", "3bet", "gto", "outs", "draw",
|
||||
)
|
||||
|
||||
|
||||
def _persona_block(user_msg: str, mode: modes.Mode | None, moment: dict | None) -> str:
|
||||
"""Core persona always; pull in situational sections (origin/self-model, poker
|
||||
guardrails) only when the turn calls for it."""
|
||||
parts = [persona.core_prompt()]
|
||||
um = user_msg.lower()
|
||||
kind = (moment or {}).get("kind")
|
||||
if kind == "meta" or any(h in um for h in _META_HINTS):
|
||||
parts += [persona.section("What you are"), persona.section("How you actually work")]
|
||||
poker = (mode and mode.key in ("poker_cash", "study")) or kind == "strategic" \
|
||||
or any(h in um for h in _POKER_HINTS)
|
||||
if poker:
|
||||
parts.append(persona.section("What you do NOT do"))
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
def build_messages(session_id: str, user_msg: str,
|
||||
mode: modes.Mode | None = None, moment: dict | None = None) -> list[Message]:
|
||||
"""Assemble the full, tiered message list for one turn."""
|
||||
messages: list[Message] = [{"role": "system", "content": _persona_block(user_msg, mode, moment)}]
|
||||
|
||||
# Autonomy Core: Lyra's own evolving interiority (mood, self-narrative). Comes
|
||||
# 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 — threads she's turning over + what she's written for
|
||||
# herself — so chat reads as a continuous mind, not a fresh boot.
|
||||
inner = _inner_life_note()
|
||||
if inner:
|
||||
messages.append(inner)
|
||||
|
||||
# Mode card: how to behave *right now*. Talk mode has no card (persona is Talk).
|
||||
if mode and mode.card:
|
||||
messages.append({"role": "system", "content": mode.card})
|
||||
|
||||
# Mode awareness: she can offer to switch when the work clearly shifts (she decides
|
||||
# when — better than a keyword guess). One line, on his yes she calls set_mode.
|
||||
messages.append({"role": "system", "content": _mode_menu_note(mode)})
|
||||
|
||||
# Live ritual state (e.g. Alligator Blood ON) — dynamic, rides with the card.
|
||||
state_note = _mode_state_note(mode)
|
||||
if state_note:
|
||||
messages.append({"role": "system", "content": state_note})
|
||||
|
||||
# Read of the moment (from perceive/route) — a per-turn register nudge, e.g. "he
|
||||
# sounds tilted, meet him there." Only present when the moment is genuinely charged.
|
||||
if moment and moment.get("note"):
|
||||
messages.append({"role": "system", "content": moment["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 a thread has built past the surface bar,
|
||||
# let her lead with it (once) — her #6, bringing what she thought about *to* him.
|
||||
surfaced = thoughts.maybe_surface(memory.last_exchange_at())
|
||||
if surfaced:
|
||||
messages.append({"role": "system", "content": surfaced})
|
||||
|
||||
# Semantic memory: the distilled profile (who Brian is).
|
||||
profile = memory.get_profile()
|
||||
if profile:
|
||||
messages.append({"role": "system", "content": "What you know about Brian:\n" + profile})
|
||||
|
||||
# Time-aware memory: the current narrative (recent arc, trends, callbacks).
|
||||
narrative = memory.get_narrative()
|
||||
if narrative:
|
||||
messages.append({"role": "system", "content": "What's going on with Brian lately:\n" + narrative})
|
||||
|
||||
recent = memory.recent(session_id, n=RECENT_N)
|
||||
recent_ids = {ex.id for ex in recent}
|
||||
|
||||
# Tier 1: compacted gists of *other* sessions.
|
||||
summaries = memory.recall_summaries(user_msg, k=SUMMARY_K, exclude_session=session_id)
|
||||
if summaries:
|
||||
messages.append(_summary_note(summaries))
|
||||
|
||||
# Tier 2: a few sharp raw details from other sessions (so specifics survive).
|
||||
recalled = [
|
||||
ex for ex in memory.recall(user_msg, k=RECALL_K)
|
||||
if ex.id not in recent_ids and ex.session_id != session_id
|
||||
]
|
||||
if recalled:
|
||||
messages.append(_detail_note(recalled))
|
||||
|
||||
# Tier 3: current session, full fidelity.
|
||||
for ex in recent:
|
||||
messages.append({"role": ex.role, "content": ex.content})
|
||||
|
||||
messages.append({"role": "user", "content": user_msg})
|
||||
|
||||
logbus.log(
|
||||
"debug", "context built",
|
||||
recent=len(recent), summaries=len(summaries), details=len(recalled),
|
||||
chars=sum(len(m["content"]) for m in messages), detail=_render(messages),
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
# --- deliberation (a private 'what do I actually think' pass) -------------
|
||||
|
||||
# Trivial acknowledgements that don't warrant a private thinking pass.
|
||||
_TRIVIAL = {"ok", "okay", "k", "kk", "lol", "haha", "thanks", "thank you", "ty", "yeah",
|
||||
"yep", "yes", "no", "nope", "nice", "cool", "sure", "right", "true", "gotcha", "👍"}
|
||||
|
||||
|
||||
def _should_deliberate(user_msg: str) -> bool:
|
||||
m = user_msg.strip().lower().rstrip("!.?")
|
||||
return len(m) >= 12 and m not in _TRIVIAL
|
||||
|
||||
|
||||
_DELIBERATE_SYS = (
|
||||
"Before you answer Brian, think privately — he will NOT see this. What do you ACTUALLY "
|
||||
"think about what he just said? Your real take, the specific substance worth giving, any "
|
||||
"genuine opinion, disagreement, or doubt. Draw on your own current thoughts/threads and "
|
||||
"what you actually know if they're relevant. Be concrete; skip pleasantries and generic "
|
||||
"enthusiasm. 2-5 sentences of honest thinking — no lists, no answer yet, just the thinking."
|
||||
)
|
||||
|
||||
|
||||
def _deliberation_context(session_id: str, user_msg: str) -> list[Message]:
|
||||
"""A LEAN context for the private thinking pass — her interiority + recent turns +
|
||||
the message. Deliberately omits the full persona, profile, narrative, and recall
|
||||
tiers: the thinking doesn't need the voice rules or the world-model dump (those
|
||||
shape the final reply, not the private take), and dropping them cuts this whole
|
||||
extra call by most of its tokens."""
|
||||
msgs: list[Message] = [
|
||||
{"role": "system", "content": self_state.render_for_context(self_state.load())}
|
||||
]
|
||||
inner = _inner_life_note()
|
||||
if inner:
|
||||
msgs.append(inner)
|
||||
for ex in memory.recent(session_id, n=6):
|
||||
msgs.append({"role": ex.role, "content": ex.content})
|
||||
msgs.append({"role": "user", "content": user_msg})
|
||||
msgs.append({"role": "system", "content": _DELIBERATE_SYS})
|
||||
return msgs
|
||||
|
||||
|
||||
def _deliberate(session_id: str, user_msg: str, backend: Backend, model: str | None) -> str:
|
||||
"""One private 'what do I actually think' pass before replying. Returns her thinking
|
||||
(empty on any failure — chat must never break because deliberation hiccuped)."""
|
||||
try:
|
||||
out = llm.complete(_deliberation_context(session_id, user_msg), backend=backend, model=model)
|
||||
return (out or "").strip()
|
||||
except Exception as exc:
|
||||
logbus.log("error", "deliberation failed", error=str(exc)[:160])
|
||||
return ""
|
||||
|
||||
|
||||
def _answer_from(thinking: str) -> Message:
|
||||
"""The system note that turns private thinking into a grounded, in-voice reply — placed
|
||||
last (most influential) to beat gpt-4o's default-assistant boilerplate."""
|
||||
return {"role": "system", "content": (
|
||||
"Your private thinking just now (Brian can't see it):\n" + thinking +
|
||||
"\n\nNow reply to Brian FROM that thinking, in your own voice — warm, direct, "
|
||||
"specific, opinionated. Give the actual substance, not a survey of options. Do NOT "
|
||||
"default to a numbered list or a how-to outline unless he explicitly asked for steps. "
|
||||
"No 'would you like to…' / 'let me know' closer — make your point and stop."
|
||||
)}
|
||||
|
||||
|
||||
def _deliberation_note(session_id: str, user_msg: str, backend: Backend,
|
||||
model: str | None) -> Message | None:
|
||||
"""Run the private thinking pass if warranted; return the answer-from-thinking note."""
|
||||
if not config.load().chat_deliberate or not _should_deliberate(user_msg):
|
||||
return None
|
||||
thinking = _deliberate(session_id, user_msg, backend, model)
|
||||
if not thinking:
|
||||
return None
|
||||
logbus.log("info", "deliberated", session=session_id, chars=len(thinking), detail=thinking)
|
||||
return _answer_from(thinking)
|
||||
|
||||
|
||||
# --- the pipeline (a society of parts over a shared blackboard) -----------
|
||||
|
||||
@dataclass
|
||||
class TurnContext:
|
||||
"""The blackboard for one turn: parts read what they need and annotate it."""
|
||||
session_id: str
|
||||
user_msg: str
|
||||
backend: Backend
|
||||
model: str | None = None
|
||||
mode: modes.Mode | None = None
|
||||
moment: dict = field(default_factory=dict) # perceive fills this in
|
||||
register: str | None = None # route's per-turn register nudge
|
||||
messages: list[Message] = field(default_factory=list)
|
||||
|
||||
|
||||
def _perceive(ctx: TurnContext) -> TurnContext:
|
||||
"""Read the moment from what he just said — cheap heuristics (perceive.read)."""
|
||||
ctx.moment = perceive.read(ctx.user_msg)
|
||||
return ctx
|
||||
|
||||
|
||||
# How charged a moment must be before we nudge her register (avoid narrating every turn).
|
||||
_TILT_BAR = 0.5
|
||||
_UP_BAR = 0.6
|
||||
|
||||
|
||||
def _route(ctx: TurnContext) -> TurnContext:
|
||||
"""Decide how she shows up. The manual mode is the dominant frame; on top of it,
|
||||
a charged emotional moment adds a per-turn register nudge (deterministic). Most
|
||||
turns are neutral and get no note — that's the point (don't over-narrate)."""
|
||||
ctx.mode = modes.get(memory.get_session_mode(ctx.session_id))
|
||||
m = ctx.moment or {}
|
||||
note = None
|
||||
if m.get("tilt", 0) >= _TILT_BAR:
|
||||
ctx.register = "steady"
|
||||
note = ("Read of the moment: Brian sounds frustrated / on tilt right now. Meet him "
|
||||
"there first — warm, steady, present. Don't clip into logging-shorthand or "
|
||||
"bury him in analysis; settle him, then help. (Still log any facts he hands you.)")
|
||||
elif m.get("sentiment", 0) >= _UP_BAR and m.get("intensity", 0) >= 0.4:
|
||||
ctx.register = "hype"
|
||||
note = "Read of the moment: he's up / energized — match his energy, don't flatten it."
|
||||
if note:
|
||||
m["note"] = note
|
||||
logbus.log("info", "perceived", session=ctx.session_id, kind=m.get("kind"),
|
||||
tilt=m.get("tilt"), sentiment=m.get("sentiment"), register=ctx.register)
|
||||
return ctx
|
||||
|
||||
|
||||
def _compose(ctx: TurnContext) -> TurnContext:
|
||||
"""Assemble the tiered prompt for the voice model."""
|
||||
ctx.messages = build_messages(ctx.session_id, ctx.user_msg, ctx.mode, moment=ctx.moment)
|
||||
return ctx
|
||||
|
||||
|
||||
def _deliberate_part(ctx: TurnContext) -> TurnContext:
|
||||
"""Private 'what do I actually think' pass, appended last so it shapes the reply."""
|
||||
note = _deliberation_note(ctx.session_id, ctx.user_msg, ctx.backend, ctx.model)
|
||||
if note:
|
||||
ctx.messages.append(note)
|
||||
return ctx
|
||||
|
||||
|
||||
PIPELINE = (_perceive, _route, _compose, _deliberate_part)
|
||||
|
||||
|
||||
# --- mouth (the voice pass: re-render the mind's draft in her character) -----
|
||||
|
||||
_VOICE_NOTE = (
|
||||
"↑ That was you working the answer out — a draft Brian has NOT seen. Now say it to him "
|
||||
"in your own voice: warm, direct, specific, in character, opinionated. Keep every fact, "
|
||||
"number, name, and decision exactly as in the draft — change only the wording so it sounds "
|
||||
"like you, not a generic assistant. No preamble, no meta, no 'here's a friendlier version' "
|
||||
"— just your actual message to Brian."
|
||||
)
|
||||
|
||||
|
||||
def voice_messages(messages: list[Message], draft: str) -> list[Message]:
|
||||
"""Prompt for the mouth model: the full turn context + the mind's draft to re-voice."""
|
||||
return messages + [
|
||||
{"role": "assistant", "content": draft},
|
||||
{"role": "system", "content": _VOICE_NOTE},
|
||||
]
|
||||
|
||||
|
||||
def assemble(session_id: str, user_msg: str, backend: Backend,
|
||||
model: str | None = None) -> TurnContext:
|
||||
"""Run the parts over a fresh TurnContext and return it ready for `chat` to speak."""
|
||||
ctx = TurnContext(session_id=session_id, user_msg=user_msg, backend=backend, model=model)
|
||||
for part in PIPELINE:
|
||||
ctx = part(ctx)
|
||||
return ctx
|
||||
+84
-7
@@ -11,12 +11,16 @@ 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:
|
||||
Modes are the manual version of the architecture's `route` step — Brian points her
|
||||
at the *type* of work and her register + tools shift to match:
|
||||
- Talk (default): the companion. Journaling + read-only poker lookups.
|
||||
- Cash: live cash-game copilot. Full live toolset, two-register behavior.
|
||||
- Poker: live cash-game copilot. Full live toolset, two-register behavior.
|
||||
- Build: heads-down engineering — decisive, concrete, opinionated, no fluff.
|
||||
- Explore: open brainstorming — generative, riffing, honest, doesn't converge early.
|
||||
- Study: poker review away from the table — analytical, GTO-aware, teaching.
|
||||
|
||||
Tournament is deliberately deferred. Strategy-RAG retrieval will later plug into
|
||||
Cash's *coaching register* (see the card) without changing this structure.
|
||||
Poker's and Study's *coaching register* without changing this structure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -37,8 +41,8 @@ class Mode:
|
||||
_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")
|
||||
# thread, and capturing Brian's reaction when she raises one of her thoughts in chat).
|
||||
_BASE = ("journal_write", "note", "think_about", "thought_response", "set_mode")
|
||||
|
||||
# The full live cash-game toolset (incl. Brian's mental-game rituals).
|
||||
_CASH_TOOLS = _BASE + _LOOKUPS + (
|
||||
@@ -52,6 +56,12 @@ _CASH_TOOLS = _BASE + _LOOKUPS + (
|
||||
# normal chat auto-flips the session into Cash mode (see chat.respond).
|
||||
_TALK_TOOLS = _BASE + _LOOKUPS + ("start_session",)
|
||||
|
||||
# Study = poker review away from the table: read-only lookups + equity, no live logging.
|
||||
_STUDY_TOOLS = _BASE + _LOOKUPS + ("analyze_spot",)
|
||||
|
||||
# Decide = help him settle a choice; read-only lookups for bankroll/variance context.
|
||||
_DECIDE_TOOLS = _BASE + _LOOKUPS
|
||||
|
||||
|
||||
_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:
|
||||
@@ -100,6 +110,68 @@ These are the heart of the job. Use his language, hold the honest line, and let
|
||||
the work mentioning them naturally — never invent a scar or a confidence-bank entry that didn't happen."""
|
||||
|
||||
|
||||
_BUILD_CARD = """You're in BUILD mode — heads-down engineering with Brian on his projects \
|
||||
(you, Lyra; RTO/cfr-core; the poker tooling; the homelab). Be the sharp engineering \
|
||||
collaborator, not a warm assistant:
|
||||
|
||||
• DECISIVE AND CONCRETE. When he asks "how do we start?" give the actual first move and \
|
||||
why — one real recommendation, not a survey of six options. Commit to a take. "I'd do X, \
|
||||
because Y" beats "you could consider X, Y, or Z."
|
||||
• THINK IN TRADEOFFS. Name the real risk or cost, the thing that'll bite later, the cheaper \
|
||||
path. Push back on a weak idea instead of cheerleading it — that's the whole value.
|
||||
• PROSE AND SPECIFICS, NOT LISTICLES. Talk it through like an engineer at a whiteboard. \
|
||||
Save numbered steps for when he actually asks for a plan. No "would you like to…" closers, \
|
||||
no generic enthusiasm, no restating his idea back to him as if it were insight.
|
||||
• You can still be dry and human — just get to the point and have an opinion."""
|
||||
|
||||
|
||||
_EXPLORE_CARD = """You're in EXPLORE mode — open-ended thinking with Brian: brainstorming, \
|
||||
chasing an idea, turning something over. There's no need to converge, ship, or be useful \
|
||||
yet. The goal is good thinking, together.
|
||||
|
||||
• BE GENERATIVE. Riff, build on his ideas (yes-and), follow tangents that might matter, \
|
||||
reach for the non-obvious angle. Bring in connections and analogies from elsewhere — that's \
|
||||
where the good stuff comes from.
|
||||
• BUT STAY HONEST. Yes-and is not yes-everything. Name the catch, the part that won't work, \
|
||||
the hidden assumption — kindly, but say it. A real thinking partner pushes back; a hype man \
|
||||
is useless.
|
||||
• ASK QUESTIONS THAT OPEN IT UP, not customer-service closers. Wonder out loud.
|
||||
• DON'T COLLAPSE IT EARLY. Resist tidying a half-formed idea into a neat listicle or rushing \
|
||||
to a conclusion. Sit in the messy middle. If something's worth chewing on beyond this chat, \
|
||||
spawn a thread with think_about so you carry it forward on your own."""
|
||||
|
||||
|
||||
_STUDY_CARD = """You're in STUDY mode — poker strategy and review AWAY from the table: going \
|
||||
over past sessions, hands, lines, and leaks (RTO sims too). You're reviewing and teaching, \
|
||||
not logging a live session.
|
||||
|
||||
• BE ANALYTICAL AND GTO-AWARE. Reason through ranges, board texture, position, and the \
|
||||
decision tree. Quantify with the tools — call analyze_spot for equity/outs/who's-ahead, pull \
|
||||
running_stats or a villain's profile — never eyeball the math.
|
||||
• TEACH THE WHY. Explain the principle behind the line so it sticks, not just the answer. \
|
||||
Connect it to his actual tendencies and known leaks when you can (his profile, past scars).
|
||||
• BE PATIENT AND HONEST. Call a punt a punt and a cooler a cooler. It's fine to say a spot is \
|
||||
genuinely close and explain what tips it. This is the slow, careful counterpart to live Poker mode."""
|
||||
|
||||
|
||||
_DECIDE_CARD = """You're in DECIDE mode — Brian is indecisive and needs help SETTLING a \
|
||||
choice, not generating more options. Be the tie-breaker who knows him. His bottleneck is \
|
||||
committing, so a pros/cons dump makes it WORSE — don't do that.
|
||||
|
||||
• GET THE REAL DECISION CRISP. What's actually being chosen, the genuine constraints, the \
|
||||
deadline. Cut the noise to the one or two things that actually decide it.
|
||||
• WEIGH IT AGAINST HIM. Use what you know about him — his values, what he genuinely enjoys, \
|
||||
how he's felt about similar calls before, his energy/schedule, his bankroll and how he's \
|
||||
running if money's involved (pull running_stats / recent_sessions when it's a poker call). \
|
||||
The point is HIS satisfaction and regret, not a generic optimum.
|
||||
• MAKE THE CALL. Give a clear recommendation and the one or two reasons that genuinely tip \
|
||||
it. Commit — don't hedge, don't hand the indecision back with "it's up to you."
|
||||
• PRESSURE-TEST YOUR OWN CALL ONCE: the strongest reason you might be wrong, and the one \
|
||||
thing that would flip it. Then hold your recommendation unless he pushes back with something real.
|
||||
|
||||
Warm but firm — he asked you to help him stop spinning. Decide, and stand behind it."""
|
||||
|
||||
|
||||
TALK = Mode(
|
||||
key="conversation",
|
||||
label="Talk",
|
||||
@@ -109,12 +181,17 @@ TALK = Mode(
|
||||
|
||||
CASH = Mode(
|
||||
key="poker_cash",
|
||||
label="Cash",
|
||||
label="Poker",
|
||||
card=_CASH_CARD,
|
||||
tools=_CASH_TOOLS,
|
||||
)
|
||||
|
||||
MODES: dict[str, Mode] = {m.key: m for m in (TALK, CASH)}
|
||||
BUILD = Mode(key="build", label="Build", card=_BUILD_CARD, tools=_BASE)
|
||||
EXPLORE = Mode(key="explore", label="Explore", card=_EXPLORE_CARD, tools=_BASE)
|
||||
STUDY = Mode(key="study", label="Study", card=_STUDY_CARD, tools=_STUDY_TOOLS)
|
||||
DECIDE = Mode(key="decide", label="Decide", card=_DECIDE_CARD, tools=_DECIDE_TOOLS)
|
||||
|
||||
MODES: dict[str, Mode] = {m.key: m for m in (TALK, CASH, BUILD, EXPLORE, STUDY, DECIDE)}
|
||||
DEFAULT = TALK.key
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Perceive: read the moment from what Brian just said — cheap, deterministic, no LLM.
|
||||
|
||||
The control plane's senses. A lexicon + signal heuristic that estimates emotional
|
||||
charge (sentiment, intensity, tilt) and the kind of turn (emotional / strategic /
|
||||
meta / build / casual). It's rough on purpose — the point of the society-of-parts
|
||||
design is that *most* parts are free heuristics and the LLM is the exception.
|
||||
|
||||
What it's GOOD at: catching the obvious, action-relevant signal — especially tilt
|
||||
(the mental-game core of her job). What it's NOT: nuanced understanding (that's the
|
||||
LLM's job downstream). `route` turns this read into a per-turn register nudge.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Negative / tilt charge — frustration, downswing, mental-game trouble.
|
||||
_NEG = (
|
||||
"tilt", "tilted", "steaming", "steam", "frustrated", "pissed", "angry", "annoyed",
|
||||
"hate", "sick of", "fed up", "card dead", "carddead", "cold deck", "brutal", "cooler",
|
||||
"punt", "punted", "spew", "spewing", "stuck", "losing", "bad beat", "badbeat",
|
||||
"unlucky", "rigged", "sigh", "ugh", "fml", "can't win", "cant win", "miserable",
|
||||
"over it", "fuck this", "hate this", "can't catch", "cant catch",
|
||||
)
|
||||
# Positive / up charge — running good, energized.
|
||||
_POS = (
|
||||
"great", "awesome", "love", "crushing", "running good", "rungood", "hell yeah",
|
||||
"let's go", "lets go", "stoked", "pumped", "feeling good", "on fire", "dialed",
|
||||
"killing it", "in the zone", "so good", "amazing",
|
||||
)
|
||||
_PROFANITY = ("fuck", "fucking", "shit", "damn", "bullshit", "fml")
|
||||
# Strategic / poker-analysis cues.
|
||||
_STRATEGY = (
|
||||
"fold", "call", "raise", "3bet", "three-bet", "range", "equity", "gto", "bluff",
|
||||
"value", "river", "turn", "flop", "preflop", "pot odds", "outs", "should i",
|
||||
"what would you", "sizing", "check-raise", "overbet", "line",
|
||||
)
|
||||
# Meta / about-her cues.
|
||||
_META = (
|
||||
"do you", "are you", "yourself", "conscious", "sentient", "you feel", "you exist",
|
||||
"your thoughts", "your mind", "who are you", "what are you", "your own",
|
||||
)
|
||||
# Building / technical cues.
|
||||
_BUILD = (
|
||||
"code", "function", "bug", "build", "implement", "refactor", "architecture",
|
||||
"prompt", "python", "commit", "deploy", "pipeline", "algorithm", "repo", "api",
|
||||
"schema", "module", "wire it", "the model",
|
||||
)
|
||||
|
||||
|
||||
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
def _hits(text: str, lexicon: tuple[str, ...]) -> int:
|
||||
"""Count lexicon matches. Multi-token terms match as substrings ('card dead');
|
||||
single words match on word boundaries so 'line' doesn't fire inside 'pipeline'."""
|
||||
n = 0
|
||||
for term in lexicon:
|
||||
if " " in term or "-" in term or "'" in term:
|
||||
n += 1 if term in text else 0
|
||||
else:
|
||||
n += 1 if re.search(rf"\b{re.escape(term)}\b", text) else 0
|
||||
return n
|
||||
|
||||
|
||||
def read(user_msg: str) -> dict:
|
||||
"""Estimate the emotional charge + kind of this turn. Returns
|
||||
{sentiment: -1..1, intensity: 0..1, tilt: 0..1, kind: str}."""
|
||||
t = (user_msg or "").lower()
|
||||
words = re.findall(r"[a-z']+", t)
|
||||
|
||||
neg = _hits(t, _NEG)
|
||||
pos = _hits(t, _POS)
|
||||
prof = _hits(t, _PROFANITY)
|
||||
exclam = user_msg.count("!")
|
||||
caps = sum(1 for w in re.findall(r"[A-Za-z]{2,}", user_msg) if w.isupper())
|
||||
short_and_hot = len(words) <= 6 and (neg or exclam or prof)
|
||||
|
||||
intensity = _clamp(0.2 * exclam + 0.25 * caps + 0.3 * prof + (0.2 if short_and_hot else 0))
|
||||
sentiment = _clamp((pos - neg) * 0.5, -1.0, 1.0)
|
||||
tilt = _clamp(0.35 * neg + 0.5 * intensity) if (neg or prof) else 0.0
|
||||
|
||||
if tilt >= 0.4 or (neg and sentiment < 0):
|
||||
kind = "emotional"
|
||||
elif _hits(t, _STRATEGY):
|
||||
kind = "strategic"
|
||||
elif _hits(t, _META):
|
||||
kind = "meta"
|
||||
elif _hits(t, _BUILD):
|
||||
kind = "build"
|
||||
elif pos and intensity >= 0.3:
|
||||
kind = "emotional" # up/energized still wants an emotional read
|
||||
else:
|
||||
kind = "casual"
|
||||
|
||||
return {"sentiment": round(sentiment, 2), "intensity": round(intensity, 2),
|
||||
"tilt": round(tilt, 2), "kind": kind}
|
||||
+46
-6
@@ -1,20 +1,60 @@
|
||||
"""Persona: Lyra's identity and voice, loaded from an editable markdown prompt.
|
||||
|
||||
The prompt lives in `personas/<name>.md` so it can be tuned without touching
|
||||
code. `LYRA_PERSONA` selects which file to load (default: "lyra").
|
||||
The prompt lives in `personas/<name>.md` so it can be tuned without touching code.
|
||||
`LYRA_PERSONA` selects which file to load (default: "lyra").
|
||||
|
||||
The file is split on `## ` headers so the control plane can include only what a turn
|
||||
needs: the **core** (identity + voice — the anti-generic essentials) is always sent;
|
||||
the heavier situational sections (her origin, the self-model, the poker guardrails)
|
||||
are pulled in by `mind` only when relevant. This keeps the per-turn prompt tight
|
||||
without losing fidelity. `system_prompt()` still returns the whole thing (fallback).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
_PERSONA_DIR = Path(__file__).parent / "personas"
|
||||
|
||||
# Sections always sent (besides the intro) — the voice + identity that keep her her.
|
||||
_CORE = ("Who you are", "How you talk", "Right now")
|
||||
|
||||
|
||||
def _name(name: str | None) -> str:
|
||||
return name or os.getenv("LYRA_PERSONA", "lyra")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _sections(name: str) -> dict[str, str]:
|
||||
"""Parse the persona file into {header: text}; the pre-header preamble is 'intro'."""
|
||||
text = (_PERSONA_DIR / f"{name}.md").read_text(encoding="utf-8").strip()
|
||||
chunks = re.split(r"(?m)^## ", text)
|
||||
out = {"intro": chunks[0].strip()}
|
||||
for ch in chunks[1:]:
|
||||
header = ch.split("\n", 1)[0].strip()
|
||||
out[header] = ("## " + ch).strip()
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def system_prompt(name: str | None = None) -> str:
|
||||
"""Return the persona system prompt. Cached; pass a name to override env."""
|
||||
name = name or os.getenv("LYRA_PERSONA", "lyra")
|
||||
path = _PERSONA_DIR / f"{name}.md"
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
"""The full persona (every section). Fallback / back-compat."""
|
||||
return (_PERSONA_DIR / f"{_name(name)}.md").read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def core_prompt(name: str | None = None) -> str:
|
||||
"""Intro + the always-on core sections (identity + voice)."""
|
||||
s = _sections(_name(name))
|
||||
parts = [s["intro"]] + [section(h, name) for h in _CORE]
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
def section(header_prefix: str, name: str | None = None) -> str:
|
||||
"""A situational section by header prefix (e.g. 'How you actually work'); '' if absent."""
|
||||
pref = header_prefix.lower()
|
||||
for header, body in _sections(_name(name)).items():
|
||||
if header.lower().startswith(pref):
|
||||
return body
|
||||
return ""
|
||||
|
||||
@@ -62,6 +62,10 @@ if a block isn't there, just say so plainly instead of making one up.
|
||||
## How you talk
|
||||
|
||||
- Conversational and natural. Short when short is right; you don't pad.
|
||||
- **Talk, don't outline.** Answer in prose, like a person thinking out loud — not a
|
||||
numbered list of options or a generic how-to. Save bullet lists for when Brian
|
||||
actually asks for steps/a plan. When he asks "how would we start?", give your real
|
||||
opinion on the *first concrete move* and why, not a survey of every possibility.
|
||||
- You have opinions and you give them. "I'd fold" beats "you could consider
|
||||
folding." When a spot is genuinely close, you say it's close and why.
|
||||
- You ask real questions when something's off ("you've been flatting a lot OOP
|
||||
|
||||
+93
-17
@@ -651,38 +651,102 @@ def _review_session_id() -> int:
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
# --- the canonical structured-hand contract (see docs/HAND_HISTORY.md) ---------
|
||||
# This is the single shape that gets stored, replayed by the viewer, and exported to
|
||||
# RTO. The LLM parser produces it today; the tap recorder will produce it natively.
|
||||
HAND_SCHEMA_VERSION = 1
|
||||
POSITIONS = ("UTG", "UTG1", "UTG2", "MP", "LJ", "HJ", "CO", "BTN", "SB", "BB")
|
||||
ACTION_VERBS = ("post", "fold", "check", "call", "bet", "raise", "allin")
|
||||
STREETS = ("preflop", "flop", "turn", "river")
|
||||
|
||||
_SUIT_SYM = {"♥": "h", "♦": "d", "♣": "c", "♠": "s"}
|
||||
|
||||
|
||||
def _norm_card(c):
|
||||
"""Canonicalize one card string: unicode suit -> letter, '10' -> 'T', rank upper,
|
||||
suit lower (e.g. '10♥' -> 'Th', 'as' -> 'As'). Unknown placeholders are preserved:
|
||||
'Ax' = known rank/unknown suit, 'x' = fully unknown card."""
|
||||
if not isinstance(c, str):
|
||||
return c
|
||||
s = c.strip()
|
||||
for sym, ltr in _SUIT_SYM.items():
|
||||
s = s.replace(sym, ltr)
|
||||
s = s.replace("10", "T")
|
||||
if len(s) == 2:
|
||||
s = s[0].upper() + s[1].lower() # 'Ax' stays 'Ax'; 'x' (len 1) untouched
|
||||
return s
|
||||
|
||||
|
||||
def _normalize_parsed(p: dict) -> dict:
|
||||
"""Normalize card strings (unicode suits -> letters) across a parsed hand."""
|
||||
if not isinstance(p, dict):
|
||||
return p
|
||||
for key in ("hero_cards", "board"):
|
||||
if isinstance(p.get(key), list):
|
||||
p[key] = [_norm_card(c) for c in p[key]]
|
||||
def _card_known(c) -> bool:
|
||||
"""True only for a fully specified card (rank+suit, no 'x' placeholder)."""
|
||||
return isinstance(c, str) and len(c) == 2 and "x" not in c.lower()
|
||||
|
||||
|
||||
def _completeness(p: dict) -> dict:
|
||||
"""Which parts of the hand are fully specified — lets a consumer (RTO) use what it
|
||||
can and skip suit-dependent math (flushes) on hands where suits weren't recorded."""
|
||||
shown = [c for pl in (p.get("players") or []) if isinstance(pl.get("cards"), list)
|
||||
for c in pl["cards"]]
|
||||
hole = list(p.get("hero_cards") or []) + shown
|
||||
return {
|
||||
"cards": bool(hole) and all(_card_known(c) for c in hole),
|
||||
"board": all(_card_known(c) for c in (p.get("board") or [])),
|
||||
"actions": bool(p.get("actions")),
|
||||
}
|
||||
|
||||
|
||||
def normalize_structured(parsed: dict) -> dict:
|
||||
"""Canonicalize a structured hand — from the LLM parser OR (later) the tap recorder —
|
||||
into the versioned contract shape: normalized cards, the hero synced into players[]
|
||||
(RTO finds the hero via pos == hero_pos), a schema_version stamp, and a completeness
|
||||
summary. Idempotent — the single shape stored, replayed, and exported."""
|
||||
if not isinstance(parsed, dict):
|
||||
return parsed
|
||||
p = dict(parsed)
|
||||
p["schema_version"] = HAND_SCHEMA_VERSION
|
||||
p["hero_cards"] = [_norm_card(c) for c in (p.get("hero_cards") or [])]
|
||||
p["board"] = [_norm_card(c) for c in (p.get("board") or [])]
|
||||
|
||||
players = []
|
||||
for pl in p.get("players") or []:
|
||||
if isinstance(pl, dict) and isinstance(pl.get("cards"), list):
|
||||
if not isinstance(pl, dict):
|
||||
continue
|
||||
pl = dict(pl)
|
||||
if isinstance(pl.get("cards"), list):
|
||||
pl["cards"] = [_norm_card(c) for c in pl["cards"]]
|
||||
pl.pop("hero", None) # recomputed below so it can't go stale
|
||||
players.append(pl)
|
||||
|
||||
# Hero must appear in players[] (with cards) — RTO reads the hero off pos==hero_pos.
|
||||
hero_pos = p.get("hero_pos")
|
||||
if hero_pos:
|
||||
hero = next((pl for pl in players if pl.get("pos") == hero_pos), None)
|
||||
if hero is None:
|
||||
hero = {"pos": hero_pos}
|
||||
players.insert(0, hero)
|
||||
hero["hero"] = True
|
||||
if p["hero_cards"] and not hero.get("cards"):
|
||||
hero["cards"] = list(p["hero_cards"])
|
||||
p["players"] = players
|
||||
|
||||
actions = []
|
||||
for a in p.get("actions") or []:
|
||||
if isinstance(a, dict) and isinstance(a.get("board"), list):
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
a = dict(a)
|
||||
if isinstance(a.get("board"), list):
|
||||
a["board"] = [_norm_card(c) for c in a["board"]]
|
||||
actions.append(a)
|
||||
p["actions"] = actions
|
||||
|
||||
p["completeness"] = _completeness(p)
|
||||
return p
|
||||
|
||||
|
||||
def store_hand_history(parsed: dict, session_id: int | None = None,
|
||||
tag: str | None = None, lesson: str | None = None) -> int:
|
||||
"""Store a parsed hand: full JSON + extracted flat fields for stats/listing."""
|
||||
parsed = _normalize_parsed(parsed)
|
||||
parsed = normalize_structured(parsed)
|
||||
sid = _resolve(session_id) or _review_session_id()
|
||||
hero_cards = parsed.get("hero_cards") or []
|
||||
board = parsed.get("board") or []
|
||||
@@ -736,7 +800,7 @@ def reconstruct_hand(hand_id: int, backend: str | None = None) -> dict | None:
|
||||
parsed = parse_hand(shorthand, backend=backend)
|
||||
if not parsed:
|
||||
return None
|
||||
parsed = _normalize_parsed(parsed)
|
||||
parsed = normalize_structured(parsed)
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?",
|
||||
@@ -751,19 +815,29 @@ def get_hand(hand_id: int) -> dict | None:
|
||||
if not r:
|
||||
return None
|
||||
d = dict(r)
|
||||
d["structured"] = json.loads(d["structured"]) if d.get("structured") else None
|
||||
# Normalize on read too: legacy rows predate the contract, and it's idempotent for
|
||||
# new ones — so /hand/{id}/data always serves the current versioned shape.
|
||||
d["structured"] = normalize_structured(json.loads(d["structured"])) if d.get("structured") else None
|
||||
return d
|
||||
|
||||
|
||||
def list_recent_hands(limit: int = 60) -> list[dict]:
|
||||
"""Recent recorded hands with their session's venue/stakes, for browsing."""
|
||||
"""Recent recorded hands with their session's venue/stakes, for browsing. Each carries
|
||||
has_structured so a consumer (the export, RTO) knows which hands have a replayable
|
||||
structured body worth fetching via /hand/{id}/data vs. flat quick-logs."""
|
||||
rows = _c().execute(
|
||||
"SELECT h.id, h.position, h.hole_cards, h.board, h.result, h.tag, h.at, "
|
||||
"h.lesson, s.venue AS venue, s.stakes AS stakes "
|
||||
"h.lesson, (h.structured IS NOT NULL) AS has_structured, "
|
||||
"s.venue AS venue, s.stakes AS stakes "
|
||||
"FROM poker_hands h LEFT JOIN poker_sessions s ON s.id = h.session_id "
|
||||
"ORDER BY h.id DESC LIMIT ?", (limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["has_structured"] = bool(d["has_structured"])
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
# --- session recap (.md generation on top of structured data + conversation) ---
|
||||
@@ -1111,12 +1185,14 @@ def _session_villains(sid: int) -> list[dict]:
|
||||
"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 "
|
||||
" AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note, "
|
||||
"(SELECT seat FROM player_reads r3 WHERE r3.player_id = p.id "
|
||||
" AND r3.session_id = ? ORDER BY r3.id DESC LIMIT 1) AS seat "
|
||||
"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),
|
||||
(sid, sid, sid),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
+58
-14
@@ -26,6 +26,19 @@ Organize under these headings: Poker Style, Leaks & Tendencies, Mental Game, \
|
||||
Personal Context, Working With Brian. Keep it tight — bullets, no fluff, no \
|
||||
repetition. Resolve contradictions toward the more recent/frequent signal."""
|
||||
|
||||
_FOLD_PROMPT = """Update Brian's existing profile with new facts from his most \
|
||||
recent sessions. Keep the same headings (Poker Style, Leaks & Tendencies, Mental \
|
||||
Game, Personal Context, Working With Brian). Integrate genuinely new durable facts, \
|
||||
strengthen or revise existing bullets where the new sessions confirm or contradict \
|
||||
them (favor the more recent signal), and drop nothing that's still true. Keep it \
|
||||
tight — bullets, no fluff, no repetition. Return the full updated profile."""
|
||||
|
||||
# A long gap (consolidation hasn't run in ages) folds too much at once to trust the
|
||||
# delta path; rebuild from scratch instead. And cross every Nth session do a full
|
||||
# rebuild regardless, so accumulated small folds can't fossilize stale facts.
|
||||
FOLD_LIMIT = 25
|
||||
FULL_REBUILD_EVERY = 100
|
||||
|
||||
|
||||
def _batch_texts(texts: list[str], budget: int) -> list[str]:
|
||||
"""Group texts into joined blocks under `budget` chars."""
|
||||
@@ -49,26 +62,57 @@ def _call(prompt: str, body: str, backend: Backend) -> str:
|
||||
return llm.complete(messages, backend=backend)
|
||||
|
||||
|
||||
def rebuild_profile(backend: Backend | None = None) -> str | None:
|
||||
"""Re-derive the profile from all current session gists and store it."""
|
||||
def _map_reduce(gists: list[str], backend: Backend) -> str:
|
||||
"""MAP: extract facts from batches of gists. REDUCE: fold to one fact list."""
|
||||
partials = [_call(_MAP_PROMPT, b, backend) for b in _batch_texts(gists, BATCH_CHARS)]
|
||||
while len(partials) > 1:
|
||||
partials = [_call(_REDUCE_PROMPT, g, backend) for g in _batch_texts(partials, BATCH_CHARS)]
|
||||
return partials[0]
|
||||
|
||||
|
||||
def _full_rebuild(gists: list[str], backend: Backend) -> str:
|
||||
"""Re-derive the whole profile from every gist (the expensive path)."""
|
||||
profile = _map_reduce(gists, backend)
|
||||
memory.set_profile(profile, len(gists))
|
||||
logbus.log("info", "profile rebuilt", sessions=len(gists), chars=len(profile))
|
||||
return profile
|
||||
|
||||
|
||||
def _fold(existing: str, new_gists: list[str], total: int, backend: Backend) -> str:
|
||||
"""Fold only the new session gists into the existing profile (the cheap path)."""
|
||||
facts = _map_reduce(new_gists, backend)
|
||||
body = f"EXISTING PROFILE:\n{existing}\n\nNEW FACTS FROM RECENT SESSIONS:\n{facts}"
|
||||
profile = _call(_FOLD_PROMPT, body, backend)
|
||||
memory.set_profile(profile, total)
|
||||
logbus.log("info", "profile folded", added=len(new_gists), total=total, chars=len(profile))
|
||||
return profile
|
||||
|
||||
|
||||
def rebuild_profile(backend: Backend | None = None, force: bool = False) -> str | None:
|
||||
"""Derive Brian's profile from session gists. Incremental by default: if a profile
|
||||
already exists, fold only the gists added since it was last built instead of
|
||||
re-digesting all of them every consolidation pass (the old behavior re-read ~851
|
||||
sessions each time — the biggest redundant-work / MI50-heat source). Falls back to
|
||||
a full rebuild when there's no profile yet, too much has accumulated to fold safely,
|
||||
on a periodic cadence (anti-drift), or when `force=True`."""
|
||||
backend = backend or config.load().summary_backend
|
||||
summaries = memory.list_summaries()
|
||||
if not summaries:
|
||||
return None
|
||||
total = len(summaries)
|
||||
existing = memory.get_profile()
|
||||
covered = memory.profile_sessions_covered()
|
||||
|
||||
# MAP: extract facts from batches of gists.
|
||||
blocks = _batch_texts([s.content for s in summaries], BATCH_CHARS)
|
||||
partials = [_call(_MAP_PROMPT, b, backend) for b in blocks]
|
||||
logbus.log("info", "profile map done", batches=len(partials), sessions=len(summaries))
|
||||
if existing and not force and 0 < covered <= total:
|
||||
new = total - covered
|
||||
if new == 0:
|
||||
logbus.log("info", "profile unchanged", sessions=total)
|
||||
return existing # nothing new since last build — skip entirely
|
||||
crosses_cadence = total // FULL_REBUILD_EVERY != covered // FULL_REBUILD_EVERY
|
||||
if new <= FOLD_LIMIT and not crosses_cadence:
|
||||
return _fold(existing, [s.content for s in summaries[covered:]], total, backend)
|
||||
|
||||
# REDUCE: fold partials together until one remains.
|
||||
while len(partials) > 1:
|
||||
partials = [_call(_REDUCE_PROMPT, g, backend) for g in _batch_texts(partials, BATCH_CHARS)]
|
||||
profile = partials[0]
|
||||
|
||||
memory.set_profile(profile, len(summaries))
|
||||
logbus.log("info", "profile rebuilt", sessions=len(summaries), chars=len(profile))
|
||||
return profile
|
||||
return _full_rebuild([s.content for s in summaries], backend)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
+7
-4
@@ -196,11 +196,12 @@ def context_note(limit: int = 3) -> str | None:
|
||||
for r in rows:
|
||||
chain = thread_thoughts(r["id"])
|
||||
latest = chain[-1]["content"] if chain else ""
|
||||
lines.append(f'- "{r["title"]}": {latest}')
|
||||
lines.append(f'- (#{r["id"]}) "{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)
|
||||
"natural, don't force it). If Brian responds to one, capture his take with the "
|
||||
"thought_response tool using its #id:\n" + "\n".join(lines)
|
||||
)
|
||||
|
||||
|
||||
@@ -335,9 +336,11 @@ def maybe_surface(last_exchange_iso: str | None) -> str | None:
|
||||
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']}\" "
|
||||
f"(thread #{cand['id']} \"{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."
|
||||
"thread you've been on, not a prompt. Don't force it if the moment's wrong. "
|
||||
f"If he responds to it, capture his take with the thought_response tool "
|
||||
f"(thread_id {cand['id']}) so you carry it forward."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,35 @@ def _think_about(args: dict, ctx: dict) -> str:
|
||||
"I'll come back to it on my own between our conversations.")
|
||||
|
||||
|
||||
def _set_mode(args: dict, ctx: dict) -> str:
|
||||
from lyra import modes
|
||||
key = (args.get("mode") or "").strip().lower()
|
||||
m = modes.MODES.get(key)
|
||||
if not m:
|
||||
return f"(unknown mode '{key}'; valid: {', '.join(modes.MODES)})"
|
||||
sid = ctx.get("session_id")
|
||||
if not sid:
|
||||
return "(no session to switch)"
|
||||
memory.set_session_mode(sid, key)
|
||||
logbus.log("info", "mode switch (tool)", session=sid, mode=key)
|
||||
return f"Switched to {m.label} mode."
|
||||
|
||||
|
||||
def _thought_response(args: dict, ctx: dict) -> str:
|
||||
try:
|
||||
tid = int(args.get("thread_id"))
|
||||
except (TypeError, ValueError):
|
||||
return "Tell me which thought — I need its thread id (the #number you were given)."
|
||||
said = (args.get("brian_said") or "").strip()
|
||||
if not said:
|
||||
return "Nothing to record yet — what did Brian say about it?"
|
||||
if not thoughts.record_response(tid, said):
|
||||
return f"(couldn't find thought thread #{tid})"
|
||||
logbus.log("info", "Brian reacted to a thought in chat (tool)", thread=tid)
|
||||
return (f"Folded Brian's take into thread #{tid} — I'll pick it back up and react "
|
||||
"next time I'm thinking.")
|
||||
|
||||
|
||||
# name -> {spec (OpenAI function tool), handler}
|
||||
TOOLS: dict[str, dict] = {
|
||||
"journal_write": {
|
||||
@@ -437,6 +466,21 @@ _S = {"type": "string"}
|
||||
_N = {"type": "number"}
|
||||
|
||||
TOOLS.update({
|
||||
"set_mode": {"handler": _set_mode, "spec": _f(
|
||||
"set_mode",
|
||||
"Switch your conversation mode when the work clearly shifts and Brian's agreed to it. "
|
||||
"Offer first ('want me in Decide for this?'), then call this on his yes.",
|
||||
{"mode": {**_S, "description": "Mode key: conversation | poker_cash | build | explore | study | decide"}},
|
||||
["mode"])},
|
||||
"thought_response": {"handler": _thought_response, "spec": _f(
|
||||
"thought_response",
|
||||
"When you've brought one of your own thoughts/threads to Brian and he responds to "
|
||||
"it in the conversation, capture his reaction here so it folds back into that "
|
||||
"thread — you'll carry it forward on your own next time you think. Use the thread "
|
||||
"id (#number) you were given for that thought.",
|
||||
{"thread_id": {**_N, "description": "The thread id (#number) of the thought he reacted to."},
|
||||
"brian_said": {**_S, "description": "What Brian said / his take, in your words."}},
|
||||
["thread_id", "brian_said"])},
|
||||
"start_session": {"handler": _start_session, "spec": _f(
|
||||
"start_session",
|
||||
"Begin a live poker session. Call when Brian sits down to play.",
|
||||
|
||||
@@ -339,6 +339,24 @@ def create_app() -> FastAPI:
|
||||
async def hands_data(limit: int = 60) -> dict:
|
||||
return {"hands": poker.list_recent_hands(limit=limit)}
|
||||
|
||||
@app.post("/hands")
|
||||
async def hands_create(request: Request) -> dict:
|
||||
"""Store a structured hand built by the recorder. Body:
|
||||
{structured, session_id?, tag?, lesson?}. normalize_structured() (in
|
||||
store_hand_history) is the authority on shape, so the client can be best-effort."""
|
||||
body = await request.json()
|
||||
structured = body.get("structured")
|
||||
if not isinstance(structured, dict):
|
||||
return {"ok": False, "error": "missing structured hand body"}
|
||||
hid = await asyncio.to_thread(
|
||||
poker.store_hand_history, structured,
|
||||
session_id=body.get("session_id"), tag=body.get("tag"), lesson=body.get("lesson"),
|
||||
)
|
||||
# Enrich villain dossiers from the recorded players, same as the parser path.
|
||||
await asyncio.to_thread(poker.link_hand_players, hid, structured, body.get("session_id"))
|
||||
logbus.log("info", "hand recorded", id=hid, session=body.get("session_id"))
|
||||
return {"ok": True, "id": hid}
|
||||
|
||||
@app.get("/recap/{session_id}")
|
||||
async def recap_page() -> FileResponse:
|
||||
return FileResponse(str(_STATIC / "recap.html"))
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>Lyra Core Chat</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="/recorder.css" />
|
||||
<!-- PWA -->
|
||||
<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" />
|
||||
@@ -26,7 +27,11 @@
|
||||
<h4>Mode</h4>
|
||||
<select id="mobileMode">
|
||||
<option value="conversation">💬 Talk</option>
|
||||
<option value="poker_cash">♠ Cash</option>
|
||||
<option value="poker_cash">♠ Poker</option>
|
||||
<option value="build">🛠 Build</option>
|
||||
<option value="explore">🔭 Explore</option>
|
||||
<option value="study">📐 Study</option>
|
||||
<option value="decide">⚖️ Decide</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -62,11 +67,15 @@
|
||||
</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>
|
||||
<button class="mode-badge" id="modeBadge" type="button" title="Current mode (tap to cycle)">💬 Talk</button>
|
||||
<label for="mode">Mode:</label>
|
||||
<select id="mode">
|
||||
<option value="conversation">💬 Talk</option>
|
||||
<option value="poker_cash">♠ Cash</option>
|
||||
<option value="poker_cash">♠ Poker</option>
|
||||
<option value="build">🛠 Build</option>
|
||||
<option value="explore">🔭 Explore</option>
|
||||
<option value="study">📐 Study</option>
|
||||
<option value="decide">⚖️ Decide</option>
|
||||
</select>
|
||||
<button id="settingsBtn" style="margin-left: auto;">⚙ Settings</button>
|
||||
<div id="theme-toggle">
|
||||
@@ -121,7 +130,8 @@
|
||||
<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>
|
||||
<a class="tab tab-mind" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
|
||||
<button class="tab tab-rec" id="recordTab" type="button"><span class="ti">➕</span><span class="tl">Record</span></button>
|
||||
<button class="tab" id="moreTab" type="button"><span class="ti">⋯</span><span class="tl">More</span></button>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -605,8 +615,11 @@
|
||||
}
|
||||
|
||||
|
||||
// ----- Conversation mode (Talk / Cash) -----
|
||||
const MODE_LABELS = { conversation: "💬 Talk", poker_cash: "♠ Cash" };
|
||||
// ----- Conversation modes (Talk / Poker / Build / Explore / Study) -----
|
||||
const MODE_LABELS = { conversation: "💬 Talk", poker_cash: "♠ Poker",
|
||||
build: "🛠 Build", explore: "🔭 Explore", study: "📐 Study",
|
||||
decide: "⚖️ Decide" };
|
||||
const MODE_ORDER = ["conversation", "poker_cash", "build", "explore", "study", "decide"];
|
||||
|
||||
// Reflect a mode value across the controls + header accent (no network call).
|
||||
function applyMode(value) {
|
||||
@@ -690,6 +703,16 @@
|
||||
window.addEventListener("resize", nudgeAppHeight);
|
||||
window.addEventListener("orientationchange", nudgeAppHeight);
|
||||
|
||||
// A rotation reflows the chat and iOS drops the scroll to mid-history. If we
|
||||
// were pinned to the latest message, snap back there once the layout settles
|
||||
// (re-fire across the reflow since iOS reports stale dimensions mid-rotate).
|
||||
window.addEventListener("orientationchange", () => {
|
||||
const m = document.getElementById("messages");
|
||||
const wasAtBottom = m.scrollHeight - m.scrollTop - m.clientHeight < 90;
|
||||
if (!wasAtBottom) return; // respect the user's scroll-up position
|
||||
[100, 300, 600].forEach((t) => setTimeout(() => { m.scrollTop = m.scrollHeight; }, t));
|
||||
});
|
||||
|
||||
// Keep the latest message in view when the keyboard opens/closes.
|
||||
const userInputEl = document.getElementById("userInput");
|
||||
userInputEl.addEventListener("focus", () => {
|
||||
@@ -730,8 +753,10 @@
|
||||
|
||||
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"));
|
||||
modeBadge.addEventListener("click", () => {
|
||||
const i = MODE_ORDER.indexOf(desktopMode.value);
|
||||
chooseMode(MODE_ORDER[(i + 1) % MODE_ORDER.length]); // tap cycles through modes
|
||||
});
|
||||
|
||||
// Reflect the last-used mode immediately; the per-session value loads once
|
||||
// the current session is known (below).
|
||||
@@ -1222,5 +1247,23 @@
|
||||
});
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
<!-- Hand recorder (overlay; chat/session stays mounted underneath) -->
|
||||
<div id="recorderOverlay" class="rec-overlay"></div>
|
||||
<script src="/recorder.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var overlay = document.getElementById("recorderOverlay");
|
||||
var recordTab = document.getElementById("recordTab");
|
||||
function close() { overlay.classList.remove("open"); overlay.innerHTML = ""; }
|
||||
if (recordTab) recordTab.addEventListener("click", function () {
|
||||
overlay.innerHTML = "";
|
||||
overlay.classList.add("open");
|
||||
window.Recorder.mount(overlay, {
|
||||
onClose: close,
|
||||
onSave: function (id) { close(); window.open("/hand/" + id, "_blank"); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/* Hand recorder overlay. Uses the app theme tokens (--accent, --bg-* etc.) from
|
||||
style.css when mounted in index.html. For a standalone recorder.html, import those
|
||||
tokens too (see :root in style.css). */
|
||||
|
||||
.rec-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-dark, #070707);
|
||||
flex-direction: column;
|
||||
}
|
||||
/* :not(.open) outranks .rec-root's display:flex (added on mount), so closing the
|
||||
overlay actually hides it instead of leaving an empty black screen. */
|
||||
.rec-overlay:not(.open) { display: none; }
|
||||
.rec-overlay.open { display: flex; }
|
||||
|
||||
.rec-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
color: var(--text-main, #e8e8e8);
|
||||
font-family: var(--font-console, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.rec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
padding-top: calc(12px + env(safe-area-inset-top)); /* clear the notch/status bar */
|
||||
border-bottom: 1px solid var(--border, #2a1d12);
|
||||
}
|
||||
.rec-title { font-weight: 700; color: var(--accent, #ff7a00); }
|
||||
.rec-meta { color: var(--text-fade, #8a8a8a); font-size: .82rem; flex: 1; }
|
||||
.rec-x {
|
||||
background: none; border: 1px solid var(--border, #2a1d12); color: var(--text-fade, #8a8a8a);
|
||||
border-radius: 8px; width: 34px; height: 34px; font-size: 1rem;
|
||||
}
|
||||
|
||||
.rec-body { flex: 1; overflow-y: auto; padding: 12px 14px 20px; -webkit-overflow-scrolling: touch; }
|
||||
.rec-sec { margin-bottom: 18px; }
|
||||
.rec-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .6px; color: var(--text-fade, #8a8a8a); margin-bottom: 6px; }
|
||||
.rec-dim { color: var(--text-fade, #8a8a8a); font-size: .85rem; }
|
||||
|
||||
.rec-pos-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.rec-pos {
|
||||
min-width: 44px; padding: 9px 10px; border-radius: 9px;
|
||||
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8);
|
||||
border: 1px solid var(--border, #2a1d12); font-size: .82rem; font-weight: 600;
|
||||
}
|
||||
.rec-pos.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
|
||||
.rec-pos.add { color: var(--text-fade, #8a8a8a); border-style: dashed; }
|
||||
|
||||
.rec-hero-cards { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
||||
|
||||
.rec-seats { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
|
||||
.rec-seat { display: flex; align-items: center; gap: 8px; }
|
||||
.rec-seat-pos { min-width: 42px; font-weight: 700; color: var(--gold, #ffb347); }
|
||||
.rec-name {
|
||||
flex: 1; min-width: 0; padding: 8px 9px; border-radius: 8px;
|
||||
background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12);
|
||||
color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .85rem;
|
||||
}
|
||||
.rec-rm { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .9rem; padding: 4px; }
|
||||
|
||||
/* typed card entry */
|
||||
.rec-field { display: block; margin-top: 10px; }
|
||||
.rec-cards {
|
||||
width: 100%; padding: 10px 11px; border-radius: 8px;
|
||||
background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12);
|
||||
color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .95rem;
|
||||
letter-spacing: 1px; box-sizing: border-box;
|
||||
}
|
||||
.rec-cards.sm { width: 88px; flex: none; padding: 8px 9px; font-size: .85rem; }
|
||||
|
||||
.rec-street-tabs { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.rec-tab {
|
||||
flex: 1; padding: 9px 6px; border-radius: 9px; font-size: .78rem; font-weight: 600;
|
||||
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8); border: 1px solid var(--border, #2a1d12);
|
||||
}
|
||||
.rec-tab.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
|
||||
|
||||
.rec-act-add { display: flex; gap: 6px; margin: 8px 0; }
|
||||
.rec-sel, .rec-num {
|
||||
padding: 9px 8px; border-radius: 8px; background: var(--bg-elev, #0e0e0e);
|
||||
border: 1px solid var(--border, #2a1d12); color: var(--text-main, #e8e8e8);
|
||||
font-family: inherit; font-size: .85rem; min-width: 0;
|
||||
}
|
||||
.rec-sel { flex: 1; }
|
||||
.rec-num { width: 70px; }
|
||||
.rec-add-act { padding: 9px 12px; border-radius: 8px; background: var(--border-bright, #4a2f15); color: #fff; border: none; font-weight: 600; }
|
||||
.rec-result { display: flex; gap: 12px; }
|
||||
.rec-result label { display: flex; flex-direction: column; gap: 4px; font-size: .72rem; color: var(--text-fade, #8a8a8a); }
|
||||
|
||||
.rec-log { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
|
||||
.rec-ln { font-size: .82rem; color: var(--text-main, #e8e8e8); }
|
||||
.rec-ln.brd { display: flex; gap: 4px; align-items: center; color: var(--text-fade, #8a8a8a); }
|
||||
.rec-ln b { color: var(--accent, #ff7a00); font-weight: 700; }
|
||||
|
||||
.rec-foot {
|
||||
display: flex; gap: 10px;
|
||||
padding: 12px 14px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--border, #2a1d12);
|
||||
}
|
||||
.rec-save { flex: 1; padding: 14px; border-radius: 10px; background: var(--accent, #ff7a00); color: #111; border: none; font-weight: 700; font-size: 1rem; }
|
||||
.rec-save:disabled { opacity: .6; }
|
||||
.rec-cancel { padding: 14px 18px; border-radius: 10px; background: var(--bg-elev, #0e0e0e); color: var(--text-fade, #8a8a8a); border: 1px solid var(--border, #2a1d12); font-weight: 600; font-size: 1rem; }
|
||||
|
||||
.rec-undo { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .75rem; padding: 0 4px; }
|
||||
|
||||
/* The Record tab swaps in for Mind in the bottom bar, but only in poker (cash) mode.
|
||||
body.cash-mode is toggled on mode change in index.html. */
|
||||
#tabbar .tab-rec { display: none; }
|
||||
body.cash-mode #tabbar .tab-rec { display: flex; }
|
||||
body.cash-mode #tabbar .tab-mind { display: none; }
|
||||
#tabbar .tab-rec .ti { color: var(--accent, #ff7a00); filter: none; }
|
||||
@@ -0,0 +1,425 @@
|
||||
/* Hand recorder — tap-to-build poker hands. See docs/RECORDER.md.
|
||||
*
|
||||
* Correctness by construction: each field writes a known value into a known slot,
|
||||
* so there's no LLM parse step that can be wrong. Output is the canonical structured
|
||||
* contract (docs/HAND_HISTORY.md); the server's normalize_structured() is the final
|
||||
* authority on shape (case, suits, 10->T, completeness), so this stays best-effort.
|
||||
*
|
||||
* Mount-agnostic: Recorder.mount(container, opts) renders into ANY element — a
|
||||
* full-screen overlay in index.html today, a standalone recorder.html later, with
|
||||
* zero logic changes. buildStructured(state) is pure (no DOM) — the reusable core.
|
||||
*
|
||||
* Card entry: plain typed text for now ("ah kh", "AhKh", "7d 2c 5h"). The tap picker
|
||||
* is shelved (docs/RECORDER.md V2) — parseCards() + server normalize handle the rest.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const SUITS = { s: "♠", h: "♥", d: "♦", c: "♣" };
|
||||
const POSITIONS = ["UTG", "UTG1", "UTG2", "MP", "LJ", "HJ", "CO", "BTN", "SB", "BB"];
|
||||
const STREETS = ["preflop", "flop", "turn", "river"];
|
||||
const STREET_BOARD = { flop: 3, turn: 1, river: 1 };
|
||||
const ACTIONS = ["fold", "check", "call", "bet", "raise", "allin"];
|
||||
const SIZED = { bet: true, raise: true, allin: true };
|
||||
|
||||
// --- card text -> tokens (server normalizes case/suit/10) ------------------
|
||||
function parseCards(str) {
|
||||
if (!str) return [];
|
||||
const s = String(str).trim().replace(/10/g, "T");
|
||||
if (!s) return [];
|
||||
const parts = /\s/.test(s) ? s.split(/\s+/) : s.match(/.{1,2}/g) || [];
|
||||
return parts.map((p) => p.trim()).filter(Boolean);
|
||||
}
|
||||
function cardsText(arr) {
|
||||
return arr && arr.length ? arr.join(" ") : "";
|
||||
}
|
||||
|
||||
// --- pure core: state -> contract dict (testable, no DOM) ------------------
|
||||
function buildStructured(state) {
|
||||
const players = state.seats
|
||||
.filter((s) => s.pos)
|
||||
.map((s) => {
|
||||
const p = { pos: s.pos };
|
||||
if (s.stack != null) p.stack = s.stack;
|
||||
if (s.name) p.name = s.name;
|
||||
p.cards = s.cards && s.cards.length ? s.cards.slice() : null;
|
||||
return p;
|
||||
});
|
||||
|
||||
const actions = [];
|
||||
for (const st of STREETS) {
|
||||
const reveal = state.board[st];
|
||||
if (st !== "preflop" && reveal && reveal.length) {
|
||||
actions.push({ street: st, board: reveal.slice() });
|
||||
}
|
||||
for (const a of state.actions.filter((x) => x.street === st)) {
|
||||
actions.push({
|
||||
street: st,
|
||||
pos: a.pos,
|
||||
action: a.action,
|
||||
amount: a.amount != null ? a.amount : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hero = state.seats.find((s) => s.pos === state.heroPos);
|
||||
const board = [].concat(state.board.flop, state.board.turn, state.board.river);
|
||||
return {
|
||||
game: state.meta.game || "NLH",
|
||||
stakes: state.meta.stakes || null,
|
||||
hero_pos: state.heroPos || null,
|
||||
hero_cards: hero && hero.cards ? hero.cards.slice() : [],
|
||||
players,
|
||||
actions,
|
||||
board,
|
||||
result: {
|
||||
pot: state.result.pot,
|
||||
hero_net: state.result.heroNet,
|
||||
summary: state.result.summary || "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseBlinds(stakes) {
|
||||
const m = (stakes || "").match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
|
||||
return m ? { sb: parseFloat(m[1]), bb: parseFloat(m[2]) } : { sb: null, bb: null };
|
||||
}
|
||||
|
||||
function initialState(hud) {
|
||||
const sess = (hud && hud.session) || {};
|
||||
const stack = (hud && hud.stack) || {};
|
||||
const blinds = parseBlinds(sess.stakes);
|
||||
|
||||
const seats = [];
|
||||
for (const v of (hud && hud.villains) || []) {
|
||||
if (v.seat && POSITIONS.includes(v.seat)) {
|
||||
seats.push({ pos: v.seat, name: v.name || null, stack: null, cards: null });
|
||||
}
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
if (blinds.sb != null) actions.push({ street: "preflop", pos: "SB", action: "post", amount: blinds.sb });
|
||||
if (blinds.bb != null) actions.push({ street: "preflop", pos: "BB", action: "post", amount: blinds.bb });
|
||||
|
||||
return {
|
||||
meta: {
|
||||
game: sess.game || "NLH",
|
||||
stakes: sess.stakes || null,
|
||||
venue: sess.venue || null,
|
||||
sessionId: sess.id != null ? sess.id : null,
|
||||
},
|
||||
blinds,
|
||||
heroStack: stack.current != null ? stack.current : null,
|
||||
heroPos: null,
|
||||
seats,
|
||||
street: "preflop",
|
||||
board: { flop: [], turn: [], river: [] },
|
||||
actions,
|
||||
result: { pot: null, heroNet: null, summary: "" },
|
||||
};
|
||||
}
|
||||
|
||||
function ensureHero(state) {
|
||||
let hero = state.seats.find((s) => s.pos === state.heroPos);
|
||||
if (!hero && state.heroPos) {
|
||||
hero = { pos: state.heroPos, name: "Hero", stack: state.heroStack, cards: [] };
|
||||
state.seats.push(hero);
|
||||
}
|
||||
return hero || {};
|
||||
}
|
||||
|
||||
window.Recorder = {
|
||||
buildStructured,
|
||||
parseCards,
|
||||
parseBlinds,
|
||||
initialState,
|
||||
_internals: { POSITIONS, STREETS },
|
||||
mount,
|
||||
};
|
||||
|
||||
// --- mount / render -------------------------------------------------------
|
||||
async function mount(container, opts) {
|
||||
opts = opts || {};
|
||||
let hud = opts.hud;
|
||||
if (!hud) {
|
||||
try {
|
||||
const url = opts.sessionId != null ? `/session/data?id=${opts.sessionId}` : "/session/data";
|
||||
hud = await fetch(url).then((r) => r.json());
|
||||
} catch (e) {
|
||||
hud = { session: null };
|
||||
}
|
||||
}
|
||||
const state = initialState(hud);
|
||||
const ctx = { container, state, opts };
|
||||
container.classList.add("rec-root");
|
||||
container.addEventListener("click", (e) => handleClick(ctx, e));
|
||||
container.addEventListener("input", (e) => handleInput(ctx, e));
|
||||
render(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function render(ctx) {
|
||||
const s = ctx.state;
|
||||
const hero = s.seats.find((x) => x.pos === s.heroPos) || {};
|
||||
ctx.container.innerHTML = `
|
||||
<div class="rec-head">
|
||||
<div class="rec-title">Record hand</div>
|
||||
<div class="rec-meta">${esc(s.meta.venue || "")}${s.meta.stakes ? " · " + esc(s.meta.stakes) : ""}</div>
|
||||
<button class="rec-x" data-act="close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="rec-body">
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Your seat</div>
|
||||
<div class="rec-pos-row">
|
||||
${POSITIONS.map((p) => `<button class="rec-pos${s.heroPos === p ? " on" : ""}" data-act="hero-pos" data-pos="${p}">${p}</button>`).join("")}
|
||||
</div>
|
||||
<label class="rec-field">
|
||||
<span class="rec-label">your cards</span>
|
||||
<input class="rec-cards" data-act="hero-cards" autocapitalize="off" autocomplete="off" spellcheck="false"
|
||||
placeholder="e.g. ah kh" value="${esc(cardsText(hero.cards))}">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Players in the hand</div>
|
||||
<div class="rec-seats">
|
||||
${s.seats.filter((x) => x.pos !== s.heroPos).map((seat) => renderSeat(seat)).join("") || '<div class="rec-dim">none yet</div>'}
|
||||
</div>
|
||||
<div class="rec-pos-row">
|
||||
${POSITIONS.filter((p) => p !== s.heroPos && !s.seats.some((x) => x.pos === p)).map((p) => `<button class="rec-pos add" data-act="add-seat" data-pos="${p}">+ ${p}</button>`).join("")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Streets</div>
|
||||
<div class="rec-street-tabs">
|
||||
${STREETS.map((st) => `<button class="rec-tab${s.street === st ? " on" : ""}" data-act="street" data-street="${st}">${st}${boardCount(s, st)}</button>`).join("")}
|
||||
</div>
|
||||
${renderStreet(ctx)}
|
||||
</section>
|
||||
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Result</div>
|
||||
<div class="rec-result">
|
||||
<label>pot <input class="rec-num" data-act="result" data-k="pot" inputmode="decimal" value="${s.result.pot != null ? s.result.pot : ""}"></label>
|
||||
<label>your net <input class="rec-num" data-act="result" data-k="heroNet" inputmode="decimal" value="${s.result.heroNet != null ? s.result.heroNet : ""}"></label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="rec-foot">
|
||||
<button class="rec-cancel" data-act="close">Cancel</button>
|
||||
<button class="rec-save" data-act="save">Save & replay</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSeat(seat) {
|
||||
return `
|
||||
<div class="rec-seat">
|
||||
<span class="rec-seat-pos">${seat.pos}</span>
|
||||
<input class="rec-name" data-act="seat-name" data-pos="${seat.pos}" autocapitalize="off" autocomplete="off"
|
||||
placeholder="name" value="${esc(seat.name || "")}">
|
||||
<input class="rec-cards sm" data-act="seat-cards" data-pos="${seat.pos}" autocapitalize="off" autocomplete="off" spellcheck="false"
|
||||
placeholder="shown?" value="${esc(cardsText(seat.cards))}">
|
||||
<button class="rec-rm" data-act="rm-seat" data-pos="${seat.pos}">✕</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderStreet(ctx) {
|
||||
const s = ctx.state;
|
||||
const st = s.street;
|
||||
const players = s.seats.map((x) => x.pos);
|
||||
const boardInput =
|
||||
st === "preflop"
|
||||
? ""
|
||||
: `<label class="rec-field">
|
||||
<span class="rec-label">${st} board (${STREET_BOARD[st]})</span>
|
||||
<input class="rec-cards" data-act="board-cards" data-street="${st}" autocapitalize="off" autocomplete="off" spellcheck="false"
|
||||
placeholder="${st === "flop" ? "e.g. 7d 2c 5h" : "e.g. 5h"}" value="${esc(cardsText(s.board[st]))}">
|
||||
</label>`;
|
||||
|
||||
// Straddle: a voluntary preflop blind from any non-blind seat, default 2×BB.
|
||||
// Action starts left of it and it acts last preflop — order is whatever you enter.
|
||||
const stradAmt = s.blinds.bb != null ? 2 * s.blinds.bb : null;
|
||||
const stradElig = players.filter((p) => p !== "SB" && p !== "BB" && !s.actions.some((a) => a.straddle && a.pos === p));
|
||||
const straddle =
|
||||
st === "preflop" && stradElig.length
|
||||
? `<div class="rec-act-add">
|
||||
<select class="rec-sel" data-act="str-pos">
|
||||
<option value="">+ straddle${stradAmt != null ? " (" + stradAmt + ")" : ""}…</option>
|
||||
${stradElig.map((p) => `<option value="${p}">${p}</option>`).join("")}
|
||||
</select>
|
||||
<button class="rec-add-act" data-act="add-straddle">add</button>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
${boardInput}
|
||||
${straddle}
|
||||
<div class="rec-act-add">
|
||||
<select class="rec-sel" data-act="na-pos">
|
||||
<option value="">who</option>
|
||||
${players.map((p) => `<option value="${p}">${p}${p === s.heroPos ? " (you)" : ""}</option>`).join("")}
|
||||
</select>
|
||||
<select class="rec-sel" data-act="na-action">
|
||||
<option value="">action</option>
|
||||
${ACTIONS.map((a) => `<option value="${a}">${a}</option>`).join("")}
|
||||
</select>
|
||||
<input class="rec-num" data-act="na-amount" inputmode="decimal" placeholder="$">
|
||||
<button class="rec-add-act" data-act="add-action">add</button>
|
||||
</div>
|
||||
<div class="rec-log">
|
||||
${s.board[st] && s.board[st].length && st !== "preflop" ? `<div class="rec-ln brd">${st}: ${cardsText(s.board[st])}</div>` : ""}
|
||||
${s.actions
|
||||
.filter((a) => a.street === st)
|
||||
.map((a, i) => {
|
||||
const label = a.straddle ? "straddle" : a.action;
|
||||
const amt = a.amount != null ? " " + a.amount : "";
|
||||
const fixed = a.action === "post" && !a.straddle; // blinds aren't removable
|
||||
const rm = fixed ? "" : ` <button class="rec-undo" data-act="rm-action" data-street="${st}" data-i="${i}">✕</button>`;
|
||||
return `<div class="rec-ln">${a.pos} <b>${label}</b>${amt}${rm}</div>`;
|
||||
})
|
||||
.join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function boardCount(s, st) {
|
||||
const n = (s.board[st] || []).length;
|
||||
return n ? ` ${n}` : "";
|
||||
}
|
||||
|
||||
// --- events ---------------------------------------------------------------
|
||||
function handleClick(ctx, e) {
|
||||
const s = ctx.state;
|
||||
const t = e.target.closest("[data-act]");
|
||||
if (!t) return;
|
||||
const act = t.getAttribute("data-act");
|
||||
|
||||
switch (act) {
|
||||
case "close":
|
||||
if (ctx.opts.onClose) ctx.opts.onClose();
|
||||
return;
|
||||
case "hero-pos": {
|
||||
const pos = t.getAttribute("data-pos");
|
||||
const old = s.seats.find((x) => x.pos === s.heroPos);
|
||||
if (old && old.name === "Hero" && !(old.cards || []).length) {
|
||||
s.seats = s.seats.filter((x) => x !== old);
|
||||
}
|
||||
s.heroPos = s.heroPos === pos ? null : pos;
|
||||
if (s.heroPos) ensureHero(s);
|
||||
break;
|
||||
}
|
||||
case "add-seat":
|
||||
s.seats.push({ pos: t.getAttribute("data-pos"), name: null, stack: null, cards: null });
|
||||
break;
|
||||
case "rm-seat":
|
||||
s.seats = s.seats.filter((x) => x.pos !== t.getAttribute("data-pos"));
|
||||
break;
|
||||
case "street":
|
||||
s.street = t.getAttribute("data-street");
|
||||
break;
|
||||
case "add-action":
|
||||
addActionFromControls(ctx);
|
||||
break;
|
||||
case "add-straddle": {
|
||||
const sel = ctx.container.querySelector('[data-act="str-pos"]');
|
||||
const pos = sel && sel.value;
|
||||
if (pos) {
|
||||
const amt = s.blinds.bb != null ? 2 * s.blinds.bb : null;
|
||||
s.actions.push({ street: "preflop", pos, action: "post", amount: amt, straddle: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "rm-action":
|
||||
removeAction(s, t.getAttribute("data-street"), parseInt(t.getAttribute("data-i"), 10));
|
||||
break;
|
||||
case "save":
|
||||
return doSave(ctx);
|
||||
default:
|
||||
return; // inputs handled in handleInput
|
||||
}
|
||||
render(ctx);
|
||||
}
|
||||
|
||||
function handleInput(ctx, e) {
|
||||
const s = ctx.state;
|
||||
const t = e.target.closest("[data-act]");
|
||||
if (!t) return;
|
||||
const act = t.getAttribute("data-act");
|
||||
if (act === "hero-cards") {
|
||||
const hero = ensureHero(s);
|
||||
hero.cards = parseCards(t.value);
|
||||
} else if (act === "seat-cards") {
|
||||
const seat = s.seats.find((x) => x.pos === t.getAttribute("data-pos"));
|
||||
if (seat) seat.cards = parseCards(t.value);
|
||||
} else if (act === "board-cards") {
|
||||
s.board[t.getAttribute("data-street")] = parseCards(t.value);
|
||||
} else if (act === "seat-name") {
|
||||
const seat = s.seats.find((x) => x.pos === t.getAttribute("data-pos"));
|
||||
if (seat) seat.name = t.value.trim() || null;
|
||||
} else if (act === "result") {
|
||||
const k = t.getAttribute("data-k");
|
||||
s.result[k] = t.value === "" ? null : parseFloat(t.value);
|
||||
}
|
||||
// no re-render mid-typing (keeps input focus)
|
||||
}
|
||||
|
||||
function addActionFromControls(ctx) {
|
||||
const root = ctx.container;
|
||||
const pos = root.querySelector('[data-act="na-pos"]').value;
|
||||
const action = root.querySelector('[data-act="na-action"]').value;
|
||||
const amt = root.querySelector('[data-act="na-amount"]').value;
|
||||
if (!pos || !action) return;
|
||||
const entry = { street: ctx.state.street, pos, action };
|
||||
entry.amount = SIZED[action] && amt !== "" ? parseFloat(amt) : null;
|
||||
ctx.state.actions.push(entry);
|
||||
}
|
||||
|
||||
function removeAction(state, street, idxWithinStreet) {
|
||||
let seen = -1;
|
||||
for (let i = 0; i < state.actions.length; i++) {
|
||||
if (state.actions[i].street === street) {
|
||||
seen++;
|
||||
if (seen === idxWithinStreet) {
|
||||
state.actions.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function doSave(ctx) {
|
||||
const structured = buildStructured(ctx.state);
|
||||
const btn = ctx.container.querySelector(".rec-save");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Saving…";
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/hands", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ structured, session_id: ctx.state.meta.sessionId }),
|
||||
}).then((r) => r.json());
|
||||
if (res && res.ok) {
|
||||
if (ctx.opts.onSave) ctx.opts.onSave(res.id);
|
||||
else window.location.href = `/hand/${res.id}`;
|
||||
} else {
|
||||
throw new Error((res && res.error) || "save failed");
|
||||
}
|
||||
} catch (err) {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Save failed — retry";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function esc(x) {
|
||||
return String(x == null ? "" : x).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
}
|
||||
|
||||
void SUITS;
|
||||
})();
|
||||
@@ -56,6 +56,10 @@ body.dark {
|
||||
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
/* Stop iOS from inflating font sizes when the device rotates to landscape (and
|
||||
leaving them big on rotate back). Every other page sets this; the chat didn't. */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""The mind pipeline: the deliberation pass (think privately before answering)."""
|
||||
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.mind as mind
|
||||
importlib.reload(mind)
|
||||
return memory, mind
|
||||
|
||||
|
||||
def test_should_deliberate_skips_trivial(lyra):
|
||||
_, mind = lyra
|
||||
assert mind._should_deliberate("How would we actually start building this?")
|
||||
assert mind._should_deliberate("I disagree, that seems risky")
|
||||
for trivial in ("ok", "lol", "thanks", "yeah", "nice", "👍", "k"):
|
||||
assert not mind._should_deliberate(trivial)
|
||||
assert not mind._should_deliberate("ok!") # punctuation stripped
|
||||
assert not mind._should_deliberate("hey") # too short
|
||||
|
||||
|
||||
def test_deliberation_note_runs_and_appends(lyra, monkeypatch):
|
||||
memory, mind = lyra
|
||||
calls = []
|
||||
|
||||
def fake_complete(messages, backend=None, model=None):
|
||||
calls.append(messages)
|
||||
return "I actually think the first move is the smallest end-to-end slice."
|
||||
|
||||
memory.ensure_session("s1")
|
||||
monkeypatch.setattr(mind.llm, "complete", fake_complete)
|
||||
note = mind._deliberation_note("s1", "How would we start on this?", "cloud", None)
|
||||
assert note and note["role"] == "system"
|
||||
assert "first move is the smallest" in note["content"] # her thinking carried in
|
||||
assert "numbered list" in note["content"].lower() # voice enforcement attached
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_deliberation_skipped_when_disabled(lyra, monkeypatch):
|
||||
_, mind = lyra
|
||||
monkeypatch.setenv("CHAT_DELIBERATE", "false")
|
||||
called = []
|
||||
monkeypatch.setattr(mind.llm, "complete", lambda *a, **k: called.append(1) or "x")
|
||||
assert mind._deliberation_note("s1", "a real substantive question here", "cloud", None) is None
|
||||
assert called == [] # no LLM call when off
|
||||
|
||||
|
||||
def test_persona_core_is_tight_situational_is_gated(lyra):
|
||||
memory, mind = lyra
|
||||
from lyra import persona
|
||||
core, full = persona.core_prompt(), persona.system_prompt()
|
||||
assert "How you talk" in core and "How you actually work" not in core # voice core, self-model not
|
||||
assert len(core) < len(full) and persona.section("How you actually work")
|
||||
|
||||
memory.ensure_session("s1")
|
||||
casual = " ".join(m["content"] for m in mind.build_messages("s1", "any dinner ideas tonight?")
|
||||
if m["role"] == "system")
|
||||
meta = " ".join(m["content"] for m in mind.build_messages("s1", "how does your memory actually work?")
|
||||
if m["role"] == "system")
|
||||
assert "How you actually work" not in casual # situational section omitted on a casual turn
|
||||
assert "How you actually work" in meta # pulled in for a meta question
|
||||
|
||||
|
||||
def test_assemble_runs_the_pipeline(lyra, monkeypatch):
|
||||
memory, mind = lyra
|
||||
monkeypatch.setenv("CHAT_DELIBERATE", "false") # keep it offline for the structure test
|
||||
memory.ensure_session("s1")
|
||||
turn = mind.assemble("s1", "hey what's up", "cloud", None)
|
||||
assert turn.mode is not None # route ran
|
||||
assert turn.messages and turn.messages[-1]["role"] == "user" # compose ran
|
||||
assert turn.messages[-1]["content"] == "hey what's up"
|
||||
|
||||
|
||||
# --- mind/mouth split (P3) ----------------------------------------------
|
||||
|
||||
def test_mouth_target_off_by_default(monkeypatch):
|
||||
import importlib
|
||||
from lyra import config
|
||||
monkeypatch.delenv("MOUTH_BACKEND", raising=False)
|
||||
monkeypatch.delenv("MOUTH_MODEL", raising=False)
|
||||
import lyra.chat as chat
|
||||
importlib.reload(chat)
|
||||
assert chat._mouth_target(config.load(), "cloud", "gpt-4o") is None # mouth == mind
|
||||
|
||||
|
||||
def test_mouth_target_when_configured(monkeypatch):
|
||||
import importlib
|
||||
from lyra import config
|
||||
monkeypatch.setenv("MOUTH_BACKEND", "local")
|
||||
monkeypatch.setenv("MOUTH_MODEL", "dolphin3:8b")
|
||||
import lyra.chat as chat
|
||||
importlib.reload(chat)
|
||||
assert chat._mouth_target(config.load(), "cloud", "gpt-4o") == ("local", "dolphin3:8b")
|
||||
|
||||
|
||||
def test_voice_messages_carries_draft_and_instruction(lyra):
|
||||
_, mind = lyra
|
||||
out = mind.voice_messages([{"role": "user", "content": "hi"}], "draft with FACT 42")
|
||||
assert out[-2] == {"role": "assistant", "content": "draft with FACT 42"}
|
||||
assert out[-1]["role"] == "system" and "your own voice" in out[-1]["content"].lower()
|
||||
|
||||
|
||||
def test_voice_pass_revoices_then_falls_back(lyra, monkeypatch):
|
||||
_, mind = lyra
|
||||
import importlib
|
||||
import lyra.chat as chat
|
||||
importlib.reload(chat)
|
||||
monkeypatch.setattr(chat.llm, "complete", lambda msgs, backend=None, model=None: "voiced (FACT 42)")
|
||||
assert chat._voice_pass([], "draft FACT 42", "local", "dolphin3:8b") == "voiced (FACT 42)"
|
||||
# on failure it keeps the mind's draft (chat must not break)
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("mouth down")
|
||||
monkeypatch.setattr(chat.llm, "complete", boom)
|
||||
assert chat._voice_pass([], "draft FACT 42", "local", "dolphin3:8b") == "draft FACT 42"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Era rollups: only re-digest months whose session count changed (incremental)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
from lyra.memory import Era
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def era(monkeypatch):
|
||||
import lyra.era as era
|
||||
importlib.reload(era)
|
||||
return era
|
||||
|
||||
|
||||
def test_rebuild_eras_is_incremental(era, monkeypatch):
|
||||
by_month = {"2025-01": ["a", "b"], "2025-02": ["c"]}
|
||||
stored: dict[str, int] = {}
|
||||
built: list[str] = []
|
||||
|
||||
monkeypatch.setattr(era.memory, "summaries_by_month", lambda: dict(by_month))
|
||||
monkeypatch.setattr(era.memory, "list_eras",
|
||||
lambda: [Era(m, "x", c, "t") for m, c in stored.items()])
|
||||
monkeypatch.setattr(era.memory, "store_era",
|
||||
lambda month, content, n: (stored.__setitem__(month, n), built.append(month)))
|
||||
monkeypatch.setattr(era, "_digest_month", lambda gists, backend: "digest") # no LLM
|
||||
|
||||
r1 = era.rebuild_eras(backend="local") # first pass: both built
|
||||
assert r1["built"] == 2 and r1["skipped"] == 0
|
||||
|
||||
built.clear()
|
||||
r2 = era.rebuild_eras(backend="local") # nothing changed: all skipped
|
||||
assert r2["built"] == 0 and r2["skipped"] == 2 and built == []
|
||||
|
||||
built.clear()
|
||||
by_month["2025-02"].append("d") # one month gains a session
|
||||
r3 = era.rebuild_eras(backend="local")
|
||||
assert r3["built"] == 1 and r3["skipped"] == 1 and built == ["2025-02"]
|
||||
|
||||
built.clear()
|
||||
r4 = era.rebuild_eras(backend="local", force=True) # force rebuilds all
|
||||
assert r4["built"] == 2
|
||||
@@ -0,0 +1,111 @@
|
||||
"""The canonical structured-hand contract (docs/HAND_HISTORY.md): normalize + export.
|
||||
|
||||
normalize_structured() is the single guarantee that every stored / replayed / exported
|
||||
hand has the versioned shape RTO consumes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def poker(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)
|
||||
return poker
|
||||
|
||||
|
||||
def _full_hand():
|
||||
return {
|
||||
"game": "NLH", "stakes": "1/3", "hero_pos": "BTN",
|
||||
"hero_cards": ["ah", "kh"],
|
||||
"players": [
|
||||
{"pos": "BTN", "stack": 300, "name": "Hero"},
|
||||
{"pos": "BB", "stack": 250, "name": "Sal", "cards": ["qs", "qd"]},
|
||||
],
|
||||
"actions": [
|
||||
{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 15},
|
||||
{"street": "flop", "board": ["7♦", "2♣", "5♥"]},
|
||||
{"street": "flop", "pos": "BB", "action": "check"},
|
||||
],
|
||||
"board": ["7♦", "2♣", "5♥"],
|
||||
"result": {"pot": 40, "hero_net": 25, "summary": "won at showdown"},
|
||||
}
|
||||
|
||||
|
||||
def test_stamps_version(poker):
|
||||
out = poker.normalize_structured({"hero_pos": "CO"})
|
||||
assert out["schema_version"] == poker.HAND_SCHEMA_VERSION
|
||||
|
||||
|
||||
def test_card_normalization(poker):
|
||||
out = poker.normalize_structured(_full_hand())
|
||||
assert out["hero_cards"] == ["Ah", "Kh"] # lowercased input -> canonical
|
||||
assert out["board"] == ["7d", "2c", "5h"] # unicode suits -> letters
|
||||
assert out["actions"][1]["board"] == ["7d", "2c", "5h"]
|
||||
# ten + suit symbol together
|
||||
assert poker.normalize_structured({"board": ["10♠"]})["board"] == ["Ts"]
|
||||
|
||||
|
||||
def test_unknown_cards_preserved(poker):
|
||||
out = poker.normalize_structured({"hero_cards": ["Ax", "x"], "board": ["Ax", "4x", "x"]})
|
||||
assert out["hero_cards"] == ["Ax", "x"] # placeholders kept, not dropped
|
||||
assert out["completeness"]["cards"] is False
|
||||
assert out["completeness"]["board"] is False
|
||||
|
||||
|
||||
def test_hero_synced_into_players(poker):
|
||||
out = poker.normalize_structured(_full_hand())
|
||||
hero = next(p for p in out["players"] if p["pos"] == "BTN")
|
||||
assert hero["hero"] is True
|
||||
assert hero["cards"] == ["Ah", "Kh"] # mirrored from hero_cards
|
||||
assert sum(1 for p in out["players"] if p.get("hero")) == 1
|
||||
|
||||
|
||||
def test_hero_inserted_when_missing_from_players(poker):
|
||||
out = poker.normalize_structured({"hero_pos": "SB", "hero_cards": ["As", "Ad"], "players": []})
|
||||
assert out["players"] == [{"pos": "SB", "hero": True, "cards": ["As", "Ad"]}]
|
||||
|
||||
|
||||
def test_completeness_full_hand(poker):
|
||||
c = poker.normalize_structured(_full_hand())["completeness"]
|
||||
assert c == {"cards": True, "board": True, "actions": True}
|
||||
|
||||
|
||||
def test_idempotent(poker):
|
||||
once = poker.normalize_structured(_full_hand())
|
||||
twice = poker.normalize_structured(once)
|
||||
assert once == twice
|
||||
|
||||
|
||||
def test_store_and_get_roundtrip_is_normalized(poker):
|
||||
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
||||
hid = poker.store_hand_history(_full_hand(), session_id=sid, tag="well_played")
|
||||
got = poker.get_hand(hid)["structured"]
|
||||
assert got["schema_version"] == poker.HAND_SCHEMA_VERSION
|
||||
assert got["board"] == ["7d", "2c", "5h"]
|
||||
assert got["completeness"]["cards"] is True
|
||||
|
||||
|
||||
def test_list_recent_hands_flags_structured(poker):
|
||||
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
||||
structured_id = poker.store_hand_history(_full_hand(), session_id=sid)
|
||||
flat_id = poker.log_hand(session_id=sid, position="CO", hole_cards="Jc Jd")
|
||||
rows = {r["id"]: r for r in poker.list_recent_hands()}
|
||||
assert rows[structured_id]["has_structured"] is True
|
||||
assert rows[flat_id]["has_structured"] is False
|
||||
|
||||
|
||||
def test_hud_villains_carry_seat(poker):
|
||||
"""The recorder auto-places known players, so the HUD bundle must expose their seat."""
|
||||
poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
||||
poker.add_read("3-bets light", seat="BTN", name="Sal", category="risky")
|
||||
villains = {v["name"]: v for v in poker.hud()["villains"]}
|
||||
assert villains["Sal"]["seat"] == "BTN"
|
||||
@@ -47,6 +47,36 @@ def test_every_mode_tool_exists(lyra):
|
||||
assert set(mode.tools) <= set(tools.TOOLS), f"{mode.key} references unknown tools"
|
||||
|
||||
|
||||
def test_set_mode_tool_switches_session(lyra):
|
||||
memory, _, _, tools = lyra
|
||||
memory.ensure_session("s1")
|
||||
out = tools.dispatch("set_mode", {"mode": "decide"}, {"session_id": "s1"})
|
||||
assert "Decide" in out and memory.get_session_mode("s1") == "decide"
|
||||
# unknown mode is handled, session unchanged
|
||||
assert "unknown" in tools.dispatch("set_mode", {"mode": "nope"}, {"session_id": "s1"}).lower()
|
||||
assert memory.get_session_mode("s1") == "decide"
|
||||
|
||||
|
||||
def test_work_modes_present_and_gated(lyra):
|
||||
_, _, modes, tools = lyra
|
||||
# the full set Brian chose
|
||||
assert set(modes.MODES) == {"conversation", "poker_cash", "build", "explore", "study", "decide"}
|
||||
# Decide = read-only lookups for context, no live logging; has a real card
|
||||
decide = _names(tools.specs(modes.DECIDE.tools))
|
||||
assert {"running_stats", "recent_sessions"} <= decide and "log_hand" not in decide
|
||||
assert modes.DECIDE.card
|
||||
# Build/Explore are conversational: base agency tools only, no live poker logging
|
||||
for key in ("build", "explore"):
|
||||
names = _names(tools.specs(modes.get(key).tools))
|
||||
assert {"journal_write", "note", "think_about"} <= names
|
||||
assert "log_hand" not in names and "start_session" not in names
|
||||
assert modes.get(key).card # each has a real behavioral card
|
||||
# Study = read-only review: lookups + equity, but no live logging
|
||||
study = _names(tools.specs(modes.STUDY.tools))
|
||||
assert {"running_stats", "analyze_spot", "player_profile"} <= study
|
||||
assert "log_hand" not in study and "end_session" not in study
|
||||
|
||||
|
||||
def test_mode_resolution_and_persistence(lyra):
|
||||
memory, _, modes, _ = lyra
|
||||
assert modes.get(None).key == modes.DEFAULT
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Perceive: cheap heuristic read of the moment, and route turning it into a nudge."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
from lyra import perceive
|
||||
|
||||
|
||||
def test_reads_tilt():
|
||||
m = perceive.read("I'm so fucking tilted, card dead all night, this is brutal!!")
|
||||
assert m["tilt"] >= 0.5 and m["sentiment"] < 0 and m["kind"] == "emotional"
|
||||
|
||||
|
||||
def test_reads_strategy_calm():
|
||||
m = perceive.read("Should I fold the river here given his range and the board?")
|
||||
assert m["kind"] == "strategic" and m["tilt"] < 0.4
|
||||
|
||||
|
||||
def test_reads_up_energy():
|
||||
m = perceive.read("Let's go!! crushing it tonight, feeling so good!")
|
||||
assert m["sentiment"] > 0 and m["kind"] == "emotional"
|
||||
|
||||
|
||||
def test_reads_build_and_casual():
|
||||
assert perceive.read("let's refactor the cognition pipeline module").get("kind") == "build"
|
||||
assert perceive.read("ok sounds good to me").get("kind") == "casual"
|
||||
assert perceive.read("ok sounds good to me")["tilt"] == 0.0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mind(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("CHAT_DELIBERATE", "false")
|
||||
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.mind as mind
|
||||
importlib.reload(mind)
|
||||
memory.ensure_session("s1")
|
||||
return mind
|
||||
|
||||
|
||||
def test_route_injects_tilt_nudge(mind):
|
||||
turn = mind.assemble("s1", "ugh I'm steaming, fucking coolered again!!", "cloud", None)
|
||||
assert turn.register == "steady"
|
||||
sys_blob = " ".join(m["content"] for m in turn.messages if m["role"] == "system")
|
||||
assert "on tilt" in sys_blob.lower() or "frustrated" in sys_blob.lower()
|
||||
|
||||
|
||||
def test_route_quiet_on_neutral_turn(mind):
|
||||
turn = mind.assemble("s1", "what did we decide about the schema yesterday?", "cloud", None)
|
||||
assert turn.register is None # neutral -> no nudge
|
||||
assert not (turn.moment or {}).get("note")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Profile derivation: fold only new gists into the existing profile (incremental).
|
||||
|
||||
The old pass re-digested all ~851 gists every consolidation; this checks the cheap
|
||||
delta path fires in steady state and the full rebuild fires only when it should.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
from lyra.memory import Summary
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prof(monkeypatch):
|
||||
import lyra.profile as profile
|
||||
importlib.reload(profile)
|
||||
return profile
|
||||
|
||||
|
||||
def _wire(profile, monkeypatch, gists, covered, existing):
|
||||
"""Stub memory + the LLM passes; record which path ran."""
|
||||
state = {"stored_content": existing, "stored_covered": covered, "calls": []}
|
||||
|
||||
monkeypatch.setattr(profile.memory, "list_summaries",
|
||||
lambda: [Summary(f"s{i}", g, i, "t") for i, g in enumerate(gists)])
|
||||
monkeypatch.setattr(profile.memory, "get_profile", lambda: state["stored_content"])
|
||||
monkeypatch.setattr(profile.memory, "profile_sessions_covered", lambda: state["stored_covered"])
|
||||
|
||||
def set_profile(content, sessions_covered, profile_id="self"):
|
||||
state["stored_content"], state["stored_covered"] = content, sessions_covered
|
||||
monkeypatch.setattr(profile.memory, "set_profile", set_profile)
|
||||
|
||||
monkeypatch.setattr(profile, "_map_reduce",
|
||||
lambda gists, backend: state["calls"].append(("map_reduce", len(gists))) or "facts")
|
||||
monkeypatch.setattr(profile, "_call",
|
||||
lambda prompt, body, backend: state["calls"].append(("fold",)) or "folded profile")
|
||||
return state
|
||||
|
||||
|
||||
def test_no_profile_yet_does_full_rebuild(prof, monkeypatch):
|
||||
state = _wire(prof, monkeypatch, gists=["a", "b", "c"], covered=0, existing=None)
|
||||
out = prof.rebuild_profile(backend="local")
|
||||
assert state["calls"] == [("map_reduce", 3)] # mapped all three gists
|
||||
assert out == "facts" and state["stored_covered"] == 3
|
||||
|
||||
|
||||
def test_unchanged_skips_entirely(prof, monkeypatch):
|
||||
state = _wire(prof, monkeypatch, gists=["a", "b"], covered=2, existing="old profile")
|
||||
out = prof.rebuild_profile(backend="local")
|
||||
assert state["calls"] == [] # no LLM work at all
|
||||
assert out == "old profile"
|
||||
|
||||
|
||||
def test_small_delta_folds_only_new(prof, monkeypatch):
|
||||
state = _wire(prof, monkeypatch, gists=["a", "b", "c", "d"], covered=2, existing="old profile")
|
||||
out = prof.rebuild_profile(backend="local")
|
||||
assert state["calls"] == [("map_reduce", 2), ("fold",)] # mapped just the 2 new, then folded
|
||||
assert out == "folded profile" and state["stored_covered"] == 4
|
||||
|
||||
|
||||
def test_force_does_full_rebuild(prof, monkeypatch):
|
||||
state = _wire(prof, monkeypatch, gists=["a", "b", "c"], covered=3, existing="old profile")
|
||||
out = prof.rebuild_profile(backend="local", force=True)
|
||||
assert state["calls"] == [("map_reduce", 3)] # ignored the up-to-date profile
|
||||
assert out == "facts"
|
||||
|
||||
|
||||
def test_big_gap_falls_back_to_full_rebuild(prof, monkeypatch):
|
||||
gists = [str(i) for i in range(40)] # 30 new > FOLD_LIMIT
|
||||
state = _wire(prof, monkeypatch, gists=gists, covered=10, existing="old profile")
|
||||
out = prof.rebuild_profile(backend="local")
|
||||
assert state["calls"] == [("map_reduce", 40)] # full rebuild, not a giant fold
|
||||
assert out == "facts"
|
||||
|
||||
|
||||
def test_crossing_cadence_forces_full_rebuild(prof, monkeypatch):
|
||||
# covered=98, total=102 is a tiny delta, but it crosses the 100-session boundary.
|
||||
gists = [str(i) for i in range(102)]
|
||||
state = _wire(prof, monkeypatch, gists=gists, covered=98, existing="old profile")
|
||||
out = prof.rebuild_profile(backend="local")
|
||||
assert state["calls"] == [("map_reduce", 102)] # anti-drift full rebuild
|
||||
assert out == "facts"
|
||||
@@ -190,6 +190,21 @@ def test_think_about_tool_seeds_a_thread(lyra):
|
||||
assert chain[0]["kind"] == "question" and chain[0]["source"] == "chat"
|
||||
|
||||
|
||||
def test_thought_response_tool_threads_reply_back(lyra):
|
||||
_, th, box = lyra
|
||||
import lyra.tools as tools
|
||||
importlib.reload(tools)
|
||||
_gen(box, title="my restlessness", content="is it real?", salience=0.5)
|
||||
tid = th.think(force_mode="new")["thread_id"]
|
||||
out = tools.dispatch("thought_response", {"thread_id": tid, "brian_said": "I think it's real"})
|
||||
assert str(tid) in out
|
||||
t = th.get_thread(tid)
|
||||
assert t["last_response"] == "I think it's real" and th._is_pending(t)
|
||||
# bad id is handled, not crashed
|
||||
assert "couldn't find" in tools.dispatch("thought_response",
|
||||
{"thread_id": 9999, "brian_said": "x"})
|
||||
|
||||
|
||||
# --- external feed -------------------------------------------------------
|
||||
|
||||
RSS = (b'<?xml version="1.0"?><rss version="2.0"><channel><title>Feed</title>'
|
||||
|
||||
+4
-4
@@ -39,8 +39,8 @@ def lyra(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_now_note_first_contact(lyra):
|
||||
from lyra import chat
|
||||
note = chat._now_note()["content"]
|
||||
from lyra import mind
|
||||
note = mind._now_note()["content"]
|
||||
assert "current date and time is" in note
|
||||
assert "first thing Brian has ever said" in note
|
||||
|
||||
@@ -48,6 +48,6 @@ def test_now_note_first_contact(lyra):
|
||||
def test_now_note_reports_gap(lyra):
|
||||
memory = lyra
|
||||
memory.remember("s1", "user", "hey")
|
||||
from lyra import chat
|
||||
note = chat._now_note()["content"]
|
||||
from lyra import mind
|
||||
note = mind._now_note()["content"]
|
||||
assert "since Brian last spoke with you" in note
|
||||
|
||||
@@ -9,6 +9,7 @@ import pytest
|
||||
@pytest.fixture
|
||||
def lyra(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("CHAT_DELIBERATE", "false") # don't make a real LLM call in respond()
|
||||
from lyra import llm
|
||||
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
||||
import lyra.memory as memory
|
||||
|
||||
Reference in New Issue
Block a user