arcane-os 0.1.2 → 0.2.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 (52) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/NOTICE +5 -3
  3. package/README.md +73 -24
  4. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +67 -18
  5. package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +16 -5
  6. package/browser-runtime/ai/browser-kokoro-worker.mjs +3 -0
  7. package/browser-runtime/ai/browser-speech-artifacts.mjs +1108 -0
  8. package/browser-runtime/ai/browser-speech-providers.mjs +475 -0
  9. package/browser-runtime/ai/browser-speech.mjs +9 -0
  10. package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1537 -167
  11. package/browser-runtime/ai/browser-wasm.mjs +46 -1
  12. package/browser-runtime/ai/browser-whisper-worker.mjs +3 -0
  13. package/browser-runtime/ai/browser-wllama-runtime.mjs +677 -132
  14. package/browser-runtime/ai/model-controller.mjs +138 -12
  15. package/browser-runtime/ai/speech-worker-client.mjs +207 -0
  16. package/browser-runtime/ai/speech-worker-runtime.mjs +516 -0
  17. package/browser-runtime/ai/wllama/index.mjs +389 -0
  18. package/docs/architecture.md +132 -22
  19. package/docs/reference/README.md +1 -1
  20. package/docs/reference/ai/browser-wasm.md +101 -42
  21. package/docs/reference/availability-and-normalization.md +19 -5
  22. package/docs/reference/behavioral-testing.md +18 -5
  23. package/docs/reference/cli.md +2 -2
  24. package/docs/reference/inventory/package-api.json +14 -14
  25. package/docs/reference/protocols.md +4 -4
  26. package/docs/reference/sdk-api.md +68 -38
  27. package/docs/work-amplification.md +8 -4
  28. package/package.json +7 -3
  29. package/runtime/ARCANE_RUNTIME_RELEASE.json +50 -20
  30. package/runtime/arcane/components/chat.html +280 -62
  31. package/runtime/arcane/components/speech.html +1113 -265
  32. package/runtime/arcane/entities/Chat.js +246 -43
  33. package/runtime/arcane/modules/AI.js +713 -162
  34. package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
  35. package/runtime/arcane/modules/AIRuntimeState.js +872 -0
  36. package/runtime/arcane/modules/ConfiguredAIChatSession.js +293 -27
  37. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +682 -0
  38. package/runtime/arcane/modules/DocumentLexicalSearch.js +292 -0
  39. package/runtime/arcane/modules/PersistentAIChatSession.js +268 -0
  40. package/runtime/arcane/modules/StaticDocumentCatalog.js +25 -206
  41. package/schemas/arcane-lock.schema.json +6 -4
  42. package/src/cli/main.mjs +14 -2
  43. package/src/constants.mjs +1 -1
  44. package/src/dev-server.mjs +244 -13
  45. package/src/import-map.mjs +59 -1
  46. package/src/packager/core.mjs +2 -2
  47. package/src/runtime.mjs +14 -4
  48. package/src/sdk-browser-runtime.mjs +28 -75
  49. package/src/templates/workspace-template.mjs +4 -4
  50. package/src/toolchain.mjs +3 -0
  51. package/src/workspace-runtime.mjs +1 -1
  52. package/src/workspace.mjs +1 -1
@@ -1,16 +1,35 @@
1
1
  import {
2
2
  ARCANE_AI_ADAPTER_PROTOCOL,
3
3
  ArcaneAIError,
4
+ normalizeModelSecurity,
4
5
  normalizeArcaneAIError,
6
+ resolveModelSecurity,
7
+ sameModelSecurity,
5
8
  } from "./model-controller.mjs";
6
9
  import { createPackagedWllamaRuntime } from "./browser-wllama-runtime.mjs";
7
10
  import { createStreamingSha256 } from "./internal/sha256.mjs";
11
+ import { arcaneEvents } from "../event-manager.mjs";
8
12
 
9
- const MODEL_MANIFEST_SCHEMA = "arcane.ai.browser-wasm.model.v2";
13
+ const MODEL_MANIFEST_SCHEMA = "arcane.ai.browser-wasm.model.v4";
14
+ const SINGLE_MODEL_MANIFEST_SCHEMA = "arcane.ai.browser-wasm.model.v3";
15
+ const LEGACY_MODEL_MANIFEST_SCHEMA = "arcane.ai.browser-wasm.model.v2";
10
16
  const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
11
17
  const MUTABLE_PATH_PATTERN = /\/(?:resolve\/)?(?:main|master|latest)(?:\/|$)/iu;
12
18
  const BROWSER_MODEL_SOURCES = new WeakSet();
19
+ const BROWSER_MODEL_SOURCE_METADATA = new WeakMap();
20
+ const MODEL_DESCRIPTOR_METADATA = new WeakMap();
13
21
  const DBOPFS_MODEL_STORES = new WeakSet();
22
+ const V1_LLM_PROVIDER_ADAPTERS = new WeakMap();
23
+ const AI_PROVIDER_PROTOCOL = "arcane-ai-provider/2";
24
+ const AI_MODEL_AUTHORITY_PROTOCOL = "arcane-ai-model-authority/1";
25
+ const WEBGPU_ADAPTER_SELECTED_EVENT = "arcane.ai.browser-wasm.webgpu.adapter.selected";
26
+ const WEBGPU_ADAPTER_SELECTION_PROTOCOL = "arcane-ai-webgpu-adapter-selection/1";
27
+ const CHROME_HIGH_PERFORMANCE_GPU_FLAG_URL =
28
+ "chrome://flags/#force-high-performance-gpu";
29
+ const INTEL_VENDOR_ID = 0x8086;
30
+ const CAPABILITY_POLICY_PROTOCOL = "arcane-ai-browser-capability-policy/1";
31
+ const WLLAMA_MAX_FILE_BYTES = 2_000_000_000;
32
+ let highPerformanceGpuNoticeShown = false;
14
33
 
15
34
  function fail(code, message, cause) {
16
35
  return new ArcaneAIError(code, message, {
@@ -29,6 +48,10 @@ function throwIfAborted(signal, operation = "request") {
29
48
  );
30
49
  }
31
50
 
51
+ function normalizationSignal(error, signal) {
52
+ return error?.code === "ARCANE_AI_WORKER_TERMINATION_UNCONFIRMED" ? null : signal;
53
+ }
54
+
32
55
  function immutableHttpsUrl(value) {
33
56
  let url;
34
57
  try {
@@ -53,63 +76,251 @@ function requiredText(value, field) {
53
76
  return value.trim();
54
77
  }
55
78
 
79
+ function modelIdText(value) {
80
+ const id = requiredText(value, "id");
81
+ for (let index = 0; index < id.length; index += 1) {
82
+ const code = id.charCodeAt(index);
83
+ if (code >= 0xd800 && code <= 0xdbff) {
84
+ const next = id.charCodeAt(index + 1);
85
+ if (!(next >= 0xdc00 && next <= 0xdfff)) {
86
+ throw new TypeError("Browser model id must contain only Unicode scalar values.");
87
+ }
88
+ index += 1;
89
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
90
+ throw new TypeError("Browser model id must contain only Unicode scalar values.");
91
+ }
92
+ }
93
+ if (id.normalize("NFC") !== id) {
94
+ throw new TypeError("Browser model id must use Unicode NFC normalization.");
95
+ }
96
+ return id;
97
+ }
98
+
99
+ function descriptorFileName(value, url, fallbackName = null) {
100
+ const suppliedName = value.name;
101
+ let name = suppliedName;
102
+ if (name === undefined) {
103
+ const encoded = url.pathname.split("/").filter(Boolean).pop() ?? "";
104
+ try {
105
+ name = decodeURIComponent(encoded);
106
+ } catch {
107
+ name = "";
108
+ }
109
+ if (!name && fallbackName) name = fallbackName;
110
+ }
111
+ name = requiredText(name, "file name");
112
+ if (
113
+ (suppliedName !== undefined && suppliedName !== name)
114
+ || name !== name.split(/[\\/]/u).pop()
115
+ || name === "."
116
+ || name === ".."
117
+ || name.endsWith(".")
118
+ || name.endsWith(" ")
119
+ || /[<>:"|?*]/u.test(name)
120
+ || /[\u0000-\u001f\u007f]/u.test(name)
121
+ ) {
122
+ throw new TypeError("Browser model file names must be safe single filenames.");
123
+ }
124
+ return name;
125
+ }
126
+
127
+ function descriptorFile(value, { fallbackName = null } = {}) {
128
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
129
+ throw new TypeError("Each browser model file descriptor must be an object.");
130
+ }
131
+ if (
132
+ value.url !== undefined
133
+ && value.immutableUrl !== undefined
134
+ && value.url !== value.immutableUrl
135
+ ) {
136
+ throw new TypeError("Browser model file url and legacy immutableUrl must match when both are provided.");
137
+ }
138
+ const url = immutableHttpsUrl(value.url ?? value.immutableUrl);
139
+ if (!url) {
140
+ throw new TypeError("Browser model file url must be immutable HTTPS without credentials or fragments.");
141
+ }
142
+ const file = {
143
+ name: descriptorFileName(value, url, fallbackName),
144
+ url: url.href,
145
+ };
146
+ if (value.bytes !== undefined) {
147
+ if (!Number.isSafeInteger(value.bytes) || value.bytes < 1) {
148
+ throw new TypeError("Browser model file bytes must be a positive safe integer when provided.");
149
+ }
150
+ file.bytes = value.bytes;
151
+ }
152
+ if (value.sha256 !== undefined) {
153
+ const sha256 = requiredText(value.sha256, "file sha256").toLowerCase();
154
+ if (!SHA256_PATTERN.test(sha256)) {
155
+ throw new TypeError("Browser model file sha256 must be exactly 64 hexadecimal characters when provided.");
156
+ }
157
+ file.sha256 = sha256;
158
+ }
159
+ return Object.freeze(file);
160
+ }
161
+
56
162
  function modelDescriptor(value) {
57
163
  if (!value || typeof value !== "object" || Array.isArray(value)) {
58
164
  throw new TypeError("A browser model descriptor is required.");
59
165
  }
60
- const id = requiredText(value.id, "id");
61
- const name = requiredText(value.name, "name");
62
- if (name !== name.split(/[\\/]/u).pop() || name === "." || name === "..") {
63
- throw new TypeError("Browser model name must be a single filename.");
166
+ const id = modelIdText(value.id);
167
+ const hasFiles = value.files !== undefined;
168
+ const hasLegacyFile = ["url", "immutableUrl", "name", "bytes", "sha256"]
169
+ .some((field) => value[field] !== undefined);
170
+ if (hasFiles && hasLegacyFile) {
171
+ throw new TypeError("Browser model files[] is mutually exclusive with legacy one-file fields.");
64
172
  }
65
- const immutableUrl = immutableHttpsUrl(value.immutableUrl);
66
- if (!immutableUrl) {
67
- throw new TypeError("Browser model immutableUrl must be immutable HTTPS without credentials or fragments.");
173
+ let files;
174
+ let legacy = false;
175
+ if (hasFiles) {
176
+ if (!Array.isArray(value.files) || value.files.length === 0) {
177
+ throw new TypeError("Browser model files must be a nonempty ordered array.");
178
+ }
179
+ files = value.files.map((file) => descriptorFile(file));
180
+ } else {
181
+ legacy = true;
182
+ const safeId = id.replace(/[^a-z0-9._-]+/giu, "_");
183
+ files = [descriptorFile(value, { fallbackName: `${safeId}.gguf` })];
68
184
  }
69
- const bytes = Number(value.bytes);
70
- if (!Number.isSafeInteger(bytes) || bytes < 1) {
71
- throw new TypeError("Browser model bytes must be a positive safe integer.");
185
+ const names = new Set();
186
+ const urls = new Set();
187
+ for (const file of files) {
188
+ const nameKey = file.name.toLowerCase();
189
+ if (names.has(nameKey)) {
190
+ throw new TypeError("Browser model file names must be unique.");
191
+ }
192
+ if (urls.has(file.url)) {
193
+ throw new TypeError("Browser model file URLs must be unique.");
194
+ }
195
+ names.add(nameKey);
196
+ urls.add(file.url);
72
197
  }
73
- const sha256 = requiredText(value.sha256, "sha256").toLowerCase();
74
- if (!SHA256_PATTERN.test(sha256)) {
75
- throw new TypeError("Browser model sha256 must be exactly 64 lowercase hexadecimal characters.");
198
+ files = Object.freeze(files);
199
+ let descriptor;
200
+ if (legacy) {
201
+ const [file] = files;
202
+ descriptor = { id, url: file.url };
203
+ if (file.bytes !== undefined) descriptor.bytes = file.bytes;
204
+ if (file.sha256 !== undefined) descriptor.sha256 = file.sha256;
205
+ } else {
206
+ descriptor = { id, files };
76
207
  }
77
- const licenseSpdx = requiredText(value.licenseSpdx, "licenseSpdx");
78
- const sourceRevision = requiredText(value.sourceRevision, "sourceRevision");
79
- return Object.freeze({
80
- id,
81
- name,
82
- immutableUrl: immutableUrl.href,
83
- bytes,
84
- sha256,
85
- licenseSpdx,
86
- sourceRevision,
87
- });
208
+ descriptor = Object.freeze(descriptor);
209
+ MODEL_DESCRIPTOR_METADATA.set(descriptor, Object.freeze({ files, legacy }));
210
+ return descriptor;
88
211
  }
89
212
 
90
213
  function publicDescriptor(source) {
214
+ if (source?.descriptor && MODEL_DESCRIPTOR_METADATA.has(source.descriptor)) {
215
+ return source.descriptor;
216
+ }
217
+ if (MODEL_DESCRIPTOR_METADATA.has(source)) return source;
218
+ return modelDescriptor(source);
219
+ }
220
+
221
+ function isChromeBrowser() {
222
+ const userAgent = String(globalThis.navigator?.userAgent ?? "");
223
+ return /\b(?:Chrome|Chromium)\//u.test(userAgent)
224
+ && !/\b(?:Edg|OPR)\//u.test(userAgent);
225
+ }
226
+
227
+ function isLowerPowerIntelAdapter(adapter) {
228
+ const identity = [
229
+ adapter.vendor,
230
+ adapter.architecture,
231
+ adapter.name,
232
+ adapter.description,
233
+ ].filter(Boolean).join(" ");
234
+ if (adapter?.vendorId !== INTEL_VENDOR_ID && !/\bintel\b/iu.test(identity)) return false;
235
+ return /(?:intel|integrated|xe-lp)/iu.test(identity);
236
+ }
237
+
238
+ function notifyChromeHighPerformanceGpu(adapter) {
239
+ if (
240
+ highPerformanceGpuNoticeShown
241
+ || !isChromeBrowser()
242
+ || !isLowerPowerIntelAdapter(adapter)
243
+ ) return;
244
+ highPerformanceGpuNoticeShown = true;
245
+ try {
246
+ globalThis.open?.(CHROME_HIGH_PERFORMANCE_GPU_FLAG_URL, "_blank", "noopener,noreferrer");
247
+ } catch {
248
+ // Chrome may reject internal-page navigation from web content.
249
+ }
250
+ const adapterName = adapter.description || adapter.name
251
+ || [adapter.vendor, adapter.architecture].filter(Boolean).join(" ")
252
+ || "a lower-power Intel adapter";
253
+ globalThis.alert?.(
254
+ `Arcane selected the lower-power WebGPU adapter: ${adapterName}.\n\n`
255
+ + "Enable “Force High Performance GPU” in the Chrome flags window. "
256
+ + "Then completely close every Chrome window and reopen Chrome before loading the model again.\n\n"
257
+ + `If the flags window did not open, paste ${CHROME_HIGH_PERFORMANCE_GPU_FLAG_URL} into Chrome.`,
258
+ );
259
+ }
260
+
261
+ function emitWebgpuAdapterSelection(source, runtime) {
262
+ const evidence = runtime.evidence();
263
+ const webgpu = evidence?.webgpu;
264
+ if (webgpu?.observed !== true || !webgpu.adapter) return;
265
+ try {
266
+ arcaneEvents.instrument(WEBGPU_ADAPTER_SELECTED_EVENT, Object.freeze({
267
+ protocol: WEBGPU_ADAPTER_SELECTION_PROTOCOL,
268
+ providerId: "arcane-browser-wasm-wllama",
269
+ modelId: source.id,
270
+ runtimeEvidenceProtocol: evidence.protocol,
271
+ adapter: webgpu.adapter,
272
+ offload: webgpu.offload ?? null,
273
+ buffers: webgpu.buffers ?? null,
274
+ queue: webgpu.queue ?? null,
275
+ }), Object.freeze({
276
+ source: "sdk:ai/browser-wasm",
277
+ category: "capability",
278
+ }));
279
+ } finally {
280
+ notifyChromeHighPerformanceGpu(webgpu.adapter);
281
+ }
282
+ }
283
+
284
+ function sourceMetadata(source) {
285
+ return BROWSER_MODEL_SOURCE_METADATA.get(source)
286
+ ?? MODEL_DESCRIPTOR_METADATA.get(publicDescriptor(source));
287
+ }
288
+
289
+ function manifestModelIdentity(source) {
91
290
  return Object.freeze({
92
291
  id: source.id,
93
- name: source.name,
94
- immutableUrl: source.immutableUrl,
95
- bytes: source.bytes,
96
- sha256: source.sha256,
97
- licenseSpdx: source.licenseSpdx,
98
- sourceRevision: source.sourceRevision,
292
+ files: Object.freeze(sourceMetadata(source).files.map((file) => Object.freeze({
293
+ name: file.name,
294
+ url: file.url,
295
+ }))),
99
296
  });
100
297
  }
101
298
 
102
299
  /**
103
- * Creates an authenticated browser download authority for one caller-supplied
104
- * immutable model. Arcane verifies the response bytes; it never trusts an
105
- * ETag, server digest, mutable model catalog, or URL helper.
300
+ * Creates a browser download authority for one caller-supplied immutable
301
+ * model file set. Legacy one-file descriptors normalize to one ordered member.
302
+ * Effective load security decides which expected-byte checks run.
106
303
  */
107
304
  export function createBrowserModelSource(descriptor, {
108
305
  fetchImpl = null,
109
306
  } = {}) {
110
307
  const model = modelDescriptor(descriptor);
308
+ const metadata = MODEL_DESCRIPTOR_METADATA.get(model);
111
309
 
112
- async function open({ signal } = {}) {
310
+ async function open(memberOrOptions = 0, options = {}) {
311
+ let memberIndex = memberOrOptions;
312
+ if (!Number.isSafeInteger(memberOrOptions)) {
313
+ if (metadata.files.length !== 1) {
314
+ throw new TypeError("A browser model file index is required for a multi-file source.");
315
+ }
316
+ memberIndex = 0;
317
+ options = memberOrOptions ?? {};
318
+ }
319
+ if (memberIndex < 0 || memberIndex >= metadata.files.length) {
320
+ throw new RangeError("Browser model file index is out of range.");
321
+ }
322
+ const member = metadata.files[memberIndex];
323
+ const { signal } = options;
113
324
  throwIfAborted(signal, "install");
114
325
  const fetchFunction = fetchImpl ?? globalThis.fetch?.bind(globalThis);
115
326
  if (typeof fetchFunction !== "function") {
@@ -118,7 +329,7 @@ export function createBrowserModelSource(descriptor, {
118
329
 
119
330
  let response;
120
331
  try {
121
- response = await fetchFunction(model.immutableUrl, {
332
+ response = await fetchFunction(member.url, {
122
333
  cache: "no-store",
123
334
  credentials: "omit",
124
335
  mode: "cors",
@@ -139,7 +350,7 @@ export function createBrowserModelSource(descriptor, {
139
350
  }
140
351
  let finalUrl;
141
352
  try {
142
- finalUrl = new URL(response.url || model.immutableUrl);
353
+ finalUrl = new URL(response.url || member.url);
143
354
  } catch {
144
355
  finalUrl = null;
145
356
  }
@@ -151,31 +362,33 @@ export function createBrowserModelSource(descriptor, {
151
362
  throw fail("ARCANE_AI_MODEL_SOURCE_INVALID", "The model response did not provide a byte stream.");
152
363
  }
153
364
  const header = response.headers?.get?.("content-length");
154
- if (header !== null && header !== undefined && header !== "") {
155
- const reported = Number(header);
156
- if (!Number.isSafeInteger(reported) || reported !== model.bytes) {
157
- await response.body.cancel().catch(() => undefined);
158
- throw fail(
159
- "ARCANE_AI_MODEL_SIZE_MISMATCH",
160
- `The model server reported ${String(header)} bytes; expected ${model.bytes}.`,
161
- );
162
- }
163
- }
365
+ const reported = header === null || header === undefined || header === ""
366
+ ? null
367
+ : Number(header);
164
368
  return Object.freeze({
165
369
  body: response.body,
166
- requestedUrl: model.immutableUrl,
370
+ requestedUrl: member.url,
167
371
  finalUrl: finalUrl.href,
372
+ reportedBytes: Number.isSafeInteger(reported) && reported >= 0 ? reported : null,
168
373
  cancel: (reason) => response.body.cancel(reason),
169
374
  });
170
375
  }
171
376
 
172
- const source = Object.freeze({
173
- kind: "arcane-authenticated-browser-model-source",
377
+ const sourceRecord = {
378
+ kind: "arcane-browser-model-source",
174
379
  ...model,
175
- descriptor: publicDescriptor(model),
380
+ descriptor: model,
176
381
  open,
177
- });
382
+ };
383
+ if (metadata.legacy) {
384
+ Object.defineProperties(sourceRecord, {
385
+ name: { value: metadata.files[0].name, enumerable: false },
386
+ immutableUrl: { value: metadata.files[0].url, enumerable: false },
387
+ });
388
+ }
389
+ const source = Object.freeze(sourceRecord);
178
390
  BROWSER_MODEL_SOURCES.add(source);
391
+ BROWSER_MODEL_SOURCE_METADATA.set(source, metadata);
179
392
  return source;
180
393
  }
181
394
 
@@ -228,55 +441,229 @@ async function* byteChunks(body, signal) {
228
441
  throw fail("ARCANE_AI_MODEL_SOURCE_INVALID", "The model source did not provide readable bytes.");
229
442
  }
230
443
 
231
- function storageName(source) {
232
- const safeId = source.id.replace(/[^a-z0-9._-]+/giu, "_");
444
+ function injectiveStorageId(id) {
445
+ return Array.from(
446
+ new TextEncoder().encode(id),
447
+ (value) => value.toString(16).padStart(2, "0"),
448
+ ).join("");
449
+ }
450
+
451
+ function storageName(source, { legacy = false } = {}) {
452
+ const safeId = legacy
453
+ ? source.id.replace(/[^a-z0-9._-]+/giu, "_")
454
+ : `id-${injectiveStorageId(source.id)}`;
455
+ const models = sourceMetadata(source).files.map((file) => Object.freeze({
456
+ file,
457
+ name: `${safeId}--${file.name}`,
458
+ }));
233
459
  return Object.freeze({
234
- model: `${safeId}--${source.name}`,
460
+ models: Object.freeze(models),
461
+ model: models[0].name,
235
462
  manifest: `${safeId}.complete.json`,
236
463
  });
237
464
  }
238
465
 
239
- function manifestFor(source, finalUrl) {
466
+ function manifestFor(source, files) {
467
+ const observedBytes = files.reduce((total, file) => total + file.observedBytes, 0);
240
468
  return Object.freeze({
241
469
  schema: MODEL_MANIFEST_SCHEMA,
242
470
  complete: true,
243
- model: publicDescriptor(source),
244
- finalUrl,
471
+ model: manifestModelIdentity(source),
472
+ files: Object.freeze(files.map((file) => Object.freeze({
473
+ name: file.name,
474
+ finalUrl: file.finalUrl,
475
+ observedBytes: file.observedBytes,
476
+ }))),
477
+ observedBytes,
245
478
  completedAt: new Date().toISOString(),
246
479
  });
247
480
  }
248
481
 
249
- function manifestMatches(manifest, source) {
482
+ function manifestByteLength(manifest) {
483
+ return new TextEncoder().encode(`${JSON.stringify(manifest)}\n`).byteLength;
484
+ }
485
+
486
+ function projectedManifestByteLength(source) {
487
+ return manifestByteLength(manifestFor(
488
+ source,
489
+ sourceMetadata(source).files.map((file) => ({
490
+ name: file.name,
491
+ finalUrl: file.url,
492
+ observedBytes: file.bytes,
493
+ })),
494
+ ));
495
+ }
496
+
497
+ function manifestKind(manifest, source) {
250
498
  const model = manifest?.model;
251
- return manifest?.schema === MODEL_MANIFEST_SCHEMA
499
+ const members = sourceMetadata(source).files;
500
+ if (
501
+ manifest?.schema === MODEL_MANIFEST_SCHEMA
502
+ && manifest?.complete === true
503
+ && model?.id === source.id
504
+ && Array.isArray(model?.files)
505
+ && model.files.length === members.length
506
+ && model.files.every((file, index) => (
507
+ file?.name === members[index].name
508
+ && file?.url === members[index].url
509
+ ))
510
+ && Array.isArray(manifest.files)
511
+ && manifest.files.length === members.length
512
+ && manifest.files.every((file, index) => (
513
+ file?.name === members[index].name
514
+ && Number.isSafeInteger(file?.observedBytes)
515
+ && file.observedBytes >= 0
516
+ && immutableHttpsUrl(file?.finalUrl)
517
+ ))
518
+ && Number.isSafeInteger(manifest.observedBytes)
519
+ && manifest.observedBytes >= 0
520
+ && manifest.files.reduce((total, file) => total + file.observedBytes, 0)
521
+ === manifest.observedBytes
522
+ ) return "set";
523
+ if (!sourceMetadata(source).legacy || members.length !== 1) return null;
524
+ const [member] = members;
525
+ if (
526
+ manifest?.schema === SINGLE_MODEL_MANIFEST_SCHEMA
527
+ && manifest?.complete === true
528
+ && model?.id === source.id
529
+ && model?.url === member.url
530
+ && Number.isSafeInteger(manifest.observedBytes)
531
+ && manifest.observedBytes >= 0
532
+ ) return "single";
533
+ if (
534
+ manifest?.schema === LEGACY_MODEL_MANIFEST_SCHEMA
252
535
  && manifest?.complete === true
253
536
  && model?.id === source.id
254
- && model?.name === source.name
255
- && model?.immutableUrl === source.immutableUrl
256
- && model?.bytes === source.bytes
257
- && model?.sha256 === source.sha256
258
- && model?.licenseSpdx === source.licenseSpdx
259
- && model?.sourceRevision === source.sourceRevision;
537
+ && model?.name === member.name
538
+ && model?.immutableUrl === member.url
539
+ ) return "legacy";
540
+ return null;
260
541
  }
261
542
 
262
- function progress(source, phase, loaded) {
543
+ function progress(source, phase, loaded, total = null, memberIndex = 0, memberLoaded = loaded) {
544
+ const members = sourceMetadata(source).files;
545
+ const member = members[memberIndex] ?? members[0];
263
546
  return Object.freeze({
264
547
  modelId: source.id,
265
548
  phase,
266
549
  loaded,
267
- total: source.bytes,
268
- percent: source.bytes ? (loaded / source.bytes) * 100 : null,
550
+ total,
551
+ percent: Number.isSafeInteger(total) && total > 0 ? (loaded / total) * 100 : null,
552
+ file: Object.freeze({
553
+ index: memberIndex,
554
+ count: members.length,
555
+ name: member.name,
556
+ loaded: memberLoaded,
557
+ total: member.bytes ?? null,
558
+ }),
559
+ });
560
+ }
561
+
562
+ function securitySnapshot(security) {
563
+ return Object.freeze({
564
+ secure: security.secure,
565
+ checks: Object.freeze({
566
+ byteLength: security.checks.byteLength,
567
+ sha256: security.checks.sha256,
568
+ }),
569
+ });
570
+ }
571
+
572
+ function assertDescriptorChecks(source, security) {
573
+ const files = sourceMetadata(source).files;
574
+ if (security.checks.byteLength && files.some((file) => file.bytes === undefined)) {
575
+ throw fail(
576
+ "ARCANE_AI_MODEL_SOURCE_INVALID",
577
+ "Browser model bytes is required for every file when the byteLength check is enabled.",
578
+ );
579
+ }
580
+ if (security.checks.sha256 && files.some((file) => file.sha256 === undefined)) {
581
+ throw fail(
582
+ "ARCANE_AI_MODEL_SOURCE_INVALID",
583
+ "Browser model sha256 is required for every file when the sha256 check is enabled.",
584
+ );
585
+ }
586
+ }
587
+
588
+ function knownModelBytes(source) {
589
+ const files = sourceMetadata(source).files;
590
+ if (files.some((file) => file.bytes === undefined)) return null;
591
+ const total = files.reduce((sum, file) => sum + file.bytes, 0);
592
+ return Number.isSafeInteger(total) ? total : null;
593
+ }
594
+
595
+ function oversizedModelFile(source) {
596
+ return sourceMetadata(source).files.find(
597
+ (file) => file.bytes !== undefined && file.bytes > WLLAMA_MAX_FILE_BYTES,
598
+ ) ?? null;
599
+ }
600
+
601
+ function integritySnapshot(security, source, {
602
+ observedBytes = null,
603
+ byteLength = security.checks.byteLength ? "pending" : "unchecked",
604
+ sha256 = security.checks.sha256 ? "pending" : "unchecked",
605
+ actualSha256 = null,
606
+ files = null,
607
+ } = {}) {
608
+ const enabledStates = [];
609
+ if (security.checks.byteLength) enabledStates.push(byteLength);
610
+ if (security.checks.sha256) enabledStates.push(sha256);
611
+ const state = enabledStates.length === 0
612
+ ? "unchecked"
613
+ : enabledStates.every((value) => value === "verified")
614
+ ? "verified"
615
+ : enabledStates.some((value) => value === "failed")
616
+ ? "failed"
617
+ : "pending";
618
+ const members = sourceMetadata(source).files;
619
+ const fileStates = members.map((member, index) => {
620
+ const evidence = files?.[index] ?? {};
621
+ return Object.freeze({
622
+ name: member.name,
623
+ observedBytes: evidence.observedBytes ?? null,
624
+ byteLength: Object.freeze({
625
+ enabled: security.checks.byteLength,
626
+ state: evidence.byteLength ?? byteLength,
627
+ expected: member.bytes ?? null,
628
+ observed: evidence.observedBytes ?? null,
629
+ }),
630
+ sha256: Object.freeze({
631
+ enabled: security.checks.sha256,
632
+ state: evidence.sha256 ?? sha256,
633
+ expected: member.sha256 ?? null,
634
+ actual: evidence.actualSha256 ?? (members.length === 1 ? actualSha256 : null),
635
+ }),
636
+ });
637
+ });
638
+ return Object.freeze({
639
+ state,
640
+ observedBytes,
641
+ byteLength: Object.freeze({
642
+ enabled: security.checks.byteLength,
643
+ state: byteLength,
644
+ expected: knownModelBytes(source),
645
+ observed: observedBytes,
646
+ }),
647
+ sha256: Object.freeze({
648
+ enabled: security.checks.sha256,
649
+ state: sha256,
650
+ expected: members.length === 1 ? members[0].sha256 ?? null : null,
651
+ actual: members.length === 1 ? actualSha256 : null,
652
+ }),
653
+ files: Object.freeze(fileStates),
269
654
  });
270
655
  }
271
656
 
272
657
  /**
273
658
  * Adapts an existing Arcane DBOPFS singleton without rebinding or changing any
274
659
  * of its public methods. The completion manifest is committed only after the
275
- * exact model file has been written and hashed.
660
+ * model file has been written. SHA-256 is read and computed only when its
661
+ * effective check is enabled.
276
662
  */
277
663
  export function createDbopfsModelStore({
278
664
  dbopfs,
279
665
  tableName = "arcane_ai_browser_models",
666
+ estimateStorage = null,
280
667
  } = {}) {
281
668
  if (!dbopfs || (typeof dbopfs !== "object" && typeof dbopfs !== "function")) {
282
669
  throw new TypeError("createDbopfsModelStore requires an existing DBOPFS instance.");
@@ -287,6 +674,9 @@ export function createDbopfsModelStore({
287
674
  if (dbopfs.readyPromise !== undefined && typeof dbopfs.readyPromise?.then !== "function") {
288
675
  throw new TypeError("The DBOPFS readyPromise must be thenable.");
289
676
  }
677
+ if (estimateStorage !== null && typeof estimateStorage !== "function") {
678
+ throw new TypeError("estimateStorage must be a function or null.");
679
+ }
290
680
  let tablePromise = null;
291
681
 
292
682
  async function table() {
@@ -340,107 +730,451 @@ export function createDbopfsModelStore({
340
730
  }
341
731
  }
342
732
 
343
- async function readManifest(name) {
733
+ async function readManifest(name, { removeInvalid = true } = {}) {
344
734
  const manifestFile = await file(name);
345
735
  if (!manifestFile) return null;
346
736
  try {
347
737
  return JSON.parse(await manifestFile.text());
348
738
  } catch {
349
- await removeEntry(name);
739
+ if (removeInvalid) await removeEntry(name);
350
740
  return null;
351
741
  }
352
742
  }
353
743
 
354
- async function remove(source) {
355
- const names = storageName(source);
356
- const removed = await Promise.all([
357
- removeEntry(names.manifest),
358
- removeEntry(names.model),
359
- ]);
744
+ async function removeNames(names) {
745
+ const removed = [await removeEntry(names.manifest)];
746
+ for (const entry of names.models) removed.push(await removeEntry(entry.name));
360
747
  return removed.some(Boolean);
361
748
  }
362
749
 
363
- async function openVerified(source, { signal, onProgress } = {}) {
364
- const names = storageName(source);
365
- const manifest = await readManifest(names.manifest);
366
- if (!manifestMatches(manifest, source)) {
367
- // A model file without the exact completion manifest is a partial, even
368
- // when the manifest is missing or malformed rather than merely stale.
369
- await remove(source);
370
- return null;
750
+ async function remove(source) {
751
+ let removed = await removeNames(storageName(source));
752
+ if (sourceMetadata(source).legacy) {
753
+ const legacyNames = storageName(source, { legacy: true });
754
+ const legacyManifest = await readManifest(legacyNames.manifest, { removeInvalid: false });
755
+ if (manifestKind(legacyManifest, source)) {
756
+ removed = await removeNames(legacyNames) || removed;
757
+ }
758
+ }
759
+ return removed;
760
+ }
761
+
762
+ async function writeManifest(name, manifest, signal) {
763
+ const encoded = new TextEncoder().encode(`${JSON.stringify(manifest)}\n`);
764
+ await write(name, encoded, { signal });
765
+ }
766
+
767
+ async function verifySha256(source, memberIndex, modelFile, {
768
+ signal,
769
+ onProgress,
770
+ phase,
771
+ completedBytes = 0,
772
+ totalBytes = null,
773
+ }) {
774
+ const digest = createStreamingSha256();
775
+ let hashed = 0;
776
+ for await (const chunk of byteChunks(modelFile, signal)) {
777
+ digest.update(chunk);
778
+ hashed += chunk.byteLength;
779
+ onProgress?.(progress(
780
+ source,
781
+ phase,
782
+ completedBytes + hashed,
783
+ totalBytes,
784
+ memberIndex,
785
+ hashed,
786
+ ));
787
+ }
788
+ return Object.freeze({ hashed, sha256: digest.digestHex() });
789
+ }
790
+
791
+ async function storagePolicy(source, { cached = null, security } = {}) {
792
+ if (cached) {
793
+ const payloadBytes = cached.observedBytes;
794
+ const manifestBytes = manifestByteLength(cached.manifest);
795
+ const requiredBytes = payloadBytes + manifestBytes;
796
+ return Object.freeze({
797
+ compatibility: "compatible",
798
+ code: "ARCANE_AI_MODEL_CACHE_COMPLETE",
799
+ requiredBytes,
800
+ payloadBytes,
801
+ manifestBytes,
802
+ quotaBytes: null,
803
+ usageBytes: null,
804
+ availableBytes: null,
805
+ measured: false,
806
+ admitted: true,
807
+ });
808
+ }
809
+ if (security?.checks?.byteLength !== true) {
810
+ return Object.freeze({
811
+ compatibility: "unknown",
812
+ code: "ARCANE_AI_MODEL_STORAGE_REQUIREMENT_UNBOUNDED",
813
+ requiredBytes: null,
814
+ payloadBytes: null,
815
+ manifestBytes: null,
816
+ quotaBytes: null,
817
+ usageBytes: null,
818
+ availableBytes: null,
819
+ measured: false,
820
+ admitted: false,
821
+ });
822
+ }
823
+ const payloadBytes = knownModelBytes(source);
824
+ const manifestBytes = payloadBytes === null ? null : projectedManifestByteLength(source);
825
+ const requiredBytes = payloadBytes === null || !Number.isSafeInteger(payloadBytes + manifestBytes)
826
+ ? null
827
+ : payloadBytes + manifestBytes;
828
+ if (requiredBytes === null) {
829
+ return Object.freeze({
830
+ compatibility: "unknown",
831
+ code: "ARCANE_AI_MODEL_STORAGE_REQUIREMENT_UNKNOWN",
832
+ requiredBytes: null,
833
+ payloadBytes,
834
+ manifestBytes,
835
+ quotaBytes: null,
836
+ usageBytes: null,
837
+ availableBytes: null,
838
+ measured: false,
839
+ admitted: false,
840
+ });
841
+ }
842
+ const estimator = estimateStorage
843
+ ?? globalThis.navigator?.storage?.estimate?.bind(globalThis.navigator.storage);
844
+ if (typeof estimator !== "function") {
845
+ return Object.freeze({
846
+ compatibility: "unknown",
847
+ code: "ARCANE_AI_STORAGE_ESTIMATE_UNAVAILABLE",
848
+ requiredBytes,
849
+ payloadBytes,
850
+ manifestBytes,
851
+ quotaBytes: null,
852
+ usageBytes: null,
853
+ availableBytes: null,
854
+ measured: false,
855
+ admitted: false,
856
+ });
857
+ }
858
+ let estimate;
859
+ try {
860
+ estimate = await estimator();
861
+ } catch {
862
+ return Object.freeze({
863
+ compatibility: "unknown",
864
+ code: "ARCANE_AI_STORAGE_ESTIMATE_FAILED",
865
+ requiredBytes,
866
+ payloadBytes,
867
+ manifestBytes,
868
+ quotaBytes: null,
869
+ usageBytes: null,
870
+ availableBytes: null,
871
+ measured: true,
872
+ admitted: false,
873
+ });
371
874
  }
372
- const modelFile = await file(names.model);
373
- if (!modelFile || modelFile.size !== source.bytes) {
875
+ const quotaBytes = Number.isSafeInteger(estimate?.quota) && estimate.quota >= 0
876
+ ? estimate.quota
877
+ : null;
878
+ const usageBytes = Number.isSafeInteger(estimate?.usage) && estimate.usage >= 0
879
+ ? estimate.usage
880
+ : null;
881
+ const availableBytes = quotaBytes !== null && usageBytes !== null && quotaBytes >= usageBytes
882
+ ? quotaBytes - usageBytes
883
+ : null;
884
+ const incompatible = availableBytes !== null && requiredBytes > availableBytes;
885
+ return Object.freeze({
886
+ compatibility: incompatible ? "incompatible" : availableBytes === null ? "unknown" : "compatible",
887
+ code: incompatible
888
+ ? "ARCANE_AI_STORAGE_CAPACITY_INSUFFICIENT"
889
+ : availableBytes === null
890
+ ? "ARCANE_AI_STORAGE_ESTIMATE_INVALID"
891
+ : "ARCANE_AI_STORAGE_CAPACITY_AVAILABLE",
892
+ requiredBytes,
893
+ payloadBytes,
894
+ manifestBytes,
895
+ quotaBytes,
896
+ usageBytes,
897
+ availableBytes,
898
+ measured: true,
899
+ admitted: false,
900
+ });
901
+ }
902
+
903
+ async function openCached(source, {
904
+ signal,
905
+ onProgress,
906
+ security = resolveModelSecurity(),
907
+ } = {}) {
908
+ assertDescriptorChecks(source, security);
909
+ let names = storageName(source);
910
+ let manifest = await readManifest(names.manifest);
911
+ let kind = manifestKind(manifest, source);
912
+ if (!kind && sourceMetadata(source).legacy) {
913
+ const legacyNames = storageName(source, { legacy: true });
914
+ const legacyManifest = await readManifest(legacyNames.manifest, { removeInvalid: false });
915
+ const legacyKind = manifestKind(legacyManifest, source);
916
+ if (legacyKind) {
917
+ names = legacyNames;
918
+ manifest = legacyManifest;
919
+ kind = legacyKind;
920
+ }
921
+ }
922
+ if (!kind) {
923
+ // Model files without the exact ordered completion manifest are partial.
374
924
  await remove(source);
375
925
  return null;
376
926
  }
377
- const digest = createStreamingSha256();
378
- let hashed = 0;
927
+ const members = sourceMetadata(source).files;
928
+ const modelFiles = [];
929
+ const fileEvidence = [];
930
+ let observedBytes = 0;
379
931
  try {
380
- for await (const chunk of byteChunks(modelFile, signal)) {
381
- digest.update(chunk);
382
- hashed += chunk.byteLength;
383
- onProgress?.(progress(source, "verify-cache", hashed));
932
+ for (let index = 0; index < names.models.length; index += 1) {
933
+ const member = members[index];
934
+ const modelFile = await file(names.models[index].name);
935
+ if (!modelFile) {
936
+ await removeNames(names);
937
+ return null;
938
+ }
939
+ if (modelFile.size > WLLAMA_MAX_FILE_BYTES) {
940
+ await removeNames(names);
941
+ throw fail(
942
+ "ARCANE_AI_MODEL_SHARD_TOO_LARGE",
943
+ `Cached model file ${member.name} exceeds Wllama's ${WLLAMA_MAX_FILE_BYTES}-byte boundary.`,
944
+ );
945
+ }
946
+ if (kind === "set" && manifest.files[index].observedBytes !== modelFile.size) {
947
+ await removeNames(names);
948
+ return null;
949
+ }
950
+ if (security.checks.byteLength && modelFile.size !== member.bytes) {
951
+ await removeNames(names);
952
+ return null;
953
+ }
954
+ modelFiles.push(modelFile);
955
+ fileEvidence.push({
956
+ observedBytes: modelFile.size,
957
+ byteLength: security.checks.byteLength ? "verified" : "unchecked",
958
+ sha256: security.checks.sha256 ? "pending" : "unchecked",
959
+ actualSha256: null,
960
+ });
961
+ observedBytes += modelFile.size;
384
962
  }
385
- if (hashed !== source.bytes || digest.digestHex() !== source.sha256) {
386
- await remove(source);
963
+ if ((kind === "set" || kind === "single") && manifest.observedBytes !== observedBytes) {
964
+ await removeNames(names);
387
965
  return null;
388
966
  }
389
- return Object.freeze({ file: modelFile, manifest });
967
+ let completedBytes = 0;
968
+ for (let index = 0; index < modelFiles.length; index += 1) {
969
+ const modelFile = modelFiles[index];
970
+ if (security.checks.sha256) {
971
+ const verification = await verifySha256(source, index, modelFile, {
972
+ signal,
973
+ onProgress,
974
+ phase: "verify-cache",
975
+ completedBytes,
976
+ totalBytes: observedBytes,
977
+ });
978
+ fileEvidence[index].actualSha256 = verification.sha256;
979
+ if (
980
+ verification.hashed !== modelFile.size
981
+ || verification.sha256 !== members[index].sha256
982
+ ) {
983
+ await removeNames(names);
984
+ return null;
985
+ }
986
+ fileEvidence[index].sha256 = "verified";
987
+ } else {
988
+ onProgress?.(progress(
989
+ source,
990
+ "cache",
991
+ completedBytes + modelFile.size,
992
+ observedBytes,
993
+ index,
994
+ modelFile.size,
995
+ ));
996
+ }
997
+ completedBytes += modelFile.size;
998
+ }
999
+ let completion = manifest;
1000
+ if (kind !== "set") {
1001
+ completion = manifestFor(source, [{
1002
+ name: members[0].name,
1003
+ finalUrl: manifest.finalUrl ?? members[0].url,
1004
+ observedBytes,
1005
+ }]);
1006
+ }
1007
+ return Object.freeze({
1008
+ files: Object.freeze(modelFiles),
1009
+ file: modelFiles.length === 1 ? modelFiles[0] : null,
1010
+ manifest: completion,
1011
+ observedBytes,
1012
+ integrity: integritySnapshot(security, source, {
1013
+ observedBytes,
1014
+ byteLength: security.checks.byteLength ? "verified" : "unchecked",
1015
+ sha256: security.checks.sha256 ? "verified" : "unchecked",
1016
+ actualSha256: fileEvidence[0]?.actualSha256 ?? null,
1017
+ files: fileEvidence,
1018
+ }),
1019
+ });
390
1020
  } catch (error) {
391
- if (!signal?.aborted) await remove(source);
1021
+ if (!signal?.aborted) await removeNames(names);
392
1022
  throw error;
393
1023
  }
394
1024
  }
395
1025
 
396
- async function install(source, { signal, onProgress } = {}) {
1026
+ async function openVerified(source, { signal, onProgress } = {}) {
1027
+ const security = resolveModelSecurity({ load: { secure: true } });
1028
+ return openCached(source, { signal, onProgress, security });
1029
+ }
1030
+
1031
+ async function install(source, { signal, onProgress, security: configuredSecurity } = {}) {
1032
+ const security = resolveModelSecurity({ load: configuredSecurity });
1033
+ assertDescriptorChecks(source, security);
397
1034
  const names = storageName(source);
1035
+ const members = sourceMetadata(source).files;
398
1036
  await remove(source);
399
- const opened = await source.open({ signal });
400
- const digest = createStreamingSha256();
1037
+ const modelFiles = [];
1038
+ const manifestFiles = [];
1039
+ const fileEvidence = [];
1040
+ const expectedTotal = knownModelBytes(source);
1041
+ let observedBytes = 0;
401
1042
  try {
402
- const written = await write(names.model, opened.body, {
403
- signal,
404
- async onChunk(chunk, loaded) {
405
- digest.update(chunk);
406
- if (loaded > source.bytes) {
407
- throw fail("ARCANE_AI_MODEL_SIZE_MISMATCH", "Downloaded model exceeded its declared size.");
1043
+ for (let index = 0; index < members.length; index += 1) {
1044
+ const member = members[index];
1045
+ const opened = await source.open(index, { signal });
1046
+ try {
1047
+ if (opened.reportedBytes !== null && opened.reportedBytes > WLLAMA_MAX_FILE_BYTES) {
1048
+ throw fail(
1049
+ "ARCANE_AI_MODEL_SHARD_TOO_LARGE",
1050
+ `Model file ${member.name} exceeds Wllama's ${WLLAMA_MAX_FILE_BYTES}-byte boundary.`,
1051
+ );
408
1052
  }
409
- onProgress?.(progress(source, "download", loaded));
410
- },
411
- });
412
- const actualSha256 = digest.digestHex();
413
- if (written !== source.bytes || actualSha256 !== source.sha256) {
414
- throw fail(
415
- actualSha256 === source.sha256
416
- ? "ARCANE_AI_MODEL_SIZE_MISMATCH"
417
- : "ARCANE_AI_MODEL_DIGEST_MISMATCH",
418
- "Downloaded model bytes did not match the caller-supplied authority.",
419
- );
1053
+ if (
1054
+ security.checks.byteLength
1055
+ && opened.reportedBytes !== null
1056
+ && opened.reportedBytes !== member.bytes
1057
+ ) {
1058
+ throw fail(
1059
+ "ARCANE_AI_MODEL_SIZE_MISMATCH",
1060
+ "A model response Content-Length did not match its expected byte length.",
1061
+ );
1062
+ }
1063
+ const downloadDigest = security.checks.sha256 ? createStreamingSha256() : null;
1064
+ const written = await write(names.models[index].name, opened.body, {
1065
+ signal,
1066
+ async onChunk(chunk, loaded) {
1067
+ downloadDigest?.update(chunk);
1068
+ if (loaded > WLLAMA_MAX_FILE_BYTES) {
1069
+ throw fail(
1070
+ "ARCANE_AI_MODEL_SHARD_TOO_LARGE",
1071
+ `Model file ${member.name} exceeds Wllama's ${WLLAMA_MAX_FILE_BYTES}-byte boundary.`,
1072
+ );
1073
+ }
1074
+ if (security.checks.byteLength && loaded > member.bytes) {
1075
+ throw fail("ARCANE_AI_MODEL_SIZE_MISMATCH", "Downloaded model file exceeded its declared size.");
1076
+ }
1077
+ onProgress?.(progress(
1078
+ source,
1079
+ "download",
1080
+ observedBytes + loaded,
1081
+ expectedTotal,
1082
+ index,
1083
+ loaded,
1084
+ ));
1085
+ },
1086
+ });
1087
+ if (security.checks.byteLength && written !== member.bytes) {
1088
+ throw fail(
1089
+ "ARCANE_AI_MODEL_SIZE_MISMATCH",
1090
+ "Downloaded model file bytes did not match the caller-supplied expected byte length.",
1091
+ );
1092
+ }
1093
+ const modelFile = await file(names.models[index].name);
1094
+ if (!modelFile || modelFile.size !== written) {
1095
+ throw fail(
1096
+ "ARCANE_AI_MODEL_CACHE_REJECTED",
1097
+ "A stored model file did not preserve the observed downloaded byte count.",
1098
+ );
1099
+ }
1100
+ const evidence = {
1101
+ observedBytes: written,
1102
+ byteLength: security.checks.byteLength ? "verified" : "unchecked",
1103
+ sha256: security.checks.sha256 ? "pending" : "unchecked",
1104
+ actualSha256: null,
1105
+ };
1106
+ if (security.checks.sha256) {
1107
+ evidence.actualSha256 = downloadDigest.digestHex();
1108
+ if (evidence.actualSha256 !== member.sha256) {
1109
+ throw fail(
1110
+ "ARCANE_AI_MODEL_DIGEST_MISMATCH",
1111
+ "Downloaded model file bytes did not match the caller-supplied SHA-256 value.",
1112
+ );
1113
+ }
1114
+ evidence.sha256 = "verified";
1115
+ }
1116
+ modelFiles.push(modelFile);
1117
+ fileEvidence.push(evidence);
1118
+ manifestFiles.push({ name: member.name, finalUrl: opened.finalUrl, observedBytes: written });
1119
+ observedBytes += written;
1120
+ } catch (error) {
1121
+ await opened.cancel?.(error).catch(() => undefined);
1122
+ throw error;
1123
+ }
420
1124
  }
421
- // Completion is the final storage mutation. A file without this exact
422
- // manifest is never admitted for inference or offline reuse.
423
- const manifest = manifestFor(source, opened.finalUrl);
424
- const encoded = new TextEncoder().encode(`${JSON.stringify(manifest)}\n`);
425
- await write(names.manifest, encoded, { signal });
426
- const admitted = await openVerified(source, { signal, onProgress });
427
- if (!admitted) throw fail("ARCANE_AI_MODEL_CACHE_REJECTED", "The completed model cache failed revalidation.");
428
- return admitted;
1125
+ // Completion is the final storage mutation. No member is admitted until
1126
+ // this exact ordered set manifest exists.
1127
+ const manifest = manifestFor(source, manifestFiles);
1128
+ await writeManifest(names.manifest, manifest, signal);
1129
+ return Object.freeze({
1130
+ files: Object.freeze(modelFiles),
1131
+ file: modelFiles.length === 1 ? modelFiles[0] : null,
1132
+ manifest,
1133
+ observedBytes,
1134
+ integrity: integritySnapshot(security, source, {
1135
+ observedBytes,
1136
+ byteLength: security.checks.byteLength ? "verified" : "unchecked",
1137
+ sha256: security.checks.sha256 ? "verified" : "unchecked",
1138
+ actualSha256: fileEvidence[0]?.actualSha256 ?? null,
1139
+ files: fileEvidence,
1140
+ }),
1141
+ });
429
1142
  } catch (error) {
430
- await opened.cancel?.(error).catch(() => undefined);
431
1143
  await remove(source).catch(() => undefined);
432
1144
  throw error;
433
1145
  }
434
1146
  }
435
1147
 
436
- async function ensure(source, { signal, onProgress, offline = false } = {}) {
437
- const cached = await openVerified(source, { signal, onProgress });
438
- if (cached) return Object.freeze({ ...cached, cache: "verified" });
1148
+ async function ensure(source, {
1149
+ signal,
1150
+ onProgress,
1151
+ onCapabilityPolicy,
1152
+ offline = false,
1153
+ security: configuredSecurity,
1154
+ } = {}) {
1155
+ const security = resolveModelSecurity({ load: configuredSecurity });
1156
+ assertDescriptorChecks(source, security);
1157
+ const cached = await openCached(source, { signal, onProgress, security });
1158
+ if (cached) {
1159
+ const storage = await storagePolicy(source, { cached, security });
1160
+ onCapabilityPolicy?.(storage);
1161
+ return Object.freeze({ ...cached, cache: "cached", storage });
1162
+ }
439
1163
  if (offline) {
440
- throw fail("ARCANE_AI_MODEL_OFFLINE_MISS", "No verified offline model cache is available.");
1164
+ throw fail("ARCANE_AI_MODEL_OFFLINE_MISS", "No admitted offline model cache is available.");
441
1165
  }
442
- const installed = await install(source, { signal, onProgress });
443
- return Object.freeze({ ...installed, cache: "installed" });
1166
+ const storage = await storagePolicy(source, { security });
1167
+ onCapabilityPolicy?.(storage);
1168
+ if (storage.compatibility === "incompatible") {
1169
+ throw fail(
1170
+ storage.code,
1171
+ "Available browser storage is smaller than the model file set and completion manifest.",
1172
+ );
1173
+ }
1174
+ const installed = await install(source, { signal, onProgress, security });
1175
+ const admittedStorage = await storagePolicy(source, { cached: installed, security });
1176
+ onCapabilityPolicy?.(admittedStorage);
1177
+ return Object.freeze({ ...installed, cache: "installed", storage: admittedStorage });
444
1178
  }
445
1179
 
446
1180
  const store = Object.freeze({
@@ -769,6 +1503,9 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
769
1503
  let terminalError = null;
770
1504
 
771
1505
  function deliver(value) {
1506
+ // This gate prevents delivery after public cancellation. It is not proof
1507
+ // that the underlying request stopped; the runtime records that separately.
1508
+ if (ended || linked.controller.signal.aborted) return;
772
1509
  const chunk = request.id === undefined ? value : { ...value, id: request.id };
773
1510
  accumulator.push(chunk);
774
1511
  const waiter = waiters.shift();
@@ -779,6 +1516,7 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
779
1516
  function finish(error = null) {
780
1517
  ended = true;
781
1518
  terminalError = error;
1519
+ if (error) chunks.length = 0;
782
1520
  while (waiters.length) {
783
1521
  const waiter = waiters.shift();
784
1522
  if (error) waiter.reject(error);
@@ -790,24 +1528,25 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
790
1528
  completionOptions(request, linked.controller.signal, true),
791
1529
  deliver,
792
1530
  );
793
- const result = Promise.resolve(terminal).then(
794
- () => {
1531
+ const result = (async () => {
1532
+ try {
1533
+ await terminal;
1534
+ throwIfAborted(linked.controller.signal);
795
1535
  const value = accumulator.result();
796
1536
  finish();
797
1537
  return value;
798
- },
799
- (error) => {
1538
+ } catch (error) {
800
1539
  const normalized = normalizeArcaneAIError(error, {
801
1540
  kind: "llm",
802
1541
  operation: "request",
803
- signal: linked.controller.signal,
1542
+ signal: normalizationSignal(error, linked.controller.signal),
804
1543
  });
805
1544
  finish(normalized);
806
1545
  throw normalized;
807
- },
808
- ).finally(() => {
1546
+ }
1547
+ })().finally(() => {
809
1548
  linked.release();
810
- onSettled();
1549
+ onSettled(terminalError);
811
1550
  });
812
1551
  result.catch(() => undefined);
813
1552
 
@@ -833,8 +1572,9 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
833
1572
  return cancelPromise;
834
1573
  },
835
1574
  async next() {
836
- if (chunks.length) return { value: chunks.shift(), done: false };
837
1575
  if (terminalError) throw terminalError;
1576
+ throwIfAborted(linked.controller.signal);
1577
+ if (chunks.length) return { value: chunks.shift(), done: false };
838
1578
  if (ended) return { value: undefined, done: true };
839
1579
  return new Promise((resolve, reject) => waiters.push({ resolve, reject }));
840
1580
  },
@@ -853,24 +1593,252 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
853
1593
  return Object.freeze(handle);
854
1594
  }
855
1595
 
1596
+ function positiveLoadInteger(value, field, fallback, maximum) {
1597
+ const resolved = value === undefined ? fallback : value;
1598
+ if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) {
1599
+ throw new RangeError(`${field} must be a positive safe integer no greater than ${maximum}.`);
1600
+ }
1601
+ return resolved;
1602
+ }
1603
+
1604
+ function measuredRuntimeCapabilities(runtimeCapabilities) {
1605
+ const measuredDeviceMemory = Number(globalThis.navigator?.deviceMemory);
1606
+ const deviceMemory = Number.isFinite(measuredDeviceMemory) && measuredDeviceMemory > 0
1607
+ ? measuredDeviceMemory
1608
+ : null;
1609
+ return Object.freeze({ ...runtimeCapabilities, deviceMemory });
1610
+ }
1611
+
1612
+ function capabilityLoadPlan(runtimeCapabilities, defaults, options = {}) {
1613
+ const configured = { ...defaults, ...options };
1614
+ const hardwareConcurrency = Number.isSafeInteger(runtimeCapabilities.hardwareConcurrency)
1615
+ && runtimeCapabilities.hardwareConcurrency > 0
1616
+ ? runtimeCapabilities.hardwareConcurrency
1617
+ : 1;
1618
+ const deviceMemory = runtimeCapabilities.deviceMemory;
1619
+ const measuredContext = deviceMemory === null
1620
+ ? 4_096
1621
+ : deviceMemory <= 2
1622
+ ? 2_048
1623
+ : deviceMemory <= 4
1624
+ ? 4_096
1625
+ : 4_096;
1626
+ const defaultContext = hardwareConcurrency <= 2
1627
+ ? Math.min(2_048, measuredContext)
1628
+ : measuredContext;
1629
+ const defaultBatch = deviceMemory !== null && deviceMemory <= 2 ? 64 : 128;
1630
+ const defaultMicroBatch = deviceMemory !== null && deviceMemory <= 2 ? 32 : 64;
1631
+ const threads = positiveLoadInteger(
1632
+ configured.threads,
1633
+ "threads",
1634
+ Math.max(1, Math.min(4, hardwareConcurrency - 1 || 1)),
1635
+ 64,
1636
+ );
1637
+ const contextTokens = positiveLoadInteger(
1638
+ configured.contextTokens,
1639
+ "contextTokens",
1640
+ defaultContext,
1641
+ 1_048_576,
1642
+ );
1643
+ const batchTokens = positiveLoadInteger(
1644
+ configured.batchTokens,
1645
+ "batchTokens",
1646
+ Math.min(defaultBatch, contextTokens),
1647
+ contextTokens,
1648
+ );
1649
+ const microBatchTokens = positiveLoadInteger(
1650
+ configured.microBatchTokens,
1651
+ "microBatchTokens",
1652
+ Math.min(defaultMicroBatch, batchTokens),
1653
+ batchTokens,
1654
+ );
1655
+ return Object.freeze({
1656
+ threads,
1657
+ contextTokens,
1658
+ batchTokens,
1659
+ microBatchTokens,
1660
+ gpuLayers: 99_999,
1661
+ });
1662
+ }
1663
+
1664
+ function sameLoadPlan(left, right) {
1665
+ return left?.threads === right?.threads
1666
+ && left?.contextTokens === right?.contextTokens
1667
+ && left?.batchTokens === right?.batchTokens
1668
+ && left?.microBatchTokens === right?.microBatchTokens
1669
+ && left?.gpuLayers === right?.gpuLayers;
1670
+ }
1671
+
1672
+ function stableModelFailure(error) {
1673
+ const code = typeof error?.code === "string" ? error.code : "";
1674
+ const message = typeof error?.message === "string" ? error.message : "";
1675
+ if (code === "ARCANE_AI_MODEL_SHARD_TOO_LARGE") {
1676
+ return Object.freeze({ code });
1677
+ }
1678
+ if (/(?:out of memory|allocation failed|failed to allocate|memory exhausted)/iu.test(message)) {
1679
+ return Object.freeze({ code: "ARCANE_AI_MODEL_GPU_MEMORY_INSUFFICIENT" });
1680
+ }
1681
+ if (code === "ARCANE_AI_WEBGPU_EVIDENCE_INVALID") {
1682
+ return Object.freeze({ code: "ARCANE_AI_MODEL_FULL_OFFLOAD_UNPROVEN" });
1683
+ }
1684
+ if (code === "ARCANE_AI_WEBGPU_REQUIRED" && /(?:offload|GPU|WebGPU)/iu.test(message)) {
1685
+ return Object.freeze({ code: "ARCANE_AI_MODEL_WEBGPU_REQUIREMENT_FAILED" });
1686
+ }
1687
+ return null;
1688
+ }
1689
+
1690
+ function capabilityPolicy(
1691
+ source,
1692
+ runtimeCapabilities,
1693
+ loadPlan,
1694
+ storage,
1695
+ runtimeEvidence,
1696
+ state,
1697
+ failure = null,
1698
+ ) {
1699
+ const reasons = [];
1700
+ const add = (code, compatibility, details = {}) => reasons.push(Object.freeze({
1701
+ code,
1702
+ compatibility,
1703
+ details: Object.freeze(details),
1704
+ }));
1705
+ if (runtimeCapabilities.webAssembly !== true) {
1706
+ add("ARCANE_AI_WEBASSEMBLY_UNAVAILABLE", "incompatible");
1707
+ }
1708
+ if (runtimeCapabilities.opfs !== true) {
1709
+ add("ARCANE_AI_OPFS_UNAVAILABLE", "incompatible");
1710
+ }
1711
+ if (runtimeCapabilities.secureContext !== true) {
1712
+ add("ARCANE_AI_SECURE_CONTEXT_REQUIRED", "incompatible");
1713
+ }
1714
+ if (runtimeCapabilities.webgpuApiPresent !== true) {
1715
+ add("ARCANE_AI_WEBGPU_API_UNAVAILABLE", "incompatible");
1716
+ }
1717
+ const oversized = oversizedModelFile(source);
1718
+ if (oversized) {
1719
+ add("ARCANE_AI_MODEL_SHARD_TOO_LARGE", "incompatible", {
1720
+ name: oversized.name,
1721
+ bytes: oversized.bytes,
1722
+ maximumBytes: WLLAMA_MAX_FILE_BYTES,
1723
+ });
1724
+ }
1725
+ if (failure && failure.code !== "ARCANE_AI_MODEL_SHARD_TOO_LARGE") {
1726
+ add(failure.code, "incompatible");
1727
+ }
1728
+ if (storage?.compatibility === "incompatible") {
1729
+ add(storage.code, "incompatible", {
1730
+ requiredBytes: storage.requiredBytes,
1731
+ availableBytes: storage.availableBytes,
1732
+ });
1733
+ } else if (!storage || storage.compatibility === "unknown") {
1734
+ add(storage?.code ?? "ARCANE_AI_STORAGE_NOT_MEASURED", "unknown", {
1735
+ requiredBytes: storage?.requiredBytes ?? knownModelBytes(source),
1736
+ availableBytes: storage?.availableBytes ?? null,
1737
+ });
1738
+ }
1739
+ const webgpu = runtimeEvidence?.webgpu;
1740
+ if (state === "ready" && webgpu?.observed === true) {
1741
+ add("ARCANE_AI_WEBGPU_EXECUTION_OBSERVED", "compatible", {
1742
+ requestedGpuLayers: loadPlan.gpuLayers,
1743
+ offloadedLayers: webgpu.offload?.layers ?? null,
1744
+ totalLayers: webgpu.offload?.totalLayers ?? null,
1745
+ queueSubmissions: webgpu.queue?.submissions ?? null,
1746
+ logicalBufferDescriptorBytes: webgpu.buffers?.descriptorBytes ?? null,
1747
+ });
1748
+ } else if (runtimeCapabilities.webgpuApiPresent === true) {
1749
+ add("ARCANE_AI_WEBGPU_EXECUTION_UNOBSERVED", "unknown", {
1750
+ requestedGpuLayers: loadPlan.gpuLayers,
1751
+ });
1752
+ }
1753
+ const compatibility = reasons.some((reason) => reason.compatibility === "incompatible")
1754
+ ? "incompatible"
1755
+ : reasons.some((reason) => reason.compatibility === "unknown")
1756
+ ? "unknown"
1757
+ : "compatible";
1758
+ return Object.freeze({
1759
+ protocol: CAPABILITY_POLICY_PROTOCOL,
1760
+ compatibility,
1761
+ reasons: Object.freeze(reasons),
1762
+ model: Object.freeze({
1763
+ id: source.id,
1764
+ fileCount: sourceMetadata(source).files.length,
1765
+ declaredBytes: knownModelBytes(source),
1766
+ }),
1767
+ load: loadPlan,
1768
+ storage: storage ?? null,
1769
+ inputs: Object.freeze({
1770
+ hardwareConcurrency: runtimeCapabilities.hardwareConcurrency,
1771
+ deviceMemory: runtimeCapabilities.deviceMemory,
1772
+ deviceMemoryMeaning: "coarse-system-memory-gib",
1773
+ webAssembly: runtimeCapabilities.webAssembly,
1774
+ opfs: runtimeCapabilities.opfs,
1775
+ secureContext: runtimeCapabilities.secureContext,
1776
+ webgpuApiPresent: runtimeCapabilities.webgpuApiPresent,
1777
+ }),
1778
+ });
1779
+ }
1780
+
1781
+ function providerModelSources(source, sources) {
1782
+ const list = sources === undefined ? [source] : sources;
1783
+ if (!Array.isArray(list) || list.length === 0) {
1784
+ throw new TypeError("createBrowserWasmLlmProvider requires a nonempty sources array or legacy source.");
1785
+ }
1786
+ const ids = new Set();
1787
+ for (const candidate of list) {
1788
+ if (!BROWSER_MODEL_SOURCES.has(candidate)) {
1789
+ throw new TypeError("Every browser-WASM model source must come from createBrowserModelSource().");
1790
+ }
1791
+ if (ids.has(candidate.id)) {
1792
+ throw new TypeError("Browser-WASM model source ids must be unique within one provider catalog.");
1793
+ }
1794
+ ids.add(candidate.id);
1795
+ }
1796
+ if (source !== undefined && !BROWSER_MODEL_SOURCES.has(source)) {
1797
+ throw new TypeError("The legacy default source must come from createBrowserModelSource().");
1798
+ }
1799
+ if (source !== undefined && !list.includes(source)) {
1800
+ throw new TypeError("The legacy default source must be one member of sources.");
1801
+ }
1802
+ return Object.freeze({
1803
+ sources: Object.freeze(list.slice()),
1804
+ defaultSource: source ?? list[0],
1805
+ });
1806
+ }
1807
+
856
1808
  export function createBrowserWasmLlmProvider({
857
1809
  source,
1810
+ sources,
858
1811
  store,
859
1812
  loadDefaults = {},
1813
+ security,
860
1814
  logger = console,
861
1815
  } = {}) {
862
- if (!BROWSER_MODEL_SOURCES.has(source)) {
863
- throw new TypeError("createBrowserWasmLlmProvider requires createBrowserModelSource().");
864
- }
1816
+ const configuredModels = providerModelSources(source, sources);
1817
+ const modelSources = configuredModels.sources;
1818
+ const defaultSource = configuredModels.defaultSource;
865
1819
  if (!DBOPFS_MODEL_STORES.has(store)) {
866
1820
  throw new TypeError("createBrowserWasmLlmProvider requires createDbopfsModelStore().");
867
1821
  }
1822
+ const bindingSecurity = normalizeModelSecurity(security, "provider security");
1823
+ const runtimeLoadDefaults = { ...loadDefaults };
1824
+ delete runtimeLoadDefaults.security;
1825
+ delete runtimeLoadDefaults.offline;
1826
+ delete runtimeLoadDefaults.onProgress;
868
1827
 
869
1828
  const runtime = createPackagedWllamaRuntime({ logger });
1829
+ const storagePolicies = new Map();
1830
+ const modelFailures = new Map();
1831
+ let activeSource = defaultSource;
1832
+ let activeLoadPlan = capabilityLoadPlan(
1833
+ measuredRuntimeCapabilities(runtime.capabilities()),
1834
+ runtimeLoadDefaults,
1835
+ );
870
1836
  let state = "unloaded";
871
1837
  let progressState = null;
872
1838
  let errorState = null;
873
1839
  let cacheState = "unknown";
1840
+ let activeSecurity = null;
1841
+ let activeIntegrity = null;
874
1842
  let queueDepth = 0;
875
1843
  let disposed = false;
876
1844
  let disposing = false;
@@ -883,21 +1851,82 @@ export function createBrowserWasmLlmProvider({
883
1851
  let activeCount = 0;
884
1852
  const queue = createSerialRequestQueue((depth) => { queueDepth = depth; });
885
1853
 
1854
+ function sourceForModel(modelId = undefined) {
1855
+ if (modelId === undefined || modelId === null) return defaultSource;
1856
+ const candidate = modelSources.find((value) => value.id === modelId);
1857
+ if (!candidate) {
1858
+ throw fail(
1859
+ "ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
1860
+ "The selected model is not present in this provider's caller-supplied catalog.",
1861
+ );
1862
+ }
1863
+ return candidate;
1864
+ }
1865
+
886
1866
  function capabilities() {
887
- const runtimeCapabilities = runtime.capabilities();
1867
+ const runtimeCapabilities = measuredRuntimeCapabilities(runtime.capabilities());
888
1868
  return Object.freeze({
889
1869
  localOnly: true,
890
1870
  toolCalls: "structural-only",
891
1871
  webAssembly: runtimeCapabilities.webAssembly,
892
1872
  opfs: runtimeCapabilities.opfs,
893
1873
  webgpu: runtimeCapabilities.webgpu,
1874
+ webgpuApiPresent: runtimeCapabilities.webgpuApiPresent,
1875
+ webgpuOperational: runtimeCapabilities.webgpuOperational,
1876
+ webgpuEvidenceProtocol: runtimeCapabilities.webgpuEvidenceProtocol,
1877
+ webgpuAdapterSelectionEvent: WEBGPU_ADAPTER_SELECTED_EVENT,
894
1878
  crossOriginIsolated: runtimeCapabilities.crossOriginIsolated,
895
1879
  secureContext: runtimeCapabilities.secureContext,
896
1880
  hardwareConcurrency: runtimeCapabilities.hardwareConcurrency,
1881
+ deviceMemory: runtimeCapabilities.deviceMemory,
1882
+ orderedModelFiles: true,
1883
+ capabilityPolicyProtocol: CAPABILITY_POLICY_PROTOCOL,
897
1884
  });
898
1885
  }
899
1886
 
900
- function status() {
1887
+ function currentCapabilityPolicy() {
1888
+ const runtimeCapabilities = measuredRuntimeCapabilities(runtime.capabilities());
1889
+ return capabilityPolicy(
1890
+ activeSource,
1891
+ runtimeCapabilities,
1892
+ activeLoadPlan,
1893
+ storagePolicies.get(activeSource.id) ?? null,
1894
+ runtime.evidence(),
1895
+ state,
1896
+ modelFailures.get(activeSource.id) ?? null,
1897
+ );
1898
+ }
1899
+
1900
+ function catalog() {
1901
+ const runtimeCapabilities = measuredRuntimeCapabilities(runtime.capabilities());
1902
+ return Object.freeze(modelSources.map((candidate) => {
1903
+ const selected = candidate === activeSource;
1904
+ const plan = selected
1905
+ ? activeLoadPlan
1906
+ : capabilityLoadPlan(runtimeCapabilities, runtimeLoadDefaults);
1907
+ const policy = capabilityPolicy(
1908
+ candidate,
1909
+ runtimeCapabilities,
1910
+ plan,
1911
+ storagePolicies.get(candidate.id) ?? null,
1912
+ selected ? runtime.evidence() : null,
1913
+ selected ? state : "unloaded",
1914
+ modelFailures.get(candidate.id) ?? null,
1915
+ );
1916
+ return Object.freeze({
1917
+ ...publicDescriptor(candidate),
1918
+ compatibility: policy.compatibility,
1919
+ compatibilityDetails: policy,
1920
+ });
1921
+ }));
1922
+ }
1923
+
1924
+ function status(context = {}) {
1925
+ const effectiveSecurity = activeSecurity ?? resolveModelSecurity({
1926
+ app: context?.security,
1927
+ binding: bindingSecurity,
1928
+ });
1929
+ const integrity = activeIntegrity ?? integritySnapshot(effectiveSecurity, activeSource);
901
1930
  return Object.freeze({
902
1931
  protocol: ARCANE_AI_ADAPTER_PROTOCOL,
903
1932
  provider: "arcane-browser-wasm-wllama",
@@ -905,16 +1934,38 @@ export function createBrowserWasmLlmProvider({
905
1934
  loaded: state === "ready" && runtime.isLoaded(),
906
1935
  busy: activeCount > 0,
907
1936
  queued: Math.max(0, queueDepth - activeCount),
908
- model: publicDescriptor(source),
1937
+ model: publicDescriptor(activeSource),
909
1938
  cache: Object.freeze({ state: cacheState, schema: MODEL_MANIFEST_SCHEMA }),
1939
+ security: securitySnapshot(effectiveSecurity),
1940
+ integrity,
910
1941
  progress: progressState,
911
1942
  error: errorState,
912
1943
  runtime: runtime.authority,
1944
+ runtimeEvidence: runtime.evidence(),
913
1945
  capabilities: capabilities(),
1946
+ capabilityPolicy: currentCapabilityPolicy(),
914
1947
  origin: globalThis.location?.origin ?? null,
915
1948
  });
916
1949
  }
917
1950
 
1951
+ function reconcileRuntimeAfterRequestError(error) {
1952
+ if (runtime.isLoaded()) return;
1953
+ const runtimeState = runtime.evidence()?.state;
1954
+ state = runtimeState === "error"
1955
+ || error?.code === "ARCANE_AI_WORKER_TERMINATION_UNCONFIRMED"
1956
+ ? "error"
1957
+ : "unloaded";
1958
+ progressState = null;
1959
+ errorState = state === "error"
1960
+ ? Object.freeze({
1961
+ code: typeof error?.code === "string" ? error.code : "ARCANE_AI_RUNTIME_FAILED",
1962
+ message: typeof error?.message === "string"
1963
+ ? error.message
1964
+ : "The browser-WASM runtime failed.",
1965
+ })
1966
+ : null;
1967
+ }
1968
+
918
1969
  function report(value, options, context) {
919
1970
  progressState = value;
920
1971
  options?.onProgress?.(value);
@@ -931,42 +1982,133 @@ export function createBrowserWasmLlmProvider({
931
1982
  "The browser-WASM model cannot load while unload is in progress.",
932
1983
  );
933
1984
  }
934
- if (state === "ready") return Object.freeze({ model: publicDescriptor(source), status: status() });
935
- if (loadPromise) return loadPromise;
1985
+ const requestedSource = sourceForModel(options.modelId);
1986
+ const effectiveSecurity = resolveModelSecurity({
1987
+ app: context.security,
1988
+ binding: bindingSecurity,
1989
+ load: options.security,
1990
+ });
1991
+ assertDescriptorChecks(requestedSource, effectiveSecurity);
1992
+ const requestedLoadPlan = capabilityLoadPlan(
1993
+ measuredRuntimeCapabilities(runtime.capabilities()),
1994
+ runtimeLoadDefaults,
1995
+ options,
1996
+ );
1997
+ const oversized = oversizedModelFile(requestedSource);
1998
+ if (oversized) {
1999
+ modelFailures.set(requestedSource.id, Object.freeze({
2000
+ code: "ARCANE_AI_MODEL_SHARD_TOO_LARGE",
2001
+ }));
2002
+ throw fail(
2003
+ "ARCANE_AI_MODEL_SHARD_TOO_LARGE",
2004
+ `Model file ${oversized.name} exceeds Wllama's ${WLLAMA_MAX_FILE_BYTES}-byte boundary.`,
2005
+ );
2006
+ }
2007
+ if (state === "ready") {
2008
+ if (activeSource !== requestedSource) {
2009
+ throw fail(
2010
+ "ARCANE_AI_MODEL_RELOAD_REQUIRED",
2011
+ "Unload the active browser-WASM model before selecting another model.",
2012
+ );
2013
+ }
2014
+ if (!sameLoadPlan(activeLoadPlan, requestedLoadPlan)) {
2015
+ throw fail(
2016
+ "ARCANE_AI_LOAD_PLAN_RELOAD_REQUIRED",
2017
+ "Unload the browser-WASM model before changing its context or batch load plan.",
2018
+ );
2019
+ }
2020
+ if (sameModelSecurity(activeSecurity, effectiveSecurity)) {
2021
+ activeSecurity = effectiveSecurity;
2022
+ return Object.freeze({ model: publicDescriptor(activeSource), status: status() });
2023
+ }
2024
+ throw fail(
2025
+ "ARCANE_AI_SECURITY_RELOAD_REQUIRED",
2026
+ "Unload the browser-WASM model before changing its effective security checks.",
2027
+ );
2028
+ }
2029
+ if (loadPromise) {
2030
+ if (activeSource !== requestedSource) {
2031
+ throw fail(
2032
+ "ARCANE_AI_MODEL_RELOAD_REQUIRED",
2033
+ "The in-flight browser-WASM load owns a different model.",
2034
+ );
2035
+ }
2036
+ if (!sameLoadPlan(activeLoadPlan, requestedLoadPlan)) {
2037
+ throw fail(
2038
+ "ARCANE_AI_LOAD_PLAN_RELOAD_REQUIRED",
2039
+ "The in-flight browser-WASM load uses a different context or batch plan.",
2040
+ );
2041
+ }
2042
+ if (sameModelSecurity(activeSecurity, effectiveSecurity)) {
2043
+ activeSecurity = effectiveSecurity;
2044
+ return loadPromise;
2045
+ }
2046
+ throw fail(
2047
+ "ARCANE_AI_SECURITY_RELOAD_REQUIRED",
2048
+ "The in-flight browser-WASM load uses different effective security checks.",
2049
+ );
2050
+ }
936
2051
  const externalSignal = options.signal ?? context.signal ?? null;
937
2052
  const linked = linkAbortSignal(externalSignal);
938
2053
  const signal = linked.controller.signal;
939
2054
  const generation = ++lifecycleGeneration;
940
2055
  loadAbort = linked.controller;
2056
+ activeSource = requestedSource;
2057
+ activeSecurity = effectiveSecurity;
2058
+ activeLoadPlan = requestedLoadPlan;
2059
+ activeIntegrity = integritySnapshot(effectiveSecurity, activeSource);
941
2060
  state = "loading";
942
2061
  progressState = null;
943
2062
  errorState = null;
944
2063
  loadPromise = (async () => {
945
2064
  try {
946
2065
  throwIfAborted(signal, "load");
947
- const admitted = await store.ensure(source, {
2066
+ const admitted = await store.ensure(activeSource, {
948
2067
  signal,
949
2068
  offline: options.offline === true,
2069
+ security: effectiveSecurity,
950
2070
  onProgress: (value) => report(value, options, context),
2071
+ onCapabilityPolicy: (value) => { storagePolicies.set(activeSource.id, value); },
951
2072
  });
952
2073
  cacheState = admitted.cache;
2074
+ activeIntegrity = admitted.integrity;
953
2075
  throwIfAborted(signal, "load");
954
2076
  if (generation !== lifecycleGeneration || state !== "loading") {
955
2077
  throw fail("ARCANE_AI_OPERATION_SUPERSEDED", "The model load was superseded by unload.");
956
2078
  }
957
- report(progress(source, "initialize", source.bytes), options, context);
2079
+ report(
2080
+ progress(activeSource, "initialize", admitted.observedBytes, admitted.observedBytes),
2081
+ options,
2082
+ context,
2083
+ );
958
2084
  throwIfAborted(signal, "load");
959
2085
  if (generation !== lifecycleGeneration || state !== "loading") {
960
2086
  throw fail("ARCANE_AI_OPERATION_SUPERSEDED", "The model load was superseded by unload.");
961
2087
  }
962
- const modelFile = typeof globalThis.File === "function"
963
- ? new File([admitted.file], source.name, { type: "application/octet-stream" })
964
- : admitted.file;
965
- await runtime.load([modelFile], {
966
- ...loadDefaults,
967
- ...options,
2088
+ const members = sourceMetadata(activeSource).files;
2089
+ const modelFiles = admitted.files.map((file, index) => (
2090
+ typeof globalThis.File === "function"
2091
+ ? new File([file], members[index].name, { type: "application/octet-stream" })
2092
+ : file
2093
+ ));
2094
+ const runtimeOptions = { ...options };
2095
+ delete runtimeOptions.security;
2096
+ delete runtimeOptions.offline;
2097
+ delete runtimeOptions.onProgress;
2098
+ delete runtimeOptions.modelId;
2099
+ await runtime.load(modelFiles, {
2100
+ ...runtimeLoadDefaults,
2101
+ ...runtimeOptions,
2102
+ ...activeLoadPlan,
968
2103
  signal,
969
2104
  });
2105
+ if (!runtime.isLoaded()) {
2106
+ throw fail(
2107
+ "ARCANE_AI_LOAD_FAILED",
2108
+ "Wllama did not confirm that the model loaded successfully.",
2109
+ );
2110
+ }
2111
+ emitWebgpuAdapterSelection(activeSource, runtime);
970
2112
  throwIfAborted(signal, "load");
971
2113
  if (generation !== lifecycleGeneration || state !== "loading") {
972
2114
  await runtime.exit();
@@ -974,14 +2116,23 @@ export function createBrowserWasmLlmProvider({
974
2116
  }
975
2117
  state = "ready";
976
2118
  progressState = null;
977
- return Object.freeze({ model: publicDescriptor(source), status: status() });
2119
+ modelFailures.delete(activeSource.id);
2120
+ return Object.freeze({ model: publicDescriptor(activeSource), status: status() });
978
2121
  } catch (error) {
979
- await runtime.exit().catch(() => undefined);
980
- const normalized = normalizeArcaneAIError(error, {
2122
+ let cleanupFailure = null;
2123
+ try {
2124
+ await runtime.exit();
2125
+ } catch (cleanupError) {
2126
+ cleanupFailure = cleanupError;
2127
+ }
2128
+ const surfaced = cleanupFailure ?? error;
2129
+ const normalized = normalizeArcaneAIError(surfaced, {
981
2130
  kind: "llm",
982
2131
  operation: "load",
983
- signal,
2132
+ signal: normalizationSignal(surfaced, signal),
984
2133
  });
2134
+ const modelFailure = stableModelFailure(normalized);
2135
+ if (modelFailure) modelFailures.set(activeSource.id, modelFailure);
985
2136
  if (generation === lifecycleGeneration && state === "loading") {
986
2137
  state = "error";
987
2138
  errorState = Object.freeze({ code: normalized.code, message: normalized.message });
@@ -1020,11 +2171,13 @@ export function createBrowserWasmLlmProvider({
1020
2171
  throwIfAborted(linked.controller.signal);
1021
2172
  return validateCompletion(completion, request.id);
1022
2173
  } catch (error) {
1023
- throw normalizeArcaneAIError(error, {
2174
+ const normalized = normalizeArcaneAIError(error, {
1024
2175
  kind: "llm",
1025
2176
  operation: "request",
1026
- signal: linked.controller.signal,
2177
+ signal: normalizationSignal(error, linked.controller.signal),
1027
2178
  });
2179
+ reconcileRuntimeAfterRequestError(normalized);
2180
+ throw normalized;
1028
2181
  } finally {
1029
2182
  activeCount -= 1;
1030
2183
  activeAbort = null;
@@ -1044,11 +2197,12 @@ export function createBrowserWasmLlmProvider({
1044
2197
  runtime,
1045
2198
  request,
1046
2199
  signal: externalSignal,
1047
- onSettled() {
2200
+ onSettled(error) {
1048
2201
  if (settled) return;
1049
2202
  settled = true;
1050
2203
  activeCount -= 1;
1051
2204
  activeAbort = null;
2205
+ if (error) reconcileRuntimeAfterRequestError(error);
1052
2206
  },
1053
2207
  });
1054
2208
  activeAbort = Object.freeze({
@@ -1087,12 +2241,14 @@ export function createBrowserWasmLlmProvider({
1087
2241
  state = "unloaded";
1088
2242
  progressState = null;
1089
2243
  errorState = null;
2244
+ activeSecurity = null;
2245
+ activeIntegrity = null;
1090
2246
  return status();
1091
2247
  } catch (error) {
1092
2248
  const normalized = normalizeArcaneAIError(error, {
1093
2249
  kind: "llm",
1094
2250
  operation: "unload",
1095
- signal,
2251
+ signal: normalizationSignal(error, signal),
1096
2252
  });
1097
2253
  state = "error";
1098
2254
  errorState = Object.freeze({ code: normalized.code, message: normalized.message });
@@ -1134,7 +2290,8 @@ export function createBrowserWasmLlmProvider({
1134
2290
  return Object.freeze({
1135
2291
  protocol: ARCANE_AI_ADAPTER_PROTOCOL,
1136
2292
  id: "arcane-browser-wasm-wllama",
1137
- model: publicDescriptor(source),
2293
+ model: publicDescriptor(defaultSource),
2294
+ catalog,
1138
2295
  capabilities,
1139
2296
  status,
1140
2297
  load,
@@ -1148,4 +2305,217 @@ export function createBrowserWasmLlmProvider({
1148
2305
  });
1149
2306
  }
1150
2307
 
2308
+ function assertV1LlmAdapterSelection(selection, providerId, modelIds, role) {
2309
+ if (role !== "llm") {
2310
+ throw fail("ARCANE_AI_PROVIDER_ROLE_MISMATCH", "The browser-WASM adapter serves only the LLM role.");
2311
+ }
2312
+ if (
2313
+ !selection
2314
+ || typeof selection !== "object"
2315
+ || Array.isArray(selection)
2316
+ || selection.providerId !== providerId
2317
+ || !modelIds.has(selection.modelId)
2318
+ || selection.localOnly !== true
2319
+ ) {
2320
+ throw fail(
2321
+ "ARCANE_AI_MODEL_AUTHORITY_REQUIRED",
2322
+ "The browser-WASM adapter requires its exact local-only provider and model selection.",
2323
+ );
2324
+ }
2325
+ }
2326
+
2327
+ function provider2ByteProgress(value) {
2328
+ const phase = typeof value?.phase === "string" ? value.phase.trim() : "";
2329
+ const completed = Number(value?.loaded);
2330
+ const total = value?.total === null ? null : Number(value?.total);
2331
+ if (
2332
+ !phase
2333
+ || !Number.isSafeInteger(completed)
2334
+ || completed < 0
2335
+ || (total !== null && (!Number.isSafeInteger(total) || total < 0))
2336
+ || (total !== null && completed > total)
2337
+ ) {
2338
+ throw fail("ARCANE_AI_PROVIDER_PROGRESS_INVALID", "The browser-WASM provider returned invalid byte progress.");
2339
+ }
2340
+ return Object.freeze({ phase, completed, total, unit: "bytes", heartbeat: false });
2341
+ }
2342
+
2343
+ /**
2344
+ * Projects the existing browser-WASM LLM provider into the provider-neutral
2345
+ * Arcane AI /2 lifecycle without changing the provider's public v1 contract.
2346
+ * The adapter is local-only, never falls back, and never executes tool calls.
2347
+ */
2348
+ export function adaptV1LlmProvider(provider) {
2349
+ if (!provider || typeof provider !== "object" || Array.isArray(provider)) {
2350
+ throw new TypeError("adaptV1LlmProvider requires an Arcane browser-WASM LLM provider.");
2351
+ }
2352
+ const existing = V1_LLM_PROVIDER_ADAPTERS.get(provider);
2353
+ if (existing) return existing;
2354
+ if (provider.protocol !== ARCANE_AI_ADAPTER_PROTOCOL) {
2355
+ throw new TypeError(`The browser-WASM LLM provider protocol must equal ${ARCANE_AI_ADAPTER_PROTOCOL}.`);
2356
+ }
2357
+
2358
+ const providerId = requiredText(provider.id, "provider id");
2359
+ const model = modelDescriptor(provider.model);
2360
+ const requiredMethods = ["capabilities", "status", "load", "unload", "chat", "stream", "dispose"];
2361
+ const methods = Object.create(null);
2362
+ for (const method of requiredMethods) {
2363
+ if (typeof provider[method] !== "function") {
2364
+ throw new TypeError(`The browser-WASM LLM provider is missing ${method}().`);
2365
+ }
2366
+ methods[method] = provider[method].bind(provider);
2367
+ }
2368
+ if (methods.capabilities()?.localOnly !== true) {
2369
+ throw new TypeError("The browser-WASM LLM provider must be explicitly local-only.");
2370
+ }
2371
+
2372
+ const fallbackCatalog = Object.freeze([model]);
2373
+ const initialCatalog = typeof provider.catalog === "function"
2374
+ ? provider.catalog()
2375
+ : fallbackCatalog;
2376
+ if (!Array.isArray(initialCatalog) || initialCatalog.length === 0) {
2377
+ throw new TypeError("The browser-WASM provider catalog must be a nonempty array.");
2378
+ }
2379
+ const catalogModels = new Map();
2380
+ for (const entry of initialCatalog) {
2381
+ const descriptor = modelDescriptor(entry);
2382
+ if (catalogModels.has(descriptor.id)) {
2383
+ throw new TypeError("The browser-WASM provider catalog model ids must be unique.");
2384
+ }
2385
+ catalogModels.set(descriptor.id, descriptor);
2386
+ }
2387
+ const modelIds = new Set(catalogModels.keys());
2388
+ let disposed = false;
2389
+
2390
+ function assertSelection(selection, role) {
2391
+ assertV1LlmAdapterSelection(selection, providerId, modelIds, role);
2392
+ }
2393
+
2394
+ function authorityFor(selection) {
2395
+ const selectedModel = catalogModels.get(selection.modelId);
2396
+ return Object.freeze({
2397
+ protocol: AI_MODEL_AUTHORITY_PROTOCOL,
2398
+ providerId,
2399
+ modelId: selectedModel.id,
2400
+ admitted: true,
2401
+ localOnly: true,
2402
+ model: selectedModel,
2403
+ });
2404
+ }
2405
+
2406
+ function assertActiveSelection(selection) {
2407
+ const active = methods.status()?.model?.id;
2408
+ if (active !== selection.modelId) {
2409
+ throw fail(
2410
+ "ARCANE_AI_MODEL_NOT_READY",
2411
+ "The selected browser-WASM model is not the provider's active model.",
2412
+ );
2413
+ }
2414
+ }
2415
+
2416
+ function status() {
2417
+ const value = methods.status();
2418
+ if (
2419
+ !value
2420
+ || typeof value !== "object"
2421
+ || typeof value.state !== "string"
2422
+ || typeof value.loaded !== "boolean"
2423
+ || typeof value.busy !== "boolean"
2424
+ ) {
2425
+ throw fail("ARCANE_AI_PROVIDER_STATUS_INVALID", "The browser-WASM provider returned an invalid status.");
2426
+ }
2427
+ return Object.freeze({
2428
+ state: disposed ? "disposed" : value.state,
2429
+ loaded: disposed ? false : value.loaded,
2430
+ busy: disposed ? false : value.busy,
2431
+ cache: value.cache,
2432
+ security: value.security,
2433
+ integrity: value.integrity,
2434
+ capabilityPolicy: value.capabilityPolicy,
2435
+ compatibility: value.capabilityPolicy?.compatibility ?? "unknown",
2436
+ });
2437
+ }
2438
+
2439
+ const adapted = Object.freeze({
2440
+ protocol: AI_PROVIDER_PROTOCOL,
2441
+ role: "llm",
2442
+ id: providerId,
2443
+ localOnly: true,
2444
+ catalog: () => typeof provider.catalog === "function"
2445
+ ? provider.catalog()
2446
+ : fallbackCatalog,
2447
+ async inspect(selection, { role = "llm", signal = null } = {}) {
2448
+ assertSelection(selection, role);
2449
+ throwIfAborted(signal, "inspect");
2450
+ if (disposed) {
2451
+ return Object.freeze({
2452
+ available: false,
2453
+ code: "ARCANE_AI_DISPOSED",
2454
+ message: "The browser-WASM provider is disposed.",
2455
+ });
2456
+ }
2457
+ const capabilities = methods.capabilities();
2458
+ const requirements = [
2459
+ [capabilities?.webAssembly === true, "WebAssembly"],
2460
+ [capabilities?.opfs === true, "OPFS"],
2461
+ [capabilities?.secureContext === true, "a secure context"],
2462
+ [capabilities?.webgpuApiPresent === true, "the WebGPU API"],
2463
+ ];
2464
+ const missing = requirements.find(([available]) => !available)?.[1] ?? null;
2465
+ if (missing) {
2466
+ return Object.freeze({
2467
+ available: false,
2468
+ code: "ARCANE_AI_PROVIDER_UNAVAILABLE",
2469
+ message: `The browser-WASM provider requires ${missing}.`,
2470
+ });
2471
+ }
2472
+ return Object.freeze({ available: true, authority: authorityFor(selection) });
2473
+ },
2474
+ status,
2475
+ async load({
2476
+ role = "llm",
2477
+ selection,
2478
+ signal = null,
2479
+ progress = null,
2480
+ security,
2481
+ } = {}) {
2482
+ assertSelection(selection, role);
2483
+ throwIfAborted(signal, "load");
2484
+ if (progress !== null && typeof progress !== "function") {
2485
+ throw new TypeError("The provider/2 progress sink must be a function or null.");
2486
+ }
2487
+ await methods.load({
2488
+ modelId: selection.modelId,
2489
+ signal,
2490
+ security,
2491
+ ...(progress ? { onProgress: (value) => progress(provider2ByteProgress(value)) } : {}),
2492
+ });
2493
+ return status();
2494
+ },
2495
+ request({ role = "llm", selection, operation, payload, signal = null } = {}) {
2496
+ assertSelection(selection, role);
2497
+ assertActiveSelection(selection);
2498
+ throwIfAborted(signal);
2499
+ if (operation === "chat") return methods.chat(payload, { signal });
2500
+ if (operation === "stream") return methods.stream(payload, { signal });
2501
+ throw fail("ARCANE_AI_PROVIDER_OPERATION_UNAVAILABLE", "The browser-WASM adapter supports only chat and stream.");
2502
+ },
2503
+ async unload({ role = "llm", selection, signal = null } = {}) {
2504
+ assertSelection(selection, role);
2505
+ throwIfAborted(signal, "unload");
2506
+ await methods.unload({ signal });
2507
+ return status();
2508
+ },
2509
+ async dispose({ role = "llm", selection, signal = null } = {}) {
2510
+ assertSelection(selection, role);
2511
+ throwIfAborted(signal, "dispose");
2512
+ await methods.dispose({ signal });
2513
+ disposed = true;
2514
+ return status();
2515
+ },
2516
+ });
2517
+ V1_LLM_PROVIDER_ADAPTERS.set(provider, adapted);
2518
+ return adapted;
2519
+ }
2520
+
1151
2521
  export { MODEL_MANIFEST_SCHEMA };