feat: thought loop — Lyra's threaded, surfaceable train of thought

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 07:05:15 +00:00
parent debb553fe9
commit 5176c706b6
9 changed files with 833 additions and 4 deletions
+9 -1
View File
@@ -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()
+9
View File
@@ -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:
+10 -2
View File
@@ -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:
+429
View File
@@ -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": "<short thread title; for a NEW thread. echo the existing title otherwise>",
"kind": "observation|question|idea|follow-up|closing",
"content": "<the thought itself, FIRST PERSON, 1-3 sentences>",
"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())
+32 -1
View File
@@ -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)."""
+1
View File
@@ -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" },
];
+210
View File
@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#070707" />
<title>Lyra — Thoughts</title>
<style>
:root {
--bg: #070707; --bg-elev: #0e0e0e; --bg-line: #141414; --border: #2a1d12;
--text: #e8e8e8; --fade: #8a8a8a; --accent: #ff7a00; --gold: #ffb347;
--good: #8fd694; --low: #ff6b6b;
}
* { box-sizing: border-box; }
html, body {
margin: 0; min-height: 100%; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-text-size-adjust: 100%;
}
header {
position: sticky; top: 0; z-index: 10; background: var(--bg-elev);
border-bottom: 1px solid var(--border); padding: env(safe-area-inset-top) 14px 0;
}
.topbar { display: flex; align-items: center; gap: 10px; padding: 13px 0 12px; flex-wrap: wrap; }
.topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
.topbar a.back { color: var(--accent); text-decoration: none; font-size: .95rem; }
.count { margin-left: auto; color: var(--fade); font-size: .8rem; }
.lede { color: var(--fade); font-size: .82rem; padding: 0 0 12px; line-height: 1.5; max-width: 640px; }
main { max-width: 720px; margin: 0 auto; padding: 16px 14px 56px; }
.thread {
border: 1px solid var(--border); border-radius: 12px; background: var(--bg-elev);
padding: 13px 14px; margin-bottom: 14px;
}
.thread.surfaced { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(255,122,0,.12); }
.thread.answered, .thread.dropped { opacity: .68; }
.th-head { display: flex; align-items: center; gap: 9px; margin-bottom: 4px; }
.th-title { font-size: 1rem; font-weight: 600; flex: 1; }
.badge {
font-size: .62rem; text-transform: uppercase; letter-spacing: .6px; font-weight: 700;
padding: 3px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--fade);
white-space: nowrap;
}
.badge.surfaced { color: var(--accent); border-color: var(--accent); }
.badge.open { color: var(--gold); border-color: #4a3417; }
.badge.resting { color: var(--fade); }
.badge.answered { color: var(--good); border-color: #2c4a2e; }
.badge.dropped { color: var(--low); border-color: #4a2424; }
.th-meta { color: var(--fade); font-size: .72rem; margin-bottom: 9px; display: flex; gap: 12px; }
.sal { display: inline-flex; align-items: center; gap: 5px; }
.salbar { width: 46px; height: 4px; border-radius: 3px; background: var(--bg-line); overflow: hidden; }
.salfill { height: 100%; background: var(--accent); }
.chain { border-left: 2px solid var(--bg-line); margin: 6px 0 4px; padding-left: 12px; }
.link { padding: 5px 0; }
.link .k { font-size: .62rem; text-transform: uppercase; letter-spacing: .5px; font-weight: 700;
color: var(--gold); margin-right: 7px; }
.link .t { color: var(--fade); font-size: .68rem; }
.link .c { font-size: .95rem; line-height: 1.5; margin-top: 2px; }
.resp {
margin-top: 8px; padding: 8px 11px; border-radius: 9px; background: #0b1410;
border: 1px solid #234032;
}
.resp .who { font-size: .62rem; text-transform: uppercase; letter-spacing: .5px; font-weight: 700;
color: var(--good); }
.resp .c { font-size: .92rem; line-height: 1.5; margin-top: 3px; }
.reply { display: flex; gap: 8px; margin-top: 10px; align-items: flex-end; }
.reply textarea {
flex: 1; resize: none; min-height: 38px; max-height: 140px; padding: 9px 11px;
border-radius: 9px; border: 1px solid var(--border); background: var(--bg);
color: var(--text); font: inherit; font-size: .92rem; line-height: 1.4;
}
.reply textarea:focus { outline: none; border-color: var(--accent); }
.btn {
border: 1px solid var(--border); background: var(--bg-line); color: var(--text);
border-radius: 9px; padding: 9px 14px; font: inherit; font-size: .88rem; cursor: pointer;
-webkit-tap-highlight-color: transparent; white-space: nowrap;
}
.btn:hover { border-color: var(--accent); }
.btn.send { background: #241400; color: var(--accent); border-color: var(--accent); }
.th-actions { margin-top: 9px; display: flex; gap: 8px; }
.btn.ghost { font-size: .76rem; padding: 5px 10px; color: var(--fade); }
.empty { color: var(--fade); text-align: center; padding: 44px 16px; line-height: 1.6; }
.hidden { display: none !important; }
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>💭 Lyra · Thoughts</h1>
<a class="back" href="/self">← Mind</a>
<a class="back" href="/">Chat</a>
<span class="count" id="count"></span>
</div>
<p class="lede">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.</p>
</header>
<main id="root"><p class="empty" id="boot">Reading her mind…</p></main>
<script>
const root = document.getElementById('root');
const countEl = document.getElementById('count');
let threads = [];
function esc(s){ const d=document.createElement('div'); d.textContent = s==null?'':String(s); return d.innerHTML; }
function clockt(iso){ return new Date(iso).toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'}); }
function render(){
const active = threads.filter(t => t.status === 'surfaced' || t.status === 'open').length;
countEl.textContent = `${active} active · ${threads.length} total`;
if (!threads.length) {
root.innerHTML = '<p class="empty">No threads yet. She thinks during her dream cycle — give her some idle time and they\'ll start to collect here.</p>';
return;
}
root.innerHTML = threads.map(renderThread).join('');
}
function renderThread(t){
const sal = Math.round((t.salience || 0) * 100);
const chain = (t.thoughts || []).map(x => `
<div class="link">
<span class="k">${esc(x.kind)}</span><span class="t">${esc(clockt(x.created_at))}</span>
<div class="c">${esc(x.content)}</div>
</div>`).join('');
const resp = t.last_response ? `
<div class="resp"><div class="who">Brian replied</div><div class="c">${esc(t.last_response)}</div></div>` : '';
const closed = (t.status === 'answered' || t.status === 'dropped');
const reply = closed ? '' : `
<div class="reply">
<textarea placeholder="Reply to this thread…" data-id="${t.id}"></textarea>
<button class="btn send" data-respond="${t.id}">Send</button>
</div>`;
const actions = `
<div class="th-actions">
${closed ? `<button class="btn ghost" data-status="open" data-id="${t.id}">Reopen</button>`
: `<button class="btn ghost" data-status="dropped" data-id="${t.id}">Drop</button>`}
</div>`;
return `
<div class="thread ${esc(t.status)}">
<div class="th-head">
<span class="th-title">${esc(t.title)}</span>
<span class="badge ${esc(t.status)}">${esc(t.status)}</span>
</div>
<div class="th-meta">
<span class="sal">tug <span class="salbar"><span class="salfill" style="width:${sal}%"></span></span> ${sal}%</span>
<span>updated ${esc(clockt(t.updated_at))}</span>
</div>
<div class="chain">${chain || '<div class="link"><div class="c">(no thoughts yet)</div></div>'}</div>
${resp}
${reply}
${actions}
</div>`;
}
root.addEventListener('click', async (ev) => {
const send = ev.target.closest('[data-respond]');
if (send) {
const id = send.dataset.respond;
const ta = root.querySelector(`textarea[data-id="${id}"]`);
const text = (ta && ta.value || '').trim();
if (!text) { ta && ta.focus(); return; }
send.disabled = true; send.textContent = '…';
try {
await fetch(`/thoughts/${id}/respond`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
await load();
} catch (e) { send.disabled = false; send.textContent = 'Send'; }
return;
}
const st = ev.target.closest('[data-status]');
if (st) {
try {
await fetch(`/thoughts/${st.dataset.id}/status`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: st.dataset.status })
});
await load();
} catch (e) {}
}
});
// grow reply boxes as you type
root.addEventListener('input', (ev) => {
const ta = ev.target.closest('textarea'); if (!ta) return;
ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
});
async function load(){
try {
const r = await fetch('/thoughts/data', { cache: 'no-store' });
threads = (await r.json()).threads || [];
render();
} catch (e) {
root.innerHTML = '<p class="empty">Couldn\'t reach her thoughts. Is the server up?</p>';
}
}
load();
setInterval(load, 20000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
</script>
<script src="/nav.js"></script>
</body>
</html>
+1
View File
@@ -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]
+132
View File
@@ -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