Files
serversdown 3573ac8d79 feat: dream-cycle time budget + default per-call timeout (guard C)
Belt-and-suspenders so no dream pass can run unchecked for hours:
- llm.complete() now always bounds the OpenAI/mi50 request: default 300s +
  max_retries=0 instead of the SDK's 600s x2 (~30 min). One change bounds every
  consolidation/introspection call (profile/era/narrative/reflect/think), not
  just summaries. Live chat (chat_call*) is a separate path, unaffected.
- dream_cycle() enforces a 20-min wall-clock budget, checked between stages;
  once past it, remaining stages are skipped, it logs 'stopped early (over
  budget)', and notify.push() pings Brian. Paired with the host watchdog (A) as
  an independent fallback.

Tests: default timeout/max_retries threaded into complete(); an over-budget pass
skips later stages + pings. 178 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-04 19:05:32 +00:00

215 lines
9.5 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):
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
# 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())