@hasna/recordings 0.1.24 → 0.1.26

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.24",
5101
+ version: "0.1.26",
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.24";
6084
+ var VERSION = "0.1.26";
6085
6085
 
6086
6086
  // src/cli/storage.ts
6087
6087
  import chalk from "chalk";
@@ -6442,6 +6442,14 @@ function registerStorageCommands(program) {
6442
6442
  });
6443
6443
  }
6444
6444
 
6445
+ // src/cli/options.ts
6446
+ function applyEnhancementOptions(config, opts) {
6447
+ if (opts.enhance === false || opts.noEnhance === false) {
6448
+ config.auto_enhance = false;
6449
+ }
6450
+ return config;
6451
+ }
6452
+
6445
6453
  // src/cli/index.ts
6446
6454
  var program = new Command;
6447
6455
  program.name("recordings").description("Speech-to-text recording tool \u2014 record, transcribe, and enhance with AI").version(VERSION).option("--json", "Output as JSON").option("--agent <name>", "Agent name or ID").option("--project <name>", "Project name or ID").option("--session <id>", "Session ID");
@@ -6451,8 +6459,7 @@ program.command("record").description("Record from microphone, transcribe, and o
6451
6459
  ensureDataDir(config);
6452
6460
  if (opts.language)
6453
6461
  config.language = opts.language;
6454
- if (opts.noEnhance === false)
6455
- config.auto_enhance = false;
6462
+ applyEnhancementOptions(config, opts);
6456
6463
  const deps = await checkRecordingDeps();
6457
6464
  if (!deps.available) {
6458
6465
  console.error(chalk2.red(`Error: ${deps.message}`));
@@ -6518,8 +6525,7 @@ Saved as ${recording.id.slice(0, 8)}`));
6518
6525
  program.command("transcribe <file>").description("Transcribe an existing audio file").option("--no-enhance", "Skip AI enhancement").option("--stream", "Stream transcription deltas while the file is processed").option("-t, --tags <tags>", "Comma-separated tags").option("--prompt <prompt>", "Vocabulary/context prompt for transcription").option("--system-prompt <prompt>", "System prompt for enhancement context").action(async (file, opts) => {
6519
6526
  const config = loadConfig();
6520
6527
  ensureDataDir(config);
6521
- if (opts.noEnhance === false)
6522
- config.auto_enhance = false;
6528
+ applyEnhancementOptions(config, opts);
6523
6529
  const parentOpts = program.opts();
6524
6530
  if (!parentOpts.json) {
6525
6531
  console.log(chalk2.blue("Transcribing..."));
@@ -6883,8 +6889,7 @@ program.command("listen").description("Push-to-talk mode \u2014 press Space to s
6883
6889
  ensureDataDir(config);
6884
6890
  if (opts.language)
6885
6891
  config.language = opts.language;
6886
- if (opts.noEnhance === false)
6887
- config.auto_enhance = false;
6892
+ applyEnhancementOptions(config, opts);
6888
6893
  const deps = await checkRecordingDeps();
6889
6894
  if (!deps.available) {
6890
6895
  console.error(chalk2.red(`Error: ${deps.message}`));
@@ -7388,4 +7393,8 @@ function findPackageRoot() {
7388
7393
  current = parent;
7389
7394
  }
7390
7395
  }
7391
- program.parse();
7396
+ program.parseAsync().catch((error) => {
7397
+ const msg = error instanceof Error ? error.message : String(error);
7398
+ console.error(`ERROR: ${msg}`);
7399
+ process.exit(1);
7400
+ });
@@ -0,0 +1,7 @@
1
+ import type { RecordingsConfig } from "../types/index.js";
2
+ export type EnhancementOptionBag = {
3
+ enhance?: boolean;
4
+ noEnhance?: boolean;
5
+ };
6
+ export declare function applyEnhancementOptions(config: RecordingsConfig, opts: EnhancementOptionBag): RecordingsConfig;
7
+ //# sourceMappingURL=options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../src/cli/options.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAE1D,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,oBAAoB,GACzB,gBAAgB,CAKlB"}
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.24",
4923
+ version: "0.1.26",
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.24";
9800
+ var VERSION = "0.1.26";
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.24";
1
+ export declare const VERSION = "0.1.26";
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.24",
3
+ "version": "0.1.26",
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": {
@@ -12,6 +12,8 @@ final class FnKeyMonitor: @unchecked Sendable {
12
12
  private var healthCheckTimer: Timer?
13
13
  private var fnIsDown = false
14
14
 
15
+ var isRunning: Bool { eventTap != nil }
16
+
15
17
  private static let fnKeyCode: UInt16 = 63
16
18
 
17
19
  /// Start monitoring. Returns true if successful.
@@ -44,7 +44,7 @@ public struct TranscriptionResult: Sendable {
44
44
  var displayText: String { processedText ?? rawText }
45
45
  }
46
46
 
47
- public enum RecordingTrigger {
47
+ public enum RecordingTrigger: Equatable, Sendable {
48
48
  case manual
49
49
  case fnKey
50
50
  case keyboardShortcut
@@ -121,6 +121,7 @@ public final class RecordingEngine: ObservableObject {
121
121
  private var recordingTimer: Timer?
122
122
  private var activeTrigger: RecordingTrigger?
123
123
  private var keyboardShortcutIsDown = false
124
+ private var fnKeyIsDown = false
124
125
  private var targetAppBundleIdentifier: String?
125
126
  private var targetAppPid: pid_t?
126
127
  public var projectStore: ProjectStore?
@@ -137,6 +138,7 @@ public final class RecordingEngine: ObservableObject {
137
138
 
138
139
  // fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
139
140
  private let fnMonitor = FnKeyMonitor()
141
+ private var permissionRetryTimer: Timer?
140
142
 
141
143
  let home = FileManager.default.homeDirectoryForCurrentUser.path
142
144
  private var audioDir: String { "\(home)/.hasna/recordings/audio" }
@@ -164,13 +166,23 @@ public final class RecordingEngine: ObservableObject {
164
166
  // Set up fn key monitor — hold fn to record, release to stop (like WisprFlow)
165
167
  fnMonitor.onFnKeyDown = { [weak self] in
166
168
  Task { @MainActor [weak self] in
167
- guard let self, self.useFnKey, !self.isRecording else { return }
169
+ guard let self else { return }
170
+ self.fnKeyIsDown = true
171
+ guard self.useFnKey, Self.canBeginRecording(isRecording: self.isRecording, isTranscribing: self.isTranscribing) else { return }
168
172
  self.startRecording(trigger: .fnKey)
169
173
  }
170
174
  }
171
175
  fnMonitor.onFnKeyUp = { [weak self] in
172
176
  Task { @MainActor [weak self] in
173
- guard let self, self.useFnKey, self.isRecording, self.activeTrigger == .fnKey else { return }
177
+ guard let self else { return }
178
+ self.fnKeyIsDown = false
179
+ guard self.useFnKey, self.activeTrigger == .fnKey else { return }
180
+ guard self.isRecording else {
181
+ self.log("fn released before recording started; cancelling pending start")
182
+ self.resetRecordingIntent()
183
+ self.updateStatus()
184
+ return
185
+ }
174
186
  self.stopAndTranscribe()
175
187
  }
176
188
  }
@@ -180,7 +192,7 @@ public final class RecordingEngine: ObservableObject {
180
192
  Task { @MainActor [weak self] in
181
193
  guard let self, !self.keyboardShortcutIsDown else { return }
182
194
  self.keyboardShortcutIsDown = true
183
- guard !self.isRecording else { return }
195
+ guard Self.canBeginRecording(isRecording: self.isRecording, isTranscribing: self.isTranscribing) else { return }
184
196
  self.startRecording(trigger: .keyboardShortcut)
185
197
  }
186
198
  }
@@ -188,11 +200,27 @@ public final class RecordingEngine: ObservableObject {
188
200
  Task { @MainActor [weak self] in
189
201
  guard let self, self.keyboardShortcutIsDown else { return }
190
202
  self.keyboardShortcutIsDown = false
191
- guard self.isRecording, self.activeTrigger == .keyboardShortcut else { return }
203
+ guard self.activeTrigger == .keyboardShortcut else { return }
204
+ guard self.isRecording else {
205
+ self.log("shortcut released before recording started; cancelling pending start")
206
+ self.resetRecordingIntent()
207
+ self.updateStatus()
208
+ return
209
+ }
192
210
  self.stopAndTranscribe()
193
211
  }
194
212
  }
195
213
 
214
+ // Granting Accessibility does not revive a tap that failed to create,
215
+ // so retry until permissions arrive instead of requiring a relaunch.
216
+ permissionRetryTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
217
+ Task { @MainActor [weak self] in
218
+ guard let self, self.useFnKey, !self.fnMonitor.isRunning, AXIsProcessTrusted() else { return }
219
+ self.log("accessibility granted — retrying fn monitor")
220
+ self.updateFnMonitor()
221
+ }
222
+ }
223
+
196
224
  updateStatus()
197
225
  }
198
226
 
@@ -278,7 +306,12 @@ public final class RecordingEngine: ObservableObject {
278
306
  // MARK: - Start Recording (Streaming)
279
307
 
280
308
  public func startRecording(trigger: RecordingTrigger = .manual) {
281
- guard !isRecording else { return }
309
+ guard Self.canBeginRecording(isRecording: isRecording, isTranscribing: isTranscribing) else {
310
+ if isTranscribing {
311
+ statusMessage = "Finish transcribing before recording again"
312
+ }
313
+ return
314
+ }
282
315
  log("startRecording trigger=\(trigger) microphoneStatus=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue) accessibility=\(AXIsProcessTrusted())")
283
316
  activeTrigger = trigger
284
317
  keyboardShortcutIsDown = trigger == .keyboardShortcut
@@ -309,6 +342,12 @@ public final class RecordingEngine: ObservableObject {
309
342
  guard let self else { return }
310
343
  self.log("microphone access response granted=\(granted)")
311
344
  if granted {
345
+ guard self.shouldContinueStarting(trigger: trigger) else {
346
+ self.log("recording start cancelled before microphone permission completed trigger=\(trigger)")
347
+ self.resetRecordingIntent()
348
+ self.updateStatus()
349
+ return
350
+ }
312
351
  self.startNativeRecording()
313
352
  } else {
314
353
  self.resetRecordingIntent()
@@ -326,6 +365,33 @@ public final class RecordingEngine: ObservableObject {
326
365
  }
327
366
  }
328
367
 
368
+ nonisolated static func canBeginRecording(isRecording: Bool, isTranscribing: Bool) -> Bool {
369
+ !isRecording && !isTranscribing
370
+ }
371
+
372
+ nonisolated static func shouldContinueStartingAfterPermission(
373
+ trigger: RecordingTrigger,
374
+ keyboardShortcutIsDown: Bool,
375
+ fnKeyIsDown: Bool
376
+ ) -> Bool {
377
+ switch trigger {
378
+ case .manual:
379
+ return true
380
+ case .keyboardShortcut:
381
+ return keyboardShortcutIsDown
382
+ case .fnKey:
383
+ return fnKeyIsDown
384
+ }
385
+ }
386
+
387
+ private func shouldContinueStarting(trigger: RecordingTrigger) -> Bool {
388
+ activeTrigger == trigger && Self.shouldContinueStartingAfterPermission(
389
+ trigger: trigger,
390
+ keyboardShortcutIsDown: keyboardShortcutIsDown,
391
+ fnKeyIsDown: fnKeyIsDown
392
+ )
393
+ }
394
+
329
395
  private func startNativeRecording() {
330
396
  let streamState = PCMStreamState()
331
397
  pcmStreamState = streamState
@@ -656,6 +722,7 @@ public final class RecordingEngine: ObservableObject {
656
722
  private func resetRecordingIntent() {
657
723
  activeTrigger = nil
658
724
  keyboardShortcutIsDown = false
725
+ fnKeyIsDown = false
659
726
  targetAppBundleIdentifier = nil
660
727
  targetAppPid = nil
661
728
  }
@@ -0,0 +1,48 @@
1
+ import Testing
2
+ @testable import RecordingsLib
3
+
4
+ struct RecordingStartGateTests {
5
+ @Test("recording cannot begin while already recording or transcribing")
6
+ func cannotBeginWhenBusy() {
7
+ #expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: false) == true)
8
+ #expect(RecordingEngine.canBeginRecording(isRecording: true, isTranscribing: false) == false)
9
+ #expect(RecordingEngine.canBeginRecording(isRecording: false, isTranscribing: true) == false)
10
+ }
11
+
12
+ @Test("manual recording can continue after permission grant")
13
+ func manualPermissionContinuation() {
14
+ #expect(RecordingEngine.shouldContinueStartingAfterPermission(
15
+ trigger: .manual,
16
+ keyboardShortcutIsDown: false,
17
+ fnKeyIsDown: false
18
+ ))
19
+ }
20
+
21
+ @Test("keyboard shortcut recording is cancelled when released before permission grant")
22
+ func keyboardPermissionContinuationRequiresHeldKey() {
23
+ #expect(RecordingEngine.shouldContinueStartingAfterPermission(
24
+ trigger: .keyboardShortcut,
25
+ keyboardShortcutIsDown: true,
26
+ fnKeyIsDown: false
27
+ ))
28
+ #expect(RecordingEngine.shouldContinueStartingAfterPermission(
29
+ trigger: .keyboardShortcut,
30
+ keyboardShortcutIsDown: false,
31
+ fnKeyIsDown: false
32
+ ) == false)
33
+ }
34
+
35
+ @Test("fn recording is cancelled when released before permission grant")
36
+ func fnPermissionContinuationRequiresHeldKey() {
37
+ #expect(RecordingEngine.shouldContinueStartingAfterPermission(
38
+ trigger: .fnKey,
39
+ keyboardShortcutIsDown: false,
40
+ fnKeyIsDown: true
41
+ ))
42
+ #expect(RecordingEngine.shouldContinueStartingAfterPermission(
43
+ trigger: .fnKey,
44
+ keyboardShortcutIsDown: false,
45
+ fnKeyIsDown: false
46
+ ) == false)
47
+ }
48
+ }