Files
shellbound/shellbound/personas.py
T
2026-09-05 23:16:38 +01:00

100 lines
3.4 KiB
Python

"""Persona (mode) resolution.
Modes are Turnstone personae whose slugs start with ``shellbound_``. A mode
argument is resolved as a case-insensitive shortest-unique prefix of that
slug. Zero matches means the argument is treated as part of the query; more
than one match (e.g. ``e`` for ``explain``/``expert``) is an ambiguity error.
"""
from __future__ import annotations
from typing import Optional
SHELLBOUND_PREFIX = "shellbound_"
# Canonical modes this CLI knows about, used to detect "persona not yet
# provisioned on the server" so intent is never silently swallowed into the
# query.
KNOWN_MODES = {
"shell": "shellbound_shell",
"s": "shellbound_shell",
"answer": "shellbound_answer",
"a": "shellbound_answer",
"explain": "shellbound_explain",
"creative": "shellbound_creative",
"c": "shellbound_creative",
"poetic": "shellbound_poetic",
"p": "shellbound_poetic",
"expert": "shellbound_expert",
}
class AmbiguousPersona(ValueError):
def __init__(self, fragment: str, candidates: list[str]):
self.fragment = fragment
self.candidates = candidates
super().__init__(
f"'{fragment}' is ambiguous ({len(candidates)} personae: "
f"{', '.join(sorted(candidates))}); supply more characters"
)
def shellbound_personas(raw: list[dict]) -> list[dict]:
return [p for p in raw if p.get("name", "").startswith(SHELLBOUND_PREFIX)]
def interactions(raw: list[dict]) -> list[dict]:
return [p for p in raw if "interactive" in (p.get("applies_to_kinds") or [])]
def can_expected_mode(fragment: str) -> bool:
return fragment.strip().lower() in KNOWN_MODES
def resolve_mode(raw: list[dict], fragment: str) -> Optional[dict]:
"""Resolve a mode fragment to a ``shellbound_*`` persona.
Returns ``None`` when the fragment matches nothing (it should then be
treated as the start of the query). Raises :class:`AmbiguousPersona`
when more than one persona matches.
"""
fragment = fragment.strip()
if not fragment:
return None
low = fragment.lower()
matches: list[dict] = []
for p in shellbound_personas(raw):
name = p["name"]
if name.lower() == low or name.lower().startswith(SHELLBOUND_PREFIX + low):
matches.append(p)
if len(matches) > 1:
raise AmbiguousPersona(fragment, [p["name"] for p in matches])
return matches[0] if matches else None
def resolve_name(raw: list[dict], name: str) -> dict:
"""Resolve an explicit ``--persona`` value (exact name or unique prefix)."""
name = name.strip()
if not name:
raise ValueError("empty persona name")
low = name.lower()
matches = [
p for p in interactions(raw)
if p["name"].lower() == low or p["name"].lower().startswith(low)
]
if not matches:
available = ", ".join(sorted(p["name"] for p in raw))
raise ValueError(f"unknown persona '{name}' (available: {available})")
if len(matches) > 1:
raise AmbiguousPersona(name, [p["name"] for p in matches])
return matches[0]
def default_persona_name(raw: list[dict]) -> Optional[str]:
"""Preferred default persona (answer) with fallbacks."""
sl = [p["name"] for p in shellbound_personas(raw)]
for candidate in ("shellbound_answer", "shellbound_shell"):
if candidate in sl:
return candidate
default = next((p["name"] for p in raw if p.get("is_default")), None)
return default