@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 +49 -1
- package/dist-lib/index.js +2 -1
- package/dist-lib/lib/capabilities/completion.js +18 -0
- package/dist-lib/lib/models/model-catalog.d.ts +3 -0
- package/dist-lib/lib/models/model-catalog.js +18 -2
- package/dist-lib/lib/models/model-pool.d.ts +2 -1
- package/dist-lib/lib/models/model-pool.js +4 -2
- package/dist-lib/lib/models/tts-model-default.js +3 -3
- package/dist-lib/lib/tools/weather-lookup/geocode.d.ts +26 -0
- package/dist-lib/lib/tools/weather-lookup/geocode.js +110 -0
- package/dist-lib/lib/tools/weather-lookup/place.js +5 -1
- package/dist-lib/lib/tools/weather-lookup.d.ts +2 -1
- package/dist-lib/lib/tools/weather-lookup.js +115 -84
- package/dist-lib/lib/tools/web-fetch.js +39 -4
- package/package.json +5 -1
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.
|
|
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
|
-
{
|
|
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 ? { ...
|
|
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-
|
|
5
|
-
high: 'Supertonic3-
|
|
6
|
-
xhigh: 'Supertonic3-
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|