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.
- package/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/browser-runtime/ai/twin-cloud.mjs +109 -46
- package/browser-runtime/core/client.mjs +20 -0
- package/browser-runtime/core/contracts.mjs +20 -0
- package/browser-runtime/core/native-diagnostics.mjs +217 -0
- package/docs/reference/ai/twin-cloud.md +35 -14
- package/docs/reference/availability-and-normalization.md +1 -1
- package/docs/reference/core/arcane-api.md +32 -0
- package/docs/reference/core/arcane-events.md +25 -0
- package/docs/reference/core-client.md +99 -0
- package/docs/reference/inventory/package-api.json +97 -13
- package/docs/reference/local-ai.md +81 -5
- package/docs/reference/native-diagnostics.md +167 -0
- package/docs/reference/native-speech.md +220 -11
- package/docs/reference/runtime-modules.md +21 -18
- package/docs/reference/sdk-api.md +152 -11
- package/package.json +4 -1
- package/schemas/arcane-app.schema.json +2 -2
- package/schemas/arcane-package.schema.json +2 -2
- package/src/core/app-control.mjs +1 -0
- package/src/core/hosts/windows/ArcaneAppControl.cs +1 -1
- package/src/core/hosts/windows/ArcaneHost.cs +167 -0
- package/src/core/hosts/windows/ArcaneLauncher.cs +1 -0
- package/src/core/hosts/windows/ArcaneNativeDiagnostics.cs +137 -0
- package/src/core/hosts/windows/ArcaneNotifications.cs +822 -0
- package/src/core/hosts/windows/ArcaneWindowControl.cs +3 -2
- package/src/core/services/speech.mjs +85 -23
- package/src/core-native-diagnostics-source.mjs +13 -0
- package/src/import-map.mjs +1 -0
- package/src/local-ai/config.mjs +12 -1
- package/src/local-ai/dev.mjs +71 -4
- package/src/local-ai/install.mjs +113 -5
- package/src/local-ai/kokoro/frontend.mjs +219 -0
- package/src/local-ai/kokoro/helper.mjs +168 -0
- package/src/local-ai/kokoro/index.mjs +645 -0
- package/src/local-ai/kokoro/native/CMakeLists.txt +45 -0
- package/src/local-ai/kokoro/native/main.cpp +249 -0
- 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.
|
|
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
|
|
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
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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. |
|