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.
- package/CHANGELOG.md +17 -0
- package/NOTICE +5 -3
- package/README.md +73 -24
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +67 -18
- package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +16 -5
- package/browser-runtime/ai/browser-kokoro-worker.mjs +3 -0
- package/browser-runtime/ai/browser-speech-artifacts.mjs +1108 -0
- package/browser-runtime/ai/browser-speech-providers.mjs +475 -0
- package/browser-runtime/ai/browser-speech.mjs +9 -0
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1537 -167
- package/browser-runtime/ai/browser-wasm.mjs +46 -1
- package/browser-runtime/ai/browser-whisper-worker.mjs +3 -0
- package/browser-runtime/ai/browser-wllama-runtime.mjs +677 -132
- package/browser-runtime/ai/model-controller.mjs +138 -12
- package/browser-runtime/ai/speech-worker-client.mjs +207 -0
- package/browser-runtime/ai/speech-worker-runtime.mjs +516 -0
- package/browser-runtime/ai/wllama/index.mjs +389 -0
- package/docs/architecture.md +132 -22
- package/docs/reference/README.md +1 -1
- package/docs/reference/ai/browser-wasm.md +101 -42
- package/docs/reference/availability-and-normalization.md +19 -5
- package/docs/reference/behavioral-testing.md +18 -5
- package/docs/reference/cli.md +2 -2
- package/docs/reference/inventory/package-api.json +14 -14
- package/docs/reference/protocols.md +4 -4
- package/docs/reference/sdk-api.md +68 -38
- package/docs/work-amplification.md +8 -4
- package/package.json +7 -3
- package/runtime/ARCANE_RUNTIME_RELEASE.json +50 -20
- package/runtime/arcane/components/chat.html +280 -62
- package/runtime/arcane/components/speech.html +1113 -265
- package/runtime/arcane/entities/Chat.js +246 -43
- package/runtime/arcane/modules/AI.js +713 -162
- package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
- package/runtime/arcane/modules/AIRuntimeState.js +872 -0
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +293 -27
- package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +682 -0
- package/runtime/arcane/modules/DocumentLexicalSearch.js +292 -0
- package/runtime/arcane/modules/PersistentAIChatSession.js +268 -0
- package/runtime/arcane/modules/StaticDocumentCatalog.js +25 -206
- package/schemas/arcane-lock.schema.json +6 -4
- package/src/cli/main.mjs +14 -2
- package/src/constants.mjs +1 -1
- package/src/dev-server.mjs +244 -13
- package/src/import-map.mjs +59 -1
- package/src/packager/core.mjs +2 -2
- package/src/runtime.mjs +14 -4
- package/src/sdk-browser-runtime.mjs +28 -75
- package/src/templates/workspace-template.mjs +4 -4
- package/src/toolchain.mjs +3 -0
- package/src/workspace-runtime.mjs +1 -1
- package/src/workspace.mjs +1 -1
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
import { createStreamingSha256 } from "./internal/sha256.mjs";
|
|
2
|
+
import {
|
|
3
|
+
normalizeModelSecurity,
|
|
4
|
+
resolveModelSecurity,
|
|
5
|
+
} from "./model-controller.mjs";
|
|
6
|
+
|
|
7
|
+
export const BROWSER_SPEECH_ARTIFACT_PROTOCOL =
|
|
8
|
+
"arcane-ai-browser-speech-artifacts/1";
|
|
9
|
+
|
|
10
|
+
const MODEL_AUTHORITY_PROTOCOL = "arcane-ai-model-authority/1";
|
|
11
|
+
const MANIFEST_SCHEMA = "arcane.ai.browser-speech.assets.v1";
|
|
12
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
13
|
+
const MUTABLE_PATH_PATTERN = /\/(?:resolve\/)?(?:main|master|latest)(?:\/|$)/iu;
|
|
14
|
+
const AUTHORITIES = new WeakSet();
|
|
15
|
+
const AUTHORITY_METADATA = new WeakMap();
|
|
16
|
+
const STORES = new WeakSet();
|
|
17
|
+
|
|
18
|
+
function speechError(code, message, cause) {
|
|
19
|
+
const error = cause === undefined
|
|
20
|
+
? new Error(message)
|
|
21
|
+
: new Error(message, { cause });
|
|
22
|
+
error.name = "ArcaneBrowserSpeechError";
|
|
23
|
+
error.code = code;
|
|
24
|
+
return error;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function throwIfAborted(signal) {
|
|
28
|
+
if (!signal?.aborted) return;
|
|
29
|
+
const error = speechError(
|
|
30
|
+
"ARCANE_AI_REQUEST_ABORTED",
|
|
31
|
+
"The browser speech operation was cancelled.",
|
|
32
|
+
signal.reason,
|
|
33
|
+
);
|
|
34
|
+
error.name = "AbortError";
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function requiredText(value, label) {
|
|
39
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
40
|
+
throw new TypeError(`${label} must be a nonempty string.`);
|
|
41
|
+
}
|
|
42
|
+
return value.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function identifier(value, label) {
|
|
46
|
+
const result = requiredText(value, label);
|
|
47
|
+
if (result.length > 128) {
|
|
48
|
+
throw new TypeError(`${label} must not exceed 128 characters.`);
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function immutableUrl(value, label) {
|
|
54
|
+
let result;
|
|
55
|
+
try {
|
|
56
|
+
result = new URL(value, globalThis.location?.href);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new TypeError(`${label} must be an absolute or same-origin URL.`);
|
|
59
|
+
}
|
|
60
|
+
const sameOrigin = globalThis.location?.origin
|
|
61
|
+
&& result.origin === globalThis.location.origin;
|
|
62
|
+
if (
|
|
63
|
+
(result.protocol !== "https:" && !sameOrigin)
|
|
64
|
+
|| result.username
|
|
65
|
+
|| result.password
|
|
66
|
+
|| result.hash
|
|
67
|
+
|| MUTABLE_PATH_PATTERN.test(result.pathname)
|
|
68
|
+
) {
|
|
69
|
+
throw new TypeError(
|
|
70
|
+
`${label} must be immutable HTTPS or a same-origin immutable URL without credentials or fragments.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return result.href;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeFile(value, kind, index, revision) {
|
|
77
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
78
|
+
throw new TypeError(`${kind} file ${String(index)} must be an object.`);
|
|
79
|
+
}
|
|
80
|
+
const path = requiredText(value.path ?? value.name, `${kind} file path`);
|
|
81
|
+
if (
|
|
82
|
+
path.startsWith("/")
|
|
83
|
+
|| path.includes("\\")
|
|
84
|
+
|| path.split("/").some((part) => !part || part === "." || part === "..")
|
|
85
|
+
) {
|
|
86
|
+
throw new TypeError(`${kind} file path must be a normalized relative path.`);
|
|
87
|
+
}
|
|
88
|
+
const url = immutableUrl(value.url, `${kind} file url`);
|
|
89
|
+
let bytes = null;
|
|
90
|
+
if (value.bytes !== undefined) {
|
|
91
|
+
if (!Number.isSafeInteger(value.bytes) || value.bytes < 1) {
|
|
92
|
+
throw new TypeError(`${kind} file bytes must be a positive safe integer.`);
|
|
93
|
+
}
|
|
94
|
+
bytes = value.bytes;
|
|
95
|
+
}
|
|
96
|
+
let sha256 = null;
|
|
97
|
+
if (value.sha256 !== undefined) {
|
|
98
|
+
sha256 = requiredText(value.sha256, `${kind} file sha256`).toLowerCase();
|
|
99
|
+
if (!SHA256_PATTERN.test(sha256)) {
|
|
100
|
+
throw new TypeError(`${kind} file sha256 must be 64 lowercase hexadecimal characters.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const identityUrl = url.toLowerCase();
|
|
104
|
+
if (
|
|
105
|
+
!identityUrl.includes(revision.toLowerCase())
|
|
106
|
+
&& (sha256 === null || !identityUrl.includes(sha256))
|
|
107
|
+
) {
|
|
108
|
+
throw new TypeError(
|
|
109
|
+
`${kind} file URL must contain its caller-supplied revision or SHA-256 identity.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return Object.freeze({
|
|
113
|
+
kind,
|
|
114
|
+
index,
|
|
115
|
+
path,
|
|
116
|
+
url,
|
|
117
|
+
bytes,
|
|
118
|
+
sha256,
|
|
119
|
+
mediaType: typeof value.mediaType === "string" && value.mediaType.trim()
|
|
120
|
+
? value.mediaType.trim()
|
|
121
|
+
: kind === "runtime" && /\.(?:m?js)$/iu.test(path)
|
|
122
|
+
? "text/javascript"
|
|
123
|
+
: "application/octet-stream",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function uniqueFiles(files, label, kind, revision) {
|
|
128
|
+
if (!Array.isArray(files) || files.length < 1) {
|
|
129
|
+
throw new TypeError(`${label} requires a nonempty files array.`);
|
|
130
|
+
}
|
|
131
|
+
const paths = new Set();
|
|
132
|
+
const urls = new Set();
|
|
133
|
+
return Object.freeze(files.map((value, index) => {
|
|
134
|
+
const file = normalizeFile(value, kind, index, revision);
|
|
135
|
+
if (paths.has(file.path) || urls.has(file.url)) {
|
|
136
|
+
throw new TypeError(`${label} file paths and URLs must be unique.`);
|
|
137
|
+
}
|
|
138
|
+
paths.add(file.path);
|
|
139
|
+
urls.add(file.url);
|
|
140
|
+
return file;
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function publicFile(file) {
|
|
145
|
+
const result = {
|
|
146
|
+
path: file.path,
|
|
147
|
+
url: file.url,
|
|
148
|
+
mediaType: file.mediaType,
|
|
149
|
+
};
|
|
150
|
+
if (file.bytes !== null) result.bytes = file.bytes;
|
|
151
|
+
if (file.sha256 !== null) result.sha256 = file.sha256;
|
|
152
|
+
return Object.freeze(result);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Admits one caller-owned browser speech model/runtime description. The SDK
|
|
157
|
+
* supplies no model URL or profile; applications choose every immutable byte.
|
|
158
|
+
*/
|
|
159
|
+
export function createBrowserSpeechAuthority({
|
|
160
|
+
providerId,
|
|
161
|
+
role,
|
|
162
|
+
model,
|
|
163
|
+
runtime,
|
|
164
|
+
security,
|
|
165
|
+
} = {}) {
|
|
166
|
+
const normalizedProviderId = identifier(providerId, "Browser speech providerId");
|
|
167
|
+
if (role !== "stt" && role !== "tts") {
|
|
168
|
+
throw new TypeError('Browser speech role must be "stt" or "tts".');
|
|
169
|
+
}
|
|
170
|
+
if (!model || typeof model !== "object" || Array.isArray(model)) {
|
|
171
|
+
throw new TypeError("Browser speech model descriptor is required.");
|
|
172
|
+
}
|
|
173
|
+
if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) {
|
|
174
|
+
throw new TypeError("Browser speech runtime descriptor is required.");
|
|
175
|
+
}
|
|
176
|
+
const normalizedSecurity = normalizeModelSecurity(
|
|
177
|
+
security,
|
|
178
|
+
"Browser speech provider security",
|
|
179
|
+
);
|
|
180
|
+
const modelId = identifier(model.id, "Browser speech model id");
|
|
181
|
+
const modelRevision = identifier(model.revision, "Browser speech model revision");
|
|
182
|
+
const repository = identifier(model.repository, "Browser speech model repository");
|
|
183
|
+
const modelFiles = uniqueFiles(
|
|
184
|
+
model.files,
|
|
185
|
+
"Browser speech model",
|
|
186
|
+
"model",
|
|
187
|
+
modelRevision,
|
|
188
|
+
);
|
|
189
|
+
const runtimeAdapter = requiredText(runtime.adapter, "Browser speech runtime adapter");
|
|
190
|
+
const expectedAdapter = role === "stt"
|
|
191
|
+
? "transformers-whisper"
|
|
192
|
+
: "kokoro-js";
|
|
193
|
+
if (runtimeAdapter !== expectedAdapter) {
|
|
194
|
+
throw new TypeError(`Browser ${role} runtime adapter must equal ${expectedAdapter}.`);
|
|
195
|
+
}
|
|
196
|
+
const runtimeVersion = identifier(runtime.version, "Browser speech runtime version");
|
|
197
|
+
const runtimeRevision = identifier(runtime.revision, "Browser speech runtime revision");
|
|
198
|
+
const runtimeFiles = uniqueFiles(
|
|
199
|
+
runtime.files,
|
|
200
|
+
"Browser speech runtime",
|
|
201
|
+
"runtime",
|
|
202
|
+
runtimeRevision,
|
|
203
|
+
);
|
|
204
|
+
const entry = requiredText(runtime.entry, "Browser speech runtime entry");
|
|
205
|
+
const entryFile = runtimeFiles.find((file) => file.path === entry);
|
|
206
|
+
if (!entryFile) {
|
|
207
|
+
throw new TypeError("Browser speech runtime entry must name one runtime file path.");
|
|
208
|
+
}
|
|
209
|
+
if (!/\.(?:m?js)$/iu.test(entryFile.path) || entryFile.mediaType !== "text/javascript") {
|
|
210
|
+
throw new TypeError("Browser speech runtime entry must be a JavaScript module.");
|
|
211
|
+
}
|
|
212
|
+
const normalizedModel = Object.freeze({
|
|
213
|
+
id: modelId,
|
|
214
|
+
repository,
|
|
215
|
+
revision: modelRevision,
|
|
216
|
+
defaultVoice: role === "tts"
|
|
217
|
+
? identifier(model.defaultVoice, "Browser Kokoro defaultVoice")
|
|
218
|
+
: null,
|
|
219
|
+
files: modelFiles,
|
|
220
|
+
});
|
|
221
|
+
const normalizedRuntime = Object.freeze({
|
|
222
|
+
adapter: runtimeAdapter,
|
|
223
|
+
version: runtimeVersion,
|
|
224
|
+
revision: runtimeRevision,
|
|
225
|
+
entry,
|
|
226
|
+
files: runtimeFiles,
|
|
227
|
+
});
|
|
228
|
+
const files = Object.freeze([...runtimeFiles, ...modelFiles]);
|
|
229
|
+
const allPaths = new Set();
|
|
230
|
+
const allUrls = new Set();
|
|
231
|
+
for (const file of files) {
|
|
232
|
+
if (allPaths.has(file.path) || allUrls.has(file.url)) {
|
|
233
|
+
throw new TypeError("Browser speech runtime and model file identities must not overlap.");
|
|
234
|
+
}
|
|
235
|
+
allPaths.add(file.path);
|
|
236
|
+
allUrls.add(file.url);
|
|
237
|
+
}
|
|
238
|
+
const authority = Object.freeze({
|
|
239
|
+
protocol: MODEL_AUTHORITY_PROTOCOL,
|
|
240
|
+
providerId: normalizedProviderId,
|
|
241
|
+
modelId,
|
|
242
|
+
admitted: true,
|
|
243
|
+
role,
|
|
244
|
+
repository,
|
|
245
|
+
revision: modelRevision,
|
|
246
|
+
defaultVoice: normalizedModel.defaultVoice,
|
|
247
|
+
runtime: Object.freeze({
|
|
248
|
+
adapter: normalizedRuntime.adapter,
|
|
249
|
+
version: normalizedRuntime.version,
|
|
250
|
+
revision: normalizedRuntime.revision,
|
|
251
|
+
entry: normalizedRuntime.entry,
|
|
252
|
+
files: Object.freeze(runtimeFiles.map(publicFile)),
|
|
253
|
+
}),
|
|
254
|
+
files: Object.freeze(modelFiles.map(publicFile)),
|
|
255
|
+
security: normalizedSecurity,
|
|
256
|
+
});
|
|
257
|
+
AUTHORITIES.add(authority);
|
|
258
|
+
AUTHORITY_METADATA.set(authority, Object.freeze({
|
|
259
|
+
model: normalizedModel,
|
|
260
|
+
runtime: normalizedRuntime,
|
|
261
|
+
files,
|
|
262
|
+
}));
|
|
263
|
+
return authority;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function authorityProjection(authority) {
|
|
267
|
+
return Object.freeze({
|
|
268
|
+
protocol: authority.protocol,
|
|
269
|
+
providerId: authority.providerId,
|
|
270
|
+
modelId: authority.modelId,
|
|
271
|
+
role: authority.role,
|
|
272
|
+
repository: authority.repository,
|
|
273
|
+
revision: authority.revision,
|
|
274
|
+
runtime: authority.runtime,
|
|
275
|
+
files: authority.files,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function storageKey(authority) {
|
|
280
|
+
const digest = createStreamingSha256();
|
|
281
|
+
digest.update(new TextEncoder().encode(JSON.stringify(authorityProjection(authority))));
|
|
282
|
+
return digest.digestHex();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function storageNames(authority, files) {
|
|
286
|
+
const prefix = `arcane-speech-${storageKey(authority)}`;
|
|
287
|
+
return Object.freeze({
|
|
288
|
+
key: prefix,
|
|
289
|
+
manifest: `${prefix}.complete.json`,
|
|
290
|
+
files: Object.freeze(files.map((_, index) =>
|
|
291
|
+
`${prefix}.${String(index).padStart(4, "0")}.artifact`)),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function manifestMatches(manifest, authority, files) {
|
|
296
|
+
return manifest?.schema === MANIFEST_SCHEMA
|
|
297
|
+
&& manifest.complete === true
|
|
298
|
+
&& JSON.stringify(manifest.authority) === JSON.stringify(authorityProjection(authority))
|
|
299
|
+
&& Array.isArray(manifest.files)
|
|
300
|
+
&& manifest.files.length === files.length;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function* byteChunks(body, signal) {
|
|
304
|
+
if (body instanceof Uint8Array || body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
|
|
305
|
+
throwIfAborted(signal);
|
|
306
|
+
yield body instanceof Uint8Array
|
|
307
|
+
? body
|
|
308
|
+
: new Uint8Array(body.buffer ?? body, body.byteOffset ?? 0, body.byteLength);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (body && typeof body.getReader === "function") {
|
|
312
|
+
const reader = body.getReader();
|
|
313
|
+
const abort = () => void reader.cancel(signal?.reason).catch(() => undefined);
|
|
314
|
+
signal?.addEventListener?.("abort", abort, { once: true });
|
|
315
|
+
try {
|
|
316
|
+
while (true) {
|
|
317
|
+
throwIfAborted(signal);
|
|
318
|
+
const { done, value } = await reader.read();
|
|
319
|
+
if (done) return;
|
|
320
|
+
yield value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
signal?.removeEventListener?.("abort", abort);
|
|
324
|
+
if (signal?.aborted) await reader.cancel(signal.reason).catch(() => undefined);
|
|
325
|
+
reader.releaseLock?.();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
throw speechError(
|
|
329
|
+
"ARCANE_AI_ARTIFACT_SOURCE_INVALID",
|
|
330
|
+
"A browser speech artifact did not provide readable bytes.",
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function providerProgress(phase, completed, total, heartbeat = false) {
|
|
335
|
+
return Object.freeze({ phase, completed, total, unit: "bytes", heartbeat });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Runtime entry bytes use one deliberately closed capability grammar. The only
|
|
339
|
+
// module reference is import.meta and the only artifact transport is fetch(),
|
|
340
|
+
// which the Worker replaces with its admitted object-URL map before import.
|
|
341
|
+
// Executable strings, child execution contexts, script loaders, and alternate
|
|
342
|
+
// network transports are outside the grammar. Literal escape sequences are
|
|
343
|
+
// decoded before the same capability tokens are evaluated.
|
|
344
|
+
const CLOSED_MODULE_OUT_OF_GRAMMAR_IDENTIFIERS = new Set([
|
|
345
|
+
"AsyncFunction",
|
|
346
|
+
"AsyncGeneratorFunction",
|
|
347
|
+
"EventSource",
|
|
348
|
+
"Function",
|
|
349
|
+
"GeneratorFunction",
|
|
350
|
+
"RTCPeerConnection",
|
|
351
|
+
"SharedWorker",
|
|
352
|
+
"WebSocket",
|
|
353
|
+
"WebTransport",
|
|
354
|
+
"Worker",
|
|
355
|
+
"XMLHttpRequest",
|
|
356
|
+
"eval",
|
|
357
|
+
"importScripts",
|
|
358
|
+
]);
|
|
359
|
+
const CLOSED_MODULE_OUT_OF_GRAMMAR_LITERAL =
|
|
360
|
+
/(?:^|[^A-Za-z0-9_$])(?:AsyncFunction|AsyncGeneratorFunction|EventSource|Function|GeneratorFunction|RTCPeerConnection|SharedWorker|WebSocket|WebTransport|Worker|XMLHttpRequest|constructor|eval|importScripts)(?:$|[^A-Za-z0-9_$])|(?:^|[^A-Za-z0-9_$])import\s*\(/u;
|
|
361
|
+
|
|
362
|
+
function assertSelfContainedModuleSource(source, label) {
|
|
363
|
+
let index = 0;
|
|
364
|
+
let nextTemplateId = 1;
|
|
365
|
+
const templateStack = [];
|
|
366
|
+
const literalFragments = [];
|
|
367
|
+
|
|
368
|
+
function fail() {
|
|
369
|
+
throw speechError(
|
|
370
|
+
"ARCANE_AI_RUNTIME_MODULE_GRAPH_UNDECLARED",
|
|
371
|
+
`${label} must be one self-contained JavaScript module without imports or re-exports.`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function identifierStart(character) {
|
|
376
|
+
return /[A-Za-z_$]/u.test(character ?? "");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function identifierPart(character) {
|
|
380
|
+
return /[A-Za-z0-9_$]/u.test(character ?? "");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function assertLiteral(value) {
|
|
384
|
+
if (CLOSED_MODULE_OUT_OF_GRAMMAR_LITERAL.test(value)) fail();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function assertComputedLiteral(value) {
|
|
388
|
+
if (value.includes("constructor") || /import\s*\(/u.test(value)) fail();
|
|
389
|
+
for (const identifier of CLOSED_MODULE_OUT_OF_GRAMMAR_IDENTIFIERS) {
|
|
390
|
+
if (value.includes(identifier)) fail();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function recordLiteral(start, end, value) {
|
|
395
|
+
assertLiteral(value);
|
|
396
|
+
literalFragments.push(Object.freeze({
|
|
397
|
+
start,
|
|
398
|
+
end,
|
|
399
|
+
value,
|
|
400
|
+
templateIds: Object.freeze([...templateStack]),
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function stripJoinerTrivia(value) {
|
|
405
|
+
return value
|
|
406
|
+
.replace(/\/\*[\s\S]*?\*\//gu, "")
|
|
407
|
+
.replace(/\/\/[^\r\n]*(?:\r?\n|$)/gu, "")
|
|
408
|
+
.replace(/\s+/gu, "");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function sharesTemplate(left, right) {
|
|
412
|
+
return left.templateIds.some((id) => right.templateIds.includes(id));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function staticallyJoins(left, right) {
|
|
416
|
+
const separator = stripJoinerTrivia(source.slice(left.end, right.start));
|
|
417
|
+
const usesConcat = separator.includes(".concat");
|
|
418
|
+
const withoutConcat = separator.replace(/\.concat/gu, "");
|
|
419
|
+
const usesPlus = withoutConcat.includes("+");
|
|
420
|
+
const usesTemplate = sharesTemplate(left, right)
|
|
421
|
+
&& (withoutConcat.includes("${") || withoutConcat.includes("}"));
|
|
422
|
+
if (!usesConcat && !usesPlus && !usesTemplate) return false;
|
|
423
|
+
return (usesTemplate ? /^[+()${}]*$/u : /^[+()]*$/u).test(withoutConcat);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function assertStaticLiteralChains() {
|
|
427
|
+
let chain = [];
|
|
428
|
+
function flushChain() {
|
|
429
|
+
if (chain.length > 1) {
|
|
430
|
+
assertComputedLiteral(chain.map((fragment) => fragment.value).join(""));
|
|
431
|
+
}
|
|
432
|
+
chain = [];
|
|
433
|
+
}
|
|
434
|
+
for (const fragment of literalFragments) {
|
|
435
|
+
const previous = chain[chain.length - 1];
|
|
436
|
+
if (previous && staticallyJoins(previous, fragment)) {
|
|
437
|
+
chain.push(fragment);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
flushChain();
|
|
441
|
+
chain.push(fragment);
|
|
442
|
+
}
|
|
443
|
+
flushChain();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function readEscape() {
|
|
447
|
+
if (index >= source.length) fail();
|
|
448
|
+
const character = source[index];
|
|
449
|
+
index += 1;
|
|
450
|
+
if (character === "x") {
|
|
451
|
+
const hex = source.slice(index, index + 2);
|
|
452
|
+
if (!/^[a-f0-9]{2}$/iu.test(hex)) fail();
|
|
453
|
+
index += 2;
|
|
454
|
+
return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
455
|
+
}
|
|
456
|
+
if (character === "u") {
|
|
457
|
+
if (source[index] === "{") {
|
|
458
|
+
const end = source.indexOf("}", index + 1);
|
|
459
|
+
if (end < 0) fail();
|
|
460
|
+
const hex = source.slice(index + 1, end);
|
|
461
|
+
if (!/^[a-f0-9]{1,6}$/iu.test(hex)) fail();
|
|
462
|
+
const codePoint = Number.parseInt(hex, 16);
|
|
463
|
+
if (codePoint > 0x10ffff) fail();
|
|
464
|
+
index = end + 1;
|
|
465
|
+
return String.fromCodePoint(codePoint);
|
|
466
|
+
}
|
|
467
|
+
const hex = source.slice(index, index + 4);
|
|
468
|
+
if (!/^[a-f0-9]{4}$/iu.test(hex)) fail();
|
|
469
|
+
index += 4;
|
|
470
|
+
return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
471
|
+
}
|
|
472
|
+
if (character === "\n") return "";
|
|
473
|
+
if (character === "\r") {
|
|
474
|
+
if (source[index] === "\n") index += 1;
|
|
475
|
+
return "";
|
|
476
|
+
}
|
|
477
|
+
return Object.freeze({
|
|
478
|
+
"0": "\0",
|
|
479
|
+
b: "\b",
|
|
480
|
+
f: "\f",
|
|
481
|
+
n: "\n",
|
|
482
|
+
r: "\r",
|
|
483
|
+
t: "\t",
|
|
484
|
+
v: "\v",
|
|
485
|
+
})[character] ?? character;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function readQuoted(quote) {
|
|
489
|
+
const start = index;
|
|
490
|
+
let value = "";
|
|
491
|
+
index += 1;
|
|
492
|
+
while (index < source.length) {
|
|
493
|
+
const character = source[index];
|
|
494
|
+
index += 1;
|
|
495
|
+
if (character === "\\") {
|
|
496
|
+
value += readEscape();
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (character === quote) {
|
|
500
|
+
recordLiteral(start, index, value);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (character === "\n" || character === "\r") fail();
|
|
504
|
+
value += character;
|
|
505
|
+
}
|
|
506
|
+
fail();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function skipRegex() {
|
|
510
|
+
index += 1;
|
|
511
|
+
let inClass = false;
|
|
512
|
+
while (index < source.length) {
|
|
513
|
+
const character = source[index];
|
|
514
|
+
index += 1;
|
|
515
|
+
if (character === "\\") {
|
|
516
|
+
index += 1;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (character === "[") inClass = true;
|
|
520
|
+
else if (character === "]") inClass = false;
|
|
521
|
+
else if (character === "/" && !inClass) {
|
|
522
|
+
while (/[A-Za-z]/u.test(source[index] ?? "")) index += 1;
|
|
523
|
+
return;
|
|
524
|
+
} else if (character === "\n" || character === "\r") {
|
|
525
|
+
fail();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
fail();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function canStartRegex(lastToken) {
|
|
532
|
+
return lastToken === null
|
|
533
|
+
|| [
|
|
534
|
+
"(", "[", "{", "=", ":", ",", ";", "!", "?",
|
|
535
|
+
"&&", "||", "=>", "return", "case", "throw",
|
|
536
|
+
].includes(lastToken);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function readTemplate() {
|
|
540
|
+
const templateId = nextTemplateId;
|
|
541
|
+
nextTemplateId += 1;
|
|
542
|
+
templateStack.push(templateId);
|
|
543
|
+
let fragmentStart = index;
|
|
544
|
+
let value = "";
|
|
545
|
+
index += 1;
|
|
546
|
+
while (index < source.length) {
|
|
547
|
+
const character = source[index];
|
|
548
|
+
index += 1;
|
|
549
|
+
if (character === "\\") {
|
|
550
|
+
value += readEscape();
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (character === "`") {
|
|
554
|
+
recordLiteral(fragmentStart, index, value);
|
|
555
|
+
templateStack.pop();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (character === "$" && source[index] === "{") {
|
|
559
|
+
recordLiteral(fragmentStart, index - 1, value);
|
|
560
|
+
value = "";
|
|
561
|
+
index += 1;
|
|
562
|
+
scanCode(true);
|
|
563
|
+
fragmentStart = index;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
value += character;
|
|
567
|
+
}
|
|
568
|
+
fail();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function scanCode(stopAtTemplateBrace = false) {
|
|
572
|
+
let nestedBraces = 0;
|
|
573
|
+
let lastToken = null;
|
|
574
|
+
while (index < source.length) {
|
|
575
|
+
const character = source[index];
|
|
576
|
+
const next = source[index + 1];
|
|
577
|
+
if (/\s/u.test(character)) {
|
|
578
|
+
index += 1;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (character === "/" && next === "/") {
|
|
582
|
+
index += 2;
|
|
583
|
+
while (index < source.length && source[index] !== "\n") index += 1;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (character === "/" && next === "*") {
|
|
587
|
+
const end = source.indexOf("*/", index + 2);
|
|
588
|
+
if (end < 0) fail();
|
|
589
|
+
index = end + 2;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (character === "'" || character === '"') {
|
|
593
|
+
if (lastToken === "from") fail();
|
|
594
|
+
readQuoted(character);
|
|
595
|
+
lastToken = "literal";
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (character === "`") {
|
|
599
|
+
if (lastToken === "from") fail();
|
|
600
|
+
readTemplate();
|
|
601
|
+
lastToken = "literal";
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (character === "/" && canStartRegex(lastToken)) {
|
|
605
|
+
skipRegex();
|
|
606
|
+
lastToken = "literal";
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (identifierStart(character)) {
|
|
610
|
+
const start = index;
|
|
611
|
+
index += 1;
|
|
612
|
+
while (identifierPart(source[index])) index += 1;
|
|
613
|
+
const word = source.slice(start, index);
|
|
614
|
+
if (CLOSED_MODULE_OUT_OF_GRAMMAR_IDENTIFIERS.has(word)) fail();
|
|
615
|
+
if (word === "constructor" && lastToken === ".") fail();
|
|
616
|
+
if (word === "import") {
|
|
617
|
+
while (/\s/u.test(source[index] ?? "")) index += 1;
|
|
618
|
+
if (source[index] === "." && source.slice(index + 1, index + 5) === "meta") {
|
|
619
|
+
index += 5;
|
|
620
|
+
lastToken = "import.meta";
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
fail();
|
|
624
|
+
}
|
|
625
|
+
lastToken = word;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (character === "\\") fail();
|
|
629
|
+
if (stopAtTemplateBrace && character === "}") {
|
|
630
|
+
if (nestedBraces === 0) {
|
|
631
|
+
index += 1;
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
nestedBraces -= 1;
|
|
635
|
+
} else if (stopAtTemplateBrace && character === "{") {
|
|
636
|
+
nestedBraces += 1;
|
|
637
|
+
}
|
|
638
|
+
const twoCharacters = `${character}${next ?? ""}`;
|
|
639
|
+
if (["&&", "||", "=>"].includes(twoCharacters)) {
|
|
640
|
+
lastToken = twoCharacters;
|
|
641
|
+
index += 2;
|
|
642
|
+
} else {
|
|
643
|
+
lastToken = character;
|
|
644
|
+
index += 1;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (stopAtTemplateBrace) fail();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
scanCode();
|
|
651
|
+
assertStaticLiteralChains();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function assertSelfContainedRuntime(admitted, metadata) {
|
|
655
|
+
const javascriptFiles = admitted.files.filter(({ descriptor }) =>
|
|
656
|
+
descriptor.kind === "runtime" && descriptor.mediaType === "text/javascript");
|
|
657
|
+
if (
|
|
658
|
+
javascriptFiles.length !== 1
|
|
659
|
+
|| javascriptFiles[0].descriptor.path !== metadata.runtime.entry
|
|
660
|
+
) {
|
|
661
|
+
throw speechError(
|
|
662
|
+
"ARCANE_AI_RUNTIME_MODULE_GRAPH_UNDECLARED",
|
|
663
|
+
"Browser speech requires exactly one admitted self-contained runtime module.",
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
const [{ descriptor, file }] = javascriptFiles;
|
|
667
|
+
assertSelfContainedModuleSource(await file.text(), descriptor.path);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function createObjectUrls(files, factory) {
|
|
671
|
+
const create = factory?.create ?? ((blob) => URL.createObjectURL(blob));
|
|
672
|
+
const revoke = factory?.revoke ?? ((url) => URL.revokeObjectURL(url));
|
|
673
|
+
if (typeof create !== "function" || typeof revoke !== "function") {
|
|
674
|
+
throw new TypeError("Browser speech objectUrlFactory requires create() and revoke().");
|
|
675
|
+
}
|
|
676
|
+
const created = [];
|
|
677
|
+
try {
|
|
678
|
+
const materialized = files.map(({ descriptor, file }) => {
|
|
679
|
+
const blob = file.type === descriptor.mediaType
|
|
680
|
+
? file
|
|
681
|
+
: new Blob([file], { type: descriptor.mediaType });
|
|
682
|
+
const url = create(blob);
|
|
683
|
+
created.push(url);
|
|
684
|
+
return Object.freeze({
|
|
685
|
+
kind: descriptor.kind,
|
|
686
|
+
path: descriptor.path,
|
|
687
|
+
sourceUrl: descriptor.url,
|
|
688
|
+
moduleUrl: url,
|
|
689
|
+
mediaType: descriptor.mediaType,
|
|
690
|
+
bytes: file.size,
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
return Object.freeze({
|
|
694
|
+
files: Object.freeze(materialized),
|
|
695
|
+
release() {
|
|
696
|
+
for (const url of created.splice(0).reverse()) {
|
|
697
|
+
try {
|
|
698
|
+
revoke(url);
|
|
699
|
+
} catch {
|
|
700
|
+
// Object URL revocation follows worker termination and is best effort.
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
} catch (error) {
|
|
706
|
+
for (const url of created.splice(0).reverse()) {
|
|
707
|
+
try {
|
|
708
|
+
revoke(url);
|
|
709
|
+
} catch {
|
|
710
|
+
// Preserve the materialization error.
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Stores an authority's complete runtime/model closure in an existing DBOPFS
|
|
719
|
+
* table. The completion manifest is always the final mutation.
|
|
720
|
+
*/
|
|
721
|
+
export function createDbopfsSpeechArtifactStore({
|
|
722
|
+
dbopfs,
|
|
723
|
+
tableName = "arcane_ai_browser_speech",
|
|
724
|
+
fetchImpl = null,
|
|
725
|
+
objectUrlFactory = null,
|
|
726
|
+
} = {}) {
|
|
727
|
+
if (!dbopfs || typeof dbopfs.getTableHandle !== "function") {
|
|
728
|
+
throw new TypeError("createDbopfsSpeechArtifactStore requires an existing DBOPFS instance.");
|
|
729
|
+
}
|
|
730
|
+
if (dbopfs.readyPromise !== undefined && typeof dbopfs.readyPromise?.then !== "function") {
|
|
731
|
+
throw new TypeError("The DBOPFS readyPromise must be thenable.");
|
|
732
|
+
}
|
|
733
|
+
const locks = dbopfs.lockManager ?? globalThis.navigator?.locks;
|
|
734
|
+
if (!locks || typeof locks.request !== "function") {
|
|
735
|
+
throw new TypeError(
|
|
736
|
+
"createDbopfsSpeechArtifactStore requires the browser Web Locks API.",
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
let tablePromise = null;
|
|
740
|
+
const operationTails = new Map();
|
|
741
|
+
|
|
742
|
+
function serializeAuthority(authority, operation) {
|
|
743
|
+
const key = storageKey(authority);
|
|
744
|
+
const previous = operationTails.get(key) ?? Promise.resolve();
|
|
745
|
+
const lockName = `arcane-ai-speech:${encodeURIComponent(tableName)}:${key}`;
|
|
746
|
+
const current = previous.catch(() => undefined).then(() => locks.request(
|
|
747
|
+
lockName,
|
|
748
|
+
{ mode: "exclusive", ifAvailable: true },
|
|
749
|
+
(lock) => {
|
|
750
|
+
if (!lock) {
|
|
751
|
+
throw speechError(
|
|
752
|
+
"ARCANE_AI_STORAGE_BUSY",
|
|
753
|
+
"Another browser context is updating this speech artifact authority.",
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
return operation();
|
|
757
|
+
},
|
|
758
|
+
));
|
|
759
|
+
const tail = current.catch(() => undefined);
|
|
760
|
+
operationTails.set(key, tail);
|
|
761
|
+
return current.finally(() => {
|
|
762
|
+
if (operationTails.get(key) === tail) operationTails.delete(key);
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function table() {
|
|
767
|
+
if (dbopfs.readyPromise) await dbopfs.readyPromise;
|
|
768
|
+
tablePromise ||= Promise.resolve(dbopfs.getTableHandle(tableName));
|
|
769
|
+
const result = await tablePromise;
|
|
770
|
+
if (!result || typeof result.getFileHandle !== "function" || typeof result.removeEntry !== "function") {
|
|
771
|
+
throw speechError("ARCANE_AI_STORAGE_UNAVAILABLE", "DBOPFS did not provide a speech artifact table.");
|
|
772
|
+
}
|
|
773
|
+
return result;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function removeEntry(name) {
|
|
777
|
+
try {
|
|
778
|
+
await (await table()).removeEntry(name);
|
|
779
|
+
return true;
|
|
780
|
+
} catch (error) {
|
|
781
|
+
if (error?.name === "NotFoundError" || error?.code === "ENOENT") return false;
|
|
782
|
+
throw speechError("ARCANE_AI_STORAGE_DELETE_FAILED", "Unable to remove a speech artifact.", error);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function readFile(name) {
|
|
787
|
+
try {
|
|
788
|
+
const handle = await (await table()).getFileHandle(name, { create: false });
|
|
789
|
+
return await handle.getFile();
|
|
790
|
+
} catch (error) {
|
|
791
|
+
if (error?.name === "NotFoundError" || error?.code === "ENOENT") return null;
|
|
792
|
+
throw speechError("ARCANE_AI_STORAGE_READ_FAILED", "Unable to read a speech artifact.", error);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function writeFile(name, body, { signal, onChunk } = {}) {
|
|
797
|
+
const directory = await table();
|
|
798
|
+
const handle = await directory.getFileHandle(name, { create: true });
|
|
799
|
+
const writable = await handle.createWritable();
|
|
800
|
+
let written = 0;
|
|
801
|
+
try {
|
|
802
|
+
for await (const chunk of byteChunks(body, signal)) {
|
|
803
|
+
await writable.write(chunk);
|
|
804
|
+
written += chunk.byteLength;
|
|
805
|
+
onChunk?.(chunk, written);
|
|
806
|
+
}
|
|
807
|
+
throwIfAborted(signal);
|
|
808
|
+
await writable.close();
|
|
809
|
+
return written;
|
|
810
|
+
} catch (error) {
|
|
811
|
+
await writable.abort?.(error).catch(() => undefined);
|
|
812
|
+
await directory.removeEntry(name).catch(() => undefined);
|
|
813
|
+
throw error;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function removeUnlocked(authority) {
|
|
818
|
+
if (!AUTHORITIES.has(authority)) {
|
|
819
|
+
throw new TypeError("Speech artifact removal requires an SDK-created authority.");
|
|
820
|
+
}
|
|
821
|
+
const metadata = AUTHORITY_METADATA.get(authority);
|
|
822
|
+
const names = storageNames(authority, metadata.files);
|
|
823
|
+
const results = await Promise.all([
|
|
824
|
+
removeEntry(names.manifest),
|
|
825
|
+
...names.files.map(removeEntry),
|
|
826
|
+
]);
|
|
827
|
+
return results.some(Boolean);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function readManifest(name) {
|
|
831
|
+
const file = await readFile(name);
|
|
832
|
+
if (!file) return null;
|
|
833
|
+
try {
|
|
834
|
+
return JSON.parse(await file.text());
|
|
835
|
+
} catch {
|
|
836
|
+
await removeEntry(name);
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function verifyFile(file, descriptor, security, signal, onProgress, phase) {
|
|
842
|
+
if (security.checks.byteLength && file.size !== descriptor.bytes) return false;
|
|
843
|
+
if (!security.checks.sha256) {
|
|
844
|
+
onProgress?.(providerProgress(phase, file.size, file.size));
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
847
|
+
const digest = createStreamingSha256();
|
|
848
|
+
let completed = 0;
|
|
849
|
+
for await (const chunk of byteChunks(file.stream(), signal)) {
|
|
850
|
+
digest.update(chunk);
|
|
851
|
+
completed += chunk.byteLength;
|
|
852
|
+
onProgress?.(providerProgress(phase, completed, file.size));
|
|
853
|
+
}
|
|
854
|
+
return completed === file.size && digest.digestHex() === descriptor.sha256;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function assertSecurityDescriptors(files, security) {
|
|
858
|
+
for (const descriptor of files) {
|
|
859
|
+
if (security.checks.byteLength && descriptor.bytes === null) {
|
|
860
|
+
throw new TypeError(
|
|
861
|
+
`${descriptor.kind} file ${descriptor.path} requires bytes under the effective security policy.`,
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
if (security.checks.sha256 && descriptor.sha256 === null) {
|
|
865
|
+
throw new TypeError(
|
|
866
|
+
`${descriptor.kind} file ${descriptor.path} requires sha256 under the effective security policy.`,
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function openCached(authority, { signal, onProgress, security } = {}) {
|
|
873
|
+
const metadata = AUTHORITY_METADATA.get(authority);
|
|
874
|
+
const names = storageNames(authority, metadata.files);
|
|
875
|
+
const manifest = await readManifest(names.manifest);
|
|
876
|
+
if (!manifestMatches(manifest, authority, metadata.files)) {
|
|
877
|
+
await removeUnlocked(authority);
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
const files = [];
|
|
881
|
+
for (let index = 0; index < metadata.files.length; index += 1) {
|
|
882
|
+
throwIfAborted(signal);
|
|
883
|
+
const descriptor = metadata.files[index];
|
|
884
|
+
const file = await readFile(names.files[index]);
|
|
885
|
+
const observed = manifest.files[index];
|
|
886
|
+
if (!file || observed?.path !== descriptor.path || observed?.bytes !== file.size) {
|
|
887
|
+
await removeUnlocked(authority);
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
if (!await verifyFile(
|
|
891
|
+
file,
|
|
892
|
+
descriptor,
|
|
893
|
+
security,
|
|
894
|
+
signal,
|
|
895
|
+
onProgress,
|
|
896
|
+
"verify-cache",
|
|
897
|
+
)) {
|
|
898
|
+
await removeUnlocked(authority);
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
files.push({ descriptor, file });
|
|
902
|
+
}
|
|
903
|
+
try {
|
|
904
|
+
await assertSelfContainedRuntime({ files }, metadata);
|
|
905
|
+
throwIfAborted(signal);
|
|
906
|
+
} catch (error) {
|
|
907
|
+
await removeUnlocked(authority);
|
|
908
|
+
throw error;
|
|
909
|
+
}
|
|
910
|
+
return Object.freeze({ files: Object.freeze(files), cache: "cached" });
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function install(authority, { signal, onProgress, security } = {}) {
|
|
914
|
+
const metadata = AUTHORITY_METADATA.get(authority);
|
|
915
|
+
const names = storageNames(authority, metadata.files);
|
|
916
|
+
const fetchFunction = fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
917
|
+
if (typeof fetchFunction !== "function") {
|
|
918
|
+
throw speechError("ARCANE_AI_ARTIFACT_SOURCE_UNAVAILABLE", "Browser fetch is unavailable.");
|
|
919
|
+
}
|
|
920
|
+
await removeUnlocked(authority);
|
|
921
|
+
const installed = [];
|
|
922
|
+
try {
|
|
923
|
+
for (let index = 0; index < metadata.files.length; index += 1) {
|
|
924
|
+
throwIfAborted(signal);
|
|
925
|
+
const descriptor = metadata.files[index];
|
|
926
|
+
let response;
|
|
927
|
+
try {
|
|
928
|
+
response = await fetchFunction(descriptor.url, {
|
|
929
|
+
cache: "no-store",
|
|
930
|
+
credentials: "omit",
|
|
931
|
+
mode: "cors",
|
|
932
|
+
redirect: "error",
|
|
933
|
+
referrerPolicy: "no-referrer",
|
|
934
|
+
signal,
|
|
935
|
+
});
|
|
936
|
+
} catch (error) {
|
|
937
|
+
if (signal?.aborted || error?.name === "AbortError") throwIfAborted(signal);
|
|
938
|
+
throw speechError("ARCANE_AI_ARTIFACT_DOWNLOAD_FAILED", "A speech artifact download failed.", error);
|
|
939
|
+
}
|
|
940
|
+
if (!response?.ok || !response.body) {
|
|
941
|
+
await response?.body?.cancel?.().catch(() => undefined);
|
|
942
|
+
throw speechError(
|
|
943
|
+
"ARCANE_AI_ARTIFACT_DOWNLOAD_FAILED",
|
|
944
|
+
`A speech artifact server returned HTTP ${response?.status ?? "unknown"}.`,
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
let finalUrl = null;
|
|
948
|
+
try {
|
|
949
|
+
finalUrl = typeof response.url === "string" && response.url
|
|
950
|
+
? new URL(response.url).href
|
|
951
|
+
: null;
|
|
952
|
+
} catch {
|
|
953
|
+
finalUrl = null;
|
|
954
|
+
}
|
|
955
|
+
if (response.redirected === true || finalUrl !== descriptor.url) {
|
|
956
|
+
await response.body.cancel?.().catch(() => undefined);
|
|
957
|
+
throw speechError(
|
|
958
|
+
"ARCANE_AI_ARTIFACT_SOURCE_CHANGED",
|
|
959
|
+
"A speech artifact response did not match its admitted URL.",
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
const header = response.headers?.get?.("content-length");
|
|
963
|
+
const reportedBytes = header ? Number(header) : null;
|
|
964
|
+
if (
|
|
965
|
+
security.checks.byteLength
|
|
966
|
+
&& Number.isSafeInteger(reportedBytes)
|
|
967
|
+
&& reportedBytes !== descriptor.bytes
|
|
968
|
+
) {
|
|
969
|
+
await response.body.cancel?.().catch(() => undefined);
|
|
970
|
+
throw speechError("ARCANE_AI_ARTIFACT_SIZE_MISMATCH", "Speech artifact Content-Length changed.");
|
|
971
|
+
}
|
|
972
|
+
const digest = security.checks.sha256
|
|
973
|
+
? createStreamingSha256()
|
|
974
|
+
: null;
|
|
975
|
+
const written = await writeFile(names.files[index], response.body, {
|
|
976
|
+
signal,
|
|
977
|
+
onChunk(chunk, completed) {
|
|
978
|
+
digest?.update(chunk);
|
|
979
|
+
if (security.checks.byteLength && completed > descriptor.bytes) {
|
|
980
|
+
throw speechError("ARCANE_AI_ARTIFACT_SIZE_MISMATCH", "A speech artifact exceeded its expected size.");
|
|
981
|
+
}
|
|
982
|
+
onProgress?.(providerProgress(
|
|
983
|
+
"download",
|
|
984
|
+
completed,
|
|
985
|
+
security.checks.byteLength ? descriptor.bytes : null,
|
|
986
|
+
));
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
if (security.checks.byteLength && written !== descriptor.bytes) {
|
|
990
|
+
throw speechError("ARCANE_AI_ARTIFACT_SIZE_MISMATCH", "A speech artifact byte count changed.");
|
|
991
|
+
}
|
|
992
|
+
if (digest && digest.digestHex() !== descriptor.sha256) {
|
|
993
|
+
throw speechError("ARCANE_AI_ARTIFACT_DIGEST_MISMATCH", "A speech artifact SHA-256 changed.");
|
|
994
|
+
}
|
|
995
|
+
const file = await readFile(names.files[index]);
|
|
996
|
+
if (!file || file.size !== written) {
|
|
997
|
+
throw speechError("ARCANE_AI_ARTIFACT_CACHE_REJECTED", "DBOPFS did not preserve a speech artifact.");
|
|
998
|
+
}
|
|
999
|
+
installed.push({ descriptor, file });
|
|
1000
|
+
}
|
|
1001
|
+
await assertSelfContainedRuntime({ files: installed }, metadata);
|
|
1002
|
+
throwIfAborted(signal);
|
|
1003
|
+
const manifest = Object.freeze({
|
|
1004
|
+
schema: MANIFEST_SCHEMA,
|
|
1005
|
+
complete: true,
|
|
1006
|
+
authority: authorityProjection(authority),
|
|
1007
|
+
files: Object.freeze(installed.map(({ descriptor, file }) => Object.freeze({
|
|
1008
|
+
path: descriptor.path,
|
|
1009
|
+
bytes: file.size,
|
|
1010
|
+
}))),
|
|
1011
|
+
completedAt: new Date().toISOString(),
|
|
1012
|
+
});
|
|
1013
|
+
const encoded = new TextEncoder().encode(`${JSON.stringify(manifest)}\n`);
|
|
1014
|
+
await writeFile(names.manifest, encoded, { signal });
|
|
1015
|
+
return Object.freeze({ files: Object.freeze(installed), cache: "installed" });
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
await removeUnlocked(authority).catch(() => undefined);
|
|
1018
|
+
throw error;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async function prepareUnlocked(authority, {
|
|
1023
|
+
signal,
|
|
1024
|
+
onProgress,
|
|
1025
|
+
offline = false,
|
|
1026
|
+
security,
|
|
1027
|
+
} = {}) {
|
|
1028
|
+
if (!AUTHORITIES.has(authority)) {
|
|
1029
|
+
throw new TypeError("Speech artifact preparation requires an SDK-created authority.");
|
|
1030
|
+
}
|
|
1031
|
+
throwIfAborted(signal);
|
|
1032
|
+
const effectiveSecurity = resolveModelSecurity({ load: security });
|
|
1033
|
+
const metadata = AUTHORITY_METADATA.get(authority);
|
|
1034
|
+
assertSecurityDescriptors(metadata.files, effectiveSecurity);
|
|
1035
|
+
const cached = await openCached(authority, {
|
|
1036
|
+
signal,
|
|
1037
|
+
onProgress,
|
|
1038
|
+
security: effectiveSecurity,
|
|
1039
|
+
});
|
|
1040
|
+
const admitted = cached ?? (offline
|
|
1041
|
+
? null
|
|
1042
|
+
: await install(authority, {
|
|
1043
|
+
signal,
|
|
1044
|
+
onProgress,
|
|
1045
|
+
security: effectiveSecurity,
|
|
1046
|
+
}));
|
|
1047
|
+
if (!admitted) {
|
|
1048
|
+
throw speechError("ARCANE_AI_ARTIFACT_OFFLINE_MISS", "No admitted offline speech cache is available.");
|
|
1049
|
+
}
|
|
1050
|
+
const materialized = createObjectUrls(admitted.files, objectUrlFactory);
|
|
1051
|
+
const runtimeFiles = materialized.files.filter((file) => file.kind === "runtime");
|
|
1052
|
+
const modelFiles = materialized.files.filter((file) => file.kind === "model");
|
|
1053
|
+
return Object.freeze({
|
|
1054
|
+
cache: admitted.cache,
|
|
1055
|
+
runtime: Object.freeze({
|
|
1056
|
+
adapter: metadata.runtime.adapter,
|
|
1057
|
+
version: metadata.runtime.version,
|
|
1058
|
+
revision: metadata.runtime.revision,
|
|
1059
|
+
entry: metadata.runtime.entry,
|
|
1060
|
+
moduleGraph: "self-contained",
|
|
1061
|
+
files: Object.freeze(runtimeFiles),
|
|
1062
|
+
}),
|
|
1063
|
+
model: Object.freeze({
|
|
1064
|
+
id: metadata.model.id,
|
|
1065
|
+
repository: metadata.model.repository,
|
|
1066
|
+
revision: metadata.model.revision,
|
|
1067
|
+
defaultVoice: metadata.model.defaultVoice,
|
|
1068
|
+
files: Object.freeze(modelFiles),
|
|
1069
|
+
}),
|
|
1070
|
+
release: materialized.release,
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function prepare(authority, options = {}) {
|
|
1075
|
+
if (!AUTHORITIES.has(authority)) {
|
|
1076
|
+
return Promise.reject(new TypeError(
|
|
1077
|
+
"Speech artifact preparation requires an SDK-created authority.",
|
|
1078
|
+
));
|
|
1079
|
+
}
|
|
1080
|
+
return serializeAuthority(authority, () => prepareUnlocked(authority, options));
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function remove(authority) {
|
|
1084
|
+
if (!AUTHORITIES.has(authority)) {
|
|
1085
|
+
return Promise.reject(new TypeError(
|
|
1086
|
+
"Speech artifact removal requires an SDK-created authority.",
|
|
1087
|
+
));
|
|
1088
|
+
}
|
|
1089
|
+
return serializeAuthority(authority, () => removeUnlocked(authority));
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const store = Object.freeze({
|
|
1093
|
+
protocol: BROWSER_SPEECH_ARTIFACT_PROTOCOL,
|
|
1094
|
+
tableName,
|
|
1095
|
+
prepare,
|
|
1096
|
+
remove,
|
|
1097
|
+
});
|
|
1098
|
+
STORES.add(store);
|
|
1099
|
+
return store;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
export function isBrowserSpeechAuthority(value) {
|
|
1103
|
+
return AUTHORITIES.has(value);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
export function isDbopfsSpeechArtifactStore(value) {
|
|
1107
|
+
return STORES.has(value);
|
|
1108
|
+
}
|