arcane-os 0.33.5 → 0.34.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.
@@ -81,8 +81,11 @@ two failure types share that three-retry budget. HTTP `429` with a message
81
81
  containing `overload` (case-insensitive) retains its unlimited three-second
82
82
  retry behavior and does not consume that budget. Each attempt reuses the exact
83
83
  destination, Fetch options, serialized request, and signal. Other HTTP errors,
84
- response decoding failures, and application callback failures do not retry.
85
- The last complete failure is thrown unchanged when recovery is exhausted.
84
+ successful-response decoding failures, and application callback failures do not
85
+ retry. A known HTTP `529` also retries when reading or parsing its diagnostic
86
+ body fails; `onRetry.error` retains that exact failure, which is thrown unchanged
87
+ if recovery is exhausted. Diagnostic-body failures for other HTTP statuses do
88
+ not retry. The last complete failure is thrown unchanged when recovery is exhausted.
86
89
  Pass a fresh
87
90
  `AbortController`'s `signal` and call `abort()` to cancel. Cancellation during
88
91
  the request, response-body read, retry wait, or callback settlement prevents
@@ -41,7 +41,7 @@ authoritative in the linked contract document.
41
41
  | `arcane/entities/Theme.js#themeTokens` | constant | `themeTokens` | Frozen ordered definitions that map public theme-token keys to CSS custom properties, labels, and defaults. |
42
42
  | `arcane/entities/Theme.js#themeColorToHex` | function | `themeColorToHex` | Validates an RGB, RGBA, or six-digit hexadecimal color and returns its RGB channels as a normalized six-digit hexadecimal color. |
43
43
  | `arcane/entities/Theme.js#default` | class | `Theme` | Validates and freezes a named light or dark token set and can serialize, restore, apply, or clear that theme on a document root. |
44
- | `arcane/entities/User.js#default` | class | `UserEntity` | Owns the browser user's validated settings and profile record, including DBOPFS load, refresh, serialized updates, and optional persistence. |
44
+ | `arcane/entities/User.js#default` | class | `UserEntity` | Owns the browser user's settings and profile record, including DBOPFS load, refresh, serialized partial or fresh-baseline callback updates, and optional persistence. Callback null skips saving; timestamp setters merge only their fields through the same write owner. |
45
45
  | `arcane/entities/Weather.js#WeatherLocation` | class | `WeatherLocation` | Validates and freezes geographic identity, coordinates, and timezone metadata for a weather location. |
46
46
  | `arcane/entities/Weather.js#WeatherObservation` | class | `WeatherObservation` | Validates and freezes one timestamped current-weather observation and its units. |
47
47
  | `arcane/entities/Weather.js#WeatherDay` | class | `WeatherDay` | Validates and freezes one daily forecast with temperature, precipitation, and optional sunrise and sunset times. |
@@ -6,7 +6,7 @@
6
6
  "minimumVersion": "22.23.2 for Node entrypoints",
7
7
  "moduleSystem": "ESM"
8
8
  },
9
- "memberCount": 217,
9
+ "memberCount": 218,
10
10
  "runtimeSubpathPatterns": {
11
11
  "arcane-os/modules/*": "./runtime/arcane/modules/*",
12
12
  "arcane-os/entities/*": "./runtime/arcane/entities/*"
@@ -3106,6 +3106,20 @@
3106
3106
  "protocol": "arcane-ai-browser-speech-artifacts/1 over arcane-ai-provider/2",
3107
3107
  "normalization": "Exact protocol string; it grants no model authority and names no model download"
3108
3108
  },
3109
+ {
3110
+ "id": "browser-speech:createDigitalOceanFalTTSProvider",
3111
+ "name": "createDigitalOceanFalTTSProvider",
3112
+ "displayName": "createDigitalOceanFalTTSProvider()",
3113
+ "kind": "function",
3114
+ "signature": "createDigitalOceanFalTTSProvider({id,model,getApiKey,maxConcurrentRequests=4,fetch=globalThis.fetch}={})",
3115
+ "entrypoints": ["arcane-os/ai/browser-speech"],
3116
+ "primaryImport": "arcane-os/ai/browser-speech",
3117
+ "group": "Browser speech providers",
3118
+ "summary": "Creates an explicitly selected DigitalOcean FAL remote TTS provider using standard Fetch and completed audio jobs.",
3119
+ "availability": "Browser or Node with Fetch, Blob, AbortController, and an application-owned DigitalOcean inference key",
3120
+ "protocol": "arcane-ai-provider/2 tts/synthesize",
3121
+ "normalization": "Construction and activation make no adapter network requests. Activation awaits the application-owned string or promise key reader. localOnly false, concurrent requests, complete audio Blob results, and local cancellation without claiming remote-job cancellation. Applications own model, voice, credentials, and activation."
3122
+ },
3109
3123
  {
3110
3124
  "id": "browser-speech:createBrowserKokoroProvider",
3111
3125
  "name": "createBrowserKokoroProvider",
@@ -19,7 +19,7 @@
19
19
  {"file":"runtime/arcane/entities/Preference.js","exports":["default","preferenceSchema"],"availability":"cross-host","normalization":"normalized"},
20
20
  {"file":"runtime/arcane/entities/TerminalSession.js","exports":["default","terminalShells"],"availability":"cross-host","normalization":"normalized"},
21
21
  {"file":"runtime/arcane/entities/Theme.js","exports":["arcaneDarkThemeTokens","arcaneLightThemeTokens","default","themeColorToHex","themeTokens"],"availability":"cross-host/dom-when-applied","normalization":"normalized/mixed"},
22
- {"file":"runtime/arcane/entities/User.js","exports":["default","profileUpdateFromSearchParams"],"availability":"browser/native-webview","normalization":"Existing User lifecycle is unchanged; profileUpdateFromSearchParams purely maps nonempty supplied URLSearchParams values without further decoding, trimming, or persistence."},
22
+ {"file":"runtime/arcane/entities/User.js","exports":["default","profileUpdateFromSearchParams"],"availability":"browser/native-webview","normalization":"updateExplicit accepts existing partial/JSON updates or an awaited fresh-profile callback under the existing serialized write owner; callback null skips saving and returns the fresh profile, while failed writes restore the fresh baseline. Timestamp setters persist partial fields through that owner. profileUpdateFromSearchParams purely maps nonempty supplied URLSearchParams values without further decoding, trimming, or persistence."},
23
23
  {"file":"runtime/arcane/entities/Weather.js","exports":["WeatherDay","WeatherLocation","WeatherObservation","WeatherSnapshot"],"availability":"cross-host","normalization":"normalized"}
24
24
  ]
25
25
  }
@@ -342,7 +342,9 @@ transcript is a masked vertical scroll viewport; status and composer remain
342
342
  outside it. Initial restoration follows the true bottom. User, assistant, tool,
343
343
  streaming, progress, and failure updates continue following it, including later
344
344
  message-card, status, composer, or viewport resizing, until the reader scrolls
345
- toward earlier content. Scrolling down to the bottom resumes following. Layout
345
+ toward earlier content. Scrolling down to the bottom or submitting an accepted
346
+ non-synthetic user message resumes following. Canceled or rejected submissions
347
+ and synthetic requests preserve the reader's position. Layout
346
348
  clamping, hidden views, and BFCache restoration preserve that choice;
347
349
  destruction releases scroll observation. Each
348
350
  restored or new message uses a separate semantic `<time>` element at the card's
@@ -274,6 +274,46 @@ Owns persisted user preference/profile fields, explicit-preference updates,
274
274
  fresh reads, saves, and JSON projection. It installs `window.user` after its
275
275
  DBLS/DBOPFS lifecycle and emits `user-entity-loaded`.
276
276
 
277
+ ### updateExplicit(update)
278
+
279
+ Accepts an ordinary partial profile object, a JSON string, or a function
280
+ `current => partialOrNull`. It returns `Promise<UserEntityData>` with the
281
+ resulting explicit profile, retaining the existing field setters and their
282
+ validation behavior.
283
+
284
+ The function is called once after the latest durable profile has been read,
285
+ inside the existing serialized update and Web Lock. It may return a promise.
286
+ Return only the fields to change, or return `null` to skip the durable write
287
+ and receive the fresh profile unchanged. This no-write meaning applies only
288
+ to the callback result; ordinary object/string inputs and direct `null` retain
289
+ their existing behavior. With `persist=false`, the callback uses current
290
+ memory and performs no durable read or write.
291
+
292
+ ```javascript
293
+ const displayedName = user.username;
294
+ const profile = await user.updateExplicit(function renameTheCaptain(current) {
295
+ if (current.username !== displayedName) return null;
296
+ return {username: 'Captain Moonboots'};
297
+ });
298
+ ```
299
+
300
+ Callbacks compute a patch without mutating `current`. Do not call or await
301
+ `updateExplicit()`, `withFreshExplicit()`, or `refresh()` on the same instance
302
+ inside the callback: those operations depend on the current queue entry
303
+ finishing. Fetch outside the callback; compare against `current` at the write
304
+ boundary. Rendering and unrelated background work need not await this update.
305
+
306
+ A thrown or rejected callback causes no save and rejects with the original
307
+ error. A failed save restores the fresh baseline in memory and rejects with
308
+ the save error. The persistence setting is restored and later queued updates
309
+ remain usable. The existing `current_time` and `last_successful_time` setters
310
+ keep their immediate in-memory assignment, then save only their timestamp
311
+ field through this same fresh partial-update owner. This preserves newer
312
+ durable profile fields when TimeGuard records a check. Automatic timestamp
313
+ save failures are reported through Arcane logging; callers requiring durable
314
+ completion use and await `updateExplicit({current_time: value})` or the
315
+ corresponding `last_successful_time` partial update.
316
+
277
317
  ### profileUpdateFromSearchParams(searchParams)
278
318
 
279
319
  Named export from `arcane-os/entities/User.js`. Pass an existing
@@ -144,10 +144,11 @@ playback.
144
144
 
145
145
  ### Public surface
146
146
 
147
- default `AI`; read-only `providerRuntime`, `browserSpeechConfiguration`, and
147
+ default `AI`; read-only `providerRuntime`, `speechActivationPending`, `browserSpeechConfiguration`, and
148
148
  `browserSpeechDescriptor`; `configureBrowserSpeech(configuration,{signal})`,
149
149
  `disposeBrowserSpeech({signal})`, `setAI()`, `configureProviders()`,
150
- `configureSpeechProviders()`, `transitionAI()`, `transitionProviders()`,
150
+ `configureSpeechProviders()`, `configureSpeechProvider(role,provider,options)`,
151
+ `transitionAI()`, `transitionProviders()`,
151
152
  `transitionSpeechProviders()`, `startProviders()`, `setSpeechMuted()`,
152
153
  `streamRequest()`, `streamMessage()`, `fetchRequest()`, `fetch()`,
153
154
  read-only `ttsSegmentation`, `configureTTSSegmentation()`,
@@ -217,16 +218,17 @@ returns aggregate runtime status; `transitionProviders()` returns the configured
217
218
  three-role route configuration. Selected TWiN Cloud `TWIN` LLM, `OLLAMA` LLM,
218
219
  and Core `LOCAL_SPEACH` STT/TTS built-in routes expose truthful capability-only
219
220
  readiness through internal provider/2 adapters without probing, downloading, or
220
- hiding a load. Configured `OPENAI` speech preferences migrate to on-device
221
- `LOCAL_SPEACH`, with Whisper for STT and Kokoro for TTS. TWiN Cloud availability
221
+ hiding a load. Explicit speech preference slots remain unchanged, including a
222
+ selected external provider that the application registers after AI startup.
223
+ TWiN Cloud availability
222
224
  requires the selected LLM route, its model, a credential, and `fetch`; Core speech
223
225
  availability requires the exact selected `Arcane.speech.transcribe` or
224
226
  `synthesize` method. `fetchRequest()`
225
227
  keeps the selected provider's public response shape. Browser speech routes
226
228
  translate the existing AI.js STT `{audio:Blob|File,mimeType,model}` and TTS
227
229
  `{model,input,responseFormat,voice?,speed?}` requests at the provider boundary;
228
- only WAV is accepted for the shared TTS result. TTS voice selection comes from
229
- the exact selected local provider/model catalog `defaultVoice`; a saved
230
+ the selected provider declares its supported audio format. TTS voice selection comes from
231
+ the exact selected provider/model catalog `defaultVoice`; a saved
230
232
  OpenAI-route voice is never forwarded to another provider route.
231
233
 
232
234
  The TWiN Cloud built-in provider and default-model preference sentinel are
@@ -252,14 +254,34 @@ tool declarations, emitted tool calls, or callback ordering.
252
254
 
253
255
  `configureSpeechProviders({stt,tts})` commits only the two speech routes and
254
256
  leaves the current LLM route and sticky lifecycle record unchanged. Both speech
255
- roles must be unloaded, use local-only selections, and own no request, load,
256
- unload, or dispose operation. Non-local STT and TTS selections reject with
257
- `AI_STT_DEVICE_ONLY` and `AI_TTS_DEVICE_ONLY`, respectively.
257
+ roles must be unloaded and own no request, load, unload, or dispose operation.
258
+ STT remains local-only (`AI_STT_DEVICE_ONLY` for a remote selection); an
259
+ explicit TTS provider may declare `localOnly:false`.
258
260
  `transitionSpeechProviders({stt,tts})` stops queued audio, explicitly unloads
259
261
  only STT and TTS, then commits that same closed speech route record. Neither
260
262
  method loads a model, selects a fallback, or changes caller-owned model or voice
261
263
  policy.
262
264
 
265
+ `configureSpeechProvider(role,provider,{modelId,signal,expectedProvider})`
266
+ asynchronously registers and selects one provider/2 speech role, unloading and
267
+ disposing only its previous SDK-owned provider. `modelId` defaults to the first
268
+ catalog entry. It returns the selected `{providerId,modelId,localOnly}` record;
269
+ passing `null` removes that role and returns `null`. An optional
270
+ `expectedProvider` is compared inside the configuration lane; if another
271
+ provider has taken ownership, removal resolves `false` without changing it.
272
+ An unaccepted candidate is disposed on failure; a failure after replacement
273
+ has `committed:true`, and runtime ownership identifies the installed provider.
274
+
275
+ This method composes with an STT-only or TTS-only `configureBrowserSpeech()`
276
+ in either order. The other speech role and LLM keep their routes, readiness,
277
+ requests, and storage. Use sequential disposal when combining this method
278
+ with `disposeBrowserSpeech()`. Configuration does not activate the new provider
279
+ or make a network request. `setSpeechMuted(false)` waits for an in-progress
280
+ configuration before activating the committed TTS selection; a later mute
281
+ cancels that pending unmute. The application keeps model, voice, and credential
282
+ selection. See [DigitalOcean FAL speech](ai/browser-speech.md#digitalocean-fal-text-to-speech)
283
+ for the public remote adapter and completed-job audio transport.
284
+
263
285
  `startProviders({startLanguageModel=true,startMuted=true,startTranscription=false,signal=null}={})`
264
286
  starts provider-owned text chat without requesting an STT load by default.
265
287
  Callers selecting a browser-WASM LLM pass `startLanguageModel:false` so it
@@ -273,6 +295,14 @@ user activation intent exposed by the shared speech component.
273
295
  `setSpeechMuted(false)` records the public unmuted state only after the selected
274
296
  TTS route reaches ready; a failed load leaves the public state muted. In contrast,
275
297
  `setSpeechMuted(true)` cancels active TTS work and unloads that role.
298
+ An explicit unmute may wait for its selected external provider to register.
299
+ `speechActivationPending` distinguishes that intent from an ordinary muted
300
+ state. Streamed text and its final remainder may enter the existing speech
301
+ queue during that wait; synthesis waits for the same activation promise.
302
+ Text never activates a provider itself. Stop cancels queued preparation even
303
+ while registration is pending; mute, disposal, a new startup operation, or an
304
+ actual route replacement also revoke the pending activation. Initial
305
+ registration of the already-selected provider/model preserves queued speech.
276
306
  The optional browser-speech `stt.execution` and `tts.execution` records select
277
307
  `device:'auto'|'webnn-npu'|'webgpu'|'wasm'` and `maxConcurrentRequests`.
278
308
  Omission uses NPU, GPU, then CPU automatic selection with one Whisper slot and
@@ -67,7 +67,7 @@ runtime layouts.
67
67
  | `arcane-os/ai/browser-wasm` | Caller-selected browser-local Wllama inference, complete DBOPFS model storage, streaming, cancellation, and structural tool-call results. |
68
68
  | `arcane-os/ai/tool-text-stream` | Shared selected tool-argument text observer for provider integration. |
69
69
  | `arcane-os/ai/twin-cloud` | Complete TWiN Cloud requests from Node or a browser with an explicit key/model and shared retry/cancellation behavior. |
70
- | `arcane-os/ai/browser-speech` | Caller-selected browser-local Whisper STT and Kokoro TTS provider mechanisms, ordinary upstream assets, materialized/native routing, Workers, and cancellation. |
70
+ | `arcane-os/ai/browser-speech` | Caller-selected browser-local Whisper STT and Kokoro TTS, explicit DigitalOcean FAL remote TTS, ordinary upstream assets, role lifecycle, and cancellation. |
71
71
  | `arcane-os/mail` | Portable Mail runtime, durable outbox, complete transport responses, and provider-neutral acceptance contracts. |
72
72
 
73
73
  These lowercase runtime-module entrypoints expose their existing exports; they
@@ -178,6 +178,7 @@ browser map are cataloged separately in [Runtime modules](runtime-modules.md).
178
178
  | `getPwaInstall()` | function | `arcane-os/pwa` | Progressive web applications | Browser native installation events; waiting state while no prompt is available |
179
179
  | `mountPwaInstallPrompt()` | function | `arcane-os/pwa` | Progressive web applications | Browser document and managed HTML import; resolves to null without a document |
180
180
  | `createDbopfsSpeechArtifactStore()` | function | `arcane-os/ai/browser-speech` | Browser speech providers | Browser with ready DBOPFS, Web Locks, Fetch, File/Blob, and object URLs |
181
+ | `createDigitalOceanFalTTSProvider()` | function | `arcane-os/ai/browser-speech` | Browser speech providers | Browser or Node with Fetch, Blob, and an application-supplied DigitalOcean inference key |
181
182
  | `removeBrowserSpeechModelCache()` | function | `arcane-os/ai/browser-speech` | Browser speech providers | Browser CacheStorage; explicit removal of one selected upstream model |
182
183
  | `createNativeBuildPlan()` | function | `arcane-os` | Targets, native plans, and providers | Node; selected browser/native target or provider as documented |
183
184
  | `createNativeTargetAdapter()` | function | `arcane-os` | Targets, native plans, and providers | Node; selected browser/native target or provider as documented |
@@ -6763,7 +6764,7 @@ releaseProvider();
6763
6764
 
6764
6765
  # Browser speech providers
6765
6766
 
6766
- `arcane-os/ai/browser-speech` exports exactly the seven package members below.
6767
+ `arcane-os/ai/browser-speech` exports the package members below.
6767
6768
  It ships provider, authority, artifact-store, and Worker mechanisms but no
6768
6769
  Whisper/Kokoro model weights, adapter runtime artifacts, voices, download URLs,
6769
6770
  default catalog, CDN loader, native bridge, or cloud fallback. The providers
@@ -7121,6 +7122,69 @@ async function transcribeAfterUserChoice(audioBlob) {
7121
7122
  }
7122
7123
  ```
7123
7124
 
7125
+ ## createDigitalOceanFalTTSProvider()
7126
+
7127
+ ### Overview
7128
+
7129
+ Creates a `localOnly:false` provider/2 TTS adapter for the caller-selected
7130
+ DigitalOcean FAL model. It submits independent synthesis jobs, polls their
7131
+ completion, and returns each complete downloaded audio `Blob`. The shared AI
7132
+ queue owns punctuation segmentation, bounded concurrent requests, and playback
7133
+ in input order; this is not provider-native live audio streaming.
7134
+
7135
+ ### Signature and parameters
7136
+
7137
+ ```text
7138
+ createDigitalOceanFalTTSProvider({id,model,getApiKey,maxConcurrentRequests=4,fetch=globalThis.fetch}={})
7139
+ ```
7140
+
7141
+ `id` is the application-owned provider identifier. `model` contains the selected
7142
+ `id` and `defaultVoice`; the supported multilingual-v2 selection is
7143
+ `fal-ai/elevenlabs/tts/multilingual-v2`. `getApiKey()` returns the
7144
+ application's current DigitalOcean inference key or a promise for it. The credential is never
7145
+ included in catalog or status. `maxConcurrentRequests` is a positive integer;
7146
+ the runtime owns admission and its FIFO queue. Optional `fetch` supplies the
7147
+ standard Fetch interface.
7148
+
7149
+ ### Availability and normalization
7150
+
7151
+ The adapter needs Fetch, Blob, and AbortController; it needs no Worker or
7152
+ DBOPFS. Browser access depends on endpoint CORS support. Construction makes
7153
+ no network request. Inspection and activation await the application's key
7154
+ reader without making an SDK network request. Activation checks
7155
+ local credential presence, not remote validity. Status reports remote
7156
+ execution and current activity. Requests preserve complete text, selected
7157
+ voice, and speed; multilingual-v2 speed outside `0.7` through `1.2` rejects
7158
+ instead of clamping. Returned media is fetched without the inference key.
7159
+ Cancelling a credential wait leaves the application's shared refresh alone
7160
+ and prevents a late result from starting this cancelled operation.
7161
+
7162
+ Each provider/2 `tts/synthesize` request returns one complete audio Blob.
7163
+ Request cancellation, unload, and disposal stop local polling/downloads and
7164
+ discard late results; they cannot promise cancellation of an already submitted
7165
+ remote job. HTTP, provider, and media failures remain observable. The adapter
7166
+ does not select a fallback or silently resubmit a paid synthesis POST.
7167
+
7168
+ ### Example
7169
+
7170
+ ```javascript
7171
+ import {createDigitalOceanFalTTSProvider} from 'arcane-os/ai/browser-speech';
7172
+
7173
+ async function configureCloudVoice(ai, getApiKey) {
7174
+ const provider = createDigitalOceanFalTTSProvider({
7175
+ id: 'selected-cloud-voice',
7176
+ model: {id: 'fal-ai/elevenlabs/tts/multilingual-v2', defaultVoice: 'Rachel'},
7177
+ getApiKey
7178
+ });
7179
+ await ai.configureSpeechProvider('tts', provider);
7180
+ return provider;
7181
+ }
7182
+ ```
7183
+
7184
+ Call activation and synthesis from the application's explicit speech action.
7185
+ For streaming text, cancellation, and independent STT configuration, see
7186
+ [DigitalOcean FAL speech](ai/browser-speech.md#digitalocean-fal-text-to-speech).
7187
+
7124
7188
  ## createBrowserKokoroProvider()
7125
7189
 
7126
7190
  ### Overview
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.33.5",
3
+ "version": "0.34.0",
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",
@@ -86,7 +86,7 @@
86
86
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
87
87
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
88
88
  "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.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",
89
- "test:functional": "node ./bin/arcane-test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.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/modal.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/twin-cloud.test.mjs test/update-check.test.mjs",
89
+ "test:functional": "node ./bin/arcane-test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/cli.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.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/modal.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/twin-cloud.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
90
90
  "test:integration": "node ./bin/arcane-test.mjs test/installed-package-runtime.test.mjs test/root-app-layout.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/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
91
91
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
92
92
  "check": "node tools/check-source.mjs && npm test",
@@ -1161,9 +1161,13 @@
1161
1161
  scrollTranscriptToBottom();
1162
1162
  }
1163
1163
 
1164
- function scrollTranscriptToBottom() {
1164
+ function scrollTranscriptToBottom(resumeFollowing = false) {
1165
1165
  // Read movement before writing: a reader's scroll event may still be queued.
1166
- if(!trackTranscriptScrollPosition() || !transcriptFollowing) return false;
1166
+ const visible = trackTranscriptScrollPosition();
1167
+ if(resumeFollowing === true && !destroyed) {
1168
+ transcriptFollowing = true;
1169
+ }
1170
+ if(!visible || !transcriptFollowing) return false;
1167
1171
  chatOutput.scrollTop = chatOutput.scrollHeight;
1168
1172
  transcriptScrollTop = chatOutput.scrollTop;
1169
1173
  return true;
@@ -2961,13 +2965,13 @@
2961
2965
  const clearThinking=thinkingHTML;
2962
2966
 
2963
2967
  //console.table(arguments);
2968
+ const aiRuntime=boundChatAI??globalThis.ai;
2964
2969
  if(
2965
2970
  !isThinking
2966
2971
  &&text
2967
- &&host.aiAvailability.tts
2968
- &&speech.muted===false
2972
+ &&((host.aiAvailability.tts&&speech.muted===false)
2973
+ ||aiRuntime?.speechActivationPending===true)
2969
2974
  ){
2970
- const aiRuntime=boundChatAI??globalThis.ai;
2971
2975
  if(is.function(aiRuntime?.streamTTS)){
2972
2976
  void Promise.resolve(aiRuntime.streamTTS(text)).catch(
2973
2977
  reportTTSError
@@ -3831,12 +3835,14 @@
3831
3835
  const visibleMessage=appendTranscriptMessage('user',text,host.name);
3832
3836
  visibleMessage.dataset.operationId=operationId;
3833
3837
  }
3834
- scrollTranscriptToBottom();
3838
+ scrollTranscriptToBottom(!eventContext.synthetic);
3835
3839
 
3836
- if(host.aiAvailability.tts&&speech.muted===false){
3837
- const aiRuntime=boundChatAI??globalThis.ai;
3840
+ const aiRuntime=boundChatAI??globalThis.ai;
3841
+ if((host.aiAvailability.tts&&speech.muted===false)
3842
+ ||aiRuntime?.speechActivationPending===true){
3838
3843
  aiRuntime?.stopAudio?.();
3839
- if(is.function(aiRuntime?.resumeAudio)){
3844
+ if(host.aiAvailability.tts&&speech.muted===false
3845
+ &&is.function(aiRuntime?.resumeAudio)){
3840
3846
  void Promise.resolve(aiRuntime.resumeAudio()).catch(
3841
3847
  function reportPlaybackResumeFailure(error){
3842
3848
  reportTTSError(error,'playback-resume');
@@ -392,6 +392,7 @@
392
392
  recordButton.addEventListener('blur', handleRecordBlur, lifecycleListenerOptions);
393
393
  stopButton.addEventListener('click', stop, lifecycleListenerOptions);
394
394
  muteButton.addEventListener('click', muteToggle, lifecycleListenerOptions);
395
+ window.addEventListener('ai-ready', synchronizeAIReadiness, lifecycleListenerOptions);
395
396
  window.addEventListener(
396
397
  'pagehide',
397
398
  handlePageHide,
@@ -417,11 +418,7 @@
417
418
  renderStatus();
418
419
  void observeMicrophonePermission();
419
420
  void aiModuleImport.then(
420
- function synchronizeMutedStateAfterAIImport() {
421
- if (!destroyed) {
422
- synchronizeAIMutedState();
423
- }
424
- },
421
+ synchronizeAIReadiness,
425
422
  function reportAIModuleImportFailure(error) {
426
423
  if (destroyed) {
427
424
  return;
@@ -546,6 +543,17 @@
546
543
  applyRoleTransitions(previousSTTRole, previousTTSRole);
547
544
  }
548
545
 
546
+ function synchronizeAIReadiness() {
547
+ if (destroyed) {
548
+ return;
549
+ }
550
+ settleSuccessfulUnmute();
551
+ continuePendingUnmute();
552
+ synchronizeAIMutedState();
553
+ renderControls();
554
+ renderStatus();
555
+ }
556
+
549
557
  function applyRoleTransitions(previousSTTRole, previousTTSRole) {
550
558
  sttActivationController?.synchronize(sttRole);
551
559
  const sttBecameUnready = previousSTTRole.state === 'ready'
@@ -996,6 +1004,13 @@
996
1004
  ) {
997
1005
  return false;
998
1006
  }
1007
+ if (!is.function(globalThis.ai?.setSpeechMuted)) {
1008
+ pendingUnmute = true;
1009
+ host.muted = true;
1010
+ renderControls();
1011
+ renderStatus();
1012
+ return false;
1013
+ }
999
1014
  const providerSelected = is.string(ttsRole.providerId)
1000
1015
  && ttsRole.providerId.length > 0
1001
1016
  && is.string(ttsRole.modelId)
@@ -1016,8 +1031,12 @@
1016
1031
  }
1017
1032
 
1018
1033
  pendingUnmute = true;
1034
+ const generation = ttsIntentGeneration + 1;
1019
1035
  const requested = await requestTTSIntent('load', 'user');
1020
- if (!requested || destroyed) {
1036
+ if (destroyed || generation !== ttsIntentGeneration) {
1037
+ return false;
1038
+ }
1039
+ if (!requested) {
1021
1040
  pendingUnmute = false;
1022
1041
  host.muted = true;
1023
1042
  stopTTSPlayback();
@@ -1774,7 +1793,12 @@
1774
1793
  return;
1775
1794
  }
1776
1795
 
1796
+ const cancelPendingUnmute = activeTTSIntent?.action === 'load';
1777
1797
  destroyed = true;
1798
+ ttsIntentGeneration += 1;
1799
+ pendingUnmute = false;
1800
+ pendingTTSIntent = null;
1801
+ activeTTSIntent = null;
1778
1802
  runtimeStateAbortController.abort();
1779
1803
  sttActivationController?.destroy();
1780
1804
  window.removeEventListener('pagehide', handlePageHide);
@@ -1785,6 +1809,16 @@
1785
1809
  );
1786
1810
  cancelSTTOperation('component-destroyed');
1787
1811
  stopTTSPlayback();
1812
+ const aiRuntime = globalThis.ai;
1813
+ if (cancelPendingUnmute
1814
+ && aiRuntime?.speechActivationPending === true
1815
+ && is.function(aiRuntime.setSpeechMuted)) {
1816
+ void Promise.resolve(aiRuntime.setSpeechMuted(true)).catch(
1817
+ function reportSpeechTeardownFailure(error) {
1818
+ arcaneLogging.error('Speech activation could not be cancelled during component teardown.', error);
1819
+ }
1820
+ );
1821
+ }
1788
1822
  events.dispose();
1789
1823
  host.componentReady = false;
1790
1824
  renderControls();
@@ -370,7 +370,7 @@ class UserEntity {
370
370
 
371
371
  this.#current_time = v;
372
372
 
373
- this.#persist();
373
+ this.#persist({current_time:v});
374
374
  }
375
375
 
376
376
  /** @returns {number} */
@@ -386,7 +386,7 @@ class UserEntity {
386
386
 
387
387
  this.#last_successful_time = v;
388
388
 
389
- this.#persist();
389
+ this.#persist({last_successful_time:v});
390
390
  }
391
391
 
392
392
  /** @returns {string} */
@@ -822,7 +822,7 @@ class UserEntity {
822
822
  src=this.explicit;
823
823
  }
824
824
 
825
- if(!is.union(src,'object','string')){
825
+ if(!is.union(src,'object|string')){
826
826
  throw new Error('UserEntity.explicit setter expects object or JSON string');
827
827
  }
828
828
 
@@ -857,9 +857,11 @@ class UserEntity {
857
857
 
858
858
  /**
859
859
  * Applies and durably saves an explicit profile update as one serialized
860
- * operation. A failed write restores the prior in-memory values.
860
+ * operation. A callback computes a partial update from the fresh profile;
861
+ * its null result skips the write. It must not await another queued User
862
+ * operation on this instance. A failed write restores the fresh baseline.
861
863
  *
862
- * @param {UserEntityData|string|Object} src
864
+ * @param {UserEntityData|string|Object|function(UserEntityData): (Object|null|Promise<Object|null>)} src
863
865
  * @returns {Promise<UserEntityData>}
864
866
  */
865
867
  updateExplicit(src){
@@ -883,7 +885,19 @@ class UserEntity {
883
885
  this.persist=false;
884
886
  try{
885
887
  this.explicit=baseline;
886
- this.explicit=src;
888
+ }finally{
889
+ this.persist=persist;
890
+ }
891
+
892
+ const conditional=is.function(src);
893
+ const update=conditional?await src(this.explicit):src;
894
+ if(conditional&&update===null){
895
+ return this.explicit;
896
+ }
897
+
898
+ this.persist=false;
899
+ try{
900
+ this.explicit=update;
887
901
  }finally{
888
902
  this.persist=persist;
889
903
  }
@@ -1107,8 +1121,17 @@ class UserEntity {
1107
1121
  /**
1108
1122
  * Internal persistence trigger
1109
1123
  */
1110
- #persist(){
1124
+ #persist(update){
1111
1125
  if(this.persist){
1126
+ // TimeGuard's ordinary timestamp setters must merge only their
1127
+ // fields into the fresh profile, never overwrite another tab's edits.
1128
+ if(!is.undefined(update)){
1129
+ const operation=this.updateExplicit(update);
1130
+ void operation.catch(function reportTimestampSaveFailure(error){
1131
+ arcaneLogging.error('The user timestamp could not be saved.',error);
1132
+ });
1133
+ return operation;
1134
+ }
1112
1135
  return this.save();
1113
1136
  }
1114
1137