Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Models/RemoteIntelligence.swift
T

179 lines
9.8 KiB
Swift

import Foundation
import NucleicProtocol
/// The Intelligence rail's state and preview on the phone (SYNC_PROTOCOL §5.2).
///
/// The division of labor with the Mac is deliberate and worth stating once here, because it's
/// what makes a rail on a thin remote client honest:
///
/// - **The host routes.** The matrix, the connected providers, the Settings pin, live quota and
/// provider incidents live on the Mac. A send carries the rail's *stop*, never a concrete pair
/// (`StartChatRequest.intelligence`, `ClientMsg.setSessionIntelligence`), and the host resolves
/// it with its own classifier. So the phone can never pin a worse model than the Mac would.
/// - **The phone previews.** It runs the shared `HeuristicPurposeClassifier` — the same code the
/// host runs, moved into NucleicProtocol precisely so both sides classify identically — over
/// the draft, and looks the answer up in the host's projected route table. Sub-millisecond, no
/// round trip, so the route line can follow every keystroke on a cellular link.
/// - **The preview can be wrong, and says so by being a preview.** The host's send-time
/// classification runs a fuller stack; when the two disagree, the transcript's routing note
/// records what actually ran. That's the same relationship the Mac's own pre-send preview has
/// with its send-time resolve.
extension RemoteStore {
/// Whether the context host offers the rail at all. False keeps the composers on their manual
/// model/effort pickers — an older host, a host with routing switched off in Settings, or one
/// we haven't finished handshaking with.
var routesIntelligence: Bool {
capabilities.canRouteIntelligence && intelligenceCatalog.isRoutable
}
/// The rail's opening stop for a fresh composer: the host's own default, so the phone and the
/// Mac open on the same rung.
var defaultIntelligenceLevel: Int { intelligenceCatalog.defaultLevel }
/// Classify a draft and resolve what the host would run for it at `level`. `nil` while the
/// draft is blank (nothing to classify, so nothing to promise) or the host isn't routing.
///
/// Classification is deliberately not cached against the draft: the heuristic is a keyword
/// and shape pass over the prompt's first 2000 characters and costs far less than the layout
/// pass that will render its result.
func intelligenceRoute(for draft: String, level: Int) -> WireIntelligenceCatalog.Route? {
guard routesIntelligence else { return nil }
let prompt = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !prompt.isEmpty else { return nil }
let verdict = HeuristicPurposeClassifier.classify(prompt)
return intelligenceCatalog.route(purpose: verdict.purpose, level: level)
}
/// The route spoken to VoiceOver as part of the rail's value ("Sonnet 5, effort High"), so
/// assistive tech learns what a stop will actually run without navigating to the route line.
func intelligenceRouteDescription(for draft: String, level: Int) -> String? {
guard let route = intelligenceRoute(for: draft, level: level) else { return nil }
return "\(modelCatalog.displayName(route.model)), "
+ "\(modelCatalog.effortNoun(forModel: route.model).lowercased()) "
+ modelCatalog.effortDisplayName(route.effort)
}
// MARK: - The new-chat rail's remembered stop
private static let newChatLevelKey = "nucleic.newChatIntelligence"
private static let newChatOrchestraKey = "nucleic.newChatOrchestra"
/// The rail's stop for a new chat — **one value, not one per project**. How much capability
/// you want is a property of the person and the moment, not of the repo; keying it per project
/// meant the bar jumped every time the picker moved and silently discarded a level just chosen.
///
/// Falls back to the host's default, so a first-ever composer opens where the Mac would, and
/// re-falls-back if a stored value names a rung this host's ladder doesn't have.
var newChatIntelligenceLevel: Int {
let stored = UserDefaults.standard.integer(forKey: Self.newChatLevelKey)
guard stored != 0, intelligenceCatalog.level(stored) != nil else {
return defaultIntelligenceLevel
}
return stored
}
func setNewChatIntelligenceLevel(_ level: Int) {
UserDefaults.standard.set(level, forKey: Self.newChatLevelKey)
}
/// Whether the new-chat rail sits on the constellation, remembered alongside the stop and for
/// the same reason.
var newChatOrchestra: Bool {
UserDefaults.standard.bool(forKey: Self.newChatOrchestraKey)
}
func setNewChatOrchestra(_ on: Bool) {
UserDefaults.standard.set(on, forKey: Self.newChatOrchestraKey)
}
/// Turn Orchestra on for an open chat. It rides the existing effort sentinel rather than a
/// stop of its own (the host resolves the sentinel to xhigh plus standing subagent consent),
/// so this is the ordinary effort verb — which also means an older host honors it.
func setSessionOrchestra(_ id: SessionID) {
setSessionEffort(id, modelCatalog.orchestraSentinelOrFallback)
}
/// The stop an open chat's rail sits at — the level the **host** recorded when it routed the
/// chat, straight off the wire. This is the same number the Mac's own rail reads, which is
/// what keeps the two devices on the same stop.
///
/// The fallback only covers a chat that was never routed (started before routing, or with a
/// model pinned by hand): there it matches the pair back through the route table, exactly as
/// the Mac's `closestSessionIntelligenceLevel` does. That inference is *only* a fallback —
/// using it for routed chats was the bug that let the two devices disagree, since several
/// stops legitimately share a model.
func sessionIntelligenceLevel(_ summary: WireSessionSummary) -> Int {
guard routesIntelligence else { return defaultIntelligenceLevel }
if let routed = summary.routedLevel, intelligenceCatalog.level(routed) != nil {
return routed
}
if modelCatalog.isOrchestra(summary.effort) {
return intelligenceCatalog.levels.last?.rawValue ?? defaultIntelligenceLevel
}
guard let model = summary.model, let effort = summary.effort else {
return defaultIntelligenceLevel
}
let candidates = intelligenceCatalog.routes.filter { $0.model == model }
if let exact = candidates.first(where: { $0.effort == effort }) { return exact.level }
return candidates.first?.level ?? defaultIntelligenceLevel
}
/// What moving an open chat's rail to `level` would resolve to — predicted with the purpose
/// the **host** routed this chat under, not with a fresh classification of the composer draft.
///
/// That distinction is the whole fix for the model mismatch: the host re-routes an existing
/// chat against its recorded purpose (moving the rail changes the budget, not what the chat is
/// for), so classifying the follow-up draft here instead produced a different row of the matrix
/// and named a model the Mac never picked. A chat with no recorded purpose falls back to the
/// conservative `general` row — which is also what the host falls back to.
func sessionIntelligenceRoute(
_ summary: WireSessionSummary, level: Int
) -> WireIntelligenceCatalog.Route? {
guard routesIntelligence else { return nil }
let purpose = summary.routedPurpose.flatMap(PromptPurpose.init(rawValue:)) ?? .general
return intelligenceCatalog.route(purpose: purpose, level: level)
}
/// A stand-in ladder for demo mode, so the rail, its constellation and the route line are all
/// exercisable without a paired Mac (App Review runs the app this way — see the demo seed).
/// Deliberately a *plausible* slice of the real matrix rather than a uniform one: a flat table
/// would make the rail look like it does nothing as you move it.
static var demoIntelligenceCatalog: WireIntelligenceCatalog {
let ladder: [(Int, String, String)] = [
(1, "Quick", "Cheapest and fastest. Good for typos, renames, and one-line changes."),
(2, "Light", "A small step up. Scoped edits you could describe in a sentence."),
(3, "Balanced", "The everyday default — a mid-tier model at moderate effort."),
(4, "Deep", "More capable models and more thinking. Worth it for real features."),
(5, "Max", "The most capable model this task can use, at its best effort."),
]
let perLevel: [Int: (String, String)] = [
1: ("claude-sonnet-4-6", "low"),
2: ("claude-sonnet-4-6", "medium"),
3: ("gpt-5.5", "high"),
4: ("claude-opus-4-8[1m]", "high"),
5: ("claude-opus-4-8[1m]", "max"),
]
return WireIntelligenceCatalog(
levels: ladder.map {
WireIntelligenceCatalog.Level(rawValue: $0.0, displayName: $0.1, blurb: $0.2)
},
routes: PromptPurpose.allCases.flatMap { purpose in
ladder.map { level, name, _ in
let pair = perLevel[level] ?? ("claude-sonnet-4-6", "high")
return WireIntelligenceCatalog.Route(
purpose: purpose.rawValue, level: level,
model: pair.0, effort: pair.1,
reason: "\(purpose.displayName) · \(name) — demo routing",
isAvailable: true)
}
},
defaultLevel: 3,
orchestraDisplayName: "Orchestra",
orchestraBlurb:
"Max, plus managed subagents: the work fans out to parallel workers and one "
+ "supervisor pulls the results together. Nucleic Control projects only.",
orchestraAvailable: true)
}
}