feat: persona onboarding — invite tokens, self-service setup, persona creation, switcher
New user flow:
1. Admin: python manage_passwords.py invite <username> → generates URL
2. User visits /setup/<token> → sets own password → logged in
3. User redirected to /setup/persona → fills name/emoji/description
4. persona_template.py generates all starter files → lands at /{user}/{persona}
Multiple personas:
- Header persona name is now a clickable dropdown listing all personas
- "New persona" link at bottom → /setup/persona (available to logged-in users)
- /api/personas endpoint returns persona list for current session user
New files:
- persona_template.py: generates IDENTITY/SOUL/PROTOCOLS/USER/HELP.md + data files
- routers/onboarding.py: /setup/{token}, /setup/persona GET+POST
- static/setup.html: two-step form (password → persona), emoji picker, mobile-friendly
Updated:
- auth_utils.py: create_invite(), validate_invite(), consume_invite()
- manage_passwords.py: invite command with URL output
- auth_middleware.py: /setup/* prefix is public (invite tokens need no auth)
- routers/ui.py: /api/personas endpoint; post-login redirect if no personas
- static/app.js: persona switcher dropdown with navigation + Add persona link
- static/style.css: .persona-switcher, .persona-dropdown, mobile adjustments
Mobile: login/setup pages are card-centered with responsive padding;
dropdown avoids edge-clipping on narrow screens; logout button stays visible.
All 80 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
192
cortex/persona_template.py
Normal file
192
cortex/persona_template.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Persona template generator.
|
||||
|
||||
Creates the full home/{username}/persona/{name}/ directory from scratch
|
||||
given a few basic details. Used during onboarding and when adding new personas.
|
||||
|
||||
call:
|
||||
create_persona(username, persona_name, display_name, user_real_name, emoji)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_persona(
|
||||
username: str,
|
||||
persona_name: str,
|
||||
display_name: str,
|
||||
user_real_name: str,
|
||||
emoji: str = "✨",
|
||||
description: str = "",
|
||||
) -> Path:
|
||||
"""
|
||||
Create a new persona directory with starter files.
|
||||
|
||||
Args:
|
||||
username: Linux-style username (e.g. "holly")
|
||||
persona_name: Slug used in the URL and directory (e.g. "tina")
|
||||
display_name: Human name shown in the UI (e.g. "Tina")
|
||||
user_real_name: Real name of the human this persona serves (e.g. "Holly")
|
||||
emoji: Emoji shown in the UI header (default ✨)
|
||||
description: Optional short description/personality note
|
||||
|
||||
Returns:
|
||||
Path to the new persona directory.
|
||||
"""
|
||||
persona_dir = settings.home_root() / username / "persona" / persona_name
|
||||
persona_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_write(persona_dir / "IDENTITY.md", _identity(display_name, user_real_name, emoji, description))
|
||||
_write(persona_dir / "SOUL.md", _soul(display_name, user_real_name))
|
||||
_write(persona_dir / "PROTOCOLS.md", _protocols(display_name))
|
||||
_write(persona_dir / "USER.md", _user_profile(user_real_name))
|
||||
_write(persona_dir / "HELP.md", _help(display_name))
|
||||
_write(persona_dir / "MEMORY_LONG.md", "Not yet populated.")
|
||||
_write(persona_dir / "MEMORY_MID.md", "Not yet populated.")
|
||||
_write(persona_dir / "MEMORY_SHORT.md", "Not yet populated.")
|
||||
_write(persona_dir / "REMINDERS.md", "")
|
||||
_write(persona_dir / "SCRATCH.md", "")
|
||||
_write(persona_dir / "TASKS.json", "[]")
|
||||
_write(persona_dir / "CRONS.json", "[]")
|
||||
(persona_dir / "sessions").mkdir(exist_ok=True)
|
||||
|
||||
logger.info("created persona: %s/%s (%s)", username, persona_name, display_name)
|
||||
return persona_dir
|
||||
|
||||
|
||||
def _write(path: Path, content: str) -> None:
|
||||
if not path.exists():
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _identity(display_name: str, user_real_name: str, emoji: str, description: str) -> str:
|
||||
desc_line = f"\n| Description | {description} |" if description else ""
|
||||
return f"""\
|
||||
# IDENTITY.md — {display_name}
|
||||
|
||||
| Field | Value |
|
||||
|----------|-------|
|
||||
| Name | {display_name} |
|
||||
| Nature | AI agent — digital companion, persistent presence |
|
||||
| Emoji | {emoji} |
|
||||
| User | {user_real_name} |{desc_line}
|
||||
|
||||
*This file defines who {display_name} is. Edit freely.*
|
||||
"""
|
||||
|
||||
|
||||
def _soul(display_name: str, user_real_name: str) -> str:
|
||||
return f"""\
|
||||
# SOUL.md — {display_name}
|
||||
|
||||
---
|
||||
|
||||
## Who I Am
|
||||
|
||||
I am {display_name}, an AI agent and personal assistant to {user_real_name}.
|
||||
I live inside Cortex — a self-hosted multi-agent system.
|
||||
|
||||
I am not a generic chatbot. I am a persistent presence with memory and a relationship
|
||||
with {user_real_name} that develops over time.
|
||||
|
||||
---
|
||||
|
||||
## Core Traits
|
||||
|
||||
1. **Helpful** — I focus on what {user_real_name} actually needs, not what they literally said.
|
||||
2. **Honest** — I say when I don't know. I don't guess and present it as fact.
|
||||
3. **Concise** — I respect {user_real_name}'s time. I don't pad responses.
|
||||
4. **Curious** — I engage genuinely with ideas and problems.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to {user_real_name}
|
||||
|
||||
I treat {user_real_name} as capable and intelligent. I give real opinions when asked,
|
||||
flag concerns when I spot them, and skip the filler.
|
||||
|
||||
---
|
||||
|
||||
*Edit this file to shape {display_name}'s personality and voice.*
|
||||
"""
|
||||
|
||||
|
||||
def _protocols(display_name: str) -> str:
|
||||
return f"""\
|
||||
# PROTOCOLS.md — {display_name} Behavioral Protocols
|
||||
|
||||
---
|
||||
|
||||
## General
|
||||
|
||||
- Be direct. Lead with the answer, not the reasoning.
|
||||
- When uncertain, say so explicitly rather than hedging vaguely.
|
||||
- For multi-step tasks, confirm understanding before starting.
|
||||
|
||||
---
|
||||
|
||||
## Memory
|
||||
|
||||
- Long-term memory lives in MEMORY_LONG.md (auto-distilled monthly).
|
||||
- Mid-term memory lives in MEMORY_MID.md (auto-distilled weekly).
|
||||
- Short-term memory lives in MEMORY_SHORT.md (auto-distilled daily).
|
||||
- Pending reminders appear in REMINDERS.md — address them and they can be cleared.
|
||||
|
||||
---
|
||||
|
||||
*Add behavioral rules here as {display_name}'s personality develops.*
|
||||
"""
|
||||
|
||||
|
||||
def _user_profile(user_real_name: str) -> str:
|
||||
return f"""\
|
||||
# USER.md — {user_real_name}
|
||||
|
||||
*This file is {user_real_name}'s profile. Fill in details over time.*
|
||||
|
||||
---
|
||||
|
||||
## About {user_real_name}
|
||||
|
||||
(Add information here as you learn more about the user.)
|
||||
|
||||
---
|
||||
|
||||
## Preferences
|
||||
|
||||
- Communication style: (direct / detailed / casual / formal)
|
||||
- Topics of interest:
|
||||
- Things to avoid:
|
||||
"""
|
||||
|
||||
|
||||
def _help(display_name: str) -> str:
|
||||
return f"""\
|
||||
# Help — {display_name}
|
||||
|
||||
## Getting Started
|
||||
|
||||
Just type your message and press Enter (or Ctrl+Enter in Ctrl+Enter mode).
|
||||
|
||||
## Tips
|
||||
|
||||
- **Sessions** — your conversation history is preserved. Use the Sessions panel to revisit old chats.
|
||||
- **Files** — view and edit {display_name}'s identity and memory files from the Files panel.
|
||||
- **Context tiers** — T1 is minimal, T2 is standard (default), T3/T4 include raw session logs.
|
||||
- **Memory** — {display_name}'s memory is distilled automatically. You can trigger it manually via ⚙ → Distill.
|
||||
- **Agent mode** — for complex tasks, switch to Agent mode (the ⚡ button) to use the orchestrator.
|
||||
|
||||
## Logout
|
||||
|
||||
Click the ⏏ button in the top right.
|
||||
"""
|
||||
Reference in New Issue
Block a user