@hasna/recordings 0.1.11 → 0.1.12

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 (67) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/index.js +274 -36
  3. package/dist/db/database.d.ts.map +1 -1
  4. package/dist/db/recordings.d.ts.map +1 -1
  5. package/dist/index.js +52 -10
  6. package/dist/lib/config.d.ts.map +1 -1
  7. package/dist/lib/enhancer.d.ts.map +1 -1
  8. package/dist/lib/recorder.d.ts.map +1 -1
  9. package/dist/lib/transcriber.d.ts +8 -2
  10. package/dist/lib/transcriber.d.ts.map +1 -1
  11. package/dist/mcp/index.js +121 -29
  12. package/dist/types/index.d.ts +3 -0
  13. package/dist/types/index.d.ts.map +1 -1
  14. package/dist/version.d.ts +2 -0
  15. package/dist/version.d.ts.map +1 -0
  16. package/package.json +20 -3
  17. package/scripts/install_macos_app.sh +76 -0
  18. package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +5 -1
  19. package/src/native/Recordings/Package.resolved +19 -1
  20. package/src/native/Recordings/Package.swift +16 -5
  21. package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
  22. package/src/native/Recordings/{Recordings → RecordingsLib}/MenuBarPopover.swift +55 -11
  23. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
  24. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
  25. package/src/native/Recordings/{Recordings → RecordingsLib}/ProjectStore.swift +11 -11
  26. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
  27. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +724 -0
  28. package/src/native/Recordings/{Recordings → RecordingsLib}/SettingsView.swift +19 -5
  29. package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
  30. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
  31. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
  32. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
  33. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
  34. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
  35. package/src/native/Recordings/build.sh +6 -6
  36. package/.takumi/settings.local.json +0 -7
  37. package/bun.lock +0 -250
  38. package/bunfig.toml +0 -2
  39. package/src/__tests__/agents.test.ts +0 -136
  40. package/src/__tests__/config.test.ts +0 -252
  41. package/src/__tests__/database.test.ts +0 -167
  42. package/src/__tests__/enhancer.test.ts +0 -639
  43. package/src/__tests__/preload.ts +0 -4
  44. package/src/__tests__/projects.test.ts +0 -109
  45. package/src/__tests__/recorder.test.ts +0 -278
  46. package/src/__tests__/recordings.test.ts +0 -353
  47. package/src/__tests__/transcriber.test.ts +0 -322
  48. package/src/__tests__/types.test.ts +0 -75
  49. package/src/cli/index.ts +0 -988
  50. package/src/db/agents.ts +0 -104
  51. package/src/db/database.ts +0 -163
  52. package/src/db/pg-migrations.ts +0 -82
  53. package/src/db/projects.ts +0 -71
  54. package/src/db/recordings.ts +0 -225
  55. package/src/index.ts +0 -81
  56. package/src/lib/config.ts +0 -223
  57. package/src/lib/enhancer.ts +0 -173
  58. package/src/lib/recorder.ts +0 -198
  59. package/src/lib/transcriber.ts +0 -105
  60. package/src/mcp/index.ts +0 -464
  61. package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -455
  62. package/src/native/Recordings/test_fn.swift +0 -79
  63. package/src/native/Recordings/test_fn2.swift +0 -33
  64. package/src/types/index.ts +0 -144
  65. package/tsconfig.json +0 -21
  66. /package/src/native/Recordings/{Recordings → RecordingsLib}/FnKeyMonitor.swift +0 -0
  67. /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
@@ -0,0 +1,724 @@
1
+ import AVFoundation
2
+ @preconcurrency import ApplicationServices
3
+ import SwiftUI
4
+ import KeyboardShortcuts
5
+
6
+ // MARK: - Custom shortcut (not fn — fn is handled by FnKeyMonitor)
7
+
8
+ extension KeyboardShortcuts.Name {
9
+ static let toggleRecording = Self("toggleRecording", default: .init(.f5))
10
+ }
11
+
12
+ // MARK: - Recording Mode
13
+
14
+ public enum RecordingMode: String, CaseIterable, Identifiable, Sendable {
15
+ case pushToTalk = "Push to Talk"
16
+ case dictation = "Dictation"
17
+ case command = "Command"
18
+
19
+ public var id: String { rawValue }
20
+ public var icon: String {
21
+ switch self {
22
+ case .pushToTalk: return "hand.tap.fill"
23
+ case .dictation: return "text.bubble.fill"
24
+ case .command: return "wand.and.stars"
25
+ }
26
+ }
27
+ public var hint: String {
28
+ switch self {
29
+ case .pushToTalk: return "Hold F5 or your chosen shortcut to record, then release to paste"
30
+ case .dictation: return "Hold F5 or your chosen shortcut to dictate, then release to paste"
31
+ case .command: return "Select text, then hold F5 or your chosen shortcut and release to rewrite"
32
+ }
33
+ }
34
+ }
35
+
36
+ // MARK: - Transcription Result
37
+
38
+ public struct TranscriptionResult: Sendable {
39
+ let rawText: String
40
+ let processedText: String?
41
+ let timestamp: Date
42
+ let projectId: String?
43
+ let projectName: String?
44
+ var displayText: String { processedText ?? rawText }
45
+ }
46
+
47
+ public enum RecordingTrigger {
48
+ case manual
49
+ case fnKey
50
+ case keyboardShortcut
51
+ }
52
+
53
+ private actor PCMStreamState {
54
+ private var recordedPCM = Data()
55
+ private var pendingChunk = Data()
56
+
57
+ func append(_ data: Data, chunkSize: Int) -> [Data] {
58
+ guard !data.isEmpty else { return [] }
59
+
60
+ recordedPCM.append(data)
61
+ pendingChunk.append(data)
62
+
63
+ var chunks: [Data] = []
64
+ while pendingChunk.count >= chunkSize {
65
+ chunks.append(pendingChunk.prefixData(count: chunkSize))
66
+ pendingChunk.removeFirst(chunkSize)
67
+ }
68
+ return chunks
69
+ }
70
+
71
+ func flushPendingChunk() -> Data? {
72
+ guard !pendingChunk.isEmpty else { return nil }
73
+ let chunk = pendingChunk
74
+ pendingChunk.removeAll(keepingCapacity: true)
75
+ return chunk
76
+ }
77
+
78
+ func capturedPCM() -> Data {
79
+ recordedPCM
80
+ }
81
+ }
82
+
83
+ private extension Data {
84
+ func prefixData(count: Int) -> Data {
85
+ Data(prefix(count))
86
+ }
87
+ }
88
+
89
+ // MARK: - Recording Engine
90
+
91
+ @MainActor
92
+ public final class RecordingEngine: ObservableObject {
93
+ @Published public var isRecording = false
94
+ @Published public var mode: RecordingMode = .pushToTalk {
95
+ didSet {
96
+ UserDefaults.standard.set(mode.rawValue, forKey: "recordingMode")
97
+ updateStatus()
98
+ }
99
+ }
100
+ @Published public var useFnKey: Bool = false {
101
+ didSet {
102
+ UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
103
+ updateFnMonitor()
104
+ updateStatus()
105
+ }
106
+ }
107
+ @Published public var isWhisperMode = false
108
+ @Published public var recentTranscriptions: [TranscriptionResult] = []
109
+ @Published public var statusMessage = "Starting..."
110
+ @Published public var isTranscribing = false
111
+ @Published public var recordingDuration: TimeInterval = 0
112
+ @Published public var liveTranscriptionText = ""
113
+
114
+ private var nativeRecorder: NativePCMRecorder?
115
+ private var recordingTimer: Timer?
116
+ private var activeTrigger: RecordingTrigger?
117
+ private var keyboardShortcutIsDown = false
118
+ private var targetAppBundleIdentifier: String?
119
+ private var targetAppPid: pid_t?
120
+ public var projectStore: ProjectStore?
121
+ public var voiceShortcuts: VoiceShortcuts?
122
+
123
+ // Real-time streaming
124
+ private var realtimeClient: RealtimeTranscriptionClient?
125
+ private var streamingTask: Task<Void, Never>?
126
+ private var pcmStreamState: PCMStreamState?
127
+ private var streamingText = ""
128
+ private var recordedPCM = Data()
129
+ private var activeAudioPath: String?
130
+
131
+ // fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
132
+ private let fnMonitor = FnKeyMonitor()
133
+
134
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
135
+ private var audioDir: String { "\(home)/.hasna/recordings/audio" }
136
+
137
+ // MARK: - OpenAI API Key
138
+
139
+ private var openAIAPIKey: String {
140
+ OpenAIAPIKeyStore.load(homePath: home)
141
+ }
142
+
143
+ public init() {
144
+ try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
145
+
146
+ // Load preferences
147
+ if let savedMode = UserDefaults.standard.string(forKey: "recordingMode"),
148
+ let parsedMode = RecordingMode(rawValue: savedMode) {
149
+ mode = parsedMode
150
+ }
151
+ useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? false
152
+ if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
153
+ KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
154
+ }
155
+
156
+ // Set up fn key monitor — hold fn to record, release to stop (like WisprFlow)
157
+ fnMonitor.onFnKeyDown = { [weak self] in
158
+ Task { @MainActor [weak self] in
159
+ guard let self, self.useFnKey, !self.isRecording else { return }
160
+ self.startRecording(trigger: .fnKey)
161
+ }
162
+ }
163
+ fnMonitor.onFnKeyUp = { [weak self] in
164
+ Task { @MainActor [weak self] in
165
+ guard let self, self.useFnKey, self.isRecording, self.activeTrigger == .fnKey else { return }
166
+ self.stopAndTranscribe()
167
+ }
168
+ }
169
+ updateFnMonitor()
170
+
171
+ KeyboardShortcuts.onKeyDown(for: .toggleRecording) { [weak self] in
172
+ Task { @MainActor [weak self] in
173
+ guard let self, !self.keyboardShortcutIsDown else { return }
174
+ self.keyboardShortcutIsDown = true
175
+ guard !self.isRecording else { return }
176
+ self.startRecording(trigger: .keyboardShortcut)
177
+ }
178
+ }
179
+ KeyboardShortcuts.onKeyUp(for: .toggleRecording) { [weak self] in
180
+ Task { @MainActor [weak self] in
181
+ guard let self, self.keyboardShortcutIsDown else { return }
182
+ self.keyboardShortcutIsDown = false
183
+ guard self.isRecording, self.activeTrigger == .keyboardShortcut else { return }
184
+ self.stopAndTranscribe()
185
+ }
186
+ }
187
+
188
+ updateStatus()
189
+ }
190
+
191
+ private func updateFnMonitor() {
192
+ if useFnKey {
193
+ let ok = fnMonitor.start()
194
+ if !ok {
195
+ statusMessage = "fn needs Input Monitoring / Accessibility permission, and Globe must be set to Do Nothing"
196
+ }
197
+ } else {
198
+ fnMonitor.stop()
199
+ }
200
+ }
201
+
202
+ public func updateStatus() {
203
+ if isRecording || isTranscribing { return }
204
+ statusMessage = "Ready"
205
+ }
206
+
207
+ // MARK: - Toggle
208
+
209
+ public func toggleRecording() {
210
+ if isRecording { stopAndTranscribe() } else { startRecording(trigger: .manual) }
211
+ }
212
+
213
+ // MARK: - Start Recording (Streaming)
214
+
215
+ public func startRecording(trigger: RecordingTrigger = .manual) {
216
+ guard !isRecording else { return }
217
+ activeTrigger = trigger
218
+ keyboardShortcutIsDown = trigger == .keyboardShortcut
219
+
220
+ let myPID = ProcessInfo.processInfo.processIdentifier
221
+ let frontmostApp = NSWorkspace.shared.frontmostApplication
222
+ let isOwnApp = frontmostApp?.processIdentifier == myPID
223
+ targetAppBundleIdentifier = isOwnApp ? nil : frontmostApp?.bundleIdentifier
224
+ targetAppPid = isOwnApp ? nil : frontmostApp?.processIdentifier
225
+
226
+ if let store = projectStore {
227
+ let windowTitle = Self.focusedWindowTitle(pid: frontmostApp?.processIdentifier)
228
+ let projects = store.settings.projects
229
+ let detected = ProjectStore.matchProject(windowTitle: windowTitle, bundleId: targetAppBundleIdentifier, projects: projects)
230
+ if let detected {
231
+ store.setActive(detected.id)
232
+ }
233
+ }
234
+
235
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
236
+ case .authorized:
237
+ startNativeRecording()
238
+ case .notDetermined:
239
+ statusMessage = "Allow microphone access to record"
240
+ AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
241
+ Task { @MainActor [weak self] in
242
+ guard let self else { return }
243
+ if granted {
244
+ self.startNativeRecording()
245
+ } else {
246
+ self.resetRecordingIntent()
247
+ self.statusMessage = "Microphone permission denied"
248
+ }
249
+ }
250
+ }
251
+ case .denied, .restricted:
252
+ resetRecordingIntent()
253
+ statusMessage = "Enable Microphone permission for Recordings in System Settings"
254
+ @unknown default:
255
+ resetRecordingIntent()
256
+ statusMessage = "Microphone permission unavailable"
257
+ }
258
+ }
259
+
260
+ private func startNativeRecording() {
261
+ let streamState = PCMStreamState()
262
+ pcmStreamState = streamState
263
+
264
+ let apiKey = openAIAPIKey
265
+ if !apiKey.isEmpty {
266
+ startRealtimeStreaming(apiKey: apiKey)
267
+ }
268
+
269
+ let client = realtimeClient
270
+ let recorder = NativePCMRecorder { [weak client] data in
271
+ Task {
272
+ let chunks = await streamState.append(data, chunkSize: 4_800)
273
+ for chunk in chunks {
274
+ await client?.sendAudio(chunk)
275
+ }
276
+ }
277
+ }
278
+
279
+ do {
280
+ try recorder.start()
281
+ nativeRecorder = recorder
282
+ isRecording = true
283
+ recordingDuration = 0
284
+ streamingText = ""
285
+ liveTranscriptionText = ""
286
+ recordedPCM.removeAll(keepingCapacity: true)
287
+ activeAudioPath = "\(audioDir)/recording-\(Self.timestampForFilename()).wav"
288
+ let trigger = activeTrigger ?? .manual
289
+ statusMessage = switch (mode, trigger) {
290
+ case (.command, _): "Speak your instruction..."
291
+ case (_, .manual): "Recording — click Stop when finished"
292
+ case (_, .fnKey), (_, .keyboardShortcut): "Recording — release to stop"
293
+ }
294
+
295
+ recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
296
+ Task { @MainActor [weak self] in
297
+ self?.recordingDuration += 0.1
298
+ }
299
+ }
300
+ } catch {
301
+ realtimeClient?.stop()
302
+ realtimeClient = nil
303
+ streamingTask?.cancel()
304
+ streamingTask = nil
305
+ pcmStreamState = nil
306
+ resetRecordingIntent()
307
+ statusMessage = "Failed: \(error.localizedDescription)"
308
+ }
309
+ }
310
+
311
+ // MARK: - Real-time Streaming
312
+
313
+ private func startRealtimeStreaming(apiKey: String) {
314
+ let systemPrompt = projectStore?.effectiveSystemPrompt ?? ""
315
+ let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
316
+ realtimeClient = client
317
+
318
+ streamingTask = Task {
319
+ await client.startStreaming(systemPrompt: systemPrompt)
320
+
321
+ // Receive deltas
322
+ while client.isStreaming {
323
+ try? await Task.sleep(for: .milliseconds(100))
324
+ let text = client.accumulatedText
325
+ if text != streamingText {
326
+ await MainActor.run {
327
+ self.streamingText = text
328
+ self.liveTranscriptionText = text
329
+ }
330
+ }
331
+ }
332
+
333
+ if let message = client.error, !message.isEmpty {
334
+ await MainActor.run {
335
+ self.statusMessage = "Realtime unavailable — will transcribe after recording"
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ // MARK: - Stop & Transcribe
342
+
343
+ public func stopAndTranscribe() {
344
+ guard isRecording else { return }
345
+
346
+ recordingTimer?.invalidate()
347
+ recordingTimer = nil
348
+
349
+ let recorder = nativeRecorder
350
+ nativeRecorder = nil
351
+ recorder?.stop()
352
+
353
+ isRecording = false
354
+ isTranscribing = true
355
+
356
+ let curMode = mode
357
+ let targetAppBundleIdentifier = targetAppBundleIdentifier
358
+ let activeProjectId = projectStore?.settings.activeProjectId
359
+ let activeProjectName = projectStore?.activeProject?.name
360
+ let audioPath = activeAudioPath
361
+ let pcmStreamState = pcmStreamState
362
+ let client = realtimeClient
363
+ resetRecordingIntent()
364
+ self.pcmStreamState = nil
365
+
366
+ Task {
367
+ if let pcmStreamState {
368
+ if let finalChunk = await pcmStreamState.flushPendingChunk() {
369
+ client?.sendAudio(finalChunk)
370
+ }
371
+ self.recordedPCM = await pcmStreamState.capturedPCM()
372
+ }
373
+
374
+ let streamingResult = await client?.finish() ?? ""
375
+
376
+ self.realtimeClient = nil
377
+ self.streamingTask?.cancel()
378
+ self.streamingTask = nil
379
+
380
+ let text = streamingResult.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : streamingResult
381
+
382
+ self.liveTranscriptionText = ""
383
+
384
+ if let text {
385
+ self.isTranscribing = false
386
+ self.finishWithText(
387
+ text,
388
+ curMode: curMode,
389
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
390
+ activeProjectId: activeProjectId,
391
+ activeProjectName: activeProjectName
392
+ )
393
+ } else if let audioPath, self.writeCapturedWAV(to: audioPath) {
394
+ self.fallbackTranscribe(
395
+ audioPath: audioPath,
396
+ curMode: curMode,
397
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
398
+ activeProjectId: activeProjectId,
399
+ activeProjectName: activeProjectName
400
+ )
401
+ } else {
402
+ self.finish("No audio captured")
403
+ }
404
+
405
+ self.activeAudioPath = nil
406
+ self.recordedPCM.removeAll(keepingCapacity: true)
407
+ }
408
+ }
409
+
410
+ private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
411
+ if curMode == .command {
412
+ runCommandMode(instruction: text)
413
+ return
414
+ }
415
+
416
+ let shortcutText = voiceShortcuts?.match(text)
417
+ let output = shortcutText ?? text
418
+ pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier)
419
+ recentTranscriptions.insert(
420
+ TranscriptionResult(
421
+ rawText: text,
422
+ processedText: shortcutText,
423
+ timestamp: Date(),
424
+ projectId: activeProjectId,
425
+ projectName: activeProjectName
426
+ ),
427
+ at: 0
428
+ )
429
+ if recentTranscriptions.count > 20 { recentTranscriptions.removeLast() }
430
+ }
431
+
432
+ private func writeCapturedWAV(to path: String) -> Bool {
433
+ guard !recordedPCM.isEmpty else { return false }
434
+ do {
435
+ try Self.writeWAV(
436
+ pcmData: recordedPCM,
437
+ sampleRate: 24_000,
438
+ channelCount: 1,
439
+ bitsPerSample: 16,
440
+ to: URL(fileURLWithPath: path)
441
+ )
442
+ return true
443
+ } catch {
444
+ statusMessage = "Failed to save audio"
445
+ return false
446
+ }
447
+ }
448
+
449
+ private static func writeWAV(pcmData: Data, sampleRate: UInt32, channelCount: UInt16, bitsPerSample: UInt16, to url: URL) throws {
450
+ let byteRate = sampleRate * UInt32(channelCount) * UInt32(bitsPerSample / 8)
451
+ let blockAlign = channelCount * (bitsPerSample / 8)
452
+ let dataSize = UInt32(pcmData.count)
453
+ let fileSize = UInt32(36) + dataSize
454
+
455
+ var wav = Data()
456
+ func appendASCII(_ string: String) {
457
+ wav.append(contentsOf: string.utf8)
458
+ }
459
+ func appendUInt16LE(_ value: UInt16) {
460
+ wav.append(UInt8(value & 0xff))
461
+ wav.append(UInt8((value >> 8) & 0xff))
462
+ }
463
+ func appendUInt32LE(_ value: UInt32) {
464
+ wav.append(UInt8(value & 0xff))
465
+ wav.append(UInt8((value >> 8) & 0xff))
466
+ wav.append(UInt8((value >> 16) & 0xff))
467
+ wav.append(UInt8((value >> 24) & 0xff))
468
+ }
469
+
470
+ appendASCII("RIFF")
471
+ appendUInt32LE(fileSize)
472
+ appendASCII("WAVE")
473
+ appendASCII("fmt ")
474
+ appendUInt32LE(16)
475
+ appendUInt16LE(1)
476
+ appendUInt16LE(channelCount)
477
+ appendUInt32LE(sampleRate)
478
+ appendUInt32LE(byteRate)
479
+ appendUInt16LE(blockAlign)
480
+ appendUInt16LE(bitsPerSample)
481
+ appendASCII("data")
482
+ appendUInt32LE(dataSize)
483
+ wav.append(pcmData)
484
+
485
+ let dir = url.deletingLastPathComponent()
486
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
487
+ try wav.write(to: url, options: .atomic)
488
+ }
489
+
490
+ private static func timestampForFilename() -> String {
491
+ let formatter = DateFormatter()
492
+ formatter.dateFormat = "yyyyMMdd-HHmmss-SSS"
493
+ return formatter.string(from: Date())
494
+ }
495
+
496
+ // MARK: - Fallback Transcription
497
+
498
+ private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
499
+ let homePath = home
500
+
501
+ isTranscribing = true
502
+ statusMessage = "Transcribing..."
503
+
504
+ Task.detached {
505
+ let output = CLIRunner.run(["--json", "transcribe", audioPath, "--no-enhance"], home: homePath)
506
+ if let error = CLIRunner.parseError(output) {
507
+ await MainActor.run { self.finish(error) }
508
+ return
509
+ }
510
+
511
+ let text = CLIRunner.parseJSON(output)
512
+ guard let text, !text.isEmpty else {
513
+ await MainActor.run { self.finish("Empty transcription") }
514
+ return
515
+ }
516
+
517
+ await MainActor.run {
518
+ self.isTranscribing = false
519
+ self.finishWithText(
520
+ text,
521
+ curMode: curMode,
522
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
523
+ activeProjectId: activeProjectId,
524
+ activeProjectName: activeProjectName
525
+ )
526
+ }
527
+ }
528
+ }
529
+
530
+ private func mostRecentAudioFile() -> String? {
531
+ let files = (try? FileManager.default.contentsOfDirectory(atPath: audioDir)) ?? []
532
+ let wavFiles = files.filter { $0.hasSuffix(".wav") }.sorted().reversed()
533
+ return wavFiles.first.map { "\(audioDir)/\($0)" }
534
+ }
535
+
536
+ private func finish(_ msg: String) {
537
+ isTranscribing = false
538
+ liveTranscriptionText = ""
539
+ statusMessage = msg
540
+ }
541
+
542
+ private func resetRecordingIntent() {
543
+ activeTrigger = nil
544
+ keyboardShortcutIsDown = false
545
+ targetAppBundleIdentifier = nil
546
+ targetAppPid = nil
547
+ }
548
+
549
+ // MARK: - Command Mode
550
+
551
+ private func runCommandMode(instruction: String) {
552
+ guard ensureAccessibilityPermission(prompt: true) else {
553
+ statusMessage = "Enable Accessibility permission for Recordings to rewrite selected text"
554
+ return
555
+ }
556
+ postKey(0x08, flags: .maskCommand) // Cmd+C
557
+ let homePath = home
558
+
559
+ Task {
560
+ try? await Task.sleep(for: .milliseconds(250))
561
+ let selected = NSPasteboard.general.string(forType: .string) ?? ""
562
+ guard !selected.isEmpty else {
563
+ statusMessage = "No text selected"
564
+ return
565
+ }
566
+ statusMessage = "Rewriting..."
567
+ isTranscribing = true
568
+
569
+ Task.detached {
570
+ let result = CLIRunner.run(["rewrite", selected, "--instruction", instruction], home: homePath)
571
+ await MainActor.run {
572
+ self.isTranscribing = false
573
+ self.liveTranscriptionText = ""
574
+ if CLIRunner.parseError(result) == nil, !result.isEmpty {
575
+ self.pasteIntoFrontApp(result)
576
+ self.statusMessage = "Rewritten"
577
+ } else {
578
+ self.statusMessage = CLIRunner.parseError(result) ?? "Rewrite failed"
579
+ }
580
+ }
581
+ }
582
+ }
583
+ }
584
+
585
+ private func postKey(_ key: CGKeyCode, flags: CGEventFlags) {
586
+ let src = CGEventSource(stateID: .hidSystemState)
587
+ guard let down = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true),
588
+ let up = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: false) else { return }
589
+ down.flags = flags
590
+ up.flags = flags
591
+ down.post(tap: .cgSessionEventTap)
592
+ up.post(tap: .cgSessionEventTap)
593
+ }
594
+
595
+ // MARK: - Window Title (Accessibility API)
596
+
597
+ private static func focusedWindowTitle(pid: pid_t?) -> String? {
598
+ guard let pid else { return nil }
599
+ let app = AXUIElementCreateApplication(pid)
600
+ var windowRef: CFTypeRef?
601
+ guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &windowRef) == .success,
602
+ let window = windowRef else { return nil }
603
+ var titleRef: CFTypeRef?
604
+ guard AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &titleRef) == .success,
605
+ let title = titleRef as? String else { return nil }
606
+ return title
607
+ }
608
+
609
+ // MARK: - Paste
610
+
611
+ func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil) {
612
+ let pb = NSPasteboard.general
613
+ pb.clearContents()
614
+ pb.setString(text, forType: .string)
615
+
616
+ guard ensureAccessibilityPermission(prompt: true) else {
617
+ self.statusMessage = "Copied — enable Accessibility permission for Recordings to paste"
618
+ return
619
+ }
620
+
621
+ let myPID = ProcessInfo.processInfo.processIdentifier
622
+ let runningApps = NSWorkspace.shared.runningApplications
623
+ let targetApp = runningApps.first(where: {
624
+ guard $0.processIdentifier != myPID, $0.activationPolicy == .regular else { return false }
625
+ guard let bundleIdentifier = targetAppBundleIdentifier else { return false }
626
+ return $0.bundleIdentifier == bundleIdentifier
627
+ }) ?? runningApps.first(where: {
628
+ $0.activationPolicy == .regular && $0.processIdentifier != myPID
629
+ })
630
+
631
+ guard let app = targetApp else {
632
+ self.statusMessage = "No target app found"
633
+ return
634
+ }
635
+
636
+ // Activate target app and wait for focus to stabilize
637
+ app.activate()
638
+
639
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
640
+ let src = CGEventSource(stateID: .hidSystemState)
641
+ if let down = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true),
642
+ let up = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false) {
643
+ down.flags = .maskCommand
644
+ up.flags = .maskCommand
645
+ down.post(tap: .cgSessionEventTap)
646
+ up.post(tap: .cgSessionEventTap)
647
+ }
648
+ self.statusMessage = "Pasted (\(text.count) chars)"
649
+ }
650
+ }
651
+
652
+ private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
653
+ if !prompt {
654
+ return AXIsProcessTrusted()
655
+ }
656
+ return AXIsProcessTrustedWithOptions(
657
+ ["AXTrustedCheckOptionPrompt" as CFString: true] as CFDictionary
658
+ )
659
+ }
660
+ }
661
+
662
+ // MARK: - CLI Runner
663
+
664
+ enum CLIRunner: Sendable {
665
+ static func run(_ args: [String], home: String) -> String {
666
+ let bin = "\(home)/.bun/bin/recordings"
667
+ let proc = Process()
668
+ let outPipe = Pipe()
669
+ let errPipe = Pipe()
670
+ if FileManager.default.fileExists(atPath: bin) {
671
+ proc.executableURL = URL(fileURLWithPath: bin)
672
+ proc.arguments = args
673
+ } else {
674
+ proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
675
+ proc.arguments = ["recordings"] + args
676
+ }
677
+ proc.environment = ProcessInfo.processInfo.environment.merging([
678
+ "PATH": "\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
679
+ ]) { _, new in new }
680
+ proc.standardOutput = outPipe
681
+ proc.standardError = errPipe
682
+ do {
683
+ try proc.run()
684
+ proc.waitUntilExit()
685
+ let stdout = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
686
+ let stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
687
+ if proc.terminationStatus != 0 {
688
+ let details = stderr.isEmpty ? stdout : stderr
689
+ return "ERROR: \(details.trimmingCharacters(in: .whitespacesAndNewlines))"
690
+ }
691
+ return stdout.isEmpty ? stderr : stdout
692
+ } catch {
693
+ return "ERROR: \(error.localizedDescription)"
694
+ }
695
+ }
696
+
697
+ static func parseError(_ output: String) -> String? {
698
+ let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
699
+ guard trimmed.hasPrefix("ERROR:") else { return nil }
700
+ let message = trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
701
+ if message.contains("OpenAI API key not configured") {
702
+ return "OpenAI API key not configured on this Mac"
703
+ }
704
+ if message.isEmpty {
705
+ return "Transcription failed"
706
+ }
707
+ return String(message.prefix(120))
708
+ }
709
+
710
+ static func parseJSON(_ output: String) -> String? {
711
+ if let s = output.range(of: "{"), let e = output.range(of: "}", options: .backwards),
712
+ s.lowerBound < e.upperBound {
713
+ let json = String(output[s.lowerBound..<e.upperBound])
714
+ if let data = json.data(using: .utf8),
715
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
716
+ if let t = obj["processed_text"] as? String, !t.isEmpty { return t }
717
+ if let t = obj["raw_text"] as? String, !t.isEmpty { return t }
718
+ }
719
+ }
720
+ return output.components(separatedBy: "\n")
721
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
722
+ .first { !$0.isEmpty && !$0.hasPrefix("{") && !$0.contains("Transcribing") && !$0.hasPrefix("Saved") && !$0.hasPrefix("ERROR:") }
723
+ }
724
+ }