@qvac/harness 0.6.0 → 0.7.1

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +132 -1
  2. package/README.md +47 -29
  3. package/dist-lib/index.d.ts +4 -0
  4. package/dist-lib/index.js +31 -19
  5. package/dist-lib/lib/capabilities/preview-model-source.d.ts +0 -1
  6. package/dist-lib/lib/connect.js +5 -2
  7. package/dist-lib/lib/contract-types.d.ts +1 -0
  8. package/dist-lib/lib/docx/index.d.ts +1 -1
  9. package/dist-lib/lib/docx/index.js +1 -1
  10. package/dist-lib/lib/hardware/capability-probe.js +9 -4
  11. package/dist-lib/lib/hooks/built-in/sandbox.js +0 -5
  12. package/dist-lib/lib/models/model-catalog-constants.d.ts +7 -0
  13. package/dist-lib/lib/models/model-catalog-constants.js +17 -0
  14. package/dist-lib/lib/models/model-catalog.d.ts +14 -6
  15. package/dist-lib/lib/models/model-catalog.js +14 -6
  16. package/dist-lib/lib/models/model-handle.d.ts +5 -1
  17. package/dist-lib/lib/models/model-handle.js +8 -3
  18. package/dist-lib/lib/models/model-list.d.ts +7 -3
  19. package/dist-lib/lib/models/model-list.js +9 -4
  20. package/dist-lib/lib/models/model-pool.d.ts +4 -1
  21. package/dist-lib/lib/models/model-pool.js +33 -10
  22. package/dist-lib/lib/models/model-task.d.ts +3 -0
  23. package/dist-lib/lib/models/model-task.js +14 -0
  24. package/dist-lib/lib/models/music-catalog.d.ts +2 -0
  25. package/dist-lib/lib/models/music-catalog.js +9 -0
  26. package/dist-lib/lib/office/index.d.ts +1 -1
  27. package/dist-lib/lib/office/index.js +1 -1
  28. package/dist-lib/lib/office/python.js +1 -2
  29. package/dist-lib/lib/serve.js +1 -1
  30. package/dist-lib/lib/tools/exec/adapters/python/deadline-state.d.ts +10 -0
  31. package/dist-lib/lib/tools/exec/adapters/python/deadline-state.js +5 -0
  32. package/dist-lib/lib/tools/exec/adapters/python/interrupt-timer.js +10 -17
  33. package/dist-lib/lib/tools/exec/adapters/python/loader.js +15 -11
  34. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +1 -15
  35. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.d.ts +1 -0
  36. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +13 -2
  37. package/dist-lib/lib/tools/exec/adapters/python/wheels.d.ts +3 -3
  38. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +3 -14
  39. package/dist-lib/lib/tools/exec/adapters/python/worker/active.d.ts +19 -0
  40. package/dist-lib/lib/tools/exec/adapters/python/worker/active.js +72 -0
  41. package/dist-lib/lib/tools/exec/adapters/python/worker/client.d.ts +0 -1
  42. package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +8 -76
  43. package/dist-lib/lib/tools/exec/adapters/python/worker/entry.js +2 -126
  44. package/dist-lib/lib/tools/exec/adapters/python/worker/interrupt-deadline.d.ts +1 -0
  45. package/dist-lib/lib/tools/exec/adapters/python/worker/interrupt-deadline.js +21 -0
  46. package/dist-lib/lib/tools/exec/adapters/python/worker/serve.d.ts +2 -0
  47. package/dist-lib/lib/tools/exec/adapters/python/worker/serve.js +90 -0
  48. package/dist-lib/lib/tools/exec/adapters/python/worker/{spawn-node.d.ts → spawn.d.ts} +0 -1
  49. package/dist-lib/lib/tools/exec/adapters/python/worker/{spawn-node.js → spawn.js} +0 -8
  50. package/dist-lib/lib/tools/exec/bare-runtime.d.ts +2 -0
  51. package/dist-lib/lib/tools/exec/bare-runtime.js +10 -0
  52. package/dist-lib/lib/tools/exec.d.ts +0 -2
  53. package/dist-lib/lib/tools/exec.js +6 -11
  54. package/dist-lib/schema/harness.js +4 -2
  55. package/dist-lib/spec/hrpc/messages.js +11 -8
  56. package/dist-lib/spec/hyperschema/index.js +11 -8
  57. package/dist-lib/spec/hyperschema/schema.json +11 -1
  58. package/dist-lib/spec/hyperschema/types.d.ts +3 -1
  59. package/package.json +39 -20
  60. package/dist/harness.bundle +0 -42313
  61. package/dist/harness.cjs +0 -18
  62. package/dist/harness.d.ts +0 -8
  63. package/dist/prebuilds-manifest.json +0 -372
  64. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-bare.d.ts +0 -3
  65. package/dist-lib/lib/tools/exec/adapters/python/worker/spawn-bare.js +0 -38
@@ -1,8 +1,8 @@
1
1
  import { AUDIOGEN_ACESTEP_5HZ_LM_0_6B_Q8_0, AUDIOGEN_ACESTEP_V15_SFT_Q8_0, AUDIOGEN_ACESTEP_V15_TURBO_Q4_K_M, AUDIOGEN_ACESTEP_V15_TURBO_Q8_0, AUDIOGEN_QWEN3_EMBEDDING_0_6B_Q8_0, AUDIOGEN_VAE_BF16, EMBEDDINGGEMMA_300M_Q4_0, FLUX_2_KLEIN_4B_Q4_0, FLUX_2_KLEIN_4B_VAE, HEALTHCARE_1_7B_MEDICAL_Q4_K_M, HEALTHCARE_4B_MEDICAL_Q4_K_M, MMPROJ_QWEN3_5_2B_MULTIMODAL_Q8_0, MMPROJ_QWEN3_5_4B_MULTIMODAL_Q8_0, MMPROJ_QWEN3_5_9B_MULTIMODAL_F16, MMPROJ_QWEN3_6_27B_MULTIMODAL_F16, MMPROJ_QWEN3_6_35B_A3B_MULTIMODAL_F16, MMPROJ_QWEN3_8_27B_MULTIMODAL_F16, QWEN3_5_2B_MULTIMODAL_Q4_K_M, QWEN3_5_4B_MULTIMODAL_Q4_K_M, QWEN3_5_9B_MULTIMODAL_Q4_K_M, PARAKEET_TDT_0_6B_V3_Q8_0, QWEN3_4B_Q4_K_M, QWEN3_6_27B_MULTIMODAL_Q4_K_XL, QWEN3_6_35B_A3B_MULTIMODAL_Q4_K_M, QWEN3_8_27B_MULTIMODAL_UD_Q4_K_XL, SD_V2_1_1B_Q8_0, SDXL_BASE_1_0_3B_Q4_0, TTS_MULTILINGUAL_SUPERTONIC3_Q4_0, TTS_MULTILINGUAL_SUPERTONIC3_Q8_0 } from '@qvac/sdk/models';
2
2
  import { detectToolDialect } from './tool-dialect.js';
3
3
  import { defaultTtsModelName } from './tts-model-default.js';
4
- import { CAPABILITY_PROFILE_ORDER, QWEN_35_2B, QWEN_35_4B, QWEN_35_9B, QWEN_36_27B, QWEN_36_35B_A3B, QWEN_38_27B, MEDPSY_1_7B, MEDPSY_4B, isCapabilityProfile, meetsMinCapabilityProfile, modelNameToCapabilityProfile, capabilityProfileToModelName, isMultimodalLlm, AGENT_MODEL_NAMES, modelDisplayName, capabilityProfileModelLabel, DEFAULT_IMAGE_MODEL_CHOICE, IMAGE_CHOICE_CATALOG } from './model-catalog-constants.js';
5
- export { CAPABILITY_PROFILE_ORDER, QWEN_35_2B, QWEN_35_4B, QWEN_35_9B, QWEN_36_27B, QWEN_36_35B_A3B, QWEN_38_27B, MEDPSY_1_7B, MEDPSY_4B, isCapabilityProfile, meetsMinCapabilityProfile, modelNameToCapabilityProfile, capabilityProfileToModelName, isMultimodalLlm, AGENT_MODEL_NAMES, modelDisplayName, capabilityProfileModelLabel, DEFAULT_IMAGE_MODEL_CHOICE, IMAGE_CHOICE_CATALOG };
4
+ import { CAPABILITY_PROFILE_ORDER, QWEN_35_2B, QWEN_35_4B, QWEN_35_9B, QWEN_36_27B, QWEN_36_35B_A3B, QWEN_38_27B, MEDPSY_1_7B, MEDPSY_4B, isCapabilityProfile, meetsMinCapabilityProfile, modelNameToCapabilityProfile, capabilityProfileToModelName, isMultimodalLlm, AGENT_MODEL_NAMES, modelDisplayName, capabilityProfileModelLabel, DEFAULT_IMAGE_MODEL_CHOICE, IMAGE_CHOICE_CATALOG, isMobilePlatform, runnableModelName } from './model-catalog-constants.js';
5
+ export { CAPABILITY_PROFILE_ORDER, QWEN_35_2B, QWEN_35_4B, QWEN_35_9B, QWEN_36_27B, QWEN_36_35B_A3B, QWEN_38_27B, MEDPSY_1_7B, MEDPSY_4B, isCapabilityProfile, meetsMinCapabilityProfile, modelNameToCapabilityProfile, capabilityProfileToModelName, isMultimodalLlm, AGENT_MODEL_NAMES, modelDisplayName, capabilityProfileModelLabel, DEFAULT_IMAGE_MODEL_CHOICE, IMAGE_CHOICE_CATALOG, isMobilePlatform, runnableModelName };
6
6
  export const LLM_CATALOG = [
7
7
  {
8
8
  name: QWEN_35_2B,
@@ -165,8 +165,8 @@ export function diffusionDownloadMetadata(model, addon) {
165
165
  assetSizes
166
166
  };
167
167
  }
168
- import { DEFAULT_MUSIC_MODEL_CHOICE } from './music-catalog.js';
169
- export { DEFAULT_MUSIC_MODEL_CHOICE };
168
+ import { DEFAULT_MUSIC_MODEL_CHOICE, isMusicModelChoice, musicModelChoiceFrom } from './music-catalog.js';
169
+ export { DEFAULT_MUSIC_MODEL_CHOICE, isMusicModelChoice, musicModelChoiceFrom };
170
170
  const MUSIC_GENERATION_SKILL = 'music-generation';
171
171
  export const AUDIOGEN_CATALOG = [
172
172
  {
@@ -251,18 +251,26 @@ export function skillModelsForAgent(agent) {
251
251
  const choice = agent.imageModelChoice ?? DEFAULT_IMAGE_MODEL_CHOICE;
252
252
  const entry = DIFFUSION_CATALOG.find((candidate) => candidate.choice === choice);
253
253
  if (entry) {
254
- models.push({ name: entry.name, addon: entry.model.addon });
254
+ models.push({ name: entry.name, addon: entry.model.addon, use: 'image' });
255
255
  }
256
256
  }
257
257
  if (agentHasSkill(agent, MUSIC_GENERATION_SKILL)) {
258
258
  const choice = agent.musicModelChoice ?? DEFAULT_MUSIC_MODEL_CHOICE;
259
259
  const entry = AUDIOGEN_CATALOG.find((candidate) => candidate.choice === choice);
260
260
  if (entry) {
261
- models.push({ name: entry.name, addon: entry.dit.addon });
261
+ models.push({ name: entry.name, addon: entry.dit.addon, use: 'music' });
262
262
  }
263
263
  }
264
264
  return models;
265
265
  }
266
+ export function modelsForAgent(agent, host) {
267
+ const skills = skillModelsForAgent(agent);
268
+ if (!agent.modelName) {
269
+ return skills;
270
+ }
271
+ const name = runnableModelName(agent.modelName, host);
272
+ return [{ name, addon: 'llm', use: 'chat' }, ...skills];
273
+ }
266
274
  export function diffusionSizeBytes(entry) {
267
275
  return diffusionAssets(entry).reduce((sum, asset) => sum + asset.expectedSize, 0);
268
276
  }
@@ -5,13 +5,17 @@ export interface ModelResource {
5
5
  }
6
6
  export interface ModelHandleOptions {
7
7
  load(): Promise<ModelResource>;
8
+ onLoaded?(): void;
9
+ onFailed?(err: unknown): void;
8
10
  }
9
11
  export declare class ModelHandle {
10
12
  private readonly _load;
11
13
  private readonly _peek;
14
+ private readonly _onLoaded;
15
+ private readonly _onFailed;
12
16
  private _tracked;
13
17
  private _loading;
14
- constructor({ load }: ModelHandleOptions);
18
+ constructor({ load, onLoaded, onFailed }: ModelHandleOptions);
15
19
  load(): Promise<ModelResource>;
16
20
  get loading(): boolean;
17
21
  peek(): Promise<ModelResource> | null;
@@ -2,12 +2,16 @@ import { retryMemo } from '../helpers/retry-memo.js';
2
2
  export class ModelHandle {
3
3
  _load;
4
4
  _peek;
5
+ _onLoaded;
6
+ _onFailed;
5
7
  _tracked = null;
6
8
  _loading = false;
7
- constructor({ load }) {
9
+ constructor({ load, onLoaded, onFailed }) {
8
10
  const memo = retryMemo(load);
9
11
  this._load = memo;
10
12
  this._peek = memo.peek;
13
+ this._onLoaded = onLoaded;
14
+ this._onFailed = onFailed;
11
15
  this.load().catch(noop);
12
16
  }
13
17
  load() {
@@ -15,12 +19,13 @@ export class ModelHandle {
15
19
  if (attempt !== this._tracked) {
16
20
  this._tracked = attempt;
17
21
  this._loading = true;
18
- const settled = () => {
22
+ const settle = (report) => {
19
23
  if (this._tracked === attempt) {
20
24
  this._loading = false;
25
+ report();
21
26
  }
22
27
  };
23
- attempt.then(settled, settled);
28
+ attempt.then(() => settle(() => this._onLoaded?.()), (err) => settle(() => this._onFailed?.(err)));
24
29
  }
25
30
  return attempt;
26
31
  }
@@ -1,5 +1,6 @@
1
1
  import type * as QvacSdk from '@qvac/sdk';
2
2
  import type { ModelAddon, ModelRegistrySdk } from './model-source.ts';
3
+ import { type ModelTaskType } from './model-task.ts';
3
4
  export type ModelType = ModelAddon;
4
5
  export interface StaticCatalogEntry {
5
6
  readonly name: string;
@@ -15,11 +16,14 @@ export interface ModelInfo {
15
16
  registrySource: string;
16
17
  isCached: boolean;
17
18
  isLoaded: boolean;
19
+ isLoading: boolean;
18
20
  supportsImages: boolean;
19
21
  expectedSize: number;
20
22
  actualSize?: number;
21
23
  label?: string;
24
+ taskType: ModelTaskType;
22
25
  }
26
+ export type CatalogRow = Omit<ModelInfo, 'isLoading'>;
23
27
  export interface ListModelsOptions {
24
28
  type?: ModelType;
25
29
  cachedOnly?: boolean;
@@ -43,11 +47,11 @@ export interface ListModelsRuntime {
43
47
  cache?: ModelStatusCache | undefined;
44
48
  projectionFor?: ((name: string) => string | undefined) | undefined;
45
49
  }
46
- export declare function listModels(sdk: ModelListSdk, { type, cachedOnly, fullRegistry }?: ListModelsOptions, { cache, projectionFor }?: ListModelsRuntime): Promise<ModelInfo[]>;
50
+ export declare function listModels(sdk: ModelListSdk, { type, cachedOnly, fullRegistry }?: ListModelsOptions, { cache, projectionFor }?: ListModelsRuntime): Promise<CatalogRow[]>;
47
51
  export interface CustomModelRowsContext {
48
52
  readonly modelsDir?: string;
49
53
  readonly loaded: ReadonlySet<string>;
50
54
  }
51
- export declare function customModelRows({ type, cachedOnly }: ListModelsOptions, { modelsDir, loaded }: CustomModelRowsContext): Promise<ModelInfo[]>;
52
- export declare function mergeCustomRows(catalog: ReadonlyArray<ModelInfo>, custom: ReadonlyArray<ModelInfo>): ModelInfo[];
55
+ export declare function customModelRows({ type, cachedOnly }: ListModelsOptions, { modelsDir, loaded }: CustomModelRowsContext): Promise<CatalogRow[]>;
56
+ export declare function mergeCustomRows(catalog: ReadonlyArray<CatalogRow>, custom: ReadonlyArray<CatalogRow>): CatalogRow[];
53
57
  export {};
@@ -1,3 +1,4 @@
1
+ import { addonTaskType } from './model-task.js';
1
2
  import { isCustomModelCached } from './custom-model-cache.js';
2
3
  import { companionRegistryPaths, curatedListables, customModels, musicListables } from './model-catalog.js';
3
4
  export const MODEL_STATUS_TTL_MS = 30_000;
@@ -42,7 +43,8 @@ async function musicRows(hydrate) {
42
43
  isCached: status.isCached && companionsCached,
43
44
  isLoaded: status.isLoaded,
44
45
  supportsImages: false,
45
- expectedSize: listable.expectedSize
46
+ expectedSize: listable.expectedSize,
47
+ taskType: addonTaskType('audiogen')
46
48
  });
47
49
  }
48
50
  return rows;
@@ -93,7 +95,8 @@ export async function listModels(sdk, { type, cachedOnly, fullRegistry } = {}, {
93
95
  isLoaded: status.isLoaded,
94
96
  supportsImages: seesImages(listable.name, listable.asset.addon),
95
97
  expectedSize: status.expectedSize ?? listable.asset.expectedSize,
96
- actualSize: status.actualSize
98
+ actualSize: status.actualSize,
99
+ taskType: addonTaskType(listable.asset.addon)
97
100
  };
98
101
  });
99
102
  const music = !type || type === 'audiogen' ? await musicRows(hydrate) : [];
@@ -121,7 +124,8 @@ export async function listModels(sdk, { type, cachedOnly, fullRegistry } = {}, {
121
124
  isLoaded: status.isLoaded,
122
125
  supportsImages: seesImages(entry.name, entry.addon),
123
126
  expectedSize: status.expectedSize ?? entry.expectedSize,
124
- actualSize: status.actualSize
127
+ actualSize: status.actualSize,
128
+ taskType: addonTaskType(entry.addon)
125
129
  };
126
130
  });
127
131
  return cachedOnly ? rows.filter((row) => row.isCached) : rows;
@@ -139,7 +143,8 @@ export async function customModelRows({ type, cachedOnly }, { modelsDir, loaded
139
143
  isLoaded: loaded.has(entry.name),
140
144
  supportsImages: addon === 'llm' && Boolean(entry.projectionSrc),
141
145
  expectedSize: entry.expectedSize ?? 0,
142
- label: entry.label
146
+ label: entry.label,
147
+ taskType: addonTaskType(addon)
143
148
  };
144
149
  }));
145
150
  return cachedOnly ? rows.filter((row) => row.isCached) : rows;
@@ -5,8 +5,9 @@ import { type ModelAddon } from './model-source.ts';
5
5
  import type { GpuFaultState } from './sdk-faults.ts';
6
6
  type SdkModule = typeof import('@qvac/sdk');
7
7
  export interface ModelState {
8
- status: 'loading' | 'downloading' | 'downloaded' | 'ready' | 'unloading' | 'unloaded';
8
+ status: 'loading' | 'downloading' | 'downloaded' | 'ready' | 'error' | 'unloading' | 'unloaded';
9
9
  model: string;
10
+ catalogChanged?: boolean;
10
11
  [key: string]: unknown;
11
12
  }
12
13
  export type NotifyFn = (state: ModelState) => void;
@@ -78,6 +79,8 @@ type PoolLike = ModelPuddle;
78
79
  export declare function modelOf(key: string): string;
79
80
  export declare function releaseModel(pool: PoolLike, key: string): void;
80
81
  export declare function loadedModels(pool: PoolLike): LoadedModel[];
82
+ export declare function settledModels(pool: PoolLike): string[];
83
+ export declare function loadingModels(pool: PoolLike): string[];
81
84
  export interface ResidentLease {
82
85
  model: string;
83
86
  modelId: string;
@@ -10,6 +10,7 @@ import { hlog } from '../log.js';
10
10
  import { RUNAWAY_STOP_SEQUENCES } from '../helpers/control-tokens.js';
11
11
  import { raceTimeout } from '../helpers/race-timeout.js';
12
12
  import { resolveModelSrc } from './model-source.js';
13
+ import { errorMessage } from '../guards.js';
13
14
  export function createLoadTracker() {
14
15
  const inflight = new Set();
15
16
  let draining = null;
@@ -101,6 +102,34 @@ export function loadedModels(pool) {
101
102
  }
102
103
  return out;
103
104
  }
105
+ export function settledModels(pool) {
106
+ const out = [];
107
+ for (const entry of pool.resources.values()) {
108
+ if (entry.value.peek() !== null && !entry.value.loading) {
109
+ out.push(modelOf(entry.id));
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+ export function loadingModels(pool) {
115
+ const out = [];
116
+ for (const entry of pool.resources.values()) {
117
+ if (entry.value.loading) {
118
+ out.push(modelOf(entry.id));
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+ function reportedHandle(notify, model, load) {
124
+ return new ModelHandle({
125
+ load: () => {
126
+ notify({ status: 'loading', model, catalogChanged: true });
127
+ return load();
128
+ },
129
+ onLoaded: () => notify({ status: 'ready', model, catalogChanged: true }),
130
+ onFailed: (err) => notify({ status: 'error', model, message: errorMessage(err), catalogChanged: true })
131
+ });
132
+ }
104
133
  export async function leaseResidentModel(pool, wanted = () => true) {
105
134
  for (const entry of pool.resources.values()) {
106
135
  const key = entry.id;
@@ -292,7 +321,6 @@ export function loadClockGuard(model, sdk, { now = () => Date.now(), monoMs = mo
292
321
  export function createModelPool(loadSdk, notify, { deviceConfig = {}, loads = createLoadTracker() } = {}) {
293
322
  const load = async (model, opts) => {
294
323
  const sdk = await loadSdk();
295
- notify({ status: 'loading', model });
296
324
  const modelSrc = await resolveModelSrc(sdk, model);
297
325
  const resolved = resolveLoadOptions(opts, deviceConfig);
298
326
  const ctxSize = resolved.ctxSize || defaultContextSize(modelSrc, customContextLength(model));
@@ -332,11 +360,10 @@ export function createModelPool(loadSdk, notify, { deviceConfig = {}, loads = cr
332
360
  onProgress: (progress) => notify(progressState(model, progress))
333
361
  })));
334
362
  hlog('llm loaded', { model, loadMs: Date.now() - loadStarted });
335
- notify({ status: 'ready', model });
336
363
  return { model, modelId, ctxSize };
337
364
  };
338
365
  return new ModelPuddle({
339
- open: (key, model = key, opts = {}) => new ModelHandle({ load: () => load(model, opts) }),
366
+ open: (key, model = key, opts = {}) => reportedHandle(notify, model, () => load(model, opts)),
340
367
  gc: unloadOnGc(loadSdk, notify),
341
368
  ...NO_IDLE_GC
342
369
  });
@@ -344,7 +371,6 @@ export function createModelPool(loadSdk, notify, { deviceConfig = {}, loads = cr
344
371
  export function createEmbedPool(loadSdk, notify, { faults, batchSize = DEFAULT_EMBED_BATCH_SIZE, openclCacheDir, loads = createLoadTracker() }) {
345
372
  const load = async (model) => {
346
373
  const sdk = await loadSdk();
347
- notify({ status: 'loading', model });
348
374
  const modelSrc = await resolveModelSrc(sdk, model, 'embeddings');
349
375
  const openclCache = faults.device !== 'cpu' && openclCacheDir ? openclCacheDir() : undefined;
350
376
  const modelId = await loadClockGuard(model, sdk).race(loads.track(sdk.loadModel({
@@ -358,11 +384,10 @@ export function createEmbedPool(loadSdk, notify, { faults, batchSize = DEFAULT_E
358
384
  },
359
385
  onProgress: (progress) => notify(progressState(model, progress))
360
386
  })));
361
- notify({ status: 'ready', model });
362
387
  return { model, modelId };
363
388
  };
364
389
  return new ModelPuddle({
365
- open: (key, model = key) => new ModelHandle({ load: () => load(model) }),
390
+ open: (key, model = key) => reportedHandle(notify, model, () => load(model)),
366
391
  gc: unloadOnGc(loadSdk, notify),
367
392
  ...NO_IDLE_GC
368
393
  });
@@ -372,7 +397,6 @@ export function createVoicePool(loadSdk, notify, { modelType, addon, modelConfig
372
397
  let gpuRefused = false;
373
398
  const load = async (model) => {
374
399
  const sdk = await loadSdk();
375
- notify({ status: 'loading', model });
376
400
  const modelSrc = await resolveModelSrc(sdk, model, addon);
377
401
  const attempt = () => {
378
402
  const config = gpuRefused ? { ...modelConfig, useGPU: false } : modelConfig;
@@ -391,11 +415,10 @@ export function createVoicePool(loadSdk, notify, { modelType, addon, modelConfig
391
415
  gpuRefused = true;
392
416
  return attempt();
393
417
  }));
394
- notify({ status: 'ready', model });
395
418
  return { model, modelId };
396
419
  };
397
420
  return new ModelPuddle({
398
- open: (key, model = key) => new ModelHandle({ load: () => load(model) }),
421
+ open: (key, model = key) => reportedHandle(notify, model, () => load(model)),
399
422
  gc: unloadOnGc(loadSdk, notify),
400
423
  ...NO_IDLE_GC
401
424
  });
@@ -423,7 +446,7 @@ function unloadOnGc(loadSdk, notify) {
423
446
  catch {
424
447
  continue;
425
448
  }
426
- notify({ status: 'unloaded', model });
449
+ notify({ status: 'unloaded', model, catalogChanged: true });
427
450
  }
428
451
  };
429
452
  }
@@ -0,0 +1,3 @@
1
+ import type { ModelAddon } from './model-source.ts';
2
+ export type ModelTaskType = 'llm' | 'embeddings' | 'speech-to-text' | 'text-to-speech' | 'image-generation' | 'audio-generation' | 'translation' | 'text-recognition' | 'other';
3
+ export declare function addonTaskType(addon: ModelAddon): ModelTaskType;
@@ -0,0 +1,14 @@
1
+ const ADDON_TASK_TYPES = {
2
+ llm: 'llm',
3
+ embeddings: 'embeddings',
4
+ whisper: 'speech-to-text',
5
+ parakeet: 'speech-to-text',
6
+ tts: 'text-to-speech',
7
+ diffusion: 'image-generation',
8
+ audiogen: 'audio-generation',
9
+ nmt: 'translation',
10
+ ocr: 'text-recognition'
11
+ };
12
+ export function addonTaskType(addon) {
13
+ return ADDON_TASK_TYPES[addon] ?? 'other';
14
+ }
@@ -10,3 +10,5 @@ export interface MusicChoiceEntry {
10
10
  readonly notes: string;
11
11
  }
12
12
  export declare const MUSIC_CHOICE_CATALOG: ReadonlyArray<MusicChoiceEntry>;
13
+ export declare function musicModelChoiceFrom(settings: unknown): MusicModelChoice | undefined;
14
+ export declare function isMusicModelChoice(value: unknown): value is MusicModelChoice;
@@ -24,3 +24,12 @@ export const MUSIC_CHOICE_CATALOG = [
24
24
  notes: 'Fine-tuned quality. Wants a desktop GPU.'
25
25
  }
26
26
  ];
27
+ export function musicModelChoiceFrom(settings) {
28
+ if (!settings || typeof settings !== 'object' || !('modelChoice' in settings)) {
29
+ return undefined;
30
+ }
31
+ return isMusicModelChoice(settings.modelChoice) ? settings.modelChoice : undefined;
32
+ }
33
+ export function isMusicModelChoice(value) {
34
+ return MUSIC_MODEL_CHOICES.some((choice) => choice === value);
35
+ }
@@ -1,5 +1,5 @@
1
1
  import type { AbortSignal } from 'bare-abort-controller';
2
- export { closePythonWorker as closeOfficeWorker } from '../tools/exec/adapters/python/worker/client.ts';
2
+ export { closePythonWorker as closeOfficeWorker } from '../tools/exec/adapters/python/worker/active.ts';
3
3
  export interface OfficeExtractOptions {
4
4
  readonly signal?: AbortSignal | undefined;
5
5
  readonly mimeType?: string | undefined;
@@ -3,7 +3,7 @@ import { extractDocxText } from '../docx/index.js';
3
3
  import { extractWithPython } from './python.js';
4
4
  import { XLSX_EXTRACT } from './xlsx.js';
5
5
  import { PPTX_EXTRACT } from './pptx.js';
6
- export { closePythonWorker as closeOfficeWorker } from '../tools/exec/adapters/python/worker/client.js';
6
+ export { closePythonWorker as closeOfficeWorker } from '../tools/exec/adapters/python/worker/active.js';
7
7
  const OFFICE_MIME_EXTENSIONS = {
8
8
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
9
9
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
@@ -10,8 +10,7 @@ export async function extractWithPython(bytes, fileName, { format, packages, sou
10
10
  const dir = wheelsDir();
11
11
  const manifest = vendoredManifest(dir);
12
12
  const install = planVendoredInstall(packages, { dir, manifest });
13
- if (!dir ||
14
- !manifest ||
13
+ if (!manifest ||
15
14
  !install ||
16
15
  !['micropip', ...install.packages].every((name) => Object.keys(manifest.files).some((file) => file.replaceAll('_', '-').startsWith(`${name}-`) && fs.existsSync(`${dir}/${file}`)))) {
17
16
  throw new Error(`bundled ${format} extraction packages are unavailable`);
@@ -270,7 +270,7 @@ function modelEvents(harness) {
270
270
  catalogChanged,
271
271
  ...loaded.map((entry) => `${entry.model}:${entry.refs}`)
272
272
  ].join('\0');
273
- if (key === last) {
273
+ if (key === last && !catalogChanged) {
274
274
  return;
275
275
  }
276
276
  last = key;
@@ -0,0 +1,10 @@
1
+ export interface DeadlineData {
2
+ readonly state: SharedArrayBuffer;
3
+ readonly interrupt: SharedArrayBuffer;
4
+ readonly ms: number;
5
+ }
6
+ export declare const ARMED = 0;
7
+ export declare const DISARMED = 1;
8
+ export declare const FIRED = 2;
9
+ export declare const SIGINT = 2;
10
+ export declare const RESEND_MS = 100;
@@ -0,0 +1,5 @@
1
+ export const ARMED = 0;
2
+ export const DISARMED = 1;
3
+ export const FIRED = 2;
4
+ export const SIGINT = 2;
5
+ export const RESEND_MS = 100;
@@ -1,28 +1,21 @@
1
- import { spawnInlineThread } from '#pyodide-worker-spawn';
2
- const ARMED = 0;
3
- const DISARMED = 1;
4
- const FIRED = 2;
5
- const SIGINT = 2;
6
- const RESEND_MS = 100;
7
- const SOURCE = `
8
- const state = new Int32Array(data.state)
9
- const interrupt = new Uint8Array(data.interrupt)
10
- Atomics.wait(state, 0, ${ARMED}, data.ms)
11
- if (Atomics.compareExchange(state, 0, ${ARMED}, ${FIRED}) === ${ARMED}) {
12
- do Atomics.store(interrupt, 0, ${SIGINT})
13
- while (Atomics.wait(state, 0, ${FIRED}, ${RESEND_MS}) === 'timed-out')
14
- }
15
- `;
1
+ import { fileURLToPath } from 'url';
2
+ import { Worker } from 'worker_threads';
3
+ import { errorMessage } from '../../../../guards.js';
4
+ import { hlog } from '../../../../log.js';
5
+ import { DISARMED, FIRED } from './deadline-state.js';
16
6
  export function startInterruptTimer(ms) {
17
7
  const state = new Int32Array(new SharedArrayBuffer(4));
18
8
  const interrupt = new Uint8Array(new SharedArrayBuffer(1));
19
- const reap = spawnInlineThread(SOURCE, { state: state.buffer, interrupt: interrupt.buffer, ms });
9
+ const workerData = { state: state.buffer, interrupt: interrupt.buffer, ms };
10
+ const entry = fileURLToPath(import.meta.resolve('#pyodide-interrupt-deadline'));
11
+ new Worker(entry, { workerData })
12
+ .on('error', (error) => hlog('python: interrupt deadline failed', { err: errorMessage(error) }))
13
+ .unref();
20
14
  return {
21
15
  interrupt,
22
16
  disarm() {
23
17
  const fired = Atomics.exchange(state, 0, DISARMED) === FIRED;
24
18
  Atomics.notify(state, 0);
25
- reap();
26
19
  return fired;
27
20
  }
28
21
  };
@@ -1,14 +1,8 @@
1
- var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
- if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
- return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
- return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
- });
6
- }
7
- return path;
8
- };
9
1
  import b4a from 'b4a';
10
2
  import { constants as fsConstants } from 'fs';
3
+ import path from 'path';
11
4
  import process from 'process';
5
+ import { fileURLToPath } from 'url';
12
6
  import { createGuardedFetch } from './guarded-fetch.js';
13
7
  import { seedPackageCache } from './wheels.js';
14
8
  class B4aTextDecoder {
@@ -95,15 +89,25 @@ export function pyodideLoadOptions() {
95
89
  }
96
90
  return { ...silentStdio, packageCacheDir: cacheDir };
97
91
  }
92
+ function pyodideIndexURL() {
93
+ import.meta.asset('pyodide/python_stdlib.zip');
94
+ import.meta.asset('pyodide/pyodide-lock.json');
95
+ return path.dirname(fileURLToPath(import.meta.asset('pyodide/pyodide.asm.wasm'))) + '/';
96
+ }
98
97
  export const defaultLoad = async () => {
99
98
  if (typeof globalThis.Bare !== 'undefined') {
100
99
  await shapeBareGlobals();
101
100
  }
102
- const moduleName = 'pyodide';
103
- const { loadPyodide } = await import(__rewriteRelativeImportExtension(moduleName));
101
+ const { loadPyodide } = await import('pyodide');
102
+ const { default: createPyodideModule } = await import('pyodide/pyodide.asm.mjs');
104
103
  const options = pyodideLoadOptions();
105
104
  if (options.packageCacheDir) {
106
105
  seedPackageCache(options.packageCacheDir);
107
106
  }
108
- return (await loadPyodide(options));
107
+ const indexURL = pyodideIndexURL();
108
+ return (await loadPyodide({
109
+ ...options,
110
+ indexURL,
111
+ createPyodideModule
112
+ }));
109
113
  };
@@ -1,12 +1,11 @@
1
1
  import { AbortController } from 'bare-abort-controller';
2
- import process from 'process';
3
2
  import ScopeLock from 'scope-lock';
4
3
  import { errorMessage } from '../../../../guards.js';
5
4
  import { AbortedError, raceAbort } from '../../../../helpers/race-abort.js';
6
5
  import { hlog } from '../../../../log.js';
7
6
  import { MAX_OUTPUT_CHARS } from '../../runner.js';
8
7
  import { startInterruptTimer } from './interrupt-timer.js';
9
- import { defaultLoad, pyodideLoadOptions } from './loader.js';
8
+ import { pyodideLoadOptions } from './loader.js';
10
9
  import { isPyodideFatal, performRun } from './run-session.js';
11
10
  import { runPythonInWorker } from './worker/client.js';
12
11
  export { pyodideLoadOptions };
@@ -98,11 +97,6 @@ async function runInProcess(load, spec, options) {
98
97
  stop.abort(undefined);
99
98
  }
100
99
  }
101
- function workerAvailable() {
102
- if (process.env.QVAC_PYODIDE_WORKER_ENTRY)
103
- return true;
104
- return !import.meta.url.includes('.bundle/');
105
- }
106
100
  export async function runPython(code, options = {}) {
107
101
  if (options.signal?.aborted)
108
102
  throw new AbortedError();
@@ -126,14 +120,6 @@ export async function runPython(code, options = {}) {
126
120
  askConnect: options.askConnect
127
121
  });
128
122
  }
129
- if (!workerAvailable()) {
130
- hlog('python: worker entry unavailable in this bundle; running in-process');
131
- return runInProcess(defaultLoad, spec, {
132
- timeoutMs,
133
- signal: options.signal,
134
- askConnect: options.askConnect
135
- });
136
- }
137
123
  await runLock.lock();
138
124
  try {
139
125
  if (options.signal?.aborted) {
@@ -12,6 +12,7 @@ export declare function pinned(spec: string): string;
12
12
  export declare function pipMessage(targets: readonly string[] | null, { sandbox }?: {
13
13
  sandbox?: boolean | undefined;
14
14
  }): string;
15
+ export declare function sandboxInstallError(command: string, declared: readonly string[] | undefined): string | null;
15
16
  export interface PipFromSourceHintOptions {
16
17
  readonly packages?: readonly string[] | undefined;
17
18
  readonly sandbox?: boolean;
@@ -242,6 +242,16 @@ export function pipMessage(targets, { sandbox = false } = {}) {
242
242
  const declare = specs ? `packages: ${JSON.stringify(specs)}` : 'the PyPI names in packages';
243
243
  return `pip is not available. Call exec with language "python", ${declare} and the Python source in command. Installing and running are the same one call.`;
244
244
  }
245
+ export function sandboxInstallError(command, declared) {
246
+ const install = pipInstallTargets(command)?.map(pinned) ?? null;
247
+ const packages = [...new Set([...(declared ?? []), ...(install ?? [])])];
248
+ if (packages.length === 0 || planVendoredInstall(packages)) {
249
+ return null;
250
+ }
251
+ return install
252
+ ? pipMessage(install, { sandbox: true })
253
+ : 'sandbox mode: installing packages reaches the network';
254
+ }
245
255
  const PIP_CALL = /\b(?:subprocess\.\w+|os\.(?:system|popen)|Popen|check_(?:output|call))\s*\(/;
246
256
  const STRING_LITERAL = /"([^"\n]*)"|'([^'\n]*)'/g;
247
257
  function pipLineTargets(line) {
@@ -1279,8 +1289,9 @@ export function createPythonAdapter(disabled, loadPyodide, network = false) {
1279
1289
  const packages = install
1280
1290
  ? [...new Set([...(prepared.packages ?? []), ...install])]
1281
1291
  : prepared.packages;
1282
- if (sandbox && install && !planVendoredInstall(packages ?? [])) {
1283
- return fail(pipMessage(install, { sandbox: true }));
1292
+ const refusal = sandbox ? sandboxInstallError(command, prepared.packages) : null;
1293
+ if (refusal) {
1294
+ return fail(refusal);
1284
1295
  }
1285
1296
  const result = await runPython(install ? 'pass' : source, {
1286
1297
  timeoutMs: options.timeoutMs,
@@ -28,8 +28,8 @@ interface WheelFs {
28
28
  exists: boolean;
29
29
  };
30
30
  }
31
- export declare function wheelsDir(): string | undefined;
32
- export declare function vendoredManifest(dir?: string | undefined): WheelManifest | null;
31
+ export declare function wheelsDir(): string;
32
+ export declare function vendoredManifest(dir?: string): WheelManifest | null;
33
33
  export declare function normalizeDistribution(name: string): string;
34
34
  export interface VendoredOptions {
35
35
  readonly dir?: string | undefined;
@@ -38,5 +38,5 @@ export interface VendoredOptions {
38
38
  export declare function vendoredSpec(distribution: string, opts?: VendoredOptions): string | null;
39
39
  export declare function planVendoredInstall(specs: readonly string[], opts?: VendoredOptions): VendoredInstall | null;
40
40
  export declare function stageVendoredWheels(target: WheelFs, install: VendoredInstall): string[];
41
- export declare function seedPackageCache(cacheDir: string, dir?: string | undefined): void;
41
+ export declare function seedPackageCache(cacheDir: string, dir?: string): void;
42
42
  export {};
@@ -16,20 +16,9 @@ const ManifestSchema = z.object({
16
16
  });
17
17
  const STAGE_DIR = '/qvac-wheels';
18
18
  export function wheelsDir() {
19
- const fromHost = process.env.QVAC_PYTHON_WHEELS_DIR;
20
- if (fromHost) {
21
- return fromHost;
22
- }
23
- const root = new URL('../../../../../', import.meta.url);
24
- const source = new URL(root.pathname.endsWith('/dist-lib/') ? '../wheels' : 'wheels', root);
25
- if (source.href.includes('.bundle/')) {
26
- return undefined;
27
- }
28
- return fileURLToPath(source.href);
19
+ return process.env.QVAC_PYTHON_WHEELS_DIR || fileURLToPath(import.meta.asset('#pyodide-wheels'));
29
20
  }
30
21
  export function vendoredManifest(dir = wheelsDir()) {
31
- if (!dir)
32
- return null;
33
22
  const file = `${dir}/manifest.json`;
34
23
  if (!fs.existsSync(file)) {
35
24
  hlog('python: no vendored wheels', { dir });
@@ -70,7 +59,7 @@ export function vendoredSpec(distribution, opts = {}) {
70
59
  export function planVendoredInstall(specs, opts = {}) {
71
60
  const dir = opts.dir ?? wheelsDir();
72
61
  const manifest = opts.manifest === undefined ? vendoredManifest(dir) : opts.manifest;
73
- if (!manifest || !dir || specs.length === 0) {
62
+ if (!manifest || specs.length === 0) {
74
63
  return null;
75
64
  }
76
65
  const wheels = new Set();
@@ -108,7 +97,7 @@ export function stageVendoredWheels(target, install) {
108
97
  }
109
98
  export function seedPackageCache(cacheDir, dir = wheelsDir()) {
110
99
  const manifest = vendoredManifest(dir);
111
- if (!manifest || !dir) {
100
+ if (!manifest) {
112
101
  return;
113
102
  }
114
103
  fs.mkdirSync(cacheDir, { recursive: true });