7 Commits

Author SHA1 Message Date
serversdown 51c2d6abb9 perf: tighten the dynamic prompt — persona split + lean deliberation
The per-turn prompt was ~5.5K tokens (persona alone ~40%), sent up to 3x/turn.
Tightened by RELEVANCE (the control plane decides what each turn needs), not by
deletion — fidelity preserved, focus improved (buried instructions were getting
ignored), tokens roughly halved.

- persona split: core (identity + voice — always) vs situational sections pulled
  in only when relevant. mind._persona_block: self-model/origin only on meta turns
  (generous _META_HINTS), poker guardrails only in poker context (mode/strategic/
  _POKER_HINTS). persona.core_prompt()/section(); system_prompt() kept as fallback.
- lean deliberation: the private 'what do I think' pass now uses a focused context
  (her interiority + recent turns + the message), not the full persona/profile/
  narrative/recall dump. It shapes the take, not the voice.

Measured: casual Talk turn 21,949 -> 15,974 chars (-27%); deliberation 21,949 ->
6,026 (-72%); meta turns still include the self-model. Suite 98 green, ruff clean.

Real retirement of the long prompt is still the fine-tune (mouth); this is the
cheap, high-leverage cut that also improves adherence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:48:44 +00:00
serversdown 8a3c9b2701 feat: she can suggest + switch modes (set_mode tool + mode awareness)
"She suggests, you confirm" — instead of brittle keyword→mode mapping, she's given
awareness of her modes + the ability to switch, and her judgment decides when to
offer (the model reads "should I drive to Cleveland?" vs "should I fold the river"
far better than a lexicon could).

- tools: set_mode(mode) — switches the session's mode; in _BASE (all modes).
- mind: a per-turn mode-menu note listing her modes + "offer a switch when the work
  clearly shifts; on his yes, call set_mode; don't nag."
- Sticky mode stays manual otherwise; Poker still auto-engages on session start.
- test: set_mode switches + rejects unknown. Suite 97 green, ruff clean.

Note: server-side switch takes effect next turn; the UI badge syncs on next mode
load (cosmetic lag).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:32:42 +00:00
serversdown 17ab95dc98 feat: Decide mode — a tie-breaker that settles choices instead of listing options
Brian's bottleneck is committing, not generating options, so a pros/cons dump makes
it worse. Decide mode's card: get the real decision crisp, weigh it against what HE
values + past regrets (pull running_stats/recent_sessions for poker/money calls),
MAKE the call with the one or two reasons that tip it, pressure-test it once, and
stand behind it — no "it's up to you." Read-only lookups, no live logging.

Sixth mode (Talk/Poker/Build/Explore/Study/Decide); added to UI selectors, labels,
badge-cycle. Suite 96 green, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:21:03 +00:00
serversdown 03aceec6fa feat(P3): mind/mouth split — separate voice model for the final reply (seam, default off)
The mind (chat backend/model) decides, reasons, and runs tools → a draft; the mouth
re-voices that draft in her character. Default: no mouth configured → the mind's
draft IS the reply, bit-for-bit the old behavior (and old streaming path untouched).

- config: MOUTH_BACKEND / MOUTH_MODEL. The slot for an eventual fine-tuned voice.
- chat: _mind_loop (tool/generation loop, non-stream, returns draft + tools_run),
  _voice_pass / mind.voice_messages (re-voice the draft, keep every fact/number),
  _mouth_target (active only when configured AND != mind). respond + respond_stream
  branch: mouth off = stream the mind directly (unchanged); mouth on = mind decides
  + runs tools, then the mouth streams the re-voiced reply. Falls back to the draft
  on any mouth failure (chat never breaks).
- Key payoff: the mouth needs no tool support (the mind handles tools), so it can be
  a non-tool character model (Dolphin / Claude / fine-tune). Makes the fine-tune
  easy: teach a small model to *sound* like Lyra, not to be smart.
- tests: mouth target on/off, voice_messages shape, voice_pass revoice+fallback.
  Suite 96 green, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 06:08:06 +00:00
serversdown a7af461cdb feat(P2): perceive (read the moment) + route nudges register on charged turns
The control plane gains senses — cheap, deterministic, no LLM:
- lyra/perceive.py: lexicon+signal heuristic → {sentiment, intensity, tilt, kind:
  emotional|strategic|meta|build|casual}. Good at the action-relevant signal,
  especially tilt (the mental-game core). Word-boundary matching so 'line' doesn't
  fire inside 'pipeline'.
- mind: _perceive fills ctx.moment; _route keeps the manual mode as the dominant
  frame but, on a genuinely charged moment, adds a per-turn register nudge — tilt →
  "meet him there, warm and steady, don't clip into logging"; up/energized → "match
  his energy." Neutral turns get nothing (don't over-narrate). Injected via
  build_messages(moment=...). Logged to /logs for observability.
- tests: perceive read (tilt/strategy/up/build/casual) + route nudge on/off.
  Suite 92 green, ruff clean.

Complements modes (manual frame) — perceive refines register within it, doesn't
override. Model routing (mind/mouth) is P3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 05:42:36 +00:00
serversdown 904eda3388 refactor(P1): extract the turn pipeline into lyra/mind.py (behavior-preserving)
First step of the cognition control plane (docs/COGNITION.md). The chat turn is now
an explicit society of parts over a shared TurnContext blackboard:
  perceive (stub) -> route (session mode) -> compose (tiered prompt) -> deliberate.

- lyra/mind.py (new): TurnContext + the pipeline + assemble(); moved build_messages
  and the deliberation helpers here (the assembly belongs in the control plane).
- lyra/chat.py: slimmed to "speak + persist" — calls mind.assemble(), runs the
  tool/generation loop, persists. No behavior change (same prompt, same output).
- tests: point test_time/test_chat at mind; add an assemble() structure test;
  make test_chat/test_tools hermetic (CHAT_DELIBERATE off so respond() doesn't make
  a real LLM call). Suite 86 green in ~5s, ruff clean, no import cycle.

This is the frame; perceive/route/learn get filled in next phases — each opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 05:19:39 +00:00
serversdown f1f15972ac feat: work-type modes — Talk / Poker / Build / Explore / Study
The manual version of the architecture's `route` step: Brian points her at the
TYPE of work and her register + tools shift to match. Biggest single lever on the
'meh' problem (a mode card can demand decisive/technical/generative, countering
gpt-4o's default warm-vapor).

- modes.py: Build (heads-down engineering — decisive, concrete, tradeoffs, no
  listicles), Explore (open brainstorming — generative, riffs + honest catch,
  spawn threads, don't converge early), Study (poker review away from the table —
  analytical, GTO-aware, teaching; read-only lookups + analyze_spot). Cash relabeled
  Poker (key kept for compat).
- UI: mode selectors (desktop + mobile) get all five; badge taps now cycle modes.
- design: docs/COGNITION.md (the society-of-parts control-plane sketch).
- tests: presence + tool-gating for the new modes. Suite 85, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 03:43:37 +00:00
15 changed files with 1108 additions and 353 deletions
+2
View File
@@ -49,3 +49,5 @@ PING_AUTO_SALIENCE=0.8 # a thought this salient auto-pings even without an exp
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=
+141
View File
@@ -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 ~23 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 23 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).
```
+107 -290
View File
@@ -1,280 +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)
# 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 _deliberate(messages: list[Message], 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(messages + [{"role": "system", "content": _DELIBERATE_SYS}],
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, messages: list[Message]) -> 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(messages, backend, model)
if not thinking:
return None
logbus.log("info", "deliberated", session=session_id, chars=len(thinking), detail=thinking)
return _answer_from(thinking)
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)
# Live thought loop: think privately about what to actually say before answering.
note = _deliberation_note(session_id, user_msg, backend, model, messages)
if note:
messages.append(note)
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
# 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 _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(
@@ -283,53 +66,70 @@ 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)
# Live thought loop: think privately about what to actually say before answering.
note = _deliberation_note(session_id, user_msg, backend, model, messages)
if note:
messages.append(note)
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}
mouth = _mouth_target(cfg, backend, model)
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
@@ -346,20 +146,37 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
tool_calls = payload
if not tool_calls:
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"])
yield ("tool", tc["name"])
reply = "".join(parts)
if not reply:
reply = "(I got tangled using my tools there — say that again?)"
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))
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)
+7
View File
@@ -38,6 +38,11 @@ class Config:
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
@@ -81,6 +86,8 @@ def load() -> Config:
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")),
)
+384
View File
@@ -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
+83 -6
View File
@@ -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
@@ -38,7 +42,7 @@ _LOOKUPS = ("player_profile", "get_villain_file", "running_stats", "recent_sessi
# Always-available core tools (her own agency: journaling/notes/starting a thought
# thread, and capturing Brian's reaction when she raises one of her thoughts in chat).
_BASE = ("journal_write", "note", "think_about", "thought_response")
_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
+97
View File
@@ -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
View File
@@ -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 ""
+20
View File
@@ -52,6 +52,20 @@ 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"))
@@ -452,6 +466,12 @@ _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 "
+20 -7
View File
@@ -26,7 +26,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 +66,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">
@@ -605,8 +613,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) {
@@ -730,8 +741,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).
+86 -16
View File
@@ -1,4 +1,4 @@
"""Live chat: the deliberation pass (think privately before answering)."""
"""The mind pipeline: the deliberation pass (think privately before answering)."""
from __future__ import annotations
import importlib
@@ -13,31 +13,32 @@ def lyra(tmp_path, monkeypatch):
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.chat as chat
importlib.reload(chat)
return memory, chat
import lyra.mind as mind
importlib.reload(mind)
return memory, mind
def test_should_deliberate_skips_trivial(lyra):
_, chat = lyra
assert chat._should_deliberate("How would we actually start building this?")
assert chat._should_deliberate("I disagree, that seems risky")
_, 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 chat._should_deliberate(trivial)
assert not chat._should_deliberate("ok!") # punctuation stripped
assert not chat._should_deliberate("hey") # too short
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):
_, chat = lyra
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."
monkeypatch.setattr(chat.llm, "complete", fake_complete)
note = chat._deliberation_note("s1", "How would we start on this?", "cloud", None, [])
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
@@ -45,9 +46,78 @@ def test_deliberation_note_runs_and_appends(lyra, monkeypatch):
def test_deliberation_skipped_when_disabled(lyra, monkeypatch):
_, chat = lyra
_, mind = lyra
monkeypatch.setenv("CHAT_DELIBERATE", "false")
called = []
monkeypatch.setattr(chat.llm, "complete", lambda *a, **k: called.append(1) or "x")
assert chat._deliberation_note("s1", "a real substantive question here", "cloud", None, []) is None
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"
+30
View File
@@ -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
+56
View File
@@ -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")
+4 -4
View File
@@ -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
+1
View File
@@ -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