9 Commits

Author SHA1 Message Date
rune 52ab774785 Merge pull request '2.4.1' (#7) from 2.4.1 into main
Reviewed-on: #7
2026-07-14 11:01:35 +02:00
rune 5031dceff8 Merge remote-tracking branch 'origin/main' into 2.4.1
# Conflicts:
#	oAI.xcodeproj/project.pbxproj
2026-07-14 11:00:16 +02:00
rune 0cefef16e4 Change Command History shortcut from ⌘H to ⇧⌘H
⌘H is reserved by macOS for "Hide Application" and pre-empts app-level
menu bindings before they ever fire, so the Command History shortcut
never actually worked. Moved to ⇧⌘H and updated all references (in-app
help, macOS Help Book, README).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:46:53 +02:00
rune 8cba92d768 Document External MCP Servers, Personal Data Tools, and Research Agents in Help
Adds three new Help sections covering the 2.4.1 features: connecting
external stdio MCP servers, Calendar/Reminders/Location access with
its approval flow, and parallel read-only research sub-agents. Also
notes French as a supported language and that OpenRouter's dedicated
image models are merged into the model picker automatically. Cleans
up a stray misplaced HTML comment above the Anytype section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 10:33:50 +02:00
rune bfcdd0164c Fix assistant message text truncating with "…" instead of wrapping
Two contributing layout issues in the chat message bubble:

1. MessageRow's content VStack (icon + text HStack) had no
   .frame(maxWidth: .infinity), so it sized to its content's ideal
   width instead of the space actually available.

2. swift-markdown-ui renders paragraphs with mixed inline styling
   (bold/italic runs next to plain text) as concatenated Text(+)
   segments, which on macOS report their ideal unwrapped single-line
   size for height purposes instead of wrapping — truncating with "…"
   regardless of window width. Plain single-style paragraphs (a single
   Text) weren't affected, which is why some lines wrapped fine and
   others didn't.

Fixed by adding .frame(maxWidth: .infinity, alignment: .leading) to
the MessageRow content stack, and .fixedSize(horizontal: false,
vertical: true) to the markdown paragraph label so height is
recomputed for the width actually given instead of the ideal width.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 08:38:39 +02:00
rune 7119cd1d06 Silence placeholder/continuing messages in the tool-call auto-retry path
The tool loop's max-iterations and empty-response fallbacks were showing
placeholder assistant bubbles ("[Tool loop reached maximum iterations]",
"[No response from the model — retrying]") followed by a "↩ Continuing…"
system message before silently re-running. None of that added anything
for the user, so the auto-continue now happens without any visible
message when there's no real content to show; genuine partial content
is still displayed as before, and usage/cost tracking is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 08:05:07 +02:00
rune e6f965ff19 Add external MCP server support (stdio JSON-RPC)
Lets the AI connect to any external stdio MCP server (e.g. safaridriver
--mcp) configured in Settings, with tools auto-discovered and prefixed
by server slug. Includes crash detection with backoff restart (5s/15s/30s)
and a Settings UI to add/enable/disable/remove servers.

Fixes the temp-dir allowlist in MCPService.isPathAllowed to also match
/tmp and /private/tmp (not just NSTemporaryDirectory(), which resolves
to a different per-user Darwin temp dir) so the MCP file tools can
actually read files external servers and image generation write there.
Also switches the Add Server sheet's argument parsing to a quote-aware
tokenizer so args containing spaces survive intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 08:01:00 +02:00
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 56099c079c Fix accidental macOS 27 deployment target bump
MACOSX_DEPLOYMENT_TARGET was silently bumped 26.2 -> 27.0 in commit
8451db1, most likely by Xcode beta auto-updating it when the project
was opened/built with the macOS 27 beta SDK. This shipped in the public
v2.4 release, meaning the app refused to launch on anything older than
macOS 27 beta. No code in the project actually requires macOS 27 APIs.
2026-06-22 11:06:29 +02:00
18 changed files with 1347 additions and 65 deletions
+3 -3
View File
@@ -21,7 +21,7 @@ A powerful native macOS AI chat application with support for multiple providers,
- **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
- **Command History** - Navigate previous commands with searchable modal (⌘H)
- **Command History** - Navigate previous commands with searchable modal (⌘H)
### 🧠 Enhanced Memory & Context System
- **Smart Context Selection** - Automatically select relevant messages to reduce token usage by 50-80%
@@ -193,7 +193,7 @@ Add your API keys in Settings (⌘,) → General tab:
- `/load` or `/list` - List and load saved conversations (⌘L)
- `/delete <name>` - Delete a saved conversation
- `/export <md|json> [filename]` - Export conversation
- `/history` - Open command history modal (⌘H)
- `/history` - Open command history modal (⌘H)
### Provider & Settings
- `/provider [name]` - Switch or display current provider
@@ -238,7 +238,7 @@ Can you review this code? @~/project/main.swift
- `⌘,` - Open settings
- `⌘N` - New conversation
- `⌘L` - List saved conversations
- `⌘H` - Command history
- `⌘H` - Command history
- `Esc` - Cancel generation / Close dropdown
- `↑/↓` - Navigate command dropdown (when typing `/`)
- `Return` - Send message
+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 {
+15 -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
+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 }
@@ -36,6 +36,9 @@
<li><a href="#shortcuts">Shortcuts (Prompt Templates)</a></li>
<li><a href="#agent-skills">Agent Skills (SKILL.md)</a></li>
<li><a href="#anytype">Anytype Integration</a></li>
<li><a href="#external-mcp">External MCP Servers</a></li>
<li><a href="#personal-data">Personal Data Tools</a></li>
<li><a href="#research-agents">Research Agents</a></li>
<li><a href="#bash-execution">Bash Execution</a></li>
<li><a href="#icloud-backup">iCloud Backup</a></li>
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
@@ -49,7 +52,7 @@
<!-- Getting Started -->
<section id="getting-started">
<h2>Getting Started</h2>
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama. The app is available in English, Norwegian Bokmål, Swedish, Danish, and German — it follows your macOS language preference automatically.</p>
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama. The app is available in English, Norwegian Bokmål, Swedish, Danish, German, and French — it follows your macOS language preference automatically.</p>
<div class="steps">
<h3>Quick Start</h3>
@@ -115,6 +118,10 @@
<li><strong>🧠 Thinking</strong> — models that support reasoning / thinking tokens</li>
</ul>
<div class="note">
<strong>Note:</strong> On OpenRouter, dedicated image-generation models (e.g. Sourceful, Seedream, Flux) are fetched from OpenRouter's separate images catalog and merged into the picker automatically — you don't need to configure anything extra to see them.
</div>
<h3>Sorting</h3>
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
<ul>
@@ -1370,7 +1377,6 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
</div>
</section>
<!-- Keyboard Shortcuts -->
<!-- Anytype Integration -->
<section id="anytype">
<h2>Anytype Integration</h2>
@@ -1413,6 +1419,104 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
</ul>
</section>
<!-- External MCP Servers -->
<section id="external-mcp">
<h2>External MCP Servers</h2>
<p>Connect any external MCP server that speaks the stdio JSON-RPC protocol (for example <code>safaridriver --mcp</code>) and give the AI access to its tools — no custom integration code needed.</p>
<h3>Adding a Server</h3>
<ol>
<li>Press <kbd>⌘,</kbd> to open Settings</li>
<li>Go to the <strong>MCP</strong> tab → <strong>External MCP Servers</strong> section</li>
<li>Click <strong>Add Server…</strong></li>
<li>Enter a <strong>Name</strong> (used to prefix its tools, e.g. "Safari" → <code>safari_navigate_to_url</code>), the <strong>Command</strong> to launch it, and any <strong>Arguments</strong></li>
<li>Click <strong>Add</strong> — the server starts automatically and its tools are discovered</li>
</ol>
<div class="tip">
<strong>💡 Tip:</strong> Arguments containing spaces can be quoted, e.g. <code>--root "/Users/you/My Documents"</code>.
</div>
<h3>Server Status</h3>
<p>Each configured server shows a status dot and label:</p>
<ul>
<li><strong>🟢 Connected</strong> — running and its tools are available to the AI</li>
<li><strong>🟠 Connecting…</strong> — starting up or performing the initial handshake</li>
<li><strong>🔴 Error / Crashed</strong> — failed to start or exited unexpectedly</li>
<li><strong>⚪ Not started</strong> — disabled via the toggle</li>
</ul>
<p>Toggle a server off/on or delete it entirely with the trash icon. Crashed servers automatically restart up to 3 times with increasing delay (5s, 15s, 30s) before giving up.</p>
<div class="note">
<strong>Note:</strong> Tool names from every external server are prefixed with that server's slug (derived from its Name) so they never collide with oAI's built-in tools or each other.
</div>
</section>
<!-- Personal Data Tools -->
<section id="personal-data">
<h2>Personal Data Tools <span style="font-size: 0.75em; background: #f90; color: #fff; border-radius: 4px; padding: 1px 5px; vertical-align: middle;">Beta</span></h2>
<p>Let the AI access your Calendar, Reminders, and Location &amp; Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses Apple's own frameworks (EventKit, CoreLocation, MapKit) with standard macOS permission prompts — nothing goes through a third-party service.</p>
<h3>Enabling a Service</h3>
<ol>
<li>Press <kbd>⌘,</kbd> to open Settings</li>
<li>Go to the <strong>MCP</strong> tab → <strong>Personal Data</strong> section</li>
<li>Toggle on the services you want: <strong>Calendar</strong>, <strong>Reminders</strong>, or <strong>Location &amp; Maps</strong></li>
<li>Click <strong>Request Access</strong> next to a service — macOS shows its standard permission prompt</li>
</ol>
<h3>What the AI Can Do</h3>
<ul>
<li><strong>Calendar</strong> — list your calendars and upcoming events, create new events</li>
<li><strong>Reminders</strong> — list reminder lists and items, create new reminders, mark reminders complete</li>
<li><strong>Location &amp; Maps</strong> — get your current location, search for places, geocode addresses, and get directions (all read-only)</li>
</ul>
<div class="warning">
<strong>⚠️ Write actions require approval:</strong> Creating a calendar event or reminder, or completing a reminder, shows an approval sheet first with a plain-language summary of what will happen. Choose <strong>Deny</strong>, <strong>Allow Once</strong>, or <strong>Allow for Session</strong>. Toggle this requirement off in Settings → MCP → Personal Data → Require Approval for Changes.
</div>
<div class="note">
<strong>Note:</strong> Contacts support exists internally but is currently hidden while a macOS permission bug affecting hardened-runtime apps is worked out on Apple's side.
</div>
<h3>Example Prompts</h3>
<ul>
<li>"What's on my calendar tomorrow?"</li>
<li>"Remind me to call the dentist on Friday at 2pm"</li>
<li>"How far is the nearest coffee shop from here?"</li>
</ul>
</section>
<!-- Research Agents -->
<section id="research-agents">
<h2>Research Agents</h2>
<p>For tasks that involve searching or reading many files, the AI can spawn several read-only research sub-agents that work in parallel instead of doing everything itself, one step at a time.</p>
<div class="warning">
<strong>⚠️ Cost warning:</strong> 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. Leave this off unless you specifically want that tradeoff.
</div>
<h3>Enabling Research Agents</h3>
<ol>
<li>Press <kbd>⌘,</kbd> to open Settings</li>
<li>Go to the <strong>MCP</strong> tab → <strong>Research Agents</strong> section</li>
<li>Toggle <strong>Enable Research Agents</strong> on</li>
<li>Adjust <strong>Max Concurrent Agents</strong> (15, default 3) to control how many sub-agents can run at once</li>
</ol>
<h3>What Sub-Agents Can Do</h3>
<p>Sub-agents are intentionally limited to read-only investigation — they cannot write files, run shell commands, or spawn further sub-agents:</p>
<ul>
<li>Read file contents</li>
<li>List directory contents</li>
<li>Search for files</li>
<li>Search the web</li>
</ul>
<p class="note">Intended for genuinely independent research tasks (e.g. "compare these five files" or "look into three unrelated topics"), not everyday questions — the AI is instructed to reserve this for cases that actually benefit from parallelism.</p>
</section>
<section id="keyboard-shortcuts">
<h2>Keyboard Shortcuts</h2>
<p>Work faster with these keyboard shortcuts.</p>
@@ -1431,7 +1535,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<dt><kbd>⌘L</kbd></dt>
<dd>Browse Conversations</dd>
<dt><kbd>⌘H</kbd></dt>
<dt><kbd>⌘H</kbd></dt>
<dd>Command History</dd>
<dt><kbd>⌘M</kbd></dt>
@@ -1490,6 +1594,9 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<li>Enable/disable write, delete, move, and bash execution permissions</li>
<li>Configure gitignore respect</li>
<li><strong>Bash Execution</strong> — enable AI shell access, set working directory, timeout, and approval behaviour (see <a href="#bash-execution">Bash Execution</a>)</li>
<li><strong>Research Agents</strong> — let the AI spawn parallel read-only research sub-agents (see <a href="#research-agents">Research Agents</a>)</li>
<li><strong>External MCP Servers</strong> — connect any stdio MCP server for additional tools (see <a href="#external-mcp">External MCP Servers</a>)</li>
<li><strong>Personal Data</strong> — Calendar, Reminders, and Location &amp; Maps access (see <a href="#personal-data">Personal Data Tools</a>)</li>
</ul>
<h3>Sync Tab</h3>
+280
View File
@@ -0,0 +1,280 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
import Foundation
// MARK: - ExternalMCPClient
/// Manages one MCP stdio server process. All state is MainActor-isolated
/// (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor project setting).
/// Background I/O runs in Task.detached; state mutations hop back to MainActor.
@MainActor
final class ExternalMCPClient {
let server: ExternalMCPServer
weak var stateDelegate: (any ExternalMCPStateDelegate)?
private var process: Process?
private var stdinHandle: FileHandle?
private var readTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
private var nextRequestId: Int = 1
private var pendingCalls: [Int: CheckedContinuation<Data, Error>] = [:]
private var lineBuffer = Data()
private(set) var state: MCPClientState = .idle
private(set) var discoveredTools: [MCPToolDefinition] = []
init(server: ExternalMCPServer, stateDelegate: (any ExternalMCPStateDelegate)?) {
self.server = server
self.stateDelegate = stateDelegate
}
// MARK: - Lifecycle
func start() async throws {
guard state == .idle || state == .stopped || state == .crashed else { return }
state = .connecting
stateDelegate?.clientDidChangeState(id: server.id, state: .connecting)
let proc = Process()
if server.command.hasPrefix("/") {
proc.executableURL = URL(fileURLWithPath: server.command)
proc.arguments = server.args
} else {
proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
proc.arguments = [server.command] + server.args
}
proc.environment = ProcessInfo.processInfo.environment
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
proc.standardInput = stdinPipe
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
proc.terminationHandler = { [weak self] _ in
Task { @MainActor [weak self] in self?.handleProcessTerminated() }
}
do {
try proc.run()
} catch {
state = .error(error.localizedDescription)
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
throw MCPClientError.processLaunchFailed(error.localizedDescription)
}
process = proc
stdinHandle = stdinPipe.fileHandleForWriting
startReadLoop(pipe: stdoutPipe)
startStderrLoop(pipe: stderrPipe)
do {
let _: MCPInitializeResult = try await timedRequest(seconds: 15, method: "initialize", params: [
"protocolVersion": "2024-11-05",
"capabilities": [:] as [String: Any],
"clientInfo": ["name": "oAI", "version": "1.0"] as [String: Any]
])
try sendNotification(method: "notifications/initialized")
let toolsResult: MCPToolsListResult = try await timedRequest(seconds: 15, method: "tools/list", params: nil)
discoveredTools = toolsResult.tools
} catch {
state = .error(error.localizedDescription)
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
proc.terminate()
throw error
}
state = .ready
stateDelegate?.clientDidBecomeReady(id: server.id, tools: discoveredTools, server: server)
}
func stop() {
state = .stopped
readTask?.cancel()
stderrTask?.cancel()
process?.terminate()
process = nil
stdinHandle = nil
lineBuffer = Data()
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
}
// MARK: - Tool Execution
func callTool(originalName: String, argumentsJSON: String) async -> [String: Any] {
guard state == .ready else {
return ["error": "MCP server '\(server.name)' is not connected"]
}
guard let argData = argumentsJSON.data(using: .utf8),
let argsDict = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
return ["error": "Invalid arguments JSON for tool \(originalName)"]
}
do {
let result: MCPToolCallResult = try await timedRequest(
seconds: server.timeout,
method: "tools/call",
params: ["name": originalName, "arguments": argsDict]
)
return convertMCPResult(result)
} catch MCPClientError.timeout {
return ["error": "MCP server '\(server.name)' timed out after \(Int(server.timeout))s"]
} catch {
return ["error": "MCP call '\(originalName)' failed: \(error.localizedDescription)"]
}
}
// MARK: - I/O Loops (detached from MainActor)
private func startReadLoop(pipe: Pipe) {
readTask = Task.detached { [weak self] in
let handle = pipe.fileHandleForReading
while true {
let data = handle.availableData
if data.isEmpty { break }
await self?.receiveData(data)
}
}
}
private func startStderrLoop(pipe: Pipe) {
let name = server.name
stderrTask = Task.detached {
let handle = pipe.fileHandleForReading
var buf = Data()
while true {
let data = handle.availableData
if data.isEmpty { break }
buf.append(data)
while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) {
let line = String(data: buf[buf.startIndex..<idx], encoding: .utf8) ?? ""
buf = Data(buf[buf.index(after: idx)...])
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
Log.extMcp.warning("[\(name)] \(line)")
}
}
}
}
}
// MARK: - Data Processing (MainActor)
private func receiveData(_ data: Data) {
lineBuffer.append(data)
while let idx = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = Data(lineBuffer[lineBuffer.startIndex..<idx])
lineBuffer = Data(lineBuffer[lineBuffer.index(after: idx)...])
processLine(lineData)
}
}
private func processLine(_ data: Data) {
guard !data.isEmpty,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = json["id"] as? Int,
let cont = pendingCalls.removeValue(forKey: id) else { return }
if let err = json["error"] as? [String: Any] {
cont.resume(throwing: MCPClientError.invalidResponse(err["message"] as? String ?? "Unknown error"))
} else if let result = json["result"],
let resultData = try? JSONSerialization.data(withJSONObject: result) {
cont.resume(returning: resultData)
} else {
cont.resume(throwing: MCPClientError.invalidResponse("Missing result field"))
}
}
// MARK: - JSON-RPC
/// Send a JSON-RPC request with a per-call timeout. The timeout fires a cancellation
/// directly into the pending-calls table rather than using a task group (which would
/// pass the generic T through a @Sendable closure and trigger an isolated-conformance warning).
private func timedRequest<T: Decodable>(seconds: Double, method: String, params: [String: Any]?) async throws -> T {
let id = nextRequestId
nextRequestId += 1
var message: [String: Any] = ["jsonrpc": "2.0", "method": method, "id": id]
if let params { message["params"] = params }
try writeJSON(message)
// Schedule timeout: cancels the specific pending call by ID
let timeoutId = id
Task { [weak self, timeoutId] in
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout)
}
// Await response data, then decode on MainActor
let resultData: Data = try await withCheckedThrowingContinuation { cont in
pendingCalls[id] = cont
}
return try JSONDecoder().decode(T.self, from: resultData)
}
private func cancelPendingCall(id: Int, with error: Error) {
pendingCalls.removeValue(forKey: id)?.resume(throwing: error)
}
private func sendNotification(method: String) throws {
try writeJSON(["jsonrpc": "2.0", "method": method])
}
private func writeJSON(_ message: [String: Any]) throws {
guard let handle = stdinHandle, process?.isRunning == true else {
throw MCPClientError.writeFailed
}
guard let data = try? JSONSerialization.data(withJSONObject: message),
let line = String(data: data, encoding: .utf8) else {
throw MCPClientError.writeFailed
}
do {
try handle.write(contentsOf: Data((line + "\n").utf8))
} catch {
throw MCPClientError.writeFailed
}
}
// MARK: - Process termination
private func handleProcessTerminated() {
guard state != .stopped else { return }
state = .crashed
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
}
// MARK: - Result conversion
private func convertMCPResult(_ result: MCPToolCallResult) -> [String: Any] {
let isError = result.isError ?? false
var parts: [String] = []
for content in result.content {
switch content.type {
case "text":
if let text = content.text { parts.append(text) }
case "image":
if let base64 = content.data, let imageData = Data(base64Encoded: base64) {
parts.append("[Image saved to: \(writeTempImage(imageData, mimeType: content.mimeType))]")
}
case "resource":
if let text = content.text { parts.append(text) }
else if let uri = content.uri { parts.append("[Resource: \(uri)]") }
default:
if let text = content.text { parts.append(text) }
}
}
let combined = parts.joined(separator: "\n")
return isError ? ["error": combined.isEmpty ? "Tool returned an error" : combined] : ["output": combined]
}
private func writeTempImage(_ data: Data, mimeType: String?) -> String {
let ext = mimeType?.contains("png") == true ? "png" : "jpg"
let path = "/tmp/oai_mcp_\(Int(Date().timeIntervalSince1970 * 1000)).\(ext)"
try? data.write(to: URL(fileURLWithPath: path))
return path
}
}
+202
View File
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
import Foundation
// MARK: - ExternalMCPManager
@Observable
@MainActor
final class ExternalMCPManager {
nonisolated static let shared = ExternalMCPManager()
private(set) var clientStates: [UUID: MCPClientState] = [:]
private(set) var cachedToolSchemas: [Tool] = []
// Keep server config alongside client so we can access slug without await
private var clients: [UUID: ExternalMCPClient] = [:]
private var serverConfigs: [UUID: ExternalMCPServer] = [:]
private var restartTasks: [UUID: Task<Void, Never>] = [:]
private var restartAttempts: [UUID: Int] = [:]
private nonisolated init() {}
// MARK: - Lifecycle
func startAll() {
for server in SettingsService.shared.externalMCPServers where server.isEnabled {
startClient(for: server)
}
}
func stopAll() {
for client in clients.values { client.stop() }
clients.removeAll()
serverConfigs.removeAll()
clientStates.removeAll()
cachedToolSchemas.removeAll()
for task in restartTasks.values { task.cancel() }
restartTasks.removeAll()
restartAttempts.removeAll()
}
func reconfigure(servers: [ExternalMCPServer]) {
let activeIds = Set(servers.filter { $0.isEnabled }.map { $0.id })
for id in clients.keys where !activeIds.contains(id) {
clients[id]?.stop()
clients.removeValue(forKey: id)
serverConfigs.removeValue(forKey: id)
clientStates.removeValue(forKey: id)
restartTasks[id]?.cancel()
restartTasks.removeValue(forKey: id)
restartAttempts.removeValue(forKey: id)
removeCachedSchemas(for: id)
}
for server in servers where server.isEnabled && clients[server.id] == nil {
startClient(for: server)
}
}
private func startClient(for server: ExternalMCPServer) {
// Stop any existing client for this ID before creating a new one
clients[server.id]?.stop()
let client = ExternalMCPClient(server: server, stateDelegate: self)
clients[server.id] = client
serverConfigs[server.id] = server
clientStates[server.id] = .connecting
Task {
do {
try await client.start()
} catch MCPClientError.processLaunchFailed(let msg) {
// Process never started termination handler won't fire, so manually trigger crashed
Log.extMcp.error("Failed to launch '\(server.name)': \(msg)")
clientDidChangeState(id: server.id, state: .crashed)
} catch {
// Handshake/other failure proc.terminate() was called in start(), termination
// handler will fire and set .crashed, which drives the restart from one place only.
Log.extMcp.warning("'\(server.name)' start failed: \(error.localizedDescription)")
}
}
}
private func scheduleRestart(for server: ExternalMCPServer, attempt: Int) {
let delays: [Double] = [5, 15, 30]
let delay = delays[min(attempt - 1, delays.count - 1)]
Log.extMcp.warning("MCP server '\(server.name)' crashed — restarting in \(Int(delay))s (attempt \(attempt)/3)")
restartTasks[server.id]?.cancel()
let id = server.id
restartTasks[id] = Task { [weak self, id] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled, let self,
self.clients[id] != nil,
SettingsService.shared.externalMCPServers.contains(where: { $0.id == id && $0.isEnabled })
else { return }
// startClient is the single place that creates and launches clients.
// It handles processLaunchFailed by calling clientDidChangeState(.crashed),
// and all other failures let the termination handler drive the .crashed callback.
self.startClient(for: server)
}
}
// MARK: - Tool Schema Integration (synchronous)
func getToolSchemas() -> [Tool] { cachedToolSchemas }
func isExternalTool(_ name: String) -> Bool {
cachedToolSchemas.contains { $0.function.name == name }
}
// MARK: - Tool Execution
func executeTool(name: String, argumentsJSON: String) async -> [String: Any] {
for (id, client) in clients {
guard clientStates[id] == .ready,
let server = serverConfigs[id] else { continue }
let prefix = "\(server.slug)_"
if name.hasPrefix(prefix) {
let originalName = String(name.dropFirst(prefix.count))
return await client.callTool(originalName: originalName, argumentsJSON: argumentsJSON)
}
}
return ["error": "No external MCP server found for tool: \(name)"]
}
// MARK: - Schema Cache
private func rebuildCache(for server: ExternalMCPServer, tools: [MCPToolDefinition]) {
removeCachedSchemas(for: server.id, slug: server.slug)
let prefixed = tools.compactMap { convertToolDefinition($0, server: server) }
cachedToolSchemas.append(contentsOf: prefixed)
Log.extMcp.info("[\(server.name)] cached \(prefixed.count) tools: \(prefixed.map { $0.function.name }.joined(separator: ", "))")
}
private func removeCachedSchemas(for id: UUID) {
guard let server = serverConfigs[id] else { return }
removeCachedSchemas(for: id, slug: server.slug)
}
private func removeCachedSchemas(for id: UUID, slug: String) {
cachedToolSchemas.removeAll { $0.function.name.hasPrefix("\(slug)_") }
}
private func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? {
Tool(
type: "function",
function: Tool.Function(
name: "\(server.slug)_\(def.name)",
description: "[\(server.name)] \(def.description ?? "")",
parameters: convertInputSchema(def.inputSchema)
)
)
}
private func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
var properties: [String: Tool.Function.Parameters.Property] = [:]
for (key, prop) in schema.properties ?? [:] {
let normalized: String
switch prop.type ?? "string" {
case "integer": normalized = "number"
case "string", "number", "boolean", "array", "object": normalized = prop.type!
default: normalized = "string"
}
var items: Tool.Function.Parameters.Property.Items? = nil
if normalized == "array", let t = prop.items?.type { items = .init(type: t) }
properties[key] = Tool.Function.Parameters.Property(
type: normalized,
description: prop.description ?? "",
enum: prop.enum,
items: items
)
}
return Tool.Function.Parameters(type: "object", properties: properties, required: schema.required)
}
}
// MARK: - ExternalMCPStateDelegate
extension ExternalMCPManager: ExternalMCPStateDelegate {
func clientDidBecomeReady(id: UUID, tools: [MCPToolDefinition], server: ExternalMCPServer) {
clientStates[id] = .ready
restartAttempts.removeValue(forKey: id)
rebuildCache(for: server, tools: tools)
}
func clientDidChangeState(id: UUID, state: MCPClientState) {
clientStates[id] = state
if case .crashed = state,
let server = serverConfigs[id],
SettingsService.shared.externalMCPServers.contains(where: { $0.id == id && $0.isEnabled }) {
removeCachedSchemas(for: id, slug: server.slug)
let attempt = (restartAttempts[id] ?? 0) + 1
guard attempt <= 3 else {
Log.extMcp.error("MCP server '\(server.name)' gave up after 3 restart attempts")
clientStates[id] = .error("Maximum restart attempts reached")
restartAttempts.removeValue(forKey: id)
return
}
restartAttempts[id] = attempt
scheduleRestart(for: server, attempt: attempt)
}
}
}
+170
View File
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
import Foundation
// MARK: - Server Configuration
struct ExternalMCPServer: Codable, Identifiable, Sendable {
var id: UUID
var name: String
var command: String
var args: [String]
var isEnabled: Bool
var timeout: TimeInterval
var createdAt: Date
init(id: UUID = UUID(), name: String, command: String, args: [String] = [],
isEnabled: Bool = true, timeout: TimeInterval = 30, createdAt: Date = Date()) {
self.id = id
self.name = name
self.command = command
self.args = args
self.isEnabled = isEnabled
self.timeout = timeout
self.createdAt = createdAt
}
var slug: String { Self.makeSlug(from: name) }
static func makeSlug(from name: String) -> String {
let s = name
.lowercased()
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
.joined(separator: "_")
return s.isEmpty ? "ext" : s
}
/// Splits a raw arguments string into tokens, respecting single/double-quoted
/// segments so arguments containing spaces (e.g. `--root "/Users/x/My Documents"`)
/// survive intact instead of being split on every space.
static func parseArguments(_ input: String) -> [String] {
var args: [String] = []
var current = ""
var inSingleQuotes = false
var inDoubleQuotes = false
for char in input {
if char == "'" && !inDoubleQuotes {
inSingleQuotes.toggle()
} else if char == "\"" && !inSingleQuotes {
inDoubleQuotes.toggle()
} else if char.isWhitespace && !inSingleQuotes && !inDoubleQuotes {
if !current.isEmpty {
args.append(current)
current = ""
}
} else {
current.append(char)
}
}
if !current.isEmpty { args.append(current) }
return args
}
static let reservedSlugs: Set<String> = [
"anytype", "paperless", "calendar", "reminders",
"contacts", "location", "maps", "bash", "web", "read", "write",
"list", "search", "edit", "delete", "create", "move", "copy", "spawn"
]
var isSlugReserved: Bool { Self.reservedSlugs.contains(slug) }
}
// MARK: - Client State
enum MCPClientState: Equatable {
case idle
case connecting
case ready
case error(String)
case crashed
case stopped
}
// MARK: - State Delegate (all callbacks on MainActor)
@MainActor
protocol ExternalMCPStateDelegate: AnyObject {
func clientDidBecomeReady(id: UUID, tools: [MCPToolDefinition], server: ExternalMCPServer)
func clientDidChangeState(id: UUID, state: MCPClientState)
}
// MARK: - Client Errors
enum MCPClientError: LocalizedError {
case notConnected
case invalidResponse(String)
case timeout
case processLaunchFailed(String)
case handshakeFailed(String)
case writeFailed
var errorDescription: String? {
switch self {
case .notConnected: return "MCP server is not connected"
case .invalidResponse(let s): return "Invalid MCP response: \(s)"
case .timeout: return "MCP request timed out"
case .processLaunchFailed(let s): return "Failed to launch MCP server: \(s)"
case .handshakeFailed(let s): return "MCP handshake failed: \(s)"
case .writeFailed: return "Failed to write to MCP server stdin"
}
}
}
// MARK: - MCP Protocol Types
struct MCPInitializeResult: Decodable {
let protocolVersion: String
let capabilities: MCPCapabilities
let serverInfo: MCPServerInfo?
}
struct MCPCapabilities: Decodable {
let tools: MCPToolsCapability?
struct MCPToolsCapability: Decodable { let listChanged: Bool? }
}
struct MCPServerInfo: Decodable {
let name: String
let version: String?
}
struct MCPToolsListResult: Decodable {
let tools: [MCPToolDefinition]
let nextCursor: String?
}
struct MCPToolDefinition: Decodable {
let name: String
let description: String?
let inputSchema: MCPInputSchema
}
struct MCPInputSchema: Decodable {
let type: String
let properties: [String: MCPPropertySchema]?
let required: [String]?
}
struct MCPPropertySchema: Decodable {
let type: String?
let description: String?
let `enum`: [String]?
let items: MCPItemsSchema?
struct MCPItemsSchema: Decodable { let type: String? }
}
struct MCPToolCallResult: Decodable {
let content: [MCPContent]
let isError: Bool?
}
struct MCPContent: Decodable {
let type: String
let text: String?
let data: String?
let mimeType: String?
let uri: String?
}
+18
View File
@@ -98,6 +98,17 @@ class MCPService {
func isPathAllowed(_ path: String) -> Bool {
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
// Always allow the system temp directory external MCP servers and tools write
// intermediate data there (e.g. Safari MCP page-content files, generated images).
// Check both NSTemporaryDirectory() (per-user Darwin temp dir) and /tmp (what this
// codebase's own temp files actually use, e.g. ChatViewModel's /tmp/oai_generated_*
// and ExternalMCPClient's /tmp/oai_mcp_* they resolve to different directories.
let tmpCandidates = [
(NSTemporaryDirectory() as NSString).standardizingPath,
"/tmp",
"/private/tmp"
]
if tmpCandidates.contains(where: { resolved.hasPrefix($0) }) { return true }
return allowedFolders.contains { resolved.hasPrefix($0) }
}
@@ -310,6 +321,9 @@ class MCPService {
))
}
// Add tools from external MCP servers (stdio JSON-RPC protocol)
tools.append(contentsOf: ExternalMCPManager.shared.getToolSchemas())
return tools
}
@@ -472,6 +486,10 @@ class MCPService {
return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)
default:
// Route to external MCP servers (stdio JSON-RPC)
if ExternalMCPManager.shared.isExternalTool(name) {
return await ExternalMCPManager.shared.executeTool(name: name, argumentsJSON: arguments)
}
// Route anytype_* tools to AnytypeMCPService
if name.hasPrefix("anytype_") {
return await anytypeService.executeTool(name: name, arguments: arguments)
+42
View File
@@ -461,6 +461,48 @@ class SettingsService {
}
}
// MARK: - External MCP Servers
var externalMCPServers: [ExternalMCPServer] {
get {
guard let json = cache["externalMCPServers"],
let data = json.data(using: .utf8) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([ExternalMCPServer].self, from: data)) ?? []
}
set {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(newValue),
let json = String(data: data, encoding: .utf8) {
cache["externalMCPServers"] = json
DatabaseService.shared.setSetting(key: "externalMCPServers", value: json)
}
Task { @MainActor in ExternalMCPManager.shared.reconfigure(servers: newValue) }
}
}
func addExternalMCPServer(_ server: ExternalMCPServer) {
externalMCPServers = externalMCPServers + [server]
}
func updateExternalMCPServer(_ server: ExternalMCPServer) {
externalMCPServers = externalMCPServers.map { $0.id == server.id ? server : $0 }
}
func deleteExternalMCPServer(id: UUID) {
externalMCPServers = externalMCPServers.filter { $0.id != id }
}
func toggleExternalMCPServer(id: UUID) {
externalMCPServers = externalMCPServers.map { s in
s.id == id ? ExternalMCPServer(id: s.id, name: s.name, command: s.command,
args: s.args, isEnabled: !s.isEnabled,
timeout: s.timeout, createdAt: s.createdAt) : s
}
}
// MARK: - Favorite Models
var favoriteModelIds: Set<String> {
+1
View File
@@ -153,4 +153,5 @@ enum Log {
nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
}
+108 -33
View File
@@ -360,7 +360,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
func startAutoContinue() {
showSystemMessage("↩ Continuing…")
silentContinuePrompt = "Please continue from where you left off."
Task { @MainActor in
generateAIResponse(to: "", attachments: nil)
@@ -801,8 +800,16 @@ Don't narrate future actions ("Let me...") - just use the tools.
let bashActive = settings.bashEnabled
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
let researchAgentsActive = settings.agentsEnabled
let externalMCPActive = !settings.externalMCPServers.filter { $0.isEnabled }.isEmpty
// 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 || personalDataActive || researchAgentsActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || externalMCPActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
generateAIResponseWithTools(provider: provider, modelId: modelId)
return
}
@@ -1263,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)")
@@ -1304,6 +1360,12 @@ Don't narrate future actions ("Let me...") - just use the tools.
systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.")
}
let activeExternalServers = settings.externalMCPServers.filter { $0.isEnabled }
if !activeExternalServers.isEmpty {
let names = activeExternalServers.map { $0.name }.joined(separator: ", ")
systemParts.append("You have access to external tools from MCP servers: \(names). Their tools are prefixed with the server slug (e.g. safari_navigate_to_url). Use them proactively when the user's request relates to what those servers provide.")
}
var systemContent = systemParts.joined(separator: "\n\n")
// Append the complete system prompt (default + custom)
@@ -1412,9 +1474,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
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.
// Silently nudge a follow-up turn instead of showing a placeholder bubble.
finishedWithEmptyContent = true
finalContent = "[No response from the model — retrying]"
}
break
}
@@ -1503,9 +1564,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// If this was the last iteration, note it
if iteration == maxIterations - 1 {
hitIterationLimit = true // We're exiting with pending tool calls
finalContent = response.content.isEmpty
? "[Tool loop reached maximum iterations]"
: response.content
finalContent = response.content
}
}
@@ -1514,41 +1573,57 @@ Don't narrate future actions ("Let me...") - just use the tools.
wasCancelled = true
}
// If we hit the iteration limit or the model returned no text at all, silently
// nudge a follow-up turn instead of showing a placeholder/blank bubble.
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
// Display the final response as an assistant message
let responseTime = Date().timeIntervalSince(startTime)
let assistantMessage = Message(
role: .assistant,
content: finalContent,
tokens: totalUsage?.completionTokens,
cost: nil,
timestamp: Date(),
attachments: nil,
responseTime: responseTime,
wasInterrupted: wasCancelled,
modelId: modelId,
generatedImages: finalImages.isEmpty ? nil : finalImages
)
messages.append(assistantMessage)
// Calculate cost
if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
messages[index].cost = cost
if willAutoContinue && finalContent.isEmpty {
// Nothing worth showing yet still record usage/cost for this turn.
if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
sessionStats.addMessage(
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
cost: cost
)
}
sessionStats.addMessage(
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
cost: cost
} else {
let assistantMessage = Message(
role: .assistant,
content: finalContent,
tokens: totalUsage?.completionTokens,
cost: nil,
timestamp: Date(),
attachments: nil,
responseTime: responseTime,
wasInterrupted: wasCancelled,
modelId: modelId,
generatedImages: finalImages.isEmpty ? nil : finalImages
)
messages.append(assistantMessage)
// Calculate cost
if let usage = totalUsage, let model = selectedModel {
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
messages[index].cost = cost
}
sessionStats.addMessage(
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
cost: cost
)
}
}
isGenerating = false
streamingTask = nil
// If we hit the iteration limit, or the model returned no text at all, nudge a follow-up turn
if (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled {
if willAutoContinue {
startAutoContinue()
}
+7
View File
@@ -61,8 +61,15 @@ struct MarkdownContentView: View {
.markdownBlockStyle(\.paragraph) { configuration in
configuration.label
.markdownMargin(top: 0, bottom: 8)
// MarkdownUI builds mixed-style paragraphs (bold/italic runs alongside
// plain text) as concatenated Text(+) segments, which on macOS report
// their ideal (unwrapped, single-line) size instead of wrapping to the
// width actually available truncating with "" mid-word. Forcing the
// height to be recomputed for the given (flexible) width fixes it.
.fixedSize(horizontal: false, vertical: true)
}
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Parsing
+1
View File
@@ -185,6 +185,7 @@ struct MessageRow: View {
.foregroundColor(.oaiSecondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
.background(Color.messageBackground(for: message.role))
+1 -1
View File
@@ -52,7 +52,7 @@ private let helpCategories: [CommandCategory] = [
brief: "View command history",
detail: "Opens a searchable modal showing all your previous messages with timestamps in European format (dd.MM.yyyy HH:mm:ss). Search by text content or date to find specific messages. Click any entry to reuse it.",
examples: ["/history"],
shortcut: "⌘H"
shortcut: "⌘H"
),
CommandDetail(
command: "/clear",
+212
View File
@@ -75,6 +75,14 @@ struct SettingsView: View {
// Default model picker state
@State private var showDefaultModelPicker = false
// External MCP Servers state
@State private var showAddExternalMCPServer = false
@State private var newMCPServerName = ""
@State private var newMCPServerCommand = ""
@State private var newMCPServerArgs = ""
@State private var newMCPServerTimeout: Double = 30
private var externalMCPManager = ExternalMCPManager.shared
// Personal Data state (Calendar/Reminders/Contacts/Location/Maps)
@State private var calendarAccessState = EventKitService.shared.calendarAccessState
@State private var remindersAccessState = EventKitService.shared.reminderAccessState
@@ -817,6 +825,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// MARK: External MCP Servers
Divider()
externalMCPSection
// MARK: Personal Data
// isHiddenPendingAppleFix hides the entire section (macOS 27 beta TCC bug).
// isContactsHiddenPendingAppleFix hides just the Contacts row (still broken in beta 2
@@ -917,6 +929,206 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// MARK: - External MCP Servers Section
@ViewBuilder
private var externalMCPSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "server.rack")
.font(.title2)
.foregroundStyle(.purple)
Text("External MCP Servers")
.font(.system(size: 18, weight: .semibold))
}
Text("Connect any stdio MCP server (e.g. safaridriver --mcp) to give the AI access to its tools. Tool names are prefixed with the server slug.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 4)
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Configured Servers")
formSection {
if settingsService.externalMCPServers.isEmpty {
VStack(spacing: 8) {
Image(systemName: "server.rack")
.font(.system(size: 32))
.foregroundStyle(.tertiary)
Text("No external servers configured")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
Text("Add a server below, e.g.: safaridriver --mcp")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else {
ForEach(settingsService.externalMCPServers) { server in
HStack(spacing: 10) {
Circle()
.fill(mcpStatusColor(externalMCPManager.clientStates[server.id]))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(server.name)
.font(.system(size: 14))
Text(([server.command] + server.args).joined(separator: " "))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Text(mcpStatusLabel(externalMCPManager.clientStates[server.id]))
.font(.system(size: 11))
.foregroundStyle(.secondary)
Toggle("", isOn: Binding(
get: { server.isEnabled },
set: { _ in settingsService.toggleExternalMCPServer(id: server.id) }
))
.toggleStyle(.switch)
.labelsHidden()
Button {
settingsService.deleteExternalMCPServer(id: server.id)
} label: {
Image(systemName: "trash.fill")
.foregroundStyle(.red)
.font(.system(size: 13))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
if server.id != settingsService.externalMCPServers.last?.id {
rowDivider()
}
}
}
}
Button {
newMCPServerName = ""
newMCPServerCommand = ""
newMCPServerArgs = ""
newMCPServerTimeout = 30
showAddExternalMCPServer = true
} label: {
Label("Add Server…", systemImage: "plus")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Color.purple)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
.sheet(isPresented: $showAddExternalMCPServer) {
addExternalMCPServerSheet
}
}
@ViewBuilder
private var addExternalMCPServerSheet: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Add External MCP Server")
.font(.system(size: 16, weight: .semibold))
.frame(maxWidth: .infinity, alignment: .center)
formSection {
row("Name") {
TextField("Safari", text: $newMCPServerName)
.textFieldStyle(.roundedBorder)
.frame(width: 240)
}
rowDivider()
row("Command") {
TextField("safaridriver", text: $newMCPServerCommand)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
}
rowDivider()
row("Arguments") {
TextField("--mcp", text: $newMCPServerArgs)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
.help("Space-separated arguments")
}
rowDivider()
row("Timeout") {
HStack(spacing: 8) {
Stepper("", value: $newMCPServerTimeout, in: 5...120, step: 5)
.labelsHidden()
Text("\(Int(newMCPServerTimeout))s")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.frame(width: 32, alignment: .leading)
}
}
}
if !newMCPServerName.isEmpty {
let slug = ExternalMCPServer.makeSlug(from: newMCPServerName)
HStack(spacing: 6) {
Text("Tool prefix:")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text("\(slug)_")
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
}
if ExternalMCPServer.reservedSlugs.contains(slug) {
Text("'\(slug)' is a reserved prefix. Choose a different name.")
.font(.system(size: 12))
.foregroundStyle(.red)
}
}
HStack {
Button("Cancel") { showAddExternalMCPServer = false }
Spacer()
Button("Add") {
let args = ExternalMCPServer.parseArguments(newMCPServerArgs)
let server = ExternalMCPServer(
name: newMCPServerName,
command: newMCPServerCommand,
args: args,
timeout: newMCPServerTimeout
)
settingsService.addExternalMCPServer(server)
showAddExternalMCPServer = false
}
.buttonStyle(.borderedProminent)
.disabled(newMCPServerName.isEmpty || newMCPServerCommand.isEmpty ||
ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)))
}
}
.padding(24)
.frame(minWidth: 460, minHeight: 320)
}
private func mcpStatusColor(_ state: MCPClientState?) -> Color {
switch state {
case .ready: return .green
case .connecting: return .orange
case .error, .crashed: return .red
default: return Color(nsColor: .tertiaryLabelColor)
}
}
private func mcpStatusLabel(_ state: MCPClientState?) -> LocalizedStringKey {
switch state {
case .ready: return "Connected"
case .connecting: return "Connecting…"
case .error: return "Error"
case .crashed: return "Crashed"
default: return "Not started"
}
}
// MARK: - Appearance Tab
@ViewBuilder
+5 -2
View File
@@ -37,6 +37,9 @@ struct oAIApp: App {
// Start email handler on app launch
EmailHandlerService.shared.start()
// Start external MCP servers
Task { @MainActor in ExternalMCPManager.shared.startAll() }
// Sync Git changes on app launch (pull + import)
Task {
await GitSyncService.shared.syncOnStartup()
@@ -56,7 +59,7 @@ struct oAIApp: App {
}
#if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
// Trigger auto-save on app quit
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
Task {
await chatViewModel.onAppWillTerminate()
}
@@ -129,7 +132,7 @@ struct oAIApp: App {
Divider()
Button("Command History") { chatViewModel.showHistory = true }
.keyboardShortcut("h", modifiers: .command)
.keyboardShortcut("h", modifiers: [.command, .shift])
Button("In-App Help") { chatViewModel.showHelp = true }
.keyboardShortcut("/", modifiers: .command)