diff --git a/.gitignore b/.gitignore
index 90cc509..b53644e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,11 @@ dist/
*.xcodeproj/xcuserdata/
.DS_Store
.swiftpm/
+
+# Rust / Harper FFI build artifacts (regenerate with harper-ffi/build.sh).
+# 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/GrammaGem.entitlements b/AppSupport/GrammarGem.entitlements
similarity index 100%
rename from AppSupport/GrammaGem.entitlements
rename to AppSupport/GrammarGem.entitlements
diff --git a/AppSupport/Info.plist b/AppSupport/Info.plist
index 8b4ef3c..31c84ac 100644
--- a/AppSupport/Info.plist
+++ b/AppSupport/Info.plist
@@ -1,6 +1,6 @@
+ SUFeedURL
+ https://www.grammargem.com/appcast.xml
+ SUPublicEDKey
+ IwYch+DRgNk3gmCwe86z+SIc4sRdyDf4Z6xGpS+JXDc=
+ SUEnableAutomaticChecks
+
+ SUScheduledCheckInterval
+ 86400
+ SUAutomaticallyUpdate
+
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..2e0f4f3
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,78 @@
+{
+ "originHash" : "f78005d8f2f9a6a31d5c42cc7ce2b46907eb92fce74810060a58056e592ea3a1",
+ "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" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
+ "version" : "2.9.3"
+ }
+ },
+ {
+ "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 f472874..bceb8a5 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,37 +1,58 @@
// swift-tools-version:5.10
import PackageDescription
+import Foundation
-// GrammaGem — native macOS menu-bar writing assistant.
+// GrammarGem — native macOS menu-bar writing assistant.
//
-// This package builds with ONLY Apple frameworks so it compiles cleanly out of
-// the box (`swift build`). The heavyweight third-party pieces from the spec —
-// Harper (Rust grammar core), MLX (local LLM), KeyboardShortcuts, Sparkle — are
-// abstracted behind protocols with working stub implementations and clearly
-// marked `TODO(real-integration)` seams. See README.md for wiring the real deps.
+// 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`; `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
+let harperLibDir = packageRoot + "/harper-ffi/lib"
+
let package = Package(
- name: "GrammaGem",
+ name: "GrammarGem",
platforms: [
.macOS(.v14) // Apple Silicon, macOS 14+ (per the product spec)
],
products: [
- .executable(name: "GrammaGem", targets: ["GrammaGem"])
+ .executable(name: "GrammarGem", targets: ["GrammarGem"])
],
dependencies: [
- // TODO(real-integration): add when wiring real engines / distribution.
- // .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0"),
- // .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
- // .package(url: "https://github.com/ml-explore/mlx-swift", from: "0.18.0"),
- // The Harper grammar core is bundled via a Rust static lib + C-FFI; see harper-ffi/.
+ // 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"),
+ // Secure auto-updates (EdDSA-signed appcast served from grammargem.com).
+ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
],
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",
- path: "Sources/GrammaGem"
+ name: "GrammarGem",
+ dependencies: [
+ "CHarper",
+ .product(name: "MLXLLM", package: "mlx-swift-examples"),
+ .product(name: "MLXLMCommon", package: "mlx-swift-examples"),
+ .product(name: "Sparkle", package: "Sparkle"),
+ ],
+ path: "Sources/GrammarGem",
+ linkerSettings: [
+ // Link the prebuilt Harper static library (arm64 slice).
+ .unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"])
+ ]
),
.testTarget(
- name: "GrammaGemTests",
- dependencies: ["GrammaGem"],
- path: "Tests/GrammaGemTests"
+ name: "GrammarGemTests",
+ dependencies: ["GrammarGem"],
+ path: "Tests/GrammarGemTests"
),
]
)
diff --git a/README.md b/README.md
index c4b5b01..5a7a26e 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# ✒️ GrammaGem
+# ✒️ GrammarGem
### Write it once. Get it right.
@@ -12,7 +12,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay


-[**⬇️ Download GrammaGem**](https://grammagem.app/#pricing) · [Docs](https://grammagem.app/docs) · [How it works](https://grammagem.app)
+[**⬇️ Download GrammarGem**](https://www.grammargem.com/#pricing) · [Docs](https://www.grammargem.com/docs) · [How it works](https://www.grammargem.com)
@@ -20,7 +20,7 @@ Fix grammar, sharpen tone, and rewrite anything — in every app — for one pay
## Stop renting your grammar checker.
-Grammarly costs around **$144 every year**, reads everything you type, and sends it to their servers. GrammaGem does the same job — **better** — entirely on your Mac, for a **single payment**.
+Grammarly costs around **$144 every year**, reads everything you type, and sends it to their servers. GrammarGem does the same job — **better** — entirely on your Mac, for a **single payment**.
Select text in any app, press one hotkey, and it’s polished in place. No copy-paste, no switching windows, and **nothing ever leaves your machine**.
@@ -30,7 +30,7 @@ Select text in any app, press one hotkey, and it’s polished in place. No copy-
## Why people switch
-| | Grammarly | **GrammaGem** |
+| | Grammarly | **GrammarGem** |
|---|:---:|:---:|
| **Price** | ~$144 / year, forever | **Pay once** |
| **Your words** | Sent to the cloud | **Never leave your Mac** |
@@ -46,7 +46,7 @@ Select text in any app, press one hotkey, and it’s polished in place. No copy-
- 🔒 **Private by design** — everything runs on your Mac’s Neural Engine. Your writing is never uploaded, logged, or used to train a model.
- ⌨️ **One global hotkey** — fix or rewrite the selected text in any app: Mail, Slack, Notion, Chrome, Messages, VS Code, and more.
- ✨ **Ask & rewrite** — “make it shorter,” “more formal,” “translate to German” — done in place.
-- 🎚️ **Writing Modes** — Email, Post, Team Chat, Academic, Code — switch the tone instantly, or let GrammaGem pick the right one for the app you’re in.
+- 🎚️ **Writing Modes** — Email, Post, Team Chat, Academic, Code — switch the tone instantly, or let GrammarGem pick the right one for the app you’re in.
- 📖 **Personal dictionary** — teach it your names, brand terms, and jargon once; it stops “correcting” them forever.
- 🖥️ **A clean management app** — manage your devices, shortcuts, modes, and privacy from one tidy window.
@@ -67,7 +67,7 @@ No subscription. No per-seat fees. One payment, lifetime updates.
-[**⬇️ Get GrammaGem**](https://grammagem.app/#pricing)
+[**⬇️ Get GrammarGem**](https://www.grammargem.com/#pricing)
@@ -85,17 +85,17 @@ No. Core features work fully offline.
No — it’s a one-time purchase with lifetime updates and a 14-day money-back guarantee.
**What about plagiarism detection?**
-GrammaGem is private-first, so it doesn’t do cloud plagiarism scanning — that would require sending your writing to a server. Everything else you’d expect is here.
+GrammarGem is private-first, so it doesn’t do cloud plagiarism scanning — that would require sending your writing to a server. Everything else you’d expect is here.
---
## Built in public
-GrammaGem is open source under the **MIT License**. We believe a privacy tool should be one you can actually inspect. Issues and pull requests are welcome.
+GrammarGem is open source under the **MIT License**. We believe a privacy tool should be one you can actually inspect. Issues and pull requests are welcome.
-**[grammagem.app](https://grammagem.app)**
+**[grammargem.com](https://www.grammargem.com)**
© 2026 FounderGem LLC · 1309 Coffeen Avenue STE 1200, Sheridan, WY 82801, USA
diff --git a/Resources/Modes/README.md b/Resources/Modes/README.md
index 3f56204..f343466 100644
--- a/Resources/Modes/README.md
+++ b/Resources/Modes/README.md
@@ -1,7 +1,7 @@
# Built-in Writing Modes
Each Mode is a named system-prompt preset for the local LLM. The canonical,
-compiled definitions live in [`Sources/GrammaGem/AI/ModeRegistry.swift`](../../Sources/GrammaGem/AI/ModeRegistry.swift);
+compiled definitions live in [`Sources/GrammarGem/AI/ModeRegistry.swift`](../../Sources/GrammarGem/AI/ModeRegistry.swift);
these JSON files mirror them for transparency and easy editing.
| Mode | Tier | In public repo? |
diff --git a/Sources/CHarper/include/harper.h b/Sources/CHarper/include/harper.h
new file mode 100644
index 0000000..45d5111
--- /dev/null
+++ b/Sources/CHarper/include/harper.h
@@ -0,0 +1,37 @@
+/*
+ * harper.h — C ABI for the harper-ffi static library (harper-core 2.5.0).
+ *
+ * Symbols are provided by libharper_ffi.a (linked via -lharper_ffi). All
+ * strings are UTF-8. See harper-ffi/ for the Rust source.
+ */
+#ifndef HARPER_FFI_H
+#define HARPER_FFI_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/*
+ * Check `text` for grammar/spelling issues using harper-core.
+ *
+ * Returns a malloc'd, NUL-terminated UTF-8 JSON C string the caller MUST free
+ * with harper_free(). The JSON is an array of objects:
+ * [{ "start":
, "len": ,
+ * "replacement": "", "message": "", "kind": "" }]
+ *
+ * - "start"/"len" are CHAR (Unicode scalar) offsets, NOT bytes/UTF-16.
+ * - "kind" is one of: spelling, grammar, punctuation, repetition,
+ * capitalization, phrasing.
+ *
+ * On null/invalid input or any internal error, returns "[]". Thread-safe.
+ */
+char *harper_check(const char *text);
+
+/* Free a string previously returned by harper_check(). NULL is a no-op. */
+void harper_free(char *ptr);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HARPER_FFI_H */
diff --git a/Sources/CHarper/include/module.modulemap b/Sources/CHarper/include/module.modulemap
new file mode 100644
index 0000000..67118c8
--- /dev/null
+++ b/Sources/CHarper/include/module.modulemap
@@ -0,0 +1,4 @@
+module CHarper {
+ header "harper.h"
+ export *
+}
diff --git a/Sources/CHarper/shim.c b/Sources/CHarper/shim.c
new file mode 100644
index 0000000..4b76007
--- /dev/null
+++ b/Sources/CHarper/shim.c
@@ -0,0 +1,4 @@
+// CHarper is a header-only shim that exposes libharper_ffi's C ABI (declared in
+// include/harper.h) to Swift as the `CHarper` module. The actual symbols are
+// provided by libharper_ffi.a, linked via the GrammarGem target's linkerSettings.
+// This translation unit exists only so SwiftPM treats CHarper as a C target.
diff --git a/Sources/GrammaGem/AI/AIEngine.swift b/Sources/GrammaGem/AI/AIEngine.swift
deleted file mode 100644
index cb03c01..0000000
--- a/Sources/GrammaGem/AI/AIEngine.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-import Foundation
-
-/// Layer 2 — local-LLM-powered rewriting / tone / "Ask" / translate.
-///
-/// Backed in production by **MLX** (mlx-swift) running a small instruct model
-/// downloaded once from Hugging Face. The protocol isolates the rest of the app
-/// from the runtime so the UI, gating, and tests don't depend on MLX being present.
-protocol AIEngine: AnyObject {
- /// Whether a model is loaded and ready to run locally.
- var isReady: Bool { get }
-
- /// Run an action against `text` and return the rewritten result.
- /// Throws on model-not-ready or generation failure. Never performs network I/O.
- func run(_ action: AIAction, on text: String) async throws -> String
-
- /// Best-effort local tone classification (used by tone-detection UI).
- func detectTone(_ text: String) async -> Tone
-}
-
-enum AIEngineError: LocalizedError {
- case modelNotReady
- case generationFailed(String)
-
- var errorDescription: String? {
- switch self {
- case .modelNotReady:
- return "The on-device model isn't ready yet. Finish the first-run download in Settings."
- case .generationFailed(let why):
- return "On-device rewrite failed: \(why)"
- }
- }
-}
diff --git a/Sources/GrammaGem/AI/MLXEngine.swift b/Sources/GrammaGem/AI/MLXEngine.swift
deleted file mode 100644
index 139bf9c..0000000
--- a/Sources/GrammaGem/AI/MLXEngine.swift
+++ /dev/null
@@ -1,91 +0,0 @@
-import Foundation
-
-/// STUB standing in for the real MLX-backed local LLM.
-///
-/// 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.
-///
-/// 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.
-final class MLXEngine: AIEngine {
- private let ready: Bool
-
- init(ready: Bool = true) {
- self.ready = ready
- }
-
- var isReady: Bool { ready }
-
- 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)
- }
- }
-
- 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 }
- 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(".") }
- 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)
- }
- }
-}
diff --git a/Sources/GrammaGem/App/GrammaGemApp.swift b/Sources/GrammaGem/App/GrammaGemApp.swift
deleted file mode 100644
index b2d538f..0000000
--- a/Sources/GrammaGem/App/GrammaGemApp.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-import SwiftUI
-import AppKit
-
-/// 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
-
- var body: some Scene {
- MenuBarExtra {
- MenuBarContent()
- .environmentObject(app)
- } label: {
- MenuBarLabel()
- }
- .menuBarExtraStyle(.window)
-
- Settings {
- SettingsView()
- .environmentObject(app)
- .frame(width: 560, height: 520)
- }
- }
-}
-
-/// Runs launch-time wiring (hotkeys, permissions, license validation).
-final class AppDelegate: NSObject, NSApplicationDelegate {
- func applicationDidFinishLaunching(_ notification: Notification) {
- AppState.shared.start()
- }
-}
diff --git a/Sources/GrammaGem/App/OnboardingView.swift b/Sources/GrammaGem/App/OnboardingView.swift
deleted file mode 100644
index 22d7b0c..0000000
--- a/Sources/GrammaGem/App/OnboardingView.swift
+++ /dev/null
@@ -1,138 +0,0 @@
-import SwiftUI
-
-/// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links
-/// to System Settings, and advances automatically the moment permission is
-/// granted (even if granted directly in System Settings).
-struct OnboardingView: View {
- @EnvironmentObject private var app: AppState
- @ObservedObject private var permissions: Permissions
- @ObservedObject private var model: ModelManager
-
- init() {
- _permissions = ObservedObject(wrappedValue: AppState.shared.permissions)
- _model = ObservedObject(wrappedValue: AppState.shared.model)
- }
-
- var body: some View {
- VStack(alignment: .leading, spacing: 18) {
- VStack(alignment: .leading, spacing: 6) {
- Text("Welcome to GrammaGem")
- .font(.largeTitle.bold())
- Text("A private, on-device writing assistant that works in every app.")
- .foregroundStyle(.secondary)
- }
-
- stepCard(
- number: 1,
- title: "Grant Accessibility access",
- body: "GrammaGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.",
- granted: permissions.accessibilityTrusted
- ) {
- if permissions.accessibilityTrusted {
- Label("Permission granted", systemImage: "checkmark.circle.fill")
- .foregroundStyle(.green)
- } else {
- HStack {
- Button("Open System Settings") { permissions.openAccessibilitySettings() }
- Button("Request permission") { permissions.requestAccessibility() }
- }
- Text("This updates automatically once you flip GrammaGem on in Accessibility.")
- .font(.caption).foregroundStyle(.secondary)
- }
- }
-
- stepCard(
- number: 2,
- title: "Download the on-device model (optional)",
- body: "Grammar & spelling work instantly with no download. The AI rewrite/tone/Ask features use a small model downloaded once from Hugging Face — then everything runs offline.",
- granted: model.state == .ready
- ) {
- ModelDownloadControls()
- }
-
- Spacer()
-
- HStack {
- if permissions.accessibilityTrusted {
- Label("You're all set", systemImage: "sparkles").foregroundStyle(.secondary)
- }
- Spacer()
- Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
- app.showOnboardingDismiss()
- }
- .keyboardShortcut(.defaultAction)
- }
- }
- .padding(28)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- .onAppear {
- permissions.startPollingUntilGranted()
- }
- .onDisappear {
- permissions.stopPolling()
- }
- }
-
- private func stepCard(
- number: Int, title: String, body: String, granted: Bool,
- @ViewBuilder controls: () -> Controls
- ) -> some View {
- HStack(alignment: .top, spacing: 14) {
- ZStack {
- Circle().fill(granted ? Color.green : Color.accentColor.opacity(0.15))
- .frame(width: 30, height: 30)
- if granted {
- Image(systemName: "checkmark").foregroundStyle(.white).font(.caption.bold())
- } else {
- Text("\(number)").font(.callout.bold()).foregroundStyle(.tint)
- }
- }
- VStack(alignment: .leading, spacing: 8) {
- Text(title).font(.headline)
- Text(body).font(.callout).foregroundStyle(.secondary)
- controls()
- }
- }
- .padding(16)
- .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12))
- }
-}
-
-/// Real model download progress + controls, shared by onboarding and settings.
-struct ModelDownloadControls: View {
- @ObservedObject private var model: ModelManager
-
- init() {
- _model = ObservedObject(wrappedValue: AppState.shared.model)
- }
-
- var body: some View {
- switch model.state {
- case .notDownloaded:
- VStack(alignment: .leading, spacing: 4) {
- Button("Download model now") { model.startDownload() }
- Text("Installs automatically in the background on first launch (about 1–2 GB from Hugging Face). One time, then fully offline.")
- .font(.caption).foregroundStyle(.secondary)
- }
- case .downloading(let progress):
- VStack(alignment: .leading, spacing: 6) {
- ProgressView(value: progress) {
- Text("Downloading… \(Int(progress * 100))%")
- }
- .frame(maxWidth: 320)
- if !model.statusText.isEmpty {
- Text(model.statusText).font(.caption).foregroundStyle(.secondary).lineLimit(1)
- }
- Button("Cancel") { model.cancel() }.controlSize(.small)
- }
- case .ready:
- Label("Model ready", systemImage: "checkmark.seal.fill").foregroundStyle(.green)
- case .failed(let message):
- VStack(alignment: .leading, spacing: 4) {
- Label("Download failed", systemImage: "exclamationmark.triangle").foregroundStyle(.orange)
- Text(message).font(.caption).foregroundStyle(.secondary).lineLimit(2)
- Button("Retry") { model.startDownload() }
- }
- }
- }
-}
diff --git a/Sources/GrammaGem/Grammar/HarperEngine.swift b/Sources/GrammaGem/Grammar/HarperEngine.swift
deleted file mode 100644
index a87af38..0000000
--- a/Sources/GrammaGem/Grammar/HarperEngine.swift
+++ /dev/null
@@ -1,75 +0,0 @@
-import Foundation
-
-/// STUB standing in for the real Harper core.
-///
-/// TODO(real-integration): replace the rule set below with calls into the Harper
-/// Rust crate over C-FFI (`harper_check(text) -> spans`). This stub implements a
-/// handful of Harper-style deterministic rules so the system loop, live
-/// underlines, and tests have something real to exercise. It runs in well under
-/// 10ms and is fully offline — matching Harper's performance profile.
-final class HarperEngine: GrammarEngine {
- var ignoreList: Set = []
-
- func check(_ text: String) -> [Suggestion] {
- var out: [Suggestion] = []
- let ns = text as NSString
-
- // Rule 1 — repeated word ("the the" -> "the").
- out += matches(in: text, pattern: #"\b(\w+)\s+\1\b"#) { match in
- let full = ns.substring(with: match.range)
- let firstWord = ns.substring(with: match.range(at: 1))
- guard !ignoreList.contains(firstWord.lowercased()) else { return nil }
- return Suggestion(
- location: match.range.location, length: match.range.length,
- original: full, replacement: firstWord,
- kind: .repetition, message: "Repeated word")
- }
-
- // Rule 2 — lowercase standalone "i" -> "I".
- out += matches(in: text, pattern: #"(?<=\s|^)i(?=\s|$|')"#) { match in
- Suggestion(
- location: match.range.location, length: match.range.length,
- original: "i", replacement: "I",
- kind: .capitalization, message: "Capitalize the pronoun “I”")
- }
-
- // Rule 3 — a few common confusions / typos.
- let confusions: [(String, String, GrammarKind, String)] = [
- (#"\btheir\b(?=\s+(any|are|is|was|were)\b)"#, "there", .grammar, "Did you mean “there”?"),
- (#"\bteh\b"#, "the", .spelling, "Spelling"),
- (#"\brecieve\b"#, "receive", .spelling, "Spelling (i before e)"),
- (#"\bcheckin\b"#, "check in", .phrasing, "“check in” is two words"),
- (#"\blmk\b"#, "let me know", .phrasing, "Expand abbreviation"),
- (#"(?<=\s|^)u(?=\s|$)"#, "you", .phrasing, "Expand “u”"),
- ]
- for (pattern, replacement, kind, message) in confusions {
- out += matches(in: text, pattern: pattern) { match in
- let original = ns.substring(with: match.range)
- guard !ignoreList.contains(original.lowercased()) else { return nil }
- return Suggestion(
- location: match.range.location, length: match.range.length,
- original: original, replacement: replacement,
- kind: kind, message: message)
- }
- }
-
- // Rule 4 — sentence should start uppercase (very light heuristic).
- // (Left to the real Harper integration; intentionally omitted to avoid
- // false positives in this stub.)
-
- return out.sorted { $0.location < $1.location }
- }
-
- // MARK: - Regex helper
-
- private func matches(
- in text: String, pattern: String,
- _ make: (NSTextCheckingResult) -> Suggestion?
- ) -> [Suggestion] {
- guard let re = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
- return []
- }
- let full = NSRange(text.startIndex..., in: text)
- return re.matches(in: text, range: full).compactMap(make)
- }
-}
diff --git a/Sources/GrammaGem/Grammar/SystemGrammarEngine.swift b/Sources/GrammaGem/Grammar/SystemGrammarEngine.swift
deleted file mode 100644
index 2854b78..0000000
--- a/Sources/GrammaGem/Grammar/SystemGrammarEngine.swift
+++ /dev/null
@@ -1,93 +0,0 @@
-import Foundation
-import AppKit
-
-/// Real, on-device grammar + spelling using macOS's built-in `NSSpellChecker`
-/// (the same engine TextEdit/Mail use). No network, no model — fully local,
-/// matching the privacy promise. Augmented with a few deterministic Harper-style
-/// rules (repeated words, lowercase "i", common expansions) the system checker
-/// doesn't surface.
-final class SystemGrammarEngine: GrammarEngine {
- var ignoreList: Set = []
-
- private let checker = NSSpellChecker.shared
- private let tag = NSSpellChecker.uniqueSpellDocumentTag()
- private let extras = HarperEngine()
-
- func check(_ text: String) -> [Suggestion] {
- guard text.count >= 2 else { return [] }
- return onMain { self.checkOnMain(text) }
- }
-
- private func checkOnMain(_ text: String) -> [Suggestion] {
- let ns = text as NSString
- let full = NSRange(location: 0, length: ns.length)
- let language = checker.language()
- var out: [Suggestion] = []
-
- let types = NSTextCheckingResult.CheckingType([.spelling, .grammar]).rawValue
- let results = checker.check(
- text, range: full, types: types, options: nil,
- inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil)
-
- for r in results {
- switch r.resultType {
- case .spelling:
- let word = ns.substring(with: r.range)
- if ignoreList.contains(word.lowercased()) { continue }
- let fix = checker.correction(
- forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag)
- ?? checker.guesses(
- forWordRange: r.range, in: text, language: language,
- inSpellDocumentWithTag: tag)?.first
- out.append(Suggestion(
- location: r.range.location, length: r.range.length,
- original: word, replacement: fix ?? word,
- kind: .spelling, message: "Possible spelling mistake"))
-
- case .grammar:
- let details = r.grammarDetails ?? []
- if details.isEmpty {
- let phrase = ns.substring(with: r.range)
- out.append(Suggestion(
- location: r.range.location, length: r.range.length,
- original: phrase, replacement: phrase,
- kind: .grammar, message: "Grammar issue"))
- } else {
- for d in details {
- var loc = r.range.location, len = r.range.length
- if let rv = d[NSGrammarRange] as? NSValue {
- let sub = rv.rangeValue
- loc = r.range.location + sub.location
- len = sub.length
- }
- guard len > 0, loc + len <= ns.length else { continue }
- let phrase = ns.substring(with: NSRange(location: loc, length: len))
- let message = (d[NSGrammarUserDescription] as? String) ?? "Grammar issue"
- let corrections = (d[NSGrammarCorrections] as? [String]) ?? []
- out.append(Suggestion(
- location: loc, length: len,
- original: phrase, replacement: corrections.first ?? phrase,
- kind: .grammar, message: message))
- }
- }
- default:
- break
- }
- }
-
- // Deterministic extras, skipping anything overlapping a system result.
- extras.ignoreList = ignoreList
- for h in extras.check(text) where !out.contains(where: { overlaps($0.range, h.range) }) {
- out.append(h)
- }
- return out.sorted { $0.location < $1.location }
- }
-
- private func overlaps(_ a: NSRange, _ b: NSRange) -> Bool {
- NSIntersectionRange(a, b).length > 0
- }
-
- private func onMain(_ work: @escaping () -> T) -> T {
- Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work)
- }
-}
diff --git a/Sources/GrammaGem/Stats/UsageStats.swift b/Sources/GrammaGem/Stats/UsageStats.swift
deleted file mode 100644
index 3495f35..0000000
--- a/Sources/GrammaGem/Stats/UsageStats.swift
+++ /dev/null
@@ -1,90 +0,0 @@
-import Foundation
-
-/// Privacy-friendly, fully-local usage insights. Counts *how often* features are
-/// used — never *what* was written. Stored only on this Mac.
-@MainActor
-final class UsageStats: ObservableObject {
- struct Snapshot: Codable, Equatable {
- var totalCorrections = 0
- var totalAIActions = 0
- var totalWords = 0
- var perDay: [String: Int] = [:] // "yyyy-MM-dd" -> actions that day
- }
-
- @Published private(set) var data: Snapshot
-
- private let key = "GrammaGem.usageStats"
- private let d = UserDefaults.standard
-
- private static let fmt: DateFormatter = {
- let f = DateFormatter()
- f.dateFormat = "yyyy-MM-dd"
- f.locale = Locale(identifier: "en_US_POSIX")
- f.timeZone = .current
- return f
- }()
-
- init() {
- if let raw = d.data(forKey: key),
- let decoded = try? JSONDecoder().decode(Snapshot.self, from: raw) {
- data = decoded
- } else {
- data = Snapshot()
- }
- }
-
- func recordCorrection(words: Int) {
- data.totalCorrections += 1
- data.totalWords += max(0, words)
- bumpToday()
- persist()
- }
-
- func recordAIAction(words: Int) {
- data.totalAIActions += 1
- data.totalWords += max(0, words)
- bumpToday()
- persist()
- }
-
- /// A playful, clearly-estimated "time saved" figure (local only).
- var estimatedMinutesSaved: Int {
- (data.totalCorrections * 8 + data.totalAIActions * 45) / 60
- }
-
- /// Counts for the last 7 calendar days, oldest → newest, for a small chart.
- func last7Days() -> [(label: String, count: Int)] {
- let cal = Calendar.current
- let today = Date()
- return (0..<7).reversed().map { offset in
- let day = cal.date(byAdding: .day, value: -offset, to: today) ?? today
- let key = Self.fmt.string(from: day)
- let short = day.formatted(.dateTime.weekday(.narrow))
- return (label: short, count: data.perDay[key] ?? 0)
- }
- }
-
- func reset() {
- data = Snapshot()
- persist()
- }
-
- private func bumpToday() {
- let k = Self.fmt.string(from: Date())
- data.perDay[k, default: 0] += 1
- // Keep only ~60 days of history.
- if data.perDay.count > 70 {
- let keep = Set((0..<60).compactMap { off -> String? in
- guard let day = Calendar.current.date(byAdding: .day, value: -off, to: Date()) else { return nil }
- return Self.fmt.string(from: day)
- })
- data.perDay = data.perDay.filter { keep.contains($0.key) }
- }
- }
-
- private func persist() {
- if let raw = try? JSONEncoder().encode(data) {
- d.set(raw, forKey: key)
- }
- }
-}
diff --git a/Sources/GrammaGem/System/AXSupport.swift b/Sources/GrammaGem/System/AXSupport.swift
deleted file mode 100644
index 7dd5567..0000000
--- a/Sources/GrammaGem/System/AXSupport.swift
+++ /dev/null
@@ -1,39 +0,0 @@
-import Foundation
-import ApplicationServices
-
-/// Safe Accessibility helpers shared across the System layer.
-///
-/// `AXUIElementCopyAttributeValue` returning `.success` only guarantees a
-/// non-null `CFTypeRef` — NOT that it is an `AXUIElement`. A misbehaving AX
-/// server (Electron/Java/custom) can return a different CF type, and a forced
-/// `as!` cast then traps and crashes the whole app. These helpers cast safely.
-enum AX {
- /// Safely bridge a CFTypeRef to AXUIElement, returning nil on type mismatch.
- static func element(_ ref: CFTypeRef?) -> AXUIElement? {
- guard let ref, CFGetTypeID(ref) == AXUIElementGetTypeID() else { return nil }
- return (ref as! AXUIElement) // safe: type id verified above
- }
-
- /// Copy an attribute as an `AXUIElement` (nil on failure/type mismatch).
- static func copyElement(_ element: AXUIElement, _ attribute: String) -> AXUIElement? {
- var ref: CFTypeRef?
- guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
- return nil
- }
- return AX.element(ref)
- }
-
- /// Copy an attribute as a `String` (nil on failure/wrong type).
- static func copyString(_ element: AXUIElement, _ attribute: String) -> String? {
- var ref: CFTypeRef?
- guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
- return nil
- }
- return ref as? String
- }
-
- /// Bound each cross-process AX message so a wedged target app can't hang us.
- static func setMessagingTimeout(_ element: AXUIElement, _ seconds: Float) {
- AXUIElementSetMessagingTimeout(element, seconds)
- }
-}
diff --git a/Sources/GrammarGem/AI/AIEngine.swift b/Sources/GrammarGem/AI/AIEngine.swift
new file mode 100644
index 0000000..1911059
--- /dev/null
+++ b/Sources/GrammarGem/AI/AIEngine.swift
@@ -0,0 +1,73 @@
+import Foundation
+
+/// Layer 2 — local-LLM-powered rewriting / tone / "Ask" / translate.
+///
+/// Backed in production by **MLX** (mlx-swift) running a small instruct model
+/// downloaded once from Hugging Face. The protocol isolates the rest of the app
+/// from the runtime so the UI, gating, and tests don't depend on MLX being present.
+protocol AIEngine: AnyObject {
+ /// Whether a model is loaded and ready to run locally.
+ var isReady: Bool { get }
+
+ /// Run an action against `text` and return the rewritten result.
+ /// `protectedTerms` are vocabulary the model must reproduce verbatim (the
+ /// personal dictionary / style-guide terms). Throws on model-not-ready or
+ /// generation failure. Never performs network I/O on the on-device backend.
+ func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String
+
+ /// Best-effort local tone classification (used by tone-detection UI).
+ func detectTone(_ text: String) async -> Tone
+
+ /// Optionally preload the model so the first real action is instant.
+ func warmup() async
+
+ /// Streaming variant: yields incremental output as it generates. The default
+ /// implementation wraps `run` as a single chunk, so a backend that doesn't
+ /// natively stream still conforms.
+ func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
+ -> AsyncThrowingStream
+}
+
+extension AIEngine {
+ /// Convenience: run with no protected terms.
+ func run(_ action: AIAction, on text: String) async throws -> String {
+ try await run(action, on: text, protectedTerms: [])
+ }
+
+ /// Default: no preload.
+ func warmup() async {}
+
+ /// Default streaming: produce the full result, then emit it as one chunk.
+ func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
+ -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ Task {
+ do {
+ continuation.yield(try await run(action, on: text, protectedTerms: protectedTerms))
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ }
+ }
+
+ /// Convenience streaming overload with no protected terms.
+ func runStreaming(_ action: AIAction, on text: String) -> AsyncThrowingStream {
+ runStreaming(action, on: text, protectedTerms: [])
+ }
+}
+
+enum AIEngineError: LocalizedError {
+ case modelNotReady
+ case generationFailed(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .modelNotReady:
+ return "The on-device model isn't ready yet. Finish the first-run download in Settings."
+ case .generationFailed(let why):
+ return "On-device rewrite failed: \(why)"
+ }
+ }
+}
diff --git a/Sources/GrammarGem/AI/AIPromptKit.swift b/Sources/GrammarGem/AI/AIPromptKit.swift
new file mode 100644
index 0000000..0194c08
--- /dev/null
+++ b/Sources/GrammarGem/AI/AIPromptKit.swift
@@ -0,0 +1,111 @@
+import Foundation
+
+/// Backend-neutral prompt construction, generation parameters, and output
+/// cleanup for Layer-2 actions. Extracted from `MLXEngine` so EVERY `AIEngine`
+/// backend (MLX today, Ollama/llama.cpp next, the localhost extension server) is
+/// guaranteed to produce identical prompts and identical output trimming — no
+/// behavioral drift between runtimes — and so the personal dictionary / a future
+/// style guide can inject protected vocabulary in exactly ONE place.
+///
+/// Everything here is a pure function of its inputs (no MLX, no I/O), which also
+/// makes per-action behavior unit-testable without a model present.
+enum AIPrompts {
+
+ /// The system prompt for an action. `protectedTerms` (personal-dictionary
+ /// entries + future style-guide vocabulary) are terms the model must never
+ /// alter, so AI rewrites stop "correcting" brand names and jargon.
+ static func systemPrompt(for action: AIAction, protectedTerms: [String] = []) -> String {
+ base(for: action) + protectedTermsClause(protectedTerms)
+ }
+
+ private static func base(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
+ }
+ }
+
+ /// Appended to every system prompt: terms the model must reproduce verbatim
+ /// (brand names, jargon the user added to their personal dictionary). Capped
+ /// so a large dictionary can't blow the context window.
+ private static func protectedTermsClause(_ terms: [String]) -> String {
+ let cleaned = terms
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ guard !cleaned.isEmpty else { return "" }
+ let list = cleaned.prefix(50).map { "\"\($0)\"" }.joined(separator: ", ")
+ return " Never change, translate, or \"correct\" these exact terms — reproduce them "
+ + "verbatim, including capitalization: \(list)."
+ }
+
+ /// Low temperature for faithful rewrites; a touch higher for free paraphrase.
+ static func temperature(for action: AIAction) -> Float {
+ switch action {
+ case .rewrite: return 0.5
+ case .ask: return 0.4
+ default: return 0.2
+ }
+ }
+
+ /// Generation token budget. Honors a Mode's `lengthCap` (in characters) for
+ /// short-form modes (Post = 280, Team Chat = 60) so we don't spend the full
+ /// 512-token default on a one-line message; falls back to the default otherwise.
+ static func maxTokens(for action: AIAction) -> Int {
+ let fallback = 512
+ guard case .applyMode(let mode) = action, let cap = mode.lengthCap else { return fallback }
+ // ~1 token per 3 characters of English, plus headroom; clamp to a floor.
+ return min(fallback, max(64, cap / 3 + 48))
+ }
+
+ /// Strip a leading "Sure, here's …:" preamble and surrounding quotes that small
+ /// instruct models sometimes add, so the result drops cleanly into the field.
+ static 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)
+ }
+
+ /// The tone-classifier system prompt (kept here so any backend reuses it).
+ static func toneClassifierPrompt() -> String {
+ let options = Tone.allCases.map(\.rawValue).joined(separator: ", ")
+ return """
+ 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.
+ """
+ }
+
+ /// Parse the one-word tone classification output into a `Tone`.
+ static 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
+ }
+}
diff --git a/Sources/GrammaGem/AI/CustomModesStore.swift b/Sources/GrammarGem/AI/CustomModesStore.swift
similarity index 72%
rename from Sources/GrammaGem/AI/CustomModesStore.swift
rename to Sources/GrammarGem/AI/CustomModesStore.swift
index 9c15c09..29dd876 100644
--- a/Sources/GrammaGem/AI/CustomModesStore.swift
+++ b/Sources/GrammarGem/AI/CustomModesStore.swift
@@ -8,8 +8,8 @@ final class CustomModesStore: ObservableObject {
/// bundleID -> mode id (overrides the default App-Aware mapping).
@Published private(set) var appOverrides: [String: String]
- private let modesKey = "GrammaGem.customModes"
- private let mapKey = "GrammaGem.appModeOverrides"
+ private let modesKey = "GrammarGem.customModes"
+ private let mapKey = "GrammarGem.appModeOverrides"
private let d = UserDefaults.standard
init() {
@@ -25,19 +25,27 @@ final class CustomModesStore: ObservableObject {
/// Built-in + custom modes (custom ones are always paid).
func allModes() -> [WritingMode] { ModeRegistry.all + customModes }
- func addCustom(name: String, prompt: String) {
+ /// Returns false (no-op) if the name or prompt is empty, or the name
+ /// collides with an existing built-in/custom mode — so an empty-prompt mode
+ /// can't slip through to the LLM as an empty system prompt.
+ @discardableResult
+ func addCustom(name: String, prompt: String) -> Bool {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return }
+ let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, !trimmedPrompt.isEmpty else { return false }
+ guard !allModes().contains(where: { $0.name.caseInsensitiveCompare(trimmed) == .orderedSame })
+ else { return false }
let mode = WritingMode(
id: "custom-\(UUID().uuidString.prefix(8))",
name: trimmed,
- systemPrompt: prompt.trimmingCharacters(in: .whitespacesAndNewlines),
+ systemPrompt: trimmedPrompt,
isPaid: true,
lengthCap: nil,
autoFormat: "Custom"
)
customModes.append(mode)
persistModes()
+ return true
}
func removeCustom(id: String) {
diff --git a/Sources/GrammarGem/AI/MLXEngine.swift b/Sources/GrammarGem/AI/MLXEngine.swift
new file mode 100644
index 0000000..d46c12f
--- /dev/null
+++ b/Sources/GrammarGem/AI/MLXEngine.swift
@@ -0,0 +1,149 @@
+import Foundation
+import MLXLMCommon
+import MLXLLM
+
+/// 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).
+///
+/// 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.
+///
+/// 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 {
+
+ /// 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 { modelDirectoryProvider() != nil }
+
+ // MARK: - AIEngine
+
+ func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return text }
+
+ let container = try await container()
+ let params = GenerateParameters(temperature: AIPrompts.temperature(for: action))
+ let raw = try await complete(
+ system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms),
+ user: trimmed,
+ maxTokens: AIPrompts.maxTokens(for: action),
+ parameters: params, container: container)
+ return AIPrompts.clean(raw)
+ }
+
+ /// Preload the model container so the first real action doesn't pay the
+ /// multi-second load. Safe to call repeatedly (the loader caches).
+ func warmup() async {
+ _ = try? await container()
+ }
+
+ func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
+ -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ Task {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { continuation.yield(text); continuation.finish(); return }
+ do {
+ let container = try await container()
+ var params = GenerateParameters(temperature: AIPrompts.temperature(for: action))
+ params.maxTokens = AIPrompts.maxTokens(for: action)
+ let system = AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms)
+ try await container.perform { context in
+ let input = try await context.processor.prepare(
+ input: UserInput(chat: [.system(system), .user(trimmed)]))
+ let stream = try MLXLMCommon.generate(
+ input: input, parameters: params, context: context)
+ for await item in stream {
+ if let chunk = item.chunk { continuation.yield(chunk) }
+ }
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ }
+ }
+
+ func detectTone(_ text: String) async -> Tone {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return .professional }
+ do {
+ let container = try await container()
+ let raw = try await complete(
+ system: AIPrompts.toneClassifierPrompt(), user: trimmed, maxTokens: 8,
+ parameters: GenerateParameters(temperature: 0.0), container: container)
+ return AIPrompts.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)
+ }
+ }
+
+ // Prompt construction, temperature, token budget, and output cleanup now live
+ // in `AIPrompts` (backend-neutral; see AIPromptKit.swift) so every AIEngine
+ // backend produces identical prompts and identical output trimming.
+}
+
+/// 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
+ }
+}
diff --git a/Sources/GrammaGem/AI/ModeRegistry.swift b/Sources/GrammarGem/AI/ModeRegistry.swift
similarity index 100%
rename from Sources/GrammaGem/AI/ModeRegistry.swift
rename to Sources/GrammarGem/AI/ModeRegistry.swift
diff --git a/Sources/GrammaGem/AI/ModelManager.swift b/Sources/GrammarGem/AI/ModelManager.swift
similarity index 67%
rename from Sources/GrammaGem/AI/ModelManager.swift
rename to Sources/GrammarGem/AI/ModelManager.swift
index fdcff5d..97f814e 100644
--- a/Sources/GrammaGem/AI/ModelManager.swift
+++ b/Sources/GrammarGem/AI/ModelManager.swift
@@ -14,7 +14,9 @@ final class ModelManager: ObservableObject {
@Published private(set) var state: State = .notDownloaded
@Published private(set) var statusText: String = ""
- @Published var selectedRepo: String = AppConfig.Model.defaultRepo
+ @Published var selectedRepo: String = ModelManager.activeRepo() {
+ didSet { UserDefaults.standard.set(selectedRepo, forKey: Self.selectedRepoKey) }
+ }
private var task: Task?
private var generation = 0
@@ -23,20 +25,65 @@ final class ModelManager: ObservableObject {
if isModelPresent(repo: selectedRepo) { state = .ready }
}
- var modelsDirectory: URL {
- let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
- return base.appendingPathComponent("GrammaGem/Models", isDirectory: true)
- }
+ var modelsDirectory: URL { Self.modelsDirectory() }
- func modelDir(_ repo: String) -> URL {
- modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true)
- }
+ func modelDir(_ repo: String) -> URL { Self.modelDir(repo) }
/// Considered present once the completion marker is written.
- func isModelPresent(repo: String) -> Bool {
+ 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("GrammarGem/Models", isDirectory: true)
+ }
+
+ nonisolated static func modelDir(_ repo: String) -> URL {
+ modelsDirectory().appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true)
+ }
+
+ 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
+ }
+
+ /// UserDefaults key backing the user's chosen model repo.
+ nonisolated static let selectedRepoKey = "GrammarGem.selectedModelRepo"
+
+ /// The user's currently-selected repo, readable from ANY thread (it lives in
+ /// UserDefaults, which is thread-safe). The AI engine's model-directory
+ /// provider reads this so download, load, and `isReady` all agree on the SAME
+ /// repo — including the paid large model, which the old hardcoded-default
+ /// provider silently ignored. Falls back to the default repo when unset.
+ nonisolated static func activeRepo() -> String {
+ UserDefaults.standard.string(forKey: selectedRepoKey) ?? AppConfig.Model.defaultRepo
+ }
+
+ /// Safe URL construction for the two Hugging Face endpoints. A malformed
+ /// repo/path yields nil → a thrown `ModelError`, never a force-unwrap crash.
+ nonisolated static func fileURL(repo: String, path: String) -> URL? {
+ var c = URLComponents(string: AppConfig.Model.huggingFaceBase)
+ c?.path = "/\(repo)/resolve/main/\(path)"
+ return c?.url
+ }
+
+ nonisolated static func treeURL(repo: String) -> URL? {
+ var c = URLComponents(string: AppConfig.Model.huggingFaceBase)
+ c?.path = "/api/models/\(repo)/tree/main"
+ c?.queryItems = [URLQueryItem(name: "recursive", value: "1")]
+ return c?.url
+ }
+
/// Kick off (or resume to) a real download. Safe to call from the UI.
func startDownload(repo: String? = nil) {
let target = repo ?? selectedRepo
@@ -74,7 +121,9 @@ final class ModelManager: ObservableObject {
try Task.checkCancellation()
set(gen, .downloading(progress: min(1, Double(done) / Double(total))),
"Downloading \(file.path) (\(byteString(file.size)))…")
- let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/\(repo)/resolve/main/\(file.path)")!
+ guard let url = Self.fileURL(repo: repo, path: file.path) else {
+ throw ModelError.invalidURL(file.path)
+ }
let (tmp, response) = try await URLSession.shared.download(from: url)
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
throw ModelError.http(http.statusCode, file.path)
@@ -130,7 +179,7 @@ final class ModelManager: ObservableObject {
/// List the repo's files (+ sizes) from the Hugging Face tree API.
private func fetchFileList(repo: String) async throws -> [(path: String, size: Int64)] {
- let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/api/models/\(repo)/tree/main?recursive=1")!
+ guard let url = Self.treeURL(repo: repo) else { throw ModelError.invalidURL("file list") }
let (data, response) = try await URLSession.shared.data(from: url)
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
throw ModelError.http(http.statusCode, "file list")
@@ -148,11 +197,13 @@ final class ModelManager: ObservableObject {
case empty
case http(Int, String)
case corrupt(String)
+ case invalidURL(String)
var errorDescription: String? {
switch self {
case .empty: return "No model files were found for this repository."
case .http(let code, let what): return "Download failed (\(code)) while fetching \(what)."
case .corrupt(let what): return "Downloaded file looked wrong (\(what)); please retry."
+ case .invalidURL(let what): return "Couldn't build a valid download URL for \(what)."
}
}
}
diff --git a/Sources/GrammarGem/AI/OllamaEngine.swift b/Sources/GrammarGem/AI/OllamaEngine.swift
new file mode 100644
index 0000000..842d870
--- /dev/null
+++ b/Sources/GrammarGem/AI/OllamaEngine.swift
@@ -0,0 +1,176 @@
+import Foundation
+
+/// A cross-platform Layer-2 backend that talks to a locally-running **Ollama**
+/// server over HTTP. Conforms to the same `AIEngine` seam as `MLXEngine`, reuses
+/// the backend-neutral `AIPrompts` (so prompts/output are identical across
+/// runtimes), and depends only on `URLSession` — no MLX, no Metal — so it compiles
+/// and runs anywhere, including Windows/Linux. This is what converts the AIEngine
+/// abstraction from nominal to real and opens the non-Apple-Silicon market.
+///
+/// Privacy is preserved: Ollama runs the model on the user's own machine on
+/// loopback (127.0.0.1); no text leaves the device.
+final class OllamaEngine: AIEngine {
+ private let base: URL
+ private let model: String
+ private let session: URLSession
+
+ /// `isReady` is read synchronously by the coordinator but readiness for an HTTP
+ /// backend is an async probe, so we cache the last-known result behind a lock.
+ private let lock = NSLock()
+ private var cachedReady = false
+
+ init(base: URL = AppConfig.Ollama.baseURL,
+ model: String = AppConfig.Ollama.defaultModel,
+ session: URLSession = .shared) {
+ self.base = base
+ self.model = model
+ self.session = session
+ }
+
+ var isReady: Bool {
+ lock.lock(); defer { lock.unlock() }
+ return cachedReady
+ }
+
+ private func setReady(_ value: Bool) {
+ lock.lock(); cachedReady = value; lock.unlock()
+ }
+
+ // MARK: - AIEngine
+
+ /// Probe the server and confirm the configured model is pulled. Readiness for
+ /// Ollama means "server up + model present" (vs. MLX's "weights on disk").
+ func warmup() async {
+ setReady(await probeModelPresent())
+ }
+
+ func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return text }
+ let raw = try await chat(
+ system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms),
+ user: trimmed,
+ temperature: AIPrompts.temperature(for: action),
+ maxTokens: AIPrompts.maxTokens(for: action))
+ setReady(true)
+ return AIPrompts.clean(raw)
+ }
+
+ func detectTone(_ text: String) async -> Tone {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return .professional }
+ do {
+ let raw = try await chat(
+ system: AIPrompts.toneClassifierPrompt(), user: trimmed,
+ temperature: 0.0, maxTokens: 8)
+ return AIPrompts.parseTone(raw)
+ } catch {
+ return .professional
+ }
+ }
+
+ func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
+ -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ Task {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { continuation.yield(text); continuation.finish(); return }
+ do {
+ try await streamChat(
+ system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms),
+ user: trimmed,
+ temperature: AIPrompts.temperature(for: action),
+ maxTokens: AIPrompts.maxTokens(for: action),
+ onChunk: { continuation.yield($0) })
+ setReady(true)
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ }
+ }
+
+ // MARK: - HTTP
+
+ private func probeModelPresent() async -> Bool {
+ do {
+ let (data, response) = try await session.data(from: base.appendingPathComponent("api/tags"))
+ guard (response as? HTTPURLResponse)?.statusCode == 200 else { return false }
+ let names = (try JSONDecoder().decode(TagsResponse.self, from: data)).models.map(\.name)
+ // Match exact or family (e.g. configured "qwen2.5:3b" vs a pulled "qwen2.5:3b-instruct").
+ return names.contains(model)
+ || names.contains { $0.hasPrefix(model) || model.hasPrefix($0) }
+ } catch {
+ return false
+ }
+ }
+
+ private func chat(system: String, user: String,
+ temperature: Float, maxTokens: Int) async throws -> String {
+ let request = try makeRequest(path: "api/chat", body: ChatRequest(
+ model: model,
+ messages: [.init(role: "system", content: system), .init(role: "user", content: user)],
+ stream: false,
+ options: .init(temperature: temperature, num_predict: maxTokens)))
+ let (data, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw AIEngineError.generationFailed("No HTTP response from Ollama at \(base.absoluteString)")
+ }
+ guard http.statusCode == 200 else {
+ throw AIEngineError.generationFailed("Ollama returned HTTP \(http.statusCode)")
+ }
+ return (try JSONDecoder().decode(ChatResponse.self, from: data)).message?.content ?? ""
+ }
+
+ private func streamChat(system: String, user: String,
+ temperature: Float, maxTokens: Int,
+ onChunk: (String) -> Void) async throws {
+ let request = try makeRequest(path: "api/chat", body: ChatRequest(
+ model: model,
+ messages: [.init(role: "system", content: system), .init(role: "user", content: user)],
+ stream: true,
+ options: .init(temperature: temperature, num_predict: maxTokens)))
+ let (bytes, response) = try await session.bytes(for: request)
+ guard (response as? HTTPURLResponse)?.statusCode == 200 else {
+ throw AIEngineError.generationFailed("Ollama streaming request failed")
+ }
+ // Ollama streams newline-delimited JSON frames, one per token-ish chunk.
+ for try await line in bytes.lines {
+ guard let data = line.data(using: .utf8),
+ let frame = try? JSONDecoder().decode(ChatResponse.self, from: data),
+ let chunk = frame.message?.content, !chunk.isEmpty
+ else { continue }
+ onChunk(chunk)
+ }
+ }
+
+ private func makeRequest(path: String, body: T) throws -> URLRequest {
+ var request = URLRequest(url: base.appendingPathComponent(path))
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONEncoder().encode(body)
+ return request
+ }
+
+ // MARK: - Wire types
+
+ private struct ChatRequest: Encodable {
+ let model: String
+ let messages: [Message]
+ let stream: Bool
+ let options: Options
+ struct Message: Encodable { let role: String; let content: String }
+ struct Options: Encodable { let temperature: Float; let num_predict: Int }
+ }
+
+ private struct ChatResponse: Decodable {
+ let message: Message?
+ struct Message: Decodable { let content: String }
+ }
+
+ private struct TagsResponse: Decodable {
+ let models: [Model]
+ struct Model: Decodable { let name: String }
+ }
+}
diff --git a/Sources/GrammaGem/App/AppState.swift b/Sources/GrammarGem/App/AppState.swift
similarity index 65%
rename from Sources/GrammaGem/App/AppState.swift
rename to Sources/GrammarGem/App/AppState.swift
index a123b7d..f3eb745 100644
--- a/Sources/GrammaGem/App/AppState.swift
+++ b/Sources/GrammarGem/App/AppState.swift
@@ -27,8 +27,11 @@ final class AppState: ObservableObject {
let snippets: SnippetStore
let exclusions: Exclusions
let liveMonitor: LiveMonitor
+ let updater: UpdaterManager
private let capture: TextCapture
let coordinator: TextReplacementCoordinator
+ /// Loopback bridge to the browser extension (opt-in; nil unless enabled).
+ private var localServer: LocalEngineServer?
// UI state
@Published var lastStatus: String = "Ready"
@@ -45,7 +48,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.
+ // Reads the user's *selected* repo (default or paid large model) so the
+ // model that gets loaded matches the one that gets downloaded.
+ ai = AppState.makeAIEngine()
permissions = Permissions()
model = ModelManager()
license = LicenseManager()
@@ -61,8 +68,30 @@ final class AppState: ObservableObject {
let excl = Exclusions()
exclusions = excl
liveMonitor = LiveMonitor(grammar: engine, detector: detector, exclusions: excl, capture: capture)
+ updater = UpdaterManager()
+ let dict = dictionary
coordinator = TextReplacementCoordinator(
- capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector)
+ capture: capture, grammar: engine, ai: ai, gate: gate, detector: detector,
+ protectedTerms: { dict.entries })
+ }
+
+ // MARK: - Backend factory
+
+ /// The single place the Layer-2 backend is chosen. Defaults to the MLX
+ /// on-device engine (Apple Silicon); a user/CI override selects the
+ /// cross-platform `OllamaEngine`. Adding a backend is a one-line branch here
+ /// instead of edits scattered across construction sites. The MLX provider
+ /// reads the user's *selected* repo so the loaded model matches the downloaded
+ /// one (incl. the paid large model).
+ static func makeAIEngine() -> AIEngine {
+ let override = ProcessInfo.processInfo.environment["GRAMMARGEM_AI_BACKEND"]
+ ?? UserDefaults.standard.string(forKey: "GrammarGem.aiBackend")
+ if override == "ollama" {
+ return OllamaEngine()
+ }
+ return MLXEngine(modelDirectoryProvider: {
+ ModelManager.completedModelDirectory(repo: ModelManager.activeRepo())
+ })
}
/// Called once at launch (from the AppDelegate).
@@ -95,10 +124,19 @@ final class AppState: ObservableObject {
// Auto-install the on-device AI model in the background on first use, so
// the AI features are ready without the user hunting for a button. The
- // download is resumable/cancelable from the AI Model screen.
- if !model.isModelPresent(repo: model.selectedRepo) {
+ // download is resumable/cancelable from the AI Model screen. If a model is
+ // already present, warm it up now so the first AI action is instant.
+ if model.isModelPresent(repo: model.selectedRepo) {
+ Task { [weak self] in await self?.ai.warmup() }
+ } else {
model.startDownload()
}
+ // Warm the model up as soon as a (first-run) download completes.
+ model.$state.dropFirst()
+ .sink { [weak self] state in
+ if case .ready = state { Task { [weak self] in await self?.ai.warmup() } }
+ }
+ .store(in: &cancellables)
permissions.refresh()
appAwareEnabled = gate.appAwareEnabled
@@ -109,7 +147,77 @@ final class AppState: ObservableObject {
}
Task { await license.validateIfNeeded() }
- Log.app.info("GrammaGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
+ startLocalServerIfEnabled()
+ Log.app.info("GrammarGem started. Licensed: \(self.license.isLicensed, privacy: .public)")
+ }
+
+ // MARK: - Browser-extension bridge (opt-in)
+
+ /// Start the loopback engine server only when the user has opted in, so the app
+ /// ships no listening socket by default. The server reuses the SAME engines,
+ /// prompts, and entitlement gate as the menu-bar app.
+ private func startLocalServerIfEnabled() {
+ let enabled = ProcessInfo.processInfo.environment["GRAMMARGEM_LOCAL_SERVER"] == "1"
+ || UserDefaults.standard.bool(forKey: AppConfig.LocalServer.enableKey)
+ guard enabled, localServer == nil else { return }
+
+ let server = LocalEngineServer(
+ port: AppConfig.LocalServer.port,
+ token: LocalEngineServer.loadOrCreateToken(),
+ grammarHandler: { [weak self] text in
+ await MainActor.run {
+ guard let self else { return (text, []) }
+ let corrected = self.grammar.correct(text)
+ let suggestions = self.grammar.check(text).map {
+ LocalEngineServer.WireSuggestion(
+ location: $0.location, length: $0.length,
+ original: $0.original, replacement: $0.replacement,
+ kind: $0.kind.rawValue, message: $0.message)
+ }
+ return (corrected, suggestions)
+ }
+ },
+ aiHandler: { [weak self] (req: LocalEngineServer.AIRequest) async -> LocalEngineServer.AIOutcome in
+ guard let self else { return .error("GrammarGem is shutting down.") }
+ return await self.handleServerAI(req)
+ },
+ aiStreamHandler: { [weak self] (req, onChunk) -> LocalEngineServer.AIOutcome in
+ guard let self else { return .error("GrammarGem is shutting down.") }
+ return await self.handleServerAIStream(req, onChunk: onChunk)
+ })
+ server.start()
+ localServer = server
+ }
+
+ /// Run an AI action requested by the extension — same gate + model as the app.
+ private func handleServerAI(_ req: LocalEngineServer.AIRequest) async -> LocalEngineServer.AIOutcome {
+ if case .denied(let reason) = gate.authorizeAI(req.action) { return .error(reason) }
+ guard ai.isReady else { return .error(AIEngineError.modelNotReady.localizedDescription) }
+ do {
+ let out = try await ai.run(req.action, on: req.text, protectedTerms: dictionary.entries)
+ gate.recordAIActionUsed()
+ return .ok(out)
+ } catch {
+ return .error(error.localizedDescription)
+ }
+ }
+
+ /// Streaming variant for the extension: yields tokens as they generate.
+ private func handleServerAIStream(_ req: LocalEngineServer.AIRequest,
+ onChunk: @escaping (String) -> Void) async -> LocalEngineServer.AIOutcome {
+ if case .denied(let reason) = gate.authorizeAI(req.action) { return .error(reason) }
+ guard ai.isReady else { return .error(AIEngineError.modelNotReady.localizedDescription) }
+ var accumulated = ""
+ do {
+ for try await chunk in ai.runStreaming(req.action, on: req.text, protectedTerms: dictionary.entries) {
+ accumulated += chunk
+ onChunk(chunk)
+ }
+ gate.recordAIActionUsed()
+ return .ok(accumulated)
+ } catch {
+ return .error(error.localizedDescription)
+ }
}
// MARK: - Hotkey handling
@@ -179,19 +287,39 @@ final class AppState: ObservableObject {
usage.recordCorrection(words: 1)
}
- /// Pause/resume all of GrammaGem (hotkeys + live monitoring).
+ /// Hide a single suggestion without changing the user's text.
+ func dismissSuggestion(_ suggestion: Suggestion, in field: DetectedField) {
+ liveMonitor.dismiss(suggestion, in: field)
+ }
+
+ /// Add a flagged word to the personal dictionary so it's never corrected
+ /// again (gated by the free cap), then re-scan so it disappears now.
+ @discardableResult
+ func addWordToDictionary(_ word: String) -> FeatureGate.Decision {
+ let decision = addDictionaryEntry(word)
+ switch decision {
+ case .allowed:
+ lastStatus = "Added “\(word)” to your dictionary"
+ liveMonitor.refreshSoon()
+ case .denied(let reason):
+ lastStatus = reason
+ }
+ return decision
+ }
+
+ /// Pause/resume all of GrammarGem (hotkeys + live monitoring).
func togglePause() {
isPaused.toggle()
liveMonitor.setPaused(isPaused)
lastStatus = isPaused ? "Paused" : "Ready"
}
- /// A reason GrammaGem should stay out of the way right now, or nil.
+ /// A reason GrammarGem should stay out of the way right now, or nil.
private func blockReason() -> String? {
- if isPaused { return "GrammaGem is paused." }
+ if isPaused { return "GrammarGem is paused." }
let front = detector.frontmost()
if exclusions.isBlocked(bundleID: front?.bundleID, domain: detector.frontmostDomain()) {
- return "GrammaGem is off for \(front?.name ?? "this app")."
+ return "GrammarGem is off for \(front?.name ?? "this app")."
}
return nil
}
@@ -256,9 +384,9 @@ final class AppState: ObservableObject {
if onboardingWindow == nil {
let host = NSHostingController(rootView: OnboardingView().environmentObject(self))
let win = NSWindow(contentViewController: host)
- win.title = "Welcome to GrammaGem"
+ win.title = "Welcome to GrammarGem"
win.styleMask = [.titled, .closable, .fullSizeContentView]
- win.setContentSize(NSSize(width: 540, height: 600))
+ win.setContentSize(NSSize(width: 560, height: 720))
win.isReleasedWhenClosed = false
win.center()
onboardingWindow = win
@@ -285,7 +413,7 @@ final class AppState: ObservableObject {
.environmentObject(liveMonitor)
let host = NSHostingController(rootView: root)
let win = NSWindow(contentViewController: host)
- win.title = "GrammaGem"
+ win.title = "GrammarGem"
win.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView]
win.setContentSize(NSSize(width: 940, height: 660))
win.minSize = NSSize(width: 820, height: 560)
@@ -323,7 +451,7 @@ final class AppState: ObservableObject {
if askWindow == nil {
let host = NSHostingController(rootView: AskView().environmentObject(self))
let win = NSWindow(contentViewController: host)
- win.title = "Ask GrammaGem"
+ win.title = "Ask GrammarGem"
win.styleMask = [.titled, .closable]
win.setContentSize(NSSize(width: 460, height: 220))
win.isReleasedWhenClosed = false
diff --git a/Sources/GrammaGem/App/AskView.swift b/Sources/GrammarGem/App/AskView.swift
similarity index 97%
rename from Sources/GrammaGem/App/AskView.swift
rename to Sources/GrammarGem/App/AskView.swift
index ce02754..618c873 100644
--- a/Sources/GrammaGem/App/AskView.swift
+++ b/Sources/GrammarGem/App/AskView.swift
@@ -8,7 +8,7 @@ struct AskView: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
- Text("Ask GrammaGem")
+ Text("Ask GrammarGem")
.font(.headline)
Text("Acts on the text selected in the frontmost app. Runs on-device.")
.font(.caption)
diff --git a/Sources/GrammarGem/App/GrammarGemApp.swift b/Sources/GrammarGem/App/GrammarGemApp.swift
new file mode 100644
index 0000000..e3726ee
--- /dev/null
+++ b/Sources/GrammarGem/App/GrammarGemApp.swift
@@ -0,0 +1,76 @@
+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 GrammarGemMain {
+ static func main() {
+ if CommandLine.arguments.contains("--mlx-selftest") {
+ MLXSelfTest.run()
+ return
+ }
+ if let i = CommandLine.arguments.firstIndex(of: "--update-selftest") {
+ let feed = i + 1 < CommandLine.arguments.count ? CommandLine.arguments[i + 1] : ""
+ UpdaterSelfTest.run(feedURL: feed)
+ return
+ }
+ GrammarGemApp.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: ModelManager.activeRepo())
+ })
+ 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`).
+struct GrammarGemApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+ @StateObject private var app = AppState.shared
+
+ var body: some Scene {
+ MenuBarExtra {
+ MenuBarContent()
+ .environmentObject(app)
+ } label: {
+ MenuBarLabel()
+ }
+ .menuBarExtraStyle(.window)
+
+ Settings {
+ SettingsView()
+ .environmentObject(app)
+ .frame(width: 560, height: 520)
+ }
+ }
+}
+
+/// Runs launch-time wiring (hotkeys, permissions, license validation).
+final class AppDelegate: NSObject, NSApplicationDelegate {
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ AppState.shared.start()
+ }
+}
diff --git a/Sources/GrammaGem/App/MainWindow/AccountViews.swift b/Sources/GrammarGem/App/MainWindow/AccountViews.swift
similarity index 96%
rename from Sources/GrammaGem/App/MainWindow/AccountViews.swift
rename to Sources/GrammarGem/App/MainWindow/AccountViews.swift
index 6304145..e77fec2 100644
--- a/Sources/GrammaGem/App/MainWindow/AccountViews.swift
+++ b/Sources/GrammarGem/App/MainWindow/AccountViews.swift
@@ -8,7 +8,7 @@ struct GeneralView: View {
@EnvironmentObject private var permissions: Permissions
var body: some View {
- DetailScaffold(title: "General", subtitle: "Make GrammaGem feel like yours.") {
+ DetailScaffold(title: "General", subtitle: "Make GrammarGem feel like yours.") {
Card {
Toggle("Launch at login", isOn: $prefs.launchAtLogin)
Divider()
@@ -134,7 +134,7 @@ struct AboutView: View {
.frame(width: 56, height: 56)
.background(GG.emerald.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
VStack(alignment: .leading, spacing: 2) {
- Text("GrammaGem").font(.title2.bold())
+ Text("GrammarGem").font(.title2.bold())
Text("Version \(AppConfig.appVersion)").foregroundStyle(.secondary)
Text("The private, on-device writing assistant for Mac.")
.font(.callout).foregroundStyle(.secondary)
@@ -153,7 +153,7 @@ struct AboutView: View {
Card {
Text("Open source").font(.headline)
- Text("GrammaGem is open source under the MIT License — a privacy tool you can inspect.")
+ Text("GrammarGem is open source under the MIT License — a privacy tool you can inspect.")
.font(.callout).foregroundStyle(.secondary)
}
diff --git a/Sources/GrammaGem/App/MainWindow/DashboardView.swift b/Sources/GrammarGem/App/MainWindow/DashboardView.swift
similarity index 83%
rename from Sources/GrammaGem/App/MainWindow/DashboardView.swift
rename to Sources/GrammarGem/App/MainWindow/DashboardView.swift
index 3b2206b..b57c192 100644
--- a/Sources/GrammaGem/App/MainWindow/DashboardView.swift
+++ b/Sources/GrammarGem/App/MainWindow/DashboardView.swift
@@ -30,6 +30,15 @@ struct DashboardView: View {
StatTile(value: "\(usage.data.totalCorrections)", label: "Corrections made", systemImage: "checkmark.seal.fill", tint: GG.emerald)
StatTile(value: timeSaved, label: "Time saved (est.)", systemImage: "clock.fill", tint: .primary)
+
+ StatTile(
+ value: usage.currentStreak > 0 ? "\(usage.currentStreak)d" : "—",
+ label: "Day streak", systemImage: "flame.fill",
+ tint: usage.currentStreak > 0 ? .orange : .secondary)
+ StatTile(
+ value: "$\(usage.subscriptionAvoidedUSD)",
+ label: "Subscription not paid", systemImage: "dollarsign.circle.fill",
+ tint: GG.emerald)
}
// Setup health
@@ -61,6 +70,19 @@ struct DashboardView: View {
.font(.caption).foregroundStyle(.secondary)
}
+ // Next milestone
+ Card {
+ let milestone = usage.nextMilestone
+ HStack {
+ Text("Next milestone").font(.headline)
+ Spacer()
+ Text(milestone.label).font(.caption).foregroundStyle(.secondary)
+ }
+ ProgressView(value: milestone.progress).tint(GG.emerald)
+ Text("\(usage.data.totalCorrections) corrections so far — keep the streak alive.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+
// Quick actions
Card {
Text("Quick actions").font(.headline)
@@ -69,7 +91,7 @@ struct DashboardView: View {
Label("Fix selection", systemImage: "checkmark.circle")
}
Button { app.showAsk() } label: {
- Label("Ask GrammaGem", systemImage: "sparkles")
+ Label("Ask GrammarGem", systemImage: "sparkles")
}
if !license.isLicensed {
Spacer()
diff --git a/Sources/GrammaGem/App/MainWindow/DataViews.swift b/Sources/GrammarGem/App/MainWindow/DataViews.swift
similarity index 98%
rename from Sources/GrammaGem/App/MainWindow/DataViews.swift
rename to Sources/GrammarGem/App/MainWindow/DataViews.swift
index de9c92d..4b35b4b 100644
--- a/Sources/GrammaGem/App/MainWindow/DataViews.swift
+++ b/Sources/GrammarGem/App/MainWindow/DataViews.swift
@@ -12,7 +12,7 @@ struct DictionaryView: View {
var body: some View {
DetailScaffold(
title: "Personal Dictionary",
- subtitle: "Words GrammaGem should never “correct” — names, brands, jargon."
+ subtitle: "Words GrammarGem should never “correct” — names, brands, jargon."
) {
Card {
HStack {
diff --git a/Sources/GrammaGem/App/MainWindow/DevicesView.swift b/Sources/GrammarGem/App/MainWindow/DevicesView.swift
similarity index 97%
rename from Sources/GrammaGem/App/MainWindow/DevicesView.swift
rename to Sources/GrammarGem/App/MainWindow/DevicesView.swift
index d2147f9..d39a93e 100644
--- a/Sources/GrammaGem/App/MainWindow/DevicesView.swift
+++ b/Sources/GrammarGem/App/MainWindow/DevicesView.swift
@@ -83,7 +83,7 @@ struct DevicesView: View {
HStack {
Link("Open license portal", destination: AppConfig.modelPortalURL)
Spacer()
- Text("Add a Mac: install GrammaGem there and paste the same key.")
+ Text("Add a Mac: install GrammarGem there and paste the same key.")
.font(.caption).foregroundStyle(.secondary)
}
}
diff --git a/Sources/GrammaGem/App/MainWindow/ExclusionsView.swift b/Sources/GrammarGem/App/MainWindow/ExclusionsView.swift
similarity index 95%
rename from Sources/GrammaGem/App/MainWindow/ExclusionsView.swift
rename to Sources/GrammarGem/App/MainWindow/ExclusionsView.swift
index 74e4667..0c005d8 100644
--- a/Sources/GrammaGem/App/MainWindow/ExclusionsView.swift
+++ b/Sources/GrammarGem/App/MainWindow/ExclusionsView.swift
@@ -8,12 +8,12 @@ struct ExclusionsView: View {
var body: some View {
DetailScaffold(
title: "Page Blocker",
- subtitle: "Keep GrammaGem out of the way in sensitive apps and sites."
+ subtitle: "Keep GrammarGem out of the way in sensitive apps and sites."
) {
Card {
Label("How it works", systemImage: "hand.raised")
.font(.headline)
- Text("When the frontmost app or website is on these lists, GrammaGem stops monitoring and the hotkeys do nothing — useful for password managers, banking, and private work.")
+ Text("When the frontmost app or website is on these lists, GrammarGem stops monitoring and the hotkeys do nothing — useful for password managers, banking, and private work.")
.font(.callout).foregroundStyle(.secondary)
}
diff --git a/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift b/Sources/GrammarGem/App/MainWindow/LiveCheckView.swift
similarity index 61%
rename from Sources/GrammaGem/App/MainWindow/LiveCheckView.swift
rename to Sources/GrammarGem/App/MainWindow/LiveCheckView.swift
index 7767c5d..dad4369 100644
--- a/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift
+++ b/Sources/GrammarGem/App/MainWindow/LiveCheckView.swift
@@ -34,22 +34,32 @@ struct LiveCheckView: View {
}
private var header: some View {
- HStack(spacing: 12) {
- VStack(alignment: .leading, spacing: 2) {
- Text(summary).font(.headline)
- Text(monitor.activeAppName.isEmpty
- ? "Checking text areas on screen"
- : "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked")
- .font(.caption).foregroundStyle(.secondary)
- }
- Spacer()
- Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") }
- .help("Re-scan now")
- if monitor.issueCount > 0 {
- Button { app.fixAllDetected() } label: {
- Label("Fix everything", systemImage: "checkmark.circle.fill")
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 12) {
+ ScoreRing(score: monitor.writingScore)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(summary).font(.headline)
+ Text(contextLine).font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") }
+ .help("Re-scan now")
+ if monitor.issueCount > 0 {
+ Button { app.fixAllDetected() } label: {
+ Label("Fix everything", systemImage: "checkmark.circle.fill")
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ if monitor.issueCount > 0 {
+ HStack(spacing: 8) {
+ ForEach(IssueBucket.allCases) { bucket in
+ if let n = monitor.bucketCounts[bucket], n > 0 {
+ BucketChip(bucket: bucket, count: n)
+ }
+ }
+ Spacer()
}
- .buttonStyle(.borderedProminent)
}
}
.padding(16)
@@ -57,10 +67,16 @@ struct LiveCheckView: View {
private var summary: String {
let issues = monitor.issueCount
- if issues == 0 { return "No issues found" }
+ if issues == 0 { return "Looks clean" }
return "\(issues) issue\(issues == 1 ? "" : "s") across \(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")"
}
+ private var contextLine: String {
+ monitor.activeAppName.isEmpty
+ ? "Checking text areas on screen"
+ : "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked"
+ }
+
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "checkmark.seal.fill").font(.system(size: 42)).foregroundStyle(GG.emerald)
@@ -112,9 +128,21 @@ private struct FieldCard: View {
}
}
Spacer()
+ if s.kind == .spelling {
+ Button { app.addWordToDictionary(s.original) } label: {
+ Image(systemName: "book.closed")
+ }
+ .controlSize(.small).buttonStyle(.borderless)
+ .help("Add “\(s.original)” to your dictionary")
+ }
+ Button { app.dismissSuggestion(s, in: field) } label: {
+ Image(systemName: "xmark")
+ }
+ .controlSize(.small).buttonStyle(.borderless)
+ .help("Dismiss this suggestion")
if s.replacement != s.original && !s.replacement.isEmpty {
Button("Apply") { app.applySuggestion(s, in: field) }
- .controlSize(.small)
+ .controlSize(.small).buttonStyle(.borderedProminent)
}
}
}
@@ -141,6 +169,14 @@ private struct FieldCard: View {
}
}
+ fileprivate static func color(for bucket: IssueBucket) -> Color {
+ switch bucket {
+ case .correctness: return .red
+ case .clarity: return .blue
+ case .polish: return GG.gold
+ }
+ }
+
/// Build an attributed string with each issue span tinted.
private func highlighted(_ text: String, _ suggestions: [Suggestion]) -> AttributedString {
let ns = text as NSString
@@ -167,3 +203,40 @@ private struct FieldCard: View {
return result
}
}
+
+/// A compact 0–100 cleanliness gauge. Green when clean, amber/orange as issues
+/// pile up — a meter the user can watch climb as they accept fixes.
+private struct ScoreRing: View {
+ let score: Int
+ var body: some View {
+ ZStack {
+ Circle().stroke(Color.primary.opacity(0.1), lineWidth: 5)
+ Circle()
+ .trim(from: 0, to: CGFloat(max(0, min(100, score))) / 100)
+ .stroke(color, style: StrokeStyle(lineWidth: 5, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ .animation(.easeOut(duration: 0.35), value: score)
+ Text("\(score)").font(.system(size: 15, weight: .bold, design: .rounded))
+ }
+ .frame(width: 44, height: 44)
+ .help("Writing score for the text on screen")
+ }
+
+ private var color: Color {
+ score >= 90 ? GG.emerald : (score >= 70 ? GG.gold : .orange)
+ }
+}
+
+/// A tappable-looking count chip per issue bucket (Correctness / Clarity / Polish).
+private struct BucketChip: View {
+ let bucket: IssueBucket
+ let count: Int
+ var body: some View {
+ HStack(spacing: 5) {
+ Circle().fill(FieldCard.color(for: bucket)).frame(width: 7, height: 7)
+ Text("\(bucket.title) \(count)").font(.caption.weight(.medium))
+ }
+ .padding(.horizontal, 9).padding(.vertical, 4)
+ .background(FieldCard.color(for: bucket).opacity(0.12), in: Capsule())
+ }
+}
diff --git a/Sources/GrammaGem/App/MainWindow/MainWindowView.swift b/Sources/GrammarGem/App/MainWindow/MainWindowView.swift
similarity index 98%
rename from Sources/GrammaGem/App/MainWindow/MainWindowView.swift
rename to Sources/GrammarGem/App/MainWindow/MainWindowView.swift
index bc81ab6..2b2d224 100644
--- a/Sources/GrammaGem/App/MainWindow/MainWindowView.swift
+++ b/Sources/GrammarGem/App/MainWindow/MainWindowView.swift
@@ -59,7 +59,7 @@ enum MainSection: String, CaseIterable, Identifiable {
]
}
-/// The full GrammaGem management window: a clean sidebar + detail layout.
+/// The full GrammarGem management window: a clean sidebar + detail layout.
struct MainWindowView: View {
@State private var selection: MainSection?
diff --git a/Sources/GrammaGem/App/MainWindow/SystemViews.swift b/Sources/GrammarGem/App/MainWindow/SystemViews.swift
similarity index 98%
rename from Sources/GrammaGem/App/MainWindow/SystemViews.swift
rename to Sources/GrammarGem/App/MainWindow/SystemViews.swift
index 41b5d08..bec3bb8 100644
--- a/Sources/GrammaGem/App/MainWindow/SystemViews.swift
+++ b/Sources/GrammarGem/App/MainWindow/SystemViews.swift
@@ -17,7 +17,7 @@ struct ShortcutsView: View {
combo: $prefs.fixHotkey)
Divider()
shortcutRow(
- name: "Ask GrammaGem",
+ name: "Ask GrammarGem",
detail: "Rewrite with an instruction",
combo: $prefs.askHotkey)
}
@@ -141,7 +141,7 @@ struct PrivacyView: View {
}
Card {
- Text("The only times GrammaGem uses the network").font(.headline)
+ Text("The only times GrammarGem uses the network").font(.headline)
networkRow("One-time AI model download", "Hugging Face, on first run")
Divider()
networkRow("License activation & checks", "To enforce your device cap")
diff --git a/Sources/GrammaGem/App/MainWindow/WritingModesView.swift b/Sources/GrammarGem/App/MainWindow/WritingModesView.swift
similarity index 97%
rename from Sources/GrammaGem/App/MainWindow/WritingModesView.swift
rename to Sources/GrammarGem/App/MainWindow/WritingModesView.swift
index e25b9f2..f981b27 100644
--- a/Sources/GrammaGem/App/MainWindow/WritingModesView.swift
+++ b/Sources/GrammarGem/App/MainWindow/WritingModesView.swift
@@ -15,7 +15,7 @@ struct WritingModesView: View {
var body: some View {
DetailScaffold(
title: "Writing Modes",
- subtitle: "Tune the tone for every context — or let GrammaGem pick it for you."
+ subtitle: "Tune the tone for every context — or let GrammarGem pick it for you."
) {
builtInCard
customCard
@@ -88,7 +88,7 @@ struct WritingModesView: View {
private var appAwareCard: some View {
Card {
HStack { Text("App-aware switching").font(.headline); if !paid { UpgradeBadge() } }
- Text("Map an app to a Mode and GrammaGem applies it automatically when that app is in front.")
+ Text("Map an app to a Mode and GrammarGem applies it automatically when that app is in front.")
.font(.callout).foregroundStyle(.secondary)
if !customModes.appOverrides.isEmpty {
diff --git a/Sources/GrammaGem/App/MenuBarContent.swift b/Sources/GrammarGem/App/MenuBarContent.swift
similarity index 76%
rename from Sources/GrammaGem/App/MenuBarContent.swift
rename to Sources/GrammarGem/App/MenuBarContent.swift
index ad71c13..4c00415 100644
--- a/Sources/GrammaGem/App/MenuBarContent.swift
+++ b/Sources/GrammarGem/App/MenuBarContent.swift
@@ -24,16 +24,22 @@ struct MenuBarLabel: View {
struct MenuBarContent: View {
@EnvironmentObject private var app: AppState
@ObservedObject private var monitor: LiveMonitor
+ @ObservedObject private var updater: UpdaterManager
@Environment(\.openSettings) private var openSettings
init() {
_monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor)
+ _updater = ObservedObject(wrappedValue: AppState.shared.updater)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
header
+ if let version = updater.updateAvailableVersion {
+ updateBanner(version: version)
+ }
+
Divider()
liveStatus
@@ -43,7 +49,7 @@ struct MenuBarContent: View {
actionRow(title: "Fix selection", shortcut: app.preferences.fixHotkey.display) {
Task { await app.runFix() }
}
- actionRow(title: "Ask GrammaGem…", shortcut: app.preferences.askHotkey.display) {
+ actionRow(title: "Ask GrammarGem…", shortcut: app.preferences.askHotkey.display) {
app.showAsk()
}
if app.gate.appAwareEnabled {
@@ -51,20 +57,22 @@ struct MenuBarContent: View {
Task { await app.runAppAwareRewrite() }
}
}
- actionRow(title: app.isPaused ? "Resume GrammaGem" : "Pause GrammaGem", shortcut: nil) {
+ actionRow(title: app.isPaused ? "Resume GrammarGem" : "Pause GrammarGem", shortcut: nil) {
app.togglePause()
}
Divider()
actionRow(title: "Show live issues…", shortcut: nil) { app.showLiveCheck() }
- actionRow(title: "Open GrammaGem…", shortcut: nil) { app.showMainWindow() }
+ actionRow(title: "Open GrammarGem…", shortcut: nil) { app.showMainWindow() }
actionRow(title: "Manage devices…", shortcut: nil) { app.showMainWindow(select: .devices) }
actionRow(title: "Page blocker…", shortcut: nil) { app.showMainWindow(select: .exclusions) }
+ actionRow(title: "Check for Updates…", shortcut: nil) { app.updater.checkForUpdates() }
Divider()
usageRow
+ valueRow
Divider()
@@ -82,11 +90,27 @@ struct MenuBarContent: View {
.frame(width: 290)
}
+ /// Shown when Sparkle has found a newer signed build on grammargem.com.
+ private func updateBanner(version: String) -> some View {
+ HStack(spacing: 8) {
+ Image(systemName: "arrow.down.circle.fill").foregroundStyle(GG.emerald)
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Update available").font(.callout.weight(.semibold))
+ Text("Version \(version) is ready to install").font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button("Update") { app.updater.checkForUpdates() }
+ .buttonStyle(.borderedProminent).controlSize(.small)
+ }
+ .padding(8)
+ .background(GG.emerald.opacity(0.10), in: RoundedRectangle(cornerRadius: 8))
+ }
+
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "pencil.and.scribble").foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 1) {
- Text("GrammaGem").font(.headline)
+ Text("GrammarGem").font(.headline)
Text(app.license.tier.displayName + (app.license.isLicensed ? " · licensed" : " · free"))
.font(.caption).foregroundStyle(.secondary)
}
@@ -152,6 +176,16 @@ struct MenuBarContent: View {
}
}
+ /// One always-visible value reminder: the streak + lifetime corrections.
+ @ViewBuilder
+ private var valueRow: some View {
+ if app.usage.currentStreak > 1 {
+ Label("\(app.usage.currentStreak)-day streak · \(app.usage.data.totalCorrections) corrections",
+ systemImage: "flame.fill")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+
private func actionRow(title: String, shortcut: String?, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
diff --git a/Sources/GrammarGem/App/OnboardingView.swift b/Sources/GrammarGem/App/OnboardingView.swift
new file mode 100644
index 0000000..b926043
--- /dev/null
+++ b/Sources/GrammarGem/App/OnboardingView.swift
@@ -0,0 +1,220 @@
+import SwiftUI
+
+/// First-run onboarding: explains why GrammarGem needs Accessibility, deep-links
+/// to System Settings, and advances automatically the moment permission is
+/// granted (even if granted directly in System Settings).
+struct OnboardingView: View {
+ @EnvironmentObject private var app: AppState
+ @ObservedObject private var permissions: Permissions
+ @ObservedObject private var model: ModelManager
+
+ init() {
+ _permissions = ObservedObject(wrappedValue: AppState.shared.permissions)
+ _model = ObservedObject(wrappedValue: AppState.shared.model)
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Welcome to GrammarGem")
+ .font(.largeTitle.bold())
+ Text("A private, on-device writing assistant that works in every app.")
+ .foregroundStyle(.secondary)
+ }
+
+ // The rigged win: prove the value in one click, before any ask.
+ VStack(alignment: .leading, spacing: 10) {
+ Label("See it work", systemImage: "wand.and.stars").font(.headline)
+ DemoStep()
+ }
+ .padding(16)
+ .background(GG.emerald.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
+ .overlay(RoundedRectangle(cornerRadius: 12).stroke(GG.emerald.opacity(0.25)))
+
+ stepCard(
+ number: 1,
+ title: "Turn it on everywhere",
+ body: "GrammarGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.",
+ granted: permissions.accessibilityTrusted
+ ) {
+ if permissions.accessibilityTrusted {
+ Label("Permission granted", systemImage: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ } else {
+ HStack {
+ Button("Open System Settings") { permissions.openAccessibilitySettings() }
+ Button("Request permission") { permissions.requestAccessibility() }
+ }
+ Text("This updates automatically once you flip GrammarGem on in Accessibility.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+
+ stepCard(
+ number: 2,
+ title: "Download the on-device model (optional)",
+ body: "Grammar & spelling work instantly with no download. The AI rewrite/tone/Ask features use a small model downloaded once from Hugging Face — then everything runs offline.",
+ granted: model.state == .ready
+ ) {
+ ModelDownloadControls()
+ }
+ }
+ .padding(28)
+ }
+
+ Divider()
+
+ HStack {
+ Label("Yours once. No subscription, nothing leaves your Mac.", systemImage: "lock.shield")
+ .font(.caption).foregroundStyle(.secondary)
+ Spacer()
+ Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
+ app.showOnboardingDismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding(.horizontal, 28).padding(.vertical, 14)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .onAppear {
+ permissions.startPollingUntilGranted()
+ }
+ .onDisappear {
+ permissions.stopPolling()
+ }
+ }
+
+ private func stepCard(
+ number: Int, title: String, body: String, granted: Bool,
+ @ViewBuilder controls: () -> Controls
+ ) -> some View {
+ HStack(alignment: .top, spacing: 14) {
+ ZStack {
+ Circle().fill(granted ? Color.green : Color.accentColor.opacity(0.15))
+ .frame(width: 30, height: 30)
+ if granted {
+ Image(systemName: "checkmark").foregroundStyle(.white).font(.caption.bold())
+ } else {
+ Text("\(number)").font(.callout.bold()).foregroundStyle(.tint)
+ }
+ }
+ VStack(alignment: .leading, spacing: 8) {
+ Text(title).font(.headline)
+ Text(body).font(.callout).foregroundStyle(.secondary)
+ controls()
+ }
+ }
+ .padding(16)
+ .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12))
+ }
+}
+
+/// A safe, on-rails first-run demo: a local sample with planted mistakes that
+/// the REAL grammar engine fixes in one click. It operates on a plain string
+/// (never a live Accessibility element), so it's completely risk-free — the
+/// "guaranteed visible win" before GrammarGem asks for anything.
+private struct DemoStep: View {
+ @State private var text = "i has wrote alot of mistakes here, and it dont look profesional"
+ @State private var issues: [Suggestion] = []
+ @State private var cleaned = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(headline)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(cleaned ? GG.emerald : .primary)
+ Text(highlighted)
+ .font(.callout)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(10)
+ .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
+ .animation(.easeInOut(duration: 0.35), value: text)
+ Button {
+ let fixed = AppState.shared.grammar.correct(text)
+ withAnimation(.easeOut(duration: 0.4)) {
+ text = fixed
+ issues = []
+ cleaned = true
+ }
+ } label: {
+ Label(cleaned ? "All clean" : "Clean it up",
+ systemImage: cleaned ? "checkmark.circle.fill" : "wand.and.stars")
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(cleaned || issues.isEmpty)
+ }
+ .onAppear {
+ if issues.isEmpty && !cleaned { issues = AppState.shared.grammar.check(text) }
+ }
+ }
+
+ private var headline: String {
+ if cleaned { return "Clean ✨ — GrammarGem does this in every app you type." }
+ if issues.isEmpty { return "Checking…" }
+ return "We spotted \(issues.count) thing\(issues.count == 1 ? "" : "s") to tidy up — one click fixes them."
+ }
+
+ private var highlighted: AttributedString {
+ let ns = text as NSString
+ var result = AttributedString()
+ var cursor = 0
+ for s in issues.sorted(by: { $0.location < $1.location }) {
+ guard s.location >= cursor, s.length > 0, s.location + s.length <= ns.length else { continue }
+ if s.location > cursor {
+ result += AttributedString(ns.substring(with: NSRange(location: cursor, length: s.location - cursor)))
+ }
+ var run = AttributedString(ns.substring(with: s.range))
+ let tint: Color = s.kind == .spelling ? .red : .orange
+ run.backgroundColor = tint.opacity(0.18)
+ run.foregroundColor = tint
+ run.underlineStyle = .single
+ result += run
+ cursor = s.location + s.length
+ }
+ if cursor < ns.length {
+ result += AttributedString(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)))
+ }
+ return result
+ }
+}
+
+/// Real model download progress + controls, shared by onboarding and settings.
+struct ModelDownloadControls: View {
+ @ObservedObject private var model: ModelManager
+
+ init() {
+ _model = ObservedObject(wrappedValue: AppState.shared.model)
+ }
+
+ var body: some View {
+ switch model.state {
+ case .notDownloaded:
+ VStack(alignment: .leading, spacing: 4) {
+ Button("Download model now") { model.startDownload() }
+ Text("Installs automatically in the background on first launch (about 1–2 GB from Hugging Face). One time, then fully offline.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ case .downloading(let progress):
+ VStack(alignment: .leading, spacing: 6) {
+ ProgressView(value: progress) {
+ Text("Downloading… \(Int(progress * 100))%")
+ }
+ .frame(maxWidth: 320)
+ if !model.statusText.isEmpty {
+ Text(model.statusText).font(.caption).foregroundStyle(.secondary).lineLimit(1)
+ }
+ Button("Cancel") { model.cancel() }.controlSize(.small)
+ }
+ case .ready:
+ Label("Model ready", systemImage: "checkmark.seal.fill").foregroundStyle(.green)
+ case .failed(let message):
+ VStack(alignment: .leading, spacing: 4) {
+ Label("Download failed", systemImage: "exclamationmark.triangle").foregroundStyle(.orange)
+ Text(message).font(.caption).foregroundStyle(.secondary).lineLimit(2)
+ Button("Retry") { model.startDownload() }
+ }
+ }
+ }
+}
diff --git a/Sources/GrammaGem/App/SettingsView.swift b/Sources/GrammarGem/App/SettingsView.swift
similarity index 97%
rename from Sources/GrammaGem/App/SettingsView.swift
rename to Sources/GrammarGem/App/SettingsView.swift
index 32981b9..b293065 100644
--- a/Sources/GrammaGem/App/SettingsView.swift
+++ b/Sources/GrammarGem/App/SettingsView.swift
@@ -25,7 +25,7 @@ private struct GeneralSettings: View {
Form {
Section("Shortcuts") {
LabeledContent("Fix selection", value: "⌘;")
- LabeledContent("Ask GrammaGem", value: "⌘'")
+ LabeledContent("Ask GrammarGem", value: "⌘'")
Text("Custom shortcuts coming via the KeyboardShortcuts recorder (see README).")
.font(.caption).foregroundStyle(.secondary)
}
@@ -147,7 +147,7 @@ private struct DictionarySettings: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
- TextField("Add a word GrammaGem should never correct", text: $newWord)
+ TextField("Add a word GrammarGem should never correct", text: $newWord)
.textFieldStyle(.roundedBorder)
.onSubmit(add)
Button("Add", action: add).disabled(newWord.trimmingCharacters(in: .whitespaces).isEmpty)
diff --git a/Sources/GrammaGem/Core/AppConfig.swift b/Sources/GrammarGem/Core/AppConfig.swift
similarity index 65%
rename from Sources/GrammaGem/Core/AppConfig.swift
rename to Sources/GrammarGem/Core/AppConfig.swift
index a1016a2..9f0f998 100644
--- a/Sources/GrammaGem/Core/AppConfig.swift
+++ b/Sources/GrammarGem/Core/AppConfig.swift
@@ -1,15 +1,15 @@
import Foundation
/// Compile-time configuration. The Lemon Squeezy identifiers are hard-coded so
-/// the licensing layer can *hard-verify* that a key belongs to GrammaGem (and to
+/// the licensing layer can *hard-verify* that a key belongs to GrammarGem (and to
/// the claimed tier) rather than to some other Lemon Squeezy product.
/// TODO(real-integration): replace every `TODO_*` value with your real IDs.
enum AppConfig {
- static let appName = "GrammaGem"
- static let bundleIdentifier = "com.foundergem.grammagem" // TODO(real-integration)
- static let supportEmail = "support@grammagem.app" // TODO(real-integration)
- static let modelPortalURL = URL(string: "https://grammagem.app/license")! // TODO
- static let websiteURL = URL(string: "https://grammagem.app")!
+ static let appName = "GrammarGem"
+ static let bundleIdentifier = "com.foundergem.grammargem" // TODO(real-integration)
+ static let supportEmail = "support@grammargem.com" // TODO(real-integration)
+ static let modelPortalURL = URL(string: "https://www.grammargem.com/license")! // TODO
+ static let websiteURL = URL(string: "https://www.grammargem.com")!
static let appVersion = "0.1.0"
// Legal entity (shown in About / legal).
@@ -37,6 +37,23 @@ enum AppConfig {
static let huggingFaceBase = "https://huggingface.co"
}
+ /// The cross-platform Layer-2 backend: a locally-running Ollama server. Used
+ /// when MLX isn't available (Windows/Linux) or when the user opts in. Inference
+ /// stays on-device — Ollama runs the model on the user's own machine.
+ enum Ollama {
+ static let baseURL = URL(string: "http://127.0.0.1:11434")!
+ static let defaultModel = "qwen2.5:3b" // matches the MLX default model family
+ }
+
+ /// Loopback engine server for the browser extension (off by default; see
+ /// `LocalEngineServer`). Exposes Harper + the local LLM to the extension over
+ /// 127.0.0.1 with a per-install token — no cloud round-trip.
+ enum LocalServer {
+ static let port: UInt16 = 47800
+ static let tokenKey = "GrammarGem.localServerToken"
+ static let enableKey = "GrammarGem.enableLocalServer"
+ }
+
/// Free-tier limits + license validation cadence (see spec §5/§6).
enum Limits {
static let freeDailyAIActions = 10
diff --git a/Sources/GrammaGem/Core/Logger.swift b/Sources/GrammarGem/Core/Logger.swift
similarity index 100%
rename from Sources/GrammaGem/Core/Logger.swift
rename to Sources/GrammarGem/Core/Logger.swift
diff --git a/Sources/GrammaGem/Core/Models.swift b/Sources/GrammarGem/Core/Models.swift
similarity index 74%
rename from Sources/GrammaGem/Core/Models.swift
rename to Sources/GrammarGem/Core/Models.swift
index d927e32..2676d8f 100644
--- a/Sources/GrammaGem/Core/Models.swift
+++ b/Sources/GrammarGem/Core/Models.swift
@@ -42,6 +42,42 @@ enum GrammarKind: String, Codable {
case phrasing
case repetition
case capitalization
+
+ /// Severity weight — drives card ordering and the writing-score penalty.
+ /// Objective errors (spelling, grammar) weigh most.
+ var severity: Int {
+ switch self {
+ case .spelling, .grammar: return 3
+ case .punctuation, .capitalization: return 2
+ case .repetition, .phrasing: return 1
+ }
+ }
+
+ /// User-facing grouping shown as filter chips in the Live Check panel.
+ var bucket: IssueBucket {
+ switch self {
+ case .spelling, .grammar, .punctuation: return .correctness
+ case .phrasing: return .clarity
+ case .repetition, .capitalization: return .polish
+ }
+ }
+}
+
+/// Our own named issue groups (deliberately NOT Grammarly's exact buckets/words):
+/// objective errors, clarity rewrites, and stylistic polish.
+enum IssueBucket: String, CaseIterable, Identifiable {
+ case correctness
+ case clarity
+ case polish
+
+ var id: String { rawValue }
+ var title: String {
+ switch self {
+ case .correctness: return "Correctness"
+ case .clarity: return "Clarity"
+ case .polish: return "Polish"
+ }
+ }
}
/// A single grammar suggestion over a span of the input text.
diff --git a/Sources/GrammaGem/Entitlements/Entitlements.swift b/Sources/GrammarGem/Entitlements/Entitlements.swift
similarity index 100%
rename from Sources/GrammaGem/Entitlements/Entitlements.swift
rename to Sources/GrammarGem/Entitlements/Entitlements.swift
diff --git a/Sources/GrammaGem/Entitlements/FeatureGate.swift b/Sources/GrammarGem/Entitlements/FeatureGate.swift
similarity index 98%
rename from Sources/GrammaGem/Entitlements/FeatureGate.swift
rename to Sources/GrammarGem/Entitlements/FeatureGate.swift
index 981a02c..13e56ef 100644
--- a/Sources/GrammaGem/Entitlements/FeatureGate.swift
+++ b/Sources/GrammarGem/Entitlements/FeatureGate.swift
@@ -72,7 +72,7 @@ final class FeatureGate: ObservableObject {
/// Free-tier AI action counter. Persists `{date, count}` in UserDefaults and
/// resets at local midnight (compares the stored local date string to today's).
final class DailyCounter {
- private let key = "GrammaGem.aiActions.daily"
+ private let key = "GrammarGem.aiActions.daily"
private let defaults = UserDefaults.standard
private static let formatter: DateFormatter = {
diff --git a/Sources/GrammaGem/Grammar/GrammarEngine.swift b/Sources/GrammarGem/Grammar/GrammarEngine.swift
similarity index 76%
rename from Sources/GrammaGem/Grammar/GrammarEngine.swift
rename to Sources/GrammarGem/Grammar/GrammarEngine.swift
index 38282a7..d559fc3 100644
--- a/Sources/GrammaGem/Grammar/GrammarEngine.swift
+++ b/Sources/GrammarGem/Grammar/GrammarEngine.swift
@@ -19,9 +19,15 @@ protocol GrammarEngine: AnyObject {
extension GrammarEngine {
/// Default correction: apply suggestions right-to-left so offsets stay valid.
+ ///
+ /// No-op flags (where the replacement equals the original — e.g. a grammar
+ /// issue Harper can describe but not auto-fix) are skipped, so "Fix all" /
+ /// the in-place hotkey only ever apply real, text-changing corrections and
+ /// never silently rewrite a span to itself.
func correct(_ text: String) -> String {
let ns = NSMutableString(string: text)
for s in check(text).sorted(by: { $0.location > $1.location }) {
+ guard s.replacement != s.original else { continue }
guard s.location + s.length <= ns.length else { continue }
ns.replaceCharacters(in: s.range, with: s.replacement)
}
diff --git a/Sources/GrammarGem/Grammar/HarperEngine.swift b/Sources/GrammarGem/Grammar/HarperEngine.swift
new file mode 100644
index 0000000..139060e
--- /dev/null
+++ b/Sources/GrammarGem/Grammar/HarperEngine.swift
@@ -0,0 +1,83 @@
+import Foundation
+import CHarper
+
+/// Layer 1 — the real **Harper** grammar core (Apache-2.0), embedded via a Rust
+/// C-FFI static lib (`harper-ffi/`, harper-core 2.5.0). Fully offline,
+/// deterministic, and fast — real subject-verb agreement, verb forms,
+/// punctuation, repetition, capitalization and spelling, not just a spell check.
+///
+/// `harper_check` returns a JSON array of spans with **char (Unicode scalar)**
+/// offsets; we map those onto UTF-16 `NSRange` offsets so they compose with the
+/// rest of the app (AX ranges, `NSMutableString`).
+final class HarperEngine: GrammarEngine {
+ var ignoreList: Set = []
+
+ func check(_ text: String) -> [Suggestion] {
+ guard !text.isEmpty else { return [] }
+
+ // Call into Rust. The returned C string is heap-allocated by Harper and
+ // must be freed with harper_free (it outlives the withCString buffer).
+ guard let raw = text.withCString({ harper_check($0) }) else { return [] }
+ defer { harper_free(raw) }
+
+ let json = String(cString: raw)
+ guard let data = json.data(using: .utf8),
+ let lints = try? JSONDecoder().decode([HarperLint].self, from: data)
+ else { return [] }
+
+ // Harper can emit several lints over the SAME span (e.g. two phrasings of
+ // "checkin" -> "check in"). Keep the first (highest-priority) per span so
+ // the right-to-left bulk apply in `correct()` never double-writes a range.
+ var kept: [Suggestion] = []
+ for lint in lints {
+ guard lint.len > 0 || !lint.replacement.isEmpty else { continue }
+ guard let range = Self.utf16Range(scalarStart: lint.start, scalarLen: lint.len, in: text)
+ else { continue }
+ let original = (text as NSString).substring(with: range)
+ if !original.isEmpty, ignoreList.contains(original.lowercased()) { continue }
+ if kept.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) { continue }
+ kept.append(Suggestion(
+ location: range.location, length: range.length,
+ original: original, replacement: lint.replacement,
+ kind: GrammarKind(harperKind: lint.kind), message: lint.message))
+ }
+ return kept.sorted { $0.location < $1.location }
+ }
+
+ // MARK: - FFI decoding
+
+ private struct HarperLint: Decodable {
+ let start: Int
+ let len: Int
+ let replacement: String
+ let message: String
+ let kind: String
+ }
+
+ /// Map a Harper char-span (Unicode scalar offsets, end-exclusive) onto a
+ /// UTF-16 `NSRange`. Scalar boundaries are always UTF-16 boundaries, so the
+ /// conversion is exact.
+ static func utf16Range(scalarStart: Int, scalarLen: Int, in text: String) -> NSRange? {
+ let scalars = text.unicodeScalars
+ guard let s = scalars.index(scalars.startIndex, offsetBy: scalarStart, limitedBy: scalars.endIndex),
+ let e = scalars.index(s, offsetBy: scalarLen, limitedBy: scalars.endIndex)
+ else { return nil }
+ let location = text.utf16.distance(from: text.utf16.startIndex, to: s)
+ let length = text.utf16.distance(from: s, to: e)
+ return NSRange(location: location, length: length)
+ }
+}
+
+extension GrammarKind {
+ /// Map Harper's `kind` string (see harper.h) onto our category enum.
+ init(harperKind kind: String) {
+ switch kind {
+ case "spelling": self = .spelling
+ case "punctuation": self = .punctuation
+ case "repetition": self = .repetition
+ case "capitalization": self = .capitalization
+ case "phrasing": self = .phrasing
+ default: self = .grammar
+ }
+ }
+}
diff --git a/Sources/GrammaGem/Grammar/PersonalDictionary.swift b/Sources/GrammarGem/Grammar/PersonalDictionary.swift
similarity index 93%
rename from Sources/GrammaGem/Grammar/PersonalDictionary.swift
rename to Sources/GrammarGem/Grammar/PersonalDictionary.swift
index 97764ac..29afb66 100644
--- a/Sources/GrammaGem/Grammar/PersonalDictionary.swift
+++ b/Sources/GrammarGem/Grammar/PersonalDictionary.swift
@@ -1,13 +1,13 @@
import Foundation
-/// The user's personal dictionary — names, brand terms, jargon GrammaGem should
+/// The user's personal dictionary — names, brand terms, jargon GrammarGem should
/// never "correct". Feeds Harper's ignore-list and the LLM context. Capped at 25
/// entries on free, unlimited on paid (enforced by `FeatureGate`, not here).
@MainActor
final class PersonalDictionary: ObservableObject {
@Published private(set) var entries: [String]
- private let key = "GrammaGem.personalDictionary"
+ private let key = "GrammarGem.personalDictionary"
private let defaults = UserDefaults.standard
init() {
diff --git a/Sources/GrammarGem/Grammar/SystemGrammarEngine.swift b/Sources/GrammarGem/Grammar/SystemGrammarEngine.swift
new file mode 100644
index 0000000..7bd85b5
--- /dev/null
+++ b/Sources/GrammarGem/Grammar/SystemGrammarEngine.swift
@@ -0,0 +1,90 @@
+import Foundation
+import AppKit
+
+/// Layer 1 orchestrator. The real **Harper** core is the primary engine
+/// (grammar, punctuation, repetition, capitalization, spelling); macOS's
+/// `NSSpellChecker` is layered on top purely for *extra spelling coverage* from
+/// its large system dictionary.
+///
+/// Two correctness rules make the combination trustworthy:
+/// 1. **Harper wins on overlap** — a system spelling guess is dropped if Harper
+/// already flagged the same span (Harper's fixes carry correct casing + a
+/// real explanation).
+/// 2. **Case-preserving** — a system spelling replacement is matched to the
+/// original token's casing, so a mid-sentence word is never spuriously
+/// capitalized (the historical `problemm` -> `Problem` bug). Genuine
+/// sentence-start capitalization is surfaced separately by Harper.
+final class SystemGrammarEngine: GrammarEngine {
+ var ignoreList: Set = [] {
+ didSet { harper.ignoreList = ignoreList }
+ }
+
+ private let checker = NSSpellChecker.shared
+ private let tag = NSSpellChecker.uniqueSpellDocumentTag()
+ private let harper = HarperEngine()
+
+ func check(_ text: String) -> [Suggestion] {
+ guard text.count >= 2 else { return [] }
+ var out = harper.check(text)
+
+ // Supplement with NSSpellChecker spelling only (Harper owns grammar),
+ // de-duped against Harper spans and case-matched to the original token.
+ let spelling = onMain { self.spellingSupplement(text) }
+ for s in spelling where !out.contains(where: { overlaps($0.range, s.range) }) {
+ out.append(s)
+ }
+ return out.sorted { $0.location < $1.location }
+ }
+
+ private func spellingSupplement(_ text: String) -> [Suggestion] {
+ let ns = text as NSString
+ let full = NSRange(location: 0, length: ns.length)
+ let language = checker.language()
+ let results = checker.check(
+ text, range: full, types: NSTextCheckingResult.CheckingType.spelling.rawValue,
+ options: nil, inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil)
+
+ var out: [Suggestion] = []
+ for r in results where r.resultType == .spelling {
+ let word = ns.substring(with: r.range)
+ if ignoreList.contains(word.lowercased()) { continue }
+ guard let raw = checker.correction(
+ forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag)
+ ?? checker.guesses(
+ forWordRange: r.range, in: text, language: language,
+ inSpellDocumentWithTag: tag)?.first
+ else { continue }
+ let fix = Self.matchingCase(of: word, in: raw)
+ guard fix != word else { continue } // never emit a no-op
+ out.append(Suggestion(
+ location: r.range.location, length: r.range.length,
+ original: word, replacement: fix,
+ kind: .spelling, message: "Possible spelling mistake"))
+ }
+ return out
+ }
+
+ /// Re-case `replacement` to follow `original`'s casing.
+ static func matchingCase(of original: String, in replacement: String) -> String {
+ guard let o = original.first, let r = replacement.first else { return replacement }
+ // ALL-CAPS original (with at least one cased letter) -> upper the whole thing.
+ if original.count > 1, original == original.uppercased(), original != original.lowercased() {
+ return replacement.uppercased()
+ }
+ if o.isLowercase, r.isUppercase {
+ return r.lowercased() + replacement.dropFirst()
+ }
+ if o.isUppercase, r.isLowercase {
+ return r.uppercased() + replacement.dropFirst()
+ }
+ return replacement
+ }
+
+ private func overlaps(_ a: NSRange, _ b: NSRange) -> Bool {
+ NSIntersectionRange(a, b).length > 0
+ }
+
+ private func onMain(_ work: @escaping () -> T) -> T {
+ Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work)
+ }
+}
diff --git a/Sources/GrammarGem/Grammar/WritingScore.swift b/Sources/GrammarGem/Grammar/WritingScore.swift
new file mode 100644
index 0000000..dd1988a
--- /dev/null
+++ b/Sources/GrammarGem/Grammar/WritingScore.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+/// A deterministic, on-device "cleanliness" score (0–100) for a chunk of text.
+///
+/// Honest by construction: it's defined relative to *this* text — weighted issues
+/// per 100 words — never a hidden cloud cohort, and grammar/spelling are always
+/// fully resolvable, so a clean document genuinely reaches 100. (Contrast with
+/// Grammarly's percentile-vs-other-users score, which needs telemetry + an
+/// account; see docs/grammarly-value-plan.md §5.)
+enum WritingScore {
+ /// - Parameters:
+ /// - weightedIssues: sum of `GrammarKind.severity` across outstanding issues.
+ /// - words: total words in the scanned text.
+ static func score(weightedIssues: Int, words: Int) -> Int {
+ guard words > 0 else { return 100 }
+ let perHundredWords = Double(weightedIssues) / Double(words) * 100.0
+ // Each weighted issue per 100 words costs ~4 points; clamp to 0…100.
+ return max(0, min(100, Int((100.0 - perHundredWords * 4.0).rounded())))
+ }
+}
diff --git a/Sources/GrammaGem/Licensing/DeviceFingerprint.swift b/Sources/GrammarGem/Licensing/DeviceFingerprint.swift
similarity index 97%
rename from Sources/GrammaGem/Licensing/DeviceFingerprint.swift
rename to Sources/GrammarGem/Licensing/DeviceFingerprint.swift
index be5ae2b..b17ee51 100644
--- a/Sources/GrammaGem/Licensing/DeviceFingerprint.swift
+++ b/Sources/GrammarGem/Licensing/DeviceFingerprint.swift
@@ -38,7 +38,7 @@ enum DeviceFingerprint {
}
private static func fallbackID() -> String {
- let key = "GrammaGem.fallbackDeviceID"
+ let key = "GrammarGem.fallbackDeviceID"
if let existing = UserDefaults.standard.string(forKey: key) { return existing }
let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: key)
diff --git a/Sources/GrammaGem/Licensing/Keychain.swift b/Sources/GrammarGem/Licensing/Keychain.swift
similarity index 100%
rename from Sources/GrammaGem/Licensing/Keychain.swift
rename to Sources/GrammarGem/Licensing/Keychain.swift
diff --git a/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift b/Sources/GrammarGem/Licensing/LemonSqueezyClient.swift
similarity index 86%
rename from Sources/GrammaGem/Licensing/LemonSqueezyClient.swift
rename to Sources/GrammarGem/Licensing/LemonSqueezyClient.swift
index 5b9a6b4..58e5399 100644
--- a/Sources/GrammaGem/Licensing/LemonSqueezyClient.swift
+++ b/Sources/GrammarGem/Licensing/LemonSqueezyClient.swift
@@ -2,7 +2,7 @@ import Foundation
/// Talks to the **Lemon Squeezy License API** directly from the app — no server
/// of ours, no database, no per-user cost (spec §6). Exposes activate / validate
-/// / deactivate and hard-verifies that a key belongs to GrammaGem + the claimed tier.
+/// / deactivate and hard-verifies that a key belongs to GrammarGem + the claimed tier.
final class LemonSqueezyClient {
private let session: URLSession
private let base = AppConfig.LemonSqueezy.apiBase
@@ -54,20 +54,28 @@ final class LemonSqueezyClient {
// MARK: - Hard verification (spec §6)
/// Confirm the key is for OUR store/product, then resolve which tier its
- /// variant corresponds to. Prevents a key from another LS product unlocking GrammaGem.
+ /// variant corresponds to. Prevents a key from another LS product unlocking GrammarGem.
private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier {
guard let meta else { throw LicenseError.wrongProduct }
- // Store + product check. If AppConfig still holds placeholder (non-numeric)
- // IDs, we log and skip the check so the stub flow runs — TODO(real-integration):
- // once real numeric IDs are set, this becomes a hard gate.
+ // Store + product check. With real numeric IDs configured this is a hard
+ // gate that prevents a key from another Lemon Squeezy product unlocking
+ // GrammarGem. If AppConfig still holds placeholder (non-numeric) IDs the
+ // gate can't run — allow the bypass ONLY in DEBUG so local stub flows work;
+ // in a release build FAIL CLOSED so the "any key unlocks paid" bypass can
+ // never ship. TODO(real-integration): set real IDs in AppConfig.LemonSqueezy.
if let expectedStore = Int(AppConfig.LemonSqueezy.expectedStoreID),
let expectedProduct = Int(AppConfig.LemonSqueezy.expectedProductID) {
guard meta.store_id == expectedStore, meta.product_id == expectedProduct else {
throw LicenseError.wrongProduct
}
} else {
- Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig.")
+ #if DEBUG
+ Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig (DEBUG only).")
+ #else
+ Log.licensing.error("Refusing to verify license: placeholder Lemon Squeezy IDs in a release build.")
+ throw LicenseError.wrongProduct
+ #endif
}
// Resolve tier from the variant id (reverse-lookup the configured map).
diff --git a/Sources/GrammaGem/Licensing/LicenseManager.swift b/Sources/GrammarGem/Licensing/LicenseManager.swift
similarity index 100%
rename from Sources/GrammaGem/Licensing/LicenseManager.swift
rename to Sources/GrammarGem/Licensing/LicenseManager.swift
diff --git a/Sources/GrammaGem/Licensing/LicenseModels.swift b/Sources/GrammarGem/Licensing/LicenseModels.swift
similarity index 99%
rename from Sources/GrammaGem/Licensing/LicenseModels.swift
rename to Sources/GrammarGem/Licensing/LicenseModels.swift
index a9196a5..140a3e2 100644
--- a/Sources/GrammaGem/Licensing/LicenseModels.swift
+++ b/Sources/GrammarGem/Licensing/LicenseModels.swift
@@ -62,7 +62,7 @@ struct LSLicenseKey: Codable {
}
/// `meta` carries the store/product/variant identity we *hard-verify* against
-/// AppConfig so a key from another Lemon Squeezy product can't unlock GrammaGem.
+/// AppConfig so a key from another Lemon Squeezy product can't unlock GrammarGem.
struct LSMeta: Codable {
let store_id: Int?
let product_id: Int?
diff --git a/Sources/GrammaGem/Preferences/Preferences.swift b/Sources/GrammarGem/Preferences/Preferences.swift
similarity index 100%
rename from Sources/GrammaGem/Preferences/Preferences.swift
rename to Sources/GrammarGem/Preferences/Preferences.swift
diff --git a/Sources/GrammarGem/Stats/UsageStats.swift b/Sources/GrammarGem/Stats/UsageStats.swift
new file mode 100644
index 0000000..40d5db0
--- /dev/null
+++ b/Sources/GrammarGem/Stats/UsageStats.swift
@@ -0,0 +1,154 @@
+import Foundation
+
+/// Privacy-friendly, fully-local usage insights. Counts *how often* features are
+/// used — never *what* was written. Stored only on this Mac.
+@MainActor
+final class UsageStats: ObservableObject {
+ struct Snapshot: Codable, Equatable {
+ var totalCorrections = 0
+ var totalAIActions = 0
+ var totalWords = 0
+ var activeDays = 0 // distinct days with ≥1 action (lifetime)
+ var firstUseDay: String? // "yyyy-MM-dd" of first ever action
+ var perDay: [String: Int] = [:] // "yyyy-MM-dd" -> actions that day
+ }
+
+ /// What a Grammarly-style subscription runs per year (USD) — used only for the
+ /// honest, local "subscription you didn't pay" figure. GrammarGem is one-time.
+ static let comparableAnnualSubscriptionUSD = 144.0
+
+ @Published private(set) var data: Snapshot
+
+ private let key = "GrammarGem.usageStats"
+ private let d = UserDefaults.standard
+
+ private static let fmt: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd"
+ f.locale = Locale(identifier: "en_US_POSIX")
+ f.timeZone = .current
+ return f
+ }()
+
+ init() {
+ if let raw = d.data(forKey: key),
+ let decoded = try? JSONDecoder().decode(Snapshot.self, from: raw) {
+ data = decoded
+ } else {
+ data = Snapshot()
+ }
+ }
+
+ func recordCorrection(words: Int) {
+ data.totalCorrections += 1
+ data.totalWords += max(0, words)
+ bumpToday()
+ persist()
+ }
+
+ func recordAIAction(words: Int) {
+ data.totalAIActions += 1
+ data.totalWords += max(0, words)
+ bumpToday()
+ persist()
+ }
+
+ /// A playful, clearly-estimated "time saved" figure (local only).
+ var estimatedMinutesSaved: Int {
+ (data.totalCorrections * 8 + data.totalAIActions * 45) / 60
+ }
+
+ // MARK: - Value loop (streak / milestones / subscription-you-didn't-pay)
+
+ /// Consecutive days, ending today or yesterday, with at least one action.
+ var currentStreak: Int { Self.streak(perDay: data.perDay, asOf: Date()) }
+
+ /// The Grammarly subscription you DIDN'T pay: their annual price prorated over
+ /// how long you've owned GrammarGem. Honest, local, illustrative — and it only
+ /// grows, which is the point of a one-time purchase (see the value plan §5).
+ var subscriptionAvoidedUSD: Int {
+ Self.subscriptionAvoidedUSD(
+ firstUseDay: data.firstUseDay, asOf: Date(),
+ annualPrice: Self.comparableAnnualSubscriptionUSD)
+ }
+
+ /// The next correction milestone and progress toward it (for a nudge/badge).
+ var nextMilestone: (target: Int, label: String, progress: Double) {
+ Self.nextMilestone(corrections: data.totalCorrections)
+ }
+
+ // MARK: - Pure helpers (unit-testable; no UserDefaults / wall clock)
+
+ static func streak(perDay: [String: Int], asOf now: Date) -> Int {
+ let cal = Calendar.current
+ func used(_ day: Date) -> Bool { (perDay[fmt.string(from: day)] ?? 0) > 0 }
+ var day = now
+ if !used(day) {
+ // Streak is still alive if used yesterday but not yet today.
+ day = cal.date(byAdding: .day, value: -1, to: day) ?? day
+ if !used(day) { return 0 }
+ }
+ var streak = 0
+ while used(day) {
+ streak += 1
+ day = cal.date(byAdding: .day, value: -1, to: day) ?? day
+ }
+ return streak
+ }
+
+ static func subscriptionAvoidedUSD(firstUseDay: String?, asOf now: Date, annualPrice: Double) -> Int {
+ guard let first = firstUseDay, let firstDate = fmt.date(from: first) else { return 0 }
+ let days = max(1, Calendar.current.dateComponents([.day], from: firstDate, to: now).day ?? 0)
+ return Int((Double(days) / 365.0 * annualPrice).rounded())
+ }
+
+ static func nextMilestone(corrections: Int) -> (target: Int, label: String, progress: Double) {
+ let targets = [100, 500, 1_000, 5_000, 10_000, 50_000, 100_000]
+ let target = targets.first { $0 > corrections } ?? (((corrections / 100_000) + 1) * 100_000)
+ let previous = targets.last { $0 <= corrections } ?? 0
+ let span = max(1, target - previous)
+ let progress = min(1.0, Double(corrections - previous) / Double(span))
+ let label = target >= 1_000 ? "\(target / 1_000)k corrections" : "\(target) corrections"
+ return (target, label, progress)
+ }
+
+ /// Counts for the last 7 calendar days, oldest → newest, for a small chart.
+ func last7Days() -> [(label: String, count: Int)] {
+ let cal = Calendar.current
+ let today = Date()
+ return (0..<7).reversed().map { offset in
+ let day = cal.date(byAdding: .day, value: -offset, to: today) ?? today
+ let key = Self.fmt.string(from: day)
+ let short = day.formatted(.dateTime.weekday(.narrow))
+ return (label: short, count: data.perDay[key] ?? 0)
+ }
+ }
+
+ func reset() {
+ data = Snapshot()
+ persist()
+ }
+
+ private func bumpToday() {
+ let k = Self.fmt.string(from: Date())
+ if data.perDay[k] == nil { // first action on a brand-new day
+ data.activeDays += 1
+ if data.firstUseDay == nil { data.firstUseDay = k }
+ }
+ data.perDay[k, default: 0] += 1
+ // Keep only ~60 days of history.
+ if data.perDay.count > 70 {
+ let keep = Set((0..<60).compactMap { off -> String? in
+ guard let day = Calendar.current.date(byAdding: .day, value: -off, to: Date()) else { return nil }
+ return Self.fmt.string(from: day)
+ })
+ data.perDay = data.perDay.filter { keep.contains($0.key) }
+ }
+ }
+
+ private func persist() {
+ if let raw = try? JSONEncoder().encode(data) {
+ d.set(raw, forKey: key)
+ }
+ }
+}
diff --git a/Sources/GrammarGem/System/AXSupport.swift b/Sources/GrammarGem/System/AXSupport.swift
new file mode 100644
index 0000000..7858204
--- /dev/null
+++ b/Sources/GrammarGem/System/AXSupport.swift
@@ -0,0 +1,76 @@
+import Foundation
+import AppKit
+import ApplicationServices
+
+/// Safe Accessibility helpers shared across the System layer.
+///
+/// `AXUIElementCopyAttributeValue` returning `.success` only guarantees a
+/// non-null `CFTypeRef` — NOT that it is an `AXUIElement`. A misbehaving AX
+/// server (Electron/Java/custom) can return a different CF type, and a forced
+/// `as!` cast then traps and crashes the whole app. These helpers cast safely.
+enum AX {
+ /// Safely bridge a CFTypeRef to AXUIElement, returning nil on type mismatch.
+ static func element(_ ref: CFTypeRef?) -> AXUIElement? {
+ guard let ref, CFGetTypeID(ref) == AXUIElementGetTypeID() else { return nil }
+ return (ref as! AXUIElement) // safe: type id verified above
+ }
+
+ /// Copy an attribute as an `AXUIElement` (nil on failure/type mismatch).
+ static func copyElement(_ element: AXUIElement, _ attribute: String) -> AXUIElement? {
+ var ref: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
+ return nil
+ }
+ return AX.element(ref)
+ }
+
+ /// Copy an attribute as a `String` (nil on failure/wrong type).
+ static func copyString(_ element: AXUIElement, _ attribute: String) -> String? {
+ var ref: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else {
+ return nil
+ }
+ return ref as? String
+ }
+
+ /// Bound each cross-process AX message so a wedged target app can't hang us.
+ static func setMessagingTimeout(_ element: AXUIElement, _ seconds: Float) {
+ AXUIElementSetMessagingTimeout(element, seconds)
+ }
+
+ /// Whether an element is a secure text field (a password input). GrammarGem
+ /// must NEVER read or rewrite these — so a user who forgot to exclude a
+ /// password manager or login form can't have a decrypted password captured,
+ /// sent through the grammar/AI engines, and written back. This is enforced in
+ /// code rather than relying on the user's exclusion blocklist.
+ static func isSecureTextField(_ element: AXUIElement) -> Bool {
+ // AXSecureTextField is the subrole AppKit/most Electron inputs report for
+ // password fields. String-compared (the constant isn't always imported).
+ copyString(element, kAXSubroleAttribute) == "AXSecureTextField"
+ }
+
+ /// The currently-focused UI element across the system — robust to the
+ /// system-wide element failing.
+ ///
+ /// On recent macOS, `AXUIElementCreateSystemWide()` returns
+ /// `kAXErrorCannotComplete` (-25204) for *every* query made by a background /
+ /// accessory process (which is exactly what a menu-bar app like GrammarGem is).
+ /// When that happens the system-wide focused-element read yields nil and the
+ /// app "sees" no text or text fields at all. Resolving the focused element
+ /// through the **frontmost application's** own AX element works reliably in
+ /// that context, so we prefer the system-wide read (one IPC hop, correct on
+ /// OSes where it works) and transparently fall back to the per-app read.
+ static func focusedElement() -> AXUIElement? {
+ let systemWide = AXUIElementCreateSystemWide()
+ setMessagingTimeout(systemWide, 0.5)
+ if let element = copyElement(systemWide, kAXFocusedUIElementAttribute as String) {
+ return element
+ }
+ guard let pid = NSWorkspace.shared.frontmostApplication?.processIdentifier else {
+ return nil
+ }
+ let axApp = AXUIElementCreateApplication(pid)
+ setMessagingTimeout(axApp, 0.5)
+ return copyElement(axApp, kAXFocusedUIElementAttribute as String)
+ }
+}
diff --git a/Sources/GrammaGem/System/AppDetector.swift b/Sources/GrammarGem/System/AppDetector.swift
similarity index 100%
rename from Sources/GrammaGem/System/AppDetector.swift
rename to Sources/GrammarGem/System/AppDetector.swift
diff --git a/Sources/GrammaGem/System/Exclusions.swift b/Sources/GrammarGem/System/Exclusions.swift
similarity index 92%
rename from Sources/GrammaGem/System/Exclusions.swift
rename to Sources/GrammarGem/System/Exclusions.swift
index 4587ed7..84ac71f 100644
--- a/Sources/GrammaGem/System/Exclusions.swift
+++ b/Sources/GrammarGem/System/Exclusions.swift
@@ -1,7 +1,7 @@
import Foundation
import AppKit
-/// The "page blocker": apps and domains where GrammaGem should stay completely
+/// The "page blocker": apps and domains where GrammarGem should stay completely
/// out of the way — password managers, banking sites, anything sensitive.
/// When the frontmost context is excluded, live monitoring and the hotkeys are
/// suppressed.
@@ -10,8 +10,8 @@ final class Exclusions: ObservableObject {
@Published private(set) var blockedApps: [String] // bundle identifiers
@Published private(set) var blockedDomains: [String] // hostnames, e.g. "chase.com"
- private let appsKey = "GrammaGem.blockedApps"
- private let domainsKey = "GrammaGem.blockedDomains"
+ private let appsKey = "GrammarGem.blockedApps"
+ private let domainsKey = "GrammarGem.blockedDomains"
private let d = UserDefaults.standard
/// Common sensitive apps offered as one-tap suggestions in the UI.
@@ -31,7 +31,7 @@ final class Exclusions: ObservableObject {
/// a browser's URL can't be read).
var hasDomainRules: Bool { !blockedDomains.isEmpty }
- /// Whether GrammaGem should be suppressed for this frontmost context.
+ /// Whether GrammarGem should be suppressed for this frontmost context.
func isBlocked(bundleID: String?, domain: String?) -> Bool {
if let bundleID, blockedApps.contains(bundleID) { return true }
if let host = domain?.lowercased(), !host.isEmpty {
diff --git a/Sources/GrammaGem/System/HotkeyCombo.swift b/Sources/GrammarGem/System/HotkeyCombo.swift
similarity index 100%
rename from Sources/GrammaGem/System/HotkeyCombo.swift
rename to Sources/GrammarGem/System/HotkeyCombo.swift
diff --git a/Sources/GrammaGem/System/HotkeyManager.swift b/Sources/GrammarGem/System/HotkeyManager.swift
similarity index 100%
rename from Sources/GrammaGem/System/HotkeyManager.swift
rename to Sources/GrammarGem/System/HotkeyManager.swift
diff --git a/Sources/GrammaGem/System/LiveMonitor.swift b/Sources/GrammarGem/System/LiveMonitor.swift
similarity index 73%
rename from Sources/GrammaGem/System/LiveMonitor.swift
rename to Sources/GrammarGem/System/LiveMonitor.swift
index 8d9124c..ad6b65c 100644
--- a/Sources/GrammaGem/System/LiveMonitor.swift
+++ b/Sources/GrammarGem/System/LiveMonitor.swift
@@ -19,18 +19,40 @@ struct DetectedField: Identifiable {
/// Performance & safety: the Accessibility-tree traversal (the part that can hang
/// on a busy/wedged app) runs OFF the main thread with a per-message timeout; only
/// the bounded spell/grammar pass and publish happen on the main actor. It never
-/// scans GrammaGem itself, respects the page blocker, and applies fixes against the
+/// scans GrammarGem itself, respects the page blocker, and applies fixes against the
/// element's *live* value so it can't overwrite text typed since the last scan.
@MainActor
final class LiveMonitor: ObservableObject {
@Published private(set) var fields: [DetectedField] = []
@Published private(set) var scannedCount = 0
+ @Published private(set) var scannedWordCount = 0
@Published private(set) var activeAppName = ""
@Published private(set) var running = false
/// Total number of issues across all detected text areas.
var issueCount: Int { fields.reduce(0) { $0 + $1.suggestions.count } }
+ /// Outstanding issues grouped into the user-facing buckets (for filter chips).
+ var bucketCounts: [IssueBucket: Int] {
+ var counts: [IssueBucket: Int] = [:]
+ for field in fields {
+ for s in field.suggestions { counts[s.kind.bucket, default: 0] += 1 }
+ }
+ return counts
+ }
+
+ /// 0–100 on-device cleanliness score for the text currently on screen.
+ var writingScore: Int {
+ let weighted = fields.reduce(0) { acc, f in
+ acc + f.suggestions.reduce(0) { $0 + $1.kind.severity }
+ }
+ return WritingScore.score(weightedIssues: weighted, words: scannedWordCount)
+ }
+
+ /// Content keys of suggestions the user has dismissed; survives re-scans so a
+ /// dismissed issue doesn't reappear on the next poll.
+ private var dismissedKeys: Set = []
+
private let grammar: GrammarEngine
private let detector: AppDetector
private let exclusions: Exclusions
@@ -88,6 +110,36 @@ final class LiveMonitor: ObservableObject {
refreshSoon()
}
+ // MARK: - Dismissing (hide a suggestion without applying it)
+
+ /// Hide one suggestion. A cheap, always-available "no" is what makes saying
+ /// "yes" (Apply) feel safe. The dismissal is keyed by content so it persists
+ /// across re-scans of unchanged text.
+ func dismiss(_ suggestion: Suggestion, in field: DetectedField) {
+ dismissedKeys.insert(Self.dismissKey(fieldID: field.id, suggestion))
+ fields = filterDismissed(fields)
+ }
+
+ private static func dismissKey(fieldID: String, _ s: Suggestion) -> String {
+ "\(fieldID)|\(s.location)|\(s.length)|\(s.original)|\(s.replacement)"
+ }
+
+ private func filterDismissed(_ input: [DetectedField]) -> [DetectedField] {
+ input.compactMap { f in
+ let remaining = f.suggestions.filter {
+ !dismissedKeys.contains(Self.dismissKey(fieldID: f.id, $0))
+ }
+ guard !remaining.isEmpty else { return nil }
+ return DetectedField(id: f.id, label: f.label, roleName: f.roleName,
+ text: f.text, suggestions: remaining,
+ element: f.element, isFocused: f.isFocused)
+ }
+ }
+
+ private static func wordCount(_ s: String) -> Int {
+ s.split { $0.isWhitespace || $0.isNewline }.count
+ }
+
/// Correct the element's *current* value (never a stale snapshot), so any
/// keystrokes entered since the scan are preserved.
private func applyCorrection(to field: DetectedField) -> Int {
@@ -127,7 +179,7 @@ final class LiveMonitor: ObservableObject {
if scanning && !force { return } // don't pile up scans
guard let app = NSWorkspace.shared.frontmostApplication else { clear(); return }
- // Never scan GrammaGem's own windows.
+ // Never scan GrammarGem's own windows.
if app.processIdentifier == ownPID { clear(); return }
activeAppName = app.localizedName ?? ""
@@ -156,6 +208,11 @@ final class LiveMonitor: ObservableObject {
guard generation == scanGeneration else { return } // superseded by a newer scan
guard !scanned.isEmpty else { clear(); return }
+ // Total words across ALL scanned fields (clean ones included) so the
+ // writing score is honest about the whole on-screen text, not just the
+ // fields that happen to have issues.
+ scannedWordCount = scanned.reduce(0) { $0 + Self.wordCount($1.text) }
+
// Change detection includes focus + element identity so tabbing between
// same-text fields still updates the focused indicator.
var hasher = Hasher()
@@ -179,16 +236,17 @@ final class LiveMonitor: ObservableObject {
text: f.text, suggestions: suggestions, element: f.element, isFocused: f.isFocused))
}
detected.sort { ($0.isFocused ? 0 : 1) < ($1.isFocused ? 0 : 1) }
- fields = detected
+ fields = filterDismissed(detected)
}
private nonisolated static func systemFocusedElement() -> AXUIElement? {
- AX.copyElement(AXUIElementCreateSystemWide(), kAXFocusedUIElementAttribute)
+ AX.focusedElement()
}
private func clear() {
if !fields.isEmpty { fields = [] }
scannedCount = 0
+ scannedWordCount = 0
lastHash = 0
}
}
diff --git a/Sources/GrammarGem/System/LocalEngineServer.swift b/Sources/GrammarGem/System/LocalEngineServer.swift
new file mode 100644
index 0000000..eb087f6
--- /dev/null
+++ b/Sources/GrammarGem/System/LocalEngineServer.swift
@@ -0,0 +1,311 @@
+import Foundation
+import Network
+
+/// A minimal, loopback-only HTTP server that exposes GrammarGem's local engines
+/// (Harper grammar + the on-device LLM) to a **browser extension** — the
+/// highest-volume surface for a grammar tool — with NO cloud round-trip. Built on
+/// Network.framework so it adds no third-party HTTP dependency, keeping the
+/// privacy/trust surface minimal.
+///
+/// Security posture (deliberate):
+/// - Binds to 127.0.0.1 only (never a routable interface), via `requiredLocalEndpoint`.
+/// - Every engine endpoint requires a per-install bearer token (`X-GrammarGem-Token`);
+/// a web page that doesn't know the token cannot drive the engines.
+/// - OFF by default — only starts when the user opts in, so the app ships no
+/// listening socket unless explicitly enabled.
+///
+/// It reuses the SAME engines as the menu-bar app via injected async handlers, so
+/// there is one engine, one set of prompts (`AIPrompts`), and one entitlement gate
+/// across the app, the extension, and (later) other backends.
+final class LocalEngineServer {
+
+ struct AIRequest { let action: AIAction; let text: String }
+
+ struct WireSuggestion: Encodable {
+ let location: Int
+ let length: Int
+ let original: String
+ let replacement: String
+ let kind: String
+ let message: String
+ }
+
+ /// Grammar pass: corrected text + suggestion spans for the extension to underline.
+ typealias GrammarHandler = (String) async -> (corrected: String, suggestions: [WireSuggestion])
+ /// AI action outcome (a plain enum so a user-facing message needn't be an `Error`).
+ enum AIOutcome { case ok(String); case error(String) }
+ /// AI action: runs the gate + model, returns the result or a user-facing error.
+ typealias AIHandler = (AIRequest) async -> AIOutcome
+ /// Streaming AI action: runs the gate + model, invoking `onChunk` per token as it
+ /// generates, then returns the final outcome (full text, or an error message).
+ typealias AIStreamHandler = (AIRequest, @escaping (String) -> Void) async -> AIOutcome
+
+ private let port: UInt16
+ private let token: String
+ private let grammarHandler: GrammarHandler
+ private let aiHandler: AIHandler
+ private let aiStreamHandler: AIStreamHandler
+
+ private var listener: NWListener?
+ private let queue = DispatchQueue(label: "com.grammargem.localserver")
+
+ init(port: UInt16, token: String,
+ grammarHandler: @escaping GrammarHandler,
+ aiHandler: @escaping AIHandler,
+ aiStreamHandler: @escaping AIStreamHandler) {
+ self.port = port
+ self.token = token
+ self.grammarHandler = grammarHandler
+ self.aiHandler = aiHandler
+ self.aiStreamHandler = aiStreamHandler
+ }
+
+ /// A stable per-install token, persisted so the extension can be paired once.
+ static func loadOrCreateToken() -> String {
+ let d = UserDefaults.standard
+ if let existing = d.string(forKey: AppConfig.LocalServer.tokenKey), !existing.isEmpty {
+ return existing
+ }
+ let token = UUID().uuidString + UUID().uuidString.replacingOccurrences(of: "-", with: "")
+ d.set(token, forKey: AppConfig.LocalServer.tokenKey)
+ return token
+ }
+
+ func start() {
+ guard listener == nil else { return }
+ do {
+ let params = NWParameters.tcp
+ guard let nwPort = NWEndpoint.Port(rawValue: port) else { return }
+ // Loopback-only: refuse to bind to any routable interface.
+ params.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: nwPort)
+ let listener = try NWListener(using: params)
+ listener.newConnectionHandler = { [weak self] conn in self?.accept(conn) }
+ listener.start(queue: queue)
+ self.listener = listener
+ Log.app.info("Local engine server on 127.0.0.1:\(self.port, privacy: .public) (extension bridge)")
+ } catch {
+ Log.app.error("Local engine server failed to start: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+
+ func stop() {
+ listener?.cancel()
+ listener = nil
+ }
+
+ // MARK: - Connection lifecycle
+
+ private func accept(_ conn: NWConnection) {
+ conn.start(queue: queue)
+ receive(conn, buffer: Data())
+ }
+
+ private func receive(_ conn: NWConnection, buffer: Data) {
+ conn.receive(minimumIncompleteLength: 1, maximumLength: 1 << 16) { [weak self] data, _, isComplete, error in
+ guard let self else { conn.cancel(); return }
+ var buffer = buffer
+ if let data { buffer.append(data) }
+ if let request = HTTPRequest(buffer), request.isComplete {
+ Task { await self.route(request, on: conn) }
+ return
+ }
+ if isComplete || error != nil { conn.cancel(); return }
+ self.receive(conn, buffer: buffer) // headers/body not fully arrived yet
+ }
+ }
+
+ // MARK: - Routing
+
+ private func route(_ request: HTTPRequest, on conn: NWConnection) async {
+ // CORS preflight — extensions issue this before a cross-origin POST.
+ if request.method == "OPTIONS" {
+ send(conn, status: 204, body: Data())
+ return
+ }
+
+ if request.path == "/health" || request.path == "/v1/health" {
+ sendJSON(conn, status: 200, HealthResponse(ok: true, app: AppConfig.appName, version: AppConfig.appVersion))
+ return
+ }
+
+ // Token gate for every engine endpoint.
+ guard request.headers["x-grammargem-token"] == token else {
+ sendJSON(conn, status: 401, ErrorResponse(error: "unauthorized"))
+ return
+ }
+
+ switch (request.method, request.path) {
+ case ("POST", "/v1/grammar"):
+ guard let text = decodeText(request.body) else {
+ sendJSON(conn, status: 400, ErrorResponse(error: "missing 'text'")); return
+ }
+ let result = await grammarHandler(text)
+ sendJSON(conn, status: 200, GrammarResponse(corrected: result.corrected, suggestions: result.suggestions))
+
+ case ("POST", "/v1/ai"):
+ guard let req = decodeAI(request.body) else {
+ sendJSON(conn, status: 400, ErrorResponse(error: "bad request")); return
+ }
+ switch await aiHandler(req) {
+ case .ok(let out): sendJSON(conn, status: 200, AIResponse(result: out))
+ case .error(let msg): sendJSON(conn, status: 422, ErrorResponse(error: msg))
+ }
+
+ case ("POST", "/v1/ai/stream"):
+ guard let req = decodeAI(request.body) else {
+ sendJSON(conn, status: 400, ErrorResponse(error: "bad request")); return
+ }
+ // Newline-delimited JSON frames: {"chunk":"…"} per token, then a final
+ // {"done":true} or {"error":"…"}. The 200 header goes out before tokens,
+ // so an entitlement/readiness failure surfaces as a closing error frame.
+ beginStream(conn)
+ let outcome = await aiStreamHandler(req) { [weak self] chunk in
+ self?.streamFrame(conn, ["chunk": chunk])
+ }
+ switch outcome {
+ case .ok: streamFrame(conn, ["done": true])
+ case .error(let msg): streamFrame(conn, ["error": msg])
+ }
+ endStream(conn)
+
+ default:
+ sendJSON(conn, status: 404, ErrorResponse(error: "not found"))
+ }
+ }
+
+ // MARK: - Body decoding
+
+ private func decodeText(_ body: Data) -> String? {
+ struct P: Decodable { let text: String }
+ guard let p = try? JSONDecoder().decode(P.self, from: body), !p.text.isEmpty else { return nil }
+ return p.text
+ }
+
+ private func decodeAI(_ body: Data) -> AIRequest? {
+ struct P: Decodable {
+ let action: String
+ let text: String
+ let instruction: String?
+ let language: String?
+ let mode: String?
+ let tone: String?
+ }
+ guard let p = try? JSONDecoder().decode(P.self, from: body), !p.text.isEmpty else { return nil }
+ let action: AIAction
+ switch p.action {
+ case "rewriteClarity": action = .rewriteClarity
+ case "rewrite": action = .rewrite
+ case "ask":
+ guard let i = p.instruction, !i.trimmingCharacters(in: .whitespaces).isEmpty else { return nil }
+ action = .ask(i)
+ case "translate":
+ guard let l = p.language, !l.isEmpty else { return nil }
+ action = .translate(language: l)
+ case "tone":
+ action = .adjustTone(Tone(rawValue: p.tone ?? "") ?? .professional)
+ case "mode":
+ guard let id = p.mode, let m = ModeRegistry.mode(id: id) else { return nil }
+ action = .applyMode(m)
+ default:
+ return nil
+ }
+ return AIRequest(action: action, text: p.text)
+ }
+
+ // MARK: - Responses
+
+ private struct HealthResponse: Encodable { let ok: Bool; let app: String; let version: String }
+ private struct GrammarResponse: Encodable { let corrected: String; let suggestions: [WireSuggestion] }
+ private struct AIResponse: Encodable { let result: String }
+ private struct ErrorResponse: Encodable { let error: String }
+
+ private func sendJSON(_ conn: NWConnection, status: Int, _ value: T) {
+ let body = (try? JSONEncoder().encode(value)) ?? Data("{}".utf8)
+ send(conn, status: status, body: body)
+ }
+
+ // MARK: - Streaming responses (newline-delimited JSON, no Content-Length)
+
+ private func beginStream(_ conn: NWConnection) {
+ var head = "HTTP/1.1 200 OK\r\n"
+ head += "Content-Type: application/x-ndjson\r\n"
+ head += "Cache-Control: no-cache\r\n"
+ head += "Access-Control-Allow-Origin: *\r\n"
+ head += "Access-Control-Allow-Headers: Content-Type, X-GrammarGem-Token\r\n"
+ head += "Connection: close\r\n\r\n"
+ conn.send(content: Data(head.utf8), completion: .contentProcessed { _ in })
+ }
+
+ private func streamFrame(_ conn: NWConnection, _ object: [String: Any]) {
+ guard var data = try? JSONSerialization.data(withJSONObject: object) else { return }
+ data.append(0x0A) // newline delimiter
+ conn.send(content: data, completion: .contentProcessed { _ in })
+ }
+
+ private func endStream(_ conn: NWConnection) {
+ conn.send(content: nil, completion: .contentProcessed { _ in conn.cancel() })
+ }
+
+ private func send(_ conn: NWConnection, status: Int, body: Data) {
+ var head = "HTTP/1.1 \(status) \(Self.reason(status))\r\n"
+ head += "Content-Type: application/json\r\n"
+ head += "Content-Length: \(body.count)\r\n"
+ // The extension calls cross-origin; a secret token (not the origin) is the gate.
+ head += "Access-Control-Allow-Origin: *\r\n"
+ head += "Access-Control-Allow-Headers: Content-Type, X-GrammarGem-Token\r\n"
+ head += "Access-Control-Allow-Methods: POST, GET, OPTIONS\r\n"
+ head += "Connection: close\r\n\r\n"
+ var out = Data(head.utf8)
+ out.append(body)
+ conn.send(content: out, completion: .contentProcessed { _ in conn.cancel() })
+ }
+
+ private static func reason(_ status: Int) -> String {
+ switch status {
+ case 200: return "OK"
+ case 204: return "No Content"
+ case 400: return "Bad Request"
+ case 401: return "Unauthorized"
+ case 404: return "Not Found"
+ case 422: return "Unprocessable Entity"
+ default: return "Error"
+ }
+ }
+}
+
+/// A just-enough HTTP/1.1 request parser for small JSON POSTs. Returns nil until
+/// the headers terminator (`\r\n\r\n`) has arrived; `isComplete` is false until the
+/// full `Content-Length` body is buffered, so the receive loop knows to read more.
+private struct HTTPRequest {
+ let method: String
+ let path: String
+ let headers: [String: String]
+ let body: Data
+ let isComplete: Bool
+
+ init?(_ raw: Data) {
+ guard let headerEnd = raw.range(of: Data("\r\n\r\n".utf8)) else { return nil }
+ guard let headerString = String(data: raw[..= 2 else { return nil }
+ method = String(parts[0])
+ path = String(parts[1])
+ lines.removeFirst()
+
+ var hdrs: [String: String] = [:]
+ for line in lines {
+ guard let colon = line.firstIndex(of: ":") else { continue }
+ let key = line[..= contentLength
+ }
+}
diff --git a/Sources/GrammaGem/System/Permissions.swift b/Sources/GrammarGem/System/Permissions.swift
similarity index 96%
rename from Sources/GrammaGem/System/Permissions.swift
rename to Sources/GrammarGem/System/Permissions.swift
index 044f44d..3ac8ec2 100644
--- a/Sources/GrammaGem/System/Permissions.swift
+++ b/Sources/GrammarGem/System/Permissions.swift
@@ -2,7 +2,7 @@ import Foundation
import AppKit
import ApplicationServices
-/// Tracks and requests the macOS permissions GrammaGem needs to read & replace
+/// Tracks and requests the macOS permissions GrammarGem needs to read & replace
/// text system-wide: **Accessibility** (required) and, for the synthetic-keystroke
/// clipboard fallback, **Input Monitoring** may also be prompted by the system.
@MainActor
diff --git a/Sources/GrammaGem/System/SnippetStore.swift b/Sources/GrammarGem/System/SnippetStore.swift
similarity index 97%
rename from Sources/GrammaGem/System/SnippetStore.swift
rename to Sources/GrammarGem/System/SnippetStore.swift
index 4d17914..a959497 100644
--- a/Sources/GrammaGem/System/SnippetStore.swift
+++ b/Sources/GrammarGem/System/SnippetStore.swift
@@ -13,7 +13,7 @@ struct Snippet: Codable, Identifiable, Equatable {
final class SnippetStore: ObservableObject {
@Published private(set) var snippets: [Snippet]
- private let key = "GrammaGem.snippets"
+ private let key = "GrammarGem.snippets"
private let d = UserDefaults.standard
init() {
diff --git a/Sources/GrammaGem/System/TextCapture.swift b/Sources/GrammarGem/System/TextCapture.swift
similarity index 85%
rename from Sources/GrammaGem/System/TextCapture.swift
rename to Sources/GrammarGem/System/TextCapture.swift
index 8c1a090..1ff2241 100644
--- a/Sources/GrammaGem/System/TextCapture.swift
+++ b/Sources/GrammarGem/System/TextCapture.swift
@@ -4,7 +4,7 @@ import ApplicationServices
import Carbon.HIToolbox
/// Reads the user's selected text from the frontmost app and writes a corrected
-/// value back — the mechanism that makes GrammaGem work *everywhere*.
+/// value back — the mechanism that makes GrammarGem work *everywhere*.
///
/// Two paths, mirroring the spec (§4):
/// 1. **Accessibility API** (preferred): read/write `kAXSelectedTextAttribute`
@@ -24,10 +24,12 @@ final class TextCapture {
enum CaptureError: LocalizedError {
case noSelection
case notPermitted
+ case secureField
var errorDescription: String? {
switch self {
case .noSelection: return "Select some text first, then press the hotkey."
- case .notPermitted: return "GrammaGem needs Accessibility permission to read and replace text."
+ case .notPermitted: return "GrammarGem needs Accessibility permission to read and replace text."
+ case .secureField: return "GrammarGem never reads password fields."
}
}
}
@@ -41,8 +43,15 @@ final class TextCapture {
// MARK: - Capture
/// Try AX first; on empty/unavailable, fall back to the clipboard path.
+ /// Refuses outright if the focused element is a password field.
func capture() throws -> Capture {
- if let ax = captureViaAccessibility() {
+ let focused = AXIsProcessTrusted() ? AX.focusedElement() : nil
+ if let focused, AX.isSecureTextField(focused) {
+ // Bail before the clipboard fallback too — never synthesize ⌘C into a
+ // password field.
+ throw CaptureError.secureField
+ }
+ if let ax = captureViaAccessibility(focused) {
return ax
}
if let clip = try captureViaClipboard() {
@@ -51,11 +60,8 @@ final class TextCapture {
throw CaptureError.noSelection
}
- private func captureViaAccessibility() -> Capture? {
- guard AXIsProcessTrusted() else { return nil }
- let systemWide = AXUIElementCreateSystemWide()
-
- guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil }
+ private func captureViaAccessibility(_ focused: AXUIElement?) -> Capture? {
+ guard let focused else { return nil }
var valueRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
@@ -93,8 +99,8 @@ final class TextCapture {
/// The full text value of the focused element (not just the selection).
func focusedFieldText() -> (text: String, element: AXUIElement)? {
guard AXIsProcessTrusted() else { return nil }
- let systemWide = AXUIElementCreateSystemWide()
- guard let focused = AX.copyElement(systemWide, kAXFocusedUIElementAttribute) else { return nil }
+ guard let focused = AX.focusedElement() else { return nil }
+ guard !AX.isSecureTextField(focused) else { return nil } // never fix a password field
// Only treat genuine editable text roles as fixable, so we don't report
// "nothing selected" failures on sliders/steppers/custom controls.
diff --git a/Sources/GrammaGem/System/TextFieldScanner.swift b/Sources/GrammarGem/System/TextFieldScanner.swift
similarity index 94%
rename from Sources/GrammaGem/System/TextFieldScanner.swift
rename to Sources/GrammarGem/System/TextFieldScanner.swift
index e2c40fb..4e320ea 100644
--- a/Sources/GrammaGem/System/TextFieldScanner.swift
+++ b/Sources/GrammarGem/System/TextFieldScanner.swift
@@ -15,7 +15,7 @@ struct ScannedField {
/// Walks the Accessibility tree of the frontmost window and collects every
/// editable text element (text fields, text areas, combo/search boxes) with
-/// content — so GrammaGem can check *all* the text on screen, not just the one
+/// content — so GrammarGem can check *all* the text on screen, not just the one
/// the cursor is in. Bounded so huge web/Electron trees can't stall the app, and
/// every cross-process message has a timeout so a wedged app can't hang the scan.
///
@@ -47,7 +47,9 @@ enum TextFieldScanner {
if role == kAXTextFieldRole as String
|| role == kAXTextAreaRole as String
|| role == kAXComboBoxRole as String {
- if let value = AX.copyString(el, kAXValueAttribute),
+ // Never surface password fields in the live monitor.
+ if !AX.isSecureTextField(el),
+ let value = AX.copyString(el, kAXValueAttribute),
value.trimmingCharacters(in: .whitespacesAndNewlines).count >= 3 {
let label = AX.copyString(el, kAXTitleAttribute)
?? AX.copyString(el, kAXDescriptionAttribute)
diff --git a/Sources/GrammaGem/System/TextReplacementCoordinator.swift b/Sources/GrammarGem/System/TextReplacementCoordinator.swift
similarity index 89%
rename from Sources/GrammaGem/System/TextReplacementCoordinator.swift
rename to Sources/GrammarGem/System/TextReplacementCoordinator.swift
index 3746686..2cc0771 100644
--- a/Sources/GrammaGem/System/TextReplacementCoordinator.swift
+++ b/Sources/GrammarGem/System/TextReplacementCoordinator.swift
@@ -12,14 +12,19 @@ final class TextReplacementCoordinator {
private let ai: AIEngine
private let gate: FeatureGate
private let detector: AppDetector
+ /// Vocabulary the LLM must reproduce verbatim (personal dictionary / style
+ /// guide). Read fresh per action so dictionary edits take effect immediately.
+ private let protectedTerms: () -> [String]
init(capture: TextCapture, grammar: GrammarEngine, ai: AIEngine,
- gate: FeatureGate, detector: AppDetector) {
+ gate: FeatureGate, detector: AppDetector,
+ protectedTerms: @escaping () -> [String] = { [] }) {
self.capture = capture
self.grammar = grammar
self.ai = ai
self.gate = gate
self.detector = detector
+ self.protectedTerms = protectedTerms
}
/// The "fix" hotkey — instant grammar correction (Layer 1, always free).
@@ -52,7 +57,7 @@ final class TextReplacementCoordinator {
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
do {
- let result = try await ai.run(action, on: cap.text)
+ let result = try await ai.run(action, on: cap.text, protectedTerms: protectedTerms())
gate.recordAIActionUsed()
let ok = await replaceOffMain(result, cap)
return ok ? .replaced(result) : .failed("Couldn't write the result back.")
@@ -73,7 +78,7 @@ final class TextReplacementCoordinator {
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
do {
- let result = try await ai.run(action, on: cap.text)
+ let result = try await ai.run(action, on: cap.text, protectedTerms: protectedTerms())
gate.recordAIActionUsed()
let ok = await replaceOffMain(result, cap)
return ok ? .replaced(result) : .failed("Couldn't write the result back.")
diff --git a/Sources/GrammarGem/System/UpdaterManager.swift b/Sources/GrammarGem/System/UpdaterManager.swift
new file mode 100644
index 0000000..e4c2981
--- /dev/null
+++ b/Sources/GrammarGem/System/UpdaterManager.swift
@@ -0,0 +1,81 @@
+import Foundation
+import Sparkle
+
+/// Wraps Sparkle's updater. It background-checks the appcast on grammargem.com and
+/// publishes `updateAvailableVersion` when a newer, EdDSA-verified build exists, so
+/// the menu bar can show an "Update available" button. Triggering an update runs
+/// Sparkle's verified download + install flow.
+///
+/// The updater is only started inside the packaged `.app` (where the Info.plist
+/// carries `SUFeedURL` + `SUPublicEDKey`); under `swift run`, tests, or the
+/// self-test binaries it stays inert so nothing crashes.
+@MainActor
+final class UpdaterManager: NSObject, ObservableObject {
+ @Published private(set) var updateAvailableVersion: String?
+
+ private var controller: SPUStandardUpdaterController?
+
+ override init() {
+ super.init()
+ guard Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") != nil else {
+ return // not the packaged app — no updater
+ }
+ controller = SPUStandardUpdaterController(
+ startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil)
+ }
+
+ var canCheckForUpdates: Bool { controller?.updater.canCheckForUpdates ?? false }
+
+ /// User-initiated check — shows Sparkle's UI (used by the menu item and the
+ /// "Update" button on the availability banner).
+ func checkForUpdates() {
+ controller?.updater.checkForUpdates()
+ }
+}
+
+extension UpdaterManager: SPUUpdaterDelegate {
+ func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
+ updateAvailableVersion = item.displayVersionString
+ }
+
+ func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
+ updateAvailableVersion = nil
+ }
+}
+
+// MARK: - Headless update self-test (build pipeline / CI)
+
+/// Checks `feedURL` for a newer version with no UI and prints the result, then
+/// exits. Used to verify the appcast + signature + version-comparison path.
+enum UpdaterSelfTest {
+ static func run(feedURL: String) {
+ let delegate = UpdateProbeDelegate(feed: feedURL)
+ UpdateProbeDelegate.retained = delegate
+ UpdateProbeDelegate.controller = SPUStandardUpdaterController(
+ startingUpdater: true, updaterDelegate: delegate, userDriverDelegate: nil)
+ UpdateProbeDelegate.controller?.updater.checkForUpdateInformation()
+ RunLoop.main.run() // Sparkle's network check needs the run loop; delegate exits.
+ }
+}
+
+private final class UpdateProbeDelegate: NSObject, SPUUpdaterDelegate {
+ static var controller: SPUStandardUpdaterController?
+ static var retained: UpdateProbeDelegate?
+ let feed: String
+ init(feed: String) { self.feed = feed; super.init() }
+
+ func feedURLString(for updater: SPUUpdater) -> String? { feed.isEmpty ? nil : feed }
+
+ func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
+ print("UPDATE_FOUND: \(item.displayVersionString)")
+ exit(0)
+ }
+ func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
+ print("UPDATE_NONE")
+ exit(0)
+ }
+ func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
+ print("UPDATE_ERROR: \(error.localizedDescription)")
+ exit(1)
+ }
+}
diff --git a/Tests/GrammaGemTests/GrammaGemTests.swift b/Tests/GrammaGemTests/GrammaGemTests.swift
deleted file mode 100644
index 17f4c96..0000000
--- a/Tests/GrammaGemTests/GrammaGemTests.swift
+++ /dev/null
@@ -1,78 +0,0 @@
-import XCTest
-@testable import GrammaGem
-
-final class GrammaGemTests: XCTestCase {
-
- // MARK: - Tiers / entitlements
-
- func testDeviceCapsMatchSpec() {
- XCTAssertEqual(Tier.solo.deviceCap, 1)
- XCTAssertEqual(Tier.personal.deviceCap, 2)
- XCTAssertEqual(Tier.studio.deviceCap, 4)
- XCTAssertEqual(Tier.free.deviceCap, 1)
- }
-
- func testFreeEntitlementsAreLimited() {
- let free = Entitlements(tier: .free)
- XCTAssertFalse(free.unlimitedAIActions)
- XCTAssertEqual(free.dailyAIActionCap, AppConfig.Limits.freeDailyAIActions)
- XCTAssertEqual(free.dictionaryCap, AppConfig.Limits.freeDictionaryCap)
- XCTAssertFalse(free.appAwareSwitching)
- XCTAssertFalse(free.allModes)
- XCTAssertTrue(free.unlimitedGrammar) // grammar is always free
- }
-
- func testPaidEntitlementsUnlockEverything() {
- let paid = Entitlements(tier: .personal)
- XCTAssertTrue(paid.unlimitedAIActions)
- XCTAssertEqual(paid.dailyAIActionCap, Int.max)
- XCTAssertEqual(paid.dictionaryCap, Int.max)
- XCTAssertTrue(paid.appAwareSwitching)
- XCTAssertTrue(paid.allModes)
- }
-
- // MARK: - Grammar (Harper stub) rules
-
- func testHarperFixesRepeatedWordAndCasing() {
- let harper = HarperEngine()
- let input = "hey i wanted to checkin about the the proposal"
- let fixed = harper.correct(input)
- XCTAssertFalse(fixed.contains("the the"), "repeated word should be collapsed")
- XCTAssertTrue(fixed.contains(" I "), "lowercase pronoun i should be capitalized")
- XCTAssertTrue(fixed.contains("check in"), "checkin should split into two words")
- }
-
- func testIgnoreListSuppressesCorrection() {
- let harper = HarperEngine()
- harper.ignoreList = ["teh"] // pretend "teh" is an intentional brand term
- let suggestions = harper.check("teh launch")
- XCTAssertFalse(suggestions.contains { $0.original.lowercased() == "teh" })
- }
-
- // MARK: - Modes
-
- func testFreeTierGetsOnlyPolishMode() {
- let free = ModeRegistry.available(for: .free)
- XCTAssertEqual(free.map(\.id), ["polish"])
- let paid = ModeRegistry.available(for: .studio)
- XCTAssertGreaterThan(paid.count, 1)
- XCTAssertTrue(paid.contains { $0.id == "email" })
- }
-
- func testAppAwareModeMapping() {
- XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.apple.mail")?.id, "email")
- XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.microsoft.VSCode")?.id, "code")
- XCTAssertNil(ModeRegistry.mode(forBundleID: "com.unknown.app"))
- }
-
- // MARK: - Device fingerprint
-
- func testDeviceFingerprintIsStableAndHashed() {
- let a = DeviceFingerprint.stableID()
- let b = DeviceFingerprint.stableID()
- XCTAssertEqual(a, b, "fingerprint must be stable across calls")
- XCTAssertFalse(a.isEmpty)
- // SHA-256 hex is 64 chars (unless the random fallback path is hit).
- XCTAssertTrue(a.count == 64 || a.count == UUID().uuidString.count)
- }
-}
diff --git a/Tests/GrammarGemTests/GrammarGemTests.swift b/Tests/GrammarGemTests/GrammarGemTests.swift
new file mode 100644
index 0000000..0341edd
--- /dev/null
+++ b/Tests/GrammarGemTests/GrammarGemTests.swift
@@ -0,0 +1,222 @@
+import XCTest
+@testable import GrammarGem
+
+final class GrammarGemTests: XCTestCase {
+
+ // MARK: - Tiers / entitlements
+
+ func testDeviceCapsMatchSpec() {
+ XCTAssertEqual(Tier.solo.deviceCap, 1)
+ XCTAssertEqual(Tier.personal.deviceCap, 2)
+ XCTAssertEqual(Tier.studio.deviceCap, 4)
+ XCTAssertEqual(Tier.free.deviceCap, 1)
+ }
+
+ func testFreeEntitlementsAreLimited() {
+ let free = Entitlements(tier: .free)
+ XCTAssertFalse(free.unlimitedAIActions)
+ XCTAssertEqual(free.dailyAIActionCap, AppConfig.Limits.freeDailyAIActions)
+ XCTAssertEqual(free.dictionaryCap, AppConfig.Limits.freeDictionaryCap)
+ XCTAssertFalse(free.appAwareSwitching)
+ XCTAssertFalse(free.allModes)
+ XCTAssertTrue(free.unlimitedGrammar) // grammar is always free
+ }
+
+ func testPaidEntitlementsUnlockEverything() {
+ let paid = Entitlements(tier: .personal)
+ XCTAssertTrue(paid.unlimitedAIActions)
+ XCTAssertEqual(paid.dailyAIActionCap, Int.max)
+ XCTAssertEqual(paid.dictionaryCap, Int.max)
+ XCTAssertTrue(paid.appAwareSwitching)
+ XCTAssertTrue(paid.allModes)
+ }
+
+ // MARK: - Grammar (Harper stub) rules
+
+ func testHarperFixesRepeatedWordAndCasing() {
+ let harper = HarperEngine()
+ let input = "hey i wanted to checkin about the the proposal"
+ let fixed = harper.correct(input)
+ XCTAssertFalse(fixed.contains("the the"), "repeated word should be collapsed")
+ XCTAssertTrue(fixed.contains(" I "), "lowercase pronoun i should be capitalized")
+ XCTAssertTrue(fixed.contains("check in"), "checkin should split into two words")
+ }
+
+ func testIgnoreListSuppressesCorrection() {
+ let harper = HarperEngine()
+ harper.ignoreList = ["teh"] // pretend "teh" is an intentional brand term
+ let suggestions = harper.check("teh launch")
+ XCTAssertFalse(suggestions.contains { $0.original.lowercased() == "teh" })
+ }
+
+ /// Real Harper does true grammar NSSpellChecker can't: subject-verb
+ /// agreement and verb-form correction, applied cleanly in place.
+ func testHarperRealGrammarCorrections() {
+ let harper = HarperEngine()
+ let fixed = harper.correct("She dont like it and she have went home.")
+ XCTAssertTrue(fixed.contains("don't"), "dont -> don't")
+ XCTAssertTrue(fixed.contains("has"), "have -> has (subject-verb agreement)")
+ XCTAssertTrue(fixed.contains("gone"), "went -> gone (past participle)")
+ XCTAssertFalse(fixed.contains("went"), "the wrong verb form should be gone")
+ }
+
+ /// The trust bug: a mid-sentence spelling fix must keep the original casing.
+ func testSpellingReplacementPreservesCase() {
+ XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "problemm", in: "Problem"), "problem")
+ XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "Teh", in: "the"), "The")
+ XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "teh", in: "the"), "the")
+ XCTAssertEqual(SystemGrammarEngine.matchingCase(of: "HELLOO", in: "hello"), "HELLO")
+ }
+
+ /// Harper returns char (Unicode scalar) offsets; they must map exactly onto
+ /// UTF-16 NSRange offsets even past multi-UTF-16-unit scalars (emoji).
+ func testHarperCharOffsetsMapToUTF16() {
+ let text = "😀 teh" // 😀 = 1 scalar / 2 UTF-16 units
+ let range = HarperEngine.utf16Range(scalarStart: 2, scalarLen: 3, in: text)
+ XCTAssertEqual(range, NSRange(location: 3, length: 3))
+ XCTAssertEqual((text as NSString).substring(with: range!), "teh")
+ }
+
+ // MARK: - Value loop (streak / subscription-avoided / milestones)
+
+ @MainActor
+ func testValueLoopMetrics() {
+ let fmt = DateFormatter()
+ fmt.dateFormat = "yyyy-MM-dd"
+ fmt.locale = Locale(identifier: "en_US_POSIX")
+ fmt.timeZone = .current
+ let cal = Calendar.current
+ let now = Date()
+ func key(_ daysAgo: Int) -> String {
+ fmt.string(from: cal.date(byAdding: .day, value: -daysAgo, to: now)!)
+ }
+
+ // today + yesterday + 2-days-ago = streak of 3 (the day-4 entry is broken off).
+ let perDay = [key(0): 2, key(1): 1, key(2): 5, key(4): 1]
+ XCTAssertEqual(UsageStats.streak(perDay: perDay, asOf: now), 3)
+ XCTAssertEqual(UsageStats.streak(perDay: [:], asOf: now), 0)
+ // Used yesterday but not yet today — streak is still alive.
+ XCTAssertEqual(UsageStats.streak(perDay: [key(1): 1], asOf: now), 1)
+
+ // ~1 year of ownership ≈ one year of the subscription you didn't pay.
+ XCTAssertEqual(
+ UsageStats.subscriptionAvoidedUSD(firstUseDay: key(365), asOf: now, annualPrice: 144), 144)
+ XCTAssertEqual(
+ UsageStats.subscriptionAvoidedUSD(firstUseDay: nil, asOf: now, annualPrice: 144), 0)
+
+ let m = UsageStats.nextMilestone(corrections: 50)
+ XCTAssertEqual(m.target, 100)
+ XCTAssertEqual(m.progress, 0.5, accuracy: 0.001)
+ 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 GRAMMARGEM_MLX_TEST=1
+ // with the metallib colocated. See scripts/build-metallib.sh.
+ guard ProcessInfo.processInfo.environment["GRAMMARGEM_MLX_TEST"] == "1" else {
+ throw XCTSkip("set GRAMMARGEM_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() {
+ let free = ModeRegistry.available(for: .free)
+ XCTAssertEqual(free.map(\.id), ["polish"])
+ let paid = ModeRegistry.available(for: .studio)
+ XCTAssertGreaterThan(paid.count, 1)
+ XCTAssertTrue(paid.contains { $0.id == "email" })
+ }
+
+ func testAppAwareModeMapping() {
+ XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.apple.mail")?.id, "email")
+ XCTAssertEqual(ModeRegistry.mode(forBundleID: "com.microsoft.VSCode")?.id, "code")
+ XCTAssertNil(ModeRegistry.mode(forBundleID: "com.unknown.app"))
+ }
+
+ // MARK: - AIPrompts (backend-neutral seam)
+
+ func testProtectedTermsInjectedIntoSystemPrompt() {
+ let base = AIPrompts.systemPrompt(for: .rewriteClarity)
+ let withTerms = AIPrompts.systemPrompt(for: .rewriteClarity, protectedTerms: ["GrammarGem", "Harper"])
+ XCTAssertFalse(base.contains("GrammarGem"))
+ XCTAssertTrue(withTerms.contains("\"GrammarGem\""))
+ XCTAssertTrue(withTerms.contains("\"Harper\""))
+ XCTAssertTrue(withTerms.contains("verbatim"))
+ }
+
+ func testProtectedTermsClauseSkipsWhenEmpty() {
+ let blank = AIPrompts.systemPrompt(for: .rewrite, protectedTerms: ["", " "])
+ XCTAssertEqual(blank, AIPrompts.systemPrompt(for: .rewrite))
+ }
+
+ func testMaxTokensHonorsModeLengthCap() {
+ // Default actions get the full budget…
+ XCTAssertEqual(AIPrompts.maxTokens(for: .rewriteClarity), 512)
+ // …but a short-form Mode (Team Chat, lengthCap 60) gets far fewer.
+ let slack = ModeRegistry.slack
+ XCTAssertNotNil(slack.lengthCap)
+ let capped = AIPrompts.maxTokens(for: .applyMode(slack))
+ XCTAssertLessThan(capped, 512)
+ XCTAssertGreaterThanOrEqual(capped, 64)
+ // A mode with no cap (Polish) falls back to the default budget.
+ XCTAssertEqual(AIPrompts.maxTokens(for: .applyMode(ModeRegistry.polish)), 512)
+ }
+
+ func testCleanStripsPreambleAndQuotes() {
+ XCTAssertEqual(AIPrompts.clean("Sure, here's the rewrite: Hello world"), "Hello world")
+ XCTAssertEqual(AIPrompts.clean("\"Quoted result\""), "Quoted result")
+ XCTAssertEqual(AIPrompts.clean(" already clean "), "already clean")
+ }
+
+ func testParseToneIsRobust() {
+ XCTAssertEqual(AIPrompts.parseTone("professional"), .professional)
+ XCTAssertEqual(AIPrompts.parseTone(" Friendly.\n"), .friendly)
+ XCTAssertEqual(AIPrompts.parseTone("the tone is academic here"), .academic)
+ XCTAssertEqual(AIPrompts.parseTone("???"), .professional) // safe fallback
+ }
+
+ // MARK: - Custom modes
+
+ @MainActor
+ func testCustomModeRejectsEmptyPromptAndDuplicates() {
+ // Deterministic regardless of prior runs (the store persists to UserDefaults).
+ UserDefaults.standard.removeObject(forKey: "GrammarGem.customModes")
+ defer { UserDefaults.standard.removeObject(forKey: "GrammarGem.customModes") }
+
+ let store = CustomModesStore()
+ XCTAssertFalse(store.addCustom(name: "Brand Voice", prompt: " "),
+ "empty prompt must be rejected")
+ XCTAssertTrue(store.addCustom(name: "Brand Voice", prompt: "Write in Acme's warm voice."))
+ XCTAssertFalse(store.addCustom(name: "brand voice", prompt: "different prompt"),
+ "duplicate name (case-insensitive) must be rejected")
+ XCTAssertFalse(store.addCustom(name: "Polish", prompt: "collides with a built-in"),
+ "name colliding with a built-in mode must be rejected")
+ }
+
+ // MARK: - Device fingerprint
+
+ func testDeviceFingerprintIsStableAndHashed() {
+ let a = DeviceFingerprint.stableID()
+ let b = DeviceFingerprint.stableID()
+ XCTAssertEqual(a, b, "fingerprint must be stable across calls")
+ XCTAssertFalse(a.isEmpty)
+ // SHA-256 hex is 64 chars (unless the random fallback path is hit).
+ XCTAssertTrue(a.count == 64 || a.count == UUID().uuidString.count)
+ }
+}
diff --git a/docs/grammarly-value-plan.md b/docs/grammarly-value-plan.md
new file mode 100644
index 0000000..11bf563
--- /dev/null
+++ b/docs/grammarly-value-plan.md
@@ -0,0 +1,204 @@
+# Grammarly Value Teardown -> GrammarGem Plan
+
+*Senior product + Swift engineering review. Every claim below is grounded in the current GrammarGem source. File references use absolute paths; the load-bearing bug is the mis-casing in `SystemGrammarEngine.swift:42-45`.*
+
+---
+
+## 1. Executive summary — what makes Grammarly FEEL valuable (priority order)
+
+Grammarly's perceived value is NOT its grammar engine. It is a stack of interaction and psychology layers on top of a competent engine. Ranked by leverage for a one-time-purchase, on-device Mac utility:
+
+1. **Trustworthy correction quality (the foundation).** Every felt-value mechanic above it collapses if a fix is wrong. Grammarly's "accept-accept-accept" rhythm only works because in-place application is reliable and casing/context is preserved. GrammarGem's engine currently *demonstrably violates this* (`problemm` -> `Problem` mid-sentence). Fix this first; nothing else matters until it's solid.
+
+2. **The fix-as-button atomic loop: read the fix and apply it in one motion.** Grammarly's clickable green replacement chip collapses diagnosis + remedy + action into a single low-friction act, with a cheap, always-present dismiss so saying no is as easy as saying yes. GrammarGem has an `Apply` button but no dismiss/ignore/add-to-dictionary, no keyboard accept, and the apply path is brittle (whole-field AX overwrite).
+
+3. **Categorization + counts + a score: turning a wall of red into a finite, beatable list.** Four named, color-coded buckets, per-category count chips, one running issue badge, and a 0-100 score convert dread into a checklist with a meter that only goes up. GrammarGem has a raw issue count (3 colors) and *zero* score concept.
+
+4. **The rigged first-run "wow": guaranteed visible win in under 60 seconds.** A pre-seeded, error-salted demo doc means the product proves itself before asking for anything. GrammarGem's onboarding only handles Accessibility + model download — it never demonstrates the correction loop at all.
+
+5. **Ongoing perceived value without surveillance: progress you can see.** Grammarly uses cloud percentiles + weekly behavioral emails. GrammarGem can recreate the *feeling* — self-vs-self, fully on-device — with a local score trend, streaks, milestones, and a "subscription you didn't pay" money figure. The plumbing (`UsageStats.perDay`) already exists.
+
+6. **The generative "writing partner" layer: hand me the better sentence, not just the flag.** Sentence rewrites, tone detection/adjustment, conciseness. This is real value but is currently 100% simulated (`MLXEngine.swift` is a string-hack stub) and gated by a daily counter. It is the right *paid* tier, but it must produce real output before it can be sold.
+
+The strategic asymmetry to exploit throughout: every Grammarly value moment carries an implicit "and you'll pay again next year." GrammarGem's identical mechanics should be framed "yours once, forever, on this Mac."
+
+---
+
+## 2. Mechanic-by-mechanic: how Grammarly does it -> how GrammarGem should do it
+
+### Mechanic 1 — Trustworthy correction quality (see §3 for the full engineering plan)
+- **Grammarly:** ranks by confidence, suppresses low-confidence edits, never garbles surrounding text, preserves casing, and limits bulk-apply to high-confidence objective mechanics only.
+- **GrammarGem today:** `SystemGrammarEngine` blindly trusts `NSSpellChecker.correction(forWordRange:)`, which is sentence-position-aware and capitalizes a word it thinks starts a sentence — producing `problemm` -> `Problem` mid-sentence (`SystemGrammarEngine.swift:42-45`). `Suggestion` has no confidence/severity/alternatives (`Models.swift:48-59`). Default `correct()` applies *every* suggestion including no-op grammar flags where `replacement == original` (`GrammarEngine.swift:22-29`).
+- **Do:** real Harper FFI; case/whitespace post-processing; add `confidence`/`severity`/`alternatives` to `Suggestion`; never auto-apply no-ops or low-confidence flags.
+- **Files:** `SystemGrammarEngine.swift`, `HarperEngine.swift`, `GrammarEngine.swift`, `Models.swift`, `harper-ffi/`.
+- **Impact: high · Effort: L**
+
+### Mechanic 2 — The fix-as-button atomic loop (+ dismiss / ignore / add-to-dictionary)
+- **Grammarly:** the suggested word *is* the button (green = go/safe/done); an always-present cheap dismiss (trash/Esc) and add-to-dictionary make accepting feel safe; Tab-to-navigate / Enter-to-accept / Esc-to-dismiss removes the mouse round-trip.
+- **GrammarGem today:** `FieldCard` shows a message + one `original -> replacement` + a small `Apply` button (`LiveCheckView.swift:104-119`). There is **no dismiss, no ignore, no add-to-dictionary, no keyboard accept.** Per-issue `apply()` bails silently if the field changed at all since the snapshot (`LiveMonitor.swift:82-89`), and even single-issue apply rewrites the *whole* AX value (`LiveMonitor.swift:101-103`), risking caret/undo/formatting loss.
+- **Do (realistic for a cross-app AX tool — we cannot draw a green chip inside arbitrary apps):**
+ - Make the replacement text itself the primary tappable control in the card (the chip *is* the fix), styled by category color.
+ - Add a per-suggestion overflow: **Dismiss** (model a `dismissedIDs: Set` on the monitor so it survives re-scans), **Ignore word** / **Add to dictionary** (route into `PersonalDictionary` -> `ignoreList`), gated by `freeDictionaryCap`.
+ - Add **keyboard flow inside the Live Check panel**: ↑/↓ or Tab to move selection, Return to apply, Delete/Esc to dismiss. This is fully feasible because the panel is our own SwiftUI window.
+ - Replace whole-field AX write with **targeted span insertion** where the element supports `kAXSelectedTextRangeAttribute` + `kAXSelectedTextAttribute` (set selection to the issue range, then set selected text to the replacement) — preserves caret and undo. Fall back to whole-value only when selection-set is unsupported.
+ - Make per-issue apply **rebase** instead of bail: if the live value changed, re-locate the span by matching `original` near the old offset rather than refusing to act.
+- **Files:** `LiveCheckView.swift`, `LiveMonitor.swift`, `Models.swift`, `PersonalDictionary.swift`, `AppState.swift`, `TextCapture`.
+- **Impact: high · Effort: M**
+
+### Mechanic 3 — Categorization, counts, and an on-device document score
+- **Grammarly:** four named buckets (Correctness/Clarity/Engagement/Delivery) each with a color and a count chip that doubles as a filter; one master running badge; a 0-100 percentile score that ticks up on every accept; cards ordered by severity (objective errors first) so the early wins build trust.
+- **GrammarGem today:** `GrammarKind` has 6 cases but `.punctuation` is dead; the panel reduces everything to 3 dot colors (`LiveCheckView.swift:136-142`); `issueCount` is a raw sum (`LiveMonitor.swift:32`); there is **no score, no per-category chips, no severity ordering, no filter**.
+- **Do (on-device, no cloud, no percentile-vs-others):**
+ - Introduce GrammarGem's own named buckets that fit the engine: e.g. **Correctness** (spelling/grammar/punctuation), **Clarity** (phrasing/conciseness — from the real LLM), **Polish** (repetition/capitalization/style). Use original color semantics (do NOT clone Grammarly's exact red/blue/green/purple mapping or wording).
+ - Add **count chips per bucket** in the panel header that filter the card list; keep the menu-bar badge as the master tally (`MenuBarLabel`).
+ - Compute a **local cleanliness score** at check-time: a deterministic function of issues-per-100-words by severity (e.g. `100 - weighted_issues`). It is honest because it's defined relative to *this document*, not a hidden user corpus. Show it on the card header and trend it on the dashboard.
+ - **Order cards by severity then document position** (objective spelling/grammar first) so the first accept is a high-confidence win.
+ - Critical: the score must reflect the *honest* free promise — grammar/spelling is unlimited and fully resolvable, so a free user CAN reach a clean score. Do **not** manufacture a permanently-nonzero count to force upgrades (that's Grammarly's locked-bucket trick and it contradicts our positioning — see §5).
+- **Files:** `Models.swift` (`GrammarKind` cleanup, add `severity`), `LiveMonitor.swift`, `LiveCheckView.swift`, `MenuBarContent.swift`, `DashboardView.swift`, a new `WritingScore` helper.
+- **Impact: high · Effort: M**
+
+### Mechanic 4 — The first-run "wow" (see §4 for the full sequence)
+- **Grammarly:** land in an error-salted demo doc -> "we found N issues" -> one-click fixes vanish underlines + decrement counter -> goal setup -> install-everywhere prompt at the dopamine peak.
+- **GrammarGem today:** `OnboardingView` only covers Accessibility + model download (`OnboardingView.swift`); it never shows the correction loop.
+- **Do:** add an in-app demo step with a pre-seeded sample, a "we found N things to fix" reveal, and a satisfying one-click-to-clean animation — *before* asking for nothing-but-praise (no install-everywhere pressure; we're already system-wide). Detail in §4.
+- **Files:** `OnboardingView.swift`, plus a small `DemoDocument` view reusing the highlight + apply UI.
+- **Impact: high · Effort: M**
+
+### Mechanic 5 — Ongoing perceived value (privacy-native, self-vs-self)
+- **Grammarly:** 0-100 score, weekly Insights email ("more productive than X% of users"), lifetime words-checked counter, streaks — all cloud + behavioral-email, the exact surveillance we market against.
+- **GrammarGem today:** `UsageStats` tracks `totalCorrections`, `totalAIActions`, `totalWords`, and `perDay` (`UsageStats.swift`); the dashboard shows four tiles + a 7-bar chart, but **no score, no streak, no week-over-week, no milestone, no money-saved figure**. `estimatedMinutesSaved` is a crude untuned constant (`8s/correction + 45s/AI-action`). Stats are invisible outside the main window.
+- **Do (all local, no telemetry, stops nagging the moment they own it):**
+ - **Local writing-score trend** on the dashboard (from Mechanic 3), compared only to the user's own past.
+ - **Streak + milestones** from existing `perDay` data ("7-day streak", "1,000th correction", "100k words polished") — cheap, high word-of-mouth, and the right re-engagement hook for a no-renewal product.
+ - **"Subscription you didn't pay" figure** — translate usage into money: the marketing site already hammers "~\$144/yr vs pay once," but the *app* never does. Show "You've polished N times — that's the price of GrammarGem recovered M times over," computed locally. This is the strongest one-time-purchase value teaser and it lives where the user actually is.
+ - **Week-over-week delta** on the bar chart and a lightweight in-app weekly recap card (NOT an email; no pipeline, no surveillance).
+ - **Surface one stat in the menu bar dropdown** (the most-seen surface) so value reminders don't require opening the dashboard.
+ - Tune `estimatedMinutesSaved` to scale by words changed / action type so the headline "was it worth it?" number is credible.
+- **Files:** `UsageStats.swift`, `DashboardView.swift`, `MenuBarContent.swift`, `web/src/lib/content.ts` (replace PLACEHOLDER stats/testimonials before launch).
+- **Impact: high · Effort: M**
+
+### Mechanic 6 — The generative "writing partner" layer (real LLM, honest gating)
+- **Grammarly:** sentence rewrites ("Replace with This Version" + Highlight-Changes diff), tone detection (emoji + label), tone adjustment, conciseness, GrammarlyGO — all one-click, reversible, before/after.
+- **GrammarGem today:** `MLXEngine` is a stub doing `replacingOccurrences` string hacks (`MLXEngine.swift`); `tidy()` even re-introduces casing risk; `detectTone` keys off substrings. `ModelManager` downloads real weights but nothing loads them. Gated by `freeDailyAIActions = 10` shared across rewrite/tone/Ask/translate.
+- **Do:**
+ - Wire the **real MLX model** from `ModelManager.modelsDirectory` (the download is currently a dead-end); uncomment `mlx-swift` in `Package.swift`.
+ - Adopt the **one-click + reversible + before/after diff** delivery contract for every rewrite — this is what neutralizes "the AI is taking over my voice."
+ - Keep the **honest quantity gate**: free users get *real, full-quality* output, just rationed (the daily counter), plus Polish mode only. This is the parity-respecting tease — satisfaction-driven ("I keep hitting the limit / I want my own modes / I want it on my other Mac"), not anxiety-driven.
+ - Real **tone detection** (label + emoji) is the highest-value non-grammar feature; it delivers value on grammatically perfect text. Build it once the model is live.
+- **Files:** `MLXEngine.swift`, `AIEngine.swift`, `ModelManager.swift`, `Package.swift`, `FeatureGate.swift`, `TextReplacementCoordinator.swift`, `WritingModesView.swift`.
+- **Impact: high · Effort: L** (model integration is the long pole)
+
+---
+
+## 3. Correction-QUALITY: the path to trustworthy fixes (this underpins ALL perceived value)
+
+The engine is the load-bearing wall. Today it is `NSSpellChecker` + a ~6-rule regex stub, and it ships a trust-destroying casing bug. Here is the ordered path.
+
+### 3.1 Fix the casing/whitespace bug NOW (cheap, immediate)
+Root cause: `SystemGrammarEngine.swift:42-45` blindly uses `checker.correction(forWordRange:)`, which is sentence/position-aware and returns a capitalized guess for a token it believes starts a sentence — so a mid-sentence `problemm` comes back `Problem`.
+
+**Post-process every spelling replacement to preserve the original token's casing** unless the token is a true sentence start or proper noun:
+- If `original` is all-lowercase, lowercase the replacement's first letter.
+- If `original` is Capitalized (single leading cap), match that.
+- If `original` is ALL-CAPS, upper-case the replacement.
+- Only adopt the corrector's case when the token is at a genuine sentence boundary (preceded by `.?!` + space, or string start). Also trim/normalize whitespace so a replacement never injects a stray leading/trailing space.
+
+This is a ~20-line transcase helper applied at suggestion-build time. It removes the single most visible trust failure before any deeper work.
+
+### 3.2 Stop auto-applying garbage
+- `GrammarEngine.correct()` (`GrammarEngine.swift:22-29`) applies **every** suggestion including grammar flags where `replacement == original` (the no-op flags emitted at `SystemGrammarEngine.swift:51-54` and `:67-70`). Filter these out of the bulk path entirely — a no-op flag must never be part of "Fix all," and a low-confidence guess must never auto-apply.
+- "Fix all" should be **trust-scoped like Grammarly's bulk accept**: only high-confidence objective mechanics (spelling/grammar/punctuation), never subjective rewrites.
+
+### 3.3 Enrich the data model
+`Suggestion` (`Models.swift:48-59`) needs:
+- `confidence: Double` (gate auto-apply below a threshold; show low-confidence ones as optional, never bulk-applied).
+- `severity` (drives ordering and the score weighting).
+- `alternatives: [String]` — `NSSpellChecker.guesses()` already returns multiple candidates but only `.first` is kept (`SystemGrammarEngine.swift:41`); surface the rest so the card can offer "other suggestions."
+- `ruleID`/`source` — so feedback (accept/dismiss) can later tune ranking and so we can attribute a fix to Harper vs NSSpellChecker.
+
+### 3.4 Integrate the real Harper core (the big precision unlock)
+`harper-ffi/` is an empty README; `Package.swift` comments out all real deps. Stand up the Rust crate behind a C-FFI static lib (`harper_check(text) -> spans`), map spans into `Suggestion`, and keep the protocol boundary so the rest of the app is unchanged. This brings real subject-verb agreement, article/preposition usage, comma/apostrophe rules, commonly-confused pairs, etc. — the category coverage NSSpellChecker structurally cannot provide. De-dup against NSSpellChecker as today (`overlaps`), but prefer Harper where both fire.
+
+### 3.5 Populate the dead `.punctuation` category
+`.punctuation` is declared but never emitted, yet punctuation is one of the highest-volume real-world error classes. Harper covers most of it; this fills the gap and makes the new category chips (Mechanic 3) meaningful.
+
+### 3.6 Incremental, async, grapheme-safe checking
+- Today: whole-document, synchronous, `DispatchQueue.main.sync` on a 1.6s poll, truncated at 4000 chars (`LiveMonitor.swift:46,174`), frontmost window only. Move to **debounced incremental re-check of changed spans** with caching of unchanged ranges; keep the heavy AX traversal off-main (already done) but get the spell/grammar pass off the main thread too once Harper is FFI-based (no `NSSpellChecker` main-thread requirement).
+- Validate that replacement sub-ranges fall on **grapheme boundaries** (emoji/combining marks) before applying — only a coarse bounds check exists today (`SystemGrammarEngine.swift:63`).
+
+### 3.7 Feedback loop
+Wire dismiss / add-to-dictionary (Mechanic 2) back into ranking so repeated false positives stop nagging — this is the "it learns" trust signal. Today `ignoreList` only suppresses; long-term, dismiss counts per `ruleID` can down-rank a noisy rule.
+
+**Sequencing:** §3.1 + §3.2 this week (trust-critical, tiny). §3.3 alongside Mechanic 2. §3.4–§3.6 are the real Harper milestone. §3.7 last.
+
+---
+
+## 4. The first-run "wow" sequence for GrammarGem (original copy)
+
+Goal: a guaranteed visible win in under 60 seconds, *inside our own window* (we don't need Grammarly's blank-canvas demo trick or its install-everywhere upsell — we're already system-wide). Onboarding today (`OnboardingView.swift`) skips this entirely.
+
+Proposed flow:
+
+1. **Welcome (1 line):** "A private writing assistant that lives in your menu bar and fixes text in every app — all on this Mac." (existing copy is close; keep it.)
+
+2. **The rigged win — a live mini-demo BEFORE any permission ask.** Show a short pre-written paragraph salted with obvious, safe errors (a misspelling, a repeated word, a lowercase "i", a missing period). Render it with the highlight UI already built in `LiveCheckView.highlighted(...)`. Above it: **"We spotted N things to tidy up."** (count = guaranteed, because the sample is fixed.)
+
+3. **One satisfying click.** A single "Clean it up" button animates the highlights resolving one by one; the count ticks to 0; the paragraph visibly improves. This is the operant loop — trivial action, immediate visible reward — experienced once, on rails, with zero risk. (Reuse the apply path; it's a local string, not a live AX element, so it's completely safe.)
+
+4. **The reveal:** "That's GrammarGem. It does this in Mail, Slack, Notes, your browser — everywhere you type." Then the *first* ask: **Accessibility** (the existing step 2 / `stepCard`), now framed as "Turn it on everywhere."
+
+5. **Optional model step:** keep the existing "Download the on-device model (optional) — grammar works instantly with no download" card. Frame the AI features as a bonus, not a blocker.
+
+6. **No install-everywhere nag, no timed discount, no streak guilt.** End on a calm "You're all set" with one honest line about ownership: "Yours once. No subscription, nothing leaves your Mac."
+
+Guardrails: the demo must use *original* sample text (not Grammarly's), and the count/score must be real (computed by our engine on the sample), never theatrical.
+
+---
+
+## 5. Do NOT copy (dark patterns + claims we can't truthfully make)
+
+These are Grammarly mechanics that either depend on infrastructure we reject or that would corrode the trust/privacy moat that *is* the product.
+
+1. **The premium-issue anxiety counter ("14 advanced issues you can't fix").** It works by detecting problems the engine deliberately won't fix. It clashes head-on with our promise that grammar/spelling/punctuation is unlimited and fully resolvable for free. Do not manufacture a permanently-nonzero count or a "score capped until you upgrade" gate.
+
+2. **Two-tier gold-vs-red underlines (a second class of locked grammar issues).** Our underlines are all free and all resolvable; there is no second class to color differently. Inventing one betrays the core differentiator.
+
+3. **Blur/lock a result we already computed.** On-device + open-source, hiding output we generated is both trivially bypassable and brand-corrosive. Gate by **quantity/frequency** (the daily counter) and **breadth** (modes, multi-device, snippets, larger model) — never by hiding the quality of a single result.
+
+4. **Cloud percentile comparisons ("more productive than X% of users").** Requires an account + aggregate corpus + telemetry — the exact surveillance we market against. Replace with **self-vs-self** local trends.
+
+5. **Behavioral weekly emails / re-nag pipeline.** For a one-time purchase there is no subscription to keep warm; post-purchase nagging is pure annoyance. Any weekly recap must be **in-app, local, opt-in, and stop entirely once they own it.**
+
+6. **Countdown/limited-time discount urgency** (Grammarly's Day-7 40% timer). Manufactured scarcity undercuts the honest "buy once, fair price" positioning.
+
+7. **Lifetime "total words checked" framed as cloud sunk-cost-to-lose.** A local milestone counter is fine and good; framing it as "you'll lose your history if you leave" is a retention dark pattern with no place in a no-renewal product.
+
+8. **Unsubstantiated social proof.** `web/src/lib/content.ts` STATS (200k+ downloads, 4.9 rating) and all TESTIMONIALS are explicitly flagged PLACEHOLDER. Do not ship these as fact — replace with real numbers or remove before launch.
+
+Positive reframe to keep: surface locked paid features (modes, app-aware, snippets, larger model) honestly — "Unlock all of this once. Yours forever, no subscription." Same mechanism (visible locked features), stronger close (no recurring cost objection).
+
+---
+
+## 6. Prioritized roadmap
+
+### NOW (trust + the atomic loop — ship-blockers for credibility)
+1. **Fix the casing/whitespace post-processing bug** (§3.1). `SystemGrammarEngine.swift`. *high / S.*
+2. **Stop bulk-applying no-ops and low-confidence guesses** (§3.2). `GrammarEngine.swift`, `SystemGrammarEngine.swift`. *high / S.*
+3. **Dismiss / Ignore / Add-to-dictionary + keyboard accept in the Live Check panel** (Mechanic 2). `LiveCheckView.swift`, `LiveMonitor.swift`, `Models.swift`, `PersonalDictionary.swift`. *high / M.*
+4. **Targeted span apply via AX selection instead of whole-field overwrite; rebase instead of bail** (Mechanic 2). `LiveMonitor.swift`, `TextCapture`. *high / M.*
+5. **First-run rigged-win demo** (§4). `OnboardingView.swift`. *high / M.*
+
+### NEXT (organize + the value loop)
+6. **Named buckets + per-category count chips + severity ordering + filter** (Mechanic 3). `Models.swift`, `LiveMonitor.swift`, `LiveCheckView.swift`. *high / M.*
+7. **On-device writing score (per-doc) + dashboard trend** (Mechanics 3 & 5). `WritingScore` helper, `LiveCheckView.swift`, `DashboardView.swift`. *high / M.*
+8. **Streaks + milestones + "subscription you didn't pay" money figure + menu-bar stat** (Mechanic 5). `UsageStats.swift`, `DashboardView.swift`, `MenuBarContent.swift`. *high / M.*
+9. **Enrich `Suggestion` with confidence/severity/alternatives + "other suggestions" in card** (§3.3). `Models.swift`, `SystemGrammarEngine.swift`, `LiveCheckView.swift`. *medium / M.*
+
+### LATER (real engine + real partner layer)
+10. **Real Harper FFI integration + populate `.punctuation` + grapheme safety + incremental async re-check** (§3.4–§3.6). `harper-ffi/`, `HarperEngine.swift`, `SystemGrammarEngine.swift`, `LiveMonitor.swift`, `Package.swift`. *high / L.*
+11. **Wire the real MLX model (load downloaded weights) + one-click/reversible/before-after diff for rewrites + real tone detection** (Mechanic 6). `MLXEngine.swift`, `ModelManager.swift`, `Package.swift`, `TextReplacementCoordinator.swift`. *high / L.*
+12. **Replace PLACEHOLDER web social proof/testimonials before launch** (§5). `web/src/lib/content.ts`. *medium / S.*
+
+---
+
+*Throughline: ship trust first (correction quality + a safe, reversible accept loop), then make the work feel finite and rewarding (buckets, counts, a local score), then sell ongoing value honestly (self-vs-self progress, money saved, lifetime ownership) — and build the generative partner tier on a real model with a reversible, before/after contract. Every Grammarly mechanic translates if you swap cloud-surveillance for on-device self-comparison and recurring-nag for one-time honesty.*
diff --git a/harper-ffi/Cargo.lock b/harper-ffi/Cargo.lock
new file mode 100644
index 0000000..0d8ebd8
--- /dev/null
+++ b/harper-ffi/Cargo.lock
@@ -0,0 +1,5617 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "ammonia"
+version = "4.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "maplit",
+ "tendril",
+ "url",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "atomic_float"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bincode"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
+dependencies = [
+ "serde",
+ "unty",
+]
+
+[[package]]
+name = "bindgen"
+version = "0.71.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
+dependencies = [
+ "bitflags",
+ "cexpr",
+ "clang-sys",
+ "itertools 0.13.0",
+ "log",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash 2.1.2",
+ "shlex 1.3.0",
+ "syn",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "blanket"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56791e4bd64c99fc361e01008f45c984baa93f12a0957d1b3c51dd2c6baab453"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "block"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "burn"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0291ea5c68786545e239a02f63331cfe39da7485164ae05197d5be6f148d0557"
+dependencies = [
+ "burn-autodiff",
+ "burn-candle",
+ "burn-core",
+ "burn-cuda",
+ "burn-ndarray",
+ "burn-nn",
+ "burn-optim",
+ "burn-rocm",
+ "burn-router",
+ "burn-store",
+ "burn-wgpu",
+]
+
+[[package]]
+name = "burn-autodiff"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "917423a74bf4d39f17a6799089869648e3d2b6ac89d93901aab4aeb9a7f82138"
+dependencies = [
+ "burn-common",
+ "burn-tensor",
+ "derive-new",
+ "hashbrown 0.15.5",
+ "log",
+ "num-traits",
+ "portable-atomic",
+ "spin",
+]
+
+[[package]]
+name = "burn-candle"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2891811d41ae30b5f1f660e7615b757b2cb4128af5e311b213656de3875e4acb"
+dependencies = [
+ "burn-common",
+ "burn-tensor",
+ "candle-core",
+ "derive-new",
+ "half",
+]
+
+[[package]]
+name = "burn-common"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb445304e4f91f8633d23c9a5258cd93639d13ce2ee47d4821fd519b683bf02"
+dependencies = [
+ "cubecl-common",
+ "rayon",
+ "serde",
+]
+
+[[package]]
+name = "burn-core"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20c93e754864080a8c27b9a47e3b6f7d79013cf82c9ce00ed57c9ba51a3e34c5"
+dependencies = [
+ "ahash",
+ "bincode",
+ "burn-common",
+ "burn-derive",
+ "burn-tensor",
+ "data-encoding",
+ "derive-new",
+ "flate2",
+ "half",
+ "hashbrown 0.15.5",
+ "log",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rand 0.9.4",
+ "regex",
+ "rmp-serde",
+ "serde",
+ "serde_json",
+ "spin",
+ "uuid",
+]
+
+[[package]]
+name = "burn-cubecl"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dd16308b7b0291c77f2d7acf428bc8254ec3db88a430a26cf3d3b0b63ae2d46"
+dependencies = [
+ "burn-common",
+ "burn-cubecl-fusion",
+ "burn-fusion",
+ "burn-ir",
+ "burn-tensor",
+ "bytemuck",
+ "cubecl",
+ "cubecl-quant",
+ "derive-new",
+ "futures-lite",
+ "half",
+ "hashbrown 0.15.5",
+ "log",
+ "num-traits",
+ "rand 0.9.4",
+ "serde",
+ "spin",
+ "text_placeholder",
+]
+
+[[package]]
+name = "burn-cubecl-fusion"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc21cf88201dfbf242cadb638a0cc924010727fc37d6a719f7e10548b339c63a"
+dependencies = [
+ "burn-common",
+ "burn-fusion",
+ "burn-ir",
+ "burn-tensor",
+ "cubecl",
+ "cubecl-quant",
+ "derive-new",
+ "half",
+ "serde",
+]
+
+[[package]]
+name = "burn-cuda"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e104dcf07eac70c7b5864b51d792df3360b11b00febb60543b4283bb414bb61"
+dependencies = [
+ "burn-cubecl",
+ "burn-tensor",
+ "bytemuck",
+ "cubecl",
+ "derive-new",
+ "half",
+ "log",
+]
+
+[[package]]
+name = "burn-derive"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bcf49261de086b8206de6c8962d2adf23feb476119a18e384f5b2c9af07c0cf"
+dependencies = [
+ "derive-new",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "burn-fusion"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "662bf2679c04be34a0c3f1b11f77f6ff49456af1620d1eca311bc2562bbb56c9"
+dependencies = [
+ "burn-common",
+ "burn-ir",
+ "burn-tensor",
+ "derive-new",
+ "half",
+ "hashbrown 0.15.5",
+ "log",
+ "serde",
+ "spin",
+]
+
+[[package]]
+name = "burn-ir"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9161239d5691c4ab6f470f2c65aaec5c0a7c1f0b0da390700bcd59f5a77d1d7b"
+dependencies = [
+ "burn-tensor",
+ "hashbrown 0.15.5",
+ "portable-atomic-util",
+ "serde",
+]
+
+[[package]]
+name = "burn-ndarray"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b78bcf4a3508043342f918e796dc79108b5f3252398403eb73952847e7683374"
+dependencies = [
+ "atomic_float",
+ "burn-autodiff",
+ "burn-common",
+ "burn-ir",
+ "burn-tensor",
+ "const-random",
+ "derive-new",
+ "libm",
+ "macerator",
+ "matrixmultiply",
+ "ndarray",
+ "num-traits",
+ "paste",
+ "portable-atomic-util",
+ "rand 0.9.4",
+ "spin",
+]
+
+[[package]]
+name = "burn-nn"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc7829c87c4dd6c7929b50fd981e7e8d1b77414323da30ce2067a3e8b7ea422b"
+dependencies = [
+ "burn-core",
+ "num-traits",
+]
+
+[[package]]
+name = "burn-optim"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31758c02e50247f12457fca1905ed8684ac1b1c5292e10cbbfffb9fa0048d4bd"
+dependencies = [
+ "burn-core",
+ "derive-new",
+ "hashbrown 0.15.5",
+ "log",
+ "num-traits",
+ "serde",
+]
+
+[[package]]
+name = "burn-rocm"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e1ceb87b6e7349b42d7995477c9a69d0e6c458c64eafa10af3b8b9070f260aa"
+dependencies = [
+ "burn-cubecl",
+ "burn-tensor",
+ "bytemuck",
+ "cubecl",
+ "derive-new",
+ "half",
+ "log",
+]
+
+[[package]]
+name = "burn-router"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45f40403c500b5df380bee47aa0f23032350bdfde5402812d6fcec4d6ff6fbad"
+dependencies = [
+ "burn-common",
+ "burn-ir",
+ "burn-tensor",
+ "hashbrown 0.15.5",
+ "log",
+ "spin",
+]
+
+[[package]]
+name = "burn-store"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a2a163486242fcb0c6e2cb89c5a803ab8588673652bb46ecd7af6378d06152f"
+dependencies = [
+ "burn-core",
+ "burn-nn",
+ "burn-tensor",
+ "byteorder",
+ "half",
+ "hashbrown 0.15.5",
+ "memmap2",
+ "regex",
+ "safetensors 0.6.2",
+]
+
+[[package]]
+name = "burn-tensor"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8861f7c21d3b07a2b19d028f6eb8903990949708b2ec825559b5200786877c"
+dependencies = [
+ "burn-common",
+ "bytemuck",
+ "colored",
+ "cubecl",
+ "cubecl-quant",
+ "derive-new",
+ "half",
+ "hashbrown 0.15.5",
+ "num-traits",
+ "rand 0.9.4",
+ "rand_distr",
+ "serde",
+ "serde_bytes",
+]
+
+[[package]]
+name = "burn-wgpu"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c17aeaa2eadaa4831a64672b99f62ffcdf4874fe4757080633d8a6c4452e2b38"
+dependencies = [
+ "burn-cubecl",
+ "burn-tensor",
+ "cubecl",
+]
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cached"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b6f5d101f0f6322c8646a45b7c581a673e476329040d97565815c2461dd0c4"
+dependencies = [
+ "ahash",
+ "cached_proc_macro",
+ "cached_proc_macro_types",
+ "hashbrown 0.16.1",
+ "once_cell",
+ "parking_lot",
+ "thiserror 2.0.18",
+ "web-time",
+]
+
+[[package]]
+name = "cached_proc_macro"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ebcf9c75f17a17d55d11afc98e46167d4790a263f428891b8705ab2f793eca3"
+dependencies = [
+ "darling 0.20.11",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cached_proc_macro_types"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
+
+[[package]]
+name = "candle-core"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c15b675b80d994b2eadb20a4bbe434eabeb454eac3ee5e2b4cf6f147ee9be091"
+dependencies = [
+ "byteorder",
+ "float8 0.6.1",
+ "gemm",
+ "half",
+ "libm",
+ "memmap2",
+ "num-traits",
+ "num_cpus",
+ "rand 0.9.4",
+ "rand_distr",
+ "rayon",
+ "safetensors 0.7.0",
+ "thiserror 2.0.18",
+ "yoke",
+ "zip 7.2.0",
+]
+
+[[package]]
+name = "caseless"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
+dependencies = [
+ "unicode-normalization",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex 2.0.1",
+]
+
+[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "codespan-reporting"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
+dependencies = [
+ "serde",
+ "termcolor",
+ "unicode-width",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "colored"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "comrak"
+version = "0.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fefab951771fc3beeed0773ce66a4f7b706273fc6c4c95b08dd1615744abcf5"
+dependencies = [
+ "caseless",
+ "entities",
+ "memchr",
+ "slug",
+ "typed-arena",
+ "unicode_categories",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "tiny-keccak",
+]
+
+[[package]]
+name = "constcat"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "136d3e02915a2cea4d74caa8681e2d44b1c3254bdbf17d11d41d587ff858832c"
+
+[[package]]
+name = "convert_case"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cubecl"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8b7c74ecaca9356c9ae79d0ebf1db04f02bd98be09eea61f51d73373dffe758"
+dependencies = [
+ "cubecl-convolution",
+ "cubecl-core",
+ "cubecl-cpu",
+ "cubecl-cuda",
+ "cubecl-hip",
+ "cubecl-matmul",
+ "cubecl-random",
+ "cubecl-reduce",
+ "cubecl-runtime",
+ "cubecl-std",
+ "cubecl-wgpu",
+ "half",
+]
+
+[[package]]
+name = "cubecl-common"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4556981155bffc057a8effcd4549b52b51df3e9edec43af6ccae2dd03fc8fbff"
+dependencies = [
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "derive-new",
+ "derive_more 2.1.1",
+ "dirs",
+ "embassy-futures",
+ "embassy-time",
+ "float4",
+ "float8 0.4.2",
+ "futures-lite",
+ "half",
+ "hashbrown 0.15.5",
+ "log",
+ "num-traits",
+ "parking_lot",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rand 0.9.4",
+ "sanitize-filename",
+ "serde",
+ "serde_bytes",
+ "serde_json",
+ "spin",
+ "wasm-bindgen-futures",
+ "web-time",
+]
+
+[[package]]
+name = "cubecl-convolution"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27c624ec400b7203673bf2db86d7ff30d1384839d497d2dd029c19b1b7371e0d"
+dependencies = [
+ "bytemuck",
+ "cubecl-common",
+ "cubecl-core",
+ "cubecl-matmul",
+ "cubecl-random",
+ "cubecl-reduce",
+ "cubecl-runtime",
+ "cubecl-std",
+ "half",
+ "pretty_assertions",
+ "serde",
+]
+
+[[package]]
+name = "cubecl-core"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffc10af538ee74535cda260e581f5a177c243803dd30b698934a515f0114b55"
+dependencies = [
+ "bitflags",
+ "bytemuck",
+ "cubecl-common",
+ "cubecl-ir",
+ "cubecl-macros",
+ "cubecl-runtime",
+ "derive-new",
+ "derive_more 2.1.1",
+ "enumset",
+ "half",
+ "hashbrown 0.15.5",
+ "log",
+ "num-traits",
+ "paste",
+ "serde",
+ "serde_json",
+ "variadics_please",
+]
+
+[[package]]
+name = "cubecl-cpp"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d630e4d10cdd3af268ac753914ca79b48f01d1e36c5b5039970a817acc925fea"
+dependencies = [
+ "bytemuck",
+ "cubecl-common",
+ "cubecl-core",
+ "cubecl-opt",
+ "cubecl-runtime",
+ "derive-new",
+ "half",
+ "itertools 0.14.0",
+ "log",
+]
+
+[[package]]
+name = "cubecl-cpu"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac1693555277d74152afb61a23e30d1f17d72cebd317a648faf50a8e69380f08"
+dependencies = [
+ "bytemuck",
+ "cubecl-common",
+ "cubecl-convolution",
+ "cubecl-core",
+ "cubecl-matmul",
+ "cubecl-opt",
+ "cubecl-reduce",
+ "cubecl-runtime",
+ "cubecl-std",
+ "derive-new",
+ "half",
+ "log",
+ "serde",
+ "sysinfo",
+ "tracel-llvm",
+ "tracel-llvm-bundler",
+]
+
+[[package]]
+name = "cubecl-cuda"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67215fcd552a9e8bc68494a71cf2979f2e2bbcbda60f0695f56f86705b89ed5f"
+dependencies = [
+ "bytemuck",
+ "cubecl-common",
+ "cubecl-core",
+ "cubecl-cpp",
+ "cubecl-runtime",
+ "cudarc",
+ "derive-new",
+ "half",
+ "log",
+ "serde",
+]
+
+[[package]]
+name = "cubecl-hip"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5e2e6a257f702fb2eb6f24e640e228a94695e4a4c73a4c549578cbb02ad4ec5"
+dependencies = [
+ "bytemuck",
+ "cubecl-common",
+ "cubecl-core",
+ "cubecl-cpp",
+ "cubecl-hip-sys",
+ "cubecl-quant",
+ "cubecl-runtime",
+ "derive-new",
+ "half",
+ "log",
+ "paste",
+ "serde",
+]
+
+[[package]]
+name = "cubecl-hip-sys"
+version = "7.2.5321100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58887c6d217859c2261a868a3a6b66aa8f44ea2f4bfa51d0dbe4dcb0d1f5768d"
+dependencies = [
+ "libc",
+ "regex",
+]
+
+[[package]]
+name = "cubecl-ir"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf5d3aa7857e6aee1622aef128d6ad8d9289ed57362b4e65d10cc182aafc585f"
+dependencies = [
+ "cubecl-common",
+ "cubecl-macros-internal",
+ "derive-new",
+ "derive_more 2.1.1",
+ "float-ord",
+ "fnv",
+ "half",
+ "hashbrown 0.15.5",
+ "num-traits",
+ "portable-atomic",
+ "serde",
+ "variadics_please",
+]
+
+[[package]]
+name = "cubecl-macros"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5200fb619be424749901e3c6e8e66ae71146c8f83636a74f171bd980cba379d7"
+dependencies = [
+ "cubecl-common",
+ "darling 0.21.3",
+ "derive-new",
+ "ident_case",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cubecl-macros-internal"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a1b673f303396fba18df83368aa4eced474584f1bca34852dccc42bd4ff050c"
+dependencies = [
+ "darling 0.21.3",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cubecl-matmul"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1cf0a00609a249d5357c27cafea477f35218579db2ab00582d8d5800be4a5a3"
+dependencies = [
+ "bytemuck",
+ "cubecl-common",
+ "cubecl-core",
+ "cubecl-random",
+ "cubecl-reduce",
+ "cubecl-runtime",
+ "cubecl-std",
+ "half",
+ "pretty_assertions",
+ "serde",
+]
+
+[[package]]
+name = "cubecl-opt"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "870ca4b52f9eebd358c9b360b89cdc9f82bde05682db63f0e90c666b3c85a04d"
+dependencies = [
+ "cubecl-common",
+ "cubecl-core",
+ "cubecl-ir",
+ "float-ord",
+ "log",
+ "num",
+ "petgraph",
+ "smallvec",
+ "stable-vec",
+ "type-map",
+]
+
+[[package]]
+name = "cubecl-quant"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9be3e1202c219078d85dbad7f30d1195fe4f9d42cbfad2c94ab0ea1a6d9f01f6"
+dependencies = [
+ "cubecl-common",
+ "cubecl-core",
+ "cubecl-runtime",
+ "cubecl-std",
+ "half",
+ "serde",
+]
+
+[[package]]
+name = "cubecl-random"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a293a05caa68663675823bab66205bca094a21a2c0f6686ad9f20b392516179"
+dependencies = [
+ "cubecl-common",
+ "cubecl-core",
+ "cubecl-runtime",
+ "cubecl-std",
+ "half",
+ "num-traits",
+ "rand 0.9.4",
+ "serde",
+]
+
+[[package]]
+name = "cubecl-reduce"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53306ace81f6262f7ae794370f47e6b5019842b27e8800240e5b039386b3ac3a"
+dependencies = [
+ "cubecl-core",
+ "cubecl-runtime",
+ "cubecl-std",
+ "half",
+ "num-traits",
+ "pretty_assertions",
+ "rand 0.9.4",
+ "serde",
+]
+
+[[package]]
+name = "cubecl-runtime"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91b823bb5899a6fa8809bf7aa36f93f72ced6de58ab9d6edea2c730b235eeda3"
+dependencies = [
+ "async-channel",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "cubecl-common",
+ "cubecl-ir",
+ "derive-new",
+ "dirs",
+ "enumset",
+ "foldhash 0.1.5",
+ "hashbrown 0.15.5",
+ "log",
+ "md5",
+ "serde",
+ "serde_json",
+ "spin",
+ "thiserror 2.0.18",
+ "toml",
+ "variadics_please",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "cubecl-std"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24536998f9fff84f9a1dd2a90f981d5aa4d15eb35cddec5021c4fcf977d2e75e"
+dependencies = [
+ "cubecl-common",
+ "cubecl-core",
+ "cubecl-runtime",
+ "foldhash 0.1.5",
+ "half",
+ "paste",
+ "serde",
+ "spin",
+ "variadics_please",
+]
+
+[[package]]
+name = "cubecl-wgpu"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d59a7d737259a784247595e2f0cc5a97d3e50f45cdaefbd4cc7d7fd2126f7a58"
+dependencies = [
+ "async-channel",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "cubecl-common",
+ "cubecl-core",
+ "cubecl-runtime",
+ "derive-new",
+ "derive_more 2.1.1",
+ "half",
+ "hashbrown 0.15.5",
+ "log",
+ "sanitize-filename",
+ "wgpu",
+]
+
+[[package]]
+name = "cudarc"
+version = "0.17.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf99ab37ee7072d64d906aa2dada9a3422f1d975cdf8c8055a573bc84897ed8"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
+]
+
+[[package]]
+name = "darling"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+dependencies = [
+ "darling_core 0.21.3",
+ "darling_macro 0.21.3",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core 0.20.11",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+dependencies = [
+ "darling_core 0.21.3",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
+[[package]]
+name = "derive-new"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "derive_more"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
+dependencies = [
+ "derive_more-impl 1.0.0",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl 2.1.1",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "unicode-xid",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "convert_case 0.10.0",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn",
+ "unicode-xid",
+]
+
+[[package]]
+name = "deunicode"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
+
+[[package]]
+name = "diff"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "dyn-stack"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8"
+dependencies = [
+ "bytemuck",
+ "dyn-stack-macros",
+]
+
+[[package]]
+name = "dyn-stack-macros"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9"
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[package]]
+name = "embassy-futures"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01"
+
+[[package]]
+name = "embassy-time"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f820157f198ada183ad62e0a66f554c610cdcd1a9f27d4b316358103ced7a1f8"
+dependencies = [
+ "cfg-if",
+ "critical-section",
+ "document-features",
+ "embassy-time-driver",
+ "embedded-hal 0.2.7",
+ "embedded-hal 1.0.0",
+ "embedded-hal-async",
+ "futures-util",
+]
+
+[[package]]
+name = "embassy-time-driver"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9"
+dependencies = [
+ "document-features",
+]
+
+[[package]]
+name = "embedded-hal"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff"
+dependencies = [
+ "nb 0.1.3",
+ "void",
+]
+
+[[package]]
+name = "embedded-hal"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89"
+
+[[package]]
+name = "embedded-hal-async"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884"
+dependencies = [
+ "embedded-hal 1.0.0",
+]
+
+[[package]]
+name = "entities"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
+
+[[package]]
+name = "enum-as-inner"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "enumset"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d"
+dependencies = [
+ "enumset_derive",
+]
+
+[[package]]
+name = "enumset_derive"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a"
+dependencies = [
+ "darling 0.21.3",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "fid-rs"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6956a1e60e2d1412b44b4169d44a03dae518f8583d3e10090c912c105e48447"
+dependencies = [
+ "rayon",
+]
+
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+ "zlib-rs",
+]
+
+[[package]]
+name = "float-ord"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
+
+[[package]]
+name = "float4"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5939bac0ef2ad7c83a53e4fb889c1d81f007b07061d648cd271071984d86f257"
+
+[[package]]
+name = "float8"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4203231de188ebbdfb85c11f3c20ca2b063945710de04e7b59268731e728b462"
+dependencies = [
+ "half",
+]
+
+[[package]]
+name = "float8"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5"
+dependencies = [
+ "half",
+ "num-traits",
+ "rand 0.9.4",
+ "rand_distr",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fst"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a"
+
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gemm"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb"
+dependencies = [
+ "dyn-stack",
+ "gemm-c32",
+ "gemm-c64",
+ "gemm-common",
+ "gemm-f16",
+ "gemm-f32",
+ "gemm-f64",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-c32"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-c64"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-common"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e"
+dependencies = [
+ "bytemuck",
+ "dyn-stack",
+ "half",
+ "libm",
+ "num-complex",
+ "num-traits",
+ "once_cell",
+ "paste",
+ "pulp",
+ "raw-cpuid",
+ "rayon",
+ "seq-macro",
+ "sysctl",
+]
+
+[[package]]
+name = "gemm-f16"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "gemm-f32",
+ "half",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "rayon",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-f32"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "gemm-f64"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a"
+dependencies = [
+ "dyn-stack",
+ "gemm-common",
+ "num-complex",
+ "num-traits",
+ "paste",
+ "raw-cpuid",
+ "seq-macro",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getopts"
+version = "0.2.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
+dependencies = [
+ "unicode-width",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "gl_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
+dependencies = [
+ "khronos_api",
+ "log",
+ "xml-rs",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "glow"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "glutin_wgl_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
+dependencies = [
+ "gl_generator",
+]
+
+[[package]]
+name = "gpu-alloc"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171"
+dependencies = [
+ "bitflags",
+ "gpu-alloc-types",
+]
+
+[[package]]
+name = "gpu-alloc-types"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "gpu-allocator"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd"
+dependencies = [
+ "log",
+ "presser",
+ "thiserror 1.0.69",
+ "windows 0.58.0",
+]
+
+[[package]]
+name = "gpu-descriptor"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
+dependencies = [
+ "bitflags",
+ "gpu-descriptor-types",
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "gpu-descriptor-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "bytemuck",
+ "cfg-if",
+ "crunchy",
+ "num-traits",
+ "rand 0.9.4",
+ "rand_distr",
+ "serde",
+ "zerocopy",
+]
+
+[[package]]
+name = "harper-brill"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78176be681477890eea55780e3323e134d4efb937c65c17f7bd9c59e457c86f3"
+dependencies = [
+ "harper-pos-utils",
+ "serde_json",
+]
+
+[[package]]
+name = "harper-core"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acab57352d953e0ab227d4a0a7a8f990e28053f47b8d633711677a83180f51fb"
+dependencies = [
+ "ammonia",
+ "bitflags",
+ "blanket",
+ "cached",
+ "foldhash 0.2.0",
+ "fst",
+ "harper-brill",
+ "harper-thesaurus",
+ "hashbrown 0.16.1",
+ "is-macro",
+ "itertools 0.14.0",
+ "levenshtein_automata",
+ "lru",
+ "ordered-float 5.3.0",
+ "paste",
+ "pulldown-cmark",
+ "regex",
+ "serde",
+ "serde_json",
+ "smallvec",
+ "strum",
+ "strum_macros",
+ "thiserror 2.0.18",
+ "trie-rs",
+ "unicode-blocks",
+ "unicode-script",
+ "unicode-width",
+ "zip 8.6.0",
+]
+
+[[package]]
+name = "harper-ffi"
+version = "0.1.0"
+dependencies = [
+ "harper-core",
+ "serde_json",
+]
+
+[[package]]
+name = "harper-pos-utils"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a191fccad8ba62230a64a36a33a16635e2f60e08aec5580cc79262fbb8c837dd"
+dependencies = [
+ "burn",
+ "burn-ndarray",
+ "hashbrown 0.16.1",
+ "is-macro",
+ "lru",
+ "rs-conllu",
+ "serde",
+ "serde_json",
+ "strum",
+ "strum_macros",
+]
+
+[[package]]
+name = "harper-thesaurus"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e4e193a731f1cdf7496aa8d1900b88cdd1f99a46f725d186feccac5cbe55ad8"
+dependencies = [
+ "hashbrown 0.16.1",
+ "indexmap",
+ "itertools 0.14.0",
+ "ruzstd",
+ "zstd",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.1.5",
+ "serde",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hexf-parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+
+[[package]]
+name = "html5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
+dependencies = [
+ "log",
+ "markup5ever",
+ "match_token",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "is-macro"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading",
+ "pkg-config",
+]
+
+[[package]]
+name = "khronos_api"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+
+[[package]]
+name = "levenshtein_automata"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25"
+dependencies = [
+ "fst",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "liblzma"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899"
+dependencies = [
+ "liblzma-sys",
+ "num_cpus",
+]
+
+[[package]]
+name = "liblzma-sys"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
+
+[[package]]
+name = "louds-rs"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "936de6c22f08e7135a921f8ada907acd0d88880c4f42b5591f634b9f1dd8e07f"
+dependencies = [
+ "fid-rs",
+]
+
+[[package]]
+name = "lru"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
+dependencies = [
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
+[[package]]
+name = "macerator"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84b0b2dbe8b22f9e96ba12e29964889010117f92e6bd006010887320ae58e2f0"
+dependencies = [
+ "bytemuck",
+ "cfg_aliases",
+ "half",
+ "macerator-macros",
+ "moddef",
+ "num-traits",
+ "paste",
+ "rustc_version",
+]
+
+[[package]]
+name = "macerator-macros"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5ce85961d618ce9794bdf822bfe96fe9dd341aa5b033b454f7a8d96e79b9b1"
+dependencies = [
+ "darling 0.20.11",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "malloc_buf"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
+[[package]]
+name = "markup5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "match_token"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "matrixmultiply"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
+[[package]]
+name = "md5"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "memmap2"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
+dependencies = [
+ "libc",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "metal"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605"
+dependencies = [
+ "bitflags",
+ "block",
+ "core-graphics-types",
+ "foreign-types",
+ "log",
+ "objc",
+ "paste",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "moddef"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a0b3262dc837d2513fe2ef31ff8461352ef932dcca31ba0c0abe33547cf6b9b"
+
+[[package]]
+name = "naga"
+version = "26.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916cbc7cb27db60be930a4e2da243cf4bc39569195f22fd8ee419cd31d5b662c"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "codespan-reporting",
+ "half",
+ "hashbrown 0.15.5",
+ "hexf-parse",
+ "indexmap",
+ "libm",
+ "log",
+ "num-traits",
+ "once_cell",
+ "rustc-hash 1.1.0",
+ "spirv",
+ "thiserror 2.0.18",
+ "unicode-ident",
+]
+
+[[package]]
+name = "nb"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f"
+dependencies = [
+ "nb 1.1.0",
+]
+
+[[package]]
+name = "nb"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d"
+
+[[package]]
+name = "ndarray"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
+]
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "ntapi"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "bytemuck",
+ "num-traits",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "objc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+dependencies = [
+ "malloc_buf",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "objc2-io-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
+dependencies = [
+ "libc",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "ordered-float"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "ordered-float"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e"
+dependencies = [
+ "num-traits",
+ "rand 0.8.6",
+ "serde",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "petgraph"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
+dependencies = [
+ "fixedbitset",
+ "indexmap",
+]
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "presser"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+
+[[package]]
+name = "pretty_assertions"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
+dependencies = [
+ "diff",
+ "yansi",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "profiling"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
+
+[[package]]
+name = "pulldown-cmark"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
+dependencies = [
+ "bitflags",
+ "getopts",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
+[[package]]
+name = "pulp"
+version = "0.22.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632"
+dependencies = [
+ "bytemuck",
+ "cfg-if",
+ "libm",
+ "num-complex",
+ "paste",
+ "pulp-wasm-simd-flag",
+ "raw-cpuid",
+ "reborrow",
+ "version_check",
+]
+
+[[package]]
+name = "pulp-wasm-simd-flag"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0"
+
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash 2.1.2",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.4",
+ "ring",
+ "rustc-hash 2.1.2",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "rand_core 0.6.4",
+ "serde",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_distr"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
+dependencies = [
+ "num-traits",
+ "rand 0.9.4",
+]
+
+[[package]]
+name = "range-alloc"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
+
+[[package]]
+name = "raw-cpuid"
+version = "11.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "reborrow"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "renderdoc-sys"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rmp"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "rmp-serde"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
+dependencies = [
+ "rmp",
+ "serde",
+]
+
+[[package]]
+name = "rs-conllu"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6de5aecf17f8fff1b35d59a12e2b8c908cad4d67208805166483655554f9169"
+dependencies = [
+ "clap",
+ "derive_more 1.0.0",
+ "thiserror 1.0.69",
+ "walkdir",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ruzstd"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8"
+dependencies = [
+ "twox-hash",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "safetensors"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "172dd94c5a87b5c79f945c863da53b2ebc7ccef4eca24ac63cca66a41aab2178"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "safetensors"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5"
+dependencies = [
+ "hashbrown 0.16.1",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "sanitize-filename"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d"
+dependencies = [
+ "regex",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "seq-macro"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_bytes"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "slug"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
+dependencies = [
+ "deunicode",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
+dependencies = [
+ "lock_api",
+ "portable-atomic",
+]
+
+[[package]]
+name = "spirv"
+version = "0.3.0+sdk-1.3.268.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "stable-vec"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dac7bc0f7d0d44329b200020effbc25a534d89fa142af95e3ddf76113412a5e"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "sysctl"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc"
+dependencies = [
+ "bitflags",
+ "byteorder",
+ "enum-as-inner",
+ "libc",
+ "thiserror 1.0.69",
+ "walkdir",
+]
+
+[[package]]
+name = "sysinfo"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d"
+dependencies = [
+ "libc",
+ "memchr",
+ "ntapi",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "windows 0.61.3",
+]
+
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "text_placeholder"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd5008f74a09742486ef0047596cf35df2b914e2a8dca5727fcb6ba6842a766b"
+dependencies = [
+ "hashbrown 0.13.2",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.9.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracel-llvm"
+version = "20.1.4-7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "982535db9eb1a30ac0f2c50239a0eec3e5cf50993a88e92b04747bd2f4d365b2"
+dependencies = [
+ "tracel-mlir-rs",
+ "tracel-mlir-sys",
+]
+
+[[package]]
+name = "tracel-llvm-bundler"
+version = "20.1.4-7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c75b8e477cb8d49d907afab029ca74d48459f5b88c27bdb4c6cd6acb5e61977"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "constcat",
+ "dirs",
+ "liblzma",
+ "regex",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tar",
+ "walkdir",
+]
+
+[[package]]
+name = "tracel-mlir-rs"
+version = "20.1.4-7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a478a35efd68d0ba73f747adfb7923b121c64e7f5be9cd8364ca1dcb772d5c"
+dependencies = [
+ "tracel-mlir-rs-macros",
+ "tracel-mlir-sys",
+]
+
+[[package]]
+name = "tracel-mlir-rs-macros"
+version = "20.1.4-7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a94f36868c3b10b1825945223d99d106c73f4d249f063caa4651deeb9379344"
+dependencies = [
+ "comrak",
+ "convert_case 0.8.0",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "syn",
+ "tracel-llvm-bundler",
+ "tracel-tblgen-rs",
+ "unindent",
+]
+
+[[package]]
+name = "tracel-mlir-sys"
+version = "20.1.4-7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02f26d31af0c225a6d2e3d65d012fd6de848c9fc776897b152ee83b7d1bd15c4"
+dependencies = [
+ "tracel-llvm-bundler",
+]
+
+[[package]]
+name = "tracel-tblgen-rs"
+version = "20.1.4-7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d2581070380418ccc33b500f3739e4d4869421fdb477fcea51ff97c6253a52"
+dependencies = [
+ "bindgen",
+ "cc",
+ "paste",
+ "thiserror 2.0.18",
+ "tracel-llvm-bundler",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "trie-rs"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f88f4b0a1ebd6c3d16be3e45eb0e8089372ccadd88849b7ca162ba64b5e6f6"
+dependencies = [
+ "louds-rs",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "twox-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
+
+[[package]]
+name = "type-map"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
+dependencies = [
+ "rustc-hash 2.1.2",
+]
+
+[[package]]
+name = "typed-arena"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
+
+[[package]]
+name = "typed-path"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-blocks"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b12e05d9e06373163a9bb6bb8c263c261b396643a99445fe6b9811fd376581b"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-script"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "unicode_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
+[[package]]
+name = "unindent"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "unty"
+version = "0.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
+
+[[package]]
+name = "variadics_please"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "void"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web_atoms"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "wgpu"
+version = "26.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70b6ff82bbf6e9206828e1a3178e851f8c20f1c9028e74dd3a8090741ccd5798"
+dependencies = [
+ "arrayvec",
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.15.5",
+ "js-sys",
+ "log",
+ "naga",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "smallvec",
+ "static_assertions",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "wgpu-core",
+ "wgpu-hal",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core"
+version = "26.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5f62f1053bd28c2268f42916f31588f81f64796e2ff91b81293515017ca8bd9"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bit-vec",
+ "bitflags",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "log",
+ "naga",
+ "once_cell",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "rustc-hash 1.1.0",
+ "smallvec",
+ "thiserror 2.0.18",
+ "wgpu-core-deps-apple",
+ "wgpu-core-deps-emscripten",
+ "wgpu-core-deps-windows-linux-android",
+ "wgpu-hal",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core-deps-apple"
+version = "26.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18ae5fbde6a4cbebae38358aa73fcd6e0f15c6144b67ef5dc91ded0db125dbdf"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-emscripten"
+version = "26.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7670e390f416006f746b4600fdd9136455e3627f5bd763abf9a65daa216dd2d"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-windows-linux-android"
+version = "26.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "720a5cb9d12b3d337c15ff0e24d3e97ed11490ff3f7506e7f3d98c68fa5d6f14"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-hal"
+version = "26.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8d0e67224cc7305b3b4eb2cc57ca4c4c3afc665c1d1bee162ea806e19c47bdd"
+dependencies = [
+ "android_system_properties",
+ "arrayvec",
+ "ash",
+ "bit-set",
+ "bitflags",
+ "block",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "core-graphics-types",
+ "glow",
+ "glutin_wgl_sys",
+ "gpu-alloc",
+ "gpu-allocator",
+ "gpu-descriptor",
+ "hashbrown 0.15.5",
+ "js-sys",
+ "khronos-egl",
+ "libc",
+ "libloading",
+ "log",
+ "metal",
+ "naga",
+ "ndk-sys",
+ "objc",
+ "ordered-float 4.6.0",
+ "parking_lot",
+ "portable-atomic",
+ "portable-atomic-util",
+ "profiling",
+ "range-alloc",
+ "raw-window-handle",
+ "renderdoc-sys",
+ "smallvec",
+ "thiserror 2.0.18",
+ "wasm-bindgen",
+ "web-sys",
+ "wgpu-types",
+ "windows 0.58.0",
+ "windows-core 0.58.0",
+]
+
+[[package]]
+name = "wgpu-types"
+version = "26.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2"
+dependencies = [
+ "bitflags",
+ "bytemuck",
+ "js-sys",
+ "log",
+ "thiserror 2.0.18",
+ "web-sys",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
+dependencies = [
+ "windows-core 0.58.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows"
+version = "0.61.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+dependencies = [
+ "windows-collections",
+ "windows-core 0.61.2",
+ "windows-future",
+ "windows-link 0.1.3",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+dependencies = [
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
+dependencies = [
+ "windows-implement 0.58.0",
+ "windows-interface 0.58.0",
+ "windows-result 0.2.0",
+ "windows-strings 0.1.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
+dependencies = [
+ "windows-result 0.2.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link 0.2.1",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+
+[[package]]
+name = "winnow"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
+[[package]]
+name = "xml-rs"
+version = "0.8.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
+
+[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zip"
+version = "7.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
+dependencies = [
+ "crc32fast",
+ "indexmap",
+ "memchr",
+ "typed-path",
+]
+
+[[package]]
+name = "zip"
+version = "8.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "indexmap",
+ "memchr",
+ "typed-path",
+ "zopfli",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
diff --git a/harper-ffi/Cargo.toml b/harper-ffi/Cargo.toml
new file mode 100644
index 0000000..e528ada
--- /dev/null
+++ b/harper-ffi/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "harper-ffi"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+# Static library exposing a C ABI for linking into the Swift app.
+name = "harper_ffi"
+crate-type = ["staticlib"]
+
+[dependencies]
+# harper-core 2.5.0 — the grammar checker from the Automattic Harper project.
+# Verified against https://docs.rs/harper-core/2.5.0/
+harper-core = "2.5.0"
+# Used to build the output JSON with correct string escaping.
+serde_json = "1"
+
+[profile.release]
+opt-level = 3
+lto = true
+codegen-units = 1
diff --git a/harper-ffi/README.md b/harper-ffi/README.md
index 2d4153f..6fd2d49 100644
--- a/harper-ffi/README.md
+++ b/harper-ffi/README.md
@@ -5,7 +5,7 @@ A thin Rust static library that exposes the **Harper** grammar core
integration (spec §1, §2, §8).
The Swift side talks to this through the `GrammarEngine` protocol; today
-[`HarperEngine.swift`](../Sources/GrammaGem/Grammar/HarperEngine.swift) ships a
+[`HarperEngine.swift`](../Sources/GrammarGem/Grammar/HarperEngine.swift) ships a
small pure-Swift rule stub so the system loop + tests run with zero native deps.
## Planned shape
diff --git a/harper-ffi/build.sh b/harper-ffi/build.sh
new file mode 100755
index 0000000..5447085
--- /dev/null
+++ b/harper-ffi/build.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# Build the UNIVERSAL (Apple Silicon + Intel) Harper static library that the
+# Swift app links against: harper-ffi/lib/libharper_ffi.a
+#
+# Requires the Rust toolchain (rustup) with both macOS targets:
+# rustup target add aarch64-apple-darwin x86_64-apple-darwin
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+cd "$ROOT"
+
+# Make cargo available even in non-login shells.
+[ -f "$HOME/.cargo/env" ] && source "$HOME/.cargo/env"
+
+echo "==> cargo build --release (aarch64 + x86_64)"
+cargo build --release --target aarch64-apple-darwin
+cargo build --release --target x86_64-apple-darwin
+
+echo "==> lipo -> lib/libharper_ffi.a (universal)"
+mkdir -p lib
+lipo -create -output lib/libharper_ffi.a \
+ target/aarch64-apple-darwin/release/libharper_ffi.a \
+ target/x86_64-apple-darwin/release/libharper_ffi.a
+
+lipo -info lib/libharper_ffi.a
+echo "==> done"
diff --git a/harper-ffi/include/harper.h b/harper-ffi/include/harper.h
new file mode 100644
index 0000000..3518fb6
--- /dev/null
+++ b/harper-ffi/include/harper.h
@@ -0,0 +1,56 @@
+/*
+ * harper.h — C ABI for the harper-ffi static library (harper-core 2.5.0).
+ *
+ * Link against libharper_ffi.a. All strings are UTF-8.
+ */
+#ifndef HARPER_FFI_H
+#define HARPER_FFI_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/*
+ * Check `text` for grammar/spelling issues using harper-core.
+ *
+ * Returns a malloc'd, NUL-terminated UTF-8 JSON C string that the caller MUST
+ * free with harper_free().
+ *
+ * The JSON is an array of objects:
+ * [
+ * {
+ * "start": ,
+ * "len": ,
+ * "replacement": "",
+ * "message": "",
+ * "kind": ""
+ * },
+ * ...
+ * ]
+ *
+ * Notes on the contract:
+ * - "start"/"len" are CHAR (Unicode scalar) offsets, matching harper's native
+ * char indexing. They are NOT byte offsets and NOT UTF-16 code-unit offsets.
+ * "len" = span.end - span.start (end-exclusive).
+ * - "replacement" is the FIRST ReplaceWith suggestion's text. If the first
+ * actionable suggestion is a removal, "replacement" is "" (empty string).
+ * - Lints with NO actionable suggestion are SKIPPED.
+ * - "kind" is one of: "spelling", "grammar", "punctuation", "repetition",
+ * "capitalization", "phrasing". Best-effort; defaults to "grammar".
+ *
+ * On a null `text`, invalid UTF-8, or any internal error/panic, returns "[]".
+ * Never returns NULL on a normal return path. Thread-safe.
+ */
+char *harper_check(const char *text);
+
+/*
+ * Free a string previously returned by harper_check().
+ * Passing NULL is a no-op. Do not free the same pointer twice.
+ */
+void harper_free(char *ptr);
+
+#ifdef __cplusplus
+} /* extern "C" */
+#endif
+
+#endif /* HARPER_FFI_H */
diff --git a/harper-ffi/src/lib.rs b/harper-ffi/src/lib.rs
new file mode 100644
index 0000000..cd6b330
--- /dev/null
+++ b/harper-ffi/src/lib.rs
@@ -0,0 +1,170 @@
+//! C-ABI FFI bridge exposing harper-core 2.5.0's grammar checker to Swift.
+//!
+//! Public surface:
+//! char* harper_check(const char* text); // malloc'd UTF-8 JSON, free with harper_free
+//! void harper_free(char* ptr);
+//!
+//! Every API call below was written against docs.rs for harper-core 2.5.0.
+
+use std::ffi::{CStr, CString};
+use std::os::raw::c_char;
+use std::panic::{catch_unwind, AssertUnwindSafe};
+use std::sync::{Arc, OnceLock};
+
+use harper_core::{Dialect, Document};
+use harper_core::linting::{LintGroup, LintKind, Linter, Suggestion};
+use harper_core::spell::FstDictionary;
+
+/// Empty JSON array used for null/invalid input and on any panic.
+const EMPTY_JSON: &str = "[]";
+
+/// The curated FST dictionary is the expensive artifact to build, so build it
+/// exactly once and share it across calls/threads via `Arc`. `FstDictionary`
+/// is immutable and `Arc` is `Send + Sync`, so this is safe to
+/// read from any thread.
+///
+/// We intentionally do NOT cache the `LintGroup` itself: `Linter::lint` takes
+/// `&mut self`, which would require a `Mutex` and serialize all callers. The
+/// `LintGroup` is cheap to reconstruct relative to the dictionary, so we build
+/// a fresh one per call from the shared dictionary instead.
+fn curated_dictionary() -> Arc {
+ static DICT: OnceLock> = OnceLock::new();
+ DICT.get_or_init(FstDictionary::curated).clone()
+}
+
+/// Map a harper `LintKind` to the lowercase string contract:
+/// one of spelling, grammar, punctuation, repetition, capitalization, phrasing.
+/// Default is "grammar" for anything not specifically categorized.
+fn kind_to_str(kind: LintKind) -> &'static str {
+ match kind {
+ LintKind::Spelling | LintKind::Typo => "spelling",
+ LintKind::Punctuation | LintKind::Formatting => "punctuation",
+ LintKind::Repetition => "repetition",
+ LintKind::Capitalization => "capitalization",
+ // "phrasing"-flavored categories: word choice / style / readability / usage.
+ LintKind::WordChoice
+ | LintKind::Style
+ | LintKind::Readability
+ | LintKind::Usage
+ | LintKind::Redundancy
+ | LintKind::Enhancement
+ | LintKind::Eggcorn
+ | LintKind::Malapropism
+ | LintKind::Nonstandard
+ | LintKind::Regionalism => "phrasing",
+ // Grammar-ish / structural categories.
+ LintKind::Grammar
+ | LintKind::Agreement
+ | LintKind::BoundaryError
+ | LintKind::Miscellaneous => "grammar",
+ // LintKind may gain variants; default safely.
+ _ => "grammar",
+ }
+}
+
+/// Run harper over `text` and produce the JSON contract string.
+fn check_to_json(text: &str) -> String {
+ let dictionary = curated_dictionary();
+
+ // Build the document with the built-in PlainEnglish parser + curated dictionary.
+ let document = Document::new_plain_english(text, &dictionary);
+
+ // Fresh curated lint group from the shared dictionary; American dialect.
+ let mut group = LintGroup::new_curated(dictionary.clone(), Dialect::American);
+
+ // Linter::lint(&mut self, &Document) -> Vec
+ let lints = group.lint(&document);
+
+ let mut out: Vec = Vec::with_capacity(lints.len());
+ for lint in lints {
+ // span is Span: start/end are CHAR (Unicode scalar) offsets, end-exclusive.
+ let start = lint.span.start;
+ let len = lint.span.end.saturating_sub(lint.span.start);
+
+ // Pick the first ReplaceWith suggestion as "replacement".
+ // If the first actionable suggestion is a Remove, replacement = "".
+ // InsertAfter is treated as non-replacement here. Lints with no
+ // actionable suggestion are skipped.
+ let mut replacement: Option = None;
+ for suggestion in &lint.suggestions {
+ match suggestion {
+ Suggestion::ReplaceWith(chars) => {
+ replacement = Some(chars.iter().collect());
+ break;
+ }
+ Suggestion::Remove => {
+ replacement = Some(String::new());
+ break;
+ }
+ Suggestion::InsertAfter(_) => {}
+ }
+ }
+
+ let replacement = match replacement {
+ Some(r) => r,
+ None => continue,
+ };
+
+ out.push(serde_json::json!({
+ "start": start,
+ "len": len,
+ "replacement": replacement,
+ "message": lint.message,
+ "kind": kind_to_str(lint.lint_kind),
+ }));
+ }
+
+ serde_json::to_string(&out).unwrap_or_else(|_| EMPTY_JSON.to_string())
+}
+
+/// Allocate a C string for return to the caller. The caller owns it and must
+/// free it with `harper_free`.
+fn into_c_string(s: String) -> *mut c_char {
+ match CString::new(s) {
+ Ok(cstr) => cstr.into_raw(),
+ Err(_) => CString::new(EMPTY_JSON).unwrap().into_raw(),
+ }
+}
+
+/// Check `text` for grammar/spelling issues.
+///
+/// Returns a malloc'd, NUL-terminated UTF-8 JSON C string the caller MUST free
+/// with `harper_free`. On null/invalid-UTF-8 input or on any internal panic,
+/// returns the JSON `"[]"`. Never returns null on a normal return path.
+///
+/// # Safety
+/// `text` must be a valid NUL-terminated C string pointer or null.
+#[no_mangle]
+pub extern "C" fn harper_check(text: *const c_char) -> *mut c_char {
+ let result = catch_unwind(AssertUnwindSafe(|| {
+ if text.is_null() {
+ return EMPTY_JSON.to_string();
+ }
+ // SAFETY: caller guarantees a valid NUL-terminated C string (non-null checked).
+ let c_str = unsafe { CStr::from_ptr(text) };
+ match c_str.to_str() {
+ Ok(s) => check_to_json(s),
+ Err(_) => EMPTY_JSON.to_string(),
+ }
+ }));
+
+ match result {
+ Ok(json) => into_c_string(json),
+ Err(_) => into_c_string(EMPTY_JSON.to_string()),
+ }
+}
+
+/// Free a string previously returned by `harper_check`.
+///
+/// # Safety
+/// `ptr` must be a pointer returned by `harper_check` (or null), freed once.
+#[no_mangle]
+pub extern "C" fn harper_free(ptr: *mut c_char) {
+ if ptr.is_null() {
+ return;
+ }
+ // SAFETY: ptr came from CString::into_raw in this module; reclaim and drop it.
+ unsafe {
+ drop(CString::from_raw(ptr));
+ }
+}
diff --git a/scripts/build-metallib.sh b/scripts/build-metallib.sh
new file mode 100755
index 0000000..755a7b4
--- /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 GrammarGem -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 86c8fc3..1d66509 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 GrammarGem.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
@@ -7,21 +8,52 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
CONFIG="release"
-APP="GrammaGem.app"
+APP="GrammarGem.app"
DIST="dist"
-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 "==> Building Harper static lib (universal)"
+chmod +x harper-ffi/build.sh
+./harper-ffi/build.sh
+
+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}"
mkdir -p "${DIST}/${APP}/Contents/MacOS" "${DIST}/${APP}/Contents/Resources"
-cp "${BIN}/GrammaGem" "${DIST}/${APP}/Contents/MacOS/GrammaGem"
+cp "${BIN}/GrammarGem" "${DIST}/${APP}/Contents/MacOS/GrammarGem"
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"
+
+# Embed Sparkle.framework (auto-update) and add an rpath so the executable can
+# resolve it at @executable_path/../Frameworks. (Signed inside-out by sign-app.sh.)
+echo "==> Embedding Sparkle.framework"
+SPARKLE_FW="$(find .build -path '*Sparkle.xcframework/macos-arm64*/Sparkle.framework' -type d | head -1)"
+mkdir -p "${DIST}/${APP}/Contents/Frameworks"
+cp -R "${SPARKLE_FW}" "${DIST}/${APP}/Contents/Frameworks/"
+install_name_tool -add_rpath "@executable_path/../Frameworks" \
+ "${DIST}/${APP}/Contents/MacOS/GrammarGem" 2>/dev/null || true
+
echo "==> Built ${DIST}/${APP}"
-lipo -info "${DIST}/${APP}/Contents/MacOS/GrammaGem"
+lipo -info "${DIST}/${APP}/Contents/MacOS/GrammarGem"
echo " Run with: open \"${DIST}/${APP}\" (grant Accessibility on first launch)"
diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift
index 81c0e3c..3226134 100644
--- a/scripts/make-icon.swift
+++ b/scripts/make-icon.swift
@@ -1,5 +1,5 @@
#!/usr/bin/env swift
-// Generates AppIcon.iconset (PNGs) for GrammaGem — a gold pen-nib on the ink
+// Generates AppIcon.iconset (PNGs) for GrammarGem — a gold pen-nib on the ink
// brand square. Run from mac/: swift scripts/make-icon.swift
// Then: iconutil -c icns AppIcon.iconset -o AppSupport/AppIcon.icns
import AppKit
diff --git a/scripts/release.sh b/scripts/release.sh
index db7a335..6711020 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -1,32 +1,71 @@
#!/usr/bin/env bash
-# Package a signed build for distribution and update the Sparkle appcast hosted
-# on GitHub Releases (zero hosting cost). Run after sign-notarize.sh.
+# Cut a GrammarGem release and publish the Sparkle appcast (hosted on grammargem.com).
#
-# This is a documented scaffold — wire it to your Sparkle EdDSA key + repo.
+# ./scripts/release.sh e.g. ./scripts/release.sh 0.2.0
+#
+# Steps: stamp version -> build -> sign inside-out (incl. Sparkle) -> notarize +
+# staple -> zip -> generate the EdDSA-signed appcast.xml. Then upload the .zip and
+# appcast.xml to grammargem.com so every installed app shows "Update available".
+#
+# Notarization needs ONE of:
+# NOTARY_PROFILE (xcrun notarytool store-credentials)
+# APPLE_ID + TEAM_ID + NOTARY_PASSWORD (Apple ID + app-specific password)
+# Set SKIP_NOTARIZE=1 for a dry run (un-notarized build; do not ship).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-APP="$ROOT/dist/GrammaGem.app"
-VERSION="${1:?Usage: release.sh e.g. 0.1.0}"
-DMG="$ROOT/dist/GrammaGem-$VERSION.dmg"
+cd "$ROOT"
-echo "▸ Creating DMG for $VERSION…"
-# hdiutil create -volname "GrammaGem" -srcfolder "$APP" -ov -format UDZO "$DMG" # TODO: enable
-echo " (DMG creation stubbed — uncomment hdiutil line)"
+VERSION="${1:?usage: release.sh e.g. release.sh 0.2.0}"
+APP="dist/GrammarGem.app"
+RELDIR="dist/releases"
+ZIP="${RELDIR}/GrammarGem-${VERSION}.zip"
+DL_PREFIX="${DL_PREFIX:-https://www.grammargem.com/releases/}"
+PB=/usr/libexec/PlistBuddy
-cat <<'NOTE'
-▸ Sparkle appcast (TODO):
- 1. Sign the update: ./bin/sign_update dist/GrammaGem-.dmg
- (Sparkle's sign_update tool, using your EdDSA private key)
- 2. Add an - to appcast.xml with the version, URL (GitHub Releases asset),
- length, and sparkle:edSignature.
- 3. Upload the DMG + appcast.xml to a GitHub Release.
- The app's SUFeedURL points at the raw appcast.xml on GitHub — $0 hosting.
+# 1. Stamp the marketing version + bump the monotonic build number.
+CUR_BUILD="$($PB -c 'Print :CFBundleVersion' AppSupport/Info.plist)"
+NEW_BUILD=$((CUR_BUILD + 1))
+$PB -c "Set :CFBundleShortVersionString ${VERSION}" AppSupport/Info.plist
+$PB -c "Set :CFBundleVersion ${NEW_BUILD}" AppSupport/Info.plist
+echo "==> Releasing ${VERSION} (build ${NEW_BUILD})"
-▸ Distribution touchpoints (all $0 marginal cost):
- - Model weights: Hugging Face CDN
- - Updates: GitHub Releases + Sparkle
- - Licensing: Lemon Squeezy License API (% per sale only)
-NOTE
+# 2. Build + sign (inside-out so the embedded Sparkle.framework is valid).
+./scripts/build.sh
+./scripts/sign-app.sh "$APP"
-echo "✓ Release steps printed for $VERSION"
+# 3. Zip.
+mkdir -p "$RELDIR"
+rm -f "$ZIP"
+ditto -c -k --keepParent "$APP" "$ZIP"
+
+# 4. Notarize + staple (the ticket lets Sparkle install it without Gatekeeper prompts).
+if [ "${SKIP_NOTARIZE:-0}" != "1" ]; then
+ echo "==> Notarizing"
+ if [ -n "${NOTARY_PROFILE:-}" ]; then
+ xcrun notarytool submit "$ZIP" --keychain-profile "$NOTARY_PROFILE" --wait
+ elif [ -n "${APPLE_ID:-}" ] && [ -n "${TEAM_ID:-}" ] && [ -n "${NOTARY_PASSWORD:-}" ]; then
+ xcrun notarytool submit "$ZIP" \
+ --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$NOTARY_PASSWORD" --wait
+ else
+ echo "ERROR: set NOTARY_PROFILE or APPLE_ID+TEAM_ID+NOTARY_PASSWORD (or SKIP_NOTARIZE=1)." >&2
+ exit 1
+ fi
+ xcrun stapler staple "$APP"
+ rm -f "$ZIP"; ditto -c -k --keepParent "$APP" "$ZIP" # re-zip the stapled app
+fi
+
+# 5. Generate the EdDSA-signed appcast (keeps every zip in RELDIR in its history).
+GA="$(find .build/artifacts -name generate_appcast -type f | head -1)"
+[ -n "$GA" ] || { echo "ERROR: generate_appcast not found (run 'swift package resolve')." >&2; exit 1; }
+"$GA" --download-url-prefix "$DL_PREFIX" "$RELDIR"
+
+echo
+echo "✓ Release ${VERSION} built. Upload these to grammargem.com:"
+echo " ${ZIP}"
+echo " -> ${DL_PREFIX}GrammarGem-${VERSION}.zip"
+echo " ${RELDIR}/appcast.xml"
+echo " -> https://www.grammargem.com/appcast.xml (matches Info.plist SUFeedURL)"
+echo
+echo " Once uploaded, installed apps show an 'Update available' button within a day"
+echo " (or immediately via menu bar -> Check for Updates...)."
diff --git a/scripts/sign-app.sh b/scripts/sign-app.sh
new file mode 100755
index 0000000..743b882
--- /dev/null
+++ b/scripts/sign-app.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Code-sign GrammarGem.app with Developer ID + Hardened Runtime, signing the
+# embedded Sparkle.framework's nested helpers INSIDE-OUT first (required for a
+# valid, notarizable signature).
+#
+# Usage: ./scripts/sign-app.sh [path/to/GrammarGem.app]
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
+ENTITLEMENTS="AppSupport/GrammarGem.entitlements"
+APP="${1:-dist/GrammarGem.app}"
+
+sign() { codesign --force --timestamp --options runtime --sign "$DEV_ID" "$@"; }
+
+FW="${APP}/Contents/Frameworks/Sparkle.framework"
+if [ -d "$FW" ]; then
+ echo "==> Signing Sparkle.framework (inside-out)"
+ V="${FW}/Versions/B"
+ for xpc in "$V"/XPCServices/*.xpc; do [ -e "$xpc" ] && sign "$xpc"; done
+ [ -e "$V/Updater.app/Contents/MacOS/Autoupdate" ] && sign "$V/Updater.app/Contents/MacOS/Autoupdate" || true
+ [ -e "$V/Updater.app" ] && sign "$V/Updater.app"
+ [ -e "$V/Autoupdate" ] && sign "$V/Autoupdate"
+ sign "$FW"
+fi
+
+echo "==> Signing ${APP}"
+codesign --force --timestamp --options runtime \
+ --entitlements "$ENTITLEMENTS" --sign "$DEV_ID" "$APP"
+
+echo "==> Verifying"
+codesign --verify --strict --verbose=2 "$APP"
+echo "✓ signed: $APP"
diff --git a/scripts/sign-notarize.sh b/scripts/sign-notarize.sh
index be65729..38787db 100755
--- a/scripts/sign-notarize.sh
+++ b/scripts/sign-notarize.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Build (universal), codesign (Hardened Runtime + Developer ID), notarize, and
-# staple GrammaGem.app. Requires the Apple Developer Program ($99/yr) — a FIXED
+# staple GrammarGem.app. Requires the Apple Developer Program ($99/yr) — a FIXED
# cost, not per-user.
#
# Credentials are read from the environment or a stored keychain profile — never
@@ -15,19 +15,15 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
DEV_ID="${DEV_ID:-Developer ID Application: Marc Schultheiss (D5KT5B9Z9M)}"
-APP="dist/GrammaGem.app"
-ZIP="dist/GrammaGem.zip"
-ENTITLEMENTS="AppSupport/GrammaGem.entitlements"
+APP="dist/GrammarGem.app"
+ZIP="dist/GrammarGem.zip"
+ENTITLEMENTS="AppSupport/GrammarGem.entitlements"
echo "==> Building universal bundle"
./scripts/build.sh >/dev/null
-echo "==> Codesigning (Hardened Runtime)"
-codesign --force --timestamp --options runtime \
- --entitlements "$ENTITLEMENTS" \
- --sign "$DEV_ID" \
- "$APP"
-codesign --verify --strict --verbose=2 "$APP"
+echo "==> Codesigning (Hardened Runtime, inside-out incl. Sparkle)"
+DEV_ID="$DEV_ID" ./scripts/sign-app.sh "$APP"
echo "==> Zipping"
ditto -c -k --keepParent "$APP" "$ZIP"