arcane-os 0.33.4 → 0.33.6

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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.33.6
4
+
5
+ - Resume transcript bottom-follow when an accepted non-synthetic user message
6
+ is sent, including when the reader had scrolled upward. Preserve reading
7
+ position for incoming content, synthetic requests, and canceled submissions.
8
+ - Keep HTTP 529 on its existing bounded retry path when reading its diagnostic
9
+ body fails. Preserve the actual parser or body-read error in retry observations
10
+ and final failure, with cancellation and other HTTP behavior unchanged.
11
+
12
+ ## 0.33.5
13
+
14
+ - Deliver the first visible streamed chat chunk to speech exactly once, without
15
+ waiting for speech playback or changing response order.
16
+ - Keep a following transcript at its actual bottom through message and composer
17
+ layout changes while preserving a reader's deliberate scroll-away choice.
18
+ - Retry pre-response Fetch failures and HTTP 529 up to three times after a
19
+ three-second delay. Preserve existing HTTP 429 overload retries, cancellation,
20
+ complete request content, and terminal errors without replaying streams or tools.
21
+ - Add observational `onRetry` waiting/requesting notifications to portable TWiN
22
+ and shared AI request APIs, including queued built-in provider requests.
23
+ - Let modal task jobs report complete transient progress to their own row through
24
+ `task(reportProgress)`, preserving parallel work and settled results.
25
+
3
26
  ## 0.33.4
4
27
 
5
28
  - Treat a repeated reader `AbortError` during intentional HTTP tool-text stream
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 share the theme, packaging, event, cancellation,
20
20
  and browser run contracts while retaining their selected application layout.
21
21
 
22
- This checkout defines the `0.33.4` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.33.6` 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
 
@@ -85,7 +85,7 @@ Create one browser application, install its pinned SDK, and start its source
85
85
  server:
86
86
 
87
87
  ```bash
88
- npx arcane-os@0.33.4 new hello-speech --path ./hello-speech --target browser
88
+ npx arcane-os@0.33.6 new hello-speech --path ./hello-speech --target browser
89
89
  cd hello-speech
90
90
  npm install
91
91
  ```
@@ -17,7 +17,8 @@ export async function fetchRequest({
17
17
  signal = null,
18
18
  id = Date.now(),
19
19
  onRequest = function observeTWiNRequest(){},
20
- onResponse = function observeTWiNResponse(){}
20
+ onResponse = function observeTWiNResponse(){},
21
+ onRetry = null
21
22
  } = {}){
22
23
  if(signal?.aborted){
23
24
  throw normalizeAIRequestAbort(signal.reason);
@@ -67,7 +68,8 @@ export async function fetchRequest({
67
68
  },
68
69
  body:JSON.stringify(request),
69
70
  ...(signal ? {signal} : {})
70
- }
71
+ },
72
+ {onRetry}
71
73
  );
72
74
  if(signal?.aborted){
73
75
  throw normalizeAIRequestAbort(signal.reason);
@@ -141,72 +143,135 @@ export function openAIResponseFormat(format){
141
143
  }
142
144
 
143
145
  /** Shared with browser streaming; consume no successful body at this boundary. */
144
- export async function fetchHTTPResponse(url, options){
146
+ export async function fetchHTTPResponse(url, options, {onRetry = null} = {}) {
145
147
  const {signal} = options;
146
148
  const retryDelayMs = 3000;
147
- try{
148
- while(true){
149
- if(signal?.aborted){
149
+ const maxRecoveryRetries = 3;
150
+ let recoveryRetries = 0;
151
+ let attempt = 0;
152
+ try {
153
+ while (true) {
154
+ if (signal?.aborted) {
150
155
  throw normalizeAIRequestAbort(signal.reason);
151
156
  }
152
- const response = await fetch(url, options);
153
- if(signal?.aborted){
157
+ let response;
158
+ let error;
159
+ try {
160
+ response = await fetch(url, options);
161
+ } catch (fetchError) {
162
+ if (isAIRequestAbort(fetchError, signal)) {
163
+ throw normalizeAIRequestAbort(fetchError);
164
+ }
165
+ error = fetchError;
166
+ }
167
+ if (signal?.aborted) {
154
168
  throw normalizeAIRequestAbort(signal.reason);
155
169
  }
156
- if(response.ok){
170
+ if (response?.ok) {
157
171
  return response;
158
172
  }
159
- const contentType = response.headers.get('content-type') || '';
160
- const error = contentType.includes('application/json')
161
- ? await response.json()
162
- : await response.text();
163
- if(signal?.aborted){
164
- throw normalizeAIRequestAbort(signal.reason);
173
+ const status = response?.status ?? null;
174
+ if (response) {
175
+ const contentType = response.headers.get('content-type') || '';
176
+ try {
177
+ error = contentType.includes('application/json')
178
+ ? await response.json()
179
+ : await response.text();
180
+ } catch (bodyError) {
181
+ if (status !== 529 || isAIRequestAbort(bodyError, signal)) {
182
+ throw bodyError;
183
+ }
184
+ error = bodyError;
185
+ }
186
+ if (signal?.aborted) {
187
+ throw normalizeAIRequestAbort(signal.reason);
188
+ }
165
189
  }
166
190
  const message = is.string(error)
167
191
  ? error
168
192
  : error?.error?.message ?? error?.message;
169
- if(
170
- response.status !== 429
171
- || !is.string(message)
172
- || !message.toLowerCase().includes('overload')
173
- ){
174
- throw error;
193
+ const overload = status === 429
194
+ && is.string(message)
195
+ && message.toLowerCase().includes('overload');
196
+ if (!overload) {
197
+ if ((status !== null && status !== 529)
198
+ || recoveryRetries >= maxRecoveryRetries) {
199
+ throw error;
200
+ }
201
+ recoveryRetries += 1;
175
202
  }
203
+ attempt += 1;
176
204
  arcaneLogging.warn(
177
- `${message}\nRetrying in ${retryDelayMs / 1000} seconds`,
205
+ `${message ?? 'AI request transport failed.'}\nRetrying in ${retryDelayMs / 1000} seconds`,
178
206
  error
179
207
  );
180
- await new Promise(function waitForOverloadRetry(resolve, reject){
181
- function finishRetryDelay(){
182
- signal?.removeEventListener('abort', cancelRetryDelay);
183
- resolve();
184
- }
185
- function cancelRetryDelay(){
186
- clearTimeout(timer);
187
- signal.removeEventListener('abort', cancelRetryDelay);
188
- reject(normalizeAIRequestAbort(signal.reason));
189
- }
190
- const timer = setTimeout(finishRetryDelay, retryDelayMs);
191
- signal?.addEventListener('abort', cancelRetryDelay, {once:true});
192
- if(signal?.aborted){
193
- cancelRetryDelay();
208
+ observeRequestRetry(
209
+ onRetry,
210
+ {phase: 'waiting', attempt, delayMs: retryDelayMs, status, error}
211
+ );
212
+ await new Promise(
213
+ function waitForRequestRetry(resolve, reject) {
214
+ function finishRetryDelay() {
215
+ signal?.removeEventListener('abort', cancelRetryDelay);
216
+ resolve();
217
+ }
218
+ function cancelRetryDelay() {
219
+ clearTimeout(timer);
220
+ signal.removeEventListener('abort', cancelRetryDelay);
221
+ reject(normalizeAIRequestAbort(signal.reason));
222
+ }
223
+ const timer = setTimeout(finishRetryDelay, retryDelayMs);
224
+ signal?.addEventListener(
225
+ 'abort',
226
+ cancelRetryDelay,
227
+ {once: true}
228
+ );
229
+ if (signal?.aborted) {
230
+ cancelRetryDelay();
231
+ }
194
232
  }
195
- });
233
+ );
234
+ if (signal?.aborted) {
235
+ throw normalizeAIRequestAbort(signal.reason);
236
+ }
237
+ observeRequestRetry(
238
+ onRetry,
239
+ {phase: 'requesting', attempt, delayMs: retryDelayMs, status, error}
240
+ );
196
241
  }
197
- }catch(error){
198
- if(isAIRequestAbort(error, signal)){
242
+ } catch (error) {
243
+ if (isAIRequestAbort(error, signal)) {
199
244
  throw normalizeAIRequestAbort(error);
200
245
  }
201
246
  throw error;
202
247
  }
203
248
  }
204
249
 
250
+ function observeRequestRetry(onRetry, state) {
251
+ if (!is.function(onRetry)) {
252
+ return;
253
+ }
254
+ function reportRetryObserverFailure(error) {
255
+ arcaneLogging.error('Arcane AI retry observer failed.', error);
256
+ }
257
+ try {
258
+ Promise.resolve(
259
+ onRetry(state)
260
+ ).catch(reportRetryObserverFailure);
261
+ } catch (error) {
262
+ reportRetryObserverFailure(error);
263
+ }
264
+ }
265
+
205
266
  /** Return the entire parsed completion without selecting or rewriting choices. */
206
- export async function fetchJSONResponse(url, options){
267
+ export async function fetchJSONResponse(url, options, {onRetry = null} = {}) {
207
268
  const {signal} = options;
208
269
  try{
209
- const response = await fetchHTTPResponse(url, options);
270
+ const response = await fetchHTTPResponse(
271
+ url,
272
+ options,
273
+ {onRetry}
274
+ );
210
275
  if(signal?.aborted){
211
276
  throw normalizeAIRequestAbort(signal.reason);
212
277
  }
@@ -75,9 +75,18 @@ The key is
75
75
  transport authentication, not part of either callback's message payload. Keep
76
76
  credentials out of application logging as well.
77
77
 
78
- Only HTTP `429` with a message containing `overload` (case-insensitive) repeats automatically,
79
- after `3000` milliseconds. Another overload repeats the same complete request;
80
- other HTTP failures do not become an automatic retry loop. Pass a fresh
78
+ Before a successful response is consumed, a rejected Fetch or HTTP `529` can
79
+ retry up to three times, waiting `3000` milliseconds before each retry. These
80
+ two failure types share that three-retry budget. HTTP `429` with a message
81
+ containing `overload` (case-insensitive) retains its unlimited three-second
82
+ retry behavior and does not consume that budget. Each attempt reuses the exact
83
+ destination, Fetch options, serialized request, and signal. Other HTTP errors,
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.
89
+ Pass a fresh
81
90
  `AbortController`'s `signal` and call `abort()` to cancel. Cancellation during
82
91
  the request, response-body read, retry wait, or callback settlement prevents
83
92
  successful result delivery and rejects with `ARCANE_AI_REQUEST_ABORTED`.
@@ -86,6 +95,17 @@ A missing key uses `AI_PROVIDER_NOT_CONFIGURED`, a missing explicit model
86
95
  throws `TypeError`, and an unsupported `structuredOutput` input uses
87
96
  `AI_STRUCTURED_OUTPUT_INVALID`.
88
97
 
98
+ Optional `onRetry({phase,attempt,delayMs,status,error})` observes each retry.
99
+ `phase:'waiting'` arrives before the abortable delay; `phase:'requesting'`
100
+ arrives immediately before the next Fetch. `attempt` is the one-based upcoming
101
+ retry number across both retry policies, `delayMs` is `3000`, `status` is the
102
+ HTTP status or `null` for a rejected Fetch, and `error` is the complete original
103
+ failure value. This observer runs synchronously without awaiting its returned
104
+ promise. Synchronous throws and rejected promises are reported through the
105
+ shared console logger and cannot change the request outcome or trigger another
106
+ retry. `onRequest` still runs once per logical request. Retry observations stay
107
+ outside the model payload and retained history.
108
+
89
109
  The SDK retains no request or response history between calls and uses no
90
110
  DBOPFS, chat entity, or memory extraction. Each call's `messages` are its
91
111
  complete caller-supplied context. The caller decides whether and how to keep
@@ -104,8 +124,8 @@ the existing implementation rather than maintain another retry or body reader.
104
124
 
105
125
  | Export | Contract |
106
126
  | --- | --- |
107
- | `fetchHTTPResponse(url,options)` | Uses the caller's Fetch options, overload retry and cancellation; returns a successful `Response` with its body unconsumed. |
108
- | `fetchJSONResponse(url,options)` | Uses that HTTP owner, requires `application/json`, and returns the complete parsed body without selecting choices. |
127
+ | `fetchHTTPResponse(url,options,{onRetry=null}={})` | Uses the caller's exact Fetch options and shared retry/cancellation behavior; returns a successful `Response` with its body unconsumed. The separate optional control observes retries without entering Fetch options. |
128
+ | `fetchJSONResponse(url,options,{onRetry=null}={})` | Uses that HTTP owner and retry observer, requires `application/json`, and returns the complete parsed body without selecting choices. |
109
129
  | `structuredOutputFormat(value=false)` | Maps false/null/undefined to null, true/`'json'` to `'json'`, and preserves a supplied plain JSON Schema object. Other inputs use `AI_STRUCTURED_OUTPUT_INVALID`. |
110
130
  | `openAIResponseFormat(format)` | Maps the normalized value to `json_object`, strict `json_schema` named `structured_response`, or null. |
111
131
  | `isAIRequestAbort(error,signal)` | Recognizes an aborted signal, `AbortError`, or the existing Arcane AI/request cancellation codes. |
@@ -113,8 +133,8 @@ the existing implementation rather than maintain another retry or body reader.
113
133
 
114
134
  These helpers start no work on import. HTTP helpers require explicit URL and
115
135
  options; they do not add a key, model, browser state, or retained conversation.
116
- Overload warnings use the shared console logger and preserve the complete
117
- provider error.
136
+ Retry warnings and observer failures use the shared console logger and preserve
137
+ the complete original error.
118
138
 
119
139
  ## Existing browser AI interface
120
140
 
@@ -78,7 +78,7 @@ version; WebKitGTK availability must not be generalized to macOS.
78
78
  | Read host identity, capabilities, storage, preferences, appearance, or platform state | `globalThis.Arcane` | **Cross-host** where the method is implemented and admitted | Promise behavior and `Arcane.Error` are normalized. Result fields are normalized unless the method explicitly documents a platform-dependent snapshot. |
79
79
  | Use local AI without coupling app code to Ollama HTTP | `Arcane.localAI`, `Arcane.ai`, or `/arcane/modules/Ollama.js` | Primarily **Native**; Android exposes a narrower admitted inference projection | Admission, errors, and managed-operation events are normalized. Direct Ollama response envelopes remain **Provider-native**. |
80
80
  | Use TWiN Cloud from the renderer profile | `/arcane/modules/AI.js` | **Cloud** from an allowed browser/native renderer | High-level chat behavior is normalized by the module. The TWiN access key authenticates remote LLM chat; raw provider diagnostics remain provider-specific. No automatic cloud fallback is inferred from local failure. |
81
- | Send one TWiN Cloud request with an explicit key and model | `fetchRequest` from `arcane-os/ai/twin-cloud` | **Node** and **Browser**, using standard Fetch and a remote HTTPS provider | Keeps complete messages and returns the full parsed provider JSON. Shared structured-output mapping, overload-only HTTP 429 retry after 3000 ms, and cancellation match browser TWiN transport. No browser profile, AI/user singleton, DBOPFS, or retained request history is created. |
81
+ | Send one TWiN Cloud request with an explicit key and model | `fetchRequest` from `arcane-os/ai/twin-cloud` | **Node** and **Browser**, using standard Fetch and a remote HTTPS provider | Keeps complete messages and returns the full parsed provider JSON. Rejected Fetch and HTTP 529 share three retries after 3000 ms; HTTP 429 overload retains unlimited three-second retries. Optional `onRetry` reports waiting/requesting phases without blocking recovery. Shared structured-output mapping and cancellation match browser TWiN transport. No browser profile, AI/user singleton, DBOPFS, or retained request history is created. |
82
82
  | Use speech through one application helper | `/arcane/modules/AI.js` and `Arcane.speech` | **Browser** or **Native** | The helper keeps audio on device: Whisper owns STT and Kokoro owns TTS. It automatically cleans only the outbound speech-input copy and normalizes application-facing audio/text behavior while browser and native request/response plumbing differs below that boundary. |
83
83
  | Inspect or manage raw Ollama models | `Arcane.ollama` or `/arcane/modules/Ollama.js` | **Native** desktop Core for management; narrower Android inference only | Wrapper method names, errors, streaming correlation, and admission are Arcane-controlled. Direct Ollama success envelopes are intentionally provider-native. |
84
84
  | Use native terminal, installation, user, provisioning, or machine controls | matching `Arcane.*` namespace | **Native** and app/capability restricted | Calls and errors use the common bridge contract. Platform results can be host-specific and are marked in the method guide. |
@@ -339,8 +339,14 @@ suppressed.
339
339
  module or an existing compatible session, restores the UI transcript when
340
340
  available, and uses provider-safe history as its restoration fallback. The
341
341
  transcript is a masked vertical scroll viewport; status and composer remain
342
- outside it. Initial restoration and every user, assistant, tool, streaming,
343
- progress, or failure mutation scrolls that viewport to its true bottom. Each
342
+ outside it. Initial restoration follows the true bottom. User, assistant, tool,
343
+ streaming, progress, and failure updates continue following it, including later
344
+ message-card, status, composer, or viewport resizing, until the reader scrolls
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
348
+ clamping, hidden views, and BFCache restoration preserve that choice;
349
+ destruction releases scroll observation. Each
344
350
  restored or new message uses a separate semantic `<time>` element at the card's
345
351
  lower-right with an ISO `datetime`, full local title, and local 24-hour `HH:MM`
346
352
  text.
@@ -475,10 +481,13 @@ terminal result of the segments submitted by one invocation; Chat continues
475
481
  to feed chunks without waiting for playback. Runtime mute, cancellation,
476
482
  permission waiting, and stale generations remain non-errors.
477
483
 
478
- Each visible model chunk is forwarded to `AI.streamTTS(text)` in arrival
479
- order. The AI owner automatically removes repeated formatting marks from an
480
- outbound speech-input copy before the
481
- configured segmentation, admits completed segments to bounded synthesis
484
+ Each visible model chunk, including the first chunk that creates its assistant
485
+ card, renders once in arrival order. When speech is ready and unmuted, each
486
+ chunk also reaches `AI.streamTTS(text)` once. Card creation is synchronous, so
487
+ consecutive chunks retain their original order without waiting for speech
488
+ preparation or playback. Thinking content remains outside speech forwarding.
489
+ The AI owner automatically removes repeated formatting marks from an outbound
490
+ speech-input copy before the configured segmentation, admits completed segments to bounded synthesis
482
491
  immediately, and schedules the contiguous ready audio prefix on the audio
483
492
  clock. Chat retains the original Markdown for display and storage and owns no
484
493
  second speech queue. No app option selects or disables this cleanup. Single
@@ -988,6 +997,16 @@ to `true`; `destroy()` releases the component. Product-specific conditions for
988
997
  closing a modal belong to the application. Content, actions and task results
989
998
  are preserved, and `runTasks()` continues to execute independent jobs concurrently.
990
999
 
1000
+ `runTasks(title, jobs)` calls each `job.task(reportProgress)` concurrently.
1001
+ The optional callback accepts complete plain-text status for that pending job
1002
+ only and returns `true` when displayed. For example, a request can report its
1003
+ retry delay without replacing another job's progress. Reports after that job
1004
+ settles or the modal is destroyed return `false` and change no UI. A display
1005
+ failure is logged without rejecting the task. Existing zero-argument task
1006
+ functions remain supported, results retain `Promise.allSettled` order, and
1007
+ destruction does not cancel the application-owned work. Progress is transient
1008
+ display content, never a task result or conversation record.
1009
+
991
1010
  Events: `modal-ready`, `modal-opened`, `modal-closed`, `modal-action`.
992
1011
 
993
1012
  Slots: `header`, `body`, `footer`.
@@ -166,7 +166,8 @@ provider implementations; neither form is a retired compatibility API.
166
166
  Built-in cloud chat decodes an HTTP error body once as JSON or text and rejects
167
167
  with that complete value unchanged. It does not reconstruct an Error, replace
168
168
  the message, or add `providerMessage`, `status`, or an SDK failure code to the
169
- provider body. Network and decoding errors also pass through; cancellation
169
+ provider body. Network errors pass through after the retry policy below;
170
+ decoding errors pass through immediately. Cancellation
170
171
  retains the existing `ARCANE_AI_REQUEST_ABORTED` contract.
171
172
 
172
173
  When the HTTP status is `429` and the existing `error.message`, `message`, or
@@ -178,8 +179,26 @@ the same destination, headers, complete serialized body, and cancellation
178
179
  signal; `onRequest` runs once for the logical request. Cancellation stops the
179
180
  delay and prevents another attempt. Retrying happens before a successful
180
181
  response is consumed, so partial streams and tool callbacks are never replayed.
181
- Native Ollama and externally supplied provider adapters retain their own
182
- transport behavior.
182
+ Rejected Fetch calls and HTTP `529` share a separate budget of three retries,
183
+ each after the same `3000` millisecond delay. HTTP `429` overload retries do not
184
+ consume that budget. The final complete network error or provider body passes
185
+ through unchanged when recovery is exhausted. Aborts, other HTTP errors,
186
+ successful-response body reads, stream decoding, and application callbacks
187
+ never start another attempt.
188
+
189
+ `fetchRequest()` and `streamRequest()` accept
190
+ `onRetry({phase,attempt,delayMs,status,error})`. The observer receives
191
+ `phase:'waiting'` before the abortable delay and `phase:'requesting'` immediately
192
+ before the next Fetch. `attempt` is the one-based upcoming retry number across
193
+ both retry policies; `delayMs` is `3000`; `status` is the HTTP status or `null`
194
+ for rejected Fetch; `error` is the complete original failure value. The SDK
195
+ invokes this observational callback synchronously and observes a returned
196
+ promise without awaiting it. Callback throws and rejections are logged through
197
+ the shared console owner without changing request success or causing retries.
198
+ The callback remains outside model payloads and is forwarded through built-in
199
+ provider controls, including queued requests, with the request owner's existing
200
+ cancellation lifetime. Native Ollama and externally supplied provider adapters
201
+ retain their own transport behavior.
183
202
 
184
203
  Initialization uses the canonical realm user's actual readiness state. If
185
204
  `window.user?.ready` is already true, AI initializes immediately. Otherwise one
@@ -472,8 +491,10 @@ cleanup errors remain visible in developer-console diagnostics.
472
491
 
473
492
  Request observers receive
474
493
  `onRequest(request,id,metadata)` and any transport metadata supplied by the
475
- selected route is forwarded unchanged. Every async native, HTTP, provider, and
476
- built-in callback is observed before the next callback or terminal settlement.
494
+ selected route is forwarded unchanged. Async request, response, content, and
495
+ tool callbacks are observed before the next callback or terminal settlement.
496
+ The observational `onRetry` callback has the separate nonblocking behavior
497
+ described above.
477
498
 
478
499
  Native Ollama responses are adapted before the shared structural validator:
479
500
  provider-native calls may omit `id` and `type` or provide object arguments, so
@@ -914,6 +935,12 @@ caller's payload and request records remain unchanged. The optional
914
935
  boundary has already cleaned the copy. Applications omit that argument. LLM
915
936
  and STT payloads are unaffected.
916
937
 
938
+ SDK-owned LLM delegation also carries `onRetry` in the separate `preparation`
939
+ control record, alongside `observeToolText` for streams. The runtime preserves
940
+ these controls across its request queue and passes them to the selected
941
+ provider without inserting callbacks in the provider payload. Applications use
942
+ the `AI.fetchRequest()` and `AI.streamRequest()` options described above.
943
+
917
944
  `register()` returns the provider's single unregister closure; caller-
918
945
  registered providers remain caller-owned. The high-level
919
946
  `AI.configureBrowserSpeech()` boundary is different: AI constructs, registers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.33.4",
3
+ "version": "0.33.6",
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-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/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",
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",
@@ -1083,8 +1083,93 @@
1083
1083
  &&is.function(session.send);
1084
1084
  }
1085
1085
 
1086
- function scrollTranscriptToBottom(){
1087
- chatOutput.scrollTop=chatOutput.scrollHeight;
1086
+ let transcriptFollowing = true;
1087
+ let transcriptScrollTop = chatOutput.scrollTop;
1088
+ let transcriptWasHidden = chatOutput.clientHeight === 0;
1089
+ const transcriptResizeObserver = is.function(globalThis.ResizeObserver)
1090
+ ? new ResizeObserver(scrollTranscriptToBottom)
1091
+ : null;
1092
+ const transcriptMutationObserver = is.function(globalThis.MutationObserver)
1093
+ ? new MutationObserver(handleTranscriptChildren)
1094
+ : null;
1095
+
1096
+ chatOutput.addEventListener(
1097
+ 'scroll',
1098
+ trackTranscriptScrollPosition,
1099
+ {
1100
+ passive: true,
1101
+ signal: aiRuntimeStateAbortController.signal
1102
+ }
1103
+ );
1104
+ transcriptResizeObserver?.observe(
1105
+ chatOutput,
1106
+ {box: 'border-box'}
1107
+ );
1108
+ for(const item of chatOutput.children) {
1109
+ if(item.localName === 'li') {
1110
+ transcriptResizeObserver?.observe(
1111
+ item,
1112
+ {box: 'border-box'}
1113
+ );
1114
+ }
1115
+ }
1116
+ transcriptMutationObserver?.observe(
1117
+ chatOutput,
1118
+ {childList: true}
1119
+ );
1120
+
1121
+ function trackTranscriptScrollPosition() {
1122
+ if(destroyed) return false;
1123
+ if(chatOutput.clientHeight === 0) {
1124
+ transcriptWasHidden = true;
1125
+ return false;
1126
+ }
1127
+ const scrollTop = chatOutput.scrollTop;
1128
+ if(transcriptWasHidden) {
1129
+ transcriptWasHidden = false;
1130
+ transcriptScrollTop = scrollTop;
1131
+ return true;
1132
+ }
1133
+ const atBottom = chatOutput.scrollHeight - chatOutput.clientHeight - scrollTop <= 1;
1134
+ // A layout clamp reaching the bottom does not resume a reader's following.
1135
+ if(scrollTop < transcriptScrollTop && !atBottom) {
1136
+ transcriptFollowing = false;
1137
+ } else if(scrollTop > transcriptScrollTop && atBottom) {
1138
+ transcriptFollowing = true;
1139
+ }
1140
+ transcriptScrollTop = scrollTop;
1141
+ return true;
1142
+ }
1143
+
1144
+ function handleTranscriptChildren(records) {
1145
+ if(destroyed) return;
1146
+ for(const record of records) {
1147
+ for(const item of record.removedNodes) {
1148
+ if(item.localName === 'li' && item.parentElement !== chatOutput) {
1149
+ transcriptResizeObserver?.unobserve(item);
1150
+ }
1151
+ }
1152
+ for(const item of record.addedNodes) {
1153
+ if(item.localName === 'li' && item.parentElement === chatOutput) {
1154
+ transcriptResizeObserver?.observe(
1155
+ item,
1156
+ {box: 'border-box'}
1157
+ );
1158
+ }
1159
+ }
1160
+ }
1161
+ scrollTranscriptToBottom();
1162
+ }
1163
+
1164
+ function scrollTranscriptToBottom(resumeFollowing = false) {
1165
+ // Read movement before writing: a reader's scroll event may still be queued.
1166
+ const visible = trackTranscriptScrollPosition();
1167
+ if(resumeFollowing === true && !destroyed) {
1168
+ transcriptFollowing = true;
1169
+ }
1170
+ if(!visible || !transcriptFollowing) return false;
1171
+ chatOutput.scrollTop = chatOutput.scrollHeight;
1172
+ transcriptScrollTop = chatOutput.scrollTop;
1088
1173
  return true;
1089
1174
  }
1090
1175
 
@@ -1513,6 +1598,8 @@
1513
1598
  actionable?[...restoredPendingToolCalls.values()]:[]
1514
1599
  );
1515
1600
  chatOutput.replaceChildren(fragment);
1601
+ transcriptFollowing = true;
1602
+ transcriptScrollTop = chatOutput.scrollTop;
1516
1603
  scrollTranscriptToBottom();
1517
1604
  return rendered;
1518
1605
  }
@@ -2014,6 +2101,10 @@
2014
2101
  ? selectedRole.localOnly === false
2015
2102
  || (selectedRole.state === 'ready' && selectedRole.loaded)
2016
2103
  : availability.llm;
2104
+ if(!conversationVisible && !host.conversationComplete) {
2105
+ trackTranscriptScrollPosition();
2106
+ transcriptWasHidden = true;
2107
+ }
2017
2108
  chatArea.dataset.modelStartRequired = String(!conversationVisible);
2018
2109
  send.disabled=host.conversationComplete
2019
2110
  ||!availability.llm
@@ -2835,21 +2926,15 @@
2835
2926
  return false;
2836
2927
  }
2837
2928
 
2838
- let message=[...chatOutput.children].find(
2839
- candidate=>candidate.id===`message-${id}`
2840
- )||null;
2841
-
2842
- if(!message){
2843
- await receivedMessage(host.aiName,isThinking?'':text,id);
2844
- if(!isThinking){
2845
- return false;
2846
- }
2847
- message=[...chatOutput.children].find(
2848
- candidate=>candidate.id===`message-${id}`
2849
- )||null;
2850
- if(!message){
2851
- return false;
2929
+ let message = [...chatOutput.children].find(
2930
+ function findStreamedMessage(candidate) {
2931
+ return candidate.id === `message-${id}`;
2852
2932
  }
2933
+ ) || null;
2934
+
2935
+ if(!message) {
2936
+ // The first chunk uses the same display and speech path as later chunks.
2937
+ message = receivedMessage(host.aiName, '', id);
2853
2938
  }
2854
2939
 
2855
2940
  let target=message.querySelector('.markdown');
@@ -3060,8 +3145,12 @@
3060
3145
  }
3061
3146
 
3062
3147
  // Reset height to shrink to content size
3148
+ trackTranscriptScrollPosition();
3063
3149
  textArea.style.height = 'auto';
3064
3150
  textArea.style.height = `${textArea.scrollHeight}px`;
3151
+ // The intermediate auto height can clamp scrollTop before final layout.
3152
+ transcriptScrollTop = chatOutput.scrollTop;
3153
+ scrollTranscriptToBottom();
3065
3154
  }
3066
3155
 
3067
3156
  function renderSessionMessageFailure(messageId,error){
@@ -3164,7 +3253,7 @@
3164
3253
  applyAIAvailability(host.aiAvailability);
3165
3254
  setSessionStatus('sending','AI is responding…');
3166
3255
  try{
3167
- await receivedMessage(host.aiName,'',messageId);
3256
+ receivedMessage(host.aiName, '', messageId);
3168
3257
  await streamMessage('',messageId,true);
3169
3258
  setMessageProgress(
3170
3259
  messageId,
@@ -3746,7 +3835,7 @@
3746
3835
  const visibleMessage=appendTranscriptMessage('user',text,host.name);
3747
3836
  visibleMessage.dataset.operationId=operationId;
3748
3837
  }
3749
- scrollTranscriptToBottom();
3838
+ scrollTranscriptToBottom(!eventContext.synthetic);
3750
3839
 
3751
3840
  if(host.aiAvailability.tts&&speech.muted===false){
3752
3841
  const aiRuntime=boundChatAI??globalThis.ai;
@@ -3775,7 +3864,7 @@
3775
3864
  }
3776
3865
  }
3777
3866
 
3778
- async function receivedMessage(name = 'Assistant', text = '', id=''){
3867
+ function receivedMessage(name = 'Assistant', text = '', id = '') {
3779
3868
  const message=appendTranscriptMessage('assistant',text,name);
3780
3869
  if(id){
3781
3870
  message.id=`message-${id}`;
@@ -3854,6 +3943,8 @@
3854
3943
  sessionBindingGeneration+=1;
3855
3944
  setSessionStatus('destroyed','Chat closed.');
3856
3945
  destroyed=true;
3946
+ transcriptResizeObserver?.disconnect();
3947
+ transcriptMutationObserver?.disconnect();
3857
3948
  const destroyReason=createChatSubmissionAbort(
3858
3949
  chatReasons.componentDestroyed,
3859
3950
  'The chat component was destroyed before message submission settled.'
@@ -421,16 +421,38 @@
421
421
  await open();
422
422
 
423
423
  activeRun=Promise.allSettled(tasks.map(async function runProgressTask(job,index){
424
+ let settled = false;
425
+ function displayTaskProgress(message, state) {
426
+ if (disposed) {
427
+ return false;
428
+ }
429
+ try {
430
+ if (state) {
431
+ rows[index].row.dataset.status = state;
432
+ }
433
+ rows[index].status.innerText = message;
434
+ return true;
435
+ } catch (error) {
436
+ arcaneLogging.error(`Unable to display progress for ${job.name}:`, error);
437
+ return false;
438
+ }
439
+ }
440
+ function reportProgress(message) {
441
+ if (settled || !is.string(message)) {
442
+ return false;
443
+ }
444
+ return displayTaskProgress(message);
445
+ }
424
446
  try{
425
- const result=await job.task();
426
- rows[index].row.dataset.status='complete';
427
- rows[index].status.innerText='Complete';
447
+ const result=await job.task(reportProgress);
448
+ displayTaskProgress('Complete', 'complete');
428
449
  return result;
429
450
  }catch(error){
430
- rows[index].row.dataset.status='failed';
431
- rows[index].status.innerText='Unable to complete';
451
+ displayTaskProgress('Unable to complete', 'failed');
432
452
  arcaneLogging.error(`Unable to complete ${job.name}:`,error);
433
453
  throw error;
454
+ } finally {
455
+ settled = true;
434
456
  }
435
457
  }));
436
458
 
@@ -439,6 +461,9 @@
439
461
  return result.status==='rejected';
440
462
  });
441
463
  running=false;
464
+ if (disposed) {
465
+ return results;
466
+ }
442
467
  updateCloseButton();
443
468
 
444
469
  if(failed){
@@ -1484,7 +1484,8 @@ class AI {
1484
1484
  return Promise.resolve(
1485
1485
  runtime.#requestBuiltInLLMChat(
1486
1486
  context.payload,
1487
- context.signal
1487
+ context.signal,
1488
+ controls.onRetry
1488
1489
  )
1489
1490
  ).finally(releaseBuiltInLLMRequest);
1490
1491
  }
@@ -1494,7 +1495,8 @@ class AI {
1494
1495
  return runtime.#requestBuiltInLLMStream(
1495
1496
  context.payload,
1496
1497
  bridge,
1497
- controls.observeToolText
1498
+ controls.observeToolText,
1499
+ controls.onRetry
1498
1500
  );
1499
1501
  },
1500
1502
  context.signal
@@ -4154,7 +4156,7 @@ class AI {
4154
4156
  return is.string(content)?content:completion;
4155
4157
  }
4156
4158
 
4157
- #requestBuiltInLLMChat(payload={},signal=null){
4159
+ #requestBuiltInLLMChat(payload = {}, signal = null, onRetry = null) {
4158
4160
  const parallelToolCalls=payload.parallelToolCalls!==undefined
4159
4161
  ?payload.parallelToolCalls
4160
4162
  :payload.parallel_tool_calls;
@@ -4168,11 +4170,12 @@ class AI {
4168
4170
  payload.id??Date.now(),
4169
4171
  function ignoreBuiltInLLMProviderRequest(){},
4170
4172
  signal,
4171
- payload.reasoningEffort
4173
+ payload.reasoningEffort,
4174
+ onRetry
4172
4175
  );
4173
4176
  }
4174
4177
 
4175
- #requestBuiltInLLMStream(payload={},bridge,observeToolText=null){
4178
+ #requestBuiltInLLMStream(payload = {}, bridge, observeToolText = null, onRetry = null) {
4176
4179
  const parallelToolCalls=payload.parallelToolCalls!==undefined
4177
4180
  ?payload.parallelToolCalls
4178
4181
  :payload.parallel_tool_calls;
@@ -4198,7 +4201,8 @@ class AI {
4198
4201
  emitBuiltInLLMStreamData,
4199
4202
  function ignoreBuiltInLLMStreamResult(){},
4200
4203
  payload.reasoningEffort,
4201
- observeToolText
4204
+ observeToolText,
4205
+ onRetry
4202
4206
  );
4203
4207
  }
4204
4208
 
@@ -4357,6 +4361,7 @@ class AI {
4357
4361
  toolChoice='auto',
4358
4362
  onToolCall=function ignoreEarlyFunction(){},
4359
4363
  onRequest=function ignoreStreamRequest(){},
4364
+ onRetry = null,
4360
4365
  parallelToolCalls,
4361
4366
  id=Date.now(),
4362
4367
  seeThinking=false,
@@ -4438,7 +4443,7 @@ class AI {
4438
4443
  localOnly,
4439
4444
  signal
4440
4445
  },
4441
- {observeToolText}
4446
+ {observeToolText, onRetry}
4442
4447
  );
4443
4448
  for await(const chunk of handle){
4444
4449
  if(signal?.aborted){
@@ -4523,7 +4528,8 @@ class AI {
4523
4528
  onDataChunk,
4524
4529
  onDataResult,
4525
4530
  normalizedReasoningEffort,
4526
- observeToolText
4531
+ observeToolText,
4532
+ onRetry
4527
4533
  );
4528
4534
  const structuralToolCalls=normalizeAICompletionToolCalls(
4529
4535
  completion
@@ -4631,7 +4637,8 @@ class AI {
4631
4637
  dataChunkHandler=function ignoreBuiltInStreamDataChunk(){},
4632
4638
  dataResultHandler=function ignoreBuiltInStreamDataResult(){},
4633
4639
  reasoningEffort,
4634
- observeToolText=null
4640
+ observeToolText=null,
4641
+ onRetry = null
4635
4642
  ){
4636
4643
  let speechTurnCompleted=false;
4637
4644
 
@@ -4863,7 +4870,8 @@ class AI {
4863
4870
  headers:this.#serviceHeaders[this.llmService],
4864
4871
  body,
4865
4872
  ...(signal?{signal}:{})
4866
- }
4873
+ },
4874
+ {onRetry}
4867
4875
  );
4868
4876
 
4869
4877
  let sseBuffer='';
@@ -5357,6 +5365,7 @@ class AI {
5357
5365
  signal=null,
5358
5366
  onRequest=function ignoreFetchRequest(){},
5359
5367
  onResponse=function ignoreFetchResponse(){},
5368
+ onRetry = null,
5360
5369
  maxOutputTokens,
5361
5370
  maxTokens,
5362
5371
  temperature,
@@ -5420,7 +5429,8 @@ class AI {
5420
5429
  payload:request,
5421
5430
  localOnly,
5422
5431
  signal
5423
- }
5432
+ },
5433
+ {onRetry}
5424
5434
  );
5425
5435
  if(signal?.aborted){
5426
5436
  throw normalizeAIRequestAbort();
@@ -5443,7 +5453,8 @@ class AI {
5443
5453
  id,
5444
5454
  onRequest,
5445
5455
  signal,
5446
- normalizedReasoningEffort
5456
+ normalizedReasoningEffort,
5457
+ onRetry
5447
5458
  );
5448
5459
  }
5449
5460
 
@@ -5496,7 +5507,8 @@ class AI {
5496
5507
  id=Date.now(),
5497
5508
  requestHandler=function ignoreFetchRequest(){},
5498
5509
  signal=null,
5499
- reasoningEffort
5510
+ reasoningEffort,
5511
+ onRetry = null
5500
5512
  ){
5501
5513
  validateAIStructuralRequest(messages,tools,parallel_tool_calls);
5502
5514
  this.#assertServiceConfigured(this.llmService);
@@ -5613,7 +5625,8 @@ class AI {
5613
5625
  headers:this.#serviceHeaders[this.llmService],
5614
5626
  body,
5615
5627
  ...(signal?{signal}:{})
5616
- }
5628
+ },
5629
+ {onRetry}
5617
5630
  );
5618
5631
 
5619
5632
  if(signal?.aborted){
@@ -3347,14 +3347,14 @@ export class AIProviderRuntime {
3347
3347
  return this.#disposeAllPromise;
3348
3348
  }
3349
3349
 
3350
- request(role, options = {}, {speechInputPrepared = false, observeToolText = null} = {}) {
3350
+ request(role, options = {}, {speechInputPrepared = false, observeToolText = null, onRetry = null} = {}) {
3351
3351
  if (SPEECH_ROLES.includes(role)) {
3352
3352
  arcaneLogging.debug('[Arcane speech runtime] request', role, options);
3353
3353
  }
3354
- return this.#requestRole(role, options, false, speechInputPrepared, observeToolText);
3354
+ return this.#requestRole(role, options, false, speechInputPrepared, observeToolText, onRetry);
3355
3355
  }
3356
3356
 
3357
- #requestRole(role, options, queued, speechInputPrepared = false, observeToolText = null) {
3357
+ #requestRole(role, options, queued, speechInputPrepared = false, observeToolText = null, onRetry = null) {
3358
3358
  this.#assertOpen();
3359
3359
  this.#assertNotConfiguring();
3360
3360
  assertRole(role);
@@ -3486,7 +3486,7 @@ export class AIProviderRuntime {
3486
3486
  }
3487
3487
  if ((!queued && slot.requestQueue.length)
3488
3488
  || slot.activeRequests.size >= maxConcurrentRequests) {
3489
- return this.#enqueueRoleRequest(slot, options, observeToolText);
3489
+ return this.#enqueueRoleRequest(slot, options, observeToolText, onRetry);
3490
3490
  }
3491
3491
  if (!slot.ready
3492
3492
  || providerStatus.state !== 'ready'
@@ -3808,7 +3808,7 @@ export class AIProviderRuntime {
3808
3808
  payload: options.payload,
3809
3809
  signal: controller.signal
3810
3810
  },
3811
- {observeToolText: observeCurrentToolText}
3811
+ {observeToolText: observeCurrentToolText, onRetry}
3812
3812
  );
3813
3813
  }
3814
3814
  );
@@ -4039,7 +4039,12 @@ export class AIProviderRuntime {
4039
4039
  }
4040
4040
  const result = role === 'tts'
4041
4041
  ? await provider.request(providerRequest, {speechInputPrepared: true})
4042
- : await provider.request(providerRequest);
4042
+ : role === 'llm'
4043
+ ? await provider.request(
4044
+ providerRequest,
4045
+ {onRetry}
4046
+ )
4047
+ : await provider.request(providerRequest);
4043
4048
  if (SPEECH_ROLES.includes(role)) {
4044
4049
  arcaneLogging.debug('[Arcane speech runtime] provider.request.result', {role, generation, requestSequence, operationId}, result);
4045
4050
  }
@@ -4222,7 +4227,7 @@ export class AIProviderRuntime {
4222
4227
  );
4223
4228
  }
4224
4229
 
4225
- #enqueueRoleRequest(slot, options, observeToolText = null) {
4230
+ #enqueueRoleRequest(slot, options, observeToolText = null, onRetry = null) {
4226
4231
  if (SPEECH_ROLES.includes(slot.role)) {
4227
4232
  arcaneLogging.debug('[Arcane speech runtime] queue.enqueue', {
4228
4233
  role: slot.role,
@@ -4237,6 +4242,7 @@ export class AIProviderRuntime {
4237
4242
  const entry = {
4238
4243
  options,
4239
4244
  observeToolText,
4245
+ onRetry,
4240
4246
  resolve,
4241
4247
  reject,
4242
4248
  detachSignal: null
@@ -4309,7 +4315,7 @@ export class AIProviderRuntime {
4309
4315
  let operation;
4310
4316
  try {
4311
4317
  operation = runtime.#requestRole(
4312
- slot.role, entry.options, true, false, entry.observeToolText
4318
+ slot.role, entry.options, true, false, entry.observeToolText, entry.onRetry
4313
4319
  );
4314
4320
  } catch (error) {
4315
4321
  if (SPEECH_ROLES.includes(slot.role)) {