From 59d684b12bfb49375e0efda4aeec21d1631de8b6 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 06:40:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Lyra's=20journal=20=E2=80=94=20permanen?= =?UTF-8?q?t=20thought=20record=20+=20a=20knowing=20journal=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lyra/dream.py | 2 +- lyra/memory.py | 40 ++++++++++ lyra/self_state.py | 24 +++++- lyra/web/server.py | 9 +++ lyra/web/static/journal.html | 138 +++++++++++++++++++++++++++++++++++ lyra/web/static/self.html | 1 + tests/test_reflect.py | 5 ++ 7 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 lyra/web/static/journal.html diff --git a/lyra/dream.py b/lyra/dream.py index 24a21f6..609d8bd 100644 --- a/lyra/dream.py +++ b/lyra/dream.py @@ -100,7 +100,7 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict: # --- curiosity: reflect and evolve the self --- if force or drives["curiosity"] >= THRESHOLD: - self_state.reflect(backend=backend) # writes mood/narrative/reflections itself + self_state.reflect(backend=backend, source="dream") # writes state + journal itself actions.append("reflected") drives["curiosity"] = CURIOSITY_FLOOR diff --git a/lyra/memory.py b/lyra/memory.py index 31c4848..30c3b68 100644 --- a/lyra/memory.py +++ b/lyra/memory.py @@ -79,6 +79,19 @@ CREATE TABLE IF NOT EXISTS self_state ( data TEXT NOT NULL, updated_at TEXT NOT NULL ); + +-- Lyra's journal: append-only, permanent record of her thoughts. The self_state +-- reflections/metacognition lists are a short rolling window for context; this +-- keeps everything so nothing is lost when those roll over. kind is +-- 'reflection' | 'metacognition' | 'journal' (a deliberate note to herself). +CREATE TABLE IF NOT EXISTS journal ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + kind TEXT NOT NULL, + content TEXT NOT NULL, + source TEXT +); +CREATE INDEX IF NOT EXISTS idx_journal_created ON journal(created_at); """ _conn: sqlite3.Connection | None = None @@ -517,6 +530,33 @@ def get_self_state(state_id: str = "lyra") -> dict | None: return json.loads(r["data"]) if r else None +def add_journal_entry(kind: str, content: str, source: str | None = None) -> int: + """Append a permanent journal entry (never truncated). Returns row id.""" + now = datetime.now(timezone.utc).isoformat() + conn = _connection() + with conn: + cur = conn.execute( + "INSERT INTO journal (created_at, kind, content, source) VALUES (?, ?, ?, ?)", + (now, kind, content, source), + ) + return int(cur.lastrowid) + + +def list_journal(limit: int | None = None, kinds: tuple[str, ...] | None = None) -> list[dict]: + """Journal entries, newest first. Optionally filter by kind.""" + conn = _connection() + sql = "SELECT id, created_at, kind, content, source FROM journal" + params: list = [] + if kinds: + sql += " WHERE kind IN (%s)" % ",".join("?" * len(kinds)) + params += list(kinds) + sql += " ORDER BY id DESC" + if limit is not None: + sql += " LIMIT ?" + params.append(limit) + return [dict(r) for r in conn.execute(sql, params).fetchall()] + + def self_state_updated_at(state_id: str = "lyra") -> str | None: """ISO timestamp her self-state was last written (None if never).""" conn = _connection() diff --git a/lyra/self_state.py b/lyra/self_state.py index 458350d..d8088b6 100644 --- a/lyra/self_state.py +++ b/lyra/self_state.py @@ -84,7 +84,12 @@ honest version to be smaller, more uncertain, more boring, to disagree with Bria little changed. Honesty beats a tidy story. Don't manufacture drama either; if the draft was \ fair, keep it. -Respond with ONLY a JSON object — the same shape as the draft, plus a "self_critique" field: +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": "", "valence": <0.0-1.0>, @@ -94,7 +99,8 @@ Respond with ONLY a JSON object — the same shape as the draft, plus a "self_cr "self_narrative": "", "relationship": "", "new_reflections": [""], - "self_critique": "" + "self_critique": "", + "journal": "" }""" @@ -164,13 +170,16 @@ def _fmt_reflection(label: str, d: dict | None) -> str: return "\n".join(lines) -def reflect(backend: Backend | None = None, session_id: str | None = None) -> dict: +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. + 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() @@ -223,11 +232,18 @@ def reflect(backend: Backend | None = None, session_id: str | None = None) -> di 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) diff --git a/lyra/web/server.py b/lyra/web/server.py index e39a837..8df6d16 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -132,6 +132,15 @@ def create_app() -> FastAPI: state = await asyncio.to_thread(self_state.reflect) return {"ok": True, "mood": state.get("mood")} + @app.get("/journal") + async def journal_page() -> FileResponse: + """Lyra's journal — the permanent, append-only record of her thoughts.""" + return FileResponse(str(_STATIC / "journal.html")) + + @app.get("/journal/data") + async def journal_data(limit: int = 300) -> dict: + return {"entries": memory.list_journal(limit=limit)} + @app.get("/stream/logs") async def stream_logs(request: Request) -> StreamingResponse: """Live activity feed: replay the recent buffer, then stream new events.""" diff --git a/lyra/web/static/journal.html b/lyra/web/static/journal.html new file mode 100644 index 0000000..0fb8995 --- /dev/null +++ b/lyra/web/static/journal.html @@ -0,0 +1,138 @@ + + + + + + + Lyra — Journal + + + +
+
+

📔 Lyra · Journal

+ ← Mind + Chat + +
+
+ all + journal + reflections + metacognition +
+
+

Opening her journal…

+ + + + diff --git a/lyra/web/static/self.html b/lyra/web/static/self.html index 85befdb..5835ddd 100644 --- a/lyra/web/static/self.html +++ b/lyra/web/static/self.html @@ -70,6 +70,7 @@

🧠 Lyra · Mind

← Chat + 📔 Journal logs ↗ diff --git a/tests/test_reflect.py b/tests/test_reflect.py index 62cb0cb..9146ea3 100644 --- a/tests/test_reflect.py +++ b/tests/test_reflect.py @@ -58,6 +58,11 @@ def test_reflect_revises_and_records_critique(lyra): # the self-critique was recorded as metacognition assert any("flattery" in m.lower() for m in state["metacognition"]) + # everything she produced was also appended to the permanent journal + import lyra.memory as memory + kinds = {e["kind"] for e in memory.list_journal()} + assert "reflection" in kinds and "metacognition" in kinds + def test_reflect_falls_back_to_draft_if_examine_unparseable(lyra, monkeypatch): from lyra import llm, self_state