feat: schedules UI, task cron type, monthly/yearly schedules, AE DB tools, integrations page

- Schedules web UI (/settings/crons): list, add, edit, pause/resume, delete jobs
- cron task type: full orchestrator tool loop on a schedule, result → notification channel
- parse_schedule: monthly/yearly formats (monthly:DD:HH:MM, yearly:MM:DD:HH:MM)
- HA inbound webhook tools toggle: orchestrator loop vs. direct LLM, configurable in UI
- ae_db_query/describe/show_view: SELECT-only Aether MariaDB access (admin, per-user creds)
- /settings/integrations: admin-only page for Aether DB credentials
- Schedules nav link added to all settings pages
- pymysql added to requirements
- Docs updated: HELP.md, MASTER.md, CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-13 21:06:43 -04:00
parent 96b3c796c5
commit 70665fadff
20 changed files with 1362 additions and 33 deletions

View File

@@ -45,12 +45,15 @@ Cortex_and_Inara_dev/
google_chat.py ← POST /webhook/google (Google Chat Add-on) google_chat.py ← POST /webhook/google (Google Chat Add-on)
ui.py ← Login/logout, /{user}/{persona} UI route, /api/personas ui.py ← Login/logout, /{user}/{persona} UI route, /api/personas
onboarding.py ← /setup/{token} password step + /setup/persona creation onboarding.py ← /setup/{token} password step + /setup/persona creation
settings.py ← /settings, /settings/notifications, /settings/integrations (admin)
tools_settings.py ← /settings/tools
crons.py ← /settings/crons — Schedules web UI (list/add/edit/toggle/remove)
tools/ tools/
__init__.py ← Tool registry (Gemini FunctionDeclarations + dispatcher) __init__.py ← Tool registry (Gemini FunctionDeclarations + dispatcher)
web.py ← DuckDuckGo web_search tool web.py ← DuckDuckGo web_search tool
scratch.py ← Scratchpad tools (scratch_read/write/append/clear) scratch.py ← Scratchpad tools (scratch_read/write/append/clear)
tasks.py ← Personal task management (task_create/list/update/complete) tasks.py ← Personal task management (task_create/list/update/complete)
cron.py ← Scheduled job tools (cron_list/add/remove/toggle) cron.py ← Scheduled job tools (cron_list/add/remove/toggle); 5 types; hourly/daily/weekly/monthly/yearly schedules
system.py ← Local machine tools (claude_allow_dir) system.py ← Local machine tools (claude_allow_dir)
tests/ ← pytest test suite (80 tests) tests/ ← pytest test suite (80 tests)
static/ ← Single-page web UI (index.html, style.css, app.js) static/ ← Single-page web UI (index.html, style.css, app.js)
@@ -272,10 +275,12 @@ Cortex is running and stable. All channels are live:
| Token usage tracking | ✅ Live | Per-user `home/{user}/usage.json`; summary in Settings | | Token usage tracking | ✅ Live | Per-user `home/{user}/usage.json`; summary in Settings |
| Web push | ✅ Live | VAPID push notifications; `web_push` tool; subscribe via ☰ menu | | Web push | ✅ Live | VAPID push notifications; `web_push` tool; subscribe via ☰ menu |
| Proactive notifications | ✅ Live | Daily reminder check (09:00); distill/cron completions; `GET /settings/notifications` dedicated page | | Proactive notifications | ✅ Live | Daily reminder check (09:00); distill/cron completions; `GET /settings/notifications` dedicated page |
| Proactive cron | ✅ Live | 5 job types: `remind`, `note`, `message`, `brief`, `task` (full orchestrator loop); monthly/yearly schedule formats; HA inbound webhook tools toggle |
| Schedules web UI | ✅ Live | `/settings/crons` — list, add, edit, pause/resume, delete scheduled jobs |
Active users: scott (inara), holly (tina), brian (wintermute) Active users: scott (inara), holly (tina), brian (wintermute)
**62 orchestrator tools** across 16 domain modules: **65 orchestrator tools** across 17 domain modules:
web_search/http_fetch/web_read/http_post, web_search/http_fetch/web_read/http_post,
project_file_read/list + file_stat/grep/diff/syntax_check (project-scoped), project_file_read/list + file_stat/grep/diff/syntax_check (project-scoped),
file_read/list/write/session_read/session_search (system-scoped, admin), file_read/list/write/session_read/session_search (system-scoped, admin),
@@ -286,7 +291,8 @@ task_list/create/update/complete, cron_list/add/remove/toggle,
reminders_add/list/remove/clear, scratch_read/write/append/clear, reminders_add/list/remove/clear, scratch_read/write/append/clear,
web_push/email_send/nc_talk_send/nc_talk_history, web_push/email_send/nc_talk_send/nc_talk_history,
ae_journal_list/search/entries_list/entry_read/create/update/disable/append/prepend, ae_journal_list/search/entries_list/entry_read/create/update/disable/append/prepend,
ae_task_list, agent_notes_read/write/append/clear, spawn_agent, ae_task_list, ae_db_query/describe/show_view (SELECT-only MariaDB access, admin; disable requires confirm),
agent_notes_read/write/append/clear, spawn_agent,
ha_get_state/ha_get_states/ha_call_service. ha_get_state/ha_get_states/ha_call_service.
Each tool has a `TOOL_RISK` rating (low/medium/high). Configure access at `/settings/tools` Each tool has a `TOOL_RISK` rating (low/medium/high). Configure access at `/settings/tools`

View File

@@ -93,6 +93,18 @@ AE_API_KEY=
AE_ACCOUNT_ID= AE_ACCOUNT_ID=
AE_API_TIMEOUT=15 AE_API_TIMEOUT=15
# ── Aether MariaDB (direct — SELECT-only via ae_db_query/describe/show_view tools) ─
# Configured per-user in home/{username}/channels.json — NOT in .env.
# Add this block to the user's channels.json to enable the tools:
#
# "aether_db": {
# "host": "192.168.64.5",
# "port": 3306,
# "name": "aether_dev",
# "user": "aether_dev",
# "password": "..."
# }
# ── Distillation schedule ──────────────────────────────────────────────────── # ── Distillation schedule ────────────────────────────────────────────────────
SCHEDULER_TIMEZONE=America/New_York SCHEDULER_TIMEZONE=America/New_York
AUTO_DISTILL=true AUTO_DISTILL=true

View File

@@ -10,9 +10,9 @@ Job schema:
"id": "c_abc123", "id": "c_abc123",
"label": "Human-readable name", "label": "Human-readable name",
"schedule": "daily:09:00", # see parse_schedule() for all formats "schedule": "daily:09:00", # see parse_schedule() for all formats
"type": "remind" | "note" | "message" | "brief", "type": "remind" | "note" | "message" | "brief" | "task",
"payload": "Text or prompt when the job fires", "payload": "Text or prompt when the job fires",
"channel": null | "nextcloud" | "google_chat", # for message/brief types "channel": null | "nextcloud" | "google_chat", # for message/brief/task types
"enabled": true, "enabled": true,
"created_at": "ISO 8601", "created_at": "ISO 8601",
"last_run": null | "ISO 8601" "last_run": null | "ISO 8601"
@@ -21,9 +21,14 @@ Job schema:
Job types: Job types:
remind → appends to REMINDERS.md (auto-loaded into context at tier 2+) remind → appends to REMINDERS.md (auto-loaded into context at tier 2+)
note → appends to SCRATCH.md (read on demand via scratch_read) note → appends to SCRATCH.md (read on demand via scratch_read)
message → sends payload as-is to NC Talk notification_room message → sends payload as-is to notification channel
brief → runs LLM with payload as the prompt, sends response to NC Talk brief → calls LLM (no tools) with payload as prompt, sends response
(good for morning briefings, summaries, proactive check-ins) (good for morning briefings, summaries, proactive check-ins)
task → runs full orchestrator tool loop with payload as the user request,
sends Claude's response to notification channel
(good for agentic scheduled work: research, file updates, checks)
Tools that require confirmation are skipped — pre-approve them
in Settings → Tools to allow them in scheduled tasks.
""" """
import logging import logging
@@ -80,11 +85,16 @@ def parse_schedule(schedule: str) -> dict:
Convert a human schedule string to APScheduler cron kwargs. Convert a human schedule string to APScheduler cron kwargs.
Formats: Formats:
"hourly" → every hour at :00 "hourly" → every hour at :00
"daily" → every day at 09:00 "daily" → every day at 09:00
"daily:HH:MM" → every day at HH:MM "daily:HH:MM" → every day at HH:MM
"weekly:DOW" → every DOW at 09:00 "weekly:DOW" → every DOW at 09:00
"weekly:DOW:HH:MM" → every DOW at HH:MM "weekly:DOW:HH:MM" → every DOW at HH:MM
"monthly" → 1st of every month at 09:00
"monthly:DD" → day DD of every month at 09:00
"monthly:DD:HH:MM" → day DD of every month at HH:MM
"yearly:MM:DD" → every year on MM/DD at 09:00 (birthdays, anniversaries)
"yearly:MM:DD:HH:MM" → every year on MM/DD at HH:MM
""" """
s = schedule.strip().lower() s = schedule.strip().lower()
@@ -112,9 +122,37 @@ def parse_schedule(schedule: str) -> dict:
h, m = _DEFAULT_HOUR, _DEFAULT_MINUTE h, m = _DEFAULT_HOUR, _DEFAULT_MINUTE
return {"day_of_week": dow, "hour": h, "minute": m} return {"day_of_week": dow, "hour": h, "minute": m}
if s.startswith("monthly"):
rest = s[7:].lstrip(":")
if not rest:
return {"day": 1, "hour": _DEFAULT_HOUR, "minute": _DEFAULT_MINUTE}
parts = rest.split(":")
day = _parse_day(parts[0], schedule)
if len(parts) == 3:
h, m = _parse_hhmm(f"{parts[1]}:{parts[2]}", schedule)
else:
h, m = _DEFAULT_HOUR, _DEFAULT_MINUTE
return {"day": day, "hour": h, "minute": m}
if s.startswith("yearly:"):
rest = s[7:].split(":")
if len(rest) < 2:
raise ValueError(
f"yearly requires at least MM:DD in {schedule!r}. "
f"Example: yearly:03:15 or yearly:03:15:09:00"
)
month = _parse_month(rest[0], schedule)
day = _parse_day(rest[1], schedule)
if len(rest) == 4:
h, m = _parse_hhmm(f"{rest[2]}:{rest[3]}", schedule)
else:
h, m = _DEFAULT_HOUR, _DEFAULT_MINUTE
return {"month": month, "day": day, "hour": h, "minute": m}
raise ValueError( raise ValueError(
f"Unrecognised schedule {schedule!r}. " f"Unrecognised schedule {schedule!r}. "
f"Valid formats: hourly | daily | daily:HH:MM | weekly:DOW | weekly:DOW:HH:MM" f"Valid formats: hourly | daily | daily:HH:MM | weekly:DOW | weekly:DOW:HH:MM | "
f"monthly | monthly:DD | monthly:DD:HH:MM | yearly:MM:DD | yearly:MM:DD:HH:MM"
) )
@@ -125,6 +163,26 @@ def _parse_hhmm(s: str, original: str) -> tuple[int, int]:
return int(parts[0]), int(parts[1]) return int(parts[0]), int(parts[1])
def _parse_day(s: str, original: str) -> int:
try:
d = int(s)
except ValueError:
raise ValueError(f"Expected day number (131) in {original!r}, got {s!r}")
if not 1 <= d <= 31:
raise ValueError(f"Day must be 131 in {original!r}, got {d}")
return d
def _parse_month(s: str, original: str) -> int:
try:
m = int(s)
except ValueError:
raise ValueError(f"Expected month number (112) in {original!r}, got {s!r}")
if not 1 <= m <= 12:
raise ValueError(f"Month must be 112 in {original!r}, got {m}")
return m
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Execution # Execution
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -188,6 +246,55 @@ async def run_job(job: dict) -> None:
except Exception as e: except Exception as e:
logger.error("cron [brief] LLM error for %s: %s", label, e) logger.error("cron [brief] LLM error for %s: %s", label, e)
elif job_type == "task":
# Run the full orchestrator tool loop, send Claude's response to the
# notification channel. Tools that require confirmation are skipped in
# cron context — the user is notified to pre-approve them.
from orchestrator_engine import run as _orch_run
from context_loader import load_context
from notification import notify
from persona import set_context
from auth_utils import get_user_gemini_key, get_tool_policy, get_risk_policy
from config import settings as _s
username = job.get("user") or _s.user_name.lower()
persona_nm = job.get("persona") or _s.agent_name.lower()
channel = job.get("channel") or None
set_context(username, persona_nm)
system_prompt = load_context(2)
policy = get_tool_policy(username)
max_risk, whitelist, blacklist = get_risk_policy(username)
gemini_key = get_user_gemini_key(username)
try:
result = await _orch_run(
task=payload,
system_prompt=system_prompt,
gemini_api_key=gemini_key,
respond_with_claude=True,
confirm_allow=set(policy.get("allow") or []),
confirm_deny=set(policy.get("deny") or []),
max_risk=max_risk,
risk_whitelist=whitelist,
risk_blacklist=blacklist,
)
if result.checkpoint:
tool_name = (result.checkpoint.pending_calls[0].name
if result.checkpoint.pending_calls else "unknown tool")
msg = (
f"Scheduled task '{label}' paused — "
f"'{tool_name}' requires confirmation. "
"Pre-approve it in Settings → Tools to allow it in scheduled tasks."
)
await notify(username, msg, channel=channel)
logger.warning("cron [task] %s: confirmation required for %s", label, tool_name)
else:
await notify(username, result.response, channel=channel)
logger.info("cron [task] completed via %s: %s", result.backend, label)
except Exception as e:
logger.error("cron [task] error for %s: %s", label, e)
else: else:
logger.warning("cron: unknown type %r (job %s)", job_type, job.get("id")) logger.warning("cron: unknown type %r (job %s)", job_type, job.get("id"))
return return

View File

@@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag
from config import settings from config import settings
from auth_middleware import SessionAuthMiddleware from auth_middleware import SessionAuthMiddleware
from routers import chat, google_chat, nextcloud_talk, homeassistant, files, distill, auth, orchestrator from routers import chat, google_chat, nextcloud_talk, homeassistant, files, distill, auth, orchestrator
from routers import ui, onboarding, settings, tools_settings, help, auth_google, local_llm, push, audit, usage from routers import ui, onboarding, settings, tools_settings, help, auth_google, local_llm, push, audit, usage, crons
@asynccontextmanager @asynccontextmanager
@@ -53,6 +53,7 @@ app.include_router(onboarding.router)
app.include_router(settings.router) app.include_router(settings.router)
app.include_router(tools_settings.router) app.include_router(tools_settings.router)
app.include_router(local_llm.router) app.include_router(local_llm.router)
app.include_router(crons.router)
# Help page # Help page
app.include_router(help.router) app.include_router(help.router)

View File

@@ -28,5 +28,8 @@ openai>=1.0.0
# Web Push / VAPID — browser push notifications # Web Push / VAPID — browser push notifications
pywebpush>=2.0.0 pywebpush>=2.0.0
# MariaDB / MySQL connector — used by ae_db_query orchestrator tool
pymysql>=1.1.0
# anthropic SDK not needed — using claude CLI subprocess for auth # anthropic SDK not needed — using claude CLI subprocess for auth
# anthropic>=0.40.0 # anthropic>=0.40.0

479
cortex/routers/crons.py Normal file
View File

@@ -0,0 +1,479 @@
"""
Schedules web UI — GET/POST /settings/crons/*
Lets users view, add, toggle, and remove cron jobs without going through the AI.
Cron data lives in home/{user}/persona/{persona}/CRONS.json.
Scheduler registration mirrors what tools/cron.py does so changes take effect immediately.
"""
import html as _html
import logging
import secrets
from datetime import datetime, timezone
from pathlib import Path
import jwt
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from auth_utils import COOKIE_NAME, decode_token
from cron_runner import load_crons, save_crons, parse_schedule
from persona import list_user_personas
logger = logging.getLogger(__name__)
router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static"
_LAST_PERSONA_COOKIE = "cx_last_persona"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_session_user(request: Request) -> str | None:
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
try:
return decode_token(token)
except jwt.InvalidTokenError:
return None
def _preferred_persona(request: Request, username: str) -> str:
names = list_user_personas(username)
if not names:
return ""
cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
if cookie_val in names:
return cookie_val
return names[0]
def _integrations_nav(username: str) -> str:
from auth_utils import _read_auth
role = _read_auth(username).get("role", "user")
if role == "admin":
return '<a href="/settings/integrations" class="nav-link">Integrations</a>'
return ""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _short_id() -> str:
return "c_" + secrets.token_urlsafe(6)
def _scheduler_add(job: dict, sched_kwargs: dict) -> None:
import asyncio
try:
import scheduler as sched_module
from cron_runner import run_job
s = sched_module.get_scheduler()
if s and s.running:
sched_id = f"{job['user']}:{job['persona']}:{job['id']}"
s.add_job(
lambda j=job: asyncio.ensure_future(run_job(j)),
"cron",
id=sched_id,
replace_existing=True,
**sched_kwargs,
)
except Exception as e:
logger.warning("scheduler_add failed: %s", e)
def _scheduler_remove(job_id: str) -> None:
try:
import scheduler as sched_module
s = sched_module.get_scheduler()
if s and s.running:
s.remove_job(job_id)
except Exception:
pass
def _scheduler_pause(job_id: str) -> None:
try:
import scheduler as sched_module
s = sched_module.get_scheduler()
if s and s.running:
s.pause_job(job_id)
except Exception:
pass
def _scheduler_resume(job_id: str) -> None:
try:
import scheduler as sched_module
s = sched_module.get_scheduler()
if s and s.running:
s.resume_job(job_id)
except Exception:
pass
_TYPE_CLASS = {
"remind": "badge-remind", "note": "badge-note", "message": "badge-message",
"brief": "badge-brief", "task": "badge-task",
}
def _render_cron_list(username: str) -> str:
personas = list_user_personas(username)
if not personas:
return '<div class="empty-state">No personas found.</div>'
all_empty = True
html_parts: list[str] = []
for persona in personas:
crons = load_crons(username, persona)
if not crons:
continue
all_empty = False
rows = []
for c in crons:
cid = _html.escape(c["id"])
label = _html.escape(c.get("label", ""))
schedule = _html.escape(c.get("schedule", ""))
job_type = _html.escape(c.get("type", ""))
payload = _html.escape(c.get("payload", ""))
enabled = c.get("enabled", True)
last_run = (c.get("last_run") or "")[:10] or "never"
pers_esc = _html.escape(persona)
type_class = _TYPE_CLASS.get(c.get("type", ""), "badge-note")
status_cls = "badge-enabled" if enabled else "badge-paused"
status_txt = "enabled" if enabled else "paused"
toggle_txt = "Pause" if enabled else "Resume"
rows.append(f"""
<tr>
<td>{label}</td>
<td><code>{schedule}</code></td>
<td><span class="badge {type_class}">{job_type}</span></td>
<td class="payload-cell" title="{payload}">{payload}</td>
<td>{last_run}</td>
<td><span class="badge {status_cls}">{status_txt}</span></td>
<td>
<div class="cron-actions">
<a href="/settings/crons/edit?cron_id={cid}&persona={pers_esc}"
class="btn-cron">Edit</a>
<form method="POST" action="/settings/crons/toggle" style="display:inline">
<input type="hidden" name="cron_id" value="{cid}">
<input type="hidden" name="persona" value="{pers_esc}">
<button type="submit" class="btn-cron">{toggle_txt}</button>
</form>
<form method="POST" action="/settings/crons/remove" style="display:inline"
onsubmit="return confirm('Delete this schedule?')">
<input type="hidden" name="cron_id" value="{cid}">
<input type="hidden" name="persona" value="{pers_esc}">
<button type="submit" class="btn-cron btn-cron-del">Delete</button>
</form>
</div>
</td>
</tr>""")
html_parts.append(f"""
<div class="persona-group">
<p class="persona-group-label">{_html.escape(persona)}</p>
<table class="cron-table">
<thead>
<tr>
<th>Label</th><th>Schedule</th><th>Type</th>
<th>Payload</th><th>Last run</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>{"".join(rows)}
</tbody>
</table>
</div>""")
if all_empty:
return '<div class="empty-state">No schedules yet. Add one below.</div>'
return "\n".join(html_parts)
def _persona_options(username: str, selected: str = "") -> str:
personas = list_user_personas(username)
return "\n".join(
f'<option value="{_html.escape(p)}"{"selected" if p == selected else ""}>{_html.escape(p)}</option>'
for p in personas
)
_TYPE_OPTIONS = ("remind", "note", "message", "brief", "task")
_TYPE_LABELS = {
"remind": "remind — append to REMINDERS.md",
"note": "note — append to SCRATCH.md",
"message": "message — send payload as-is",
"brief": "brief — LLM response, no tools",
"task": "task — full orchestrator tool loop",
}
def _render_edit_form(job: dict, persona: str) -> str:
cid = _html.escape(job["id"])
pers_esc = _html.escape(persona)
label = _html.escape(job.get("label", ""))
schedule = _html.escape(job.get("schedule", ""))
payload = _html.escape(job.get("payload", ""))
cur_type = job.get("type", "remind")
type_opts = "\n".join(
f'<option value="{t}" {"selected" if t == cur_type else ""}>{_html.escape(_TYPE_LABELS.get(t, t))}</option>'
for t in _TYPE_OPTIONS
)
return f"""
<div class="section" style="border: 2px solid var(--pg-accent); border-radius: 8px; padding: 1rem;">
<h2 style="margin-top:0">Edit schedule</h2>
<form method="POST" action="/settings/crons/save">
<input type="hidden" name="cron_id" value="{cid}">
<input type="hidden" name="persona" value="{pers_esc}">
<div class="add-form-grid">
<div class="field">
<label>Persona</label>
<input type="text" value="{pers_esc}" disabled style="opacity:0.5">
</div>
<div class="field">
<label for="edit_job_type">Type</label>
<select id="edit_job_type" name="job_type">{type_opts}</select>
</div>
<div class="field">
<label for="edit_label">Label</label>
<input type="text" id="edit_label" name="label" value="{label}" required autocomplete="off">
</div>
<div class="field">
<label for="edit_schedule">Schedule</label>
<input type="text" id="edit_schedule" name="schedule" value="{schedule}"
required autocomplete="off" spellcheck="false">
<p class="hint">
hourly · daily · daily:HH:MM · weekly:DOW · weekly:DOW:HH:MM ·
monthly · monthly:DD · monthly:DD:HH:MM · yearly:MM:DD · yearly:MM:DD:HH:MM
</p>
</div>
<div class="field field-full">
<label for="edit_payload">Payload / prompt</label>
<textarea id="edit_payload" name="payload" rows="3" required>{payload}</textarea>
</div>
</div>
<div style="display:flex; gap:0.5rem; align-items:center; margin-top:0.5rem">
<button type="submit" class="btn-submit" style="margin-top:0">Save changes</button>
<a href="/settings/crons" style="font-size:0.85rem; color:var(--pg-muted)">Cancel</a>
</div>
</form>
</div>"""
def _render_page(username: str, back_persona: str = "", success: str = "", error: str = "",
edit_html: str = "") -> str:
html = (_STATIC / "crons.html").read_text()
html = html.replace("{{ edit_html }}", edit_html)
html = html.replace("{{ cron_list_html }}", _render_cron_list(username))
html = html.replace("{{ persona_options }}", _persona_options(username, back_persona))
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
html = html.replace("{{ integrations_nav }}", _integrations_nav(username))
if success:
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{_html.escape(success)}</p>')
if error:
html = html.replace("<!-- ERROR -->", f'<p class="error">{_html.escape(error)}</p>')
return html
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/settings/crons", include_in_schema=False)
async def crons_page(request: Request):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
back_persona = _preferred_persona(request, username)
return HTMLResponse(_render_page(username, back_persona))
@router.post("/settings/crons/add", include_in_schema=False)
async def cron_add(
request: Request,
persona: str = Form(""),
label: str = Form(""),
schedule: str = Form(""),
job_type: str = Form(""),
payload: str = Form(""),
):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
back_persona = _preferred_persona(request, username)
label = label.strip()
schedule = schedule.strip()
payload = payload.strip()
persona = persona.strip()
_VALID_TYPES = ("remind", "note", "message", "brief", "task")
if job_type not in _VALID_TYPES:
return HTMLResponse(_render_page(username, back_persona, error=f"Invalid type: {job_type}"))
try:
sched_kwargs = parse_schedule(schedule)
except ValueError as e:
return HTMLResponse(_render_page(username, back_persona, error=f"Bad schedule: {e}"))
if not label:
return HTMLResponse(_render_page(username, back_persona, error="Label is required."))
if not payload:
return HTMLResponse(_render_page(username, back_persona, error="Payload is required."))
crons = load_crons(username, persona)
job = {
"id": _short_id(),
"user": username,
"persona": persona,
"label": label,
"schedule": schedule,
"type": job_type,
"payload": payload,
"enabled": True,
"created_at": _now(),
"last_run": None,
}
crons.append(job)
save_crons(crons, username, persona)
_scheduler_add(job, sched_kwargs)
logger.info("cron added via UI: %s %s [%s]", job["id"], schedule, job_type)
return HTMLResponse(_render_page(username, back_persona, success=f"Schedule '{label}' added."))
@router.post("/settings/crons/toggle", include_in_schema=False)
async def cron_toggle(
request: Request,
cron_id: str = Form(""),
persona: str = Form(""),
):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
back_persona = _preferred_persona(request, username)
crons = load_crons(username, persona)
for c in crons:
if c["id"] == cron_id:
c["enabled"] = not c.get("enabled", True)
save_crons(crons, username, persona)
sched_id = f"{username}:{persona}:{cron_id}"
if c["enabled"]:
_scheduler_resume(sched_id)
action = "resumed"
else:
_scheduler_pause(sched_id)
action = "paused"
logger.info("cron %s %s via UI", cron_id, action)
return HTMLResponse(_render_page(username, back_persona, success=f"Schedule {action}."))
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))
@router.post("/settings/crons/remove", include_in_schema=False)
async def cron_remove(
request: Request,
cron_id: str = Form(""),
persona: str = Form(""),
):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
back_persona = _preferred_persona(request, username)
crons = load_crons(username, persona)
before = len(crons)
crons = [c for c in crons if c["id"] != cron_id]
if len(crons) == before:
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))
save_crons(crons, username, persona)
_scheduler_remove(f"{username}:{persona}:{cron_id}")
logger.info("cron %s removed via UI", cron_id)
return HTMLResponse(_render_page(username, back_persona, success="Schedule deleted."))
@router.get("/settings/crons/edit", include_in_schema=False)
async def cron_edit_page(request: Request, cron_id: str = "", persona: str = ""):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
back_persona = _preferred_persona(request, username)
crons = load_crons(username, persona)
job = next((c for c in crons if c["id"] == cron_id), None)
if not job:
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))
edit_html = _render_edit_form(job, persona)
return HTMLResponse(_render_page(username, back_persona, edit_html=edit_html))
@router.post("/settings/crons/save", include_in_schema=False)
async def cron_save(
request: Request,
cron_id: str = Form(""),
persona: str = Form(""),
label: str = Form(""),
schedule: str = Form(""),
job_type: str = Form(""),
payload: str = Form(""),
):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
back_persona = _preferred_persona(request, username)
label = label.strip()
schedule = schedule.strip()
payload = payload.strip()
if job_type not in _TYPE_OPTIONS:
return HTMLResponse(_render_page(username, back_persona, error=f"Invalid type: {job_type}"))
if not label:
return HTMLResponse(_render_page(username, back_persona, error="Label is required."))
if not payload:
return HTMLResponse(_render_page(username, back_persona, error="Payload is required."))
try:
sched_kwargs = parse_schedule(schedule)
except ValueError as e:
# Re-render with the edit form still open so the user can fix the schedule
crons = load_crons(username, persona)
job = next((c for c in crons if c["id"] == cron_id), None)
edit_html = _render_edit_form(job, persona) if job else ""
return HTMLResponse(_render_page(username, back_persona, error=f"Bad schedule: {e}",
edit_html=edit_html))
crons = load_crons(username, persona)
for c in crons:
if c["id"] == cron_id:
c["label"] = label
c["schedule"] = schedule
c["type"] = job_type
c["payload"] = payload
save_crons(crons, username, persona)
# Replace the live scheduler job with the updated schedule
sched_id = f"{username}:{persona}:{cron_id}"
_scheduler_remove(sched_id)
if c.get("enabled", True):
_scheduler_add(c, sched_kwargs)
logger.info("cron %s updated via UI [%s]", cron_id, schedule)
return HTMLResponse(_render_page(username, back_persona,
success=f"Schedule '{label}' updated."))
return HTMLResponse(_render_page(username, back_persona, error=f"Schedule not found: {cron_id}"))

View File

@@ -12,7 +12,7 @@ import jwt
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from auth_utils import COOKIE_NAME, decode_token from auth_utils import COOKIE_NAME, decode_token, _read_auth
from persona import list_user_personas from persona import list_user_personas
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -64,4 +64,7 @@ async def help_page(request: Request, persona: str = ""):
f'{{user: "{username}", persona: "{back_persona}", backHref: "{back_href}"}};</script>' f'{{user: "{username}", persona: "{back_persona}", backHref: "{back_href}"}};</script>'
) )
html = html.replace("</head>", f"{config_tag}\n</head>", 1) html = html.replace("</head>", f"{config_tag}\n</head>", 1)
nav = '<a href="/settings/integrations" class="nav-link">Integrations</a>' \
if _read_auth(username).get("role", "user") == "admin" else ""
html = html.replace("{{ integrations_nav }}", nav)
return HTMLResponse(html) return HTMLResponse(html)

View File

@@ -53,6 +53,14 @@ def _preferred_persona(request: Request, username: str) -> str:
return names[0] return names[0]
def _integrations_nav(username: str) -> str:
"""Return the Integrations nav link for admin users, empty string otherwise."""
role = _read_auth(username).get("role", "user")
if role == "admin":
return '<a href="/settings/integrations" class="nav-link">Integrations</a>'
return ""
def _notifications_page(username: str, back_persona: str = "", success: str = "", error: str = "") -> str: def _notifications_page(username: str, back_persona: str = "", success: str = "", error: str = "") -> str:
html = (_STATIC / "notifications.html").read_text() html = (_STATIC / "notifications.html").read_text()
channels = get_user_channels(username) channels = get_user_channels(username)
@@ -69,6 +77,7 @@ def _notifications_page(username: str, back_persona: str = "", success: str = ""
ha = channels.get("homeassistant") or {} ha = channels.get("homeassistant") or {}
ha_url = _html.escape(ha.get("url", "") or "") ha_url = _html.escape(ha.get("url", "") or "")
ha_webhook_id = _html.escape(ha.get("webhook_id", "") or "") ha_webhook_id = _html.escape(ha.get("webhook_id", "") or "")
ha_tools_checked = "checked" if ha.get("tools", False) else ""
html = html.replace("{{ notify_channel }}", notify_ch) html = html.replace("{{ notify_channel }}", notify_ch)
html = html.replace("{{ notify_email_override }}", notify_email) html = html.replace("{{ notify_email_override }}", notify_email)
@@ -80,9 +89,11 @@ def _notifications_page(username: str, back_persona: str = "", success: str = ""
html = html.replace("{{ gc_webhook }}", gc_webhook) html = html.replace("{{ gc_webhook }}", gc_webhook)
html = html.replace("{{ ha_url }}", ha_url) html = html.replace("{{ ha_url }}", ha_url)
html = html.replace("{{ ha_webhook_id }}", ha_webhook_id) html = html.replace("{{ ha_webhook_id }}", ha_webhook_id)
html = html.replace("{{ ha_tools_checked }}", ha_tools_checked)
html = html.replace("{{ ha_username }}", username) html = html.replace("{{ ha_username }}", username)
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/") html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help") html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
html = html.replace("{{ integrations_nav }}", _integrations_nav(username))
if success: if success:
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>') html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
if error: if error:
@@ -137,6 +148,25 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s
back_persona = personas[0] if personas else "" back_persona = personas[0] if personas else ""
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/") html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help") html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
html = html.replace("{{ integrations_nav }}", _integrations_nav(username))
if success:
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
if error:
html = html.replace("<!-- ERROR -->", f'<p class="error">{error}</p>')
return html
def _integrations_page(username: str, back_persona: str = "", success: str = "", error: str = "") -> str:
html = (_STATIC / "integrations.html").read_text()
channels = get_user_channels(username)
ae_db = channels.get("aether_db") or {}
html = html.replace("{{ ae_db_host }}", _html.escape(ae_db.get("host", "") or ""))
html = html.replace("{{ ae_db_port }}", _html.escape(str(ae_db.get("port", 3306))))
html = html.replace("{{ ae_db_name }}", _html.escape(ae_db.get("name", "") or ""))
html = html.replace("{{ ae_db_user }}", _html.escape(ae_db.get("user", "") or ""))
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
if success: if success:
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>') html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
if error: if error:
@@ -308,6 +338,7 @@ async def save_notifications(
ha_url: str = Form(""), ha_url: str = Form(""),
ha_token: str = Form(""), ha_token: str = Form(""),
ha_webhook_id: str = Form(""), ha_webhook_id: str = Form(""),
ha_tools: str = Form(""),
): ):
username = _get_session_user(request) username = _get_session_user(request)
if not username: if not username:
@@ -365,6 +396,7 @@ async def save_notifications(
ha["token"] = ha_token.strip() ha["token"] = ha_token.strip()
if ha_webhook_id.strip(): if ha_webhook_id.strip():
ha["webhook_id"] = ha_webhook_id.strip() ha["webhook_id"] = ha_webhook_id.strip()
ha["tools"] = ha_tools == "1"
channels_path.write_text(json.dumps(channels, indent=2) + "\n") channels_path.write_text(json.dumps(channels, indent=2) + "\n")
logger.info("notifications updated for %s (channel=%s)", username, notification_channel or "none") logger.info("notifications updated for %s (channel=%s)", username, notification_channel or "none")
@@ -405,3 +437,63 @@ async def save_http_allowlist(
path.write_text(json.dumps(lines, indent=2)) path.write_text(json.dumps(lines, indent=2))
logger.info("http allowlist updated for %s (%d prefixes)", username, len(lines)) logger.info("http allowlist updated for %s (%d prefixes)", username, len(lines))
return HTMLResponse(_settings_page(username, personas, back_persona, success=f"HTTP allowlist saved ({len(lines)} prefix{'es' if len(lines) != 1 else ''}).")) return HTMLResponse(_settings_page(username, personas, back_persona, success=f"HTTP allowlist saved ({len(lines)} prefix{'es' if len(lines) != 1 else ''})."))
def _require_admin(username: str) -> bool:
return _read_auth(username).get("role", "user") == "admin"
@router.get("/settings/integrations", include_in_schema=False)
async def integrations_page(request: Request):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
if not _require_admin(username):
return RedirectResponse("/settings", status_code=302)
back_persona = _preferred_persona(request, username)
return HTMLResponse(_integrations_page(username, back_persona))
@router.post("/settings/integrations", include_in_schema=False)
async def save_integrations(
request: Request,
ae_db_host: str = Form(""),
ae_db_port: str = Form("3306"),
ae_db_name: str = Form(""),
ae_db_user: str = Form(""),
ae_db_password: str = Form(""),
):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
if not _require_admin(username):
return RedirectResponse("/settings", status_code=302)
back_persona = _preferred_persona(request, username)
channels_path = app_settings.home_root() / username / "channels.json"
try:
channels = json.loads(channels_path.read_text())
except Exception:
channels = {}
if "aether_db" not in channels:
channels["aether_db"] = {}
db = channels["aether_db"]
if ae_db_host.strip():
db["host"] = ae_db_host.strip()
try:
db["port"] = int(ae_db_port.strip()) if ae_db_port.strip() else 3306
except ValueError:
db["port"] = 3306
if ae_db_name.strip():
db["name"] = ae_db_name.strip()
if ae_db_user.strip():
db["user"] = ae_db_user.strip()
if ae_db_password.strip():
db["password"] = ae_db_password.strip()
channels_path.write_text(json.dumps(channels, indent=2) + "\n")
logger.info("integrations updated for %s", username)
return HTMLResponse(_integrations_page(username, back_persona, success="Integration settings saved."))

View File

@@ -15,7 +15,7 @@ import jwt
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from auth_utils import COOKIE_NAME, decode_token, get_tool_policy, save_tool_policy from auth_utils import COOKIE_NAME, decode_token, get_tool_policy, save_tool_policy, _read_auth
from persona import list_user_personas from persona import list_user_personas
from tools import TOOL_CATEGORIES, TOOL_RISK, CONFIRM_REQUIRED from tools import TOOL_CATEGORIES, TOOL_RISK, CONFIRM_REQUIRED
@@ -123,6 +123,9 @@ def _tools_page(
html = html.replace("{{ tool_deny }}", _html.escape("\n".join(policy.get("deny") or []))) html = html.replace("{{ tool_deny }}", _html.escape("\n".join(policy.get("deny") or [])))
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/") html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help") html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
nav = '<a href="/settings/integrations" class="nav-link">Integrations</a>' \
if _read_auth(username).get("role", "user") == "admin" else ""
html = html.replace("{{ integrations_nav }}", nav)
if success: if success:
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>') html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')

View File

@@ -6,7 +6,7 @@
and are appended automatically by help.html when present. and are appended automatically by help.html when present.
--> -->
*Last updated: 2026-05-12* *Last updated: 2026-05-13*
--- ---
@@ -82,7 +82,7 @@ Orchestrated sessions persist to history exactly like regular chat.
### Available Tools ### Available Tools
62 tools across 16 categories. Each tool schema is sent to the model on every orchestrated call — fewer active tools means fewer tokens per call. 65 tools across 17 categories. Each tool schema is sent to the model on every orchestrated call — fewer active tools means fewer tokens per call.
| Category | Tools | | Category | Tools |
|---|---| |---|---|
@@ -99,13 +99,15 @@ Orchestrated sessions persist to history exactly like regular chat.
| **Notifications** | `web_push`, `email_send`, `nc_talk_send`, `nc_talk_history` | | **Notifications** | `web_push`, `email_send`, `nc_talk_send`, `nc_talk_history` |
| **Aether Journals** | `ae_journal_list/search`, `ae_journal_entries_list`, `ae_journal_entry_read/create/update/disable/append/prepend` | | **Aether Journals** | `ae_journal_list/search`, `ae_journal_entries_list`, `ae_journal_entry_read/create/update/disable/append/prepend` |
| **Aether Tasks** | `ae_task_list` | | **Aether Tasks** | `ae_task_list` |
| **Aether Database** (admin) | `ae_db_query`, `ae_db_describe`, `ae_db_show_view` |
| **Agent Notes** | `agent_notes_read`, `agent_notes_write`, `agent_notes_append`, `agent_notes_clear` | | **Agent Notes** | `agent_notes_read`, `agent_notes_write`, `agent_notes_append`, `agent_notes_clear` |
| **Agents** | `spawn_agent` | | **Agents** | `spawn_agent` |
| **Home Assistant** | `ha_get_state`, `ha_get_states`, `ha_call_service` | | **Home Assistant** | `ha_get_state`, `ha_get_states`, `ha_call_service` |
Files, Shell, System, Agents, and some Notification/Web tools are **admin-only** and not visible to regular users. Files, Shell, System, Aether Database, Agents, and some Notification/Web tools are **admin-only** and not visible to regular users.
`http_post` requires a URL prefix allowlist in `home/{user}/http_allowlist.json`. `http_post` requires a URL prefix allowlist in `home/{user}/http_allowlist.json`.
`nc_talk_history` requires `nc_username` and `nc_app_password` in `channels.json` under `nextcloud`. `nc_talk_history` requires `nc_username` and `nc_app_password` in `channels.json` under `nextcloud`.
`ae_db_*` tools require Aether DB credentials configured in **Integrations** settings. All queries are SELECT-only — no writes possible.
### Per-Role Tool Sets ### Per-Role Tool Sets
@@ -175,7 +177,8 @@ Each response shows a **model tag** (bottom-right of message) with the model lab
| **Account** | View your username, role badge (Admin / User), rename your username | | **Account** | View your username, role badge (Admin / User), rename your username |
| **Connected Accounts** | See which Google account is linked for OAuth sign-in | | **Connected Accounts** | See which Google account is linked for OAuth sign-in |
| **Email Allowlist** | Regex patterns controlling which addresses the `email_send` tool can reach | | **Email Allowlist** | Regex patterns controlling which addresses the `email_send` tool can reach |
| **Notifications** | Dedicated page — set channel (Browser Push, NC Talk, Google Chat, email) for proactive messages; test buttons for instant verification | | **Notifications** | Dedicated page — set channel (Browser Push, NC Talk, Google Chat, email) for proactive messages; configure Home Assistant inbound webhook; test buttons for instant verification |
| **Schedules** | View, add, edit, pause, and delete scheduled jobs directly — without going through the AI |
| **Tool Permissions** | Allow or block specific orchestrator tools for your account | | **Tool Permissions** | Allow or block specific orchestrator tools for your account |
| **Usage** | Token consumption by model — see below | | **Usage** | Token consumption by model — see below |
| **Browser Cache** | Clear UI preferences stored locally (theme, font size, session ID, etc.) | | **Browser Cache** | Clear UI preferences stored locally (theme, font size, session ID, etc.) |
@@ -382,6 +385,53 @@ Distillation builds up the memory layers from raw session logs. Runs automatical
--- ---
## Scheduled Jobs
Cortex can run recurring jobs on a schedule — reminders, daily briefings, automated research, and more. Manage them by asking Inara to set them up, or go directly to **☰ → Account → Schedules**.
### Job Types
| Type | What it does |
|---|---|
| `remind` | Appends to `REMINDERS.md` — automatically surfaced in chat context |
| `note` | Appends to `SCRATCH.md` — read on demand via the scratchpad |
| `message` | Sends the payload text directly to your notification channel |
| `brief` | Calls the AI with your payload as the prompt, sends the response to your notification channel. Good for morning briefings, check-ins. |
| `task` | Runs the full orchestrator tool loop with your payload as the request, sends Claude's response to your notification channel. Use this for agentic scheduled work: research, file updates, summaries that need tool access. |
For `task` jobs: tools that require confirmation are skipped in scheduled context. Pre-approve them in **Settings → Tools** to allow them in scheduled tasks.
### Schedule Formats
| Format | When it runs |
|---|---|
| `hourly` | Every hour at :00 |
| `daily` | Every day at 09:00 |
| `daily:HH:MM` | Every day at the specified time |
| `weekly:DOW` | Every specified day at 09:00 (e.g. `weekly:mon`) |
| `weekly:DOW:HH:MM` | Every specified day at the specified time (e.g. `weekly:fri:17:00`) |
| `monthly` | 1st of every month at 09:00 |
| `monthly:DD` | Specific day of month at 09:00 (e.g. `monthly:15`) |
| `monthly:DD:HH:MM` | Specific day of month at the specified time |
| `yearly:MM:DD` | Every year on that date at 09:00 — for birthdays, anniversaries (e.g. `yearly:03:15`) |
| `yearly:MM:DD:HH:MM` | Every year on that date at the specified time |
DOW values: `mon tue wed thu fri sat sun`. All times are server-local.
Schedules take effect immediately when added or edited — no restart needed. Paused jobs stay in the list and can be resumed at any time.
### Home Assistant Integration
HA automations can trigger Inara via webhook. Configure in **Notifications → Home Assistant → Inbound webhook**:
- Set a **Webhook ID** (long random string — this is your secret URL component)
- Your endpoint: `https://cortex.dgrzone.com/webhook/ha/{username}/{webhook_id}`
- **Enable orchestrator tools** — when checked, HA events trigger the full tool loop; when unchecked, events get a direct LLM response (faster, no tools)
HA payload fields recognized: `message`, `entity_id`, `state`, `trigger`, `event`, `area`.
---
## Keyboard Shortcuts ## Keyboard Shortcuts
| Keys | Action | | Keys | Action |

150
cortex/static/crons.html Normal file
View File

@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Schedules</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style>
.cron-table {
width: 100%; border-collapse: collapse; font-size: 0.82rem;
margin-bottom: 1.5rem;
}
.cron-table th {
text-align: left; padding: 0.4rem 0.6rem;
border-bottom: 2px solid var(--pg-border);
color: var(--pg-muted); font-weight: 600; font-size: 0.75rem;
text-transform: uppercase; letter-spacing: 0.04em;
}
.cron-table td {
padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--pg-border);
vertical-align: middle;
}
.cron-table tr:last-child td { border-bottom: none; }
.cron-table tr:hover td { background: var(--pg-hover); }
.badge {
display: inline-block; padding: 0.15rem 0.45rem;
border-radius: 4px; font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.03em;
}
.badge-enabled { background: color-mix(in srgb, var(--pg-accent) 18%, transparent); color: var(--pg-accent); }
.badge-paused { background: color-mix(in srgb, var(--pg-muted) 18%, transparent); color: var(--pg-muted); }
.badge-remind { background: color-mix(in srgb, #a78bfa 15%, transparent); color: #a78bfa; }
.badge-note { background: color-mix(in srgb, #60a5fa 15%, transparent); color: #60a5fa; }
.badge-message { background: color-mix(in srgb, #34d399 15%, transparent); color: #34d399; }
.badge-brief { background: color-mix(in srgb, #fb923c 15%, transparent); color: #fb923c; }
.badge-task { background: color-mix(in srgb, #f472b6 15%, transparent); color: #f472b6; }
.cron-actions { display: flex; gap: 0.35rem; }
.btn-cron {
padding: 0.2rem 0.55rem; border-radius: 4px; border: 1px solid var(--pg-border);
background: transparent; color: var(--pg-muted); font-size: 0.75rem; cursor: pointer;
font-family: inherit;
}
.btn-cron:hover { border-color: var(--pg-accent); color: var(--pg-accent); }
.btn-cron-del:hover { border-color: var(--pg-danger, #ef4444); color: var(--pg-danger, #ef4444); }
.btn-cron-del { color: var(--pg-dimmer); }
.payload-cell {
max-width: 240px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; color: var(--pg-dimmer);
}
.persona-group { margin-bottom: 0.25rem; }
.persona-group-label {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--pg-dimmer); margin: 1.25rem 0 0.5rem;
}
.empty-state {
text-align: center; padding: 2rem 1rem;
color: var(--pg-dimmer); font-size: 0.85rem;
border: 1px dashed var(--pg-border); border-radius: 8px;
margin-bottom: 1.5rem;
}
.add-form-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0 0.75rem;
}
.add-form-grid .field-full { grid-column: 1 / -1; }
</style>
</head>
<body>
<nav class="page-nav">
<a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link active">Schedules</a>
{{ integrations_nav }}
<span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav>
<div class="page-wrap">
<h1 class="page-title">Schedules</h1>
<p class="page-subtitle">Recurring jobs — reminders, notes, briefings, and agentic tasks.</p>
<!-- SUCCESS -->
<!-- ERROR -->
<!-- Edit form (shown only when editing) -->
{{ edit_html }}
<!-- Cron list -->
{{ cron_list_html }}
<!-- Add new cron -->
<div class="section">
<h2>Add schedule</h2>
<form method="POST" action="/settings/crons/add">
<div class="add-form-grid">
<div class="field">
<label for="add_persona">Persona</label>
<select id="add_persona" name="persona">
{{ persona_options }}
</select>
</div>
<div class="field">
<label for="add_job_type">Type</label>
<select id="add_job_type" name="job_type">
<option value="remind">remind — append to REMINDERS.md</option>
<option value="note">note — append to SCRATCH.md</option>
<option value="message">message — send payload as-is</option>
<option value="brief">brief — LLM response, no tools</option>
<option value="task">task — full orchestrator tool loop</option>
</select>
</div>
<div class="field">
<label for="add_label">Label</label>
<input type="text" id="add_label" name="label"
placeholder="Monday morning summary"
required autocomplete="off">
</div>
<div class="field">
<label for="add_schedule">Schedule</label>
<input type="text" id="add_schedule" name="schedule"
placeholder="weekly:mon:08:00"
required autocomplete="off" spellcheck="false">
<p class="hint">
hourly · daily · daily:HH:MM · weekly:DOW · weekly:DOW:HH:MM ·
monthly · monthly:DD · monthly:DD:HH:MM · yearly:MM:DD · yearly:MM:DD:HH:MM
</p>
</div>
<div class="field field-full">
<label for="add_payload">Payload / prompt</label>
<textarea id="add_payload" name="payload" rows="3"
placeholder="Check my open tasks and send a summary." required></textarea>
</div>
</div>
<button type="submit" class="btn-submit">Add schedule</button>
</form>
</div>
</div>
</body>
</html>

View File

@@ -94,6 +94,8 @@
<a href="/settings" class="nav-link" id="nav-settings">Settings</a> <a href="/settings" class="nav-link" id="nav-settings">Settings</a>
<a href="/settings/notifications" class="nav-link">Notifications</a> <a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a> <a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a>
{{ integrations_nav }}
<span class="nav-spacer"></span> <span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a> <a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav> </nav>

View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Integrations</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style>
details.channel-block {
border: 1px solid var(--pg-border); border-radius: 8px;
margin-bottom: 0.75rem; overflow: hidden;
}
details.channel-block summary {
padding: 0.75rem 1rem; font-size: 0.85rem; font-weight: 600;
color: var(--pg-muted); cursor: pointer; list-style: none;
display: flex; align-items: center; gap: 0.5rem;
user-select: none; background: var(--pg-bg);
}
details.channel-block summary::-webkit-details-marker { display: none; }
details.channel-block summary::before {
content: '▶'; font-size: 0.65rem; color: var(--pg-dimmer);
transition: transform 0.15s; flex-shrink: 0;
}
details.channel-block[open] summary::before { transform: rotate(90deg); }
details.channel-block[open] summary { border-bottom: 1px solid var(--pg-border); }
.channel-block-body { padding: 1rem 1rem 0.25rem; }
.channel-hint {
font-size: 0.75rem; color: var(--pg-dimmer);
margin-top: -0.6rem; margin-bottom: 1rem; line-height: 1.5;
}
.field-row {
display: grid; grid-template-columns: 1fr auto; gap: 0.75rem; align-items: end;
}
.field-row .field { margin-bottom: 0; }
.field-narrow input { max-width: 120px; }
</style>
</head>
<body>
<nav class="page-nav">
<a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a>
<a href="/settings/integrations" class="nav-link active">Integrations</a>
<span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav>
<div class="page-wrap">
<h1 class="page-title">Integrations</h1>
<p class="page-subtitle">External service connections — admin only.</p>
<!-- SUCCESS -->
<!-- ERROR -->
<form method="POST" action="/settings/integrations">
<!-- Aether Platform Database -->
<div class="section">
<h2>Aether Platform Database</h2>
<p class="section-note">
Gives the orchestrator direct read-only access to the Aether MariaDB via the
<code>ae_db_query</code>, <code>ae_db_describe</code>, and <code>ae_db_show_view</code> tools.
Only SELECT, SHOW, DESCRIBE, and EXPLAIN are permitted — no writes possible.
</p>
<details class="channel-block" {{ ae_db_host and 'open' or '' }}>
<summary>Connection</summary>
<div class="channel-block-body">
<p class="channel-hint">
Use the same credentials as <code>agents_sync/mcp/scripts/sql_inspector.py</code>.
The password field is left blank in the form — leave it blank to keep the stored value.
</p>
<div class="field-row">
<div class="field">
<label for="ae_db_host">Host</label>
<input type="text" id="ae_db_host" name="ae_db_host"
value="{{ ae_db_host }}"
placeholder="192.168.64.5"
autocomplete="off" spellcheck="false">
</div>
<div class="field field-narrow">
<label for="ae_db_port">Port</label>
<input type="number" id="ae_db_port" name="ae_db_port"
value="{{ ae_db_port }}"
placeholder="3306" min="1" max="65535"
autocomplete="off">
</div>
</div>
<div class="field">
<label for="ae_db_name">Database name</label>
<input type="text" id="ae_db_name" name="ae_db_name"
value="{{ ae_db_name }}"
placeholder="aether_dev"
autocomplete="off" spellcheck="false">
</div>
<div class="field">
<label for="ae_db_user">Username</label>
<input type="text" id="ae_db_user" name="ae_db_user"
value="{{ ae_db_user }}"
placeholder="aether_dev"
autocomplete="off" spellcheck="false">
</div>
<div class="field">
<label for="ae_db_password">Password</label>
<input type="password" id="ae_db_password" name="ae_db_password"
value=""
placeholder="Leave blank to keep existing value"
autocomplete="new-password" spellcheck="false">
</div>
</div>
</details>
</div>
<button type="submit" class="btn-submit">Save integrations</button>
</form>
</div>
</body>
</html>

View File

@@ -60,6 +60,8 @@
<a href="/settings" class="nav-link">Settings</a> <a href="/settings" class="nav-link">Settings</a>
<a href="/settings/notifications" class="nav-link active">Notifications</a> <a href="/settings/notifications" class="nav-link active">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a> <a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a>
{{ integrations_nav }}
<span class="nav-spacer"></span> <span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a> <a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav> </nav>
@@ -214,6 +216,13 @@
autocomplete="off" spellcheck="false"> autocomplete="off" spellcheck="false">
<p class="hint">Treat this like a password — use a long, random string.</p> <p class="hint">Treat this like a password — use a long, random string.</p>
</div> </div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" name="ha_tools" value="1" {{ ha_tools_checked }}>
Enable orchestrator tools
</label>
<p class="hint">When checked, HA events trigger the full tool loop (research, home control, tasks). When unchecked, events get a direct LLM response — faster but no tools.</p>
</div>
</div> </div>
</details> </details>
</div> </div>

View File

@@ -88,6 +88,8 @@
<a href="/settings" class="nav-link active">Settings</a> <a href="/settings" class="nav-link active">Settings</a>
<a href="/settings/notifications" class="nav-link">Notifications</a> <a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a> <a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a>
{{ integrations_nav }}
<span class="nav-spacer"></span> <span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a> <a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav> </nav>

View File

@@ -107,6 +107,8 @@
<a href="/settings" class="nav-link">Settings</a> <a href="/settings" class="nav-link">Settings</a>
<a href="/settings/notifications" class="nav-link">Notifications</a> <a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link active">Tools</a> <a href="/settings/tools" class="nav-link active">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a>
{{ integrations_nav }}
<span class="nav-spacer"></span> <span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a> <a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav> </nav>

View File

@@ -93,6 +93,11 @@ from tools.homeassistant import (
ha_get_states as _ha_get_states, ha_get_states as _ha_get_states,
ha_call_service as _ha_call_service, ha_call_service as _ha_call_service,
) )
from tools.ae_database import (
ae_db_query as _ae_db_query,
ae_db_describe as _ae_db_describe,
ae_db_show_view as _ae_db_show_view,
)
# ── Declaration imports ─────────────────────────────────────────────────────── # ── Declaration imports ───────────────────────────────────────────────────────
@@ -110,6 +115,7 @@ import tools.agent_notes as _mod_agent_notes
import tools.git as _mod_git import tools.git as _mod_git
import tools.agents as _mod_agents import tools.agents as _mod_agents
import tools.homeassistant as _mod_homeassistant import tools.homeassistant as _mod_homeassistant
import tools.ae_database as _mod_ae_database
# ── Tool categories — used by the Model Registry UI for grouped checkboxes ─── # ── Tool categories — used by the Model Registry UI for grouped checkboxes ───
@@ -136,6 +142,7 @@ TOOL_CATEGORIES: dict[str, list[str]] = {
"Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"], "Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"],
"Agents": ["spawn_agent"], "Agents": ["spawn_agent"],
"Home Assistant": ["ha_get_state", "ha_get_states", "ha_call_service"], "Home Assistant": ["ha_get_state", "ha_get_states", "ha_call_service"],
"Aether Database": ["ae_db_query", "ae_db_describe", "ae_db_show_view"],
} }
# ── Callable registry ───────────────────────────────────────────────────────── # ── Callable registry ─────────────────────────────────────────────────────────
@@ -203,6 +210,9 @@ _CALLABLES: dict[str, callable] = {
"ha_get_state": _ha_get_state, "ha_get_state": _ha_get_state,
"ha_get_states": _ha_get_states, "ha_get_states": _ha_get_states,
"ha_call_service": _ha_call_service, "ha_call_service": _ha_call_service,
"ae_db_query": _ae_db_query,
"ae_db_describe": _ae_db_describe,
"ae_db_show_view": _ae_db_show_view,
} }
# ── Role-based access control ───────────────────────────────────────────────── # ── Role-based access control ─────────────────────────────────────────────────
@@ -225,6 +235,9 @@ TOOL_ROLES: dict[str, str] = {
"http_post": "admin", "http_post": "admin",
"nc_talk_history": "admin", "nc_talk_history": "admin",
"ha_call_service": "admin", "ha_call_service": "admin",
"ae_db_query": "admin",
"ae_db_describe": "admin",
"ae_db_show_view": "admin",
} }
# Tools that require explicit user confirmation before executing. # Tools that require explicit user confirmation before executing.
@@ -237,6 +250,7 @@ CONFIRM_REQUIRED: set[str] = {
"reminders_clear", "reminders_clear",
"http_post", "http_post",
"ha_call_service", "ha_call_service",
"ae_journal_entry_disable", # disables a journal entry — not easily reversed
} }
# Security risk ratings — informational for now; will drive auto-allow tiers later. # Security risk ratings — informational for now; will drive auto-allow tiers later.
@@ -341,6 +355,11 @@ TOOL_RISK: dict[str, str] = {
"ha_get_state": "low", "ha_get_state": "low",
"ha_get_states": "low", "ha_get_states": "low",
"ha_call_service": "high", "ha_call_service": "high",
# Aether Database — all read-only; query reads data, describe/show_view read schema only
"ae_db_query": "medium",
"ae_db_describe": "low",
"ae_db_show_view": "low",
} }
_RISK_RANK: dict[str, int] = {"low": 0, "medium": 1, "high": 2} _RISK_RANK: dict[str, int] = {"low": 0, "medium": 1, "high": 2}
@@ -370,6 +389,7 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
+ _mod_agent_notes.DECLARATIONS + _mod_agent_notes.DECLARATIONS
+ _mod_agents.DECLARATIONS + _mod_agents.DECLARATIONS
+ _mod_homeassistant.DECLARATIONS + _mod_homeassistant.DECLARATIONS
+ _mod_ae_database.DECLARATIONS
) )
# Full Gemini Tool object (all tools — use get_tools_for_role() in production) # Full Gemini Tool object (all tools — use get_tools_for_role() in production)

253
cortex/tools/ae_database.py Normal file
View File

@@ -0,0 +1,253 @@
"""
Aether MariaDB tools — SELECT-only access to the Aether Platform database.
Credentials are read from the current user's channels.json:
"aether_db": {
"host": "192.168.64.5",
"port": 3306,
"name": "aether_dev",
"user": "aether_dev",
"password": "..."
}
Configure per-user in Settings → Notifications (or edit channels.json directly).
Only SELECT, SHOW, DESCRIBE, and EXPLAIN statements are permitted — no writes possible.
"""
import asyncio
import logging
import re
from google.genai import types
from auth_utils import get_user_channels
from persona import get_user
logger = logging.getLogger(__name__)
_MAX_ROWS = 200
_MAX_CELL = 120
_ALLOWED = {"select", "show", "describe", "desc", "explain"}
_SAFE_ID = re.compile(r'^[a-zA-Z0-9_]+$')
def _get_db_cfg() -> tuple[dict, str | None]:
"""Return (cfg_dict, error_string). cfg is empty dict on error."""
channels = get_user_channels(get_user())
cfg = channels.get("aether_db") or {}
if not cfg.get("host") or not cfg.get("user"):
return {}, (
"Aether DB not configured for this user. "
"Add an 'aether_db' block to channels.json: "
'{"host": "...", "port": 3306, "name": "aether_dev", "user": "...", "password": "..."}'
)
return cfg, None
def _is_read_only(sql: str) -> bool:
stripped = sql.strip()
if not stripped:
return False
first = stripped.split()[0].lower().rstrip(";")
return first in _ALLOWED
def _fmt(columns: list[str], rows: list[tuple]) -> str:
if not rows:
return f"({len(columns)} column{'s' if len(columns) != 1 else ''}, 0 rows)"
str_rows = [
[("NULL" if v is None else str(v))[:_MAX_CELL] for v in row]
for row in rows
]
widths = [
max([len(col)] + [len(r[i]) for r in str_rows])
for i, col in enumerate(columns)
]
sep = "+" + "+".join("-" * (w + 2) for w in widths) + "+"
header = "|" + "|".join(f" {c:<{w}} " for c, w in zip(columns, widths)) + "|"
lines = [sep, header, sep]
for row in str_rows:
lines.append("|" + "|".join(f" {v:<{w}} " for v, w in zip(row, widths)) + "|")
lines.append(sep)
note = " — results truncated at limit" if len(rows) == _MAX_ROWS else ""
lines.append(f"({len(rows)} row{'s' if len(rows) != 1 else ''}{note})")
return "\n".join(lines)
def _connect(cfg: dict):
import pymysql
import pymysql.cursors
return pymysql.connect(
host=cfg["host"],
port=int(cfg.get("port", 3306)),
user=cfg["user"],
password=cfg.get("password", ""),
database=cfg.get("name", "aether_dev"),
cursorclass=pymysql.cursors.Cursor,
connect_timeout=10,
)
async def ae_db_query(sql: str) -> str:
"""Run a read-only SQL query against the Aether MariaDB and return formatted results."""
cfg, err = _get_db_cfg()
if err:
return err
if not _is_read_only(sql):
first = sql.strip().split()[0] if sql.strip() else "(empty)"
return f"Only SELECT, SHOW, DESCRIBE, and EXPLAIN are permitted. Got: {first!r}"
def _run() -> tuple[list[str], list[tuple]]:
conn = _connect(cfg)
try:
with conn.cursor() as cur:
cur.execute(sql)
columns = [d[0] for d in cur.description] if cur.description else []
rows = list(cur.fetchmany(_MAX_ROWS))
return columns, rows
finally:
conn.close()
try:
columns, rows = await asyncio.to_thread(_run)
return _fmt(columns, rows)
except Exception as e:
logger.warning("ae_db_query error: %s", e)
return f"Query error: {e}"
async def ae_db_describe(table: str, detailed: bool = False) -> str:
"""Describe the columns of an Aether DB table or view."""
cfg, err = _get_db_cfg()
if err:
return err
if not _SAFE_ID.match(table):
return f"Invalid table name: {table!r}. Only letters, digits, and underscores allowed."
def _run():
conn = _connect(cfg)
try:
with conn.cursor() as cur:
cur.execute(f"DESCRIBE `{table}`")
columns = [d[0] for d in cur.description] if cur.description else []
rows = list(cur.fetchall())
return columns, rows
finally:
conn.close()
try:
columns, rows = await asyncio.to_thread(_run)
if not detailed:
fields = [row[0] for row in rows]
return f"{table}: " + ", ".join(fields)
return _fmt(columns, rows)
except Exception as e:
logger.warning("ae_db_describe error: %s", e)
return f"Describe error: {e}"
async def ae_db_show_view(view_name: str) -> str:
"""Return the CREATE VIEW SQL for an Aether DB view."""
cfg, err = _get_db_cfg()
if err:
return err
if not _SAFE_ID.match(view_name):
return f"Invalid view name: {view_name!r}. Only letters, digits, and underscores allowed."
def _run():
conn = _connect(cfg)
try:
with conn.cursor() as cur:
cur.execute(f"SHOW CREATE VIEW `{view_name}`")
return cur.fetchone()
finally:
conn.close()
try:
row = await asyncio.to_thread(_run)
if not row:
return f"View not found: {view_name}"
return str(row[1]) if len(row) > 1 else str(row[0])
except Exception as e:
logger.warning("ae_db_show_view error: %s", e)
return f"Show view error: {e}"
DECLARATIONS = [
types.FunctionDeclaration(
name="ae_db_describe",
description=(
"Describe the columns of an Aether Platform table or view. "
"Returns a compact field list by default; pass detailed=true for full schema "
"(type, nullability, default, key). Use to understand data structure before "
"writing a SELECT query, or to answer 'what fields does X have?'. "
"Examples: table='ae_journals'; table='clients'; table='time_entries'."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"table": types.Schema(
type=types.Type.STRING,
description="Table or view name (letters, digits, underscores only)",
),
"detailed": types.Schema(
type=types.Type.BOOLEAN,
description="Return full schema (type, nullability, key, default) instead of just field names",
),
},
required=["table"],
),
),
types.FunctionDeclaration(
name="ae_db_show_view",
description=(
"Return the CREATE VIEW SQL for an Aether Platform database view. "
"Use to understand how a view is constructed before querying it, "
"or to debug unexpected results from a view. "
"Example: view_name='v_active_journals'."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"view_name": types.Schema(
type=types.Type.STRING,
description="View name (letters, digits, underscores only)",
),
},
required=["view_name"],
),
),
types.FunctionDeclaration(
name="ae_db_query",
description=(
"Run a read-only SQL query against the Aether Platform MariaDB. "
"Permitted statements: SELECT, SHOW, DESCRIBE, EXPLAIN. No writes are possible. "
"Use for debugging: bad data, missing records, broken foreign keys, schema questions. "
"Results capped at 200 rows; cells truncated at 120 chars. "
"Examples: SELECT * FROM clients WHERE email = 'x@y.com'; "
"SELECT COUNT(*) FROM time_entries WHERE billed = 0 AND deleted_at IS NULL; "
"SHOW TABLES; DESCRIBE ae_journals; "
"SELECT id_random, enable, deleted_at FROM ae_journals WHERE id_random = 'abc123'."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"sql": types.Schema(
type=types.Type.STRING,
description=(
"SQL query to run — SELECT, SHOW, DESCRIBE, or EXPLAIN only. "
"No semicolons required but harmless if present."
),
),
},
required=["sql"],
),
),
]

View File

@@ -58,8 +58,9 @@ def _cron_add(label: str, schedule: str, job_type: str, payload: str) -> str:
except ValueError as e: except ValueError as e:
return f"Bad schedule: {e}" return f"Bad schedule: {e}"
if job_type not in ("remind", "note"): _VALID_TYPES = ("remind", "note", "message", "brief", "task")
return "Bad type: must be 'remind' or 'note'." if job_type not in _VALID_TYPES:
return f"Bad type: must be one of {', '.join(_VALID_TYPES)}."
current_user = get_user() current_user = get_user()
current_persona = get_persona() current_persona = get_persona()
@@ -210,18 +211,27 @@ DECLARATIONS = [
name="cron_add", name="cron_add",
description=( description=(
"Create a new scheduled cron job and register it immediately (no restart needed). " "Create a new scheduled cron job and register it immediately (no restart needed). "
"Two types: 'remind' writes to the pending reminders queue (Inara sees it automatically " "Job types: "
"in context next session); 'note' appends to the scratchpad. " "'remind' appends to REMINDERS.md, auto-surfaced in chat context at tier 2+; "
"Schedule formats: 'hourly' | 'daily' | 'daily:HH:MM' | 'weekly:DOW' | 'weekly:DOW:HH:MM'. " "'note' — appends to SCRATCH.md, read on demand; "
"Example: schedule='daily:09:00', type='remind', payload='Check in with Scott.'" "'message' — sends payload text directly to the user's notification channel; "
"'brief' — calls the LLM (no tools) with payload as the prompt, sends the response; "
"'task' — runs the full orchestrator tool loop with payload as the request, sends "
"Claude's response to the notification channel (use for agentic scheduled work: "
"research, checks, file updates, summaries that need tool access). "
"Schedule formats: 'hourly' | 'daily' | 'daily:HH:MM' | 'weekly:DOW' | 'weekly:DOW:HH:MM' | "
"'monthly' | 'monthly:DD' | 'monthly:DD:HH:MM' | 'yearly:MM:DD' | 'yearly:MM:DD:HH:MM'. "
"Examples: schedule='weekly:mon:08:00' for Monday briefings; "
"schedule='monthly:1:09:00' for a first-of-month review; "
"schedule='yearly:03:15' for a March 15 birthday reminder."
), ),
parameters=types.Schema( parameters=types.Schema(
type=types.Type.OBJECT, type=types.Type.OBJECT,
properties={ properties={
"label": types.Schema(type=types.Type.STRING, description="Short human-readable name for this job (e.g. 'Morning check-in')"), "label": types.Schema(type=types.Type.STRING, description="Short human-readable name for this job (e.g. 'Monday task summary')"),
"schedule": types.Schema(type=types.Type.STRING, description="When to run. Formats: hourly | daily | daily:HH:MM | weekly:DOW | weekly:DOW:HH:MM (e.g. 'weekly:mon:09:00')"), "schedule": types.Schema(type=types.Type.STRING, description="When to run: hourly | daily | daily:HH:MM | weekly:DOW | weekly:DOW:HH:MM | monthly | monthly:DD | monthly:DD:HH:MM | yearly:MM:DD | yearly:MM:DD:HH:MM"),
"job_type": types.Schema(type=types.Type.STRING, description="'remind' (→ REMINDERS.md, auto-surfaced in context) or 'note' (→ SCRATCH.md)"), "job_type": types.Schema(type=types.Type.STRING, description="remind | note | message | brief | task"),
"payload": types.Schema(type=types.Type.STRING, description="The text to write when the job fires"), "payload": types.Schema(type=types.Type.STRING, description="The text/prompt to use when the job fires"),
}, },
required=["label", "schedule", "job_type", "payload"], required=["label", "schedule", "job_type", "payload"],
), ),

View File

@@ -1,7 +1,7 @@
# Cortex / Inara — Master Index # Cortex / Inara — Master Index
> Start here. This document is a map, not a manual. > Start here. This document is a map, not a manual.
> Last updated: 2026-05-12 > Last updated: 2026-05-13
> >
> **Documentation philosophy:** Cortex is a no-black-box system. Docs must match reality. > **Documentation philosophy:** Cortex is a no-black-box system. Docs must match reality.
> Update docs before implementing significant changes. Verify they still match after. > Update docs before implementing significant changes. Verify they still match after.
@@ -32,7 +32,8 @@ Cortex is a self-hosted personal AI platform. It routes messages from any input
| Memory distillation | ✅ Live | Short (daily) / Mid (weekly) / Long (monthly) | | Memory distillation | ✅ Live | Short (daily) / Mid (weekly) / Long (monthly) |
| Multi-user | ✅ Live | Scott, Holly, Brian — each with own personas | | Multi-user | ✅ Live | Scott, Holly, Brian — each with own personas |
| Session search | ✅ Live | Full-text search across past session logs | | Session search | ✅ Live | Full-text search across past session logs |
| Proactive cron | ✅ Live | `message` and `brief` job types → NC Talk / web push | | Proactive cron | ✅ Live | 5 job types: `remind`, `note`, `message`, `brief`, `task` (full orchestrator loop) → NC Talk / web push |
| Schedules web UI | ✅ Live | `/settings/crons` — view, add, edit, pause/resume, delete jobs without going through the AI |
| Tool audit log | ✅ Live | Every orchestrator tool call logged to `home/{user}/tool_audit/` | | Tool audit log | ✅ Live | Every orchestrator tool call logged to `home/{user}/tool_audit/` |
| Token usage tracking | ✅ Live | Per-user daily buckets in `home/{user}/usage.json`; visible in Settings | | Token usage tracking | ✅ Live | Per-user daily buckets in `home/{user}/usage.json`; visible in Settings |
| Web push notifications | ✅ Live | VAPID push; `web_push` orchestrator tool; subscribe via ☰ menu | | Web push notifications | ✅ Live | VAPID push; `web_push` orchestrator tool; subscribe via ☰ menu |
@@ -42,7 +43,7 @@ Cortex is a self-hosted personal AI platform. It routes messages from any input
| Distill safety | ✅ Live | Per-persona asyncio lock, per-endpoint cooldowns, Rebuild option | | Distill safety | ✅ Live | Per-persona asyncio lock, per-endpoint cooldowns, Rebuild option |
| Guided onboarding | ✅ Live | Setup Step 3 for OpenRouter; existing-user banner; settings quick-link | | Guided onboarding | ✅ Live | Setup Step 3 for OpenRouter; existing-user banner; settings quick-link |
**62 orchestrator tools** across 16 domain modules — added 2026-05-12: `file_diff` (unified diff of two project files), `git_status` / `git_log` / `git_diff` (read-only git inspection). Settings pages unified under `pg.css`. Reasoning level control (Off/Light/Moderate/High/Max) added to chat UI. **65 orchestrator tools** across 17 domain modules — added 2026-05-12: `file_diff`, `git_status` / `git_log` / `git_diff` (read-only git inspection), `ae_db_query` / `ae_db_describe` / `ae_db_show_view` (SELECT-only Aether MariaDB access, admin, per-user credentials). `/settings/integrations` page added (admin-only). File attachments in chat (images for vision-capable local models; text/code files for all backends). Settings pages unified under `pg.css`. Added 2026-05-13: `task` cron type (full orchestrator loop on a schedule); monthly/yearly schedule formats (`monthly`, `monthly:DD:HH:MM`, `yearly:MM:DD:HH:MM`); Schedules web UI at `/settings/crons` (list, add, edit, pause, delete); HA inbound webhook tools toggle (orchestrator vs. direct LLM).
**Active users / personas:** scott/inara, holly/tina, brian/wintermute **Active users / personas:** scott/inara, holly/tina, brian/wintermute