import Foundation import NucleicCore /// Selectable model SKUs and effort levels, plus the persistence keys for the per-app /// defaults applied to new chats. The chosen SKU also selects the backend /// (`BackendID.forModel`), and each model exposes only the effort levels it supports. enum ModelCatalog { /// Full model SKUs the picker offers (not friendly aliases). The chosen SKU also selects /// the backend (`BackendID.forModel`): the Claude SKUs run on Claude Code, the `gpt-*` SKUs /// on Codex (`codex app-server`), and the `grok-*` SKUs on Grok over ACP (`grok agent stdio`). static let models: [String] = [ "claude-opus-5", "claude-fable-5", "claude-opus-4-8[1m]", "claude-opus-4-8", "claude-sonnet-5", "claude-sonnet-4-6", "claude-haiku-4-5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "grok-build", "opencode", "openclaw", "hermes", "cursor-agent", "acp-agent", ] /// All effort levels, ordered lowest → highest. A given model supports a *prefix* of /// these (see `efforts(for:)`); the picker should only ever offer the supported ones. static let efforts: [String] = ["low", "medium", "high", "xhigh", "max"] /// The single **"Auto"** reasoning mode used by the ACP wrapper agents (Grok/OpenCode/OpenClaw/ /// Hermes/Cursor): the agent picks the thinking depth itself, so the only API-level choice is `auto` — /// presented under the "Reasoning" noun (see `effortNoun`) and rendered "Auto" /// (`effortDisplayName`). The backend never passes a reasoning flag; the selection is cosmetic. /// (Orchestra is still offered below it in the menu — an orchestration mode, not a reasoning /// level.) static let autoEfforts: [String] = ["auto"] /// The backends whose agents expose only the single "Auto" reasoning (`autoEfforts`) — the ACP /// wrapper agents, which don't take a `reasoning_effort`-style flag. static let autoReasoningBackends: Set = [ .grok, .opencode, .openclaw, .hermes, .cursorAgent, .acp, ] /// The effort levels `sku` actually supports. The ACP wrapper agents expose only their single /// "Auto" reasoning (`autoEfforts`); Codex models top out at "xhigh" (confirmed from `codex /// model/list`: gpt-5.x supports low/medium/high/xhigh); Claude models support the full range /// incl. "max". static func efforts(for sku: String) -> [String] { if let backend = BackendID.forModel(sku), autoReasoningBackends.contains(backend) { return autoEfforts } guard let cap = effortCap(for: sku), let idx = efforts.firstIndex(of: cap) else { return efforts } return Array(efforts[...idx]) } /// Highest effort `sku` supports, or `nil` for the full range. private static func effortCap(for sku: String) -> String? { BackendID.forModel(sku) == .codex ? "xhigh" : nil } /// Clamp `effort` to what `sku` supports, so switching to a model with a lower cap can't /// leave an unsupported level selected (e.g. "max" carried onto a Codex model → "xhigh"). /// Orchestra is an orchestration mode, not an API level — valid for every model (it /// resolves to a supported effort, `xhigh`, on the host), so it's always preserved. static func clampedEffort(_ effort: String, for sku: String) -> String { if isOrchestra(effort) { return effort } let supported = efforts(for: sku) return supported.contains(effort) ? effort : (supported.last ?? fallbackEffort) } /// The reasoning control's noun in the composer: Codex and Grok call it "Reasoning" (both /// expose a `reasoning_effort`-style thinking level), Claude "Effort". static func effortNoun(for sku: String) -> String { switch BackendID.forModel(sku) { case .codex, .grok, .opencode, .openclaw, .hermes, .cursorAgent, .acp: return "Reasoning" default: return "Effort" } } /// The picker's SKUs split into per-provider runs, preserving `models` order, so the /// composer's model menu can set each provider apart with a divider (Claude, then GPT, /// then Grok). Providers are contiguous in `models`, so a new group starts whenever the /// inferred backend changes (`BackendID.forModel`). static var modelGroups: [[String]] { var groups: [[String]] = [] var current: [String] = [] var currentBackend: BackendID? for sku in models { let backend = BackendID.forModel(sku) ?? .claudeCode if backend != currentBackend, !current.isEmpty { groups.append(current) current = [] } currentBackend = backend current.append(sku) } if !current.isEmpty { groups.append(current) } return groups } /// The SKUs that run on `backend` — for the in-session model menu, where the backend is /// fixed at creation and can't change. (The home composer offers all SKUs, since the model /// chooses the backend.) static func models(for backend: BackendID) -> [String] { models.filter { sku in let modelBackend = BackendID.forModel(sku) ?? .claudeCode switch backend { case .claudeCode: return modelBackend == .claudeCode case .codex, .codexExec: return modelBackend == .codex case .grok: return modelBackend == .grok case .opencode: return modelBackend == .opencode case .openclaw: return modelBackend == .openclaw case .hermes: return modelBackend == .hermes case .cursorAgent: return modelBackend == .cursorAgent case .acp: return modelBackend == .acp } } } /// The "orchestra" orchestration mode (`OrchestrationMode`). It rides in the effort menu /// like a level, but it isn't an API effort — it pairs `xhigh` with standing consent to /// fan work out to parallel subagents. Kept out of `efforts` (the canonical API levels) so /// it can be presented separately, below a divider, with its own purple treatment. static let orchestraEffort = OrchestrationMode.effortSentinel /// Whether `effort` selects orchestra. static func isOrchestra(_ effort: String?) -> Bool { OrchestrationMode.isOrchestra(effort) } /// A pretty, human label for an effort level — orchestra reads "Orchestra"; Grok's single /// reasoning mode reads "Auto" (a named mode, like Orchestra, not a generic level); the plain /// API levels keep their lowercase word (matching how they're shown in the menu today). static func effortDisplayName(_ effort: String) -> String { if isOrchestra(effort) { return "Orchestra" } if effort == "auto" { return "Auto" } return effort } /// One-line description of what orchestra does, for the menu item's help tooltip. static let orchestraBlurb = "Orchestra: maximum-effort orchestration. Runs at xhigh and lets the agent fan work " + "out to parallel subagents on its own for thorough, verified results (uses more tokens)." /// Tooltip shown once Orchestra is on for a chat: it's a one-way latch and can't be turned off, /// so the other effort levels are disabled (the host enforces this in `SessionController`). static let orchestraLockedHelp = "Orchestra is on for this chat and can't be turned off — start a new chat to use a " + "different effort level." /// Tooltip shown on the Orchestra row for a chat that isn't already in Orchestra: the mode is /// fixed when a chat is created (that's when the supervisor model is chosen), so it can't be /// switched on partway through — you pick it in the new-chat composer instead. static let orchestraStartOnlyHelp = "Orchestra can only be turned on when you start a chat — pick it in the new-chat composer." /// Why Orchestra may be unavailable: it's a Nucleic Control capability (the hardened, /// sandboxed path), so it's only offered for projects under Nucleic Control. Shown as the /// grayed annotation on the disabled menu item and as its tooltip. static let orchestraRequiresControlNote = "Requires Nucleic Control" /// Full explanation for the requirement tooltip. static let orchestraRequiresControlHelp = "Orchestra is a Nucleic Control capability — clone or move this project under Nucleic " + "Control to enable it." /// A pretty, human label for a model SKU (e.g. "claude-sonnet-4-6" → "Sonnet /// 4.6"). The two Opus 4.8 SKUs share the name "Opus 4.8"; their context window /// is conveyed by the picker badge (see `contextBadge`). Falls back to a /// best-effort prettifier for unknown SKUs. static func displayName(_ sku: String) -> String { switch sku { case "claude-opus-5": return "Opus 5" case "claude-fable-5": return "Fable 5" case "claude-opus-4-8[1m]": return "Opus 4.8" case "claude-opus-4-8": return "Opus 4.8" case "claude-sonnet-5": return "Sonnet 5" case "claude-sonnet-4-6": return "Sonnet 4.6" case "claude-haiku-4-5": return "Haiku 4.5" case "gpt-5.6-terra": return "GPT-5.6 Terra" case "gpt-5.6-luna": return "GPT-5.6 Luna" case "gpt-5.6-sol": return "GPT-5.6 Sol" case "gpt-5.5": return "GPT-5.5" case "gpt-5.4": return "GPT-5.4" case "gpt-5.4-mini": return "GPT-5.4 Mini" case "grok-build": return "Grok Build" case "opencode": return "OpenCode" case "openclaw": return "OpenClaw" case "hermes": return "Hermes" case "cursor-agent": return "Cursor Agent" case "acp-agent": return "Custom ACP" default: return prettify(sku) } } private static func prettify(_ sku: String) -> String { var stem = sku if stem.hasPrefix("claude-") { stem.removeFirst("claude-".count) } var suffix = "" if let open = stem.firstIndex(of: "["), let close = stem.firstIndex(of: "]") { suffix = " (\(stem[stem.index(after: open).. String? { switch sku { case "claude-opus-5": return "gpt-5.5" case "claude-fable-5": return "gpt-5.5" case "claude-opus-4-8[1m]", "claude-opus-4-8": return "gpt-5.5" case "claude-sonnet-5", "claude-sonnet-4-6": return "gpt-5.4" case "claude-haiku-4-5": return "gpt-5.4-mini" case "gpt-5.5": return "claude-opus-4-8[1m]" case "gpt-5.4": return "claude-sonnet-5" case "gpt-5.4-mini": return "claude-haiku-4-5" default: return nil } } /// Approximate input context-window size (tokens) for a model SKU, used by the /// composer's session context-usage indicator. The `[1m]` variant gets the 1M /// window; standard Opus 4.8 gets 256K; every other Claude model uses 200K. static func contextWindow(for sku: String) -> Int { if sku.contains("[1m]") { return 1_000_000 } if sku.hasPrefix("claude-fable") { return 1_000_000 } // Fable 5: 1M (max and default) if sku.hasPrefix("claude-sonnet-5") { return 1_000_000 } // Sonnet 5: 1M if sku.hasPrefix("claude-opus-5") { return 1_000_000 } // Opus 5: 1M if sku.hasPrefix("claude-opus-4-8") { return 256_000 } if sku.hasPrefix("gpt-5") { return 350_000 } // codex gpt-5.x window (~353K observed) if sku.hasPrefix("grok") { return 256_000 } // grok-build window (inferred) // ACP wrapper agents are model-agnostic; use a conservative default window for the // composer's usage indicator until a live capture pins each one. if sku.hasPrefix("opencode") || sku.hasPrefix("openclaw") || sku.hasPrefix("hermes") || sku.hasPrefix("cursor") || sku == "acp-agent" { return 200_000 } return 200_000 } /// Short, grayed annotation shown beside a SKU in the model picker to call out /// its context window — "1M" for Opus 5 and the `[1m]` Opus 4.8, "256K" for the /// standard Opus 4.8 — so the flagship models' windows are legible at a glance and /// the two same-named Opus 4.8 entries are distinguishable. Nil for other models. static func contextBadge(for sku: String) -> String? { switch sku { case "claude-opus-5": return "1M" case "claude-opus-4-8[1m]": return "1M" case "claude-opus-4-8": return "256K" default: return nil } } static let defaultModelKey = "nucleic.defaultModel" static let defaultEffortKey = "nucleic.defaultEffort" static let defaultSupervisorModelKey = "nucleic.defaultSupervisorModel" static let defaultOrchestraWorkerModelKey = "nucleic.defaultOrchestraWorkerModel" static let defaultOrchestraMaxWorkersKey = "nucleic.defaultOrchestraMaxConcurrentWorkers" static let defaultAutoKey = "nucleic.defaultAuto" static let defaultAutoShipKey = "nucleic.defaultAutoShip" static var storedDefaultModel: String { UserDefaults.standard.string(forKey: defaultModelKey) ?? fallbackModel } static var storedDefaultEffort: String { UserDefaults.standard.string(forKey: defaultEffortKey) ?? fallbackEffort } static var storedDefaultSupervisorModel: String { UserDefaults.standard.string(forKey: defaultSupervisorModelKey) ?? fallbackSupervisorModel } static var storedDefaultOrchestraWorkerModel: String { UserDefaults.standard.string(forKey: defaultOrchestraWorkerModelKey) ?? fallbackOrchestraWorkerModel } /// The stored concurrent-worker cap, or the fallback when unset. `object(forKey:)` (not /// `integer(forKey:)`) so an explicit `0` (Unlimited) is preserved rather than read as unset. static var storedDefaultOrchestraMaxWorkers: Int { UserDefaults.standard.object(forKey: defaultOrchestraMaxWorkersKey) as? Int ?? fallbackOrchestraMaxWorkers } static var storedDefaultAuto: Bool { UserDefaults.standard.bool(forKey: defaultAutoKey) } static var storedDefaultAutoShip: Bool { UserDefaults.standard.bool(forKey: defaultAutoShipKey) } /// The default `(model, effort)` a new chat should use given which provider backends are /// **connected** (installed *and* authenticated — see `ProviderAvailability`). Honors the /// stored default when its backend is connected, so an explicit choice still wins; otherwise /// falls through `providerDefaults` (Claude → Codex → Grok) to the highest-priority connected /// provider, so the default is never a model whose backend the user can't reach. With nothing /// connected, keeps the stored/`fallback` default so the picker always has *a* selection. static func resolvedDefault(connected: Set) -> (model: String, effort: String) { let storedModel = storedDefaultModel if let backend = BackendID.forModel(storedModel), connected.contains(backend) { return (storedModel, clampedEffort(storedDefaultEffort, for: storedModel)) } if let pick = providerDefaults.first(where: { connected.contains($0.backend) }) { return (pick.model, pick.effort) } return (storedModel, storedDefaultEffort) } /// The wire projection sent to paired phones (SYNC §5.2) so their composer/header pickers /// mirror this catalog — same per-backend effort caps, context windows, and grouping — /// without re-deriving any backend rules on-device. Built from this single source of truth. static var wireCatalog: WireModelCatalog { let groups: [[WireModelCatalog.Model]] = modelGroups.map { group in group.map { sku in WireModelCatalog.Model( sku: sku, displayName: displayName(sku), backend: BackendID.forModel(sku) ?? .claudeCode, contextBadge: contextBadge(for: sku), contextWindow: contextWindow(for: sku), efforts: efforts(for: sku), effortNoun: effortNoun(for: sku)) } } // Pretty labels only for the non-identity effort levels (e.g. "auto" → "Auto") plus the // orchestra sentinel; the phone shows the raw level for everything else. var effortNames: [String: String] = [:] for sku in models { for level in efforts(for: sku) where effortDisplayName(level) != level { effortNames[level] = effortDisplayName(level) } } effortNames[orchestraEffort] = effortDisplayName(orchestraEffort) // "Orchestra" return WireModelCatalog( groups: groups, effortDisplayNames: effortNames, orchestraSentinel: orchestraEffort, orchestraRequiresControlNote: orchestraRequiresControlNote, fallbackModel: fallbackModel, fallbackEffort: fallbackEffort) } } /// How long a *completed* chat may sit untouched before Nucleic auto-archives it. /// Persisted as the raw seconds value; `.never` (0) turns auto-archiving off. The /// `AppStore` only ever sees the resolved `interval`, keeping the core unaware of the /// UI's specific choices. enum AutoArchivePolicy: Int, CaseIterable, Identifiable { case never = 0 case after30Minutes = 1800 case afterHour = 3600 case after3Hours = 10800 case afterDay = 86_400 case afterWeek = 604_800 var id: Int { rawValue } var label: String { switch self { case .never: "Never" case .after30Minutes: "After 30 minutes" case .afterHour: "After 1 hour" case .after3Hours: "After 3 hours" case .afterDay: "After 1 day" case .afterWeek: "After 1 week" } } /// The bare duration phrase used in explanatory copy ("30 minutes", "a week"). var durationPhrase: String { switch self { case .never: "" case .after30Minutes: "30 minutes" case .afterHour: "an hour" case .after3Hours: "3 hours" case .afterDay: "a day" case .afterWeek: "a week" } } /// Idle threshold in seconds the `AppStore` sweeps against, or nil to disable. var interval: TimeInterval? { self == .never ? nil : TimeInterval(rawValue) } static let storageKey = "nucleic.autoArchivePolicy" /// Out of the box, completed chats archive after a day; the user can shorten it, /// lengthen it, or pick "Never" in Settings. static let fallback = AutoArchivePolicy.afterDay static var stored: AutoArchivePolicy { // No stored key yet → the default behavior; `.never` is a real stored 0. guard UserDefaults.standard.object(forKey: storageKey) != nil else { return fallback } return AutoArchivePolicy(rawValue: UserDefaults.standard.integer(forKey: storageKey)) ?? fallback } } /// How long a chat may stay *archived* before Nucleic reclaims its worktree checkout to free /// disk. The branch — and any uncommitted work, committed as a WIP commit first — is kept, so /// unarchiving the chat re-creates the worktree exactly. Persisted as the raw seconds value; /// `.never` (0) turns cleanup off. Like `AutoArchivePolicy`, the `AppStore` only ever sees the /// resolved `interval`, keeping the core unaware of the UI's specific choices. enum ArchivedWorktreeCleanupPolicy: Int, CaseIterable, Identifiable { case never = 0 case afterHour = 3600 case afterDay = 86_400 case after3Days = 259_200 case afterWeek = 604_800 case afterMonth = 2_592_000 var id: Int { rawValue } var label: String { switch self { case .never: "Never" case .afterHour: "After 1 hour" case .afterDay: "After 1 day" case .after3Days: "After 3 days" case .afterWeek: "After 1 week" case .afterMonth: "After 1 month" } } /// The bare duration phrase used in explanatory copy ("an hour", "a month"). var durationPhrase: String { switch self { case .never: "" case .afterHour: "an hour" case .afterDay: "a day" case .after3Days: "3 days" case .afterWeek: "a week" case .afterMonth: "a month" } } /// Threshold in seconds the `AppStore` sweeps against, or nil to disable. var interval: TimeInterval? { self == .never ? nil : TimeInterval(rawValue) } static let storageKey = "nucleic.archivedWorktreeCleanupPolicy" /// Out of the box, an archived chat's worktree is reclaimed after a day; the user can /// shorten it, lengthen it, or pick "Never" in Settings. static let fallback = ArchivedWorktreeCleanupPolicy.afterDay static var stored: ArchivedWorktreeCleanupPolicy { // No stored key yet → the default behavior; `.never` is a real stored 0. guard UserDefaults.standard.object(forKey: storageKey) != nil else { return fallback } return ArchivedWorktreeCleanupPolicy( rawValue: UserDefaults.standard.integer(forKey: storageKey)) ?? fallback } }