At hundreds-to-thousands of sessions Nucleic's storage balloons into the hundreds of gigabytes across worktrees and per-session storage. Two changes: 1. "Delete archived chat worktrees" now reclaims only chats whose work is Done (a finished run, or a last turn classified/marked .completed) — a chat archived mid-conversation keeps its checkout. Moved-away tombstones remain reclaimable regardless: their work lives on another Mac, and the transfer's own discard is best-effort. The Done predicate is the new Session.isCompleted, shared with isChatDone so the definitions can't drift, and the Settings ▸ Chats picker documents the narrowed behavior. 2. Idle-chat session-storage compression for Macs in Carbon's "All Copies" role — the machines carrying the mesh's full storage burden. A new SessionStorageArchiver packs sessions/<id>/ (transcript, render sidecars, agent home) into a sibling <id>.tar.gz via the system tar (~10x on this JSONL-heavy data), with a partial-then-rename protocol and a "directory wins over any archive beside it" invariant so every crash point degrades safely. A sweep on the auto-archive loop packs at most 4 chats per pass, and only ones with no live controller, no hydration in flight or queued, not open, not pinned, not transferring, and idle past the threshold. Restore is transparent at every read funnel: prepareSession (open/hydration/dashboard repair), the open-chat preview streamer, and the mesh transcript server. The idle window is a new Settings ▸ Carbon dropdown (default: after 7 days), resolved against the storage role at launch and on change; Optimized-role Macs and headless hosts stay off. Co-Authored-By: Claude Fable 5 <[email protected]>
640 lines
32 KiB
Swift
640 lines
32 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 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",
|
||
]
|
||
/// 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 (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] = 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] {
|
||
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 {
|
||
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-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", "auto"),
|
||
(.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"
|
||
/// 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 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
|
||
}
|
||
}
|