grammargem/Sources/GrammarGem/System/TextReplacementCoordinator.swift
BigMarc 201fe04d8a 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>
2026-06-20 20:21:29 +02:00

133 lines
5.4 KiB
Swift

import Foundation
/// Orchestrates the end-to-end system-wide loop:
/// capture selected text → gate by entitlement → run an engine → write it back.
///
/// `fix` uses Layer 1 (Harper grammar — free, unlimited, instant).
/// `ask` and Mode rewrites use Layer 2 (local LLM — gated + counted on free tier).
@MainActor
final class TextReplacementCoordinator {
private let capture: TextCapture
private let grammar: GrammarEngine
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,
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).
func handleFix() async -> ProcessOutcome {
let cap: TextCapture.Capture
do {
cap = try await captureOffMain()
} catch TextCapture.CaptureError.noSelection {
return .noSelection
} catch {
return .failed(error.localizedDescription)
}
let corrected = grammar.correct(cap.text)
guard corrected != cap.text else { return .replaced(corrected) }
let ok = await replaceOffMain(corrected, cap)
return ok ? .replaced(corrected) : .failed("Couldn't write the correction back.")
}
/// The "Ask" hotkey/popover — a local-LLM instruction over the selection.
func handleAsk(_ instruction: String) async -> ProcessOutcome {
let action = AIAction.ask(instruction)
if case .denied(let reason) = gate.authorizeAI(action) {
return .blockedByEntitlement(reason)
}
guard ai.isReady else { return .failed(AIEngineError.modelNotReady.localizedDescription) }
let cap: TextCapture.Capture
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
do {
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.")
} catch {
return .failed(error.localizedDescription)
}
}
/// Apply a Writing Mode rewrite to the selection (paid; free tier only Polish).
func handleApplyMode(_ mode: WritingMode) async -> ProcessOutcome {
let action = AIAction.applyMode(mode)
if case .denied(let reason) = gate.authorizeAI(action) {
return .blockedByEntitlement(reason)
}
guard ai.isReady else { return .failed(AIEngineError.modelNotReady.localizedDescription) }
let cap: TextCapture.Capture
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
do {
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.")
} catch {
return .failed(error.localizedDescription)
}
}
/// Grammar-fix the entire focused text field (used by the live monitor's
/// "Fix all" — Layer 1, always free, no selection required).
func handleFixFocusedField() async -> ProcessOutcome {
let field = await Task.detached(priority: .userInitiated) { [capture] in
capture.focusedFieldText()
}.value
guard let field else { return .noSelection }
let corrected = grammar.correct(field.text)
guard corrected != field.text else { return .replaced(corrected) }
let element = field.element
let ok = await Task.detached(priority: .userInitiated) { [capture] in
capture.setFocusedField(corrected, element: element)
}.value
return ok ? .replaced(corrected) : .failed("Couldn't update the text field.")
}
/// Milestone-1 proof: capture → UPPERCASE → replace, end-to-end, no engine.
func handleDebugUppercase() async -> ProcessOutcome {
let cap: TextCapture.Capture
do { cap = try await captureOffMain() } catch { return .failed(error.localizedDescription) }
let out = cap.text.uppercased()
let ok = await replaceOffMain(out, cap)
return ok ? .replaced(out) : .failed("Replace failed.")
}
// MARK: - Off-main wrappers (capture/replace block on synthetic keystrokes)
private func captureOffMain() async throws -> TextCapture.Capture {
let capture = self.capture
return try await Task.detached(priority: .userInitiated) {
try capture.capture()
}.value
}
private func replaceOffMain(_ text: String, _ cap: TextCapture.Capture) async -> Bool {
let capture = self.capture
return await Task.detached(priority: .userInitiated) {
capture.replace(text, capture: cap)
}.value
}
}