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

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

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

View File

@@ -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
View File

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

View File

@@ -58,8 +58,9 @@ def _cron_add(label: str, schedule: str, job_type: str, payload: str) -> str:
except ValueError as e:
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"],
),