diff --git a/app/main.py b/app/main.py index 3d75232..07094c7 100644 --- a/app/main.py +++ b/app/main.py @@ -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_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. OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "cyankiwi/qwen3-coder-next:awq") OPENCODE_PROVIDER = os.environ.get("OPENCODE_PROVIDER", "vllm") @@ -375,54 +372,33 @@ async def _stream_opencode( return full_response -# -------------------------------------------------------------------------- sidecar helpers +# -------------------------------------------------------------------------- persona helpers -async def _sidecar_get_binding(session_id: str) -> dict | None: - """Fetch the current persona binding from the sidecar. Returns None on 404 or error.""" - try: - async with httpx.AsyncClient(timeout=5.0) as client: - resp = await client.get( - f"{SIDECAR_URL}/bind/{session_id}", - auth=_opencode_auth(), - ) - if resp.status_code == 200: - return resp.json() - return None - except Exception as e: - log.warning("sidecar get binding failed: %s", e) +def _slug_from_cart(cart: Any) -> str | None: + """Derive a PERSONAS slug from the operator's cart. + + The cart is the source of truth. We match by persona_name first + (case-insensitive), then by voice prefix as a fallback. + Returns None if no PERSONAS entry matches. + """ + if not cart: 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: - """Bind a persona in the sidecar. Returns True on success.""" - 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 +def _pick_system_prompt(cart: Any) -> str: + """Choose system prompt: cart (calibrated) → BT default.""" if cart and cart.system_prompt: return cart.system_prompt return BT_SYSTEM_PROMPT @@ -523,12 +499,8 @@ async def index(request: Request) -> Any: return RedirectResponse("/auth/login", status_code=302) cart = cart_store.load(user["email"]) - # Fetch current sidecar binding for display — non-blocking, best-effort. - session_id = _session_id_for_user(user["email"]) - binding = None - if not PREVIEW_MODE: - binding = await _sidecar_get_binding(session_id) - bound_slug = (binding or {}).get("slug", "") + # Derive bound persona from the cart — cart is the source of truth. + bound_slug = _slug_from_cart(cart) or "" bound_display = PERSONAS.get(bound_slug, {}).get("display", bound_slug) if bound_slug else "" return templates.TemplateResponse( @@ -645,10 +617,18 @@ class PersonaRequest(BaseModel): @app.post("/api/persona") 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 - request body, then POSTs to the sidecar's /bind route. + The chat-persona.ts opencode plugin reads the cart at every session prompt, + 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) if not user: @@ -661,18 +641,35 @@ async def set_persona(body: PersonaRequest, request: Request) -> Any: canonical = PERSONAS[slug] voice = body.voice or canonical["voice"] 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) - if not ok: - raise HTTPException(status_code=502, detail="sidecar bind failed") + cart = cart_store.load(user["email"]) + if cart is None: + # 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 { "ok": True, "slug": slug, - "display": canonical["display"], + "display": display, "voice": voice, "backend": backend, } @@ -685,15 +682,17 @@ async def get_persona(request: Request) -> Any: if not user: raise HTTPException(status_code=401, detail="not authenticated") - session_id = _session_id_for_user(user["email"]) - - binding = await _sidecar_get_binding(session_id) - if not binding: + cart = cart_store.load(user["email"]) + slug = _slug_from_cart(cart) + if not slug: return {"slug": None, "display": None, "bound": False} - - slug = binding.get("slug") - display = PERSONAS.get(slug, {}).get("display", slug) if slug else None - return {"slug": slug, "display": display, "voice": binding.get("voice"), "bound": True} + display = PERSONAS.get(slug, {}).get("display", slug) + return { + "slug": slug, + "display": display, + "voice": cart.voice if cart else None, + "bound": True, + } @app.get("/api/personas") @@ -791,9 +790,6 @@ async def chat_ws(ws: WebSocket) -> None: log.exception("EEMS context pull failed; continuing without") eems_context = "" - # Session ID for sidecar persona lookups - session_id = _session_id_for_user(user["email"]) - try: while True: payload = await ws.receive_json() @@ -868,18 +864,13 @@ async def chat_ws(ws: WebSocket) -> None: await _send_audio(ws, full) continue - # Resolve current persona — sidecar binding wins over cart default. - binding = await _sidecar_get_binding(session_id) - bound_slug = (binding or {}).get("slug") if binding else None - # Voice: sidecar binding → cart → env default - if binding and binding.get("voice"): - voice = binding["voice"] - elif cart and cart.voice: - voice = cart.voice - else: - voice = TTS_VOICE + # Re-load the cart each turn so persona switches via /api/persona + # take effect on the very next message (no reconnect required). + cart = cart_store.load(user["email"]) or cart + # Voice: cart → env default + voice = (cart.voice if cart and cart.voice else 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 oc_session = await _ensure_opencode_session(user["email"])