refactor: drop dead sidecar, cart becomes source of truth for persona

The sin:4098 sidecar service has been dead since the last reboot, and the
real binding mechanism is the chat-persona.ts opencode plugin (now in
madcat-plugin) that reads the operator cart file directly. The sidecar
HTTP round-trip was vestigial.

Changes:
- Remove SIDECAR_URL env, _sidecar_get_binding(), _sidecar_bind(),
  _session_id_for_user() — all dead code paths.
- Add _slug_from_cart(cart): derives canonical PERSONAS slug from a cart's
  persona_name (case-insensitive) or voice prefix fallback.
- Simplify _pick_system_prompt(cart): cart.system_prompt (calibrated) →
  BT default. No more sidecar override layer.
- / route: bound_slug from _slug_from_cart(cart) instead of sidecar lookup.
- /api/persona POST: mutates the cart file in-place (preserves calibrated
  UI prefs), creates a minimal cart for fresh operators. The opencode plugin
  re-reads the cart on every turn, so switches take effect on the very next
  message — no session reconnect.
- /api/persona/current GET: reads from cart, returns {slug, display, voice,
  bound}.
- WS handler: re-loads cart at the start of each turn for live persona
  switching; voice falls back cart.voice → TTS_VOICE.

Operator cart at /home/madcat/.local/share/chat-saiden/operators/<email>.json
is now the single source of truth.
This commit is contained in:
marauder-actual
2026-05-29 22:54:24 +02:00
parent 2ddf6b7741
commit a783da7415
+74 -83
View File
@@ -64,9 +64,6 @@ PREVIEW_MODE = os.environ.get("PREVIEW_MODE", "").lower() in ("1", "true", "yes"
OPENCODE_URL = os.environ.get("OPENCODE_URL", "http://sin:4096").rstrip("/") OPENCODE_URL = os.environ.get("OPENCODE_URL", "http://sin:4096").rstrip("/")
OPENCODE_PASSWORD = os.environ.get("OPENCODE_PASSWORD", "") OPENCODE_PASSWORD = os.environ.get("OPENCODE_PASSWORD", "")
# Sidecar: persona bind/unbind routes.
SIDECAR_URL = os.environ.get("SIDECAR_URL", "http://sin:4098").rstrip("/")
# Model to use for opencode sessions. # Model to use for opencode sessions.
OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "cyankiwi/qwen3-coder-next:awq") OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "cyankiwi/qwen3-coder-next:awq")
OPENCODE_PROVIDER = os.environ.get("OPENCODE_PROVIDER", "vllm") OPENCODE_PROVIDER = os.environ.get("OPENCODE_PROVIDER", "vllm")
@@ -375,54 +372,33 @@ async def _stream_opencode(
return full_response return full_response
# -------------------------------------------------------------------------- sidecar helpers # -------------------------------------------------------------------------- persona helpers
async def _sidecar_get_binding(session_id: str) -> dict | None: def _slug_from_cart(cart: Any) -> str | None:
"""Fetch the current persona binding from the sidecar. Returns None on 404 or error.""" """Derive a PERSONAS slug from the operator's cart.
try:
async with httpx.AsyncClient(timeout=5.0) as client: The cart is the source of truth. We match by persona_name first
resp = await client.get( (case-insensitive), then by voice prefix as a fallback.
f"{SIDECAR_URL}/bind/{session_id}", Returns None if no PERSONAS entry matches.
auth=_opencode_auth(), """
) if not cart:
if resp.status_code == 200:
return resp.json()
return None
except Exception as e:
log.warning("sidecar get binding failed: %s", e)
return None return None
name = (cart.persona_name or "").strip().lower()
# Match canonical persona by name (BT-7274 → bt7274, FRIDAY → friday, Samantha → samantha)
for slug, spec in PERSONAS.items():
if (spec.get("display") or "").lower() == name or slug == name:
return slug
# Fallback: match by voice prefix (e.g. samantha-pl → samantha)
voice = (cart.voice or "").lower()
for slug in PERSONAS:
if voice.startswith(slug):
return slug
return None
async def _sidecar_bind(session_id: str, slug: str, voice: str, backend: str) -> bool: def _pick_system_prompt(cart: Any) -> str:
"""Bind a persona in the sidecar. Returns True on success.""" """Choose system prompt: cart (calibrated) → BT default."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.post(
f"{SIDECAR_URL}/bind",
auth=_opencode_auth(),
json={"sessionId": session_id, "persona": {"slug": slug, "voice": voice, "backend": backend}},
)
return resp.status_code == 200
except Exception as e:
log.warning("sidecar bind failed: %s", e)
return False
def _session_id_for_user(email: str) -> str:
"""Derive a stable opencode session ID for an operator email."""
# Use a deterministic slug so the sidecar binding persists across reconnects.
import hashlib
return "chat-" + hashlib.sha256(email.encode()).hexdigest()[:16]
def _pick_system_prompt(slug: str | None, cart: Any) -> str:
"""Choose system prompt: sidecar slug override → cart → BT default."""
if slug and slug in PERSONAS:
override = PERSONAS[slug].get("system_prompt")
if override:
return override
# cart may have a calibrated system_prompt
if cart and cart.system_prompt: if cart and cart.system_prompt:
return cart.system_prompt return cart.system_prompt
return BT_SYSTEM_PROMPT return BT_SYSTEM_PROMPT
@@ -523,12 +499,8 @@ async def index(request: Request) -> Any:
return RedirectResponse("/auth/login", status_code=302) return RedirectResponse("/auth/login", status_code=302)
cart = cart_store.load(user["email"]) cart = cart_store.load(user["email"])
# Fetch current sidecar binding for display — non-blocking, best-effort. # Derive bound persona from the cart — cart is the source of truth.
session_id = _session_id_for_user(user["email"]) bound_slug = _slug_from_cart(cart) or ""
binding = None
if not PREVIEW_MODE:
binding = await _sidecar_get_binding(session_id)
bound_slug = (binding or {}).get("slug", "")
bound_display = PERSONAS.get(bound_slug, {}).get("display", bound_slug) if bound_slug else "" bound_display = PERSONAS.get(bound_slug, {}).get("display", bound_slug) if bound_slug else ""
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -645,10 +617,18 @@ class PersonaRequest(BaseModel):
@app.post("/api/persona") @app.post("/api/persona")
async def set_persona(body: PersonaRequest, request: Request) -> Any: async def set_persona(body: PersonaRequest, request: Request) -> Any:
"""Bind a persona for this operator's opencode session. """Switch the operator's persona by mutating their cart in-place.
Looks up the canonical config from PERSONAS, merges any overrides from the The chat-persona.ts opencode plugin reads the cart at every session prompt,
request body, then POSTs to the sidecar's /bind route. so the new persona takes effect on the very next message — no reconnect or
session restart needed.
Behaviour:
- Looks up canonical voice/backend/system_prompt from PERSONAS.
- Loads the operator's existing cart (preserving calibrated UI prefs).
- If no cart yet, creates one with sensible defaults so the switcher works
even before calibration.
- Persists the updated cart.
""" """
user = current_user(request) user = current_user(request)
if not user: if not user:
@@ -661,18 +641,35 @@ async def set_persona(body: PersonaRequest, request: Request) -> Any:
canonical = PERSONAS[slug] canonical = PERSONAS[slug]
voice = body.voice or canonical["voice"] voice = body.voice or canonical["voice"]
backend = body.backend or canonical["backend"] backend = body.backend or canonical["backend"]
session_id = _session_id_for_user(user["email"]) display = canonical["display"]
system_prompt = canonical.get("system_prompt") or BT_SYSTEM_PROMPT
log.info("%s binding persona %r (voice=%s backend=%s)", user["email"], slug, voice, backend) log.info("%s switching persona %r (voice=%s backend=%s)", user["email"], slug, voice, backend)
ok = await _sidecar_bind(session_id, slug, voice, backend) cart = cart_store.load(user["email"])
if not ok: if cart is None:
raise HTTPException(status_code=502, detail="sidecar bind failed") # Fresh operator — create a minimal cart with this persona.
cart = cart_store.Cart(
operator_email=user["email"],
operator_name=user.get("name") or user["email"].split("@")[0],
persona_name=display,
cart_tag=f"{slug}-{user['email'].split('@')[0]}".lower().replace(".", "-"),
language="en",
voice=voice,
system_prompt=system_prompt,
)
else:
# Preserve calibrated UI prefs + operator_name; update persona identity.
cart.persona_name = display
cart.voice = voice
cart.system_prompt = system_prompt
cart_store.save(cart)
return { return {
"ok": True, "ok": True,
"slug": slug, "slug": slug,
"display": canonical["display"], "display": display,
"voice": voice, "voice": voice,
"backend": backend, "backend": backend,
} }
@@ -685,15 +682,17 @@ async def get_persona(request: Request) -> Any:
if not user: if not user:
raise HTTPException(status_code=401, detail="not authenticated") raise HTTPException(status_code=401, detail="not authenticated")
session_id = _session_id_for_user(user["email"]) cart = cart_store.load(user["email"])
slug = _slug_from_cart(cart)
binding = await _sidecar_get_binding(session_id) if not slug:
if not binding:
return {"slug": None, "display": None, "bound": False} return {"slug": None, "display": None, "bound": False}
display = PERSONAS.get(slug, {}).get("display", slug)
slug = binding.get("slug") return {
display = PERSONAS.get(slug, {}).get("display", slug) if slug else None "slug": slug,
return {"slug": slug, "display": display, "voice": binding.get("voice"), "bound": True} "display": display,
"voice": cart.voice if cart else None,
"bound": True,
}
@app.get("/api/personas") @app.get("/api/personas")
@@ -791,9 +790,6 @@ async def chat_ws(ws: WebSocket) -> None:
log.exception("EEMS context pull failed; continuing without") log.exception("EEMS context pull failed; continuing without")
eems_context = "" eems_context = ""
# Session ID for sidecar persona lookups
session_id = _session_id_for_user(user["email"])
try: try:
while True: while True:
payload = await ws.receive_json() payload = await ws.receive_json()
@@ -868,18 +864,13 @@ async def chat_ws(ws: WebSocket) -> None:
await _send_audio(ws, full) await _send_audio(ws, full)
continue continue
# Resolve current persona — sidecar binding wins over cart default. # Re-load the cart each turn so persona switches via /api/persona
binding = await _sidecar_get_binding(session_id) # take effect on the very next message (no reconnect required).
bound_slug = (binding or {}).get("slug") if binding else None cart = cart_store.load(user["email"]) or cart
# Voice: sidecar binding → cart → env default # Voice: cart → env default
if binding and binding.get("voice"): voice = (cart.voice if cart and cart.voice else TTS_VOICE)
voice = binding["voice"]
elif cart and cart.voice:
voice = cart.voice
else:
voice = TTS_VOICE
system_prompt = _pick_system_prompt(bound_slug, cart) + eems_context system_prompt = _pick_system_prompt(cart) + eems_context
# Send to opencode and stream response via SSE # Send to opencode and stream response via SSE
oc_session = await _ensure_opencode_session(user["email"]) oc_session = await _ensure_opencode_session(user["email"])