@kernel.chat/kbot 3.40.0 → 3.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +5 -5
  2. package/dist/agent-teams.d.ts +1 -1
  3. package/dist/agent-teams.d.ts.map +1 -1
  4. package/dist/agent-teams.js +36 -3
  5. package/dist/agent-teams.js.map +1 -1
  6. package/dist/agents/specialists.d.ts.map +1 -1
  7. package/dist/agents/specialists.js +20 -0
  8. package/dist/agents/specialists.js.map +1 -1
  9. package/dist/channels/kbot-channel.js +8 -31
  10. package/dist/channels/kbot-channel.js.map +1 -1
  11. package/dist/cli.js +8 -8
  12. package/dist/digest.js +1 -1
  13. package/dist/digest.js.map +1 -1
  14. package/dist/email-service.d.ts.map +1 -1
  15. package/dist/email-service.js +1 -2
  16. package/dist/email-service.js.map +1 -1
  17. package/dist/episodic-memory.d.ts.map +1 -1
  18. package/dist/episodic-memory.js +14 -0
  19. package/dist/episodic-memory.js.map +1 -1
  20. package/dist/interactive-buttons.d.ts +90 -0
  21. package/dist/interactive-buttons.d.ts.map +1 -0
  22. package/dist/interactive-buttons.js +286 -0
  23. package/dist/interactive-buttons.js.map +1 -0
  24. package/dist/learned-router.d.ts.map +1 -1
  25. package/dist/learned-router.js +29 -0
  26. package/dist/learned-router.js.map +1 -1
  27. package/dist/memory-hotswap.d.ts +58 -0
  28. package/dist/memory-hotswap.d.ts.map +1 -0
  29. package/dist/memory-hotswap.js +288 -0
  30. package/dist/memory-hotswap.js.map +1 -0
  31. package/dist/personal-security.d.ts +142 -0
  32. package/dist/personal-security.d.ts.map +1 -0
  33. package/dist/personal-security.js +1151 -0
  34. package/dist/personal-security.js.map +1 -0
  35. package/dist/side-conversation.d.ts +58 -0
  36. package/dist/side-conversation.d.ts.map +1 -0
  37. package/dist/side-conversation.js +224 -0
  38. package/dist/side-conversation.js.map +1 -0
  39. package/dist/tools/email.d.ts.map +1 -1
  40. package/dist/tools/email.js +2 -3
  41. package/dist/tools/email.js.map +1 -1
  42. package/dist/tools/index.d.ts.map +1 -1
  43. package/dist/tools/index.js +7 -1
  44. package/dist/tools/index.js.map +1 -1
  45. package/dist/tools/lab-bio.d.ts +2 -0
  46. package/dist/tools/lab-bio.d.ts.map +1 -0
  47. package/dist/tools/lab-bio.js +1392 -0
  48. package/dist/tools/lab-bio.js.map +1 -0
  49. package/dist/tools/lab-chem.d.ts +2 -0
  50. package/dist/tools/lab-chem.d.ts.map +1 -0
  51. package/dist/tools/lab-chem.js +1257 -0
  52. package/dist/tools/lab-chem.js.map +1 -0
  53. package/dist/tools/lab-core.d.ts +2 -0
  54. package/dist/tools/lab-core.d.ts.map +1 -0
  55. package/dist/tools/lab-core.js +2452 -0
  56. package/dist/tools/lab-core.js.map +1 -0
  57. package/dist/tools/lab-data.d.ts +2 -0
  58. package/dist/tools/lab-data.d.ts.map +1 -0
  59. package/dist/tools/lab-data.js +2464 -0
  60. package/dist/tools/lab-data.js.map +1 -0
  61. package/dist/tools/lab-earth.d.ts +2 -0
  62. package/dist/tools/lab-earth.d.ts.map +1 -0
  63. package/dist/tools/lab-earth.js +1124 -0
  64. package/dist/tools/lab-earth.js.map +1 -0
  65. package/dist/tools/lab-math.d.ts +2 -0
  66. package/dist/tools/lab-math.d.ts.map +1 -0
  67. package/dist/tools/lab-math.js +3021 -0
  68. package/dist/tools/lab-math.js.map +1 -0
  69. package/dist/tools/lab-physics.d.ts +2 -0
  70. package/dist/tools/lab-physics.d.ts.map +1 -0
  71. package/dist/tools/lab-physics.js +2423 -0
  72. package/dist/tools/lab-physics.js.map +1 -0
  73. package/package.json +2 -3
@@ -0,0 +1,1124 @@
1
+ // kbot Lab Earth & Environmental Sciences Tools
2
+ // Provides earthquake data, climate analysis, satellite imagery search,
3
+ // geological queries, ocean data, air quality, soil properties, volcano
4
+ // monitoring, water resources, and biodiversity indices.
5
+ // All API calls use public, freely accessible endpoints — no API keys required.
6
+ import { registerTool } from './index.js';
7
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
8
+ const UA = 'KBot/3.0 (Lab Tools)';
9
+ async function labFetch(url) {
10
+ return fetch(url, {
11
+ headers: { 'User-Agent': UA },
12
+ signal: AbortSignal.timeout(10000),
13
+ });
14
+ }
15
+ function fmt(n, d = 4) {
16
+ if (!isFinite(n))
17
+ return String(n);
18
+ return n.toFixed(d);
19
+ }
20
+ function isoDate(epoch) {
21
+ return new Date(epoch).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
22
+ }
23
+ function today() {
24
+ return new Date().toISOString().split('T')[0];
25
+ }
26
+ function daysAgo(n) {
27
+ const d = new Date();
28
+ d.setDate(d.getDate() - n);
29
+ return d.toISOString().split('T')[0];
30
+ }
31
+ const MAJOR_VOLCANOES = [
32
+ { name: 'Kilauea', country: 'United States', region: 'Hawaii', lat: 19.421, lon: -155.287, elevation_m: 1222, type: 'Shield', last_eruption: '2023', status: 'Active' },
33
+ { name: 'Mauna Loa', country: 'United States', region: 'Hawaii', lat: 19.475, lon: -155.608, elevation_m: 4169, type: 'Shield', last_eruption: '2022', status: 'Active' },
34
+ { name: 'Mount Etna', country: 'Italy', region: 'Sicily', lat: 37.748, lon: 15.002, elevation_m: 3357, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
35
+ { name: 'Stromboli', country: 'Italy', region: 'Aeolian Islands', lat: 38.789, lon: 15.213, elevation_m: 924, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
36
+ { name: 'Mount Vesuvius', country: 'Italy', region: 'Campania', lat: 40.821, lon: 14.426, elevation_m: 1281, type: 'Stratovolcano', last_eruption: '1944', status: 'Dormant' },
37
+ { name: 'Piton de la Fournaise', country: 'France', region: 'Reunion', lat: -21.244, lon: 55.714, elevation_m: 2632, type: 'Shield', last_eruption: '2024', status: 'Active' },
38
+ { name: 'Eyjafjallajokull', country: 'Iceland', region: 'South Iceland', lat: 63.633, lon: -19.633, elevation_m: 1651, type: 'Stratovolcano', last_eruption: '2010', status: 'Active' },
39
+ { name: 'Fagradalsfjall', country: 'Iceland', region: 'Reykjanes', lat: 63.903, lon: -22.267, elevation_m: 385, type: 'Tuya', last_eruption: '2024', status: 'Active' },
40
+ { name: 'Hekla', country: 'Iceland', region: 'South Iceland', lat: 63.983, lon: -19.700, elevation_m: 1491, type: 'Stratovolcano', last_eruption: '2000', status: 'Active' },
41
+ { name: 'Mount Fuji', country: 'Japan', region: 'Honshu', lat: 35.361, lon: 138.727, elevation_m: 3776, type: 'Stratovolcano', last_eruption: '1707', status: 'Active' },
42
+ { name: 'Sakurajima', country: 'Japan', region: 'Kyushu', lat: 31.593, lon: 130.657, elevation_m: 1117, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
43
+ { name: 'Mount Aso', country: 'Japan', region: 'Kyushu', lat: 32.884, lon: 131.104, elevation_m: 1592, type: 'Caldera', last_eruption: '2021', status: 'Active' },
44
+ { name: 'Suwanosejima', country: 'Japan', region: 'Ryukyu Islands', lat: 29.638, lon: 129.714, elevation_m: 796, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
45
+ { name: 'Mount Merapi', country: 'Indonesia', region: 'Java', lat: -7.540, lon: 110.446, elevation_m: 2930, type: 'Stratovolcano', last_eruption: '2023', status: 'Active' },
46
+ { name: 'Mount Semeru', country: 'Indonesia', region: 'Java', lat: -8.108, lon: 112.922, elevation_m: 3676, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
47
+ { name: 'Mount Sinabung', country: 'Indonesia', region: 'Sumatra', lat: 3.170, lon: 98.392, elevation_m: 2460, type: 'Stratovolcano', last_eruption: '2021', status: 'Active' },
48
+ { name: 'Krakatoa (Anak Krakatau)', country: 'Indonesia', region: 'Sunda Strait', lat: -6.102, lon: 105.423, elevation_m: 155, type: 'Caldera', last_eruption: '2023', status: 'Active' },
49
+ { name: 'Dukono', country: 'Indonesia', region: 'Halmahera', lat: 1.693, lon: 127.894, elevation_m: 1229, type: 'Complex', last_eruption: '2024', status: 'Active' },
50
+ { name: 'Mount Ruapehu', country: 'New Zealand', region: 'North Island', lat: -39.281, lon: 175.564, elevation_m: 2797, type: 'Stratovolcano', last_eruption: '2007', status: 'Active' },
51
+ { name: 'White Island (Whakaari)', country: 'New Zealand', region: 'Bay of Plenty', lat: -37.520, lon: 177.183, elevation_m: 321, type: 'Stratovolcano', last_eruption: '2019', status: 'Active' },
52
+ { name: 'Taal', country: 'Philippines', region: 'Luzon', lat: 14.002, lon: 120.993, elevation_m: 311, type: 'Caldera', last_eruption: '2022', status: 'Active' },
53
+ { name: 'Mayon', country: 'Philippines', region: 'Luzon', lat: 13.257, lon: 123.685, elevation_m: 2462, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
54
+ { name: 'Mount Pinatubo', country: 'Philippines', region: 'Luzon', lat: 15.130, lon: 120.350, elevation_m: 1486, type: 'Stratovolcano', last_eruption: '1991', status: 'Active' },
55
+ { name: 'Popocatepetl', country: 'Mexico', region: 'Trans-Mexican', lat: 19.023, lon: -98.622, elevation_m: 5426, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
56
+ { name: 'Colima', country: 'Mexico', region: 'Jalisco', lat: 19.514, lon: -103.617, elevation_m: 3850, type: 'Stratovolcano', last_eruption: '2019', status: 'Active' },
57
+ { name: 'Fuego', country: 'Guatemala', region: 'Central Highlands', lat: 14.473, lon: -90.880, elevation_m: 3763, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
58
+ { name: 'Pacaya', country: 'Guatemala', region: 'Central Highlands', lat: 14.381, lon: -90.601, elevation_m: 2552, type: 'Complex', last_eruption: '2024', status: 'Active' },
59
+ { name: 'Arenal', country: 'Costa Rica', region: 'Guanacaste', lat: 10.463, lon: -84.703, elevation_m: 1670, type: 'Stratovolcano', last_eruption: '2010', status: 'Dormant' },
60
+ { name: 'Cotopaxi', country: 'Ecuador', region: 'Andes', lat: -0.677, lon: -78.436, elevation_m: 5897, type: 'Stratovolcano', last_eruption: '2023', status: 'Active' },
61
+ { name: 'Sangay', country: 'Ecuador', region: 'Andes', lat: -2.002, lon: -78.341, elevation_m: 5230, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
62
+ { name: 'Villarrica', country: 'Chile', region: 'Araucania', lat: -39.420, lon: -71.939, elevation_m: 2847, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
63
+ { name: 'Calbuco', country: 'Chile', region: 'Los Lagos', lat: -41.326, lon: -72.614, elevation_m: 2003, type: 'Stratovolcano', last_eruption: '2015', status: 'Active' },
64
+ { name: 'Mount Erebus', country: 'Antarctica', region: 'Ross Island', lat: -77.530, lon: 167.170, elevation_m: 3794, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
65
+ { name: 'Mount St. Helens', country: 'United States', region: 'Cascades', lat: 46.200, lon: -122.180, elevation_m: 2549, type: 'Stratovolcano', last_eruption: '2008', status: 'Active' },
66
+ { name: 'Mount Rainier', country: 'United States', region: 'Cascades', lat: 46.853, lon: -121.760, elevation_m: 4392, type: 'Stratovolcano', last_eruption: '1894', status: 'Active' },
67
+ { name: 'Mount Shasta', country: 'United States', region: 'Cascades', lat: 41.409, lon: -122.194, elevation_m: 4322, type: 'Stratovolcano', last_eruption: '~1250', status: 'Dormant' },
68
+ { name: 'Mount Hood', country: 'United States', region: 'Cascades', lat: 45.374, lon: -121.696, elevation_m: 3429, type: 'Stratovolcano', last_eruption: '~1866', status: 'Dormant' },
69
+ { name: 'Erta Ale', country: 'Ethiopia', region: 'Afar', lat: 13.600, lon: 40.670, elevation_m: 613, type: 'Shield', last_eruption: '2024', status: 'Active' },
70
+ { name: 'Nyiragongo', country: 'DR Congo', region: 'Virunga', lat: -1.520, lon: 29.250, elevation_m: 3470, type: 'Stratovolcano', last_eruption: '2021', status: 'Active' },
71
+ { name: 'Ol Doinyo Lengai', country: 'Tanzania', region: 'East African Rift', lat: -2.764, lon: 35.914, elevation_m: 2962, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
72
+ { name: 'Mount Cameroon', country: 'Cameroon', region: 'West Africa', lat: 4.203, lon: 9.170, elevation_m: 4095, type: 'Stratovolcano', last_eruption: '2012', status: 'Active' },
73
+ { name: 'Klyuchevskoy', country: 'Russia', region: 'Kamchatka', lat: 56.056, lon: 160.638, elevation_m: 4835, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
74
+ { name: 'Shiveluch', country: 'Russia', region: 'Kamchatka', lat: 56.653, lon: 161.360, elevation_m: 3283, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
75
+ { name: 'Bezymianny', country: 'Russia', region: 'Kamchatka', lat: 55.978, lon: 160.587, elevation_m: 2882, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
76
+ { name: 'Karymsky', country: 'Russia', region: 'Kamchatka', lat: 54.049, lon: 159.443, elevation_m: 1536, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
77
+ { name: 'Ebeko', country: 'Russia', region: 'Kuril Islands', lat: 50.686, lon: 156.014, elevation_m: 1156, type: 'Stratovolcano', last_eruption: '2023', status: 'Active' },
78
+ { name: 'Mount Nyamuragira', country: 'DR Congo', region: 'Virunga', lat: -1.408, lon: 29.200, elevation_m: 3058, type: 'Shield', last_eruption: '2024', status: 'Active' },
79
+ { name: 'Piton des Neiges', country: 'France', region: 'Reunion', lat: -21.100, lon: 55.483, elevation_m: 3069, type: 'Shield', last_eruption: 'Pleistocene', status: 'Extinct' },
80
+ { name: 'Tambora', country: 'Indonesia', region: 'Sumbawa', lat: -8.250, lon: 118.000, elevation_m: 2850, type: 'Stratovolcano', last_eruption: '1967', status: 'Active' },
81
+ { name: 'Nevado del Ruiz', country: 'Colombia', region: 'Andes', lat: 4.892, lon: -75.322, elevation_m: 5321, type: 'Stratovolcano', last_eruption: '2024', status: 'Active' },
82
+ ];
83
+ // ─── Registration ────────────────────────────────────────────────────────────
84
+ export function registerLabEarthTools() {
85
+ // ── 1. Earthquake Query ─────────────────────────────────────────────────
86
+ registerTool({
87
+ name: 'earthquake_query',
88
+ description: 'Query USGS earthquake data: recent events, historical quakes, filter by magnitude, location, and time range. Returns magnitude, location, depth, time, tsunami alert, and USGS detail URL.',
89
+ parameters: {
90
+ min_magnitude: { type: 'number', description: 'Minimum magnitude (default: 4)' },
91
+ start_date: { type: 'string', description: 'Start date YYYY-MM-DD (default: 30 days ago)' },
92
+ end_date: { type: 'string', description: 'End date YYYY-MM-DD (default: today)' },
93
+ latitude: { type: 'number', description: 'Latitude for geographic filter' },
94
+ longitude: { type: 'number', description: 'Longitude for geographic filter' },
95
+ max_radius_km: { type: 'number', description: 'Max radius in km from lat/lon (default: 500)' },
96
+ limit: { type: 'number', description: 'Max results (default: 10, max: 50)' },
97
+ },
98
+ tier: 'free',
99
+ async execute(args) {
100
+ const minMag = typeof args.min_magnitude === 'number' ? args.min_magnitude : 4;
101
+ const startDate = typeof args.start_date === 'string' ? args.start_date : daysAgo(30);
102
+ const endDate = typeof args.end_date === 'string' ? args.end_date : today();
103
+ const limit = typeof args.limit === 'number' ? Math.min(args.limit, 50) : 10;
104
+ let url = `https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&minmagnitude=${minMag}&starttime=${startDate}&endtime=${endDate}&limit=${limit}&orderby=time`;
105
+ if (typeof args.latitude === 'number' && typeof args.longitude === 'number') {
106
+ const lat = args.latitude;
107
+ const lon = args.longitude;
108
+ const radius = typeof args.max_radius_km === 'number' ? args.max_radius_km : 500;
109
+ url += `&latitude=${lat}&longitude=${lon}&maxradiuskm=${radius}`;
110
+ }
111
+ try {
112
+ const res = await labFetch(url);
113
+ if (!res.ok)
114
+ return `**Error**: USGS API returned ${res.status} ${res.statusText}`;
115
+ const data = await res.json();
116
+ if (!data.features || data.features.length === 0) {
117
+ return `**No earthquakes found** matching M >= ${minMag} from ${startDate} to ${endDate}.`;
118
+ }
119
+ const lines = [
120
+ `# Earthquake Query Results`,
121
+ `**Source**: USGS Earthquake Hazards Program`,
122
+ `**Filter**: M >= ${minMag} | ${startDate} to ${endDate} | ${data.metadata.count} event(s)\n`,
123
+ `| Mag | Location | Depth (km) | Time (UTC) | Tsunami | Link |`,
124
+ `|-----|----------|------------|------------|---------|------|`,
125
+ ];
126
+ for (const f of data.features) {
127
+ const p = f.properties;
128
+ const [lon, lat, depth] = f.geometry.coordinates;
129
+ const mag = p.mag !== null ? p.mag.toFixed(1) : '?';
130
+ const place = p.place || 'Unknown location';
131
+ const time = isoDate(p.time);
132
+ const tsunami = p.tsunami ? 'Yes' : 'No';
133
+ const link = p.url ? `[Details](${p.url})` : '-';
134
+ lines.push(`| ${mag} | ${place} | ${depth.toFixed(1)} | ${time} | ${tsunami} | ${link} |`);
135
+ }
136
+ lines.push('');
137
+ // Summary statistics
138
+ const mags = data.features.map(f => f.properties.mag).filter((m) => m !== null);
139
+ if (mags.length > 0) {
140
+ const maxMag = Math.max(...mags);
141
+ const avgMag = mags.reduce((s, m) => s + m, 0) / mags.length;
142
+ const depths = data.features.map(f => f.geometry.coordinates[2]);
143
+ const avgDepth = depths.reduce((s, d) => s + d, 0) / depths.length;
144
+ const tsunamiCount = data.features.filter(f => f.properties.tsunami).length;
145
+ lines.push(`### Summary`, `- **Events shown**: ${data.features.length} of ${data.metadata.count} total`, `- **Max magnitude**: ${maxMag.toFixed(1)}`, `- **Avg magnitude**: ${avgMag.toFixed(1)}`, `- **Avg depth**: ${avgDepth.toFixed(1)} km`, tsunamiCount > 0 ? `- **Tsunami alerts**: ${tsunamiCount}` : '');
146
+ }
147
+ return lines.filter(l => l !== '').join('\n');
148
+ }
149
+ catch (err) {
150
+ return `**Error**: Failed to query USGS earthquake API — ${err instanceof Error ? err.message : String(err)}`;
151
+ }
152
+ },
153
+ });
154
+ // ── 2. Climate Data ─────────────────────────────────────────────────────
155
+ registerTool({
156
+ name: 'climate_data',
157
+ description: 'Retrieve historical climate data: global temperature anomalies (NASA GISS), atmospheric CO2 concentrations (NOAA Mauna Loa), or sea level data. Filter by year range.',
158
+ parameters: {
159
+ variable: { type: 'string', description: 'Climate variable: temperature, co2, or sea_level', required: true },
160
+ year_from: { type: 'number', description: 'Start year (optional)' },
161
+ year_to: { type: 'number', description: 'End year (optional)' },
162
+ },
163
+ tier: 'free',
164
+ async execute(args) {
165
+ const variable = String(args.variable).toLowerCase().trim();
166
+ const yearFrom = typeof args.year_from === 'number' ? args.year_from : undefined;
167
+ const yearTo = typeof args.year_to === 'number' ? args.year_to : undefined;
168
+ if (variable === 'temperature') {
169
+ try {
170
+ const url = 'https://data.giss.nasa.gov/gistemp/tabledata_v4/GLB.Ts+dSST.csv';
171
+ const res = await labFetch(url);
172
+ if (!res.ok)
173
+ return `**Error**: NASA GISS returned ${res.status}`;
174
+ const text = await res.text();
175
+ // Parse the CSV: first valid line has "Year" header, data lines have year as first field
176
+ const rawLines = text.split('\n');
177
+ // Find the header line
178
+ let headerIdx = -1;
179
+ for (let i = 0; i < rawLines.length; i++) {
180
+ if (rawLines[i].startsWith('Year')) {
181
+ headerIdx = i;
182
+ break;
183
+ }
184
+ }
185
+ if (headerIdx === -1)
186
+ return '**Error**: Could not parse NASA GISS temperature data — header not found.';
187
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
188
+ const lines = [
189
+ '# Global Temperature Anomalies',
190
+ '**Source**: NASA GISS Surface Temperature Analysis (GISTEMP v4)',
191
+ '**Baseline**: 1951-1980 average\n',
192
+ '| Year | Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec | Annual |',
193
+ '|------|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|--------|',
194
+ ];
195
+ const years = [];
196
+ const annualValues = [];
197
+ for (let i = headerIdx + 1; i < rawLines.length; i++) {
198
+ const row = rawLines[i].split(',');
199
+ if (row.length < 2)
200
+ continue;
201
+ const year = parseInt(row[0], 10);
202
+ if (isNaN(year))
203
+ continue;
204
+ if (yearFrom !== undefined && year < yearFrom)
205
+ continue;
206
+ if (yearTo !== undefined && year > yearTo)
207
+ continue;
208
+ // Columns: Year, Jan, Feb, ..., Dec, J-D, D-N, DJF, MAM, JJA, SON
209
+ const monthVals = row.slice(1, 13).map(v => {
210
+ const n = parseFloat(v);
211
+ return isNaN(n) ? '***' : n.toFixed(2);
212
+ });
213
+ // J-D (annual mean) is typically column 14 (index 13)
214
+ const annual = row[13] ? parseFloat(row[13]) : NaN;
215
+ const annualStr = isNaN(annual) ? '***' : annual.toFixed(2);
216
+ if (!isNaN(annual)) {
217
+ years.push(year);
218
+ annualValues.push(annual);
219
+ }
220
+ lines.push(`| ${year} | ${monthVals.join(' | ')} | ${annualStr} |`);
221
+ }
222
+ if (annualValues.length > 0) {
223
+ const maxAnomaly = Math.max(...annualValues);
224
+ const minAnomaly = Math.min(...annualValues);
225
+ const maxYear = years[annualValues.indexOf(maxAnomaly)];
226
+ const minYear = years[annualValues.indexOf(minAnomaly)];
227
+ const latest = annualValues[annualValues.length - 1];
228
+ const latestYear = years[years.length - 1];
229
+ lines.push('', '### Summary', `- **Range**: ${years[0]} to ${years[years.length - 1]} (${years.length} years)`, `- **Warmest year**: ${maxYear} (+${maxAnomaly.toFixed(2)} C)`, `- **Coolest year**: ${minYear} (${minAnomaly.toFixed(2)} C)`, `- **Latest**: ${latestYear} (${latest >= 0 ? '+' : ''}${latest.toFixed(2)} C)`);
230
+ // Trend via linear regression
231
+ if (years.length >= 3) {
232
+ const n = years.length;
233
+ const sumX = years.reduce((s, y) => s + y, 0);
234
+ const sumY = annualValues.reduce((s, v) => s + v, 0);
235
+ const sumXY = years.reduce((s, y, i) => s + y * annualValues[i], 0);
236
+ const sumX2 = years.reduce((s, y) => s + y * y, 0);
237
+ const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
238
+ lines.push(`- **Trend**: ${slope >= 0 ? '+' : ''}${(slope * 10).toFixed(3)} C/decade`);
239
+ }
240
+ }
241
+ return lines.join('\n');
242
+ }
243
+ catch (err) {
244
+ return `**Error**: Failed to fetch NASA GISS temperature data — ${err instanceof Error ? err.message : String(err)}`;
245
+ }
246
+ }
247
+ if (variable === 'co2') {
248
+ try {
249
+ const url = 'https://gml.noaa.gov/webdata/ccgg/trends/co2/co2_mm_mlo.txt';
250
+ const res = await labFetch(url);
251
+ if (!res.ok)
252
+ return `**Error**: NOAA GML returned ${res.status}`;
253
+ const text = await res.text();
254
+ // Parse: skip comment lines (#), space-separated: year month decimal_date average interpolated trend days
255
+ const dataLines = text.split('\n').filter(l => l.trim() && !l.startsWith('#'));
256
+ const lines = [
257
+ '# Atmospheric CO2 Concentrations',
258
+ '**Source**: NOAA Global Monitoring Laboratory, Mauna Loa Observatory',
259
+ '**Units**: parts per million (ppm)\n',
260
+ ];
261
+ const rows = [];
262
+ for (const line of dataLines) {
263
+ const parts = line.trim().split(/\s+/);
264
+ if (parts.length < 5)
265
+ continue;
266
+ const year = parseInt(parts[0], 10);
267
+ const month = parseInt(parts[1], 10);
268
+ const avg = parseFloat(parts[3]);
269
+ const trend = parseFloat(parts[5]);
270
+ if (isNaN(year) || isNaN(month) || isNaN(avg))
271
+ continue;
272
+ if (yearFrom !== undefined && year < yearFrom)
273
+ continue;
274
+ if (yearTo !== undefined && year > yearTo)
275
+ continue;
276
+ rows.push({ year, month, avg, trend: isNaN(trend) ? avg : trend });
277
+ }
278
+ if (rows.length === 0)
279
+ return '**No CO2 data found** for the specified year range.';
280
+ // If many rows, show annual averages; otherwise monthly
281
+ const yearGroups = new Map();
282
+ for (const r of rows) {
283
+ if (!yearGroups.has(r.year))
284
+ yearGroups.set(r.year, []);
285
+ yearGroups.get(r.year).push(r.avg);
286
+ }
287
+ if (yearGroups.size > 20) {
288
+ // Annual summary
289
+ lines.push('| Year | Mean CO2 (ppm) | Min (ppm) | Max (ppm) |', '|------|---------------|-----------|-----------|');
290
+ for (const [year, vals] of yearGroups) {
291
+ const mean = vals.reduce((s, v) => s + v, 0) / vals.length;
292
+ lines.push(`| ${year} | ${mean.toFixed(2)} | ${Math.min(...vals).toFixed(2)} | ${Math.max(...vals).toFixed(2)} |`);
293
+ }
294
+ }
295
+ else {
296
+ // Monthly detail
297
+ lines.push('| Year | Month | CO2 (ppm) | Trend (ppm) |', '|------|-------|-----------|-------------|');
298
+ for (const r of rows) {
299
+ lines.push(`| ${r.year} | ${String(r.month).padStart(2, '0')} | ${r.avg.toFixed(2)} | ${r.trend.toFixed(2)} |`);
300
+ }
301
+ }
302
+ // Summary
303
+ const allAvg = rows.map(r => r.avg);
304
+ const latest = rows[rows.length - 1];
305
+ const earliest = rows[0];
306
+ lines.push('', '### Summary', `- **Range**: ${earliest.year}/${String(earliest.month).padStart(2, '0')} to ${latest.year}/${String(latest.month).padStart(2, '0')}`, `- **Latest reading**: ${latest.avg.toFixed(2)} ppm (${latest.year}/${String(latest.month).padStart(2, '0')})`, `- **Maximum**: ${Math.max(...allAvg).toFixed(2)} ppm`, `- **Minimum**: ${Math.min(...allAvg).toFixed(2)} ppm`);
307
+ // Rate of change
308
+ if (rows.length >= 24) {
309
+ const recentYear = rows.slice(-12).reduce((s, r) => s + r.avg, 0) / 12;
310
+ const priorYear = rows.slice(-24, -12).reduce((s, r) => s + r.avg, 0) / 12;
311
+ lines.push(`- **Year-over-year change**: +${(recentYear - priorYear).toFixed(2)} ppm`);
312
+ }
313
+ return lines.join('\n');
314
+ }
315
+ catch (err) {
316
+ return `**Error**: Failed to fetch NOAA CO2 data — ${err instanceof Error ? err.message : String(err)}`;
317
+ }
318
+ }
319
+ if (variable === 'sea_level') {
320
+ try {
321
+ // CSIRO/NOAA reconstructed sea level
322
+ const url = 'https://www.star.nesdis.noaa.gov/socd/lsa/SeaLevelRise/slr/slr_sla_gbl_free_txj1j2_90.csv';
323
+ const res = await labFetch(url);
324
+ if (!res.ok)
325
+ return `**Error**: NOAA sea level API returned ${res.status}`;
326
+ const text = await res.text();
327
+ const rawLines = text.split('\n');
328
+ // Find header
329
+ let headerIdx = -1;
330
+ for (let i = 0; i < rawLines.length; i++) {
331
+ if (rawLines[i].toLowerCase().includes('year') || /^\d{4}/.test(rawLines[i].trim())) {
332
+ headerIdx = i;
333
+ break;
334
+ }
335
+ }
336
+ const lines = [
337
+ '# Global Mean Sea Level',
338
+ '**Source**: NOAA Laboratory for Satellite Altimetry',
339
+ '**Reference**: Satellite altimetry (TOPEX/Jason)\n',
340
+ ];
341
+ // Parse what we can from the CSV
342
+ const dataRows = [];
343
+ const startIdx = headerIdx >= 0 ? headerIdx : 0;
344
+ for (let i = startIdx; i < rawLines.length; i++) {
345
+ const parts = rawLines[i].split(',').map(s => s.trim());
346
+ if (parts.length < 2)
347
+ continue;
348
+ const year = parseFloat(parts[0]);
349
+ // Try second column as sea level
350
+ const level = parseFloat(parts[1]);
351
+ if (isNaN(year) || isNaN(level))
352
+ continue;
353
+ const roundYear = Math.floor(year);
354
+ if (yearFrom !== undefined && roundYear < yearFrom)
355
+ continue;
356
+ if (yearTo !== undefined && roundYear > yearTo)
357
+ continue;
358
+ dataRows.push({ year, level });
359
+ }
360
+ if (dataRows.length === 0) {
361
+ return '**No sea level data** could be parsed for the requested range. The NOAA sea level CSV format may have changed. Try the `climate_data` tool with `variable: temperature` or `variable: co2` instead.';
362
+ }
363
+ // Group by integer year for summary
364
+ const yearMap = new Map();
365
+ for (const r of dataRows) {
366
+ const y = Math.floor(r.year);
367
+ if (!yearMap.has(y))
368
+ yearMap.set(y, []);
369
+ yearMap.get(y).push(r.level);
370
+ }
371
+ lines.push('| Year | Mean Sea Level (mm) |', '|------|-------------------|');
372
+ for (const [year, vals] of yearMap) {
373
+ const mean = vals.reduce((s, v) => s + v, 0) / vals.length;
374
+ lines.push(`| ${year} | ${mean.toFixed(1)} |`);
375
+ }
376
+ const allLevels = dataRows.map(r => r.level);
377
+ const firstLevel = allLevels[0];
378
+ const lastLevel = allLevels[allLevels.length - 1];
379
+ const firstYear = dataRows[0].year;
380
+ const lastYear = dataRows[dataRows.length - 1].year;
381
+ lines.push('', '### Summary', `- **Period**: ${firstYear.toFixed(1)} to ${lastYear.toFixed(1)}`, `- **Total change**: ${(lastLevel - firstLevel).toFixed(1)} mm`, `- **Rate**: ~${((lastLevel - firstLevel) / (lastYear - firstYear)).toFixed(2)} mm/year`);
382
+ return lines.join('\n');
383
+ }
384
+ catch (err) {
385
+ return `**Error**: Failed to fetch sea level data — ${err instanceof Error ? err.message : String(err)}`;
386
+ }
387
+ }
388
+ return `**Error**: Unknown variable "${variable}". Supported: temperature, co2, sea_level.`;
389
+ },
390
+ });
391
+ // ── 3. Satellite Imagery ────────────────────────────────────────────────
392
+ registerTool({
393
+ name: 'satellite_imagery',
394
+ description: 'Search NASA EOSDIS Common Metadata Repository (CMR) for satellite imagery metadata. Returns granule IDs, temporal coverage, browse image URLs, and download links for Landsat, Sentinel, MODIS, and other datasets.',
395
+ parameters: {
396
+ dataset: { type: 'string', description: 'Dataset: landsat, sentinel, modis (or a specific CMR short_name)', required: true },
397
+ latitude: { type: 'number', description: 'Latitude of the point of interest', required: true },
398
+ longitude: { type: 'number', description: 'Longitude of the point of interest', required: true },
399
+ date: { type: 'string', description: 'Target date YYYY-MM-DD or date range YYYY-MM-DD,YYYY-MM-DD (default: last 30 days)' },
400
+ },
401
+ tier: 'free',
402
+ async execute(args) {
403
+ const datasetInput = String(args.dataset).toLowerCase().trim();
404
+ const lat = args.latitude;
405
+ const lon = args.longitude;
406
+ const dateArg = typeof args.date === 'string' ? args.date : undefined;
407
+ // Map friendly names to CMR short_names
408
+ const datasetMap = {
409
+ landsat: 'LANDSAT_8_OLI_TIRS_C2_L2',
410
+ sentinel: 'HLSS30',
411
+ modis: 'MOD09GA',
412
+ 'landsat-8': 'LANDSAT_8_OLI_TIRS_C2_L2',
413
+ 'landsat-9': 'LANDSAT_9_OLI_TIRS_C2_L2',
414
+ 'sentinel-2': 'HLSS30',
415
+ 'modis-terra': 'MOD09GA',
416
+ 'modis-aqua': 'MYD09GA',
417
+ };
418
+ const shortName = datasetMap[datasetInput] || datasetInput;
419
+ let temporalParam = '';
420
+ if (dateArg) {
421
+ if (dateArg.includes(',')) {
422
+ const [start, end] = dateArg.split(',');
423
+ temporalParam = `&temporal[]=${start}T00:00:00Z,${end}T23:59:59Z`;
424
+ }
425
+ else {
426
+ // Search +/- 7 days around the target date
427
+ const d = new Date(dateArg);
428
+ const start = new Date(d.getTime() - 7 * 86400000).toISOString().split('T')[0];
429
+ const end = new Date(d.getTime() + 7 * 86400000).toISOString().split('T')[0];
430
+ temporalParam = `&temporal[]=${start}T00:00:00Z,${end}T23:59:59Z`;
431
+ }
432
+ }
433
+ else {
434
+ temporalParam = `&temporal[]=${daysAgo(30)}T00:00:00Z,${today()}T23:59:59Z`;
435
+ }
436
+ const url = `https://cmr.earthdata.nasa.gov/search/granules.json?short_name=${encodeURIComponent(shortName)}&point=${lon},${lat}&page_size=5${temporalParam}&sort_key=-start_date`;
437
+ try {
438
+ const res = await labFetch(url);
439
+ if (!res.ok)
440
+ return `**Error**: NASA CMR returned ${res.status} ${res.statusText}`;
441
+ const data = await res.json();
442
+ const entries = data.feed?.entry || [];
443
+ if (entries.length === 0) {
444
+ return `**No satellite imagery found** for ${shortName} at (${lat}, ${lon}). Try broadening the date range or using a different dataset.`;
445
+ }
446
+ const lines = [
447
+ `# Satellite Imagery Search`,
448
+ `**Dataset**: ${shortName}`,
449
+ `**Location**: (${lat}, ${lon})`,
450
+ `**Results**: ${entries.length} granule(s)\n`,
451
+ ];
452
+ for (const e of entries) {
453
+ const browseLinks = e.links?.filter(l => l.rel && (l.rel.includes('browse') || l.rel === 'http://esipfed.org/ns/fedsearch/1.1/browse#')) || [];
454
+ const dataLinks = e.links?.filter(l => l.rel && (l.rel.includes('data') || l.rel === 'http://esipfed.org/ns/fedsearch/1.1/data#')) || [];
455
+ lines.push(`## ${e.title || e.id}`, `- **Time**: ${e.time_start || '?'} to ${e.time_end || '?'}`);
456
+ if (e.cloud_cover)
457
+ lines.push(`- **Cloud cover**: ${e.cloud_cover}%`);
458
+ if (e.day_night_flag)
459
+ lines.push(`- **Day/Night**: ${e.day_night_flag}`);
460
+ if (e.granule_size)
461
+ lines.push(`- **Size**: ${e.granule_size} MB`);
462
+ if (browseLinks.length > 0)
463
+ lines.push(`- **Browse**: ${browseLinks[0].href}`);
464
+ if (dataLinks.length > 0)
465
+ lines.push(`- **Download**: ${dataLinks[0].href}`);
466
+ lines.push(`- **Granule ID**: ${e.id}`, '');
467
+ }
468
+ return lines.join('\n');
469
+ }
470
+ catch (err) {
471
+ return `**Error**: Failed to query NASA CMR — ${err instanceof Error ? err.message : String(err)}`;
472
+ }
473
+ },
474
+ });
475
+ // ── 4. Geological Query ─────────────────────────────────────────────────
476
+ registerTool({
477
+ name: 'geological_query',
478
+ description: 'Query Macrostrat for geological data: rock units, formations, lithology, and geological ages by location or formation name. Returns unit names, rock types, ages, thicknesses, and stratigraphic context.',
479
+ parameters: {
480
+ query: { type: 'string', description: 'Search term for formation or lithology name' },
481
+ latitude: { type: 'number', description: 'Latitude for location-based search' },
482
+ longitude: { type: 'number', description: 'Longitude for location-based search' },
483
+ search_type: { type: 'string', description: 'Search type: location, formation, or lithology', required: true },
484
+ },
485
+ tier: 'free',
486
+ async execute(args) {
487
+ const searchType = String(args.search_type).toLowerCase().trim();
488
+ const query = typeof args.query === 'string' ? args.query : '';
489
+ const lat = typeof args.latitude === 'number' ? args.latitude : undefined;
490
+ const lon = typeof args.longitude === 'number' ? args.longitude : undefined;
491
+ let url;
492
+ if (searchType === 'location') {
493
+ if (lat === undefined || lon === undefined) {
494
+ return '**Error**: Location search requires both latitude and longitude parameters.';
495
+ }
496
+ url = `https://macrostrat.org/api/v2/units?lat=${lat}&lng=${lon}&response=long`;
497
+ }
498
+ else if (searchType === 'formation') {
499
+ if (!query)
500
+ return '**Error**: Formation search requires a query parameter.';
501
+ url = `https://macrostrat.org/api/v2/units?strat_name=${encodeURIComponent(query)}&response=long`;
502
+ }
503
+ else if (searchType === 'lithology') {
504
+ if (!query)
505
+ return '**Error**: Lithology search requires a query parameter.';
506
+ url = `https://macrostrat.org/api/v2/units?lith=${encodeURIComponent(query)}&response=long`;
507
+ }
508
+ else {
509
+ return `**Error**: Unknown search_type "${searchType}". Use: location, formation, or lithology.`;
510
+ }
511
+ try {
512
+ const res = await labFetch(url);
513
+ if (!res.ok)
514
+ return `**Error**: Macrostrat API returned ${res.status}`;
515
+ const data = await res.json();
516
+ const units = data.success?.data || [];
517
+ if (units.length === 0) {
518
+ return `**No geological units found** for ${searchType} query${query ? ` "${query}"` : ''}.`;
519
+ }
520
+ const lines = [
521
+ `# Geological Query Results`,
522
+ `**Source**: Macrostrat`,
523
+ `**Search**: ${searchType}${query ? ` — "${query}"` : ''}${lat !== undefined ? ` at (${lat}, ${lon})` : ''}`,
524
+ `**Units found**: ${units.length}\n`,
525
+ ];
526
+ // Show up to 20 units
527
+ const shown = units.slice(0, 20);
528
+ for (const u of shown) {
529
+ const name = u.unit_name || u.strat_name_long || 'Unnamed unit';
530
+ lines.push(`## ${name}`);
531
+ if (u.Mbr)
532
+ lines.push(`- **Member**: ${u.Mbr}`);
533
+ if (u.Fm)
534
+ lines.push(`- **Formation**: ${u.Fm}`);
535
+ if (u.Gp)
536
+ lines.push(`- **Group**: ${u.Gp}`);
537
+ if (u.SGp)
538
+ lines.push(`- **Supergroup**: ${u.SGp}`);
539
+ if (u.lith)
540
+ lines.push(`- **Lithology**: ${u.lith}`);
541
+ if (u.descrip)
542
+ lines.push(`- **Description**: ${u.descrip}`);
543
+ if (u.environ)
544
+ lines.push(`- **Environment**: ${u.environ}`);
545
+ const t_age = u.t_age;
546
+ const b_age = u.b_age;
547
+ if (t_age !== undefined && b_age !== undefined) {
548
+ lines.push(`- **Age**: ${b_age} — ${t_age} Ma`);
549
+ }
550
+ if (u.t_int_name)
551
+ lines.push(`- **Top interval**: ${u.t_int_name}`);
552
+ if (u.b_int_name)
553
+ lines.push(`- **Base interval**: ${u.b_int_name}`);
554
+ const minThick = u.min_thick;
555
+ const maxThick = u.max_thick;
556
+ if (minThick !== undefined && maxThick !== undefined) {
557
+ lines.push(`- **Thickness**: ${minThick} — ${maxThick} m`);
558
+ }
559
+ if (u.pbdb_collections)
560
+ lines.push(`- **Fossil collections**: ${u.pbdb_collections}`);
561
+ if (u.clat && u.clng)
562
+ lines.push(`- **Centroid**: (${u.clat}, ${u.clng})`);
563
+ lines.push('');
564
+ }
565
+ if (units.length > 20) {
566
+ lines.push(`*...and ${units.length - 20} more units.*`);
567
+ }
568
+ return lines.join('\n');
569
+ }
570
+ catch (err) {
571
+ return `**Error**: Failed to query Macrostrat — ${err instanceof Error ? err.message : String(err)}`;
572
+ }
573
+ },
574
+ });
575
+ // ── 5. Ocean Data ───────────────────────────────────────────────────────
576
+ registerTool({
577
+ name: 'ocean_data',
578
+ description: 'Retrieve oceanographic data: sea surface temperature (SST), salinity, or chlorophyll concentrations from NOAA ERDDAP. Returns gridded data nearest to the specified coordinates.',
579
+ parameters: {
580
+ variable: { type: 'string', description: 'Variable: sst, salinity, or chlorophyll', required: true },
581
+ latitude: { type: 'number', description: 'Latitude', required: true },
582
+ longitude: { type: 'number', description: 'Longitude', required: true },
583
+ date: { type: 'string', description: 'Target date YYYY-MM-DD (default: most recent available)' },
584
+ },
585
+ tier: 'free',
586
+ async execute(args) {
587
+ const variable = String(args.variable).toLowerCase().trim();
588
+ const lat = args.latitude;
589
+ const lon = args.longitude;
590
+ const dateStr = typeof args.date === 'string' ? args.date : 'last';
591
+ // ERDDAP dataset IDs and variable names
592
+ const datasets = {
593
+ sst: { id: 'jplMURSST41', varName: 'analysed_sst', unit: 'K', fullName: 'Sea Surface Temperature' },
594
+ salinity: { id: 'hawaii_soest_a29a_6358_f498', varName: 'salt', unit: 'PSU', fullName: 'Sea Surface Salinity' },
595
+ chlorophyll: { id: 'erdMH1chla8day', varName: 'chlorophyll', unit: 'mg/m^3', fullName: 'Chlorophyll-a Concentration' },
596
+ };
597
+ const ds = datasets[variable];
598
+ if (!ds)
599
+ return `**Error**: Unknown variable "${variable}". Supported: sst, salinity, chlorophyll.`;
600
+ // ERDDAP griddap query — use nearest point and last time step
601
+ const timeConstraint = dateStr === 'last' ? 'last' : `(${dateStr}T12:00:00Z)`;
602
+ const baseUrl = 'https://coastwatch.pfeg.noaa.gov/erddap/griddap';
603
+ const url = `${baseUrl}/${ds.id}.json?${ds.varName}[${timeConstraint}][(${lat})][(${lon})]`;
604
+ try {
605
+ const res = await labFetch(url);
606
+ if (!res.ok) {
607
+ // Try with longitude shifted for datasets expecting 0-360
608
+ const lon360 = lon < 0 ? lon + 360 : lon;
609
+ const altUrl = `${baseUrl}/${ds.id}.json?${ds.varName}[${timeConstraint}][(${lat})][(${lon360})]`;
610
+ const altRes = await labFetch(altUrl);
611
+ if (!altRes.ok) {
612
+ return `**Error**: ERDDAP returned ${res.status} for ${ds.fullName}. The dataset may not cover location (${lat}, ${lon}) or the requested date. Try a different date or location.`;
613
+ }
614
+ const altData = await altRes.json();
615
+ return formatErddapResult(altData, ds, lat, lon);
616
+ }
617
+ const data = await res.json();
618
+ return formatErddapResult(data, ds, lat, lon);
619
+ }
620
+ catch (err) {
621
+ return `**Error**: Failed to query ERDDAP ocean data — ${err instanceof Error ? err.message : String(err)}`;
622
+ }
623
+ },
624
+ });
625
+ function formatErddapResult(data, ds, lat, lon) {
626
+ const table = data.table;
627
+ if (!table || !table.rows || table.rows.length === 0) {
628
+ return `**No data** available for ${ds.fullName} at (${lat}, ${lon}).`;
629
+ }
630
+ const colNames = table.columnNames || [];
631
+ const lines = [
632
+ `# ${ds.fullName}`,
633
+ `**Source**: NOAA ERDDAP (${ds.id})`,
634
+ `**Location**: (${lat}, ${lon})\n`,
635
+ ];
636
+ // Build a readable table
637
+ lines.push(`| ${colNames.join(' | ')} |`);
638
+ lines.push(`|${colNames.map(() => '---').join('|')}|`);
639
+ for (const row of table.rows) {
640
+ const cells = row.map((v, i) => {
641
+ if (typeof v === 'number') {
642
+ // Convert SST from Kelvin to Celsius for readability
643
+ if (ds.varName === 'analysed_sst' && colNames[i] === ds.varName) {
644
+ return `${(v - 273.15).toFixed(2)} C (${v.toFixed(2)} K)`;
645
+ }
646
+ return typeof v === 'number' && !Number.isInteger(v) ? v.toFixed(4) : String(v);
647
+ }
648
+ return String(v);
649
+ });
650
+ lines.push(`| ${cells.join(' | ')} |`);
651
+ }
652
+ return lines.join('\n');
653
+ }
654
+ // ── 6. Air Quality ──────────────────────────────────────────────────────
655
+ registerTool({
656
+ name: 'air_quality',
657
+ description: 'Get current air quality data: PM2.5, PM10, ozone (O3), nitrogen dioxide (NO2), sulfur dioxide (SO2), and carbon monoxide (CO). Uses OpenAQ network of monitoring stations worldwide.',
658
+ parameters: {
659
+ latitude: { type: 'number', description: 'Latitude for geographic search' },
660
+ longitude: { type: 'number', description: 'Longitude for geographic search' },
661
+ city: { type: 'string', description: 'City name (alternative to lat/lon)' },
662
+ },
663
+ tier: 'free',
664
+ async execute(args) {
665
+ const lat = typeof args.latitude === 'number' ? args.latitude : undefined;
666
+ const lon = typeof args.longitude === 'number' ? args.longitude : undefined;
667
+ const city = typeof args.city === 'string' ? args.city : undefined;
668
+ let url;
669
+ if (lat !== undefined && lon !== undefined) {
670
+ url = `https://api.openaq.org/v2/latest?coordinates=${lat},${lon}&radius=25000&limit=5`;
671
+ }
672
+ else if (city) {
673
+ url = `https://api.openaq.org/v2/latest?city=${encodeURIComponent(city)}&limit=5`;
674
+ }
675
+ else {
676
+ return '**Error**: Provide either latitude/longitude or a city name.';
677
+ }
678
+ try {
679
+ const res = await labFetch(url);
680
+ if (!res.ok)
681
+ return `**Error**: OpenAQ API returned ${res.status} ${res.statusText}`;
682
+ const data = await res.json();
683
+ const results = data.results || [];
684
+ if (results.length === 0) {
685
+ return `**No air quality stations found** near ${city || `(${lat}, ${lon})`}. OpenAQ coverage varies — try a nearby city or broader coordinates.`;
686
+ }
687
+ const lines = [
688
+ '# Air Quality Data',
689
+ `**Source**: OpenAQ`,
690
+ `**Search**: ${city || `(${lat}, ${lon}), radius 25 km`}`,
691
+ `**Stations found**: ${results.length}\n`,
692
+ ];
693
+ // AQI breakpoints for PM2.5 (EPA standard)
694
+ function pm25Aqi(v) {
695
+ if (v <= 12)
696
+ return 'Good';
697
+ if (v <= 35.4)
698
+ return 'Moderate';
699
+ if (v <= 55.4)
700
+ return 'Unhealthy for Sensitive Groups';
701
+ if (v <= 150.4)
702
+ return 'Unhealthy';
703
+ if (v <= 250.4)
704
+ return 'Very Unhealthy';
705
+ return 'Hazardous';
706
+ }
707
+ for (const station of results) {
708
+ lines.push(`## ${station.location}`, `**City**: ${station.city || 'N/A'} | **Country**: ${station.country || 'N/A'}`, `**Coordinates**: (${station.coordinates.latitude.toFixed(3)}, ${station.coordinates.longitude.toFixed(3)})\n`, '| Parameter | Value | Unit | Last Updated |', '|-----------|-------|------|-------------|');
709
+ for (const m of station.measurements) {
710
+ const name = m.parameter.toUpperCase();
711
+ const updated = m.lastUpdated ? m.lastUpdated.replace('T', ' ').replace(/\.\d+.*/, '') : '?';
712
+ let extra = '';
713
+ if (m.parameter === 'pm25' && m.unit === 'µg/m³') {
714
+ extra = ` (${pm25Aqi(m.value)})`;
715
+ }
716
+ lines.push(`| ${name} | ${m.value}${extra} | ${m.unit} | ${updated} |`);
717
+ }
718
+ lines.push('');
719
+ }
720
+ // WHO guideline comparison
721
+ lines.push('### WHO Air Quality Guidelines (2021)', '| Pollutant | Annual Mean | 24-hour Mean |', '|-----------|------------|-------------|', '| PM2.5 | 5 ug/m3 | 15 ug/m3 |', '| PM10 | 15 ug/m3 | 45 ug/m3 |', '| O3 | - | 100 ug/m3 (8-hr) |', '| NO2 | 10 ug/m3 | 25 ug/m3 |', '| SO2 | - | 40 ug/m3 |', '| CO | - | 4 mg/m3 |');
722
+ return lines.join('\n');
723
+ }
724
+ catch (err) {
725
+ return `**Error**: Failed to query OpenAQ — ${err instanceof Error ? err.message : String(err)}`;
726
+ }
727
+ },
728
+ });
729
+ // ── 7. Soil Data ────────────────────────────────────────────────────────
730
+ registerTool({
731
+ name: 'soil_data',
732
+ description: 'Retrieve soil properties by location from SoilGrids (ISRIC): pH, soil organic carbon, bulk density, clay/sand/silt fractions. Returns predicted values at 0-5cm depth.',
733
+ parameters: {
734
+ latitude: { type: 'number', description: 'Latitude', required: true },
735
+ longitude: { type: 'number', description: 'Longitude', required: true },
736
+ },
737
+ tier: 'free',
738
+ async execute(args) {
739
+ const lat = args.latitude;
740
+ const lon = args.longitude;
741
+ const url = `https://rest.isric.org/soilgrids/v2.0/properties/query?lon=${lon}&lat=${lat}&property=phh2o&property=soc&property=bdod&property=clay&property=sand&property=silt&depth=0-5cm&value=mean`;
742
+ try {
743
+ const res = await labFetch(url);
744
+ if (!res.ok)
745
+ return `**Error**: SoilGrids API returned ${res.status}. Location (${lat}, ${lon}) may be outside coverage (oceans, ice, etc.).`;
746
+ const data = await res.json();
747
+ const layers = data.properties?.layers || [];
748
+ if (layers.length === 0) {
749
+ return `**No soil data** available for (${lat}, ${lon}). The location may be ocean, ice, or outside SoilGrids coverage.`;
750
+ }
751
+ const lines = [
752
+ '# Soil Properties',
753
+ `**Source**: ISRIC SoilGrids v2.0 (250m resolution)`,
754
+ `**Location**: (${lat}, ${lon})`,
755
+ `**Depth**: 0-5 cm\n`,
756
+ '| Property | Value | Unit | Description |',
757
+ '|----------|-------|------|-------------|',
758
+ ];
759
+ const descriptions = {
760
+ phh2o: 'Soil pH in H2O solution',
761
+ soc: 'Soil organic carbon content',
762
+ bdod: 'Bulk density of fine earth fraction',
763
+ clay: 'Clay content (< 2 um)',
764
+ sand: 'Sand content (50-2000 um)',
765
+ silt: 'Silt content (2-50 um)',
766
+ };
767
+ const scaleFactors = {
768
+ phh2o: 10, // stored as pH*10
769
+ soc: 10, // stored as dg/kg (need g/kg)
770
+ bdod: 100, // stored as cg/cm3 (need g/cm3 => kg/m3)
771
+ clay: 10, // stored as g/kg
772
+ sand: 10, // stored as g/kg
773
+ silt: 10, // stored as g/kg
774
+ };
775
+ const units = {
776
+ phh2o: 'pH units',
777
+ soc: 'g/kg',
778
+ bdod: 'cg/cm3',
779
+ clay: 'g/kg',
780
+ sand: 'g/kg',
781
+ silt: 'g/kg',
782
+ };
783
+ for (const layer of layers) {
784
+ const name = layer.name;
785
+ const depth = layer.depths?.[0];
786
+ if (!depth || depth.values?.mean === undefined || depth.values.mean === null)
787
+ continue;
788
+ const rawValue = depth.values.mean;
789
+ const factor = scaleFactors[name] || 1;
790
+ const displayValue = rawValue / factor;
791
+ const unit = layer.unit_measure?.target_units || units[name] || layer.unit_measure?.mapped_units || '';
792
+ const desc = descriptions[name] || name;
793
+ lines.push(`| **${name.toUpperCase()}** | ${displayValue.toFixed(2)} | ${unit} | ${desc} |`);
794
+ }
795
+ // Soil texture classification
796
+ let clay = 0, sand = 0, silt = 0;
797
+ for (const layer of layers) {
798
+ const val = layer.depths?.[0]?.values?.mean;
799
+ if (val === undefined || val === null)
800
+ continue;
801
+ if (layer.name === 'clay')
802
+ clay = val / 10; // g/kg to %
803
+ if (layer.name === 'sand')
804
+ sand = val / 10;
805
+ if (layer.name === 'silt')
806
+ silt = val / 10;
807
+ }
808
+ if (clay + sand + silt > 0) {
809
+ // Convert g/kg to percentage
810
+ const total = clay + sand + silt;
811
+ const clayPct = (clay / total) * 100;
812
+ const sandPct = (sand / total) * 100;
813
+ const siltPct = (silt / total) * 100;
814
+ let textureClass = 'Unknown';
815
+ // USDA soil texture triangle (simplified)
816
+ if (clayPct >= 40) {
817
+ if (sandPct >= 45)
818
+ textureClass = 'Sandy Clay';
819
+ else if (siltPct >= 40)
820
+ textureClass = 'Silty Clay';
821
+ else
822
+ textureClass = 'Clay';
823
+ }
824
+ else if (clayPct >= 27) {
825
+ if (sandPct >= 20 && sandPct <= 45)
826
+ textureClass = 'Clay Loam';
827
+ else if (sandPct < 20)
828
+ textureClass = 'Silty Clay Loam';
829
+ else
830
+ textureClass = 'Sandy Clay Loam';
831
+ }
832
+ else if (siltPct >= 50) {
833
+ if (clayPct < 12)
834
+ textureClass = 'Silt';
835
+ else
836
+ textureClass = 'Silt Loam';
837
+ }
838
+ else if (sandPct >= 85) {
839
+ textureClass = 'Sand';
840
+ }
841
+ else if (sandPct >= 70) {
842
+ textureClass = 'Loamy Sand';
843
+ }
844
+ else if (sandPct >= 52) {
845
+ textureClass = 'Sandy Loam';
846
+ }
847
+ else {
848
+ textureClass = 'Loam';
849
+ }
850
+ lines.push('', '### Soil Texture', `- **Clay**: ${clayPct.toFixed(1)}%`, `- **Sand**: ${sandPct.toFixed(1)}%`, `- **Silt**: ${siltPct.toFixed(1)}%`, `- **USDA Classification**: ${textureClass}`);
851
+ }
852
+ return lines.join('\n');
853
+ }
854
+ catch (err) {
855
+ return `**Error**: Failed to query SoilGrids — ${err instanceof Error ? err.message : String(err)}`;
856
+ }
857
+ },
858
+ });
859
+ // ── 8. Volcano Monitor ──────────────────────────────────────────────────
860
+ registerTool({
861
+ name: 'volcano_monitor',
862
+ description: 'Get global volcano activity data: search by volcano name or region. Uses embedded data for ~50 major active volcanoes from Smithsonian GVP with name, country, region, elevation, type, last eruption, and status.',
863
+ parameters: {
864
+ volcano: { type: 'string', description: 'Volcano name to search for (fuzzy match)' },
865
+ region: { type: 'string', description: 'Region to filter by (e.g. Hawaii, Kamchatka, Java, Cascades)' },
866
+ },
867
+ tier: 'free',
868
+ async execute(args) {
869
+ const volcanoQuery = typeof args.volcano === 'string' ? args.volcano.toLowerCase().trim() : '';
870
+ const regionQuery = typeof args.region === 'string' ? args.region.toLowerCase().trim() : '';
871
+ let filtered = MAJOR_VOLCANOES;
872
+ if (volcanoQuery) {
873
+ filtered = filtered.filter(v => v.name.toLowerCase().includes(volcanoQuery) ||
874
+ v.country.toLowerCase().includes(volcanoQuery));
875
+ }
876
+ if (regionQuery) {
877
+ filtered = filtered.filter(v => v.region.toLowerCase().includes(regionQuery) ||
878
+ v.country.toLowerCase().includes(regionQuery));
879
+ }
880
+ if (filtered.length === 0) {
881
+ // Show available regions as a hint
882
+ const regions = [...new Set(MAJOR_VOLCANOES.map(v => v.region))].sort();
883
+ return `**No volcanoes found** matching "${volcanoQuery || regionQuery}".\n\n**Available regions**: ${regions.join(', ')}\n\n**Tip**: Try a partial name like "etna", "kil", or a region like "kamchatka", "java".`;
884
+ }
885
+ const lines = [
886
+ '# Volcano Monitor',
887
+ `**Source**: Smithsonian GVP (Global Volcanism Program)`,
888
+ `**Results**: ${filtered.length} volcano(es)\n`,
889
+ '| Volcano | Country | Region | Elev (m) | Type | Last Eruption | Status |',
890
+ '|---------|---------|--------|----------|------|---------------|--------|',
891
+ ];
892
+ for (const v of filtered) {
893
+ lines.push(`| ${v.name} | ${v.country} | ${v.region} | ${v.elevation_m} | ${v.type} | ${v.last_eruption} | ${v.status} |`);
894
+ }
895
+ // Also try to fetch live data from USGS VONA (Volcano Observatory Notices)
896
+ try {
897
+ const usgsUrl = 'https://volcanoes.usgs.gov/api/1/observatoryNotices?limit=5';
898
+ const res = await labFetch(usgsUrl);
899
+ if (res.ok) {
900
+ const notices = await res.json();
901
+ const items = notices.data || [];
902
+ if (items.length > 0) {
903
+ lines.push('', '### Recent USGS Volcano Notices');
904
+ for (const n of items.slice(0, 5)) {
905
+ const dateStr = n.issued ? new Date(n.issued).toISOString().split('T')[0] : '?';
906
+ const alert = n.alert_level ? ` [${n.alert_level.toUpperCase()}]` : '';
907
+ const color = n.color_code ? ` (${n.color_code})` : '';
908
+ lines.push(`- **${n.volcano_name || n.title}**${alert}${color} — ${dateStr}`);
909
+ }
910
+ }
911
+ }
912
+ }
913
+ catch {
914
+ // Silently skip live notices if unavailable
915
+ }
916
+ // Stats
917
+ const activeCount = filtered.filter(v => v.status === 'Active').length;
918
+ const dormantCount = filtered.filter(v => v.status === 'Dormant').length;
919
+ const countries = [...new Set(filtered.map(v => v.country))];
920
+ const avgElev = filtered.reduce((s, v) => s + v.elevation_m, 0) / filtered.length;
921
+ lines.push('', '### Summary', `- **Active**: ${activeCount} | **Dormant**: ${dormantCount}`, `- **Countries**: ${countries.join(', ')}`, `- **Avg elevation**: ${avgElev.toFixed(0)} m`, `- **Highest**: ${filtered.reduce((max, v) => v.elevation_m > max.elevation_m ? v : max).name} (${Math.max(...filtered.map(v => v.elevation_m))} m)`);
922
+ return lines.join('\n');
923
+ },
924
+ });
925
+ // ── 9. Water Resources ──────────────────────────────────────────────────
926
+ registerTool({
927
+ name: 'water_resources',
928
+ description: 'Retrieve USGS water data: real-time streamflow, groundwater levels, and water temperature at monitoring sites. Search by state or specific site ID.',
929
+ parameters: {
930
+ site_id: { type: 'string', description: 'USGS site number (e.g. "09380000" for Colorado River at Lees Ferry)' },
931
+ parameter: { type: 'string', description: 'Parameter: streamflow, groundwater, or temperature', required: true },
932
+ state: { type: 'string', description: 'Two-letter state code (e.g. "AZ", "CO") for state-wide search' },
933
+ days: { type: 'number', description: 'Number of days of data (default: 7, max: 30)' },
934
+ },
935
+ tier: 'free',
936
+ async execute(args) {
937
+ const parameter = String(args.parameter).toLowerCase().trim();
938
+ const siteId = typeof args.site_id === 'string' ? args.site_id.trim() : undefined;
939
+ const state = typeof args.state === 'string' ? args.state.toUpperCase().trim() : undefined;
940
+ const days = typeof args.days === 'number' ? Math.min(args.days, 30) : 7;
941
+ // USGS parameter codes
942
+ const paramCodes = {
943
+ streamflow: { code: '00060', name: 'Discharge', unit: 'cfs', siteType: 'ST' },
944
+ groundwater: { code: '72019', name: 'Depth to water level', unit: 'ft below surface', siteType: 'GW' },
945
+ temperature: { code: '00010', name: 'Water Temperature', unit: 'deg C', siteType: 'ST' },
946
+ };
947
+ const pc = paramCodes[parameter];
948
+ if (!pc)
949
+ return `**Error**: Unknown parameter "${parameter}". Supported: streamflow, groundwater, temperature.`;
950
+ let url;
951
+ if (siteId) {
952
+ url = `https://waterservices.usgs.gov/nwis/iv/?format=json&sites=${siteId}&parameterCd=${pc.code}&period=P${days}D`;
953
+ }
954
+ else if (state) {
955
+ url = `https://waterservices.usgs.gov/nwis/iv/?format=json&stateCd=${state}&parameterCd=${pc.code}&period=P${days}D&siteType=${pc.siteType}&siteStatus=active`;
956
+ }
957
+ else {
958
+ return '**Error**: Provide either a site_id or state code.';
959
+ }
960
+ try {
961
+ const res = await labFetch(url);
962
+ if (!res.ok)
963
+ return `**Error**: USGS Water Services returned ${res.status}`;
964
+ const data = await res.json();
965
+ const series = data.value?.timeSeries || [];
966
+ if (series.length === 0) {
967
+ return `**No water data** found for ${parameter}${state ? ` in ${state}` : ''}${siteId ? ` at site ${siteId}` : ''}. The parameter may not be monitored at these sites.`;
968
+ }
969
+ const lines = [
970
+ `# USGS Water Resources Data`,
971
+ `**Parameter**: ${pc.name} (${pc.code})`,
972
+ `**Period**: Last ${days} days`,
973
+ `**Sites**: ${series.length}\n`,
974
+ ];
975
+ // Limit to 10 sites for readability
976
+ const shown = series.slice(0, 10);
977
+ for (const ts of shown) {
978
+ const siteName = ts.sourceInfo.siteName || 'Unknown site';
979
+ const siteCode = ts.sourceInfo.siteCode?.[0]?.value || '?';
980
+ const geo = ts.sourceInfo.geoLocation?.geogLocation;
981
+ const unit = ts.variable?.unit?.unitCode || pc.unit;
982
+ const values = ts.values?.[0]?.value || [];
983
+ const numericValues = values
984
+ .map(v => parseFloat(v.value))
985
+ .filter(v => !isNaN(v) && v >= 0);
986
+ lines.push(`## ${siteName}`);
987
+ lines.push(`- **Site ID**: ${siteCode}`);
988
+ if (geo)
989
+ lines.push(`- **Location**: (${geo.latitude.toFixed(4)}, ${geo.longitude.toFixed(4)})`);
990
+ if (numericValues.length > 0) {
991
+ const current = numericValues[numericValues.length - 1];
992
+ const min = Math.min(...numericValues);
993
+ const max = Math.max(...numericValues);
994
+ const avg = numericValues.reduce((s, v) => s + v, 0) / numericValues.length;
995
+ lines.push(`- **Current**: ${current.toFixed(1)} ${unit}`, `- **Min**: ${min.toFixed(1)} ${unit}`, `- **Max**: ${max.toFixed(1)} ${unit}`, `- **Mean**: ${avg.toFixed(1)} ${unit}`, `- **Observations**: ${numericValues.length}`);
996
+ // Last 5 readings
997
+ const recent = values.slice(-5);
998
+ if (recent.length > 0) {
999
+ lines.push('', '**Recent readings**:', '');
1000
+ lines.push('| Time | Value | Qualifiers |');
1001
+ lines.push('|------|-------|------------|');
1002
+ for (const r of recent) {
1003
+ const time = r.dateTime.replace('T', ' ').replace(/\.000.*/, '');
1004
+ lines.push(`| ${time} | ${r.value} ${unit} | ${r.qualifiers?.join(', ') || '-'} |`);
1005
+ }
1006
+ }
1007
+ }
1008
+ else {
1009
+ lines.push('- *No valid readings in the requested period.*');
1010
+ }
1011
+ lines.push('');
1012
+ }
1013
+ if (series.length > 10) {
1014
+ lines.push(`*...and ${series.length - 10} more sites. Use a specific site_id for detailed data.*`);
1015
+ }
1016
+ return lines.join('\n');
1017
+ }
1018
+ catch (err) {
1019
+ return `**Error**: Failed to query USGS Water Services — ${err instanceof Error ? err.message : String(err)}`;
1020
+ }
1021
+ },
1022
+ });
1023
+ // ── 10. Biodiversity Index ──────────────────────────────────────────────
1024
+ registerTool({
1025
+ name: 'biodiversity_index',
1026
+ description: 'Calculate ecological diversity indices from species abundance data. Supports Shannon H\', Simpson D, Chao1, species richness, and Pielou evenness. All computation is local — no API calls needed.',
1027
+ parameters: {
1028
+ abundances: { type: 'string', description: 'Comma-separated species abundance counts (e.g. "10,20,30,5,1,1")', required: true },
1029
+ index_type: { type: 'string', description: 'Index: shannon, simpson, chao1, richness, evenness, or all (default: all)', required: true },
1030
+ },
1031
+ tier: 'free',
1032
+ async execute(args) {
1033
+ const raw = String(args.abundances);
1034
+ const counts = raw.split(',').map(s => s.trim()).filter(s => s !== '').map(Number).filter(n => !isNaN(n) && n >= 0);
1035
+ const indexType = String(args.index_type).toLowerCase().trim();
1036
+ if (counts.length === 0)
1037
+ return '**Error**: No valid abundance counts provided. Pass comma-separated integers (e.g. "10,20,30,5,1,1").';
1038
+ // Filter out zeros
1039
+ const abundances = counts.filter(n => n > 0);
1040
+ if (abundances.length === 0)
1041
+ return '**Error**: All abundance values are zero.';
1042
+ const N = abundances.reduce((s, v) => s + v, 0); // total individuals
1043
+ const S = abundances.length; // species richness (observed)
1044
+ const pi = abundances.map(n => n / N); // proportional abundances
1045
+ // Shannon diversity: H' = -sum(pi * ln(pi))
1046
+ const shannon = -pi.reduce((s, p) => s + (p > 0 ? p * Math.log(p) : 0), 0);
1047
+ // Simpson diversity: D = 1 - sum(pi^2)
1048
+ const simpsonD = 1 - pi.reduce((s, p) => s + p * p, 0);
1049
+ // Simpson's reciprocal: 1/D (where D = sum(pi^2))
1050
+ const simpsonReciprocal = 1 / pi.reduce((s, p) => s + p * p, 0);
1051
+ // Chao1 estimator: S_est = S_obs + (f1^2 / (2 * f2))
1052
+ const f1 = abundances.filter(n => n === 1).length; // singletons
1053
+ const f2 = abundances.filter(n => n === 2).length; // doubletons
1054
+ let chao1;
1055
+ if (f2 === 0) {
1056
+ // Bias-corrected Chao1 when f2 = 0
1057
+ chao1 = S + (f1 * (f1 - 1)) / 2;
1058
+ }
1059
+ else {
1060
+ chao1 = S + (f1 * f1) / (2 * f2);
1061
+ }
1062
+ // Pielou evenness: J = H' / ln(S)
1063
+ const pielou = S > 1 ? shannon / Math.log(S) : 1;
1064
+ // Maximum possible Shannon entropy
1065
+ const hMax = Math.log(S);
1066
+ // Effective number of species (Hill numbers)
1067
+ const hill0 = S; // q=0: richness
1068
+ const hill1 = Math.exp(shannon); // q=1: exp(H')
1069
+ const hill2 = simpsonReciprocal; // q=2: 1/sum(pi^2)
1070
+ // Berger-Parker dominance
1071
+ const maxAbundance = Math.max(...abundances);
1072
+ const bergerParker = maxAbundance / N;
1073
+ const validTypes = ['shannon', 'simpson', 'chao1', 'richness', 'evenness', 'all'];
1074
+ if (!validTypes.includes(indexType)) {
1075
+ return `**Error**: Unknown index_type "${indexType}". Supported: ${validTypes.join(', ')}.`;
1076
+ }
1077
+ const lines = [
1078
+ '# Biodiversity Indices',
1079
+ `**Input**: ${S} species, ${N} total individuals`,
1080
+ `**Abundances**: [${abundances.join(', ')}]\n`,
1081
+ ];
1082
+ const showAll = indexType === 'all';
1083
+ if (showAll || indexType === 'richness') {
1084
+ lines.push('## Species Richness', `- **Observed species (S)**: ${S}`, `- **Singletons (f1)**: ${f1}`, `- **Doubletons (f2)**: ${f2}`, `- **Total individuals (N)**: ${N}`, `- **Berger-Parker dominance**: ${bergerParker.toFixed(4)} (${(bergerParker * 100).toFixed(1)}%)`, '');
1085
+ }
1086
+ if (showAll || indexType === 'shannon') {
1087
+ lines.push("## Shannon Diversity (H')", `- **H'**: ${shannon.toFixed(4)}`, `- **H' max (ln S)**: ${hMax.toFixed(4)}`, `- **Effective species (exp H')**: ${hill1.toFixed(2)}`, `- **Formula**: H' = -sum(pi * ln(pi))`, '', '**Interpretation**: H\' typically ranges 1.5-3.5 for ecological communities.', shannon < 1.5 ? 'This community shows **low diversity**.' :
1088
+ shannon < 3.5 ? 'This community shows **moderate diversity**.' :
1089
+ 'This community shows **high diversity**.', '');
1090
+ }
1091
+ if (showAll || indexType === 'simpson') {
1092
+ lines.push('## Simpson Diversity', `- **Simpson D (1 - sum pi^2)**: ${simpsonD.toFixed(4)}`, `- **Simpson reciprocal (1/sum pi^2)**: ${simpsonReciprocal.toFixed(4)}`, `- **Formula**: D = 1 - sum(pi^2)`, '', '**Interpretation**: D ranges 0-1. Higher values indicate greater diversity.', simpsonD > 0.8 ? 'This community is **highly diverse** (D > 0.8).' :
1093
+ simpsonD > 0.5 ? 'This community has **moderate diversity** (0.5 < D < 0.8).' :
1094
+ 'This community has **low diversity** (D < 0.5).', '');
1095
+ }
1096
+ if (showAll || indexType === 'chao1') {
1097
+ lines.push('## Chao1 Richness Estimator', `- **Chao1 estimate**: ${chao1.toFixed(2)}`, `- **Observed species**: ${S}`, `- **Undetected species (est.)**: ${(chao1 - S).toFixed(2)}`, `- **Singletons (f1)**: ${f1}`, `- **Doubletons (f2)**: ${f2}`, f2 === 0
1098
+ ? `- **Formula** (bias-corrected): S + f1*(f1-1)/2`
1099
+ : `- **Formula**: S + f1^2 / (2*f2)`, '', `**Interpretation**: Chao1 estimates true species richness from incomplete sampling.`, chao1 > S * 1.5 ? `The estimate suggests **significant undersampling** — ~${((chao1 - S) / S * 100).toFixed(0)}% more species likely exist.` :
1100
+ chao1 > S * 1.1 ? `The estimate suggests **moderate undersampling** — ~${((chao1 - S) / S * 100).toFixed(0)}% more species likely exist.` :
1101
+ 'Sampling appears **relatively complete** — Chao1 is close to observed richness.', '');
1102
+ }
1103
+ if (showAll || indexType === 'evenness') {
1104
+ lines.push('## Pielou Evenness (J)', `- **J = H' / ln(S)**: ${pielou.toFixed(4)}`, `- **Formula**: J = H' / ln(S)`, '', '**Interpretation**: J ranges 0-1. Values near 1 indicate equal abundances across species.', pielou > 0.8 ? 'This community is **highly even** (J > 0.8).' :
1105
+ pielou > 0.5 ? 'This community has **moderate evenness** (0.5 < J < 0.8).' :
1106
+ 'This community is **highly uneven** (J < 0.5) — a few species dominate.', '');
1107
+ }
1108
+ if (showAll) {
1109
+ lines.push('## Hill Numbers (Effective Species)', '| Order (q) | Name | Value |', '|-----------|------|-------|', `| 0 | Species richness | ${hill0} |`, `| 1 | exp(Shannon H') | ${hill1.toFixed(2)} |`, `| 2 | Simpson reciprocal | ${hill2.toFixed(2)} |`, '', '**Note**: Hill numbers provide a unified framework for diversity. Higher q gives more weight to abundant species.');
1110
+ // Rank-abundance breakdown
1111
+ const sorted = [...abundances].sort((a, b) => b - a);
1112
+ lines.push('', '## Rank-Abundance Distribution', '| Rank | Abundance | Proportion | Cumulative % |', '|------|-----------|------------|-------------|');
1113
+ let cumulative = 0;
1114
+ for (let i = 0; i < sorted.length; i++) {
1115
+ const prop = sorted[i] / N;
1116
+ cumulative += prop;
1117
+ lines.push(`| ${i + 1} | ${sorted[i]} | ${(prop * 100).toFixed(1)}% | ${(cumulative * 100).toFixed(1)}% |`);
1118
+ }
1119
+ }
1120
+ return lines.join('\n');
1121
+ },
1122
+ });
1123
+ }
1124
+ //# sourceMappingURL=lab-earth.js.map