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.

ModuleContents
scheduler/service.pyLunaScheduler class + luna_scheduler singleton.
scheduler/jobs.pyAll job functions (events, tasks, memory, personality, patterns).
scheduler/notifications.pysend_windows_notification(), shared proactive_queue.

Start / stop

example.py
from backend.services.scheduler import luna_scheduler

luna_scheduler.start()   # called by FastAPI lifespan on startup
luna_scheduler.stop()    # called on shutdown
💡
start() 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

FunctionSchedulePurpose
check_upcoming_eventsEvery 15 minWindows notification for events starting within 1 hour.
check_overdue_tasksDaily 08:00Notification for past-due tasks.
morning_greetingDaily 08:00Good-morning push + proactive queue entry.
daily_memory_compactionDaily 03:00LLM-based dedup of the facts table.
daily_personality_decayDaily 00:00Pulls style preferences back toward 0.5.
confidence_decayWeekly Sun 02:00Reduces confidence on stale preference/goal facts.
mine_behavioral_patternsDaily 04:00Detects recurring habits and stores as behavioral facts.
companion_check_inEvery 10 minLLM-generated check-in after 25–180 quiet minutes.
state_aware_proactiveEvery 10 minState-contextual proactive message (STAYING_UP, JUST_WOKE_UP, etc.).
proactive_commitment_followupDaily 12:00Follows 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.

example.py
from backend.services.scheduler.jobs import check_upcoming_events

check_upcoming_events()   # run manually for testing

check_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).

example.py
from backend.services.scheduler.jobs import daily_memory_compaction

daily_memory_compaction()   # blocks until compaction completes

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

WeekConfidence
0 (stored)0.85
5 weeks0.65
10 weeks0.45
15+ weeks0.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.

stored facts example
"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.
LLM prompt (abbreviated)
"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:

commitment keywords
tomorrow | next week | next monday/tuesday/... | this friday/...
tonight | later today | in a few days
interview | presentation | exam | test | deadline | surgery | appointment | flight

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

StateMessage style
STAYING_UPAcknowledges late hour, offers focus mode or sleep reminder.
JUST_WOKE_UPMorning brief with today's events and open tasks.
BACK_FROM_WORKWarm 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.

example.py
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)
📌
The queue is in-process memory. If the server restarts, any pending messages are lost. For durable delivery use the 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.

backend/processes/my_process.py
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.