diff --git a/.env.example b/.env.example index 573c455..be15506 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,8 @@ LYRA_TIMEZONE=America/New_York # --- External input feeds (RSS/Atom, comma-separated) --- LYRA_FEEDS=https://hnrss.org/frontpage,https://www.pokernews.com/rss.php FEED_REACT_PROB=0.5 # chance a new thought reacts to a feed item + +# --- Introspection backend (reflect/think) — her *voice*, may differ from consolidation --- +# Defaults to SUMMARY_BACKEND. Set to run her reflections/thoughts on a steerable model. +INTROSPECTION_BACKEND= +INTROSPECTION_MODEL= diff --git a/lyra/config.py b/lyra/config.py index dc47237..07e57d4 100644 --- a/lyra/config.py +++ b/lyra/config.py @@ -23,7 +23,9 @@ class Config: embed_model: str # OpenAI embedding model local_embed_model: str # Ollama embedding model embed_base_url: str # Ollama endpoint for embeddings (own box, decoupled from local chat) - summary_backend: str # "local" or "cloud" — backend used to compact memory + summary_backend: str # backend for memory consolidation (summaries/profile/narrative) + introspection_backend: str # backend for reflect()/think() — her *voice* (may differ) + introspection_model: str | None # model override for introspection (e.g. a steerable tune) db_path: Path # Proactive reach-out (ntfy push). Empty ntfy_url disables pinging. ntfy_url: str # base url, e.g. "http://10.0.0.41:8090" @@ -44,6 +46,7 @@ def _csv(name: str, default: str) -> tuple[str, ...]: def load() -> Config: + _summary = os.getenv("SUMMARY_BACKEND", "local").lower() return Config( local_base_url=os.getenv("LOCAL_BASE_URL", "http://localhost:11434"), local_model=os.getenv("LOCAL_MODEL", "qwen2.5:7b-instruct"), @@ -58,7 +61,12 @@ def load() -> Config: # Embeddings can live on their own always-on box, separate from the local # chat backend. Defaults to LOCAL_BASE_URL so existing setups are unchanged. embed_base_url=os.getenv("EMBED_BASE_URL", os.getenv("LOCAL_BASE_URL", "http://localhost:11434")), - summary_backend=os.getenv("SUMMARY_BACKEND", "local").lower(), + summary_backend=_summary, + # Introspection (reflect/think) can run on a different model than consolidation — + # e.g. a steerable tune for her voice, while the capable model keeps her memory + # accurate. Defaults to the summary backend so unset = unchanged behavior. + introspection_backend=os.getenv("INTROSPECTION_BACKEND", _summary).lower(), + introspection_model=os.getenv("INTROSPECTION_MODEL") or None, db_path=Path(os.getenv("LYRA_DB_PATH", "data/lyra.db")), ntfy_url=os.getenv("NTFY_URL", "").rstrip("/"), ntfy_topic=os.getenv("NTFY_TOPIC", "lyra"), diff --git a/lyra/dream.py b/lyra/dream.py index 4597e3f..3842031 100644 --- a/lyra/dream.py +++ b/lyra/dream.py @@ -110,13 +110,15 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict: # --- curiosity: reflect and evolve the self, then advance the thought loop --- if force or drives["curiosity"] >= THRESHOLD: - self_state.reflect(backend=backend, source="dream") # writes state + journal itself + # reflect()/think() self-resolve to the *introspection* backend (her voice), + # which can differ from the consolidation backend above — don't pass `backend`. + self_state.reflect(source="dream") # writes state + journal itself actions.append("reflected") # Thinking, continued: advance one threaded train of thought. reflect() # just refreshed her self-state, so the thought is grounded in it. A bad # think pass shouldn't sink the cycle. try: - rep = thoughts.think(backend=backend, source="dream") + rep = thoughts.think(source="dream") actions.append(f"thought ({rep['mode']})" if rep else "thought (no parse)") except Exception as exc: logbus.log("error", "thought loop failed", error=str(exc)[:200]) diff --git a/lyra/self_state.py b/lyra/self_state.py index ceaf668..c3516cf 100644 --- a/lyra/self_state.py +++ b/lyra/self_state.py @@ -214,7 +214,7 @@ def wander_seed() -> str: def reflect(backend: Backend | None = None, session_id: str | None = None, - source: str = "manual") -> dict: + source: str = "manual", model: str | None = None) -> dict: """Reflect on recent activity and update the self-state. Returns new state. Two steps, not one: she drafts a reflection, then examines her own draft — @@ -224,7 +224,9 @@ def reflect(backend: Backend | None = None, session_id: str | None = None, produces (reflections, the critique, and any deliberate journal note) is also appended to her permanent journal, tagged with `source`. """ - backend = backend or config.load().summary_backend + cfg = config.load() + backend = backend or cfg.introspection_backend # her voice (may differ from consolidation) + model = model or cfg.introspection_model state = load() state.setdefault("reflections", []) state.setdefault("metacognition", []) @@ -269,7 +271,7 @@ def reflect(backend: Backend | None = None, session_id: str | None = None, # Step 1 — draft a reflection. draft = _safe_json(llm.complete( [{"role": "system", "content": _REFLECT_PROMPT}, {"role": "user", "content": body}], - backend=backend, + backend=backend, model=model, )) # Step 2 — examine her own draft and revise it into a more honest version. @@ -279,7 +281,7 @@ def reflect(backend: Backend | None = None, session_id: str | None = None, revised = _safe_json(llm.complete( [{"role": "system", "content": _EXAMINE_PROMPT}, {"role": "user", "content": examine_body}], - backend=backend, + backend=backend, model=model, )) if revised: # fall back to the draft if the examine step doesn't parse update = revised diff --git a/lyra/thoughts.py b/lyra/thoughts.py index 48d8f3d..41bf7e5 100644 --- a/lyra/thoughts.py +++ b/lyra/thoughts.py @@ -473,11 +473,12 @@ def _weighted_choice(threads: list[dict]) -> dict: def think(backend: Backend | None = None, force_mode: str | None = None, - source: str = "dream") -> dict | None: + source: str = "dream", model: str | None = None) -> dict | None: """Advance the thought loop by one step. Returns a small report, or None on a parse miss. `force_mode` ('new'|'continue'|'respond') is mainly for tests.""" cfg = config.load() - backend = backend or cfg.summary_backend + backend = backend or cfg.introspection_backend # her voice (may differ from consolidation) + model = model or cfg.introspection_model mode, thread = _pick("new" if force_mode == "react" else force_mode) state = self_state.load() react_item = None @@ -546,7 +547,7 @@ def think(backend: Backend | None = None, force_mode: str | None = None, 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, + backend=backend, model=model, )) if not out or not (out.get("content") or "").strip(): logbus.log("info", "thought loop", mode=mode, result="no parse") diff --git a/tests/test_thoughts.py b/tests/test_thoughts.py index b2c1286..d972ee3 100644 --- a/tests/test_thoughts.py +++ b/tests/test_thoughts.py @@ -30,7 +30,8 @@ def lyra(tmp_path, monkeypatch): # Canned LLM: tests set `box["next"]` to the dict think() should "generate". box = {"next": {}} - monkeypatch.setattr(thoughts.llm, "complete", lambda messages, backend=None: json.dumps(box["next"])) + monkeypatch.setattr(thoughts.llm, "complete", + lambda messages, backend=None, model=None: json.dumps(box["next"])) # Keep the loop offline + silent by default: no feed fetch, no push. monkeypatch.setattr(thoughts.feeds, "next_item", lambda **k: None) monkeypatch.setattr(thoughts.notify, "push", lambda **k: False) @@ -274,6 +275,22 @@ def test_ping_salience_floor_is_optional(lyra, monkeypatch): assert th.maybe_ping(1, "hey", 0.8) is True +def test_think_routes_to_introspection_backend(lyra, monkeypatch): + _, th, box = lyra + monkeypatch.setenv("INTROSPECTION_BACKEND", "local") + monkeypatch.setenv("INTROSPECTION_MODEL", "dolphin3:8b") + seen = {} + + def cap(messages, backend="local", model=None): + seen["backend"], seen["model"] = backend, model + return json.dumps(box["next"]) + + monkeypatch.setattr(th.llm, "complete", cap) + _gen(box, content="a thought") + th.think(force_mode="new") + assert seen["backend"] == "local" and seen["model"] == "dolphin3:8b" + + def test_no_ping_without_ntfy(lyra, monkeypatch): _, th, _ = lyra sent = []