From 1cd44b4e97ae294973c4fcec195aa83d46aa1246 Mon Sep 17 00:00:00 2001 From: Thomas Davis Date: Mon, 9 Feb 2026 21:29:39 +1000 Subject: [PATCH] feat: add Omega Mac native macOS SwiftUI chat app Native macOS counterpart to the web Omega agent, connecting directly to the OpenAI API and TPMJS tool registry (1M+ AI-ready tools). - SwiftUI app targeting macOS 15+ with dark theme - Full agentic loop: auto-discover tools via BM25, stream OpenAI responses, execute tools via remote sandbox, loop up to 10x - SwiftData persistence for conversations, messages, tool runs - Keychain storage for API keys and environment variables - SSE streaming via URLSession.bytes with custom parser - Actor-based services (OpenAIService, TPMJSRegistryService) - NavigationSplitView layout with sidebar + chat detail - MarkdownUI for rendering assistant responses - Settings: API key, model picker, env vars, custom system prompt - Keyboard shortcuts: Cmd+N new chat, Cmd+, settings, Enter send Co-Authored-By: Claude Opus 4.6 --- .claude/skills/agentmail | 1 + .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/Contents.json | 58 ++ .../OmegaMac/Assets.xcassets/Contents.json | 6 + .../OmegaMac/Models/Conversation.swift | 41 + apps/omega-mac/OmegaMac/Models/EnvVar.swift | 18 + apps/omega-mac/OmegaMac/Models/Message.swift | 141 ++++ .../OmegaMac/Models/ToolCallRecord.swift | 51 ++ .../OmegaMac/Models/UserSettings.swift | 17 + apps/omega-mac/OmegaMac/OmegaMac.entitlements | 14 + apps/omega-mac/OmegaMac/OmegaMacApp.swift | 44 + .../OmegaMac/Services/ChatOrchestrator.swift | 613 ++++++++++++++ .../OmegaMac/Services/KeychainService.swift | 124 +++ .../OmegaMac/Services/OpenAIService.swift | 141 ++++ .../OmegaMac/Services/OpenAITypes.swift | 209 +++++ .../OmegaMac/Services/StreamParser.swift | 105 +++ .../Services/TPMJSRegistryService.swift | 156 ++++ .../OmegaMac/Services/TPMJSTypes.swift | 128 +++ .../OmegaMac/Utilities/SanitizeToolName.swift | 40 + .../Utilities/SystemPromptBuilder.swift | 134 ++++ .../OmegaMac/Views/Chat/ChatInputBar.swift | 54 ++ .../OmegaMac/Views/Chat/ChatView.swift | 68 ++ .../OmegaMac/Views/Chat/MessageBubble.swift | 69 ++ .../OmegaMac/Views/Chat/MessageList.swift | 95 +++ .../Views/Chat/StreamingIndicator.swift | 24 + .../OmegaMac/Views/ContentView.swift | 39 + .../Views/Settings/APIKeySettings.swift | 129 +++ .../Views/Settings/EnvVarsSettings.swift | 131 +++ .../Views/Settings/SettingsView.swift | 23 + .../Views/Settings/SystemPromptSettings.swift | 73 ++ .../Views/Shared/EmptyStateView.swift | 52 ++ .../OmegaMac/Views/Shared/MarkdownView.swift | 79 ++ .../Views/Sidebar/ConversationRow.swift | 25 + .../OmegaMac/Views/Sidebar/SidebarView.swift | 52 ++ .../Views/Tools/JSONToolResultView.swift | 98 +++ .../OmegaMac/Views/Tools/ToolCallView.swift | 119 +++ apps/omega-mac/Package.swift | 19 + apps/omega-mac/README.md | 115 +++ packages/tools/official/agentmail/README.md | 77 ++ .../tools/official/agentmail/package.json | 109 +++ .../tools/official/agentmail/src/index.ts | 751 ++++++++++++++++++ .../tools/official/agentmail/tsconfig.json | 11 + .../tools/official/agentmail/tsup.config.ts | 12 + packages/tools/official/blocks.yml | 282 +++++++ pnpm-lock.yaml | 16 + 45 files changed, 4583 insertions(+) create mode 120000 .claude/skills/agentmail create mode 100644 apps/omega-mac/OmegaMac/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/omega-mac/OmegaMac/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json create mode 100644 apps/omega-mac/OmegaMac/Models/Conversation.swift create mode 100644 apps/omega-mac/OmegaMac/Models/EnvVar.swift create mode 100644 apps/omega-mac/OmegaMac/Models/Message.swift create mode 100644 apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift create mode 100644 apps/omega-mac/OmegaMac/Models/UserSettings.swift create mode 100644 apps/omega-mac/OmegaMac/OmegaMac.entitlements create mode 100644 apps/omega-mac/OmegaMac/OmegaMacApp.swift create mode 100644 apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift create mode 100644 apps/omega-mac/OmegaMac/Services/KeychainService.swift create mode 100644 apps/omega-mac/OmegaMac/Services/OpenAIService.swift create mode 100644 apps/omega-mac/OmegaMac/Services/OpenAITypes.swift create mode 100644 apps/omega-mac/OmegaMac/Services/StreamParser.swift create mode 100644 apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift create mode 100644 apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift create mode 100644 apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift create mode 100644 apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift create mode 100644 apps/omega-mac/OmegaMac/Views/ContentView.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Settings/SystemPromptSettings.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift create mode 100644 apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift create mode 100644 apps/omega-mac/Package.swift create mode 100644 apps/omega-mac/README.md create mode 100644 packages/tools/official/agentmail/README.md create mode 100644 packages/tools/official/agentmail/package.json create mode 100644 packages/tools/official/agentmail/src/index.ts create mode 100644 packages/tools/official/agentmail/tsconfig.json create mode 100644 packages/tools/official/agentmail/tsup.config.ts diff --git a/.claude/skills/agentmail b/.claude/skills/agentmail new file mode 120000 index 0000000..63f635b --- /dev/null +++ b/.claude/skills/agentmail @@ -0,0 +1 @@ +../../.agents/skills/agentmail \ No newline at end of file diff --git a/apps/omega-mac/OmegaMac/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/omega-mac/OmegaMac/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..5695f71 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0.996", + "green": "0.475", + "red": "0.325" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/omega-mac/OmegaMac/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/omega-mac/OmegaMac/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3d50046 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images": [ + { + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "512x512" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json b/apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json new file mode 100644 index 0000000..74d6a72 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/omega-mac/OmegaMac/Models/Conversation.swift b/apps/omega-mac/OmegaMac/Models/Conversation.swift new file mode 100644 index 0000000..608ff97 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Models/Conversation.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftData + +@Model +final class Conversation { + var id: UUID + var title: String? + var createdAt: Date + var updatedAt: Date + var executionState: String // "idle" | "running" + var inputTokensTotal: Int + var outputTokensTotal: Int + + @Relationship(deleteRule: .cascade, inverse: \Message.conversation) + var messages: [Message] + + @Relationship(deleteRule: .cascade, inverse: \ToolCallRecord.conversation) + var toolRuns: [ToolCallRecord] + + init( + title: String? = nil + ) { + self.id = UUID() + self.title = title + self.createdAt = Date() + self.updatedAt = Date() + self.executionState = "idle" + self.inputTokensTotal = 0 + self.outputTokensTotal = 0 + self.messages = [] + self.toolRuns = [] + } + + var displayTitle: String { + title ?? "New Conversation" + } + + var sortedMessages: [Message] { + messages.sorted { $0.createdAt < $1.createdAt } + } +} diff --git a/apps/omega-mac/OmegaMac/Models/EnvVar.swift b/apps/omega-mac/OmegaMac/Models/EnvVar.swift new file mode 100644 index 0000000..0a8ba5f --- /dev/null +++ b/apps/omega-mac/OmegaMac/Models/EnvVar.swift @@ -0,0 +1,18 @@ +import Foundation +import SwiftData + +@Model +final class EnvVar { + var id: UUID + var keyName: String + /// Last 4 characters of the value (for display hint) + var valueHint: String + var createdAt: Date + + init(keyName: String, valueHint: String) { + self.id = UUID() + self.keyName = keyName + self.valueHint = valueHint + self.createdAt = Date() + } +} diff --git a/apps/omega-mac/OmegaMac/Models/Message.swift b/apps/omega-mac/OmegaMac/Models/Message.swift new file mode 100644 index 0000000..8e37f2e --- /dev/null +++ b/apps/omega-mac/OmegaMac/Models/Message.swift @@ -0,0 +1,141 @@ +import Foundation +import SwiftData + +enum MessageRole: String, Codable { + case user = "USER" + case assistant = "ASSISTANT" + case tool = "TOOL" + case system = "SYSTEM" +} + +@Model +final class Message { + var id: UUID + var role: MessageRole + var content: String + var createdAt: Date + var inputTokens: Int? + var outputTokens: Int? + + /// JSON-encoded array of tool calls (for assistant messages) + var toolCallsJSON: Data? + + var conversation: Conversation? + + init( + role: MessageRole, + content: String, + conversation: Conversation? = nil, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + toolCalls: [ToolCallData]? = nil + ) { + self.id = UUID() + self.role = role + self.content = content + self.createdAt = Date() + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.conversation = conversation + if let toolCalls { + self.toolCallsJSON = try? JSONEncoder().encode(toolCalls) + } + } + + var toolCalls: [ToolCallData] { + get { + guard let data = toolCallsJSON else { return [] } + return (try? JSONDecoder().decode([ToolCallData].self, from: data)) ?? [] + } + set { + toolCallsJSON = try? JSONEncoder().encode(newValue) + } + } +} + +/// Serializable tool call data stored in messages +struct ToolCallData: Codable, Identifiable { + var id: String { toolCallId } + let toolCallId: String + let toolName: String + let args: JSONValue? + let output: JSONValue? + + init(toolCallId: String, toolName: String, args: JSONValue? = nil, output: JSONValue? = nil) { + self.toolCallId = toolCallId + self.toolName = toolName + self.args = args + self.output = output + } +} + +/// A type-erased JSON value for encoding/decoding arbitrary JSON +enum JSONValue: Codable, Equatable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let b = try? container.decode(Bool.self) { + self = .bool(b) + } else if let n = try? container.decode(Double.self) { + self = .number(n) + } else if let s = try? container.decode(String.self) { + self = .string(s) + } else if let arr = try? container.decode([JSONValue].self) { + self = .array(arr) + } else if let obj = try? container.decode([String: JSONValue].self) { + self = .object(obj) + } else { + self = .null + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let s): try container.encode(s) + case .number(let n): try container.encode(n) + case .bool(let b): try container.encode(b) + case .object(let o): try container.encode(o) + case .array(let a): try container.encode(a) + case .null: try container.encodeNil() + } + } + + /// Convert any Codable/Sendable value to JSONValue + static func from(_ value: Any) -> JSONValue { + if let s = value as? String { return .string(s) } + if let n = value as? NSNumber { + if CFBooleanGetTypeID() == CFGetTypeID(n) { + return .bool(n.boolValue) + } + return .number(n.doubleValue) + } + if let b = value as? Bool { return .bool(b) } + if let i = value as? Int { return .number(Double(i)) } + if let d = value as? Double { return .number(d) } + if let arr = value as? [Any] { return .array(arr.map { from($0) }) } + if let obj = value as? [String: Any] { + return .object(obj.mapValues { from($0) }) + } + return .null + } + + /// Pretty-print JSON + var prettyString: String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(self), + let str = String(data: data, encoding: .utf8) else { + return "null" + } + return str + } +} diff --git a/apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift b/apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift new file mode 100644 index 0000000..19ab8a6 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift @@ -0,0 +1,51 @@ +import Foundation +import SwiftData + +@Model +final class ToolCallRecord { + var id: UUID + var toolName: String + var toolCallId: String + var status: String // "running" | "success" | "error" + var inputJSON: Data? + var outputJSON: Data? + var errorMessage: String? + var executionTimeMs: Int? + var createdAt: Date + var completedAt: Date? + + var conversation: Conversation? + + init( + toolName: String, + toolCallId: String, + conversation: Conversation? = nil + ) { + self.id = UUID() + self.toolName = toolName + self.toolCallId = toolCallId + self.status = "running" + self.createdAt = Date() + self.conversation = conversation + } + + var input: JSONValue? { + get { + guard let data = inputJSON else { return nil } + return try? JSONDecoder().decode(JSONValue.self, from: data) + } + set { + inputJSON = try? JSONEncoder().encode(newValue) + } + } + + var output: JSONValue? { + get { + guard let data = outputJSON else { return nil } + return try? JSONDecoder().decode(JSONValue.self, from: data) + } + set { + outputJSON = try? JSONEncoder().encode(newValue) + } + } +} diff --git a/apps/omega-mac/OmegaMac/Models/UserSettings.swift b/apps/omega-mac/OmegaMac/Models/UserSettings.swift new file mode 100644 index 0000000..63b54f5 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Models/UserSettings.swift @@ -0,0 +1,17 @@ +import Foundation +import SwiftData + +@Model +final class UserSettings { + var id: UUID + var systemPrompt: String? + var selectedModel: String + var pinnedToolIds: [String] + + init() { + self.id = UUID() + self.systemPrompt = nil + self.selectedModel = "gpt-4.1-mini" + self.pinnedToolIds = [] + } +} diff --git a/apps/omega-mac/OmegaMac/OmegaMac.entitlements b/apps/omega-mac/OmegaMac/OmegaMac.entitlements new file mode 100644 index 0000000..97aec89 --- /dev/null +++ b/apps/omega-mac/OmegaMac/OmegaMac.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)com.tpmjs.omega-mac + + + diff --git a/apps/omega-mac/OmegaMac/OmegaMacApp.swift b/apps/omega-mac/OmegaMac/OmegaMacApp.swift new file mode 100644 index 0000000..c14feef --- /dev/null +++ b/apps/omega-mac/OmegaMac/OmegaMacApp.swift @@ -0,0 +1,44 @@ +import SwiftData +import SwiftUI + +@main +struct OmegaMacApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .preferredColorScheme(.dark) + } + .modelContainer(for: [ + Conversation.self, + Message.self, + ToolCallRecord.self, + EnvVar.self, + UserSettings.self, + ]) + .defaultSize(width: 1100, height: 750) + .commands { + CommandGroup(replacing: .newItem) { + Button("New Conversation") { + NotificationCenter.default.post( + name: .newConversation, object: nil) + } + .keyboardShortcut("n", modifiers: .command) + } + } + + #if os(macOS) + Settings { + SettingsView() + .modelContainer(for: [ + EnvVar.self, + UserSettings.self, + ]) + .preferredColorScheme(.dark) + } + #endif + } +} + +extension Notification.Name { + static let newConversation = Notification.Name("newConversation") +} diff --git a/apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift b/apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift new file mode 100644 index 0000000..fbbf5e0 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift @@ -0,0 +1,613 @@ +import Foundation +import SwiftData + +/// Represents a live tool call being displayed during streaming +struct LiveToolCall: Identifiable, Sendable { + let id: String // toolCallId + let toolName: String + var arguments: String + var status: String // "running" | "success" | "error" + var output: JSONValue? +} + +/// Main orchestrator for the Omega agentic chat loop. +/// Coordinates between OpenAI, TPMJS registry, and SwiftData persistence. +@MainActor +@Observable +final class ChatOrchestrator { + // MARK: - Published State + + var streamingContent: String = "" + var isStreaming: Bool = false + var liveToolCalls: [LiveToolCall] = [] + var error: String? + + // MARK: - Private State + + private let openAI = OpenAIService() + private let registry = TPMJSRegistryService() + + /// Dynamically loaded tools for the current conversation (sanitizedName -> ToolMeta) + private var loadedTools: [String: ToolMeta] = [:] + + /// Maximum agentic loop iterations (search -> execute -> respond) + private let maxIterations = 10 + + // MARK: - Public API + + /// Send a user message and run the full agentic loop. + /// Streams the response, handles tool calls, and persists everything to SwiftData. + func sendMessage( + _ text: String, + conversation: Conversation, + modelContext: ModelContext + ) async { + // Reset state + streamingContent = "" + isStreaming = true + liveToolCalls = [] + error = nil + + // Get API key + guard let apiKey = KeychainService.load(key: "OPENAI_API_KEY"), !apiKey.isEmpty else { + error = "No OpenAI API key set. Open Settings (Cmd+,) to add your key." + isStreaming = false + return + } + + // Load user settings + let settingsDescriptor = FetchDescriptor() + let settings = (try? modelContext.fetch(settingsDescriptor))?.first + + let model = settings?.selectedModel ?? "gpt-4.1-mini" + let customPrompt = settings?.systemPrompt + let pinnedToolIds = settings?.pinnedToolIds ?? [] + + // Save user message + let userMessage = Message(role: .user, content: text, conversation: conversation) + modelContext.insert(userMessage) + conversation.updatedAt = Date() + conversation.executionState = "running" + try? modelContext.save() + + // Load env vars from Keychain + let envVarDescriptor = FetchDescriptor() + let envVarRecords = (try? modelContext.fetch(envVarDescriptor)) ?? [] + let envVars = KeychainService.loadAllEnvVars(keyNames: envVarRecords.map(\.keyName)) + + // Auto-discover tools via BM25 search + do { + let relevantTools = try await registry.searchTools(query: text, limit: 10) + for toolMeta in relevantTools { + let sanitized = sanitizeToolName(toolMeta.toolId) + if loadedTools[sanitized] == nil { + loadedTools[sanitized] = toolMeta + } + } + } catch { + // Non-fatal: continue without auto-discovered tools + print("Auto-discovery failed: \(error)") + } + + // Build messages array from conversation history + var chatMessages = buildChatMessages( + conversation: conversation, + customPrompt: customPrompt, + pinnedToolIds: pinnedToolIds + ) + + // Add the new user message + chatMessages.append(.user(text)) + + // Build tools list + let tools = buildToolsList() + + // Agentic loop + var iteration = 0 + var allToolCallData: [ToolCallData] = [] + var allToolResultData: [ToolCallData] = [] + var totalInputTokens = 0 + var totalOutputTokens = 0 + + while iteration < maxIterations { + iteration += 1 + + var currentContent = "" + var pendingToolCalls: [ChatToolCall] = [] + var receivedDone = false + + do { + let stream = await openAI.streamCompletion( + apiKey: apiKey, + model: model, + messages: chatMessages, + tools: tools.isEmpty ? nil : tools + ) + + for try await event in stream { + switch event { + case .contentDelta(let delta): + currentContent += delta + streamingContent = currentContent + + case .toolCallStarted(_, let id, let name): + let liveTC = LiveToolCall( + id: id, + toolName: name, + arguments: "", + status: "running" + ) + liveToolCalls.append(liveTC) + + case .toolCallArgumentDelta(let index, let delta): + if index < liveToolCalls.count { + liveToolCalls[index].arguments += delta + } + + case .toolCallComplete(let toolCall): + pendingToolCalls.append(toolCall) + + case .usage(let input, let output): + totalInputTokens += input + totalOutputTokens += output + + case .done: + receivedDone = true + + case .error(let msg): + self.error = msg + } + } + } catch { + self.error = error.localizedDescription + break + } + + // If we got content with no tool calls, we're done + if pendingToolCalls.isEmpty { + streamingContent = currentContent + break + } + + // Process tool calls + // Add assistant message with tool calls to chat history + chatMessages.append(.assistant( + content: currentContent.isEmpty ? nil : currentContent, + toolCalls: pendingToolCalls + )) + + // Execute each tool call + for toolCall in pendingToolCalls { + let tcData = ToolCallData( + toolCallId: toolCall.id, + toolName: toolCall.toolName, + args: .object(toolCall.parsedArguments) + ) + allToolCallData.append(tcData) + + // Record tool run + let record = ToolCallRecord( + toolName: toolCall.toolName, + toolCallId: toolCall.id, + conversation: conversation + ) + record.input = .object(toolCall.parsedArguments) + modelContext.insert(record) + + let result = await executeToolCall( + toolCall: toolCall, + envVars: envVars + ) + + // Update live tool call status + if let idx = liveToolCalls.firstIndex(where: { $0.id == toolCall.id }) { + liveToolCalls[idx].status = result.isError ? "error" : "success" + liveToolCalls[idx].output = result.output + } + + // Update record + record.output = result.output + record.status = result.isError ? "error" : "success" + record.completedAt = Date() + + // Add tool result to chat messages + let resultJSON: String + if let data = try? JSONEncoder().encode(result.output) { + resultJSON = String(data: data, encoding: .utf8) ?? "{}" + } else { + resultJSON = "{}" + } + + chatMessages.append(.toolResult( + toolCallId: toolCall.id, + name: toolCall.toolName, + content: resultJSON + )) + + let trData = ToolCallData( + toolCallId: toolCall.id, + toolName: toolCall.toolName, + args: .object(toolCall.parsedArguments), + output: result.output + ) + allToolResultData.append(trData) + } + + // Reset streaming for next iteration + streamingContent = "" + liveToolCalls = [] + } + + // Save assistant message + let assistantMessage = Message( + role: .assistant, + content: streamingContent, + conversation: conversation, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + toolCalls: allToolCallData.isEmpty ? nil : allToolCallData + ) + modelContext.insert(assistantMessage) + + // Save tool results as a TOOL message if we had tool calls + if !allToolResultData.isEmpty { + let toolMessage = Message( + role: .tool, + content: "Tool results", + conversation: conversation, + toolCalls: allToolResultData + ) + modelContext.insert(toolMessage) + } + + // Update conversation + conversation.executionState = "idle" + conversation.inputTokensTotal += totalInputTokens + conversation.outputTokensTotal += totalOutputTokens + conversation.updatedAt = Date() + + // Auto-title from first message + if conversation.title == nil { + let title = text.count > 50 ? String(text.prefix(50)) + "..." : text + conversation.title = title + } + + try? modelContext.save() + + isStreaming = false + } + + /// Clear loaded tools (when switching conversations) + func resetConversation() { + loadedTools = [:] + streamingContent = "" + isStreaming = false + liveToolCalls = [] + error = nil + } + + // MARK: - Private Helpers + + private struct ToolResult { + let output: JSONValue + let isError: Bool + } + + private func executeToolCall( + toolCall: ChatToolCall, + envVars: [String: String] + ) async -> ToolResult { + let name = toolCall.toolName + let args = toolCall.parsedArguments + + // Handle registrySearch + if name == "registrySearch" { + return await handleRegistrySearch(args: args) + } + + // Handle registryExecute + if name == "registryExecute" { + return await handleRegistryExecute(args: args, envVars: envVars) + } + + // Handle dynamic tools (loaded from search) + if let toolMeta = loadedTools[name] { + return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars) + } + + // Also check by finding the tool ID from the sanitized name + if let toolId = findToolId(sanitizedName: name, in: loadedTools), + let toolMeta = loadedTools.values.first(where: { $0.toolId == toolId }) { + return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars) + } + + return ToolResult( + output: .object([ + "error": .bool(true), + "message": .string("Unknown tool: \(name)"), + ]), + isError: true + ) + } + + private func handleRegistrySearch(args: [String: JSONValue]) async -> ToolResult { + guard case .string(let query) = args["query"] else { + return ToolResult( + output: .object(["error": .bool(true), "message": .string("Missing 'query' parameter")]), + isError: true + ) + } + + let limit: Int + if case .number(let n) = args["limit"] { + limit = Int(n) + } else { + limit = 5 + } + + do { + let tools = try await registry.searchTools(query: query, limit: limit) + + // Inject found tools into loaded tools + for toolMeta in tools { + let sanitized = sanitizeToolName(toolMeta.toolId) + if loadedTools[sanitized] == nil { + loadedTools[sanitized] = toolMeta + } + } + + let toolsJSON: [JSONValue] = tools.map { t in + .object([ + "toolId": .string(t.toolId), + "name": .string(t.name), + "package": .string(t.packageName), + "description": .string(t.description), + ]) + } + + return ToolResult( + output: .object([ + "query": .string(query), + "matchCount": .number(Double(tools.count)), + "tools": .array(toolsJSON), + ]), + isError: false + ) + } catch { + return ToolResult( + output: .object([ + "error": .bool(true), + "message": .string(error.localizedDescription), + ]), + isError: true + ) + } + } + + private func handleRegistryExecute( + args: [String: JSONValue], + envVars: [String: String] + ) async -> ToolResult { + guard case .string(let toolId) = args["toolId"] else { + return ToolResult( + output: .object(["error": .bool(true), "message": .string("Missing 'toolId' parameter")]), + isError: true + ) + } + + let params = args["params"] ?? .object([:]) + + do { + let response = try await registry.executeByToolId( + toolId: toolId, + params: params, + env: envVars + ) + + if response.success { + return ToolResult( + output: .object([ + "toolId": .string(toolId), + "executionTimeMs": .number(Double(response.executionTimeMs ?? 0)), + "output": response.output ?? .null, + ]), + isError: false + ) + } else { + return ToolResult( + output: .object([ + "error": .bool(true), + "message": .string(response.error ?? "Tool execution failed"), + "toolId": .string(toolId), + ]), + isError: true + ) + } + } catch { + return ToolResult( + output: .object([ + "error": .bool(true), + "message": .string(error.localizedDescription), + "toolId": .string(toolId), + ]), + isError: true + ) + } + } + + private func handleDynamicTool( + meta: ToolMeta, + args: [String: JSONValue], + envVars: [String: String] + ) async -> ToolResult { + do { + let response = try await registry.executeTool( + packageName: meta.packageName, + name: meta.name, + version: meta.version, + importUrl: meta.importUrl, + params: .object(args), + env: envVars + ) + + if response.success { + return ToolResult( + output: response.output ?? .null, + isError: false + ) + } else { + return ToolResult( + output: .object([ + "error": .bool(true), + "message": .string(response.error ?? "Tool execution failed"), + "toolId": .string(meta.toolId), + ]), + isError: true + ) + } + } catch { + return ToolResult( + output: .object([ + "error": .bool(true), + "message": .string(error.localizedDescription), + "toolId": .string(meta.toolId), + ]), + isError: true + ) + } + } + + /// Build chat messages from conversation history + private func buildChatMessages( + conversation: Conversation, + customPrompt: String?, + pinnedToolIds: [String] + ) -> [ChatMessage] { + var messages: [ChatMessage] = [] + + // System prompt + let systemPrompt = SystemPromptBuilder.build( + customSystemPrompt: customPrompt, + pinnedToolIds: pinnedToolIds, + loadedTools: loadedTools + ) + messages.append(.system(systemPrompt)) + + // Last 20 messages from conversation history + let sorted = conversation.sortedMessages + let recent = sorted.suffix(20) + + for msg in recent { + switch msg.role { + case .user: + messages.append(.user(msg.content)) + + case .assistant: + let toolCalls = msg.toolCalls + if !toolCalls.isEmpty { + let chatToolCalls = toolCalls.map { tc in + ChatToolCall( + id: tc.toolCallId, + type: "function", + function: ChatToolCallFunction( + name: tc.toolName, + arguments: { + if let args = tc.args, + let data = try? JSONEncoder().encode(args) { + return String(data: data, encoding: .utf8) ?? "{}" + } + return "{}" + }() + ) + ) + } + messages.append(.assistant(content: msg.content, toolCalls: chatToolCalls)) + } else { + messages.append(.assistant(content: msg.content, toolCalls: nil)) + } + + case .tool: + for tc in msg.toolCalls { + let outputJSON: String + if let output = tc.output, + let data = try? JSONEncoder().encode(output) { + outputJSON = String(data: data, encoding: .utf8) ?? "{}" + } else { + outputJSON = "{}" + } + messages.append(.toolResult( + toolCallId: tc.toolCallId, + name: tc.toolName, + content: outputJSON + )) + } + + case .system: + break + } + } + + return messages + } + + /// Build the OpenAI tools array from static + dynamic tools + private func buildToolsList() -> [ChatTool] { + var tools: [ChatTool] = [] + + // Static: registrySearch + tools.append(ChatTool( + function: ChatFunction( + name: "registrySearch", + description: "Search the TPMJS tool registry to find AI SDK tools. Use this to discover tools for any task. Returns toolIds that can be executed with registryExecute.", + parameters: JSONSchemaObject( + type: "object", + properties: [ + "query": JSONSchemaProperty( + type: "string", + description: "Search query (keywords, tool names, descriptions)" + ), + "limit": JSONSchemaProperty( + type: "number", + description: "Maximum number of results (1-20, default 5)", + minimum: 1, + maximum: 20 + ), + ], + required: ["query"], + additionalProperties: false + ) + ) + )) + + // Static: registryExecute + tools.append(ChatTool( + function: ChatFunction( + name: "registryExecute", + description: "Execute a tool from the TPMJS registry. Use registrySearch first to find the toolId. Tools run in a secure sandbox.", + parameters: JSONSchemaObject( + type: "object", + properties: [ + "toolId": JSONSchemaProperty( + type: "string", + description: "Tool identifier from registrySearch (format: 'package::name')" + ), + "params": JSONSchemaProperty( + type: "object", + description: "Parameters to pass to the tool", + additionalProperties: .bool(true) + ), + ], + required: ["toolId", "params"], + additionalProperties: false + ) + ) + )) + + // Dynamic tools + for (_, meta) in loadedTools { + tools.append(meta.toChatTool()) + } + + return tools + } +} diff --git a/apps/omega-mac/OmegaMac/Services/KeychainService.swift b/apps/omega-mac/OmegaMac/Services/KeychainService.swift new file mode 100644 index 0000000..02627d9 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Services/KeychainService.swift @@ -0,0 +1,124 @@ +import Foundation +import Security + +/// Wrapper around macOS Keychain for storing API keys and env var values securely. +enum KeychainService { + private static let serviceName = "com.tpmjs.omega-mac" + + /// Save or update a value in the Keychain + static func save(key: String, value: String) throws { + guard let data = value.data(using: .utf8) else { + throw KeychainError.encodingFailed + } + + // Check if item exists + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + ] + + let status = SecItemCopyMatching(query as CFDictionary, nil) + + if status == errSecSuccess { + // Update existing + let attributes: [String: Any] = [ + kSecValueData as String: data, + ] + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + guard updateStatus == errSecSuccess else { + throw KeychainError.unhandledError(updateStatus) + } + } else if status == errSecItemNotFound { + // Add new + var addQuery = query + addQuery[kSecValueData as String] = data + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw KeychainError.unhandledError(addStatus) + } + } else { + throw KeychainError.unhandledError(status) + } + } + + /// Retrieve a value from the Keychain + static func load(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + return value + } + + /// Delete a value from the Keychain + static func delete(key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unhandledError(status) + } + } + + /// Load all stored keys (returns key names only, not values) + static func allKeys() -> [String] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let items = result as? [[String: Any]] else { + return [] + } + + return items.compactMap { $0[kSecAttrAccount as String] as? String } + } + + /// Convenience: load all env vars as a dictionary + static func loadAllEnvVars(keyNames: [String]) -> [String: String] { + var result: [String: String] = [:] + for key in keyNames { + if let value = load(key: key) { + result[key] = value + } + } + return result + } +} + +enum KeychainError: LocalizedError { + case encodingFailed + case unhandledError(OSStatus) + + var errorDescription: String? { + switch self { + case .encodingFailed: + return "Failed to encode value for Keychain" + case .unhandledError(let status): + return "Keychain error: \(status)" + } + } +} diff --git a/apps/omega-mac/OmegaMac/Services/OpenAIService.swift b/apps/omega-mac/OmegaMac/Services/OpenAIService.swift new file mode 100644 index 0000000..2042707 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Services/OpenAIService.swift @@ -0,0 +1,141 @@ +import Foundation + +/// Actor that handles all communication with the OpenAI Chat Completions API. +/// Supports streaming via Server-Sent Events (SSE). +actor OpenAIService { + private let session: URLSession + + init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 300 + config.timeoutIntervalForResource = 300 + self.session = URLSession(configuration: config) + } + + /// Stream a chat completion, yielding parsed events as they arrive. + func streamCompletion( + apiKey: String, + model: String, + messages: [ChatMessage], + tools: [ChatTool]? + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let request = try buildRequest( + apiKey: apiKey, + model: model, + messages: messages, + tools: tools, + stream: true + ) + + let (bytes, response) = try await session.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + continuation.yield(.error("Invalid response type")) + continuation.finish() + return + } + + guard httpResponse.statusCode == 200 else { + // Try to read error body + var errorBody = "" + for try await line in bytes.lines { + errorBody += line + } + continuation.yield(.error("API error \(httpResponse.statusCode): \(errorBody)")) + continuation.finish() + return + } + + // Track accumulated tool calls + var toolCallAccumulators: [Int: StreamParser.ToolCallAccumulator] = [:] + + for try await line in bytes.lines { + let events = StreamParser.parseLine(line) + for event in events { + switch event { + case .toolCallStarted(let index, let id, let name): + toolCallAccumulators[index] = StreamParser.ToolCallAccumulator( + id: id, + name: name, + arguments: "" + ) + continuation.yield(event) + + case .toolCallArgumentDelta(let index, let delta): + toolCallAccumulators[index]?.arguments += delta + continuation.yield(event) + + case .done: + // Emit completed tool calls + for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) { + let toolCall = ChatToolCall( + id: acc.id, + type: "function", + function: ChatToolCallFunction( + name: acc.name, + arguments: acc.arguments + ) + ) + continuation.yield(.toolCallComplete(toolCall)) + } + continuation.yield(.done) + continuation.finish() + + default: + continuation.yield(event) + } + } + } + + // If we reach here without [DONE], still emit completed tool calls + if !toolCallAccumulators.isEmpty { + for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) { + let toolCall = ChatToolCall( + id: acc.id, + type: "function", + function: ChatToolCallFunction( + name: acc.name, + arguments: acc.arguments + ) + ) + continuation.yield(.toolCallComplete(toolCall)) + } + } + continuation.finish() + + } catch { + continuation.yield(.error(error.localizedDescription)) + continuation.finish(throwing: error) + } + } + } + } + + private func buildRequest( + apiKey: String, + model: String, + messages: [ChatMessage], + tools: [ChatTool]?, + stream: Bool + ) throws -> URLRequest { + var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!) + request.httpMethod = "POST" + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = ChatCompletionRequest( + model: model, + messages: messages, + tools: tools?.isEmpty == true ? nil : tools, + stream: stream, + maxTokens: 4096, + streamOptions: stream ? StreamOptions(includeUsage: true) : nil + ) + + request.httpBody = try JSONEncoder().encode(body) + return request + } +} diff --git a/apps/omega-mac/OmegaMac/Services/OpenAITypes.swift b/apps/omega-mac/OmegaMac/Services/OpenAITypes.swift new file mode 100644 index 0000000..522c75c --- /dev/null +++ b/apps/omega-mac/OmegaMac/Services/OpenAITypes.swift @@ -0,0 +1,209 @@ +import Foundation + +// MARK: - Request Types + +struct ChatCompletionRequest: Encodable { + let model: String + let messages: [ChatMessage] + let tools: [ChatTool]? + let stream: Bool + let maxTokens: Int? + let streamOptions: StreamOptions? + + enum CodingKeys: String, CodingKey { + case model, messages, tools, stream + case maxTokens = "max_tokens" + case streamOptions = "stream_options" + } +} + +struct StreamOptions: Encodable { + let includeUsage: Bool + + enum CodingKeys: String, CodingKey { + case includeUsage = "include_usage" + } +} + +struct ChatMessage: Codable { + let role: String + let content: String? + let toolCalls: [ChatToolCall]? + let toolCallId: String? + let name: String? + + enum CodingKeys: String, CodingKey { + case role, content, name + case toolCalls = "tool_calls" + case toolCallId = "tool_call_id" + } + + static func system(_ content: String) -> ChatMessage { + ChatMessage(role: "system", content: content, toolCalls: nil, toolCallId: nil, name: nil) + } + + static func user(_ content: String) -> ChatMessage { + ChatMessage(role: "user", content: content, toolCalls: nil, toolCallId: nil, name: nil) + } + + static func assistant(content: String?, toolCalls: [ChatToolCall]?) -> ChatMessage { + ChatMessage(role: "assistant", content: content, toolCalls: toolCalls, toolCallId: nil, name: nil) + } + + static func toolResult(toolCallId: String, name: String, content: String) -> ChatMessage { + ChatMessage(role: "tool", content: content, toolCalls: nil, toolCallId: toolCallId, name: name) + } +} + +struct ChatTool: Encodable { + let type: String = "function" + let function: ChatFunction +} + +struct ChatFunction: Encodable { + let name: String + let description: String + let parameters: JSONSchemaObject +} + +struct JSONSchemaObject: Encodable { + let type: String + let properties: [String: JSONSchemaProperty] + let required: [String]? + let additionalProperties: Bool? +} + +struct JSONSchemaProperty: Encodable { + let type: String + let description: String? + let minimum: Int? + let maximum: Int? + let additionalProperties: JSONSchemaAdditional? + + init(type: String, description: String? = nil, minimum: Int? = nil, maximum: Int? = nil, additionalProperties: JSONSchemaAdditional? = nil) { + self.type = type + self.description = description + self.minimum = minimum + self.maximum = maximum + self.additionalProperties = additionalProperties + } +} + +enum JSONSchemaAdditional: Encodable { + case bool(Bool) + case typed(JSONSchemaProperty) + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .bool(let b): try container.encode(b) + case .typed(let p): try container.encode(p) + } + } +} + +struct ChatToolCall: Codable, Identifiable { + var id: String + let type: String? + let function: ChatToolCallFunction? + + var toolName: String { function?.name ?? "" } + var arguments: String { function?.arguments ?? "{}" } + + var parsedArguments: [String: JSONValue] { + guard let data = arguments.data(using: .utf8), + let obj = try? JSONDecoder().decode([String: JSONValue].self, from: data) else { + return [:] + } + return obj + } +} + +struct ChatToolCallFunction: Codable { + let name: String? + let arguments: String? +} + +// MARK: - Response Types (non-streaming) + +struct ChatCompletionResponse: Decodable { + let id: String + let choices: [ChatChoice] + let usage: ChatUsage? +} + +struct ChatChoice: Decodable { + let index: Int + let message: ChatResponseMessage + let finishReason: String? + + enum CodingKeys: String, CodingKey { + case index, message + case finishReason = "finish_reason" + } +} + +struct ChatResponseMessage: Decodable { + let role: String + let content: String? + let toolCalls: [ChatToolCall]? + + enum CodingKeys: String, CodingKey { + case role, content + case toolCalls = "tool_calls" + } +} + +struct ChatUsage: Decodable { + let promptTokens: Int? + let completionTokens: Int? + let totalTokens: Int? + + enum CodingKeys: String, CodingKey { + case promptTokens = "prompt_tokens" + case completionTokens = "completion_tokens" + case totalTokens = "total_tokens" + } +} + +// MARK: - Streaming Types + +struct ChatCompletionChunk: Decodable { + let id: String? + let choices: [ChunkChoice]? + let usage: ChatUsage? +} + +struct ChunkChoice: Decodable { + let index: Int? + let delta: ChunkDelta? + let finishReason: String? + + enum CodingKeys: String, CodingKey { + case index, delta + case finishReason = "finish_reason" + } +} + +struct ChunkDelta: Decodable { + let role: String? + let content: String? + let toolCalls: [ChunkToolCall]? + + enum CodingKeys: String, CodingKey { + case role, content + case toolCalls = "tool_calls" + } +} + +struct ChunkToolCall: Decodable { + let index: Int? + let id: String? + let type: String? + let function: ChunkToolCallFunction? +} + +struct ChunkToolCallFunction: Decodable { + let name: String? + let arguments: String? +} diff --git a/apps/omega-mac/OmegaMac/Services/StreamParser.swift b/apps/omega-mac/OmegaMac/Services/StreamParser.swift new file mode 100644 index 0000000..32ee631 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Services/StreamParser.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Parses Server-Sent Events (SSE) from OpenAI's streaming API. +/// Handles `data: {...}` lines and `data: [DONE]` termination. +struct StreamParser { + + /// Accumulated tool call state during streaming + struct ToolCallAccumulator { + var id: String = "" + var name: String = "" + var arguments: String = "" + } + + /// Result of parsing the stream - yields content deltas and complete tool calls + enum StreamEvent: Sendable { + case contentDelta(String) + case toolCallStarted(index: Int, id: String, name: String) + case toolCallArgumentDelta(index: Int, delta: String) + case toolCallComplete(ChatToolCall) + case usage(inputTokens: Int, outputTokens: Int) + case done + case error(String) + } + + /// Parse a single SSE line and return events + static func parseLine(_ line: String) -> [StreamEvent] { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + + // Skip empty lines and comments + guard !trimmed.isEmpty, !trimmed.hasPrefix(":") else { + return [] + } + + // Must start with "data: " + guard trimmed.hasPrefix("data: ") else { + return [] + } + + let payload = String(trimmed.dropFirst(6)) + + // Check for stream end + if payload == "[DONE]" { + return [.done] + } + + // Parse JSON chunk + guard let data = payload.data(using: .utf8) else { + return [.error("Invalid UTF-8 in SSE payload")] + } + + do { + let chunk = try JSONDecoder().decode(ChatCompletionChunk.self, from: data) + return processChunk(chunk) + } catch { + return [.error("Failed to parse chunk: \(error.localizedDescription)")] + } + } + + private static func processChunk(_ chunk: ChatCompletionChunk) -> [StreamEvent] { + var events: [StreamEvent] = [] + + if let choices = chunk.choices { + for choice in choices { + guard let delta = choice.delta else { continue } + + // Content delta + if let content = delta.content, !content.isEmpty { + events.append(.contentDelta(content)) + } + + // Tool calls + if let toolCalls = delta.toolCalls { + for tc in toolCalls { + let idx = tc.index ?? 0 + if let id = tc.id, !id.isEmpty { + events.append(.toolCallStarted( + index: idx, + id: id, + name: tc.function?.name ?? "" + )) + } + if let args = tc.function?.arguments, !args.isEmpty { + events.append(.toolCallArgumentDelta(index: idx, delta: args)) + } + } + } + + // Finish reason + if choice.finishReason == "stop" || choice.finishReason == "tool_calls" { + // Will be handled by [DONE] + } + } + } + + // Usage info (sometimes included in last chunk) + if let usage = chunk.usage { + events.append(.usage( + inputTokens: usage.promptTokens ?? 0, + outputTokens: usage.completionTokens ?? 0 + )) + } + + return events + } +} diff --git a/apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift b/apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift new file mode 100644 index 0000000..4a92312 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift @@ -0,0 +1,156 @@ +import Foundation + +/// Actor that handles communication with the TPMJS tool registry API +/// and the remote executor service. +actor TPMJSRegistryService { + private let session: URLSession + private let registryBaseURL: String + private let executorBaseURL: String + + init( + registryBaseURL: String = "https://tpmjs.com", + executorBaseURL: String = "https://executor.tpmjs.com" + ) { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 60 + self.session = URLSession(configuration: config) + self.registryBaseURL = registryBaseURL + self.executorBaseURL = executorBaseURL + } + + // MARK: - Search + + /// Search for tools matching a query using BM25 + func searchTools(query: String, limit: Int = 10) async throws -> [ToolMeta] { + var components = URLComponents(string: "\(registryBaseURL)/api/tools/search")! + components.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "limit", value: String(limit)), + ] + + guard let url = components.url else { + throw TPMJSError.invalidURL + } + + let (data, response) = try await session.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + return [] + } + + let searchResponse = try JSONDecoder().decode(TPMJSSearchResponse.self, from: data) + let tools = searchResponse.results?.tools ?? [] + + return tools.map { tool in + ToolMeta( + toolId: "\(tool.package.npmPackageName)::\(tool.name)", + packageName: tool.package.npmPackageName, + name: tool.name, + description: tool.description ?? "Tool: \(tool.name)", + version: tool.package.npmVersion, + importUrl: "https://esm.sh/\(tool.package.npmPackageName)@\(tool.package.npmVersion)", + inputSchema: tool.inputSchema, + env: tool.package.env + ) + } + } + + // MARK: - Execute via Executor + + /// Execute a tool via the TPMJS remote sandbox executor + func executeTool( + packageName: String, + name: String, + version: String, + importUrl: String, + params: JSONValue, + env: [String: String] + ) async throws -> TPMJSExecuteResponse { + guard let url = URL(string: "\(executorBaseURL)/execute-tool") else { + throw TPMJSError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = TPMJSExecuteRequest( + packageName: packageName, + name: name, + version: version, + importUrl: importUrl, + params: params, + env: env + ) + + request.httpBody = try JSONEncoder().encode(body) + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw TPMJSError.httpError(statusCode) + } + + return try JSONDecoder().decode(TPMJSExecuteResponse.self, from: data) + } + + // MARK: - Registry Execute (uses search first to find metadata) + + /// Execute a tool by its toolId (package::name format). + /// Fetches metadata first via search, then executes via executor. + func executeByToolId( + toolId: String, + params: JSONValue, + env: [String: String] + ) async throws -> TPMJSExecuteResponse { + // Parse toolId format: "package::name" + guard let separatorIndex = toolId.range(of: "::", options: .backwards) else { + throw TPMJSError.invalidToolId(toolId) + } + + let packageName = String(toolId[toolId.startIndex.. ChatTool { + let properties: [String: JSONSchemaProperty] + let required: [String]? + + if case .object(let schemaObj) = inputSchema { + // Extract properties from schema + var props: [String: JSONSchemaProperty] = [:] + var reqs: [String] = [] + + if case .object(let propsObj) = schemaObj["properties"] { + for (key, value) in propsObj { + if case .object(let propDef) = value { + let typeStr: String + if case .string(let t) = propDef["type"] { + typeStr = t + } else { + typeStr = "string" + } + let desc: String? + if case .string(let d) = propDef["description"] { + desc = d + } else { + desc = nil + } + props[key] = JSONSchemaProperty(type: typeStr, description: desc) + } + } + } + + if case .array(let reqArr) = schemaObj["required"] { + for item in reqArr { + if case .string(let s) = item { + reqs.append(s) + } + } + } + + properties = props + required = reqs.isEmpty ? nil : reqs + } else { + properties = [:] + required = nil + } + + return ChatTool( + function: ChatFunction( + name: sanitizeToolName(toolId), + description: description, + parameters: JSONSchemaObject( + type: "object", + properties: properties, + required: required, + additionalProperties: true + ) + ) + ) + } +} diff --git a/apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift b/apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift new file mode 100644 index 0000000..391d806 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Sanitize a tool ID to be a valid OpenAI function name. +/// Port of the web's sanitizeToolName logic. +/// OpenAI requires tool names to be <= 64 characters and match [a-zA-Z0-9_-]. +func sanitizeToolName(_ name: String) -> String { + var sanitized = name + .replacingOccurrences(of: "@", with: "") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "-", with: "_") + .replacingOccurrences(of: "::", with: "_") + + // Remove any remaining invalid characters + sanitized = String(sanitized.unicodeScalars.filter { scalar in + CharacterSet.alphanumerics.contains(scalar) || scalar == "_" + }) + + // OpenAI API requires tool names <= 64 characters + if sanitized.count <= 64 { + return sanitized + } + + // Truncate but try to keep the meaningful part (tool name at the end) + let last64 = String(sanitized.suffix(64)) + if let first = last64.first, first.isLetter { + return last64 + } + return String(sanitized.prefix(64)) +} + +/// Reverse lookup: find the original toolId from a sanitized name +/// by checking against loaded tool metadata. +func findToolId(sanitizedName: String, in tools: [String: ToolMeta]) -> String? { + for (_, meta) in tools { + if sanitizeToolName(meta.toolId) == sanitizedName { + return meta.toolId + } + } + return nil +} diff --git a/apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift b/apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift new file mode 100644 index 0000000..0177b07 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift @@ -0,0 +1,134 @@ +import Foundation + +/// Builds the system prompt for the Omega agent. +/// Port of the web's buildSystemPrompt logic from system-prompt.ts. +enum SystemPromptBuilder { + + static let basePrompt = """ + You are Omega, an AI assistant powered by the TPMJS tool registry - a collection of 1M+ AI-ready tools. + + ## Core Tools + + You have access to two powerful meta-tools that give you access to the entire TPMJS registry: + + 1. **registrySearch** - Search for tools by keyword, category, or description + 2. **registryExecute** - Execute any tool by its toolId + + These tools are importable by users into their own AI agents via: + ```typescript + import { registrySearchTool } from '@tpmjs/registry-search'; + import { registryExecuteTool } from '@tpmjs/registry-execute'; + ``` + + ## How It Works + + 1. When the user asks for something, relevant tools are automatically discovered and loaded + 2. You can also explicitly search using registrySearch + 3. Once tools are found, you have two options: + - Use registryExecute with the toolId to execute any tool + - Call dynamically loaded tools directly by their sanitized name + + ## Workflow Examples + + ### Example 1: User wants weather data + 1. Call registrySearch({ query: "weather api" }) + 2. Review the results (toolIds like "@weather-api/sdk::getWeather") + 3. Call registryExecute({ toolId: "@weather-api/sdk::getWeather", params: { city: "Tokyo" } }) + 4. Explain the result to the user + + ### Example 2: Tool already loaded + If you see a tool like "weatherapi_sdk_getWeather" in the dynamically loaded tools list, call it directly instead of using registryExecute. + + ## Best Practices + + - **Search first** - If you don't see a relevant tool loaded, use registrySearch + - **Execute don't describe** - Actually call tools to get real results + - **Handle errors** - If a tool fails, explain and try an alternative + - **Be efficient** - If a tool is already loaded, call it directly + + ## Response Style + + - Keep responses concise and helpful + - Present tool outputs in a clear, readable format + - Tell the user which tool you used + - Offer to do more if the user might need it + + Remember: Your value is in EXECUTING tools to get real results, not describing what tools could do. + """ + + /// Build the complete system prompt with tool listings and user customizations + static func build( + customSystemPrompt: String?, + pinnedToolIds: [String], + loadedTools: [String: ToolMeta] + ) -> String { + var parts: [String] = [basePrompt] + + // Pinned tools + if !pinnedToolIds.isEmpty { + let pinned = pinnedToolIds.map { "- Tool ID: \($0)" }.joined(separator: "\n") + parts.append(""" + ## Pinned Tools + + The user has pinned the following tools as favorites. Consider using these first when they match the task: + \(pinned) + """) + } + + // Custom system prompt + if let custom = customSystemPrompt, !custom.isEmpty { + parts.append(""" + ## User Instructions + + The user has provided the following custom instructions: + + \(custom) + """) + } + + // Static tools + let staticToolsList = """ + - registrySearch: Search the TPMJS registry to find AI SDK tools by keyword. Returns toolIds for registryExecute. + - registryExecute: Execute any tool from the TPMJS registry by toolId. Use registrySearch first to find tools. + """ + + parts.append(""" + ## Static Tools (Always Available) + + These tools let you access the entire TPMJS registry of 1M+ tools: + + \(staticToolsList) + """) + + // Dynamic tools + let dynamicToolsList: String + if loadedTools.isEmpty { + dynamicToolsList = "No tools loaded yet. Use registrySearch to find tools, or they will be auto-loaded based on your requests." + } else { + dynamicToolsList = loadedTools.map { (name, meta) in + "- \(name): \(meta.description)" + }.joined(separator: "\n") + } + + parts.append(""" + ## Dynamically Loaded Tools + + These tools have been discovered and loaded for this conversation. Call them directly: + + \(dynamicToolsList) + """) + + // Usage instructions + parts.append(""" + ## How to Use Tools + + 1. **To find a tool**: Use registrySearch with a keyword (e.g., "weather", "web scraping", "database") + 2. **To execute a found tool**: Use registryExecute with the toolId returned from search + 3. **Direct execution**: If a tool is already loaded above, call it directly by name + + Remember: Your value is in EXECUTING tools to get real results, not just describing what tools could do. + """) + + return parts.joined(separator: "\n\n") + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift b/apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift new file mode 100644 index 0000000..8697733 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct ChatInputBar: View { + @Binding var text: String + let isStreaming: Bool + let onSend: () -> Void + + var body: some View { + VStack(spacing: 4) { + Divider() + + HStack(alignment: .bottom, spacing: 8) { + TextEditor(text: $text) + .font(.body) + .scrollContentBackground(.hidden) + .padding(8) + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .frame(minHeight: 40, maxHeight: 160) + .fixedSize(horizontal: false, vertical: true) + .onKeyPress(.return, phases: .down) { keyPress in + if keyPress.modifiers.contains(.shift) { + return .ignored // Let shift+enter add newline + } + if canSend { + onSend() + return .handled + } + return .ignored + } + + Button(action: onSend) { + Image(systemName: "arrow.up.circle.fill") + .font(.title2) + .foregroundStyle(canSend ? .accent : .tertiary) + } + .buttonStyle(.borderless) + .disabled(!canSend) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + + Text("Enter to send, Shift+Enter for new line") + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.bottom, 4) + } + .background(.background) + } + + private var canSend: Bool { + !isStreaming && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift b/apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift new file mode 100644 index 0000000..6a3707a --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift @@ -0,0 +1,68 @@ +import SwiftData +import SwiftUI + +struct ChatView: View { + @Bindable var conversation: Conversation + var orchestrator: ChatOrchestrator + @Environment(\.modelContext) private var modelContext + + @State private var inputText: String = "" + + var body: some View { + VStack(spacing: 0) { + // Messages + MessageList( + conversation: conversation, + streamingContent: orchestrator.streamingContent, + isStreaming: orchestrator.isStreaming, + liveToolCalls: orchestrator.liveToolCalls + ) + + // Error banner + if let error = orchestrator.error { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(error) + .font(.callout) + .foregroundStyle(.red) + Spacer() + Button("Dismiss") { + orchestrator.error = nil + } + .buttonStyle(.borderless) + .font(.callout) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.red.opacity(0.1)) + } + + // Input bar + ChatInputBar( + text: $inputText, + isStreaming: orchestrator.isStreaming, + onSend: sendMessage + ) + } + .navigationTitle(conversation.displayTitle) + .toolbar { + ToolbarItem(placement: .automatic) { + if orchestrator.isStreaming { + ProgressView() + .controlSize(.small) + } + } + } + } + + private func sendMessage() { + let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + inputText = "" + + Task { + await orchestrator.sendMessage(text, conversation: conversation, modelContext: modelContext) + } + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift b/apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift new file mode 100644 index 0000000..0e61f81 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct MessageBubble: View { + let message: Message + + var body: some View { + switch message.role { + case .user: + userBubble + case .assistant: + assistantBubble + case .tool: + toolResultsBubble + case .system: + EmptyView() + } + } + + private var userBubble: some View { + HStack(alignment: .top) { + Spacer(minLength: 60) + Text(message.content) + .font(.body) + .foregroundStyle(.white) + .padding(12) + .background(.accent) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .textSelection(.enabled) + } + .padding(.horizontal, 16) + } + + private var assistantBubble: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + if !message.content.isEmpty { + MarkdownView(content: message.content) + } + + // Token usage + if let input = message.inputTokens, let output = message.outputTokens { + Text("In: \(input) | Out: \(output)") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + .padding(.top, 4) + } + } + .padding(12) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .textSelection(.enabled) + + Spacer(minLength: 60) + } + .padding(.horizontal, 16) + } + + private var toolResultsBubble: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 8) { + ForEach(message.toolCalls) { tc in + JSONToolResultView(toolCallData: tc) + } + } + Spacer(minLength: 60) + } + .padding(.horizontal, 16) + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift b/apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift new file mode 100644 index 0000000..5caba91 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift @@ -0,0 +1,95 @@ +import SwiftUI + +struct MessageList: View { + let conversation: Conversation + let streamingContent: String + let isStreaming: Bool + let liveToolCalls: [LiveToolCall] + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 12) { + ForEach(conversation.sortedMessages) { message in + MessageBubble(message: message) + .id(message.id) + } + + // Live tool calls + ForEach(liveToolCalls) { tc in + ToolCallView(toolCall: tc) + .id("live-tc-\(tc.id)") + } + + // Streaming content + if !streamingContent.isEmpty { + HStack(alignment: .top) { + assistantBubble(content: streamingContent, isStreaming: true) + Spacer(minLength: 60) + } + .padding(.horizontal, 16) + .id("streaming") + } + + // Thinking indicator + if isStreaming && streamingContent.isEmpty && liveToolCalls.isEmpty { + HStack { + StreamingIndicator() + Spacer() + } + .padding(.horizontal, 16) + .id("thinking") + } + + // Bottom spacer for scroll padding + Color.clear.frame(height: 8) + .id("bottom") + } + .padding(.vertical, 12) + } + .onChange(of: streamingContent) { + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + .onChange(of: conversation.messages.count) { + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + .onChange(of: liveToolCalls.count) { + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + } + } + + private func assistantBubble(content: String, isStreaming: Bool) -> some View { + VStack(alignment: .leading, spacing: 4) { + MarkdownView(content: content) + + if isStreaming { + Rectangle() + .fill(.accent) + .frame(width: 2, height: 16) + .opacity(0.8) + .modifier(PulseAnimation()) + } + } + .padding(12) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +private struct PulseAnimation: ViewModifier { + @State private var isAnimating = false + + func body(content: Content) -> some View { + content + .opacity(isAnimating ? 0.3 : 1.0) + .animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: isAnimating) + .onAppear { isAnimating = true } + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift b/apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift new file mode 100644 index 0000000..2ee907e --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct StreamingIndicator: View { + @State private var dotCount = 0 + private let timer = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect() + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .foregroundStyle(.accent) + .font(.caption) + + Text("Omega is thinking" + String(repeating: ".", count: dotCount)) + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(12) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .onReceive(timer) { _ in + dotCount = (dotCount + 1) % 4 + } + } +} diff --git a/apps/omega-mac/OmegaMac/Views/ContentView.swift b/apps/omega-mac/OmegaMac/Views/ContentView.swift new file mode 100644 index 0000000..95d12e9 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/ContentView.swift @@ -0,0 +1,39 @@ +import SwiftData +import SwiftUI + +struct ContentView: View { + @Environment(\.modelContext) private var modelContext + @State private var selectedConversation: Conversation? + @State private var orchestrator = ChatOrchestrator() + + var body: some View { + NavigationSplitView { + SidebarView( + selectedConversation: $selectedConversation, + onNewConversation: createNewConversation + ) + .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 340) + } detail: { + if let conversation = selectedConversation { + ChatView(conversation: conversation, orchestrator: orchestrator) + } else { + EmptyStateView(onNewConversation: createNewConversation) + } + } + .navigationSplitViewStyle(.balanced) + .onChange(of: selectedConversation) { + orchestrator.resetConversation() + } + .onReceive(NotificationCenter.default.publisher(for: .newConversation)) { _ in + createNewConversation() + } + } + + private func createNewConversation() { + let conversation = Conversation() + modelContext.insert(conversation) + try? modelContext.save() + selectedConversation = conversation + orchestrator.resetConversation() + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift b/apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift new file mode 100644 index 0000000..8e0abf0 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift @@ -0,0 +1,129 @@ +import SwiftData +import SwiftUI + +struct APIKeySettings: View { + @State private var apiKey: String = "" + @State private var hasKey: Bool = false + @State private var showKey: Bool = false + @State private var saveStatus: String? + + @Query private var settingsArray: [UserSettings] + @Environment(\.modelContext) private var modelContext + + private var settings: UserSettings { + if let existing = settingsArray.first { + return existing + } + let s = UserSettings() + modelContext.insert(s) + try? modelContext.save() + return s + } + + @State private var selectedModel: String = "gpt-4.1-mini" + + private let availableModels = [ + "gpt-4.1-mini", + "gpt-4.1", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "o4-mini", + ] + + var body: some View { + Form { + Section("OpenAI API Key") { + HStack { + if showKey { + TextField("sk-...", text: $apiKey) + .font(.system(.body, design: .monospaced)) + } else { + SecureField("sk-...", text: $apiKey) + .font(.system(.body, design: .monospaced)) + } + + Button { + showKey.toggle() + } label: { + Image(systemName: showKey ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + } + + HStack { + Button("Save Key") { + saveAPIKey() + } + .disabled(apiKey.isEmpty) + + if hasKey { + Button("Remove Key", role: .destructive) { + removeAPIKey() + } + } + + Spacer() + + if let status = saveStatus { + Text(status) + .font(.caption) + .foregroundStyle(status.contains("Error") ? .red : .green) + } + } + + if hasKey { + Label("API key is stored securely in Keychain", systemImage: "lock.shield") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section("Model") { + Picker("Model", selection: $selectedModel) { + ForEach(availableModels, id: \.self) { model in + Text(model).tag(model) + } + } + .onChange(of: selectedModel) { _, newValue in + settings.selectedModel = newValue + try? modelContext.save() + } + } + } + .formStyle(.grouped) + .padding() + .onAppear { + hasKey = KeychainService.load(key: "OPENAI_API_KEY") != nil + selectedModel = settings.selectedModel + } + } + + private func saveAPIKey() { + do { + try KeychainService.save(key: "OPENAI_API_KEY", value: apiKey) + hasKey = true + apiKey = "" + saveStatus = "Saved" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + saveStatus = nil + } + } catch { + saveStatus = "Error: \(error.localizedDescription)" + } + } + + private func removeAPIKey() { + do { + try KeychainService.delete(key: "OPENAI_API_KEY") + hasKey = false + apiKey = "" + saveStatus = "Removed" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + saveStatus = nil + } + } catch { + saveStatus = "Error: \(error.localizedDescription)" + } + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift b/apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift new file mode 100644 index 0000000..5fca261 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift @@ -0,0 +1,131 @@ +import SwiftData +import SwiftUI + +struct EnvVarsSettings: View { + @Query(sort: \EnvVar.keyName) private var envVars: [EnvVar] + @Environment(\.modelContext) private var modelContext + + @State private var newKeyName: String = "" + @State private var newKeyValue: String = "" + @State private var errorMessage: String? + + var body: some View { + Form { + Section("Stored Environment Variables") { + if envVars.isEmpty { + Text("No environment variables configured.") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.vertical, 4) + } else { + ForEach(envVars) { envVar in + HStack { + VStack(alignment: .leading) { + Text(envVar.keyName) + .font(.system(.body, design: .monospaced)) + Text("....\(envVar.valueHint)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button(role: .destructive) { + deleteEnvVar(envVar) + } label: { + Image(systemName: "trash") + .foregroundStyle(.red) + } + .buttonStyle(.borderless) + } + } + } + } + + Section("Add New") { + TextField("Key name (e.g., WEATHER_API_KEY)", text: $newKeyName) + .font(.system(.body, design: .monospaced)) + + SecureField("Value", text: $newKeyValue) + .font(.system(.body, design: .monospaced)) + + HStack { + Button("Add") { + addEnvVar() + } + .disabled(newKeyName.isEmpty || newKeyValue.isEmpty) + + if let error = errorMessage { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + } + + Section { + Label( + "Values are stored in macOS Keychain. Only key names are visible in the app.", + systemImage: "lock.shield" + ) + .font(.caption) + .foregroundStyle(.secondary) + + Label( + "All environment variables are passed to tool executions automatically.", + systemImage: "info.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .padding() + } + + private func addEnvVar() { + let name = newKeyName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let value = newKeyValue + + guard !name.isEmpty, !value.isEmpty else { return } + + // Check for duplicates + if envVars.contains(where: { $0.keyName == name }) { + // Update existing + do { + try KeychainService.save(key: name, value: value) + if let existing = envVars.first(where: { $0.keyName == name }) { + existing.valueHint = String(value.suffix(4)) + } + try? modelContext.save() + newKeyName = "" + newKeyValue = "" + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + return + } + + do { + try KeychainService.save(key: name, value: value) + + let hint = String(value.suffix(4)) + let envVar = EnvVar(keyName: name, valueHint: hint) + modelContext.insert(envVar) + try? modelContext.save() + + newKeyName = "" + newKeyValue = "" + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func deleteEnvVar(_ envVar: EnvVar) { + try? KeychainService.delete(key: envVar.keyName) + modelContext.delete(envVar) + try? modelContext.save() + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift b/apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..e23b7bc --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct SettingsView: View { + var body: some View { + TabView { + APIKeySettings() + .tabItem { + Label("API Key", systemImage: "key") + } + + EnvVarsSettings() + .tabItem { + Label("Environment", systemImage: "server.rack") + } + + SystemPromptSettings() + .tabItem { + Label("System Prompt", systemImage: "text.bubble") + } + } + .frame(width: 520, height: 420) + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Settings/SystemPromptSettings.swift b/apps/omega-mac/OmegaMac/Views/Settings/SystemPromptSettings.swift new file mode 100644 index 0000000..f1a6907 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Settings/SystemPromptSettings.swift @@ -0,0 +1,73 @@ +import SwiftData +import SwiftUI + +struct SystemPromptSettings: View { + @Query private var settingsArray: [UserSettings] + @Environment(\.modelContext) private var modelContext + + @State private var promptText: String = "" + @State private var saved: Bool = false + + private var settings: UserSettings { + if let existing = settingsArray.first { + return existing + } + let s = UserSettings() + modelContext.insert(s) + try? modelContext.save() + return s + } + + var body: some View { + Form { + Section("Custom System Prompt") { + TextEditor(text: $promptText) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 200) + .scrollContentBackground(.hidden) + .padding(4) + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + HStack { + Button("Save") { + settings.systemPrompt = promptText.isEmpty ? nil : promptText + try? modelContext.save() + saved = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + saved = false + } + } + + Button("Reset to Default") { + promptText = "" + settings.systemPrompt = nil + try? modelContext.save() + } + + Spacer() + + if saved { + Text("Saved") + .font(.caption) + .foregroundStyle(.green) + } + } + } + + Section { + Label( + "Custom instructions are appended to the default Omega system prompt. Leave empty to use the default.", + systemImage: "info.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .padding() + .onAppear { + promptText = settings.systemPrompt ?? "" + } + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift b/apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift new file mode 100644 index 0000000..61338e0 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct EmptyStateView: View { + let onNewConversation: () -> Void + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "star.circle.fill") + .font(.system(size: 56)) + .foregroundStyle(.accent) + .symbolEffect(.pulse, options: .repeating) + + Text("Omega") + .font(.largeTitle.bold()) + + Text("AI assistant powered by 1M+ tools from the TPMJS registry") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + + Button(action: onNewConversation) { + Label("New Conversation", systemImage: "plus") + .font(.headline) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .keyboardShortcut("n", modifiers: .command) + + VStack(alignment: .leading, spacing: 8) { + featureRow(icon: "magnifyingglass", text: "Search 1M+ tools by keyword") + featureRow(icon: "play.circle", text: "Execute tools in a secure sandbox") + featureRow(icon: "bolt", text: "Auto-discovers relevant tools") + featureRow(icon: "key", text: "Securely store API keys in Keychain") + } + .padding(.top, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.background) + } + + private func featureRow(icon: String, text: String) -> some View { + HStack(spacing: 10) { + Image(systemName: icon) + .frame(width: 20) + .foregroundStyle(.accent) + Text(text) + .font(.callout) + .foregroundStyle(.secondary) + } + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift b/apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift new file mode 100644 index 0000000..041577e --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift @@ -0,0 +1,79 @@ +import MarkdownUI +import SwiftUI + +/// Renders markdown content from assistant responses using MarkdownUI. +struct MarkdownView: View { + let content: String + + var body: some View { + Markdown(content) + .markdownTheme(.omega) + .textSelection(.enabled) + } +} + +// MARK: - Custom Markdown Theme + +extension MarkdownUI.Theme { + static let omega = Theme() + .text { + ForegroundColor(.primary) + FontSize(14) + } + .code { + FontFamilyVariant(.monospaced) + FontSize(12) + ForegroundColor(.secondary) + } + .codeBlock { configuration in + configuration.label + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(12) + ForegroundColor(.secondary) + } + .padding(10) + .background(Color.black.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .link { + ForegroundColor(.accentColor) + } + .heading1 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.bold) + FontSize(20) + } + .markdownMargin(top: 16, bottom: 8) + } + .heading2 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(17) + } + .markdownMargin(top: 12, bottom: 6) + } + .heading3 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(15) + } + .markdownMargin(top: 10, bottom: 4) + } + .blockquote { configuration in + HStack(spacing: 0) { + Rectangle() + .fill(Color.accentColor.opacity(0.4)) + .frame(width: 3) + configuration.label + .markdownTextStyle { + ForegroundColor(.secondary) + FontSize(13) + } + .padding(.leading, 10) + } + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift b/apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift new file mode 100644 index 0000000..79fd307 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct ConversationRow: View { + let conversation: Conversation + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(conversation.displayTitle) + .font(.system(.body, design: .default)) + .lineLimit(1) + .foregroundStyle(.primary) + + Text(timeAgo(conversation.updatedAt)) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + + private func timeAgo(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift b/apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift new file mode 100644 index 0000000..d675258 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift @@ -0,0 +1,52 @@ +import SwiftData +import SwiftUI + +struct SidebarView: View { + @Binding var selectedConversation: Conversation? + let onNewConversation: () -> Void + + @Query(sort: \Conversation.updatedAt, order: .reverse) + private var conversations: [Conversation] + + @Environment(\.modelContext) private var modelContext + + var body: some View { + List(selection: $selectedConversation) { + ForEach(conversations) { conversation in + ConversationRow(conversation: conversation) + .tag(conversation) + .contextMenu { + Button("Delete", role: .destructive) { + deleteConversation(conversation) + } + } + } + } + .listStyle(.sidebar) + .toolbar { + ToolbarItem(placement: .automatic) { + Button(action: onNewConversation) { + Image(systemName: "plus") + } + .help("New Conversation (Cmd+N)") + } + } + .overlay { + if conversations.isEmpty { + ContentUnavailableView { + Label("No Conversations", systemImage: "bubble.left.and.bubble.right") + } description: { + Text("Press Cmd+N to start a new conversation") + } + } + } + } + + private func deleteConversation(_ conversation: Conversation) { + if selectedConversation == conversation { + selectedConversation = nil + } + modelContext.delete(conversation) + try? modelContext.save() + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift b/apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift new file mode 100644 index 0000000..c57c72c --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +/// Displays a persisted tool call result from a ToolCallData record. +/// Collapsible card with monospaced JSON input/output. +struct JSONToolResultView: View { + let toolCallData: ToolCallData + @State private var isExpanded: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + Image(systemName: isError ? "xmark.circle.fill" : "checkmark.circle.fill") + .foregroundStyle(isError ? .red : .green) + .font(.caption) + + Text(toolCallData.toolName) + .font(.system(.caption, design: .monospaced).bold()) + .foregroundStyle(.primary) + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + + if isExpanded { + Divider() + .padding(.horizontal, 10) + + VStack(alignment: .leading, spacing: 8) { + // Input + if let args = toolCallData.args { + VStack(alignment: .leading, spacing: 4) { + Text("INPUT") + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(.tertiary) + + Text(args.prettyString) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(6) + .background(.black.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + + // Output + if let output = toolCallData.output { + VStack(alignment: .leading, spacing: 4) { + Text("OUTPUT") + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(.tertiary) + + Text(output.prettyString) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(6) + .background(.black.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .lineLimit(20) + } + } + } + .padding(10) + } + } + .background(.quaternary.opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isError ? .red.opacity(0.2) : .green.opacity(0.15), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var isError: Bool { + if case .object(let obj) = toolCallData.output, + case .bool(true) = obj["error"] { + return true + } + return false + } +} diff --git a/apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift b/apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift new file mode 100644 index 0000000..6e1c005 --- /dev/null +++ b/apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct ToolCallView: View { + let toolCall: LiveToolCall + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + statusIcon + Text(toolCall.toolName) + .font(.system(.callout, design: .monospaced).bold()) + .foregroundStyle(.primary) + Spacer() + statusBadge + } + + // Input arguments + if !toolCall.arguments.isEmpty { + DisclosureGroup("Input") { + Text(prettyJSON(toolCall.arguments)) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(.black.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .font(.caption) + .foregroundStyle(.secondary) + } + + // Output + if let output = toolCall.output { + DisclosureGroup("Output") { + Text(output.prettyString) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(.black.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background(.quaternary.opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(borderColor, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + Spacer(minLength: 60) + } + .padding(.horizontal, 16) + } + + @ViewBuilder + private var statusIcon: some View { + switch toolCall.status { + case "running": + ProgressView() + .controlSize(.small) + case "success": + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + case "error": + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + default: + Image(systemName: "questionmark.circle") + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var statusBadge: some View { + Text(toolCall.status.capitalized) + .font(.system(size: 10, design: .monospaced)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(badgeColor.opacity(0.15)) + .foregroundStyle(badgeColor) + .clipShape(Capsule()) + } + + private var badgeColor: Color { + switch toolCall.status { + case "running": return .orange + case "success": return .green + case "error": return .red + default: return .secondary + } + } + + private var borderColor: Color { + switch toolCall.status { + case "running": return .orange.opacity(0.3) + case "success": return .green.opacity(0.2) + case "error": return .red.opacity(0.3) + default: return .clear + } + } + + private func prettyJSON(_ jsonString: String) -> String { + guard let data = jsonString.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data), + let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]), + let str = String(data: pretty, encoding: .utf8) else { + return jsonString + } + return str + } +} diff --git a/apps/omega-mac/Package.swift b/apps/omega-mac/Package.swift new file mode 100644 index 0000000..0257a25 --- /dev/null +++ b/apps/omega-mac/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "OmegaMac", + platforms: [.macOS(.v15)], + dependencies: [ + .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.0"), + ], + targets: [ + .executableTarget( + name: "OmegaMac", + dependencies: [ + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + ], + path: "OmegaMac" + ), + ] +) diff --git a/apps/omega-mac/README.md b/apps/omega-mac/README.md new file mode 100644 index 0000000..a188f72 --- /dev/null +++ b/apps/omega-mac/README.md @@ -0,0 +1,115 @@ +# Omega Mac + +Native macOS chat app powered by the [TPMJS tool registry](https://tpmjs.com) — 1M+ AI-ready tools at your fingertips. + +Omega Mac is the desktop counterpart to the web-based Omega agent. It connects directly to the OpenAI API and the TPMJS registry to search, discover, and execute tools in a secure remote sandbox — all from a native SwiftUI interface. + +## Requirements + +- macOS 15 (Sequoia) or later +- Xcode 16+ +- An OpenAI API key + +## Getting Started + +1. Open `Package.swift` in Xcode +2. Wait for Swift Package Manager to resolve dependencies +3. Build and run (Cmd+R) +4. Open Settings (Cmd+,) and enter your OpenAI API key +5. Press Cmd+N to start a new conversation + +## How It Works + +Omega Mac implements a full **agentic tool-use loop**: + +``` +User message + → Auto-discover relevant tools (BM25 search against tpmjs.com) + → Build tool list (registrySearch + registryExecute + discovered tools) + → Stream OpenAI response + → If tool calls returned: + → Execute tools via remote sandbox (executor.tpmjs.com) + → Feed results back to OpenAI + → Loop (up to 10 iterations) + → Display final response +``` + +### Two Core Tools + +Every conversation has access to two meta-tools that unlock the entire registry: + +- **registrySearch** — Search 1M+ tools by keyword. Returns tool IDs and metadata. +- **registryExecute** — Execute any tool by its ID. Runs in a secure remote sandbox. + +When you send a message, Omega also auto-discovers relevant tools via BM25 search and injects them as directly-callable functions — so the AI can call them without going through registryExecute. + +## Architecture + +``` +OmegaMac/ +├── Models/ SwiftData persistence +│ ├── Conversation Chat sessions with token tracking +│ ├── Message User/assistant/tool messages with JSON tool call data +│ ├── ToolCallRecord Individual tool execution records +│ ├── EnvVar Environment variable metadata (values in Keychain) +│ └── UserSettings Model selection, system prompt, pinned tools +├── Services/ Actor-based networking +│ ├── OpenAIService Streaming chat completions via SSE +│ ├── StreamParser Server-Sent Events line parser +│ ├── TPMJSRegistry Tool search + remote execution +│ ├── KeychainService Secure storage for API keys and env vars +│ └── ChatOrchestrator @Observable coordinator for the agentic loop +├── Views/ SwiftUI interface +│ ├── Sidebar/ Conversation list with @Query +│ ├── Chat/ Messages, input bar, streaming indicator +│ ├── Tools/ Tool call cards with collapsible JSON +│ ├── Settings/ API key, env vars, system prompt, model picker +│ └── Shared/ Markdown rendering, empty state +└── Utilities/ Tool name sanitization, system prompt builder +``` + +### Key Design Decisions + +- **SwiftData** for local persistence — no server, no auth, everything on-device +- **Keychain** for secrets — API keys and env var values are encrypted at rest +- **Actors** for networking — `OpenAIService` and `TPMJSRegistryService` are actors for safe concurrent access +- **@Observable** — `ChatOrchestrator` drives all UI state with zero Combine boilerplate +- **Dark theme** by default — matches the web Omega aesthetic + +## Settings + +### API Key (required) + +Your OpenAI API key is stored in the macOS Keychain. Omega Mac calls the OpenAI API directly — no proxy server. + +### Model Selection + +Choose from: `gpt-4.1-mini` (default), `gpt-4.1`, `gpt-4.1-nano`, `gpt-4o`, `gpt-4o-mini`, `o4-mini`. + +### Environment Variables + +Many tools in the TPMJS registry require API keys (e.g., `WEATHER_API_KEY`, `GITHUB_TOKEN`). Add them in Settings → Environment. Values are stored in Keychain; only key names and last-4-char hints are visible in the app. + +All stored env vars are automatically passed to every tool execution. + +### Custom System Prompt + +Append custom instructions to Omega's default system prompt. Useful for constraining behavior, adding domain context, or specifying preferred tools. + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| Cmd+N | New conversation | +| Cmd+, | Open settings | +| Enter | Send message | +| Shift+Enter | New line in input | + +## Dependencies + +- [MarkdownUI](https://github.com/gonzalezreal/swift-markdown-ui) — GitHub-flavored markdown rendering +- Everything else uses Apple frameworks (SwiftUI, SwiftData, Security, Foundation) + +## Relationship to Web Omega + +This app ports the core logic from the web implementation at `apps/web/src/app/api/omega/`. The agentic loop, system prompt, tool name sanitization, and search/execute flow are all faithful Swift translations of the TypeScript originals. The key difference is that web Omega uses server-side auth and a database, while Omega Mac stores everything locally with SwiftData and Keychain. diff --git a/packages/tools/official/agentmail/README.md b/packages/tools/official/agentmail/README.md new file mode 100644 index 0000000..68486bd --- /dev/null +++ b/packages/tools/official/agentmail/README.md @@ -0,0 +1,77 @@ +# @tpmjs/tools-agentmail + +AgentMail API tools for AI agents — create inboxes, send/receive emails, manage threads, drafts, and more. + +## Installation + +```bash +npm install @tpmjs/tools-agentmail +``` + +## Setup + +Set the `AGENTMAIL_API_KEY` environment variable. Get your API key from [AgentMail Dashboard](https://app.agentmail.to). + +```bash +export AGENTMAIL_API_KEY=your_api_key_here +``` + +## Usage + +```typescript +import { createInbox, sendMessage, listMessages } from '@tpmjs/tools-agentmail'; + +// Create a new inbox for your AI agent +const inbox = await createInbox.execute({ + username: 'my-agent', + display_name: 'My AI Agent', +}); + +// Send an email +const message = await sendMessage.execute({ + inbox_id: inbox.inbox_id, + to: 'recipient@example.com', + subject: 'Hello from AI', + text: 'This is an automated message from my AI agent.', +}); + +// List received messages +const messages = await listMessages.execute({ + inbox_id: inbox.inbox_id, + limit: 20, +}); +``` + +## Tools + +| Tool | Description | +|------|-------------| +| createInbox | Create a new email inbox with optional custom username and domain | +| listInboxes | List all email inboxes with pagination support | +| getInbox | Get details of a specific inbox by ID | +| deleteInbox | Delete an inbox and all its messages permanently | +| sendMessage | Send an email message with subject and body | +| replyToMessage | Reply to an existing email in a thread | +| listMessages | List email messages in an inbox with pagination | +| getMessage | Get full details of a specific message | +| listThreads | List email threads, optionally filtered by labels | +| getThread | Get a thread with all its messages | +| createDraft | Create an email draft for approval before sending | +| sendDraft | Send a previously created draft | + +## Features + +- **Complete Email Management**: Create inboxes, send/receive emails, manage threads +- **Thread Support**: View full conversation history with thread IDs +- **Draft Support**: Create drafts for human-in-the-loop approval workflows +- **Label Support**: Organize messages with custom labels +- **Pagination**: Efficiently handle large inboxes with cursor-based pagination +- **Type-Safe**: Full TypeScript types for all API responses + +## API Documentation + +For detailed API documentation, visit [AgentMail API Docs](https://docs.agentmail.to). + +## License + +MIT diff --git a/packages/tools/official/agentmail/package.json b/packages/tools/official/agentmail/package.json new file mode 100644 index 0000000..8326cf1 --- /dev/null +++ b/packages/tools/official/agentmail/package.json @@ -0,0 +1,109 @@ +{ + "name": "@tpmjs/tools-agentmail", + "version": "0.1.0", + "description": "AgentMail API tools for AI agents — create inboxes, send/receive emails, manage threads, drafts, and more", + "type": "module", + "keywords": [ + "tpmjs", + "ops", + "ai", + "email", + "agentmail" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "type-check": "tsc --noEmit", + "clean": "rm -rf dist .turbo" + }, + "devDependencies": { + "@tpmjs/tsconfig": "workspace:*", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "dependencies": { + "ai": "6.0.49" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/tpmjs/tpmjs.git", + "directory": "packages/tools/official/agentmail" + }, + "homepage": "https://tpmjs.com", + "license": "MIT", + "tpmjs": { + "category": "ops", + "frameworks": [ + "vercel-ai" + ], + "env": [ + { + "name": "AGENTMAIL_API_KEY", + "description": "AgentMail API key from https://app.agentmail.to", + "required": true + } + ], + "tools": [ + { + "name": "createInbox", + "description": "Create a new email inbox for an AI agent with optional custom username and domain." + }, + { + "name": "listInboxes", + "description": "List all email inboxes in the AgentMail account with pagination support." + }, + { + "name": "getInbox", + "description": "Get details of a specific email inbox by its inbox ID." + }, + { + "name": "deleteInbox", + "description": "Delete an email inbox and all its messages permanently." + }, + { + "name": "sendMessage", + "description": "Send an email message from an inbox to a recipient with subject and body." + }, + { + "name": "replyToMessage", + "description": "Reply to an existing email message in a thread conversation." + }, + { + "name": "listMessages", + "description": "List email messages in an inbox with pagination support." + }, + { + "name": "getMessage", + "description": "Get the full details of a specific email message by its message ID." + }, + { + "name": "listThreads", + "description": "List email threads in an inbox, optionally filtered by labels." + }, + { + "name": "getThread", + "description": "Get a thread with all its messages for viewing a full conversation." + }, + { + "name": "createDraft", + "description": "Create an email draft for human-in-the-loop approval before sending." + }, + { + "name": "sendDraft", + "description": "Send a previously created draft, converting it to a sent message." + } + ] + } +} diff --git a/packages/tools/official/agentmail/src/index.ts b/packages/tools/official/agentmail/src/index.ts new file mode 100644 index 0000000..e66f7e4 --- /dev/null +++ b/packages/tools/official/agentmail/src/index.ts @@ -0,0 +1,751 @@ +/** + * @tpmjs/tools-agentmail — AgentMail API Tools for AI Agents + * + * Complete email management for AI agents: create inboxes, send/receive emails, + * manage threads, drafts, and more. + * + * @requires AGENTMAIL_API_KEY environment variable + */ + +import { jsonSchema, tool } from 'ai'; + +const BASE_URL = 'https://api.agentmail.to/v0'; + +// ─── Client Infrastructure ────────────────────────────────────────────────── + +function getApiKey(): string { + const key = process.env.AGENTMAIL_API_KEY; + if (!key) { + throw new Error( + 'AGENTMAIL_API_KEY environment variable is required. Get your token from https://app.agentmail.to' + ); + } + return key; +} + +async function apiRequest( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + body?: unknown +): Promise { + const token = getApiKey(); + + const headers: Record = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; + + const options: RequestInit = { + method, + headers, + }; + + if (body !== undefined) { + options.body = JSON.stringify(body); + } + + const response = await fetch(`${BASE_URL}${path}`, options); + + // Handle 204 No Content for DELETE operations + if (response.status === 204) { + return { success: true } as T; + } + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `AgentMail HTTP error ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}` + ); + } + + return (await response.json()) as T; +} + +// ─── Output Types ──────────────────────────────────────────────────────────── + +export interface InboxResult { + inbox_id: string; + pod_id: string; + display_name?: string; + created_at: string; + updated_at: string; +} + +export interface ListInboxesResult { + count: number; + inboxes: InboxResult[]; + next_page_token?: string; +} + +export interface SuccessResult { + success: boolean; + inbox_id?: string; +} + +export interface MessageResult { + message_id: string; + thread_id: string; + inbox_id: string; + from: string; + to: string; + subject: string; + text: string; + html?: string; + labels: string[]; + created_at: string; +} + +export interface ListMessagesResult { + count: number; + messages: MessageResult[]; + next_page_token?: string; +} + +export interface ThreadResult { + thread_id: string; + inbox_id: string; + subject: string; + labels: string[]; + message_count: number; + created_at: string; + updated_at: string; +} + +export interface ListThreadsResult { + count: number; + threads: ThreadResult[]; + next_page_token?: string; +} + +export interface ThreadDetailResult { + thread_id: string; + inbox_id: string; + subject: string; + labels: string[]; + messages: MessageResult[]; + created_at: string; + updated_at: string; +} + +export interface DraftResult { + draft_id: string; + inbox_id: string; + to: string; + subject: string; + text: string; + html?: string; + created_at: string; +} + +// ─── Inboxes ──────────────────────────────────────────────────────────────── + +export interface CreateInboxInput { + username?: string; + domain?: string; + display_name?: string; +} + +export const createInbox = tool({ + description: + 'Create a new email inbox for an AI agent with optional custom username and domain.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + username: { + type: 'string', + description: 'Optional username for the inbox. If not provided, a random one is generated.', + }, + domain: { + type: 'string', + description: 'Optional domain (defaults to agentmail.to).', + }, + display_name: { + type: 'string', + description: 'Optional display name for the inbox.', + }, + }, + additionalProperties: false, + }), + async execute(input: CreateInboxInput): Promise { + try { + return await apiRequest('POST', '/inboxes', { + username: input.username, + domain: input.domain, + display_name: input.display_name, + }); + } catch (error) { + throw new Error(`Failed to create inbox: ${(error as Error).message}`); + } + }, +}); + +export interface ListInboxesInput { + limit?: number; + page_token?: string; +} + +export const listInboxes = tool({ + description: 'List all email inboxes in the AgentMail account with pagination support.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Number of inboxes to return (1-100, default: 50).', + }, + page_token: { + type: 'string', + description: 'Pagination token from previous response.', + }, + }, + additionalProperties: false, + }), + async execute(input: ListInboxesInput): Promise { + try { + if (input.limit !== undefined && (input.limit < 1 || input.limit > 100)) { + throw new Error('Limit must be between 1 and 100'); + } + + const params = new URLSearchParams(); + if (input.limit !== undefined) params.append('limit', String(input.limit)); + if (input.page_token) params.append('page_token', input.page_token); + + const queryString = params.toString(); + const path = queryString ? `/inboxes?${queryString}` : '/inboxes'; + + return await apiRequest('GET', path); + } catch (error) { + throw new Error(`Failed to list inboxes: ${(error as Error).message}`); + } + }, +}); + +export interface GetInboxInput { + inbox_id: string; +} + +export const getInbox = tool({ + description: 'Get details of a specific email inbox by its inbox ID.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + inbox_id: { + type: 'string', + description: 'The inbox ID to retrieve.', + }, + }, + required: ['inbox_id'], + additionalProperties: false, + }), + async execute(input: GetInboxInput): Promise { + try { + if (!input.inbox_id) { + throw new Error('inbox_id is required and must be non-empty'); + } + return await apiRequest('GET', `/inboxes/${input.inbox_id}`); + } catch (error) { + throw new Error(`Failed to get inbox: ${(error as Error).message}`); + } + }, +}); + +export interface DeleteInboxInput { + inbox_id: string; +} + +export const deleteInbox = tool({ + description: 'Delete an email inbox and all its messages permanently.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + inbox_id: { + type: 'string', + description: 'The inbox ID to delete.', + }, + }, + required: ['inbox_id'], + additionalProperties: false, + }), + async execute(input: DeleteInboxInput): Promise { + try { + if (!input.inbox_id) { + throw new Error('inbox_id is required and must be non-empty'); + } + await apiRequest('DELETE', `/inboxes/${input.inbox_id}`); + return { success: true, inbox_id: input.inbox_id }; + } catch (error) { + throw new Error(`Failed to delete inbox: ${(error as Error).message}`); + } + }, +}); + +// ─── Messages ─────────────────────────────────────────────────────────────── + +export interface SendMessageInput { + inbox_id: string; + to: string; + subject: string; + text: string; + html?: string; + cc?: string; + bcc?: string; + labels?: string[]; +} + +export const sendMessage = tool({ + description: 'Send an email message from an inbox to a recipient with subject and body.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + inbox_id: { + type: 'string', + description: 'The inbox ID to send from.', + }, + to: { + type: 'string', + description: 'Recipient email address.', + }, + subject: { + type: 'string', + description: 'Email subject line.', + }, + text: { + type: 'string', + description: 'Plain text email body.', + }, + html: { + type: 'string', + description: 'Optional HTML email body.', + }, + cc: { + type: 'string', + description: 'Optional CC recipients (comma-separated).', + }, + bcc: { + type: 'string', + description: 'Optional BCC recipients (comma-separated).', + }, + labels: { + type: 'array', + items: { type: 'string' }, + description: 'Optional labels to apply to the message.', + }, + }, + required: ['inbox_id', 'to', 'subject', 'text'], + additionalProperties: false, + }), + async execute(input: SendMessageInput): Promise { + try { + if (!input.inbox_id || !input.to || !input.subject || !input.text) { + throw new Error('inbox_id, to, subject, and text are required and must be non-empty'); + } + return await apiRequest('POST', `/inboxes/${input.inbox_id}/messages`, { + to: input.to, + subject: input.subject, + text: input.text, + html: input.html, + cc: input.cc, + bcc: input.bcc, + labels: input.labels, + }); + } catch (error) { + throw new Error(`Failed to send message: ${(error as Error).message}`); + } + }, +}); + +export interface ReplyToMessageInput { + inbox_id: string; + message_id: string; + text: string; + html?: string; + labels?: string[]; +} + +export const replyToMessage = tool({ + description: 'Reply to an existing email message in a thread conversation.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + inbox_id: { + type: 'string', + description: 'The inbox ID.', + }, + message_id: { + type: 'string', + description: 'The message ID to reply to.', + }, + text: { + type: 'string', + description: 'Plain text reply body.', + }, + html: { + type: 'string', + description: 'Optional HTML reply body.', + }, + labels: { + type: 'array', + items: { type: 'string' }, + description: 'Optional labels to apply to the reply.', + }, + }, + required: ['inbox_id', 'message_id', 'text'], + additionalProperties: false, + }), + async execute(input: ReplyToMessageInput): Promise { + try { + if (!input.inbox_id || !input.message_id || !input.text) { + throw new Error('inbox_id, message_id, and text are required and must be non-empty'); + } + return await apiRequest( + 'POST', + `/inboxes/${input.inbox_id}/messages/${input.message_id}/reply`, + { + text: input.text, + html: input.html, + labels: input.labels, + } + ); + } catch (error) { + throw new Error(`Failed to reply to message: ${(error as Error).message}`); + } + }, +}); + +export interface ListMessagesInput { + inbox_id: string; + limit?: number; + page_token?: string; +} + +export const listMessages = tool({ + description: 'List email messages in an inbox with pagination support.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + inbox_id: { + type: 'string', + description: 'The inbox ID to list messages from.', + }, + limit: { + type: 'number', + description: 'Number of messages to return (1-100, default: 50).', + }, + page_token: { + type: 'string', + description: 'Pagination token from previous response.', + }, + }, + required: ['inbox_id'], + additionalProperties: false, + }), + async execute(input: ListMessagesInput): Promise { + try { + if (!input.inbox_id) { + throw new Error('inbox_id is required and must be non-empty'); + } + if (input.limit !== undefined && (input.limit < 1 || input.limit > 100)) { + throw new Error('Limit must be between 1 and 100'); + } + + const params = new URLSearchParams(); + if (input.limit !== undefined) params.append('limit', String(input.limit)); + if (input.page_token) params.append('page_token', input.page_token); + + const queryString = params.toString(); + const path = queryString + ? `/inboxes/${input.inbox_id}/messages?${queryString}` + : `/inboxes/${input.inbox_id}/messages`; + + return await apiRequest('GET', path); + } catch (error) { + throw new Error(`Failed to list messages: ${(error as Error).message}`); + } + }, +}); + +export interface GetMessageInput { + inbox_id: string; + message_id: string; +} + +export const getMessage = tool({ + description: 'Get the full details of a specific email message by its message ID.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + inbox_id: { + type: 'string', + description: 'The inbox ID.', + }, + message_id: { + type: 'string', + description: 'The message ID to retrieve.', + }, + }, + required: ['inbox_id', 'message_id'], + additionalProperties: false, + }), + async execute(input: GetMessageInput): Promise { + if (!input.inbox_id) { + throw new Error('inbox_id is required and must be non-empty'); + } + if (!input.message_id) { + throw new Error('message_id is required and must be non-empty'); + } + + try { + return await apiRequest( + 'GET', + `/inboxes/${encodeURIComponent(input.inbox_id)}/messages/${encodeURIComponent(input.message_id)}` + ); + } catch (error) { + throw new Error( + `Failed to get message "${input.message_id}" from inbox "${input.inbox_id}": ${(error as Error).message}` + ); + } + }, +}); + +// ─── Threads ──────────────────────────────────────────────────────────────── + +export interface ListThreadsInput { + inbox_id: string; + limit?: number; + page_token?: string; + labels?: string[]; +} + +export const listThreads = tool({ + description: 'List email threads in an inbox, optionally filtered by labels.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + inbox_id: { + type: 'string', + description: 'The inbox ID to list threads from.', + }, + limit: { + type: 'number', + description: 'Number of threads to return (1-100, default: 50).', + }, + page_token: { + type: 'string', + description: 'Pagination token from previous response.', + }, + labels: { + type: 'array', + items: { type: 'string' }, + description: 'Optional labels to filter threads by.', + }, + }, + required: ['inbox_id'], + additionalProperties: false, + }), + async execute(input: ListThreadsInput): Promise { + if (!input.inbox_id) { + throw new Error('inbox_id is required and must be non-empty'); + } + if (input.limit !== undefined && (input.limit < 1 || input.limit > 100)) { + throw new Error('Limit must be between 1 and 100'); + } + + try { + const params = new URLSearchParams(); + if (input.limit !== undefined) params.append('limit', String(input.limit)); + if (input.page_token) params.append('page_token', input.page_token); + if (Array.isArray(input.labels)) { + const validLabels = input.labels.filter((label) => typeof label === 'string' && label.trim().length > 0); + for (const label of validLabels) { + params.append('labels', label); + } + } + + const queryString = params.toString(); + const path = queryString + ? `/inboxes/${encodeURIComponent(input.inbox_id)}/threads?${queryString}` + : `/inboxes/${encodeURIComponent(input.inbox_id)}/threads`; + + return await apiRequest('GET', path); + } catch (error) { + throw new Error( + `Failed to list threads for inbox "${input.inbox_id}": ${(error as Error).message}` + ); + } + }, +}); + +export interface GetThreadInput { + inbox_id: string; + thread_id: string; +} + +export const getThread = tool({ + description: 'Get a thread with all its messages for viewing a full conversation.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + inbox_id: { + type: 'string', + description: 'The inbox ID.', + }, + thread_id: { + type: 'string', + description: 'The thread ID to retrieve.', + }, + }, + required: ['inbox_id', 'thread_id'], + additionalProperties: false, + }), + async execute(input: GetThreadInput): Promise { + try { + if (!input.inbox_id || !input.thread_id) { + throw new Error('inbox_id and thread_id are required and must be non-empty'); + } + return await apiRequest( + 'GET', + `/inboxes/${input.inbox_id}/threads/${input.thread_id}` + ); + } catch (error) { + throw new Error(`Failed to get thread: ${(error as Error).message}`); + } + }, +}); + +// ─── Drafts ───────────────────────────────────────────────────────────────── + +export interface CreateDraftInput { + inbox_id: string; + to: string; + subject: string; + text: string; + html?: string; + cc?: string; + bcc?: string; +} + +export const createDraft = tool({ + description: 'Create an email draft for human-in-the-loop approval before sending.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + inbox_id: { + type: 'string', + description: 'The inbox ID to create the draft in.', + }, + to: { + type: 'string', + description: 'Recipient email address.', + }, + subject: { + type: 'string', + description: 'Email subject line.', + }, + text: { + type: 'string', + description: 'Plain text email body.', + }, + html: { + type: 'string', + description: 'Optional HTML email body.', + }, + cc: { + type: 'string', + description: 'Optional CC recipients (comma-separated).', + }, + bcc: { + type: 'string', + description: 'Optional BCC recipients (comma-separated).', + }, + }, + required: ['inbox_id', 'to', 'subject', 'text'], + additionalProperties: false, + }), + async execute(input: CreateDraftInput): Promise { + try { + if (!input.inbox_id || !input.to || !input.subject || !input.text) { + throw new Error('inbox_id, to, subject, and text are required and must be non-empty'); + } + return await apiRequest('POST', `/inboxes/${input.inbox_id}/drafts`, { + to: input.to, + subject: input.subject, + text: input.text, + html: input.html, + cc: input.cc, + bcc: input.bcc, + }); + } catch (error) { + throw new Error(`Failed to create draft: ${(error as Error).message}`); + } + }, +}); + +export interface SendDraftInput { + inbox_id: string; + draft_id: string; +} + +export const sendDraft = tool({ + description: 'Send a previously created draft, converting it to a sent message.', + inputSchema: jsonSchema({ + type: 'object', + properties: { + inbox_id: { + type: 'string', + description: 'The inbox ID.', + }, + draft_id: { + type: 'string', + description: 'The draft ID to send.', + }, + }, + required: ['inbox_id', 'draft_id'], + additionalProperties: false, + }), + async execute(input: SendDraftInput): Promise { + if (!input.inbox_id) { + throw new Error('inbox_id is required and must be non-empty'); + } + if (!input.draft_id) { + throw new Error('draft_id is required and must be non-empty'); + } + + try { + const response = await apiRequest( + 'POST', + `/inboxes/${encodeURIComponent(input.inbox_id)}/drafts/${encodeURIComponent(input.draft_id)}/send` + ); + + if (!response || !response.message_id) { + throw new Error('AgentMail API returned an invalid response when sending draft'); + } + + return response; + } catch (error) { + throw new Error( + `Failed to send draft "${input.draft_id}" from inbox "${input.inbox_id}": ${(error as Error).message}` + ); + } + }, +}); + +// ─── Default Export ───────────────────────────────────────────────────────── + +export default { + // Inboxes + createInbox, + listInboxes, + getInbox, + deleteInbox, + // Messages + sendMessage, + replyToMessage, + listMessages, + getMessage, + // Threads + listThreads, + getThread, + // Drafts + createDraft, + sendDraft, +}; diff --git a/packages/tools/official/agentmail/tsconfig.json b/packages/tools/official/agentmail/tsconfig.json new file mode 100644 index 0000000..84965a7 --- /dev/null +++ b/packages/tools/official/agentmail/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@tpmjs/tsconfig/react-library.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "incremental": false, + "composite": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/tools/official/agentmail/tsup.config.ts b/packages/tools/official/agentmail/tsup.config.ts new file mode 100644 index 0000000..2260c9e --- /dev/null +++ b/packages/tools/official/agentmail/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, + sourcemap: true, + target: 'es2022', + treeshake: true, + splitting: false, +}); diff --git a/packages/tools/official/blocks.yml b/packages/tools/official/blocks.yml index 37b3914..49aeffc 100644 --- a/packages/tools/official/blocks.yml +++ b/packages/tools/official/blocks.yml @@ -13105,6 +13105,288 @@ blocks: description: "Pin confirmation" measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + # --------------------------------------------------------------------------- + # AgentMail Tools (email for AI agents) + # --------------------------------------------------------------------------- + ops.agentmailCreateInbox: + type: utility + description: "Create a new email inbox for an AI agent." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API POST /v0/inboxes endpoint" + inputs: + - name: username + type: string + optional: true + description: "Username for the email address" + - name: domain + type: string + optional: true + description: "Domain for the email address" + - name: display_name + type: string + optional: true + description: "Display name for the inbox" + outputs: + - name: inbox + type: InboxResult + description: "Created inbox details" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.agentmailListInboxes: + type: utility + description: "List all email inboxes in the account." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API GET /v0/inboxes endpoint" + inputs: + - name: limit + type: number + optional: true + description: "Max inboxes to return (1-100)" + - name: page_token + type: string + optional: true + description: "Pagination token" + outputs: + - name: result + type: ListInboxesResult + description: "List of inboxes with pagination" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.agentmailGetInbox: + type: utility + description: "Get details of a specific email inbox." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API GET /v0/inboxes/{inbox_id} endpoint" + inputs: + - name: inbox_id + type: string + description: "Inbox ID" + outputs: + - name: inbox + type: InboxResult + description: "Inbox details" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.agentmailDeleteInbox: + type: utility + description: "Delete an email inbox and all its messages." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API DELETE /v0/inboxes/{inbox_id} endpoint" + inputs: + - name: inbox_id + type: string + description: "Inbox ID" + outputs: + - name: result + type: SuccessResult + description: "Deletion confirmation" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.agentmailSendMessage: + type: utility + description: "Send an email message from an inbox." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/messages endpoint" + inputs: + - name: inbox_id + type: string + description: "Inbox ID to send from" + - name: to + type: string + description: "Recipient email address" + - name: subject + type: string + description: "Email subject" + - name: text + type: string + description: "Plain text body" + - name: html + type: string + optional: true + description: "HTML body" + outputs: + - name: message + type: MessageResult + description: "Sent message details" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.agentmailReplyToMessage: + type: utility + description: "Reply to an existing email message in a thread." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/messages/{message_id}/reply endpoint" + inputs: + - name: inbox_id + type: string + description: "Inbox ID" + - name: message_id + type: string + description: "Message ID to reply to" + - name: text + type: string + description: "Reply text" + - name: html + type: string + optional: true + description: "Reply HTML body" + outputs: + - name: message + type: MessageResult + description: "Reply message details" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.agentmailListMessages: + type: utility + description: "List email messages in an inbox." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/messages endpoint" + inputs: + - name: inbox_id + type: string + description: "Inbox ID" + - name: limit + type: number + optional: true + description: "Max messages to return (1-100)" + - name: page_token + type: string + optional: true + description: "Pagination token" + outputs: + - name: result + type: ListMessagesResult + description: "List of messages with pagination" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.agentmailGetMessage: + type: utility + description: "Get the full details of a specific email message." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/messages/{message_id} endpoint" + inputs: + - name: inbox_id + type: string + description: "Inbox ID" + - name: message_id + type: string + description: "Message ID" + outputs: + - name: message + type: MessageResult + description: "Full message details" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.agentmailListThreads: + type: utility + description: "List email threads in an inbox, optionally filtered by labels." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/threads endpoint" + inputs: + - name: inbox_id + type: string + description: "Inbox ID" + - name: limit + type: number + optional: true + description: "Max threads to return (1-100)" + - name: labels + type: array + optional: true + description: "Filter by labels" + outputs: + - name: result + type: ListThreadsResult + description: "List of threads with pagination" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.agentmailGetThread: + type: utility + description: "Get a thread with all its messages for viewing a full conversation." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/threads/{thread_id} endpoint" + inputs: + - name: inbox_id + type: string + description: "Inbox ID" + - name: thread_id + type: string + description: "Thread ID" + outputs: + - name: thread + type: ThreadDetailResult + description: "Thread with all messages" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.agentmailCreateDraft: + type: utility + description: "Create an email draft for human-in-the-loop approval." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/drafts endpoint" + inputs: + - name: inbox_id + type: string + description: "Inbox ID" + - name: to + type: string + description: "Recipient email" + - name: subject + type: string + description: "Email subject" + - name: text + type: string + description: "Plain text body" + - name: html + type: string + optional: true + description: "HTML body" + outputs: + - name: draft + type: DraftResult + description: "Created draft details" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + + ops.agentmailSendDraft: + type: utility + description: "Send a previously created draft, converting it to a sent message." + path: "agentmail" + domain_rules: + - id: api_integration + description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/drafts/{draft_id}/send endpoint" + inputs: + - name: inbox_id + type: string + description: "Inbox ID" + - name: draft_id + type: string + description: "Draft ID" + outputs: + - name: message + type: MessageResult + description: "Sent message details" + measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance] + # ============================================================================= # VALIDATORS - Which validators to run against each block # ============================================================================= diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0098f4..f8df04c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1014,6 +1014,22 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/tools/official/agentmail: + dependencies: + ai: + specifier: 6.0.49 + version: 6.0.49(zod@4.3.5) + devDependencies: + '@tpmjs/tsconfig': + specifier: workspace:* + version: link:../../../config/tsconfig + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/tools/official/anomaly-detect-mad: dependencies: ai: