From bb5045c10fcb06b55e057c7d6cad119d56293164 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 12:19:17 +0000 Subject: [PATCH] iOS port M0a: extract 13 leaf Models to new ScarfCore local SPM package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ScarfCore}/Models/ACPMessages.swift | 190 ++-- .../ScarfCore/Models/HermesConfig.swift | 888 ++++++++++++++++++ .../ScarfCore}/Models/HermesCronJob.swift | 76 +- .../ScarfCore/Models/HermesMCPServer.swift | 104 ++ .../ScarfCore}/Models/HermesMessage.swift | 90 +- .../ScarfCore}/Models/HermesPathSet.swift | 66 +- .../ScarfCore/Models/HermesSession.swift | 103 ++ .../ScarfCore/Models/HermesSkill.swift | 42 + .../ScarfCore/Models/HermesSlashCommand.swift | 29 + .../ScarfCore}/Models/HermesTool.swift | 52 +- .../ScarfCore}/Models/MCPServerPreset.swift | 68 +- .../ScarfCore/Models/ProjectDashboard.swift | 250 +++++ scarf/docs/IOS_PORT_PLAN.md | 191 ++++ scarf/scarf.xcodeproj/project.pbxproj | 15 + scarf/scarf/Core/Models/HermesConfig.swift | 486 ---------- scarf/scarf/Core/Models/HermesMCPServer.swift | 54 -- ...nts.swift => HermesPaths+Deprecated.swift} | 33 +- scarf/scarf/Core/Models/HermesSession.swift | 59 -- scarf/scarf/Core/Models/HermesSkill.swift | 16 - .../Core/Models/HermesSlashCommand.swift | 17 - .../scarf/Core/Models/ProjectDashboard.swift | 138 --- scarf/scarf/Core/Models/ServerContext.swift | 1 + scarf/scarf/Core/Services/ACPClient.swift | 1 + .../Core/Services/HermesDataService.swift | 1 + .../Core/Services/HermesFileService.swift | 1 + .../Core/Services/HermesLogService.swift | 1 + .../Services/ProjectDashboardService.swift | 1 + .../ViewModels/ActivityViewModel.swift | 1 + .../Activity/Views/ActivityView.swift | 1 + .../Chat/ViewModels/ChatViewModel.swift | 1 + .../Chat/ViewModels/RichChatViewModel.swift | 1 + .../Chat/Views/RichChatInputBar.swift | 1 + .../Chat/Views/RichChatMessageList.swift | 1 + .../Chat/Views/RichMessageBubble.swift | 1 + .../Features/Chat/Views/SessionInfoBar.swift | 1 + .../Chat/Views/SlashCommandMenu.swift | 1 + .../Features/Chat/Views/ToolCallCard.swift | 1 + .../Cron/ViewModels/CronViewModel.swift | 1 + .../scarf/Features/Cron/Views/CronView.swift | 1 + .../ViewModels/DashboardViewModel.swift | 1 + .../Dashboard/Views/DashboardView.swift | 1 + .../Gateway/ViewModels/GatewayViewModel.swift | 1 + .../Features/Gateway/Views/GatewayView.swift | 1 + .../ViewModels/InsightsViewModel.swift | 1 + .../Insights/Views/InsightsView.swift | 1 + .../ViewModels/MCPServerEditorViewModel.swift | 1 + .../ViewModels/MCPServersViewModel.swift | 1 + .../Views/MCPServerAddCustomView.swift | 1 + .../Views/MCPServerDetailView.swift | 1 + .../Views/MCPServerPresetPickerView.swift | 1 + .../Views/MCPServerTestResultView.swift | 1 + .../MCPServers/Views/MCPServersView.swift | 1 + .../PlatformSetup/DiscordSetupViewModel.swift | 1 + .../ViewModels/PlatformsViewModel.swift | 1 + .../Platforms/Views/PlatformsView.swift | 1 + .../ViewModels/ProjectsViewModel.swift | 1 + .../Projects/Views/ProjectsView.swift | 1 + .../Views/Widgets/ChartWidgetView.swift | 1 + .../Views/Widgets/ListWidgetView.swift | 1 + .../Views/Widgets/ProgressWidgetView.swift | 1 + .../Views/Widgets/StatWidgetView.swift | 1 + .../Views/Widgets/TableWidgetView.swift | 1 + .../Views/Widgets/TextWidgetView.swift | 1 + .../Views/Widgets/WebviewWidgetView.swift | 1 + .../ViewModels/TestConnectionProbe.swift | 1 + .../ViewModels/SessionsViewModel.swift | 1 + .../Sessions/Views/SessionDetailView.swift | 1 + .../Sessions/Views/SessionsView.swift | 1 + .../ViewModels/SettingsViewModel.swift | 1 + .../Settings/Views/Tabs/AuxiliaryTab.swift | 1 + .../Skills/ViewModels/SkillsViewModel.swift | 1 + .../Tools/ViewModels/ToolsViewModel.swift | 1 + .../Features/Tools/Views/ToolsView.swift | 1 + 73 files changed, 2029 insertions(+), 990 deletions(-) rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Models/ACPMessages.swift (65%) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Models/HermesCronJob.swift (74%) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Models/HermesMessage.swift (59%) rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Models/HermesPathSet.swift (58%) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSession.swift create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Models/HermesTool.swift (64%) rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Models/MCPServerPreset.swift (78%) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectDashboard.swift create mode 100644 scarf/docs/IOS_PORT_PLAN.md delete mode 100644 scarf/scarf/Core/Models/HermesConfig.swift delete mode 100644 scarf/scarf/Core/Models/HermesMCPServer.swift rename scarf/scarf/Core/Models/{HermesConstants.swift => HermesPaths+Deprecated.swift} (79%) delete mode 100644 scarf/scarf/Core/Models/HermesSession.swift delete mode 100644 scarf/scarf/Core/Models/HermesSkill.swift delete mode 100644 scarf/scarf/Core/Models/HermesSlashCommand.swift delete mode 100644 scarf/scarf/Core/Models/ProjectDashboard.swift diff --git a/scarf/scarf/Core/Models/ACPMessages.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ACPMessages.swift similarity index 65% rename from scarf/scarf/Core/Models/ACPMessages.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ACPMessages.swift index 52171da..8bd602f 100644 --- a/scarf/scarf/Core/Models/ACPMessages.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ACPMessages.swift @@ -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, diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift new file mode 100644 index 0000000..5973962 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift @@ -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) + } +} diff --git a/scarf/scarf/Core/Models/HermesCronJob.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift similarity index 74% rename from scarf/scarf/Core/Models/HermesCronJob.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift index df32f9d..e709ddc 100644 --- a/scarf/scarf/Core/Models/HermesCronJob.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift @@ -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::`. 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) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift new file mode 100644 index 0000000..c62cbc5 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMCPServer.swift @@ -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 + } +} diff --git a/scarf/scarf/Core/Models/HermesMessage.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift similarity index 59% rename from scarf/scarf/Core/Models/HermesMessage.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift index 1cd0394..357d731 100644 --- a/scarf/scarf/Core/Models/HermesMessage.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift @@ -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" diff --git a/scarf/scarf/Core/Models/HermesPathSet.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift similarity index 58% rename from scarf/scarf/Core/Models/HermesPathSet.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift index dfe91cc..554b5c4 100644 --- a/scarf/scarf/Core/Models/HermesPathSet.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift @@ -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" } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSession.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSession.swift new file mode 100644 index 0000000..c661e96 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSession.swift @@ -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 + ) + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift new file mode 100644 index 0000000..6ced10b --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSkill.swift @@ -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 + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift new file mode 100644 index 0000000..bb850d7 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift @@ -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 + } +} diff --git a/scarf/scarf/Core/Models/HermesTool.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesTool.swift similarity index 64% rename from scarf/scarf/Core/Models/HermesTool.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesTool.swift index fa1bd67..db9c04b 100644 --- a/scarf/scarf/Core/Models/HermesTool.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesTool.swift @@ -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" diff --git a/scarf/scarf/Core/Models/MCPServerPreset.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/MCPServerPreset.swift similarity index 78% rename from scarf/scarf/Core/Models/MCPServerPreset.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Models/MCPServerPreset.swift index 5e15b14..d26c3a3 100644 --- a/scarf/scarf/Core/Models/MCPServerPreset.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/MCPServerPreset.swift @@ -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() 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 } } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectDashboard.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectDashboard.swift new file mode 100644 index 0000000..8b9ec5b --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectDashboard.swift @@ -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 + } +} diff --git a/scarf/docs/IOS_PORT_PLAN.md b/scarf/docs/IOS_PORT_PLAN.md new file mode 100644 index 0000000..479e127 --- /dev/null +++ b/scarf/docs/IOS_PORT_PLAN.md @@ -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 { 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 diff --git a/scarf/scarf.xcodeproj/project.pbxproj b/scarf/scarf.xcodeproj/project.pbxproj index 485ad51..eb33ee2 100644 --- a/scarf/scarf.xcodeproj/project.pbxproj +++ b/scarf/scarf.xcodeproj/project.pbxproj @@ -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" */; diff --git a/scarf/scarf/Core/Models/HermesConfig.swift b/scarf/scarf/Core/Models/HermesConfig.swift deleted file mode 100644 index 2a0436a..0000000 --- a/scarf/scarf/Core/Models/HermesConfig.swift +++ /dev/null @@ -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) - } -} diff --git a/scarf/scarf/Core/Models/HermesMCPServer.swift b/scarf/scarf/Core/Models/HermesMCPServer.swift deleted file mode 100644 index d35cb14..0000000 --- a/scarf/scarf/Core/Models/HermesMCPServer.swift +++ /dev/null @@ -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 -} diff --git a/scarf/scarf/Core/Models/HermesConstants.swift b/scarf/scarf/Core/Models/HermesPaths+Deprecated.swift similarity index 79% rename from scarf/scarf/Core/Models/HermesConstants.swift rename to scarf/scarf/Core/Models/HermesPaths+Deprecated.swift index 4baf015..c07d02a 100644 --- a/scarf/scarf/Core/Models/HermesConstants.swift +++ b/scarf/scarf/Core/Models/HermesPaths+Deprecated.swift @@ -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.`. +/// +/// **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 -} diff --git a/scarf/scarf/Core/Models/HermesSession.swift b/scarf/scarf/Core/Models/HermesSession.swift deleted file mode 100644 index bc57522..0000000 --- a/scarf/scarf/Core/Models/HermesSession.swift +++ /dev/null @@ -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 - ) - } -} diff --git a/scarf/scarf/Core/Models/HermesSkill.swift b/scarf/scarf/Core/Models/HermesSkill.swift deleted file mode 100644 index 4907668..0000000 --- a/scarf/scarf/Core/Models/HermesSkill.swift +++ /dev/null @@ -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] -} diff --git a/scarf/scarf/Core/Models/HermesSlashCommand.swift b/scarf/scarf/Core/Models/HermesSlashCommand.swift deleted file mode 100644 index e0702a0..0000000 --- a/scarf/scarf/Core/Models/HermesSlashCommand.swift +++ /dev/null @@ -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 -} diff --git a/scarf/scarf/Core/Models/ProjectDashboard.swift b/scarf/scarf/Core/Models/ProjectDashboard.swift deleted file mode 100644 index abf598c..0000000 --- a/scarf/scarf/Core/Models/ProjectDashboard.swift +++ /dev/null @@ -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? -} diff --git a/scarf/scarf/Core/Models/ServerContext.swift b/scarf/scarf/Core/Models/ServerContext.swift index eb2db63..9510a8c 100644 --- a/scarf/scarf/Core/Models/ServerContext.swift +++ b/scarf/scarf/Core/Models/ServerContext.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import SwiftUI import AppKit diff --git a/scarf/scarf/Core/Services/ACPClient.swift b/scarf/scarf/Core/Services/ACPClient.swift index b1628a1..f6f8af3 100644 --- a/scarf/scarf/Core/Services/ACPClient.swift +++ b/scarf/scarf/Core/Services/ACPClient.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os /// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio. diff --git a/scarf/scarf/Core/Services/HermesDataService.swift b/scarf/scarf/Core/Services/HermesDataService.swift index c75fbaf..4ab81c1 100644 --- a/scarf/scarf/Core/Services/HermesDataService.swift +++ b/scarf/scarf/Core/Services/HermesDataService.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import SQLite3 import os diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index cf5e980..4997abc 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os struct HermesFileService: Sendable { diff --git a/scarf/scarf/Core/Services/HermesLogService.swift b/scarf/scarf/Core/Services/HermesLogService.swift index 6e92ef5..26d985b 100644 --- a/scarf/scarf/Core/Services/HermesLogService.swift +++ b/scarf/scarf/Core/Services/HermesLogService.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore struct LogEntry: Identifiable, Sendable { let id: Int diff --git a/scarf/scarf/Core/Services/ProjectDashboardService.swift b/scarf/scarf/Core/Services/ProjectDashboardService.swift index 2d616c7..2d32d3d 100644 --- a/scarf/scarf/Core/Services/ProjectDashboardService.swift +++ b/scarf/scarf/Core/Services/ProjectDashboardService.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os struct ProjectDashboardService: Sendable { diff --git a/scarf/scarf/Features/Activity/ViewModels/ActivityViewModel.swift b/scarf/scarf/Features/Activity/ViewModels/ActivityViewModel.swift index a4b7edf..fe01030 100644 --- a/scarf/scarf/Features/Activity/ViewModels/ActivityViewModel.swift +++ b/scarf/scarf/Features/Activity/ViewModels/ActivityViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore @Observable final class ActivityViewModel { diff --git a/scarf/scarf/Features/Activity/Views/ActivityView.swift b/scarf/scarf/Features/Activity/Views/ActivityView.swift index 9ebf53f..eb84244 100644 --- a/scarf/scarf/Features/Activity/Views/ActivityView.swift +++ b/scarf/scarf/Features/Activity/Views/ActivityView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct ActivityView: View { @State private var viewModel: ActivityViewModel diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 2f37ada..094eaf7 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import AppKit import SwiftTerm import os diff --git a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift index 6718bd5..e29fa24 100644 --- a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore enum ChatDisplayMode: String, CaseIterable { case terminal diff --git a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift index 5590884..34ac54f 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct RichChatInputBar: View { let onSend: (String) -> Void diff --git a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift index 0f33cd5..1841b02 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct RichChatMessageList: View { let groups: [MessageGroup] diff --git a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift index c9dfc7f..8ca7d84 100644 --- a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift +++ b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct RichMessageBubble: View { let message: HermesMessage diff --git a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift index e4c7a3a..27c625d 100644 --- a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift +++ b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct SessionInfoBar: View { let session: HermesSession? diff --git a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift index 915f9ad..e6c1cef 100644 --- a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift +++ b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift @@ -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 diff --git a/scarf/scarf/Features/Chat/Views/ToolCallCard.swift b/scarf/scarf/Features/Chat/Views/ToolCallCard.swift index 3499993..6b8a1b7 100644 --- a/scarf/scarf/Features/Chat/Views/ToolCallCard.swift +++ b/scarf/scarf/Features/Chat/Views/ToolCallCard.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct ToolCallCard: View { let call: HermesToolCall diff --git a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift index 6ae4196..7fce827 100644 --- a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift +++ b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import AppKit import os diff --git a/scarf/scarf/Features/Cron/Views/CronView.swift b/scarf/scarf/Features/Cron/Views/CronView.swift index 169a495..d8c9891 100644 --- a/scarf/scarf/Features/Cron/Views/CronView.swift +++ b/scarf/scarf/Features/Cron/Views/CronView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct CronView: View { @State private var viewModel: CronViewModel diff --git a/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift b/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift index a1956da..e51c21a 100644 --- a/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift +++ b/scarf/scarf/Features/Dashboard/ViewModels/DashboardViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore @Observable final class DashboardViewModel { diff --git a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift index 9aacca7..87e155d 100644 --- a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift +++ b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct DashboardView: View { @State private var viewModel: DashboardViewModel diff --git a/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift index eab0a99..bf8754f 100644 --- a/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift +++ b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore struct GatewayInfo { let pid: Int? diff --git a/scarf/scarf/Features/Gateway/Views/GatewayView.swift b/scarf/scarf/Features/Gateway/Views/GatewayView.swift index 1e908ba..323ba98 100644 --- a/scarf/scarf/Features/Gateway/Views/GatewayView.swift +++ b/scarf/scarf/Features/Gateway/Views/GatewayView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct GatewayView: View { @State private var viewModel: GatewayViewModel diff --git a/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift b/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift index bf1ce76..23fe8a3 100644 --- a/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift +++ b/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore enum InsightsPeriod: String, CaseIterable, Identifiable { case week = "7 Days" diff --git a/scarf/scarf/Features/Insights/Views/InsightsView.swift b/scarf/scarf/Features/Insights/Views/InsightsView.swift index 496458b..98abbdd 100644 --- a/scarf/scarf/Features/Insights/Views/InsightsView.swift +++ b/scarf/scarf/Features/Insights/Views/InsightsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct InsightsView: View { @State private var viewModel: InsightsViewModel diff --git a/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift b/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift index 76eed94..6f85fa2 100644 --- a/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift +++ b/scarf/scarf/Features/MCPServers/ViewModels/MCPServerEditorViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore @Observable final class MCPServerEditorViewModel { diff --git a/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift b/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift index bed1756..71deaa7 100644 --- a/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift +++ b/scarf/scarf/Features/MCPServers/ViewModels/MCPServersViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore @Observable final class MCPServersViewModel { diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift index f8ae576..e51b70d 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerAddCustomView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct MCPServerAddCustomView: View { let viewModel: MCPServersViewModel diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift index f076800..d963669 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerDetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct MCPServerDetailView: View { let server: HermesMCPServer diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerPresetPickerView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerPresetPickerView.swift index 58491c6..33bbbad 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServerPresetPickerView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerPresetPickerView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct MCPServerPresetPickerView: View { let viewModel: MCPServersViewModel diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServerTestResultView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServerTestResultView.swift index b15e947..d98cf45 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServerTestResultView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServerTestResultView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct MCPServerTestResultView: View { let result: MCPTestResult diff --git a/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift b/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift index 4aef8e4..949dbce 100644 --- a/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift +++ b/scarf/scarf/Features/MCPServers/Views/MCPServersView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct MCPServersView: View { @State private var viewModel: MCPServersViewModel diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift index 99bad9a..0072a04 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/DiscordSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os /// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`. diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift index 5a5468c..e417e44 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os /// Platform list/selection coordinator. Per-platform configuration now lives in diff --git a/scarf/scarf/Features/Platforms/Views/PlatformsView.swift b/scarf/scarf/Features/Platforms/Views/PlatformsView.swift index 8fff3bb..d9330c8 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformsView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct PlatformsView: View { @State private var viewModel: PlatformsViewModel diff --git a/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift b/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift index 53bfb14..17652ac 100644 --- a/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift +++ b/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os @Observable diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index f80be89..9056a31 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore import UniformTypeIdentifiers private enum DashboardTab: String, CaseIterable { diff --git a/scarf/scarf/Features/Projects/Views/Widgets/ChartWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/ChartWidgetView.swift index 14175b4..525c0da 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/ChartWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/ChartWidgetView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore import Charts // Flattened data point for Charts to avoid complex nested generic inference diff --git a/scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift index e3ed9cb..a8c82d5 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct ListWidgetView: View { let widget: DashboardWidget diff --git a/scarf/scarf/Features/Projects/Views/Widgets/ProgressWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/ProgressWidgetView.swift index 94abe93..e89c392 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/ProgressWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/ProgressWidgetView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct ProgressWidgetView: View { let widget: DashboardWidget diff --git a/scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift index 23ec09e..7ebf5ee 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct StatWidgetView: View { let widget: DashboardWidget diff --git a/scarf/scarf/Features/Projects/Views/Widgets/TableWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/TableWidgetView.swift index 8da5879..1f69285 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/TableWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/TableWidgetView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct TableWidgetView: View { let widget: DashboardWidget diff --git a/scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift index 6334cb3..4d26216 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct TextWidgetView: View { let widget: DashboardWidget diff --git a/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift index c1d68ac..41c5f9d 100644 --- a/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift +++ b/scarf/scarf/Features/Projects/Views/Widgets/WebviewWidgetView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore import WebKit struct WebviewWidgetView: View { diff --git a/scarf/scarf/Features/Servers/ViewModels/TestConnectionProbe.swift b/scarf/scarf/Features/Servers/ViewModels/TestConnectionProbe.swift index 0e3ba44..fa4dc50 100644 --- a/scarf/scarf/Features/Servers/ViewModels/TestConnectionProbe.swift +++ b/scarf/scarf/Features/Servers/ViewModels/TestConnectionProbe.swift @@ -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 diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift index 7d8ba36..2cf5f42 100644 --- a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift +++ b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import AppKit import UniformTypeIdentifiers diff --git a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift index 7439822..4dd074b 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct SessionDetailView: View { let session: HermesSession diff --git a/scarf/scarf/Features/Sessions/Views/SessionsView.swift b/scarf/scarf/Features/Sessions/Views/SessionsView.swift index 3e9f47e..d8093da 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionsView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct SessionsView: View { @State private var viewModel: SessionsViewModel diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index 8aec927..e3a4245 100644 --- a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import AppKit import UniformTypeIdentifiers import os diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift index df3c7ee..d035877 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift @@ -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. diff --git a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift index 8178e0a..a929577 100644 --- a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift +++ b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os /// A single search/browse result from a skill registry. diff --git a/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift index 9b8800e..a0aa465 100644 --- a/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift +++ b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os /// Connection/configuration status for a messaging platform, used for indicator dots in the picker. diff --git a/scarf/scarf/Features/Tools/Views/ToolsView.swift b/scarf/scarf/Features/Tools/Views/ToolsView.swift index ec813fa..4bdc0eb 100644 --- a/scarf/scarf/Features/Tools/Views/ToolsView.swift +++ b/scarf/scarf/Features/Tools/Views/ToolsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct ToolsView: View { @State private var viewModel: ToolsViewModel