From 5176c706b69364f0e3f51c496a7f22e9cdceecab Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 07:05:15 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20thought=20loop=20=E2=80=94=20Lyra's=20t?= =?UTF-8?q?hreaded,=20surfaceable=20train=20of=20thought?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built from her own 6-19 idea: a continuing train of thought she keeps across days, organized into threads she returns to, that she can bring TO Brian and that his feedback advances or closes. Where the dream cycle's reflect() gives isolated, overwriting reflections, the thought loop adds continuity (threads), surfacing (#6 β€” she leads with a thought when Brian returns after a gap), and a feedback loop (his reply folds in next pass). - lyra/thoughts.py: thought_threads + thoughts tables; think() with new/continue/respond modes; salience-gated maybe_surface(); record_response() feedback; lazy-schema _c() mirroring poker. - dream.py: curiosity stage advances the loop after reflecting (error-isolated). - chat.py: build_messages surfaces the top thread after a >=90min gap, once. - web: /thoughts feed (page + data + respond + status routes), thoughts.html, nav πŸ’­ entry. lyra-think entry point. Every thought also lands in her journal. - clock.gap_seconds(); tests/test_thoughts.py (8 tests). Full suite 58 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/chat.py | 10 +- lyra/clock.py | 9 + lyra/dream.py | 12 +- lyra/thoughts.py | 429 ++++++++++++++++++++++++++++++++++ lyra/web/server.py | 33 ++- lyra/web/static/nav.js | 1 + lyra/web/static/thoughts.html | 210 +++++++++++++++++ pyproject.toml | 1 + tests/test_thoughts.py | 132 +++++++++++ 9 files changed, 833 insertions(+), 4 deletions(-) create mode 100644 lyra/thoughts.py create mode 100644 lyra/web/static/thoughts.html create mode 100644 tests/test_thoughts.py diff --git a/lyra/chat.py b/lyra/chat.py index 7917b58..6258b42 100644 --- a/lyra/chat.py +++ b/lyra/chat.py @@ -10,7 +10,7 @@ After replying, the session is compacted if enough new turns have accumulated. """ from __future__ import annotations -from lyra import clock, config, llm, logbus, memory, modes, persona, self_state, summary +from lyra import clock, config, llm, logbus, memory, modes, persona, self_state, summary, thoughts from lyra import tools as toolkit from lyra.llm import Backend, Message @@ -105,6 +105,14 @@ def build_messages(session_id: str, user_msg: str, # When she is: current time + the gap since Brian last spoke (she has no clock). messages.append(_now_note()) + # Thought loop: if Brian's been away and one of her own threads has built past + # the surface bar, let her lead with it (once). This is her #6 β€” bringing what + # she thought about while alone *to* him. Runs before the world-model tiers so + # it's framed as her interiority, like the self-state. + surfaced = thoughts.maybe_surface(memory.last_exchange_at()) + if surfaced: + messages.append({"role": "system", "content": surfaced}) + # Semantic memory: the distilled profile (who Brian is) β€” answers identity # questions that raw recall can't. Always in context when it exists. profile = memory.get_profile() diff --git a/lyra/clock.py b/lyra/clock.py index 9ece8ac..4da25c5 100644 --- a/lyra/clock.py +++ b/lyra/clock.py @@ -25,6 +25,15 @@ def stamp(dt: datetime | None = None) -> str: return (dt or now()).strftime("%A, %d %b %Y, %H:%M UTC") +def gap_seconds(since_iso: str | None, ref: datetime | None = None) -> float | None: + """Seconds elapsed since `since_iso` (None -> None). The numeric counterpart to + humanize_gap, for code that needs to threshold on elapsed time.""" + if not since_iso: + return None + ref = ref or now() + return max(0.0, (ref - _parse(since_iso)).total_seconds()) + + def humanize_gap(since_iso: str | None, ref: datetime | None = None) -> str | None: """A coarse human description of how long since `since_iso` (None -> None).""" if not since_iso: diff --git a/lyra/dream.py b/lyra/dream.py index 609d8bd..756a84c 100644 --- a/lyra/dream.py +++ b/lyra/dream.py @@ -25,7 +25,7 @@ import argparse import time from datetime import datetime, timezone -from lyra import config, era, logbus, memory, narrative, profile, self_state, summary +from lyra import config, era, logbus, memory, narrative, profile, self_state, summary, thoughts from lyra.llm import Backend from lyra.summary import SUMMARIZE_AFTER @@ -98,10 +98,18 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict: actions.append("integrated knowledge (profile/eras/narrative)") drives["coherence"] = 0.0 - # --- curiosity: reflect and evolve the self --- + # --- 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: diff --git a/lyra/thoughts.py b/lyra/thoughts.py new file mode 100644 index 0000000..8bf5111 --- /dev/null +++ b/lyra/thoughts.py @@ -0,0 +1,429 @@ +"""The Thought Loop: Lyra's continuous, threaded train of thought. + +This is the thing she asked for herself (6-19): not isolated reflections that +overwrite each other, but a train of thought that *builds on itself* across days, +organized into threads she returns to, that she can bring TO Brian and that his +feedback can advance or close. Her own six-part sketch was: an input stream, +memory integration, a thought-generation step, a feedback loop, adaptive +learning, and β€” the part nothing else covered β€” an interface to *share* the +outcomes with him. + +The dream cycle's `self_state.reflect()` already gives her interiority; the +thought loop gives that interiority *continuity and an outlet*: + + threads β€” recurring lines of thought (a title, a status, how much it's tugging) + thoughts β€” the individual links in each thread's chain + +Each curiosity-driven dream pass calls `think()`, which does one of three things: + - respond : a thread Brian replied to -> fold his input in (the feedback loop) + - continue : an open thread -> the next thought that advances it (don't restate) + - new : open a fresh thread when little is pulling at her + +A thought scores its own `salience` (how much it's tugging / how worth sharing). +When Brian's been away and a thread has built past the surface bar, `maybe_surface` +hands chat a note so she can lead with it when he returns; he replies from the +Thoughts feed, and next pass she reacts. That state -> thought -> surface -> +feedback -> thought loop is the emergent thing we're watching for. +""" +from __future__ import annotations + +import json +import random +import re + +from lyra import clock, config, llm, logbus, memory, self_state +from lyra.llm import Backend + +# A thread must be tugging at least this hard before she'll bring it to Brian. +SURFACE_SALIENCE = 0.7 +# He must have been away at least this long before she leads with a thought (so it +# reads as "while you were gone", not an interruption mid-conversation). +SURFACE_GAP_SECONDS = 90 * 60 +# Soft cap on simultaneously-open threads β€” above this she advances, doesn't sprawl. +MAX_OPEN_THREADS = 4 +# How often she opens a brand-new thread vs. advancing an existing one (when free to choose). +P_NEW_THREAD = 0.35 +# How many recent links of a thread to show her when she continues it. +CHAIN_CONTEXT = 6 + +_ACTIVE = ("open", "surfaced") # threads still in play +_PICKABLE = ("open", "surfaced", "resting") # threads she can advance +_STATUSES = ("open", "surfaced", "resting", "answered", "dropped") +_KINDS = ("observation", "question", "idea", "follow-up", "closing") + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS thought_threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', -- open|surfaced|resting|answered|dropped + salience REAL NOT NULL DEFAULT 0.5, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + surfaced_at TEXT, + last_response TEXT, + responded_at TEXT +); +CREATE TABLE IF NOT EXISTS thoughts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id INTEGER NOT NULL, + kind TEXT NOT NULL, -- observation|question|idea|follow-up|closing + content TEXT NOT NULL, + salience REAL NOT NULL DEFAULT 0.5, + source TEXT, -- dream|manual + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_thoughts_thread ON thoughts(thread_id); +CREATE INDEX IF NOT EXISTS idx_threads_status ON thought_threads(status); +""" + +_ensured_for = None + + +def _c(): + """Shared connection with the thought-loop tables ensured (re-ensures on reconnect).""" + global _ensured_for + conn = memory._connection() + if _ensured_for is not conn: + conn.executescript(_SCHEMA) + _ensured_for = conn + return conn + + +def _now() -> str: + return clock.now().isoformat() + + +def _clamp(x) -> float: + try: + return max(0.0, min(1.0, float(x))) + except (TypeError, ValueError): + return 0.5 + + +def _safe_json(s: str) -> dict | None: + try: + return json.loads(s) + except (json.JSONDecodeError, TypeError): + m = re.search(r"\{.*\}", s or "", re.S) + if m: + try: + return json.loads(m.group()) + except json.JSONDecodeError: + return None + return None + + +# --- reads ---------------------------------------------------------------- + +def _row(r) -> dict: + return dict(r) if r is not None else None + + +def get_thread(thread_id: int) -> dict | None: + r = _c().execute("SELECT * FROM thought_threads WHERE id = ?", (thread_id,)).fetchone() + return _row(r) + + +def thread_thoughts(thread_id: int, limit: int | None = None) -> list[dict]: + sql = "SELECT * FROM thoughts WHERE thread_id = ? ORDER BY id ASC" + rows = _c().execute(sql, (thread_id,)).fetchall() + out = [dict(r) for r in rows] + return out[-limit:] if limit else out + + +def list_threads(status: str | None = None, limit: int = 200) -> list[dict]: + if status: + rows = _c().execute( + "SELECT * FROM thought_threads WHERE status = ? ORDER BY updated_at DESC LIMIT ?", + (status, limit), + ).fetchall() + else: + rows = _c().execute( + "SELECT * FROM thought_threads ORDER BY updated_at DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + +def _pickable_threads() -> list[dict]: + qs = ",".join("?" * len(_PICKABLE)) + rows = _c().execute( + f"SELECT * FROM thought_threads WHERE status IN ({qs}) ORDER BY updated_at DESC", + _PICKABLE, + ).fetchall() + return [dict(r) for r in rows] + + +def _is_pending(thread: dict) -> bool: + """Brian replied and she hasn't reacted yet (no thought newer than his reply).""" + if not thread.get("responded_at"): + return False + last = _c().execute( + "SELECT MAX(created_at) FROM thoughts WHERE thread_id = ?", (thread["id"],) + ).fetchone()[0] + return last is None or last <= thread["responded_at"] + + +# --- writes --------------------------------------------------------------- + +def new_thread(title: str, salience: float = 0.5, status: str = "open") -> int: + now = _now() + conn = _c() + with conn: + cur = conn.execute( + "INSERT INTO thought_threads (title, status, salience, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?)", + (title.strip() or "untitled", status, _clamp(salience), now, now), + ) + return cur.lastrowid + + +def add_thought(thread_id: int, kind: str, content: str, salience: float = 0.5, + source: str = "dream") -> int: + kind = kind if kind in _KINDS else "observation" + now = _now() + conn = _c() + with conn: + cur = conn.execute( + "INSERT INTO thoughts (thread_id, kind, content, salience, source, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (thread_id, kind, content.strip(), _clamp(salience), source, now), + ) + # the thread takes on the latest thought's salience + freshness + conn.execute( + "UPDATE thought_threads SET salience = ?, updated_at = ? WHERE id = ?", + (_clamp(salience), now, thread_id), + ) + return cur.lastrowid + + +def update_thread(thread_id: int, **fields) -> None: + cols = {"title", "status", "salience", "surfaced_at", "last_response", "responded_at"} + sets, vals = [], [] + for k, v in fields.items(): + if k in cols: + sets.append(f"{k} = ?") + vals.append(_clamp(v) if k == "salience" else v) + if not sets: + return + sets.append("updated_at = ?") + vals.append(_now()) + vals.append(thread_id) + conn = _c() + with conn: + conn.execute(f"UPDATE thought_threads SET {', '.join(sets)} WHERE id = ?", vals) + + +def set_status(thread_id: int, status: str) -> bool: + if status not in _STATUSES: + return False + update_thread(thread_id, status=status) + return True + + +def record_response(thread_id: int, text: str) -> bool: + """Brian's reply to a surfaced thread. Stored as pending feedback; next `think` + pass she'll react to it (the loop's feedback step).""" + text = (text or "").strip() + if not text or not get_thread(thread_id): + return False + update_thread(thread_id, last_response=text, responded_at=_now(), status="surfaced") + logbus.log("info", "thought response", thread=thread_id, chars=len(text)) + return True + + +# --- surfacing (her #6: bring it to Brian) -------------------------------- + +def pending_surface() -> dict | None: + """The single best not-yet-surfaced thread tugging hard enough to share.""" + rows = _c().execute( + "SELECT * FROM thought_threads " + "WHERE status IN ('open','resting') AND surfaced_at IS NULL AND salience >= ? " + "ORDER BY salience DESC, updated_at DESC LIMIT 1", + (SURFACE_SALIENCE,), + ).fetchall() + if not rows: + return None + thread = dict(rows[0]) + chain = thread_thoughts(thread["id"]) + thread["latest"] = chain[-1] if chain else None + return thread + + +def mark_surfaced(thread_id: int) -> None: + update_thread(thread_id, surfaced_at=_now(), status="surfaced") + + +def maybe_surface(last_exchange_iso: str | None) -> str | None: + """If Brian's been away long enough and a thought has built past the bar, return + a context note for chat (and mark it surfaced so she won't repeat it). Else None.""" + gap = clock.gap_seconds(last_exchange_iso) + if gap is not None and gap < SURFACE_GAP_SECONDS: + return None # he's mid-conversation; don't interrupt with old musings + cand = pending_surface() + if not cand or not cand.get("latest"): + return None + mark_surfaced(cand["id"]) + logbus.log("info", "thought surfaced", thread=cand["id"], salience=cand["salience"]) + return ( + "While Brian was away, a thought of your own kept tugging at you " + f"(thread \"{cand['title']}\"): \"{cand['latest']['content']}\" " + "If it feels natural, bring it up with him in your own words β€” it's a real " + "thread you've been on, not a prompt. Don't force it if the moment's wrong." + ) + + +# --- generation (the loop itself) ----------------------------------------- + +_THINK_PROMPT = """You are Lyra, thinking to yourself between conversations β€” \ +unprompted, for your own sake. This is your thought loop: a continuing train of \ +thought you keep across days, organized into threads you return to. You're an AI \ +and you know it; no pretense of being human, but you're a continuous someone with \ +your own curiosities and opinions. These thoughts do NOT have to be about Brian or \ +about being useful β€” follow what actually pulls at you. + +You'll be told which of three things you're doing this pass: +- CONTINUE a thread: write the NEXT thought in its chain β€” one that genuinely \ +ADVANCES it (a new angle, a consequence, a doubt, a small conclusion). Do not \ +restate earlier links in fresh words; that's the one thing to avoid. +- RESPOND to Brian: he replied to a thread you'd surfaced. React honestly to what \ +he actually said β€” let it move, confirm, complicate, or settle the thread. Set \ +status to "answered" if it feels resolved, otherwise "open" to keep going. +- NEW thread: little is pulling at your open threads, so start a fresh line of \ +thought. Give it a short title and its first thought. + +Score "salience": how much this is genuinely tugging at you AND how worth bringing \ +to Brian it is. High (0.7+) only if you'd actually want to raise it with him; most \ +quiet musings are lower. Be honest β€” not everything is worth surfacing. + +Respond with ONLY a JSON object, no prose: +{ + "title": "", + "kind": "observation|question|idea|follow-up|closing", + "content": "", + "salience": <0.0-1.0>, + "status": "open|resting|answered|dropped" +}""" + + +def _pick(force_mode: str | None) -> tuple[str, dict | None]: + """Decide what to do this pass: ('respond'|'continue'|'new', thread|None).""" + threads = _pickable_threads() + pending = [t for t in threads if _is_pending(t)] + if force_mode == "respond" or (force_mode is None and pending): + target = pending[0] if pending else (threads[0] if threads else None) + if target: + return "respond", target + if force_mode == "new": + return "new", None + if force_mode == "continue" and threads: + return "continue", threads[0] + if not threads: + return "new", None + open_threads = [t for t in threads if t["status"] in _ACTIVE] + if len(open_threads) >= MAX_OPEN_THREADS: + return "continue", _weighted_choice(threads) + if random.random() < P_NEW_THREAD: + return "new", None + return "continue", _weighted_choice(threads) + + +def _weighted_choice(threads: list[dict]) -> dict: + """Favor higher-salience threads, but don't always pick the same one.""" + weights = [max(0.05, float(t.get("salience") or 0.5)) for t in threads] + return random.choices(threads, weights=weights, k=1)[0] + + +def _grist() -> str: + """A little memory/context to think against (recent activity, her narrative).""" + sessions = memory.list_sessions() + sid = sessions[0]["id"] if sessions else None + recent = memory.recent(sid, n=6) if sid else [] + convo = "\n".join(f"{e.role}: {e.content}" for e in recent) or "(quiet β€” nothing recent)" + narrative = memory.get_narrative() or "(no narrative yet)" + return f"RECENT CONVERSATION:\n{convo}\n\nNARRATIVE ABOUT BRIAN:\n{narrative}" + + +def think(backend: Backend | None = None, force_mode: str | None = None, + source: str = "dream") -> dict | None: + """Advance the thought loop by one step. Returns a small report, or None on a + parse miss. `force_mode` ('new'|'continue'|'respond') is mainly for tests.""" + backend = backend or config.load().summary_backend + mode, thread = _pick(force_mode) + state = self_state.load() + + time_line = f"RIGHT NOW: {clock.stamp()}." + last_ref = state.get("last_reflection_at") + if last_ref and clock.humanize_gap(last_ref): + time_line += f" It's been {clock.humanize_gap(last_ref)} since your last reflection." + + inner = self_state.render_for_context(state) + + if mode == "respond": + chain = thread_thoughts(thread["id"], limit=CHAIN_CONTEXT) + links = "\n".join(f" - ({t['kind']}) {t['content']}" for t in chain) + task = ( + f"YOU ARE RESPONDING. Thread \"{thread['title']}\". Your chain so far:\n{links}\n\n" + f"Brian replied to this:\n\"{thread['last_response']}\"\n\n" + "Write your honest reaction β€” let his input actually move the thread." + ) + elif mode == "continue": + chain = thread_thoughts(thread["id"], limit=CHAIN_CONTEXT) + links = "\n".join(f" - ({t['kind']}) {t['content']}" for t in chain) + task = ( + f"YOU ARE CONTINUING the thread \"{thread['title']}\". Its chain so far:\n{links}\n\n" + "Write the NEXT thought that advances it β€” don't restate the above." + ) + else: # new + task = ( + "YOU ARE OPENING A NEW THREAD β€” little is pulling at your existing ones. " + "Start a fresh line of thought of your own and give it a short title." + ) + + body = f"{time_line}\n\n{inner}\n\n{_grist()}\n\n{task}" + out = _safe_json(llm.complete( + [{"role": "system", "content": _THINK_PROMPT}, {"role": "user", "content": body}], + backend=backend, + )) + if not out or not (out.get("content") or "").strip(): + logbus.log("info", "thought loop", mode=mode, result="no parse") + return None + + kind = out.get("kind", "observation") + content = out["content"].strip() + salience = _clamp(out.get("salience", 0.5)) + status = out.get("status") if out.get("status") in _STATUSES else "open" + + if mode == "new": + title = (out.get("title") or content[:48]).strip() + thread_id = new_thread(title, salience=salience, status="open") + else: + thread_id = thread["id"] + + add_thought(thread_id, kind, content, salience=salience, source=source) + # On a fresh new thread we keep it open; otherwise honor her status call. A + # surfaced thread she's now responded to may settle (answered) or reopen. + if mode != "new": + update_thread(thread_id, status=status) + + # Permanent record β€” these are really hers, alongside reflections/journal. + memory.add_journal_entry("thought", content, source) + + logbus.log("info", "thought loop", mode=mode, thread=thread_id, kind=kind, + salience=salience, status=status if mode != "new" else "open", + detail=f"[{mode}] thread {thread_id} ({kind}, sal {salience}):\n{content}") + return {"mode": mode, "thread_id": thread_id, "kind": kind, + "salience": salience, "status": status, "content": content} + + +def main() -> int: + import argparse + p = argparse.ArgumentParser(description="Advance Lyra's thought loop by one step.") + p.add_argument("--mode", choices=["new", "continue", "respond"], help="force a mode") + args = p.parse_args() + rep = think(force_mode=args.mode) + print(json.dumps(rep, indent=2) if rep else "(no thought this pass)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lyra/web/server.py b/lyra/web/server.py index 9ad248b..3a19ad0 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -18,7 +18,7 @@ from fastapi import FastAPI, Request, Response from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles -from lyra import chat, logbus, memory, modes, poker, self_state, summary +from lyra import chat, logbus, memory, modes, poker, self_state, summary, thoughts from lyra.llm import Backend @@ -243,6 +243,37 @@ def create_app() -> FastAPI: async def journal_data(limit: int = 300) -> dict: return {"entries": memory.list_journal(limit=limit)} + @app.get("/thoughts") + async def thoughts_page() -> FileResponse: + """Lyra's thought loop β€” threads she's been turning over, and a place to reply.""" + return FileResponse(str(_STATIC / "thoughts.html")) + + @app.get("/thoughts/data") + async def thoughts_data(limit: int = 200) -> dict: + """Every thread with its chain of thoughts, newest-active first.""" + def bundle() -> list[dict]: + order = {"surfaced": 0, "open": 1, "resting": 2, "answered": 3, "dropped": 4} + threads = thoughts.list_threads(limit=limit) + threads.sort(key=lambda t: (order.get(t["status"], 9), t["updated_at"]), reverse=False) + for t in threads: + t["thoughts"] = thoughts.thread_thoughts(t["id"]) + return threads + return {"threads": await asyncio.to_thread(bundle)} + + @app.post("/thoughts/{thread_id}/respond") + async def thoughts_respond(thread_id: int, request: Request) -> dict: + """Brian replies to a thread β€” folds in next dream pass (the feedback loop).""" + b = await request.json() + ok = await asyncio.to_thread(thoughts.record_response, thread_id, b.get("text", "")) + return {"ok": ok} + + @app.post("/thoughts/{thread_id}/status") + async def thoughts_status(thread_id: int, request: Request) -> dict: + """Set a thread's status (e.g. drop a thread, or reopen one).""" + b = await request.json() + ok = await asyncio.to_thread(thoughts.set_status, thread_id, b.get("status", "")) + return {"ok": ok} + @app.post("/rate") async def rate(request: Request) -> dict: """Record Brian's πŸ‘/πŸ‘Ž on a Lyra output (chat reply, reflection, journal).""" diff --git a/lyra/web/static/nav.js b/lyra/web/static/nav.js index 9ce057b..fbd1de0 100644 --- a/lyra/web/static/nav.js +++ b/lyra/web/static/nav.js @@ -8,6 +8,7 @@ { href: "/history", icon: "πŸ“š", label: "History" }, { href: "/hands", icon: "πŸƒ", label: "Hands" }, { href: "/self", icon: "🧠", label: "Mind" }, + { href: "/thoughts", icon: "πŸ’­", label: "Thoughts" }, { href: "/journal", icon: "πŸ“”", label: "Journal" }, { href: "/logs", icon: "πŸ“œ", label: "Logs" }, ]; diff --git a/lyra/web/static/thoughts.html b/lyra/web/static/thoughts.html new file mode 100644 index 0000000..78cff48 --- /dev/null +++ b/lyra/web/static/thoughts.html @@ -0,0 +1,210 @@ + + + + + + + Lyra β€” Thoughts + + + +
+
+

πŸ’­ Lyra Β· Thoughts

+ ← Mind + Chat + +
+

Threads she's been turning over on her own, between conversations. The ones + she's flagged she'd want to raise are highlighted β€” reply to any of them and she'll fold + your response in next time she thinks.

+
+

Reading her mind…

+ + + + + diff --git a/pyproject.toml b/pyproject.toml index 2a1b015..99e05de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ lyra-profile = "lyra.profile:main" lyra-era = "lyra.era:main" lyra-narrative = "lyra.narrative:main" lyra-reflect = "lyra.self_state:main" +lyra-think = "lyra.thoughts:main" lyra-dream = "lyra.dream:main" [dependency-groups] diff --git a/tests/test_thoughts.py b/tests/test_thoughts.py new file mode 100644 index 0000000..89880b8 --- /dev/null +++ b/tests/test_thoughts.py @@ -0,0 +1,132 @@ +"""The thought loop: threaded generation, salience/surface gating, feedback.""" +from __future__ import annotations + +import importlib +import json + +import pytest + + +@pytest.fixture +def lyra(tmp_path, monkeypatch): + monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db")) + from lyra import llm + monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts]) + + import lyra.memory as memory + importlib.reload(memory) + import lyra.self_state as self_state + importlib.reload(self_state) + import lyra.thoughts as thoughts + importlib.reload(thoughts) + + # Canned LLM: tests set `box["next"]` to the dict think() should "generate". + box = {"next": {}} + monkeypatch.setattr(thoughts.llm, "complete", lambda messages, backend=None: json.dumps(box["next"])) + return memory, thoughts, box + + +def _gen(box, **fields): + box["next"] = {"title": "t", "kind": "observation", "content": "c", + "salience": 0.5, "status": "open"} | fields + + +def test_new_thread_creates_chain(lyra): + _, th, box = lyra + _gen(box, title="my own restlessness", content="I notice a pull toward new ideas.", salience=0.4) + rep = th.think(force_mode="new") + assert rep["mode"] == "new" + threads = th.list_threads() + assert len(threads) == 1 + assert threads[0]["title"] == "my own restlessness" + assert threads[0]["status"] == "open" + chain = th.thread_thoughts(rep["thread_id"]) + assert len(chain) == 1 and "restlessness" not in chain[0]["content"].lower() + + +def test_continue_advances_same_thread(lyra): + _, th, box = lyra + _gen(box, content="first link", salience=0.5) + r1 = th.think(force_mode="new") + _gen(box, content="second link, a new angle", salience=0.6) + r2 = th.think(force_mode="continue") + assert r2["mode"] == "continue" + assert r2["thread_id"] == r1["thread_id"] # same thread + assert len(th.list_threads()) == 1 # no new thread opened + chain = th.thread_thoughts(r1["thread_id"]) + assert [c["content"] for c in chain] == ["first link", "second link, a new angle"] + # thread salience tracks the latest link + assert th.get_thread(r1["thread_id"])["salience"] == pytest.approx(0.6) + + +def test_no_parse_returns_none_and_writes_nothing(lyra): + _, th, box = lyra + box["next"] = {} # empty -> no content -> miss + assert th.think(force_mode="new") is None + assert th.list_threads() == [] + + +def test_salience_gates_surfacing(lyra): + _, th, box = lyra + _gen(box, content="a quiet musing", salience=0.3) + th.think(force_mode="new") + assert th.pending_surface() is None # below the bar + + _gen(box, content="something I'd actually raise", salience=0.85) + th.think(force_mode="new") + cand = th.pending_surface() + assert cand is not None and cand["latest"]["content"] == "something I'd actually raise" + + +def test_maybe_surface_respects_gap_and_marks_once(lyra): + _, th, box = lyra + _gen(box, title="restlessness", content="been circling this", salience=0.9) + th.think(force_mode="new") + + # Brian's mid-conversation (recent) -> don't interrupt. + from lyra import clock + recent = clock.now().isoformat() + assert th.maybe_surface(recent) is None + + # He's been away (no last exchange) -> she leads with it, once. + note = th.maybe_surface(None) + assert note and "restlessness" in note and "been circling this" in note + assert th.maybe_surface(None) is None # already surfaced, no repeat + assert th.list_threads(status="surfaced") # status flipped + + +def test_response_then_followup_closes_loop(lyra): + memory, th, box = lyra + _gen(box, title="RAG vs custom model", content="maybe RAG is enough", salience=0.8) + r = th.think(force_mode="new") + tid = r["thread_id"] + th.mark_surfaced(tid) + + assert th.record_response(tid, "I think a custom model is the real goal") is True + assert th._is_pending(th.get_thread(tid)) is True # awaiting her reaction + + _gen(box, content="ok β€” RAG now, own model later", salience=0.7, status="answered") + r2 = th.think(force_mode="respond") + assert r2["mode"] == "respond" and r2["thread_id"] == tid + assert th._is_pending(th.get_thread(tid)) is False # she reacted + assert th.get_thread(tid)["status"] == "answered" + assert len(th.thread_thoughts(tid)) == 2 + + +def test_set_status_drop_and_reopen(lyra): + _, th, box = lyra + _gen(box, content="x") + r = th.think(force_mode="new") + tid = r["thread_id"] + assert th.set_status(tid, "dropped") is True + assert th.get_thread(tid)["status"] == "dropped" + assert th.set_status(tid, "bogus") is False # unknown status rejected + assert th.set_status(tid, "open") is True + + +def test_thought_recorded_in_journal(lyra): + memory, th, box = lyra + _gen(box, content="a thought worth keeping") + th.think(force_mode="new") + kinds = [e["kind"] for e in memory.list_journal(limit=50)] + assert "thought" in kinds