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);
|
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,
|
-- Brian's behind-the-scenes feedback on Lyra's outputs (chat replies, reflections,
|
||||||
-- journal/metacognition). Stored as (context, content, rating) — the shape a future
|
-- 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).
|
-- 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
|
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,
|
def add_rating(kind: str, rating: int, content: str, context: str | None = None,
|
||||||
ref: str | None = None, note: str | None = None) -> int:
|
ref: str | None = None, note: str | None = None) -> int:
|
||||||
"""Record (or replace) Brian's feedback on one Lyra output. One row per item:
|
"""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:
|
def load() -> dict:
|
||||||
"""Current self-state, or a copy of the default (not persisted until reflect).
|
"""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
|
produces (reflections, the critique, and any deliberate journal note) is also
|
||||||
appended to her permanent journal, tagged with `source`.
|
appended to her permanent journal, tagged with `source`.
|
||||||
"""
|
"""
|
||||||
cfg = config.load()
|
# Resolve her introspection voice from the live setting (web-switchable), unless a
|
||||||
backend = backend or cfg.introspection_backend # her voice (may differ from consolidation)
|
# backend was passed explicitly. If introspection is switched off, skip entirely.
|
||||||
model = model or cfg.introspection_model
|
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 = load()
|
||||||
state.setdefault("reflections", [])
|
state.setdefault("reflections", [])
|
||||||
state.setdefault("metacognition", [])
|
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
|
"""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."""
|
parse miss. `force_mode` ('new'|'continue'|'respond') is mainly for tests."""
|
||||||
cfg = config.load()
|
cfg = config.load()
|
||||||
backend = backend or cfg.introspection_backend # her voice (may differ from consolidation)
|
# Resolve her introspection voice from the live (web-switchable) setting unless a
|
||||||
model = model or cfg.introspection_model
|
# 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)
|
mode, thread = _pick("new" if force_mode == "react" else force_mode)
|
||||||
state = self_state.load()
|
state = self_state.load()
|
||||||
react_item = None
|
react_item = None
|
||||||
|
|||||||
@@ -243,6 +243,21 @@ def create_app() -> FastAPI:
|
|||||||
async def journal_data(limit: int = 300) -> dict:
|
async def journal_data(limit: int = 300) -> dict:
|
||||||
return {"entries": memory.list_journal(limit=limit)}
|
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")
|
@app.get("/thoughts")
|
||||||
async def thoughts_page() -> FileResponse:
|
async def thoughts_page() -> FileResponse:
|
||||||
"""Lyra's thought loop — threads she's been turning over, and a place to reply."""
|
"""Lyra's thought loop — threads she's been turning over, and a place to reply."""
|
||||||
|
|||||||
@@ -169,6 +169,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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;">
|
<div class="settings-section" style="margin-top: 24px;">
|
||||||
<h4>Session Management</h4>
|
<h4>Session Management</h4>
|
||||||
<p class="settings-desc">Manage your saved chat sessions:</p>
|
<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
|
// Show modal and load session list
|
||||||
settingsBtn.addEventListener("click", () => {
|
settingsBtn.addEventListener("click", () => {
|
||||||
settingsModal.classList.add("show");
|
settingsModal.classList.add("show");
|
||||||
loadSessionList(); // Refresh session list when opening settings
|
loadSessionList(); // Refresh session list when opening settings
|
||||||
|
loadIntrospection(); // reflect the current inner-voice setting
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sidebar "Settings" from another page navigates here with ?settings=1.
|
// 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"]
|
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):
|
def test_consolidation_skips_with_too_few_reflections(lyra):
|
||||||
from lyra import memory, self_state
|
from lyra import memory, self_state
|
||||||
st = self_state.load()
|
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
|
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
|
_, th, box = lyra
|
||||||
monkeypatch.setenv("INTROSPECTION_BACKEND", "local")
|
self_state.set_introspection_mode("dolphin")
|
||||||
monkeypatch.setenv("INTROSPECTION_MODEL", "dolphin3:8b")
|
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def cap(messages, backend="local", model=None):
|
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")
|
th.think(force_mode="new")
|
||||||
assert seen["backend"] == "local" and seen["model"] == "dolphin3:8b"
|
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):
|
def test_no_ping_without_ntfy(lyra, monkeypatch):
|
||||||
_, th, _ = lyra
|
_, th, _ = lyra
|
||||||
|
|||||||
Reference in New Issue
Block a user