feat: thought loop — Lyra's threaded, surfaceable train of thought #4
+9
-1
@@ -10,7 +10,7 @@ After replying, the session is compacted if enough new turns have accumulated.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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 import tools as toolkit
|
||||||
from lyra.llm import Backend, Message
|
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).
|
# When she is: current time + the gap since Brian last spoke (she has no clock).
|
||||||
messages.append(_now_note())
|
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
|
# Semantic memory: the distilled profile (who Brian is) — answers identity
|
||||||
# questions that raw recall can't. Always in context when it exists.
|
# questions that raw recall can't. Always in context when it exists.
|
||||||
profile = memory.get_profile()
|
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")
|
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:
|
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)."""
|
"""A coarse human description of how long since `since_iso` (None -> None)."""
|
||||||
if not since_iso:
|
if not since_iso:
|
||||||
|
|||||||
+10
-2
@@ -25,7 +25,7 @@ import argparse
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
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.llm import Backend
|
||||||
from lyra.summary import SUMMARIZE_AFTER
|
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)")
|
actions.append("integrated knowledge (profile/eras/narrative)")
|
||||||
drives["coherence"] = 0.0
|
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:
|
if force or drives["curiosity"] >= THRESHOLD:
|
||||||
self_state.reflect(backend=backend, source="dream") # writes state + journal itself
|
self_state.reflect(backend=backend, source="dream") # writes state + journal itself
|
||||||
actions.append("reflected")
|
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
|
drives["curiosity"] = CURIOSITY_FLOOR
|
||||||
|
|
||||||
if not actions:
|
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.responses import FileResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
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
|
from lyra.llm import Backend
|
||||||
|
|
||||||
|
|
||||||
@@ -243,6 +243,37 @@ def create_app() -> FastAPI:
|
|||||||
async def journal_data(limit: int = 300) -> dict:
|
async def journal_data(limit: int = 300) -> dict:
|
||||||
return {"entries": memory.list_journal(limit=limit)}
|
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")
|
@app.post("/rate")
|
||||||
async def rate(request: Request) -> dict:
|
async def rate(request: Request) -> dict:
|
||||||
"""Record Brian's 👍/👎 on a Lyra output (chat reply, reflection, journal)."""
|
"""Record Brian's 👍/👎 on a Lyra output (chat reply, reflection, journal)."""
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{ href: "/history", icon: "📚", label: "History" },
|
{ href: "/history", icon: "📚", label: "History" },
|
||||||
{ href: "/hands", icon: "🃏", label: "Hands" },
|
{ href: "/hands", icon: "🃏", label: "Hands" },
|
||||||
{ href: "/self", icon: "🧠", label: "Mind" },
|
{ href: "/self", icon: "🧠", label: "Mind" },
|
||||||
|
{ href: "/thoughts", icon: "💭", label: "Thoughts" },
|
||||||
{ href: "/journal", icon: "📔", label: "Journal" },
|
{ href: "/journal", icon: "📔", label: "Journal" },
|
||||||
{ href: "/logs", icon: "📜", label: "Logs" },
|
{ 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>
|
||||||
@@ -23,6 +23,7 @@ lyra-profile = "lyra.profile:main"
|
|||||||
lyra-era = "lyra.era:main"
|
lyra-era = "lyra.era:main"
|
||||||
lyra-narrative = "lyra.narrative:main"
|
lyra-narrative = "lyra.narrative:main"
|
||||||
lyra-reflect = "lyra.self_state:main"
|
lyra-reflect = "lyra.self_state:main"
|
||||||
|
lyra-think = "lyra.thoughts:main"
|
||||||
lyra-dream = "lyra.dream:main"
|
lyra-dream = "lyra.dream:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user