feat: backend-neutral AI seam, Ollama backend, extension bridge + trust/correctness fixes

Wave 1 (correctness/trust):
- Fix paid large-model dead path: engine reads the persisted selected repo via a
  thread-safe ModelManager.activeRepo(), so download/load/isReady now agree.
- Lemon Squeezy store/product check fails closed in release builds — the
  placeholder-ID 'any key unlocks paid' bypass can no longer ship.
- Replace force-unwrapped Hugging Face URLs with safe URLComponents construction.
- Never read/replace/scan secure (password) fields via AX.isSecureTextField.

Wave 2 (compounding seam):
- Extract AIPromptKit (prompts/temperature/maxTokens/cleanup) out of MLXEngine so
  every backend produces identical prompts and output.
- AIEngine gains protectedTerms (personal dictionary now actually injected into the
  LLM), warmup(), and runStreaming(); add makeAIEngine() factory.
- Honor WritingMode.lengthCap for maxTokens; warm the model on launch/after download.
- Guard custom modes against empty prompts and duplicate names.

Wave 3: OllamaEngine — a URLSession-only AIEngine backend (cross-platform, opt-in
via GRAMMARGEM_AI_BACKEND=ollama).

Wave 4: LocalEngineServer — loopback-only, token-authed HTTP bridge (Network.framework,
no new deps), off by default, exposing Harper + the LLM + the entitlement gate to a
browser extension.

Tests: 6 new unit tests for the AIPrompts seam and custom-mode guard (19 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BigMarc 2026-06-20 20:21:29 +02:00
parent e5b0977abf
commit 201fe04d8a
16 changed files with 900 additions and 98 deletions

View File

@ -10,11 +10,52 @@ protocol AIEngine: AnyObject {
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
/// `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<String, Error>
}
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<String, Error> {
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<String, Error> {
runStreaming(action, on: text, protectedTerms: [])
}
}
enum AIEngineError: LocalizedError {

View File

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

View File

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

View File

@ -33,33 +33,63 @@ final class MLXEngine: AIEngine {
// MARK: - AIEngine
func run(_ action: AIAction, on text: String) async throws -> String {
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: temperature(for: action))
let params = GenerateParameters(temperature: AIPrompts.temperature(for: action))
let raw = try await complete(
system: systemPrompt(for: action), user: trimmed,
maxTokens: 512, parameters: params, container: container)
return clean(raw)
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<String, Error> {
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 }
let options = Tone.allCases.map(\.rawValue).joined(separator: ", ")
let system = """
You are a tone classifier. Read the user's text and respond with exactly ONE \
word — the single best-matching tone from this list: \(options). \
Output only that word, lowercase, no punctuation or explanation.
"""
do {
let container = try await container()
let raw = try await complete(
system: system, user: trimmed, maxTokens: 8,
system: AIPrompts.toneClassifierPrompt(), user: trimmed, maxTokens: 8,
parameters: GenerateParameters(temperature: 0.0), container: container)
return parseTone(raw)
return AIPrompts.parseTone(raw)
} catch {
return .professional
}
@ -98,64 +128,9 @@ final class MLXEngine: AIEngine {
}
}
// MARK: - Prompting
private func systemPrompt(for action: AIAction) -> String {
switch action {
case .rewriteClarity:
return "Rewrite the user's text to be clearer and more concise. Preserve the "
+ "original meaning and the author's voice. Output only the rewritten text."
case .rewrite:
return "Paraphrase the user's text. Keep the meaning but vary the wording and "
+ "structure. Output only the rewritten text."
case .adjustTone(let tone):
return "Rewrite the user's text in a \(tone.rawValue) tone. Preserve the meaning. "
+ "Output only the rewritten text."
case .ask(let instruction):
return "Apply this instruction to the user's text: \(instruction). "
+ "Output only the resulting text."
case .translate(let language):
return "Translate the user's text into \(language). Output only the translation."
case .applyMode(let mode):
return mode.systemPrompt
}
}
/// Low temperature for faithful rewrites; a touch higher for free paraphrase.
private func temperature(for action: AIAction) -> Float {
switch action {
case .rewrite: return 0.5
case .ask: return 0.4
default: return 0.2
}
}
// MARK: - Output cleanup
private func clean(_ s: String) -> String {
var out = s.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = out.lowercased()
for prefix in ["sure, here", "sure! here", "here is", "here's", "certainly"] {
if lower.hasPrefix(prefix), let colon = out.firstIndex(of: ":") {
let after = out[out.index(after: colon)...]
.trimmingCharacters(in: .whitespacesAndNewlines)
if !after.isEmpty { out = after }
break
}
}
if out.count >= 2, out.first == "\"", out.last == "\"" {
out = String(out.dropFirst().dropLast())
}
return out.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func parseTone(_ raw: String) -> Tone {
let token = raw.lowercased().trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
if let exact = Tone(rawValue: token) { return exact }
let lower = raw.lowercased()
for tone in Tone.allCases where lower.contains(tone.rawValue) { return tone }
return .professional
}
// 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

View File

@ -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<Void, Never>?
private var generation = 0
@ -55,6 +57,33 @@ final class ModelManager: ObservableObject {
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
@ -92,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)
@ -148,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")
@ -166,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)."
}
}
}

View File

@ -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<String, Error> {
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<T: Encodable>(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 }
}
}

View File

@ -30,6 +30,8 @@ final class AppState: ObservableObject {
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"
@ -48,9 +50,9 @@ final class AppState: ObservableObject {
grammar = engine
// The on-device LLM loads weights from ModelManager's local snapshot (if a
// complete download is present) — never the network. Resolved lazily on use.
ai = MLXEngine(modelDirectoryProvider: {
ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo)
})
// 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()
@ -67,8 +69,29 @@ final class AppState: ObservableObject {
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).
@ -101,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
@ -115,9 +147,57 @@ final class AppState: ObservableObject {
}
Task { await license.validateIfNeeded() }
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)
})
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)
}
}
// MARK: - Hotkey handling
/// (Re)register the global hotkeys from the current preferences.

View File

@ -25,7 +25,7 @@ enum MLXSelfTest {
static func run() {
let sample = "me and him was going to the store for to buy some milks"
let engine = MLXEngine(modelDirectoryProvider: {
ModelManager.completedModelDirectory(repo: AppConfig.Model.defaultRepo)
ModelManager.completedModelDirectory(repo: ModelManager.activeRepo())
})
guard engine.isReady else {
FileHandle.standardError.write(Data("SELFTEST_FAIL: model not present\n".utf8))

View File

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

View File

@ -58,16 +58,24 @@ final class LemonSqueezyClient {
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).

View File

@ -38,6 +38,17 @@ enum AX {
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.
///

View File

@ -0,0 +1,266 @@
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
private let port: UInt16
private let token: String
private let grammarHandler: GrammarHandler
private let aiHandler: AIHandler
private var listener: NWListener?
private let queue = DispatchQueue(label: "com.grammargem.localserver")
init(port: UInt16, token: String,
grammarHandler: @escaping GrammarHandler,
aiHandler: @escaping AIHandler) {
self.port = port
self.token = token
self.grammarHandler = grammarHandler
self.aiHandler = aiHandler
}
/// 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))
}
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<T: Encodable>(_ conn: NWConnection, status: Int, _ value: T) {
let body = (try? JSONEncoder().encode(value)) ?? Data("{}".utf8)
send(conn, status: status, body: body)
}
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[..<headerEnd.lowerBound], encoding: .utf8) else { return nil }
var lines = headerString.components(separatedBy: "\r\n")
guard let requestLine = lines.first else { return nil }
let parts = requestLine.split(separator: " ")
guard parts.count >= 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[..<colon].trimmingCharacters(in: .whitespaces).lowercased()
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
hdrs[key] = value
}
headers = hdrs
let contentLength = Int(hdrs["content-length"] ?? "0") ?? 0
let available = raw[headerEnd.upperBound...]
body = Data(available.prefix(contentLength))
isComplete = body.count >= contentLength
}
}

View File

@ -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 "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,9 +60,8 @@ final class TextCapture {
throw CaptureError.noSelection
}
private func captureViaAccessibility() -> Capture? {
guard AXIsProcessTrusted() else { return nil }
guard let focused = AX.focusedElement() else { return nil }
private func captureViaAccessibility(_ focused: AXUIElement?) -> Capture? {
guard let focused else { return nil }
var valueRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
@ -92,6 +100,7 @@ final class TextCapture {
func focusedFieldText() -> (text: String, element: AXUIElement)? {
guard AXIsProcessTrusted() 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.

View File

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

View File

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

View File

@ -149,6 +149,66 @@ final class GrammarGemTests: XCTestCase {
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() {