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
+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)."""