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.
- package/CHANGELOG.md +35 -0
- package/NOTICE +5 -3
- package/README.md +73 -24
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +67 -18
- package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +16 -5
- package/browser-runtime/ai/browser-kokoro-worker.mjs +3 -0
- package/browser-runtime/ai/browser-speech-artifacts.mjs +1108 -0
- package/browser-runtime/ai/browser-speech-providers.mjs +780 -0
- package/browser-runtime/ai/browser-speech.mjs +9 -0
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1537 -167
- package/browser-runtime/ai/browser-wasm.mjs +46 -1
- package/browser-runtime/ai/browser-whisper-worker.mjs +3 -0
- package/browser-runtime/ai/browser-wllama-runtime.mjs +677 -132
- package/browser-runtime/ai/model-controller.mjs +138 -12
- package/browser-runtime/ai/speech-worker-client.mjs +207 -0
- package/browser-runtime/ai/speech-worker-runtime.mjs +516 -0
- package/browser-runtime/ai/wllama/index.mjs +389 -0
- package/docs/architecture.md +132 -22
- package/docs/reference/README.md +1 -1
- package/docs/reference/ai/browser-wasm.md +101 -42
- package/docs/reference/availability-and-normalization.md +19 -5
- package/docs/reference/behavioral-testing.md +18 -5
- package/docs/reference/cli.md +2 -2
- package/docs/reference/inventory/package-api.json +14 -14
- package/docs/reference/protocols.md +4 -4
- package/docs/reference/sdk-api.md +68 -38
- package/docs/work-amplification.md +8 -4
- package/package.json +7 -3
- package/runtime/ARCANE_RUNTIME_RELEASE.json +50 -20
- package/runtime/arcane/components/chat.html +551 -62
- package/runtime/arcane/components/speech.html +1113 -265
- package/runtime/arcane/entities/Chat.js +246 -43
- package/runtime/arcane/modules/AI.js +1394 -162
- package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
- package/runtime/arcane/modules/AIRuntimeState.js +872 -0
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +382 -31
- package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +1106 -0
- package/runtime/arcane/modules/DocumentLexicalSearch.js +292 -0
- package/runtime/arcane/modules/PersistentAIChatSession.js +268 -0
- package/runtime/arcane/modules/StaticDocumentCatalog.js +25 -206
- package/schemas/arcane-lock.schema.json +10 -6
- package/src/cli/main.mjs +14 -2
- package/src/constants.mjs +1 -1
- package/src/dev-server.mjs +273 -26
- package/src/doctor.mjs +1 -3
- package/src/import-map.mjs +193 -84
- package/src/packager/core.mjs +313 -41
- package/src/runtime.mjs +14 -4
- package/src/scaffold.mjs +45 -17
- package/src/sdk-browser-runtime.mjs +28 -75
- package/src/templates/workspace-template.mjs +27 -8
- package/src/toolchain.mjs +13 -2
- package/src/workspace-runtime.mjs +1 -1
- 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
|
+
}
|