feat: multi-persona support (single Cortex, multiple users)

- Add cortex/persona.py: ContextVar-based per-request routing with
  path traversal protection and persona validation
- Migrate inara/ → personas/inara/ (git history preserved via git mv)
- config.py: add personas_root(), inara_path() delegates to personas/inara
- All 14 settings.inara_path() call sites replaced with persona_path()
- ChatRequest + OrchestrateRequest: add persona field (default: "inara")
  with validation at request entry before any processing
- memory_distiller: add optional persona param for future per-persona distill
- cron_runner/tools/cron: stamp persona on jobs, prefix APScheduler IDs
  (persona:job_id) to prevent collisions across personas
- scheduler: _load_user_crons() iterates all personas at startup

Adding a new persona: create personas/<name>/ with IDENTITY.md + SOUL.md.
Auth: handled at nginx level (inject X-Cortex-Persona header per subdomain).
Future: persona maps to Aether account_id_random for full integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-20 21:50:02 -04:00
parent 6316ffa1d4
commit 5cadb836fa
40 changed files with 634 additions and 289 deletions

View File

@@ -27,7 +27,8 @@ class Settings(BaseSettings):
agent_name: str = "Inara"
user_name: str = "Scott"
inara_dir: Path = Path("../inara")
personas_dir: Path = Path("../personas")
inara_dir: Path = Path("../personas/inara") # legacy — use personas_dir
sessions_dir: Path = Path("./data/sessions")
default_model: str = "claude-sonnet-4-6"
default_tier: int = 2
@@ -39,6 +40,11 @@ class Settings(BaseSettings):
timeout_gemini: int = 120 # frequently slow under load
timeout_local: int = 300 # local models may need to load first
# Google Chat
# JWT audience (aud) claim to verify on inbound webhook requests.
# Google Chat sets aud = the Google Cloud project number (e.g. "741112865538").
# Set to "" to disable verification (dev/testing only).
google_chat_audience: str = ""
# Google Chat must receive a response within 30s or shows an error to the user
google_chat_timeout: int = 25
# Backend forced for Google Chat — Claude is more reliable within the 25s deadline
@@ -68,11 +74,15 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
def personas_root(self) -> Path:
"""Resolve personas_dir relative to this file's location if not absolute."""
if self.personas_dir.is_absolute():
return self.personas_dir
return (Path(__file__).parent / self.personas_dir).resolve()
def inara_path(self) -> Path:
"""Resolve inara_dir relative to this file's location if not absolute."""
if self.inara_dir.is_absolute():
return self.inara_dir
return (Path(__file__).parent / self.inara_dir).resolve()
"""Legacy helper — returns the inara persona directory. Prefer persona_path()."""
return self.personas_root() / "inara"
def sessions_path(self) -> Path:
"""Resolve sessions_dir relative to this file's location if not absolute."""

View File

@@ -1,5 +1,4 @@
from pathlib import Path
from config import settings
from persona import persona_path
# Core identity files — always loaded regardless of tier
@@ -26,7 +25,7 @@ def load_context(
Tier 3 — + last 2 raw session logs (~15,000 tokens)
Tier 4 — + last 7 raw session logs (~50,000 tokens)
"""
inara_dir = settings.inara_path()
inara_dir = persona_path()
parts = []
# ── 1. Core identity (always) ──────────────────────────────────

View File

@@ -26,7 +26,7 @@ import logging
from datetime import datetime, timezone
from pathlib import Path
from config import settings
from persona import persona_path as _persona_path
logger = logging.getLogger(__name__)
@@ -45,12 +45,12 @@ _DOW = {
# Storage
# ---------------------------------------------------------------------------
def crons_path() -> Path:
return settings.inara_path() / "CRONS.json"
def crons_path(persona: str | None = None) -> Path:
return _persona_path(persona) / "CRONS.json"
def load_crons() -> list[dict]:
p = crons_path()
def load_crons(persona: str | None = None) -> list[dict]:
p = crons_path(persona)
if not p.exists():
return []
try:
@@ -60,9 +60,9 @@ def load_crons() -> list[dict]:
return []
def save_crons(crons: list[dict]) -> None:
def save_crons(crons: list[dict], persona: str | None = None) -> None:
import json
crons_path().write_text(json.dumps(crons, indent=2) + "\n")
crons_path(persona).write_text(json.dumps(crons, indent=2) + "\n")
# ---------------------------------------------------------------------------
@@ -134,16 +134,16 @@ async def run_job(job: dict) -> None:
label = job.get("label", job.get("id", "cron"))
section = f"\n## {label}{_now_label()}\n\n{payload}\n"
inara_dir = settings.inara_path()
p_root = _persona_path(job.get("persona"))
if job_type == "remind":
p = inara_dir / "REMINDERS.md"
p = p_root / "REMINDERS.md"
existing = p.read_text() if p.exists() else ""
p.write_text(existing.rstrip() + "\n" + section)
logger.info("cron [remind] fired: %s", label)
elif job_type == "note":
p = inara_dir / "SCRATCH.md"
p = p_root / "SCRATCH.md"
existing = p.read_text() if p.exists() else ""
p.write_text(existing.rstrip() + "\n" + section)
logger.info("cron [note] fired: %s", label)
@@ -152,10 +152,11 @@ async def run_job(job: dict) -> None:
logger.warning("cron: unknown type %r (job %s)", job_type, job.get("id"))
return
# Record last_run
crons = load_crons()
# Record last_run in the right persona's CRONS.json
persona = job.get("persona")
crons = load_crons(persona)
for c in crons:
if c["id"] == job["id"]:
c["last_run"] = datetime.now(timezone.utc).isoformat()
break
save_crons(crons)
save_crons(crons, persona)

View File

@@ -7,8 +7,7 @@ Inara tiered memory distillation.
"""
import logging
from datetime import datetime
from pathlib import Path
from config import settings
from persona import persona_path as _persona_path
logger = logging.getLogger(__name__)
@@ -24,14 +23,14 @@ def _read(path: Path) -> str:
return path.read_text() if path.exists() else ""
def distill_short() -> dict:
def distill_short(persona: str | None = None) -> dict:
"""
Roll the most recent session log files into MEMORY_SHORT.md.
No LLM involved — pure aggregation with budget truncation.
Files are included newest-first until the budget is reached,
then written in chronological order (oldest first).
"""
inara_dir = settings.inara_path()
inara_dir = _persona_path(persona)
sessions_dir = inara_dir / "sessions"
budget = _budget_chars(settings.memory_budget_short)
@@ -73,13 +72,13 @@ def distill_short() -> dict:
}
async def distill_mid() -> dict:
async def distill_mid(persona: str | None = None) -> dict:
"""
Ask the LLM to summarize MEMORY_SHORT.md → MEMORY_MID.md.
"""
from llm_client import complete
inara_dir = settings.inara_path()
inara_dir = _persona_path(persona)
short_content = _read(inara_dir / "MEMORY_SHORT.md")
if not short_content.strip() or "Not yet populated" in short_content:
@@ -117,13 +116,13 @@ async def distill_mid() -> dict:
}
async def distill_long() -> dict:
async def distill_long(persona: str | None = None) -> dict:
"""
Ask the LLM to integrate MEMORY_MID.md into MEMORY_LONG.md.
"""
from llm_client import complete
inara_dir = settings.inara_path()
inara_dir = _persona_path(persona)
long_content = _read(inara_dir / "MEMORY_LONG.md")
mid_content = _read(inara_dir / "MEMORY_MID.md")

78
cortex/persona.py Normal file
View File

@@ -0,0 +1,78 @@
"""
Persona routing — per-request identity context.
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.
Directory layout:
personas/
inara/ ← Scott's agent
holly/ ← Second persona (add IDENTITY.md + SOUL.md + USER.md)
...
Background tasks (cron runner, memory distiller) don't have a request
context — they pass persona by name explicitly to persona_path(name).
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.
"""
import re
from contextvars import ContextVar
from pathlib import Path
from config import settings
_current: ContextVar[str] = ContextVar("persona", default="inara")
# Only alphanumeric + underscore + hyphen, 132 chars. Prevents path traversal.
_VALID = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
def set_persona(name: str) -> None:
"""Set the active persona for the current async task/coroutine."""
_current.set(name)
def get_persona() -> str:
"""Return the active persona name for the current task."""
return _current.get()
def persona_path(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).
"""
return settings.personas_root() / (name or _current.get())
def list_personas() -> list[str]:
"""Return all persona names that have an IDENTITY.md (i.e. are real personas)."""
root = settings.personas_root()
if not root.exists():
return []
return sorted(
d.name for d in 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.
"""
if not _VALID.match(name):
raise ValueError(
f"Invalid persona name {name!r}. "
f"Use letters, digits, underscores, or hyphens (max 32 chars)."
)
if not (settings.personas_root() / name / "IDENTITY.md").exists():
raise ValueError(f"Unknown persona: {name!r}")
return name

View File

@@ -8,6 +8,7 @@ from llm_client import complete
from session_logger import log_turn
from session_store import load as load_session, save as save_session, list_all, generate_session_id, delete as delete_session
from config import settings
from persona import set_persona, validate as validate_persona
import event_bus
@@ -22,6 +23,7 @@ class ChatRequest(BaseModel):
include_long: bool = True
include_mid: bool = True
include_short: bool = True
persona: str = "inara"
class BackendRequest(BaseModel):
@@ -49,6 +51,12 @@ async def _stream_chat(req: ChatRequest):
"backend": "...", "fallback_used": bool}
data: {"type": "error", "message": "..."}
"""
try:
set_persona(validate_persona(req.persona))
except ValueError as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return
session_id = req.session_id or generate_session_id()
tier = req.tier or settings.default_tier

View File

@@ -4,7 +4,7 @@ Only whitelisted filenames are accessible — no path traversal possible.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from config import settings
from persona import persona_path
router = APIRouter()
@@ -25,12 +25,12 @@ ALLOWED = {
def _path(filename: str):
if filename not in ALLOWED:
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
return settings.inara_path() / filename
return persona_path() / filename
@router.get("/files")
async def list_files() -> dict:
inara_dir = settings.inara_path()
inara_dir = persona_path()
files = []
for name in sorted(ALLOWED):
p = inara_dir / name

View File

@@ -20,6 +20,7 @@ from pydantic import BaseModel
from config import settings
from context_loader import load_context
from persona import set_persona, validate as validate_persona
import orchestrator_engine
logger = logging.getLogger(__name__)
@@ -46,6 +47,7 @@ class OrchestrateRequest(BaseModel):
include_long: bool = True
include_mid: bool = True
include_short: bool = True
persona: str = "inara"
class OrchestrateResponse(BaseModel):
@@ -74,6 +76,12 @@ class JobStatusResponse(BaseModel):
@router.post("", response_model=OrchestrateResponse)
async def orchestrate(req: OrchestrateRequest) -> OrchestrateResponse:
"""Submit a task to the orchestrator. Returns a job_id to poll."""
try:
set_persona(validate_persona(req.persona))
except ValueError as e:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail=str(e))
job_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()

View File

@@ -84,34 +84,38 @@ def start() -> None:
def _load_user_crons() -> None:
"""Register all enabled user-defined cron jobs from CRONS.json."""
"""Register all enabled user-defined cron jobs across all personas."""
import asyncio
try:
from cron_runner import load_crons, parse_schedule, run_job
from persona import list_personas
except ImportError as e:
logger.warning("could not import cron_runner: %s", e)
logger.warning("could not import cron modules: %s", e)
return
crons = load_crons()
loaded = 0
for job in crons:
if not job.get("enabled", True):
continue
try:
kwargs = parse_schedule(job["schedule"])
_scheduler.add_job(
lambda j=job: asyncio.ensure_future(run_job(j)),
"cron",
id=job["id"],
replace_existing=True,
**kwargs,
)
loaded += 1
except Exception as e:
logger.warning("cron job %s skipped: %s", job.get("id"), e)
total = 0
for persona_name in list_personas():
for job in load_crons(persona_name):
if not job.get("enabled", True):
continue
# Ensure persona is stamped on the job for run_job() to resolve paths
job.setdefault("persona", persona_name)
try:
kwargs = parse_schedule(job["schedule"])
sched_id = f"{persona_name}:{job['id']}"
_scheduler.add_job(
lambda j=job: asyncio.ensure_future(run_job(j)),
"cron",
id=sched_id,
replace_existing=True,
**kwargs,
)
total += 1
except Exception as e:
logger.warning("cron %s/%s skipped: %s", persona_name, job.get("id"), e)
if loaded:
logger.info("loaded %d user cron job(s)", loaded)
if total:
logger.info("loaded %d user cron job(s) across %d persona(s)", total, len(list_personas()))
def stop() -> None:

View File

@@ -1,11 +1,11 @@
from pathlib import Path
from datetime import datetime
from config import settings
from persona import persona_path
def log_turn(session_id: str, user_msg: str, assistant_msg: str) -> None:
today = datetime.now().strftime("%Y-%m-%d")
sessions_dir = settings.inara_path() / "sessions"
sessions_dir = persona_path() / "sessions"
sessions_dir.mkdir(exist_ok=True)
log_file = sessions_dir / f"{today}.md"

View File

@@ -17,7 +17,7 @@ import secrets
from datetime import datetime, timezone
from pathlib import Path
from config import settings
from persona import persona_path, get_persona
from cron_runner import load_crons, save_crons, parse_schedule
@@ -61,8 +61,10 @@ def _cron_add(label: str, schedule: str, job_type: str, payload: str) -> str:
return "Bad type: must be 'remind' or 'note'."
crons = load_crons()
current_persona = get_persona()
job = {
"id": _short_id(),
"persona": current_persona,
"label": label,
"schedule": schedule,
"type": job_type,
@@ -72,39 +74,42 @@ def _cron_add(label: str, schedule: str, job_type: str, payload: str) -> str:
"last_run": None,
}
crons.append(job)
save_crons(crons)
save_crons(crons, current_persona)
# Register with the live scheduler
_scheduler_add(job, sched_kwargs)
return f"Created and scheduled: {job['id']} {schedule} [{job_type}] {label}"
return f"Created and scheduled: {job['id']} {schedule} [{job_type}] {label} (persona: {current_persona})"
def _cron_remove(cron_id: str) -> str:
crons = load_crons()
persona = get_persona()
crons = load_crons(persona)
before = len(crons)
crons = [c for c in crons if c["id"] != cron_id]
if len(crons) == before:
return f"Not found: {cron_id}"
save_crons(crons)
_scheduler_remove(cron_id)
save_crons(crons, persona)
_scheduler_remove(f"{persona}:{cron_id}")
return f"Removed: {cron_id}"
def _cron_toggle(cron_id: str) -> str:
crons = load_crons()
persona = get_persona()
crons = load_crons(persona)
for c in crons:
if c["id"] == cron_id:
c["enabled"] = not c.get("enabled", True)
save_crons(crons)
save_crons(crons, persona)
action = "resumed" if c["enabled"] else "paused"
_scheduler_resume(cron_id) if c["enabled"] else _scheduler_pause(cron_id)
sched_id = f"{persona}:{cron_id}"
_scheduler_resume(sched_id) if c["enabled"] else _scheduler_pause(sched_id)
return f"{action.capitalize()}: {cron_id} {c['label']}"
return f"Not found: {cron_id}"
def _reminders_clear() -> str:
p = settings.inara_path() / "REMINDERS.md"
p = persona_path() / "REMINDERS.md"
p.write_text("")
return "Reminders cleared."
@@ -120,10 +125,11 @@ def _scheduler_add(job: dict, sched_kwargs: dict) -> None:
from cron_runner import run_job
s = sched_module.get_scheduler()
if s and s.running:
sched_id = f"{job.get('persona', 'inara')}:{job['id']}"
s.add_job(
lambda j=job: asyncio.ensure_future(run_job(j)),
"cron",
id=job["id"],
id=sched_id,
replace_existing=True,
**sched_kwargs,
)

View File

@@ -17,11 +17,11 @@ import asyncio
from datetime import datetime, timezone
from pathlib import Path
from config import settings
from persona import persona_path
def _scratch_path() -> Path:
return settings.inara_path() / "SCRATCH.md"
return persona_path() / "SCRATCH.md"
def _now_label() -> str:

View File

@@ -20,11 +20,11 @@ import asyncio
from datetime import datetime, timezone
from pathlib import Path
from config import settings
from persona import persona_path
def _tasks_path() -> Path:
return settings.inara_path() / "TASKS.json"
return persona_path() / "TASKS.json"
def _now() -> str: