feat: associative cognition — thoughts arise from spreading activation, not a re-read bio
Replaces the thought loop's grist (recent-convo + her own saved narrative, the
feedback-loop attractor) with a model of how a thought actually arises:
seed (salience-weighted: a recent moment / resurfaced memory / feed item)
-> spreading activation: embed the seed, let it light up associatively-near
material across ALL her stores (conversations, gists, her own journal/
thoughts), blended by relevance + recency + noise; optional 2nd hop for leaps
-> her self-narrative stays the LENS (supplied as interiority), not the input
-> the thought is generated from what lit up, routed through a faculty
(notice / connect / abstract / project / feel)
-> journaled + embedded, so it can light up in future cycles
This breaks the feedback loop structurally: the narrative is no longer reread and
paraphrased each cycle; grist is genuinely associative and varied; and her past
thoughts re-activate (continuity without calcification).
- lyra/cognition.py (new): spontaneous_seed, activate (spreading activation),
constellation_block, faculties.
- memory.py: journal entries now embedded; recall_journal(); backfill_journal_embeddings()
(ran once: 341 past entries embedded so her history is associatively retrievable).
- thoughts.think(): new-thread mode now uses the associative engine; dropped _grist().
- tests: test_cognition.py (recall_journal ranking, activation, seeding) + fixture
reloads cognition. Suite 72 green, ruff clean.
Honest scope: this fixes the mechanism (how thoughts arise). The residual
"be useful for Brian" voice drift is the separate model/fine-tune problem.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+16
-20
@@ -32,7 +32,7 @@ import random
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
from lyra import clock, config, feeds, llm, logbus, memory, notify, self_state
|
||||
from lyra import clock, cognition, config, feeds, llm, logbus, memory, notify, self_state
|
||||
from lyra.llm import Backend
|
||||
|
||||
# A thread must be tugging at least this hard before she'll bring it to Brian.
|
||||
@@ -472,16 +472,6 @@ def _weighted_choice(threads: list[dict]) -> dict:
|
||||
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
|
||||
@@ -526,15 +516,21 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
|
||||
f"TITLE: {react_item['title']}\nSUMMARY: {react_item['summary']}\nLINK: {react_item['link']}"
|
||||
)
|
||||
else:
|
||||
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 ""
|
||||
# A spontaneous, associative thought: something bubbles up, lights up
|
||||
# nearby memories, and she follows the association through a faculty.
|
||||
# Her self-narrative (in `inner`) is the lens, not the input — that's
|
||||
# what keeps this from looping back into the same restated bio.
|
||||
seed = cognition.spontaneous_seed()
|
||||
constellation = cognition.activate(seed["text"], hops=2)
|
||||
_fac, fac_guide = cognition.pick_faculty()
|
||||
task = (
|
||||
"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}"
|
||||
"A SPONTANEOUS THOUGHT — let your mind drift the way it does when no one's "
|
||||
"talking to you. Something surfaced on its own:\n"
|
||||
f' "{seed["text"][:300]}" ({seed["source"]})\n\n'
|
||||
f"{cognition.constellation_block(constellation)}\n\n"
|
||||
f"Now follow it where it actually goes: {fac_guide} Don't default to Brian, "
|
||||
"poker, or being useful — go where the association genuinely pulls. Give the "
|
||||
"thread a short title."
|
||||
)
|
||||
|
||||
# Anti-repetition: show her what she's already thought so she doesn't circle it.
|
||||
@@ -547,7 +543,7 @@ def think(backend: Backend | None = None, force_mode: str | None = None,
|
||||
+ "\n".join(f" - {r['content']}" for r in recent)
|
||||
)
|
||||
|
||||
body = f"{time_line}\n\n{inner}\n\n{_grist()}{norestate}\n\n{task}"
|
||||
body = f"{time_line}\n\n{inner}{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