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:
12
CLAUDE.md
12
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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
479
cortex/routers/crons.py
Normal file
479
cortex/routers/crons.py
Normal 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}"))
|
||||
@@ -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}"}};</script>'
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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 '<a href="/settings/integrations" class="nav-link">Integrations</a>'
|
||||
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("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
|
||||
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("<!-- 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:
|
||||
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
|
||||
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."))
|
||||
|
||||
@@ -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 = '<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:
|
||||
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
|
||||
|
||||
@@ -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 |
|
||||
|
||||
150
cortex/static/crons.html
Normal file
150
cortex/static/crons.html
Normal 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>
|
||||
@@ -94,6 +94,8 @@
|
||||
<a href="/settings" class="nav-link" id="nav-settings">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>
|
||||
{{ integrations_nav }}
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
|
||||
124
cortex/static/integrations.html
Normal file
124
cortex/static/integrations.html
Normal 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>
|
||||
@@ -60,6 +60,8 @@
|
||||
<a href="/settings" class="nav-link">Settings</a>
|
||||
<a href="/settings/notifications" class="nav-link active">Notifications</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>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
@@ -214,6 +216,13 @@
|
||||
autocomplete="off" spellcheck="false">
|
||||
<p class="hint">Treat this like a password — use a long, random string.</p>
|
||||
</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>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
<a href="/settings" class="nav-link active">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>
|
||||
{{ integrations_nav }}
|
||||
<span class="nav-spacer"></span>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
|
||||
@@ -107,6 +107,8 @@
|
||||
<a href="/settings" class="nav-link">Settings</a>
|
||||
<a href="/settings/notifications" class="nav-link">Notifications</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>
|
||||
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
||||
</nav>
|
||||
|
||||
@@ -93,6 +93,11 @@ from tools.homeassistant import (
|
||||
ha_get_states as _ha_get_states,
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -110,6 +115,7 @@ import tools.agent_notes as _mod_agent_notes
|
||||
import tools.git as _mod_git
|
||||
import tools.agents as _mod_agents
|
||||
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 ───
|
||||
|
||||
@@ -136,6 +142,7 @@ TOOL_CATEGORIES: dict[str, list[str]] = {
|
||||
"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"],
|
||||
"Aether Database": ["ae_db_query", "ae_db_describe", "ae_db_show_view"],
|
||||
}
|
||||
|
||||
# ── Callable registry ─────────────────────────────────────────────────────────
|
||||
@@ -203,6 +210,9 @@ _CALLABLES: dict[str, callable] = {
|
||||
"ha_get_state": _ha_get_state,
|
||||
"ha_get_states": _ha_get_states,
|
||||
"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 ─────────────────────────────────────────────────
|
||||
@@ -225,6 +235,9 @@ TOOL_ROLES: dict[str, str] = {
|
||||
"http_post": "admin",
|
||||
"nc_talk_history": "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.
|
||||
@@ -237,6 +250,7 @@ CONFIRM_REQUIRED: set[str] = {
|
||||
"reminders_clear",
|
||||
"http_post",
|
||||
"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.
|
||||
@@ -341,6 +355,11 @@ TOOL_RISK: dict[str, str] = {
|
||||
"ha_get_state": "low",
|
||||
"ha_get_states": "low",
|
||||
"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}
|
||||
@@ -370,6 +389,7 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
|
||||
+ _mod_agent_notes.DECLARATIONS
|
||||
+ _mod_agents.DECLARATIONS
|
||||
+ _mod_homeassistant.DECLARATIONS
|
||||
+ _mod_ae_database.DECLARATIONS
|
||||
)
|
||||
|
||||
# Full Gemini Tool object (all tools — use get_tools_for_role() in production)
|
||||
|
||||
253
cortex/tools/ae_database.py
Normal file
253
cortex/tools/ae_database.py
Normal 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"],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -58,8 +58,9 @@ def _cron_add(label: str, schedule: str, job_type: str, payload: str) -> str:
|
||||
except ValueError as e:
|
||||
return f"Bad schedule: {e}"
|
||||
|
||||
if job_type not in ("remind", "note"):
|
||||
return "Bad type: must be 'remind' or 'note'."
|
||||
_VALID_TYPES = ("remind", "note", "message", "brief", "task")
|
||||
if job_type not in _VALID_TYPES:
|
||||
return f"Bad type: must be one of {', '.join(_VALID_TYPES)}."
|
||||
|
||||
current_user = get_user()
|
||||
current_persona = get_persona()
|
||||
@@ -210,18 +211,27 @@ DECLARATIONS = [
|
||||
name="cron_add",
|
||||
description=(
|
||||
"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 "
|
||||
"in context next session); 'note' appends to the scratchpad. "
|
||||
"Schedule formats: 'hourly' | 'daily' | 'daily:HH:MM' | 'weekly:DOW' | 'weekly:DOW:HH:MM'. "
|
||||
"Example: schedule='daily:09:00', type='remind', payload='Check in with Scott.'"
|
||||
"Job types: "
|
||||
"'remind' — appends to REMINDERS.md, auto-surfaced in chat context at tier 2+; "
|
||||
"'note' — appends to SCRATCH.md, read on demand; "
|
||||
"'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(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"label": types.Schema(type=types.Type.STRING, description="Short human-readable name for this job (e.g. 'Morning check-in')"),
|
||||
"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')"),
|
||||
"job_type": types.Schema(type=types.Type.STRING, description="'remind' (→ REMINDERS.md, auto-surfaced in context) or 'note' (→ SCRATCH.md)"),
|
||||
"payload": types.Schema(type=types.Type.STRING, description="The text to write when the job fires"),
|
||||
"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: 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 | note | message | brief | task"),
|
||||
"payload": types.Schema(type=types.Type.STRING, description="The text/prompt to use when the job fires"),
|
||||
},
|
||||
required=["label", "schedule", "job_type", "payload"],
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Cortex / Inara — Master Index
|
||||
|
||||
> 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.
|
||||
> 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) |
|
||||
| Multi-user | ✅ Live | Scott, Holly, Brian — each with own personas |
|
||||
| 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/` |
|
||||
| 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 |
|
||||
@@ -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 |
|
||||
| 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user