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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user