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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user