Coverage for session_mgmt_mcp/natural_scheduler.py: 0.00%
338 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-01 05:22 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-01 05:22 -0700
1"""Natural Language Scheduling module for time-based reminders and triggers.
3This module provides intelligent scheduling capabilities including:
4- Natural language time parsing ("in 30 minutes", "tomorrow at 9am")
5- Recurring reminders and cron-like scheduling
6- Context-aware reminder triggers
7- Integration with session workflow
8"""
10import asyncio
11import importlib.util
12import json
13import logging
14import re
15import sqlite3
16import threading
17import time
18from collections.abc import Callable
19from dataclasses import dataclass
20from datetime import datetime, timedelta
21from enum import Enum
22from pathlib import Path
23from typing import Any
25DATEUTIL_AVAILABLE = importlib.util.find_spec("dateutil") is not None
26CRONTAB_AVAILABLE = importlib.util.find_spec("python_crontab") is not None
27SCHEDULE_AVAILABLE = importlib.util.find_spec("schedule") is not None
29if DATEUTIL_AVAILABLE:
30 from dateutil import parser as date_parser
31 from dateutil.relativedelta import relativedelta
33logger = logging.getLogger(__name__)
36class ReminderType(Enum):
37 """Types of reminders."""
39 ONE_TIME = "one_time"
40 RECURRING = "recurring"
41 CONTEXT_TRIGGER = "context_trigger"
42 SESSION_MILESTONE = "session_milestone"
45class ReminderStatus(Enum):
46 """Reminder execution status."""
48 PENDING = "pending"
49 ACTIVE = "active"
50 COMPLETED = "completed"
51 CANCELLED = "cancelled"
52 FAILED = "failed"
55@dataclass
56class NaturalReminder:
57 """Natural language reminder with scheduling information."""
59 id: str
60 title: str
61 description: str
62 reminder_type: ReminderType
63 status: ReminderStatus
64 created_at: datetime
65 scheduled_for: datetime
66 executed_at: datetime | None
67 user_id: str
68 project_id: str | None
69 context_triggers: list[str]
70 recurrence_rule: str | None
71 notification_method: str
72 metadata: dict[str, Any]
75@dataclass
76class SchedulingContext:
77 """Context information for scheduling decisions."""
79 current_time: datetime
80 timezone: str
81 user_preferences: dict[str, Any]
82 active_project: str | None
83 session_duration: int
84 recent_activity: list[dict[str, Any]]
87class NaturalLanguageParser:
88 """Parses natural language time expressions."""
90 def __init__(self) -> None:
91 """Initialize natural language parser."""
92 self.time_patterns = {
93 # Relative time patterns
94 r"in (\d+) (minute|min|minutes|mins)": lambda m: timedelta(
95 minutes=int(m.group(1)),
96 ),
97 r"in (\d+) (hour|hours|hr|hrs)": lambda m: timedelta(hours=int(m.group(1))),
98 r"in (\d+) (day|days)": lambda m: timedelta(days=int(m.group(1))),
99 r"in (\d+) (week|weeks)": lambda m: timedelta(weeks=int(m.group(1))),
100 r"in (\d+) (month|months)": lambda m: relativedelta(months=int(m.group(1)))
101 if DATEUTIL_AVAILABLE
102 else timedelta(days=int(m.group(1)) * 30),
103 # Specific times
104 r"tomorrow( at (\d{1,2}):?(\d{2})?)?(am|pm)?": self._parse_tomorrow,
105 r"next (monday|tuesday|wednesday|thursday|friday|saturday|sunday)": self._parse_next_weekday,
106 r"at (\d{1,2}):?(\d{2})?\s*(am|pm)?": self._parse_specific_time,
107 r"(monday|tuesday|wednesday|thursday|friday|saturday|sunday) at (\d{1,2}):?(\d{2})?\s*(am|pm)?": self._parse_weekday_time,
108 # Session-relative
109 r"end of (session|work)": lambda m: timedelta(
110 hours=2,
111 ), # Default session length
112 r"after (break|lunch)": lambda m: timedelta(hours=1),
113 r"before (meeting|call)": lambda m: timedelta(minutes=15),
114 }
116 self.recurrence_patterns = {
117 r"every (day|daily)": "FREQ=DAILY",
118 r"every (week|weekly)": "FREQ=WEEKLY",
119 r"every (month|monthly)": "FREQ=MONTHLY",
120 r"every (\d+) (minute|minutes)": lambda m: f"FREQ=MINUTELY;INTERVAL={m.group(1)}",
121 r"every (\d+) (hour|hours)": lambda m: f"FREQ=HOURLY;INTERVAL={m.group(1)}",
122 r"every (\d+) (day|days)": lambda m: f"FREQ=DAILY;INTERVAL={m.group(1)}",
123 }
125 def parse_time_expression(
126 self,
127 expression: str,
128 base_time: datetime | None = None,
129 ) -> datetime | None:
130 """Parse natural language time expression."""
131 if not expression:
132 return None
134 base_time = base_time or datetime.now()
135 expression = expression.lower().strip()
137 # Try relative patterns first
138 for pattern, handler in self.time_patterns.items():
139 match = re.search(pattern, expression, re.IGNORECASE)
140 if match:
141 try:
142 if callable(handler):
143 if isinstance(handler(match), timedelta):
144 return base_time + handler(match)
145 if isinstance(handler(match), datetime):
146 return handler(match)
147 delta = handler(match)
148 if hasattr(delta, "days") or hasattr(delta, "months"):
149 return base_time + delta
150 except Exception:
151 continue
153 # Try dateutil parser for absolute dates
154 if DATEUTIL_AVAILABLE:
155 try:
156 parsed_date = date_parser.parse(expression, default=base_time)
157 if parsed_date > base_time: # Only future dates
158 return parsed_date
159 except (ValueError, TypeError):
160 pass
162 return None
164 def parse_recurrence(self, expression: str) -> str | None:
165 """Parse recurrence pattern from natural language."""
166 if not expression:
167 return None
169 expression = expression.lower().strip()
171 for pattern, handler in self.recurrence_patterns.items():
172 match = re.search(pattern, expression, re.IGNORECASE)
173 if match:
174 if callable(handler):
175 return handler(match)
176 return handler
178 return None
180 def _parse_tomorrow(self, match):
181 """Parse 'tomorrow' with optional time."""
182 tomorrow = datetime.now() + timedelta(days=1)
184 if match.group(2) and match.group(3): # Has time
185 hour = int(match.group(2))
186 minute = int(match.group(3))
187 am_pm = match.group(4)
189 if am_pm and am_pm.lower() == "pm" and hour != 12:
190 hour += 12
191 elif am_pm and am_pm.lower() == "am" and hour == 12:
192 hour = 0
194 return tomorrow.replace(hour=hour, minute=minute, second=0, microsecond=0)
195 # Default to 9 AM tomorrow
196 return tomorrow.replace(hour=9, minute=0, second=0, microsecond=0)
198 def _parse_next_weekday(self, match):
199 """Parse 'next monday', etc."""
200 weekdays = {
201 "monday": 0,
202 "tuesday": 1,
203 "wednesday": 2,
204 "thursday": 3,
205 "friday": 4,
206 "saturday": 5,
207 "sunday": 6,
208 }
210 target_weekday = weekdays[match.group(1)]
211 today = datetime.now()
212 days_ahead = target_weekday - today.weekday()
214 if days_ahead <= 0: # Target day already happened this week
215 days_ahead += 7
217 return today + timedelta(days=days_ahead)
219 def _parse_specific_time(self, match):
220 """Parse 'at 3:30pm' for today."""
221 hour = int(match.group(1))
222 minute = int(match.group(2)) if match.group(2) else 0
223 am_pm = match.group(3)
225 if am_pm and am_pm.lower() == "pm" and hour != 12:
226 hour += 12
227 elif am_pm and am_pm.lower() == "am" and hour == 12:
228 hour = 0
230 target_time = datetime.now().replace(
231 hour=hour,
232 minute=minute,
233 second=0,
234 microsecond=0,
235 )
237 # If time has passed today, schedule for tomorrow
238 if target_time <= datetime.now():
239 target_time += timedelta(days=1)
241 return target_time
243 def _parse_weekday_time(self, match):
244 """Parse 'monday at 3pm'."""
245 weekdays = {
246 "monday": 0,
247 "tuesday": 1,
248 "wednesday": 2,
249 "thursday": 3,
250 "friday": 4,
251 "saturday": 5,
252 "sunday": 6,
253 }
255 target_weekday = weekdays[match.group(1)]
256 hour = int(match.group(2))
257 minute = int(match.group(3)) if match.group(3) else 0
258 am_pm = match.group(4)
260 if am_pm and am_pm.lower() == "pm" and hour != 12:
261 hour += 12
262 elif am_pm and am_pm.lower() == "am" and hour == 12:
263 hour = 0
265 today = datetime.now()
266 days_ahead = target_weekday - today.weekday()
268 if days_ahead < 0: # Target day already happened this week
269 days_ahead += 7
270 elif days_ahead == 0: # Today - check if time has passed
271 target_time = today.replace(
272 hour=hour,
273 minute=minute,
274 second=0,
275 microsecond=0,
276 )
277 if target_time <= today:
278 days_ahead = 7
280 target_date = today + timedelta(days=days_ahead)
281 return target_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
284class ReminderScheduler:
285 """Manages scheduling and execution of reminders."""
287 def __init__(self, db_path: str | None = None) -> None:
288 """Initialize reminder scheduler."""
289 self.db_path = db_path or str(
290 Path.home() / ".claude" / "data" / "natural_scheduler.db",
291 )
292 self.parser = NaturalLanguageParser()
293 self._lock = threading.Lock()
294 self._running = False
295 self._scheduler_thread = None
296 self._callbacks: dict[str, list[Callable]] = {}
297 self._init_database()
299 def _init_database(self) -> None:
300 """Initialize SQLite database for reminders."""
301 Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
303 with sqlite3.connect(self.db_path) as conn:
304 conn.execute("""
305 CREATE TABLE IF NOT EXISTS reminders (
306 id TEXT PRIMARY KEY,
307 title TEXT NOT NULL,
308 description TEXT,
309 reminder_type TEXT NOT NULL,
310 status TEXT NOT NULL,
311 created_at TIMESTAMP,
312 scheduled_for TIMESTAMP,
313 executed_at TIMESTAMP,
314 user_id TEXT NOT NULL,
315 project_id TEXT,
316 context_triggers TEXT, -- JSON array
317 recurrence_rule TEXT,
318 notification_method TEXT,
319 metadata TEXT -- JSON object
320 )
321 """)
323 conn.execute("""
324 CREATE TABLE IF NOT EXISTS reminder_history (
325 id INTEGER PRIMARY KEY AUTOINCREMENT,
326 reminder_id TEXT NOT NULL,
327 action TEXT NOT NULL,
328 timestamp TIMESTAMP,
329 result TEXT,
330 details TEXT -- JSON object
331 )
332 """)
334 # Create indices
335 conn.execute(
336 "CREATE INDEX IF NOT EXISTS idx_reminders_scheduled ON reminders(scheduled_for)",
337 )
338 conn.execute(
339 "CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders(status)",
340 )
341 conn.execute(
342 "CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(user_id)",
343 )
344 conn.execute(
345 "CREATE INDEX IF NOT EXISTS idx_reminders_project ON reminders(project_id)",
346 )
348 async def create_reminder(
349 self,
350 title: str,
351 time_expression: str,
352 description: str = "",
353 user_id: str = "default",
354 project_id: str | None = None,
355 notification_method: str = "session",
356 context_triggers: list[str] | None = None,
357 metadata: dict[str, Any] | None = None,
358 ) -> str | None:
359 """Create a new reminder from natural language."""
360 # Parse the time expression
361 scheduled_time = self.parser.parse_time_expression(time_expression)
362 if not scheduled_time:
363 return None
365 # Check for recurrence
366 recurrence_rule = self.parser.parse_recurrence(time_expression)
367 reminder_type = (
368 ReminderType.RECURRING if recurrence_rule else ReminderType.ONE_TIME
369 )
371 # Generate reminder ID
372 reminder_id = f"rem_{int(time.time() * 1000)}"
374 reminder = NaturalReminder(
375 id=reminder_id,
376 title=title,
377 description=description,
378 reminder_type=reminder_type,
379 status=ReminderStatus.PENDING,
380 created_at=datetime.now(),
381 scheduled_for=scheduled_time,
382 executed_at=None,
383 user_id=user_id,
384 project_id=project_id,
385 context_triggers=context_triggers or [],
386 recurrence_rule=recurrence_rule,
387 notification_method=notification_method,
388 metadata=metadata or {},
389 )
391 # Store in database
392 with sqlite3.connect(self.db_path) as conn:
393 conn.execute(
394 """
395 INSERT INTO reminders (id, title, description, reminder_type, status, created_at,
396 scheduled_for, executed_at, user_id, project_id, context_triggers,
397 recurrence_rule, notification_method, metadata)
398 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
399 """,
400 (
401 reminder.id,
402 reminder.title,
403 reminder.description,
404 reminder.reminder_type.value,
405 reminder.status.value,
406 reminder.created_at,
407 reminder.scheduled_for,
408 reminder.executed_at,
409 reminder.user_id,
410 reminder.project_id,
411 json.dumps(reminder.context_triggers),
412 reminder.recurrence_rule,
413 reminder.notification_method,
414 json.dumps(reminder.metadata),
415 ),
416 )
418 # Log creation
419 await self._log_reminder_action(
420 reminder_id,
421 "created",
422 "success",
423 {
424 "scheduled_for": scheduled_time.isoformat(),
425 "time_expression": time_expression,
426 },
427 )
429 return reminder_id
431 async def get_pending_reminders(
432 self,
433 user_id: str | None = None,
434 project_id: str | None = None,
435 ) -> list[dict[str, Any]]:
436 """Get pending reminders."""
437 with sqlite3.connect(self.db_path) as conn:
438 conn.row_factory = sqlite3.Row
440 where_conditions = ["status IN ('pending', 'active')"]
441 params = []
443 if user_id:
444 where_conditions.append("user_id = ?")
445 params.append(user_id)
447 if project_id:
448 where_conditions.append("project_id = ?")
449 params.append(project_id)
451 query = f"SELECT * FROM reminders WHERE {' AND '.join(where_conditions)} ORDER BY scheduled_for"
453 cursor = conn.execute(query, params)
454 results = []
456 for row in cursor.fetchall():
457 result = dict(row)
458 result["context_triggers"] = json.loads(
459 result["context_triggers"] or "[]",
460 )
461 result["metadata"] = json.loads(result["metadata"] or "{}")
462 results.append(result)
464 return results
466 async def get_due_reminders(
467 self,
468 check_time: datetime | None = None,
469 ) -> list[dict[str, Any]]:
470 """Get reminders that are due for execution."""
471 check_time = check_time or datetime.now()
473 with sqlite3.connect(self.db_path) as conn:
474 conn.row_factory = sqlite3.Row
476 cursor = conn.execute(
477 """
478 SELECT * FROM reminders
479 WHERE status = 'pending' AND scheduled_for <= ?
480 ORDER BY scheduled_for
481 """,
482 (check_time,),
483 )
485 results = []
486 for row in cursor.fetchall():
487 result = dict(row)
488 result["context_triggers"] = json.loads(
489 result["context_triggers"] or "[]",
490 )
491 result["metadata"] = json.loads(result["metadata"] or "{}")
492 results.append(result)
494 return results
496 async def execute_reminder(self, reminder_id: str) -> bool:
497 """Execute a due reminder."""
498 try:
499 # Get reminder details
500 with sqlite3.connect(self.db_path) as conn:
501 conn.row_factory = sqlite3.Row
502 row = conn.execute(
503 "SELECT * FROM reminders WHERE id = ?",
504 (reminder_id,),
505 ).fetchone()
507 if not row:
508 return False
510 reminder_data = dict(row)
511 reminder_data["context_triggers"] = json.loads(
512 reminder_data["context_triggers"] or "[]",
513 )
514 reminder_data["metadata"] = json.loads(
515 reminder_data["metadata"] or "{}",
516 )
518 # Execute callbacks
519 callbacks = self._callbacks.get(reminder_data["notification_method"], [])
520 for callback in callbacks:
521 try:
522 await callback(reminder_data)
523 except Exception as e:
524 logger.exception(f"Callback error for reminder {reminder_id}: {e}")
526 # Update status
527 now = datetime.now()
528 new_status = ReminderStatus.COMPLETED
530 # Handle recurring reminders
531 if reminder_data["recurrence_rule"]:
532 # Schedule next occurrence
533 next_time = self._calculate_next_occurrence(
534 reminder_data["scheduled_for"],
535 reminder_data["recurrence_rule"],
536 )
537 if next_time:
538 with sqlite3.connect(self.db_path) as conn:
539 conn.execute(
540 """
541 UPDATE reminders
542 SET scheduled_for = ?, status = 'pending', executed_at = NULL
543 WHERE id = ?
544 """,
545 (next_time, reminder_id),
546 )
548 await self._log_reminder_action(
549 reminder_id,
550 "rescheduled",
551 "success",
552 {"next_occurrence": next_time.isoformat()},
553 )
554 return True
556 # Mark as completed
557 with sqlite3.connect(self.db_path) as conn:
558 conn.execute(
559 """
560 UPDATE reminders
561 SET status = ?, executed_at = ?
562 WHERE id = ?
563 """,
564 (new_status.value, now, reminder_id),
565 )
567 await self._log_reminder_action(
568 reminder_id,
569 "executed",
570 "success",
571 {"executed_at": now.isoformat()},
572 )
574 return True
576 except Exception as e:
577 await self._log_reminder_action(
578 reminder_id,
579 "executed",
580 "failed",
581 {"error": str(e)},
582 )
583 return False
585 async def cancel_reminder(self, reminder_id: str) -> bool:
586 """Cancel a pending reminder."""
587 try:
588 with sqlite3.connect(self.db_path) as conn:
589 result = conn.execute(
590 """
591 UPDATE reminders
592 SET status = ?
593 WHERE id = ? AND status IN ('pending', 'active')
594 """,
595 (ReminderStatus.CANCELLED.value, reminder_id),
596 )
598 success = result.rowcount > 0
600 if success:
601 await self._log_reminder_action(reminder_id, "cancelled", "success", {})
603 return success
605 except Exception as e:
606 await self._log_reminder_action(
607 reminder_id,
608 "cancelled",
609 "failed",
610 {"error": str(e)},
611 )
612 return False
614 def register_notification_callback(self, method: str, callback: Callable) -> None:
615 """Register callback for notification method."""
616 if method not in self._callbacks:
617 self._callbacks[method] = []
618 self._callbacks[method].append(callback)
620 def start_scheduler(self) -> None:
621 """Start the background scheduler."""
622 if self._running:
623 return
625 self._running = True
626 self._scheduler_thread = threading.Thread(
627 target=self._scheduler_loop,
628 daemon=True,
629 )
630 self._scheduler_thread.start()
632 def stop_scheduler(self) -> None:
633 """Stop the background scheduler."""
634 self._running = False
635 if self._scheduler_thread and self._scheduler_thread.is_alive():
636 self._scheduler_thread.join(timeout=5.0)
638 def _scheduler_loop(self) -> None:
639 """Background scheduler loop."""
640 while self._running:
641 try:
642 loop = asyncio.new_event_loop()
643 asyncio.set_event_loop(loop)
644 loop.run_until_complete(self._check_and_execute_reminders())
645 except Exception as e:
646 logger.exception(f"Scheduler loop error: {e}")
647 finally:
648 if loop and not loop.is_closed():
649 loop.close()
650 time.sleep(60) # Check every minute
652 async def _check_and_execute_reminders(self) -> None:
653 """Check for due reminders and execute them."""
654 due_reminders = await self.get_due_reminders()
656 for reminder in due_reminders:
657 await self.execute_reminder(reminder["id"])
659 def _calculate_next_occurrence(
660 self,
661 last_time: datetime,
662 recurrence_rule: str,
663 ) -> datetime | None:
664 """Calculate next occurrence for recurring reminder."""
665 if not DATEUTIL_AVAILABLE:
666 return None
668 try:
669 # Simple rule parsing (extend as needed)
670 if recurrence_rule.startswith("FREQ=DAILY"):
671 return last_time + timedelta(days=1)
672 if recurrence_rule.startswith("FREQ=WEEKLY"):
673 return last_time + timedelta(weeks=1)
674 if recurrence_rule.startswith("FREQ=MONTHLY"):
675 return last_time + relativedelta(months=1)
676 if "INTERVAL=" in recurrence_rule:
677 # Parse interval from rule like "FREQ=HOURLY;INTERVAL=2"
678 parts = recurrence_rule.split(";")
679 interval = 1
680 freq = None
682 for part in parts:
683 if part.startswith("FREQ="):
684 freq = part.split("=")[1]
685 elif part.startswith("INTERVAL="):
686 interval = int(part.split("=")[1])
688 if freq == "HOURLY":
689 return last_time + timedelta(hours=interval)
690 if freq == "MINUTELY":
691 return last_time + timedelta(minutes=interval)
692 if freq == "DAILY":
693 return last_time + timedelta(days=interval)
695 except Exception as e:
696 logger.exception(f"Error calculating next occurrence: {e}")
698 return None
700 async def _log_reminder_action(
701 self,
702 reminder_id: str,
703 action: str,
704 result: str,
705 details: dict[str, Any],
706 ) -> None:
707 """Log reminder action for audit trail."""
708 with sqlite3.connect(self.db_path) as conn:
709 conn.execute(
710 """
711 INSERT INTO reminder_history (reminder_id, action, timestamp, result, details)
712 VALUES (?, ?, ?, ?, ?)
713 """,
714 (reminder_id, action, datetime.now(), result, json.dumps(details)),
715 )
718# Global scheduler instance
719_reminder_scheduler = None
722def get_reminder_scheduler() -> ReminderScheduler:
723 """Get global reminder scheduler instance."""
724 global _reminder_scheduler
725 if _reminder_scheduler is None:
726 _reminder_scheduler = ReminderScheduler()
727 return _reminder_scheduler
730# Public API functions for MCP tools
731async def create_natural_reminder(
732 title: str,
733 time_expression: str,
734 description: str = "",
735 user_id: str = "default",
736 project_id: str | None = None,
737 notification_method: str = "session",
738) -> str | None:
739 """Create reminder from natural language time expression."""
740 scheduler = get_reminder_scheduler()
741 return await scheduler.create_reminder(
742 title,
743 time_expression,
744 description,
745 user_id,
746 project_id,
747 notification_method,
748 )
751async def list_user_reminders(
752 user_id: str = "default",
753 project_id: str | None = None,
754) -> list[dict[str, Any]]:
755 """List pending reminders for user/project."""
756 scheduler = get_reminder_scheduler()
757 return await scheduler.get_pending_reminders(user_id, project_id)
760async def cancel_user_reminder(reminder_id: str) -> bool:
761 """Cancel a specific reminder."""
762 scheduler = get_reminder_scheduler()
763 return await scheduler.cancel_reminder(reminder_id)
766async def check_due_reminders() -> list[dict[str, Any]]:
767 """Check for reminders that are due now."""
768 scheduler = get_reminder_scheduler()
769 return await scheduler.get_due_reminders()
772def start_reminder_service() -> None:
773 """Start the background reminder service."""
774 scheduler = get_reminder_scheduler()
775 scheduler.start_scheduler()
778def stop_reminder_service() -> None:
779 """Stop the background reminder service."""
780 scheduler = get_reminder_scheduler()
781 scheduler.stop_scheduler()
784def register_session_notifications() -> None:
785 """Register session-based notification callbacks."""
786 scheduler = get_reminder_scheduler()
788 async def session_notification(reminder_data: dict[str, Any]) -> None:
789 """Default session notification handler."""
790 logger.info(
791 f"Reminder: {reminder_data['title']} - {reminder_data['description']}",
792 )
794 scheduler.register_notification_callback("session", session_notification)