diff --git a/lyra/chat.py b/lyra/chat.py index 6258b42..3b49b8f 100644 --- a/lyra/chat.py +++ b/lyra/chat.py @@ -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. 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 — # 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). diff --git a/lyra/dream.py b/lyra/dream.py index 756a84c..fc9807d 100644 --- a/lyra/dream.py +++ b/lyra/dream.py @@ -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"], 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] = [] # --- continuity: compact raw sessions into gists --- diff --git a/lyra/modes.py b/lyra/modes.py index 3155cf9..5b24c77 100644 --- a/lyra/modes.py +++ b/lyra/modes.py @@ -36,8 +36,9 @@ class Mode: # even when we're just talking. _LOOKUPS = ("player_profile", "get_villain_file", "running_stats", "recent_sessions") -# Always-available core tools (her own agency: journaling/notes). -_BASE = ("journal_write", "note") +# Always-available core tools (her own agency: journaling/notes/starting a thought +# 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). _CASH_TOOLS = _BASE + _LOOKUPS + ( diff --git a/lyra/self_state.py b/lyra/self_state.py index b25e618..ceaf668 100644 --- a/lyra/self_state.py +++ b/lyra/self_state.py @@ -206,6 +206,13 @@ def _idle_focus() -> str: 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, source: str = "manual") -> dict: """Reflect on recent activity and update the self-state. Returns new state. diff --git a/lyra/thoughts.py b/lyra/thoughts.py index 8bf5111..0b66983 100644 --- a/lyra/thoughts.py +++ b/lyra/thoughts.py @@ -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, diff --git a/lyra/tools.py b/lyra/tools.py index d98c3ed..a868ffd 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -12,7 +12,7 @@ from __future__ import annotations import json 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: @@ -35,6 +35,23 @@ def _note(args: dict, ctx: dict) -> str: 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} TOOLS: dict[str, dict] = { "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"], + }, + }, + }, + }, } diff --git a/lyra/web/static/thoughts.html b/lyra/web/static/thoughts.html index 78cff48..871fc26 100644 --- a/lyra/web/static/thoughts.html +++ b/lyra/web/static/thoughts.html @@ -170,7 +170,8 @@ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) }); - await load(); + if (ta) ta.value = ''; + await load(true); } catch (e) { send.disabled = false; send.textContent = 'Send'; } return; } @@ -181,7 +182,7 @@ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: st.dataset.status }) }); - await load(); + await load(true); } catch (e) {} } }); @@ -192,7 +193,15 @@ 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 { const r = await fetch('/thoughts/data', { cache: 'no-store' }); threads = (await r.json()).threads || []; @@ -201,9 +210,9 @@ root.innerHTML = '
Couldn\'t reach her thoughts. Is the server up?
'; } } - load(); - setInterval(load, 20000); - document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); }); + load(true); + setInterval(() => load(false), 20000); + document.addEventListener('visibilitychange', () => { if (!document.hidden) load(false); });