59d684b12b
Her reflections/metacognition were capped rolling windows (6/5), so older thoughts were lost for good. Now everything she produces is also appended to a permanent, append-only journal; the capped lists stay as her working-memory window for context. - memory: journal table + add_journal_entry/list_journal - reflect(): persists every committed reflection + critique to the journal, and the examine step gains a "journal" field — a deliberate, first-person note she writes for herself (her knowing journaling), tagged by source (dream/manual) - web: /journal diary view (kind filters, grouped by day) + /journal/data; linked from /self - tests assert reflections + metacognition land in the journal Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
6.4 KiB
Python
154 lines
6.4 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
|
|
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, source="dream") # writes state + journal 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())
|