Add real on-device LLM (MLX) for rewrite / tone / Ask

Replace the MLXEngine stub with real Qwen2.5-3B-4bit inference via
mlx-swift-examples (2.25.9): loads the local model snapshot on disk (no
network), chat-templated generation per AIAction, and tone classification.
Wired behind the existing AIEngine seam; AppState resolves the model dir
through ModelManager.

Build notes:
- arm64-only now (MLX is Metal / Apple-Silicon only; matches the spec).
- swift build (CLI) cannot compile mlx-swift's .metal kernels, so the
  metallib is generated once via scripts/build-metallib.sh (xcodebuild) and
  vendored at AppSupport/mlx.metallib; scripts/build.sh ships it as
  Contents/Resources/mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib
  (the path mlx resolves, and it codesigns cleanly).
- A hidden --mlx-selftest entrypoint runs one headless generation to verify
  the runtime end-to-end.

Verified: the signed app's self-test produces a real rewrite; full test
suite green (the MLX integration test is gated behind GRAMMAGEM_MLX_TEST).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BigMarc 2026-06-18 07:34:02 +02:00
parent 143620e44e
commit 8a9ffb55af
11 changed files with 378 additions and 85 deletions

3
.gitignore vendored
View File

@ -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/

BIN
AppSupport/mlx.metallib Normal file

Binary file not shown.

69
Package.resolved Normal file
View File

@ -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
}

View File

@ -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"])
]
),

View File

@ -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<Generation> 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: - Deterministic stand-in transforms
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(".") }
// 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
}
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")
} catch let error as AIEngineError {
throw error
} catch {
throw AIEngineError.generationFailed(error.localizedDescription)
}
}
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)
// 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
}
}
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)
/// 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
}
}
/// 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)?
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
}
}

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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() {

25
scripts/build-metallib.sh Executable file
View File

@ -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))"

View File

@ -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)"