diff --git a/.gitignore b/.gitignore index 9c5b0be..b53644e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist/ # Cargo.lock IS committed (binary crate) for reproducible builds. harper-ffi/target/ harper-ffi/lib/ + +# xcodebuild DerivedData (used only to generate the MLX metallib) +.xcode-build/ diff --git a/AppSupport/mlx.metallib b/AppSupport/mlx.metallib new file mode 100644 index 0000000..50598b9 Binary files /dev/null and b/AppSupport/mlx.metallib differ diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..1197b37 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "1aab35282a23815fc7c2ac7484414660e78b19f68cfbf14f87004cf55bb3ac6b", + "pins" : [ + { + "identity" : "gzipswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/1024jp/GzipSwift", + "state" : { + "revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05", + "version" : "6.0.1" + } + }, + { + "identity" : "mlx-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift", + "state" : { + "revision" : "96c0f69cf15f38abd6248bb71fca908171dab1d8", + "version" : "0.25.6" + } + }, + { + "identity" : "mlx-swift-examples", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift-examples.git", + "state" : { + "revision" : "8775112da257e8b0a5bf45e3f84fb80222592c89", + "version" : "2.25.9" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-jinja", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-jinja.git", + "state" : { + "revision" : "0b67ecb79139f6addef8699eff3622808aa6c7dc", + "version" : "2.3.6" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-transformers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-transformers", + "state" : { + "revision" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2", + "version" : "1.0.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index f5a8e5a..04ed911 100644 --- a/Package.swift +++ b/Package.swift @@ -6,9 +6,12 @@ import Foundation // // Layer-1 grammar is the real **Harper** core (Apache-2.0), embedded as a Rust // C-FFI static library (see `harper-ffi/`). Build the lib first with -// `harper-ffi/build.sh` (universal arm64+x86_64 -> harper-ffi/lib/libharper_ffi.a); -// `scripts/build.sh` does this automatically. The remaining heavyweight pieces -// (MLX local LLM, Sparkle, KeyboardShortcuts) are still behind protocol seams. +// `harper-ffi/build.sh`; `scripts/build.sh` does this automatically. +// +// Layer-2 AI (rewrite / tone / Ask) is the real on-device LLM via MLX +// (mlx-swift-examples). MLX is **Apple-Silicon / Metal only**, so the app is +// arm64-only (matches the product spec: Apple Silicon, macOS 14+). The Harper +// static lib stays universal; the linker just uses its arm64 slice. // Absolute path to the prebuilt Harper static lib, independent of build cwd. let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path @@ -22,15 +25,24 @@ let package = Package( products: [ .executable(name: "GrammaGem", targets: ["GrammaGem"]) ], + dependencies: [ + // On-device LLM runtime. Pinned to an exact tag (the LLM libraries still + // ship from this repo at 2.25.9; the package name is "mlx-libraries"). + .package(url: "https://github.com/ml-explore/mlx-swift-examples.git", exact: "2.25.9"), + ], targets: [ // C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift. .target(name: "CHarper", path: "Sources/CHarper"), .executableTarget( name: "GrammaGem", - dependencies: ["CHarper"], + dependencies: [ + "CHarper", + .product(name: "MLXLLM", package: "mlx-swift-examples"), + .product(name: "MLXLMCommon", package: "mlx-swift-examples"), + ], path: "Sources/GrammaGem", linkerSettings: [ - // Link the prebuilt universal Harper static library. + // Link the prebuilt Harper static library (arm64 slice). .unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"]) ] ), diff --git a/Sources/GrammaGem/AI/MLXEngine.swift b/Sources/GrammaGem/AI/MLXEngine.swift index 139bf9c..e2e2c96 100644 --- a/Sources/GrammaGem/AI/MLXEngine.swift +++ b/Sources/GrammaGem/AI/MLXEngine.swift @@ -1,91 +1,174 @@ import Foundation +import MLXLMCommon +import MLXLLM -/// STUB standing in for the real MLX-backed local LLM. +/// Layer 2 — the real on-device LLM (rewrite / tone / "Ask" / translate), backed +/// by **MLX** via mlx-swift-examples (verified against tag 2.25.9; SPM package +/// "mlx-libraries", products MLXLLM + MLXLMCommon). /// -/// TODO(real-integration): load the model via mlx-swift from -/// `ModelManager.modelsDirectory`, build the prompt from the action's system -/// prompt + the user's text, and stream tokens (target: first token < 1s on -/// M-series). Everything stays on-device — this method must never hit the network. +/// Loads weights ONLY from a local on-disk snapshot (ModelManager's download +/// directory) — `ModelConfiguration(directory:)` never contacts the network, so +/// inference is fully on-device, honoring the privacy promise. /// -/// The stub produces deterministic, plausible transformations so the capture → -/// engine → replace loop and the entitlement gating can be exercised end-to-end -/// without the multi-gigabyte model present. +/// API surface used (all confirmed in the 2.25.9 source): +/// ModelConfiguration(directory:) MLXLMCommon/ModelConfiguration.swift +/// LLMModelFactory.shared.loadContainer(...) MLXLLM/LLMModelFactory.swift +/// ModelContainer.perform { context in ... } MLXLMCommon/ModelContainer.swift +/// context.processor.prepare(input: UserInput(chat:)) MLXLMCommon/UserInput.swift +/// MLXLMCommon.generate(...) -> AsyncStream MLXLMCommon/Evaluate.swift final class MLXEngine: AIEngine { - private let ready: Bool - init(ready: Bool = true) { - self.ready = ready + /// Returns the local model snapshot directory if a complete model is present, + /// else nil. Owned by ModelManager; MLXEngine only loads + infers. + private let modelDirectoryProvider: () -> URL? + + /// Serializes the (expensive) first load and caches the container. + private let loader = ContainerLoader() + + init(modelDirectoryProvider: @escaping () -> URL?) { + self.modelDirectoryProvider = modelDirectoryProvider } - var isReady: Bool { ready } + var isReady: Bool { modelDirectoryProvider() != nil } + + // MARK: - AIEngine func run(_ action: AIAction, on text: String) async throws -> String { - guard ready else { throw AIEngineError.modelNotReady } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return text } - switch action { - case .rewriteClarity, .rewrite: - return tidy(trimmed) - case .adjustTone(let tone): - return apply(tone: tone, to: tidy(trimmed)) - case .ask(let instruction): - return follow(instruction: instruction, on: trimmed) - case .translate(let language): - return "[\(language)] " + tidy(trimmed) - case .applyMode(let mode): - return applyMode(mode, to: trimmed) - } + let container = try await container() + let params = GenerateParameters(temperature: temperature(for: action)) + let raw = try await complete( + system: systemPrompt(for: action), user: trimmed, + maxTokens: 512, parameters: params, container: container) + return clean(raw) } func detectTone(_ text: String) async -> Tone { - let lower = text.lowercased() - if lower.contains("!") || lower.contains("🚀") { return .punchy } - if lower.contains("therefore") || lower.contains("hereby") { return .academic } - if lower.contains("hey") || lower.contains("lol") { return .friendly } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .professional } + let options = Tone.allCases.map(\.rawValue).joined(separator: ", ") + let system = """ + You are a tone classifier. Read the user's text and respond with exactly ONE \ + word — the single best-matching tone from this list: \(options). \ + Output only that word, lowercase, no punctuation or explanation. + """ + do { + let container = try await container() + let raw = try await complete( + system: system, user: trimmed, maxTokens: 8, + parameters: GenerateParameters(temperature: 0.0), container: container) + return parseTone(raw) + } catch { + return .professional + } + } + + // MARK: - Loading + + private func container() async throws -> ModelContainer { + guard let dir = modelDirectoryProvider() else { throw AIEngineError.modelNotReady } + return try await loader.container(at: dir) + } + + // MARK: - Generation + + private func complete(system: String, user: String, maxTokens: Int, + parameters: GenerateParameters, + container: ModelContainer) async throws -> String { + do { + return try await container.perform { context in + let input = try await context.processor.prepare( + input: UserInput(chat: [.system(system), .user(user)])) + var params = parameters + params.maxTokens = maxTokens + var out = "" + let stream = try MLXLMCommon.generate( + input: input, parameters: params, context: context) + for await item in stream { + if let chunk = item.chunk { out += chunk } + } + return out + } + } catch let error as AIEngineError { + throw error + } catch { + throw AIEngineError.generationFailed(error.localizedDescription) + } + } + + // MARK: - Prompting + + private func systemPrompt(for action: AIAction) -> String { + switch action { + case .rewriteClarity: + return "Rewrite the user's text to be clearer and more concise. Preserve the " + + "original meaning and the author's voice. Output only the rewritten text." + case .rewrite: + return "Paraphrase the user's text. Keep the meaning but vary the wording and " + + "structure. Output only the rewritten text." + case .adjustTone(let tone): + return "Rewrite the user's text in a \(tone.rawValue) tone. Preserve the meaning. " + + "Output only the rewritten text." + case .ask(let instruction): + return "Apply this instruction to the user's text: \(instruction). " + + "Output only the resulting text." + case .translate(let language): + return "Translate the user's text into \(language). Output only the translation." + case .applyMode(let mode): + return mode.systemPrompt + } + } + + /// Low temperature for faithful rewrites; a touch higher for free paraphrase. + private func temperature(for action: AIAction) -> Float { + switch action { + case .rewrite: return 0.5 + case .ask: return 0.4 + default: return 0.2 + } + } + + // MARK: - Output cleanup + + private func clean(_ s: String) -> String { + var out = s.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = out.lowercased() + for prefix in ["sure, here", "sure! here", "here is", "here's", "certainly"] { + if lower.hasPrefix(prefix), let colon = out.firstIndex(of: ":") { + let after = out[out.index(after: colon)...] + .trimmingCharacters(in: .whitespacesAndNewlines) + if !after.isEmpty { out = after } + break + } + } + if out.count >= 2, out.first == "\"", out.last == "\"" { + out = String(out.dropFirst().dropLast()) + } + return out.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func parseTone(_ raw: String) -> Tone { + let token = raw.lowercased().trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + if let exact = Tone(rawValue: token) { return exact } + let lower = raw.lowercased() + for tone in Tone.allCases where lower.contains(tone.rawValue) { return tone } return .professional } +} - // MARK: - Deterministic stand-in transforms +/// Caches one loaded `ModelContainer` (keyed by directory) and coalesces +/// concurrent first-loads. `ModelContainer` is itself an actor, so generation +/// calls through it are serialized safely. +private actor ContainerLoader { + private var cached: (url: URL, container: ModelContainer)? - private func tidy(_ s: String) -> String { - var out = s - out = out.replacingOccurrences(of: " i ", with: " I ") - out = out.replacingOccurrences(of: "lmk", with: "let me know") - out = out.replacingOccurrences(of: "u ", with: "you ") - if let first = out.first, first.isLowercase { - out.replaceSubrange(out.startIndex...out.startIndex, with: String(first).uppercased()) - } - if let last = out.last, !".!?".contains(last) { out.append(".") } - return out - } - - private func apply(tone: Tone, to s: String) -> String { - switch tone { - case .professional: return s - case .friendly: return "Hi! " + s - case .confident: return s.replacingOccurrences(of: "I think ", with: "") - case .academic: return "It is worth noting that " + s.prefix(1).lowercased() + s.dropFirst() - case .punchy: return s.replacingOccurrences(of: ". ", with: ".\n") - } - } - - private func follow(instruction: String, on s: String) -> String { - let i = instruction.lowercased() - if i.contains("short") { return String(s.split(separator: " ").prefix(8).joined(separator: " ")) + "." } - if i.contains("formal") { return apply(tone: .academic, to: tidy(s)) } - if i.contains("translate") { return "[translated] " + tidy(s) } - return tidy(s) - } - - private func applyMode(_ mode: WritingMode, to s: String) -> String { - switch mode.id { - case "email": return "Hi,\n\n\(tidy(s))\n\nThanks!" - case "post": return apply(tone: .punchy, to: tidy(s)) - case "academic": return apply(tone: .academic, to: tidy(s)) - case "slack": return s.lowercased() - case "code": return "// " + tidy(s) - default: return tidy(s) - } + func container(at directory: URL) async throws -> ModelContainer { + if let cached, cached.url == directory { return cached.container } + let configuration = ModelConfiguration(directory: directory) + let container = try await LLMModelFactory.shared.loadContainer(configuration: configuration) + cached = (directory, container) + return container } } diff --git a/Sources/GrammaGem/AI/ModelManager.swift b/Sources/GrammaGem/AI/ModelManager.swift index fdcff5d..0402aff 100644 --- a/Sources/GrammaGem/AI/ModelManager.swift +++ b/Sources/GrammaGem/AI/ModelManager.swift @@ -23,20 +23,38 @@ final class ModelManager: ObservableObject { if isModelPresent(repo: selectedRepo) { state = .ready } } - var modelsDirectory: URL { + var modelsDirectory: URL { Self.modelsDirectory() } + + func modelDir(_ repo: String) -> URL { Self.modelDir(repo) } + + /// Considered present once the completion marker is written. + func isModelPresent(repo: String) -> Bool { Self.isModelPresent(repo: repo) } + + // MARK: - Nonisolated path helpers + // + // The AI engine loads weights off the main actor, so the directory lookup must + // be callable from any thread. These are pure FileManager/URL ops (no actor + // state, no network) and back the @MainActor instance accessors above. + + nonisolated static func modelsDirectory() -> URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] return base.appendingPathComponent("GrammaGem/Models", isDirectory: true) } - func modelDir(_ repo: String) -> URL { - modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true) + nonisolated static func modelDir(_ repo: String) -> URL { + modelsDirectory().appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true) } - /// Considered present once the completion marker is written. - func isModelPresent(repo: String) -> Bool { + nonisolated static func isModelPresent(repo: String) -> Bool { FileManager.default.fileExists(atPath: modelDir(repo).appendingPathComponent(".complete").path) } + /// The local model directory iff a *complete* model is on disk, else nil — + /// fed to the AI engine as its network-free model source. + nonisolated static func completedModelDirectory(repo: String) -> URL? { + isModelPresent(repo: repo) ? modelDir(repo) : nil + } + /// Kick off (or resume to) a real download. Safe to call from the UI. func startDownload(repo: String? = nil) { let target = repo ?? selectedRepo diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammaGem/App/AppState.swift index 5bb74e4..07e3b93 100644 --- a/Sources/GrammaGem/App/AppState.swift +++ b/Sources/GrammaGem/App/AppState.swift @@ -45,7 +45,11 @@ final class AppState: ObservableObject { private init() { let engine = SystemGrammarEngine() grammar = engine - ai = MLXEngine(ready: true) + // The on-device LLM loads weights from ModelManager's local snapshot (if a + // complete download is present) — never the network. Resolved lazily on use. + ai = MLXEngine(modelDirectoryProvider: { + ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo) + }) permissions = Permissions() model = ModelManager() license = LicenseManager() diff --git a/Sources/GrammaGem/App/GrammaGemApp.swift b/Sources/GrammaGem/App/GrammaGemApp.swift index b2d538f..61de6b0 100644 --- a/Sources/GrammaGem/App/GrammaGemApp.swift +++ b/Sources/GrammaGem/App/GrammaGemApp.swift @@ -1,10 +1,47 @@ import SwiftUI import AppKit +/// Process entry point. A hidden `--mlx-selftest` flag loads the on-device model +/// and runs one real generation (verifies the MLX runtime + metallib headlessly), +/// then exits. Otherwise the normal menu-bar app launches. +@main +enum GrammaGemMain { + static func main() { + if CommandLine.arguments.contains("--mlx-selftest") { + MLXSelfTest.run() + return + } + GrammaGemApp.main() + } +} + +/// Headless verification of the on-device LLM (used by CI / the build pipeline). +enum MLXSelfTest { + static func run() { + let sample = "me and him was going to the store for to buy some milks" + let engine = MLXEngine(modelDirectoryProvider: { + ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo) + }) + guard engine.isReady else { + FileHandle.standardError.write(Data("SELFTEST_FAIL: model not present\n".utf8)) + exit(2) + } + let sem = DispatchSemaphore(value: 0) + var line = "SELFTEST_FAIL: unknown" + Task.detached { + do { line = "SELFTEST_OK: " + (try await engine.run(.rewriteClarity, on: sample)) } + catch { line = "SELFTEST_FAIL: \(error.localizedDescription)" } + sem.signal() + } + sem.wait() + print(line) + exit(line.hasPrefix("SELFTEST_OK") ? 0 : 1) + } +} + /// App entry point. A menu-bar (`LSUIElement`) SwiftUI app: the only persistent /// scene is the `MenuBarExtra`; Settings uses the standard scene; Onboarding and /// Ask are AppKit-hosted windows opened on demand (see `AppState`). -@main struct GrammaGemApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var app = AppState.shared diff --git a/Tests/GrammaGemTests/GrammaGemTests.swift b/Tests/GrammaGemTests/GrammaGemTests.swift index 24a23e4..82f420f 100644 --- a/Tests/GrammaGemTests/GrammaGemTests.swift +++ b/Tests/GrammaGemTests/GrammaGemTests.swift @@ -110,6 +110,29 @@ final class GrammaGemTests: XCTestCase { XCTAssertEqual(UsageStats.nextMilestone(corrections: 100).target, 500) } + // MARK: - MLX on-device LLM (integration; skipped unless the model is present) + + /// Loads the real downloaded model and runs one generation. Skipped in CI / + /// on machines without the weights. Run with the model present to verify the + /// MLX integration end-to-end. + func testMLXRealGenerationIfModelPresent() async throws { + // Opt-in: `swift test` (CLI) can't place mlx-swift's metallib, so this is + // gated. Run it via the app's `--mlx-selftest`, or set GRAMMAGEM_MLX_TEST=1 + // with the metallib colocated. See scripts/build-metallib.sh. + guard ProcessInfo.processInfo.environment["GRAMMAGEM_MLX_TEST"] == "1" else { + throw XCTSkip("set GRAMMAGEM_MLX_TEST=1 (metallib colocated) to run the MLX integration test") + } + guard let dir = ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo) else { + throw XCTSkip("on-device model not downloaded; skipping MLX integration test") + } + let engine = MLXEngine(modelDirectoryProvider: { dir }) + XCTAssertTrue(engine.isReady) + let out = try await engine.run(.rewriteClarity, + on: "me and him was going to the store for to buy some milks") + print("MLX rewriteClarity -> \(out)") + XCTAssertFalse(out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + // MARK: - Modes func testFreeTierGetsOnlyPolishMode() { diff --git a/scripts/build-metallib.sh b/scripts/build-metallib.sh new file mode 100755 index 0000000..ce22774 --- /dev/null +++ b/scripts/build-metallib.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Regenerate AppSupport/mlx.metallib — the MLX on-device LLM's compiled Metal +# kernels. +# +# `swift build` (SwiftPM CLI) does NOT compile mlx-swift's .metal sources into a +# metallib, but Xcode's build system does. So we build the package once via +# xcodebuild, then vendor the resulting metallib (shipped colocated with the app +# executable; see scripts/build.sh). Run this when bumping the mlx-swift version. +# +# Requires the Metal Toolchain: xcodebuild -downloadComponent MetalToolchain +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +DD=".xcode-build" + +echo "==> xcodebuild (compiles mlx-swift metal kernels — a few minutes)" +xcodebuild build -scheme GrammaGem -configuration Release \ + -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath "$DD" -skipMacroValidation >/dev/null + +SRC="$DD/Build/Products/Release/mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib" +[ -f "$SRC" ] || { echo "ERROR: metallib not produced at $SRC" >&2; exit 1; } +cp "$SRC" AppSupport/mlx.metallib +echo "==> vendored AppSupport/mlx.metallib ($(du -h AppSupport/mlx.metallib | cut -f1))" diff --git a/scripts/build.sh b/scripts/build.sh index e04fd1c..62aa160 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Build a runnable, UNIVERSAL (Apple Silicon + Intel) GrammaGem.app bundle. +# Build a runnable GrammaGem.app bundle (arm64 / Apple Silicon — the MLX +# on-device LLM runtime is Metal-only, so Intel is not supported). # Usage: ./scripts/build.sh (run from the mac/ directory) set -euo pipefail @@ -14,9 +15,10 @@ echo "==> Building Harper static lib (universal)" chmod +x harper-ffi/build.sh ./harper-ffi/build.sh -echo "==> Compiling universal (arm64 + x86_64, ${CONFIG})" -swift build -c "${CONFIG}" --arch arm64 --arch x86_64 -BIN="$(swift build -c "${CONFIG}" --arch arm64 --arch x86_64 --show-bin-path)" +echo "==> Compiling (arm64 / Apple Silicon, ${CONFIG})" +# arm64-only: the MLX on-device LLM runtime is Apple-Silicon / Metal only. +swift build -c "${CONFIG}" --arch arm64 +BIN="$(swift build -c "${CONFIG}" --arch arm64 --show-bin-path)" echo "==> Assembling ${APP}" rm -rf "${DIST}/${APP}" @@ -26,6 +28,23 @@ cp "${BIN}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem" cp "AppSupport/Info.plist" "${DIST}/${APP}/Contents/Info.plist" cp "AppSupport/AppIcon.icns" "${DIST}/${APP}/Contents/Resources/AppIcon.icns" +# SwiftPM resource bundles (tokenizer data, etc.) go in Contents/Resources so +# Bundle.module resolves via the main bundle and the signature stays valid. +shopt -s nullglob +for b in "${BIN}"/*.bundle; do + cp -R "$b" "${DIST}/${APP}/Contents/Resources/" +done +shopt -u nullglob + +# MLX Metal kernels: `swift build` (CLI) does NOT compile mlx-swift's .metal +# sources, so we ship a prebuilt metallib in the exact bundle mlx looks for — +# Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib — +# which mlx finds via the main bundle's resources and which codesigns cleanly. +# Regenerate AppSupport/mlx.metallib with: ./scripts/build-metallib.sh +MLX_RES="${DIST}/${APP}/Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resources" +mkdir -p "${MLX_RES}" +cp "AppSupport/mlx.metallib" "${MLX_RES}/default.metallib" + echo "==> Built ${DIST}/${APP}" lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem" echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"