import EventKit
import Foundation
import OSLog
import Ontology
private let log = Logger.service("reminders")
final class RemindersService: Service {
private let eventStore = EKEventStore()
static let shared = RemindersService()
var isActivated: Bool {
get async {
return EKEventStore.authorizationStatus(for: .reminder) == .fullAccess
}
}
func activate() async throws {
try await eventStore.requestFullAccessToReminders()
}
var tools: [Tool] {
Tool(
name: "reminders_lists",
description: "List available reminder lists",
inputSchema: .object(
properties: [:],
additionalProperties: false
),
annotations: .init(
title: "List Reminder Lists",
readOnlyHint: true,
openWorldHint: false
)
) { arguments in
guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else {
log.error("Reminders access not authorized")
throw NSError(
domain: "RemindersError",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Reminders access not authorized"]
)
}
let reminderLists = self.eventStore.calendars(for: .reminder)
return reminderLists.map { reminderList in
Value.object([
"title": .string(reminderList.title),
"source": .string(reminderList.source.title),
"color": .string(reminderList.color.accessibilityName),
"isEditable": .bool(reminderList.allowsContentModifications),
"isSubscribed": .bool(reminderList.isSubscribed),
])
}
}
Tool(
name: "reminders_fetch",
description: "Get reminders from the reminders app with flexible filtering options",
inputSchema: .object(
properties: [
"completed": .boolean(
description:
"If true, fetch completed reminders; if false, fetch incomplete; if omitted, fetch all"
),
"start": .string(
description:
"Start date/time range for fetching reminders. If timezone is omitted, local time is assumed. Date-only uses local midnight.",
format: .dateTime
),
"end": .string(
description:
"End date/time range for fetching reminders. If timezone is omitted, local time is assumed. Date-only uses local midnight.",
format: .dateTime
),
"lists": .array(
description:
"Names of reminder lists to fetch from; if empty, fetches from all lists",
items: .string()
),
"query": .string(
description: "Text to search for in reminder titles"
),
],
additionalProperties: false
),
annotations: .init(
title: "Fetch Reminders",
readOnlyHint: true,
openWorldHint: false
)
) { arguments in
try await self.activate()
guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else {
log.error("Reminders access not authorized")
throw NSError(
domain: "RemindersError",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Reminders access not authorized"]
)
}
// Filter reminder lists based on provided names
var reminderLists = self.eventStore.calendars(for: .reminder)
if case .array(let listNames) = arguments["lists"],
!listNames.isEmpty
{
let requestedNames = Set(
listNames.compactMap { $0.stringValue?.lowercased() }
)
reminderLists = reminderLists.filter {
requestedNames.contains($0.title.lowercased())
}
}
// Parse dates if provided
var startDate: Date? = nil
var endDate: Date? = nil
var startIsDateOnly = false
var endIsDateOnly = false
if case .string(let start) = arguments["start"],
let parsedStart = ISO8601DateFormatter.parsedLenientISO8601Date(
fromISO8601String: start
)
{
startDate = parsedStart.date
startIsDateOnly = parsedStart.isDateOnly
}
if case .string(let end) = arguments["end"],
let parsedEnd = ISO8601DateFormatter.parsedLenientISO8601Date(
fromISO8601String: end
)
{
endDate = parsedEnd.date
endIsDateOnly = parsedEnd.isDateOnly
}
let calendar = Calendar.current
if let startDateValue = startDate {
startDate = calendar.normalizedStartDate(
from: startDateValue,
isDateOnly: startIsDateOnly
)
}
if let endDateValue = endDate {
endDate = calendar.normalizedEndDate(from: endDateValue, isDateOnly: endIsDateOnly)
}
// Create predicate based on completion status
let predicate: NSPredicate
if case .bool(let completed) = arguments["completed"] {
if completed {
predicate = self.eventStore.predicateForCompletedReminders(
withCompletionDateStarting: startDate,
ending: endDate,
calendars: reminderLists
)
} else {
predicate = self.eventStore.predicateForIncompleteReminders(
withDueDateStarting: startDate,
ending: endDate,
calendars: reminderLists
)
}
} else {
// If completion status not specified, use incomplete predicate as default
predicate = self.eventStore.predicateForReminders(in: reminderLists)
}
// Fetch reminders
let reminders = try await withCheckedThrowingContinuation { continuation in
self.eventStore.fetchReminders(matching: predicate) { fetchedReminders in
continuation.resume(returning: fetchedReminders ?? [])
}
}
// Apply additional filters
var filteredReminders = reminders
// Filter by search text if provided
if case .string(let searchText) = arguments["query"],
!searchText.isEmpty
{
filteredReminders = filteredReminders.filter {
$0.title?.localizedCaseInsensitiveContains(searchText) == true
}
}
return filteredReminders.map { PlanAction($0) }
}
Tool(
name: "reminders_create",
description: "Create a new reminder with specified properties",
inputSchema: .object(
properties: [
"title": .string(),
"due": .string(
description:
"Due date/time for the reminder. If timezone is omitted, local time is assumed. Date-only uses local midnight.",
format: .dateTime
),
"list": .string(
description: "Reminder list name (uses default if not specified)"
),
"notes": .string(),
"priority": .string(
default: .string(EKReminderPriority.none.stringValue),
enum: EKReminderPriority.allCases.map { .string($0.stringValue) }
),
"alarms": .array(
description: "Minutes before due date to set alarms",
items: .integer()
),
],
required: ["title"],
additionalProperties: false
),
annotations: .init(
title: "Create Reminder",
destructiveHint: true,
openWorldHint: false
)
) { arguments in
try await self.activate()
guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else {
log.error("Reminders access not authorized")
throw NSError(
domain: "RemindersError",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Reminders access not authorized"]
)
}
let reminder = EKReminder(eventStore: self.eventStore)
// Set required properties
guard case .string(let title) = arguments["title"] else {
throw NSError(
domain: "RemindersError",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Reminder title is required"]
)
}
reminder.title = title
// Set calendar (list)
var calendar = self.eventStore.defaultCalendarForNewReminders()
if case .string(let listName) = arguments["list"] {
if let matchingCalendar = self.eventStore.calendars(for: .reminder)
.first(where: { $0.title.lowercased() == listName.lowercased() })
{
calendar = matchingCalendar
}
}
reminder.calendar = calendar
// Set optional properties
if case .string(let dueDateStr) = arguments["due"],
let parsedDueDate = ISO8601DateFormatter.parsedLenientISO8601Date(
fromISO8601String: dueDateStr
)
{
let calendar = Calendar.current
let dueDate = calendar.normalizedStartDate(
from: parsedDueDate.date,
isDateOnly: parsedDueDate.isDateOnly
)
reminder.dueDateComponents = calendar.dateComponents(
[.year, .month, .day, .hour, .minute, .second],
from: dueDate
)
}
if case .string(let notes) = arguments["notes"] {
reminder.notes = notes
}
if case .string(let priorityStr) = arguments["priority"] {
reminder.priority = Int(EKReminderPriority.from(string: priorityStr).rawValue)
}
// Set alarms
if case .array(let alarmMinutes) = arguments["alarms"] {
reminder.alarms = alarmMinutes.compactMap {
guard case .int(let minutes) = $0 else { return nil }
return EKAlarm(relativeOffset: TimeInterval(-minutes * 60))
}
}
// Save the reminder
try self.eventStore.save(reminder, commit: true)
return PlanAction(reminder)
}
}
}