"""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, logbus, memory, narrative, profile, self_state, summary 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 # 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)) actions: list[str] = [] # --- continuity: compact raw sessions into gists --- if force or drives["continuity"] >= THRESHOLD: 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: 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 # --- curiosity: reflect and evolve the self --- if force or drives["curiosity"] >= THRESHOLD: self_state.reflect(backend=backend) # writes mood/narrative/reflections itself actions.append("reflected") drives["curiosity"] = CURIOSITY_FLOOR 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())