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

1"""Natural Language Scheduling module for time-based reminders and triggers. 

2 

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""" 

9 

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 

24 

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 

28 

29if DATEUTIL_AVAILABLE: 

30 from dateutil import parser as date_parser 

31 from dateutil.relativedelta import relativedelta 

32 

33logger = logging.getLogger(__name__) 

34 

35 

36class ReminderType(Enum): 

37 """Types of reminders.""" 

38 

39 ONE_TIME = "one_time" 

40 RECURRING = "recurring" 

41 CONTEXT_TRIGGER = "context_trigger" 

42 SESSION_MILESTONE = "session_milestone" 

43 

44 

45class ReminderStatus(Enum): 

46 """Reminder execution status.""" 

47 

48 PENDING = "pending" 

49 ACTIVE = "active" 

50 COMPLETED = "completed" 

51 CANCELLED = "cancelled" 

52 FAILED = "failed" 

53 

54 

55@dataclass 

56class NaturalReminder: 

57 """Natural language reminder with scheduling information.""" 

58 

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] 

73 

74 

75@dataclass 

76class SchedulingContext: 

77 """Context information for scheduling decisions.""" 

78 

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]] 

85 

86 

87class NaturalLanguageParser: 

88 """Parses natural language time expressions.""" 

89 

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 } 

115 

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 } 

124 

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 

133 

134 base_time = base_time or datetime.now() 

135 expression = expression.lower().strip() 

136 

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 

152 

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 

161 

162 return None 

163 

164 def parse_recurrence(self, expression: str) -> str | None: 

165 """Parse recurrence pattern from natural language.""" 

166 if not expression: 

167 return None 

168 

169 expression = expression.lower().strip() 

170 

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 

177 

178 return None 

179 

180 def _parse_tomorrow(self, match): 

181 """Parse 'tomorrow' with optional time.""" 

182 tomorrow = datetime.now() + timedelta(days=1) 

183 

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) 

188 

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 

193 

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) 

197 

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 } 

209 

210 target_weekday = weekdays[match.group(1)] 

211 today = datetime.now() 

212 days_ahead = target_weekday - today.weekday() 

213 

214 if days_ahead <= 0: # Target day already happened this week 

215 days_ahead += 7 

216 

217 return today + timedelta(days=days_ahead) 

218 

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) 

224 

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 

229 

230 target_time = datetime.now().replace( 

231 hour=hour, 

232 minute=minute, 

233 second=0, 

234 microsecond=0, 

235 ) 

236 

237 # If time has passed today, schedule for tomorrow 

238 if target_time <= datetime.now(): 

239 target_time += timedelta(days=1) 

240 

241 return target_time 

242 

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 } 

254 

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) 

259 

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 

264 

265 today = datetime.now() 

266 days_ahead = target_weekday - today.weekday() 

267 

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 

279 

280 target_date = today + timedelta(days=days_ahead) 

281 return target_date.replace(hour=hour, minute=minute, second=0, microsecond=0) 

282 

283 

284class ReminderScheduler: 

285 """Manages scheduling and execution of reminders.""" 

286 

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() 

298 

299 def _init_database(self) -> None: 

300 """Initialize SQLite database for reminders.""" 

301 Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) 

302 

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 """) 

322 

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 """) 

333 

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 ) 

347 

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 

364 

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 ) 

370 

371 # Generate reminder ID 

372 reminder_id = f"rem_{int(time.time() * 1000)}" 

373 

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 ) 

390 

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 ) 

417 

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 ) 

428 

429 return reminder_id 

430 

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 

439 

440 where_conditions = ["status IN ('pending', 'active')"] 

441 params = [] 

442 

443 if user_id: 

444 where_conditions.append("user_id = ?") 

445 params.append(user_id) 

446 

447 if project_id: 

448 where_conditions.append("project_id = ?") 

449 params.append(project_id) 

450 

451 query = f"SELECT * FROM reminders WHERE {' AND '.join(where_conditions)} ORDER BY scheduled_for" 

452 

453 cursor = conn.execute(query, params) 

454 results = [] 

455 

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) 

463 

464 return results 

465 

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() 

472 

473 with sqlite3.connect(self.db_path) as conn: 

474 conn.row_factory = sqlite3.Row 

475 

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 ) 

484 

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) 

493 

494 return results 

495 

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() 

506 

507 if not row: 

508 return False 

509 

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 ) 

517 

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}") 

525 

526 # Update status 

527 now = datetime.now() 

528 new_status = ReminderStatus.COMPLETED 

529 

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 ) 

547 

548 await self._log_reminder_action( 

549 reminder_id, 

550 "rescheduled", 

551 "success", 

552 {"next_occurrence": next_time.isoformat()}, 

553 ) 

554 return True 

555 

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 ) 

566 

567 await self._log_reminder_action( 

568 reminder_id, 

569 "executed", 

570 "success", 

571 {"executed_at": now.isoformat()}, 

572 ) 

573 

574 return True 

575 

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 

584 

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 ) 

597 

598 success = result.rowcount > 0 

599 

600 if success: 

601 await self._log_reminder_action(reminder_id, "cancelled", "success", {}) 

602 

603 return success 

604 

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 

613 

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) 

619 

620 def start_scheduler(self) -> None: 

621 """Start the background scheduler.""" 

622 if self._running: 

623 return 

624 

625 self._running = True 

626 self._scheduler_thread = threading.Thread( 

627 target=self._scheduler_loop, 

628 daemon=True, 

629 ) 

630 self._scheduler_thread.start() 

631 

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) 

637 

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 

651 

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() 

655 

656 for reminder in due_reminders: 

657 await self.execute_reminder(reminder["id"]) 

658 

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 

667 

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 

681 

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]) 

687 

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) 

694 

695 except Exception as e: 

696 logger.exception(f"Error calculating next occurrence: {e}") 

697 

698 return None 

699 

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 ) 

716 

717 

718# Global scheduler instance 

719_reminder_scheduler = None 

720 

721 

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 

728 

729 

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 ) 

749 

750 

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) 

758 

759 

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) 

764 

765 

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() 

770 

771 

772def start_reminder_service() -> None: 

773 """Start the background reminder service.""" 

774 scheduler = get_reminder_scheduler() 

775 scheduler.start_scheduler() 

776 

777 

778def stop_reminder_service() -> None: 

779 """Stop the background reminder service.""" 

780 scheduler = get_reminder_scheduler() 

781 scheduler.stop_scheduler() 

782 

783 

784def register_session_notifications() -> None: 

785 """Register session-based notification callbacks.""" 

786 scheduler = get_reminder_scheduler() 

787 

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 ) 

793 

794 scheduler.register_notification_callback("session", session_notification)