@hasna/recordings 0.1.28 → 0.1.30

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
@@ -5098,7 +5098,7 @@ var init_pg_migrate = __esm(() => {
5098
5098
  var require_package = __commonJS((exports, module) => {
5099
5099
  module.exports = {
5100
5100
  name: "@hasna/recordings",
5101
- version: "0.1.28",
5101
+ version: "0.1.30",
5102
5102
  type: "module",
5103
5103
  description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
5104
5104
  repository: {
@@ -6081,7 +6081,7 @@ async function processText(rawText, config, systemPrompt) {
6081
6081
  }
6082
6082
 
6083
6083
  // src/version.ts
6084
- var VERSION = "0.1.28";
6084
+ var VERSION = "0.1.30";
6085
6085
 
6086
6086
  // src/cli/storage.ts
6087
6087
  import chalk from "chalk";
@@ -6804,16 +6804,33 @@ appCommand.command("reset-permissions").description("Reset macOS Microphone and
6804
6804
  console.error(chalk2.red("Permission reset is only available on macOS"));
6805
6805
  process.exit(1);
6806
6806
  }
6807
- const services = ["Microphone", "Accessibility"];
6808
- for (const service of services) {
6809
- const result = spawnSync("tccutil", ["reset", service, "com.hasna.recordings"], {
6810
- stdio: "inherit"
6811
- });
6812
- if (result.error) {
6813
- console.error(chalk2.red(result.error.message));
6814
- process.exit(1);
6815
- }
6807
+ resetMacOSPermissions();
6808
+ });
6809
+ appCommand.command("request-permissions").description("Open Recordings.app and trigger macOS Microphone and Accessibility permission prompts").option("--reset", "Reset existing Microphone and Accessibility decisions before requesting").action((opts) => {
6810
+ if (process.platform !== "darwin") {
6811
+ console.error(chalk2.red("Permission prompts are only available on macOS"));
6812
+ process.exit(1);
6813
+ }
6814
+ const status = getMacOSAppStatus();
6815
+ if (!status.installed) {
6816
+ console.error(chalk2.red("Recordings.app is not installed. Run: recordings app install"));
6817
+ process.exit(1);
6816
6818
  }
6819
+ if (opts.reset) {
6820
+ resetMacOSPermissions();
6821
+ }
6822
+ const result = spawnSync("open", [
6823
+ "-n",
6824
+ status.installed_app_path,
6825
+ "--args",
6826
+ "--request-permissions",
6827
+ "--open-permission-settings"
6828
+ ], { stdio: "inherit" });
6829
+ if (result.error) {
6830
+ console.error(chalk2.red(result.error.message));
6831
+ process.exit(1);
6832
+ }
6833
+ process.exit(result.status ?? 1);
6817
6834
  });
6818
6835
  appCommand.command("log").description("Show the Recordings.app diagnostic log").option("-n, --lines <lines>", "Number of lines to print", "120").action((opts) => {
6819
6836
  const status = getMacOSAppStatus();
@@ -7319,6 +7336,18 @@ function getMacOSAppStatus() {
7319
7336
  log_path: logPath
7320
7337
  };
7321
7338
  }
7339
+ function resetMacOSPermissions() {
7340
+ const services = ["Microphone", "Accessibility"];
7341
+ for (const service of services) {
7342
+ const result = spawnSync("tccutil", ["reset", service, "com.hasna.recordings"], {
7343
+ stdio: "inherit"
7344
+ });
7345
+ if (result.error) {
7346
+ console.error(chalk2.red(result.error.message));
7347
+ process.exit(1);
7348
+ }
7349
+ }
7350
+ }
7322
7351
  function getCodeSigningInfo(appPath) {
7323
7352
  if (process.platform !== "darwin" || !existsSync4(appPath)) {
7324
7353
  return { cdHash: null, adHoc: false };
package/dist/mcp/index.js CHANGED
@@ -4920,7 +4920,7 @@ var require_lib2 = __commonJS((exports, module) => {
4920
4920
  var require_package = __commonJS((exports, module) => {
4921
4921
  module.exports = {
4922
4922
  name: "@hasna/recordings",
4923
- version: "0.1.28",
4923
+ version: "0.1.30",
4924
4924
  type: "module",
4925
4925
  description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
4926
4926
  repository: {
@@ -9797,7 +9797,7 @@ async function processText(rawText, config, systemPrompt) {
9797
9797
  }
9798
9798
 
9799
9799
  // src/version.ts
9800
- var VERSION = "0.1.28";
9800
+ var VERSION = "0.1.30";
9801
9801
 
9802
9802
  // src/db/storage-config.ts
9803
9803
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.1.28";
1
+ export declare const VERSION = "0.1.30";
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.28",
3
+ "version": "0.1.30",
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": {
@@ -1,4 +1,5 @@
1
1
  @preconcurrency import Cocoa
2
+ import AVFoundation
2
3
  import SwiftUI
3
4
  import KeyboardShortcuts
4
5
  import RecordingsLib
@@ -14,6 +15,7 @@ struct RecordingsApp: App {
14
15
  AXIsProcessTrustedWithOptions(
15
16
  [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
16
17
  )
18
+ Self.handlePermissionRequestArguments()
17
19
  }
18
20
 
19
21
  var body: some Scene {
@@ -49,4 +51,29 @@ struct RecordingsApp: App {
49
51
  app.terminate()
50
52
  }
51
53
  }
54
+
55
+ private static func handlePermissionRequestArguments() {
56
+ let arguments = CommandLine.arguments
57
+ guard arguments.contains("--request-permissions") else { return }
58
+
59
+ NativeAppLog.write("request-permissions argument received")
60
+ AVCaptureDevice.requestAccess(for: .audio) { granted in
61
+ NativeAppLog.write("request-permissions microphone granted=\(granted)")
62
+ }
63
+
64
+ let trusted = AXIsProcessTrustedWithOptions(
65
+ [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
66
+ )
67
+ NativeAppLog.write("request-permissions accessibility trusted=\(trusted)")
68
+
69
+ guard arguments.contains("--open-permission-settings") else { return }
70
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
71
+ if let microphone = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") {
72
+ NSWorkspace.shared.open(microphone)
73
+ }
74
+ if let accessibility = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
75
+ NSWorkspace.shared.open(accessibility)
76
+ }
77
+ }
78
+ }
52
79
  }
@@ -1,9 +1,9 @@
1
1
  import Foundation
2
2
 
3
- enum NativeAppLog {
3
+ public enum NativeAppLog {
4
4
  private static let lock = NSLock()
5
5
 
6
- static func write(_ message: String, homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
6
+ public static func write(_ message: String, homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
7
7
  lock.lock()
8
8
  defer { lock.unlock() }
9
9
 
@@ -558,20 +558,21 @@ public final class RecordingEngine: ObservableObject {
558
558
  self.streamingTask = nil
559
559
 
560
560
  let realtimeText = Self.normalizedRealtimeTranscript(streamingResult)
561
-
562
- self.liveTranscriptionText = ""
563
-
564
- if Self.shouldUseRealtimeFastPath(
561
+ let fastPathText = Self.realtimeFastPathTranscript(
565
562
  realtimeText: streamingResult,
566
563
  pcmByteCount: self.recordedPCM.count,
567
564
  language: transcriptionLanguage
568
- ),
569
- let realtimeText {
565
+ )
566
+
567
+ self.liveTranscriptionText = ""
568
+
569
+ if let fastPathText {
570
570
  let releaseToTextMS = Int(Date().timeIntervalSince(stopStartedAt) * 1_000)
571
- self.log("using realtime fast path chars=\(realtimeText.count) releaseToTextMs=\(releaseToTextMS)")
571
+ let repaired = Self.wasRealtimeTranscriptRepaired(rawText: streamingResult, cleanedText: fastPathText)
572
+ self.log("using realtime fast path chars=\(fastPathText.count) repaired=\(repaired) releaseToTextMs=\(releaseToTextMS)")
572
573
  self.isTranscribing = false
573
574
  self.finishWithText(
574
- realtimeText,
575
+ fastPathText,
575
576
  curMode: curMode,
576
577
  targetAppBundleIdentifier: targetAppBundleIdentifier,
577
578
  targetAppPid: targetAppPid,
@@ -639,11 +640,23 @@ public final class RecordingEngine: ObservableObject {
639
640
  pcmByteCount: Int,
640
641
  language: String = "en"
641
642
  ) -> Bool {
642
- guard let text = normalizedRealtimeTranscript(realtimeText) else { return false }
643
+ realtimeFastPathTranscript(
644
+ realtimeText: realtimeText,
645
+ pcmByteCount: pcmByteCount,
646
+ language: language
647
+ ) != nil
648
+ }
649
+
650
+ public nonisolated static func realtimeFastPathTranscript(
651
+ realtimeText: String?,
652
+ pcmByteCount: Int,
653
+ language: String = "en"
654
+ ) -> String? {
655
+ guard let text = normalizedRealtimeTranscript(realtimeText) else { return nil }
643
656
  guard isSafeRealtimeFastPathText(rawText: realtimeText ?? "", cleanedText: text, language: language) else {
644
- return false
657
+ return nil
645
658
  }
646
- return !shouldFallbackFromPartialRealtime(text: text, pcmByteCount: pcmByteCount)
659
+ return shouldFallbackFromPartialRealtime(text: text, pcmByteCount: pcmByteCount) ? nil : text
647
660
  }
648
661
 
649
662
  public nonisolated static func isSafeRealtimeFastPathText(rawText: String, cleanedText: String, language: String) -> Bool {
@@ -652,19 +665,31 @@ public final class RecordingEngine: ObservableObject {
652
665
  guard languageHint == "en" else { return true }
653
666
 
654
667
  let rawTrimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
655
- guard !containsCJKArtifact(in: rawTrimmed) else { return false }
668
+ let rawNormalized = normalizedTranscriptText(rawTrimmed)
669
+ let cleanedNormalized = normalizedTranscriptText(cleanedText)
670
+ guard !cleanedNormalized.isEmpty else { return false }
671
+ guard rawNormalized != cleanedNormalized else { return true }
672
+ guard cjkLetterCount(in: cleanedNormalized) == 0 else { return false }
656
673
 
657
- let rawNormalized = rawTrimmed.replacingOccurrences(
658
- of: #"\s+"#,
659
- with: " ",
660
- options: .regularExpression
661
- )
662
- let cleanedNormalized = cleanedText.replacingOccurrences(
663
- of: #"\s+"#,
664
- with: " ",
665
- options: .regularExpression
666
- )
667
- return rawNormalized == cleanedNormalized
674
+ let cleanedWords = canonicalTranscriptWords(cleanedNormalized, droppingArtifacts: false)
675
+ guard !cleanedWords.isEmpty else { return false }
676
+
677
+ let rawWords = canonicalTranscriptWords(rawNormalized, droppingArtifacts: true)
678
+ guard isSubsequence(cleanedWords, of: rawWords) else { return false }
679
+
680
+ let rawCJKCount = cjkLetterCount(in: rawNormalized)
681
+ if rawCJKCount > 0 {
682
+ let cleanedLatinCount = latinLetterCount(in: cleanedNormalized)
683
+ guard cleanedLatinCount >= max(2, rawCJKCount * 2) else { return false }
684
+ guard rawCJKCount <= max(6, cleanedLatinCount / 3) else { return false }
685
+ }
686
+
687
+ let removedWordCount = max(0, rawWords.count - cleanedWords.count)
688
+ return removedWordCount <= max(8, cleanedWords.count + 3)
689
+ }
690
+
691
+ public nonisolated static func wasRealtimeTranscriptRepaired(rawText: String, cleanedText: String) -> Bool {
692
+ normalizedTranscriptText(rawText) != normalizedTranscriptText(cleanedText)
668
693
  }
669
694
 
670
695
  public nonisolated static func cleanRealtimeArtifactText(_ text: String) -> String {
@@ -709,6 +734,55 @@ public final class RecordingEngine: ObservableObject {
709
734
  return words.joined(separator: " ")
710
735
  }
711
736
 
737
+ private nonisolated static func normalizedTranscriptText(_ text: String) -> String {
738
+ text
739
+ .replacingOccurrences(of: "\n", with: " ")
740
+ .replacingOccurrences(of: "\t", with: " ")
741
+ .replacingOccurrences(
742
+ of: #"\s+"#,
743
+ with: " ",
744
+ options: .regularExpression
745
+ )
746
+ .trimmingCharacters(in: .whitespacesAndNewlines)
747
+ }
748
+
749
+ private nonisolated static func canonicalTranscriptWords(_ text: String, droppingArtifacts: Bool) -> [String] {
750
+ text.split(whereSeparator: { $0.isWhitespace || $0.isNewline }).compactMap { rawWord in
751
+ var normalized = String(rawWord)
752
+ .trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines))
753
+ .lowercased()
754
+ if droppingArtifacts {
755
+ normalized = normalized.unicodeScalars.reduce(into: "") { output, scalar in
756
+ if !isCJKScalar(scalar) {
757
+ output.unicodeScalars.append(scalar)
758
+ }
759
+ }
760
+ }
761
+ guard !normalized.isEmpty else { return nil }
762
+ if droppingArtifacts, isStandaloneRealtimeArtifact(normalized) {
763
+ return nil
764
+ }
765
+ return normalized
766
+ }
767
+ }
768
+
769
+ private nonisolated static func isSubsequence(_ needle: [String], of haystack: [String]) -> Bool {
770
+ guard !needle.isEmpty else { return true }
771
+ var index = 0
772
+ for word in haystack where word == needle[index] {
773
+ index += 1
774
+ if index == needle.count {
775
+ return true
776
+ }
777
+ }
778
+ return false
779
+ }
780
+
781
+ private nonisolated static func isStandaloneRealtimeArtifact(_ word: String) -> Bool {
782
+ let artifactTokens: Set<String> = ["어", "음", "um", "umm", "uh", "uhh", "erm", "hmm", "eh"]
783
+ return artifactTokens.contains(word) || isMostlyCJKArtifact(word)
784
+ }
785
+
712
786
  private nonisolated static func collapseAdjacentDuplicateWords(in text: String) -> String {
713
787
  let words = text.split(separator: " ").map(String.init)
714
788
  guard words.count > 1 else { return text }
@@ -126,22 +126,32 @@ struct RealtimeTranscriptionTests {
126
126
  #expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 12_000) == false)
127
127
  }
128
128
 
129
- @Test("Realtime fast path accepts useful text and rejects empty or suspicious partial output")
129
+ @Test("Realtime fast path accepts useful and safely repaired text")
130
130
  func realtimeFastPathDecision() {
131
131
  #expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: " this is a useful transcript ", pcmByteCount: 96_000))
132
132
  #expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: "Hi", pcmByteCount: 12_000))
133
133
  #expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: "Hi", pcmByteCount: 96_000) == false)
134
134
  #expect(RecordingEngine.shouldUseRealtimeFastPath(realtimeText: " ", pcmByteCount: 96_000) == false)
135
- #expect(RecordingEngine.shouldUseRealtimeFastPath(
135
+ #expect(RecordingEngine.realtimeFastPathTranscript(
136
136
  realtimeText: "Actually 리수 Zoom your goal",
137
137
  pcmByteCount: 96_000,
138
138
  language: "en"
139
- ) == false)
140
- #expect(RecordingEngine.shouldUseRealtimeFastPath(
139
+ ) == "Actually Zoom your goal")
140
+ #expect(RecordingEngine.realtimeFastPathTranscript(
141
+ realtimeText: "어 Okay I don't know if this This is working어 Okay I don't know if this This is working",
142
+ pcmByteCount: 96_000,
143
+ language: "en"
144
+ ) == "Okay I don't know if this is working")
145
+ #expect(RecordingEngine.realtimeFastPathTranscript(
141
146
  realtimeText: "Actually Zoom your goal",
142
147
  pcmByteCount: 96_000,
143
148
  language: "en"
144
- ))
149
+ ) == "Actually Zoom your goal")
150
+ #expect(RecordingEngine.realtimeFastPathTranscript(
151
+ realtimeText: "리수 度扫 開けたの 어",
152
+ pcmByteCount: 96_000,
153
+ language: "en"
154
+ ) == nil)
145
155
  }
146
156
 
147
157
  @Test("Realtime artifact cleanup removes duplicated chunks and filler tokens")
@@ -64,17 +64,26 @@ struct TranscriptResolutionTests {
64
64
  #expect(RecordingEngine.normalizedRealtimeTranscript(nil) == nil)
65
65
  }
66
66
 
67
- @Test("Unsafe English realtime text is not eligible for fast-path paste")
68
- func unsafeRealtimeTextRejectedForFastPath() {
67
+ @Test("English realtime text can be safely repaired before fast-path paste")
68
+ func realtimeTextRepairedForFastPath() {
69
69
  #expect(RecordingEngine.isSafeRealtimeFastPathText(
70
70
  rawText: "Actually 리수 Zoom your goal",
71
71
  cleanedText: "Actually Zoom your goal",
72
72
  language: "en"
73
- ) == false)
73
+ ))
74
74
  #expect(RecordingEngine.isSafeRealtimeFastPathText(
75
75
  rawText: "Actually Zoom your goal",
76
76
  cleanedText: "Actually Zoom your goal",
77
77
  language: "en"
78
78
  ))
79
+ #expect(RecordingEngine.isSafeRealtimeFastPathText(
80
+ rawText: "Actually Zoom your goal",
81
+ cleanedText: "Actually invented Zoom your goal",
82
+ language: "en"
83
+ ) == false)
84
+ #expect(RecordingEngine.wasRealtimeTranscriptRepaired(
85
+ rawText: "working어",
86
+ cleanedText: "working"
87
+ ))
79
88
  }
80
89
  }