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