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:
+9
-1
@@ -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()
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)."""
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user