arcane-os 0.66.0 → 0.68.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 (38) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/ai/core-image.mjs +84 -34
  4. package/browser-runtime/ai/core-local.mjs +195 -43
  5. package/browser-runtime/ai/core-onnx.mjs +71 -12
  6. package/docs/reference/README.md +6 -0
  7. package/docs/reference/availability-and-normalization.md +1 -0
  8. package/docs/reference/core-native-packaging.md +11 -4
  9. package/docs/reference/core-repositories.md +93 -9
  10. package/docs/reference/core-runtime.md +46 -5
  11. package/docs/reference/inventory/package-api.json +62 -20
  12. package/docs/reference/inventory/runtime-components.json +2 -1
  13. package/docs/reference/inventory/runtime-modules.json +3 -3
  14. package/docs/reference/local-ai.md +112 -2
  15. package/docs/reference/local-image-generation.md +15 -4
  16. package/docs/reference/model-assets.md +50 -3
  17. package/docs/reference/native-decisions.md +181 -0
  18. package/docs/reference/runtime-components.md +62 -5
  19. package/docs/reference/runtime-modules.md +44 -7
  20. package/docs/reference/sdk-api.md +229 -19
  21. package/package.json +5 -2
  22. package/runtime/arcane/components/theme-switcher.html +112 -13
  23. package/runtime/arcane/modules/PreferenceStore.js +18 -13
  24. package/runtime/arcane/modules/SystemAppearance.js +24 -6
  25. package/src/core/repositories.mjs +27 -8
  26. package/src/core/runtime.mjs +26 -1
  27. package/src/core/services/decisions.mjs +144 -0
  28. package/src/core/services/local-ai.mjs +31 -9
  29. package/src/core/services/model-assets.mjs +98 -13
  30. package/src/git-identity.mjs +22 -58
  31. package/src/local-ai/decision-tokenizer-worker.mjs +31 -0
  32. package/src/local-ai/decision-tokenizer.mjs +109 -0
  33. package/src/local-ai/decisions.mjs +273 -0
  34. package/src/local-ai/dev.mjs +8 -2
  35. package/src/local-ai/onnx-worker.mjs +67 -7
  36. package/src/local-ai/onnx.mjs +23 -12
  37. package/src/native/portable-layout.mjs +8 -3
  38. package/src/repository.mjs +144 -4
@@ -153,21 +153,26 @@ function nativeAdapter(){
153
153
  return preferences;
154
154
  }
155
155
 
156
- function isUnsupportedNativeAdapter(error){
157
- return error?.code==='ANDROID_CAPABILITY_UNSUPPORTED';
156
+ function isUnsupportedNativeAdapter(error,method){
157
+ return error?.code==='ANDROID_CAPABILITY_UNSUPPORTED'
158
+ ||(error?.code==='METHOD_NOT_ALLOWED'
159
+ &&error.reason==='core-namespace-unavailable'
160
+ &&error.namespace==='preferences'
161
+ &&error.method===`preferences.${method}`);
158
162
  }
159
163
 
160
- function preferenceAdapter(){
164
+ function preferenceAdapter(assertActive){
161
165
  const local=localAdapter('arcane.preferences');
162
166
  if(is.function(globalThis.arcaneAndroid?.postMessage)) return local;
163
167
  const native=nativeAdapter();
164
168
  if(!native) return local;
165
169
  let active=native;
166
- async function call(method,args){
170
+ async function call(method,args,context){
167
171
  try{
168
172
  return await active[method](...args);
169
173
  }catch(error){
170
- if(active!==native||!isUnsupportedNativeAdapter(error)) throw error;
174
+ if(active!==native||!isUnsupportedNativeAdapter(error,method)) throw error;
175
+ assertActive(context?.signal);
171
176
  active=local;
172
177
  delete adapter.setMany;
173
178
  if(!is.function(active[method])) throw error;
@@ -175,19 +180,19 @@ function preferenceAdapter(){
175
180
  }
176
181
  }
177
182
  const adapter={
178
- async get(key){
179
- return call('get',[key]);
183
+ async get(key,context){
184
+ return call('get',[key,context],context);
180
185
  },
181
- async set(key,value){
182
- return call('set',[key,value]);
186
+ async set(key,value,context){
187
+ return call('set',[key,value,context],context);
183
188
  },
184
- async delete(key){
185
- return call('delete',[key]);
189
+ async delete(key,context){
190
+ return call('delete',[key,context],context);
186
191
  }
187
192
  };
188
193
  if(is.function(native.setMany)){
189
194
  adapter.setMany=async function setMany(entries,context){
190
- return call('setMany',[entries,context]);
195
+ return call('setMany',[entries,context],context);
191
196
  };
192
197
  }
193
198
  return adapter;
@@ -204,7 +209,7 @@ export default class PreferenceStore extends EventTarget{
204
209
  super();
205
210
  this.namespace=String(namespace||'arcane');
206
211
  this.schema=preferenceSchema(schema);
207
- this.adapter=validateAdapter(adapter||preferenceAdapter());
212
+ this.adapter=validateAdapter(adapter||preferenceAdapter(this.#assertOperationActive.bind(this)));
208
213
  this.values=this.defaults();
209
214
  this.#events=createArcaneEventSource(
210
215
  this,
@@ -8,16 +8,34 @@ export default class SystemAppearance{
8
8
 
9
9
  async current(){
10
10
  if(!this.api||!is.function(this.api.current)) return {supported:false,platform:'browser'};
11
- return this.api.current();
11
+ try{
12
+ return await this.api.current();
13
+ }catch(error){
14
+ return unsupportedNamespace(error,'current');
15
+ }
12
16
  }
13
17
 
14
18
  async apply(input={}){
15
19
  if(!this.available()) return {supported:false,platform:'browser'};
16
20
  const scheme=['system','light','dark'].includes(input.scheme)?input.scheme:'system';
17
- return this.api.apply({
18
- scheme,
19
- captionColor:scheme==='system'?null:input.captionColor||null,
20
- textColor:scheme==='system'?null:input.textColor||null
21
- });
21
+ try{
22
+ return await this.api.apply({
23
+ scheme,
24
+ captionColor:scheme==='system'?null:input.captionColor||null,
25
+ textColor:scheme==='system'?null:input.textColor||null
26
+ });
27
+ }catch(error){
28
+ return unsupportedNamespace(error,'apply');
29
+ }
22
30
  }
23
31
  }
32
+
33
+ function unsupportedNamespace(error,method){
34
+ if(error?.code==='METHOD_NOT_ALLOWED'
35
+ &&error.reason==='core-namespace-unavailable'
36
+ &&error.namespace==='appearance'
37
+ &&error.method===`appearance.${method}`){
38
+ return {supported:false,reason:'core-namespace-unavailable',error};
39
+ }
40
+ throw error;
41
+ }
@@ -4,7 +4,8 @@ import path from 'node:path';
4
4
  import Is from 'strong-type';
5
5
  import {ArcaneError, throwIfAborted} from '../errors.mjs';
6
6
  import {runProcess} from '../process.mjs';
7
- import {repositoryPull, repositoryPush, repositoryStatus} from '../repository.mjs';
7
+ import {captureRepositoryTarget, repositoryConfiguration, repositoryPull, repositoryPush,
8
+ repositoryPushArguments, repositoryStatus} from '../repository.mjs';
8
9
  import {createGitIdentityRunner} from '../git-identity.mjs';
9
10
 
10
11
  export {readGitIdentity} from '../git-identity.mjs';
@@ -152,29 +153,46 @@ export function createRepositoryWorkspace(
152
153
  );
153
154
  }
154
155
 
155
- function pull({signal} = {}) {
156
+ function configuration({signal} = {}) {
157
+ return accept(
158
+ function readRepositoryConfiguration() {
159
+ return repositoryConfiguration({workspaceRoot: repositoryDirectory, signal, onEvent, run: execute});
160
+ },
161
+ signal
162
+ );
163
+ }
164
+
165
+ function pull({target, signal} = {}) {
166
+ let selectedTarget;
167
+ try { selectedTarget = captureRepositoryTarget(target); }
168
+ catch (error) { return Promise.reject(error); }
156
169
  return accept(
157
170
  async function pullRepository() {
158
171
  await prepare(signal);
159
- return repositoryPull({workspaceRoot: repositoryDirectory, signal, onEvent, run: execute});
172
+ return repositoryPull({workspaceRoot: repositoryDirectory, target: selectedTarget, signal, onEvent, run: execute});
160
173
  },
161
174
  signal
162
175
  );
163
176
  }
164
177
 
165
- function push({signal} = {}) {
178
+ function push({target, signal} = {}) {
179
+ let selectedTarget;
180
+ try { selectedTarget = captureRepositoryTarget(target); }
181
+ catch (error) { return Promise.reject(error); }
166
182
  return accept(
167
183
  async function pushRepository() {
168
184
  await prepare(signal);
169
- return repositoryPush({workspaceRoot: repositoryDirectory, signal, onEvent, run: execute});
185
+ return repositoryPush({workspaceRoot: repositoryDirectory, target: selectedTarget, signal, onEvent, run: execute});
170
186
  },
171
187
  signal
172
188
  );
173
189
  }
174
190
 
175
- function write({files, message, signal} = {}) {
191
+ function write({files, message, target, signal} = {}) {
176
192
  let selectedFiles;
193
+ let selectedTarget;
177
194
  try {
195
+ selectedTarget = captureRepositoryTarget(target);
178
196
  requireString(message, 'message');
179
197
  if (!message.isWellFormed()) throw new TypeError('message cannot be represented completely as UTF-8 text.');
180
198
  if (!is.array(files) || files.length === 0) {
@@ -210,6 +228,7 @@ export function createRepositoryWorkspace(
210
228
  async function writeRepositoryFiles() {
211
229
  const outcome = {
212
230
  directory: repositoryDirectory,
231
+ ...(selectedTarget === undefined ? {} : {target: {...selectedTarget}}),
213
232
  state: 'local',
214
233
  stage: 'prepare',
215
234
  paths: selectedFiles.map(function selectedPath(file) { return file.path; }),
@@ -321,7 +340,7 @@ export function createRepositoryWorkspace(
321
340
  ['--literal-pathspecs', 'commit', '--only', '--cleanup=verbatim', '--file', '-', '--', ...outcome.paths],
322
341
  commitMessage()
323
342
  );
324
- await executeGit('push', ['push']);
343
+ await executeGit('push', repositoryPushArguments(selectedTarget));
325
344
  outcome.stage = 'complete';
326
345
  return outcome;
327
346
  } catch (cause) {
@@ -357,7 +376,7 @@ export function createRepositoryWorkspace(
357
376
  return closing;
358
377
  }
359
378
 
360
- return {directory: repositoryDirectory, open, status, pull, push, write, close, drain: close, dispose: close};
379
+ return {directory: repositoryDirectory, open, status, configuration, pull, push, write, close, drain: close, dispose: close};
361
380
  }
362
381
 
363
382
  function requireString(value, name) {
@@ -150,11 +150,22 @@ class CoreRuntime {
150
150
  };
151
151
  }
152
152
 
153
+ async getService(name) {
154
+ if (this.#closing) throw failure('CORE_CLOSING', 'Core is closing.');
155
+ const service = this.#services.get(name);
156
+ if (!service) throw failure('CORE_SERVICE_UNAVAILABLE', `Core service ${String(name)} is not registered.`);
157
+ await this.#startService(service);
158
+ return service.definition;
159
+ }
160
+
153
161
  #serviceContext(service) {
154
162
  const runtime = this;
155
163
  return {
156
164
  application: this.#application,
157
165
  service: service.name,
166
+ getService(name) {
167
+ return runtime.getService(name);
168
+ },
158
169
  emit(event, data) {
159
170
  return runtime.emit(event, data);
160
171
  }
@@ -256,7 +267,21 @@ class CoreRuntime {
256
267
 
257
268
  #request(frame) {
258
269
  const registration = this.#methods.get(frame.method);
259
- if (!registration) throw failure('METHOD_NOT_ALLOWED', `Core does not expose ${frame.method}.`);
270
+ if (!registration) {
271
+ const namespace = frame.method.split('.')[0];
272
+ const prefix = `${namespace}.`;
273
+ const namespacePresent = this.#services.has(namespace)
274
+ || [...this.#methods.keys(), 'system.ping', 'app.current', 'version.current'].some(
275
+ function inNamespace(method) { return method.startsWith(prefix); }
276
+ );
277
+ throw new CoreError({
278
+ code: 'METHOD_NOT_ALLOWED',
279
+ message: `Core does not expose ${frame.method}.`,
280
+ reason: namespacePresent ? 'core-method-unavailable' : 'core-namespace-unavailable',
281
+ namespace,
282
+ method: frame.method
283
+ });
284
+ }
260
285
  const {service, handle, lifetime} = registration;
261
286
  const controller = new AbortController();
262
287
  const request = {id: frame.id, method: frame.method, lifetime, controller, task: null};
@@ -0,0 +1,144 @@
1
+ import path from 'node:path';
2
+ import {createNativeDecisionModel} from '../../local-ai/decisions.mjs';
3
+ import {createArcaneEventSource} from '../../event-manager.mjs';
4
+ import {CoreError} from '../../../browser-runtime/core/contracts.mjs';
5
+ import {encodeTensorMap} from '../../../browser-runtime/ai/onnx-tensors.mjs';
6
+
7
+ /** App-owned selection; shared typed-decision execution, without a renderer. */
8
+ export function createNativeDecisionService(configuration = {}, {appRoot = process.cwd()} = {}) {
9
+ const events = createArcaneEventSource({}, {source: 'core-decisions', eventTypes: ['decisions.state']});
10
+ const lifetime = new AbortController();
11
+ let context;
12
+ let model;
13
+ let loading;
14
+ let loadController;
15
+ let releasing;
16
+ let closing;
17
+ let unsubscribe;
18
+
19
+ function current() {
20
+ return model?.current() ?? {
21
+ family: 'laya', model: configuration.model ?? 'onnx-community/laya-typed-decisions-ONNX',
22
+ revision: configuration.revision ?? 'main', dtype: configuration.dtype ?? 'fp32',
23
+ state: closing ? 'disposed' : 'unloaded', loaded: false, busy: Boolean(loading),
24
+ activeRequests: 0, progress: null, error: null, execution: null
25
+ };
26
+ }
27
+
28
+ function publish(value = current()) {
29
+ events.dispatch('decisions.state', value);
30
+ context?.emit('decisions.state', value);
31
+ }
32
+
33
+ function subscribe(listener, {emitCurrent = true, signal} = {}) {
34
+ const stop = events.on('decisions.state', function changed(event) { listener(event.detail); }, {signal});
35
+ try { if (emitCurrent && !signal?.aborted) listener(current()); }
36
+ catch (error) { stop(); throw error; }
37
+ return stop;
38
+ }
39
+
40
+ function operationSignal(signal) { return signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal; }
41
+
42
+ function load({signal} = {}) {
43
+ operationSignal(signal).throwIfAborted();
44
+ 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;
49
+ const selectedSignal = AbortSignal.any([lifetime.signal, controller.signal]);
50
+ const precedingRelease = releasing;
51
+ loading = Promise.resolve().then(async function loadSelectedModel() {
52
+ selectedSignal.throwIfAborted();
53
+ if (precedingRelease) await precedingRelease;
54
+ selectedSignal.throwIfAborted();
55
+ if (model) await model.dispose();
56
+ unsubscribe?.();
57
+ // Only the explicit load waits for its native dependencies. Merely
58
+ // registering this service does not download a model or delay UI.
59
+ const [localAI, modelAssets] = await Promise.all([
60
+ context.getService('local-ai'),
61
+ configuration.paths ? undefined : context.getService('model-assets')
62
+ ]);
63
+ selectedSignal.throwIfAborted();
64
+ const paths = configuration.paths && Object.fromEntries(
65
+ Object.entries(configuration.paths).map(function nativePath([name, value]) { return [name, path.resolve(appRoot, value)]; })
66
+ );
67
+ model = createNativeDecisionModel({
68
+ ...configuration, paths, onnx: localAI.getONNXRuntime(), modelAssets,
69
+ signal: lifetime.signal
70
+ });
71
+ unsubscribe = model.subscribe(publish);
72
+ return model.load({signal: selectedSignal});
73
+ });
74
+ function settled() { loading = undefined; loadController = undefined; publish(); }
75
+ loading.then(settled, settled);
76
+ publish();
77
+ return observeLoad(loading, controller, signal);
78
+ }
79
+
80
+ function observeLoad(task, controller, signal) {
81
+ function abort() { controller.abort(signal.reason); }
82
+ signal?.addEventListener('abort', abort, {once: true});
83
+ if (signal?.aborted) abort();
84
+ return task.finally(function detachLoadCaller() { signal?.removeEventListener('abort', abort); });
85
+ }
86
+
87
+ async function evaluate(rows, {signal, runOptions} = {}) {
88
+ const selectedSignal = operationSignal(signal);
89
+ selectedSignal.throwIfAborted();
90
+ if (!model) throw new CoreError({code: 'ARCANE_DECISION_NOT_LOADED', message: 'Load the selected native decision model before evaluating.'});
91
+ return model.evaluate(rows, {signal: selectedSignal, runOptions});
92
+ }
93
+
94
+ function unload() {
95
+ if (releasing) return releasing;
96
+ // An unload during model preparation must cancel that accepted load,
97
+ // rather than wait for it to download and activate first.
98
+ loadController?.abort(new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision load was cancelled.'}));
99
+ const task = model?.unload() ?? Promise.resolve();
100
+ const acceptedLoad = loading;
101
+ releasing = Promise.resolve().then(async function releaseSelectedModel() {
102
+ const results = await Promise.allSettled([task, acceptedLoad]);
103
+ if (results[0].status === 'rejected') throw results[0].reason;
104
+ return current();
105
+ });
106
+ function released() { releasing = undefined; }
107
+ releasing.then(released, released);
108
+ return releasing;
109
+ }
110
+
111
+ function dispose() {
112
+ if (closing) return closing;
113
+ const reason = new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision service is closing.'});
114
+ closing = Promise.resolve().then(async function closeDecisionService() {
115
+ const failures = [];
116
+ try { await loading; } catch (error) { if (!lifetime.signal.aborted) failures.push(error); }
117
+ try { await model?.dispose(); } catch (error) { failures.push(error); }
118
+ unsubscribe?.();
119
+ publish();
120
+ events.dispose();
121
+ if (failures.length) throw new AggregateError(failures, 'Closing the decision service failed.');
122
+ });
123
+ lifetime.abort(reason);
124
+ return closing;
125
+ }
126
+
127
+ return {
128
+ name: configuration.name ?? 'decisions', current, subscribe, load, evaluate, classify: evaluate, unload, dispose,
129
+ start(serviceContext) { context = serviceContext; },
130
+ methods: {
131
+ 'decisions.status': current,
132
+ 'decisions.load': function loadRequest(parameters, request) { return load({signal: request.signal}); },
133
+ 'decisions.evaluate': async function evaluateRequest({rows, runOptions}, request) {
134
+ const result = await evaluate(rows, {runOptions, signal: request.signal});
135
+ // Typed arrays retain their complete values through Core's
136
+ // existing JSON tensor encoding, only at this transport edge.
137
+ return {...result, outputs: encodeTensorMap(result.outputs)};
138
+ },
139
+ 'decisions.unload': {lifetime: 'service', handle: unload}
140
+ }
141
+ };
142
+ }
143
+
144
+ export default createNativeDecisionService;
@@ -149,8 +149,13 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
149
149
  };
150
150
  }
151
151
 
152
+ function runtimeRecord(engine) {
153
+ return {...engine.state, models: [...engine.models],
154
+ ...(engine.id === 'llama.cpp' ? {released: engine.released === true} : {})};
155
+ }
156
+
152
157
  function snapshot() {
153
- const records = [...engines.values()].map(function runtimeRecord(engine) { return {...engine.state, models: [...engine.models]}; });
158
+ const records = [...engines.values()].map(runtimeRecord);
154
159
  if (onnxSelected) records.push(onnxStatus());
155
160
  const ollama = records.find(function ollamaRecord(record) { return record.id === 'ollama'; });
156
161
  return {
@@ -283,7 +288,7 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
283
288
  engine.models = [...merged.values()];
284
289
  }
285
290
  publish();
286
- return {...engine.state, models: [...engine.models]};
291
+ return runtimeRecord(engine);
287
292
  }
288
293
 
289
294
  function observeModels(engine) {
@@ -347,7 +352,7 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
347
352
  return recovering;
348
353
  }
349
354
 
350
- async function engineReady(id, signal, {retryLoading = false} = {}) {
355
+ async function engineReady(id, signal, {retryLoading = false, resumeReleased = true} = {}) {
351
356
  signal?.throwIfAborted();
352
357
  const engine = engines.get(id);
353
358
  if (!engine) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', `${id} is not selected in this application's localAI configuration.`);
@@ -358,13 +363,28 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
358
363
  if (retryLoading && engine.state.state === 'loading') {
359
364
  await awaitReadiness(restartEngine(engine), signal);
360
365
  }
361
- if (engine.released) { engine.released = false; startEngine(engine); }
366
+ if (engine.released) {
367
+ if (!resumeReleased) return engine;
368
+ engine.released = false;
369
+ startEngine(engine);
370
+ }
362
371
  await awaitReadiness(engine.ready, signal);
363
372
  signal?.throwIfAborted();
364
373
  if (!engine.state.available) throw failure('LOCAL_AI_RUNTIME_UNAVAILABLE', `${id} is unavailable.`);
365
374
  return engine;
366
375
  }
367
376
 
377
+ function observeLlama(signal) {
378
+ signal?.throwIfAborted();
379
+ const engine = engines.get('llama.cpp');
380
+ if (!engine) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', "llama.cpp is not selected in this application's localAI configuration.");
381
+ // Observation neither waits for startup nor starts a released engine.
382
+ if (!engine.state.available || engine.released || engine.stopping || engine.recovering || engine.signal.aborted) {
383
+ return runtimeRecord(engine);
384
+ }
385
+ return refreshModels(engine, signal);
386
+ }
387
+
368
388
  function waitForModel(engine, model, signal) {
369
389
  return new Promise(function awaitModel(resolve, reject) {
370
390
  let unsubscribe;
@@ -563,6 +583,9 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
563
583
  const service = {
564
584
  name: 'local-ai',
565
585
  current: snapshot,
586
+ getONNXRuntime() {
587
+ return requireONNX();
588
+ },
566
589
  async start(currentContext) {
567
590
  context = currentContext;
568
591
  if (prepare) {
@@ -633,17 +656,16 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
633
656
  return snapshot();
634
657
  },
635
658
  'llama.status': async function llamaStatus(_parameters, request) {
636
- const engine = await engineReady('llama.cpp', request.signal);
637
- return refreshModels(engine, request.signal);
659
+ return observeLlama(request.signal);
638
660
  },
639
661
  'llama.models': async function llamaModels(_parameters, request) {
640
- const engine = await engineReady('llama.cpp', request.signal);
641
- const status = await refreshModels(engine, request.signal);
662
+ const status = await observeLlama(request.signal);
642
663
  return {models: status.models};
643
664
  },
644
665
  'llama.load': loadLlama,
645
666
  'llama.unload': async function unloadLlama({model}, request) {
646
- const engine = await engineReady('llama.cpp', request.signal);
667
+ const engine = await engineReady('llama.cpp', request.signal, {resumeReleased: false});
668
+ if (engine.released) return {...engine.state, models: [...engine.models], released: true, unloaded: true};
647
669
  engine.loadControllers.get(model)?.abort(failure('LOCAL_AI_MODEL_UNLOADED', `The application unloaded ${model}.`));
648
670
  if (engine.loads.has(model)) await Promise.allSettled([engine.loads.get(model)]);
649
671
  if (!engine.router) {
@@ -10,7 +10,7 @@ function failure(code, message) {
10
10
  return new CoreError({code, message});
11
11
  }
12
12
 
13
- /** Working files for native engines. The browser's stored assets stay authoritative. */
13
+ /** Working files for native engines. Caller-selected source assets stay authoritative. */
14
14
  export function createModelAssetService({appRoot = process.cwd()} = {}) {
15
15
  const projections = new Map();
16
16
  let context;
@@ -63,6 +63,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
63
63
  const errors = [];
64
64
  let filesClosed = false;
65
65
  try { await record.opening; } catch (error) { record.error ??= error; }
66
+ try { await record.downloading; } catch (error) { record.error ??= error; }
66
67
  try { await record.finishing; } catch (error) { record.error ??= error; }
67
68
  try { await closeMembers(record); filesClosed = true; } catch (error) { errors.push(error); }
68
69
  if (record.directory && filesClosed) {
@@ -87,10 +88,16 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
87
88
 
88
89
  function releasePreparation(record) {
89
90
  record.preparationOwned = false;
91
+ record.downloadController?.abort(failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.'));
90
92
  publish(record);
91
93
  return cleanup(record);
92
94
  }
93
95
 
96
+ function release(id) {
97
+ const record = projections.get(id);
98
+ return record ? releasePreparation(record) : Promise.resolve({id, state: 'released'});
99
+ }
100
+
94
101
  async function failedOperation(record, error) {
95
102
  record.error = error;
96
103
  record.state = 'error';
@@ -165,19 +172,98 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
165
172
  const member = record.members[memberIndex];
166
173
  if (!member) throw new TypeError('Select an existing model asset member.');
167
174
  try {
168
- const task = member.tail.then(async function appendOriginalContent() {
169
- request.signal.throwIfAborted();
170
- if (!record.preparationOwned) throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
171
- await member.handle.appendFile(Buffer.from(contentBase64, 'base64'));
172
- request.signal.throwIfAborted();
173
- });
174
- // File writes remain ordered; unrelated member files write independently.
175
- member.tail = task.catch(function observeWriteFailure() {});
176
- await task;
175
+ await appendMemberContent(record, member, Buffer.from(contentBase64, 'base64'), request.signal);
177
176
  return {id, memberIndex, written: true};
178
177
  } catch (error) { return failedOperation(record, error); }
179
178
  }
180
179
 
180
+ function appendMemberContent(record, member, content, signal) {
181
+ const task = member.tail.then(async function appendOriginalContent() {
182
+ signal.throwIfAborted();
183
+ if (!record.preparationOwned) throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
184
+ await member.handle.appendFile(content);
185
+ signal.throwIfAborted();
186
+ });
187
+ // File writes remain ordered; unrelated member files write independently.
188
+ member.tail = task.catch(function observeWriteFailure() {});
189
+ return task;
190
+ }
191
+
192
+ async function prepare({id, workingDirectory, members, signal, onProgress} = {}) {
193
+ const controller = new AbortController();
194
+ const operationSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
195
+ const request = {signal: operationSignal};
196
+ let record;
197
+ let completed = 0;
198
+ let firstFailure;
199
+
200
+ async function progress(phase, memberIndex) {
201
+ operationSignal.throwIfAborted();
202
+ await onProgress?.({
203
+ phase, completed, total: members.length, unit: 'files',
204
+ ...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path})
205
+ });
206
+ operationSignal.throwIfAborted();
207
+ }
208
+
209
+ async function downloadMember(member, memberIndex) {
210
+ try {
211
+ await progress('download', memberIndex);
212
+ const response = await fetch(member.url, {signal: operationSignal});
213
+ if (!response.ok) {
214
+ const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
215
+ error.url = member.url;
216
+ error.status = response.status;
217
+ error.response = await response.text();
218
+ throw error;
219
+ }
220
+ if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
221
+ for await (const content of response.body) {
222
+ operationSignal.throwIfAborted();
223
+ await appendMemberContent(record, record.members[memberIndex], content, operationSignal);
224
+ await progress('download', memberIndex);
225
+ }
226
+ completed += 1;
227
+ await progress('download', memberIndex);
228
+ } catch (error) {
229
+ firstFailure ??= error;
230
+ controller.abort(error);
231
+ throw error;
232
+ }
233
+ }
234
+
235
+ try {
236
+ await openProjection({id, workingDirectory, members}, request);
237
+ record = requireProjection(id);
238
+ operationSignal.throwIfAborted();
239
+ if (!record.preparationOwned || closing) {
240
+ throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
241
+ }
242
+ record.downloadController = controller;
243
+ // Cleanup joins this transfer-only task. Its rejection handler below
244
+ // may release preparation without waiting on itself.
245
+ record.downloading = Promise.resolve().then(async function downloadMembers() {
246
+ await progress('open');
247
+ const results = await Promise.allSettled(members.map(downloadMember));
248
+ const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
249
+ .map(function reason(result) { return result.reason; });
250
+ if (errors.length > 1) {
251
+ throw new AggregateError(errors, 'Downloading model asset members failed.', {cause: firstFailure});
252
+ }
253
+ if (errors.length) throw errors[0];
254
+ });
255
+ await record.downloading;
256
+ await progress('complete');
257
+ const projection = await completeProjection({id}, request);
258
+ await progress('ready');
259
+ return projection;
260
+ } catch (error) {
261
+ controller.abort(error);
262
+ if (record) return failedOperation(record, error);
263
+ throw error;
264
+ }
265
+ }
266
+
181
267
  async function completeProjection({id}, request) {
182
268
  const record = requireProjection(id);
183
269
  await record.opening;
@@ -245,7 +331,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
245
331
  }
246
332
 
247
333
  return {
248
- name: 'model-assets', current, retain,
334
+ name: 'model-assets', current, prepare, retain, release,
249
335
  start(serviceContext) { context = serviceContext; },
250
336
  methods: {
251
337
  'modelAssets.open': openProjection,
@@ -255,8 +341,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
255
341
  'modelAssets.release': {
256
342
  lifetime: 'service',
257
343
  handle({id}) {
258
- const record = projections.get(id);
259
- return record ? releasePreparation(record) : {id, state: 'released'};
344
+ return release(id);
260
345
  }
261
346
  }
262
347
  },