""" Auto memory distillation scheduler. Default schedule (all overridable via .env flags): short — daily at 03:00 (no LLM — fast) mid — weekly Sun at 03:30 (LLM call) long — monthly 1st at 04:00 (LLM call — off by default) Set AUTO_DISTILL=false to disable entirely. Set AUTO_DISTILL_LONG=true to enable monthly long-term integration. """ import logging from zoneinfo import ZoneInfo from apscheduler.schedulers.asyncio import AsyncIOScheduler from config import settings logger = logging.getLogger(__name__) _scheduler: AsyncIOScheduler | None = None async def _run_short() -> None: from memory_distiller import distill_short try: result = distill_short() logger.info("auto distill short: %d files, %d chars", result["files_included"], result["chars_written"]) except Exception as e: logger.error("auto distill short failed: %s", e) async def _run_mid() -> None: from memory_distiller import distill_mid try: result = await distill_mid() if "error" in result: logger.warning("auto distill mid skipped: %s", result["error"]) else: logger.info("auto distill mid: %d chars via %s", result["chars_written"], result["backend"]) except Exception as e: logger.error("auto distill mid failed: %s", e) async def _run_long() -> None: from memory_distiller import distill_long try: result = await distill_long() if "error" in result: logger.warning("auto distill long skipped: %s", result["error"]) else: logger.info("auto distill long: %d chars via %s", result["chars_written"], result["backend"]) except Exception as e: 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)") if settings.auto_distill_short: _scheduler.add_job(_run_short, "cron", hour=3, minute=0, id="distill_short") logger.info("scheduled: distill_short daily at 03:00") if settings.auto_distill_mid: _scheduler.add_job(_run_mid, "cron", day_of_week="sun", hour=3, minute=30, id="distill_mid") logger.info("scheduled: distill_mid weekly Sun at 03:30") if settings.auto_distill_long: _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") # 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: global _scheduler if _scheduler and _scheduler.running: _scheduler.shutdown(wait=False) logger.info("auto distillation scheduler stopped") def status() -> list[dict]: """Return next-run info for all scheduled jobs.""" if not _scheduler or not _scheduler.running: return [] jobs = [] for job in _scheduler.get_jobs(): next_run = job.next_run_time jobs.append({ "id": job.id, "next_run": next_run.isoformat() if next_run else None, }) return jobs