openvibers/openvibe.actor Commits

Live data tools for Actor v2: weather, markets, FX, sports, time zones, places, Wikipedia, exact arithmetic (#190)

OP OpenVibers committed 14 days ago · 1163bc929a · 40 files, +3920 −0

Ten tool modules in server/runtime/tools/, one file per capability, each one keyless and public:
weather_now, market_quote, currency_convert, sports_scores, time_in, convert_time, geocode,
place_info, wikipedia and calculate.

Every call goes through the fetch the loop hands a tool, and a URL is always a fixed provider base
plus the model's own words, so no address a caller or a model wrote is ever fetched. Answers are
cached (weather and standings ten minutes, quotes a minute, a place an hour) and every answer
carries the provider's page as its source. A tool that cannot answer returns an error the model can
act on, never a throw. calculate parses the expression itself, with no eval and no Function, and
keeps whole-number arithmetic exact.

Tests: a recorded fixture per module under test/fixtures/tools/ with the fetch stubbed, a shape
test for the interface of every module in the folder, and the whole suite green.

Changes

ddiff --git a/server/runtime/tools/calculate.js b/server/runtime/tools/calculate.js
nnew file mode 100644
iindex 0000000..038c497
---- /dev/null
++++ b/server/runtime/tools/calculate.js
@@@ -0,0 +1,552 @@
++'use strict';
++/**
++ * calculate: exact arithmetic and unit conversion, offline.
++ *
++ * The expression is parsed by the small parser below - never by eval, never by Function, never
++ * by a child process - so an expression can only ever be arithmetic: numbers, + - * / % ^, brackets,
++ * a fixed list of functions and a fixed list of constants. Integer arithmetic stays exact (BigInt)
++ * from end to end; anything with a decimal point is a double.
++ *
++ * A question that also carries units ("10 km to miles", "72 F to C") is answered as a conversion
++ * from the table below. The factors are the international ones (1 mile = 1609.344 m, 1 pound =
++ * 0.45359237 kg, and so on); a month and a year are the mean Gregorian ones.
++ */
++const { ToolError, round, group, source, fail } = require('./lib/util');
++
++const CONSTANTS = {
++ pi: Math.PI,
++ e: Math.E,
++ tau: Math.PI * 2,
++};
++
++const FUNCTIONS = {
++ sqrt: (a) => {
++ if (Number(a) < 0) throw new ToolError('the square root of a negative number is not a real number');
++ return Math.sqrt(Number(a));
++ },
++ abs: (a) => (typeof a === 'bigint' ? (a < 0n ? -a : a) : Math.abs(Number(a))),
++ round: (a, d) => {
++ const places = d === undefined ? 0 : Math.round(Number(d));
++ const p = Math.pow(10, places);
++ return Math.round(Number(a) * p) / p;
++ },
++ floor: (a) => (typeof a === 'bigint' ? a : Math.floor(Number(a))),
++ ceil: (a) => (typeof a === 'bigint' ? a : Math.ceil(Number(a))),
++ trunc: (a) => (typeof a === 'bigint' ? a : Math.trunc(Number(a))),
++ sign: (a) => Math.sign(Number(a)),
++ min: (...args) => Math.min(...args.map(Number)),
++ max: (...args) => Math.max(...args.map(Number)),
++ pow: (a, b) => Math.pow(Number(a), Number(b)),
++ hypot: (...args) => Math.hypot(...args.map(Number)),
++ log: (a) => Math.log10(Number(a)),
++ log2: (a) => Math.log2(Number(a)),
++ ln: (a) => Math.log(Number(a)),
++ exp: (a) => Math.exp(Number(a)),
++ sin: (a) => Math.sin(Number(a)),
++ cos: (a) => Math.cos(Number(a)),
++ tan: (a) => Math.tan(Number(a)),
++ asin: (a) => Math.asin(Number(a)),
++ acos: (a) => Math.acos(Number(a)),
++ atan: (a) => Math.atan(Number(a)),
++ sinh: (a) => Math.sinh(Number(a)),
++ cosh: (a) => Math.cosh(Number(a)),
++ tanh: (a) => Math.tanh(Number(a)),
++ deg: (a) => (Number(a) * 180) / Math.PI,
++ rad: (a) => (Number(a) * Math.PI) / 180,
++ gcd: (...args) => {
++ let a = BigInt(args[0]);
++ let b = BigInt(args[1]);
++ if (a < 0n) a = -a;
++ if (b < 0n) b = -b;
++ while (b) { const t = a % b; a = b; b = t; }
++ return a;
++ },
++ lcm: (...args) => {
++ const a = BigInt(args[0]);
++ const b = BigInt(args[1]);
++ const g = (x, y) => { let p = x < 0n ? -x : x; let q = y < 0n ? -y : y; while (q) { const t = p % q; p = q; q = t; } return p; };
++ if (a === 0n || b === 0n) return 0n;
++ return (a / g(a, b)) * b;
++ },
++ fact: (a) => {
++ const n = Number(a);
++ if (!Number.isInteger(n) || n < 0) throw new ToolError('the factorial is only defined for whole numbers of 0 or more');
++ let out = 1n;
++ for (let i = 2n; i <= BigInt(n); i += 1n) out *= i;
++ return out;
++ },
++};
++
++const ARG_COUNT = {
++ sqrt: 1, abs: 1, round: [1, 2], floor: 1, ceil: 1, trunc: 1, sign: 1, pow: 2,
++ min: [1, 8], max: [1, 8], hypot: [2, 8], log: 1, log2: 1, ln: 1, exp: 1,
++ sin: 1, cos: 1, tan: 1, asin: 1, acos: 1, atan: 1, sinh: 1, cosh: 1, tanh: 1,
++ deg: 1, rad: 1, gcd: 2, lcm: 2, fact: 1,
++};
++
++/** The units this tool knows, by kind. The factor turns a unit into the kind's base unit. */
++const UNIT_TABLE = (() => {
++ const table = {};
++ const add = (kind, entries) => {
++ for (const [names, factor] of entries) {
++ for (const name of names.split(' ')) table[name] = { kind, factor };
++ }
++ };
++ add('length', [
++ ['m m meter meters metre metres', 1],
++ ['km kilometer kilometers kilometre kilometres', 1000],
++ ['cm centimeter centimeters centimetre centimetres', 0.01],
++ ['mm millimeter millimeters millimetre millimetres', 0.001],
++ ['um micrometer micrometers micrometre micrometres', 1e-6],
++ ['nm nanometer nanometers', 1e-9],
++ ['mi mile miles', 1609.344],
++ ['yd yard yards', 0.9144],
++ ['ft foot feet', 0.3048],
++ ['in inch inches', 0.0254],
++ ['nmi nauticalmile nauticalmiles', 1852],
++ ['ly lightyear lightyears', 9.4607304725808e15],
++ ['au astronomicunit', 1.495978707e11],
++ ]);
++ add('mass', [
++ ['kg kilogram kilograms kilogramme kilogrammes', 1],
++ ['g gram grams gramme grammes', 0.001],
++ ['mg milligram milligrams', 1e-6],
++ ['t tonne tonnes metricton', 1000],
++ ['lb lbs pound pounds', 0.45359237],
++ ['oz ounce ounces', 0.028349523125],
++ ['st stone stones', 6.35029318],
++ ]);
++ add('time', [
++ ['s sec secs second seconds', 1],
++ ['min mins minute minutes', 60],
++ ['h hr hrs hour hours', 3600],
++ ['day days', 86400],
++ ['week weeks', 604800],
++ ['fortnight fortnights', 1209600],
++ ['month months', 2629746],
++ ['year years yr yrs', 31556952],
++ ]);
++ add('volume', [
++ ['l l liter liters litre litres', 1],
++ ['ml milliliter milliliters millilitre millilitres', 0.001],
++ ['m3 cubicmeter cubicmetre', 1000],
++ ['cm3 cubiccentimeter', 0.001],
++ ['gal gallon gallons', 3.785411784],
++ ['qt quart quarts', 0.946352946],
++ ['pt pint pints', 0.473176473],
++ ['floz fluidounce', 0.0295735295625],
++ ['cup cups', 0.2365882365],
++ ]);
++ add('area', [
++ ['m2 sqm squaremeter', 1],
++ ['km2 sqkm squarekilometer', 1e6],
++ ['cm2 sqcm', 1e-4],
++ ['ha hectare hectares', 10000],
++ ['acre acres', 4046.8564224],
++ ['ft2 sqft', 0.09290304],
++ ['yd2 sqyd', 0.83612736],
++ ['mi2 sqmi', 2589988.110336],
++ ]);
++ add('speed', [
++ ['mps', 1],
++ ['kmh kph', 1 / 3.6],
++ ['mph', 0.44704],
++ ['kn knot knots', 0.5144444444444445],
++ ['fps', 0.3048],
++ ]);
++ add('pressure', [
++ ['pa pascal pascals', 1],
++ ['kpa kilopascal', 1000],
++ ['mpa megapascal', 1e6],
++ ['bar bars', 100000],
++ ['mbar millibar', 100],
++ ['psi', 6894.757293168361],
++ ['atm', 101325],
++ ['torr', 133.32236842105263],
++ ['mmhg', 133.322387415],
++ ]);
++ add('energy', [
++ ['j joule joules', 1],
++ ['kj kilojoule', 1000],
++ ['mj megajoule', 1e6],
++ ['cal calorie calories', 4.184],
++ ['kcal kilocalorie', 4184],
++ ['wh watthour', 3600],
++ ['kwh kilowatthour', 3.6e6],
++ ['btu', 1055.05585262],
++ ['ftlb', 1.3558179483314004],
++ ]);
++ add('power', [
++ ['w watt watts', 1],
++ ['kw kilowatt kilowatts', 1000],
++ ['mw megawatt', 1e6],
++ ['hp horsepower', 745.6998715822702],
++ ]);
++ add('data', [
++ ['b byte bytes', 1],
++ ['kb kilobyte', 1000],
++ ['mb megabyte', 1e6],
++ ['gb gigabyte', 1e9],
++ ['tb terabyte', 1e12],
++ ['kib kibibyte', 1024],
++ ['mib mebibyte', 1048576],
++ ['gib gibibyte', 1073741824],
++ ['tib tebibyte', 1099511627776],
++ ['bit bits', 0.125],
++ ]);
++ add('angle', [
++ ['deg degree degrees', 1],
++ ['rad radian radians', 57.29577951308232],
++ ['grad gradian gradians', 0.9],
++ ]);
++ add('frequency', [
++ ['hz hertz', 1],
++ ['khz kilohertz', 1000],
++ ['mhz megahertz', 1e6],
++ ['ghz gigahertz', 1e9],
++ ]);
++ return table;
++})();
++
++const TEMPERATURE = { c: 'c', celsius: 'c', centigrade: 'c', f: 'f', fahrenheit: 'f', k: 'k', kelvin: 'k' };
++
++const cleanUnit = (unit) => String(unit || '').trim().toLowerCase().replace(/°/g, '').replace(/\s+/g, '');
++
++/** The factor that turns one unit into the base unit of its kind. */
++function unitFactor(unit) {
++ const name = cleanUnit(unit);
++ if (!name) return null;
++ if (name.includes('/')) {
++ const [top, bottom] = name.split('/');
++ const a = unitFactor(top);
++ const b = unitFactor(bottom);
++ if (!a || !b) return null;
++ return { kind: a.kind, factor: a.factor / b.factor };
++ }
++ if (UNIT_TABLE[name]) return UNIT_TABLE[name];
++ return null;
++}
++
++const isTemperature = (unit) => Object.prototype.hasOwnProperty.call(TEMPERATURE, cleanUnit(unit));
++
++/** A temperature in Celsius. */
++function toCelsius(value, unit) {
++ const kind = TEMPERATURE[cleanUnit(unit)];
++ if (kind === 'f') return ((value - 32) * 5) / 9;
++ if (kind === 'k') return value - 273.15;
++ return value;
++}
++
++function fromCelsius(celsius, unit) {
++ const kind = TEMPERATURE[cleanUnit(unit)];
++ if (kind === 'f') return (celsius * 9) / 5 + 32;
++ if (kind === 'k') return celsius + 273.15;
++ return celsius;
++}
++
++/** Split the expression into tokens. */
++function tokenize(input) {
++ const text = String(input);
++ const tokens = [];
++ let i = 0;
++ while (i < text.length) {
++ const ch = text[i];
++ if (ch === ' ' || ch === '\t' || ch === '\n') { i += 1; continue; }
++ if (/[0-9.]/.test(ch)) {
++ const m = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(text.slice(i));
++ if (!m) throw new ToolError('unexpected "' + ch + '" in the expression');
++ tokens.push({ type: 'number', value: m[0] });
++ i += m[0].length;
++ continue;
++ }
++ if (/[A-Za-z_]/.test(ch)) {
++ const m = /^[A-Za-z_][A-Za-z_0-9]*/.exec(text.slice(i));
++ tokens.push({ type: 'ident', value: m[0] });
++ i += m[0].length;
++ continue;
++ }
++ if (text.startsWith('**', i)) { tokens.push({ type: 'op', value: '^' }); i += 2; continue; }
++ if ('+-*/%^'.includes(ch)) { tokens.push({ type: 'op', value: ch }); i += 1; continue; }
++ if (ch === '(' || ch === ')' || ch === ',') { tokens.push({ type: ch, value: ch }); i += 1; continue; }
++ throw new ToolError('unexpected "' + ch + '" in the expression');
++ }
++ return tokens;
++}
++
++/** The parser: expressions become a tree, never code. */
++class Parser {
++ constructor(tokens) {
++ this.tokens = tokens;
++ this.pos = 0;
++ }
++
++ peek() {
++ return this.tokens[this.pos] || null;
++ }
++
++ next() {
++ const token = this.tokens[this.pos] || null;
++ this.pos += 1;
++ return token;
++ }
++
++ expect(type) {
++ const token = this.next();
++ if (!token || token.type !== type) throw new ToolError('the expression is not complete');
++ return token;
++ }
++
++ parse() {
++ const node = this.expression();
++ if (this.pos < this.tokens.length) throw new ToolError('the expression has something left over');
++ return node;
++ }
++
++ expression() {
++ let left = this.term();
++ while (this.peek() && this.peek().type === 'op' && (this.peek().value === '+' || this.peek().value === '-')) {
++ const op = this.next().value;
++ left = { t: 'bin', op, left, right: this.term() };
++ }
++ return left;
++ }
++
++ term() {
++ let left = this.unary();
++ while (this.peek() && this.peek().type === 'op' && ['*', '/', '%'].includes(this.peek().value)) {
++ const op = this.next().value;
++ left = { t: 'bin', op, left, right: this.unary() };
++ }
++ return left;
++ }
++
++ unary() {
++ const token = this.peek();
++ if (token && token.type === 'op' && (token.value === '+' || token.value === '-')) {
++ this.next();
++ return { t: 'un', op: token.value, operand: this.unary() };
++ }
++ return this.power();
++ }
++
++ power() {
++ const base = this.primary();
++ const token = this.peek();
++ if (token && token.type === 'op' && token.value === '^') {
++ this.next();
++ return { t: 'bin', op: '^', left: base, right: this.unary() };
++ }
++ return base;
++ }
++
++ primary() {
++ const token = this.next();
++ if (!token) throw new ToolError('the expression ends too early');
++ if (token.type === 'number') {
++ const exact = !/[.eE]/.test(token.value);
++ return { t: 'num', text: token.value, exact };
++ }
++ if (token.type === '(') {
++ const inner = this.expression();
++ this.expect(')');
++ return inner;
++ }
++ if (token.type === 'ident') {
++ const name = token.value.toLowerCase();
++ const after = this.peek();
++ if (after && after.type === '(') {
++ this.next();
++ const args = [];
++ if (this.peek() && this.peek().type === ')') this.next();
++ else {
++ for (;;) {
++ args.push(this.expression());
++ if (this.peek() && this.peek().type === ')') { this.next(); break; }
++ const sep = this.next();
++ if (!sep || sep.type !== ',') throw new ToolError('the arguments of ' + name + ' are not separated');
++ }
++ }
++ return { t: 'call', name, args };
++ }
++ if (Object.prototype.hasOwnProperty.call(CONSTANTS, name)) return { t: 'const', name };
++ throw new ToolError('there is no "' + token.value + '" in this calculator');
++ }
++ throw new ToolError('unexpected "' + (token.value || token.type) + '" in the expression');
++ }
++}
++
++/** The tree, evaluated. Whole numbers stay whole. */
++function evaluate(node) {
++ if (node.t === 'num') return node.exact ? BigInt(node.text) : Number(node.text);
++ if (node.t === 'const') return CONSTANTS[node.name];
++ if (node.t === 'un') {
++ const value = evaluate(node.operand);
++ return node.op === '-' ? -value : value;
++ }
++ if (node.t === 'bin') {
++ const left = evaluate(node.left);
++ const right = evaluate(node.right);
++ return applyOperator(node.op, left, right);
++ }
++ if (node.t === 'call') {
++ const fn = FUNCTIONS[node.name];
++ if (!fn) throw new ToolError('there is no function called "' + node.name + '"');
++ const args = node.args.map(evaluate);
++ const expected = ARG_COUNT[node.name];
++ if (expected !== undefined) {
++ const min = Array.isArray(expected) ? expected[0] : expected;
++ const max = Array.isArray(expected) ? expected[1] : expected;
++ if (args.length < min || args.length > max) {
++ throw new ToolError(node.name + ' takes ' + (min === max ? min + ' argument' : min + ' to ' + max + ' arguments'));
++ }
++ }
++ return fn(...args);
++ }
++ throw new ToolError('the expression could not be read');
++}
++
++function applyOperator(op, left, right) {
++ const bothIntegers = typeof left === 'bigint' && typeof right === 'bigint';
++ if (op === '+') return bothIntegers ? left + right : Number(left) + Number(right);
++ if (op === '-') return bothIntegers ? left - right : Number(left) - Number(right);
++ if (op === '*') return bothIntegers ? left * right : Number(left) * Number(right);
++ if (op === '/') {
++ if (bothIntegers) {
++ if (right === 0n) throw new ToolError('division by zero');
++ return left % right === 0n ? left / right : Number(left) / Number(right);
++ }
++ if (Number(right) === 0) throw new ToolError('division by zero');
++ return Number(left) / Number(right);
++ }
++ if (op === '%') {
++ if (bothIntegers) {
++ if (right === 0n) throw new ToolError('division by zero');
++ return left % right;
++ }
++ if (Number(right) === 0) throw new ToolError('division by zero');
++ return Number(left) % Number(right);
++ }
++ if (op === '^') {
++ if (bothIntegers && right >= 0n && right < 1000n) return left ** right;
++ return Math.pow(Number(left), Number(right));
++ }
++ throw new ToolError('the operator "' + op + '" is not one this calculator knows');
++}
++
++/** A result a person can read: whole numbers whole, everything else to twelve useful digits. */
++function formatValue(value) {
++ if (typeof value === 'bigint') return value.toString();
++ if (!Number.isFinite(value)) return String(value);
++ if (Number.isInteger(value)) return String(value);
++ return String(Number(value.toPrecision(12)));
++}
++
++/** "10 km to miles", "72 F to C", "100 kg in pounds": a number, a unit and another unit. */
++function parseConversion(expression) {
++ const m = /^([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*([^\d\s][^\s]*)?\s+(?:to|in|as|into)\s+([^\d\s][^\s]*)\s*$/i.exec(expression);
++ if (!m) return null;
++ const value = Number(m[1]);
++ if (!Number.isFinite(value)) return null;
++ return { value, from: m[2] || null, to: m[3] || null };
++}
++
++module.exports = {
++ name: 'calculate',
++ description: 'Do arithmetic and convert units, offline and exactly. Handles + - * / % ^, brackets, whole-number '
++ + 'arithmetic without rounding, functions (sqrt, abs, round, min, max, pow, log, ln, sin, cos, floor, ceil, gcd, '
++ + 'lcm, fact, deg, rad), constants (pi, e, tau) and conversions such as "10 km to miles", "72 F to C" or '
++ + '"100 kg in pounds". Use it instead of working the arithmetic out in your head.',
++ input: {
++ type: 'object',
++ properties: {
++ expression: { type: 'string', description: 'The calculation, for example "((12 + 8) * 3) / 4", "2^10", "sqrt(144) + 2^3" or "10 km to miles".' },
++ },
++ required: ['expression'],
++ },
++ scope: 'public',
++ costUsd: 0,
++ timeoutMs: 5000,
++ sources: [source('https://www.nist.gov/pml/owm/si-units', 'NIST: the international system of units')],
++
++ async run(input = {}, ctx = {}) {
++ const expression = String(input.expression || '').trim();
++ if (!expression) return fail('calculate needs an expression, for example "2 + 2" or "10 km to miles".');
++ try {
++ const conversion = parseConversion(expression);
++ if (conversion && conversion.from && conversion.to) return convert(conversion, expression);
++ if (conversion && !conversion.from) {
++ return fail('a conversion needs both units, for example "10 km to miles".');
++ }
++ const tree = new Parser(tokenize(expression)).parse();
++ const value = evaluate(tree);
++ const exact = typeof value === 'bigint';
++ const text = formatValue(value);
++ const lines = [expression + ' = ' + text + '.'];
++ if (exact) lines.push('The result is exact (whole numbers, no rounding).');
++ const small = exact && BigInt(text) <= BigInt(Number.MAX_SAFE_INTEGER) && BigInt(text) >= BigInt(Number.MIN_SAFE_INTEGER);
++ return {
++ text: lines.join(' '),
++ data: {
++ kind: 'expression',
++ expression,
++ value: exact ? (small ? Number(text) : text) : Number(text),
++ value_exact: text,
++ exact,
++ fetched_at: new Date().toISOString(),
++ },
++ sources: [],
++ artifacts: [],
++ };
++ } catch (err) {
++ return fail((err && err.message) || 'the calculation could not be done');
++ }
++ },
++};
++
++function convert(conversion, expression) {
++ const from = cleanUnit(conversion.from);
++ const to = cleanUnit(conversion.to);
++ if (isTemperature(from) && isTemperature(to)) {
++ const celsius = toCelsius(conversion.value, from);
++ const result = fromCelsius(celsius, to);
++ return {
++ text: conversion.value + ' ' + conversion.from + ' = ' + round(result, 6) + ' ' + conversion.to + '.',
++ data: {
++ kind: 'conversion',
++ expression,
++ value: round(result, 6),
++ from: conversion.from,
++ to: conversion.to,
++ exact: false,
++ fetched_at: new Date().toISOString(),
++ },
++ sources: [source('https://www.nist.gov/pml/owm/si-units', 'NIST: the international system of units')],
++ artifacts: [],
++ };
++ }
++ const a = unitFactor(from);
++ const b = unitFactor(to);
++ if (!a || !b) {
++ return fail('this tool does not know "' + (a ? conversion.to : conversion.from) + '". It knows metres, kilometres, miles, feet, kilograms, pounds, seconds, hours, days, litres, gallons, m/s, km/h, mph, pascals, bar, psi, joules, calories, watts, bytes and degrees.');
++ }
++ if (a.kind !== b.kind) {
++ return fail('a ' + conversion.from + ' is not a ' + conversion.to + ' (one is a ' + a.kind + ', the other a ' + b.kind + ').');
++ }
++ const base = conversion.value * a.factor;
++ const result = base / b.factor;
++ return {
++ text: group(conversion.value) + ' ' + conversion.from + ' = ' + group(round(result, 6)) + ' ' + conversion.to + '.',
++ data: {
++ kind: 'conversion',
++ expression,
++ value: round(result, 10),
++ from: conversion.from,
++ to: conversion.to,
++ factor: round(b.factor, 12),
++ exact: false,
++ fetched_at: new Date().toISOString(),
++ },
++ sources: [source('https://www.nist.gov/pml/owm/si-units', 'NIST: the international system of units')],
++ artifacts: [],
++ };
++}
ddiff --git a/server/runtime/tools/convert_time.js b/server/runtime/tools/convert_time.js
nnew file mode 100644
iindex 0000000..9abb746
---- /dev/null
++++ b/server/runtime/tools/convert_time.js
@@@ -0,0 +1,109 @@
++'use strict';
++/**
++ * convert_time: one moment, seen from two places.
++ *
++ * Everything comes from Node's own IANA time zone database through Intl: no network call, and the
++ * offsets are the ones in force on that date, so a conversion across a daylight saving change is
++ * right. A wall-clock time without a zone is read in the "from" zone; a time with an offset (or
++ * with Z) is that absolute moment.
++ */
++const { ToolError, source, fail } = require('./lib/util');
++const tz = require('./lib/tz');
++const timeIn = require('./time_in');
++
++/** "now", "2026-10-30", "2026-10-30 14:00", "2026-10-30T14:00:00Z" and "2026-10-30T14:00+02:00". */
++function parseMoment(input, fromZone) {
++ const text = String(input || '').trim();
++ if (!text || text.toLowerCase() === 'now') return { date: new Date(), given: 'now' };
++ const hasZone = /(Z|[+-]\d{2}:?\d{2})$/i.test(text);
++ const wall = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/.exec(text);
++ if (wall) {
++ const date = tz.zonedToUtc({
++ year: Number(wall[1]),
++ month: Number(wall[2]),
++ day: Number(wall[3]),
++ hour: Number(wall[4] || 0),
++ minute: Number(wall[5] || 0),
++ second: Number(wall[6] || 0),
++ }, fromZone);
++ return { date, given: text };
++ }
++ const parsed = new Date(text);
++ if (Number.isNaN(parsed.getTime())) {
++ throw new ToolError('"' + text + '" is not a date this tool can read. Use "now", "2026-10-30 14:00" or "2026-10-30T14:00:00Z".');
++ }
++ return { date: parsed, given: hasZone ? text : text };
++}
++
++module.exports = {
++ name: 'convert_time',
++ description: 'Convert one moment between two places, with each place\'s offset and daylight saving. Give the zone '
++ + 'to convert to and, if the time is a wall-clock time, the zone it is written in. Works out of the platform '
++ + 'time zone database, with no network call.',
++ input: {
++ type: 'object',
++ properties: {
++ to: { type: 'string', description: 'The place to convert to: an IANA zone ("Europe/Berlin") or a city ("Tokyo").' },
++ from: { type: 'string', description: 'The place the time is written in. Defaults to UTC.' },
++ datetime: { type: 'string', description: 'The moment to convert: "now" (the default), "2026-10-30 14:00" or "2026-10-30T14:00:00Z".' },
++ },
++ required: ['to'],
++ },
++ scope: 'public',
++ costUsd: 0,
++ timeoutMs: 5000,
++ sources: [source('https://www.iana.org/time-zones', 'IANA: the time zone database')],
++
++ async run(input = {}, ctx = {}) {
++ const toName = String(input.to || '').trim();
++ const fromName = String(input.from || 'UTC').trim() || 'UTC';
++ if (!toName) return fail('convert_time needs the place to convert to, for example "Europe/Berlin".');
++ const toZone = timeIn.resolveZone(toName);
++ const fromZone = timeIn.resolveZone(fromName);
++ if (!toZone) return fail('"' + toName + '" is not a time zone this tool knows. Give an IANA zone ("Europe/Berlin") or a city ("Tokyo").');
++ if (!fromZone) return fail('"' + fromName + '" is not a time zone this tool knows. Give an IANA zone ("America/New_York") or a city ("New York").');
++
++ try {
++ const moment = parseMoment(input.datetime, fromZone);
++ const date = moment.date;
++ const difference = tz.differenceMinutes(date, fromZone, toZone);
++ const fromDst = tz.daylightSaving(date, fromZone);
++ const toDst = tz.daylightSaving(date, toZone);
++ const lines = [
++ tz.formatInZone(date, fromZone) + ' in ' + fromZone + ' is ' + tz.formatInZone(date, toZone) + ' in ' + toZone + '.',
++ toZone + ' is ' + tz.describeDifference(difference) + ' ' + fromZone
++ + ' (' + tz.offsetLabel(date, toZone) + ' against ' + tz.offsetLabel(date, fromZone) + ').',
++ 'The same moment in UTC is ' + date.toISOString() + '.',
++ ];
++ return {
++ text: lines.join(' '),
++ data: {
++ given: moment.given,
++ from: {
++ zone: fromZone,
++ local_time: tz.formatInZone(date, fromZone),
++ utc_offset: tz.offsetLabel(date, fromZone),
++ utc_offset_minutes: tz.offsetMinutes(date, fromZone),
++ abbreviation: tz.zoneAbbreviation(date, fromZone),
++ dst: fromDst.inEffect,
++ },
++ to: {
++ zone: toZone,
++ local_time: tz.formatInZone(date, toZone),
++ utc_offset: tz.offsetLabel(date, toZone),
++ utc_offset_minutes: tz.offsetMinutes(date, toZone),
++ abbreviation: tz.zoneAbbreviation(date, toZone),
++ dst: toDst.inEffect,
++ },
++ utc_time: date.toISOString(),
++ difference_minutes: difference,
++ difference: tz.describeDifference(difference),
++ },
++ sources: [source('https://www.iana.org/time-zones', 'IANA: the time zone database')],
++ artifacts: [],
++ };
++ } catch (err) {
++ return fail((err && err.message) || 'the time could not be converted');
++ }
++ },
++};
ddiff --git a/server/runtime/tools/currency_convert.js b/server/runtime/tools/currency_convert.js
nnew file mode 100644
iindex 0000000..296e5b4
---- /dev/null
++++ b/server/runtime/tools/currency_convert.js
@@@ -0,0 +1,140 @@
++'use strict';
++/**
++ * currency_convert: how much one currency is worth in another.
++ *
++ * Source: Frankfurter (https://www.frankfurter.app), which republishes the European Central
++ * Bank's reference rates. It is free, keyless and covers about thirty currencies; the rates are
++ * published once a working day (around 16:00 CET), so the answer carries the date of the rate and
++ * not just the moment of the call.
++ */
++const { Cache, memo, requestJson, ToolError, round, group, source, fail } = require('./lib/util');
++
++const BASE = 'https://api.frankfurter.dev/v1';
++const TTL_MS = 60 * 60 * 1000;
++const CACHE = new Cache();
++
++const isCode = (code) => typeof code === 'string' && /^[A-Za-z]{3}$/.test(code);
++
++async function currencies(ctx, signal) {
++ return await memo(CACHE, 'fx:currencies', TTL_MS, async () => {
++ const { data } = await requestJson(ctx, BASE + '/currencies', { signal, label: 'Frankfurter' });
++ return (data && typeof data === 'object') ? data : {};
++ });
++}
++
++async function rates(ctx, base, symbols, signal) {
++ const url = BASE + '/latest?base=' + encodeURIComponent(base)
++ + (symbols && symbols.length ? '&symbols=' + encodeURIComponent(symbols.join(',')) : '');
++ const { data } = await requestJson(ctx, url, { signal, label: 'Frankfurter' });
++ if (!data || !data.rates) throw new ToolError('Frankfurter answered without rates', 'tool.bad_answer');
++ return data;
++}
++
++module.exports = {
++ name: 'currency_convert',
++ description: 'Convert an amount of money between currencies at the European Central Bank reference rates '
++ + '(Frankfurter). For example: convert 250 USD to EUR, or "1 JPY in GBP". The rates are published once a '
++ + 'working day and the answer says which day they are from.',
++ input: {
++ type: 'object',
++ properties: {
++ amount: { type: 'number', description: 'The amount to convert. Defaults to 1.' },
++ from: { type: 'string', description: 'The currency to convert from, as its three-letter code (USD, EUR, GBP).' },
++ to: { type: 'string', description: 'The currency (or comma-separated currencies) to convert to. Leave it out to get every rate for the base currency.' },
++ },
++ required: ['from'],
++ },
++ scope: 'public',
++ costUsd: 0,
++ timeoutMs: 15000,
++ sources: [source('https://www.frankfurter.app/', 'Frankfurter: ECB reference exchange rates')],
++
++ async run(input = {}, ctx = {}) {
++ const base = String(input.from || '').trim().toUpperCase();
++ if (!base) return fail('currency_convert needs the currency to convert from, for example from: "USD".');
++ const amount = Number.isFinite(Number(input.amount)) ? Number(input.amount) : 1;
++ const wanted = String(input.to || '').split(',').map((c) => c.trim().toUpperCase()).filter(Boolean);
++
++ try {
++ const known = await currencies(ctx, ctx.signal);
++ if (Object.keys(known).length && !known[base]) {
++ return fail('Frankfurter does not know the currency "' + base + '". It covers the euro and the major world currencies.');
++ }
++ for (const code of wanted) {
++ if (Object.keys(known).length && !known[code]) {
++ return fail('Frankfurter does not know the currency "' + code + '".');
++ }
++ }
++ if (!wanted.length && known[base] && Object.keys(known).length > 1) {
++ const all = Object.keys(known).filter((c) => c !== base);
++ const data = await rates(ctx, base, all, ctx.signal);
++ return buildAll(base, amount, data);
++ }
++ const data = await rates(ctx, base, wanted, ctx.signal);
++ return build(base, amount, wanted, data);
++ } catch (err) {
++ return fail((err && err.message) || 'the exchange rate could not be fetched');
++ }
++ },
++};
++
++function build(base, amount, wanted, data) {
++ const date = data.date || null;
++ const lines = [];
++ const converted = [];
++ for (const code of wanted) {
++ const rate = data.rates[code];
++ if (rate == null) {
++ lines.push('No rate for ' + code + '.');
++ continue;
++ }
++ const value = amount * rate;
++ converted.push({ currency: code, rate: round(rate, 8), value: round(value, 6), name: null });
++ lines.push(
++ amount + ' ' + base + ' = ' + group(value) + ' ' + code
++ + ' (1 ' + base + ' = ' + round(rate, 6) + ' ' + code + ').'
++ );
++ }
++ lines.push('The rate is the European Central Bank reference rate of ' + (date || 'the latest working day') + '.');
++ return {
++ text: lines.join(' '),
++ data: {
++ kind: 'conversion',
++ amount,
++ from: base,
++ to: wanted,
++ date,
++ rates: converted,
++ quote_time: date,
++ retrieved_at: new Date().toISOString(),
++ source: 'Frankfurter (ECB reference rates)',
++ },
++ sources: [source('https://www.frankfurter.app/', 'Frankfurter: ECB reference exchange rates')],
++ artifacts: [],
++ };
++}
++
++function buildAll(base, amount, data) {
++ const entries = Object.entries(data.rates || {})
++ .sort((a, b) => b[1] - a[1])
++ .slice(0, 25)
++ .map(([code, rate]) => ({ currency: code, rate: round(rate, 8), value: round(amount * rate, 6) }));
++ const lines = entries.slice(0, 10).map((e) => '1 ' + base + ' = ' + round(e.rate, 6) + ' ' + e.currency);
++ lines.push('The rate is the European Central Bank reference rate of ' + (data.date || 'the latest working day') + '.');
++ return {
++ text: lines.join('\n'),
++ data: {
++ kind: 'conversion',
++ amount,
++ from: base,
++ to: entries.map((e) => e.currency),
++ date: data.date || null,
++ rates: entries,
++ quote_time: data.date || null,
++ retrieved_at: new Date().toISOString(),
++ source: 'Frankfurter (ECB reference rates)',
++ },
++ sources: [source('https://www.frankfurter.app/', 'Frankfurter: ECB reference exchange rates')],
++ artifacts: [],
++ };
++}
ddiff --git a/server/runtime/tools/geocode.js b/server/runtime/tools/geocode.js
nnew file mode 100644
iindex 0000000..808319a
---- /dev/null
++++ b/server/runtime/tools/geocode.js
@@@ -0,0 +1,151 @@
++'use strict';
++/**
++ * geocode: a place name in coordinates, and what goes with them (the country, the time zone, the
++ * population, the elevation).
++ *
++ * Source: Open-Meteo's geocoding API (https://open-meteo.com/en/docs/geocoding-api), free and
++ * keyless. When Open-Meteo does not know the name, OpenStreetMap's Nominatim is asked instead
++ * (see lib/nominatim.js for its usage policy).
++ */
++const { Cache, ToolError, memo, requestJson, round, source, fail } = require('./lib/util');
++const nominatim = require('./lib/nominatim');
++
++const ENDPOINT = 'https://geocoding-api.open-meteo.com/v1/search';
++const TTL_MS = 10 * 60 * 1000;
++const CACHE = new Cache();
++
++/** The place, as Open-Meteo knows it. */
++async function openMeteo(ctx, place, signal) {
++ const url = ENDPOINT + '?name=' + encodeURIComponent(place) + '&count=5&language=en&format=json';
++ const { data } = await requestJson(ctx, url, { signal, label: 'Open-Meteo geocoding' });
++ return (data && Array.isArray(data.results)) ? data.results : [];
++}
++
++/** The same place, as Nominatim knows it. */
++async function osm(ctx, place, signal) {
++ const hits = await nominatim.search(ctx, place, { limit: 1, signal });
++ const hit = hits[0];
++ if (!hit) return null;
++ return {
++ name: hit.name || String(place).split(',')[0].trim(),
++ admin1: (hit.address && (hit.address.state || hit.address.county)) || null,
++ country: (hit.address && hit.address.country) || null,
++ country_code: (hit.address && hit.address.country_code) || null,
++ latitude: Number(hit.lat),
++ longitude: Number(hit.lon),
++ elevation_m: null,
++ population: null,
++ timezone: null,
++ feature_code: hit.type || null,
++ display_name: hit.display_name || null,
++ };
++}
++
++module.exports = {
++ name: 'geocode',
++ description: 'Turn a place name into coordinates and what goes with them: the country, the region, the time zone, '
++ + 'the population and the elevation. Uses Open-Meteo geocoding (free, no key), with OpenStreetMap Nominatim as '
++ + 'the second opinion. Good for "where is X", for a timezone and for checking a location before a lookup.',
++ input: {
++ type: 'object',
++ properties: {
++ place: { type: 'string', description: 'The place to look up, for example "Lisbon", "Mount Fuji" or "1600 Pennsylvania Avenue".' },
++ },
++ required: ['place'],
++ },
++ scope: 'public',
++ costUsd: 0,
++ timeoutMs: 15000,
++ sources: [
++ source('https://open-meteo.com/en/docs/geocoding-api', 'Open-Meteo: geocoding API'),
++ source('https://www.openstreetmap.org/copyright', 'OpenStreetMap contributors (ODbL)'),
++ ],
++
++ async run(input = {}, ctx = {}) {
++ const place = String(input.place || '').trim();
++ if (!place) return fail('geocode needs a place to look up, for example "Lisbon".');
++ const key = 'geocode:' + place.toLowerCase();
++
++ try {
++ const result = await memo(CACHE, key, TTL_MS, async () => {
++ let hits = [];
++ try {
++ hits = await openMeteo(ctx, place, ctx.signal);
++ } catch (err) {
++ if (err && err.code === 'tool.http') throw err;
++ }
++ if (!hits.length) {
++ const fallback = await osm(ctx, place, ctx.signal);
++ if (!fallback) throw new ToolError('no place called "' + place + '" was found', 'tool.not_found');
++ return build(fallback, [], 'OpenStreetMap Nominatim');
++ }
++ const best = hits[0];
++ const normalised = {
++ name: best.name,
++ admin1: best.admin1 || null,
++ country: best.country || null,
++ country_code: best.country_code || null,
++ latitude: best.latitude,
++ longitude: best.longitude,
++ elevation_m: best.elevation == null ? null : round(best.elevation),
++ population: best.population || null,
++ timezone: best.timezone || null,
++ feature_code: best.feature_code || null,
++ display_name: null,
++ };
++ return build(normalised, hits.slice(1), 'Open-Meteo geocoding');
++ });
++ return result;
++ } catch (err) {
++ return fail((err && err.message) || 'the place could not be looked up');
++ }
++ },
++};
++
++function build(place, alternatives, provider) {
++ const where = [place.name, place.admin1, place.country].filter(Boolean).join(', ');
++ const coords = round(place.latitude, 5) + ', ' + round(place.longitude, 5);
++ const lines = [where + ' is at ' + coords + '.'];
++ if (place.timezone) lines.push('Time zone: ' + place.timezone + '.');
++ if (place.population) lines.push('Population: ' + place.population.toLocaleString('en-US') + '.');
++ if (place.elevation_m != null) lines.push('Elevation: ' + place.elevation_m + ' m.');
++ if (place.display_name) lines.push(place.display_name + '.');
++ if (alternatives.length) {
++ lines.push('Other places with that name:');
++ for (const alt of alternatives) {
++ lines.push('- ' + [alt.name, alt.admin1, alt.country].filter(Boolean).join(', ') + ' (' + round(alt.latitude, 4) + ', ' + round(alt.longitude, 4) + ')');
++ }
++ }
++ return {
++ text: lines.join('\n'),
++ data: {
++ place: {
++ name: place.name,
++ admin1: place.admin1,
++ country: place.country,
++ country_code: place.country_code,
++ latitude: round(place.latitude, 6),
++ longitude: round(place.longitude, 6),
++ elevation_m: place.elevation_m,
++ population: place.population,
++ timezone: place.timezone,
++ feature_code: place.feature_code,
++ display_name: place.display_name,
++ },
++ alternatives: alternatives.map((alt) => ({
++ name: alt.name,
++ admin1: alt.admin1 || null,
++ country: alt.country || null,
++ latitude: round(alt.latitude, 6),
++ longitude: round(alt.longitude, 6),
++ })),
++ provider,
++ fetched_at: new Date().toISOString(),
++ },
++ sources: [
++ source('https://open-meteo.com/en/docs/geocoding-api', 'Open-Meteo: geocoding API'),
++ source('https://www.openstreetmap.org/copyright', 'OpenStreetMap contributors (ODbL)'),
++ ],
++ artifacts: [],
++ };
++}
ddiff --git a/server/runtime/tools/lib/nominatim.js b/server/runtime/tools/lib/nominatim.js
nnew file mode 100644
iindex 0000000..0a8180a
---- /dev/null
++++ b/server/runtime/tools/lib/nominatim.js
@@@ -0,0 +1,35 @@
++'use strict';
++/**
++ * OpenStreetMap Nominatim, the map search Actor uses when a name has to become a place.
++ *
++ * Nominatim's usage policy (https://operations.osmfoundation.org/policies/nominatim/): a real
++ * application User-Agent with a way to reach us, at most one request a second, no heavy use, and
++ * results that are cached rather than asked for again. This module keeps all four: the User-Agent
++ * below names Actor and its address, the limiter spaces the calls a second apart, and the cache
++ * holds an answer for an hour. We only ever search for a complete place name.
++ */
++const { Cache, HostLimiter, MAPS_USER_AGENT, requestJson, memo } = require('./util');
++
++const ENDPOINT = 'https://nominatim.openstreetmap.org/search';
++const LIMITER = new HostLimiter({ gapMs: 1000 });
++const CACHE = new Cache({ max: 128 });
++const TTL_MS = 60 * 60 * 1000;
++
++/** The places Nominatim knows for a name, best first. */
++async function search(ctx, query, { limit = 1, signal } = {}) {
++ const key = 'nominatim:' + String(query).trim().toLowerCase() + ':' + limit;
++ return await memo(CACHE, key, TTL_MS, async () => {
++ await LIMITER.wait('nominatim.openstreetmap.org');
++ const url = ENDPOINT
++ + '?q=' + encodeURIComponent(String(query).trim())
++ + '&format=jsonv2&limit=' + limit + '&addressdetails=1&extratags=1&namedetails=1';
++ const { data } = await requestJson(ctx, url, {
++ signal,
++ headers: { 'user-agent': MAPS_USER_AGENT },
++ label: 'OpenStreetMap Nominatim',
++ });
++ return Array.isArray(data) ? data : [];
++ });
++}
++
++module.exports = { search, ENDPOINT };
ddiff --git a/server/runtime/tools/lib/tz.js b/server/runtime/tools/lib/tz.js
nnew file mode 100644
iindex 0000000..b5bf9dd
---- /dev/null
++++ b/server/runtime/tools/lib/tz.js
@@@ -0,0 +1,155 @@
++'use strict';
++/**
++ * IANA time zones through Node's own Intl: no network, no table of ours, and daylight saving
++ * handled by the platform (a zone's offset depends on the date, so every offset here is read for
++ * the instant it belongs to).
++ */
++
++const PARTS = { timeZone: 'UTC', hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' };
++
++/** True when Node knows the zone. */
++function isZone(zone) {
++ if (typeof zone !== 'string' || !zone.trim()) return false;
++ try {
++ new Intl.DateTimeFormat('en-US', { timeZone: zone.trim() });
++ return true;
++ } catch {
++ return false;
++ }
++}
++
++/** The wall-clock parts of an instant in a zone. */
++function partsInZone(date, zone) {
++ const out = {};
++ for (const part of new Intl.DateTimeFormat('en-US', { ...PARTS, timeZone: zone }).formatToParts(date)) {
++ if (part.type !== 'literal') out[part.type] = part.value;
++ }
++ return {
++ year: Number(out.year),
++ month: Number(out.month),
++ day: Number(out.day),
++ hour: Number(out.hour) % 24,
++ minute: Number(out.minute),
++ second: Number(out.second),
++ };
++}
++
++/** The zone's offset from UTC at an instant, in milliseconds. */
++function offsetMs(date, zone) {
++ const p = partsInZone(date, zone);
++ const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
++ return asUtc - Math.floor(date.getTime() / 1000) * 1000;
++}
++
++/** The offset in minutes, with the sign a person expects (+120 for UTC+2). */
++function offsetMinutes(date, zone) {
++ return Math.round(offsetMs(date, zone) / 60000);
++}
++
++/** "UTC+02:00" (or "UTC" for no offset). */
++function offsetLabel(date, zone) {
++ const mins = offsetMinutes(date, zone);
++ if (mins === 0) return 'UTC';
++ const sign = mins < 0 ? '-' : '+';
++ const abs = Math.abs(mins);
++ return 'UTC' + sign + String(Math.floor(abs / 60)).padStart(2, '0') + ':' + String(abs % 60).padStart(2, '0');
++}
++
++/** The zone's short name at an instant ("CET", "PDT", "GMT+1"). */
++function zoneAbbreviation(date, zone) {
++ const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'short' }).formatToParts(date);
++ const found = parts.find((p) => p.type === 'timeZoneName');
++ return found ? found.value : '';
++}
++
++/** The wall-clock time in a zone as "YYYY-MM-DD HH:MM:SS". */
++function formatInZone(date, zone) {
++ const p = partsInZone(date, zone);
++ const pad = (n) => String(n).padStart(2, '0');
++ return p.year + '-' + pad(p.month) + '-' + pad(p.day) + ' ' + pad(p.hour) + ':' + pad(p.minute) + ':' + pad(p.second);
++}
++
++/** The wall-clock time in a zone without the seconds: "2026-10-30 14:36". */
++function formatInZoneShort(date, zone) {
++ return formatInZone(date, zone).slice(0, 16);
++}
++
++/** The UTC instant of a wall-clock time in a zone (two passes settle the offset, DST included). */
++function zonedToUtc({ year, month, day, hour = 0, minute = 0, second = 0 }, zone) {
++ const wall = Date.UTC(year, month - 1, day, hour, minute, second);
++ let ts = wall - offsetMs(new Date(wall), zone);
++ ts = wall - offsetMs(new Date(ts), zone);
++ return new Date(ts);
++}
++
++/**
++ * Every offset change of a zone in a year: when the clocks go forward or back, and by how much.
++ * Read from the platform day by day, so a zone that does not use daylight saving simply has none.
++ */
++function yearTransitions(year, zone) {
++ const out = [];
++ let previous = offsetMs(new Date(Date.UTC(year, 0, 1)), zone);
++ for (let day = 1; day < 366; day += 1) {
++ const at = Date.UTC(year, 0, 1) + day * 86400000;
++ const d = new Date(at);
++ if (d.getUTCFullYear() !== year) break;
++ const now = offsetMs(d, zone);
++ if (now !== previous) {
++ out.push({ at: new Date(at), before: previous, after: now });
++ previous = now;
++ }
++ }
++ return out;
++}
++
++/** Whether daylight saving is in force right now, and when it next changes. */
++function daylightSaving(now, zone) {
++ const year = now.getUTCFullYear();
++ const transitions = yearTransitions(year, zone);
++ const current = offsetMs(now, zone);
++ const standard = transitions.length ? Math.min(...transitions.map((t) => Math.min(t.before, t.after))) : current;
++ const next = transitions.find((t) => t.at.getTime() > now.getTime()) || null;
++ const last = [...transitions].reverse().find((t) => t.at.getTime() <= now.getTime()) || null;
++ return {
++ inEffect: current !== standard,
++ standardOffsetMs: standard,
++ currentOffsetMs: current,
++ transitions,
++ next,
++ last,
++ };
++}
++
++/** The difference between two zones right now, in minutes (the second minus the first). */
++function differenceMinutes(now, fromZone, toZone) {
++ return offsetMinutes(now, toZone) - offsetMinutes(now, fromZone);
++}
++
++/** "6 hours ahead", "30 minutes behind", "the same time". */
++function describeDifference(minutes) {
++ if (minutes === 0) return 'the same time';
++ const abs = Math.abs(minutes);
++ const hours = Math.floor(abs / 60);
++ const mins = abs % 60;
++ const span = [
++ hours ? hours + (hours === 1 ? ' hour' : ' hours') : null,
++ mins ? mins + (mins === 1 ? ' minute' : ' minutes') : null,
++ ].filter(Boolean).join(' ');
++ return span + (minutes > 0 ? ' ahead' : ' behind');
++}
++
++module.exports = {
++ isZone,
++ partsInZone,
++ offsetMs,
++ offsetMinutes,
++ offsetLabel,
++ zoneAbbreviation,
++ formatInZone,
++ formatInZoneShort,
++ zonedToUtc,
++ yearTransitions,
++ daylightSaving,
++ differenceMinutes,
++ describeDifference,
++};
ddiff --git a/server/runtime/tools/lib/util.js b/server/runtime/tools/lib/util.js
nnew file mode 100644
iindex 0000000..0bc45a5
---- /dev/null
++++ b/server/runtime/tools/lib/util.js
@@@ -0,0 +1,155 @@
++'use strict';
++/**
++ * Shared plumbing for Actor's live-data tool modules (server/runtime/tools/).
++ *
++ * Every outbound call goes through the fetch the loop hands a tool (ctx.fetch). A URL is always
++ * built here from a fixed provider base plus the model's own words, encoded into the query, so
++ * there is never an address a caller or a model chose. The hosts these modules need the fetch to
++ * allow:
++ * api.open-meteo.com, geocoding-api.open-meteo.com, api.coingecko.com, api.frankfurter.dev,
++ * api.frankfurter.app, www.thesportsdb.com, nominatim.openstreetmap.org, en.wikipedia.org
++ * (and the other language hosts of Wikipedia).
++ */
++const USER_AGENT = 'OpenVibeActor/2.0 (+https://openvibe.actor)';
++/** Nominatim asks for an application User-Agent with a way to reach us. */
++const MAPS_USER_AGENT = 'OpenVibe.Actor/2.0 (https://openvibe.actor; [email protected])';
++
++/** An error the model can act on: it becomes the tool's error text, never a crash. */
++class ToolError extends Error {
++ constructor(message, code = 'tool.error') {
++ super(message);
++ this.name = 'ToolError';
++ this.code = code;
++ }
++}
++
++const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
++
++/** An in-process cache with a TTL and a size cap. */
++class Cache {
++ constructor({ max = 256 } = {}) {
++ this.max = max;
++ this.entries = new Map();
++ }
++
++ get(key) {
++ const hit = this.entries.get(key);
++ if (!hit) return undefined;
++ if (hit.expires <= Date.now()) {
++ this.entries.delete(key);
++ return undefined;
++ }
++ return hit.value;
++ }
++
++ set(key, value, ttlMs) {
++ this.entries.set(key, { value, expires: Date.now() + ttlMs });
++ while (this.entries.size > this.max) this.entries.delete(this.entries.keys().next().value);
++ }
++}
++
++/** Remember a result for ttlMs, so the same call twice in a minute costs one request. */
++async function memo(cache, key, ttlMs, produce) {
++ const hit = cache.get(key);
++ if (hit !== undefined) return hit;
++ const value = await produce();
++ cache.set(key, value, ttlMs);
++ return value;
++}
++
++/** At most one call per host every gapMs (Nominatim asks for no more than one a second). */
++class HostLimiter {
++ constructor({ gapMs = 1000 } = {}) {
++ this.gapMs = gapMs;
++ this.nextAt = new Map();
++ }
++
++ async wait(host) {
++ const now = Date.now();
++ const at = Math.max(now, this.nextAt.get(host) || 0);
++ this.nextAt.set(host, at + this.gapMs);
++ if (at > now) await sleep(at - now);
++ }
++}
++
++/** The loop's fetch. A tool without one cannot reach the network, and says so. */
++function fetchOf(ctx) {
++ if (ctx && typeof ctx.fetch === 'function') return ctx.fetch;
++ throw new ToolError('this tool needs the fetch the loop hands it (ctx.fetch); it has none', 'tool.no_fetch');
++}
++
++/** One GET, with the task's signal and a timeout, and JSON in hand. */
++async function requestJson(ctx, url, { signal, headers = {}, label = 'the source', timeoutMs = 15000 } = {}) {
++ const timeout = AbortSignal.timeout(timeoutMs);
++ const sig = signal ? AbortSignal.any([signal, timeout]) : timeout;
++ let res;
++ try {
++ res = await fetchOf(ctx)(url, {
++ method: 'GET',
++ headers: { accept: 'application/json', 'user-agent': USER_AGENT, ...headers },
++ signal: sig,
++ });
++ } catch (err) {
++ if (err && err.name === 'ToolError') throw err;
++ throw new ToolError(label + ' could not be reached: ' + ((err && err.message) || 'network error'), 'tool.network');
++ }
++ const status = res && typeof res.status === 'number' ? res.status : 200;
++ let data = null;
++ if (res && typeof res.text === 'function') {
++ const text = await res.text();
++ try {
++ data = text ? JSON.parse(text) : null;
++ } catch {
++ data = null;
++ }
++ if (!data && status >= 200 && status < 300) throw new ToolError(label + ' answered with something that is not JSON', 'tool.bad_answer');
++ if (!data) throw new ToolError(label + ' answered ' + status, 'tool.http');
++ } else if (res && typeof res.json === 'function') {
++ data = await res.json();
++ }
++ if (status < 200 || status >= 300) {
++ const detail = (data && (data.error || data.message || data.detail)) || ('status ' + status);
++ throw new ToolError(label + ' answered ' + status + ': ' + detail, 'tool.http');
++ }
++ return { status, data };
++}
++
++/** A number for a person: no float noise, no negative zero. */
++function round(value, digits = 10) {
++ if (!Number.isFinite(value)) return value;
++ const r = Number(value.toPrecision(digits));
++ return Object.is(r, -0) ? 0 : r;
++}
++
++/** The same number with a thousands separator, for a text line a person reads. */
++function group(value) {
++ const n = round(value, 10);
++ if (!Number.isFinite(n)) return String(n);
++ const fixed = Math.abs(n) >= 1000 ? n.toFixed(2) : String(n);
++ const parts = fixed.split('.');
++ const whole = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
++ return parts[1] ? whole + '.' + parts[1] : whole;
++}
++
++/**
++ * A tool that could not answer. The message is in the error, in the text the model reads and in the
++ * isError flag the loop marks the step with, so nothing is lost on the way out.
++ */
++const fail = (message) => ({ error: message, text: message, isError: true });
++
++/** The provider's page for the data: every result carries it as its evidence. */
++const source = (url, title) => ({ url, title });
++
++module.exports = {
++ USER_AGENT,
++ MAPS_USER_AGENT,
++ ToolError,
++ Cache,
++ HostLimiter,
++ memo,
++ requestJson,
++ round,
++ group,
++ source,
++ fail,
++};
ddiff --git a/server/runtime/tools/market_quote.js b/server/runtime/tools/market_quote.js
nnew file mode 100644
iindex 0000000..1230b98
---- /dev/null
++++ b/server/runtime/tools/market_quote.js
@@@ -0,0 +1,170 @@
++'use strict';
++/**
++ * market_quote: a price for a crypto asset or a currency pair.
++ *
++ * Crypto comes from CoinGecko's simple price API (https://www.coingecko.com/en/api), which is free
++ * and keyless; the rate-limited free tier is why a quote is cached for a minute.
++ * Foreign exchange comes from Frankfurter (https://www.frankfurter.app), the European Central
++ * Bank's reference rates, refreshed once a working day.
++ *
++ * Stocks are deliberately left out: the free quote feeds that need no key (stooq's CSV, Yahoo's
++ * chart endpoint) answer a browser check instead of data from a server, so a share price could not
++ * be trusted to be current. This tool never invents one; it says it has no stock source.
++ */
++const { Cache, memo, requestJson, ToolError, round, group, source, fail } = require('./lib/util');
++
++const TTL_MS = 60 * 1000;
++const CACHE = new Cache();
++
++const COINGECKO = 'https://api.coingecko.com/api/v3/simple/price';
++const FRANKFURTER = 'https://api.frankfurter.dev/v1/latest';
++
++/** The coins a person is most likely to ask for, by ticker. */
++const COIN_IDS = {
++ BTC: 'bitcoin', ETH: 'ethereum', USDT: 'tether', USDC: 'usd-coin', BNB: 'binancecoin',
++ SOL: 'solana', XRP: 'ripple', DOGE: 'dogecoin', ADA: 'cardano', DOT: 'polkadot',
++ LINK: 'chainlink', TRX: 'tron', AVAX: 'avalanche-2', SHIB: 'shiba-inu', MATIC: 'matic-network',
++ TON: 'the-open-network', LTC: 'litecoin', BCH: 'bitcoin-cash', ATOM: 'cosmos', UNI: 'uniswap',
++};
++
++/** The currencies Frankfurter knows (the ECB's set). */
++const isCurrencyCode = (code) => typeof code === 'string' && /^[A-Za-z]{3}$/.test(code);
++
++/** "btc", "BTC", "bitcoin" or "avalanche-2" all name a coin; a pair like "EURUSD" names a rate. */
++function parseAsset(raw) {
++ const asset = String(raw || '').trim();
++ if (!asset) return null;
++ const pair = asset.replace(/[^A-Za-z]/g, '');
++ if (pair.length === 6 && isCurrencyCode(pair.slice(0, 3)) && isCurrencyCode(pair.slice(3))) {
++ return { kind: 'fx', base: pair.slice(0, 3).toUpperCase(), quote: pair.slice(3).toUpperCase() };
++ }
++ const upper = asset.toUpperCase();
++ if (COIN_IDS[upper]) return { kind: 'crypto', id: COIN_IDS[upper], symbol: upper, name: null };
++ const lower = asset.toLowerCase();
++ if (/^[a-z0-9][a-z0-9-]*$/.test(lower)) return { kind: 'crypto', id: lower, symbol: null, name: null };
++ return null;
++}
++
++async function cryptoQuote(ctx, id, currency, signal) {
++ const url = COINGECKO
++ + '?ids=' + encodeURIComponent(id)
++ + '&vs_currencies=' + encodeURIComponent(currency)
++ + '&include_24hr_change=true&include_market_cap=true&include_last_updated_at=true';
++ const { data } = await requestJson(ctx, url, { signal, label: 'CoinGecko' });
++ const entry = data && data[id];
++ if (!entry) throw new ToolError('CoinGecko has no coin called "' + id + '"', 'tool.not_found');
++ const price = entry[currency];
++ if (price == null) throw new ToolError('CoinGecko has no ' + currency.toUpperCase() + ' price for "' + id + '"', 'tool.not_found');
++ return { price, change: entry[currency + '_24h_change'], marketCap: entry[currency + '_market_cap'], updatedAt: entry.last_updated_at };
++}
++
++async function fxQuote(ctx, base, quote, signal) {
++ const url = FRANKFURTER + '?base=' + encodeURIComponent(base) + '&symbols=' + encodeURIComponent(quote);
++ const { data } = await requestJson(ctx, url, { signal, label: 'Frankfurter' });
++ const rate = data && data.rates && data.rates[quote];
++ if (rate == null) throw new ToolError('Frankfurter does not know the currency "' + quote + '"', 'tool.not_found');
++ return { price: rate, change: null, marketCap: null, updatedAt: null, date: data.date };
++}
++
++module.exports = {
++ name: 'market_quote',
++ description: 'A current price for a crypto asset (bitcoin, ethereum, SOL, ...) or a currency pair (EURUSD). '
++ + 'Crypto comes from CoinGecko and foreign exchange from Frankfurter (the ECB reference rates); both are free '
++ + 'and need no key. Every quote carries the time it was taken. '
++ + 'Stocks are not part of this tool: there is no share-price source that is both keyless and reliable from a '
++ + 'server, so this tool never quotes a stock.',
++ input: {
++ type: 'object',
++ properties: {
++ asset: { type: 'string', description: 'The asset to price: a coin ("bitcoin", "ETH", "solana") or a currency pair ("EURUSD", "USD/JPY").' },
++ currency: { type: 'string', description: 'The currency to price a coin in. Defaults to usd.' },
++ },
++ required: ['asset'],
++ },
++ scope: 'public',
++ costUsd: 0,
++ timeoutMs: 15000,
++ sources: [
++ source('https://www.coingecko.com/en/api', 'CoinGecko: free crypto price API'),
++ source('https://www.frankfurter.app/', 'Frankfurter: ECB reference exchange rates'),
++ ],
++
++ async run(input = {}, ctx = {}) {
++ const parsed = parseAsset(input.asset);
++ if (!parsed) {
++ return fail('market_quote needs an asset, for example "bitcoin", "ETH" or "EURUSD".');
++ }
++ const currency = String(input.currency || 'usd').trim().toLowerCase();
++ const key = 'quote:' + (parsed.kind === 'fx' ? parsed.base + parsed.quote : parsed.id) + ':' + currency;
++
++ try {
++ const result = await memo(CACHE, key, TTL_MS, async () => {
++ if (parsed.kind === 'fx') {
++ const q = await fxQuote(ctx, parsed.base, parsed.quote, ctx.signal);
++ return buildFx(parsed, q);
++ }
++ const q = await cryptoQuote(ctx, parsed.id, currency, ctx.signal);
++ return buildCrypto(parsed, q, currency);
++ });
++ return result;
++ } catch (err) {
++ return fail((err && err.message) || 'the quote could not be fetched');
++ }
++ },
++};
++
++function buildCrypto(parsed, q, currency) {
++ const code = currency.toUpperCase();
++ const quoteTime = q.updatedAt ? new Date(q.updatedAt * 1000).toISOString() : new Date().toISOString();
++ const change = q.change == null ? null : round(q.change, 4);
++ const lines = [
++ 'One ' + parsed.id + ' is ' + group(q.price) + ' ' + code + '.',
++ ];
++ if (change != null) lines.push('Over 24 hours: ' + (change >= 0 ? 'up ' : 'down ') + Math.abs(change) + '%.');
++ if (q.marketCap != null) lines.push('Market capitalisation: ' + group(q.marketCap) + ' ' + code + '.');
++ lines.push('Quoted by CoinGecko at ' + quoteTime + '.');
++ return {
++ text: lines.join(' '),
++ data: {
++ asset: parsed.id,
++ kind: 'crypto',
++ currency: code,
++ price: round(q.price, 8),
++ change_24h_percent: change,
++ market_cap: q.marketCap == null ? null : round(q.marketCap, 2),
++ quote_time: quoteTime,
++ retrieved_at: new Date().toISOString(),
++ source: 'CoinGecko',
++ },
++ sources: [
++ source('https://www.coingecko.com/en/coins/' + parsed.id, 'CoinGecko: ' + parsed.id),
++ source('https://www.coingecko.com/en/api', 'CoinGecko: free crypto price API'),
++ ],
++ artifacts: [],
++ };
++}
++
++function buildFx(parsed, q) {
++ const quoteTime = q.date || new Date().toISOString().slice(0, 10);
++ const lines = [
++ '1 ' + parsed.base + ' = ' + group(q.price) + ' ' + parsed.quote + '.',
++ 'The rate is the European Central Bank reference rate of ' + quoteTime + ', published once a working day.',
++ ];
++ return {
++ text: lines.join(' '),
++ data: {
++ asset: parsed.base + '/' + parsed.quote,
++ kind: 'fx',
++ base: parsed.base,
++ quote: parsed.quote,
++ currency: parsed.quote,
++ price: round(q.price, 8),
++ change_24h_percent: null,
++ quote_time: quoteTime,
++ retrieved_at: new Date().toISOString(),
++ source: 'Frankfurter (ECB reference rates)',
++ },
++ sources: [source('https://www.frankfurter.app/', 'Frankfurter: ECB reference exchange rates')],
++ artifacts: [],
++ };
++}
ddiff --git a/server/runtime/tools/place_info.js b/server/runtime/tools/place_info.js
nnew file mode 100644
iindex 0000000..a989a6d
---- /dev/null
++++ b/server/runtime/tools/place_info.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * place_info: what a place is, in a sentence a person can use.
++ *
++ * Source: OpenStreetMap's Nominatim (https://nominatim.org) for the name, the kind of place and
++ * the address, and Open-Meteo's geocoding for the time zone, the population and the elevation.
++ * Nominatim's usage policy is kept in lib/nominatim.js: a real User-Agent, one call a second, and
++ * an hour of caching.
++ */
++const { Cache, ToolError, memo, requestJson, round, source, fail } = require('./lib/util');
++const nominatim = require('./lib/nominatim');
++
++const GEOCODE = 'https://geocoding-api.open-meteo.com/v1/search';
++const TTL_MS = 60 * 60 * 1000;
++const CACHE = new Cache();
++
++/** The name, the kind of place and the address, from OpenStreetMap. */
++async function osm(ctx, place, signal) {
++ const hits = await nominatim.search(ctx, place, { limit: 1, signal });
++ const hit = hits[0];
++ if (!hit) throw new ToolError('no place called "' + place + '" was found', 'tool.not_found');
++ return hit;
++}
++
++/** The time zone, the population and the elevation, from Open-Meteo (best effort). */
++async function extras(ctx, place, signal) {
++ try {
++ const url = GEOCODE + '?name=' + encodeURIComponent(place) + '&count=1&language=en&format=json';
++ const { data } = await requestJson(ctx, url, { signal, label: 'Open-Meteo geocoding' });
++ const hit = data && Array.isArray(data.results) ? data.results[0] : null;
++ if (!hit) return null;
++ return {
++ timezone: hit.timezone || null,
++ population: hit.population || null,
++ elevation_m: hit.elevation == null ? null : round(hit.elevation),
++ region: hit.admin1 || null,
++ country: hit.country || null,
++ country_code: hit.country_code || null,
++ };
++ } catch {
++ return null;
++ }
++}
++
++module.exports = {
++ name: 'place_info',
++ description: 'Describe a place: its name, the kind of place it is (a tower, a city, a park), its address, its '
++ + 'coordinates, its time zone and its population. Uses OpenStreetMap Nominatim and Open-Meteo geocoding; no key, '
++ + 'no sign-in.',
++ input: {
++ type: 'object',
++ properties: {
++ place: { type: 'string', description: 'The place to describe, for example "Eiffel Tower", "Sagrada Familia" or "Reykjavik".' },
++ },
++ required: ['place'],
++ },
++ scope: 'public',
++ costUsd: 0,
++ timeoutMs: 15000,
++ sources: [
++ source('https://www.openstreetmap.org/copyright', 'OpenStreetMap contributors (ODbL)'),
++ source('https://open-meteo.com/en/docs/geocoding-api', 'Open-Meteo: geocoding API'),
++ ],
++
++ async run(input = {}, ctx = {}) {
++ const place = String(input.place || '').trim();
++ if (!place) return fail('place_info needs a place to describe, for example "Eiffel Tower".');
++ const key = 'place:' + place.toLowerCase();
++
++ try {
++ const result = await memo(CACHE, key, TTL_MS, async () => {
++ const hit = await osm(ctx, place, ctx.signal);
++ const more = await extras(ctx, place, ctx.signal);
++ return build(hit, more);
++ });
++ return result;
++ } catch (err) {
++ return fail((err && err.message) || 'the place could not be looked up');
++ }
++ },
++};
++
++function build(hit, more) {
++ const address = hit.address || {};
++ const parts = [address.road, address.suburb, address.city || address.town || address.village, address.state, address.country].filter(Boolean);
++ const kind = [hit.category, hit.type].filter(Boolean).join('/');
++ const lines = [];
++ lines.push((hit.name || hit.display_name) + ' is a ' + (kind || 'place') + (parts.length ? ' in ' + parts.join(', ') : '') + '.');
++ lines.push('Coordinates: ' + round(Number(hit.lat), 6) + ', ' + round(Number(hit.lon), 6) + '.');
++ if (more && more.timezone) lines.push('Time zone: ' + more.timezone + '.');
++ if (more && more.population) lines.push('Population: ' + more.population.toLocaleString('en-US') + '.');
++ if (more && more.elevation_m != null) lines.push('Elevation: ' + more.elevation_m + ' m.');
++ if (hit.display_name) lines.push('OpenStreetMap says: ' + hit.display_name + '.');
++ return {
++ text: lines.join('\n'),
++ data: {
++ name: hit.name || null,
++ display_name: hit.display_name || null,
++ kind: hit.category || null,
++ type: hit.type || null,
++ address: {
++ road: address.road || null,
++ suburb: address.suburb || null,
++ city: address.city || address.town || address.village || null,
++ state: address.state || null,
++ country: address.country || null,
++ country_code: address.country_code || null,
++ postcode: address.postcode || null,
++ },
++ latitude: round(Number(hit.lat), 6),
++ longitude: round(Number(hit.lon), 6),
++ timezone: more ? more.timezone : null,
++ population: more ? more.population : null,
++ elevation_m: more ? more.elevation_m : null,
++ place_id: hit.place_id || null,
++ osm_type: hit.osm_type || null,
++ osm_id: hit.osm_id || null,
++ provider: 'OpenStreetMap Nominatim',
++ fetched_at: new Date().toISOString(),
++ },
++ sources: [
++ source('https://www.openstreetmap.org/copyright', 'OpenStreetMap contributors (ODbL)'),
++ source('https://open-meteo.com/en/docs/geocoding-api', 'Open-Meteo: geocoding API'),
++ ],
++ artifacts: [],
++ };
++}
ddiff --git a/server/runtime/tools/sports_scores.js b/server/runtime/tools/sports_scores.js
nnew file mode 100644
iindex 0000000..87ac504
---- /dev/null
++++ b/server/runtime/tools/sports_scores.js
@@@ -0,0 +1,268 @@
++'use strict';
++/**
++ * sports_scores: the standings of a league and the games around them.
++ *
++ * Source: TheSportsDB's free API (https://www.thesportsdb.com/api.php). Their public test key
++ * "123" answers the endpoints used here (a league table, the next games of a league, the last and
++ * next games of a team). Their terms: the free tier is rate-limited, so every answer here is
++ * cached for ten minutes and a league costs one table plus one page of games; the data is theirs,
++ * and they ask for a credit to TheSportsDB, which this tool gives in its sources. A deployment
++ * that wants more should set its own key (ctx.config.thesportsdbKey, or THESPORTSDB_KEY).
++ *
++ * The fixtures in test/fixtures/tools/ are recorded answers, so the test suite never calls out.
++ */
++const { Cache, ToolError, memo, requestJson, round, source, fail } = require('./lib/util');
++
++const BASE = 'https://www.thesportsdb.com/api/v1/json';
++const TTL_MS = 10 * 60 * 1000;
++const CACHE = new Cache();
++
++/** The leagues this tool knows by name, with TheSportsDB's league id. */
++const LEAGUES = {
++ 'premier league': { id: '4328', name: 'English Premier League', sport: 'Soccer' },
++ 'english premier league': { id: '4328', name: 'English Premier League', sport: 'Soccer' },
++ 'epl': { id: '4328', name: 'English Premier League', sport: 'Soccer' },
++ 'championship': { id: '4329', name: 'English League Championship', sport: 'Soccer' },
++ 'league championship': { id: '4329', name: 'English League Championship', sport: 'Soccer' },
++ 'scottish premier league': { id: '4330', name: 'Scottish Premier League', sport: 'Soccer' },
++ 'bundesliga': { id: '4331', name: 'German Bundesliga', sport: 'Soccer' },
++ 'serie a': { id: '4332', name: 'Italian Serie A', sport: 'Soccer' },
++ 'italian serie a': { id: '4332', name: 'Italian Serie A', sport: 'Soccer' },
++ 'ligue 1': { id: '4334', name: 'French Ligue 1', sport: 'Soccer' },
++ 'la liga': { id: '4335', name: 'Spanish La Liga', sport: 'Soccer' },
++ 'spanish la liga': { id: '4335', name: 'Spanish La Liga', sport: 'Soccer' },
++ 'greek super league': { id: '4336', name: 'Greek Super League 1', sport: 'Soccer' },
++ 'eredivisie': { id: '4337', name: 'Dutch Eredivisie', sport: 'Soccer' },
++ 'belgian pro league': { id: '4338', name: 'Belgian Pro League', sport: 'Soccer' },
++ 'mls': { id: '4346', name: 'Major League Soccer', sport: 'Soccer' },
++ 'major league soccer': { id: '4346', name: 'Major League Soccer', sport: 'Soccer' },
++ 'brasileirao': { id: '4404', name: 'Brazilian Série A', sport: 'Soccer' },
++ 'brazilian serie a': { id: '4404', name: 'Brazilian Série A', sport: 'Soccer' },
++ 'nba': { id: '4387', name: 'National Basketball Association', sport: 'Basketball' },
++ 'nfl': { id: '4391', name: 'National Football League', sport: 'American Football' },
++ 'national football league': { id: '4391', name: 'National Football League', sport: 'American Football' },
++ 'nhl': { id: '4380', name: 'National Hockey League', sport: 'Ice Hockey' },
++ 'national hockey league': { id: '4380', name: 'National Hockey League', sport: 'Ice Hockey' },
++ 'mlb': { id: '4424', name: 'Major League Baseball', sport: 'Baseball' },
++ 'major league baseball': { id: '4424', name: 'Major League Baseball', sport: 'Baseball' },
++};
++
++const KEY = (ctx) => {
++ const fromConfig = ctx && ctx.config && ctx.config.thesportsdbKey;
++ return String(fromConfig || process.env.THESPORTSDB_KEY || '123');
++};
++
++async function call(ctx, path, { signal, label } = {}) {
++ const url = BASE + '/' + KEY(ctx) + '/' + path;
++ const { data } = await requestJson(ctx, url, { signal, label: label || 'TheSportsDB' });
++ return data || {};
++}
++
++/** The leagues TheSportsDB lists, for a name this tool does not know. */
++async function allLeagues(ctx, signal) {
++ return await memo(CACHE, 'sports:leagues', 6 * 60 * 60 * 1000, async () => {
++ const data = await call(ctx, 'all_leagues.php', { signal });
++ return Array.isArray(data.leagues) ? data.leagues : [];
++ });
++}
++
++/** The league behind a name. */
++async function resolveLeague(ctx, league, signal) {
++ const key = String(league || '').trim().toLowerCase();
++ if (!key) throw new ToolError('sports_scores needs a league, for example "premier league" or "nba".', 'tool.not_found');
++ if (LEAGUES[key]) return LEAGUES[key];
++ const list = await allLeagues(ctx, signal);
++ const exact = list.find((l) => String(l.strLeague).toLowerCase() === key);
++ if (exact) return { id: String(exact.idLeague), name: exact.strLeague, sport: exact.strSport };
++ const partial = list.find((l) => String(l.strLeague).toLowerCase().includes(key));
++ if (partial) return { id: String(partial.idLeague), name: partial.strLeague, sport: partial.strSport };
++ throw new ToolError('this tool does not know the league "' + league + '". It knows ' + Object.values(LEAGUES).slice(0, 8).map((l) => l.name).join(', ') + ' and more.', 'tool.not_found');
++}
++
++/** The league table. */
++async function standings(ctx, league, signal) {
++ const data = await call(ctx, 'lookuptable.php?l=' + encodeURIComponent(league.id), { signal });
++ const table = Array.isArray(data.table) ? data.table : [];
++ return table.map((row) => ({
++ rank: row.intRank ? Number(row.intRank) : null,
++ team: row.strTeam || null,
++ team_id: row.idTeam ? String(row.idTeam) : null,
++ played: row.intPlayed != null ? Number(row.intPlayed) : null,
++ won: row.intWin != null ? Number(row.intWin) : null,
++ drawn: row.intDraw != null ? Number(row.intDraw) : null,
++ lost: row.intLoss != null ? Number(row.intLoss) : null,
++ goals_for: row.intGoalsFor != null ? Number(row.intGoalsFor) : null,
++ goals_against: row.intGoalsAgainst != null ? Number(row.intGoalsAgainst) : null,
++ goal_difference: row.intGoalDiff != null ? Number(row.intGoalDiff) : null,
++ points: row.intPoints != null ? Number(row.intPoints) : null,
++ form: row.strForm || null,
++ note: row.strDescription || null,
++ season: row.strSeason || null,
++ group: row.strGroup || null,
++ }));
++}
++
++/** The games, as one list whatever the endpoint called them. */
++const gamesOf = (data) => {
++ const list = Array.isArray(data.events) ? data.events : (Array.isArray(data.results) ? data.results : []);
++ return list.map((g) => ({
++ timestamp: g.strTimestamp || null,
++ date: g.strTimestamp ? String(g.strTimestamp).slice(0, 10) : null,
++ time: g.strTimestamp ? String(g.strTimestamp).slice(11, 16) : null,
++ event: g.strEvent || null,
++ home: g.strHomeTeam || null,
++ away: g.strAwayTeam || null,
++ home_score: g.intHomeScore != null ? Number(g.intHomeScore) : null,
++ away_score: g.intAwayScore != null ? Number(g.intAwayScore) : null,
++ status: g.strStatus || null,
++ venue: g.strVenue || null,
++ league: g.strLeague || null,
++ }));
++};
++
++async function nextLeagueGames(ctx, league, signal) {
++ const data = await call(ctx, 'eventsnextleague.php?id=' + encodeURIComponent(league.id), { signal });
++ return gamesOf(data);
++}
++
++/** The round just finished, so the games of it are the league's last results. */
++async function lastRoundGames(ctx, league, table, signal) {
++ const played = table.map((row) => row.played).filter((n) => typeof n === 'number');
++ const season = (table.find((row) => row.season) || {}).season;
++ if (!played.length || !season) return [];
++ const round = Math.max(...played);
++ const data = await call(ctx, 'eventsround.php?id=' + encodeURIComponent(league.id) + '&r=' + round + '&s=' + encodeURIComponent(season), { signal });
++ const games = gamesOf(data);
++ return games.filter((g) => g.home_score != null || g.away_score != null);
++}
++
++/** A team, by name. */
++async function findTeam(ctx, team, signal) {
++ const data = await call(ctx, 'searchteams.php?t=' + encodeURIComponent(team), { signal });
++ const teams = Array.isArray(data.teams) ? data.teams : [];
++ const wanted = String(team).trim().toLowerCase();
++ const hit = teams.find((t) => String(t.strTeam).toLowerCase() === wanted)
++ || teams.find((t) => String(t.strTeam).toLowerCase().includes(wanted))
++ || teams[0];
++ if (!hit) throw new ToolError('no team called "' + team + '" was found', 'tool.not_found');
++ return {
++ id: String(hit.idTeam),
++ name: hit.strTeam || null,
++ sport: hit.strSport || null,
++ league: hit.strLeague || null,
++ league_id: hit.idLeague ? String(hit.idLeague) : null,
++ badge: hit.strBadge || null,
++ };
++}
++
++async function teamGames(ctx, teamId, which, signal) {
++ const data = await call(ctx, which + '.php?id=' + encodeURIComponent(teamId), { signal });
++ return gamesOf(data);
++}
++
++module.exports = {
++ name: 'sports_scores',
++ description: 'The standings of a league and the games around them: the league table, the results of the last round '
++ + 'and the next games, or the same for one team (its last results, its next game and its place in the table). '
++ + 'Uses TheSportsDB (free, no key). Say the league ("premier league", "la liga", "nba", "nfl", "mls") and, '
++ + 'optionally, a team.',
++ input: {
++ type: 'object',
++ properties: {
++ league: { type: 'string', description: 'The league, for example "premier league", "la liga", "bundesliga", "nba", "nfl", "mls".' },
++ team: { type: 'string', description: 'A team in that league, for example "Arsenal" or "Lakers".' },
++ },
++ required: ['league'],
++ },
++ scope: 'public',
++ costUsd: 0,
++ timeoutMs: 15000,
++ sources: [source('https://www.thesportsdb.com/', 'TheSportsDB: free sports data')],
++
++ async run(input = {}, ctx = {}) {
++ const leagueName = String(input.league || '').trim();
++ const teamName = String(input.team || '').trim();
++ if (!leagueName && !teamName) {
++ return fail('sports_scores needs a league, for example "premier league" or "nba".');
++ }
++ const key = 'sports:' + leagueName.toLowerCase() + ':' + teamName.toLowerCase();
++
++ try {
++ const result = await memo(CACHE, key, TTL_MS, async () => {
++ const league = await resolveLeague(ctx, leagueName || 'premier league', ctx.signal);
++ // Some leagues have no table in the free key (the NFL among them); the games still do.
++ let table = [];
++ try {
++ table = await standings(ctx, league, ctx.signal);
++ } catch {
++ table = [];
++ }
++ const team = teamName ? await findTeam(ctx, teamName, ctx.signal) : null;
++
++ let recent = [];
++ let next = [];
++ if (team) {
++ recent = await teamGames(ctx, team.id, 'eventslast', ctx.signal);
++ next = await teamGames(ctx, team.id, 'eventsnext', ctx.signal);
++ } else {
++ next = await nextLeagueGames(ctx, league, ctx.signal);
++ recent = await lastRoundGames(ctx, league, table, ctx.signal);
++ }
++ const row = team ? table.find((r) => r.team_id === team.id) || null : null;
++ return build(league, table, row, team, recent, next);
++ });
++ return result;
++ } catch (err) {
++ return fail((err && err.message) || 'the standings could not be fetched');
++ }
++ },
++};
++
++function build(league, table, teamRow, team, recent, next) {
++ const lines = [];
++ const heading = team ? team.name + ' (' + league.name + ')' : league.name;
++ lines.push(heading + ' — standings');
++
++ const rows = team && teamRow ? [teamRow] : table.slice(0, 10);
++ for (const row of rows) {
++ const bits = [];
++ if (row.played != null) bits.push(row.played + ' played');
++ if (row.won != null) bits.push(row.won + ' won');
++ if (row.drawn != null) bits.push(row.drawn + ' drawn');
++ if (row.lost != null) bits.push(row.lost + ' lost');
++ if (row.points != null) bits.push(row.points + ' points');
++ if (row.goals_for != null) bits.push(row.goals_for + ' goals for');
++ if (row.goals_against != null) bits.push(row.goals_against + ' against');
++ lines.push((row.rank != null ? row.rank + '. ' : '') + row.team + (bits.length ? ' — ' + bits.join(', ') : '') + (row.form ? ' (form ' + row.form + ')' : '') + (row.note ? ' [' + row.note + ']' : ''));
++ }
++ if (team && !teamRow) lines.push(team.name + ' is not in this league table.');
++
++ if (recent.length) {
++ lines.push('Last results:');
++ for (const g of recent.slice(0, 5)) {
++ lines.push('- ' + (g.date || '') + ' ' + (g.home || '?') + ' ' + (g.home_score != null ? g.home_score : '-') + ' - ' + (g.away_score != null ? g.away_score : '-') + ' ' + (g.away || '?'));
++ }
++ }
++ if (next.length) {
++ lines.push('Next games:');
++ for (const g of next.slice(0, 5)) {
++ lines.push('- ' + (g.date || '') + (g.time ? ' ' + g.time : '') + ' ' + (g.home || '?') + ' vs ' + (g.away || '?'));
++ }
++ }
++ if (!recent.length && !next.length) lines.push('No games are listed for this league right now.');
++
++ return {
++ text: lines.join('\n'),
++ data: {
++ league: { id: league.id, name: league.name, sport: league.sport },
++ team: team ? { id: team.id, name: team.name, league: team.league } : null,
++ standings: table,
++ team_standing: teamRow,
++ recent_games: recent,
++ next_games: next,
++ fetched_at: new Date().toISOString(),
++ },
++ sources: [source('https://www.thesportsdb.com/', 'TheSportsDB: free sports data')],
++ artifacts: [],
++ };
++}
ddiff --git a/server/runtime/tools/time_in.js b/server/runtime/tools/time_in.js
nnew file mode 100644
iindex 0000000..62952cb
---- /dev/null
++++ b/server/runtime/tools/time_in.js
@@@ -0,0 +1,108 @@
++'use strict';
++/**
++ * time_in: the time in a place, right now, with the daylight saving rules of that place.
++ *
++ * Everything comes from Node's own IANA time zone database through Intl, so no network call is
++ * made and the offset that comes back is the one in force on that date - a zone that shifts its
++ * clocks twice a year gets the right offset in summer and in winter.
++ */
++const { ToolError, source, fail } = require('./lib/util');
++const tz = require('./lib/tz');
++
++/** The cities a person is most likely to ask about, so "New York" works as well as the zone name. */
++const CITIES = {
++ 'new york': 'America/New_York', 'los angeles': 'America/Los_angeles', 'san francisco': 'America/Los_angeles',
++ 'chicago': 'America/Chicago', 'denver': 'America/Denver', 'toronto': 'America/Toronto', 'vancouver': 'America/Vancouver',
++ 'mexico city': 'America/Mexico_City', 'sao paulo': 'America/Sao_Paulo', 'buenos aires': 'America/Argentina/Buenos_Aires',
++ 'london': 'Europe/London', 'dublin': 'Europe/Dublin', 'lisbon': 'Europe/Lisbon', 'madrid': 'Europe/Madrid',
++ 'barcelona': 'Europe/Madrid', 'paris': 'Europe/Paris', 'brussels': 'Europe/Brussels', 'amsterdam': 'Europe/Amsterdam',
++ 'berlin': 'Europe/Berlin', 'munich': 'Europe/Berlin', 'vienna': 'Europe/Vienna', 'zurich': 'Europe/Zurich',
++ 'milan': 'Europe/Rome', 'rome': 'Europe/Rome', 'copenhagen': 'Europe/Copenhagen', 'stockholm': 'Europe/Stockholm',
++ 'oslo': 'Europe/Oslo', 'helsinki': 'Europe/Helsinki', 'warsaw': 'Europe/Warsaw', 'prague': 'Europe/Prague',
++ 'athens': 'Europe/Athens', 'istanbul': 'Europe/Istanbul', 'moscow': 'Europe/Moscow', 'kyiv': 'Europe/Kyiv',
++ 'jerusalem': 'Asia/Jerusalem', 'dubai': 'Asia/Dubai', 'mumbai': 'Asia/Kolkata', 'delhi': 'Asia/Kolkata',
++ 'bangalore': 'Asia/Kolkata', 'singapore': 'Asia/Singapore', 'bangkok': 'Asia/Bangkok', 'jakarta': 'Asia/Jakarta',
++ 'hong kong': 'Asia/Hong_Kong', 'shanghai': 'Asia/Shanghai', 'beijing': 'Asia/Shanghai', 'taipei': 'Asia/Taipei',
++ 'seoul': 'Asia/Seoul', 'tokyo': 'Asia/Tokyo', 'osaka': 'Asia/Tokyo', 'perth': 'Australia/Perth',
++ 'sydney': 'Australia/Sydney', 'melbourne': 'Australia/Melbourne', 'brisbane': 'Australia/Brisbane',
++ 'auckland': 'Pacific/Auckland', 'honolulu': 'Pacific/Honolulu',
++ 'utc': 'UTC', 'gmt': 'UTC',
++};
++
++/** The zone behind a name: an IANA zone as it is, or one of the cities above. */
++function resolveZone(name) {
++ const wanted = String(name || '').trim();
++ if (!wanted) return null;
++ if (tz.isZone(wanted)) return wanted;
++ const key = wanted.toLowerCase();
++ if (CITIES[key]) return CITIES[key];
++ return null;
++}
++
++module.exports = {
++ name: 'time_in',
++ description: 'The local time in a place right now, with its UTC offset, its time zone abbreviation and whether '
++ + 'daylight saving is in force (and when it next changes). Give an IANA zone ("Europe/Berlin", "America/New_York") '
++ + 'or a city name ("Tokyo", "Sao Paulo"). No network call: the answer comes from the platform time zone database.',
++ input: {
++ type: 'object',
++ properties: {
++ zone: { type: 'string', description: 'The place to give the time for: an IANA zone ("Europe/Berlin") or a city ("Tokyo").' },
++ },
++ required: ['zone'],
++ },
++ scope: 'public',
++ costUsd: 0,
++ timeoutMs: 5000,
++ sources: [source('https://www.iana.org/time-zones', 'IANA: the time zone database')],
++
++ async run(input = {}, ctx = {}) {
++ const wanted = String(input.zone || '').trim();
++ if (!wanted) return fail('time_in needs a place, for example "Europe/Berlin" or "Tokyo".');
++ const zone = resolveZone(wanted);
++ if (!zone) {
++ return fail('"' + wanted + '" is not a time zone this tool knows. Give an IANA zone ("Europe/Berlin", "America/New_York") or a city ("Tokyo", "Sao Paulo").');
++ }
++ try {
++ const now = new Date();
++ const dst = tz.daylightSaving(now, zone);
++ const offset = tz.offsetLabel(now, zone);
++ const abbreviation = tz.zoneAbbreviation(now, zone);
++ const local = tz.formatInZone(now, zone);
++ const lines = [
++ zone + ' is ' + local + ' (' + (abbreviation || 'local time') + ', ' + offset + ').',
++ 'It is ' + (dst.inEffect ? 'daylight saving time' : 'standard time') + ' there'
++ + (dst.next ? '; the clocks change on ' + dst.next.at.toISOString().slice(0, 10) + ' (to ' + tz.offsetLabel(dst.next.at, zone) + ')' : '') + '.',
++ 'That is ' + local.slice(11) + ' in UTC (' + now.toISOString() + ').',
++ ];
++ return {
++ text: lines.join(' '),
++ data: {
++ zone,
++ zone_name: zone.split('/').pop().replace(/_/g, ' '),
++ local_time: local,
++ utc_offset: offset,
++ utc_offset_minutes: tz.offsetMinutes(now, zone),
++ abbreviation,
++ dst: dst.inEffect,
++ dst_next_transition: dst.next
++ ? {
++ date: dst.next.at.toISOString().slice(0, 10),
++ offset_before: tz.offsetLabel(dst.next.at, zone),
++ offset_after: tz.offsetLabel(new Date(dst.next.at.getTime() + 86400000), zone),
++ }
++ : null,
++ utc_time: now.toISOString(),
++ fetched_at: now.toISOString(),
++ },
++ sources: [source('https://www.iana.org/time-zones', 'IANA: the time zone database')],
++ artifacts: [],
++ };
++ } catch (err) {
++ return fail((err && err.message) || 'the time could not be read');
++ }
++ },
++};
++
++module.exports.CITIES = CITIES;
++module.exports.resolveZone = resolveZone;
ddiff --git a/server/runtime/tools/weather_now.js b/server/runtime/tools/weather_now.js
nnew file mode 100644
iindex 0000000..28055da
---- /dev/null
++++ b/server/runtime/tools/weather_now.js
@@@ -0,0 +1,199 @@
++'use strict';
++/**
++ * weather_now: the weather now and the next three days for a place.
++ *
++ * Source: Open-Meteo (https://open-meteo.com). Its geocoding API finds the place (a name, in any
++ * language), its forecast API gives the current conditions and the daily forecast. Both are free
++ * and keyless; Open-Meteo asks for fair use, which the ten-minute cache and the timeout respect.
++ * The API is documented at https://open-meteo.com/en/docs.
++ *
++ * Units: the place's own units come first (places that use the US system show Fahrenheit, every
++ * other place Celsius) and the other one follows, so no one has to convert.
++ */
++const { Cache, memo, requestJson, ToolError, round, group, source, fail } = require('./lib/util');
++
++const TTL_MS = 10 * 60_000;
++const CACHE = new Cache();
++
++const GEOCODE = 'https://geocoding-api.open-meteo.com/v1/search';
++const FORECAST = 'https://api.open-meteo.com/v1/forecast';
++
++/** The WMO weather codes Open-Meteo answers with, in words. */
++const WEATHER = {
++ 0: 'clear sky', 1: 'mainly clear', 2: 'partly cloudy', 3: 'overcast',
++ 45: 'fog', 48: 'rime fog',
++ 51: 'light drizzle', 53: 'drizzle', 55: 'heavy drizzle', 56: 'freezing drizzle', 57: 'freezing drizzle',
++ 61: 'light rain', 63: 'rain', 65: 'heavy rain', 66: 'freezing rain', 67: 'freezing rain',
++ 71: 'light snow', 73: 'snow', 75: 'heavy snow', 77: 'snow grains',
++ 80: 'light showers', 81: 'showers', 82: 'violent showers', 85: 'snow showers', 86: 'heavy snow showers',
++ 95: 'thunderstorm', 96: 'thunderstorm with hail', 99: 'thunderstorm with heavy hail',
++};
++
++/** The places that use the US system. */
++const US_FIRST = new Set(['US', 'LR', 'MM']);
++
++const cToF = (c) => (c * 9) / 5 + 32;
++const kmhToMph = (k) => k * 0.621371;
++const mmToIn = (m) => m / 25.4;
++
++const describe = (code) => WEATHER[code] || 'unknown conditions';
++
++const dayName = (iso) => {
++ const d = new Date(iso + 'T12:00:00Z');
++ return d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
++};
++
++async function geocode(ctx, place, signal) {
++ const url = GEOCODE + '?name=' + encodeURIComponent(place) + '&count=1&language=en&format=json';
++ const { data } = await requestJson(ctx, url, { signal, label: 'Open-Meteo geocoding' });
++ const hit = data && Array.isArray(data.results) && data.results[0];
++ if (!hit) throw new ToolError('no place called "' + place + '" was found', 'tool.not_found');
++ return hit;
++}
++
++async function forecast(ctx, hit, days, signal) {
++ const url = FORECAST
++ + '?latitude=' + hit.latitude + '&longitude=' + hit.longitude
++ + '&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,precipitation'
++ + '&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max'
++ + '&timezone=auto&forecast_days=' + days;
++ const { data } = await requestJson(ctx, url, { signal, label: 'Open-Meteo forecast' });
++ if (!data || !data.current) throw new ToolError('Open-Meteo answered without current conditions', 'tool.bad_answer');
++ return data;
++}
++
++module.exports = {
++ name: 'weather_now',
++ description: 'Current weather and the next three days for a place (a city, a town, a landmark). '
++ + 'Uses Open-Meteo: no key, no sign-in. Gives the temperature now in both Celsius and Fahrenheit, '
++ + 'the wind, the humidity and a three-day forecast.',
++ input: {
++ type: 'object',
++ properties: {
++ place: { type: 'string', description: 'The place to report on, for example "Berlin", "Kyoto, Japan" or "Eiffel Tower".' },
++ days: { type: 'integer', description: 'How many days of forecast to include, 1 to 3.', minimum: 1, maximum: 3 },
++ },
++ required: ['place'],
++ },
++ scope: 'public',
++ costUsd: 0,
++ timeoutMs: 15000,
++ sources: [
++ source('https://open-meteo.com/', 'Open-Meteo: free weather forecast API'),
++ source('https://open-meteo.com/en/docs/geocoding-api', 'Open-Meteo: geocoding API'),
++ ],
++
++ async run(input = {}, ctx = {}) {
++ const place = String(input.place || '').trim();
++ if (!place) return fail('weather_now needs a place, for example "Berlin" or "Kyoto, Japan".');
++ const days = Math.min(3, Math.max(1, parseInt(input.days, 10) || 3));
++ const key = 'weather:' + place.toLowerCase() + ':' + days;
++
++ try {
++ const result = await memo(CACHE, key, TTL_MS, async () => {
++ const hit = await geocode(ctx, place, ctx.signal);
++ const data = await forecast(ctx, hit, days, ctx.signal);
++ return build(hit, data, days);
++ });
++ return result;
++ } catch (err) {
++ return fail((err && err.message) || 'the weather could not be fetched');
++ }
++ },
++};
++
++/** The place, the current conditions and the forecast, as text for the model and as data. */
++function build(hit, data, days) {
++ const c = data.current || {};
++ const daily = data.daily || {};
++ const usFirst = US_FIRST.has(String(hit.country_code || '').toUpperCase());
++ const temp = (celsius) => {
++ const f = cToF(celsius);
++ return usFirst ? round(f) + ' °F (' + round(celsius) + ' °C)' : round(celsius) + ' °C (' + round(f) + ' °F)';
++ };
++ const wind = (kmh) => usFirst ? round(kmhToMph(kmh)) + ' mph (' + round(kmh) + ' km/h)' : round(kmh) + ' km/h (' + round(kmhToMph(kmh)) + ' mph)';
++ const rain = (mm) => usFirst ? round(mmToIn(mm)) + ' in (' + round(mm) + ' mm)' : round(mm) + ' mm (' + round(mmToIn(mm)) + ' in)';
++
++ const where = [hit.name, hit.admin1 && hit.admin1 !== hit.name ? hit.admin1 : null, hit.country].filter(Boolean).join(', ');
++ const lines = [];
++ lines.push(where + ' — now ' + temp(c.temperature_2m) + ', ' + describe(c.weather_code) + '.');
++ const details = [];
++ if (c.apparent_temperature != null) details.push('feels like ' + temp(c.apparent_temperature));
++ if (c.relative_humidity_2m != null) details.push('humidity ' + c.relative_humidity_2m + '%');
++ if (c.wind_speed_10m != null) details.push('wind ' + wind(c.wind_speed_10m));
++ if (c.precipitation != null) details.push('precipitation ' + rain(c.precipitation));
++ if (details.length) lines.push(details.join(', ') + '.');
++ lines.push('Local time in ' + (data.timezone || 'the place') + ': ' + (c.time || 'unknown') + '.');
++
++ const forecast = [];
++ const times = daily.time || [];
++ for (let i = 0; i < times.length && i < days; i += 1) {
++ const code = (daily.weather_code || [])[i];
++ const max = (daily.temperature_2m_max || [])[i];
++ const min = (daily.temperature_2m_min || [])[i];
++ const amount = (daily.precipitation_sum || [])[i];
++ const chance = (daily.precipitation_probability_max || [])[i];
++ forecast.push({
++ date: times[i],
++ code: code == null ? null : code,
++ description: describe(code),
++ temp_max_c: max == null ? null : round(max),
++ temp_max_f: max == null ? null : round(cToF(max)),
++ temp_min_c: min == null ? null : round(min),
++ temp_min_f: min == null ? null : round(cToF(min)),
++ precipitation_mm: amount == null ? null : round(amount),
++ precipitation_in: amount == null ? null : round(mmToIn(amount)),
++ precipitation_probability: chance == null ? null : chance,
++ });
++ const bits = [describe(code), round(min) + ' to ' + round(max) + ' °C (' + round(cToF(min)) + ' to ' + round(cToF(max)) + ' °F)'];
++ if (amount != null) bits.push(rain(amount) + ' of rain');
++ if (chance != null) bits.push(chance + '% chance of rain');
++ lines.push('- ' + dayName(times[i]) + ' ' + times[i] + ': ' + bits.join(', ') + '.');
++ }
++
++ const place = {
++ name: hit.name,
++ admin1: hit.admin1 || null,
++ country: hit.country || null,
++ country_code: hit.country_code || null,
++ latitude: hit.latitude,
++ longitude: hit.longitude,
++ elevation_m: hit.elevation == null ? null : round(hit.elevation),
++ population: hit.population || null,
++ timezone: data.timezone || hit.timezone || null,
++ };
++
++ return {
++ text: lines.join('\n'),
++ data: {
++ place,
++ current: {
++ time: c.time || null,
++ temperature_c: c.temperature_2m == null ? null : round(c.temperature_2m),
++ temperature_f: c.temperature_2m == null ? null : round(cToF(c.temperature_2m)),
++ apparent_c: c.apparent_temperature == null ? null : round(c.apparent_temperature),
++ apparent_f: c.apparent_temperature == null ? null : round(cToF(c.apparent_temperature)),
++ humidity_percent: c.relative_humidity_2m == null ? null : c.relative_humidity_2m,
++ wind_kmh: c.wind_speed_10m == null ? null : round(c.wind_speed_10m),
++ wind_mph: c.wind_speed_10m == null ? null : round(kmhToMph(c.wind_speed_10m)),
++ precipitation_mm: c.precipitation == null ? null : round(c.precipitation),
++ precipitation_in: c.precipitation == null ? null : round(mmToIn(c.precipitation)),
++ code: c.weather_code == null ? null : c.weather_code,
++ description: describe(c.weather_code),
++ },
++ forecast,
++ units: {
++ temperature: 'Celsius and Fahrenheit',
++ wind: 'km/h and mph',
++ precipitation: 'mm and inches',
++ order: usFirst ? 'Fahrenheit first (the place uses the US system)' : 'Celsius first',
++ },
++ fetched_at: new Date().toISOString(),
++ },
++ sources: [
++ source('https://open-meteo.com/', 'Open-Meteo: free weather forecast API'),
++ source('https://open-meteo.com/en/docs/geocoding-api', 'Open-Meteo: geocoding API'),
++ ],
++ artifacts: [],
++ };
++}
ddiff --git a/server/runtime/tools/wikipedia.js b/server/runtime/tools/wikipedia.js
nnew file mode 100644
iindex 0000000..eeb71b0
---- /dev/null
++++ b/server/runtime/tools/wikipedia.js
@@@ -0,0 +1,126 @@
++'use strict';
++/**
++ * wikipedia: what a topic is, in a summary and in the facts that go with it.
++ *
++ * Source: Wikipedia's own REST API (the page summary) and its action API (the lead section, the
++ * page id, the Wikidata item, the last edit and the categories). Both are free, keyless and ask
++ * for a descriptive User-Agent, which Actor sends. The English edition is the default; any other
++ * language Wikipedia has can be asked for by its code.
++ */
++const { Cache, ToolError, memo, requestJson, source, fail } = require('./lib/util');
++
++const TTL_MS = 30 * 60 * 1000;
++const CACHE = new Cache();
++
++const isLanguage = (code) => typeof code === 'string' && /^[a-z]{2,3}(-[a-z]{2,8})?$/.test(code);
++
++/** Wikipedia's own summary of a page. */
++async function summary(ctx, language, title, signal) {
++ const url = 'https://' + language + '.wikipedia.org/api/rest_v1/page/summary/' + encodeURIComponent(title.replace(/ /g, '_'));
++ const { data } = await requestJson(ctx, url, { signal, label: 'Wikipedia' });
++ return data || null;
++}
++
++/** The lead section and the facts behind the page. */
++async function facts(ctx, language, title, signal) {
++ const url = 'https://' + language + '.wikipedia.org/w/api.php'
++ + '?action=query&format=json&formatversion=2&redirects=1'
++ + '&prop=extracts|pageprops|revisions|categories'
++ + '&exintro=1&explaintext=1&rvprop=timestamp&cllimit=30'
++ + '&titles=' + encodeURIComponent(title);
++ const { data } = await requestJson(ctx, url, { signal, label: 'Wikipedia' });
++ const pages = data && data.query && Array.isArray(data.query.pages) ? data.query.pages : [];
++ return pages[0] || null;
++}
++
++module.exports = {
++ name: 'wikipedia',
++ description: 'A summary of a topic from Wikipedia, with its key facts: the lead section, the Wikidata item, the '
++ + 'last edit and the categories it sits in. English by default; any Wikipedia language can be asked for. '
++ + 'Use it for people, places, works, events and ideas - not for numbers that change (prices, weather, scores).',
++ input: {
++ type: 'object',
++ properties: {
++ topic: { type: 'string', description: 'The topic to look up, for example "Marie Curie", "Ada Lovelace" or "Photosynthesis".' },
++ language: { type: 'string', description: 'The Wikipedia language edition, for example "en", "de", "fr". Defaults to en.' },
++ },
++ required: ['topic'],
++ },
++ scope: 'public',
++ costUsd: 0,
++ timeoutMs: 15000,
++ sources: [source('https://en.wikipedia.org/', 'Wikipedia')],
++
++ async run(input = {}, ctx = {}) {
++ const topic = String(input.topic || '').trim();
++ if (!topic) return fail('wikipedia needs a topic, for example "Marie Curie".');
++ const language = String(input.language || 'en').trim().toLowerCase();
++ if (!isLanguage(language)) {
++ return fail('the language "' + language + '" is not a Wikipedia language code (for example "en" or "pt-br").');
++ }
++ const key = 'wikipedia:' + language + ':' + topic.toLowerCase();
++
++ try {
++ const result = await memo(CACHE, key, TTL_MS, async () => {
++ let sum = null;
++ try {
++ sum = await summary(ctx, language, topic, ctx.signal);
++ } catch (err) {
++ if (err && /answered 404/.test(err.message)) throw new ToolError('Wikipedia has no article about "' + topic + '"', 'tool.not_found');
++ throw err;
++ }
++ if (!sum || sum.type === 'https://mediawiki.org/wiki/HyperSwitch/errors/not_found') {
++ throw new ToolError('Wikipedia has no article about "' + topic + '"', 'tool.not_found');
++ }
++ let page = null;
++ try {
++ page = await facts(ctx, language, topic, ctx.signal);
++ } catch {
++ page = null;
++ }
++ return build(sum, page, language, topic);
++ });
++ return result;
++ } catch (err) {
++ return fail((err && err.message) || 'the article could not be fetched');
++ }
++ },
++};
++
++function build(sum, page, language, topic) {
++ const title = (page && page.title) || sum.title || topic;
++ const pageUrl = 'https://' + language + '.wikipedia.org/wiki/' + encodeURIComponent(String(title).replace(/ /g, '_'));
++ const extract = (page && page.extract && page.extract.trim()) || (sum.extract || '');
++ const categories = ((page && page.categories) || []).map((c) => c.title).filter(Boolean);
++ const revision = page && page.revisions && page.revisions[0] ? page.revisions[0].timestamp : null;
++ const wikibase = (page && page.pageprops && page.pageprops.wikibase_item) || sum.wikibase_item || null;
++
++ const lines = [];
++ if (sum.description) lines.push(title + ' — ' + sum.description + '.');
++ if (extract) lines.push(extract);
++ const keyFacts = [];
++ if (page && page.pageid) keyFacts.push('Wikipedia page ' + page.pageid);
++ if (wikibase) keyFacts.push('Wikidata ' + wikibase);
++ if (revision) keyFacts.push('last edited ' + revision);
++ if (categories.length) keyFacts.push('categories: ' + categories.slice(0, 12).join(', '));
++ if (keyFacts.length) lines.push('Key facts — ' + keyFacts.join('; ') + '.');
++
++ return {
++ text: lines.join('\n'),
++ data: {
++ title,
++ description: sum.description || null,
++ extract,
++ page_id: page && page.pageid ? page.pageid : (sum.pageid || null),
++ wikibase_item: wikibase,
++ last_revision: revision,
++ categories,
++ page_url: pageUrl,
++ thumbnail: sum.thumbnail ? sum.thumbnail.source : null,
++ language,
++ fetched_at: new Date().toISOString(),
++ },
++ sources: [source(pageUrl, 'Wikipedia: ' + title)],
++ artifacts: [],
++ };
++}
ddiff --git a/test/fixtures/tools/coingecko-bitcoin-usd.json b/test/fixtures/tools/coingecko-bitcoin-usd.json
nnew file mode 100644
iindex 0000000..9973691
---- /dev/null
++++ b/test/fixtures/tools/coingecko-bitcoin-usd.json
@@@ -0,0 +1,8 @@
++{
++ "bitcoin": {
++ "usd": 77933,
++ "usd_market_cap": 1567022411171.7034,
++ "usd_24h_change": -2.5489154146606503,
++ "last_updated_at": 1793350664
++ }
++}
ddiff --git a/test/fixtures/tools/coingecko-ethereum-usd.json b/test/fixtures/tools/coingecko-ethereum-usd.json
nnew file mode 100644
iindex 0000000..2ea98e4
---- /dev/null
++++ b/test/fixtures/tools/coingecko-ethereum-usd.json
@@@ -0,0 +1 @@
++{"ethereum":{"usd":2226.77,"usd_market_cap":272013938595.87808,"usd_24h_change":-3.9261023875260377,"last_updated_at":1793350760}}
\\ No newline at end of file
ddiff --git a/test/fixtures/tools/frankfurter-currencies.json b/test/fixtures/tools/frankfurter-currencies.json
nnew file mode 100644
iindex 0000000..65af56b
---- /dev/null
++++ b/test/fixtures/tools/frankfurter-currencies.json
@@@ -0,0 +1,32 @@
++{
++ "AUD": "Australian Dollar",
++ "BRL": "Brazilian Real",
++ "CAD": "Canadian Dollar",
++ "CHF": "Swiss Franc",
++ "CNY": "Chinese Renminbi Yuan",
++ "CZK": "Czech Koruna",
++ "DKK": "Danish Krone",
++ "EUR": "Euro",
++ "GBP": "British Pound",
++ "HKD": "Hong Kong Dollar",
++ "HUF": "Hungarian Forint",
++ "IDR": "Indonesian Rupiah",
++ "ILS": "Israeli New Shekel",
++ "INR": "Indian Rupee",
++ "ISK": "Icelandic Króna",
++ "JPY": "Japanese Yen",
++ "KRW": "South Korean Won",
++ "MXN": "Mexican Peso",
++ "MYR": "Malaysian Ringgit",
++ "NOK": "Norwegian Krone",
++ "NZD": "New Zealand Dollar",
++ "PHP": "Philippine Peso",
++ "PLN": "Polish Złoty",
++ "RON": "Romanian Leu",
++ "SEK": "Swedish Krona",
++ "SGD": "Singapore Dollar",
++ "THB": "Thai Baht",
++ "TRY": "Turkish Lira",
++ "USD": "United States Dollar",
++ "ZAR": "South African Rand"
++}
ddiff --git a/test/fixtures/tools/frankfurter-usd-eur.json b/test/fixtures/tools/frankfurter-usd-eur.json
nnew file mode 100644
iindex 0000000..19c1ccd
---- /dev/null
++++ b/test/fixtures/tools/frankfurter-usd-eur.json
@@@ -0,0 +1,8 @@
++{
++ "amount": 1,
++ "base": "USD",
++ "date": "2026-10-29",
++ "rates": {
++ "EUR": 0.89847
++ }
++}
ddiff --git a/test/fixtures/tools/nominatim-eiffel-tower.json b/test/fixtures/tools/nominatim-eiffel-tower.json
nnew file mode 100644
iindex 0000000..8613367
---- /dev/null
++++ b/test/fixtures/tools/nominatim-eiffel-tower.json
@@@ -0,0 +1,140 @@
++[
++ {
++ "place_id": 97665530,
++ "licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
++ "osm_type": "way",
++ "osm_id": 5013364,
++ "lat": "48.8582599",
++ "lon": "2.2945006",
++ "category": "man_made",
++ "type": "tower",
++ "place_rank": 30,
++ "importance": 0.6205937724353116,
++ "addresstype": "man_made",
++ "name": "Tour Eiffel",
++ "display_name": "Tour Eiffel, 5, Avenue Anatole France, Quartier du Gros-Caillou, Paris 7e Arrondissement, Paris, Île-de-France, France métropolitaine, 75007, France",
++ "address": {
++ "man_made": "Tour Eiffel",
++ "house_number": "5",
++ "road": "Avenue Anatole France",
++ "quarter": "Quartier du Gros-Caillou",
++ "suburb": "Paris 7e Arrondissement",
++ "city_district": "Paris",
++ "city": "Paris",
++ "ISO3166-2-lvl6": "FR-75C",
++ "state": "Île-de-France",
++ "ISO3166-2-lvl4": "FR-IDF",
++ "region": "France métropolitaine",
++ "postcode": "75007",
++ "country": "France",
++ "country_code": "fr"
++ },
++ "extratags": {
++ "fee": "10-25€",
++ "3dmr": "4",
++ "image": "https://fr.wikipedia.org/wiki/Fichier:Tour_Eiffel_Wikimedia_Commons.jpg",
++ "layer": "2",
++ "height": "330",
++ "ref:mhs": "PA00088801",
++ "tourism": "attraction",
++ "website": "https://www.toureiffel.paris/",
++ "building": "tower",
++ "engineer": "Gustave Eiffel;Maurice Koechlin;Émile Nouguier",
++ "heritage": "3",
++ "historic": "monument",
++ "landmark": "1",
++ "operator": "Société d’Exploitation de la Tour Eiffel",
++ "wikidata": "Q243",
++ "architect": "Stephen Sauvestre",
++ "panoramax": "b57848f0-a526-4bab-8cb3-ede3df7b41c3;7e69687f-3956-41f2-bd85-03af73df2b2b;7ca4fd32-0e0a-498f-826c-d106ee360948&xyz=3.20/53.64/30",
++ "wikipedia": "fr:Tour Eiffel",
++ "check_date": "2026-09-23",
++ "importance": "international",
++ "min_height": "0",
++ "roof:shape": "pyramidal",
++ "start_date": "1889",
++ "tower:type": "communication;observation",
++ "wheelchair": "limited",
++ "ref:FR:ANFR": "548894",
++ "roof:height": "330",
++ "building:part": "no",
++ "opening_hours": "09:30-23:45; Jun 21-Sep 02: 09:00-00:45; Jul 14,Jul 15 off",
++ "building:shape": "pyramidal",
++ "ref:FR:FANTOIR": "75107P320D",
++ "building:colour": "#706550",
++ "historic:period": "modern",
++ "tourism:visitors": "7000000",
++ "building:material": "iron",
++ "heritage:operator": "mhs",
++ "wikimedia_commons": "Category:Eiffel Tower",
++ "tower:construction": "lattice",
++ "communication:radio": "fm",
++ "mhs:inscription_date": "1964-06-24",
++ "wheelchair:description": "1. and 2. platform is accessable by elevator but not the 3. platform.",
++ "communication:television": "dvb-t"
++ },
++ "namedetails": {
++ "name": "Tour Eiffel",
++ "name:af": "Eiffel-toring",
++ "name:ar": "برج إيفل",
++ "name:ba": "Эйфель башняһы",
++ "name:be": "Вежа Эйфеля",
++ "name:br": "Tour Eiffel",
++ "name:ca": "Torre Eiffel",
++ "name:cs": "Eiffelova věž",
++ "name:da": "Eiffeltårnet",
++ "name:de": "Eiffelturm",
++ "name:el": "Πύργος του Άιφελ",
++ "name:en": "Eiffel Tower",
++ "name:eo": "Eiffel-Turo",
++ "name:es": "Torre Eiffel",
++ "name:et": "Eiffeli torn",
++ "name:eu": "Eiffel dorrea",
++ "name:fa": "برج ایفل",
++ "name:fi": "Eiffel-torni",
++ "name:fr": "Tour Eiffel",
++ "name:he": "מגדל אייפל",
++ "name:hi": "एफ़िल टॉवर",
++ "name:hr": "Eiffelov toranj",
++ "name:hu": "Eiffel-torony",
++ "name:ia": "Turre Eiffel",
++ "name:id": "Menara Eiffel",
++ "name:io": "Turmo Eiffel",
++ "name:it": "Torre Eiffel",
++ "name:ja": "エッフェル塔",
++ "name:kk": "Эйфель мұнарасы",
++ "name:ko": "에펠탑",
++ "name:ku": "Barûya Eyfelê",
++ "name:la": "Turris Eiffelia",
++ "name:lb": "Eiffeltuerm",
++ "name:nl": "Eiffeltoren",
++ "name:pl": "Wieża Eiffla",
++ "name:pt": "Torre Eiffel",
++ "name:ro": "Turnul Eiffel",
++ "name:ru": "Эйфелева башня",
++ "name:sk": "Eiffelova veža",
++ "name:sl": "Eifflov stolp",
++ "name:sr": "Ајфелова кула",
++ "name:sv": "Eiffeltornet",
++ "name:ta": "ஈபெல் கோபுரம்",
++ "name:tr": "Eyfel Kulesi",
++ "name:tt": "Эйфель манарасы",
++ "name:uk": "Ейфелева вежа",
++ "name:vi": "Tháp Eiffel",
++ "name:vo": "Tüm di Eiffel",
++ "name:zh": "艾菲爾鐵塔",
++ "name:ast": "Torrne Eiffel",
++ "alt_name:sr": "Ајфелов торањ",
++ "name:zh-Hans": "埃菲尔铁塔",
++ "name:zh-Hant": "艾菲爾鐵塔",
++ "alt_name:zh-Hans": "巴黎铁塔",
++ "alt_name:zh-Hant": "巴黎鐵塔"
++ },
++ "boundingbox": [
++ "48.8574753",
++ "48.8590453",
++ "2.2933119",
++ "2.2956897"
++ ]
++ }
++]
ddiff --git a/test/fixtures/tools/nominatim-empty.json b/test/fixtures/tools/nominatim-empty.json
nnew file mode 100644
iindex 0000000..0637a08
---- /dev/null
++++ b/test/fixtures/tools/nominatim-empty.json
@@@ -0,0 +1 @@
++[]
\\ No newline at end of file
ddiff --git a/test/fixtures/tools/open-meteo-forecast-berlin.json b/test/fixtures/tools/open-meteo-forecast-berlin.json
nnew file mode 100644
iindex 0000000..c64b8e8
---- /dev/null
++++ b/test/fixtures/tools/open-meteo-forecast-berlin.json
@@@ -0,0 +1,69 @@
++{
++ "latitude": 52.512604,
++ "longitude": 13.419517,
++ "generationtime_ms": 0.6148815155029297,
++ "utc_offset_seconds": 3600,
++ "timezone": "Europe/Berlin",
++ "timezone_abbreviation": "GMT+1",
++ "elevation": 38,
++ "current_units": {
++ "time": "iso8601",
++ "interval": "seconds",
++ "temperature_2m": "°C",
++ "relative_humidity_2m": "%",
++ "apparent_temperature": "°C",
++ "weather_code": "wmo code",
++ "wind_speed_10m": "km/h",
++ "precipitation": "mm"
++ },
++ "current": {
++ "time": "2026-10-30T09:45",
++ "interval": 900,
++ "temperature_2m": 7.4,
++ "relative_humidity_2m": 92,
++ "apparent_temperature": 6,
++ "weather_code": 45,
++ "wind_speed_10m": 3.3,
++ "precipitation": 0
++ },
++ "daily_units": {
++ "time": "iso8601",
++ "weather_code": "wmo code",
++ "temperature_2m_max": "°C",
++ "temperature_2m_min": "°C",
++ "precipitation_sum": "mm",
++ "precipitation_probability_max": "%"
++ },
++ "daily": {
++ "time": [
++ "2026-10-30",
++ "2026-10-31",
++ "2026-11-01"
++ ],
++ "weather_code": [
++ 51,
++ 61,
++ 3
++ ],
++ "temperature_2m_max": [
++ 10.7,
++ 11.4,
++ 9.7
++ ],
++ "temperature_2m_min": [
++ 7.1,
++ 7.5,
++ 4.9
++ ],
++ "precipitation_sum": [
++ 0,
++ 0.03,
++ 0
++ ],
++ "precipitation_probability_max": [
++ 0,
++ 5,
++ 2
++ ]
++ }
++}
ddiff --git a/test/fixtures/tools/open-meteo-geocode-berlin.json b/test/fixtures/tools/open-meteo-geocode-berlin.json
nnew file mode 100644
iindex 0000000..4b13bbf
---- /dev/null
++++ b/test/fixtures/tools/open-meteo-geocode-berlin.json
@@@ -0,0 +1,114 @@
++{
++ "results": [
++ {
++ "id": 2950159,
++ "name": "Berlin",
++ "latitude": 52.52437,
++ "longitude": 13.41053,
++ "elevation": 74,
++ "feature_code": "PPLC",
++ "country_code": "DE",
++ "admin1_id": 2950157,
++ "admin3_id": 6547383,
++ "admin4_id": 6547539,
++ "timezone": "Europe/Berlin",
++ "population": 3426354,
++ "postcodes": [
++ "10967",
++ "13347"
++ ],
++ "country_id": 2921044,
++ "country": "Germany",
++ "admin1": "State of Berlin",
++ "admin3": "Berlin, Stadt",
++ "admin4": "Berlin"
++ },
++ {
++ "id": 5083330,
++ "name": "Berlin",
++ "latitude": 44.46867,
++ "longitude": -71.18508,
++ "elevation": 311,
++ "feature_code": "PPL",
++ "country_code": "US",
++ "admin1_id": 5090174,
++ "admin2_id": 5084973,
++ "admin3_id": 5083340,
++ "timezone": "America/New_York",
++ "population": 9367,
++ "postcodes": [
++ "03570"
++ ],
++ "country_id": 6252001,
++ "country": "United States",
++ "admin1": "New Hampshire",
++ "admin2": "Coos",
++ "admin3": "City of Berlin"
++ },
++ {
++ "id": 4500771,
++ "name": "Berlin",
++ "latitude": 39.79123,
++ "longitude": -74.92905,
++ "elevation": 50,
++ "feature_code": "PPL",
++ "country_code": "US",
++ "admin1_id": 5101760,
++ "admin2_id": 4501019,
++ "admin3_id": 4500776,
++ "timezone": "America/New_York",
++ "population": 7590,
++ "postcodes": [
++ "08009"
++ ],
++ "country_id": 6252001,
++ "country": "United States",
++ "admin1": "New Jersey",
++ "admin2": "Camden",
++ "admin3": "Borough of Berlin"
++ },
++ {
++ "id": 5245497,
++ "name": "Berlin",
++ "latitude": 43.96804,
++ "longitude": -88.94345,
++ "elevation": 246,
++ "feature_code": "PPL",
++ "country_code": "US",
++ "admin1_id": 5279468,
++ "admin2_id": 5255015,
++ "admin3_id": 5245510,
++ "timezone": "America/Chicago",
++ "population": 5420,
++ "postcodes": [
++ "54923"
++ ],
++ "country_id": 6252001,
++ "country": "United States",
++ "admin1": "Wisconsin",
++ "admin2": "Green Lake",
++ "admin3": "City of Berlin"
++ },
++ {
++ "id": 4348460,
++ "name": "Berlin",
++ "latitude": 38.32262,
++ "longitude": -75.21769,
++ "elevation": 11,
++ "feature_code": "PPL",
++ "country_code": "US",
++ "admin1_id": 4361885,
++ "admin2_id": 4374180,
++ "timezone": "America/New_York",
++ "population": 5065,
++ "postcodes": [
++ "21811"
++ ],
++ "country_id": 6252001,
++ "country": "United States",
++ "admin1": "Maryland",
++ "admin2": "Worcester"
++ }
++ ],
++ "generationtime_ms": 0.20706654
++}
ddiff --git a/test/fixtures/tools/open-meteo-geocode-eiffel.json b/test/fixtures/tools/open-meteo-geocode-eiffel.json
nnew file mode 100644
iindex 0000000..dc9c665
---- /dev/null
++++ b/test/fixtures/tools/open-meteo-geocode-eiffel.json
@@@ -0,0 +1,3 @@
++{
++ "generationtime_ms": 0.09262562
++}
ddiff --git a/test/fixtures/tools/thesportsdb-eventslast-133604.json b/test/fixtures/tools/thesportsdb-eventslast-133604.json
nnew file mode 100644
iindex 0000000..a1d8890
---- /dev/null
++++ b/test/fixtures/tools/thesportsdb-eventslast-133604.json
@@@ -0,0 +1,56 @@
++{
++ "results": [
++ {
++ "idEvent": "2494072",
++ "idAPIfootball": "1557437",
++ "strTimestamp": "2026-10-24T14:00:00",
++ "strEvent": "Arsenal vs Everton",
++ "strEventAlternate": "Everton @ Arsenal",
++ "strFilename": "English Premier League 2026-10-24 Arsenal vs Everton",
++ "strSport": "Soccer",
++ "idLeague": "4328",
++ "strLeague": "English Premier League",
++ "strLeagueBadge": "https://r2.thesportsdb.com/images/media/league/badge/gasy9d1737743125.png",
++ "strSeason": "2026-2027",
++ "strDescriptionEN": "",
++ "strHomeTeam": "Arsenal",
++ "strAwayTeam": "Everton",
++ "intHomeScore": "2",
++ "intHomeScoreExtra": null,
++ "intAwayScoreExtra": null,
++ "intRound": "8",
++ "intAwayScore": "0",
++ "intSpectators": null,
++ "strOfficial": "",
++ "strWeather": "",
++ "dateEvent": "2026-10-24",
++ "dateEventLocal": "2026-10-24",
++ "strTime": "14:00:00",
++ "strTimeLocal": "15:00:00",
++ "strGroup": "",
++ "idHomeTeam": "133604",
++ "strHomeTeamBadge": "https://r2.thesportsdb.com/images/media/team/badge/uyhbfe1612467038.png",
++ "idAwayTeam": "133615",
++ "strAwayTeamBadge": "https://r2.thesportsdb.com/images/media/team/badge/eqayrf1523184794.png",
++ "intScore": null,
++ "intScoreVotes": null,
++ "strResult": "",
++ "idVenue": "15528",
++ "strVenue": "Emirates Stadium",
++ "strCountry": "England",
++ "strCity": "",
++ "strPoster": "https://r2.thesportsdb.com/images/media/event/poster/wu7p4i1784549644.jpg",
++ "strSquare": "https://r2.thesportsdb.com/images/media/event/square/npg14a1784550190.jpg",
++ "strFanart": null,
++ "strThumb": "https://r2.thesportsdb.com/images/media/event/thumb/ldysmn1784549342.jpg",
++ "strThumbAlternate": null,
++ "strBanner": "https://r2.thesportsdb.com/images/media/event/banner/o660j31784549922.jpg",
++ "strMap": null,
++ "strTweet1": "",
++ "strVideo": "https://www.youtube.com/watch?v=GTLPc85qrp4",
++ "strStatus": "FT",
++ "strPostponed": "no",
++ "strLocked": "unlocked"
++ }
++ ]
++}
ddiff --git a/test/fixtures/tools/thesportsdb-eventsnext-133604.json b/test/fixtures/tools/thesportsdb-eventsnext-133604.json
nnew file mode 100644
iindex 0000000..38bc4aa
---- /dev/null
++++ b/test/fixtures/tools/thesportsdb-eventsnext-133604.json
@@@ -0,0 +1,56 @@
++{
++ "events": [
++ {
++ "idEvent": "2494082",
++ "idAPIfootball": "1557453",
++ "strTimestamp": "2026-11-01T16:30:00",
++ "strEvent": "Liverpool vs Arsenal",
++ "strEventAlternate": "Arsenal @ Liverpool",
++ "strFilename": "English Premier League 2026-11-01 Liverpool vs Arsenal",
++ "strSport": "Soccer",
++ "idLeague": "4328",
++ "strLeague": "English Premier League",
++ "strLeagueBadge": "https://r2.thesportsdb.com/images/media/league/badge/gasy9d1737743125.png",
++ "strSeason": "2026-2027",
++ "strDescriptionEN": "Liverpool host Arsenal at Anfield in one of the biggest fixtures of the Premier League season. Arsenal arrive as the reigning champions and one of the leading title contenders again, while Liverpool look to use home advantage to strengthen their own position near the top. With both sides carrying genuine championship ambitions, the result could have major significance in the title race.",
++ "strHomeTeam": "Liverpool",
++ "strAwayTeam": "Arsenal",
++ "intHomeScore": null,
++ "intHomeScoreExtra": null,
++ "intAwayScoreExtra": null,
++ "intRound": "9",
++ "intAwayScore": null,
++ "intSpectators": null,
++ "strOfficial": "",
++ "strWeather": "",
++ "dateEvent": "2026-11-01",
++ "dateEventLocal": "2026-11-01",
++ "strTime": "16:30:00",
++ "strTimeLocal": "16:30:00",
++ "strGroup": "",
++ "idHomeTeam": "133602",
++ "strHomeTeamBadge": "https://r2.thesportsdb.com/images/media/team/badge/kfaher1737969724.png",
++ "idAwayTeam": "133604",
++ "strAwayTeamBadge": "https://r2.thesportsdb.com/images/media/team/badge/uyhbfe1612467038.png",
++ "intScore": null,
++ "intScoreVotes": null,
++ "strResult": "",
++ "idVenue": "15407",
++ "strVenue": "Anfield",
++ "strCountry": "England",
++ "strCity": "",
++ "strPoster": "https://r2.thesportsdb.com/images/media/event/poster/97zria1784549652.jpg",
++ "strSquare": "https://r2.thesportsdb.com/images/media/event/square/n8w8b31784550197.jpg",
++ "strFanart": null,
++ "strThumb": "https://r2.thesportsdb.com/images/media/event/thumb/5qdwpe1784549350.jpg",
++ "strThumbAlternate": null,
++ "strBanner": "https://r2.thesportsdb.com/images/media/event/banner/t8a6bs1784549929.jpg",
++ "strMap": null,
++ "strTweet1": "",
++ "strVideo": "",
++ "strStatus": "NS",
++ "strPostponed": "no",
++ "strLocked": "unlocked"
++ }
++ ]
++}
ddiff --git a/test/fixtures/tools/thesportsdb-leagues.json b/test/fixtures/tools/thesportsdb-leagues.json
nnew file mode 100644
iindex 0000000..87ae0b3
---- /dev/null
++++ b/test/fixtures/tools/thesportsdb-leagues.json
@@@ -0,0 +1,54 @@
++{
++ "leagues": [
++ {
++ "idLeague": "4328",
++ "strLeague": "English Premier League",
++ "strSport": "Soccer"
++ },
++ {
++ "idLeague": "4329",
++ "strLeague": "English League Championship",
++ "strSport": "Soccer"
++ },
++ {
++ "idLeague": "4330",
++ "strLeague": "Scottish Premier League",
++ "strSport": "Soccer"
++ },
++ {
++ "idLeague": "4331",
++ "strLeague": "German Bundesliga",
++ "strSport": "Soccer"
++ },
++ {
++ "idLeague": "4332",
++ "strLeague": "Italian Serie A",
++ "strSport": "Soccer"
++ },
++ {
++ "idLeague": "4334",
++ "strLeague": "French Ligue 1",
++ "strSport": "Soccer"
++ },
++ {
++ "idLeague": "4335",
++ "strLeague": "Spanish La Liga",
++ "strSport": "Soccer"
++ },
++ {
++ "idLeague": "4336",
++ "strLeague": "Greek Super League 1",
++ "strSport": "Soccer"
++ },
++ {
++ "idLeague": "4337",
++ "strLeague": "Dutch Eredivisie",
++ "strSport": "Soccer"
++ },
++ {
++ "idLeague": "4338",
++ "strLeague": "Belgian Pro League",
++ "strSport": "Soccer"
++ }
++ ]
++}
ddiff --git a/test/fixtures/tools/thesportsdb-nextleague-4328.json b/test/fixtures/tools/thesportsdb-nextleague-4328.json
nnew file mode 100644
iindex 0000000..3a25be0
---- /dev/null
++++ b/test/fixtures/tools/thesportsdb-nextleague-4328.json
@@@ -0,0 +1,56 @@
++{
++ "events": [
++ {
++ "idEvent": "2494084",
++ "idAPIfootball": "1557450",
++ "strTimestamp": "2026-10-31T12:30:00",
++ "strEvent": "Chelsea vs Manchester United",
++ "strEventAlternate": "Manchester United @ Chelsea",
++ "strFilename": "English Premier League 2026-10-31 Chelsea vs Manchester United",
++ "strSport": "Soccer",
++ "idLeague": "4328",
++ "strLeague": "English Premier League",
++ "strLeagueBadge": "https://r2.thesportsdb.com/images/media/league/badge/gasy9d1737743125.png",
++ "strSeason": "2026-2027",
++ "strDescriptionEN": "Chelsea host Manchester United at Stamford Bridge in one of the standout fixtures of Premier League Matchday 9. Chelsea arrive in stronger form, while United are under pressure to turn a difficult start into a statement result away from home. With two of England’s biggest clubs meeting, the match carries major prestige as well as important early-season implications.",
++ "strHomeTeam": "Chelsea",
++ "strAwayTeam": "Manchester United",
++ "intHomeScore": null,
++ "intHomeScoreExtra": null,
++ "intAwayScoreExtra": null,
++ "intRound": "9",
++ "intAwayScore": null,
++ "intSpectators": null,
++ "strOfficial": "",
++ "strWeather": "",
++ "dateEvent": "2026-10-31",
++ "dateEventLocal": "2026-10-31",
++ "strTime": "12:30:00",
++ "strTimeLocal": "12:30:00",
++ "strGroup": "",
++ "idHomeTeam": "133610",
++ "strHomeTeamBadge": "https://r2.thesportsdb.com/images/media/team/badge/pbf4ul1782638263.png",
++ "idAwayTeam": "133612",
++ "strAwayTeamBadge": "https://r2.thesportsdb.com/images/media/team/badge/xzqdr11517660252.png",
++ "intScore": null,
++ "intScoreVotes": null,
++ "strResult": "",
++ "idVenue": "16126",
++ "strVenue": "Stamford Bridge",
++ "strCountry": "England",
++ "strCity": "",
++ "strPoster": "https://r2.thesportsdb.com/images/media/event/poster/4jfe231785931600.jpg",
++ "strSquare": "https://r2.thesportsdb.com/images/media/event/square/xiknnl1785931697.jpg",
++ "strFanart": null,
++ "strThumb": "https://r2.thesportsdb.com/images/media/event/thumb/ps2rmf1785931541.jpg",
++ "strThumbAlternate": null,
++ "strBanner": "https://r2.thesportsdb.com/images/media/event/banner/jlzz601785931649.jpg",
++ "strMap": null,
++ "strTweet1": "",
++ "strVideo": "",
++ "strStatus": "NS",
++ "strPostponed": "no",
++ "strLocked": "unlocked"
++ }
++ ]
++}
ddiff --git a/test/fixtures/tools/thesportsdb-nextleague-4391.json b/test/fixtures/tools/thesportsdb-nextleague-4391.json
nnew file mode 100644
iindex 0000000..1b82c2d
---- /dev/null
++++ b/test/fixtures/tools/thesportsdb-nextleague-4391.json
@@@ -0,0 +1,19 @@
++{
++ "type": "https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1015/",
++ "title": "Error 1015: You are being rate limited",
++ "status": 429,
++ "detail": "You are being rate-limited by the website owner's configuration.",
++ "instance": "a5293723dda34f73",
++ "error_code": 1015,
++ "error_name": "rate_limited",
++ "error_category": "rate_limit",
++ "ray_id": "a5293723dda34f73",
++ "timestamp": "2026-10-30T08:57:57Z",
++ "zone": "www.thesportsdb.com",
++ "cloudflare_error": true,
++ "retryable": true,
++ "retry_after": 30,
++ "owner_action_required": false,
++ "what_you_should_do": "**Wait and retry.** This block is transient. Wait at least 30 seconds, then retry with exponential backoff.\n\nRecommended approach:\n1. Wait 30 seconds before your next request\n2. If rate-limited again, double the wait time (60s, 120s, etc.)\n3. If rate-limiting persists after 5 retries, stop and reassess your request pattern",
++ "footer": "This error was generated by Cloudflare on behalf of the website owner."
++}
ddiff --git a/test/fixtures/tools/thesportsdb-searchteams-arsenal.json b/test/fixtures/tools/thesportsdb-searchteams-arsenal.json
nnew file mode 100644
iindex 0000000..b5c2326
---- /dev/null
++++ b/test/fixtures/tools/thesportsdb-searchteams-arsenal.json
@@@ -0,0 +1,69 @@
++{
++ "teams": [
++ {
++ "idTeam": "133604",
++ "idESPN": "359",
++ "idAPIfootball": "42",
++ "intLoved": "9",
++ "strTeam": "Arsenal",
++ "strTeamAlternate": "Arsenal Football Club, AFC, Arsenal FC",
++ "strTeamShort": "ARS",
++ "intFormedYear": "1892",
++ "strSport": "Soccer",
++ "strLeague": "English Premier League",
++ "idLeague": "4328",
++ "strLeague2": "FA Cup",
++ "idLeague2": "4482",
++ "strLeague3": "EFL Cup",
++ "idLeague3": "4570",
++ "strLeague4": "UEFA Champions League",
++ "idLeague4": "4480",
++ "strLeague5": "Emirates Cup",
++ "idLeague5": "5648",
++ "strLeague6": "Club Friendlies",
++ "idLeague6": "4569",
++ "strLeague7": "FA Community Shield",
++ "idLeague7": "4571",
++ "strDivision": null,
++ "idVenue": "15528",
++ "strStadium": "Emirates Stadium",
++ "strKeywords": "Gunners, Gooners",
++ "strRSS": "",
++ "strLocation": "Holloway, London, England",
++ "strWebsite": "www.arsenal.com",
++ "strFacebook": "www.facebook.com/Arsenal",
++ "strTwitter": "twitter.com/arsenal",
++ "strInstagram": "instagram.com/arsenal",
++ "strDescriptionEN": "Arsenal Football Club is a professional football club based in Islington, London, England, that plays in the Premier League, the top flight of English football. The club has won 13 League titles, a record 13 FA Cups, 2 League Cups, 15 FA Community Shields, 1 League Centenary Trophy, 1 UEFA Cup Winners' Cup and 1 Inter-Cities Fairs Cup.\r\n\r\nArsenal was the first club from the South of England to join The Football League, in 1893, and they reached the First Division in 1904. Relegated only once, in 1913, they continue the longest streak in the top division, and have won the second-most top-flight matches in English football history. In the 1930s, Arsenal won five League Championships and two FA Cups, and another FA Cup and two Championships after the war. In 1970–71, they won their first League and FA Cup Double. Between 1989 and 2005, they won five League titles and five FA Cups, including two more Doubles. They completed the 20th century with the highest average league position.\r\n\r\nHerbert Chapman won Arsenal's first national trophies, but died prematurely. He helped introduce the WM formation, floodlights, and shirt numbers, and added the white sleeves and brighter red to the club's kit. Arsène Wenger was the longest-serving manager and won the most trophies. He won a record 7 FA Cups, and his title-winning team set an English record for the longest top-flight unbeaten league run at 49 games between 2003 and 2004, receiving the nickname The Invincibles.\r\n\r\nIn 1886, Woolwich munitions workers founded the club as Dial Square. In 1913, the club crossed the city to Arsenal Stadium in Highbury, becoming close neighbours of Tottenham Hotspur, and creating the North London derby. In 2006, they moved to the nearby Emirates Stadium. In terms of revenue, Arsenal is the ninth highest-earning football club in the world, earned €487.6m in 2016–17 season. Based on social media activity from 2014 to 2015, Arsenal's fanbase is the fifth largest in the world. In 2018, Forbes estimated the club was the third most valuable in England, with the club being worth $2.24 billion.",
++ "strDescriptionDE": "Der FC Arsenal (offiziell: Arsenal Football Club) – auch bekannt als (The) Arsenal, (The) Gunners (deutsche Übersetzung: „Schützen“ oder „Kanoniere“) oder im deutschen Sprachraum auch Arsenal London genannt – ist ein 1886 gegründeter Fußballverein aus dem Ortsteil Holloway des Nordlondoner Bezirks Islington. Mit 13 englischen Meisterschaften und elf FA-Pokalsiegen zählt der Klub zu den erfolgreichsten englischen Fußballvereinen.Erst über 40 Jahre nach der Gründung gewann Arsenal mit fünf Ligatiteln und zwei FA Cups in den 1930er Jahren seine ersten bedeutenden Titel. Der nächste Meilenstein war in der Saison 1970/71 der Gewinn des zweiten englischen „Doubles“ im 20. Jahrhundert. In den vergangenen 20 Jahren etablierte sich Arsenal endgültig als einer der erfolgreichsten englischen Fußballvereine, und beim Gewinn zweier weiterer Doubles zu Beginn des 21. Jahrhunderts blieb die Mannschaft in der Ligasaison 2003/04 als zweite in der englischen Fußballgeschichte ungeschlagen. Zunehmende europäische Ambitionen unterstrich der Verein in der Spielzeit 2005/06, als Arsenal als erster Londoner Verein das Finale der Champions League erreichte.",
++ "strDescriptionFR": "Arsenal Football Club est un club de football anglais fondé le 1er décembre 1886 à Londres. Son siège est situé dans le borough londonien d'Islington.\r\n\r\nArsenal participe au championnat d'Angleterre de football depuis 1919 dont il a remporté treize éditions, ce qui en fait le troisième club le plus titré d'Angleterre, ainsi que treize coupes d'Angleterre ce qui constitue un record (devant Manchester United, qui en a remporté 12). Sur le plan continental, les Gunners (en français : « les canonniers ») comptent à leur palmarès une Coupe d'Europe des vainqueurs de coupe obtenue en 1994. Deux fois finalistes de cette coupe des vainqueurs de coupe en 1980 et 1995, ils ont également atteint les finales de la coupe de l'UEFA en 2000, de la Ligue des champions en 2006 et de l'Europa League en 2019.\r\n\r\nRésident dès 1913 du stade de Highbury, situé dans un quartier du nord de Londres, le club s’est installé en 2006 dans une nouvelle enceinte de 60 355 places : l'Emirates Stadium, situé à Ashburton Grove. Arsenal nourrit une certaine rivalité avec les nombreux autres clubs de la capitale, mais celle l'opposant à Tottenham Hotspur, avec qui il dispute chaque année le « North London derby » est particulièrement ancienne et profonde.\r\n\r\nLe club est dirigé par Chips Keswick (en) qui succède pour raisons de santé en juin 2013 à Peter Hill-Wood, fils et petit-fils des anciens présidents Denis et Samuel Hill-Wood. Il remplace son père à la mort de ce dernier, en 1982, après vingt ans de responsabilités. Son entraîneur depuis le 29 novembre 2019 est le Suédois Fredrik Ljungberg, ancien joueur du club, après le limogeage de l’Espagnol Unai Emery. Cependant le 20 décembre 2019, l'Espagnol Mikel Arteta, lui aussi ancien joueur du club, est nommé entraîneur. Il était avant sa nomination l'assistant de Pep Guardiola à Manchester City",
++ "strDescriptionCN": "阿森纳足球俱乐部(英语:The Arsenal Football Club),昵称枪手(Gunners),是一家英国英格兰的职业足球俱乐部,主场位于北伦敦霍洛韦的阿联酋航空足球场(Emirates Stadium,又被译作“酋长球场”)。阿森纳参加英格兰足球联赛的最高级别——英格兰足球超级联赛。该俱乐部共赢得14次顶级联赛冠军(包括一次不败夺冠)、创纪录的14次足总杯冠军、2次联赛杯冠军、18次社区盾冠军、1次欧洲优胜者杯冠军和1次国际城市博览会杯冠军。\r\n\r\n阿森纳成立于1886年12月1日,起初的名字为戴尔广场足球俱乐部(Dial Square FC)。1893年,俱乐部加入英格兰足球联赛系统,成为首支加入的南部俱乐部。1904年,阿森纳首次升级至甲级联赛,于联赛累积的分数为第二多。1913年,阿森纳历史上首次,亦是唯一一次降级。6年后,俱乐部重回顶级联赛后一直停留至今,成为停留于顶级联赛最长时间的英格兰俱乐部。1930年代,阿森纳赢得多项主要锦标,包括5次联赛冠军和2次足总杯冠军,在第二次世界大战结束后,俱乐部亦赢得2次联赛冠军和1次足总杯冠军。阿森纳于1970-71赛季成功赢得联赛冠军和足总杯冠军,首次成为双冠王。1989年至2005年间,俱乐部赢得5次联赛冠军和5次足总杯冠军,包括于1997-98赛季和2001-02赛季成为双冠王。2003-04赛季,阿森纳更以不败的成绩夺得当届联赛冠军。俱乐部于20世纪的联赛平均排名是所有英格兰俱乐部之冠。\r\n\r\n领队赫伯特·查普曼赢得阿森纳首个本土比赛的冠军,阿森纳的球衣设计、球衣号码的使用、球场泛光灯的使用、以及阿森纳当年比赛使用的“WM阵式”皆是他的贡献,可惜的是他英年早逝。阿尔塞纳·温格则是球队史上率领时间最长的主教练,亦是赢得最多冠军的领队,他打破了英格兰联赛多项纪录,包括最长连胜纪录、最长不败纪录,和英格兰顶级联赛历史上唯一一支整个赛季38场比赛不败的球队。1913年,俱乐部迁移主场至北伦敦的海布里球场,成为地理上最接近热刺的俱乐部,因此之后每年双方的比赛被称作北伦敦德比。2006年,阿森纳将主场搬迁至新落成的酋长球场。",
++ "strDescriptionIT": "L'Arsenal Football Club, noto semplicemente come Arsenal, è una società calcistica inglese con sede a Londra, più precisamente nel quartiere di Holloway, nel borgo di Islington.\r\n\r\nFondato nel 1886, è uno dei quattordici club che rappresentano la città di Londra a livello professionistico, nonché uno dei più antichi del Paese. Milita nella massima serie del calcio inglese ininterrottamente dal 1919-1920, risultando quindi la squadra da più tempo presente in First Division/Premier League. È la prima squadra della capitale del Regno Unito per successi sportivi e, in ambito federale, la terza dopo Manchester United e Liverpool, essendosi aggiudicata nel corso della sua storia tredici campionati inglesi, dodici FA Cup (record di vittorie, condiviso con il Manchester United), due League Cup e quattordici Community Shield (una condivisa), mentre in ambito internazionale ha conquistato una Coppa delle Coppe ed una Coppa delle Fiere. Inoltre è una delle tredici squadre che hanno raggiunto le finali di tutte le tre principali competizioni gestite dalla UEFA: Champions League (2005-2006), Coppa UEFA (1999-2000) e Coppa delle Coppe (1979-1980, 1993-1994 e 1994-1995).\r\n\r\nI colori sociali, rosso per la maglietta e bianco per i pantaloncini, hanno subìto variazioni più o meno evidenti nel corso degli anni. Anche la sede del club è stata cambiata più volte: inizialmente la squadra giocava a Woolwich, ma nel 1913 si spostò all'Arsenal Stadium, nel quartiere di Highbury; dal 2006 disputa invece le sue partite casalinghe nel nuovo Emirates Stadium. Lo stemma è stato modificato ripetutamente, ma al suo interno è sempre comparso almeno un cannone. Proprio per questo motivo i giocatori ed i tifosi dell'Arsenal sono spesso soprannominati Gunners (in italiano \"cannonieri\").\r\n\r\nL'Arsenal conta su una schiera di tifosi molto nutrita, distribuita in ogni parte del mondo. Nel corso degli anni sono sorte profonde rivalità con i sostenitori di club concittadini, la più sentita delle quali è quella con i seguaci del Tottenham Hotspur, con i quali i Gunners giocano regolarmente il North London derby. L'Arsenal è anche uno dei club più ricchi del mondo, con un patrimonio stimato di 1,3 miliardi di dollari, secondo la rivista Forbes nel 2014, facendone il quinto club più ricco del pianeta e il secondo in Inghilterra.",
++ "strDescriptionJP": "アーセナル・フットボール・クラブ(Arsenal Football Club、イギリス英語発音: [ˈɑːsənl ˈfutˌbɔːl klʌb])は、イングランドの首都ロンドン北部をホームタウンとする、イングランドプロサッカーリーグ(プレミアリーグ)に加盟するプロサッカークラブ。クラブカラーは赤と白。\r\n\r\n現在のホームスタジアムはロンドンのエミレーツ・スタジアム(収容人数60,260人)。プレミアリーグに所属し、同リーグにおいて3回の優勝記録を持つ(フットボールリーグ時代を含めると13回)。2003-2004シーズンには無敗優勝を達成したイングランド屈指の強豪である。1886年に軍需工場の労働者のクラブとして創設されたため、チームのエンブレムは大砲のマークを持つ。「ガナーズ(Gunners)」の愛称で知られ[1]、サポーターは「グーナー(Gooner)」と呼ばれる。\r\n\r\nクラブのモットーは「勝利は調和の中から生まれる(ラテン語: Victoria Concordia Crescit)」。1949年から使用されたクレストに初めて登場する。現行のクレストは2002年から使用されており、大砲の上にサンセリフ体でチーム名が書かれている。",
++ "strDescriptionRU": "«Арсена́л» (официальное полное название — Футбольный клуб «Арсенал», англ. Arsenal Football Club, английское произношение: [ˈɑrsənəl 'futbɔ:l klʌb]) — английский профессиональный футбольный клуб из Северного Лондона, выступающий в Премьер-лиге. Основан в октябре 1886 года. Клуб 13 раз становился чемпионом Англии, 13 раз выигрывал Кубок Англии. Домашним стадионом клуба является Эмирейтс, вмещающий 60 704 зрителей[1].\r\n\r\n«Арсенал» провёл бо́льшую часть своей истории в высшем дивизионе английского футбола, в 1892 году стал одним из клубов-основателей английской Премьер-лиги[4]. Также клуб является рекордсменом по количеству сезонов подряд, проведённых в высшем дивизионе чемпионата Англии, начиная с 1919 года[5].\r\n\r\nПо данным Forbes на 2018 год, «Арсенал» занимал шестое место в списке самых дорогих футбольных клубов мира и оценивался в сумму 2,238 млрд. долларов США[6]. По данным аудиторской компании Deloitte, по итогам сезона 2016/17 «Арсенал» занимал 6-ю строчку в рейтинге самых доходных футбольных клубов с доходом в размере 487,6 млн евро[7].",
++ "strDescriptionES": "El Arsenal Football Club (pronunciación en inglés: /ˈɑː(ɹ)sənəl ˈfʊtˌbɔl klʌb/) es un club de fútbol profesional con sede en Holloway, Londres, Inglaterra, que juega en la máxima categoría del fútbol de ese país, la Premier League. Uno de los más laureados del fútbol inglés, ha ganado 43 títulos en su país, incluyendo 13 campeonatos de liga y un récord de 13 Copas de Inglaterra; también ha ganado dos títulos internacionales: la Copa europea de Ferias en 1970 y la Recopa de Europa en 1994.\r\n\r\nFundado en 1886 en el sudeste de Londres, en 1893, se convirtió en el primer club del sur de Inglaterra en unirse a la Football League. Ganó sus primeros títulos —cinco ligas y dos FA Cups— en los años 1930. Luego de un período de escasez en los años posteriores a la Segunda Guerra Mundial, se convirtió en el segundo club del siglo XX en ganar el doblete, obteniendo el Campeonato de Liga y la FA Cup de la temporada 1970-71. Los logros siguieron en los años 1990 y 2000. Durante esos años, Arsenal ganó un doblete de copas nacionales, dos dobletes de Liga y Copa, y dos bicampeonatos de la Copa de Inglaterra. Finalizó una temporada de Liga invicto y se convirtió en el primer club de Londres en llegar a la final de la Liga de Campeones de la UEFA.\r\n\r\nLos colores tradicionales del club, el blanco y el rojo, han evolucionado con el tiempo. Del mismo modo, fue cambiando la ubicación del club; fundado en el distrito de Woolwich, en el sureste de Londres, en 1913 se mudó al norte de Londres, más exactamente en el distrito de Highbury, donde fue establecido el Arsenal Stadium, que funcionó hasta 2006, donde se hizo un traslado más corto, hacia los alrededores de Holloway, donde se levantó el actual Emirates Stadium. La estadía histórica del Arsenal en el norte de Londres, ha creado con el tiempo una fuerte rivalidad con el Tottenham Hotspur, conocida como el Derbi del Norte de Londres.4​\r\n\r\nArsenal posee una de las aficiones más numerosas del mundo.5​6​7​ Según la revista Forbes, el club fue el quinto club más valioso del mundo en 2014, con un valor de 1300 millones libras.8​",
++ "strDescriptionPT": "Arsenal Football Club é um clube de futebol, fundado e baseado em Londres, capital da Inglaterra.\r\n\r\nO clube disputa a Premier League e é um dos mais bem sucedidos do futebol inglês, tendo ganhado por 13 vezes o título de campeão do Campeonato Inglês, sendo o terceiro em número de conquistas, e por 13 vezes a Copa da Inglaterra (recorde), sendo também detentor do recorde de maior período de invencibilidade no Campeonato Inglês e também o de ser o único a ganhar a Premier League invicto. No plano internacional, conquistou a Recopa Europeia e a Taça das Cidades com Feiras, já tendo sido finalista da Liga dos Campeões da UEFA, a principal competição europeia de clubes.\r\n\r\nO Arsenal mudou de localização ao longo do tempo, tendo sido fundado em Woolwich, sudeste de Londres, em 1913 mudou-se para o norte de Londres, para o Arsenal Stadium, em Highbury. Em 2006 foi concluída a construção do Emirates Stadium, que está localizado nas proximidades de Holloway, o que foi prometido desde 2004, após o clube fechar negócio com a Emirates Airlines, tendo capacidade para mais de 60.000 expectadores.[3]\r\n\r\nO Arsenal tem muitos torcedores por todo mundo, possuindo uma série de rivalidades de longa data com outros clubes, sendo a mais notável delas contra os vizinhos do Tottenham, com quem disputa regularmente o North London Derby, bastante intensas também contra o Chelsea e o Manchester United.[4]\r\n\r\nSuas cores têm sido tradicionalmente vermelho e branco, mas seus uniformes evoluíram ao longo da história. Um outro grande feito é o de ser um dos 5 únicos clubes do mundo que já venceram a Seleção Brasileira.[5] Já o Arsenal Ladies, é um dos clubes mais bem sucedidos do futebol feminino na Inglaterra.",
++ "strDescriptionSE": "Arsenal FC är en fotbollsklubb i stadsdelen Islington i norra London, men ursprungligen grundad i stadsdelen Woolwich i sydöstra London 1886. Klubbens femstjärniga hemmaarena heter Emirates Stadium (men kallas i folkmun för Ashburton Grove) och är belägen intill den gamla arenan, Arsenal Stadium, mer känd som Highbury (vars läktare byggts om till bostadshus där fasaden har bevarats och planen är en park).\r\n\r\nResultatmässigt är Arsenal Londons mest framgångsrika fotbollsklubb med Tottenham Hotspur och Chelsea som främsta rivaler. Den senaste storhetsperioden har skapats under den franska tränaren Arsène Wenger. Arsenal är även en av de rikaste och mest supporterstöttade fotbollsklubbarna i världen. Forbes rankade klubben som den tionde rikaste 2024 till ett värde av 2,6 miljarder dollar.",
++ "strDescriptionNL": "Arsenal Football Club, kortweg Arsenal of The Arsenal, is een Engelse voetbalclub uit Highbury, Londen, die werd opgericht in 1886 als Dial Square, waarna het in 1913 de huidige naam aan nam.",
++ "strDescriptionHU": "Az Arsenal Football Club (röviden Arsenal, The Arsenal vagy az Ágyúsok) angol profi labdarúgócsapat székhelye az észak-londoni Holloway-ben van. Az egyik legsikeresebb klub az angol labdarúgásban 14 First Division és Premier League címmel és 14 FA-kupával.\r\n\r\nAz Arsenalt 1886-ban alapították (Dial Square néven). Első fontosabb trófeáikat az 1930-as években nyerték, öt League Championship címet és két FA Kupát. Egy sikertelen időszak után (a háború utáni években) a második olyan klub volt, amelynek sikerült dupláznia (a bajnokságot és az FA kupát is megnyerték) az 1970–71-es szezonban. Az elkövetkezendő harminc év folyamán az egyik legsikeresebb angol csapat lett, ebben az időszakban az Arsenal kétszer duplázott, 2004-ben veretlenül nyerte meg a Premier League-et, valamint az első londoni klub volt, amely döntőt játszhatott a UEFA-bajnokok ligájában. 2026-ban 22 évvel a veretlen szezon és az utolsó bajnoki cím után újra elhódította a Premier League-et.\r\n\r\nA csapat színei hagyományosan piros és fehér, ezek a történelem során alakultak ki. Az idők során a klub többször megváltoztatta székhelyét, melyet eredetileg Woolwichban (Délkelet-Londonban) alapítottak, de 1913-ban elköltöztek a város északi részébe, a Highburybe. 2006-ban az újonnan felépült Emirates Stadionba tették át székhelyüket.\r\n\r\nAz Arsenalnak hatalmas szurkolói bázisa van, akik kitartanak az ősi riválisokkal szemben. Ezek közül a legjelentősebb a rivális a Tottenham Hotspur, akikkel az észak-londoni derbiket játsszák. Az Arsenal WFC a legsikeresebb angol klub a női labdarúgásban, amely kapcsolatban áll a férfi klubbal is.",
++ "strDescriptionNO": "Arsenal Football Club er en engelsk fotballklubb som spiller i Premier League. Klubben ble stiftet i 1886, og ble valgt inn i The Football League i 1893. «The Gunners», som er kallenavnet til Arsenal, spiller hjemmekampene sine på Emirates Stadium i Holloway. Holloway er et sted i bydelen Islington som ligger i det nordlige London.",
++ "strDescriptionIL": null,
++ "strDescriptionPL": "Arsenal Football Club (wym. [ˈɑ:sənl] lub [ˈɑ:snəl]) – klub piłki nożnej z siedzibą w Londynie. Jest jedną z najbardziej utytułowanych angielskich drużyn, 14-krotnym mistrzem kraju, 14-krotnym zdobywcą Pucharu Anglii i najdłużej utrzymującym się w najwyższej klasie rozgrywkowej zespołem w Anglii – od 1919 roku.\r\n\r\nArsenal został założony w 1886 roku i był pierwszym klubem z południa Anglii, który dołączył do The Football League, w 1893 roku. W latach 30. XX w. klub pięć razy był mistrzem kraju i zdobył dwa Puchary Anglii. Po II wojnie światowej klub osiągał mniej sukcesów. W sezonie 1970/1971 Arsenal zdobył dublet. Od lat 90. sześciokrotnie wygrywał rozgrywki Pucharu Anglii, oraz czterokrotnie był mistrzem Anglii. W sezonie 2003/2004 Arsenal zdobył tytuł mistrza Anglii bez żadnej porażki i został drugą drużyną po Preston North End, która to uczyniła (a pierwszą, która nie przegrała w 38 meczach). Dwa lata później dotarł do finału Ligi Mistrzów, w którym przegrał z FC Barceloną.\r\n\r\nTradycyjnymi barwami klubu są czerwony i biały. Kolory te w historii klubu przyjmowały różne odcienie. Arsenal zmieniał także swój stadion; początkowo mieścił się on w Woolwich, w południowo-wschodnim Londynie, w 1913 roku drużyna przeniosła się na północ miasta, do Arsenal Stadium w Highbury. W 2006 roku klub przeprowadził się na Emirates Stadium położony w Holloway.\r\n\r\nKlub posiada liczną i wierną grupę kibiców. Największym rywalem zespołu jest Tottenham Hotspur. Mecze Arsenalu z tym klubem określane są jako derby północnego Londynu. Arsenal jest jednym z najwartościowszych klubów w Anglii. Szacowana jego wartość w 2008 roku wynosiła ponad 600 milionów funtów. Dzięki swojej renomie regularnie pojawia się w brytyjskich mediach. Ponadto corocznie organizuje na swoim stadionie przedsezonowy towarzyski turniej Emirates Cup. Zespół kobiecy Arsenalu jest najbardziej utytułowaną żeńską drużyną piłkarską w kraju.",
++ "strColour1": "#EF0107",
++ "strColour2": "#fbffff",
++ "strColour3": "#013373",
++ "strGender": "Male",
++ "strCountry": "England",
++ "strBadge": "https://r2.thesportsdb.com/images/media/team/badge/uyhbfe1612467038.png",
++ "strLogo": "https://r2.thesportsdb.com/images/media/team/logo/q2mxlz1512644512.png",
++ "strFanart1": "https://r2.thesportsdb.com/images/media/team/fanart/ouqjzl1769332470.jpg",
++ "strFanart2": "https://r2.thesportsdb.com/images/media/team/fanart/db23ct1769332477.jpg",
++ "strFanart3": "https://r2.thesportsdb.com/images/media/team/fanart/k3r9od1769332483.jpg",
++ "strFanart4": "https://r2.thesportsdb.com/images/media/team/fanart/2uvo8p1731313907.jpg",
++ "strBanner": "https://r2.thesportsdb.com/images/media/team/banner/ecl7mi1790192440.jpg",
++ "strEquipment": "https://r2.thesportsdb.com/images/media/team/equipment/fwav4f1752100996.png",
++ "strYoutube": "www.youtube.com/user/ArsenalTour",
++ "strLocked": "unlocked"
++ }
++ ]
++}
ddiff --git a/test/fixtures/tools/thesportsdb-standings-4328.json b/test/fixtures/tools/thesportsdb-standings-4328.json
nnew file mode 100644
iindex 0000000..92563e3
---- /dev/null
++++ b/test/fixtures/tools/thesportsdb-standings-4328.json
@@@ -0,0 +1,114 @@
++{
++ "table": [
++ {
++ "idStanding": "10023512",
++ "intRank": "1",
++ "idTeam": "133613",
++ "strTeam": "Manchester City",
++ "strBadge": "https://r2.thesportsdb.com/images/media/team/badge/vwpvry1467462651.png/tiny",
++ "idLeague": "4328",
++ "strLeague": "English Premier League",
++ "strSeason": "2026-2027",
++ "strGroup": "Premier League",
++ "strForm": "LWWWW",
++ "strDescription": "Promotion - Champions League (League phase)",
++ "intPlayed": "8",
++ "intWin": "7",
++ "intLoss": "1",
++ "intDraw": "0",
++ "intGoalsFor": "20",
++ "intGoalsAgainst": "9",
++ "intGoalDifference": "11",
++ "intPoints": "21",
++ "dateUpdated": "2026-10-29 10:46:01"
++ },
++ {
++ "idStanding": "10023513",
++ "intRank": "2",
++ "idTeam": "133604",
++ "strTeam": "Arsenal",
++ "strBadge": "https://r2.thesportsdb.com/images/media/team/badge/uyhbfe1612467038.png/tiny",
++ "idLeague": "4328",
++ "strLeague": "English Premier League",
++ "strSeason": "2026-2027",
++ "strGroup": "Premier League",
++ "strForm": "WWWLW",
++ "strDescription": "Promotion - Champions League (League phase)",
++ "intPlayed": "8",
++ "intWin": "7",
++ "intLoss": "1",
++ "intDraw": "0",
++ "intGoalsFor": "13",
++ "intGoalsAgainst": "5",
++ "intGoalDifference": "8",
++ "intPoints": "21",
++ "dateUpdated": "2026-10-29 10:46:01"
++ },
++ {
++ "idStanding": "10023514",
++ "intRank": "3",
++ "idTeam": "133619",
++ "strTeam": "Brighton and Hove Albion",
++ "strBadge": "https://r2.thesportsdb.com/images/media/team/badge/ywypts1448810904.png/tiny",
++ "idLeague": "4328",
++ "strLeague": "English Premier League",
++ "strSeason": "2026-2027",
++ "strGroup": "Premier League",
++ "strForm": "LWWWW",
++ "strDescription": "Promotion - Champions League (League phase)",
++ "intPlayed": "8",
++ "intWin": "5",
++ "intLoss": "2",
++ "intDraw": "1",
++ "intGoalsFor": "23",
++ "intGoalsAgainst": "7",
++ "intGoalDifference": "16",
++ "intPoints": "16",
++ "dateUpdated": "2026-10-29 10:46:01"
++ },
++ {
++ "idStanding": "10023515",
++ "intRank": "4",
++ "idTeam": "133602",
++ "strTeam": "Liverpool",
++ "strBadge": "https://r2.thesportsdb.com/images/media/team/badge/kfaher1737969724.png/tiny",
++ "idLeague": "4328",
++ "strLeague": "English Premier League",
++ "strSeason": "2026-2027",
++ "strGroup": "Premier League",
++ "strForm": "WWLWD",
++ "strDescription": "Promotion - Champions League (League phase)",
++ "intPlayed": "8",
++ "intWin": "4",
++ "intLoss": "1",
++ "intDraw": "3",
++ "intGoalsFor": "11",
++ "intGoalsAgainst": "8",
++ "intGoalDifference": "3",
++ "intPoints": "15",
++ "dateUpdated": "2026-10-29 10:46:01"
++ },
++ {
++ "idStanding": "10023516",
++ "intRank": "5",
++ "idTeam": "133610",
++ "strTeam": "Chelsea",
++ "strBadge": "https://r2.thesportsdb.com/images/media/team/badge/pbf4ul1782638263.png/tiny",
++ "idLeague": "4328",
++ "strLeague": "English Premier League",
++ "strSeason": "2026-2027",
++ "strGroup": "Premier League",
++ "strForm": "LWWLD",
++ "strDescription": "Promotion - Europa League (League phase)",
++ "intPlayed": "8",
++ "intWin": "4",
++ "intLoss": "3",
++ "intDraw": "1",
++ "intGoalsFor": "17",
++ "intGoalsAgainst": "15",
++ "intGoalDifference": "2",
++ "intPoints": "13",
++ "dateUpdated": "2026-10-29 10:46:01"
++ }
++ ]
++}
ddiff --git a/test/fixtures/tools/wikipedia-query-marie-curie.json b/test/fixtures/tools/wikipedia-query-marie-curie.json
nnew file mode 100644
iindex 0000000..e895a3d
---- /dev/null
++++ b/test/fixtures/tools/wikipedia-query-marie-curie.json
@@@ -0,0 +1,150 @@
++{
++ "continue": {
++ "clcontinue": "20408|Articles_with_Project_Gutenberg_links",
++ "continue": "||extracts|pageprops|revisions"
++ },
++ "query": {
++ "pages": [
++ {
++ "pageid": 20408,
++ "ns": 0,
++ "title": "Marie Curie",
++ "extract": "Maria Salomea Skłodowska Curie (née Skłodowska; 7 November 1867 – 4 July 1934), better known as Marie Curie, was a Polish and naturalised-French physicist and chemist. She shared the 1903 Nobel Prize in Physics with her husband, Pierre Curie, \"for their joint researches on the radioactivity phenomena discovered by Professor Henri Becquerel\". She won the 1911 Nobel Prize in Chemistry \"[for] the discovery of the elements radium and polonium, by the isolation of radium and the study of the nature and compounds of this remarkable element\". \nCurie was the first woman to win a Nobel Prize, the first person to win a Nobel Prize twice and the only person to win a Nobel Prize in two different scientific fields. She and Pierre were the first married couple to win the Nobel Prize, launching the Curie family legacy of five Nobel Prizes. She was, in 1906, the first woman to become a professor at the University of Paris.\nCurie was born in Warsaw, Russian Empire, where she studied at the clandestine Flying University and began her practical scientific training. In 1891, Curie followed her elder sister Bronisława to study in Paris, where she earned her higher degrees and conducted her subsequent scientific work. In 1895, Curie married Pierre, with whom she conducted pioneering research on radioactivity—a term she coined. Pierre died in 1906 in a Paris street accident.\nUnder Curie's direction, the world's first studies were conducted into the treatment of neoplasms by the use of radioactive isotopes. She founded the Curie Institute in Paris in 1920, and the Curie Institute in Warsaw in 1932; both remain major medical research centres. During the First World War, she developed mobile radiography units to provide X-ray services to field hospitals.\nWhile a French citizen, Curie, who used both surnames, never lost her sense of Polish identity. She taught her daughters the Polish language and took them on visits to Poland. She named the first chemical element she and Pierre discovered polonium, after her native country. \nCurie died in 1934, aged 66, at the Sancellemoz sanatorium in Passy, Haute-Savoie, France, of aplastic anaemia likely from exposure to radiation in the course of her scientific research and in the course of her radiological work at field hospitals during the First World War. In addition to her Nobel Prizes, she received numerous other honours and tributes; in 1995, she became the first woman to be entombed on her own merits in the Paris Panthéon, and Poland declared 2011 the Year of Marie Curie during the International Year of Chemistry. Curie is the subject of numerous biographies, including Madame Curie by her daughter Ève. The synthetic element curium is named in her honour.",
++ "pageprops": {
++ "defaultsort": "Curie, Marie",
++ "page_image_free": "Marie_Curie_c._1920s.jpg",
++ "wikibase-badge-Q17437798": "",
++ "wikibase-shortdesc": "Polish-French physicist and chemist (1867–1934)",
++ "wikibase_item": "Q7186"
++ },
++ "revisions": [
++ {
++ "timestamp": "2026-10-29T13:15:52Z"
++ }
++ ],
++ "categories": [
++ {
++ "ns": 14,
++ "title": "Category:1867 births"
++ },
++ {
++ "ns": 14,
++ "title": "Category:1934 deaths"
++ },
++ {
++ "ns": 14,
++ "title": "Category:19th-century French chemists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:19th-century French inventors"
++ },
++ {
++ "ns": 14,
++ "title": "Category:19th-century French physicists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:19th-century French women scientists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:19th-century Polish chemists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:19th-century Polish inventors"
++ },
++ {
++ "ns": 14,
++ "title": "Category:19th-century Polish physicists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:19th-century Polish women scientists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:19th-century women inventors"
++ },
++ {
++ "ns": 14,
++ "title": "Category:19th-century women physicists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:20th-century French chemists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:20th-century French inventors"
++ },
++ {
++ "ns": 14,
++ "title": "Category:20th-century French physicists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:20th-century French women scientists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:20th-century Polish chemists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:20th-century Polish inventors"
++ },
++ {
++ "ns": 14,
++ "title": "Category:20th-century Polish physicists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:20th-century Polish women scientists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:20th-century women inventors"
++ },
++ {
++ "ns": 14,
++ "title": "Category:20th-century women physicists"
++ },
++ {
++ "ns": 14,
++ "title": "Category:Academic staff of the University of Paris"
++ },
++ {
++ "ns": 14,
++ "title": "Category:All Wikipedia articles written in British English"
++ },
++ {
++ "ns": 14,
++ "title": "Category:Articles containing French-language text"
++ },
++ {
++ "ns": 14,
++ "title": "Category:Articles linking to deprecated archival repositories"
++ },
++ {
++ "ns": 14,
++ "title": "Category:Articles prone to spam from August 2020"
++ },
++ {
++ "ns": 14,
++ "title": "Category:Articles with Internet Archive links"
++ },
++ {
++ "ns": 14,
++ "title": "Category:Articles with LibriVox links"
++ },
++ {
++ "ns": 14,
++ "title": "Category:Articles with Open Library links"
++ }
++ ]
++ }
++ ]
++ }
++}
ddiff --git a/test/fixtures/tools/wikipedia-summary-marie-curie.json b/test/fixtures/tools/wikipedia-summary-marie-curie.json
nnew file mode 100644
iindex 0000000..e343b10
---- /dev/null
++++ b/test/fixtures/tools/wikipedia-summary-marie-curie.json
@@@ -0,0 +1,49 @@
++{
++ "type": "standard",
++ "title": "Marie Curie",
++ "displaytitle": "<span lang=\"en\" dir=\"ltr\"><span class=\"mw-page-title-main\">Marie Curie</span></span>",
++ "namespace": {
++ "id": 0,
++ "text": ""
++ },
++ "wikibase_item": "Q7186",
++ "titles": {
++ "canonical": "Marie_Curie",
++ "normalized": "Marie Curie",
++ "display": "<span lang=\"en\" dir=\"ltr\"><span class=\"mw-page-title-main\">Marie Curie</span></span>"
++ },
++ "pageid": 20408,
++ "thumbnail": {
++ "source": "https://thumb.wikimedia.org/wikipedia/commons/thumb/c/c8/Marie_Curie_c._1920s.jpg/330px-Marie_Curie_c._1920s.jpg?utm_source=en.wikipedia.org&utm_campaign=api&utm_content=thumbnail",
++ "width": 330,
++ "height": 448
++ },
++ "originalimage": {
++ "source": "https://upload.wikimedia.org/wikipedia/commons/c/c8/Marie_Curie_c._1920s.jpg?utm_source=en.wikipedia.org&utm_campaign=api&utm_content=thumbnail_unscaled",
++ "width": 1549,
++ "height": 2105
++ },
++ "lang": "en",
++ "dir": "ltr",
++ "revision": "1382609933",
++ "tid": "dd837495-d39a-11f1-a20a-5bc5eb35dd29",
++ "timestamp": "2026-10-29T13:15:52Z",
++ "description": "Polish-French physicist and chemist (1867–1934)",
++ "description_source": "local",
++ "content_urls": {
++ "desktop": {
++ "page": "https://en.wikipedia.org/wiki/Marie_Curie",
++ "revisions": "https://en.wikipedia.org/wiki/Marie_Curie?action=history",
++ "edit": "https://en.wikipedia.org/wiki/Marie_Curie?action=edit",
++ "talk": "https://en.wikipedia.org/wiki/Talk:Marie_Curie"
++ },
++ "mobile": {
++ "page": "https://en.wikipedia.org/wiki/Marie_Curie",
++ "revisions": "https://en.wikipedia.org/wiki/Special:History/Marie_Curie",
++ "edit": "https://en.wikipedia.org/wiki/Marie_Curie?action=edit",
++ "talk": "https://en.wikipedia.org/wiki/Talk:Marie_Curie"
++ }
++ },
++ "extract": "Maria Salomea Skłodowska Curie, better known as Marie Curie, was a Polish and naturalised-French physicist and chemist. She shared the 1903 Nobel Prize in Physics with her husband, Pierre Curie, \"for their joint researches on the radioactivity phenomena discovered by Professor Henri Becquerel\". She won the 1911 Nobel Prize in Chemistry \"[for] the discovery of the elements radium and polonium, by the isolation of radium and the study of the nature and compounds of this remarkable element\".",
++ "extract_html": "<p><b>Maria Salomea Skłodowska Curie</b>, better known as <b>Marie Curie</b>, was a Polish and naturalised-French physicist and chemist. She shared the 1903 Nobel Prize in Physics with her husband, Pierre Curie, \"for their joint researches on the radioactivity phenomena discovered by Professor Henri Becquerel\". She won the 1911 Nobel Prize in Chemistry \"[for] the discovery of the elements radium and polonium, by the isolation of radium and the study of the nature and compounds of this remarkable element\".</p>"
++}
ddiff --git a/test/helpers/tool-fixtures.js b/test/helpers/tool-fixtures.js
nnew file mode 100644
iindex 0000000..8fcfc43
---- /dev/null
++++ b/test/helpers/tool-fixtures.js
@@@ -0,0 +1,35 @@
++'use strict';
++/**
++ * The fetch the tool tests use: it answers from the recorded answers in test/fixtures/tools, so the
++ * suite never touches the network. A route is a host and path; its value is either the name of a
++ * fixture file or a function that picks one from the whole URL.
++ */
++const fs = require('fs');
++const path = require('path');
++
++const DIR = path.join(__dirname, '..', 'fixtures', 'tools');
++
++function fixtureFetch(routes) {
++ const calls = [];
++ const fetchImpl = async (url, init = {}) => {
++ const text = String(url);
++ calls.push({ url: text, headers: init.headers || {} });
++ const u = new URL(text);
++ const route = routes[u.host + u.pathname] !== undefined ? routes[u.host + u.pathname] : routes[u.host];
++ if (!route) {
++ return { ok: false, status: 404, text: async () => JSON.stringify({ message: 'no fixture for ' + u.host + u.pathname }) };
++ }
++ const file = typeof route === 'function' ? route(text, u) : route;
++ if (!file) {
++ return { ok: false, status: 404, text: async () => JSON.stringify({ message: 'no fixture for ' + text }) };
++ }
++ const body = fs.readFileSync(path.join(DIR, file), 'utf8');
++ return { ok: true, status: 200, text: async () => body };
++ };
++ return { fetchImpl, calls };
++}
++
++/** The context the loop hands a tool: the egress-safe fetch and nothing else. */
++const ctxOf = (fetchImpl) => ({ fetch: fetchImpl, signal: undefined, config: {} });
++
++module.exports = { fixtureFetch, ctxOf, DIR };
ddiff --git a/test/tool-calculate.test.js b/test/tool-calculate.test.js
nnew file mode 100644
iindex 0000000..d37c359
---- /dev/null
++++ b/test/tool-calculate.test.js
@@@ -0,0 +1,79 @@
++'use strict';
++// calculate: exact arithmetic, the fixed set of functions, unit conversion, and no way out of the
++// parser (no eval, no Function, no shell).
++const assert = require('assert');
++const calculate = require('../server/runtime/tools/calculate');
++const { check, done } = require('./helpers/boot');
++
++const noNetwork = { fetch: async () => { throw new Error('this tool must not touch the network'); } };
++
++const run = async (expression) => await calculate.run({ expression }, noNetwork);
++
++(async () => {
++ await check('arithmetic is done exactly', async () => {
++ const out = await run('((12 + 8) * 3) / 4');
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.value, 15);
++ assert.ok(out.text.includes('15'), out.text);
++ });
++
++ await check('whole numbers stay whole, to the last digit', async () => {
++ const out = await run('123456789012345678901 * 2');
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.value, '246913578024691357802');
++ assert.strictEqual(out.data.exact, true);
++ assert.ok(out.text.includes('exact'), out.text);
++ });
++
++ await check('powers, brackets and division work', async () => {
++ assert.strictEqual((await run('2^10')).data.value, 1024);
++ assert.strictEqual((await run('7 / 2')).data.value, 3.5);
++ assert.strictEqual((await run('1 / 3')).data.value, 0.333333333333);
++ assert.strictEqual((await run('-2^2')).data.value, -4);
++ assert.strictEqual((await run('(2 + 3) * (4 - 1)')).data.value, 15);
++ assert.strictEqual((await run('17 % 5')).data.value, 2);
++ });
++
++ await check('the functions and the constants are there', async () => {
++ assert.strictEqual((await run('sqrt(144) + 2^3')).data.value, 20);
++ assert.strictEqual((await run('round(3.14159, 2)')).data.value, 3.14);
++ assert.strictEqual((await run('max(3, 9, 4)')).data.value, 9);
++ assert.strictEqual((await run('gcd(12, 18)')).data.value, 6);
++ assert.strictEqual((await run('fact(5)')).data.value, 120);
++ assert.ok(Math.abs((await run('pi')).data.value - Math.PI) < 1e-9);
++ });
++
++ await check('units are converted', async () => {
++ const km = await run('10 km to miles');
++ assert.ok(!km.error, km.error);
++ assert.ok(Math.abs(km.data.value - 6.21371) < 0.0001, km.data.value);
++ assert.strictEqual(km.data.kind, 'conversion');
++ const f = await run('72 F to C');
++ assert.ok(Math.abs(f.data.value - 22.2222) < 0.001, f.data.value);
++ const kg = await run('100 kg in pounds');
++ assert.ok(Math.abs(kg.data.value - 220.462) < 0.001, kg.data.value);
++ const hours = await run('90 minutes to hours');
++ assert.strictEqual(hours.data.value, 1.5);
++ });
++
++ await check('two different kinds of unit are not converted into each other', async () => {
++ const out = await run('10 kg to miles');
++ assert.ok(out.error, 'there is an error');
++ assert.ok(out.error.includes('not a'), out.error);
++ });
++
++ await check('nothing but arithmetic gets through', async () => {
++ for (const expression of ['process.exit(1)', 'require("fs")', 'globalThis', 'constructor', 'foo()', '2 +', 'sqrt(-1)', '1 / 0']) {
++ const out = await run(expression);
++ assert.ok(out.error, 'the expression was refused: ' + expression);
++ assert.strictEqual(typeof out.error, 'string');
++ }
++ });
++
++ await check('an empty expression is an error, not a crash', async () => {
++ const out = await calculate.run({}, noNetwork);
++ assert.ok(out.error && out.error.includes('expression'), out.error);
++ });
++
++ done();
++})();
ddiff --git a/test/tool-market-quote.test.js b/test/tool-market-quote.test.js
nnew file mode 100644
iindex 0000000..ce6f8d1
---- /dev/null
++++ b/test/tool-market-quote.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++// market_quote and currency_convert: the price, the change, the time of the quote and the source.
++const assert = require('assert');
++const quote = require('../server/runtime/tools/market_quote');
++const convert = require('../server/runtime/tools/currency_convert');
++const { fixtureFetch, ctxOf } = require('./helpers/tool-fixtures');
++const { check, done } = require('./helpers/boot');
++
++const cryptoRoutes = {
++ 'api.coingecko.com/api/v3/simple/price': (url) => (url.includes('ids=ethereum') ? 'coingecko-ethereum-usd.json' : 'coingecko-bitcoin-usd.json'),
++ 'api.frankfurter.dev/v1/latest': 'frankfurter-usd-eur.json',
++};
++
++(async () => {
++ await check('market_quote prices a coin and says when the price was', async () => {
++ const f = fixtureFetch(cryptoRoutes);
++ const out = await quote.run({ asset: 'bitcoin' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.kind, 'crypto');
++ assert.strictEqual(out.data.price, 77933);
++ assert.strictEqual(out.data.currency, 'USD');
++ assert.ok(Math.abs(out.data.change_24h_percent + 2.5489) < 0.001, 'the 24 hour change');
++ assert.strictEqual(out.data.quote_time, new Date(1793350664 * 1000).toISOString());
++ assert.ok(out.text.includes('77,933'), out.text);
++ assert.ok(out.sources.some((s) => s.url.includes('coingecko.com')), 'the source is CoinGecko');
++ });
++
++ await check('market_quote takes a ticker as well as a name', async () => {
++ const f = fixtureFetch(cryptoRoutes);
++ const out = await quote.run({ asset: 'ETH' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.ok(f.calls[0].url.includes('ids=ethereum'), f.calls[0].url);
++ });
++
++ await check('market_quote answers a currency pair from the ECB rates', async () => {
++ const f = fixtureFetch(cryptoRoutes);
++ const out = await quote.run({ asset: 'USD/EUR' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.kind, 'fx');
++ assert.strictEqual(out.data.base, 'USD');
++ assert.strictEqual(out.data.quote, 'EUR');
++ assert.strictEqual(out.data.price, 0.89847);
++ assert.strictEqual(out.data.quote_time, '2026-10-29');
++ assert.ok(out.text.includes('Central Bank reference rate'), out.text);
++ assert.ok(out.sources.some((s) => s.url.includes('frankfurter')), 'the source is Frankfurter');
++ });
++
++ await check('market_quote says it has no stock source rather than inventing one', async () => {
++ const f = fixtureFetch(cryptoRoutes);
++ const out = await quote.run({ asset: 'AAPL' }, ctxOf(f.fetchImpl));
++ assert.ok(out.error, 'a stock is not quoted');
++ assert.ok(quote.description.toLowerCase().includes('stocks are not'), 'the description says stocks are left out');
++ });
++
++ await check('currency_convert converts an amount and names the rate day', async () => {
++ const f = fixtureFetch({
++ 'api.frankfurter.dev/v1/currencies': 'frankfurter-currencies.json',
++ 'api.frankfurter.dev/v1/latest': 'frankfurter-usd-eur.json',
++ });
++ const out = await convert.run({ amount: 250, from: 'USD', to: 'EUR' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.rates[0].currency, 'EUR');
++ assert.strictEqual(out.data.rates[0].rate, 0.89847);
++ assert.ok(Math.abs(out.data.rates[0].value - 224.6175) < 0.001, 'the converted amount');
++ assert.strictEqual(out.data.date, '2026-10-29');
++ assert.ok(out.text.includes('2026-10-29'), out.text);
++ assert.ok(out.sources.some((s) => s.url.includes('frankfurter')), 'the source is Frankfurter');
++ });
++
++ await check('currency_convert refuses a currency the rates do not have', async () => {
++ const f = fixtureFetch({
++ 'api.frankfurter.dev/v1/currencies': 'frankfurter-currencies.json',
++ 'api.frankfurter.dev/v1/latest': 'frankfurter-usd-eur.json',
++ });
++ const out = await convert.run({ from: 'USD', to: 'XYZ' }, ctxOf(f.fetchImpl));
++ assert.ok(out.error && out.error.includes('XYZ'), out.error);
++ });
++
++ done();
++})();
ddiff --git a/test/tool-modules-shape.test.js b/test/tool-modules-shape.test.js
nnew file mode 100644
iindex 0000000..5d3b501
---- /dev/null
++++ b/test/tool-modules-shape.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++// Every module in server/runtime/tools/ has the plugin interface the runtime expects: a name, a
++// description, a JSON schema for its input, a scope, a cost, a timeout, a source and a run that
++// answers with text, data and sources - or with an error the model can act on, never a crash.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { check, done } = require('./helpers/boot');
++
++const DIR = path.join(__dirname, '..', 'server', 'runtime', 'tools');
++
++const files = fs.readdirSync(DIR).filter((f) => f.endsWith('.js')).sort();
++
++(async () => {
++ await check('the folder holds the live-data modules', async () => {
++ assert.ok(files.length >= 9, 'expected the live-data modules, found ' + files.length);
++ assert.ok(!files.includes('index.js'), 'the registry is the core job\'s file, not this job\'s');
++ });
++
++ const names = new Set();
++ for (const file of files) {
++ const mod = require(path.join(DIR, file));
++ await check(file + ' has the plugin interface', async () => {
++ assert.strictEqual(typeof mod, 'object');
++ assert.strictEqual(typeof mod.name, 'string', 'name');
++ assert.ok(/^[a-z][a-z0-9_]*$/.test(mod.name), 'name is snake_case: ' + mod.name);
++ assert.ok(!names.has(mod.name), 'the name is unique: ' + mod.name);
++ names.add(mod.name);
++ assert.strictEqual(typeof mod.description, 'string', 'description');
++ assert.ok(mod.description.length > 40, 'the description says what the tool does');
++ assert.strictEqual(typeof mod.input, 'object', 'input');
++ assert.strictEqual(mod.input.type, 'object', 'input.type');
++ assert.strictEqual(typeof mod.input.properties, 'object', 'input.properties');
++ assert.ok(Array.isArray(mod.input.required), 'input.required');
++ assert.strictEqual(mod.scope, 'public', 'scope');
++ assert.strictEqual(mod.costUsd, 0, 'costUsd');
++ assert.ok(Number.isFinite(mod.timeoutMs) && mod.timeoutMs > 0, 'timeoutMs');
++ assert.strictEqual(typeof mod.run, 'function', 'run');
++ assert.ok(Array.isArray(mod.sources) && mod.sources.length, 'the module names the provider it uses');
++ for (const s of mod.sources) {
++ assert.strictEqual(typeof s.url, 'string');
++ assert.ok(/^https:\/\//.test(s.url), 'a source is an https page: ' + s.url);
++ assert.strictEqual(typeof s.title, 'string');
++ }
++ });
++
++ await check(file + ' answers an empty input with an error, not a crash', async () => {
++ const out = await mod.run({}, { fetch: async () => { throw new Error('the network must not be touched'); } });
++ assert.strictEqual(typeof out, 'object');
++ assert.ok(out && typeof out.error === 'string' && out.error.length > 0, 'an error the model can act on');
++ // The registry keeps the text and the isError flag; a bare error would be dropped on the way out.
++ assert.strictEqual(typeof out.text, 'string', 'the error is also the text the model reads');
++ assert.ok(out.text.length > 0);
++ assert.strictEqual(out.isError, true, 'the step is marked as an error');
++ });
++ }
++
++ await check('the modules cover the live data the plan asks for', async () => {
++ const wanted = ['weather_now', 'market_quote', 'currency_convert', 'sports_scores', 'time_in', 'convert_time', 'geocode', 'place_info', 'wikipedia', 'calculate'];
++ const have = files.map((f) => require(path.join(DIR, f)).name);
++ for (const name of wanted) assert.ok(have.includes(name), 'missing module: ' + name);
++ });
++
++ done();
++})();
ddiff --git a/test/tool-place.test.js b/test/tool-place.test.js
nnew file mode 100644
iindex 0000000..b67143f
---- /dev/null
++++ b/test/tool-place.test.js
@@@ -0,0 +1,97 @@
++'use strict';
++// geocode and place_info: a name becomes a place, with the facts that belong to it. Each case
++// uses a name of its own, so the tools' own cache never answers a later question.
++const assert = require('assert');
++const geocode = require('../server/runtime/tools/geocode');
++const placeInfo = require('../server/runtime/tools/place_info');
++const { fixtureFetch, ctxOf } = require('./helpers/tool-fixtures');
++const { check, done } = require('./helpers/boot');
++
++const nominatim = (url) => (url.includes('Atlantis') ? 'nominatim-empty.json' : 'nominatim-eiffel-tower.json');
++const openMeteo = (url) => (url.includes('Berlin') ? 'open-meteo-geocode-berlin.json' : 'open-meteo-geocode-eiffel.json');
++
++(async () => {
++ await check('geocode turns a name into coordinates and what goes with them', async () => {
++ const f = fixtureFetch({
++ 'geocoding-api.open-meteo.com/v1/search': openMeteo,
++ 'nominatim.openstreetmap.org/search': nominatim,
++ });
++ const out = await geocode.run({ place: 'Berlin' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.place.name, 'Berlin');
++ assert.strictEqual(out.data.place.country, 'Germany');
++ assert.ok(out.data.place.latitude > 52 && out.data.place.latitude < 53, 'the latitude');
++ assert.strictEqual(out.data.place.timezone, 'Europe/Berlin');
++ assert.ok(out.data.place.population > 1000000, 'the population');
++ assert.ok(out.text.includes('Berlin'), out.text);
++ assert.ok(out.sources.some((s) => s.url.includes('open-meteo')), 'the source is Open-Meteo');
++ });
++
++ await check('geocode falls back to OpenStreetMap when Open-Meteo does not know the name', async () => {
++ const f = fixtureFetch({
++ 'geocoding-api.open-meteo.com/v1/search': openMeteo,
++ 'nominatim.openstreetmap.org/search': nominatim,
++ });
++ const out = await geocode.run({ place: 'Eiffel Tower' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.ok(f.calls.some((c) => c.url.includes('nominatim')), 'Nominatim was asked');
++ assert.ok(out.data.place.latitude > 48 && out.data.place.latitude < 49, 'the latitude');
++ assert.ok(out.text.includes('Eiffel'), out.text);
++ });
++
++ await check('geocode asks Nominatim with a User-Agent that names Actor', async () => {
++ const f = fixtureFetch({
++ 'geocoding-api.open-meteo.com/v1/search': openMeteo,
++ 'nominatim.openstreetmap.org/search': nominatim,
++ });
++ const out = await geocode.run({ place: 'Brandenburg Gate' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ const call = f.calls.find((c) => c.url.includes('nominatim'));
++ assert.ok(call, 'Nominatim was called');
++ const agent = call.headers['user-agent'] || '';
++ assert.ok(agent.includes('OpenVibe.Actor'), 'the User-Agent names the application: ' + agent);
++ assert.ok(agent.includes('https://openvibe.actor'), 'the User-Agent has a way to reach us: ' + agent);
++ });
++
++ await check('a place nobody knows is an error the model can act on', async () => {
++ const f = fixtureFetch({
++ 'geocoding-api.open-meteo.com/v1/search': openMeteo,
++ 'nominatim.openstreetmap.org/search': nominatim,
++ });
++ const out = await geocode.run({ place: 'Atlantis' }, ctxOf(f.fetchImpl));
++ assert.ok(out.error && out.error.includes('Atlantis'), out.error);
++ });
++
++ await check('place_info describes the place, its kind and its address', async () => {
++ const f = fixtureFetch({
++ 'nominatim.openstreetmap.org/search': nominatim,
++ 'geocoding-api.open-meteo.com/v1/search': openMeteo,
++ });
++ const out = await placeInfo.run({ place: 'Eiffel Tower' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.kind, 'man_made');
++ assert.strictEqual(out.data.type, 'tower');
++ assert.ok(out.data.address.country, 'the country');
++ assert.ok(out.data.latitude > 48 && out.data.latitude < 49);
++ assert.ok(out.text.includes('tower'), out.text);
++ assert.ok(out.sources.some((s) => s.url.includes('openstreetmap')), 'the source is OpenStreetMap');
++ });
++
++ await check('place_info asks Nominatim with the application User-Agent', async () => {
++ const f = fixtureFetch({
++ 'nominatim.openstreetmap.org/search': nominatim,
++ 'geocoding-api.open-meteo.com/v1/search': openMeteo,
++ });
++ await placeInfo.run({ place: 'Colosseum' }, ctxOf(f.fetchImpl));
++ const call = f.calls.find((c) => c.url.includes('nominatim'));
++ assert.ok(call, 'Nominatim was called');
++ assert.ok((call.headers['user-agent'] || '').includes('OpenVibe.Actor'), 'the User-Agent names the application');
++ });
++
++ await check('place_info needs a place', async () => {
++ const out = await placeInfo.run({}, ctxOf(fixtureFetch({}).fetchImpl));
++ assert.ok(out.error && out.error.includes('place'), out.error);
++ });
++
++ done();
++})();
ddiff --git a/test/tool-sports-scores.test.js b/test/tool-sports-scores.test.js
nnew file mode 100644
iindex 0000000..a5dc0c5
---- /dev/null
++++ b/test/tool-sports-scores.test.js
@@@ -0,0 +1,68 @@
++'use strict';
++// sports_scores: the table, the games and the team's own place in it, all from recorded answers.
++const assert = require('assert');
++const sports = require('../server/runtime/tools/sports_scores');
++const { fixtureFetch, ctxOf } = require('./helpers/tool-fixtures');
++const { check, done } = require('./helpers/boot');
++
++const routes = {
++ 'www.thesportsdb.com/api/v1/json/123/lookuptable.php': (url) => (url.includes('l=4391') ? 'thesportsdb-standings-4328.json' : 'thesportsdb-standings-4328.json'),
++ 'www.thesportsdb.com/api/v1/json/123/eventsnextleague.php': (url) => (url.includes('id=4391') ? 'thesportsdb-nextleague-4391.json' : 'thesportsdb-nextleague-4328.json'),
++ 'www.thesportsdb.com/api/v1/json/123/eventsround.php': 'thesportsdb-nextleague-4328.json',
++ 'www.thesportsdb.com/api/v1/json/123/eventslast.php': 'thesportsdb-eventslast-133604.json',
++ 'www.thesportsdb.com/api/v1/json/123/eventsnext.php': 'thesportsdb-eventsnext-133604.json',
++ 'www.thesportsdb.com/api/v1/json/123/searchteams.php': 'thesportsdb-searchteams-arsenal.json',
++ 'www.thesportsdb.com/api/v1/json/123/all_leagues.php': 'thesportsdb-leagues.json',
++};
++
++(async () => {
++ await check('sports_scores gives the league table', async () => {
++ const f = fixtureFetch(routes);
++ const out = await sports.run({ league: 'premier league' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.league.name, 'English Premier League');
++ assert.ok(out.data.standings.length >= 5, 'the table has rows');
++ assert.strictEqual(out.data.standings[0].rank, 1);
++ assert.strictEqual(out.data.standings[0].team, 'Manchester City');
++ assert.ok(out.data.standings[0].played > 0);
++ assert.ok(out.text.includes('Manchester City'), out.text);
++ assert.ok(out.sources.some((s) => s.url.includes('thesportsdb')), 'the source is TheSportsDB');
++ });
++
++ await check('sports_scores gives the next games of the league', async () => {
++ const f = fixtureFetch(routes);
++ const out = await sports.run({ league: 'epl' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.ok(out.data.next_games.length > 0, 'there are games');
++ assert.ok(out.data.next_games[0].home && out.data.next_games[0].away, 'a game has both teams');
++ assert.ok(out.text.includes('Next games'), out.text);
++ });
++
++ await check('sports_scores gives a team its last results, its next game and its place', async () => {
++ const f = fixtureFetch(routes);
++ const out = await sports.run({ league: 'premier league', team: 'Arsenal' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.team.name, 'Arsenal');
++ assert.ok(out.data.recent_games.length > 0, 'the last results');
++ assert.ok(out.data.next_games.length > 0, 'the next games');
++ assert.ok(out.data.team_standing, 'the team is in the table');
++ assert.ok(out.text.includes('Arsenal'), out.text);
++ assert.ok(f.calls.some((c) => c.url.includes('eventslast.php')), 'the last results are fetched');
++ assert.ok(f.calls.some((c) => c.url.includes('eventsnext.php')), 'the next games are fetched');
++ });
++
++ await check('a league nobody knows is an error the model can act on', async () => {
++ const f = fixtureFetch(routes);
++ const out = await sports.run({ league: 'Quidditch League' }, ctxOf(f.fetchImpl));
++ assert.ok(out.error, 'there is an error');
++ assert.ok(out.error.includes('Quidditch League'), out.error);
++ });
++
++ await check('no league at all is an error, not a crash', async () => {
++ const f = fixtureFetch(routes);
++ const out = await sports.run({}, ctxOf(f.fetchImpl));
++ assert.ok(out.error && out.error.includes('league'), out.error);
++ });
++
++ done();
++})();
ddiff --git a/test/tool-time.test.js b/test/tool-time.test.js
nnew file mode 100644
iindex 0000000..61a8bae
---- /dev/null
++++ b/test/tool-time.test.js
@@@ -0,0 +1,79 @@
++'use strict';
++// time_in and convert_time: the platform time zone database, with daylight saving on the right
++// side of the change. No network call is made.
++const assert = require('assert');
++const timeIn = require('../server/runtime/tools/time_in');
++const convertTime = require('../server/runtime/tools/convert_time');
++const { check, done } = require('./helpers/boot');
++
++const noNetwork = { fetch: async () => { throw new Error('these tools must not touch the network'); } };
++
++(async () => {
++ await check('time_in gives the local time, the offset and the zone name', async () => {
++ const out = await timeIn.run({ zone: 'Europe/Berlin' }, noNetwork);
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.zone, 'Europe/Berlin');
++ assert.ok(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(out.data.local_time), out.data.local_time);
++ assert.ok(/^UTC([+-]\d{2}:\d{2})?$/.test(out.data.utc_offset), out.data.utc_offset);
++ assert.ok(out.data.abbreviation.length > 0);
++ assert.strictEqual(typeof out.data.dst, 'boolean');
++ assert.ok(out.text.includes('Europe/Berlin'), out.text);
++ });
++
++ await check('time_in takes a city as well as a zone', async () => {
++ const out = await timeIn.run({ zone: 'Tokyo' }, noNetwork);
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.zone, 'Asia/Tokyo');
++ assert.strictEqual(out.data.utc_offset, 'UTC+09:00');
++ });
++
++ await check('a place that is not a zone is an error the model can act on', async () => {
++ const out = await timeIn.run({ zone: 'Middle Earth' }, noNetwork);
++ assert.ok(out.error && out.error.includes('Middle Earth'), out.error);
++ });
++
++ await check('convert_time reads a summer moment with the summer offsets', async () => {
++ const out = await convertTime.run({ from: 'Australia/Sydney', to: 'Europe/Berlin', datetime: '2026-07-01T00:00:00Z' }, noNetwork);
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.from.utc_offset, 'UTC+10:00');
++ assert.strictEqual(out.data.to.utc_offset, 'UTC+02:00');
++ assert.strictEqual(out.data.difference_minutes, -480);
++ assert.strictEqual(out.data.from.local_time, '2026-07-01 10:00:00');
++ assert.strictEqual(out.data.to.local_time, '2026-07-01 02:00:00');
++ assert.strictEqual(out.data.from.dst, false);
++ assert.strictEqual(out.data.to.dst, true);
++ });
++
++ await check('convert_time reads a winter moment with the winter offsets', async () => {
++ const out = await convertTime.run({ from: 'Australia/Sydney', to: 'Europe/Berlin', datetime: '2026-01-15T00:00:00Z' }, noNetwork);
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.from.utc_offset, 'UTC+11:00');
++ assert.strictEqual(out.data.to.utc_offset, 'UTC+01:00');
++ assert.strictEqual(out.data.difference_minutes, -600);
++ assert.strictEqual(out.data.from.dst, true);
++ assert.strictEqual(out.data.to.dst, false);
++ });
++
++ await check('convert_time reads a wall-clock time in the zone it was written in', async () => {
++ const out = await convertTime.run({ from: 'America/New_York', to: 'UTC', datetime: '2026-07-01 08:00' }, noNetwork);
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.utc_time, '2026-07-01T12:00:00.000Z');
++ assert.strictEqual(out.data.from.utc_offset, 'UTC-04:00');
++ assert.strictEqual(out.data.from.local_time, '2026-07-01 08:00:00');
++ });
++
++ await check('convert_time says how far ahead the other place is', async () => {
++ const out = await convertTime.run({ from: 'America/New_York', to: 'Europe/Berlin', datetime: '2026-07-01T12:00:00Z' }, noNetwork);
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.difference_minutes, 360);
++ assert.ok(out.data.difference.includes('ahead'), out.data.difference);
++ assert.ok(out.text.includes('Europe/Berlin'), out.text);
++ });
++
++ await check('a moment nobody can read is an error the model can act on', async () => {
++ const out = await convertTime.run({ to: 'UTC', datetime: 'the day before yesterday-ish' }, noNetwork);
++ assert.ok(out.error && out.error.length > 10, out.error);
++ });
++
++ done();
++})();
ddiff --git a/test/tool-weather.test.js b/test/tool-weather.test.js
nnew file mode 100644
iindex 0000000..394478f
---- /dev/null
++++ b/test/tool-weather.test.js
@@@ -0,0 +1,73 @@
++'use strict';
++// weather_now: the place is found, the forecast is read, the units are given both ways and the
++// source is carried with the answer. The answers come from the recorded fixtures, not the network.
++const assert = require('assert');
++const weather = require('../server/runtime/tools/weather_now');
++const { fixtureFetch, ctxOf } = require('./helpers/tool-fixtures');
++const { check, done } = require('./helpers/boot');
++
++const routes = {
++ 'geocoding-api.open-meteo.com/v1/search': (url) => (url.includes('Nowhere') ? 'open-meteo-geocode-eiffel.json' : 'open-meteo-geocode-berlin.json'),
++ 'api.open-meteo.com/v1/forecast': 'open-meteo-forecast-berlin.json',
++};
++
++(async () => {
++ await check('weather_now gives the conditions now, in Celsius and Fahrenheit', async () => {
++ const f = fixtureFetch(routes);
++ const out = await weather.run({ place: 'Berlin' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.ok(out.text.includes('Berlin'), out.text);
++ assert.ok(out.text.includes('°C'), out.text);
++ assert.ok(out.text.includes('°F'), out.text);
++ assert.strictEqual(out.data.current.temperature_c, 7.4);
++ assert.strictEqual(out.data.current.temperature_f, 45.32);
++ assert.strictEqual(out.data.current.description, 'fog');
++ assert.strictEqual(out.data.current.wind_kmh, 3.3);
++ assert.ok(out.data.current.wind_mph > 2 && out.data.current.wind_mph < 2.1, 'the wind in mph too');
++ assert.strictEqual(out.data.place.timezone, 'Europe/Berlin');
++ assert.strictEqual(out.data.place.country, 'Germany');
++ });
++
++ await check('weather_now gives three days of forecast', async () => {
++ const f = fixtureFetch(routes);
++ const out = await weather.run({ place: 'Berlin, Germany' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.data.forecast.length, 3);
++ assert.strictEqual(out.data.forecast[0].date, '2026-10-30');
++ assert.strictEqual(out.data.forecast[0].temp_max_c, 10.7);
++ assert.strictEqual(out.data.forecast[0].temp_max_f, 51.26);
++ assert.strictEqual(out.data.forecast[0].description, 'light drizzle');
++ assert.ok(out.text.includes('light drizzle'), out.text);
++ });
++
++ await check('weather_now carries the provider as its source', async () => {
++ const f = fixtureFetch(routes);
++ const out = await weather.run({ place: 'Hamburg' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.ok(out.sources.length >= 1);
++ assert.ok(out.sources.some((s) => s.url.startsWith('https://open-meteo.com')), 'the source is the provider page');
++ assert.ok(out.sources.every((s) => s.title && s.url), 'every source has a title and a url');
++ });
++
++ await check('weather_now asks the geocoding API for the place first', async () => {
++ const f = fixtureFetch(routes);
++ await weather.run({ place: 'Lisbon' }, ctxOf(f.fetchImpl));
++ assert.ok(f.calls.some((c) => c.url.includes('geocoding-api.open-meteo.com')), 'the place is geocoded');
++ assert.ok(f.calls.some((c) => c.url.includes('api.open-meteo.com/v1/forecast')), 'then the forecast is fetched');
++ });
++
++ await check('a place that does not exist is an error the model can act on', async () => {
++ const f = fixtureFetch(routes);
++ const out = await weather.run({ place: 'Nowhere' }, ctxOf(f.fetchImpl));
++ assert.ok(out.error, 'there is an error');
++ assert.ok(out.error.includes('Nowhere'), out.error);
++ });
++
++ await check('no place at all is an error, not a crash', async () => {
++ const f = fixtureFetch(routes);
++ const out = await weather.run({}, ctxOf(f.fetchImpl));
++ assert.ok(out.error && out.error.includes('place'), out.error);
++ });
++
++ done();
++})();
ddiff --git a/test/tool-wikipedia.test.js b/test/tool-wikipedia.test.js
nnew file mode 100644
iindex 0000000..4f3f801
---- /dev/null
++++ b/test/tool-wikipedia.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++// wikipedia: the summary, the key facts and the page as the source.
++const assert = require('assert');
++const wikipedia = require('../server/runtime/tools/wikipedia');
++const { fixtureFetch, ctxOf } = require('./helpers/tool-fixtures');
++const { check, done } = require('./helpers/boot');
++
++const routes = {
++ 'en.wikipedia.org/api/rest_v1/page/summary/Marie_Curie': 'wikipedia-summary-marie-curie.json',
++ 'en.wikipedia.org/w/api.php': 'wikipedia-query-marie-curie.json',
++};
++
++(async () => {
++ await check('wikipedia gives the summary and the key facts', async () => {
++ const f = fixtureFetch(routes);
++ const out = await wikipedia.run({ topic: 'Marie Curie' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.ok(out.data.extract.includes('Curie'), out.data.extract);
++ assert.strictEqual(out.data.title, 'Marie Curie');
++ assert.strictEqual(out.data.wikibase_item, 'Q7186');
++ assert.ok(out.data.page_id > 0);
++ assert.ok(out.data.last_revision, 'the last edit');
++ assert.ok(out.data.categories.length > 0, 'the categories');
++ assert.ok(out.text.includes('Key facts'), out.text);
++ });
++
++ await check('wikipedia carries the page as its source', async () => {
++ const f = fixtureFetch(routes);
++ const out = await wikipedia.run({ topic: 'Marie Curie' }, ctxOf(f.fetchImpl));
++ assert.ok(!out.error, out.error);
++ assert.strictEqual(out.sources[0].url, 'https://en.wikipedia.org/wiki/Marie_Curie');
++ assert.ok(out.sources[0].title.includes('Marie Curie'));
++ });
++
++ await check('a topic Wikipedia does not have is an error the model can act on', async () => {
++ const f = fixtureFetch({
++ 'en.wikipedia.org/api/rest_v1/page/summary/Marie_Curie': () => null,
++ 'en.wikipedia.org/w/api.php': 'wikipedia-query-marie-curie.json',
++ });
++ const out = await wikipedia.run({ topic: 'Nobody At All' }, ctxOf(f.fetchImpl));
++ assert.ok(out.error && out.error.includes('Nobody At All'), out.error);
++ });
++
++ await check('a language that is not a Wikipedia language is refused', async () => {
++ const out = await wikipedia.run({ topic: 'Marie Curie', language: 'nonsense!' }, ctxOf(fixtureFetch(routes).fetchImpl));
++ assert.ok(out.error && out.error.includes('language'), out.error);
++ });
++
++ done();
++})();