179 lines
9.8 KiB
Swift
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)
|
|
}
|
|
}
|