arcane-os 0.1.2 → 0.2.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 (54) hide show
  1. package/CHANGELOG.md +35 -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 +780 -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 +551 -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 +1394 -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 +382 -31
  37. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +1106 -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 +10 -6
  42. package/src/cli/main.mjs +14 -2
  43. package/src/constants.mjs +1 -1
  44. package/src/dev-server.mjs +273 -26
  45. package/src/doctor.mjs +1 -3
  46. package/src/import-map.mjs +193 -84
  47. package/src/packager/core.mjs +313 -41
  48. package/src/runtime.mjs +14 -4
  49. package/src/scaffold.mjs +45 -17
  50. package/src/sdk-browser-runtime.mjs +28 -75
  51. package/src/templates/workspace-template.mjs +27 -8
  52. package/src/toolchain.mjs +13 -2
  53. package/src/workspace-runtime.mjs +1 -1
  54. package/src/workspace.mjs +178 -25
@@ -0,0 +1,872 @@
1
+ export const AI_RUNTIME_PROTOCOL = 'arcane-ai-runtime-state/1';
2
+ export const AI_RUNTIME_STATE_EVENT = 'arcane-ai-runtime-state';
3
+ export const AI_RUNTIME_INTENT_EVENT = 'arcane-ai-runtime-intent';
4
+ /** Emitted with the text-chat barrier report, not full speech settlement. */
5
+ export const AI_RUNTIME_STARTUP_EVENT = 'arcane-ai-runtime-startup-settled';
6
+
7
+ export const AI_RUNTIME_ROLES = Object.freeze([
8
+ 'llm',
9
+ 'stt',
10
+ 'tts'
11
+ ]);
12
+
13
+ export const AI_RUNTIME_STATES = Object.freeze([
14
+ 'unavailable',
15
+ 'unloaded',
16
+ 'loading',
17
+ 'ready',
18
+ 'unloading',
19
+ 'error',
20
+ 'disposed'
21
+ ]);
22
+
23
+ const ROLE_KEYS = Object.freeze([
24
+ 'role',
25
+ 'state',
26
+ 'providerId',
27
+ 'modelId',
28
+ 'localOnly',
29
+ 'loaded',
30
+ 'busy',
31
+ 'operationId',
32
+ 'progress',
33
+ 'error'
34
+ ]);
35
+ const PROGRESS_KEYS = Object.freeze([
36
+ 'phase',
37
+ 'completed',
38
+ 'total',
39
+ 'unit',
40
+ 'heartbeat'
41
+ ]);
42
+ const ERROR_KEYS = Object.freeze([
43
+ 'code',
44
+ 'message'
45
+ ]);
46
+ const INTENT_KEYS = Object.freeze([
47
+ 'role',
48
+ 'action',
49
+ 'reason'
50
+ ]);
51
+ const SUBSCRIPTION_OPTION_KEYS = Object.freeze([
52
+ 'signal',
53
+ 'emitCurrent'
54
+ ]);
55
+ const INTENT_SUBSCRIPTION_OPTION_KEYS = Object.freeze([
56
+ 'signal'
57
+ ]);
58
+ const STARTUP_OPTION_KEYS = Object.freeze([
59
+ 'startMuted',
60
+ 'signal'
61
+ ]);
62
+ const ROLE_SET = new Set(AI_RUNTIME_ROLES);
63
+ const STATE_SET = new Set(AI_RUNTIME_STATES);
64
+ const INTENT_ACTIONS = new Set([
65
+ 'load',
66
+ 'unload',
67
+ 'dispose'
68
+ ]);
69
+ const INTENT_REASONS = new Set([
70
+ 'startup',
71
+ 'user',
72
+ 'teardown'
73
+ ]);
74
+ const MUST_BE_UNLOADED = new Set([
75
+ 'unavailable',
76
+ 'unloaded',
77
+ 'loading',
78
+ 'disposed'
79
+ ]);
80
+ const STARTUP_TERMINAL_STATES = new Set([
81
+ 'unavailable',
82
+ 'error',
83
+ 'disposed'
84
+ ]);
85
+ const MAX_IDENTIFIER_LENGTH = 128;
86
+ const MAX_PROGRESS_UNIT_LENGTH = 32;
87
+ const MAX_ERROR_MESSAGE_LENGTH = 512;
88
+
89
+ export const aiRuntimeEvents = new EventTarget();
90
+
91
+ function fail(message) {
92
+ throw new TypeError(`ARCANE_AI_RUNTIME_STATE_INVALID: ${message}`);
93
+ }
94
+
95
+ function assertClosedRecord(value, expectedKeys, label) {
96
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
97
+ fail(`${label} must be a plain object.`);
98
+ }
99
+
100
+ const prototype = Object.getPrototypeOf(value);
101
+ if (prototype !== Object.prototype && prototype !== null) {
102
+ fail(`${label} must be a plain object.`);
103
+ }
104
+
105
+ const ownKeys = Reflect.ownKeys(value);
106
+ if (ownKeys.some(function hasSymbolKey(key) {
107
+ return typeof key === 'symbol';
108
+ })) {
109
+ fail(`${label} must not contain symbol keys.`);
110
+ }
111
+
112
+ const actualKeys = ownKeys.slice().sort();
113
+ const requiredKeys = expectedKeys.slice().sort();
114
+ if (actualKeys.length !== requiredKeys.length
115
+ || actualKeys.some(function hasUnexpectedKey(key, index) {
116
+ return key !== requiredKeys[index];
117
+ })) {
118
+ fail(`${label} must contain exactly ${expectedKeys.join(', ')}.`);
119
+ }
120
+
121
+ const descriptors = Object.getOwnPropertyDescriptors(value);
122
+ for (const key of expectedKeys) {
123
+ if (!Object.hasOwn(descriptors[key], 'value')) {
124
+ fail(`${label}.${key} must be a data property.`);
125
+ }
126
+ }
127
+ }
128
+
129
+ function assertClosedOptions(value, allowedKeys, label) {
130
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
131
+ fail(`${label} must be a plain object.`);
132
+ }
133
+
134
+ const prototype = Object.getPrototypeOf(value);
135
+ if (prototype !== Object.prototype && prototype !== null) {
136
+ fail(`${label} must be a plain object.`);
137
+ }
138
+
139
+ const descriptors = Object.getOwnPropertyDescriptors(value);
140
+ for (const key of Reflect.ownKeys(value)) {
141
+ if (typeof key === 'symbol' || !allowedKeys.includes(key)) {
142
+ fail(`${label} contains an unknown option.`);
143
+ }
144
+ if (!Object.hasOwn(descriptors[key], 'value')) {
145
+ fail(`${label}.${key} must be a data property.`);
146
+ }
147
+ }
148
+ }
149
+
150
+ function assertNullableIdentifier(value, label) {
151
+ if (value === null) {
152
+ return;
153
+ }
154
+
155
+ if (typeof value !== 'string'
156
+ || value.length < 1
157
+ || value.length > MAX_IDENTIFIER_LENGTH
158
+ || value.trim() !== value) {
159
+ fail(`${label} must be null or a trimmed 1-${MAX_IDENTIFIER_LENGTH} character string.`);
160
+ }
161
+ }
162
+
163
+ function assertRole(role) {
164
+ if (!ROLE_SET.has(role)) {
165
+ fail(`role must be one of ${AI_RUNTIME_ROLES.join(', ')}.`);
166
+ }
167
+ }
168
+
169
+ function copyProgress(progress) {
170
+ if (progress === null) {
171
+ return null;
172
+ }
173
+
174
+ assertClosedRecord(progress, PROGRESS_KEYS, 'progress');
175
+ if (typeof progress.phase !== 'string'
176
+ || progress.phase.length < 1
177
+ || progress.phase.length > MAX_IDENTIFIER_LENGTH
178
+ || progress.phase.trim() !== progress.phase) {
179
+ fail(`progress.phase must be a trimmed 1-${MAX_IDENTIFIER_LENGTH} character string.`);
180
+ }
181
+ if (!Number.isSafeInteger(progress.completed) || progress.completed < 0) {
182
+ fail('progress.completed must be a nonnegative safe integer.');
183
+ }
184
+ if (progress.total !== null
185
+ && (!Number.isSafeInteger(progress.total)
186
+ || progress.total < progress.completed)) {
187
+ fail('progress.total must be null or a safe integer no smaller than progress.completed.');
188
+ }
189
+ if (typeof progress.unit !== 'string'
190
+ || progress.unit.length < 1
191
+ || progress.unit.length > MAX_PROGRESS_UNIT_LENGTH
192
+ || progress.unit.trim() !== progress.unit) {
193
+ fail(`progress.unit must be a trimmed 1-${MAX_PROGRESS_UNIT_LENGTH} character string.`);
194
+ }
195
+ if (typeof progress.heartbeat !== 'boolean') {
196
+ fail('progress.heartbeat must be a boolean.');
197
+ }
198
+
199
+ return Object.freeze(
200
+ {
201
+ phase: progress.phase,
202
+ completed: progress.completed,
203
+ total: progress.total,
204
+ unit: progress.unit,
205
+ heartbeat: progress.heartbeat
206
+ }
207
+ );
208
+ }
209
+
210
+ function copyError(error) {
211
+ if (error === null) {
212
+ return null;
213
+ }
214
+
215
+ assertClosedRecord(error, ERROR_KEYS, 'error');
216
+ if (typeof error.code !== 'string'
217
+ || error.code.length < 1
218
+ || error.code.length > MAX_IDENTIFIER_LENGTH
219
+ || error.code.trim() !== error.code) {
220
+ fail(`error.code must be a trimmed 1-${MAX_IDENTIFIER_LENGTH} character string.`);
221
+ }
222
+ if (typeof error.message !== 'string'
223
+ || error.message.length < 1
224
+ || error.message.length > MAX_ERROR_MESSAGE_LENGTH
225
+ || error.message.trim() !== error.message) {
226
+ fail(`error.message must be a trimmed 1-${MAX_ERROR_MESSAGE_LENGTH} character string.`);
227
+ }
228
+
229
+ return Object.freeze(
230
+ {
231
+ code: error.code,
232
+ message: error.message
233
+ }
234
+ );
235
+ }
236
+
237
+ function copyRoleRecord(role, record) {
238
+ assertRole(role);
239
+ assertClosedRecord(record, ROLE_KEYS, 'role state');
240
+ if (record.role !== role) {
241
+ fail('role state.role must match the published role.');
242
+ }
243
+ if (!STATE_SET.has(record.state)) {
244
+ fail(`role state.state must be one of ${AI_RUNTIME_STATES.join(', ')}.`);
245
+ }
246
+
247
+ assertNullableIdentifier(record.providerId, 'role state.providerId');
248
+ assertNullableIdentifier(record.modelId, 'role state.modelId');
249
+ if (record.localOnly !== null && typeof record.localOnly !== 'boolean') {
250
+ fail('role state.localOnly must be null or a boolean.');
251
+ }
252
+ if (typeof record.loaded !== 'boolean') {
253
+ fail('role state.loaded must be a boolean.');
254
+ }
255
+ if (typeof record.busy !== 'boolean') {
256
+ fail('role state.busy must be a boolean.');
257
+ }
258
+ assertNullableIdentifier(record.operationId, 'role state.operationId');
259
+
260
+ const progress = copyProgress(record.progress);
261
+ const error = copyError(record.error);
262
+ if (record.state === 'ready' && !record.loaded) {
263
+ fail('a ready role must be loaded.');
264
+ }
265
+ if (record.state === 'unloading' && !record.loaded) {
266
+ fail('an unloading role must remain loaded until unloading completes.');
267
+ }
268
+ if (MUST_BE_UNLOADED.has(record.state) && record.loaded) {
269
+ fail(`${record.state} role state must not be loaded.`);
270
+ }
271
+ if (record.loaded
272
+ && (record.providerId === null || record.modelId === null)) {
273
+ fail('a loaded role must identify its provider and model.');
274
+ }
275
+ if (record.busy && (record.state !== 'ready' || !record.loaded)) {
276
+ fail('a busy role must be ready and loaded.');
277
+ }
278
+ if (record.state === 'error' && error === null) {
279
+ fail('an error role state must include error details.');
280
+ }
281
+ if (record.state !== 'error' && error !== null) {
282
+ fail('only an error role state may include error details.');
283
+ }
284
+
285
+ return Object.freeze(
286
+ {
287
+ role,
288
+ state: record.state,
289
+ providerId: record.providerId,
290
+ modelId: record.modelId,
291
+ localOnly: record.localOnly,
292
+ loaded: record.loaded,
293
+ busy: record.busy,
294
+ operationId: record.operationId,
295
+ progress,
296
+ error
297
+ }
298
+ );
299
+ }
300
+
301
+ function unavailableRole(role) {
302
+ return Object.freeze(
303
+ {
304
+ role,
305
+ state: 'unavailable',
306
+ providerId: null,
307
+ modelId: null,
308
+ localOnly: null,
309
+ loaded: false,
310
+ busy: false,
311
+ operationId: null,
312
+ progress: null,
313
+ error: null
314
+ }
315
+ );
316
+ }
317
+
318
+ function initialSnapshot() {
319
+ return Object.freeze(
320
+ {
321
+ protocol: AI_RUNTIME_PROTOCOL,
322
+ revision: 0,
323
+ roles: Object.freeze(
324
+ {
325
+ llm: unavailableRole('llm'),
326
+ stt: unavailableRole('stt'),
327
+ tts: unavailableRole('tts')
328
+ }
329
+ )
330
+ }
331
+ );
332
+ }
333
+
334
+ let currentSnapshot = initialSnapshot();
335
+
336
+ function assertAbortSignal(signal) {
337
+ if (signal === null || signal === undefined) {
338
+ return;
339
+ }
340
+
341
+ if (typeof signal !== 'object'
342
+ || typeof signal.aborted !== 'boolean'
343
+ || typeof signal.addEventListener !== 'function'
344
+ || typeof signal.removeEventListener !== 'function') {
345
+ fail('subscription signal must be an AbortSignal.');
346
+ }
347
+ }
348
+
349
+ function stateSubscriptionOptions(options) {
350
+ if (options === undefined) {
351
+ return {
352
+ signal: null,
353
+ emitCurrent: true
354
+ };
355
+ }
356
+
357
+ assertClosedOptions(options, SUBSCRIPTION_OPTION_KEYS, 'subscription options');
358
+ const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
359
+ const emitCurrent = Object.hasOwn(options, 'emitCurrent')
360
+ ? options.emitCurrent
361
+ : true;
362
+ assertAbortSignal(signal);
363
+ if (typeof emitCurrent !== 'boolean') {
364
+ fail('subscription options.emitCurrent must be a boolean.');
365
+ }
366
+
367
+ return {
368
+ signal,
369
+ emitCurrent
370
+ };
371
+ }
372
+
373
+ function intentSubscriptionOptions(options) {
374
+ if (options === undefined) {
375
+ return {
376
+ signal: null,
377
+ emitCurrent: false
378
+ };
379
+ }
380
+
381
+ assertClosedOptions(
382
+ options,
383
+ INTENT_SUBSCRIPTION_OPTION_KEYS,
384
+ 'intent subscription options'
385
+ );
386
+ const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
387
+ assertAbortSignal(signal);
388
+ return {
389
+ signal,
390
+ emitCurrent: false
391
+ };
392
+ }
393
+
394
+ function startupOptions(options) {
395
+ if (options === undefined) {
396
+ return {
397
+ startMuted: true,
398
+ signal: null
399
+ };
400
+ }
401
+
402
+ assertClosedOptions(options, STARTUP_OPTION_KEYS, 'startup options');
403
+ const startMuted = Object.hasOwn(options, 'startMuted')
404
+ ? options.startMuted
405
+ : true;
406
+ const signal = Object.hasOwn(options, 'signal') ? options.signal : null;
407
+ if (typeof startMuted !== 'boolean') {
408
+ fail('startup options.startMuted must be a boolean.');
409
+ }
410
+ assertAbortSignal(signal);
411
+
412
+ return {
413
+ startMuted,
414
+ signal
415
+ };
416
+ }
417
+
418
+ function hasAIRuntimeSelection(roleState) {
419
+ return roleState.providerId !== null && roleState.modelId !== null;
420
+ }
421
+
422
+ function startupRequestedRoles(snapshot, startMuted) {
423
+ return Object.freeze(
424
+ {
425
+ llm: hasAIRuntimeSelection(snapshot.roles.llm),
426
+ stt: hasAIRuntimeSelection(snapshot.roles.stt),
427
+ tts: !startMuted && hasAIRuntimeSelection(snapshot.roles.tts)
428
+ }
429
+ );
430
+ }
431
+
432
+ function isAIRuntimeStartupRoleSettled(roleState) {
433
+ return !hasAIRuntimeSelection(roleState)
434
+ || roleState.state === 'ready'
435
+ || STARTUP_TERMINAL_STATES.has(roleState.state);
436
+ }
437
+
438
+ function startupRoleReport(requested, roleState) {
439
+ return Object.freeze(
440
+ {
441
+ requested,
442
+ state: roleState
443
+ }
444
+ );
445
+ }
446
+
447
+ function startupReport(snapshot, startRevision, startMuted, requestedRoles) {
448
+ return Object.freeze(
449
+ {
450
+ protocol: AI_RUNTIME_PROTOCOL,
451
+ startRevision,
452
+ currentRevision: snapshot.revision,
453
+ startMuted,
454
+ chatReady: snapshot.roles.llm.state === 'ready',
455
+ roles: Object.freeze(
456
+ {
457
+ llm: startupRoleReport(
458
+ requestedRoles.llm,
459
+ snapshot.roles.llm
460
+ ),
461
+ stt: startupRoleReport(
462
+ requestedRoles.stt,
463
+ snapshot.roles.stt
464
+ ),
465
+ tts: startupRoleReport(
466
+ requestedRoles.tts,
467
+ snapshot.roles.tts
468
+ )
469
+ }
470
+ )
471
+ }
472
+ );
473
+ }
474
+
475
+ function normalizedAIRuntimeStartupAbort() {
476
+ const error = new Error('The AI runtime startup was cancelled.');
477
+ error.name = 'AbortError';
478
+ error.code = 'ARCANE_AI_REQUEST_ABORTED';
479
+ return error;
480
+ }
481
+
482
+ function assertListener(listener) {
483
+ if (typeof listener !== 'function') {
484
+ fail('listener must be a function.');
485
+ }
486
+ }
487
+
488
+ function subscribe(eventName, listener, normalized, currentValue) {
489
+ assertListener(listener);
490
+ let active = !normalized.signal?.aborted;
491
+
492
+ function unsubscribeAIRuntimeEvent() {
493
+ if (!active) {
494
+ return;
495
+ }
496
+
497
+ active = false;
498
+ aiRuntimeEvents.removeEventListener(eventName, forwardAIRuntimeEvent);
499
+ normalized.signal?.removeEventListener('abort', unsubscribeAIRuntimeEvent);
500
+ }
501
+
502
+ function forwardAIRuntimeEvent(event) {
503
+ listener(event.detail);
504
+ }
505
+
506
+ if (!active) {
507
+ return unsubscribeAIRuntimeEvent;
508
+ }
509
+
510
+ aiRuntimeEvents.addEventListener(eventName, forwardAIRuntimeEvent);
511
+ normalized.signal?.addEventListener(
512
+ 'abort',
513
+ unsubscribeAIRuntimeEvent,
514
+ {
515
+ once: true
516
+ }
517
+ );
518
+
519
+ try {
520
+ if (normalized.emitCurrent && currentValue !== undefined) {
521
+ listener(currentValue);
522
+ }
523
+ } catch (error) {
524
+ unsubscribeAIRuntimeEvent();
525
+ throw error;
526
+ }
527
+
528
+ return unsubscribeAIRuntimeEvent;
529
+ }
530
+
531
+ /** Returns the current deeply immutable runtime-state snapshot. */
532
+ export function getAIRuntimeState() {
533
+ return currentSnapshot;
534
+ }
535
+
536
+ /**
537
+ * Subscribes to full runtime-state snapshots. The current snapshot is delivered
538
+ * synchronously by default so a late subscriber never needs to poll.
539
+ */
540
+ export function subscribeAIRuntimeState(listener, options) {
541
+ return subscribe(
542
+ AI_RUNTIME_STATE_EVENT,
543
+ listener,
544
+ stateSubscriptionOptions(options),
545
+ currentSnapshot
546
+ );
547
+ }
548
+
549
+ /**
550
+ * Replaces one role's complete observational record and emits the new full
551
+ * snapshot. Publishing state never grants provider authority.
552
+ */
553
+ export function publishAIRuntimeRoleState(role, completeRecord) {
554
+ const nextRole = copyRoleRecord(role, completeRecord);
555
+ if (currentSnapshot.revision === Number.MAX_SAFE_INTEGER) {
556
+ throw new RangeError(
557
+ 'ARCANE_AI_RUNTIME_STATE_INVALID: state revision exhausted.'
558
+ );
559
+ }
560
+
561
+ const nextRoles = {
562
+ llm: currentSnapshot.roles.llm,
563
+ stt: currentSnapshot.roles.stt,
564
+ tts: currentSnapshot.roles.tts
565
+ };
566
+ nextRoles[role] = nextRole;
567
+ currentSnapshot = Object.freeze(
568
+ {
569
+ protocol: AI_RUNTIME_PROTOCOL,
570
+ revision: currentSnapshot.revision + 1,
571
+ roles: Object.freeze(nextRoles)
572
+ }
573
+ );
574
+ aiRuntimeEvents.dispatchEvent(
575
+ new CustomEvent(
576
+ AI_RUNTIME_STATE_EVENT,
577
+ {
578
+ detail: currentSnapshot
579
+ }
580
+ )
581
+ );
582
+ return currentSnapshot;
583
+ }
584
+
585
+ /**
586
+ * Atomically replaces all three role records and emits one coherent snapshot.
587
+ * Provider routing commits use this boundary so synchronous subscribers never
588
+ * observe a partially configured role set.
589
+ */
590
+ export function publishAIRuntimeRolesState(completeRecords) {
591
+ assertClosedRecord(completeRecords, AI_RUNTIME_ROLES, 'runtime role states');
592
+ const nextRoles = Object.freeze(
593
+ {
594
+ llm: copyRoleRecord('llm', completeRecords.llm),
595
+ stt: copyRoleRecord('stt', completeRecords.stt),
596
+ tts: copyRoleRecord('tts', completeRecords.tts)
597
+ }
598
+ );
599
+ if (currentSnapshot.revision === Number.MAX_SAFE_INTEGER) {
600
+ throw new RangeError(
601
+ 'ARCANE_AI_RUNTIME_STATE_INVALID: state revision exhausted.'
602
+ );
603
+ }
604
+ currentSnapshot = Object.freeze(
605
+ {
606
+ protocol: AI_RUNTIME_PROTOCOL,
607
+ revision: currentSnapshot.revision + 1,
608
+ roles: nextRoles
609
+ }
610
+ );
611
+ aiRuntimeEvents.dispatchEvent(
612
+ new CustomEvent(
613
+ AI_RUNTIME_STATE_EVENT,
614
+ {
615
+ detail: currentSnapshot
616
+ }
617
+ )
618
+ );
619
+ return currentSnapshot;
620
+ }
621
+
622
+ /**
623
+ * Emits an immutable capability-neutral lifecycle request. This function does
624
+ * not execute, authorize, fetch, load, unload, dispose, or select a fallback.
625
+ */
626
+ export function requestAIRuntimeIntent(intent) {
627
+ assertClosedRecord(intent, INTENT_KEYS, 'runtime intent');
628
+ assertRole(intent.role);
629
+ if (!INTENT_ACTIONS.has(intent.action)) {
630
+ fail('runtime intent.action must be load, unload, or dispose.');
631
+ }
632
+ if (!INTENT_REASONS.has(intent.reason)) {
633
+ fail('runtime intent.reason must be startup, user, or teardown.');
634
+ }
635
+
636
+ const publishedIntent = Object.freeze(
637
+ {
638
+ role: intent.role,
639
+ action: intent.action,
640
+ reason: intent.reason
641
+ }
642
+ );
643
+ aiRuntimeEvents.dispatchEvent(
644
+ new CustomEvent(
645
+ AI_RUNTIME_INTENT_EVENT,
646
+ {
647
+ detail: publishedIntent
648
+ }
649
+ )
650
+ );
651
+ return publishedIntent;
652
+ }
653
+
654
+ /** Subscribes to transient runtime intents; intents have no sticky replay. */
655
+ export function subscribeAIRuntimeIntents(listener, options) {
656
+ return subscribe(
657
+ AI_RUNTIME_INTENT_EVENT,
658
+ listener,
659
+ intentSubscriptionOptions(options),
660
+ undefined
661
+ );
662
+ }
663
+
664
+ /**
665
+ * Starts one observational runtime barrier after preferences and provider
666
+ * routes are hydrated. `barrier` resolves when LLM is ready or terminal;
667
+ * `settled` waits for every requested role. Providers own every requested load.
668
+ */
669
+ export function startAIRuntime(options) {
670
+ const normalized = startupOptions(options);
671
+ let latestSnapshot = currentSnapshot;
672
+ let requestedRoles = null;
673
+ let startRevision = null;
674
+ let unsubscribeState = null;
675
+ let started = false;
676
+ let cancelled = false;
677
+ const loadRequested = {
678
+ llm: false,
679
+ stt: false,
680
+ tts: false
681
+ };
682
+ let barrierResolved = false;
683
+ let settledResolved = false;
684
+ let resolveBarrier;
685
+ let rejectBarrier;
686
+ let resolveSettled;
687
+ let rejectSettled;
688
+
689
+ const barrier = new Promise(
690
+ function createAIRuntimeStartupBarrier(resolve, reject) {
691
+ resolveBarrier = resolve;
692
+ rejectBarrier = reject;
693
+ }
694
+ );
695
+ const settled = new Promise(
696
+ function createAIRuntimeStartupSettlement(resolve, reject) {
697
+ resolveSettled = resolve;
698
+ rejectSettled = reject;
699
+ }
700
+ );
701
+
702
+ function closeAIRuntimeStartupObservation() {
703
+ if (unsubscribeState) {
704
+ unsubscribeState();
705
+ unsubscribeState = null;
706
+ }
707
+ normalized.signal?.removeEventListener(
708
+ 'abort',
709
+ handleAIRuntimeStartupAbort
710
+ );
711
+ }
712
+
713
+ function requestedRolesAreSettled(snapshot) {
714
+ for (const role of AI_RUNTIME_ROLES) {
715
+ if (requestedRoles[role]
716
+ && !isAIRuntimeStartupRoleSettled(snapshot.roles[role])) {
717
+ return false;
718
+ }
719
+ }
720
+
721
+ return true;
722
+ }
723
+
724
+ function resolveAIRuntimeStartupBarrier(snapshot) {
725
+ const report = startupReport(
726
+ snapshot,
727
+ startRevision,
728
+ normalized.startMuted,
729
+ requestedRoles
730
+ );
731
+ barrierResolved = true;
732
+ resolveBarrier(report);
733
+ aiRuntimeEvents.dispatchEvent(
734
+ new CustomEvent(
735
+ AI_RUNTIME_STARTUP_EVENT,
736
+ {
737
+ detail: report
738
+ }
739
+ )
740
+ );
741
+ }
742
+
743
+ function resolveAIRuntimeStartupSettlement(snapshot) {
744
+ settledResolved = true;
745
+ resolveSettled(
746
+ startupReport(
747
+ snapshot,
748
+ startRevision,
749
+ normalized.startMuted,
750
+ requestedRoles
751
+ )
752
+ );
753
+ }
754
+
755
+ function evaluateAIRuntimeStartup(snapshot) {
756
+ if (cancelled) {
757
+ return;
758
+ }
759
+
760
+ if (!barrierResolved
761
+ && isAIRuntimeStartupRoleSettled(snapshot.roles.llm)) {
762
+ resolveAIRuntimeStartupBarrier(snapshot);
763
+ }
764
+ if (cancelled) {
765
+ return;
766
+ }
767
+
768
+ if (!settledResolved && requestedRolesAreSettled(snapshot)) {
769
+ resolveAIRuntimeStartupSettlement(snapshot);
770
+ }
771
+ if (barrierResolved && settledResolved) {
772
+ closeAIRuntimeStartupObservation();
773
+ }
774
+ }
775
+
776
+ function observeAIRuntimeStartupState() {
777
+ latestSnapshot = currentSnapshot;
778
+ if (started) {
779
+ requestPendingAIRuntimeStartupLoads(latestSnapshot);
780
+ evaluateAIRuntimeStartup(latestSnapshot);
781
+ }
782
+ }
783
+
784
+ function requestPendingAIRuntimeStartupLoads(snapshot) {
785
+ for (const role of AI_RUNTIME_ROLES) {
786
+ const roleState = snapshot.roles[role];
787
+ if (!cancelled
788
+ && requestedRoles[role]
789
+ && !loadRequested[role]
790
+ && hasAIRuntimeSelection(roleState)
791
+ && roleState.state === 'unloaded') {
792
+ loadRequested[role] = true;
793
+ requestAIRuntimeIntent(
794
+ {
795
+ role,
796
+ action: 'load',
797
+ reason: 'startup'
798
+ }
799
+ );
800
+ }
801
+ }
802
+ }
803
+
804
+ function cancelAIRuntimeStartup() {
805
+ if (cancelled || settledResolved) {
806
+ return;
807
+ }
808
+
809
+ cancelled = true;
810
+ closeAIRuntimeStartupObservation();
811
+ const error = normalizedAIRuntimeStartupAbort();
812
+ if (!barrierResolved) {
813
+ rejectBarrier(error);
814
+ }
815
+ if (!settledResolved) {
816
+ rejectSettled(error);
817
+ }
818
+
819
+ const cancellationSnapshot = currentSnapshot;
820
+ for (const role of AI_RUNTIME_ROLES) {
821
+ if (requestedRoles[role]
822
+ && cancellationSnapshot.roles[role].state === 'loading') {
823
+ requestAIRuntimeIntent(
824
+ {
825
+ role,
826
+ action: 'unload',
827
+ reason: 'startup'
828
+ }
829
+ );
830
+ }
831
+ }
832
+ }
833
+
834
+ function handleAIRuntimeStartupAbort() {
835
+ cancelAIRuntimeStartup();
836
+ }
837
+
838
+ const handle = Object.freeze(
839
+ {
840
+ barrier,
841
+ settled,
842
+ cancel: cancelAIRuntimeStartup
843
+ }
844
+ );
845
+
846
+ unsubscribeState = subscribeAIRuntimeState(observeAIRuntimeStartupState);
847
+ startRevision = latestSnapshot.revision;
848
+ requestedRoles = startupRequestedRoles(
849
+ latestSnapshot,
850
+ normalized.startMuted
851
+ );
852
+ normalized.signal?.addEventListener(
853
+ 'abort',
854
+ handleAIRuntimeStartupAbort,
855
+ {
856
+ once: true
857
+ }
858
+ );
859
+ if (normalized.signal?.aborted) {
860
+ cancelAIRuntimeStartup();
861
+ return handle;
862
+ }
863
+
864
+ requestPendingAIRuntimeStartupLoads(currentSnapshot);
865
+
866
+ if (!cancelled) {
867
+ started = true;
868
+ latestSnapshot = currentSnapshot;
869
+ evaluateAIRuntimeStartup(latestSnapshot);
870
+ }
871
+ return handle;
872
+ }