@hasna/recordings 0.2.11 → 0.2.14

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 (167) hide show
  1. package/README.md +349 -69
  2. package/bun.lock +3 -0
  3. package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
  4. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
  5. package/dist/cli/index.js +1728 -320
  6. package/dist/cli/macos-permissions.d.ts +13 -0
  7. package/dist/cli/macos-permissions.d.ts.map +1 -0
  8. package/dist/cli/options.d.ts +12 -0
  9. package/dist/cli/options.d.ts.map +1 -1
  10. package/dist/db/pg-migrations.d.ts.map +1 -1
  11. package/dist/db/recordings.d.ts +2 -1
  12. package/dist/db/recordings.d.ts.map +1 -1
  13. package/dist/db/remote-storage.d.ts +5 -1
  14. package/dist/db/remote-storage.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +628 -147
  18. package/dist/lib/bun-runtime.d.ts +11 -0
  19. package/dist/lib/bun-runtime.d.ts.map +1 -0
  20. package/dist/lib/config.d.ts +8 -1
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/enhancer.d.ts +10 -2
  23. package/dist/lib/enhancer.d.ts.map +1 -1
  24. package/dist/lib/install-maintenance.d.ts +24 -0
  25. package/dist/lib/install-maintenance.d.ts.map +1 -0
  26. package/dist/lib/machine.d.ts +2 -0
  27. package/dist/lib/machine.d.ts.map +1 -0
  28. package/dist/lib/recorder.d.ts +2 -1
  29. package/dist/lib/recorder.d.ts.map +1 -1
  30. package/dist/lib/recording-create-identity.d.ts +13 -0
  31. package/dist/lib/recording-create-identity.d.ts.map +1 -0
  32. package/dist/lib/release-install-policy.d.ts +31 -0
  33. package/dist/lib/release-install-policy.d.ts.map +1 -0
  34. package/dist/lib/transcriber.d.ts.map +1 -1
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +977 -194
  37. package/dist/sdk/index.js +5 -1
  38. package/dist/sdk/v1.generated.d.ts +4 -0
  39. package/dist/sdk/v1.generated.d.ts.map +1 -1
  40. package/dist/server/cloud-config.d.ts +9 -0
  41. package/dist/server/cloud-config.d.ts.map +1 -0
  42. package/dist/server/cloud-readiness.d.ts +10 -0
  43. package/dist/server/cloud-readiness.d.ts.map +1 -0
  44. package/dist/server/cloud.d.ts +9 -18
  45. package/dist/server/cloud.d.ts.map +1 -1
  46. package/dist/server/index.js +1654 -304
  47. package/dist/server/migrate-command.d.ts +11 -0
  48. package/dist/server/migrate-command.d.ts.map +1 -0
  49. package/dist/server/openapi.d.ts +22 -0
  50. package/dist/server/openapi.d.ts.map +1 -1
  51. package/dist/server/repo.d.ts +8 -1
  52. package/dist/server/repo.d.ts.map +1 -1
  53. package/dist/server/serve.d.ts +6 -1
  54. package/dist/server/serve.d.ts.map +1 -1
  55. package/dist/server/v1.d.ts.map +1 -1
  56. package/dist/storage.js +515 -80
  57. package/dist/store.d.ts +3 -1
  58. package/dist/store.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +10 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/package.json +7 -4
  63. package/packaging/macos/Empty.entitlements +5 -0
  64. package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
  65. package/packaging/macos/Verifier.entitlements +10 -0
  66. package/packaging/macos/artifact-verifier.sb +33 -0
  67. package/packaging/macos/build_release_pkg.sh +872 -0
  68. package/packaging/macos/managed_bootstrap.sh +623 -0
  69. package/packaging/macos/pkgutil_fingerprint.awk +37 -0
  70. package/packaging/macos/release_lifecycle.ts +463 -0
  71. package/packaging/macos/scripts/postinstall +565 -0
  72. package/packaging/macos/scripts/preinstall +297 -0
  73. package/scripts/build_companion_cli.sh +337 -0
  74. package/scripts/build_native_fs_guard.sh +72 -0
  75. package/scripts/generate-sdk.ts +19 -1
  76. package/scripts/install_macos_app.sh +1919 -98
  77. package/scripts/macos_artifact.ts +5316 -0
  78. package/scripts/migrate.ts +38 -10
  79. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  80. package/scripts/native/recordings_fs_guard.c +1167 -0
  81. package/scripts/native_fs_guard.ts +158 -0
  82. package/scripts/release-guard.ts +20 -1
  83. package/scripts/resolve_tailscale_cli.sh +389 -0
  84. package/scripts/smoke_macos_app.sh +573 -0
  85. package/src/native/Recordings/App/ContentView.swift +83 -0
  86. package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
  87. package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
  88. package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
  89. package/src/native/Recordings/App/RecordingsApp.swift +367 -44
  90. package/src/native/Recordings/App/RecordingsListView.swift +130 -0
  91. package/src/native/Recordings/App/RecordingsStore.swift +206 -0
  92. package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
  93. package/src/native/Recordings/App/SidebarView.swift +212 -0
  94. package/src/native/Recordings/App/Theme.swift +87 -0
  95. package/src/native/Recordings/Package.resolved +5 -5
  96. package/src/native/Recordings/Package.swift +62 -2
  97. package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
  98. package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
  99. package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
  101. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
  102. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
  103. package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
  104. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
  105. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
  106. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
  107. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
  108. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
  109. package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
  110. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
  111. package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
  112. package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
  113. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
  114. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
  115. package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
  116. package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
  117. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
  118. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
  119. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
  120. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
  121. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
  122. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
  123. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
  124. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
  125. package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
  126. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
  127. package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
  128. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
  129. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
  130. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
  131. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
  132. package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
  133. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
  134. package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
  135. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
  136. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
  137. package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
  138. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
  139. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
  140. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
  141. package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
  142. package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
  143. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
  144. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
  145. package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
  146. package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
  147. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
  148. package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
  149. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
  150. package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
  151. package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
  152. package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
  153. package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
  154. package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
  155. package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
  156. package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
  157. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
  158. package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
  159. package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
  160. package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
  161. package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
  162. package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
  163. package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
  164. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
  165. package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
  166. package/src/native/Recordings/build.sh +1973 -236
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
package/dist/index.js CHANGED
@@ -30,8 +30,12 @@ class RecordingsV1Client {
30
30
  const url = new URL(this.baseUrl + path);
31
31
  if (opts.query) {
32
32
  for (const [key, value] of Object.entries(opts.query)) {
33
- if (value !== undefined && value !== null)
33
+ if (Array.isArray(value)) {
34
+ for (const item of value)
35
+ url.searchParams.append(key, String(item));
36
+ } else if (value !== undefined && value !== null) {
34
37
  url.searchParams.set(key, String(value));
38
+ }
35
39
  }
36
40
  }
37
41
  const headers = { Accept: "application/json", ...this.baseHeaders, ...opts.init?.headers };
@@ -194,23 +198,200 @@ class SqliteAdapter {
194
198
  }
195
199
 
196
200
  // src/db/database.ts
197
- import { mkdirSync as mkdirSync2 } from "fs";
198
- import { dirname as dirname2 } from "path";
201
+ import { mkdirSync as mkdirSync3 } from "fs";
202
+ import { dirname as dirname3 } from "path";
199
203
 
200
204
  // src/lib/config.ts
201
- import { copyFileSync, existsSync, readFileSync, mkdirSync, readdirSync, statSync } from "fs";
202
- import { dirname, join, resolve } from "path";
205
+ import { copyFileSync, existsSync, readFileSync, mkdirSync as mkdirSync2, readdirSync, realpathSync as realpathSync2, statSync } from "fs";
206
+ import { dirname as dirname2, join as join2, resolve as resolve2, sep as sep2 } from "path";
207
+ import { homedir as homedir2 } from "os";
208
+
209
+ // src/lib/install-maintenance.ts
210
+ import { randomUUID } from "crypto";
211
+ import {
212
+ chmodSync,
213
+ lstatSync,
214
+ mkdirSync,
215
+ realpathSync,
216
+ renameSync,
217
+ rmSync,
218
+ writeFileSync
219
+ } from "fs";
203
220
  import { homedir } from "os";
221
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
222
+ import { spawnSync } from "child_process";
223
+ var INSTALL_MAINTENANCE_MARKER_NAME = ".recordings-install-maintenance";
224
+ var STORE_READER_LEASES_NAME = ".recordings-store-readers";
225
+ function stateParent(env = process.env) {
226
+ const home = env["HOME"] || env["USERPROFILE"] || homedir();
227
+ return join(home, ".hasna");
228
+ }
229
+ function installMaintenanceMarkerPath(env = process.env) {
230
+ return join(stateParent(env), INSTALL_MAINTENANCE_MARKER_NAME);
231
+ }
232
+ function storeReaderLeasesPath(env = process.env) {
233
+ return join(stateParent(env), STORE_READER_LEASES_NAME);
234
+ }
235
+ function pathExists(path) {
236
+ try {
237
+ lstatSync(path);
238
+ return true;
239
+ } catch (error) {
240
+ if (error.code === "ENOENT")
241
+ return false;
242
+ throw error;
243
+ }
244
+ }
245
+ function assertPrivateDirectory(path) {
246
+ const details = lstatSync(path);
247
+ if (!details.isDirectory() || details.isSymbolicLink()) {
248
+ throw new Error(`Local Recordings coordination path is not a secure directory: ${path}`);
249
+ }
250
+ if (typeof process.getuid === "function" && details.uid !== process.getuid()) {
251
+ throw new Error(`Local Recordings coordination path has an unexpected owner: ${path}`);
252
+ }
253
+ if ((details.mode & 18) !== 0) {
254
+ throw new Error(`Local Recordings coordination path is group/world writable: ${path}`);
255
+ }
256
+ }
257
+ function ensureReaderRoot(env) {
258
+ const parent = stateParent(env);
259
+ const readerRoot = storeReaderLeasesPath(env);
260
+ mkdirSync(parent, { recursive: true, mode: 448 });
261
+ assertPrivateDirectory(parent);
262
+ try {
263
+ mkdirSync(readerRoot, { mode: 448 });
264
+ } catch (error) {
265
+ if (error.code !== "EEXIST")
266
+ throw error;
267
+ }
268
+ assertPrivateDirectory(readerRoot);
269
+ return readerRoot;
270
+ }
271
+ function processStartIdentity() {
272
+ const result = spawnSync("/bin/ps", ["-o", "lstart=", "-p", String(process.pid)], {
273
+ encoding: "utf8",
274
+ env: { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC0" }
275
+ });
276
+ const identity = result.status === 0 ? result.stdout.trim().replace(/\s+/g, " ") : "";
277
+ if (!identity) {
278
+ throw new Error("Could not establish the local Store reader process identity");
279
+ }
280
+ return identity;
281
+ }
282
+ function maintenanceUnavailable() {
283
+ return new Error("Local Recordings storage is temporarily unavailable during app installation maintenance");
284
+ }
285
+ function assertMaintenanceAbsent(env) {
286
+ if (pathExists(installMaintenanceMarkerPath(env)))
287
+ throw maintenanceUnavailable();
288
+ }
289
+ function canonicalPotentialPath(path) {
290
+ const absolutePath = resolve(path);
291
+ let existingPath = absolutePath;
292
+ const missingEntries = [];
293
+ while (true) {
294
+ try {
295
+ return join(realpathSync(existingPath), ...missingEntries);
296
+ } catch (error) {
297
+ if (error.code !== "ENOENT")
298
+ return absolutePath;
299
+ const parent = dirname(existingPath);
300
+ if (parent === existingPath)
301
+ return absolutePath;
302
+ missingEntries.unshift(basename(existingPath));
303
+ existingPath = parent;
304
+ }
305
+ }
306
+ }
307
+ function pathIsWithin(root, candidate) {
308
+ const relativePath = relative(root, candidate);
309
+ return relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
310
+ }
311
+ function isGlobalRecordingsStatePath(path, env = process.env) {
312
+ const globalStateRoot = join(stateParent(env), "recordings");
313
+ return pathIsWithin(resolve(globalStateRoot), resolve(path)) || pathIsWithin(canonicalPotentialPath(globalStateRoot), canonicalPotentialPath(path));
314
+ }
315
+ function removeActiveLease(activeLease, releasedLease) {
316
+ try {
317
+ renameSync(activeLease, releasedLease);
318
+ } catch (error) {
319
+ if (error.code !== "ENOENT")
320
+ throw error;
321
+ }
322
+ rmSync(releasedLease, { recursive: true, force: true });
323
+ }
324
+ function acquireLocalStoreReaderLease(env = process.env) {
325
+ assertMaintenanceAbsent(env);
326
+ const readerRoot = ensureReaderRoot(env);
327
+ const nonce = randomUUID();
328
+ const pendingLease = join(readerRoot, `.pending-${process.pid}-${nonce}`);
329
+ const activeLease = join(readerRoot, `lease-${process.pid}-${nonce}`);
330
+ const releasedLease = join(readerRoot, `.released-${process.pid}-${nonce}`);
331
+ let active = false;
332
+ try {
333
+ mkdirSync(pendingLease, { mode: 448 });
334
+ const owner = join(pendingLease, "owner");
335
+ writeFileSync(owner, `${process.pid}
336
+ ${processStartIdentity()}
337
+ `, {
338
+ encoding: "utf8",
339
+ flag: "wx",
340
+ mode: 384
341
+ });
342
+ chmodSync(owner, 384);
343
+ renameSync(pendingLease, activeLease);
344
+ active = true;
345
+ assertMaintenanceAbsent(env);
346
+ } catch (error) {
347
+ if (active)
348
+ removeActiveLease(activeLease, releasedLease);
349
+ else
350
+ rmSync(pendingLease, { recursive: true, force: true });
351
+ throw error;
352
+ }
353
+ let released = false;
354
+ return () => {
355
+ if (released)
356
+ return;
357
+ removeActiveLease(activeLease, releasedLease);
358
+ released = true;
359
+ };
360
+ }
361
+ async function withLocalStoreReaderLease(operation, env = process.env) {
362
+ const release = acquireLocalStoreReaderLease(env);
363
+ try {
364
+ return await operation();
365
+ } finally {
366
+ release();
367
+ }
368
+ }
369
+
370
+ // src/lib/config.ts
371
+ var POST_PROCESSING_MODES = new Set([
372
+ "off",
373
+ "auto",
374
+ "always"
375
+ ]);
376
+ var DEFAULT_TRANSCRIPTION_MODEL = "gpt-4o-transcribe";
377
+ var DEFAULT_REALTIME_SESSION_MODEL = "gpt-realtime";
378
+ var DEFAULT_REALTIME_TRANSCRIPTION_MODEL = "gpt-realtime-whisper";
204
379
  var DEFAULT_CONFIG = {
205
380
  openai_api_key: "",
206
381
  enhancement_api_key: "",
207
- transcription_model: "gpt-4o-transcribe",
382
+ transcription_model: DEFAULT_TRANSCRIPTION_MODEL,
383
+ realtime_session_model: DEFAULT_REALTIME_SESSION_MODEL,
384
+ realtime_transcription_model: DEFAULT_REALTIME_TRANSCRIPTION_MODEL,
208
385
  enhancement_model: "gpt-4o",
386
+ transcriber_model: "gpt-4o",
209
387
  language: "en",
210
388
  audio_format: "wav",
211
389
  sample_rate: 16000,
212
390
  record_command: "sox",
213
391
  hotkey: "space",
392
+ transcription_prompt: "",
393
+ transcriber_prompt: "",
394
+ post_processing_mode: "auto",
214
395
  auto_enhance: true,
215
396
  enhance_triggers: [
216
397
  "say it better",
@@ -227,17 +408,23 @@ var DEFAULT_CONFIG = {
227
408
  keyword_transforms: {},
228
409
  db_path: "",
229
410
  audio_dir: "",
230
- max_recording_seconds: 1800
411
+ max_recording_seconds: 1800,
412
+ config_warnings: []
231
413
  };
232
414
  function loadConfig(configPath) {
233
415
  const config = { ...DEFAULT_CONFIG };
234
- const filePath = configPath || findConfigFile() || join(getDataDir(), "config.json");
416
+ config.config_warnings = [];
417
+ let explicitPostProcessingMode = false;
418
+ let explicitTranscriberModel = false;
419
+ const filePath = configPath || findConfigFile() || join2(getDataDir(), "config.json");
235
420
  let fileProvidedOpenAIKey = false;
236
421
  if (existsSync(filePath)) {
237
422
  try {
238
423
  const raw = readFileSync(filePath, "utf-8");
239
424
  const fileConfig = JSON.parse(raw);
240
425
  const expanded = expandEnvBackedConfig(fileConfig);
426
+ explicitPostProcessingMode = typeof fileConfig.post_processing_mode === "string";
427
+ explicitTranscriberModel = typeof fileConfig.transcriber_model === "string";
241
428
  Object.assign(config, expanded);
242
429
  fileProvidedOpenAIKey = typeof expanded.openai_api_key === "string" && expanded.openai_api_key.length > 0;
243
430
  } catch {}
@@ -254,12 +441,35 @@ function loadConfig(configPath) {
254
441
  if (process.env.RECORDINGS_MODEL) {
255
442
  config.transcription_model = process.env.RECORDINGS_MODEL;
256
443
  }
444
+ if (process.env.RECORDINGS_REALTIME_SESSION_MODEL) {
445
+ config.realtime_session_model = process.env.RECORDINGS_REALTIME_SESSION_MODEL;
446
+ }
447
+ if (process.env.RECORDINGS_REALTIME_TRANSCRIPTION_MODEL) {
448
+ config.realtime_transcription_model = process.env.RECORDINGS_REALTIME_TRANSCRIPTION_MODEL;
449
+ }
257
450
  if (process.env.RECORDINGS_ENHANCEMENT_MODEL) {
258
451
  config.enhancement_model = process.env.RECORDINGS_ENHANCEMENT_MODEL;
259
452
  }
453
+ if (process.env.RECORDINGS_TRANSCRIBER_MODEL) {
454
+ config.transcriber_model = process.env.RECORDINGS_TRANSCRIBER_MODEL;
455
+ explicitTranscriberModel = true;
456
+ }
260
457
  if (process.env.RECORDINGS_LANGUAGE) {
261
458
  config.language = process.env.RECORDINGS_LANGUAGE;
262
459
  }
460
+ if (process.env.RECORDINGS_TRANSCRIPTION_PROMPT) {
461
+ config.transcription_prompt = process.env.RECORDINGS_TRANSCRIPTION_PROMPT;
462
+ }
463
+ if (process.env.RECORDINGS_TRANSCRIBER_PROMPT) {
464
+ config.transcriber_prompt = process.env.RECORDINGS_TRANSCRIBER_PROMPT;
465
+ }
466
+ if (process.env.RECORDINGS_POST_PROCESSING_MODE) {
467
+ config.post_processing_mode = normalizePostProcessingMode(process.env.RECORDINGS_POST_PROCESSING_MODE, config.post_processing_mode ?? "auto");
468
+ explicitPostProcessingMode = true;
469
+ }
470
+ if (process.env.RECORDINGS_AUTO_ENHANCE) {
471
+ config.auto_enhance = parseBooleanEnv(process.env.RECORDINGS_AUTO_ENHANCE, config.auto_enhance);
472
+ }
263
473
  if (process.env.HASNA_RECORDINGS_DB_PATH) {
264
474
  config.db_path = process.env.HASNA_RECORDINGS_DB_PATH;
265
475
  } else if (process.env.RECORDINGS_DB_PATH) {
@@ -277,14 +487,86 @@ function loadConfig(configPath) {
277
487
  if (!config.enhancement_api_key) {
278
488
  config.enhancement_api_key = config.openai_api_key || loadSecretKey("OPENAI_API_KEY");
279
489
  }
490
+ if (!explicitTranscriberModel) {
491
+ config.transcriber_model = config.enhancement_model;
492
+ }
493
+ normalizeModelSlots(config);
494
+ normalizePostProcessingConfig(config, explicitPostProcessingMode);
280
495
  if (!config.db_path) {
281
- config.db_path = join(getDataDir(), "recordings.db");
496
+ config.db_path = join2(getDataDir(), "recordings.db");
282
497
  }
283
498
  if (!config.audio_dir) {
284
- config.audio_dir = join(getDataDir(), "audio");
499
+ config.audio_dir = join2(getDataDir(), "audio");
500
+ }
501
+ return config;
502
+ }
503
+ function normalizeModelSlots(config) {
504
+ const warnings = config.config_warnings ?? [];
505
+ config.config_warnings = warnings;
506
+ const boundedModel = config.transcription_model?.trim() || DEFAULT_TRANSCRIPTION_MODEL;
507
+ if (isRealtimeOnlyModel(boundedModel)) {
508
+ warnings.push(`Ignoring RECORDINGS_MODEL=${boundedModel}; bounded transcription uses ${DEFAULT_TRANSCRIPTION_MODEL}.`);
509
+ config.transcription_model = DEFAULT_TRANSCRIPTION_MODEL;
510
+ } else {
511
+ config.transcription_model = boundedModel;
512
+ }
513
+ const realtimeSessionModel = config.realtime_session_model?.trim() || DEFAULT_REALTIME_SESSION_MODEL;
514
+ if (isTranscriptionOnlyModel(realtimeSessionModel)) {
515
+ warnings.push(`Ignoring realtime session model ${realtimeSessionModel}; use ${DEFAULT_REALTIME_TRANSCRIPTION_MODEL} as realtime_transcription_model instead.`);
516
+ config.realtime_session_model = DEFAULT_REALTIME_SESSION_MODEL;
517
+ } else {
518
+ config.realtime_session_model = realtimeSessionModel;
519
+ }
520
+ const realtimeTranscriptionModel = config.realtime_transcription_model?.trim() || DEFAULT_REALTIME_TRANSCRIPTION_MODEL;
521
+ if (!isRealtimeTranscriptionModel(realtimeTranscriptionModel)) {
522
+ warnings.push(`Ignoring realtime transcription model ${realtimeTranscriptionModel}; realtime transcription uses ${DEFAULT_REALTIME_TRANSCRIPTION_MODEL}.`);
523
+ config.realtime_transcription_model = DEFAULT_REALTIME_TRANSCRIPTION_MODEL;
524
+ } else {
525
+ config.realtime_transcription_model = realtimeTranscriptionModel;
526
+ }
527
+ return config;
528
+ }
529
+ function isTranscriptionOnlyModel(model) {
530
+ const m = model.trim().toLowerCase();
531
+ return m === "whisper-1" || m === DEFAULT_REALTIME_TRANSCRIPTION_MODEL || m.includes("transcribe");
532
+ }
533
+ function isRealtimeOnlyModel(model) {
534
+ const m = model.trim().toLowerCase();
535
+ return m.startsWith("gpt-realtime");
536
+ }
537
+ function isRealtimeTranscriptionModel(model) {
538
+ const m = model.trim().toLowerCase();
539
+ return m === DEFAULT_REALTIME_TRANSCRIPTION_MODEL || m.startsWith("gpt-realtime") && m.includes("whisper");
540
+ }
541
+ function normalizePostProcessingMode(value, fallback = "auto") {
542
+ const mode = value?.trim().toLowerCase();
543
+ if (mode && POST_PROCESSING_MODES.has(mode)) {
544
+ return mode;
545
+ }
546
+ return fallback;
547
+ }
548
+ function normalizePostProcessingConfig(config, preferPostProcessingMode = true) {
549
+ if (preferPostProcessingMode) {
550
+ config.post_processing_mode = normalizePostProcessingMode(config.post_processing_mode, "auto");
551
+ config.auto_enhance = config.post_processing_mode !== "off";
552
+ return config;
553
+ }
554
+ if (config.auto_enhance === false) {
555
+ config.post_processing_mode = "off";
556
+ } else {
557
+ config.post_processing_mode = normalizePostProcessingMode(config.post_processing_mode, "auto");
285
558
  }
559
+ config.auto_enhance = config.post_processing_mode !== "off";
286
560
  return config;
287
561
  }
562
+ function parseBooleanEnv(value, fallback) {
563
+ const normalized = value.trim().toLowerCase();
564
+ if (["1", "true", "yes", "on"].includes(normalized))
565
+ return true;
566
+ if (["0", "false", "no", "off"].includes(normalized))
567
+ return false;
568
+ return fallback;
569
+ }
288
570
  function expandEnvBackedConfig(config) {
289
571
  const expanded = { ...config };
290
572
  for (const key of ["openai_api_key", "enhancement_api_key"]) {
@@ -303,36 +585,68 @@ function getDataDir() {
303
585
  if (projectLocalDir)
304
586
  return projectLocalDir;
305
587
  const home = getHomeDir();
306
- const newDir = join(home, ".hasna", "recordings");
307
- const oldDir = join(home, ".recordings");
588
+ const newDir = join2(home, ".hasna", "recordings");
589
+ const oldDir = join2(home, ".recordings");
308
590
  if (existsSync(oldDir)) {
591
+ const releaseLease = acquireLocalStoreReaderLease();
309
592
  try {
310
- mergeDirectoryContents(oldDir, newDir);
311
- } catch {}
593
+ try {
594
+ if (existsSync(oldDir))
595
+ mergeDirectoryContents(oldDir, newDir);
596
+ } catch {}
597
+ } finally {
598
+ releaseLease();
599
+ }
312
600
  }
313
601
  return newDir;
314
602
  }
315
603
  function findProjectRecordingsPath(entry) {
316
- const home = resolve(getHomeDir());
317
- let dir = resolve(process.cwd());
604
+ const cwd = canonicalExistingPath(process.cwd());
605
+ const home = canonicalExistingPath(getHomeDir());
606
+ const repositoryRoot = findRepositoryRoot(cwd);
607
+ const cwdIsInsideHome = cwd === home || cwd.startsWith(`${home}${sep2}`);
608
+ const repositoryIsInsideHome = repositoryRoot !== null && repositoryRoot !== home && repositoryRoot.startsWith(`${home}${sep2}`);
609
+ const boundary = cwdIsInsideHome ? repositoryIsInsideHome ? repositoryRoot : home : repositoryRoot ?? cwd;
610
+ const excludeBoundary = boundary === home;
611
+ let dir = cwd;
318
612
  while (true) {
319
- if (dir !== home) {
320
- const candidate = entry ? join(dir, ".recordings", entry) : join(dir, ".recordings");
321
- if (existsSync(candidate))
322
- return candidate;
323
- }
324
- const parent = dirname(dir);
613
+ if (excludeBoundary && dir === boundary)
614
+ break;
615
+ const candidate = entry ? join2(dir, ".recordings", entry) : join2(dir, ".recordings");
616
+ if (existsSync(candidate))
617
+ return candidate;
618
+ if (dir === boundary)
619
+ break;
620
+ const parent = dirname2(dir);
325
621
  if (parent === dir)
326
622
  break;
327
623
  dir = parent;
328
624
  }
329
625
  return null;
330
626
  }
627
+ function findRepositoryRoot(start) {
628
+ let dir = start;
629
+ while (true) {
630
+ if (existsSync(join2(dir, ".git")))
631
+ return dir;
632
+ const parent = dirname2(dir);
633
+ if (parent === dir)
634
+ return null;
635
+ dir = parent;
636
+ }
637
+ }
638
+ function canonicalExistingPath(path) {
639
+ try {
640
+ return realpathSync2(path);
641
+ } catch {
642
+ return resolve2(path);
643
+ }
644
+ }
331
645
  function mergeDirectoryContents(sourceDir, targetDir) {
332
- mkdirSync(targetDir, { recursive: true });
646
+ mkdirSync2(targetDir, { recursive: true });
333
647
  for (const entry of readdirSync(sourceDir)) {
334
- const sourcePath = join(sourceDir, entry);
335
- const targetPath = join(targetDir, entry);
648
+ const sourcePath = join2(sourceDir, entry);
649
+ const targetPath = join2(targetDir, entry);
336
650
  const sourceStats = statSync(sourcePath);
337
651
  if (sourceStats.isDirectory()) {
338
652
  mergeDirectoryContents(sourcePath, targetPath);
@@ -342,7 +656,7 @@ function mergeDirectoryContents(sourceDir, targetDir) {
342
656
  }
343
657
  }
344
658
  function loadSecretKey(keyName) {
345
- const secretsPath = join(getHomeDir(), ".secrets");
659
+ const secretsPath = join2(getHomeDir(), ".secrets");
346
660
  if (!existsSync(secretsPath))
347
661
  return "";
348
662
  for (const candidate of listSecretFiles(secretsPath)) {
@@ -369,7 +683,7 @@ function listSecretFiles(path) {
369
683
  if (!stats.isDirectory())
370
684
  return [];
371
685
  return readdirSync(path).sort().flatMap((entry) => {
372
- const child = join(path, entry);
686
+ const child = join2(path, entry);
373
687
  try {
374
688
  const childStats = statSync(child);
375
689
  if (childStats.isDirectory())
@@ -384,14 +698,19 @@ function listSecretFiles(path) {
384
698
  }
385
699
  }
386
700
  function getHomeDir() {
387
- return process.env["HOME"] || process.env["USERPROFILE"] || homedir();
701
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
388
702
  }
389
703
  function ensureDataDir(config) {
390
- const { mkdirSync: mkdirSync2 } = __require("fs");
391
- mkdirSync2(config.audio_dir, { recursive: true });
704
+ const { mkdirSync: mkdirSync3 } = __require("fs");
392
705
  const dbDir = config.db_path.substring(0, config.db_path.lastIndexOf("/"));
393
- if (dbDir)
394
- mkdirSync2(dbDir, { recursive: true });
706
+ const releaseLease = [config.audio_dir, dbDir].some((path) => path.length > 0 && isGlobalRecordingsStatePath(path)) ? acquireLocalStoreReaderLease() : () => {};
707
+ try {
708
+ mkdirSync3(config.audio_dir, { recursive: true });
709
+ if (dbDir)
710
+ mkdirSync3(dbDir, { recursive: true });
711
+ } finally {
712
+ releaseLease();
713
+ }
395
714
  }
396
715
 
397
716
  // src/db/database.ts
@@ -480,8 +799,8 @@ function getDatabase(dbPath) {
480
799
  if (_db)
481
800
  return _db;
482
801
  const path = dbPath || loadConfig().db_path;
483
- const dir = dirname2(path);
484
- mkdirSync2(dir, { recursive: true });
802
+ const dir = dirname3(path);
803
+ mkdirSync3(dir, { recursive: true });
485
804
  _adapter = new SqliteAdapter(path);
486
805
  _db = _adapter.raw;
487
806
  _db.run("PRAGMA busy_timeout = 5000");
@@ -537,6 +856,63 @@ function shortUuid() {
537
856
  return crypto.randomUUID().slice(0, 8);
538
857
  }
539
858
 
859
+ // src/db/errors.ts
860
+ class ProjectNotFoundError extends Error {
861
+ ref;
862
+ constructor(ref) {
863
+ super(`project not found: ${ref}`);
864
+ this.name = "ProjectNotFoundError";
865
+ this.ref = ref;
866
+ }
867
+ }
868
+
869
+ class ValidationError extends Error {
870
+ constructor(message) {
871
+ super(message);
872
+ this.name = "ValidationError";
873
+ }
874
+ }
875
+
876
+ // src/lib/recording-create-identity.ts
877
+ var MAX_RECORDING_IDENTITY_LENGTH = 255;
878
+ function validateIdentityValue(value, label, nullMeansAbsent = false) {
879
+ if (value === undefined || nullMeansAbsent && value === null)
880
+ return;
881
+ if (typeof value !== "string") {
882
+ throw new ValidationError(`${label} must be a string`);
883
+ }
884
+ if (value.length === 0) {
885
+ throw new ValidationError(`${label} must not be empty`);
886
+ }
887
+ if (value.length > MAX_RECORDING_IDENTITY_LENGTH) {
888
+ throw new ValidationError(`${label} must not exceed ${MAX_RECORDING_IDENTITY_LENGTH} characters`);
889
+ }
890
+ if (value !== value.trim()) {
891
+ throw new ValidationError(`${label} must not contain leading or trailing whitespace`);
892
+ }
893
+ if (/[\u0000-\u001f\u007f]/.test(value)) {
894
+ throw new ValidationError(`${label} must not contain control characters`);
895
+ }
896
+ if (/[^\u0020-\u007e]/.test(value)) {
897
+ throw new ValidationError(`${label} must contain only printable ASCII characters`);
898
+ }
899
+ return value;
900
+ }
901
+ function recordingCreateIdentity(input, idempotencyKey, options = {}) {
902
+ const bodyId = validateIdentityValue(input.id, "recording id", true);
903
+ const headerKey = validateIdentityValue(idempotencyKey, "idempotency key");
904
+ if (bodyId !== undefined && headerKey !== undefined && bodyId !== headerKey) {
905
+ throw new ValidationError("recording id conflicts with idempotency key");
906
+ }
907
+ const effectiveKey = headerKey ?? bodyId;
908
+ const { id: _runtimeId, ...inputWithoutId } = input;
909
+ const persistedId = options.bindIdempotencyKeyToId === false ? bodyId : effectiveKey;
910
+ return {
911
+ input: persistedId === undefined ? inputWithoutId : { ...inputWithoutId, id: persistedId },
912
+ idempotencyKey: effectiveKey
913
+ };
914
+ }
915
+
540
916
  // src/db/recordings.ts
541
917
  function parseRow(row) {
542
918
  return {
@@ -561,20 +937,36 @@ function parseRow(row) {
561
937
  created_at: row["created_at"]
562
938
  };
563
939
  }
564
- function createRecording(input, db) {
940
+ function createRecording(input, db, idempotencyKey) {
565
941
  const d = db || getDatabase();
566
- const id = shortUuid();
567
- const tagsJson = JSON.stringify(input.tags || []);
568
- const metadataJson = JSON.stringify(input.metadata || {});
569
- d.query(`INSERT INTO recordings (id, audio_path, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
570
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.audio_path || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, tagsJson, input.agent_id || null, input.project_id || null, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, metadataJson);
571
- if (input.tags && input.tags.length > 0) {
572
- const insertTag = d.query("INSERT OR IGNORE INTO recording_tags (recording_id, tag) VALUES (?, ?)");
573
- for (const tag of input.tags) {
574
- insertTag.run(id, tag);
942
+ input = recordingCreateIdentity(input, idempotencyKey).input;
943
+ const id = input.id || shortUuid();
944
+ const create = d.transaction(() => {
945
+ if (input.id) {
946
+ const existing = getRecording(input.id, d);
947
+ if (existing?.id === input.id)
948
+ return existing;
575
949
  }
576
- }
577
- return getRecording(id, d);
950
+ const tagsJson = JSON.stringify(input.tags || []);
951
+ const metadataJson = JSON.stringify(input.metadata || {});
952
+ const insertResult = d.query(`INSERT INTO recordings (id, audio_path, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
953
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
954
+ ON CONFLICT(id) DO NOTHING`).run(id, input.audio_path || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, tagsJson, input.agent_id || null, input.project_id || null, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, metadataJson);
955
+ if (insertResult.changes === 0) {
956
+ const existing = getRecording(id, d);
957
+ if (existing?.id === id)
958
+ return existing;
959
+ throw new Error("recording id conflict could not be read back");
960
+ }
961
+ if (input.tags && input.tags.length > 0) {
962
+ const insertTag = d.query("INSERT OR IGNORE INTO recording_tags (recording_id, tag) VALUES (?, ?)");
963
+ for (const tag of input.tags) {
964
+ insertTag.run(id, tag);
965
+ }
966
+ }
967
+ return getRecording(id, d);
968
+ });
969
+ return create();
578
970
  }
579
971
  function getRecording(id, db) {
580
972
  const d = db || getDatabase();
@@ -586,6 +978,20 @@ function getRecording(id, db) {
586
978
  }
587
979
  function listRecordings(filter, db) {
588
980
  const d = db || getDatabase();
981
+ const { where, params } = buildRecordingWhere(filter);
982
+ const limit = filter?.limit || 50;
983
+ const offset = filter?.offset || 0;
984
+ const sql = `SELECT * FROM recordings ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
985
+ const rows = d.query(sql).all(...params, limit, offset);
986
+ return rows.map(parseRow);
987
+ }
988
+ function countRecordings(filter, db) {
989
+ const d = db || getDatabase();
990
+ const { where, params } = buildRecordingWhere(filter);
991
+ const row = d.query(`SELECT COUNT(*) as c FROM recordings ${where}`).get(...params);
992
+ return row.c;
993
+ }
994
+ function buildRecordingWhere(filter) {
589
995
  const conditions = [];
590
996
  const params = [];
591
997
  if (filter?.agent_id) {
@@ -624,12 +1030,7 @@ function listRecordings(filter, db) {
624
1030
  params.push(filter.until);
625
1031
  }
626
1032
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
627
- const limit = filter?.limit || 50;
628
- const offset = filter?.offset || 0;
629
- const sql = `SELECT * FROM recordings ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
630
- params.push(limit, offset);
631
- const rows = d.query(sql).all(...params);
632
- return rows.map(parseRow);
1033
+ return { where, params };
633
1034
  }
634
1035
  function deleteRecording(id, db) {
635
1036
  const d = db || getDatabase();
@@ -702,16 +1103,6 @@ function listProjects(db) {
702
1103
  return rows.map(parseProject);
703
1104
  }
704
1105
 
705
- // src/db/errors.ts
706
- class ProjectNotFoundError extends Error {
707
- ref;
708
- constructor(ref) {
709
- super(`project not found: ${ref}`);
710
- this.name = "ProjectNotFoundError";
711
- this.ref = ref;
712
- }
713
- }
714
-
715
1106
  // src/db/agents.ts
716
1107
  function parseAgent(row) {
717
1108
  return {
@@ -777,7 +1168,7 @@ function setAgentFocus(idOrName, projectId, db) {
777
1168
  }
778
1169
 
779
1170
  // src/version.ts
780
- var VERSION = "0.2.11";
1171
+ var VERSION = "0.2.14";
781
1172
 
782
1173
  // src/db/feedback.ts
783
1174
  function saveFeedback(input) {
@@ -1070,6 +1461,7 @@ function resolveStorageClient(name, env = process.env, fetchImpl) {
1070
1461
  }
1071
1462
 
1072
1463
  // src/store.ts
1464
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
1073
1465
  var APP = "recordings";
1074
1466
  function listQuery(filter) {
1075
1467
  if (!filter)
@@ -1079,6 +1471,7 @@ function listQuery(filter) {
1079
1471
  project_id: filter.project_id,
1080
1472
  session_id: filter.session_id,
1081
1473
  processing_mode: filter.processing_mode,
1474
+ tags: filter.tags,
1082
1475
  search: filter.search,
1083
1476
  since: filter.since,
1084
1477
  until: filter.until,
@@ -1095,58 +1488,63 @@ function unwrap(res, key) {
1095
1488
  var localStore = {
1096
1489
  mode: "local",
1097
1490
  baseUrl: null,
1098
- async createRecording(input) {
1099
- return createRecording(input);
1491
+ async createRecording(input, idempotencyKey) {
1492
+ return withLocalStoreReaderLease(() => createRecording(input, undefined, idempotencyKey));
1100
1493
  },
1101
1494
  async getRecording(id) {
1102
- return getRecording(id);
1495
+ return withLocalStoreReaderLease(() => getRecording(id));
1103
1496
  },
1104
1497
  async listRecordings(filter) {
1105
- return listRecordings(filter);
1498
+ return withLocalStoreReaderLease(() => listRecordings(filter));
1499
+ },
1500
+ async countRecordings(filter) {
1501
+ return withLocalStoreReaderLease(() => countRecordings(filter));
1106
1502
  },
1107
1503
  async searchRecordings(query, filter) {
1108
- return searchRecordings(query, filter);
1504
+ return withLocalStoreReaderLease(() => searchRecordings(query, filter));
1109
1505
  },
1110
1506
  async deleteRecording(id) {
1111
- return deleteRecording(id);
1507
+ return withLocalStoreReaderLease(() => deleteRecording(id));
1112
1508
  },
1113
1509
  async getRecordingStats() {
1114
- return getRecordingStats();
1510
+ return withLocalStoreReaderLease(() => getRecordingStats());
1115
1511
  },
1116
1512
  async registerAgent(name, description, role) {
1117
- return registerAgent(name, description, role);
1513
+ return withLocalStoreReaderLease(() => registerAgent(name, description, role));
1118
1514
  },
1119
1515
  async getAgent(idOrName) {
1120
- return getAgent(idOrName);
1516
+ return withLocalStoreReaderLease(() => getAgent(idOrName));
1121
1517
  },
1122
1518
  async listAgents() {
1123
- return listAgents();
1519
+ return withLocalStoreReaderLease(() => listAgents());
1124
1520
  },
1125
1521
  async heartbeatAgent(idOrName) {
1126
- return heartbeatAgent(idOrName);
1522
+ return withLocalStoreReaderLease(() => heartbeatAgent(idOrName));
1127
1523
  },
1128
1524
  async setAgentFocus(idOrName, projectId) {
1129
- return setAgentFocus(idOrName, projectId);
1525
+ return withLocalStoreReaderLease(() => setAgentFocus(idOrName, projectId));
1130
1526
  },
1131
1527
  async registerProject(name, path, description) {
1132
- return registerProject(name, path, description);
1528
+ return withLocalStoreReaderLease(() => registerProject(name, path, description));
1133
1529
  },
1134
1530
  async getProject(idOrPath) {
1135
- return getProject(idOrPath);
1531
+ return withLocalStoreReaderLease(() => getProject(idOrPath));
1136
1532
  },
1137
1533
  async listProjects() {
1138
- return listProjects();
1534
+ return withLocalStoreReaderLease(() => listProjects());
1139
1535
  },
1140
1536
  async saveFeedback(input) {
1141
- saveFeedback(input);
1537
+ await withLocalStoreReaderLease(() => saveFeedback(input));
1142
1538
  }
1143
1539
  };
1144
1540
  function apiStore(client) {
1145
1541
  return {
1146
1542
  mode: "cloud-http",
1147
1543
  baseUrl: client.baseUrl,
1148
- async createRecording(input) {
1149
- const res = await client.create("recordings", input);
1544
+ async createRecording(input, idempotencyKey) {
1545
+ const keyCandidate = idempotencyKey === undefined && (input.id === undefined || input.id === null) ? randomUUID2() : idempotencyKey;
1546
+ const identity = recordingCreateIdentity(input, keyCandidate, { bindIdempotencyKeyToId: false });
1547
+ const res = await client.create("recordings", identity.input, identity.idempotencyKey);
1150
1548
  return unwrap(res, "recording");
1151
1549
  },
1152
1550
  async getRecording(id) {
@@ -1157,6 +1555,36 @@ function apiStore(client) {
1157
1555
  const { items } = await client.list("recordings", listQuery(filter));
1158
1556
  return items;
1159
1557
  },
1558
+ async countRecordings(filter) {
1559
+ const pageLimit = 500;
1560
+ const maxPageRequests = 1e4;
1561
+ let offset = 0;
1562
+ let pageRequests = 0;
1563
+ const seenPageKeys = new Set;
1564
+ while (pageRequests < maxPageRequests) {
1565
+ pageRequests += 1;
1566
+ const { items, raw } = await client.list("recordings", {
1567
+ ...listQuery(filter),
1568
+ limit: pageLimit,
1569
+ offset
1570
+ });
1571
+ const count = raw && typeof raw === "object" ? raw.count : undefined;
1572
+ if (typeof count !== "number" || !Number.isFinite(count)) {
1573
+ throw new Error("Recordings API response is missing a valid count");
1574
+ }
1575
+ if (count > items.length)
1576
+ return count;
1577
+ if (items.length === 0)
1578
+ return offset;
1579
+ offset += items.length;
1580
+ const pageKey = recordingPageFingerprint(items);
1581
+ if (seenPageKeys.has(pageKey)) {
1582
+ throw new Error("Recordings API ignored pagination while counting legacy results");
1583
+ }
1584
+ seenPageKeys.add(pageKey);
1585
+ }
1586
+ throw new Error(`Recordings API exceeded ${maxPageRequests} pages while counting legacy results`);
1587
+ },
1160
1588
  async searchRecordings(query, filter) {
1161
1589
  const { items } = await client.list("recordings", listQuery({ ...filter ?? {}, search: query }));
1162
1590
  return items;
@@ -1238,6 +1666,17 @@ function apiStore(client) {
1238
1666
  }
1239
1667
  };
1240
1668
  }
1669
+ function recordingPageFingerprint(items) {
1670
+ const hash = createHash("sha256");
1671
+ const ids = items.map((item) => String(item.id ?? "")).sort();
1672
+ for (const id of ids) {
1673
+ hash.update(String(id.length));
1674
+ hash.update(":");
1675
+ hash.update(id);
1676
+ hash.update(";");
1677
+ }
1678
+ return `${items.length}:${hash.digest("hex")}`;
1679
+ }
1241
1680
  var cached = null;
1242
1681
  function getStore(env = process.env) {
1243
1682
  if (env === process.env && cached)
@@ -1255,17 +1694,20 @@ function __resetStore() {
1255
1694
  import OpenAI from "openai";
1256
1695
  import { createReadStream } from "fs";
1257
1696
  var _client = null;
1697
+ var _clientApiKey = null;
1258
1698
  function getClient(config) {
1259
- if (_client)
1260
- return _client;
1261
1699
  if (!config.openai_api_key) {
1262
1700
  throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var or add to ~/.secrets");
1263
1701
  }
1702
+ if (_client && _clientApiKey === config.openai_api_key)
1703
+ return _client;
1264
1704
  _client = new OpenAI({ apiKey: config.openai_api_key });
1705
+ _clientApiKey = config.openai_api_key;
1265
1706
  return _client;
1266
1707
  }
1267
1708
  function resetClient() {
1268
1709
  _client = null;
1710
+ _clientApiKey = null;
1269
1711
  }
1270
1712
  async function transcribeAudio(audioPath, config, options = {}) {
1271
1713
  const client = getClient(config);
@@ -1276,7 +1718,7 @@ async function transcribeAudio(audioPath, config, options = {}) {
1276
1718
  file: stream,
1277
1719
  model: config.transcription_model,
1278
1720
  language: config.language || undefined,
1279
- prompt: buildVerbatimPrompt(options.prompt),
1721
+ prompt: buildVerbatimPrompt(options.prompt ?? config.transcription_prompt),
1280
1722
  response_format: "json"
1281
1723
  });
1282
1724
  stream.destroy();
@@ -1303,7 +1745,7 @@ async function transcribeBuffer(buffer, filename, config, options = {}) {
1303
1745
  file,
1304
1746
  model: config.transcription_model,
1305
1747
  language: config.language || undefined,
1306
- prompt: buildVerbatimPrompt(options.prompt),
1748
+ prompt: buildVerbatimPrompt(options.prompt ?? config.transcription_prompt),
1307
1749
  response_format: "json"
1308
1750
  });
1309
1751
  const durationMs = Date.now() - startTime;
@@ -1360,18 +1802,21 @@ function getMimeType(filename) {
1360
1802
  // src/lib/enhancer.ts
1361
1803
  import OpenAI2 from "openai";
1362
1804
  var _enhancementClient = null;
1805
+ var _enhancementClientApiKey = null;
1363
1806
  function getEnhancementClient(config) {
1364
- if (_enhancementClient)
1365
- return _enhancementClient;
1366
1807
  const key = config.enhancement_api_key || config.openai_api_key;
1367
1808
  if (!key) {
1368
1809
  throw new EnhancementError("API key not configured for enhancement. Set OPENAI_API_KEY or RECORDINGS_ENHANCEMENT_KEY");
1369
1810
  }
1811
+ if (_enhancementClient && _enhancementClientApiKey === key)
1812
+ return _enhancementClient;
1370
1813
  _enhancementClient = new OpenAI2({ apiKey: key });
1814
+ _enhancementClientApiKey = key;
1371
1815
  return _enhancementClient;
1372
1816
  }
1373
1817
  function resetEnhancementClient() {
1374
1818
  _enhancementClient = null;
1819
+ _enhancementClientApiKey = null;
1375
1820
  }
1376
1821
  function needsEnhancement(text, config) {
1377
1822
  const lower = text.toLowerCase().trim();
@@ -1417,6 +1862,7 @@ function extractInstruction(text, trigger) {
1417
1862
  }
1418
1863
  async function enhanceText(rawText, instruction, config, systemPrompt) {
1419
1864
  const client = getEnhancementClient(config);
1865
+ const model = resolveTranscriberModel(config);
1420
1866
  let basePrompt = `You are a writing assistant. The user has dictated speech that needs to be transformed into polished output.
1421
1867
 
1422
1868
  Rules:
@@ -1437,13 +1883,14 @@ Rules:
1437
1883
  Keyword Transformations:
1438
1884
  ${transformRules}`;
1439
1885
  }
1440
- const fullPrompt = systemPrompt ? `${basePrompt}
1886
+ const transcriberPrompt = combinePrompts(config.transcriber_prompt, systemPrompt);
1887
+ const fullPrompt = transcriberPrompt ? `${basePrompt}
1441
1888
 
1442
- Additional context:
1443
- ${systemPrompt}` : basePrompt;
1889
+ Transcriber instructions:
1890
+ ${transcriberPrompt}` : basePrompt;
1444
1891
  try {
1445
1892
  const response = await client.chat.completions.create({
1446
- model: config.enhancement_model,
1893
+ model,
1447
1894
  messages: [
1448
1895
  {
1449
1896
  role: "system",
@@ -1461,7 +1908,7 @@ ${systemPrompt}` : basePrompt;
1461
1908
  return {
1462
1909
  original: rawText,
1463
1910
  enhanced,
1464
- model: config.enhancement_model,
1911
+ model,
1465
1912
  reasoning: null
1466
1913
  };
1467
1914
  } catch (error) {
@@ -1469,40 +1916,64 @@ ${systemPrompt}` : basePrompt;
1469
1916
  throw new EnhancementError(`Enhancement failed: ${describeTranscriptionFailure(msg)}`);
1470
1917
  }
1471
1918
  }
1472
- async function processText(rawText, config, systemPrompt, options) {
1473
- const force = options?.force === true;
1474
- if (!force && !config.auto_enhance) {
1475
- return { text: rawText, mode: "raw", enhancement_model: null };
1919
+ async function processText(rawText, config, systemPromptOrOptions, legacyOptions) {
1920
+ const options = {
1921
+ ...normalizeProcessTextOptions(systemPromptOrOptions),
1922
+ ...legacyOptions
1923
+ };
1924
+ const postProcessingMode = options.force ? "always" : options.postProcessingMode ?? normalizePostProcessingMode(config.post_processing_mode, config.auto_enhance === false ? "off" : "auto");
1925
+ if (postProcessingMode === "off") {
1926
+ return {
1927
+ text: rawText,
1928
+ mode: "raw",
1929
+ enhancement_model: null,
1930
+ post_processing_mode: "off",
1931
+ enhancement_reason: null
1932
+ };
1476
1933
  }
1477
- const detection = needsEnhancement(rawText, config);
1478
- if (!force && !detection.needs) {
1479
- return { text: rawText, mode: "raw", enhancement_model: null };
1934
+ const detection = postProcessingMode === "always" ? {
1935
+ needs: true,
1936
+ reason: "Always-on post-processing",
1937
+ instruction: rawText
1938
+ } : needsEnhancement(rawText, config);
1939
+ if (!detection.needs) {
1940
+ return {
1941
+ text: rawText,
1942
+ mode: "raw",
1943
+ enhancement_model: null,
1944
+ post_processing_mode: postProcessingMode,
1945
+ enhancement_reason: detection.reason
1946
+ };
1480
1947
  }
1481
- const instruction = detection.needs ? detection.instruction : rawText;
1482
- const result = await enhanceText(rawText, instruction, config, systemPrompt);
1948
+ const result = await enhanceText(rawText, detection.instruction, config, options.systemPrompt);
1483
1949
  return {
1484
1950
  text: result.enhanced,
1485
1951
  mode: "enhanced",
1486
- enhancement_model: result.model
1952
+ enhancement_model: result.model,
1953
+ post_processing_mode: postProcessingMode,
1954
+ enhancement_reason: detection.reason
1487
1955
  };
1488
1956
  }
1957
+ function resolveTranscriberModel(config) {
1958
+ return config.transcriber_model || config.enhancement_model;
1959
+ }
1960
+ function normalizeProcessTextOptions(value) {
1961
+ if (typeof value === "string")
1962
+ return { systemPrompt: value };
1963
+ return value ?? {};
1964
+ }
1965
+ function combinePrompts(...prompts) {
1966
+ return prompts.map((prompt) => prompt?.trim() ?? "").filter(Boolean).join(`
1967
+
1968
+ `);
1969
+ }
1489
1970
  // src/lib/recorder.ts
1490
1971
  import { spawn } from "child_process";
1491
- import { join as join2 } from "path";
1972
+ import { join as join3 } from "path";
1492
1973
  import { existsSync as existsSync2 } from "fs";
1493
1974
  var _recordProcess = null;
1494
1975
  var _currentFile = null;
1495
1976
  async function checkRecordingDeps() {
1496
- try {
1497
- const proc = Bun.spawn(["which", "sox"], {
1498
- stdout: "pipe",
1499
- stderr: "pipe"
1500
- });
1501
- await proc.exited;
1502
- if (proc.exitCode === 0) {
1503
- return { available: true, tool: "sox", message: "sox is available" };
1504
- }
1505
- } catch {}
1506
1977
  try {
1507
1978
  const proc = Bun.spawn(["which", "rec"], {
1508
1979
  stdout: "pipe",
@@ -1513,20 +1984,6 @@ async function checkRecordingDeps() {
1513
1984
  return { available: true, tool: "rec", message: "rec is available" };
1514
1985
  }
1515
1986
  } catch {}
1516
- try {
1517
- const proc = Bun.spawn(["which", "ffmpeg"], {
1518
- stdout: "pipe",
1519
- stderr: "pipe"
1520
- });
1521
- await proc.exited;
1522
- if (proc.exitCode === 0) {
1523
- return {
1524
- available: true,
1525
- tool: "ffmpeg",
1526
- message: "ffmpeg is available"
1527
- };
1528
- }
1529
- } catch {}
1530
1987
  return {
1531
1988
  available: false,
1532
1989
  tool: "none",
@@ -1539,11 +1996,20 @@ function startRecording(config) {
1539
1996
  }
1540
1997
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1541
1998
  const filename = `recording-${timestamp}.${config.audio_format}`;
1542
- const filepath = join2(config.audio_dir, filename);
1999
+ const filepath = join3(config.audio_dir, filename);
1543
2000
  const args = buildRecordArgs(filepath, config);
1544
- _recordProcess = spawn(args[0], args.slice(1), {
1545
- stdio: ["pipe", "pipe", "pipe"]
1546
- });
2001
+ const releaseLease = isGlobalRecordingsStatePath(filepath) ? acquireLocalStoreReaderLease() : () => {};
2002
+ let recordProcess;
2003
+ try {
2004
+ recordProcess = spawn(args[0], args.slice(1), {
2005
+ stdio: ["pipe", "pipe", "pipe"]
2006
+ });
2007
+ } catch (error) {
2008
+ releaseLease();
2009
+ throw error;
2010
+ }
2011
+ _recordProcess = recordProcess;
2012
+ _currentFile = filepath;
1547
2013
  if (config.max_recording_seconds > 0) {
1548
2014
  setTimeout(() => {
1549
2015
  if (_recordProcess && _currentFile === filepath) {
@@ -1552,14 +2018,18 @@ function startRecording(config) {
1552
2018
  }
1553
2019
  }, config.max_recording_seconds * 1000);
1554
2020
  }
1555
- _currentFile = filepath;
1556
- _recordProcess.on("error", (err) => {
1557
- _recordProcess = null;
1558
- _currentFile = null;
2021
+ recordProcess.on("error", (err) => {
2022
+ if (_recordProcess === recordProcess) {
2023
+ _recordProcess = null;
2024
+ _currentFile = null;
2025
+ }
2026
+ releaseLease();
1559
2027
  throw new RecordingError(`Recording process error: ${err.message}`);
1560
2028
  });
1561
- _recordProcess.on("exit", () => {
1562
- _recordProcess = null;
2029
+ recordProcess.on("exit", () => {
2030
+ if (_recordProcess === recordProcess)
2031
+ _recordProcess = null;
2032
+ releaseLease();
1563
2033
  });
1564
2034
  return filepath;
1565
2035
  }
@@ -1600,7 +2070,7 @@ function buildRecordArgs(filepath, config) {
1600
2070
  async function recordDuration(seconds, config) {
1601
2071
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1602
2072
  const filename = `recording-${timestamp}.${config.audio_format}`;
1603
- const filepath = join2(config.audio_dir, filename);
2073
+ const filepath = join3(config.audio_dir, filename);
1604
2074
  const args = [
1605
2075
  "rec",
1606
2076
  "-r",
@@ -1614,16 +2084,21 @@ async function recordDuration(seconds, config) {
1614
2084
  "0",
1615
2085
  seconds.toString()
1616
2086
  ];
1617
- const proc = Bun.spawn(args, {
1618
- stdout: "pipe",
1619
- stderr: "pipe"
1620
- });
1621
- const exitCode = await proc.exited;
1622
- if (exitCode !== 0 && !existsSync2(filepath)) {
1623
- const stderr = await new Response(proc.stderr).text();
1624
- throw new RecordingError(`Recording failed (exit ${exitCode}): ${stderr}`);
2087
+ const releaseLease = isGlobalRecordingsStatePath(filepath) ? acquireLocalStoreReaderLease() : () => {};
2088
+ try {
2089
+ const proc = Bun.spawn(args, {
2090
+ stdout: "pipe",
2091
+ stderr: "pipe"
2092
+ });
2093
+ const exitCode = await proc.exited;
2094
+ if (exitCode !== 0 && !existsSync2(filepath)) {
2095
+ const stderr = await new Response(proc.stderr).text();
2096
+ throw new RecordingError(`Recording failed (exit ${exitCode}): ${stderr}`);
2097
+ }
2098
+ return filepath;
2099
+ } finally {
2100
+ releaseLease();
1625
2101
  }
1626
- return filepath;
1627
2102
  }
1628
2103
  export {
1629
2104
  transcribeBuffer,
@@ -1636,6 +2111,9 @@ export {
1636
2111
  resetClient,
1637
2112
  recordDuration,
1638
2113
  processText,
2114
+ normalizePostProcessingMode,
2115
+ normalizePostProcessingConfig,
2116
+ normalizeModelSlots,
1639
2117
  needsEnhancement,
1640
2118
  loadConfig,
1641
2119
  isRecording,
@@ -1655,6 +2133,9 @@ export {
1655
2133
  RecordingError,
1656
2134
  HasnaHttpError,
1657
2135
  EnhancementError,
2136
+ DEFAULT_TRANSCRIPTION_MODEL,
2137
+ DEFAULT_REALTIME_TRANSCRIPTION_MODEL,
2138
+ DEFAULT_REALTIME_SESSION_MODEL,
1658
2139
  DEFAULT_CONFIG,
1659
2140
  APP
1660
2141
  };