@hasna/recordings 0.1.10 → 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 (71) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/index.js +342 -153
  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 +114 -33
  6. package/dist/lib/config.d.ts.map +1 -1
  7. package/dist/lib/enhancer.d.ts +2 -2
  8. package/dist/lib/enhancer.d.ts.map +1 -1
  9. package/dist/lib/recorder.d.ts.map +1 -1
  10. package/dist/lib/transcriber.d.ts +8 -2
  11. package/dist/lib/transcriber.d.ts.map +1 -1
  12. package/dist/mcp/index.js +183 -52
  13. package/dist/types/index.d.ts +3 -0
  14. package/dist/types/index.d.ts.map +1 -1
  15. package/dist/version.d.ts +2 -0
  16. package/dist/version.d.ts.map +1 -0
  17. package/package.json +20 -3
  18. package/scripts/install_macos_app.sh +76 -0
  19. package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +16 -4
  20. package/src/native/Recordings/Package.resolved +19 -1
  21. package/src/native/Recordings/Package.swift +16 -5
  22. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +131 -0
  23. package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
  24. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +361 -0
  25. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
  26. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
  27. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +126 -0
  28. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
  29. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +724 -0
  30. package/src/native/Recordings/RecordingsLib/SettingsView.swift +218 -0
  31. package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
  32. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
  33. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
  34. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
  35. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
  36. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
  37. package/src/native/Recordings/build.sh +6 -6
  38. package/bun.lock +0 -250
  39. package/bunfig.toml +0 -2
  40. package/src/__tests__/agents.test.ts +0 -136
  41. package/src/__tests__/config.test.ts +0 -252
  42. package/src/__tests__/database.test.ts +0 -167
  43. package/src/__tests__/enhancer.test.ts +0 -574
  44. package/src/__tests__/preload.ts +0 -4
  45. package/src/__tests__/projects.test.ts +0 -109
  46. package/src/__tests__/recorder.test.ts +0 -278
  47. package/src/__tests__/recordings.test.ts +0 -353
  48. package/src/__tests__/transcriber.test.ts +0 -322
  49. package/src/__tests__/types.test.ts +0 -75
  50. package/src/cli/index.ts +0 -1115
  51. package/src/db/agents.ts +0 -104
  52. package/src/db/database.ts +0 -163
  53. package/src/db/pg-migrations.ts +0 -82
  54. package/src/db/projects.ts +0 -71
  55. package/src/db/recordings.ts +0 -225
  56. package/src/index.ts +0 -81
  57. package/src/lib/config.ts +0 -183
  58. package/src/lib/enhancer.ts +0 -167
  59. package/src/lib/recorder.ts +0 -198
  60. package/src/lib/transcriber.ts +0 -105
  61. package/src/mcp/index.ts +0 -464
  62. package/src/native/Recordings/Recordings/FnKeyMonitor.swift +0 -122
  63. package/src/native/Recordings/Recordings/MenuBarPopover.swift +0 -188
  64. package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -355
  65. package/src/native/Recordings/Recordings/SettingsView.swift +0 -93
  66. package/src/native/Recordings/test_fn.swift +0 -79
  67. package/src/native/Recordings/test_fn2.swift +0 -33
  68. package/src/native/RecordingsHelper.swift +0 -395
  69. package/src/types/index.ts +0 -144
  70. package/tsconfig.json +0 -21
  71. /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
@@ -0,0 +1,164 @@
1
+ @preconcurrency import AVFoundation
2
+ import Foundation
3
+
4
+ enum NativePCMRecorderError: LocalizedError {
5
+ case noInputDevice
6
+ case unsupportedInputFormat
7
+ case failedToCreateConverter
8
+ case failedToStart(String)
9
+
10
+ var errorDescription: String? {
11
+ switch self {
12
+ case .noInputDevice:
13
+ return "No microphone input device is available"
14
+ case .unsupportedInputFormat:
15
+ return "The selected microphone format is not supported"
16
+ case .failedToCreateConverter:
17
+ return "Could not prepare microphone audio conversion"
18
+ case .failedToStart(let message):
19
+ return "Could not start microphone capture: \(message)"
20
+ }
21
+ }
22
+ }
23
+
24
+ final class NativePCMRecorder: @unchecked Sendable {
25
+ private let engine = AVAudioEngine()
26
+ private let onPCM: @Sendable (Data) -> Void
27
+ private var converter: AVAudioConverter?
28
+ private var inputFormat: AVAudioFormat?
29
+ private var outputFormat: AVAudioFormat?
30
+ private var started = false
31
+
32
+ init(onPCM: @escaping @Sendable (Data) -> Void) {
33
+ self.onPCM = onPCM
34
+ }
35
+
36
+ func start() throws {
37
+ guard !started else { return }
38
+
39
+ let inputNode = engine.inputNode
40
+ let inputFormat = inputNode.inputFormat(forBus: 0)
41
+ guard inputFormat.channelCount > 0 else {
42
+ throw NativePCMRecorderError.noInputDevice
43
+ }
44
+ guard inputFormat.sampleRate > 0 else {
45
+ throw NativePCMRecorderError.unsupportedInputFormat
46
+ }
47
+ guard let outputFormat = Self.realtimeOutputFormat() else {
48
+ throw NativePCMRecorderError.unsupportedInputFormat
49
+ }
50
+ guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else {
51
+ throw NativePCMRecorderError.failedToCreateConverter
52
+ }
53
+
54
+ self.converter = converter
55
+ self.inputFormat = inputFormat
56
+ self.outputFormat = outputFormat
57
+
58
+ inputNode.installTap(onBus: 0, bufferSize: 1_024, format: inputFormat) { [weak self] buffer, _ in
59
+ self?.convertAndEmit(buffer)
60
+ }
61
+
62
+ do {
63
+ engine.prepare()
64
+ try engine.start()
65
+ started = true
66
+ } catch {
67
+ inputNode.removeTap(onBus: 0)
68
+ self.converter = nil
69
+ self.inputFormat = nil
70
+ self.outputFormat = nil
71
+ throw NativePCMRecorderError.failedToStart(error.localizedDescription)
72
+ }
73
+ }
74
+
75
+ func stop() {
76
+ guard started else {
77
+ converter = nil
78
+ inputFormat = nil
79
+ outputFormat = nil
80
+ return
81
+ }
82
+
83
+ engine.inputNode.removeTap(onBus: 0)
84
+ engine.stop()
85
+ converter = nil
86
+ inputFormat = nil
87
+ outputFormat = nil
88
+ started = false
89
+ }
90
+
91
+ deinit {
92
+ stop()
93
+ }
94
+
95
+ private func convertAndEmit(_ inputBuffer: AVAudioPCMBuffer) {
96
+ guard let converter, let inputFormat, let outputFormat else { return }
97
+
98
+ let sampleRateRatio = outputFormat.sampleRate / inputFormat.sampleRate
99
+ let estimatedFrames = AVAudioFrameCount(Double(inputBuffer.frameLength) * sampleRateRatio) + 512
100
+ guard let outputBuffer = AVAudioPCMBuffer(
101
+ pcmFormat: outputFormat,
102
+ frameCapacity: max(estimatedFrames, 1)
103
+ ) else {
104
+ return
105
+ }
106
+
107
+ let inputSource = AudioConverterInputSource(buffer: inputBuffer)
108
+ var conversionError: NSError?
109
+ converter.convert(to: outputBuffer, error: &conversionError) { _, status in
110
+ inputSource.next(status: status)
111
+ }
112
+
113
+ guard conversionError == nil else { return }
114
+ let data = Self.extractPCM16Data(from: outputBuffer)
115
+ if !data.isEmpty {
116
+ onPCM(data)
117
+ }
118
+ }
119
+
120
+ static func realtimeOutputFormat() -> AVAudioFormat? {
121
+ AVAudioFormat(
122
+ commonFormat: .pcmFormatInt16,
123
+ sampleRate: 24_000,
124
+ channels: 1,
125
+ interleaved: true
126
+ )
127
+ }
128
+
129
+ static func extractPCM16Data(from buffer: AVAudioPCMBuffer) -> Data {
130
+ var data = Data()
131
+ for audioBuffer in UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList) {
132
+ guard let bytes = audioBuffer.mData?.assumingMemoryBound(to: UInt8.self),
133
+ audioBuffer.mDataByteSize > 0 else {
134
+ continue
135
+ }
136
+ data.append(bytes, count: Int(audioBuffer.mDataByteSize))
137
+ }
138
+ return data
139
+ }
140
+ }
141
+
142
+ private final class AudioConverterInputSource: @unchecked Sendable {
143
+ private let buffer: AVAudioPCMBuffer
144
+ private let lock = NSLock()
145
+ private var consumed = false
146
+
147
+ init(buffer: AVAudioPCMBuffer) {
148
+ self.buffer = buffer
149
+ }
150
+
151
+ func next(status: UnsafeMutablePointer<AVAudioConverterInputStatus>) -> AVAudioBuffer? {
152
+ lock.lock()
153
+ defer { lock.unlock() }
154
+
155
+ if consumed {
156
+ status.pointee = .noDataNow
157
+ return nil
158
+ }
159
+
160
+ consumed = true
161
+ status.pointee = .haveData
162
+ return buffer
163
+ }
164
+ }
@@ -0,0 +1,113 @@
1
+ import Foundation
2
+
3
+ enum OpenAIAPIKeyStore {
4
+ static func load(
5
+ homePath: String,
6
+ environment: [String: String] = ProcessInfo.processInfo.environment,
7
+ userDefaultKey: String? = UserDefaults.standard.string(forKey: "openAIAPIKey")
8
+ ) -> String {
9
+ if let key = firstNonEmpty(environment["OPENAI_API_KEY"], environment["RECORDINGS_API_KEY"]) {
10
+ return key
11
+ }
12
+ if let key = firstNonEmpty(userDefaultKey) {
13
+ return key
14
+ }
15
+ if let key = loadConfigKey(homePath: homePath, environment: environment) {
16
+ return key
17
+ }
18
+ if let key = loadSecretKey(homePath: homePath) {
19
+ return key
20
+ }
21
+ return ""
22
+ }
23
+
24
+ private static func loadConfigKey(homePath: String, environment: [String: String]) -> String? {
25
+ let url = URL(fileURLWithPath: homePath)
26
+ .appendingPathComponent(".hasna")
27
+ .appendingPathComponent("recordings")
28
+ .appendingPathComponent("config.json")
29
+
30
+ guard let data = try? Data(contentsOf: url),
31
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
32
+ else { return nil }
33
+
34
+ for key in ["openai_api_key", "api_key"] {
35
+ guard let value = json[key] as? String,
36
+ let resolved = resolve(value: value, environment: environment)
37
+ else { continue }
38
+ return resolved
39
+ }
40
+ return nil
41
+ }
42
+
43
+ private static func loadSecretKey(homePath: String) -> String? {
44
+ let root = URL(fileURLWithPath: homePath).appendingPathComponent(".secrets")
45
+ let fileManager = FileManager.default
46
+ guard let enumerator = fileManager.enumerator(
47
+ at: root,
48
+ includingPropertiesForKeys: [.isRegularFileKey],
49
+ options: [.skipsHiddenFiles]
50
+ ) else { return nil }
51
+
52
+ for case let url as URL in enumerator {
53
+ guard url.pathExtension == "env",
54
+ let values = try? parseEnvFile(url: url)
55
+ else { continue }
56
+
57
+ if let key = firstNonEmpty(values["RECORDINGS_API_KEY"], values["OPENAI_API_KEY"]) {
58
+ return key
59
+ }
60
+ }
61
+ return nil
62
+ }
63
+
64
+ private static func parseEnvFile(url: URL) throws -> [String: String] {
65
+ let content = try String(contentsOf: url, encoding: .utf8)
66
+ var values: [String: String] = [:]
67
+
68
+ for rawLine in content.split(whereSeparator: \.isNewline) {
69
+ var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
70
+ guard !line.isEmpty, !line.hasPrefix("#") else { continue }
71
+ if line.hasPrefix("export ") {
72
+ line.removeFirst("export ".count)
73
+ }
74
+
75
+ let parts = line.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
76
+ guard parts.count == 2 else { continue }
77
+
78
+ let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
79
+ let value = stripQuotes(String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines))
80
+ values[key] = value
81
+ }
82
+
83
+ return values
84
+ }
85
+
86
+ private static func resolve(value: String, environment: [String: String]) -> String? {
87
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
88
+ guard !trimmed.isEmpty else { return nil }
89
+ if trimmed.hasPrefix("$"), trimmed.count > 1 {
90
+ return firstNonEmpty(environment[String(trimmed.dropFirst())])
91
+ }
92
+ return trimmed
93
+ }
94
+
95
+ private static func stripQuotes(_ value: String) -> String {
96
+ guard value.count >= 2 else { return value }
97
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
98
+ (value.hasPrefix("'") && value.hasSuffix("'")) {
99
+ return String(value.dropFirst().dropLast())
100
+ }
101
+ return value
102
+ }
103
+
104
+ private static func firstNonEmpty(_ values: String?...) -> String? {
105
+ for value in values {
106
+ let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
107
+ if !trimmed.isEmpty {
108
+ return trimmed
109
+ }
110
+ }
111
+ return nil
112
+ }
113
+ }
@@ -0,0 +1,126 @@
1
+ import Foundation
2
+
3
+ public struct RecProject: Codable, Identifiable, Sendable {
4
+ public let id: String
5
+ public var name: String
6
+ var path: String?
7
+ var systemPrompt: String?
8
+ var appBundleIds: [String]?
9
+
10
+ public init(name: String, path: String? = nil, systemPrompt: String? = nil, appBundleIds: [String]? = nil) {
11
+ self.id = UUID().uuidString
12
+ self.name = name
13
+ self.path = path
14
+ self.systemPrompt = systemPrompt
15
+ self.appBundleIds = appBundleIds
16
+ }
17
+ }
18
+
19
+ public struct ProjectSettings: Codable, Sendable {
20
+ var globalSystemPrompt: String
21
+ var projects: [RecProject]
22
+ var activeProjectId: String?
23
+
24
+ public init() {
25
+ globalSystemPrompt = ""
26
+ projects = []
27
+ activeProjectId = nil
28
+ }
29
+ }
30
+
31
+ @MainActor
32
+ public final class ProjectStore: ObservableObject {
33
+ @Published public var settings = ProjectSettings()
34
+
35
+ private let filePath: String
36
+
37
+ public var activeProject: RecProject? {
38
+ settings.projects.first { $0.id == settings.activeProjectId }
39
+ }
40
+
41
+ public var effectiveSystemPrompt: String {
42
+ let global = settings.globalSystemPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
43
+ let project = activeProject?.systemPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
44
+ if global.isEmpty && project.isEmpty { return "" }
45
+ if global.isEmpty { return project }
46
+ if project.isEmpty { return global }
47
+ return "\(global)\n\n\(project)"
48
+ }
49
+
50
+ public init() {
51
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
52
+ filePath = "\(home)/.hasna/recordings/projects.json"
53
+ load()
54
+ }
55
+
56
+ func load() {
57
+ guard FileManager.default.fileExists(atPath: filePath),
58
+ let data = FileManager.default.contents(atPath: filePath) else { return }
59
+ do {
60
+ settings = try JSONDecoder().decode(ProjectSettings.self, from: data)
61
+ } catch {
62
+ fputs("[ProjectStore] Failed to load: \(error)\n", stderr)
63
+ }
64
+ }
65
+
66
+ func save() {
67
+ do {
68
+ let data = try JSONEncoder().encode(settings)
69
+ let dir = (filePath as NSString).deletingLastPathComponent
70
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
71
+ try data.write(to: URL(fileURLWithPath: filePath))
72
+ } catch {
73
+ fputs("[ProjectStore] Failed to save: \(error)\n", stderr)
74
+ }
75
+ }
76
+
77
+ func addProject(name: String, path: String? = nil, systemPrompt: String? = nil) {
78
+ let project = RecProject(name: name, path: path, systemPrompt: systemPrompt)
79
+ settings.projects.append(project)
80
+ save()
81
+ }
82
+
83
+ func updateProject(_ project: RecProject) {
84
+ guard let idx = settings.projects.firstIndex(where: { $0.id == project.id }) else { return }
85
+ settings.projects[idx] = project
86
+ save()
87
+ }
88
+
89
+ func removeProject(id: String) {
90
+ settings.projects.removeAll { $0.id == id }
91
+ if settings.activeProjectId == id { settings.activeProjectId = nil }
92
+ save()
93
+ }
94
+
95
+ func setActive(_ id: String?) {
96
+ settings.activeProjectId = id
97
+ save()
98
+ }
99
+
100
+ // MARK: - Auto-detection
101
+
102
+ nonisolated static func matchProject(windowTitle: String?, bundleId: String?, projects: [RecProject]) -> RecProject? {
103
+ if let bundleId {
104
+ if let match = projects.first(where: { $0.appBundleIds?.contains(bundleId) == true }) {
105
+ return match
106
+ }
107
+ }
108
+
109
+ if let title = windowTitle {
110
+ let lower = title.lowercased()
111
+ for project in projects {
112
+ let name = project.name.lowercased()
113
+ if lower.contains(name) { return project }
114
+ let slug = name.replacingOccurrences(of: " ", with: "-")
115
+ if lower.contains(slug) { return project }
116
+ if let path = project.path {
117
+ let dirName = (path as NSString).lastPathComponent.lowercased()
118
+ if lower.contains(dirName) { return project }
119
+ }
120
+ }
121
+ }
122
+
123
+ return nil
124
+ }
125
+
126
+ }