From 8a3c9b27010a04ab7febb5f7523f1332fe1b694c Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 24 Jun 2026 16:32:42 +0000 Subject: [PATCH] feat: she can suggest + switch modes (set_mode tool + mode awareness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "She suggests, you confirm" — instead of brittle keyword→mode mapping, she's given awareness of her modes + the ability to switch, and her judgment decides when to offer (the model reads "should I drive to Cleveland?" vs "should I fold the river" far better than a lexicon could). - tools: set_mode(mode) — switches the session's mode; in _BASE (all modes). - mind: a per-turn mode-menu note listing her modes + "offer a switch when the work clearly shifts; on his yes, call set_mode; don't nag." - Sticky mode stays manual otherwise; Poker still auto-engages on session start. - test: set_mode switches + rejects unknown. Suite 97 green, ruff clean. Note: server-side switch takes effect next turn; the UI badge syncs on next mode load (cosmetic lag). Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/mind.py | 18 ++++++++++++++++++ lyra/modes.py | 2 +- lyra/tools.py | 20 ++++++++++++++++++++ tests/test_modes.py | 10 ++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/lyra/mind.py b/lyra/mind.py index 31202d7..12d4b88 100644 --- a/lyra/mind.py +++ b/lyra/mind.py @@ -74,6 +74,20 @@ def _inner_life_note() -> Message | None: return {"role": "system", "content": "\n\n".join(parts)} +def _mode_menu_note(current: modes.Mode | None) -> str: + """Tell her the modes she can switch to + when to offer it. She judges the fit + (the model reads context far better than a keyword would).""" + menu = ", ".join(f"{m.label} ({k})" for k, m in modes.MODES.items()) + cur = current.label if current else "Talk" + return ( + f"Your modes: {menu}. You're in {cur} right now. If Brian is clearly doing a " + "different kind of work than your current mode — weighing a real decision while " + "you're in Talk, digging into engineering, reviewing poker away from the table — " + "briefly OFFER to switch (one short line). If he says yes, call set_mode with the " + "mode key. Don't offer every turn or nag; only when it genuinely fits and serves him." + ) + + def _now_note() -> Message: """Current wall-clock time + how long since Brian last said anything.""" line = f"The current date and time is {clock.stamp()}." @@ -109,6 +123,10 @@ def build_messages(session_id: str, user_msg: str, if mode and mode.card: messages.append({"role": "system", "content": mode.card}) + # Mode awareness: she can offer to switch when the work clearly shifts (she decides + # when — better than a keyword guess). One line, on his yes she calls set_mode. + messages.append({"role": "system", "content": _mode_menu_note(mode)}) + # Live ritual state (e.g. Alligator Blood ON) — dynamic, rides with the card. state_note = _mode_state_note(mode) if state_note: diff --git a/lyra/modes.py b/lyra/modes.py index efec2f7..175d7ee 100644 --- a/lyra/modes.py +++ b/lyra/modes.py @@ -42,7 +42,7 @@ _LOOKUPS = ("player_profile", "get_villain_file", "running_stats", "recent_sessi # Always-available core tools (her own agency: journaling/notes/starting a thought # thread, and capturing Brian's reaction when she raises one of her thoughts in chat). -_BASE = ("journal_write", "note", "think_about", "thought_response") +_BASE = ("journal_write", "note", "think_about", "thought_response", "set_mode") # The full live cash-game toolset (incl. Brian's mental-game rituals). _CASH_TOOLS = _BASE + _LOOKUPS + ( diff --git a/lyra/tools.py b/lyra/tools.py index afd45bd..5ecbd94 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -52,6 +52,20 @@ def _think_about(args: dict, ctx: dict) -> str: "I'll come back to it on my own between our conversations.") +def _set_mode(args: dict, ctx: dict) -> str: + from lyra import modes + key = (args.get("mode") or "").strip().lower() + m = modes.MODES.get(key) + if not m: + return f"(unknown mode '{key}'; valid: {', '.join(modes.MODES)})" + sid = ctx.get("session_id") + if not sid: + return "(no session to switch)" + memory.set_session_mode(sid, key) + logbus.log("info", "mode switch (tool)", session=sid, mode=key) + return f"Switched to {m.label} mode." + + def _thought_response(args: dict, ctx: dict) -> str: try: tid = int(args.get("thread_id")) @@ -452,6 +466,12 @@ _S = {"type": "string"} _N = {"type": "number"} TOOLS.update({ + "set_mode": {"handler": _set_mode, "spec": _f( + "set_mode", + "Switch your conversation mode when the work clearly shifts and Brian's agreed to it. " + "Offer first ('want me in Decide for this?'), then call this on his yes.", + {"mode": {**_S, "description": "Mode key: conversation | poker_cash | build | explore | study | decide"}}, + ["mode"])}, "thought_response": {"handler": _thought_response, "spec": _f( "thought_response", "When you've brought one of your own thoughts/threads to Brian and he responds to " diff --git a/tests/test_modes.py b/tests/test_modes.py index 8543714..df787fc 100644 --- a/tests/test_modes.py +++ b/tests/test_modes.py @@ -47,6 +47,16 @@ def test_every_mode_tool_exists(lyra): assert set(mode.tools) <= set(tools.TOOLS), f"{mode.key} references unknown tools" +def test_set_mode_tool_switches_session(lyra): + memory, _, _, tools = lyra + memory.ensure_session("s1") + out = tools.dispatch("set_mode", {"mode": "decide"}, {"session_id": "s1"}) + assert "Decide" in out and memory.get_session_mode("s1") == "decide" + # unknown mode is handled, session unchanged + assert "unknown" in tools.dispatch("set_mode", {"mode": "nope"}, {"session_id": "s1"}).lower() + assert memory.get_session_mode("s1") == "decide" + + def test_work_modes_present_and_gated(lyra): _, _, modes, tools = lyra # the full set Brian chose