Scheduler
APScheduler-based background job runner for proactive notifications, memory maintenance, personality decay, and behavioral pattern mining.
Overview
LunaScheduler wraps APScheduler's BackgroundSchedulerand registers all periodic jobs at startup. It runs in-process alongside the FastAPI server — no separate worker process required.
| Module | Contents |
|---|---|
scheduler/service.py | LunaScheduler class + luna_scheduler singleton. |
scheduler/jobs.py | All job functions (events, tasks, memory, personality, patterns). |
scheduler/notifications.py | send_windows_notification(), shared proactive_queue. |
Start / stop
from backend.services.scheduler import luna_scheduler
luna_scheduler.start() # called by FastAPI lifespan on startup
luna_scheduler.stop() # called on shutdownstart() is idempotent — calling it a second time is a no-op. All jobs self-throttle with recent-log checks; running them manually during development is safe.Scheduled jobs
| Function | Schedule | Purpose |
|---|---|---|
check_upcoming_events | Every 15 min | Windows notification for events starting within 1 hour. |
check_overdue_tasks | Daily 08:00 | Notification for past-due tasks. |
morning_greeting | Daily 08:00 | Good-morning push + proactive queue entry. |
daily_memory_compaction | Daily 03:00 | LLM-based dedup of the facts table. |
daily_personality_decay | Daily 00:00 | Pulls style preferences back toward 0.5. |
confidence_decay | Weekly Sun 02:00 | Reduces confidence on stale preference/goal facts. |
mine_behavioral_patterns | Daily 04:00 | Detects recurring habits and stores as behavioral facts. |
companion_check_in | Every 10 min | LLM-generated check-in after 25–180 quiet minutes. |
state_aware_proactive | Every 10 min | State-contextual proactive message (STAYING_UP, JUST_WOKE_UP, etc.). |
proactive_commitment_followup | Daily 12:00 | Follows up on past commitments mentioned 1–6 days ago. |
check_upcoming_events()
Queries CalendarEvent records starting within the next 60 minutes and fires a Windows desktop notification for each one. The message is also appended to proactive_queue so the chat frontend can surface it.
from backend.services.scheduler.jobs import check_upcoming_events
check_upcoming_events() # run manually for testingcheck_overdue_tasks()
Finds incomplete tasks whose due_date is in the past and fires a single notification listing up to 3 task names (with "+N more" if there are more).
daily_memory_compaction()
Calls MemoryManager.compact_facts() which performs an LLM pass to identify redundant or contradicted facts and deactivates them. Runs at 3:00 AM daily in a fresh event loop (safe to call from a thread).
from backend.services.scheduler.jobs import daily_memory_compaction
daily_memory_compaction() # blocks until compaction completesdaily_personality_decay()
Calls PersonalityEngine.daily_decay() to nudge all six style preference axes slightly toward 0.5. Prevents the personality from polarising based on short-run conversation patterns.
confidence_decay()
Reduces the confidence column by 0.04 for everypreference or goal fact that hasn't been updated in more than 30 days. The floor is 0.3 — facts are never deleted, just de-prioritised in retrieval scoring.
Runs weekly (Sunday 02:00). Example decay path for a fact untouched for 6 months:
| Week | Confidence |
|---|---|
| 0 (stored) | 0.85 |
| 5 weeks | 0.65 |
| 10 weeks | 0.45 |
| 15+ weeks | 0.30 (floor) |
mine_behavioral_patterns()
Scans the StateEvent table for the last 30 days and derives three types of behavioral patterns, which are stored as long-termbehavior facts in the memory manager:
- Peak activity hour — hour with the most state events.
- Emotional days — days where > 50% of events show a negative emotion (sad / angry / stressed).
- Late-night tendency — if > 30% of events occur between 23:00–04:00.
Patterns are only stored if they don't already exist (dedup check on the first 35 characters). Requires at least 20 state events in the window.
"User is most active around 10pm"
"User often feels stressed on Mondays"
"User is frequently active late at night"companion_check_in()
Generates an LLM-written unprompted message from Luna if the user has been quiet for 25–180 minutes within the same calendar day. The message is seeded from the last 6 conversation turns so it's never a generic filler line.
Throttle rules
- Only fires when last user message was 25–180 minutes ago (same day).
- No second check-in within 90 minutes of the previous one.
- Skipped if the last message was on a prior calendar day.
"The conversation ended 42 minutes ago.
Recent exchange:
User: just got back from the gym
Luna: Nice, how'd it go?
Generate a single short unprompted message from Luna...
Rules: max 2 sentences, no questions asking how they are,
not clingy, could be a random thought or observation."proactive_commitment_followup()
Scans user messages from 20 hours to 6 days ago for future-tense commitments (interviews, exams, appointments, flights, etc.) and generates a one-sentence follow-up if the event has likely passed.
Trigger keywords detected by regex:
tomorrow | next week | next monday/tuesday/... | this friday/...
tonight | later today | in a few days
interview | presentation | exam | test | deadline | surgery | appointment | flightThrottled to one follow-up per 24 hours. Only fires once per scheduler run (the first matching message).
state_aware_proactive()
Checks the current inferred UserState every 10 minutes and queues a state-specific proactive message when warranted. Messages for three states:
| State | Message style |
|---|---|
STAYING_UP | Acknowledges late hour, offers focus mode or sleep reminder. |
JUST_WOKE_UP | Morning brief with today's events and open tasks. |
BACK_FROM_WORK | Warm welcome back, asks if they want a summary. |
Throttled: no repeat of any of these three reasons within 60 minutes.
Proactive queue
proactive_queue is a plain Python list[str] shared between the scheduler jobs and the chat router. When the frontend pollsGET /api/chat/proactive, the router drains the queue and returns any pending messages as SSE events.
from backend.services.scheduler.notifications import proactive_queue
# Add a custom proactive message
proactive_queue.append("Your 2 PM meeting is in 10 minutes.")
# Read + drain (done by the chat router)
while proactive_queue:
msg = proactive_queue.pop(0)
print(msg)ProactiveLog database table.Adding custom jobs
Each process module in backend/processes/ can register its own APScheduler jobs by exposing a register_scheduler(scheduler)function. The scheduler calls it at startup via the process registry.
from apscheduler.schedulers.background import BackgroundScheduler
def register_scheduler(scheduler: BackgroundScheduler):
scheduler.add_job(
my_job_function,
trigger="interval",
minutes=30,
id="my_custom_job",
replace_existing=True,
)
def my_job_function():
print("custom job running")Any module placed in backend/processes/ is auto-discovered byiter_processes() — no manual registration needed.