@hasna/recordings 0.1.16 → 0.1.18

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.
package/dist/cli/index.js CHANGED
@@ -7,7 +7,7 @@ var __require = import.meta.require;
7
7
  var require_package = __commonJS((exports, module) => {
8
8
  module.exports = {
9
9
  name: "@hasna/recordings",
10
- version: "0.1.16",
10
+ version: "0.1.18",
11
11
  type: "module",
12
12
  description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
13
13
  repository: {
@@ -10466,7 +10466,7 @@ async function processText(rawText, config, systemPrompt) {
10466
10466
  }
10467
10467
 
10468
10468
  // src/version.ts
10469
- var VERSION = "0.1.16";
10469
+ var VERSION = "0.1.17";
10470
10470
 
10471
10471
  // src/cli/index.ts
10472
10472
  var program = new Command;
package/dist/mcp/index.js CHANGED
@@ -21,7 +21,7 @@ var __require = import.meta.require;
21
21
  var require_package = __commonJS((exports, module) => {
22
22
  module.exports = {
23
23
  name: "@hasna/recordings",
24
- version: "0.1.16",
24
+ version: "0.1.18",
25
25
  type: "module",
26
26
  description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
27
27
  repository: {
@@ -14983,7 +14983,7 @@ async function processText(rawText, config, systemPrompt) {
14983
14983
  }
14984
14984
 
14985
14985
  // src/version.ts
14986
- var VERSION = "0.1.16";
14986
+ var VERSION = "0.1.17";
14987
14987
 
14988
14988
  // src/mcp/index.ts
14989
14989
  var config = loadConfig();
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.1.16";
1
+ export declare const VERSION = "0.1.17";
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.16",
3
+ "version": "0.1.18",
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": {
@@ -237,11 +237,10 @@ public struct MenuBarPopover: View {
237
237
  showProject: filterProjectId == nil,
238
238
  isCopied: copiedIndex == i
239
239
  ) {
240
- NSPasteboard.general.clearContents()
241
- NSPasteboard.general.setString(item.displayText, forType: .string)
242
240
  withAnimation(.easeInOut(duration: 0.15)) {
243
241
  copiedIndex = i
244
242
  }
243
+ engine.pasteIntoFrontApp(item.displayText)
245
244
  DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
246
245
  withAnimation { if copiedIndex == i { copiedIndex = nil } }
247
246
  }
@@ -17,7 +17,6 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
17
17
  private var ws: URLSessionWebSocketTask?
18
18
  private var receiveTask: Task<Void, Never>?
19
19
  private var outboundEventTask: Task<Void, Never>?
20
- private var liveCommitTask: Task<Void, Never>?
21
20
  private var isConfigured = false
22
21
  private var pendingAudioChunks: [Data] = []
23
22
  private var itemOrder: [String] = []
@@ -29,11 +28,8 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
29
28
  private var queuedCommitCount = 0
30
29
  private var uncommittedAudioBytes = 0
31
30
  private var lastRealtimeEventAt = Date.distantPast
32
- private var lastCommitQueuedAt = Date.distantPast
33
31
 
34
32
  private nonisolated static let minimumManualCommitBytes = 5_760
35
- private nonisolated static let minimumLiveCommitBytes = 38_400
36
- private nonisolated static let liveCommitIntervalSeconds: TimeInterval = 0.8
37
33
 
38
34
  private let apiKey: String
39
35
  private let homePath: String
@@ -70,7 +66,6 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
70
66
  queuedCommitCount = 0
71
67
  uncommittedAudioBytes = 0
72
68
  lastRealtimeEventAt = Date()
73
- lastCommitQueuedAt = Date()
74
69
  error = nil
75
70
 
76
71
  var request = URLRequest(url: Self.transcriptionURL)
@@ -122,7 +117,6 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
122
117
  "audio": base64,
123
118
  ]
124
119
  enqueueOutboundEvent(msg)
125
- scheduleLiveCommitIfNeeded()
126
120
  }
127
121
 
128
122
  /// Signal end of input — triggers final transcription completion.
@@ -140,7 +134,6 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
140
134
  uncommittedAudioBytes = 0
141
135
  queuedCommitCount += 1
142
136
  lastRealtimeEventAt = Date()
143
- lastCommitQueuedAt = Date()
144
137
  NativeAppLog.write("realtime commit queued reason=\(reason) bytes=\(bytesToCommit)", homePath: homePath)
145
138
  let commitTask = enqueueOutboundEvent(msg)
146
139
  await commitTask?.value
@@ -178,11 +171,9 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
178
171
  receiveTask?.cancel()
179
172
  ws?.cancel(with: .normalClosure, reason: nil)
180
173
  outboundEventTask?.cancel()
181
- liveCommitTask?.cancel()
182
174
  ws = nil
183
175
  receiveTask = nil
184
176
  outboundEventTask = nil
185
- liveCommitTask = nil
186
177
  pendingAudioChunks.removeAll(keepingCapacity: true)
187
178
  let text = accumulatedText
188
179
  return text
@@ -285,25 +276,6 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
285
276
  return task
286
277
  }
287
278
 
288
- private func scheduleLiveCommitIfNeeded(now: Date = Date()) {
289
- guard liveCommitTask == nil else { return }
290
- let secondsSinceLastCommit = now.timeIntervalSince(lastCommitQueuedAt)
291
- guard Self.shouldAutoCommitLiveInput(
292
- uncommittedAudioBytes: uncommittedAudioBytes,
293
- secondsSinceLastCommit: secondsSinceLastCommit
294
- ) else { return }
295
-
296
- liveCommitTask = Task { [weak self] in
297
- _ = await self?.commitLiveInput()
298
- }
299
- }
300
-
301
- @discardableResult
302
- private func commitLiveInput() async -> Bool {
303
- defer { liveCommitTask = nil }
304
- return await commitInput(reason: "live")
305
- }
306
-
307
279
  private func registerItem(_ itemID: String, previousItemID: String?) {
308
280
  guard !itemOrder.contains(itemID) else { return }
309
281
  if let previousItemID, let previousIndex = itemOrder.firstIndex(of: previousItemID) {
@@ -383,14 +355,6 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
383
355
  uncommittedAudioBytes >= minimumManualCommitBytes
384
356
  }
385
357
 
386
- private nonisolated static func shouldAutoCommitLiveInput(
387
- uncommittedAudioBytes: Int,
388
- secondsSinceLastCommit: TimeInterval
389
- ) -> Bool {
390
- uncommittedAudioBytes >= minimumLiveCommitBytes &&
391
- secondsSinceLastCommit >= liveCommitIntervalSeconds
392
- }
393
-
394
358
  private nonisolated static func transcriptionSessionUpdateEvent(transcription: [String: Any]) -> [String: Any] {
395
359
  [
396
360
  "type": "session.update",
@@ -403,7 +367,12 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
403
367
  "rate": 24_000,
404
368
  ],
405
369
  "transcription": transcription,
406
- "turn_detection": NSNull(),
370
+ "turn_detection": [
371
+ "type": "server_vad",
372
+ "threshold": 0.5,
373
+ "prefix_padding_ms": 300,
374
+ "silence_duration_ms": 350,
375
+ ],
407
376
  "noise_reduction": [
408
377
  "type": "near_field",
409
378
  ],
@@ -458,16 +427,6 @@ extension RealtimeTranscriptionClient {
458
427
  shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes)
459
428
  }
460
429
 
461
- public nonisolated static func shouldAutoCommitLiveInputTestHelper(
462
- uncommittedAudioBytes: Int,
463
- secondsSinceLastCommit: TimeInterval
464
- ) -> Bool {
465
- shouldAutoCommitLiveInput(
466
- uncommittedAudioBytes: uncommittedAudioBytes,
467
- secondsSinceLastCommit: secondsSinceLastCommit
468
- )
469
- }
470
-
471
430
  public nonisolated static func sessionUpdateTestHelper(prompt: String, language: String = "") -> [String: Any] {
472
431
  var transcription: [String: Any] = [
473
432
  "model": modelID,
@@ -50,6 +50,12 @@ public enum RecordingTrigger {
50
50
  case keyboardShortcut
51
51
  }
52
52
 
53
+ struct PasteTargetCandidate: Equatable, Sendable {
54
+ let pid: pid_t
55
+ let bundleIdentifier: String?
56
+ let isRegularApp: Bool
57
+ }
58
+
53
59
  private actor PCMStreamState {
54
60
  private var recordedPCM = Data()
55
61
  private var pendingChunk = Data()
@@ -382,13 +388,12 @@ public final class RecordingEngine: ObservableObject {
382
388
  // MARK: - Real-time Streaming
383
389
 
384
390
  private func startRealtimeStreaming(apiKey: String) {
385
- let systemPrompt = projectStore?.effectiveSystemPrompt ?? ""
386
391
  let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
387
392
  realtimeClient = client
388
393
  log("realtime streaming task starting")
389
394
 
390
395
  streamingTask = Task {
391
- await client.startStreaming(systemPrompt: systemPrompt)
396
+ await client.startStreaming()
392
397
  self.log("realtime start completed streaming=\(client.isStreaming) error=\(client.error ?? "")")
393
398
 
394
399
  // Receive deltas
@@ -430,6 +435,7 @@ public final class RecordingEngine: ObservableObject {
430
435
 
431
436
  let curMode = mode
432
437
  let targetAppBundleIdentifier = targetAppBundleIdentifier
438
+ let targetAppPid = targetAppPid
433
439
  let activeProjectId = projectStore?.settings.activeProjectId
434
440
  let activeProjectName = projectStore?.activeProject?.name
435
441
  let audioPath = activeAudioPath
@@ -453,29 +459,20 @@ public final class RecordingEngine: ObservableObject {
453
459
  self.streamingTask?.cancel()
454
460
  self.streamingTask = nil
455
461
 
456
- let text = streamingResult.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : streamingResult
462
+ let realtimeText = streamingResult.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : streamingResult
457
463
 
458
464
  self.liveTranscriptionText = ""
459
465
 
460
- if let text, !Self.shouldFallbackFromPartialRealtime(text: text, pcmByteCount: self.recordedPCM.count) {
461
- self.log("finish using realtime text chars=\(text.count)")
462
- self.isTranscribing = false
463
- self.finishWithText(
464
- text,
465
- curMode: curMode,
466
- targetAppBundleIdentifier: targetAppBundleIdentifier,
467
- activeProjectId: activeProjectId,
468
- activeProjectName: activeProjectName
469
- )
470
- } else if let audioPath, self.writeCapturedWAV(to: audioPath) {
471
- if let text {
472
- self.log("realtime text looked partial chars=\(text.count); falling back to CLI")
466
+ if let audioPath, self.writeCapturedWAV(to: audioPath) {
467
+ if let realtimeText {
468
+ self.log("realtime preview ignored for final chars=\(realtimeText.count)")
473
469
  }
474
- self.log("falling back to CLI transcription audioPath=\(audioPath)")
470
+ self.log("transcribing captured full audio audioPath=\(audioPath)")
475
471
  self.fallbackTranscribe(
476
472
  audioPath: audioPath,
477
473
  curMode: curMode,
478
474
  targetAppBundleIdentifier: targetAppBundleIdentifier,
475
+ targetAppPid: targetAppPid,
479
476
  activeProjectId: activeProjectId,
480
477
  activeProjectName: activeProjectName
481
478
  )
@@ -496,7 +493,7 @@ public final class RecordingEngine: ObservableObject {
496
493
  return trimmed.count < 12 || words.count <= 2
497
494
  }
498
495
 
499
- private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
496
+ private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?) {
500
497
  log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
501
498
  if curMode == .command {
502
499
  runCommandMode(instruction: text)
@@ -505,7 +502,7 @@ public final class RecordingEngine: ObservableObject {
505
502
 
506
503
  let shortcutText = voiceShortcuts?.match(text)
507
504
  let output = shortcutText ?? text
508
- pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier)
505
+ pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid)
509
506
  recentTranscriptions.insert(
510
507
  TranscriptionResult(
511
508
  rawText: text,
@@ -587,7 +584,7 @@ public final class RecordingEngine: ObservableObject {
587
584
 
588
585
  // MARK: - Fallback Transcription
589
586
 
590
- private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
587
+ private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?) {
591
588
  let homePath = home
592
589
 
593
590
  isTranscribing = true
@@ -619,6 +616,7 @@ public final class RecordingEngine: ObservableObject {
619
616
  text,
620
617
  curMode: curMode,
621
618
  targetAppBundleIdentifier: targetAppBundleIdentifier,
619
+ targetAppPid: targetAppPid,
622
620
  activeProjectId: activeProjectId,
623
621
  activeProjectName: activeProjectName
624
622
  )
@@ -709,8 +707,8 @@ public final class RecordingEngine: ObservableObject {
709
707
 
710
708
  // MARK: - Paste
711
709
 
712
- func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil) {
713
- log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") accessibility=\(AXIsProcessTrusted())")
710
+ func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil) {
711
+ log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") pid=\(targetAppPid.map(String.init) ?? "nil") accessibility=\(AXIsProcessTrusted())")
714
712
  let pb = NSPasteboard.general
715
713
  pb.clearContents()
716
714
  pb.setString(text, forType: .string)
@@ -726,13 +724,22 @@ public final class RecordingEngine: ObservableObject {
726
724
 
727
725
  let myPID = ProcessInfo.processInfo.processIdentifier
728
726
  let runningApps = NSWorkspace.shared.runningApplications
729
- let targetApp = runningApps.first(where: {
730
- guard $0.processIdentifier != myPID, $0.activationPolicy == .regular else { return false }
731
- guard let bundleIdentifier = targetAppBundleIdentifier else { return false }
732
- return $0.bundleIdentifier == bundleIdentifier
733
- }) ?? runningApps.first(where: {
734
- $0.activationPolicy == .regular && $0.processIdentifier != myPID
735
- })
727
+ let candidates = runningApps.map {
728
+ PasteTargetCandidate(
729
+ pid: $0.processIdentifier,
730
+ bundleIdentifier: $0.bundleIdentifier,
731
+ isRegularApp: $0.activationPolicy == .regular
732
+ )
733
+ }
734
+ let selectedTarget = Self.selectPasteTarget(
735
+ candidates: candidates,
736
+ currentPid: myPID,
737
+ targetBundleIdentifier: targetAppBundleIdentifier,
738
+ targetPid: targetAppPid
739
+ )
740
+ let targetApp = selectedTarget.flatMap { selected in
741
+ runningApps.first { $0.processIdentifier == selected.pid }
742
+ }
736
743
 
737
744
  guard let app = targetApp else {
738
745
  log("paste target app not found")
@@ -740,8 +747,8 @@ public final class RecordingEngine: ObservableObject {
740
747
  return
741
748
  }
742
749
 
743
- // Activate target app and wait for focus to stabilize
744
- app.activate()
750
+ // Activate the exact app that owned focus when recording started, then paste after focus settles.
751
+ app.activate(options: [.activateIgnoringOtherApps])
745
752
 
746
753
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
747
754
  let src = CGEventSource(stateID: .hidSystemState)
@@ -757,6 +764,24 @@ public final class RecordingEngine: ObservableObject {
757
764
  }
758
765
  }
759
766
 
767
+ nonisolated static func selectPasteTarget(
768
+ candidates: [PasteTargetCandidate],
769
+ currentPid: pid_t,
770
+ targetBundleIdentifier: String?,
771
+ targetPid: pid_t?
772
+ ) -> PasteTargetCandidate? {
773
+ candidates.first {
774
+ guard let targetPid else { return false }
775
+ return $0.pid == targetPid && $0.pid != currentPid
776
+ } ?? candidates.first {
777
+ guard $0.pid != currentPid, $0.isRegularApp else { return false }
778
+ guard let targetBundleIdentifier else { return false }
779
+ return $0.bundleIdentifier == targetBundleIdentifier
780
+ } ?? candidates.first {
781
+ $0.isRegularApp && $0.pid != currentPid
782
+ }
783
+ }
784
+
760
785
  private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
761
786
  if AXIsProcessTrusted() {
762
787
  return true
@@ -0,0 +1,38 @@
1
+ import Testing
2
+ @testable import RecordingsLib
3
+
4
+ struct PasteTargetTests {
5
+ @Test("paste target prefers captured process id over bundle fallback")
6
+ func prefersCapturedPid() {
7
+ let candidates = [
8
+ PasteTargetCandidate(pid: 10, bundleIdentifier: "com.editor", isRegularApp: true),
9
+ PasteTargetCandidate(pid: 20, bundleIdentifier: "com.editor", isRegularApp: true),
10
+ ]
11
+
12
+ let selected = RecordingEngine.selectPasteTarget(
13
+ candidates: candidates,
14
+ currentPid: 99,
15
+ targetBundleIdentifier: "com.editor",
16
+ targetPid: 20
17
+ )
18
+
19
+ #expect(selected?.pid == 20)
20
+ }
21
+
22
+ @Test("paste target ignores current app when falling back")
23
+ func ignoresCurrentApp() {
24
+ let candidates = [
25
+ PasteTargetCandidate(pid: 99, bundleIdentifier: "com.hasna.recordings", isRegularApp: true),
26
+ PasteTargetCandidate(pid: 30, bundleIdentifier: "com.notes", isRegularApp: true),
27
+ ]
28
+
29
+ let selected = RecordingEngine.selectPasteTarget(
30
+ candidates: candidates,
31
+ currentPid: 99,
32
+ targetBundleIdentifier: nil,
33
+ targetPid: nil
34
+ )
35
+
36
+ #expect(selected?.pid == 30)
37
+ }
38
+ }
@@ -1,4 +1,3 @@
1
- import Foundation
2
1
  import Testing
3
2
  @testable import RecordingsLib
4
3
 
@@ -99,7 +98,8 @@ struct RealtimeTranscriptionTests {
99
98
  #expect(transcription?["prompt"] as? String == "Use Alumia as vocabulary")
100
99
  #expect(transcription?["language"] as? String == "en")
101
100
 
102
- #expect(input?["turn_detection"] is NSNull)
101
+ let turnDetection = input?["turn_detection"] as? [String: Any]
102
+ #expect(turnDetection?["type"] as? String == "server_vad")
103
103
 
104
104
  let include = session?["include"] as? [String]
105
105
  #expect(include?.contains("item.input_audio_transcription.logprobs") == true)
@@ -117,22 +117,6 @@ struct RealtimeTranscriptionTests {
117
117
  #expect(RealtimeTranscriptionClient.shouldManuallyCommitTestHelper(uncommittedAudioBytes: 5_760) == true)
118
118
  }
119
119
 
120
- @Test("Live commits wait for enough audio and elapsed time")
121
- func liveCommitThreshold() {
122
- #expect(RealtimeTranscriptionClient.shouldAutoCommitLiveInputTestHelper(
123
- uncommittedAudioBytes: 38_399,
124
- secondsSinceLastCommit: 1.0
125
- ) == false)
126
- #expect(RealtimeTranscriptionClient.shouldAutoCommitLiveInputTestHelper(
127
- uncommittedAudioBytes: 38_400,
128
- secondsSinceLastCommit: 0.79
129
- ) == false)
130
- #expect(RealtimeTranscriptionClient.shouldAutoCommitLiveInputTestHelper(
131
- uncommittedAudioBytes: 38_400,
132
- secondsSinceLastCommit: 0.8
133
- ) == true)
134
- }
135
-
136
120
  @Test("Partial realtime text falls back for longer recordings")
137
121
  func partialRealtimeFallback() {
138
122
  #expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 96_000) == true)