arcane-os 0.88.1 → 0.89.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 (41) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/browser-runtime/ai/browser-decisions.mjs +31 -4
  3. package/browser-runtime/ai/core-local.mjs +105 -25
  4. package/browser-runtime/ai/decision-runtime.mjs +7 -0
  5. package/browser-runtime/ai/decision-worker.mjs +9 -1
  6. package/browser-runtime/core/client.mjs +6 -0
  7. package/browser-runtime/core/contracts.mjs +10 -0
  8. package/docs/reference/ai/browser-decisions.md +89 -7
  9. package/docs/reference/core-client.md +59 -0
  10. package/docs/reference/core-native-packaging.md +57 -10
  11. package/docs/reference/core-repositories.md +47 -3
  12. package/docs/reference/inventory/package-api.json +15 -15
  13. package/docs/reference/inventory/runtime-components.json +1 -1
  14. package/docs/reference/inventory/runtime-modules.json +4 -4
  15. package/docs/reference/local-ai.md +86 -3
  16. package/docs/reference/mcp-stdio.md +76 -1
  17. package/docs/reference/native-app-control.md +17 -5
  18. package/docs/reference/protocols.md +14 -0
  19. package/docs/reference/runtime-modules.md +9 -3
  20. package/docs/reference/sdk-api.md +135 -23
  21. package/package.json +2 -2
  22. package/runtime/arcane/modules/RiskSignalAnalyzer.js +1 -5
  23. package/schemas/arcane-app.schema.json +13 -3
  24. package/schemas/arcane-package.schema.json +10 -1
  25. package/src/app-descriptor.mjs +6 -2
  26. package/src/cli/app-control.mjs +1 -1
  27. package/src/core/hosts/windows/ArcaneHost.cs +273 -5
  28. package/src/core/hosts/windows/ArcaneLauncher.cs +9 -0
  29. package/src/core/hosts/windows/ArcaneWindowControl.cs +7 -7
  30. package/src/core/repositories.mjs +11 -1
  31. package/src/core/services/local-ai.mjs +183 -22
  32. package/src/installed-sdk-runtime.mjs +2 -0
  33. package/src/local-ai/config.mjs +16 -0
  34. package/src/local-ai/dev.mjs +2 -2
  35. package/src/local-ai/discover.mjs +3 -0
  36. package/src/local-ai/install.mjs +130 -20
  37. package/src/local-ai/llama-execution-windows.ps1 +158 -0
  38. package/src/local-ai/llama-execution.mjs +169 -0
  39. package/src/local-ai/server.mjs +38 -8
  40. package/src/native/portable-layout.mjs +13 -3
  41. package/src/workspace-runtime.mjs +97 -1
@@ -130,6 +130,7 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
130
130
  const owner = {};
131
131
  const events = createArcaneEventSource(owner, {source: 'core-local-ai', eventTypes: ['localai.state']});
132
132
  let context;
133
+ let modelAssetOwner;
133
134
  let closed = false;
134
135
  let onnx;
135
136
  let onnxRecovery;
@@ -334,17 +335,31 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
334
335
 
335
336
  function restartEngine(engine) {
336
337
  if (engine.recovering) return engine.recovering;
338
+ const selecting = engine.selecting;
337
339
  const recovering = Promise.resolve().then(async function recoverSelectedEngine() {
338
340
  lifetimeSignal.throwIfAborted();
341
+ if (selecting) await selecting;
342
+ const previous = engine.launch ?? engine.selection;
343
+ // Keep a second use across replacement, before the old process
344
+ // releases its own use on exit.
345
+ const projection = previous?.assetProjectionId
346
+ ? modelAssetOwner.retain(previous.assetProjectionId) : undefined;
347
+ const replacement = previous ? {...previous, projection} : undefined;
339
348
  engine.controller.abort();
340
- await engine.server.close();
341
- await Promise.allSettled([...engine.loads.values()]);
342
- lifetimeSignal.throwIfAborted();
343
- engine.models = [];
344
- engine.modelChanges.clear();
345
- engine.revision = 0;
346
- engine.released = false;
347
- startEngine(engine);
349
+ try {
350
+ await engine.server.close();
351
+ await Promise.allSettled([...engine.loads.values()]);
352
+ lifetimeSignal.throwIfAborted();
353
+ engine.models = [];
354
+ engine.modelChanges.clear();
355
+ engine.revision = 0;
356
+ engine.released = false;
357
+ startEngine(engine, replacement);
358
+ } catch (error) {
359
+ try { await projection?.release(); }
360
+ catch (cleanupError) { throw new AggregateError([error, cleanupError], 'llama.cpp recovery and model release failed.', {cause: error}); }
361
+ throw error;
362
+ }
348
363
  });
349
364
  engine.recovering = recovering;
350
365
  function releaseRecovery() {
@@ -360,6 +375,7 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
360
375
  if (!engine) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', `${id} is not selected in this application's localAI configuration.`);
361
376
  if (engine.recovering) await awaitReadiness(engine.recovering, signal);
362
377
  if (engine.stopping) await awaitReadiness(engine.stopping, signal);
378
+ if (engine.selecting) await awaitReadiness(engine.selecting, signal);
363
379
  // An external server exposes no readiness subscription during initial
364
380
  // loading. A new explicit load makes one fresh attempt, without polling.
365
381
  if (retryLoading && engine.state.state === 'loading') {
@@ -431,7 +447,115 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
431
447
  return {...engine.state, models: [...engine.models]};
432
448
  }
433
449
 
434
- async function loadLlama({model}, request) {
450
+ async function activateLlamaSelection(engine, parameters, request) {
451
+ const {model, assetProjectionId, resourcePaths} = parameters;
452
+ const signal = AbortSignal.any([lifetimeSignal, request.signal]);
453
+ signal.throwIfAborted();
454
+ const previous = engine.launch ?? engine.selection;
455
+ const retained = previous?.model === model ? previous : undefined;
456
+ const projectionId = assetProjectionId ?? retained?.assetProjectionId;
457
+ const memberPath = resourcePaths?.model ?? (projectionId === retained?.assetProjectionId ? retained?.memberPath : undefined);
458
+ const executionTarget = parameters.executionTarget !== undefined
459
+ ? parameters.executionTarget : retained ? retained.executionTarget : config.llamaCpp?.executionTarget;
460
+ if (executionTarget !== undefined && executionTarget !== null
461
+ && (!is.string(executionTarget.deviceId) || !executionTarget.deviceId)) {
462
+ throw new TypeError('A llama.cpp execution target needs its host deviceId, or null for the configured default.');
463
+ }
464
+ if (projectionId !== undefined && (!is.string(memberPath) || !memberPath)) {
465
+ throw failure('LOCAL_AI_MODEL_RESOURCE_REQUIRED', 'Projected llama.cpp loading needs resourcePaths.model.');
466
+ }
467
+ if (resourcePaths !== undefined && projectionId === undefined) {
468
+ throw failure('LOCAL_AI_MODEL_PROJECTION_REQUIRED', 'resourcePaths needs its retained assetProjectionId.');
469
+ }
470
+ if (retained && retained.assetProjectionId === projectionId && retained.memberPath === memberPath
471
+ && (retained.executionTarget?.deviceId ?? null) === (executionTarget?.deviceId ?? null)
472
+ && engine.state.available) {
473
+ return prepareLlamaModel(engine, model, signal);
474
+ }
475
+ if (!await awaitReadiness(engine.server.management, signal)) {
476
+ if (engine.server.external) {
477
+ throw failure('LOCAL_AI_EXTERNAL_SELECTION_UNSUPPORTED', 'The existing llama.cpp server owns its model and device selection. Use an available listener for SDK-managed model loading.');
478
+ }
479
+ await engine.ready;
480
+ throw failure('LOCAL_AI_RUNTIME_UNAVAILABLE', 'No managed llama.cpp executable is available.');
481
+ }
482
+ let projection;
483
+ let launch;
484
+ function cancelSelectedLoad() {
485
+ if (engine.launch === launch) engine.controller.abort(signal.reason);
486
+ }
487
+ try {
488
+ const configuration = {...config.llamaCpp, executionTarget};
489
+ if (projectionId !== undefined) {
490
+ const assets = modelAssetOwner ?? await context.getService('model-assets');
491
+ if (!assets) throw failure('LOCAL_AI_MODEL_ASSETS_UNAVAILABLE', 'The Core model-assets service is unavailable.');
492
+ signal.throwIfAborted();
493
+ modelAssetOwner = assets;
494
+ projection = assets.retain(projectionId);
495
+ const member = projection.members.find(function selectedMember(value) { return value.path === memberPath; });
496
+ if (!member) throw failure('LOCAL_AI_MODEL_RESOURCE_UNAVAILABLE', `The projection does not contain ${memberPath}.`);
497
+ configuration.model = member.nativePath;
498
+ configuration.alias = model;
499
+ }
500
+ launch = {model, assetProjectionId: projectionId, memberPath, executionTarget, configuration, projection};
501
+ // One server owns this listener. Only its replacement is serialized;
502
+ // ordinary router model loads keep their independent existing flow.
503
+ signal.throwIfAborted();
504
+ engine.controller.abort();
505
+ await engine.server.close();
506
+ await Promise.allSettled([...engine.loads.values()]);
507
+ signal.throwIfAborted();
508
+ engine.models = [];
509
+ engine.modelChanges.clear();
510
+ engine.revision = 0;
511
+ engine.released = false;
512
+ startEngine(engine, launch);
513
+ signal.addEventListener('abort', cancelSelectedLoad, {once: true});
514
+ if (signal.aborted) cancelSelectedLoad();
515
+ await engine.ready;
516
+ signal.throwIfAborted();
517
+ const result = await prepareLlamaModel(engine, model, signal);
518
+ signal.throwIfAborted();
519
+ return result;
520
+ } catch (error) {
521
+ try {
522
+ if (launch && engine.launch === launch) {
523
+ engine.controller.abort(error);
524
+ await engine.server.close();
525
+ }
526
+ await projection?.release();
527
+ } catch (cleanupError) {
528
+ throw new AggregateError([error, cleanupError], 'llama.cpp loading and model release failed.', {cause: error});
529
+ }
530
+ throw error;
531
+ } finally {
532
+ signal.removeEventListener('abort', cancelSelectedLoad);
533
+ }
534
+ }
535
+
536
+ function selectLlamaModel(parameters, request) {
537
+ const engine = engines.get('llama.cpp');
538
+ if (!engine) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', "llama.cpp is not selected in this application's localAI configuration.");
539
+ const preceding = engine.selecting;
540
+ const recovering = engine.recovering;
541
+ const stopping = engine.stopping;
542
+ const task = (async function replaceSelectedLlamaModel() {
543
+ if (preceding) await Promise.allSettled([preceding]);
544
+ if (recovering) await recovering;
545
+ if (stopping) await stopping;
546
+ return activateLlamaSelection(engine, parameters, request);
547
+ })();
548
+ engine.selecting = task;
549
+ function releaseSelection() { if (engine.selecting === task) engine.selecting = null; }
550
+ task.then(releaseSelection, releaseSelection);
551
+ return task;
552
+ }
553
+
554
+ async function loadLlama(parameters, request) {
555
+ const {model} = parameters;
556
+ if (parameters.assetProjectionId !== undefined || parameters.resourcePaths !== undefined || parameters.executionTarget !== undefined) {
557
+ return selectLlamaModel(parameters, request);
558
+ }
435
559
  const engine = await engineReady('llama.cpp', request.signal, {retryLoading: true});
436
560
  let task = engine.loads.get(model);
437
561
  if (!task) {
@@ -451,6 +575,17 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
451
575
  return awaitReadiness(task, request.signal);
452
576
  }
453
577
 
578
+ async function releaseLlamaServer(engine) {
579
+ engine.controller.abort();
580
+ engine.stopping = engine.server.close();
581
+ await engine.stopping;
582
+ engine.stopping = null;
583
+ engine.released = true;
584
+ engine.models = engine.models.map(function unloadedModel(value) { return {...value, loaded: false}; });
585
+ publish();
586
+ return {...engine.state, models: [...engine.models], released: true, unloaded: true};
587
+ }
588
+
454
589
  async function chatLlama({model, payload, stream = false, streamId}, request) {
455
590
  model = payload?.model ?? model;
456
591
  const engine = await engineReady('llama.cpp', request.signal, {retryLoading: true});
@@ -556,17 +691,38 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
556
691
  return result;
557
692
  }
558
693
 
559
- function startEngine(engine) {
694
+ function startEngine(engine, launch) {
695
+ if (!launch && engine.selection) {
696
+ // A stopped selected projection may have been released by its
697
+ // preparation owner. Retaining it either succeeds or reports that
698
+ // it must be prepared again; never start a different default model.
699
+ const projection = engine.selection.assetProjectionId
700
+ ? modelAssetOwner.retain(engine.selection.assetProjectionId) : undefined;
701
+ launch = {...engine.selection, projection};
702
+ }
703
+ if (launch) {
704
+ const {projection, ...selection} = launch;
705
+ engine.selection = selection;
706
+ }
560
707
  engine.controller = new AbortController();
561
708
  engine.signal = AbortSignal.any([lifetimeSignal, engine.controller.signal]);
562
709
  engine.ollamaRevision = 0;
563
710
  engine.ollamaAppliedRevision = 0;
564
711
  const signal = engine.signal;
712
+ engine.launch = launch;
565
713
  engine.server = createLocalAIServer({
566
714
  id: engine.id, appRoot, runtime: runtimes.find(function runtimeRecord(record) { return record.id === engine.id; }),
567
- configuration: engine.id === 'llama.cpp' ? config.llamaCpp : config.ollama,
568
- signal, onEvent,
569
- onState(state) { engine.state = {...engine.state, ...state, error: state.error ?? null}; publish(); }
715
+ configuration: engine.id === 'llama.cpp' ? launch?.configuration ?? config.llamaCpp : config.ollama,
716
+ signal, onEvent, executionDevices, requireManaged: Boolean(launch),
717
+ onState(state) { engine.state = {...engine.state, ...state, error: state.error ?? null}; publish(); },
718
+ async onExit() {
719
+ await launch?.projection?.release();
720
+ if (launch && engine.launch === launch) {
721
+ engine.launch = undefined;
722
+ engine.models = engine.models.map(function releasedModel(model) { return {...model, loaded: false}; });
723
+ if (!closed) publish();
724
+ }
725
+ }
570
726
  });
571
727
  engine.ready = engine.server.ready.then(async function prepareModelCatalog() {
572
728
  await refreshModels(engine, signal);
@@ -671,20 +827,25 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
671
827
  },
672
828
  'llama.load': loadLlama,
673
829
  'llama.unload': async function unloadLlama({model}, request) {
830
+ const selected = engines.get('llama.cpp');
831
+ if (selected?.launch?.model === model && selected.selecting) {
832
+ selected.controller.abort(failure('LOCAL_AI_MODEL_UNLOADED', `The application unloaded ${model}.`));
833
+ }
834
+ if (selected?.selecting) await Promise.allSettled([selected.selecting]);
835
+ request.signal.throwIfAborted();
836
+ // A failed or cancelled projected startup may already have
837
+ // stopped. Release joins its cleanup rather than replaying the
838
+ // rejected readiness promise as a new unload failure.
839
+ if (selected?.selection?.model === model && !selected.state.available && !selected.server.external) {
840
+ return releaseLlamaServer(selected);
841
+ }
674
842
  const engine = await engineReady('llama.cpp', request.signal, {resumeReleased: false});
675
843
  if (engine.released) return {...engine.state, models: [...engine.models], released: true, unloaded: true};
676
844
  engine.loadControllers.get(model)?.abort(failure('LOCAL_AI_MODEL_UNLOADED', `The application unloaded ${model}.`));
677
845
  if (engine.loads.has(model)) await Promise.allSettled([engine.loads.get(model)]);
678
846
  if (!engine.router) {
679
847
  if (!engine.server.owned) return {...engine.state, models: [...engine.models], released: true, unloaded: false};
680
- engine.controller.abort();
681
- engine.stopping = engine.server.close();
682
- await engine.stopping;
683
- engine.stopping = null;
684
- engine.released = true;
685
- engine.models = engine.models.map(function unloadedModel(value) { return {...value, loaded: false}; });
686
- publish();
687
- return {...engine.state, models: [...engine.models], released: true, unloaded: true};
848
+ return releaseLlamaServer(engine);
688
849
  }
689
850
  await requestLocalJSON({url: engine.server.url, path: '/models/unload', method: 'POST', payload: {model}, signal: request.signal});
690
851
  return refreshModels(engine, request.signal);
@@ -712,7 +873,7 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
712
873
  ]);
713
874
  stopONNXSubscription?.();
714
875
  await Promise.allSettled([...jobs, ...(onnxRecovery ? [onnxRecovery] : []), ...[...engines.values()].flatMap(function pendingWork(engine) {
715
- return [...engine.loads.values(), ...(engine.recovering ? [engine.recovering] : [])];
876
+ return [...engine.loads.values(), ...(engine.recovering ? [engine.recovering] : []), ...(engine.selecting ? [engine.selecting] : [])];
716
877
  })]);
717
878
  events.dispose();
718
879
  const failures = results.filter(function failed(result) { return result.status === 'rejected'; }).map(function cause(result) { return result.reason; });
@@ -74,6 +74,7 @@ async function writeWorkspaceLock(lockPath,lock){
74
74
  export async function materializeInstalledSdkRuntime({
75
75
  workspaceRoot,
76
76
  sdkPackageSource,
77
+ browserDecisions=false,
77
78
  workspaceOperationLease,
78
79
  signal,
79
80
  onEvent
@@ -93,6 +94,7 @@ export async function materializeInstalledSdkRuntime({
93
94
  runtimeRoot:authority.installation.runtimeRoot,
94
95
  browserRuntimeRoot:authority.installation.browserRuntimeRoot,
95
96
  sdkVersion:authority.installation.packageVersion,
97
+ browserDecisions,
96
98
  signal,
97
99
  onEvent
98
100
  });
@@ -10,6 +10,21 @@ import {normalizeWhisperRuntimeRequirement} from './whisper/config.mjs';
10
10
  const is = new Is(false);
11
11
  export const LOCAL_AI_RUNTIME_IDS = ['llama.cpp', 'ollama', 'onnx', 'stable-diffusion.cpp', 'whisper.cpp'];
12
12
 
13
+ /** Select a llama distribution without discovering hardware or acquiring it. */
14
+ export function normalizeLlamaRuntimeRequirement(record) {
15
+ const backend = record.backend ?? 'auto';
16
+ if (!['auto', 'cpu', 'cuda'].includes(backend)) {
17
+ throw new ArcaneError(ERROR_CODES.usage, `Unknown llama.cpp backend: ${String(backend)}.`);
18
+ }
19
+ if (record.companionUrl !== undefined && (!is.string(record.companionUrl) || !record.companionUrl)) {
20
+ throw new ArcaneError(ERROR_CODES.usage, 'llama.cpp companionUrl must be a nonempty string.');
21
+ }
22
+ if (record.url && backend === 'cuda' && !record.companionUrl) {
23
+ throw new ArcaneError(ERROR_CODES.usage, 'A custom llama.cpp CUDA archive requires an explicit companionUrl for its CUDA runtime libraries.');
24
+ }
25
+ return {...record, backend};
26
+ }
27
+
13
28
  /** Image requirements describe assets without acquiring or loading a model. */
14
29
  export function normalizeImageRuntimeRequirement(record) {
15
30
  const backend = record.backend ?? 'auto';
@@ -61,6 +76,7 @@ export function normalizeLocalAIConfig(value) {
61
76
  if (!record || !LOCAL_AI_RUNTIME_IDS.includes(record.id)) {
62
77
  throw new ArcaneError(ERROR_CODES.usage, `Unknown local AI runtime: ${String(record?.id ?? item)}.`);
63
78
  }
79
+ if (record.id === 'llama.cpp') return normalizeLlamaRuntimeRequirement(record);
64
80
  if (record.id === 'stable-diffusion.cpp') return normalizeImageRuntimeRequirement(record);
65
81
  if (record.id === 'whisper.cpp') return normalizeWhisperRuntimeRequirement(record);
66
82
  return {...record};
@@ -47,8 +47,8 @@ export function createDevelopmentLocalAI({
47
47
  const imageSelected = selected.runtimes.some(function selectedImageRuntime(requirement) {
48
48
  return requirement.id === 'stable-diffusion.cpp';
49
49
  });
50
- if (imageSelected || selected.runtimes.some(function selectedONNXRuntime(requirement) {
51
- return requirement.id === 'onnx';
50
+ if (imageSelected || selected.runtimes.some(function selectedProjectedRuntime(requirement) {
51
+ return ['onnx', 'llama.cpp'].includes(requirement.id);
52
52
  })) {
53
53
  modelAssets = createModelAssetService({appRoot});
54
54
  definitions.push(modelAssets);
@@ -27,6 +27,9 @@ export async function discoverLocalAIRuntimes({config,appRoot,signal}={}){
27
27
  const outcomes=await Promise.allSettled(selected.map(async function discoverRuntime(requirement){
28
28
  throwIfAborted(signal);
29
29
  if(!['llama.cpp','ollama'].includes(requirement.id))return {missing:requirement};
30
+ // A PATH executable or HTTP listener cannot establish its compiled
31
+ // backend. An explicit distribution goes through its installer owner.
32
+ if(requirement.id==='llama.cpp'&&requirement.backend&&requirement.backend!=='auto')return {missing:requirement};
30
33
  const configuration=config[requirement.id==='llama.cpp'?'llamaCpp':'ollama']??{};
31
34
  const url=localAIServerURL(requirement.id,configuration);
32
35
  const concreteVersion=requirement.version&&requirement.version!=='latest';
@@ -5,16 +5,28 @@ import {createRequire} from 'node:module';
5
5
  import path from 'node:path';
6
6
  import {Readable} from 'node:stream';
7
7
  import {pipeline} from 'node:stream/promises';
8
+ import packageMetadata from '../../package.json' with {type:'json'};
8
9
  import {ArcaneError,ERROR_CODES,normalizeError,throwIfAborted} from '../errors.mjs';
9
10
  import {createEventQueue} from '../event-queue.mjs';
10
11
  import {runProcess} from '../process.mjs';
11
12
  import {extractLocalAIArchive} from './archive.mjs';
12
- import {normalizeImageRuntimeRequirement} from './config.mjs';
13
+ import {normalizeImageRuntimeRequirement,normalizeLlamaRuntimeRequirement} from './config.mjs';
13
14
  import {normalizeWhisperRuntimeRequirement} from './whisper/config.mjs';
14
15
  import {bundledWhisperRuntime, installedWhisperRuntime, installWhisperDistribution, whisperRuntimeSelection} from './whisper/install.mjs';
15
16
 
16
17
  const is=new Is(false);
17
18
  const installations=new Map();
19
+ const onnxWindowsDistribution={
20
+ version:'1.30.0',
21
+ revision:'1.30.0-directml-reshape-1',
22
+ asset:'arcane-onnx-runtime-windows-x64.tar.gz'
23
+ };
24
+
25
+ function selectedONNXNativeDistribution(runtime,platform,architecture){
26
+ if(runtime.id!=='onnx'||runtime.url||platform!=='win32'||architecture!=='x64')return null;
27
+ return (runtime.version??onnxWindowsDistribution.version)===onnxWindowsDistribution.version
28
+ ?onnxWindowsDistribution:null;
29
+ }
18
30
 
19
31
  function selectRuntimes(runtimes){
20
32
  if(!is.array(runtimes)){
@@ -24,7 +36,8 @@ function selectRuntimes(runtimes){
24
36
  for(const item of runtimes){
25
37
  const requirement=is.string(item)?{id:item}:item;
26
38
  const runtime=requirement?.id==='stable-diffusion.cpp'?normalizeImageRuntimeRequirement(requirement)
27
- :requirement?.id==='whisper.cpp'?normalizeWhisperRuntimeRequirement(requirement):requirement;
39
+ :requirement?.id==='whisper.cpp'?normalizeWhisperRuntimeRequirement(requirement)
40
+ :requirement?.id==='llama.cpp'?normalizeLlamaRuntimeRequirement(requirement):requirement;
28
41
  if(!runtime||!['llama.cpp','ollama','nemo-speech','onnx','stable-diffusion.cpp','whisper.cpp'].includes(runtime.id)){
29
42
  throw new ArcaneError(ERROR_CODES.targetUnavailable,`Local AI runtime installation is unavailable for ${String(runtime?.id)}.`);
30
43
  }
@@ -32,7 +45,9 @@ function selectRuntimes(runtimes){
32
45
  if(prior&&runtime.id==='whisper.cpp'&&whisperRuntimeSelection(prior)!==whisperRuntimeSelection(runtime)){
33
46
  throw new ArcaneError(ERROR_CODES.usage,'Select one complete whisper.cpp runtime and model requirement for this operation.');
34
47
  }
35
- if(prior&&(prior.version!==runtime.version||prior.url!==runtime.url||(runtime.id==='stable-diffusion.cpp'&&prior.backend!==runtime.backend))){
48
+ if(prior&&(prior.version!==runtime.version||prior.url!==runtime.url
49
+ ||(['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)&&prior.backend!==runtime.backend)
50
+ ||(runtime.id==='llama.cpp'&&prior.companionUrl!==runtime.companionUrl))){
36
51
  throw new ArcaneError(ERROR_CODES.usage,`Select one ${runtime.id} runtime version and URL for this operation.`);
37
52
  }
38
53
  if(runtime.version!==undefined&&(!is.string(runtime.version)||!runtime.version)){
@@ -53,7 +68,7 @@ function selectedDirectory(directory){
53
68
  return path.resolve(directory);
54
69
  }
55
70
 
56
- function releaseAssetNames(id,version,platform,architecture){
71
+ function releaseAssetNames(id,version,platform,architecture,backend){
57
72
  if(id==='nemo-speech'){
58
73
  if(!['x64','arm64'].includes(architecture))return [];
59
74
  const arch=architecture==='x64'?'x86_64':'aarch64';
@@ -71,6 +86,9 @@ function releaseAssetNames(id,version,platform,architecture){
71
86
  if(platform==='linux')return [`ollama-linux-${arch}.tar.zst`,`ollama-linux-${arch}.tgz`];
72
87
  return [];
73
88
  }
89
+ if(id==='llama.cpp'&&backend==='cuda'){
90
+ return platform==='win32'&&architecture==='x64'?[`llama-${version}-bin-win-cuda-13.4-x64.zip`]:[];
91
+ }
74
92
  if(!['x64','arm64'].includes(architecture))return [];
75
93
  if(platform==='win32')return [`llama-${version}-bin-win-cpu-${architecture}.zip`];
76
94
  if(platform==='linux')return [`llama-${version}-bin-ubuntu-${architecture}.tar.gz`,`llama-${version}-bin-ubuntu-${architecture}.zip`];
@@ -93,9 +111,17 @@ async function selectRelease(runtime,platform,architecture,signal){
93
111
  if (runtime.id === 'stable-diffusion.cpp') {
94
112
  return selectImageRelease(runtime, platform, architecture, signal);
95
113
  }
114
+ const backend=runtime.id==='llama.cpp'?llamaBackendCandidate(runtime,platform,architecture):undefined;
96
115
  if(runtime.url){
97
116
  const name=path.posix.basename(new URL(runtime.url).pathname);
98
- return {version:runtime.version??'custom',url:runtime.url,name};
117
+ return {
118
+ version:runtime.version??'custom',url:runtime.url,name,
119
+ ...(runtime.id==='llama.cpp'?{backend,requestedBackend:runtime.backend}:{}),
120
+ ...(backend==='cuda'?{companion:{
121
+ url:runtime.companionUrl,
122
+ name:path.posix.basename(new URL(runtime.companionUrl).pathname)
123
+ }}:{})
124
+ };
99
125
  }
100
126
  const repository=runtime.id==='nemo-speech'?'NVIDIA/NeMo-Speech.cpp':runtime.id==='llama.cpp'?'ggml-org/llama.cpp':'ollama/ollama';
101
127
  const version=runtime.version??(runtime.id==='nemo-speech'?'0.2.0':undefined);
@@ -112,12 +138,38 @@ async function selectRelease(runtime,platform,architecture,signal){
112
138
  }
113
139
  }
114
140
  // Select only published format variants for this exact target distribution.
115
- const names=releaseAssetNames(runtime.id,release.tag_name,platform,architecture);
141
+ const names=releaseAssetNames(runtime.id,release.tag_name,platform,architecture,backend);
116
142
  const asset=release.assets?.find(function isSelectedAsset(candidate){return names.includes(candidate.name);});
117
143
  if(!asset){
118
144
  throw new ArcaneError(ERROR_CODES.targetUnavailable,`${runtime.id} ${release.tag_name} has no published ${platform}/${architecture} runtime archive${names.length?` named ${names.join(' or ')}`:''}.`);
119
145
  }
120
- return {version:release.tag_name,url:asset.browser_download_url,name:asset.name};
146
+ let companion;
147
+ if(backend==='cuda'){
148
+ const companionName='cudart-llama-bin-win-cuda-13.4-x64.zip';
149
+ const companionAsset=release.assets?.find(function selectedCudaRuntime(candidate){return candidate.name===companionName;});
150
+ if(!companionAsset){
151
+ throw new ArcaneError(ERROR_CODES.targetUnavailable,`llama.cpp ${release.tag_name} has no matching CUDA runtime archive named ${companionName}.`);
152
+ }
153
+ companion={url:companionAsset.browser_download_url,name:companionAsset.name};
154
+ }
155
+ return {
156
+ version:release.tag_name,url:asset.browser_download_url,name:asset.name,
157
+ ...(runtime.id==='llama.cpp'?{backend,requestedBackend:runtime.backend}:{}),
158
+ ...(companion?{companion}:{})
159
+ };
160
+ }
161
+
162
+ function llamaBackendCandidate(runtime,platform,architecture){
163
+ if(runtime.backend==='cuda'){
164
+ if(platform!=='win32'||architecture!=='x64'){
165
+ throw new ArcaneError(ERROR_CODES.targetUnavailable,`The selected llama.cpp CUDA distribution is unavailable for ${platform}/${architecture}.`);
166
+ }
167
+ return 'cuda';
168
+ }
169
+ // A custom automatic archive declares no backend capability. Official macOS
170
+ // archives include Metal; requested CPU execution is configured at launch.
171
+ if(runtime.url)return runtime.backend;
172
+ return platform==='darwin'?'metal':'cpu';
121
173
  }
122
174
 
123
175
  async function imageBackendCandidate(backend, platform, signal) {
@@ -368,6 +420,7 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
368
420
  const imageBackend = runtime.id === 'stable-diffusion.cpp' && !runtime.url
369
421
  ? await imageBackendCandidate(runtime.backend, platform, signal)
370
422
  : runtime.backend;
423
+ const llamaBackend=runtime.id==='llama.cpp'?llamaBackendCandidate(runtime,platform,architecture):null;
371
424
  for(const entry of entries){
372
425
  throwIfAborted(signal);
373
426
  if(!entry.isDirectory())continue;
@@ -380,6 +433,8 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
380
433
  }
381
434
  if(installation.requestVersion!==(runtime.version??null)||installation.requestUrl!==(runtime.url??null))continue;
382
435
  if (runtime.id === 'stable-diffusion.cpp' && installation.requestBackend !== runtime.backend) continue;
436
+ if(runtime.id==='llama.cpp'&&((installation.requestBackend??'auto')!==runtime.backend
437
+ ||(installation.requestCompanionUrl??null)!==(runtime.companionUrl??null)))continue;
383
438
  let whisperRequirement;
384
439
  if (runtime.id === 'whisper.cpp') {
385
440
  if (!installation.requestWhisper) continue;
@@ -388,6 +443,7 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
388
443
  }
389
444
  const record=installation.runtime;
390
445
  if(record?.id!==runtime.id||record.platform!==platform||record.architecture!==architecture)continue;
446
+ if(runtime.id==='llama.cpp'&&record.backend!==undefined&&record.backend!==llamaBackend)continue;
391
447
  try{
392
448
  if(runtime.id==='whisper.cpp'){
393
449
  if(await installedWhisperRuntime(record,signal,!reuseWhisper))return reuseWhisper?{runtime:record,requirement:whisperRequirement}:record;
@@ -423,6 +479,8 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
423
479
  }
424
480
  if(runtime.id==='onnx'){
425
481
  if(!is.string(record.root)||!is.string(record.modulePath))continue;
482
+ const distribution=selectedONNXNativeDistribution(runtime,platform,architecture);
483
+ if(distribution&&record.nativeDistributionRevision!==distribution.revision)continue;
426
484
  const locations=await onnxRuntimeModule(record.root,platform,architecture,signal);
427
485
  return {...record,...locations,...(runtime.version?{requestedVersion:runtime.version}:{})};
428
486
  }
@@ -436,7 +494,11 @@ async function installedRuntime(base,runtime,platform,architecture,signal,reuseW
436
494
  }
437
495
  continue;
438
496
  }
439
- if((await stat(record.executable)).isFile())return {...record,...(runtime.version?{requestedVersion:runtime.version}:{})};
497
+ if((await stat(record.executable)).isFile())return {
498
+ ...record,
499
+ ...(runtime.id==='llama.cpp'?{backend:record.backend??llamaBackend,requestedBackend:runtime.backend}:{}),
500
+ ...(runtime.version?{requestedVersion:runtime.version}:{})
501
+ };
440
502
  }catch(error){
441
503
  if(runtime.id==='onnx'&&(error.code==='MODULE_NOT_FOUND'||error instanceof SyntaxError))continue;
442
504
  if(error.code!=='ENOENT')throw error;
@@ -519,7 +581,7 @@ async function onnxRuntimeModule(root,platform,architecture,signal){
519
581
  }
520
582
 
521
583
  async function installONNXPackage(runtime,{root,directory,platform,architecture,signal,onEvent}){
522
- const version=runtime.version??'1.30.0';
584
+ const version=runtime.version??onnxWindowsDistribution.version;
523
585
  await onEvent({type:'local-ai.install.installing',message:`Installing onnxruntime-node ${version} for ${platform}/${architecture}.`,data:{id:runtime.id,version,platform,architecture}});
524
586
  await mkdir(root,{recursive:true});
525
587
  await writeFile(path.join(root,'package.json'),'{"private":true}\n',{flag:'wx',signal});
@@ -528,14 +590,34 @@ async function installONNXPackage(runtime,{root,directory,platform,architecture,
528
590
  '--no-audit','--no-fund','--foreground-scripts','--registry=https://registry.npmjs.org/',
529
591
  '--cache',path.join(directory,'npm-cache'),`onnxruntime-node@${runtime.url??version}`
530
592
  ],{cwd:root,env:{ONNXRUNTIME_NODE_INSTALL:'skip'},signal,onEvent});
531
- return onnxRuntimeModule(root,platform,architecture,signal);
593
+ const distribution=selectedONNXNativeDistribution(runtime,platform,architecture);
594
+ if(distribution){
595
+ const archive=path.join(path.dirname(root),distribution.asset);
596
+ const nativeDirectory=path.join(root,'node_modules','onnxruntime-node','bin','napi-v6',platform,architecture);
597
+ const url=`https://github.com/TheWizardNexus/arcane-os-sdk/releases/download/${packageMetadata.version}/${distribution.asset}`;
598
+ await onEvent({type:'local-ai.install.downloading',message:`Downloading ${distribution.asset}.`,data:{id:runtime.id,asset:distribution.asset,nativeDistributionRevision:distribution.revision}});
599
+ const response=await upstreamResponse(url,signal);
600
+ await pipeline(Readable.fromWeb(response.body),createWriteStream(archive,{flags:'wx'}),{signal});
601
+ await onEvent({type:'local-ai.install.extracting',message:`Preparing ${distribution.asset}.`,data:{id:runtime.id,asset:distribution.asset,nativeDistributionRevision:distribution.revision}});
602
+ // This is the new attempt's package. Running installations retain their
603
+ // original trees; the same-version JavaScript and binding stay intact.
604
+ await extractLocalAIArchive({archive,directory:nativeDirectory,signal,onEvent});
605
+ await unlink(archive);
606
+ }
607
+ const locations=await onnxRuntimeModule(root,platform,architecture,signal);
608
+ return {...locations,...(distribution?{nativeDistributionRevision:distribution.revision}:{})};
532
609
  }
533
610
 
534
611
  async function installRuntime(runtime,{directory,platform,architecture,signal,onEvent}){
535
612
  const runtimeBase=path.join(directory,runtime.id,`${platform}-${architecture}`,encodeURIComponent(runtime.version??'default'));
536
- const base=runtime.id==='stable-diffusion.cpp'?path.join(runtimeBase,runtime.backend):runtimeBase;
613
+ const base=['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)?path.join(runtimeBase,runtime.backend):runtimeBase;
537
614
  await onEvent({type:'local-ai.install.starting',message:`Preparing ${runtime.id} for ${platform}/${architecture}.`,data:{id:runtime.id,platform,architecture}});
538
- const installed=await installedRuntime(base,runtime,platform,architecture,signal);
615
+ let installed=await installedRuntime(base,runtime,platform,architecture,signal);
616
+ // Previous default installations predate backend-specific directories. Keep
617
+ // those working trees reusable for the same automatic requirement.
618
+ if(!installed&&runtime.id==='llama.cpp'&&runtime.backend==='auto'){
619
+ installed=await installedRuntime(runtimeBase,runtime,platform,architecture,signal);
620
+ }
539
621
  if(installed){
540
622
  await onEvent({type:'local-ai.install.available',message:`${runtime.id} ${installed.version} is already installed.`,data:installed});
541
623
  return installed;
@@ -550,6 +632,7 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
550
632
  const attempt=await mkdtemp(path.join(base,'install-'));
551
633
  const root=path.join(attempt,'runtime');
552
634
  const archive=release&&runtime.id!=='stable-diffusion.cpp'?path.join(attempt,release.name):null;
635
+ const companionArchive=release?.companion?path.join(attempt,'companion',release.companion.name):null;
553
636
  let completed=false;
554
637
  try{
555
638
  let locations;
@@ -563,19 +646,46 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
563
646
  {root, directory, platform, architecture, signal, onEvent}
564
647
  );
565
648
  }else{
566
- await onEvent({type:'local-ai.install.downloading',message:`Downloading ${runtime.id} ${release.version}.`,data:{id:runtime.id,version:release.version,platform,architecture}});
567
- const response=await upstreamResponse(release.url,signal);
568
- await pipeline(Readable.fromWeb(response.body),createWriteStream(archive,{flags:'wx'}),{signal});
569
- await onEvent({type:'local-ai.install.extracting',message:`Extracting ${runtime.id} ${release.version}.`,data:{id:runtime.id,version:release.version}});
570
- await extractLocalAIArchive({archive,directory:root,signal,onEvent});
571
- locations={version:release.version,...(runtime.id==='nemo-speech'
649
+ const assets=[{url:release.url,name:release.name,archive}];
650
+ if(companionArchive){
651
+ await mkdir(path.dirname(companionArchive));
652
+ assets.push({...release.companion,archive:companionArchive});
653
+ }
654
+ const downloads=await Promise.allSettled(assets.map(async function downloadRuntimeArchive(asset){
655
+ await onEvent({
656
+ type:'local-ai.install.downloading',
657
+ message:runtime.id==='llama.cpp'?`Downloading ${runtime.id} ${release.version}: ${asset.name}.`:`Downloading ${runtime.id} ${release.version}.`,
658
+ data:{id:runtime.id,version:release.version,platform,architecture,...(runtime.id==='llama.cpp'?{asset:asset.name}:{})}
659
+ });
660
+ const response=await upstreamResponse(asset.url,signal);
661
+ await pipeline(Readable.fromWeb(response.body),createWriteStream(asset.archive,{flags:'wx'}),{signal});
662
+ }));
663
+ const failures=downloads.filter(function archiveDownloadFailed(outcome){return outcome.status==='rejected';});
664
+ if(failures.length===1)throw failures[0].reason;
665
+ if(failures.length>1){
666
+ throw new AggregateError(failures.map(function archiveDownloadFailure(outcome){return outcome.reason;}),`Download of ${runtime.id} archives failed.`);
667
+ }
668
+ // The pair downloads independently, then extracts in order because
669
+ // both archives populate the same new runtime directory.
670
+ for(const asset of assets){
671
+ await onEvent({
672
+ type:'local-ai.install.extracting',
673
+ message:runtime.id==='llama.cpp'?`Extracting ${runtime.id} ${release.version}: ${asset.name}.`:`Extracting ${runtime.id} ${release.version}.`,
674
+ data:{id:runtime.id,version:release.version,...(runtime.id==='llama.cpp'?{asset:asset.name}:{})}
675
+ });
676
+ await extractLocalAIArchive({archive:asset.archive,directory:root,signal,onEvent});
677
+ }
678
+ locations={version:release.version,
679
+ ...(runtime.id==='llama.cpp'?{backend:release.backend,requestedBackend:release.requestedBackend}:{}),
680
+ ...(runtime.id==='nemo-speech'
572
681
  ?await nemoRuntimeDirectories(root,signal)
573
682
  :{executable:await runtimeExecutable(root,runtime.id,platform,signal)})};
574
683
  }
575
684
  const record={id:runtime.id,platform,architecture,root,...locations,...(runtime.version?{requestedVersion:runtime.version}:{})};
576
685
  throwIfAborted(signal);
577
686
  if(archive)await unlink(archive);
578
- await writeFile(path.join(attempt,'installation.json'),`${JSON.stringify({requestVersion:runtime.version??null,requestUrl:runtime.url??null,...(runtime.id==='stable-diffusion.cpp'?{requestBackend:runtime.backend}:{}),...(runtime.id==='whisper.cpp'?{requestWhisper:whisperRuntimeSelection(runtime)}:{}),runtime:record},null,2)}\n`,{flag:'wx',signal});
687
+ if(companionArchive)await unlink(companionArchive);
688
+ await writeFile(path.join(attempt,'installation.json'),`${JSON.stringify({requestVersion:runtime.version??null,requestUrl:runtime.url??null,...(['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)?{requestBackend:runtime.backend}:{}),...(runtime.id==='llama.cpp'?{requestCompanionUrl:runtime.companionUrl??null}:{}),...(runtime.id==='whisper.cpp'?{requestWhisper:whisperRuntimeSelection(runtime)}:{}),runtime:record},null,2)}\n`,{flag:'wx',signal});
579
689
  completed=true;
580
690
  await onEvent({type:'local-ai.install.completed',message:`${runtime.id} ${record.version} is available.`,data:record});
581
691
  return record;
@@ -595,7 +705,7 @@ async function installRuntime(runtime,{directory,platform,architecture,signal,on
595
705
 
596
706
  function shareInstallation(runtime,options){
597
707
  throwIfAborted(options.signal);
598
- const key=JSON.stringify([options.directory,options.platform,options.architecture,runtime.id,runtime.version??null,runtime.url??null,runtime.id==='stable-diffusion.cpp'?runtime.backend:null,runtime.id==='whisper.cpp'?whisperRuntimeSelection(runtime):null]);
708
+ const key=JSON.stringify([options.directory,options.platform,options.architecture,runtime.id,runtime.version??null,runtime.url??null,['stable-diffusion.cpp','llama.cpp'].includes(runtime.id)?runtime.backend:null,runtime.id==='whisper.cpp'?whisperRuntimeSelection(runtime):null,runtime.id==='llama.cpp'?runtime.companionUrl??null:null]);
599
709
  let entry=installations.get(key);
600
710
  if(entry?.controller.signal.aborted){
601
711
  // The prior cancelled attempt owns its cleanup. A later request waits