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:
Thomas Davis 2026-02-09 21:29:39 +10:00
parent c8e8a7d9b4
commit 1cd44b4e97
45 changed files with 4583 additions and 0 deletions

1
.claude/skills/agentmail Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/agentmail

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View 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 }
}
}

View 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()
}
}

View 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
}
}

View 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)
}
}
}

View 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 = []
}
}

View 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>

View 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")
}

View 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
}
}

View 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)"
}
}
}

View 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
}
}

View 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?
}

View 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
}
}

View 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."
}
}
}

View 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
)
)
)
}
}

View 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
}

View 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")
}
}

View 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
}
}

View 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)
}
}
}

View 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)
}
}

View 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 }
}
}

View 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
}
}
}

View 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()
}
}

View 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)"
}
}
}

View 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()
}
}

View 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)
}
}

View file

@ -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 ?? ""
}
}
}

View 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)
}
}
}

View 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)
}
}
}

View 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())
}
}

View 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()
}
}

View 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
}
}

View 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
}
}

View 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
View 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.

View 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

View 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."
}
]
}
}

View 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,
};

View 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"]
}

View 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,
});

View file

@ -13105,6 +13105,288 @@ blocks:
description: "Pin confirmation"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
# ---------------------------------------------------------------------------
# AgentMail Tools (email for AI agents)
# ---------------------------------------------------------------------------
ops.agentmailCreateInbox:
type: utility
description: "Create a new email inbox for an AI agent."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API POST /v0/inboxes endpoint"
inputs:
- name: username
type: string
optional: true
description: "Username for the email address"
- name: domain
type: string
optional: true
description: "Domain for the email address"
- name: display_name
type: string
optional: true
description: "Display name for the inbox"
outputs:
- name: inbox
type: InboxResult
description: "Created inbox details"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
ops.agentmailListInboxes:
type: utility
description: "List all email inboxes in the account."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API GET /v0/inboxes endpoint"
inputs:
- name: limit
type: number
optional: true
description: "Max inboxes to return (1-100)"
- name: page_token
type: string
optional: true
description: "Pagination token"
outputs:
- name: result
type: ListInboxesResult
description: "List of inboxes with pagination"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
ops.agentmailGetInbox:
type: utility
description: "Get details of a specific email inbox."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API GET /v0/inboxes/{inbox_id} endpoint"
inputs:
- name: inbox_id
type: string
description: "Inbox ID"
outputs:
- name: inbox
type: InboxResult
description: "Inbox details"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
ops.agentmailDeleteInbox:
type: utility
description: "Delete an email inbox and all its messages."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API DELETE /v0/inboxes/{inbox_id} endpoint"
inputs:
- name: inbox_id
type: string
description: "Inbox ID"
outputs:
- name: result
type: SuccessResult
description: "Deletion confirmation"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
ops.agentmailSendMessage:
type: utility
description: "Send an email message from an inbox."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/messages endpoint"
inputs:
- name: inbox_id
type: string
description: "Inbox ID to send from"
- name: to
type: string
description: "Recipient email address"
- name: subject
type: string
description: "Email subject"
- name: text
type: string
description: "Plain text body"
- name: html
type: string
optional: true
description: "HTML body"
outputs:
- name: message
type: MessageResult
description: "Sent message details"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
ops.agentmailReplyToMessage:
type: utility
description: "Reply to an existing email message in a thread."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/messages/{message_id}/reply endpoint"
inputs:
- name: inbox_id
type: string
description: "Inbox ID"
- name: message_id
type: string
description: "Message ID to reply to"
- name: text
type: string
description: "Reply text"
- name: html
type: string
optional: true
description: "Reply HTML body"
outputs:
- name: message
type: MessageResult
description: "Reply message details"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
ops.agentmailListMessages:
type: utility
description: "List email messages in an inbox."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/messages endpoint"
inputs:
- name: inbox_id
type: string
description: "Inbox ID"
- name: limit
type: number
optional: true
description: "Max messages to return (1-100)"
- name: page_token
type: string
optional: true
description: "Pagination token"
outputs:
- name: result
type: ListMessagesResult
description: "List of messages with pagination"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
ops.agentmailGetMessage:
type: utility
description: "Get the full details of a specific email message."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/messages/{message_id} endpoint"
inputs:
- name: inbox_id
type: string
description: "Inbox ID"
- name: message_id
type: string
description: "Message ID"
outputs:
- name: message
type: MessageResult
description: "Full message details"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
ops.agentmailListThreads:
type: utility
description: "List email threads in an inbox, optionally filtered by labels."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/threads endpoint"
inputs:
- name: inbox_id
type: string
description: "Inbox ID"
- name: limit
type: number
optional: true
description: "Max threads to return (1-100)"
- name: labels
type: array
optional: true
description: "Filter by labels"
outputs:
- name: result
type: ListThreadsResult
description: "List of threads with pagination"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
ops.agentmailGetThread:
type: utility
description: "Get a thread with all its messages for viewing a full conversation."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/threads/{thread_id} endpoint"
inputs:
- name: inbox_id
type: string
description: "Inbox ID"
- name: thread_id
type: string
description: "Thread ID"
outputs:
- name: thread
type: ThreadDetailResult
description: "Thread with all messages"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
ops.agentmailCreateDraft:
type: utility
description: "Create an email draft for human-in-the-loop approval."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/drafts endpoint"
inputs:
- name: inbox_id
type: string
description: "Inbox ID"
- name: to
type: string
description: "Recipient email"
- name: subject
type: string
description: "Email subject"
- name: text
type: string
description: "Plain text body"
- name: html
type: string
optional: true
description: "HTML body"
outputs:
- name: draft
type: DraftResult
description: "Created draft details"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
ops.agentmailSendDraft:
type: utility
description: "Send a previously created draft, converting it to a sent message."
path: "agentmail"
domain_rules:
- id: api_integration
description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/drafts/{draft_id}/send endpoint"
inputs:
- name: inbox_id
type: string
description: "Inbox ID"
- name: draft_id
type: string
description: "Draft ID"
outputs:
- name: message
type: MessageResult
description: "Sent message details"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
# =============================================================================
# VALIDATORS - Which validators to run against each block
# =============================================================================

16
pnpm-lock.yaml generated
View file

@ -1014,6 +1014,22 @@ importers:
specifier: ^5.9.3
version: 5.9.3
packages/tools/official/agentmail:
dependencies:
ai:
specifier: 6.0.49
version: 6.0.49(zod@4.3.5)
devDependencies:
'@tpmjs/tsconfig':
specifier: workspace:*
version: link:../../../config/tsconfig
tsup:
specifier: ^8.5.1
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
typescript:
specifier: ^5.9.3
version: 5.9.3
packages/tools/official/anomaly-detect-mad:
dependencies:
ai: