@qvac/harness 0.7.1 → 0.7.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
@@ -8,6 +8,28 @@ While the major version is `0`, a breaking change bumps the minor version.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.7.2] - 2026-10-01
12
+
13
+ ### Added
14
+
15
+ - `web_fetch` attaches a plain-text file (.txt, .csv, .json…) to the chat
16
+ besides returning its text, so the user can open or save it and `exec` can
17
+ stage it to read past the returned text. The result carries its
18
+ `attachmentId`, `fileName` and `byteLength`. A body cut at the fetch cap is
19
+ not attached, and a host that cannot store files gets the text alone, as
20
+ with page images. (#147, #148)
21
+
22
+ ### Changed
23
+
24
+ - Speech sounds cleaner on desktops. Devices on the `mid` profile and up now
25
+ speak with Supertonic 3 FP16 (`Supertonic3-FP16`, 207 MB) instead of Q8_0
26
+ (127 MB), at 16 inference steps instead of 5, which removes the artifacts
27
+ the 5-step output had. Synthesis takes about 2.7× as long, still around 7×
28
+ faster than real time on an M-series Mac. Phones (`low`) and `tiny` devices
29
+ keep Q4_0 at 5 steps. A host that prefetches `infraModelsFor` downloads the
30
+ FP16 model once; `Supertonic3-Q8_0` stays in the catalog, and now also runs
31
+ at 16 steps when a host asks for it by name. (#149)
32
+
11
33
  ## [0.7.1] - 2026-09-30
12
34
 
13
35
  ### Added
@@ -1019,7 +1041,8 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1019
1041
  - GPU stats are read through `bare-gpu-info`, so every dependency installs from
1020
1042
  the public npm registry. (#12)
1021
1043
 
1022
- [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.7.1...HEAD
1044
+ [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.7.2...HEAD
1045
+ [0.7.2]: https://github.com/tetherto/qvac-harness/compare/v0.7.1...v0.7.2
1023
1046
  [0.7.1]: https://github.com/tetherto/qvac-harness/compare/v0.7.0...v0.7.1
1024
1047
  [0.7.0]: https://github.com/tetherto/qvac-harness/compare/v0.6.0...v0.7.0
1025
1048
  [0.6.0]: https://github.com/tetherto/qvac-harness/compare/v0.5.0...v0.6.0
package/dist-lib/index.js CHANGED
@@ -21,7 +21,7 @@ import { buildWavBuffer } from './lib/wav-encode.js';
21
21
  import { createVoiceLane, packSamples, speakOnce, speakStreamOnce, transcribeOnce, transcribeStreaming, SUPERTONIC_SAMPLE_RATE } from './lib/capabilities/voice.js';
22
22
  import { pushQueue } from './lib/queue.js';
23
23
  import { AbortedError, raceAbort } from './lib/helpers/race-abort.js';
24
- import { audioGenDownloadMetadata, customProjectionMap, defaultProjectionMap, defaultTtsFor, defaultVoiceModel, diffusionDownloadMetadata, curatedModelAsset, infraModelsFor, modelAddonOf, modelNameToCapabilityProfile, musicModelSet, residentSrc, setCustomModels } from './lib/models/model-catalog.js';
24
+ import { audioGenDownloadMetadata, categoryModelConfig, customProjectionMap, defaultProjectionMap, defaultTtsFor, defaultVoiceModel, diffusionDownloadMetadata, curatedModelAsset, infraModelsFor, modelAddonOf, modelNameToCapabilityProfile, musicModelSet, residentSrc, setCustomModels } from './lib/models/model-catalog.js';
25
25
  import { createModelStatusCache, customModelRows, invalidateModelStatusCache, mergeCustomRows, listModels } from './lib/models/model-list.js';
26
26
  import { deleteModelFiles, summarizeDelete } from './lib/models/model-delete.js';
27
27
  import { createEmbedPool, createLoadTracker, createModelPool, createVoicePool, awaitModelCapacity, evictIdle, evictIdleWhere, evictModel, evictModelWithin, isResident, leaseResidentModel, leaseCount, loadedModels, loadingModels, settledModels, modelKey, modelOf, releaseModel, resolveLoadOptions, progressState } from './lib/models/model-pool.js';
@@ -662,6 +662,7 @@ export class Harness extends ReadyResource {
662
662
  modelType: 'tts-ggml',
663
663
  addon: 'tts',
664
664
  modelConfig: TTS_MODEL_CONFIG,
665
+ modelConfigFor: categoryModelConfig,
665
666
  loads: this._loads
666
667
  });
667
668
  }
@@ -1,6 +1,7 @@
1
1
  import type { ModelRegistryEntry, ToolDialect } from '@qvac/sdk';
2
2
  import type { ImageModelSet } from '../tools/generate-image.ts';
3
3
  import type { MusicModelStages } from '../tools/generate-music.ts';
4
+ import type { ModelSrc } from './context.ts';
4
5
  import type { ModelAddon } from './model-source.ts';
5
6
  export interface SdkModelAsset extends ModelRegistryEntry {
6
7
  readonly src: string;
@@ -22,8 +23,10 @@ export interface CategoryDefault {
22
23
  readonly name: string;
23
24
  readonly category: ModelCategory;
24
25
  readonly asset: SdkModelAsset;
26
+ readonly modelConfig?: Readonly<Record<string, unknown>>;
25
27
  }
26
28
  export declare const CATEGORY_DEFAULTS: ReadonlyArray<CategoryDefault>;
29
+ export declare function categoryModelConfig(modelSrc: ModelSrc): Readonly<Record<string, unknown>> | undefined;
27
30
  export declare function defaultVoiceModel(category: ModelCategory): string;
28
31
  export declare function defaultTtsFor(profile: CapabilityProfile): CategoryDefault;
29
32
  export interface CatalogModel {
@@ -1,4 +1,4 @@
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';
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_FP16, 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
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';
@@ -62,9 +62,21 @@ export function defaultProjectionMap() {
62
62
  }
63
63
  return map;
64
64
  }
65
+ const TTS_FULL_STEPS = { ttsNumInferenceSteps: 16 };
65
66
  export const CATEGORY_DEFAULTS = [
66
67
  { name: 'Supertonic3-Q4_0', category: 'tts', asset: TTS_MULTILINGUAL_SUPERTONIC3_Q4_0 },
67
- { name: 'Supertonic3-Q8_0', category: 'tts', asset: TTS_MULTILINGUAL_SUPERTONIC3_Q8_0 },
68
+ {
69
+ name: 'Supertonic3-Q8_0',
70
+ category: 'tts',
71
+ asset: TTS_MULTILINGUAL_SUPERTONIC3_Q8_0,
72
+ modelConfig: TTS_FULL_STEPS
73
+ },
74
+ {
75
+ name: 'Supertonic3-FP16',
76
+ category: 'tts',
77
+ asset: TTS_MULTILINGUAL_SUPERTONIC3_FP16,
78
+ modelConfig: TTS_FULL_STEPS
79
+ },
68
80
  {
69
81
  name: 'Parakeet-TDT-0.6B-v3-Q8_0',
70
82
  category: 'transcription',
@@ -72,6 +84,10 @@ export const CATEGORY_DEFAULTS = [
72
84
  },
73
85
  { name: 'EmbeddingGemma-300M-Q4_0', category: 'embedding', asset: EMBEDDINGGEMMA_300M_Q4_0 }
74
86
  ];
87
+ export function categoryModelConfig(modelSrc) {
88
+ const src = typeof modelSrc === 'string' ? modelSrc : modelSrc.src;
89
+ return CATEGORY_DEFAULTS.find((entry) => entry.asset.src === src)?.modelConfig;
90
+ }
75
91
  export function defaultVoiceModel(category) {
76
92
  const entry = CATEGORY_DEFAULTS.find((candidate) => candidate.category === category);
77
93
  if (!entry) {
@@ -156,8 +156,9 @@ export interface VoicePoolOptions {
156
156
  modelType: string;
157
157
  addon: ModelAddon;
158
158
  modelConfig?: Record<string, unknown> | undefined;
159
+ modelConfigFor?: (modelSrc: ModelSrc) => Readonly<Record<string, unknown>> | undefined;
159
160
  openclCacheDir?: (() => string | undefined) | undefined;
160
161
  loads?: LoadTracker;
161
162
  }
162
- export declare function createVoicePool(loadSdk: LoadVoiceSdk, notify: NotifyFn, { modelType, addon, modelConfig, openclCacheDir, loads }: VoicePoolOptions): ModelPuddle;
163
+ export declare function createVoicePool(loadSdk: LoadVoiceSdk, notify: NotifyFn, { modelType, addon, modelConfig, modelConfigFor, openclCacheDir, loads }: VoicePoolOptions): ModelPuddle;
163
164
  export {};
@@ -392,14 +392,16 @@ export function createEmbedPool(loadSdk, notify, { faults, batchSize = DEFAULT_E
392
392
  ...NO_IDLE_GC
393
393
  });
394
394
  }
395
- export function createVoicePool(loadSdk, notify, { modelType, addon, modelConfig, openclCacheDir, loads = createLoadTracker() }) {
395
+ export function createVoicePool(loadSdk, notify, { modelType, addon, modelConfig, modelConfigFor, openclCacheDir, loads = createLoadTracker() }) {
396
396
  const wantsGpu = modelConfig?.['useGPU'] === true;
397
397
  let gpuRefused = false;
398
398
  const load = async (model) => {
399
399
  const sdk = await loadSdk();
400
400
  const modelSrc = await resolveModelSrc(sdk, model, addon);
401
+ const tuned = modelConfigFor?.(modelSrc);
402
+ const loadConfig = tuned ? { ...modelConfig, ...tuned } : modelConfig;
401
403
  const attempt = () => {
402
- const config = gpuRefused ? { ...modelConfig, useGPU: false } : modelConfig;
404
+ const config = gpuRefused ? { ...loadConfig, useGPU: false } : loadConfig;
403
405
  const openclCache = gpuRefused ? undefined : openclCacheDir?.();
404
406
  return loads.track(sdk.loadModel({
405
407
  modelSrc,
@@ -1,9 +1,9 @@
1
1
  const TTS_MODEL_BY_PROFILE = {
2
2
  tiny: 'Supertonic3-Q4_0',
3
3
  low: 'Supertonic3-Q4_0',
4
- mid: 'Supertonic3-Q8_0',
5
- high: 'Supertonic3-Q8_0',
6
- xhigh: 'Supertonic3-Q8_0'
4
+ mid: 'Supertonic3-FP16',
5
+ high: 'Supertonic3-FP16',
6
+ xhigh: 'Supertonic3-FP16'
7
7
  };
8
8
  export const TTS_MODEL_NAMES = [
9
9
  ...new Set(Object.values(TTS_MODEL_BY_PROFILE))
@@ -32,7 +32,8 @@ export function createWebFetchTool({ fetch: fetchImpl, resolveHost = lookupHost
32
32
  'shares, when a search snippet is not enough to answer. ' +
33
33
  'When the URL serves a file rather than a page — PDF, .docx, .xlsx, .pptx, an image, any ' +
34
34
  'binary — this is also the tool that downloads it: the bytes become a chat attachment and ' +
35
- 'the result carries its attachmentId. It is the only way to get a remote file into the ' +
35
+ 'the result carries its attachmentId. A plain-text file (.txt, .csv, .json…) is attached ' +
36
+ 'too, and its text is also returned. It is the only way to get a remote file into the ' +
36
37
  'chat; exec has no network and http_request truncates binary bodies.',
37
38
  parameters: {
38
39
  type: 'object',
@@ -133,13 +134,22 @@ export function createWebFetchTool({ fetch: fetchImpl, resolveHost = lookupHost
133
134
  const imagesLine = attached.length
134
135
  ? `\n\n[Attached ${attached.length === 1 ? 'image' : 'images'}: ${attached.join(', ')} — shown to the user automatically]`
135
136
  : '';
137
+ const file = isHtml
138
+ ? null
139
+ : await attachTextFile(response, { url: currentUrl, mimeType, attach: ctx?.attach });
140
+ const fileLine = file
141
+ ? `\n\n[Attached the file "${file.fileName}" (${Math.round(file.byteLength / 1024)}KB) — the user can open or save it; stage it with exec as inputs [{ "attachmentId": "${file.id}", "path": "${file.fileName}" }] to read past the text above]`
142
+ : '';
136
143
  const video = isHtml ? pageVideoRef(body, currentUrl) : null;
137
144
  const videoLine = video
138
145
  ? `\n\n[This page's video: ${video.url} — share this link when the user wants to watch it]`
139
146
  : '';
140
147
  const header = page.title ? `${page.title}\nSource: ${url}` : `Source: ${url}`;
141
148
  return {
142
- content: `${header}\n\n${text}${imagesLine}${videoLine}`,
149
+ attachmentId: file?.id,
150
+ fileName: file?.fileName,
151
+ byteLength: file?.byteLength,
152
+ content: `${header}\n\n${text}${fileLine}${imagesLine}${videoLine}`,
143
153
  data: {
144
154
  results: [
145
155
  {
@@ -201,6 +211,30 @@ async function attachDocument(response, { url, mimeType, attach }) {
201
211
  'user to attach a file this call already attached.'
202
212
  };
203
213
  }
214
+ async function attachTextFile(response, { url, mimeType, attach }) {
215
+ if (!attach)
216
+ return null;
217
+ const bytes = await response.bytes?.();
218
+ if (!bytes || bytes.byteLength === 0 || bytes.byteLength >= MAX_PAGE_BYTES) {
219
+ return null;
220
+ }
221
+ const mime = mimeType || 'text/plain';
222
+ try {
223
+ return await attach({
224
+ fileName: attachmentFileName(url, mime),
225
+ mimeType: mime,
226
+ bytes,
227
+ raw: true
228
+ });
229
+ }
230
+ catch {
231
+ return null;
232
+ }
233
+ }
234
+ const TEXT_EXTENSIONS = {
235
+ 'text/plain': 'txt',
236
+ 'text/markdown': 'md'
237
+ };
204
238
  const MIME_EXTENSIONS = {
205
239
  'application/pdf': 'pdf',
206
240
  'application/msword': 'doc',
@@ -234,7 +268,8 @@ function attachmentFileName(url, mimeType) {
234
268
  if (extension && acceptableExtensions(mimeType).has(extension)) {
235
269
  return base;
236
270
  }
237
- if (mimeType === 'application/octet-stream' && /^[a-z][\w-]*$/.test(extension)) {
271
+ const trustsUrl = mimeType === 'application/octet-stream' || !isBinaryMime(mimeType);
272
+ if (trustsUrl && /^[a-z][\w-]*$/.test(extension)) {
238
273
  return base;
239
274
  }
240
275
  return `${base}.${extensionFor(mimeType)}`;
@@ -248,7 +283,7 @@ function acceptableExtensions(mimeType) {
248
283
  return accepted;
249
284
  }
250
285
  function extensionFor(mimeType) {
251
- const known = MIME_EXTENSIONS[mimeType];
286
+ const known = MIME_EXTENSIONS[mimeType] ?? TEXT_EXTENSIONS[mimeType];
252
287
  if (known) {
253
288
  return known;
254
289
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/harness",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bare": ">=1.34.0"