diff --git a/NOTICE b/NOTICE index bb242ab343..e61a52bf4b 100644 --- a/NOTICE +++ b/NOTICE @@ -16,11 +16,39 @@ ZIPFoundation — https://github.com/weichsel/ZIPFoundation License: MIT Used for: reading WHOOP / Apple Health export archives during import. +llama.cpp (and its ggml core) — https://github.com/ggml-org/llama.cpp + License: MIT — Copyright (c) 2023-2024 The ggml authors + Used for: in-process on-device LLM inference for the optional AI Coach (iOS + only). NOOP links a pinned prebuilt xcframework (official ggml-org release + b9947); see Packages/LlamaCpp. + The MIT license is permissive: these components may be used, modified, and redistributed (including commercially) on their own terms. When they are used as part of NOOP's combined work, the combined work as a whole is governed by NOOP's PolyForm Noncommercial license. +On-device AI Coach model (downloaded at first run — NOT redistributed by NOOP) +----------------------------------------------------------------------------- + +The optional on-device AI Coach runs Meta's Llama 3.2 3B Instruct. NOOP does NOT +bundle or redistribute the model weights: the repository and app contain only a +pinned download URL + SHA-256 (see Strand/AI/OnDevice/ModelCatalog.swift), and the +device fetches the weights directly from Hugging Face on first run, at the user's +request. + + Model: Llama 3.2 3B Instruct (Q4_K_M GGUF quantization by "bartowski") + License: Llama 3.2 Community License Agreement — Copyright © Meta Platforms, + Inc. All Rights Reserved. https://www.llama.com/llama3_2/license/ + Acceptable Use Policy: https://www.llama.com/llama3_2/use-policy/ + + "Built with Llama." The Llama 3.2 Community License is Meta's own license (not + an OSI open-source license): it grants use — including redistribution and + commercial use — under its terms (attribution, the Acceptable Use Policy, and a + separate license from Meta above 700M monthly active users). It does not + relicense NOOP's own code, and NOOP's PolyForm Noncommercial license does not + relicense the model. The text-only 3.2 models (1B/3B) are not subject to the + license's EU restriction (which applies only to the multimodal Llama models). + Prior reverse-engineering research ---------------------------------- diff --git a/Packages/LlamaCpp/Package.swift b/Packages/LlamaCpp/Package.swift new file mode 100644 index 0000000000..b1fd1e972b --- /dev/null +++ b/Packages/LlamaCpp/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version:5.9 +import PackageDescription + +// Wraps the pinned llama.cpp prebuilt xcframework. URL + checksum are pinned EXACTLY (supply-chain: +// a clean resolve can't pull a different artifact). To bump llama.cpp, update BOTH fields together. +let package = Package( + name: "LlamaCpp", + platforms: [.iOS(.v17)], + products: [.library(name: "LlamaCpp", targets: ["LlamaCpp"])], + targets: [ + .binaryTarget( + name: "llama", + url: "https://github.com/ggml-org/llama.cpp/releases/download/b9947/llama-b9947-xcframework.zip", + checksum: "56047fa796b6e156d890a65e8811261572c3bb63811341ea6a84735253feba9d" + ), + .target(name: "LlamaCpp", dependencies: ["llama"], path: "Sources/LlamaCpp") + ] +) diff --git a/Packages/LlamaCpp/Sources/LlamaCpp/Exports.swift b/Packages/LlamaCpp/Sources/LlamaCpp/Exports.swift new file mode 100644 index 0000000000..1823fb0f65 --- /dev/null +++ b/Packages/LlamaCpp/Sources/LlamaCpp/Exports.swift @@ -0,0 +1,2 @@ +// Re-export the binary module so app code writes `import LlamaCpp`. +@_exported import llama diff --git a/Strand/AI/AICoach.swift b/Strand/AI/AICoach.swift index 8418bce233..75a2700f50 100644 --- a/Strand/AI/AICoach.swift +++ b/Strand/AI/AICoach.swift @@ -4,6 +4,9 @@ import Security import WhoopStore import StrandAnalytics import StrandImport +#if canImport(UIKit) +import UIKit +#endif // MARK: - AI Coach (the one networked feature, strictly opt-in, bring-your-own-key) // @@ -27,7 +30,7 @@ struct ChatMessage: Identifiable, Equatable { enum Role: String { case user, assistant } let id: UUID let role: Role - let text: String + var text: String // var — streaming mutates this in place init(id: UUID = UUID(), role: Role, text: String) { self.id = id @@ -117,6 +120,10 @@ enum AICoachError: LocalizedError { case decode case keySaveFailed case badCustomURL(String) + case modelNotDownloaded + case modelLoadFailed(String) + case generationFailed(String) + case deviceUnsupported var errorDescription: String? { switch self { @@ -139,6 +146,16 @@ enum AICoachError: LocalizedError { return "Network problem: \(detail). The coach is the only feature that needs the internet." case .decode: return "Couldn't read the provider's reply. Try again." + case .modelNotDownloaded: + return "Download the on-device coach model first, then ask again." + case .modelLoadFailed(let detail): + let extra = detail.isEmpty ? "" : " — \(detail)" + return "Couldn't load the on-device model\(extra). Try re-downloading it." + case .generationFailed(let detail): + let extra = detail.isEmpty ? "" : " — \(detail)" + return "The on-device coach stopped unexpectedly\(extra). Try again." + case .deviceUnsupported: + return "\(Platform.deviceNounPhrase.prefix(1).uppercased() + Platform.deviceNounPhrase.dropFirst()) doesn't have enough memory to run the on-device coach. Use a cloud provider instead." } } } @@ -196,6 +213,16 @@ final class AICoachEngine: ObservableObject { didSet { UserDefaults.standard.set(includeOnDeviceSignals, forKey: Self.onDeviceSignalsKey) } } + /// Owns the on-device model file lifecycle (download/verify/delete). Drives the on-device setup card + /// and gates `isConfigured` for the on-device provider. + let modelDownloads = ModelDownloadManager() + + /// Re-publishes the nested `modelDownloads` manager's changes as OUR changes. CoachView observes the + /// engine (`@EnvironmentObject`), not `modelDownloads`, and SwiftUI does not propagate a nested + /// ObservableObject automatically — so without this forward the download progress / cancel / ready + /// transitions wouldn't re-render the setup card until the view was recreated. + private var downloadForwarding: AnyCancellable? + private let repo: Repository private let session: URLSession @@ -283,7 +310,7 @@ final class AICoachEngine: ObservableObject { // Restore persisted provider / model (falling back to sane defaults). let storedProvider = UserDefaults.standard.string(forKey: Self.providerKey) - .flatMap(AIProvider.init(rawValue:)) ?? .openAI + .flatMap(AIProvider.init(rawValue:)) ?? AIProvider.defaultProvider self.provider = storedProvider let storedModel = UserDefaults.standard.string(forKey: Self.modelKey) @@ -305,8 +332,42 @@ final class AICoachEngine: ObservableObject { self.customBaseURL = UserDefaults.standard.string(forKey: AIProvider.customBaseURLKey) ?? "" self.customConnected = UserDefaults.standard.bool(forKey: Self.customConnectedKey) self.includeOnDeviceSignals = UserDefaults.standard.bool(forKey: Self.onDeviceSignalsKey) + + // Forward the nested download manager's change notifications to this engine's observers, so the + // on-device setup card (which binds to the engine) updates live during download / verify / cancel. + downloadForwarding = modelDownloads.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } + #if os(iOS) + installMemoryGuards() + #endif } + // MARK: Memory guards (iOS only) + + #if os(iOS) + private var memoryPressureSource: DispatchSourceMemoryPressure? + + /// Free the model under critical memory pressure (only when idle) and on backgrounding, so the + /// coach is never the top jetsam target. Reloads lazily on the next generation. Call once from init. + func installMemoryGuards() { + let src = DispatchSource.makeMemoryPressureSource(eventMask: .critical, queue: .main) + src.setEventHandler { [weak self] in + guard let self, !self.sending else { return } + Task { await LlamaEngine.shared.unload() } + } + src.resume() + memoryPressureSource = src + + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main + ) { [weak self] _ in + guard let self, !self.sending else { return } + Task { await LlamaEngine.shared.unload() } + } + } + #endif + // MARK: Key management /// True when a key is present in the Keychain. @@ -314,8 +375,15 @@ final class AICoachEngine: ObservableObject { /// True once the coach can actually send: a stored key for the cloud providers, or, for the /// Custom (local) provider, a committed base URL (a key is optional there, as local servers - /// usually need none). Gates the setup card vs. the live chat. - var isConfigured: Bool { provider == .custom ? customConnected : hasKey } + /// usually need none). For the on-device provider, requires the model to be downloaded and ready. + /// Gates the setup card vs. the live chat. + var isConfigured: Bool { + switch provider { + case .onDevice: return modelDownloads.state == .ready + case .custom: return customConnected + default: return hasKey + } + } /// The key to send with a request: the stored key, or an empty string for the keyless Custom /// provider. `nil` means "not configured", the caller surfaces `.noKey`. @@ -329,7 +397,7 @@ final class AICoachEngine: ObservableObject { if owner == provider.rawValue { return k } if owner == nil && provider != .custom { return k } } - return provider == .custom ? "" : nil + return (provider == .custom || provider == .onDevice) ? "" : nil } /// Commit the Custom (local) provider once the user has entered a server URL. Optionally stores a @@ -453,6 +521,98 @@ final class AICoachEngine: ObservableObject { // MARK: Sending + private var genTask: Task? + + #if DEBUG + /// Test seam: stand in for the provider's streaming call. Production leaves this nil. + var streamOverride: ((_ wire: [(role: ChatMessage.Role, content: String)]) -> AsyncThrowingStream)? + #endif + + /// Append an empty assistant bubble and grow it as stream chunks arrive. Optional `header` is + /// prepended once the FIRST chunk arrives (so an errored/empty generation leaves no stray bubble). + /// Sets `genTask` so `stop()` cancels it. Never throws; failures land in `errorText`. + private func runAssistantStream(key: String, + wire: [(role: ChatMessage.Role, content: String)], + header: String? = nil) async { + let assistantId = UUID() + messages.append(ChatMessage(id: assistantId, role: .assistant, text: "")) + + let stream: AsyncThrowingStream + #if DEBUG + if let streamOverride { stream = streamOverride(wire) } + else { stream = provider.client.stream(key: key, model: model, systemPrompt: systemPrompt, messages: wire, session: session) } + #else + stream = provider.client.stream(key: key, model: model, systemPrompt: systemPrompt, messages: wire, session: session) + #endif + + let handle = Task { @MainActor in + // Coalesce UI updates: the on-device provider streams token-by-token, and writing each token + // straight into the `@Published messages` array re-renders the whole transcript AND re-parses + // the growing assistant bubble through MarkdownUI on every token — O(n²) over the reply. Buffer + // into `accumulated` and push to the published text at most ~16 Hz, with a final flush at the + // end. Cloud providers resolve to one chunk, so they flush once and are unaffected. + var accumulated = "" + var started = false + var lastFlush = Date.distantPast + let flushInterval: TimeInterval = 0.06 + @MainActor func flush() { + guard let idx = messages.firstIndex(where: { $0.id == assistantId }) else { return } + messages[idx].text = accumulated + } + do { + for try await chunk in stream { + if !started { started = true; accumulated = (header ?? "") + chunk } + else { accumulated += chunk } + let now = Date() + if now.timeIntervalSince(lastFlush) >= flushInterval { + lastFlush = now + flush() + } + } + } catch let e as AICoachError { + errorText = e.errorDescription + } catch is CancellationError { + // user pressed Stop — keep whatever streamed so far + } catch { + errorText = AICoachError.network(error.localizedDescription).errorDescription + } + flush() // ensure the final (and any sub-interval) content is shown + // Drop a bubble that never received content (nothing beyond the header). + if let idx = messages.firstIndex(where: { $0.id == assistantId }), + messages[idx].text.isEmpty { + messages.remove(at: idx) + } + } + genTask = handle + await handle.value + genTask = nil + } + + /// Streaming send: append the user turn, build context, append an empty assistant turn, then grow + /// its text as chunks arrive. Uses `stream(...)` for EVERY provider — cloud providers resolve to one + /// chunk, the on-device provider streams token-by-token. Never throws; failures land in `errorText`. + func sendStreaming(_ userText: String) async { + let trimmed = userText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { errorText = AICoachError.emptyQuestion.errorDescription; return } + guard let key = resolvedKey else { errorText = AICoachError.noKey.errorDescription; return } + + errorText = nil + messages.append(ChatMessage(role: .user, text: trimmed)) + sending = true + defer { sending = false } + + let context = dataConsent ? await buildFullContext() : noConsentNote + let wire = wireMessages(context: context) + + await runAssistantStream(key: key, wire: wire) + } + + /// Cancel an in-flight streaming generation (Stop button). Safe to call when idle. + func stop() { + genTask?.cancel() + genTask = nil + } + /// Send a question: append it, build the metrics context, call the chosen provider with the /// system prompt + context + running history, parse the reply, append it. Never throws/crashes; /// failures land in `errorText`. @@ -486,6 +646,7 @@ final class AICoachEngine: ObservableObject { /// Proactively generate "Today's brief" the first time the Coach opens, readiness + a training /// prescription + one recovery tip, without the user typing. Requires a key + data consent. + /// Streams token-by-token so on-device generation is incremental, not a frozen spinner. func startBriefIfNeeded() async { guard isConfigured, dataConsent, messages.isEmpty, !sending else { return } guard let key = resolvedKey else { return } @@ -501,17 +662,7 @@ final class AICoachEngine: ObservableObject { (3) one specific thing to improve my charge. Be punchy and motivating. """ let wire: [(role: ChatMessage.Role, content: String)] = [(.user, context + "\n\n---\n\n" + instruction)] - do { - let reply = try await callProvider(key: key, messages: wire) - let clean = reply.trimmingCharacters(in: .whitespacesAndNewlines) - if !clean.isEmpty { - messages.append(ChatMessage(role: .assistant, text: "Today's brief\n\n" + clean)) - } - } catch let e as AICoachError { - errorText = e.errorDescription - } catch { - errorText = AICoachError.network(error.localizedDescription).errorDescription - } + await runAssistantStream(key: key, wire: wire, header: "Today's brief\n\n") } /// Full data context = the metrics summary + recent workouts (+ an OPT-IN on-device-signals summary diff --git a/Strand/AI/AIProvider.swift b/Strand/AI/AIProvider.swift index b401d0c1e3..9f00d7d869 100644 --- a/Strand/AI/AIProvider.swift +++ b/Strand/AI/AIProvider.swift @@ -3,6 +3,7 @@ import Foundation // MARK: - Provider enum enum AIProvider: String, CaseIterable, Identifiable { + case onDevice case openAI case anthropic case gemini @@ -12,6 +13,7 @@ enum AIProvider: String, CaseIterable, Identifiable { var displayName: String { switch self { + case .onDevice: return "On-device (no setup, fully private)" case .openAI: return "OpenAI" case .anthropic: return "Anthropic" case .gemini: return "Google Gemini" @@ -21,6 +23,7 @@ enum AIProvider: String, CaseIterable, Identifiable { var defaultModel: String { switch self { + case .onDevice: return ModelCatalog.coach.id case .openAI: return "gpt-4o-mini" case .anthropic: return "claude-sonnet-4-6" case .gemini: return "gemini-flash-latest" // stable alias → current Flash, no version churn (#400) @@ -32,6 +35,7 @@ enum AIProvider: String, CaseIterable, Identifiable { /// these, and `refreshModels()` can merge the provider's live list. var modelOptions: [String] { switch self { + case .onDevice: return [ModelCatalog.coach.id] case .openAI: return ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"] case .anthropic: @@ -60,6 +64,7 @@ enum AIProvider: String, CaseIterable, Identifiable { var endpoint: URL { switch self { + case .onDevice: return URL(string: "file:///on-device")! // unused; inference is in-process case .openAI: return URL(string: "https://api.openai.com/v1/chat/completions")! case .anthropic: return URL(string: "https://api.anthropic.com/v1/messages")! case .gemini: return URL(string: "https://generativelanguage.googleapis.com/v1beta/models")! @@ -69,6 +74,7 @@ enum AIProvider: String, CaseIterable, Identifiable { var modelsEndpoint: URL { switch self { + case .onDevice: return URL(string: "file:///on-device")! // unused case .openAI: return URL(string: "https://api.openai.com/v1/models")! case .anthropic: return URL(string: "https://api.anthropic.com/v1/models")! case .gemini: return URL(string: "https://generativelanguage.googleapis.com/v1beta/models")! @@ -78,6 +84,12 @@ enum AIProvider: String, CaseIterable, Identifiable { var client: any AIProviderClient { switch self { + case .onDevice: + #if os(iOS) + return OnDeviceClient.shared + #else + return UnavailableOnDeviceClient() + #endif case .openAI: return OpenAIClient() case .anthropic: return AnthropicClient() case .gemini: return GeminiClient() @@ -166,6 +178,41 @@ enum AIProvider: String, CaseIterable, Identifiable { } } +// MARK: - Platform availability + defaults + +extension AIProvider { + /// Providers shown in the picker. The on-device provider is iOS-only; macOS keeps the cloud set. + static var available: [AIProvider] { + #if os(iOS) + return allCases + #else + return allCases.filter { $0 != .onDevice } + #endif + } + + /// The provider a fresh install starts on: on-device (zero-setup) on iOS, OpenAI on macOS. + static var defaultProvider: AIProvider { + #if os(iOS) + return .onDevice + #else + return .openAI + #endif + } +} + +#if !os(iOS) +/// macOS stand-in so `AIProvider.onDevice.client` type-checks in the shared enum. Never selectable on +/// macOS (filtered out of `available`); if somehow invoked it fails clearly rather than doing anything. +/// Task 9 replaced the iOS path with OnDeviceClient.shared; only macOS uses this stub now. +struct UnavailableOnDeviceClient: AIProviderClient { + func send(key: String, model: String, systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], session: URLSession) async throws -> String { + throw AICoachError.deviceUnsupported + } + func fetchModels(key: String, session: URLSession) async throws -> [String] { [ModelCatalog.coach.id] } +} +#endif + // MARK: - Provider protocol protocol AIProviderClient { @@ -178,10 +225,46 @@ protocol AIProviderClient { session: URLSession ) async throws -> String + /// Stream a chat turn as incremental text chunks. Cloud clients inherit the default below (one + /// chunk); only the on-device client overrides this to emit true token-by-token output. + func stream( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession + ) -> AsyncThrowingStream + /// Fetch the provider's live model list and return plain model ids. func fetchModels(key: String, session: URLSession) async throws -> [String] } +// MARK: - Protocol extension default + +extension AIProviderClient { + func stream( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let full = try await send(key: key, model: model, systemPrompt: systemPrompt, + messages: messages, session: session) + continuation.yield(full) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } +} + // MARK: - Shared HTTP helpers /// Execute a request, map HTTP status codes to `AICoachError`, return the decoded JSON object. diff --git a/Strand/AI/OnDevice/LlamaEngine.swift b/Strand/AI/OnDevice/LlamaEngine.swift new file mode 100644 index 0000000000..8aee08e6fa --- /dev/null +++ b/Strand/AI/OnDevice/LlamaEngine.swift @@ -0,0 +1,202 @@ +import Foundation +import LlamaCpp + +/// In-process llama.cpp wrapper. An `actor` so the C context is never touched concurrently. iOS-only. +/// +/// All raw llama.cpp pointers (`model`/`ctx`/`vocab`) are confined to this actor's isolation domain: +/// they are declared `private`, and every C call that reads them runs inside an actor-isolated method. +/// `generate` is `nonisolated` only so it can synchronously hand back an `AsyncStream`; the actual +/// decode loop runs in `runGeneration`, hopping onto the actor before touching any pointer. +actor LlamaEngine { + static let shared = LlamaEngine() + + private var model: OpaquePointer? + private var ctx: OpaquePointer? + private var vocab: OpaquePointer? + private var loadedModel: BundledModel? + + /// Load a GGUF into a llama context with Metal enabled. Idempotent: reloads only when the target + /// differs from what's loaded. Throws `AICoachError.modelLoadFailed` on any C failure. + func load(modelURL: URL, model: BundledModel) async throws { + if loadedModel?.id == model.id, ctx != nil { return } + unload() + + llama_backend_init() + + var mparams = llama_model_default_params() + mparams.n_gpu_layers = -1 // all layers on the Metal GPU + guard let m = llama_model_load_from_file(modelURL.path, mparams) else { + throw AICoachError.modelLoadFailed("could not open \(model.id)") + } + + var cparams = llama_context_default_params() + cparams.n_ctx = UInt32(model.contextLength) + // n_batch MUST stay = n_ctx: runGeneration prefills the whole prompt (up to ~n_ctx tokens) in a + // SINGLE llama_decode (llama_batch_get_one) after clearing the KV cache, so the logical batch must + // be able to hold the full prompt. Do NOT lower n_batch below n_ctx or large prompts silently fail + // to prefill. n_ubatch (physical batch) is what bounds compute-buffer memory — pin it at 512. + cparams.n_batch = UInt32(model.contextLength) + cparams.n_ubatch = 512 + // Flash Attention: cuts KV-cache memory bandwidth and speeds prefill/decode on Metal. b9947's + // default is AUTO; force ENABLED. One line to revert to LLAMA_FLASH_ATTN_TYPE_AUTO — verify + // tokens/sec + output coherence on device. + cparams.flash_attn_type = LLAMA_FLASH_ATTN_TYPE_ENABLED + // All transformer layers run on the Metal GPU (n_gpu_layers = -1), so the CPU only samples/book- + // keeps; pin a small thread count so we don't oversubscribe the efficiency cores (thermal jitter). + let threads = Int32(max(2, min(4, ProcessInfo.processInfo.processorCount))) + cparams.n_threads = threads + cparams.n_threads_batch = threads + guard let c = llama_init_from_model(m, cparams) else { + llama_model_free(m) + throw AICoachError.modelLoadFailed("could not create context") + } + + self.model = m + self.ctx = c + self.vocab = llama_model_get_vocab(m) + self.loadedModel = model + } + + /// Free the context + model. Safe to call when nothing is loaded. + func unload() { + if let ctx { llama_free(ctx) } + if let model { llama_model_free(model) } + ctx = nil; model = nil; vocab = nil; loadedModel = nil + } + + var isLoaded: Bool { ctx != nil } + + /// Generate a reply, yielding detokenized text pieces as they are produced. Stops on EOS, the + /// 512-token cap, or `Task` cancellation. Applies the model's chat template. Assumes a model is + /// already loaded (the caller loads via `load`); yields nothing and finishes if none is loaded. + nonisolated func generate(systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)]) -> AsyncStream { + AsyncStream { continuation in + let task = Task { + await self.runGeneration(systemPrompt: systemPrompt, + messages: messages, + into: continuation) + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Actor-isolated decode loop. All C pointer access happens here so nothing escapes isolation. + private func runGeneration(systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + into continuation: AsyncStream.Continuation) { + guard let ctx = self.ctx, let vocab = self.vocab, let model = self.model else { return } + let maxTokens = 512 + + // 1. Build the prompt via the model's chat template. + let prompt = applyTemplate(model: model, systemPrompt: systemPrompt, messages: messages) + + // 2. Tokenize. + var tokens = tokenize(vocab: vocab, text: prompt, addBOS: true) + guard !tokens.isEmpty else { return } + + // 2b. Reset the KV cache so this generation starts at position 0. The context is reused across + // turns (the model stays loaded), and `llama_batch_get_one` auto-assigns positions continuing + // from the current `n_past` — so without this clear, each turn's full-conversation prefill is + // appended ON TOP of the previous turns' KV, and cumulative positions overrun `n_ctx` after a + // few turns (`llama_decode` then fails and the reply comes back empty). We already re-send the + // entire conversation via `applyTemplate`, so clearing here is both the fix and more correct. + llama_memory_clear(llama_get_memory(ctx), true) + + // 3. Prefill. The token buffer must stay alive across the decode call. + let prefilled = tokens.withUnsafeMutableBufferPointer { buf -> Bool in + let batch = llama_batch_get_one(buf.baseAddress, Int32(buf.count)) + return llama_decode(ctx, batch) == 0 + } + guard prefilled else { return } + + // 4. Sampler chain (greedy-ish: top-k / top-p / temp). Freed at the end. + let sampler = llama_sampler_chain_init(llama_sampler_chain_default_params()) + llama_sampler_chain_add(sampler, llama_sampler_init_top_k(40)) + llama_sampler_chain_add(sampler, llama_sampler_init_top_p(0.95, 1)) + llama_sampler_chain_add(sampler, llama_sampler_init_temp(0.7)) + llama_sampler_chain_add(sampler, llama_sampler_init_dist(LLAMA_DEFAULT_SEED)) + defer { llama_sampler_free(sampler) } + + var generated = 0 + while generated < maxTokens { + if Task.isCancelled { break } + let next = llama_sampler_sample(sampler, ctx, -1) + if llama_vocab_is_eog(vocab, next) { break } + + if let piece = pieceToString(vocab: vocab, token: next), !piece.isEmpty { + continuation.yield(piece) + } + + // Feed the sampled token back in. `one` must outlive the decode call. + var one = next + let ok = withUnsafeMutablePointer(to: &one) { ptr -> Bool in + let batch = llama_batch_get_one(ptr, 1) + return llama_decode(ctx, batch) == 0 + } + if !ok { break } + generated += 1 + } + } + + // MARK: - C helpers (exact symbols track the pinned llama.cpp b9947 release) + + private func applyTemplate(model: OpaquePointer, systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)]) -> String { + var chat: [llama_chat_message] = [] + var cStrings: [UnsafeMutablePointer] = [] + func cstr(_ s: String) -> UnsafeMutablePointer? { + guard let p = strdup(s) else { return nil } + cStrings.append(p) + return p + } + defer { cStrings.forEach { free($0) } } + + chat.append(llama_chat_message(role: cstr("system"), content: cstr(systemPrompt))) + for m in messages { + chat.append(llama_chat_message(role: cstr(m.role.rawValue), content: cstr(m.content))) + } + + let tmpl = llama_model_chat_template(model, nil) + // `llama_chat_apply_template` returns the TOTAL length; a fixed buffer silently truncated a large + // coach prompt (system context + lab book + windowed history can exceed a fixed size), cutting the + // user's actual question. Start modest, then grow to the returned length and re-render if needed. + var buf = [CChar](repeating: 0, count: 8192) + var n = llama_chat_apply_template(tmpl, chat, chat.count, true, &buf, Int32(buf.count)) + if n <= 0 { return systemPrompt + "\n\n" + (messages.last?.content ?? "") } + if Int(n) > buf.count { + buf = [CChar](repeating: 0, count: Int(n)) + n = llama_chat_apply_template(tmpl, chat, chat.count, true, &buf, Int32(buf.count)) + if n <= 0 { return systemPrompt + "\n\n" + (messages.last?.content ?? "") } + } + return buf.withUnsafeBufferPointer { p in + String(decoding: UnsafeRawBufferPointer(start: p.baseAddress, count: Int(n)), as: UTF8.self) + } + } + + private func tokenize(vocab: OpaquePointer, text: String, addBOS: Bool) -> [llama_token] { + let utf8 = Array(text.utf8) + guard !utf8.isEmpty else { return [] } + let byteCount = Int32(utf8.count) + let cap = Int32(utf8.count + 8) + var out = [llama_token](repeating: 0, count: Int(cap)) + // Use the UTF-8 byte length directly; do not re-derive it from a C string. + let n: Int32 = utf8.withUnsafeBufferPointer { src in + src.withMemoryRebound(to: CChar.self) { rebound in + llama_tokenize(vocab, rebound.baseAddress, byteCount, &out, cap, addBOS, true) + } + } + if n < 0 { return [] } + return Array(out.prefix(Int(n))) + } + + private func pieceToString(vocab: OpaquePointer, token: llama_token) -> String? { + var buf = [CChar](repeating: 0, count: 256) + let n = llama_token_to_piece(vocab, token, &buf, Int32(buf.count), 0, true) + if n <= 0 { return nil } + return buf.withUnsafeBufferPointer { p in + String(decoding: UnsafeRawBufferPointer(start: p.baseAddress, count: Int(n)), as: UTF8.self) + } + } +} diff --git a/Strand/AI/OnDevice/ModelCatalog.swift b/Strand/AI/OnDevice/ModelCatalog.swift new file mode 100644 index 0000000000..462699e830 --- /dev/null +++ b/Strand/AI/OnDevice/ModelCatalog.swift @@ -0,0 +1,69 @@ +import Foundation + +/// A pinned on-device model: where to fetch it, how to verify it, and the runtime parameters the +/// engine needs. Only a factual pointer + checksum is committed — never the weights themselves. +struct BundledModel { + let id: String // filename stem, e.g. "llama-3.2-3b-instruct-q4_k_m" + let displayName: String // shown in the picker / setup card + let url: URL // pinned HuggingFace resolve URL for the .gguf + let sha256: String // 64 lowercase hex chars; verified after download + let sizeBytes: Int64 // approximate download size, shown in the confirm UI + let contextLength: Int // llama_context n_ctx + let chatTemplate: String // template id for llama_chat_apply_template ("llama3", "phi3", …) +} + +/// The single bundled coach model. To change models, update EVERY field (URL + sha256 must match). +enum ModelCatalog { + static let coach = BundledModel( + id: "llama-3.2-3b-instruct-q4_k_m", + displayName: "On-device Coach (Llama 3.2 3B)", + // Pinned to an IMMUTABLE Hugging Face commit revision (not `main`) so the URL can't drift; the + // sha256 below is an independent fail-closed check. The file's LFS sha256 at this revision + // (HF `x-linked-etag`) equals the sha256 below. sha256 verified against the downloaded file: + // curl -L -o m.gguf && shasum -a 256 m.gguf (→ the value below) + url: URL(string: "https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/5ab33fa94d1d04e903623ae72c95d1696f09f9e8/Llama-3.2-3B-Instruct-Q4_K_M.gguf")!, + sha256: "6c1a2b41161032677be168d354123594c0e6e67d2b9227c84f296ad037c728ff", + sizeBytes: 2_019_377_696, + contextLength: 4096, + chatTemplate: "llama3" + ) + + /// Minimum installed RAM to run the 3B model without jetsam risk. ~6 GB covers iPhone 15/16-class + /// devices; confirmed against real-device measurement during implementation. + static let minPhysicalMemory: UInt64 = 6 * 1024 * 1024 * 1024 + + static func deviceMeetsRequirements(physicalMemory: UInt64) -> Bool { + physicalMemory >= minPhysicalMemory + } + + /// Convenience for callers using the live device value. + static func deviceMeetsRequirements() -> Bool { + deviceMeetsRequirements(physicalMemory: ProcessInfo.processInfo.physicalMemory) + } +} + +/// On-disk location for downloaded model files: Application Support/OnDeviceModels, excluded from backup. +enum ModelStorage { + static func directory() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return base.appendingPathComponent("OnDeviceModels", isDirectory: true) + } + + static func fileURL(for model: BundledModel) -> URL { + directory().appendingPathComponent(model.id + ".gguf", isDirectory: false) + } + + static func isPresent(_ model: BundledModel) -> Bool { + FileManager.default.fileExists(atPath: fileURL(for: model).path) + } + + /// Create the directory if needed and mark it excluded from iCloud/iTunes backup. + static func ensureDirectory() throws { + let dir = directory() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + var values = URLResourceValues() + values.isExcludedFromBackup = true + var mutable = dir + try? mutable.setResourceValues(values) + } +} diff --git a/Strand/AI/OnDevice/ModelDownloadManager.swift b/Strand/AI/OnDevice/ModelDownloadManager.swift new file mode 100644 index 0000000000..6d24a14916 --- /dev/null +++ b/Strand/AI/OnDevice/ModelDownloadManager.swift @@ -0,0 +1,151 @@ +import Foundation +import CryptoKit +import Combine + +enum ModelDownloadState: Equatable { + case absent + case downloading(progress: Double) + case verifying + case ready + case failed(String) +} + +/// Abstraction over the actual network fetch so the state machine is unit-testable without a server. +protocol ModelFileFetcher { + /// Download `url` to a temporary file, reporting fractional progress, and return the temp URL. + func fetch(from url: URL, progress: @escaping (Double) -> Void) async throws -> URL +} + +/// Production fetcher: URLSession download with progress via a delegate. Resumes from where a dropped +/// transfer left off: on a network failure it captures the server's resume data and the NEXT fetch +/// continues instead of restarting the ~2 GB download (Hugging Face's CDN supports range requests). The +/// fetcher is held for the download manager's lifetime, so the resume data survives across retries. +/// (Full background-session survival across app suspension is a further follow-up.) +final class URLSessionModelFetcher: NSObject, ModelFileFetcher, URLSessionDownloadDelegate { + private var progressHandler: ((Double) -> Void)? + private var continuation: CheckedContinuation? + private var resumeData: Data? + private var resumeURL: URL? + private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + + func fetch(from url: URL, progress: @escaping (Double) -> Void) async throws -> URL { + self.progressHandler = progress + return try await withCheckedThrowingContinuation { cont in + self.continuation = cont + let task: URLSessionDownloadTask + if let data = resumeData, resumeURL == url { + task = session.downloadTask(withResumeData: data) // continue a dropped transfer + } else { + task = session.downloadTask(with: url) + } + resumeData = nil // consumed; a fresh failure will repopulate it + resumeURL = url + task.resume() + } + } + + func urlSession(_ s: URLSession, downloadTask t: URLSessionDownloadTask, + didWriteData _: Int64, totalBytesWritten w: Int64, totalBytesExpectedToWrite e: Int64) { + if e > 0 { progressHandler?(Double(w) / Double(e)) } + } + func urlSession(_ s: URLSession, downloadTask t: URLSessionDownloadTask, didFinishDownloadingTo loc: URL) { + // Move out of the delegate's temp dir immediately (it is deleted when this returns). + guard let cont = continuation else { return } + continuation = nil; resumeData = nil + let dst = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".gguf") + do { try FileManager.default.moveItem(at: loc, to: dst); cont.resume(returning: dst) } + catch { cont.resume(throwing: error) } + } + func urlSession(_ s: URLSession, task t: URLSessionTask, didCompleteWithError err: Error?) { + guard let err, let cont = continuation else { return } + continuation = nil + // Stash the server's resume data so the next fetch continues the download instead of restarting. + resumeData = (err as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data + cont.resume(throwing: err) + } +} + +/// Owns the on-device model file lifecycle: download → verify → ready, plus delete. `@MainActor` so the +/// `@Published` state drives SwiftUI directly. All decision logic is here and unit-tested via a stub fetcher. +@MainActor +final class ModelDownloadManager: ObservableObject { + @Published private(set) var state: ModelDownloadState + let model: BundledModel + private let fetcher: ModelFileFetcher + private var task: Task? + + init(model: BundledModel = ModelCatalog.coach, fetcher: ModelFileFetcher = URLSessionModelFetcher()) { + self.model = model + self.fetcher = fetcher + self.state = ModelStorage.isPresent(model) ? .ready : .absent + } + + func refreshPresence() { + if case .downloading = state { return } + if case .verifying = state { return } + state = ModelStorage.isPresent(model) ? .ready : .absent + } + + func startDownload() { task = Task { await runDownload() } } + + /// Test seam: run the download synchronously to completion. + func startDownloadAndWait() async { await runDownload() } + + #if DEBUG + func setStateForTesting(_ s: ModelDownloadState) { state = s } + #endif + + func cancel() { + task?.cancel() + task = nil + state = ModelStorage.isPresent(model) ? .ready : .absent + } + + func deleteModel() { + task?.cancel(); task = nil + try? FileManager.default.removeItem(at: ModelStorage.fileURL(for: model)) + state = .absent + } + + private func runDownload() async { + state = .downloading(progress: 0) + do { + let tmp = try await fetcher.fetch(from: model.url) { [weak self] p in + Task { @MainActor in + guard let self else { return } + if case .downloading = self.state { self.state = .downloading(progress: p) } + } + } + try Task.checkCancellation() + state = .verifying + guard let hex = Self.sha256Hex(ofFileAt: tmp), hex == model.sha256.lowercased() else { + try? FileManager.default.removeItem(at: tmp) + state = .failed("Downloaded file failed integrity check. Delete and retry.") + return + } + try ModelStorage.ensureDirectory() + let dst = ModelStorage.fileURL(for: model) + try? FileManager.default.removeItem(at: dst) + try FileManager.default.moveItem(at: tmp, to: dst) + state = .ready + } catch is CancellationError { + state = ModelStorage.isPresent(model) ? .ready : .absent + } catch { + state = .failed(error.localizedDescription) + } + } + + /// Stream a file through SHA-256 so a 2 GB model is never fully resident in memory. + static func sha256Hex(ofFileAt url: URL) -> String? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + var hasher = SHA256() + while autoreleasepool(invoking: { + let chunk = (try? handle.read(upToCount: 1 << 20)) ?? nil + guard let chunk, !chunk.isEmpty else { return false } + hasher.update(data: chunk) + return true + }) {} + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Strand/AI/OnDevice/OnDeviceClient.swift b/Strand/AI/OnDevice/OnDeviceClient.swift new file mode 100644 index 0000000000..1578b05196 --- /dev/null +++ b/Strand/AI/OnDevice/OnDeviceClient.swift @@ -0,0 +1,45 @@ +import Foundation + +/// The AIProviderClient backed by the in-process llama.cpp engine. Ensures the pinned model is present +/// and loaded, then streams tokens. No network, no key. +struct OnDeviceClient: AIProviderClient { + static let shared = OnDeviceClient() + + private var model: BundledModel { ModelCatalog.coach } + + /// Real token streaming: load-if-needed then relay the engine's AsyncStream as chunks. + func stream(key: String, model modelId: String, systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + let fileURL = ModelStorage.fileURL(for: model) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + continuation.finish(throwing: AICoachError.modelNotDownloaded); return + } + do { + try await LlamaEngine.shared.load(modelURL: fileURL, model: model) + } catch { + continuation.finish(throwing: error); return + } + for await piece in LlamaEngine.shared.generate(systemPrompt: systemPrompt, messages: messages) { + if Task.isCancelled { break } + continuation.yield(piece) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Non-streaming path (kept for protocol completeness): drain the stream into one string. + func send(key: String, model modelId: String, systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], session: URLSession) async throws -> String { + var out = "" + for try await piece in stream(key: key, model: modelId, systemPrompt: systemPrompt, + messages: messages, session: session) { out += piece } + return out + } + + func fetchModels(key: String, session: URLSession) async throws -> [String] { [model.id] } +} diff --git a/Strand/AI/OnDevice/README.md b/Strand/AI/OnDevice/README.md new file mode 100644 index 0000000000..2c87e76472 --- /dev/null +++ b/Strand/AI/OnDevice/README.md @@ -0,0 +1,88 @@ +# On-Device Coach (iOS) + +A fifth AI Coach provider (`AIProvider.onDevice`) that runs a small LLM **entirely on +the iPhone** via in-process llama.cpp — no server, no API key, and (after a one-time +model download) no network. It is the default, front-and-center coach on iOS; the +cloud BYOK providers (OpenAI/Anthropic/Gemini/Custom) remain available. + +## How it fits + +It conforms to the existing `AIProviderClient` protocol, so the whole coach pipeline — +context building, consent gating, chat history/windowing, and `CoachView` — is reused +unchanged. We added a provider, not a parallel coach. + +``` +CoachView ─▶ AICoachEngine (sendStreaming / stop, owns ModelDownloadManager) + │ provider == .onDevice + ▼ + OnDeviceClient : AIProviderClient (no URLSession, no HTTP) + ▼ + LlamaEngine (actor) ──C API──▶ llama.cpp xcframework (Metal) + ▲ + ModelDownloadManager (first-run GGUF fetch + SHA-256 verify) +``` + +## Files + +- `ModelCatalog.swift` — the pinned model (`BundledModel`), on-disk paths (`ModelStorage`), + and the RAM device gate. Pure, macOS + iOS. +- `ModelDownloadManager.swift` — first-run download state machine + streaming SHA-256 + verify + delete. Pure, macOS + iOS. +- `LlamaEngine.swift` — Swift `actor` wrapping llama.cpp (load/unload/generate, token + streaming). **iOS only** (excluded from the macOS target). +- `OnDeviceClient.swift` — `AIProviderClient` conformance driving `LlamaEngine`. **iOS only**. +- llama.cpp is a pinned prebuilt binary xcframework: `Packages/LlamaCpp` (URL + checksum). + +## Privacy / network posture + +Coach **inference is 100% offline**. The only network activity is the **one-time, +user-initiated** download of the public model weights (opposite direction — no user +data leaves the device). The `CoachView` privacy copy reflects this for `.onDevice`. + +## Runtime notes + +- Model: **Llama-3.2-3B-Instruct Q4_K_M** (~2 GB), Metal-accelerated (`n_gpu_layers = -1`), + `n_ctx = 4096`, `n_batch = n_ctx` so the full coach context prefills in one decode. +- Memory: needs the `com.apple.developer.kernel.increased-memory-limit` entitlement. + `AICoachEngine.installMemoryGuards()` unloads the model on critical memory pressure + (when idle) and on app backgrounding, reloading lazily on the next turn. Devices below + ~6 GB RAM are gated out (`ModelCatalog.deviceMeetsRequirements`). +- Downloaded weights live in `Application Support/OnDeviceModels/.gguf`, excluded + from iCloud/iTunes backup. + +## Changing the model + +Update **every** field of `ModelCatalog.coach` together (`id`, `url`, `sha256`, +`sizeBytes`, `contextLength`, `chatTemplate`). The `sha256` must match the file: + +```sh +curl -L "" -o m.gguf && shasum -a 256 m.gguf # → sha256 +``` + +## Bumping llama.cpp + +Update **both** the `url` and `checksum` in `Packages/LlamaCpp/Package.swift` together +(a llama.cpp release that publishes a `llama-b-xcframework.zip` asset): + +```sh +curl -L -o llama.zip "https://github.com/ggml-org/llama.cpp/releases/download/b/llama-b-xcframework.zip" +swift package compute-checksum llama.zip +``` + +Then re-verify the `LlamaEngine.swift` C calls still match that release's `llama.h`. +The currently pinned release is **b9947**. + +## Building / verifying iOS locally + +The `NOOPiOS` scheme embeds the watch app, which some environments can't build. To +compile-check the iOS app on its own, build `NOOPiOS` for the simulator with the +`NOOPWatch` dependency temporarily removed from `project.yml` (restore it afterward — +never commit the watch-dropped state). CI (`app-build.yml`, macos-15) builds the full +scheme. + +## Device verification (not covered by CI) + +The native path (Metal inference, real memory behavior) is validated on a physical +iPhone (≥ 6 GB RAM): download → verify → stream a reply → Stop mid-stream → background +unload/reload → measure tokens/sec and peak memory (Instruments) → confirm no jetsam → +delete reclaims ~2 GB. diff --git a/Strand/Screens/CoachView.swift b/Strand/Screens/CoachView.swift index c46bc2ac62..4c0abe25df 100644 --- a/Strand/Screens/CoachView.swift +++ b/Strand/Screens/CoachView.swift @@ -29,6 +29,10 @@ struct CoachView: View { /// Working copy of the system prompt while editing, committed to the engine on change so an edit /// takes effect on the next send. Seeded from the engine when the editor opens. @State private var promptDraft: String = "" + /// Presents the provider/model/key settings (the setup card) as a sheet from the toolbar gear. + /// Without this, a configured coach has no way back to settings — and for the on-device provider + /// (configured = model downloaded, no key) the old toolbar "Disconnect" was a silent no-op. + @State private var showingSettings = false @FocusState private var composerFocused: Bool /// Sentinel tag for the "Custom…" entry in the model Picker. @@ -69,17 +73,17 @@ struct CoachView: View { .toolbar { if coach.isConfigured { ToolbarItem { - Button(role: .destructive) { - coach.disconnect() - keyDraft = "" + Button { + showingSettings = true } label: { - Label("Disconnect", systemImage: "gearshape") + Label("Coach settings", systemImage: "gearshape") } - .help("Forget the saved key and disconnect") - .accessibilityLabel("Disconnect provider") + .help("Provider, model and key settings") + .accessibilityLabel("Coach settings") } } } + .sheet(isPresented: $showingSettings) { settingsSheet } .task(id: coach.dataConsent) { await coach.startBriefIfNeeded() } } @@ -203,6 +207,36 @@ struct CoachView: View { } } + // MARK: - Settings sheet (reachable once configured, via the toolbar gear) + + /// The provider/model/key controls, presented as a sheet so they stay reachable after the coach is + /// configured. Reuses `setupCard` (the same controls shown inline before first configuration) and + /// adds a Done button plus, for key-based providers, the Disconnect action the toolbar used to hold. + private var settingsSheet: some View { + NavigationStack { + ScrollView { + VStack(spacing: 16) { + setupCard + if coach.provider != .onDevice && (coach.hasKey || coach.customConnected) { + NoopButton("Disconnect \(coach.provider.displayName)", + systemImage: "xmark.circle", kind: .secondary) { + coach.disconnect() + keyDraft = "" + } + } + } + .padding(NoopMetrics.screenPadding) + } + .background(StrandPalette.surfaceBase) + .navigationTitle("Coach settings") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showingSettings = false } + } + } + } + } + // MARK: - Setup (no key yet) private var setupCard: some View { @@ -226,7 +260,7 @@ struct CoachView: View { VStack(alignment: .leading, spacing: 6) { Text("Provider").strandOverline() Picker("Provider", selection: $coach.provider) { - ForEach(AIProvider.allCases) { p in + ForEach(AIProvider.available) { p in Text(p.displayName).tag(p) } } @@ -235,6 +269,11 @@ struct CoachView: View { .accessibilityLabel("Provider") } + // On-device model download (on-device provider only) + if coach.provider == .onDevice { + onDeviceSetupSection + } + // Server URL (Custom / local LLM only) if coach.provider == .custom { VStack(alignment: .leading, spacing: 6) { @@ -260,33 +299,35 @@ struct CoachView: View { // Model modelSelector - // Key - VStack(alignment: .leading, spacing: 6) { - Text(coach.provider == .custom ? "API key (optional)" : "API key").strandOverline() - SecureField(coach.provider == .custom - ? "Only if your server requires one" - : "Paste your \(coach.provider.displayName) API key", text: $keyDraft) - .textFieldStyle(.plain) - .font(StrandFont.body) - .foregroundStyle(StrandPalette.textPrimary) - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .onSubmit { coach.provider == .custom ? connectCustom() : saveKey() } - .accessibilityLabel("API key") - } + // Key (hidden for on-device provider — no API key is needed) + if coach.provider != .onDevice { + VStack(alignment: .leading, spacing: 6) { + Text(coach.provider == .custom ? "API key (optional)" : "API key").strandOverline() + SecureField(coach.provider == .custom + ? "Only if your server requires one" + : "Paste your \(coach.provider.displayName) API key", text: $keyDraft) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(StrandPalette.hairline, lineWidth: 1)) + .onSubmit { coach.provider == .custom ? connectCustom() : saveKey() } + .accessibilityLabel("API key") + } - HStack { - if coach.provider == .custom { - NoopButton("Connect", systemImage: "link", kind: .primary, action: connectCustom) - .disabled(coach.customBaseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } else { - NoopButton("Save key", systemImage: "key.fill", kind: .primary, action: saveKey) - .disabled(keyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + HStack { + if coach.provider == .custom { + NoopButton("Connect", systemImage: "link", kind: .primary, action: connectCustom) + .disabled(coach.customBaseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } else { + NoopButton("Save key", systemImage: "key.fill", kind: .primary, action: saveKey) + .disabled(keyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + Spacer() } - Spacer() } Divider().overlay(StrandPalette.hairline) @@ -375,6 +416,87 @@ struct CoachView: View { customModel = false } + // MARK: - On-device setup section + + @ViewBuilder + private var onDeviceSetupSection: some View { + let m = ModelCatalog.coach + VStack(alignment: .leading, spacing: 10) { + if !ModelCatalog.deviceMeetsRequirements() { + Text("This \(Platform.deviceNounPhrase) doesn't have enough memory to run the on-device coach. Pick a cloud provider above instead.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } else { + switch coach.modelDownloads.state { + case .absent, .failed: + Text("\(m.displayName) runs entirely on your \(Platform.deviceNounPhrase). One-time download over Wi-Fi (~\(byteString(m.sizeBytes))). After that, coaching works with no internet.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + if case .failed(let msg) = coach.modelDownloads.state { + Text(msg) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.statusCritical) + .fixedSize(horizontal: false, vertical: true) + } + NoopButton("Download coach model", systemImage: "arrow.down.circle", kind: .primary) { + coach.modelDownloads.startDownload() + } + case .downloading(let p): + ProgressView(value: p) { + Text("Downloading… \(Int(p * 100))%") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + } + NoopButton("Cancel", systemImage: "xmark", kind: .secondary) { + coach.modelDownloads.cancel() + } + case .verifying: + ProgressView { + Text("Verifying…") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + } + case .ready: + StatePill("Model ready", tone: .positive, showsDot: true) + NoopButton("Delete model (free \(byteString(m.sizeBytes)))", systemImage: "trash", kind: .secondary) { + coach.modelDownloads.deleteModel() + } + } + + // Meta Llama 3.2 attribution + license — required by the Llama 3.2 Community License + // whenever the Llama model is offered. Shown in every state (before, during, after + // download) so the notice is present at the download prompt too. + llamaAttribution + } + } + } + + /// "Built with Llama" attribution + license/AUP links. The weights are downloaded from Hugging + /// Face on request and NOT redistributed by NOOP; the model is Meta's own (non-OSI) license. + private var llamaAttribution: some View { + VStack(alignment: .leading, spacing: 3) { + Text("Built with Llama") + .font(StrandFont.footnote.weight(.semibold)) + .foregroundStyle(StrandPalette.textSecondary) + Text("The on-device model is Meta's Llama 3.2, downloaded from Hugging Face on first run under the Llama 3.2 Community License (© Meta Platforms, Inc.). NOOP doesn't redistribute the weights.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 14) { + Link("Llama 3.2 license", destination: URL(string: "https://www.llama.com/llama3_2/license/")!) + Link("Acceptable-use policy", destination: URL(string: "https://www.llama.com/llama3_2/use-policy/")!) + } + .font(StrandFont.footnote) + .tint(StrandPalette.accent) // design-system: links use the palette accent, never system blue + } + } + + private func byteString(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + // MARK: - Connected state private var connectedHeader: some View { @@ -566,6 +688,10 @@ struct CoachView: View { .buttonStyle(.plain) .disabled(coach.sending || draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .accessibilityLabel("Send") + + if coach.sending { + NoopButton("Stop", systemImage: "stop.fill", kind: .secondary) { coach.stop() } + } } .padding(8) .background(StrandPalette.surfaceOverlay, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) @@ -575,9 +701,11 @@ struct CoachView: View { private var privacyFootnote: some View { Label { - Text(coach.provider == .custom - ? "Coach talks only to the server URL you set. Point it at a local model (Ollama, LM Studio, llama.cpp) to keep everything on your own machine. Nothing is sent until you ask." - : "This is the only feature that leaves \(Platform.deviceNounPhrase). It sends a summary of your metrics to \(coach.provider.displayName) using your own key. Nothing is sent until you ask.") + Text(coach.provider == .onDevice + ? "On-device coaching never leaves your \(Platform.deviceNounPhrase) — your metrics are read and answered locally." + : (coach.provider == .custom + ? "Coach talks only to the server URL you set. Point it at a local model (Ollama, LM Studio, llama.cpp) to keep everything on your own machine. Nothing is sent until you ask." + : "This is the only feature that leaves \(Platform.deviceNounPhrase). It sends a summary of your metrics to \(coach.provider.displayName) using your own key. Nothing is sent until you ask.")) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -612,7 +740,7 @@ struct CoachView: View { guard !trimmed.isEmpty, !coach.sending else { return } draft = "" composerFocused = false - Task { await coach.send(trimmed) } + Task { await coach.sendStreaming(trimmed) } } private func scrollToEnd(_ proxy: ScrollViewProxy) { diff --git a/StrandTests/AICoachStreamingTests.swift b/StrandTests/AICoachStreamingTests.swift new file mode 100644 index 0000000000..36fa692f63 --- /dev/null +++ b/StrandTests/AICoachStreamingTests.swift @@ -0,0 +1,44 @@ +import XCTest +import Foundation +@testable import Strand + +/// A client whose send() returns a canned string, to prove the default stream() yields it as one chunk. +private struct OneShotClient: AIProviderClient { + let reply: String + func send(key: String, model: String, systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], session: URLSession) async throws -> String { + reply + } + func fetchModels(key: String, session: URLSession) async throws -> [String] { [] } +} + +final class StreamingAdapterTests: XCTestCase { + func testDefaultStreamYieldsWholeReplyAsOneChunk() async throws { + let client = OneShotClient(reply: "Hello world") + var chunks: [String] = [] + for try await c in client.stream(key: "", model: "m", systemPrompt: "s", + messages: [(.user, "hi")], session: .shared) { + chunks.append(c) + } + XCTAssertEqual(chunks, ["Hello world"]) + } +} + +@MainActor +final class SendStreamingTests: XCTestCase { + func testStreamingAccumulatesChunksIntoOneAssistantMessage() async { + let engine = AICoachEngine(repo: Repository(deviceId: "test-aicoach-streaming")) + engine.provider = .custom + #if DEBUG + engine.streamOverride = { _ in + AsyncThrowingStream { c in + c.yield("He"); c.yield("llo"); c.finish() + } + } + #endif + await engine.sendStreaming("hi") + XCTAssertEqual(engine.messages.last?.role, .assistant) + XCTAssertEqual(engine.messages.last?.text, "Hello") + XCTAssertFalse(engine.sending) + } +} diff --git a/StrandTests/ModelDownloadManagerTests.swift b/StrandTests/ModelDownloadManagerTests.swift new file mode 100644 index 0000000000..18a2161d89 --- /dev/null +++ b/StrandTests/ModelDownloadManagerTests.swift @@ -0,0 +1,70 @@ +import XCTest +import CryptoKit +@testable import Strand + +/// A fetcher we fully control: hands back a temp file with chosen bytes, or throws. +private final class StubFetcher: ModelFileFetcher { + var bytes: Data + var error: Error? + init(bytes: Data = Data([0x1, 0x2, 0x3]), error: Error? = nil) { self.bytes = bytes; self.error = error } + func fetch(from url: URL, progress: @escaping (Double) -> Void) async throws -> URL { + if let error { throw error } + progress(0.5); progress(1.0) + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".gguf") + try bytes.write(to: tmp) + return tmp + } +} + +@MainActor +final class ModelDownloadManagerTests: XCTestCase { + + private func model(matching bytes: Data) -> BundledModel { + let hex = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + let m = ModelCatalog.coach + return BundledModel(id: "test-model", displayName: m.displayName, url: m.url, + sha256: hex, sizeBytes: Int64(bytes.count), + contextLength: m.contextLength, chatTemplate: m.chatTemplate) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: ModelStorage.directory()) + super.tearDown() + } + + func testSuccessfulDownloadVerifiesAndBecomesReady() async { + let bytes = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let m = model(matching: bytes) + let mgr = ModelDownloadManager(model: m, fetcher: StubFetcher(bytes: bytes)) + await mgr.startDownloadAndWait() + XCTAssertEqual(mgr.state, .ready) + XCTAssertTrue(ModelStorage.isPresent(m)) + } + + func testChecksumMismatchFailsAndDeletesFile() async { + let m = model(matching: Data([0x1])) // expects hash of [0x1] + let mgr = ModelDownloadManager(model: m, fetcher: StubFetcher(bytes: Data([0x2]))) // delivers [0x2] + await mgr.startDownloadAndWait() + if case .failed = mgr.state {} else { XCTFail("expected .failed, got \(mgr.state)") } + XCTAssertFalse(ModelStorage.isPresent(m)) + } + + func testFetchErrorBecomesFailed() async { + let m = model(matching: Data([0x1])) + let mgr = ModelDownloadManager(model: m, + fetcher: StubFetcher(error: URLError(.notConnectedToInternet))) + await mgr.startDownloadAndWait() + if case .failed = mgr.state {} else { XCTFail("expected .failed") } + } + + func testDeleteReturnsToAbsent() async { + let bytes = Data([0xAB]) + let m = model(matching: bytes) + let mgr = ModelDownloadManager(model: m, fetcher: StubFetcher(bytes: bytes)) + await mgr.startDownloadAndWait() + mgr.deleteModel() + XCTAssertEqual(mgr.state, .absent) + XCTAssertFalse(ModelStorage.isPresent(m)) + } +} diff --git a/StrandTests/OnDeviceModelCatalogTests.swift b/StrandTests/OnDeviceModelCatalogTests.swift new file mode 100644 index 0000000000..01caec1e0e --- /dev/null +++ b/StrandTests/OnDeviceModelCatalogTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import Strand + +final class OnDeviceCoachErrorTests: XCTestCase { + func testNewErrorCasesHaveMessages() { + let cases: [AICoachError] = [ + .modelNotDownloaded, + .modelLoadFailed("boom"), + .generationFailed("mid-stream"), + .deviceUnsupported + ] + for e in cases { + XCTAssertFalse((e.errorDescription ?? "").isEmpty, "\(e) has empty description") + } + } +} + +final class ModelCatalogTests: XCTestCase { + func testCoachModelIsWellFormed() { + let m = ModelCatalog.coach + XCTAssertFalse(m.id.isEmpty) + XCTAssertFalse(m.displayName.isEmpty) + XCTAssertEqual(m.url.scheme, "https") + XCTAssertEqual(m.sha256.count, 64, "SHA-256 hex must be 64 chars") + XCTAssertTrue(m.sha256.allSatisfy { $0.isHexDigit }) + XCTAssertGreaterThan(m.sizeBytes, 0) + XCTAssertEqual(m.contextLength, 4096) + XCTAssertFalse(m.chatTemplate.isEmpty) + } + + func testDeviceGateBoundary() { + let sixGB: UInt64 = 6 * 1024 * 1024 * 1024 + XCTAssertFalse(ModelCatalog.deviceMeetsRequirements(physicalMemory: sixGB - 1)) + XCTAssertTrue(ModelCatalog.deviceMeetsRequirements(physicalMemory: sixGB)) + XCTAssertTrue(ModelCatalog.deviceMeetsRequirements(physicalMemory: 8 * 1024 * 1024 * 1024)) + } + + func testStorageFileURLUsesModelId() { + let url = ModelStorage.fileURL(for: ModelCatalog.coach) + XCTAssertTrue(url.lastPathComponent.hasSuffix(".gguf")) + XCTAssertTrue(url.lastPathComponent.contains(ModelCatalog.coach.id)) + } +} diff --git a/StrandTests/OnDeviceProviderGatingTests.swift b/StrandTests/OnDeviceProviderGatingTests.swift new file mode 100644 index 0000000000..16eabb0409 --- /dev/null +++ b/StrandTests/OnDeviceProviderGatingTests.swift @@ -0,0 +1,45 @@ +import XCTest +import Combine +@testable import Strand + +@MainActor +final class OnDeviceProviderGatingTests: XCTestCase { + func testOnDeviceIsFirstCaseAndInAllCases() { + XCTAssertEqual(AIProvider.allCases.first, .onDevice) + } + + /// The download setup card in CoachView observes the engine (`@EnvironmentObject`), not the nested + /// `modelDownloads` manager. So the engine MUST forward the manager's changes, or the progress / + /// cancel / ready transitions never re-render until the view is recreated (leave + re-enter). + func testEngineForwardsModelDownloadStateChanges() { + let engine = AICoachEngine(repo: Repository(deviceId: "test-download-forwarding")) + var fired = 0 + let cancellable = engine.objectWillChange.sink { _ in fired += 1 } + engine.modelDownloads.setStateForTesting(.downloading(progress: 0.35)) + engine.modelDownloads.setStateForTesting(.absent) + cancellable.cancel() + XCTAssertGreaterThanOrEqual(fired, 2, + "engine.objectWillChange must fire on each modelDownloads.state change so the setup card updates live") + } + + func testIsConfiguredTracksDownloadReadiness() { + let engine = AICoachEngine(repo: Repository(deviceId: "test-ondevice-gating")) + engine.provider = .onDevice + // Fresh install: model absent → not configured. + engine.modelDownloads.setStateForTesting(.absent) + XCTAssertFalse(engine.isConfigured) + engine.modelDownloads.setStateForTesting(.ready) + XCTAssertTrue(engine.isConfigured) + } + + #if os(iOS) + func testDefaultProviderIsOnDeviceOniOS() { + XCTAssertEqual(AIProvider.defaultProvider, .onDevice) + } + #else + func testOnDeviceHiddenFromPickerOnMac() { + XCTAssertFalse(AIProvider.available.contains(.onDevice)) + XCTAssertEqual(AIProvider.defaultProvider, .openAI) + } + #endif +} diff --git a/StrandiOS/Resources/NOOP.entitlements b/StrandiOS/Resources/NOOP.entitlements index 674f121fc1..a1418132eb 100644 --- a/StrandiOS/Resources/NOOP.entitlements +++ b/StrandiOS/Resources/NOOP.entitlements @@ -6,6 +6,8 @@ com.apple.developer.healthkit.access + com.apple.developer.kernel.increased-memory-limit + com.apple.security.application-groups $(APP_GROUP_ID) diff --git a/docs/superpowers/plans/2026-07-09-on-device-bundled-llm-coach.md b/docs/superpowers/plans/2026-07-09-on-device-bundled-llm-coach.md new file mode 100644 index 0000000000..e0c0d43ab4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-on-device-bundled-llm-coach.md @@ -0,0 +1,1556 @@ +# On-Device Bundled LLM Coach Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a fifth AI Coach provider that runs a ~3B GGUF LLM entirely on-device (iOS) via in-process llama.cpp, downloaded once on first run, streaming replies, with no server and no API key. + +**Architecture:** A new `AIProvider.onDevice` case is backed by an `OnDeviceClient` conforming to the existing `AIProviderClient` protocol, so it drops into the existing `AICoachEngine` and `CoachView` unchanged. `OnDeviceClient` drives a `LlamaEngine` Swift actor wrapping llama.cpp (Metal). A `ModelDownloadManager` fetches + SHA-256-verifies the GGUF on first run. All native code is iOS-only and excluded from the macOS target; all decision logic is pure and unit-tested on the macOS `StrandTests` target. + +**Tech Stack:** Swift 6 / SwiftUI, llama.cpp (prebuilt xcframework via a local SPM binaryTarget), CryptoKit (SHA-256), URLSession background download, XcodeGen (`project.yml`). + +## Global Constraints + +- **Platform:** iOS only (`NOOPiOS` target, deployment target 17.0). All new code must keep the macOS `Strand` target and `StrandTests` (macOS) compiling — native llama.cpp code is excluded from macOS and platform-guarded with `#if os(iOS)`. +- **Module name is `Strand`**, product is `NOOP`. Tests use `@testable import Strand`. +- **`project.yml` is the source of truth** — run `xcodegen generate` after any target/file/package change. `Strand.xcodeproj` is generated (gitignored). +- **Offline by design:** the only new network egress is the one-time, user-initiated model download of public weights. No user data leaves the device. Coach inference is 100% offline. +- **Supply-chain:** pin the llama.cpp binary artifact by exact URL **and** SHA-256 checksum (mirrors the `exactVersion` pinning of `MarkdownUI`/`ZIPFoundation`). No committed binary blobs, no `from:` version ranges. +- **BLE safety contract is unrelated here** — no BLE changes. +- **Not a medical device** — no diagnostic/medical copy in UI strings. +- **Pinned model (verbatim into `ModelCatalog`):** Llama-3.2-3B-Instruct Q4_K_M, `contextLength = 4096`, downloaded directly from a pinned HuggingFace resolve URL; app never hosts weights. +- **Test commands:** macOS app tests run via `xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test` (run `xcodegen generate` first). New pure-logic tests live in `StrandTests/`. + +## File Structure + +**New files:** +- `Strand/AI/OnDevice/ModelCatalog.swift` — `BundledModel` struct, the pinned default model, `ModelStorage` (on-disk path helpers), `deviceMeetsRequirements(physicalMemory:)`. Pure, macOS + iOS. +- `Strand/AI/OnDevice/ModelDownloadManager.swift` — `@MainActor ObservableObject` download state machine, SHA-256 verify, delete. Pure (injected downloader), macOS + iOS. +- `Strand/AI/OnDevice/LlamaEngine.swift` — Swift `actor` wrapping llama.cpp. **iOS only** (excluded from macOS target). +- `Strand/AI/OnDevice/OnDeviceClient.swift` — `AIProviderClient` conformance. **iOS only** (excluded from macOS target). +- `Packages/LlamaCpp/Package.swift` — local SPM package declaring the pinned llama.cpp binary xcframework target. +- `StrandTests/OnDeviceModelCatalogTests.swift`, `StrandTests/ModelDownloadManagerTests.swift`, `StrandTests/AICoachStreamingTests.swift`, `StrandTests/OnDeviceProviderGatingTests.swift` — new tests. + +**Modified files:** +- `Strand/AI/AIProvider.swift` — add `.onDevice` case (first), platform-guarded `client`/`available`/`defaultProvider`, streaming protocol method + default. +- `Strand/AI/AICoach.swift` — `ChatMessage.text` → `var`; new `AICoachError` cases; `sendStreaming(_:)`, `stop()`, own the `ModelDownloadManager`, on-device-aware `isConfigured`; on-device privacy note. +- `Strand/Screens/CoachView.swift` — on-device setup card, Stop button, streaming render already works via message mutation, on-device privacy copy. +- `project.yml` — new `LlamaCpp` package, add it to `NOOPiOS` deps, exclude the two native files from macOS `Strand` target, add the increased-memory entitlement to `NOOPiOS`. + +--- + +### Task 1: New `AICoachError` cases + +**Files:** +- Modify: `Strand/AI/AICoach.swift` (the `AICoachError` enum, ~lines 110-141) +- Test: `StrandTests/OnDeviceModelCatalogTests.swift` (create — shared file for Task 1 + Task 2 pure error/catalog tests) + +**Interfaces:** +- Produces: `AICoachError.modelNotDownloaded`, `.modelLoadFailed(String)`, `.generationFailed(String)`, `.deviceUnsupported` — each with a non-empty `errorDescription`. + +- [ ] **Step 1: Write the failing test** + +Create `StrandTests/OnDeviceModelCatalogTests.swift`: + +```swift +import XCTest +@testable import Strand + +final class OnDeviceCoachErrorTests: XCTestCase { + func testNewErrorCasesHaveMessages() { + let cases: [AICoachError] = [ + .modelNotDownloaded, + .modelLoadFailed("boom"), + .generationFailed("mid-stream"), + .deviceUnsupported + ] + for e in cases { + XCTAssertFalse((e.errorDescription ?? "").isEmpty, "\(e) has empty description") + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/OnDeviceCoachErrorTests` +Expected: FAIL to compile — `modelNotDownloaded` is not a member of `AICoachError`. + +- [ ] **Step 3: Add the cases** + +In `Strand/AI/AICoach.swift`, add to the `AICoachError` enum cases: + +```swift + case modelNotDownloaded + case modelLoadFailed(String) + case generationFailed(String) + case deviceUnsupported +``` + +And add to the `errorDescription` switch: + +```swift + case .modelNotDownloaded: + return "Download the on-device coach model first, then ask again." + case .modelLoadFailed(let detail): + let extra = detail.isEmpty ? "" : " — \(detail)" + return "Couldn't load the on-device model\(extra). Try re-downloading it." + case .generationFailed(let detail): + let extra = detail.isEmpty ? "" : " — \(detail)" + return "The on-device coach stopped unexpectedly\(extra). Try again." + case .deviceUnsupported: + return "This \(Platform.deviceNounPhrase) doesn't have enough memory to run the on-device coach. Use a cloud provider instead." +``` + +Note: `Platform.deviceNounPhrase` is already used elsewhere in the codebase (see `CoachView.swift`). If it is not importable in this file, substitute the literal `"device"`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/OnDeviceCoachErrorTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Strand/AI/AICoach.swift StrandTests/OnDeviceModelCatalogTests.swift +git commit -m "coach: add on-device AICoachError cases" +``` + +--- + +### Task 2: `ModelCatalog`, `ModelStorage`, device gate + +**Files:** +- Create: `Strand/AI/OnDevice/ModelCatalog.swift` +- Test: `StrandTests/OnDeviceModelCatalogTests.swift` (append) + +**Interfaces:** +- Produces: + - `struct BundledModel { let id: String; let displayName: String; let url: URL; let sha256: String; let sizeBytes: Int64; let contextLength: Int; let chatTemplate: String }` + - `enum ModelCatalog { static let coach: BundledModel; static func deviceMeetsRequirements(physicalMemory: UInt64) -> Bool }` + - `enum ModelStorage { static func directory() -> URL; static func fileURL(for: BundledModel) -> URL; static func isPresent(_: BundledModel) -> Bool }` + +- [ ] **Step 1: Write the failing test** + +Append to `StrandTests/OnDeviceModelCatalogTests.swift`: + +```swift +final class ModelCatalogTests: XCTestCase { + func testCoachModelIsWellFormed() { + let m = ModelCatalog.coach + XCTAssertFalse(m.id.isEmpty) + XCTAssertFalse(m.displayName.isEmpty) + XCTAssertEqual(m.url.scheme, "https") + XCTAssertEqual(m.sha256.count, 64, "SHA-256 hex must be 64 chars") + XCTAssertTrue(m.sha256.allSatisfy { $0.isHexDigit }) + XCTAssertGreaterThan(m.sizeBytes, 0) + XCTAssertEqual(m.contextLength, 4096) + XCTAssertFalse(m.chatTemplate.isEmpty) + } + + func testDeviceGateBoundary() { + let sixGB: UInt64 = 6 * 1024 * 1024 * 1024 + XCTAssertFalse(ModelCatalog.deviceMeetsRequirements(physicalMemory: sixGB - 1)) + XCTAssertTrue(ModelCatalog.deviceMeetsRequirements(physicalMemory: sixGB)) + XCTAssertTrue(ModelCatalog.deviceMeetsRequirements(physicalMemory: 8 * 1024 * 1024 * 1024)) + } + + func testStorageFileURLUsesModelId() { + let url = ModelStorage.fileURL(for: ModelCatalog.coach) + XCTAssertTrue(url.lastPathComponent.hasSuffix(".gguf")) + XCTAssertTrue(url.lastPathComponent.contains(ModelCatalog.coach.id)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/ModelCatalogTests` +Expected: FAIL to compile — `ModelCatalog`/`ModelStorage` undefined. + +- [ ] **Step 3: Create the implementation** + +Create `Strand/AI/OnDevice/ModelCatalog.swift`: + +```swift +import Foundation + +/// A pinned on-device model: where to fetch it, how to verify it, and the runtime parameters the +/// engine needs. Only a factual pointer + checksum is committed — never the weights themselves. +struct BundledModel { + let id: String // filename stem, e.g. "llama-3.2-3b-instruct-q4_k_m" + let displayName: String // shown in the picker / setup card + let url: URL // pinned HuggingFace resolve URL for the .gguf + let sha256: String // 64 lowercase hex chars; verified after download + let sizeBytes: Int64 // approximate download size, shown in the confirm UI + let contextLength: Int // llama_context n_ctx + let chatTemplate: String // template id for llama_chat_apply_template ("llama3", "phi3", …) +} + +/// The single bundled coach model. To change models, update EVERY field (URL + sha256 must match). +enum ModelCatalog { + static let coach = BundledModel( + id: "llama-3.2-3b-instruct-q4_k_m", + displayName: "On-device Coach (Llama 3.2 3B)", + // Pinned resolve URL. VERIFY the sha256 below against the actual file before shipping: + // curl -L -o m.gguf && shasum -a 256 m.gguf + url: URL(string: "https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf")!, + sha256: "0000000000000000000000000000000000000000000000000000000000000000", // FILL from shasum before merge + sizeBytes: 2_019_377_408, + contextLength: 4096, + chatTemplate: "llama3" + ) + + /// Minimum installed RAM to run the 3B model without jetsam risk. ~6 GB covers iPhone 15/16-class + /// devices; confirmed against real-device measurement during implementation. + static let minPhysicalMemory: UInt64 = 6 * 1024 * 1024 * 1024 + + static func deviceMeetsRequirements(physicalMemory: UInt64) -> Bool { + physicalMemory >= minPhysicalMemory + } + + /// Convenience for callers using the live device value. + static func deviceMeetsRequirements() -> Bool { + deviceMeetsRequirements(physicalMemory: ProcessInfo.processInfo.physicalMemory) + } +} + +/// On-disk location for downloaded model files: Application Support/OnDeviceModels, excluded from backup. +enum ModelStorage { + static func directory() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return base.appendingPathComponent("OnDeviceModels", isDirectory: true) + } + + static func fileURL(for model: BundledModel) -> URL { + directory().appendingPathComponent(model.id + ".gguf", isDirectory: false) + } + + static func isPresent(_ model: BundledModel) -> Bool { + FileManager.default.fileExists(atPath: fileURL(for: model).path) + } + + /// Create the directory if needed and mark it excluded from iCloud/iTunes backup. + static func ensureDirectory() throws { + let dir = directory() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + var values = URLResourceValues() + values.isExcludedFromBackup = true + var mutable = dir + try? mutable.setResourceValues(values) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/ModelCatalogTests` +Expected: PASS. (The placeholder sha256 is still 64 hex chars, so the format test passes; the real value is filled before merge.) + +- [ ] **Step 5: Commit** + +```bash +git add Strand/AI/OnDevice/ModelCatalog.swift StrandTests/OnDeviceModelCatalogTests.swift project.yml +git commit -m "coach: add on-device ModelCatalog + storage + device gate" +``` + +--- + +### Task 3: `ModelDownloadManager` state machine + SHA-256 verify + +**Files:** +- Create: `Strand/AI/OnDevice/ModelDownloadManager.swift` +- Test: `StrandTests/ModelDownloadManagerTests.swift` + +**Interfaces:** +- Consumes: `ModelCatalog.coach`, `ModelStorage`. +- Produces: + - `enum ModelDownloadState: Equatable { case absent; case downloading(progress: Double); case verifying; case ready; case failed(String) }` + - `protocol ModelFileFetcher { func fetch(from: URL, progress: @escaping (Double) -> Void) async throws -> URL }` (returns a temp file URL) + - `@MainActor final class ModelDownloadManager: ObservableObject { @Published var state; init(model:fetcher:); func refreshPresence(); func startDownload(); func cancel(); func deleteModel(); static func sha256Hex(ofFileAt:) -> String? }` + +- [ ] **Step 1: Write the failing test** + +Create `StrandTests/ModelDownloadManagerTests.swift`: + +```swift +import XCTest +import CryptoKit +@testable import Strand + +/// A fetcher we fully control: hands back a temp file with chosen bytes, or throws. +private final class StubFetcher: ModelFileFetcher { + var bytes: Data + var error: Error? + init(bytes: Data = Data([0x1, 0x2, 0x3]), error: Error? = nil) { self.bytes = bytes; self.error = error } + func fetch(from url: URL, progress: @escaping (Double) -> Void) async throws -> URL { + if let error { throw error } + progress(0.5); progress(1.0) + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".gguf") + try bytes.write(to: tmp) + return tmp + } +} + +@MainActor +final class ModelDownloadManagerTests: XCTestCase { + + private func model(matching bytes: Data) -> BundledModel { + let hex = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + var m = ModelCatalog.coach + return BundledModel(id: "test-model", displayName: m.displayName, url: m.url, + sha256: hex, sizeBytes: Int64(bytes.count), + contextLength: m.contextLength, chatTemplate: m.chatTemplate) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: ModelStorage.directory()) + super.tearDown() + } + + func testSuccessfulDownloadVerifiesAndBecomesReady() async { + let bytes = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let m = model(matching: bytes) + let mgr = ModelDownloadManager(model: m, fetcher: StubFetcher(bytes: bytes)) + await mgr.startDownloadAndWait() + XCTAssertEqual(mgr.state, .ready) + XCTAssertTrue(ModelStorage.isPresent(m)) + } + + func testChecksumMismatchFailsAndDeletesFile() async { + let m = model(matching: Data([0x1])) // expects hash of [0x1] + let mgr = ModelDownloadManager(model: m, fetcher: StubFetcher(bytes: Data([0x2]))) // delivers [0x2] + await mgr.startDownloadAndWait() + if case .failed = mgr.state {} else { XCTFail("expected .failed, got \(mgr.state)") } + XCTAssertFalse(ModelStorage.isPresent(m)) + } + + func testFetchErrorBecomesFailed() async { + let m = model(matching: Data([0x1])) + let mgr = ModelDownloadManager(model: m, + fetcher: StubFetcher(error: URLError(.notConnectedToInternet))) + await mgr.startDownloadAndWait() + if case .failed = mgr.state {} else { XCTFail("expected .failed") } + } + + func testDeleteReturnsToAbsent() async { + let bytes = Data([0xAB]) + let m = model(matching: bytes) + let mgr = ModelDownloadManager(model: m, fetcher: StubFetcher(bytes: bytes)) + await mgr.startDownloadAndWait() + mgr.deleteModel() + XCTAssertEqual(mgr.state, .absent) + XCTAssertFalse(ModelStorage.isPresent(m)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/ModelDownloadManagerTests` +Expected: FAIL to compile — `ModelDownloadManager`, `ModelFileFetcher`, `startDownloadAndWait` undefined. + +- [ ] **Step 3: Create the implementation** + +Create `Strand/AI/OnDevice/ModelDownloadManager.swift`: + +```swift +import Foundation +import CryptoKit +import Combine + +enum ModelDownloadState: Equatable { + case absent + case downloading(progress: Double) + case verifying + case ready + case failed(String) +} + +/// Abstraction over the actual network fetch so the state machine is unit-testable without a server. +protocol ModelFileFetcher { + /// Download `url` to a temporary file, reporting fractional progress, and return the temp URL. + func fetch(from url: URL, progress: @escaping (Double) -> Void) async throws -> URL +} + +/// Production fetcher: URLSession download with progress via a delegate. Resume support is added in the +/// live path; the protocol keeps the state machine independent of it. +final class URLSessionModelFetcher: NSObject, ModelFileFetcher, URLSessionDownloadDelegate { + private var progressHandler: ((Double) -> Void)? + private var continuation: CheckedContinuation? + private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + + func fetch(from url: URL, progress: @escaping (Double) -> Void) async throws -> URL { + self.progressHandler = progress + return try await withCheckedThrowingContinuation { cont in + self.continuation = cont + session.downloadTask(with: url).resume() + } + } + + func urlSession(_ s: URLSession, downloadTask t: URLSessionDownloadTask, + didWriteData _: Int64, totalBytesWritten w: Int64, totalBytesExpectedToWrite e: Int64) { + if e > 0 { progressHandler?(Double(w) / Double(e)) } + } + func urlSession(_ s: URLSession, downloadTask t: URLSessionDownloadTask, didFinishDownloadingTo loc: URL) { + // Move out of the delegate's temp dir immediately (it is deleted when this returns). + let dst = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".gguf") + do { try FileManager.default.moveItem(at: loc, to: dst); continuation?.resume(returning: dst) } + catch { continuation?.resume(throwing: error) } + continuation = nil + } + func urlSession(_ s: URLSession, task t: URLSessionTask, didCompleteWithError err: Error?) { + if let err { continuation?.resume(throwing: err); continuation = nil } + } +} + +/// Owns the on-device model file lifecycle: download → verify → ready, plus delete. `@MainActor` so the +/// `@Published` state drives SwiftUI directly. All decision logic is here and unit-tested via a stub fetcher. +@MainActor +final class ModelDownloadManager: ObservableObject { + @Published private(set) var state: ModelDownloadState + let model: BundledModel + private let fetcher: ModelFileFetcher + private var task: Task? + + init(model: BundledModel = ModelCatalog.coach, fetcher: ModelFileFetcher = URLSessionModelFetcher()) { + self.model = model + self.fetcher = fetcher + self.state = ModelStorage.isPresent(model) ? .ready : .absent + } + + func refreshPresence() { + if case .downloading = state { return } + if case .verifying = state { return } + state = ModelStorage.isPresent(model) ? .ready : .absent + } + + func startDownload() { task = Task { await runDownload() } } + + /// Test seam: run the download synchronously to completion. + func startDownloadAndWait() async { await runDownload() } + + func cancel() { + task?.cancel() + task = nil + state = ModelStorage.isPresent(model) ? .ready : .absent + } + + func deleteModel() { + task?.cancel(); task = nil + try? FileManager.default.removeItem(at: ModelStorage.fileURL(for: model)) + state = .absent + } + + private func runDownload() async { + state = .downloading(progress: 0) + do { + let tmp = try await fetcher.fetch(from: model.url) { [weak self] p in + Task { @MainActor in + guard let self else { return } + if case .downloading = self.state { self.state = .downloading(progress: p) } + } + } + try Task.checkCancellation() + state = .verifying + guard let hex = Self.sha256Hex(ofFileAt: tmp), hex == model.sha256.lowercased() else { + try? FileManager.default.removeItem(at: tmp) + state = .failed("Downloaded file failed integrity check. Delete and retry.") + return + } + try ModelStorage.ensureDirectory() + let dst = ModelStorage.fileURL(for: model) + try? FileManager.default.removeItem(at: dst) + try FileManager.default.moveItem(at: tmp, to: dst) + state = .ready + } catch is CancellationError { + state = ModelStorage.isPresent(model) ? .ready : .absent + } catch { + state = .failed(error.localizedDescription) + } + } + + /// Stream a file through SHA-256 so a 2 GB model is never fully resident in memory. + static func sha256Hex(ofFileAt url: URL) -> String? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + var hasher = SHA256() + while autoreleasepool(invoking: { + let chunk = (try? handle.read(upToCount: 1 << 20)) ?? nil + guard let chunk, !chunk.isEmpty else { return false } + hasher.update(data: chunk) + return true + }) {} + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/ModelDownloadManagerTests` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add Strand/AI/OnDevice/ModelDownloadManager.swift StrandTests/ModelDownloadManagerTests.swift +git commit -m "coach: add ModelDownloadManager with SHA-256 verify" +``` + +--- + +### Task 4: Streaming protocol method + adapting default + +**Files:** +- Modify: `Strand/AI/AIProvider.swift` (the `AIProviderClient` protocol + a new extension) +- Test: `StrandTests/AICoachStreamingTests.swift` (create) + +**Interfaces:** +- Produces: on `AIProviderClient`: + - `func stream(key: String, model: String, systemPrompt: String, messages: [(role: ChatMessage.Role, content: String)], session: URLSession) -> AsyncThrowingStream` + - a protocol-extension default that wraps `send(...)` into exactly one yielded chunk. + +- [ ] **Step 1: Write the failing test** + +Create `StrandTests/AICoachStreamingTests.swift`: + +```swift +import XCTest +import Foundation +@testable import Strand + +/// A client whose send() returns a canned string, to prove the default stream() yields it as one chunk. +private struct OneShotClient: AIProviderClient { + let reply: String + func send(key: String, model: String, systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], session: URLSession) async throws -> String { + reply + } + func fetchModels(key: String, session: URLSession) async throws -> [String] { [] } +} + +final class StreamingAdapterTests: XCTestCase { + func testDefaultStreamYieldsWholeReplyAsOneChunk() async throws { + let client = OneShotClient(reply: "Hello world") + var chunks: [String] = [] + for try await c in client.stream(key: "", model: "m", systemPrompt: "s", + messages: [(.user, "hi")], session: .shared) { + chunks.append(c) + } + XCTAssertEqual(chunks, ["Hello world"]) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/StreamingAdapterTests` +Expected: FAIL to compile — `stream(...)` is not a member of `AIProviderClient`. + +- [ ] **Step 3: Add the protocol method + default** + +In `Strand/AI/AIProvider.swift`, add to the `AIProviderClient` protocol: + +```swift + /// Stream a chat turn as incremental text chunks. Cloud clients inherit the default below (one + /// chunk); only the on-device client overrides this to emit true token-by-token output. + func stream( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession + ) -> AsyncThrowingStream +``` + +Add a new extension below the protocol: + +```swift +extension AIProviderClient { + func stream( + key: String, + model: String, + systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let full = try await send(key: key, model: model, systemPrompt: systemPrompt, + messages: messages, session: session) + continuation.yield(full) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/StreamingAdapterTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Strand/AI/AIProvider.swift StrandTests/AICoachStreamingTests.swift +git commit -m "coach: add streaming method to AIProviderClient with wrapping default" +``` + +--- + +### Task 5: `AICoachEngine.sendStreaming` + `stop()` + `ChatMessage.text` mutable + +**Files:** +- Modify: `Strand/AI/AICoach.swift` (`ChatMessage` struct; `AICoachEngine` — add `sendStreaming`, `stop`, a `genTask` handle) +- Test: `StrandTests/AICoachStreamingTests.swift` (append) + +**Interfaces:** +- Consumes: `AIProviderClient.stream(...)` (Task 4); `ChatMessage`. +- Produces: `AICoachEngine.sendStreaming(_ userText: String) async` (appends a user turn, then an empty assistant turn whose `.text` grows as chunks arrive); `AICoachEngine.stop()`; `ChatMessage.text` becomes `var`. + +**Note on testability:** `sendStreaming` must be drivable with an injected client. If `AICoachEngine` currently always resolves `provider.client`, add a test-only override seam mirroring the existing `#if DEBUG fetchModelsOverride` pattern: + +```swift +#if DEBUG +var streamOverride: ((_ wire: [(role: ChatMessage.Role, content: String)]) -> AsyncThrowingStream)? +#endif +``` + +- [ ] **Step 1: Write the failing test** + +Append to `StrandTests/AICoachStreamingTests.swift`: + +```swift +@MainActor +final class SendStreamingTests: XCTestCase { + func testStreamingAccumulatesChunksIntoOneAssistantMessage() async { + let engine = AICoachEngine(repo: .previewEmpty) // see note below + #if DEBUG + engine.streamOverride = { _ in + AsyncThrowingStream { c in + c.yield("He"); c.yield("llo"); c.finish() + } + } + #endif + await engine.sendStreaming("hi") + XCTAssertEqual(engine.messages.last?.role, .assistant) + XCTAssertEqual(engine.messages.last?.text, "Hello") + XCTAssertFalse(engine.sending) + } +} +``` + +If a `Repository` test double is not readily available, this test may construct the engine via the same helper the existing `AICoachPromptAndStressTests` uses (inspect that file for the pattern — reuse it rather than inventing `.previewEmpty`). The assertion set stays identical. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/SendStreamingTests` +Expected: FAIL to compile — `sendStreaming`, `streamOverride` undefined. + +- [ ] **Step 3: Implement** + +In `Strand/AI/AICoach.swift`, change `ChatMessage`: + +```swift +struct ChatMessage: Identifiable, Equatable { + enum Role: String { case user, assistant } + let id: UUID + let role: Role + var text: String // was `let` — streaming mutates this in place + + init(id: UUID = UUID(), role: Role, text: String) { + self.id = id + self.role = role + self.text = text + } +} +``` + +Add to `AICoachEngine` (near `send`): + +```swift + private var genTask: Task? + + #if DEBUG + /// Test seam: stand in for the provider's streaming call. Production leaves this nil. + var streamOverride: ((_ wire: [(role: ChatMessage.Role, content: String)]) -> AsyncThrowingStream)? + #endif + + /// Streaming send: append the user turn, build context, append an empty assistant turn, then grow + /// its text as chunks arrive. Uses `stream(...)` for EVERY provider — cloud providers resolve to one + /// chunk, the on-device provider streams token-by-token. Never throws; failures land in `errorText`. + func sendStreaming(_ userText: String) async { + let trimmed = userText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { errorText = AICoachError.emptyQuestion.errorDescription; return } + guard let key = resolvedKey else { errorText = AICoachError.noKey.errorDescription; return } + + errorText = nil + messages.append(ChatMessage(role: .user, text: trimmed)) + sending = true + defer { sending = false } + + let context = dataConsent ? await buildFullContext() : noConsentNote + let wire = wireMessages(context: context) + + let assistantId = UUID() + messages.append(ChatMessage(id: assistantId, role: .assistant, text: "")) + + let stream: AsyncThrowingStream + #if DEBUG + if let streamOverride { stream = streamOverride(wire) } + else { stream = provider.client.stream(key: key, model: model, systemPrompt: systemPrompt, messages: wire, session: session) } + #else + stream = provider.client.stream(key: key, model: model, systemPrompt: systemPrompt, messages: wire, session: session) + #endif + + let handle = Task { @MainActor in + do { + for try await chunk in stream { + if let idx = messages.firstIndex(where: { $0.id == assistantId }) { + messages[idx].text += chunk + } + } + } catch let e as AICoachError { + errorText = e.errorDescription + } catch is CancellationError { + // user pressed Stop — keep whatever streamed so far + } catch { + errorText = AICoachError.network(error.localizedDescription).errorDescription + } + // Drop an empty assistant bubble if nothing arrived and there's an error to show instead. + if let idx = messages.firstIndex(where: { $0.id == assistantId }), + messages[idx].text.isEmpty { + messages.remove(at: idx) + } + } + genTask = handle + await handle.value + genTask = nil + } + + /// Cancel an in-flight streaming generation (Stop button). Safe to call when idle. + func stop() { + genTask?.cancel() + genTask = nil + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/SendStreamingTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Strand/AI/AICoach.swift StrandTests/AICoachStreamingTests.swift +git commit -m "coach: add sendStreaming + stop() with mutable ChatMessage.text" +``` + +--- + +### Task 6: `AIProvider.onDevice` case, platform-gated availability & default, on-device `isConfigured` + +**Files:** +- Modify: `Strand/AI/AIProvider.swift` (enum case + `displayName`/`defaultModel`/`modelOptions`/`endpoint`/`modelsEndpoint`/`client`, add `available` + `defaultProvider`) +- Modify: `Strand/AI/AICoach.swift` (`AICoachEngine` owns `ModelDownloadManager`; `isConfigured` on-device branch; default provider) +- Test: `StrandTests/OnDeviceProviderGatingTests.swift` (create) + +**Interfaces:** +- Consumes: `ModelDownloadManager` (Task 3), `ModelCatalog`. +- Produces: `AIProvider.onDevice`; `AIProvider.available: [AIProvider]`; `AIProvider.defaultProvider: AIProvider`; `AICoachEngine.modelDownloads: ModelDownloadManager`; `isConfigured` returns true for `.onDevice` iff `modelDownloads.state == .ready`. + +- [ ] **Step 1: Write the failing test** + +Create `StrandTests/OnDeviceProviderGatingTests.swift`: + +```swift +import XCTest +@testable import Strand + +@MainActor +final class OnDeviceProviderGatingTests: XCTestCase { + func testOnDeviceIsFirstCaseAndInAllCases() { + XCTAssertEqual(AIProvider.allCases.first, .onDevice) + } + + func testIsConfiguredTracksDownloadReadiness() { + let engine = AICoachEngine(repo: .previewEmpty) // reuse existing test helper if different + engine.provider = .onDevice + // Fresh install: model absent → not configured. + engine.modelDownloads.setStateForTesting(.absent) + XCTAssertFalse(engine.isConfigured) + engine.modelDownloads.setStateForTesting(.ready) + XCTAssertTrue(engine.isConfigured) + } + + #if os(iOS) + func testDefaultProviderIsOnDeviceOniOS() { + XCTAssertEqual(AIProvider.defaultProvider, .onDevice) + } + #else + func testOnDeviceHiddenFromPickerOnMac() { + XCTAssertFalse(AIProvider.available.contains(.onDevice)) + XCTAssertEqual(AIProvider.defaultProvider, .openAI) + } + #endif +} +``` + +Add a tiny test seam to `ModelDownloadManager` (guarded so it never ships in a way that matters): + +```swift + #if DEBUG + func setStateForTesting(_ s: ModelDownloadState) { state = s } + #endif +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/OnDeviceProviderGatingTests` +Expected: FAIL to compile — `.onDevice`, `available`, `defaultProvider`, `modelDownloads`, `setStateForTesting` undefined. + +- [ ] **Step 3: Implement enum + engine wiring** + +In `Strand/AI/AIProvider.swift`, add `onDevice` as the FIRST case: + +```swift +enum AIProvider: String, CaseIterable, Identifiable { + case onDevice + case openAI + case anthropic + case gemini + case custom +``` + +Extend each switch with `.onDevice`: + +```swift + var displayName: String { + switch self { + case .onDevice: return "On-device (no setup, fully private)" + ... + var defaultModel: String { + switch self { + case .onDevice: return ModelCatalog.coach.id + ... + var modelOptions: [String] { + switch self { + case .onDevice: return [ModelCatalog.coach.id] + ... + var endpoint: URL { + switch self { + case .onDevice: return URL(string: "file:///on-device")! // unused; inference is in-process + ... + var modelsEndpoint: URL { + switch self { + case .onDevice: return URL(string: "file:///on-device")! // unused + ... + var client: any AIProviderClient { + switch self { + case .onDevice: + #if os(iOS) + return OnDeviceClient.shared + #else + return UnavailableOnDeviceClient() + #endif + ... +``` + +Add the platform-gated availability + default, and the macOS stub client, at the bottom of the file: + +```swift +extension AIProvider { + /// Providers shown in the picker. The on-device provider is iOS-only; macOS keeps the cloud set. + static var available: [AIProvider] { + #if os(iOS) + return allCases + #else + return allCases.filter { $0 != .onDevice } + #endif + } + + /// The provider a fresh install starts on: on-device (zero-setup) on iOS, OpenAI on macOS. + static var defaultProvider: AIProvider { + #if os(iOS) + return .onDevice + #else + return .openAI + #endif + } +} + +#if !os(iOS) +/// macOS stand-in so `AIProvider.onDevice.client` type-checks in the shared enum. Never selectable on +/// macOS (filtered out of `available`); if somehow invoked it fails clearly rather than doing anything. +struct UnavailableOnDeviceClient: AIProviderClient { + func send(key: String, model: String, systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], session: URLSession) async throws -> String { + throw AICoachError.deviceUnsupported + } + func fetchModels(key: String, session: URLSession) async throws -> [String] { [ModelCatalog.coach.id] } +} +#endif +``` + +In `Strand/AI/AICoach.swift` `AICoachEngine`: + +1. Add the property and construct it in `init`: + +```swift + /// Owns the on-device model file lifecycle (download/verify/delete). Drives the on-device setup card + /// and gates `isConfigured` for the on-device provider. + let modelDownloads = ModelDownloadManager() +``` + +2. Change the default provider fallback in `init` from `?? .openAI` to `?? AIProvider.defaultProvider`. + +3. Update `isConfigured`: + +```swift + var isConfigured: Bool { + switch provider { + case .onDevice: return modelDownloads.state == .ready + case .custom: return customConnected + default: return hasKey + } + } +``` + +4. Update `resolvedKey` so the on-device provider needs no key (like `.custom`): in the final `return`, treat `.onDevice` the same as `.custom` — return `""` when no stored key applies: + +```swift + return (provider == .custom || provider == .onDevice) ? "" : nil +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test -only-testing:StrandTests/OnDeviceProviderGatingTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Strand/AI/AIProvider.swift Strand/AI/AICoach.swift StrandTests/OnDeviceProviderGatingTests.swift Strand/AI/OnDevice/ModelDownloadManager.swift +git commit -m "coach: add onDevice provider case, platform gating, download-aware isConfigured" +``` + +--- + +### Task 7: Vendor llama.cpp as a pinned SPM binary xcframework + +**Files:** +- Create: `Packages/LlamaCpp/Package.swift` +- Modify: `project.yml` (add `LlamaCpp` package; add it to `NOOPiOS` dependencies; exclude the two native files from the macOS `Strand` target) + +**Interfaces:** +- Produces: an importable `llama` module for iOS device + simulator, available only to `NOOPiOS`. + +**Background:** llama.cpp's CI publishes a prebuilt `llama-b-xcframework.zip` per release. We pin one by URL + SHA-256 via an SPM `binaryTarget`. This matches the repo's "pin exact, no committed binaries" convention: our `Package.swift` is original; the artifact is fetched and checksum-verified by SPM. + +- [ ] **Step 1: Determine the pinned artifact + checksum** + +Choose a recent stable llama.cpp release that publishes an xcframework asset. Compute the SwiftPM checksum: + +```bash +curl -L -o llama.xcframework.zip \ + https://github.com/ggml-org/llama.cpp/releases/download/b/llama-b-xcframework.zip +swift package compute-checksum llama.xcframework.zip +``` + +Record the URL and the printed checksum for Step 2. + +- [ ] **Step 2: Create the local package** + +Create `Packages/LlamaCpp/Package.swift`: + +```swift +// swift-tools-version:5.9 +import PackageDescription + +// Wraps the pinned llama.cpp prebuilt xcframework. URL + checksum are pinned EXACTLY (supply-chain: +// a clean resolve can't pull a different artifact). To bump llama.cpp, update BOTH fields together. +let package = Package( + name: "LlamaCpp", + platforms: [.iOS(.v17)], + products: [.library(name: "LlamaCpp", targets: ["LlamaCpp"])], + targets: [ + .binaryTarget( + name: "llama", + url: "https://github.com/ggml-org/llama.cpp/releases/download/b/llama-b-xcframework.zip", + checksum: "" + ), + .target(name: "LlamaCpp", dependencies: ["llama"], path: "Sources/LlamaCpp") + ] +) +``` + +Create `Packages/LlamaCpp/Sources/LlamaCpp/Exports.swift`: + +```swift +// Re-export the binary module so app code writes `import LlamaCpp`. +@_exported import llama +``` + +- [ ] **Step 3: Wire into project.yml** + +In `project.yml` `packages:` add: + +```yaml + # Prebuilt llama.cpp xcframework (Metal) for the on-device Coach. Local package pins the binary + # artifact by URL + checksum (see Packages/LlamaCpp/Package.swift). iOS-only. + LlamaCpp: + path: Packages/LlamaCpp +``` + +In the `NOOPiOS` target `dependencies:` add: + +```yaml + - package: LlamaCpp +``` + +In the macOS `Strand` target `sources:` `excludes:` list, add the two native files (they must NOT compile on macOS): + +```yaml + - "AI/OnDevice/LlamaEngine.swift" + - "AI/OnDevice/OnDeviceClient.swift" +``` + +- [ ] **Step 4: Verify both targets still build** + +Run: +```bash +xcodegen generate +xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project Strand.xcodeproj -scheme NOOPiOS -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` +Expected: both compile. (The native files don't exist yet — that's fine; the excludes and package resolve are what's being verified. If the iOS build fails to *resolve* `LlamaCpp`, fix the URL/checksum before proceeding.) + +- [ ] **Step 5: Commit** + +```bash +git add Packages/LlamaCpp/Package.swift Packages/LlamaCpp/Sources/LlamaCpp/Exports.swift project.yml +git commit -m "coach: vendor pinned llama.cpp xcframework for iOS on-device coach" +``` + +--- + +### Task 8: `LlamaEngine` actor (iOS-only, device-verified) + +**Files:** +- Create: `Strand/AI/OnDevice/LlamaEngine.swift` + +**Interfaces:** +- Consumes: `import LlamaCpp`, `BundledModel`. +- Produces: + - `actor LlamaEngine { static let shared: LlamaEngine; func load(modelURL: URL, model: BundledModel) async throws; func unload(); func generate(systemPrompt: String, messages: [(role: ChatMessage.Role, content: String)]) -> AsyncStream }` + +**Important:** This file wraps the llama.cpp C API. Exact symbol names track the pinned llama.cpp `b` release; verify against its headers. There is **no CI unit test** for this file — it is validated by the device smoke test in Task 12. Keep it minimal and defensive (no force-unwraps around C pointers; every failure path returns/throws a Swift error). + +- [ ] **Step 1: Implement the actor** + +Create `Strand/AI/OnDevice/LlamaEngine.swift` (the whole file is one step — it is not unit-tested, so there is no red/green cycle; correctness is proven on device in Task 12): + +```swift +import Foundation +import LlamaCpp + +/// In-process llama.cpp wrapper. An actor so the C context is never touched concurrently. iOS-only. +actor LlamaEngine { + static let shared = LlamaEngine() + + private var model: OpaquePointer? + private var ctx: OpaquePointer? + private var vocab: OpaquePointer? + private var loadedModel: BundledModel? + + /// Load a GGUF into a llama context with Metal enabled. Idempotent: reloads only when the target + /// differs from what's loaded. Throws `AICoachError.modelLoadFailed` on any C failure. + func load(modelURL: URL, model: BundledModel) async throws { + if loadedModel?.id == model.id, ctx != nil { return } + unload() + + llama_backend_init() + + var mparams = llama_model_default_params() + mparams.n_gpu_layers = -1 // all layers on the Metal GPU + guard let m = llama_model_load_from_file(modelURL.path, mparams) else { + throw AICoachError.modelLoadFailed("could not open \(model.id)") + } + + var cparams = llama_context_default_params() + cparams.n_ctx = UInt32(model.contextLength) + cparams.n_batch = 512 + guard let c = llama_init_from_model(m, cparams) else { + llama_model_free(m) + throw AICoachError.modelLoadFailed("could not create context") + } + + self.model = m + self.ctx = c + self.vocab = llama_model_get_vocab(m) + self.loadedModel = model + } + + /// Free the context + model. Safe to call when nothing is loaded. + func unload() { + if let ctx { llama_free(ctx) } + if let model { llama_model_free(model) } + ctx = nil; model = nil; vocab = nil; loadedModel = nil + } + + var isLoaded: Bool { ctx != nil } + + /// Generate a reply, yielding detokenized text pieces as they are produced. Stops on EOS, the + /// context limit, `maxTokens`, or Task cancellation. Applies the model's chat template. + func generate(systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)]) -> AsyncStream { + AsyncStream { continuation in + let task = Task { + guard let ctx = self.ctx, let vocab = self.vocab, let model = self.model else { + continuation.finish(); return + } + let maxTokens = 512 + + // 1. Build the prompt via the model's chat template. + let prompt = self.applyTemplate(model: model, systemPrompt: systemPrompt, messages: messages) + + // 2. Tokenize. + var tokens = self.tokenize(vocab: vocab, text: prompt, addBOS: true) + guard !tokens.isEmpty else { continuation.finish(); return } + + // 3. Prefill. + var batch = llama_batch_get_one(&tokens, Int32(tokens.count)) + if llama_decode(ctx, batch) != 0 { continuation.finish(); return } + + // 4. Sampler chain (greedy-ish: top-k / top-p / temp). Freed at the end. + let sampler = llama_sampler_chain_init(llama_sampler_chain_default_params()) + llama_sampler_chain_add(sampler, llama_sampler_init_top_k(40)) + llama_sampler_chain_add(sampler, llama_sampler_init_top_p(0.95, 1)) + llama_sampler_chain_add(sampler, llama_sampler_init_temp(0.7)) + llama_sampler_chain_add(sampler, llama_sampler_init_dist(LLAMA_DEFAULT_SEED)) + defer { llama_sampler_free(sampler) } + + var generated = 0 + while generated < maxTokens { + if Task.isCancelled { break } + let next = llama_sampler_sample(sampler, ctx, -1) + if llama_vocab_is_eog(vocab, next) { break } + + if let piece = self.pieceToString(vocab: vocab, token: next), !piece.isEmpty { + continuation.yield(piece) + } + var one = next + batch = llama_batch_get_one(&one, 1) + if llama_decode(ctx, batch) != 0 { break } + generated += 1 + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - C helpers (exact symbols track the pinned llama.cpp release; verify vs its headers) + + private func applyTemplate(model: OpaquePointer, systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)]) -> String { + var chat: [llama_chat_message] = [] + var cStrings: [UnsafeMutablePointer] = [] + func cstr(_ s: String) -> UnsafeMutablePointer { let p = strdup(s)!; cStrings.append(p); return p } + chat.append(llama_chat_message(role: cstr("system"), content: cstr(systemPrompt))) + for m in messages { chat.append(llama_chat_message(role: cstr(m.role.rawValue), content: cstr(m.content))) } + defer { cStrings.forEach { free($0) } } + + let tmpl = llama_model_chat_template(model, nil) + var buf = [CChar](repeating: 0, count: 32_768) + let n = llama_chat_apply_template(tmpl, &chat, chat.count, true, &buf, Int32(buf.count)) + if n <= 0 { return systemPrompt + "\n\n" + (messages.last?.content ?? "") } + return String(cString: buf) + } + + private func tokenize(vocab: OpaquePointer, text: String, addBOS: Bool) -> [llama_token] { + let utf8 = Array(text.utf8CString) + let cap = Int32(utf8.count + 8) + var out = [llama_token](repeating: 0, count: Int(cap)) + let n = llama_tokenize(vocab, text, Int32(strlen(text)), &out, cap, addBOS, true) + if n < 0 { return [] } + return Array(out.prefix(Int(n))) + } + + private func pieceToString(vocab: OpaquePointer, token: llama_token) -> String? { + var buf = [CChar](repeating: 0, count: 256) + let n = llama_token_to_piece(vocab, token, &buf, Int32(buf.count), 0, true) + if n <= 0 { return nil } + return buf.withUnsafeBufferPointer { p in + String(decoding: UnsafeRawBufferPointer(start: p.baseAddress, count: Int(n)), as: UTF8.self) + } + } +} +``` + +- [ ] **Step 2: Verify it compiles for iOS** + +Run: +```bash +xcodegen generate +xcodebuild -project Strand.xcodeproj -scheme NOOPiOS -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` +Expected: compiles. If any llama.cpp symbol name mismatches the pinned release, fix it against that release's `llama.h` (the wrappers are intentionally thin, so fixes are local). + +- [ ] **Step 3: Commit** + +```bash +git add Strand/AI/OnDevice/LlamaEngine.swift +git commit -m "coach: add LlamaEngine actor wrapping llama.cpp (iOS)" +``` + +--- + +### Task 9: `OnDeviceClient` conforming to `AIProviderClient` (iOS-only) + +**Files:** +- Create: `Strand/AI/OnDevice/OnDeviceClient.swift` + +**Interfaces:** +- Consumes: `LlamaEngine.shared`, `ModelCatalog`, `ModelStorage`, `AIProviderClient`. +- Produces: `struct OnDeviceClient: AIProviderClient { static let shared: OnDeviceClient }` overriding `stream(...)` with real token streaming, plus `send(...)` (concatenates the stream) and `fetchModels(...)` (the single catalog id). + +- [ ] **Step 1: Implement** + +Create `Strand/AI/OnDevice/OnDeviceClient.swift` (not CI-unit-tested — exercised on device in Task 12): + +```swift +import Foundation + +/// The AIProviderClient backed by the in-process llama.cpp engine. Ensures the pinned model is present +/// and loaded, then streams tokens. No network, no key. +struct OnDeviceClient: AIProviderClient { + static let shared = OnDeviceClient() + + private var model: BundledModel { ModelCatalog.coach } + + /// Real token streaming: load-if-needed then relay the engine's AsyncStream as chunks. + func stream(key: String, model modelId: String, systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], + session: URLSession) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + let fileURL = ModelStorage.fileURL(for: model) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + continuation.finish(throwing: AICoachError.modelNotDownloaded); return + } + do { + try await LlamaEngine.shared.load(modelURL: fileURL, model: model) + } catch { + continuation.finish(throwing: error); return + } + for await piece in await LlamaEngine.shared.generate(systemPrompt: systemPrompt, messages: messages) { + if Task.isCancelled { break } + continuation.yield(piece) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Non-streaming path (kept for protocol completeness): drain the stream into one string. + func send(key: String, model modelId: String, systemPrompt: String, + messages: [(role: ChatMessage.Role, content: String)], session: URLSession) async throws -> String { + var out = "" + for try await piece in stream(key: key, model: modelId, systemPrompt: systemPrompt, + messages: messages, session: session) { out += piece } + return out + } + + func fetchModels(key: String, session: URLSession) async throws -> [String] { [model.id] } +} +``` + +- [ ] **Step 2: Verify iOS build** + +Run: +```bash +xcodegen generate +xcodebuild -project Strand.xcodeproj -scheme NOOPiOS -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build +``` +Expected: iOS build links `OnDeviceClient.shared` in `AIProvider.client`; macOS build still compiles (uses `UnavailableOnDeviceClient`). + +- [ ] **Step 3: Commit** + +```bash +git add Strand/AI/OnDevice/OnDeviceClient.swift +git commit -m "coach: add OnDeviceClient streaming via LlamaEngine (iOS)" +``` + +--- + +### Task 10: CoachView — on-device setup card, streaming send, Stop button, privacy copy + +**Files:** +- Modify: `Strand/Screens/CoachView.swift` + +**Interfaces:** +- Consumes: `coach.provider`, `coach.isConfigured`, `coach.modelDownloads` (`state`, `startDownload()`, `cancel()`, `deleteModel()`), `coach.sendStreaming(_:)`, `coach.stop()`, `ModelCatalog.coach`, `ModelCatalog.deviceMeetsRequirements()`, `AIProvider.available`. + +**Note:** `CoachView` is shared by macOS + iOS. The on-device card is only reachable when `provider == .onDevice`, which macOS filters out of `available`, so the card is effectively iOS-only at runtime while the code compiles on both. Reference `coach.modelDownloads` (compiles on both since `ModelDownloadManager` is cross-platform). + +- [ ] **Step 1: Point the picker at `AIProvider.available`** + +Find the provider Picker (around line 224): + +```swift + Picker("Provider", selection: $coach.provider) { + ForEach(AIProvider.allCases) { p in +``` + +Change `AIProvider.allCases` → `AIProvider.available`. + +- [ ] **Step 2: Add the on-device setup card branch** + +In the setup card body, add a branch that renders when `coach.provider == .onDevice` INSTEAD of the API-key field (mirror how `provider == .custom` branches today, around lines 235-280). Insert: + +```swift + if coach.provider == .onDevice { + onDeviceSetupSection + } else if coach.provider == .custom { + // …existing custom URL field… + } +``` + +Add the section as a computed view on `CoachView`: + +```swift + @ViewBuilder + private var onDeviceSetupSection: some View { + let m = ModelCatalog.coach + VStack(alignment: .leading, spacing: 10) { + if !ModelCatalog.deviceMeetsRequirements() { + Text("This \(Platform.deviceNounPhrase) doesn't have enough memory to run the on-device coach. Pick a cloud provider above instead.") + .strandCaption() + } else { + switch coach.modelDownloads.state { + case .absent, .failed: + Text("\(m.displayName) runs entirely on your \(Platform.deviceNounPhrase). One-time download over Wi-Fi (~\(byteString(m.sizeBytes))). After that, coaching works with no internet.") + .strandCaption() + if case .failed(let msg) = coach.modelDownloads.state { + Text(msg).strandCaption().foregroundStyle(.red) + } + NoopButton("Download coach model", systemImage: "arrow.down.circle", + kind: .primary) { coach.modelDownloads.startDownload() } + case .downloading(let p): + ProgressView(value: p) { Text("Downloading… \(Int(p * 100))%").strandCaption() } + NoopButton("Cancel", systemImage: "xmark", kind: .secondary) { coach.modelDownloads.cancel() } + case .verifying: + ProgressView { Text("Verifying…").strandCaption() } + case .ready: + StatePill("Model ready", tone: .accent, showsDot: true) + NoopButton("Delete model (free \(byteString(m.sizeBytes)))", systemImage: "trash", + kind: .secondary) { coach.modelDownloads.deleteModel() } + } + } + } + } + + private func byteString(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } +``` + +(Use whatever the file's existing caption/pill helpers are — `strandCaption`, `StatePill`, `NoopButton` are already used in this file. Match them.) + +- [ ] **Step 3: Route sending through `sendStreaming` and add a Stop button** + +Find where the composer calls `coach.send(...)` (the send action). Change it to `await coach.sendStreaming(text)`. Where the send button shows a spinner while `coach.sending`, add a Stop affordance: + +```swift + if coach.sending { + NoopButton("Stop", systemImage: "stop.fill", kind: .secondary) { coach.stop() } + } +``` + +Streaming render needs no extra work: the assistant `ChatMessage.text` mutates in place and the message list re-renders. + +- [ ] **Step 4: On-device privacy copy** + +Find the bottom privacy line (around lines 570-575, the `coach.provider == .custom ? … : …` ternary). Add an on-device case so it reads: + +```swift + Text(coach.provider == .onDevice + ? "On-device coaching never leaves your \(Platform.deviceNounPhrase) — your metrics are read and answered locally." + : (coach.provider == .custom + ? /* existing custom text */ + : /* existing cloud text */)) +``` + +- [ ] **Step 5: Verify both builds compile** + +Run: +```bash +xcodegen generate +xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project Strand.xcodeproj -scheme NOOPiOS -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` +Expected: both compile. + +- [ ] **Step 6: Commit** + +```bash +git add Strand/Screens/CoachView.swift +git commit -m "coach: on-device setup card, streaming send, Stop button, privacy copy" +``` + +--- + +### Task 11: Entitlement, memory-pressure unload, background unload + +**Files:** +- Modify: `project.yml` (`NOOPiOS` entitlements) +- Modify: `Strand/AI/AICoach.swift` (memory-pressure + background-unload wiring in `AICoachEngine`) + +**Interfaces:** +- Consumes: `LlamaEngine.shared` (iOS), `NotificationCenter` app lifecycle, `DispatchSource` memory pressure. +- Produces: `AICoachEngine` unloads the model on memory pressure (critical) while idle and on backgrounding (default ON). + +- [ ] **Step 1: Add the increased-memory entitlement** + +In `project.yml`, `NOOPiOS` → `entitlements` → `properties`, add: + +```yaml + com.apple.developer.kernel.increased-memory-limit: true +``` + +- [ ] **Step 2: Add memory-pressure + background unload to the engine** + +In `Strand/AI/AICoach.swift`, add to `AICoachEngine` (iOS-guarded so macOS is untouched): + +```swift + #if os(iOS) + private var memoryPressureSource: DispatchSourceMemoryPressure? + + /// Free the model under critical memory pressure (only when idle) and on backgrounding, so the + /// coach is never the top jetsam target. Reloads lazily on the next generation. Call once from init. + func installMemoryGuards() { + let src = DispatchSource.makeMemoryPressureSource(eventMask: .critical, queue: .main) + src.setEventHandler { [weak self] in + guard let self, !self.sending else { return } + Task { await LlamaEngine.shared.unload() } + } + src.resume() + memoryPressureSource = src + + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main + ) { [weak self] _ in + guard let self, !self.sending else { return } + Task { await LlamaEngine.shared.unload() } + } + } + #endif +``` + +Call `installMemoryGuards()` at the end of `init` under `#if os(iOS)`. Add `import UIKit` guarded by `#if canImport(UIKit)` at the top of the file if not present. + +- [ ] **Step 3: Verify builds** + +Run: +```bash +xcodegen generate +xcodebuild -project Strand.xcodeproj -scheme NOOPiOS -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build +``` +Expected: both compile. + +- [ ] **Step 4: Run the full macOS test suite (no regressions)** + +Run: `xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test` +Expected: all tests pass, including the new on-device suites. + +- [ ] **Step 5: Commit** + +```bash +git add project.yml Strand/AI/AICoach.swift +git commit -m "coach: increased-memory entitlement + memory-pressure/background model unload" +``` + +--- + +### Task 12: Device verification + docs + +**Files:** +- Modify: `noop/CLAUDE.md` (document the on-device coach + how to change the model) +- Create: `docs/superpowers/plans/2026-07-09-on-device-coach-device-checklist.md` (verification record) + +**This task has NO CI test** — it is the physical-device verification the pure tests can't cover. It must be run on a real iPhone (BLE/Metal/memory), not the simulator. + +- [ ] **Step 1: Fill the real model checksum** + +Download the pinned GGUF, compute its SHA-256, and replace the placeholder in `ModelCatalog.coach.sha256`: + +```bash +curl -L "" -o coach.gguf +shasum -a 256 coach.gguf +``` +Commit the real checksum. **This is required before the download can ever succeed** (a placeholder guarantees a verify failure). + +- [ ] **Step 2: On-device smoke test** + +On a physical iPhone (≥6 GB RAM) with a source build: +1. Open Coach → provider defaults to On-device → tap "Download coach model" → progress → verifying → **Model ready**. +2. Ask a question with data consent ON → reply **streams** token-by-token. +3. Tap **Stop** mid-reply → generation halts, partial text retained. +4. "Today's brief" runs on first open. +5. Background the app during idle → reopen → next question still works (lazy reload). +6. Instruments (Allocations/Memory): note peak memory; confirm no jetsam. If it jetsams, raise `ModelCatalog.minPhysicalMemory` and/or revisit the entitlement. +7. Delete model → returns to the download prompt; ~2 GB reclaimed. +8. Toggle to a cloud provider → still works (cloud path unchanged, now via the streaming default = one chunk). + +Record measured tokens/sec and peak memory in the checklist doc. + +- [ ] **Step 3: Confirm / adjust the RAM gate** + +Based on Step 2 measurements, confirm `minPhysicalMemory = 6 GB` (or adjust). Commit any change. + +- [ ] **Step 4: Update CLAUDE.md** + +Add a short subsection under the coach/AI area of `noop/CLAUDE.md` documenting: the on-device provider is iOS-only, in-process llama.cpp, first-run download (pinned URL + SHA-256 in `ModelCatalog`), how to change the model (update every `BundledModel` field + the `LlamaCpp` package pin), and that inference is 100% offline (only the one-time weight download uses the network). + +- [ ] **Step 5: Commit** + +```bash +git add Strand/AI/OnDevice/ModelCatalog.swift noop/CLAUDE.md docs/superpowers/plans/2026-07-09-on-device-coach-device-checklist.md +git commit -m "coach: pin real model checksum + document on-device coach; device verification" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Fifth `.onDevice` provider via `AIProviderClient` → Tasks 6, 9. ✓ +- llama.cpp in-process, Metal → Tasks 7, 8. ✓ +- ~3B Q4 GGUF, Llama-3.2-3B-Instruct pinned → Task 2 (`ModelCatalog`). ✓ +- First-run download, SHA-256 verify, resume, excluded-from-backup, delete → Tasks 2, 3. ✓ +- Streaming token-by-token + uniform path for cloud → Tasks 4, 5. ✓ +- Default provider + front-and-center positioning + setup card + Stop + privacy copy → Tasks 6, 10. ✓ +- Memory: increased-memory entitlement, RAM gate, lazy load, memory-pressure unload, background unload → Tasks 2, 11. ✓ +- Error handling via extended `AICoachError` → Task 1. ✓ +- Testing: pure units in CI (catalog/gate, download state machine, verifier, streaming adapter, sendStreaming, isConfigured truth table); native device-verified → Tasks 1-6, 12. ✓ +- Reuse of context/consent/chat/UI unchanged → guaranteed by conforming to `AIProviderClient` and only branching the setup card. ✓ +- macOS + `StrandTests` stay green (native excluded, platform-guarded) → Tasks 6, 7. ✓ + +**Placeholder scan:** the only intentional placeholder is `ModelCatalog.coach.sha256` (64 zeros) and the `b` release tag — both are explicitly resolved in Tasks 7 (checksum/URL) and 12 Step 1 (model sha256) with exact commands. No "TODO/handle edge cases" hand-waving remains. + +**Type consistency:** `ModelDownloadState`, `ModelFileFetcher`, `ModelDownloadManager` (`state`, `startDownload`, `startDownloadAndWait`, `cancel`, `deleteModel`, `setStateForTesting`, `sha256Hex`), `BundledModel` fields, `ModelStorage.fileURL(for:)`, `LlamaEngine.shared`/`load`/`unload`/`generate`, `OnDeviceClient.shared`, `AIProvider.onDevice`/`available`/`defaultProvider`/`client`, `AICoachEngine.modelDownloads`/`sendStreaming`/`stop`/`streamOverride`, `AIProviderClient.stream(...)` — names are used identically across the tasks that define and consume them. + +**Known verification-dependent items (not gaps):** exact llama.cpp C symbol names track the pinned `b` release (Task 8 Step 2 compiles against real headers); the RAM-gate number and peak-memory behavior are confirmed on device (Task 12). Both are called out where they occur. diff --git a/docs/superpowers/specs/2026-07-09-on-device-bundled-llm-coach-design.md b/docs/superpowers/specs/2026-07-09-on-device-bundled-llm-coach-design.md new file mode 100644 index 0000000000..074edd4ee1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-on-device-bundled-llm-coach-design.md @@ -0,0 +1,219 @@ +# On-Device Bundled LLM Coach — Design + +**Date:** 2026-07-09 +**Status:** Approved (design), pending implementation plan +**Platform:** iOS (NOOPiOS target) first +**Scope:** A fifth AI Coach provider that runs a small LLM entirely on-device, with no server to run and no API key. + +## Summary + +Add an **on-device** coach provider for iOS, powered by **in-process llama.cpp (GGUF, Metal)** running a **~3B Q4 instruct model** (default: Llama-3.2-3B-Instruct Q4_K_M). The model is **downloaded once on first run** (pinned URL, SHA-256 verified, excluded from backup), replies **stream** token-by-token, and it is positioned as the **default "no setup, fully private"** option in the existing provider picker. + +The feature reuses the entire existing coach pipeline — context building, consent gating, chat history/windowing, and `CoachView` UI — by conforming to the existing `AIProviderClient` protocol. It is RAM-gated and uses the increased-memory entitlement plus memory-pressure unloading to stay within iOS limits. + +## Motivation + +The existing AI Coach is bring-your-own-key (BYOK) and networked: it sends a text summary of the user's metrics to a cloud provider (OpenAI/Anthropic/Gemini) or a **separately-run** local server (Ollama/LM Studio via the "Custom" provider's user-typed URL). This new feature removes both the cloud dependency and the "run your own server" friction: the app ships the ability to run a capable coach model itself, so coaching inference is **100% offline and zero-config**. + +This aligns with NOOP's "offline by design" ethos. Today the coach is the one networked exception; the on-device provider makes the coach offline too, leaving only a **one-time, user-initiated model download** as network activity — which sends *no user data* (it only fetches public weights). + +## Non-goals + +- macOS, Android: out of scope for this first release (iOS only). The design keeps new code macOS-safe (guards, no iOS-only assumptions leaking into shared types) but does not wire up other platforms. +- A live/remote model catalog or in-app model marketplace. One pinned default model, hard-coded. +- Replacing the cloud providers. They remain available and unchanged. +- Fine-tuning, RAG, embeddings, tools/function-calling. Plain chat completion only. + +## Existing architecture this builds on + +- `Strand/AI/AIProvider.swift` — `AIProvider` enum (`openAI`, `anthropic`, `gemini`, `custom`) and the `AIProviderClient` protocol (`send(...) -> String`, `fetchModels(...)`). +- `Strand/AI/AICoach.swift` — `AICoachEngine` (`@MainActor ObservableObject`): chat state, provider/model selection, consent gating (`dataConsent`, `includeOnDeviceSignals`), context builders (`buildFullContext`, `buildContext`, workouts/stress/on-device-signals blocks), chat windowing, `send(_:)`, `startBriefIfNeeded()`. `isConfigured` gates setup-card vs chat. +- `Strand/AI/Providers/Custom.swift` — proves the OpenAI-shaped `AIProviderClient` seam isolates "how we talk to a model" from "what the coach does". +- `Strand/Screens/CoachView.swift` — shared macOS/iOS chat UI (StrandiOS reaches it via `RootTabView` → `CoachView()`). Branches its setup card on `provider`. +- `Strand/App/AppModel.swift` — constructs the single `AICoachEngine(repo:)`. +- `project.yml` — XcodeGen source of truth; `NOOPiOS` target, iOS deployment target 17.0. + +Note: iOS 17.0 deployment target rules out Apple's Foundation Models framework (needs iOS 26), so we bring our own inference runtime. + +## Architecture + +``` +CoachView (shared macOS/iOS, unchanged UI shell) + │ + AICoachEngine (@MainActor, + streaming path, + download state) + │ provider == .onDevice + ▼ +OnDeviceClient : AIProviderClient ← new + │ (no URLSession, no HTTP) + ▼ +LlamaEngine (Swift actor) ← new: owns model lifecycle + │ C API + ▼ +llama.cpp xcframework (Metal) ← new binary dependency + ▲ +ModelDownloadManager ← new: first-run GGUF fetch + SHA-256 verify +``` + +### Runtime choice: llama.cpp (in-process) + +Chosen over MLX Swift (clear #2) and MLC LLM. Rationale: + +| | llama.cpp | MLX Swift | MLC LLM | +|---|---|---|---| +| Maturity on iOS | Very high, widely shipped | Growing, examples-grade | Low, heavy toolchain | +| Model/quant choice | Huge (GGUF, Q4/Q5/Q8) | Moderate (mlx-community) | Limited | +| Metal accel | Yes (ggml-metal) | Yes (native) | Yes | +| Memory tunability | Excellent (quant + ctx) | Good, needs cache tuning | Good | +| Swift integration | C API + thin wrapper | Native Swift pkg | Weakest | +| Structured output | GBNF grammars (JSON) | Manual | Manual | + +llama.cpp is the lowest-risk path to a shipped feature: broadest tiny-model choice, mature Metal + memory controls, thin C API. We run it **in-process** (no HTTP server — not viable in the iOS sandbox anyway) and wrap it so it conforms to `AIProviderClient`, dropping the on-device provider straight into `AICoachEngine`. We are adding a fifth provider, not a parallel coach. + +### New files + +- `Strand/AI/OnDevice/LlamaEngine.swift` — Swift `actor` wrapping llama.cpp: `load(modelURL:)`, `unload()`, `generate(systemPrompt:messages:) -> AsyncStream`. +- `Strand/AI/OnDevice/OnDeviceClient.swift` — `AIProviderClient` conformance calling `LlamaEngine`. +- `Strand/AI/OnDevice/ModelDownloadManager.swift` — `@MainActor ObservableObject`: download to Application Support, resume, SHA-256 verify, delete, state. +- `Strand/AI/OnDevice/ModelCatalog.swift` — the pinned default `BundledModel` + `deviceMeetsRequirements(physicalMemory:)`. +- llama.cpp binary **xcframework** added to `NOOPiOS` via `project.yml`. + +## Inference engine & streaming + +### `LlamaEngine` (Swift actor) + +Owns the whole native lifecycle so llama.cpp state is never touched concurrently. + +- `load(modelURL:) async throws` — `llama_model_load_from_file` + context creation. `n_ctx = 4096` (fits the ~1500-token metric context + system prompt + windowed history + reply). Metal enabled, `n_gpu_layers = -1` (all layers on GPU). Idempotent; unloads a prior model first. +- `unload()` — frees context/model. Called on memory pressure and provider switch-away. +- `generate(systemPrompt:messages:) -> AsyncStream` — applies the model's **chat template** via `llama_chat_apply_template` (template id from `ModelCatalog`), tokenizes, decodes token-by-token, yields each detokenized piece. Stops on EOS, `n_ctx` limit, or a max-tokens cap (~512). Cancellation-aware: Task cancellation stops the decode loop and yields nothing further. + +All llama.cpp calls are confined to this actor; failures surface as Swift errors (no traps/force-unwraps). Native pointers never escape the actor. + +### Streaming — the one protocol change + +Today `AIProviderClient.send(...)` returns `String`. Add an **optional** streaming method with a default that adapts existing clients: + +```swift +protocol AIProviderClient { + func send(...) async throws -> String // existing, unchanged + func stream(...) -> AsyncThrowingStream // new +} +extension AIProviderClient { + // Default: run send(), yield the whole reply as ONE chunk. Cloud clients get "streaming" + // for free as a single chunk; only OnDeviceClient overrides with true token streaming. + func stream(...) -> AsyncThrowingStream { /* wraps send() */ } +} +``` + +`AICoachEngine` gains a parallel `sendStreaming(_:)` that appends an empty assistant `ChatMessage`, then mutates its `.text` as chunks arrive (`ChatMessage.text` becomes a `var`). It uses `stream(...)` for **every** provider — cloud providers resolve to one chunk, on-device streams for real — so the streaming plumbing is uniform and the send path is not forked per provider. + +**Threading:** generation runs on the `LlamaEngine` actor (off main); each yielded chunk is applied on `@MainActor` in `AICoachEngine`. UI never blocks. + +**Stop:** add `stop()` to `AICoachEngine` that cancels the generation Task → the actor exits its decode loop cleanly. Surfaced as a Stop button while `sending`. + +## Model delivery, storage & verification + +### `ModelCatalog` + +Static, checked-in description of the pinned default model (no live network catalog): + +```swift +struct BundledModel { + let id: String // "llama-3.2-3b-instruct-q4_k_m" + let displayName: String // "On-device Coach (Llama 3.2 3B)" + let url: URL // pinned HuggingFace resolve URL + let sha256: String // verified after download + let sizeBytes: Int64 // ~2.0 GB, shown in the confirm dialog + let contextLength: Int // 4096 + let chatTemplate: String // template id for llama_chat_apply_template +} +``` + +**Model choice + license (resolved):** **Llama-3.2-3B-Instruct Q4_K_M**, pinned to an official/community HuggingFace resolve URL. Chosen over the MIT-licensed Phi-3.5-mini for coaching quality and Metal-friendliness. The app **never hosts or redistributes weights** — `ModelCatalog` holds only a pinned pointer + SHA-256, and the device downloads directly from HuggingFace — so the Llama 3.2 Community License concern is minimal. Only the factual pointer + checksum is committed; no weights in the repo. + +### `ModelDownloadManager` (`@MainActor ObservableObject`) + +- State enum the UI binds to: `.absent`, `.downloading(progress: Double)`, `.verifying`, `.ready`, `.failed(String)`. +- `URLSession` background `downloadTask` with **resume data** so a dropped 2 GB download continues rather than restarting. +- On completion: stream-hash the file (SHA-256, CryptoKit) and compare to the catalog value. **Mismatch → delete + `.failed`** (never load an unverified/corrupt blob). +- Atomic move into place only after verification passes. +- `deleteModel()` — frees the ~2 GB, surfaced in settings. +- Launch presence check sets initial state to `.ready` vs `.absent`. + +### Storage + +- `Application Support/OnDeviceModels/.gguf`. +- Marked **excluded from iCloud/iTunes backup** (`URLResourceValues.isExcludedFromBackup = true`) — a re-downloadable 2 GB blob must not bloat backups. + +### Trust & network honesty + +- URL and SHA-256 are compiled in: first-run download is pinned and integrity-checked, consistent with the app's security posture (verified artifact, no arbitrary remote code). +- The model download is a **new network egress**. It is one-time, user-initiated, fetches public weights, and sends **no user data**. The spec/UI call this out explicitly so "offline by design" stays accurate: coach *inference* is 100% offline; only the one-time weight download touches the network, and only when the user taps it. + +## UX flow in CoachView + +**Provider picker.** `.onDevice` becomes the first case in `AIProvider.allCases` and the app default provider. Displayed as **"On-device — no setup, fully private"**. The setup card branches on provider; on-device shows a distinct card instead of the API-key field. + +**On-device setup card (driven by `ModelDownloadManager.state`):** +- `.absent` → explainer + **"Download coach model (~2 GB)"** button, with: *"One-time download of the model over Wi-Fi. After that, coaching runs entirely on your \(deviceNoun) with no internet."* +- `.downloading(p)` → progress bar + bytes, **Cancel**. +- `.verifying` → "Verifying…" indeterminate. +- `.ready` → green "Model ready" pill; chat available; **Delete model (free 2 GB)** in the settings/disconnect area. +- `.failed(msg)` → error + **Retry** (uses resume data when possible). + +**`isConfigured`:** `provider == .onDevice ? (downloadState == .ready) : `. + +**Chat behavior:** identical composer and message list. Differences for on-device: +- Replies **stream** in token-by-token. +- A **Stop** button appears while `sending` (calls `stop()`). +- First-run **"Today's brief"** (`startBriefIfNeeded`) works the same — runs on-device. + +**Consent & privacy copy:** data-consent toggle and system-prompt editor unchanged. The bottom privacy line gets an on-device variant: *"On-device coaching never leaves your \(deviceNoun) — your metrics are read and answered locally."* (No "sent to a provider" language for this case.) + +**Reused verbatim:** message list, markdown rendering (`CoachMarkdownTheme`), consent toggle, system-prompt editor, "Today's brief". + +## Memory, device gating & error handling + +The core iOS risk is RAM (3B Q4 ~2 GB resident + KV cache). Mitigations: + +- **Entitlement:** add `com.apple.developer.kernel.increased-memory-limit` to the `NOOPiOS` target (raises the jetsam ceiling on supported devices). Friction-free since NOOP is build-from-source / not App-Store-reviewed. +- **Device gate (`ModelCatalog.deviceMeetsRequirements(physicalMemory:)`):** check `ProcessInfo.physicalMemory`. Below ~6 GB installed RAM, disable the download button with an explainer ("Your \(deviceNoun) doesn't have enough memory to run the on-device coach; use a cloud provider instead"). Honest rather than jetsam-on-load. +- **Lazy load + single instance:** model loaded on first generation, not at launch; only one `llama_context` ever exists. +- **Memory-pressure handling:** `DispatchSource.makeMemoryPressureSource` (warning/critical). On critical while idle → `LlamaEngine.unload()` (reload lazily next turn). Never unload mid-generation. +- **Background unload:** unload the model when the app is backgrounded (default **ON**) to avoid being the top jetsam target; reload lazily on next use. Exposed as a tunable; the ON default may be revisited after device testing. + +**Error handling — extend `AICoachError`** (keeps the UI's error surface uniform): +- `.modelNotDownloaded` — ask the user to download first. +- `.modelLoadFailed(String)` — llama.cpp failed to load/init (corrupt file, OOM at load) → suggest re-download / smaller context. +- `.generationFailed(String)` — decode error mid-stream. +- `.deviceUnsupported` — RAM gate failed. +- Download errors map to existing `.network(...)`. + +All non-fatal: they land in `errorText` exactly like today. No crashes. + +## Testing strategy + +Native/model-dependent parts can't run in CI (no 2 GB weights, no Metal on runners), so logic is pushed into pure, testable units and the native surface is kept thin — matching the repo convention that wire/math-level logic lives deep and is covered by fast tests. + +**Pure unit tests (CI, no model, no device) — in `StrandTests`:** +- `ModelCatalog`: URL well-formed, sha256 is 64 hex chars, `deviceMeetsRequirements(physicalMemory:)` correct at boundary values (inject RAM, don't read the device). +- SHA-256 verifier: hash a known fixture → match; corrupt a byte → fail. +- `ModelDownloadManager` state machine (protocol-injected downloader, no real network): absent→downloading→verifying→ready; verify-fail→failed+file-deleted; cancel→absent; resume path. +- `AICoachError`: new cases produce non-empty user-facing strings. +- Streaming adapter: `AIProviderClient.stream` default wraps `send()` into exactly one chunk (fake client asserts cloud parity); `AICoachEngine.sendStreaming` accumulates a stub stream `["He","llo"]` → message text "Hello", `sending` toggles, `stop()` cancels. +- `isConfigured` truth table across all five providers including `.onDevice` × download states. + +**Manual / device verification (documented, not CI):** +- Real download + verify + generate on a physical iPhone; measure tokens/sec, peak memory (Instruments), jetsam with/without the entitlement. +- Streaming renders incrementally; Stop interrupts cleanly; memory-pressure unload/reload works. + +**TDD:** the pure units (state machine, verifier, streaming adapter, gate, error strings) get tests first. `LlamaEngine` is validated by device smoke-testing; its logic is deliberately minimal. + +## Resolved decisions + +1. **Model:** Llama-3.2-3B-Instruct Q4_K_M, pinned HuggingFace resolve URL + SHA-256 in `ModelCatalog`. App never hosts weights. +2. **llama.cpp packaging:** pinned prebuilt binary **xcframework** (specific release tag) for reproducible, fast builds. +3. **Background unload:** default **ON**; exposed as a tunable, revisit after device testing. +4. **RAM gate threshold:** ~6 GB `physicalMemory`; the exact number confirmed against real-device measurements during implementation. + +The one item still requiring a physical iPhone is confirming the exact RAM-gate number and validating tokens/sec + peak memory — captured in the device-verification testing section, not blocking the plan. diff --git a/project.yml b/project.yml index 4454bcc084..383d82ca4d 100644 --- a/project.yml +++ b/project.yml @@ -11,7 +11,7 @@ options: developmentLanguage: en settings: base: - MARKETING_VERSION: "9.0.1" + MARKETING_VERSION: "9.1.0" # iOS build number (CFBundleVersion). GLOBAL so the iOS app AND its widget extension inherit the # SAME value — they must match or iOS warns "extension version must match parent app" (#416). # macOS sets its own CFBundleVersion on the Strand target and is unaffected by this. @@ -63,6 +63,10 @@ packages: ZIPFoundation: url: https://github.com/weichsel/ZIPFoundation.git exactVersion: 0.9.20 + # Prebuilt llama.cpp xcframework (Metal) for the on-device Coach. Local package pins the binary + # artifact by URL + checksum (see Packages/LlamaCpp/Package.swift). iOS-only. + LlamaCpp: + path: Packages/LlamaCpp targets: Strand: type: application @@ -76,6 +80,10 @@ targets: # The liquid Today is now cross-platform: its iOS-only chrome (topBarTrailing / navigationBarTitleDisplayMode # / presentationCompactAdaptation / two-param onChange / sensoryFeedback) is guarded, so the mac split-view # shell hosts LiquidTodayView too. All Liquid/* now compile into the mac target. + # iOS-only on-device Coach files (llama.cpp). Excluded so they never compile on macOS + # (the LlamaCpp package is iOS-only). + - "AI/OnDevice/LlamaEngine.swift" + - "AI/OnDevice/OnDeviceClient.swift" # Oura BYO-app OAuth credentials. OuraConfig.xcconfig is a tracked wrapper that optionally # includes the untracked, gitignored Strand/Oura/OuraSecrets.xcconfig (template at # OuraSecrets.example.xcconfig) — absent secrets leave the $(OURA_*) references in the @@ -285,6 +293,7 @@ targets: com.apple.developer.healthkit.access: [] com.apple.security.application-groups: - $(APP_GROUP_ID) + com.apple.developer.kernel.increased-memory-limit: true settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.noopapp.noop @@ -304,6 +313,7 @@ targets: - package: StrandDesign - package: MarkdownUI - package: ZIPFoundation + - package: LlamaCpp - target: NOOPiOSWidgets # Embed the watchOS app so the iPhone app carries it (NOOP.app/Watch/NOOPWatch.app). A watch app # installs by riding inside its iOS companion; without this it would never reach a paired watch.