arcane-os 0.1.2 → 0.2.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 (52) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/NOTICE +5 -3
  3. package/README.md +73 -24
  4. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +67 -18
  5. package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +16 -5
  6. package/browser-runtime/ai/browser-kokoro-worker.mjs +3 -0
  7. package/browser-runtime/ai/browser-speech-artifacts.mjs +1108 -0
  8. package/browser-runtime/ai/browser-speech-providers.mjs +475 -0
  9. package/browser-runtime/ai/browser-speech.mjs +9 -0
  10. package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1537 -167
  11. package/browser-runtime/ai/browser-wasm.mjs +46 -1
  12. package/browser-runtime/ai/browser-whisper-worker.mjs +3 -0
  13. package/browser-runtime/ai/browser-wllama-runtime.mjs +677 -132
  14. package/browser-runtime/ai/model-controller.mjs +138 -12
  15. package/browser-runtime/ai/speech-worker-client.mjs +207 -0
  16. package/browser-runtime/ai/speech-worker-runtime.mjs +516 -0
  17. package/browser-runtime/ai/wllama/index.mjs +389 -0
  18. package/docs/architecture.md +132 -22
  19. package/docs/reference/README.md +1 -1
  20. package/docs/reference/ai/browser-wasm.md +101 -42
  21. package/docs/reference/availability-and-normalization.md +19 -5
  22. package/docs/reference/behavioral-testing.md +18 -5
  23. package/docs/reference/cli.md +2 -2
  24. package/docs/reference/inventory/package-api.json +14 -14
  25. package/docs/reference/protocols.md +4 -4
  26. package/docs/reference/sdk-api.md +68 -38
  27. package/docs/work-amplification.md +8 -4
  28. package/package.json +7 -3
  29. package/runtime/ARCANE_RUNTIME_RELEASE.json +50 -20
  30. package/runtime/arcane/components/chat.html +280 -62
  31. package/runtime/arcane/components/speech.html +1113 -265
  32. package/runtime/arcane/entities/Chat.js +246 -43
  33. package/runtime/arcane/modules/AI.js +713 -162
  34. package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
  35. package/runtime/arcane/modules/AIRuntimeState.js +872 -0
  36. package/runtime/arcane/modules/ConfiguredAIChatSession.js +293 -27
  37. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +682 -0
  38. package/runtime/arcane/modules/DocumentLexicalSearch.js +292 -0
  39. package/runtime/arcane/modules/PersistentAIChatSession.js +268 -0
  40. package/runtime/arcane/modules/StaticDocumentCatalog.js +25 -206
  41. package/schemas/arcane-lock.schema.json +6 -4
  42. package/src/cli/main.mjs +14 -2
  43. package/src/constants.mjs +1 -1
  44. package/src/dev-server.mjs +244 -13
  45. package/src/import-map.mjs +59 -1
  46. package/src/packager/core.mjs +2 -2
  47. package/src/runtime.mjs +14 -4
  48. package/src/sdk-browser-runtime.mjs +28 -75
  49. package/src/templates/workspace-template.mjs +4 -4
  50. package/src/toolchain.mjs +3 -0
  51. package/src/workspace-runtime.mjs +1 -1
  52. package/src/workspace.mjs +1 -1
@@ -0,0 +1,2289 @@
1
+ import {
2
+ AI_RUNTIME_ROLES,
3
+ getAIRuntimeState,
4
+ publishAIRuntimeRoleState,
5
+ publishAIRuntimeRolesState,
6
+ startAIRuntime,
7
+ subscribeAIRuntimeIntents
8
+ } from './AIRuntimeState.js';
9
+
10
+ export const AI_PROVIDER_PROTOCOL = 'arcane-ai-provider/2';
11
+ export const AI_PROVIDER_RUNTIME_PROTOCOL = 'arcane-ai-runtime/2';
12
+ export const AI_MODEL_AUTHORITY_PROTOCOL = 'arcane-ai-model-authority/1';
13
+
14
+ const ROLE_SET = new Set(AI_RUNTIME_ROLES);
15
+ const PROVIDER_METHODS = Object.freeze([
16
+ 'catalog',
17
+ 'inspect',
18
+ 'status',
19
+ 'load',
20
+ 'request',
21
+ 'unload',
22
+ 'dispose'
23
+ ]);
24
+ const ROLE_OPERATIONS = Object.freeze(
25
+ {
26
+ llm: Object.freeze(['chat', 'stream']),
27
+ stt: Object.freeze(['transcribe']),
28
+ tts: Object.freeze(['synthesize'])
29
+ }
30
+ );
31
+ const ROLE_OPERATION_SETS = Object.freeze(
32
+ {
33
+ llm: new Set(ROLE_OPERATIONS.llm),
34
+ stt: new Set(ROLE_OPERATIONS.stt),
35
+ tts: new Set(ROLE_OPERATIONS.tts)
36
+ }
37
+ );
38
+ const IDENTIFIER_LIMIT = 128;
39
+ const ERROR_MESSAGE_LIMIT = 512;
40
+ const STREAM_CLEANUP_TIMEOUT_MS = 2_000;
41
+ const ROUTE_KEYS = Object.freeze(['default', 'localOnly']);
42
+ const RUNTIME_CONSTRUCTION_AUTHORITY = Object.freeze({});
43
+ const PROVIDER_RECORDS = new WeakMap();
44
+
45
+ function fail(message, code = 'ARCANE_AI_PROVIDER_RUNTIME_INVALID') {
46
+ const error = new TypeError(message);
47
+ error.code = code;
48
+ throw error;
49
+ }
50
+
51
+ function operationError(message, code, cause) {
52
+ const error = cause === undefined
53
+ ? new Error(message)
54
+ : new Error(message, {cause});
55
+ error.code = code;
56
+ return error;
57
+ }
58
+
59
+ function normalizedAbort(cause) {
60
+ if (cause?.code === 'ARCANE_AI_REQUEST_ABORTED'
61
+ && cause?.name === 'AbortError') {
62
+ return cause;
63
+ }
64
+
65
+ const error = operationError(
66
+ 'The AI provider operation was cancelled.',
67
+ 'ARCANE_AI_REQUEST_ABORTED',
68
+ cause
69
+ );
70
+ error.name = 'AbortError';
71
+ return error;
72
+ }
73
+
74
+ function isAbort(error, signal) {
75
+ return signal?.aborted
76
+ || error?.name === 'AbortError'
77
+ || error?.code === 'AI_REQUEST_ABORTED'
78
+ || error?.code === 'ARCANE_AI_REQUEST_ABORTED'
79
+ || error?.code === 'ARCANE_REQUEST_ABORTED';
80
+ }
81
+
82
+ async function awaitBoundedStreamCleanup(operation) {
83
+ let timer = null;
84
+ const timeout = new Promise(function resolveBoundedAIStreamCleanup(resolve) {
85
+ timer = setTimeout(
86
+ function settleAIStreamCleanupTimeout() {
87
+ resolve({completed: false, results: null});
88
+ },
89
+ STREAM_CLEANUP_TIMEOUT_MS
90
+ );
91
+ });
92
+ try {
93
+ return await Promise.race([
94
+ Promise.resolve(operation).then(
95
+ function settleAIStreamCleanup(results) {
96
+ return {completed: true, results};
97
+ }
98
+ ),
99
+ timeout
100
+ ]);
101
+ } finally {
102
+ if (timer !== null) {
103
+ clearTimeout(timer);
104
+ }
105
+ }
106
+ }
107
+
108
+ function assertStreamCleanupComplete(outcome) {
109
+ const rejected = outcome.completed
110
+ && Array.isArray(outcome.results)
111
+ && outcome.results.some(function hasRejectedAIStreamCleanup(result) {
112
+ return result.status === 'rejected';
113
+ });
114
+ if (!outcome.completed || rejected) {
115
+ throw operationError(
116
+ 'The AI provider stream did not confirm bounded cleanup.',
117
+ 'ARCANE_AI_STREAM_CLEANUP_INCOMPLETE'
118
+ );
119
+ }
120
+ }
121
+
122
+ function assertRole(role) {
123
+ if (!ROLE_SET.has(role)) {
124
+ fail(`AI provider role must be one of ${AI_RUNTIME_ROLES.join(', ')}.`);
125
+ }
126
+ }
127
+
128
+ function assertIdentifier(value, label) {
129
+ if (typeof value !== 'string'
130
+ || value.length < 1
131
+ || value.length > IDENTIFIER_LIMIT
132
+ || value.trim() !== value) {
133
+ fail(`${label} must be a trimmed 1-${IDENTIFIER_LIMIT} character string.`);
134
+ }
135
+ }
136
+
137
+ function assertAbortSignal(signal) {
138
+ if (signal === null || signal === undefined) {
139
+ return;
140
+ }
141
+
142
+ if (typeof signal !== 'object'
143
+ || typeof signal.aborted !== 'boolean'
144
+ || typeof signal.addEventListener !== 'function'
145
+ || typeof signal.removeEventListener !== 'function') {
146
+ fail('AI provider operation signal must be an AbortSignal.');
147
+ }
148
+ }
149
+
150
+ function assertPlainObject(value, label) {
151
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
152
+ fail(`${label} must be a plain object.`);
153
+ }
154
+
155
+ const prototype = Object.getPrototypeOf(value);
156
+ if (prototype !== Object.prototype && prototype !== null) {
157
+ fail(`${label} must be a plain object.`);
158
+ }
159
+ }
160
+
161
+ function assertCallbackFreeProviderValue(value, seen = new WeakSet(), depth = 0) {
162
+ if (typeof value === 'function') {
163
+ fail(
164
+ 'AI providers receive data-only request payloads.',
165
+ 'ARCANE_AI_PROVIDER_CALLBACK_BOUNDARY'
166
+ );
167
+ }
168
+ if (!value || typeof value !== 'object') {
169
+ return;
170
+ }
171
+ if (depth > 32) {
172
+ fail('AI provider request payload nesting exceeds the supported limit.');
173
+ }
174
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
175
+ return;
176
+ }
177
+ if (typeof Blob === 'function' && value instanceof Blob) {
178
+ return;
179
+ }
180
+ if (seen.has(value)) {
181
+ fail('AI provider request payloads must not contain cycles.');
182
+ }
183
+ seen.add(value);
184
+ const descriptors = Object.getOwnPropertyDescriptors(value);
185
+ for (const key of Reflect.ownKeys(descriptors)) {
186
+ const descriptor = descriptors[key];
187
+ if (typeof key === 'symbol' || !Object.hasOwn(descriptor, 'value')) {
188
+ fail('AI provider request payloads must contain data properties only.');
189
+ }
190
+ assertCallbackFreeProviderValue(descriptor.value, seen, depth + 1);
191
+ }
192
+ seen.delete(value);
193
+ }
194
+
195
+ function assertClosedRecord(value, keys, label) {
196
+ assertPlainObject(value, label);
197
+ const descriptors = Object.getOwnPropertyDescriptors(value);
198
+ const actual = Reflect.ownKeys(value);
199
+ if (actual.some(function hasSymbolKey(key) {
200
+ return typeof key === 'symbol';
201
+ })) {
202
+ fail(`${label} must not contain symbol keys.`);
203
+ }
204
+ if (actual.length !== keys.length
205
+ || actual.some(function hasUnknownKey(key) {
206
+ return !keys.includes(key);
207
+ })) {
208
+ fail(`${label} must contain exactly ${keys.join(', ')}.`);
209
+ }
210
+ for (const key of keys) {
211
+ if (!Object.hasOwn(descriptors[key], 'value')) {
212
+ fail(`${label}.${key} must be a data property.`);
213
+ }
214
+ }
215
+ }
216
+
217
+ function immutableSelection(role, value) {
218
+ if (value === null) {
219
+ return null;
220
+ }
221
+
222
+ assertClosedRecord(
223
+ value,
224
+ ['providerId', 'modelId', 'localOnly'],
225
+ `${role} provider selection`
226
+ );
227
+ assertIdentifier(value.providerId, `${role} provider selection.providerId`);
228
+ assertIdentifier(value.modelId, `${role} provider selection.modelId`);
229
+ if (value.localOnly !== null && typeof value.localOnly !== 'boolean') {
230
+ fail(`${role} provider selection.localOnly must be null or a boolean.`);
231
+ }
232
+
233
+ return Object.freeze(
234
+ {
235
+ providerId: value.providerId,
236
+ modelId: value.modelId,
237
+ localOnly: value.localOnly
238
+ }
239
+ );
240
+ }
241
+
242
+ function immutableRoleRoutes(role, value) {
243
+ assertClosedRecord(value, ROUTE_KEYS, `${role} provider routes`);
244
+ const defaultSelection = immutableSelection(role, value.default);
245
+ const localSelection = immutableSelection(role, value.localOnly);
246
+ if (localSelection && localSelection.localOnly !== true) {
247
+ fail(`${role} localOnly route must identify a local-only selection.`);
248
+ }
249
+ return Object.freeze(
250
+ {
251
+ default: defaultSelection,
252
+ localOnly: localSelection
253
+ }
254
+ );
255
+ }
256
+
257
+ function immutableSelections(value) {
258
+ assertClosedRecord(value, AI_RUNTIME_ROLES, 'AI provider selections');
259
+ return Object.freeze(
260
+ {
261
+ llm: immutableRoleRoutes('llm', value.llm),
262
+ stt: immutableRoleRoutes('stt', value.stt),
263
+ tts: immutableRoleRoutes('tts', value.tts)
264
+ }
265
+ );
266
+ }
267
+
268
+ function immutableStartupOptions(options) {
269
+ if (options === undefined) {
270
+ return Object.freeze({startMuted: true, signal: null});
271
+ }
272
+
273
+ assertPlainObject(options, 'AI provider startup options');
274
+ const descriptors = Object.getOwnPropertyDescriptors(options);
275
+ for (const key of Reflect.ownKeys(descriptors)) {
276
+ if (typeof key === 'symbol'
277
+ || (key !== 'startMuted' && key !== 'signal')) {
278
+ fail('AI provider startup options contain an unknown option.');
279
+ }
280
+ if (!Object.hasOwn(descriptors[key], 'value')) {
281
+ fail(`AI provider startup options.${key} must be a data property.`);
282
+ }
283
+ }
284
+ const startMuted = Object.hasOwn(descriptors, 'startMuted')
285
+ ? descriptors.startMuted.value
286
+ : true;
287
+ const signal = Object.hasOwn(descriptors, 'signal')
288
+ ? descriptors.signal.value
289
+ : null;
290
+ if (typeof startMuted !== 'boolean') {
291
+ fail('AI startup startMuted must be a boolean.');
292
+ }
293
+ assertAbortSignal(signal);
294
+ return Object.freeze({startMuted, signal});
295
+ }
296
+
297
+ function immutableInspectionOptions(options) {
298
+ assertPlainObject(options, 'AI provider inspection options');
299
+ const descriptors = Object.getOwnPropertyDescriptors(options);
300
+ for (const key of Reflect.ownKeys(descriptors)) {
301
+ if (typeof key === 'symbol'
302
+ || (key !== 'localOnly' && key !== 'signal')) {
303
+ fail('AI provider inspection options contain an unknown option.');
304
+ }
305
+ if (!Object.hasOwn(descriptors[key], 'value')) {
306
+ fail(`AI provider inspection options.${key} must be a data property.`);
307
+ }
308
+ }
309
+ const localOnly = Object.hasOwn(descriptors, 'localOnly')
310
+ ? descriptors.localOnly.value
311
+ : false;
312
+ const signal = Object.hasOwn(descriptors, 'signal')
313
+ ? descriptors.signal.value
314
+ : null;
315
+ if (typeof localOnly !== 'boolean') {
316
+ fail('AI provider inspection localOnly must be a boolean.');
317
+ }
318
+ assertAbortSignal(signal);
319
+ return Object.freeze({localOnly, signal});
320
+ }
321
+
322
+ function nullableTupleIdentifier(value) {
323
+ return typeof value === 'string' && value.trim()
324
+ ? value.trim()
325
+ : null;
326
+ }
327
+
328
+ function immutableProgress(value) {
329
+ assertClosedRecord(
330
+ value,
331
+ ['phase', 'completed', 'total', 'unit', 'heartbeat'],
332
+ 'AI provider progress'
333
+ );
334
+ return Object.freeze(
335
+ {
336
+ phase: value.phase,
337
+ completed: value.completed,
338
+ total: value.total,
339
+ unit: value.unit,
340
+ heartbeat: value.heartbeat
341
+ }
342
+ );
343
+ }
344
+
345
+ function stateError(error, fallbackCode) {
346
+ const code = typeof error?.code === 'string'
347
+ && /^[A-Z][A-Z0-9_]{0,127}$/.test(error.code)
348
+ ? error.code.trim()
349
+ : fallbackCode;
350
+ const message = code === 'ARCANE_AI_REQUEST_ABORTED'
351
+ || code === 'AI_REQUEST_ABORTED'
352
+ ? 'The AI operation was cancelled.'
353
+ : code.includes('AUTHORITY') || code.includes('PROVENANCE')
354
+ ? 'The selected AI model is not admitted for use.'
355
+ : code.includes('UNAVAILABLE') || code.includes('NOT_REGISTERED')
356
+ ? 'The selected AI provider is unavailable.'
357
+ : 'The selected AI provider operation failed.';
358
+ return Object.freeze(
359
+ {
360
+ code,
361
+ message: message.slice(0, ERROR_MESSAGE_LIMIT)
362
+ }
363
+ );
364
+ }
365
+
366
+ function roleRecord(role, selection, overrides = {}) {
367
+ return {
368
+ role,
369
+ state: selection ? 'unloaded' : 'unavailable',
370
+ providerId: selection?.providerId ?? null,
371
+ modelId: selection?.modelId ?? null,
372
+ localOnly: selection?.localOnly ?? null,
373
+ loaded: false,
374
+ busy: false,
375
+ operationId: null,
376
+ progress: null,
377
+ error: null,
378
+ ...overrides
379
+ };
380
+ }
381
+
382
+ function providerKey(role, providerId) {
383
+ return `${role}:${providerId}`;
384
+ }
385
+
386
+ function validateProvider(provider) {
387
+ const existing = PROVIDER_RECORDS.get(provider);
388
+ if (existing) {
389
+ return existing;
390
+ }
391
+ assertPlainObject(provider, 'AI provider');
392
+ if (provider.protocol !== AI_PROVIDER_PROTOCOL) {
393
+ fail(`AI provider.protocol must equal ${AI_PROVIDER_PROTOCOL}.`);
394
+ }
395
+ assertRole(provider.role);
396
+ assertIdentifier(provider.id, 'AI provider.id');
397
+ if (typeof provider.localOnly !== 'boolean') {
398
+ fail('AI provider.localOnly must be a boolean.');
399
+ }
400
+ for (const method of PROVIDER_METHODS) {
401
+ if (typeof provider[method] !== 'function') {
402
+ fail(`AI provider.${method} must be a function.`);
403
+ }
404
+ }
405
+ const record = {
406
+ protocol: AI_PROVIDER_PROTOCOL,
407
+ role: provider.role,
408
+ id: provider.id,
409
+ localOnly: provider.localOnly
410
+ };
411
+ for (const method of PROVIDER_METHODS) {
412
+ record[method] = provider[method].bind(provider);
413
+ }
414
+ const admitted = Object.freeze(record);
415
+ PROVIDER_RECORDS.set(provider, admitted);
416
+ return admitted;
417
+ }
418
+
419
+ function validateProviderStatus(status) {
420
+ assertPlainObject(status, 'AI provider status');
421
+ if (typeof status.state !== 'string'
422
+ || typeof status.loaded !== 'boolean'
423
+ || typeof status.busy !== 'boolean') {
424
+ fail('AI provider status must include state, loaded, and busy values.');
425
+ }
426
+ return status;
427
+ }
428
+
429
+ function validateInspection(inspection, selection) {
430
+ assertPlainObject(inspection, 'AI provider inspection');
431
+ if (typeof inspection.available !== 'boolean') {
432
+ fail('AI provider inspection.available must be a boolean.');
433
+ }
434
+ if (!inspection.available) {
435
+ const code = typeof inspection.code === 'string' && inspection.code.trim()
436
+ ? inspection.code.trim()
437
+ : 'ARCANE_AI_PROVIDER_AUTHORITY_BLOCKED';
438
+ const message = typeof inspection.message === 'string' && inspection.message.trim()
439
+ ? inspection.message.trim()
440
+ : 'The selected AI provider is not admitted for use.';
441
+ throw operationError(message, code);
442
+ }
443
+ assertPlainObject(inspection.authority, 'AI provider inspection.authority');
444
+ if (inspection.authority.protocol !== AI_MODEL_AUTHORITY_PROTOCOL
445
+ || inspection.authority.providerId !== selection.providerId
446
+ || inspection.authority.modelId !== selection.modelId
447
+ || inspection.authority.admitted !== true) {
448
+ throw operationError(
449
+ 'The selected AI provider did not return an admitted model authority.',
450
+ 'ARCANE_AI_MODEL_AUTHORITY_REQUIRED'
451
+ );
452
+ }
453
+ return inspection;
454
+ }
455
+
456
+ function createRoleSlot(role) {
457
+ return {
458
+ role,
459
+ routes: Object.freeze({default: null, localOnly: null}),
460
+ selection: null,
461
+ generation: 0,
462
+ operationSequence: 0,
463
+ loadController: null,
464
+ loadPromise: null,
465
+ unloadPromise: null,
466
+ disposePromise: null,
467
+ request: null,
468
+ disposed: false,
469
+ ready: false
470
+ };
471
+ }
472
+
473
+ export class AIProviderRuntime {
474
+ #providers = new Map();
475
+ #slots = Object.freeze(
476
+ {
477
+ llm: createRoleSlot('llm'),
478
+ stt: createRoleSlot('stt'),
479
+ tts: createRoleSlot('tts')
480
+ }
481
+ );
482
+ #speechMuted = true;
483
+ #speechDesiredMuted = true;
484
+ #speechTransition = Promise.resolve();
485
+ #unsubscribeIntents = null;
486
+ #closed = false;
487
+ #closing = false;
488
+ #configured = false;
489
+ #configuring = false;
490
+ #disposeAllPromise = null;
491
+ #disposeAllCompletedProviders = new Set();
492
+
493
+ constructor(authority) {
494
+ if (authority !== RUNTIME_CONSTRUCTION_AUTHORITY) {
495
+ throw operationError(
496
+ 'AIProviderRuntime is singleton-owned; use getAIProviderRuntime().',
497
+ 'ARCANE_AI_RUNTIME_SINGLETON_REQUIRED'
498
+ );
499
+ }
500
+ const runtime = this;
501
+ this.#unsubscribeIntents = subscribeAIRuntimeIntents(
502
+ function handleAIProviderRuntimeIntent(intent) {
503
+ runtime.#acceptIntent(intent);
504
+ }
505
+ );
506
+ }
507
+
508
+ get protocol() {
509
+ return AI_PROVIDER_RUNTIME_PROTOCOL;
510
+ }
511
+
512
+ get speechMuted() {
513
+ return this.#speechMuted;
514
+ }
515
+
516
+ get configured() {
517
+ return this.#configured;
518
+ }
519
+
520
+ register(provider) {
521
+ this.#assertOpen();
522
+ this.#assertNotConfiguring();
523
+ const admitted = validateProvider(provider);
524
+ const key = providerKey(admitted.role, admitted.id);
525
+ const existing = this.#providers.get(key);
526
+ if (existing && existing !== admitted) {
527
+ throw operationError(
528
+ `AI provider ${key} is already registered.`,
529
+ 'ARCANE_AI_PROVIDER_ALREADY_REGISTERED'
530
+ );
531
+ }
532
+ const slot = this.#slots[admitted.role];
533
+ const nextRoutes = {
534
+ default: slot.routes.default,
535
+ localOnly: slot.routes.localOnly
536
+ };
537
+ let reconciled = false;
538
+ for (const routeName of ROUTE_KEYS) {
539
+ const selection = slot.routes[routeName];
540
+ if (!selection || selection.providerId !== admitted.id) {
541
+ continue;
542
+ }
543
+ if (selection.localOnly !== null
544
+ && selection.localOnly !== admitted.localOnly) {
545
+ throw operationError(
546
+ `AI provider ${key} does not match the configured ${routeName} route locality.`,
547
+ 'ARCANE_AI_PROVIDER_LOCALITY_MISMATCH'
548
+ );
549
+ }
550
+ if (selection.localOnly === null) {
551
+ nextRoutes[routeName] = Object.freeze(
552
+ {
553
+ providerId: selection.providerId,
554
+ modelId: selection.modelId,
555
+ localOnly: admitted.localOnly
556
+ }
557
+ );
558
+ reconciled = true;
559
+ }
560
+ }
561
+ if (reconciled
562
+ && admitted.localOnly
563
+ && !nextRoutes.localOnly
564
+ && nextRoutes.default?.providerId === admitted.id) {
565
+ nextRoutes.localOnly = nextRoutes.default;
566
+ }
567
+ this.#providers.set(key, admitted);
568
+ if (reconciled) {
569
+ const previousSelection = slot.selection;
570
+ slot.routes = Object.freeze(nextRoutes);
571
+ if (previousSelection?.providerId === admitted.id
572
+ && previousSelection.localOnly === null) {
573
+ slot.generation += 1;
574
+ slot.selection = slot.routes.default?.providerId === admitted.id
575
+ && slot.routes.default.modelId === previousSelection.modelId
576
+ ? slot.routes.default
577
+ : Object.values(slot.routes).find(
578
+ function findReconciledAIProviderSelection(selection) {
579
+ return selection?.providerId === admitted.id
580
+ && selection.modelId === previousSelection.modelId;
581
+ }
582
+ ) ?? previousSelection;
583
+ publishAIRuntimeRoleState(
584
+ admitted.role,
585
+ roleRecord(admitted.role, slot.selection)
586
+ );
587
+ }
588
+ }
589
+
590
+ const runtime = this;
591
+ let active = true;
592
+ return function unregisterAIProvider() {
593
+ if (!active) {
594
+ return false;
595
+ }
596
+ const removed = runtime.unregister(admitted.role, admitted.id, admitted);
597
+ if (removed) {
598
+ active = false;
599
+ }
600
+ return removed;
601
+ };
602
+ }
603
+
604
+ unregister(role, providerId, expectedProvider = null) {
605
+ this.#assertOpen();
606
+ this.#assertNotConfiguring();
607
+ assertRole(role);
608
+ assertIdentifier(providerId, 'AI provider id');
609
+ const key = providerKey(role, providerId);
610
+ const provider = this.#providers.get(key);
611
+ if (!provider || (expectedProvider && provider !== expectedProvider)) {
612
+ return false;
613
+ }
614
+
615
+ const slot = this.#slots[role];
616
+ if (slot.routes.default?.providerId === providerId
617
+ || slot.routes.localOnly?.providerId === providerId) {
618
+ throw operationError(
619
+ `AI provider ${key} must be deselected before it is unregistered.`,
620
+ 'ARCANE_AI_PROVIDER_SELECTED'
621
+ );
622
+ }
623
+ this.#providers.delete(key);
624
+ this.#disposeAllCompletedProviders.delete(provider);
625
+ return true;
626
+ }
627
+
628
+ hasProvider(role, providerId) {
629
+ assertRole(role);
630
+ assertIdentifier(providerId, 'AI provider id');
631
+ return this.#providers.has(providerKey(role, providerId));
632
+ }
633
+
634
+ providerIdentity(role, providerId) {
635
+ assertRole(role);
636
+ assertIdentifier(providerId, 'AI provider id');
637
+ const provider = this.#providers.get(providerKey(role, providerId)) ?? null;
638
+ if (!provider) {
639
+ return null;
640
+ }
641
+ return Object.freeze(
642
+ {
643
+ protocol: provider.protocol,
644
+ role: provider.role,
645
+ id: provider.id,
646
+ localOnly: provider.localOnly
647
+ }
648
+ );
649
+ }
650
+
651
+ selection(role, options = {}) {
652
+ assertRole(role);
653
+ assertPlainObject(options, 'AI provider route options');
654
+ for (const key of Reflect.ownKeys(options)) {
655
+ if (key !== 'localOnly') {
656
+ fail('AI provider route options contain an unknown option.');
657
+ }
658
+ }
659
+ const localOnly = Object.hasOwn(options, 'localOnly')
660
+ ? options.localOnly
661
+ : false;
662
+ if (typeof localOnly !== 'boolean') {
663
+ fail('AI provider route localOnly must be a boolean.');
664
+ }
665
+ const slot = this.#slots[role];
666
+ return localOnly ? slot.routes.localOnly : slot.routes.default;
667
+ }
668
+
669
+ ownsSelection(role, providerId, options = {}) {
670
+ assertRole(role);
671
+ assertIdentifier(providerId, 'AI provider id');
672
+ return this.selection(role, options)?.providerId === providerId;
673
+ }
674
+
675
+ validateConfiguration(value) {
676
+ this.#assertOpen();
677
+ const selections = immutableSelections(value);
678
+ for (const role of AI_RUNTIME_ROLES) {
679
+ for (const routeName of ROUTE_KEYS) {
680
+ const selection = selections[role][routeName];
681
+ if (!selection) {
682
+ continue;
683
+ }
684
+ const provider = this.#providers.get(
685
+ providerKey(role, selection.providerId)
686
+ ) ?? null;
687
+ if (provider && selection.localOnly !== provider.localOnly) {
688
+ throw operationError(
689
+ `AI ${role} route ${routeName} does not match provider locality.`,
690
+ 'ARCANE_AI_PROVIDER_LOCALITY_MISMATCH'
691
+ );
692
+ }
693
+ }
694
+ }
695
+ return selections;
696
+ }
697
+
698
+ configure(value) {
699
+ this.#assertOpen();
700
+ if (this.#configuring) {
701
+ throw operationError(
702
+ 'AI provider configuration cannot be changed reentrantly.',
703
+ 'ARCANE_AI_CONFIGURATION_REENTRANT'
704
+ );
705
+ }
706
+ const selections = this.validateConfiguration(value);
707
+ for (const role of AI_RUNTIME_ROLES) {
708
+ const slot = this.#slots[role];
709
+ if (this.#roleHasOwnedWork(slot)) {
710
+ throw operationError(
711
+ `AI role ${role} must be unloaded before it is reconfigured.`,
712
+ 'ARCANE_AI_ROLE_BUSY'
713
+ );
714
+ }
715
+ }
716
+
717
+ this.#configuring = true;
718
+ try {
719
+ for (const role of AI_RUNTIME_ROLES) {
720
+ const slot = this.#slots[role];
721
+ slot.generation += 1;
722
+ slot.disposed = false;
723
+ slot.ready = false;
724
+ slot.routes = selections[role];
725
+ slot.selection = selections[role].default;
726
+ }
727
+ this.#speechMuted = true;
728
+ this.#configured = true;
729
+ publishAIRuntimeRolesState(
730
+ {
731
+ llm: roleRecord('llm', this.#slots.llm.selection),
732
+ stt: roleRecord('stt', this.#slots.stt.selection),
733
+ tts: roleRecord('tts', this.#slots.tts.selection)
734
+ }
735
+ );
736
+ } finally {
737
+ this.#configuring = false;
738
+ }
739
+ return selections;
740
+ }
741
+
742
+ configureFromTuple(tuple) {
743
+ if (!Array.isArray(tuple) || tuple.length !== 6) {
744
+ fail('AI preference tuple must contain exactly six entries.');
745
+ }
746
+
747
+ const providerIds = {
748
+ llm: nullableTupleIdentifier(tuple[0]),
749
+ stt: nullableTupleIdentifier(tuple[1]),
750
+ tts: nullableTupleIdentifier(tuple[2])
751
+ };
752
+ const modelIds = {
753
+ llm: nullableTupleIdentifier(tuple[3]),
754
+ tts: nullableTupleIdentifier(tuple[4]),
755
+ stt: nullableTupleIdentifier(tuple[5])
756
+ };
757
+ const selections = {};
758
+ for (const role of AI_RUNTIME_ROLES) {
759
+ const providerId = providerIds[role];
760
+ const modelId = modelIds[role];
761
+ const provider = providerId
762
+ ? this.#providers.get(providerKey(role, providerId)) ?? null
763
+ : null;
764
+ if (!providerId && !modelId) {
765
+ selections[role] = {
766
+ default: null,
767
+ localOnly: null
768
+ };
769
+ continue;
770
+ }
771
+ if (!providerId || !modelId) {
772
+ throw operationError(
773
+ `AI ${role} selection requires both provider and model ids.`,
774
+ 'ARCANE_AI_SELECTION_INCOMPLETE'
775
+ );
776
+ }
777
+ const selection = {
778
+ providerId,
779
+ modelId,
780
+ localOnly: provider?.localOnly ?? null
781
+ };
782
+ selections[role] = {
783
+ default: selection,
784
+ localOnly: provider?.localOnly === true
785
+ ? {...selection, localOnly: true}
786
+ : null
787
+ };
788
+ }
789
+ return this.configure(selections);
790
+ }
791
+
792
+ status(role = null) {
793
+ if (role === null) {
794
+ return getAIRuntimeState();
795
+ }
796
+ assertRole(role);
797
+ return getAIRuntimeState().roles[role];
798
+ }
799
+
800
+ catalog(role) {
801
+ assertRole(role);
802
+ const entries = [];
803
+ for (const provider of this.#providers.values()) {
804
+ if (provider.role !== role) {
805
+ continue;
806
+ }
807
+ const providerCatalog = provider.catalog();
808
+ if (!Array.isArray(providerCatalog)) {
809
+ fail('AI provider.catalog() must synchronously return an array.');
810
+ }
811
+ entries.push(
812
+ Object.freeze(
813
+ {
814
+ providerId: provider.id,
815
+ localOnly: provider.localOnly,
816
+ models: Object.freeze(providerCatalog.slice())
817
+ }
818
+ )
819
+ );
820
+ }
821
+ return Object.freeze(entries);
822
+ }
823
+
824
+ async inspect(role, options = {}) {
825
+ this.#assertOpen();
826
+ this.#assertNotConfiguring();
827
+ assertRole(role);
828
+ const {localOnly, signal} = immutableInspectionOptions(options);
829
+ this.#assertOpen();
830
+ this.#assertNotConfiguring();
831
+ if (signal?.aborted) {
832
+ throw normalizedAbort();
833
+ }
834
+ const slot = this.#slots[role];
835
+ const generation = slot.generation;
836
+ const selection = this.selection(role, {localOnly});
837
+ if (!selection) {
838
+ return Object.freeze(
839
+ {
840
+ available: false,
841
+ code: 'ARCANE_AI_ROLE_NOT_SELECTED',
842
+ message: `No ${role} provider and model are selected.`
843
+ }
844
+ );
845
+ }
846
+ const provider = this.#providers.get(
847
+ providerKey(role, selection.providerId)
848
+ ) ?? null;
849
+ if (!provider) {
850
+ return Object.freeze(
851
+ {
852
+ available: false,
853
+ code: 'ARCANE_AI_PROVIDER_UNAVAILABLE',
854
+ message: `The selected ${role} provider is not registered.`
855
+ }
856
+ );
857
+ }
858
+
859
+ let inspection;
860
+ try {
861
+ if (signal?.aborted) {
862
+ throw normalizedAbort();
863
+ }
864
+ inspection = await provider.inspect(
865
+ selection,
866
+ {role, signal}
867
+ );
868
+ if (signal?.aborted) {
869
+ throw normalizedAbort();
870
+ }
871
+ } catch (error) {
872
+ if (isAbort(error, signal)) {
873
+ throw normalizedAbort(error);
874
+ }
875
+ this.#assertOpen();
876
+ this.#assertNotConfiguring();
877
+ this.#assertCurrentOperation(slot, generation, signal);
878
+ const unavailable = stateError(
879
+ error,
880
+ 'ARCANE_AI_PROVIDER_AUTHORITY_BLOCKED'
881
+ );
882
+ return Object.freeze(
883
+ {
884
+ available: false,
885
+ code: unavailable.code,
886
+ message: unavailable.message
887
+ }
888
+ );
889
+ }
890
+ this.#assertOpen();
891
+ this.#assertNotConfiguring();
892
+ this.#assertCurrentOperation(slot, generation, signal);
893
+ try {
894
+ validateInspection(inspection, selection);
895
+ return inspection;
896
+ } catch (error) {
897
+ const unavailable = stateError(
898
+ error,
899
+ 'ARCANE_AI_PROVIDER_AUTHORITY_BLOCKED'
900
+ );
901
+ return Object.freeze(
902
+ {
903
+ available: false,
904
+ code: unavailable.code,
905
+ message: unavailable.message
906
+ }
907
+ );
908
+ }
909
+ }
910
+
911
+ async start(options) {
912
+ this.#assertOpen();
913
+ this.#assertNotConfiguring();
914
+ if (!this.#configured) {
915
+ throw operationError(
916
+ 'AI provider routes must be configured before startup.',
917
+ 'ARCANE_AI_RUNTIME_NOT_CONFIGURED'
918
+ );
919
+ }
920
+ const normalized = immutableStartupOptions(options);
921
+ await this.#speechTransition.catch(
922
+ function retainPriorAIStartupSpeechTransitionFailure() {}
923
+ );
924
+ await Promise.all(
925
+ AI_RUNTIME_ROLES.map(
926
+ function awaitActiveAIProviderUnload(role) {
927
+ return this.#slots[role].unloadPromise ?? Promise.resolve();
928
+ },
929
+ this
930
+ )
931
+ );
932
+ this.#assertOpen();
933
+ this.#assertNotConfiguring();
934
+ if (normalized.startMuted) {
935
+ await this.setSpeechMuted(true);
936
+ } else {
937
+ this.#speechDesiredMuted = false;
938
+ }
939
+ this.#assertOpen();
940
+ this.#assertNotConfiguring();
941
+ return startAIRuntime(normalized);
942
+ }
943
+
944
+ load(role, options = {}) {
945
+ this.#assertOpen();
946
+ this.#assertNotConfiguring();
947
+ assertRole(role);
948
+ assertPlainObject(options, 'AI provider load options');
949
+ for (const key of Reflect.ownKeys(options)) {
950
+ if (key !== 'signal' && key !== 'localOnly') {
951
+ fail('AI provider load options contain an unknown option.');
952
+ }
953
+ }
954
+ const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
955
+ const localOnly = Object.hasOwn(options, 'localOnly')
956
+ ? options.localOnly
957
+ : false;
958
+ assertAbortSignal(signal);
959
+ if (typeof localOnly !== 'boolean') {
960
+ fail('AI provider load localOnly must be a boolean.');
961
+ }
962
+ const slot = this.#slots[role];
963
+ if (slot.disposed) {
964
+ return Promise.reject(
965
+ operationError(
966
+ `AI role ${role} is disposed and must be explicitly reconfigured.`,
967
+ 'ARCANE_AI_ROLE_DISPOSED'
968
+ )
969
+ );
970
+ }
971
+ if (slot.unloadPromise || slot.disposePromise) {
972
+ return Promise.reject(
973
+ operationError(
974
+ `AI role ${role} cannot load while cleanup is active.`,
975
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
976
+ )
977
+ );
978
+ }
979
+ if (slot.request) {
980
+ return Promise.reject(
981
+ operationError(
982
+ `AI role ${role} cannot load during an active request.`,
983
+ 'ARCANE_AI_ROLE_BUSY'
984
+ )
985
+ );
986
+ }
987
+ if (role === 'tts' && this.#speechDesiredMuted) {
988
+ return Promise.reject(
989
+ operationError(
990
+ 'The TTS role remains unloaded while speech is muted.',
991
+ 'ARCANE_AI_TTS_MUTED'
992
+ )
993
+ );
994
+ }
995
+ const targetSelection = localOnly
996
+ ? slot.routes.localOnly
997
+ : slot.routes.default;
998
+ if (!targetSelection) {
999
+ const code = localOnly
1000
+ ? 'AI_LOCAL_MODEL_REQUIRED'
1001
+ : 'ARCANE_AI_ROLE_NOT_SELECTED';
1002
+ const message = localOnly
1003
+ ? `No explicit local-only ${role} route is configured.`
1004
+ : `No ${role} provider and model are selected.`;
1005
+ return Promise.reject(operationError(message, code));
1006
+ }
1007
+ if (slot.loadPromise) {
1008
+ if (this.#sameSelection(slot.selection, targetSelection)) {
1009
+ return slot.loadPromise;
1010
+ }
1011
+ return Promise.reject(
1012
+ operationError(
1013
+ `AI role ${role} is loading a different explicit route.`,
1014
+ 'ARCANE_AI_ROUTE_NOT_READY'
1015
+ )
1016
+ );
1017
+ }
1018
+ if (slot.ready && this.#sameSelection(slot.selection, targetSelection)) {
1019
+ const readyProvider = this.#providers.get(
1020
+ providerKey(role, targetSelection.providerId)
1021
+ ) ?? null;
1022
+ try {
1023
+ const readyStatus = readyProvider
1024
+ ? validateProviderStatus(readyProvider.status())
1025
+ : null;
1026
+ if (readyStatus?.state === 'ready'
1027
+ && readyStatus.loaded === true
1028
+ && readyStatus.busy === false) {
1029
+ publishAIRuntimeRoleState(
1030
+ role,
1031
+ roleRecord(
1032
+ role,
1033
+ slot.selection,
1034
+ {state: 'ready', loaded: true}
1035
+ )
1036
+ );
1037
+ return Promise.resolve(this.status(role));
1038
+ }
1039
+ } catch {
1040
+ // The private ready flag is revoked when provider proof is stale.
1041
+ }
1042
+ slot.ready = false;
1043
+ }
1044
+ if (slot.ready && !this.#sameSelection(slot.selection, targetSelection)) {
1045
+ return Promise.reject(
1046
+ operationError(
1047
+ `AI role ${role} must unload before changing routes.`,
1048
+ 'ARCANE_AI_ROUTE_SWITCH_REQUIRES_UNLOAD'
1049
+ )
1050
+ );
1051
+ }
1052
+ if (!this.#sameSelection(slot.selection, targetSelection)) {
1053
+ slot.generation += 1;
1054
+ slot.ready = false;
1055
+ slot.selection = targetSelection;
1056
+ }
1057
+
1058
+ const provider = this.#providerFor(slot);
1059
+ if (!provider) {
1060
+ slot.ready = false;
1061
+ const error = operationError(
1062
+ `The selected ${role} provider is not registered.`,
1063
+ 'ARCANE_AI_PROVIDER_UNAVAILABLE'
1064
+ );
1065
+ this.#publishRoleError(slot, error, false);
1066
+ return Promise.reject(error);
1067
+ }
1068
+ if (slot.selection.localOnly === true && provider.localOnly !== true) {
1069
+ const error = operationError(
1070
+ `The selected ${role} route requires a local-only provider.`,
1071
+ 'ARCANE_AI_LOCAL_PROVIDER_REQUIRED'
1072
+ );
1073
+ this.#publishRoleError(slot, error, false);
1074
+ return Promise.reject(error);
1075
+ }
1076
+
1077
+ slot.generation += 1;
1078
+ slot.ready = false;
1079
+ const generation = slot.generation;
1080
+ const operationId = this.#nextOperationId(slot, 'load');
1081
+ const controller = new AbortController();
1082
+ slot.loadController = controller;
1083
+ const detachSignal = this.#forwardAbort(signal, controller);
1084
+ const runtime = this;
1085
+ const loadOperation = Promise.resolve().then(
1086
+ async function loadAIProviderRole() {
1087
+ try {
1088
+ if (controller.signal.aborted) {
1089
+ throw normalizedAbort();
1090
+ }
1091
+ const inspection = await provider.inspect(
1092
+ slot.selection,
1093
+ {role, signal: controller.signal}
1094
+ );
1095
+ validateInspection(inspection, slot.selection);
1096
+ runtime.#assertCurrentOperation(slot, generation, controller.signal);
1097
+ await provider.load(
1098
+ {
1099
+ role,
1100
+ selection: slot.selection,
1101
+ signal: controller.signal,
1102
+ progress: function publishAIProviderLoadProgress(progress) {
1103
+ runtime.#publishLoadProgress(
1104
+ slot,
1105
+ generation,
1106
+ operationId,
1107
+ progress
1108
+ );
1109
+ }
1110
+ }
1111
+ );
1112
+ runtime.#assertCurrentOperation(slot, generation, controller.signal);
1113
+ const providerStatus = validateProviderStatus(provider.status());
1114
+ if (providerStatus.state !== 'ready'
1115
+ || providerStatus.loaded !== true
1116
+ || providerStatus.busy !== false) {
1117
+ throw operationError(
1118
+ `The selected ${role} provider did not become ready.`,
1119
+ 'ARCANE_AI_PROVIDER_NOT_READY'
1120
+ );
1121
+ }
1122
+ slot.ready = true;
1123
+ if (role === 'tts' && !runtime.#speechDesiredMuted) {
1124
+ runtime.#speechMuted = false;
1125
+ }
1126
+ publishAIRuntimeRoleState(
1127
+ role,
1128
+ roleRecord(
1129
+ role,
1130
+ slot.selection,
1131
+ {
1132
+ state: 'ready',
1133
+ loaded: true
1134
+ }
1135
+ )
1136
+ );
1137
+ return runtime.status(role);
1138
+ } catch (error) {
1139
+ const normalized = isAbort(error, controller.signal)
1140
+ ? normalizedAbort(error)
1141
+ : error;
1142
+ if (slot.generation === generation && !slot.unloadPromise) {
1143
+ slot.ready = false;
1144
+ if (role === 'tts') {
1145
+ runtime.#speechMuted = true;
1146
+ }
1147
+ runtime.#publishRoleError(slot, normalized, false);
1148
+ }
1149
+ throw normalized;
1150
+ } finally {
1151
+ detachSignal();
1152
+ if (slot.generation === generation) {
1153
+ slot.loadController = null;
1154
+ }
1155
+ slot.loadPromise = null;
1156
+ }
1157
+ }
1158
+ );
1159
+ slot.loadPromise = loadOperation;
1160
+ publishAIRuntimeRoleState(
1161
+ role,
1162
+ roleRecord(
1163
+ role,
1164
+ slot.selection,
1165
+ {
1166
+ state: 'loading',
1167
+ operationId,
1168
+ progress: {
1169
+ phase: 'admission',
1170
+ completed: 0,
1171
+ total: null,
1172
+ unit: 'items',
1173
+ heartbeat: false
1174
+ }
1175
+ }
1176
+ )
1177
+ );
1178
+ return loadOperation;
1179
+ }
1180
+
1181
+ unload(role, options = {}) {
1182
+ this.#assertNotConfiguring();
1183
+ assertRole(role);
1184
+ assertPlainObject(options, 'AI provider unload options');
1185
+ for (const key of Reflect.ownKeys(options)) {
1186
+ if (key !== 'signal') {
1187
+ fail('AI provider unload options contain an unknown option.');
1188
+ }
1189
+ }
1190
+ const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
1191
+ assertAbortSignal(signal);
1192
+ const slot = this.#slots[role];
1193
+ if (slot.unloadPromise) {
1194
+ return slot.unloadPromise;
1195
+ }
1196
+
1197
+ const provider = this.#providerFor(slot);
1198
+ const before = this.status(role);
1199
+ let providerStatus = null;
1200
+ if (provider) {
1201
+ try {
1202
+ providerStatus = validateProviderStatus(provider.status());
1203
+ } catch {
1204
+ // Cleanup remains fail-closed when provider status is malformed.
1205
+ }
1206
+ }
1207
+ if (slot.disposed) {
1208
+ return Promise.resolve(before);
1209
+ }
1210
+ const providerOwned = slot.ready
1211
+ || providerStatus?.loaded === true
1212
+ || providerStatus?.busy === true;
1213
+ if (!slot.loadPromise
1214
+ && !slot.request
1215
+ && !providerOwned
1216
+ && (!provider || providerStatus)) {
1217
+ slot.ready = false;
1218
+ return Promise.resolve(before);
1219
+ }
1220
+
1221
+ slot.generation += 1;
1222
+ const generation = slot.generation;
1223
+ slot.ready = false;
1224
+ if (role === 'tts') {
1225
+ this.#speechMuted = true;
1226
+ }
1227
+ slot.loadController?.abort();
1228
+ slot.request?.controller.abort();
1229
+ const capturedLoad = slot.loadPromise;
1230
+ const capturedRequestRecord = slot.request;
1231
+ const capturedRequest = capturedRequestRecord?.promise ?? null;
1232
+ const unloadOperationId = providerOwned
1233
+ ? this.#nextOperationId(slot, 'unload')
1234
+ : null;
1235
+ const runtime = this;
1236
+ const unloadOperation = Promise.resolve().then(async function unloadAIProviderRole() {
1237
+ try {
1238
+ if (signal?.aborted) {
1239
+ throw normalizedAbort();
1240
+ }
1241
+ if (capturedRequestRecord?.cancel) {
1242
+ try {
1243
+ await capturedRequestRecord.cancel(
1244
+ operationError(
1245
+ `AI role ${role} is unloading.`,
1246
+ 'ARCANE_AI_REQUEST_ABORTED'
1247
+ )
1248
+ );
1249
+ } catch {
1250
+ // Provider unload below remains the authoritative cleanup.
1251
+ }
1252
+ }
1253
+ await Promise.allSettled(
1254
+ [capturedLoad, capturedRequest].filter(Boolean)
1255
+ );
1256
+ if (signal?.aborted) {
1257
+ throw normalizedAbort();
1258
+ }
1259
+ if (provider) {
1260
+ await provider.unload(
1261
+ {
1262
+ role,
1263
+ selection: slot.selection,
1264
+ signal
1265
+ }
1266
+ );
1267
+ const unloadedStatus = validateProviderStatus(provider.status());
1268
+ if (unloadedStatus.loaded || unloadedStatus.busy) {
1269
+ throw operationError(
1270
+ `The selected ${role} provider remained loaded after unload.`,
1271
+ 'ARCANE_AI_PROVIDER_UNLOAD_INCOMPLETE'
1272
+ );
1273
+ }
1274
+ }
1275
+ if (slot.generation === generation) {
1276
+ publishAIRuntimeRoleState(
1277
+ role,
1278
+ roleRecord(role, slot.selection)
1279
+ );
1280
+ }
1281
+ return runtime.status(role);
1282
+ } catch (error) {
1283
+ if (slot.generation === generation) {
1284
+ runtime.#publishRoleError(slot, error, providerOwned);
1285
+ }
1286
+ throw error;
1287
+ } finally {
1288
+ slot.unloadPromise = null;
1289
+ }
1290
+ });
1291
+ slot.unloadPromise = unloadOperation;
1292
+ if (providerOwned) {
1293
+ publishAIRuntimeRoleState(
1294
+ role,
1295
+ roleRecord(
1296
+ role,
1297
+ slot.selection,
1298
+ {
1299
+ state: 'unloading',
1300
+ loaded: true,
1301
+ operationId: unloadOperationId
1302
+ }
1303
+ )
1304
+ );
1305
+ }
1306
+ return unloadOperation;
1307
+ }
1308
+
1309
+ dispose(role, options = {}) {
1310
+ this.#assertNotConfiguring();
1311
+ assertRole(role);
1312
+ assertPlainObject(options, 'AI provider dispose options');
1313
+ for (const key of Reflect.ownKeys(options)) {
1314
+ if (key !== 'signal') {
1315
+ fail('AI provider dispose options contain an unknown option.');
1316
+ }
1317
+ }
1318
+ const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
1319
+ assertAbortSignal(signal);
1320
+ const slot = this.#slots[role];
1321
+ if (slot.disposePromise) {
1322
+ return slot.disposePromise;
1323
+ }
1324
+ if (slot.disposed) {
1325
+ return Promise.resolve(this.status(role));
1326
+ }
1327
+
1328
+ const provider = this.#providerFor(slot);
1329
+ const runtime = this;
1330
+ const disposeOperation = Promise.resolve().then(async function disposeAIProviderRole() {
1331
+ try {
1332
+ await runtime.unload(role, {signal});
1333
+ if (provider) {
1334
+ await provider.dispose(
1335
+ {
1336
+ role,
1337
+ selection: slot.selection,
1338
+ signal
1339
+ }
1340
+ );
1341
+ const disposedStatus = validateProviderStatus(provider.status());
1342
+ if (disposedStatus.loaded || disposedStatus.busy) {
1343
+ throw operationError(
1344
+ `The selected ${role} provider remained active after disposal.`,
1345
+ 'ARCANE_AI_PROVIDER_DISPOSE_INCOMPLETE'
1346
+ );
1347
+ }
1348
+ }
1349
+ slot.ready = false;
1350
+ slot.disposed = true;
1351
+ publishAIRuntimeRoleState(
1352
+ role,
1353
+ roleRecord(
1354
+ role,
1355
+ slot.selection,
1356
+ {
1357
+ state: 'disposed'
1358
+ }
1359
+ )
1360
+ );
1361
+ return runtime.status(role);
1362
+ } catch (error) {
1363
+ runtime.#publishRoleError(slot, error, runtime.status(role).loaded);
1364
+ throw error;
1365
+ } finally {
1366
+ slot.disposePromise = null;
1367
+ }
1368
+ });
1369
+ slot.disposePromise = disposeOperation;
1370
+ return disposeOperation;
1371
+ }
1372
+
1373
+ disposeAll(options = {}) {
1374
+ this.#assertNotConfiguring();
1375
+ assertPlainObject(options, 'AI provider dispose-all options');
1376
+ for (const key of Reflect.ownKeys(options)) {
1377
+ if (key !== 'signal') {
1378
+ fail('AI provider dispose-all options contain an unknown option.');
1379
+ }
1380
+ }
1381
+ const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
1382
+ assertAbortSignal(signal);
1383
+ if (this.#disposeAllPromise) {
1384
+ return this.#disposeAllPromise;
1385
+ }
1386
+ if (this.#closed) {
1387
+ return Promise.resolve(this.status());
1388
+ }
1389
+ this.#closing = true;
1390
+ const runtime = this;
1391
+ const disposing = (async function disposeAIProviderRuntime() {
1392
+ const selectedProviders = new Set();
1393
+ const tasks = [];
1394
+ for (const role of AI_RUNTIME_ROLES) {
1395
+ const selectedProvider = runtime.#providerFor(runtime.#slots[role]);
1396
+ if (selectedProvider) {
1397
+ selectedProviders.add(selectedProvider);
1398
+ }
1399
+ tasks.push(
1400
+ {
1401
+ provider: selectedProvider,
1402
+ operation: runtime.dispose(role, {signal})
1403
+ }
1404
+ );
1405
+ }
1406
+ const uniqueProviders = new Set(runtime.#providers.values());
1407
+ for (const provider of uniqueProviders) {
1408
+ if (selectedProviders.has(provider)
1409
+ || runtime.#disposeAllCompletedProviders.has(provider)) {
1410
+ continue;
1411
+ }
1412
+ const slot = runtime.#slots[provider.role];
1413
+ const selection = ROUTE_KEYS.map(
1414
+ function selectRegisteredAIProviderRoute(routeName) {
1415
+ return slot.routes[routeName];
1416
+ }
1417
+ ).find(
1418
+ function findRegisteredAIProviderRoute(candidate) {
1419
+ return candidate?.providerId === provider.id;
1420
+ }
1421
+ ) ?? null;
1422
+ tasks.push(
1423
+ {
1424
+ provider,
1425
+ operation: Promise.resolve().then(
1426
+ async function disposeUnselectedAIProvider() {
1427
+ if (signal?.aborted) {
1428
+ throw normalizedAbort();
1429
+ }
1430
+ await provider.dispose(
1431
+ {
1432
+ role: provider.role,
1433
+ selection,
1434
+ signal
1435
+ }
1436
+ );
1437
+ const disposedStatus = validateProviderStatus(
1438
+ provider.status()
1439
+ );
1440
+ if (disposedStatus.loaded || disposedStatus.busy) {
1441
+ throw operationError(
1442
+ `AI provider ${providerKey(provider.role, provider.id)} remained active after disposal.`,
1443
+ 'ARCANE_AI_PROVIDER_DISPOSE_INCOMPLETE'
1444
+ );
1445
+ }
1446
+ }
1447
+ )
1448
+ }
1449
+ );
1450
+ }
1451
+ const results = await Promise.allSettled(
1452
+ tasks.map(function runAIProviderDisposal(task) {
1453
+ return task.operation;
1454
+ })
1455
+ );
1456
+ results.forEach(function retainCompletedAIProviderDisposal(result, index) {
1457
+ const provider = tasks[index].provider;
1458
+ if (result.status === 'fulfilled' && provider) {
1459
+ runtime.#disposeAllCompletedProviders.add(provider);
1460
+ }
1461
+ });
1462
+ const failed = results.find(function findFailedDispose(result) {
1463
+ return result.status === 'rejected';
1464
+ });
1465
+ if (failed) {
1466
+ throw failed.reason;
1467
+ }
1468
+ runtime.#providers.clear();
1469
+ runtime.#disposeAllCompletedProviders.clear();
1470
+ for (const role of AI_RUNTIME_ROLES) {
1471
+ const slot = runtime.#slots[role];
1472
+ slot.generation += 1;
1473
+ slot.routes = Object.freeze({default: null, localOnly: null});
1474
+ slot.selection = null;
1475
+ slot.loadController = null;
1476
+ slot.loadPromise = null;
1477
+ slot.unloadPromise = null;
1478
+ slot.disposePromise = null;
1479
+ slot.request = null;
1480
+ slot.ready = false;
1481
+ slot.disposed = true;
1482
+ }
1483
+ publishAIRuntimeRolesState(
1484
+ {
1485
+ llm: roleRecord('llm', null, {state: 'disposed'}),
1486
+ stt: roleRecord('stt', null, {state: 'disposed'}),
1487
+ tts: roleRecord('tts', null, {state: 'disposed'})
1488
+ }
1489
+ );
1490
+ runtime.#closing = false;
1491
+ runtime.#closed = true;
1492
+ runtime.#unsubscribeIntents?.();
1493
+ runtime.#unsubscribeIntents = null;
1494
+ return runtime.status();
1495
+ })();
1496
+ this.#disposeAllPromise = disposing.then(
1497
+ function releaseCompletedAIProviderRuntimeDisposal(result) {
1498
+ runtime.#disposeAllPromise = null;
1499
+ return result;
1500
+ },
1501
+ function releaseFailedAIProviderRuntimeDisposal(error) {
1502
+ runtime.#disposeAllPromise = null;
1503
+ throw error;
1504
+ }
1505
+ );
1506
+ return this.#disposeAllPromise;
1507
+ }
1508
+
1509
+ request(role, options = {}) {
1510
+ this.#assertOpen();
1511
+ this.#assertNotConfiguring();
1512
+ assertRole(role);
1513
+ assertClosedRecord(
1514
+ options,
1515
+ ['operation', 'payload', 'localOnly', 'signal'],
1516
+ 'AI provider request options'
1517
+ );
1518
+ if (!ROLE_OPERATION_SETS[role].has(options.operation)) {
1519
+ fail(
1520
+ `AI ${role} operation must be one of ${ROLE_OPERATIONS[role].join(', ')}.`
1521
+ );
1522
+ }
1523
+ if (options.localOnly !== true && options.localOnly !== false) {
1524
+ fail('AI provider request localOnly must be a boolean.');
1525
+ }
1526
+ assertAbortSignal(options.signal);
1527
+ try {
1528
+ assertCallbackFreeProviderValue(options.payload);
1529
+ } catch (error) {
1530
+ return Promise.reject(error);
1531
+ }
1532
+ const slot = this.#slots[role];
1533
+ if (slot.disposed) {
1534
+ return Promise.reject(
1535
+ operationError(
1536
+ `AI role ${role} is disposed and must be explicitly reconfigured.`,
1537
+ 'ARCANE_AI_ROLE_DISPOSED'
1538
+ )
1539
+ );
1540
+ }
1541
+ if (slot.unloadPromise || slot.disposePromise) {
1542
+ return Promise.reject(
1543
+ operationError(
1544
+ `AI role ${role} is cleaning up.`,
1545
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
1546
+ )
1547
+ );
1548
+ }
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
+ const targetSelection = options.localOnly
1558
+ ? slot.routes.localOnly
1559
+ : slot.routes.default;
1560
+ if (!targetSelection) {
1561
+ return Promise.reject(
1562
+ operationError(
1563
+ options.localOnly
1564
+ ? `No explicit local-only ${role} route is configured.`
1565
+ : `No default ${role} route is configured.`,
1566
+ options.localOnly
1567
+ ? 'AI_LOCAL_MODEL_REQUIRED'
1568
+ : 'ARCANE_AI_ROLE_NOT_SELECTED'
1569
+ )
1570
+ );
1571
+ }
1572
+ if (!this.#sameSelection(slot.selection, targetSelection)) {
1573
+ return Promise.reject(
1574
+ operationError(
1575
+ `The explicit ${options.localOnly ? 'local-only' : 'default'} ${role} route is not loaded.`,
1576
+ 'ARCANE_AI_ROUTE_NOT_READY'
1577
+ )
1578
+ );
1579
+ }
1580
+ const provider = this.#providerFor(slot);
1581
+ if (!provider) {
1582
+ return Promise.reject(
1583
+ operationError(
1584
+ `The selected ${role} provider is not registered.`,
1585
+ 'ARCANE_AI_PROVIDER_UNAVAILABLE'
1586
+ )
1587
+ );
1588
+ }
1589
+ let providerStatus;
1590
+ try {
1591
+ providerStatus = validateProviderStatus(provider.status());
1592
+ } catch (error) {
1593
+ slot.ready = false;
1594
+ const invalidStatus = operationError(
1595
+ `The selected ${role} provider did not return a valid status.`,
1596
+ 'ARCANE_AI_PROVIDER_STATUS_INVALID',
1597
+ error
1598
+ );
1599
+ this.#publishRoleError(slot, invalidStatus, false);
1600
+ return Promise.reject(invalidStatus);
1601
+ }
1602
+ if (!slot.ready
1603
+ || providerStatus.state !== 'ready'
1604
+ || providerStatus.loaded !== true
1605
+ || providerStatus.busy !== false) {
1606
+ slot.ready = false;
1607
+ return Promise.reject(
1608
+ operationError(
1609
+ `AI role ${role} is not ready.`,
1610
+ 'ARCANE_AI_ROLE_NOT_READY'
1611
+ )
1612
+ );
1613
+ }
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
+
1623
+ const generation = slot.generation;
1624
+ const operationId = this.#nextOperationId(slot, options.operation);
1625
+ const controller = new AbortController();
1626
+ const detachSignal = this.#forwardAbort(options.signal, controller);
1627
+ const runtime = this;
1628
+ const requestRecord = {
1629
+ controller,
1630
+ promise: null,
1631
+ cancel: null
1632
+ };
1633
+ slot.request = requestRecord;
1634
+ publishAIRuntimeRoleState(
1635
+ role,
1636
+ roleRecord(
1637
+ role,
1638
+ slot.selection,
1639
+ {
1640
+ state: 'ready',
1641
+ loaded: true,
1642
+ busy: true,
1643
+ operationId
1644
+ }
1645
+ )
1646
+ );
1647
+
1648
+ function restoreAIProviderRoleAfterRequest(error) {
1649
+ if (slot.generation !== generation || slot.unloadPromise) {
1650
+ return;
1651
+ }
1652
+ let providerState = null;
1653
+ try {
1654
+ providerState = validateProviderStatus(provider.status());
1655
+ } catch {
1656
+ // A malformed provider status is itself a lifecycle failure.
1657
+ }
1658
+ if (providerState?.state === 'ready'
1659
+ && providerState.loaded
1660
+ && !providerState.busy) {
1661
+ slot.ready = true;
1662
+ publishAIRuntimeRoleState(
1663
+ role,
1664
+ roleRecord(
1665
+ role,
1666
+ slot.selection,
1667
+ {
1668
+ state: 'ready',
1669
+ loaded: true
1670
+ }
1671
+ )
1672
+ );
1673
+ return;
1674
+ }
1675
+ slot.ready = false;
1676
+ runtime.#publishRoleError(slot, error, providerState?.loaded === true);
1677
+ }
1678
+
1679
+ if (options.operation === 'stream') {
1680
+ let providerHandle = null;
1681
+ let providerOpenPromise = null;
1682
+ let iterator = null;
1683
+ let cleanupPromise = null;
1684
+ let terminalSettled = false;
1685
+ let resolveTerminal;
1686
+ let rejectTerminal;
1687
+ const terminal = new Promise(function createAIProviderStreamTerminal(resolve, reject) {
1688
+ resolveTerminal = resolve;
1689
+ rejectTerminal = reject;
1690
+ });
1691
+ terminal.catch(function retainAIProviderStreamTerminalRejection() {});
1692
+ requestRecord.promise = terminal;
1693
+
1694
+ function settleAIProviderStream(error, value) {
1695
+ if (terminalSettled) {
1696
+ return;
1697
+ }
1698
+ terminalSettled = true;
1699
+ controller.signal.removeEventListener(
1700
+ 'abort',
1701
+ cancelAbortedAIProviderStream
1702
+ );
1703
+ detachSignal();
1704
+ if (slot.request === requestRecord) {
1705
+ slot.request = null;
1706
+ }
1707
+ if (error) {
1708
+ restoreAIProviderRoleAfterRequest(error);
1709
+ rejectTerminal(error);
1710
+ } else {
1711
+ restoreAIProviderRoleAfterRequest(null);
1712
+ resolveTerminal(value);
1713
+ }
1714
+ }
1715
+
1716
+ function beginAIProviderStreamHandleCleanup(opened, activeIterator, reason) {
1717
+ const cleanup = [];
1718
+ if (typeof opened?.cancel === 'function') {
1719
+ cleanup.push(
1720
+ Promise.resolve().then(function cancelOpenedAIStream() {
1721
+ return opened.cancel(reason);
1722
+ })
1723
+ );
1724
+ }
1725
+ if (activeIterator && typeof activeIterator.return === 'function') {
1726
+ cleanup.push(
1727
+ Promise.resolve().then(function returnOpenedAIStream() {
1728
+ return activeIterator.return();
1729
+ })
1730
+ );
1731
+ }
1732
+ return Promise.allSettled(cleanup);
1733
+ }
1734
+
1735
+ async function cleanupAIProviderStreamHandle(opened, activeIterator, reason) {
1736
+ return awaitBoundedStreamCleanup(
1737
+ beginAIProviderStreamHandleCleanup(
1738
+ opened,
1739
+ activeIterator,
1740
+ reason
1741
+ )
1742
+ );
1743
+ }
1744
+
1745
+ async function cleanupOwnedAIProviderStream(reason) {
1746
+ if (providerHandle) {
1747
+ return cleanupAIProviderStreamHandle(
1748
+ providerHandle,
1749
+ iterator,
1750
+ reason
1751
+ );
1752
+ }
1753
+ if (!providerOpenPromise) {
1754
+ return {completed: true, results: []};
1755
+ }
1756
+ const lateCleanup = providerOpenPromise.then(
1757
+ async function cleanupLateAIProviderStream(lateHandle) {
1758
+ if (!lateHandle || typeof lateHandle.cancel !== 'function') {
1759
+ throw operationError(
1760
+ 'The late AI provider stream did not expose cancellable ownership.',
1761
+ 'ARCANE_AI_STREAM_CLEANUP_INCOMPLETE'
1762
+ );
1763
+ }
1764
+ providerHandle = lateHandle;
1765
+ const results = await beginAIProviderStreamHandleCleanup(
1766
+ lateHandle,
1767
+ null,
1768
+ reason
1769
+ );
1770
+ assertStreamCleanupComplete(
1771
+ {completed: true, results}
1772
+ );
1773
+ },
1774
+ function confirmRejectedAIProviderStreamOpen() {
1775
+ // A rejected open confirms that no provider handle was returned.
1776
+ }
1777
+ );
1778
+ return awaitBoundedStreamCleanup(
1779
+ Promise.allSettled([lateCleanup])
1780
+ );
1781
+ }
1782
+
1783
+ async function cancelAIProviderStream(reason, terminalError = null) {
1784
+ if (cleanupPromise) {
1785
+ return cleanupPromise;
1786
+ }
1787
+ let resolveCleanup;
1788
+ let rejectCleanup;
1789
+ cleanupPromise = new Promise(
1790
+ function createAIProviderStreamCleanup(resolve, reject) {
1791
+ resolveCleanup = resolve;
1792
+ rejectCleanup = reject;
1793
+ }
1794
+ );
1795
+ controller.abort();
1796
+ (async function closeAIProviderStream() {
1797
+ const terminalOutcome = terminalError ?? normalizedAbort(
1798
+ reason instanceof Error
1799
+ ? reason
1800
+ : operationError(
1801
+ 'The AI stream was cancelled.',
1802
+ 'ARCANE_AI_REQUEST_ABORTED'
1803
+ )
1804
+ );
1805
+ try {
1806
+ const cleanupOutcome = await cleanupOwnedAIProviderStream(reason);
1807
+ assertStreamCleanupComplete(cleanupOutcome);
1808
+ settleAIProviderStream(terminalOutcome);
1809
+ } catch (cleanupError) {
1810
+ const incomplete = cleanupError?.code === 'ARCANE_AI_STREAM_CLEANUP_INCOMPLETE'
1811
+ ? cleanupError
1812
+ : operationError(
1813
+ 'The AI provider stream did not confirm bounded cleanup.',
1814
+ 'ARCANE_AI_STREAM_CLEANUP_INCOMPLETE',
1815
+ cleanupError
1816
+ );
1817
+ settleAIProviderStream(incomplete);
1818
+ throw incomplete;
1819
+ }
1820
+ })().then(resolveCleanup, rejectCleanup);
1821
+ await cleanupPromise;
1822
+ }
1823
+ requestRecord.cancel = cancelAIProviderStream;
1824
+ function cancelAbortedAIProviderStream() {
1825
+ cancelAIProviderStream(normalizedAbort()).catch(
1826
+ function retainAbortedAIProviderStreamCleanupFailure() {}
1827
+ );
1828
+ }
1829
+ controller.signal.addEventListener(
1830
+ 'abort',
1831
+ cancelAbortedAIProviderStream,
1832
+ {once: true}
1833
+ );
1834
+
1835
+ const openPromise = (async function openAIProviderStream() {
1836
+ let opened = null;
1837
+ let detachOpenAbort = function detachAbsentAIStreamOpenAbort() {};
1838
+ try {
1839
+ if (controller.signal.aborted) {
1840
+ throw normalizedAbort();
1841
+ }
1842
+ providerOpenPromise = Promise.resolve().then(
1843
+ function requestAIProviderStream() {
1844
+ return provider.request(
1845
+ {
1846
+ role,
1847
+ selection: slot.selection,
1848
+ operation: options.operation,
1849
+ payload: options.payload,
1850
+ signal: controller.signal
1851
+ }
1852
+ );
1853
+ }
1854
+ );
1855
+ const abortedOpen = new Promise(function rejectAbortedAIStreamOpen(resolve, reject) {
1856
+ function rejectAIStreamOpenAbort() {
1857
+ reject(normalizedAbort());
1858
+ }
1859
+ controller.signal.addEventListener(
1860
+ 'abort',
1861
+ rejectAIStreamOpenAbort,
1862
+ {once: true}
1863
+ );
1864
+ detachOpenAbort = function detachAIStreamOpenAbort() {
1865
+ controller.signal.removeEventListener(
1866
+ 'abort',
1867
+ rejectAIStreamOpenAbort
1868
+ );
1869
+ };
1870
+ });
1871
+ opened = await Promise.race([providerOpenPromise, abortedOpen]);
1872
+ if (!opened
1873
+ || typeof opened[Symbol.asyncIterator] !== 'function'
1874
+ || typeof opened.cancel !== 'function'
1875
+ || !opened.result
1876
+ || typeof opened.result.then !== 'function') {
1877
+ throw operationError(
1878
+ 'AI stream providers must return an async iterable with result and cancel().',
1879
+ 'ARCANE_AI_PROVIDER_STREAM_INVALID'
1880
+ );
1881
+ }
1882
+ providerHandle = opened;
1883
+ iterator = opened[Symbol.asyncIterator]();
1884
+ runtime.#assertCurrentOperation(slot, generation, controller.signal);
1885
+ Promise.resolve(opened.result).then(
1886
+ function acceptAIProviderStreamResult(value) {
1887
+ try {
1888
+ runtime.#assertCurrentOperation(
1889
+ slot,
1890
+ generation,
1891
+ controller.signal
1892
+ );
1893
+ settleAIProviderStream(null, value);
1894
+ } catch (error) {
1895
+ const normalized = isAbort(error, controller.signal)
1896
+ ? normalizedAbort(error)
1897
+ : error;
1898
+ settleAIProviderStream(normalized);
1899
+ }
1900
+ },
1901
+ async function rejectAIProviderStreamResult(error) {
1902
+ const normalized = isAbort(error, controller.signal)
1903
+ ? normalizedAbort(error)
1904
+ : error;
1905
+ try {
1906
+ await cancelAIProviderStream(normalized, normalized);
1907
+ } catch {
1908
+ // The original provider result error remains terminal.
1909
+ }
1910
+ }
1911
+ );
1912
+
1913
+ const handle = {
1914
+ result: terminal,
1915
+ cancel: cancelAIProviderStream,
1916
+ async next(value) {
1917
+ try {
1918
+ return await iterator.next(value);
1919
+ } catch (error) {
1920
+ await cancelAIProviderStream(error);
1921
+ throw isAbort(error, controller.signal)
1922
+ ? normalizedAbort(error)
1923
+ : error;
1924
+ }
1925
+ },
1926
+ async return(value) {
1927
+ await cancelAIProviderStream(
1928
+ operationError(
1929
+ 'The AI stream consumer stopped before completion.',
1930
+ 'ARCANE_AI_REQUEST_ABORTED'
1931
+ )
1932
+ );
1933
+ return {value, done: true};
1934
+ },
1935
+ async throw(error) {
1936
+ await cancelAIProviderStream(error);
1937
+ throw error;
1938
+ },
1939
+ [Symbol.asyncIterator]() {
1940
+ return this;
1941
+ }
1942
+ };
1943
+ return Object.freeze(handle);
1944
+ } catch (error) {
1945
+ const normalized = isAbort(error, controller.signal)
1946
+ ? normalizedAbort(error)
1947
+ : error;
1948
+ if (cleanupPromise) {
1949
+ try {
1950
+ await cleanupPromise;
1951
+ } catch (cleanupError) {
1952
+ throw cleanupError;
1953
+ }
1954
+ } else if (providerHandle) {
1955
+ try {
1956
+ await cancelAIProviderStream(normalized, normalized);
1957
+ } catch {
1958
+ // The original stream-open failure remains authoritative.
1959
+ }
1960
+ } else if (opened) {
1961
+ await cleanupAIProviderStreamHandle(
1962
+ opened,
1963
+ null,
1964
+ normalized
1965
+ ).catch(
1966
+ function retainRejectedAIProviderStreamHandleCleanup() {}
1967
+ );
1968
+ settleAIProviderStream(normalized);
1969
+ } else {
1970
+ settleAIProviderStream(normalized);
1971
+ }
1972
+ throw normalized;
1973
+ } finally {
1974
+ detachOpenAbort();
1975
+ }
1976
+ })();
1977
+ return openPromise;
1978
+ }
1979
+
1980
+ requestRecord.cancel = async function cancelAIProviderRequest() {
1981
+ controller.abort();
1982
+ await requestRecord.promise?.catch(
1983
+ function retainCancelledAIProviderRequest() {}
1984
+ );
1985
+ };
1986
+ requestRecord.promise = (async function requestAIProviderRole() {
1987
+ let requestError = null;
1988
+ try {
1989
+ if (controller.signal.aborted) {
1990
+ throw normalizedAbort();
1991
+ }
1992
+ const result = await provider.request(
1993
+ {
1994
+ role,
1995
+ selection: slot.selection,
1996
+ operation: options.operation,
1997
+ payload: options.payload,
1998
+ signal: controller.signal
1999
+ }
2000
+ );
2001
+ runtime.#assertCurrentOperation(slot, generation, controller.signal);
2002
+ return result;
2003
+ } catch (error) {
2004
+ const normalized = isAbort(error, controller.signal)
2005
+ ? normalizedAbort(error)
2006
+ : error;
2007
+ requestError = normalized;
2008
+ restoreAIProviderRoleAfterRequest(normalized);
2009
+ throw normalized;
2010
+ } finally {
2011
+ detachSignal();
2012
+ if (slot.request === requestRecord) {
2013
+ slot.request = null;
2014
+ }
2015
+ if (!requestError
2016
+ && slot.generation === generation
2017
+ && !slot.unloadPromise) {
2018
+ restoreAIProviderRoleAfterRequest(null);
2019
+ }
2020
+ }
2021
+ })();
2022
+ return requestRecord.promise;
2023
+ }
2024
+
2025
+ chat(payload, options = {}) {
2026
+ return this.#roleRequestAlias('llm', 'chat', payload, options);
2027
+ }
2028
+
2029
+ stream(payload, options = {}) {
2030
+ return this.#roleRequestAlias('llm', 'stream', payload, options);
2031
+ }
2032
+
2033
+ transcribe(payload, options = {}) {
2034
+ return this.#roleRequestAlias('stt', 'transcribe', payload, options);
2035
+ }
2036
+
2037
+ synthesize(payload, options = {}) {
2038
+ return this.#roleRequestAlias('tts', 'synthesize', payload, options);
2039
+ }
2040
+
2041
+ cancel(role) {
2042
+ assertRole(role);
2043
+ const slot = this.#slots[role];
2044
+ if (!slot.request) {
2045
+ return false;
2046
+ }
2047
+ slot.request.controller.abort();
2048
+ slot.request.cancel?.(
2049
+ operationError(
2050
+ `AI role ${role} request was cancelled.`,
2051
+ 'ARCANE_AI_REQUEST_ABORTED'
2052
+ )
2053
+ ).catch(function retainAIProviderCancelFailureInState() {});
2054
+ return true;
2055
+ }
2056
+
2057
+ async setSpeechMuted(muted) {
2058
+ this.#assertOpen();
2059
+ this.#assertNotConfiguring();
2060
+ if (typeof muted !== 'boolean') {
2061
+ fail('AI speech muted state must be a boolean.');
2062
+ }
2063
+ this.#speechDesiredMuted = muted;
2064
+ if (muted) {
2065
+ this.#speechMuted = true;
2066
+ this.#slots.tts.loadController?.abort();
2067
+ this.cancel('tts');
2068
+ }
2069
+ const runtime = this;
2070
+ const transition = this.#speechTransition.catch(
2071
+ function retainPriorAISpeechTransitionFailure() {}
2072
+ ).then(
2073
+ async function reconcileLatestAISpeechPreference() {
2074
+ while (true) {
2075
+ const desiredMuted = runtime.#speechDesiredMuted;
2076
+ if (desiredMuted) {
2077
+ runtime.cancel('tts');
2078
+ try {
2079
+ await runtime.unload('tts');
2080
+ } finally {
2081
+ runtime.#speechMuted = true;
2082
+ }
2083
+ } else {
2084
+ const slot = runtime.#slots.tts;
2085
+ if (slot.unloadPromise) {
2086
+ await slot.unloadPromise;
2087
+ }
2088
+ if (runtime.#speechDesiredMuted) {
2089
+ continue;
2090
+ }
2091
+ await runtime.load('tts');
2092
+ }
2093
+ if (runtime.#speechDesiredMuted === desiredMuted) {
2094
+ runtime.#speechMuted = desiredMuted;
2095
+ return runtime.status('tts');
2096
+ }
2097
+ }
2098
+ }
2099
+ );
2100
+ this.#speechTransition = transition;
2101
+ return transition;
2102
+ }
2103
+
2104
+ #providerFor(slot) {
2105
+ if (!slot.selection) {
2106
+ return null;
2107
+ }
2108
+ return this.#providers.get(
2109
+ providerKey(slot.role, slot.selection.providerId)
2110
+ ) ?? null;
2111
+ }
2112
+
2113
+ #roleRequestAlias(role, operation, payload, options) {
2114
+ assertPlainObject(options, `AI ${operation} options`);
2115
+ for (const key of Reflect.ownKeys(options)) {
2116
+ if (key !== 'localOnly' && key !== 'signal') {
2117
+ fail(`AI ${operation} options contain an unknown option.`);
2118
+ }
2119
+ }
2120
+ const localOnly = Object.hasOwn(options, 'localOnly')
2121
+ ? options.localOnly
2122
+ : false;
2123
+ const signal = Object.hasOwn(options, 'signal')
2124
+ ? options.signal
2125
+ : null;
2126
+ return this.request(
2127
+ role,
2128
+ {
2129
+ operation,
2130
+ payload,
2131
+ localOnly,
2132
+ signal
2133
+ }
2134
+ );
2135
+ }
2136
+
2137
+ #sameSelection(left, right) {
2138
+ return left === right
2139
+ || Boolean(
2140
+ left
2141
+ && right
2142
+ && left.providerId === right.providerId
2143
+ && left.modelId === right.modelId
2144
+ && left.localOnly === right.localOnly
2145
+ );
2146
+ }
2147
+
2148
+ #assertOpen() {
2149
+ if (this.#closed || this.#closing) {
2150
+ throw operationError(
2151
+ this.#closed
2152
+ ? 'The AI provider runtime is disposed.'
2153
+ : 'The AI provider runtime is disposing.',
2154
+ this.#closed
2155
+ ? 'ARCANE_AI_RUNTIME_DISPOSED'
2156
+ : 'ARCANE_AI_RUNTIME_DISPOSING'
2157
+ );
2158
+ }
2159
+ }
2160
+
2161
+ #assertNotConfiguring() {
2162
+ if (this.#configuring) {
2163
+ throw operationError(
2164
+ 'The AI provider runtime is committing a configuration.',
2165
+ 'ARCANE_AI_RUNTIME_CONFIGURING'
2166
+ );
2167
+ }
2168
+ }
2169
+
2170
+ #roleHasOwnedWork(slot) {
2171
+ return Boolean(
2172
+ slot.loadPromise
2173
+ || slot.unloadPromise
2174
+ || slot.disposePromise
2175
+ || slot.request
2176
+ || slot.ready
2177
+ );
2178
+ }
2179
+
2180
+ #nextOperationId(slot, operation) {
2181
+ if (slot.operationSequence === Number.MAX_SAFE_INTEGER) {
2182
+ throw operationError(
2183
+ `AI role ${slot.role} exhausted its operation sequence.`,
2184
+ 'ARCANE_AI_OPERATION_SEQUENCE_EXHAUSTED'
2185
+ );
2186
+ }
2187
+ slot.operationSequence += 1;
2188
+ return `${slot.role}-${operation}-${slot.operationSequence}`;
2189
+ }
2190
+
2191
+ #assertCurrentOperation(slot, generation, signal) {
2192
+ if (signal?.aborted) {
2193
+ throw normalizedAbort();
2194
+ }
2195
+ if (slot.generation !== generation) {
2196
+ throw operationError(
2197
+ `AI role ${slot.role} operation was superseded.`,
2198
+ 'ARCANE_AI_OPERATION_SUPERSEDED'
2199
+ );
2200
+ }
2201
+ }
2202
+
2203
+ #forwardAbort(signal, controller) {
2204
+ if (!signal) {
2205
+ return function detachAbsentAIProviderAbort() {};
2206
+ }
2207
+ function forwardAIProviderAbort() {
2208
+ controller.abort();
2209
+ }
2210
+ if (signal.aborted) {
2211
+ controller.abort();
2212
+ return function detachAlreadyAbortedAIProviderSignal() {};
2213
+ }
2214
+ signal.addEventListener('abort', forwardAIProviderAbort, {once: true});
2215
+ return function detachAIProviderAbort() {
2216
+ signal.removeEventListener('abort', forwardAIProviderAbort);
2217
+ };
2218
+ }
2219
+
2220
+ #publishLoadProgress(slot, generation, operationId, progress) {
2221
+ if (slot.generation !== generation || slot.loadController?.signal.aborted) {
2222
+ return false;
2223
+ }
2224
+ publishAIRuntimeRoleState(
2225
+ slot.role,
2226
+ roleRecord(
2227
+ slot.role,
2228
+ slot.selection,
2229
+ {
2230
+ state: 'loading',
2231
+ operationId,
2232
+ progress: immutableProgress(progress)
2233
+ }
2234
+ )
2235
+ );
2236
+ return true;
2237
+ }
2238
+
2239
+ #publishRoleError(slot, error, loaded) {
2240
+ publishAIRuntimeRoleState(
2241
+ slot.role,
2242
+ roleRecord(
2243
+ slot.role,
2244
+ slot.selection,
2245
+ {
2246
+ state: 'error',
2247
+ loaded,
2248
+ error: stateError(
2249
+ error,
2250
+ 'ARCANE_AI_PROVIDER_OPERATION_FAILED'
2251
+ )
2252
+ }
2253
+ )
2254
+ );
2255
+ }
2256
+
2257
+ #acceptIntent(intent) {
2258
+ if (this.#closed || this.#closing || this.#configuring) {
2259
+ return;
2260
+ }
2261
+ const slot = this.#slots[intent.role];
2262
+ if (!slot.selection) {
2263
+ return;
2264
+ }
2265
+ let operation;
2266
+ try {
2267
+ if (intent.action === 'load') {
2268
+ operation = this.load(intent.role);
2269
+ } else if (intent.action === 'unload') {
2270
+ operation = this.unload(intent.role);
2271
+ } else {
2272
+ operation = this.dispose(intent.role);
2273
+ }
2274
+ } catch {
2275
+ return;
2276
+ }
2277
+ operation.catch(function retainAIProviderIntentFailureInState() {
2278
+ // Lifecycle failures are published as sticky role state.
2279
+ });
2280
+ }
2281
+ }
2282
+
2283
+ export const aiProviderRuntime = new AIProviderRuntime(
2284
+ RUNTIME_CONSTRUCTION_AUTHORITY
2285
+ );
2286
+
2287
+ export function getAIProviderRuntime() {
2288
+ return aiProviderRuntime;
2289
+ }