Files
project-lyra/lyra/dream.py
T
serversdown 951788f9ec feat: thought loop closer to her vision — wander grist, continuity, seeding, lifecycle
Four additions so the loop is "more what she wanted" (think to herself, unprompted):

- Wander grist (#1): think() new-thread mode now draws the same varied seeds
  reflect() uses (self_state.wander_seed: own curiosity/existence/disagreement or
  a resurfaced memory) + an anti-restate block of her recent thoughts + a list of
  existing open-thread titles to avoid. Directly counters the RLHF "supportive
  presence serving Brian" drift visible in her first thoughts.
- Continuity: thoughts.context_note() injects her active threads into every chat
  turn, so she's aware of her own ongoing mind and can reference it anytime — not
  only when a thought crosses the surface bar.
- Bidirectional: new think_about tool (in _BASE, all modes) lets her spawn a
  thread from conversation to develop on her own later. Conversations seed her
  solo thinking.
- Lifecycle: thoughts.decay() rests stale active threads (>48h) and decays their
  salience, sparing pending-response ones; runs each dream cycle (no LLM). Frees
  the open-thread cap and keeps the feed current.

Also: thoughts feed no longer wipes a reply you're mid-composing (skip poll
re-render while a textarea is focused/non-empty; force-refresh after send).

61 tests passing, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:28:15 +00:00

166 lines
7.1 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, logbus, memory, narrative, 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
# 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()
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, then advance the thought loop ---
if force or drives["curiosity"] >= THRESHOLD:
self_state.reflect(backend=backend, 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(backend=backend, 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 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())