-
{badge}{m.get("label") or m.get("model_name","")} {ctx}
-
{m.get("model_name","")}
- {sec}
-
{tags}
+
'''
if not model_rows:
@@ -356,6 +436,42 @@ async def add_model(
return HTMLResponse(_render(username, success=f'Model "{display}" added.'))
+@router.post("/settings/local/models/{model_id}/edit", include_in_schema=False)
+async def edit_model(
+ request: Request,
+ model_id: str,
+ mtype: str = Form(""),
+ label: str = Form(""),
+ model_name: str = Form(""),
+ context_k: int = Form(0),
+ tags: str = Form(""),
+ host_id: str = Form(""),
+ account_id: str = Form(""),
+ credential_id: str = Form("cli"),
+):
+ username = _get_user(request)
+ if not username:
+ return RedirectResponse("/login", status_code=302)
+ if not model_name.strip():
+ return HTMLResponse(_render(username, error="Model name is required."))
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()]
+ if mtype == "local_openai":
+ if not host_id.strip():
+ return HTMLResponse(_render(username, error="Select a host for this model."))
+ reg.save_model(username, model_id, host_id, label, model_name, context_k, tag_list)
+ elif mtype == "gemini_api":
+ reg.save_cloud_model(username, model_id, "google", model_name, label,
+ account_id=account_id or None, context_k=context_k, tags=tag_list)
+ elif mtype == "claude_cli":
+ reg.save_cloud_model(username, model_id, "anthropic", model_name, label,
+ credential_id=credential_id or "cli", context_k=context_k, tags=tag_list)
+ else:
+ return HTMLResponse(_render(username, error=f"Unknown model type: {mtype}"))
+ display = label.strip() or model_name.strip()
+ logger.info("model edited: %s / %s (%s)", username, display, mtype)
+ return HTMLResponse(_render(username, success=f'Model "{display}" updated.'))
+
+
@router.post("/settings/local/models/{model_id}/remove", include_in_schema=False)
async def remove_model(request: Request, model_id: str):
username = _get_user(request)
diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py
index cd71901..1b58a1d 100644
--- a/cortex/routers/settings.py
+++ b/cortex/routers/settings.py
@@ -60,6 +60,9 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s
google_email = auth_data.get("google_email") or ""
html = html.replace("{{ google_email }}", google_email)
+ role = auth_data.get("role", "user")
+ html = html.replace("{{ user_role }}", role)
+
persona_items = "\n".join(
f'''
{p}
diff --git a/cortex/static/HELP.md b/cortex/static/HELP.md
index bb8a6a2..86ac009 100644
--- a/cortex/static/HELP.md
+++ b/cortex/static/HELP.md
@@ -6,7 +6,7 @@
and are appended automatically by help.html when present.
-->
-*Last updated: 2026-04-28*
+*Last updated: 2026-04-29*
---
@@ -63,15 +63,20 @@ The ⚡ toggle is **independent of the Role selector** — you can use any role
| Category | Tools |
|---|---|
-| Web | `web_search` |
-| Files | `file_read` |
-| Shell | `shell_exec`, `claude_allow_dir` |
+| Web | `web_search`, `http_fetch` |
+| Files | `file_read` ¹, `file_list` ¹, `file_write` ¹ ² |
+| Shell | `shell_exec` ¹ ², `claude_allow_dir` ¹ |
+| System | `cortex_restart` ¹ ², `cortex_logs` ¹ |
| Tasks | `task_list`, `task_create`, `task_update`, `task_complete` |
-| Cron | `cron_list`, `cron_add`, `cron_remove`, `cron_toggle` |
-| Reminders | `reminders_add`, `reminders_list`, `reminders_clear` |
+| Cron | `cron_list`, `cron_add`, `cron_remove` ², `cron_toggle` |
+| Reminders | `reminders_add`, `reminders_list`, `reminders_clear` ² |
| Scratchpad | `scratch_read`, `scratch_write`, `scratch_append`, `scratch_clear` |
+| Notifications | `nc_talk_send` ¹ |
| Aether Journals | `ae_journal_list`, `ae_journal_search`, `ae_journal_entry_create`, `ae_journal_entry_update`, `ae_journal_entry_disable`, `ae_journal_entry_append`, `ae_journal_entry_prepend` |
-| Aether Tasks | `ae_task_list` |
+| Aether Tasks | `ae_task_list` ¹ |
+
+¹ **Admin only** — requires the `admin` role. These tools are invisible to regular users.
+² **Confirmation required** — the orchestrator pauses and asks you to confirm before executing. Reply to confirm and it will proceed.
Tools mode is best for tasks requiring research, multi-step reasoning, or side effects (e.g. "search for X", "add a task", "what's on my list?", "append this to my journal"). Regular chat is faster for conversational turns.
@@ -101,6 +106,18 @@ Notes are injected into a session without triggering an LLM response.
---
+## Install as App (PWA)
+
+Cortex supports installation as a Progressive Web App — it runs in its own window with no browser chrome.
+
+- **Chrome / Edge (desktop):** Look for the install icon in the address bar, or open the browser menu → **Install Cortex…**
+- **Android (Chrome):** Tap ⋮ → **Add to Home Screen**
+- **iOS (Safari):** Tap the Share button → **Add to Home Screen**
+
+Once installed, opening Cortex from the home screen or app launcher skips the browser UI entirely.
+
+---
+
## Backends
Three backends are available:
diff --git a/cortex/static/local_llm.html b/cortex/static/local_llm.html
index eb27f27..77e1323 100644
--- a/cortex/static/local_llm.html
+++ b/cortex/static/local_llm.html
@@ -182,10 +182,18 @@
.fetch-status.err { color: #f87171; }
.model-row {
+ background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 8px;
+ margin-bottom: 0.5rem; overflow: hidden;
+ }
+ .model-row-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 0.9rem;
- background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 8px;
- margin-bottom: 0.5rem;
+ }
+ .model-btns { display: flex; gap: 0.4rem; align-items: flex-start; flex-shrink: 0; }
+ .model-edit-form {
+ padding: 0.85rem 0.9rem 0.9rem;
+ border-top: 1px solid var(--pg-border);
+ background: var(--pg-surface);
}
.model-info { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
.model-label { font-size: 0.9rem; font-weight: 600; color: var(--pg-text); }
@@ -670,6 +678,78 @@
// Hide fetch button initially if no hosts
if (!HAS_HOSTS) fetchBtn.style.display = 'none';
+ // ── Model inline edit toggle ──────────────────────────────────────────
+ document.querySelectorAll('.model-edit-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const id = btn.dataset.id;
+ const form = document.getElementById('edit-form-' + id);
+ const open = form.style.display !== 'none';
+ form.style.display = open ? 'none' : 'block';
+ btn.textContent = open ? 'Edit' : 'Close';
+ });
+ });
+ document.querySelectorAll('.model-edit-cancel').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const id = btn.dataset.id;
+ document.getElementById('edit-form-' + id).style.display = 'none';
+ document.querySelector(`.model-edit-btn[data-id="${id}"]`).textContent = 'Edit';
+ });
+ });
+
+ // ── Edit form: fetch models from host ────────────────────────────────
+ document.querySelectorAll('.edit-fetch-btn').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const id = btn.dataset.id;
+ const hostSel = document.getElementById('edit-host-' + id);
+ const hostId = hostSel ? hostSel.value : '';
+ const statusEl = document.getElementById('edit-fetch-status-' + id);
+ btn.disabled = true;
+ statusEl.textContent = 'Fetching…'; statusEl.className = 'fetch-status';
+ const url = '/api/local-llm/fetch-models' + (hostId ? '?host_id=' + encodeURIComponent(hostId) : '');
+ try {
+ const res = await fetch(url);
+ const data = await res.json();
+ if (data.error) {
+ statusEl.textContent = '✗ ' + data.error; statusEl.className = 'fetch-status err';
+ return;
+ }
+ const picker = document.getElementById('edit-model-picker-' + id);
+ const wrap = document.getElementById('edit-model-select-wrap-' + id);
+ picker.innerHTML = '— select to fill — ';
+ data.models.forEach(m => {
+ const opt = document.createElement('option');
+ opt.value = m.id;
+ opt.textContent = m.name !== m.id ? `${m.name} (${m.id})` : m.id;
+ opt.dataset.id = m.id;
+ opt.dataset.name = m.name;
+ picker.appendChild(opt);
+ });
+ wrap.style.display = 'block';
+ statusEl.textContent = `✓ ${data.models.length} model${data.models.length !== 1 ? 's' : ''}`;
+ statusEl.className = 'fetch-status ok';
+ } catch (e) {
+ statusEl.textContent = '✗ ' + e.message; statusEl.className = 'fetch-status err';
+ } finally {
+ btn.disabled = false;
+ }
+ });
+ });
+
+ document.querySelectorAll('[id^="edit-model-picker-"]').forEach(picker => {
+ picker.addEventListener('change', () => {
+ const opt = picker.options[picker.selectedIndex];
+ if (!opt.value) return;
+ const id = picker.id.replace('edit-model-picker-', '');
+ const form = document.getElementById('edit-form-' + id);
+ form.querySelector('input[name="model_name"]').value = opt.dataset.id || opt.value;
+ const labelInput = form.querySelector('input[name="label"]');
+ if (!labelInput.value) {
+ const n = opt.dataset.name;
+ labelInput.value = (n && n !== opt.dataset.id) ? n : '';
+ }
+ });
+ });
+
// ── Claude CLI auth status ─────────────────────────────────────────────
(async function() {
const el = document.getElementById('claude-auth-status');
diff --git a/cortex/static/settings.html b/cortex/static/settings.html
index ac6aa73..4e177b1 100644
--- a/cortex/static/settings.html
+++ b/cortex/static/settings.html
@@ -220,6 +220,26 @@
text-decoration: none;
}
.add-persona:hover { color: #a78bfa; }
+
+ .role-badge {
+ display: inline-block;
+ padding: 0.25rem 0.75rem;
+ border-radius: 20px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ }
+ .role-badge.role-admin {
+ background: rgba(124, 58, 237, 0.15);
+ color: #a78bfa;
+ border: 1px solid rgba(124, 58, 237, 0.4);
+ }
+ .role-badge.role-user {
+ background: rgba(100, 116, 139, 0.12);
+ color: var(--pg-muted);
+ border: 1px solid var(--pg-border);
+ }
@@ -247,6 +267,10 @@
Username