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:
@@ -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"],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user