feat: multi-user/multi-persona support with two-level home directory layout
Restructures persona storage from a flat personas/{name}/ layout to
home/{username}/persona/{name}/, mirroring Linux home directories.
Changes:
- persona.py: two ContextVars (user + persona), Linux-style name validation,
set_context(), get_user(), get_persona(), validate(), list_users(),
list_user_personas(); persona_path() takes (username, name)
- config.py: replaces personas_dir with home_dir + home_root()
- git mv personas/inara → home/scott/persona/inara (history preserved)
- home/holly/persona/tina/: Holly's persona stub added
- cron_runner.py: all storage functions take (username, persona) params
- tools/cron.py: stamps user + persona on jobs; APScheduler IDs are
{user}:{persona}:{job_id} to prevent collisions across users
- memory_distiller.py: distill_short/mid/long take (username, persona);
added missing Path + settings imports
- scheduler.py: _load_user_crons() iterates home/*/persona/* (two-level)
- routers/chat.py, orchestrator.py: user field added; set_context() called
- tests/conftest.py: home_root fixture with two-level structure;
patches home_dir instead of personas_dir
- tests/test_persona.py: fully rewritten for two-level API
- tests/test_api_files.py: updated fixture name and path
- .env.default: documents HOME_DIR setting; scrubs stale API key
- CLAUDE.md, README.md: directory maps updated for new layout
All 80 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,28 @@
|
||||
"""
|
||||
Persona routing — per-request identity context.
|
||||
Two-level identity context — user + persona, modelled on OS home directories.
|
||||
|
||||
Each HTTP request sets the active persona via set_persona() at entry.
|
||||
Everything downstream (context_loader, tools, session_logger) calls
|
||||
persona_path() to get the right working directory without needing to
|
||||
pass the name through every function signature.
|
||||
Layout on disk:
|
||||
home/
|
||||
scott/
|
||||
persona/
|
||||
inara/ ← IDENTITY.md, SOUL.md, sessions/, TASKS.json, …
|
||||
abc/ ← a second persona for the same user
|
||||
holly/
|
||||
persona/
|
||||
tina/
|
||||
|
||||
Directory layout:
|
||||
personas/
|
||||
inara/ ← Scott's agent
|
||||
holly/ ← Second persona (add IDENTITY.md + SOUL.md + USER.md)
|
||||
...
|
||||
Each HTTP request sets both user and persona via set_context() at entry.
|
||||
Everything downstream calls persona_path() to get the right directory.
|
||||
Background tasks (cron, distiller) pass both names explicitly.
|
||||
|
||||
Background tasks (cron runner, memory distiller) don't have a request
|
||||
context — they pass persona by name explicitly to persona_path(name).
|
||||
Naming rules — same as Linux usernames:
|
||||
^[a-z_][a-z0-9_-]{0,31}$
|
||||
Lowercase, start with letter or underscore, max 32 chars.
|
||||
Examples: scott, holly, whatever_name_asian-v3
|
||||
|
||||
Future Aether integration: persona name maps to account_id_random.
|
||||
Replace the directory lookup in persona_path() with a DB lookup at
|
||||
that point; the ContextVar contract stays the same.
|
||||
Future Aether integration: (user, persona) maps to (account_id, persona_id).
|
||||
Replace the directory lookups in persona_path() / validate() with DB lookups;
|
||||
the ContextVar contract stays identical.
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -26,53 +31,103 @@ from pathlib import Path
|
||||
|
||||
from config import settings
|
||||
|
||||
_current: ContextVar[str] = ContextVar("persona", default="inara")
|
||||
_user: ContextVar[str] = ContextVar("user", default="scott")
|
||||
_persona: ContextVar[str] = ContextVar("persona", default="inara")
|
||||
|
||||
# Only alphanumeric + underscore + hyphen, 1–32 chars. Prevents path traversal.
|
||||
_VALID = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
|
||||
# Same rules as Linux usernames.
|
||||
_VALID = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
|
||||
|
||||
|
||||
def set_persona(name: str) -> None:
|
||||
"""Set the active persona for the current async task/coroutine."""
|
||||
_current.set(name)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context setters / getters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def set_context(username: str, persona_name: str) -> None:
|
||||
"""Set the active user + persona for the current async task/coroutine."""
|
||||
_user.set(username)
|
||||
_persona.set(persona_name)
|
||||
|
||||
|
||||
def get_user() -> str:
|
||||
return _user.get()
|
||||
|
||||
|
||||
def get_persona() -> str:
|
||||
"""Return the active persona name for the current task."""
|
||||
return _current.get()
|
||||
return _persona.get()
|
||||
|
||||
|
||||
def persona_path(name: str | None = None) -> Path:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def persona_path(username: str | None = None, name: str | None = None) -> Path:
|
||||
"""
|
||||
Return the filesystem path for a persona's data directory.
|
||||
|
||||
If name is omitted, uses the persona set for the current request.
|
||||
Pass name explicitly for background tasks (cron, distiller).
|
||||
home/{username}/persona/{name}/
|
||||
|
||||
If either arg is omitted, falls back to the ContextVar for the current request.
|
||||
Pass both explicitly for background tasks (cron, distiller).
|
||||
"""
|
||||
return settings.personas_root() / (name or _current.get())
|
||||
u = username or _user.get()
|
||||
p = name or _persona.get()
|
||||
return settings.home_root() / u / "persona" / p
|
||||
|
||||
|
||||
def list_personas() -> list[str]:
|
||||
"""Return all persona names that have an IDENTITY.md (i.e. are real personas)."""
|
||||
root = settings.personas_root()
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_users() -> list[str]:
|
||||
"""Return all usernames that have at least one persona."""
|
||||
root = settings.home_root()
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in root.iterdir()
|
||||
if d.is_dir() and (d / "persona").is_dir()
|
||||
)
|
||||
|
||||
|
||||
def list_user_personas(username: str) -> list[str]:
|
||||
"""Return all persona names for a given user (must have IDENTITY.md)."""
|
||||
persona_root = settings.home_root() / username / "persona"
|
||||
if not persona_root.exists():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in persona_root.iterdir()
|
||||
if d.is_dir() and (d / "IDENTITY.md").exists()
|
||||
)
|
||||
|
||||
|
||||
def validate(name: str) -> str:
|
||||
"""
|
||||
Validate a persona name from an untrusted source (e.g. HTTP request).
|
||||
Returns the name if valid, raises ValueError otherwise.
|
||||
"""
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_name(name: str, label: str) -> None:
|
||||
if not _VALID.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid persona name {name!r}. "
|
||||
f"Use letters, digits, underscores, or hyphens (max 32 chars)."
|
||||
f"Invalid {label} {name!r}. "
|
||||
f"Use lowercase letters, digits, underscores, or hyphens "
|
||||
f"(must start with letter/underscore, max 32 chars)."
|
||||
)
|
||||
if not (settings.personas_root() / name / "IDENTITY.md").exists():
|
||||
raise ValueError(f"Unknown persona: {name!r}")
|
||||
return name
|
||||
|
||||
|
||||
def validate(username: str, persona_name: str) -> tuple[str, str]:
|
||||
"""
|
||||
Validate a (username, persona_name) pair from an untrusted source.
|
||||
Returns (username, persona_name) if valid, raises ValueError otherwise.
|
||||
Checks format first (blocks path traversal), then existence.
|
||||
"""
|
||||
_check_name(username, "username")
|
||||
_check_name(persona_name, "persona")
|
||||
|
||||
user_dir = settings.home_root() / username
|
||||
if not user_dir.is_dir():
|
||||
raise ValueError(f"Unknown user: {username!r}")
|
||||
|
||||
persona_dir = user_dir / "persona" / persona_name
|
||||
if not (persona_dir / "IDENTITY.md").exists():
|
||||
raise ValueError(f"Unknown persona {persona_name!r} for user {username!r}")
|
||||
|
||||
return username, persona_name
|
||||
|
||||
Reference in New Issue
Block a user