@chatpanel/events 0.33.0 → 0.46.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.
- package/backup-envelope.js +221 -0
- package/curate.js +509 -0
- package/distance.js +124 -0
- package/entity.js +354 -0
- package/flowchart.js +350 -97
- package/index.js +67 -0
- package/knowledge-derive.js +267 -0
- package/knowledge.js +221 -0
- package/library.js +265 -0
- package/omni.js +125 -0
- package/package.json +43 -11
- package/promotion.js +171 -0
- package/redaction-tokens.js +61 -0
- package/ref.js +4 -1
- package/subject-kinds.js +5 -0
- package/subject-name.js +96 -0
- package/sync-plan.js +170 -0
- package/synthesis.js +123 -0
- package/theme.js +155 -0
- package/voice-intents.js +5 -23
- package/weather.js +211 -0
package/theme.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// THE PALETTE, AS DATA — so every client paints the same product.
|
|
2
|
+
//
|
|
3
|
+
// These values are not new. They are the extension's shipped dashboard stylesheet
|
|
4
|
+
// (`meetings.css`, which `notes.css` and `briefs.css` both build on), lifted out of CSS and
|
|
5
|
+
// into a form a non-CSS client can read. A SwiftUI or Compose client cannot import a
|
|
6
|
+
// stylesheet, and a desktop app that re-types the hexes will drift the first time one is
|
|
7
|
+
// tuned — which is the same failure mode as the record model, in a smaller coat.
|
|
8
|
+
//
|
|
9
|
+
// WHAT BELONGS HERE AND WHAT DOES NOT. Tokens are shared; layout is not. A color named
|
|
10
|
+
// `--risk` means the same thing in every surface, so it is a contract. The width of a rail,
|
|
11
|
+
// the height of a row and the shape of a shadow are rendering decisions each platform makes
|
|
12
|
+
// for itself, and pretending otherwise is how a Mac app ends up looking like a web page.
|
|
13
|
+
//
|
|
14
|
+
// TWO THEMES, ONE SET OF NAMES. Every token exists in both themes — a name defined in only
|
|
15
|
+
// one is what produces the invisible-text-in-light-mode bug. `TOKEN_NAMES` is the guard:
|
|
16
|
+
// a test asserts both themes carry exactly it.
|
|
17
|
+
|
|
18
|
+
export const THEMES = Object.freeze(['light', 'dark']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Semantic roles, and what each is FOR — the part a hex value cannot carry.
|
|
22
|
+
*
|
|
23
|
+
* `bg` is the window; `card` is a raised surface on it; `elev` is raised again (a popover);
|
|
24
|
+
* `field` is a recessed surface (an input, a well). Getting that ladder wrong is why some
|
|
25
|
+
* dark UIs look flat: the surfaces must step in one direction.
|
|
26
|
+
*/
|
|
27
|
+
export const TOKEN_ROLES = Object.freeze({
|
|
28
|
+
bg: 'the window ground',
|
|
29
|
+
card: 'a surface raised off the ground',
|
|
30
|
+
elev: 'a surface raised off a card — popovers, overlays',
|
|
31
|
+
field: 'a recessed surface — inputs, wells, meters',
|
|
32
|
+
border: 'hairline separators',
|
|
33
|
+
borderStrong: 'a border that must be seen, not merely felt',
|
|
34
|
+
text: 'primary reading colour',
|
|
35
|
+
muted: 'secondary text that is still content',
|
|
36
|
+
faint: 'labels, captions and metadata',
|
|
37
|
+
accent: 'the product colour — selection, links, the active mode',
|
|
38
|
+
accentWeak: 'accent as a background wash',
|
|
39
|
+
decision: 'something settled',
|
|
40
|
+
risk: 'something that can hurt',
|
|
41
|
+
question: 'something open',
|
|
42
|
+
person: 'a person subject',
|
|
43
|
+
topic: 'a topic subject',
|
|
44
|
+
tag: 'a tag subject',
|
|
45
|
+
title: 'a title subject',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const TOKEN_NAMES = Object.freeze(Object.keys(TOKEN_ROLES));
|
|
49
|
+
|
|
50
|
+
export const LIGHT = Object.freeze({
|
|
51
|
+
bg: '#f5f6f8',
|
|
52
|
+
card: '#ffffff',
|
|
53
|
+
elev: '#ffffff',
|
|
54
|
+
field: '#f7f8fa',
|
|
55
|
+
border: '#e4e7ec',
|
|
56
|
+
borderStrong: '#d3d8e0',
|
|
57
|
+
text: '#181b20',
|
|
58
|
+
muted: '#646b76',
|
|
59
|
+
faint: '#8a909b',
|
|
60
|
+
accent: '#5b5bf0',
|
|
61
|
+
accentWeak: 'rgba(91,91,240,.10)',
|
|
62
|
+
decision: '#15a34a',
|
|
63
|
+
risk: '#dc2626',
|
|
64
|
+
question: '#b45309',
|
|
65
|
+
person: '#7c4dff',
|
|
66
|
+
topic: '#0b84ff',
|
|
67
|
+
tag: '#17a673',
|
|
68
|
+
title: '#b7791f',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const DARK = Object.freeze({
|
|
72
|
+
bg: '#0e1014',
|
|
73
|
+
card: '#16191f',
|
|
74
|
+
elev: '#1b1f26',
|
|
75
|
+
field: '#1f242c',
|
|
76
|
+
border: '#272c34',
|
|
77
|
+
borderStrong: '#353c46',
|
|
78
|
+
text: '#e9ebee',
|
|
79
|
+
muted: '#9aa1ac',
|
|
80
|
+
faint: '#6b7280',
|
|
81
|
+
accent: '#818cf8',
|
|
82
|
+
accentWeak: 'rgba(129,140,248,.13)',
|
|
83
|
+
decision: '#34d399',
|
|
84
|
+
risk: '#f87171',
|
|
85
|
+
question: '#fbbf24',
|
|
86
|
+
person: '#7c4dff',
|
|
87
|
+
topic: '#0b84ff',
|
|
88
|
+
tag: '#17a673',
|
|
89
|
+
title: '#b7791f',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const PALETTES = Object.freeze({ light: LIGHT, dark: DARK });
|
|
93
|
+
|
|
94
|
+
/** Shape values that are the same in both themes, so they are stated once. */
|
|
95
|
+
export const SHAPE = Object.freeze({
|
|
96
|
+
radius: '13px',
|
|
97
|
+
radiusSm: '9px',
|
|
98
|
+
radiusXs: '5px',
|
|
99
|
+
mono: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
100
|
+
sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export function paletteFor(theme) {
|
|
104
|
+
return PALETTES[theme] || DARK;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** camelCase → the `--kebab-case` custom property the stylesheets already use. */
|
|
108
|
+
export function cssVarName(token) {
|
|
109
|
+
return `--${String(token).replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A palette as CSS custom-property declarations.
|
|
114
|
+
*
|
|
115
|
+
* Returns only the declarations — the caller wraps them in whatever selector its theming
|
|
116
|
+
* strategy needs (`:root`, `[data-theme="dark"]`, a media query). Deciding the selector here
|
|
117
|
+
* would force one strategy on every client.
|
|
118
|
+
*/
|
|
119
|
+
export function toCssVars(theme, { includeShape = false } = {}) {
|
|
120
|
+
const palette = paletteFor(theme);
|
|
121
|
+
const lines = TOKEN_NAMES.map((name) => ` ${cssVarName(name)}: ${palette[name]};`);
|
|
122
|
+
if (includeShape) {
|
|
123
|
+
for (const [k, v] of Object.entries(SHAPE)) lines.push(` ${cssVarName(k)}: ${v};`);
|
|
124
|
+
}
|
|
125
|
+
return lines.join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A complete, theme-aware stylesheet block for a web client.
|
|
130
|
+
*
|
|
131
|
+
* Three selectors, and all three are needed: bare `:root` is the light default, the media
|
|
132
|
+
* query serves the "system" setting that stamps no attribute, and `[data-theme]` lets an
|
|
133
|
+
* explicit choice win in BOTH directions. A client that ships only the media query cannot
|
|
134
|
+
* offer a theme switch that overrides the OS.
|
|
135
|
+
*/
|
|
136
|
+
export function themeStylesheet() {
|
|
137
|
+
return [
|
|
138
|
+
`:root {\n${toCssVars('light', { includeShape: true })}\n color-scheme: light;\n}`,
|
|
139
|
+
`@media (prefers-color-scheme: dark) {\n :root:not([data-theme="light"]) {\n${toCssVars('dark')}\n color-scheme: dark;\n }\n}`,
|
|
140
|
+
`:root[data-theme="dark"] {\n${toCssVars('dark')}\n color-scheme: dark;\n}`,
|
|
141
|
+
`:root[data-theme="light"] {\n${toCssVars('light')}\n color-scheme: light;\n}`,
|
|
142
|
+
].join('\n\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve the theme actually in force.
|
|
147
|
+
*
|
|
148
|
+
* `preference` is what the user chose — `'light'`, `'dark'` or `'system'`; `systemDark` is
|
|
149
|
+
* what the OS reports. Kept as a function because three surfaces need the same answer and
|
|
150
|
+
* two of them cannot read a media query.
|
|
151
|
+
*/
|
|
152
|
+
export function resolveTheme(preference, systemDark = false) {
|
|
153
|
+
if (preference === 'light' || preference === 'dark') return preference;
|
|
154
|
+
return systemDark ? 'dark' : 'light';
|
|
155
|
+
}
|
package/voice-intents.js
CHANGED
|
@@ -34,6 +34,11 @@ import {
|
|
|
34
34
|
// answer in this package — a second copy here would drift, and two features would disagree
|
|
35
35
|
// about the same caption on the same screen.
|
|
36
36
|
import { DANGLING_TAILS } from './schedule.js';
|
|
37
|
+
// The wake matcher's fuzzy compare. It lived here until the maintenance pass needed the
|
|
38
|
+
// same question answered; re-exported so every existing caller (and index.js) is unchanged.
|
|
39
|
+
import { editDistance } from './distance.js';
|
|
40
|
+
|
|
41
|
+
export { editDistance };
|
|
37
42
|
|
|
38
43
|
export class VoiceIntentError extends Error {
|
|
39
44
|
constructor(code, message) { super(message); this.name = 'VoiceIntentError'; this.code = code; }
|
|
@@ -56,29 +61,6 @@ const MAX_WAKE_TOKENS = 3;
|
|
|
56
61
|
// and a wake phrase longer than this is a sentence, not a wake phrase.
|
|
57
62
|
const WAKE_TOKEN_CEILING = 8;
|
|
58
63
|
|
|
59
|
-
// Bounded Levenshtein — returns early once the distance cannot come in under `max`, so a
|
|
60
|
-
// wake scan over a long transcript stays linear in practice.
|
|
61
|
-
export function editDistance(a, b, max = Infinity) {
|
|
62
|
-
if (a === b) return 0;
|
|
63
|
-
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
64
|
-
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
65
|
-
for (let i = 1; i <= a.length; i++) {
|
|
66
|
-
const cur = [i];
|
|
67
|
-
let best = i;
|
|
68
|
-
for (let j = 1; j <= b.length; j++) {
|
|
69
|
-
cur[j] = Math.min(
|
|
70
|
-
prev[j] + 1,
|
|
71
|
-
cur[j - 1] + 1,
|
|
72
|
-
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
73
|
-
);
|
|
74
|
-
if (cur[j] < best) best = cur[j];
|
|
75
|
-
}
|
|
76
|
-
if (best > max) return max + 1;
|
|
77
|
-
prev = cur;
|
|
78
|
-
}
|
|
79
|
-
return prev[b.length];
|
|
80
|
-
}
|
|
81
|
-
|
|
82
64
|
// Lowercase and blank out punctuation WITHOUT changing length, so every offset computed
|
|
83
65
|
// against the normalised copy still points at the same character of the original. The
|
|
84
66
|
// command text handed back to the user keeps its capitals and its apostrophes; matching
|
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
|
+
}
|