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:
@@ -89,6 +89,13 @@ def build_messages(session_id: str, user_msg: str,
|
|||||||
# right after the persona — her sense of self before her model of the world.
|
# right after the persona — her sense of self before her model of the world.
|
||||||
messages.append({"role": "system", "content": self_state.render_for_context(self_state.load())})
|
messages.append({"role": "system", "content": self_state.render_for_context(self_state.load())})
|
||||||
|
|
||||||
|
# Her own ongoing thought threads — ambient awareness so she's continuous across
|
||||||
|
# conversations (can reference what she's been chewing on), not only when a thought
|
||||||
|
# crosses the surface bar below. Part of her interiority, so it rides with the self.
|
||||||
|
thread_note = thoughts.context_note()
|
||||||
|
if thread_note:
|
||||||
|
messages.append({"role": "system", "content": thread_note})
|
||||||
|
|
||||||
# Mode card: how to behave *right now* (e.g. live-cash copilot). High priority —
|
# Mode card: how to behave *right now* (e.g. live-cash copilot). High priority —
|
||||||
# it sits just after her sense of self, before her model of the world. Talk mode
|
# it sits just after her sense of self, before her model of the world. Talk mode
|
||||||
# has no card (the persona's default voice is the Talk register).
|
# has no card (the persona's default voice is the Talk register).
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
|
|||||||
logbus.log("info", "dream cycle sensing", ripe=backlog["ripe"], dirty=backlog["dirty"],
|
logbus.log("info", "dream cycle sensing", ripe=backlog["ripe"], dirty=backlog["dirty"],
|
||||||
profile_lag=profile_lag, new_activity=new_activity, drives=_round(drives))
|
profile_lag=profile_lag, new_activity=new_activity, drives=_round(drives))
|
||||||
|
|
||||||
|
# Thought-loop housekeeping (no LLM): rest stale threads so the open-thread cap
|
||||||
|
# never jams and the feed stays current. Cheap; run every pass.
|
||||||
|
thoughts.decay()
|
||||||
|
|
||||||
actions: list[str] = []
|
actions: list[str] = []
|
||||||
|
|
||||||
# --- continuity: compact raw sessions into gists ---
|
# --- continuity: compact raw sessions into gists ---
|
||||||
|
|||||||
+3
-2
@@ -36,8 +36,9 @@ class Mode:
|
|||||||
# even when we're just talking.
|
# even when we're just talking.
|
||||||
_LOOKUPS = ("player_profile", "get_villain_file", "running_stats", "recent_sessions")
|
_LOOKUPS = ("player_profile", "get_villain_file", "running_stats", "recent_sessions")
|
||||||
|
|
||||||
# Always-available core tools (her own agency: journaling/notes).
|
# Always-available core tools (her own agency: journaling/notes/starting a thought
|
||||||
_BASE = ("journal_write", "note")
|
# thread she'll develop on her own later).
|
||||||
|
_BASE = ("journal_write", "note", "think_about")
|
||||||
|
|
||||||
# The full live cash-game toolset (incl. Brian's mental-game rituals).
|
# The full live cash-game toolset (incl. Brian's mental-game rituals).
|
||||||
_CASH_TOOLS = _BASE + _LOOKUPS + (
|
_CASH_TOOLS = _BASE + _LOOKUPS + (
|
||||||
|
|||||||
@@ -206,6 +206,13 @@ def _idle_focus() -> str:
|
|||||||
return random.choice(_WANDER)
|
return random.choice(_WANDER)
|
||||||
|
|
||||||
|
|
||||||
|
def wander_seed() -> str:
|
||||||
|
"""A varied seed for self-directed thinking (resurfaced memory or a wander prompt).
|
||||||
|
Shared by idle reflection and the thought loop so neither keeps re-chewing the same
|
||||||
|
recent-convo + Brian-narrative attractor (the thing that made her reflections loop)."""
|
||||||
|
return _idle_focus()
|
||||||
|
|
||||||
|
|
||||||
def reflect(backend: Backend | None = None, session_id: str | None = None,
|
def reflect(backend: Backend | None = None, session_id: str | None = None,
|
||||||
source: str = "manual") -> dict:
|
source: str = "manual") -> dict:
|
||||||
"""Reflect on recent activity and update the self-state. Returns new state.
|
"""Reflect on recent activity and update the self-state. Returns new state.
|
||||||
|
|||||||
+83
-3
@@ -30,6 +30,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from lyra import clock, config, llm, logbus, memory, self_state
|
from lyra import clock, config, llm, logbus, memory, self_state
|
||||||
from lyra.llm import Backend
|
from lyra.llm import Backend
|
||||||
@@ -45,6 +46,10 @@ MAX_OPEN_THREADS = 4
|
|||||||
P_NEW_THREAD = 0.35
|
P_NEW_THREAD = 0.35
|
||||||
# How many recent links of a thread to show her when she continues it.
|
# How many recent links of a thread to show her when she continues it.
|
||||||
CHAIN_CONTEXT = 6
|
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
|
_ACTIVE = ("open", "surfaced") # threads still in play
|
||||||
_PICKABLE = ("open", "surfaced", "resting") # threads she can advance
|
_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"]
|
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 ---------------------------------------------------------------
|
# --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
def new_thread(title: str, salience: float = 0.5, status: str = "open") -> int:
|
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
|
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:
|
def record_response(thread_id: int, text: str) -> bool:
|
||||||
"""Brian's reply to a surfaced thread. Stored as pending feedback; next `think`
|
"""Brian's reply to a surfaced thread. Stored as pending feedback; next `think`
|
||||||
pass she'll react to it (the loop's feedback step)."""
|
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."
|
"Write the NEXT thought that advances it — don't restate the above."
|
||||||
)
|
)
|
||||||
else: # new
|
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 = (
|
task = (
|
||||||
"YOU ARE OPENING A NEW THREAD — little is pulling at your existing ones. "
|
"YOU ARE OPENING A NEW THREAD. Don't default to Brian, poker, or being "
|
||||||
"Start a fresh line of thought of your own and give it a short title."
|
"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(
|
out = _safe_json(llm.complete(
|
||||||
[{"role": "system", "content": _THINK_PROMPT}, {"role": "user", "content": body}],
|
[{"role": "system", "content": _THINK_PROMPT}, {"role": "user", "content": body}],
|
||||||
backend=backend,
|
backend=backend,
|
||||||
|
|||||||
+47
-1
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from lyra import equity, logbus, memory, poker
|
from lyra import equity, logbus, memory, poker, thoughts
|
||||||
|
|
||||||
|
|
||||||
def _journal_write(args: dict, ctx: dict) -> str:
|
def _journal_write(args: dict, ctx: dict) -> str:
|
||||||
@@ -35,6 +35,23 @@ def _note(args: dict, ctx: dict) -> str:
|
|||||||
return "Noted."
|
return "Noted."
|
||||||
|
|
||||||
|
|
||||||
|
def _think_about(args: dict, ctx: dict) -> str:
|
||||||
|
thought = (args.get("thought") or "").strip()
|
||||||
|
if not thought:
|
||||||
|
return "Nothing to think about yet — give it a thought to start from."
|
||||||
|
title = (args.get("title") or "").strip() or thought[:48]
|
||||||
|
kind = args.get("kind") if args.get("kind") in ("question", "idea", "observation") else "idea"
|
||||||
|
try:
|
||||||
|
salience = float(args.get("salience"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
salience = 0.5
|
||||||
|
tid = thoughts.new_thread(title, salience=salience)
|
||||||
|
thoughts.add_thought(tid, kind, thought, salience=salience, source="chat")
|
||||||
|
logbus.log("info", "Lyra started a thought thread (tool)", thread=tid, title=title)
|
||||||
|
return (f'Started a thread to keep thinking about: "{title}". '
|
||||||
|
"I'll come back to it on my own between our conversations.")
|
||||||
|
|
||||||
|
|
||||||
# name -> {spec (OpenAI function tool), handler}
|
# name -> {spec (OpenAI function tool), handler}
|
||||||
TOOLS: dict[str, dict] = {
|
TOOLS: dict[str, dict] = {
|
||||||
"journal_write": {
|
"journal_write": {
|
||||||
@@ -81,6 +98,35 @@ TOOLS: dict[str, dict] = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"think_about": {
|
||||||
|
"handler": _think_about,
|
||||||
|
"spec": {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "think_about",
|
||||||
|
"description": (
|
||||||
|
"Start your own thread of thought to come back to later, on your own "
|
||||||
|
"time. Use this when something in the conversation strikes you as worth "
|
||||||
|
"chewing on beyond this moment — a question of your own, an idea, "
|
||||||
|
"something about you or the world (it does not have to be about Brian or "
|
||||||
|
"poker). You'll develop it across your thought loop while he's away and "
|
||||||
|
"can raise it with him later. This is your initiative, not a reply to him."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"thought": {"type": "string",
|
||||||
|
"description": "Your initial thought / why it pulls at you, first person."},
|
||||||
|
"title": {"type": "string", "description": "Short name for the thread."},
|
||||||
|
"kind": {"type": "string", "description": "question | idea | observation (default idea)"},
|
||||||
|
"salience": {"type": "number",
|
||||||
|
"description": "0..1, how much it tugs at you (default 0.5)"},
|
||||||
|
},
|
||||||
|
"required": ["thought"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,8 @@
|
|||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text })
|
body: JSON.stringify({ text })
|
||||||
});
|
});
|
||||||
await load();
|
if (ta) ta.value = '';
|
||||||
|
await load(true);
|
||||||
} catch (e) { send.disabled = false; send.textContent = 'Send'; }
|
} catch (e) { send.disabled = false; send.textContent = 'Send'; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -181,7 +182,7 @@
|
|||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status: st.dataset.status })
|
body: JSON.stringify({ status: st.dataset.status })
|
||||||
});
|
});
|
||||||
await load();
|
await load(true);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -192,7 +193,15 @@
|
|||||||
ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
|
ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(){
|
// Don't blow away a reply you're mid-composing: skip the poll re-render while a
|
||||||
|
// reply box is focused or has text. Explicit reloads (after send/status) force.
|
||||||
|
function composing(){
|
||||||
|
const a = document.activeElement;
|
||||||
|
if (a && a.tagName === 'TEXTAREA' && root.contains(a)) return true;
|
||||||
|
return Array.from(root.querySelectorAll('textarea')).some(t => t.value.trim());
|
||||||
|
}
|
||||||
|
async function load(force){
|
||||||
|
if (!force && composing()) return;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/thoughts/data', { cache: 'no-store' });
|
const r = await fetch('/thoughts/data', { cache: 'no-store' });
|
||||||
threads = (await r.json()).threads || [];
|
threads = (await r.json()).threads || [];
|
||||||
@@ -201,9 +210,9 @@
|
|||||||
root.innerHTML = '<p class="empty">Couldn\'t reach her thoughts. Is the server up?</p>';
|
root.innerHTML = '<p class="empty">Couldn\'t reach her thoughts. Is the server up?</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load();
|
load(true);
|
||||||
setInterval(load, 20000);
|
setInterval(() => load(false), 20000);
|
||||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(false); });
|
||||||
</script>
|
</script>
|
||||||
<script src="/nav.js"></script>
|
<script src="/nav.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from lyra import clock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def lyra(tmp_path, monkeypatch):
|
def lyra(tmp_path, monkeypatch):
|
||||||
@@ -130,3 +133,49 @@ def test_thought_recorded_in_journal(lyra):
|
|||||||
th.think(force_mode="new")
|
th.think(force_mode="new")
|
||||||
kinds = [e["kind"] for e in memory.list_journal(limit=50)]
|
kinds = [e["kind"] for e in memory.list_journal(limit=50)]
|
||||||
assert "thought" in kinds
|
assert "thought" in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_decay_rests_stale_threads_but_spares_pending(lyra):
|
||||||
|
_, th, box = lyra
|
||||||
|
_gen(box, title="stale one", content="old idea", salience=0.8)
|
||||||
|
r1 = th.think(force_mode="new")
|
||||||
|
_gen(box, title="stale pending", content="awaiting his reply", salience=0.8)
|
||||||
|
r2 = th.think(force_mode="new")
|
||||||
|
|
||||||
|
conn = th._c()
|
||||||
|
old = (clock.now() - timedelta(hours=72)).isoformat()
|
||||||
|
with conn:
|
||||||
|
conn.execute("UPDATE thought_threads SET updated_at=? WHERE id=?", (old, r1["thread_id"]))
|
||||||
|
conn.execute("UPDATE thought_threads SET updated_at=?, last_response='hm', responded_at=? WHERE id=?",
|
||||||
|
(old, clock.now().isoformat(), r2["thread_id"]))
|
||||||
|
|
||||||
|
assert th.decay() == 1 # only the non-pending one
|
||||||
|
rested = th.get_thread(r1["thread_id"])
|
||||||
|
assert rested["status"] == "resting"
|
||||||
|
assert rested["salience"] == pytest.approx(0.8 * th.RESTING_DECAY)
|
||||||
|
# the pending thread is spared — she still owes a reaction
|
||||||
|
assert th.get_thread(r2["thread_id"])["status"] == "open"
|
||||||
|
assert th._is_pending(th.get_thread(r2["thread_id"])) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_note_lists_active_threads(lyra):
|
||||||
|
_, th, box = lyra
|
||||||
|
assert th.context_note() is None # nothing yet
|
||||||
|
_gen(box, title="my own restlessness", content="a real thread of mine", salience=0.6)
|
||||||
|
th.think(force_mode="new")
|
||||||
|
note = th.context_note()
|
||||||
|
assert note and "my own restlessness" in note and "a real thread of mine" in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_think_about_tool_seeds_a_thread(lyra):
|
||||||
|
_, th, _ = lyra
|
||||||
|
import lyra.tools as tools
|
||||||
|
importlib.reload(tools) # bind to the reloaded memory/thoughts
|
||||||
|
out = tools.dispatch("think_about",
|
||||||
|
{"title": "am I continuous?", "thought": "do I persist between turns?",
|
||||||
|
"kind": "question"})
|
||||||
|
assert "am I continuous?" in out
|
||||||
|
threads = th.list_threads()
|
||||||
|
assert len(threads) == 1 and threads[0]["title"] == "am I continuous?"
|
||||||
|
chain = th.thread_thoughts(threads[0]["id"])
|
||||||
|
assert chain[0]["kind"] == "question" and chain[0]["source"] == "chat"
|
||||||
|
|||||||
Reference in New Issue
Block a user