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:
+83
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user