5 Commits

Author SHA1 Message Date
rune f2949cea3b Add OpenRouter dedicated images API support
Fetches /api/v1/images/models in parallel with /models and merges results
into the model picker. Image-only models (e.g. Sourceful, Seedream, Flux
via this endpoint) were previously invisible since they don't appear in the
standard /models endpoint.

Models from the images API get usesImagesAPI=true and route through a new
generateImageAPIResponse() path in ChatViewModel that POSTs to /api/v1/images
with {model, prompt} instead of the chat completions endpoint. The response's
b64_json data is decoded and displayed via the existing GeneratedImagesView.

Cost is taken directly from the usage.cost field in the images API response
(USD per image) via a new rawCostUSD field on ChatResponse.Usage, bypassing
the token-based calculateCost() path used for chat models.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:34:56 +02:00
rune 40c5f25517 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>
2026-06-29 14:04:47 +02:00
rune 454cef4193 Update AppLogo imageset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:04:27 +02:00
rune 66c9054bd5 Update README with i18n disclosure and newly added features
Document Jarvis integration, Combine Conversations, model category
filter, sidebar navigation, prompt caching, and the 2nd Brain trust
toggle. Clarify that localization is AI/machine-translated rather than
reviewed by native speakers. Normalize all em-dashes to plain hyphens.
2026-06-22 11:14:34 +02:00
rune 20121981a0 Add French localization and catch up nb/sv/da/de translations
French (fr) added as a 5th supported language; full catalog translated.
Also caught up nb/sv/da/de for ~300 strings added since the last
localization pass (Jarvis, Anytype, model categories, reasoning effort,
Combine Conversations) plus Button/Toggle/Menu/CommandMenu titles and
custom sectionHeader/row helpers in Settings that were never extracted
by prior tooling, leaving Settings and the View menu English-only
regardless of locale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:17:36 +02:00
22 changed files with 13931 additions and 94 deletions
+34 -20
View File
@@ -8,15 +8,16 @@ A powerful native macOS AI chat application with support for multiple providers,
### 🤖 Multi-Provider Support
- **OpenAI** - GPT models with native API support
- **Anthropic** - All Claude models
- **Anthropic** - All Claude models; prompt caching support (direct API and via OpenRouter) reduces cost on repeated system prompts/context
- **OpenRouter** - Access to 300+ AI models from multiple providers
- **Ollama** - Local model inference for privacy
### 💬 Core Chat Capabilities
- **Streaming Responses** - Real-time token streaming for faster interactions
- **Conversation Management** - Save, load, export, and search conversations
- **Combine Conversations** - Merge 2+ saved conversations, either by chronological concatenation or AI-assisted synthesis
- **File Attachments** - Support for text files, images, and PDFs
- **Image Generation** - Create images with supported models (DALL-E, Flux, etc.) renders inline in chat
- **Image Generation** - Create images with supported models (DALL-E, Flux, etc.) - renders inline in chat
- **Reasoning / Thinking Tokens** - Stream live reasoning from thinking-capable models (DeepSeek R1, Claude 3.7+, o1/o3, Qwen); configurable effort level (High/Medium/Low/Minimal); collapsible block auto-expands while thinking and collapses when the answer arrives
- **Online Mode** - DuckDuckGo and Google web search integration
- **Session Statistics** - Track token usage, costs, and response times
@@ -51,17 +52,25 @@ Seamless conversation backup and sync across devices:
### ⚡ Shortcuts & Agent Skills
- **Shortcuts** - Personal slash commands that expand to prompt templates; optional `{{input}}` placeholder for inline input
- **Agent Skills (SKILL.md)** - Markdown instruction files injected into the system prompt; compatible with skill0.io, skillsmp.com, and other SKILL.md marketplaces; import as `.md` or `.zip` bundle with attached data files
- **Agent Skills (SKILL.md)** - Markdown instruction files injected into the system prompt; compatible with skill0.io, skillsmp.com, and other SKILL.md marketplaces; import as `.md` or `.zip` bundle with attached data files; a skill named exactly "2nd Brain" can be marked always-trusted, skipping the bash approval prompt for its helper-script calls
### 📚 Anytype Integration
Connect oAI to your local [Anytype](https://anytype.io) knowledge base:
- **Search** find objects by keyword across all spaces or within a specific one
- **Read** open any object and read its full markdown content
- **Append** add content to the end of an existing object without touching existing text or internal links (preferred over full update)
- **Create** make new notes, tasks, or pages
- **Checkbox tools** surgically toggle to-do checkboxes or set task done/undone via native relation
- **Search** - find objects by keyword across all spaces or within a specific one
- **Read** - open any object and read its full markdown content
- **Append** - add content to the end of an existing object without touching existing text or internal links (preferred over full update)
- **Create** - make new notes, tasks, or pages
- **Checkbox tools** - surgically toggle to-do checkboxes or set task done/undone via native relation
- All data stays on your machine (local API, no cloud)
### 🛰️ Jarvis Integration
Connect oAI to a self-hosted [Jarvis](https://jarvis.pm) agent-automation server:
- **Agent Management** - List, create, edit, enable/disable, run, and stop agents
- **Run History** - Expandable per-run output with status and timing
- **Usage & Credits** - Per-agent usage stats and credits balance
- **Queue Control** - Pause/resume all agents
- `/jarvis` slash command opens the Jarvis panel directly
### 🖥️ Power-User Features
- **Bash Execution** - AI can run shell commands via `/bin/zsh` (opt-in, with per-command approval prompt)
- **iCloud Backup** - One-click settings backup to iCloud Drive; restore on any Mac; API keys excluded for security
@@ -85,9 +94,10 @@ Automated email responses powered by AI:
- Footer stats display (messages, tokens, cost, sync status)
- Header status indicators (MCP, Online mode, Git sync)
- Responsive message layout with copy buttons
- **Model Selector (⌘M)** - Filter by capability (Vision / Tools / Online / Image Gen / Thinking 🧠), sort by price or context window, search by name or description, per-row ⓘ info button; ★ favourite any model favourites float to the top and can be filtered in one click
- **Model Selector (⌘M)** - Filter by capability (Vision / Tools / Online / Image Gen / Thinking 🧠) or by category (Programming, Math, Medical, Translation, Roleplay, Creative, Science, Finance, Legal), sort by price or context window, search by name or description, per-row ⓘ info button; ★ favourite any model - favourites float to the top and can be filtered in one click
- **Default Model** - Set a fixed startup model in Settings → General; switching models during a session does not overwrite it
- **Localization** - UI ~~fully translated~~ being translated into Norwegian Bokmål, Swedish, Danish, and German; follows macOS language preference automatically
- **Sidebar Navigation** - Collapsible sidebar for switching between conversations
- **Localization** - Fully localized into Norwegian Bokmål, Swedish, Danish, German, and French; follows macOS language preference automatically. Translations are AI-generated (machine translation), not reviewed by native speakers - if you spot an awkward or incorrect phrase, please [open an issue](https://gitlab.pm/rune/oai-swift/issues/new)
![Advanced Features](Screenshots/4.png)
@@ -97,8 +107,8 @@ Automated email responses powered by AI:
Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-swift/releases). Two builds are available:
- **oAI-x.x.x-AppleSilicon.dmg** for Macs with an Apple Silicon chip (M1 and later)
- **oAI-x.x.x-Universal.dmg** runs natively on both Apple Silicon and Intel Macs
- **oAI-x.x.x-AppleSilicon.dmg** - for Macs with an Apple Silicon chip (M1 and later)
- **oAI-x.x.x-Universal.dmg** - runs natively on both Apple Silicon and Intel Macs
### Installing from DMG
@@ -107,19 +117,19 @@ Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-
3. Eject the DMG
4. Launch oAI from Applications or Spotlight
### First Launch Gatekeeper Warning
### First Launch - Gatekeeper Warning
oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified."
oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan - the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified."
To open the app, you have two options:
**Option A Right-click to open (quickest):**
**Option A - Right-click to open (quickest):**
1. Right-click (or Control-click) `oAI.app` in Applications
2. Select **Open** from the context menu
3. Click **Open** in the dialog that appears
4. After doing this once, the app opens normally from then on
**Option B Remove the quarantine flag via Terminal:**
**Option B - Remove the quarantine flag via Terminal:**
```bash
xattr -dr com.apple.quarantine /Applications/oAI.app
@@ -139,7 +149,7 @@ Add your API keys in Settings (⌘,) → General tab:
- **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/) or use OAuth
- **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys)
- **Ollama** - Base URL (default: http://localhost:11434)
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) not a chat provider
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) - not a chat provider
### Essential Settings
@@ -313,12 +323,16 @@ AI-powered email auto-responder:
- [x] Vector index for faster semantic search (sqlite-vss)
- [x] Reasoning / thinking tokens (streamed live, collapsible)
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German)
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German, French)
- [x] iCloud Backup (settings export/restore)
- [x] Bash execution with per-command approval
- [x] Anytype integration (read, append, create, checkbox tools)
- [x] Model favourites (starred models, filter, float to top)
- [ ] SOUL.md / USER.md — living identity documents injected into system prompt
- [x] Jarvis integration (agent management, run history, usage/credits)
- [x] Model category filter (Programming, Math, Medical, etc.)
- [x] Combine saved conversations (concatenation or AI-assisted synthesis)
- [x] Sidebar navigation redesign
- [ ] SOUL.md / USER.md - living identity documents injected into system prompt
- [ ] Parallel research agents (read-only, concurrent)
- [ ] Local embeddings (sentence-transformers, $0 cost)
- [ ] Multi-modal conversation export (PDF, HTML)
@@ -346,7 +360,7 @@ See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agp
## Disclaimer
oAI takes real actions on your behalf it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
oAI takes real actions on your behalf - it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind - the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
---
+15 -4
View File
@@ -104,6 +104,7 @@
da,
de,
sv,
fr,
);
mainGroup = A550A6592F3B72EA00136F2B;
minimizedProjectReferenceProxies = 1;
@@ -269,6 +270,11 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings.";
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -282,8 +288,8 @@
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 2.4.0;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.4.1;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -313,6 +319,11 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings.";
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -326,8 +337,8 @@
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 2.4.0;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.4.1;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+8
View File
@@ -4,6 +4,14 @@
"filename" : "AppLogo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
+11657 -40
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -57,4 +57,10 @@ struct AgentSkill: Codable, Identifiable {
}
return name
}
/// Matches the user's "2nd Brain" skill by name there's no canonical skill ID,
/// so this is the only way to recognize it (used to gate the "always trust" bash setting).
var isSecondBrainSkill: Bool {
name.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("2nd Brain") == .orderedSame
}
}
+1
View File
@@ -47,6 +47,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
let online: Bool // Web search
var imageGeneration: Bool = false // Image output
var thinking: Bool = false // Reasoning/thinking tokens
var usesImagesAPI: Bool = false // OpenRouter dedicated /images endpoint
}
struct Architecture: Codable, Hashable {
+28 -1
View File
@@ -132,15 +132,19 @@ struct ChatResponse: Codable {
let totalTokens: Int
let cacheCreationInputTokens: Int?
let cacheReadInputTokens: Int?
/// Direct USD cost returned by the images API (bypasses token-based calculation).
let rawCostUSD: Double?
init(promptTokens: Int, completionTokens: Int, totalTokens: Int, cacheCreationInputTokens: Int? = nil, cacheReadInputTokens: Int? = nil) {
init(promptTokens: Int, completionTokens: Int, totalTokens: Int, cacheCreationInputTokens: Int? = nil, cacheReadInputTokens: Int? = nil, rawCostUSD: Double? = nil) {
self.promptTokens = promptTokens
self.completionTokens = completionTokens
self.totalTokens = totalTokens
self.cacheCreationInputTokens = cacheCreationInputTokens
self.cacheReadInputTokens = cacheReadInputTokens
self.rawCostUSD = rawCostUSD
}
// rawCostUSD is set programmatically, never decoded from API responses
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
@@ -148,6 +152,16 @@ struct ChatResponse: Codable {
case cacheCreationInputTokens = "cache_creation_input_tokens"
case cacheReadInputTokens = "cache_read_input_tokens"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
promptTokens = try c.decode(Int.self, forKey: .promptTokens)
completionTokens = try c.decode(Int.self, forKey: .completionTokens)
totalTokens = try c.decode(Int.self, forKey: .totalTokens)
cacheCreationInputTokens = try c.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens)
cacheReadInputTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens)
rawCostUSD = nil
}
}
// Custom Codable since ToolCallInfo/generatedImages are not from API directly
@@ -229,6 +243,19 @@ struct Tool: Codable {
let type: String
let description: String
let `enum`: [String]?
let items: Items?
/// Item schema for `type: "array"` properties (e.g. an array of strings).
struct Items: Codable {
let type: String
}
init(type: String, description: String, enum: [String]? = nil, items: Items? = nil) {
self.type = type
self.description = description
self.enum = `enum`
self.items = items
}
}
}
}
+3
View File
@@ -753,6 +753,9 @@ class AnthropicProvider: AIProvider {
if let enumVals = prop.enum {
propDict["enum"] = enumVals
}
if let items = prop.items {
propDict["items"] = ["type": items.type]
}
props[key] = propDict
}
var dict: [String: Any] = [
+61
View File
@@ -420,6 +420,67 @@ struct ToolResultMessage: Encodable {
}
}
// MARK: - Images API Model Discovery
struct OpenRouterImageModelsResponse: Codable {
let data: [ImageModelData]
struct ImageModelData: Codable {
let id: String
let name: String
let description: String?
let architecture: Architecture?
let supportsStreaming: Bool?
struct Architecture: Codable {
let inputModalities: [String]?
let outputModalities: [String]?
enum CodingKeys: String, CodingKey {
case inputModalities = "input_modalities"
case outputModalities = "output_modalities"
}
}
enum CodingKeys: String, CodingKey {
case id, name, description, architecture
case supportsStreaming = "supports_streaming"
}
}
}
// MARK: - Images API Generation Response
struct OpenRouterImageGenerationResponse: Codable {
let created: Int?
let data: [ImageData]
let usage: Usage?
struct ImageData: Codable {
let b64Json: String
let mediaType: String?
enum CodingKeys: String, CodingKey {
case b64Json = "b64_json"
case mediaType = "media_type"
}
}
struct Usage: Codable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
let cost: Double?
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
case cost
}
}
}
// MARK: - Error Response
struct OpenRouterErrorResponse: Codable {
+110 -22
View File
@@ -53,30 +53,16 @@ class OpenRouterProvider: AIProvider {
func listModels() async throws -> [ModelInfo] {
Log.api.info("Fetching model list from OpenRouter")
let url = URL(string: "\(baseURL)/models")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.data(for: request)
// Fetch chat models and image models in parallel
async let chatData = fetchRaw(path: "/models")
async let imageData = fetchRaw(path: "/images/models")
let (chatRaw, imageRaw) = try await (chatData, imageData)
guard let httpResponse = response as? HTTPURLResponse else {
Log.api.error("OpenRouter models: invalid response (not HTTP)")
throw ProviderError.invalidResponse
}
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: chatRaw)
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) chat models")
guard httpResponse.statusCode == 200 else {
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
throw ProviderError.unknown(errorResponse.error.message)
}
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: data)
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) models")
return modelsResponse.data.map { modelData in
var models = modelsResponse.data.map { modelData in
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
@@ -129,8 +115,110 @@ class OpenRouterProvider: AIProvider {
)
return info
}
// Merge dedicated image models (these don't appear in /models)
if let imageModelsResponse = try? JSONDecoder().decode(OpenRouterImageModelsResponse.self, from: imageRaw) {
Log.api.info("OpenRouter loaded \(imageModelsResponse.data.count) image models")
let existingIds = Set(models.map { $0.id })
let imageModels = imageModelsResponse.data.compactMap { m -> ModelInfo? in
guard !existingIds.contains(m.id) else { return nil }
let acceptsImageInput = m.architecture?.inputModalities?.contains("image") ?? false
var info = ModelInfo(
id: m.id,
name: m.name,
description: m.description,
contextLength: 0,
pricing: ModelInfo.Pricing(prompt: 0, completion: 0),
capabilities: ModelInfo.ModelCapabilities(
vision: acceptsImageInput,
tools: false,
online: false,
imageGeneration: true,
thinking: false,
usesImagesAPI: true
),
topProvider: m.id.components(separatedBy: "/").first
)
info.categories = ModelCategory.infer(name: m.name, id: m.id, description: m.description)
return info
}
models.append(contentsOf: imageModels)
}
return models
}
private func fetchRaw(path: String) async throws -> Data {
let url = URL(string: "\(baseURL)\(path)")!
var request = URLRequest(url: url)
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { throw ProviderError.invalidResponse }
guard httpResponse.statusCode == 200 else {
if let err = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
throw ProviderError.unknown(err.error.message)
}
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
return data
}
// MARK: - Images API
func generateImage(model: String, prompt: String) async throws -> ChatResponse {
Log.api.info("OpenRouter images API: model=\(model)")
let url = URL(string: "\(baseURL)/images")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: ["model": model, "prompt": prompt])
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else { throw ProviderError.invalidResponse }
if httpResponse.statusCode != 200 {
if let err = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
Log.api.error("OpenRouter images HTTP \(httpResponse.statusCode): \(err.error.message)")
throw ProviderError.unknown(err.error.message)
}
Log.api.error("OpenRouter images HTTP \(httpResponse.statusCode)")
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
}
if let rawStr = String(data: data, encoding: .utf8) {
Log.api.debug("Images API raw response (first 200 chars): \(rawStr.prefix(200))")
}
let imageResponse = try JSONDecoder().decode(OpenRouterImageGenerationResponse.self, from: data)
let images: [Data] = imageResponse.data.compactMap { item in
Data(base64Encoded: item.b64Json)
}
let usage: ChatResponse.Usage? = imageResponse.usage.map { u in
ChatResponse.Usage(
promptTokens: u.promptTokens,
completionTokens: u.completionTokens,
totalTokens: u.totalTokens,
rawCostUSD: u.cost
)
}
return ChatResponse(
id: UUID().uuidString,
model: model,
content: "",
role: "assistant",
finishReason: "stop",
usage: usage,
created: Date(),
generatedImages: images.isEmpty ? nil : images
)
}
func getModel(_ id: String) async throws -> ModelInfo? {
let models = try await listModels()
return models.first { $0.id == id }
+250
View File
@@ -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)
}
}
+593
View File
@@ -0,0 +1,593 @@
//
// EventKitService.swift
// oAI
//
// Calendar and Reminders integration via EventKit
//
// 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 EventKit
import Foundation
import os
/// Shared tri-state authorization status for the Settings UI across all Personal Data services.
/// `.denied` also covers `.restricted` and (for Calendar) `.writeOnly` states where the OS
/// will not show a request dialog again; the user must go to System Settings manually.
enum PersonalDataAccessState {
case notDetermined
case denied
case granted
}
@Observable
class EventKitService {
static let shared = EventKitService()
private let store = EKEventStore()
private init() {}
// MARK: - Authorization
var calendarAuthStatus: EKAuthorizationStatus {
let status = EKEventStore.authorizationStatus(for: .event)
Log.mcp.debug("EventKitService.calendarAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
return status
}
var reminderAuthStatus: EKAuthorizationStatus {
let status = EKEventStore.authorizationStatus(for: .reminder)
Log.mcp.debug("EventKitService.reminderAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
return status
}
var calendarAuthorized: Bool {
calendarAuthStatus == .fullAccess
}
var reminderAuthorized: Bool {
reminderAuthStatus == .fullAccess
}
var calendarAccessState: PersonalDataAccessState {
switch calendarAuthStatus {
case .fullAccess: return .granted
case .notDetermined: return .notDetermined
default: return .denied // .denied, .restricted, .writeOnly (no read access for our tools)
}
}
var reminderAccessState: PersonalDataAccessState {
switch reminderAuthStatus {
case .fullAccess: return .granted
case .notDetermined: return .notDetermined
default: return .denied
}
}
@discardableResult
func requestCalendarAccess() async -> Bool {
let before = EKEventStore.authorizationStatus(for: .event)
Log.mcp.info("requestCalendarAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
do {
let granted = try await store.requestFullAccessToEvents()
let after = EKEventStore.authorizationStatus(for: .event)
Log.mcp.info("requestCalendarAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
return granted
} catch {
let after = EKEventStore.authorizationStatus(for: .event)
Log.mcp.error("requestCalendarAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
return false
}
}
@discardableResult
func requestReminderAccess() async -> Bool {
let before = EKEventStore.authorizationStatus(for: .reminder)
Log.mcp.info("requestReminderAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
do {
let granted = try await store.requestFullAccessToReminders()
let after = EKEventStore.authorizationStatus(for: .reminder)
Log.mcp.info("requestReminderAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
return granted
} catch {
let after = EKEventStore.authorizationStatus(for: .reminder)
Log.mcp.error("requestReminderAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
return false
}
}
nonisolated static func describe(_ status: EKAuthorizationStatus) -> String {
switch status {
case .notDetermined: return "notDetermined"
case .restricted: return "restricted"
case .denied: return "denied"
case .fullAccess: return "fullAccess"
case .writeOnly: return "writeOnly"
@unknown default: return "unknown"
}
}
// MARK: - Tool Schemas
func getToolSchemas(calendarEnabled: Bool, remindersEnabled: Bool) -> [Tool] {
var tools: [Tool] = []
if calendarEnabled {
tools.append(makeTool(
name: "calendar_list_calendars",
description: "List all calendars available on this Mac (e.g. iCloud, Work, Home).",
properties: [:],
required: []
))
tools.append(makeTool(
name: "calendar_list_events",
description: "List calendar events within a date range. Dates are ISO8601 (e.g. '2026-06-20T00:00:00' or '2026-06-20'). Range is limited to 1 year. For open-ended queries like 'next appointment' or 'upcoming events', do NOT limit the range to just today — use a generous forward-looking window (e.g. today through +90 days) so you don't miss events further out.",
properties: [
"start_date": prop("string", "Start of the date range (ISO8601)"),
"end_date": prop("string", "End of the date range (ISO8601)"),
"calendar_name": prop("string", "Optional: only list events from this calendar")
],
required: ["start_date", "end_date"]
))
tools.append(makeTool(
name: "calendar_create_event",
description: "Create a new calendar event. Requires user approval before it is actually created.",
properties: [
"title": prop("string", "Event title"),
"start_date": prop("string", "Start date/time (ISO8601, e.g. '2026-06-20T14:00:00')"),
"end_date": prop("string", "End date/time (ISO8601)"),
"calendar_name": prop("string", "Optional: calendar to add the event to (defaults to the system default calendar)"),
"location": prop("string", "Optional: event location text"),
"notes": prop("string", "Optional: event notes"),
"all_day": prop("boolean", "Optional: whether this is an all-day event (default: false)"),
"alarm_minutes_before": prop("number", "Optional: minutes before the start time to show an alert")
],
required: ["title", "start_date", "end_date"]
))
}
if remindersEnabled {
tools.append(makeTool(
name: "reminders_list_lists",
description: "List all reminder lists available on this Mac.",
properties: [:],
required: []
))
tools.append(makeTool(
name: "reminders_list",
description: "List reminders. Omit list_name to search across ALL reminder lists in a single call — prefer this over calling once per list. Incomplete reminders only unless include_completed is true.",
properties: [
"list_name": prop("string", "Optional: only list reminders from this one list (omit to search all lists at once)"),
"include_completed": prop("boolean", "Optional: include completed reminders (default: false)")
],
required: []
))
tools.append(makeTool(
name: "reminders_create",
description: "Create a new reminder. Requires user approval before it is actually created.",
properties: [
"title": prop("string", "Reminder title"),
"list_name": prop("string", "Optional: reminder list to add to (defaults to the system default list)"),
"due_date": prop("string", "Optional: due date/time (ISO8601)"),
"priority": prop("string", "Optional: priority level", enumValues: ["none", "low", "medium", "high"]),
"notes": prop("string", "Optional: reminder notes")
],
required: ["title"]
))
tools.append(makeTool(
name: "reminders_complete",
description: "Mark a reminder as completed. Requires user approval. Use reminders_list to find the reminder_id first.",
properties: [
"reminder_id": prop("string", "The reminder's identifier, from reminders_list")
],
required: ["reminder_id"]
))
}
return tools
}
// MARK: - Read Tool Execution
func executeTool(name: String, arguments: String) async -> [String: Any] {
Log.mcp.info("Executing EventKit tool: \(name)")
let args = Self.parseArgs(arguments)
switch name {
case "calendar_list_calendars":
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
return listCalendars()
case "calendar_list_events":
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
return ["error": "Missing or invalid parameter: start_date"]
}
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
return ["error": "Missing or invalid parameter: end_date"]
}
let calendarName = args["calendar_name"] as? String
return listEvents(start: start, end: end, calendarName: calendarName)
case "reminders_list_lists":
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
return listReminderLists()
case "reminders_list":
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
let listName = args["list_name"] as? String
let includeCompleted = args["include_completed"] as? Bool ?? false
return await listReminders(listName: listName, includeCompleted: includeCompleted)
default:
return ["error": "Unknown EventKit tool: \(name)"]
}
}
// MARK: - Write Tool Execution (called only after approval)
func executeWriteTool(name: String, arguments: String) async -> [String: Any] {
Log.mcp.info("Executing EventKit write tool: \(name)")
let args = Self.parseArgs(arguments)
switch name {
case "calendar_create_event":
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
return createEvent(args: args)
case "reminders_create":
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
return createReminder(args: args)
case "reminders_complete":
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
guard let reminderId = args["reminder_id"] as? String else {
return ["error": "Missing required parameter: reminder_id"]
}
return completeReminder(reminderId: reminderId)
default:
return ["error": "Unknown EventKit write tool: \(name)"]
}
}
// MARK: - Approval Summary
/// Human-readable description shown in the approval sheet before a write tool runs.
func approvalSummary(forTool name: String, arguments: String) -> String {
let args = Self.parseArgs(arguments)
switch name {
case "calendar_create_event":
let title = args["title"] as? String ?? "Untitled event"
let start = (args["start_date"] as? String).flatMap(Self.parseDate) ?? Date()
let end = (args["end_date"] as? String).flatMap(Self.parseDate) ?? start
return "Create calendar event \"\(title)\" from \(Self.displayFormatter.string(from: start)) to \(Self.displayFormatter.string(from: end))"
case "reminders_create":
let title = args["title"] as? String ?? "Untitled reminder"
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
return "Create reminder \"\(title)\" due \(Self.displayFormatter.string(from: due))"
}
return "Create reminder \"\(title)\""
case "reminders_complete":
return "Mark reminder as completed"
default:
return "Perform action: \(name)"
}
}
// MARK: - Calendar Read Implementations
private func listCalendars() -> [String: Any] {
let calendars = store.calendars(for: .event).map { cal -> [String: Any] in
[
"name": cal.title,
"type": calendarTypeDescription(cal),
"allows_modifications": cal.allowsContentModifications
]
}
return ["calendars": calendars]
}
private func listEvents(start: Date, end: Date, calendarName: String?) -> [String: Any] {
guard end > start else { return ["error": "end_date must be after start_date"] }
guard end.timeIntervalSince(start) <= 366 * 24 * 60 * 60 else {
return ["error": "Date range too large — limit to 1 year or less"]
}
var calendars = store.calendars(for: .event)
if let calendarName {
calendars = calendars.filter { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }
if calendars.isEmpty {
return ["error": "No calendar found named '\(calendarName)'"]
}
}
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
let events = store.events(matching: predicate)
.sorted { $0.startDate < $1.startDate }
.prefix(200)
.map { event -> [String: Any] in
var item: [String: Any] = [
"id": event.eventIdentifier ?? "",
"title": event.title ?? "Untitled",
"start": Self.isoFormatter.string(from: event.startDate),
"end": Self.isoFormatter.string(from: event.endDate),
"all_day": event.isAllDay,
"calendar": event.calendar?.title ?? ""
]
if let location = event.location, !location.isEmpty {
item["location"] = location
}
if let notes = event.notes, !notes.isEmpty {
item["notes"] = String(notes.prefix(500))
}
return item
}
return ["count": events.count, "events": Array(events)]
}
private func createEvent(args: [String: Any]) -> [String: Any] {
guard let title = args["title"] as? String, !title.isEmpty else {
return ["error": "Missing required parameter: title"]
}
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
return ["error": "Missing or invalid parameter: start_date"]
}
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
return ["error": "Missing or invalid parameter: end_date"]
}
guard end >= start else {
return ["error": "end_date must not be before start_date"]
}
let event = EKEvent(eventStore: store)
event.title = title
event.startDate = start
event.endDate = end
event.isAllDay = args["all_day"] as? Bool ?? false
if let calendarName = args["calendar_name"] as? String,
let calendar = store.calendars(for: .event).first(where: { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }) {
event.calendar = calendar
} else if let defaultCalendar = store.defaultCalendarForNewEvents {
event.calendar = defaultCalendar
} else {
guard let fallback = store.calendars(for: .event).first(where: { $0.allowsContentModifications }) else {
return ["error": "No writable calendar available"]
}
event.calendar = fallback
}
if let location = args["location"] as? String { event.location = location }
if let notes = args["notes"] as? String { event.notes = notes }
if let minutesBefore = (args["alarm_minutes_before"] as? Double) ?? (args["alarm_minutes_before"] as? Int).map(Double.init) {
event.addAlarm(EKAlarm(relativeOffset: -(minutesBefore * 60)))
}
do {
try store.save(event, span: .thisEvent, commit: true)
return ["success": true, "event_id": event.eventIdentifier ?? "", "calendar": event.calendar?.title ?? ""]
} catch {
Log.mcp.error("calendar_create_event failed: \(error.localizedDescription)")
return ["error": "Failed to create event: \(error.localizedDescription)"]
}
}
// MARK: - Reminders Read Implementations
private func listReminderLists() -> [String: Any] {
let lists = store.calendars(for: .reminder).map { cal -> [String: Any] in
["name": cal.title, "allows_modifications": cal.allowsContentModifications]
}
return ["lists": lists]
}
private func listReminders(listName: String?, includeCompleted: Bool) async -> [String: Any] {
var lists = store.calendars(for: .reminder)
if let listName {
lists = lists.filter { $0.title.caseInsensitiveCompare(listName) == .orderedSame }
if lists.isEmpty {
return ["error": "No reminder list found named '\(listName)'"]
}
}
let predicate = store.predicateForReminders(in: lists)
let reminders: [EKReminder] = await withCheckedContinuation { continuation in
store.fetchReminders(matching: predicate) { results in
continuation.resume(returning: results ?? [])
}
}
let filtered = reminders
.filter { includeCompleted || !$0.isCompleted }
.sorted { lhs, rhs in
let l = lhs.dueDateComponents?.date ?? .distantFuture
let r = rhs.dueDateComponents?.date ?? .distantFuture
return l < r
}
.prefix(200)
.map { reminder -> [String: Any] in
var item: [String: Any] = [
"id": reminder.calendarItemIdentifier,
"title": reminder.title ?? "Untitled",
"completed": reminder.isCompleted,
"list": reminder.calendar?.title ?? ""
]
if let due = reminder.dueDateComponents?.date {
item["due"] = Self.isoFormatter.string(from: due)
}
if reminder.priority > 0 {
item["priority"] = priorityDescription(reminder.priority)
}
if let notes = reminder.notes, !notes.isEmpty {
item["notes"] = String(notes.prefix(500))
}
return item
}
return ["count": filtered.count, "reminders": Array(filtered)]
}
private func createReminder(args: [String: Any]) -> [String: Any] {
guard let title = args["title"] as? String, !title.isEmpty else {
return ["error": "Missing required parameter: title"]
}
let reminder = EKReminder(eventStore: store)
reminder.title = title
if let listName = args["list_name"] as? String,
let list = store.calendars(for: .reminder).first(where: { $0.title.caseInsensitiveCompare(listName) == .orderedSame }) {
reminder.calendar = list
} else if let defaultList = store.defaultCalendarForNewReminders() {
reminder.calendar = defaultList
} else {
guard let fallback = store.calendars(for: .reminder).first(where: { $0.allowsContentModifications }) else {
return ["error": "No writable reminder list available"]
}
reminder.calendar = fallback
}
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: due)
}
if let notes = args["notes"] as? String { reminder.notes = notes }
if let priority = args["priority"] as? String { reminder.priority = priorityValue(priority) }
do {
try store.save(reminder, commit: true)
return ["success": true, "reminder_id": reminder.calendarItemIdentifier, "list": reminder.calendar?.title ?? ""]
} catch {
Log.mcp.error("reminders_create failed: \(error.localizedDescription)")
return ["error": "Failed to create reminder: \(error.localizedDescription)"]
}
}
private func completeReminder(reminderId: String) -> [String: Any] {
guard let item = store.calendarItem(withIdentifier: reminderId) as? EKReminder else {
return ["error": "No reminder found with id '\(reminderId)'"]
}
item.isCompleted = true
item.completionDate = Date()
do {
try store.save(item, commit: true)
return ["success": true, "reminder_id": reminderId, "title": item.title ?? ""]
} catch {
Log.mcp.error("reminders_complete failed: \(error.localizedDescription)")
return ["error": "Failed to complete reminder: \(error.localizedDescription)"]
}
}
// MARK: - Helpers
private func calendarTypeDescription(_ cal: EKCalendar) -> String {
switch cal.type {
case .local: return "local"
case .calDAV: return "caldav"
case .exchange: return "exchange"
case .subscription: return "subscription"
case .birthday: return "birthday"
@unknown default: return "unknown"
}
}
private func priorityDescription(_ value: Int) -> String {
switch value {
case 1...4: return "high"
case 5: return "medium"
case 6...9: return "low"
default: return "none"
}
}
private func priorityValue(_ description: String) -> Int {
switch description.lowercased() {
case "high": return 1
case "medium": return 5
case "low": return 9
default: return 0
}
}
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)
}
nonisolated static 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
}
nonisolated static func permissionError(domain: String) -> [String: Any] {
["error": "\(domain) permission not granted. Grant access in Settings > MCP."]
}
nonisolated(unsafe) static let isoFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
nonisolated static let displayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
nonisolated static func parseDate(_ string: String) -> Date? {
if let date = isoFormatter.date(from: string) { return date }
let isoNoTimezone = ISO8601DateFormatter()
isoNoTimezone.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = isoNoTimezone.date(from: string) { return date }
let localDateTime = DateFormatter()
localDateTime.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
if let date = localDateTime.date(from: string) { return date }
let dateOnly = DateFormatter()
dateOnly.dateFormat = "yyyy-MM-dd"
if let date = dateOnly.date(from: string) { return date }
return nil
}
}
+343
View File
@@ -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)
}
}
+274 -1
View File
@@ -111,6 +111,9 @@ class MCPService {
private let anytypeService = AnytypeMCPService.shared
private let paperlessService = PaperlessService.shared
private let eventKitService = EventKitService.shared
private let contactsService = ContactsService.shared
private let locationMapsService = LocationMapsService.shared
// MARK: - Bash Approval State
@@ -124,6 +127,19 @@ class MCPService {
private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
private(set) var bashSessionApproved: Bool = false
// MARK: - Personal Data (Calendar/Reminders) Approval State
struct PendingPersonalDataAction: Identifiable {
let id = UUID()
let toolName: String
let argumentsJSON: String
let summary: String
}
private(set) var pendingPersonalDataAction: PendingPersonalDataAction? = nil
private var pendingPersonalDataContinuation: CheckedContinuation<[String: Any], Never>? = nil
private(set) var personalDataSessionApproved: Bool = false
// MARK: - Tool Schema Generation
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
@@ -232,6 +248,24 @@ class MCPService {
tools.append(contentsOf: paperlessService.getToolSchemas())
}
// Add Calendar/Reminders tools if enabled
if settings.calendarEnabled || settings.remindersEnabled {
tools.append(contentsOf: eventKitService.getToolSchemas(
calendarEnabled: settings.calendarEnabled,
remindersEnabled: settings.remindersEnabled
))
}
// Add Contacts tools if enabled
if settings.contactsEnabled {
tools.append(contentsOf: contactsService.getToolSchemas())
}
// Add Location/Maps tools if enabled
if settings.locationMapsEnabled {
tools.append(contentsOf: locationMapsService.getToolSchemas())
}
// Add bash_execute tool when bash is enabled
if settings.bashEnabled {
let workDir = settings.bashWorkingDirectory
@@ -260,6 +294,22 @@ class MCPService {
))
}
// Add spawn_research_agents when Research Agents are enabled
if settings.agentsEnabled {
tools.append(makeTool(
name: "spawn_research_agents",
description: "Spawn multiple READ-ONLY research sub-agents that investigate independent questions IN PARALLEL. Each sub-agent gets its own read_file/list_directory/search_files/web_search loop — no write, no bash, no nesting (sub-agents cannot call this tool). ONLY use this when you have 2 or more genuinely independent research questions that benefit from running at the same time (e.g. comparing several unrelated files, topics, or sources). Do NOT use this for a single lookup, a sequential task, or anything answerable with one direct tool call — call read_file/search_files/web_search yourself instead. Each sub-agent is its own full chain of model calls and meaningfully increases cost and latency; using this for trivial tasks is wasteful. Prefer the smallest number of tasks that actually need to run in parallel.",
properties: [
"tasks": Tool.Function.Parameters.Property(
type: "array",
description: "List of independent, self-contained research questions — one per sub-agent. Keep this list as short as the task genuinely requires.",
items: .init(type: "string")
)
],
required: ["tasks"]
))
}
return tools
}
@@ -284,7 +334,10 @@ class MCPService {
// MARK: - Tool Execution
func executeTool(name: String, arguments: String) async -> [String: Any] {
/// `agentProvider`/`agentModelId` are only needed for `spawn_research_agents`, which drives
/// its own model calls every other tool ignores them. Passed in by the caller's active
/// chat session rather than stored on MCPService, since this service has no provider state.
func executeTool(name: String, arguments: String, agentProvider: AIProvider? = nil, agentModelId: String? = nil) async -> [String: Any] {
Log.mcp.info("Executing tool: \(name)")
guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
@@ -399,6 +452,25 @@ class MCPService {
let mapped = results.map { ["title": $0.title, "url": $0.url, "snippet": $0.snippet] }
return ["results": mapped]
case "spawn_research_agents":
guard settings.agentsEnabled else {
return ["error": "Research agents are disabled. Enable 'Research Agents' in Settings > MCP."]
}
guard let agentProvider, let agentModelId else {
return ["error": "Internal error: missing model context for spawn_research_agents"]
}
guard let tasks = args["tasks"] as? [String], !tasks.isEmpty else {
return ["error": "Missing required parameter: tasks (non-empty array of strings)"]
}
return await runResearchAgents(tasks: tasks, provider: agentProvider, modelId: agentModelId)
case "calendar_create_event", "reminders_create", "reminders_complete":
guard settings.calendarEnabled || settings.remindersEnabled else {
return ["error": "Calendar/Reminders access is disabled. Enable it in Settings > MCP."]
}
let summary = eventKitService.approvalSummary(forTool: name, arguments: arguments)
return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)
default:
// Route anytype_* tools to AnytypeMCPService
if name.hasPrefix("anytype_") {
@@ -408,6 +480,18 @@ class MCPService {
if name.hasPrefix("paperless_") {
return await paperlessService.executeTool(name: name, arguments: arguments)
}
// Route calendar_*/reminders_* read tools to EventKitService
if name.hasPrefix("calendar_") || name.hasPrefix("reminders_") {
return await eventKitService.executeTool(name: name, arguments: arguments)
}
// Route contacts_* tools to ContactsService
if name.hasPrefix("contacts_") {
return await contactsService.executeTool(name: name, arguments: arguments)
}
// Route location_*/maps_* tools to LocationMapsService
if name.hasPrefix("location_") || name.hasPrefix("maps_") {
return await locationMapsService.executeTool(name: name, arguments: arguments)
}
return ["error": "Unknown tool: \(name)"]
}
}
@@ -754,6 +838,11 @@ class MCPService {
if bashSessionApproved {
return await runBashCommand(command, workingDirectory: workingDirectory)
}
// 2nd Brain calls its helper script via bash_execute let the user mark that
// specific traffic as always-trusted instead of approving it every time.
if isTrustedSecondBrainCommand(command) {
return await runBashCommand(command, workingDirectory: workingDirectory)
}
return await withCheckedContinuation { continuation in
DispatchQueue.main.async {
self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
@@ -786,6 +875,190 @@ class MCPService {
bashSessionApproved = false
}
private func isTrustedSecondBrainCommand(_ command: String) -> Bool {
guard settings.trustSecondBrainSkill, command.contains(".brain_helper.py") else { return false }
return settings.agentSkills.contains { $0.isActive && $0.isSecondBrainSkill }
}
// MARK: - Research Agents (read-only, parallel)
/// Runs `tasks.count` sub-agents (capped) with bounded concurrency, each in its own
/// read-only tool loop, and returns their findings concatenated for the orchestrator.
private func runResearchAgents(tasks: [String], provider: AIProvider, modelId: String) async -> [String: Any] {
let maxConcurrent = max(1, min(5, settings.maxConcurrentAgents))
// Hard cap on total sub-agents regardless of concurrency setting, so a model
// requesting an unreasonably long task list can't run away with cost.
let cappedTasks = Array(tasks.prefix(8))
var results: [(Int, String)] = []
await withTaskGroup(of: (Int, String).self) { group in
var nextIndex = 0
func launchNext() {
guard nextIndex < cappedTasks.count else { return }
let idx = nextIndex
let task = cappedTasks[idx]
nextIndex += 1
group.addTask {
let answer = await self.runSingleResearchAgent(task: task, provider: provider, modelId: modelId)
return (idx, answer)
}
}
for _ in 0..<min(maxConcurrent, cappedTasks.count) { launchNext() }
for await result in group {
results.append(result)
launchNext()
}
}
let sorted = results.sorted { $0.0 < $1.0 }
let formatted = sorted.map { idx, answer in
"### Agent \(idx + 1): \(cappedTasks[idx])\n\(answer)"
}.joined(separator: "\n\n")
var response: [String: Any] = ["agent_count": sorted.count, "results": formatted]
if tasks.count > cappedTasks.count {
response["note"] = "Only the first \(cappedTasks.count) of \(tasks.count) requested tasks were run (per-call cap)."
}
return response
}
/// A single sub-agent's self-contained tool loop. Read-only tools only; cannot write,
/// run bash, or spawn further sub-agents (spawn_research_agents is not in its tool list).
private func runSingleResearchAgent(task: String, provider: AIProvider, modelId: String) async -> String {
let readOnlyTools: [Tool] = [
makeTool(
name: "read_file",
description: "Read the contents of a file. Maximum file size is 10MB.",
properties: ["file_path": prop("string", "The absolute path to the file to read")],
required: ["file_path"]
),
makeTool(
name: "list_directory",
description: "List the contents of a directory. Skips hidden/build directories like .git, node_modules, etc.",
properties: [
"dir_path": prop("string", "The absolute path to the directory to list"),
"recursive": prop("boolean", "Whether to list recursively (default: false)")
],
required: ["dir_path"]
),
makeTool(
name: "search_files",
description: "Search for files by name pattern or content.",
properties: [
"pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
"search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
"content_search": prop("string", "Optional text to search for inside files")
],
required: ["pattern"]
),
makeTool(
name: "web_search",
description: "Search the web for current information using DuckDuckGo.",
properties: ["query": prop("string", "The search query to look up")],
required: ["query"]
)
]
let allowedNames = Set(readOnlyTools.map { $0.function.name })
var apiMessages: [[String: Any]] = [
["role": "system", "content": "You are a read-only research sub-agent. Investigate the assigned task using the available tools and report concise findings as plain text. You cannot write, delete, or execute anything, and cannot spawn further sub-agents. Once you have enough information, respond with your final answer and stop calling tools."],
["role": "user", "content": task]
]
let maxIterations = 6
for iteration in 0..<maxIterations {
if Task.isCancelled { return "(cancelled)" }
guard let response = try? await provider.chatWithToolMessages(
model: modelId, messages: apiMessages, tools: readOnlyTools, maxTokens: nil, temperature: nil
) else {
return "(error: sub-agent request failed)"
}
let toolCalls = response.toolCalls ?? []
guard !toolCalls.isEmpty else {
return response.content.isEmpty ? "(no findings)" : response.content
}
var assistantMsg: [String: Any] = ["role": "assistant"]
if !response.content.isEmpty { assistantMsg["content"] = response.content }
assistantMsg["tool_calls"] = toolCalls.map { tc in
["id": tc.id, "type": tc.type, "function": ["name": tc.functionName, "arguments": tc.arguments]]
}
apiMessages.append(assistantMsg)
for tc in toolCalls {
let resultJSON: String
if allowedNames.contains(tc.functionName) {
let result = await executeTool(name: tc.functionName, arguments: tc.arguments)
resultJSON = serializeToolResult(result)
} else {
resultJSON = "{\"error\": \"Tool not available to research sub-agents\"}"
}
apiMessages.append([
"role": "tool",
"tool_call_id": tc.id,
"name": tc.functionName,
"content": resultJSON
])
}
if iteration == maxIterations - 1 {
return "(research incomplete: sub-agent reached its iteration limit)"
}
}
return "(no findings)"
}
private func serializeToolResult(_ result: [String: Any], maxBytes: Int = 20_000) -> String {
guard let data = try? JSONSerialization.data(withJSONObject: result),
let str = String(data: data, encoding: .utf8) else {
return "{\"error\": \"Failed to serialize result\"}"
}
guard str.utf8.count > maxBytes, let truncated = String(str.utf8.prefix(maxBytes)) else {
return str
}
return truncated + "\n... (result truncated)"
}
// MARK: - Personal Data (Calendar/Reminders) Approval
private func executePersonalDataAction(toolName: String, argumentsJSON: String, summary: String) async -> [String: Any] {
guard settings.personalDataRequireApproval, !personalDataSessionApproved else {
return await eventKitService.executeWriteTool(name: toolName, arguments: argumentsJSON)
}
return await withCheckedContinuation { continuation in
DispatchQueue.main.async {
self.pendingPersonalDataAction = PendingPersonalDataAction(toolName: toolName, argumentsJSON: argumentsJSON, summary: summary)
self.pendingPersonalDataContinuation = continuation
}
}
}
func approvePendingPersonalDataAction(forSession: Bool = false) {
guard let pending = pendingPersonalDataAction, let cont = pendingPersonalDataContinuation else { return }
pendingPersonalDataAction = nil
pendingPersonalDataContinuation = nil
if forSession {
personalDataSessionApproved = true
}
Task.detached(priority: .userInitiated) {
let result = await self.eventKitService.executeWriteTool(name: pending.toolName, arguments: pending.argumentsJSON)
cont.resume(returning: result)
}
}
func denyPendingPersonalDataAction() {
guard pendingPersonalDataAction != nil else { return }
pendingPersonalDataAction = nil
pendingPersonalDataContinuation?.resume(returning: ["error": "User denied this action"])
pendingPersonalDataContinuation = nil
}
func resetPersonalDataSessionApproval() {
personalDataSessionApproved = false
}
private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
let timeoutSeconds = settings.bashTimeout
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
+85
View File
@@ -27,6 +27,18 @@ import Foundation
import os
import Security
/// Kill switch for the Personal Data tools (Calendar/Reminders/Contacts/Location & Maps).
/// Flip `isHiddenPendingAppleFix` back to `false` once Apple fixes the macOS 27 beta TCC bug
/// (filed with Apple). Each flag hides the relevant UI and forces the `*Enabled` getter to
/// return `false` regardless of the persisted DB value no code deleted, just inert.
enum PersonalDataTools {
/// Hides the entire Personal Data section. Flip to `false` once all four services work.
static let isHiddenPendingAppleFix = false
/// Hides only the Contacts row. Contacts TCC still broken under hardened runtime on
/// macOS 27 beta 2 while Calendar/Reminders/Location are fixed. Flip to `false` once fixed.
static let isContactsHiddenPendingAppleFix = true
}
@Observable
class SettingsService {
static let shared = SettingsService()
@@ -577,6 +589,37 @@ class SettingsService {
}
}
// MARK: - Research Agents Settings
/// When true, the AI can call `spawn_research_agents` to run multiple read-only
/// sub-agents in parallel. Each sub-agent is its own full chain of model calls, so
/// this can noticeably increase cost opt-in, off by default.
var agentsEnabled: Bool {
get { cache["agentsEnabled"] == "true" }
set {
cache["agentsEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "agentsEnabled", value: String(newValue))
}
}
var maxConcurrentAgents: Int {
get { cache["maxConcurrentAgents"].flatMap(Int.init) ?? 3 }
set {
cache["maxConcurrentAgents"] = String(newValue)
DatabaseService.shared.setSetting(key: "maxConcurrentAgents", value: String(newValue))
}
}
/// When true (and an active "2nd Brain" Agent Skill is installed), bash commands that
/// invoke the 2nd Brain helper script skip the approval dialog entirely.
var trustSecondBrainSkill: Bool {
get { cache["trustSecondBrainSkill"] == "true" }
set {
cache["trustSecondBrainSkill"] = String(newValue)
DatabaseService.shared.setSetting(key: "trustSecondBrainSkill", value: String(newValue))
}
}
var bashWorkingDirectory: String {
get { cache["bashWorkingDirectory"] ?? "~" }
set {
@@ -593,6 +636,48 @@ class SettingsService {
}
}
// MARK: - Personal Data Settings (Calendar/Reminders/Contacts/Location/Maps)
var calendarEnabled: Bool {
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["calendarEnabled"] == "true" }
set {
cache["calendarEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "calendarEnabled", value: String(newValue))
}
}
var remindersEnabled: Bool {
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["remindersEnabled"] == "true" }
set {
cache["remindersEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "remindersEnabled", value: String(newValue))
}
}
var contactsEnabled: Bool {
get { !PersonalDataTools.isHiddenPendingAppleFix && !PersonalDataTools.isContactsHiddenPendingAppleFix && cache["contactsEnabled"] == "true" }
set {
cache["contactsEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "contactsEnabled", value: String(newValue))
}
}
var locationMapsEnabled: Bool {
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["locationMapsEnabled"] == "true" }
set {
cache["locationMapsEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "locationMapsEnabled", value: String(newValue))
}
}
var personalDataRequireApproval: Bool {
get { cache["personalDataRequireApproval"].map { $0 == "true" } ?? true }
set {
cache["personalDataRequireApproval"] = String(newValue)
DatabaseService.shared.setSetting(key: "personalDataRequireApproval", value: String(newValue))
}
}
// MARK: - Paperless-NGX Settings
var paperlessEnabled: Bool {
+70 -4
View File
@@ -799,8 +799,17 @@ Don't narrate future actions ("Let me...") - just use the tools.
let mcpActive = mcpEnabled || settings.mcpEnabled
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
let bashActive = settings.bashEnabled
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
let researchAgentsActive = settings.agentsEnabled
// Dedicated images API path (OpenRouter /images endpoint separate from chat completions)
if selectedModel?.capabilities.usesImagesAPI == true,
let orProvider = provider as? OpenRouterProvider {
generateImageAPIResponse(orProvider: orProvider, modelId: modelId, prompt: prompt)
return
}
let modelSupportTools = selectedModel?.capabilities.tools ?? false
if modelSupportTools && (anytypeActive || bashActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
generateAIResponseWithTools(provider: provider, modelId: modelId)
return
}
@@ -1261,6 +1270,55 @@ Don't narrate future actions ("Let me...") - just use the tools.
// MARK: - AI Response with Tool Calls
// MARK: - Images API Generation
private func generateImageAPIResponse(orProvider: OpenRouterProvider, modelId: String, prompt: String) {
isGenerating = true
streamingTask?.cancel()
streamingTask = Task {
let startTime = Date()
let assistantMessage = Message(
role: .assistant,
content: ThinkingVerbs.random(),
tokens: nil,
cost: nil,
timestamp: Date(),
attachments: nil,
modelId: modelId,
isStreaming: true
)
let messageId = assistantMessage.id
messages.append(assistantMessage)
do {
let response = try await orProvider.generateImage(model: modelId, prompt: prompt)
let responseTime = Date().timeIntervalSince(startTime)
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = response.content
messages[index].isStreaming = false
messages[index].generatedImages = response.generatedImages
messages[index].responseTime = responseTime
if let usage = response.usage {
messages[index].tokens = usage.completionTokens
let cost = usage.rawCostUSD
messages[index].cost = cost
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
_ = detectGoodbyePhrase(in: "")
} catch {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = "❌ Image generation failed: \(error.localizedDescription)"
messages[index].isStreaming = false
}
Log.api.error("Images API error: \(error)")
}
isGenerating = false
}
}
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
let mcp = MCPService.shared
Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
@@ -1354,6 +1412,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
var didContinueAfterImages = false // Only inject temp-file continuation once
var totalUsage: ChatResponse.Usage?
var hitIterationLimit = false // Track if we exited due to hitting the limit
var finishedWithEmptyContent = false // Model stopped calling tools but said nothing
for iteration in 0..<maxIterations {
if Task.isCancelled {
@@ -1406,6 +1465,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
continue
}
}
if finalContent.isEmpty {
// Some models (observed with Qwen via OpenRouter) stop calling tools
// after a long tool-call chain but return no summarizing text at all.
// Surface a placeholder and nudge a follow-up turn instead of a silent blank bubble.
finishedWithEmptyContent = true
finalContent = "[No response from the model — retrying]"
}
break
}
@@ -1452,7 +1518,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
break
}
let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments)
let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments, agentProvider: provider, agentModelId: effectiveModelId)
let resultJSON: String
if let data = try? JSONSerialization.data(withJSONObject: result),
let str = String(data: data, encoding: .utf8) {
@@ -1537,8 +1603,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
isGenerating = false
streamingTask = nil
// If we hit the iteration limit and weren't cancelled, start auto-continue
if hitIterationLimit && !wasCancelled {
// If we hit the iteration limit, or the model returned no text at all, nudge a follow-up turn
if (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled {
startAutoContinue()
}
+10
View File
@@ -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() }
)
}
}
}
+16 -2
View File
@@ -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)
}
}
+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) {
+48
View File
@@ -36,6 +36,54 @@
}
}
}
},
"NSCalendarsFullAccessUsageDescription" : {
"comment" : "Privacy - Calendars Full Access Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings."
}
}
}
},
"NSContactsUsageDescription" : {
"comment" : "Privacy - Contacts Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings."
}
}
}
},
"NSLocationWhenInUseUsageDescription" : {
"comment" : "Privacy - Location When In Use Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings."
}
}
}
},
"NSRemindersFullAccessUsageDescription" : {
"comment" : "Privacy - Reminders Full Access Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings."
}
}
}
}
},
"version" : "1.1"
+8
View File
@@ -8,5 +8,13 @@
<true/>
<key>com.apple.security.network.server</key>
<false/>
<key>com.apple.security.personal-information.calendars</key>
<true/>
<key>com.apple.security.personal-information.reminders</key>
<true/>
<key>com.apple.security.personal-information.contacts</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>
</plist>