From 6316ffa1d4ffaba2a3a83eea8823a277eecc8b5d Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Fri, 20 Mar 2026 21:17:49 -0400 Subject: [PATCH] feat: cron job system for Inara (remind + note types) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cron_runner.py: job storage (CRONS.json), schedule parsing, execution - tools/cron.py: cron_list/add/remove/toggle + reminders_clear tools - scheduler.py: load user crons at startup, expose get_scheduler() for live add/remove without restarts - context_loader.py: auto-include REMINDERS.md in system prompt (tier 2+) so cron reminders surface automatically without Inara having to poll - inara/CRONS.json + REMINDERS.md: backing files (initially empty) Schedule formats: hourly | daily | daily:HH:MM | weekly:DOW | weekly:DOW:HH:MM Job types: remind (→ REMINDERS.md) | note (→ SCRATCH.md) Co-Authored-By: Claude Sonnet 4.6 --- cortex/context_loader.py | 12 ++- cortex/cron_runner.py | 161 +++++++++++++++++++++++++++++++++ cortex/scheduler.py | 51 +++++++++-- cortex/tools/__init__.py | 109 +++++++++++++++++++++++ cortex/tools/cron.py | 186 +++++++++++++++++++++++++++++++++++++++ inara/CRONS.json | 1 + inara/REMINDERS.md | 0 7 files changed, 510 insertions(+), 10 deletions(-) create mode 100644 cortex/cron_runner.py create mode 100644 cortex/tools/cron.py create mode 100644 inara/CRONS.json create mode 100644 inara/REMINDERS.md diff --git a/cortex/context_loader.py b/cortex/context_loader.py index 74a27bb..ca43126 100644 --- a/cortex/context_loader.py +++ b/cortex/context_loader.py @@ -57,7 +57,15 @@ def load_context( if help_path.exists(): parts.append(f"--- HELP.md ---\n{help_path.read_text()}") - # ── 4. Tiered memory — long → mid → short ───────────────────── + # ── 4. Pending reminders (tier 2+) ──────────────────────────── + # Written by cron jobs; cleared by Inara after acting on them. + reminders_path = inara_dir / "REMINDERS.md" + if reminders_path.exists() and reminders_path.stat().st_size > 10: + content = reminders_path.read_text().strip() + if content: + parts.append(f"--- REMINDERS.md ---\n{content}") + + # ── 5. Tiered memory — long → mid → short ───────────────────── # Short is last so it sits closest to the conversation turn. if include_long: # Fall back to legacy MEMORY.md during/after migration @@ -81,7 +89,7 @@ def load_context( if "Not yet populated" not in content: parts.append(f"--- MEMORY_SHORT.md ---\n{content}") - # ── 5. Raw session logs (tier 3+) ────────────────────────────── + # ── 6. Raw session logs (tier 3+) ────────────────────────────── if tier >= 3: sessions_dir = inara_dir / "sessions" if sessions_dir.exists(): diff --git a/cortex/cron_runner.py b/cortex/cron_runner.py new file mode 100644 index 0000000..17299ef --- /dev/null +++ b/cortex/cron_runner.py @@ -0,0 +1,161 @@ +""" +Cron job storage and execution. + +Handles reading/writing CRONS.json and running jobs when they fire. +Imported by scheduler.py (to load jobs at startup) and tools/cron.py +(to add/remove jobs at runtime). + +Job schema: + { + "id": "c_abc123", + "label": "Human-readable name", + "schedule": "daily:09:00", # see parse_schedule() for all formats + "type": "remind" | "note", + "payload": "Text to write when the job fires", + "enabled": true, + "created_at": "ISO 8601", + "last_run": null | "ISO 8601" + } + +Job types: + remind → appends to inara/REMINDERS.md (auto-loaded into Inara's context) + note → appends to inara/SCRATCH.md (read on demand via scratch_read) +""" + +import logging +from datetime import datetime, timezone +from pathlib import Path + +from config import settings + +logger = logging.getLogger(__name__) + +_DEFAULT_HOUR = 9 +_DEFAULT_MINUTE = 0 + +_DOW = { + "mon": "mon", "tue": "tue", "wed": "wed", "thu": "thu", + "fri": "fri", "sat": "sat", "sun": "sun", + "monday": "mon", "tuesday": "tue", "wednesday": "wed", + "thursday": "thu", "friday": "fri", "saturday": "sat", "sunday": "sun", +} + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +def crons_path() -> Path: + return settings.inara_path() / "CRONS.json" + + +def load_crons() -> list[dict]: + p = crons_path() + if not p.exists(): + return [] + try: + import json + return json.loads(p.read_text()) + except Exception: + return [] + + +def save_crons(crons: list[dict]) -> None: + import json + crons_path().write_text(json.dumps(crons, indent=2) + "\n") + + +# --------------------------------------------------------------------------- +# Schedule parsing +# --------------------------------------------------------------------------- + +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 + """ + s = schedule.strip().lower() + + if s == "hourly": + return {"minute": 0} + + if s == "daily": + return {"hour": _DEFAULT_HOUR, "minute": _DEFAULT_MINUTE} + + if s.startswith("daily:"): + h, m = _parse_hhmm(s[6:], schedule) + return {"hour": h, "minute": m} + + if s.startswith("weekly:"): + rest = s[7:].split(":") + dow = _DOW.get(rest[0]) + if not dow: + raise ValueError( + f"Unknown day of week {rest[0]!r}. " + f"Use: mon tue wed thu fri sat sun" + ) + if len(rest) == 3: + h, m = _parse_hhmm(f"{rest[1]}:{rest[2]}", schedule) + else: + h, m = _DEFAULT_HOUR, _DEFAULT_MINUTE + return {"day_of_week": dow, "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" + ) + + +def _parse_hhmm(s: str, original: str) -> tuple[int, int]: + parts = s.split(":") + if len(parts) != 2: + raise ValueError(f"Expected HH:MM in {original!r}, got {s!r}") + return int(parts[0]), int(parts[1]) + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- + +def _now_label() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + +async def run_job(job: dict) -> None: + """Execute a cron job. Called by APScheduler when the job fires.""" + job_type = job.get("type") + payload = job.get("payload", "").strip() + label = job.get("label", job.get("id", "cron")) + section = f"\n## {label} — {_now_label()}\n\n{payload}\n" + + inara_dir = settings.inara_path() + + if job_type == "remind": + p = inara_dir / "REMINDERS.md" + existing = p.read_text() if p.exists() else "" + p.write_text(existing.rstrip() + "\n" + section) + logger.info("cron [remind] fired: %s", label) + + elif job_type == "note": + p = inara_dir / "SCRATCH.md" + existing = p.read_text() if p.exists() else "" + p.write_text(existing.rstrip() + "\n" + section) + logger.info("cron [note] fired: %s", label) + + else: + logger.warning("cron: unknown type %r (job %s)", job_type, job.get("id")) + return + + # Record last_run + crons = load_crons() + for c in crons: + if c["id"] == job["id"]: + c["last_run"] = datetime.now(timezone.utc).isoformat() + break + save_crons(crons) diff --git a/cortex/scheduler.py b/cortex/scheduler.py index f53a55f..9d9c414 100644 --- a/cortex/scheduler.py +++ b/cortex/scheduler.py @@ -52,13 +52,17 @@ async def _run_long() -> None: logger.error("auto distill long failed: %s", e) +def get_scheduler() -> AsyncIOScheduler | None: + """Return the running scheduler instance (used by cron tools for live add/remove).""" + return _scheduler + + def start() -> None: global _scheduler + _scheduler = AsyncIOScheduler(timezone=ZoneInfo(settings.scheduler_timezone)) + if not settings.auto_distill: logger.info("auto distillation disabled (AUTO_DISTILL=false)") - return - - _scheduler = AsyncIOScheduler(timezone=ZoneInfo(settings.scheduler_timezone)) if settings.auto_distill_short: _scheduler.add_job(_run_short, "cron", hour=3, minute=0, id="distill_short") @@ -72,11 +76,42 @@ def start() -> None: _scheduler.add_job(_run_long, "cron", day=1, hour=4, minute=0, id="distill_long") logger.info("scheduled: distill_long monthly on 1st at 04:00") - if _scheduler.get_jobs(): - _scheduler.start() - logger.info("auto distillation scheduler started (%d jobs)", len(_scheduler.get_jobs())) - else: - logger.info("auto distillation: no jobs enabled") + # Load user-defined cron jobs from CRONS.json + _load_user_crons() + + _scheduler.start() + logger.info("scheduler started (%d jobs)", len(_scheduler.get_jobs())) + + +def _load_user_crons() -> None: + """Register all enabled user-defined cron jobs from CRONS.json.""" + import asyncio + try: + from cron_runner import load_crons, parse_schedule, run_job + except ImportError as e: + logger.warning("could not import cron_runner: %s", e) + return + + crons = load_crons() + loaded = 0 + for job in crons: + if not job.get("enabled", True): + continue + try: + kwargs = parse_schedule(job["schedule"]) + _scheduler.add_job( + lambda j=job: asyncio.ensure_future(run_job(j)), + "cron", + id=job["id"], + replace_existing=True, + **kwargs, + ) + loaded += 1 + except Exception as e: + logger.warning("cron job %s skipped: %s", job.get("id"), e) + + if loaded: + logger.info("loaded %d user cron job(s)", loaded) def stop() -> None: diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index 83ee252..6e77574 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -23,6 +23,13 @@ from tools.files import file_read as _file_read from tools.system import claude_allow_dir as _claude_allow_dir from tools.tasks import task_list as _task_list, task_create as _task_create from tools.tasks import task_update as _task_update, task_complete as _task_complete +from tools.cron import ( + cron_list as _cron_list, + cron_add as _cron_add, + cron_remove as _cron_remove, + cron_toggle as _cron_toggle, + reminders_clear as _reminders_clear, +) from tools.scratch import ( scratch_read as _scratch_read, scratch_write as _scratch_write, @@ -185,6 +192,11 @@ _CALLABLES: dict[str, callable] = { "task_create": _task_create, "task_update": _task_update, "task_complete": _task_complete, + "cron_list": _cron_list, + "cron_add": _cron_add, + "cron_remove": _cron_remove, + "cron_toggle": _cron_toggle, + "reminders_clear": _reminders_clear, "scratch_read": _scratch_read, "scratch_write": _scratch_write, "scratch_append": _scratch_append, @@ -315,6 +327,98 @@ _task_complete_declaration = types.FunctionDeclaration( ), ) +_cron_list_declaration = types.FunctionDeclaration( + name="cron_list", + description=( + "List all scheduled cron jobs — their ID, label, schedule, type, and last run time. " + "Use this to see what's scheduled before adding or removing jobs." + ), + parameters=types.Schema(type=types.Type.OBJECT, properties={}), +) + +_cron_add_declaration = types.FunctionDeclaration( + 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.'" + ), + 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", + ), + }, + required=["label", "schedule", "job_type", "payload"], + ), +) + +_cron_remove_declaration = types.FunctionDeclaration( + name="cron_remove", + description=( + "Permanently delete a scheduled cron job. Use cron_list first to get the ID. " + "To temporarily disable without deleting, use cron_toggle instead." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "cron_id": types.Schema( + type=types.Type.STRING, + description="Job ID (e.g. c_abc123) — get from cron_list", + ), + }, + required=["cron_id"], + ), +) + +_cron_toggle_declaration = types.FunctionDeclaration( + name="cron_toggle", + description=( + "Pause a running cron job, or resume a paused one. " + "The job stays in the list and can be re-enabled later. " + "Use cron_list to see current enabled/paused state." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "cron_id": types.Schema( + type=types.Type.STRING, + description="Job ID (e.g. c_abc123) — get from cron_list", + ), + }, + required=["cron_id"], + ), +) + +_reminders_clear_declaration = types.FunctionDeclaration( + name="reminders_clear", + description=( + "Erase all pending reminders from REMINDERS.md. " + "Use this after you have acknowledged and acted on the reminders shown in your context." + ), + parameters=types.Schema(type=types.Type.OBJECT, properties={}), +) + + _scratch_read_declaration = types.FunctionDeclaration( name="scratch_read", description=( @@ -386,6 +490,11 @@ TOOL_DECLARATIONS = [ _task_create_declaration, _task_update_declaration, _task_complete_declaration, + _cron_list_declaration, + _cron_add_declaration, + _cron_remove_declaration, + _cron_toggle_declaration, + _reminders_clear_declaration, _scratch_read_declaration, _scratch_write_declaration, _scratch_append_declaration, diff --git a/cortex/tools/cron.py b/cortex/tools/cron.py new file mode 100644 index 0000000..d259c2c --- /dev/null +++ b/cortex/tools/cron.py @@ -0,0 +1,186 @@ +""" +Cron job management tools for Inara. + +Jobs are stored in inara/CRONS.json and registered with the live APScheduler +instance so they survive restarts and take effect immediately without a restart. + +Tools: + cron_list — show all scheduled jobs + cron_add — create a job and register it immediately + cron_remove — delete a job and unschedule it + cron_toggle — pause or resume a job without deleting it + reminders_clear — erase inara/REMINDERS.md (dismiss all pending reminders) +""" + +import asyncio +import secrets +from datetime import datetime, timezone +from pathlib import Path + +from config import settings +from cron_runner import load_crons, save_crons, parse_schedule + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _short_id() -> str: + return "c_" + secrets.token_urlsafe(6) + + +# --------------------------------------------------------------------------- +# Sync implementations +# --------------------------------------------------------------------------- + +def _cron_list() -> str: + crons = load_crons() + if not crons: + return "No crons scheduled." + + lines = [f"Crons ({len(crons)}):\n"] + for c in crons: + status = "enabled" if c.get("enabled", True) else "PAUSED " + last = c.get("last_run") + last_str = last[:10] if last else "never" + lines.append( + f" {c['id']} [{status}] {c['schedule']:<18} " + f"{c['type']:<7} {c['label']} (last: {last_str})" + ) + return "\n".join(lines) + + +def _cron_add(label: str, schedule: str, job_type: str, payload: str) -> str: + # Validate schedule first — raises ValueError with a clear message on bad input + try: + sched_kwargs = parse_schedule(schedule) + except ValueError as e: + return f"Bad schedule: {e}" + + if job_type not in ("remind", "note"): + return "Bad type: must be 'remind' or 'note'." + + crons = load_crons() + job = { + "id": _short_id(), + "label": label, + "schedule": schedule, + "type": job_type, + "payload": payload, + "enabled": True, + "created_at": _now(), + "last_run": None, + } + crons.append(job) + save_crons(crons) + + # Register with the live scheduler + _scheduler_add(job, sched_kwargs) + + return f"Created and scheduled: {job['id']} {schedule} [{job_type}] {label}" + + +def _cron_remove(cron_id: str) -> str: + crons = load_crons() + before = len(crons) + crons = [c for c in crons if c["id"] != cron_id] + if len(crons) == before: + return f"Not found: {cron_id}" + save_crons(crons) + _scheduler_remove(cron_id) + return f"Removed: {cron_id}" + + +def _cron_toggle(cron_id: str) -> str: + crons = load_crons() + for c in crons: + if c["id"] == cron_id: + c["enabled"] = not c.get("enabled", True) + save_crons(crons) + action = "resumed" if c["enabled"] else "paused" + _scheduler_resume(cron_id) if c["enabled"] else _scheduler_pause(cron_id) + return f"{action.capitalize()}: {cron_id} {c['label']}" + return f"Not found: {cron_id}" + + +def _reminders_clear() -> str: + p = settings.inara_path() / "REMINDERS.md" + p.write_text("") + return "Reminders cleared." + + +# --------------------------------------------------------------------------- +# Scheduler bridge — thin wrappers so the tool layer never touches APScheduler +# directly, keeping it swappable +# --------------------------------------------------------------------------- + +def _scheduler_add(job: dict, sched_kwargs: dict) -> None: + try: + import scheduler as sched_module + from cron_runner import run_job + s = sched_module.get_scheduler() + if s and s.running: + s.add_job( + lambda j=job: asyncio.ensure_future(run_job(j)), + "cron", + id=job["id"], + replace_existing=True, + **sched_kwargs, + ) + except Exception as e: + import logging + logging.getLogger(__name__).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 + + +# --------------------------------------------------------------------------- +# Async wrappers +# --------------------------------------------------------------------------- + +async def cron_list() -> str: + return await asyncio.to_thread(_cron_list) + + +async def cron_add(label: str, schedule: str, job_type: str, payload: str) -> str: + return await asyncio.to_thread(_cron_add, label, schedule, job_type, payload) + + +async def cron_remove(cron_id: str) -> str: + return await asyncio.to_thread(_cron_remove, cron_id) + + +async def cron_toggle(cron_id: str) -> str: + return await asyncio.to_thread(_cron_toggle, cron_id) + + +async def reminders_clear() -> str: + return await asyncio.to_thread(_reminders_clear) diff --git a/inara/CRONS.json b/inara/CRONS.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/inara/CRONS.json @@ -0,0 +1 @@ +[] diff --git a/inara/REMINDERS.md b/inara/REMINDERS.md new file mode 100644 index 0000000..e69de29