diff --git a/cortex/routers/chat.py b/cortex/routers/chat.py
index d7e14c1..69291e8 100644
--- a/cortex/routers/chat.py
+++ b/cortex/routers/chat.py
@@ -6,7 +6,7 @@ from pydantic import BaseModel
from context_loader import load_context
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 session_store import load as load_session, save as save_session, list_all, generate_session_id, delete as delete_session, rename as rename_session
from config import settings
from persona import set_context, validate as validate_persona
import event_bus
@@ -171,6 +171,24 @@ async def list_sessions(
return {"sessions": list_all()}
+class SessionRename(BaseModel):
+ name: str
+
+
+@router.patch("/sessions/{session_id}")
+async def rename_session_endpoint(
+ session_id: str,
+ req: SessionRename,
+ user: str = Query("scott"),
+ persona: str = Query("inara"),
+) -> dict:
+ _set_ctx(user, persona)
+ found = rename_session(session_id, req.name.strip())
+ if not found:
+ raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
+ return {"ok": True, "session_id": session_id, "name": req.name.strip()}
+
+
@router.delete("/sessions/{session_id}")
async def delete_session_endpoint(
session_id: str,
diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py
index 796a738..b2e8a88 100644
--- a/cortex/routers/settings.py
+++ b/cortex/routers/settings.py
@@ -2,11 +2,14 @@
Account settings router.
Routes:
- GET /settings → show account settings page (requires auth)
- POST /settings/password → change password
+ GET /settings → show account settings page (requires auth)
+ POST /settings/password → change password
+ POST /settings/username → rename the user account (forces re-login)
+ POST /settings/persona/rename → rename a persona directory
"""
import logging
+import re
from pathlib import Path
import jwt
@@ -15,6 +18,9 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password
from persona import list_user_personas
+from config import settings as app_settings
+
+_SLUG_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -36,7 +42,18 @@ def _settings_page(username: str, personas: list[str], success: str = "", error:
html = (_STATIC / "settings.html").read_text()
html = html.replace("{{ username }}", username)
persona_items = "\n".join(
- f'
{p}' for p in personas
+ f'''
+ {p}
+
+
+ ''' for p in personas
)
html = html.replace("{{ persona_items }}", persona_items or "No personas yet.")
back_persona = personas[0] if personas else ""
@@ -82,3 +99,79 @@ async def change_password(
set_password(username, new_password)
logger.info("password changed: %s", username)
return HTMLResponse(_settings_page(username, personas, success="Password updated successfully."))
+
+
+@router.post("/settings/username", include_in_schema=False)
+async def rename_username(
+ request: Request,
+ new_username: str = Form(...),
+):
+ username = _get_session_user(request)
+ if not username:
+ return RedirectResponse("/login", status_code=302)
+
+ personas = list_user_personas(username)
+ new_username = new_username.strip().lower()
+
+ if not _SLUG_RE.match(new_username):
+ return HTMLResponse(_settings_page(
+ username, personas,
+ error="Invalid username. Use lowercase letters, digits, _ or - only."))
+
+ if new_username == username:
+ return RedirectResponse("/settings", status_code=302)
+
+ home_root = app_settings.home_root()
+ old_dir = home_root / username
+ new_dir = home_root / new_username
+
+ if new_dir.exists():
+ return HTMLResponse(_settings_page(
+ username, personas,
+ error=f"Username '{new_username}' is already taken."))
+
+ old_dir.rename(new_dir)
+ logger.info("user renamed: %s → %s", username, new_username)
+
+ # Clear the auth cookie — old JWT now refers to a non-existent user
+ resp = RedirectResponse("/login?msg=username_changed", status_code=302)
+ resp.delete_cookie(COOKIE_NAME)
+ return resp
+
+
+@router.post("/settings/persona/rename", include_in_schema=False)
+async def rename_persona(
+ request: Request,
+ old_name: str = Form(...),
+ new_name: str = Form(...),
+):
+ username = _get_session_user(request)
+ if not username:
+ return RedirectResponse("/login", status_code=302)
+
+ personas = list_user_personas(username)
+ new_name = new_name.strip().lower()
+
+ if not _SLUG_RE.match(new_name):
+ return HTMLResponse(_settings_page(
+ username, personas,
+ error="Invalid name. Use lowercase letters, digits, _ or - only."))
+
+ if new_name == old_name:
+ return RedirectResponse("/settings", status_code=302)
+
+ persona_root = app_settings.home_root() / username / "persona"
+ old_dir = persona_root / old_name
+ new_dir = persona_root / new_name
+
+ if not old_dir.exists():
+ return HTMLResponse(_settings_page(username, personas, error=f"Persona '{old_name}' not found."))
+
+ if new_dir.exists():
+ return HTMLResponse(_settings_page(
+ username, personas,
+ error=f"A persona named '{new_name}' already exists."))
+
+ old_dir.rename(new_dir)
+ logger.info("persona renamed: %s/%s → %s", username, old_name, new_name)
+ return RedirectResponse("/settings", status_code=302)
diff --git a/cortex/session_store.py b/cortex/session_store.py
index 61d14f3..50dd3ad 100644
--- a/cortex/session_store.py
+++ b/cortex/session_store.py
@@ -62,12 +62,29 @@ def save(session_id: str, messages: list[dict]) -> None:
# Enforce rolling window
windowed = messages[-settings.max_history_messages:]
- path.write_text(json.dumps({
+ data = {
"session_id": session_id,
"created": existing.get("created", datetime.now().isoformat()),
"updated": datetime.now().isoformat(),
"messages": windowed,
- }, indent=2))
+ }
+ if "name" in existing:
+ data["name"] = existing["name"]
+ path.write_text(json.dumps(data, indent=2))
+
+
+def rename(session_id: str, name: str) -> bool:
+ """Set (or clear) the friendly name on a session. Returns False if not found."""
+ path = _path(session_id)
+ if not path.exists():
+ return False
+ data = json.loads(path.read_text())
+ if name:
+ data["name"] = name
+ else:
+ data.pop("name", None)
+ path.write_text(json.dumps(data, indent=2))
+ return True
def delete(session_id: str) -> bool:
@@ -87,11 +104,13 @@ def list_all() -> list[dict]:
for f in sorted(d.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
data = json.loads(f.read_text())
- results.append({
+ entry = {
"session_id": data["session_id"],
+ "name": data.get("name", ""),
"updated": data.get("updated"),
"message_count": len(data.get("messages", [])),
- })
+ }
+ results.append(entry)
except Exception:
pass
return results
diff --git a/cortex/static/app.js b/cortex/static/app.js
index 92f7fba..2fadc7f 100644
--- a/cortex/static/app.js
+++ b/cortex/static/app.js
@@ -235,8 +235,12 @@
}
});
+ // session_id → friendly name (populated on each panel render)
+ const sessionNames = new Map();
+
function renderPanel(sessions) {
sessionsPanel.innerHTML = '';
+ sessionNames.clear();
const newItem = makeItem('new', '+ New session', '');
newItem.addEventListener('click', () => {
@@ -259,13 +263,57 @@
}
for (const s of sessions) {
+ const displayName = s.name || s.session_id;
+ sessionNames.set(s.session_id, displayName);
+
const item = makeItem(
s.session_id === sessionId ? 'active' : '',
- s.session_id,
+ displayName,
`${s.message_count} msgs · ${timeAgo(s.updated)}`
);
item.addEventListener('click', () => resumeSession(s.session_id));
+ // Rename button (✎)
+ const renameBtn = document.createElement('button');
+ renameBtn.className = 'session-rename-btn';
+ renameBtn.textContent = '✎';
+ renameBtn.title = 'Rename session';
+ renameBtn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const labelEl = item.querySelector('.session-id');
+ const current = s.name || '';
+ const input = document.createElement('input');
+ input.className = 'session-rename-input';
+ input.value = current;
+ input.placeholder = s.session_id;
+ labelEl.replaceWith(input);
+ input.focus();
+ input.select();
+
+ async function commitRename() {
+ const newName = input.value.trim();
+ await fetch(`/sessions/${s.session_id}?${_fileParams}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newName }),
+ });
+ const res = await fetch(`/sessions?${_fileParams}`);
+ const data = await res.json();
+ renderPanel(data.sessions);
+ // Update status bar if this is the active session
+ if (sessionId === s.session_id) {
+ sessionEl.textContent = `session: ${newName || s.session_id}`;
+ }
+ }
+
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') { e.preventDefault(); commitRename(); }
+ if (e.key === 'Escape') { renderPanel(sessions); }
+ });
+ input.addEventListener('blur', commitRename);
+ });
+ item.appendChild(renameBtn);
+
const delBtn = document.createElement('button');
delBtn.className = 'session-delete-btn';
delBtn.textContent = '×';
@@ -316,7 +364,7 @@
messagesEl.innerHTML = '';
sessionId = id;
- sessionEl.textContent = `session: ${id}`;
+ sessionEl.textContent = `session: ${sessionNames.get(id) || id}`;
currentHistory = [];
for (let i = 0; i < data.messages.length; i++) {
diff --git a/cortex/static/help.html b/cortex/static/help.html
index 7feae07..eb73496 100644
--- a/cortex/static/help.html
+++ b/cortex/static/help.html
@@ -24,7 +24,7 @@
.back-link {
display: inline-block;
font-size: 0.8rem;
- color: #64748b;
+ color: #94a3b8;
text-decoration: none;
margin-bottom: 1.5rem;
}
@@ -36,7 +36,7 @@
border-bottom: 1px solid #2d3148;
}
header h1 { font-size: 1.5rem; font-weight: 700; color: #a78bfa; }
- header p { font-size: 0.85rem; color: #64748b; margin-top: 0.25rem; }
+ header p { font-size: 0.85rem; color: #94a3b8; margin-top: 0.25rem; }
#help-body { line-height: 1.7; }
@@ -62,7 +62,7 @@
summary::before {
content: '▶';
font-size: 0.65rem;
- color: #64748b;
+ color: #94a3b8;
transition: transform 0.15s;
}
details[open] summary::before { transform: rotate(90deg); }
@@ -89,13 +89,13 @@
#help-body h3 {
font-size: 0.8rem;
font-weight: 600;
- color: #64748b;
+ color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0.75rem 0 0.25rem;
}
- #loading { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
+ #loading { color: #94a3b8; font-size: 0.9rem; padding: 1rem 0; }
diff --git a/cortex/static/login.html b/cortex/static/login.html
index eee5cc4..47731eb 100644
--- a/cortex/static/login.html
+++ b/cortex/static/login.html
@@ -40,7 +40,7 @@
.logo p {
font-size: 0.8rem;
- color: #64748b;
+ color: #94a3b8;
margin-top: 0.25rem;
}
diff --git a/cortex/static/settings.html b/cortex/static/settings.html
index 6176b61..cd86c32 100644
--- a/cortex/static/settings.html
+++ b/cortex/static/settings.html
@@ -30,7 +30,7 @@
.back-link {
display: inline-block;
font-size: 0.8rem;
- color: #64748b;
+ color: #94a3b8;
text-decoration: none;
margin-bottom: 1.5rem;
}
@@ -40,7 +40,7 @@
margin-bottom: 1.75rem;
}
.logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
- .logo p { font-size: 0.8rem; color: #64748b; margin-top: 0.2rem; }
+ .logo p { font-size: 0.8rem; color: #94a3b8; margin-top: 0.2rem; }
h2 {
font-size: 0.9rem;
@@ -73,7 +73,7 @@
transition: border-color 0.15s;
}
input:focus { border-color: #7c3aed; }
- input[readonly] { color: #64748b; cursor: default; }
+ input[readonly] { color: #94a3b8; cursor: default; }
.field { margin-bottom: 1rem; }
@@ -109,11 +109,17 @@
.persona-list {
list-style: none;
display: flex;
- flex-wrap: wrap;
+ flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
- .persona-list li a {
+ .persona-list li {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+ .persona-link {
display: inline-block;
padding: 0.3rem 0.75rem;
background: #0f1117;
@@ -124,14 +130,55 @@
text-decoration: none;
transition: border-color 0.15s;
}
- .persona-list li a:hover { border-color: #7c3aed; }
- .persona-list li em { color: #475569; font-size: 0.85rem; }
+ .persona-link:hover { border-color: #7c3aed; }
+ .persona-list li em { color: #94a3b8; font-size: 0.85rem; }
+
+ .persona-rename-toggle {
+ background: none;
+ border: none;
+ color: #94a3b8;
+ font-size: 0.85rem;
+ cursor: pointer;
+ padding: 0.2rem 0.4rem;
+ border-radius: 4px;
+ opacity: 0.7;
+ transition: opacity 0.15s, color 0.15s;
+ }
+ .persona-rename-toggle:hover { opacity: 1; color: #a78bfa; }
+
+ .persona-rename-form { display: flex; align-items: center; gap: 0.4rem; }
+ .persona-rename-form input[type="text"] {
+ width: 12rem;
+ padding: 0.3rem 0.6rem;
+ background: #0f1117;
+ border: 1px solid #7c3aed;
+ border-radius: 6px;
+ color: #e2e8f0;
+ font-size: 0.9rem;
+ outline: none;
+ }
+ .persona-rename-form button[type="submit"] {
+ width: auto;
+ padding: 0.3rem 0.75rem;
+ font-size: 0.85rem;
+ margin-top: 0;
+ }
+ .persona-rename-cancel {
+ background: none;
+ border: 1px solid #2d3148;
+ border-radius: 6px;
+ color: #94a3b8;
+ font-size: 0.85rem;
+ padding: 0.3rem 0.6rem;
+ cursor: pointer;
+ }
+ .persona-rename-cancel:hover { border-color: #94a3b8; color: #e2e8f0; }
.add-persona {
display: inline-block;
margin-top: 0.75rem;
font-size: 0.8rem;
- color: #64748b;
+ color: #94a3b8;
text-decoration: none;
}
.add-persona:hover { color: #a78bfa; }
@@ -156,12 +203,33 @@
+
+