Add personal data tools, 2nd Brain trust toggle, and research agents
Personal Data Tools: native Calendar, Reminders, Contacts (hidden pending Apple TCC fix in beta), and Location & Maps access via EventKit, Contacts framework, and MapKit. Write actions (create event/reminder, complete reminder) gate through an approval sheet. Four hardened-runtime entitlements added to oAI.entitlements; Info.plist usage strings added for all services. Personal Data section shows a β badge while Contacts is hidden. 2nd Brain always-trust: inline toggle on the Agent Skills row for the skill named "2nd Brain" skips the bash approval dialog when the command contains .brain_helper.py, gated by three runtime checks in MCPService. Research agents: spawn_research_agents tool runs up to 5 concurrent read-only sub-agents (read_file, list_directory, search_files, web_search — no write, no bash, no nesting). Bounded by maxConcurrentAgents setting (default 3) and a hard ceiling of 8 tasks. Added items field to Tool.Function.Parameters.Property for JSON Schema array support; wired into AnthropicProvider.convertParametersToDict. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user