@hasna/recordings 0.3.13 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +223 -37
  2. package/bun.lock +37 -38
  3. package/dist/cli/index.js +1884 -615
  4. package/dist/cli/macos-shortcut.d.ts +2 -2
  5. package/dist/db/database.d.ts.map +1 -1
  6. package/dist/db/pg-migrations.d.ts.map +1 -1
  7. package/dist/db/recordings.d.ts +6 -0
  8. package/dist/db/recordings.d.ts.map +1 -1
  9. package/dist/http/client.d.ts +183 -22
  10. package/dist/http/client.d.ts.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2858 -2288
  14. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  15. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  16. package/dist/lib/capture-probe.d.ts +3 -3
  17. package/dist/lib/capture-probe.d.ts.map +1 -1
  18. package/dist/lib/config.d.ts.map +1 -1
  19. package/dist/lib/local-opt-in.d.ts +132 -0
  20. package/dist/lib/local-opt-in.d.ts.map +1 -0
  21. package/dist/lib/macos-bundle.d.ts +1 -1
  22. package/dist/lib/persistence-probe.d.ts +56 -17
  23. package/dist/lib/persistence-probe.d.ts.map +1 -1
  24. package/dist/lib/transcriber.d.ts.map +1 -1
  25. package/dist/mcp/index.d.ts.map +1 -1
  26. package/dist/mcp/index.js +1689 -456
  27. package/dist/sdk/index.d.ts +13 -5
  28. package/dist/sdk/index.d.ts.map +1 -1
  29. package/dist/sdk/index.js +904 -1
  30. package/dist/sdk/resolve.d.ts +53 -0
  31. package/dist/sdk/resolve.d.ts.map +1 -0
  32. package/dist/sdk/v1.generated.d.ts +6 -0
  33. package/dist/sdk/v1.generated.d.ts.map +1 -1
  34. package/dist/server/client-ip.d.ts +39 -0
  35. package/dist/server/client-ip.d.ts.map +1 -0
  36. package/dist/server/cloud-config.d.ts +12 -0
  37. package/dist/server/cloud-config.d.ts.map +1 -1
  38. package/dist/server/cloud-readiness.d.ts.map +1 -1
  39. package/dist/server/cloud.d.ts +20 -2
  40. package/dist/server/cloud.d.ts.map +1 -1
  41. package/dist/server/index.js +566 -284
  42. package/dist/server/migrate-command.d.ts +1 -1
  43. package/dist/server/migrate-command.d.ts.map +1 -1
  44. package/dist/server/openapi.d.ts +21 -0
  45. package/dist/server/openapi.d.ts.map +1 -1
  46. package/dist/server/repo.d.ts.map +1 -1
  47. package/dist/server/serve.d.ts.map +1 -1
  48. package/dist/storage.d.ts +2 -2
  49. package/dist/storage.d.ts.map +1 -1
  50. package/dist/storage.js +1334 -871
  51. package/dist/store.d.ts +12 -3
  52. package/dist/store.d.ts.map +1 -1
  53. package/dist/types/index.d.ts +15 -0
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/package.json +9 -8
  56. package/packaging/macos/build_release_pkg.sh +3 -3
  57. package/packaging/macos/managed_bootstrap.sh +4 -4
  58. package/packaging/macos/scripts/postinstall +2 -2
  59. package/packaging/macos/scripts/preinstall +2 -2
  60. package/scripts/build_companion_cli.sh +7 -1
  61. package/scripts/install_macos_app.sh +27 -24
  62. package/scripts/macos_artifact.ts +20 -16
  63. package/scripts/migrate.ts +4 -20
  64. package/scripts/smoke_macos_app.sh +21 -21
  65. package/src/native/Recordings/App/ContentView.swift +53 -68
  66. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  67. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  68. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  69. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  70. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  71. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  72. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  73. package/src/native/Recordings/App/Theme.swift +73 -61
  74. package/src/native/Recordings/Package.swift +1 -0
  75. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  76. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  77. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  78. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  79. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +316 -34
  80. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  81. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +102 -0
  82. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  83. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  84. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  85. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  86. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  87. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +13 -4
  88. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  89. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  90. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  91. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +195 -0
  92. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  93. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +30 -0
  94. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  95. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  96. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  97. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  98. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  99. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  100. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  101. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  102. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  103. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  104. package/src/native/Recordings/build.sh +6 -6
  105. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -16,19 +16,23 @@ extension KeyboardShortcuts.Name {
16
16
 
17
17
  public struct TranscriptionResult: Identifiable, Sendable {
18
18
  public let id = UUID()
19
- let rawText: String
20
- let processedText: String?
21
- let timestamp: Date
22
- let projectId: String?
23
- let projectName: String?
19
+ public let rawText: String
20
+ public let processedText: String?
21
+ public let timestamp: Date
22
+ public let projectId: String?
23
+ public let projectName: String?
24
+ public let captureID: String?
25
+ public let audioURL: URL?
24
26
  public var displayText: String { processedText ?? rawText }
25
27
 
26
- init(rawText: String, processedText: String?, timestamp: Date, projectId: String?, projectName: String?) {
28
+ public init(rawText: String, processedText: String?, timestamp: Date, projectId: String?, projectName: String?, captureID: String? = nil, audioURL: URL? = nil) {
27
29
  self.rawText = rawText
28
30
  self.processedText = processedText
29
31
  self.timestamp = timestamp
30
32
  self.projectId = projectId
31
33
  self.projectName = projectName
34
+ self.captureID = captureID
35
+ self.audioURL = audioURL
32
36
  }
33
37
  }
34
38
 
@@ -95,7 +99,7 @@ struct FrontmostAppSnapshot: Equatable, Sendable {
95
99
 
96
100
  /// The one capability `RecordingEngine` needs from an audio recorder; lets tests run the
97
101
  /// production start path without microphone hardware or TCC grants.
98
- protocol PCMRecordingSource: AnyObject {
102
+ protocol PCMRecordingSource: AnyObject, Sendable {
99
103
  func start() throws
100
104
  func stop()
101
105
  }
@@ -671,7 +675,7 @@ private final class PCMStreamPipe: @unchecked Sendable {
671
675
  private let continuation: AsyncStream<Data>.Continuation
672
676
  private let processor: Task<Data, Never>
673
677
 
674
- init(chunkSize: Int, client: RealtimeTranscriptionClient?) {
678
+ init(chunkSize: Int, client: RealtimeTranscriptionClient?, providerSession: (any RecordingTranscriptionSession)? = nil) {
675
679
  var streamContinuation: AsyncStream<Data>.Continuation!
676
680
  let stream = AsyncStream<Data>(bufferingPolicy: .unbounded) { continuation in
677
681
  streamContinuation = continuation
@@ -682,17 +686,21 @@ private final class PCMStreamPipe: @unchecked Sendable {
682
686
  var pendingChunk = Data()
683
687
 
684
688
  for await data in stream {
689
+ guard !Task.isCancelled else { break }
685
690
  guard !data.isEmpty else { continue }
686
691
  recordedPCM.append(data)
687
692
  pendingChunk.append(data)
688
693
 
689
694
  while pendingChunk.count >= chunkSize {
690
- await client?.sendAudio(pendingChunk.prefixData(count: chunkSize))
695
+ let chunk = pendingChunk.prefixData(count: chunkSize)
696
+ providerSession?.appendPCM(chunk)
697
+ await client?.sendAudio(chunk)
691
698
  pendingChunk.removeFirst(chunkSize)
692
699
  }
693
700
  }
694
701
 
695
- if !pendingChunk.isEmpty {
702
+ if !Task.isCancelled && !pendingChunk.isEmpty {
703
+ providerSession?.appendPCM(pendingChunk)
696
704
  await client?.sendAudio(pendingChunk)
697
705
  }
698
706
  return recordedPCM
@@ -737,7 +745,7 @@ public final class RecordingEngine: ObservableObject {
737
745
  @Published public private(set) var isWarmingUpCapture = false
738
746
  @Published public var useFnKey: Bool = false {
739
747
  didSet {
740
- UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
748
+ preferences.set(useFnKey, forKey: "useFnKey")
741
749
  updateFnMonitor()
742
750
  refreshTriggerDiagnostics()
743
751
  }
@@ -854,9 +862,9 @@ public final class RecordingEngine: ObservableObject {
854
862
  private var deliveryBlockedReasonGeneration: UInt64?
855
863
  /// Advanced fallback policy (Settings only): when off, every recording is dictated
856
864
  /// literally and the classifier is never consulted.
857
- @Published public var intentDetectionEnabled: Bool = true {
865
+ @Published public var intentDetectionEnabled: Bool = false {
858
866
  didSet {
859
- UserDefaults.standard.set(intentDetectionEnabled, forKey: "intentDetectionEnabled")
867
+ preferences.set(intentDetectionEnabled, forKey: "intentDetectionEnabled")
860
868
  }
861
869
  }
862
870
  /// Typed Record-page state; views render idle/listening/finalizing/processing/ready/error
@@ -874,13 +882,64 @@ public final class RecordingEngine: ObservableObject {
874
882
  @Published public var isTranscribing = false
875
883
  @Published public var recordingDuration: TimeInterval = 0
876
884
  @Published public var liveTranscriptionText = ""
885
+ @Published public private(set) var isPaused = false
886
+ @Published public private(set) var audioLevel: Double = 0
887
+ @Published public private(set) var recentPastes: [RecentPaste] = []
888
+ @Published public private(set) var latestAudioPath: String?
889
+ @Published public var autoPasteEnabled = true {
890
+ didSet {
891
+ if usesIsolatedProvider || home == FileManager.default.homeDirectoryForCurrentUser.path {
892
+ preferences.set(autoPasteEnabled, forKey: "recordingsAutoPaste")
893
+ }
894
+ }
895
+ }
896
+ private var captureMonitor = CaptureMonitor()
897
+
898
+ public func togglePause() {
899
+ guard isRecording else { return }
900
+ isPaused.toggle()
901
+ captureMonitor.setPaused(isPaused)
902
+ statusMessage = isPaused ? "Paused" : "Recording…"
903
+ }
904
+
905
+ public func clearRecentPastes() { recentPastes.removeAll() }
906
+
907
+ #if DEBUG
908
+ /// Explicitly isolated design review only; does not open a microphone or connection.
909
+ public func configureVisualPreview() {
910
+ isRecording = true
911
+ recordingDuration = 12
912
+ audioLevel = 0.65
913
+ liveTranscriptionText = "Here are the key takeaways from today’s meeting…"
914
+ let samples = [
915
+ ("com.apple.MobileSMS", "To: James", "Schedule a review for tomorrow at 2 PM…"),
916
+ ("com.apple.Notes", "New Note", "Here are the key takeaways from today’s…"),
917
+ ("com.tinyspeck.slackmacgap", "#product", "Can you confirm we’re still on for Friday?"),
918
+ ("com.apple.mail", "New message", "Sharing the updated timeline and next steps."),
919
+ ("notion.id", "Page", "Write a short summary of the discussion…")
920
+ ]
921
+ recentPastes = zip(samples, ["Messages", "Notes", "Slack", "Mail", "Notion"]).map { sample, name in RecentPaste(text: sample.2, bundleIdentifier: sample.0, appName: name, location: sample.1, status: "Pasted", verified: true) }
922
+ }
923
+ #endif
924
+
877
925
  @Published public var transcriptionLanguage = OpenAIAPIKeyStore.defaultLanguage {
878
926
  didSet {
879
- UserDefaults.standard.set(transcriptionLanguage, forKey: "recordingsLanguage")
880
- try? OpenAIAPIKeyStore.saveLanguage(language: transcriptionLanguage, homePath: home)
927
+ preferences.set(transcriptionLanguage, forKey: "recordingsLanguage")
928
+ if !usesIsolatedProvider {
929
+ try? OpenAIAPIKeyStore.saveLanguage(language: transcriptionLanguage, homePath: home)
930
+ }
881
931
  }
882
932
  }
883
933
 
934
+ private let preferences: UserDefaults
935
+ private let preferencesSuiteName: String?
936
+ private let installsGlobalHandlers: Bool
937
+ private let transcriptionProvider: (any RecordingTranscriptionProvider)?
938
+ private var usesIsolatedProvider: Bool { transcriptionProvider != nil }
939
+ private var providerSession: (any RecordingTranscriptionSession)?
940
+ private var providerConfiguration: RecordingProviderSessionConfiguration?
941
+ private var providerCompletionTask: Task<Void, Never>?
942
+
884
943
  private var nativeRecorder: PCMRecordingSource?
885
944
  private var recordingTimer: Timer?
886
945
  private var activeTrigger: RecordingTrigger?
@@ -891,8 +950,18 @@ public final class RecordingEngine: ObservableObject {
891
950
  private var targetAppPid: pid_t?
892
951
  private var pasteTargetProcessIdentityByGeneration: [UInt64: PasteTargetProcessIdentity] = [:]
893
952
  public var projectStore: ProjectStore?
953
+ /// The minimal app uses only global cleanup preferences from the legacy settings
954
+ /// file. Keeping this separate prevents old active projects from tagging new captures.
955
+ public var globalRecordingPreferences: ProjectStore?
894
956
  public var voiceShortcuts: VoiceShortcuts?
895
957
 
958
+ var recordingCleanupPreferences: (prompt: String, mode: String) {
959
+ if let settings = globalRecordingPreferences?.settings {
960
+ return (settings.globalSystemPrompt, (PostProcessingMode(rawValue: settings.postProcessingMode) ?? .auto).rawValue)
961
+ }
962
+ return (projectStore?.effectiveSystemPrompt ?? "", projectStore?.effectivePostProcessingMode ?? PostProcessingMode.auto.rawValue)
963
+ }
964
+
896
965
  // MARK: - Injectable boundaries
897
966
  // Production defaults perform the real I/O; tests replace them to drive the production
898
967
  // start/delivery paths without microphone, Accessibility, network, or CLI access.
@@ -1188,15 +1257,37 @@ public final class RecordingEngine: ObservableObject {
1188
1257
  let home: String
1189
1258
  private var audioDir: String { "\(home)/.hasna/recordings/audio" }
1190
1259
 
1191
- public init(homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
1260
+ public convenience init(homePath: String = FileManager.default.homeDirectoryForCurrentUser.path, installsGlobalHandlers: Bool = true) {
1261
+ self.init(homePath: homePath, preferences: .standard, preferencesSuiteName: nil, installsGlobalHandlers: installsGlobalHandlers, transcriptionProvider: nil)
1262
+ }
1263
+
1264
+ /// Reuses native capture and paste with isolated preferences and an explicit provider.
1265
+ /// No global handlers, legacy keys, environment routing or helper CLI are consulted.
1266
+ public convenience init(configuration: RecordingEngineConfiguration, transcriptionProvider: any RecordingTranscriptionProvider) {
1267
+ // A validated non-empty suite name is supported by Foundation on macOS.
1268
+ let preferences = UserDefaults(suiteName: configuration.preferencesSuiteName)!
1269
+ self.init(homePath: configuration.isolatedHomePath, preferences: preferences, preferencesSuiteName: configuration.preferencesSuiteName, installsGlobalHandlers: false, transcriptionProvider: transcriptionProvider)
1270
+ }
1271
+
1272
+ private init(homePath: String, preferences: UserDefaults, preferencesSuiteName: String?, installsGlobalHandlers: Bool, transcriptionProvider: (any RecordingTranscriptionProvider)?) {
1192
1273
  home = homePath
1274
+ self.preferences = preferences
1275
+ self.preferencesSuiteName = preferencesSuiteName
1276
+ self.installsGlobalHandlers = installsGlobalHandlers
1277
+ self.transcriptionProvider = transcriptionProvider
1193
1278
  try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
1194
1279
  log("RecordingEngine init; microphone=\(microphonePermissionLabel); accessibility=\(accessibilityPermissionLabel)")
1195
1280
 
1196
1281
  // Load preferences
1197
- intentDetectionEnabled = UserDefaults.standard.object(forKey: "intentDetectionEnabled") as? Bool ?? true
1198
- transcriptionLanguage = OpenAIAPIKeyStore.loadLanguage(homePath: home)
1199
- useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? false
1282
+ if usesIsolatedProvider || home == FileManager.default.homeDirectoryForCurrentUser.path {
1283
+ autoPasteEnabled = storedPreference("recordingsAutoPaste") as? Bool ?? true
1284
+ }
1285
+ intentDetectionEnabled = storedPreference("intentDetectionEnabled") as? Bool ?? false
1286
+ transcriptionLanguage = usesIsolatedProvider
1287
+ ? (storedPreference("recordingsLanguage") as? String ?? "en")
1288
+ : OpenAIAPIKeyStore.loadLanguage(homePath: home)
1289
+ useFnKey = storedPreference("useFnKey") as? Bool ?? false
1290
+ guard installsGlobalHandlers else { statusMessage = "Ready"; return }
1200
1291
  if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
1201
1292
  KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
1202
1293
  }
@@ -1268,6 +1359,15 @@ public final class RecordingEngine: ObservableObject {
1268
1359
  updateStatus()
1269
1360
  }
1270
1361
 
1362
+ private func storedPreference(_ key: String) -> Any? {
1363
+ if let preferencesSuiteName {
1364
+ // UserDefaults.object also consults process/global domains. A separate app's
1365
+ // engine reads only its explicitly named persistent domain.
1366
+ return preferences.persistentDomain(forName: preferencesSuiteName)?[key]
1367
+ }
1368
+ return preferences.object(forKey: key)
1369
+ }
1370
+
1271
1371
  public var microphonePermissionLabel: String {
1272
1372
  switch AVCaptureDevice.authorizationStatus(for: .audio) {
1273
1373
  case .authorized:
@@ -1331,6 +1431,7 @@ public final class RecordingEngine: ObservableObject {
1331
1431
  /// was indistinguishable from a working one. Log the resolved binding so "is the
1332
1432
  /// trigger armed, and to what" is answerable from the log alone.
1333
1433
  public func logResolvedTrigger() {
1434
+ guard installsGlobalHandlers else { return }
1334
1435
  let stored = KeyboardShortcuts.getShortcut(for: .toggleRecording)
1335
1436
  let bound = stored
1336
1437
  .map { "carbonKeyCode=\($0.carbonKeyCode) carbonModifiers=\($0.carbonModifiers)" }
@@ -1406,6 +1507,7 @@ public final class RecordingEngine: ObservableObject {
1406
1507
  }
1407
1508
 
1408
1509
  private func updateFnMonitor(allowAutomaticPrompt: Bool = true) {
1510
+ guard installsGlobalHandlers else { return }
1409
1511
  // Decided as a local first, then handed to the single writer once. Assigning the
1410
1512
  // published property from each branch is how the per-source erasure bug got in.
1411
1513
  var reason: String?
@@ -1512,6 +1614,7 @@ public final class RecordingEngine: ObservableObject {
1512
1614
  /// Re-evaluate every trigger's health, push it to the UI, and record it. The one entry
1513
1615
  /// point callers should use after anything changes a binding.
1514
1616
  public func refreshTriggerDiagnostics() {
1617
+ guard installsGlobalHandlers else { updateStatus(); return }
1515
1618
  refreshHotkeyDiagnostics()
1516
1619
  updateStatus()
1517
1620
  logResolvedTrigger()
@@ -1702,7 +1805,7 @@ public final class RecordingEngine: ObservableObject {
1702
1805
  let shouldCaptureSelection = Self.shouldCaptureSelection(
1703
1806
  targetPid: targetAppPid,
1704
1807
  accessibilityTrusted: accessibilityTrustCheck(),
1705
- intentDetectionEnabled: intentDetectionEnabled
1808
+ intentDetectionEnabled: !usesIsolatedProvider && intentDetectionEnabled
1706
1809
  )
1707
1810
  let capturePid = targetAppPid
1708
1811
  let captureSelection = selectionCapture
@@ -1722,9 +1825,11 @@ public final class RecordingEngine: ObservableObject {
1722
1825
  // recording pipeline awaits this context only after the recorder has stopped.
1723
1826
  let generation = recordingGeneration
1724
1827
  let projectStore = projectStore
1828
+ let cleanupPreferences = recordingCleanupPreferences
1725
1829
  let targetBundleIdentifierForProjects = targetAppBundleIdentifier
1726
1830
  let transcriptionLanguageAtStart = transcriptionLanguage
1727
- let intentDetectionEnabledAtStart = intentDetectionEnabled
1831
+ let intentDetectionEnabledAtStart = !usesIsolatedProvider && intentDetectionEnabled
1832
+ let usesIsolatedProvider = usesIsolatedProvider
1728
1833
  let homePath = home
1729
1834
  let startContext = Task { @MainActor [weak self] () -> RecordingStartResolvedContext in
1730
1835
  let axSnapshot = await axSnapshotTask.value
@@ -1748,7 +1853,9 @@ public final class RecordingEngine: ObservableObject {
1748
1853
  self.log("project synchronization degraded; continuing capture: \(warning)")
1749
1854
  }
1750
1855
  }
1751
- let modelSelection = OpenAIAPIKeyStore.loadProcessingModelSelection(homePath: homePath)
1856
+ let modelSelection = usesIsolatedProvider
1857
+ ? ProcessingModelSelection(transcriptionPrompt: "", transcriptionModel: "", transcriberModel: "", enhancementModel: "", intentModel: "", enhanceTriggersJSON: "[]", keywordTransformsJSON: "{}")
1858
+ : OpenAIAPIKeyStore.loadProcessingModelSelection(homePath: homePath)
1752
1859
  return RecordingStartResolvedContext(
1753
1860
  selectionToken: axSnapshot.selectionToken,
1754
1861
  canonicalProjectId: projectStore?.activeCanonicalProjectIdForRecording,
@@ -1756,8 +1863,8 @@ public final class RecordingEngine: ObservableObject {
1756
1863
  activeProjectName: projectStore?.activeProject?.name,
1757
1864
  processing: RecordingProcessingConfiguration(
1758
1865
  transcriptionPrompt: modelSelection.transcriptionPrompt,
1759
- transcriberPrompt: projectStore?.effectiveSystemPrompt ?? "",
1760
- postProcessingMode: projectStore?.effectivePostProcessingMode ?? PostProcessingMode.auto.rawValue,
1866
+ transcriberPrompt: cleanupPreferences.prompt,
1867
+ postProcessingMode: cleanupPreferences.mode,
1761
1868
  transcriptionLanguage: transcriptionLanguageAtStart,
1762
1869
  transcriptionModel: modelSelection.transcriptionModel,
1763
1870
  transcriberModel: modelSelection.transcriberModel,
@@ -1945,7 +2052,26 @@ public final class RecordingEngine: ObservableObject {
1945
2052
  }
1946
2053
 
1947
2054
  private func startNativeRecording(startContext: Task<RecordingStartResolvedContext, Never>) {
1948
- let apiKey = openAIAPIKeyProvider()
2055
+ let apiKey = usesIsolatedProvider ? "" : openAIAPIKeyProvider()
2056
+ if let transcriptionProvider {
2057
+ let generation = recordingGeneration
2058
+ let configuration = RecordingProviderSessionConfiguration(captureID: UUID().uuidString, language: transcriptionLanguage)
2059
+ do {
2060
+ providerSession = try transcriptionProvider.makeSession(configuration: configuration) { [weak self] text in
2061
+ Task { @MainActor [weak self] in
2062
+ guard let self, self.recordingGeneration == generation,
2063
+ self.captureIsActive || self.isTranscribing else { return }
2064
+ self.liveTranscriptionText = text
2065
+ }
2066
+ }
2067
+ providerConfiguration = configuration
2068
+ } catch {
2069
+ resetRecordingIntent()
2070
+ statusMessage = error.localizedDescription
2071
+ flowPhase = .failed(statusMessage)
2072
+ return
2073
+ }
2074
+ }
1949
2075
  let captureConfiguration = RecordingCaptureConfiguration(
1950
2076
  targetAppBundleIdentifier: targetAppBundleIdentifier,
1951
2077
  targetAppPid: targetAppPid,
@@ -1964,7 +2090,7 @@ public final class RecordingEngine: ObservableObject {
1964
2090
  : RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
1965
2091
  realtimeClient = client
1966
2092
 
1967
- let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client)
2093
+ let streamPipe = PCMStreamPipe(chunkSize: 4_800, client: client, providerSession: providerSession)
1968
2094
  pcmStreamPipe = streamPipe
1969
2095
  let homePath = home
1970
2096
  let captureGeneration = recordingGeneration
@@ -1972,7 +2098,12 @@ public final class RecordingEngine: ObservableObject {
1972
2098
  self?.confirmCaptureIsLive(generation: generation)
1973
2099
  }
1974
2100
  let firstChunkLogged = LockedFlag()
2101
+ let monitor = CaptureMonitor()
2102
+ captureMonitor = monitor
2103
+ isPaused = false
2104
+ audioLevel = 0
1975
2105
  let recorder = recorderFactory { data in
2106
+ guard monitor.admit(data) else { return }
1976
2107
  if firstChunkLogged.take() {
1977
2108
  NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
1978
2109
  // The first sample is the only honest signal that this recording exists.
@@ -2004,10 +2135,14 @@ public final class RecordingEngine: ObservableObject {
2004
2135
 
2005
2136
  recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
2006
2137
  Task { @MainActor [weak self] in
2007
- self?.recordingDuration += 0.1
2138
+ guard let self else { return }
2139
+ let sample = monitor.snapshot()
2140
+ self.recordingDuration = sample.duration
2141
+ self.audioLevel = sample.level
2008
2142
  }
2009
2143
  }
2010
2144
  } catch {
2145
+ cancelProviderSession()
2011
2146
  log("native recorder failed error=\(error.localizedDescription)")
2012
2147
  // Unreachable today — nothing between `recorder.start()` and `isWarmingUpCapture`
2013
2148
  // can throw — but a warming flag left set here wedges the engine permanently: the
@@ -2049,6 +2184,7 @@ public final class RecordingEngine: ObservableObject {
2049
2184
  private func abandonWarmingCapture(reason: String, alert: RecordingAttemptAlert?) {
2050
2185
  guard isWarmingUpCapture else { return }
2051
2186
  log("capture abandoned before first audio reason=\(reason)")
2187
+ cancelProviderSession()
2052
2188
  // Supersede the attempt so every completion still bound to it — a queued first-chunk
2053
2189
  // confirmation, the resolved start context — is stale and cannot apply.
2054
2190
  recordingGeneration &+= 1
@@ -2154,6 +2290,18 @@ public final class RecordingEngine: ObservableObject {
2154
2290
  // MARK: - Cancel (discard without transcribing)
2155
2291
 
2156
2292
  public func cancelRecording() {
2293
+ if usesIsolatedProvider && isTranscribing {
2294
+ pipelineDeliveryGate.abandonPipeline(recordingGeneration)
2295
+ recordingGeneration &+= 1
2296
+ cancelProviderSession()
2297
+ isTranscribing = false
2298
+ liveTranscriptionText = ""
2299
+ recordedPCM.removeAll(keepingCapacity: true)
2300
+ activeAudioPath = nil
2301
+ resetRecordingIntent()
2302
+ updateStatus()
2303
+ return
2304
+ }
2157
2305
  // Discard during warm-up: identical teardown, but the user asked for it, so the glyph
2158
2306
  // stays quiet.
2159
2307
  if isWarmingUpCapture {
@@ -2162,6 +2310,7 @@ public final class RecordingEngine: ObservableObject {
2162
2310
  }
2163
2311
  guard isRecording else { return }
2164
2312
  log("cancelRecording")
2313
+ cancelProviderSession()
2165
2314
 
2166
2315
  recordingTimer?.invalidate()
2167
2316
  recordingTimer = nil
@@ -2178,6 +2327,9 @@ public final class RecordingEngine: ObservableObject {
2178
2327
  pcmStreamPipe = nil
2179
2328
 
2180
2329
  isRecording = false
2330
+ isPaused = false
2331
+ audioLevel = 0
2332
+ recordingDuration = 0
2181
2333
  isTranscribing = false
2182
2334
  liveTranscriptionText = ""
2183
2335
  recordedPCM.removeAll(keepingCapacity: true)
@@ -2191,6 +2343,89 @@ public final class RecordingEngine: ObservableObject {
2191
2343
 
2192
2344
  // MARK: - Stop & Transcribe
2193
2345
 
2346
+ private func cancelProviderSession() {
2347
+ providerCompletionTask?.cancel()
2348
+ providerCompletionTask = nil
2349
+ providerSession?.cancel()
2350
+ providerSession = nil
2351
+ providerConfiguration = nil
2352
+ }
2353
+
2354
+ /// The provider path shares capture, WAV writing and verified paste delivery with the
2355
+ /// legacy recorder. It never enters the legacy CLI, credential or intent-model paths.
2356
+ private func stopWithProvider(
2357
+ recorder: PCMRecordingSource?, pipe: PCMStreamPipe?,
2358
+ session: any RecordingTranscriptionSession,
2359
+ configuration: RecordingProviderSessionConfiguration, audioPath: String?,
2360
+ targetAppBundleIdentifier: String?, targetAppPid: pid_t?,
2361
+ pipelineGeneration: UInt64, pipelineTrace: RecordingPipelineTrace
2362
+ ) {
2363
+ providerCompletionTask = Task { [weak self] in
2364
+ // Shutdown and drain belong to this capture even if the user cancels and starts
2365
+ // another one before the native input tap has stopped.
2366
+ await Task.detached(priority: .userInitiated) { recorder?.stop() }.value
2367
+ let pcm = await pipe?.finish() ?? Data()
2368
+ guard !Task.isCancelled, let self,
2369
+ self.recordingGeneration == pipelineGeneration else { return }
2370
+ do {
2371
+ guard !pcm.isEmpty, let audioPath else { throw RecordingProviderError.noAudio }
2372
+ let audioURL = URL(fileURLWithPath: audioPath)
2373
+ try await Task.detached(priority: .userInitiated) {
2374
+ try Self.writeWAV(pcmData: pcm, sampleRate: 24_000, channelCount: 1, bitsPerSample: 16, to: audioURL)
2375
+ }.value
2376
+ try Task.checkCancellation()
2377
+ guard self.recordingGeneration == pipelineGeneration else { return }
2378
+ let duration = Double(pcm.count) / 48_000
2379
+ self.recordingDuration = duration
2380
+ let result = try await session.finish(RecordingTranscriptionRequest(
2381
+ captureID: configuration.captureID, audioURL: audioURL,
2382
+ duration: duration, language: configuration.language
2383
+ ))
2384
+ try Task.checkCancellation()
2385
+ guard self.recordingGeneration == pipelineGeneration else { return }
2386
+ let rawText = result.rawText.trimmingCharacters(in: .whitespacesAndNewlines)
2387
+ let processed = result.processedText?.trimmingCharacters(in: .whitespacesAndNewlines)
2388
+ let text = (processed?.isEmpty == false ? processed : nil) ?? rawText
2389
+ guard !text.isEmpty else { throw RecordingProviderError.emptyTranscript }
2390
+ self.isTranscribing = false
2391
+ self.liveTranscriptionText = ""
2392
+ self.activeAudioPath = nil
2393
+ self.providerSession = nil
2394
+ self.providerConfiguration = nil
2395
+ self.providerCompletionTask = nil
2396
+ // Publication precedes delivery. Consumers may persist asynchronously without
2397
+ // delaying paste; this is an in-memory result, not a persistence receipt.
2398
+ self.recentTranscriptions.insert(TranscriptionResult(
2399
+ rawText: rawText, processedText: processed?.isEmpty == false ? processed : nil,
2400
+ timestamp: Date(), projectId: nil, projectName: nil,
2401
+ captureID: configuration.captureID, audioURL: audioURL
2402
+ ), at: 0)
2403
+ if self.recentTranscriptions.count > 20 { self.recentTranscriptions.removeLast() }
2404
+ guard self.pipelineDeliveryGate.claimDelivery(for: pipelineGeneration) else { return }
2405
+ self.pasteIntoFrontApp(
2406
+ text, targetAppBundleIdentifier: targetAppBundleIdentifier,
2407
+ targetAppPid: targetAppPid, restoreClipboard: true,
2408
+ deliveryKind: .ordinaryDictation, captureID: configuration.captureID, pipelineTrace: pipelineTrace,
2409
+ pipelineGeneration: pipelineGeneration, deliveryCompleted: nil
2410
+ )
2411
+ } catch {
2412
+ guard self.recordingGeneration == pipelineGeneration, !Task.isCancelled else { return }
2413
+ session.cancel()
2414
+ self.providerSession = nil
2415
+ self.providerConfiguration = nil
2416
+ self.providerCompletionTask = nil
2417
+ self.activeAudioPath = nil
2418
+ self.pipelineDeliveryGate.abandonPipeline(pipelineGeneration)
2419
+ // Do not log provider errors: third-party error text can contain a token or
2420
+ // request URL. The host UI receives the localized description for diagnosis.
2421
+ self.isTranscribing = false
2422
+ self.liveTranscriptionText = ""
2423
+ self.statusMessage = error.localizedDescription
2424
+ self.flowPhase = .failed(self.statusMessage)
2425
+ }
2426
+ }
2427
+ }
2428
+
2194
2429
  public func stopAndTranscribe() {
2195
2430
  // Stop during the warm-up window has nothing to transcribe — the microphone was opened
2196
2431
  // but has not delivered a sample. Abandon visibly instead of spending a transcription
@@ -2208,12 +2443,12 @@ public final class RecordingEngine: ObservableObject {
2208
2443
 
2209
2444
  let recorder = nativeRecorder
2210
2445
  nativeRecorder = nil
2211
- recorder?.stop()
2212
2446
 
2213
2447
  isRecording = false
2214
2448
  isTranscribing = true
2215
2449
 
2216
2450
  guard let captureConfiguration = activeCaptureConfiguration else {
2451
+ recorder?.stop()
2217
2452
  realtimeClient?.stop()
2218
2453
  realtimeClient = nil
2219
2454
  streamingTask?.cancel()
@@ -2230,6 +2465,9 @@ public final class RecordingEngine: ObservableObject {
2230
2465
  let targetAppBundleIdentifier = captureConfiguration.targetAppBundleIdentifier
2231
2466
  let targetAppPid = captureConfiguration.targetAppPid
2232
2467
  let audioPath = activeAudioPath
2468
+ latestAudioPath = audioPath
2469
+ isPaused = false
2470
+ audioLevel = 0
2233
2471
  let pcmStreamPipe = pcmStreamPipe
2234
2472
  let client = realtimeClient
2235
2473
  let pipelineGeneration = recordingGeneration
@@ -2239,9 +2477,23 @@ public final class RecordingEngine: ObservableObject {
2239
2477
  resetRecordingIntent()
2240
2478
  self.pcmStreamPipe = nil
2241
2479
 
2480
+ if let providerSession, let providerConfiguration {
2481
+ stopWithProvider(
2482
+ recorder: recorder, pipe: pcmStreamPipe, session: providerSession,
2483
+ configuration: providerConfiguration, audioPath: audioPath,
2484
+ targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid,
2485
+ pipelineGeneration: pipelineGeneration, pipelineTrace: pipelineTrace
2486
+ )
2487
+ return
2488
+ }
2489
+
2242
2490
  Task {
2491
+ // AVAudioEngine shutdown can block for hundreds of milliseconds. Keep
2492
+ // receiving realtime text and repainting while capture drains completely.
2493
+ await Task.detached(priority: .userInitiated) { recorder?.stop() }.value
2243
2494
  if let pcmStreamPipe {
2244
2495
  self.recordedPCM = await pcmStreamPipe.finish()
2496
+ self.recordingDuration = Double(self.recordedPCM.count) / 48_000
2245
2497
  }
2246
2498
  self.log(pipelineTrace.message(
2247
2499
  stage: "pcm_drain_complete",
@@ -3969,7 +4221,8 @@ public final class RecordingEngine: ObservableObject {
3969
4221
  _ text: String,
3970
4222
  targetAppBundleIdentifier: String? = nil,
3971
4223
  targetAppPid: pid_t? = nil,
3972
- restoreClipboard: Bool = false
4224
+ restoreClipboard: Bool = false,
4225
+ captureID: String? = nil
3973
4226
  ) {
3974
4227
  pasteIntoFrontApp(
3975
4228
  text,
@@ -3977,6 +4230,7 @@ public final class RecordingEngine: ObservableObject {
3977
4230
  targetAppPid: targetAppPid,
3978
4231
  restoreClipboard: restoreClipboard,
3979
4232
  deliveryKind: .manualPaste,
4233
+ captureID: captureID,
3980
4234
  pipelineTrace: nil,
3981
4235
  pipelineGeneration: nil,
3982
4236
  deliveryCompleted: nil
@@ -3989,6 +4243,7 @@ public final class RecordingEngine: ObservableObject {
3989
4243
  targetAppPid: pid_t? = nil,
3990
4244
  restoreClipboard: Bool = false,
3991
4245
  deliveryKind: PasteDeliveryKind,
4246
+ captureID: String? = nil,
3992
4247
  selectionToken: AccessibilitySelectionToken? = nil,
3993
4248
  pipelineTrace: RecordingPipelineTrace?,
3994
4249
  pipelineGeneration: UInt64?,
@@ -4007,6 +4262,11 @@ public final class RecordingEngine: ObservableObject {
4007
4262
  deliveryCompleted?()
4008
4263
  return
4009
4264
  }
4265
+ if !autoPasteEnabled && deliveryKind != .manualPaste {
4266
+ updateDeliveryStatus("Transcript ready — auto-paste is off", kind: .success, pipelineGeneration: pipelineGeneration)
4267
+ deliveryCompleted?()
4268
+ return
4269
+ }
4010
4270
  if let pasteInterceptorForTesting {
4011
4271
  pasteInterceptorForTesting(text, deliveryKind, pipelineGeneration)
4012
4272
  deliveryCompleted?()
@@ -4176,6 +4436,14 @@ public final class RecordingEngine: ObservableObject {
4176
4436
  detail: "chars=\(transaction.text.count)"
4177
4437
  ))
4178
4438
  }
4439
+ let verified = Self.pasteTraceStage(for: outcome) == "paste_delivery_confirmed"
4440
+ let deliveryStatus = Self.recentPasteDeliveryStatus(for: outcome)
4441
+ self.recentPastes.insert(RecentPaste(
4442
+ text: transaction.text, bundleIdentifier: app.bundleIdentifier, appName: app.localizedName ?? "Application",
4443
+ location: "Focused field", status: verified ? "Pasted" : "Unconfirmed", verified: verified,
4444
+ captureID: captureID, deliveryStatus: deliveryStatus
4445
+ ), at: 0)
4446
+ if self.recentPastes.count > 50 { self.recentPastes.removeLast() }
4179
4447
  deliveryCompleted?()
4180
4448
  let message = switch outcome {
4181
4449
  case .pasted: "Pasted (\(transaction.text.count) chars)"
@@ -4521,6 +4789,15 @@ public final class RecordingEngine: ObservableObject {
4521
4789
  /// Pipeline-timing stage name. `paste_posted` used to be emitted for every posted
4522
4790
  /// keystroke, which made the timing trace read like a delivery record; the three delivery
4523
4791
  /// verdicts are now distinct stages.
4792
+ nonisolated static func recentPasteDeliveryStatus(for outcome: PasteDeliveryOutcome) -> RecentPasteDeliveryStatus {
4793
+ switch outcome {
4794
+ case .pasted: .confirmed
4795
+ case .deliveredUnverified: .unconfirmed
4796
+ case .deliveryNotObserved, .secureInputActive, .targetUnavailable,
4797
+ .clipboardOwnershipLost, .clipboardWriteFailed, .eventPostFailed: .notDelivered
4798
+ }
4799
+ }
4800
+
4524
4801
  nonisolated static func pasteTraceStage(for outcome: PasteDeliveryOutcome) -> String {
4525
4802
  switch outcome {
4526
4803
  case .pasted: "paste_delivery_confirmed"
@@ -4792,14 +5069,17 @@ enum CLIRunner: Sendable {
4792
5069
  _ args: [String],
4793
5070
  home: String,
4794
5071
  timeout: TimeInterval = 120,
4795
- totalWallClockBudget: TimeInterval? = nil
5072
+ totalWallClockBudget: TimeInterval? = nil,
5073
+ environment suppliedEnvironment: [String: String]? = nil
4796
5074
  ) -> String {
4797
5075
  let command = resolveCommand(home: home)
4798
5076
  let arguments = command.argumentsPrefix + args
4799
- let environment = ProcessInfo.processInfo.environment.merging([
4800
- "PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
4801
- ]) { _, new in new }
4802
5077
  do {
5078
+ let environment = try suppliedEnvironment ?? ServiceAPIConfiguration.childEnvironment(
5079
+ base: OpenAIAPIKeyStore.childEnvironment(base: ProcessInfo.processInfo.environment.merging([
5080
+ "PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
5081
+ ]) { _, new in new }, homePath: home)
5082
+ )
4803
5083
  let output = try runExecutable(
4804
5084
  command.executable,
4805
5085
  arguments: arguments,
@@ -5611,7 +5891,7 @@ enum CLIRunner: Sendable {
5611
5891
  }
5612
5892
  }
5613
5893
 
5614
- static func parseError(_ output: String) -> String? {
5894
+ static func parseError(_ output: String, serviceAPI: Bool = false) -> String? {
5615
5895
  let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
5616
5896
  guard trimmed.hasPrefix("ERROR:") else { return nil }
5617
5897
  let message = NativeErrorSanitizer.sanitize(
@@ -5620,10 +5900,12 @@ enum CLIRunner: Sendable {
5620
5900
  let lowercased = message.lowercased()
5621
5901
  if lowercased.contains("401") || lowercased.contains("incorrect api key")
5622
5902
  || lowercased.contains("invalid_api_key") || lowercased.contains("invalid or expired") {
5903
+ if serviceAPI { return "Recordings API authentication failed — check the API connection in Settings" }
5623
5904
  return "OpenAI API key invalid or expired — update it in Recordings Settings"
5624
5905
  }
5625
5906
  if lowercased.contains("429") || lowercased.contains("exceeded your current quota")
5626
5907
  || lowercased.contains("insufficient_quota") || lowercased.contains("quota exceeded") {
5908
+ if serviceAPI { return "Recordings API request limit reached — try again later" }
5627
5909
  return "OpenAI quota exceeded — check the OpenAI account billing"
5628
5910
  }
5629
5911
  if message.contains("OpenAI API key not configured") {