arcane-os 0.92.0 → 0.94.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 (39) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/ai/twin-cloud.mjs +109 -46
  4. package/browser-runtime/core/client.mjs +20 -0
  5. package/browser-runtime/core/contracts.mjs +20 -0
  6. package/browser-runtime/core/native-diagnostics.mjs +217 -0
  7. package/docs/reference/ai/twin-cloud.md +35 -14
  8. package/docs/reference/availability-and-normalization.md +1 -1
  9. package/docs/reference/core/arcane-api.md +32 -0
  10. package/docs/reference/core/arcane-events.md +25 -0
  11. package/docs/reference/core-client.md +99 -0
  12. package/docs/reference/inventory/package-api.json +97 -13
  13. package/docs/reference/local-ai.md +81 -5
  14. package/docs/reference/native-diagnostics.md +167 -0
  15. package/docs/reference/native-speech.md +220 -11
  16. package/docs/reference/runtime-modules.md +21 -18
  17. package/docs/reference/sdk-api.md +152 -11
  18. package/package.json +4 -1
  19. package/schemas/arcane-app.schema.json +2 -2
  20. package/schemas/arcane-package.schema.json +2 -2
  21. package/src/core/app-control.mjs +1 -0
  22. package/src/core/hosts/windows/ArcaneAppControl.cs +1 -1
  23. package/src/core/hosts/windows/ArcaneHost.cs +167 -0
  24. package/src/core/hosts/windows/ArcaneLauncher.cs +1 -0
  25. package/src/core/hosts/windows/ArcaneNativeDiagnostics.cs +137 -0
  26. package/src/core/hosts/windows/ArcaneNotifications.cs +822 -0
  27. package/src/core/hosts/windows/ArcaneWindowControl.cs +3 -2
  28. package/src/core/services/speech.mjs +85 -23
  29. package/src/core-native-diagnostics-source.mjs +13 -0
  30. package/src/import-map.mjs +1 -0
  31. package/src/local-ai/config.mjs +12 -1
  32. package/src/local-ai/dev.mjs +71 -4
  33. package/src/local-ai/install.mjs +113 -5
  34. package/src/local-ai/kokoro/frontend.mjs +219 -0
  35. package/src/local-ai/kokoro/helper.mjs +168 -0
  36. package/src/local-ai/kokoro/index.mjs +645 -0
  37. package/src/local-ai/kokoro/native/CMakeLists.txt +45 -0
  38. package/src/local-ai/kokoro/native/main.cpp +249 -0
  39. package/src/native/portable-layout.mjs +67 -9
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.94.0
4
+
5
+ - Recover all TWiN HTTP 429 quota responses through the shared chat/System One
6
+ transport. Honor Retry-After or exponential backoff, share a transient
7
+ same-endpoint/credential cooldown, and pace concurrent recovery dispatches.
8
+ Fetch rejection, 429 and 529 share three retries; cancellation stops waits.
9
+ Preserve exact requests and complete terminal errors, including provider
10
+ status_code and nested error records. Do not replay 400/413 errors, consumed
11
+ streams, callbacks, or image submissions.
12
+ - Include the completed native Kokoro synthesis capability: retained voices,
13
+ explicit model preparation, owned cancellation and audio output through the
14
+ shared native speech contract. Reuse its retained compatible runtime and
15
+ source archives alongside the existing native host, Whisper and ONNX assets.
16
+ Package inclusion is distinct from application or native-runtime acceptance.
17
+
18
+ ## 0.93.0
19
+
20
+ - Add native desktop notification status, submission, state and removal through
21
+ `Arcane.notifications`, with ordered notification state events and an owned
22
+ Windows adapter. The host reports actual submission and OS errors; submission
23
+ does not establish that a person saw or activated the notification.
24
+ - Retain complete native document errors in the Windows host session, including
25
+ `window.error`, `unhandledrejection` and explicit `reportNativeError()` reports.
26
+ Retrieve non-consuming records through `connectAppControl().diagnostics()`
27
+ without foregrounding the app or waiting for Core/model readiness. Records
28
+ survive document navigation within that host session and end with the host.
29
+ - Publish the matching rebuilt Windows host archive with its document-start
30
+ diagnostic helper. Keep ordinary console output separate from retained error
31
+ history; console interception and caught console arguments are outside this
32
+ release. Native notification behavior and application acceptance remain
33
+ consumer-owned verification, distinct from the selected compile and package.
34
+
3
35
  ## 0.92.0
4
36
 
5
37
  - Add opt-in `allowUnborn` Git text snapshots and `initialBranch` repository
package/README.md CHANGED
@@ -25,7 +25,7 @@ selected SDK projection or an explicit live SDK source mount. Both profiles
25
25
  share the theme, packaging, event, cancellation,
26
26
  and browser run contracts while retaining their selected application layout.
27
27
 
28
- This checkout defines the `0.92.0` SDK contract. Development applications declare
28
+ This checkout defines the `0.94.0` SDK contract. Development applications declare
29
29
  `arcane-os` as `latest` and retain their lockfile. Exact SDK pins begin when Roshi
30
30
  designates a production build. Follow [Consumer updates](docs/consumer-updates.md)
31
31
  to refresh and compare the installed SDK and lockfile with the published channel;
@@ -4,6 +4,8 @@ import {arcaneLogging} from '../logging.mjs';
4
4
  const is = new Is(false);
5
5
  const twinChatURL = 'https://inference.do-ai.run/v1/chat/completions';
6
6
  const twinSystemOneURL = 'https://inference.do-ai.run/v1/systemone';
7
+ const activeHTTPRequests = new Map();
8
+ const httpRetryDelayMs = 3000;
7
9
 
8
10
  /** A stateless TWiN request; browser AI shares the HTTP and format owners below. */
9
11
  export async function fetchRequest({
@@ -450,10 +452,24 @@ export function openAIResponseFormat(format){
450
452
  /** Shared with browser streaming; consume no successful body at this boundary. */
451
453
  export async function fetchHTTPResponse(url, options, {onRetry = null} = {}) {
452
454
  const {signal} = options;
453
- const retryDelayMs = 3000;
454
455
  const maxRecoveryRetries = 3;
455
- let recoveryRetries = 0;
456
456
  let attempt = 0;
457
+ let retry = null;
458
+ const destination = url?.url ?? String(url);
459
+ const headers = new Headers(options.headers ?? url?.headers);
460
+ const requestOptions = {...options, headers};
461
+ const authorization = headers.get('authorization') ?? '';
462
+ let accounts = activeHTTPRequests.get(destination);
463
+ if (!accounts) {
464
+ accounts = new Map();
465
+ activeHTTPRequests.set(destination, accounts);
466
+ }
467
+ let pacing = accounts.get(authorization);
468
+ if (!pacing) {
469
+ pacing = {active: 0, rateLimited: false, resumeAt: 0, nextDispatchAt: 0, error: null};
470
+ accounts.set(authorization, pacing);
471
+ }
472
+ pacing.active += 1;
457
473
  try {
458
474
  while (true) {
459
475
  if (signal?.aborted) {
@@ -462,7 +478,7 @@ export async function fetchHTTPResponse(url, options, {onRetry = null} = {}) {
462
478
  let response;
463
479
  let error;
464
480
  try {
465
- response = await fetch(url, options);
481
+ response = await dispatchHTTPRequest(url, requestOptions, pacing, onRetry, retry, attempt);
466
482
  } catch (fetchError) {
467
483
  if (isAIRequestAbort(fetchError, signal)) {
468
484
  throw normalizeAIRequestAbort(fetchError);
@@ -483,7 +499,7 @@ export async function fetchHTTPResponse(url, options, {onRetry = null} = {}) {
483
499
  ? await response.json()
484
500
  : await response.text();
485
501
  } catch (bodyError) {
486
- if (status !== 529 || isAIRequestAbort(bodyError, signal)) {
502
+ if ((status !== 429 && status !== 529) || isAIRequestAbort(bodyError, signal)) {
487
503
  throw bodyError;
488
504
  }
489
505
  error = bodyError;
@@ -495,61 +511,108 @@ export async function fetchHTTPResponse(url, options, {onRetry = null} = {}) {
495
511
  const message = is.string(error)
496
512
  ? error
497
513
  : error?.error?.message ?? error?.message;
498
- const overload = status === 429
499
- && is.string(message)
500
- && message.toLowerCase().includes('overload');
501
- if (!overload) {
502
- if ((status !== null && status !== 529)
503
- || recoveryRetries >= maxRecoveryRetries) {
504
- throw error;
505
- }
506
- recoveryRetries += 1;
514
+ const retryDelayMs = status === 429
515
+ ? httpRateLimitDelay(response, httpRetryDelayMs * (2 ** Math.min(attempt, maxRecoveryRetries - 1)))
516
+ : httpRetryDelayMs;
517
+ if (status === 429) {
518
+ pacing.rateLimited = true;
519
+ pacing.resumeAt = Math.max(pacing.resumeAt, Date.now() + retryDelayMs);
520
+ pacing.error = error;
521
+ }
522
+ if ((status !== null && status !== 429 && status !== 529)
523
+ || attempt >= maxRecoveryRetries) {
524
+ throw error;
507
525
  }
508
526
  attempt += 1;
509
527
  arcaneLogging.warn(
510
528
  `${message ?? 'AI request transport failed.'}\nRetrying in ${retryDelayMs / 1000} seconds`,
511
529
  error
512
530
  );
513
- observeRequestRetry(
514
- onRetry,
515
- {phase: 'waiting', attempt, delayMs: retryDelayMs, status, error}
516
- );
517
- await new Promise(
518
- function waitForRequestRetry(resolve, reject) {
519
- function finishRetryDelay() {
520
- signal?.removeEventListener('abort', cancelRetryDelay);
521
- resolve();
522
- }
523
- function cancelRetryDelay() {
524
- clearTimeout(timer);
525
- signal.removeEventListener('abort', cancelRetryDelay);
526
- reject(normalizeAIRequestAbort(signal.reason));
527
- }
528
- const timer = setTimeout(finishRetryDelay, retryDelayMs);
529
- signal?.addEventListener(
530
- 'abort',
531
- cancelRetryDelay,
532
- {once: true}
533
- );
534
- if (signal?.aborted) {
535
- cancelRetryDelay();
536
- }
537
- }
538
- );
539
- if (signal?.aborted) {
540
- throw normalizeAIRequestAbort(signal.reason);
541
- }
542
- observeRequestRetry(
543
- onRetry,
544
- {phase: 'requesting', attempt, delayMs: retryDelayMs, status, error}
545
- );
531
+ retry = {phase: 'waiting', attempt, delayMs: retryDelayMs, status, error};
532
+ observeRequestRetry(onRetry, retry);
533
+ await waitForHTTPDelay(retryDelayMs, signal);
546
534
  }
547
535
  } catch (error) {
548
536
  if (isAIRequestAbort(error, signal)) {
549
537
  throw normalizeAIRequestAbort(error);
550
538
  }
551
539
  throw error;
540
+ } finally {
541
+ pacing.active -= 1;
542
+ if (pacing.active === 0) {
543
+ // Retain neither credentials nor quota state beyond the active request group.
544
+ accounts.delete(authorization);
545
+ if (accounts.size === 0) {
546
+ activeHTTPRequests.delete(destination);
547
+ }
548
+ }
549
+ }
550
+ }
551
+
552
+ function httpRateLimitDelay(response, fallback) {
553
+ const value = response.headers.get('retry-after')?.trim();
554
+ if (!value) return fallback;
555
+ const seconds = Number(value);
556
+ if (is.finite(seconds)) {
557
+ return seconds >= 0 && is.finite(seconds * 1000) ? seconds * 1000 : fallback;
552
558
  }
559
+ // Accept HTTP weekday date forms, not an arbitrary numeric string interpreted as a date.
560
+ const date = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/i.test(value) ? Date.parse(value) : NaN;
561
+ return is.finite(date) ? Math.max(0, date - Date.now()) : fallback;
562
+ }
563
+
564
+ async function dispatchHTTPRequest(url, options, pacing, onRetry, retry, attempt) {
565
+ const {signal} = options;
566
+ let observation = null;
567
+ // Only an observed quota response coordinates otherwise independent requests.
568
+ while (pacing.rateLimited) {
569
+ if (signal?.aborted) throw normalizeAIRequestAbort(signal.reason);
570
+ const delayMs = Math.max(pacing.resumeAt, pacing.nextDispatchAt) - Date.now();
571
+ if (delayMs <= 0) {
572
+ // Claim and initiate Fetch in this continuation, with no intervening asynchronous wait.
573
+ pacing.nextDispatchAt = Date.now() + httpRetryDelayMs;
574
+ break;
575
+ }
576
+ if (!retry && !observation) {
577
+ observation = {phase: 'waiting', attempt, delayMs, status: 429, error: pacing.error};
578
+ observeRequestRetry(onRetry, observation);
579
+ }
580
+ await waitForHTTPDelay(delayMs, signal);
581
+ }
582
+ if (observation && !signal?.aborted) {
583
+ observeRequestRetry(onRetry, {...observation, phase: 'requesting'});
584
+ }
585
+ if (retry) {
586
+ observeRequestRetry(onRetry, {...retry, phase: 'requesting'});
587
+ }
588
+ if (signal?.aborted) throw normalizeAIRequestAbort(signal.reason);
589
+ return fetch(url, options);
590
+ }
591
+
592
+ async function waitForHTTPDelay(delayMs, signal) {
593
+ let remaining = delayMs;
594
+ do {
595
+ if (signal?.aborted) throw normalizeAIRequestAbort(signal.reason);
596
+ // Only split at the host timer range; never shorten the provider's requested wait.
597
+ const milliseconds = Math.min(remaining, 2147483647);
598
+ await new Promise(
599
+ function waitForRequestRetry(resolve, reject) {
600
+ function finishRetryDelay() {
601
+ signal?.removeEventListener('abort', cancelRetryDelay);
602
+ resolve();
603
+ }
604
+ function cancelRetryDelay() {
605
+ clearTimeout(timer);
606
+ signal.removeEventListener('abort', cancelRetryDelay);
607
+ reject(normalizeAIRequestAbort(signal.reason));
608
+ }
609
+ const timer = setTimeout(finishRetryDelay, milliseconds);
610
+ signal?.addEventListener('abort', cancelRetryDelay, {once: true});
611
+ if (signal?.aborted) cancelRetryDelay();
612
+ }
613
+ );
614
+ remaining -= milliseconds;
615
+ } while (remaining > 0);
553
616
  }
554
617
 
555
618
  function observeRequestRetry(onRetry, state) {
@@ -598,6 +598,8 @@ export function createCoreFacade(client){
598
598
  remove:name=>invoke('environment.delete',{name})},
599
599
  mail:{send:request=>invoke('mail.send',request??{},{timeoutMs:450000})},
600
600
  speech:{status:()=>invoke('speech.status',{},{timeoutMs:10000}),
601
+ load:function loadSpeech(request,options){return invoke('speech.load',request??{},{timeoutMs:0,signal:options?.signal,onRequest:options?.onRequest});},
602
+ unload:function unloadSpeech(request,options){return invoke('speech.unload',request??{},{timeoutMs:0,signal:options?.signal,onRequest:options?.onRequest});},
601
603
  synthesize:(request,options)=>invoke('speech.synthesize',request??{},{timeoutMs:180000,signal:options?.signal}),
602
604
  transcribe:(request,options)=>invoke('speech.transcribe',request??{},{timeoutMs:180000,signal:options?.signal,onRequest:options?.onRequest})},
603
605
  localAI:{status:()=>invoke('localai.status',{},{timeoutMs:15000}),
@@ -646,6 +648,24 @@ export function createCoreFacade(client){
646
648
  storage:{list:()=>invoke('storage.list'),get:key=>invoke('storage.get',{key}),set:(key,value)=>invoke('storage.set',{key,value}),delete:key=>invoke('storage.delete',{key})},
647
649
  preferences:{list:()=>invoke('preferences.list'),get:key=>invoke('preferences.get',{key}),set:(key,value)=>invoke('preferences.set',{key,value}),
648
650
  setMany:entries=>invoke('preferences.setMany',{entries}),delete:key=>invoke('preferences.delete',{key})},
651
+ notifications: {
652
+ status(options) {
653
+ return invoke('notifications.status', {}, options).catch(function notificationStatusUnavailable(error) {
654
+ if (error?.code !== 'METHOD_NOT_ALLOWED' || error?.reason !== 'core-namespace-unavailable') throw error;
655
+ return {supported: false, available: false, permissionDisabled: null,
656
+ reason: 'host-notifications-unavailable', error};
657
+ });
658
+ },
659
+ show(request, options) {
660
+ return invoke('notifications.show', request, options);
661
+ },
662
+ state(selection = {}, options) {
663
+ return invoke('notifications.state', selection, options);
664
+ },
665
+ close(selection, options) {
666
+ return invoke('notifications.close', selection, options);
667
+ }
668
+ },
649
669
  window: {
650
670
  state(options) {
651
671
  return invoke('window.state', {}, {signal: options?.signal});
@@ -43,6 +43,26 @@ export const CORE_METHOD_CONTRACTS={
43
43
  output: '{requestedTarget,resolvedDevice,resolution,reason}',
44
44
  meaning: 'Resolve a saved physical identity. Explicit null selects engine-owned automatic resolution; it does not claim a configured or observed device.'
45
45
  },
46
+ 'notifications.status': {
47
+ input: '{}',
48
+ output: '{supported,available,permissionDisabled,reason?,error?}',
49
+ meaning: 'Actual native host support and OS permission state; no preference changes or browser/audio substitution.'
50
+ },
51
+ 'notifications.show': {
52
+ input: '{id,title,body,data}',
53
+ output: 'notification-record-v1',
54
+ meaning: 'Submit one exact application notification. Submitted means native Show returned, not display or human receipt. Caller IDs cannot be reused during this host lifetime.'
55
+ },
56
+ 'notifications.state': {
57
+ input: '{id?}',
58
+ output: '{revision,notifications:notification-record-v1[]}',
59
+ meaning: 'Retained complete records for this application host lifetime, including actual terminal outcomes; accepted notices survive renderer navigation.'
60
+ },
61
+ 'notifications.close': {
62
+ input: '{id}',
63
+ output: 'notification-record-v1',
64
+ meaning: 'Remove only the exact owned notice and retain its record. Cancellation after submission does not reverse human interaction.'
65
+ },
46
66
  'window.state': {
47
67
  input: '{}',
48
68
  output: '{platform,supported,state}',
@@ -0,0 +1,217 @@
1
+ const NATIVE_DIAGNOSTICS_KEY = Symbol.for('arcane-os.native-diagnostics');
2
+ const NATIVE_DOCUMENT_KEY = Symbol.for('arcane-os.app-control.document');
3
+
4
+ /** Report an application-caught error to this document's native host ledger. */
5
+ export async function reportNativeError(error, options = {}) {
6
+ const owner = globalThis[NATIVE_DIAGNOSTICS_KEY];
7
+ if (!owner) throw nativeDiagnosticsUnavailable();
8
+ return owner.report(error, options);
9
+ }
10
+
11
+ /** The native host installs this once, at document creation, before app scripts. */
12
+ export function installNativeDiagnostics(global = globalThis) {
13
+ if (global[NATIVE_DIAGNOSTICS_KEY]) return global[NATIVE_DIAGNOSTICS_KEY];
14
+
15
+ const documentId = global[NATIVE_DOCUMENT_KEY] ??= global.crypto.randomUUID();
16
+ let delivery = Promise.resolve();
17
+ const owner = {
18
+ documentId,
19
+ ready: null,
20
+ report(error, options = {}) {
21
+ return capture('application', error, options);
22
+ }
23
+ };
24
+ global[NATIVE_DIAGNOSTICS_KEY] = owner;
25
+ global.addEventListener('error', captureWindowError);
26
+ global.addEventListener('unhandledrejection', captureUnhandledRejection);
27
+ owner.ready = transmit(envelope('document'));
28
+ owner.ready.catch(reportCaptureFailure);
29
+ return owner;
30
+
31
+ function envelope(type, record) {
32
+ return {
33
+ protocol: 'arcane.native-diagnostics/1',
34
+ type,
35
+ documentId,
36
+ documentUrl: global.location.href,
37
+ time: new Date().toISOString(),
38
+ ...(record === undefined ? {} : {record})
39
+ };
40
+ }
41
+
42
+ async function capture(kind, error, options = {}, observation) {
43
+ // Snapshot synchronously at the reported failure, before any bridge wait.
44
+ const record = {kind, ...encodeDiagnosticValues(error, options)};
45
+ if (observation !== undefined) {
46
+ record.location = observation.location;
47
+ record.message = observation.message;
48
+ }
49
+ return transmit(envelope('error', record));
50
+ }
51
+
52
+ function transmit(frame) {
53
+ // This document has one ordered ingress. A failed write is observed by
54
+ // its caller and does not prevent subsequent independent reports.
55
+ const text = JSON.stringify(frame);
56
+ const pending = delivery.then(async function sendDiagnostic() {
57
+ const windows = global.chrome?.webview?.hostObjects?.arcaneDiagnostics;
58
+ const webkit = global.webkit?.messageHandlers?.arcaneDiagnostics;
59
+ let reply;
60
+ if (windows) reply = await windows.Send(text);
61
+ else if (webkit) reply = await webkit.postMessage(text);
62
+ else throw nativeDiagnosticsUnavailable();
63
+ const result = typeof reply === 'string' ? JSON.parse(reply) : reply;
64
+ if (result?.accepted !== true) {
65
+ const error = new Error('The native host did not retain the diagnostic.', {cause: result?.error});
66
+ error.code = 'ARCANE_NATIVE_DIAGNOSTIC_REJECTED';
67
+ error.details = result;
68
+ throw error;
69
+ }
70
+ return result;
71
+ });
72
+ delivery = pending.catch(function observeOrderedFailure() {});
73
+ return pending;
74
+ }
75
+
76
+ function captureWindowError(event) {
77
+ // Resource-element load failures are a different observation boundary;
78
+ // this listener does not inspect their DOM or request payloads.
79
+ if (event.target !== global) return;
80
+ capture('window.error', event.error, {},
81
+ {message: event.message, location: {file: event.filename, line: event.lineno, column: event.colno}}
82
+ ).catch(function failedWindowCapture(failure) {
83
+ reportCaptureFailure(failure, event.error);
84
+ });
85
+ }
86
+
87
+ function captureUnhandledRejection(event) {
88
+ capture('unhandledrejection', event.reason).catch(function failedRejectionCapture(failure) {
89
+ reportCaptureFailure(failure, event.reason);
90
+ });
91
+ }
92
+
93
+ function reportCaptureFailure(failure, original) {
94
+ // Observe our own asynchronous failure without manufacturing another
95
+ // unhandled rejection. The browser's ordinary error behavior continues.
96
+ global.console.error('Arcane native diagnostic capture failed.', failure, original);
97
+ }
98
+ }
99
+
100
+ function nativeDiagnosticsUnavailable() {
101
+ const error = new Error('This document has no native diagnostic capture host.');
102
+ error.code = 'ARCANE_NATIVE_DIAGNOSTICS_UNAVAILABLE';
103
+ return error;
104
+ }
105
+
106
+ /**
107
+ * Transport-local property graph, not JSON.stringify(error). Descriptor values
108
+ * preserve non-enumerable Error fields, symbols, cycles and shared references.
109
+ * Accessors remain descriptors; inspection never calls their get/set functions.
110
+ * An object that rejects reflection fails the report with that original error.
111
+ */
112
+ function encodeDiagnosticValues(error, options) {
113
+ const references = new Map();
114
+ const pending = [];
115
+ const values = [];
116
+ const record = {valueFormat: 'arcane.diagnostic-value/1', error: encode(error), values};
117
+ const details = Object.getOwnPropertyDescriptor(options, 'details');
118
+ if (details) {
119
+ record.details = Object.hasOwn(details, 'value') ? encode(details.value)
120
+ : {type: 'accessor', get: encode(details.get), set: encode(details.set)};
121
+ }
122
+
123
+ for (let index = 0; index < pending.length; index += 1) {
124
+ const value = pending[index];
125
+ const node = values[index];
126
+ if (typeof value === 'symbol') {
127
+ node.description = encode(value.description);
128
+ node.globalKey = encode(Symbol.keyFor(value));
129
+ continue;
130
+ }
131
+ const descriptors = Object.getOwnPropertyDescriptors(value);
132
+ node.properties = Reflect.ownKeys(descriptors).map(function describeProperty(key) {
133
+ return {key: encode(key), ...encodeDescriptor(descriptors[key])};
134
+ });
135
+ const prototype = Object.getPrototypeOf(value);
136
+ const names = prototypeNames(prototype);
137
+ if (names.length) node.constructorName = names[0];
138
+
139
+ // Error subclasses may supply these on a prototype, including custom
140
+ // accessors. Preserve the descriptor without reading through it.
141
+ node.inherited = [];
142
+ for (const key of ['name', 'message', 'stack', 'cause', 'errors', 'code', 'details']) {
143
+ if (Object.hasOwn(descriptors, key)) continue;
144
+ const descriptor = findDescriptor(prototype, key);
145
+ if (descriptor) node.inherited.push({key, ...encodeDescriptor(descriptor)});
146
+ }
147
+ if (names.includes('Map')) {
148
+ node.entries = Array.from(Map.prototype.entries.call(value), function encodeEntry([key, item]) {
149
+ return [encode(key), encode(item)];
150
+ });
151
+ } else if (names.includes('Set')) {
152
+ node.entries = Array.from(Set.prototype.values.call(value), encode);
153
+ } else if (names.includes('Date')) {
154
+ node.time = encode(Date.prototype.getTime.call(value));
155
+ } else if (names.includes('RegExp')) {
156
+ node.source = Object.getOwnPropertyDescriptor(RegExp.prototype, 'source').get.call(value);
157
+ node.flags = {};
158
+ for (const key of ['hasIndices', 'global', 'ignoreCase', 'multiline', 'dotAll', 'unicode', 'unicodeSets', 'sticky']) {
159
+ const getter = Object.getOwnPropertyDescriptor(RegExp.prototype, key)?.get;
160
+ if (getter) node.flags[key] = getter.call(value);
161
+ }
162
+ }
163
+ }
164
+ return record;
165
+
166
+ function encode(value) {
167
+ const type = typeof value;
168
+ if (value === null || type === 'string' || type === 'boolean') return value;
169
+ if (type === 'number') {
170
+ return Number.isFinite(value) && !Object.is(value, -0)
171
+ ? value : {type, value: Object.is(value, -0) ? '-0' : String(value)};
172
+ }
173
+ if (type === 'undefined') return {type};
174
+ if (type === 'bigint') return {type, value: String(value)};
175
+ if (!references.has(value)) {
176
+ const id = values.length + 1;
177
+ references.set(value, id);
178
+ pending.push(value);
179
+ values.push({id, type: Array.isArray(value) ? 'array' : type});
180
+ }
181
+ return {ref: references.get(value)};
182
+ }
183
+
184
+ function encodeDescriptor(descriptor) {
185
+ const result = {enumerable: descriptor.enumerable, configurable: descriptor.configurable};
186
+ if (Object.hasOwn(descriptor, 'value')) {
187
+ return {...result, value: encode(descriptor.value), writable: descriptor.writable};
188
+ }
189
+ return {...result, get: encode(descriptor.get), set: encode(descriptor.set)};
190
+ }
191
+
192
+ function findDescriptor(prototype, key) {
193
+ const visited = new Set();
194
+ while (prototype && !visited.has(prototype)) {
195
+ visited.add(prototype);
196
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, key);
197
+ if (descriptor) return descriptor;
198
+ prototype = Object.getPrototypeOf(prototype);
199
+ }
200
+ return undefined;
201
+ }
202
+
203
+ function prototypeNames(prototype) {
204
+ const names = [];
205
+ const visited = new Set();
206
+ while (prototype && !visited.has(prototype)) {
207
+ visited.add(prototype);
208
+ const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor')?.value;
209
+ if (typeof constructor === 'function') {
210
+ const name = Object.getOwnPropertyDescriptor(constructor, 'name')?.value;
211
+ if (typeof name === 'string') names.push(name);
212
+ }
213
+ prototype = Object.getPrototypeOf(prototype);
214
+ }
215
+ return names;
216
+ }
217
+ }
@@ -94,17 +94,34 @@ The key is
94
94
  transport authentication, not part of either callback's message payload. Keep
95
95
  credentials out of application logging as well.
96
96
 
97
- Before a successful response is consumed, a rejected Fetch or HTTP `529` can
98
- retry up to three times, waiting `3000` milliseconds before each retry. These
99
- two failure types share that three-retry budget. HTTP `429` with a message
100
- containing `overload` (case-insensitive) retains its unlimited three-second
101
- retry behavior and does not consume that budget. Each attempt reuses the exact
102
- destination, Fetch options, serialized request, and signal. Other HTTP errors,
103
- successful-response decoding failures, and application callback failures do not
104
- retry. A known HTTP `529` also retries when reading or parsing its diagnostic
105
- body fails; `onRetry.error` retains that exact failure, which is thrown unchanged
106
- if recovery is exhausted. Diagnostic-body failures for other HTTP statuses do
107
- not retry. The last complete failure is thrown unchanged when recovery is exhausted.
97
+ Before a successful response is consumed, rejected Fetch calls and HTTP `429`
98
+ or `529` share a budget of three retries (four total attempts). Every `429`
99
+ qualifies, including request-per-minute and token-per-minute quota responses;
100
+ the error message does not select recovery. A readable `Retry-After` header
101
+ supplies the `429` delay as seconds or an HTTP date. Otherwise the upcoming
102
+ retry number selects a `3000`, `6000`, or `12000` millisecond delay. Rejected
103
+ Fetch and `529` retain their `3000` millisecond delay. A provider's longer wait
104
+ is preserved, split only at the host timer's range when necessary.
105
+
106
+ After an observed `429`, overlapping requests through this module with the
107
+ same exact endpoint and Authorization value share the latest cooldown and
108
+ dispatch at least three seconds apart. A later quota response extends that
109
+ cooldown, including a terminal quota response while sibling calls remain active;
110
+ its fallback remains at most `12000` milliseconds. Different endpoints and credentials remain independent, and requests
111
+ before any quota response remain concurrent. This is active-operation pacing,
112
+ not a persistent global rate limiter: the module releases the group, including
113
+ its credential key and error, when its last HTTP operation settles. It does not
114
+ coordinate separate tabs, processes, or independently loaded module instances.
115
+
116
+ Each attempt reuses the exact destination, Fetch option values, serialized request,
117
+ and signal. Headers are normalized once through standard Fetch `Headers` and
118
+ retained for retries, including when the caller supplied a one-shot iterable.
119
+ Other HTTP errors (including `400` and `413`), successful-response
120
+ decoding failures, and application callback failures do not retry. A known
121
+ `429` or `529` also retries when reading its diagnostic body fails;
122
+ `onRetry.error` retains that exact failure. The last complete error is thrown
123
+ unchanged on exhaustion, preserving provider fields such as `status_code` and
124
+ nested `error.type`/`error.message`. No error envelope is rebuilt or sanitized.
108
125
  Pass a fresh
109
126
  `AbortController`'s `signal` and call `abort()` to cancel. Cancellation during
110
127
  the request, response-body read, retry wait, or callback settlement prevents
@@ -114,12 +131,16 @@ A missing key uses `AI_PROVIDER_NOT_CONFIGURED`, a missing explicit model
114
131
  throws `TypeError`, and an unsupported `structuredOutput` input uses
115
132
  `AI_STRUCTURED_OUTPUT_INVALID`.
116
133
 
117
- Optional `onRetry({phase,attempt,delayMs,status,error})` observes each retry.
134
+ Optional `onRetry({phase,attempt,delayMs,status,error})` observes recovery.
118
135
  `phase:'waiting'` arrives before the abortable delay; `phase:'requesting'`
119
136
  arrives immediately before the next Fetch. `attempt` is the one-based upcoming
120
- retry number across both retry policies, `delayMs` is `3000`, `status` is the
137
+ retry number across the shared budget, `delayMs` is the selected wait, `status` is the
121
138
  HTTP status or `null` for a rejected Fetch, and `error` is the complete original
122
- failure value. This observer runs synchronously without awaiting its returned
139
+ failure value. An initial request waiting behind an already observed quota
140
+ reports `attempt:0`, `status:429`, and that complete quota error. Shared pacing
141
+ can extend the actual wait beyond its initial observation; `requesting` is
142
+ emitted only after that wait, immediately before dispatch. This observer runs
143
+ synchronously without awaiting its returned
123
144
  promise. Synchronous throws and rejected promises are reported through the
124
145
  shared console logger and cannot change the request outcome or trigger another
125
146
  retry. `onRequest` still runs once per logical request. Retry observations stay
@@ -99,7 +99,7 @@ where listed. See [native output and host requirements](core-native-packaging.md
99
99
  | Label speakers in caller-fed mono audio | [`arcane-os/diarization`, `arcane-os/core/diarization`](diarization.md) | **Node** with built helper, selected official NeMo libraries and Nemotron model; official CPU archives cover Windows x64 and Linux/macOS x64/arm64 | Independent retained streams, complete timed segments and optional probability frames. Generic labels are not personal identities or speech transcripts; cancellation and close observe native cleanup. Android needs host adaptation. |
100
100
  | Use local AI without coupling app code to Ollama HTTP | `Arcane.localAI`, `Arcane.ai`, or `/arcane/modules/Ollama.js` | Primarily **Native**; Android exposes a narrower admitted inference projection | Admission, errors, and managed-operation events are normalized. Direct Ollama response envelopes remain **Provider-native**. |
101
101
  | Use TWiN Cloud from the renderer profile | `/arcane/modules/AI.js` | **Cloud** from an allowed browser/native renderer | High-level chat behavior is normalized by the module. The TWiN access key authenticates remote LLM chat; raw provider diagnostics remain provider-specific. No automatic cloud fallback is inferred from local failure. |
102
- | Send one TWiN Cloud request with an explicit key and model | `fetchRequest` from `arcane-os/ai/twin-cloud` | **Node** and **Browser**, using standard Fetch and a remote HTTPS provider | Keeps complete messages and returns the full parsed provider JSON. Rejected Fetch and HTTP 529 share three retries after 3000 ms; HTTP 429 overload retains unlimited three-second retries. Optional `onRetry` reports waiting/requesting phases without blocking recovery. Shared structured-output mapping and cancellation match browser TWiN transport. No browser profile, AI/user singleton, DBOPFS, or retained request history is created. |
102
+ | Send one TWiN Cloud request with an explicit key and model | `fetchRequest` from `arcane-os/ai/twin-cloud` | **Node** and **Browser**, using standard Fetch and a remote HTTPS provider | Keeps complete messages and returns the full parsed provider JSON. Rejected Fetch and all HTTP 429/529 share three retries. Quota recovery honors Retry-After or exponential delay; active same-endpoint/credential requests share cooldown and paced dispatch. Optional `onRetry` reports waiting/requesting without blocking recovery. Complete errors and cancellation match browser TWiN transport. No browser profile, AI/user singleton, DBOPFS, or retained request history is created. |
103
103
  | Evaluate caller-owned state and questions with TWiN System One | `fetchSystemOneRequest` from `arcane-os/ai/twin-cloud` | **Node** and **Browser**, using standard Fetch and a remote HTTPS provider | Posts the explicit model and complete state/questions through the existing JSON transport. Returns the entire parsed provider result and shares its retry, diagnostic, and cancellation behavior. Model choice, question definitions, answer interpretation, and scoring policy stay with the caller; no conversation state is retained. |
104
104
  | Generate images from a complete caller-owned prompt | `generateImages` from `arcane-os/ai/twin-cloud` | **Node** and **Browser**, using standard Fetch, Blob, and a remote HTTPS provider | Caller selects `fal-ai/flux/schnell` or `stable-diffusion-3.5-large`, credentials, and provider parameters. Returns every image as a Blob with media metadata in provider order; owns progress, job polling, and cancellation. Submission is not automatically replayed after an ambiguous failure. Display and storage stay application-owned. |
105
105
  | Use speech through one application helper | `/arcane/modules/AI.js` and `Arcane.speech` | **Browser** or **Native** | Whisper and Kokoro keep their on-device paths; explicitly selected Web Speech STT/TTS and TWiN Cloud TTS declare their non-local behavior. `prepareTTSPlayback` supports real audio and silent native descriptors; `fetchTTS` and durable `prepareTTS` require real audio. Provider choice, model, voice, and credentials stay application-owned. |