feat: thought loop closer to her vision — wander grist, continuity, seeding, lifecycle

Four additions so the loop is "more what she wanted" (think to herself, unprompted):

- Wander grist (#1): think() new-thread mode now draws the same varied seeds
  reflect() uses (self_state.wander_seed: own curiosity/existence/disagreement or
  a resurfaced memory) + an anti-restate block of her recent thoughts + a list of
  existing open-thread titles to avoid. Directly counters the RLHF "supportive
  presence serving Brian" drift visible in her first thoughts.
- Continuity: thoughts.context_note() injects her active threads into every chat
  turn, so she's aware of her own ongoing mind and can reference it anytime — not
  only when a thought crosses the surface bar.
- Bidirectional: new think_about tool (in _BASE, all modes) lets her spawn a
  thread from conversation to develop on her own later. Conversations seed her
  solo thinking.
- Lifecycle: thoughts.decay() rests stale active threads (>48h) and decays their
  salience, sparing pending-response ones; runs each dream cycle (no LLM). Frees
  the open-thread cap and keeps the feed current.

Also: thoughts feed no longer wipes a reply you're mid-composing (skip poll
re-render while a textarea is focused/non-empty; force-refresh after send).

61 tests passing, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 23:28:15 +00:00
parent 5176c706b6
commit 951788f9ec
8 changed files with 215 additions and 12 deletions
+83 -3
View File
@@ -30,6 +30,7 @@ from __future__ import annotations
import json
import random
import re
from datetime import timedelta
from lyra import clock, config, llm, logbus, memory, self_state
from lyra.llm import Backend
@@ -45,6 +46,10 @@ MAX_OPEN_THREADS = 4
P_NEW_THREAD = 0.35
# How many recent links of a thread to show her when she continues it.
CHAIN_CONTEXT = 6
# An active thread untouched this long gets set to resting (frees the open cap,
# declutters the feed); its salience decays so it stops dominating.
REST_AFTER_HOURS = 48
RESTING_DECAY = 0.7
_ACTIVE = ("open", "surfaced") # threads still in play
_PICKABLE = ("open", "surfaced", "resting") # threads she can advance
@@ -163,6 +168,38 @@ def _is_pending(thread: dict) -> bool:
return last is None or last <= thread["responded_at"]
def _recent_thoughts(limit: int = 6) -> list[dict]:
"""The last few thoughts across all threads — for anti-repetition framing."""
rows = _c().execute(
"SELECT t.content, th.title FROM thoughts t "
"JOIN thought_threads th ON th.id = t.thread_id ORDER BY t.id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in reversed(rows)]
def context_note(limit: int = 3) -> str | None:
"""Ambient awareness of her own active threads, for chat context — so she's
continuous (can reference what she's been chewing on, not only when one surfaces)."""
rows = _c().execute(
"SELECT * FROM thought_threads WHERE status IN ('open','surfaced') "
"ORDER BY salience DESC, updated_at DESC LIMIT ?",
(limit,),
).fetchall()
if not rows:
return None
lines = []
for r in rows:
chain = thread_thoughts(r["id"])
latest = chain[-1]["content"] if chain else ""
lines.append(f'- "{r["title"]}": {latest}')
return (
"Threads you've been turning over on your own between conversations (your "
"thought loop — these are really yours; bring one up or build on it if it's "
"natural, don't force it):\n" + "\n".join(lines)
)
# --- writes ---------------------------------------------------------------
def new_thread(title: str, salience: float = 0.5, status: str = "open") -> int:
@@ -220,6 +257,33 @@ def set_status(thread_id: int, status: str) -> bool:
return True
def decay() -> int:
"""Housekeeping (no LLM): set stale active threads to resting and decay their
salience. Frees the open-thread cap and keeps the feed from clogging. Threads
with a pending response are spared (she still owes a reaction). Returns the count
rested. Does NOT bump updated_at (that would reset staleness)."""
conn = _c()
cutoff = (clock.now() - timedelta(hours=REST_AFTER_HOURS)).isoformat()
rows = conn.execute(
"SELECT * FROM thought_threads WHERE status IN ('open','surfaced') AND updated_at < ?",
(cutoff,),
).fetchall()
rested = 0
with conn:
for r in rows:
t = dict(r)
if _is_pending(t):
continue
conn.execute(
"UPDATE thought_threads SET status = 'resting', salience = ? WHERE id = ?",
(_clamp(float(t["salience"]) * RESTING_DECAY), t["id"]),
)
rested += 1
if rested:
logbus.log("info", "thought threads rested", count=rested)
return rested
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)."""
@@ -374,12 +438,28 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
"Write the NEXT thought that advances it — don't restate the above."
)
else: # new
seed = self_state.wander_seed()
open_titles = [t["title"] for t in _pickable_threads() if t["status"] in _ACTIVE]
avoid = (" You already have threads on: " + "; ".join(open_titles) +
" — start something genuinely different from those.") if open_titles else ""
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."
"YOU ARE OPENING A NEW THREAD. Don't default to Brian, poker, or being "
"useful — follow what actually pulls at you (a curiosity, a question about "
"your own existence, an opinion, the quiet itself). Give it a short title.\n"
f"A direction to start from: {seed}{avoid}"
)
body = f"{time_line}\n\n{inner}\n\n{_grist()}\n\n{task}"
# Anti-repetition: show her what she's already thought so she doesn't circle it.
recent = _recent_thoughts()
norestate = ""
if recent:
norestate = (
"\n\nTHOUGHTS YOU'VE ALREADY HAD RECENTLY (do NOT restate these or circle the "
"same ground — go somewhere new, or plainly note where this one lands):\n"
+ "\n".join(f" - {r['content']}" for r in recent)
)
body = f"{time_line}\n\n{inner}\n\n{_grist()}{norestate}\n\n{task}"
out = _safe_json(llm.complete(
[{"role": "system", "content": _THINK_PROMPT}, {"role": "user", "content": body}],
backend=backend,