arcane-os 0.90.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.91.0
4
+
5
+ - Persist complete native `modelAssets.prepare()` acquisitions across release,
6
+ unload, service disposal and process restart. Reuse the complete ordered
7
+ source selection from its selected store, with independent caller lifetimes,
8
+ cancellation and progress. Explicit `refresh: true` acquires a new complete
9
+ set while preserving earlier completed acquisitions and retained uses.
10
+ - Add `packageApp({browserDecisions: true})` to acquire the upstream browser
11
+ decision distribution inside the package stage beside the actual selected
12
+ SDK module, including installed aliases and relocated routes. Both module
13
+ formats retain complete executables and licenses; default and dry-run
14
+ behavior stay unchanged. Existing workspace materialization remains supported.
15
+ - Reuse the retained Windows host, Whisper and ONNX runtime archives; this
16
+ release changes shared JavaScript acquisition and packaging, not those native
17
+ binaries. Application adoption and native model acceptance remain separately
18
+ owned.
19
+
3
20
  ## 0.90.0
4
21
 
5
22
  - Add optional `remoteBase` to repository workspaces, Git text snapshots and
package/README.md CHANGED
@@ -25,7 +25,7 @@ selected SDK projection or an explicit live SDK source mount. Both profiles
25
25
  share the theme, packaging, event, cancellation,
26
26
  and browser run contracts while retaining their selected application layout.
27
27
 
28
- This checkout defines the `0.88.1` SDK contract. Development applications declare
28
+ This checkout defines the `0.91.0` SDK contract. Development applications declare
29
29
  `arcane-os` as `latest` and retain their lockfile. Exact SDK pins begin when Roshi
30
30
  designates a production build. Follow [Consumer updates](docs/consumer-updates.md)
31
31
  to refresh and compare the installed SDK and lockfile with the published channel;
@@ -95,14 +95,18 @@ pipeline. Existing Wllama, cloud, speech and chat APIs are unchanged.
95
95
 
96
96
  ## Extension-local executables (Manifest V3)
97
97
 
98
- During the application's ordinary SDK materialization, opt into the upstream
99
- browser distribution through the public Node API:
98
+ When packaging the application, opt into the upstream browser distribution
99
+ through the public Node API:
100
100
 
101
101
  ```js
102
- import {materializeInstalledSdkRuntime} from 'arcane-os';
102
+ import {packageApp} from 'arcane-os';
103
103
 
104
- await materializeInstalledSdkRuntime({
104
+ const release = await packageApp({
105
105
  workspaceRoot: process.cwd(),
106
+ appId: 'moon-dashboard',
107
+ outputDirectory: 'dist/extension/app',
108
+ moduleFormat: 'native',
109
+ browserPwa: false,
106
110
  browserDecisions: true
107
111
  });
108
112
  ```
@@ -111,30 +115,50 @@ This acquires the official bundled Transformers.js `4.3.0`
111
115
  `transformers.min.js`, matching ONNX Runtime
112
116
  `1.31.0-dev.20260914-8d85527a0` `ort-wasm-simd-threaded.asyncify.mjs` and
113
117
  `ort-wasm-simd-threaded.asyncify.wasm`, plus `TRANSFORMERS-LICENSE` and
114
- `ONNX-RUNTIME-LICENSE`. The five complete files are staged under
115
- `arcane/sdk/ai/decisions-runtime/` before replacing the projected runtime.
116
- Retain the supplied licenses with the distribution. No bundler, Node engine
117
- dependency tree, model graph or tokenizer is installed by this option.
118
- Repeat `browserDecisions: true` on each materialization that should retain
119
- this closure; whole-tree replacement removes files absent from a later selection.
120
-
121
- The result's `workspaceRuntime.browserDecisions` contains `directory`,
122
- `transformersVersion`, `onnxRuntimeVersion` and `files`. The lower-level
118
+ `ONNX-RUNTIME-LICENSE`. The five complete files are acquired only into the
119
+ package stage, under `decisions-runtime/` beside the selected SDK
120
+ `ai/browser-decisions.mjs`. Installed-package routes retain their actual
121
+ dependency path, including npm aliases: for example,
122
+ `node_modules/arcane-os/browser-runtime/ai/decisions-runtime/` inside
123
+ `release.outputRoot`. The installed dependency and application source remain
124
+ unchanged; this route creates no workspace-root `arcane/` projection.
125
+
126
+ Acquisition follows SDK module/asset rewriting, preserving complete upstream
127
+ executable content and licenses unchanged. It works with both
128
+ `moduleFormat: 'native'` and the default `'import-map'`. The returned `files`
129
+ and release manifest include the distribution; when browser PWA generation
130
+ is enabled, its ordinary offline include/exclude selection applies too.
131
+ Retain the supplied licenses with the deployment. No bundler, Node engine
132
+ dependency tree, model graph or tokenizer is installed by this option. Select
133
+ `browserDecisions: true` on each package build that should acquire the closure.
134
+ The default `false` and `dryRun: true` perform no optional acquisition; dry runs
135
+ report selected input files rather than downloaded distribution files.
136
+
137
+ Existing physical workspace projections retain
138
+ `materializeInstalledSdkRuntime({workspaceRoot, browserDecisions: true})`.
139
+ That operation stages these files under `arcane/sdk/ai/decisions-runtime/`
140
+ before replacing the projection, returning `directory`, `transformersVersion`,
141
+ `onnxRuntimeVersion` and `files` under `workspaceRuntime.browserDecisions`.
123
142
  `materializeWorkspaceRuntimeContent()` accepts the same option and returns
124
- that record directly as `result.browserDecisions`. Omission performs no
125
- optional network acquisition. The fixed distribution files are acquired
126
- concurrently, once each per selected materialization. Events
143
+ that record as `result.browserDecisions`. Repeat the option on each projection
144
+ refresh that should retain the closure. Installed-package applications use
145
+ `packageApp()` above, because their routes read from the installed dependency
146
+ rather than this separate projection.
147
+
148
+ Both operations share one distribution owner. Its fixed files are acquired
149
+ concurrently, once each per operation. Events
127
150
  `workspace.decisions.started` and `workspace.decisions.progress` expose
128
151
  `completed`/`total` file counts; each progress event also has `file` and `url`.
129
152
  Cancellation and HTTP or observer failure abort/join pending acquisition and
130
- preserve the prior runtime. An HTTP failure retains the complete response as
153
+ preserve the prior package output or physical runtime. An HTTP failure retains
154
+ the complete response as
131
155
  `error.response` (`url`, `status`, `statusText`, `headers`, `content`), including
132
156
  inside `AggregateError.errors` when several operations fail.
133
157
 
134
158
  Load from an extension-owned module script, using the deployed SDK path:
135
159
 
136
160
  ```js
137
- import {createBrowserDecisionModel} from './arcane/sdk/ai/browser-decisions.mjs';
161
+ import {createBrowserDecisionModel} from './node_modules/arcane-os/browser-runtime/ai/browser-decisions.mjs';
138
162
 
139
163
  const decisions = createBrowserDecisionModel({
140
164
  family: 'laya',
@@ -147,6 +171,9 @@ const decisions = createBrowserDecisionModel({
147
171
  await decisions.load();
148
172
  ```
149
173
 
174
+ Use the actual deployed alias path when the dependency has another name;
175
+ physical projections instead use `./arcane/sdk/ai/browser-decisions.mjs`.
176
+
150
177
  For Julia-1, change the family/model to `julia` /
151
178
  `SupersonicLabs/Julia-1-ONNX` and select `dtype: 'fp32'`. Model and tokenizer
152
179
  data remain upstream resources served through the same store, preserving its
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "sdkVersion": "0.89.0",
3
+ "sdkVersion": "0.90.0",
4
4
  "environment": {
5
5
  "runtime": "Node.js for Node entrypoints; browser for browser-only entrypoints",
6
6
  "minimumVersion": "22.23.2 for Node entrypoints",
@@ -441,10 +441,10 @@
441
441
  "entrypoints": ["arcane-os/core/model-assets"],
442
442
  "primaryImport": "arcane-os/core/model-assets",
443
443
  "group": "Core model assets",
444
- "summary": "Owns selected native working-file projections and engine retain handles while original stored model files remain unchanged.",
444
+ "summary": "Owns persistent native model acquisitions, temporary browser projections and actual engine retain lifetimes.",
445
445
  "availability": "Node Core with an application-selected working directory",
446
446
  "protocol": "arcane/1 modelAssets.open/write/complete/release/status and modelAssets.state",
447
- "normalization": "Returns current/prepare/retain/release/dispose and Core service lifecycle. Explicit prepare({id,workingDirectory,members:[{path,url}],signal,onProgress}) streams complete upstream members through the existing file owner and reports file/phase progress. release(id) relinquishes preparation ownership; cancellation and cleanup join fetches/writes. Native retains survive until actual engine release before deleting only the owned working projection. Startup downloads nothing."
447
+ "normalization": "Returns current/prepare/retain/release/dispose and Core service lifecycle. Explicit prepare({id,workingDirectory,members:[{path,url}],signal,onProgress,refresh=false}) reuses complete exact ordered source selections stored beneath workingDirectory/model-assets or streams every selected upstream member concurrently. Complete acquisitions survive release, disposal and restart. refresh:true acquires a separate complete set without overwriting retained originals; ordinary reuse makes no network freshness claim. Matching same-process callers share acquisition with independent callbacks/cancellation, and the final interested cancellation joins unfinished I/O before removing only that attempt. Separate processes may fetch independently. release(id) relinquishes live preparation ownership; actual native retain lifetimes remain. Browser open/write/complete projections retain temporary cleanup after final native use. Startup downloads nothing; no migration, eviction or partial-transfer resumption."
448
448
  },
449
449
  {
450
450
  "id": "core-model-assets-service:default",
@@ -654,7 +654,7 @@
654
654
  "summary": "Explicit-load Laya and Julia-1 decisions with caller-selected precision, complete inputs and graph results.",
655
655
  "availability": "Browser module Workers and the selected Transformers.js backend; import/construction starts no model load",
656
656
  "protocol": "Caller-owned state/question/options rows; five-input decision graph; native Worker request/result lifecycle",
657
- "normalization": "Full field tokenization, raw logits and unrounded softmax; dtype defaults to fp16 for Laya and fp32 for Julia, with explicit values forwarded unchanged. Optional existing SDK store routes actual loader resources through fetchResource outside cloned Worker configuration. By default stored activation also materializes the unchanged runtime entry as a JavaScript object URL, retains its configured URL for later loads and revokes the object URL after Worker termination. Explicit runtime.local:true instead imports same-deployment moduleUrl and wasmPaths.mjs/wasm directly, defaulting to ai/decisions-runtime installed by browserDecisions:true materialization; only those exact executable URLs bypass the model store. It disables upstream executable WASM caching/proxy and selects one WASM thread, preserving the requested inference backend. Stored mode disables competing model caches, preserves complete HTTP responses and joins resource cleanup before outstanding calls settle. Default runtime-entry HTTP failures retain error.response; custom stored entries must support an object URL and changed import.meta.url. Omission retains existing browser behavior; no decision-result persistence or precision fallback"
657
+ "normalization": "Full field tokenization, raw logits and unrounded softmax; dtype defaults to fp16 for Laya and fp32 for Julia, with explicit values forwarded unchanged. Optional existing SDK store routes actual loader resources through fetchResource outside cloned Worker configuration. By default stored activation also materializes the unchanged runtime entry as a JavaScript object URL, retains its configured URL for later loads and revokes the object URL after Worker termination. Explicit runtime.local:true instead imports same-deployment moduleUrl and wasmPaths.mjs/wasm directly, defaulting to adjacent ai/decisions-runtime acquired by packageApp({browserDecisions:true}) for installed-package routes or by the existing physical materializer option; only those exact executable URLs bypass the model store. It disables upstream executable WASM caching/proxy and selects one WASM thread, preserving the requested inference backend. Stored mode disables competing model caches, preserves complete HTTP responses and joins resource cleanup before outstanding calls settle. Default runtime-entry HTTP failures retain error.response; custom stored entries must support an object URL and changed import.meta.url. Omission retains existing browser behavior; no decision-result persistence or precision fallback"
658
658
  },
659
659
  {
660
660
  "id": "root:acceptWebSocket",
@@ -1858,7 +1858,7 @@
1858
1858
  "summary": "Builds one complete app release with browser selection by default. Nonbrowser targets add explicit nativeResources through a late complete-content copy and separate native input output; moduleFormat is independent. Adapters receive actual target and effective browserPwa. Explicit operation outputDirectory wins; staged replacement preserves source and unrelated outputs. No tests or checks run automatically.",
1859
1859
  "availability": "Node",
1860
1860
  "protocol": "SDK packager and deterministic bundle contract",
1861
- "normalization": "Normalized SDK validation with complete canonical archive and release content"
1861
+ "normalization": "Complete selected release content. Optional browserDecisions:true acquires bundled Transformers.js 4.3.0 and matched ONNX Runtime 1.31.0-dev.20260914-8d85527a0 asyncify module/WASM plus licenses beside the selected SDK browser-decisions module in staging only, following installed routes and aliases. Acquisition follows native-module and asset rewriting, preserves complete upstream content, and joins the final release inventory and enabled PWA offline selection. Default false and dryRun perform no optional acquisition. Existing workspace.decisions started/progress events, cancellation, full HTTP errors and joined failure cleanup are preserved; prior output, source and installed dependencies remain unchanged on acquisition failure. Repeat the option per selected package build."
1862
1862
  },
1863
1863
  {
1864
1864
  "id": "root:packageApplication",
@@ -1,4 +1,4 @@
1
- # Native working files from model assets
1
+ # Native model acquisitions and working projections
2
2
 
3
3
  Browser preparation and native retain/release ownership were first published in `arcane-os@0.61.0`. Browser
4
4
  preparation is exported by `arcane-os/ai/core-model-assets`, and the native
@@ -110,29 +110,65 @@ try {
110
110
  }
111
111
  ```
112
112
 
113
- `prepare({id,workingDirectory,members,signal,onProgress})` performs standard
114
- fetches only when explicitly called. Every member has its original relative
115
- `path` and selected `url`; the caller supplies the complete companion-file set.
116
- Independent members download concurrently. Each complete response stream is
117
- written directly through the existing ordered native file owner, without a
118
- whole-model buffer or base64 conversion. No model or runtime is selected or
119
- downloaded by service startup. The result is the ready projection snapshot with
120
- ordered `members: [{path,nativePath}]`.
113
+ `prepare({id,workingDirectory,members,signal,onProgress,refresh=false})` reuses
114
+ a complete native acquisition or fetches the explicitly selected members.
115
+ Every member has its original relative `path` and selected `url`; the caller
116
+ supplies the complete companion-file set. Independent members download
117
+ concurrently. Each complete response stream is written directly to its native
118
+ file, without a whole-model buffer or base64 conversion. No model or runtime
119
+ is selected or downloaded by service startup. The result remains the ready
120
+ projection snapshot with ordered `members: [{path,nativePath}]`.
121
121
 
122
- Progress uses `open`, `download`, `complete` and `ready` phases, with
122
+ Complete acquisitions persist under `workingDirectory/model-assets/` across
123
+ release, native unload, service disposal and process restart. Relative working
124
+ directories resolve from `appRoot`. Reuse matches the entire ordered
125
+ `[{path,url}]` selection in that actual store; the operation `id` is a separate
126
+ live lifetime, not the stored selection. Different URLs, relative paths, member
127
+ order or stores select separate acquisitions. Original member names and content
128
+ stay unchanged, and native engines use those files directly. Model, revision,
129
+ precision and device choices remain with their existing owners.
130
+
131
+ Ordinary preparation performs no network freshness check for a completed
132
+ selection. To acquire a mutable source again, explicitly pass `refresh:true` to
133
+ this native `prepare` method. Refresh fetches a new complete set in its own
134
+ directory and keeps earlier complete acquisitions and retained engine uses
135
+ untouched. Later ordinary preparations reuse the newer completed selection.
136
+ A failed refresh leaves the earlier completed acquisition available. Refresh
137
+ does not change the selected URLs or imply upstream revision discovery.
138
+
139
+ Progress uses `open`, `download`, `complete`, `reuse` and `ready` phases, with
123
140
  `completed`, `total` and `unit:'files'`; member progress additionally includes
124
141
  `memberIndex` and `path`. Progress callbacks may return a promise, which the
125
- preparation observes. They must not await cleanup of the preparation currently
126
- calling them. Callback failures remain preparation failures. HTTP failures
142
+ preparation observes in order. Each caller owns its callbacks; a slow or failed
143
+ callback does not hold another caller's transfer or result. Callbacks must not
144
+ await cleanup of the preparation currently calling them. Callback failures
145
+ reject that caller's preparation. HTTP failures
127
146
  retain `url`, `status` and the complete response text in `response`.
128
147
 
129
148
  `release(id)` relinquishes preparation ownership without a request signal.
130
- For active native downloads, release or disposal aborts their fetches and joins
131
- the transfer tasks and native writes before deleting only their owned child
132
- directory. Preparation cancellation and failure follow that same cleanup path;
133
- the original failures and cleanup failures remain observable. A native retain
134
- handle continues to protect completed files through actual engine release.
135
- Native preparation leaves browser DBOPFS originals and unrelated files alone.
149
+ Same-process callers sharing the actual store, complete source selection and
150
+ refresh mode share an acquisition, including across service instances. They
151
+ keep independent operation IDs, retain handles, cancellation and callbacks.
152
+ Cancelling, releasing or disposing one interested caller leaves the others
153
+ running. The final interested caller aborts the acquisition and joins its
154
+ streams, writes and closes before removing only its unfinished attempt. Original
155
+ failures and cleanup failures remain observable. A completed acquisition stays
156
+ available even if a caller cancels or its callback fails after completion.
157
+
158
+ The service records completion only after every selected response and file
159
+ close finishes. That record is ordinary cache state describing the selected
160
+ sources and completed acquisition. A missing original member requires another
161
+ complete acquisition. Failed attempts are removed after their owned I/O drains;
162
+ there is no partial-transfer resumption. An abruptly interrupted process can
163
+ leave an unfinished directory, which later calls ignore without sweeping it.
164
+ There is no automatic migration, eviction or removal of completed acquisitions.
165
+
166
+ Independent processes use disjoint attempt directories and may fetch the same
167
+ selection concurrently. They do not overwrite each other's files or coordinate
168
+ a cross-process acquisition lock. Concurrent completions can produce multiple
169
+ reusable sets; either complete result may be selected when their completion
170
+ order ties. Applications may retain their existing shared Core owner without
171
+ introducing an additional host or application-local cache copy.
136
172
 
137
173
  ```javascript
138
174
  import {createModelAssetService} from 'arcane-os/core/model-assets';
@@ -158,15 +194,18 @@ release. Multiple contexts can retain one prepared set, and repeated inference
158
194
  on a retained context reuses its files.
159
195
 
160
196
  The browser's `projection.release()` relinquishes preparation ownership. It
161
- does not delete files retained by a native engine. Deletion follows the final
162
- native release and removes only that operation's working directory. Core closes
163
- independent services concurrently, so the model-assets service waits for all
164
- native retain handles during disposal instead of assuming service order.
165
- Owners must release their handles after joining their actual engine lifetime.
197
+ does not delete files retained by a native engine. For browser `open`/`write`/
198
+ `complete` projections, deletion follows the final native release and removes
199
+ only that operation's temporary working directory; DBOPFS originals remain
200
+ untouched. Native `prepare` acquisitions remain stored after their final use.
201
+ Core closes independent services concurrently, so the model-assets service
202
+ waits for all native retain handles during disposal instead of assuming service
203
+ order. Owners must release their handles after joining their actual engine
204
+ lifetime.
166
205
 
167
206
  Closing a browser accessor does not by itself establish native unload.
168
207
  Explicitly release unused prepared projections. Core disposal also releases
169
- preparation ownership and cleans up after retained engine uses end.
208
+ preparation ownership and ends the live records after retained engine uses end.
170
209
 
171
210
  ### Native Laya decisions
172
211
 
@@ -179,10 +179,25 @@ create a fresh projection from the stored originals before loading again.
179
179
  ## Existing upstream preparation
180
180
 
181
181
  `model` selects the upstream Hugging Face repository and `revision` its
182
- revision. Native preparation performs normal upstream fetches only during
183
- explicit `load()`. It streams complete members through the existing working
184
- file owner; it neither vendors the weights nor creates a second persistent
185
- model store. The approved `@huggingface/tokenizers@0.2.0` dependency reads the
182
+ revision. On explicit `load()`, the existing model-assets owner reuses complete
183
+ native acquisitions under the selected `workingDirectory/model-assets/`, or
184
+ fetches the complete selected members concurrently when absent. Complete
185
+ acquisitions survive unload, disposal and application restart. Reuse matches
186
+ the entire ordered original path/URL selection; it never substitutes another
187
+ model, revision, precision or device. Supplied projections and caller-owned
188
+ `paths` retain their existing precedence and lifecycle.
189
+
190
+ Ordinary preparation reuses the selected URLs without checking whether mutable
191
+ upstream content changed. Explicit reacquisition is available through native
192
+ [`modelAssets.prepare({refresh:true,...})`](model-assets.md#native-ownership),
193
+ whose ready projection can be supplied through the existing
194
+ `assetProjectionId`/`resourcePaths` contract. It preserves earlier complete
195
+ acquisitions and active native retains. Incomplete attempts are not reused or
196
+ resumed; separate processes may perform duplicate acquisitions in disjoint
197
+ directories. The shared model-assets owner handles storage without an
198
+ application-local cache copy or a second model store.
199
+
200
+ The approved `@huggingface/tokenizers@0.2.0` dependency reads the
186
201
  selected tokenizer files in its Worker. No Transformers or second ONNX runtime
187
202
  is loaded by this API.
188
203
 
@@ -1044,6 +1044,25 @@ ordinary browser output remain unchanged. See
1044
1044
  [native-module package output](protocols.md#native-module-package-output)
1045
1045
  for resource handling and the static transformation boundary.
1046
1046
 
1047
+ `browserDecisions` defaults to `false`. Explicit `browserDecisions: true`
1048
+ acquires the bundled Transformers.js 4.3.0 entry, matching ONNX Runtime
1049
+ 1.31.0-dev.20260914-8d85527a0 asyncify module/WASM and both licenses into
1050
+ the package stage beside the selected SDK `ai/browser-decisions.mjs`, under
1051
+ `decisions-runtime/`. Installed-package routes retain their configured
1052
+ dependency path, including aliases; source and installed dependencies remain
1053
+ unchanged, with no workspace-root `arcane/` projection. The selected SDK decision
1054
+ module must be included in the package. Acquisition happens after native-module
1055
+ and asset rewriting, preserving upstream content unchanged in both module
1056
+ formats. The distribution joins the returned and release-manifest inventories
1057
+ and any enabled PWA's ordinary offline selection. The default and dry runs
1058
+ perform no optional acquisition; dry runs report selected inputs. Repeat this
1059
+ option on each package build that should acquire the closure. Cancellation and
1060
+ HTTP or observer failures abort/join acquisition before stage cleanup and
1061
+ preserve previous output. The existing `workspace.decisions.started` and
1062
+ `workspace.decisions.progress` events report file progress. See the
1063
+ [extension-local decision guide](ai/browser-decisions.md#extension-local-executables-manifest-v3)
1064
+ for `runtime.local: true`, upstream versions and complete error details.
1065
+
1047
1066
  An operation may override `outputDirectory` without changing the saved app
1048
1067
  configuration. For example, package a complete application beneath an
1049
1068
  application-owned extension manifest and launcher:
@@ -1875,6 +1894,11 @@ module/WASM and license files from their upstream distributions into
1875
1894
  `transformersVersion`, `onnxRuntimeVersion` and `files` under
1876
1895
  `workspaceRuntime.browserDecisions`. The default `false` performs no optional
1877
1896
  acquisition. Select this option on each refresh that should retain those files.
1897
+ For applications with installed-package routes, use
1898
+ `packageApp({workspaceRoot, appId, browserDecisions: true})` to place the
1899
+ distribution beside the actual deployed SDK module. This physical materializer
1900
+ continues to own only the separate `arcane/` projection; it does not populate or
1901
+ modify `node_modules`.
1878
1902
  The [extension-local decision guide](ai/browser-decisions.md#extension-local-executables-manifest-v3)
1879
1903
  describes `runtime.local: true`, complete resource ownership and MV3 usage.
1880
1904
 
@@ -9012,14 +9036,22 @@ directory and complete member files.
9012
9036
 
9013
9037
  `current()` returns `{closing,projections}`. `retain(id)` accepts a completed
9014
9038
  projection and returns `{id,directory,members,release}` for a native owner.
9015
- Native `prepare({id,workingDirectory,members,signal,onProgress})` accepts
9016
- ordered `{path,url}` members and returns their ready projection. Explicit calls
9017
- stream complete upstream responses through the same file owner; startup
9018
- downloads nothing. Independent members transfer concurrently and progress
9019
- reports phases and completed files. `release(id)` relinquishes preparation
9020
- ownership; cancellation, release and disposal join fetches and writes before
9021
- cleanup. Native retain handles keep completed files alive until actual engine
9022
- release. See [native preparation](model-assets.md#native-ownership).
9039
+ Native `prepare({id,workingDirectory,members,signal,onProgress,refresh=false})`
9040
+ accepts ordered `{path,url}` members and returns their ready projection.
9041
+ Complete native acquisitions persist beneath `workingDirectory/model-assets/`
9042
+ through unload, disposal and restart; ordinary calls reuse the exact complete
9043
+ ordered source selection without a network freshness check. Explicit
9044
+ `refresh:true` acquires a separate complete set without replacing active files.
9045
+ Independent members transfer concurrently and progress reports phases and
9046
+ completed files. Matching same-process preparations share acquisition with
9047
+ independent callbacks and cancellation; the last interested cancellation joins
9048
+ unfinished I/O and removes only that attempt. Separate processes may acquire
9049
+ the same selection independently. Startup downloads nothing.
9050
+ `release(id)` relinquishes live preparation ownership. Native retains continue
9051
+ through actual engine release; completed native acquisitions remain stored.
9052
+ Browser `open/write/complete` projections retain their temporary lifetime and
9053
+ are removed only after all native uses end. See
9054
+ [native preparation](model-assets.md#native-ownership).
9023
9055
  `dispose()` releases preparation ownership and joins file writes, closes and
9024
9056
  all native retain lifetimes before cleanup. The native owner releases its
9025
9057
  handle only after its actual context/worker use ends. Original stored model
@@ -10146,7 +10178,9 @@ Cancellation terminates that client's Worker and rejects its in-flight work.
10146
10178
  No decisions are inserted into chat history or durable storage.
10147
10179
 
10148
10180
  For explicitly selected extension-local execution, supply `runtime: {local:
10149
- true}` after materializing with `browserDecisions: true`. The SDK directly
10181
+ true}` after `packageApp({workspaceRoot, appId, browserDecisions: true})` for
10182
+ installed-package routes, or after opting into the existing physical runtime
10183
+ materializer. The SDK directly
10150
10184
  imports its deployment-local `decisions-runtime/transformers.min.js` and
10151
10185
  matched asyncify module/WASM. Explicit `runtime.moduleUrl` and
10152
10186
  `runtime.wasmPaths: {mjs, wasm}` may select other compatible files in that same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.90.0",
3
+ "version": "0.91.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",
@@ -0,0 +1,95 @@
1
+ import Is from 'strong-type';
2
+ import {mkdir, writeFile} from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const is = new Is(false);
6
+
7
+ function throwIfAborted(signal) {
8
+ if (!signal?.aborted) return;
9
+ const error = signal.reason instanceof Error ? signal.reason : new Error('Operation cancelled.');
10
+ error.code = error.code || 'ARCANE_CANCELLED';
11
+ throw error;
12
+ }
13
+
14
+ async function emit(onEvent, event) {
15
+ if (is.function(onEvent)) await onEvent(event);
16
+ }
17
+
18
+ export async function materializeBrowserDecisions(destination, signal, onEvent) {
19
+ const transformers = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0/';
20
+ const onnx = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.31.0-dev.20260914-8d85527a0/';
21
+ const files = [
22
+ {name: 'transformers.min.js', url: `${transformers}dist/transformers.min.js`},
23
+ {name: 'ort-wasm-simd-threaded.asyncify.mjs', url: `${onnx}dist/ort-wasm-simd-threaded.asyncify.mjs`},
24
+ {name: 'ort-wasm-simd-threaded.asyncify.wasm', url: `${onnx}dist/ort-wasm-simd-threaded.asyncify.wasm`},
25
+ {name: 'TRANSFORMERS-LICENSE', url: `${transformers}LICENSE`},
26
+ {name: 'ONNX-RUNTIME-LICENSE', url: `${onnx}LICENSE`}
27
+ ];
28
+ const controller = new AbortController();
29
+ function cancelDistribution() {
30
+ controller.abort(signal.reason);
31
+ }
32
+ signal?.addEventListener('abort', cancelDistribution, {once: true});
33
+ if (signal?.aborted) cancelDistribution();
34
+ let completed = 0;
35
+ try {
36
+ throwIfAborted(controller.signal);
37
+ await emit(
38
+ onEvent,
39
+ {type: 'workspace.decisions.started', completed, total: files.length}
40
+ );
41
+ throwIfAborted(controller.signal);
42
+ await mkdir(destination, {recursive: true});
43
+ async function acquireDistributionFile(file) {
44
+ try {
45
+ throwIfAborted(controller.signal);
46
+ const response = await fetch(file.url, {signal: controller.signal});
47
+ const content = new Uint8Array(await response.arrayBuffer());
48
+ if (!response.ok) {
49
+ const error = new Error(`Browser decision distribution returned HTTP ${response.status} ${response.statusText}.`);
50
+ error.code = 'ARCANE_DECISION_DISTRIBUTION_DOWNLOAD_FAILED';
51
+ error.response = {
52
+ url: response.url,
53
+ status: response.status,
54
+ statusText: response.statusText,
55
+ headers: [...response.headers],
56
+ content
57
+ };
58
+ throw error;
59
+ }
60
+ throwIfAborted(controller.signal);
61
+ // Upstream distribution content is opaque, including its own
62
+ // imports and license text. No SDK source rewriting applies.
63
+ await writeFile(path.join(destination, file.name), content);
64
+ throwIfAborted(controller.signal);
65
+ completed += 1;
66
+ await emit(
67
+ onEvent,
68
+ {type: 'workspace.decisions.progress', file: file.name, url: file.url, completed, total: files.length}
69
+ );
70
+ } catch (error) {
71
+ controller.abort(error);
72
+ throw error;
73
+ }
74
+ }
75
+ // One fixed distribution, acquired once for the selected projection or
76
+ // package; no model/app/architecture multiplier or installation script.
77
+ const outcomes = await Promise.allSettled(files.map(acquireDistributionFile));
78
+ const failures = new Set();
79
+ for (const outcome of outcomes) {
80
+ if (outcome.status === 'rejected') failures.add(outcome.reason);
81
+ }
82
+ if (failures.size === 1) throw [...failures][0];
83
+ if (failures.size > 1) {
84
+ throw new AggregateError([...failures], 'Browser decision distribution acquisition failed.');
85
+ }
86
+ throwIfAborted(controller.signal);
87
+ } finally {
88
+ signal?.removeEventListener('abort', cancelDistribution);
89
+ }
90
+ return {
91
+ transformersVersion: '4.3.0',
92
+ onnxRuntimeVersion: '1.31.0-dev.20260914-8d85527a0',
93
+ files: files.map(function distributionFilename(file) { return file.name; })
94
+ };
95
+ }
@@ -1,16 +1,231 @@
1
- import {mkdir, mkdtemp, open, rm} from 'node:fs/promises';
1
+ import {mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, writeFile} from 'node:fs/promises';
2
2
  import {Buffer} from 'node:buffer';
3
3
  import path from 'node:path';
4
4
  import Is from 'strong-type';
5
5
  import {CoreError, serializeCoreError} from '../../../browser-runtime/core/contracts.mjs';
6
6
 
7
7
  const is = new Is(false);
8
+ const nativeAcquisitions = new Map();
9
+ let nativeAcquisitionOrder = 0;
8
10
 
9
11
  function failure(code, message) {
10
12
  return new CoreError({code, message});
11
13
  }
12
14
 
13
- /** Working files for native engines. Caller-selected source assets stay authoritative. */
15
+ function memberLocation(directory, member) {
16
+ const nativePath = path.resolve(directory, member.path);
17
+ const relative = path.relative(directory, nativePath);
18
+ if (path.isAbsolute(member.path) || !relative || relative === '..'
19
+ || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
20
+ throw new TypeError('Model asset member paths must be relative files within their projection.');
21
+ }
22
+ return {path: member.path, nativePath};
23
+ }
24
+
25
+ function sameMembers(left, right) {
26
+ return is.array(left) && left.length === right.length
27
+ && left.every(function sameMember(member, index) {
28
+ return member.path === right[index].path && member.url === right[index].url;
29
+ });
30
+ }
31
+
32
+ async function storedAcquisition(directory, members, signal) {
33
+ let latest;
34
+ for (const entry of await readdir(directory, {withFileTypes: true})) {
35
+ signal.throwIfAborted();
36
+ if (!entry.isDirectory() || !entry.name.startsWith('acquisition-')) continue;
37
+ const root = path.join(directory, entry.name);
38
+ let selected;
39
+ try { selected = JSON.parse(await readFile(path.join(root, 'acquisition.json'), {encoding: 'utf8', signal})); }
40
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
41
+ if (!sameMembers(selected.members, members)) continue;
42
+ nativeAcquisitionOrder = Math.max(nativeAcquisitionOrder, selected.order);
43
+ const files = path.join(root, 'files');
44
+ const locations = members.map(function originalLocation(member) { return memberLocation(files, member); });
45
+ let available = true;
46
+ for (const member of locations) {
47
+ signal.throwIfAborted();
48
+ try { if (!(await stat(member.nativePath)).isFile()) available = false; }
49
+ catch (error) { if (error.code !== 'ENOENT') throw error; available = false; }
50
+ }
51
+ if (available && (!latest || selected.order > latest.order
52
+ || (selected.order === latest.order && entry.name > latest.name))) {
53
+ latest = {name: entry.name, order: selected.order, directory: files, members: locations};
54
+ }
55
+ }
56
+ return latest ? {directory: latest.directory, members: latest.members} : null;
57
+ }
58
+
59
+ async function acquireNativeMembers(entry) {
60
+ const {directory, members, controller} = entry;
61
+ const {signal} = controller;
62
+ let attempt;
63
+ let completed = 0;
64
+ let committed = false;
65
+ let firstFailure;
66
+ function progress(phase, memberIndex) {
67
+ entry.publish({phase, completed, total: members.length, unit: 'files',
68
+ ...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path})});
69
+ }
70
+ try {
71
+ signal.throwIfAborted();
72
+ const stored = await storedAcquisition(directory, members, signal);
73
+ if (stored && !entry.refresh) {
74
+ entry.location = stored;
75
+ completed = members.length;
76
+ progress('reuse');
77
+ progress('ready');
78
+ return stored;
79
+ }
80
+ signal.throwIfAborted();
81
+ attempt = await mkdtemp(path.join(directory, 'acquisition-'));
82
+ const files = path.join(attempt, 'files');
83
+ await mkdir(files);
84
+ entry.location = {directory: files, members: members.map(function selectedMember(member) { return memberLocation(files, member); })};
85
+ progress('open');
86
+ const results = await Promise.allSettled(members.map(async function downloadMember(member, memberIndex) {
87
+ let handle;
88
+ let response;
89
+ let downloadError;
90
+ const failures = [];
91
+ let bodyOwned = false;
92
+ try {
93
+ signal.throwIfAborted();
94
+ const nativePath = entry.location.members[memberIndex].nativePath;
95
+ await mkdir(path.dirname(nativePath), {recursive: true});
96
+ handle = await open(nativePath, 'wx');
97
+ signal.throwIfAborted();
98
+ progress('download', memberIndex);
99
+ response = await fetch(member.url, {signal});
100
+ if (!response.ok) {
101
+ const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
102
+ error.url = member.url;
103
+ error.status = response.status;
104
+ error.response = await response.text();
105
+ throw error;
106
+ }
107
+ if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
108
+ bodyOwned = true;
109
+ for await (const content of response.body) {
110
+ signal.throwIfAborted();
111
+ await handle.appendFile(content);
112
+ progress('download', memberIndex);
113
+ }
114
+ signal.throwIfAborted();
115
+ } catch (error) {
116
+ downloadError = error;
117
+ failures.push(error);
118
+ firstFailure ??= error;
119
+ controller.abort(error);
120
+ }
121
+ if (response?.body && !bodyOwned && !response.bodyUsed && !response.body.locked) {
122
+ try { await response.body.cancel(downloadError); } catch (error) { if (error !== downloadError) failures.push(error); }
123
+ }
124
+ if (handle) {
125
+ try { await handle.close(); } catch (error) { failures.push(error); }
126
+ }
127
+ if (failures.length) {
128
+ const error = failures.length === 1 ? failures[0] : new AggregateError(failures, 'Downloading a model member and closing its resources failed.');
129
+ firstFailure ??= error;
130
+ controller.abort(error);
131
+ throw error;
132
+ }
133
+ completed += 1;
134
+ progress('download', memberIndex);
135
+ }));
136
+ const failures = results.filter(function rejected(result) { return result.status === 'rejected'; })
137
+ .map(function reason(result) { return result.reason; });
138
+ if (failures.length > 1) throw new AggregateError(failures, 'Downloading model asset members failed.', {cause: firstFailure});
139
+ if (failures.length) throw failures[0];
140
+ signal.throwIfAborted();
141
+ progress('complete');
142
+ // The record describes a completed acquisition, not content identity.
143
+ // Publish it after every stream and file close, leaving older entries alone.
144
+ const pending = path.join(attempt, 'acquisition.pending.json');
145
+ await writeFile(pending, JSON.stringify({members, order: ++nativeAcquisitionOrder}), {encoding: 'utf8', flag: 'wx', signal});
146
+ signal.throwIfAborted();
147
+ await rename(pending, path.join(attempt, 'acquisition.json'));
148
+ committed = true;
149
+ progress('ready');
150
+ return entry.location;
151
+ } catch (error) {
152
+ if (attempt && !committed) {
153
+ try { await rm(attempt, {recursive: true}); }
154
+ catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Preparing native model assets failed and cleanup failed.'); }
155
+ }
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ function shareNativeAcquisition({directory, members, refresh, signal, onProgress}) {
161
+ signal.throwIfAborted();
162
+ const key = JSON.stringify([directory, members, refresh]);
163
+ let entry = nativeAcquisitions.get(key);
164
+ if (entry?.controller.signal.aborted) {
165
+ return entry.task.catch(function observeRetiringAcquisition() {}).then(function acquireAfterDrain() {
166
+ return shareNativeAcquisition({directory, members, refresh, signal, onProgress});
167
+ });
168
+ }
169
+ if (!entry) {
170
+ entry = {directory, members, refresh, controller: new AbortController(), consumers: new Set(), task: null, location: null, progress: null};
171
+ nativeAcquisitions.set(key, entry);
172
+ entry.publish = function publishProgress(progress) {
173
+ entry.progress = progress;
174
+ for (const consumer of entry.consumers) consumer.progress(progress);
175
+ };
176
+ }
177
+ const acquisition = entry;
178
+ return new Promise(function observeAcquisition(resolve, reject) {
179
+ let settled = false;
180
+ let cancelled;
181
+ let observer = Promise.resolve();
182
+ const consumer = {progress};
183
+ function finish(failed, error, location) {
184
+ if (settled) return;
185
+ settled = true;
186
+ signal.removeEventListener('abort', abort);
187
+ acquisition.consumers.delete(consumer);
188
+ if (consumer.cancelled && failed && error !== cancelled) {
189
+ reject(new AggregateError([cancelled, error], 'Native model acquisition cancellation and cleanup failed.', {cause: cancelled}));
190
+ } else if (consumer.cancelled) reject(cancelled);
191
+ else if (failed) reject(error);
192
+ else resolve(location);
193
+ }
194
+ function cancel(error) {
195
+ if (settled || consumer.cancelled) return;
196
+ cancelled = error;
197
+ consumer.cancelled = true;
198
+ const others = [...acquisition.consumers].some(function interested(other) { return other !== consumer && !other.cancelled; });
199
+ if (others) observer.then(function cancelledObserverSettled() { finish(true, error); });
200
+ else acquisition.controller.abort(error);
201
+ }
202
+ function abort() { cancel(signal.reason); }
203
+ function progress(value) {
204
+ const notification = {...value};
205
+ // Each subscriber owns its ordered observers. A slow or failing
206
+ // observer never holds another subscriber's transfer or result.
207
+ observer = observer.then(async function observeProgress() {
208
+ if (!consumer.cancelled && !settled) await onProgress?.(notification, acquisition.location);
209
+ }).catch(cancel);
210
+ }
211
+ acquisition.consumers.add(consumer);
212
+ signal.addEventListener('abort', abort, {once: true});
213
+ if (acquisition.progress) progress(acquisition.progress);
214
+ if (signal.aborted) abort();
215
+ if (!acquisition.task) {
216
+ acquisition.task = Promise.resolve().then(function acquireSelectedMembers() { return acquireNativeMembers(acquisition); });
217
+ function releaseAcquisition() { if (nativeAcquisitions.get(key) === acquisition) nativeAcquisitions.delete(key); }
218
+ acquisition.task.then(releaseAcquisition, releaseAcquisition);
219
+ }
220
+ acquisition.task.then(function completed(location) {
221
+ observer.then(function observersCompleted() { finish(false, null, location); });
222
+ }, function failed(error) {
223
+ observer.then(function observersCompleted() { finish(true, error); });
224
+ });
225
+ });
226
+ }
227
+
228
+ /** Temporary browser projections and persistent selected native acquisitions. */
14
229
  export function createModelAssetService({appRoot = process.cwd()} = {}) {
15
230
  const projections = new Map();
16
231
  let context;
@@ -66,7 +281,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
66
281
  try { await record.downloading; } catch (error) { record.error ??= error; }
67
282
  try { await record.finishing; } catch (error) { record.error ??= error; }
68
283
  try { await closeMembers(record); filesClosed = true; } catch (error) { errors.push(error); }
69
- if (record.directory && filesClosed) {
284
+ if (record.directory && filesClosed && !record.persistent) {
70
285
  try { await rm(record.directory, {recursive: true}); }
71
286
  catch (error) { if (error.code !== 'ENOENT') errors.push(error); }
72
287
  }
@@ -107,8 +322,8 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
107
322
  throw error;
108
323
  }
109
324
 
110
- async function openProjection({id, workingDirectory, members}, request) {
111
- request.signal.throwIfAborted();
325
+ function beginProjection({id, workingDirectory, members}, signal) {
326
+ signal.throwIfAborted();
112
327
  if (closing) throw failure('MODEL_ASSETS_CLOSING', 'Model asset preparation is closing.');
113
328
  if (!is.string(id) || !id) throw new TypeError('A model asset projection needs an operation id.');
114
329
  if (!is.string(workingDirectory) || !workingDirectory) {
@@ -126,21 +341,20 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
126
341
  });
127
342
  record.released.catch(function observeReleaseFailure() {});
128
343
  projections.set(id, record);
344
+ return record;
345
+ }
346
+
347
+ async function openProjection({id, workingDirectory, members}, request) {
348
+ const record = beginProjection({id, workingDirectory, members}, request.signal);
129
349
  record.opening = Promise.resolve().then(async function createWorkingFiles() {
130
350
  const selectedDirectory = path.resolve(appRoot, workingDirectory);
131
351
  await mkdir(selectedDirectory, {recursive: true});
132
352
  request.signal.throwIfAborted();
133
353
  record.directory = await mkdtemp(path.join(selectedDirectory, 'arcane-model-'));
134
354
  record.members = members.map(function memberRecord(member) {
135
- const nativePath = path.resolve(record.directory, member.path);
136
- const relative = path.relative(record.directory, nativePath);
137
355
  // A projection consists of files under its one owned directory.
138
356
  // Absolute or escaping members cannot participate in that lifetime.
139
- if (path.isAbsolute(member.path) || !relative || relative === '..'
140
- || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
141
- throw new TypeError('Model asset member paths must be relative files within their projection.');
142
- }
143
- return {path: member.path, nativePath, handle: null, tail: Promise.resolve()};
357
+ return {...memberLocation(record.directory, member), handle: null, tail: Promise.resolve()};
144
358
  });
145
359
  const results = await Promise.allSettled(record.members.map(async function createMember(member) {
146
360
  request.signal.throwIfAborted();
@@ -189,78 +403,44 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
189
403
  return task;
190
404
  }
191
405
 
192
- async function prepare({id, workingDirectory, members, signal, onProgress} = {}) {
406
+ async function prepare({id, workingDirectory, members, signal, onProgress, refresh = false} = {}) {
193
407
  const controller = new AbortController();
194
408
  const operationSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
195
- const request = {signal: operationSignal};
196
- let record;
197
- let completed = 0;
198
- let firstFailure;
199
-
200
- async function progress(phase, memberIndex) {
409
+ const record = beginProjection({id, workingDirectory, members}, operationSignal);
410
+ let selectedMembers;
411
+ try { selectedMembers = members.map(function selectedMember(member) { return {path: member.path, url: member.url}; }); }
412
+ catch (error) { return failedOperation(record, error); }
413
+ record.persistent = true;
414
+ record.downloadController = controller;
415
+ // This task owns only acquisition. Failure cleanup may join it without
416
+ // recursively waiting for the preparation promise that requests cleanup.
417
+ record.downloading = Promise.resolve().then(async function prepareNativeFiles() {
201
418
  operationSignal.throwIfAborted();
202
- await onProgress?.({
203
- phase, completed, total: members.length, unit: 'files',
204
- ...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path})
205
- });
419
+ const selectedDirectory = path.join(path.resolve(appRoot, workingDirectory), 'model-assets');
420
+ await mkdir(selectedDirectory, {recursive: true});
421
+ const directory = await realpath(selectedDirectory);
206
422
  operationSignal.throwIfAborted();
207
- }
208
-
209
- async function downloadMember(member, memberIndex) {
210
- try {
211
- await progress('download', memberIndex);
212
- const response = await fetch(member.url, {signal: operationSignal});
213
- if (!response.ok) {
214
- const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
215
- error.url = member.url;
216
- error.status = response.status;
217
- error.response = await response.text();
218
- throw error;
219
- }
220
- if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
221
- for await (const content of response.body) {
222
- operationSignal.throwIfAborted();
223
- await appendMemberContent(record, record.members[memberIndex], content, operationSignal);
224
- await progress('download', memberIndex);
225
- }
226
- completed += 1;
227
- await progress('download', memberIndex);
228
- } catch (error) {
229
- firstFailure ??= error;
230
- controller.abort(error);
231
- throw error;
232
- }
233
- }
234
-
423
+ return shareNativeAcquisition({directory, members: selectedMembers, refresh: refresh === true, signal: operationSignal,
424
+ async onProgress(value, location) {
425
+ if (location) {
426
+ record.directory = location.directory;
427
+ record.members = location.members;
428
+ }
429
+ await onProgress?.(value);
430
+ }});
431
+ });
432
+ publish(record);
235
433
  try {
236
- await openProjection({id, workingDirectory, members}, request);
237
- record = requireProjection(id);
434
+ const location = await record.downloading;
238
435
  operationSignal.throwIfAborted();
239
- if (!record.preparationOwned || closing) {
240
- throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
241
- }
242
- record.downloadController = controller;
243
- // Cleanup joins this transfer-only task. Its rejection handler below
244
- // may release preparation without waiting on itself.
245
- record.downloading = Promise.resolve().then(async function downloadMembers() {
246
- await progress('open');
247
- const results = await Promise.allSettled(members.map(downloadMember));
248
- const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
249
- .map(function reason(result) { return result.reason; });
250
- if (errors.length > 1) {
251
- throw new AggregateError(errors, 'Downloading model asset members failed.', {cause: firstFailure});
252
- }
253
- if (errors.length) throw errors[0];
254
- });
255
- await record.downloading;
256
- await progress('complete');
257
- const projection = await completeProjection({id}, request);
258
- await progress('ready');
259
- return projection;
436
+ record.directory = location.directory;
437
+ record.members = location.members;
438
+ record.state = 'ready';
439
+ publish(record);
440
+ return snapshot(record);
260
441
  } catch (error) {
261
442
  controller.abort(error);
262
- if (record) return failedOperation(record, error);
263
- throw error;
443
+ return failedOperation(record, error);
264
444
  }
265
445
  }
266
446
 
@@ -13,6 +13,7 @@ import {
13
13
  import path from 'node:path';
14
14
  import {pathToFileURL} from 'node:url';
15
15
  import {appRelativeRoot,resolveAppRoot,resolvePackageOutputRoot} from '../app-layout.mjs';
16
+ import {materializeBrowserDecisions} from '../browser-decisions-distribution.mjs';
16
17
  import {TARGET_IDS} from '../constants.mjs';
17
18
  import {normalizeLocalAIConfig} from '../local-ai/config.mjs';
18
19
  import {readInstalledSdkLayout} from '../sdk-runtime-layout.mjs';
@@ -904,7 +905,8 @@ async function replaceDirectory(stagingRoot,outputRoot){
904
905
  }
905
906
 
906
907
  async function packageWithContext(context,options={}){
907
- const {signal,onEvent,moduleFormat='import-map',browserPwa:requestedBrowserPwa=true}=options;
908
+ const {signal,onEvent,moduleFormat='import-map',browserPwa:requestedBrowserPwa=true,browserDecisions=false}=options;
909
+ if (!is.boolean(browserDecisions)) throw new TypeError('packageApp browserDecisions must be a boolean.');
908
910
  const browserPwa=context.target==='browser'&&requestedBrowserPwa;
909
911
  if(!['import-map','native'].includes(moduleFormat)){
910
912
  throw new TypeError('packageApp moduleFormat must be "import-map" or "native".');
@@ -1151,7 +1153,32 @@ async function packageWithContext(context,options={}){
1151
1153
  if(!files.some(file=>pathKey(file)===pathKey(entryPath))){
1152
1154
  fail(`Package output is missing its entry file: ${context.config.entry}.`);
1153
1155
  }
1154
- const installed=pwaEnabled?await readInstalledSdkLayout(context.workspaceRoot,context.rootConfig):null;
1156
+ const installed=pwaEnabled||browserDecisions
1157
+ ?await readInstalledSdkLayout(context.workspaceRoot,context.rootConfig):null;
1158
+ if (browserDecisions) {
1159
+ const moduleSource = installed
1160
+ ? path.join(installed.packageRoot, 'browser-runtime', 'ai', 'browser-decisions.mjs')
1161
+ : path.join(context.workspaceRoot, 'arcane', 'sdk', 'ai', 'browser-decisions.mjs');
1162
+ const moduleRecord = browserRecords.find(
1163
+ function selectedDecisionModule(record) { return record.source === moduleSource; }
1164
+ );
1165
+ if (!moduleRecord || !inventory.has(moduleRecord.destination)) {
1166
+ fail('packageApp browserDecisions requires the selected SDK browser-decisions module in the package.');
1167
+ }
1168
+ const distributionPath = path.posix.join(path.posix.dirname(moduleRecord.destination), 'decisions-runtime');
1169
+ // Acquire after native module and asset rewriting. The upstream
1170
+ // distribution stays opaque and joins the final PWA/release inventory.
1171
+ const distribution = await materializeBrowserDecisions(
1172
+ path.join(stagingRoot, ...distributionPath.split('/')),
1173
+ signal,
1174
+ onEvent
1175
+ );
1176
+ for (const file of distribution.files) {
1177
+ const relative = `${distributionPath}/${file}`;
1178
+ if (!inventory.has(relative)) files.push(relative);
1179
+ inventory.add(relative);
1180
+ }
1181
+ }
1155
1182
  const pwaArtifacts=pwaEnabled?createPwaArtifacts({
1156
1183
  app:{
1157
1184
  id:context.appId,
@@ -2,6 +2,7 @@ import Is from 'strong-type';
2
2
  import {randomUUID} from 'node:crypto';
3
3
  import {copyFile,lstat,mkdir,readFile,readdir,realpath,rename,rm,writeFile} from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ import {materializeBrowserDecisions} from './browser-decisions-distribution.mjs';
5
6
  import {SDK_VERSION} from './constants.mjs';
6
7
  import {rewriteAssetReferences} from './import-map.mjs';
7
8
  import {getSdkRoot} from './runtime.mjs';
@@ -85,85 +86,6 @@ async function removeTemporaryTree(location){
85
86
  await rm(location,{recursive:true,force:true}).catch(()=>{});
86
87
  }
87
88
 
88
- async function materializeBrowserDecisions(destination, signal, onEvent) {
89
- const transformers = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0/';
90
- const onnx = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.31.0-dev.20260914-8d85527a0/';
91
- const files = [
92
- {name: 'transformers.min.js', url: `${transformers}dist/transformers.min.js`},
93
- {name: 'ort-wasm-simd-threaded.asyncify.mjs', url: `${onnx}dist/ort-wasm-simd-threaded.asyncify.mjs`},
94
- {name: 'ort-wasm-simd-threaded.asyncify.wasm', url: `${onnx}dist/ort-wasm-simd-threaded.asyncify.wasm`},
95
- {name: 'TRANSFORMERS-LICENSE', url: `${transformers}LICENSE`},
96
- {name: 'ONNX-RUNTIME-LICENSE', url: `${onnx}LICENSE`}
97
- ];
98
- const controller = new AbortController();
99
- function cancelDistribution() {
100
- controller.abort(signal.reason);
101
- }
102
- signal?.addEventListener('abort', cancelDistribution, {once: true});
103
- if (signal?.aborted) cancelDistribution();
104
- let completed = 0;
105
- try {
106
- throwIfAborted(controller.signal);
107
- await emit(
108
- onEvent,
109
- {type: 'workspace.decisions.started', completed, total: files.length}
110
- );
111
- throwIfAborted(controller.signal);
112
- await mkdir(destination, {recursive: true});
113
- async function acquireDistributionFile(file) {
114
- try {
115
- throwIfAborted(controller.signal);
116
- const response = await fetch(file.url, {signal: controller.signal});
117
- const content = new Uint8Array(await response.arrayBuffer());
118
- if (!response.ok) {
119
- const error = new Error(`Browser decision distribution returned HTTP ${response.status} ${response.statusText}.`);
120
- error.code = 'ARCANE_DECISION_DISTRIBUTION_DOWNLOAD_FAILED';
121
- error.response = {
122
- url: response.url,
123
- status: response.status,
124
- statusText: response.statusText,
125
- headers: [...response.headers],
126
- content
127
- };
128
- throw error;
129
- }
130
- throwIfAborted(controller.signal);
131
- // Upstream distribution content is opaque, including its own
132
- // imports and license text. No SDK source rewriting applies.
133
- await writeFile(path.join(destination, file.name), content);
134
- throwIfAborted(controller.signal);
135
- completed += 1;
136
- await emit(
137
- onEvent,
138
- {type: 'workspace.decisions.progress', file: file.name, url: file.url, completed, total: files.length}
139
- );
140
- } catch (error) {
141
- controller.abort(error);
142
- throw error;
143
- }
144
- }
145
- // One fixed distribution, acquired once for the selected projection;
146
- // no model/app/architecture multiplier or installation script.
147
- const outcomes = await Promise.allSettled(files.map(acquireDistributionFile));
148
- const failures = new Set();
149
- for (const outcome of outcomes) {
150
- if (outcome.status === 'rejected') failures.add(outcome.reason);
151
- }
152
- if (failures.size === 1) throw [...failures][0];
153
- if (failures.size > 1) {
154
- throw new AggregateError([...failures], 'Browser decision distribution acquisition failed.');
155
- }
156
- throwIfAborted(controller.signal);
157
- } finally {
158
- signal?.removeEventListener('abort', cancelDistribution);
159
- }
160
- return {
161
- transformersVersion: '4.3.0',
162
- onnxRuntimeVersion: '1.31.0-dev.20260914-8d85527a0',
163
- files: files.map(function distributionFilename(file) { return file.name; })
164
- };
165
- }
166
-
167
89
  export async function materializeWorkspaceRuntimeContent({
168
90
  workspaceRoot,
169
91
  runtimeRoot=path.join(getSdkRoot(),'runtime'),