Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40c5f25517 | |||
| 454cef4193 |
@@ -270,6 +270,11 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
|
||||
INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings.";
|
||||
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings.";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -283,8 +288,8 @@
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 2.4.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -314,6 +319,11 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
|
||||
INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings.";
|
||||
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings.";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -327,8 +337,8 @@
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 2.4.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.4.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
"filename" : "AppLogo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
+9053
-8432
File diff suppressed because it is too large
Load Diff
@@ -57,4 +57,10 @@ struct AgentSkill: Codable, Identifiable {
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/// Matches the user's "2nd Brain" skill by name — there's no canonical skill ID,
|
||||
/// so this is the only way to recognize it (used to gate the "always trust" bash setting).
|
||||
var isSecondBrainSkill: Bool {
|
||||
name.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("2nd Brain") == .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,19 @@ struct Tool: Codable {
|
||||
let type: String
|
||||
let description: String
|
||||
let `enum`: [String]?
|
||||
let items: Items?
|
||||
|
||||
/// Item schema for `type: "array"` properties (e.g. an array of strings).
|
||||
struct Items: Codable {
|
||||
let type: String
|
||||
}
|
||||
|
||||
init(type: String, description: String, enum: [String]? = nil, items: Items? = nil) {
|
||||
self.type = type
|
||||
self.description = description
|
||||
self.enum = `enum`
|
||||
self.items = items
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,6 +753,9 @@ class AnthropicProvider: AIProvider {
|
||||
if let enumVals = prop.enum {
|
||||
propDict["enum"] = enumVals
|
||||
}
|
||||
if let items = prop.items {
|
||||
propDict["items"] = ["type": items.type]
|
||||
}
|
||||
props[key] = propDict
|
||||
}
|
||||
var dict: [String: Any] = [
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//
|
||||
// ContactsService.swift
|
||||
// oAI
|
||||
//
|
||||
// Read-only Contacts integration: search and "my card" lookup
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
//
|
||||
// This file is part of oAI.
|
||||
//
|
||||
// oAI is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
||||
// Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public
|
||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import Contacts
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
class ContactsService {
|
||||
static let shared = ContactsService()
|
||||
|
||||
private let store = CNContactStore()
|
||||
private let maxResults = 20
|
||||
private let maxScan = 5000
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
var authStatus: CNAuthorizationStatus {
|
||||
CNContactStore.authorizationStatus(for: .contacts)
|
||||
}
|
||||
|
||||
var authorized: Bool {
|
||||
authStatus == .authorized
|
||||
}
|
||||
|
||||
var accessState: PersonalDataAccessState {
|
||||
let status = authStatus
|
||||
Log.mcp.debug("ContactsService.accessState -> status=\(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
switch status {
|
||||
case .authorized: return .granted
|
||||
case .notDetermined: return .notDetermined
|
||||
default: return .denied
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requestAccess() async -> Bool {
|
||||
let before = CNContactStore.authorizationStatus(for: .contacts)
|
||||
Log.mcp.info("ContactsService.requestAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||
return await withCheckedContinuation { continuation in
|
||||
store.requestAccess(for: .contacts) { granted, error in
|
||||
let after = CNContactStore.authorizationStatus(for: .contacts)
|
||||
if let error {
|
||||
Log.mcp.error("ContactsService.requestAccess: error=\(error.localizedDescription); granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
} else {
|
||||
Log.mcp.info("ContactsService.requestAccess: granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
}
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func describe(_ status: CNAuthorizationStatus) -> String {
|
||||
switch status {
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .restricted: return "restricted"
|
||||
case .denied: return "denied"
|
||||
case .authorized: return "authorized"
|
||||
case .limited: return "limited"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool Schemas
|
||||
|
||||
func getToolSchemas() -> [Tool] {
|
||||
[
|
||||
makeTool(
|
||||
name: "contacts_search",
|
||||
description: "Search Contacts by name, phone number, or email address. Returns matching contacts with their phone numbers and emails. This does NOT match relationship labels like 'mother' or 'spouse' — for those, call contacts_get_me first to find the related person's name, then search for that name.",
|
||||
properties: [
|
||||
"query": prop("string", "Name, phone number, or email fragment to search for")
|
||||
],
|
||||
required: ["query"]
|
||||
),
|
||||
makeTool(
|
||||
name: "contacts_get_me",
|
||||
description: "Get the user's own contact card (\"My Card\" in Contacts.app), if configured. Includes any defined relationships (e.g. mother, spouse, child) with the related person's name — use contacts_search with that name to find their phone/email.",
|
||||
properties: [:],
|
||||
required: []
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Tool Execution
|
||||
|
||||
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||
Log.mcp.info("Executing Contacts tool: \(name)")
|
||||
guard authorized else {
|
||||
return ["error": "Contacts permission not granted. Grant access in Settings > MCP."]
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "contacts_search":
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let args = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let query = args["query"] as? String, !query.isEmpty else {
|
||||
return ["error": "Missing required parameter: query"]
|
||||
}
|
||||
return search(query: query)
|
||||
|
||||
case "contacts_get_me":
|
||||
return getMe()
|
||||
|
||||
default:
|
||||
return ["error": "Unknown Contacts tool: \(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Implementation
|
||||
|
||||
private let keysToFetch: [CNKeyDescriptor] = [
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
||||
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||
CNContactRelationsKey as CNKeyDescriptor
|
||||
]
|
||||
|
||||
private func search(query: String) -> [String: Any] {
|
||||
var matches: [CNContact] = []
|
||||
|
||||
// Fast path: name predicate
|
||||
let namePredicate = CNContact.predicateForContacts(matchingName: query)
|
||||
if let nameMatches = try? store.unifiedContacts(matching: namePredicate, keysToFetch: keysToFetch) {
|
||||
matches.append(contentsOf: nameMatches)
|
||||
}
|
||||
|
||||
// Fallback: scan for phone/email substring matches
|
||||
if matches.isEmpty {
|
||||
let lowerQuery = query.lowercased()
|
||||
let digitsQuery = query.filter(\.isNumber)
|
||||
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
|
||||
var scanned = 0
|
||||
try? store.enumerateContacts(with: request) { contact, stop in
|
||||
scanned += 1
|
||||
if scanned > self.maxScan || matches.count >= self.maxResults {
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
let emailMatch = contact.emailAddresses.contains {
|
||||
($0.value as String).lowercased().contains(lowerQuery)
|
||||
}
|
||||
let phoneMatch = !digitsQuery.isEmpty && contact.phoneNumbers.contains {
|
||||
$0.value.stringValue.filter(\.isNumber).contains(digitsQuery)
|
||||
}
|
||||
if emailMatch || phoneMatch {
|
||||
matches.append(contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let deduped = dedupContacts(matches)
|
||||
let formatted = deduped.prefix(maxResults).map(formatContact)
|
||||
return ["count": formatted.count, "contacts": Array(formatted)]
|
||||
}
|
||||
|
||||
/// Collapses contacts that share a phone number or email — Contacts.app's "linked contacts"
|
||||
/// merge doesn't catch every real-world duplicate card, so do a best-effort merge here too.
|
||||
private func dedupContacts(_ contacts: [CNContact]) -> [CNContact] {
|
||||
var result: [CNContact] = []
|
||||
outer: for contact in contacts {
|
||||
let phones = Set(contact.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) })
|
||||
let emails = Set(contact.emailAddresses.map { ($0.value as String).lowercased() })
|
||||
for existing in result {
|
||||
let existingPhones = Set(existing.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) })
|
||||
let existingEmails = Set(existing.emailAddresses.map { ($0.value as String).lowercased() })
|
||||
if !phones.isDisjoint(with: existingPhones) || !emails.isDisjoint(with: existingEmails) {
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
result.append(contact)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func getMe() -> [String: Any] {
|
||||
guard let me = try? store.unifiedMeContactWithKeys(toFetch: keysToFetch) else {
|
||||
return ["error": "No 'My Card' is configured in Contacts.app"]
|
||||
}
|
||||
return formatContact(me)
|
||||
}
|
||||
|
||||
private func formatContact(_ contact: CNContact) -> [String: Any] {
|
||||
var item: [String: Any] = [
|
||||
"given_name": contact.givenName,
|
||||
"family_name": contact.familyName
|
||||
]
|
||||
if !contact.organizationName.isEmpty {
|
||||
item["organization"] = contact.organizationName
|
||||
}
|
||||
if !contact.phoneNumbers.isEmpty {
|
||||
item["phones"] = contact.phoneNumbers.map { $0.value.stringValue }
|
||||
}
|
||||
if !contact.emailAddresses.isEmpty {
|
||||
item["emails"] = contact.emailAddresses.map { $0.value as String }
|
||||
}
|
||||
if !contact.contactRelations.isEmpty {
|
||||
item["relations"] = contact.contactRelations.map { labeled -> [String: String] in
|
||||
let label = labeled.label.map { CNLabeledValue<CNContactRelation>.localizedString(forLabel: $0) } ?? "relation"
|
||||
return ["label": label, "name": labeled.value.name]
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
||||
Tool(
|
||||
type: "function",
|
||||
function: Tool.Function(
|
||||
name: name,
|
||||
description: description,
|
||||
parameters: Tool.Function.Parameters(
|
||||
type: "object",
|
||||
properties: properties,
|
||||
required: required
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
|
||||
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
//
|
||||
// EventKitService.swift
|
||||
// oAI
|
||||
//
|
||||
// Calendar and Reminders integration via EventKit
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
//
|
||||
// This file is part of oAI.
|
||||
//
|
||||
// oAI is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
||||
// Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public
|
||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import EventKit
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Shared tri-state authorization status for the Settings UI across all Personal Data services.
|
||||
/// `.denied` also covers `.restricted` and (for Calendar) `.writeOnly` — states where the OS
|
||||
/// will not show a request dialog again; the user must go to System Settings manually.
|
||||
enum PersonalDataAccessState {
|
||||
case notDetermined
|
||||
case denied
|
||||
case granted
|
||||
}
|
||||
|
||||
@Observable
|
||||
class EventKitService {
|
||||
static let shared = EventKitService()
|
||||
|
||||
private let store = EKEventStore()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
var calendarAuthStatus: EKAuthorizationStatus {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.debug("EventKitService.calendarAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
return status
|
||||
}
|
||||
|
||||
var reminderAuthStatus: EKAuthorizationStatus {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.debug("EventKitService.reminderAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
return status
|
||||
}
|
||||
|
||||
var calendarAuthorized: Bool {
|
||||
calendarAuthStatus == .fullAccess
|
||||
}
|
||||
|
||||
var reminderAuthorized: Bool {
|
||||
reminderAuthStatus == .fullAccess
|
||||
}
|
||||
|
||||
var calendarAccessState: PersonalDataAccessState {
|
||||
switch calendarAuthStatus {
|
||||
case .fullAccess: return .granted
|
||||
case .notDetermined: return .notDetermined
|
||||
default: return .denied // .denied, .restricted, .writeOnly (no read access for our tools)
|
||||
}
|
||||
}
|
||||
|
||||
var reminderAccessState: PersonalDataAccessState {
|
||||
switch reminderAuthStatus {
|
||||
case .fullAccess: return .granted
|
||||
case .notDetermined: return .notDetermined
|
||||
default: return .denied
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requestCalendarAccess() async -> Bool {
|
||||
let before = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.info("requestCalendarAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||
do {
|
||||
let granted = try await store.requestFullAccessToEvents()
|
||||
let after = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.info("requestCalendarAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return granted
|
||||
} catch {
|
||||
let after = EKEventStore.authorizationStatus(for: .event)
|
||||
Log.mcp.error("requestCalendarAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requestReminderAccess() async -> Bool {
|
||||
let before = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.info("requestReminderAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||
do {
|
||||
let granted = try await store.requestFullAccessToReminders()
|
||||
let after = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.info("requestReminderAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return granted
|
||||
} catch {
|
||||
let after = EKEventStore.authorizationStatus(for: .reminder)
|
||||
Log.mcp.error("requestReminderAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func describe(_ status: EKAuthorizationStatus) -> String {
|
||||
switch status {
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .restricted: return "restricted"
|
||||
case .denied: return "denied"
|
||||
case .fullAccess: return "fullAccess"
|
||||
case .writeOnly: return "writeOnly"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool Schemas
|
||||
|
||||
func getToolSchemas(calendarEnabled: Bool, remindersEnabled: Bool) -> [Tool] {
|
||||
var tools: [Tool] = []
|
||||
|
||||
if calendarEnabled {
|
||||
tools.append(makeTool(
|
||||
name: "calendar_list_calendars",
|
||||
description: "List all calendars available on this Mac (e.g. iCloud, Work, Home).",
|
||||
properties: [:],
|
||||
required: []
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "calendar_list_events",
|
||||
description: "List calendar events within a date range. Dates are ISO8601 (e.g. '2026-06-20T00:00:00' or '2026-06-20'). Range is limited to 1 year. For open-ended queries like 'next appointment' or 'upcoming events', do NOT limit the range to just today — use a generous forward-looking window (e.g. today through +90 days) so you don't miss events further out.",
|
||||
properties: [
|
||||
"start_date": prop("string", "Start of the date range (ISO8601)"),
|
||||
"end_date": prop("string", "End of the date range (ISO8601)"),
|
||||
"calendar_name": prop("string", "Optional: only list events from this calendar")
|
||||
],
|
||||
required: ["start_date", "end_date"]
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "calendar_create_event",
|
||||
description: "Create a new calendar event. Requires user approval before it is actually created.",
|
||||
properties: [
|
||||
"title": prop("string", "Event title"),
|
||||
"start_date": prop("string", "Start date/time (ISO8601, e.g. '2026-06-20T14:00:00')"),
|
||||
"end_date": prop("string", "End date/time (ISO8601)"),
|
||||
"calendar_name": prop("string", "Optional: calendar to add the event to (defaults to the system default calendar)"),
|
||||
"location": prop("string", "Optional: event location text"),
|
||||
"notes": prop("string", "Optional: event notes"),
|
||||
"all_day": prop("boolean", "Optional: whether this is an all-day event (default: false)"),
|
||||
"alarm_minutes_before": prop("number", "Optional: minutes before the start time to show an alert")
|
||||
],
|
||||
required: ["title", "start_date", "end_date"]
|
||||
))
|
||||
}
|
||||
|
||||
if remindersEnabled {
|
||||
tools.append(makeTool(
|
||||
name: "reminders_list_lists",
|
||||
description: "List all reminder lists available on this Mac.",
|
||||
properties: [:],
|
||||
required: []
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "reminders_list",
|
||||
description: "List reminders. Omit list_name to search across ALL reminder lists in a single call — prefer this over calling once per list. Incomplete reminders only unless include_completed is true.",
|
||||
properties: [
|
||||
"list_name": prop("string", "Optional: only list reminders from this one list (omit to search all lists at once)"),
|
||||
"include_completed": prop("boolean", "Optional: include completed reminders (default: false)")
|
||||
],
|
||||
required: []
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "reminders_create",
|
||||
description: "Create a new reminder. Requires user approval before it is actually created.",
|
||||
properties: [
|
||||
"title": prop("string", "Reminder title"),
|
||||
"list_name": prop("string", "Optional: reminder list to add to (defaults to the system default list)"),
|
||||
"due_date": prop("string", "Optional: due date/time (ISO8601)"),
|
||||
"priority": prop("string", "Optional: priority level", enumValues: ["none", "low", "medium", "high"]),
|
||||
"notes": prop("string", "Optional: reminder notes")
|
||||
],
|
||||
required: ["title"]
|
||||
))
|
||||
tools.append(makeTool(
|
||||
name: "reminders_complete",
|
||||
description: "Mark a reminder as completed. Requires user approval. Use reminders_list to find the reminder_id first.",
|
||||
properties: [
|
||||
"reminder_id": prop("string", "The reminder's identifier, from reminders_list")
|
||||
],
|
||||
required: ["reminder_id"]
|
||||
))
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
// MARK: - Read Tool Execution
|
||||
|
||||
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||
Log.mcp.info("Executing EventKit tool: \(name)")
|
||||
let args = Self.parseArgs(arguments)
|
||||
|
||||
switch name {
|
||||
case "calendar_list_calendars":
|
||||
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||
return listCalendars()
|
||||
|
||||
case "calendar_list_events":
|
||||
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
|
||||
return ["error": "Missing or invalid parameter: start_date"]
|
||||
}
|
||||
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
|
||||
return ["error": "Missing or invalid parameter: end_date"]
|
||||
}
|
||||
let calendarName = args["calendar_name"] as? String
|
||||
return listEvents(start: start, end: end, calendarName: calendarName)
|
||||
|
||||
case "reminders_list_lists":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
return listReminderLists()
|
||||
|
||||
case "reminders_list":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
let listName = args["list_name"] as? String
|
||||
let includeCompleted = args["include_completed"] as? Bool ?? false
|
||||
return await listReminders(listName: listName, includeCompleted: includeCompleted)
|
||||
|
||||
default:
|
||||
return ["error": "Unknown EventKit tool: \(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Write Tool Execution (called only after approval)
|
||||
|
||||
func executeWriteTool(name: String, arguments: String) async -> [String: Any] {
|
||||
Log.mcp.info("Executing EventKit write tool: \(name)")
|
||||
let args = Self.parseArgs(arguments)
|
||||
|
||||
switch name {
|
||||
case "calendar_create_event":
|
||||
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||
return createEvent(args: args)
|
||||
|
||||
case "reminders_create":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
return createReminder(args: args)
|
||||
|
||||
case "reminders_complete":
|
||||
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||
guard let reminderId = args["reminder_id"] as? String else {
|
||||
return ["error": "Missing required parameter: reminder_id"]
|
||||
}
|
||||
return completeReminder(reminderId: reminderId)
|
||||
|
||||
default:
|
||||
return ["error": "Unknown EventKit write tool: \(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Approval Summary
|
||||
|
||||
/// Human-readable description shown in the approval sheet before a write tool runs.
|
||||
func approvalSummary(forTool name: String, arguments: String) -> String {
|
||||
let args = Self.parseArgs(arguments)
|
||||
switch name {
|
||||
case "calendar_create_event":
|
||||
let title = args["title"] as? String ?? "Untitled event"
|
||||
let start = (args["start_date"] as? String).flatMap(Self.parseDate) ?? Date()
|
||||
let end = (args["end_date"] as? String).flatMap(Self.parseDate) ?? start
|
||||
return "Create calendar event \"\(title)\" from \(Self.displayFormatter.string(from: start)) to \(Self.displayFormatter.string(from: end))"
|
||||
case "reminders_create":
|
||||
let title = args["title"] as? String ?? "Untitled reminder"
|
||||
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
|
||||
return "Create reminder \"\(title)\" due \(Self.displayFormatter.string(from: due))"
|
||||
}
|
||||
return "Create reminder \"\(title)\""
|
||||
case "reminders_complete":
|
||||
return "Mark reminder as completed"
|
||||
default:
|
||||
return "Perform action: \(name)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calendar Read Implementations
|
||||
|
||||
private func listCalendars() -> [String: Any] {
|
||||
let calendars = store.calendars(for: .event).map { cal -> [String: Any] in
|
||||
[
|
||||
"name": cal.title,
|
||||
"type": calendarTypeDescription(cal),
|
||||
"allows_modifications": cal.allowsContentModifications
|
||||
]
|
||||
}
|
||||
return ["calendars": calendars]
|
||||
}
|
||||
|
||||
private func listEvents(start: Date, end: Date, calendarName: String?) -> [String: Any] {
|
||||
guard end > start else { return ["error": "end_date must be after start_date"] }
|
||||
guard end.timeIntervalSince(start) <= 366 * 24 * 60 * 60 else {
|
||||
return ["error": "Date range too large — limit to 1 year or less"]
|
||||
}
|
||||
|
||||
var calendars = store.calendars(for: .event)
|
||||
if let calendarName {
|
||||
calendars = calendars.filter { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }
|
||||
if calendars.isEmpty {
|
||||
return ["error": "No calendar found named '\(calendarName)'"]
|
||||
}
|
||||
}
|
||||
|
||||
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
|
||||
let events = store.events(matching: predicate)
|
||||
.sorted { $0.startDate < $1.startDate }
|
||||
.prefix(200)
|
||||
.map { event -> [String: Any] in
|
||||
var item: [String: Any] = [
|
||||
"id": event.eventIdentifier ?? "",
|
||||
"title": event.title ?? "Untitled",
|
||||
"start": Self.isoFormatter.string(from: event.startDate),
|
||||
"end": Self.isoFormatter.string(from: event.endDate),
|
||||
"all_day": event.isAllDay,
|
||||
"calendar": event.calendar?.title ?? ""
|
||||
]
|
||||
if let location = event.location, !location.isEmpty {
|
||||
item["location"] = location
|
||||
}
|
||||
if let notes = event.notes, !notes.isEmpty {
|
||||
item["notes"] = String(notes.prefix(500))
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
return ["count": events.count, "events": Array(events)]
|
||||
}
|
||||
|
||||
private func createEvent(args: [String: Any]) -> [String: Any] {
|
||||
guard let title = args["title"] as? String, !title.isEmpty else {
|
||||
return ["error": "Missing required parameter: title"]
|
||||
}
|
||||
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
|
||||
return ["error": "Missing or invalid parameter: start_date"]
|
||||
}
|
||||
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
|
||||
return ["error": "Missing or invalid parameter: end_date"]
|
||||
}
|
||||
guard end >= start else {
|
||||
return ["error": "end_date must not be before start_date"]
|
||||
}
|
||||
|
||||
let event = EKEvent(eventStore: store)
|
||||
event.title = title
|
||||
event.startDate = start
|
||||
event.endDate = end
|
||||
event.isAllDay = args["all_day"] as? Bool ?? false
|
||||
|
||||
if let calendarName = args["calendar_name"] as? String,
|
||||
let calendar = store.calendars(for: .event).first(where: { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }) {
|
||||
event.calendar = calendar
|
||||
} else if let defaultCalendar = store.defaultCalendarForNewEvents {
|
||||
event.calendar = defaultCalendar
|
||||
} else {
|
||||
guard let fallback = store.calendars(for: .event).first(where: { $0.allowsContentModifications }) else {
|
||||
return ["error": "No writable calendar available"]
|
||||
}
|
||||
event.calendar = fallback
|
||||
}
|
||||
|
||||
if let location = args["location"] as? String { event.location = location }
|
||||
if let notes = args["notes"] as? String { event.notes = notes }
|
||||
if let minutesBefore = (args["alarm_minutes_before"] as? Double) ?? (args["alarm_minutes_before"] as? Int).map(Double.init) {
|
||||
event.addAlarm(EKAlarm(relativeOffset: -(minutesBefore * 60)))
|
||||
}
|
||||
|
||||
do {
|
||||
try store.save(event, span: .thisEvent, commit: true)
|
||||
return ["success": true, "event_id": event.eventIdentifier ?? "", "calendar": event.calendar?.title ?? ""]
|
||||
} catch {
|
||||
Log.mcp.error("calendar_create_event failed: \(error.localizedDescription)")
|
||||
return ["error": "Failed to create event: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reminders Read Implementations
|
||||
|
||||
private func listReminderLists() -> [String: Any] {
|
||||
let lists = store.calendars(for: .reminder).map { cal -> [String: Any] in
|
||||
["name": cal.title, "allows_modifications": cal.allowsContentModifications]
|
||||
}
|
||||
return ["lists": lists]
|
||||
}
|
||||
|
||||
private func listReminders(listName: String?, includeCompleted: Bool) async -> [String: Any] {
|
||||
var lists = store.calendars(for: .reminder)
|
||||
if let listName {
|
||||
lists = lists.filter { $0.title.caseInsensitiveCompare(listName) == .orderedSame }
|
||||
if lists.isEmpty {
|
||||
return ["error": "No reminder list found named '\(listName)'"]
|
||||
}
|
||||
}
|
||||
|
||||
let predicate = store.predicateForReminders(in: lists)
|
||||
let reminders: [EKReminder] = await withCheckedContinuation { continuation in
|
||||
store.fetchReminders(matching: predicate) { results in
|
||||
continuation.resume(returning: results ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
let filtered = reminders
|
||||
.filter { includeCompleted || !$0.isCompleted }
|
||||
.sorted { lhs, rhs in
|
||||
let l = lhs.dueDateComponents?.date ?? .distantFuture
|
||||
let r = rhs.dueDateComponents?.date ?? .distantFuture
|
||||
return l < r
|
||||
}
|
||||
.prefix(200)
|
||||
.map { reminder -> [String: Any] in
|
||||
var item: [String: Any] = [
|
||||
"id": reminder.calendarItemIdentifier,
|
||||
"title": reminder.title ?? "Untitled",
|
||||
"completed": reminder.isCompleted,
|
||||
"list": reminder.calendar?.title ?? ""
|
||||
]
|
||||
if let due = reminder.dueDateComponents?.date {
|
||||
item["due"] = Self.isoFormatter.string(from: due)
|
||||
}
|
||||
if reminder.priority > 0 {
|
||||
item["priority"] = priorityDescription(reminder.priority)
|
||||
}
|
||||
if let notes = reminder.notes, !notes.isEmpty {
|
||||
item["notes"] = String(notes.prefix(500))
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
return ["count": filtered.count, "reminders": Array(filtered)]
|
||||
}
|
||||
|
||||
private func createReminder(args: [String: Any]) -> [String: Any] {
|
||||
guard let title = args["title"] as? String, !title.isEmpty else {
|
||||
return ["error": "Missing required parameter: title"]
|
||||
}
|
||||
|
||||
let reminder = EKReminder(eventStore: store)
|
||||
reminder.title = title
|
||||
|
||||
if let listName = args["list_name"] as? String,
|
||||
let list = store.calendars(for: .reminder).first(where: { $0.title.caseInsensitiveCompare(listName) == .orderedSame }) {
|
||||
reminder.calendar = list
|
||||
} else if let defaultList = store.defaultCalendarForNewReminders() {
|
||||
reminder.calendar = defaultList
|
||||
} else {
|
||||
guard let fallback = store.calendars(for: .reminder).first(where: { $0.allowsContentModifications }) else {
|
||||
return ["error": "No writable reminder list available"]
|
||||
}
|
||||
reminder.calendar = fallback
|
||||
}
|
||||
|
||||
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
|
||||
reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: due)
|
||||
}
|
||||
if let notes = args["notes"] as? String { reminder.notes = notes }
|
||||
if let priority = args["priority"] as? String { reminder.priority = priorityValue(priority) }
|
||||
|
||||
do {
|
||||
try store.save(reminder, commit: true)
|
||||
return ["success": true, "reminder_id": reminder.calendarItemIdentifier, "list": reminder.calendar?.title ?? ""]
|
||||
} catch {
|
||||
Log.mcp.error("reminders_create failed: \(error.localizedDescription)")
|
||||
return ["error": "Failed to create reminder: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
private func completeReminder(reminderId: String) -> [String: Any] {
|
||||
guard let item = store.calendarItem(withIdentifier: reminderId) as? EKReminder else {
|
||||
return ["error": "No reminder found with id '\(reminderId)'"]
|
||||
}
|
||||
item.isCompleted = true
|
||||
item.completionDate = Date()
|
||||
|
||||
do {
|
||||
try store.save(item, commit: true)
|
||||
return ["success": true, "reminder_id": reminderId, "title": item.title ?? ""]
|
||||
} catch {
|
||||
Log.mcp.error("reminders_complete failed: \(error.localizedDescription)")
|
||||
return ["error": "Failed to complete reminder: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func calendarTypeDescription(_ cal: EKCalendar) -> String {
|
||||
switch cal.type {
|
||||
case .local: return "local"
|
||||
case .calDAV: return "caldav"
|
||||
case .exchange: return "exchange"
|
||||
case .subscription: return "subscription"
|
||||
case .birthday: return "birthday"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private func priorityDescription(_ value: Int) -> String {
|
||||
switch value {
|
||||
case 1...4: return "high"
|
||||
case 5: return "medium"
|
||||
case 6...9: return "low"
|
||||
default: return "none"
|
||||
}
|
||||
}
|
||||
|
||||
private func priorityValue(_ description: String) -> Int {
|
||||
switch description.lowercased() {
|
||||
case "high": return 1
|
||||
case "medium": return 5
|
||||
case "low": return 9
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
||||
Tool(
|
||||
type: "function",
|
||||
function: Tool.Function(
|
||||
name: name,
|
||||
description: description,
|
||||
parameters: Tool.Function.Parameters(
|
||||
type: "object",
|
||||
properties: properties,
|
||||
required: required
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func prop(_ type: String, _ description: String, enumValues: [String]? = nil) -> Tool.Function.Parameters.Property {
|
||||
Tool.Function.Parameters.Property(type: type, description: description, enum: enumValues)
|
||||
}
|
||||
|
||||
nonisolated static func parseArgs(_ arguments: String) -> [String: Any] {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return [:]
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
nonisolated static func permissionError(domain: String) -> [String: Any] {
|
||||
["error": "\(domain) permission not granted. Grant access in Settings > MCP."]
|
||||
}
|
||||
|
||||
nonisolated(unsafe) static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
nonisolated static let displayFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .short
|
||||
return f
|
||||
}()
|
||||
|
||||
nonisolated static func parseDate(_ string: String) -> Date? {
|
||||
if let date = isoFormatter.date(from: string) { return date }
|
||||
|
||||
let isoNoTimezone = ISO8601DateFormatter()
|
||||
isoNoTimezone.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = isoNoTimezone.date(from: string) { return date }
|
||||
|
||||
let localDateTime = DateFormatter()
|
||||
localDateTime.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
if let date = localDateTime.date(from: string) { return date }
|
||||
|
||||
let dateOnly = DateFormatter()
|
||||
dateOnly.dateFormat = "yyyy-MM-dd"
|
||||
if let date = dateOnly.date(from: string) { return date }
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//
|
||||
// LocationMapsService.swift
|
||||
// oAI
|
||||
//
|
||||
// Read-only Location and Maps integration via CoreLocation and MapKit
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
//
|
||||
// This file is part of oAI.
|
||||
//
|
||||
// oAI is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
||||
// Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public
|
||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import MapKit
|
||||
import os
|
||||
|
||||
@Observable
|
||||
class LocationMapsService: NSObject, CLLocationManagerDelegate {
|
||||
static let shared = LocationMapsService()
|
||||
|
||||
private let locationManager = CLLocationManager()
|
||||
|
||||
private var authContinuation: CheckedContinuation<Bool, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation?, Never>?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
locationManager.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
var authStatus: CLAuthorizationStatus {
|
||||
locationManager.authorizationStatus
|
||||
}
|
||||
|
||||
var authorized: Bool {
|
||||
authStatus == .authorizedAlways || authStatus == .authorized
|
||||
}
|
||||
|
||||
var accessState: PersonalDataAccessState {
|
||||
let status = authStatus
|
||||
Log.mcp.debug("LocationMapsService.accessState -> status=\(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
switch status {
|
||||
case .authorizedAlways, .authorized: return .granted
|
||||
case .notDetermined: return .notDetermined
|
||||
default: return .denied
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requestAccess() async -> Bool {
|
||||
let before = locationManager.authorizationStatus
|
||||
Log.mcp.info("LocationMapsService.requestAccess: status before = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||
if before != .notDetermined {
|
||||
Log.mcp.info("LocationMapsService.requestAccess: skipping OS prompt (not notDetermined)")
|
||||
return authorized
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
self.authContinuation = continuation
|
||||
locationManager.requestWhenInUseAuthorization()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Log.mcp.info("LocationMapsService: authorization changed -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
||||
authContinuation?.resume(returning: authorized)
|
||||
authContinuation = nil
|
||||
}
|
||||
|
||||
nonisolated static func describe(_ status: CLAuthorizationStatus) -> String {
|
||||
switch status {
|
||||
case .notDetermined: return "notDetermined"
|
||||
case .restricted: return "restricted"
|
||||
case .denied: return "denied"
|
||||
case .authorizedAlways: return "authorizedAlways"
|
||||
case .authorized: return "authorized"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
locationContinuation?.resume(returning: locations.last)
|
||||
locationContinuation = nil
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
Log.mcp.error("Location request failed: \(error.localizedDescription)")
|
||||
locationContinuation?.resume(returning: nil)
|
||||
locationContinuation = nil
|
||||
}
|
||||
|
||||
private func currentLocation() async -> CLLocation? {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.locationContinuation = continuation
|
||||
locationManager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool Schemas
|
||||
|
||||
func getToolSchemas() -> [Tool] {
|
||||
[
|
||||
makeTool(
|
||||
name: "location_get_current",
|
||||
description: "Get the device's current location, including a human-readable address.",
|
||||
properties: [:],
|
||||
required: []
|
||||
),
|
||||
makeTool(
|
||||
name: "maps_search_places",
|
||||
description: "Search for places (businesses, landmarks, addresses) by name or category.",
|
||||
properties: [
|
||||
"query": prop("string", "What to search for, e.g. 'coffee shops' or 'Eiffel Tower'"),
|
||||
"near": prop("string", "Optional: an address or 'latitude,longitude' to search near")
|
||||
],
|
||||
required: ["query"]
|
||||
),
|
||||
makeTool(
|
||||
name: "maps_geocode",
|
||||
description: "Convert an address into geographic coordinates and a formatted address.",
|
||||
properties: [
|
||||
"address": prop("string", "The address to geocode")
|
||||
],
|
||||
required: ["address"]
|
||||
),
|
||||
makeTool(
|
||||
name: "maps_get_directions",
|
||||
description: "Get distance and estimated travel time between two locations.",
|
||||
properties: [
|
||||
"origin": prop("string", "Starting address or 'latitude,longitude'"),
|
||||
"destination": prop("string", "Destination address or 'latitude,longitude'"),
|
||||
"transport_type": prop("string", "Mode of transport", enumValues: ["driving", "walking", "transit"])
|
||||
],
|
||||
required: ["origin", "destination"]
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Tool Execution
|
||||
|
||||
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||
Log.mcp.info("Executing LocationMaps tool: \(name)")
|
||||
let args = parseArgs(arguments)
|
||||
|
||||
switch name {
|
||||
case "location_get_current":
|
||||
guard authorized else { return ["error": "Location permission not granted. Grant access in Settings > MCP."] }
|
||||
return await getCurrentLocation()
|
||||
|
||||
case "maps_search_places":
|
||||
guard let query = args["query"] as? String, !query.isEmpty else {
|
||||
return ["error": "Missing required parameter: query"]
|
||||
}
|
||||
let near = args["near"] as? String
|
||||
return await searchPlaces(query: query, near: near)
|
||||
|
||||
case "maps_geocode":
|
||||
guard let address = args["address"] as? String, !address.isEmpty else {
|
||||
return ["error": "Missing required parameter: address"]
|
||||
}
|
||||
return await geocode(address: address)
|
||||
|
||||
case "maps_get_directions":
|
||||
guard let origin = args["origin"] as? String, let destination = args["destination"] as? String else {
|
||||
return ["error": "Missing required parameter: origin and/or destination"]
|
||||
}
|
||||
let transportType = args["transport_type"] as? String ?? "driving"
|
||||
return await getDirections(origin: origin, destination: destination, transportType: transportType)
|
||||
|
||||
default:
|
||||
return ["error": "Unknown LocationMaps tool: \(name)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Implementations
|
||||
|
||||
private func getCurrentLocation() async -> [String: Any] {
|
||||
guard let location = await currentLocation() else {
|
||||
return ["error": "Could not determine current location"]
|
||||
}
|
||||
var result: [String: Any] = [
|
||||
"latitude": location.coordinate.latitude,
|
||||
"longitude": location.coordinate.longitude
|
||||
]
|
||||
if let mapItem = await reverseGeocode(location), let address = addressString(for: mapItem) {
|
||||
result["address"] = address
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func searchPlaces(query: String, near: String?) async -> [String: Any] {
|
||||
let request = MKLocalSearch.Request()
|
||||
request.naturalLanguageQuery = query
|
||||
if let near, let coordinate = await coordinate(for: near) {
|
||||
request.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 20_000, longitudinalMeters: 20_000)
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await MKLocalSearch(request: request).start()
|
||||
let places = response.mapItems.prefix(15).map { item -> [String: Any] in
|
||||
var place: [String: Any] = ["name": item.name ?? "Unknown"]
|
||||
place["latitude"] = item.location.coordinate.latitude
|
||||
place["longitude"] = item.location.coordinate.longitude
|
||||
if let address = addressString(for: item) {
|
||||
place["address"] = address
|
||||
}
|
||||
if let phone = item.phoneNumber { place["phone"] = phone }
|
||||
return place
|
||||
}
|
||||
return ["count": places.count, "places": Array(places)]
|
||||
} catch {
|
||||
return ["error": "Search failed: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
private func geocode(address: String) async -> [String: Any] {
|
||||
guard let request = MKGeocodingRequest(addressString: address) else {
|
||||
return ["error": "Invalid address: \(address)"]
|
||||
}
|
||||
do {
|
||||
guard let mapItem = try await request.mapItems.first else {
|
||||
return ["error": "No results found for address: \(address)"]
|
||||
}
|
||||
var result: [String: Any] = [
|
||||
"latitude": mapItem.location.coordinate.latitude,
|
||||
"longitude": mapItem.location.coordinate.longitude
|
||||
]
|
||||
if let formatted = addressString(for: mapItem) {
|
||||
result["formatted_address"] = formatted
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return ["error": "Geocoding failed: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
private func getDirections(origin: String, destination: String, transportType: String) async -> [String: Any] {
|
||||
guard let originCoordinate = await coordinate(for: origin) else {
|
||||
return ["error": "Could not resolve origin: \(origin)"]
|
||||
}
|
||||
guard let destinationCoordinate = await coordinate(for: destination) else {
|
||||
return ["error": "Could not resolve destination: \(destination)"]
|
||||
}
|
||||
|
||||
let request = MKDirections.Request()
|
||||
request.source = MKMapItem(location: CLLocation(latitude: originCoordinate.latitude, longitude: originCoordinate.longitude), address: nil)
|
||||
request.destination = MKMapItem(location: CLLocation(latitude: destinationCoordinate.latitude, longitude: destinationCoordinate.longitude), address: nil)
|
||||
switch transportType {
|
||||
case "walking": request.transportType = .walking
|
||||
case "transit": request.transportType = .transit
|
||||
default: request.transportType = .automobile
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await MKDirections(request: request).calculate()
|
||||
guard let route = response.routes.first else {
|
||||
return ["error": "No route found"]
|
||||
}
|
||||
let distanceFormatter = MKDistanceFormatter()
|
||||
let durationFormatter = DateComponentsFormatter()
|
||||
durationFormatter.allowedUnits = [.hour, .minute]
|
||||
durationFormatter.unitsStyle = .short
|
||||
|
||||
return [
|
||||
"distance_meters": route.distance,
|
||||
"distance_text": distanceFormatter.string(fromDistance: route.distance),
|
||||
"duration_seconds": route.expectedTravelTime,
|
||||
"duration_text": durationFormatter.string(from: route.expectedTravelTime) ?? "",
|
||||
"transport_type": transportType
|
||||
]
|
||||
} catch {
|
||||
return ["error": "Directions failed: \(error.localizedDescription)"]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func coordinate(for text: String) async -> CLLocationCoordinate2D? {
|
||||
let parts = text.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
if parts.count == 2, let lat = Double(parts[0]), let lon = Double(parts[1]) {
|
||||
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||||
}
|
||||
guard let request = MKGeocodingRequest(addressString: text) else { return nil }
|
||||
if let mapItems = try? await request.mapItems, let mapItem = mapItems.first {
|
||||
return mapItem.location.coordinate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func reverseGeocode(_ location: CLLocation) async -> MKMapItem? {
|
||||
guard let request = MKReverseGeocodingRequest(location: location) else { return nil }
|
||||
let mapItems = try? await request.mapItems
|
||||
return mapItems?.first
|
||||
}
|
||||
|
||||
private func addressString(for mapItem: MKMapItem) -> String? {
|
||||
mapItem.address?.fullAddress
|
||||
?? mapItem.addressRepresentations?.fullAddress(includingRegion: true, singleLine: true)
|
||||
}
|
||||
|
||||
private func parseArgs(_ arguments: String) -> [String: Any] {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return [:]
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
||||
Tool(
|
||||
type: "function",
|
||||
function: Tool.Function(
|
||||
name: name,
|
||||
description: description,
|
||||
parameters: Tool.Function.Parameters(
|
||||
type: "object",
|
||||
properties: properties,
|
||||
required: required
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func prop(_ type: String, _ description: String, enumValues: [String]? = nil) -> Tool.Function.Parameters.Property {
|
||||
Tool.Function.Parameters.Property(type: type, description: description, enum: enumValues)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,9 @@ class MCPService {
|
||||
|
||||
private let anytypeService = AnytypeMCPService.shared
|
||||
private let paperlessService = PaperlessService.shared
|
||||
private let eventKitService = EventKitService.shared
|
||||
private let contactsService = ContactsService.shared
|
||||
private let locationMapsService = LocationMapsService.shared
|
||||
|
||||
// MARK: - Bash Approval State
|
||||
|
||||
@@ -124,6 +127,19 @@ class MCPService {
|
||||
private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
|
||||
private(set) var bashSessionApproved: Bool = false
|
||||
|
||||
// MARK: - Personal Data (Calendar/Reminders) Approval State
|
||||
|
||||
struct PendingPersonalDataAction: Identifiable {
|
||||
let id = UUID()
|
||||
let toolName: String
|
||||
let argumentsJSON: String
|
||||
let summary: String
|
||||
}
|
||||
|
||||
private(set) var pendingPersonalDataAction: PendingPersonalDataAction? = nil
|
||||
private var pendingPersonalDataContinuation: CheckedContinuation<[String: Any], Never>? = nil
|
||||
private(set) var personalDataSessionApproved: Bool = false
|
||||
|
||||
// MARK: - Tool Schema Generation
|
||||
|
||||
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
|
||||
@@ -232,6 +248,24 @@ class MCPService {
|
||||
tools.append(contentsOf: paperlessService.getToolSchemas())
|
||||
}
|
||||
|
||||
// Add Calendar/Reminders tools if enabled
|
||||
if settings.calendarEnabled || settings.remindersEnabled {
|
||||
tools.append(contentsOf: eventKitService.getToolSchemas(
|
||||
calendarEnabled: settings.calendarEnabled,
|
||||
remindersEnabled: settings.remindersEnabled
|
||||
))
|
||||
}
|
||||
|
||||
// Add Contacts tools if enabled
|
||||
if settings.contactsEnabled {
|
||||
tools.append(contentsOf: contactsService.getToolSchemas())
|
||||
}
|
||||
|
||||
// Add Location/Maps tools if enabled
|
||||
if settings.locationMapsEnabled {
|
||||
tools.append(contentsOf: locationMapsService.getToolSchemas())
|
||||
}
|
||||
|
||||
// Add bash_execute tool when bash is enabled
|
||||
if settings.bashEnabled {
|
||||
let workDir = settings.bashWorkingDirectory
|
||||
@@ -260,6 +294,22 @@ class MCPService {
|
||||
))
|
||||
}
|
||||
|
||||
// Add spawn_research_agents when Research Agents are enabled
|
||||
if settings.agentsEnabled {
|
||||
tools.append(makeTool(
|
||||
name: "spawn_research_agents",
|
||||
description: "Spawn multiple READ-ONLY research sub-agents that investigate independent questions IN PARALLEL. Each sub-agent gets its own read_file/list_directory/search_files/web_search loop — no write, no bash, no nesting (sub-agents cannot call this tool). ONLY use this when you have 2 or more genuinely independent research questions that benefit from running at the same time (e.g. comparing several unrelated files, topics, or sources). Do NOT use this for a single lookup, a sequential task, or anything answerable with one direct tool call — call read_file/search_files/web_search yourself instead. Each sub-agent is its own full chain of model calls and meaningfully increases cost and latency; using this for trivial tasks is wasteful. Prefer the smallest number of tasks that actually need to run in parallel.",
|
||||
properties: [
|
||||
"tasks": Tool.Function.Parameters.Property(
|
||||
type: "array",
|
||||
description: "List of independent, self-contained research questions — one per sub-agent. Keep this list as short as the task genuinely requires.",
|
||||
items: .init(type: "string")
|
||||
)
|
||||
],
|
||||
required: ["tasks"]
|
||||
))
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -284,7 +334,10 @@ class MCPService {
|
||||
|
||||
// MARK: - Tool Execution
|
||||
|
||||
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||
/// `agentProvider`/`agentModelId` are only needed for `spawn_research_agents`, which drives
|
||||
/// its own model calls — every other tool ignores them. Passed in by the caller's active
|
||||
/// chat session rather than stored on MCPService, since this service has no provider state.
|
||||
func executeTool(name: String, arguments: String, agentProvider: AIProvider? = nil, agentModelId: String? = nil) async -> [String: Any] {
|
||||
Log.mcp.info("Executing tool: \(name)")
|
||||
guard let argData = arguments.data(using: .utf8),
|
||||
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
|
||||
@@ -399,6 +452,25 @@ class MCPService {
|
||||
let mapped = results.map { ["title": $0.title, "url": $0.url, "snippet": $0.snippet] }
|
||||
return ["results": mapped]
|
||||
|
||||
case "spawn_research_agents":
|
||||
guard settings.agentsEnabled else {
|
||||
return ["error": "Research agents are disabled. Enable 'Research Agents' in Settings > MCP."]
|
||||
}
|
||||
guard let agentProvider, let agentModelId else {
|
||||
return ["error": "Internal error: missing model context for spawn_research_agents"]
|
||||
}
|
||||
guard let tasks = args["tasks"] as? [String], !tasks.isEmpty else {
|
||||
return ["error": "Missing required parameter: tasks (non-empty array of strings)"]
|
||||
}
|
||||
return await runResearchAgents(tasks: tasks, provider: agentProvider, modelId: agentModelId)
|
||||
|
||||
case "calendar_create_event", "reminders_create", "reminders_complete":
|
||||
guard settings.calendarEnabled || settings.remindersEnabled else {
|
||||
return ["error": "Calendar/Reminders access is disabled. Enable it in Settings > MCP."]
|
||||
}
|
||||
let summary = eventKitService.approvalSummary(forTool: name, arguments: arguments)
|
||||
return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)
|
||||
|
||||
default:
|
||||
// Route anytype_* tools to AnytypeMCPService
|
||||
if name.hasPrefix("anytype_") {
|
||||
@@ -408,6 +480,18 @@ class MCPService {
|
||||
if name.hasPrefix("paperless_") {
|
||||
return await paperlessService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
// Route calendar_*/reminders_* read tools to EventKitService
|
||||
if name.hasPrefix("calendar_") || name.hasPrefix("reminders_") {
|
||||
return await eventKitService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
// Route contacts_* tools to ContactsService
|
||||
if name.hasPrefix("contacts_") {
|
||||
return await contactsService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
// Route location_*/maps_* tools to LocationMapsService
|
||||
if name.hasPrefix("location_") || name.hasPrefix("maps_") {
|
||||
return await locationMapsService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
return ["error": "Unknown tool: \(name)"]
|
||||
}
|
||||
}
|
||||
@@ -754,6 +838,11 @@ class MCPService {
|
||||
if bashSessionApproved {
|
||||
return await runBashCommand(command, workingDirectory: workingDirectory)
|
||||
}
|
||||
// 2nd Brain calls its helper script via bash_execute — let the user mark that
|
||||
// specific traffic as always-trusted instead of approving it every time.
|
||||
if isTrustedSecondBrainCommand(command) {
|
||||
return await runBashCommand(command, workingDirectory: workingDirectory)
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
|
||||
@@ -786,6 +875,190 @@ class MCPService {
|
||||
bashSessionApproved = false
|
||||
}
|
||||
|
||||
private func isTrustedSecondBrainCommand(_ command: String) -> Bool {
|
||||
guard settings.trustSecondBrainSkill, command.contains(".brain_helper.py") else { return false }
|
||||
return settings.agentSkills.contains { $0.isActive && $0.isSecondBrainSkill }
|
||||
}
|
||||
|
||||
// MARK: - Research Agents (read-only, parallel)
|
||||
|
||||
/// Runs `tasks.count` sub-agents (capped) with bounded concurrency, each in its own
|
||||
/// read-only tool loop, and returns their findings concatenated for the orchestrator.
|
||||
private func runResearchAgents(tasks: [String], provider: AIProvider, modelId: String) async -> [String: Any] {
|
||||
let maxConcurrent = max(1, min(5, settings.maxConcurrentAgents))
|
||||
// Hard cap on total sub-agents regardless of concurrency setting, so a model
|
||||
// requesting an unreasonably long task list can't run away with cost.
|
||||
let cappedTasks = Array(tasks.prefix(8))
|
||||
|
||||
var results: [(Int, String)] = []
|
||||
await withTaskGroup(of: (Int, String).self) { group in
|
||||
var nextIndex = 0
|
||||
func launchNext() {
|
||||
guard nextIndex < cappedTasks.count else { return }
|
||||
let idx = nextIndex
|
||||
let task = cappedTasks[idx]
|
||||
nextIndex += 1
|
||||
group.addTask {
|
||||
let answer = await self.runSingleResearchAgent(task: task, provider: provider, modelId: modelId)
|
||||
return (idx, answer)
|
||||
}
|
||||
}
|
||||
for _ in 0..<min(maxConcurrent, cappedTasks.count) { launchNext() }
|
||||
for await result in group {
|
||||
results.append(result)
|
||||
launchNext()
|
||||
}
|
||||
}
|
||||
|
||||
let sorted = results.sorted { $0.0 < $1.0 }
|
||||
let formatted = sorted.map { idx, answer in
|
||||
"### Agent \(idx + 1): \(cappedTasks[idx])\n\(answer)"
|
||||
}.joined(separator: "\n\n")
|
||||
|
||||
var response: [String: Any] = ["agent_count": sorted.count, "results": formatted]
|
||||
if tasks.count > cappedTasks.count {
|
||||
response["note"] = "Only the first \(cappedTasks.count) of \(tasks.count) requested tasks were run (per-call cap)."
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/// A single sub-agent's self-contained tool loop. Read-only tools only; cannot write,
|
||||
/// run bash, or spawn further sub-agents (spawn_research_agents is not in its tool list).
|
||||
private func runSingleResearchAgent(task: String, provider: AIProvider, modelId: String) async -> String {
|
||||
let readOnlyTools: [Tool] = [
|
||||
makeTool(
|
||||
name: "read_file",
|
||||
description: "Read the contents of a file. Maximum file size is 10MB.",
|
||||
properties: ["file_path": prop("string", "The absolute path to the file to read")],
|
||||
required: ["file_path"]
|
||||
),
|
||||
makeTool(
|
||||
name: "list_directory",
|
||||
description: "List the contents of a directory. Skips hidden/build directories like .git, node_modules, etc.",
|
||||
properties: [
|
||||
"dir_path": prop("string", "The absolute path to the directory to list"),
|
||||
"recursive": prop("boolean", "Whether to list recursively (default: false)")
|
||||
],
|
||||
required: ["dir_path"]
|
||||
),
|
||||
makeTool(
|
||||
name: "search_files",
|
||||
description: "Search for files by name pattern or content.",
|
||||
properties: [
|
||||
"pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
|
||||
"search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
|
||||
"content_search": prop("string", "Optional text to search for inside files")
|
||||
],
|
||||
required: ["pattern"]
|
||||
),
|
||||
makeTool(
|
||||
name: "web_search",
|
||||
description: "Search the web for current information using DuckDuckGo.",
|
||||
properties: ["query": prop("string", "The search query to look up")],
|
||||
required: ["query"]
|
||||
)
|
||||
]
|
||||
let allowedNames = Set(readOnlyTools.map { $0.function.name })
|
||||
|
||||
var apiMessages: [[String: Any]] = [
|
||||
["role": "system", "content": "You are a read-only research sub-agent. Investigate the assigned task using the available tools and report concise findings as plain text. You cannot write, delete, or execute anything, and cannot spawn further sub-agents. Once you have enough information, respond with your final answer and stop calling tools."],
|
||||
["role": "user", "content": task]
|
||||
]
|
||||
|
||||
let maxIterations = 6
|
||||
for iteration in 0..<maxIterations {
|
||||
if Task.isCancelled { return "(cancelled)" }
|
||||
|
||||
guard let response = try? await provider.chatWithToolMessages(
|
||||
model: modelId, messages: apiMessages, tools: readOnlyTools, maxTokens: nil, temperature: nil
|
||||
) else {
|
||||
return "(error: sub-agent request failed)"
|
||||
}
|
||||
|
||||
let toolCalls = response.toolCalls ?? []
|
||||
guard !toolCalls.isEmpty else {
|
||||
return response.content.isEmpty ? "(no findings)" : response.content
|
||||
}
|
||||
|
||||
var assistantMsg: [String: Any] = ["role": "assistant"]
|
||||
if !response.content.isEmpty { assistantMsg["content"] = response.content }
|
||||
assistantMsg["tool_calls"] = toolCalls.map { tc in
|
||||
["id": tc.id, "type": tc.type, "function": ["name": tc.functionName, "arguments": tc.arguments]]
|
||||
}
|
||||
apiMessages.append(assistantMsg)
|
||||
|
||||
for tc in toolCalls {
|
||||
let resultJSON: String
|
||||
if allowedNames.contains(tc.functionName) {
|
||||
let result = await executeTool(name: tc.functionName, arguments: tc.arguments)
|
||||
resultJSON = serializeToolResult(result)
|
||||
} else {
|
||||
resultJSON = "{\"error\": \"Tool not available to research sub-agents\"}"
|
||||
}
|
||||
apiMessages.append([
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"name": tc.functionName,
|
||||
"content": resultJSON
|
||||
])
|
||||
}
|
||||
|
||||
if iteration == maxIterations - 1 {
|
||||
return "(research incomplete: sub-agent reached its iteration limit)"
|
||||
}
|
||||
}
|
||||
return "(no findings)"
|
||||
}
|
||||
|
||||
private func serializeToolResult(_ result: [String: Any], maxBytes: Int = 20_000) -> String {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: result),
|
||||
let str = String(data: data, encoding: .utf8) else {
|
||||
return "{\"error\": \"Failed to serialize result\"}"
|
||||
}
|
||||
guard str.utf8.count > maxBytes, let truncated = String(str.utf8.prefix(maxBytes)) else {
|
||||
return str
|
||||
}
|
||||
return truncated + "\n... (result truncated)"
|
||||
}
|
||||
|
||||
// MARK: - Personal Data (Calendar/Reminders) Approval
|
||||
|
||||
private func executePersonalDataAction(toolName: String, argumentsJSON: String, summary: String) async -> [String: Any] {
|
||||
guard settings.personalDataRequireApproval, !personalDataSessionApproved else {
|
||||
return await eventKitService.executeWriteTool(name: toolName, arguments: argumentsJSON)
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
self.pendingPersonalDataAction = PendingPersonalDataAction(toolName: toolName, argumentsJSON: argumentsJSON, summary: summary)
|
||||
self.pendingPersonalDataContinuation = continuation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func approvePendingPersonalDataAction(forSession: Bool = false) {
|
||||
guard let pending = pendingPersonalDataAction, let cont = pendingPersonalDataContinuation else { return }
|
||||
pendingPersonalDataAction = nil
|
||||
pendingPersonalDataContinuation = nil
|
||||
if forSession {
|
||||
personalDataSessionApproved = true
|
||||
}
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let result = await self.eventKitService.executeWriteTool(name: pending.toolName, arguments: pending.argumentsJSON)
|
||||
cont.resume(returning: result)
|
||||
}
|
||||
}
|
||||
|
||||
func denyPendingPersonalDataAction() {
|
||||
guard pendingPersonalDataAction != nil else { return }
|
||||
pendingPersonalDataAction = nil
|
||||
pendingPersonalDataContinuation?.resume(returning: ["error": "User denied this action"])
|
||||
pendingPersonalDataContinuation = nil
|
||||
}
|
||||
|
||||
func resetPersonalDataSessionApproval() {
|
||||
personalDataSessionApproved = false
|
||||
}
|
||||
|
||||
private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
|
||||
let timeoutSeconds = settings.bashTimeout
|
||||
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
@@ -27,6 +27,18 @@ import Foundation
|
||||
import os
|
||||
import Security
|
||||
|
||||
/// Kill switch for the Personal Data tools (Calendar/Reminders/Contacts/Location & Maps).
|
||||
/// Flip `isHiddenPendingAppleFix` back to `false` once Apple fixes the macOS 27 beta TCC bug
|
||||
/// (filed with Apple). Each flag hides the relevant UI and forces the `*Enabled` getter to
|
||||
/// return `false` regardless of the persisted DB value — no code deleted, just inert.
|
||||
enum PersonalDataTools {
|
||||
/// Hides the entire Personal Data section. Flip to `false` once all four services work.
|
||||
static let isHiddenPendingAppleFix = false
|
||||
/// Hides only the Contacts row. Contacts TCC still broken under hardened runtime on
|
||||
/// macOS 27 beta 2 while Calendar/Reminders/Location are fixed. Flip to `false` once fixed.
|
||||
static let isContactsHiddenPendingAppleFix = true
|
||||
}
|
||||
|
||||
@Observable
|
||||
class SettingsService {
|
||||
static let shared = SettingsService()
|
||||
@@ -577,6 +589,37 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Research Agents Settings
|
||||
|
||||
/// When true, the AI can call `spawn_research_agents` to run multiple read-only
|
||||
/// sub-agents in parallel. Each sub-agent is its own full chain of model calls, so
|
||||
/// this can noticeably increase cost — opt-in, off by default.
|
||||
var agentsEnabled: Bool {
|
||||
get { cache["agentsEnabled"] == "true" }
|
||||
set {
|
||||
cache["agentsEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "agentsEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var maxConcurrentAgents: Int {
|
||||
get { cache["maxConcurrentAgents"].flatMap(Int.init) ?? 3 }
|
||||
set {
|
||||
cache["maxConcurrentAgents"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "maxConcurrentAgents", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// When true (and an active "2nd Brain" Agent Skill is installed), bash commands that
|
||||
/// invoke the 2nd Brain helper script skip the approval dialog entirely.
|
||||
var trustSecondBrainSkill: Bool {
|
||||
get { cache["trustSecondBrainSkill"] == "true" }
|
||||
set {
|
||||
cache["trustSecondBrainSkill"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "trustSecondBrainSkill", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var bashWorkingDirectory: String {
|
||||
get { cache["bashWorkingDirectory"] ?? "~" }
|
||||
set {
|
||||
@@ -593,6 +636,48 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Personal Data Settings (Calendar/Reminders/Contacts/Location/Maps)
|
||||
|
||||
var calendarEnabled: Bool {
|
||||
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["calendarEnabled"] == "true" }
|
||||
set {
|
||||
cache["calendarEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "calendarEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var remindersEnabled: Bool {
|
||||
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["remindersEnabled"] == "true" }
|
||||
set {
|
||||
cache["remindersEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "remindersEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var contactsEnabled: Bool {
|
||||
get { !PersonalDataTools.isHiddenPendingAppleFix && !PersonalDataTools.isContactsHiddenPendingAppleFix && cache["contactsEnabled"] == "true" }
|
||||
set {
|
||||
cache["contactsEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "contactsEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var locationMapsEnabled: Bool {
|
||||
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["locationMapsEnabled"] == "true" }
|
||||
set {
|
||||
cache["locationMapsEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "locationMapsEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var personalDataRequireApproval: Bool {
|
||||
get { cache["personalDataRequireApproval"].map { $0 == "true" } ?? true }
|
||||
set {
|
||||
cache["personalDataRequireApproval"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "personalDataRequireApproval", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paperless-NGX Settings
|
||||
|
||||
var paperlessEnabled: Bool {
|
||||
|
||||
@@ -799,8 +799,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
let mcpActive = mcpEnabled || settings.mcpEnabled
|
||||
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
|
||||
let bashActive = settings.bashEnabled
|
||||
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
|
||||
let researchAgentsActive = settings.agentsEnabled
|
||||
let modelSupportTools = selectedModel?.capabilities.tools ?? false
|
||||
if modelSupportTools && (anytypeActive || bashActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||
generateAIResponseWithTools(provider: provider, modelId: modelId)
|
||||
return
|
||||
}
|
||||
@@ -1354,6 +1356,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
var didContinueAfterImages = false // Only inject temp-file continuation once
|
||||
var totalUsage: ChatResponse.Usage?
|
||||
var hitIterationLimit = false // Track if we exited due to hitting the limit
|
||||
var finishedWithEmptyContent = false // Model stopped calling tools but said nothing
|
||||
|
||||
for iteration in 0..<maxIterations {
|
||||
if Task.isCancelled {
|
||||
@@ -1406,6 +1409,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
continue
|
||||
}
|
||||
}
|
||||
if finalContent.isEmpty {
|
||||
// Some models (observed with Qwen via OpenRouter) stop calling tools
|
||||
// after a long tool-call chain but return no summarizing text at all.
|
||||
// Surface a placeholder and nudge a follow-up turn instead of a silent blank bubble.
|
||||
finishedWithEmptyContent = true
|
||||
finalContent = "[No response from the model — retrying]"
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1452,7 +1462,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
break
|
||||
}
|
||||
|
||||
let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments)
|
||||
let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments, agentProvider: provider, agentModelId: effectiveModelId)
|
||||
let resultJSON: String
|
||||
if let data = try? JSONSerialization.data(withJSONObject: result),
|
||||
let str = String(data: data, encoding: .utf8) {
|
||||
@@ -1537,8 +1547,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
isGenerating = false
|
||||
streamingTask = nil
|
||||
|
||||
// If we hit the iteration limit and weren't cancelled, start auto-continue
|
||||
if hitIterationLimit && !wasCancelled {
|
||||
// If we hit the iteration limit, or the model returned no text at all, nudge a follow-up turn
|
||||
if (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled {
|
||||
startAutoContinue()
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,16 @@ struct ChatView: View {
|
||||
onDeny: { MCPService.shared.denyPendingBashCommand() }
|
||||
)
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { MCPService.shared.pendingPersonalDataAction },
|
||||
set: { _ in }
|
||||
)) { pending in
|
||||
PersonalDataApprovalSheet(
|
||||
pending: pending,
|
||||
onApprove: { forSession in MCPService.shared.approvePendingPersonalDataAction(forSession: forSession) },
|
||||
onDeny: { MCPService.shared.denyPendingPersonalDataAction() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,9 @@ struct AgentSkillsView: View {
|
||||
onToggle: { settings.toggleAgentSkill(id: skill.id) },
|
||||
onEdit: { editContext = SkillEditContext(skill: skill) },
|
||||
onExport: { exportOne(skill) },
|
||||
onDelete: { settings.deleteAgentSkill(id: skill.id) }
|
||||
onDelete: { settings.deleteAgentSkill(id: skill.id) },
|
||||
isTrusted: settings.trustSecondBrainSkill,
|
||||
onToggleTrust: { settings.trustSecondBrainSkill.toggle() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -349,6 +351,8 @@ private struct AgentSkillRow: View {
|
||||
let onEdit: () -> Void
|
||||
let onExport: () -> Void
|
||||
let onDelete: () -> Void
|
||||
var isTrusted: Bool = false
|
||||
var onToggleTrust: (() -> Void)? = nil
|
||||
|
||||
private var fileCount: Int {
|
||||
AgentSkillFilesService.shared.listFiles(for: skill.id).count
|
||||
@@ -378,6 +382,14 @@ private struct AgentSkillRow: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// 2nd Brain: let the user mark bash calls to its helper script as always-trusted,
|
||||
// skipping the bash approval dialog. Only shown for this specific skill while active.
|
||||
if skill.isActive, skill.isSecondBrainSkill, let onToggleTrust {
|
||||
Toggle("Trust", isOn: Binding(get: { isTrusted }, set: { _ in onToggleTrust() }))
|
||||
.toggleStyle(.switch).controlSize(.small)
|
||||
.help("When on, bash commands that call the 2nd Brain helper script run without asking for approval each time.")
|
||||
}
|
||||
|
||||
// File count badge
|
||||
if fileCount > 0 {
|
||||
Label("^[\(fileCount) file](inflect: true)", systemImage: "doc")
|
||||
@@ -479,7 +491,9 @@ struct AgentSkillsTabContent: View {
|
||||
onToggle: { settings.toggleAgentSkill(id: skill.id) },
|
||||
onEdit: { editContext = SkillEditContext(skill: skill) },
|
||||
onExport: { exportOne(skill) },
|
||||
onDelete: { settings.deleteAgentSkill(id: skill.id) }
|
||||
onDelete: { settings.deleteAgentSkill(id: skill.id) },
|
||||
isTrusted: settings.trustSecondBrainSkill,
|
||||
onToggleTrust: { settings.trustSecondBrainSkill.toggle() }
|
||||
)
|
||||
if idx < settings.agentSkills.count - 1 { Divider() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// PersonalDataApprovalSheet.swift
|
||||
// oAI
|
||||
//
|
||||
// Approval UI for AI-requested Calendar/Reminders write actions
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
//
|
||||
// This file is part of oAI.
|
||||
//
|
||||
// oAI is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
||||
// Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public
|
||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PersonalDataApprovalSheet: View {
|
||||
let pending: MCPService.PendingPersonalDataAction
|
||||
let onApprove: (_ forSession: Bool) -> Void
|
||||
let onDeny: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Header
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "calendar.badge.exclamationmark")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Allow This Action?")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
Text("The AI wants to make a change to your calendar or reminders")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Action description
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("ACTION")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(pending.summary)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// Buttons
|
||||
HStack(spacing: 8) {
|
||||
Button("Deny") {
|
||||
onDeny()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Allow Once") {
|
||||
onApprove(false)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.orange)
|
||||
|
||||
Button("Allow for Session") {
|
||||
onApprove(true)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.orange)
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 480)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ struct SettingsView: View {
|
||||
// Default model picker state
|
||||
@State private var showDefaultModelPicker = false
|
||||
|
||||
// Personal Data state (Calendar/Reminders/Contacts/Location/Maps)
|
||||
@State private var calendarAccessState = EventKitService.shared.calendarAccessState
|
||||
@State private var remindersAccessState = EventKitService.shared.reminderAccessState
|
||||
@State private var contactsAccessState = ContactsService.shared.accessState
|
||||
@State private var locationAccessState = LocationMapsService.shared.accessState
|
||||
|
||||
// Paperless-NGX state
|
||||
@State private var paperlessURL = ""
|
||||
@State private var paperlessToken = ""
|
||||
@@ -752,6 +758,163 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Research Agents
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.indigo)
|
||||
Text("Research Agents")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
}
|
||||
Text("Let the AI spawn read-only research sub-agents to investigate multiple things in parallel (read files, list/search directories, search the web — no writing, no bash). Intended for genuinely independent research tasks, not everyday questions.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Status")
|
||||
formSection {
|
||||
row("Enable Research Agents") {
|
||||
Toggle("", isOn: $settingsService.agentsEnabled)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.top, 1)
|
||||
Text("Cost warning: each sub-agent runs its own full chain of model calls. A single request that spawns several agents can cost several times a normal reply. The AI is instructed to only use this for genuinely parallel research, but model behavior can vary — leave this off unless you want that tradeoff.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
if settingsService.agentsEnabled {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Settings")
|
||||
formSection {
|
||||
row("Max Concurrent Agents") {
|
||||
HStack(spacing: 8) {
|
||||
Stepper("", value: $settingsService.maxConcurrentAgents, in: 1...5)
|
||||
.labelsHidden()
|
||||
Text("\(settingsService.maxConcurrentAgents)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 24, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Personal Data
|
||||
// isHiddenPendingAppleFix hides the entire section (macOS 27 beta TCC bug).
|
||||
// isContactsHiddenPendingAppleFix hides just the Contacts row (still broken in beta 2
|
||||
// under hardened runtime while Calendar/Reminders/Location are fixed). Flip each flag
|
||||
// to false once Apple ships a fix.
|
||||
if !PersonalDataTools.isHiddenPendingAppleFix {
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "person.crop.circle.badge.checkmark")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.teal)
|
||||
Text("Personal Data")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
if PersonalDataTools.isContactsHiddenPendingAppleFix {
|
||||
Text("β")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.help("Beta Feature. May change.")
|
||||
}
|
||||
}
|
||||
Text("Let the AI access your Calendar, Reminders, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses standard macOS permission prompts. This functionality is in beta and may change.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
.onAppear {
|
||||
// Permission status can change outside the app (System Settings, or a prior
|
||||
// request elsewhere) — re-read it fresh every time this tab appears rather than
|
||||
// trusting the one-time @State initializer.
|
||||
calendarAccessState = EventKitService.shared.calendarAccessState
|
||||
remindersAccessState = EventKitService.shared.reminderAccessState
|
||||
contactsAccessState = ContactsService.shared.accessState
|
||||
locationAccessState = LocationMapsService.shared.accessState
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Services")
|
||||
formSection {
|
||||
personalDataRow(
|
||||
title: "Calendar",
|
||||
isEnabled: $settingsService.calendarEnabled,
|
||||
state: calendarAccessState,
|
||||
systemSettingsAnchor: "Privacy_Calendars",
|
||||
requestAccess: { calendarAccessState = await EventKitService.shared.requestCalendarAccess() ? .granted : EventKitService.shared.calendarAccessState }
|
||||
)
|
||||
rowDivider()
|
||||
personalDataRow(
|
||||
title: "Reminders",
|
||||
isEnabled: $settingsService.remindersEnabled,
|
||||
state: remindersAccessState,
|
||||
systemSettingsAnchor: "Privacy_Reminders",
|
||||
requestAccess: { remindersAccessState = await EventKitService.shared.requestReminderAccess() ? .granted : EventKitService.shared.reminderAccessState }
|
||||
)
|
||||
rowDivider()
|
||||
if !PersonalDataTools.isContactsHiddenPendingAppleFix {
|
||||
personalDataRow(
|
||||
title: "Contacts",
|
||||
isEnabled: $settingsService.contactsEnabled,
|
||||
state: contactsAccessState,
|
||||
systemSettingsAnchor: "Privacy_Contacts",
|
||||
requestAccess: { contactsAccessState = await ContactsService.shared.requestAccess() ? .granted : ContactsService.shared.accessState }
|
||||
)
|
||||
rowDivider()
|
||||
}
|
||||
personalDataRow(
|
||||
title: "Location & Maps",
|
||||
isEnabled: $settingsService.locationMapsEnabled,
|
||||
state: locationAccessState,
|
||||
systemSettingsAnchor: "Privacy_LocationServices",
|
||||
requestAccess: { locationAccessState = await LocationMapsService.shared.requestAccess() ? .granted : LocationMapsService.shared.accessState }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if settingsService.calendarEnabled || settingsService.remindersEnabled {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Write Actions")
|
||||
formSection {
|
||||
row("Require Approval for Changes") {
|
||||
Toggle("", isOn: $settingsService.personalDataRequireApproval)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("Creating calendar events or reminders, and completing reminders, will ask for your approval first.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Appearance Tab
|
||||
@@ -2446,6 +2609,57 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func personalDataRow(title: LocalizedStringKey, isEnabled: Binding<Bool>, state: PersonalDataAccessState, systemSettingsAnchor: String, requestAccess: @escaping () async -> Void) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Text(title).font(.system(size: 14))
|
||||
Spacer()
|
||||
Toggle("", isOn: isEnabled)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
if isEnabled.wrappedValue {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: state == .granted ? "checkmark.circle.fill" : (state == .denied ? "exclamationmark.circle.fill" : "circle"))
|
||||
.foregroundStyle(state == .granted ? .green : (state == .denied ? .orange : .secondary))
|
||||
.font(.system(size: 12))
|
||||
Text(statusText(for: state))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if state == .notDetermined {
|
||||
Button("Request Access") {
|
||||
Task { await requestAccess() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
} else if state == .denied {
|
||||
Button("Open System Settings") {
|
||||
openPrivacySettings(anchor: systemSettingsAnchor)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func statusText(for state: PersonalDataAccessState) -> LocalizedStringKey {
|
||||
switch state {
|
||||
case .granted: return "Access granted"
|
||||
case .denied: return "Access denied — enable in System Settings"
|
||||
case .notDetermined: return "Access not granted"
|
||||
}
|
||||
}
|
||||
|
||||
private func openPrivacySettings(anchor: String) {
|
||||
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(anchor)") else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
private func abbreviatePath(_ path: String) -> String {
|
||||
let home = NSHomeDirectory()
|
||||
if path.hasPrefix(home) {
|
||||
|
||||
@@ -36,6 +36,54 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSCalendarsFullAccessUsageDescription" : {
|
||||
"comment" : "Privacy - Calendars Full Access Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSContactsUsageDescription" : {
|
||||
"comment" : "Privacy - Contacts Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSLocationWhenInUseUsageDescription" : {
|
||||
"comment" : "Privacy - Location When In Use Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSRemindersFullAccessUsageDescription" : {
|
||||
"comment" : "Privacy - Reminders Full Access Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
|
||||
@@ -8,5 +8,13 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<false/>
|
||||
<key>com.apple.security.personal-information.calendars</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.reminders</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.contacts</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Reference in New Issue
Block a user