iOS port M0a: extract 13 leaf Models to new ScarfCore local SPM package

First of four M0 sub-PRs that carve a platform-neutral ScarfCore package
out of the Mac app, in preparation for an iOS target. This PR is
Mac-only — no iOS target yet, no behavior changes expected.

What moves to ScarfCore:
  - 13 leaf model files (HermesSession, HermesMessage, HermesConfig and
    its 19 nested Settings structs, HermesCronJob, HermesMCPServer,
    HermesSkill, HermesSlashCommand, HermesTool + KnownPlatforms,
    HermesPathSet, MCPServerPreset, ProjectDashboard family, ACPMessages).
  - Portable half of HermesConstants.swift (sqliteTransient, QueryDefaults,
    FileSizeUnit). The deprecated HermesPaths enum stays in main target
    as HermesPaths+Deprecated.swift since it references ServerContext.

What stays in the Mac target:
  - ServerContext.swift (moves in M0b alongside Transport — depends on
    LocalTransport/SSHTransport + HermesFileService).
  - HermesPaths+Deprecated.swift (dead forwarders, zero callers in-tree;
    kept for safety until M0b can clean them up).

Mechanics:
  - New Packages/ScarfCore/Package.swift targeting macOS 14 / iOS 18,
    Swift 6 language mode.
  - Every moved type and member marked public; explicit public memberwise
    init added to every struct (Swift's synthesized memberwise init is
    internal and would break cross-module construction).
  - Xcode project references the package via XCLocalSwiftPackageReference
    and links ScarfCore into the scarf target.
  - 49 consumer files get `import ScarfCore` added.

See scarf/docs/IOS_PORT_PLAN.md for the full multi-phase plan, locked
decisions (iOS 18, iPhone only, no APNs v1), and the M0b–M6 roadmap.

Manual verification checklist:
  - Open scarf.xcodeproj in Xcode and build the scarf scheme — should
    resolve the local package and compile with no new errors.
  - Run scarfTests — should pass (tests don't touch moved types).
  - Smoke-run the app: Dashboard, Sessions, Chat, Memory should render
    with identical data to pre-PR.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
Claude
2026-04-22 12:19:17 +00:00
parent 3e0d2db4c7
commit bb5045c10f
73 changed files with 2029 additions and 990 deletions
@@ -0,0 +1,354 @@
import Foundation
// MARK: - JSON-RPC Transport
// Hand-written `encode(to:)` / `init(from:)` with explicit `nonisolated` so
// Swift 6's default-isolation doesn't synthesize a MainActor-isolated
// conformance which would prevent these payloads from being encoded or
// decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop).
// The member list must stay in sync with the stored properties above.
public struct ACPRequest: Encodable, Sendable {
public nonisolated let jsonrpc = "2.0"
public nonisolated let id: Int
public nonisolated let method: String
public nonisolated let params: [String: AnyCodable]
public init(
id: Int,
method: String,
params: [String: AnyCodable]
) {
self.id = id
self.method = method
self.params = params
}
public enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(jsonrpc, forKey: .jsonrpc)
try c.encode(id, forKey: .id)
try c.encode(method, forKey: .method)
try c.encode(params, forKey: .params)
}
}
public struct ACPRawMessage: Decodable, Sendable {
public nonisolated let jsonrpc: String?
public nonisolated let id: Int?
public nonisolated let method: String?
public nonisolated let result: AnyCodable?
public nonisolated let error: ACPError?
public nonisolated let params: AnyCodable?
public nonisolated var isResponse: Bool { id != nil && method == nil }
public nonisolated var isNotification: Bool { method != nil && id == nil }
public nonisolated var isRequest: Bool { method != nil && id != nil }
public enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
self.id = try c.decodeIfPresent(Int.self, forKey: .id)
self.method = try c.decodeIfPresent(String.self, forKey: .method)
self.result = try c.decodeIfPresent(AnyCodable.self, forKey: .result)
self.error = try c.decodeIfPresent(ACPError.self, forKey: .error)
self.params = try c.decodeIfPresent(AnyCodable.self, forKey: .params)
}
}
public struct ACPError: Decodable, Sendable {
public nonisolated let code: Int
public nonisolated let message: String
public enum CodingKeys: String, CodingKey { case code, message }
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.code = try c.decode(Int.self, forKey: .code)
self.message = try c.decode(String.self, forKey: .message)
}
}
// MARK: - AnyCodable (for dynamic JSON)
public struct AnyCodable: Codable, @unchecked Sendable {
public nonisolated let value: Any
public nonisolated init(_ value: Any) { self.value = value }
// NOT marked `nonisolated`: Swift's default-isolation treats writes to a
// `let value: Any` stored property as MainActor-isolated even when the
// property is declared nonisolated (Any can't be strictly Sendable, so
// the compiler can't prove the write is safe off-main). Leaving the
// init as default-isolated silences the mutation warnings; the Decodable
// conformance is still usable from ACPClient's nonisolated read loop
// because all callers are already @preconcurrency with respect to
// `AnyCodable` (it's @unchecked Sendable).
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
value = NSNull()
} else if let bool = try? container.decode(Bool.self) {
value = bool
} else if let int = try? container.decode(Int.self) {
value = int
} else if let double = try? container.decode(Double.self) {
value = double
} else if let string = try? container.decode(String.self) {
value = string
} else if let array = try? container.decode([AnyCodable].self) {
value = array.map(\.value)
} else if let dict = try? container.decode([String: AnyCodable].self) {
value = dict.mapValues(\.value)
} else {
value = NSNull()
}
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case is NSNull:
try container.encodeNil()
case let bool as Bool:
try container.encode(bool)
case let int as Int:
try container.encode(int)
case let double as Double:
try container.encode(double)
case let string as String:
try container.encode(string)
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
default:
try container.encodeNil()
}
}
// MARK: - Accessors
public nonisolated var stringValue: String? { value as? String }
public nonisolated var intValue: Int? { value as? Int }
public nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
public nonisolated var arrayValue: [Any]? { value as? [Any] }
}
// MARK: - ACP Events (parsed from session/update notifications)
public enum ACPEvent: Sendable {
case messageChunk(sessionId: String, text: String)
case thoughtChunk(sessionId: String, text: String)
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
case toolCallUpdate(sessionId: String, update: ACPToolCallUpdateEvent)
case permissionRequest(sessionId: String, requestId: Int, request: ACPPermissionRequestEvent)
case promptComplete(sessionId: String, response: ACPPromptResult)
case availableCommands(sessionId: String, commands: [[String: Any]])
case connectionLost(reason: String)
case unknown(sessionId: String, type: String)
}
public struct ACPToolCallEvent: Sendable {
public let toolCallId: String
public let title: String
public let kind: String
public let status: String
public let content: String
public let rawInput: [String: Any]?
public init(
toolCallId: String,
title: String,
kind: String,
status: String,
content: String,
rawInput: [String: Any]?
) {
self.toolCallId = toolCallId
self.title = title
self.kind = kind
self.status = status
self.content = content
self.rawInput = rawInput
}
public var functionName: String {
// title format is "functionName: summary" or just "functionName"
let parts = title.split(separator: ":", maxSplits: 1)
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
}
public var argumentsSummary: String {
let parts = title.split(separator: ":", maxSplits: 1)
if parts.count > 1 {
return String(parts[1]).trimmingCharacters(in: .whitespaces)
}
return ""
}
public var argumentsJSON: String {
guard let input = rawInput,
let data = try? JSONSerialization.data(withJSONObject: input),
let str = String(data: data, encoding: .utf8) else { return "{}" }
return str
}
}
public struct ACPToolCallUpdateEvent: Sendable {
public let toolCallId: String
public let kind: String
public let status: String
public let content: String
public let rawOutput: String?
public init(
toolCallId: String,
kind: String,
status: String,
content: String,
rawOutput: String?
) {
self.toolCallId = toolCallId
self.kind = kind
self.status = status
self.content = content
self.rawOutput = rawOutput
}
}
public struct ACPPermissionRequestEvent: Sendable {
public let toolCallTitle: String
public let toolCallKind: String
public let options: [(optionId: String, name: String)]
public init(
toolCallTitle: String,
toolCallKind: String,
options: [(optionId: String, name: String)]
) {
self.toolCallTitle = toolCallTitle
self.toolCallKind = toolCallKind
self.options = options
}
}
public struct ACPPromptResult: Sendable {
public let stopReason: String
public let inputTokens: Int
public let outputTokens: Int
public let thoughtTokens: Int
public let cachedReadTokens: Int
public init(
stopReason: String,
inputTokens: Int,
outputTokens: Int,
thoughtTokens: Int,
cachedReadTokens: Int
) {
self.stopReason = stopReason
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.thoughtTokens = thoughtTokens
self.cachedReadTokens = cachedReadTokens
}
}
// MARK: - Event Parsing
public enum ACPEventParser {
public nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
guard notification.method == "session/update",
let params = notification.params?.dictValue,
let sessionId = params["sessionId"] as? String,
let update = params["update"] as? [String: Any],
let updateType = update["sessionUpdate"] as? String else {
return nil
}
switch updateType {
case "agent_message_chunk":
let text = extractContentText(from: update)
return .messageChunk(sessionId: sessionId, text: text)
case "agent_thought_chunk":
let text = extractContentText(from: update)
return .thoughtChunk(sessionId: sessionId, text: text)
case "tool_call":
let event = ACPToolCallEvent(
toolCallId: update["toolCallId"] as? String ?? "",
title: update["title"] as? String ?? "",
kind: update["kind"] as? String ?? "other",
status: update["status"] as? String ?? "pending",
content: extractContentArrayText(from: update),
rawInput: update["rawInput"] as? [String: Any]
)
return .toolCallStart(sessionId: sessionId, call: event)
case "tool_call_update":
let event = ACPToolCallUpdateEvent(
toolCallId: update["toolCallId"] as? String ?? "",
kind: update["kind"] as? String ?? "other",
status: update["status"] as? String ?? "completed",
content: extractContentArrayText(from: update),
rawOutput: update["rawOutput"] as? String
)
return .toolCallUpdate(sessionId: sessionId, update: event)
case "available_commands_update":
let commands = update["availableCommands"] as? [[String: Any]] ?? []
return .availableCommands(sessionId: sessionId, commands: commands)
default:
return .unknown(sessionId: sessionId, type: updateType)
}
}
public nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
guard message.method == "session/request_permission",
let params = message.params?.dictValue,
let sessionId = params["sessionId"] as? String,
let requestId = message.id else { return nil }
let toolCall = params["toolCall"] as? [String: Any] ?? [:]
let optionsRaw = params["options"] as? [[String: Any]] ?? []
let options = optionsRaw.compactMap { opt -> (optionId: String, name: String)? in
guard let id = opt["optionId"] as? String,
let name = opt["name"] as? String else { return nil }
return (optionId: id, name: name)
}
let event = ACPPermissionRequestEvent(
toolCallTitle: toolCall["title"] as? String ?? "",
toolCallKind: toolCall["kind"] as? String ?? "other",
options: options
)
return .permissionRequest(sessionId: sessionId, requestId: requestId, request: event)
}
// MARK: - Content Extraction
nonisolated private static func extractContentText(from update: [String: Any]) -> String {
if let content = update["content"] as? [String: Any],
let text = content["text"] as? String {
return text
}
return ""
}
nonisolated private static func extractContentArrayText(from update: [String: Any]) -> String {
if let contentArray = update["content"] as? [[String: Any]] {
return contentArray.compactMap { item -> String? in
guard let inner = item["content"] as? [String: Any] else { return nil }
return inner["text"] as? String
}.joined(separator: "\n")
}
return ""
}
}
@@ -0,0 +1,888 @@
import Foundation
/// Settings for one of hermes's auxiliary model tasks (vision, compression, approvals, etc.).
/// Every auxiliary task follows the same provider/model/base_url/api_key/timeout pattern.
public struct AuxiliaryModel: Sendable, Equatable {
public var provider: String
public var model: String
public var baseURL: String
public var apiKey: String
public var timeout: Int
public init(
provider: String,
model: String,
baseURL: String,
apiKey: String,
timeout: Int
) {
self.provider = provider
self.model = model
self.baseURL = baseURL
self.apiKey = apiKey
self.timeout = timeout
}
public nonisolated static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
}
/// Group of display-related settings mirroring the `display:` block in config.yaml.
public struct DisplaySettings: Sendable, Equatable {
public var skin: String
public var compact: Bool
public var resumeDisplay: String // "full" | "minimal"
public var bellOnComplete: Bool
public var inlineDiffs: Bool
public var toolProgressCommand: Bool
public var toolPreviewLength: Int
public var busyInputMode: String // e.g. "interrupt"
public init(
skin: String,
compact: Bool,
resumeDisplay: String,
bellOnComplete: Bool,
inlineDiffs: Bool,
toolProgressCommand: Bool,
toolPreviewLength: Int,
busyInputMode: String
) {
self.skin = skin
self.compact = compact
self.resumeDisplay = resumeDisplay
self.bellOnComplete = bellOnComplete
self.inlineDiffs = inlineDiffs
self.toolProgressCommand = toolProgressCommand
self.toolPreviewLength = toolPreviewLength
self.busyInputMode = busyInputMode
}
public nonisolated static let empty = DisplaySettings(
skin: "default",
compact: false,
resumeDisplay: "full",
bellOnComplete: false,
inlineDiffs: true,
toolProgressCommand: false,
toolPreviewLength: 0,
busyInputMode: "interrupt"
)
}
/// Container/terminal backend options. These map to `terminal.*` keys in config.yaml.
public struct TerminalSettings: Sendable, Equatable {
public var cwd: String
public var timeout: Int
public var envPassthrough: [String]
public var persistentShell: Bool
public var dockerImage: String
public var dockerMountCwdToWorkspace: Bool
public var dockerForwardEnv: [String]
public var dockerVolumes: [String]
public var containerCPU: Int // 0 = unlimited
public var containerMemory: Int // MB, 0 = unlimited
public var containerDisk: Int // MB, 0 = unlimited
public var containerPersistent: Bool
public var modalImage: String
public var modalMode: String // "auto" | other
public var daytonaImage: String
public var singularityImage: String
public init(
cwd: String,
timeout: Int,
envPassthrough: [String],
persistentShell: Bool,
dockerImage: String,
dockerMountCwdToWorkspace: Bool,
dockerForwardEnv: [String],
dockerVolumes: [String],
containerCPU: Int,
containerMemory: Int,
containerDisk: Int,
containerPersistent: Bool,
modalImage: String,
modalMode: String,
daytonaImage: String,
singularityImage: String
) {
self.cwd = cwd
self.timeout = timeout
self.envPassthrough = envPassthrough
self.persistentShell = persistentShell
self.dockerImage = dockerImage
self.dockerMountCwdToWorkspace = dockerMountCwdToWorkspace
self.dockerForwardEnv = dockerForwardEnv
self.dockerVolumes = dockerVolumes
self.containerCPU = containerCPU
self.containerMemory = containerMemory
self.containerDisk = containerDisk
self.containerPersistent = containerPersistent
self.modalImage = modalImage
self.modalMode = modalMode
self.daytonaImage = daytonaImage
self.singularityImage = singularityImage
}
public nonisolated static let empty = TerminalSettings(
cwd: ".",
timeout: 180,
envPassthrough: [],
persistentShell: true,
dockerImage: "",
dockerMountCwdToWorkspace: false,
dockerForwardEnv: [],
dockerVolumes: [],
containerCPU: 0,
containerMemory: 0,
containerDisk: 0,
containerPersistent: false,
modalImage: "",
modalMode: "auto",
daytonaImage: "",
singularityImage: ""
)
}
/// Browser automation tuning (`browser.*`).
public struct BrowserSettings: Sendable, Equatable {
public var inactivityTimeout: Int
public var commandTimeout: Int
public var recordSessions: Bool
public var allowPrivateURLs: Bool
public var camofoxManagedPersistence: Bool
public init(
inactivityTimeout: Int,
commandTimeout: Int,
recordSessions: Bool,
allowPrivateURLs: Bool,
camofoxManagedPersistence: Bool
) {
self.inactivityTimeout = inactivityTimeout
self.commandTimeout = commandTimeout
self.recordSessions = recordSessions
self.allowPrivateURLs = allowPrivateURLs
self.camofoxManagedPersistence = camofoxManagedPersistence
}
public nonisolated static let empty = BrowserSettings(
inactivityTimeout: 120,
commandTimeout: 30,
recordSessions: false,
allowPrivateURLs: false,
camofoxManagedPersistence: false
)
}
/// Voice push-to-talk plus TTS/STT provider settings.
public struct VoiceSettings: Sendable, Equatable {
public var recordKey: String
public var maxRecordingSeconds: Int
public var silenceDuration: Double
// TTS
public var ttsProvider: String
public var ttsEdgeVoice: String
public var ttsElevenLabsVoiceID: String
public var ttsElevenLabsModelID: String
public var ttsOpenAIModel: String
public var ttsOpenAIVoice: String
public var ttsNeuTTSModel: String
public var ttsNeuTTSDevice: String
// STT
public var sttEnabled: Bool
public var sttProvider: String
public var sttLocalModel: String
public var sttLocalLanguage: String
public var sttOpenAIModel: String
public var sttMistralModel: String
public init(
recordKey: String,
maxRecordingSeconds: Int,
silenceDuration: Double,
ttsProvider: String,
ttsEdgeVoice: String,
ttsElevenLabsVoiceID: String,
ttsElevenLabsModelID: String,
ttsOpenAIModel: String,
ttsOpenAIVoice: String,
ttsNeuTTSModel: String,
ttsNeuTTSDevice: String,
sttEnabled: Bool,
sttProvider: String,
sttLocalModel: String,
sttLocalLanguage: String,
sttOpenAIModel: String,
sttMistralModel: String
) {
self.recordKey = recordKey
self.maxRecordingSeconds = maxRecordingSeconds
self.silenceDuration = silenceDuration
self.ttsProvider = ttsProvider
self.ttsEdgeVoice = ttsEdgeVoice
self.ttsElevenLabsVoiceID = ttsElevenLabsVoiceID
self.ttsElevenLabsModelID = ttsElevenLabsModelID
self.ttsOpenAIModel = ttsOpenAIModel
self.ttsOpenAIVoice = ttsOpenAIVoice
self.ttsNeuTTSModel = ttsNeuTTSModel
self.ttsNeuTTSDevice = ttsNeuTTSDevice
self.sttEnabled = sttEnabled
self.sttProvider = sttProvider
self.sttLocalModel = sttLocalModel
self.sttLocalLanguage = sttLocalLanguage
self.sttOpenAIModel = sttOpenAIModel
self.sttMistralModel = sttMistralModel
}
public nonisolated static let empty = VoiceSettings(
recordKey: "ctrl+b",
maxRecordingSeconds: 120,
silenceDuration: 3.0,
ttsProvider: "edge",
ttsEdgeVoice: "en-US-AriaNeural",
ttsElevenLabsVoiceID: "",
ttsElevenLabsModelID: "eleven_multilingual_v2",
ttsOpenAIModel: "gpt-4o-mini-tts",
ttsOpenAIVoice: "alloy",
ttsNeuTTSModel: "neuphonic/neutts-air-q4-gguf",
ttsNeuTTSDevice: "cpu",
sttEnabled: true,
sttProvider: "local",
sttLocalModel: "base",
sttLocalLanguage: "",
sttOpenAIModel: "whisper-1",
sttMistralModel: "voxtral-mini-latest"
)
}
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
public struct AuxiliarySettings: Sendable, Equatable {
public var vision: AuxiliaryModel
public var webExtract: AuxiliaryModel
public var compression: AuxiliaryModel
public var sessionSearch: AuxiliaryModel
public var skillsHub: AuxiliaryModel
public var approval: AuxiliaryModel
public var mcp: AuxiliaryModel
public var flushMemories: AuxiliaryModel
public init(
vision: AuxiliaryModel,
webExtract: AuxiliaryModel,
compression: AuxiliaryModel,
sessionSearch: AuxiliaryModel,
skillsHub: AuxiliaryModel,
approval: AuxiliaryModel,
mcp: AuxiliaryModel,
flushMemories: AuxiliaryModel
) {
self.vision = vision
self.webExtract = webExtract
self.compression = compression
self.sessionSearch = sessionSearch
self.skillsHub = skillsHub
self.approval = approval
self.mcp = mcp
self.flushMemories = flushMemories
}
public nonisolated static let empty = AuxiliarySettings(
vision: .empty,
webExtract: .empty,
compression: .empty,
sessionSearch: .empty,
skillsHub: .empty,
approval: .empty,
mcp: .empty,
flushMemories: .empty
)
}
/// Security/redaction/firewall config. Website blocklist is nested in YAML.
public struct SecuritySettings: Sendable, Equatable {
public var redactSecrets: Bool
public var redactPII: Bool // from privacy.redact_pii
public var tirithEnabled: Bool
public var tirithPath: String
public var tirithTimeout: Int
public var tirithFailOpen: Bool
public var blocklistEnabled: Bool
public var blocklistDomains: [String]
public init(
redactSecrets: Bool,
redactPII: Bool,
tirithEnabled: Bool,
tirithPath: String,
tirithTimeout: Int,
tirithFailOpen: Bool,
blocklistEnabled: Bool,
blocklistDomains: [String]
) {
self.redactSecrets = redactSecrets
self.redactPII = redactPII
self.tirithEnabled = tirithEnabled
self.tirithPath = tirithPath
self.tirithTimeout = tirithTimeout
self.tirithFailOpen = tirithFailOpen
self.blocklistEnabled = blocklistEnabled
self.blocklistDomains = blocklistDomains
}
public nonisolated static let empty = SecuritySettings(
redactSecrets: true,
redactPII: false,
tirithEnabled: true,
tirithPath: "tirith",
tirithTimeout: 5,
tirithFailOpen: true,
blocklistEnabled: false,
blocklistDomains: []
)
}
/// Human-delay simulates realistic typing pace (`human_delay.*`).
public struct HumanDelaySettings: Sendable, Equatable {
public var mode: String // "off" | "natural" | "custom"
public var minMS: Int
public var maxMS: Int
public init(
mode: String,
minMS: Int,
maxMS: Int
) {
self.mode = mode
self.minMS = minMS
self.maxMS = maxMS
}
public nonisolated static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
}
/// Compression / context routing.
public struct CompressionSettings: Sendable, Equatable {
public var enabled: Bool
public var threshold: Double
public var targetRatio: Double
public var protectLastN: Int
public init(
enabled: Bool,
threshold: Double,
targetRatio: Double,
protectLastN: Int
) {
self.enabled = enabled
self.threshold = threshold
self.targetRatio = targetRatio
self.protectLastN = protectLastN
}
public nonisolated static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
}
public struct CheckpointSettings: Sendable, Equatable {
public var enabled: Bool
public var maxSnapshots: Int
public init(
enabled: Bool,
maxSnapshots: Int
) {
self.enabled = enabled
self.maxSnapshots = maxSnapshots
}
public nonisolated static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
}
public struct LoggingSettings: Sendable, Equatable {
public var level: String // DEBUG | INFO | WARNING | ERROR
public var maxSizeMB: Int
public var backupCount: Int
public init(
level: String,
maxSizeMB: Int,
backupCount: Int
) {
self.level = level
self.maxSizeMB = maxSizeMB
self.backupCount = backupCount
}
public nonisolated static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
}
public struct DelegationSettings: Sendable, Equatable {
public var model: String
public var provider: String
public var baseURL: String
public var apiKey: String
public var maxIterations: Int
public init(
model: String,
provider: String,
baseURL: String,
apiKey: String,
maxIterations: Int
) {
self.model = model
self.provider = provider
self.baseURL = baseURL
self.apiKey = apiKey
self.maxIterations = maxIterations
}
public nonisolated static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
}
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
public struct DiscordSettings: Sendable, Equatable {
public var requireMention: Bool
public var freeResponseChannels: String
public var autoThread: Bool
public var reactions: Bool
public init(
requireMention: Bool,
freeResponseChannels: String,
autoThread: Bool,
reactions: Bool
) {
self.requireMention = requireMention
self.freeResponseChannels = freeResponseChannels
self.autoThread = autoThread
self.reactions = reactions
}
public nonisolated static let empty = DiscordSettings(requireMention: true, freeResponseChannels: "", autoThread: true, reactions: true)
}
/// Telegram settings under `telegram.*` in config.yaml. Most Telegram tuning is
/// done via environment variables (`TELEGRAM_*`) this is the subset that lives
/// in the YAML.
public struct TelegramSettings: Sendable, Equatable {
public var requireMention: Bool
public var reactions: Bool
public init(
requireMention: Bool,
reactions: Bool
) {
self.requireMention = requireMention
self.reactions = reactions
}
public nonisolated static let empty = TelegramSettings(requireMention: true, reactions: false)
}
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
public struct SlackSettings: Sendable, Equatable {
public var replyToMode: String // "off" | "first" | "all"
public var requireMention: Bool
public var replyInThread: Bool
public var replyBroadcast: Bool
public init(
replyToMode: String,
requireMention: Bool,
replyInThread: Bool,
replyBroadcast: Bool
) {
self.replyToMode = replyToMode
self.requireMention = requireMention
self.replyInThread = replyInThread
self.replyBroadcast = replyBroadcast
}
public nonisolated static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
}
/// Matrix settings under `matrix.*`.
public struct MatrixSettings: Sendable, Equatable {
public var requireMention: Bool
public var autoThread: Bool
public var dmMentionThreads: Bool
public init(
requireMention: Bool,
autoThread: Bool,
dmMentionThreads: Bool
) {
self.requireMention = requireMention
self.autoThread = autoThread
self.dmMentionThreads = dmMentionThreads
}
public nonisolated static let empty = MatrixSettings(requireMention: true, autoThread: true, dmMentionThreads: false)
}
/// Mattermost settings. Mattermost is mostly driven by env vars; config.yaml
/// currently just exposes `group_sessions_per_user` at the top level, but we
/// reserve this struct for future expansion so the form has a stable type.
public struct MattermostSettings: Sendable, Equatable {
public var requireMention: Bool
public var replyMode: String // "thread" | "off"
public init(
requireMention: Bool,
replyMode: String
) {
self.requireMention = requireMention
self.replyMode = replyMode
}
public nonisolated static let empty = MattermostSettings(requireMention: true, replyMode: "off")
}
/// WhatsApp settings under `whatsapp.*`.
public struct WhatsAppSettings: Sendable, Equatable {
public var unauthorizedDMBehavior: String // "pair" | "ignore"
public var replyPrefix: String
public init(
unauthorizedDMBehavior: String,
replyPrefix: String
) {
self.unauthorizedDMBehavior = unauthorizedDMBehavior
self.replyPrefix = replyPrefix
}
public nonisolated static let empty = WhatsAppSettings(unauthorizedDMBehavior: "pair", replyPrefix: "")
}
/// Home Assistant filters under `platforms.homeassistant.extra`. Hermes ignores
/// every state change by default; users must opt-in via at least one filter.
public struct HomeAssistantSettings: Sendable, Equatable {
public var watchDomains: [String]
public var watchEntities: [String]
public var watchAll: Bool
public var ignoreEntities: [String]
public var cooldownSeconds: Int
public init(
watchDomains: [String],
watchEntities: [String],
watchAll: Bool,
ignoreEntities: [String],
cooldownSeconds: Int
) {
self.watchDomains = watchDomains
self.watchEntities = watchEntities
self.watchAll = watchAll
self.ignoreEntities = ignoreEntities
self.cooldownSeconds = cooldownSeconds
}
public nonisolated static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
}
// MARK: - Root Config
public struct HermesConfig: Sendable {
// Original fields preserved for zero breakage with existing call sites.
public var model: String
public var provider: String
public var maxTurns: Int
public var personality: String
public var terminalBackend: String
public var memoryEnabled: Bool
public var memoryCharLimit: Int
public var userCharLimit: Int
public var nudgeInterval: Int
public var streaming: Bool
public var showReasoning: Bool
public var verbose: Bool
public var autoTTS: Bool
public var silenceThreshold: Int
public var reasoningEffort: String
public var showCost: Bool
public var approvalMode: String
public var browserBackend: String
public var memoryProvider: String
public var dockerEnv: [String: String]
public var commandAllowlist: [String]
public var memoryProfile: String
public var serviceTier: String
public var gatewayNotifyInterval: Int
public var forceIPv4: Bool
public var contextEngine: String
public var interimAssistantMessages: Bool
public var honchoInitOnSessionStart: Bool
// Phase 1 additions
public var timezone: String
public var userProfileEnabled: Bool
public var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
public var gatewayTimeout: Int
public var approvalTimeout: Int
public var fileReadMaxChars: Int
public var cronWrapResponse: Bool
public var prefillMessagesFile: String
public var skillsExternalDirs: [String]
// Grouped blocks
public var display: DisplaySettings
public var terminal: TerminalSettings
public var browser: BrowserSettings
public var voice: VoiceSettings
public var auxiliary: AuxiliarySettings
public var security: SecuritySettings
public var humanDelay: HumanDelaySettings
public var compression: CompressionSettings
public var checkpoints: CheckpointSettings
public var logging: LoggingSettings
public var delegation: DelegationSettings
public var discord: DiscordSettings
public var telegram: TelegramSettings
public var slack: SlackSettings
public var matrix: MatrixSettings
public var mattermost: MattermostSettings
public var whatsapp: WhatsAppSettings
public var homeAssistant: HomeAssistantSettings
public init(
model: String,
provider: String,
maxTurns: Int,
personality: String,
terminalBackend: String,
memoryEnabled: Bool,
memoryCharLimit: Int,
userCharLimit: Int,
nudgeInterval: Int,
streaming: Bool,
showReasoning: Bool,
verbose: Bool,
autoTTS: Bool,
silenceThreshold: Int,
reasoningEffort: String,
showCost: Bool,
approvalMode: String,
browserBackend: String,
memoryProvider: String,
dockerEnv: [String: String],
commandAllowlist: [String],
memoryProfile: String,
serviceTier: String,
gatewayNotifyInterval: Int,
forceIPv4: Bool,
contextEngine: String,
interimAssistantMessages: Bool,
honchoInitOnSessionStart: Bool,
timezone: String,
userProfileEnabled: Bool,
toolUseEnforcement: String,
gatewayTimeout: Int,
approvalTimeout: Int,
fileReadMaxChars: Int,
cronWrapResponse: Bool,
prefillMessagesFile: String,
skillsExternalDirs: [String],
display: DisplaySettings,
terminal: TerminalSettings,
browser: BrowserSettings,
voice: VoiceSettings,
auxiliary: AuxiliarySettings,
security: SecuritySettings,
humanDelay: HumanDelaySettings,
compression: CompressionSettings,
checkpoints: CheckpointSettings,
logging: LoggingSettings,
delegation: DelegationSettings,
discord: DiscordSettings,
telegram: TelegramSettings,
slack: SlackSettings,
matrix: MatrixSettings,
mattermost: MattermostSettings,
whatsapp: WhatsAppSettings,
homeAssistant: HomeAssistantSettings
) {
self.model = model
self.provider = provider
self.maxTurns = maxTurns
self.personality = personality
self.terminalBackend = terminalBackend
self.memoryEnabled = memoryEnabled
self.memoryCharLimit = memoryCharLimit
self.userCharLimit = userCharLimit
self.nudgeInterval = nudgeInterval
self.streaming = streaming
self.showReasoning = showReasoning
self.verbose = verbose
self.autoTTS = autoTTS
self.silenceThreshold = silenceThreshold
self.reasoningEffort = reasoningEffort
self.showCost = showCost
self.approvalMode = approvalMode
self.browserBackend = browserBackend
self.memoryProvider = memoryProvider
self.dockerEnv = dockerEnv
self.commandAllowlist = commandAllowlist
self.memoryProfile = memoryProfile
self.serviceTier = serviceTier
self.gatewayNotifyInterval = gatewayNotifyInterval
self.forceIPv4 = forceIPv4
self.contextEngine = contextEngine
self.interimAssistantMessages = interimAssistantMessages
self.honchoInitOnSessionStart = honchoInitOnSessionStart
self.timezone = timezone
self.userProfileEnabled = userProfileEnabled
self.toolUseEnforcement = toolUseEnforcement
self.gatewayTimeout = gatewayTimeout
self.approvalTimeout = approvalTimeout
self.fileReadMaxChars = fileReadMaxChars
self.cronWrapResponse = cronWrapResponse
self.prefillMessagesFile = prefillMessagesFile
self.skillsExternalDirs = skillsExternalDirs
self.display = display
self.terminal = terminal
self.browser = browser
self.voice = voice
self.auxiliary = auxiliary
self.security = security
self.humanDelay = humanDelay
self.compression = compression
self.checkpoints = checkpoints
self.logging = logging
self.delegation = delegation
self.discord = discord
self.telegram = telegram
self.slack = slack
self.matrix = matrix
self.mattermost = mattermost
self.whatsapp = whatsapp
self.homeAssistant = homeAssistant
}
public nonisolated static let empty = HermesConfig(
model: "unknown",
provider: "unknown",
maxTurns: 0,
personality: "default",
terminalBackend: "local",
memoryEnabled: false,
memoryCharLimit: 0,
userCharLimit: 0,
nudgeInterval: 0,
streaming: true,
showReasoning: false,
verbose: false,
autoTTS: true,
silenceThreshold: 200,
reasoningEffort: "medium",
showCost: false,
approvalMode: "manual",
browserBackend: "",
memoryProvider: "",
dockerEnv: [:],
commandAllowlist: [],
memoryProfile: "",
serviceTier: "normal",
gatewayNotifyInterval: 600,
forceIPv4: false,
contextEngine: "compressor",
interimAssistantMessages: true,
honchoInitOnSessionStart: false,
timezone: "",
userProfileEnabled: true,
toolUseEnforcement: "auto",
gatewayTimeout: 1800,
approvalTimeout: 60,
fileReadMaxChars: 100_000,
cronWrapResponse: true,
prefillMessagesFile: "",
skillsExternalDirs: [],
display: .empty,
terminal: .empty,
browser: .empty,
voice: .empty,
auxiliary: .empty,
security: .empty,
humanDelay: .empty,
compression: .empty,
checkpoints: .empty,
logging: .empty,
delegation: .empty,
discord: .empty,
telegram: .empty,
slack: .empty,
matrix: .empty,
mattermost: .empty,
whatsapp: .empty,
homeAssistant: .empty
)
}
// Hand-written `init(from:)` so Swift 6 doesn't synthesize a
// MainActor-isolated Decodable conformance (which would fail to be used from
// `HermesFileService.loadGatewayState()`, a nonisolated method).
public struct GatewayState: Sendable, Codable {
public nonisolated let pid: Int?
public nonisolated let kind: String?
public nonisolated let gatewayState: String?
public nonisolated let exitReason: String?
public nonisolated let platforms: [String: PlatformState]?
public nonisolated let updatedAt: String?
public enum CodingKeys: String, CodingKey {
case pid, kind
case gatewayState = "gateway_state"
case exitReason = "exit_reason"
case platforms
case updatedAt = "updated_at"
}
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.pid = try c.decodeIfPresent(Int.self, forKey: .pid)
self.kind = try c.decodeIfPresent(String.self, forKey: .kind)
self.gatewayState = try c.decodeIfPresent(String.self, forKey: .gatewayState)
self.exitReason = try c.decodeIfPresent(String.self, forKey: .exitReason)
self.platforms = try c.decodeIfPresent([String: PlatformState].self, forKey: .platforms)
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
}
public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(pid, forKey: .pid)
try c.encodeIfPresent(kind, forKey: .kind)
try c.encodeIfPresent(gatewayState, forKey: .gatewayState)
try c.encodeIfPresent(exitReason, forKey: .exitReason)
try c.encodeIfPresent(platforms, forKey: .platforms)
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
}
public nonisolated var isRunning: Bool {
gatewayState == "running"
}
public nonisolated var statusText: String {
gatewayState ?? "unknown"
}
}
public struct PlatformState: Sendable, Codable {
public nonisolated let connected: Bool?
public nonisolated let error: String?
public enum CodingKeys: String, CodingKey { case connected, error }
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.connected = try c.decodeIfPresent(Bool.self, forKey: .connected)
self.error = try c.decodeIfPresent(String.self, forKey: .error)
}
public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(connected, forKey: .connected)
try c.encodeIfPresent(error, forKey: .error)
}
}
@@ -0,0 +1,158 @@
import Foundation
public struct HermesCronJob: Identifiable, Sendable, Codable {
public nonisolated let id: String
public nonisolated let name: String
public nonisolated let prompt: String
public nonisolated let skills: [String]?
public nonisolated let model: String?
public nonisolated let schedule: CronSchedule
public nonisolated let enabled: Bool
public nonisolated let state: String
public nonisolated let deliver: String?
public nonisolated let nextRunAt: String?
public nonisolated let lastRunAt: String?
public nonisolated let lastError: String?
public nonisolated let preRunScript: String?
public nonisolated let deliveryFailures: Int?
public nonisolated let lastDeliveryError: String?
public nonisolated let timeoutType: String?
public nonisolated let timeoutSeconds: Int?
public nonisolated let silent: Bool?
public enum CodingKeys: String, CodingKey {
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
case nextRunAt = "next_run_at"
case lastRunAt = "last_run_at"
case lastError = "last_error"
case preRunScript = "pre_run_script"
case deliveryFailures = "delivery_failures"
case lastDeliveryError = "last_delivery_error"
case timeoutType = "timeout_type"
case timeoutSeconds = "timeout_seconds"
}
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(String.self, forKey: .id)
self.name = try c.decode(String.self, forKey: .name)
self.prompt = try c.decode(String.self, forKey: .prompt)
self.skills = try c.decodeIfPresent([String].self, forKey: .skills)
self.model = try c.decodeIfPresent(String.self, forKey: .model)
self.schedule = try c.decode(CronSchedule.self, forKey: .schedule)
self.enabled = try c.decode(Bool.self, forKey: .enabled)
self.state = try c.decode(String.self, forKey: .state)
self.deliver = try c.decodeIfPresent(String.self, forKey: .deliver)
self.nextRunAt = try c.decodeIfPresent(String.self, forKey: .nextRunAt)
self.lastRunAt = try c.decodeIfPresent(String.self, forKey: .lastRunAt)
self.lastError = try c.decodeIfPresent(String.self, forKey: .lastError)
self.preRunScript = try c.decodeIfPresent(String.self, forKey: .preRunScript)
self.deliveryFailures = try c.decodeIfPresent(Int.self, forKey: .deliveryFailures)
self.lastDeliveryError = try c.decodeIfPresent(String.self, forKey: .lastDeliveryError)
self.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType)
self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds)
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
}
public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encode(name, forKey: .name)
try c.encode(prompt, forKey: .prompt)
try c.encodeIfPresent(skills, forKey: .skills)
try c.encodeIfPresent(model, forKey: .model)
try c.encode(schedule, forKey: .schedule)
try c.encode(enabled, forKey: .enabled)
try c.encode(state, forKey: .state)
try c.encodeIfPresent(deliver, forKey: .deliver)
try c.encodeIfPresent(nextRunAt, forKey: .nextRunAt)
try c.encodeIfPresent(lastRunAt, forKey: .lastRunAt)
try c.encodeIfPresent(lastError, forKey: .lastError)
try c.encodeIfPresent(preRunScript, forKey: .preRunScript)
try c.encodeIfPresent(deliveryFailures, forKey: .deliveryFailures)
try c.encodeIfPresent(lastDeliveryError, forKey: .lastDeliveryError)
try c.encodeIfPresent(timeoutType, forKey: .timeoutType)
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try c.encodeIfPresent(silent, forKey: .silent)
}
public nonisolated var stateIcon: String {
switch state {
case "scheduled": return "clock"
case "running": return "play.circle"
case "completed": return "checkmark.circle"
case "failed": return "xmark.circle"
default: return "questionmark.circle"
}
}
public nonisolated var deliveryDisplay: String? {
guard let deliver, !deliver.isEmpty else { return nil }
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
if deliver.hasPrefix("discord:") {
let parts = deliver.dropFirst("discord:".count).split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
if parts.count == 2 {
return "Discord thread \(parts[1]) in \(parts[0])"
}
if parts.count == 1 {
return "Discord \(parts[0])"
}
}
return deliver
}
}
public struct CronSchedule: Sendable, Codable {
public nonisolated let kind: String
public nonisolated let runAt: String?
public nonisolated let display: String?
public nonisolated let expression: String?
public enum CodingKeys: String, CodingKey {
case kind
case runAt = "run_at"
case display
case expression
}
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.kind = try c.decode(String.self, forKey: .kind)
self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt)
self.display = try c.decodeIfPresent(String.self, forKey: .display)
self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
}
public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(kind, forKey: .kind)
try c.encodeIfPresent(runAt, forKey: .runAt)
try c.encodeIfPresent(display, forKey: .display)
try c.encodeIfPresent(expression, forKey: .expression)
}
}
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
// MainActor-isolated Codable conformance `HermesFileService.loadCronJobs`
// is nonisolated and needs to decode this from a background task.
public struct CronJobsFile: Sendable, Codable {
public nonisolated let jobs: [HermesCronJob]
public nonisolated let updatedAt: String?
public enum CodingKeys: String, CodingKey {
case jobs
case updatedAt = "updated_at"
}
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
}
public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(jobs, forKey: .jobs)
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
}
}
@@ -0,0 +1,104 @@
import Foundation
public enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
case stdio
case http
public var id: String { rawValue }
public var displayName: LocalizedStringResource {
switch self {
case .stdio: return "Local (stdio)"
case .http: return "Remote (HTTP)"
}
}
}
public struct HermesMCPServer: Identifiable, Sendable, Equatable {
public let name: String
public let transport: MCPTransport
public let command: String?
public let args: [String]
public let url: String?
public let auth: String?
public let env: [String: String]
public let headers: [String: String]
public let timeout: Int?
public let connectTimeout: Int?
public let enabled: Bool
public let toolsInclude: [String]
public let toolsExclude: [String]
public let resourcesEnabled: Bool
public let promptsEnabled: Bool
public let hasOAuthToken: Bool
public init(
name: String,
transport: MCPTransport,
command: String?,
args: [String],
url: String?,
auth: String?,
env: [String: String],
headers: [String: String],
timeout: Int?,
connectTimeout: Int?,
enabled: Bool,
toolsInclude: [String],
toolsExclude: [String],
resourcesEnabled: Bool,
promptsEnabled: Bool,
hasOAuthToken: Bool
) {
self.name = name
self.transport = transport
self.command = command
self.args = args
self.url = url
self.auth = auth
self.env = env
self.headers = headers
self.timeout = timeout
self.connectTimeout = connectTimeout
self.enabled = enabled
self.toolsInclude = toolsInclude
self.toolsExclude = toolsExclude
self.resourcesEnabled = resourcesEnabled
self.promptsEnabled = promptsEnabled
self.hasOAuthToken = hasOAuthToken
}
public var id: String { name }
public var summary: String {
switch transport {
case .stdio:
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
return (command ?? "") + argString
case .http:
return url ?? ""
}
}
}
public struct MCPTestResult: Sendable, Equatable {
public let serverName: String
public let succeeded: Bool
public let output: String
public let tools: [String]
public let elapsed: TimeInterval
public init(
serverName: String,
succeeded: Bool,
output: String,
tools: [String],
elapsed: TimeInterval
) {
self.serverName = serverName
self.succeeded = succeeded
self.output = output
self.tools = tools
self.elapsed = elapsed
}
}
@@ -0,0 +1,160 @@
import Foundation
public struct HermesMessage: Identifiable, Sendable {
public let id: Int
public let sessionId: String
public let role: String
public let content: String
public let toolCallId: String?
public let toolCalls: [HermesToolCall]
public let toolName: String?
public let timestamp: Date?
public let tokenCount: Int?
public let finishReason: String?
public let reasoning: String?
public init(
id: Int,
sessionId: String,
role: String,
content: String,
toolCallId: String?,
toolCalls: [HermesToolCall],
toolName: String?,
timestamp: Date?,
tokenCount: Int?,
finishReason: String?,
reasoning: String?
) {
self.id = id
self.sessionId = sessionId
self.role = role
self.content = content
self.toolCallId = toolCallId
self.toolCalls = toolCalls
self.toolName = toolName
self.timestamp = timestamp
self.tokenCount = tokenCount
self.finishReason = finishReason
self.reasoning = reasoning
}
public var isUser: Bool { role == "user" }
public var isAssistant: Bool { role == "assistant" }
public var isToolResult: Bool { role == "tool" }
public var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
}
public struct HermesToolCall: Identifiable, Sendable, Codable {
public var id: String { callId }
public let callId: String
public let functionName: String
public let arguments: String
public enum CodingKeys: String, CodingKey {
case callId = "id"
case type
case function
}
public enum FunctionKeys: String, CodingKey {
case name
case arguments
}
public init(callId: String, functionName: String, arguments: String) {
self.callId = callId
self.functionName = functionName
self.arguments = arguments
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
callId = try container.decode(String.self, forKey: .callId)
let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
functionName = try funcContainer.decode(String.self, forKey: .name)
arguments = try funcContainer.decode(String.self, forKey: .arguments)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(callId, forKey: .callId)
try container.encode("function", forKey: .type)
var funcContainer = container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
try funcContainer.encode(functionName, forKey: .name)
try funcContainer.encode(arguments, forKey: .arguments)
}
public var toolKind: ToolKind {
switch functionName {
case "read_file", "search_files", "vision_analyze": return .read
case "write_file", "patch": return .edit
case "terminal", "execute_code": return .execute
case "web_search", "web_extract": return .fetch
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
default: return .other
}
}
public var argumentsSummary: String {
guard let data = arguments.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return arguments
}
if let command = json["command"] as? String {
return command
}
if let path = json["path"] as? String {
return path
}
if let query = json["query"] as? String {
return query
}
if let url = json["url"] as? String {
return url
}
return arguments.prefix(120) + (arguments.count > 120 ? "..." : "")
}
}
public enum ToolKind: String, Sendable, CaseIterable {
case read
case edit
case execute
case fetch
case browser
case other
public var displayName: LocalizedStringResource {
switch self {
case .read: return "Read"
case .edit: return "Edit"
case .execute: return "Execute"
case .fetch: return "Fetch"
case .browser: return "Browser"
case .other: return "Other"
}
}
public var icon: String {
switch self {
case .read: return "doc.text.magnifyingglass"
case .edit: return "pencil"
case .execute: return "terminal"
case .fetch: return "globe"
case .browser: return "safari"
case .other: return "gearshape"
}
}
public var color: String {
switch self {
case .read: return "green"
case .edit: return "blue"
case .execute: return "orange"
case .fetch: return "purple"
case .browser: return "indigo"
case .other: return "gray"
}
}
}
@@ -0,0 +1,102 @@
import Foundation
/// The filesystem layout of a Hermes installation, parameterized by the
/// `home` directory. The same layout is used for local installations (where
/// `home` is an absolute macOS path like `/Users/alan/.hermes`) and for
/// remote installations reached over SSH (where `home` is a remote path like
/// `/home/deploy/.hermes` or an unexpanded `~/.hermes` that the remote shell
/// will resolve).
///
/// Every path that used to live as a module-level static on `HermesPaths` is
/// an instance property here. `ServerContext.paths` is the canonical way to
/// reach these values; the old `HermesPaths` statics are preserved as
/// deprecated forwarders so Phase 1 can migrate call sites incrementally.
public struct HermesPathSet: Sendable, Hashable {
public let home: String
/// `true` when this path set belongs to a remote installation. Affects
/// only `hermesBinary` resolution every other path is identical in
/// shape between local and remote.
public let isRemote: Bool
/// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`).
/// Populated by `SSHTransport` once `command -v hermes` has run on the
/// target host. Unused when `isRemote == false`.
public let binaryHint: String?
// MARK: - Defaults
/// Absolute path to the local user's `~/.hermes` directory.
public init(
home: String,
isRemote: Bool,
binaryHint: String?
) {
self.home = home
self.isRemote = isRemote
self.binaryHint = binaryHint
}
public nonisolated static let defaultLocalHome: String = {
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return user + "/.hermes"
}()
/// Default remote home when the user doesn't override it in `SSHConfig`.
/// We leave `~` unexpanded on purpose the remote shell resolves it.
public nonisolated static let defaultRemoteHome: String = "~/.hermes"
// MARK: - Paths (mirror of the old HermesPaths layout)
public nonisolated var stateDB: String { home + "/state.db" }
public nonisolated var configYAML: String { home + "/config.yaml" }
public nonisolated var envFile: String { home + "/.env" }
public nonisolated var authJSON: String { home + "/auth.json" }
public nonisolated var soulMD: String { home + "/SOUL.md" }
public nonisolated var pluginsDir: String { home + "/plugins" }
public nonisolated var memoriesDir: String { home + "/memories" }
public nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
public nonisolated var userMD: String { memoriesDir + "/USER.md" }
public nonisolated var sessionsDir: String { home + "/sessions" }
public nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
public nonisolated var cronOutputDir: String { home + "/cron/output" }
public nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
public nonisolated var skillsDir: String { home + "/skills" }
public nonisolated var errorsLog: String { home + "/logs/errors.log" }
public nonisolated var agentLog: String { home + "/logs/agent.log" }
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
public nonisolated var scarfDir: String { home + "/scarf" }
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
// MARK: - Binary resolution
/// Install locations we probe for the local `hermes` binary, in priority
/// order. Checked on every access so a user installing via a different
/// method doesn't need to relaunch Scarf.
public nonisolated static let hermesBinaryCandidates: [String] = {
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return [
user + "/.local/bin/hermes", // pipx / pip --user (default)
"/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon
"/usr/local/bin/hermes", // Homebrew on Intel / manual install
user + "/.hermes/bin/hermes" // Some self-install layouts
]
}()
/// Resolved path to the `hermes` executable for this installation.
///
/// Local: returns the first executable candidate, falling back to the
/// pipx default so error messages still make sense on a fresh machine.
///
/// Remote: returns `binaryHint` (populated at connect time) or bare
/// `"hermes"` as a last-resort default that relies on the remote `$PATH`.
public nonisolated var hermesBinary: String {
if isRemote {
return binaryHint ?? "hermes"
}
for path in Self.hermesBinaryCandidates
where FileManager.default.isExecutableFile(atPath: path) {
return path
}
return Self.hermesBinaryCandidates[0]
}
}
@@ -0,0 +1,103 @@
import Foundation
public struct HermesSession: Identifiable, Sendable {
public let id: String
public let source: String
public let userId: String?
public let model: String?
public let title: String?
public let parentSessionId: String?
public let startedAt: Date?
public let endedAt: Date?
public let endReason: String?
public let messageCount: Int
public let toolCallCount: Int
public let inputTokens: Int
public let outputTokens: Int
public let cacheReadTokens: Int
public let cacheWriteTokens: Int
public let estimatedCostUSD: Double?
public let reasoningTokens: Int
public let actualCostUSD: Double?
public let costStatus: String?
public let billingProvider: String?
public init(
id: String,
source: String,
userId: String?,
model: String?,
title: String?,
parentSessionId: String?,
startedAt: Date?,
endedAt: Date?,
endReason: String?,
messageCount: Int,
toolCallCount: Int,
inputTokens: Int,
outputTokens: Int,
cacheReadTokens: Int,
cacheWriteTokens: Int,
estimatedCostUSD: Double?,
reasoningTokens: Int,
actualCostUSD: Double?,
costStatus: String?,
billingProvider: String?
) {
self.id = id
self.source = source
self.userId = userId
self.model = model
self.title = title
self.parentSessionId = parentSessionId
self.startedAt = startedAt
self.endedAt = endedAt
self.endReason = endReason
self.messageCount = messageCount
self.toolCallCount = toolCallCount
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheReadTokens = cacheReadTokens
self.cacheWriteTokens = cacheWriteTokens
self.estimatedCostUSD = estimatedCostUSD
self.reasoningTokens = reasoningTokens
self.actualCostUSD = actualCostUSD
self.costStatus = costStatus
self.billingProvider = billingProvider
}
public var isSubagent: Bool { parentSessionId != nil }
public var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
public var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
public var costIsActual: Bool { actualCostUSD != nil }
public var duration: TimeInterval? {
guard let start = startedAt, let end = endedAt else { return nil }
return end.timeIntervalSince(start)
}
public var displayTitle: String {
title ?? id
}
public var sourceIcon: String {
KnownPlatforms.icon(for: source)
}
public func withTitle(_ newTitle: String) -> HermesSession {
HermesSession(
id: id, source: source, userId: userId, model: model,
title: newTitle, parentSessionId: parentSessionId,
startedAt: startedAt, endedAt: endedAt, endReason: endReason,
messageCount: messageCount, toolCallCount: toolCallCount,
inputTokens: inputTokens, outputTokens: outputTokens,
cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens,
estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens,
actualCostUSD: actualCostUSD, costStatus: costStatus,
billingProvider: billingProvider
)
}
}
@@ -0,0 +1,42 @@
import Foundation
public struct HermesSkillCategory: Identifiable, Sendable {
public let id: String
public let name: String
public let skills: [HermesSkill]
public init(
id: String,
name: String,
skills: [HermesSkill]
) {
self.id = id
self.name = name
self.skills = skills
}
}
public struct HermesSkill: Identifiable, Sendable {
public let id: String
public let name: String
public let category: String
public let path: String
public let files: [String]
public let requiredConfig: [String]
public init(
id: String,
name: String,
category: String,
path: String,
files: [String],
requiredConfig: [String]
) {
self.id = id
self.name = name
self.category = category
self.path = path
self.files = files
self.requiredConfig = requiredConfig
}
}
@@ -0,0 +1,29 @@
import Foundation
/// A slash command available in chat. Sourced either from the ACP server
/// (`available_commands_update`) or from user-defined `quick_commands` in
/// `config.yaml`.
public struct HermesSlashCommand: Identifiable, Sendable, Equatable {
public enum Source: Sendable, Equatable {
case acp
case quickCommand
}
public var id: String { name }
public let name: String
public let description: String
public let argumentHint: String?
public let source: Source
public init(
name: String,
description: String,
argumentHint: String?,
source: Source
) {
self.name = name
self.description = description
self.argumentHint = argumentHint
self.source = source
}
}
@@ -0,0 +1,76 @@
import Foundation
public struct HermesToolset: Identifiable, Sendable {
public var id: String { name }
public let name: String
public let description: String
public let icon: String
public var enabled: Bool
public init(
name: String,
description: String,
icon: String,
enabled: Bool
) {
self.name = name
self.description = description
self.icon = icon
self.enabled = enabled
}
}
public struct HermesToolPlatform: Identifiable, Sendable {
public var id: String { name }
public let name: String
public let displayName: String
public let icon: String
public init(
name: String,
displayName: String,
icon: String
) {
self.name = name
self.displayName = displayName
self.icon = icon
}
}
public enum KnownPlatforms {
public static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
public static let all: [HermesToolPlatform] = [
cli,
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"),
HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"),
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
]
public static func icon(for platform: String) -> String {
switch platform {
case "cli": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "whatsapp": return "phone.bubble"
case "signal": return "lock.shield"
case "email": return "envelope"
case "homeassistant": return "house"
case "webhook": return "arrow.up.right.square"
case "matrix": return "lock.rectangle.stack"
case "feishu": return "message.badge.circle"
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
case "imessage": return "message.fill"
default: return "bubble.left"
}
}
}
@@ -0,0 +1,206 @@
import Foundation
public struct MCPServerPreset: Identifiable, Sendable, Equatable {
public let id: String
public let displayName: String
public let description: String
public let category: String
public let iconSystemName: String
public let transport: MCPTransport
public let command: String?
public let args: [String]
public let url: String?
public let auth: String?
public let requiredEnvKeys: [String]
public let optionalEnvKeys: [String]
public let pathArgPrompt: String?
public let docsURL: String
public init(
id: String,
displayName: String,
description: String,
category: String,
iconSystemName: String,
transport: MCPTransport,
command: String?,
args: [String],
url: String?,
auth: String?,
requiredEnvKeys: [String],
optionalEnvKeys: [String],
pathArgPrompt: String?,
docsURL: String
) {
self.id = id
self.displayName = displayName
self.description = description
self.category = category
self.iconSystemName = iconSystemName
self.transport = transport
self.command = command
self.args = args
self.url = url
self.auth = auth
self.requiredEnvKeys = requiredEnvKeys
self.optionalEnvKeys = optionalEnvKeys
self.pathArgPrompt = pathArgPrompt
self.docsURL = docsURL
}
public static let gallery: [MCPServerPreset] = [
MCPServerPreset(
id: "filesystem",
displayName: "Filesystem",
description: "Read and write files under a root directory you choose.",
category: "Built-in",
iconSystemName: "folder",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: "Root directory (absolute path)",
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem"
),
MCPServerPreset(
id: "github",
displayName: "GitHub",
description: "Issues, pull requests, code search, and file operations via GitHub API.",
category: "Dev",
iconSystemName: "chevron.left.forwardslash.chevron.right",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
url: nil,
auth: nil,
requiredEnvKeys: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/github"
),
MCPServerPreset(
id: "postgres",
displayName: "Postgres",
description: "Read-only SQL access against a Postgres database.",
category: "Data",
iconSystemName: "cylinder.split.1x2",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-postgres"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: "Connection URL (postgres://user:pass@host/db)",
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres"
),
MCPServerPreset(
id: "slack",
displayName: "Slack",
description: "Read channels, post messages, and search your Slack workspace.",
category: "Productivity",
iconSystemName: "bubble.left.and.bubble.right",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-slack"],
url: nil,
auth: nil,
requiredEnvKeys: ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack"
),
MCPServerPreset(
id: "linear",
displayName: "Linear",
description: "Query and update Linear issues. Uses OAuth — no token needed.",
category: "Productivity",
iconSystemName: "list.bullet.rectangle",
transport: .http,
command: nil,
args: [],
url: "https://mcp.linear.app/sse",
auth: "oauth",
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://linear.app/docs/mcp"
),
MCPServerPreset(
id: "sentry",
displayName: "Sentry",
description: "Investigate errors and performance issues from Sentry.",
category: "Dev",
iconSystemName: "exclamationmark.triangle",
transport: .stdio,
command: "npx",
args: ["-y", "@sentry/mcp-server"],
url: nil,
auth: nil,
requiredEnvKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://docs.sentry.io/product/mcp/"
),
MCPServerPreset(
id: "puppeteer",
displayName: "Puppeteer",
description: "Headless browser automation — navigate pages, click, screenshot.",
category: "Automation",
iconSystemName: "safari",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-puppeteer"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer"
),
MCPServerPreset(
id: "memory",
displayName: "Memory (Knowledge Graph)",
description: "Persistent knowledge graph of entities and relations across sessions.",
category: "Built-in",
iconSystemName: "brain",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-memory"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: ["MEMORY_FILE_PATH"],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory"
),
MCPServerPreset(
id: "fetch",
displayName: "Fetch",
description: "Retrieve and convert web pages to markdown.",
category: "Built-in",
iconSystemName: "arrow.down.circle",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-fetch"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch"
)
]
public static var categories: [String] {
var seen = Set<String>()
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
}
public static func byCategory(_ category: String) -> [MCPServerPreset] {
gallery.filter { $0.category == category }
}
}
@@ -0,0 +1,250 @@
import Foundation
// MARK: - Registry
public struct ProjectRegistry: Codable, Sendable {
public var projects: [ProjectEntry]
public init(
projects: [ProjectEntry]
) {
self.projects = projects
}
}
public struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
public var id: String { name }
public let name: String
public let path: String
public init(
name: String,
path: String
) {
self.name = name
self.path = path
}
public var dashboardPath: String { path + "/.scarf/dashboard.json" }
}
// MARK: - Dashboard
public struct ProjectDashboard: Codable, Sendable {
public let version: Int
public let title: String
public let description: String?
public let updatedAt: String?
public let theme: DashboardTheme?
public let sections: [DashboardSection]
public init(
version: Int,
title: String,
description: String?,
updatedAt: String?,
theme: DashboardTheme?,
sections: [DashboardSection]
) {
self.version = version
self.title = title
self.description = description
self.updatedAt = updatedAt
self.theme = theme
self.sections = sections
}
}
public struct DashboardTheme: Codable, Sendable {
public let accent: String?
public init(
accent: String?
) {
self.accent = accent
}
}
public struct DashboardSection: Codable, Sendable, Identifiable {
public var id: String { title }
public let title: String
public let columns: Int?
public let widgets: [DashboardWidget]
public init(
title: String,
columns: Int?,
widgets: [DashboardWidget]
) {
self.title = title
self.columns = columns
self.widgets = widgets
}
public var columnCount: Int { columns ?? 3 }
}
public struct DashboardWidget: Codable, Sendable, Identifiable {
public var id: String { type + ":" + title }
public let type: String
public let title: String
// Stat
public let value: WidgetValue?
public let icon: String?
public let color: String?
public let subtitle: String?
// Progress
public let label: String?
// Text
public let content: String?
public let format: String?
// Table
public let columns: [String]?
public let rows: [[String]]?
// Chart
public let chartType: String?
public let xLabel: String?
public let yLabel: String?
public let series: [ChartSeries]?
// List
public let items: [ListItem]?
// Webview
public let url: String?
public let height: Double?
public init(
type: String,
title: String,
value: WidgetValue?,
icon: String?,
color: String?,
subtitle: String?,
label: String?,
content: String?,
format: String?,
columns: [String]?,
rows: [[String]]?,
chartType: String?,
xLabel: String?,
yLabel: String?,
series: [ChartSeries]?,
items: [ListItem]?,
url: String?,
height: Double?
) {
self.type = type
self.title = title
self.value = value
self.icon = icon
self.color = color
self.subtitle = subtitle
self.label = label
self.content = content
self.format = format
self.columns = columns
self.rows = rows
self.chartType = chartType
self.xLabel = xLabel
self.yLabel = yLabel
self.series = series
self.items = items
self.url = url
self.height = height
}
}
// MARK: - Widget Value (String or Number)
public enum WidgetValue: Codable, Sendable, Hashable {
case string(String)
case number(Double)
public var displayString: String {
switch self {
case .string(let s): return s
case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0
? Int(n).formatted(.number)
: n.formatted(.number.precision(.fractionLength(1)))
}
}
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let d = try? container.decode(Double.self) {
self = .number(d)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else {
throw DecodingError.typeMismatch(
WidgetValue.self,
.init(codingPath: decoder.codingPath, debugDescription: "Expected String or Number")
)
}
}
public 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)
}
}
}
// MARK: - Chart Data
public struct ChartSeries: Codable, Sendable, Identifiable {
public var id: String { name }
public let name: String
public let color: String?
public let data: [ChartDataPoint]
public init(
name: String,
color: String?,
data: [ChartDataPoint]
) {
self.name = name
self.color = color
self.data = data
}
}
public struct ChartDataPoint: Codable, Sendable, Identifiable {
public var id: String { x }
public let x: String
public let y: Double
public init(
x: String,
y: Double
) {
self.x = x
self.y = y
}
}
// MARK: - List Data
public struct ListItem: Codable, Sendable, Identifiable {
public var id: String { text }
public let text: String
public let status: String?
public init(
text: String,
status: String?
) {
self.text = text
self.status = status
}
}