@hasna/recordings 0.1.22 → 0.1.24

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 (43) hide show
  1. package/README.md +11 -5
  2. package/dist/cli/index.js +5365 -9390
  3. package/dist/cli/storage.d.ts +3 -0
  4. package/dist/cli/storage.d.ts.map +1 -0
  5. package/dist/db/database.d.ts +1 -1
  6. package/dist/db/database.d.ts.map +1 -1
  7. package/dist/db/pg-migrate.d.ts +8 -0
  8. package/dist/db/pg-migrate.d.ts.map +1 -0
  9. package/dist/db/pg-migrations.d.ts +1 -1
  10. package/dist/db/pg-migrations.d.ts.map +1 -1
  11. package/dist/db/remote-storage.d.ts +12 -0
  12. package/dist/db/remote-storage.d.ts.map +1 -0
  13. package/dist/db/sqlite-adapter.d.ts +14 -0
  14. package/dist/db/sqlite-adapter.d.ts.map +1 -0
  15. package/dist/db/storage-config.d.ts +27 -0
  16. package/dist/db/storage-config.d.ts.map +1 -0
  17. package/dist/db/storage-sync.d.ts +36 -0
  18. package/dist/db/storage-sync.d.ts.map +1 -0
  19. package/dist/index.d.ts +4 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +684 -4857
  22. package/dist/lib/enhancer.d.ts.map +1 -1
  23. package/dist/lib/transcriber.d.ts +1 -0
  24. package/dist/lib/transcriber.d.ts.map +1 -1
  25. package/dist/mcp/index.js +1797 -6616
  26. package/dist/mcp/storage-tools.d.ts +3 -0
  27. package/dist/mcp/storage-tools.d.ts.map +1 -0
  28. package/dist/storage.d.ts +7 -0
  29. package/dist/storage.d.ts.map +1 -0
  30. package/dist/storage.js +5656 -0
  31. package/dist/version.d.ts +1 -1
  32. package/package.json +10 -4
  33. package/scripts/install_macos_app.sh +17 -0
  34. package/src/native/Recordings/App/RecordingsApp.swift +3 -2
  35. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +101 -80
  36. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +26 -0
  37. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +13 -1
  38. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +92 -31
  39. package/src/native/Recordings/RecordingsLib/SettingsView.swift +5 -1
  40. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +12 -0
  41. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +49 -0
  42. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +54 -0
  43. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +58 -0
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.1.21";
1
+ export declare const VERSION = "0.1.24";
2
2
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/recordings",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "type": "module",
5
5
  "description": "Speech-to-text recording tool with MCP and CLI — records, transcribes, and optionally enhances text using AI",
6
6
  "repository": {
@@ -17,13 +17,18 @@
17
17
  ".": {
18
18
  "import": "./dist/index.js",
19
19
  "types": "./dist/index.d.ts"
20
+ },
21
+ "./storage": {
22
+ "import": "./dist/storage.js",
23
+ "types": "./dist/storage.d.ts"
20
24
  }
21
25
  },
22
26
  "scripts": {
23
- "build": "bun run build:cli && bun run build:mcp && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
27
+ "clean": "rm -rf dist",
28
+ "build": "bun run clean && bun run build:cli && bun run build:mcp && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
24
29
  "build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
25
30
  "build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
26
- "build:lib": "bun build src/index.ts --target=bun --outfile=dist/index.js --external=openai",
31
+ "build:lib": "bun build src/index.ts src/storage.ts --target=bun --outdir=dist --external=openai",
27
32
  "typecheck": "tsc --noEmit",
28
33
  "test": "bun test",
29
34
  "test:coverage": "bun test --coverage",
@@ -45,15 +50,16 @@
45
50
  "LICENSE"
46
51
  ],
47
52
  "dependencies": {
48
- "@hasna/cloud": "^0.1.24",
49
53
  "@modelcontextprotocol/sdk": "^1.12.1",
50
54
  "chalk": "^5.4.1",
51
55
  "commander": "^13.1.0",
52
56
  "openai": "^5.1.0",
57
+ "pg": "^8.13.3",
53
58
  "zod": "^3.24.2"
54
59
  },
55
60
  "devDependencies": {
56
61
  "@types/bun": "^1.2.5",
62
+ "@types/pg": "^8.11.11",
57
63
  "typescript": "^5.8.2"
58
64
  },
59
65
  "publishConfig": {
@@ -105,4 +105,21 @@ APP_CDHASH="$(current_cdhash "$APP_DEST" || true)"
105
105
  reset_stale_permission "Microphone" "kTCCServiceMicrophone" "${HOME}/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
106
106
  reset_stale_permission "Accessibility" "kTCCServiceAccessibility" "/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
107
107
 
108
+ # A second copy in ~/Applications splits TCC permissions and leaves users running
109
+ # stale builds. Keep that launch point but always point it at the fresh build.
110
+ ALT_COPY="${HOME}/Applications/Recordings.app"
111
+ if [ -d "$ALT_COPY" ]; then
112
+ rm -rf "$ALT_COPY"
113
+ cp -R "$APP_SOURCE" "$ALT_COPY" || true
114
+ echo "Updated stale copy at ${ALT_COPY} to the freshly built app."
115
+ fi
116
+
117
+ # If an old instance is running, restart it on the new build.
118
+ if pgrep -x Recordings >/dev/null 2>&1; then
119
+ pkill -x Recordings || true
120
+ sleep 1
121
+ open "$APP_DEST" || true
122
+ echo "Restarted Recordings.app on the new build."
123
+ fi
124
+
108
125
  echo "Installed Recordings.app from package: ${APP_DEST}"
@@ -25,10 +25,11 @@ struct RecordingsApp: App {
25
25
  }
26
26
  } label: {
27
27
  if engine.isRecording {
28
- Image(systemName: "record.circle.fill")
29
- .symbolRenderingMode(.multicolor)
28
+ Image(systemName: "waveform")
29
+ .symbolEffect(.variableColor.iterative, isActive: true)
30
30
  } else if engine.isTranscribing {
31
31
  Image(systemName: "ellipsis.circle")
32
+ .symbolEffect(.pulse, isActive: true)
32
33
  } else {
33
34
  Image(systemName: "mic.fill")
34
35
  }
@@ -28,11 +28,10 @@ public struct MenuBarPopover: View {
28
28
  VStack(spacing: 0) {
29
29
  header
30
30
  .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
31
- Divider()
32
31
  modePicker
33
- .padding(.horizontal, 16).padding(.top, 10)
32
+ .padding(.horizontal, 16).padding(.top, 2)
34
33
  recordingArea
35
- .padding(.horizontal, 16).padding(.top, 8).padding(.bottom, 12)
34
+ .padding(.horizontal, 16).padding(.top, 10).padding(.bottom, 12)
36
35
  Divider()
37
36
  if !engine.recentTranscriptions.isEmpty {
38
37
  filterBar
@@ -44,6 +43,8 @@ public struct MenuBarPopover: View {
44
43
  Divider()
45
44
  footerMenu
46
45
  }
46
+ .animation(.smooth(duration: 0.25), value: engine.isRecording)
47
+ .animation(.smooth(duration: 0.25), value: engine.isTranscribing)
47
48
  }
48
49
 
49
50
  // MARK: - Header
@@ -54,16 +55,21 @@ public struct MenuBarPopover: View {
54
55
  Image(systemName: "mic.fill")
55
56
  .foregroundStyle(.tint)
56
57
  Text("Hasna Recordings")
58
+ .font(.headline)
57
59
  Spacer()
58
60
  if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
59
61
  Text(shortcut.description)
62
+ .font(.caption)
60
63
  .foregroundStyle(.secondary)
64
+ .padding(.horizontal, 7)
65
+ .padding(.vertical, 3)
66
+ .glassEffect(.regular, in: .capsule)
61
67
  }
62
68
  }
63
69
  if !projectStore.settings.projects.isEmpty {
64
70
  HStack(spacing: 4) {
65
71
  Image(systemName: "folder.fill")
66
- .foregroundColor(projectStore.activeProject != nil ? .accentColor : .secondary)
72
+ .foregroundStyle(projectStore.activeProject != nil ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
67
73
  Menu {
68
74
  Button("None") { projectStore.setActive(nil) }
69
75
  Divider()
@@ -102,91 +108,103 @@ public struct MenuBarPopover: View {
102
108
  }
103
109
  }
104
110
  .pickerStyle(.segmented)
111
+ .help(engine.mode.hint)
105
112
  }
106
113
 
107
114
  @ViewBuilder
108
115
  private var recordingArea: some View {
109
116
  if engine.isRecording {
110
- VStack(spacing: 6) {
111
- HStack {
112
- Circle().fill(.red).frame(width: 8, height: 8)
113
- Text(fmt(engine.recordingDuration))
114
- .monospacedDigit()
115
- Spacer()
116
- Button("Stop") { engine.stopAndTranscribe() }
117
- .controlSize(.small)
118
- }
119
- // Live streaming transcription text
120
- if !engine.liveTranscriptionText.isEmpty {
121
- Text(engine.liveTranscriptionText)
122
- .font(.callout)
123
- .foregroundStyle(.primary)
124
- .multilineTextAlignment(.leading)
125
- .lineLimit(3)
126
- .padding(.vertical, 4)
127
- Text("Listening...")
128
- .font(.caption)
129
- .foregroundStyle(.green)
130
- }
131
- if let project = projectStore.activeProject {
132
- HStack(spacing: 4) {
133
- Image(systemName: "folder.fill").foregroundStyle(.tint)
134
- Text(project.name).foregroundStyle(.secondary)
135
- Spacer()
136
- }
137
- }
117
+ VStack(spacing: 8) {
118
+ HStack {
119
+ Image(systemName: "waveform")
120
+ .symbolEffect(.variableColor.iterative, isActive: true)
121
+ .foregroundStyle(.red)
122
+ Text(fmt(engine.recordingDuration))
123
+ .monospacedDigit()
124
+ .contentTransition(.numericText())
125
+ .font(.title3.weight(.medium))
126
+ Spacer()
127
+ Button("Stop", systemImage: "stop.fill") { engine.stopAndTranscribe() }
128
+ .buttonStyle(.glassProminent)
129
+ .tint(.red)
130
+ .controlSize(.small)
138
131
  }
139
- .padding(10)
140
- .glassEffect(.regular)
132
+ if !engine.liveTranscriptionText.isEmpty {
133
+ Text(engine.liveTranscriptionText)
134
+ .font(.callout)
135
+ .foregroundStyle(.primary)
136
+ .frame(maxWidth: .infinity, alignment: .leading)
137
+ .lineLimit(3)
138
+ .contentTransition(.opacity)
139
+ } else {
140
+ Text("Listening…")
141
+ .font(.caption)
142
+ .foregroundStyle(.secondary)
143
+ .frame(maxWidth: .infinity, alignment: .leading)
144
+ }
145
+ if let project = projectStore.activeProject {
146
+ activeProjectTag(project)
147
+ }
148
+ }
149
+ .padding(12)
150
+ .glassEffect(.regular.tint(.red.opacity(0.1)), in: .rect(cornerRadius: 14))
141
151
  } else if engine.isTranscribing {
142
- VStack(spacing: 6) {
143
- HStack(spacing: 8) {
144
- ProgressView().controlSize(.small)
145
- Text("Transcribing...")
146
- .foregroundStyle(.secondary)
147
- Spacer()
148
- }
149
- // Show accumulated text during transcribing
150
- if !engine.liveTranscriptionText.isEmpty {
151
- Text(engine.liveTranscriptionText)
152
- .font(.callout)
153
- .foregroundStyle(.primary)
154
- .multilineTextAlignment(.leading)
155
- .lineLimit(3)
156
- .padding(.vertical, 4)
157
- }
158
- if let project = projectStore.activeProject {
159
- HStack(spacing: 4) {
160
- Image(systemName: "folder.fill").foregroundStyle(.tint)
161
- Text(project.name).foregroundStyle(.secondary)
162
- Spacer()
163
- }
164
- }
152
+ VStack(spacing: 8) {
153
+ HStack(spacing: 8) {
154
+ ProgressView().controlSize(.small)
155
+ Text("Transcribing")
156
+ .foregroundStyle(.secondary)
157
+ Spacer()
158
+ }
159
+ if !engine.liveTranscriptionText.isEmpty {
160
+ Text(engine.liveTranscriptionText)
161
+ .font(.callout)
162
+ .foregroundStyle(.primary)
163
+ .frame(maxWidth: .infinity, alignment: .leading)
164
+ .lineLimit(3)
165
165
  }
166
- .frame(maxWidth: .infinity)
167
- .padding(10)
168
- .glassEffect(.regular)
166
+ if let project = projectStore.activeProject {
167
+ activeProjectTag(project)
168
+ }
169
+ }
170
+ .frame(maxWidth: .infinity)
171
+ .padding(12)
172
+ .glassEffect(.regular, in: .rect(cornerRadius: 14))
169
173
  } else {
170
- VStack(spacing: 6) {
171
- Button { engine.startRecording() } label: {
172
- Label("Record", systemImage: "mic.circle.fill")
173
- }
174
- .controlSize(.regular)
174
+ VStack(spacing: 8) {
175
+ Button {
176
+ engine.startRecording()
177
+ } label: {
178
+ Label("Record", systemImage: "mic.fill")
179
+ .frame(maxWidth: .infinity)
180
+ }
181
+ .buttonStyle(.glassProminent)
182
+ .controlSize(.large)
175
183
 
176
- if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
177
- Text("or hold \(shortcut.description)")
178
- .foregroundStyle(.tertiary)
179
- }
180
- Text(engine.statusMessage)
184
+ if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
185
+ Text("or hold \(shortcut.description)")
181
186
  .font(.caption)
182
- .foregroundStyle(engine.statusMessage == "Ready" ? Color.secondary : Color.orange)
183
- .multilineTextAlignment(.center)
184
- .lineLimit(2)
187
+ .foregroundStyle(.tertiary)
185
188
  }
186
- .frame(maxWidth: .infinity)
187
- .padding(10)
188
- .glassEffect(.regular)
189
+ Text(engine.statusMessage)
190
+ .font(.caption)
191
+ .foregroundStyle(engine.statusMessage == "Ready" ? Color.secondary : Color.orange)
192
+ .multilineTextAlignment(.center)
193
+ .lineLimit(2)
194
+ }
195
+ .frame(maxWidth: .infinity)
196
+ .padding(12)
197
+ .glassEffect(.regular, in: .rect(cornerRadius: 14))
198
+ }
199
+ }
200
+
201
+ private func activeProjectTag(_ project: RecProject) -> some View {
202
+ HStack(spacing: 4) {
203
+ Image(systemName: "folder.fill").foregroundStyle(.tint)
204
+ Text(project.name).foregroundStyle(.secondary)
205
+ Spacer()
189
206
  }
207
+ .font(.caption)
190
208
  }
191
209
 
192
210
  // MARK: - Filter Bar
@@ -300,13 +318,16 @@ struct FilterChip: View {
300
318
  var body: some View {
301
319
  Button(action: action) {
302
320
  Text(label)
303
- .padding(.horizontal, 8)
304
- .padding(.vertical, 3)
305
- .background(isActive ? Color.accentColor.opacity(0.15) : Color.clear)
306
- .clipShape(RoundedRectangle(cornerRadius: 6))
321
+ .font(.caption)
322
+ .padding(.horizontal, 9)
323
+ .padding(.vertical, 4)
307
324
  }
308
325
  .buttonStyle(.plain)
309
326
  .foregroundStyle(isActive ? .primary : .secondary)
327
+ .glassEffect(
328
+ isActive ? .regular.tint(.accentColor.opacity(0.25)).interactive() : .regular.interactive(),
329
+ in: .capsule
330
+ )
310
331
  }
311
332
  }
312
333
 
@@ -21,6 +21,32 @@ enum OpenAIAPIKeyStore {
21
21
  return ""
22
22
  }
23
23
 
24
+ /// Persist the key into ~/.hasna/recordings/config.json so the CLI (which the app
25
+ /// shells out to for final transcription) uses the same key as the app itself.
26
+ static func save(key: String, homePath: String) throws {
27
+ let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
28
+ let dir = URL(fileURLWithPath: homePath)
29
+ .appendingPathComponent(".hasna")
30
+ .appendingPathComponent("recordings")
31
+ let url = dir.appendingPathComponent("config.json")
32
+
33
+ var json: [String: Any] = [:]
34
+ if let data = try? Data(contentsOf: url),
35
+ let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
36
+ json = existing
37
+ }
38
+
39
+ if trimmed.isEmpty {
40
+ json.removeValue(forKey: "openai_api_key")
41
+ } else {
42
+ json["openai_api_key"] = trimmed
43
+ }
44
+
45
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
46
+ let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
47
+ try data.write(to: url, options: .atomic)
48
+ }
49
+
24
50
  private static func loadConfigKey(homePath: String, environment: [String: String]) -> String? {
25
51
  let url = URL(fileURLWithPath: homePath)
26
52
  .appendingPathComponent(".hasna")
@@ -166,6 +166,12 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
166
166
  /// Returns the final accumulated transcription text.
167
167
  @discardableResult
168
168
  public func stop() -> String {
169
+ if isStreaming {
170
+ NativeAppLog.write(
171
+ "realtime session summary items=\(itemOrder.count) committed=\(committedItemIDs.count) completed=\(completedItemIDs.count) deltaChars=\(deltaTextByItem.values.map(\.count).reduce(0, +)) finalChars=\(accumulatedText.count) error=\(error ?? "none")",
172
+ homePath: homePath
173
+ )
174
+ }
169
175
  isStreaming = false
170
176
  isConfigured = false
171
177
  receiveTask?.cancel()
@@ -256,7 +262,13 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
256
262
  if let detail = json["error"] as? [String: Any],
257
263
  let msg = detail["message"] as? String {
258
264
  fputs("[RealtimeClient] Error: \(msg)\n", stderr)
259
- self.error = msg
265
+ // "buffer too small" on commit just means server VAD already consumed
266
+ // the audio — the transcript is intact, so don't surface it as a failure.
267
+ if msg.localizedCaseInsensitiveContains("buffer too small") {
268
+ NativeAppLog.write("realtime benign commit error: \(msg)", homePath: homePath)
269
+ } else {
270
+ self.error = msg
271
+ }
260
272
  }
261
273
 
262
274
  default:
@@ -411,7 +411,9 @@ public final class RecordingEngine: ObservableObject {
411
411
  if let message = client.error, !message.isEmpty {
412
412
  await MainActor.run {
413
413
  self.log("realtime unavailable message=\(message)")
414
- self.statusMessage = "Realtime unavailable — will transcribe after recording"
414
+ if self.isRecording {
415
+ self.statusMessage = "Live preview unavailable — will transcribe after recording"
416
+ }
415
417
  }
416
418
  }
417
419
  }
@@ -464,21 +466,33 @@ public final class RecordingEngine: ObservableObject {
464
466
  self.liveTranscriptionText = ""
465
467
 
466
468
  if let audioPath, self.writeCapturedWAV(to: audioPath) {
467
- if let realtimeText {
468
- self.log("realtime preview ignored for final chars=\(realtimeText.count)")
469
- }
470
- self.log("transcribing captured full audio audioPath=\(audioPath)")
469
+ self.log("transcribing captured full audio audioPath=\(audioPath) realtimePreviewChars=\(realtimeText?.count ?? 0)")
471
470
  self.fallbackTranscribe(
472
471
  audioPath: audioPath,
473
472
  curMode: curMode,
474
473
  targetAppBundleIdentifier: targetAppBundleIdentifier,
475
474
  targetAppPid: targetAppPid,
476
475
  activeProjectId: activeProjectId,
477
- activeProjectName: activeProjectName
476
+ activeProjectName: activeProjectName,
477
+ realtimeText: realtimeText
478
478
  )
479
479
  } else {
480
- self.log("no audio captured")
481
- self.finish("No audio captured")
480
+ let resolved = Self.resolveFinalTranscript(cliText: nil, cliError: "No audio captured", realtimeText: realtimeText)
481
+ if let text = resolved.text {
482
+ self.log("no audio file written; using realtime transcript chars=\(text.count)")
483
+ self.isTranscribing = false
484
+ self.finishWithText(
485
+ text,
486
+ curMode: curMode,
487
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
488
+ targetAppPid: targetAppPid,
489
+ activeProjectId: activeProjectId,
490
+ activeProjectName: activeProjectName
491
+ )
492
+ } else {
493
+ self.log("no audio captured")
494
+ self.finish(resolved.failureStatus ?? "No audio captured")
495
+ }
482
496
  }
483
497
 
484
498
  self.activeAudioPath = nil
@@ -502,7 +516,7 @@ public final class RecordingEngine: ObservableObject {
502
516
 
503
517
  let shortcutText = voiceShortcuts?.match(text)
504
518
  let output = shortcutText ?? text
505
- pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid)
519
+ pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid, restoreClipboard: true)
506
520
  recentTranscriptions.insert(
507
521
  TranscriptionResult(
508
522
  rawText: text,
@@ -584,7 +598,7 @@ public final class RecordingEngine: ObservableObject {
584
598
 
585
599
  // MARK: - Fallback Transcription
586
600
 
587
- private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?) {
601
+ private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?, realtimeText: String? = nil) {
588
602
  let homePath = home
589
603
 
590
604
  isTranscribing = true
@@ -592,25 +606,27 @@ public final class RecordingEngine: ObservableObject {
592
606
 
593
607
  Task.detached {
594
608
  let output = CLIRunner.run(["--json", "transcribe", audioPath, "--no-enhance"], home: homePath)
595
- if let error = CLIRunner.parseError(output) {
596
- await MainActor.run {
597
- self.log("fallback transcription failed error=\(error)")
598
- self.finish(error)
609
+ let cliError = CLIRunner.parseError(output)
610
+ let cliText = cliError == nil ? CLIRunner.parseJSON(output) : nil
611
+
612
+ await MainActor.run {
613
+ if let cliError {
614
+ self.log("cli transcription failed error=\(cliError)")
615
+ } else if cliText == nil {
616
+ self.log("cli transcription empty output=\(output.prefix(160))")
599
617
  }
600
- return
601
- }
602
618
 
603
- let text = CLIRunner.parseJSON(output)
604
- guard let text, !text.isEmpty else {
605
- await MainActor.run {
606
- self.log("fallback transcription empty output=\(output.prefix(160))")
607
- self.finish("Empty transcription")
619
+ let resolved = Self.resolveFinalTranscript(cliText: cliText, cliError: cliError, realtimeText: realtimeText)
620
+ guard let text = resolved.text else {
621
+ self.finish(resolved.failureStatus ?? "Transcription failed")
622
+ return
608
623
  }
609
- return
610
- }
611
624
 
612
- await MainActor.run {
613
- self.log("fallback transcription succeeded chars=\(text.count)")
625
+ if cliText == nil {
626
+ self.log("using realtime transcript fallback chars=\(text.count)")
627
+ } else {
628
+ self.log("cli transcription succeeded chars=\(text.count)")
629
+ }
614
630
  self.isTranscribing = false
615
631
  self.finishWithText(
616
632
  text,
@@ -707,9 +723,10 @@ public final class RecordingEngine: ObservableObject {
707
723
 
708
724
  // MARK: - Paste
709
725
 
710
- func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil) {
726
+ func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil, restoreClipboard: Bool = false) {
711
727
  log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") pid=\(targetAppPid.map(String.init) ?? "nil") accessibility=\(AXIsProcessTrusted())")
712
728
  let pb = NSPasteboard.general
729
+ let previousClipboard = restoreClipboard ? pb.string(forType: .string) : nil
713
730
  pb.clearContents()
714
731
  pb.setString(text, forType: .string)
715
732
 
@@ -724,6 +741,7 @@ public final class RecordingEngine: ObservableObject {
724
741
 
725
742
  let myPID = ProcessInfo.processInfo.processIdentifier
726
743
  let runningApps = NSWorkspace.shared.runningApplications
744
+ let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
727
745
  let candidates = runningApps.map {
728
746
  PasteTargetCandidate(
729
747
  pid: $0.processIdentifier,
@@ -735,7 +753,8 @@ public final class RecordingEngine: ObservableObject {
735
753
  candidates: candidates,
736
754
  currentPid: myPID,
737
755
  targetBundleIdentifier: targetAppBundleIdentifier,
738
- targetPid: targetAppPid
756
+ targetPid: targetAppPid,
757
+ frontmostPid: frontmostPid
739
758
  )
740
759
  let targetApp = selectedTarget.flatMap { selected in
741
760
  runningApps.first { $0.processIdentifier == selected.pid }
@@ -748,9 +767,13 @@ public final class RecordingEngine: ObservableObject {
748
767
  }
749
768
 
750
769
  // Activate the exact app that owned focus when recording started, then paste after focus settles.
751
- app.activate(options: [.activateIgnoringOtherApps])
770
+ let alreadyFrontmost = app.processIdentifier == frontmostPid
771
+ if !alreadyFrontmost {
772
+ app.activate(options: [.activateIgnoringOtherApps])
773
+ }
752
774
 
753
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
775
+ let pasteDelay: TimeInterval = alreadyFrontmost ? 0.15 : 0.5
776
+ DispatchQueue.main.asyncAfter(deadline: .now() + pasteDelay) {
754
777
  let src = CGEventSource(stateID: .hidSystemState)
755
778
  if let down = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true),
756
779
  let up = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false) {
@@ -759,8 +782,19 @@ public final class RecordingEngine: ObservableObject {
759
782
  down.post(tap: .cgSessionEventTap)
760
783
  up.post(tap: .cgSessionEventTap)
761
784
  }
762
- self.log("paste event posted")
785
+ self.log("paste event posted target=\(app.bundleIdentifier ?? "?") alreadyFrontmost=\(alreadyFrontmost)")
763
786
  self.statusMessage = "Pasted (\(text.count) chars)"
787
+
788
+ if let previousClipboard {
789
+ // Give the target app time to consume the paste, then hand the clipboard back.
790
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
791
+ let pb = NSPasteboard.general
792
+ if pb.string(forType: .string) == text {
793
+ pb.clearContents()
794
+ pb.setString(previousClipboard, forType: .string)
795
+ }
796
+ }
797
+ }
764
798
  }
765
799
  }
766
800
 
@@ -768,7 +802,8 @@ public final class RecordingEngine: ObservableObject {
768
802
  candidates: [PasteTargetCandidate],
769
803
  currentPid: pid_t,
770
804
  targetBundleIdentifier: String?,
771
- targetPid: pid_t?
805
+ targetPid: pid_t?,
806
+ frontmostPid: pid_t? = nil
772
807
  ) -> PasteTargetCandidate? {
773
808
  candidates.first {
774
809
  guard let targetPid else { return false }
@@ -777,11 +812,28 @@ public final class RecordingEngine: ObservableObject {
777
812
  guard $0.pid != currentPid, $0.isRegularApp else { return false }
778
813
  guard let targetBundleIdentifier else { return false }
779
814
  return $0.bundleIdentifier == targetBundleIdentifier
815
+ } ?? candidates.first {
816
+ guard let frontmostPid else { return false }
817
+ return $0.pid == frontmostPid && $0.pid != currentPid && $0.isRegularApp
780
818
  } ?? candidates.first {
781
819
  $0.isRegularApp && $0.pid != currentPid
782
820
  }
783
821
  }
784
822
 
823
+ nonisolated static func resolveFinalTranscript(
824
+ cliText: String?,
825
+ cliError: String?,
826
+ realtimeText: String?
827
+ ) -> (text: String?, failureStatus: String?) {
828
+ if let cliText, !cliText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
829
+ return (cliText, nil)
830
+ }
831
+ if let realtimeText, !realtimeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
832
+ return (realtimeText, nil)
833
+ }
834
+ return (nil, cliError ?? "Empty transcription")
835
+ }
836
+
785
837
  private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
786
838
  if AXIsProcessTrusted() {
787
839
  return true
@@ -861,6 +913,15 @@ enum CLIRunner: Sendable {
861
913
  let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
862
914
  guard trimmed.hasPrefix("ERROR:") else { return nil }
863
915
  let message = trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
916
+ let lowercased = message.lowercased()
917
+ if lowercased.contains("401") || lowercased.contains("incorrect api key")
918
+ || lowercased.contains("invalid_api_key") || lowercased.contains("invalid or expired") {
919
+ return "OpenAI API key invalid or expired — update it in Recordings Settings"
920
+ }
921
+ if lowercased.contains("429") || lowercased.contains("exceeded your current quota")
922
+ || lowercased.contains("insufficient_quota") || lowercased.contains("quota exceeded") {
923
+ return "OpenAI quota exceeded — check the OpenAI account billing"
924
+ }
864
925
  if message.contains("OpenAI API key not configured") {
865
926
  return "OpenAI API key not configured on this Mac"
866
927
  }
@@ -29,7 +29,11 @@ public struct SettingsView: View {
29
29
  Section("OpenAI") {
30
30
  SecureField("API key", text: $openAIAPIKey)
31
31
  .textFieldStyle(.roundedBorder)
32
- Text("Used for realtime transcription in the menu bar app.")
32
+ .onChange(of: openAIAPIKey) {
33
+ // Keep the CLI config in sync — final transcription shells out to it.
34
+ try? OpenAIAPIKeyStore.save(key: openAIAPIKey, homePath: engine.home)
35
+ }
36
+ Text("Used for live transcription and the final paste. Stored in ~/.hasna/recordings/config.json.")
33
37
  .foregroundStyle(.secondary)
34
38
  }
35
39