arcane-os 0.62.0 → 0.63.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.
@@ -0,0 +1,360 @@
1
+ import Is from 'strong-type';
2
+ import {createArcaneEventSource} from '../../event-manager.mjs';
3
+ import {CoreError, serializeCoreError} from '../../../browser-runtime/core/contracts.mjs';
4
+
5
+ const is = new Is(false);
6
+
7
+ function failure(code, message) {
8
+ return new CoreError(
9
+ {code, message}
10
+ );
11
+ }
12
+
13
+ function waitForLoad(task, signal) {
14
+ signal.throwIfAborted();
15
+ return new Promise(
16
+ function awaitSpeechLoad(resolve, reject) {
17
+ function cancelled() {
18
+ signal.removeEventListener('abort', cancelled);
19
+ reject(signal.reason);
20
+ }
21
+ signal.addEventListener(
22
+ 'abort',
23
+ cancelled,
24
+ {once: true}
25
+ );
26
+ task.then(
27
+ function loaded(value) {
28
+ signal.removeEventListener('abort', cancelled);
29
+ if (signal.aborted) {
30
+ reject(signal.reason);
31
+ } else {
32
+ resolve(value);
33
+ }
34
+ },
35
+ function loadFailed(error) {
36
+ signal.removeEventListener('abort', cancelled);
37
+ reject(error);
38
+ }
39
+ );
40
+ }
41
+ );
42
+ }
43
+
44
+ /** Hosts supply selected engines; this service owns their independent lifetimes. */
45
+ export function createSpeechService({stt, tts, signal} = {}) {
46
+ const roles = {
47
+ stt: {engine: stt, operation: 'transcribe', state: null, loading: null, unsubscribe: null},
48
+ tts: {engine: tts, operation: 'synthesize', state: null, loading: null, unsubscribe: null}
49
+ };
50
+ for (const [name, role] of Object.entries(roles)) {
51
+ if (role.engine == null) {
52
+ role.state = {providerId: '', modelId: null, state: 'unavailable', loaded: false, busy: false};
53
+ continue;
54
+ }
55
+ for (const method of ['current', 'subscribe', 'load', 'close', role.operation]) {
56
+ if (!is.function(role.engine[method])) {
57
+ throw new TypeError(`The ${name} speech engine requires ${method}().`);
58
+ }
59
+ }
60
+ role.state = role.engine.current();
61
+ }
62
+
63
+ const lifetime = new AbortController();
64
+ const lifetimeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
65
+ const owner = {};
66
+ const events = createArcaneEventSource(
67
+ owner,
68
+ {source: 'core-speech', eventTypes: ['speech.state']}
69
+ );
70
+ const jobs = new Set();
71
+ const publications = [];
72
+ let context;
73
+ let started = false;
74
+ let publishing = false;
75
+ let closing = null;
76
+ let closed = false;
77
+
78
+ function available(role) {
79
+ return started && !closing && !closed && role.state.loaded === true
80
+ && (role.state.state === 'ready' || role.state.state === 'running');
81
+ }
82
+
83
+ function current() {
84
+ const transcriptionAvailable = available(roles.stt);
85
+ const synthesisAvailable = available(roles.tts);
86
+ return {
87
+ ready: transcriptionAvailable && synthesisAvailable,
88
+ synthesisAvailable,
89
+ transcriptionAvailable,
90
+ status: closed ? 'closed' : closing ? 'closing' : started ? 'ok' : 'created',
91
+ ttsEngine: roles.tts.state.providerId ?? '',
92
+ sttEngine: roles.stt.state.providerId ?? '',
93
+ roles: {
94
+ stt: {...roles.stt.state, available: transcriptionAvailable},
95
+ tts: {...roles.tts.state, available: synthesisAvailable}
96
+ },
97
+ closed
98
+ };
99
+ }
100
+
101
+ function publish() {
102
+ publications.push(current());
103
+ if (publishing) {
104
+ return;
105
+ }
106
+ publishing = true;
107
+ try {
108
+ // A listener may close the service. Finish this snapshot before
109
+ // publishing the lifecycle change that listener caused.
110
+ while (publications.length) {
111
+ const snapshot = publications.shift();
112
+ events.dispatch('speech.state', snapshot);
113
+ context?.emit('speech.state', snapshot);
114
+ }
115
+ } finally {
116
+ publishing = false;
117
+ }
118
+ }
119
+
120
+ function roleFailed(name, error) {
121
+ const role = roles[name];
122
+ role.state = {...role.state, state: 'error', error: serializeCoreError(error)};
123
+ console.error(`The ${name} speech engine failed.`, error);
124
+ publish();
125
+ }
126
+
127
+ function startRole(name) {
128
+ const role = roles[name];
129
+ if (!role.engine || closing) {
130
+ return;
131
+ }
132
+ try {
133
+ role.unsubscribe = role.engine.subscribe(
134
+ function speechEngineChanged(state) {
135
+ if (!closed) {
136
+ role.state = state;
137
+ publish();
138
+ }
139
+ }
140
+ );
141
+ } catch (error) {
142
+ roleFailed(name, error);
143
+ return;
144
+ }
145
+ const task = Promise.resolve().then(
146
+ async function loadSpeechEngine() {
147
+ lifetimeSignal.throwIfAborted();
148
+ await role.engine.load(
149
+ {signal: lifetimeSignal}
150
+ );
151
+ if (!closed) {
152
+ role.state = role.engine.current();
153
+ publish();
154
+ }
155
+ }
156
+ );
157
+ role.loading = task;
158
+ task.then(
159
+ function speechEngineLoaded() {
160
+ role.loading = null;
161
+ },
162
+ function speechEngineLoadFailed(error) {
163
+ role.loading = null;
164
+ if (!closing) {
165
+ roleFailed(name, error);
166
+ }
167
+ }
168
+ ).catch(
169
+ function speechLoadObserverFailed(error) {
170
+ console.error('Speech load state observer failed.', error);
171
+ }
172
+ );
173
+ }
174
+
175
+ function invoke(name, parameters, request) {
176
+ if (closing || closed) {
177
+ throw failure('CORE_CLOSING', 'The speech service is closing.');
178
+ }
179
+ const role = roles[name];
180
+ const operationSignal = request.signal
181
+ ? AbortSignal.any([lifetimeSignal, request.signal]) : lifetimeSignal;
182
+ operationSignal.throwIfAborted();
183
+ if (!role.engine) {
184
+ throw failure('SPEECH_ENGINE_UNAVAILABLE', `No ${name} speech engine is configured.`);
185
+ }
186
+ request.emit?.(
187
+ 'speech.progress',
188
+ {requestId: request.requestId, role: name, status: 'Thinking', progress: {phase: 'accepted'}}
189
+ );
190
+ const task = (async function invokeSpeechEngine() {
191
+ if (role.loading) {
192
+ await waitForLoad(role.loading, operationSignal);
193
+ }
194
+ operationSignal.throwIfAborted();
195
+ const result = await role.engine[role.operation](
196
+ parameters,
197
+ {
198
+ signal: operationSignal,
199
+ onProgress(progress) {
200
+ if (!operationSignal.aborted) {
201
+ request.emit?.(
202
+ 'speech.progress',
203
+ {requestId: request.requestId, role: name, status: 'Thinking', progress}
204
+ );
205
+ }
206
+ }
207
+ }
208
+ );
209
+ operationSignal.throwIfAborted();
210
+ return result;
211
+ })();
212
+ jobs.add(task);
213
+ function speechRequestSettled() {
214
+ jobs.delete(task);
215
+ }
216
+ task.then(speechRequestSettled, speechRequestSettled);
217
+ return task;
218
+ }
219
+
220
+ function notifyListener(listener, state) {
221
+ const result = listener(state);
222
+ if (result && is.function(result.then)) {
223
+ Promise.resolve(result).catch(
224
+ function speechListenerFailed(error) {
225
+ console.error('Speech state listener failed.', error);
226
+ }
227
+ );
228
+ }
229
+ }
230
+
231
+ function subscribe(listener, {replay = true, signal: subscriptionSignal} = {}) {
232
+ if (!is.function(listener)) {
233
+ throw new TypeError('A speech state listener must be a function.');
234
+ }
235
+ if (closed) {
236
+ if (replay && !subscriptionSignal?.aborted) {
237
+ notifyListener(listener, current());
238
+ }
239
+ return function closedSubscription() {};
240
+ }
241
+ const unsubscribe = events.on(
242
+ 'speech.state',
243
+ function speechStateChanged(event) {
244
+ notifyListener(listener, event.detail);
245
+ },
246
+ {signal: subscriptionSignal}
247
+ );
248
+ try {
249
+ if (replay && !subscriptionSignal?.aborted) {
250
+ notifyListener(listener, current());
251
+ }
252
+ } catch (error) {
253
+ unsubscribe();
254
+ throw error;
255
+ }
256
+ return unsubscribe;
257
+ }
258
+
259
+ function close() {
260
+ if (closing) {
261
+ return closing;
262
+ }
263
+ closing = Promise.resolve().then(
264
+ async function closeSpeechService() {
265
+ const shutdowns = Object.entries(roles).map(
266
+ async function closeSpeechEngine([name, role]) {
267
+ if (!role.engine) {
268
+ return;
269
+ }
270
+ try {
271
+ await role.engine.close();
272
+ role.state = role.engine.current();
273
+ } catch (error) {
274
+ roleFailed(name, error);
275
+ throw error;
276
+ } finally {
277
+ role.unsubscribe?.();
278
+ }
279
+ }
280
+ );
281
+ const loading = Object.values(roles).map(
282
+ function speechLoadTask(role) {
283
+ return role.loading;
284
+ }
285
+ ).filter(Boolean);
286
+ const results = await Promise.allSettled([...shutdowns, ...loading, ...jobs]);
287
+ lifetimeSignal.removeEventListener('abort', abortService);
288
+ closed = true;
289
+ publish();
290
+ const failures = [];
291
+ for (let index = 0; index < shutdowns.length; index += 1) {
292
+ if (results[index].status === 'rejected') {
293
+ failures.push(results[index].reason);
294
+ }
295
+ }
296
+ if (failures.length) {
297
+ throw new AggregateError(failures, 'Speech service shutdown failed.');
298
+ }
299
+ return current();
300
+ }
301
+ );
302
+ lifetime.abort();
303
+ publish();
304
+ return closing;
305
+ }
306
+
307
+ function abortService() {
308
+ close().catch(
309
+ function speechShutdownFailed(error) {
310
+ console.error('Speech service shutdown failed.', error);
311
+ }
312
+ );
313
+ }
314
+
315
+ lifetimeSignal.addEventListener(
316
+ 'abort',
317
+ abortService,
318
+ {once: true}
319
+ );
320
+
321
+ return {
322
+ name: 'speech',
323
+ current,
324
+ subscribe,
325
+ close,
326
+ start(currentContext) {
327
+ if (started || closing) {
328
+ return;
329
+ }
330
+ context = currentContext;
331
+ if (lifetimeSignal.aborted) {
332
+ abortService();
333
+ return;
334
+ }
335
+ started = true;
336
+ startRole('stt');
337
+ startRole('tts');
338
+ publish();
339
+ },
340
+ methods: {
341
+ 'speech.status': current,
342
+ 'speech.transcribe': function transcribe(parameters, request = {}) {
343
+ return invoke('stt', parameters, request);
344
+ },
345
+ 'speech.synthesize': function synthesize(parameters, request = {}) {
346
+ return invoke('tts', parameters, request);
347
+ }
348
+ },
349
+ drain: close,
350
+ async dispose() {
351
+ try {
352
+ await close();
353
+ } finally {
354
+ events.dispose();
355
+ }
356
+ }
357
+ };
358
+ }
359
+
360
+ export default createSpeechService;
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import Is from 'strong-type';
4
4
  import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
5
5
  import {SD14_MODEL} from './image-models/sd14.mjs';
6
+ import {FLUX2_KLEIN_4B_MODEL} from './image-models/flux.mjs';
6
7
 
7
8
  const is = new Is(false);
8
9
  export const LOCAL_AI_RUNTIME_IDS = ['llama.cpp', 'ollama', 'onnx', 'stable-diffusion.cpp'];
@@ -18,9 +19,10 @@ export function normalizeImageRuntimeRequirement(record) {
18
19
  }
19
20
  const models = (record.models ?? []).map(
20
21
  function selectedImageModel(value) {
21
- const model = value === 'sd14' ? SD14_MODEL : value;
22
+ const model = value === 'sd14' ? SD14_MODEL
23
+ : value === 'flux2-klein-4b' ? FLUX2_KLEIN_4B_MODEL : value;
22
24
  if (!model || !is.object(model) || is.array(model) || !is.string(model.id) || !model.id) {
23
- throw new ArcaneError(ERROR_CODES.usage, 'An image model needs a descriptor with an id, or the built-in sd14 ID.');
25
+ throw new ArcaneError(ERROR_CODES.usage, 'An image model needs a descriptor with an id, or a built-in model ID (sd14 or flux2-klein-4b).');
24
26
  }
25
27
  if (!model.resources || !is.object(model.resources) || is.array(model.resources)) {
26
28
  throw new ArcaneError(ERROR_CODES.usage, `Image model ${model.id} needs resources keyed by model role.`);
@@ -0,0 +1,31 @@
1
+ /** Distilled Klein model resources; acquisition belongs to the model asset owner. */
2
+ export const FLUX2_KLEIN_4B_MODEL = {
3
+ id: 'flux2-klein-4b',
4
+ name: 'FLUX.2 Klein 4B',
5
+ family: 'flux',
6
+ resources: {
7
+ diffusion_model: {
8
+ url: 'https://huggingface.co/black-forest-labs/FLUX.2-klein-4B/resolve/e7b7dc27f91deacad38e78976d1f2b499d76a294/flux-2-klein-4b.safetensors',
9
+ filename: 'flux-2-klein-4b.safetensors'
10
+ },
11
+ vae: {
12
+ url: 'https://huggingface.co/black-forest-labs/FLUX.2-small-decoder/resolve/a3efc24f613ef42d9428af62fdbd6f5fd8856c4a/full_encoder_small_decoder.safetensors',
13
+ filename: 'full_encoder_small_decoder.safetensors'
14
+ },
15
+ llm: {
16
+ url: 'https://huggingface.co/Qwen/Qwen3-4B-GGUF/resolve/bc640142c66e1fdd12af0bd68f40445458f3869b/Qwen3-4B-Q8_0.gguf',
17
+ filename: 'Qwen3-4B-Q8_0.gguf'
18
+ }
19
+ },
20
+ context: {},
21
+ defaults: {
22
+ sample_params: {
23
+ sample_steps: 4,
24
+ sample_method: 'euler',
25
+ guidance: {
26
+ txt_cfg: 1
27
+ }
28
+ }
29
+ },
30
+ operations: ['txt2img']
31
+ };
@@ -41,13 +41,30 @@ function portableRequest(target) {
41
41
  return {...target, target: 'portable', format: 'portable'};
42
42
  }
43
43
 
44
+ function applicationExecutable(application) {
45
+ const name = is.string(application.displayName) && application.displayName
46
+ ? application.displayName : application.id;
47
+ // Derive a Windows filename without changing the application's name.
48
+ // These substitutions belong only to Windows' reserved filename syntax.
49
+ const filename = `${name}.exe`.replace(/[<>:"/\\|?*\u0000-\u001f]/gu, '_');
50
+ return /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu.test(filename)
51
+ ? `_${filename}` : filename;
52
+ }
53
+
54
+ function hostAssetPath(relative, executable) {
55
+ if (relative === 'Arcane.exe') return executable;
56
+ if (relative === 'Arcane.exe.config') return `${executable}.config`;
57
+ return relative;
58
+ }
59
+
44
60
  async function emit(onEvent, event) {
45
61
  if (is.function(onEvent)) await onEvent(event);
46
62
  }
47
63
 
48
- async function hostFilesMissing(directory) {
64
+ async function hostFilesMissing(directory, executable = 'Arcane.exe') {
49
65
  const missing = [];
50
- for (const relative of HOST_FILES) {
66
+ for (const source of HOST_FILES) {
67
+ const relative = hostAssetPath(source, executable);
51
68
  try {
52
69
  if (!(await stat(path.join(directory, relative))).isFile()) missing.push(relative);
53
70
  } catch (error) {
@@ -58,9 +75,9 @@ async function hostFilesMissing(directory) {
58
75
  return missing;
59
76
  }
60
77
 
61
- async function requireHostDirectory(directory) {
78
+ async function requireHostDirectory(directory, executable = 'Arcane.exe') {
62
79
  const resolved = await realpath(directory);
63
- const missing = await hostFilesMissing(resolved);
80
+ const missing = await hostFilesMissing(resolved, executable);
64
81
  if (missing.length) {
65
82
  throw new ArcaneError(ERROR_CODES.prerequisiteMissing, 'The selected Windows host assets are incomplete.', {
66
83
  details: {hostDirectory: resolved, missing}
@@ -126,7 +143,7 @@ function contains(root, candidate) {
126
143
  return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
127
144
  }
128
145
 
129
- async function copyHostAssets(source, root, files, signal, relative = '', ancestors = new Set()) {
146
+ async function copyHostAssets(source, root, files, signal, executable, relative = '', ancestors = new Set()) {
130
147
  throwIfAborted(signal);
131
148
  const canonical = await realpath(source);
132
149
  const output = await realpath(root);
@@ -139,11 +156,12 @@ async function copyHostAssets(source, root, files, signal, relative = '', ancest
139
156
  for (const entry of await readdir(source, {withFileTypes: true})) {
140
157
  throwIfAborted(signal);
141
158
  const selected = path.join(source, entry.name);
142
- const destination = relative ? `${relative}/${entry.name}` : entry.name;
159
+ const sourceRelative = relative ? `${relative}/${entry.name}` : entry.name;
160
+ const destination = hostAssetPath(sourceRelative, executable);
143
161
  const information = await stat(selected);
144
162
  if (information.isDirectory()) {
145
163
  await mkdir(path.join(root, destination), {recursive: true});
146
- await copyHostAssets(selected, root, files, signal, destination, parents);
164
+ await copyHostAssets(selected, root, files, signal, executable, sourceRelative, parents);
147
165
  } else if (information.isFile()) {
148
166
  await writeOutput(root, destination, await readFile(selected, {signal}), files, signal);
149
167
  } else {
@@ -152,7 +170,7 @@ async function copyHostAssets(source, root, files, signal, relative = '', ancest
152
170
  }
153
171
  }
154
172
 
155
- async function applyApplicationIcon(request, root, files) {
173
+ async function applyApplicationIcon(request, root, files, executableName) {
156
174
  const {appDescriptor, release, signal, onEvent} = request;
157
175
  const icon = appDescriptor.native?.icon;
158
176
  if (!icon) return {};
@@ -168,7 +186,7 @@ async function applyApplicationIcon(request, root, files) {
168
186
  {signal}
169
187
  );
170
188
  throwIfAborted(signal);
171
- const executable = path.join(root, 'Arcane.exe');
189
+ const executable = path.join(root, executableName);
172
190
  let branded;
173
191
  try {
174
192
  const launcher = await readFile(
@@ -268,14 +286,15 @@ export function createWindowsNativeProvider({services, hostDirectory} = {}) {
268
286
  try {
269
287
  const host = selectedHost ?? await releasedHostDirectory(path.resolve(request.outputRoot), artifact.manifest.sdk.version, {signal, onEvent});
270
288
  const files = [...artifact.manifest.files];
271
- await copyHostAssets(host, root, files, signal);
272
- const branding = await applyApplicationIcon(request, root, files);
289
+ const executable = applicationExecutable(request.appDescriptor);
290
+ await copyHostAssets(host, root, files, signal, executable);
291
+ const branding = await applyApplicationIcon(request, root, files, executable);
273
292
  const manifest = {
274
293
  ...artifact.manifest,
275
294
  kind: 'arcane-windows-native',
276
295
  target,
277
296
  host: {
278
- executable: 'Arcane.exe',
297
+ executable,
279
298
  platform: 'windows', architecture: 'x64', runtime: 'webview2',
280
299
  coreExecutable: 'runtime/ArcaneCore.exe',
281
300
  coreLoader: 'runtime/arcane-core-loader.cjs',
@@ -296,7 +315,7 @@ export function createWindowsNativeProvider({services, hostDirectory} = {}) {
296
315
  throwIfAborted(signal);
297
316
  const target = windowsTarget(targetRequest);
298
317
  const result = await portable.verify({artifact, targetRequest: portableRequest(target), signal});
299
- await requireHostDirectory(result.outputRoot);
318
+ await requireHostDirectory(result.outputRoot, result.manifest.host.executable);
300
319
  const verified = {...result, target: 'windows-x64', executable: true};
301
320
  await emit(onEvent, {type: 'native.verified', target: 'windows-x64', outputRoot: result.outputRoot});
302
321
  throwIfAborted(signal);
@@ -308,8 +327,13 @@ export function createWindowsNativeProvider({services, hostDirectory} = {}) {
308
327
  if (process.platform !== 'win32' || process.arch !== 'x64') {
309
328
  throw new ArcaneError(ERROR_CODES.nativeRunUnsupported, 'Running this Windows x64 artifact requires a Windows x64 host.');
310
329
  }
311
- const root = await requireHostDirectory(artifact?.target?.rootDir);
312
- return runProcess(path.join(root, 'Arcane.exe'), ['--close-on-stdin-eof'], {
330
+ const directory = artifact?.target?.rootDir;
331
+ const manifest = JSON.parse(
332
+ await readFile(path.join(directory, MANIFEST), 'utf8')
333
+ );
334
+ const executable = manifest.host.executable;
335
+ const root = await requireHostDirectory(directory, executable);
336
+ return runProcess(path.join(root, executable), ['--close-on-stdin-eof'], {
313
337
  cwd: root, signal, onEvent, cancellationMode: 'close-input'
314
338
  });
315
339
  }