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:
@@ -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."""
|
||||
|
||||
@@ -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) ──────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
78
cortex/persona.py
Normal 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, 1–32 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user