arcane-os 0.5.2 → 0.5.4

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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.4
4
+
5
+ - Reduced Browser-WASM HTTP Range parts from roughly 128 MB to roughly 4 MB
6
+ and raised the deterministic part ceiling, so a refresh re-fetches only the
7
+ small in-flight parts while every completed part remains reusable. An
8
+ incomplete 0.5.3 cache keeps its completed legacy parts and subdivides only
9
+ the missing legacy intervals, preserving existing progress during adoption.
10
+ - Kept all built-in audio processing on device: Whisper owns transcription and
11
+ Kokoro owns speech synthesis. Legacy OpenAI audio selections migrate to those
12
+ local routes, non-local speech configurations are rejected, and the retired
13
+ OpenAI audio credential and endpoints are no longer used.
14
+
15
+ ## 0.5.3
16
+
17
+ - Preserved every independently completed Browser-WASM HTTP Range part and
18
+ split GGUF member when a sibling transfer fails, so a retry restores prior
19
+ progress and requests only the missing work instead of restarting all active
20
+ transfers.
21
+
3
22
  ## 0.5.2
4
23
 
5
24
  - Forwarded each application's existing managed browser import map into
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles preserve the same app URLs, theme, packaging,
20
20
  event, cancellation, and browser run contracts.
21
21
 
22
- This checkout defines the `0.5.2` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.5.4` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -88,10 +88,10 @@ OpenAI-compatible chat-completion requests to
88
88
  `globalThis.arcane.config.twinCloud.accessKey`. The established `ai.license`
89
89
  property and internal `OPENAI` route identifier remain compatibility aliases;
90
90
  applications should present the service and credential as **TWiN Cloud** and
91
- **TWiN access key**. The TWiN key is used only for remote LLM chat. Existing
92
- OpenAI speech routes remain separate and read only their legacy
93
- `globalThis.arcane.config.openAI.apiKey`; the SDK never sends a TWiN key to
94
- those speech endpoints.
91
+ **TWiN access key**. The TWiN key is used only for remote LLM chat. Audio stays
92
+ on device: Whisper (`LOCAL_SPEACH` / `whisper-small`) owns transcription and
93
+ Kokoro (`LOCAL_SPEACH` / `kokoro`) owns speech synthesis. Neither audio route
94
+ uses the TWiN key, and no OpenAI audio key is required.
95
95
 
96
96
  ## Browser-local AI
97
97
 
@@ -124,11 +124,14 @@ Optional hardening is inactive unless the caller explicitly selects
124
124
  `secure:true`. The DBOPFS store downloads ordered members with one bounded
125
125
  parallel transfer pool and returns them in descriptor order. Completed shards
126
126
  survive an interrupted attempt and only missing shard or Range-part work is
127
- fetched on retry. A
128
- monolithic file uses up to `downloadConcurrency` active workers (four by
129
- default) over up to 16 deterministic, resumable HTTP range parts when the server
130
- confirms `206` responses and the total comes from `Content-Range` or, when that
131
- header is not exposed, optional declared `bytes`.
127
+ fetched on retry. A monolithic file uses up to `downloadConcurrency` active
128
+ workers (four by default) over deterministic, resumable HTTP range parts of
129
+ roughly 4 MB each, up to 4,096 parts, when the server confirms `206` responses
130
+ and the total comes from `Content-Range` or, when that header is not exposed,
131
+ optional declared `bytes`.
132
+ An incomplete cache written by 0.5.3 keeps its completed coarse parts and
133
+ subdivides only the missing intervals into the smaller current parts, so an SDK
134
+ update preserves existing progress while reducing later refresh loss.
132
135
  When a followed redirect turns the first Range probe into `200`, the SDK probes
133
136
  the final URL directly before reusing that original response as the single-fetch
134
137
  fallback. Completed range parts survive restart and are presented to Wllama as
@@ -165,7 +168,7 @@ uses the same controller for automatic memory extraction.
165
168
  Create a new repository-shaped Arcane application with the exact stable SDK:
166
169
 
167
170
  ```bash
168
- npx arcane-os@0.5.2 new my-app --path ./my-app --target portable --git
171
+ npx arcane-os@0.5.4 new my-app --path ./my-app --target portable --git
169
172
  cd my-app
170
173
  npm install
171
174
  npm run check
@@ -176,7 +179,7 @@ To enroll an existing repository, install the exact SDK and initialize only
176
179
  missing Arcane files:
177
180
 
178
181
  ```bash
179
- npm install --save-dev --save-exact arcane-os@0.5.2
182
+ npm install --save-dev --save-exact arcane-os@0.5.4
180
183
  npm exec -- arcane init my-app --target portable
181
184
  ```
182
185
 
@@ -192,7 +195,7 @@ npm exec -- arcane-os targets
192
195
  No global SDK install or standalone Arcane CLI is required. The application
193
196
  repository's exact npm dependency and lockfile own the CLI and toolchain version.
194
197
 
195
- Use `npx arcane-os@0.5.2` for the initial bootstrap because it names this npm
198
+ Use `npx arcane-os@0.5.4` for the initial bootstrap because it names this npm
196
199
  package explicitly; bare `npx arcane` outside an installed project could resolve
197
200
  a different package. Both installed commands invoke the same headless toolchain.
198
201
  Project-local npm scripts use the SDK pinned by that app's `package-lock.json`,
@@ -213,7 +216,7 @@ node ./bin/arcane.mjs new local-app --path ../local-app --target portable --git
213
216
 
214
217
  # From the generated app repository
215
218
  cd ../local-app
216
- npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.2.tgz
219
+ npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.5.4.tgz
217
220
  npm run check
218
221
  npm ci
219
222
  ```
@@ -223,7 +226,7 @@ same location. The lockfile retains the selected package dependency while
223
226
  Arcane uses the installed package name and version. Local directory `file:` dependencies are not
224
227
  accepted because npm may install them as links; use a packed `.tgz`. A GitHub
225
228
  runner also needs that tarball at the locked path. After publication, replace
226
- the local declaration with the exact `arcane-os@0.5.2` registry package and
229
+ the local declaration with the exact `arcane-os@0.5.4` registry package and
227
230
  commit the regenerated lock.
228
231
 
229
232
  Generated repositories use `npm ci --ignore-scripts` in CI. Run dependency
@@ -361,7 +364,7 @@ package installation, or assertions.
361
364
 
362
365
  ## Current target support
363
366
 
364
- Version `0.5.2` exposes one browser target and five explicitly paired
367
+ Version `0.5.4` exposes one browser target and five explicitly paired
365
368
  native development targets: a non-runnable portable directory, a
366
369
  Windows x64 unsigned-local-test EXE bundle, Linux x64 and Linux ARM64
367
370
  unsigned-local-test DEBs, and an Android development-signed APK. The
@@ -29,8 +29,10 @@ const MODEL_LOAD_HEARTBEAT_MS = 5_000;
29
29
  const DEFAULT_MODEL_DOWNLOAD_CONCURRENCY = 4;
30
30
  const MODEL_DOWNLOAD_PROGRESS_INTERVAL_MS = 250;
31
31
  const MODEL_DOWNLOAD_SPEED_WINDOW_MS = 5_000;
32
- const MODEL_DOWNLOAD_MAX_RANGE_PARTS = 16;
33
- const MODEL_DOWNLOAD_TARGET_RANGE_BYTES = 128_000_000;
32
+ const MODEL_DOWNLOAD_MAX_RANGE_PARTS = 4_096;
33
+ const MODEL_DOWNLOAD_TARGET_RANGE_BYTES = 4_000_000;
34
+ const LEGACY_MODEL_DOWNLOAD_MAX_RANGE_PARTS = 16;
35
+ const LEGACY_MODEL_DOWNLOAD_TARGET_RANGE_BYTES = 128_000_000;
34
36
  const INTEL_VENDOR_ID = 0x8086;
35
37
  const CAPABILITY_POLICY_PROTOCOL = "arcane-ai-browser-capability-policy/1";
36
38
  let highPerformanceGpuNoticeShown = false;
@@ -103,10 +105,10 @@ function downloadConcurrencyValue(value) {
103
105
  return value;
104
106
  }
105
107
 
106
- function modelHttpRanges(total) {
108
+ function modelHttpRangesFor(total, maximumParts, targetPartBytes) {
107
109
  const count = Math.min(
108
- MODEL_DOWNLOAD_MAX_RANGE_PARTS,
109
- Math.ceil(total / MODEL_DOWNLOAD_TARGET_RANGE_BYTES),
110
+ maximumParts,
111
+ Math.ceil(total / targetPartBytes),
110
112
  total,
111
113
  );
112
114
  const width = Math.floor(total / count);
@@ -122,6 +124,47 @@ function modelHttpRanges(total) {
122
124
  return completeValue(ranges);
123
125
  }
124
126
 
127
+ function modelHttpRanges(total) {
128
+ return modelHttpRangesFor(
129
+ total,
130
+ MODEL_DOWNLOAD_MAX_RANGE_PARTS,
131
+ MODEL_DOWNLOAD_TARGET_RANGE_BYTES,
132
+ );
133
+ }
134
+
135
+ function modelHttpSubranges(range) {
136
+ const length = range.end - range.start + 1;
137
+ const maximumParts = Math.floor(
138
+ MODEL_DOWNLOAD_MAX_RANGE_PARTS / LEGACY_MODEL_DOWNLOAD_MAX_RANGE_PARTS,
139
+ );
140
+ return completeValue(modelHttpRangesFor(
141
+ length,
142
+ maximumParts,
143
+ MODEL_DOWNLOAD_TARGET_RANGE_BYTES,
144
+ ).map((part) => completeValue({
145
+ start: range.start + part.start,
146
+ end: range.start + part.end,
147
+ total: range.total,
148
+ })));
149
+ }
150
+
151
+ function modelHttpRangePlans(total) {
152
+ const current = modelHttpRanges(total);
153
+ const legacy = modelHttpRangesFor(
154
+ total,
155
+ LEGACY_MODEL_DOWNLOAD_MAX_RANGE_PARTS,
156
+ LEGACY_MODEL_DOWNLOAD_TARGET_RANGE_BYTES,
157
+ );
158
+ if (
159
+ current.length === legacy.length
160
+ && current.every((range, index) => (
161
+ range.start === legacy[index].start
162
+ && range.end === legacy[index].end
163
+ ))
164
+ ) return completeValue([current]);
165
+ return completeValue([current, legacy]);
166
+ }
167
+
125
168
  function progressClock() {
126
169
  return typeof globalThis.performance?.now === "function"
127
170
  ? globalThis.performance.now()
@@ -926,12 +969,11 @@ export function createDbopfsModelStore({
926
969
  {
927
970
  signal,
928
971
  progress,
929
- retainInstallFailure = null,
930
972
  rangeWorkerLimit = workerLimit,
931
973
  } = {},
932
974
  ) {
933
975
  const transport = BROWSER_MODEL_SOURCE_TRANSPORTS.get(source);
934
- const ranges = modelHttpRanges(total);
976
+ const ranges = await rangePlanForInstall(name, total, signal);
935
977
  const partFiles = new Array(ranges.length);
936
978
  const pendingRangeIndexes = [];
937
979
  for (let rangeIndex = 0; rangeIndex < ranges.length; rangeIndex += 1) {
@@ -955,7 +997,6 @@ export function createDbopfsModelStore({
955
997
 
956
998
  function retainFailure(error) {
957
999
  if (failure === null) failure = error;
958
- if (!downloadSignal.aborted) linked.controller.abort(error);
959
1000
  }
960
1001
 
961
1002
  async function transferRangeWorker() {
@@ -1005,17 +1046,31 @@ export function createDbopfsModelStore({
1005
1046
  writable = null;
1006
1047
  partFiles[rangeIndex] = await storedModelFile(partName);
1007
1048
  } catch (error) {
1008
- retainInstallFailure?.(error);
1009
- retainFailure(error);
1010
- await cancelOpenedDownload(opened, error);
1049
+ let transferError = error;
1050
+ if (downloadSignal.aborted
1051
+ && error?.code !== "ARCANE_AI_REQUEST_ABORTED") {
1052
+ try {
1053
+ throwIfAborted(downloadSignal, "install");
1054
+ } catch (abortError) {
1055
+ transferError = abortError;
1056
+ }
1057
+ } else if (!error?.code) {
1058
+ transferError = fail(
1059
+ "ARCANE_AI_MODEL_DOWNLOAD_FAILED",
1060
+ "The model download failed while reading an HTTP byte range.",
1061
+ error,
1062
+ );
1063
+ }
1064
+ retainFailure(transferError);
1065
+ await cancelOpenedDownload(opened, transferError);
1011
1066
  try {
1012
- await writable?.abort?.(error);
1067
+ await writable?.abort?.(transferError);
1013
1068
  } catch {
1014
1069
  // The range part is removed below even if its writable already closed.
1015
1070
  }
1016
1071
  await removeEntry(partName).catch(() => undefined);
1017
1072
  progress?.discardBytes(received);
1018
- throw error;
1073
+ throw transferError;
1019
1074
  } finally {
1020
1075
  progress?.endTransfer({ publishProgress: failure === null });
1021
1076
  }
@@ -1100,7 +1155,6 @@ export function createDbopfsModelStore({
1100
1155
  signal,
1101
1156
  progress,
1102
1157
  rangeWorkerLimit,
1103
- retainInstallFailure: retainFailure,
1104
1158
  });
1105
1159
  }
1106
1160
 
@@ -1122,16 +1176,93 @@ export function createDbopfsModelStore({
1122
1176
  return names;
1123
1177
  }
1124
1178
 
1179
+ async function rangePlanForInstall(
1180
+ modelName,
1181
+ total,
1182
+ signal,
1183
+ knownNames = undefined,
1184
+ ) {
1185
+ const plans = modelHttpRangePlans(total);
1186
+ const existingNames = knownNames === undefined
1187
+ ? await memberRangePartNames(modelName)
1188
+ : knownNames;
1189
+ const available = existingNames === null
1190
+ ? null
1191
+ : existingNames instanceof Set
1192
+ ? existingNames
1193
+ : new Set(existingNames);
1194
+ if (available?.size === 0) return plans[0];
1195
+ if (plans.length === 1) return plans[0];
1196
+ const completedParts = new Map();
1197
+
1198
+ async function completedPart(range) {
1199
+ throwIfAborted(signal, "install");
1200
+ const partName = rangePartName(modelName, range);
1201
+ if (available && !available.has(partName)) return null;
1202
+ if (completedParts.has(partName)) return completedParts.get(partName);
1203
+ const partFile = await file(partName);
1204
+ const expected = range.end - range.start + 1;
1205
+ const completed = partFile?.size === expected ? partFile : null;
1206
+ completedParts.set(partName, completed);
1207
+ return completed;
1208
+ }
1209
+
1210
+ const completedLegacyRanges = new Set();
1211
+ for (const range of plans[1]) {
1212
+ if (await completedPart(range)) completedLegacyRanges.add(range);
1213
+ }
1214
+ const hybrid = completeValue(plans[1].flatMap((range) => (
1215
+ completedLegacyRanges.has(range)
1216
+ ? [range]
1217
+ : modelHttpSubranges(range)
1218
+ )));
1219
+ let selected = plans[0];
1220
+ let selectedBytes = 0;
1221
+ let selectedLastModified = 0;
1222
+ for (const ranges of [plans[0], hybrid]) {
1223
+ let completedBytes = 0;
1224
+ let lastModified = 0;
1225
+ for (const range of ranges) {
1226
+ const partFile = await completedPart(range);
1227
+ if (!partFile) continue;
1228
+ completedBytes += range.end - range.start + 1;
1229
+ if (Number.isFinite(partFile.lastModified)) {
1230
+ lastModified = Math.max(lastModified, partFile.lastModified);
1231
+ }
1232
+ }
1233
+ if (
1234
+ completedBytes > selectedBytes
1235
+ || (
1236
+ completedBytes === selectedBytes
1237
+ && completedBytes > 0
1238
+ && lastModified > selectedLastModified
1239
+ )
1240
+ ) {
1241
+ selected = ranges;
1242
+ selectedBytes = completedBytes;
1243
+ selectedLastModified = lastModified;
1244
+ }
1245
+ }
1246
+ return selected;
1247
+ }
1248
+
1125
1249
  async function removeMemberRangeParts(modelName, {
1126
1250
  except = null,
1127
1251
  total = null,
1128
1252
  } = {}) {
1129
1253
  const existingNames = await memberRangePartNames(modelName);
1130
- const names = existingNames ?? (
1131
- Number.isSafeInteger(total) && total > 0
1132
- ? modelHttpRanges(total).map((range) => rangePartName(modelName, range))
1133
- : []
1134
- );
1254
+ let names = existingNames;
1255
+ if (names === null && Number.isSafeInteger(total) && total > 0) {
1256
+ const plans = modelHttpRangePlans(total);
1257
+ const hybridRanges = plans.length === 1
1258
+ ? []
1259
+ : plans[1].flatMap((range) => modelHttpSubranges(range));
1260
+ names = [...new Set([
1261
+ ...plans.flat(),
1262
+ ...hybridRanges,
1263
+ ].map((range) => rangePartName(modelName, range)))];
1264
+ }
1265
+ names ??= [];
1135
1266
  const removed = [];
1136
1267
  for (const name of names) {
1137
1268
  if (except?.has(name)) continue;
@@ -1215,9 +1346,10 @@ export function createDbopfsModelStore({
1215
1346
  for (let memberIndex = 0; memberIndex < members.length; memberIndex += 1) {
1216
1347
  const total = members[memberIndex].bytes;
1217
1348
  if (!Number.isSafeInteger(total) || total <= 0) continue;
1218
- for (const range of modelHttpRanges(total)) {
1219
- removed.push(await removeEntry(rangePartName(names.models[memberIndex].name, range)));
1220
- }
1349
+ removed.push(await removeMemberRangeParts(
1350
+ names.models[memberIndex].name,
1351
+ { total },
1352
+ ));
1221
1353
  }
1222
1354
  return removed.some(Boolean);
1223
1355
  }
@@ -1368,12 +1500,18 @@ export function createDbopfsModelStore({
1368
1500
  return modelFiles.every(Boolean) ? modelFiles : null;
1369
1501
  }
1370
1502
 
1371
- async function storedRangeCandidate(modelName, total, signal) {
1503
+ async function storedRangeCandidateForPlan(
1504
+ modelName,
1505
+ ranges,
1506
+ signal,
1507
+ availableNames = null,
1508
+ ) {
1372
1509
  const partFiles = [];
1373
1510
  let lastModified = 0;
1374
- for (const range of modelHttpRanges(total)) {
1511
+ for (const range of ranges) {
1375
1512
  throwIfAborted(signal, "install");
1376
1513
  const partName = rangePartName(modelName, range);
1514
+ if (availableNames && !availableNames.has(partName)) return null;
1377
1515
  const partFile = await file(partName);
1378
1516
  if (!partFile) return null;
1379
1517
  const expected = range.end - range.start + 1;
@@ -1389,26 +1527,53 @@ export function createDbopfsModelStore({
1389
1527
  return completeValue({
1390
1528
  file: new Blob(partFiles, { type: "application/octet-stream" }),
1391
1529
  lastModified,
1392
- total,
1530
+ ranges,
1531
+ total: ranges[0].total,
1393
1532
  });
1394
1533
  }
1395
1534
 
1535
+ async function storedRangeCandidate(
1536
+ modelName,
1537
+ total,
1538
+ signal,
1539
+ availableNames = null,
1540
+ ) {
1541
+ const ranges = await rangePlanForInstall(
1542
+ modelName,
1543
+ total,
1544
+ signal,
1545
+ availableNames,
1546
+ );
1547
+ return storedRangeCandidateForPlan(
1548
+ modelName,
1549
+ ranges,
1550
+ signal,
1551
+ availableNames,
1552
+ );
1553
+ }
1554
+
1396
1555
  async function storedRangeMember(member, modelName, signal) {
1556
+ const names = await memberRangePartNames(modelName);
1557
+ const availableNames = names === null ? null : new Set(names);
1397
1558
  const declaredTotal = Number.isSafeInteger(member.bytes) && member.bytes > 0
1398
1559
  ? member.bytes
1399
1560
  : null;
1400
1561
  if (declaredTotal !== null) {
1401
- const declared = await storedRangeCandidate(modelName, declaredTotal, signal);
1562
+ const declared = await storedRangeCandidate(
1563
+ modelName,
1564
+ declaredTotal,
1565
+ signal,
1566
+ availableNames,
1567
+ );
1402
1568
  if (declared) {
1403
1569
  await removeStaleRangePartsAfterCompletion(
1404
1570
  modelName,
1405
- modelHttpRanges(declaredTotal),
1571
+ declared.ranges,
1406
1572
  );
1407
1573
  return declared.file;
1408
1574
  }
1409
1575
  }
1410
1576
 
1411
- const names = await memberRangePartNames(modelName);
1412
1577
  if (names === null) return null;
1413
1578
  const discoveredTotals = new Set();
1414
1579
  for (const name of names) {
@@ -1418,7 +1583,12 @@ export function createDbopfsModelStore({
1418
1583
  let selected = null;
1419
1584
  const totals = [...discoveredTotals].sort((left, right) => right - left);
1420
1585
  for (const total of totals) {
1421
- const candidate = await storedRangeCandidate(modelName, total, signal);
1586
+ const candidate = await storedRangeCandidate(
1587
+ modelName,
1588
+ total,
1589
+ signal,
1590
+ availableNames,
1591
+ );
1422
1592
  if (
1423
1593
  candidate
1424
1594
  && (
@@ -1430,7 +1600,7 @@ export function createDbopfsModelStore({
1430
1600
  if (selected === null) return null;
1431
1601
  await removeStaleRangePartsAfterCompletion(
1432
1602
  modelName,
1433
- modelHttpRanges(selected.total),
1603
+ selected.ranges,
1434
1604
  );
1435
1605
  return selected.file;
1436
1606
  }
@@ -1485,7 +1655,6 @@ export function createDbopfsModelStore({
1485
1655
 
1486
1656
  function retainFailure(error) {
1487
1657
  if (failure === null) failure = error;
1488
- if (!downloadSignal.aborted) linked.controller.abort(error);
1489
1658
  }
1490
1659
 
1491
1660
  const progress = createDownloadProgressReporter(members, onProgress, retainFailure);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -54,7 +54,7 @@
54
54
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
55
55
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
56
56
  "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/contracts.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
57
- "test:functional": "node ./bin/arcane-test.mjs test/browser-speech-providers.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dev-server.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/packaging.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime-sync.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/site.test.mjs test/update-check.test.mjs",
57
+ "test:functional": "node ./bin/arcane-test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-download-resume.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dev-server.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/packaging.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime-sync.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/site.test.mjs test/update-check.test.mjs",
58
58
  "test:integration": "node ./bin/arcane-test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-plan.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/wllama-webgpu-runtime.test.mjs",
59
59
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/native-provider-generation.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
60
60
  "check": "node tools/check-source.mjs && npm test",
@@ -922,12 +922,10 @@ class AI {
922
922
  OPENAI: 'https://inference.do-ai.run/v1'
923
923
  },
924
924
  sttURL: {
925
- LOCAL_SPEACH: 'http://127.0.0.1:8011/v1',
926
- OPENAI: 'https://api.openai.com/v1'
925
+ LOCAL_SPEACH: 'http://127.0.0.1:8011/v1'
927
926
  },
928
927
  ttsURL: {
929
- LOCAL_SPEACH: 'http://127.0.0.1:8011/v1',
930
- OPENAI: 'https://api.openai.com/v1'
928
+ LOCAL_SPEACH: 'http://127.0.0.1:8011/v1'
931
929
  },
932
930
  }
933
931
 
@@ -936,12 +934,10 @@ class AI {
936
934
  OPENAI: '/chat/completions'
937
935
  },
938
936
  stt: {
939
- LOCAL_SPEACH: '/audio/transcriptions',
940
- OPENAI: '/audio/transcriptions'
937
+ LOCAL_SPEACH: '/audio/transcriptions'
941
938
  },
942
939
  tts: {
943
- LOCAL_SPEACH: '/audio/speech',
944
- OPENAI: '/audio/speech'
940
+ LOCAL_SPEACH: '/audio/speech'
945
941
  }
946
942
  }
947
943
 
@@ -950,12 +946,10 @@ class AI {
950
946
  }
951
947
 
952
948
  #sttModels = {
953
- OPENAI: 'whisper-1',
954
949
  LOCAL_SPEACH: 'whisper-small'
955
950
  }
956
951
 
957
952
  #ttsModels = {
958
- OPENAI: 'gpt-4o-mini-tts',
959
953
  LOCAL_SPEACH: 'kokoro'
960
954
  }
961
955
 
@@ -974,17 +968,8 @@ class AI {
974
968
  };
975
969
  }
976
970
 
977
- get #legacyOpenAISpeechKey(){
978
- const value=globalThis.arcane?.config?.openAI?.apiKey;
979
- return typeof value==='string'?value:'';
980
- }
981
-
982
971
  get #ttsHeaders(){
983
972
  return {
984
- OPENAI: {
985
- 'Content-Type': 'application/json',
986
- 'Authorization': `Bearer ${this.#legacyOpenAISpeechKey}`
987
- },
988
973
  LOCAL_SPEACH: {
989
974
  'Content-Type': 'application/json',
990
975
  }
@@ -993,9 +978,6 @@ class AI {
993
978
 
994
979
  get #sttHeaders(){
995
980
  return {
996
- OPENAI: {
997
- 'Authorization': `Bearer ${this.#legacyOpenAISpeechKey}`,
998
- },
999
981
  LOCAL_SPEACH: {}
1000
982
  };
1001
983
  }
@@ -1048,11 +1030,11 @@ class AI {
1048
1030
 
1049
1031
  const preferences=[
1050
1032
  llmService||'OPENAI',
1051
- sttService||'OPENAI',
1052
- ttsService||'OPENAI',
1033
+ sttService||'LOCAL_SPEACH',
1034
+ ttsService||'LOCAL_SPEACH',
1053
1035
  model||'OPENAI',
1054
- modelTTS||'OPENAI',
1055
- modelSTT||'OPENAI'
1036
+ modelTTS||'LOCAL_SPEACH',
1037
+ modelSTT||'LOCAL_SPEACH'
1056
1038
  ];
1057
1039
  this.setAI(
1058
1040
  ...preferences
@@ -1089,11 +1071,11 @@ class AI {
1089
1071
  #stopOllamaReady=null;
1090
1072
  #preferenceTuple=completeValue([
1091
1073
  'OPENAI',
1074
+ 'LOCAL_SPEACH',
1075
+ 'LOCAL_SPEACH',
1092
1076
  'OPENAI',
1093
- 'OPENAI',
1094
- 'OPENAI',
1095
- 'OPENAI',
1096
- 'OPENAI'
1077
+ 'LOCAL_SPEACH',
1078
+ 'LOCAL_SPEACH'
1097
1079
  ]);
1098
1080
 
1099
1081
  get providerRuntime(){
@@ -1173,9 +1155,6 @@ class AI {
1173
1155
  this.#retainLegacyLLMReadiness(
1174
1156
  this.#reconcileLegacyLLMReadiness()
1175
1157
  );
1176
- this.#retainLegacySpeechReadiness(
1177
- this.#reconcileLegacySpeechReadiness()
1178
- );
1179
1158
  return this.#license;
1180
1159
  }
1181
1160
 
@@ -1427,12 +1406,6 @@ class AI {
1427
1406
  if(role!=='tts'){
1428
1407
  return null;
1429
1408
  }
1430
- if(providerId==='OPENAI'){
1431
- const selected=typeof globalThis.window?.user?.AI_voice==='string'
1432
- ?globalThis.window.user.AI_voice.trim()
1433
- :'';
1434
- return selected||'alloy';
1435
- }
1436
1409
  if(providerId==='LOCAL_SPEACH'){
1437
1410
  return 'af_heart';
1438
1411
  }
@@ -1445,10 +1418,6 @@ class AI {
1445
1418
  if(service!==providerId||!model){
1446
1419
  return false;
1447
1420
  }
1448
- if(providerId==='OPENAI'){
1449
- return Boolean(this.#legacyOpenAISpeechKey)
1450
- &&typeof globalThis.fetch==='function';
1451
- }
1452
1421
  if(providerId==='LOCAL_SPEACH'){
1453
1422
  return Boolean(this.#nativeSpeech(service,role));
1454
1423
  }
@@ -1685,8 +1654,7 @@ class AI {
1685
1654
  }
1686
1655
 
1687
1656
  #ensureLegacySpeechProvider(role,providerId){
1688
- if(!['stt','tts'].includes(role)
1689
- ||!['OPENAI','LOCAL_SPEACH'].includes(providerId)){
1657
+ if(!['stt','tts'].includes(role)||providerId!=='LOCAL_SPEACH'){
1690
1658
  return false;
1691
1659
  }
1692
1660
  if(this.#providerRuntime.hasProvider(role,providerId)){
@@ -1875,11 +1843,7 @@ class AI {
1875
1843
  throw error;
1876
1844
  }
1877
1845
 
1878
- if(service==='OPENAI'&&(
1879
- role==='llm'
1880
- ?Boolean(this.twinKey)
1881
- :Boolean(this.#legacyOpenAISpeechKey)
1882
- )){
1846
+ if(service==='OPENAI'&&role==='llm'&&Boolean(this.twinKey)){
1883
1847
  return true;
1884
1848
  }
1885
1849
 
@@ -1947,6 +1911,10 @@ class AI {
1947
1911
  }
1948
1912
  return value;
1949
1913
  });
1914
+ next[1]='LOCAL_SPEACH';
1915
+ next[2]='LOCAL_SPEACH';
1916
+ next[4]='LOCAL_SPEACH';
1917
+ next[5]='LOCAL_SPEACH';
1950
1918
  return completeValue(next);
1951
1919
  }
1952
1920
 
@@ -1961,6 +1929,26 @@ class AI {
1961
1929
  }
1962
1930
  }
1963
1931
 
1932
+ #assertDeviceSpeechConfiguration(configuration){
1933
+ for(const role of ['stt','tts']){
1934
+ for(const routeName of ['default','localOnly']){
1935
+ const selection=configuration?.[role]?.[routeName];
1936
+ if(selection&&selection.localOnly!==true){
1937
+ const operation=role==='stt'
1938
+ ?'Speech recognition'
1939
+ :'Speech synthesis';
1940
+ const error=new TypeError(
1941
+ `${operation} supports on-device providers only.`
1942
+ );
1943
+ error.code=role==='stt'
1944
+ ?'AI_STT_DEVICE_ONLY'
1945
+ :'AI_TTS_DEVICE_ONLY';
1946
+ throw error;
1947
+ }
1948
+ }
1949
+ }
1950
+ }
1951
+
1964
1952
  #normalizedLLMModel(service,model){
1965
1953
  if(service==='OLLAMA'){
1966
1954
  const mappedModel=model==='OPENAI'?null:this.#models[model];
@@ -2187,6 +2175,7 @@ class AI {
2187
2175
 
2188
2176
  configureProviders(selections){
2189
2177
  const prepared=this.#providerRuntime.validateConfiguration(selections);
2178
+ this.#assertDeviceSpeechConfiguration(prepared);
2190
2179
  this.#assertSynchronousBrowserSpeechSupersession('AI.configureProviders');
2191
2180
  this.#ensureLegacyLLMProvider(
2192
2181
  prepared.llm.default?.providerId
@@ -2221,6 +2210,7 @@ class AI {
2221
2210
 
2222
2211
  configureSpeechProviders(selections){
2223
2212
  const prepared=this.#providerRuntime.validateSpeechConfiguration(selections);
2213
+ this.#assertDeviceSpeechConfiguration(prepared);
2224
2214
  this.#assertSynchronousBrowserSpeechSupersession(
2225
2215
  'AI.configureSpeechProviders'
2226
2216
  );
@@ -2287,6 +2277,7 @@ class AI {
2287
2277
 
2288
2278
  async transitionProviders(selections){
2289
2279
  const prepared=this.#providerRuntime.validateConfiguration(selections);
2280
+ this.#assertDeviceSpeechConfiguration(prepared);
2290
2281
  await this.#supersedeBrowserSpeechForRouteChange();
2291
2282
  this.#ensureLegacyLLMProvider(
2292
2283
  prepared.llm.default?.providerId
@@ -2318,6 +2309,7 @@ class AI {
2318
2309
 
2319
2310
  async transitionSpeechProviders(selections){
2320
2311
  const prepared=this.#providerRuntime.validateSpeechConfiguration(selections);
2312
+ this.#assertDeviceSpeechConfiguration(prepared);
2321
2313
  await this.#supersedeBrowserSpeechForRouteChange();
2322
2314
  this.#invalidateSpeechControl();
2323
2315
  await this.#unloadSpeechProviderRolesForTransition();