@hasna/recordings 0.2.13 → 0.3.1

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 (120) hide show
  1. package/Dockerfile.package +4 -5
  2. package/README.md +113 -8
  3. package/bun.lock +2 -2
  4. package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
  5. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
  6. package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
  7. package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
  8. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
  9. package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
  10. package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
  11. package/dist/__tests__/preload.d.ts +1 -4
  12. package/dist/__tests__/preload.d.ts.map +1 -1
  13. package/dist/cli/desktop-snapshot.d.ts +20 -0
  14. package/dist/cli/desktop-snapshot.d.ts.map +1 -0
  15. package/dist/cli/index.js +2078 -277
  16. package/dist/cli/macos-permissions.d.ts +72 -0
  17. package/dist/cli/macos-permissions.d.ts.map +1 -1
  18. package/dist/cli/macos-shortcut.d.ts +139 -0
  19. package/dist/cli/macos-shortcut.d.ts.map +1 -0
  20. package/dist/cli/trigger-probe.d.ts +172 -0
  21. package/dist/cli/trigger-probe.d.ts.map +1 -0
  22. package/dist/db/database.d.ts +9 -0
  23. package/dist/db/database.d.ts.map +1 -1
  24. package/dist/http/client.d.ts +10 -7
  25. package/dist/http/client.d.ts.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +634 -49
  29. package/dist/lib/capture-probe.d.ts +216 -0
  30. package/dist/lib/capture-probe.d.ts.map +1 -0
  31. package/dist/lib/config.d.ts.map +1 -1
  32. package/dist/lib/macos-bundle.d.ts +50 -0
  33. package/dist/lib/macos-bundle.d.ts.map +1 -0
  34. package/dist/lib/persistence-probe.d.ts +208 -0
  35. package/dist/lib/persistence-probe.d.ts.map +1 -0
  36. package/dist/lib/recorder.d.ts.map +1 -1
  37. package/dist/lib/retired-deployment-modes.d.ts +24 -0
  38. package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
  39. package/dist/lib/transcriber.d.ts +60 -0
  40. package/dist/lib/transcriber.d.ts.map +1 -1
  41. package/dist/mcp/index.js +140 -56
  42. package/dist/server/cloud-config.d.ts +25 -3
  43. package/dist/server/cloud-config.d.ts.map +1 -1
  44. package/dist/server/cloud-readiness.d.ts.map +1 -1
  45. package/dist/server/cloud.d.ts +8 -7
  46. package/dist/server/cloud.d.ts.map +1 -1
  47. package/dist/server/index.js +187 -79
  48. package/dist/server/migrate-command.d.ts.map +1 -1
  49. package/dist/server/serve.d.ts.map +1 -1
  50. package/dist/storage.d.ts +2 -2
  51. package/dist/storage.d.ts.map +1 -1
  52. package/dist/storage.js +108 -49
  53. package/dist/store.d.ts +1 -1
  54. package/dist/store.d.ts.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.d.ts.map +1 -1
  57. package/package.json +25 -6
  58. package/scripts/build_native_fs_guard.sh +15 -2
  59. package/scripts/ci-linux-suite.ts +403 -0
  60. package/scripts/ci-native-build.ts +402 -0
  61. package/scripts/enforce_identity_migration.sh +110 -0
  62. package/scripts/generate-sdk.ts +1 -1
  63. package/scripts/install_macos_app.sh +138 -24
  64. package/scripts/macos_artifact.ts +205 -33
  65. package/scripts/native/realtime-settle-probe/README.md +39 -0
  66. package/scripts/native/realtime-settle-probe/main.swift +135 -0
  67. package/scripts/policy/local-only-approved-targets.txt +21 -0
  68. package/scripts/read_local_only_targets.sh +203 -0
  69. package/scripts/scan-artifact.ts +40 -0
  70. package/scripts/set-version.ts +145 -0
  71. package/scripts/vacuity-manifest-gen.ts +96 -0
  72. package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
  73. package/scripts/vacuity-manifests/install-chain.tsv +13 -0
  74. package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
  75. package/scripts/vacuity-manifests/reorder.tsv +4 -0
  76. package/scripts/vacuity-manifests/source-side.tsv +15 -0
  77. package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
  78. package/scripts/vacuity-manifests/version-sites.tsv +61 -0
  79. package/scripts/vacuity-mutation-battery.ts +266 -0
  80. package/src/native/Recordings/App/MenuBarStatusView.swift +14 -7
  81. package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
  82. package/src/native/Recordings/App/SidebarView.swift +1 -1
  83. package/src/native/Recordings/Package.swift +24 -5
  84. package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
  85. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
  86. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  87. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
  88. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
  89. package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
  90. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
  91. package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
  92. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
  93. package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
  94. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
  95. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
  96. package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
  97. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
  98. package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
  99. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
  100. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
  101. package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
  102. package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
  103. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
  104. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
  105. package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
  106. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
  107. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
  108. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
  109. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
  110. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
  111. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
  112. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
  113. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
  114. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
  115. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
  116. package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
  117. package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
  118. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
  119. package/src/native/Recordings/build.sh +75 -9
  120. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
package/dist/index.js CHANGED
@@ -627,7 +627,8 @@ function findProjectRecordingsPath(entry) {
627
627
  function findRepositoryRoot(start) {
628
628
  let dir = start;
629
629
  while (true) {
630
- if (existsSync(join2(dir, ".git")))
630
+ const gitPath = join2(dir, ".git");
631
+ if (isRepositoryMarker(gitPath))
631
632
  return dir;
632
633
  const parent = dirname2(dir);
633
634
  if (parent === dir)
@@ -635,6 +636,15 @@ function findRepositoryRoot(start) {
635
636
  dir = parent;
636
637
  }
637
638
  }
639
+ function isRepositoryMarker(gitPath) {
640
+ try {
641
+ if (statSync(gitPath).isFile())
642
+ return true;
643
+ return existsSync(join2(gitPath, "HEAD"));
644
+ } catch {
645
+ return false;
646
+ }
647
+ }
638
648
  function canonicalExistingPath(path) {
639
649
  try {
640
650
  return realpathSync2(path);
@@ -663,14 +673,17 @@ function loadSecretKey(keyName) {
663
673
  try {
664
674
  const content = readFileSync(candidate, "utf-8");
665
675
  const match = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`));
666
- if (match)
667
- return match[1];
676
+ const doubleQuotedValue = match?.[1];
677
+ if (doubleQuotedValue !== undefined)
678
+ return doubleQuotedValue;
668
679
  const match2 = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`));
669
- if (match2)
670
- return match2[1];
680
+ const singleQuotedValue = match2?.[1];
681
+ if (singleQuotedValue !== undefined)
682
+ return singleQuotedValue;
671
683
  const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
672
- if (match3)
673
- return match3[1].trim().replace(/^["']|["']$/g, "");
684
+ const unquotedValue = match3?.[1];
685
+ if (unquotedValue !== undefined)
686
+ return unquotedValue.trim().replace(/^["']|["']$/g, "");
674
687
  } catch {}
675
688
  }
676
689
  return "";
@@ -795,6 +808,7 @@ var MIGRATIONS = [
795
808
  ALTER TABLE agents ADD COLUMN active_project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
796
809
  `
797
810
  ];
811
+ var CURRENT_MIGRATION_LEVEL = MIGRATIONS.length - 1;
798
812
  function getDatabase(dbPath) {
799
813
  if (_db)
800
814
  return _db;
@@ -818,7 +832,11 @@ function runMigrations(db) {
818
832
  const currentLevel = result?.max_id ?? -1;
819
833
  for (let i = currentLevel + 1;i < MIGRATIONS.length; i++) {
820
834
  try {
821
- db.run(MIGRATIONS[i]);
835
+ const migration = MIGRATIONS[i];
836
+ if (migration === undefined) {
837
+ throw new Error(`Missing SQLite migration at version ${i}`);
838
+ }
839
+ db.run(migration);
822
840
  db.query("INSERT INTO _migrations (id) VALUES (?)").run(i);
823
841
  } catch (e) {
824
842
  if (isBenignMigrationError(e)) {
@@ -1168,7 +1186,7 @@ function setAgentFocus(idOrName, projectId, db) {
1168
1186
  }
1169
1187
 
1170
1188
  // src/version.ts
1171
- var VERSION = "0.2.13";
1189
+ var VERSION = "0.3.1";
1172
1190
 
1173
1191
  // src/db/feedback.ts
1174
1192
  function saveFeedback(input) {
@@ -1176,29 +1194,54 @@ function saveFeedback(input) {
1176
1194
  db.query("INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)").run(input.message, input.email ?? null, input.category ?? "general", input.version ?? VERSION);
1177
1195
  }
1178
1196
 
1197
+ // src/lib/retired-deployment-modes.ts
1198
+ var RETIRED_DEPLOYMENT_MODES = [
1199
+ "local",
1200
+ "self_hosted",
1201
+ "cloud",
1202
+ "remote",
1203
+ "hybrid"
1204
+ ];
1205
+ function normalizeModeToken(value) {
1206
+ return value.trim().toLowerCase().replace(/-/g, "_");
1207
+ }
1208
+ function asRetiredDeploymentMode(value) {
1209
+ const token = normalizeModeToken(value);
1210
+ return RETIRED_DEPLOYMENT_MODES.includes(token) ? token : null;
1211
+ }
1212
+ function retiredDeploymentModeError(rawValue, sourceEnvKey, replacement) {
1213
+ const mode = asRetiredDeploymentMode(rawValue);
1214
+ const value = mode === "local" ? replacement.onBox : replacement.offBox;
1215
+ return new Error(`${sourceEnvKey}=${rawValue} names a deployment mode, and deployment modes are removed: ` + `${RETIRED_DEPLOYMENT_MODES.join(" | ")} no longer select anything. ` + `Set ${replacement.envKey}=${value} instead ` + `(${replacement.envKey} takes ${replacement.onBox} or ${replacement.offBox}).`);
1216
+ }
1217
+
1179
1218
  // src/http/client.ts
1180
- var DEPRECATED_MODE_ALIASES = ["self_hosted", "remote", "hybrid"];
1219
+ var SERVER_BACKEND_VALUES = ["sqlite", "postgresql", "postgres"];
1181
1220
  function envToken(name) {
1182
1221
  return name.toUpperCase().replace(/-/g, "_");
1183
1222
  }
1184
- function normalizeMode(value) {
1185
- const normalized = value.trim().toLowerCase().replace(/-/g, "_");
1186
- if (normalized === "local")
1187
- return { mode: "local", deprecatedAlias: null };
1188
- if (normalized === "cloud")
1189
- return { mode: "cloud", deprecatedAlias: null };
1190
- if (DEPRECATED_MODE_ALIASES.includes(normalized)) {
1191
- return { mode: "cloud", deprecatedAlias: normalized };
1223
+ function clientStoreReplacement(envKey) {
1224
+ return { envKey, onBox: "sqlite", offBox: "http" };
1225
+ }
1226
+ function normalizeClientStore(value, sourceEnvKey, replacementEnvKey) {
1227
+ const normalized = normalizeModeToken(value);
1228
+ if (normalized === "sqlite")
1229
+ return "sqlite";
1230
+ if (normalized === "http" || normalized === "https")
1231
+ return "http";
1232
+ if (asRetiredDeploymentMode(value)) {
1233
+ throw retiredDeploymentModeError(value, sourceEnvKey, clientStoreReplacement(replacementEnvKey));
1192
1234
  }
1193
- throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`);
1235
+ throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
1194
1236
  }
1195
- function defaultCloudBaseUrl(name) {
1237
+ function defaultApiBaseUrl(name) {
1196
1238
  return `https://${name}.hasna.xyz`;
1197
1239
  }
1198
1240
  function envKeys(name) {
1199
1241
  const token = envToken(name);
1200
1242
  return {
1201
- modeKeys: [`HASNA_${token}_STORAGE_MODE`, `HASNA_${token}_MODE`, `${token}_STORAGE_MODE`, `${token}_MODE`],
1243
+ storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
1244
+ retiredModeKeys: [`HASNA_${token}_STORAGE_MODE`, `HASNA_${token}_MODE`, `${token}_STORAGE_MODE`, `${token}_MODE`],
1202
1245
  apiUrlKeys: [`HASNA_${token}_API_URL`, `${token}_API_URL`],
1203
1246
  apiKeyKeys: [`HASNA_${token}_API_KEY`, `${token}_API_KEY`]
1204
1247
  };
@@ -1211,6 +1254,20 @@ function firstEnv(env, keys) {
1211
1254
  }
1212
1255
  return null;
1213
1256
  }
1257
+ function assertNoRetiredMode(env, keys) {
1258
+ for (const key of keys.retiredModeKeys) {
1259
+ const value = env[key]?.trim();
1260
+ if (!value)
1261
+ continue;
1262
+ const normalized = normalizeModeToken(value);
1263
+ if (SERVER_BACKEND_VALUES.includes(normalized))
1264
+ continue;
1265
+ if (asRetiredDeploymentMode(value)) {
1266
+ throw retiredDeploymentModeError(value, key, clientStoreReplacement(keys.storeKeys[0]));
1267
+ }
1268
+ throw new Error(`${key}=${value} is not a client store. ${key} no longer selects one; ` + `set ${keys.storeKeys[0]}=sqlite or ${keys.storeKeys[0]}=http instead.`);
1269
+ }
1270
+ }
1214
1271
  function toV1BaseUrl(apiUrl) {
1215
1272
  const url = new URL(apiUrl);
1216
1273
  if (url.protocol !== "http:" && url.protocol !== "https:") {
@@ -1226,45 +1283,42 @@ function toV1BaseUrl(apiUrl) {
1226
1283
  }
1227
1284
  function resolveTransport(name, env = process.env) {
1228
1285
  const keys = envKeys(name);
1229
- const modeHit = firstEnv(env, keys.modeKeys);
1286
+ assertNoRetiredMode(env, keys);
1287
+ const storeHit = firstEnv(env, keys.storeKeys);
1230
1288
  const urlHit = firstEnv(env, keys.apiUrlKeys);
1231
1289
  const keyHit = firstEnv(env, keys.apiKeyKeys);
1232
- let mode = "local";
1233
- let deprecatedAlias = null;
1290
+ let requested = "sqlite";
1234
1291
  let modeSource = "default";
1235
- if (modeHit) {
1236
- const normalized = normalizeMode(modeHit.value);
1237
- mode = normalized.mode;
1238
- deprecatedAlias = normalized.deprecatedAlias;
1239
- modeSource = modeHit.key;
1292
+ if (storeHit) {
1293
+ requested = normalizeClientStore(storeHit.value, storeHit.key, keys.storeKeys[0]);
1294
+ modeSource = storeHit.key;
1240
1295
  } else if (urlHit && keyHit) {
1241
- mode = "cloud";
1296
+ requested = "http";
1242
1297
  modeSource = "auto:api-url+api-key";
1243
1298
  }
1244
- if (mode === "local") {
1245
- return { transport: "local", mode, deprecatedAlias, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
1299
+ if (requested === "sqlite") {
1300
+ return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
1246
1301
  }
1247
1302
  if (!keyHit) {
1248
1303
  return {
1249
- transport: "local",
1250
- mode,
1251
- deprecatedAlias,
1304
+ transport: "sqlite",
1305
+ requested,
1252
1306
  modeSource,
1253
1307
  baseUrl: null,
1254
1308
  apiKeyPresent: false,
1255
1309
  misconfigured: true,
1256
- warning: `${modeSource}=cloud but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to cloud.`
1310
+ warning: `${modeSource}=http but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to the API.`
1257
1311
  };
1258
1312
  }
1259
- const rawUrl = urlHit?.value ?? defaultCloudBaseUrl(name);
1313
+ const rawUrl = urlHit?.value ?? defaultApiBaseUrl(name);
1260
1314
  let baseUrl;
1261
1315
  try {
1262
1316
  baseUrl = toV1BaseUrl(rawUrl);
1263
1317
  } catch (error) {
1264
1318
  const message = error instanceof Error ? error.message : String(error);
1265
- return { transport: "local", mode, deprecatedAlias, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
1319
+ return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
1266
1320
  }
1267
- return { transport: "cloud-http", mode, deprecatedAlias, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
1321
+ return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
1268
1322
  }
1269
1323
 
1270
1324
  class HasnaHttpError extends Error {
@@ -1376,6 +1430,8 @@ function createHttpTransport(options) {
1376
1430
  const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
1377
1431
  await sleep(backoff + jitter);
1378
1432
  }
1433
+ if (last === null)
1434
+ throw new Error(`Request to ${rel} completed without a result`);
1379
1435
  throw last.error;
1380
1436
  }
1381
1437
  return {
@@ -1447,17 +1503,17 @@ function createStorageClient(name, transport) {
1447
1503
  function resolveStorageClient(name, env = process.env, fetchImpl) {
1448
1504
  const resolution = resolveTransport(name, env);
1449
1505
  if (resolution.misconfigured) {
1450
- throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for cloud mode.`);
1506
+ throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the /v1 API.`);
1451
1507
  }
1452
- if (resolution.transport === "local" || !resolution.baseUrl) {
1453
- return { transport: "local", client: null, resolution };
1508
+ if (resolution.transport === "sqlite" || !resolution.baseUrl) {
1509
+ return { transport: "sqlite", client: null, resolution };
1454
1510
  }
1455
1511
  const keys = envKeys(name);
1456
1512
  const apiKey = firstEnv(env, keys.apiKeyKeys)?.value;
1457
1513
  if (!apiKey)
1458
- throw new Error(`Client for '${name}' resolved to cloud-http without an API key.`);
1514
+ throw new Error(`Client for '${name}' resolved to the /v1 API without an API key.`);
1459
1515
  const transport = createHttpTransport({ name, baseUrl: resolution.baseUrl, apiKey, ...fetchImpl ? { fetchImpl } : {} });
1460
- return { transport: "cloud-http", client: createStorageClient(name, transport), resolution };
1516
+ return { transport: "http", client: createStorageClient(name, transport), resolution };
1461
1517
  }
1462
1518
 
1463
1519
  // src/store.ts
@@ -1481,12 +1537,15 @@ function listQuery(filter) {
1481
1537
  }
1482
1538
  function unwrap(res, key) {
1483
1539
  if (res && typeof res === "object" && key in res) {
1484
- return res[key];
1540
+ const value = res[key];
1541
+ if (value === undefined)
1542
+ throw new Error(`Response field ${key} is undefined`);
1543
+ return value;
1485
1544
  }
1486
1545
  return res;
1487
1546
  }
1488
1547
  var localStore = {
1489
- mode: "local",
1548
+ mode: "sqlite",
1490
1549
  baseUrl: null,
1491
1550
  async createRecording(input, idempotencyKey) {
1492
1551
  return withLocalStoreReaderLease(() => createRecording(input, undefined, idempotencyKey));
@@ -1539,7 +1598,7 @@ var localStore = {
1539
1598
  };
1540
1599
  function apiStore(client) {
1541
1600
  return {
1542
- mode: "cloud-http",
1601
+ mode: "http",
1543
1602
  baseUrl: client.baseUrl,
1544
1603
  async createRecording(input, idempotencyKey) {
1545
1604
  const keyCandidate = idempotencyKey === undefined && (input.id === undefined || input.id === null) ? randomUUID2() : idempotencyKey;
@@ -1682,7 +1741,7 @@ function getStore(env = process.env) {
1682
1741
  if (env === process.env && cached)
1683
1742
  return cached;
1684
1743
  const resolved = resolveStorageClient(APP, env);
1685
- const store = resolved.transport === "cloud-http" ? apiStore(resolved.client) : localStore;
1744
+ const store = resolved.transport === "http" ? apiStore(resolved.client) : localStore;
1686
1745
  if (env === process.env)
1687
1746
  cached = store;
1688
1747
  return store;
@@ -1709,6 +1768,9 @@ function resetClient() {
1709
1768
  _client = null;
1710
1769
  _clientApiKey = null;
1711
1770
  }
1771
+ function redactKeyMaterial(text) {
1772
+ return text.replace(/\b(sk|rk|org)-[A-Za-z0-9_*\-]{4,}/g, "[redacted]").replace(/\bBearer\s+[A-Za-z0-9._*\-]{4,}/gi, "Bearer [redacted]").replace(/\bBasic\s+[A-Za-z0-9+/=]{8,}/gi, "Basic [redacted]").replace(/:\/\/[^/\s:@]+(?::[^/\s@]*)?@/g, "://[redacted]@").replace(/\b(x-api-key|api[-_]?key|authorization|token|secret|password)(\s*[:=]\s*)("?)[^\s"',;}&]+\3/gi, (_match, name, separator) => `${name}${separator}[redacted]`);
1773
+ }
1712
1774
  async function transcribeAudio(audioPath, config, options = {}) {
1713
1775
  const client = getClient(config);
1714
1776
  const startTime = Date.now();
@@ -1998,10 +2060,14 @@ function startRecording(config) {
1998
2060
  const filename = `recording-${timestamp}.${config.audio_format}`;
1999
2061
  const filepath = join3(config.audio_dir, filename);
2000
2062
  const args = buildRecordArgs(filepath, config);
2063
+ const [command, ...commandArgs] = args;
2064
+ if (command === undefined) {
2065
+ throw new RecordingError("Recording command is empty.");
2066
+ }
2001
2067
  const releaseLease = isGlobalRecordingsStatePath(filepath) ? acquireLocalStoreReaderLease() : () => {};
2002
2068
  let recordProcess;
2003
2069
  try {
2004
- recordProcess = spawn(args[0], args.slice(1), {
2070
+ recordProcess = spawn(command, commandArgs, {
2005
2071
  stdio: ["pipe", "pipe", "pipe"]
2006
2072
  });
2007
2073
  } catch (error) {
@@ -2100,21 +2166,527 @@ async function recordDuration(seconds, config) {
2100
2166
  releaseLease();
2101
2167
  }
2102
2168
  }
2169
+ // src/lib/capture-probe.ts
2170
+ import { spawnSync as spawnSync2 } from "child_process";
2171
+ import { existsSync as existsSync3, readFileSync as readFileSync2, rmSync as rmSync2 } from "fs";
2172
+ import { join as join4 } from "path";
2173
+ import { tmpdir } from "os";
2174
+
2175
+ // src/lib/macos-bundle.ts
2176
+ var RECORDINGS_BUNDLE_IDENTIFIER = "com.hasna.recordings";
2177
+ var TCC_DATABASE_UNREADABLE_STATE = "undetermined_tcc_database_unreadable";
2178
+
2179
+ // src/lib/capture-probe.ts
2180
+ var RIFF_HEADER_BYTES = 12;
2181
+ var CHUNK_HEADER_BYTES = 8;
2182
+ var INT16_FULL_SCALE = 32768;
2183
+ var FMT_CHUNK_MIN_BYTES = 16;
2184
+ var WAVE_FORMAT_PCM = 1;
2185
+ var WAVE_FORMAT_EXTENSIBLE = 65534;
2186
+ var SUBFORMAT_OFFSET_IN_EXTENSION = 8;
2187
+ function readWavPeak(filepath) {
2188
+ const buf = readFileSync2(filepath);
2189
+ if (buf.length < RIFF_HEADER_BYTES) {
2190
+ throw new Error(`not a RIFF file (${buf.length} bytes): ${filepath}`);
2191
+ }
2192
+ if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") {
2193
+ throw new Error(`not a RIFF/WAVE file: ${filepath}`);
2194
+ }
2195
+ let offset = RIFF_HEADER_BYTES;
2196
+ let bitsPerSample = null;
2197
+ let formatTag = null;
2198
+ let dataStart = -1;
2199
+ let dataLength = 0;
2200
+ while (offset + CHUNK_HEADER_BYTES <= buf.length) {
2201
+ const chunkId = buf.toString("ascii", offset, offset + 4);
2202
+ const chunkSize = buf.readUInt32LE(offset + 4);
2203
+ const body = offset + CHUNK_HEADER_BYTES;
2204
+ if (chunkId === "fmt " && formatTag === null) {
2205
+ if (chunkSize < FMT_CHUNK_MIN_BYTES || body + FMT_CHUNK_MIN_BYTES > buf.length) {
2206
+ throw new Error(`truncated fmt chunk (${chunkSize} bytes, need ${FMT_CHUNK_MIN_BYTES}): ${filepath}`);
2207
+ }
2208
+ formatTag = buf.readUInt16LE(body);
2209
+ bitsPerSample = buf.readUInt16LE(body + 14);
2210
+ if (formatTag === WAVE_FORMAT_EXTENSIBLE) {
2211
+ const extensionStart = body + FMT_CHUNK_MIN_BYTES;
2212
+ const subFormatAt = extensionStart + SUBFORMAT_OFFSET_IN_EXTENSION;
2213
+ if (chunkSize < FMT_CHUNK_MIN_BYTES + 10 || subFormatAt + 2 > buf.length) {
2214
+ throw new Error(`WAVE_FORMAT_EXTENSIBLE without a readable SubFormat GUID: ${filepath}`);
2215
+ }
2216
+ formatTag = buf.readUInt16LE(subFormatAt);
2217
+ }
2218
+ } else if (chunkId === "data" && dataStart < 0) {
2219
+ dataStart = body;
2220
+ const remaining = buf.length - body;
2221
+ dataLength = chunkSize === 0 ? remaining : Math.min(chunkSize, remaining);
2222
+ }
2223
+ const declaredAdvance = chunkSize + chunkSize % 2;
2224
+ offset = body + Math.min(declaredAdvance, Math.max(0, buf.length - body));
2225
+ }
2226
+ if (dataStart < 0) {
2227
+ throw new Error(`no data chunk in WAV: ${filepath}`);
2228
+ }
2229
+ if (bitsPerSample === null || formatTag === null) {
2230
+ throw new Error(`no fmt chunk in WAV, so the sample format is unknown: ${filepath}`);
2231
+ }
2232
+ if (formatTag !== WAVE_FORMAT_PCM) {
2233
+ throw new Error(`expected uncompressed PCM (format tag ${WAVE_FORMAT_PCM}), got tag ${formatTag}: ${filepath}`);
2234
+ }
2235
+ if (bitsPerSample !== 16) {
2236
+ throw new Error(`expected 16-bit PCM, got ${bitsPerSample}-bit: ${filepath}`);
2237
+ }
2238
+ const samples = Math.floor(dataLength / 2);
2239
+ let maxAbs = 0;
2240
+ for (let i = 0;i < samples; i++) {
2241
+ const value = Math.abs(buf.readInt16LE(dataStart + i * 2));
2242
+ if (value > maxAbs)
2243
+ maxAbs = value;
2244
+ }
2245
+ return { samples, peak: maxAbs / INT16_FULL_SCALE };
2246
+ }
2247
+ var DEFAULT_PROBE_SECONDS = 1;
2248
+ var MAX_PROBE_SECONDS = 60;
2249
+ var DEFAULT_RECORD_EXECUTABLE = "rec";
2250
+ var SILENCE_PEAK_THRESHOLD = 0.001;
2251
+ var PROBE_TIMEOUT_GRACE_MS = 1e4;
2252
+ function probeMicrophoneCapture(config, options = {}) {
2253
+ const requested = options.seconds ?? DEFAULT_PROBE_SECONDS;
2254
+ const seconds = Math.min(Math.max(requested, 1), MAX_PROBE_SECONDS);
2255
+ const executable = options.executable ?? DEFAULT_RECORD_EXECUTABLE;
2256
+ const base = {
2257
+ tool: null,
2258
+ seconds,
2259
+ samples: 0,
2260
+ peak: 0,
2261
+ silent: null
2262
+ };
2263
+ const filepath = join4(tmpdir(), `recordings-capture-probe-${process.pid}-${Date.now()}.wav`);
2264
+ try {
2265
+ const result = spawnSync2(executable, [
2266
+ "-q",
2267
+ "-r",
2268
+ String(config.sample_rate),
2269
+ "-c",
2270
+ "1",
2271
+ "-b",
2272
+ "16",
2273
+ filepath,
2274
+ "trim",
2275
+ "0",
2276
+ String(seconds)
2277
+ ], {
2278
+ stdio: ["ignore", "ignore", "pipe"],
2279
+ encoding: "utf8",
2280
+ timeout: options.timeoutMs ?? seconds * 1000 + PROBE_TIMEOUT_GRACE_MS
2281
+ });
2282
+ if (result.signal) {
2283
+ return {
2284
+ ...base,
2285
+ ok: false,
2286
+ message: `'${executable}' did not finish within ` + `${((options.timeoutMs ?? seconds * 1000 + PROBE_TIMEOUT_GRACE_MS) / 1000).toFixed(1)}s ` + `and was killed (${result.signal}). The input device is most likely busy or absent.`
2287
+ };
2288
+ }
2289
+ if (result.error) {
2290
+ return {
2291
+ ...base,
2292
+ ok: false,
2293
+ message: `could not run '${executable}': ${result.error.message}`
2294
+ };
2295
+ }
2296
+ if (!existsSync3(filepath)) {
2297
+ const stderr = (result.stderr || "").trim();
2298
+ return {
2299
+ ...base,
2300
+ ok: false,
2301
+ message: `'${executable}' wrote no audio file${stderr ? `: ${stderr}` : ""}`
2302
+ };
2303
+ }
2304
+ let peak;
2305
+ try {
2306
+ peak = readWavPeak(filepath);
2307
+ } catch (error) {
2308
+ return {
2309
+ ...base,
2310
+ ok: false,
2311
+ message: `could not read captured audio: ${error.message}`
2312
+ };
2313
+ }
2314
+ const tool = executable;
2315
+ if (peak.samples === 0) {
2316
+ return {
2317
+ ...base,
2318
+ tool,
2319
+ ok: false,
2320
+ message: "capture produced no samples"
2321
+ };
2322
+ }
2323
+ if (peak.peak <= SILENCE_PEAK_THRESHOLD) {
2324
+ const subject = captureProbeSubject();
2325
+ return {
2326
+ ...base,
2327
+ tool,
2328
+ samples: peak.samples,
2329
+ peak: peak.peak,
2330
+ ok: false,
2331
+ silent: true,
2332
+ message: `captured ${peak.samples} samples with no usable signal ` + `(peak ${peak.peak.toFixed(6)}, at or below the ${SILENCE_PEAK_THRESHOLD} silence threshold). ` + (process.platform === "darwin" ? "On macOS this is what a process WITHOUT the Microphone permission receives \u2014 " + "CoreAudio zero-fills its buffers instead of returning an error. " + "Also rule out a hardware-muted or unselected input device. " : "The input device delivered no signal \u2014 check that it is selected and not muted. ") + `This probe's capture is attributed to ${subject.subject}. ${subject.note}`
2333
+ };
2334
+ }
2335
+ return {
2336
+ tool,
2337
+ seconds,
2338
+ samples: peak.samples,
2339
+ peak: peak.peak,
2340
+ silent: false,
2341
+ ok: true,
2342
+ message: `captured ${peak.samples} samples, peak amplitude ${peak.peak.toFixed(6)} ` + `(proves capture for ${captureProbeSubject().subject})`
2343
+ };
2344
+ } finally {
2345
+ rmSync2(filepath, { force: true });
2346
+ }
2347
+ }
2348
+ function captureProbeSubject(env = process.env, options = {}) {
2349
+ const platform = options.platform ?? process.platform;
2350
+ if (platform !== "darwin") {
2351
+ return {
2352
+ headless: false,
2353
+ subject_known: true,
2354
+ subject: "this process",
2355
+ note: "microphone access is not gated by TCC on this platform"
2356
+ };
2357
+ }
2358
+ const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
2359
+ const inTmux = Boolean(env.TMUX || env.TMUX_PANE);
2360
+ const termProgram = env.TERM_PROGRAM?.trim();
2361
+ const hasTty = options.hasTty ?? Boolean(process.stdin.isTTY || process.stdout.isTTY);
2362
+ if (overSsh) {
2363
+ const subject2 = "the SSH session (sshd), not Recordings.app";
2364
+ return {
2365
+ headless: true,
2366
+ subject_known: true,
2367
+ subject: subject2,
2368
+ note: "Running over SSH: macOS cannot display a consent prompt to a session with no GUI, " + "so Microphone stays not_determined and a silent capture here says NOTHING about " + "whether Recordings.app can record. Judge the app by its own TCC entry and its log."
2369
+ };
2370
+ }
2371
+ if (inTmux) {
2372
+ const subject2 = "the tmux server, not Recordings.app and not this pane's shell";
2373
+ return {
2374
+ headless: false,
2375
+ subject_known: true,
2376
+ subject: subject2,
2377
+ note: "Running inside tmux: TCC attributes this capture to the tmux binary, which holds its " + "own grant. tmux also snapshots the environment at pane creation, so SSH variables may " + "be missing even in a remote session \u2014 treat a silent result as inconclusive about both " + "Recordings.app and about whether anyone could have been prompted."
2378
+ };
2379
+ }
2380
+ if (!termProgram && !hasTty) {
2381
+ return {
2382
+ headless: true,
2383
+ subject_known: false,
2384
+ subject: "an unidentified responsible process (not Recordings.app)",
2385
+ note: "The responsible process could not be identified: no SSH variables, no TERM_PROGRAM and " + "no tty, which is what launchd, cron, CI and sudo look like. Whatever holds the grant, it " + "is not Recordings.app, and nothing here can be shown a consent prompt. Inconclusive."
2386
+ };
2387
+ }
2388
+ const subject = `${termProgram || "the terminal application running this command"}, not Recordings.app`;
2389
+ return {
2390
+ headless: false,
2391
+ subject_known: Boolean(termProgram),
2392
+ subject,
2393
+ note: `Grants are per responsible process: this probe exercises ${subject}. ` + "A pass proves the microphone hardware and the input device work; it does not " + "transfer to Recordings.app, which needs its own grant."
2394
+ };
2395
+ }
2396
+ var TCC_UNREADABLE_STATE = TCC_DATABASE_UNREADABLE_STATE;
2397
+ function classifyPermissionState(permissionState) {
2398
+ switch (permissionState) {
2399
+ case "allowed":
2400
+ case "allowed_identity_unverified":
2401
+ case "denied":
2402
+ case "limited":
2403
+ case "unknown":
2404
+ case "stale_allowed_for_previous_app_build":
2405
+ case "unverified_no_installed_bundle":
2406
+ return "requested";
2407
+ case "not_determined":
2408
+ return "never_requested";
2409
+ default:
2410
+ break;
2411
+ }
2412
+ if (permissionState.startsWith("unknown("))
2413
+ return "requested";
2414
+ if (permissionState.endsWith("_identity_unverified"))
2415
+ return "requested";
2416
+ return "unknown";
2417
+ }
2418
+ function microphoneGrantInstruction(options) {
2419
+ const candidates = [options.installedAppPath, ...options.otherAppPaths ?? []].filter((path) => Boolean(path)).filter((path, index, all) => all.indexOf(path) === index).filter((path) => existsSync3(path));
2420
+ const bundlePath = candidates[0] ?? null;
2421
+ const steps = [];
2422
+ if (!bundlePath) {
2423
+ steps.push("No Recordings.app bundle was found on disk, so there is nothing to grant Microphone to yet. " + "Install the app first ('recordings app install').");
2424
+ return {
2425
+ bundle_path: null,
2426
+ bundle_identifier: RECORDINGS_BUNDLE_IDENTIFIER,
2427
+ candidate_bundle_paths: candidates,
2428
+ steps
2429
+ };
2430
+ }
2431
+ if (candidates.length > 1) {
2432
+ steps.push(`AMBIGUOUS: ${candidates.length} Recordings.app bundles exist (${candidates.join(", ")}). ` + "A TCC grant is bound to the bundle's code signature, so granting one does not grant the " + "other, and the toggle in Settings does not say which is which. Remove the bundles you are " + "not running before granting, or the grant may attach to the wrong one.");
2433
+ }
2434
+ steps.push(`At the keyboard on the machine itself (not over SSH), launch ${bundlePath} and start a ` + "recording once. macOS shows the consent sheet titled " + `"\u201CRecordings\u201D would like to access the microphone" \u2014 click Allow.`);
2435
+ steps.push("If no sheet appears, open System Settings \u2192 Privacy & Security \u2192 Microphone " + "and switch ON the row named \u201CRecordings\u201D. " + `That row is bundle ${RECORDINGS_BUNDLE_IDENTIFIER} at ${bundlePath}; the binary that ` + `receives the grant is ${join4(bundlePath, "Contents", "MacOS", "Recordings")}.`);
2436
+ if (options.requestState === "never_requested") {
2437
+ steps.push("Note: the app has never requested microphone access on this machine (no TCC entry exists), " + "so the Microphone list will NOT contain a \u201CRecordings\u201D row until the app asks once. " + "Do the launch-and-record step first; the Settings toggle only exists afterwards.");
2438
+ } else if (options.requestState === "unknown") {
2439
+ steps.push("Note: whether the app has already asked could NOT be determined \u2014 the permission state " + `did not resolve (e.g. ${TCC_UNREADABLE_STATE}: the user TCC database could not be read). ` + "So a \u201CRecordings\u201D row may or may not already be in the Microphone list. Full Disk Access " + "is inherited from the session's responsible process, not granted per-tool, so re-read the " + "state from a plain ssh shell (whose parent holds it) rather than granting it to tmux or " + "bun \u2014 and not under sudo, which changes the responsible process. Do not conclude anything " + "from this state alone.");
2440
+ }
2441
+ steps.push("Confirm afterwards with 'recordings app status --json' \u2014 microphone_permission must read " + "allowed, and a fresh line in ~/.hasna/recordings/Recordings.log must read " + "'RecordingEngine init; microphone=Microphone allowed'.");
2442
+ steps.push("The grant is bound to the bundle's code signature: re-signing or rebuilding the app with a " + "different identity voids it and this instruction has to be repeated.");
2443
+ return {
2444
+ bundle_path: bundlePath,
2445
+ bundle_identifier: RECORDINGS_BUNDLE_IDENTIFIER,
2446
+ candidate_bundle_paths: candidates,
2447
+ steps
2448
+ };
2449
+ }
2450
+ async function captureProbePrecondition() {
2451
+ const deps = await checkRecordingDeps();
2452
+ return deps.available ? { ready: true, message: deps.message } : { ready: false, message: deps.message };
2453
+ }
2454
+ // src/lib/persistence-probe.ts
2455
+ import { Database as Database2 } from "bun:sqlite";
2456
+ import { existsSync as existsSync4 } from "fs";
2457
+ var AUTO_FLIP_MODE_SOURCE = "auto:api-url+api-key";
2458
+ function safeBaseUrl(baseUrl) {
2459
+ if (!baseUrl)
2460
+ return baseUrl;
2461
+ try {
2462
+ const url = new URL(baseUrl);
2463
+ if (!url.username && !url.password)
2464
+ return baseUrl;
2465
+ url.username = "";
2466
+ url.password = "";
2467
+ return `${url.toString().replace(/\/+$/, "")} (credentials in the URL redacted)`;
2468
+ } catch {
2469
+ return "[unparseable URL redacted]";
2470
+ }
2471
+ }
2472
+ function readLocalRecordingCount(dbPath) {
2473
+ try {
2474
+ const db = new Database2(dbPath, { readonly: true });
2475
+ try {
2476
+ const row = db.query("SELECT COUNT(*) AS count FROM recordings").get();
2477
+ return typeof row?.count === "number" ? row.count : null;
2478
+ } finally {
2479
+ db.close();
2480
+ }
2481
+ } catch {
2482
+ return null;
2483
+ }
2484
+ }
2485
+ function describeActiveStore(config, env = process.env) {
2486
+ const localDbPath = config.db_path;
2487
+ const localDbPresent = existsSync4(localDbPath);
2488
+ const localDbRecordings = localDbPresent ? readLocalRecordingCount(localDbPath) : null;
2489
+ let resolution;
2490
+ try {
2491
+ resolution = resolveTransport(APP, env);
2492
+ } catch (error) {
2493
+ return {
2494
+ transport: "sqlite",
2495
+ mode_source: "unresolved",
2496
+ base_url: null,
2497
+ local_db_path: localDbPath,
2498
+ local_db_present: localDbPresent,
2499
+ local_db_recordings: localDbRecordings,
2500
+ divergent: false,
2501
+ warning: redactKeyMaterial(`could not resolve the active store: ${error.message}`)
2502
+ };
2503
+ }
2504
+ const divergent = resolution.transport === "http" && (localDbRecordings ?? 0) > 0;
2505
+ const warnings = [];
2506
+ if (resolution.warning)
2507
+ warnings.push(resolution.warning);
2508
+ if (resolution.transport === "http" && localDbPresent && localDbRecordings === null) {
2509
+ warnings.push(`writes go to ${safeBaseUrl(resolution.baseUrl)}, and ${localDbPath} exists but could not be counted, ` + "so whether a second dataset is sitting there is UNKNOWN \u2014 not 'none'.");
2510
+ }
2511
+ if (divergent) {
2512
+ warnings.push(`writes go to ${safeBaseUrl(resolution.baseUrl)}, but ${localDbPath} still holds ` + `${localDbRecordings} recordings from an earlier on-box-only period. ` + "That file is NOT the live store \u2014 auditing it undercounts and looks like data loss.");
2513
+ }
2514
+ if (resolution.transport === "http" && resolution.modeSource === AUTO_FLIP_MODE_SOURCE) {
2515
+ warnings.push("the API transport was selected by the mere PRESENCE of " + "HASNA_RECORDINGS_API_URL + HASNA_RECORDINGS_API_KEY, with no store variable set. " + "Check `launchctl getenv HASNA_RECORDINGS_API_URL` too: a launchd session variable " + "is inherited by the GUI app as well as by shells, and is invisible in a login profile.");
2516
+ }
2517
+ return {
2518
+ transport: resolution.transport,
2519
+ mode_source: resolution.modeSource,
2520
+ base_url: safeBaseUrl(resolution.baseUrl),
2521
+ local_db_path: localDbPath,
2522
+ local_db_present: localDbPresent,
2523
+ local_db_recordings: localDbRecordings,
2524
+ divergent,
2525
+ warning: warnings.length > 0 ? warnings.join(" ") : null
2526
+ };
2527
+ }
2528
+ var PERSISTENCE_PROBE_TAG = "check-probe";
2529
+ var PERSISTENCE_PROBE_MARKER_PREFIX = "recordings check --probe persistence round-trip";
2530
+ async function probeRecordingPersistence(options = {}) {
2531
+ const store = options.store ?? getStore();
2532
+ const stamp = (options.now?.() ?? new Date).toISOString();
2533
+ const marker = `${PERSISTENCE_PROBE_MARKER_PREFIX} ${stamp}`;
2534
+ const transport = store.mode === "http" ? "http" : "sqlite";
2535
+ const base = {
2536
+ attempted: false,
2537
+ transport,
2538
+ base_url: safeBaseUrl(store.baseUrl),
2539
+ recording_id: null,
2540
+ read_back: false,
2541
+ cleaned_up: false,
2542
+ created_local_store: false,
2543
+ reachable: null
2544
+ };
2545
+ if (transport === "sqlite" && options.localStoreIsLegacy === true && !options.allowLocalMigration) {
2546
+ return {
2547
+ ...base,
2548
+ ok: false,
2549
+ outcome: "skipped",
2550
+ message: "NOT MEASURED: the local SQLite store is behind the current schema, and writing to it " + "would apply pending migrations to a legacy file. Migrate deliberately " + "('recordings migrate') or re-run with --probe-store-write to accept the migration as " + "part of the probe."
2551
+ };
2552
+ }
2553
+ if (transport === "http" && !options.allowRemoteWrite) {
2554
+ let reachable = null;
2555
+ let reachabilityDetail = "";
2556
+ try {
2557
+ await store.getRecordingStats();
2558
+ reachable = true;
2559
+ reachabilityDetail = " The store did answer a read-only stats request, so it is reachable " + "and the credential is accepted.";
2560
+ } catch (error) {
2561
+ reachable = false;
2562
+ reachabilityDetail = " The store did NOT answer a read-only stats request: " + `${redactKeyMaterial(error.message)}.`;
2563
+ }
2564
+ return {
2565
+ ...base,
2566
+ reachable,
2567
+ ok: false,
2568
+ outcome: "skipped",
2569
+ message: `NOT MEASURED: the write round-trip was skipped, because it would add a marker recording ` + `to the shared API store at ${safeBaseUrl(store.baseUrl)}, which is production for every ` + `machine pointed at it. Re-run with --probe-store-write to prove the write path for real.` + reachabilityDetail
2570
+ };
2571
+ }
2572
+ let created = null;
2573
+ try {
2574
+ created = await store.createRecording({
2575
+ raw_text: marker,
2576
+ processing_mode: "raw",
2577
+ model_used: "check-probe",
2578
+ duration_ms: 0,
2579
+ tags: ["diagnostic", PERSISTENCE_PROBE_TAG],
2580
+ metadata: { persistence_probe: true, probed_at: stamp }
2581
+ });
2582
+ } catch (error) {
2583
+ return {
2584
+ ...base,
2585
+ attempted: true,
2586
+ ok: false,
2587
+ outcome: "failed",
2588
+ message: `store rejected the write: ${redactKeyMaterial(error.message)}. ` + "If this was a timeout rather than a refusal, the write may still have committed under " + `an id this process never saw \u2014 check with 'recordings search "${PERSISTENCE_PROBE_MARKER_PREFIX}"'.`
2589
+ };
2590
+ }
2591
+ if (!created?.id) {
2592
+ return {
2593
+ ...base,
2594
+ attempted: true,
2595
+ ok: false,
2596
+ outcome: "failed",
2597
+ message: "store accepted the write but returned no id"
2598
+ };
2599
+ }
2600
+ let readBack = false;
2601
+ let readError = null;
2602
+ try {
2603
+ const fetched = await store.getRecording(created.id);
2604
+ readBack = fetched?.raw_text === marker;
2605
+ if (fetched && !readBack) {
2606
+ readError = "the stored transcript did not match what was written";
2607
+ } else if (!fetched) {
2608
+ readError = "the write was accepted but the recording could not be read back";
2609
+ }
2610
+ } catch (error) {
2611
+ readError = `read-back failed: ${redactKeyMaterial(error.message)}`;
2612
+ }
2613
+ let cleanedUp = false;
2614
+ let cleanupError = null;
2615
+ try {
2616
+ const deleteReported = await store.deleteRecording(created.id);
2617
+ if (!deleteReported) {
2618
+ cleanupError = "the store reported nothing to delete";
2619
+ } else {
2620
+ const stillThere = await store.getRecording(created.id);
2621
+ if (stillThere) {
2622
+ cleanupError = "the store reported the delete succeeded but the recording is still readable";
2623
+ } else {
2624
+ cleanedUp = true;
2625
+ }
2626
+ }
2627
+ } catch (error) {
2628
+ cleanupError = redactKeyMaterial(error.message);
2629
+ }
2630
+ const createdLocalStore = transport === "sqlite" && options.localStoreExistedBefore === false;
2631
+ const where = store.baseUrl ? `the API store at ${safeBaseUrl(store.baseUrl)}` : "the local SQLite store";
2632
+ const createdNote = createdLocalStore ? " (this probe CREATED and migrated the local SQLite store, which did not exist before)" : "";
2633
+ if (readError) {
2634
+ return {
2635
+ ...base,
2636
+ attempted: true,
2637
+ recording_id: created.id,
2638
+ cleaned_up: cleanedUp,
2639
+ created_local_store: createdLocalStore,
2640
+ ok: false,
2641
+ outcome: "failed",
2642
+ message: `${readError} (${where}, id ${created.id.slice(0, 8)})${createdNote}`
2643
+ };
2644
+ }
2645
+ if (!cleanedUp) {
2646
+ return {
2647
+ ...base,
2648
+ attempted: true,
2649
+ recording_id: created.id,
2650
+ read_back: true,
2651
+ cleaned_up: false,
2652
+ created_local_store: createdLocalStore,
2653
+ ok: false,
2654
+ outcome: "failed",
2655
+ message: `wrote and read back a marker recording in ${where}, but could NOT confirm its removal ` + `(${cleanupError ?? "cleanup failed"}). The probe recording ${created.id} may still be ` + `there \u2014 remove it with 'recordings delete ${created.id}'.${createdNote}`
2656
+ };
2657
+ }
2658
+ return {
2659
+ ...base,
2660
+ attempted: true,
2661
+ recording_id: created.id,
2662
+ read_back: true,
2663
+ cleaned_up: true,
2664
+ created_local_store: createdLocalStore,
2665
+ ok: true,
2666
+ outcome: "proved",
2667
+ message: `wrote, read back and confirmed removal of a marker recording in ${where}${createdNote}`
2668
+ };
2669
+ }
2103
2670
  export {
2104
2671
  transcribeBuffer,
2105
2672
  transcribeAudio,
2106
2673
  stopRecording,
2107
2674
  startRecording,
2675
+ safeBaseUrl,
2108
2676
  resolveTransport,
2109
2677
  resolveStorageClient,
2110
2678
  resetEnhancementClient,
2111
2679
  resetClient,
2112
2680
  recordDuration,
2681
+ readWavPeak,
2113
2682
  processText,
2683
+ probeRecordingPersistence,
2684
+ probeMicrophoneCapture,
2114
2685
  normalizePostProcessingMode,
2115
2686
  normalizePostProcessingConfig,
2116
2687
  normalizeModelSlots,
2117
2688
  needsEnhancement,
2689
+ microphoneGrantInstruction,
2118
2690
  loadConfig,
2119
2691
  isRecording,
2120
2692
  getStore,
@@ -2122,20 +2694,33 @@ export {
2122
2694
  getCurrentFile,
2123
2695
  ensureDataDir,
2124
2696
  enhanceText,
2697
+ describeActiveStore,
2125
2698
  createStorageClient,
2126
2699
  createHttpTransport,
2700
+ classifyPermissionState,
2127
2701
  checkRecordingDeps,
2702
+ captureProbeSubject,
2703
+ captureProbePrecondition,
2128
2704
  __resetStore,
2129
2705
  TranscriptionError,
2706
+ TCC_UNREADABLE_STATE,
2707
+ SILENCE_PEAK_THRESHOLD,
2130
2708
  RecordingsV1Client,
2131
2709
  ApiError as RecordingsV1ApiError,
2132
2710
  RecordingNotFoundError,
2133
2711
  RecordingError,
2712
+ RECORDINGS_BUNDLE_IDENTIFIER,
2713
+ PERSISTENCE_PROBE_TAG,
2714
+ PERSISTENCE_PROBE_MARKER_PREFIX,
2715
+ MAX_PROBE_SECONDS,
2134
2716
  HasnaHttpError,
2135
2717
  EnhancementError,
2136
2718
  DEFAULT_TRANSCRIPTION_MODEL,
2719
+ DEFAULT_RECORD_EXECUTABLE,
2137
2720
  DEFAULT_REALTIME_TRANSCRIPTION_MODEL,
2138
2721
  DEFAULT_REALTIME_SESSION_MODEL,
2722
+ DEFAULT_PROBE_SECONDS,
2139
2723
  DEFAULT_CONFIG,
2724
+ AUTO_FLIP_MODE_SOURCE,
2140
2725
  APP
2141
2726
  };