arcane-os 0.1.2 → 0.2.1

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 (54) hide show
  1. package/CHANGELOG.md +35 -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 +780 -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 +551 -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 +1394 -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 +382 -31
  37. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +1106 -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 +10 -6
  42. package/src/cli/main.mjs +14 -2
  43. package/src/constants.mjs +1 -1
  44. package/src/dev-server.mjs +273 -26
  45. package/src/doctor.mjs +1 -3
  46. package/src/import-map.mjs +193 -84
  47. package/src/packager/core.mjs +313 -41
  48. package/src/runtime.mjs +14 -4
  49. package/src/scaffold.mjs +45 -17
  50. package/src/sdk-browser-runtime.mjs +28 -75
  51. package/src/templates/workspace-template.mjs +27 -8
  52. package/src/toolchain.mjs +13 -2
  53. package/src/workspace-runtime.mjs +1 -1
  54. package/src/workspace.mjs +178 -25
@@ -1,5 +1,96 @@
1
1
  export const ARCANE_AI_ADAPTER_PROTOCOL = "arcane-ai-adapter/1";
2
2
 
3
+ const SECURITY_KEYS = Object.freeze(["secure", "checks"]);
4
+ const SECURITY_CHECK_KEYS = Object.freeze(["byteLength", "sha256"]);
5
+ const EMPTY_SECURITY_CHECKS = Object.freeze({});
6
+ const EMPTY_MODEL_SECURITY = Object.freeze({ checks: EMPTY_SECURITY_CHECKS });
7
+
8
+ function closedSecurityRecord(value, keys, label) {
9
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
10
+ throw new TypeError(`${label} must be a plain object when provided.`);
11
+ }
12
+ const prototype = Object.getPrototypeOf(value);
13
+ if (prototype !== Object.prototype && prototype !== null) {
14
+ throw new TypeError(`${label} must be a plain object when provided.`);
15
+ }
16
+ const descriptors = Object.getOwnPropertyDescriptors(value);
17
+ for (const key of Reflect.ownKeys(descriptors)) {
18
+ if (typeof key !== "string" || !keys.includes(key)) {
19
+ throw new TypeError(`${label} contains an unknown ${String(key)} field.`);
20
+ }
21
+ if (descriptors[key].get || descriptors[key].set) {
22
+ throw new TypeError(`${label}.${key} must be a data property.`);
23
+ }
24
+ }
25
+ return descriptors;
26
+ }
27
+
28
+ export function normalizeModelSecurity(value, label = "security") {
29
+ if (value === undefined) return EMPTY_MODEL_SECURITY;
30
+ const descriptors = closedSecurityRecord(value, SECURITY_KEYS, label);
31
+ const normalized = {};
32
+ if (descriptors.secure?.value !== undefined) {
33
+ if (typeof descriptors.secure.value !== "boolean") {
34
+ throw new TypeError(`${label}.secure must be a boolean when provided.`);
35
+ }
36
+ normalized.secure = descriptors.secure.value;
37
+ }
38
+
39
+ let checks = EMPTY_SECURITY_CHECKS;
40
+ if (descriptors.checks?.value !== undefined) {
41
+ const checkDescriptors = closedSecurityRecord(
42
+ descriptors.checks.value,
43
+ SECURITY_CHECK_KEYS,
44
+ `${label}.checks`,
45
+ );
46
+ const normalizedChecks = {};
47
+ for (const check of SECURITY_CHECK_KEYS) {
48
+ if (checkDescriptors[check]?.value === undefined) continue;
49
+ if (typeof checkDescriptors[check].value !== "boolean") {
50
+ throw new TypeError(`${label}.checks.${check} must be a boolean when provided.`);
51
+ }
52
+ normalizedChecks[check] = checkDescriptors[check].value;
53
+ }
54
+ checks = Object.freeze(normalizedChecks);
55
+ }
56
+ normalized.checks = checks;
57
+ return Object.freeze(normalized);
58
+ }
59
+
60
+ export function resolveModelSecurity({ app, binding, load } = {}) {
61
+ const scopes = [
62
+ normalizeModelSecurity(app, "app security"),
63
+ normalizeModelSecurity(binding, "provider security"),
64
+ normalizeModelSecurity(load, "load security"),
65
+ ];
66
+ let secure = false;
67
+ let byteLength;
68
+ let sha256;
69
+ for (const scope of scopes) {
70
+ if (Object.hasOwn(scope, "secure")) secure = scope.secure;
71
+ if (Object.hasOwn(scope.checks, "byteLength")) byteLength = scope.checks.byteLength;
72
+ if (Object.hasOwn(scope.checks, "sha256")) sha256 = scope.checks.sha256;
73
+ }
74
+ return Object.freeze({
75
+ secure,
76
+ checks: Object.freeze({
77
+ byteLength: byteLength ?? secure,
78
+ sha256: sha256 ?? secure,
79
+ }),
80
+ });
81
+ }
82
+
83
+ export function sameModelSecurity(left, right) {
84
+ return left?.checks?.byteLength === right?.checks?.byteLength
85
+ && left?.checks?.sha256 === right?.checks?.sha256;
86
+ }
87
+
88
+ function hasModelSecurityOverrides(value) {
89
+ return Object.hasOwn(value, "secure")
90
+ || Object.hasOwn(value.checks, "byteLength")
91
+ || Object.hasOwn(value.checks, "sha256");
92
+ }
93
+
3
94
  const ERROR_CODES = Object.freeze({
4
95
  load: "ARCANE_AI_LOAD_FAILED",
5
96
  unload: "ARCANE_AI_UNLOAD_FAILED",
@@ -133,8 +224,10 @@ function toolRecordFromCompletion(completion) {
133
224
  export class ModelController {
134
225
  #provider;
135
226
  #loadPolicy;
227
+ #security;
136
228
  #listeners = new Map();
137
229
  #loadPromise = null;
230
+ #readyPolicyResolved = false;
138
231
  #unloadPromise = null;
139
232
  #disposePromise = null;
140
233
  #operationGeneration = 0;
@@ -145,7 +238,7 @@ export class ModelController {
145
238
  #progress = null;
146
239
  #error = null;
147
240
 
148
- constructor({ provider, loadPolicy = "on-demand" } = {}) {
241
+ constructor({ provider, loadPolicy = "on-demand", security } = {}) {
149
242
  if (!provider || typeof provider !== "object") {
150
243
  throw new TypeError("ModelController requires an LLM provider.");
151
244
  }
@@ -164,10 +257,13 @@ export class ModelController {
164
257
  }
165
258
  this.#provider = provider;
166
259
  this.#loadPolicy = loadPolicy;
260
+ this.#security = normalizeModelSecurity(security, "app security");
167
261
  }
168
262
 
169
263
  status() {
170
- const providerStatus = providerMethod(this.#provider, "status")?.() ?? {};
264
+ const providerStatus = providerMethod(this.#provider, "status")?.(
265
+ Object.freeze({ security: this.#security }),
266
+ ) ?? {};
171
267
  return Object.freeze({
172
268
  ...providerStatus,
173
269
  kind: "llm",
@@ -221,16 +317,40 @@ export class ModelController {
221
317
  { operation: "load" },
222
318
  );
223
319
  }
224
- if (state === "ready") return this.status();
225
- if (this.#loadPromise) return this.#loadPromise;
226
320
  const load = providerMethod(this.#provider, "load");
227
321
  if (!load) throw new ArcaneAIError("ARCANE_AI_UNAVAILABLE", "The LLM provider cannot load a model.");
228
322
  const signal = options.signal ?? null;
323
+ const explicitOperationSecurity = options.security !== undefined;
324
+ const securityMustResolve = explicitOperationSecurity
325
+ || (
326
+ state === "ready"
327
+ && !this.#readyPolicyResolved
328
+ && hasModelSecurityOverrides(this.#security)
329
+ );
330
+ if (state === "ready" && !securityMustResolve) return this.status();
331
+ if (this.#loadPromise) {
332
+ if (!explicitOperationSecurity) return this.#loadPromise;
333
+ try {
334
+ await load(options, Object.freeze({
335
+ protocol: ARCANE_AI_ADAPTER_PROTOCOL,
336
+ kind: "llm",
337
+ operation: "load",
338
+ signal,
339
+ security: this.#security,
340
+ }));
341
+ return this.status();
342
+ } catch (error) {
343
+ throw normalizeArcaneAIError(error, { operation: "load", signal });
344
+ }
345
+ }
346
+ const wasReady = state === "ready";
229
347
  const operationGeneration = ++this.#operationGeneration;
230
- this.#fallbackState = "loading";
231
- this.#progress = null;
232
- this.#error = null;
233
- this.#emit("statechange");
348
+ if (!wasReady) {
349
+ this.#fallbackState = "loading";
350
+ this.#progress = null;
351
+ this.#error = null;
352
+ this.#emit("statechange");
353
+ }
234
354
  this.#loadPromise = (async () => {
235
355
  try {
236
356
  await load(options, Object.freeze({
@@ -238,6 +358,7 @@ export class ModelController {
238
358
  kind: "llm",
239
359
  operation: "load",
240
360
  signal,
361
+ security: this.#security,
241
362
  reportProgress: (progress) => {
242
363
  if (
243
364
  operationGeneration !== this.#operationGeneration
@@ -253,9 +374,12 @@ export class ModelController {
253
374
  || this.#disposing
254
375
  || this.#disposed
255
376
  ) return this.status();
256
- this.#fallbackState = "ready";
257
- this.#progress = null;
258
- this.#emit("statechange");
377
+ this.#readyPolicyResolved = true;
378
+ if (!wasReady) {
379
+ this.#fallbackState = "ready";
380
+ this.#progress = null;
381
+ this.#emit("statechange");
382
+ }
259
383
  return this.status();
260
384
  } catch (error) {
261
385
  const normalized = normalizeArcaneAIError(error, { operation: "load", signal });
@@ -263,6 +387,7 @@ export class ModelController {
263
387
  operationGeneration === this.#operationGeneration
264
388
  && !this.#disposing
265
389
  && !this.#disposed
390
+ && !wasReady
266
391
  ) {
267
392
  this.#fallbackState = "error";
268
393
  this.#error = normalized;
@@ -286,7 +411,7 @@ export class ModelController {
286
411
  { operation: "request" },
287
412
  );
288
413
  }
289
- if (state === "ready") return;
414
+ if (state === "ready") return void await this.load(loadOptions);
290
415
  if (this.#loadPolicy === "manual") {
291
416
  throw new ArcaneAIError(
292
417
  "ARCANE_AI_NOT_READY",
@@ -330,6 +455,7 @@ export class ModelController {
330
455
  }
331
456
  if (operationGeneration === this.#operationGeneration) {
332
457
  this.#fallbackState = "unloaded";
458
+ this.#readyPolicyResolved = false;
333
459
  this.#progress = null;
334
460
  this.#error = null;
335
461
  this.#emit("statechange");
@@ -0,0 +1,207 @@
1
+ import {
2
+ collectSpeechTransferables,
3
+ SPEECH_WORKER_PROTOCOL,
4
+ } from "./speech-worker-runtime.mjs";
5
+
6
+ function clientError(code, message, cause) {
7
+ const error = cause === undefined
8
+ ? new Error(message)
9
+ : new Error(message, { cause });
10
+ error.name = code === "ARCANE_AI_REQUEST_ABORTED"
11
+ ? "AbortError"
12
+ : "ArcaneSpeechWorkerError";
13
+ error.code = code;
14
+ return error;
15
+ }
16
+
17
+ function abortError(signal) {
18
+ return clientError(
19
+ "ARCANE_AI_REQUEST_ABORTED",
20
+ "The speech worker operation was cancelled.",
21
+ signal?.reason,
22
+ );
23
+ }
24
+
25
+ function validateWorker(worker) {
26
+ if (!worker || typeof worker.postMessage !== "function" || typeof worker.terminate !== "function") {
27
+ throw new TypeError("The packaged speech worker did not create a Worker.");
28
+ }
29
+ return worker;
30
+ }
31
+
32
+ const WORKER_CLIENTS = new WeakSet();
33
+
34
+ /**
35
+ * Owns exactly one role Worker. Cancellation terminates that Worker, which is
36
+ * the only reliable preemption boundary for the third-party WASM engines.
37
+ */
38
+ class SpeechWorkerClient {
39
+ #createWorker;
40
+ #worker = null;
41
+ #pending = new Map();
42
+ #nextId = 1;
43
+ #listeners = [];
44
+ #terminated = false;
45
+ #onTermination;
46
+
47
+ constructor({ role, onTermination = () => undefined } = {}) {
48
+ if (role !== "stt" && role !== "tts") {
49
+ throw new TypeError('SpeechWorkerClient role must be "stt" or "tts".');
50
+ }
51
+ if (typeof onTermination !== "function") {
52
+ throw new TypeError("SpeechWorkerClient onTermination must be a function.");
53
+ }
54
+ const workerUrl = role === "stt"
55
+ ? new URL("./browser-whisper-worker.mjs", import.meta.url)
56
+ : new URL("./browser-kokoro-worker.mjs", import.meta.url);
57
+ this.#createWorker = () => new Worker(workerUrl, {
58
+ type: "module",
59
+ name: role === "stt" ? "arcane-whisper-stt" : "arcane-kokoro-tts",
60
+ });
61
+ this.#onTermination = onTermination;
62
+ WORKER_CLIENTS.add(this);
63
+ }
64
+
65
+ #listen(worker, type, listener) {
66
+ worker.addEventListener(type, listener);
67
+ this.#listeners.push(() => worker.removeEventListener(type, listener));
68
+ }
69
+
70
+ #start() {
71
+ if (this.#worker) return this.#worker;
72
+ if (this.#terminated) {
73
+ throw clientError("ARCANE_AI_OPERATION_SUPERSEDED", "The speech Worker was already terminated.");
74
+ }
75
+ const worker = validateWorker(this.#createWorker());
76
+ this.#worker = worker;
77
+ this.#listen(worker, "message", (event) => this.#handleMessage(event.data));
78
+ this.#listen(worker, "messageerror", (event) => {
79
+ void this.terminate(clientError(
80
+ "ARCANE_AI_WORKER_MESSAGE_ERROR",
81
+ "The speech Worker returned an unreadable message.",
82
+ event,
83
+ ), { intentional: false });
84
+ });
85
+ this.#listen(worker, "error", (event) => {
86
+ void this.terminate(clientError(
87
+ "ARCANE_AI_WORKER_CRASHED",
88
+ "The speech Worker crashed.",
89
+ event,
90
+ ), { intentional: false });
91
+ });
92
+ return worker;
93
+ }
94
+
95
+ #handleMessage(message) {
96
+ if (message?.protocol !== SPEECH_WORKER_PROTOCOL) {
97
+ void this.terminate(clientError(
98
+ "ARCANE_AI_ADAPTER_PROTOCOL_MISMATCH",
99
+ "The speech Worker protocol did not match the SDK.",
100
+ ), { intentional: false });
101
+ return;
102
+ }
103
+ if (message.event === "progress") {
104
+ this.#pending.get(message.requestId)?.progress(message.progress);
105
+ return;
106
+ }
107
+ const pending = this.#pending.get(message.id);
108
+ if (!pending) return;
109
+ this.#pending.delete(message.id);
110
+ pending.cleanup();
111
+ if (message.ok === true) {
112
+ pending.resolve(message.result);
113
+ return;
114
+ }
115
+ pending.reject(clientError(
116
+ message.error?.code ?? "ARCANE_AI_PROVIDER_REQUEST_FAILED",
117
+ message.error?.message ?? "The speech Worker operation failed.",
118
+ ));
119
+ }
120
+
121
+ request(op, payload, { signal = null, progress = () => undefined } = {}) {
122
+ if (signal?.aborted) return Promise.reject(abortError(signal));
123
+ if (typeof progress !== "function") {
124
+ return Promise.reject(new TypeError("Speech Worker progress must be a function."));
125
+ }
126
+ let worker;
127
+ try {
128
+ worker = this.#start();
129
+ } catch (error) {
130
+ return Promise.reject(error);
131
+ }
132
+ const id = this.#nextId;
133
+ this.#nextId += 1;
134
+ return new Promise((resolve, reject) => {
135
+ let listening = true;
136
+ const cleanup = () => {
137
+ if (!listening) return;
138
+ listening = false;
139
+ signal?.removeEventListener?.("abort", onAbort);
140
+ };
141
+ const onAbort = () => {
142
+ if (!this.#pending.has(id)) return;
143
+ void this.terminate(abortError(signal), { intentional: true });
144
+ };
145
+ signal?.addEventListener?.("abort", onAbort, { once: true });
146
+ this.#pending.set(id, { resolve, reject, progress, cleanup });
147
+ try {
148
+ worker.postMessage({
149
+ protocol: SPEECH_WORKER_PROTOCOL,
150
+ id,
151
+ op,
152
+ payload,
153
+ }, collectSpeechTransferables(payload));
154
+ } catch (error) {
155
+ this.#pending.delete(id);
156
+ cleanup();
157
+ const failure = clientError(
158
+ "ARCANE_AI_WORKER_MESSAGE_ERROR",
159
+ "Unable to send an operation to the speech Worker.",
160
+ error,
161
+ );
162
+ reject(failure);
163
+ void this.terminate(failure, { intentional: false });
164
+ }
165
+ });
166
+ }
167
+
168
+ async terminate(reason = clientError(
169
+ "ARCANE_AI_OPERATION_SUPERSEDED",
170
+ "The speech Worker was terminated.",
171
+ ), { intentional = true } = {}) {
172
+ if (typeof intentional !== "boolean") {
173
+ throw new TypeError("Speech Worker termination intent must be a boolean.");
174
+ }
175
+ if (this.#terminated) return;
176
+ this.#terminated = true;
177
+ const worker = this.#worker;
178
+ this.#worker = null;
179
+ for (const remove of this.#listeners.splice(0).reverse()) {
180
+ try {
181
+ remove();
182
+ } catch {
183
+ // Listener removal follows Worker isolation and is best effort.
184
+ }
185
+ }
186
+ const pendingOperations = [...this.#pending.values()];
187
+ for (const pending of pendingOperations) {
188
+ pending.cleanup();
189
+ }
190
+ this.#pending.clear();
191
+ try {
192
+ const termination = worker?.terminate();
193
+ if (termination && typeof termination.then === "function") await termination;
194
+ } finally {
195
+ for (const pending of pendingOperations) pending.reject(reason);
196
+ this.#onTermination(Object.freeze({ reason, intentional }));
197
+ }
198
+ }
199
+ }
200
+
201
+ export function createSpeechWorkerClient(options) {
202
+ return new SpeechWorkerClient(options);
203
+ }
204
+
205
+ export function isSpeechWorkerClient(value) {
206
+ return WORKER_CLIENTS.has(value);
207
+ }