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:
2026-06-29 14:04:47 +02:00
parent 454cef4193
commit 40c5f25517
17 changed files with 11041 additions and 8443 deletions
+214
View File
@@ -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) {