feat: cron job system for Inara (remind + note types)

- 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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-20 21:17:49 -04:00
parent 1b32667872
commit 6316ffa1d4
7 changed files with 510 additions and 10 deletions

View File

@@ -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: