arcane-os 0.56.0 → 0.58.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 (75) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -2
  3. package/browser-runtime/ai/core-local.mjs +306 -0
  4. package/browser-runtime/ai/core-onnx.mjs +96 -0
  5. package/browser-runtime/ai/onnx-tensors.mjs +85 -0
  6. package/browser-runtime/core/client.mjs +151 -16
  7. package/docs/architecture.md +5 -2
  8. package/docs/platform-targets.md +30 -13
  9. package/docs/reference/README.md +38 -3
  10. package/docs/reference/availability-and-normalization.md +9 -6
  11. package/docs/reference/cli.md +29 -9
  12. package/docs/reference/core-client.md +80 -0
  13. package/docs/reference/core-native-packaging.md +304 -12
  14. package/docs/reference/core-preferences.md +49 -0
  15. package/docs/reference/diarization.md +206 -0
  16. package/docs/reference/git-text-snapshot.md +104 -0
  17. package/docs/reference/inventory/package-api.json +330 -22
  18. package/docs/reference/local-ai.md +331 -0
  19. package/docs/reference/protocols.md +97 -20
  20. package/docs/reference/pwa.md +5 -2
  21. package/docs/reference/runtime-modules.md +6 -5
  22. package/docs/reference/sdk-api.md +611 -63
  23. package/package.json +15 -3
  24. package/runtime/arcane/modules/LocalAIReadiness.js +3 -1
  25. package/schemas/arcane-app.schema.json +55 -1
  26. package/schemas/arcane-package.schema.json +44 -0
  27. package/schemas/native-build-plan.schema.json +27 -1
  28. package/schemas/target-adapter.schema.json +2 -0
  29. package/src/app-descriptor.mjs +18 -2
  30. package/src/cli/main.mjs +20 -4
  31. package/src/constants.mjs +2 -0
  32. package/src/core/host.mjs +23 -0
  33. package/src/core/hosts/macos/ArcaneCoreProcess.h +85 -0
  34. package/src/core/hosts/macos/ArcaneCoreProcess.m +357 -0
  35. package/src/core/hosts/macos/ArcaneHost.h +86 -0
  36. package/src/core/hosts/macos/ArcaneHost.m +455 -0
  37. package/src/core/hosts/macos/ArcaneLauncher.m +432 -0
  38. package/src/core/hosts/windows/ArcaneCoreProcess.cs +3 -3
  39. package/src/core/hosts/windows/ArcaneHost.cs +695 -0
  40. package/src/core/hosts/windows/ArcaneHost.manifest +22 -0
  41. package/src/core/hosts/windows/ArcaneLauncher.cs +239 -0
  42. package/src/core/services/local-ai.mjs +621 -0
  43. package/src/core/services/packaged-web.mjs +89 -0
  44. package/src/core/services/preferences.mjs +201 -0
  45. package/src/core-classic-source.mjs +2 -2
  46. package/src/dev-server.mjs +9 -5
  47. package/src/diarization/build.mjs +35 -0
  48. package/src/diarization/index.mjs +432 -0
  49. package/src/diarization/native/CMakeLists.txt +9 -0
  50. package/src/diarization/native/main.cpp +269 -0
  51. package/src/diarization/service.mjs +129 -0
  52. package/src/git-text-snapshot.mjs +265 -0
  53. package/src/import-map.mjs +2 -0
  54. package/src/index.mjs +1 -0
  55. package/src/local-ai/archive.mjs +52 -0
  56. package/src/local-ai/config.mjs +62 -0
  57. package/src/local-ai/dev.mjs +390 -0
  58. package/src/local-ai/discover.mjs +71 -0
  59. package/src/local-ai/http.mjs +106 -0
  60. package/src/local-ai/install.mjs +459 -0
  61. package/src/local-ai/onnx-worker.mjs +85 -0
  62. package/src/local-ai/onnx.mjs +368 -0
  63. package/src/local-ai/server.mjs +262 -0
  64. package/src/native/macos-provider.mjs +291 -0
  65. package/src/native/portable-layout.mjs +37 -4
  66. package/src/native/portable-provider.mjs +44 -8
  67. package/src/native/windows-provider.mjs +262 -0
  68. package/src/native-plan.mjs +9 -1
  69. package/src/native-provider-loader.mjs +10 -2
  70. package/src/packager/core.mjs +6 -2
  71. package/src/process.mjs +184 -20
  72. package/src/scaffold.mjs +1 -1
  73. package/src/targets/index.mjs +11 -1
  74. package/src/templates/workspace-template.mjs +6 -5
  75. package/src/toolchain.mjs +33 -5
@@ -0,0 +1,459 @@
1
+ import Is from 'strong-type';
2
+ import {createWriteStream} from 'node:fs';
3
+ import {chmod,copyFile,lstat,mkdir,mkdtemp,readFile,readdir,readlink,rm,stat,symlink,unlink,writeFile} from 'node:fs/promises';
4
+ import {createRequire} from 'node:module';
5
+ import path from 'node:path';
6
+ import {Readable} from 'node:stream';
7
+ import {pipeline} from 'node:stream/promises';
8
+ import {ArcaneError,ERROR_CODES,normalizeError,throwIfAborted} from '../errors.mjs';
9
+ import {createEventQueue} from '../event-queue.mjs';
10
+ import {runProcess} from '../process.mjs';
11
+ import {extractLocalAIArchive} from './archive.mjs';
12
+
13
+ const is=new Is(false);
14
+ const installations=new Map();
15
+
16
+ function selectRuntimes(runtimes){
17
+ if(!is.array(runtimes)){
18
+ throw new ArcaneError(ERROR_CODES.usage,'Local AI runtimes must be an array.');
19
+ }
20
+ const selected=new Map();
21
+ for(const item of runtimes){
22
+ const runtime=is.string(item)?{id:item}:item;
23
+ if(!runtime||!['llama.cpp','ollama','nemo-speech','onnx'].includes(runtime.id)){
24
+ throw new ArcaneError(ERROR_CODES.targetUnavailable,`Local AI runtime installation is unavailable for ${String(runtime?.id)}.`);
25
+ }
26
+ const prior=selected.get(runtime.id);
27
+ if(prior&&(prior.version!==runtime.version||prior.url!==runtime.url)){
28
+ throw new ArcaneError(ERROR_CODES.usage,`Select one ${runtime.id} runtime version and URL for this operation.`);
29
+ }
30
+ if(runtime.version!==undefined&&(!is.string(runtime.version)||!runtime.version)){
31
+ throw new ArcaneError(ERROR_CODES.usage,`${runtime.id} version must be a nonempty string.`);
32
+ }
33
+ if(runtime.url!==undefined&&(!is.string(runtime.url)||!runtime.url)){
34
+ throw new ArcaneError(ERROR_CODES.usage,`${runtime.id} URL must be a nonempty string.`);
35
+ }
36
+ selected.set(runtime.id,runtime);
37
+ }
38
+ return [...selected.values()];
39
+ }
40
+
41
+ function selectedDirectory(directory){
42
+ if(!is.string(directory)||!directory){
43
+ throw new ArcaneError(ERROR_CODES.usage,'Local AI installation needs an explicit directory.');
44
+ }
45
+ return path.resolve(directory);
46
+ }
47
+
48
+ function releaseAssetNames(id,version,platform,architecture){
49
+ if(id==='nemo-speech'){
50
+ if(!['x64','arm64'].includes(architecture))return [];
51
+ const arch=architecture==='x64'?'x86_64':'aarch64';
52
+ const name=`nemo-speech-${version.replace(/^v/u,'')}`;
53
+ if(platform==='win32'&&architecture==='x64')return [`${name}-windows-${arch}-cpu.zip`];
54
+ if(platform==='linux')return [`${name}-linux-${arch}-cpu.tar.gz`];
55
+ if(platform==='darwin')return [`${name}-macos-${arch}-cpu.tar.gz`];
56
+ return [];
57
+ }
58
+ if(id==='ollama'){
59
+ if(platform==='darwin'&&['x64','arm64'].includes(architecture))return ['ollama-darwin.tgz'];
60
+ const arch=architecture==='x64'?'amd64':architecture;
61
+ if(!['amd64','arm64'].includes(arch))return [];
62
+ if(platform==='win32')return [`ollama-windows-${arch}.zip`];
63
+ if(platform==='linux')return [`ollama-linux-${arch}.tar.zst`,`ollama-linux-${arch}.tgz`];
64
+ return [];
65
+ }
66
+ if(!['x64','arm64'].includes(architecture))return [];
67
+ if(platform==='win32')return [`llama-${version}-bin-win-cpu-${architecture}.zip`];
68
+ if(platform==='linux')return [`llama-${version}-bin-ubuntu-${architecture}.tar.gz`,`llama-${version}-bin-ubuntu-${architecture}.zip`];
69
+ if(platform==='darwin')return [`llama-${version}-bin-macos-${architecture}.tar.gz`,`llama-${version}-bin-macos-${architecture}.zip`];
70
+ if(platform==='android'&&architecture==='arm64')return [`llama-${version}-bin-android-arm64.tar.gz`];
71
+ return [];
72
+ }
73
+
74
+ async function upstreamResponse(url,signal){
75
+ throwIfAborted(signal);
76
+ const response=await fetch(url,{signal,headers:{'User-Agent':'arcane-os-local-ai'}});
77
+ if(!response.ok){
78
+ const body=await response.text();
79
+ throw new ArcaneError(ERROR_CODES.operationFailed,`Local AI download returned HTTP ${response.status}${body?`: ${body}`:''}.`);
80
+ }
81
+ return response;
82
+ }
83
+
84
+ async function selectRelease(runtime,platform,architecture,signal){
85
+ if(runtime.url){
86
+ const name=path.posix.basename(new URL(runtime.url).pathname);
87
+ return {version:runtime.version??'custom',url:runtime.url,name};
88
+ }
89
+ const repository=runtime.id==='nemo-speech'?'NVIDIA/NeMo-Speech.cpp':runtime.id==='llama.cpp'?'ggml-org/llama.cpp':'ollama/ollama';
90
+ const version=runtime.version??(runtime.id==='nemo-speech'?'0.2.0':undefined);
91
+ const selected=version&&version!=='latest'
92
+ ?`tags/${encodeURIComponent(runtime.id!=='llama.cpp'&&!version.startsWith('v')?`v${version}`:version)}`
93
+ :'latest';
94
+ let release=await (await upstreamResponse(`https://api.github.com/repos/${repository}/releases/${selected}`,signal)).json();
95
+ if(runtime.id==='llama.cpp'){
96
+ // Stable llama.cpp releases identify their binary build explicitly.
97
+ const nightly=release.assets?.find(function isNightlySelection(asset){return asset.name==='nightly-tag.txt';});
98
+ if(nightly){
99
+ const tag=(await (await upstreamResponse(nightly.browser_download_url,signal)).text()).trim();
100
+ release=await (await upstreamResponse(`https://api.github.com/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`,signal)).json();
101
+ }
102
+ }
103
+ // Select only published format variants for this exact target distribution.
104
+ const names=releaseAssetNames(runtime.id,release.tag_name,platform,architecture);
105
+ const asset=release.assets?.find(function isSelectedAsset(candidate){return names.includes(candidate.name);});
106
+ if(!asset){
107
+ throw new ArcaneError(ERROR_CODES.targetUnavailable,`${runtime.id} ${release.tag_name} has no published ${platform}/${architecture} runtime archive${names.length?` named ${names.join(' or ')}`:''}.`);
108
+ }
109
+ return {version:release.tag_name,url:asset.browser_download_url,name:asset.name};
110
+ }
111
+
112
+ async function installedRuntime(base,runtime,platform,architecture,signal){
113
+ let entries;
114
+ try{
115
+ entries=await readdir(base,{withFileTypes:true});
116
+ }catch(error){
117
+ if(error.code==='ENOENT')return null;
118
+ throw error;
119
+ }
120
+ for(const entry of entries){
121
+ throwIfAborted(signal);
122
+ if(!entry.isDirectory())continue;
123
+ let installation;
124
+ try{
125
+ installation=JSON.parse(await readFile(path.join(base,entry.name,'installation.json'),{encoding:'utf8',signal}));
126
+ }catch(error){
127
+ if(error.code==='ENOENT'||error instanceof SyntaxError)continue;
128
+ throw error;
129
+ }
130
+ if(installation.requestVersion!==(runtime.version??null)||installation.requestUrl!==(runtime.url??null))continue;
131
+ const record=installation.runtime;
132
+ if(record?.id!==runtime.id||record.platform!==platform||record.architecture!==architecture)continue;
133
+ try{
134
+ if(runtime.id==='onnx'){
135
+ if(!is.string(record.root)||!is.string(record.modulePath))continue;
136
+ const locations=await onnxRuntimeModule(record.root,platform,architecture,signal);
137
+ return {...record,...locations,...(runtime.version?{requestedVersion:runtime.version}:{})};
138
+ }
139
+ if(runtime.id==='nemo-speech'){
140
+ const directories=[record.root,record.includeDirectory,record.libraryDirectory,record.binaryDirectory,record.cmakeDirectory];
141
+ if(directories.some(function missingDirectory(directory){return !is.string(directory);}))continue;
142
+ const files=[path.join(record.includeDirectory,'nemo_speech','diar.h'),path.join(record.includeDirectory,'nemo_speech','asr.h'),path.join(record.cmakeDirectory,'NeMoSpeechConfig.cmake')];
143
+ const entries=await Promise.all([...directories,...files].map(function inspectRuntimePath(location){return stat(location);}));
144
+ if(entries.every(function runtimePathPresent(info,index){return index<directories.length?info.isDirectory():info.isFile();})){
145
+ return {...record,...(runtime.version?{requestedVersion:runtime.version}:{})};
146
+ }
147
+ continue;
148
+ }
149
+ if((await stat(record.executable)).isFile())return {...record,...(runtime.version?{requestedVersion:runtime.version}:{})};
150
+ }catch(error){
151
+ if(runtime.id==='onnx'&&(error.code==='MODULE_NOT_FOUND'||error instanceof SyntaxError))continue;
152
+ if(error.code!=='ENOENT')throw error;
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+
158
+ async function runtimeExecutable(root,id,platform,signal){
159
+ const filename=`${id==='llama.cpp'?'llama-server':'ollama'}${platform==='win32'?'.exe':''}`;
160
+ const directories=[root];
161
+ for(const directory of directories){
162
+ throwIfAborted(signal);
163
+ const entries=await readdir(directory,{withFileTypes:true});
164
+ for(const entry of entries){
165
+ const selected=path.join(directory,entry.name);
166
+ if(entry.name===filename&&(entry.isFile()||entry.isSymbolicLink()))return selected;
167
+ if(entry.isDirectory())directories.push(selected);
168
+ }
169
+ }
170
+ throw new ArcaneError(ERROR_CODES.operationFailed,`The ${id} archive does not contain ${filename}.`);
171
+ }
172
+
173
+ async function nemoRuntimeDirectories(root,signal){
174
+ const directories=[root];
175
+ for(const directory of directories){
176
+ throwIfAborted(signal);
177
+ const entries=await readdir(directory,{withFileTypes:true});
178
+ for(const entry of entries){
179
+ if(entry.isDirectory())directories.push(path.join(directory,entry.name));
180
+ }
181
+ if(path.basename(directory)!=='nemo_speech'||path.basename(path.dirname(directory))!=='include')continue;
182
+ const headers=['diar.h','asr.h'];
183
+ if(!headers.every(function headerPresent(name){
184
+ return entries.some(function matchingHeader(entry){return entry.name===name&&(entry.isFile()||entry.isSymbolicLink());});
185
+ }))continue;
186
+ const includeDirectory=path.dirname(directory);
187
+ const prefix=path.dirname(includeDirectory);
188
+ const children=await readdir(prefix,{withFileTypes:true});
189
+ const binaryDirectory=path.join(prefix,'bin');
190
+ if(!(await stat(binaryDirectory)).isDirectory()){
191
+ throw new ArcaneError(ERROR_CODES.operationFailed,'The NeMo Speech archive does not contain its installed binary directory.');
192
+ }
193
+ const candidates=children.filter(function libraryDirectory(entry){
194
+ return ['lib','lib64'].includes(entry.name)&&(entry.isDirectory()||entry.isSymbolicLink());
195
+ }).map(function librarySearch(entry){
196
+ const libraryDirectory=path.join(prefix,entry.name);
197
+ return {directory:libraryDirectory,libraryDirectory};
198
+ });
199
+ for(const candidate of candidates){
200
+ throwIfAborted(signal);
201
+ const entries=await readdir(candidate.directory,{withFileTypes:true});
202
+ if(entries.some(function packageConfiguration(entry){
203
+ return entry.name==='NeMoSpeechConfig.cmake'&&(entry.isFile()||entry.isSymbolicLink());
204
+ })){
205
+ return {includeDirectory,libraryDirectory:candidate.libraryDirectory,binaryDirectory,cmakeDirectory:candidate.directory};
206
+ }
207
+ for(const entry of entries){
208
+ if(entry.isDirectory())candidates.push({directory:path.join(candidate.directory,entry.name),libraryDirectory:candidate.libraryDirectory});
209
+ }
210
+ }
211
+ throw new ArcaneError(ERROR_CODES.operationFailed,'The NeMo Speech archive does not contain its installed NeMoSpeech CMake package.');
212
+ }
213
+ throw new ArcaneError(ERROR_CODES.operationFailed,'The NeMo Speech archive does not contain include/nemo_speech/diar.h and asr.h.');
214
+ }
215
+
216
+ async function onnxRuntimeModule(root,platform,architecture,signal){
217
+ throwIfAborted(signal);
218
+ const packageDirectory=path.join(root,'node_modules','onnxruntime-node');
219
+ const metadata=JSON.parse(await readFile(path.join(packageDirectory,'package.json'),{encoding:'utf8',signal}));
220
+ // Node resolves the installed public entry without loading its native code.
221
+ const modulePath=createRequire(path.join(root,'package.json')).resolve('onnxruntime-node');
222
+ const binding=path.join(packageDirectory,'bin','napi-v6',platform,architecture,'onnxruntime_binding.node');
223
+ const entries=await Promise.all([modulePath,binding].map(function inspectONNXFile(filename){return stat(filename);}));
224
+ if(!entries.every(function onnxFilePresent(info){return info.isFile();})){
225
+ throw new ArcaneError(ERROR_CODES.operationFailed,`The ONNX package does not contain its public module and ${platform}/${architecture} native binding.`);
226
+ }
227
+ throwIfAborted(signal);
228
+ return {version:metadata.version,modulePath};
229
+ }
230
+
231
+ async function installONNXPackage(runtime,{root,directory,platform,architecture,signal,onEvent}){
232
+ const version=runtime.version??'1.30.0';
233
+ await onEvent({type:'local-ai.install.installing',message:`Installing onnxruntime-node ${version} for ${platform}/${architecture}.`,data:{id:runtime.id,version,platform,architecture}});
234
+ await mkdir(root,{recursive:true});
235
+ await writeFile(path.join(root,'package.json'),'{"private":true}\n',{flag:'wx',signal});
236
+ await runProcess('npm',[
237
+ 'install','--prefix',root,'--global=false','--save-exact','--omit=dev',
238
+ '--no-audit','--no-fund','--foreground-scripts','--registry=https://registry.npmjs.org/',
239
+ '--cache',path.join(directory,'npm-cache'),`onnxruntime-node@${runtime.url??version}`
240
+ ],{cwd:root,env:{ONNXRUNTIME_NODE_INSTALL:'skip'},signal,onEvent});
241
+ return onnxRuntimeModule(root,platform,architecture,signal);
242
+ }
243
+
244
+ async function installRuntime(runtime,{directory,platform,architecture,signal,onEvent}){
245
+ const base=path.join(directory,runtime.id,`${platform}-${architecture}`,encodeURIComponent(runtime.version??'default'));
246
+ await onEvent({type:'local-ai.install.starting',message:`Preparing ${runtime.id} for ${platform}/${architecture}.`,data:{id:runtime.id,platform,architecture}});
247
+ const installed=await installedRuntime(base,runtime,platform,architecture,signal);
248
+ if(installed){
249
+ await onEvent({type:'local-ai.install.available',message:`${runtime.id} ${installed.version} is already installed.`,data:installed});
250
+ return installed;
251
+ }
252
+ if(runtime.id==='onnx'&&(!['win32','linux','darwin'].includes(platform)||!['x64','arm64'].includes(architecture))){
253
+ throw new ArcaneError(ERROR_CODES.targetUnavailable,`onnxruntime-node has no supported ${platform}/${architecture} CPU runtime.`);
254
+ }
255
+ const release=runtime.id==='onnx'?null:await selectRelease(runtime,platform,architecture,signal);
256
+ throwIfAborted(signal);
257
+ await mkdir(base,{recursive:true});
258
+ const attempt=await mkdtemp(path.join(base,'install-'));
259
+ const root=path.join(attempt,'runtime');
260
+ const archive=release?path.join(attempt,release.name):null;
261
+ let completed=false;
262
+ try{
263
+ let locations;
264
+ if(runtime.id==='onnx'){
265
+ locations=await installONNXPackage(runtime,{root,directory,platform,architecture,signal,onEvent});
266
+ }else{
267
+ await onEvent({type:'local-ai.install.downloading',message:`Downloading ${runtime.id} ${release.version}.`,data:{id:runtime.id,version:release.version,platform,architecture}});
268
+ const response=await upstreamResponse(release.url,signal);
269
+ await pipeline(Readable.fromWeb(response.body),createWriteStream(archive,{flags:'wx'}),{signal});
270
+ await onEvent({type:'local-ai.install.extracting',message:`Extracting ${runtime.id} ${release.version}.`,data:{id:runtime.id,version:release.version}});
271
+ await extractLocalAIArchive({archive,directory:root,signal,onEvent});
272
+ locations={version:release.version,...(runtime.id==='nemo-speech'
273
+ ?await nemoRuntimeDirectories(root,signal)
274
+ :{executable:await runtimeExecutable(root,runtime.id,platform,signal)})};
275
+ }
276
+ const record={id:runtime.id,platform,architecture,root,...locations,...(runtime.version?{requestedVersion:runtime.version}:{})};
277
+ throwIfAborted(signal);
278
+ if(archive)await unlink(archive);
279
+ await writeFile(path.join(attempt,'installation.json'),`${JSON.stringify({requestVersion:runtime.version??null,requestUrl:runtime.url??null,runtime:record},null,2)}\n`,{flag:'wx',signal});
280
+ completed=true;
281
+ await onEvent({type:'local-ai.install.completed',message:`${runtime.id} ${record.version} is available.`,data:record});
282
+ return record;
283
+ }catch(error){
284
+ // Only this invocation's newly created incomplete attempt is removed.
285
+ // Previously installed runtimes are never overwritten or retired here.
286
+ if(!completed){
287
+ try{
288
+ await rm(attempt,{recursive:true,force:true});
289
+ }catch(cleanupError){
290
+ throw new AggregateError([error,cleanupError],`Installation of ${runtime.id} failed and its incomplete directory could not be removed.`);
291
+ }
292
+ }
293
+ throw error;
294
+ }
295
+ }
296
+
297
+ function shareInstallation(runtime,options){
298
+ throwIfAborted(options.signal);
299
+ const key=JSON.stringify([options.directory,options.platform,options.architecture,runtime.id,runtime.version??null,runtime.url??null]);
300
+ let entry=installations.get(key);
301
+ if(entry?.controller.signal.aborted){
302
+ // The prior cancelled attempt owns its cleanup. A later request waits
303
+ // for that narrow shared destination operation before starting afresh.
304
+ return entry.task.catch(function observeCancelledAttempt(){return undefined;}).then(function restartAfterCancellation(){
305
+ return shareInstallation(runtime,options);
306
+ });
307
+ }
308
+ if(!entry){
309
+ entry={controller:new AbortController(),consumers:new Set(),task:null};
310
+ installations.set(key,entry);
311
+ }
312
+ const current=entry;
313
+ return new Promise(function subscribeInstallation(resolve,reject){
314
+ let settled=false;
315
+ const consumer={onEvent:options.onEvent,finish,cancel,cancelError:null};
316
+ function finish(error,value){
317
+ if(settled)return;
318
+ settled=true;
319
+ options.signal?.removeEventListener('abort',abort);
320
+ current.consumers.delete(consumer);
321
+ if(current.consumers.size===0&&!current.controller.signal.aborted){
322
+ current.controller.abort(error);
323
+ }
324
+ if(consumer.cancelError)reject(consumer.cancelError);
325
+ else if(error)reject(error);
326
+ else resolve(value);
327
+ }
328
+ function abort(){
329
+ cancel(new ArcaneError(ERROR_CODES.cancelled,`Cancelled preparation of ${runtime.id}.`,{cause:options.signal?.reason,exitCode:130}));
330
+ }
331
+ function cancel(error){
332
+ if(settled)return;
333
+ consumer.cancelError??=error;
334
+ const hasOtherConsumer=[...current.consumers].some(function stillNeedsInstallation(subscriber){
335
+ return subscriber!==consumer&&!subscriber.cancelError;
336
+ });
337
+ if(hasOtherConsumer){
338
+ finish(consumer.cancelError);
339
+ }else{
340
+ // The last consumer stays joined until the download, extractor
341
+ // and incomplete-output cleanup have actually stopped.
342
+ current.controller.abort(consumer.cancelError);
343
+ }
344
+ }
345
+ async function publish(event){
346
+ await Promise.all([...current.consumers].map(async function sendConsumerEvent(subscriber){
347
+ if(subscriber.cancelError)return;
348
+ try{
349
+ await subscriber.onEvent(event);
350
+ }catch(error){
351
+ subscriber.cancel(error);
352
+ }
353
+ }));
354
+ throwIfAborted(current.controller.signal);
355
+ }
356
+ current.consumers.add(consumer);
357
+ options.signal?.addEventListener('abort',abort,{once:true});
358
+ if(options.signal?.aborted)abort();
359
+ if(!current.task){
360
+ current.task=installRuntime(runtime,{...options,signal:current.controller.signal,onEvent:publish});
361
+ void current.task.then(function installationCompleted(record){
362
+ installations.delete(key);
363
+ for(const subscriber of [...current.consumers])subscriber.finish(null,record);
364
+ },function installationFailed(error){
365
+ installations.delete(key);
366
+ for(const subscriber of [...current.consumers])subscriber.finish(normalizeError(error));
367
+ });
368
+ }
369
+ });
370
+ }
371
+
372
+ export async function ensureLocalAIRuntimes({runtimes=[],directory,platform=process.platform,architecture=process.arch,signal,onEvent}={}){
373
+ const selected=selectRuntimes(runtimes);
374
+ if(selected.length===0)return [];
375
+ const root=selectedDirectory(directory);
376
+ const controller=new AbortController();
377
+ const operationSignal=signal?AbortSignal.any([signal,controller.signal]):controller.signal;
378
+ const events=createEventQueue(onEvent,{onFailure:function cancelFailedObserver(error){controller.abort(error);}});
379
+ const startedAt=Date.now();
380
+ const heartbeat=setInterval(function reportInstallHeartbeat(){
381
+ void events.enqueue({type:'local-ai.install.heartbeat',message:'Local AI runtime preparation is active.',data:{elapsedMs:Date.now()-startedAt}},{coalesce:'local-ai.install.heartbeat'});
382
+ },5000);
383
+ heartbeat.unref?.();
384
+ try{
385
+ // There is one job per selected runtime. Independent runtime archives
386
+ // download and extract concurrently; matching requests share that job.
387
+ const outcomes=await Promise.allSettled(selected.map(function prepareRuntime(runtime){
388
+ return shareInstallation(runtime,{directory:root,platform,architecture,signal:operationSignal,onEvent:events.send});
389
+ }));
390
+ await events.drain();
391
+ const failures=outcomes.filter(function failedRuntime(outcome){return outcome.status==='rejected';});
392
+ if(failures.length===1)throw failures[0].reason;
393
+ if(failures.length>1)throw new AggregateError(failures.map(function runtimeFailure(outcome){return outcome.reason;}),'Local AI runtime preparation failed.');
394
+ throwIfAborted(operationSignal);
395
+ return outcomes.map(function completedRuntime(outcome){return outcome.value;});
396
+ }finally{
397
+ clearInterval(heartbeat);
398
+ await events.drain();
399
+ }
400
+ }
401
+
402
+ async function copyRuntimeTree(source,destination,outputRoot,files,signal){
403
+ throwIfAborted(signal);
404
+ const info=await lstat(source);
405
+ if(info.isDirectory()){
406
+ await mkdir(destination);
407
+ for(const entry of await readdir(source)){
408
+ await copyRuntimeTree(path.join(source,entry),path.join(destination,entry),outputRoot,files,signal);
409
+ }
410
+ await chmod(destination,info.mode);
411
+ return;
412
+ }
413
+ if(info.isSymbolicLink()){
414
+ await symlink(await readlink(source),destination);
415
+ }else{
416
+ await copyFile(source,destination);
417
+ await chmod(destination,info.mode);
418
+ }
419
+ files.push(path.relative(outputRoot,destination).split(path.sep).join('/'));
420
+ }
421
+
422
+ export async function bundleLocalAIRuntimes({runtimes=[],directory,outputRoot,platform=process.platform,architecture=process.arch,signal,onEvent}={}){
423
+ const selected=selectRuntimes(runtimes);
424
+ const root=selectedDirectory(outputRoot);
425
+ for(const runtime of selected){
426
+ const destination=path.join(root,'runtime','local-ai',runtime.id);
427
+ try{
428
+ await lstat(destination);
429
+ }catch(error){
430
+ if(error.code==='ENOENT')continue;
431
+ throw error;
432
+ }
433
+ throw new ArcaneError(ERROR_CODES.operationFailed,`Local AI bundle destination already exists: ${destination}. Select a fresh native staging directory.`);
434
+ }
435
+ const installed=await ensureLocalAIRuntimes({runtimes:selected,directory,platform,architecture,signal,onEvent});
436
+ if(installed.length===0)return {runtimes:[],files:[]};
437
+ await onEvent?.({type:'local-ai.bundle.starting',message:'Copying selected local AI runtimes into the native bundle.',data:{platform,architecture,runtimes:selected.map(function runtimeId(runtime){return runtime.id;})}});
438
+ await mkdir(path.join(root,'runtime','local-ai'),{recursive:true});
439
+ const outcomes=await Promise.allSettled(installed.map(async function bundleRuntime(runtime){
440
+ throwIfAborted(signal);
441
+ const files=[];
442
+ const relative=`runtime/local-ai/${runtime.id}`;
443
+ const destination=path.join(root,relative);
444
+ await copyRuntimeTree(runtime.root,destination,root,files,signal);
445
+ const bundled={...runtime,root:relative};
446
+ for(const field of ['executable','modulePath','includeDirectory','libraryDirectory','binaryDirectory','cmakeDirectory']){
447
+ if(runtime[field]===undefined)continue;
448
+ bundled[field]=path.posix.join(relative,path.relative(runtime.root,runtime[field]).split(path.sep).join('/'));
449
+ }
450
+ return {runtime:bundled,files};
451
+ }));
452
+ const failures=outcomes.filter(function failedBundle(outcome){return outcome.status==='rejected';});
453
+ if(failures.length===1)throw failures[0].reason;
454
+ if(failures.length>1)throw new AggregateError(failures.map(function bundleFailure(outcome){return outcome.reason;}),'Local AI runtime bundling failed.');
455
+ const records=outcomes.map(function bundledRecord(outcome){return outcome.value.runtime;});
456
+ const files=outcomes.flatMap(function bundledFiles(outcome){return outcome.value.files;});
457
+ await onEvent?.({type:'local-ai.bundle.completed',message:'Selected local AI runtimes are included in the native bundle.',data:{platform,architecture,files}});
458
+ return {runtimes:records,files};
459
+ }
@@ -0,0 +1,85 @@
1
+ import {parentPort,workerData} from 'node:worker_threads';
2
+ import {pathToFileURL} from 'node:url';
3
+ import Is from 'strong-type';
4
+
5
+ const is=new Is(false);
6
+ const imported=await import(pathToFileURL(workerData.modulePath).href);
7
+ const ort=imported.default??imported;
8
+ let session;
9
+
10
+ function describeError(error){
11
+ if(!(error instanceof Error))return {name:'Error',message:String(error),cause:error};
12
+ const result={...error,name:error.name,message:error.message,stack:error.stack};
13
+ if(error.cause!==undefined)result.cause=error.cause instanceof Error?describeError(error.cause):error.cause;
14
+ if(error instanceof AggregateError)result.errors=error.errors.map(describeError);
15
+ return result;
16
+ }
17
+
18
+ function tensorMap(records,inputs=false){
19
+ return Object.fromEntries(Object.entries(records).map(function createTensor([name,record]){
20
+ // ORT Node 1.30.0 supplies string feeds to FillStringTensor through
21
+ // null-terminated strings, so this exact content cannot be preserved.
22
+ if(inputs&&record?.type==='string'&&is.array(record.data)
23
+ &&record.data.some(function embeddedNull(value){return is.string(value)&&value.includes('\0');})){
24
+ const error=new Error(`ONNX Runtime Node cannot preserve embedded U+0000 in string tensor input "${name}".`);
25
+ error.code='LOCAL_AI_ONNX_STRING_INPUT_UNSUPPORTED';
26
+ throw error;
27
+ }
28
+ return [name,record===null?null:new ort.Tensor(record.type,record.data,record.dims)];
29
+ }));
30
+ }
31
+
32
+ async function handleRequest(request){
33
+ try{
34
+ let result;
35
+ if(request.operation==='load'){
36
+ const supplied=request.sessionOptions;
37
+ const options=supplied===undefined
38
+ ?{executionProviders:['cpu']}
39
+ :supplied!==null&&is.object(supplied)&&supplied.executionProviders===undefined
40
+ ?{...supplied,executionProviders:['cpu']}
41
+ :supplied;
42
+ session=await ort.InferenceSession.create(request.model,options);
43
+ result={
44
+ inputNames:session.inputNames,
45
+ outputNames:session.outputNames,
46
+ inputMetadata:session.inputMetadata,
47
+ outputMetadata:session.outputMetadata
48
+ };
49
+ }else if(request.operation==='run'){
50
+ const feeds=tensorMap(request.feeds,true);
51
+ const fetches=request.fetches===undefined||request.fetches===null||is.array(request.fetches)
52
+ ?request.fetches:tensorMap(request.fetches);
53
+ // ORT treats an empty fetch map as its options overload and would
54
+ // ignore the third argument. Both forms select every output.
55
+ const allOutputs=fetches===undefined
56
+ ||(fetches!==null&&!is.array(fetches)&&Object.keys(fetches).length===0);
57
+ const outputs=allOutputs
58
+ ?await session.run(feeds,request.runOptions)
59
+ :await session.run(feeds,fetches,request.runOptions);
60
+ result=Object.fromEntries(Object.entries(outputs).map(function tensorRecord([name,tensor]){
61
+ return [name,{type:tensor.type,data:tensor.data,dims:tensor.dims}];
62
+ }));
63
+ }else if(request.operation==='unload'){
64
+ await session?.release();
65
+ session=undefined;
66
+ }else{
67
+ throw new Error(`Unknown ONNX worker operation: ${String(request.operation)}.`);
68
+ }
69
+ // Structured cloning retains typed tensors and BigInt without taking
70
+ // ownership of the caller's buffers or converting their contents.
71
+ parentPort.postMessage({requestId:request.requestId,operation:request.operation,result});
72
+ }catch(error){
73
+ parentPort.postMessage({requestId:request.requestId,operation:request.operation,error:describeError(error)});
74
+ }finally{
75
+ if(request.operation==='unload')parentPort.close();
76
+ }
77
+ }
78
+
79
+ parentPort.on('message',function receiveRequest(request){
80
+ // The session owner dispatches one operation at a time. Native ORT work
81
+ // runs synchronously inside this worker, never on the caller's thread.
82
+ void handleRequest(request).catch(function reportWorkerFailure(error){
83
+ setImmediate(function throwWorkerFailure(){throw error;});
84
+ });
85
+ });