arcane-os 0.69.0 → 0.71.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 (37) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/ai/browser-decisions.mjs +46 -2
  4. package/browser-runtime/ai/browser-speech-artifacts.mjs +58 -62
  5. package/browser-runtime/ai/browser-speech-providers.mjs +3 -0
  6. package/browser-runtime/ai/browser-wasm-llm-provider.mjs +76 -164
  7. package/browser-runtime/ai/dbopfs-model-parts.mjs +344 -0
  8. package/browser-runtime/ai/dbopfs-model-resources.mjs +190 -0
  9. package/browser-runtime/ai/decision-runtime.mjs +17 -1
  10. package/browser-runtime/ai/decision-worker.mjs +21 -1
  11. package/browser-runtime/ai/model-resource-bridge.mjs +231 -0
  12. package/browser-runtime/ai/speech-worker-client.mjs +68 -28
  13. package/browser-runtime/ai/speech-worker-runtime.mjs +212 -97
  14. package/browser-runtime/core/contracts.mjs +1 -1
  15. package/docs/reference/README.md +2 -0
  16. package/docs/reference/ai/browser-decisions.md +57 -3
  17. package/docs/reference/ai/browser-speech.md +45 -17
  18. package/docs/reference/ai/browser-wasm.md +28 -20
  19. package/docs/reference/core-native-packaging.md +13 -0
  20. package/docs/reference/core-runtime.md +9 -0
  21. package/docs/reference/core-shared-host.md +190 -0
  22. package/docs/reference/inventory/package-api.json +3 -3
  23. package/docs/reference/inventory/runtime-components.json +2 -2
  24. package/docs/reference/inventory/runtime-modules.json +6 -6
  25. package/docs/reference/runtime-components.md +35 -1
  26. package/docs/reference/runtime-modules.md +97 -7
  27. package/docs/reference/sdk-api.md +69 -31
  28. package/package.json +2 -2
  29. package/runtime/arcane/components/theme-switcher.html +4 -4
  30. package/runtime/arcane/modules/AI.js +2 -18
  31. package/runtime/arcane/modules/AIProviderRuntime.js +77 -3
  32. package/runtime/arcane/modules/DBOPFS.js +180 -26
  33. package/src/core/host.mjs +2 -0
  34. package/src/core/runtime.mjs +9 -4
  35. package/src/core/services/packaged-web.mjs +8 -1
  36. package/src/core/shared-host.mjs +500 -0
  37. package/src/native/portable-layout.mjs +66 -34
@@ -0,0 +1,231 @@
1
+ import Is from '../dependencies/strong-type/index.js';
2
+
3
+ const is = new Is(false);
4
+
5
+ function errorRecord(value, seen = new Map()) {
6
+ if (!is.error(value)) return value;
7
+ if (seen.has(value)) return seen.get(value);
8
+ const record = {name: value.name, message: value.message, stack: value.stack};
9
+ seen.set(value, record);
10
+ for (const key of Object.getOwnPropertyNames(value)) {
11
+ if (key === 'cause') record[key] = errorRecord(value[key], seen);
12
+ else if (key === 'errors' && is.array(value[key])) {
13
+ record[key] = value[key].map(function recordError(error) {
14
+ return errorRecord(error, seen);
15
+ });
16
+ } else record[key] = value[key];
17
+ }
18
+ return record;
19
+ }
20
+
21
+ function reviveError(value, seen = new Map()) {
22
+ if (!value || !is.string(value.name) || !is.string(value.message)) return value;
23
+ if (seen.has(value)) return seen.get(value);
24
+ const error = new Error(value.message);
25
+ seen.set(value, error);
26
+ for (const [key, field] of Object.entries(value)) {
27
+ let item = field;
28
+ if (key === 'cause') item = reviveError(field, seen);
29
+ if (key === 'errors' && is.array(field)) {
30
+ item = field.map(function reviveNestedError(nested) {
31
+ return reviveError(nested, seen);
32
+ });
33
+ }
34
+ Object.defineProperty(error, key, {value: item, enumerable: true, configurable: true, writable: true});
35
+ }
36
+ return error;
37
+ }
38
+
39
+ function abortReason(signal) {
40
+ return signal?.reason ?? new DOMException('The model resource operation was cancelled.', 'AbortError');
41
+ }
42
+
43
+ /** The store stays at its owner; only requests and complete Blob handles cross. */
44
+ export function createModelResourceHost({fetchResource, send, onError}) {
45
+ const operations = new Map();
46
+ const transportFailures = [];
47
+ let closed = false;
48
+ let closing;
49
+ function receive(message) {
50
+ if (message?.arcaneModelResource !== true) return false;
51
+ const {resourceId, op} = message;
52
+ if (op === 'cancel') {
53
+ operations.get(resourceId)?.controller.abort(reviveError(message.reason));
54
+ return true;
55
+ }
56
+ if (op !== 'fetch' || closed) return true;
57
+ const controller = new AbortController();
58
+ const operation = {controller};
59
+ operations.set(resourceId, operation);
60
+ async function runResourceFetch() {
61
+ try {
62
+ if (controller.signal.aborted) throw controller.signal.reason;
63
+ const result = await fetchResource(message.request.url, {
64
+ ...message.request.options,
65
+ signal: controller.signal,
66
+ onProgress: function reportResourceProgress(progress) {
67
+ if (!closed) send({arcaneModelResource: true, resourceId, op: 'progress', progress});
68
+ }
69
+ });
70
+ if (!closed) send({arcaneModelResource: true, resourceId, op: 'result', result});
71
+ } catch (error) {
72
+ if (!closed) {
73
+ try {
74
+ send({arcaneModelResource: true, resourceId, op: 'error', error: errorRecord(error)});
75
+ } catch (transportError) {
76
+ throw new AggregateError([error, transportError], 'Model resource operation and error delivery failed.');
77
+ }
78
+ }
79
+ else if (error !== controller.signal.reason && error?.name !== 'AbortError') throw error;
80
+ } finally {
81
+ operations.delete(resourceId);
82
+ }
83
+ }
84
+ // Publish ownership before invoking a store that can synchronously
85
+ // report progress and trigger reentrant Worker shutdown.
86
+ operation.promise = Promise.resolve().then(runResourceFetch);
87
+ // Keep transport failures observable at close without an unhandled
88
+ // rejection while the caller's Worker lifecycle is still active.
89
+ operation.promise.catch(function observeResourceFailure(error) {
90
+ transportFailures.push(error);
91
+ if (!closed) {
92
+ try {
93
+ if (onError) onError(error);
94
+ else globalThis.console?.error('Model resource transport failed.', error);
95
+ } catch (handlerError) {
96
+ const failure = new AggregateError([error, handlerError], 'Model resource transport and its failure handler failed.');
97
+ transportFailures.push(failure);
98
+ globalThis.console?.error('Model resource failure handler failed.', failure);
99
+ }
100
+ }
101
+ });
102
+ return true;
103
+ }
104
+ function close(reason = abortReason()) {
105
+ if (closing) return closing;
106
+ closed = true;
107
+ let resolveClose;
108
+ let rejectClose;
109
+ closing = new Promise(function retainResourceClose(resolve, reject) {
110
+ resolveClose = resolve;
111
+ rejectClose = reject;
112
+ });
113
+ const pending = [...operations.values()];
114
+ for (const operation of pending) operation.controller.abort(reason);
115
+ Promise.allSettled(pending.map(function resourceSettlement(operation) {
116
+ return operation.promise;
117
+ })).then(function finishResourceClose() {
118
+ if (transportFailures.length) {
119
+ rejectClose(new AggregateError(transportFailures, 'Unable to settle model resource transport.'));
120
+ } else resolveClose();
121
+ });
122
+ return closing;
123
+ }
124
+ return {receive, close};
125
+ }
126
+
127
+ export function createModelResourceClient({send}) {
128
+ const pending = new Map();
129
+ let nextId = 1;
130
+ let closed = false;
131
+ let closeReason;
132
+ function receive(message) {
133
+ if (message?.arcaneModelResource !== true) return false;
134
+ const operation = pending.get(message.resourceId);
135
+ if (!operation) return true;
136
+ if (message.op === 'progress') {
137
+ try {
138
+ operation.onProgress?.(message.progress);
139
+ } catch (error) {
140
+ operation.cancel(error);
141
+ }
142
+ return true;
143
+ }
144
+ pending.delete(message.resourceId);
145
+ operation.release();
146
+ if (operation.cancelled) operation.reject(operation.reason);
147
+ else if (message.op === 'error') operation.reject(reviveError(message.error));
148
+ else operation.resolve(message.result);
149
+ return true;
150
+ }
151
+ async function fetchResource(input, {onProgress, ...options} = {}) {
152
+ if (closed) throw closeReason;
153
+ const request = new Request(input, options);
154
+ const signal = request.signal;
155
+ if (signal.aborted) throw abortReason(signal);
156
+ const requestOptions = {
157
+ method: request.method,
158
+ headers: [...request.headers],
159
+ mode: request.mode,
160
+ credentials: request.credentials,
161
+ cache: request.cache,
162
+ redirect: request.redirect,
163
+ referrer: request.referrer,
164
+ referrerPolicy: request.referrerPolicy,
165
+ integrity: request.integrity,
166
+ keepalive: request.keepalive
167
+ };
168
+ if (request.body) requestOptions.body = await request.blob();
169
+ if (closed) throw closeReason;
170
+ if (signal.aborted) throw abortReason(signal);
171
+ const resourceId = nextId++;
172
+ return new Promise(function requestModelResource(resolve, reject) {
173
+ const operation = {resolve, reject, onProgress, cancelled: false};
174
+ function cancel(reason) {
175
+ if (operation.cancelled) return;
176
+ operation.cancelled = true;
177
+ operation.reason = reason;
178
+ try {
179
+ send({arcaneModelResource: true, resourceId, op: 'cancel', reason: errorRecord(reason)});
180
+ } catch (error) {
181
+ pending.delete(resourceId);
182
+ release();
183
+ reject(new AggregateError([reason, error], 'Unable to cancel the model resource transport.'));
184
+ }
185
+ }
186
+ function cancelFromSignal() {
187
+ cancel(abortReason(signal));
188
+ }
189
+ function release() {
190
+ signal.removeEventListener('abort', cancelFromSignal);
191
+ }
192
+ operation.cancel = cancel;
193
+ operation.release = release;
194
+ pending.set(resourceId, operation);
195
+ signal.addEventListener('abort', cancelFromSignal, {once: true});
196
+ try {
197
+ send({arcaneModelResource: true, resourceId, op: 'fetch',
198
+ request: {url: request.url, options: requestOptions}});
199
+ } catch (error) {
200
+ pending.delete(resourceId);
201
+ release();
202
+ reject(error);
203
+ }
204
+ });
205
+ }
206
+ async function fetch(input, options) {
207
+ const request = new Request(input, options);
208
+ const resource = await fetchResource(request, {onProgress: options?.onProgress});
209
+ const response = resource.status === 0 ? Response.error() : new Response(
210
+ request.method === 'HEAD' || [204, 205, 304].includes(resource.status) ? null : resource.file,
211
+ {status: resource.status, statusText: resource.statusText, headers: resource.headers}
212
+ );
213
+ Object.defineProperties(response, {
214
+ url: {value: resource.url, configurable: true},
215
+ redirected: {value: resource.redirected, configurable: true}
216
+ });
217
+ return response;
218
+ }
219
+ function close(reason = abortReason()) {
220
+ if (closed) return;
221
+ closed = true;
222
+ closeReason = reason;
223
+ for (const operation of pending.values()) {
224
+ operation.cancel(reason);
225
+ operation.release();
226
+ operation.reject(reason);
227
+ }
228
+ pending.clear();
229
+ }
230
+ return {receive, fetchResource, fetch, close};
231
+ }
@@ -5,6 +5,7 @@ import {
5
5
  SPEECH_WORKER_PROTOCOL,
6
6
  } from "./speech-worker-runtime.mjs";
7
7
  import { arcaneLogging } from "../logging.mjs";
8
+ import { createModelResourceHost } from './model-resource-bridge.mjs';
8
9
 
9
10
  const is = new Is(false);
10
11
 
@@ -47,6 +48,10 @@ function abortError(signal, role, op) {
47
48
  );
48
49
  }
49
50
 
51
+ function reportSpeechTerminationFailure(error) {
52
+ arcaneLogging.error('Speech Worker termination failed.', error);
53
+ }
54
+
50
55
  function validateWorker(worker) {
51
56
  if (!worker || !is.function(worker.postMessage) || !is.function(worker.terminate)) {
52
57
  throw new TypeError("The packaged speech worker did not create a Worker.");
@@ -72,8 +77,11 @@ class SpeechWorkerClient {
72
77
  #listeners = [];
73
78
  #terminated = false;
74
79
  #onTermination;
80
+ #fetchResource;
81
+ #resourceHost = null;
82
+ #terminationPromise = null;
75
83
 
76
- constructor({ role, onTermination = () => undefined } = {}) {
84
+ constructor({ role, fetchResource = null, onTermination = () => undefined } = {}) {
77
85
  if (role !== "stt" && role !== "tts") {
78
86
  throw new TypeError('SpeechWorkerClient role must be "stt" or "tts".');
79
87
  }
@@ -89,6 +97,7 @@ class SpeechWorkerClient {
89
97
  name: role === "stt" ? "arcane-whisper-stt" : "arcane-kokoro-tts",
90
98
  });
91
99
  this.#onTermination = onTermination;
100
+ this.#fetchResource = fetchResource;
92
101
  WORKER_CLIENTS.add(this);
93
102
  this.#trace("created", { workerUrl: workerUrl.href });
94
103
  }
@@ -131,7 +140,25 @@ class SpeechWorkerClient {
131
140
  );
132
141
  }
133
142
  const worker = validateWorker(this.#createWorker());
143
+ const client = this;
134
144
  this.#worker = worker;
145
+ if (this.#fetchResource) {
146
+ this.#resourceHost = createModelResourceHost({
147
+ fetchResource: this.#fetchResource,
148
+ send: function sendSpeechResource(message) {
149
+ worker.postMessage(message);
150
+ },
151
+ onError: function speechResourceTransportFailed(error) {
152
+ const failure = clientError(
153
+ 'ARCANE_AI_WORKER_MESSAGE_ERROR',
154
+ 'Unable to deliver a resource to the speech Worker.',
155
+ error,
156
+ `${client.#role}-worker-message-rejected`,
157
+ );
158
+ void client.terminate(failure, { intentional: false }).catch(reportSpeechTerminationFailure);
159
+ },
160
+ });
161
+ }
135
162
  this.#trace("started");
136
163
  this.#listen(worker, "message", (event) => {
137
164
  this.#handleMessage(event.data);
@@ -142,7 +169,7 @@ class SpeechWorkerClient {
142
169
  "The speech Worker returned an unreadable message.",
143
170
  event,
144
171
  `${this.#role}-worker-message-rejected`,
145
- ), { intentional: false }).catch(() => undefined);
172
+ ), { intentional: false }).catch(reportSpeechTerminationFailure);
146
173
  });
147
174
  this.#listen(worker, "error", (event) => {
148
175
  void this.terminate(clientError(
@@ -150,12 +177,13 @@ class SpeechWorkerClient {
150
177
  "The speech Worker crashed.",
151
178
  event,
152
179
  `${this.#role}-worker-crashed`,
153
- ), { intentional: false }).catch(() => undefined);
180
+ ), { intentional: false }).catch(reportSpeechTerminationFailure);
154
181
  });
155
182
  return worker;
156
183
  }
157
184
 
158
185
  #handleMessage(message) {
186
+ if (this.#resourceHost?.receive(message)) return;
159
187
  this.#trace("response", { message });
160
188
  if (message?.protocol !== SPEECH_WORKER_PROTOCOL) {
161
189
  void this.terminate(clientError(
@@ -163,12 +191,12 @@ class SpeechWorkerClient {
163
191
  "The speech Worker protocol did not match the SDK.",
164
192
  undefined,
165
193
  `${this.#role}-worker-protocol-mismatch`,
166
- ), { intentional: false }).catch(() => undefined);
194
+ ), { intentional: false }).catch(reportSpeechTerminationFailure);
167
195
  return;
168
196
  }
169
197
  if (is.safeInteger(message.id) && message.type === 'progress') {
170
198
  const pending = this.#pending.get(message.id);
171
- if (pending?.op === 'load') pending.onProgress?.(message.progress);
199
+ pending?.onProgress?.(message.progress);
172
200
  return;
173
201
  }
174
202
  if (!is.safeInteger(message.id) || !is.boolean(message.ok)) {
@@ -177,7 +205,7 @@ class SpeechWorkerClient {
177
205
  "The speech Worker response envelope shape was rejected.",
178
206
  undefined,
179
207
  `${this.#role}-worker-response-envelope-shape-rejected`,
180
- ), { intentional: false }).catch(() => undefined);
208
+ ), { intentional: false }).catch(reportSpeechTerminationFailure);
181
209
  return;
182
210
  }
183
211
  const pending = this.#pending.get(message.id);
@@ -201,7 +229,7 @@ class SpeechWorkerClient {
201
229
  `${this.#role}-worker-error-envelope-rejected`,
202
230
  );
203
231
  pending.reject(failure);
204
- void this.terminate(failure, { intentional: false }).catch(() => undefined);
232
+ void this.terminate(failure, { intentional: false }).catch(reportSpeechTerminationFailure);
205
233
  return;
206
234
  }
207
235
  pending.reject(clientError(
@@ -272,20 +300,14 @@ class SpeechWorkerClient {
272
300
  error,
273
301
  `${operationSubject(client.#role, op)}-message-rejected`,
274
302
  );
275
- void client.terminate(failure, { intentional: false }).catch(
276
- function ignoreCancellationTerminationFailure() {
277
- return undefined;
278
- },
279
- );
303
+ void client.terminate(failure, { intentional: false }).catch(reportSpeechTerminationFailure);
280
304
  }
281
305
  return;
282
306
  }
283
307
  void client.terminate(
284
308
  abortError(signal, client.#role, op),
285
309
  { intentional: true },
286
- ).catch(function ignoreAbortTerminationFailure() {
287
- return undefined;
288
- });
310
+ ).catch(reportSpeechTerminationFailure);
289
311
  }
290
312
  signal?.addEventListener?.("abort", onAbort, { once: true });
291
313
  client.#pending.set(
@@ -313,11 +335,7 @@ class SpeechWorkerClient {
313
335
  `${operationSubject(client.#role, op)}-message-rejected`,
314
336
  );
315
337
  reject(failure);
316
- void client.terminate(failure, { intentional: false }).catch(
317
- function ignoreRequestTerminationFailure() {
318
- return undefined;
319
- },
320
- );
338
+ void client.terminate(failure, { intentional: false }).catch(reportSpeechTerminationFailure);
321
339
  }
322
340
  });
323
341
  }
@@ -327,7 +345,7 @@ class SpeechWorkerClient {
327
345
  if (!is.boolean(intentional)) {
328
346
  throw new TypeError("Speech Worker termination intent must be a boolean.");
329
347
  }
330
- if (this.#terminated) return;
348
+ if (this.#terminationPromise) return this.#terminationPromise;
331
349
  const terminationReason = is.error(reason) && WORKER_CLIENT_ERRORS.has(reason)
332
350
  ? reason
333
351
  : clientError(
@@ -349,15 +367,37 @@ class SpeechWorkerClient {
349
367
  const pendingOperations = [...this.#pending.values()];
350
368
  for (const pending of pendingOperations) pending.cleanup();
351
369
  this.#pending.clear();
370
+ const resourceHost = this.#resourceHost;
371
+ this.#resourceHost = null;
372
+ this.#transport = null;
373
+ const client = this;
374
+ const failures = [];
375
+ this.#terminationPromise = Promise.resolve().then(async function terminateSpeechResources() {
376
+ const settlements = await Promise.allSettled([
377
+ Promise.resolve().then(function closeSpeechResources() {
378
+ return resourceHost?.close(terminationReason);
379
+ }),
380
+ Promise.resolve().then(function terminateSpeechWorker() {
381
+ return worker?.terminate();
382
+ }),
383
+ ]);
384
+ for (const settlement of settlements) {
385
+ if (settlement.status === 'rejected') failures.push(settlement.reason);
386
+ }
387
+ for (const pending of pendingOperations) pending.reject(terminationReason);
388
+ client.#trace('terminated', { reason: terminationReason, intentional });
389
+ if (failures.length) {
390
+ throw new AggregateError(failures, 'Unable to complete speech Worker termination.', { cause: terminationReason });
391
+ }
392
+ });
393
+ // Revoke provider readiness immediately; reentrant pool cleanup joins the
394
+ // already-owned promise before releasing prepared files.
352
395
  try {
353
- this.#transport = null;
354
- const termination = worker?.terminate();
355
- if (termination && is.function(termination.then)) await termination;
356
- } finally {
357
- for (const pending of pendingOperations) pending.reject(terminationReason);
358
- this.#trace("terminated", { reason: terminationReason, intentional });
359
- this.#onTermination(completeValue({ reason: terminationReason, intentional }));
396
+ this.#onTermination(completeValue({ reason: terminationReason, intentional }));
397
+ } catch (error) {
398
+ failures.push(error);
360
399
  }
400
+ return this.#terminationPromise;
361
401
  }
362
402
  }
363
403