diff --git a/CLAUDE.md b/CLAUDE.md index 76701de..a650857 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,12 +45,15 @@ Cortex_and_Inara_dev/ google_chat.py ← POST /webhook/google (Google Chat Add-on) ui.py ← Login/logout, /{user}/{persona} UI route, /api/personas 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/ __init__.py ← Tool registry (Gemini FunctionDeclarations + dispatcher) web.py ← DuckDuckGo web_search tool scratch.py ← Scratchpad tools (scratch_read/write/append/clear) 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) tests/ ← pytest test suite (80 tests) 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 | | 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 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) -**62 orchestrator tools** across 16 domain modules: +**65 orchestrator tools** across 17 domain modules: web_search/http_fetch/web_read/http_post, project_file_read/list + file_stat/grep/diff/syntax_check (project-scoped), 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, web_push/email_send/nc_talk_send/nc_talk_history, 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. Each tool has a `TOOL_RISK` rating (low/medium/high). Configure access at `/settings/tools` diff --git a/cortex/.env.example b/cortex/.env.example index b3ac1f6..6a789ce 100644 --- a/cortex/.env.example +++ b/cortex/.env.example @@ -93,6 +93,18 @@ AE_API_KEY= AE_ACCOUNT_ID= 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 ──────────────────────────────────────────────────── SCHEDULER_TIMEZONE=America/New_York AUTO_DISTILL=true diff --git a/cortex/cron_runner.py b/cortex/cron_runner.py index 7af7107..5a8a5c0 100644 --- a/cortex/cron_runner.py +++ b/cortex/cron_runner.py @@ -10,9 +10,9 @@ Job schema: "id": "c_abc123", "label": "Human-readable name", "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", - "channel": null | "nextcloud" | "google_chat", # for message/brief types + "channel": null | "nextcloud" | "google_chat", # for message/brief/task types "enabled": true, "created_at": "ISO 8601", "last_run": null | "ISO 8601" @@ -21,9 +21,14 @@ Job schema: Job types: remind → appends to REMINDERS.md (auto-loaded into context at tier 2+) note → appends to SCRATCH.md (read on demand via scratch_read) - message → sends payload as-is to NC Talk notification_room - brief → runs LLM with payload as the prompt, sends response to NC Talk + message → sends payload as-is to notification channel + brief → calls LLM (no tools) with payload as prompt, sends response (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 @@ -80,11 +85,16 @@ def parse_schedule(schedule: str) -> dict: Convert a human schedule string to APScheduler cron kwargs. Formats: - "hourly" → every hour at :00 - "daily" → every day at 09:00 - "daily:HH:MM" → every day at HH:MM - "weekly:DOW" → every DOW at 09:00 - "weekly:DOW:HH:MM" → every DOW at HH:MM + "hourly" → every hour at :00 + "daily" → every day at 09:00 + "daily:HH:MM" → every day at HH:MM + "weekly:DOW" → every DOW at 09:00 + "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() @@ -112,9 +122,37 @@ def parse_schedule(schedule: str) -> dict: h, m = _DEFAULT_HOUR, _DEFAULT_MINUTE 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( 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]) +def _parse_day(s: str, original: str) -> int: + try: + d = int(s) + except ValueError: + raise ValueError(f"Expected day number (1–31) in {original!r}, got {s!r}") + if not 1 <= d <= 31: + raise ValueError(f"Day must be 1–31 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 (1–12) in {original!r}, got {s!r}") + if not 1 <= m <= 12: + raise ValueError(f"Month must be 1–12 in {original!r}, got {m}") + return m + + # --------------------------------------------------------------------------- # Execution # --------------------------------------------------------------------------- @@ -188,6 +246,55 @@ async def run_job(job: dict) -> None: except Exception as 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: logger.warning("cron: unknown type %r (job %s)", job_type, job.get("id")) return diff --git a/cortex/main.py b/cortex/main.py index 349faa3..9cc6a8e 100644 --- a/cortex/main.py +++ b/cortex/main.py @@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag from config import settings from auth_middleware import SessionAuthMiddleware 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 @@ -53,6 +53,7 @@ app.include_router(onboarding.router) app.include_router(settings.router) app.include_router(tools_settings.router) app.include_router(local_llm.router) +app.include_router(crons.router) # Help page app.include_router(help.router) diff --git a/cortex/requirements.txt b/cortex/requirements.txt index fd2be4a..0fa347e 100644 --- a/cortex/requirements.txt +++ b/cortex/requirements.txt @@ -28,5 +28,8 @@ openai>=1.0.0 # Web Push / VAPID — browser push notifications 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>=0.40.0 diff --git a/cortex/routers/crons.py b/cortex/routers/crons.py new file mode 100644 index 0000000..2888800 --- /dev/null +++ b/cortex/routers/crons.py @@ -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 'Integrations' + 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 '
{schedule}{_html.escape(persona)}
+| Label | Schedule | Type | +Payload | Last run | Status | + |
|---|
{_html.escape(success)}
') + if error: + html = html.replace("", f'{_html.escape(error)}
') + 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}")) diff --git a/cortex/routers/help.py b/cortex/routers/help.py index f0bfcc3..c181629 100644 --- a/cortex/routers/help.py +++ b/cortex/routers/help.py @@ -12,7 +12,7 @@ import jwt from fastapi import APIRouter, Request 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 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}"}};' ) html = html.replace("", f"{config_tag}\n", 1) + nav = 'Integrations' \ + if _read_auth(username).get("role", "user") == "admin" else "" + html = html.replace("{{ integrations_nav }}", nav) return HTMLResponse(html) diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py index 8b32656..1e6daec 100644 --- a/cortex/routers/settings.py +++ b/cortex/routers/settings.py @@ -53,6 +53,14 @@ def _preferred_persona(request: Request, username: str) -> str: 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 'Integrations' + return "" + + def _notifications_page(username: str, back_persona: str = "", success: str = "", error: str = "") -> str: html = (_STATIC / "notifications.html").read_text() 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_url = _html.escape(ha.get("url", "") 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_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("{{ ha_url }}", ha_url) 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("{{ 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("", f'{success}
') 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 "" 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("", f'{success}
') + if error: + html = html.replace("", f'{error}
') + 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: html = html.replace("", f'{success}
') if error: @@ -308,6 +338,7 @@ async def save_notifications( ha_url: str = Form(""), ha_token: str = Form(""), ha_webhook_id: str = Form(""), + ha_tools: str = Form(""), ): username = _get_session_user(request) if not username: @@ -365,6 +396,7 @@ async def save_notifications( ha["token"] = ha_token.strip() if 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") 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)) 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 ''}).")) + + +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.")) diff --git a/cortex/routers/tools_settings.py b/cortex/routers/tools_settings.py index 7e55343..9631768 100644 --- a/cortex/routers/tools_settings.py +++ b/cortex/routers/tools_settings.py @@ -15,7 +15,7 @@ import jwt from fastapi import APIRouter, Form, Request 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 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("{{ 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") + nav = 'Integrations' \ + if _read_auth(username).get("role", "user") == "admin" else "" + html = html.replace("{{ integrations_nav }}", nav) if success: html = html.replace("", f'{success}
') diff --git a/cortex/static/HELP.md b/cortex/static/HELP.md index c07eea0..3cf537f 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-05-12* +*Last updated: 2026-05-13* --- @@ -82,7 +82,7 @@ Orchestrated sessions persist to history exactly like regular chat. ### 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 | |---|---| @@ -99,13 +99,15 @@ Orchestrated sessions persist to history exactly like regular chat. | **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 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` | | **Agents** | `spawn_agent` | | **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`. `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 @@ -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 | | **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 | -| **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 | | **Usage** | Token consumption by model — see below | | **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 | Keys | Action | diff --git a/cortex/static/crons.html b/cortex/static/crons.html new file mode 100644 index 0000000..d80a7e6 --- /dev/null +++ b/cortex/static/crons.html @@ -0,0 +1,150 @@ + + + + + +Recurring jobs — reminders, notes, briefings, and agentic tasks.
+ + + + + + {{ edit_html }} + + + {{ cron_list_html }} + + +External service connections — admin only.
+ + + + + +Treat this like a password — use a long, random string.
+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.
+