mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user