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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user