arcane-os 0.2.0 → 0.2.2

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 (38) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +5 -5
  4. package/browser-runtime/ai/browser-speech-providers.mjs +331 -25
  5. package/docs/architecture.md +2 -2
  6. package/docs/reference/README.md +79 -13
  7. package/docs/reference/ai/browser-speech.md +336 -0
  8. package/docs/reference/ai/browser-wasm.md +207 -82
  9. package/docs/reference/availability-and-normalization.md +30 -4
  10. package/docs/reference/behavioral-testing.md +4 -1
  11. package/docs/reference/cli.md +29 -10
  12. package/docs/reference/core/arcane-ai-contracts.md +43 -9
  13. package/docs/reference/inventory/package-api.json +110 -14
  14. package/docs/reference/inventory/runtime-components.json +19 -6
  15. package/docs/reference/inventory/runtime-modules.json +113 -9
  16. package/docs/reference/protocols.md +260 -38
  17. package/docs/reference/runtime-components.md +108 -15
  18. package/docs/reference/runtime-modules.md +422 -8
  19. package/docs/reference/sdk-api.md +626 -85
  20. package/package.json +1 -1
  21. package/runtime/ARCANE_RUNTIME_RELEASE.json +19 -19
  22. package/runtime/arcane/components/chat.html +288 -52
  23. package/runtime/arcane/components/speech.html +339 -15
  24. package/runtime/arcane/modules/AI.js +1245 -136
  25. package/runtime/arcane/modules/AIProviderRuntime.js +299 -30
  26. package/runtime/arcane/modules/AIRuntimeState.js +23 -4
  27. package/runtime/arcane/modules/ConfiguredAIChatSession.js +93 -8
  28. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +448 -24
  29. package/runtime/arcane/modules/LocalAIReadinessController.js +1 -1
  30. package/schemas/arcane-lock.schema.json +6 -4
  31. package/src/dev-server.mjs +29 -13
  32. package/src/doctor.mjs +1 -3
  33. package/src/import-map.mjs +134 -83
  34. package/src/packager/core.mjs +311 -39
  35. package/src/scaffold.mjs +45 -17
  36. package/src/templates/workspace-template.mjs +23 -4
  37. package/src/toolchain.mjs +10 -2
  38. package/src/workspace.mjs +177 -24
@@ -267,14 +267,20 @@ function immutableSelections(value) {
267
267
 
268
268
  function immutableStartupOptions(options) {
269
269
  if (options === undefined) {
270
- return Object.freeze({startMuted: true, signal: null});
270
+ return Object.freeze({
271
+ startMuted: true,
272
+ startTranscription: false,
273
+ signal: null
274
+ });
271
275
  }
272
276
 
273
277
  assertPlainObject(options, 'AI provider startup options');
274
278
  const descriptors = Object.getOwnPropertyDescriptors(options);
275
279
  for (const key of Reflect.ownKeys(descriptors)) {
276
280
  if (typeof key === 'symbol'
277
- || (key !== 'startMuted' && key !== 'signal')) {
281
+ || (key !== 'startMuted'
282
+ && key !== 'startTranscription'
283
+ && key !== 'signal')) {
278
284
  fail('AI provider startup options contain an unknown option.');
279
285
  }
280
286
  if (!Object.hasOwn(descriptors[key], 'value')) {
@@ -284,14 +290,20 @@ function immutableStartupOptions(options) {
284
290
  const startMuted = Object.hasOwn(descriptors, 'startMuted')
285
291
  ? descriptors.startMuted.value
286
292
  : true;
293
+ const startTranscription = Object.hasOwn(descriptors, 'startTranscription')
294
+ ? descriptors.startTranscription.value
295
+ : false;
287
296
  const signal = Object.hasOwn(descriptors, 'signal')
288
297
  ? descriptors.signal.value
289
298
  : null;
290
299
  if (typeof startMuted !== 'boolean') {
291
300
  fail('AI startup startMuted must be a boolean.');
292
301
  }
302
+ if (typeof startTranscription !== 'boolean') {
303
+ fail('AI startup startTranscription must be a boolean.');
304
+ }
293
305
  assertAbortSignal(signal);
294
- return Object.freeze({startMuted, signal});
306
+ return Object.freeze({startMuted, startTranscription, signal});
295
307
  }
296
308
 
297
309
  function immutableInspectionOptions(options) {
@@ -460,10 +472,12 @@ function createRoleSlot(role) {
460
472
  selection: null,
461
473
  generation: 0,
462
474
  operationSequence: 0,
475
+ requestSequence: 0,
463
476
  loadController: null,
464
477
  loadPromise: null,
465
478
  unloadPromise: null,
466
479
  disposePromise: null,
480
+ requestAdmission: null,
467
481
  request: null,
468
482
  disposed: false,
469
483
  ready: false
@@ -976,10 +990,10 @@ export class AIProviderRuntime {
976
990
  )
977
991
  );
978
992
  }
979
- if (slot.request) {
993
+ if (slot.request || slot.requestAdmission) {
980
994
  return Promise.reject(
981
995
  operationError(
982
- `AI role ${role} cannot load during an active request.`,
996
+ `AI role ${role} cannot load during active request ownership.`,
983
997
  'ARCANE_AI_ROLE_BUSY'
984
998
  )
985
999
  );
@@ -1036,6 +1050,14 @@ export class AIProviderRuntime {
1036
1050
  );
1037
1051
  return Promise.resolve(this.status(role));
1038
1052
  }
1053
+ if (readyStatus?.busy === true) {
1054
+ return Promise.reject(
1055
+ operationError(
1056
+ `AI role ${role} cannot load while its provider remains busy.`,
1057
+ 'ARCANE_AI_ROLE_BUSY'
1058
+ )
1059
+ );
1060
+ }
1039
1061
  } catch {
1040
1062
  // The private ready flag is revoked when provider proof is stale.
1041
1063
  }
@@ -1211,6 +1233,7 @@ export class AIProviderRuntime {
1211
1233
  || providerStatus?.loaded === true
1212
1234
  || providerStatus?.busy === true;
1213
1235
  if (!slot.loadPromise
1236
+ && !slot.requestAdmission
1214
1237
  && !slot.request
1215
1238
  && !providerOwned
1216
1239
  && (!provider || providerStatus)) {
@@ -1225,6 +1248,7 @@ export class AIProviderRuntime {
1225
1248
  this.#speechMuted = true;
1226
1249
  }
1227
1250
  slot.loadController?.abort();
1251
+ slot.requestAdmission?.controller.abort();
1228
1252
  slot.request?.controller.abort();
1229
1253
  const capturedLoad = slot.loadPromise;
1230
1254
  const capturedRequestRecord = slot.request;
@@ -1476,6 +1500,7 @@ export class AIProviderRuntime {
1476
1500
  slot.loadPromise = null;
1477
1501
  slot.unloadPromise = null;
1478
1502
  slot.disposePromise = null;
1503
+ slot.requestAdmission = null;
1479
1504
  slot.request = null;
1480
1505
  slot.ready = false;
1481
1506
  slot.disposed = true;
@@ -1529,6 +1554,9 @@ export class AIProviderRuntime {
1529
1554
  } catch (error) {
1530
1555
  return Promise.reject(error);
1531
1556
  }
1557
+ if (options.signal?.aborted) {
1558
+ return Promise.reject(normalizedAbort());
1559
+ }
1532
1560
  const slot = this.#slots[role];
1533
1561
  if (slot.disposed) {
1534
1562
  return Promise.reject(
@@ -1546,14 +1574,6 @@ export class AIProviderRuntime {
1546
1574
  )
1547
1575
  );
1548
1576
  }
1549
- if (slot.request) {
1550
- return Promise.reject(
1551
- operationError(
1552
- `AI role ${role} already owns an active request.`,
1553
- 'ARCANE_AI_ROLE_BUSY'
1554
- )
1555
- );
1556
- }
1557
1577
  const targetSelection = options.localOnly
1558
1578
  ? slot.routes.localOnly
1559
1579
  : slot.routes.default;
@@ -1599,6 +1619,17 @@ export class AIProviderRuntime {
1599
1619
  this.#publishRoleError(slot, invalidStatus, false);
1600
1620
  return Promise.reject(invalidStatus);
1601
1621
  }
1622
+ if (options.localOnly && targetSelection.localOnly !== true) {
1623
+ return Promise.reject(
1624
+ operationError(
1625
+ `AI role ${role} does not have a local-only route.`,
1626
+ 'AI_LOCAL_MODEL_REQUIRED'
1627
+ )
1628
+ );
1629
+ }
1630
+ if (slot.request || slot.requestAdmission) {
1631
+ return this.#supersedeRoleRequest(slot, provider, options);
1632
+ }
1602
1633
  if (!slot.ready
1603
1634
  || providerStatus.state !== 'ready'
1604
1635
  || providerStatus.loaded !== true
@@ -1611,22 +1642,16 @@ export class AIProviderRuntime {
1611
1642
  )
1612
1643
  );
1613
1644
  }
1614
- if (options.localOnly && targetSelection.localOnly !== true) {
1615
- return Promise.reject(
1616
- operationError(
1617
- `AI role ${role} does not have a local-only route.`,
1618
- 'AI_LOCAL_MODEL_REQUIRED'
1619
- )
1620
- );
1621
- }
1622
1645
 
1623
1646
  const generation = slot.generation;
1647
+ const requestSequence = this.#nextRequestSequence(slot);
1624
1648
  const operationId = this.#nextOperationId(slot, options.operation);
1625
1649
  const controller = new AbortController();
1626
1650
  const detachSignal = this.#forwardAbort(options.signal, controller);
1627
1651
  const runtime = this;
1628
1652
  const requestRecord = {
1629
1653
  controller,
1654
+ requestSequence,
1630
1655
  promise: null,
1631
1656
  cancel: null
1632
1657
  };
@@ -1646,7 +1671,10 @@ export class AIProviderRuntime {
1646
1671
  );
1647
1672
 
1648
1673
  function restoreAIProviderRoleAfterRequest(error) {
1649
- if (slot.generation !== generation || slot.unloadPromise) {
1674
+ if (slot.generation !== generation
1675
+ || slot.requestSequence !== requestSequence
1676
+ || slot.requestAdmission
1677
+ || slot.unloadPromise) {
1650
1678
  return;
1651
1679
  }
1652
1680
  let providerState = null;
@@ -1881,13 +1909,19 @@ export class AIProviderRuntime {
1881
1909
  }
1882
1910
  providerHandle = opened;
1883
1911
  iterator = opened[Symbol.asyncIterator]();
1884
- runtime.#assertCurrentOperation(slot, generation, controller.signal);
1912
+ runtime.#assertCurrentRequest(
1913
+ slot,
1914
+ generation,
1915
+ requestSequence,
1916
+ controller.signal
1917
+ );
1885
1918
  Promise.resolve(opened.result).then(
1886
1919
  function acceptAIProviderStreamResult(value) {
1887
1920
  try {
1888
- runtime.#assertCurrentOperation(
1921
+ runtime.#assertCurrentRequest(
1889
1922
  slot,
1890
1923
  generation,
1924
+ requestSequence,
1891
1925
  controller.signal
1892
1926
  );
1893
1927
  settleAIProviderStream(null, value);
@@ -1915,7 +1949,14 @@ export class AIProviderRuntime {
1915
1949
  cancel: cancelAIProviderStream,
1916
1950
  async next(value) {
1917
1951
  try {
1918
- return await iterator.next(value);
1952
+ const result = await iterator.next(value);
1953
+ runtime.#assertCurrentRequest(
1954
+ slot,
1955
+ generation,
1956
+ requestSequence,
1957
+ controller.signal
1958
+ );
1959
+ return result;
1919
1960
  } catch (error) {
1920
1961
  await cancelAIProviderStream(error);
1921
1962
  throw isAbort(error, controller.signal)
@@ -1998,7 +2039,12 @@ export class AIProviderRuntime {
1998
2039
  signal: controller.signal
1999
2040
  }
2000
2041
  );
2001
- runtime.#assertCurrentOperation(slot, generation, controller.signal);
2042
+ runtime.#assertCurrentRequest(
2043
+ slot,
2044
+ generation,
2045
+ requestSequence,
2046
+ controller.signal
2047
+ );
2002
2048
  return result;
2003
2049
  } catch (error) {
2004
2050
  const normalized = isAbort(error, controller.signal)
@@ -2041,16 +2087,19 @@ export class AIProviderRuntime {
2041
2087
  cancel(role) {
2042
2088
  assertRole(role);
2043
2089
  const slot = this.#slots[role];
2044
- if (!slot.request) {
2090
+ if (!slot.request && !slot.requestAdmission) {
2045
2091
  return false;
2046
2092
  }
2047
- slot.request.controller.abort();
2048
- slot.request.cancel?.(
2093
+ slot.requestAdmission?.controller.abort();
2094
+ const requestRecord = slot.request;
2095
+ requestRecord?.controller.abort();
2096
+ const cancellation = requestRecord?.cancel?.(
2049
2097
  operationError(
2050
2098
  `AI role ${role} request was cancelled.`,
2051
2099
  'ARCANE_AI_REQUEST_ABORTED'
2052
2100
  )
2053
- ).catch(function retainAIProviderCancelFailureInState() {});
2101
+ );
2102
+ cancellation?.catch(function retainAIProviderCancelFailureInState() {});
2054
2103
  return true;
2055
2104
  }
2056
2105
 
@@ -2134,6 +2183,128 @@ export class AIProviderRuntime {
2134
2183
  );
2135
2184
  }
2136
2185
 
2186
+ #supersedeRoleRequest(slot, provider, options) {
2187
+ const generation = slot.generation;
2188
+ const requestSequence = this.#nextRequestSequence(slot);
2189
+ const priorAdmission = slot.requestAdmission;
2190
+ const activeRequest = priorAdmission?.activeRequest ?? slot.request;
2191
+ const controller = new AbortController();
2192
+ const detachSignal = this.#forwardAbort(options.signal, controller);
2193
+ let settlement = priorAdmission?.settlement ?? null;
2194
+ if (!settlement && activeRequest) {
2195
+ activeRequest.controller.abort();
2196
+ settlement = Promise.allSettled(
2197
+ [
2198
+ Promise.resolve().then(
2199
+ function cancelSupersededAIProviderRequest() {
2200
+ return activeRequest.cancel(
2201
+ operationError(
2202
+ `AI role ${slot.role} request was superseded.`,
2203
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
2204
+ )
2205
+ );
2206
+ }
2207
+ ),
2208
+ activeRequest.promise
2209
+ ]
2210
+ );
2211
+ }
2212
+ if (!settlement) {
2213
+ settlement = Promise.resolve([]);
2214
+ }
2215
+ const admission = {
2216
+ activeRequest,
2217
+ controller,
2218
+ generation,
2219
+ requestSequence,
2220
+ settlement
2221
+ };
2222
+ slot.requestAdmission = admission;
2223
+ const runtime = this;
2224
+ return (async function supersedeAIProviderRoleRequest() {
2225
+ try {
2226
+ const outcomes = await settlement;
2227
+ runtime.#assertCurrentRequestAdmission(slot, admission);
2228
+ if (outcomes[0]?.status === 'rejected') {
2229
+ throw outcomes[0].reason;
2230
+ }
2231
+ let providerStatus;
2232
+ try {
2233
+ providerStatus = validateProviderStatus(provider.status());
2234
+ } catch (error) {
2235
+ throw operationError(
2236
+ `The selected ${slot.role} provider did not return a valid status.`,
2237
+ 'ARCANE_AI_PROVIDER_STATUS_INVALID',
2238
+ error
2239
+ );
2240
+ }
2241
+ if (!slot.ready
2242
+ || providerStatus.state !== 'ready'
2243
+ || providerStatus.loaded !== true
2244
+ || providerStatus.busy !== false) {
2245
+ slot.requestAdmission = null;
2246
+ slot.ready = false;
2247
+ const notReady = operationError(
2248
+ `AI role ${slot.role} is not ready after superseding its active request.`,
2249
+ 'ARCANE_AI_ROLE_NOT_READY'
2250
+ );
2251
+ if (providerStatus.state === 'unloaded'
2252
+ && !providerStatus.loaded
2253
+ && !providerStatus.busy) {
2254
+ publishAIRuntimeRoleState(
2255
+ slot.role,
2256
+ roleRecord(slot.role, slot.selection)
2257
+ );
2258
+ } else if (providerStatus.state === 'ready'
2259
+ && providerStatus.loaded
2260
+ && providerStatus.busy) {
2261
+ slot.ready = true;
2262
+ publishAIRuntimeRoleState(
2263
+ slot.role,
2264
+ roleRecord(
2265
+ slot.role,
2266
+ slot.selection,
2267
+ {
2268
+ state: 'ready',
2269
+ loaded: true,
2270
+ busy: true
2271
+ }
2272
+ )
2273
+ );
2274
+ } else {
2275
+ runtime.#publishRoleError(
2276
+ slot,
2277
+ notReady,
2278
+ providerStatus.loaded
2279
+ );
2280
+ }
2281
+ throw notReady;
2282
+ }
2283
+ slot.requestAdmission = null;
2284
+ return await runtime.request(slot.role, options);
2285
+ } catch (error) {
2286
+ const normalized = isAbort(error, controller.signal)
2287
+ ? normalizedAbort(error)
2288
+ : error;
2289
+ if (slot.requestAdmission === admission) {
2290
+ slot.requestAdmission = null;
2291
+ if (slot.generation === generation
2292
+ && !slot.unloadPromise
2293
+ && !slot.disposePromise) {
2294
+ runtime.#publishRequestAdmissionFailure(
2295
+ slot,
2296
+ provider,
2297
+ normalized
2298
+ );
2299
+ }
2300
+ }
2301
+ throw normalized;
2302
+ } finally {
2303
+ detachSignal();
2304
+ }
2305
+ })();
2306
+ }
2307
+
2137
2308
  #sameSelection(left, right) {
2138
2309
  return left === right
2139
2310
  || Boolean(
@@ -2172,11 +2343,23 @@ export class AIProviderRuntime {
2172
2343
  slot.loadPromise
2173
2344
  || slot.unloadPromise
2174
2345
  || slot.disposePromise
2346
+ || slot.requestAdmission
2175
2347
  || slot.request
2176
2348
  || slot.ready
2177
2349
  );
2178
2350
  }
2179
2351
 
2352
+ #nextRequestSequence(slot) {
2353
+ if (slot.requestSequence === Number.MAX_SAFE_INTEGER) {
2354
+ throw operationError(
2355
+ `AI role ${slot.role} exhausted its request sequence.`,
2356
+ 'ARCANE_AI_OPERATION_SEQUENCE_EXHAUSTED'
2357
+ );
2358
+ }
2359
+ slot.requestSequence += 1;
2360
+ return slot.requestSequence;
2361
+ }
2362
+
2180
2363
  #nextOperationId(slot, operation) {
2181
2364
  if (slot.operationSequence === Number.MAX_SAFE_INTEGER) {
2182
2365
  throw operationError(
@@ -2200,6 +2383,30 @@ export class AIProviderRuntime {
2200
2383
  }
2201
2384
  }
2202
2385
 
2386
+ #assertCurrentRequest(slot, generation, requestSequence, signal) {
2387
+ this.#assertCurrentOperation(slot, generation, signal);
2388
+ if (slot.requestSequence !== requestSequence) {
2389
+ throw operationError(
2390
+ `AI role ${slot.role} request was superseded.`,
2391
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
2392
+ );
2393
+ }
2394
+ }
2395
+
2396
+ #assertCurrentRequestAdmission(slot, admission) {
2397
+ if (admission.controller.signal.aborted) {
2398
+ throw normalizedAbort();
2399
+ }
2400
+ if (slot.generation !== admission.generation
2401
+ || slot.requestSequence !== admission.requestSequence
2402
+ || slot.requestAdmission !== admission) {
2403
+ throw operationError(
2404
+ `AI role ${slot.role} request admission was superseded.`,
2405
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
2406
+ );
2407
+ }
2408
+ }
2409
+
2203
2410
  #forwardAbort(signal, controller) {
2204
2411
  if (!signal) {
2205
2412
  return function detachAbsentAIProviderAbort() {};
@@ -2236,6 +2443,68 @@ export class AIProviderRuntime {
2236
2443
  return true;
2237
2444
  }
2238
2445
 
2446
+ #publishRequestAdmissionFailure(slot, provider, error) {
2447
+ let providerStatus = null;
2448
+ try {
2449
+ providerStatus = validateProviderStatus(provider.status());
2450
+ } catch {
2451
+ // The admission error remains authoritative when status is malformed.
2452
+ }
2453
+ if (isAbort(error, null)
2454
+ && providerStatus?.state === 'ready'
2455
+ && providerStatus.loaded
2456
+ && !providerStatus.busy) {
2457
+ slot.ready = true;
2458
+ publishAIRuntimeRoleState(
2459
+ slot.role,
2460
+ roleRecord(
2461
+ slot.role,
2462
+ slot.selection,
2463
+ {
2464
+ state: 'ready',
2465
+ loaded: true
2466
+ }
2467
+ )
2468
+ );
2469
+ return;
2470
+ }
2471
+ if (isAbort(error, null)
2472
+ && providerStatus?.state === 'unloaded'
2473
+ && !providerStatus.loaded
2474
+ && !providerStatus.busy) {
2475
+ slot.ready = false;
2476
+ publishAIRuntimeRoleState(
2477
+ slot.role,
2478
+ roleRecord(slot.role, slot.selection)
2479
+ );
2480
+ return;
2481
+ }
2482
+ if (providerStatus?.state === 'ready'
2483
+ && providerStatus.loaded
2484
+ && providerStatus.busy) {
2485
+ slot.ready = true;
2486
+ publishAIRuntimeRoleState(
2487
+ slot.role,
2488
+ roleRecord(
2489
+ slot.role,
2490
+ slot.selection,
2491
+ {
2492
+ state: 'ready',
2493
+ loaded: true,
2494
+ busy: true
2495
+ }
2496
+ )
2497
+ );
2498
+ return;
2499
+ }
2500
+ slot.ready = false;
2501
+ this.#publishRoleError(
2502
+ slot,
2503
+ error,
2504
+ providerStatus?.loaded === true
2505
+ );
2506
+ }
2507
+
2239
2508
  #publishRoleError(slot, error, loaded) {
2240
2509
  publishAIRuntimeRoleState(
2241
2510
  slot.role,
@@ -57,6 +57,7 @@ const INTENT_SUBSCRIPTION_OPTION_KEYS = Object.freeze([
57
57
  ]);
58
58
  const STARTUP_OPTION_KEYS = Object.freeze([
59
59
  'startMuted',
60
+ 'startTranscription',
60
61
  'signal'
61
62
  ]);
62
63
  const ROLE_SET = new Set(AI_RUNTIME_ROLES);
@@ -395,6 +396,7 @@ function startupOptions(options) {
395
396
  if (options === undefined) {
396
397
  return {
397
398
  startMuted: true,
399
+ startTranscription: false,
398
400
  signal: null
399
401
  };
400
402
  }
@@ -403,14 +405,21 @@ function startupOptions(options) {
403
405
  const startMuted = Object.hasOwn(options, 'startMuted')
404
406
  ? options.startMuted
405
407
  : true;
408
+ const startTranscription = Object.hasOwn(options, 'startTranscription')
409
+ ? options.startTranscription
410
+ : false;
406
411
  const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
407
412
  if (typeof startMuted !== 'boolean') {
408
413
  fail('startup options.startMuted must be a boolean.');
409
414
  }
415
+ if (typeof startTranscription !== 'boolean') {
416
+ fail('startup options.startTranscription must be a boolean.');
417
+ }
410
418
  assertAbortSignal(signal);
411
419
 
412
420
  return {
413
421
  startMuted,
422
+ startTranscription,
414
423
  signal
415
424
  };
416
425
  }
@@ -419,11 +428,11 @@ function hasAIRuntimeSelection(roleState) {
419
428
  return roleState.providerId !== null && roleState.modelId !== null;
420
429
  }
421
430
 
422
- function startupRequestedRoles(snapshot, startMuted) {
431
+ function startupRequestedRoles(snapshot, startMuted, startTranscription) {
423
432
  return Object.freeze(
424
433
  {
425
434
  llm: hasAIRuntimeSelection(snapshot.roles.llm),
426
- stt: hasAIRuntimeSelection(snapshot.roles.stt),
435
+ stt: startTranscription && hasAIRuntimeSelection(snapshot.roles.stt),
427
436
  tts: !startMuted && hasAIRuntimeSelection(snapshot.roles.tts)
428
437
  }
429
438
  );
@@ -444,13 +453,20 @@ function startupRoleReport(requested, roleState) {
444
453
  );
445
454
  }
446
455
 
447
- function startupReport(snapshot, startRevision, startMuted, requestedRoles) {
456
+ function startupReport(
457
+ snapshot,
458
+ startRevision,
459
+ startMuted,
460
+ startTranscription,
461
+ requestedRoles
462
+ ) {
448
463
  return Object.freeze(
449
464
  {
450
465
  protocol: AI_RUNTIME_PROTOCOL,
451
466
  startRevision,
452
467
  currentRevision: snapshot.revision,
453
468
  startMuted,
469
+ startTranscription,
454
470
  chatReady: snapshot.roles.llm.state === 'ready',
455
471
  roles: Object.freeze(
456
472
  {
@@ -726,6 +742,7 @@ export function startAIRuntime(options) {
726
742
  snapshot,
727
743
  startRevision,
728
744
  normalized.startMuted,
745
+ normalized.startTranscription,
729
746
  requestedRoles
730
747
  );
731
748
  barrierResolved = true;
@@ -747,6 +764,7 @@ export function startAIRuntime(options) {
747
764
  snapshot,
748
765
  startRevision,
749
766
  normalized.startMuted,
767
+ normalized.startTranscription,
750
768
  requestedRoles
751
769
  )
752
770
  );
@@ -847,7 +865,8 @@ export function startAIRuntime(options) {
847
865
  startRevision = latestSnapshot.revision;
848
866
  requestedRoles = startupRequestedRoles(
849
867
  latestSnapshot,
850
- normalized.startMuted
868
+ normalized.startMuted,
869
+ normalized.startTranscription
851
870
  );
852
871
  normalized.signal?.addEventListener(
853
872
  'abort',