feat: web switch for her inner voice (Dolphin/3090 | Qwen-32B/MI50 | Off)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 19:16:35 +00:00
parent a705e573a9
commit a7966e4bab
7 changed files with 139 additions and 8 deletions
+22
View File
@@ -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:
+38 -3
View File
@@ -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", [])
+8 -2
View File
@@ -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
+15
View File
@@ -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."""
+32
View File
@@ -169,6 +169,17 @@
</select>
</div>
<div class="settings-section" style="margin-top: 24px;">
<h4>Inner Voice (introspection)</h4>
<p class="settings-desc">Which model runs her reflections & thoughts (her dream loop).
Dolphin is richer but shares the 3090 — switch to MI50 or Off before gaming.</p>
<select id="introspectionMode">
<option value="dolphin">Dolphin · 3090 (richer voice)</option>
<option value="mi50">Qwen-32B · MI50 (gaming-safe)</option>
<option value="off">Off (pause her thinking)</option>
</select>
</div>
<div class="settings-section" style="margin-top: 24px;">
<h4>Session Management</h4>
<p class="settings-desc">Manage your saved chat sessions:</p>
@@ -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.
+8
View File
@@ -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()
+16 -3
View File
@@ -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