From a7966e4babf040f4fd785088e207fc72615bd795 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 22 Jun 2026 19:16:35 +0000 Subject: [PATCH] feat: web switch for her inner voice (Dolphin/3090 | Qwen-32B/MI50 | Off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Her introspection (reflect/think) voice is now switchable live from the web settings, read each cycle by the dream loop — so Brian can flip it off the 3090 before gaming without touching config or restarting. - memory: runtime key/value settings table + get_setting/set_setting. - self_state: INTROSPECTION_MODES (dolphin=local/dolphin3:8b, mi50=Qwen-32B, off=paused) + introspection_target()/set_introspection_mode(); default "dolphin". reflect() resolves from the live setting and SKIPS entirely when off. - thoughts.think(): same resolution + skip-when-off. - server: GET/POST /settings/introspection. - index.html: "Inner Voice (introspection)" selector in Settings, applies instantly. - tests: routing (dolphin/mi50), off-skip for think + reflect. Suite 77, ruff clean. Default = Dolphin on the 3090 (richer voice). Flip to MI50 or Off in Settings before gaming — that was the GPU-contention culprit. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/memory.py | 22 ++++++++++++++++++++ lyra/self_state.py | 41 +++++++++++++++++++++++++++++++++++--- lyra/thoughts.py | 10 ++++++++-- lyra/web/server.py | 15 ++++++++++++++ lyra/web/static/index.html | 32 +++++++++++++++++++++++++++++ tests/test_reflect.py | 8 ++++++++ tests/test_thoughts.py | 19 +++++++++++++++--- 7 files changed, 139 insertions(+), 8 deletions(-) diff --git a/lyra/memory.py b/lyra/memory.py index ad409e4..0b9d633 100644 --- a/lyra/memory.py +++ b/lyra/memory.py @@ -95,6 +95,12 @@ CREATE TABLE IF NOT EXISTS journal ( ); CREATE INDEX IF NOT EXISTS idx_journal_created ON journal(created_at); +-- Small runtime key/value settings (UI-tunable, read live by the dream loop). +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); + -- Brian's behind-the-scenes feedback on Lyra's outputs (chat replies, reflections, -- journal/metacognition). Stored as (context, content, rating) — the shape a future -- fine-tune / preference dataset wants. One row per rated item (re-rating updates it). @@ -639,6 +645,22 @@ def backfill_journal_embeddings(limit: int | None = None) -> int: return n +def get_setting(key: str, default: str | None = None) -> str | None: + """A runtime setting value (UI-tunable), or `default` if unset.""" + r = _connection().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return r["value"] if r else default + + +def set_setting(key: str, value: str) -> None: + conn = _connection() + with conn: + conn.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, str(value)), + ) + + def add_rating(kind: str, rating: int, content: str, context: str | None = None, ref: str | None = None, note: str | None = None) -> int: """Record (or replace) Brian's feedback on one Lyra output. One row per item: diff --git a/lyra/self_state.py b/lyra/self_state.py index 585cb5c..1b02cc3 100644 --- a/lyra/self_state.py +++ b/lyra/self_state.py @@ -136,6 +136,36 @@ Respond with ONLY a JSON object — the same shape as the draft, plus "self_crit }""" +# Her introspection (reflect/think) voice — switchable live from the web settings. +# "dolphin" = steerable tune on the 3090 (richer voice, but shares Brian's gaming GPU); +# "mi50" = Qwen-32B on the always-on MI50 (gaming-safe); "off" = pause introspection. +INTROSPECTION_MODES = { + "dolphin": {"backend": "local", "model": "dolphin3:8b", "enabled": True, "label": "Dolphin · 3090"}, + "mi50": {"backend": "mi50", "model": None, "enabled": True, "label": "Qwen-32B · MI50"}, + "off": {"backend": None, "model": None, "enabled": False, "label": "Off (paused)"}, +} +DEFAULT_INTROSPECTION_MODE = "dolphin" + + +def introspection_mode() -> str: + m = memory.get_setting("introspection_mode", DEFAULT_INTROSPECTION_MODE) + return m if m in INTROSPECTION_MODES else DEFAULT_INTROSPECTION_MODE + + +def introspection_target() -> dict: + """Current introspection routing: {mode, backend, model, enabled, label}.""" + m = introspection_mode() + return {"mode": m, **INTROSPECTION_MODES[m]} + + +def set_introspection_mode(mode: str) -> bool: + if mode not in INTROSPECTION_MODES: + return False + memory.set_setting("introspection_mode", mode) + logbus.log("info", "introspection mode set", mode=mode) + return True + + def load() -> dict: """Current self-state, or a copy of the default (not persisted until reflect). @@ -240,9 +270,14 @@ 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`. """ - cfg = config.load() - backend = backend or cfg.introspection_backend # her voice (may differ from consolidation) - model = model or cfg.introspection_model + # Resolve her introspection voice from the live setting (web-switchable), unless a + # backend was passed explicitly. If introspection is switched off, skip entirely. + if backend is None and model is None: + tgt = introspection_target() + if not tgt["enabled"]: + logbus.log("info", "reflection skipped — introspection off") + return load() + backend, model = tgt["backend"], tgt["model"] state = load() state.setdefault("reflections", []) state.setdefault("metacognition", []) diff --git a/lyra/thoughts.py b/lyra/thoughts.py index 41bf7e5..688a3b8 100644 --- a/lyra/thoughts.py +++ b/lyra/thoughts.py @@ -477,8 +477,14 @@ def think(backend: Backend | None = None, force_mode: str | None = 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.introspection_backend # her voice (may differ from consolidation) - model = model or cfg.introspection_model + # Resolve her introspection voice from the live (web-switchable) setting unless a + # backend was passed explicitly; skip entirely if introspection is switched off. + if backend is None and model is None: + tgt = self_state.introspection_target() + if not tgt["enabled"]: + logbus.log("info", "thought skipped — introspection off") + return None + backend, model = tgt["backend"], tgt["model"] mode, thread = _pick("new" if force_mode == "react" else force_mode) state = self_state.load() react_item = None diff --git a/lyra/web/server.py b/lyra/web/server.py index 3a19ad0..2759781 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -243,6 +243,21 @@ def create_app() -> FastAPI: async def journal_data(limit: int = 300) -> dict: return {"entries": memory.list_journal(limit=limit)} + @app.get("/settings/introspection") + async def get_introspection() -> dict: + """Current introspection (her inner voice) routing + the available options.""" + tgt = self_state.introspection_target() + return {"mode": tgt["mode"], + "options": [{"key": k, "label": v["label"]} + for k, v in self_state.INTROSPECTION_MODES.items()]} + + @app.post("/settings/introspection") + async def set_introspection(request: Request) -> dict: + """Switch her inner voice: dolphin (3090) | mi50 (gaming-safe) | off.""" + b = await request.json() + ok = await asyncio.to_thread(self_state.set_introspection_mode, b.get("mode", "")) + return {"ok": ok, "mode": self_state.introspection_target()["mode"]} + @app.get("/thoughts") async def thoughts_page() -> FileResponse: """Lyra's thought loop — threads she's been turning over, and a place to reply.""" diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index 08d6771..94a471c 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -169,6 +169,17 @@ +
+

Inner Voice (introspection)

+

Which model runs her reflections & thoughts (her dream loop). + Dolphin is richer but shares the 3090 — switch to MI50 or Off before gaming.

+ +
+

Session Management

Manage your saved chat sessions:

@@ -979,10 +990,31 @@ } } + // Inner-voice (introspection) switch — applies instantly, read live by the dream loop. + const introspectionSel = document.getElementById("introspectionMode"); + async function loadIntrospection() { + try { + const r = await fetch("/settings/introspection", { cache: "no-store" }); + const d = await r.json(); + if (d.mode) introspectionSel.value = d.mode; + } catch (e) {} + } + if (introspectionSel) { + introspectionSel.addEventListener("change", async () => { + try { + await fetch("/settings/introspection", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: introspectionSel.value }) + }); + } catch (e) {} + }); + } + // Show modal and load session list settingsBtn.addEventListener("click", () => { settingsModal.classList.add("show"); loadSessionList(); // Refresh session list when opening settings + loadIntrospection(); // reflect the current inner-voice setting }); // Sidebar "Settings" from another page navigates here with ?settings=1. diff --git a/tests/test_reflect.py b/tests/test_reflect.py index 8f3d883..9e14531 100644 --- a/tests/test_reflect.py +++ b/tests/test_reflect.py @@ -99,6 +99,14 @@ def test_consolidation_rebuilds_narrative_from_reflections(lyra, monkeypatch): assert "steady" in out["relationship"] +def test_reflect_skipped_when_introspection_off(lyra): + calls = lyra + from lyra import self_state + self_state.set_introspection_mode("off") + self_state.reflect() + assert calls == [] # paused -> no draft/examine LLM calls at all + + def test_consolidation_skips_with_too_few_reflections(lyra): from lyra import memory, self_state st = self_state.load() diff --git a/tests/test_thoughts.py b/tests/test_thoughts.py index d972ee3..08d60b8 100644 --- a/tests/test_thoughts.py +++ b/tests/test_thoughts.py @@ -275,10 +275,10 @@ 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): +def test_think_routes_to_selected_voice(lyra, monkeypatch): + from lyra import self_state _, th, box = lyra - monkeypatch.setenv("INTROSPECTION_BACKEND", "local") - monkeypatch.setenv("INTROSPECTION_MODEL", "dolphin3:8b") + self_state.set_introspection_mode("dolphin") seen = {} def cap(messages, backend="local", model=None): @@ -290,6 +290,19 @@ def test_think_routes_to_introspection_backend(lyra, monkeypatch): th.think(force_mode="new") assert seen["backend"] == "local" and seen["model"] == "dolphin3:8b" + self_state.set_introspection_mode("mi50") # gaming-safe: Qwen-32B on the MI50 + th.think(force_mode="new") + assert seen["backend"] == "mi50" and seen["model"] is None + + +def test_think_skipped_when_introspection_off(lyra): + from lyra import self_state + _, th, box = lyra + self_state.set_introspection_mode("off") + _gen(box, content="should not be generated") + assert th.think(force_mode="new") is None # paused -> no thought, no LLM call + assert th.list_threads() == [] + def test_no_ping_without_ntfy(lyra, monkeypatch): _, th, _ = lyra