arcane-os 0.2.3 → 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 (118) hide show
  1. package/CHANGELOG.md +30 -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 +3242 -391
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1209 -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 +249 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2322 -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 +18 -20
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1205 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +2 -2
  21. package/docs/reference/behavioral-testing.md +28 -5
  22. package/docs/reference/cli.md +115 -7
  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 +86 -37
  27. package/docs/reference/inventory/runtime-modules.json +126 -49
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +185 -33
  30. package/docs/reference/runtime-components.md +192 -80
  31. package/docs/reference/runtime-modules.md +589 -70
  32. package/docs/reference/sdk-api.md +1017 -24
  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 +453 -131
  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 +244 -72
  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 +2117 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +756 -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 +235 -37
  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 +130 -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-runtime.mjs +804 -22
  118. 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,412 @@ 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
+ #pendingSpeechProviderHydrationMatches(role, slot, provider, routes) {
1128
+ if (!provider || !slot.selection || !routes.default) {
1129
+ return false;
1130
+ }
1131
+ const pending = slot.selection;
1132
+ if (this.#providers.has(providerKey(role, pending.providerId))
1133
+ || provider.id !== pending.providerId
1134
+ || routes.default.providerId !== pending.providerId
1135
+ || routes.default.modelId !== pending.modelId) {
1136
+ return false;
1137
+ }
1138
+ const currentSelections = [
1139
+ slot.selection,
1140
+ slot.routes.default,
1141
+ slot.routes.localOnly
1142
+ ].filter(Boolean);
1143
+ return currentSelections.length > 0
1144
+ && currentSelections.every(selection =>
1145
+ selection.providerId === pending.providerId
1146
+ && selection.modelId === pending.modelId
1147
+ && selection.localOnly === null
1148
+ );
1149
+ }
1150
+
1151
+ #commitSpeechProviderRoleReplacement(role, value) {
1152
+ const replacement = immutableSpeechProviderRoleReplacement(role, value);
1153
+ const currentState = getAIRuntimeState();
1154
+ const slot = this.#slots[role];
1155
+ const roleState = currentState.roles[role];
1156
+ if (this.#roleHasOwnedWork(slot)
1157
+ || roleState.loaded
1158
+ || roleState.busy
1159
+ || (role === 'tts' && this.#speechTransitionOwners > 0)) {
1160
+ throw speechProviderReplacementError(
1161
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_ROLE_NOT_UNLOADED',
1162
+ 'speech-provider-replacement-role-not-unloaded',
1163
+ `AI role ${role} must be fully unloaded with no owned work before speech provider replacement.`
1164
+ );
1165
+ }
1166
+
1167
+ const expected = replacement.expectedProvider;
1168
+ if (expected) {
1169
+ const registered = this.#providers.get(
1170
+ providerKey(role, expected.id)
1171
+ ) ?? null;
1172
+ if (registered !== expected) {
1173
+ throw speechProviderReplacementError(
1174
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_EXPECTED_PROVIDER_MISMATCH',
1175
+ 'speech-provider-replacement-expected-provider-mismatch',
1176
+ `AI role ${role} expected provider identity is no longer registered.`
1177
+ );
1178
+ }
1179
+ const routeProviderMismatch = ROUTE_KEYS.some(
1180
+ function hasUnexpectedSelectedSpeechProvider(routeName) {
1181
+ const selection = slot.routes[routeName];
1182
+ return selection !== null
1183
+ && selection.providerId !== expected.id;
1184
+ }
1185
+ );
1186
+ if (slot.routes.default?.providerId !== expected.id
1187
+ || slot.selection?.providerId !== expected.id
1188
+ || routeProviderMismatch) {
1189
+ throw speechProviderReplacementError(
1190
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_SELECTED_PROVIDER_MISMATCH',
1191
+ 'speech-provider-replacement-selected-provider-mismatch',
1192
+ `AI role ${role} selected routes no longer belong to the expected provider identity.`
1193
+ );
1194
+ }
1195
+ } else if ((slot.routes.default
1196
+ || slot.routes.localOnly
1197
+ || slot.selection)
1198
+ && !this.#pendingSpeechProviderHydrationMatches(
1199
+ role,
1200
+ slot,
1201
+ replacement.provider,
1202
+ replacement.routes
1203
+ )) {
1204
+ throw speechProviderReplacementError(
1205
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_EXPECTED_PROVIDER_REQUIRED',
1206
+ 'speech-provider-replacement-expected-provider-required',
1207
+ `AI role ${role} has a selected provider, so replacement requires its exact expected provider identity.`
1208
+ );
1209
+ }
1210
+
1211
+ const provider = replacement.provider;
1212
+ if (provider) {
1213
+ const registeredAtReplacementKey = this.#providers.get(
1214
+ providerKey(role, provider.id)
1215
+ ) ?? null;
1216
+ if (registeredAtReplacementKey
1217
+ && registeredAtReplacementKey !== expected) {
1218
+ throw speechProviderReplacementError(
1219
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_ID_COLLISION',
1220
+ 'speech-provider-replacement-provider-id-collision',
1221
+ `AI role ${role} replacement provider id is registered by a different provider identity.`
1222
+ );
1223
+ }
1224
+ }
1225
+
1226
+ const currentProvider = this.#providerFor(slot);
1227
+ const inspectedProviders = new Set([currentProvider, expected, provider]);
1228
+ inspectedProviders.delete(null);
1229
+ for (const inspected of inspectedProviders) {
1230
+ let providerStatus;
1231
+ try {
1232
+ providerStatus = validateProviderStatus(inspected.status());
1233
+ } catch (cause) {
1234
+ throw speechProviderReplacementError(
1235
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_STATUS_INVALID',
1236
+ 'speech-provider-replacement-provider-status-invalid',
1237
+ `AI role ${role} replacement boundary received an invalid provider status.`,
1238
+ cause
1239
+ );
1240
+ }
1241
+ if (providerStatus.state !== 'unloaded'
1242
+ || providerStatus.loaded
1243
+ || providerStatus.busy) {
1244
+ throw speechProviderReplacementError(
1245
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_NOT_UNLOADED',
1246
+ 'speech-provider-replacement-provider-not-unloaded',
1247
+ `AI role ${role} replacement boundary requires selected, old, and new providers to report unloaded and idle.`
1248
+ );
1249
+ }
1250
+ }
1251
+ if (currentState.revision === Number.MAX_SAFE_INTEGER) {
1252
+ throw speechProviderReplacementError(
1253
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_STATE_REVISION_EXHAUSTED',
1254
+ 'speech-provider-replacement-state-revision-exhausted',
1255
+ 'AI speech provider replacement cannot publish another runtime state revision.'
1256
+ );
1257
+ }
1258
+
1259
+ if (expected) {
1260
+ this.#providers.delete(providerKey(role, expected.id));
1261
+ this.#disposeAllCompletedProviders.delete(expected);
1262
+ }
1263
+ if (provider) {
1264
+ this.#providers.set(providerKey(role, provider.id), provider);
1265
+ this.#disposeAllCompletedProviders.delete(provider);
1266
+ }
1267
+ slot.generation += 1;
1268
+ slot.disposed = false;
1269
+ slot.ready = false;
1270
+ slot.routes = replacement.routes;
1271
+ slot.selection = replacement.routes.default;
1272
+ this.#configured = true;
1273
+ publishAIRuntimeRoleState(role, roleRecord(role, slot.selection));
1274
+
1275
+ return Object.freeze(
1276
+ {
1277
+ role,
1278
+ routes: replacement.routes,
1279
+ unregister: replacement.removal
1280
+ ? null
1281
+ : this.#createProviderUnregisterHandle(provider)
1282
+ }
1283
+ );
1284
+ }
1285
+
1286
+ replaceSpeechProviders(value) {
1287
+ this.#assertOpen();
1288
+ if (this.#configuring) {
1289
+ throw speechProviderReplacementError(
1290
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_REENTRANT',
1291
+ 'speech-provider-replacement-reentrant',
1292
+ 'AI speech provider replacement cannot be committed reentrantly.'
1293
+ );
1294
+ }
1295
+ this.#configuring = true;
1296
+ try {
1297
+ return this.#commitSpeechProviderReplacement(value);
1298
+ } finally {
1299
+ this.#configuring = false;
1300
+ }
1301
+ }
1302
+
1303
+ #commitSpeechProviderReplacement(value) {
1304
+ const replacement = immutableSpeechProviderReplacement(value);
1305
+ const currentState = getAIRuntimeState();
1306
+ for (const role of SPEECH_ROLES) {
1307
+ const slot = this.#slots[role];
1308
+ const roleState = currentState.roles[role];
1309
+ if (this.#roleHasOwnedWork(slot)
1310
+ || roleState.loaded
1311
+ || roleState.busy
1312
+ || this.#speechTransitionOwners > 0) {
1313
+ throw speechProviderReplacementError(
1314
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_ROLE_NOT_UNLOADED',
1315
+ 'speech-provider-replacement-role-not-unloaded',
1316
+ `AI role ${role} must be fully unloaded with no owned work before speech provider replacement.`
1317
+ );
1318
+ }
1319
+
1320
+ const expected = replacement.expectedProviders[role];
1321
+ if (expected) {
1322
+ const registered = this.#providers.get(
1323
+ providerKey(role, expected.id)
1324
+ ) ?? null;
1325
+ if (registered !== expected) {
1326
+ throw speechProviderReplacementError(
1327
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_EXPECTED_PROVIDER_MISMATCH',
1328
+ 'speech-provider-replacement-expected-provider-mismatch',
1329
+ `AI role ${role} expected provider identity is no longer registered.`
1330
+ );
1331
+ }
1332
+ const routeProviderMismatch = ROUTE_KEYS.some(
1333
+ function hasUnexpectedSelectedSpeechProvider(routeName) {
1334
+ const selection = slot.routes[routeName];
1335
+ return selection !== null
1336
+ && selection.providerId !== expected.id;
1337
+ }
1338
+ );
1339
+ if (slot.routes.default?.providerId !== expected.id
1340
+ || slot.selection?.providerId !== expected.id
1341
+ || routeProviderMismatch) {
1342
+ throw speechProviderReplacementError(
1343
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_SELECTED_PROVIDER_MISMATCH',
1344
+ 'speech-provider-replacement-selected-provider-mismatch',
1345
+ `AI role ${role} selected routes no longer belong to the expected provider identity.`
1346
+ );
1347
+ }
1348
+ } else if ((slot.routes.default
1349
+ || slot.routes.localOnly
1350
+ || slot.selection)
1351
+ && !this.#pendingSpeechProviderHydrationMatches(
1352
+ role,
1353
+ slot,
1354
+ replacement.providers[role],
1355
+ replacement.routes[role]
1356
+ )) {
1357
+ throw speechProviderReplacementError(
1358
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_EXPECTED_PROVIDER_REQUIRED',
1359
+ 'speech-provider-replacement-expected-provider-required',
1360
+ `AI role ${role} has a selected provider, so replacement requires its exact expected provider identity.`
1361
+ );
1362
+ }
1363
+
1364
+ const provider = replacement.providers[role];
1365
+ if (provider) {
1366
+ const registeredAtReplacementKey = this.#providers.get(
1367
+ providerKey(role, provider.id)
1368
+ ) ?? null;
1369
+ if (registeredAtReplacementKey
1370
+ && registeredAtReplacementKey !== expected) {
1371
+ throw speechProviderReplacementError(
1372
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_ID_COLLISION',
1373
+ 'speech-provider-replacement-provider-id-collision',
1374
+ `AI role ${role} replacement provider id is registered by a different provider identity.`
1375
+ );
1376
+ }
1377
+ }
1378
+
1379
+ const currentProvider = this.#providerFor(slot);
1380
+ const inspectedProviders = new Set([
1381
+ currentProvider,
1382
+ expected,
1383
+ provider
1384
+ ]);
1385
+ inspectedProviders.delete(null);
1386
+ for (const inspected of inspectedProviders) {
1387
+ if (!inspected) {
1388
+ continue;
1389
+ }
1390
+ let providerStatus;
1391
+ try {
1392
+ providerStatus = validateProviderStatus(inspected.status());
1393
+ } catch (cause) {
1394
+ throw speechProviderReplacementError(
1395
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_STATUS_INVALID',
1396
+ 'speech-provider-replacement-provider-status-invalid',
1397
+ `AI role ${role} replacement boundary received an invalid provider status.`,
1398
+ cause
1399
+ );
1400
+ }
1401
+ if (providerStatus.state !== 'unloaded'
1402
+ || providerStatus.loaded
1403
+ || providerStatus.busy) {
1404
+ throw speechProviderReplacementError(
1405
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_PROVIDER_NOT_UNLOADED',
1406
+ 'speech-provider-replacement-provider-not-unloaded',
1407
+ `AI role ${role} replacement boundary requires selected, old, and new providers to report unloaded and idle.`
1408
+ );
1409
+ }
1410
+ }
1411
+ }
1412
+ if (currentState.revision === Number.MAX_SAFE_INTEGER) {
1413
+ throw speechProviderReplacementError(
1414
+ 'ARCANE_AI_SPEECH_PROVIDER_REPLACEMENT_STATE_REVISION_EXHAUSTED',
1415
+ 'speech-provider-replacement-state-revision-exhausted',
1416
+ 'AI speech provider replacement cannot publish another runtime state revision.'
1417
+ );
1418
+ }
1419
+
1420
+ for (const role of SPEECH_ROLES) {
1421
+ const expected = replacement.expectedProviders[role];
1422
+ const provider = replacement.providers[role];
1423
+ if (expected) {
1424
+ this.#providers.delete(providerKey(role, expected.id));
1425
+ this.#disposeAllCompletedProviders.delete(expected);
1426
+ }
1427
+ if (provider) {
1428
+ this.#providers.set(providerKey(role, provider.id), provider);
1429
+ this.#disposeAllCompletedProviders.delete(provider);
1430
+ }
1431
+
1432
+ const slot = this.#slots[role];
1433
+ slot.generation += 1;
1434
+ slot.disposed = false;
1435
+ slot.ready = false;
1436
+ slot.routes = replacement.routes[role];
1437
+ slot.selection = replacement.routes[role].default;
1438
+ }
1439
+ this.#speechDesiredMuted = true;
1440
+ this.#speechMuted = true;
1441
+ this.#configured = true;
1442
+ publishAIRuntimeRolesState(
1443
+ {
1444
+ llm: currentState.roles.llm,
1445
+ stt: roleRecord('stt', this.#slots.stt.selection),
1446
+ tts: roleRecord('tts', this.#slots.tts.selection)
1447
+ }
1448
+ );
1449
+
1450
+ return Object.freeze(
1451
+ {
1452
+ routes: replacement.routes,
1453
+ unregisters: Object.freeze(
1454
+ {
1455
+ stt: replacement.removal
1456
+ ? null
1457
+ : this.#createProviderUnregisterHandle(
1458
+ replacement.providers.stt
1459
+ ),
1460
+ tts: replacement.removal
1461
+ ? null
1462
+ : this.#createProviderUnregisterHandle(
1463
+ replacement.providers.tts
1464
+ )
1465
+ }
1466
+ )
1467
+ }
1468
+ );
1469
+ }
1470
+
756
1471
  configureFromTuple(tuple) {
757
1472
  if (!Array.isArray(tuple) || tuple.length !== 6) {
758
1473
  fail('AI preference tuple must contain exactly six entries.');
@@ -1238,7 +1953,11 @@ export class AIProviderRuntime {
1238
1953
  && !providerOwned
1239
1954
  && (!provider || providerStatus)) {
1240
1955
  slot.ready = false;
1241
- return Promise.resolve(before);
1956
+ publishAIRuntimeRoleState(
1957
+ role,
1958
+ roleRecord(role, slot.selection)
1959
+ );
1960
+ return Promise.resolve(this.status(role));
1242
1961
  }
1243
1962
 
1244
1963
  slot.generation += 1;
@@ -2116,6 +2835,7 @@ export class AIProviderRuntime {
2116
2835
  this.cancel('tts');
2117
2836
  }
2118
2837
  const runtime = this;
2838
+ this.#speechTransitionOwners += 1;
2119
2839
  const transition = this.#speechTransition.catch(
2120
2840
  function retainPriorAISpeechTransitionFailure() {}
2121
2841
  ).then(
@@ -2145,6 +2865,10 @@ export class AIProviderRuntime {
2145
2865
  }
2146
2866
  }
2147
2867
  }
2868
+ ).finally(
2869
+ function releaseAIProviderSpeechTransitionOwnership() {
2870
+ runtime.#speechTransitionOwners -= 1;
2871
+ }
2148
2872
  );
2149
2873
  this.#speechTransition = transition;
2150
2874
  return transition;
@@ -2349,6 +3073,25 @@ export class AIProviderRuntime {
2349
3073
  );
2350
3074
  }
2351
3075
 
3076
+ #createProviderUnregisterHandle(provider) {
3077
+ const runtime = this;
3078
+ let active = true;
3079
+ return function unregisterAIProvider() {
3080
+ if (!active) {
3081
+ return false;
3082
+ }
3083
+ const removed = runtime.unregister(
3084
+ provider.role,
3085
+ provider.id,
3086
+ provider
3087
+ );
3088
+ if (removed) {
3089
+ active = false;
3090
+ }
3091
+ return removed;
3092
+ };
3093
+ }
3094
+
2352
3095
  #nextRequestSequence(slot) {
2353
3096
  if (slot.requestSequence === Number.MAX_SAFE_INTEGER) {
2354
3097
  throw operationError(