"""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())