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