Files
project-lyra/lyra/dream.py
T
serversdown 173fd18688 feat: cloud-first consolidation routing + graceful backend fallback
Nail down which backend each LLM path uses, and make the dream cycle resilient to
a backend being down (the MI50 outage left profile/era/narrative — pinned to mi50
with no fallback — aborting every dream cycle).

- Consolidation (summaries + profile/era/narrative) -> cloud via SUMMARY_BACKEND=
  cloud (.env, not committed). Matches the documented lesson that the MI50 is too
  slow/hot for bulk consolidation; nothing background touches the card now.
- llm.complete_with_fallback(): try the primary backend, fall back to cloud on
  error (re-raise if already cloud / no key). Wired into reflect + think so the
  introspection voice (3090/dolphin) survives the gaming PC being powered off.
- dream coherence stage is now fault-isolated: a rebuild failure logs + continues
  instead of sinking the whole pass (reflection still runs).
- .env: removed stale INTROSPECTION_BACKEND=mi50 (live routing is the web-switchable
  introspection_mode DB setting = dolphin/3090; the var only fed a dead fallback).

Verified: forced cycle runs consolidation on cloud, introspection on the 3090,
completes with zero MI50 calls. 206 pass, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-07 06:53:55 +00:00

222 lines
9.9 KiB
Python

"""The dream cycle: Lyra's unattended inner loop.
Chat updates her in the moment; the dream cycle is what keeps her *going* when
no one's talking to her. On each pass she senses her own backlog and novelty,
lets four drives build from it, and acts on whichever have built past threshold:
continuity -> summarize sessions with new turns (don't lose the thread)
coherence -> rebuild profile / eras / narrative (keep my understanding current)
curiosity -> reflect and evolve the self-state (think, notice, change)
The drives are derived from real signals (unsummarized backlog, gists not yet
folded into the profile, new activity since last cycle), so they genuinely build
up and relieve as work gets done — and the chain is causal: consolidating
sessions creates new gists, which raises coherence, which triggers integration.
stability is the readout of how caught-up she ended up.
Run one pass (`lyra-dream`), force every stage (`lyra-dream --force`), or run it
as a long-lived loop (`lyra-dream --loop 1800`). The loop is the "unattended"
mode — point cron or a systemd service at it (or just `--loop`) and her inner
life keeps ticking between conversations.
"""
from __future__ import annotations
import argparse
import time
from datetime import datetime, timezone
from lyra import (
config, era, feeds, logbus, memory, narrative, notify, poker, profile, self_state,
summary, thoughts,
)
from lyra.llm import Backend
from lyra.summary import SUMMARIZE_AFTER
# A drive at/above this has built up enough to act on.
THRESHOLD = 0.6
# Wall-clock ceiling for a single pass. Every consolidation/introspection call is
# individually bounded (llm.complete's default timeout), but this caps the whole
# pass: once exceeded, remaining stages are skipped and Brian is pinged — so a slow
# or wedged MI50 can never grind for hours unattended. The host watchdog (A) is the
# independent fallback if this ever fails to fire.
DREAM_CYCLE_BUDGET_SEC = 20 * 60
def _over_budget(deadline: float) -> bool:
return time.monotonic() > deadline
# How much backlog saturates each pressure (the drive reaches ~1.0 at this level).
CONTINUITY_FULL = 4 # ripe (summary-needing) sessions
COHERENCE_FULL = 10 # gists not yet folded into the profile
# Curiosity is an accumulator, not a backlog: it rises with time and novelty and
# is relieved by reflecting.
CURIOSITY_IDLE_GAIN = 0.15 # per cycle, just from time passing
CURIOSITY_ACTIVITY_GAIN = 0.30 # bonus when there's been new conversation
CURIOSITY_FLOOR = 0.10 # where it resets to after a reflection
def _clamp(x: float) -> float:
return max(0.0, min(1.0, x))
def _round(drives: dict) -> dict:
return {k: round(float(v), 2) for k, v in drives.items()}
def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
"""Run one pass: sense, let drives build, act on those past threshold."""
backend = backend or config.load().summary_backend
state = self_state.load()
drives = dict(self_state.DEFAULT_DRIVES) | (state.get("drives") or {})
book = state.get("dream") or {}
# --- sense ---
backlog = memory.backlog_stats(ripe_threshold=SUMMARIZE_AFTER)
summary_count = len(memory.list_summaries())
profile_lag = max(0, summary_count - memory.profile_sessions_covered())
last_xid = int(book.get("last_exchange_id", 0))
new_activity = backlog["max_exchange_id"] > last_xid
# --- let drives build from what we sensed ---
drives["continuity"] = _clamp(backlog["ripe"] / CONTINUITY_FULL)
drives["coherence"] = _clamp(profile_lag / COHERENCE_FULL)
drives["curiosity"] = _clamp(
drives.get("curiosity", CURIOSITY_FLOOR)
+ CURIOSITY_IDLE_GAIN
+ (CURIOSITY_ACTIVITY_GAIN if new_activity else 0.0)
)
drives["stability"] = _clamp(1.0 - (drives["continuity"] + drives["coherence"]) / 2)
logbus.log("info", "dream cycle sensing", ripe=backlog["ripe"], dirty=backlog["dirty"],
profile_lag=profile_lag, new_activity=new_activity, drives=_round(drives))
# Thought-loop housekeeping (no LLM): rest stale threads so the open-thread cap
# never jams and the feed stays current. Cheap; run every pass.
thoughts.decay()
# Pull external feeds on the cycle cadence (~30 min) so she has fresh items from
# the world to react to. Network-only; failures degrade to no new items.
try:
feeds.refresh()
except Exception as exc:
logbus.log("error", "feed refresh failed", error=str(exc)[:160])
# Her daily "what I've been turning over" digest (sends at most once/local-day).
try:
thoughts.maybe_daily_digest()
except Exception as exc:
logbus.log("error", "daily digest failed", error=str(exc)[:160])
actions: list[str] = []
# Cap the whole pass: skip any stage we reach after the deadline (checked
# between stages; each call is already individually bounded).
deadline = time.monotonic() + DREAM_CYCLE_BUDGET_SEC
# --- continuity: compact raw sessions into gists ---
if (force or drives["continuity"] >= THRESHOLD) and not _over_budget(deadline):
report = summary.summarize_all(backend=backend)
actions.append(f"consolidated {report['summarized']} sessions")
drives["continuity"] = 0.0
# fresh gists make the profile stale -> coherence rises now, may fire below
summary_count = len(memory.list_summaries())
profile_lag = max(0, summary_count - memory.profile_sessions_covered())
drives["coherence"] = _clamp(profile_lag / COHERENCE_FULL)
# --- coherence: fold gists up into profile / eras / narrative ---
if (force or drives["coherence"] >= THRESHOLD) and not _over_budget(deadline):
# A backend hiccup here must not sink the whole pass (reflection still
# deserves to run); log it and move on, leaving coherence unrelieved so a
# later cycle retries.
try:
profile.rebuild_profile(backend=backend)
era.rebuild_eras(backend=backend)
narrative.rebuild_narrative(backend=backend)
actions.append("integrated knowledge (profile/eras/narrative)")
drives["coherence"] = 0.0
except Exception as exc:
logbus.log("error", "coherence stage failed", error=str(exc)[:200])
actions.append("coherence stage failed")
# Off-hot-path villain identity housekeeping: propose likely same-person
# merges for Brian to confirm on the Players page. Never sinks the cycle.
try:
filed = poker.scan_merge_candidates()
if filed:
actions.append(f"flagged {filed} possible villain merge(s)")
except Exception as exc:
logbus.log("error", "villain merge scan failed", error=str(exc)[:200])
# --- curiosity: reflect and evolve the self, then advance the thought loop ---
if (force or drives["curiosity"] >= THRESHOLD) and not _over_budget(deadline):
# reflect()/think() self-resolve to the *introspection* backend (her voice),
# which can differ from the consolidation backend above — don't pass `backend`.
self_state.reflect(source="dream") # writes state + journal itself
actions.append("reflected")
# Thinking, continued: advance one threaded train of thought. reflect()
# just refreshed her self-state, so the thought is grounded in it. A bad
# think pass shouldn't sink the cycle.
try:
rep = thoughts.think(source="dream")
actions.append(f"thought ({rep['mode']})" if rep else "thought (no parse)")
except Exception as exc:
logbus.log("error", "thought loop failed", error=str(exc)[:200])
drives["curiosity"] = CURIOSITY_FLOOR
if _over_budget(deadline):
logbus.log("error", "dream cycle over budget — stopped early",
budget_min=DREAM_CYCLE_BUDGET_SEC // 60, done=actions)
actions.append("stopped early (over budget)")
notify.push(
"Lyra — dream cycle over budget",
f"A dream pass ran past {DREAM_CYCLE_BUDGET_SEC // 60} min and stopped early. "
"The MI50 backend may be slow or wedged — worth a look.",
tags="warning",
)
if not actions:
actions.append("rested (nothing past threshold)")
# final stability readout — how caught-up we ended up this pass
drives["stability"] = _clamp(1.0 - (drives["continuity"] + drives["coherence"]) / 2)
# reflect() may have rewritten the row — reload, then attach drives + bookkeeping
state = self_state.load()
state["drives"] = drives
state["dream"] = {
"last_exchange_id": backlog["max_exchange_id"],
"cycle_count": int(book.get("cycle_count", 0)) + 1,
"last_cycle_at": datetime.now(timezone.utc).isoformat(),
"last_actions": actions,
}
memory.set_self_state(state)
logbus.log("info", "dream cycle complete", cycle=state["dream"]["cycle_count"],
actions=actions, drives=_round(drives))
return state
def main() -> int:
p = argparse.ArgumentParser(description="Run Lyra's dream cycle.")
p.add_argument("--force", action="store_true",
help="run every stage regardless of drive levels")
p.add_argument("--loop", type=int, metavar="SECONDS",
help="run continuously, sleeping SECONDS between cycles")
args = p.parse_args()
if args.loop:
logbus.log("system", "dream loop starting", interval=args.loop, force=args.force)
while True:
try:
dream_cycle(force=args.force)
except Exception as exc: # one bad cycle shouldn't kill the loop
logbus.log("error", "dream cycle failed", error=str(exc)[:200])
time.sleep(args.loop)
state = dream_cycle(force=args.force)
print(f"drives: {_round(state.get('drives') or {})}")
print(f"dream: {state.get('dream')}")
return 0
if __name__ == "__main__":
raise SystemExit(main())