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 <noreply@anthropic.com>
This commit is contained in:
parent
c8e8a7d9b4
commit
1cd44b4e97
45 changed files with 4583 additions and 0 deletions
1
.claude/skills/agentmail
Symbolic link
1
.claude/skills/agentmail
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../.agents/skills/agentmail
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json
Normal file
6
apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/omega-mac/OmegaMac/Models/Conversation.swift
Normal file
41
apps/omega-mac/OmegaMac/Models/Conversation.swift
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/omega-mac/OmegaMac/Models/EnvVar.swift
Normal file
18
apps/omega-mac/OmegaMac/Models/EnvVar.swift
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apps/omega-mac/OmegaMac/Models/Message.swift
Normal file
141
apps/omega-mac/OmegaMac/Models/Message.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift
Normal file
51
apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/omega-mac/OmegaMac/Models/UserSettings.swift
Normal file
17
apps/omega-mac/OmegaMac/Models/UserSettings.swift
Normal file
|
|
@ -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 = []
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/omega-mac/OmegaMac/OmegaMac.entitlements
Normal file
14
apps/omega-mac/OmegaMac/OmegaMac.entitlements
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)com.tpmjs.omega-mac</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
44
apps/omega-mac/OmegaMac/OmegaMacApp.swift
Normal file
44
apps/omega-mac/OmegaMac/OmegaMacApp.swift
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
613
apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift
Normal file
613
apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift
Normal file
|
|
@ -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<UserSettings>()
|
||||||
|
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<EnvVar>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
124
apps/omega-mac/OmegaMac/Services/KeychainService.swift
Normal file
124
apps/omega-mac/OmegaMac/Services/KeychainService.swift
Normal file
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apps/omega-mac/OmegaMac/Services/OpenAIService.swift
Normal file
141
apps/omega-mac/OmegaMac/Services/OpenAIService.swift
Normal file
|
|
@ -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<StreamParser.StreamEvent, Error> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
209
apps/omega-mac/OmegaMac/Services/OpenAITypes.swift
Normal file
209
apps/omega-mac/OmegaMac/Services/OpenAITypes.swift
Normal file
|
|
@ -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?
|
||||||
|
}
|
||||||
105
apps/omega-mac/OmegaMac/Services/StreamParser.swift
Normal file
105
apps/omega-mac/OmegaMac/Services/StreamParser.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
156
apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift
Normal file
156
apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift
Normal file
|
|
@ -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..<separatorIndex.lowerBound])
|
||||||
|
let name = String(toolId[separatorIndex.upperBound...])
|
||||||
|
|
||||||
|
guard !packageName.isEmpty, !name.isEmpty else {
|
||||||
|
throw TPMJSError.invalidToolId(toolId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for the tool to get version metadata
|
||||||
|
let searchResults = try await searchTools(query: name, limit: 10)
|
||||||
|
guard let toolMeta = searchResults.first(where: {
|
||||||
|
$0.packageName == packageName && $0.name == name
|
||||||
|
}) else {
|
||||||
|
throw TPMJSError.toolNotFound(toolId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await executeTool(
|
||||||
|
packageName: toolMeta.packageName,
|
||||||
|
name: toolMeta.name,
|
||||||
|
version: toolMeta.version,
|
||||||
|
importUrl: toolMeta.importUrl,
|
||||||
|
params: params,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum TPMJSError: LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case httpError(Int)
|
||||||
|
case invalidToolId(String)
|
||||||
|
case toolNotFound(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL: return "Invalid URL"
|
||||||
|
case .httpError(let code): return "HTTP error: \(code)"
|
||||||
|
case .invalidToolId(let id): return "Invalid tool ID format: \(id). Expected 'package::name'"
|
||||||
|
case .toolNotFound(let id): return "Tool not found: \(id). Try using registrySearch to find available tools."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift
Normal file
128
apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Search API Types
|
||||||
|
|
||||||
|
struct TPMJSSearchResponse: Decodable {
|
||||||
|
let results: TPMJSSearchResults?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TPMJSSearchResults: Decodable {
|
||||||
|
let tools: [TPMJSToolResult]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TPMJSToolResult: Decodable {
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let inputSchema: JSONValue?
|
||||||
|
let qualityScore: Double?
|
||||||
|
let executionHealth: String?
|
||||||
|
let package: TPMJSPackageInfo
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, description, inputSchema, qualityScore, executionHealth
|
||||||
|
case package = "package"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TPMJSPackageInfo: Decodable {
|
||||||
|
let npmPackageName: String
|
||||||
|
let npmVersion: String
|
||||||
|
let category: String?
|
||||||
|
let env: [TPMJSEnvVarDef]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TPMJSEnvVarDef: Decodable, Sendable {
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let required: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Executor API Types
|
||||||
|
|
||||||
|
struct TPMJSExecuteRequest: Encodable {
|
||||||
|
let packageName: String
|
||||||
|
let name: String
|
||||||
|
let version: String
|
||||||
|
let importUrl: String
|
||||||
|
let params: JSONValue
|
||||||
|
let env: [String: String]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TPMJSExecuteResponse: Decodable, Sendable {
|
||||||
|
let success: Bool
|
||||||
|
let output: JSONValue?
|
||||||
|
let error: String?
|
||||||
|
let executionTimeMs: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tool Metadata (internal tracking)
|
||||||
|
|
||||||
|
struct ToolMeta: Sendable {
|
||||||
|
let toolId: String
|
||||||
|
let packageName: String
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let version: String
|
||||||
|
let importUrl: String
|
||||||
|
let inputSchema: JSONValue?
|
||||||
|
let env: [TPMJSEnvVarDef]?
|
||||||
|
|
||||||
|
/// Convert to an OpenAI function tool definition
|
||||||
|
func toChatTool() -> 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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift
Normal file
40
apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
134
apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift
Normal file
134
apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
54
apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift
Normal file
54
apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift
Normal file
68
apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift
Normal file
69
apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift
Normal file
95
apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift
Normal file
24
apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/omega-mac/OmegaMac/Views/ContentView.swift
Normal file
39
apps/omega-mac/OmegaMac/Views/ContentView.swift
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
129
apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift
Normal file
129
apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift
Normal file
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift
Normal file
131
apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift
Normal file
23
apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift
Normal file
52
apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift
Normal file
79
apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift
Normal file
25
apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift
Normal file
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift
Normal file
52
apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
98
apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift
Normal file
98
apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
119
apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift
Normal file
119
apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/omega-mac/Package.swift
Normal file
19
apps/omega-mac/Package.swift
Normal file
|
|
@ -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"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
115
apps/omega-mac/README.md
Normal file
115
apps/omega-mac/README.md
Normal file
|
|
@ -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.
|
||||||
77
packages/tools/official/agentmail/README.md
Normal file
77
packages/tools/official/agentmail/README.md
Normal file
|
|
@ -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
|
||||||
109
packages/tools/official/agentmail/package.json
Normal file
109
packages/tools/official/agentmail/package.json
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
751
packages/tools/official/agentmail/src/index.ts
Normal file
751
packages/tools/official/agentmail/src/index.ts
Normal file
|
|
@ -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<T>(
|
||||||
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
||||||
|
path: string,
|
||||||
|
body?: unknown
|
||||||
|
): Promise<T> {
|
||||||
|
const token = getApiKey();
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
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<CreateInboxInput>({
|
||||||
|
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<InboxResult> {
|
||||||
|
try {
|
||||||
|
return await apiRequest<InboxResult>('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<ListInboxesInput>({
|
||||||
|
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<ListInboxesResult> {
|
||||||
|
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<ListInboxesResult>('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<GetInboxInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
inbox_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The inbox ID to retrieve.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['inbox_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: GetInboxInput): Promise<InboxResult> {
|
||||||
|
try {
|
||||||
|
if (!input.inbox_id) {
|
||||||
|
throw new Error('inbox_id is required and must be non-empty');
|
||||||
|
}
|
||||||
|
return await apiRequest<InboxResult>('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<DeleteInboxInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
inbox_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The inbox ID to delete.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['inbox_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
async execute(input: DeleteInboxInput): Promise<SuccessResult> {
|
||||||
|
try {
|
||||||
|
if (!input.inbox_id) {
|
||||||
|
throw new Error('inbox_id is required and must be non-empty');
|
||||||
|
}
|
||||||
|
await apiRequest<SuccessResult>('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<SendMessageInput>({
|
||||||
|
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<MessageResult> {
|
||||||
|
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<MessageResult>('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<ReplyToMessageInput>({
|
||||||
|
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<MessageResult> {
|
||||||
|
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<MessageResult>(
|
||||||
|
'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<ListMessagesInput>({
|
||||||
|
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<ListMessagesResult> {
|
||||||
|
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<ListMessagesResult>('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<GetMessageInput>({
|
||||||
|
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<MessageResult> {
|
||||||
|
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<MessageResult>(
|
||||||
|
'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<ListThreadsInput>({
|
||||||
|
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<ListThreadsResult> {
|
||||||
|
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<ListThreadsResult>('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<GetThreadInput>({
|
||||||
|
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<ThreadDetailResult> {
|
||||||
|
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<ThreadDetailResult>(
|
||||||
|
'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<CreateDraftInput>({
|
||||||
|
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<DraftResult> {
|
||||||
|
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<DraftResult>('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<SendDraftInput>({
|
||||||
|
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<MessageResult> {
|
||||||
|
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<MessageResult>(
|
||||||
|
'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,
|
||||||
|
};
|
||||||
11
packages/tools/official/agentmail/tsconfig.json
Normal file
11
packages/tools/official/agentmail/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
12
packages/tools/official/agentmail/tsup.config.ts
Normal file
12
packages/tools/official/agentmail/tsup.config.ts
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
|
@ -13105,6 +13105,288 @@ blocks:
|
||||||
description: "Pin confirmation"
|
description: "Pin confirmation"
|
||||||
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
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
|
# VALIDATORS - Which validators to run against each block
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
|
|
@ -1014,6 +1014,22 @@ importers:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 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:
|
packages/tools/official/anomaly-detect-mad:
|
||||||
dependencies:
|
dependencies:
|
||||||
ai:
|
ai:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue