mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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).
|
||||
// The member list must stay in sync with the stored properties above.
|
||||
|
||||
struct ACPRequest: Encodable, Sendable {
|
||||
nonisolated let jsonrpc = "2.0"
|
||||
nonisolated let id: Int
|
||||
nonisolated let method: String
|
||||
nonisolated let params: [String: AnyCodable]
|
||||
public struct ACPRequest: Encodable, Sendable {
|
||||
public nonisolated let jsonrpc = "2.0"
|
||||
public nonisolated let id: Int
|
||||
public nonisolated let method: String
|
||||
public nonisolated let params: [String: AnyCodable]
|
||||
|
||||
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)
|
||||
try c.encode(jsonrpc, forKey: .jsonrpc)
|
||||
try c.encode(id, forKey: .id)
|
||||
@@ -25,21 +35,21 @@ struct ACPRequest: Encodable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPRawMessage: Decodable, Sendable {
|
||||
nonisolated let jsonrpc: String?
|
||||
nonisolated let id: Int?
|
||||
nonisolated let method: String?
|
||||
nonisolated let result: AnyCodable?
|
||||
nonisolated let error: ACPError?
|
||||
nonisolated let params: AnyCodable?
|
||||
public struct ACPRawMessage: Decodable, Sendable {
|
||||
public nonisolated let jsonrpc: String?
|
||||
public nonisolated let id: Int?
|
||||
public nonisolated let method: String?
|
||||
public nonisolated let result: AnyCodable?
|
||||
public nonisolated let error: ACPError?
|
||||
public nonisolated let params: AnyCodable?
|
||||
|
||||
nonisolated var isResponse: Bool { id != nil && method == nil }
|
||||
nonisolated var isNotification: Bool { method != nil && id == nil }
|
||||
nonisolated var isRequest: Bool { method != nil && id != nil }
|
||||
public nonisolated var isResponse: Bool { id != nil && method == nil }
|
||||
public nonisolated var isNotification: Bool { method != nil && id == nil }
|
||||
public nonisolated var isRequest: Bool { method != nil && id != nil }
|
||||
|
||||
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)
|
||||
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
|
||||
self.id = try c.decodeIfPresent(Int.self, forKey: .id)
|
||||
@@ -50,13 +60,13 @@ struct ACPRawMessage: Decodable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPError: Decodable, Sendable {
|
||||
nonisolated let code: Int
|
||||
nonisolated let message: String
|
||||
public struct ACPError: Decodable, Sendable {
|
||||
public nonisolated let code: Int
|
||||
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)
|
||||
self.code = try c.decode(Int.self, forKey: .code)
|
||||
self.message = try c.decode(String.self, forKey: .message)
|
||||
@@ -65,10 +75,10 @@ struct ACPError: Decodable, Sendable {
|
||||
|
||||
// MARK: - AnyCodable (for dynamic JSON)
|
||||
|
||||
struct AnyCodable: Codable, @unchecked Sendable {
|
||||
nonisolated let value: Any
|
||||
public struct AnyCodable: Codable, @unchecked Sendable {
|
||||
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
|
||||
// `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
|
||||
// because all callers are already @preconcurrency with respect to
|
||||
// `AnyCodable` (it's @unchecked Sendable).
|
||||
init(from decoder: any Decoder) throws {
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
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()
|
||||
switch value {
|
||||
case is NSNull:
|
||||
@@ -123,15 +133,15 @@ struct AnyCodable: Codable, @unchecked Sendable {
|
||||
|
||||
// MARK: - Accessors
|
||||
|
||||
nonisolated var stringValue: String? { value as? String }
|
||||
nonisolated var intValue: Int? { value as? Int }
|
||||
nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
|
||||
nonisolated var arrayValue: [Any]? { value as? [Any] }
|
||||
public nonisolated var stringValue: String? { value as? String }
|
||||
public nonisolated var intValue: Int? { value as? Int }
|
||||
public nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
|
||||
public nonisolated var arrayValue: [Any]? { value as? [Any] }
|
||||
}
|
||||
|
||||
// MARK: - ACP Events (parsed from session/update notifications)
|
||||
|
||||
enum ACPEvent: Sendable {
|
||||
public enum ACPEvent: Sendable {
|
||||
case messageChunk(sessionId: String, text: String)
|
||||
case thoughtChunk(sessionId: String, text: String)
|
||||
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
|
||||
@@ -143,21 +153,37 @@ enum ACPEvent: Sendable {
|
||||
case unknown(sessionId: String, type: String)
|
||||
}
|
||||
|
||||
struct ACPToolCallEvent: Sendable {
|
||||
let toolCallId: String
|
||||
let title: String
|
||||
let kind: String
|
||||
let status: String
|
||||
let content: String
|
||||
let rawInput: [String: Any]?
|
||||
public struct ACPToolCallEvent: Sendable {
|
||||
public let toolCallId: String
|
||||
public let title: String
|
||||
public let kind: String
|
||||
public let status: String
|
||||
public let content: String
|
||||
public let rawInput: [String: Any]?
|
||||
|
||||
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"
|
||||
let parts = title.split(separator: ":", maxSplits: 1)
|
||||
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
var argumentsSummary: String {
|
||||
public var argumentsSummary: String {
|
||||
let parts = title.split(separator: ":", maxSplits: 1)
|
||||
if parts.count > 1 {
|
||||
return String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||
@@ -165,7 +191,7 @@ struct ACPToolCallEvent: Sendable {
|
||||
return ""
|
||||
}
|
||||
|
||||
var argumentsJSON: String {
|
||||
public var argumentsJSON: String {
|
||||
guard let input = rawInput,
|
||||
let data = try? JSONSerialization.data(withJSONObject: input),
|
||||
let str = String(data: data, encoding: .utf8) else { return "{}" }
|
||||
@@ -173,32 +199,70 @@ struct ACPToolCallEvent: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPToolCallUpdateEvent: Sendable {
|
||||
let toolCallId: String
|
||||
let kind: String
|
||||
let status: String
|
||||
let content: String
|
||||
let rawOutput: String?
|
||||
public struct ACPToolCallUpdateEvent: Sendable {
|
||||
public let toolCallId: String
|
||||
public let kind: String
|
||||
public let status: String
|
||||
public let content: String
|
||||
public let rawOutput: String?
|
||||
|
||||
public init(
|
||||
toolCallId: String,
|
||||
kind: String,
|
||||
status: String,
|
||||
content: String,
|
||||
rawOutput: String?
|
||||
) {
|
||||
self.toolCallId = toolCallId
|
||||
self.kind = kind
|
||||
self.status = status
|
||||
self.content = content
|
||||
self.rawOutput = rawOutput
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPPermissionRequestEvent: Sendable {
|
||||
let toolCallTitle: String
|
||||
let toolCallKind: String
|
||||
let options: [(optionId: String, name: String)]
|
||||
public struct ACPPermissionRequestEvent: Sendable {
|
||||
public let toolCallTitle: String
|
||||
public let toolCallKind: String
|
||||
public let options: [(optionId: String, name: String)]
|
||||
|
||||
public init(
|
||||
toolCallTitle: String,
|
||||
toolCallKind: String,
|
||||
options: [(optionId: String, name: String)]
|
||||
) {
|
||||
self.toolCallTitle = toolCallTitle
|
||||
self.toolCallKind = toolCallKind
|
||||
self.options = options
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPPromptResult: Sendable {
|
||||
let stopReason: String
|
||||
let inputTokens: Int
|
||||
let outputTokens: Int
|
||||
let thoughtTokens: Int
|
||||
let cachedReadTokens: Int
|
||||
public struct ACPPromptResult: Sendable {
|
||||
public let stopReason: String
|
||||
public let inputTokens: Int
|
||||
public let outputTokens: Int
|
||||
public let thoughtTokens: Int
|
||||
public let cachedReadTokens: Int
|
||||
|
||||
public init(
|
||||
stopReason: String,
|
||||
inputTokens: Int,
|
||||
outputTokens: Int,
|
||||
thoughtTokens: Int,
|
||||
cachedReadTokens: Int
|
||||
) {
|
||||
self.stopReason = stopReason
|
||||
self.inputTokens = inputTokens
|
||||
self.outputTokens = outputTokens
|
||||
self.thoughtTokens = thoughtTokens
|
||||
self.cachedReadTokens = cachedReadTokens
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Parsing
|
||||
|
||||
enum ACPEventParser {
|
||||
nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
|
||||
public enum ACPEventParser {
|
||||
public nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
|
||||
guard notification.method == "session/update",
|
||||
let params = notification.params?.dictValue,
|
||||
let sessionId = params["sessionId"] as? String,
|
||||
@@ -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",
|
||||
let params = message.params?.dictValue,
|
||||
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
|
||||
|
||||
struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
nonisolated let id: String
|
||||
nonisolated let name: String
|
||||
nonisolated let prompt: String
|
||||
nonisolated let skills: [String]?
|
||||
nonisolated let model: String?
|
||||
nonisolated let schedule: CronSchedule
|
||||
nonisolated let enabled: Bool
|
||||
nonisolated let state: String
|
||||
nonisolated let deliver: String?
|
||||
nonisolated let nextRunAt: String?
|
||||
nonisolated let lastRunAt: String?
|
||||
nonisolated let lastError: String?
|
||||
nonisolated let preRunScript: String?
|
||||
nonisolated let deliveryFailures: Int?
|
||||
nonisolated let lastDeliveryError: String?
|
||||
nonisolated let timeoutType: String?
|
||||
nonisolated let timeoutSeconds: Int?
|
||||
nonisolated let silent: Bool?
|
||||
public struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
public nonisolated let id: String
|
||||
public nonisolated let name: String
|
||||
public nonisolated let prompt: String
|
||||
public nonisolated let skills: [String]?
|
||||
public nonisolated let model: String?
|
||||
public nonisolated let schedule: CronSchedule
|
||||
public nonisolated let enabled: Bool
|
||||
public nonisolated let state: String
|
||||
public nonisolated let deliver: String?
|
||||
public nonisolated let nextRunAt: String?
|
||||
public nonisolated let lastRunAt: String?
|
||||
public nonisolated let lastError: String?
|
||||
public nonisolated let preRunScript: String?
|
||||
public nonisolated let deliveryFailures: Int?
|
||||
public nonisolated let lastDeliveryError: String?
|
||||
public nonisolated let timeoutType: String?
|
||||
public nonisolated let timeoutSeconds: Int?
|
||||
public nonisolated let silent: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
||||
case nextRunAt = "next_run_at"
|
||||
case lastRunAt = "last_run_at"
|
||||
@@ -32,7 +32,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
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)
|
||||
self.id = try c.decode(String.self, forKey: .id)
|
||||
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)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(id, forKey: .id)
|
||||
try c.encode(name, forKey: .name)
|
||||
@@ -76,7 +76,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
try c.encodeIfPresent(silent, forKey: .silent)
|
||||
}
|
||||
|
||||
nonisolated var stateIcon: String {
|
||||
public nonisolated var stateIcon: String {
|
||||
switch state {
|
||||
case "scheduled": return "clock"
|
||||
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 }
|
||||
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
|
||||
if deliver.hasPrefix("discord:") {
|
||||
@@ -102,20 +102,20 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
struct CronSchedule: Sendable, Codable {
|
||||
nonisolated let kind: String
|
||||
nonisolated let runAt: String?
|
||||
nonisolated let display: String?
|
||||
nonisolated let expression: String?
|
||||
public struct CronSchedule: Sendable, Codable {
|
||||
public nonisolated let kind: String
|
||||
public nonisolated let runAt: String?
|
||||
public nonisolated let display: String?
|
||||
public nonisolated let expression: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case runAt = "run_at"
|
||||
case display
|
||||
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)
|
||||
self.kind = try c.decode(String.self, forKey: .kind)
|
||||
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)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(kind, forKey: .kind)
|
||||
try c.encodeIfPresent(runAt, forKey: .runAt)
|
||||
@@ -135,22 +135,22 @@ struct CronSchedule: Sendable, Codable {
|
||||
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
|
||||
// MainActor-isolated Codable conformance — `HermesFileService.loadCronJobs`
|
||||
// is nonisolated and needs to decode this from a background task.
|
||||
struct CronJobsFile: Sendable, Codable {
|
||||
nonisolated let jobs: [HermesCronJob]
|
||||
nonisolated let updatedAt: String?
|
||||
public struct CronJobsFile: Sendable, Codable {
|
||||
public nonisolated let jobs: [HermesCronJob]
|
||||
public nonisolated let updatedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case jobs
|
||||
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)
|
||||
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
|
||||
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)
|
||||
try c.encode(jobs, forKey: .jobs)
|
||||
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
|
||||
public enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
|
||||
case stdio
|
||||
case http
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .stdio: return "Local (stdio)"
|
||||
case .http: return "Remote (HTTP)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct HermesMCPServer: Identifiable, Sendable, Equatable {
|
||||
public let name: String
|
||||
public let transport: MCPTransport
|
||||
public let command: String?
|
||||
public let args: [String]
|
||||
public let url: String?
|
||||
public let auth: String?
|
||||
public let env: [String: String]
|
||||
public let headers: [String: String]
|
||||
public let timeout: Int?
|
||||
public let connectTimeout: Int?
|
||||
public let enabled: Bool
|
||||
public let toolsInclude: [String]
|
||||
public let toolsExclude: [String]
|
||||
public let resourcesEnabled: Bool
|
||||
public let promptsEnabled: Bool
|
||||
public let hasOAuthToken: Bool
|
||||
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
transport: MCPTransport,
|
||||
command: String?,
|
||||
args: [String],
|
||||
url: String?,
|
||||
auth: String?,
|
||||
env: [String: String],
|
||||
headers: [String: String],
|
||||
timeout: Int?,
|
||||
connectTimeout: Int?,
|
||||
enabled: Bool,
|
||||
toolsInclude: [String],
|
||||
toolsExclude: [String],
|
||||
resourcesEnabled: Bool,
|
||||
promptsEnabled: Bool,
|
||||
hasOAuthToken: Bool
|
||||
) {
|
||||
self.name = name
|
||||
self.transport = transport
|
||||
self.command = command
|
||||
self.args = args
|
||||
self.url = url
|
||||
self.auth = auth
|
||||
self.env = env
|
||||
self.headers = headers
|
||||
self.timeout = timeout
|
||||
self.connectTimeout = connectTimeout
|
||||
self.enabled = enabled
|
||||
self.toolsInclude = toolsInclude
|
||||
self.toolsExclude = toolsExclude
|
||||
self.resourcesEnabled = resourcesEnabled
|
||||
self.promptsEnabled = promptsEnabled
|
||||
self.hasOAuthToken = hasOAuthToken
|
||||
}
|
||||
public var id: String { name }
|
||||
|
||||
public var summary: String {
|
||||
switch transport {
|
||||
case .stdio:
|
||||
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
|
||||
return (command ?? "") + argString
|
||||
case .http:
|
||||
return url ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct MCPTestResult: Sendable, Equatable {
|
||||
public let serverName: String
|
||||
public let succeeded: Bool
|
||||
public let output: String
|
||||
public let tools: [String]
|
||||
public let elapsed: TimeInterval
|
||||
|
||||
public init(
|
||||
serverName: String,
|
||||
succeeded: Bool,
|
||||
output: String,
|
||||
tools: [String],
|
||||
elapsed: TimeInterval
|
||||
) {
|
||||
self.serverName = serverName
|
||||
self.succeeded = succeeded
|
||||
self.output = output
|
||||
self.tools = tools
|
||||
self.elapsed = elapsed
|
||||
}
|
||||
}
|
||||
+58
-32
@@ -1,48 +1,74 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesMessage: Identifiable, Sendable {
|
||||
let id: Int
|
||||
let sessionId: String
|
||||
let role: String
|
||||
let content: String
|
||||
let toolCallId: String?
|
||||
let toolCalls: [HermesToolCall]
|
||||
let toolName: String?
|
||||
let timestamp: Date?
|
||||
let tokenCount: Int?
|
||||
let finishReason: String?
|
||||
let reasoning: String?
|
||||
public struct HermesMessage: Identifiable, Sendable {
|
||||
public let id: Int
|
||||
public let sessionId: String
|
||||
public let role: String
|
||||
public let content: String
|
||||
public let toolCallId: String?
|
||||
public let toolCalls: [HermesToolCall]
|
||||
public let toolName: String?
|
||||
public let timestamp: Date?
|
||||
public let tokenCount: Int?
|
||||
public let finishReason: String?
|
||||
public let reasoning: String?
|
||||
|
||||
var isUser: Bool { role == "user" }
|
||||
var isAssistant: Bool { role == "assistant" }
|
||||
var isToolResult: Bool { role == "tool" }
|
||||
var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
|
||||
|
||||
public init(
|
||||
id: Int,
|
||||
sessionId: String,
|
||||
role: String,
|
||||
content: String,
|
||||
toolCallId: String?,
|
||||
toolCalls: [HermesToolCall],
|
||||
toolName: String?,
|
||||
timestamp: Date?,
|
||||
tokenCount: Int?,
|
||||
finishReason: String?,
|
||||
reasoning: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.sessionId = sessionId
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.toolCallId = toolCallId
|
||||
self.toolCalls = toolCalls
|
||||
self.toolName = toolName
|
||||
self.timestamp = timestamp
|
||||
self.tokenCount = tokenCount
|
||||
self.finishReason = finishReason
|
||||
self.reasoning = reasoning
|
||||
}
|
||||
public var isUser: Bool { role == "user" }
|
||||
public var isAssistant: Bool { role == "assistant" }
|
||||
public var isToolResult: Bool { role == "tool" }
|
||||
public var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
|
||||
}
|
||||
|
||||
struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||
var id: String { callId }
|
||||
let callId: String
|
||||
let functionName: String
|
||||
let arguments: String
|
||||
public struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||
public var id: String { callId }
|
||||
public let callId: String
|
||||
public let functionName: String
|
||||
public let arguments: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case callId = "id"
|
||||
case type
|
||||
case function
|
||||
}
|
||||
|
||||
enum FunctionKeys: String, CodingKey {
|
||||
public enum FunctionKeys: String, CodingKey {
|
||||
case name
|
||||
case arguments
|
||||
}
|
||||
|
||||
init(callId: String, functionName: String, arguments: String) {
|
||||
public init(callId: String, functionName: String, arguments: String) {
|
||||
self.callId = callId
|
||||
self.functionName = functionName
|
||||
self.arguments = arguments
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
callId = try container.decode(String.self, forKey: .callId)
|
||||
let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
|
||||
@@ -50,7 +76,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||
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)
|
||||
try container.encode(callId, forKey: .callId)
|
||||
try container.encode("function", forKey: .type)
|
||||
@@ -59,7 +85,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||
try funcContainer.encode(arguments, forKey: .arguments)
|
||||
}
|
||||
|
||||
var toolKind: ToolKind {
|
||||
public var toolKind: ToolKind {
|
||||
switch functionName {
|
||||
case "read_file", "search_files", "vision_analyze": return .read
|
||||
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),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
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 edit
|
||||
case execute
|
||||
@@ -99,7 +125,7 @@ enum ToolKind: String, Sendable, CaseIterable {
|
||||
case browser
|
||||
case other
|
||||
|
||||
var displayName: LocalizedStringResource {
|
||||
public var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .read: return "Read"
|
||||
case .edit: return "Edit"
|
||||
@@ -110,7 +136,7 @@ enum ToolKind: String, Sendable, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
public var icon: String {
|
||||
switch self {
|
||||
case .read: return "doc.text.magnifyingglass"
|
||||
case .edit: return "pencil"
|
||||
@@ -121,7 +147,7 @@ enum ToolKind: String, Sendable, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
var color: String {
|
||||
public var color: String {
|
||||
switch self {
|
||||
case .read: return "green"
|
||||
case .edit: return "blue"
|
||||
+38
-28
@@ -11,58 +11,68 @@ import Foundation
|
||||
/// an instance property here. `ServerContext.paths` is the canonical way to
|
||||
/// reach these values; the old `HermesPaths` statics are preserved as
|
||||
/// deprecated forwarders so Phase 1 can migrate call sites incrementally.
|
||||
struct HermesPathSet: Sendable, Hashable {
|
||||
let home: String
|
||||
public struct HermesPathSet: Sendable, Hashable {
|
||||
public let home: String
|
||||
/// `true` when this path set belongs to a remote installation. Affects
|
||||
/// only `hermesBinary` resolution — every other path is identical in
|
||||
/// shape between local and remote.
|
||||
let isRemote: Bool
|
||||
public let isRemote: Bool
|
||||
/// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`).
|
||||
/// Populated by `SSHTransport` once `command -v hermes` has run on the
|
||||
/// target host. Unused when `isRemote == false`.
|
||||
let binaryHint: String?
|
||||
public let binaryHint: String?
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
/// 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()
|
||||
return user + "/.hermes"
|
||||
}()
|
||||
|
||||
/// Default remote home when the user doesn't override it in `SSHConfig`.
|
||||
/// We leave `~` unexpanded on purpose — the remote shell resolves it.
|
||||
nonisolated static let defaultRemoteHome: String = "~/.hermes"
|
||||
public nonisolated static let defaultRemoteHome: String = "~/.hermes"
|
||||
|
||||
// MARK: - Paths (mirror of the old HermesPaths layout)
|
||||
|
||||
nonisolated var stateDB: String { home + "/state.db" }
|
||||
nonisolated var configYAML: String { home + "/config.yaml" }
|
||||
nonisolated var envFile: String { home + "/.env" }
|
||||
nonisolated var authJSON: String { home + "/auth.json" }
|
||||
nonisolated var soulMD: String { home + "/SOUL.md" }
|
||||
nonisolated var pluginsDir: String { home + "/plugins" }
|
||||
nonisolated var memoriesDir: String { home + "/memories" }
|
||||
nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
|
||||
nonisolated var userMD: String { memoriesDir + "/USER.md" }
|
||||
nonisolated var sessionsDir: String { home + "/sessions" }
|
||||
nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
|
||||
nonisolated var cronOutputDir: String { home + "/cron/output" }
|
||||
nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
|
||||
nonisolated var skillsDir: String { home + "/skills" }
|
||||
nonisolated var errorsLog: String { home + "/logs/errors.log" }
|
||||
nonisolated var agentLog: String { home + "/logs/agent.log" }
|
||||
nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||
nonisolated var scarfDir: String { home + "/scarf" }
|
||||
nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||
nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||
public nonisolated var stateDB: String { home + "/state.db" }
|
||||
public nonisolated var configYAML: String { home + "/config.yaml" }
|
||||
public nonisolated var envFile: String { home + "/.env" }
|
||||
public nonisolated var authJSON: String { home + "/auth.json" }
|
||||
public nonisolated var soulMD: String { home + "/SOUL.md" }
|
||||
public nonisolated var pluginsDir: String { home + "/plugins" }
|
||||
public nonisolated var memoriesDir: String { home + "/memories" }
|
||||
public nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
|
||||
public nonisolated var userMD: String { memoriesDir + "/USER.md" }
|
||||
public nonisolated var sessionsDir: String { home + "/sessions" }
|
||||
public nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
|
||||
public nonisolated var cronOutputDir: String { home + "/cron/output" }
|
||||
public nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
|
||||
public nonisolated var skillsDir: String { home + "/skills" }
|
||||
public nonisolated var errorsLog: String { home + "/logs/errors.log" }
|
||||
public nonisolated var agentLog: String { home + "/logs/agent.log" }
|
||||
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||
public nonisolated var scarfDir: String { home + "/scarf" }
|
||||
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||
|
||||
// MARK: - Binary resolution
|
||||
|
||||
/// Install locations we probe for the local `hermes` binary, in priority
|
||||
/// order. Checked on every access so a user installing via a different
|
||||
/// method doesn't need to relaunch Scarf.
|
||||
nonisolated static let hermesBinaryCandidates: [String] = {
|
||||
public nonisolated static let hermesBinaryCandidates: [String] = {
|
||||
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||
return [
|
||||
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
|
||||
/// `"hermes"` as a last-resort default that relies on the remote `$PATH`.
|
||||
nonisolated var hermesBinary: String {
|
||||
public nonisolated var hermesBinary: String {
|
||||
if isRemote {
|
||||
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
|
||||
|
||||
struct HermesToolset: Identifiable, Sendable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let description: String
|
||||
let icon: String
|
||||
var enabled: Bool
|
||||
public struct HermesToolset: Identifiable, Sendable {
|
||||
public var id: String { name }
|
||||
public let name: String
|
||||
public let description: String
|
||||
public let icon: String
|
||||
public var enabled: Bool
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
description: String,
|
||||
icon: String,
|
||||
enabled: Bool
|
||||
) {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.icon = icon
|
||||
self.enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
struct HermesToolPlatform: Identifiable, Sendable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let displayName: String
|
||||
let icon: String
|
||||
public struct HermesToolPlatform: Identifiable, Sendable {
|
||||
public var id: String { name }
|
||||
public let name: String
|
||||
public let displayName: String
|
||||
public let icon: String
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
displayName: String,
|
||||
icon: String
|
||||
) {
|
||||
self.name = name
|
||||
self.displayName = displayName
|
||||
self.icon = icon
|
||||
}
|
||||
}
|
||||
|
||||
enum KnownPlatforms {
|
||||
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
|
||||
static let all: [HermesToolPlatform] = [
|
||||
public enum KnownPlatforms {
|
||||
public static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
|
||||
public static let all: [HermesToolPlatform] = [
|
||||
cli,
|
||||
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
||||
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
||||
@@ -33,7 +55,7 @@ enum KnownPlatforms {
|
||||
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 {
|
||||
case "cli": return "terminal"
|
||||
case "telegram": return "paperplane"
|
||||
+50
-18
@@ -1,22 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
struct MCPServerPreset: Identifiable, Sendable, Equatable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
let description: String
|
||||
let category: String
|
||||
let iconSystemName: String
|
||||
let transport: MCPTransport
|
||||
let command: String?
|
||||
let args: [String]
|
||||
let url: String?
|
||||
let auth: String?
|
||||
let requiredEnvKeys: [String]
|
||||
let optionalEnvKeys: [String]
|
||||
let pathArgPrompt: String?
|
||||
let docsURL: String
|
||||
public struct MCPServerPreset: Identifiable, Sendable, Equatable {
|
||||
public let id: String
|
||||
public let displayName: String
|
||||
public let description: String
|
||||
public let category: String
|
||||
public let iconSystemName: String
|
||||
public let transport: MCPTransport
|
||||
public let command: String?
|
||||
public let args: [String]
|
||||
public let url: String?
|
||||
public let auth: String?
|
||||
public let requiredEnvKeys: [String]
|
||||
public let optionalEnvKeys: [String]
|
||||
public let pathArgPrompt: String?
|
||||
public let docsURL: String
|
||||
|
||||
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(
|
||||
id: "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>()
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -71,6 +72,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
|
||||
53SCARFCORE0010 /* ScarfCore in Frameworks */,
|
||||
53SPARKLE00010 /* Sparkle in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -133,6 +135,7 @@
|
||||
name = scarf;
|
||||
packageProductDependencies = (
|
||||
53SWIFTTERM0001 /* SwiftTerm */,
|
||||
53SCARFCORE0001 /* ScarfCore */,
|
||||
53SPARKLE00011 /* Sparkle */,
|
||||
);
|
||||
productName = scarf;
|
||||
@@ -225,6 +228,7 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||
53SCARFCORE0020 /* XCLocalSwiftPackageReference "Packages/ScarfCore" */,
|
||||
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
@@ -622,6 +626,13 @@
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
53SCARFCORE0020 /* XCLocalSwiftPackageReference "Packages/ScarfCore" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Packages/ScarfCore;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
@@ -642,6 +653,10 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
53SCARFCORE0001 /* ScarfCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ScarfCore;
|
||||
};
|
||||
53SPARKLE00011 /* Sparkle */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
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 SQLite3
|
||||
import ScarfCore
|
||||
|
||||
/// Deprecated module-level path statics. Preserved as thin forwarders to
|
||||
/// `ServerContext.local.paths` so existing call sites continue to compile
|
||||
/// while Phase 1 migrates them to a per-server `ServerContext`.
|
||||
///
|
||||
/// 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 {
|
||||
@available(*, deprecated, message: "use ServerContext.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")
|
||||
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 ScarfCore
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import SQLite3
|
||||
import os
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
struct HermesFileService: Sendable {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
struct LogEntry: Identifiable, Sendable {
|
||||
let id: Int
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
struct ProjectDashboardService: Sendable {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
@Observable
|
||||
final class ActivityViewModel {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct ActivityView: View {
|
||||
@State private var viewModel: ActivityViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
import SwiftTerm
|
||||
import os
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
enum ChatDisplayMode: String, CaseIterable {
|
||||
case terminal
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct RichChatInputBar: View {
|
||||
let onSend: (String) -> Void
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct RichChatMessageList: View {
|
||||
let groups: [MessageGroup]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct RichMessageBubble: View {
|
||||
let message: HermesMessage
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct SessionInfoBar: View {
|
||||
let session: HermesSession?
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Floating menu of available slash commands shown above the chat input when
|
||||
/// the user types `/` as the first character. Purely presentational — the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct ToolCallCard: View {
|
||||
let call: HermesToolCall
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct CronView: View {
|
||||
@State private var viewModel: CronViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
@Observable
|
||||
final class DashboardViewModel {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct DashboardView: View {
|
||||
@State private var viewModel: DashboardViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
struct GatewayInfo {
|
||||
let pid: Int?
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct GatewayView: View {
|
||||
@State private var viewModel: GatewayViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
enum InsightsPeriod: String, CaseIterable, Identifiable {
|
||||
case week = "7 Days"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct InsightsView: View {
|
||||
@State private var viewModel: InsightsViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
@Observable
|
||||
final class MCPServerEditorViewModel {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
@Observable
|
||||
final class MCPServersViewModel {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct MCPServerAddCustomView: View {
|
||||
let viewModel: MCPServersViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct MCPServerDetailView: View {
|
||||
let server: HermesMCPServer
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct MCPServerPresetPickerView: View {
|
||||
let viewModel: MCPServersViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct MCPServerTestResultView: View {
|
||||
let result: MCPTestResult
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct MCPServersView: View {
|
||||
@State private var viewModel: MCPServersViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// Platform list/selection coordinator. Per-platform configuration now lives in
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct PlatformsView: View {
|
||||
@State private var viewModel: PlatformsViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
@Observable
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
private enum DashboardTab: String, CaseIterable {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import Charts
|
||||
|
||||
// Flattened data point for Charts to avoid complex nested generic inference
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct ListWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct ProgressWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct StatWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct TableWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct TextWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import WebKit
|
||||
|
||||
struct WebviewWidgetView: View {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct SessionDetailView: View {
|
||||
let session: HermesSession
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct SessionsView: View {
|
||||
@State private var viewModel: SessionsViewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
import os
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Auxiliary tab — the 8 sub-model tasks hermes delegates to cheaper models.
|
||||
/// Each follows the same provider/model/base_url/api_key/timeout pattern.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// A single search/browse result from a skill registry.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// Connection/configuration status for a messaging platform, used for indicator dots in the picker.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct ToolsView: View {
|
||||
@State private var viewModel: ToolsViewModel
|
||||
|
||||
Reference in New Issue
Block a user