@voicethere/agent 0.5.3 → 0.5.5
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/dist/templates/game-sync/agent.js +11428 -191
- package/dist/templates/registry.d.ts.map +1 -1
- package/dist/templates/registry.js +21 -1
- package/dist/templates/registry.js.map +1 -1
- package/dist/templates/voice-showcase/agent.js +1397 -0
- package/package.json +11 -11
- package/templates/README.md +19 -11
- package/templates/game-sync-protocol.ts +65 -0
- package/templates/game-sync-redis.ts +121 -0
- package/templates/game-sync-sim.ts +122 -0
- package/templates/game-sync-world-layout.ts +194 -0
- package/templates/game-sync.ts +482 -300
- package/templates/voice-showcase/agent.ts +119 -0
- package/templates/voice-showcase/conversation.ts +531 -0
- package/templates/voice-showcase/fun-facts.ts +24 -0
- package/templates/voice-showcase/recipes.ts +57 -0
- package/templates/voice-showcase/weather.ts +356 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Hardcoded short recipes for the voice showcase. */
|
|
2
|
+
|
|
3
|
+
export interface Recipe {
|
|
4
|
+
title: string;
|
|
5
|
+
keywords: string[];
|
|
6
|
+
steps: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const RECIPES: readonly Recipe[] = [
|
|
10
|
+
{
|
|
11
|
+
title: "Quick garlic pasta",
|
|
12
|
+
keywords: ["pasta", "noodle", "spaghetti", "italian"],
|
|
13
|
+
steps:
|
|
14
|
+
"Boil pasta until al dente. Sauté minced garlic in olive oil, toss with pasta, parmesan, and black pepper. Serve hot.",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
title: "Simple vegetable soup",
|
|
18
|
+
keywords: ["soup", "broth", "stew"],
|
|
19
|
+
steps:
|
|
20
|
+
"Sauté onion and carrot in a pot. Add vegetable stock, diced potatoes, and simmer twenty minutes. Season with salt and herbs.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
title: "Easy breakfast scramble",
|
|
24
|
+
keywords: ["breakfast", "eggs", "morning", "brunch"],
|
|
25
|
+
steps:
|
|
26
|
+
"Whisk three eggs with a splash of milk. Cook in a buttered pan with spinach and cheese. Fold and serve with toast.",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
title: "Classic chocolate chip cookies",
|
|
30
|
+
keywords: ["cookie", "cookies", "dessert", "sweet", "bake"],
|
|
31
|
+
steps:
|
|
32
|
+
"Cream butter and sugar, mix in flour, egg, and chocolate chips. Drop spoonfuls on a tray and bake at one seventy five Celsius for ten minutes.",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
title: "Fresh garden salad",
|
|
36
|
+
keywords: ["salad", "greens", "vegetable", "healthy"],
|
|
37
|
+
steps:
|
|
38
|
+
"Toss mixed greens with cherry tomatoes, cucumber, and feta. Dress with olive oil, lemon juice, salt, and pepper.",
|
|
39
|
+
},
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
const DEFAULT_RECIPE = RECIPES[0]!;
|
|
43
|
+
|
|
44
|
+
/** Match a recipe by keywords in the utterance, or return the default. */
|
|
45
|
+
export function pickRecipe(utterance: string): Recipe {
|
|
46
|
+
const lower = utterance.toLowerCase();
|
|
47
|
+
for (const recipe of RECIPES) {
|
|
48
|
+
if (recipe.keywords.some((kw) => lower.includes(kw))) {
|
|
49
|
+
return recipe;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return DEFAULT_RECIPE;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatRecipeSpeech(recipe: Recipe): string {
|
|
56
|
+
return `${recipe.title}. ${recipe.steps}`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/** Open-Meteo geocoding + current weather (no API key). */
|
|
2
|
+
|
|
3
|
+
export const GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search";
|
|
4
|
+
export const FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
|
|
5
|
+
export const FETCH_TIMEOUT_MS = 8000;
|
|
6
|
+
|
|
7
|
+
export interface GeocodeResult {
|
|
8
|
+
name: string;
|
|
9
|
+
country: string;
|
|
10
|
+
latitude: number;
|
|
11
|
+
longitude: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WeatherResult {
|
|
15
|
+
place: string;
|
|
16
|
+
temperatureC: number;
|
|
17
|
+
condition: string;
|
|
18
|
+
windKmh: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type FetchFn = typeof fetch;
|
|
22
|
+
|
|
23
|
+
export type ParsedLocation = {
|
|
24
|
+
city?: string;
|
|
25
|
+
country?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DIGIT_WORDS: Record<string, string> = {
|
|
29
|
+
zero: "0",
|
|
30
|
+
oh: "0",
|
|
31
|
+
o: "0",
|
|
32
|
+
one: "1",
|
|
33
|
+
two: "2",
|
|
34
|
+
three: "3",
|
|
35
|
+
four: "4",
|
|
36
|
+
five: "5",
|
|
37
|
+
six: "6",
|
|
38
|
+
seven: "7",
|
|
39
|
+
eight: "8",
|
|
40
|
+
nine: "9",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Lowercase aliases → Open-Meteo-friendly country names. */
|
|
44
|
+
const COUNTRY_ALIASES: Record<string, string> = {
|
|
45
|
+
thailand: "Thailand",
|
|
46
|
+
us: "United States",
|
|
47
|
+
usa: "United States",
|
|
48
|
+
america: "United States",
|
|
49
|
+
"united states": "United States",
|
|
50
|
+
"united states of america": "United States",
|
|
51
|
+
uk: "United Kingdom",
|
|
52
|
+
britain: "United Kingdom",
|
|
53
|
+
england: "United Kingdom",
|
|
54
|
+
"united kingdom": "United Kingdom",
|
|
55
|
+
"great britain": "United Kingdom",
|
|
56
|
+
germany: "Germany",
|
|
57
|
+
france: "France",
|
|
58
|
+
spain: "Spain",
|
|
59
|
+
italy: "Italy",
|
|
60
|
+
japan: "Japan",
|
|
61
|
+
china: "China",
|
|
62
|
+
india: "India",
|
|
63
|
+
australia: "Australia",
|
|
64
|
+
canada: "Canada",
|
|
65
|
+
brazil: "Brazil",
|
|
66
|
+
mexico: "Mexico",
|
|
67
|
+
netherlands: "Netherlands",
|
|
68
|
+
holland: "Netherlands",
|
|
69
|
+
"the netherlands": "Netherlands",
|
|
70
|
+
belgium: "Belgium",
|
|
71
|
+
switzerland: "Switzerland",
|
|
72
|
+
sweden: "Sweden",
|
|
73
|
+
norway: "Norway",
|
|
74
|
+
denmark: "Denmark",
|
|
75
|
+
finland: "Finland",
|
|
76
|
+
poland: "Poland",
|
|
77
|
+
portugal: "Portugal",
|
|
78
|
+
greece: "Greece",
|
|
79
|
+
turkey: "Turkey",
|
|
80
|
+
egypt: "Egypt",
|
|
81
|
+
"south africa": "South Africa",
|
|
82
|
+
"new zealand": "New Zealand",
|
|
83
|
+
ireland: "Ireland",
|
|
84
|
+
singapore: "Singapore",
|
|
85
|
+
malaysia: "Malaysia",
|
|
86
|
+
indonesia: "Indonesia",
|
|
87
|
+
vietnam: "Vietnam",
|
|
88
|
+
philippines: "Philippines",
|
|
89
|
+
"south korea": "South Korea",
|
|
90
|
+
korea: "South Korea",
|
|
91
|
+
taiwan: "Taiwan",
|
|
92
|
+
"hong kong": "Hong Kong",
|
|
93
|
+
israel: "Israel",
|
|
94
|
+
uae: "United Arab Emirates",
|
|
95
|
+
"united arab emirates": "United Arab Emirates",
|
|
96
|
+
"saudi arabia": "Saudi Arabia",
|
|
97
|
+
pakistan: "Pakistan",
|
|
98
|
+
bangladesh: "Bangladesh",
|
|
99
|
+
nigeria: "Nigeria",
|
|
100
|
+
kenya: "Kenya",
|
|
101
|
+
argentina: "Argentina",
|
|
102
|
+
chile: "Chile",
|
|
103
|
+
colombia: "Colombia",
|
|
104
|
+
peru: "Peru",
|
|
105
|
+
austria: "Austria",
|
|
106
|
+
"czech republic": "Czechia",
|
|
107
|
+
czechia: "Czechia",
|
|
108
|
+
romania: "Romania",
|
|
109
|
+
hungary: "Hungary",
|
|
110
|
+
ukraine: "Ukraine",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function matchCountryName(text: string): string | null {
|
|
114
|
+
const key = text
|
|
115
|
+
.trim()
|
|
116
|
+
.toLowerCase()
|
|
117
|
+
.replace(/[.,!?]+$/g, "")
|
|
118
|
+
.replace(/\s+/g, " ");
|
|
119
|
+
if (!key) return null;
|
|
120
|
+
return COUNTRY_ALIASES[key] ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** "eight four three two zero" → "84320" (4–6 digits). */
|
|
124
|
+
export function spokenDigitsToPostal(text: string): string | null {
|
|
125
|
+
const tokens = text
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.split(/[^a-z0-9]+/)
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
const digits: string[] = [];
|
|
130
|
+
for (const token of tokens) {
|
|
131
|
+
if (/^\d$/.test(token)) {
|
|
132
|
+
digits.push(token);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const mapped = DIGIT_WORDS[token];
|
|
136
|
+
if (mapped) {
|
|
137
|
+
digits.push(mapped);
|
|
138
|
+
}
|
|
139
|
+
// Skip STT filler ("welcome", "down", "there", …).
|
|
140
|
+
}
|
|
141
|
+
if (digits.length >= 4 && digits.length <= 6) {
|
|
142
|
+
return digits.join("");
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function splitTrailingCountry(
|
|
148
|
+
text: string,
|
|
149
|
+
): { rest: string; country: string } | null {
|
|
150
|
+
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
151
|
+
if (words.length < 2) return null;
|
|
152
|
+
for (let n = Math.min(3, words.length - 1); n >= 1; n -= 1) {
|
|
153
|
+
const tail = words.slice(-n).join(" ");
|
|
154
|
+
const country = matchCountryName(tail);
|
|
155
|
+
if (country) {
|
|
156
|
+
return { rest: words.slice(0, -n).join(" "), country };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function cityFromRemainder(rest: string): string | null {
|
|
163
|
+
const trimmed = rest
|
|
164
|
+
.trim()
|
|
165
|
+
.replace(/[.,!?;:]+$/g, "")
|
|
166
|
+
.replace(/\s+(?:in the|in|of)$/i, "")
|
|
167
|
+
.trim();
|
|
168
|
+
if (!trimmed) return null;
|
|
169
|
+
if (/^\d{4,6}(-\d{4})?$/.test(trimmed)) {
|
|
170
|
+
return trimmed;
|
|
171
|
+
}
|
|
172
|
+
const spoken = spokenDigitsToPostal(trimmed);
|
|
173
|
+
if (spoken) return spoken;
|
|
174
|
+
if (trimmed.length >= 2 && trimmed.length <= 60) {
|
|
175
|
+
return trimmed;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Map WMO weather_code to a short English phrase. */
|
|
181
|
+
export function wmoCodeToPhrase(code: number): string {
|
|
182
|
+
if (code === 0) return "clear sky";
|
|
183
|
+
if (code <= 3) return "partly cloudy";
|
|
184
|
+
if (code <= 48) return "foggy";
|
|
185
|
+
if (code <= 57) return "drizzle";
|
|
186
|
+
if (code <= 67) return "rain";
|
|
187
|
+
if (code <= 77) return "snow";
|
|
188
|
+
if (code <= 82) return "rain showers";
|
|
189
|
+
if (code <= 86) return "snow showers";
|
|
190
|
+
if (code <= 99) return "thunderstorm";
|
|
191
|
+
return "variable conditions";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function formatWeatherSpeech(result: WeatherResult): string {
|
|
195
|
+
const temp = Math.round(result.temperatureC);
|
|
196
|
+
const wind = Math.round(result.windKmh);
|
|
197
|
+
return `In ${result.place}, it is ${temp} degrees Celsius with ${result.condition} and winds around ${wind} kilometers per hour.`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Parse city/zip and country from a single utterance when possible. */
|
|
201
|
+
export function parseLocationUtterance(
|
|
202
|
+
utterance: string,
|
|
203
|
+
): ParsedLocation | null {
|
|
204
|
+
const text = utterance.trim();
|
|
205
|
+
if (!text) return null;
|
|
206
|
+
|
|
207
|
+
const countryOnly = matchCountryName(text);
|
|
208
|
+
if (countryOnly) {
|
|
209
|
+
return { country: countryOnly };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const trailing = splitTrailingCountry(text);
|
|
213
|
+
if (trailing) {
|
|
214
|
+
const city = cityFromRemainder(trailing.rest);
|
|
215
|
+
if (city) {
|
|
216
|
+
return { city, country: trailing.country };
|
|
217
|
+
}
|
|
218
|
+
return { country: trailing.country };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const inMatch = text.match(
|
|
222
|
+
/^(?:in\s+)?(.+?)\s+in\s+([a-zA-Z][\w\s.-]{1,40})$/i,
|
|
223
|
+
);
|
|
224
|
+
if (inMatch) {
|
|
225
|
+
const country = matchCountryName(inMatch[2]!) ?? inMatch[2]!.trim();
|
|
226
|
+
return { city: inMatch[1]!.trim(), country };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const commaMatch = text.match(/^(.+?),\s*([a-zA-Z][\w\s.-]{1,40})$/);
|
|
230
|
+
if (commaMatch) {
|
|
231
|
+
const country = matchCountryName(commaMatch[2]!) ?? commaMatch[2]!.trim();
|
|
232
|
+
return { city: commaMatch[1]!.trim(), country };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const countryMatch = text.match(
|
|
236
|
+
/^(.+?)\s+(?:country\s+)?([a-zA-Z][\w\s.-]{2,40})$/i,
|
|
237
|
+
);
|
|
238
|
+
if (countryMatch && countryMatch[2]!.split(/\s+/).length <= 3) {
|
|
239
|
+
const city = countryMatch[1]!.trim();
|
|
240
|
+
const countryRaw = countryMatch[2]!.trim();
|
|
241
|
+
const country = matchCountryName(countryRaw) ?? countryRaw;
|
|
242
|
+
if (city.length >= 2 && country.length >= 2) {
|
|
243
|
+
return { city, country };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (/^\d{4,6}(-\d{4})?$/.test(text)) {
|
|
248
|
+
return { city: text };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const spokenPostal = spokenDigitsToPostal(text);
|
|
252
|
+
if (spokenPostal) {
|
|
253
|
+
return { city: spokenPostal };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (text.length >= 2 && text.length <= 60) {
|
|
257
|
+
return { city: text };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function geocodeLocation(
|
|
264
|
+
city: string,
|
|
265
|
+
country: string | undefined,
|
|
266
|
+
fetchFn: FetchFn = fetch,
|
|
267
|
+
): Promise<GeocodeResult | null> {
|
|
268
|
+
const query = country ? `${city}, ${country}` : city;
|
|
269
|
+
const url = new URL(GEOCODE_URL);
|
|
270
|
+
url.searchParams.set("name", query);
|
|
271
|
+
url.searchParams.set("count", "1");
|
|
272
|
+
url.searchParams.set("language", "en");
|
|
273
|
+
url.searchParams.set("format", "json");
|
|
274
|
+
|
|
275
|
+
const response = await fetchFn(url.toString(), {
|
|
276
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
277
|
+
});
|
|
278
|
+
if (!response.ok) return null;
|
|
279
|
+
|
|
280
|
+
const data = (await response.json()) as {
|
|
281
|
+
results?: Array<{
|
|
282
|
+
name?: string;
|
|
283
|
+
country?: string;
|
|
284
|
+
latitude?: number;
|
|
285
|
+
longitude?: number;
|
|
286
|
+
}>;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const hit = data.results?.[0];
|
|
290
|
+
if (
|
|
291
|
+
!hit ||
|
|
292
|
+
typeof hit.latitude !== "number" ||
|
|
293
|
+
typeof hit.longitude !== "number"
|
|
294
|
+
) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
name: hit.name ?? city,
|
|
300
|
+
country: hit.country ?? country ?? "",
|
|
301
|
+
latitude: hit.latitude,
|
|
302
|
+
longitude: hit.longitude,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function fetchCurrentWeather(
|
|
307
|
+
geo: GeocodeResult,
|
|
308
|
+
fetchFn: FetchFn = fetch,
|
|
309
|
+
): Promise<WeatherResult | null> {
|
|
310
|
+
const url = new URL(FORECAST_URL);
|
|
311
|
+
url.searchParams.set("latitude", String(geo.latitude));
|
|
312
|
+
url.searchParams.set("longitude", String(geo.longitude));
|
|
313
|
+
url.searchParams.set("current", "temperature_2m,weather_code,wind_speed_10m");
|
|
314
|
+
url.searchParams.set("wind_speed_unit", "kmh");
|
|
315
|
+
|
|
316
|
+
const response = await fetchFn(url.toString(), {
|
|
317
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
318
|
+
});
|
|
319
|
+
if (!response.ok) return null;
|
|
320
|
+
|
|
321
|
+
const data = (await response.json()) as {
|
|
322
|
+
current?: {
|
|
323
|
+
temperature_2m?: number;
|
|
324
|
+
weather_code?: number;
|
|
325
|
+
wind_speed_10m?: number;
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const current = data.current;
|
|
330
|
+
if (
|
|
331
|
+
!current ||
|
|
332
|
+
typeof current.temperature_2m !== "number" ||
|
|
333
|
+
typeof current.weather_code !== "number"
|
|
334
|
+
) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const place = geo.country ? `${geo.name}, ${geo.country}` : geo.name;
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
place,
|
|
342
|
+
temperatureC: current.temperature_2m,
|
|
343
|
+
condition: wmoCodeToPhrase(current.weather_code),
|
|
344
|
+
windKmh: current.wind_speed_10m ?? 0,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function lookupWeather(
|
|
349
|
+
city: string,
|
|
350
|
+
country: string | undefined,
|
|
351
|
+
fetchFn: FetchFn = fetch,
|
|
352
|
+
): Promise<WeatherResult | null> {
|
|
353
|
+
const geo = await geocodeLocation(city, country, fetchFn);
|
|
354
|
+
if (!geo) return null;
|
|
355
|
+
return fetchCurrentWeather(geo, fetchFn);
|
|
356
|
+
}
|