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:
+127
-63
@@ -8,15 +8,25 @@ import Foundation
|
|||||||
// decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop).
|
// decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop).
|
||||||
// The member list must stay in sync with the stored properties above.
|
// The member list must stay in sync with the stored properties above.
|
||||||
|
|
||||||
struct ACPRequest: Encodable, Sendable {
|
public struct ACPRequest: Encodable, Sendable {
|
||||||
nonisolated let jsonrpc = "2.0"
|
public nonisolated let jsonrpc = "2.0"
|
||||||
nonisolated let id: Int
|
public nonisolated let id: Int
|
||||||
nonisolated let method: String
|
public nonisolated let method: String
|
||||||
nonisolated let params: [String: AnyCodable]
|
public nonisolated let params: [String: AnyCodable]
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
|
|
||||||
|
|
||||||
nonisolated func encode(to encoder: any Encoder) throws {
|
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)
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try c.encode(jsonrpc, forKey: .jsonrpc)
|
try c.encode(jsonrpc, forKey: .jsonrpc)
|
||||||
try c.encode(id, forKey: .id)
|
try c.encode(id, forKey: .id)
|
||||||
@@ -25,21 +35,21 @@ struct ACPRequest: Encodable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPRawMessage: Decodable, Sendable {
|
public struct ACPRawMessage: Decodable, Sendable {
|
||||||
nonisolated let jsonrpc: String?
|
public nonisolated let jsonrpc: String?
|
||||||
nonisolated let id: Int?
|
public nonisolated let id: Int?
|
||||||
nonisolated let method: String?
|
public nonisolated let method: String?
|
||||||
nonisolated let result: AnyCodable?
|
public nonisolated let result: AnyCodable?
|
||||||
nonisolated let error: ACPError?
|
public nonisolated let error: ACPError?
|
||||||
nonisolated let params: AnyCodable?
|
public nonisolated let params: AnyCodable?
|
||||||
|
|
||||||
nonisolated var isResponse: Bool { id != nil && method == nil }
|
public nonisolated var isResponse: Bool { id != nil && method == nil }
|
||||||
nonisolated var isNotification: Bool { method != nil && id == nil }
|
public nonisolated var isNotification: Bool { method != nil && id == nil }
|
||||||
nonisolated var isRequest: Bool { method != nil && id != nil }
|
public nonisolated var isRequest: Bool { method != nil && id != nil }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
|
public enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
|
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
|
||||||
self.id = try c.decodeIfPresent(Int.self, forKey: .id)
|
self.id = try c.decodeIfPresent(Int.self, forKey: .id)
|
||||||
@@ -50,13 +60,13 @@ struct ACPRawMessage: Decodable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPError: Decodable, Sendable {
|
public struct ACPError: Decodable, Sendable {
|
||||||
nonisolated let code: Int
|
public nonisolated let code: Int
|
||||||
nonisolated let message: String
|
public nonisolated let message: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case code, message }
|
public enum CodingKeys: String, CodingKey { case code, message }
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.code = try c.decode(Int.self, forKey: .code)
|
self.code = try c.decode(Int.self, forKey: .code)
|
||||||
self.message = try c.decode(String.self, forKey: .message)
|
self.message = try c.decode(String.self, forKey: .message)
|
||||||
@@ -65,10 +75,10 @@ struct ACPError: Decodable, Sendable {
|
|||||||
|
|
||||||
// MARK: - AnyCodable (for dynamic JSON)
|
// MARK: - AnyCodable (for dynamic JSON)
|
||||||
|
|
||||||
struct AnyCodable: Codable, @unchecked Sendable {
|
public struct AnyCodable: Codable, @unchecked Sendable {
|
||||||
nonisolated let value: Any
|
public nonisolated let value: Any
|
||||||
|
|
||||||
nonisolated init(_ value: Any) { self.value = value }
|
public nonisolated init(_ value: Any) { self.value = value }
|
||||||
|
|
||||||
// NOT marked `nonisolated`: Swift's default-isolation treats writes to a
|
// NOT marked `nonisolated`: Swift's default-isolation treats writes to a
|
||||||
// `let value: Any` stored property as MainActor-isolated even when the
|
// `let value: Any` stored property as MainActor-isolated even when the
|
||||||
@@ -78,7 +88,7 @@ struct AnyCodable: Codable, @unchecked Sendable {
|
|||||||
// conformance is still usable from ACPClient's nonisolated read loop
|
// conformance is still usable from ACPClient's nonisolated read loop
|
||||||
// because all callers are already @preconcurrency with respect to
|
// because all callers are already @preconcurrency with respect to
|
||||||
// `AnyCodable` (it's @unchecked Sendable).
|
// `AnyCodable` (it's @unchecked Sendable).
|
||||||
init(from decoder: any Decoder) throws {
|
public init(from decoder: any Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
if container.decodeNil() {
|
if container.decodeNil() {
|
||||||
value = NSNull()
|
value = NSNull()
|
||||||
@@ -99,7 +109,7 @@ struct AnyCodable: Codable, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: any Encoder) throws {
|
public func encode(to encoder: any Encoder) throws {
|
||||||
var container = encoder.singleValueContainer()
|
var container = encoder.singleValueContainer()
|
||||||
switch value {
|
switch value {
|
||||||
case is NSNull:
|
case is NSNull:
|
||||||
@@ -123,15 +133,15 @@ struct AnyCodable: Codable, @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Accessors
|
// MARK: - Accessors
|
||||||
|
|
||||||
nonisolated var stringValue: String? { value as? String }
|
public nonisolated var stringValue: String? { value as? String }
|
||||||
nonisolated var intValue: Int? { value as? Int }
|
public nonisolated var intValue: Int? { value as? Int }
|
||||||
nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
|
public nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
|
||||||
nonisolated var arrayValue: [Any]? { value as? [Any] }
|
public nonisolated var arrayValue: [Any]? { value as? [Any] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ACP Events (parsed from session/update notifications)
|
// MARK: - ACP Events (parsed from session/update notifications)
|
||||||
|
|
||||||
enum ACPEvent: Sendable {
|
public enum ACPEvent: Sendable {
|
||||||
case messageChunk(sessionId: String, text: String)
|
case messageChunk(sessionId: String, text: String)
|
||||||
case thoughtChunk(sessionId: String, text: String)
|
case thoughtChunk(sessionId: String, text: String)
|
||||||
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
|
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
|
||||||
@@ -143,21 +153,37 @@ enum ACPEvent: Sendable {
|
|||||||
case unknown(sessionId: String, type: String)
|
case unknown(sessionId: String, type: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPToolCallEvent: Sendable {
|
public struct ACPToolCallEvent: Sendable {
|
||||||
let toolCallId: String
|
public let toolCallId: String
|
||||||
let title: String
|
public let title: String
|
||||||
let kind: String
|
public let kind: String
|
||||||
let status: String
|
public let status: String
|
||||||
let content: String
|
public let content: String
|
||||||
let rawInput: [String: Any]?
|
public let rawInput: [String: Any]?
|
||||||
|
|
||||||
var functionName: String {
|
|
||||||
|
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"
|
// title format is "functionName: summary" or just "functionName"
|
||||||
let parts = title.split(separator: ":", maxSplits: 1)
|
let parts = title.split(separator: ":", maxSplits: 1)
|
||||||
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
|
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
|
||||||
}
|
}
|
||||||
|
|
||||||
var argumentsSummary: String {
|
public var argumentsSummary: String {
|
||||||
let parts = title.split(separator: ":", maxSplits: 1)
|
let parts = title.split(separator: ":", maxSplits: 1)
|
||||||
if parts.count > 1 {
|
if parts.count > 1 {
|
||||||
return String(parts[1]).trimmingCharacters(in: .whitespaces)
|
return String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||||
@@ -165,7 +191,7 @@ struct ACPToolCallEvent: Sendable {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var argumentsJSON: String {
|
public var argumentsJSON: String {
|
||||||
guard let input = rawInput,
|
guard let input = rawInput,
|
||||||
let data = try? JSONSerialization.data(withJSONObject: input),
|
let data = try? JSONSerialization.data(withJSONObject: input),
|
||||||
let str = String(data: data, encoding: .utf8) else { return "{}" }
|
let str = String(data: data, encoding: .utf8) else { return "{}" }
|
||||||
@@ -173,32 +199,70 @@ struct ACPToolCallEvent: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPToolCallUpdateEvent: Sendable {
|
public struct ACPToolCallUpdateEvent: Sendable {
|
||||||
let toolCallId: String
|
public let toolCallId: String
|
||||||
let kind: String
|
public let kind: String
|
||||||
let status: String
|
public let status: String
|
||||||
let content: String
|
public let content: String
|
||||||
let rawOutput: 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPPermissionRequestEvent: Sendable {
|
public struct ACPPermissionRequestEvent: Sendable {
|
||||||
let toolCallTitle: String
|
public let toolCallTitle: String
|
||||||
let toolCallKind: String
|
public let toolCallKind: String
|
||||||
let options: [(optionId: String, name: 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPPromptResult: Sendable {
|
public struct ACPPromptResult: Sendable {
|
||||||
let stopReason: String
|
public let stopReason: String
|
||||||
let inputTokens: Int
|
public let inputTokens: Int
|
||||||
let outputTokens: Int
|
public let outputTokens: Int
|
||||||
let thoughtTokens: Int
|
public let thoughtTokens: Int
|
||||||
let cachedReadTokens: 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
|
// MARK: - Event Parsing
|
||||||
|
|
||||||
enum ACPEventParser {
|
public enum ACPEventParser {
|
||||||
nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
|
public nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
|
||||||
guard notification.method == "session/update",
|
guard notification.method == "session/update",
|
||||||
let params = notification.params?.dictValue,
|
let params = notification.params?.dictValue,
|
||||||
let sessionId = params["sessionId"] as? String,
|
let sessionId = params["sessionId"] as? String,
|
||||||
@@ -246,7 +310,7 @@ enum ACPEventParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
|
public nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
|
||||||
guard message.method == "session/request_permission",
|
guard message.method == "session/request_permission",
|
||||||
let params = message.params?.dictValue,
|
let params = message.params?.dictValue,
|
||||||
let sessionId = params["sessionId"] as? String,
|
let sessionId = params["sessionId"] as? String,
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-38
@@ -1,26 +1,26 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct HermesCronJob: Identifiable, Sendable, Codable {
|
public struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||||
nonisolated let id: String
|
public nonisolated let id: String
|
||||||
nonisolated let name: String
|
public nonisolated let name: String
|
||||||
nonisolated let prompt: String
|
public nonisolated let prompt: String
|
||||||
nonisolated let skills: [String]?
|
public nonisolated let skills: [String]?
|
||||||
nonisolated let model: String?
|
public nonisolated let model: String?
|
||||||
nonisolated let schedule: CronSchedule
|
public nonisolated let schedule: CronSchedule
|
||||||
nonisolated let enabled: Bool
|
public nonisolated let enabled: Bool
|
||||||
nonisolated let state: String
|
public nonisolated let state: String
|
||||||
nonisolated let deliver: String?
|
public nonisolated let deliver: String?
|
||||||
nonisolated let nextRunAt: String?
|
public nonisolated let nextRunAt: String?
|
||||||
nonisolated let lastRunAt: String?
|
public nonisolated let lastRunAt: String?
|
||||||
nonisolated let lastError: String?
|
public nonisolated let lastError: String?
|
||||||
nonisolated let preRunScript: String?
|
public nonisolated let preRunScript: String?
|
||||||
nonisolated let deliveryFailures: Int?
|
public nonisolated let deliveryFailures: Int?
|
||||||
nonisolated let lastDeliveryError: String?
|
public nonisolated let lastDeliveryError: String?
|
||||||
nonisolated let timeoutType: String?
|
public nonisolated let timeoutType: String?
|
||||||
nonisolated let timeoutSeconds: Int?
|
public nonisolated let timeoutSeconds: Int?
|
||||||
nonisolated let silent: Bool?
|
public nonisolated let silent: Bool?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
public enum CodingKeys: String, CodingKey {
|
||||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
||||||
case nextRunAt = "next_run_at"
|
case nextRunAt = "next_run_at"
|
||||||
case lastRunAt = "last_run_at"
|
case lastRunAt = "last_run_at"
|
||||||
@@ -32,7 +32,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
case timeoutSeconds = "timeout_seconds"
|
case timeoutSeconds = "timeout_seconds"
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.id = try c.decode(String.self, forKey: .id)
|
self.id = try c.decode(String.self, forKey: .id)
|
||||||
self.name = try c.decode(String.self, forKey: .name)
|
self.name = try c.decode(String.self, forKey: .name)
|
||||||
@@ -54,7 +54,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
|
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func encode(to encoder: any Encoder) throws {
|
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try c.encode(id, forKey: .id)
|
try c.encode(id, forKey: .id)
|
||||||
try c.encode(name, forKey: .name)
|
try c.encode(name, forKey: .name)
|
||||||
@@ -76,7 +76,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
try c.encodeIfPresent(silent, forKey: .silent)
|
try c.encodeIfPresent(silent, forKey: .silent)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated var stateIcon: String {
|
public nonisolated var stateIcon: String {
|
||||||
switch state {
|
switch state {
|
||||||
case "scheduled": return "clock"
|
case "scheduled": return "clock"
|
||||||
case "running": return "play.circle"
|
case "running": return "play.circle"
|
||||||
@@ -86,7 +86,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated var deliveryDisplay: String? {
|
public nonisolated var deliveryDisplay: String? {
|
||||||
guard let deliver, !deliver.isEmpty else { return nil }
|
guard let deliver, !deliver.isEmpty else { return nil }
|
||||||
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
|
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
|
||||||
if deliver.hasPrefix("discord:") {
|
if deliver.hasPrefix("discord:") {
|
||||||
@@ -102,20 +102,20 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CronSchedule: Sendable, Codable {
|
public struct CronSchedule: Sendable, Codable {
|
||||||
nonisolated let kind: String
|
public nonisolated let kind: String
|
||||||
nonisolated let runAt: String?
|
public nonisolated let runAt: String?
|
||||||
nonisolated let display: String?
|
public nonisolated let display: String?
|
||||||
nonisolated let expression: String?
|
public nonisolated let expression: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
public enum CodingKeys: String, CodingKey {
|
||||||
case kind
|
case kind
|
||||||
case runAt = "run_at"
|
case runAt = "run_at"
|
||||||
case display
|
case display
|
||||||
case expression
|
case expression
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.kind = try c.decode(String.self, forKey: .kind)
|
self.kind = try c.decode(String.self, forKey: .kind)
|
||||||
self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt)
|
self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt)
|
||||||
@@ -123,7 +123,7 @@ struct CronSchedule: Sendable, Codable {
|
|||||||
self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
|
self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func encode(to encoder: any Encoder) throws {
|
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try c.encode(kind, forKey: .kind)
|
try c.encode(kind, forKey: .kind)
|
||||||
try c.encodeIfPresent(runAt, forKey: .runAt)
|
try c.encodeIfPresent(runAt, forKey: .runAt)
|
||||||
@@ -135,22 +135,22 @@ struct CronSchedule: Sendable, Codable {
|
|||||||
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
|
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
|
||||||
// MainActor-isolated Codable conformance — `HermesFileService.loadCronJobs`
|
// MainActor-isolated Codable conformance — `HermesFileService.loadCronJobs`
|
||||||
// is nonisolated and needs to decode this from a background task.
|
// is nonisolated and needs to decode this from a background task.
|
||||||
struct CronJobsFile: Sendable, Codable {
|
public struct CronJobsFile: Sendable, Codable {
|
||||||
nonisolated let jobs: [HermesCronJob]
|
public nonisolated let jobs: [HermesCronJob]
|
||||||
nonisolated let updatedAt: String?
|
public nonisolated let updatedAt: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
public enum CodingKeys: String, CodingKey {
|
||||||
case jobs
|
case jobs
|
||||||
case updatedAt = "updated_at"
|
case updatedAt = "updated_at"
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
|
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
|
||||||
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
|
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func encode(to encoder: any Encoder) throws {
|
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try c.encode(jobs, forKey: .jobs)
|
try c.encode(jobs, forKey: .jobs)
|
||||||
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-32
@@ -1,48 +1,74 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct HermesMessage: Identifiable, Sendable {
|
public struct HermesMessage: Identifiable, Sendable {
|
||||||
let id: Int
|
public let id: Int
|
||||||
let sessionId: String
|
public let sessionId: String
|
||||||
let role: String
|
public let role: String
|
||||||
let content: String
|
public let content: String
|
||||||
let toolCallId: String?
|
public let toolCallId: String?
|
||||||
let toolCalls: [HermesToolCall]
|
public let toolCalls: [HermesToolCall]
|
||||||
let toolName: String?
|
public let toolName: String?
|
||||||
let timestamp: Date?
|
public let timestamp: Date?
|
||||||
let tokenCount: Int?
|
public let tokenCount: Int?
|
||||||
let finishReason: String?
|
public let finishReason: String?
|
||||||
let reasoning: String?
|
public let reasoning: String?
|
||||||
|
|
||||||
var isUser: Bool { role == "user" }
|
|
||||||
var isAssistant: Bool { role == "assistant" }
|
public init(
|
||||||
var isToolResult: Bool { role == "tool" }
|
id: Int,
|
||||||
var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
|
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HermesToolCall: Identifiable, Sendable, Codable {
|
public struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||||
var id: String { callId }
|
public var id: String { callId }
|
||||||
let callId: String
|
public let callId: String
|
||||||
let functionName: String
|
public let functionName: String
|
||||||
let arguments: String
|
public let arguments: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
public enum CodingKeys: String, CodingKey {
|
||||||
case callId = "id"
|
case callId = "id"
|
||||||
case type
|
case type
|
||||||
case function
|
case function
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FunctionKeys: String, CodingKey {
|
public enum FunctionKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
case arguments
|
case arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
init(callId: String, functionName: String, arguments: String) {
|
public init(callId: String, functionName: String, arguments: String) {
|
||||||
self.callId = callId
|
self.callId = callId
|
||||||
self.functionName = functionName
|
self.functionName = functionName
|
||||||
self.arguments = arguments
|
self.arguments = arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
callId = try container.decode(String.self, forKey: .callId)
|
callId = try container.decode(String.self, forKey: .callId)
|
||||||
let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
|
let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
|
||||||
@@ -50,7 +76,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
|
|||||||
arguments = try funcContainer.decode(String.self, forKey: .arguments)
|
arguments = try funcContainer.decode(String.self, forKey: .arguments)
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
public func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try container.encode(callId, forKey: .callId)
|
try container.encode(callId, forKey: .callId)
|
||||||
try container.encode("function", forKey: .type)
|
try container.encode("function", forKey: .type)
|
||||||
@@ -59,7 +85,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
|
|||||||
try funcContainer.encode(arguments, forKey: .arguments)
|
try funcContainer.encode(arguments, forKey: .arguments)
|
||||||
}
|
}
|
||||||
|
|
||||||
var toolKind: ToolKind {
|
public var toolKind: ToolKind {
|
||||||
switch functionName {
|
switch functionName {
|
||||||
case "read_file", "search_files", "vision_analyze": return .read
|
case "read_file", "search_files", "vision_analyze": return .read
|
||||||
case "write_file", "patch": return .edit
|
case "write_file", "patch": return .edit
|
||||||
@@ -70,7 +96,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var argumentsSummary: String {
|
public var argumentsSummary: String {
|
||||||
guard let data = arguments.data(using: .utf8),
|
guard let data = arguments.data(using: .utf8),
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
return arguments
|
return arguments
|
||||||
@@ -91,7 +117,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ToolKind: String, Sendable, CaseIterable {
|
public enum ToolKind: String, Sendable, CaseIterable {
|
||||||
case read
|
case read
|
||||||
case edit
|
case edit
|
||||||
case execute
|
case execute
|
||||||
@@ -99,7 +125,7 @@ enum ToolKind: String, Sendable, CaseIterable {
|
|||||||
case browser
|
case browser
|
||||||
case other
|
case other
|
||||||
|
|
||||||
var displayName: LocalizedStringResource {
|
public var displayName: LocalizedStringResource {
|
||||||
switch self {
|
switch self {
|
||||||
case .read: return "Read"
|
case .read: return "Read"
|
||||||
case .edit: return "Edit"
|
case .edit: return "Edit"
|
||||||
@@ -110,7 +136,7 @@ enum ToolKind: String, Sendable, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var icon: String {
|
public var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .read: return "doc.text.magnifyingglass"
|
case .read: return "doc.text.magnifyingglass"
|
||||||
case .edit: return "pencil"
|
case .edit: return "pencil"
|
||||||
@@ -121,7 +147,7 @@ enum ToolKind: String, Sendable, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var color: String {
|
public var color: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .read: return "green"
|
case .read: return "green"
|
||||||
case .edit: return "blue"
|
case .edit: return "blue"
|
||||||
+38
-28
@@ -11,58 +11,68 @@ import Foundation
|
|||||||
/// an instance property here. `ServerContext.paths` is the canonical way to
|
/// an instance property here. `ServerContext.paths` is the canonical way to
|
||||||
/// reach these values; the old `HermesPaths` statics are preserved as
|
/// reach these values; the old `HermesPaths` statics are preserved as
|
||||||
/// deprecated forwarders so Phase 1 can migrate call sites incrementally.
|
/// deprecated forwarders so Phase 1 can migrate call sites incrementally.
|
||||||
struct HermesPathSet: Sendable, Hashable {
|
public struct HermesPathSet: Sendable, Hashable {
|
||||||
let home: String
|
public let home: String
|
||||||
/// `true` when this path set belongs to a remote installation. Affects
|
/// `true` when this path set belongs to a remote installation. Affects
|
||||||
/// only `hermesBinary` resolution — every other path is identical in
|
/// only `hermesBinary` resolution — every other path is identical in
|
||||||
/// shape between local and remote.
|
/// shape between local and remote.
|
||||||
let isRemote: Bool
|
public let isRemote: Bool
|
||||||
/// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`).
|
/// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`).
|
||||||
/// Populated by `SSHTransport` once `command -v hermes` has run on the
|
/// Populated by `SSHTransport` once `command -v hermes` has run on the
|
||||||
/// target host. Unused when `isRemote == false`.
|
/// target host. Unused when `isRemote == false`.
|
||||||
let binaryHint: String?
|
public let binaryHint: String?
|
||||||
|
|
||||||
// MARK: - Defaults
|
// MARK: - Defaults
|
||||||
|
|
||||||
/// Absolute path to the local user's `~/.hermes` directory.
|
/// Absolute path to the local user's `~/.hermes` directory.
|
||||||
nonisolated static let defaultLocalHome: String = {
|
|
||||||
|
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()
|
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||||
return user + "/.hermes"
|
return user + "/.hermes"
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// Default remote home when the user doesn't override it in `SSHConfig`.
|
/// Default remote home when the user doesn't override it in `SSHConfig`.
|
||||||
/// We leave `~` unexpanded on purpose — the remote shell resolves it.
|
/// We leave `~` unexpanded on purpose — the remote shell resolves it.
|
||||||
nonisolated static let defaultRemoteHome: String = "~/.hermes"
|
public nonisolated static let defaultRemoteHome: String = "~/.hermes"
|
||||||
|
|
||||||
// MARK: - Paths (mirror of the old HermesPaths layout)
|
// MARK: - Paths (mirror of the old HermesPaths layout)
|
||||||
|
|
||||||
nonisolated var stateDB: String { home + "/state.db" }
|
public nonisolated var stateDB: String { home + "/state.db" }
|
||||||
nonisolated var configYAML: String { home + "/config.yaml" }
|
public nonisolated var configYAML: String { home + "/config.yaml" }
|
||||||
nonisolated var envFile: String { home + "/.env" }
|
public nonisolated var envFile: String { home + "/.env" }
|
||||||
nonisolated var authJSON: String { home + "/auth.json" }
|
public nonisolated var authJSON: String { home + "/auth.json" }
|
||||||
nonisolated var soulMD: String { home + "/SOUL.md" }
|
public nonisolated var soulMD: String { home + "/SOUL.md" }
|
||||||
nonisolated var pluginsDir: String { home + "/plugins" }
|
public nonisolated var pluginsDir: String { home + "/plugins" }
|
||||||
nonisolated var memoriesDir: String { home + "/memories" }
|
public nonisolated var memoriesDir: String { home + "/memories" }
|
||||||
nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
|
public nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
|
||||||
nonisolated var userMD: String { memoriesDir + "/USER.md" }
|
public nonisolated var userMD: String { memoriesDir + "/USER.md" }
|
||||||
nonisolated var sessionsDir: String { home + "/sessions" }
|
public nonisolated var sessionsDir: String { home + "/sessions" }
|
||||||
nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
|
public nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
|
||||||
nonisolated var cronOutputDir: String { home + "/cron/output" }
|
public nonisolated var cronOutputDir: String { home + "/cron/output" }
|
||||||
nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
|
public nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
|
||||||
nonisolated var skillsDir: String { home + "/skills" }
|
public nonisolated var skillsDir: String { home + "/skills" }
|
||||||
nonisolated var errorsLog: String { home + "/logs/errors.log" }
|
public nonisolated var errorsLog: String { home + "/logs/errors.log" }
|
||||||
nonisolated var agentLog: String { home + "/logs/agent.log" }
|
public nonisolated var agentLog: String { home + "/logs/agent.log" }
|
||||||
nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||||
nonisolated var scarfDir: String { home + "/scarf" }
|
public nonisolated var scarfDir: String { home + "/scarf" }
|
||||||
nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||||
nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||||
|
|
||||||
// MARK: - Binary resolution
|
// MARK: - Binary resolution
|
||||||
|
|
||||||
/// Install locations we probe for the local `hermes` binary, in priority
|
/// Install locations we probe for the local `hermes` binary, in priority
|
||||||
/// order. Checked on every access so a user installing via a different
|
/// order. Checked on every access so a user installing via a different
|
||||||
/// method doesn't need to relaunch Scarf.
|
/// method doesn't need to relaunch Scarf.
|
||||||
nonisolated static let hermesBinaryCandidates: [String] = {
|
public nonisolated static let hermesBinaryCandidates: [String] = {
|
||||||
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||||
return [
|
return [
|
||||||
user + "/.local/bin/hermes", // pipx / pip --user (default)
|
user + "/.local/bin/hermes", // pipx / pip --user (default)
|
||||||
@@ -79,7 +89,7 @@ struct HermesPathSet: Sendable, Hashable {
|
|||||||
///
|
///
|
||||||
/// Remote: returns `binaryHint` (populated at connect time) or bare
|
/// Remote: returns `binaryHint` (populated at connect time) or bare
|
||||||
/// `"hermes"` as a last-resort default that relies on the remote `$PATH`.
|
/// `"hermes"` as a last-resort default that relies on the remote `$PATH`.
|
||||||
nonisolated var hermesBinary: String {
|
public nonisolated var hermesBinary: String {
|
||||||
if isRemote {
|
if isRemote {
|
||||||
return binaryHint ?? "hermes"
|
return binaryHint ?? "hermes"
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-15
@@ -1,23 +1,45 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct HermesToolset: Identifiable, Sendable {
|
public struct HermesToolset: Identifiable, Sendable {
|
||||||
var id: String { name }
|
public var id: String { name }
|
||||||
let name: String
|
public let name: String
|
||||||
let description: String
|
public let description: String
|
||||||
let icon: String
|
public let icon: String
|
||||||
var enabled: Bool
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HermesToolPlatform: Identifiable, Sendable {
|
public struct HermesToolPlatform: Identifiable, Sendable {
|
||||||
var id: String { name }
|
public var id: String { name }
|
||||||
let name: String
|
public let name: String
|
||||||
let displayName: String
|
public let displayName: String
|
||||||
let icon: String
|
public let icon: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
displayName: String,
|
||||||
|
icon: String
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.displayName = displayName
|
||||||
|
self.icon = icon
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum KnownPlatforms {
|
public enum KnownPlatforms {
|
||||||
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
|
public static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
|
||||||
static let all: [HermesToolPlatform] = [
|
public static let all: [HermesToolPlatform] = [
|
||||||
cli,
|
cli,
|
||||||
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
||||||
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
||||||
@@ -33,7 +55,7 @@ enum KnownPlatforms {
|
|||||||
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
|
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
|
||||||
]
|
]
|
||||||
|
|
||||||
static func icon(for platform: String) -> String {
|
public static func icon(for platform: String) -> String {
|
||||||
switch platform {
|
switch platform {
|
||||||
case "cli": return "terminal"
|
case "cli": return "terminal"
|
||||||
case "telegram": return "paperplane"
|
case "telegram": return "paperplane"
|
||||||
+50
-18
@@ -1,22 +1,54 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct MCPServerPreset: Identifiable, Sendable, Equatable {
|
public struct MCPServerPreset: Identifiable, Sendable, Equatable {
|
||||||
let id: String
|
public let id: String
|
||||||
let displayName: String
|
public let displayName: String
|
||||||
let description: String
|
public let description: String
|
||||||
let category: String
|
public let category: String
|
||||||
let iconSystemName: String
|
public let iconSystemName: String
|
||||||
let transport: MCPTransport
|
public let transport: MCPTransport
|
||||||
let command: String?
|
public let command: String?
|
||||||
let args: [String]
|
public let args: [String]
|
||||||
let url: String?
|
public let url: String?
|
||||||
let auth: String?
|
public let auth: String?
|
||||||
let requiredEnvKeys: [String]
|
public let requiredEnvKeys: [String]
|
||||||
let optionalEnvKeys: [String]
|
public let optionalEnvKeys: [String]
|
||||||
let pathArgPrompt: String?
|
public let pathArgPrompt: String?
|
||||||
let docsURL: String
|
public let docsURL: String
|
||||||
|
|
||||||
static let gallery: [MCPServerPreset] = [
|
|
||||||
|
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(
|
MCPServerPreset(
|
||||||
id: "filesystem",
|
id: "filesystem",
|
||||||
displayName: "Filesystem",
|
displayName: "Filesystem",
|
||||||
@@ -163,12 +195,12 @@ struct MCPServerPreset: Identifiable, Sendable, Equatable {
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
static var categories: [String] {
|
public static var categories: [String] {
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
|
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func byCategory(_ category: String) -> [MCPServerPreset] {
|
public static func byCategory(_ category: String) -> [MCPServerPreset] {
|
||||||
gallery.filter { $0.category == category }
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Scarf iOS Port — Plan & Progress Log
|
||||||
|
|
||||||
|
> Living document. Updated at the end of each phase. Read this before starting
|
||||||
|
> any phase so you know what the prior phase did, what shipped, and what the
|
||||||
|
> next phase is allowed to assume.
|
||||||
|
|
||||||
|
## Locked Decisions
|
||||||
|
|
||||||
|
- **iOS 18 minimum.** Matches the Mac app's `@Observable` / `NavigationStack`
|
||||||
|
APIs so ViewModels can move into `ScarfCore` without `#if os(iOS)` gymnastics
|
||||||
|
on the navigation layer.
|
||||||
|
- **iPhone only for v1.** iPad Universal deferred (+1 week to add later).
|
||||||
|
- **No APNs push for v1.** Requires a Hermes-side server component. Deferred.
|
||||||
|
- **Remote-only on iOS.** No local Hermes mode — iOS sandbox can't read
|
||||||
|
`~/.hermes/` and can't spawn subprocesses. SSH to a user-owned Hermes
|
||||||
|
install (Mac, home server, VPS) is the only connection model. This is by
|
||||||
|
design, not a regression.
|
||||||
|
- **SSH library: Citadel** (pure Swift, SwiftNIO, MIT licensed).
|
||||||
|
- **Distribution: TestFlight → App Store.** No Sparkle on iOS. Apple
|
||||||
|
Developer team `3Q6X2L86C4` is reused.
|
||||||
|
- **Shared-code strategy: local Swift Package (`ScarfCore`).** Not a
|
||||||
|
multiplatform target. `PBXFileSystemSynchronizedRootGroup` makes
|
||||||
|
per-file target membership impractical, so the Mac and iOS apps each
|
||||||
|
consume a separate SPM package and provide their own platform shells.
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
scarf/ (repo root)
|
||||||
|
scarf/ (Xcode project folder)
|
||||||
|
scarf.xcodeproj/
|
||||||
|
Packages/
|
||||||
|
ScarfCore/ (local SPM — platform-neutral)
|
||||||
|
Package.swift
|
||||||
|
Sources/ScarfCore/
|
||||||
|
Models/ (added in M0a)
|
||||||
|
Transport/ (added in M0b)
|
||||||
|
Services/ (added in M0c — portable subset)
|
||||||
|
ViewModels/ (added in M0d — portable subset)
|
||||||
|
Views/ (added in M0d — portable subset)
|
||||||
|
Tests/ScarfCoreTests/
|
||||||
|
scarf/ (macOS app — PBXFileSystemSynchronizedRootGroup)
|
||||||
|
MacApp/ (Mac-only glue: Sparkle, SwiftTerm, NSWorkspace shims)
|
||||||
|
Core/Services/ (Mac-only services remain here)
|
||||||
|
Features/ (Mac-only features remain here)
|
||||||
|
Navigation/
|
||||||
|
scarf-ios/ (iOS app — added in M2)
|
||||||
|
iOSApp/ (iOS-only glue: CitadelTransport, tab/stack nav)
|
||||||
|
```
|
||||||
|
|
||||||
|
## What We Give Up On iOS (Intentional)
|
||||||
|
|
||||||
|
| Dropped | Reason |
|
||||||
|
|---|---|
|
||||||
|
| Local Hermes mode | Sandbox + no subprocess on iOS |
|
||||||
|
| Sparkle auto-updates | App Store handles updates |
|
||||||
|
| Terminal mode in Chat (SwiftTerm) | Mac-only in v1; SwiftTerm does support iOS, defer to v1.1 |
|
||||||
|
| Embedded terminal platform-setup (Signal/WhatsApp pairing) | Same SwiftTerm dependency |
|
||||||
|
| `NSWorkspace.open(_:)` "open in editor" / "reveal" | No equivalent; use `UIApplication.open(_:)` for URLs |
|
||||||
|
| Multi-window (one window per server) | iPhone-only v1; iPad scenes may come later |
|
||||||
|
| Menu bar, global shortcuts, drag-and-drop from Finder | Not applicable on iOS |
|
||||||
|
|
||||||
|
## What Ships In The v1 iOS App
|
||||||
|
|
||||||
|
Dashboard, Sessions Browser, Sessions Detail, Activity Feed, Insights,
|
||||||
|
Memory viewer/editor, Skills, Cron, Logs, Health, Rich Chat, Settings
|
||||||
|
(read-mostly). ~70% of the current Mac feature surface.
|
||||||
|
|
||||||
|
## The One Real Refactor: Decouple ACP from `Process`
|
||||||
|
|
||||||
|
`Core/Services/ACPClient.swift` currently pokes at `Process.isRunning`,
|
||||||
|
`Process.terminationHandler`, and `Darwin.write()` on raw pipe file
|
||||||
|
descriptors. Those APIs don't exist on iOS. We introduce:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
protocol ACPChannel: Sendable {
|
||||||
|
var isOpen: Bool { get }
|
||||||
|
func send(_ line: String) async throws // JSON line + "\n"
|
||||||
|
var incoming: AsyncThrowingStream<String, Error> { get }
|
||||||
|
func close() async
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Mac: `ProcessACPChannel` wraps today's `Process` + `Pipe` code.
|
||||||
|
- iOS: `SSHExecACPChannel` wraps a Citadel exec session.
|
||||||
|
|
||||||
|
This lands in **M1**.
|
||||||
|
|
||||||
|
## SSH on iOS: Citadel
|
||||||
|
|
||||||
|
[`orlandos-nl/Citadel`](https://github.com/orlandos-nl/Citadel) is pure-Swift
|
||||||
|
SSH on SwiftNIO. What we use:
|
||||||
|
|
||||||
|
- Public-key auth, keys imported from Files.app or generated on-device and
|
||||||
|
exported as public key for `authorized_keys`.
|
||||||
|
- Long-lived exec channel for ACP JSON-RPC over stdio.
|
||||||
|
- SFTP for `state.db` snapshot pulls (same flow as Mac's `scp`).
|
||||||
|
- One-shot exec for `stat`/`cat`/`sqlite3 .backup` used by existing services.
|
||||||
|
|
||||||
|
What we lose vs. system `ssh`: no `~/.ssh/config`, no `ProxyJump`, no
|
||||||
|
ControlMaster, no ssh-agent. We run a per-app in-memory session pool (one
|
||||||
|
session per server, reused across calls) to recover the perf benefit.
|
||||||
|
|
||||||
|
## Distribution, Testing, CI
|
||||||
|
|
||||||
|
- **TestFlight** primary beta channel.
|
||||||
|
- **App Store** production distribution.
|
||||||
|
- **CI** (GitHub Actions, `macos-latest`):
|
||||||
|
- `swift test` against `Packages/ScarfCore` — fast, no simulator.
|
||||||
|
- `xcodebuild test -scheme scarf-ios -destination 'platform=iOS Simulator,...'`
|
||||||
|
for iOS UI tests (added in M2+).
|
||||||
|
- `xcodebuild test -scheme scarf` for the Mac target (unchanged).
|
||||||
|
- **Release script** `scripts/release-ios.sh` added in M6: `xcodebuild archive`
|
||||||
|
→ `-exportArchive` with App Store profile → `xcrun notarytool`-free path
|
||||||
|
(App Store review replaces notarization for iOS). The existing
|
||||||
|
`scripts/release.sh` keeps its Mac-specific Sparkle flow.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
| ID | Scope | Size |
|
||||||
|
|---|---|---|
|
||||||
|
| **M0** | Extract `ScarfCore` package (Mac-only, no iOS yet) | 1–2 weeks |
|
||||||
|
| **M1** | Decouple ACP from `Process` via `ACPChannel` protocol | 2–3 days |
|
||||||
|
| **M2** | iOS app skeleton — Citadel, onboarding, Dashboard only | ~1 week |
|
||||||
|
| **M3** | iOS monitor surface — Sessions, Activity, Insights, Logs, Health | 1–2 weeks |
|
||||||
|
| **M4** | iOS Rich Chat — `SSHExecACPChannel` + ACPClient wiring | ~1 week |
|
||||||
|
| **M5** | iOS writes — Memory, Cron, Skills, Settings | 3–5 days |
|
||||||
|
| **M6** | Polish, TestFlight public beta, App Store submission | ~1 week |
|
||||||
|
|
||||||
|
Total: **6–9 weeks.**
|
||||||
|
|
||||||
|
### M0 Sub-Phases (each is its own PR)
|
||||||
|
|
||||||
|
Because M0 is too large for a single safe PR (no ability to run builds
|
||||||
|
between commits), it's split into 4 self-contained sub-PRs that each leave
|
||||||
|
the Mac app in a working state:
|
||||||
|
|
||||||
|
- **M0a** — Package scaffolding + move 13 leaf Models to `ScarfCore`
|
||||||
|
- **M0b** — Move Transport + `ServerContext` to `ScarfCore`
|
||||||
|
- **M0c** — Move portable Services (`HermesDataService`, `HermesLogService`,
|
||||||
|
`ModelCatalogService`, `ProjectDashboardService`) to `ScarfCore`
|
||||||
|
- **M0d** — Move portable ViewModels + Views to `ScarfCore`
|
||||||
|
|
||||||
|
## Rules For Future Phases
|
||||||
|
|
||||||
|
1. **Any new feature lands in `ScarfCore` by default.** macOS-only is allowed
|
||||||
|
for features that need `Process`, `NSWorkspace`, embedded `SwiftTerm`, or
|
||||||
|
menu-bar integration — document why in the feature's header comment.
|
||||||
|
2. **Every PR leaves the Mac app building and passing tests.** If the PR's
|
||||||
|
own changes can't be verified in the sandbox agent environment, the PR
|
||||||
|
description must list a manual verification checklist for Alan to run
|
||||||
|
before merging.
|
||||||
|
3. **Wiki updates follow the CLAUDE.md rules** — if the feature was moved,
|
||||||
|
the wiki page for that feature should note whether it's available on
|
||||||
|
macOS, iOS, or both.
|
||||||
|
4. **Version numbers stay in lockstep.** Mac and iOS bump to the same
|
||||||
|
`MARKETING_VERSION` in one commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
### M0a — in progress
|
||||||
|
**Goal:** Create `Packages/ScarfCore` scaffolding and migrate the 13 leaf
|
||||||
|
Model files to it. `ServerContext.swift` stays in the Mac target (it
|
||||||
|
depends on Transport + HermesFileService and is not a leaf).
|
||||||
|
|
||||||
|
Expected artifacts when done:
|
||||||
|
- `Packages/ScarfCore/Package.swift` exists.
|
||||||
|
- 13 model files moved under `Sources/ScarfCore/Models/`.
|
||||||
|
- `HermesConstants.swift` split: portable parts (`sqliteTransient`,
|
||||||
|
`QueryDefaults`, `FileSizeUnit`) move to ScarfCore; the deprecated
|
||||||
|
`HermesPaths` enum stays behind (it references `ServerContext.local`).
|
||||||
|
- Every moved type annotated `public` with explicit `public init`.
|
||||||
|
- `scarf.xcodeproj/project.pbxproj` gains one `XCLocalSwiftPackageReference`
|
||||||
|
for `Packages/ScarfCore` and one `XCSwiftPackageProductDependency` on the
|
||||||
|
`scarf` target (and `scarfTests` + `scarfUITests` inherit via the target).
|
||||||
|
- 35 main-target files get `import ScarfCore` added.
|
||||||
|
- Mac app builds and runs identically to before.
|
||||||
|
|
||||||
|
(This section will be finalized when M0a is merged.)
|
||||||
|
|
||||||
|
### M0b — pending
|
||||||
|
### M0c — pending
|
||||||
|
### M0d — pending
|
||||||
|
### M1 — pending
|
||||||
|
### M2 — pending
|
||||||
|
### M3 — pending
|
||||||
|
### M4 — pending
|
||||||
|
### M5 — pending
|
||||||
|
### M6 — pending
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
|
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
|
||||||
|
53SCARFCORE0010 /* ScarfCore in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0001 /* ScarfCore */; };
|
||||||
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
|
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
|
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
|
||||||
|
53SCARFCORE0010 /* ScarfCore in Frameworks */,
|
||||||
53SPARKLE00010 /* Sparkle in Frameworks */,
|
53SPARKLE00010 /* Sparkle in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -133,6 +135,7 @@
|
|||||||
name = scarf;
|
name = scarf;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
53SWIFTTERM0001 /* SwiftTerm */,
|
53SWIFTTERM0001 /* SwiftTerm */,
|
||||||
|
53SCARFCORE0001 /* ScarfCore */,
|
||||||
53SPARKLE00011 /* Sparkle */,
|
53SPARKLE00011 /* Sparkle */,
|
||||||
);
|
);
|
||||||
productName = scarf;
|
productName = scarf;
|
||||||
@@ -225,6 +228,7 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||||
|
53SCARFCORE0020 /* XCLocalSwiftPackageReference "Packages/ScarfCore" */,
|
||||||
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
@@ -622,6 +626,13 @@
|
|||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
53SCARFCORE0020 /* XCLocalSwiftPackageReference "Packages/ScarfCore" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = Packages/ScarfCore;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
@@ -642,6 +653,10 @@
|
|||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
53SCARFCORE0001 /* ScarfCore */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = ScarfCore;
|
||||||
|
};
|
||||||
53SPARKLE00011 /* Sparkle */ = {
|
53SPARKLE00011 /* Sparkle */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||||
|
|||||||
@@ -1,486 +0,0 @@
|
|||||||
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.
|
|
||||||
struct AuxiliaryModel: Sendable, Equatable {
|
|
||||||
var provider: String
|
|
||||||
var model: String
|
|
||||||
var baseURL: String
|
|
||||||
var apiKey: String
|
|
||||||
var timeout: Int
|
|
||||||
|
|
||||||
nonisolated static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Group of display-related settings mirroring the `display:` block in config.yaml.
|
|
||||||
struct DisplaySettings: Sendable, Equatable {
|
|
||||||
var skin: String
|
|
||||||
var compact: Bool
|
|
||||||
var resumeDisplay: String // "full" | "minimal"
|
|
||||||
var bellOnComplete: Bool
|
|
||||||
var inlineDiffs: Bool
|
|
||||||
var toolProgressCommand: Bool
|
|
||||||
var toolPreviewLength: Int
|
|
||||||
var busyInputMode: String // e.g. "interrupt"
|
|
||||||
|
|
||||||
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.
|
|
||||||
struct TerminalSettings: Sendable, Equatable {
|
|
||||||
var cwd: String
|
|
||||||
var timeout: Int
|
|
||||||
var envPassthrough: [String]
|
|
||||||
var persistentShell: Bool
|
|
||||||
var dockerImage: String
|
|
||||||
var dockerMountCwdToWorkspace: Bool
|
|
||||||
var dockerForwardEnv: [String]
|
|
||||||
var dockerVolumes: [String]
|
|
||||||
var containerCPU: Int // 0 = unlimited
|
|
||||||
var containerMemory: Int // MB, 0 = unlimited
|
|
||||||
var containerDisk: Int // MB, 0 = unlimited
|
|
||||||
var containerPersistent: Bool
|
|
||||||
var modalImage: String
|
|
||||||
var modalMode: String // "auto" | other
|
|
||||||
var daytonaImage: String
|
|
||||||
var singularityImage: String
|
|
||||||
|
|
||||||
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.*`).
|
|
||||||
struct BrowserSettings: Sendable, Equatable {
|
|
||||||
var inactivityTimeout: Int
|
|
||||||
var commandTimeout: Int
|
|
||||||
var recordSessions: Bool
|
|
||||||
var allowPrivateURLs: Bool
|
|
||||||
var camofoxManagedPersistence: Bool
|
|
||||||
|
|
||||||
nonisolated static let empty = BrowserSettings(
|
|
||||||
inactivityTimeout: 120,
|
|
||||||
commandTimeout: 30,
|
|
||||||
recordSessions: false,
|
|
||||||
allowPrivateURLs: false,
|
|
||||||
camofoxManagedPersistence: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Voice push-to-talk plus TTS/STT provider settings.
|
|
||||||
struct VoiceSettings: Sendable, Equatable {
|
|
||||||
var recordKey: String
|
|
||||||
var maxRecordingSeconds: Int
|
|
||||||
var silenceDuration: Double
|
|
||||||
|
|
||||||
// TTS
|
|
||||||
var ttsProvider: String
|
|
||||||
var ttsEdgeVoice: String
|
|
||||||
var ttsElevenLabsVoiceID: String
|
|
||||||
var ttsElevenLabsModelID: String
|
|
||||||
var ttsOpenAIModel: String
|
|
||||||
var ttsOpenAIVoice: String
|
|
||||||
var ttsNeuTTSModel: String
|
|
||||||
var ttsNeuTTSDevice: String
|
|
||||||
|
|
||||||
// STT
|
|
||||||
var sttEnabled: Bool
|
|
||||||
var sttProvider: String
|
|
||||||
var sttLocalModel: String
|
|
||||||
var sttLocalLanguage: String
|
|
||||||
var sttOpenAIModel: String
|
|
||||||
var sttMistralModel: String
|
|
||||||
|
|
||||||
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.
|
|
||||||
struct AuxiliarySettings: Sendable, Equatable {
|
|
||||||
var vision: AuxiliaryModel
|
|
||||||
var webExtract: AuxiliaryModel
|
|
||||||
var compression: AuxiliaryModel
|
|
||||||
var sessionSearch: AuxiliaryModel
|
|
||||||
var skillsHub: AuxiliaryModel
|
|
||||||
var approval: AuxiliaryModel
|
|
||||||
var mcp: AuxiliaryModel
|
|
||||||
var flushMemories: AuxiliaryModel
|
|
||||||
|
|
||||||
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.
|
|
||||||
struct SecuritySettings: Sendable, Equatable {
|
|
||||||
var redactSecrets: Bool
|
|
||||||
var redactPII: Bool // from privacy.redact_pii
|
|
||||||
var tirithEnabled: Bool
|
|
||||||
var tirithPath: String
|
|
||||||
var tirithTimeout: Int
|
|
||||||
var tirithFailOpen: Bool
|
|
||||||
var blocklistEnabled: Bool
|
|
||||||
var blocklistDomains: [String]
|
|
||||||
|
|
||||||
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.*`).
|
|
||||||
struct HumanDelaySettings: Sendable, Equatable {
|
|
||||||
var mode: String // "off" | "natural" | "custom"
|
|
||||||
var minMS: Int
|
|
||||||
var maxMS: Int
|
|
||||||
|
|
||||||
nonisolated static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compression / context routing.
|
|
||||||
struct CompressionSettings: Sendable, Equatable {
|
|
||||||
var enabled: Bool
|
|
||||||
var threshold: Double
|
|
||||||
var targetRatio: Double
|
|
||||||
var protectLastN: Int
|
|
||||||
|
|
||||||
nonisolated static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CheckpointSettings: Sendable, Equatable {
|
|
||||||
var enabled: Bool
|
|
||||||
var maxSnapshots: Int
|
|
||||||
|
|
||||||
nonisolated static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LoggingSettings: Sendable, Equatable {
|
|
||||||
var level: String // DEBUG | INFO | WARNING | ERROR
|
|
||||||
var maxSizeMB: Int
|
|
||||||
var backupCount: Int
|
|
||||||
|
|
||||||
nonisolated static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DelegationSettings: Sendable, Equatable {
|
|
||||||
var model: String
|
|
||||||
var provider: String
|
|
||||||
var baseURL: String
|
|
||||||
var apiKey: String
|
|
||||||
var maxIterations: Int
|
|
||||||
|
|
||||||
nonisolated static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
|
|
||||||
struct DiscordSettings: Sendable, Equatable {
|
|
||||||
var requireMention: Bool
|
|
||||||
var freeResponseChannels: String
|
|
||||||
var autoThread: Bool
|
|
||||||
var reactions: Bool
|
|
||||||
|
|
||||||
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.
|
|
||||||
struct TelegramSettings: Sendable, Equatable {
|
|
||||||
var requireMention: Bool
|
|
||||||
var reactions: Bool
|
|
||||||
|
|
||||||
nonisolated static let empty = TelegramSettings(requireMention: true, reactions: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
|
|
||||||
struct SlackSettings: Sendable, Equatable {
|
|
||||||
var replyToMode: String // "off" | "first" | "all"
|
|
||||||
var requireMention: Bool
|
|
||||||
var replyInThread: Bool
|
|
||||||
var replyBroadcast: Bool
|
|
||||||
|
|
||||||
nonisolated static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Matrix settings under `matrix.*`.
|
|
||||||
struct MatrixSettings: Sendable, Equatable {
|
|
||||||
var requireMention: Bool
|
|
||||||
var autoThread: Bool
|
|
||||||
var dmMentionThreads: Bool
|
|
||||||
|
|
||||||
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.
|
|
||||||
struct MattermostSettings: Sendable, Equatable {
|
|
||||||
var requireMention: Bool
|
|
||||||
var replyMode: String // "thread" | "off"
|
|
||||||
|
|
||||||
nonisolated static let empty = MattermostSettings(requireMention: true, replyMode: "off")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// WhatsApp settings under `whatsapp.*`.
|
|
||||||
struct WhatsAppSettings: Sendable, Equatable {
|
|
||||||
var unauthorizedDMBehavior: String // "pair" | "ignore"
|
|
||||||
var replyPrefix: String
|
|
||||||
|
|
||||||
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.
|
|
||||||
struct HomeAssistantSettings: Sendable, Equatable {
|
|
||||||
var watchDomains: [String]
|
|
||||||
var watchEntities: [String]
|
|
||||||
var watchAll: Bool
|
|
||||||
var ignoreEntities: [String]
|
|
||||||
var cooldownSeconds: Int
|
|
||||||
|
|
||||||
nonisolated static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Root Config
|
|
||||||
|
|
||||||
struct HermesConfig: Sendable {
|
|
||||||
// Original fields — preserved for zero breakage with existing call sites.
|
|
||||||
var model: String
|
|
||||||
var provider: String
|
|
||||||
var maxTurns: Int
|
|
||||||
var personality: String
|
|
||||||
var terminalBackend: String
|
|
||||||
var memoryEnabled: Bool
|
|
||||||
var memoryCharLimit: Int
|
|
||||||
var userCharLimit: Int
|
|
||||||
var nudgeInterval: Int
|
|
||||||
var streaming: Bool
|
|
||||||
var showReasoning: Bool
|
|
||||||
var verbose: Bool
|
|
||||||
var autoTTS: Bool
|
|
||||||
var silenceThreshold: Int
|
|
||||||
var reasoningEffort: String
|
|
||||||
var showCost: Bool
|
|
||||||
var approvalMode: String
|
|
||||||
var browserBackend: String
|
|
||||||
var memoryProvider: String
|
|
||||||
var dockerEnv: [String: String]
|
|
||||||
var commandAllowlist: [String]
|
|
||||||
var memoryProfile: String
|
|
||||||
var serviceTier: String
|
|
||||||
var gatewayNotifyInterval: Int
|
|
||||||
var forceIPv4: Bool
|
|
||||||
var contextEngine: String
|
|
||||||
var interimAssistantMessages: Bool
|
|
||||||
var honchoInitOnSessionStart: Bool
|
|
||||||
|
|
||||||
// Phase 1 additions
|
|
||||||
var timezone: String
|
|
||||||
var userProfileEnabled: Bool
|
|
||||||
var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
|
|
||||||
var gatewayTimeout: Int
|
|
||||||
var approvalTimeout: Int
|
|
||||||
var fileReadMaxChars: Int
|
|
||||||
var cronWrapResponse: Bool
|
|
||||||
var prefillMessagesFile: String
|
|
||||||
var skillsExternalDirs: [String]
|
|
||||||
|
|
||||||
// Grouped blocks
|
|
||||||
var display: DisplaySettings
|
|
||||||
var terminal: TerminalSettings
|
|
||||||
var browser: BrowserSettings
|
|
||||||
var voice: VoiceSettings
|
|
||||||
var auxiliary: AuxiliarySettings
|
|
||||||
var security: SecuritySettings
|
|
||||||
var humanDelay: HumanDelaySettings
|
|
||||||
var compression: CompressionSettings
|
|
||||||
var checkpoints: CheckpointSettings
|
|
||||||
var logging: LoggingSettings
|
|
||||||
var delegation: DelegationSettings
|
|
||||||
var discord: DiscordSettings
|
|
||||||
var telegram: TelegramSettings
|
|
||||||
var slack: SlackSettings
|
|
||||||
var matrix: MatrixSettings
|
|
||||||
var mattermost: MattermostSettings
|
|
||||||
var whatsapp: WhatsAppSettings
|
|
||||||
var homeAssistant: HomeAssistantSettings
|
|
||||||
|
|
||||||
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).
|
|
||||||
struct GatewayState: Sendable, Codable {
|
|
||||||
nonisolated let pid: Int?
|
|
||||||
nonisolated let kind: String?
|
|
||||||
nonisolated let gatewayState: String?
|
|
||||||
nonisolated let exitReason: String?
|
|
||||||
nonisolated let platforms: [String: PlatformState]?
|
|
||||||
nonisolated let updatedAt: String?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case pid, kind
|
|
||||||
case gatewayState = "gateway_state"
|
|
||||||
case exitReason = "exit_reason"
|
|
||||||
case platforms
|
|
||||||
case updatedAt = "updated_at"
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated var isRunning: Bool {
|
|
||||||
gatewayState == "running"
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated var statusText: String {
|
|
||||||
gatewayState ?? "unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PlatformState: Sendable, Codable {
|
|
||||||
nonisolated let connected: Bool?
|
|
||||||
nonisolated let error: String?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case connected, error }
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
|
|
||||||
case stdio
|
|
||||||
case http
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var displayName: LocalizedStringResource {
|
|
||||||
switch self {
|
|
||||||
case .stdio: return "Local (stdio)"
|
|
||||||
case .http: return "Remote (HTTP)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HermesMCPServer: Identifiable, Sendable, Equatable {
|
|
||||||
let name: String
|
|
||||||
let transport: MCPTransport
|
|
||||||
let command: String?
|
|
||||||
let args: [String]
|
|
||||||
let url: String?
|
|
||||||
let auth: String?
|
|
||||||
let env: [String: String]
|
|
||||||
let headers: [String: String]
|
|
||||||
let timeout: Int?
|
|
||||||
let connectTimeout: Int?
|
|
||||||
let enabled: Bool
|
|
||||||
let toolsInclude: [String]
|
|
||||||
let toolsExclude: [String]
|
|
||||||
let resourcesEnabled: Bool
|
|
||||||
let promptsEnabled: Bool
|
|
||||||
let hasOAuthToken: Bool
|
|
||||||
|
|
||||||
var id: String { name }
|
|
||||||
|
|
||||||
var summary: String {
|
|
||||||
switch transport {
|
|
||||||
case .stdio:
|
|
||||||
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
|
|
||||||
return (command ?? "") + argString
|
|
||||||
case .http:
|
|
||||||
return url ?? ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MCPTestResult: Sendable, Equatable {
|
|
||||||
let serverName: String
|
|
||||||
let succeeded: Bool
|
|
||||||
let output: String
|
|
||||||
let tools: [String]
|
|
||||||
let elapsed: TimeInterval
|
|
||||||
}
|
|
||||||
+7
-26
@@ -1,11 +1,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SQLite3
|
import ScarfCore
|
||||||
|
|
||||||
/// Deprecated module-level path statics. Preserved as thin forwarders to
|
/// Deprecated module-level path statics. Preserved as thin forwarders to
|
||||||
/// `ServerContext.local.paths` so existing call sites continue to compile
|
/// `ServerContext.local.paths` so existing call sites continue to compile
|
||||||
/// while Phase 1 migrates them to a per-server `ServerContext`.
|
/// while Phase 1 migrates them to a per-server `ServerContext`.
|
||||||
///
|
///
|
||||||
/// New code should accept a `ServerContext` and read `context.paths.<field>`.
|
/// New code should accept a `ServerContext` and read `context.paths.<field>`.
|
||||||
|
///
|
||||||
|
/// **Staying behind in the Mac target**: this enum references
|
||||||
|
/// `ServerContext.local`, which currently lives in the Mac target (not yet
|
||||||
|
/// extracted to `ScarfCore` — that move is part of M0b). Once `ServerContext`
|
||||||
|
/// moves, this file can be deleted or moved alongside it. Until then, leaving
|
||||||
|
/// it here keeps the Mac build behavior unchanged.
|
||||||
enum HermesPaths: Sendable {
|
enum HermesPaths: Sendable {
|
||||||
@available(*, deprecated, message: "use ServerContext.paths.home")
|
@available(*, deprecated, message: "use ServerContext.paths.home")
|
||||||
nonisolated static var home: String { ServerContext.local.paths.home }
|
nonisolated static var home: String { ServerContext.local.paths.home }
|
||||||
@@ -66,28 +72,3 @@ enum HermesPaths: Sendable {
|
|||||||
@available(*, deprecated, message: "use ServerContext.paths.hermesBinary")
|
@available(*, deprecated, message: "use ServerContext.paths.hermesBinary")
|
||||||
nonisolated static var hermesBinary: String { ServerContext.local.paths.hermesBinary }
|
nonisolated static var hermesBinary: String { ServerContext.local.paths.hermesBinary }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SQLite Constants
|
|
||||||
|
|
||||||
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
|
|
||||||
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
|
|
||||||
nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
||||||
|
|
||||||
// MARK: - Query Defaults
|
|
||||||
|
|
||||||
enum QueryDefaults: Sendable {
|
|
||||||
nonisolated static let sessionLimit = 100
|
|
||||||
nonisolated static let messageSearchLimit = 50
|
|
||||||
nonisolated static let toolCallLimit = 50
|
|
||||||
nonisolated static let sessionPreviewLimit = 10
|
|
||||||
nonisolated static let previewContentLength = 100
|
|
||||||
nonisolated static let logLineLimit = 200
|
|
||||||
nonisolated static let defaultSilenceThreshold = 200
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - File Size Formatting
|
|
||||||
|
|
||||||
enum FileSizeUnit: Sendable {
|
|
||||||
nonisolated static let kilobyte = 1_024.0
|
|
||||||
nonisolated static let megabyte = 1_048_576.0
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct HermesSession: Identifiable, Sendable {
|
|
||||||
let id: String
|
|
||||||
let source: String
|
|
||||||
let userId: String?
|
|
||||||
let model: String?
|
|
||||||
let title: String?
|
|
||||||
let parentSessionId: String?
|
|
||||||
let startedAt: Date?
|
|
||||||
let endedAt: Date?
|
|
||||||
let endReason: String?
|
|
||||||
let messageCount: Int
|
|
||||||
let toolCallCount: Int
|
|
||||||
let inputTokens: Int
|
|
||||||
let outputTokens: Int
|
|
||||||
let cacheReadTokens: Int
|
|
||||||
let cacheWriteTokens: Int
|
|
||||||
let estimatedCostUSD: Double?
|
|
||||||
let reasoningTokens: Int
|
|
||||||
let actualCostUSD: Double?
|
|
||||||
let costStatus: String?
|
|
||||||
let billingProvider: String?
|
|
||||||
|
|
||||||
var isSubagent: Bool { parentSessionId != nil }
|
|
||||||
|
|
||||||
var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
|
|
||||||
|
|
||||||
var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
|
|
||||||
|
|
||||||
var costIsActual: Bool { actualCostUSD != nil }
|
|
||||||
|
|
||||||
var duration: TimeInterval? {
|
|
||||||
guard let start = startedAt, let end = endedAt else { return nil }
|
|
||||||
return end.timeIntervalSince(start)
|
|
||||||
}
|
|
||||||
|
|
||||||
var displayTitle: String {
|
|
||||||
title ?? id
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourceIcon: String {
|
|
||||||
KnownPlatforms.icon(for: source)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct HermesSkillCategory: Identifiable, Sendable {
|
|
||||||
let id: String
|
|
||||||
let name: String
|
|
||||||
let skills: [HermesSkill]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HermesSkill: Identifiable, Sendable {
|
|
||||||
let id: String
|
|
||||||
let name: String
|
|
||||||
let category: String
|
|
||||||
let path: String
|
|
||||||
let files: [String]
|
|
||||||
let requiredConfig: [String]
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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`.
|
|
||||||
struct HermesSlashCommand: Identifiable, Sendable, Equatable {
|
|
||||||
enum Source: Sendable, Equatable {
|
|
||||||
case acp
|
|
||||||
case quickCommand
|
|
||||||
}
|
|
||||||
|
|
||||||
var id: String { name }
|
|
||||||
let name: String
|
|
||||||
let description: String
|
|
||||||
let argumentHint: String?
|
|
||||||
let source: Source
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - Registry
|
|
||||||
|
|
||||||
struct ProjectRegistry: Codable, Sendable {
|
|
||||||
var projects: [ProjectEntry]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
|
|
||||||
var id: String { name }
|
|
||||||
let name: String
|
|
||||||
let path: String
|
|
||||||
|
|
||||||
var dashboardPath: String { path + "/.scarf/dashboard.json" }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Dashboard
|
|
||||||
|
|
||||||
struct ProjectDashboard: Codable, Sendable {
|
|
||||||
let version: Int
|
|
||||||
let title: String
|
|
||||||
let description: String?
|
|
||||||
let updatedAt: String?
|
|
||||||
let theme: DashboardTheme?
|
|
||||||
let sections: [DashboardSection]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DashboardTheme: Codable, Sendable {
|
|
||||||
let accent: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DashboardSection: Codable, Sendable, Identifiable {
|
|
||||||
var id: String { title }
|
|
||||||
let title: String
|
|
||||||
let columns: Int?
|
|
||||||
let widgets: [DashboardWidget]
|
|
||||||
|
|
||||||
var columnCount: Int { columns ?? 3 }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DashboardWidget: Codable, Sendable, Identifiable {
|
|
||||||
var id: String { type + ":" + title }
|
|
||||||
|
|
||||||
let type: String
|
|
||||||
let title: String
|
|
||||||
|
|
||||||
// Stat
|
|
||||||
let value: WidgetValue?
|
|
||||||
let icon: String?
|
|
||||||
let color: String?
|
|
||||||
let subtitle: String?
|
|
||||||
|
|
||||||
// Progress
|
|
||||||
let label: String?
|
|
||||||
|
|
||||||
// Text
|
|
||||||
let content: String?
|
|
||||||
let format: String?
|
|
||||||
|
|
||||||
// Table
|
|
||||||
let columns: [String]?
|
|
||||||
let rows: [[String]]?
|
|
||||||
|
|
||||||
// Chart
|
|
||||||
let chartType: String?
|
|
||||||
let xLabel: String?
|
|
||||||
let yLabel: String?
|
|
||||||
let series: [ChartSeries]?
|
|
||||||
|
|
||||||
// List
|
|
||||||
let items: [ListItem]?
|
|
||||||
|
|
||||||
// Webview
|
|
||||||
let url: String?
|
|
||||||
let height: Double?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Widget Value (String or Number)
|
|
||||||
|
|
||||||
enum WidgetValue: Codable, Sendable, Hashable {
|
|
||||||
case string(String)
|
|
||||||
case number(Double)
|
|
||||||
|
|
||||||
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)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
struct ChartSeries: Codable, Sendable, Identifiable {
|
|
||||||
var id: String { name }
|
|
||||||
let name: String
|
|
||||||
let color: String?
|
|
||||||
let data: [ChartDataPoint]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ChartDataPoint: Codable, Sendable, Identifiable {
|
|
||||||
var id: String { x }
|
|
||||||
let x: String
|
|
||||||
let y: Double
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - List Data
|
|
||||||
|
|
||||||
struct ListItem: Codable, Sendable, Identifiable {
|
|
||||||
var id: String { text }
|
|
||||||
let text: String
|
|
||||||
let status: String?
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import os
|
import os
|
||||||
|
|
||||||
/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio.
|
/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import SQLite3
|
import SQLite3
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import os
|
import os
|
||||||
|
|
||||||
struct HermesFileService: Sendable {
|
struct HermesFileService: Sendable {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct LogEntry: Identifiable, Sendable {
|
struct LogEntry: Identifiable, Sendable {
|
||||||
let id: Int
|
let id: Int
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import os
|
import os
|
||||||
|
|
||||||
struct ProjectDashboardService: Sendable {
|
struct ProjectDashboardService: Sendable {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class ActivityViewModel {
|
final class ActivityViewModel {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct ActivityView: View {
|
struct ActivityView: View {
|
||||||
@State private var viewModel: ActivityViewModel
|
@State private var viewModel: ActivityViewModel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import AppKit
|
import AppKit
|
||||||
import SwiftTerm
|
import SwiftTerm
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
enum ChatDisplayMode: String, CaseIterable {
|
enum ChatDisplayMode: String, CaseIterable {
|
||||||
case terminal
|
case terminal
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct RichChatInputBar: View {
|
struct RichChatInputBar: View {
|
||||||
let onSend: (String) -> Void
|
let onSend: (String) -> Void
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct RichChatMessageList: View {
|
struct RichChatMessageList: View {
|
||||||
let groups: [MessageGroup]
|
let groups: [MessageGroup]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct RichMessageBubble: View {
|
struct RichMessageBubble: View {
|
||||||
let message: HermesMessage
|
let message: HermesMessage
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct SessionInfoBar: View {
|
struct SessionInfoBar: View {
|
||||||
let session: HermesSession?
|
let session: HermesSession?
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
/// Floating menu of available slash commands shown above the chat input when
|
/// Floating menu of available slash commands shown above the chat input when
|
||||||
/// the user types `/` as the first character. Purely presentational — the
|
/// the user types `/` as the first character. Purely presentational — the
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct ToolCallCard: View {
|
struct ToolCallCard: View {
|
||||||
let call: HermesToolCall
|
let call: HermesToolCall
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import AppKit
|
import AppKit
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct CronView: View {
|
struct CronView: View {
|
||||||
@State private var viewModel: CronViewModel
|
@State private var viewModel: CronViewModel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class DashboardViewModel {
|
final class DashboardViewModel {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@State private var viewModel: DashboardViewModel
|
@State private var viewModel: DashboardViewModel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct GatewayInfo {
|
struct GatewayInfo {
|
||||||
let pid: Int?
|
let pid: Int?
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct GatewayView: View {
|
struct GatewayView: View {
|
||||||
@State private var viewModel: GatewayViewModel
|
@State private var viewModel: GatewayViewModel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
enum InsightsPeriod: String, CaseIterable, Identifiable {
|
enum InsightsPeriod: String, CaseIterable, Identifiable {
|
||||||
case week = "7 Days"
|
case week = "7 Days"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct InsightsView: View {
|
struct InsightsView: View {
|
||||||
@State private var viewModel: InsightsViewModel
|
@State private var viewModel: InsightsViewModel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class MCPServerEditorViewModel {
|
final class MCPServerEditorViewModel {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class MCPServersViewModel {
|
final class MCPServersViewModel {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct MCPServerAddCustomView: View {
|
struct MCPServerAddCustomView: View {
|
||||||
let viewModel: MCPServersViewModel
|
let viewModel: MCPServersViewModel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct MCPServerDetailView: View {
|
struct MCPServerDetailView: View {
|
||||||
let server: HermesMCPServer
|
let server: HermesMCPServer
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct MCPServerPresetPickerView: View {
|
struct MCPServerPresetPickerView: View {
|
||||||
let viewModel: MCPServersViewModel
|
let viewModel: MCPServersViewModel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct MCPServerTestResultView: View {
|
struct MCPServerTestResultView: View {
|
||||||
let result: MCPTestResult
|
let result: MCPTestResult
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct MCPServersView: View {
|
struct MCPServersView: View {
|
||||||
@State private var viewModel: MCPServersViewModel
|
@State private var viewModel: MCPServersViewModel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import os
|
import os
|
||||||
|
|
||||||
/// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`.
|
/// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import os
|
import os
|
||||||
|
|
||||||
/// Platform list/selection coordinator. Per-platform configuration now lives in
|
/// Platform list/selection coordinator. Per-platform configuration now lives in
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct PlatformsView: View {
|
struct PlatformsView: View {
|
||||||
@State private var viewModel: PlatformsViewModel
|
@State private var viewModel: PlatformsViewModel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
private enum DashboardTab: String, CaseIterable {
|
private enum DashboardTab: String, CaseIterable {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
import Charts
|
import Charts
|
||||||
|
|
||||||
// Flattened data point for Charts to avoid complex nested generic inference
|
// Flattened data point for Charts to avoid complex nested generic inference
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct ListWidgetView: View {
|
struct ListWidgetView: View {
|
||||||
let widget: DashboardWidget
|
let widget: DashboardWidget
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct ProgressWidgetView: View {
|
struct ProgressWidgetView: View {
|
||||||
let widget: DashboardWidget
|
let widget: DashboardWidget
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct StatWidgetView: View {
|
struct StatWidgetView: View {
|
||||||
let widget: DashboardWidget
|
let widget: DashboardWidget
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct TableWidgetView: View {
|
struct TableWidgetView: View {
|
||||||
let widget: DashboardWidget
|
let widget: DashboardWidget
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct TextWidgetView: View {
|
struct TextWidgetView: View {
|
||||||
let widget: DashboardWidget
|
let widget: DashboardWidget
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
struct WebviewWidgetView: View {
|
struct WebviewWidgetView: View {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
/// Bypasses `SSHTransport`'s normal terse-error path so the Add Server sheet
|
/// Bypasses `SSHTransport`'s normal terse-error path so the Add Server sheet
|
||||||
/// can show the user a full diagnostic on failure: the exact ssh command we
|
/// can show the user a full diagnostic on failure: the exact ssh command we
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import AppKit
|
import AppKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct SessionDetailView: View {
|
struct SessionDetailView: View {
|
||||||
let session: HermesSession
|
let session: HermesSession
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct SessionsView: View {
|
struct SessionsView: View {
|
||||||
@State private var viewModel: SessionsViewModel
|
@State private var viewModel: SessionsViewModel
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import AppKit
|
import AppKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
/// Auxiliary tab — the 8 sub-model tasks hermes delegates to cheaper models.
|
/// Auxiliary tab — the 8 sub-model tasks hermes delegates to cheaper models.
|
||||||
/// Each follows the same provider/model/base_url/api_key/timeout pattern.
|
/// Each follows the same provider/model/base_url/api_key/timeout pattern.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import os
|
import os
|
||||||
|
|
||||||
/// A single search/browse result from a skill registry.
|
/// A single search/browse result from a skill registry.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ScarfCore
|
||||||
import os
|
import os
|
||||||
|
|
||||||
/// Connection/configuration status for a messaging platform, used for indicator dots in the picker.
|
/// Connection/configuration status for a messaging platform, used for indicator dots in the picker.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
struct ToolsView: View {
|
struct ToolsView: View {
|
||||||
@State private var viewModel: ToolsViewModel
|
@State private var viewModel: ToolsViewModel
|
||||||
|
|||||||
Reference in New Issue
Block a user