arcane-os 0.2.2 → 0.3.0

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 (117) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +247 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +27 -27
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1162 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +6 -3
  21. package/docs/reference/behavioral-testing.md +29 -6
  22. package/docs/reference/cli.md +117 -9
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +108 -44
  27. package/docs/reference/inventory/runtime-modules.json +131 -53
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +157 -43
  30. package/docs/reference/runtime-components.md +258 -83
  31. package/docs/reference/runtime-modules.md +613 -77
  32. package/docs/reference/sdk-api.md +1014 -25
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +462 -384
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +639 -98
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2109 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +470 -0
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +112 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. package/src/workspace.mjs +1 -1
@@ -12,6 +12,7 @@ export const AI_PROVIDER_RUNTIME_PROTOCOL = 'arcane-ai-runtime/2';
12
12
  export const AI_MODEL_AUTHORITY_PROTOCOL = 'arcane-ai-model-authority/1';
13
13
 
14
14
  const ROLE_SET = new Set(AI_RUNTIME_ROLES);
15
+ const SPEECH_ROLES = Object.freeze(['stt', 'tts']);
15
16
  const PROVIDER_METHODS = Object.freeze([
16
17
  'catalog',
17
18
  'inspect',
@@ -265,6 +266,24 @@ function immutableSelections(value) {
265
266
  );
266
267
  }
267
268
 
269
+ function immutableSpeechSelections(value) {
270
+ try {
271
+ assertClosedRecord(value, SPEECH_ROLES, 'AI speech provider selections');
272
+ return Object.freeze(
273
+ {
274
+ stt: immutableRoleRoutes('stt', value.stt),
275
+ tts: immutableRoleRoutes('tts', value.tts)
276
+ }
277
+ );
278
+ } catch (error) {
279
+ if (error?.code === 'ARCANE_AI_PROVIDER_RUNTIME_INVALID'
280
+ && !Object.hasOwn(error, 'reason')) {
281
+ error.reason = 'speech-configuration-contract-mismatch';
282
+ }
283
+ throw error;
284
+ }
285
+ }
286
+
268
287
  function immutableStartupOptions(options) {
269
288
  if (options === undefined) {
270
289
  return Object.freeze({
@@ -428,6 +447,252 @@ function validateProvider(provider) {
428
447
  return admitted;
429
448
  }
430
449
 
450
+ function speechProviderReplacementError(code, reason, message, cause) {
451
+ const error = cause === undefined
452
+ ? new Error(message)
453
+ : new Error(message, {cause});
454
+ error.code = code;
455
+ error.reason = reason;
456
+ return error;
457
+ }
458
+
459
+ function validateSpeechProviderReplacementProvider(provider, role, label) {
460
+ let admitted;
461
+ try {
462
+ admitted = validateProvider(provider);
463
+ } catch (cause) {
464
+ throw speechProviderReplacementError(
465
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_CONTRACT_MISMATCH',
466
+ 'speech-provider-replacement-provider-contract-mismatch',
467
+ `${label} must implement the ${AI_PROVIDER_PROTOCOL} ${role} provider contract.`,
468
+ cause
469
+ );
470
+ }
471
+ if (admitted.role !== role) {
472
+ throw speechProviderReplacementError(
473
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_ROLE_MISMATCH',
474
+ 'speech-provider-replacement-provider-role-mismatch',
475
+ `${label}.role must equal ${role}.`
476
+ );
477
+ }
478
+ return admitted;
479
+ }
480
+
481
+ function immutableSpeechProviderReplacement(value) {
482
+ try {
483
+ assertClosedRecord(
484
+ value,
485
+ ['providers', 'routes', 'expectedProviders'],
486
+ 'AI speech provider replacement'
487
+ );
488
+ assertClosedRecord(
489
+ value.providers,
490
+ SPEECH_ROLES,
491
+ 'AI speech provider replacement.providers'
492
+ );
493
+ assertClosedRecord(
494
+ value.expectedProviders,
495
+ SPEECH_ROLES,
496
+ 'AI speech provider replacement.expectedProviders'
497
+ );
498
+ } catch (cause) {
499
+ throw speechProviderReplacementError(
500
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_CONTRACT_MISMATCH',
501
+ 'speech-provider-replacement-contract-mismatch',
502
+ 'AI speech provider replacement must be a closed providers, routes, and expectedProviders record.',
503
+ cause
504
+ );
505
+ }
506
+
507
+ let routes;
508
+ try {
509
+ routes = immutableSpeechSelections(value.routes);
510
+ } catch (cause) {
511
+ throw speechProviderReplacementError(
512
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_ROUTE_CONTRACT_MISMATCH',
513
+ 'speech-provider-replacement-route-contract-mismatch',
514
+ 'AI speech provider replacement.routes must be a closed STT/TTS route record.',
515
+ cause
516
+ );
517
+ }
518
+
519
+ const providers = {};
520
+ const expectedProviders = {};
521
+ const replacementProviderCount = SPEECH_ROLES.filter(
522
+ function hasSpeechReplacementProvider(role) {
523
+ return value.providers[role] !== null;
524
+ }
525
+ ).length;
526
+ const expectedProviderCount = SPEECH_ROLES.filter(
527
+ function hasExpectedSpeechProvider(role) {
528
+ return value.expectedProviders[role] !== null;
529
+ }
530
+ ).length;
531
+ if (replacementProviderCount !== 0
532
+ && replacementProviderCount !== SPEECH_ROLES.length) {
533
+ throw speechProviderReplacementError(
534
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_PAIR_MISMATCH',
535
+ 'speech-provider-replacement-provider-pair-mismatch',
536
+ 'AI speech provider replacement must supply both replacement providers or null for both.'
537
+ );
538
+ }
539
+ const removal = replacementProviderCount === 0;
540
+ if (removal && expectedProviderCount !== SPEECH_ROLES.length) {
541
+ throw speechProviderReplacementError(
542
+ 'ARCANE_AI_SPEECH_PROVIDER_REMOVAL_EXPECTED_PROVIDER_REQUIRED',
543
+ 'speech-provider-removal-expected-provider-required',
544
+ 'AI speech provider removal requires exact expected STT and TTS provider identities.'
545
+ );
546
+ }
547
+ for (const role of SPEECH_ROLES) {
548
+ const provider = removal
549
+ ? null
550
+ : validateSpeechProviderReplacementProvider(
551
+ value.providers[role],
552
+ role,
553
+ `AI speech provider replacement.providers.${role}`
554
+ );
555
+ const expectedValue = value.expectedProviders[role];
556
+ if (expectedValue !== null) {
557
+ expectedProviders[role] = validateSpeechProviderReplacementProvider(
558
+ expectedValue,
559
+ role,
560
+ `AI speech provider replacement.expectedProviders.${role}`
561
+ );
562
+ } else {
563
+ expectedProviders[role] = null;
564
+ }
565
+ if (removal) {
566
+ if (routes[role].default || routes[role].localOnly) {
567
+ throw speechProviderReplacementError(
568
+ 'ARCANE_AI_SPEECH_PROVIDER_REMOVAL_ROUTE_NOT_EMPTY',
569
+ 'speech-provider-removal-route-not-empty',
570
+ `AI speech provider removal routes for ${role} must both be null.`
571
+ );
572
+ }
573
+ } else if (!routes[role].default) {
574
+ throw speechProviderReplacementError(
575
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_DEFAULT_ROUTE_REQUIRED',
576
+ 'speech-provider-replacement-default-route-required',
577
+ `AI speech provider replacement.routes.${role}.default must select the replacement provider.`
578
+ );
579
+ }
580
+ for (const routeName of ROUTE_KEYS) {
581
+ const selection = routes[role][routeName];
582
+ if (!selection) {
583
+ continue;
584
+ }
585
+ if (!provider
586
+ || selection.providerId !== provider.id
587
+ || selection.localOnly !== provider.localOnly) {
588
+ throw speechProviderReplacementError(
589
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_ROUTE_PROVIDER_MISMATCH',
590
+ 'speech-provider-replacement-route-provider-mismatch',
591
+ `AI speech provider replacement ${role} ${routeName} route must match its replacement provider identity and locality.`
592
+ );
593
+ }
594
+ }
595
+ providers[role] = provider;
596
+ }
597
+
598
+ return Object.freeze(
599
+ {
600
+ providers: Object.freeze(providers),
601
+ routes,
602
+ expectedProviders: Object.freeze(expectedProviders),
603
+ removal
604
+ }
605
+ );
606
+ }
607
+
608
+ function immutableSpeechProviderRoleReplacement(role, value) {
609
+ if (!SPEECH_ROLES.includes(role)) {
610
+ throw speechProviderReplacementError(
611
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_CONTRACT_MISMATCH',
612
+ 'speech-provider-replacement-contract-mismatch',
613
+ 'AI speech provider role replacement requires role stt or tts.'
614
+ );
615
+ }
616
+ try {
617
+ assertClosedRecord(
618
+ value,
619
+ ['provider', 'routes', 'expectedProvider'],
620
+ 'AI speech provider role replacement'
621
+ );
622
+ } catch (cause) {
623
+ throw speechProviderReplacementError(
624
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_CONTRACT_MISMATCH',
625
+ 'speech-provider-replacement-contract-mismatch',
626
+ 'AI speech provider role replacement must be a closed provider, routes, and expectedProvider record.',
627
+ cause
628
+ );
629
+ }
630
+
631
+ let routes;
632
+ try {
633
+ routes = immutableRoleRoutes(role, value.routes);
634
+ } catch (cause) {
635
+ throw speechProviderReplacementError(
636
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_ROUTE_CONTRACT_MISMATCH',
637
+ 'speech-provider-replacement-route-contract-mismatch',
638
+ `AI speech provider role replacement.routes must be a closed ${role.toUpperCase()} route record.`,
639
+ cause
640
+ );
641
+ }
642
+ const removal = value.provider === null;
643
+ const provider = removal
644
+ ? null
645
+ : validateSpeechProviderReplacementProvider(
646
+ value.provider,
647
+ role,
648
+ 'AI speech provider role replacement.provider'
649
+ );
650
+ const expectedProvider = value.expectedProvider === null
651
+ ? null
652
+ : validateSpeechProviderReplacementProvider(
653
+ value.expectedProvider,
654
+ role,
655
+ 'AI speech provider role replacement.expectedProvider'
656
+ );
657
+ if (removal && expectedProvider === null) {
658
+ throw speechProviderReplacementError(
659
+ 'ARCANE_AI_SPEECH_PROVIDER_REMOVAL_EXPECTED_PROVIDER_REQUIRED',
660
+ 'speech-provider-removal-expected-provider-required',
661
+ 'AI speech provider role removal requires its exact expected provider identity.'
662
+ );
663
+ }
664
+ if (removal && (routes.default || routes.localOnly)) {
665
+ throw speechProviderReplacementError(
666
+ 'ARCANE_AI_SPEECH_PROVIDER_REMOVAL_ROUTE_NOT_EMPTY',
667
+ 'speech-provider-removal-route-not-empty',
668
+ `AI speech provider removal routes for ${role} must both be null.`
669
+ );
670
+ }
671
+ if (!removal && !routes.default) {
672
+ throw speechProviderReplacementError(
673
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_DEFAULT_ROUTE_REQUIRED',
674
+ 'speech-provider-replacement-default-route-required',
675
+ 'AI speech provider role replacement.routes.default must select the replacement provider.'
676
+ );
677
+ }
678
+ for (const routeName of ROUTE_KEYS) {
679
+ const selection = routes[routeName];
680
+ if (!selection) {
681
+ continue;
682
+ }
683
+ if (!provider
684
+ || selection.providerId !== provider.id
685
+ || selection.localOnly !== provider.localOnly) {
686
+ throw speechProviderReplacementError(
687
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_ROUTE_PROVIDER_MISMATCH',
688
+ 'speech-provider-replacement-route-provider-mismatch',
689
+ `AI speech provider role replacement ${role} ${routeName} route must match its replacement provider identity and locality.`
690
+ );
691
+ }
692
+ }
693
+ return Object.freeze({role, provider, routes, expectedProvider, removal});
694
+ }
695
+
431
696
  function validateProviderStatus(status) {
432
697
  assertPlainObject(status, 'AI provider status');
433
698
  if (typeof status.state !== 'string'
@@ -496,6 +761,7 @@ export class AIProviderRuntime {
496
761
  #speechMuted = true;
497
762
  #speechDesiredMuted = true;
498
763
  #speechTransition = Promise.resolve();
764
+ #speechTransitionOwners = 0;
499
765
  #unsubscribeIntents = null;
500
766
  #closed = false;
501
767
  #closing = false;
@@ -601,18 +867,7 @@ export class AIProviderRuntime {
601
867
  }
602
868
  }
603
869
 
604
- const runtime = this;
605
- let active = true;
606
- return function unregisterAIProvider() {
607
- if (!active) {
608
- return false;
609
- }
610
- const removed = runtime.unregister(admitted.role, admitted.id, admitted);
611
- if (removed) {
612
- active = false;
613
- }
614
- return removed;
615
- };
870
+ return this.#createProviderUnregisterHandle(admitted);
616
871
  }
617
872
 
618
873
  unregister(role, providerId, expectedProvider = null) {
@@ -645,6 +900,37 @@ export class AIProviderRuntime {
645
900
  return this.#providers.has(providerKey(role, providerId));
646
901
  }
647
902
 
903
+ ownsProvider(role, expectedProvider) {
904
+ if (!ROLE_SET.has(role)) {
905
+ throw speechProviderReplacementError(
906
+ 'ARCANE_AI_PROVIDER_IDENTITY_ROLE_INVALID',
907
+ 'provider-identity-role-invalid',
908
+ `AI provider identity role must be one of ${AI_RUNTIME_ROLES.join(', ')}.`
909
+ );
910
+ }
911
+
912
+ let admitted;
913
+ try {
914
+ admitted = validateProvider(expectedProvider);
915
+ } catch (cause) {
916
+ throw speechProviderReplacementError(
917
+ 'ARCANE_AI_PROVIDER_IDENTITY_CONTRACT_MISMATCH',
918
+ 'provider-identity-contract-mismatch',
919
+ `Expected ${role} provider identity must implement the ${AI_PROVIDER_PROTOCOL} provider contract.`,
920
+ cause
921
+ );
922
+ }
923
+ if (admitted.role !== role) {
924
+ throw speechProviderReplacementError(
925
+ 'ARCANE_AI_PROVIDER_IDENTITY_ROLE_MISMATCH',
926
+ 'provider-identity-role-mismatch',
927
+ `Expected provider identity role ${admitted.role} does not match ${role}.`
928
+ );
929
+ }
930
+
931
+ return this.#providers.get(providerKey(role, admitted.id)) === admitted;
932
+ }
933
+
648
934
  providerIdentity(role, providerId) {
649
935
  assertRole(role);
650
936
  assertIdentifier(providerId, 'AI provider id');
@@ -709,6 +995,29 @@ export class AIProviderRuntime {
709
995
  return selections;
710
996
  }
711
997
 
998
+ validateSpeechConfiguration(value) {
999
+ this.#assertOpen();
1000
+ const selections = immutableSpeechSelections(value);
1001
+ for (const role of SPEECH_ROLES) {
1002
+ for (const routeName of ROUTE_KEYS) {
1003
+ const selection = selections[role][routeName];
1004
+ if (!selection) {
1005
+ continue;
1006
+ }
1007
+ const provider = this.#providers.get(
1008
+ providerKey(role, selection.providerId)
1009
+ ) ?? null;
1010
+ if (provider && selection.localOnly !== provider.localOnly) {
1011
+ throw operationError(
1012
+ `AI ${role} route ${routeName} does not match provider locality.`,
1013
+ 'ARCANE_AI_PROVIDER_LOCALITY_MISMATCH'
1014
+ );
1015
+ }
1016
+ }
1017
+ }
1018
+ return selections;
1019
+ }
1020
+
712
1021
  configure(value) {
713
1022
  this.#assertOpen();
714
1023
  if (this.#configuring) {
@@ -753,6 +1062,376 @@ export class AIProviderRuntime {
753
1062
  return selections;
754
1063
  }
755
1064
 
1065
+ configureSpeech(value) {
1066
+ this.#assertOpen();
1067
+ if (this.#configuring) {
1068
+ throw operationError(
1069
+ 'AI speech provider configuration cannot be changed reentrantly.',
1070
+ 'ARCANE_AI_SPEECH_CONFIGURATION_REENTRANT'
1071
+ );
1072
+ }
1073
+ const selections = this.validateSpeechConfiguration(value);
1074
+ for (const role of SPEECH_ROLES) {
1075
+ const slot = this.#slots[role];
1076
+ if (this.#roleHasOwnedWork(slot)) {
1077
+ throw operationError(
1078
+ `AI role ${role} must be unloaded before it is reconfigured.`,
1079
+ 'ARCANE_AI_ROLE_BUSY'
1080
+ );
1081
+ }
1082
+ }
1083
+
1084
+ this.#configuring = true;
1085
+ try {
1086
+ for (const role of SPEECH_ROLES) {
1087
+ const slot = this.#slots[role];
1088
+ slot.generation += 1;
1089
+ slot.disposed = false;
1090
+ slot.ready = false;
1091
+ slot.routes = selections[role];
1092
+ slot.selection = selections[role].default;
1093
+ }
1094
+ this.#speechDesiredMuted = true;
1095
+ this.#speechMuted = true;
1096
+ this.#configured = true;
1097
+ publishAIRuntimeRolesState(
1098
+ {
1099
+ llm: this.status('llm'),
1100
+ stt: roleRecord('stt', this.#slots.stt.selection),
1101
+ tts: roleRecord('tts', this.#slots.tts.selection)
1102
+ }
1103
+ );
1104
+ } finally {
1105
+ this.#configuring = false;
1106
+ }
1107
+ return selections;
1108
+ }
1109
+
1110
+ replaceSpeechProvider(role, value) {
1111
+ this.#assertOpen();
1112
+ if (this.#configuring) {
1113
+ throw speechProviderReplacementError(
1114
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_REENTRANT',
1115
+ 'speech-provider-replacement-reentrant',
1116
+ 'AI speech provider replacement cannot be committed reentrantly.'
1117
+ );
1118
+ }
1119
+ this.#configuring = true;
1120
+ try {
1121
+ return this.#commitSpeechProviderRoleReplacement(role, value);
1122
+ } finally {
1123
+ this.#configuring = false;
1124
+ }
1125
+ }
1126
+
1127
+ #commitSpeechProviderRoleReplacement(role, value) {
1128
+ const replacement = immutableSpeechProviderRoleReplacement(role, value);
1129
+ const currentState = getAIRuntimeState();
1130
+ const slot = this.#slots[role];
1131
+ const roleState = currentState.roles[role];
1132
+ if (this.#roleHasOwnedWork(slot)
1133
+ || roleState.loaded
1134
+ || roleState.busy
1135
+ || (role === 'tts' && this.#speechTransitionOwners > 0)) {
1136
+ throw speechProviderReplacementError(
1137
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_ROLE_NOT_UNLOADED',
1138
+ 'speech-provider-replacement-role-not-unloaded',
1139
+ `AI role ${role} must be fully unloaded with no owned work before speech provider replacement.`
1140
+ );
1141
+ }
1142
+
1143
+ const expected = replacement.expectedProvider;
1144
+ if (expected) {
1145
+ const registered = this.#providers.get(
1146
+ providerKey(role, expected.id)
1147
+ ) ?? null;
1148
+ if (registered !== expected) {
1149
+ throw speechProviderReplacementError(
1150
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_EXPECTED_PROVIDER_MISMATCH',
1151
+ 'speech-provider-replacement-expected-provider-mismatch',
1152
+ `AI role ${role} expected provider identity is no longer registered.`
1153
+ );
1154
+ }
1155
+ const routeProviderMismatch = ROUTE_KEYS.some(
1156
+ function hasUnexpectedSelectedSpeechProvider(routeName) {
1157
+ const selection = slot.routes[routeName];
1158
+ return selection !== null
1159
+ && selection.providerId !== expected.id;
1160
+ }
1161
+ );
1162
+ if (slot.routes.default?.providerId !== expected.id
1163
+ || slot.selection?.providerId !== expected.id
1164
+ || routeProviderMismatch) {
1165
+ throw speechProviderReplacementError(
1166
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_SELECTED_PROVIDER_MISMATCH',
1167
+ 'speech-provider-replacement-selected-provider-mismatch',
1168
+ `AI role ${role} selected routes no longer belong to the expected provider identity.`
1169
+ );
1170
+ }
1171
+ } else if (slot.routes.default
1172
+ || slot.routes.localOnly
1173
+ || slot.selection) {
1174
+ throw speechProviderReplacementError(
1175
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_EXPECTED_PROVIDER_REQUIRED',
1176
+ 'speech-provider-replacement-expected-provider-required',
1177
+ `AI role ${role} has a selected provider, so replacement requires its exact expected provider identity.`
1178
+ );
1179
+ }
1180
+
1181
+ const provider = replacement.provider;
1182
+ if (provider) {
1183
+ const registeredAtReplacementKey = this.#providers.get(
1184
+ providerKey(role, provider.id)
1185
+ ) ?? null;
1186
+ if (registeredAtReplacementKey
1187
+ && registeredAtReplacementKey !== expected) {
1188
+ throw speechProviderReplacementError(
1189
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_ID_COLLISION',
1190
+ 'speech-provider-replacement-provider-id-collision',
1191
+ `AI role ${role} replacement provider id is registered by a different provider identity.`
1192
+ );
1193
+ }
1194
+ }
1195
+
1196
+ const currentProvider = this.#providerFor(slot);
1197
+ const inspectedProviders = new Set([currentProvider, expected, provider]);
1198
+ inspectedProviders.delete(null);
1199
+ for (const inspected of inspectedProviders) {
1200
+ let providerStatus;
1201
+ try {
1202
+ providerStatus = validateProviderStatus(inspected.status());
1203
+ } catch (cause) {
1204
+ throw speechProviderReplacementError(
1205
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_STATUS_INVALID',
1206
+ 'speech-provider-replacement-provider-status-invalid',
1207
+ `AI role ${role} replacement boundary received an invalid provider status.`,
1208
+ cause
1209
+ );
1210
+ }
1211
+ if (providerStatus.state !== 'unloaded'
1212
+ || providerStatus.loaded
1213
+ || providerStatus.busy) {
1214
+ throw speechProviderReplacementError(
1215
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_NOT_UNLOADED',
1216
+ 'speech-provider-replacement-provider-not-unloaded',
1217
+ `AI role ${role} replacement boundary requires selected, old, and new providers to report unloaded and idle.`
1218
+ );
1219
+ }
1220
+ }
1221
+ if (currentState.revision === Number.MAX_SAFE_INTEGER) {
1222
+ throw speechProviderReplacementError(
1223
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_STATE_REVISION_EXHAUSTED',
1224
+ 'speech-provider-replacement-state-revision-exhausted',
1225
+ 'AI speech provider replacement cannot publish another runtime state revision.'
1226
+ );
1227
+ }
1228
+
1229
+ if (expected) {
1230
+ this.#providers.delete(providerKey(role, expected.id));
1231
+ this.#disposeAllCompletedProviders.delete(expected);
1232
+ }
1233
+ if (provider) {
1234
+ this.#providers.set(providerKey(role, provider.id), provider);
1235
+ this.#disposeAllCompletedProviders.delete(provider);
1236
+ }
1237
+ slot.generation += 1;
1238
+ slot.disposed = false;
1239
+ slot.ready = false;
1240
+ slot.routes = replacement.routes;
1241
+ slot.selection = replacement.routes.default;
1242
+ this.#configured = true;
1243
+ publishAIRuntimeRoleState(role, roleRecord(role, slot.selection));
1244
+
1245
+ return Object.freeze(
1246
+ {
1247
+ role,
1248
+ routes: replacement.routes,
1249
+ unregister: replacement.removal
1250
+ ? null
1251
+ : this.#createProviderUnregisterHandle(provider)
1252
+ }
1253
+ );
1254
+ }
1255
+
1256
+ replaceSpeechProviders(value) {
1257
+ this.#assertOpen();
1258
+ if (this.#configuring) {
1259
+ throw speechProviderReplacementError(
1260
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_REENTRANT',
1261
+ 'speech-provider-replacement-reentrant',
1262
+ 'AI speech provider replacement cannot be committed reentrantly.'
1263
+ );
1264
+ }
1265
+ this.#configuring = true;
1266
+ try {
1267
+ return this.#commitSpeechProviderReplacement(value);
1268
+ } finally {
1269
+ this.#configuring = false;
1270
+ }
1271
+ }
1272
+
1273
+ #commitSpeechProviderReplacement(value) {
1274
+ const replacement = immutableSpeechProviderReplacement(value);
1275
+ const currentState = getAIRuntimeState();
1276
+ for (const role of SPEECH_ROLES) {
1277
+ const slot = this.#slots[role];
1278
+ const roleState = currentState.roles[role];
1279
+ if (this.#roleHasOwnedWork(slot)
1280
+ || roleState.loaded
1281
+ || roleState.busy
1282
+ || this.#speechTransitionOwners > 0) {
1283
+ throw speechProviderReplacementError(
1284
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_ROLE_NOT_UNLOADED',
1285
+ 'speech-provider-replacement-role-not-unloaded',
1286
+ `AI role ${role} must be fully unloaded with no owned work before speech provider replacement.`
1287
+ );
1288
+ }
1289
+
1290
+ const expected = replacement.expectedProviders[role];
1291
+ if (expected) {
1292
+ const registered = this.#providers.get(
1293
+ providerKey(role, expected.id)
1294
+ ) ?? null;
1295
+ if (registered !== expected) {
1296
+ throw speechProviderReplacementError(
1297
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_EXPECTED_PROVIDER_MISMATCH',
1298
+ 'speech-provider-replacement-expected-provider-mismatch',
1299
+ `AI role ${role} expected provider identity is no longer registered.`
1300
+ );
1301
+ }
1302
+ const routeProviderMismatch = ROUTE_KEYS.some(
1303
+ function hasUnexpectedSelectedSpeechProvider(routeName) {
1304
+ const selection = slot.routes[routeName];
1305
+ return selection !== null
1306
+ && selection.providerId !== expected.id;
1307
+ }
1308
+ );
1309
+ if (slot.routes.default?.providerId !== expected.id
1310
+ || slot.selection?.providerId !== expected.id
1311
+ || routeProviderMismatch) {
1312
+ throw speechProviderReplacementError(
1313
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_SELECTED_PROVIDER_MISMATCH',
1314
+ 'speech-provider-replacement-selected-provider-mismatch',
1315
+ `AI role ${role} selected routes no longer belong to the expected provider identity.`
1316
+ );
1317
+ }
1318
+ } else if (slot.routes.default
1319
+ || slot.routes.localOnly
1320
+ || slot.selection) {
1321
+ throw speechProviderReplacementError(
1322
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_EXPECTED_PROVIDER_REQUIRED',
1323
+ 'speech-provider-replacement-expected-provider-required',
1324
+ `AI role ${role} has a selected provider, so replacement requires its exact expected provider identity.`
1325
+ );
1326
+ }
1327
+
1328
+ const provider = replacement.providers[role];
1329
+ if (provider) {
1330
+ const registeredAtReplacementKey = this.#providers.get(
1331
+ providerKey(role, provider.id)
1332
+ ) ?? null;
1333
+ if (registeredAtReplacementKey
1334
+ && registeredAtReplacementKey !== expected) {
1335
+ throw speechProviderReplacementError(
1336
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_ID_COLLISION',
1337
+ 'speech-provider-replacement-provider-id-collision',
1338
+ `AI role ${role} replacement provider id is registered by a different provider identity.`
1339
+ );
1340
+ }
1341
+ }
1342
+
1343
+ const currentProvider = this.#providerFor(slot);
1344
+ const inspectedProviders = new Set([
1345
+ currentProvider,
1346
+ expected,
1347
+ provider
1348
+ ]);
1349
+ inspectedProviders.delete(null);
1350
+ for (const inspected of inspectedProviders) {
1351
+ if (!inspected) {
1352
+ continue;
1353
+ }
1354
+ let providerStatus;
1355
+ try {
1356
+ providerStatus = validateProviderStatus(inspected.status());
1357
+ } catch (cause) {
1358
+ throw speechProviderReplacementError(
1359
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_STATUS_INVALID',
1360
+ 'speech-provider-replacement-provider-status-invalid',
1361
+ `AI role ${role} replacement boundary received an invalid provider status.`,
1362
+ cause
1363
+ );
1364
+ }
1365
+ if (providerStatus.state !== 'unloaded'
1366
+ || providerStatus.loaded
1367
+ || providerStatus.busy) {
1368
+ throw speechProviderReplacementError(
1369
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_NOT_UNLOADED',
1370
+ 'speech-provider-replacement-provider-not-unloaded',
1371
+ `AI role ${role} replacement boundary requires selected, old, and new providers to report unloaded and idle.`
1372
+ );
1373
+ }
1374
+ }
1375
+ }
1376
+ if (currentState.revision === Number.MAX_SAFE_INTEGER) {
1377
+ throw speechProviderReplacementError(
1378
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_STATE_REVISION_EXHAUSTED',
1379
+ 'speech-provider-replacement-state-revision-exhausted',
1380
+ 'AI speech provider replacement cannot publish another runtime state revision.'
1381
+ );
1382
+ }
1383
+
1384
+ for (const role of SPEECH_ROLES) {
1385
+ const expected = replacement.expectedProviders[role];
1386
+ const provider = replacement.providers[role];
1387
+ if (expected) {
1388
+ this.#providers.delete(providerKey(role, expected.id));
1389
+ this.#disposeAllCompletedProviders.delete(expected);
1390
+ }
1391
+ if (provider) {
1392
+ this.#providers.set(providerKey(role, provider.id), provider);
1393
+ this.#disposeAllCompletedProviders.delete(provider);
1394
+ }
1395
+
1396
+ const slot = this.#slots[role];
1397
+ slot.generation += 1;
1398
+ slot.disposed = false;
1399
+ slot.ready = false;
1400
+ slot.routes = replacement.routes[role];
1401
+ slot.selection = replacement.routes[role].default;
1402
+ }
1403
+ this.#speechDesiredMuted = true;
1404
+ this.#speechMuted = true;
1405
+ this.#configured = true;
1406
+ publishAIRuntimeRolesState(
1407
+ {
1408
+ llm: currentState.roles.llm,
1409
+ stt: roleRecord('stt', this.#slots.stt.selection),
1410
+ tts: roleRecord('tts', this.#slots.tts.selection)
1411
+ }
1412
+ );
1413
+
1414
+ return Object.freeze(
1415
+ {
1416
+ routes: replacement.routes,
1417
+ unregisters: Object.freeze(
1418
+ {
1419
+ stt: replacement.removal
1420
+ ? null
1421
+ : this.#createProviderUnregisterHandle(
1422
+ replacement.providers.stt
1423
+ ),
1424
+ tts: replacement.removal
1425
+ ? null
1426
+ : this.#createProviderUnregisterHandle(
1427
+ replacement.providers.tts
1428
+ )
1429
+ }
1430
+ )
1431
+ }
1432
+ );
1433
+ }
1434
+
756
1435
  configureFromTuple(tuple) {
757
1436
  if (!Array.isArray(tuple) || tuple.length !== 6) {
758
1437
  fail('AI preference tuple must contain exactly six entries.');
@@ -1238,7 +1917,11 @@ export class AIProviderRuntime {
1238
1917
  && !providerOwned
1239
1918
  && (!provider || providerStatus)) {
1240
1919
  slot.ready = false;
1241
- return Promise.resolve(before);
1920
+ publishAIRuntimeRoleState(
1921
+ role,
1922
+ roleRecord(role, slot.selection)
1923
+ );
1924
+ return Promise.resolve(this.status(role));
1242
1925
  }
1243
1926
 
1244
1927
  slot.generation += 1;
@@ -2116,6 +2799,7 @@ export class AIProviderRuntime {
2116
2799
  this.cancel('tts');
2117
2800
  }
2118
2801
  const runtime = this;
2802
+ this.#speechTransitionOwners += 1;
2119
2803
  const transition = this.#speechTransition.catch(
2120
2804
  function retainPriorAISpeechTransitionFailure() {}
2121
2805
  ).then(
@@ -2145,6 +2829,10 @@ export class AIProviderRuntime {
2145
2829
  }
2146
2830
  }
2147
2831
  }
2832
+ ).finally(
2833
+ function releaseAIProviderSpeechTransitionOwnership() {
2834
+ runtime.#speechTransitionOwners -= 1;
2835
+ }
2148
2836
  );
2149
2837
  this.#speechTransition = transition;
2150
2838
  return transition;
@@ -2349,6 +3037,25 @@ export class AIProviderRuntime {
2349
3037
  );
2350
3038
  }
2351
3039
 
3040
+ #createProviderUnregisterHandle(provider) {
3041
+ const runtime = this;
3042
+ let active = true;
3043
+ return function unregisterAIProvider() {
3044
+ if (!active) {
3045
+ return false;
3046
+ }
3047
+ const removed = runtime.unregister(
3048
+ provider.role,
3049
+ provider.id,
3050
+ provider
3051
+ );
3052
+ if (removed) {
3053
+ active = false;
3054
+ }
3055
+ return removed;
3056
+ };
3057
+ }
3058
+
2352
3059
  #nextRequestSequence(slot) {
2353
3060
  if (slot.requestSequence === Number.MAX_SAFE_INTEGER) {
2354
3061
  throw operationError(