@hasna/recordings 0.2.10 → 0.2.13

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 +350 -35
  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 +59 -0
  75. package/scripts/generate-sdk.ts +19 -1
  76. package/scripts/install_macos_app.sh +1922 -77
  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 +1990 -29
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
package/dist/storage.js CHANGED
@@ -33,23 +33,200 @@ class SqliteAdapter {
33
33
  }
34
34
 
35
35
  // src/db/database.ts
36
- import { mkdirSync as mkdirSync2 } from "fs";
37
- import { dirname as dirname2 } from "path";
36
+ import { mkdirSync as mkdirSync3 } from "fs";
37
+ import { dirname as dirname3 } from "path";
38
38
 
39
39
  // src/lib/config.ts
40
- import { copyFileSync, existsSync, readFileSync, mkdirSync, readdirSync, statSync } from "fs";
41
- import { dirname, join, resolve } from "path";
40
+ import { copyFileSync, existsSync, readFileSync, mkdirSync as mkdirSync2, readdirSync, realpathSync as realpathSync2, statSync } from "fs";
41
+ import { dirname as dirname2, join as join2, resolve as resolve2, sep as sep2 } from "path";
42
+ import { homedir as homedir2 } from "os";
43
+
44
+ // src/lib/install-maintenance.ts
45
+ import { randomUUID } from "crypto";
46
+ import {
47
+ chmodSync,
48
+ lstatSync,
49
+ mkdirSync,
50
+ realpathSync,
51
+ renameSync,
52
+ rmSync,
53
+ writeFileSync
54
+ } from "fs";
42
55
  import { homedir } from "os";
56
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
57
+ import { spawnSync } from "child_process";
58
+ var INSTALL_MAINTENANCE_MARKER_NAME = ".recordings-install-maintenance";
59
+ var STORE_READER_LEASES_NAME = ".recordings-store-readers";
60
+ function stateParent(env = process.env) {
61
+ const home = env["HOME"] || env["USERPROFILE"] || homedir();
62
+ return join(home, ".hasna");
63
+ }
64
+ function installMaintenanceMarkerPath(env = process.env) {
65
+ return join(stateParent(env), INSTALL_MAINTENANCE_MARKER_NAME);
66
+ }
67
+ function storeReaderLeasesPath(env = process.env) {
68
+ return join(stateParent(env), STORE_READER_LEASES_NAME);
69
+ }
70
+ function pathExists(path) {
71
+ try {
72
+ lstatSync(path);
73
+ return true;
74
+ } catch (error) {
75
+ if (error.code === "ENOENT")
76
+ return false;
77
+ throw error;
78
+ }
79
+ }
80
+ function assertPrivateDirectory(path) {
81
+ const details = lstatSync(path);
82
+ if (!details.isDirectory() || details.isSymbolicLink()) {
83
+ throw new Error(`Local Recordings coordination path is not a secure directory: ${path}`);
84
+ }
85
+ if (typeof process.getuid === "function" && details.uid !== process.getuid()) {
86
+ throw new Error(`Local Recordings coordination path has an unexpected owner: ${path}`);
87
+ }
88
+ if ((details.mode & 18) !== 0) {
89
+ throw new Error(`Local Recordings coordination path is group/world writable: ${path}`);
90
+ }
91
+ }
92
+ function ensureReaderRoot(env) {
93
+ const parent = stateParent(env);
94
+ const readerRoot = storeReaderLeasesPath(env);
95
+ mkdirSync(parent, { recursive: true, mode: 448 });
96
+ assertPrivateDirectory(parent);
97
+ try {
98
+ mkdirSync(readerRoot, { mode: 448 });
99
+ } catch (error) {
100
+ if (error.code !== "EEXIST")
101
+ throw error;
102
+ }
103
+ assertPrivateDirectory(readerRoot);
104
+ return readerRoot;
105
+ }
106
+ function processStartIdentity() {
107
+ const result = spawnSync("/bin/ps", ["-o", "lstart=", "-p", String(process.pid)], {
108
+ encoding: "utf8",
109
+ env: { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC0" }
110
+ });
111
+ const identity = result.status === 0 ? result.stdout.trim().replace(/\s+/g, " ") : "";
112
+ if (!identity) {
113
+ throw new Error("Could not establish the local Store reader process identity");
114
+ }
115
+ return identity;
116
+ }
117
+ function maintenanceUnavailable() {
118
+ return new Error("Local Recordings storage is temporarily unavailable during app installation maintenance");
119
+ }
120
+ function assertMaintenanceAbsent(env) {
121
+ if (pathExists(installMaintenanceMarkerPath(env)))
122
+ throw maintenanceUnavailable();
123
+ }
124
+ function canonicalPotentialPath(path) {
125
+ const absolutePath = resolve(path);
126
+ let existingPath = absolutePath;
127
+ const missingEntries = [];
128
+ while (true) {
129
+ try {
130
+ return join(realpathSync(existingPath), ...missingEntries);
131
+ } catch (error) {
132
+ if (error.code !== "ENOENT")
133
+ return absolutePath;
134
+ const parent = dirname(existingPath);
135
+ if (parent === existingPath)
136
+ return absolutePath;
137
+ missingEntries.unshift(basename(existingPath));
138
+ existingPath = parent;
139
+ }
140
+ }
141
+ }
142
+ function pathIsWithin(root, candidate) {
143
+ const relativePath = relative(root, candidate);
144
+ return relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
145
+ }
146
+ function isGlobalRecordingsStatePath(path, env = process.env) {
147
+ const globalStateRoot = join(stateParent(env), "recordings");
148
+ return pathIsWithin(resolve(globalStateRoot), resolve(path)) || pathIsWithin(canonicalPotentialPath(globalStateRoot), canonicalPotentialPath(path));
149
+ }
150
+ function removeActiveLease(activeLease, releasedLease) {
151
+ try {
152
+ renameSync(activeLease, releasedLease);
153
+ } catch (error) {
154
+ if (error.code !== "ENOENT")
155
+ throw error;
156
+ }
157
+ rmSync(releasedLease, { recursive: true, force: true });
158
+ }
159
+ function acquireLocalStoreReaderLease(env = process.env) {
160
+ assertMaintenanceAbsent(env);
161
+ const readerRoot = ensureReaderRoot(env);
162
+ const nonce = randomUUID();
163
+ const pendingLease = join(readerRoot, `.pending-${process.pid}-${nonce}`);
164
+ const activeLease = join(readerRoot, `lease-${process.pid}-${nonce}`);
165
+ const releasedLease = join(readerRoot, `.released-${process.pid}-${nonce}`);
166
+ let active = false;
167
+ try {
168
+ mkdirSync(pendingLease, { mode: 448 });
169
+ const owner = join(pendingLease, "owner");
170
+ writeFileSync(owner, `${process.pid}
171
+ ${processStartIdentity()}
172
+ `, {
173
+ encoding: "utf8",
174
+ flag: "wx",
175
+ mode: 384
176
+ });
177
+ chmodSync(owner, 384);
178
+ renameSync(pendingLease, activeLease);
179
+ active = true;
180
+ assertMaintenanceAbsent(env);
181
+ } catch (error) {
182
+ if (active)
183
+ removeActiveLease(activeLease, releasedLease);
184
+ else
185
+ rmSync(pendingLease, { recursive: true, force: true });
186
+ throw error;
187
+ }
188
+ let released = false;
189
+ return () => {
190
+ if (released)
191
+ return;
192
+ removeActiveLease(activeLease, releasedLease);
193
+ released = true;
194
+ };
195
+ }
196
+ async function withLocalStoreReaderLease(operation, env = process.env) {
197
+ const release = acquireLocalStoreReaderLease(env);
198
+ try {
199
+ return await operation();
200
+ } finally {
201
+ release();
202
+ }
203
+ }
204
+
205
+ // src/lib/config.ts
206
+ var POST_PROCESSING_MODES = new Set([
207
+ "off",
208
+ "auto",
209
+ "always"
210
+ ]);
211
+ var DEFAULT_TRANSCRIPTION_MODEL = "gpt-4o-transcribe";
212
+ var DEFAULT_REALTIME_SESSION_MODEL = "gpt-realtime";
213
+ var DEFAULT_REALTIME_TRANSCRIPTION_MODEL = "gpt-realtime-whisper";
43
214
  var DEFAULT_CONFIG = {
44
215
  openai_api_key: "",
45
216
  enhancement_api_key: "",
46
- transcription_model: "gpt-4o-transcribe",
217
+ transcription_model: DEFAULT_TRANSCRIPTION_MODEL,
218
+ realtime_session_model: DEFAULT_REALTIME_SESSION_MODEL,
219
+ realtime_transcription_model: DEFAULT_REALTIME_TRANSCRIPTION_MODEL,
47
220
  enhancement_model: "gpt-4o",
221
+ transcriber_model: "gpt-4o",
48
222
  language: "en",
49
223
  audio_format: "wav",
50
224
  sample_rate: 16000,
51
225
  record_command: "sox",
52
226
  hotkey: "space",
227
+ transcription_prompt: "",
228
+ transcriber_prompt: "",
229
+ post_processing_mode: "auto",
53
230
  auto_enhance: true,
54
231
  enhance_triggers: [
55
232
  "say it better",
@@ -66,17 +243,23 @@ var DEFAULT_CONFIG = {
66
243
  keyword_transforms: {},
67
244
  db_path: "",
68
245
  audio_dir: "",
69
- max_recording_seconds: 1800
246
+ max_recording_seconds: 1800,
247
+ config_warnings: []
70
248
  };
71
249
  function loadConfig(configPath) {
72
250
  const config = { ...DEFAULT_CONFIG };
73
- const filePath = configPath || findConfigFile() || join(getDataDir(), "config.json");
251
+ config.config_warnings = [];
252
+ let explicitPostProcessingMode = false;
253
+ let explicitTranscriberModel = false;
254
+ const filePath = configPath || findConfigFile() || join2(getDataDir(), "config.json");
74
255
  let fileProvidedOpenAIKey = false;
75
256
  if (existsSync(filePath)) {
76
257
  try {
77
258
  const raw = readFileSync(filePath, "utf-8");
78
259
  const fileConfig = JSON.parse(raw);
79
260
  const expanded = expandEnvBackedConfig(fileConfig);
261
+ explicitPostProcessingMode = typeof fileConfig.post_processing_mode === "string";
262
+ explicitTranscriberModel = typeof fileConfig.transcriber_model === "string";
80
263
  Object.assign(config, expanded);
81
264
  fileProvidedOpenAIKey = typeof expanded.openai_api_key === "string" && expanded.openai_api_key.length > 0;
82
265
  } catch {}
@@ -93,12 +276,35 @@ function loadConfig(configPath) {
93
276
  if (process.env.RECORDINGS_MODEL) {
94
277
  config.transcription_model = process.env.RECORDINGS_MODEL;
95
278
  }
279
+ if (process.env.RECORDINGS_REALTIME_SESSION_MODEL) {
280
+ config.realtime_session_model = process.env.RECORDINGS_REALTIME_SESSION_MODEL;
281
+ }
282
+ if (process.env.RECORDINGS_REALTIME_TRANSCRIPTION_MODEL) {
283
+ config.realtime_transcription_model = process.env.RECORDINGS_REALTIME_TRANSCRIPTION_MODEL;
284
+ }
96
285
  if (process.env.RECORDINGS_ENHANCEMENT_MODEL) {
97
286
  config.enhancement_model = process.env.RECORDINGS_ENHANCEMENT_MODEL;
98
287
  }
288
+ if (process.env.RECORDINGS_TRANSCRIBER_MODEL) {
289
+ config.transcriber_model = process.env.RECORDINGS_TRANSCRIBER_MODEL;
290
+ explicitTranscriberModel = true;
291
+ }
99
292
  if (process.env.RECORDINGS_LANGUAGE) {
100
293
  config.language = process.env.RECORDINGS_LANGUAGE;
101
294
  }
295
+ if (process.env.RECORDINGS_TRANSCRIPTION_PROMPT) {
296
+ config.transcription_prompt = process.env.RECORDINGS_TRANSCRIPTION_PROMPT;
297
+ }
298
+ if (process.env.RECORDINGS_TRANSCRIBER_PROMPT) {
299
+ config.transcriber_prompt = process.env.RECORDINGS_TRANSCRIBER_PROMPT;
300
+ }
301
+ if (process.env.RECORDINGS_POST_PROCESSING_MODE) {
302
+ config.post_processing_mode = normalizePostProcessingMode(process.env.RECORDINGS_POST_PROCESSING_MODE, config.post_processing_mode ?? "auto");
303
+ explicitPostProcessingMode = true;
304
+ }
305
+ if (process.env.RECORDINGS_AUTO_ENHANCE) {
306
+ config.auto_enhance = parseBooleanEnv(process.env.RECORDINGS_AUTO_ENHANCE, config.auto_enhance);
307
+ }
102
308
  if (process.env.HASNA_RECORDINGS_DB_PATH) {
103
309
  config.db_path = process.env.HASNA_RECORDINGS_DB_PATH;
104
310
  } else if (process.env.RECORDINGS_DB_PATH) {
@@ -116,14 +322,86 @@ function loadConfig(configPath) {
116
322
  if (!config.enhancement_api_key) {
117
323
  config.enhancement_api_key = config.openai_api_key || loadSecretKey("OPENAI_API_KEY");
118
324
  }
325
+ if (!explicitTranscriberModel) {
326
+ config.transcriber_model = config.enhancement_model;
327
+ }
328
+ normalizeModelSlots(config);
329
+ normalizePostProcessingConfig(config, explicitPostProcessingMode);
119
330
  if (!config.db_path) {
120
- config.db_path = join(getDataDir(), "recordings.db");
331
+ config.db_path = join2(getDataDir(), "recordings.db");
121
332
  }
122
333
  if (!config.audio_dir) {
123
- config.audio_dir = join(getDataDir(), "audio");
334
+ config.audio_dir = join2(getDataDir(), "audio");
335
+ }
336
+ return config;
337
+ }
338
+ function normalizeModelSlots(config) {
339
+ const warnings = config.config_warnings ?? [];
340
+ config.config_warnings = warnings;
341
+ const boundedModel = config.transcription_model?.trim() || DEFAULT_TRANSCRIPTION_MODEL;
342
+ if (isRealtimeOnlyModel(boundedModel)) {
343
+ warnings.push(`Ignoring RECORDINGS_MODEL=${boundedModel}; bounded transcription uses ${DEFAULT_TRANSCRIPTION_MODEL}.`);
344
+ config.transcription_model = DEFAULT_TRANSCRIPTION_MODEL;
345
+ } else {
346
+ config.transcription_model = boundedModel;
347
+ }
348
+ const realtimeSessionModel = config.realtime_session_model?.trim() || DEFAULT_REALTIME_SESSION_MODEL;
349
+ if (isTranscriptionOnlyModel(realtimeSessionModel)) {
350
+ warnings.push(`Ignoring realtime session model ${realtimeSessionModel}; use ${DEFAULT_REALTIME_TRANSCRIPTION_MODEL} as realtime_transcription_model instead.`);
351
+ config.realtime_session_model = DEFAULT_REALTIME_SESSION_MODEL;
352
+ } else {
353
+ config.realtime_session_model = realtimeSessionModel;
354
+ }
355
+ const realtimeTranscriptionModel = config.realtime_transcription_model?.trim() || DEFAULT_REALTIME_TRANSCRIPTION_MODEL;
356
+ if (!isRealtimeTranscriptionModel(realtimeTranscriptionModel)) {
357
+ warnings.push(`Ignoring realtime transcription model ${realtimeTranscriptionModel}; realtime transcription uses ${DEFAULT_REALTIME_TRANSCRIPTION_MODEL}.`);
358
+ config.realtime_transcription_model = DEFAULT_REALTIME_TRANSCRIPTION_MODEL;
359
+ } else {
360
+ config.realtime_transcription_model = realtimeTranscriptionModel;
124
361
  }
125
362
  return config;
126
363
  }
364
+ function isTranscriptionOnlyModel(model) {
365
+ const m = model.trim().toLowerCase();
366
+ return m === "whisper-1" || m === DEFAULT_REALTIME_TRANSCRIPTION_MODEL || m.includes("transcribe");
367
+ }
368
+ function isRealtimeOnlyModel(model) {
369
+ const m = model.trim().toLowerCase();
370
+ return m.startsWith("gpt-realtime");
371
+ }
372
+ function isRealtimeTranscriptionModel(model) {
373
+ const m = model.trim().toLowerCase();
374
+ return m === DEFAULT_REALTIME_TRANSCRIPTION_MODEL || m.startsWith("gpt-realtime") && m.includes("whisper");
375
+ }
376
+ function normalizePostProcessingMode(value, fallback = "auto") {
377
+ const mode = value?.trim().toLowerCase();
378
+ if (mode && POST_PROCESSING_MODES.has(mode)) {
379
+ return mode;
380
+ }
381
+ return fallback;
382
+ }
383
+ function normalizePostProcessingConfig(config, preferPostProcessingMode = true) {
384
+ if (preferPostProcessingMode) {
385
+ config.post_processing_mode = normalizePostProcessingMode(config.post_processing_mode, "auto");
386
+ config.auto_enhance = config.post_processing_mode !== "off";
387
+ return config;
388
+ }
389
+ if (config.auto_enhance === false) {
390
+ config.post_processing_mode = "off";
391
+ } else {
392
+ config.post_processing_mode = normalizePostProcessingMode(config.post_processing_mode, "auto");
393
+ }
394
+ config.auto_enhance = config.post_processing_mode !== "off";
395
+ return config;
396
+ }
397
+ function parseBooleanEnv(value, fallback) {
398
+ const normalized = value.trim().toLowerCase();
399
+ if (["1", "true", "yes", "on"].includes(normalized))
400
+ return true;
401
+ if (["0", "false", "no", "off"].includes(normalized))
402
+ return false;
403
+ return fallback;
404
+ }
127
405
  function expandEnvBackedConfig(config) {
128
406
  const expanded = { ...config };
129
407
  for (const key of ["openai_api_key", "enhancement_api_key"]) {
@@ -142,36 +420,68 @@ function getDataDir() {
142
420
  if (projectLocalDir)
143
421
  return projectLocalDir;
144
422
  const home = getHomeDir();
145
- const newDir = join(home, ".hasna", "recordings");
146
- const oldDir = join(home, ".recordings");
423
+ const newDir = join2(home, ".hasna", "recordings");
424
+ const oldDir = join2(home, ".recordings");
147
425
  if (existsSync(oldDir)) {
426
+ const releaseLease = acquireLocalStoreReaderLease();
148
427
  try {
149
- mergeDirectoryContents(oldDir, newDir);
150
- } catch {}
428
+ try {
429
+ if (existsSync(oldDir))
430
+ mergeDirectoryContents(oldDir, newDir);
431
+ } catch {}
432
+ } finally {
433
+ releaseLease();
434
+ }
151
435
  }
152
436
  return newDir;
153
437
  }
154
438
  function findProjectRecordingsPath(entry) {
155
- const home = resolve(getHomeDir());
156
- let dir = resolve(process.cwd());
439
+ const cwd = canonicalExistingPath(process.cwd());
440
+ const home = canonicalExistingPath(getHomeDir());
441
+ const repositoryRoot = findRepositoryRoot(cwd);
442
+ const cwdIsInsideHome = cwd === home || cwd.startsWith(`${home}${sep2}`);
443
+ const repositoryIsInsideHome = repositoryRoot !== null && repositoryRoot !== home && repositoryRoot.startsWith(`${home}${sep2}`);
444
+ const boundary = cwdIsInsideHome ? repositoryIsInsideHome ? repositoryRoot : home : repositoryRoot ?? cwd;
445
+ const excludeBoundary = boundary === home;
446
+ let dir = cwd;
157
447
  while (true) {
158
- if (dir !== home) {
159
- const candidate = entry ? join(dir, ".recordings", entry) : join(dir, ".recordings");
160
- if (existsSync(candidate))
161
- return candidate;
162
- }
163
- const parent = dirname(dir);
448
+ if (excludeBoundary && dir === boundary)
449
+ break;
450
+ const candidate = entry ? join2(dir, ".recordings", entry) : join2(dir, ".recordings");
451
+ if (existsSync(candidate))
452
+ return candidate;
453
+ if (dir === boundary)
454
+ break;
455
+ const parent = dirname2(dir);
164
456
  if (parent === dir)
165
457
  break;
166
458
  dir = parent;
167
459
  }
168
460
  return null;
169
461
  }
462
+ function findRepositoryRoot(start) {
463
+ let dir = start;
464
+ while (true) {
465
+ if (existsSync(join2(dir, ".git")))
466
+ return dir;
467
+ const parent = dirname2(dir);
468
+ if (parent === dir)
469
+ return null;
470
+ dir = parent;
471
+ }
472
+ }
473
+ function canonicalExistingPath(path) {
474
+ try {
475
+ return realpathSync2(path);
476
+ } catch {
477
+ return resolve2(path);
478
+ }
479
+ }
170
480
  function mergeDirectoryContents(sourceDir, targetDir) {
171
- mkdirSync(targetDir, { recursive: true });
481
+ mkdirSync2(targetDir, { recursive: true });
172
482
  for (const entry of readdirSync(sourceDir)) {
173
- const sourcePath = join(sourceDir, entry);
174
- const targetPath = join(targetDir, entry);
483
+ const sourcePath = join2(sourceDir, entry);
484
+ const targetPath = join2(targetDir, entry);
175
485
  const sourceStats = statSync(sourcePath);
176
486
  if (sourceStats.isDirectory()) {
177
487
  mergeDirectoryContents(sourcePath, targetPath);
@@ -181,7 +491,7 @@ function mergeDirectoryContents(sourceDir, targetDir) {
181
491
  }
182
492
  }
183
493
  function loadSecretKey(keyName) {
184
- const secretsPath = join(getHomeDir(), ".secrets");
494
+ const secretsPath = join2(getHomeDir(), ".secrets");
185
495
  if (!existsSync(secretsPath))
186
496
  return "";
187
497
  for (const candidate of listSecretFiles(secretsPath)) {
@@ -208,7 +518,7 @@ function listSecretFiles(path) {
208
518
  if (!stats.isDirectory())
209
519
  return [];
210
520
  return readdirSync(path).sort().flatMap((entry) => {
211
- const child = join(path, entry);
521
+ const child = join2(path, entry);
212
522
  try {
213
523
  const childStats = statSync(child);
214
524
  if (childStats.isDirectory())
@@ -223,14 +533,19 @@ function listSecretFiles(path) {
223
533
  }
224
534
  }
225
535
  function getHomeDir() {
226
- return process.env["HOME"] || process.env["USERPROFILE"] || homedir();
536
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
227
537
  }
228
538
  function ensureDataDir(config) {
229
- const { mkdirSync: mkdirSync2 } = __require("fs");
230
- mkdirSync2(config.audio_dir, { recursive: true });
539
+ const { mkdirSync: mkdirSync3 } = __require("fs");
231
540
  const dbDir = config.db_path.substring(0, config.db_path.lastIndexOf("/"));
232
- if (dbDir)
233
- mkdirSync2(dbDir, { recursive: true });
541
+ const releaseLease = [config.audio_dir, dbDir].some((path) => path.length > 0 && isGlobalRecordingsStatePath(path)) ? acquireLocalStoreReaderLease() : () => {};
542
+ try {
543
+ mkdirSync3(config.audio_dir, { recursive: true });
544
+ if (dbDir)
545
+ mkdirSync3(dbDir, { recursive: true });
546
+ } finally {
547
+ releaseLease();
548
+ }
234
549
  }
235
550
 
236
551
  // src/db/database.ts
@@ -319,8 +634,8 @@ function getDatabase(dbPath) {
319
634
  if (_db)
320
635
  return _db;
321
636
  const path = dbPath || loadConfig().db_path;
322
- const dir = dirname2(path);
323
- mkdirSync2(dir, { recursive: true });
637
+ const dir = dirname3(path);
638
+ mkdirSync3(dir, { recursive: true });
324
639
  _adapter = new SqliteAdapter(path);
325
640
  _db = _adapter.raw;
326
641
  _db.run("PRAGMA busy_timeout = 5000");
@@ -376,6 +691,63 @@ function shortUuid() {
376
691
  return crypto.randomUUID().slice(0, 8);
377
692
  }
378
693
 
694
+ // src/db/errors.ts
695
+ class ProjectNotFoundError extends Error {
696
+ ref;
697
+ constructor(ref) {
698
+ super(`project not found: ${ref}`);
699
+ this.name = "ProjectNotFoundError";
700
+ this.ref = ref;
701
+ }
702
+ }
703
+
704
+ class ValidationError extends Error {
705
+ constructor(message) {
706
+ super(message);
707
+ this.name = "ValidationError";
708
+ }
709
+ }
710
+
711
+ // src/lib/recording-create-identity.ts
712
+ var MAX_RECORDING_IDENTITY_LENGTH = 255;
713
+ function validateIdentityValue(value, label, nullMeansAbsent = false) {
714
+ if (value === undefined || nullMeansAbsent && value === null)
715
+ return;
716
+ if (typeof value !== "string") {
717
+ throw new ValidationError(`${label} must be a string`);
718
+ }
719
+ if (value.length === 0) {
720
+ throw new ValidationError(`${label} must not be empty`);
721
+ }
722
+ if (value.length > MAX_RECORDING_IDENTITY_LENGTH) {
723
+ throw new ValidationError(`${label} must not exceed ${MAX_RECORDING_IDENTITY_LENGTH} characters`);
724
+ }
725
+ if (value !== value.trim()) {
726
+ throw new ValidationError(`${label} must not contain leading or trailing whitespace`);
727
+ }
728
+ if (/[\u0000-\u001f\u007f]/.test(value)) {
729
+ throw new ValidationError(`${label} must not contain control characters`);
730
+ }
731
+ if (/[^\u0020-\u007e]/.test(value)) {
732
+ throw new ValidationError(`${label} must contain only printable ASCII characters`);
733
+ }
734
+ return value;
735
+ }
736
+ function recordingCreateIdentity(input, idempotencyKey, options = {}) {
737
+ const bodyId = validateIdentityValue(input.id, "recording id", true);
738
+ const headerKey = validateIdentityValue(idempotencyKey, "idempotency key");
739
+ if (bodyId !== undefined && headerKey !== undefined && bodyId !== headerKey) {
740
+ throw new ValidationError("recording id conflicts with idempotency key");
741
+ }
742
+ const effectiveKey = headerKey ?? bodyId;
743
+ const { id: _runtimeId, ...inputWithoutId } = input;
744
+ const persistedId = options.bindIdempotencyKeyToId === false ? bodyId : effectiveKey;
745
+ return {
746
+ input: persistedId === undefined ? inputWithoutId : { ...inputWithoutId, id: persistedId },
747
+ idempotencyKey: effectiveKey
748
+ };
749
+ }
750
+
379
751
  // src/db/recordings.ts
380
752
  function parseRow(row) {
381
753
  return {
@@ -400,20 +772,36 @@ function parseRow(row) {
400
772
  created_at: row["created_at"]
401
773
  };
402
774
  }
403
- function createRecording(input, db) {
775
+ function createRecording(input, db, idempotencyKey) {
404
776
  const d = db || getDatabase();
405
- const id = shortUuid();
406
- const tagsJson = JSON.stringify(input.tags || []);
407
- const metadataJson = JSON.stringify(input.metadata || {});
408
- 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)
409
- 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);
410
- if (input.tags && input.tags.length > 0) {
411
- const insertTag = d.query("INSERT OR IGNORE INTO recording_tags (recording_id, tag) VALUES (?, ?)");
412
- for (const tag of input.tags) {
413
- insertTag.run(id, tag);
777
+ input = recordingCreateIdentity(input, idempotencyKey).input;
778
+ const id = input.id || shortUuid();
779
+ const create = d.transaction(() => {
780
+ if (input.id) {
781
+ const existing = getRecording(input.id, d);
782
+ if (existing?.id === input.id)
783
+ return existing;
414
784
  }
415
- }
416
- return getRecording(id, d);
785
+ const tagsJson = JSON.stringify(input.tags || []);
786
+ const metadataJson = JSON.stringify(input.metadata || {});
787
+ 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)
788
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
789
+ 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);
790
+ if (insertResult.changes === 0) {
791
+ const existing = getRecording(id, d);
792
+ if (existing?.id === id)
793
+ return existing;
794
+ throw new Error("recording id conflict could not be read back");
795
+ }
796
+ if (input.tags && input.tags.length > 0) {
797
+ const insertTag = d.query("INSERT OR IGNORE INTO recording_tags (recording_id, tag) VALUES (?, ?)");
798
+ for (const tag of input.tags) {
799
+ insertTag.run(id, tag);
800
+ }
801
+ }
802
+ return getRecording(id, d);
803
+ });
804
+ return create();
417
805
  }
418
806
  function getRecording(id, db) {
419
807
  const d = db || getDatabase();
@@ -425,6 +813,20 @@ function getRecording(id, db) {
425
813
  }
426
814
  function listRecordings(filter, db) {
427
815
  const d = db || getDatabase();
816
+ const { where, params } = buildRecordingWhere(filter);
817
+ const limit = filter?.limit || 50;
818
+ const offset = filter?.offset || 0;
819
+ const sql = `SELECT * FROM recordings ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
820
+ const rows = d.query(sql).all(...params, limit, offset);
821
+ return rows.map(parseRow);
822
+ }
823
+ function countRecordings(filter, db) {
824
+ const d = db || getDatabase();
825
+ const { where, params } = buildRecordingWhere(filter);
826
+ const row = d.query(`SELECT COUNT(*) as c FROM recordings ${where}`).get(...params);
827
+ return row.c;
828
+ }
829
+ function buildRecordingWhere(filter) {
428
830
  const conditions = [];
429
831
  const params = [];
430
832
  if (filter?.agent_id) {
@@ -463,12 +865,7 @@ function listRecordings(filter, db) {
463
865
  params.push(filter.until);
464
866
  }
465
867
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
466
- const limit = filter?.limit || 50;
467
- const offset = filter?.offset || 0;
468
- const sql = `SELECT * FROM recordings ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
469
- params.push(limit, offset);
470
- const rows = d.query(sql).all(...params);
471
- return rows.map(parseRow);
868
+ return { where, params };
472
869
  }
473
870
  function deleteRecording(id, db) {
474
871
  const d = db || getDatabase();
@@ -541,16 +938,6 @@ function listProjects(db) {
541
938
  return rows.map(parseProject);
542
939
  }
543
940
 
544
- // src/db/errors.ts
545
- class ProjectNotFoundError extends Error {
546
- ref;
547
- constructor(ref) {
548
- super(`project not found: ${ref}`);
549
- this.name = "ProjectNotFoundError";
550
- this.ref = ref;
551
- }
552
- }
553
-
554
941
  // src/db/agents.ts
555
942
  function parseAgent(row) {
556
943
  return {
@@ -616,7 +1003,7 @@ function setAgentFocus(idOrName, projectId, db) {
616
1003
  }
617
1004
 
618
1005
  // src/version.ts
619
- var VERSION = "0.2.10";
1006
+ var VERSION = "0.2.13";
620
1007
 
621
1008
  // src/db/feedback.ts
622
1009
  function saveFeedback(input) {
@@ -909,6 +1296,7 @@ function resolveStorageClient(name, env = process.env, fetchImpl) {
909
1296
  }
910
1297
 
911
1298
  // src/store.ts
1299
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
912
1300
  var APP = "recordings";
913
1301
  function listQuery(filter) {
914
1302
  if (!filter)
@@ -918,6 +1306,7 @@ function listQuery(filter) {
918
1306
  project_id: filter.project_id,
919
1307
  session_id: filter.session_id,
920
1308
  processing_mode: filter.processing_mode,
1309
+ tags: filter.tags,
921
1310
  search: filter.search,
922
1311
  since: filter.since,
923
1312
  until: filter.until,
@@ -934,58 +1323,63 @@ function unwrap(res, key) {
934
1323
  var localStore = {
935
1324
  mode: "local",
936
1325
  baseUrl: null,
937
- async createRecording(input) {
938
- return createRecording(input);
1326
+ async createRecording(input, idempotencyKey) {
1327
+ return withLocalStoreReaderLease(() => createRecording(input, undefined, idempotencyKey));
939
1328
  },
940
1329
  async getRecording(id) {
941
- return getRecording(id);
1330
+ return withLocalStoreReaderLease(() => getRecording(id));
942
1331
  },
943
1332
  async listRecordings(filter) {
944
- return listRecordings(filter);
1333
+ return withLocalStoreReaderLease(() => listRecordings(filter));
1334
+ },
1335
+ async countRecordings(filter) {
1336
+ return withLocalStoreReaderLease(() => countRecordings(filter));
945
1337
  },
946
1338
  async searchRecordings(query, filter) {
947
- return searchRecordings(query, filter);
1339
+ return withLocalStoreReaderLease(() => searchRecordings(query, filter));
948
1340
  },
949
1341
  async deleteRecording(id) {
950
- return deleteRecording(id);
1342
+ return withLocalStoreReaderLease(() => deleteRecording(id));
951
1343
  },
952
1344
  async getRecordingStats() {
953
- return getRecordingStats();
1345
+ return withLocalStoreReaderLease(() => getRecordingStats());
954
1346
  },
955
1347
  async registerAgent(name, description, role) {
956
- return registerAgent(name, description, role);
1348
+ return withLocalStoreReaderLease(() => registerAgent(name, description, role));
957
1349
  },
958
1350
  async getAgent(idOrName) {
959
- return getAgent(idOrName);
1351
+ return withLocalStoreReaderLease(() => getAgent(idOrName));
960
1352
  },
961
1353
  async listAgents() {
962
- return listAgents();
1354
+ return withLocalStoreReaderLease(() => listAgents());
963
1355
  },
964
1356
  async heartbeatAgent(idOrName) {
965
- return heartbeatAgent(idOrName);
1357
+ return withLocalStoreReaderLease(() => heartbeatAgent(idOrName));
966
1358
  },
967
1359
  async setAgentFocus(idOrName, projectId) {
968
- return setAgentFocus(idOrName, projectId);
1360
+ return withLocalStoreReaderLease(() => setAgentFocus(idOrName, projectId));
969
1361
  },
970
1362
  async registerProject(name, path, description) {
971
- return registerProject(name, path, description);
1363
+ return withLocalStoreReaderLease(() => registerProject(name, path, description));
972
1364
  },
973
1365
  async getProject(idOrPath) {
974
- return getProject(idOrPath);
1366
+ return withLocalStoreReaderLease(() => getProject(idOrPath));
975
1367
  },
976
1368
  async listProjects() {
977
- return listProjects();
1369
+ return withLocalStoreReaderLease(() => listProjects());
978
1370
  },
979
1371
  async saveFeedback(input) {
980
- saveFeedback(input);
1372
+ await withLocalStoreReaderLease(() => saveFeedback(input));
981
1373
  }
982
1374
  };
983
1375
  function apiStore(client) {
984
1376
  return {
985
1377
  mode: "cloud-http",
986
1378
  baseUrl: client.baseUrl,
987
- async createRecording(input) {
988
- const res = await client.create("recordings", input);
1379
+ async createRecording(input, idempotencyKey) {
1380
+ const keyCandidate = idempotencyKey === undefined && (input.id === undefined || input.id === null) ? randomUUID2() : idempotencyKey;
1381
+ const identity = recordingCreateIdentity(input, keyCandidate, { bindIdempotencyKeyToId: false });
1382
+ const res = await client.create("recordings", identity.input, identity.idempotencyKey);
989
1383
  return unwrap(res, "recording");
990
1384
  },
991
1385
  async getRecording(id) {
@@ -996,6 +1390,36 @@ function apiStore(client) {
996
1390
  const { items } = await client.list("recordings", listQuery(filter));
997
1391
  return items;
998
1392
  },
1393
+ async countRecordings(filter) {
1394
+ const pageLimit = 500;
1395
+ const maxPageRequests = 1e4;
1396
+ let offset = 0;
1397
+ let pageRequests = 0;
1398
+ const seenPageKeys = new Set;
1399
+ while (pageRequests < maxPageRequests) {
1400
+ pageRequests += 1;
1401
+ const { items, raw } = await client.list("recordings", {
1402
+ ...listQuery(filter),
1403
+ limit: pageLimit,
1404
+ offset
1405
+ });
1406
+ const count = raw && typeof raw === "object" ? raw.count : undefined;
1407
+ if (typeof count !== "number" || !Number.isFinite(count)) {
1408
+ throw new Error("Recordings API response is missing a valid count");
1409
+ }
1410
+ if (count > items.length)
1411
+ return count;
1412
+ if (items.length === 0)
1413
+ return offset;
1414
+ offset += items.length;
1415
+ const pageKey = recordingPageFingerprint(items);
1416
+ if (seenPageKeys.has(pageKey)) {
1417
+ throw new Error("Recordings API ignored pagination while counting legacy results");
1418
+ }
1419
+ seenPageKeys.add(pageKey);
1420
+ }
1421
+ throw new Error(`Recordings API exceeded ${maxPageRequests} pages while counting legacy results`);
1422
+ },
999
1423
  async searchRecordings(query, filter) {
1000
1424
  const { items } = await client.list("recordings", listQuery({ ...filter ?? {}, search: query }));
1001
1425
  return items;
@@ -1077,6 +1501,17 @@ function apiStore(client) {
1077
1501
  }
1078
1502
  };
1079
1503
  }
1504
+ function recordingPageFingerprint(items) {
1505
+ const hash = createHash("sha256");
1506
+ const ids = items.map((item) => String(item.id ?? "")).sort();
1507
+ for (const id of ids) {
1508
+ hash.update(String(id.length));
1509
+ hash.update(":");
1510
+ hash.update(id);
1511
+ hash.update(";");
1512
+ }
1513
+ return `${items.length}:${hash.digest("hex")}`;
1514
+ }
1080
1515
  var cached = null;
1081
1516
  function getStore(env = process.env) {
1082
1517
  if (env === process.env && cached)