691 lines
34 KiB
Swift
691 lines
34 KiB
Swift
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 the first-class `grok -p` backend.
|
||
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-4.5",
|
||
"grok-build",
|
||
"opencode",
|
||
"openclaw",
|
||
"hermes",
|
||
"cursor-agent",
|
||
"acp-agent",
|
||
]
|
||
/// Ordinary effort levels, ordered lowest → highest. A given model supports a *prefix* of
|
||
/// these (see `efforts(for:codexPro:)`); the picker should only ever offer supported ones.
|
||
/// The capability rules themselves live in Core's `EffortLadder` (shared with the
|
||
/// Intelligence router and its tests); these wrappers keep the catalog the app-side face.
|
||
static let efforts: [String] = EffortLadder.efforts
|
||
/// Codex's wire value for the Pro-only GPT-5.6 Sol mode. The product surface calls this
|
||
/// reasoning level “Pro”; Codex names the underlying supported effort `ultra`.
|
||
static let proEffort = EffortLadder.proEffort
|
||
|
||
/// The single **"Auto"** reasoning mode used by the ACP wrapper agents (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] = EffortLadder.autoEfforts
|
||
|
||
/// 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<BackendID> = EffortLadder.autoReasoningBackends
|
||
|
||
/// The effort levels `sku` actually supports for this account (see `EffortLadder`).
|
||
static func efforts(for sku: String, codexPro: Bool = false) -> [String] {
|
||
return EffortLadder.efforts(for: sku, codexPro: codexPro)
|
||
}
|
||
|
||
/// Clamp `effort` to what `sku` supports (see `EffortLadder.clampedEffort`).
|
||
static func clampedEffort(_ effort: String, for sku: String, codexPro: Bool = false) -> String {
|
||
return EffortLadder.clampedEffort(effort, for: sku, codexPro: codexPro)
|
||
}
|
||
|
||
/// 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. Wire values are SKU-shaped ("xhigh", "ultra")
|
||
/// rather than words a user would recognize, so every one of them is spelled out here — never
|
||
/// title-cased from the raw string — and the mapping is the single source both this app and
|
||
/// the synced phone catalog (`wireCatalog`) read from, so a level reads the same word no
|
||
/// matter which provider's model produced it.
|
||
static func effortDisplayName(_ effort: String) -> String {
|
||
if isOrchestra(effort) { return "Orchestra" }
|
||
switch effort {
|
||
case "auto": return "Auto"
|
||
case proEffort: return "Pro"
|
||
case "low": return "Low"
|
||
case "medium": return "Medium"
|
||
case "high": return "High"
|
||
case "xhigh": return "Extra"
|
||
case "max": return "Max"
|
||
default: return effort.localizedCapitalized
|
||
}
|
||
}
|
||
|
||
/// 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)."
|
||
|
||
/// 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-4.5": return "Grok 4.5"
|
||
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)..<close].uppercased()))" // [1m] → (1M)
|
||
stem = String(stem[..<open])
|
||
}
|
||
let parts = stem.split(separator: "-")
|
||
guard let family = parts.first else { return sku }
|
||
let familyName = family.prefix(1).uppercased() + family.dropFirst()
|
||
let version = parts.dropFirst().joined(separator: ".")
|
||
return version.isEmpty ? "\(familyName)\(suffix)" : "\(familyName) \(version)\(suffix)"
|
||
}
|
||
|
||
static let fallbackModel = "claude-opus-5"
|
||
static let fallbackEffort = EffortLadder.fallbackEffort
|
||
|
||
// MARK: - Orchestra supervisor & worker selection
|
||
|
||
/// One "Supervisor model" choice: a model paired with the effort it conducts at. Orchestra used
|
||
/// to pin every supervisor to `xhigh`; the two levels make that selectable without adding a
|
||
/// second control, since the only supervisor efforts worth offering are the top two.
|
||
///
|
||
/// The list is deliberately short. A supervisor plans, delegates, and synthesizes — long-horizon
|
||
/// reasoning work — so it is offered only the two models that lead that lane (Fable 5 on the
|
||
/// Claude side, GPT-5.6 Sol on the GPT side), one per provider, rather than the whole catalog.
|
||
struct SupervisorChoice: Hashable, Identifiable {
|
||
let model: String
|
||
/// The API effort this choice conducts at — `xhigh` for Deep, `max` for Max.
|
||
let effort: String
|
||
/// The level's product name, shown in parentheses after the model.
|
||
let levelName: String
|
||
|
||
var id: String { "\(model)|\(effort)" }
|
||
var displayName: String { "\(ModelCatalog.displayName(model)) (\(levelName))" }
|
||
}
|
||
|
||
/// The supervisor choices, Claude lane first (matching `models` order).
|
||
static let supervisorChoices: [SupervisorChoice] = [
|
||
SupervisorChoice(model: "claude-fable-5", effort: "xhigh", levelName: "Deep"),
|
||
SupervisorChoice(model: "claude-fable-5", effort: "max", levelName: "Max"),
|
||
SupervisorChoice(model: "gpt-5.6-sol", effort: "xhigh", levelName: "Deep"),
|
||
SupervisorChoice(model: "gpt-5.6-sol", effort: "max", levelName: "Max"),
|
||
]
|
||
|
||
/// Fable 5 at `xhigh` — the model Orchestra already defaulted to, at the effort it already ran,
|
||
/// so an untouched install behaves exactly as before.
|
||
static let fallbackSupervisorChoice = supervisorChoices[0]
|
||
static let fallbackSupervisorModel = fallbackSupervisorChoice.model
|
||
static let fallbackSupervisorEffort = fallbackSupervisorChoice.effort
|
||
|
||
/// The choice matching a stored (model, effort) pair. Falls back to the default for anything
|
||
/// unrecognized — including a model stored before the list was shortened (an install that had
|
||
/// picked Opus 5 as its supervisor lands on Fable 5 (Deep) rather than on an empty picker).
|
||
static func supervisorChoice(model: String?, effort: String?) -> SupervisorChoice {
|
||
if let exact = supervisorChoices.first(where: { $0.model == model && $0.effort == effort }) {
|
||
return exact
|
||
}
|
||
// A known model whose stored effort is no longer offered keeps the model, at Deep.
|
||
if let sameModel = supervisorChoices.first(where: { $0.model == model }) { return sameModel }
|
||
return fallbackSupervisorChoice
|
||
}
|
||
|
||
/// The **Intelligent** worker option: classify each worker's task and let the Intelligence
|
||
/// router pick the model for it, rather than pinning one SKU for the whole fan-out. Stored as
|
||
/// `OrchestrationMode.intelligentWorkerModel`, which is not a real SKU and is resolved to a
|
||
/// concrete model at spawn (`AppStore.orchestraWorkerSelection`).
|
||
static let intelligentWorkerModel = OrchestrationMode.intelligentWorkerModel
|
||
|
||
/// Named for the routing it does, not for "the app picks" — "Auto" is already taken by the
|
||
/// single reasoning mode the ACP agents expose (see `effortDisplayName`) and by Auto-approve.
|
||
static let intelligentWorkerDisplayName = "Intelligent"
|
||
|
||
static let intelligentWorkerBlurb =
|
||
"Classifies each worker's task and routes it to the model that suits it, so a quick fix "
|
||
+ "runs cheap and a hard subtask runs strong."
|
||
|
||
/// Workers default to Intelligent: a fan-out's subtasks vary far more in difficulty than one
|
||
/// chat's turns do, so per-task routing is a better default than any single pinned SKU.
|
||
static let fallbackOrchestraWorkerModel = intelligentWorkerModel
|
||
/// How many Orchestra workers the supervisor may run at once by default. The fan-out
|
||
/// multiplies token usage, so this bounds a single supervisor's concurrent workers rather
|
||
/// than letting an over-eager fan-out spawn an unlimited wave. `0` means "unlimited".
|
||
static let fallbackOrchestraMaxWorkers = 4
|
||
/// The discrete choices offered in Settings for the concurrent-worker cap; `0` = Unlimited.
|
||
static let orchestraMaxWorkerChoices = [0, 2, 3, 4, 6, 8, 12, 16]
|
||
|
||
/// Per-provider default model + effort, **highest priority first**, used to pick a new-chat
|
||
/// default the user can actually run. The first provider the user has connected wins: Claude
|
||
/// → Opus 5 at high effort, then Codex → GPT-5.5 at medium, then Grok → Grok Build at
|
||
/// "auto" (its only reasoning mode). Consulted by `resolvedDefault` when the stored default's
|
||
/// backend isn't connected — so the default is never a model on a provider the user lacks.
|
||
static let providerDefaults: [(backend: BackendID, model: String, effort: String)] = [
|
||
(.claudeCode, "claude-opus-5", "high"),
|
||
(.codex, "gpt-5.5", "medium"),
|
||
(.grok, "grok-build", "high"),
|
||
(.opencode, "opencode", "auto"),
|
||
(.openclaw, "openclaw", "auto"),
|
||
(.hermes, "hermes", "auto"),
|
||
(.cursorAgent, "cursor-agent", "auto"),
|
||
(.acp, "acp-agent", "auto"),
|
||
]
|
||
|
||
/// A comparable model on the *other* provider, for status-driven failover: each SKU pairs
|
||
/// with its nearest-tier counterpart across the Claude ↔ GPT line, so when one provider
|
||
/// reports an incident the app can offer a like-for-like default on the other (Opus ↔
|
||
/// GPT-5.5, Sonnet ↔ GPT-5.4, Haiku ↔ GPT-5.4 Mini). Nil for an unrecognized SKU.
|
||
static func crossProviderAlternative(for sku: String) -> 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"
|
||
/// The supervisor's effort — the "(Deep)" / "(Max)" half of the supervisor choice. Kept as its
|
||
/// own key rather than folded into the model string so both halves stay independently readable.
|
||
static let defaultSupervisorEffortKey = "nucleic.defaultSupervisorEffort"
|
||
static let defaultOrchestraWorkerModelKey = "nucleic.defaultOrchestraWorkerModel"
|
||
static let defaultOrchestraMaxWorkersKey = "nucleic.defaultOrchestraMaxConcurrentWorkers"
|
||
static let defaultAutoKey = "nucleic.defaultAuto"
|
||
static let defaultAutoShipKey = "nucleic.defaultAutoShip"
|
||
/// Whether the composers show the Intelligence slider (purpose-routed model/effort)
|
||
/// instead of the manual Model + Effort menus. On by default; the Settings toggle
|
||
/// restores manual selection.
|
||
static let intelligenceSliderEnabledKey = "nucleic.intelligenceSliderEnabled"
|
||
/// Whether Intelligence-routed chats may offer an in-place handover when their work drifts
|
||
/// to a purpose whose routing lane uses a different model. Dark-shipped off by default.
|
||
static let contextSwitchEnabledKey = "nucleic.contextSwitchEnabled"
|
||
/// The Intelligence level new chats start on (raw `IntelligenceLevel` 1–5).
|
||
static let defaultIntelligenceLevelKey = "nucleic.defaultIntelligenceLevel"
|
||
/// Restrict Intelligence routing to a single provider (`BackendID` raw value; "" = any
|
||
/// connected provider). Only providers with routing-matrix presence are offered.
|
||
static let intelligencePinnedProviderKey = "nucleic.intelligencePinnedProvider"
|
||
|
||
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 storedDefaultSupervisorEffort: String {
|
||
UserDefaults.standard.string(forKey: defaultSupervisorEffortKey) ?? fallbackSupervisorEffort
|
||
}
|
||
/// The stored supervisor choice, normalized through ``supervisorChoice(model:effort:)`` so a
|
||
/// value from before the list was shortened resolves to a real entry.
|
||
static var storedSupervisorChoice: SupervisorChoice {
|
||
supervisorChoice(
|
||
model: storedDefaultSupervisorModel, effort: storedDefaultSupervisorEffort)
|
||
}
|
||
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
|
||
}
|
||
/// On unless explicitly turned off — `object(forKey:)` (not `bool`) so a missing key
|
||
/// reads as the enabled default rather than false.
|
||
static var storedIntelligenceSliderEnabled: Bool {
|
||
UserDefaults.standard.object(forKey: intelligenceSliderEnabledKey) as? Bool ?? true
|
||
}
|
||
static var storedContextSwitchEnabled: Bool {
|
||
UserDefaults.standard.object(forKey: contextSwitchEnabledKey) as? Bool ?? false
|
||
}
|
||
static var storedDefaultIntelligenceLevel: IntelligenceLevel {
|
||
IntelligenceLevel(rawValue: UserDefaults.standard.integer(forKey: defaultIntelligenceLevelKey))
|
||
?? .fallback
|
||
}
|
||
/// The pinned routing provider, or nil for "any connected".
|
||
static var storedIntelligencePinnedProvider: BackendID? {
|
||
guard let raw = UserDefaults.standard.string(forKey: intelligencePinnedProviderKey),
|
||
!raw.isEmpty
|
||
else { return nil }
|
||
return BackendID(rawValue: raw)
|
||
}
|
||
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<BackendID>, codexPro: Bool = false
|
||
) -> (model: String, effort: String) {
|
||
let storedModel = storedDefaultModel
|
||
if let backend = BackendID.forModel(storedModel), connected.contains(backend) {
|
||
return (
|
||
storedModel,
|
||
clampedEffort(storedDefaultEffort, for: storedModel, codexPro: codexPro))
|
||
}
|
||
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 func wireCatalog(codexPro: Bool) -> 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, codexPro: codexPro),
|
||
effortNoun: effortNoun(for: sku))
|
||
}
|
||
}
|
||
// Pretty labels for every effort level (e.g. "xhigh" → "Extra") plus the orchestra
|
||
// sentinel, so the phone never falls back to showing a raw wire value.
|
||
var effortNames: [String: String] = [:]
|
||
for sku in models {
|
||
for level in efforts(for: sku, codexPro: codexPro)
|
||
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 **Done** chat may stay *archived* before Nucleic reclaims its worktree checkout
|
||
/// to free disk. Only chats whose work finished (the sidebar's "Done") are reclaimed — one
|
||
/// archived mid-conversation (asking a question, errored, interrupted) keeps its checkout. 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
|
||
}
|
||
}
|
||
|
||
/// How long a chat may sit idle before its *session storage* — the transcript and agent
|
||
/// state under `Application Support/Nucleic/sessions/<id>` — is compressed on disk. At
|
||
/// hundreds-to-thousands of sessions that storage is what balloons, and a Mac in Carbon's
|
||
/// "All Copies" role is the one carrying the mesh's full storage burden, so compression is
|
||
/// applied only there (the Settings pane resolves the Carbon storage mode before pushing an
|
||
/// interval to `AppStore.sessionStorageCompressionInterval`; Optimized-mode Macs push nil).
|
||
/// Opening, resuming, or transferring a compressed chat unpacks it transparently. Persisted
|
||
/// as the raw seconds value; `.never` (0) turns compression off. Like its sibling policies,
|
||
/// the `AppStore` only ever sees the resolved `interval`.
|
||
enum SessionStorageCompressionPolicy: Int, CaseIterable, Identifiable {
|
||
case never = 0
|
||
case afterDay = 86_400
|
||
case after3Days = 259_200
|
||
case afterWeek = 604_800
|
||
case after2Weeks = 1_209_600
|
||
case afterMonth = 2_592_000
|
||
|
||
var id: Int { rawValue }
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .never: "Never"
|
||
case .afterDay: "After 1 day"
|
||
case .after3Days: "After 3 days"
|
||
case .afterWeek: "After 7 days"
|
||
case .after2Weeks: "After 14 days"
|
||
case .afterMonth: "After 1 month"
|
||
}
|
||
}
|
||
|
||
/// The bare duration phrase used in explanatory copy ("a day", "7 days").
|
||
var durationPhrase: String {
|
||
switch self {
|
||
case .never: ""
|
||
case .afterDay: "a day"
|
||
case .after3Days: "3 days"
|
||
case .afterWeek: "7 days"
|
||
case .after2Weeks: "14 days"
|
||
case .afterMonth: "a month"
|
||
}
|
||
}
|
||
|
||
/// Idle threshold in seconds the `AppStore` sweeps against, or nil to disable.
|
||
var interval: TimeInterval? { self == .never ? nil : TimeInterval(rawValue) }
|
||
|
||
static let storageKey = "nucleic.sessionStorageCompressionPolicy"
|
||
/// Out of the box, an idle chat's session storage compresses after 7 days (on Macs in
|
||
/// Carbon's "All Copies" role); the user can shorten it, lengthen it, or pick "Never".
|
||
static let fallback = SessionStorageCompressionPolicy.afterWeek
|
||
|
||
static var stored: SessionStorageCompressionPolicy {
|
||
// No stored key yet → the default behavior; `.never` is a real stored 0.
|
||
guard UserDefaults.standard.object(forKey: storageKey) != nil else { return fallback }
|
||
return SessionStorageCompressionPolicy(
|
||
rawValue: UserDefaults.standard.integer(forKey: storageKey)) ?? fallback
|
||
}
|
||
|
||
/// The interval the app pushes to `AppStore.sessionStorageCompressionInterval`, resolving
|
||
/// the Carbon storage-mode gate: only a Mac holding **All Copies** compresses (it bears the
|
||
/// mesh's full storage burden), so an Optimized-mode Mac always resolves nil. One place, so
|
||
/// launch (`NucleicApp`) and the Settings pane's `.onChange` pushes can't drift.
|
||
static func resolvedInterval(mode: CarbonStorageMode) -> TimeInterval? {
|
||
mode == .all ? stored.interval : nil
|
||
}
|
||
}
|
||
|
||
/// How long a chat may stay *archived* before Nucleic reclaims its session data outright,
|
||
/// bounding disk use across thousands of accumulated chats. Unlike `ArchivedWorktreeCleanupPolicy`
|
||
/// (which only reclaims the git checkout and always keeps the branch) this is a destructive
|
||
/// action by default: with Carbon set to "Optimized" storage the chat, its branch, and its
|
||
/// transcript are deleted outright; with Carbon set to "All Copies" (full-storage) the chat's
|
||
/// storage is compressed in place instead (see `AppStore.sweepExpiredSessionData`) — nothing is
|
||
/// deleted, since full-storage means the user wants everything kept and recoverable. `.never`
|
||
/// (0) turns this off; that's the fallback, since reclaiming worktrees is already
|
||
/// non-destructive and safe to default on, but discarding chat data must be an explicit opt-in.
|
||
enum SessionDataRetentionPolicy: Int, CaseIterable, Identifiable {
|
||
case never = 0
|
||
case afterMonth = 2_592_000
|
||
case after3Months = 7_776_000
|
||
case after6Months = 15_552_000
|
||
case afterYear = 31_536_000
|
||
|
||
var id: Int { rawValue }
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .never: "Never"
|
||
case .afterMonth: "After 1 month"
|
||
case .after3Months: "After 3 months"
|
||
case .after6Months: "After 6 months"
|
||
case .afterYear: "After 1 year"
|
||
}
|
||
}
|
||
|
||
/// Threshold in seconds the `AppStore` sweeps against, or nil to disable.
|
||
var interval: TimeInterval? { self == .never ? nil : TimeInterval(rawValue) }
|
||
|
||
static let storageKey = "nucleic.sessionDataRetentionPolicy"
|
||
/// Off by default — reclaiming chat data (deleting it, or even just compressing it) is an
|
||
/// opt-in choice the user makes in Settings, not a silent background default.
|
||
static let fallback = SessionDataRetentionPolicy.never
|
||
|
||
static var stored: SessionDataRetentionPolicy {
|
||
guard UserDefaults.standard.object(forKey: storageKey) != nil else { return fallback }
|
||
return SessionDataRetentionPolicy(
|
||
rawValue: UserDefaults.standard.integer(forKey: storageKey)) ?? fallback
|
||
}
|
||
}
|