@chatpanel/events 0.32.0 → 0.33.1

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 (3) hide show
  1. package/index.js +4 -0
  2. package/package.json +3 -1
  3. package/weather.js +211 -0
package/index.js CHANGED
@@ -28,6 +28,10 @@ export { checkInvariants, INVARIANTS } from './invariants.js';
28
28
  export { createMemoryAdapter, createLogStore, createBlobStore } from './store.js';
29
29
  export { createRegistry, REGISTRY_STATES } from './registry.js';
30
30
  export { defineSearchEngine, reconcileEngines, attemptOrder, ENGINE_KINDS, SearchEngineError } from './search-engines.js';
31
+ export {
32
+ getWeather, weatherUrl, parseWeather, formatWeather, areaLabel, isAmbiguousLocation,
33
+ WEATHER_HOST, WEATHER_TIMEOUT_MS, WeatherError,
34
+ } from './weather.js';
31
35
  export { defineToolGroup, createToolGroupRegistry, ToolGroupError } from './tool-groups.js';
32
36
  export { toolNeedFor } from './tool-need.js';
33
37
  export { parseFlowchart, layoutFlowchart, renderFlowchartSvg } from './flowchart.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.32.0",
3
+ "version": "0.33.1",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts — typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -50,6 +50,7 @@
50
50
  "./flowchart.js": "./flowchart.js",
51
51
  "./rrf.js": "./rrf.js",
52
52
  "./view.js": "./view.js",
53
+ "./weather.js": "./weather.js",
53
54
  "./widget.js": "./widget.js",
54
55
  "./tags.js": "./tags.js",
55
56
  "./titles.js": "./titles.js",
@@ -108,6 +109,7 @@
108
109
  "vault.js",
109
110
  "view.js",
110
111
  "voice-intents.js",
112
+ "weather.js",
111
113
  "widget.js"
112
114
  ],
113
115
  "scripts": {
package/weather.js ADDED
@@ -0,0 +1,211 @@
1
+ // Weather — one deterministic source, asked first, with search as the fallback.
2
+ //
3
+ // There was no weather capability at all, so every "how is the weather in X" went to
4
+ // web_search and the model assembled an answer from whatever three sites it happened to
5
+ // scrape. That is slow (several page fetches for one number), inconsistent between asks, and
6
+ // it failed in the way scraping fails: a repeated search got refused by the loop guard, the
7
+ // reworded retry returned nothing, and the model announced it had no access to weather.
8
+ //
9
+ // wttr.in answers the whole question in ONE request as JSON, which is why it is the default.
10
+ // It is not authoritative and it is not always up, so the tool falls back to search rather
11
+ // than pretending — but the common case costs one round trip and reads the same every time.
12
+ //
13
+ // THE GEOCODER IS THE KNOWN WEAKNESS, and it is handled by disclosure rather than cleverness.
14
+ // "Issaquah" once resolved to somewhere obscure and the answer looked plausible and was for
15
+ // the wrong place. wttr.in reports what it actually resolved to, so `formatWeather` always
16
+ // prints that line: a wrong place is then visible instead of silent. Guessing a region on the
17
+ // user's behalf would replace a visible error with an invisible one.
18
+ //
19
+ // Pure and platform-free, like every other module here: the fetch is INJECTED, so the
20
+ // extension passes its SSRF-guarded secureFetch, the gateway passes its own, and neither has
21
+ // a second copy of the parsing.
22
+
23
+ export const WEATHER_HOST = 'https://wttr.in';
24
+
25
+ /** Long enough for a cold CDN edge, short enough that a hung host still falls back. */
26
+ export const WEATHER_TIMEOUT_MS = 7000;
27
+
28
+ export class WeatherError extends Error {
29
+ constructor(code, message) { super(message); this.name = 'WeatherError'; this.code = code; }
30
+ }
31
+
32
+ /**
33
+ * The URL for one location.
34
+ *
35
+ * The location goes in the PATH, so it is percent-encoded whole — a bare `encodeURIComponent`
36
+ * of "Seattle, WA" gives `Seattle%2C%20WA`, which wttr.in reads correctly, while leaving the
37
+ * comma raw would let a location containing `/` or `?` reshape the request. Never interpolate
38
+ * an unencoded user string into a path.
39
+ */
40
+ export function weatherUrl(location, { host = WEATHER_HOST } = {}) {
41
+ const q = String(location ?? '').trim();
42
+ if (!q) throw new WeatherError('NO_LOCATION', 'a location is required');
43
+ if (q.length > 120) throw new WeatherError('BAD_LOCATION', 'location is too long to be one');
44
+ return `${host}/${encodeURIComponent(q)}?format=j1`;
45
+ }
46
+
47
+ const num = (v) => {
48
+ const n = Number(v);
49
+ return Number.isFinite(n) ? n : null;
50
+ };
51
+ const str = (v) => String(v ?? '').trim();
52
+ // wttr.in nests half its scalars as [{ value: "…" }], which is a shape, not data.
53
+ const firstValue = (v) => (Array.isArray(v) ? str(v[0]?.value) : str(v));
54
+
55
+ /**
56
+ * wttr.in's `format=j1` body → a shape that does not mention wttr.in.
57
+ *
58
+ * Returns null for anything unusable rather than throwing: a malformed body and an outage are
59
+ * the same event to the caller, and both mean "fall back to search".
60
+ */
61
+ export function parseWeather(json, { query = '' } = {}) {
62
+ if (!json || typeof json !== 'object') return null;
63
+ const cur = Array.isArray(json.current_condition) ? json.current_condition[0] : null;
64
+ if (!cur) return null;
65
+ const tempC = num(cur.temp_C);
66
+ const tempF = num(cur.temp_F);
67
+ // A reading with no temperature is not a weather reading. Guarded because an error page
68
+ // served as JSON still parses into an object, and half-empty output read as real data is
69
+ // worse than no data.
70
+ if (tempC === null && tempF === null) return null;
71
+
72
+ const areaRaw = Array.isArray(json.nearest_area) ? json.nearest_area[0] : null;
73
+ const area = areaRaw ? {
74
+ name: firstValue(areaRaw.areaName),
75
+ region: firstValue(areaRaw.region),
76
+ country: firstValue(areaRaw.country),
77
+ lat: num(areaRaw.latitude),
78
+ lon: num(areaRaw.longitude),
79
+ } : null;
80
+
81
+ const days = (Array.isArray(json.weather) ? json.weather : []).slice(0, 3).map((d) => ({
82
+ date: str(d.date),
83
+ maxC: num(d.maxtempC),
84
+ maxF: num(d.maxtempF),
85
+ minC: num(d.mintempC),
86
+ minF: num(d.mintempF),
87
+ sunrise: str(d.astronomy?.[0]?.sunrise),
88
+ sunset: str(d.astronomy?.[0]?.sunset),
89
+ // The midday slot is the day's headline condition; the array is 3-hourly.
90
+ condition: firstValue(d.hourly?.[4]?.weatherDesc) || firstValue(d.hourly?.[0]?.weatherDesc),
91
+ chanceOfRain: num(d.hourly?.[4]?.chanceofrain),
92
+ }));
93
+
94
+ return {
95
+ query: str(query),
96
+ area,
97
+ now: {
98
+ tempC,
99
+ tempF,
100
+ feelsLikeC: num(cur.FeelsLikeC),
101
+ feelsLikeF: num(cur.FeelsLikeF),
102
+ condition: firstValue(cur.weatherDesc),
103
+ humidity: num(cur.humidity),
104
+ windKph: num(cur.windspeedKmph),
105
+ windMph: num(cur.windspeedMiles),
106
+ windDir: str(cur.winddir16Point),
107
+ precipMm: num(cur.precipMM),
108
+ uv: num(cur.uvIndex),
109
+ observedAt: str(cur.localObsDateTime),
110
+ },
111
+ days,
112
+ };
113
+ }
114
+
115
+ /** "Seattle, Washington, United States" — whatever of it exists, deduped. */
116
+ export function areaLabel(area, fallback = '') {
117
+ if (!area) return fallback;
118
+ const parts = [area.name, area.region, area.country].map(str).filter(Boolean);
119
+ const seen = new Set();
120
+ const uniq = parts.filter((p) => {
121
+ const k = p.toLowerCase();
122
+ if (seen.has(k)) return false;
123
+ seen.add(k);
124
+ return true;
125
+ });
126
+ return uniq.join(', ') || fallback;
127
+ }
128
+
129
+ /**
130
+ * True when the user named a bare place with no region or country.
131
+ *
132
+ * Not used to REWRITE the query — that is how you turn a wrong answer into a confidently
133
+ * wrong one. It is used to add a line telling the model to say which place it got, so the
134
+ * user can correct it. Dozens of towns are called Fairview.
135
+ */
136
+ export function isAmbiguousLocation(location) {
137
+ const q = str(location);
138
+ if (!q) return false;
139
+ return !/[,]/.test(q) && q.split(/\s+/).length <= 2;
140
+ }
141
+
142
+ const c2f = (c) => Math.round((c * 9) / 5 + 32);
143
+ const temp = (c, f) => {
144
+ const cc = c === null && f !== null ? Math.round(((f - 32) * 5) / 9) : c;
145
+ const ff = f === null && c !== null ? c2f(c) : f;
146
+ if (cc === null && ff === null) return '';
147
+ if (cc === null) return `${ff}°F`;
148
+ if (ff === null) return `${cc}°C`;
149
+ return `${ff}°F / ${cc}°C`;
150
+ };
151
+
152
+ /** What the model reads. Compact — this is one fact, not a report. */
153
+ export function formatWeather(w) {
154
+ if (!w) return '';
155
+ const where = areaLabel(w.area, w.query);
156
+ const lines = [];
157
+ // FIRST LINE NAMES THE PLACE IT ACTUALLY RESOLVED. The geocoder picks obscure matches for
158
+ // bare town names, and an answer that does not say where it is from cannot be caught.
159
+ lines.push(`Weather for ${where}${w.query && where.toLowerCase() !== w.query.toLowerCase() ? ` (asked: "${w.query}")` : ''}`);
160
+ const n = w.now;
161
+ const now = [`Now: ${temp(n.tempC, n.tempF)}`];
162
+ if (n.feelsLikeC !== null || n.feelsLikeF !== null) now.push(`feels like ${temp(n.feelsLikeC, n.feelsLikeF)}`);
163
+ if (n.condition) now.push(n.condition.toLowerCase());
164
+ lines.push(now.join(', '));
165
+ const detail = [];
166
+ if (n.humidity !== null) detail.push(`humidity ${n.humidity}%`);
167
+ if (n.windMph !== null || n.windKph !== null) {
168
+ detail.push(`wind ${n.windMph !== null ? `${n.windMph} mph` : `${n.windKph} km/h`}${n.windDir ? ` ${n.windDir}` : ''}`);
169
+ }
170
+ if (n.precipMm !== null) detail.push(`precip ${n.precipMm} mm`);
171
+ if (n.uv !== null) detail.push(`UV ${n.uv}`);
172
+ if (detail.length) lines.push(detail.join(' · '));
173
+ for (const d of w.days) {
174
+ const range = `${temp(d.maxC, d.maxF)} high / ${temp(d.minC, d.minF)} low`;
175
+ const rain = d.chanceOfRain !== null ? `, ${d.chanceOfRain}% rain` : '';
176
+ lines.push(`${d.date}: ${range}${d.condition ? `, ${d.condition.toLowerCase()}` : ''}${rain}`);
177
+ }
178
+ if (n.observedAt) lines.push(`Observed ${n.observedAt} local. Source: wttr.in`);
179
+ if (isAmbiguousLocation(w.query)) {
180
+ lines.push('The place name was ambiguous — TELL THE USER which location this is for, and '
181
+ + 'offer to re-check with a state or country if it is the wrong one.');
182
+ }
183
+ return lines.join('\n');
184
+ }
185
+
186
+ /**
187
+ * Ask for one location's weather.
188
+ *
189
+ * @param fetchJson async (url, { timeoutMs }) -> parsed JSON. Injected, so the caller's
190
+ * SSRF-guarded fetch is the only one that runs and this module stays runnable in Node,
191
+ * a browser and a mobile runtime.
192
+ * @returns { ok: true, weather, text } | { ok: false, reason }
193
+ *
194
+ * NEVER THROWS. Every failure is a reason the caller can hand to the model as "use search
195
+ * instead", because an exception here would surface as a tool error and stop the turn on a
196
+ * question the fallback can still answer.
197
+ */
198
+ export async function getWeather(location, { fetchJson, timeoutMs = WEATHER_TIMEOUT_MS, host = WEATHER_HOST } = {}) {
199
+ let url;
200
+ try { url = weatherUrl(location, { host }); } catch (e) { return { ok: false, reason: e.message }; }
201
+ if (typeof fetchJson !== 'function') return { ok: false, reason: 'no fetch was provided' };
202
+ let json;
203
+ try {
204
+ json = await fetchJson(url, { timeoutMs });
205
+ } catch (e) {
206
+ return { ok: false, reason: e?.message || 'the weather service did not answer' };
207
+ }
208
+ const weather = parseWeather(json, { query: location });
209
+ if (!weather) return { ok: false, reason: 'the weather service returned nothing usable' };
210
+ return { ok: true, weather, text: formatWeather(weather) };
211
+ }