arcane-os 0.95.0 → 0.96.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.
@@ -25,10 +25,174 @@ function memberLocation(directory, member) {
25
25
  function sameMembers(left, right) {
26
26
  return is.array(left) && left.length === right.length
27
27
  && left.every(function sameMember(member, index) {
28
- return member.path === right[index].path && member.url === right[index].url;
28
+ return member.path === right[index].path && member.url === right[index].url
29
+ && member.archiveMember === right[index].archiveMember;
29
30
  });
30
31
  }
31
32
 
33
+ function selectedSources(members = [], archives = []) {
34
+ const selected = members.map(function ordinaryMember(member) { return {path: member.path, url: member.url}; });
35
+ for (const archive of archives) {
36
+ if (archive.format !== 'tar') throw new TypeError('Model asset archives require the uncompressed tar format.');
37
+ for (const member of archive.members) {
38
+ selected.push({path: member.path, url: archive.url, archiveMember: member.member});
39
+ }
40
+ }
41
+ return selected;
42
+ }
43
+
44
+ async function memberAvailability(directory, members, signal) {
45
+ const result = [];
46
+ for (const member of members) {
47
+ signal?.throwIfAborted();
48
+ const location = memberLocation(directory, member);
49
+ let available;
50
+ try { available = (await stat(location.nativePath)).isFile(); }
51
+ catch (error) { if (error.code !== 'ENOENT') throw error; available = false; }
52
+ result.push({...location, available});
53
+ }
54
+ return result;
55
+ }
56
+
57
+ function discoveryRecord(directory, members, source, catalog, entryPath) {
58
+ const missingMembers = members.filter(function missing(member) { return !member.available; })
59
+ .map(function memberPath(member) { return member.path; });
60
+ return {directory, entryPath: entryPath ?? members[0]?.path, members, missingMembers,
61
+ complete: missingMembers.length === 0, source, ...(catalog ? {catalogId: catalog.id} : {})};
62
+ }
63
+
64
+ function groupedMembers(members) {
65
+ const groups = [];
66
+ const shards = new Map();
67
+ for (const member of members) {
68
+ const filename = path.basename(member.path);
69
+ const split = /^(.*)-(\d+)-of-(\d+)(\.gguf)$/i.exec(filename);
70
+ if (!split) {
71
+ groups.push({members: [member], gguf: /\.gguf$/i.test(filename), entryPath: member.path});
72
+ continue;
73
+ }
74
+ const total = Number(split[3]);
75
+ if (!Number.isSafeInteger(total) || total < 1) {
76
+ throw failure('MODEL_ASSET_SHARDS_UNREADABLE', `The split GGUF member count is unreadable: ${member.path}`);
77
+ }
78
+ const directory = member.path.substring(0, member.path.length - filename.length);
79
+ const key = `${directory}${split[1]}-of-${split[3]}${split[4]}`;
80
+ if (!shards.has(key)) {
81
+ const group = {members: [], observed: new Map(), gguf: true, total, directory,
82
+ prefix: split[1], width: split[2].length, totalText: split[3], extension: split[4]};
83
+ shards.set(key, group);
84
+ groups.push(group);
85
+ }
86
+ shards.get(key).observed.set(Number(split[2]), member);
87
+ }
88
+ for (const group of shards.values()) {
89
+ for (let index = 1; index <= group.total; index += 1) {
90
+ group.members.push(group.observed.get(index) ?? {path: `${group.directory}${group.prefix}-${String(index).padStart(group.width, '0')}-of-${group.totalText}${group.extension}`});
91
+ }
92
+ // Preserve any supplied out-of-sequence member instead of dropping it.
93
+ for (const [index, member] of group.observed) if (index < 1 || index > group.total) group.members.push(member);
94
+ group.entryPath = group.members[0].path;
95
+ }
96
+ return groups;
97
+ }
98
+
99
+ function discoveryEntry(groups, selected) {
100
+ if (selected) {
101
+ const group = groups.find(function selectedGroup(candidate) {
102
+ return candidate.members.some(function selectedMember(member) { return member.path === selected; });
103
+ });
104
+ return group?.observed ? group.entryPath : selected;
105
+ }
106
+ return groups.find(function modelGroup(group) { return group.gguf; })?.entryPath ?? groups[0]?.entryPath;
107
+ }
108
+
109
+ async function discoverDirectories(appRoot, {directories, catalog = [], recursive = true, signal} = {}) {
110
+ if (!is.array(directories)) throw new TypeError('Model discovery requires explicit directories.');
111
+ const roots = [];
112
+ const models = [];
113
+ const visited = new Set();
114
+ const candidates = new Set();
115
+ const claimed = new Set();
116
+ const selections = catalog.map(function catalogSelection(item) {
117
+ return {id: item.id, entryPath: item.entryPath, members: selectedSources(item.members, item.archives)};
118
+ });
119
+
120
+ function add(record) {
121
+ const key = JSON.stringify([record.directory, record.members.map(function memberPath(member) { return member.path; })]);
122
+ if (candidates.has(key)) return;
123
+ candidates.add(key);
124
+ models.push(record);
125
+ }
126
+
127
+ async function scan(directory, root = false) {
128
+ signal?.throwIfAborted();
129
+ let entries;
130
+ try { entries = await readdir(directory, {withFileTypes: true}); }
131
+ catch (error) {
132
+ if (!root) throw error;
133
+ roots.push({directory, exists: error.code !== 'ENOENT', ...(error.code === 'ENOENT' ? {} : {error: serializeCoreError(error)})});
134
+ return;
135
+ }
136
+ if (root) roots.push({directory, exists: true});
137
+ const canonical = await realpath(directory);
138
+ if (visited.has(canonical)) return;
139
+ visited.add(canonical);
140
+ entries.sort(function byName(left, right) { return left.name.localeCompare(right.name); });
141
+ const linked = new Map();
142
+ for (const entry of entries) {
143
+ if (!entry.isSymbolicLink()) continue;
144
+ signal?.throwIfAborted();
145
+ try { linked.set(entry, await stat(path.join(directory, entry.name))); }
146
+ catch (error) { if (error.code !== 'ENOENT') throw error; linked.set(entry, null); }
147
+ }
148
+ let acquisition;
149
+ if (path.basename(directory).startsWith('acquisition-')) {
150
+ try { acquisition = JSON.parse(await readFile(path.join(directory, 'acquisition.json'), {encoding: 'utf8', signal})); }
151
+ catch (error) {
152
+ if (error.code !== 'ENOENT') throw error;
153
+ if (path.basename(path.dirname(directory)) === 'model-assets') return;
154
+ }
155
+ }
156
+ if (acquisition) {
157
+ const files = path.join(directory, 'files');
158
+ const groups = groupedMembers(acquisition.members);
159
+ const members = await memberAvailability(files, groups.flatMap(function completeGroup(group) { return group.members; }), signal);
160
+ const selection = selections.find(function matching(item) {
161
+ return item.members.length === acquisition.members.length && item.members.every(function samePath(member, index) {
162
+ const original = acquisition.members[index];
163
+ return member.path === original.path && (member.url === undefined || member.url === original.url)
164
+ && (member.archiveMember === undefined || member.archiveMember === original.archiveMember);
165
+ });
166
+ });
167
+ add(discoveryRecord(files, members, 'acquisition', selection, discoveryEntry(groups, selection?.entryPath)));
168
+ return;
169
+ }
170
+ for (const selection of selections) {
171
+ const groups = groupedMembers(selection.members);
172
+ const members = await memberAvailability(directory, groups.flatMap(function completeGroup(group) { return group.members; }), signal);
173
+ if (!members.some(function present(member) { return member.available; })) continue;
174
+ add(discoveryRecord(directory, members, 'catalog', selection, discoveryEntry(groups, selection.entryPath)));
175
+ for (const member of members) claimed.add(member.nativePath);
176
+ }
177
+ const ggufMembers = entries.filter(function gguf(entry) {
178
+ const file = linked.has(entry) ? linked.get(entry)?.isFile() ?? true : entry.isFile();
179
+ return file && entry.name.toLowerCase().endsWith('.gguf') && !claimed.has(path.join(directory, entry.name));
180
+ }).map(function memberPath(entry) { return {path: entry.name}; });
181
+ for (const group of groupedMembers(ggufMembers)) {
182
+ add(discoveryRecord(directory, await memberAvailability(directory, group.members, signal), 'directory', null, group.entryPath));
183
+ }
184
+ if (recursive) {
185
+ for (const entry of entries) {
186
+ if (linked.has(entry) ? linked.get(entry)?.isDirectory() : entry.isDirectory()) await scan(path.join(directory, entry.name));
187
+ }
188
+ }
189
+ }
190
+
191
+ for (const directory of directories) await scan(path.resolve(appRoot, directory), true);
192
+ signal?.throwIfAborted();
193
+ return {directories: roots, models};
194
+ }
195
+
32
196
  async function storedAcquisition(directory, members, signal) {
33
197
  let latest;
34
198
  for (const entry of await readdir(directory, {withFileTypes: true})) {
@@ -56,6 +220,151 @@ async function storedAcquisition(directory, members, signal) {
56
220
  return latest ? {directory: latest.directory, members: latest.members} : null;
57
221
  }
58
222
 
223
+ async function fetchModelResponse(url, signal, range) {
224
+ const response = await fetch(url, {signal, ...(range ? {headers: {Range: `bytes=${range.start}-${range.end}`}} : {})});
225
+ if (!response.ok) {
226
+ const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
227
+ error.url = url;
228
+ error.status = response.status;
229
+ error.response = await response.text();
230
+ throw error;
231
+ }
232
+ if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${url}`);
233
+ if (range) {
234
+ const contentRange = /^bytes (\d+)-(\d+)\/(?:\d+|\*)$/.exec(response.headers.get('content-range') ?? '');
235
+ if (response.status !== 206 || !contentRange || Number(contentRange[1]) !== range.start || Number(contentRange[2]) !== range.end) {
236
+ const error = failure('MODEL_ASSET_ARCHIVE_RANGE_FAILED', 'The archive server did not return the requested TAR segment.');
237
+ error.url = url;
238
+ error.status = response.status;
239
+ try { await response.body.cancel(error); }
240
+ catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Rejecting an unusable HTTP range and closing its response failed.'); }
241
+ throw error;
242
+ }
243
+ }
244
+ return response;
245
+ }
246
+
247
+ function tarName(name) { return name.replace(/^(?:\.\/)+/, ''); }
248
+
249
+ function tarText(header, start, end) {
250
+ const field = header.subarray(start, end);
251
+ const terminator = field.indexOf(0);
252
+ return field.subarray(0, terminator === -1 ? field.length : terminator).toString('utf8');
253
+ }
254
+
255
+ function paxFields(content) {
256
+ const fields = {};
257
+ let offset = 0;
258
+ while (offset < content.length) {
259
+ const space = content.indexOf(32, offset);
260
+ const length = Number(content.subarray(offset, space).toString('ascii'));
261
+ if (space === -1 || !Number.isSafeInteger(length) || length <= space - offset + 1 || offset + length > content.length) {
262
+ throw failure('MODEL_ASSET_ARCHIVE_FORMAT', 'The TAR PAX record is incomplete or unreadable.');
263
+ }
264
+ const record = content.subarray(space + 1, offset + length - 1).toString('utf8');
265
+ const equals = record.indexOf('=');
266
+ if (equals !== -1) fields[record.substring(0, equals)] = record.substring(equals + 1);
267
+ offset += length;
268
+ }
269
+ return fields;
270
+ }
271
+
272
+ // TAR framing and HTTP range offsets stay inside this transport reader. They
273
+ // never describe model readiness, download progress or content identity.
274
+ async function extractTarRange(response, range, selected, found, entry, progress, completeMember) {
275
+ const {signal} = entry.controller;
276
+ const reader = response.body.getReader();
277
+ let pending = Buffer.alloc(0);
278
+ let ended = false;
279
+ let localPax = {};
280
+ let globalPax = {};
281
+ let operationError;
282
+
283
+ async function consume(length, consumePart, allowEnd = false) {
284
+ let remaining = length;
285
+ while (remaining) {
286
+ signal.throwIfAborted();
287
+ if (!pending.length && !ended) {
288
+ const chunk = await reader.read();
289
+ ended = chunk.done;
290
+ if (!ended) pending = Buffer.from(chunk.value);
291
+ }
292
+ if (!pending.length && ended) {
293
+ if (allowEnd && remaining === length) return false;
294
+ throw failure('MODEL_ASSET_ARCHIVE_INCOMPLETE', 'The TAR segment ended inside a member or header.');
295
+ }
296
+ const consumed = Math.min(remaining, pending.length);
297
+ if (consumePart) await consumePart(pending.subarray(0, consumed));
298
+ pending = pending.subarray(consumed);
299
+ remaining -= consumed;
300
+ }
301
+ return true;
302
+ }
303
+
304
+ try {
305
+ while (true) {
306
+ const headers = [];
307
+ if (!await consume(512, function headerPart(part) { headers.push(part); }, true)) break;
308
+ const header = Buffer.concat(headers);
309
+ if (header.every(function empty(value) { return value === 0; })) break;
310
+ const type = String.fromCharCode(header[156]);
311
+ const prefix = tarText(header, 345, 500);
312
+ const name = tarText(header, 0, 100);
313
+ const framingLength = Number.parseInt(tarText(header, 124, 136).trim(), 8);
314
+ const length = type === 'x' || type === 'g' ? framingLength : Number(localPax.size ?? globalPax.size ?? framingLength);
315
+ if (!Number.isSafeInteger(length) || length < 0) throw failure('MODEL_ASSET_ARCHIVE_FORMAT', 'The TAR member has unreadable framing.');
316
+ if (type === 'x' || type === 'g') {
317
+ const parts = [];
318
+ await consume(length, function paxPart(part) { parts.push(part); });
319
+ const fields = paxFields(Buffer.concat(parts));
320
+ if (type === 'g') globalPax = {...globalPax, ...fields};
321
+ else localPax = fields;
322
+ await consume((512 - length % 512) % 512);
323
+ continue;
324
+ }
325
+ const memberName = tarName(localPax.path ?? globalPax.path ?? (prefix ? `${prefix}/${name}` : name));
326
+ localPax = {};
327
+ if (range?.stopBefore && (memberName === range.stopBefore || path.posix.basename(memberName) === range.stopBefore)) break;
328
+ const memberIndexes = selected.get(memberName);
329
+ if (memberIndexes && (type === '0' || type === '\0')) {
330
+ if (memberIndexes.some(function alreadyRead(index) { return found.has(index); })) {
331
+ throw failure('MODEL_ASSET_ARCHIVE_FORMAT', `The selected TAR member occurs more than once: ${memberName}`);
332
+ }
333
+ const handles = [];
334
+ const errors = [];
335
+ try {
336
+ for (const memberIndex of memberIndexes) {
337
+ const location = entry.location.members[memberIndex];
338
+ await mkdir(path.dirname(location.nativePath), {recursive: true});
339
+ handles.push(await open(location.nativePath, 'wx'));
340
+ progress('download', memberIndex);
341
+ }
342
+ await consume(length, async function originalMemberContent(content) {
343
+ const writes = await Promise.allSettled(handles.map(function appendOriginal(handle) { return handle.appendFile(content); }));
344
+ const failures = writes.filter(function failed(result) { return result.status === 'rejected'; }).map(function reason(result) { return result.reason; });
345
+ if (failures.length) throw new AggregateError(failures, 'Writing selected TAR member destinations failed.');
346
+ for (const memberIndex of memberIndexes) progress('download', memberIndex, true);
347
+ });
348
+ } catch (error) { errors.push(error); }
349
+ const closes = await Promise.allSettled(handles.map(function closeOriginal(handle) { return handle.close(); }));
350
+ for (const result of closes) if (result.status === 'rejected') errors.push(result.reason);
351
+ if (errors.length === 1) throw errors[0];
352
+ if (errors.length) throw new AggregateError(errors, 'Extracting a model member and closing its files failed.');
353
+ for (const memberIndex of memberIndexes) {
354
+ found.add(memberIndex);
355
+ completeMember(memberIndex);
356
+ }
357
+ } else await consume(length);
358
+ await consume((512 - length % 512) % 512);
359
+ }
360
+ } catch (error) { operationError = error; }
361
+ try { if (!ended) await reader.cancel(operationError); }
362
+ catch (error) { operationError = operationError ? new AggregateError([operationError, error], 'Reading a TAR segment and closing its response failed.') : error; }
363
+ try { reader.releaseLock(); }
364
+ catch (error) { operationError = operationError ? new AggregateError([operationError, error], 'Reading a TAR segment and releasing its reader failed.') : error; }
365
+ if (operationError) throw operationError;
366
+ }
367
+
59
368
  async function acquireNativeMembers(entry) {
60
369
  const {directory, members, controller} = entry;
61
370
  const {signal} = controller;
@@ -63,7 +372,13 @@ async function acquireNativeMembers(entry) {
63
372
  let completed = 0;
64
373
  let committed = false;
65
374
  let firstFailure;
66
- function progress(phase, memberIndex) {
375
+ const activity = new Map();
376
+ function progress(phase, memberIndex, repeatedActivity = false) {
377
+ if (repeatedActivity) {
378
+ const now = Date.now();
379
+ if (now - (activity.get(memberIndex) ?? 0) < 1000) return;
380
+ activity.set(memberIndex, now);
381
+ }
67
382
  entry.publish({phase, completed, total: members.length, unit: 'files',
68
383
  ...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path})});
69
384
  }
@@ -83,55 +398,92 @@ async function acquireNativeMembers(entry) {
83
398
  await mkdir(files);
84
399
  entry.location = {directory: files, members: members.map(function selectedMember(member) { return memberLocation(files, member); })};
85
400
  progress('open');
86
- const results = await Promise.allSettled(members.map(async function downloadMember(member, memberIndex) {
87
- let handle;
88
- let response;
89
- let downloadError;
90
- const failures = [];
91
- let bodyOwned = false;
92
- try {
93
- signal.throwIfAborted();
94
- const nativePath = entry.location.members[memberIndex].nativePath;
95
- await mkdir(path.dirname(nativePath), {recursive: true});
96
- handle = await open(nativePath, 'wx');
97
- signal.throwIfAborted();
98
- progress('download', memberIndex);
99
- response = await fetch(member.url, {signal});
100
- if (!response.ok) {
101
- const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
102
- error.url = member.url;
103
- error.status = response.status;
104
- error.response = await response.text();
105
- throw error;
106
- }
107
- if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
108
- bodyOwned = true;
109
- for await (const content of response.body) {
401
+ function completeMember(memberIndex) {
402
+ completed += 1;
403
+ progress('download', memberIndex);
404
+ }
405
+ const jobs = members.map(function ordinaryJob(member, memberIndex) {
406
+ if (member.archiveMember !== undefined) return null;
407
+ return async function downloadMember() {
408
+ let handle;
409
+ let response;
410
+ let downloadError;
411
+ const failures = [];
412
+ let bodyOwned = false;
413
+ try {
414
+ signal.throwIfAborted();
415
+ const nativePath = entry.location.members[memberIndex].nativePath;
416
+ await mkdir(path.dirname(nativePath), {recursive: true});
417
+ handle = await open(nativePath, 'wx');
110
418
  signal.throwIfAborted();
111
- await handle.appendFile(content);
112
419
  progress('download', memberIndex);
420
+ response = await fetchModelResponse(member.url, signal);
421
+ bodyOwned = true;
422
+ for await (const content of response.body) {
423
+ signal.throwIfAborted();
424
+ await handle.appendFile(content);
425
+ progress('download', memberIndex, true);
426
+ }
427
+ signal.throwIfAborted();
428
+ } catch (error) {
429
+ downloadError = error;
430
+ failures.push(error);
431
+ firstFailure ??= error;
432
+ controller.abort(error);
113
433
  }
114
- signal.throwIfAborted();
115
- } catch (error) {
116
- downloadError = error;
117
- failures.push(error);
118
- firstFailure ??= error;
119
- controller.abort(error);
120
- }
121
- if (response?.body && !bodyOwned && !response.bodyUsed && !response.body.locked) {
122
- try { await response.body.cancel(downloadError); } catch (error) { if (error !== downloadError) failures.push(error); }
123
- }
124
- if (handle) {
125
- try { await handle.close(); } catch (error) { failures.push(error); }
434
+ if (response?.body && !bodyOwned && !response.bodyUsed && !response.body.locked) {
435
+ try { await response.body.cancel(downloadError); } catch (error) { if (error !== downloadError) failures.push(error); }
436
+ }
437
+ if (handle) {
438
+ try { await handle.close(); } catch (error) { failures.push(error); }
439
+ }
440
+ if (failures.length) {
441
+ const error = failures.length === 1 ? failures[0] : new AggregateError(failures, 'Downloading a model member and closing its resources failed.');
442
+ firstFailure ??= error;
443
+ controller.abort(error);
444
+ throw error;
445
+ }
446
+ completeMember(memberIndex);
447
+ };
448
+ }).filter(Boolean);
449
+ let archiveIndex = members.findIndex(function archiveMember(member) { return member.archiveMember !== undefined; });
450
+ for (const archive of entry.archives) {
451
+ const selected = new Map();
452
+ for (const member of archive.members) {
453
+ const name = tarName(member.member);
454
+ if (!selected.has(name)) selected.set(name, []);
455
+ selected.get(name).push(archiveIndex++);
126
456
  }
127
- if (failures.length) {
128
- const error = failures.length === 1 ? failures[0] : new AggregateError(failures, 'Downloading a model member and closing its resources failed.');
129
- firstFailure ??= error;
130
- controller.abort(error);
131
- throw error;
457
+ jobs.push(async function downloadArchiveMembers() {
458
+ const found = new Set();
459
+ try {
460
+ for (const range of archive.ranges ?? [undefined]) {
461
+ signal.throwIfAborted();
462
+ const response = await fetchModelResponse(archive.url, signal, range);
463
+ await extractTarRange(response, range, selected, found, entry, progress, completeMember);
464
+ }
465
+ const missing = [...selected.entries()].filter(function absent([name, indexes]) {
466
+ return indexes.some(function missingDestination(index) { return !found.has(index); });
467
+ })
468
+ .map(function memberName([name]) { return name; });
469
+ if (missing.length) {
470
+ const error = failure('MODEL_ASSET_ARCHIVE_MEMBERS_MISSING', 'The archive did not provide every selected model member.');
471
+ error.members = missing;
472
+ throw error;
473
+ }
474
+ } catch (error) {
475
+ firstFailure ??= error;
476
+ controller.abort(error);
477
+ throw error;
478
+ }
479
+ });
480
+ }
481
+ let nextJob = 0;
482
+ const results = await Promise.allSettled(Array.from({length: Math.min(4, jobs.length)}, async function downloadWorker() {
483
+ while (nextJob < jobs.length) {
484
+ signal.throwIfAborted();
485
+ await jobs[nextJob++]();
132
486
  }
133
- completed += 1;
134
- progress('download', memberIndex);
135
487
  }));
136
488
  const failures = results.filter(function rejected(result) { return result.status === 'rejected'; })
137
489
  .map(function reason(result) { return result.reason; });
@@ -157,17 +509,17 @@ async function acquireNativeMembers(entry) {
157
509
  }
158
510
  }
159
511
 
160
- function shareNativeAcquisition({directory, members, refresh, signal, onProgress}) {
512
+ function shareNativeAcquisition({directory, members, archives, refresh, signal, onProgress}) {
161
513
  signal.throwIfAborted();
162
514
  const key = JSON.stringify([directory, members, refresh]);
163
515
  let entry = nativeAcquisitions.get(key);
164
516
  if (entry?.controller.signal.aborted) {
165
517
  return entry.task.catch(function observeRetiringAcquisition() {}).then(function acquireAfterDrain() {
166
- return shareNativeAcquisition({directory, members, refresh, signal, onProgress});
518
+ return shareNativeAcquisition({directory, members, archives, refresh, signal, onProgress});
167
519
  });
168
520
  }
169
521
  if (!entry) {
170
- entry = {directory, members, refresh, controller: new AbortController(), consumers: new Set(), task: null, location: null, progress: null};
522
+ entry = {directory, members, archives, refresh, controller: new AbortController(), consumers: new Set(), task: null, location: null, progress: null};
171
523
  nativeAcquisitions.set(key, entry);
172
524
  entry.publish = function publishProgress(progress) {
173
525
  entry.progress = progress;
@@ -228,6 +580,7 @@ function shareNativeAcquisition({directory, members, refresh, signal, onProgress
228
580
  /** Temporary browser projections and persistent selected native acquisitions. */
229
581
  export function createModelAssetService({appRoot = process.cwd()} = {}) {
230
582
  const projections = new Map();
583
+ const preparations = new Map();
231
584
  let context;
232
585
  let closing;
233
586
 
@@ -403,13 +756,11 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
403
756
  return task;
404
757
  }
405
758
 
406
- async function prepare({id, workingDirectory, members, signal, onProgress, refresh = false} = {}) {
759
+ async function prepare({id, workingDirectory, members = [], archives = [], signal, onProgress, refresh = false} = {}) {
407
760
  const controller = new AbortController();
408
761
  const operationSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
409
- const record = beginProjection({id, workingDirectory, members}, operationSignal);
410
- let selectedMembers;
411
- try { selectedMembers = members.map(function selectedMember(member) { return {path: member.path, url: member.url}; }); }
412
- catch (error) { return failedOperation(record, error); }
762
+ const selectedMembers = selectedSources(members, archives);
763
+ const record = beginProjection({id, workingDirectory, members: selectedMembers}, operationSignal);
413
764
  record.persistent = true;
414
765
  record.downloadController = controller;
415
766
  // This task owns only acquisition. Failure cleanup may join it without
@@ -420,7 +771,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
420
771
  await mkdir(selectedDirectory, {recursive: true});
421
772
  const directory = await realpath(selectedDirectory);
422
773
  operationSignal.throwIfAborted();
423
- return shareNativeAcquisition({directory, members: selectedMembers, refresh: refresh === true, signal: operationSignal,
774
+ return shareNativeAcquisition({directory, members: selectedMembers, archives, refresh: refresh === true, signal: operationSignal,
424
775
  async onProgress(value, location) {
425
776
  if (location) {
426
777
  record.directory = location.directory;
@@ -444,6 +795,84 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
444
795
  }
445
796
  }
446
797
 
798
+ function discover(options) { return discoverDirectories(appRoot, options); }
799
+
800
+ async function openExisting({id, directory, members, signal} = {}) {
801
+ const controller = new AbortController();
802
+ const operationSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
803
+ const record = beginProjection({id, workingDirectory: directory, members}, operationSignal);
804
+ // Borrowed originals never become disposable working files, even when
805
+ // opening fails or the caller releases while filesystem I/O is pending.
806
+ record.persistent = true;
807
+ record.downloadController = controller;
808
+ record.opening = Promise.resolve().then(async function inspectExistingMembers() {
809
+ record.directory = path.resolve(appRoot, directory);
810
+ const locations = await memberAvailability(record.directory, members, operationSignal);
811
+ const missing = locations.filter(function absent(member) { return !member.available; });
812
+ if (missing.length) {
813
+ const error = failure('MODEL_ASSET_MEMBERS_MISSING', 'The selected model has missing members.');
814
+ error.members = missing.map(function memberPath(member) { return member.path; });
815
+ throw error;
816
+ }
817
+ record.members = locations;
818
+ });
819
+ publish(record);
820
+ try {
821
+ await record.opening;
822
+ operationSignal.throwIfAborted();
823
+ record.state = 'ready';
824
+ publish(record);
825
+ return snapshot(record);
826
+ } catch (error) { return failedOperation(record, error); }
827
+ }
828
+
829
+ function prepareRequest(params, request) {
830
+ if (preparations.has(params.id)) throw failure('MODEL_ASSET_PROJECTION_ACTIVE', `Model asset preparation ${params.id} already exists.`);
831
+ const controller = new AbortController();
832
+ const signal = AbortSignal.any([request.signal, controller.signal]);
833
+ const operation = {controller, signal, task: null};
834
+ preparations.set(params.id, operation);
835
+ operation.task = prepare({...params, signal,
836
+ onProgress(progress) { request.emit?.('modelAssets.progress', {id: params.id, progress}); }
837
+ });
838
+ // Retain failed completion until the caller's explicit cleanup control
839
+ // joins it; local RPC cancellation alone settles before native cleanup.
840
+ operation.task.then(function prepared() {
841
+ preparations.delete(params.id);
842
+ }, function preparationFailed() {
843
+ if (!signal.aborted) preparations.delete(params.id);
844
+ });
845
+ return operation.task;
846
+ }
847
+
848
+ function cancellationOnly(error, reason) {
849
+ return error === reason || (error instanceof AggregateError && error.errors.length > 0
850
+ && error.errors.every(function cancellation(member) { return cancellationOnly(member, reason); }));
851
+ }
852
+
853
+ async function cancelPrepare({id}) {
854
+ const operation = preparations.get(id);
855
+ const errors = [];
856
+ if (operation) {
857
+ operation.controller.abort(failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was cancelled.'));
858
+ try { await operation.task; }
859
+ catch (error) { if (!cancellationOnly(error, operation.signal.reason)) errors.push(error); }
860
+ preparations.delete(id);
861
+ }
862
+ try { await release(id); } catch (error) { errors.push(error); }
863
+ if (errors.length === 1) throw errors[0];
864
+ if (errors.length) throw new AggregateError(errors, 'Cancelling native model preparation failed.');
865
+ return {id, state: 'released'};
866
+ }
867
+
868
+ async function releaseRequest({id}) {
869
+ const operation = preparations.get(id);
870
+ if (operation && projections.get(id)?.state !== 'ready') return cancelPrepare({id});
871
+ const result = await release(id);
872
+ preparations.delete(id);
873
+ return result;
874
+ }
875
+
447
876
  async function completeProjection({id}, request) {
448
877
  const record = requireProjection(id);
449
878
  await record.opening;
@@ -505,24 +934,27 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
505
934
  const results = await Promise.allSettled(records.map(function awaitRelease(record) { return record.released; }));
506
935
  const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
507
936
  .map(function reason(result) { return result.reason; });
937
+ preparations.clear();
508
938
  if (errors.length) throw new AggregateError(errors, 'Closing model asset preparation failed.');
509
939
  });
510
940
  return closing;
511
941
  }
512
942
 
513
943
  return {
514
- name: 'model-assets', current, prepare, retain, release,
944
+ name: 'model-assets', current, discover, openExisting, prepare, retain, release,
515
945
  start(serviceContext) { context = serviceContext; },
516
946
  methods: {
517
947
  'modelAssets.open': openProjection,
948
+ 'modelAssets.discover': function discoverRequest(params, request) { return discover({...params, signal: request.signal}); },
949
+ 'modelAssets.openExisting': function openExistingRequest(params, request) { return openExisting({...params, signal: request.signal}); },
950
+ 'modelAssets.prepare': prepareRequest,
951
+ 'modelAssets.cancelPrepare': {lifetime: 'service', handle: cancelPrepare},
518
952
  'modelAssets.write': writeMember,
519
953
  'modelAssets.complete': completeProjection,
520
954
  'modelAssets.status': function status() { return current(); },
521
955
  'modelAssets.release': {
522
956
  lifetime: 'service',
523
- handle({id}) {
524
- return release(id);
525
- }
957
+ handle: releaseRequest
526
958
  }
527
959
  },
528
960
  dispose
@@ -42,7 +42,7 @@ function waitForLoad(task, signal) {
42
42
  }
43
43
 
44
44
  /** Hosts supply selected engines; this service owns their independent lifetimes. */
45
- export function createSpeechService({stt, tts, signal} = {}) {
45
+ export function createSpeechService({stt, tts, signal, autoLoad = true} = {}) {
46
46
  const roles = {
47
47
  stt: {engine: stt, operation: 'transcribe', state: null, loading: null, changing: null, unsubscribe: null},
48
48
  tts: {engine: tts, operation: 'synthesize', state: null, loading: null, changing: null, unsubscribe: null}
@@ -177,7 +177,7 @@ export function createSpeechService({stt, tts, signal} = {}) {
177
177
  return;
178
178
  }
179
179
  // Replay can synchronously select an activation or close the service.
180
- if (role.changing || closing || closed) return;
180
+ if (role.changing || closing || closed || autoLoad === false) return;
181
181
  const task = Promise.resolve().then(
182
182
  async function loadSpeechEngine() {
183
183
  lifetimeSignal.throwIfAborted();