Orchestra already worked end-to-end on Codex (resolvedEffort maps it to xhigh, which Codex accepts, and CodexAppServerBackend forwards appendSystemPrompt as developerInstructions); it was only held back by a deliberate Claude-only UI gate. Drops supportsOrchestra and the menu gating so the mode is offered for every model, and simplifies clampedEffort to always preserve Orchestra (it's an orchestration mode, not an API level). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
270 lines
12 KiB
Swift
270 lines
12 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`).
|
|
static let models: [String] = [
|
|
"claude-opus-4-8[1m]",
|
|
"claude-opus-4-8",
|
|
"claude-sonnet-4-6",
|
|
"claude-haiku-4-5",
|
|
"gpt-5.5",
|
|
"gpt-5.4",
|
|
"gpt-5.4-mini",
|
|
]
|
|
/// 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 effort levels `sku` actually supports, a prefix of `efforts`. 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 including "max".
|
|
static func efforts(for sku: String) -> [String] {
|
|
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 calls it "Reasoning", Claude "Effort".
|
|
static func effortNoun(for sku: String) -> String {
|
|
BackendID.forModel(sku) == .codex ? "Reasoning" : "Effort"
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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"; the plain API
|
|
/// levels keep their lowercase word (matching how they're shown in the menu today).
|
|
static func effortDisplayName(_ effort: String) -> String {
|
|
isOrchestra(effort) ? "Orchestra" : 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)."
|
|
|
|
/// 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-4-8[1m]": return "Opus 4.8"
|
|
case "claude-opus-4-8": return "Opus 4.8"
|
|
case "claude-sonnet-4-6": return "Sonnet 4.6"
|
|
case "claude-haiku-4-5": return "Haiku 4.5"
|
|
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"
|
|
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-4-8[1m]"
|
|
static let fallbackEffort = "high"
|
|
|
|
/// 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-opus-4-8") { return 256_000 }
|
|
if sku.hasPrefix("gpt-5") { return 350_000 } // codex gpt-5.x window (~353K observed)
|
|
return 200_000
|
|
}
|
|
|
|
/// Short, grayed annotation shown beside a SKU in the model picker to call out
|
|
/// its context window — "1M" for the `[1m]` Opus 4.8 and "256K" for the
|
|
/// standard one — so the two same-named Opus entries are distinguishable. Nil
|
|
/// for other models.
|
|
static func contextBadge(for sku: String) -> String? {
|
|
switch sku {
|
|
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 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 storedDefaultAuto: Bool {
|
|
UserDefaults.standard.bool(forKey: defaultAutoKey)
|
|
}
|
|
static var storedDefaultAutoShip: Bool {
|
|
UserDefaults.standard.bool(forKey: defaultAutoShipKey)
|
|
}
|
|
}
|
|
|
|
|
|
/// 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
|
|
}
|
|
}
|