@qvac/harness 0.7.1 → 0.7.3

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,52 @@ While the major version is `0`, a breaking change bumps the minor version.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.7.3] - 2026-10-01
12
+
13
+ ### Added
14
+
15
+ - A tool result may carry `answer: true` beside its `content` to end the turn
16
+ with that content as the reply, without another round through the model.
17
+ The `tool-result` event still fires. `weather_lookup` uses it for an
18
+ ambiguous or unknown place: the question that lists the namesakes, or the
19
+ not-found line, is the reply.
20
+
21
+ ### Fixed
22
+
23
+ - `weather_lookup` finds the place before it asks for the weather. It looks the
24
+ city up on Open-Meteo's geocoder, `geocoding-api.open-meteo.com`, a new
25
+ outbound host, and asks wttr.in for the forecast at the coordinates of the
26
+ place it picked, which leads the report. A bare "Nassau" now reports Nassau,
27
+ Bahamas. It used to ask wttr.in by name, whose own geocoder put it in
28
+ Westbury, New York. A name that places of a similar size share, such as
29
+ Springfield, gets a question listing up to four of them instead of a
30
+ forecast: any namesake with a tenth of the largest one's population counts.
31
+ A country or state in the request picks among them. When the geocoder is
32
+ blocked, slow or failing, or knows no such place, the lookup asks wttr.in by
33
+ name, as before. The resource label names both hosts.
34
+
35
+ ## [0.7.2] - 2026-10-01
36
+
37
+ ### Added
38
+
39
+ - `web_fetch` attaches a plain-text file (.txt, .csv, .json…) to the chat
40
+ besides returning its text, so the user can open or save it and `exec` can
41
+ stage it to read past the returned text. The result carries its
42
+ `attachmentId`, `fileName` and `byteLength`. A body cut at the fetch cap is
43
+ not attached, and a host that cannot store files gets the text alone, as
44
+ with page images. (#147, #148)
45
+
46
+ ### Changed
47
+
48
+ - Speech sounds cleaner on desktops. Devices on the `mid` profile and up now
49
+ speak with Supertonic 3 FP16 (`Supertonic3-FP16`, 207 MB) instead of Q8_0
50
+ (127 MB), at 16 inference steps instead of 5, which removes the artifacts
51
+ the 5-step output had. Synthesis takes about 2.7× as long, still around 7×
52
+ faster than real time on an M-series Mac. Phones (`low`) and `tiny` devices
53
+ keep Q4_0 at 5 steps. A host that prefetches `infraModelsFor` downloads the
54
+ FP16 model once; `Supertonic3-Q8_0` stays in the catalog, and now also runs
55
+ at 16 steps when a host asks for it by name. (#149)
56
+
11
57
  ## [0.7.1] - 2026-09-30
12
58
 
13
59
  ### Added
@@ -1019,7 +1065,9 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1019
1065
  - GPU stats are read through `bare-gpu-info`, so every dependency installs from
1020
1066
  the public npm registry. (#12)
1021
1067
 
1022
- [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.7.1...HEAD
1068
+ [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.7.3...HEAD
1069
+ [0.7.3]: https://github.com/tetherto/qvac-harness/compare/v0.7.2...v0.7.3
1070
+ [0.7.2]: https://github.com/tetherto/qvac-harness/compare/v0.7.1...v0.7.2
1023
1071
  [0.7.1]: https://github.com/tetherto/qvac-harness/compare/v0.7.0...v0.7.1
1024
1072
  [0.7.0]: https://github.com/tetherto/qvac-harness/compare/v0.6.0...v0.7.0
1025
1073
  [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
  }
@@ -232,6 +232,15 @@ function callSucceeded(result, outcome) {
232
232
  const exitCode = isRecord(result) ? result.exitCode : undefined;
233
233
  return typeof exitCode !== 'number' || exitCode === 0;
234
234
  }
235
+ function isAnswerResult(result) {
236
+ return (isRecord(result) &&
237
+ result.answer === true &&
238
+ typeof result.content === 'string' &&
239
+ result.content.trim().length > 0);
240
+ }
241
+ function answerOf(result) {
242
+ return isAnswerResult(result) ? result.content : undefined;
243
+ }
235
244
  function retryHints(result) {
236
245
  if (!result || typeof result !== 'object' || Array.isArray(result))
237
246
  return {};
@@ -1038,6 +1047,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1038
1047
  ];
1039
1048
  let cappedCalls = 0;
1040
1049
  let progressed = false;
1050
+ let answer;
1041
1051
  runOutcomes.beginRound();
1042
1052
  for (const { call, tool, args, rawCall, rejection, intent } of resolved) {
1043
1053
  if (signal?.aborted)
@@ -1144,6 +1154,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1144
1154
  }
1145
1155
  else if (execution.executed && callSucceeded(output, executionOutcome)) {
1146
1156
  progressed = true;
1157
+ answer ??= answerOf(output);
1147
1158
  }
1148
1159
  runOutcomes.record(output, executionOutcome);
1149
1160
  if (searchCapped) {
@@ -1197,6 +1208,13 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1197
1208
  failed.clear();
1198
1209
  blockedRepeats = 0;
1199
1210
  }
1211
+ if (answer !== undefined) {
1212
+ yield { type: 'content', text: answer };
1213
+ history.push({ role: 'assistant', content: answer });
1214
+ said = answer;
1215
+ outcome = 'answered';
1216
+ break;
1217
+ }
1200
1218
  history.push({ role: 'user', content: TOOL_CONTINUATION_NUDGE });
1201
1219
  if (blockedRepeats >= MAX_BLOCKED_REPEATS) {
1202
1220
  break;
@@ -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))
@@ -0,0 +1,26 @@
1
+ import { type AbortSignal as BareAbortSignal } from 'bare-abort-controller';
2
+ import type { FetchFn } from '../http-request.ts';
3
+ import { type HostResolver } from '../url-guard.ts';
4
+ import { type ResolvedPlace } from './place.ts';
5
+ export declare const GEOCODER_HOST = "geocoding-api.open-meteo.com";
6
+ export declare const GEOCODE_TIMEOUT_MS = 5000;
7
+ export interface Candidate extends ResolvedPlace {
8
+ readonly latitude: number;
9
+ readonly longitude: number;
10
+ readonly population: number | undefined;
11
+ }
12
+ export interface Chosen {
13
+ readonly place: Candidate;
14
+ readonly verified: boolean;
15
+ }
16
+ export type Choice = Chosen | {
17
+ readonly contenders: readonly Candidate[];
18
+ };
19
+ export interface GeocodeRequest {
20
+ readonly fetch: FetchFn;
21
+ readonly resolveHost: HostResolver;
22
+ readonly signal: BareAbortSignal;
23
+ readonly timeoutMs: number;
24
+ }
25
+ export declare function geocode(city: string, lookup: GeocodeRequest): Promise<Candidate[]>;
26
+ export declare function choose(city: string, qualifier: string | null, candidates: readonly Candidate[]): Choice | null;
@@ -0,0 +1,110 @@
1
+ import { AbortController } from 'bare-abort-controller';
2
+ import { z } from 'zod';
3
+ import { errorMessage } from '../../guards.js';
4
+ import { hlog } from '../../log.js';
5
+ import { resolveOutboundUrl } from '../url-guard.js';
6
+ import { checkQualifier, describePlace, fold } from './place.js';
7
+ export const GEOCODER_HOST = 'geocoding-api.open-meteo.com';
8
+ const MAX_BODY_BYTES = 200_000;
9
+ export const GEOCODE_TIMEOUT_MS = 5_000;
10
+ const CONTENDER_RATIO = 10;
11
+ const MAX_LISTED = 4;
12
+ const GeocodeSchema = z.object({
13
+ results: z
14
+ .array(z.object({
15
+ name: z.string(),
16
+ latitude: z.number(),
17
+ longitude: z.number(),
18
+ country: z.string().optional(),
19
+ admin1: z.string().optional(),
20
+ population: z.number().optional()
21
+ }))
22
+ .optional()
23
+ });
24
+ export async function geocode(city, lookup) {
25
+ const controller = new AbortController();
26
+ const timeout = setTimeout(() => controller.abort(undefined), lookup.timeoutMs);
27
+ const onLookupAbort = () => controller.abort(undefined);
28
+ lookup.signal.addEventListener('abort', onLookupAbort);
29
+ try {
30
+ return await search(city, lookup, controller.signal);
31
+ }
32
+ catch (err) {
33
+ if (lookup.signal.aborted) {
34
+ throw err;
35
+ }
36
+ hlog('warn: weather geocoder failed, asking wttr.in by name', { err: errorMessage(err) });
37
+ return [];
38
+ }
39
+ finally {
40
+ clearTimeout(timeout);
41
+ lookup.signal.removeEventListener('abort', onLookupAbort);
42
+ }
43
+ }
44
+ async function search(city, { fetch, resolveHost }, signal) {
45
+ const query = `name=${encodeURIComponent(city)}&count=10&language=en&format=json`;
46
+ const url = new URL(`https://${GEOCODER_HOST}/v1/search?${query}`);
47
+ const check = await resolveOutboundUrl(url, resolveHost, signal);
48
+ if (check.blocked) {
49
+ throw new Error(check.blocked);
50
+ }
51
+ const response = await fetch(url, {
52
+ method: 'GET',
53
+ headers: { Accept: 'application/json' },
54
+ signal,
55
+ redirect: 'manual',
56
+ validatedAddresses: check.addresses,
57
+ maxBytes: MAX_BODY_BYTES
58
+ });
59
+ if (response.status !== 200) {
60
+ throw new Error(`geocoder answered HTTP ${response.status}`);
61
+ }
62
+ const { results = [] } = GeocodeSchema.parse(JSON.parse(await response.text()));
63
+ return results.map((result) => ({
64
+ area: result.name,
65
+ region: result.admin1 ?? '',
66
+ country: result.country ?? '',
67
+ latitude: result.latitude,
68
+ longitude: result.longitude,
69
+ population: result.population
70
+ }));
71
+ }
72
+ export function choose(city, qualifier, candidates) {
73
+ const exact = candidates.filter((candidate) => fold(candidate.area) === fold(city));
74
+ const top = byPopulation(exact)[0] ?? candidates[0];
75
+ if (top === undefined) {
76
+ return null;
77
+ }
78
+ if (qualifier !== null) {
79
+ return qualified(qualifier, { exact, candidates, top });
80
+ }
81
+ const contenders = contendersOf(exact);
82
+ if (contenders.length > 1) {
83
+ return { contenders };
84
+ }
85
+ return { place: top, verified: exact.includes(top) };
86
+ }
87
+ function qualified(qualifier, { exact, candidates, top }) {
88
+ const named = (pool) => pool.find((candidate) => checkQualifier(qualifier, candidate) === 'match');
89
+ const namesake = named(byPopulation(exact));
90
+ if (namesake !== undefined) {
91
+ return { place: namesake, verified: true };
92
+ }
93
+ return { place: named(candidates) ?? top, verified: false };
94
+ }
95
+ function contendersOf(exact) {
96
+ const pool = byPopulation(exact);
97
+ const top = pool[0]?.population;
98
+ if (top === undefined) {
99
+ return [];
100
+ }
101
+ const lines = pool.map(describePlace);
102
+ return pool
103
+ .filter((candidate, index) => candidate.population !== undefined &&
104
+ candidate.population * CONTENDER_RATIO >= top &&
105
+ lines.indexOf(describePlace(candidate)) === index)
106
+ .slice(0, MAX_LISTED);
107
+ }
108
+ function byPopulation(candidates) {
109
+ return [...candidates].sort((a, b) => (b.population ?? -1) - (a.population ?? -1));
110
+ }
@@ -17,6 +17,8 @@ const QUALIFIER_ALIASES = [
17
17
  ['united arab emirates', 'united arab emirates'],
18
18
  ['uae', 'united arab emirates'],
19
19
  ['holland', 'netherlands'],
20
+ ['schweiz', 'switzerland'],
21
+ ['suisse', 'switzerland'],
20
22
  ['deutschland', 'germany'],
21
23
  ['espana', 'spain'],
22
24
  ['italia', 'italy']
@@ -103,7 +105,9 @@ export function fold(value) {
103
105
  .trim();
104
106
  }
105
107
  export function describePlace(place) {
106
- return [place.area, place.region, place.country].filter((part) => part.length > 0).join(', ');
108
+ return [place.area, place.region, place.country]
109
+ .filter((part, index, parts) => part.length > 0 && parts.indexOf(part) === index)
110
+ .join(', ');
107
111
  }
108
112
  export function qualifierOf(location) {
109
113
  const comma = location.indexOf(',');
@@ -4,5 +4,6 @@ import { type HostResolver } from './url-guard.ts';
4
4
  export interface WeatherLookupToolOptions {
5
5
  fetch?: FetchFn;
6
6
  resolveHost?: HostResolver;
7
+ geocodeTimeoutMs?: number;
7
8
  }
8
- export declare function createWeatherLookupTool({ fetch: fetchImpl, resolveHost }?: WeatherLookupToolOptions): ToolDefinition;
9
+ export declare function createWeatherLookupTool({ fetch, resolveHost, geocodeTimeoutMs }?: WeatherLookupToolOptions): ToolDefinition;
@@ -4,12 +4,13 @@ import { lookupHost } from '../dns-lookup.js';
4
4
  import { errorMessage } from '../guards.js';
5
5
  import { isRedirectStatus } from './http-request/request.js';
6
6
  import { resolveOutboundUrl } from './url-guard.js';
7
+ import { choose, geocode, GEOCODE_TIMEOUT_MS, GEOCODER_HOST } from './weather-lookup/geocode.js';
7
8
  import { checkQualifier, cityMatches, describePlace, qualifierOf } from './weather-lookup/place.js';
8
9
  import { placeOf, renderReport, WeatherJsonSchema } from './weather-lookup/report.js';
9
10
  const WEATHER_HOST = 'wttr.in';
10
11
  const LOOKUP_TIMEOUT_MS = 15_000;
11
12
  const MAX_BODY_BYTES = 400_000;
12
- export function createWeatherLookupTool({ fetch: fetchImpl, resolveHost = lookupHost } = {}) {
13
+ export function createWeatherLookupTool({ fetch = noRedirectFetch, resolveHost = lookupHost, geocodeTimeoutMs = GEOCODE_TIMEOUT_MS } = {}) {
13
14
  return {
14
15
  name: 'weather_lookup',
15
16
  toolset: 'http-request',
@@ -17,7 +18,8 @@ export function createWeatherLookupTool({ fetch: fetchImpl, resolveHost = lookup
17
18
  autoApprove: true,
18
19
  description: 'Look up the current weather and a three-day forecast for a place, via wttr.in. ' +
19
20
  'Returns the place the service resolved on its first line, then the conditions now and ' +
20
- 'one line per day. Use it for any question about weather, temperature or forecast.',
21
+ 'one line per day. Use it for any question about weather, temperature or forecast. ' +
22
+ 'When a name matches several places it answers with a question listing them.',
21
23
  parameters: {
22
24
  type: 'object',
23
25
  properties: {
@@ -30,93 +32,122 @@ export function createWeatherLookupTool({ fetch: fetchImpl, resolveHost = lookup
30
32
  required: ['location']
31
33
  },
32
34
  effect: () => 'read',
33
- resource: () => ({ kind: 'host', label: WEATHER_HOST }),
34
- async execute(args, ctx) {
35
- const raw = args.location;
36
- if (typeof raw !== 'string' || raw.trim().length === 0) {
37
- return { error: 'location must be a non-empty string' };
38
- }
39
- const location = raw.trim();
40
- if (ctx?.signal?.aborted) {
41
- return { error: 'weather_lookup cancelled' };
42
- }
43
- const url = new URL(`https://${WEATHER_HOST}/${encodeURIComponent(location)}?format=j1`);
44
- const fetch = fetchImpl ?? noRedirectFetch;
45
- const controller = new AbortController();
46
- const timeout = setTimeout(() => controller.abort(undefined), LOOKUP_TIMEOUT_MS);
47
- const onCallerAbort = () => controller.abort(undefined);
48
- ctx?.signal?.addEventListener('abort', onCallerAbort);
49
- try {
50
- const check = await resolveOutboundUrl(url, resolveHost, ctx?.signal);
51
- if (check.blocked) {
52
- return { error: check.blocked };
53
- }
54
- const response = await fetch(url, {
55
- method: 'GET',
56
- headers: { Accept: 'application/json' },
57
- signal: controller.signal,
58
- redirect: 'manual',
59
- validatedAddresses: check.addresses,
60
- maxBytes: MAX_BODY_BYTES
61
- });
62
- if (isRedirectStatus(response.status)) {
63
- return { error: `${WEATHER_HOST} redirected the lookup for "${location}"` };
64
- }
65
- const body = await response.text();
66
- if (/location not found/i.test(body)) {
67
- return { content: notFound(location) };
68
- }
69
- if (response.status !== 200) {
70
- return { error: unavailable(`it answered HTTP ${response.status}`), code: 'transport' };
71
- }
72
- let json;
73
- try {
74
- json = WeatherJsonSchema.parse(JSON.parse(body));
75
- }
76
- catch {
77
- return { error: unavailable('it did not return a forecast') };
78
- }
79
- const place = placeOf(json);
80
- if (place === null) {
81
- return { content: notFound(location) };
82
- }
83
- const qualifier = qualifierOf(location);
84
- const verdict = qualifier === null ? null : checkQualifier(qualifier, place);
85
- const named = cityMatches(location, place);
86
- if (verdict === 'contradiction' && !named) {
87
- return {
88
- content: `No such place: "${location}". ${WEATHER_HOST} resolved it to ` +
89
- `${describePlace(place)}, which is somewhere else.`
90
- };
91
- }
92
- const verified = qualifier === null ? named : verdict === 'match';
93
- return { content: renderReport(json, place, { asked: location, verified }) };
94
- }
95
- catch (err) {
96
- if (ctx?.signal?.aborted) {
97
- return { error: 'weather_lookup cancelled' };
98
- }
99
- if (controller.signal.aborted) {
100
- return {
101
- error: `weather_lookup timed out after ${LOOKUP_TIMEOUT_MS} ms`,
102
- code: 'timeout'
103
- };
104
- }
105
- return {
106
- error: unavailable(errorMessage(err)),
107
- code: 'transport'
108
- };
109
- }
110
- finally {
111
- clearTimeout(timeout);
112
- ctx?.signal?.removeEventListener('abort', onCallerAbort);
113
- }
114
- }
35
+ resource: () => ({ kind: 'host', label: `${GEOCODER_HOST}, ${WEATHER_HOST}` }),
36
+ execute: (args, ctx) => execute(args.location, ctx?.signal, { fetch, resolveHost, timeoutMs: geocodeTimeoutMs })
115
37
  };
116
38
  }
39
+ async function execute(raw, callerSignal, deps) {
40
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
41
+ return { error: 'location must be a non-empty string' };
42
+ }
43
+ const location = raw.trim();
44
+ if (callerSignal?.aborted) {
45
+ return { error: 'weather_lookup cancelled' };
46
+ }
47
+ const controller = new AbortController();
48
+ const timeout = setTimeout(() => controller.abort(undefined), LOOKUP_TIMEOUT_MS);
49
+ const onCallerAbort = () => controller.abort(undefined);
50
+ callerSignal?.addEventListener('abort', onCallerAbort);
51
+ try {
52
+ return await lookup(location, { ...deps, signal: controller.signal });
53
+ }
54
+ catch (err) {
55
+ return failure(err, controller.signal, callerSignal);
56
+ }
57
+ finally {
58
+ clearTimeout(timeout);
59
+ callerSignal?.removeEventListener('abort', onCallerAbort);
60
+ }
61
+ }
62
+ async function lookup(location, request) {
63
+ const city = (location.split(',')[0] ?? '').trim();
64
+ const candidates = city.length === 0 ? [] : await geocode(city, request);
65
+ const choice = choose(city, qualifierOf(location), candidates);
66
+ if (choice === null) {
67
+ return byName(location, request);
68
+ }
69
+ if ('contenders' in choice) {
70
+ return reply(which(city, choice.contenders));
71
+ }
72
+ return byCoordinates(location, choice, request);
73
+ }
74
+ async function byCoordinates(location, { place, verified }, request) {
75
+ const fetched = await forecast(`${place.latitude},${place.longitude}`, location, request);
76
+ if (!('json' in fetched)) {
77
+ return fetched;
78
+ }
79
+ return { content: renderReport(fetched.json, place, { asked: location, verified }) };
80
+ }
81
+ async function byName(location, request) {
82
+ const fetched = await forecast(encodeURIComponent(location), location, request);
83
+ if (!('json' in fetched)) {
84
+ return fetched;
85
+ }
86
+ const place = placeOf(fetched.json);
87
+ if (place === null) {
88
+ return reply(notFound(location));
89
+ }
90
+ const qualifier = qualifierOf(location);
91
+ const verdict = qualifier === null ? null : checkQualifier(qualifier, place);
92
+ const named = cityMatches(location, place);
93
+ if (verdict === 'contradiction' && !named) {
94
+ return reply(`No such place: "${location}". ${WEATHER_HOST} resolved it to ` +
95
+ `${describePlace(place)}, which is somewhere else.`);
96
+ }
97
+ const verified = qualifier === null ? named : verdict === 'match';
98
+ return { content: renderReport(fetched.json, place, { asked: location, verified }) };
99
+ }
100
+ async function forecast(path, location, request) {
101
+ const url = new URL(`https://${WEATHER_HOST}/${path}?format=j1`);
102
+ const check = await resolveOutboundUrl(url, request.resolveHost, request.signal);
103
+ if (check.blocked) {
104
+ return { error: check.blocked };
105
+ }
106
+ const response = await request.fetch(url, {
107
+ method: 'GET',
108
+ headers: { Accept: 'application/json' },
109
+ signal: request.signal,
110
+ redirect: 'manual',
111
+ validatedAddresses: check.addresses,
112
+ maxBytes: MAX_BODY_BYTES
113
+ });
114
+ if (isRedirectStatus(response.status)) {
115
+ return { error: `${WEATHER_HOST} redirected the lookup for "${location}"` };
116
+ }
117
+ return readForecast(response.status, await response.text(), location);
118
+ }
119
+ function readForecast(status, body, location) {
120
+ if (/location not found/i.test(body)) {
121
+ return reply(notFound(location));
122
+ }
123
+ if (status !== 200) {
124
+ return { error: unavailable(`it answered HTTP ${status}`), code: 'transport' };
125
+ }
126
+ try {
127
+ return { json: WeatherJsonSchema.parse(JSON.parse(body)) };
128
+ }
129
+ catch {
130
+ return { error: unavailable('it did not return a forecast') };
131
+ }
132
+ }
133
+ function failure(err, signal, callerSignal) {
134
+ if (callerSignal?.aborted) {
135
+ return { error: 'weather_lookup cancelled' };
136
+ }
137
+ if (signal.aborted) {
138
+ return { error: `weather_lookup timed out after ${LOOKUP_TIMEOUT_MS} ms`, code: 'timeout' };
139
+ }
140
+ return { error: unavailable(errorMessage(err)), code: 'transport' };
141
+ }
117
142
  function unavailable(reason) {
118
143
  return `The weather service could not be reached: ${reason}.`;
119
144
  }
145
+ function reply(content) {
146
+ return { content, answer: true };
147
+ }
120
148
  function notFound(location) {
121
149
  return `No such place: "${location}". ${WEATHER_HOST} does not know it.`;
122
150
  }
151
+ function which(city, places) {
152
+ return `Which ${city}: ${places.map(describePlace).join('; ')}?`;
153
+ }
@@ -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.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bare": ">=1.34.0"
@@ -606,6 +606,10 @@
606
606
  "types": "./dist-lib/lib/tools/weather-lookup.d.ts",
607
607
  "default": "./dist-lib/lib/tools/weather-lookup.js"
608
608
  },
609
+ "./lib/tools/weather-lookup/geocode.ts": {
610
+ "types": "./dist-lib/lib/tools/weather-lookup/geocode.d.ts",
611
+ "default": "./dist-lib/lib/tools/weather-lookup/geocode.js"
612
+ },
609
613
  "./lib/tools/weather-lookup/place.ts": {
610
614
  "types": "./dist-lib/lib/tools/weather-lookup/place.d.ts",
611
615
  "default": "./dist-lib/lib/tools/weather-lookup/place.js"