arcane-os 0.77.0 → 0.79.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.77.0",
3
+ "version": "0.79.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -69,6 +69,7 @@
69
69
  "./core/preferences": "./src/core/services/preferences.mjs",
70
70
  "./core/diarization": "./src/diarization/service.mjs",
71
71
  "./core/local-ai": "./src/core/services/local-ai.mjs",
72
+ "./core/execution-devices": "./src/core/services/execution-devices.mjs",
72
73
  "./core/decisions": "./src/core/services/decisions.mjs",
73
74
  "./core/image": "./src/core/services/image.mjs",
74
75
  "./core/speech": "./src/core/services/speech.mjs",
@@ -77,6 +78,7 @@
77
78
  "./diarization/build": "./src/diarization/build.mjs",
78
79
  "./local-ai": "./src/local-ai/install.mjs",
79
80
  "./local-ai/onnx": "./src/local-ai/onnx.mjs",
81
+ "./local-ai/execution-devices": "./src/local-ai/execution-devices.mjs",
80
82
  "./local-ai/decisions": "./src/local-ai/decisions.mjs",
81
83
  "./local-ai/image": "./src/local-ai/image-runtime.mjs",
82
84
  "./local-ai/whisper": "./src/local-ai/whisper/index.mjs",
@@ -123,7 +125,7 @@
123
125
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
124
126
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
125
127
  "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.test.mjs test/contracts.test.mjs test/conversation-closing-report.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/native-windows-icon.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
126
- "test:functional": "node ./bin/arcane-test.mjs test/ai-ollama-readiness.test.mjs test/core-ollama-lifecycle.test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-decisions.test.mjs test/browser-kokoro-complete-input.test.mjs test/browser-speech-providers.test.mjs test/browser-speech-artifacts.test.mjs test/browser-speech-resource-bridge.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/codex-app-server.test.mjs test/mcp-stdio-server.test.mjs test/core-classic-source.test.mjs test/core-client.test.mjs test/core-client-installation.test.mjs test/core-ai-installation.test.mjs test/core-llama-lifecycle.test.mjs test/core-development.test.mjs test/core-host.test.mjs test/core-shared-host.test.mjs test/core-model-assets.test.mjs test/core-native-services.test.mjs test/core-packaged-web.test.mjs test/core-runtime.test.mjs test/core-stdio.test.mjs test/dbopfs-committed-events.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-model-resources.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/local-image-editing.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/model-resource-bridge.test.mjs test/native-decisions.test.mjs test/native-onnx.test.mjs test/native-module-packaging.test.mjs test/native-module-resource-references.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/websocket-client-import-map.test.mjs test/websocket.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
128
+ "test:functional": "node ./bin/arcane-test.mjs test/ai-ollama-readiness.test.mjs test/core-ollama-lifecycle.test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-decisions.test.mjs test/browser-kokoro-complete-input.test.mjs test/browser-speech-providers.test.mjs test/browser-speech-artifacts.test.mjs test/browser-speech-resource-bridge.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/codex-app-server.test.mjs test/mcp-stdio-server.test.mjs test/core-classic-source.test.mjs test/core-client.test.mjs test/core-client-installation.test.mjs test/core-ai-installation.test.mjs test/core-llama-lifecycle.test.mjs test/core-development.test.mjs test/core-host.test.mjs test/core-shared-host.test.mjs test/core-model-assets.test.mjs test/core-native-services.test.mjs test/core-packaged-web.test.mjs test/core-runtime.test.mjs test/core-stdio.test.mjs test/dbopfs-committed-events.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-model-resources.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/execution-devices.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/local-image-editing.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/model-resource-bridge.test.mjs test/native-decisions.test.mjs test/native-onnx.test.mjs test/native-module-packaging.test.mjs test/native-module-resource-references.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/websocket-client-import-map.test.mjs test/websocket.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
127
129
  "test:integration": "node ./bin/arcane-test.mjs test/installed-package-runtime.test.mjs test/root-app-layout.test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-macos-provider.test.mjs test/native-plan.test.mjs test/native-portable-provider.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
128
130
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/html-import-packaged-script.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/ai-audio-output.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
129
131
  "check": "node tools/check-source.mjs && npm test",
@@ -26,7 +26,7 @@ import {
26
26
  openAIResponseFormat
27
27
  } from 'arcane-os/ai/twin-cloud';
28
28
  import {MarkdownSpeech,stripSpeechFormatting} from 'arcane-os/speech-text';
29
- import {prepareSpeech} from './PreparedSpeech.js';
29
+ import {prepareSpeech, readPreparedSpeech} from './PreparedSpeech.js';
30
30
  import {createToolTextObserver} from 'arcane-os/ai/tool-text-stream';
31
31
 
32
32
  const completeValue=(value)=>value;
@@ -6341,16 +6341,15 @@ class AI {
6341
6341
  return result;
6342
6342
  }
6343
6343
 
6344
- prepareTTS({parts, textFormat, storage = null, identity = null, signal = null, onState = null} = {}) {
6344
+ #preparedSpeechInput(parts, textFormat, operation) {
6345
6345
  if(this.#providerSpeechModel()?.speech?.playback==='native'){
6346
6346
  const error=new Error('Native browser speech has no audio file to prepare or store; use prepareTTSPlayback.');
6347
6347
  error.code='ARCANE_AI_TTS_AUDIO_EXPORT_UNAVAILABLE';
6348
6348
  throw error;
6349
6349
  }
6350
6350
  if(!is.array(parts)) {
6351
- throw new TypeError('AI.prepareTTS parts must be an array.');
6351
+ throw new TypeError(`${operation} parts must be an array.`);
6352
6352
  }
6353
- const runtime = this;
6354
6353
  const selected = this.#providerRuntime.selection('tts');
6355
6354
  const configuredSpeech = this.browserSpeechConfiguration?.tts;
6356
6355
  const configuration = configuredSpeech
@@ -6367,12 +6366,9 @@ class AI {
6367
6366
  responseFormat: selected ? this.#providerSpeechResponseFormat() : this.audioFormat,
6368
6367
  configuration
6369
6368
  };
6370
- const browserGeneration = this.#browserSpeechGeneration;
6371
- const controlGeneration = this.#speechControlGeneration;
6372
6369
  const defaultVoice = selected
6373
6370
  ? this.#providerSpeechVoice()
6374
6371
  : this.#builtInSpeechDefaultVoice('tts', this.ttsService);
6375
- const responseFormat = selection.responseFormat;
6376
6372
  const segmentation = this.ttsSegmentation;
6377
6373
  const segments = [];
6378
6374
  for(const part of parts) {
@@ -6398,6 +6394,33 @@ class AI {
6398
6394
  );
6399
6395
  }
6400
6396
  }
6397
+ return {segments, selection, segmentation, selected};
6398
+ }
6399
+
6400
+ async readPreparedTTS({parts, textFormat, storage = null, identity = null, signal = null, onState = null} = {}) {
6401
+ const {segments, selection, segmentation} = this.#preparedSpeechInput(parts, textFormat, 'AI.readPreparedTTS');
6402
+ this.#traceSpeech('readPreparedTTS.call', {parts, segments, selection, segmentation, identity, storage});
6403
+ return readPreparedSpeech(
6404
+ {
6405
+ owner: this,
6406
+ parts: segments,
6407
+ originalParts: parts,
6408
+ selection,
6409
+ segmentation,
6410
+ storage,
6411
+ identity,
6412
+ signal,
6413
+ onState
6414
+ }
6415
+ );
6416
+ }
6417
+
6418
+ prepareTTS({parts, textFormat, storage = null, identity = null, signal = null, onState = null} = {}) {
6419
+ const {segments, selection, segmentation, selected} = this.#preparedSpeechInput(parts, textFormat, 'AI.prepareTTS');
6420
+ const runtime = this;
6421
+ const browserGeneration = this.#browserSpeechGeneration;
6422
+ const controlGeneration = this.#speechControlGeneration;
6423
+ const responseFormat = selection.responseFormat;
6401
6424
  this.#traceSpeech('prepareTTS.call', {parts, segments, selection, segmentation, identity, storage});
6402
6425
  async function synthesizePreparedSpeech(part, requestSignal) {
6403
6426
  function assertPreparedSpeechSelection() {
@@ -554,12 +554,13 @@ class DBOPFS {
554
554
 
555
555
  /**
556
556
  * Gets the directory handle for a table.
557
- * Creates the table if it does not exist.
557
+ * Creates the table if it does not exist unless create is false.
558
558
  *
559
559
  * @param {string} tableName
560
+ * @param {{create?:boolean}} options A false create performs an existing-table lookup only.
560
561
  * @returns {Promise<FileSystemDirectoryHandle>}
561
562
  */
562
- async getTableHandle(tableName=''){
563
+ async getTableHandle(tableName='',{create=true}={}){
563
564
  if(!this.ready){
564
565
  await this.readyPromise;
565
566
  }
@@ -582,6 +583,14 @@ class DBOPFS {
582
583
  return existingHandle;
583
584
  }
584
585
 
586
+ if(!create){
587
+ // A read-existing lookup must not wait for another caller to create the table.
588
+ return this.#db.getDirectoryHandle(
589
+ directoryName,
590
+ {create:false}
591
+ );
592
+ }
593
+
585
594
  if(!this.#tableHandlePromises[registeredTableName]){
586
595
  const handlePromise=this.#db.getDirectoryHandle(
587
596
  directoryName,
@@ -736,10 +745,14 @@ class DBOPFS {
736
745
  *
737
746
  * @param {string} tableName
738
747
  * @param {string} fileName
748
+ * @param {{createTable?:boolean}} options A false createTable preserves an absent table.
739
749
  * @returns {Promise<File>}
740
750
  */
741
- async readFile(tableName='',fileName=''){
742
- const table=await this.getTableHandle(tableName)
751
+ async readFile(tableName='',fileName='',{createTable=true}={}){
752
+ const table=await this.getTableHandle(
753
+ tableName,
754
+ {create:createTable}
755
+ )
743
756
  const handle=await table.getFileHandle(fileName,{create:false})
744
757
 
745
758
  if(is.function(handle.getFile)){
@@ -818,14 +831,19 @@ class DBOPFS {
818
831
  * @param {string} tableName
819
832
  * @param {string} fileName
820
833
  * @param {boolean} force
834
+ * @param {{createTable?:boolean}} options A false createTable preserves an absent table.
821
835
  * @returns {Promise<*>}
822
836
  */
823
- async get(tableName='',fileName='',force=false){
837
+ async get(tableName='',fileName='',force=false,{createTable=true}={}){
824
838
  if(force||!this.#tables[tableName]?.[fileName]){
825
839
  const cacheState=this.#fileCacheState(tableName,fileName);
826
840
  const cacheRevision=cacheState.revision;
827
841
  try{
828
- const file=await this.readFile(tableName,fileName)
842
+ const file=await this.readFile(
843
+ tableName,
844
+ fileName,
845
+ {createTable}
846
+ )
829
847
  const textContent=await file.text()
830
848
 
831
849
  if(!this.#tables[tableName]){
@@ -111,8 +111,10 @@ function serializeStorage(storage,execute){
111
111
  return operation;
112
112
  }
113
113
 
114
- async function readManifest(storage){
115
- const stored=await storage.db.get(storage.table,storage.fileName,true);
114
+ async function readManifest(storage,cacheOnly=false){
115
+ const stored=cacheOnly
116
+ ?await storage.db.get(storage.table,storage.fileName,true,{createTable:false})
117
+ :await storage.db.get(storage.table,storage.fileName,true);
116
118
  if(stored===null||stored===undefined)return {version:1,entries:[]};
117
119
  const manifest=copyJSON(stored);
118
120
  if(manifest.version!==1||!is.array(manifest.entries)){
@@ -349,9 +351,10 @@ function finishPreparation(job,error){
349
351
  job.finished.resolve();
350
352
  }
351
353
 
352
- function createJob(ownerState,input,storage,synthesize,previous){
353
- const job={
354
+ function createJobState(input,storage,synthesize=null,previous=null){
355
+ return {
354
356
  input,storage,synthesize,previous,
357
+ cacheOnly:false,
355
358
  controller:new AbortController(),
356
359
  interests:new Set(),
357
360
  state:'queued',
@@ -367,6 +370,10 @@ function createJob(ownerState,input,storage,synthesize,previous){
367
370
  return {...part,index,state:'queued',audioFile:null,contentType:null,error:null};
368
371
  })
369
372
  };
373
+ }
374
+
375
+ function createJob(ownerState,input,storage,synthesize,previous){
376
+ const job=createJobState(input,storage,synthesize,previous);
370
377
  const admission=ownerState.admission.then(function admitOrderedPreparation(){
371
378
  return admitPreparation(job);
372
379
  });
@@ -416,7 +423,9 @@ function createHandle(job,signal,onState){
416
423
  if(!interest.active)throw abortError();
417
424
  if(!job.storage)return job.slots[index].blob;
418
425
  const audio=job.record.segments[index];
419
- const file=await job.storage.db.readFile(job.storage.table,audio.audioFile);
426
+ const file=job.cacheOnly
427
+ ?await job.storage.db.readFile(job.storage.table,audio.audioFile,{createTable:false})
428
+ :await job.storage.db.readFile(job.storage.table,audio.audioFile);
420
429
  if(!interest.active)throw abortError();
421
430
  return file.type===audio.contentType?file:new Blob([file],{type:audio.contentType});
422
431
  },
@@ -482,4 +491,100 @@ function prepareSpeech({
482
491
  return createHandle(job,signal,onState);
483
492
  }
484
493
 
485
- export {prepareSpeech};
494
+ /** Read complete existing audio without synthesis, storage writes, or repair. */
495
+ async function readPreparedSpeech({
496
+ owner,
497
+ parts,
498
+ originalParts=parts,
499
+ selection=null,
500
+ segmentation=null,
501
+ storage=null,
502
+ identity=null,
503
+ signal=null,
504
+ onState=null
505
+ }={}){
506
+ if((!owner||!is.object(owner))&&!is.function(owner)){
507
+ throw new TypeError('Speech preparation requires its owning AI instance.');
508
+ }
509
+ if(!is.array(parts)||(onState!==null&&!is.function(onState))){
510
+ throw new TypeError('Cached speech requires ordered parts and an optional state callback.');
511
+ }
512
+ const input=copyJSON({parts,originalParts,selection,segmentation,identity});
513
+ const destination=normalizeStorage(storage);
514
+ if(signal?.aborted)throw abortError();
515
+ function completeCachedRead(job){
516
+ // The lookup signal owns only this read. Retained audio keeps its own
517
+ // explicit cancellation lifetime after a successful lookup returns.
518
+ const handle=createHandle(job,null,onState);
519
+ if(signal?.aborted){
520
+ handle.cancel();
521
+ throw abortError();
522
+ }
523
+ return handle;
524
+ }
525
+ if(!destination){
526
+ const jobs=owners.get(owner)?.jobs;
527
+ if(!jobs)return null;
528
+ for(const job of jobs){
529
+ if(job.storage||!job.done||job.failed||job.controller.signal.aborted
530
+ ||!sameInput(job.input,input))continue;
531
+ if(!job.slots.every(function hasRetainedAudio(slot){
532
+ return slot.settled&&slot.blob instanceof Blob;
533
+ }))return null;
534
+ return completeCachedRead(job);
535
+ }
536
+ return null;
537
+ }
538
+ const cancelled=deferred();
539
+ function cancelRead(){cancelled.reject(abortError());}
540
+ signal?.addEventListener('abort',cancelRead,{once:true});
541
+ try{
542
+ const reading=serializeStorage(destination,async function readCachedPreparation(){
543
+ if(signal?.aborted)throw abortError();
544
+ const manifest=await readManifest(destination,true);
545
+ if(signal?.aborted)throw abortError();
546
+ const record=manifest.entries.find(function findCachedPreparation(entry){
547
+ return sameInput(entry,input);
548
+ });
549
+ if(!record)return null;
550
+ if(!is.array(record.segments)||record.segments.length!==input.parts.length){
551
+ throw new TypeError('Stored speech preparation segments are unreadable.');
552
+ }
553
+ let complete=true;
554
+ for(const audio of record.segments){
555
+ if(!audio?.audioFile){
556
+ complete=false;
557
+ continue;
558
+ }
559
+ if(!is.string(audio.contentType)){
560
+ throw new TypeError('Stored speech audio content type is unreadable.');
561
+ }
562
+ if(signal?.aborted)throw abortError();
563
+ try{
564
+ const file=await destination.db.readFile(destination.table,audio.audioFile,{createTable:false});
565
+ if(!(file instanceof Blob)){
566
+ throw new TypeError('Stored speech audio must be a Blob.');
567
+ }
568
+ }catch(error){
569
+ if(error?.name!=='NotFoundError')throw error;
570
+ complete=false;
571
+ }
572
+ if(signal?.aborted)throw abortError();
573
+ }
574
+ return complete?record:null;
575
+ });
576
+ const record=await Promise.race([reading,cancelled.promise]);
577
+ if(signal?.aborted)throw abortError();
578
+ if(!record)return null;
579
+ const job=createJobState(input,destination);
580
+ job.cacheOnly=true;
581
+ job.record=record;
582
+ for(let index=0;index<job.slots.length;index+=1)settleSegment(job,index);
583
+ finishPreparation(job);
584
+ return completeCachedRead(job);
585
+ }finally{
586
+ signal?.removeEventListener('abort',cancelRead);
587
+ }
588
+ }
589
+
590
+ export {prepareSpeech,readPreparedSpeech};
@@ -4,6 +4,10 @@ import {createArcaneEventSource} from '../../event-manager.mjs';
4
4
  import {CoreError} from '../../../browser-runtime/core/contracts.mjs';
5
5
  import {encodeTensorMap} from '../../../browser-runtime/ai/onnx-tensors.mjs';
6
6
 
7
+ function sameExecutionTarget(left, right) {
8
+ return left === right || (left != null && right != null && left.deviceId !== undefined && left.deviceId === right.deviceId);
9
+ }
10
+
7
11
  /** App-owned selection; shared typed-decision execution, without a renderer. */
8
12
  export function createNativeDecisionService(configuration = {}, {appRoot = process.cwd()} = {}) {
9
13
  const events = createArcaneEventSource({}, {source: 'core-decisions', eventTypes: ['decisions.state']});
@@ -15,14 +19,22 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
15
19
  let releasing;
16
20
  let closing;
17
21
  let unsubscribe;
22
+ let selectedTarget = configuration.executionTarget;
23
+ let loadingTarget;
24
+ let loadedTarget;
18
25
 
19
26
  function current() {
20
- return model?.current() ?? {
27
+ const snapshot = model?.current() ?? {
21
28
  family: 'laya', model: configuration.model ?? 'onnx-community/laya-typed-decisions-ONNX',
22
29
  revision: configuration.revision ?? 'main', dtype: configuration.dtype ?? 'fp32',
23
30
  state: closing ? 'disposed' : 'unloaded', loaded: false, busy: Boolean(loading),
24
31
  activeRequests: 0, progress: null, error: null, execution: null
25
32
  };
33
+ const pending = Boolean(loading) && (!snapshot.loaded || !sameExecutionTarget(loadedTarget, loadingTarget));
34
+ return {
35
+ ...snapshot, busy: snapshot.busy || pending,
36
+ pendingActivation: pending ? {executionTarget: loadingTarget} : snapshot.pendingActivation ?? null
37
+ };
26
38
  }
27
39
 
28
40
  function publish(value = current()) {
@@ -39,20 +51,24 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
39
51
 
40
52
  function operationSignal(signal) { return signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal; }
41
53
 
42
- function load({signal} = {}) {
54
+ function load({signal, executionTarget: requestedTarget} = {}) {
43
55
  operationSignal(signal).throwIfAborted();
44
56
  if (!context) throw new CoreError({code: 'CORE_NOT_READY', message: 'Register and start the decision service with Core before loading.'});
45
- if (loading) return observeLoad(loading, loadController, signal);
46
- if (model?.current().loaded) return Promise.resolve(model.current());
47
- loadController = new AbortController();
48
- const controller = loadController;
57
+ const target = requestedTarget === undefined ? selectedTarget : requestedTarget;
58
+ if (loading && !loadController.signal.aborted && sameExecutionTarget(loadingTarget, target)) return observeLoad(loading, loadController, signal);
59
+ if (!loading && model?.current().loaded && sameExecutionTarget(loadedTarget, target)) return Promise.resolve(current());
60
+ const previousLoad = loading;
61
+ const previousController = loadController;
62
+ const controller = new AbortController();
49
63
  const selectedSignal = AbortSignal.any([lifetime.signal, controller.signal]);
50
64
  const precedingRelease = releasing;
51
- loading = Promise.resolve().then(async function loadSelectedModel() {
65
+ const task = Promise.resolve().then(async function loadSelectedModel() {
52
66
  selectedSignal.throwIfAborted();
53
67
  if (precedingRelease) await precedingRelease;
68
+ if (previousLoad) await Promise.allSettled([previousLoad]);
54
69
  selectedSignal.throwIfAborted();
55
70
  if (model) await model.dispose();
71
+ selectedSignal.throwIfAborted();
56
72
  unsubscribe?.();
57
73
  // Only the explicit load waits for its native dependencies. Merely
58
74
  // registering this service does not download a model or delay UI.
@@ -65,23 +81,43 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
65
81
  Object.entries(configuration.paths).map(function nativePath([name, value]) { return [name, path.resolve(appRoot, value)]; })
66
82
  );
67
83
  model = createNativeDecisionModel({
68
- ...configuration, paths, onnx: localAI.getONNXRuntime(), modelAssets,
84
+ ...configuration, paths, onnx: localAI.getONNXRuntime(), modelAssets, executionTarget: target,
69
85
  signal: lifetime.signal
70
86
  });
71
- unsubscribe = model.subscribe(publish);
72
- return model.load({signal: selectedSignal});
87
+ unsubscribe = model.subscribe(function modelStateChanged() { publish(); });
88
+ const result = await model.load({signal: selectedSignal});
89
+ selectedSignal.throwIfAborted();
90
+ loadedTarget = target;
91
+ return result;
73
92
  });
74
- function settled() { loading = undefined; loadController = undefined; publish(); }
75
- loading.then(settled, settled);
93
+ function settled() {
94
+ if (loading !== task) return;
95
+ loading = undefined;
96
+ loadController = undefined;
97
+ publish();
98
+ }
99
+ task.then(settled, settled).catch(function reportStateFailure(error) { console.error('Native decision state publication failed.', error); });
100
+ loading = task;
101
+ loadController = controller;
102
+ loadingTarget = target;
103
+ selectedTarget = target;
104
+ previousController?.abort(new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision activation request was replaced.'}));
76
105
  publish();
77
- return observeLoad(loading, controller, signal);
106
+ return observeLoad(task, controller, signal);
78
107
  }
79
108
 
80
109
  function observeLoad(task, controller, signal) {
81
110
  function abort() { controller.abort(signal.reason); }
82
111
  signal?.addEventListener('abort', abort, {once: true});
83
112
  if (signal?.aborted) abort();
84
- return task.finally(function detachLoadCaller() { signal?.removeEventListener('abort', abort); });
113
+ return task.then(function loadedActivation(result) {
114
+ controller.signal.throwIfAborted();
115
+ const snapshot = current();
116
+ if ((loading && loadController !== controller) || !snapshot.loaded || snapshot.execution !== result.execution) {
117
+ throw new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision activation was replaced.'});
118
+ }
119
+ return snapshot;
120
+ }).finally(function detachLoadCaller() { signal?.removeEventListener('abort', abort); });
85
121
  }
86
122
 
87
123
  async function evaluate(rows, {signal, runOptions} = {}) {
@@ -129,7 +165,7 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
129
165
  start(serviceContext) { context = serviceContext; },
130
166
  methods: {
131
167
  'decisions.status': current,
132
- 'decisions.load': function loadRequest(parameters, request) { return load({signal: request.signal}); },
168
+ 'decisions.load': function loadRequest(parameters, request) { return load({executionTarget: parameters?.executionTarget, signal: request.signal}); },
133
169
  'decisions.evaluate': async function evaluateRequest({rows, runOptions}, request) {
134
170
  const result = await evaluate(rows, {runOptions, signal: request.signal});
135
171
  // Typed arrays retain their complete values through Core's
@@ -0,0 +1,32 @@
1
+ import {createExecutionDeviceCatalog} from '../../local-ai/execution-devices.mjs';
2
+
3
+ /**
4
+ * Independent of model-service startup. Core gives this service ownership of
5
+ * the shared catalog's disposal; model engines borrowing it do not dispose it.
6
+ */
7
+ export function createExecutionDeviceService({catalog = createExecutionDeviceCatalog()} = {}) {
8
+ function devices({refresh = false} = {}, {signal} = {}) {
9
+ return catalog.devices(
10
+ {refresh, signal}
11
+ );
12
+ }
13
+
14
+ function resolveTarget({executionTarget = null, refresh = false} = {}, {signal} = {}) {
15
+ return catalog.resolveTarget(
16
+ {executionTarget, refresh, signal}
17
+ );
18
+ }
19
+
20
+ function dispose() {
21
+ return catalog.dispose();
22
+ }
23
+
24
+ return {
25
+ name: 'execution-devices',
26
+ methods: {
27
+ 'localai.devices': devices,
28
+ 'localai.resolveTarget': resolveTarget
29
+ },
30
+ dispose
31
+ };
32
+ }
@@ -121,7 +121,7 @@ function collectCompletion(completion, chunk) {
121
121
  }
122
122
 
123
123
  /** Core owns native processes; applications own runtime and model selection. */
124
- export function createLocalAIService(configuration, {appRoot, runtimes = [], signal, onEvent, prepare} = {}) {
124
+ export function createLocalAIService(configuration, {appRoot, runtimes = [], signal, onEvent, prepare, executionDevices} = {}) {
125
125
  const config = normalizeLocalAIConfig(configuration) ?? {runtimes: []};
126
126
  const lifetime = new AbortController();
127
127
  const lifetimeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
@@ -173,7 +173,9 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
173
173
 
174
174
  function startONNX() {
175
175
  if (!onnxRecord?.modulePath) return;
176
- onnx = createONNXRuntime({modulePath: onnxRecord.modulePath, signal: lifetimeSignal, onEvent});
176
+ onnx = createONNXRuntime(
177
+ {modulePath: onnxRecord.modulePath, signal: lifetimeSignal, onEvent, executionDevices}
178
+ );
177
179
  stopONNXSubscription = onnx.subscribe(function onnxChanged() { publish(); });
178
180
  }
179
181
 
@@ -630,9 +632,14 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
630
632
  return snapshot();
631
633
  },
632
634
  'onnx.status': function currentONNX() { return onnxStatus(); },
633
- 'onnx.load': function loadONNX({id, model, sessionOptions}, request) {
634
- return requireONNX().load({id, model: path.resolve(appRoot ?? process.cwd(), model), sessionOptions,
635
- signal: AbortSignal.any([lifetimeSignal, request.signal])});
635
+ 'onnx.load': function loadONNX({id, model, sessionOptions, executionPreference, executionTarget}, request) {
636
+ return requireONNX().load(
637
+ {
638
+ id, model: path.resolve(appRoot ?? process.cwd(), model), sessionOptions,
639
+ executionPreference, executionTarget,
640
+ signal: AbortSignal.any([lifetimeSignal, request.signal])
641
+ }
642
+ );
636
643
  },
637
644
  'onnx.run': async function runONNX({id, feeds, fetches, runOptions}, request) {
638
645
  const outputs = await requireONNX().run({id, feeds: decodeTensorMap(feeds),