"""Resolving the active identity into a context profile (spec §5, §6.3). This is the join between Phase 5's identity runtime and Phase 4's conversation loop. The runtime decides *who* is being talked to and hands out a :class:`~chittios_core.identity.session.Session`; this turns that session into the :class:`~chittios_core.context.profile.Profile` the conversation runs under — the persona for the system prompt, the role for the tool gate, the namespace for memory. Pure, then stateful ------------------- Two objects, because there are two jobs. :class:`ProfileResolver` is a pure function of an identity: same session in, same profile out, no state, no I/O. :class:`ContextManager` is the thin stateful shell the runtime drives — it remembers the *current* profile and swaps it when the active identity changes, so ``current_profile`` is always the one in force. Splitting them keeps the policy testable in isolation and the state trivial. Guest is the safe default ------------------------- Before anyone is recognised, and any time recognition drops, the profile is the guest one: no private memory, minimal tools, child-safe content. A :class:`ContextManager` therefore *starts* on the guest profile rather than on nothing, so there is never a window where the active persona is undefined. Why a Protocol and not ``Session`` directly -------------------------------------------- The resolver needs three facts about the active identity — is it a guest, what band is it, what namespace does it own — and nothing else. Depending on the whole :class:`~chittios_core.identity.session.Session` would couple context resolution to fields it never reads and make it awkward to test with a fake. :class:`ActiveIdentity` names exactly the three facts; ``Session`` satisfies it structurally, and a test can satisfy it with three lines. """ from __future__ import annotations from dataclasses import dataclass, replace from typing import Final, Protocol, runtime_checkable from chittios_core.context.profile import ( ADULT_PROFILE, CHILD_PROFILE, GUEST_PROFILE, Profile, ) from chittios_core.identity.types import AgeBand @runtime_checkable class ActiveIdentity(Protocol): """The slice of the active identity that context resolution reads. Exactly the three facts a :class:`Profile` derives from. :class:`~chittios_core.identity.session.Session` satisfies this structurally — its ``is_guest``, ``age_band``, and ``memory_namespace`` line up — so the runtime passes its session straight in, and a test passes any object with the same three members. """ @property def is_guest(self) -> bool: """Whether this identity was *not* backed by a biometric match. The gate between the guest profile and a member profile. A guest is anyone the robot could not verify, however friendly or familiar. """ ... @property def age_band(self) -> AgeBand: """The identity's own band, selecting the child or adult persona. This is the active speaker's band, not the room's. Content-policy tightening for a child *present* while an adult speaks stays on the session (``requires_child_safe_content``) and is deliberately not folded into persona selection: the adult keeps their own voice, tools, and memory, exactly as the session boundary keeps their scopes. """ ... @property def memory_namespace(self) -> str: """The namespace whose private memory is in scope — the member's id.""" ... @dataclass(frozen=True, slots=True) class ProfileResolver: """Turns an active identity into its context profile. Pure and injectable. The three templates are fields, not hardcoded lookups, so a household can supply its own personas without touching this logic:: resolver = ProfileResolver(adult=my_tuned_adult_profile) The defaults are the age-band profiles from :mod:`chittios_core.context.profile`. """ adult: Profile = ADULT_PROFILE child: Profile = CHILD_PROFILE guest: Profile = GUEST_PROFILE def resolve(self, active: ActiveIdentity | None) -> Profile: """Return the profile for ``active``; the guest profile for no one. A missing identity (``None``) and a guest session resolve identically — both mean "nobody is verified" — and both return the guest profile *as-is*, ignoring any namespace the caller may have attached. That is the guarantee behind "a guest never sees a member's memory": the guest profile is a constant with an empty namespace, and no verified member's id can be substituted into it here. For a verified member, the persona and role come from the age-band template and the memory namespace is re-keyed to *this* member's own namespace, so the returned profile opens their memory and no one else's. """ if active is None or active.is_guest: return self.guest template = self.child if active.age_band is AgeBand.CHILD else self.adult return replace(template, memory_namespace=active.memory_namespace) class ContextManager: """Holds the profile currently in force and swaps it when identity changes. The stateful counterpart to the pure resolver, and the object Phase 4 reads ``current_profile`` from when building a turn. It is driven by the identity runtime: on each step where the active identity changed, call :meth:`on_active_changed` with the new session. Starts on the guest profile so the active persona is defined from the first moment — before anyone is recognised, ChittiOS is already a guest-safe companion rather than a companion with no persona at all. """ def __init__( self, resolver: ProfileResolver | None = None, *, initial: Profile = GUEST_PROFILE, ) -> None: """Build a manager. Args: resolver: The resolution policy. Defaults to a standard :class:`ProfileResolver`; inject one to supply custom personas. initial: The profile in force before any identity is seen. Defaults to the guest profile, the least-privilege starting point. """ self._resolver: Final = resolver if resolver is not None else ProfileResolver() self._current: Profile = initial @property def current_profile(self) -> Profile: """The profile in force right now — persona, role, and memory scope.""" return self._current def on_active_changed(self, active: ActiveIdentity | None) -> Profile: """Switch the current profile to match a newly active identity. Wired to the identity runtime's ``active_changed`` signal: when the recognised person changes, the persona, tools, and memory change with them. Returns the profile now in force so a caller can react to the swap in one call. """ self._current = self._resolver.resolve(active) return self._current