@qvac/harness 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/NOTICE.md +16 -0
  3. package/dist-lib/lib/capabilities/completion.d.ts +1 -0
  4. package/dist-lib/lib/capabilities/completion.js +46 -8
  5. package/dist-lib/lib/capabilities/tool-turns.d.ts +3 -0
  6. package/dist-lib/lib/capabilities/tool-turns.js +7 -0
  7. package/dist-lib/lib/context-window.js +2 -2
  8. package/dist-lib/lib/helpers/run-outcome.d.ts +4 -1
  9. package/dist-lib/lib/helpers/run-outcome.js +14 -8
  10. package/dist-lib/lib/helpers/tool-call-markup.js +4 -4
  11. package/dist-lib/lib/hooks/built-in/arg-repair.js +7 -0
  12. package/dist-lib/lib/office/engine/codes.d.ts +62 -0
  13. package/dist-lib/lib/office/engine/codes.js +61 -0
  14. package/dist-lib/lib/office/engine/docx/document.d.ts +216 -0
  15. package/dist-lib/lib/office/engine/docx/document.js +1152 -0
  16. package/dist-lib/lib/office/engine/docx/index.d.ts +7 -0
  17. package/dist-lib/lib/office/engine/docx/index.js +8 -0
  18. package/dist-lib/lib/office/engine/docx/styles.d.ts +31 -0
  19. package/dist-lib/lib/office/engine/docx/styles.js +173 -0
  20. package/dist-lib/lib/office/engine/docx/template.d.ts +18 -0
  21. package/dist-lib/lib/office/engine/docx/template.js +133 -0
  22. package/dist-lib/lib/office/engine/docx/text.d.ts +55 -0
  23. package/dist-lib/lib/office/engine/docx/text.js +538 -0
  24. package/dist-lib/lib/office/engine/drawingml/chart.d.ts +63 -0
  25. package/dist-lib/lib/office/engine/drawingml/chart.js +539 -0
  26. package/dist-lib/lib/office/engine/drawingml/text.d.ts +35 -0
  27. package/dist-lib/lib/office/engine/drawingml/text.js +352 -0
  28. package/dist-lib/lib/office/engine/errors.d.ts +6 -0
  29. package/dist-lib/lib/office/engine/errors.js +12 -0
  30. package/dist-lib/lib/office/engine/image.d.ts +8 -0
  31. package/dist-lib/lib/office/engine/image.js +48 -0
  32. package/dist-lib/lib/office/engine/index.d.ts +12 -0
  33. package/dist-lib/lib/office/engine/index.js +7 -0
  34. package/dist-lib/lib/office/engine/opc/content-types.d.ts +17 -0
  35. package/dist-lib/lib/office/engine/opc/content-types.js +74 -0
  36. package/dist-lib/lib/office/engine/opc/media.d.ts +13 -0
  37. package/dist-lib/lib/office/engine/opc/media.js +11 -0
  38. package/dist-lib/lib/office/engine/opc/package.d.ts +51 -0
  39. package/dist-lib/lib/office/engine/opc/package.js +324 -0
  40. package/dist-lib/lib/office/engine/opc/paths.d.ts +7 -0
  41. package/dist-lib/lib/office/engine/opc/paths.js +46 -0
  42. package/dist-lib/lib/office/engine/opc/properties.d.ts +7 -0
  43. package/dist-lib/lib/office/engine/opc/properties.js +75 -0
  44. package/dist-lib/lib/office/engine/opc/relationships.d.ts +23 -0
  45. package/dist-lib/lib/office/engine/opc/relationships.js +65 -0
  46. package/dist-lib/lib/office/engine/opc/zip.d.ts +15 -0
  47. package/dist-lib/lib/office/engine/opc/zip.js +264 -0
  48. package/dist-lib/lib/office/engine/pptx/fit.d.ts +12 -0
  49. package/dist-lib/lib/office/engine/pptx/fit.js +37 -0
  50. package/dist-lib/lib/office/engine/pptx/index.d.ts +8 -0
  51. package/dist-lib/lib/office/engine/pptx/index.js +8 -0
  52. package/dist-lib/lib/office/engine/pptx/inherit.d.ts +29 -0
  53. package/dist-lib/lib/office/engine/pptx/inherit.js +155 -0
  54. package/dist-lib/lib/office/engine/pptx/notes.d.ts +5 -0
  55. package/dist-lib/lib/office/engine/pptx/notes.js +85 -0
  56. package/dist-lib/lib/office/engine/pptx/presentation.d.ts +54 -0
  57. package/dist-lib/lib/office/engine/pptx/presentation.js +749 -0
  58. package/dist-lib/lib/office/engine/pptx/read.d.ts +9 -0
  59. package/dist-lib/lib/office/engine/pptx/read.js +66 -0
  60. package/dist-lib/lib/office/engine/pptx/shapes.d.ts +52 -0
  61. package/dist-lib/lib/office/engine/pptx/shapes.js +276 -0
  62. package/dist-lib/lib/office/engine/pptx/slides.d.ts +27 -0
  63. package/dist-lib/lib/office/engine/pptx/slides.js +123 -0
  64. package/dist-lib/lib/office/engine/pptx/template.d.ts +20 -0
  65. package/dist-lib/lib/office/engine/pptx/template.js +391 -0
  66. package/dist-lib/lib/office/engine/pptx/types.d.ts +120 -0
  67. package/dist-lib/lib/office/engine/pptx/types.js +1 -0
  68. package/dist-lib/lib/office/engine/units.d.ts +3 -0
  69. package/dist-lib/lib/office/engine/units.js +12 -0
  70. package/dist-lib/lib/office/engine/utf8.d.ts +2 -0
  71. package/dist-lib/lib/office/engine/utf8.js +103 -0
  72. package/dist-lib/lib/office/engine/xlsx/formula.d.ts +49 -0
  73. package/dist-lib/lib/office/engine/xlsx/formula.js +381 -0
  74. package/dist-lib/lib/office/engine/xlsx/index.d.ts +8 -0
  75. package/dist-lib/lib/office/engine/xlsx/index.js +8 -0
  76. package/dist-lib/lib/office/engine/xlsx/refs.d.ts +22 -0
  77. package/dist-lib/lib/office/engine/xlsx/refs.js +82 -0
  78. package/dist-lib/lib/office/engine/xlsx/reshape.d.ts +21 -0
  79. package/dist-lib/lib/office/engine/xlsx/reshape.js +424 -0
  80. package/dist-lib/lib/office/engine/xlsx/sheet.d.ts +108 -0
  81. package/dist-lib/lib/office/engine/xlsx/sheet.js +662 -0
  82. package/dist-lib/lib/office/engine/xlsx/strings.d.ts +15 -0
  83. package/dist-lib/lib/office/engine/xlsx/strings.js +93 -0
  84. package/dist-lib/lib/office/engine/xlsx/styles.d.ts +38 -0
  85. package/dist-lib/lib/office/engine/xlsx/styles.js +369 -0
  86. package/dist-lib/lib/office/engine/xlsx/template.d.ts +7 -0
  87. package/dist-lib/lib/office/engine/xlsx/template.js +54 -0
  88. package/dist-lib/lib/office/engine/xlsx/workbook.d.ts +161 -0
  89. package/dist-lib/lib/office/engine/xlsx/workbook.js +850 -0
  90. package/dist-lib/lib/office/engine/xlsx/write.d.ts +2 -0
  91. package/dist-lib/lib/office/engine/xlsx/write.js +42 -0
  92. package/dist-lib/lib/office/engine/xml.d.ts +45 -0
  93. package/dist-lib/lib/office/engine/xml.js +258 -0
  94. package/dist-lib/lib/tools/exec/scope.d.ts +2 -0
  95. package/dist-lib/lib/tools/exec/scope.js +33 -2
  96. package/dist-lib/lib/tools/pdf/build.d.ts +43 -0
  97. package/dist-lib/lib/tools/pdf/build.js +150 -0
  98. package/dist-lib/lib/tools/pdf/images.d.ts +7 -0
  99. package/dist-lib/lib/tools/pdf/images.js +82 -0
  100. package/dist-lib/lib/tools/pdf/request.d.ts +63 -0
  101. package/dist-lib/lib/tools/pdf/request.js +335 -0
  102. package/dist-lib/lib/tools/pdf.d.ts +6 -0
  103. package/dist-lib/lib/tools/pdf.js +302 -0
  104. package/dist-lib/lib/tools/skill.js +14 -0
  105. package/dist-lib/lib/tools/weather-lookup/geocode.d.ts +26 -0
  106. package/dist-lib/lib/tools/weather-lookup/geocode.js +110 -0
  107. package/dist-lib/lib/tools/weather-lookup/place.js +5 -1
  108. package/dist-lib/lib/tools/weather-lookup.d.ts +2 -1
  109. package/dist-lib/lib/tools/weather-lookup.js +115 -84
  110. package/package.json +29 -1
  111. package/worker/harness-sidecar.mjs +3 -1
@@ -0,0 +1,302 @@
1
+ import { errorCode, errorMessage } from '../guards.js';
2
+ import { checkOutputBytes, RUN_OUTPUT_BUDGET_BYTES } from './exec/adapters/python/output-policy.js';
3
+ import { build, pagesLabel, readDocs } from './pdf/build.js';
4
+ import { placeImages } from './pdf/images.js';
5
+ import { PAGE_SIZES, parseRequest, PDF_ACTIONS, WHERE_IDS_ARE } from './pdf/request.js';
6
+ export { parsePages, parseRequest } from './pdf/request.js';
7
+ const SUFFIXES = {
8
+ create: 'revised',
9
+ merge: 'merged',
10
+ split: 'pages',
11
+ rotate: 'rotated',
12
+ watermark: 'watermarked',
13
+ fill: 'filled',
14
+ encrypt: 'locked',
15
+ decrypt: 'unlocked',
16
+ text: 'text',
17
+ read: 'copy'
18
+ };
19
+ const attachedThisTurn = new WeakMap();
20
+ let loaded = null;
21
+ function loadPdfium() {
22
+ loaded ??= import('bare-pdfium').then((pdfium) => pdfium, () => null);
23
+ return loaded;
24
+ }
25
+ function stemOf(fileName) {
26
+ const base = fileName.replace(/^.*[\\/]/, '');
27
+ const dot = base.lastIndexOf('.');
28
+ return dot > 0 ? base.slice(0, dot) : base;
29
+ }
30
+ function slug(markdown) {
31
+ const heading = /^#{1,6}\s+(.+)$/m.exec(markdown)?.[1] ?? '';
32
+ const name = heading
33
+ .toLowerCase()
34
+ .replace(/[*_`[\]]/g, '')
35
+ .replace(/[^a-z0-9]+/g, '_')
36
+ .replace(/^_+|_+$/g, '')
37
+ .slice(0, 48);
38
+ return name || 'document';
39
+ }
40
+ function extensionOf(request) {
41
+ return request.action === 'text' ? 'txt' : 'pdf';
42
+ }
43
+ function defaultName(request, first) {
44
+ if (!first) {
45
+ return `${request.action === 'create' ? slug(request.markdown) : 'document'}.pdf`;
46
+ }
47
+ if (request.action === 'text') {
48
+ return `${stemOf(first.fileName)}.txt`;
49
+ }
50
+ return `${stemOf(first.fileName)}_${SUFFIXES[request.action]}.pdf`;
51
+ }
52
+ export function outputName(request, inputs) {
53
+ const extension = extensionOf(request);
54
+ const { output } = request;
55
+ let wanted = defaultName(request, inputs[0]);
56
+ if (output && /\.[a-z0-9]{2,4}$/i.test(output)) {
57
+ wanted = output.replace(/\.pdf$/i, `.${extension}`);
58
+ }
59
+ else if (output) {
60
+ wanted = `${output}.${extension}`;
61
+ }
62
+ const taken = inputs.some((input) => input.fileName.toLowerCase() === wanted.toLowerCase());
63
+ return taken ? `${stemOf(wanted)}_${SUFFIXES[request.action]}.${extension}` : wanted;
64
+ }
65
+ async function fetchSources(ids, ctx) {
66
+ if (!ctx.fetchAttachment) {
67
+ return { error: 'this device cannot read chat attachments' };
68
+ }
69
+ const sources = [];
70
+ for (const attachmentId of ids) {
71
+ const found = await ctx.fetchAttachment({ attachmentId, signal: ctx.signal });
72
+ if (!found || !('bytes' in found)) {
73
+ return { error: `attachment ${attachmentId} not found in this chat; ${WHERE_IDS_ARE}` };
74
+ }
75
+ sources.push({ attachmentId, fileName: found.fileName, bytes: found.bytes });
76
+ }
77
+ return sources;
78
+ }
79
+ function openError(err, source, password) {
80
+ const code = errorCode(err);
81
+ if (code === 'PASSWORD' && password) {
82
+ return `the password is wrong for ${source.fileName}; ask the user for the right one, never guess`;
83
+ }
84
+ if (code === 'PASSWORD') {
85
+ return `${source.fileName} is locked; ask the user for its password and pass it as password`;
86
+ }
87
+ if (code === 'FORMAT' || code === 'FILE') {
88
+ return `${source.fileName} is not a PDF that opens`;
89
+ }
90
+ return `${source.fileName}: ${errorMessage(err)}`;
91
+ }
92
+ function openAll(pdfium, sources, request) {
93
+ const password = request.action === 'encrypt' ? undefined : request.password;
94
+ const opened = [];
95
+ for (const source of sources) {
96
+ try {
97
+ opened.push({ source, doc: pdfium.open(source.bytes, { password }) });
98
+ }
99
+ catch (err) {
100
+ return { opened, error: openError(err, source, password) };
101
+ }
102
+ }
103
+ return { opened, error: null };
104
+ }
105
+ async function attachOutput(fileName, bytes, ctx) {
106
+ if (!ctx.attach) {
107
+ return { error: 'this device cannot attach a file to the chat' };
108
+ }
109
+ const delivered = attachedThisTurn.get(ctx.attach) ?? new Map();
110
+ const earlier = delivered.get(fileName.toLowerCase());
111
+ if (earlier) {
112
+ return { attachment: earlier, repeated: true };
113
+ }
114
+ const verdict = checkOutputBytes(fileName, bytes, 0);
115
+ if ('error' in verdict) {
116
+ return verdict;
117
+ }
118
+ const budget = ctx.runBudget;
119
+ if (budget && budget.outputBytesUsed + bytes.byteLength > RUN_OUTPUT_BUDGET_BYTES) {
120
+ return { error: 'run output budget exceeded' };
121
+ }
122
+ const record = await ctx.attach({ fileName, mimeType: verdict.mimeType, bytes, raw: true });
123
+ if (budget) {
124
+ budget.outputBytesUsed += bytes.byteLength;
125
+ }
126
+ const attachment = {
127
+ attachmentId: record.id,
128
+ fileName: record.fileName,
129
+ byteLength: record.byteLength
130
+ };
131
+ delivered.set(fileName.toLowerCase(), attachment);
132
+ attachedThisTurn.set(ctx.attach, delivered);
133
+ return { attachment, repeated: false };
134
+ }
135
+ const UNDRAWABLE = /^('[^']+' \(U\+[0-9A-F]+\)(?:, '[^']+' \(U\+[0-9A-F]+\))*) cannot be drawn by the built-in fonts/;
136
+ function buildError(err) {
137
+ const message = errorMessage(err);
138
+ const characters = UNDRAWABLE.exec(message)?.[1];
139
+ if (!characters) {
140
+ return message;
141
+ }
142
+ return (`${characters} cannot be drawn: a PDF made here takes Western European text only. ` +
143
+ 'Leave those characters out, or tell the user this text cannot go in a PDF');
144
+ }
145
+ function buildSafely({ pdfium, request, opened }) {
146
+ try {
147
+ return build(pdfium, request, opened);
148
+ }
149
+ catch (err) {
150
+ return buildError(err);
151
+ }
152
+ }
153
+ function reply(request, { output, built, delivered }) {
154
+ const { attachment, repeated } = delivered;
155
+ const summary = request.action === 'text'
156
+ ? `text of ${pagesLabel(built.pageCount)}`
157
+ : pagesLabel(built.pageCount);
158
+ const renamed = request.output && output !== request.output
159
+ ? `Saved as ${output}, so the attached file keeps its own name. `
160
+ : '';
161
+ return {
162
+ attachments: [attachment],
163
+ pageCount: built.pageCount,
164
+ reply: `${attachment.fileName} — ${summary}`,
165
+ next: renamed +
166
+ `Done: ${attachment.fileName} ${repeated ? 'was already' : 'is'} attached to the chat and shown to the user. ` +
167
+ 'Do not call pdf again for it. Your answer is the reply value alone, word for word.'
168
+ };
169
+ }
170
+ async function deliver(job, ctx) {
171
+ const { request, opened } = job;
172
+ const output = outputName(request, opened.map((one) => one.source));
173
+ const extension = `.${extensionOf(request)}`;
174
+ if (!output.toLowerCase().endsWith(extension)) {
175
+ return { error: `output must end in ${extension}, got "${output}"` };
176
+ }
177
+ const built = buildSafely(job);
178
+ if (typeof built === 'string') {
179
+ return { error: built };
180
+ }
181
+ const delivered = await attachOutput(output, built.bytes, ctx);
182
+ return 'error' in delivered ? delivered : reply(request, { output, built, delivered });
183
+ }
184
+ function isLocked(pdfium, source) {
185
+ try {
186
+ pdfium.open(source.bytes).close();
187
+ return false;
188
+ }
189
+ catch (err) {
190
+ return errorCode(err) === 'PASSWORD';
191
+ }
192
+ }
193
+ function lockOrUnlock(pdfium, request, sources) {
194
+ const [only] = sources;
195
+ const wantsFile = request.output?.toLowerCase().endsWith('.pdf') === true;
196
+ if (request.action !== 'read' || !request.password || !wantsFile || !only || sources.length > 1) {
197
+ return request;
198
+ }
199
+ if (isLocked(pdfium, only)) {
200
+ return { ...request, action: 'decrypt' };
201
+ }
202
+ return { ...request, action: 'encrypt', ownerPassword: undefined, permissions: undefined };
203
+ }
204
+ async function run(pdfium, asked, ctx) {
205
+ if (asked.action === 'create') {
206
+ const placed = await placeImages(asked, ctx);
207
+ return 'error' in placed ? placed : deliver({ pdfium, request: placed, opened: [] }, ctx);
208
+ }
209
+ const sources = asked.ids.length ? await fetchSources(asked.ids, ctx) : [];
210
+ if ('error' in sources) {
211
+ return sources;
212
+ }
213
+ const request = lockOrUnlock(pdfium, asked, sources);
214
+ const { opened, error } = openAll(pdfium, sources, request);
215
+ try {
216
+ if (error) {
217
+ return { error };
218
+ }
219
+ if (request.action === 'read') {
220
+ return readDocs(opened);
221
+ }
222
+ return await deliver({ pdfium, request, opened }, ctx);
223
+ }
224
+ finally {
225
+ for (const { doc } of opened) {
226
+ doc.close();
227
+ }
228
+ }
229
+ }
230
+ const DESCRIPTION = 'Create, read or change PDF files. With attachmentId and no action it returns the page ' +
231
+ 'count, each page’s text and the form fields (name, type, value, options). An action saves ' +
232
+ 'a new file named output and attaches it to the chat: create (markdown, where ' +
233
+ '![caption](attachmentId) on its own line places a chat image and the file name of the ' +
234
+ "user's upload places that upload; optional pageSize, " +
235
+ 'landscape, pageNumbers, password), merge (attachmentIds in order), split (pages), rotate ' +
236
+ '(degrees 90, 180 or 270, optional pages), watermark (text, optional opacity, position, ' +
237
+ 'pages), fill (values keyed by field name), encrypt (password, optional ownerPassword, ' +
238
+ 'permissions), decrypt (password), text (a .txt of the text). Pages count from 1. A locked ' +
239
+ 'PDF needs its password on every action. A failing call saves nothing and its error names ' +
240
+ 'what exists, so fix the call from it.';
241
+ const PARAMETERS = {
242
+ type: 'object',
243
+ properties: {
244
+ action: {
245
+ type: 'string',
246
+ enum: [...PDF_ACTIONS],
247
+ description: 'What to do; leave it out to read the PDF'
248
+ },
249
+ attachmentId: { type: 'string', description: 'The PDF in this chat' },
250
+ attachmentIds: {
251
+ type: 'array',
252
+ items: { type: 'string' },
253
+ description: 'merge: the PDFs to join, in order'
254
+ },
255
+ markdown: { type: 'string', description: 'create: the whole document as Markdown' },
256
+ pages: {
257
+ type: 'array',
258
+ items: { type: 'integer' },
259
+ description: 'Page numbers counted from 1'
260
+ },
261
+ degrees: { type: 'integer', description: 'rotate: 90, 180 or 270, clockwise' },
262
+ text: { type: 'string', description: 'watermark: the text to stamp' },
263
+ opacity: { type: 'number', description: 'watermark: 0 to 1' },
264
+ position: { type: 'string', enum: ['over', 'under'], description: 'watermark' },
265
+ values: { type: 'object', description: 'fill: { "field name": value }' },
266
+ password: {
267
+ type: 'string',
268
+ description: 'encrypt: the new password; otherwise the password of a locked PDF'
269
+ },
270
+ ownerPassword: { type: 'string', description: 'encrypt' },
271
+ permissions: { type: 'object', description: 'encrypt: { print, copy, modify, ... }' },
272
+ pageSize: { type: 'string', enum: [...PAGE_SIZES], description: 'create' },
273
+ landscape: { type: 'boolean', description: 'create' },
274
+ pageNumbers: { type: 'boolean', description: 'create' },
275
+ output: { type: 'string', description: 'File name for the saved file' }
276
+ }
277
+ };
278
+ export function createPdfTool(load = loadPdfium) {
279
+ return {
280
+ name: 'pdf',
281
+ toolset: null,
282
+ skillGated: true,
283
+ description: DESCRIPTION,
284
+ parameters: PARAMETERS,
285
+ effect: () => 'read',
286
+ validate(args) {
287
+ const request = parseRequest(args);
288
+ return typeof request === 'string' ? request : null;
289
+ },
290
+ async execute(args, ctx = {}) {
291
+ const request = parseRequest(args);
292
+ if (typeof request === 'string') {
293
+ return { error: request };
294
+ }
295
+ const pdfium = await load();
296
+ if (!pdfium) {
297
+ return { error: 'this device cannot open PDFs' };
298
+ }
299
+ return run(pdfium, request, ctx);
300
+ }
301
+ };
302
+ }
@@ -158,6 +158,14 @@ export function createSkillTool({ skillsDir, overlayDir = resolveSkillsOverlayDi
158
158
  content: notes.length ? [content.trim(), ...notes].join('\n\n') : content
159
159
  };
160
160
  };
161
+ const mainInstead = async (asked, available) => {
162
+ const main = await load(MAIN_FILE);
163
+ const others = available.length ? ` Its other files: ${available.join(', ')}.` : '';
164
+ return {
165
+ ...main,
166
+ content: `There is no file ${asked} in this skill; these are its instructions.${others}\n\n${main.content}`
167
+ };
168
+ };
161
169
  try {
162
170
  return await load(file);
163
171
  }
@@ -173,6 +181,12 @@ export function createSkillTool({ skillsDir, overlayDir = resolveSkillsOverlayDi
173
181
  }
174
182
  catch { }
175
183
  }
184
+ if (!ctx?.skillsLoadedThisTurn?.includes(name)) {
185
+ try {
186
+ return await mainInstead(file, available);
187
+ }
188
+ catch { }
189
+ }
176
190
  const rest = available.length
177
191
  ? ` — files this skill has: ${available.join(', ')}`
178
192
  : '. This skill has no other files; you already have its full instructions — act now.';
@@ -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;