arcane-os 0.4.0 → 0.4.2

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2
4
+
5
+ - Added transparent Browser-WASM model loading status across cache checks,
6
+ ordered model-file downloads, and WebGPU initialization, including file
7
+ counts, elapsed time, and five-second heartbeats so long downloads remain
8
+ visibly active without byte-based progress.
9
+
10
+ ## 0.4.1
11
+
12
+ - Preserved complete DirectoryPicker titles, paths, caller options, and
13
+ provider result fields without application clipping, trimming, freezing, or
14
+ generic path-character policy while retaining selected/cancelled semantics
15
+ and provider-owned path failures.
16
+
3
17
  ## 0.4.0
4
18
 
5
19
  - Preserved complete finite provider progress values and units as
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles preserve the same app URLs, theme, packaging,
20
20
  event, cancellation, and browser run contracts.
21
21
 
22
- This checkout defines the `0.4.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.4.2` 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
 
@@ -126,7 +126,7 @@ uses the same controller for automatic memory extraction.
126
126
  Create a new repository-shaped Arcane application with the exact stable SDK:
127
127
 
128
128
  ```bash
129
- npx arcane-os@0.4.0 new my-app --path ./my-app --target portable --git
129
+ npx arcane-os@0.4.2 new my-app --path ./my-app --target portable --git
130
130
  cd my-app
131
131
  npm install
132
132
  npm run check
@@ -137,7 +137,7 @@ To enroll an existing repository, install the exact SDK and initialize only
137
137
  missing Arcane files:
138
138
 
139
139
  ```bash
140
- npm install --save-dev --save-exact arcane-os@0.4.0
140
+ npm install --save-dev --save-exact arcane-os@0.4.2
141
141
  npm exec -- arcane init my-app --target portable
142
142
  ```
143
143
 
@@ -153,7 +153,7 @@ npm exec -- arcane-os targets
153
153
  No global SDK install or standalone Arcane CLI is required. The application
154
154
  repository's exact npm dependency and lockfile own the CLI and toolchain version.
155
155
 
156
- Use `npx arcane-os@0.4.0` for the initial bootstrap because it names this npm
156
+ Use `npx arcane-os@0.4.2` for the initial bootstrap because it names this npm
157
157
  package explicitly; bare `npx arcane` outside an installed project could resolve
158
158
  a different package. Both installed commands invoke the same headless toolchain.
159
159
  Project-local npm scripts use the SDK pinned by that app's `package-lock.json`,
@@ -174,7 +174,7 @@ node ./bin/arcane.mjs new local-app --path ../local-app --target portable --git
174
174
 
175
175
  # From the generated app repository
176
176
  cd ../local-app
177
- npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.4.0.tgz
177
+ npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.4.2.tgz
178
178
  npm run check
179
179
  npm ci
180
180
  ```
@@ -184,7 +184,7 @@ same location. The lockfile retains the selected package dependency while
184
184
  Arcane uses the installed package name and version. Local directory `file:` dependencies are not
185
185
  accepted because npm may install them as links; use a packed `.tgz`. A GitHub
186
186
  runner also needs that tarball at the locked path. After publication, replace
187
- the local declaration with the exact `arcane-os@0.4.0` registry package and
187
+ the local declaration with the exact `arcane-os@0.4.2` registry package and
188
188
  commit the regenerated lock.
189
189
 
190
190
  Generated repositories use `npm ci --ignore-scripts` in CI. Run dependency
@@ -322,7 +322,7 @@ package installation, or assertions.
322
322
 
323
323
  ## Current target support
324
324
 
325
- Version `0.4.0` exposes one browser target and five explicitly paired
325
+ Version `0.4.2` exposes one browser target and five explicitly paired
326
326
  native development targets: a non-runnable portable directory, a
327
327
  Windows x64 unsigned-local-test EXE bundle, Linux x64 and Linux ARM64
328
328
  unsigned-local-test DEBs, and an Android development-signed APK. The
@@ -24,6 +24,7 @@ const WEBGPU_ADAPTER_SELECTED_EVENT = "arcane.ai.browser-wasm.webgpu.adapter.sel
24
24
  const WEBGPU_ADAPTER_SELECTION_PROTOCOL = "arcane-ai-webgpu-adapter-selection/1";
25
25
  const CHROME_HIGH_PERFORMANCE_GPU_FLAG_URL =
26
26
  "chrome://flags/#force-high-performance-gpu";
27
+ const MODEL_LOAD_HEARTBEAT_MS = 5_000;
27
28
  const INTEL_VENDOR_ID = 0x8086;
28
29
  const CAPABILITY_POLICY_PROTOCOL = "arcane-ai-browser-capability-policy/1";
29
30
  let highPerformanceGpuNoticeShown = false;
@@ -561,7 +562,10 @@ export function createDbopfsModelStore({
561
562
  }
562
563
  }
563
564
 
564
- async function install(source, { signal } = {}) {
565
+ async function install(source, { signal, onProgress = null } = {}) {
566
+ if (onProgress !== null && typeof onProgress !== "function") {
567
+ throw new TypeError("Model store onProgress must be a function or null.");
568
+ }
565
569
  const names = storageName(source);
566
570
  const members = sourceMetadata(source).files;
567
571
  await remove(source);
@@ -569,6 +573,13 @@ export function createDbopfsModelStore({
569
573
  try {
570
574
  for (let index = 0; index < members.length; index += 1) {
571
575
  const member = members[index];
576
+ onProgress?.(completeValue({
577
+ phase: "download",
578
+ completed: index,
579
+ total: members.length,
580
+ unit: "files",
581
+ heartbeat: false,
582
+ }));
572
583
  const opened = await source.open(index, { signal });
573
584
  try {
574
585
  await write(names.models[index].name, opened.body, { signal });
@@ -585,6 +596,13 @@ export function createDbopfsModelStore({
585
596
  throw error;
586
597
  }
587
598
  }
599
+ onProgress?.(completeValue({
600
+ phase: "download",
601
+ completed: members.length,
602
+ total: members.length,
603
+ unit: "files",
604
+ heartbeat: false,
605
+ }));
588
606
  return completeValue({
589
607
  files: completeValue(modelFiles),
590
608
  file: modelFiles.length === 1 ? modelFiles[0] : null,
@@ -598,8 +616,20 @@ export function createDbopfsModelStore({
598
616
  async function ensure(source, {
599
617
  signal,
600
618
  onCapabilityPolicy,
619
+ onProgress = null,
601
620
  offline = false,
602
621
  } = {}) {
622
+ if (onProgress !== null && typeof onProgress !== "function") {
623
+ throw new TypeError("Model store onProgress must be a function or null.");
624
+ }
625
+ const total = sourceMetadata(source).files.length;
626
+ onProgress?.(completeValue({
627
+ phase: "cache-check",
628
+ completed: 0,
629
+ total,
630
+ unit: "files",
631
+ heartbeat: false,
632
+ }));
603
633
  const cached = await openCached(source, { signal });
604
634
  if (cached) {
605
635
  const storage = await storagePolicy({ cached: true });
@@ -611,7 +641,7 @@ export function createDbopfsModelStore({
611
641
  }
612
642
  const storage = await storagePolicy();
613
643
  onCapabilityPolicy?.(storage);
614
- const installed = await install(source, { signal });
644
+ const installed = await install(source, { signal, onProgress });
615
645
  const admittedStorage = await storagePolicy({ cached: true });
616
646
  onCapabilityPolicy?.(admittedStorage);
617
647
  return completeValue({ ...installed, cache: "installed", storage: admittedStorage });
@@ -2068,16 +2098,54 @@ export function createBrowserWasmLlmProvider({
2068
2098
  activeSecurity = effectiveSecurity;
2069
2099
  return loadPromise;
2070
2100
  }
2101
+ if (
2102
+ options.onProgress !== undefined
2103
+ && options.onProgress !== null
2104
+ && typeof options.onProgress !== "function"
2105
+ ) {
2106
+ throw new TypeError("Browser-WASM load onProgress must be a function or null.");
2107
+ }
2071
2108
  const externalSignal = options.signal ?? context.signal ?? null;
2072
2109
  const linked = linkAbortSignal(externalSignal);
2073
2110
  const signal = linked.controller.signal;
2074
2111
  const generation = ++lifecycleGeneration;
2112
+ const reportProgress = typeof context.reportProgress === "function"
2113
+ ? context.reportProgress
2114
+ : options.onProgress ?? null;
2115
+ const progressStartedAt = Date.now();
2116
+ let currentProgress = null;
2117
+ let progressHeartbeat = null;
2118
+
2119
+ function publishModelLoadProgress(progress) {
2120
+ if (!reportProgress) return;
2121
+ currentProgress = { ...progress, heartbeat: false };
2122
+ reportProgress(completeValue({
2123
+ ...currentProgress,
2124
+ elapsedMs: Math.max(0, Date.now() - progressStartedAt),
2125
+ }));
2126
+ }
2127
+
2128
+ function publishModelLoadHeartbeat() {
2129
+ if (!reportProgress || !currentProgress) return;
2130
+ reportProgress(completeValue({
2131
+ ...currentProgress,
2132
+ heartbeat: true,
2133
+ elapsedMs: Math.max(0, Date.now() - progressStartedAt),
2134
+ }));
2135
+ }
2136
+
2075
2137
  loadAbort = linked.controller;
2076
2138
  activeSource = requestedSource;
2077
2139
  activeSecurity = effectiveSecurity;
2078
2140
  activeLoadPlan = requestedLoadPlan;
2079
2141
  state = "loading";
2080
2142
  errorState = null;
2143
+ if (reportProgress) {
2144
+ progressHeartbeat = globalThis.setInterval(
2145
+ publishModelLoadHeartbeat,
2146
+ MODEL_LOAD_HEARTBEAT_MS,
2147
+ );
2148
+ }
2081
2149
  loadPromise = (async () => {
2082
2150
  try {
2083
2151
  throwIfAborted(signal, "load");
@@ -2085,6 +2153,7 @@ export function createBrowserWasmLlmProvider({
2085
2153
  signal,
2086
2154
  offline: options.offline === true,
2087
2155
  onCapabilityPolicy: (value) => { storagePolicies.set(activeSource.id, value); },
2156
+ onProgress: publishModelLoadProgress,
2088
2157
  });
2089
2158
  cacheState = admitted.cache;
2090
2159
  throwIfAborted(signal, "load");
@@ -2096,6 +2165,13 @@ export function createBrowserWasmLlmProvider({
2096
2165
  throw fail("ARCANE_AI_OPERATION_SUPERSEDED", "The model load was superseded by unload.");
2097
2166
  }
2098
2167
  const members = sourceMetadata(activeSource).files;
2168
+ publishModelLoadProgress({
2169
+ phase: "initialize",
2170
+ completed: members.length,
2171
+ total: members.length,
2172
+ unit: "files",
2173
+ heartbeat: false,
2174
+ });
2099
2175
  const modelFiles = admitted.files.map((file, index) => (
2100
2176
  typeof globalThis.File === "function"
2101
2177
  ? new File([file], members[index].name, { type: "application/octet-stream" })
@@ -2148,6 +2224,10 @@ export function createBrowserWasmLlmProvider({
2148
2224
  }
2149
2225
  throw normalized;
2150
2226
  } finally {
2227
+ if (progressHeartbeat !== null) {
2228
+ globalThis.clearInterval(progressHeartbeat);
2229
+ progressHeartbeat = null;
2230
+ }
2151
2231
  if (loadAbort === linked.controller) loadAbort = null;
2152
2232
  linked.release();
2153
2233
  loadPromise = null;
@@ -2478,6 +2558,7 @@ export function adaptV1LlmProvider(provider) {
2478
2558
  ...loadOptions,
2479
2559
  modelId: selection.modelId,
2480
2560
  signal,
2561
+ onProgress: progress,
2481
2562
  ...(security?.secure===true?{security:{secure:true}}:{}),
2482
2563
  });
2483
2564
  return status();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",
@@ -2072,17 +2072,51 @@
2072
2072
  };
2073
2073
  }
2074
2074
 
2075
+ function progressElapsedText(){
2076
+ const elapsedMilliseconds=Number(role?.progress?.elapsedMs);
2077
+ if(!Number.isFinite(elapsedMilliseconds)||elapsedMilliseconds<0){
2078
+ return '';
2079
+ }
2080
+ const elapsedSeconds=Math.floor(elapsedMilliseconds/1000);
2081
+ const hours=Math.floor(elapsedSeconds/3600);
2082
+ const minutes=Math.floor(elapsedSeconds%3600/60);
2083
+ const seconds=elapsedSeconds%60;
2084
+ if(hours>0){
2085
+ return `${hours}h ${minutes}m elapsed`;
2086
+ }
2087
+ if(minutes>0){
2088
+ return `${minutes}m ${String(seconds).padStart(2,'0')}s elapsed`;
2089
+ }
2090
+ return `${seconds}s elapsed`;
2091
+ }
2092
+
2075
2093
  function progressMessage(){
2076
2094
  const measured=determinateProgress();
2077
- const phase=typeof role?.progress?.phase==='string'&&role.progress.phase
2095
+ const phaseValue=typeof role?.progress?.phase==='string'&&role.progress.phase
2078
2096
  ?role.progress.phase
2079
2097
  :'loading';
2098
+ const phase=phaseValue.replaceAll('-',' ');
2080
2099
  const parts=[
2081
2100
  `Loading ${modelDisplayName()} through the Arcane SDK`,
2082
2101
  phase
2083
2102
  ];
2084
2103
  if(measured){
2085
- parts.push(`${measured.completed} of ${measured.total} ${measured.unit}`);
2104
+ if(
2105
+ phaseValue==='download'
2106
+ &&measured.unit==='files'
2107
+ &&measured.completed<measured.total
2108
+ ){
2109
+ parts.push(`part ${measured.completed+1} of ${measured.total}`);
2110
+ }else{
2111
+ parts.push(`${measured.completed} of ${measured.total} ${measured.unit}`);
2112
+ }
2113
+ }
2114
+ const elapsed=progressElapsedText();
2115
+ if(elapsed){
2116
+ parts.push(elapsed);
2117
+ if(role?.progress?.heartbeat===true){
2118
+ parts.push('Still working');
2119
+ }
2086
2120
  }
2087
2121
  parts.push('The first activation can take several minutes; keep this tab open');
2088
2122
  return parts.join(' · ');
@@ -165,11 +165,7 @@
165
165
  }
166
166
 
167
167
  function normalizeValue(value=''){
168
- const next=`${value??''}`.trim();
169
- if(/[\u0000-\u001f\u007f]/.test(next)){
170
- throw new TypeError('The directory path must be plain text.');
171
- }
172
- return next;
168
+ return `${value??''}`;
173
169
  }
174
170
 
175
171
  function setDisabled(value=false){
@@ -1,12 +1,7 @@
1
- const TITLE_MAX_LENGTH=160;
2
- const PATH_MAX_LENGTH=4096;
3
- const CONTROL_CHARACTERS=/[\u0000-\u001f\u007f]/;
4
-
5
- function isPlainRecord(value){
1
+ function isRecord(value){
6
2
  return Boolean(value)
7
3
  &&typeof value==='object'
8
- &&!Array.isArray(value)
9
- &&Object.getPrototypeOf(value)===Object.prototype;
4
+ &&!Array.isArray(value);
10
5
  }
11
6
 
12
7
  function coded(error,code){
@@ -14,37 +9,27 @@ function coded(error,code){
14
9
  return error;
15
10
  }
16
11
 
17
- function optionalText(value,label,maximum){
18
- if(value===undefined||value===null||value==='') return null;
12
+ function optionalText(value,label){
13
+ if(value===undefined||value===null) return value;
19
14
  if(typeof value!=='string') throw new TypeError(`${label} must be a string when provided.`);
20
- const normalized=value.trim();
21
- if(!normalized) return null;
22
- if(normalized.length>maximum) throw new RangeError(`${label} exceeds ${maximum} characters.`);
23
- if(CONTROL_CHARACTERS.test(normalized)) throw new TypeError(`${label} cannot contain control characters.`);
24
- return normalized;
15
+ return value;
25
16
  }
26
17
 
27
18
  function normalizeDirectoryPickerOptions(input={}){
28
- if(!isPlainRecord(input)) throw new TypeError('Directory picker options must be a plain object.');
29
- const allowed=new Set(['initialPath','title']);
30
- const unsupported=Object.keys(input).find(key=>!allowed.has(key));
31
- if(unsupported) throw new TypeError(`Unsupported directory picker option: ${unsupported}`);
32
-
33
- const title=optionalText(input.title,'title',TITLE_MAX_LENGTH);
34
- const initialPath=optionalText(input.initialPath,'initialPath',PATH_MAX_LENGTH);
35
- return Object.freeze({
36
- ...(title?{title}:{}),
37
- ...(initialPath?{initialPath}:{}),
38
- });
19
+ if(!isRecord(input)) throw new TypeError('Directory picker options must be an object.');
20
+ const normalized={...input};
21
+ if(Object.prototype.hasOwnProperty.call(input,'title')){
22
+ normalized.title=optionalText(input.title,'title');
23
+ }
24
+ if(Object.prototype.hasOwnProperty.call(input,'initialPath')){
25
+ normalized.initialPath=optionalText(input.initialPath,'initialPath');
26
+ }
27
+ return normalized;
39
28
  }
40
29
 
41
30
  function normalizeDirectorySelection(input){
42
- const keys=isPlainRecord(input)?Object.keys(input):[];
43
31
  if(
44
- !isPlainRecord(input)
45
- ||keys.length!==2
46
- ||!keys.includes('cancelled')
47
- ||!keys.includes('path')
32
+ !isRecord(input)
48
33
  ||typeof input.cancelled!=='boolean'
49
34
  ){
50
35
  throw coded(
@@ -53,17 +38,15 @@ function normalizeDirectorySelection(input){
53
38
  );
54
39
  }
55
40
  if(input.cancelled){
56
- if(input.path!==null){
57
- throw coded(
58
- new TypeError('A canceled directory selection must return a null path.'),
59
- 'DIRECTORY_PICKER_INVALID_RESULT',
60
- );
61
- }
62
- return Object.freeze({cancelled:true,path:null});
41
+ return {
42
+ ...input,
43
+ cancelled:true,
44
+ path:input.path===undefined?null:input.path,
45
+ };
63
46
  }
64
47
  let path;
65
48
  try{
66
- path=optionalText(input.path,'The selected directory path',PATH_MAX_LENGTH);
49
+ path=optionalText(input.path,'The selected directory path');
67
50
  }catch(error){
68
51
  throw coded(error,'DIRECTORY_PICKER_INVALID_RESULT');
69
52
  }
@@ -73,7 +56,7 @@ function normalizeDirectorySelection(input){
73
56
  'DIRECTORY_PICKER_INVALID_RESULT',
74
57
  );
75
58
  }
76
- return Object.freeze({cancelled:false,path});
59
+ return {...input,cancelled:false,path};
77
60
  }
78
61
 
79
62
  /**
@@ -81,7 +64,8 @@ function normalizeDirectorySelection(input){
81
64
  *
82
65
  * This wrapper does not enumerate directories, persist a selected path, or use
83
66
  * a browser file picker. The injected provider must expose
84
- * `selectDirectory(options)` and return `{cancelled, path}`.
67
+ * `selectDirectory(options)` and return a record with `cancelled` and `path`.
68
+ * Caller options and additional provider result fields pass through unchanged.
85
69
  */
86
70
  export default class DirectoryPicker{
87
71
  constructor(provider=globalThis.Arcane?.filesystem){