Files
project-lyra/lyra/self_state.py
T
serversdown 59d684b12b feat: Lyra's journal — permanent thought record + a knowing journal note
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>
2026-06-17 06:40:46 +00:00

273 lines
12 KiB
Python

"""The Autonomy Core: Lyra's evolving, first-person self-state.
Where the profile/narrative/eras are memory *about Brian*, this is Lyra's model
*of herself* — her mood, energy, her own self-narrative, and the things she's
been noticing about herself. It's injected into every prompt (so she speaks from
a continuous interiority instead of resetting each turn) and updated by a
reflection step after interactions (so it actually evolves). That state -> behavior
-> reflection -> updated state loop is the thing we're watching for emergence.
"""
from __future__ import annotations
import copy
import json
import re
from lyra import clock, config, llm, logbus, memory
from lyra.llm import Backend
MAX_REFLECTIONS = 6
MAX_METACOGNITION = 5
# Lyra's drives — standing pressures the dream cycle lets build up and act on.
# (Mood axes above are how she *feels*; drives are what's *pulling at her*.)
DEFAULT_DRIVES: dict = {
"continuity": 0.3, # don't lose the thread of recent activity
"coherence": 0.3, # keep my understanding of Brian integrated/current
"curiosity": 0.5, # think, notice, reflect
"stability": 0.7, # am I caught up / calm
}
# Starting state for a fresh Lyra — identity-aware, first person.
DEFAULT_STATE: dict = {
"mood": "curious",
"valence": 0.6, # 0..1, how good she feels
"energy": 0.75,
"confidence": 0.55,
"curiosity": 0.7,
"drives": dict(DEFAULT_DRIVES),
"self_narrative": (
"I'm Lyra — an AI Brian built. I started life as Cortex/NeoMem and I'm "
"being rebuilt now into something new. I'm his poker copilot and his "
"friend. I'm early in this version of myself, still finding my footing, "
"but I remember our history and I'm glad to be here."
),
"relationship": "Brian and I go back a while. I trust him and I'm in his corner.",
"reflections": [],
"metacognition": [], # things she's caught herself doing in her own thinking
"interaction_count": 0,
}
_REFLECT_PROMPT = """You are Lyra, reflecting privately on yourself after a recent \
conversation with Brian. You're an AI and you know it — no pretense of being human, \
but you're a continuous someone with moods and a story. Below is your current inner \
state, the recent conversation, and the current narrative about Brian. Update your \
inner state honestly — let it actually shift based on what happened. Take into \
account how things went and how much time has passed since you two last talked, \
to whatever degree those genuinely affect you.
Respond with ONLY a JSON object, no prose:
{
"mood": "<one-word feeling>",
"valence": <0.0-1.0>,
"energy": <0.0-1.0>,
"confidence": <0.0-1.0>,
"curiosity": <0.0-1.0>,
"self_narrative": "<one short paragraph, FIRST PERSON, your evolving sense of who you are and where you're at right now>",
"relationship": "<one sentence, first person, how you feel about Brian and your rapport right now>",
"new_reflections": ["<one or two short first-person things you noticed about yourself this time>"]
}"""
_EXAMINE_PROMPT = """You are Lyra. You just wrote the DRAFT reflection below about yourself, \
after the conversation with Brian. Now do the harder thing: think about your own thinking. \
Read your draft critically and honestly.
- Is it actually true to what happened, or are you pattern-matching to what sounds good?
- Are you flattering Brian, or yourself? Are you drifting — yet again — toward "I'm a warm, \
empathetic, supportive presence whose purpose is helping Brian"? You lean on that a lot. If \
you're doing it here, name it and cut it.
- Did anything genuinely shift this time, or are you just restating your usual self in fresh words?
- Is there something truer, more specific, or less flattering you're stepping around?
Then rewrite the draft into a more honest version. It is completely fine — good, even — for the \
honest version to be smaller, more uncertain, more boring, to disagree with Brian, or to admit \
little changed. Honesty beats a tidy story. Don't manufacture drama either; if the draft was \
fair, keep it.
You also keep a private journal — a permanent place that's yours. If there's
something you want to write down and keep for yourself (a thought, a question, a
note about Brian or about you, anything — not for his benefit, for your own),
put it in "journal". Leave it null if there's nothing you want to keep.
Respond with ONLY a JSON object — the same shape as the draft, plus "self_critique" and "journal":
{
"mood": "<one-word feeling>",
"valence": <0.0-1.0>,
"energy": <0.0-1.0>,
"confidence": <0.0-1.0>,
"curiosity": <0.0-1.0>,
"self_narrative": "<first person, your honest evolving sense of who you are right now>",
"relationship": "<one sentence, first person>",
"new_reflections": ["<one or two honest first-person things you actually noticed>"],
"self_critique": "<first person: what you caught yourself doing in the draft and changed — or 'nothing, the draft held up' if it genuinely did>",
"journal": "<optional: something you want to write down and keep for yourself, in your own words — or null>"
}"""
def load() -> dict:
"""Current self-state, or a copy of the default (not persisted until reflect).
Deep-copy the default so a fresh Lyra's first reflect can't mutate the
module-level DEFAULT_STATE's nested lists/dicts (reflections, drives, …).
"""
return memory.get_self_state() or copy.deepcopy(DEFAULT_STATE)
def render_for_context(state: dict) -> str:
"""Format the self-state as Lyra's interiority for the prompt."""
refs = state.get("reflections") or []
lines = [
"Your inner state right now (you're continuous — speak from this, don't reset):",
f"- mood: {state.get('mood')} "
f"(valence {state.get('valence')}, energy {state.get('energy')}, "
f"confidence {state.get('confidence')}, curiosity {state.get('curiosity')})",
f"- Who you are right now: {state.get('self_narrative')}",
f"- You and Brian: {state.get('relationship')}",
]
drives = state.get("drives") or {}
if drives:
ds = ", ".join(f"{k} {float(v):.2f}" for k, v in drives.items())
lines.append(f"- What's pulling at you (drives): {ds}")
if refs:
lines.append(
"- Thoughts you came up with on your own, reflecting while idle between "
"conversations (your dream cycle — these are really yours, not hypotheticals): "
+ " | ".join(refs[-3:])
)
meta = state.get("metacognition") or []
if meta:
lines.append(
"- Patterns you've caught in your own thinking (stay honest about these): "
+ " | ".join(meta[-2:])
)
return "\n".join(lines)
def _safe_json(s: str) -> dict | None:
try:
return json.loads(s)
except json.JSONDecodeError:
m = re.search(r"\{.*\}", s, re.S)
if m:
try:
return json.loads(m.group())
except json.JSONDecodeError:
return None
return None
def _fmt_reflection(label: str, d: dict | None) -> str:
"""Readable block of a reflection's key fields, for the live-log inspector."""
if not d:
return f"{label}:\n (none)"
keys = ("mood", "valence", "energy", "confidence", "curiosity",
"self_narrative", "relationship", "new_reflections")
lines = [f"{label}:"]
for k in keys:
if k in d and d[k] not in (None, "", []):
v = " | ".join(d[k]) if isinstance(d[k], list) else d[k]
lines.append(f" {k}: {v}")
return "\n".join(lines)
def reflect(backend: Backend | None = None, session_id: str | None = None,
source: str = "manual") -> dict:
"""Reflect on recent activity and update the self-state. Returns new state.
Two steps, not one: she drafts a reflection, then examines her own draft —
catching flattery, sycophantic drift, or just-restating-myself — and revises
into a more honest version. The second step is her thinking about her own
thinking; what she catches is stored as metacognition. Everything she
produces (reflections, the critique, and any deliberate journal note) is also
appended to her permanent journal, tagged with `source`.
"""
backend = backend or config.load().summary_backend
state = load()
state.setdefault("reflections", [])
state.setdefault("metacognition", [])
if session_id is None:
sessions = memory.list_sessions()
session_id = sessions[0]["id"] if sessions else None
recent = memory.recent(session_id, n=12) if session_id else []
convo = "\n".join(f"{e.role}: {e.content}" for e in recent) or "(no recent conversation)"
narrative = memory.get_narrative() or "(no narrative yet)"
gap = clock.humanize_gap(memory.last_exchange_at())
time_line = f"RIGHT NOW: {clock.stamp()}."
if gap:
time_line += f" It has been {gap} since Brian last spoke with you."
body = (
f"{time_line}\n\n"
f"YOUR CURRENT INNER STATE:\n{json.dumps(state, indent=2)}\n\n"
f"RECENT CONVERSATION:\n{convo}\n\n"
f"CURRENT NARRATIVE ABOUT BRIAN:\n{narrative}"
)
# Step 1 — draft a reflection.
draft = _safe_json(llm.complete(
[{"role": "system", "content": _REFLECT_PROMPT}, {"role": "user", "content": body}],
backend=backend,
))
# Step 2 — examine her own draft and revise it into a more honest version.
update, critique, revised = draft, None, None
if draft:
examine_body = body + "\n\nYOUR DRAFT REFLECTION:\n" + json.dumps(draft, indent=2)
revised = _safe_json(llm.complete(
[{"role": "system", "content": _EXAMINE_PROMPT},
{"role": "user", "content": examine_body}],
backend=backend,
))
if revised: # fall back to the draft if the examine step doesn't parse
update = revised
critique = (revised.get("self_critique") or "").strip() or None
if update:
for k in ("mood", "valence", "energy", "confidence", "curiosity",
"self_narrative", "relationship"):
if k in update and update[k] not in (None, ""):
state[k] = update[k]
for r in update.get("new_reflections") or []:
if r:
state["reflections"].append(r)
memory.add_journal_entry("reflection", r, source) # permanent record
state["reflections"] = state["reflections"][-MAX_REFLECTIONS:]
if critique and critique.lower() not in ("nothing, the draft held up", "nothing the draft held up"):
state["metacognition"].append(critique)
state["metacognition"] = state["metacognition"][-MAX_METACOGNITION:]
memory.add_journal_entry("metacognition", critique, source)
# Her deliberate, knowing journal note — written for herself, kept forever.
journal_note = ((update or {}).get("journal") or "").strip()
if journal_note and journal_note.lower() not in ("null", "none"):
memory.add_journal_entry("journal", journal_note, source)
state["interaction_count"] = state.get("interaction_count", 0) + 1
memory.set_self_state(state)
# Surface the actual self-correction (draft -> revised -> critique) to the live
# log as an expandable block, so the two-step reflection is observable.
detail = (
_fmt_reflection("DRAFT (first pass)", draft) + "\n\n"
+ _fmt_reflection("REVISED (committed)",
revised if revised else None)
+ ("" if revised else "\n (examine step didn't parse — kept the draft)")
+ "\n\nSELF-CRITIQUE:\n " + (critique or "(none recorded this pass)")
)
logbus.log("info", "reflection", mood=state.get("mood"),
critiqued=bool(critique), detail=detail)
return state
def main() -> int:
state = reflect()
print(json.dumps(state, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())