diff --git a/docs/superpowers/plans/2026-10-01-app-1-engine-hooks.md b/docs/superpowers/plans/2026-10-01-app-1-engine-hooks.md new file mode 100644 index 0000000..9716a51 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-app-1-engine-hooks.md @@ -0,0 +1,1101 @@ +# App fase 1 — Engine-raakpunten Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** De engine krijgt vier additieve uitbreidingen (woordenlijst voor de transcriber, niveau-uitvoer, resultaat-hook met correctie, instelbare sneltoets) zonder dat bestaand gedrag verandert. + +**Architecture:** Alle wijzigingen zijn additief met standaardwaarden die het huidige gedrag behouden. Nieuwe pure logica (`PromptEcho`, `AudioLevel`, `HotkeyBinding`, `ModifierKeyTracker`, `promptTokens`) staat los van hardware en wordt met unit-tests gedekt; de hardware-afhankelijke stukken (`AudioRecorder`, `HotkeyMonitor`, WhisperKit) zijn dun en worden handmatig gecontroleerd. + +**Tech Stack:** Swift 6.4 (language mode 5), Swift Testing, AVFoundation, CoreGraphics, WhisperKit 0.18. + +**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (secties "Engine-raakpunten", "Woordenboek — mechanisme 1") + +## Global Constraints + +- Bestaande tests (13 stuks) blijven ongewijzigd groen; bestaande testbestanden worden alleen *aangevuld* (`Fakes.swift`) of krijgen er een nieuw bestand naast. +- Nieuwe parameters hebben standaardwaarden die het huidige gedrag behouden. +- Vocabulaire: maximaal 64 tokens in de prompt; prompt alleen bij opnames met RMS ≥ 0,015; stiltefilter blijft 0,005. +- Prompt-echo: resultaat dat uitsluitend uit de eerste *k* ≥ 2 termen van de prompt bestaat wordt verworpen; één term alleen niet. +- Niveau: `onLevel` levert RMS in dBFS; 0 VU = −20 dBFS. +- Toegestane sneltoetsen (exact): Fn (keycode 63), Rechter Option (61), Rechter Command (54), Rechter Control (62), Linker Control (59). +- Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 ` (tweede `-m`). + +## Bestandsstructuur + +``` +Sources/SstCore/Models.swift (wijzig) Transcriber + AppliedCorrection, CorrectionResult, TextCorrecting, DictationResult +Sources/SstCore/PromptEcho.swift (nieuw) +Sources/SstCore/DictationController.swift(vervang) corrector, onResult, vocabularyProvider +Sources/SstCore/HotkeyBinding.swift (nieuw) +Sources/SstCore/ModifierKeyTracker.swift (nieuw) +Sources/SstCore/HotkeyMonitor.swift (vervang) binding +Sources/SstCore/AudioLevel.swift (nieuw) +Sources/SstCore/AudioRecorder.swift (wijzig) onLevel +Sources/SstWhisper/WhisperKitTranscriber.swift (wijzig) vocabulary → promptTokens +Sources/SstProbe/RecordProbe.swift, TranscribeProbe.swift (wijzig) handmatige controle +Package.swift (wijzig) SstWhisperTests +Tests/SstCoreTests/Fakes.swift (aanvullen) +Tests/SstCoreTests/TranscriberVocabularyTests.swift, PromptEchoTests.swift, + DictationControllerHooksTests.swift, HotkeyBindingTests.swift, + ModifierKeyTrackerTests.swift, AudioLevelTests.swift (nieuw) +Tests/SstWhisperTests/PromptTokensTests.swift (nieuw) +``` + +--- + +### Task 1: Gedeelde typen en woordenlijst op `Transcriber` + +**Files:** +- Modify: `Sources/SstCore/Models.swift` +- Test: `Tests/SstCoreTests/TranscriberVocabularyTests.swift` + +**Interfaces:** +- Produces (gebruikt door alle latere fasen): + - `Transcriber.transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String` (protocol-eis, met standaardimplementatie die `transcribe(_:)` aanroept) + - `struct AppliedCorrection: Equatable, Codable, Sendable { heard, wrote, rule: String; start, length: Int }` met publieke init `init(heard:wrote:rule:start:length:)` + - `struct CorrectionResult: Equatable, Sendable { text: String; corrections: [AppliedCorrection] }` met publieke init `init(text:corrections:)` + - `protocol TextCorrecting { func correct(_ text: String) -> CorrectionResult }` + - `struct DictationResult: Equatable, Sendable { date: Date; duration: TimeInterval; rawText: String; finalText: String; corrections: [AppliedCorrection] }` met publieke init in die volgorde + +- [ ] **Step 1: Schrijf de falende test** + +`Tests/SstCoreTests/TranscriberVocabularyTests.swift`: + +```swift +import Testing +@testable import SstCore + +private struct PlainTranscriber: Transcriber { + func transcribe(_ samples: [Float]) async throws -> String { "plain \(samples.count)" } +} + +struct TranscriberVocabularyTests { + @Test func vocabularyVariantFallsBackToPlainTranscribe() async throws { + let result = try await PlainTranscriber().transcribe([0, 0, 0], vocabulary: ["Anthropic"]) + #expect(result == "plain 3") + } + + @Test func appliedCorrectionRoundTripsThroughJSON() throws { + let original = AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 12, length: 11) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(AppliedCorrection.self, from: data) + #expect(decoded == original) + } +} +``` + +- [ ] **Step 2: Draai de test, moet falen** + +Run: `swift test --filter TranscriberVocabularyTests` +Expected: compilatiefout `extra argument 'vocabulary' in call` / `cannot find 'AppliedCorrection' in scope`. + +- [ ] **Step 3: Implementatie** + +In `Sources/SstCore/Models.swift` vervang het blok + +```swift +public protocol Transcriber { + func transcribe(_ samples: [Float]) async throws -> String +} +``` + +door: + +```swift +public protocol Transcriber { + func transcribe(_ samples: [Float]) async throws -> String + /// Biases decoding towards `vocabulary`. Engines without bias support ignore the list. + func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String +} + +public extension Transcriber { + func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + try await transcribe(samples) + } +} + +/// One replacement made by the dictionary correction pass. +/// `start`/`length` are UTF-16 offsets into the corrected text. +public struct AppliedCorrection: Equatable, Codable, Sendable { + public var heard: String + public var wrote: String + public var rule: String + public var start: Int + public var length: Int + + public init(heard: String, wrote: String, rule: String, start: Int, length: Int) { + self.heard = heard + self.wrote = wrote + self.rule = rule + self.start = start + self.length = length + } +} + +public struct CorrectionResult: Equatable, Sendable { + public var text: String + public var corrections: [AppliedCorrection] + + public init(text: String, corrections: [AppliedCorrection]) { + self.text = text + self.corrections = corrections + } +} + +public protocol TextCorrecting { + func correct(_ text: String) -> CorrectionResult +} + +/// A finished dictation, emitted just before the text is inserted. +public struct DictationResult: Equatable, Sendable { + public var date: Date + public var duration: TimeInterval + public var rawText: String + public var finalText: String + public var corrections: [AppliedCorrection] + + public init(date: Date, duration: TimeInterval, rawText: String, finalText: String, corrections: [AppliedCorrection]) { + self.date = date + self.duration = duration + self.rawText = rawText + self.finalText = finalText + self.corrections = corrections + } +} +``` + +- [ ] **Step 4: Draai alle tests** + +Run: `swift test` +Expected: alle tests PASS (13 bestaande + 2 nieuwe). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstCore/Models.swift Tests/SstCoreTests/TranscriberVocabularyTests.swift +git commit -m "feat: add vocabulary-aware Transcriber and shared correction/result types" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: `DictationController` — voorrang, echo-filter, correctie en resultaat-hook + +**Files:** +- Create: `Sources/SstCore/PromptEcho.swift` +- Replace: `Sources/SstCore/DictationController.swift` +- Modify: `Tests/SstCoreTests/Fakes.swift` (aanvullen onderaan) +- Test: `Tests/SstCoreTests/PromptEchoTests.swift`, `Tests/SstCoreTests/DictationControllerHooksTests.swift` + +**Interfaces:** +- Consumes: Task 1 (`Transcriber.transcribe(_:vocabulary:)`, `TextCorrecting`, `CorrectionResult`, `DictationResult`). +- Produces: + - `PromptEcho.isEcho(_ text: String, vocabulary: [String]) -> Bool` + - `DictationController.init(recorder:transcriber:cleaner:inserter:minimumSamples:silenceThreshold:corrector:vocabularyProvider:biasMinimumRMS:)` — de nieuwe parameters zijn `corrector: TextCorrecting? = nil`, `vocabularyProvider: @escaping () -> [String] = { [] }`, `biasMinimumRMS: Float = 0.015` + - `DictationController.onResult: ((DictationResult) -> Void)?` + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstCoreTests/PromptEchoTests.swift`: + +```swift +import Testing +@testable import SstCore + +struct PromptEchoTests { + let vocabulary = ["Anthropic", "Vercel", "Supabase"] + + @Test func firstTwoTermsAreAnEcho() { + #expect(PromptEcho.isEcho("Anthropic, Vercel.", vocabulary: vocabulary)) + } + + @Test func wholeListIsAnEcho() { + #expect(PromptEcho.isEcho("anthropic vercel supabase", vocabulary: vocabulary)) + } + + @Test func singleTermIsRealSpeech() { + #expect(!PromptEcho.isEcho("Anthropic", vocabulary: vocabulary)) + } + + @Test func termsOutOfOrderAreNotAnEcho() { + #expect(!PromptEcho.isEcho("Vercel, Anthropic", vocabulary: vocabulary)) + } + + @Test func sentenceContainingTermsIsNotAnEcho() { + #expect(!PromptEcho.isEcho("Ik gebruik Anthropic en Vercel", vocabulary: vocabulary)) + } + + @Test func tooShortVocabularyNeverEchoes() { + #expect(!PromptEcho.isEcho("Anthropic", vocabulary: ["Anthropic"])) + #expect(!PromptEcho.isEcho("", vocabulary: vocabulary)) + } +} +``` + +Voeg onderaan `Tests/SstCoreTests/Fakes.swift` toe: + +```swift + +final class FakeVocabularyTranscriber: Transcriber { + var result = "hallo wereld" + private(set) var vocabularies: [[String]] = [] + + func transcribe(_ samples: [Float]) async throws -> String { result } + + func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + vocabularies.append(vocabulary) + return result + } +} + +struct UppercaseCorrector: TextCorrecting { + func correct(_ text: String) -> CorrectionResult { + let upper = text.uppercased() + return CorrectionResult( + text: upper, + corrections: [AppliedCorrection(heard: text, wrote: upper, rule: "upper", start: 0, length: upper.utf16.count)] + ) + } +} +``` + +`Tests/SstCoreTests/DictationControllerHooksTests.swift`: + +```swift +import Foundation +import Testing +@testable import SstCore + +@MainActor +struct DictationControllerHooksTests { + let recorder = FakeRecorder() + let transcriber = FakeVocabularyTranscriber() + let inserter = FakeInserter() + static let loud = [Float](repeating: 0.1, count: 16_000) + static let quiet = [Float](repeating: 0.01, count: 16_000) + + func makeController( + cleaner: Cleaner = FakeCleaner(), + corrector: TextCorrecting? = nil, + vocabulary: [String] = [] + ) -> DictationController { + DictationController( + recorder: recorder, + transcriber: transcriber, + cleaner: cleaner, + inserter: inserter, + corrector: corrector, + vocabularyProvider: { vocabulary } + ) + } + + func dictate(_ controller: DictationController) async { + await controller.handle(.pressed) + await controller.handle(.released) + } + + @Test func loudRecordingPassesVocabularyToTranscriber() async { + recorder.samples = Self.loud + await dictate(makeController(vocabulary: ["Anthropic", "Vercel"])) + #expect(transcriber.vocabularies == [["Anthropic", "Vercel"]]) + } + + @Test func quietRecordingSkipsVocabularyButStillTranscribes() async { + recorder.samples = Self.quiet // RMS 0.01: above silence (0.005), below bias minimum (0.015) + await dictate(makeController(vocabulary: ["Anthropic", "Vercel"])) + #expect(transcriber.vocabularies == [[]]) + #expect(inserter.inserted == ["[hallo wereld]"]) + } + + @Test func promptEchoIsDiscarded() async { + recorder.samples = Self.loud + transcriber.result = "Anthropic, Vercel." + let controller = makeController(vocabulary: ["Anthropic", "Vercel", "Supabase"]) + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + + await dictate(controller) + + #expect(inserter.inserted.isEmpty) + #expect(results.isEmpty) + #expect(controller.state == .idle) + } + + @Test func singleVocabularyTermIsKept() async { + recorder.samples = Self.loud + transcriber.result = "Anthropic" + await dictate(makeController(vocabulary: ["Anthropic", "Vercel", "Supabase"])) + #expect(inserter.inserted == ["[Anthropic]"]) + } + + @Test func correctorRunsBeforeCleanerAndCorrectionsSurviveAPassthroughCleaner() async { + recorder.samples = Self.loud + transcriber.result = "hallo" + let controller = makeController(cleaner: PassthroughCleaner(), corrector: UppercaseCorrector()) + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + + await dictate(controller) + + #expect(inserter.inserted == ["HALLO"]) + #expect(results.count == 1) + #expect(results[0].rawText == "hallo") + #expect(results[0].finalText == "HALLO") + #expect(results[0].corrections.count == 1) + } + + @Test func correctionsAreDroppedWhenTheCleanerChangesTheText() async { + recorder.samples = Self.loud + transcriber.result = "hallo" + let controller = makeController(cleaner: FakeCleaner(), corrector: UppercaseCorrector()) + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + + await dictate(controller) + + #expect(results[0].finalText == "[HALLO]") + #expect(results[0].corrections.isEmpty) + } + + @Test func resultIsReportedEvenWhenInsertionFails() async { + recorder.samples = Self.loud + inserter.error = TestError() + let controller = makeController() + var results: [DictationResult] = [] + var errors: [String] = [] + controller.onResult = { results.append($0) } + controller.onError = { errors.append($0) } + + await dictate(controller) + + #expect(results.count == 1) + #expect(errors.count == 1) + } + + @Test func resultCarriesDurationAndDate() async { + recorder.samples = Self.loud // 16 000 samples = 1 s + let controller = makeController() + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + let before = Date() + + await dictate(controller) + + #expect(results[0].duration == 1.0) + #expect(results[0].date >= before) + #expect(results[0].corrections.isEmpty) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "PromptEchoTests|DictationControllerHooksTests"` +Expected: compilatiefouten (`cannot find 'PromptEcho'`, `extra argument 'corrector'`). + +- [ ] **Step 3: Implementatie** + +`Sources/SstCore/PromptEcho.swift`: + +```swift +import Foundation + +/// Whisper sometimes answers a vocabulary prompt with the prompt itself when there is little speech. +public enum PromptEcho { + static func normalize(_ text: String) -> String { + String(text.lowercased().map { $0.isLetter || $0.isNumber ? $0 : " " }) + .split(separator: " ") + .joined(separator: " ") + } + + /// True when `text` is nothing but the first k ≥ 2 terms of the prompt. + /// A single term on its own is valid speech ("Anthropic"). + public static func isEcho(_ text: String, vocabulary: [String]) -> Bool { + guard vocabulary.count >= 2 else { return false } + let normalized = normalize(text) + guard !normalized.isEmpty else { return false } + return (2...vocabulary.count).contains { k in + normalize(vocabulary.prefix(k).joined(separator: " ")) == normalized + } + } +} +``` + +Vervang `Sources/SstCore/DictationController.swift` volledig door: + +```swift +import Foundation + +@MainActor +public final class DictationController { + public private(set) var state: DictationState = .idle { + didSet { onStateChange?(state) } + } + public var onStateChange: ((DictationState) -> Void)? + public var onError: ((String) -> Void)? + /// Called with every finished transcription, just before it is inserted. + public var onResult: ((DictationResult) -> Void)? + + private let recorder: AudioRecording + private let transcriber: Transcriber + private let cleaner: Cleaner + private let inserter: TextInserting + private let minimumSamples: Int + private let silenceThreshold: Float + private let corrector: TextCorrecting? + private let vocabularyProvider: () -> [String] + private let biasMinimumRMS: Float + + /// - Parameter minimumSamples: 16 kHz samples; default is 0.3 s. + /// - Parameter silenceThreshold: RMS below which a recording counts as silence and is skipped. + /// - Parameter corrector: optional dictionary correction applied to the transcript. + /// - Parameter vocabularyProvider: words to bias the transcriber towards. + /// - Parameter biasMinimumRMS: below this RMS no vocabulary is passed (guards against invented text on quiet audio). + public init( + recorder: AudioRecording, + transcriber: Transcriber, + cleaner: Cleaner, + inserter: TextInserting, + minimumSamples: Int = 4_800, + silenceThreshold: Float = 0.005, + corrector: TextCorrecting? = nil, + vocabularyProvider: @escaping () -> [String] = { [] }, + biasMinimumRMS: Float = 0.015 + ) { + self.recorder = recorder + self.transcriber = transcriber + self.cleaner = cleaner + self.inserter = inserter + self.minimumSamples = minimumSamples + self.silenceThreshold = silenceThreshold + self.corrector = corrector + self.vocabularyProvider = vocabularyProvider + self.biasMinimumRMS = biasMinimumRMS + } + + public func handle(_ event: HotkeyEvent) async { + switch (event, state) { + case (.pressed, .idle): + do { + try recorder.start() + state = .recording + } catch { + onError?(error.localizedDescription) + } + case (.released, .recording): + let samples = recorder.stop() + guard samples.count >= minimumSamples else { + state = .idle + return + } + let energy = samples.reduce(0) { $0 + $1 * $1 } + let rms = (energy / Float(samples.count)).squareRoot() + guard rms >= silenceThreshold else { + state = .idle + return + } + await process(samples, rms: rms) + default: + break + } + } + + private func process(_ samples: [Float], rms: Float) async { + defer { state = .idle } + do { + state = .transcribing + let vocabulary = rms >= biasMinimumRMS ? vocabularyProvider() : [] + let raw = try await transcriber.transcribe(samples, vocabulary: vocabulary) + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !PromptEcho.isEcho(text, vocabulary: vocabulary) else { return } + let corrected = corrector?.correct(text) ?? CorrectionResult(text: text, corrections: []) + let cleaned = try await cleaner.clean(corrected.text, context: CleaningContext()) + onResult?(DictationResult( + date: Date(), + duration: Double(samples.count) / 16_000, + rawText: text, + finalText: cleaned, + // Offsets only stay valid if the cleaner left the corrected text alone. + corrections: cleaned == corrected.text ? corrected.corrections : [] + )) + state = .inserting + try await inserter.insert(cleaned) + } catch { + onError?(error.localizedDescription) + } + } +} +``` + +- [ ] **Step 4: Draai alle tests** + +Run: `swift test` +Expected: alle tests PASS (de 13 + 2 + 6 + 8 nieuwe = 29). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstCore Tests/SstCoreTests +git commit -m "feat: controller supports vocabulary bias, prompt-echo guard, corrector and result hook" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 3: Instelbare sneltoets — `HotkeyBinding`, `ModifierKeyTracker`, `HotkeyMonitor` + +**Files:** +- Create: `Sources/SstCore/HotkeyBinding.swift`, `Sources/SstCore/ModifierKeyTracker.swift` +- Replace: `Sources/SstCore/HotkeyMonitor.swift` +- Test: `Tests/SstCoreTests/HotkeyBindingTests.swift`, `Tests/SstCoreTests/ModifierKeyTrackerTests.swift` + +**Interfaces:** +- Consumes: `HotkeyEvent` uit Models.swift. +- Produces: + - `struct HotkeyBinding: Hashable, Sendable, Identifiable` met `id: String`, `displayName: String`, `keyCode: Int`, `flagRawValue: UInt64`, `flag: CGEventFlags`; statics `.fn`, `.rightOption`, `.rightCommand`, `.rightControl`, `.leftControl`, `allowed: [HotkeyBinding]`, `binding(id:)`, `binding(keyCode:)` + - `struct ModifierKeyTracker { init(); mutating func process(isDown: Bool) -> HotkeyEvent? }` + - `HotkeyMonitor.binding: HotkeyBinding` (standaard `.fn`; wisselen herstelt de tracker) + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstCoreTests/HotkeyBindingTests.swift`: + +```swift +import Testing +@testable import SstCore + +struct HotkeyBindingTests { + @Test func allowedBindingsMatchTheSpec() { + #expect(HotkeyBinding.allowed.map(\.id) == ["fn", "rightOption", "rightCommand", "rightControl", "leftControl"]) + #expect(HotkeyBinding.allowed.map(\.keyCode) == [63, 61, 54, 62, 59]) + } + + @Test func idsAndKeyCodesAreUnique() { + #expect(Set(HotkeyBinding.allowed.map(\.id)).count == HotkeyBinding.allowed.count) + #expect(Set(HotkeyBinding.allowed.map(\.keyCode)).count == HotkeyBinding.allowed.count) + } + + @Test func lookupByKeyCodeAndId() { + #expect(HotkeyBinding.binding(keyCode: 61) == .rightOption) + #expect(HotkeyBinding.binding(id: "leftControl") == .leftControl) + } + + @Test func unsupportedKeysAreRejected() { + #expect(HotkeyBinding.binding(keyCode: 56) == nil) // left shift + #expect(HotkeyBinding.binding(keyCode: 57) == nil) // caps lock + #expect(HotkeyBinding.binding(id: "nope") == nil) + } + + @Test func everyBindingHasAFlagAndName() { + for binding in HotkeyBinding.allowed { + #expect(binding.flagRawValue != 0) + #expect(!binding.displayName.isEmpty) + } + } +} +``` + +`Tests/SstCoreTests/ModifierKeyTrackerTests.swift`: + +```swift +import Testing +@testable import SstCore + +struct ModifierKeyTrackerTests { + @Test func emitsPressedThenReleasedOnlyOnTransitions() { + var tracker = ModifierKeyTracker() + #expect(tracker.process(isDown: false) == nil) + #expect(tracker.process(isDown: true) == .pressed) + #expect(tracker.process(isDown: true) == nil) + #expect(tracker.process(isDown: false) == .released) + #expect(tracker.process(isDown: false) == nil) + } + + @Test func freshTrackerStartsReleased() { + var tracker = ModifierKeyTracker() + #expect(tracker.process(isDown: true) == .pressed) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "HotkeyBindingTests|ModifierKeyTrackerTests"` +Expected: compilatiefout `cannot find 'HotkeyBinding' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstCore/ModifierKeyTracker.swift`: + +```swift +public struct ModifierKeyTracker { + private var isDown = false + + public init() {} + + /// Feed the current state of the bound modifier flag; returns an event only on a transition. + public mutating func process(isDown: Bool) -> HotkeyEvent? { + guard isDown != self.isDown else { return nil } + self.isDown = isDown + return isDown ? .pressed : .released + } +} +``` + +`Sources/SstCore/HotkeyBinding.swift`: + +```swift +import CoreGraphics + +/// A modifier key that can be held to dictate. Left/right keys share a flag, so the key code decides. +public struct HotkeyBinding: Hashable, Sendable, Identifiable { + public let id: String + public let displayName: String + public let keyCode: Int + public let flagRawValue: UInt64 + + public var flag: CGEventFlags { CGEventFlags(rawValue: flagRawValue) } + + public static let fn = HotkeyBinding( + id: "fn", displayName: "Fn", keyCode: 63, flagRawValue: CGEventFlags.maskSecondaryFn.rawValue) + public static let rightOption = HotkeyBinding( + id: "rightOption", displayName: "Rechter Option", keyCode: 61, flagRawValue: CGEventFlags.maskAlternate.rawValue) + public static let rightCommand = HotkeyBinding( + id: "rightCommand", displayName: "Rechter Command", keyCode: 54, flagRawValue: CGEventFlags.maskCommand.rawValue) + public static let rightControl = HotkeyBinding( + id: "rightControl", displayName: "Rechter Control", keyCode: 62, flagRawValue: CGEventFlags.maskControl.rawValue) + public static let leftControl = HotkeyBinding( + id: "leftControl", displayName: "Linker Control", keyCode: 59, flagRawValue: CGEventFlags.maskControl.rawValue) + + public static let allowed: [HotkeyBinding] = [.fn, .rightOption, .rightCommand, .rightControl, .leftControl] + + public static func binding(id: String) -> HotkeyBinding? { allowed.first { $0.id == id } } + public static func binding(keyCode: Int) -> HotkeyBinding? { allowed.first { $0.keyCode == keyCode } } +} +``` + +Vervang `Sources/SstCore/HotkeyMonitor.swift` volledig door: + +```swift +import ApplicationServices +import CoreGraphics + +public final class HotkeyMonitor { + public enum HotkeyError: Error, LocalizedError { + case tapUnavailable + + public var errorDescription: String? { + "Kan de sneltoets niet volgen: geef Input Monitoring-rechten in Systeeminstellingen en probeer opnieuw (soms is een herstart van de app nodig)." + } + } + + public var onEvent: ((HotkeyEvent) -> Void)? + + /// The key that has to be held. Changing it resets the press tracking. + public var binding: HotkeyBinding = .fn { + didSet { tracker = ModifierKeyTracker() } + } + + private var tracker = ModifierKeyTracker() + private var tap: CFMachPort? + private var source: CFRunLoopSource? + + public init() {} + + public func start() throws { + guard tap == nil else { return } + let mask = CGEventMask(1 << CGEventType.flagsChanged.rawValue) + let callback: CGEventTapCallBack = { _, type, event, refcon in + if let refcon { + let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + monitor.handle(type: type, event: event) + } + return Unmanaged.passUnretained(event) + } + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: mask, + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { throw HotkeyError.tapUnavailable } + + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + self.tap = tap + self.source = source + } + + public func stop() { + if let source { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) } + if let tap { CGEvent.tapEnable(tap: tap, enable: false) } + tap = nil + source = nil + } + + private func handle(type: CGEventType, event: CGEvent) { + switch type { + case .tapDisabledByTimeout, .tapDisabledByUserInput: + if let tap { CGEvent.tapEnable(tap: tap, enable: true) } + case .flagsChanged: + // Flags are shared between left and right keys and with arrows/F-keys: only react to the bound key itself. + guard event.getIntegerValueField(.keyboardEventKeycode) == Int64(binding.keyCode) else { return } + let isDown = event.flags.contains(binding.flag) + if let hotkeyEvent = tracker.process(isDown: isDown) { + onEvent?(hotkeyEvent) + } + default: + break + } + } +} +``` + +> `FnKeyTracker` en zijn test blijven ongewijzigd bestaan; `HotkeyMonitor` gebruikt hem niet meer. + +- [ ] **Step 4: Draai alle tests en bouw** + +Run: `swift test && swift build` +Expected: alle tests PASS; build slaagt (de app en `SstProbe` gebruiken `HotkeyMonitor()` zonder argumenten en blijven dus compileren). + +- [ ] **Step 5: Handmatige controle (met de gebruiker, na Task 5)** + +Wordt gebundeld in Task 5, stap 6. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/SstCore Tests/SstCoreTests +git commit -m "feat: configurable hotkey binding and ModifierKeyTracker" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 4: Niveau-uitvoer — `AudioLevel` en `AudioRecorder.onLevel` + +**Files:** +- Create: `Sources/SstCore/AudioLevel.swift` +- Modify: `Sources/SstCore/AudioRecorder.swift` +- Modify: `Sources/SstProbe/RecordProbe.swift` +- Test: `Tests/SstCoreTests/AudioLevelTests.swift` + +**Interfaces:** +- Produces: + - `AudioLevel.rmsDbfs(_ samples: [Float]) -> Float` en `rmsDbfs(_ samples: UnsafeBufferPointer) -> Float` (−120 voor stilte of leeg) + - `AudioLevel.vu(fromDbfs:referenceDbfs:) -> Float` (standaard referentie −20) + - `AudioRecorder.onLevel: ((Float) -> Void)?` — RMS in dBFS per aangeleverde buffer, aangeroepen op de audio-thread; vóór `start()` instellen. + +- [ ] **Step 1: Schrijf de falende test** + +`Tests/SstCoreTests/AudioLevelTests.swift`: + +```swift +import Testing +@testable import SstCore + +struct AudioLevelTests { + @Test func constantTenthIsMinusTwentyDbfs() { + let level = AudioLevel.rmsDbfs([Float](repeating: 0.1, count: 1_000)) + #expect(abs(level - (-20)) < 0.01) + } + + @Test func fullScaleIsZeroDbfs() { + let level = AudioLevel.rmsDbfs([Float](repeating: 1.0, count: 100)) + #expect(abs(level) < 0.01) + } + + @Test func negativeSamplesCountLikePositiveOnes() { + let level = AudioLevel.rmsDbfs([Float](repeating: -0.1, count: 1_000)) + #expect(abs(level - (-20)) < 0.01) + } + + @Test func emptyAndSilentBuffersHitTheFloor() { + #expect(AudioLevel.rmsDbfs([]) == AudioLevel.silenceFloorDbfs) + #expect(AudioLevel.rmsDbfs([Float](repeating: 0, count: 64)) == AudioLevel.silenceFloorDbfs) + } + + @Test func vuIsRelativeToTheReference() { + #expect(AudioLevel.vu(fromDbfs: -20) == 0) + #expect(AudioLevel.vu(fromDbfs: -40) == -20) + #expect(AudioLevel.vu(fromDbfs: -17) == 3) + #expect(AudioLevel.vu(fromDbfs: -30, referenceDbfs: -18) == -12) + } +} +``` + +- [ ] **Step 2: Draai de test, moet falen** + +Run: `swift test --filter AudioLevelTests` +Expected: compilatiefout `cannot find 'AudioLevel' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstCore/AudioLevel.swift`: + +```swift +import Foundation + +public enum AudioLevel { + /// Reported for empty or silent buffers (log of zero would be -infinity). + public static let silenceFloorDbfs: Float = -120 + + public static func rmsDbfs(_ samples: [Float]) -> Float { + samples.withUnsafeBufferPointer { rmsDbfs($0) } + } + + public static func rmsDbfs(_ samples: UnsafeBufferPointer) -> Float { + guard !samples.isEmpty else { return silenceFloorDbfs } + var sum: Float = 0 + for sample in samples { sum += sample * sample } + let rms = (sum / Float(samples.count)).squareRoot() + guard rms > 0 else { return silenceFloorDbfs } + return max(silenceFloorDbfs, 20 * log10(rms)) + } + + /// VU reading for a dBFS level, where `referenceDbfs` is 0 VU. + public static func vu(fromDbfs dbfs: Float, referenceDbfs: Float = -20) -> Float { + dbfs - referenceDbfs + } +} +``` + +In `Sources/SstCore/AudioRecorder.swift`: + +1. Voeg onder `private var buffer: [Float] = []` toe: + +```swift + + /// RMS level (dBFS) of every delivered input buffer. Called on the audio thread; set before `start()`. + public var onLevel: ((Float) -> Void)? +``` + +2. Vervang in de tap-closure de regel `guard let self else { return }` door: + +```swift + guard let self else { return } + if let onLevel = self.onLevel, let channel = pcm.floatChannelData?[0] { + onLevel(AudioLevel.rmsDbfs(UnsafeBufferPointer(start: channel, count: Int(pcm.frameLength)))) + } +``` + +3. Vervang `bufferSize: 4096` door `bufferSize: 1024` (macOS mag een grotere buffer leveren; de meter werkt met elke grootte). + +`Sources/SstProbe/RecordProbe.swift` — vervang volledig door: + +```swift +import AVFoundation +import SstCore + +/// Records `seconds` of microphone audio; returns nil if access is denied or recording fails. +func recordSamples(seconds: Double, onLevel: ((Float) -> Void)? = nil) async -> [Float]? { + guard await AVCaptureDevice.requestAccess(for: .audio) else { + print("Microfoon-toegang geweigerd (Systeeminstellingen → Privacy → Microfoon).") + return nil + } + let recorder = AudioRecorder() + recorder.onLevel = onLevel + do { try recorder.start() } catch { + print("start mislukt:", error.localizedDescription) + return nil + } + print("Neem \(Int(seconds)) s op — praat nu") + try? await Task.sleep(for: .seconds(seconds)) + return recorder.stop() +} + +func runRecordProbe() async { + final class Levels: @unchecked Sendable { + let lock = NSLock() + var values: [Float] = [] + func add(_ v: Float) { lock.withLock { values.append(v) } } + } + let levels = Levels() + guard let samples = await recordSamples(seconds: 3, onLevel: { levels.add($0) }) else { return } + let peak = samples.map { abs($0) }.max() ?? 0 + print("samples: \(samples.count) (verwacht ≈ 48000), piek: \(peak)") + let values = levels.lock.withLock { levels.values } + if let lo = values.min(), let hi = values.max() { + print(String(format: "niveau-meldingen: %d (≈ %.0f per s), bereik %.1f … %.1f dBFS", values.count, Double(values.count) / 3, lo, hi)) + } else { + print("geen niveau-meldingen ontvangen") + } +} +``` + +- [ ] **Step 4: Draai alle tests en bouw** + +Run: `swift test && swift build` +Expected: alle tests PASS; build slaagt. + +- [ ] **Step 5: Handmatige controle (met de gebruiker, in Task 5, stap 6)** + +Hier alleen: de code compileert. + +- [ ] **Step 6: Commit** + +```bash +git add Sources Tests +git commit -m "feat: AudioLevel helper and AudioRecorder.onLevel" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: WhisperKit-voorrang — woordenlijst als `promptTokens` + +**Files:** +- Modify: `Package.swift` (testtarget `SstWhisperTests`) +- Modify: `Sources/SstWhisper/WhisperKitTranscriber.swift` +- Modify: `Sources/SstProbe/TranscribeProbe.swift` +- Test: `Tests/SstWhisperTests/PromptTokensTests.swift` + +**Interfaces:** +- Consumes: Task 1 (`Transcriber.transcribe(_:vocabulary:)`). +- Produces: `WhisperKitTranscriber.transcribe(_:vocabulary:)`; intern `static func promptTokens(vocabulary:maxTokens:specialTokenBegin:encode:) -> [Int]?`. + +- [ ] **Step 1: Testtarget toevoegen** + +In `Package.swift` voeg onder de bestaande `.testTarget(name: "SstCoreTests", …)` toe: + +```swift + .testTarget(name: "SstWhisperTests", dependencies: ["SstWhisper"]), +``` + +- [ ] **Step 2: Schrijf de falende test** + +`Tests/SstWhisperTests/PromptTokensTests.swift`: + +```swift +import Testing +@testable import SstWhisper + +struct PromptTokensTests { + /// Fake tokenizer: one token (id 7) per word, plus one special token (id 2000) that must be filtered out. + let encode: (String) -> [Int] = { text in + text.split(whereSeparator: { !$0.isLetter }).map { _ in 7 } + [2000] + } + + @Test func allTermsFitAndSpecialTokensAreFiltered() { + let tokens = WhisperKitTranscriber.promptTokens( + vocabulary: ["Anthropic", "Vercel", "Supabase"], maxTokens: 64, specialTokenBegin: 1000, encode: encode) + #expect(tokens == [7, 7, 7]) + } + + @Test func termsThatDoNotFitAreDroppedFromTheEnd() { + let vocab = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] + let tokens = WhisperKitTranscriber.promptTokens( + vocabulary: vocab, maxTokens: 4, specialTokenBegin: 1000, encode: encode) + #expect(tokens?.count == 4) + } + + @Test func emptyVocabularyGivesNoPrompt() { + #expect(WhisperKitTranscriber.promptTokens( + vocabulary: [], maxTokens: 64, specialTokenBegin: 1000, encode: encode) == nil) + } + + @Test func firstTermAloneTooLongGivesNoPrompt() { + #expect(WhisperKitTranscriber.promptTokens( + vocabulary: ["one two three"], maxTokens: 2, specialTokenBegin: 1000, encode: encode) == nil) + } +} +``` + +- [ ] **Step 3: Draai de test, moet falen** + +Run: `swift test --filter PromptTokensTests` +Expected: compilatiefout `type 'WhisperKitTranscriber' has no member 'promptTokens'`. + +- [ ] **Step 4: Implementatie** + +In `Sources/SstWhisper/WhisperKitTranscriber.swift` vervang de hele methode `public func transcribe(_ samples: [Float]) async throws -> String { … }` (onderaan het bestand) door: + +```swift + public func transcribe(_ samples: [Float]) async throws -> String { + try await transcribe(samples, vocabulary: []) + } + + /// Terms are added one by one while the encoded prompt stays within `maxTokens`. + /// Returns nil when nothing fits or the list is empty. + static func promptTokens( + vocabulary: [String], + maxTokens: Int = 64, + specialTokenBegin: Int, + encode: (String) -> [Int] + ) -> [Int]? { + var kept: [String] = [] + var tokens: [Int] = [] + for term in vocabulary { + let candidate = kept + [term] + let encoded = encode(" " + candidate.joined(separator: ", ")).filter { $0 < specialTokenBegin } + if encoded.count > maxTokens { break } + kept = candidate + tokens = encoded + } + return tokens.isEmpty ? nil : tokens + } + + public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + guard let pipeline else { throw TranscriberError.notLoaded } + // language nil + detectLanguage: Whisper picks NL or EN per utterance. + var options = DecodingOptions( + task: .transcribe, + language: nil, + detectLanguage: true, + skipSpecialTokens: true, + withoutTimestamps: true + ) + if !vocabulary.isEmpty, let tokenizer = pipeline.tokenizer { + options.promptTokens = Self.promptTokens( + vocabulary: vocabulary, + specialTokenBegin: tokenizer.specialTokens.specialTokenBegin, + encode: { tokenizer.encode(text: $0) } + ) + } + let results = try await pipeline.transcribe(audioArray: samples, decodeOptions: options) + return results + .map(\.text) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +``` + +> Wijkt de WhisperKit-API licht af (bijvoorbeeld de plaats van `specialTokens`), pas dan alleen dit bestand aan en behoud de signatuur van `promptTokens` zodat de test geldig blijft. + +`Sources/SstProbe/TranscribeProbe.swift` — vervang regel `let text = try await transcriber.transcribe(samples)` door: + +```swift + let vocabulary = (ProcessInfo.processInfo.environment["SST_VOCAB"] ?? "") + .split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + if !vocabulary.isEmpty { print("voorrang: \(vocabulary.joined(separator: ", "))") } + let text = try await transcriber.transcribe(samples, vocabulary: vocabulary) +``` + +- [ ] **Step 5: Draai alle tests en bouw** + +Run: `swift test && swift build` +Expected: alle tests PASS (incl. 4 nieuwe in `SstWhisperTests`); build slaagt. + +- [ ] **Step 6: Handmatige controle samen met de gebruiker (hardware)** + +Doe in deze volgorde en noteer de uitkomsten in het commit-bericht van stap 7: + +1. `swift run SstProbe record` → er verschijnt een regel `niveau-meldingen: N (≈ X per s), bereik … dBFS`. Verwacht: meer dan 5 meldingen per seconde en een bereik dat bij praten duidelijk hoger ligt dan bij stilte. Noteer X (macOS levert soms ~10 per s in plaats van ~47). +2. `swift run SstProbe transcribe` — zeg een zin, noteer de tijd **zonder** voorrang. +3. `SST_VOCAB="Anthropic,Vercel,Supabase,Claude Code" swift run SstProbe transcribe` — zeg dezelfde zin met een of twee van die woorden erin ("Ik werk met Supabase en Claude Code"); noteer de tijd en of de spelling beter is. Verwacht: tijd onder 1,5 s voor 8 s spraak. Is het trager, noteer hoeveel; dat is geen blokkade, wel informatie voor de spec. +4. Zeg niets (8 s stilte) met `SST_VOCAB` gezet: het resultaat mag geen woorden uit de lijst bevatten. +5. Hotkey-controle: `swift run SstProbe hotkey` toont nog steeds `pressed`/`released` voor Fn. + +- [ ] **Step 7: Commit** + +```bash +git add Package.swift Sources Tests +git commit -m "feat: WhisperKit transcriber biases decoding with vocabulary prompt tokens" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +## Self-review + +- **Spec-dekking:** raakpunt 1 (woordenlijst → `promptTokens`, Task 1+5), 2 (niveau, Task 4), 3 (corrector + `onResult`, Task 1+2), 4 (instelbare toets, Task 3). Voorrang-regels: RMS-grens 0,015 (Task 2 test `quietRecordingSkipsVocabularyButStillTranscribes`), echo-filter (Task 2), 64 tokens (Task 5). Niet in deze fase, wel in fase 2: het opbouwen van de woordenlijst (`VocabularyBuilder`) en de correctie zelf. +- **Placeholders:** geen; de waarschuwing in Task 5 stap 4 is een gerichte terugval, geen open eind. +- **Typeconsistentie:** `AppliedCorrection(heard:wrote:rule:start:length:)`, `CorrectionResult(text:corrections:)` en `DictationResult(date:duration:rawText:finalText:corrections:)` worden in Task 2 precies zo gebruikt als in Task 1 gedefinieerd. `HotkeyMonitor.binding` gebruikt `HotkeyBinding.keyCode: Int` en `flag`. +- **Risico's:** de bufferlevering van `installTap` (Task 4, stap 6.1) en de snelheid met prompt (stap 6.3) zijn de enige onbekenden; beide worden gemeten. diff --git a/docs/superpowers/plans/2026-10-01-app-2-data.md b/docs/superpowers/plans/2026-10-01-app-2-data.md new file mode 100644 index 0000000..6db5f90 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-app-2-data.md @@ -0,0 +1,1413 @@ +# App fase 2 — SstData (woordenboek, correcties, geschiedenis) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** De gegevenslaag van de app: het woordenboek (bestand, correctieronde, waarschuwingen, voorrangslijst) en de transcriptiegeschiedenis (opslag, zoeken, groeperen), allemaal los van de interface en volledig getest. + +**Architecture:** Eén nieuw target `SstData` dat alleen van `SstCore` afhangt (voor `AppliedCorrection`, `CorrectionResult`, `TextCorrecting`, `DictationResult`). Pure logica (`DictionaryFile`, `CorrectionEngine`, `EntryWarnings`, `VocabularyBuilder`, `HistorySearch`, `HistoryGrouping`) is hardware- en UI-vrij; bestandstoegang zit in `DictionaryStore` en `HistoryStore`. Woordkennis zit achter het protocol `WordKnowledge` zodat tests deterministisch zijn. + +**Tech Stack:** Swift 6.4 (language mode 5), Swift Testing, Foundation, NaturalLanguage, AppKit (spellingcontrole). + +**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (secties "Woordenboek — het ontwerp in detail", "Geschiedenis") + +**Voorwaarde:** fase 1 (`2026-10-01-app-1-engine-hooks.md`) is afgerond: `AppliedCorrection`, `CorrectionResult`, `TextCorrecting` en `DictationResult` bestaan in `SstCore`. + +## Global Constraints + +- Dictionary-bestand: UTF-8, één invoer per regel; `#` = opmerking; `gehoord => schrijf` = correctiepaar; een regel zonder `=>` is een term. Opmerkingen, lege regels en volgorde blijven bij opslaan behouden; onleesbare regels blijven staan (`invalid`). +- Correctie: hele woorden, hoofdletter-onafhankelijk, één doorgang van links naar rechts, langste overeenkomst eerst (gelijke lengte: eerste in het bestand), vervangen tekst wordt niet opnieuw gescand, vervanging is letterlijk. Delen van een bron worden verbonden door nul of meer spaties/koppeltekens (`[\s\-‐‑–]*`). Woordgrenzen: niet direct naast een letter of cijfer (`\p{L}`, `\p{N}`). +- Een term van één woord dat een gewoon woord is (spellingcontrole of Engelse woordenlijst) krijgt géén automatische hoofdletterregel (wel voorrang bij het model). +- Voorrangslijst: eerst termen (bestandsvolgorde), dan unieke rechterkanten van paren; maximaal 24 woorden. +- Geschiedenis: JSON `{ "version": 1, "entries": [...] }`, ISO-8601 datums, nieuwste eerst, atomisch schrijven met 300 ms debounce; beschadigd bestand wordt hernoemd naar `history.corrupt-.json`. +- Zoeken: hoofdletter- en diakriet-ongevoelig; alle zoekwoorden moeten voorkomen in de uiteindelijke of oorspronkelijke tekst. +- Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 ` (tweede `-m`). + +## Bestandsstructuur + +``` +Package.swift (wijzig) target SstData + SstDataTests +Sources/SstData/WordKnowledge.swift (nieuw) WordKnowledge, EntryKey, CommonWords, SystemWordKnowledge +Sources/SstData/DictionaryFile.swift (nieuw) DictionaryEntry, DictionaryLine, DictionaryFile +Sources/SstData/CorrectionEngine.swift (nieuw) +Sources/SstData/EntryWarnings.swift (nieuw) +Sources/SstData/VocabularyBuilder.swift (nieuw) +Sources/SstData/History.swift (nieuw) HistoryEntry, HistoryStore, HistorySearch, HistoryGrouping +Sources/SstData/DictionaryStore.swift (nieuw) DictionaryStore, DictionaryCorrector +Tests/SstDataTests/Fakes.swift, DictionaryFileTests.swift, SystemWordKnowledgeTests.swift, + CorrectionEngineTests.swift, EntryWarningsTests.swift, VocabularyBuilderTests.swift, + HistoryTests.swift, DictionaryStoreTests.swift +``` + +--- + +### Task 1: Target, woordkennis en `DictionaryFile` + +**Files:** +- Modify: `Package.swift` +- Create: `Sources/SstData/WordKnowledge.swift`, `Sources/SstData/DictionaryFile.swift` +- Test: `Tests/SstDataTests/Fakes.swift`, `Tests/SstDataTests/DictionaryFileTests.swift`, `Tests/SstDataTests/SystemWordKnowledgeTests.swift` + +**Interfaces:** +- Produces (gebruikt door Task 2–5 en de latere fasen): + - `protocol WordKnowledge { func isWord(_:) -> Bool; func isFunctionWord(_:) -> Bool }` + - `final class SystemWordKnowledge: WordKnowledge` (`init()`) + - `enum EntryKey { static func parts(_:) -> [String]; static func key(_:) -> String }` (intern) + - `struct DictionaryEntry: Identifiable, Equatable` met `id`, `kind: Kind` (`.term(String)` | `.correction(heard:wrote:)`), `source`, `wrote` + - `enum DictionaryLine { entry, comment, blank, invalid }` + - `struct DictionaryFile` met `init(parsing:)`, `serialized()`, `entries`, `append(_:) -> UUID`, `update(id:kind:)`, `remove(id:)` + +- [ ] **Step 1: Targets toevoegen** + +In `Package.swift`, voeg bij `targets` toe (naast de bestaande regels): + +```swift + .target(name: "SstData", dependencies: ["SstCore"]), +``` + +en bij de testtargets: + +```swift + .testTarget(name: "SstDataTests", dependencies: ["SstData", "SstCore"]), +``` + +- [ ] **Step 2: Schrijf de falende tests** + +`Tests/SstDataTests/Fakes.swift`: + +```swift +import Foundation +@testable import SstData + +/// Deterministic stand-in for the macOS spell checker. +struct FakeKnowledge: WordKnowledge { + var words: Set = ["cloud", "code", "some", "thing", "something", "reef", "slack", "in", "de", "the", "of"] + var functionWords: Set = ["in", "de", "the", "of"] + func isWord(_ word: String) -> Bool { words.contains(word.lowercased()) } + func isFunctionWord(_ word: String) -> Bool { functionWords.contains(word.lowercased()) } +} + +func term(_ text: String) -> DictionaryEntry { DictionaryEntry(kind: .term(text)) } +func pair(_ heard: String, _ wrote: String) -> DictionaryEntry { DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)) } +``` + +`Tests/SstDataTests/DictionaryFileTests.swift`: + +```swift +import Testing +@testable import SstData + +struct DictionaryFileTests { + let sample = "# Opmerking\nAnthropic\n\ncloud code => Claude Code\n" + + @Test func parsesTermsPairsCommentsAndBlanks() { + let file = DictionaryFile(parsing: sample) + #expect(file.lines.count == 4) + #expect(file.entries == [term("Anthropic"), pair("cloud code", "Claude Code")]) + #expect(file.lines[0] == .comment("# Opmerking")) + #expect(file.lines[2] == .blank) + } + + @Test func roundTripKeepsCommentsBlankLinesAndOrder() { + #expect(DictionaryFile(parsing: sample).serialized() == sample) + } + + @Test func normalisesSpacingAroundTheArrow() { + #expect(DictionaryFile(parsing: "a=>b\n").serialized() == "a => b\n") + #expect(DictionaryFile(parsing: " cloud code => Claude Code \n").entries == [pair("cloud code", "Claude Code")]) + } + + @Test func unreadableLinesAreKeptAsInvalid() { + let file = DictionaryFile(parsing: "oops =>\n=> nothing\nok\n") + #expect(file.lines[0] == .invalid("oops =>")) + #expect(file.lines[1] == .invalid("=> nothing")) + #expect(file.serialized() == "oops =>\n=> nothing\nok\n") + } + + @Test func handlesWindowsLineEndingsAndMissingFinalNewline() { + let file = DictionaryFile(parsing: "a\r\nb => c") + #expect(file.entries == [term("a"), pair("b", "c")]) + #expect(file.serialized() == "a\nb => c\n") + } + + @Test func emptyTextIsAValidEmptyFile() { + #expect(DictionaryFile(parsing: "").entries.isEmpty) + #expect(DictionaryFile(parsing: "").serialized() == "") + } + + @Test func appendUpdateAndRemove() { + var file = DictionaryFile(parsing: "# c\n") + let id = file.append(.term("Vercel")) + #expect(file.entries == [term("Vercel")]) + file.update(id: id, kind: .correction(heard: "versel", wrote: "Vercel")) + #expect(file.entries == [pair("versel", "Vercel")]) + file.remove(id: id) + #expect(file.entries.isEmpty) + #expect(file.serialized() == "# c\n") + } +} +``` + +`Tests/SstDataTests/SystemWordKnowledgeTests.swift`: + +```swift +import Testing +@testable import SstData + +struct SystemWordKnowledgeTests { + let knowledge = SystemWordKnowledge() + + @Test func recognisesEnglishAndDutchWords() { + #expect(knowledge.isWord("cloud")) + #expect(knowledge.isWord("Cloud")) + #expect(knowledge.isWord("vandaag")) + } + + @Test func doesNotTreatProductNamesAsWords() { + #expect(!knowledge.isWord("vercel")) + #expect(!knowledge.isWord("supabase")) + #expect(!knowledge.isWord("anthropic")) + } + + @Test func functionWordsComeFromTheBuiltInList() { + #expect(knowledge.isFunctionWord("de")) + #expect(knowledge.isFunctionWord("The")) + #expect(!knowledge.isFunctionWord("vercel")) + } +} +``` + +- [ ] **Step 3: Draai de tests, moeten falen** + +Run: `swift test --filter SstDataTests` +Expected: compilatiefouten (`cannot find 'DictionaryFile'`, `cannot find type 'WordKnowledge'`). + +- [ ] **Step 4: Implementatie** + +`Sources/SstData/WordKnowledge.swift`: + +```swift +import AppKit +import Foundation +import NaturalLanguage + +public protocol WordKnowledge { + /// A common word in Dutch or English (case-insensitive). Rare words, names and product names are not. + func isWord(_ word: String) -> Bool + /// A very frequent function word ("in", "de", "the", "of"). + func isFunctionWord(_ word: String) -> Bool +} + +/// Splitting and normalising the left-hand side of an entry, shared by the engine and the warnings. +enum EntryKey { + static func parts(_ source: String) -> [String] { + source.split(whereSeparator: { $0.isWhitespace || "-‐‑–".contains($0) }).map(String.init) + } + + /// Spacing, hyphens and case do not matter: "Cloud-Code", "cloud code" and "cloudcode" are the same source. + static func key(_ source: String) -> String { + parts(source).joined().lowercased() + } +} + +/// The most frequent Dutch and English function words. +enum CommonWords { + static let english: Set = [ + "a", "about", "after", "all", "also", "an", "and", "any", "are", "as", "at", "be", "because", "been", "but", + "by", "can", "could", "did", "do", "does", "for", "from", "had", "has", "have", "he", "her", "here", "him", + "his", "how", "i", "if", "in", "into", "is", "it", "its", "just", "me", "more", "most", "my", "no", "not", + "now", "of", "on", "one", "only", "or", "other", "our", "out", "over", "she", "so", "some", "such", "than", + "that", "the", "their", "them", "then", "there", "these", "they", "this", "those", "to", "up", "us", "very", + "was", "we", "were", "what", "when", "which", "who", "will", "with", "would", "you", "your", + ] + static let dutch: Set = [ + "aan", "al", "als", "ben", "bij", "dan", "dat", "de", "den", "der", "deze", "die", "dit", "doen", "door", + "dus", "een", "en", "er", "ga", "had", "heb", "heeft", "hem", "het", "hier", "hij", "hoe", "ik", "in", "is", + "ja", "je", "jij", "kan", "kon", "maar", "me", "mij", "mijn", "moet", "na", "naar", "niet", "nog", "nu", + "of", "om", "ons", "onze", "ook", "op", "over", "te", "ten", "ter", "toch", "toen", "tot", "u", "uit", "van", + "veel", "voor", "was", "wat", "we", "wel", "werd", "wij", "wil", "worden", "wordt", "zal", "ze", "zei", + "zich", "zij", "zijn", "zo", "zou", + ] + static let all: Set = english.union(dutch) +} + +/// English: the on-device word list of `NaturalLanguage` (common words only, so "cloud" and "reef" are in, "anthropic" and "vercel" are not). +/// Dutch has no such list, so the spell checker decides. Function words come from the built-in list. +public final class SystemWordKnowledge: WordKnowledge, @unchecked Sendable { + private let englishWords = NLEmbedding.wordEmbedding(for: .english) + + public init() {} + + public func isWord(_ word: String) -> Bool { + let lowered = word.lowercased() + guard !lowered.isEmpty else { return false } + if let englishWords { + if englishWords.contains(lowered) { return true } + } else if spellChecks(lowered, language: "en") { + return true + } + return spellChecks(lowered, language: "nl") + } + + public func isFunctionWord(_ word: String) -> Bool { + CommonWords.all.contains(word.lowercased()) + } + + private func spellChecks(_ word: String, language: String) -> Bool { + NSSpellChecker.shared.checkSpelling( + of: word, startingAt: 0, language: language, wrap: false, + inSpellDocumentWithTag: 0, wordCount: nil).location == NSNotFound + } +} +``` + +`Sources/SstData/DictionaryFile.swift`: + +```swift +import Foundation + +public struct DictionaryEntry: Identifiable, Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case term(String) + case correction(heard: String, wrote: String) + } + + public let id: UUID + public var kind: Kind + + public init(id: UUID = UUID(), kind: Kind) { + self.id = id + self.kind = kind + } + + /// Equality ignores `id`: two entries with the same content are the same entry. + public static func == (lhs: DictionaryEntry, rhs: DictionaryEntry) -> Bool { lhs.kind == rhs.kind } + + /// The text the correction pass listens for (a term listens for itself). + public var source: String { + switch kind { + case .term(let term): term + case .correction(let heard, _): heard + } + } + + /// The text that ends up in the transcript. + public var wrote: String { + switch kind { + case .term(let term): term + case .correction(_, let wrote): wrote + } + } +} + +public enum DictionaryLine: Equatable, Sendable { + case entry(DictionaryEntry) + case comment(String) + case blank + case invalid(String) +} + +/// The dictionary as a list of lines, so comments, blank lines and order survive an edit. +public struct DictionaryFile: Equatable, Sendable { + public var lines: [DictionaryLine] + + public init(lines: [DictionaryLine] = []) { + self.lines = lines + } + + public init(parsing text: String) { + var raw = text.replacingOccurrences(of: "\r\n", with: "\n") + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + if raw.last == "" { raw.removeLast() } + lines = raw.map(Self.parseLine) + } + + static func parseLine(_ line: String) -> DictionaryLine { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .blank } + if trimmed.hasPrefix("#") { return .comment(line) } + if let arrow = trimmed.range(of: "=>") { + let heard = trimmed[.. String { + let out = lines.map { line -> String in + switch line { + case .entry(let entry): + switch entry.kind { + case .term(let term): return term + case .correction(let heard, let wrote): return "\(heard) => \(wrote)" + } + case .comment(let text), .invalid(let text): return text + case .blank: return "" + } + } + return out.isEmpty ? "" : out.joined(separator: "\n") + "\n" + } + + public var entries: [DictionaryEntry] { + lines.compactMap { line in + if case .entry(let entry) = line { return entry } + return nil + } + } + + @discardableResult + public mutating func append(_ kind: DictionaryEntry.Kind) -> UUID { + let entry = DictionaryEntry(kind: kind) + lines.append(.entry(entry)) + return entry.id + } + + public mutating func update(id: UUID, kind: DictionaryEntry.Kind) { + guard let index = lines.firstIndex(where: { if case .entry(let e) = $0 { return e.id == id } else { return false } }) else { return } + lines[index] = .entry(DictionaryEntry(id: id, kind: kind)) + } + + public mutating func remove(id: UUID) { + lines.removeAll { if case .entry(let e) = $0 { return e.id == id } else { return false } } + } +} +``` + +- [ ] **Step 5: Draai de tests** + +Run: `swift test --filter SstDataTests` +Expected: alle tests PASS (7 `DictionaryFile`, 3 `SystemWordKnowledge`). + +> De Engelse woordenlijst komt uit `NLEmbedding.wordEmbedding(for: .english)`. Is die op de machine niet beschikbaar, dan valt `isWord` terug op de Engelse spellingcontrole; de tests verwachten dan dat "anthropic" óók een woord is en kunnen falen. Meld dat als BLOCKED met de uitvoer van `swift test`, wijzig de test niet stilletjes. + +- [ ] **Step 6: Commit** + +```bash +git add Package.swift Sources/SstData Tests/SstDataTests +git commit -m "feat: add SstData with DictionaryFile and word knowledge" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: `CorrectionEngine` + +**Files:** +- Create: `Sources/SstData/CorrectionEngine.swift` +- Test: `Tests/SstDataTests/CorrectionEngineTests.swift` + +**Interfaces:** +- Consumes: Task 1 (`DictionaryEntry`, `WordKnowledge`, `EntryKey`) en `SstCore.TextCorrecting`/`CorrectionResult`/`AppliedCorrection`. +- Produces: `struct CorrectionEngine: TextCorrecting` met `init(entries: [DictionaryEntry], knowledge: WordKnowledge)` en `correct(_:) -> CorrectionResult`. + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstDataTests/CorrectionEngineTests.swift`: + +```swift +import Foundation +import Testing +import SstCore +@testable import SstData + +struct CorrectionEngineTests { + let knowledge = FakeKnowledge() + + func engine(_ entries: [DictionaryEntry]) -> CorrectionEngine { + CorrectionEngine(entries: entries, knowledge: knowledge) + } + + @Test func replacesAPairAndReportsIt() { + let result = engine([pair("cloud code", "Claude Code")]).correct("ik gebruik cloud code vandaag") + #expect(result.text == "ik gebruik Claude Code vandaag") + #expect(result.corrections.count == 1) + let c = result.corrections[0] + #expect(c.heard == "cloud code" && c.wrote == "Claude Code" && c.rule == "cloud code") + #expect((result.text as NSString).substring(with: NSRange(location: c.start, length: c.length)) == "Claude Code") + } + + @Test func findsGluedHyphenatedAndSpacedVariants() { + let e = engine([pair("cloud code", "Claude Code")]) + for variant in ["CloudCode", "Cloud-Code", "CLOUD CODE", "cloud\u{2011}code", "cloud code"] { + #expect(e.correct(variant).text == "Claude Code", "variant \(variant)") + } + } + + @Test func neverTouchesRealWordsThatMerelyStartTheSame() { + let e = engine([pair("cloud code", "Claude Code")]) + #expect(e.correct("Cloudflare is handig").text == "Cloudflare is handig") + #expect(e.correct("cloud codex").text == "cloud codex") + #expect(e.correct("de cloud en de code").text == "de cloud en de code") + #expect(e.correct("the cloud").corrections.isEmpty) + } + + @Test func respectsPunctuationBoundaries() { + let e = engine([pair("cloud code", "Claude Code")]) + #expect(e.correct("(cloud code)").text == "(Claude Code)") + #expect(e.correct("cloud code.").text == "Claude Code.") + #expect(e.correct("Cloud Code, Cloud Code").text == "Claude Code, Claude Code") + } + + @Test func longestMatchWins() { + let e = engine([pair("cloud code", "Claude Code"), pair("cloud code review", "Claude Code Review")]) + #expect(e.correct("cloud code review nu").text == "Claude Code Review nu") + #expect(e.correct("cloud-code-review").text == "Claude Code Review") + } + + @Test func replacedTextIsNeverRescanned() { + let e = engine([pair("a", "b"), pair("b", "c")]) + #expect(e.correct("a b").text == "b c") + } + + @Test func termsFixCasingAndSpacing() { + let e = engine([term("Anthropic"), term("Claude Code")]) + #expect(e.correct("anthropic ANTHROPIC").text == "Anthropic Anthropic") + #expect(e.correct("claudecode en claude-code").text == "Claude Code en Claude Code") + } + + @Test func termAlreadyRightIsNotReportedAsACorrection() { + let result = engine([term("Anthropic")]).correct("Anthropic bouwt Claude") + #expect(result.text == "Anthropic bouwt Claude") + #expect(result.corrections.isEmpty) + } + + @Test func singleWordTermThatIsARealWordGetsNoCasingRule() { + let e = engine([term("Reef"), term("Slack")]) + #expect(e.correct("het reef en slack").text == "het reef en slack") + } + + @Test func firstEntryWinsOnDuplicateSources() { + let e = engine([pair("cloud code", "Claude Code"), pair("Cloud-Code", "Other")]) + #expect(e.correct("cloudcode").text == "Claude Code") + } + + @Test func replacementIsLiteral() { + let e = engine([pair("price", "US$1 \\0 $1")]) + #expect(e.correct("the price").text == "the US$1 \\0 $1") + } + + @Test func handlesDiacritics() { + let e = engine([term("Café")]) + #expect(e.correct("een CAFÉ om de hoek").text == "een Café om de hoek") + } + + @Test func offsetsAreRelativeToTheCorrectedText() { + let result = engine([pair("a", "xxx"), pair("b", "y")]).correct("a b a") + #expect(result.text == "xxx y xxx") + #expect(result.corrections.map(\.start) == [0, 4, 6]) + #expect(result.corrections.map(\.length) == [3, 1, 3]) + } + + @Test func emptyDictionaryAndEmptyTextAreNoOps() { + #expect(engine([]).correct("hallo").text == "hallo") + #expect(engine([pair("a", "b")]).correct("").text == "") + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter CorrectionEngineTests` +Expected: compilatiefout `cannot find 'CorrectionEngine' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstData/CorrectionEngine.swift`: + +```swift +import Foundation +import SstCore + +/// Whole-word, case-insensitive, single-pass replacement. Longest match wins, replaced text is never rescanned. +public struct CorrectionEngine: TextCorrecting { + private struct Rule { + let source: String + let wrote: String + } + + private let rules: [Rule] + private let regex: NSRegularExpression? + + public init(entries: [DictionaryEntry], knowledge: WordKnowledge) { + var seen = Set() + var built: [(rule: Rule, order: Int)] = [] + for (order, entry) in entries.enumerated() { + let source = entry.source.trimmingCharacters(in: .whitespacesAndNewlines) + let key = EntryKey.key(source) + guard !key.isEmpty, !seen.contains(key) else { continue } + // A single-word term that is also a real word only biases the model; capitalising it everywhere would corrupt normal text. + if case .term(let term) = entry.kind, EntryKey.parts(term).count == 1, knowledge.isWord(term) { continue } + seen.insert(key) + built.append((Rule(source: source, wrote: entry.wrote), order)) + } + let ordered = built.sorted { a, b in + let la = a.rule.source.filter(\.isLetter).count + let lb = b.rule.source.filter(\.isLetter).count + return la != lb ? la > lb : a.order < b.order + }.map(\.rule) + rules = ordered + if ordered.isEmpty { + regex = nil + } else { + let alternatives = ordered.map { "(\(Self.pattern($0.source)))" }.joined(separator: "|") + regex = try? NSRegularExpression( + pattern: "(? String { + EntryKey.parts(source) + .map { NSRegularExpression.escapedPattern(for: $0) } + .joined(separator: "[\\s\\-‐‑–]*") + } + + public func correct(_ text: String) -> CorrectionResult { + guard let regex, !text.isEmpty else { return CorrectionResult(text: text, corrections: []) } + let ns = text as NSString + var output = "" + var outputLength = 0 + var last = 0 + var applied: [AppliedCorrection] = [] + + for match in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { + let gap = ns.substring(with: NSRange(location: last, length: match.range.location - last)) + output += gap + outputLength += gap.utf16.count + + let heard = ns.substring(with: match.range) + let group = (1.." +``` + +--- + +### Task 3: `EntryWarnings` en `VocabularyBuilder` + +**Files:** +- Create: `Sources/SstData/EntryWarnings.swift`, `Sources/SstData/VocabularyBuilder.swift` +- Test: `Tests/SstDataTests/EntryWarningsTests.swift`, `Tests/SstDataTests/VocabularyBuilderTests.swift` + +**Interfaces:** +- Consumes: Task 1. +- Produces: + - `struct EntryWarning { level: Level (.note < .warning < .error); message: String }` + - `EntryWarnings.check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning]` (meest ernstig eerst; `others` = alle andere invoeren) + - `VocabularyBuilder.build(from: [DictionaryEntry], maxTerms: Int = 24) -> [String]` + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstDataTests/EntryWarningsTests.swift`: + +```swift +import Testing +@testable import SstData + +struct EntryWarningsTests { + let knowledge = FakeKnowledge() + + func check(_ kind: DictionaryEntry.Kind, others: [DictionaryEntry] = []) -> [EntryWarning] { + EntryWarnings.check(kind, among: others, knowledge: knowledge) + } + + @Test func singleCommonWordIsAWarning() { + let w = check(.correction(heard: "cloud", wrote: "Claude")) + #expect(w.contains { $0.level == .warning && $0.message.contains("cloud") }) + } + + @Test func veryShortSourceIsAWarning() { + #expect(check(.correction(heard: "ab", wrote: "AB")).contains { $0.level == .warning }) + } + + @Test func functionWordInsideAPhraseIsAWarning() { + #expect(check(.correction(heard: "in de", wrote: "indeed")).contains { $0.level == .warning }) + } + + @Test func gluedFormThatIsARealWordIsAWarning() { + let w = check(.correction(heard: "some thing", wrote: "Something")) + #expect(w.contains { $0.level == .warning && $0.message.contains("something") }) + } + + @Test func phraseOfCommonWordsOnlyGetsANote() { + let w = check(.correction(heard: "cloud code", wrote: "Claude Code")) + #expect(w.count == 1) + #expect(w[0].level == .note) + } + + @Test func distinctiveEntriesGetNoWarnings() { + #expect(check(.correction(heard: "versel", wrote: "Vercel")).isEmpty) + #expect(check(.term("Supabase")).isEmpty) + } + + @Test func duplicateSourceIsAnErrorEvenWithDifferentSpelling() { + let others = [pair("cloud code", "Claude Code")] + let w = check(.correction(heard: "Cloud-Code", wrote: "X"), others: others) + #expect(w.first?.level == .error) + } + + @Test func termThatIsARealWordGetsANoteAboutBiasOnly() { + let w = check(.term("Reef")) + #expect(w.count == 1) + #expect(w[0].level == .note) + } + + @Test func emptySourceIsAnError() { + #expect(check(.term(" ")).first?.level == .error) + } + + @Test func warningsAreSortedMostSevereFirst() { + let w = check(.correction(heard: "cloud", wrote: "Claude"), others: [pair("cloud", "x")]) + #expect(w.map(\.level) == w.map(\.level).sorted(by: >)) + } +} +``` + +`Tests/SstDataTests/VocabularyBuilderTests.swift`: + +```swift +import Testing +@testable import SstData + +struct VocabularyBuilderTests { + @Test func termsComeFirstThenCorrectionTargetsWithoutDuplicates() { + let entries = [pair("cloud code", "Claude Code"), term("Anthropic"), term("anthropic"), pair("versel", "Vercel"), term("Supabase")] + #expect(VocabularyBuilder.build(from: entries) == ["Anthropic", "Supabase", "Claude Code", "Vercel"]) + } + + @Test func listIsCapped() { + let entries = (1...40).map { term("Woord\($0)") } + #expect(VocabularyBuilder.build(from: entries).count == 24) + #expect(VocabularyBuilder.build(from: entries, maxTerms: 5).count == 5) + } + + @Test func emptyDictionaryGivesNoVocabulary() { + #expect(VocabularyBuilder.build(from: []).isEmpty) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "EntryWarningsTests|VocabularyBuilderTests"` +Expected: compilatiefouten (`cannot find 'EntryWarnings'`, `cannot find 'VocabularyBuilder'`). + +- [ ] **Step 3: Implementatie** + +`Sources/SstData/EntryWarnings.swift`: + +```swift +import Foundation + +public struct EntryWarning: Equatable, Sendable { + public enum Level: Int, Comparable, Sendable { + case note = 0, warning, error + public static func < (a: Level, b: Level) -> Bool { a.rawValue < b.rawValue } + } + + public let level: Level + public let message: String + + public init(level: Level, message: String) { + self.level = level + self.message = message + } +} + +/// Flags dictionary entries that are likely to corrupt normal text. +public enum EntryWarnings { + /// - Parameter others: all entries except the one being checked. + public static func check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning] { + let source: String + let isCorrection: Bool + switch kind { + case .term(let term): (source, isCorrection) = (term, false) + case .correction(let heard, _): (source, isCorrection) = (heard, true) + } + + let parts = EntryKey.parts(source) + guard !parts.isEmpty else { return [EntryWarning(level: .error, message: "Leeg: vul een woord in.")] } + + var result: [EntryWarning] = [] + if others.contains(where: { EntryKey.key($0.source) == EntryKey.key(source) }) { + result.append(EntryWarning(level: .error, message: "Dubbele invoer: deze bron staat er al, de eerste telt.")) + } + + if !isCorrection { + if parts.count == 1, knowledge.isWord(source) { + result.append(EntryWarning(level: .note, message: "Gewoon woord: krijgt alleen voorrang bij het model, geen automatische hoofdletterregel.")) + } + return result.sorted { $0.level > $1.level } + } + + if parts.count == 1, knowledge.isWord(parts[0]) { + result.append(EntryWarning(level: .warning, message: "'\(parts[0])' is een gewoon woord en wordt overal in je tekst vervangen.")) + } + if source.filter(\.isLetter).count < 3 { + result.append(EntryWarning(level: .warning, message: "Erg kort (minder dan 3 letters): veel kans op ongewenste vervangingen.")) + } + if parts.count > 1 { + var flagged = false + if let common = parts.first(where: { knowledge.isFunctionWord($0) }) { + result.append(EntryWarning(level: .warning, message: "Bevat het veelvoorkomende woord '\(common)'; dit kan gewone zinnen raken.")) + flagged = true + } + let glued = parts.joined() + if knowledge.isWord(glued) { + result.append(EntryWarning(level: .warning, message: "Aaneengeschreven ('\(glued)') is een bestaand woord en wordt ook vervangen.")) + flagged = true + } + if !flagged, parts.allSatisfy({ knowledge.isWord($0) }) { + result.append(EntryWarning(level: .note, message: "Alle delen zijn gewone woorden: alleen de hele combinatie wordt vervangen.")) + } + } + return result.sorted { $0.level > $1.level } + } +} +``` + +`Sources/SstData/VocabularyBuilder.swift`: + +```swift +import Foundation + +/// The short word list handed to the speech engine as context. +public enum VocabularyBuilder { + /// Terms first (file order), then the unique right-hand sides of correction pairs; at most `maxTerms` words. + public static func build(from entries: [DictionaryEntry], maxTerms: Int = 24) -> [String] { + var out: [String] = [] + var seen = Set() + func add(_ word: String) { + let key = word.lowercased() + guard !word.isEmpty, !seen.contains(key) else { return } + seen.insert(key) + out.append(word) + } + for entry in entries { if case .term(let term) = entry.kind { add(term) } } + for entry in entries { if case .correction(_, let wrote) = entry.kind { add(wrote) } } + return Array(out.prefix(maxTerms)) + } +} +``` + +- [ ] **Step 4: Draai de tests** + +Run: `swift test --filter "EntryWarningsTests|VocabularyBuilderTests"` +Expected: 10 + 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstData Tests/SstDataTests +git commit -m "feat: add EntryWarnings and VocabularyBuilder" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 4: Geschiedenis — opslag, zoeken, groeperen + +**Files:** +- Create: `Sources/SstData/History.swift` +- Test: `Tests/SstDataTests/HistoryTests.swift` + +**Interfaces:** +- Consumes: `SstCore.DictationResult`, `SstCore.AppliedCorrection`. +- Produces: + - `struct HistoryEntry: Codable, Equatable, Identifiable` (`id`, `date`, `duration`, `rawText`, `finalText`, `corrections`; `init(result:)`) + - `@MainActor final class HistoryStore` met `init(fileURL:)`, `entries` (nieuwste eerst), `load() -> LoadOutcome` (`.empty | .loaded | .recoveredCorrupt(backup:)`), `add(_:)`, `remove(id:)`, `removeAll()`, `flush()`, `onChange`, `lastError` + - `HistorySearch.filter(_:query:) -> [HistoryEntry]` + - `HistoryGrouping.byDay(_:calendar:) -> [HistoryDay]` (`HistoryDay`: `day: Date`, `entries`) + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstDataTests/HistoryTests.swift`: + +```swift +import Foundation +import Testing +import SstCore +@testable import SstData + +@MainActor +struct HistoryStoreTests { + func tempFile() -> URL { + FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/history.json") + } + + func entry(_ text: String, at seconds: TimeInterval) -> HistoryEntry { + HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + seconds), duration: 2, rawText: text, finalText: text) + } + + @Test func newestEntryComesFirstAndSurvivesARoundTrip() { + let url = tempFile() + let store = HistoryStore(fileURL: url) + store.add(entry("eerste", at: 0)) + store.add(HistoryEntry( + date: Date(timeIntervalSince1970: 1_700_000_100), duration: 3, rawText: "cloud code", finalText: "Claude Code", + corrections: [AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 0, length: 11)])) + store.flush() + + let reloaded = HistoryStore(fileURL: url) + #expect(reloaded.load() == .loaded) + #expect(reloaded.entries.map(\.finalText) == ["Claude Code", "eerste"]) + #expect(reloaded.entries == store.entries) + } + + @Test func missingFileIsEmpty() { + let store = HistoryStore(fileURL: tempFile()) + #expect(store.load() == .empty) + #expect(store.entries.isEmpty) + } + + @Test func removeAndRemoveAll() { + let store = HistoryStore(fileURL: tempFile()) + let a = entry("a", at: 0), b = entry("b", at: 1) + store.add(a); store.add(b) + store.remove(id: a.id) + #expect(store.entries.map(\.finalText) == ["b"]) + store.removeAll() + #expect(store.entries.isEmpty) + } + + @Test func corruptFileIsMovedAsideAndStoreStartsFresh() throws { + let url = tempFile() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try "dit is geen json".write(to: url, atomically: true, encoding: .utf8) + let store = HistoryStore(fileURL: url) + guard case .recoveredCorrupt(let backup) = store.load() else { + Issue.record("expected recoveredCorrupt"); return + } + #expect(FileManager.default.fileExists(atPath: backup.path)) + #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(store.entries.isEmpty) + } + + @Test func onChangeFiresForEdits() { + let store = HistoryStore(fileURL: tempFile()) + var calls = 0 + store.onChange = { calls += 1 } + store.add(entry("a", at: 0)) + store.removeAll() + #expect(calls == 2) + } + + @Test func entryFromDictationResultKeepsEverything() { + let result = DictationResult( + date: Date(timeIntervalSince1970: 1_700_000_000), duration: 4.5, rawText: "raw", finalText: "final", + corrections: [AppliedCorrection(heard: "a", wrote: "b", rule: "a", start: 0, length: 1)]) + let e = HistoryEntry(result: result) + #expect(e.rawText == "raw" && e.finalText == "final" && e.duration == 4.5 && e.corrections.count == 1) + } +} + +struct HistorySearchTests { + let entries = [ + HistoryEntry(date: .now, duration: 1, rawText: "ik ga naar het café", finalText: "Ik ga naar het Café"), + HistoryEntry(date: .now, duration: 1, rawText: "cloud code is handig", finalText: "Claude Code is handig"), + ] + + @Test func emptyQueryReturnsEverything() { + #expect(HistorySearch.filter(entries, query: " ").count == 2) + } + + @Test func ignoresCaseAndDiacritics() { + #expect(HistorySearch.filter(entries, query: "CAFE").count == 1) + #expect(HistorySearch.filter(entries, query: "café").count == 1) + } + + @Test func allWordsMustMatchInAnyOrder() { + #expect(HistorySearch.filter(entries, query: "handig claude").count == 1) + #expect(HistorySearch.filter(entries, query: "handig café").isEmpty) + } + + @Test func searchesTheOriginalTextToo() { + #expect(HistorySearch.filter(entries, query: "cloud").count == 1) + } +} + +struct HistoryGroupingTests { + @Test func groupsByDayKeepingOrder() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + func at(_ day: Int, _ hour: Int) -> Date { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour))! + } + let entries = [at(2, 15), at(2, 9), at(1, 20)].map { + HistoryEntry(date: $0, duration: 1, rawText: "x", finalText: "x") + } + let days = HistoryGrouping.byDay(entries, calendar: calendar) + #expect(days.count == 2) + #expect(days[0].entries.count == 2) + #expect(days[1].entries.count == 1) + #expect(days[0].day > days[1].day) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "HistoryStoreTests|HistorySearchTests|HistoryGroupingTests"` +Expected: compilatiefout `cannot find 'HistoryStore' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstData/History.swift`: + +```swift +import Foundation +import SstCore + +public struct HistoryEntry: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var date: Date + public var duration: TimeInterval + public var rawText: String + public var finalText: String + public var corrections: [AppliedCorrection] + + public init( + id: UUID = UUID(), date: Date, duration: TimeInterval, + rawText: String, finalText: String, corrections: [AppliedCorrection] = [] + ) { + self.id = id + self.date = date + self.duration = duration + self.rawText = rawText + self.finalText = finalText + self.corrections = corrections + } + + public init(result: DictationResult) { + self.init( + date: result.date, duration: result.duration, rawText: result.rawText, + finalText: result.finalText, corrections: result.corrections) + } +} + +/// Newest first, stored as one JSON file; saves are debounced. +@MainActor +public final class HistoryStore { + public enum LoadOutcome: Equatable { + case empty + case loaded + case recoveredCorrupt(backup: URL) + } + + private struct Document: Codable { + var version: Int + var entries: [HistoryEntry] + } + + public private(set) var entries: [HistoryEntry] = [] + public private(set) var lastError: String? + public var onChange: (() -> Void)? + + private let fileURL: URL + private var saveTask: Task? + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + + @discardableResult + public func load() -> LoadOutcome { + guard let data = try? Data(contentsOf: fileURL) else { + entries = [] + return .empty + } + do { + entries = try Self.makeDecoder().decode(Document.self, from: data).entries + return .loaded + } catch { + let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") + let backup = fileURL.deletingLastPathComponent().appending(path: "history.corrupt-\(stamp).json") + try? FileManager.default.moveItem(at: fileURL, to: backup) + entries = [] + return .recoveredCorrupt(backup: backup) + } + } + + public func add(_ entry: HistoryEntry) { + entries.insert(entry, at: 0) + changed() + } + + public func remove(id: UUID) { + entries.removeAll { $0.id == id } + changed() + } + + public func removeAll() { + entries = [] + changed() + } + + /// Writes immediately (also call this when the app quits). + public func flush() { + saveTask?.cancel() + saveTask = nil + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try Self.makeEncoder().encode(Document(version: 1, entries: entries)) + try data.write(to: fileURL, options: .atomic) + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + private func changed() { + onChange?() + saveTask?.cancel() + saveTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + self?.flush() + } + } +} + +public enum HistorySearch { + static func fold(_ text: String) -> String { + text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) + } + + /// Every query word must occur in the final or the original text (case- and diacritic-insensitive). + public static func filter(_ entries: [HistoryEntry], query: String) -> [HistoryEntry] { + let words = fold(query).split(whereSeparator: \.isWhitespace).map(String.init) + guard !words.isEmpty else { return entries } + return entries.filter { entry in + let haystack = fold(entry.finalText + " " + entry.rawText) + return words.allSatisfy { haystack.contains($0) } + } + } +} + +public struct HistoryDay: Identifiable, Equatable, Sendable { + public let day: Date + public let entries: [HistoryEntry] + public var id: Date { day } +} + +public enum HistoryGrouping { + /// Groups newest-first entries by calendar day, keeping their order. + public static func byDay(_ entries: [HistoryEntry], calendar: Calendar = .current) -> [HistoryDay] { + var days: [HistoryDay] = [] + for entry in entries { + let start = calendar.startOfDay(for: entry.date) + if let last = days.last, last.day == start { + days[days.count - 1] = HistoryDay(day: start, entries: last.entries + [entry]) + } else { + days.append(HistoryDay(day: start, entries: [entry])) + } + } + return days + } +} +``` + +- [ ] **Step 4: Draai de tests** + +Run: `swift test --filter "HistoryStoreTests|HistorySearchTests|HistoryGroupingTests"` +Expected: 6 + 4 + 1 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstData Tests/SstDataTests +git commit -m "feat: add history store, search and day grouping" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: `DictionaryStore` en `DictionaryCorrector` + +**Files:** +- Create: `Sources/SstData/DictionaryStore.swift` +- Test: `Tests/SstDataTests/DictionaryStoreTests.swift` + +**Interfaces:** +- Consumes: Task 1 (`DictionaryFile`), Task 2 (`CorrectionEngine`). +- Produces: + - `@MainActor final class DictionaryStore` met `init(fileURL:)`, `fileURL`, `file`, `entries`, `load() throws`, `apply(_ mutate: (inout DictionaryFile) -> Void) throws`, `reloadIfChanged()`, `startWatching()`, `stopWatching()`, `onChange`, `static header` + - `final class DictionaryCorrector: TextCorrecting` met `init(knowledge:)`, `update(entries:)`, `setEnabled(_:)` + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstDataTests/DictionaryStoreTests.swift`: + +```swift +import Foundation +import Testing +import SstCore +@testable import SstData + +@MainActor +struct DictionaryStoreTests { + func tempFile() -> URL { + FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/dictionary.txt") + } + + @Test func loadCreatesAFileWithAHeaderWhenMissing() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(store.entries.isEmpty) + #expect(try String(contentsOf: url, encoding: .utf8).hasPrefix("# Sst woordenboek")) + } + + @Test func applyWritesAtomicallyAndKeepsComments() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + try store.apply { $0.append(.correction(heard: "cloud code", wrote: "Claude Code")) } + let text = try String(contentsOf: url, encoding: .utf8) + #expect(text.hasPrefix("# Sst woordenboek")) + #expect(text.hasSuffix("cloud code => Claude Code\n")) + #expect(store.entries == [pair("cloud code", "Claude Code")]) + } + + @Test func externalEditIsPickedUp() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + var changes = 0 + store.onChange = { changes += 1 } + try "Vercel\n".write(to: url, atomically: true, encoding: .utf8) + store.reloadIfChanged() + #expect(store.entries == [term("Vercel")]) + #expect(changes == 1) + store.reloadIfChanged() // unchanged text: no second notification + #expect(changes == 1) + } + + @Test func ourOwnWriteDoesNotTriggerAReload() throws { + let store = DictionaryStore(fileURL: tempFile()) + try store.load() + try store.apply { $0.append(.term("Supabase")) } + var changes = 0 + store.onChange = { changes += 1 } + store.reloadIfChanged() + #expect(changes == 0) + } + + @Test func watcherNoticesAnEditorSavingTheFile() async throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + var changed = false + store.onChange = { changed = true } + store.startWatching() + try "Anthropic\n".write(to: url, atomically: true, encoding: .utf8) + for _ in 0..<40 where !changed { try await Task.sleep(for: .milliseconds(100)) } + store.stopWatching() + #expect(changed) + #expect(store.entries == [term("Anthropic")]) + } +} + +struct DictionaryCorrectorTests { + @Test func correctsWhenEnabledAndPassesThroughWhenDisabled() { + let corrector = DictionaryCorrector(knowledge: FakeKnowledge()) + corrector.update(entries: [pair("cloud code", "Claude Code")]) + #expect(corrector.correct("cloud code").text == "Claude Code") + corrector.setEnabled(false) + #expect(corrector.correct("cloud code").text == "cloud code") + corrector.setEnabled(true) + corrector.update(entries: []) + #expect(corrector.correct("cloud code").text == "cloud code") + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "DictionaryStoreTests|DictionaryCorrectorTests"` +Expected: compilatiefout `cannot find 'DictionaryStore' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstData/DictionaryStore.swift`: + +```swift +import Foundation +import SstCore + +/// Owns `dictionary.txt`: reads, writes atomically, and notices edits made in another editor. +@MainActor +public final class DictionaryStore { + public static let header = """ + # Sst woordenboek + # Eén invoer per regel. Een los woord is een term (voorrang bij het model). + # 'gehoord => schrijf' is een correctie, bijvoorbeeld: cloud code => Claude Code + + """ + + public let fileURL: URL + public private(set) var file = DictionaryFile() + public var onChange: (() -> Void)? + + private var lastText = "" + private var watcher: DispatchSourceFileSystemObject? + private var debounce: Task? + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + public var entries: [DictionaryEntry] { file.entries } + + /// Reads the file; creates it with a short header when it does not exist yet. + public func load() throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: fileURL.path) { + try Self.header.write(to: fileURL, atomically: true, encoding: .utf8) + } + let text = try String(contentsOf: fileURL, encoding: .utf8) + lastText = text + file = DictionaryFile(parsing: text) + onChange?() + } + + /// Applies an edit and writes the file straight away. + public func apply(_ mutate: (inout DictionaryFile) -> Void) throws { + var updated = file + mutate(&updated) + let text = updated.serialized() + try text.write(to: fileURL, atomically: true, encoding: .utf8) + file = updated + lastText = text + onChange?() + } + + /// Re-reads the file when its text differs from what we last wrote or read. + public func reloadIfChanged() { + guard let text = try? String(contentsOf: fileURL, encoding: .utf8), text != lastText else { return } + lastText = text + file = DictionaryFile(parsing: text) + onChange?() + } + + /// Watches the folder (editors replace files instead of editing them in place). + public func startWatching() { + guard watcher == nil else { return } + let descriptor = open(fileURL.deletingLastPathComponent().path, O_EVTONLY) + guard descriptor >= 0 else { return } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, eventMask: .write, queue: .main) + source.setEventHandler { [weak self] in + MainActor.assumeIsolated { self?.scheduleReload() } + } + source.setCancelHandler { close(descriptor) } + source.resume() + watcher = source + } + + public func stopWatching() { + watcher?.cancel() + watcher = nil + debounce?.cancel() + } + + private func scheduleReload() { + debounce?.cancel() + debounce = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(200)) + guard !Task.isCancelled else { return } + self?.reloadIfChanged() + } + } +} + +/// Bridges the dictionary to the controller: thread-safe, can be switched off, rebuilt when the dictionary changes. +public final class DictionaryCorrector: TextCorrecting, @unchecked Sendable { + private let lock = NSLock() + private let knowledge: WordKnowledge + private var engine: CorrectionEngine + private var enabled = true + + public init(knowledge: WordKnowledge) { + self.knowledge = knowledge + self.engine = CorrectionEngine(entries: [], knowledge: knowledge) + } + + public func update(entries: [DictionaryEntry]) { + let rebuilt = CorrectionEngine(entries: entries, knowledge: knowledge) + lock.withLock { engine = rebuilt } + } + + public func setEnabled(_ value: Bool) { + lock.withLock { enabled = value } + } + + public func correct(_ text: String) -> CorrectionResult { + let (current, isOn) = lock.withLock { (engine, enabled) } + return isOn ? current.correct(text) : CorrectionResult(text: text, corrections: []) + } +} +``` + +- [ ] **Step 4: Draai alle tests** + +Run: `swift test` +Expected: alle tests PASS (engine-tests uit fase 1 + 54 in `SstDataTests`). De test `watcherNoticesAnEditorSavingTheFile` wacht maximaal 4 s; faalt hij sporadisch, draai hem apart opnieuw voordat je de implementatie wijzigt. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstData Tests/SstDataTests +git commit -m "feat: add DictionaryStore with file watching and DictionaryCorrector" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +## Self-review + +- **Spec-dekking:** bestandsformaat en behoud van opmerkingen/volgorde/ongeldige regels (Task 1), alle correctieregels uit de spec — hele woorden, glue/koppelteken, langste eerst, geen ketens, termen die gewone woorden zijn, letterlijke vervanging, Cloudflare/cloud codex (Task 2), waarschuwingstabel (Task 3), voorrangslijst met limiet 24 (Task 3), geschiedenis met zoeken, groeperen, beschadigd bestand (Task 4), bestandsmonitor en uit/aan-schakelaar (Task 5). Niet in deze fase: de plaats van de bestanden in Application Support, de koppeling aan `DictationController` en de UI (fase 4 en 5). +- **Placeholders:** geen; alle code is uit een werkende, geteste versie overgenomen. +- **Typeconsistentie:** `DictionaryEntry.Kind` (`.term`, `.correction(heard:wrote:)`), `CorrectionEngine(entries:knowledge:)`, `EntryWarnings.check(_:among:knowledge:)`, `HistoryEntry(result:)` en `DictionaryStore.apply` komen overeen met wat Task 2–5 en de latere fasen gebruiken. +- **Afwijking van de spec (bewust):** "gewoon woord" gebruikt de Engelse woordenlijst van `NaturalLanguage` (alleen gewone woorden) plus de Nederlandse spellingcontrole, niet de Engelse spellingcontrole: die rekent "anthropic" tot de woorden en zou jouw eigen voorbeeldterm onterecht uitsluiten van de hoofdletterregel. diff --git a/docs/superpowers/specs/2026-10-01-macos-app-design.md b/docs/superpowers/specs/2026-10-01-macos-app-design.md index 865de69..6f468c9 100644 --- a/docs/superpowers/specs/2026-10-01-macos-app-design.md +++ b/docs/superpowers/specs/2026-10-01-macos-app-design.md @@ -174,7 +174,7 @@ Tegen verzinnen op stilte: ### Waarschuwingen bij invoer die veel voorkomt -`EntryWarnings` bepaalt bij toevoegen en bewerken, met een injecteerbare `WordKnowledge` (standaard: `NSSpellChecker` voor nl en en, plus een meegeleverde lijst van de ~300 meest voorkomende functiewoorden in beide talen): +`EntryWarnings` bepaalt bij toevoegen en bewerken, met een injecteerbare `WordKnowledge`. Standaard: de Engelse woordenlijst van `NaturalLanguage` (alleen gewone woorden; "anthropic" en "vercel" staan er niet in, "cloud" en "reef" wel), de Nederlandse spellingcontrole van macOS, en een meegeleverde lijst van de meest voorkomende functiewoorden in beide talen (circa 170): | Niveau | Wanneer | |---|---|