"""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 _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) 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.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 β€” 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}) # 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 _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) # --- 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, ctx.messages) if note: ctx.messages.append(note) return ctx PIPELINE = (_perceive, _route, _compose, _deliberate_part) 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