@triplef/agent 0.1.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.
@@ -0,0 +1,1637 @@
1
+ import { z } from 'zod';
2
+ import { tool } from 'ai';
3
+ import { Readability } from '@mozilla/readability';
4
+ import { parseHTML } from 'linkedom';
5
+ import TurndownService from 'turndown';
6
+
7
+ // src/tools/bright-data/bright-data.constants.ts
8
+ var SOURCE = "brightData";
9
+ function buildGoogleUrl(query, params) {
10
+ const search = new URLSearchParams();
11
+ search.set("q", query);
12
+ for (const [key, value] of Object.entries(params)) {
13
+ if (value === void 0 || value === null || value === "") continue;
14
+ search.set(key, String(value));
15
+ }
16
+ search.set("brd_json", "1");
17
+ return `https://www.google.com/search?${search.toString()}`;
18
+ }
19
+ function engineEnabled(deps, endpoint) {
20
+ const cfg = deps.getLiveConfig().brightData;
21
+ if (!cfg.enabled || !cfg.apiKey || !cfg.serpZone) return void 0;
22
+ const ep = cfg[endpoint];
23
+ if (typeof ep === "object" && ep !== null && "enabled" in ep && !ep.enabled) return void 0;
24
+ return cfg.apiKey;
25
+ }
26
+
27
+ // src/tools/constants/search-timeout.ts
28
+ var SEARCH_TIMEOUT_MS = 1e4;
29
+ var BRIGHT_DATA_TIMEOUT_MS = 3e4;
30
+ var SEARCH_RETRIES = 1;
31
+
32
+ // src/tools/helpers/fetch-with-timeout.ts
33
+ async function fetchWithTimeout(url, init, options = {}) {
34
+ const timeoutMs = options.timeoutMs ?? SEARCH_TIMEOUT_MS;
35
+ const retries = options.retries ?? SEARCH_RETRIES;
36
+ let lastError;
37
+ for (let attempt = 0; attempt <= retries; attempt++) {
38
+ try {
39
+ const res = await fetch(url, {
40
+ ...init,
41
+ signal: AbortSignal.timeout(timeoutMs)
42
+ });
43
+ return res;
44
+ } catch (err) {
45
+ lastError = err instanceof Error ? err : new Error(String(err));
46
+ const isTransient = lastError.name === "AbortError" || lastError.name === "TimeoutError" || lastError.message.toLowerCase().includes("the operation was aborted due to timeout") || lastError.message.toLowerCase().includes("fetch failed");
47
+ if (!isTransient) throw lastError;
48
+ }
49
+ }
50
+ throw lastError ?? new Error(`Failed to fetch ${url}`);
51
+ }
52
+
53
+ // src/tools/bright-data/bright-data-client.ts
54
+ var BRIGHT_DATA_ENDPOINT = "https://api.brightdata.com/request";
55
+ async function requestBrightData(apiKey, zone, url, opts) {
56
+ const res = await fetchWithTimeout(
57
+ BRIGHT_DATA_ENDPOINT,
58
+ {
59
+ method: "POST",
60
+ headers: {
61
+ Authorization: `Bearer ${apiKey}`,
62
+ "Content-Type": "application/json"
63
+ },
64
+ body: JSON.stringify({
65
+ zone,
66
+ url,
67
+ format: "raw",
68
+ ...opts.markdown ? { data_format: "markdown" } : {}
69
+ })
70
+ },
71
+ { timeoutMs: opts.timeoutMs }
72
+ );
73
+ if (!res.ok) throw new Error(`Bright Data returned HTTP ${res.status}`);
74
+ if (opts.markdown) return { text: await res.text() };
75
+ return res.json();
76
+ }
77
+
78
+ // src/tools/constants/recency.constants.ts
79
+ var RECENCY_DESCRIPTION = "Restrict results to the given past period (day=24 hours, week=7 days, month=1 month, year=1 year). Use for fresh content such as news, recent releases, or trending topics; leave unset for evergreen, historical, or general queries.";
80
+
81
+ // src/tools/constants/standalone-query.constants.ts
82
+ var STANDALONE_QUERY_DESCRIPTION = 'A standalone, self-contained search query that explicitly names the subject (title, entity, brand, person, place, or topic). Never copy the user message verbatim: rewrite short follow-ups (e.g. "what do the reviews say?") into a full query that names the established subject from the conversation (e.g. "Neverness to Everness NTE reviews").';
83
+ var STANDALONE_QUERY_TOOL_CLAUSE = "Always pass a standalone query that names the subject explicitly \u2014 never the user message verbatim.";
84
+
85
+ // src/tools/bright-data/image-search.schema.ts
86
+ var brightDataImageSearchSchema = z.object({
87
+ query: z.string().describe(`${STANDALONE_QUERY_DESCRIPTION} Add short visual qualifiers describing the subject.`),
88
+ count: z.number().optional().describe("Number of results (max 100)"),
89
+ minWidth: z.number().optional().describe("Minimum image width in pixels (floor 1280 / 720p)."),
90
+ minHeight: z.number().optional().describe("Minimum image height in pixels (floor 720 / 720p)."),
91
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)"),
92
+ recency: z.enum(["day", "week", "month", "year"]).optional().describe(RECENCY_DESCRIPTION)
93
+ });
94
+
95
+ // src/schemas/constants/blocked-image-hosts.ts
96
+ var BLOCKED_IMAGE_HOSTS = /* @__PURE__ */ new Set([
97
+ // Google thumbnail proxies (low-res, frequently 404 when hot-linked)
98
+ "encrypted-tbn0.gstatic.com",
99
+ "encrypted-tbn1.gstatic.com",
100
+ "encrypted-tbn2.gstatic.com",
101
+ "encrypted-tbn3.gstatic.com",
102
+ "t0.gstatic.com",
103
+ "t1.gstatic.com",
104
+ "t2.gstatic.com",
105
+ "t3.gstatic.com",
106
+ "t4.gstatic.com",
107
+ "t5.gstatic.com",
108
+ "t6.gstatic.com",
109
+ "t7.gstatic.com",
110
+ "t8.gstatic.com",
111
+ "t9.gstatic.com",
112
+ "t10.gstatic.com",
113
+ "news.gstatic.com",
114
+ "books.gstatic.com",
115
+ "maps.gstatic.com",
116
+ // Google user-content thumbnails
117
+ "lh1.googleusercontent.com",
118
+ "lh2.googleusercontent.com",
119
+ "lh3.googleusercontent.com",
120
+ "lh4.googleusercontent.com",
121
+ "lh5.googleusercontent.com",
122
+ "lh6.googleusercontent.com"
123
+ ]);
124
+
125
+ // src/schemas/helpers/url-trust/is-trusted-image-url.helper.ts
126
+ var TRUSTED_IMAGE_HOSTS = /* @__PURE__ */ new Set([
127
+ "i.imgur.com",
128
+ "i.redd.it",
129
+ "preview.redd.it",
130
+ "upload.wikimedia.org",
131
+ "commons.wikimedia.org",
132
+ "images.unsplash.com",
133
+ "images.pexels.com",
134
+ "cdn.pixabay.com",
135
+ "live.staticflickr.com",
136
+ "farm1.staticflickr.com",
137
+ "farm2.staticflickr.com",
138
+ "farm3.staticflickr.com",
139
+ "farm4.staticflickr.com",
140
+ "farm5.staticflickr.com",
141
+ "farm6.staticflickr.com",
142
+ "farm7.staticflickr.com",
143
+ "farm8.staticflickr.com",
144
+ "farm9.staticflickr.com",
145
+ "i.pinimg.com",
146
+ "media.istockphoto.com",
147
+ "assets.istockphoto.com",
148
+ "media.gettyimages.com",
149
+ "embed.gettyimages.com",
150
+ // Social image CDNs (images are generally embeddable, unlike videos)
151
+ "pbs.twimg.com",
152
+ "cdninstagram.com",
153
+ "scontent.cdninstagram.com",
154
+ "scontent-iad3-1.cdninstagram.com",
155
+ "graph.facebook.com",
156
+ "scontent-iad3-1.xx.fbcdn.net",
157
+ "scontent.xx.fbcdn.net",
158
+ "static.xx.fbcdn.net",
159
+ // Cloud/CDNs
160
+ "res.cloudinary.com",
161
+ "images.ctfassets.net",
162
+ "cdn.shopify.com",
163
+ "imgix.net",
164
+ "wpmedia.roomsketcher.com",
165
+ // Bing image CDN
166
+ "tse1.mm.bing.net",
167
+ "tse2.mm.bing.net",
168
+ "tse3.mm.bing.net",
169
+ "tse4.mm.bing.net"
170
+ ]);
171
+ var DIRECT_IMAGE_EXTENSION = /\.(jpg|jpeg|png|gif|webp|bmp|tiff|tif|avif|svg|ico)(\?.*)?$/i;
172
+ function isTrustedImageUrl(url) {
173
+ if (!url) return false;
174
+ if (url.startsWith("/") || url.startsWith("data:image/")) return true;
175
+ let parsed;
176
+ try {
177
+ parsed = new URL(url);
178
+ } catch {
179
+ return false;
180
+ }
181
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
182
+ const hostname = parsed.hostname.toLowerCase();
183
+ if (BLOCKED_IMAGE_HOSTS.has(hostname)) return false;
184
+ if (TRUSTED_IMAGE_HOSTS.has(hostname)) return true;
185
+ if (DIRECT_IMAGE_EXTENSION.test(parsed.pathname)) return true;
186
+ return false;
187
+ }
188
+
189
+ // src/tools/constants/image-search.constants.ts
190
+ var MIN_IMAGE_WIDTH = 1280;
191
+ var MIN_IMAGE_HEIGHT = 720;
192
+ function meetsMinimumImageDimensions(width, height, minWidth = MIN_IMAGE_WIDTH, minHeight = MIN_IMAGE_HEIGHT) {
193
+ if (!width || !height) return false;
194
+ return width >= minWidth && height >= minHeight;
195
+ }
196
+
197
+ // src/tools/helpers/apply-locale-params.helper.ts
198
+ function applyLocaleParams(body, lang) {
199
+ if (!lang) return;
200
+ body.hl = lang;
201
+ const country = resolveLikelyCountry(lang);
202
+ if (country) body.gl = country;
203
+ }
204
+ function resolveLikelyCountry(lang) {
205
+ try {
206
+ const region = new Intl.Locale(lang).maximize().region;
207
+ return region ? region.toLowerCase() : void 0;
208
+ } catch {
209
+ return void 0;
210
+ }
211
+ }
212
+
213
+ // src/tools/helpers/apply-recency-param.helper.ts
214
+ var QDR_BY_RECENCY = {
215
+ day: "qdr:d",
216
+ week: "qdr:w",
217
+ month: "qdr:m",
218
+ year: "qdr:y"
219
+ };
220
+ function applyRecencyParam(body, recency) {
221
+ if (!recency) return;
222
+ const qdr = QDR_BY_RECENCY[recency];
223
+ const existing = typeof body.tbs === "string" ? body.tbs : "";
224
+ body.tbs = existing ? `${existing},${qdr}` : qdr;
225
+ }
226
+
227
+ // src/tools/helpers/image-size-buckets.ts
228
+ var IMAGE_SIZE_BUCKETS = [
229
+ { mp: 0.12, label: "qsvga" },
230
+ // > 400×300
231
+ { mp: 0.307, label: "vga" },
232
+ // > 640×480
233
+ { mp: 0.48, label: "svga" },
234
+ // > 800×600
235
+ { mp: 0.786, label: "xga" },
236
+ // > 1024×768
237
+ { mp: 2, label: "2mp" },
238
+ // > 2 MP
239
+ { mp: 4, label: "4mp" },
240
+ // > 4 MP
241
+ { mp: 6, label: "6mp" },
242
+ // > 6 MP
243
+ { mp: 8, label: "8mp" },
244
+ // > 8 MP
245
+ { mp: 10, label: "10mp" },
246
+ // > 10 MP
247
+ { mp: 12, label: "12mp" },
248
+ // > 12 MP
249
+ { mp: 15, label: "15mp" },
250
+ // > 15 MP
251
+ { mp: 20, label: "20mp" },
252
+ // > 20 MP
253
+ { mp: 40, label: "40mp" },
254
+ // > 40 MP
255
+ { mp: 70, label: "70mp" }
256
+ // > 70 MP
257
+ ];
258
+ function tbsSizeLabelForPixels(pixels) {
259
+ const targetMp = pixels / 1e6;
260
+ const selected = IMAGE_SIZE_BUCKETS.findLast((bucket) => bucket.mp <= targetMp) ?? IMAGE_SIZE_BUCKETS[IMAGE_SIZE_BUCKETS.length - 1];
261
+ return selected.label;
262
+ }
263
+
264
+ // src/tools/bright-data/image-search.tool.ts
265
+ function createBrightDataImageSearch(deps) {
266
+ return tool({
267
+ description: "Search for images using Bright Data SERP API (Google Images). Returns image URLs and source pages. The tool passes the appropriate Google Images `tbs=isz:lt,islt:<bucket>` size filter server-side and trusts it for minimum resolution (Bright Data does not return pixel dimensions), while still rejecting untrusted domains such as Google thumbnail proxies (encrypted-tbn*.gstatic.com), data URIs, localhost, and private IPs. Pass minWidth/minHeight to request higher resolutions. " + STANDALONE_QUERY_TOOL_CLAUSE,
268
+ inputSchema: brightDataImageSearchSchema,
269
+ execute: async ({
270
+ query,
271
+ count: reqCount,
272
+ minWidth: requestedMinWidth,
273
+ minHeight: requestedMinHeight,
274
+ lang,
275
+ recency
276
+ }) => {
277
+ const cfg = deps.getLiveConfig().brightData;
278
+ const apiKey = engineEnabled(deps, "images");
279
+ if (!apiKey)
280
+ return {
281
+ results: [],
282
+ error: "Bright Data image search is not enabled"
283
+ };
284
+ const minWidth = Math.max(requestedMinWidth ?? 0, MIN_IMAGE_WIDTH);
285
+ const minHeight = Math.max(requestedMinHeight ?? 0, MIN_IMAGE_HEIGHT);
286
+ deps.logger.log(`Bright Data image search for "${query}" min ${minWidth}x${minHeight}`);
287
+ const body = {};
288
+ applyLocaleParams(body, lang);
289
+ applyRecencyParam(body, recency);
290
+ const tbs = `isz:lt,islt:${tbsSizeLabelForPixels(minWidth * minHeight)}`;
291
+ const url = buildGoogleUrl(query, {
292
+ udm: 2,
293
+ hl: body.hl,
294
+ gl: body.gl,
295
+ tbs: body.tbs ? `${body.tbs},${tbs}` : tbs,
296
+ num: reqCount ?? cfg.images.results
297
+ });
298
+ try {
299
+ const data = await requestBrightData(apiKey, cfg.serpZone, url, {
300
+ timeoutMs: BRIGHT_DATA_TIMEOUT_MS
301
+ });
302
+ const images = data.images ?? [];
303
+ if (!images.length) {
304
+ deps.logger.warn(`Bright Data image search returned 0 results for "${query}"`);
305
+ return { results: [] };
306
+ }
307
+ const results = images.map((r) => ({
308
+ title: r.title || "",
309
+ // Prefer the real image URL; `image` is a base64 thumbnail data
310
+ // URI that our trust rules reject.
311
+ imageUrl: r.original_image || r.image_url || r.imageUrl || r.link || "",
312
+ sourcePageUrl: r.source_link || r.link || "",
313
+ width: r.width,
314
+ height: r.height,
315
+ source: r.source || "",
316
+ domain: ""
317
+ })).filter((r) => {
318
+ if (!isTrustedImageUrl(r.imageUrl)) return false;
319
+ const w = r.width ?? 0;
320
+ const h = r.height ?? 0;
321
+ if (!w || !h) return true;
322
+ return meetsMinimumImageDimensions(w, h, minWidth, minHeight);
323
+ });
324
+ deps.logger.log(`Bright Data image search returned ${results.length} results for "${query}"`);
325
+ return { results };
326
+ } catch (err) {
327
+ deps.logger.warn(`Bright Data image search failed for "${query}": ${String(err)}`);
328
+ return { results: [] };
329
+ }
330
+ }
331
+ });
332
+ }
333
+ var brightDataNewsSearchSchema = z.object({
334
+ query: z.string().describe(`${STANDALONE_QUERY_DESCRIPTION} Include the newsworthy angle (announcement, release, event, update).`),
335
+ count: z.number().optional().describe("Number of results (max 100)"),
336
+ recency: z.enum(["day", "week", "month", "year"]).optional().describe(RECENCY_DESCRIPTION),
337
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
338
+ });
339
+ function createBrightDataNewsSearch(deps) {
340
+ return tool({
341
+ description: 'Search the latest news using Bright Data SERP API. Returns headlines, sources, dates, and snippets. Pass recency ("day"|"week"|"month"|"year") to restrict to a recent period. ' + STANDALONE_QUERY_TOOL_CLAUSE,
342
+ inputSchema: brightDataNewsSearchSchema,
343
+ execute: async ({ query, count: reqCount, recency, lang }) => {
344
+ const cfg = deps.getLiveConfig().brightData;
345
+ const apiKey = engineEnabled(deps, "news");
346
+ if (!apiKey) return { results: [], error: "Bright Data news search is not enabled" };
347
+ deps.logger.log(`Bright Data news search for "${query}"`);
348
+ const body = {};
349
+ applyLocaleParams(body, lang ?? deps.defaultLang);
350
+ applyRecencyParam(body, recency);
351
+ const url = buildGoogleUrl(query, {
352
+ tbm: "nws",
353
+ hl: body.hl,
354
+ gl: body.gl,
355
+ tbs: body.tbs,
356
+ num: reqCount ?? cfg.news.results
357
+ });
358
+ try {
359
+ const data = await requestBrightData(apiKey, cfg.serpZone, url, {
360
+ timeoutMs: BRIGHT_DATA_TIMEOUT_MS
361
+ });
362
+ const news = data.news ?? [];
363
+ if (!news.length) return { results: [] };
364
+ const results = news.map((r) => ({
365
+ title: r.title,
366
+ snippet: r.description || "",
367
+ url: r.link,
368
+ source: r.source || SOURCE,
369
+ date: r.date || "",
370
+ imageUrl: r.image_url || ""
371
+ }));
372
+ deps.logger.log(`Bright Data news returned ${results.length} results for "${query}"`);
373
+ return { results };
374
+ } catch (err) {
375
+ deps.logger.warn(`Bright Data news search failed for "${query}": ${String(err)}`);
376
+ return { results: [] };
377
+ }
378
+ }
379
+ });
380
+ }
381
+ var brightDataPlacesSearchSchema = z.object({
382
+ query: z.string().describe(
383
+ 'A standalone places search query that explicitly names the business or business type plus location (e.g. "MediaMarkt Berlin", "coffee shops in Munich").'
384
+ ),
385
+ count: z.number().optional().describe("Number of results (max 100)"),
386
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
387
+ });
388
+ function createBrightDataPlacesSearch(deps) {
389
+ return tool({
390
+ description: 'Search for places and businesses using Bright Data SERP API (Google Maps local results). Returns names, addresses, ratings, review counts, and coordinates. Phrase the query like a Google Maps search: a business name, a business type, or a business type plus location (e.g. "MediaMarkt Berlin", "coffee shops in Munich").',
391
+ inputSchema: brightDataPlacesSearchSchema,
392
+ execute: async ({ query, count: reqCount, lang }) => {
393
+ const cfg = deps.getLiveConfig().brightData;
394
+ const apiKey = engineEnabled(deps, "places");
395
+ if (!apiKey)
396
+ return {
397
+ results: [],
398
+ error: "Bright Data places search is not enabled"
399
+ };
400
+ deps.logger.log(`Bright Data places search for "${query}"`);
401
+ const body = {};
402
+ applyLocaleParams(body, lang ?? deps.defaultLang);
403
+ const url = buildGoogleUrl(query, {
404
+ tbm: "lcl",
405
+ hl: body.hl,
406
+ gl: body.gl,
407
+ num: reqCount ?? cfg.places.results
408
+ });
409
+ try {
410
+ const data = await requestBrightData(apiKey, cfg.serpZone, url, {
411
+ timeoutMs: BRIGHT_DATA_TIMEOUT_MS
412
+ });
413
+ const places = data.local_results ?? data.places ?? [];
414
+ if (!places.length) {
415
+ deps.logger.warn(`Bright Data places returned 0 results for "${query}"`);
416
+ return { results: [] };
417
+ }
418
+ const results = places.map((r) => ({
419
+ title: r.title || "",
420
+ address: r.address || "",
421
+ phoneNumber: r.phone || "",
422
+ latitude: r.latitude,
423
+ longitude: r.longitude,
424
+ rating: r.rating,
425
+ ratingCount: r.reviews_cnt,
426
+ type: r.type || "",
427
+ website: r.website || ""
428
+ }));
429
+ return { results };
430
+ } catch (err) {
431
+ deps.logger.warn(`Bright Data places search failed for "${query}": ${String(err)}`);
432
+ return { results: [] };
433
+ }
434
+ }
435
+ });
436
+ }
437
+ var brightDataShoppingSearchSchema = z.object({
438
+ query: z.string().describe("The exact product name with model number, kept short and standalone."),
439
+ count: z.number().optional().describe("Number of results (max 100)"),
440
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
441
+ });
442
+ function createBrightDataShoppingSearch(deps) {
443
+ return tool({
444
+ description: 'Search for products using Bright Data SERP API (Google Shopping). Returns prices, sellers, images, and ratings. Phrase the query as the bare product name with model number (e.g. "Sony WH-1000XM5") \u2014 do NOT add words like "review", "test", or long descriptive sentences.',
445
+ inputSchema: brightDataShoppingSearchSchema,
446
+ execute: async ({ query, count: reqCount, lang }) => {
447
+ const cfg = deps.getLiveConfig().brightData;
448
+ const apiKey = engineEnabled(deps, "shopping");
449
+ if (!apiKey)
450
+ return {
451
+ results: [],
452
+ error: "Bright Data shopping search is not enabled"
453
+ };
454
+ deps.logger.log(`Bright Data shopping search for "${query}"`);
455
+ const body = {};
456
+ applyLocaleParams(body, lang ?? deps.defaultLang);
457
+ const url = buildGoogleUrl(query, {
458
+ udm: 28,
459
+ hl: body.hl,
460
+ gl: body.gl,
461
+ num: reqCount ?? cfg.shopping.results
462
+ });
463
+ try {
464
+ const data = await requestBrightData(apiKey, cfg.serpZone, url, {
465
+ timeoutMs: BRIGHT_DATA_TIMEOUT_MS
466
+ });
467
+ const shopping = data.shopping ?? [];
468
+ if (!shopping.length) {
469
+ deps.logger.warn(`Bright Data shopping returned 0 results for "${query}"`);
470
+ return { results: [] };
471
+ }
472
+ const results = shopping.map((r) => ({
473
+ title: r.title || "",
474
+ price: r.price || "",
475
+ link: r.link || "",
476
+ source: r.source || "",
477
+ imageUrl: r.image_url || r.image || "",
478
+ delivery: r.delivery || "",
479
+ rating: r.rating,
480
+ ratingCount: r.rating_count
481
+ }));
482
+ return { results };
483
+ } catch (err) {
484
+ deps.logger.warn(`Bright Data shopping search failed for "${query}": ${String(err)}`);
485
+ return { results: [] };
486
+ }
487
+ }
488
+ });
489
+ }
490
+ var brightDataVideoSearchSchema = z.object({
491
+ query: z.string().describe(`${STANDALONE_QUERY_DESCRIPTION} Add the video type (e.g. review, trailer, tutorial, gameplay).`),
492
+ count: z.number().optional().describe("Number of results (max 100)"),
493
+ recency: z.enum(["day", "week", "month", "year"]).optional().describe(RECENCY_DESCRIPTION),
494
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
495
+ });
496
+
497
+ // src/tools/helpers/build-youtube-thumbnail-url.helper.ts
498
+ var YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
499
+ function extractYoutubeVideoId(url) {
500
+ let parsed;
501
+ try {
502
+ parsed = new URL(url);
503
+ } catch {
504
+ return void 0;
505
+ }
506
+ const host = parsed.hostname.toLowerCase().replace(/^www\.|^m\./, "");
507
+ if (host === "youtu.be") {
508
+ const id = parsed.pathname.slice(1).split("/")[0];
509
+ return YOUTUBE_ID_PATTERN.test(id) ? id : void 0;
510
+ }
511
+ if (host === "youtube.com" || host === "youtube-nocookie.com") {
512
+ const watchId = parsed.searchParams.get("v");
513
+ if (watchId && YOUTUBE_ID_PATTERN.test(watchId)) return watchId;
514
+ const pathMatch = parsed.pathname.match(/^\/(shorts|embed|live)\/([A-Za-z0-9_-]{11})/);
515
+ if (pathMatch) return pathMatch[2];
516
+ }
517
+ return void 0;
518
+ }
519
+ function buildYoutubeThumbnailUrl(url) {
520
+ const id = extractYoutubeVideoId(url);
521
+ return id ? `https://i.ytimg.com/vi/${id}/hqdefault.jpg` : void 0;
522
+ }
523
+
524
+ // src/tools/helpers/localized-query-suffix.helper.ts
525
+ function localizedQuerySuffix(lang) {
526
+ if (!lang) return "";
527
+ let code;
528
+ try {
529
+ code = new Intl.Locale(lang).language;
530
+ } catch {
531
+ return "";
532
+ }
533
+ if (!code || code === "en") return "";
534
+ try {
535
+ const name = new Intl.DisplayNames([code], { type: "language" }).of(code);
536
+ return !name || name === code ? "" : name;
537
+ } catch {
538
+ return "";
539
+ }
540
+ }
541
+
542
+ // src/tools/bright-data/video-search.tool.ts
543
+ function createBrightDataVideoSearch(deps) {
544
+ return tool({
545
+ description: 'Search for videos using Bright Data SERP API (Google Videos). Returns titles, links, snippets, and duration. Only return URLs from supported embeddable providers: YouTube, Vimeo, Dailymotion, Loom, Wistia, or direct video files. Reject Instagram, Facebook, TikTok, Twitch, X/Twitter, and other unreliable platforms. Pass recency ("day"|"week"|"month"|"year") to restrict to recently uploaded videos. ' + STANDALONE_QUERY_TOOL_CLAUSE,
546
+ inputSchema: brightDataVideoSearchSchema,
547
+ execute: async ({ query, count: reqCount, recency, lang }) => {
548
+ const cfg = deps.getLiveConfig().brightData;
549
+ const apiKey = engineEnabled(deps, "videos");
550
+ if (!apiKey)
551
+ return {
552
+ results: [],
553
+ error: "Bright Data video search is not enabled"
554
+ };
555
+ const langSuffix = localizedQuerySuffix(lang ?? deps.defaultLang);
556
+ const searchQuery = langSuffix && !query.toLowerCase().includes(langSuffix.toLowerCase()) ? `${query} ${langSuffix}` : query;
557
+ deps.logger.log(`Bright Data video search for "${searchQuery}"`);
558
+ const body = {};
559
+ applyLocaleParams(body, lang ?? deps.defaultLang);
560
+ applyRecencyParam(body, recency);
561
+ const url = buildGoogleUrl(searchQuery, {
562
+ udm: 7,
563
+ hl: body.hl,
564
+ gl: body.gl,
565
+ tbs: body.tbs,
566
+ num: reqCount ?? cfg.videos.results
567
+ });
568
+ try {
569
+ const data = await requestBrightData(apiKey, cfg.serpZone, url, {
570
+ timeoutMs: BRIGHT_DATA_TIMEOUT_MS
571
+ });
572
+ const videos = data.organic ?? [];
573
+ if (!videos.length) return { results: [] };
574
+ const results = videos.map((r) => ({
575
+ title: r.title || "",
576
+ link: r.link || "",
577
+ snippet: r.description || "",
578
+ channel: "",
579
+ duration: r.duration || "",
580
+ date: "",
581
+ // `image` is an embedded base64 thumbnail — derive a direct YouTube
582
+ // thumbnail from the link instead.
583
+ thumbnailUrl: buildYoutubeThumbnailUrl(r.link || "") ?? "",
584
+ source: SOURCE,
585
+ views: 0
586
+ }));
587
+ deps.logger.log(`Bright Data video search returned ${results.length} results for "${searchQuery}"`);
588
+ return { results };
589
+ } catch (err) {
590
+ deps.logger.warn(`Bright Data video search failed for "${searchQuery}": ${String(err)}`);
591
+ return { results: [] };
592
+ }
593
+ }
594
+ });
595
+ }
596
+ var brightDataWebSearchSchema = z.object({
597
+ query: z.string().describe(STANDALONE_QUERY_DESCRIPTION),
598
+ recency: z.enum(["day", "week", "month", "year"]).optional().describe(RECENCY_DESCRIPTION),
599
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
600
+ });
601
+ function createBrightDataWebSearch(deps) {
602
+ return tool({
603
+ description: 'Search the web using Bright Data SERP API (Google). Returns organic results with titles, snippets, and links. Pass recency ("day"|"week"|"month"|"year") to restrict to fresh results. ' + STANDALONE_QUERY_TOOL_CLAUSE,
604
+ inputSchema: brightDataWebSearchSchema,
605
+ execute: async ({ query, recency, lang }) => {
606
+ const cfg = deps.getLiveConfig().brightData;
607
+ const apiKey = engineEnabled(deps, "web");
608
+ if (!apiKey) return { results: [], error: "Bright Data web search is not enabled" };
609
+ deps.logger.log(`Bright Data web search for "${query}"`);
610
+ const body = {};
611
+ applyLocaleParams(body, lang ?? deps.defaultLang);
612
+ applyRecencyParam(body, recency);
613
+ const url = buildGoogleUrl(query, {
614
+ hl: body.hl,
615
+ gl: body.gl,
616
+ tbs: body.tbs,
617
+ num: cfg.web.results
618
+ });
619
+ try {
620
+ const data = await requestBrightData(apiKey, cfg.serpZone, url, {
621
+ timeoutMs: BRIGHT_DATA_TIMEOUT_MS
622
+ });
623
+ const organic = data.organic ?? [];
624
+ if (!organic.length) {
625
+ deps.logger.warn(`Bright Data returned 0 results for "${query}"`);
626
+ return { results: [] };
627
+ }
628
+ const results = organic.map((r) => ({
629
+ title: r.title,
630
+ snippet: r.description || "",
631
+ url: r.link,
632
+ source: SOURCE
633
+ }));
634
+ deps.logger.log(`Bright Data returned ${results.length} results for "${query}"`);
635
+ return { results };
636
+ } catch (err) {
637
+ deps.logger.warn(`Bright Data web search failed for "${query}": ${String(err)}`);
638
+ return { results: [] };
639
+ }
640
+ }
641
+ });
642
+ }
643
+ var brightDataWebpageScrapeSchema = z.object({
644
+ url: z.string().describe("The URL to fetch and render")
645
+ });
646
+ function createBrightDataWebpageScrape(deps) {
647
+ return tool({
648
+ description: "Fetch and render a full webpage using Bright Data Web Unlocker API. Returns clean Markdown text with its title. Use for pages behind anti-bot protection that plain fetch cannot reach.",
649
+ inputSchema: brightDataWebpageScrapeSchema,
650
+ execute: async ({ url }) => {
651
+ const cfg = deps.getLiveConfig().brightData;
652
+ if (!cfg.enabled || !cfg.apiKey || !cfg.unlockerZone) {
653
+ return {
654
+ content: "",
655
+ error: "Bright Data webpage scrape is not enabled"
656
+ };
657
+ }
658
+ if (!cfg.scrape.enabled) {
659
+ return {
660
+ content: "",
661
+ error: "Bright Data webpage scrape is not enabled"
662
+ };
663
+ }
664
+ deps.logger.log(`Bright Data webpage scrape for "${url}"`);
665
+ try {
666
+ const data = await requestBrightData(cfg.apiKey, cfg.unlockerZone, url, {
667
+ markdown: true,
668
+ timeoutMs: BRIGHT_DATA_TIMEOUT_MS
669
+ });
670
+ const content = data.text || "";
671
+ deps.logger.log(`Bright Data webpage scraped ${content.length} chars from "${url}"`);
672
+ return { content, title: "" };
673
+ } catch (err) {
674
+ deps.logger.warn(`Bright Data webpage scrape failed for "${url}": ${String(err)}`);
675
+ return { content: "", error: String(err) };
676
+ }
677
+ }
678
+ });
679
+ }
680
+
681
+ // src/tools/helpers/is-google-host-url.helper.ts
682
+ function isGoogleHostUrl(url) {
683
+ try {
684
+ return /(^|\.)google\.[a-z.]+$/.test(new URL(url).hostname.toLowerCase());
685
+ } catch {
686
+ return false;
687
+ }
688
+ }
689
+
690
+ // src/tools/helpers/pick-merchant-result.helper.ts
691
+ function pickMerchantResult(results, storeToken) {
692
+ if (!storeToken) return void 0;
693
+ for (const { url } of results) {
694
+ if (!url) continue;
695
+ let hostname;
696
+ try {
697
+ hostname = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
698
+ } catch {
699
+ continue;
700
+ }
701
+ if (hostname.replaceAll(".", "").includes(storeToken)) return url;
702
+ const labelMatch = hostname.split(".").some((label) => label.length >= 4 && storeToken.includes(label));
703
+ if (labelMatch) return url;
704
+ }
705
+ return void 0;
706
+ }
707
+
708
+ // src/tools/helpers/store-host-token.helper.ts
709
+ var NON_IDENTITY_TOKENS = /* @__PURE__ */ new Set([
710
+ // company/legal forms
711
+ "gmbh",
712
+ "ag",
713
+ "se",
714
+ "kg",
715
+ "ohg",
716
+ "ug",
717
+ "ltd",
718
+ "inc",
719
+ "llc",
720
+ "sarl",
721
+ "sa",
722
+ "bv",
723
+ "oy",
724
+ "ab",
725
+ "as",
726
+ "spa",
727
+ "srl",
728
+ "pte",
729
+ "pty",
730
+ // tld and host labels
731
+ "com",
732
+ "de",
733
+ "at",
734
+ "ch",
735
+ "co",
736
+ "uk",
737
+ "net",
738
+ "org",
739
+ "io",
740
+ "eu",
741
+ "fr",
742
+ "nl",
743
+ "it",
744
+ "es",
745
+ "pl",
746
+ "no",
747
+ "fi",
748
+ "dk",
749
+ "us",
750
+ "ca",
751
+ "au",
752
+ "nz",
753
+ "jp",
754
+ "kr",
755
+ "cn",
756
+ "in",
757
+ "br",
758
+ "mx",
759
+ "ru",
760
+ "be",
761
+ "ie",
762
+ "cz",
763
+ "gr",
764
+ "pt",
765
+ "hu",
766
+ "ro",
767
+ "shop",
768
+ "store",
769
+ "online"
770
+ ]);
771
+ function storeHostToken(source) {
772
+ return source.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 0 && !NON_IDENTITY_TOKENS.has(token)).join("");
773
+ }
774
+
775
+ // src/tools/helpers/serper-shop-links.ts
776
+ var SERPER_SEARCH_URL = "https://google.serper.dev/search";
777
+ var MAX_LINK_RESOLUTIONS = 12;
778
+ var RESOLUTION_RESULT_COUNT = 3;
779
+ async function resolveSerperShopOfferLinks(offers, deps) {
780
+ const googleLinked = offers.filter((offer) => offer.link && isGoogleHostUrl(offer.link));
781
+ if (googleLinked.length === 0) return offers;
782
+ const resolvable = googleLinked.slice(0, MAX_LINK_RESOLUTIONS);
783
+ const resolutions = await Promise.all(
784
+ resolvable.map(async (offer) => ({
785
+ offer,
786
+ merchantUrl: await fetchMerchantUrl(offer, deps)
787
+ }))
788
+ );
789
+ const merchantUrlByLink = /* @__PURE__ */ new Map();
790
+ for (const { offer, merchantUrl } of resolutions) {
791
+ if (merchantUrl) merchantUrlByLink.set(offer.link, merchantUrl);
792
+ }
793
+ if (merchantUrlByLink.size === 0) return offers;
794
+ deps.logger.log(`Resolved ${merchantUrlByLink.size}/${googleLinked.length} Google shop link(s) to merchant URLs`);
795
+ return offers.map(
796
+ (offer) => merchantUrlByLink.has(offer.link) ? { ...offer, link: merchantUrlByLink.get(offer.link) } : offer
797
+ );
798
+ }
799
+ async function fetchMerchantUrl(offer, deps) {
800
+ const token = storeHostToken(offer.source ?? "");
801
+ if (!token || !offer.title) return void 0;
802
+ try {
803
+ const body = {
804
+ q: `${offer.title} ${offer.source}`,
805
+ num: RESOLUTION_RESULT_COUNT
806
+ };
807
+ applyLocaleParams(body, deps.lang);
808
+ const res = await fetchWithTimeout(
809
+ SERPER_SEARCH_URL,
810
+ {
811
+ method: "POST",
812
+ headers: {
813
+ "X-API-KEY": deps.apiKey,
814
+ "Content-Type": "application/json"
815
+ },
816
+ body: JSON.stringify(body)
817
+ },
818
+ { timeoutMs: SEARCH_TIMEOUT_MS }
819
+ );
820
+ if (!res.ok) return void 0;
821
+ const data = await res.json();
822
+ return pickMerchantResult(
823
+ (data.organic ?? []).map((entry) => ({ url: entry.link })),
824
+ token
825
+ );
826
+ } catch {
827
+ return void 0;
828
+ }
829
+ }
830
+ var variantRequestSchema = z.object({});
831
+ function createVariantRequestTool(variant) {
832
+ const descriptions = {
833
+ grayscale: "Request a grayscale version of the images. Use when color noise or color information is irrelevant, for example when reading text or analyzing shapes.",
834
+ denoised: "Request a denoised (blurred) version of the images. Use when the original has noise, grain, or artifacts that hide details.",
835
+ sharpened: "Request a sharpened version of the images. Use when edges or fine details are blurry.",
836
+ clahe: "Request a CLAHE (contrast-enhanced) version of the images. Use when details are hidden in shadows or highlights."
837
+ };
838
+ return tool({
839
+ description: descriptions[variant],
840
+ inputSchema: variantRequestSchema,
841
+ execute: async () => ({ variant })
842
+ });
843
+ }
844
+ var memoryDeleteSchema = z.object({
845
+ text: z.string().min(3).max(2e3).optional().describe(
846
+ "The exact stored statement to delete, quoted verbatim from a memoryRecall result. Record texts are the record identity \u2014 no ids needed. Never paraphrase: recall first, then delete the exact text."
847
+ ),
848
+ cognition: z.boolean().optional().describe(
849
+ "Set true ONLY when the user asks you to forget your accumulated understanding of them (the cognition profile \u2014 your derived model of their traits, likes and dislikes) or to start over entirely. Cannot be combined with text."
850
+ )
851
+ });
852
+ function createMemoryDeleteTool(deps) {
853
+ return tool({
854
+ description: "Delete from YOUR long-term memory of this user. Pass text \u2014 one exact stored statement quoted verbatim from a memoryRecall result \u2014 to delete that fact record. Pass cognition:true ONLY when the user asks you to forget your learned understanding of them or to start over (wipes your entire cognition space of the user: the structured profile AND every derived insight). Never delete on a guess: recall first with memoryRecall, then delete the verbatim statement. Deletion is permanent; the result confirms exactly what was removed.",
855
+ inputSchema: memoryDeleteSchema,
856
+ execute: async ({ text, cognition }) => {
857
+ if (cognition === true) {
858
+ try {
859
+ const removed = await deps.deleteCognition(deps.scope.memoryCognition ?? deps.scope.memoryPartition);
860
+ return removed.length > 0 ? {
861
+ deleted: removed.length,
862
+ removed,
863
+ note: "Your cognition space of this user was wiped (profile and insights) \u2014 understanding is forgotten, fact records are untouched."
864
+ } : {
865
+ deleted: 0,
866
+ message: "No cognition exists for this user."
867
+ };
868
+ } catch (error) {
869
+ return {
870
+ deleted: 0,
871
+ error: error instanceof Error ? error.message : "cognition delete failed"
872
+ };
873
+ }
874
+ }
875
+ if (!text) {
876
+ return {
877
+ deleted: 0,
878
+ error: "Provide exactly one of the two modes: text (verbatim record) or cognition:true."
879
+ };
880
+ }
881
+ try {
882
+ const outcome = await deps.deleteRecords({
883
+ memoryPartition: deps.scope.memoryPartition,
884
+ text
885
+ });
886
+ return outcome.deleted > 0 ? { deleted: outcome.deleted, removed: outcome.texts } : {
887
+ deleted: 0,
888
+ message: "No stored record matches that exact text \u2014 use memoryRecall to find the verbatim statement first."
889
+ };
890
+ } catch (error) {
891
+ return {
892
+ deleted: 0,
893
+ error: error instanceof Error ? error.message : "memory delete failed"
894
+ };
895
+ }
896
+ }
897
+ });
898
+ }
899
+ var memoryRecallSchema = z.object({
900
+ query: z.string().min(1).max(1e3).describe('What to recall as a natural-language question, e.g. "What is Sams phone number?"'),
901
+ tags: z.array(z.string().min(1).max(40)).max(8).optional().describe('Restrict to records tagged with ANY of these topics, e.g. ["work"].'),
902
+ contains: z.string().max(200).optional().describe('Restrict to records whose text contains this exact phrase, e.g. "phone number".'),
903
+ topK: z.number().int().min(1).max(10).optional().describe("Maximum number of results to return (default 5).")
904
+ });
905
+ function createMemoryRecallTool(deps) {
906
+ return tool({
907
+ description: 'Retrieve from YOUR long-term memory of this user \u2014 things the user told you in past conversations or asked you to remember (contact details, preferences, decisions, past topics). These are trusted user statements, NOT public facts. Use when the user asks whether you remember something they told you or refers back to a statement from earlier conversations; the results are also the verbatim source for memoryDelete. For "where did we leave off" / "what were we doing recently" questions use your injected RECENT CONVERSATIONS notes instead \u2014 this lane holds what the user told you, not the log of your recent activity. Attribute what you find to the user ("you mentioned\u2026", "you asked me to remember\u2026") and prefer it over web-search results for anything personal. Retrieval is semantic and sentence-aware \u2014 ask in natural language. Optionally restrict by topic tags (e.g. ["contacts"]) or exact phrase containment.',
908
+ inputSchema: memoryRecallSchema,
909
+ execute: async ({ query, tags, contains, topK }) => {
910
+ const hits = await deps.searchByText({
911
+ memoryPartition: deps.scope.memoryPartition,
912
+ text: query,
913
+ tags,
914
+ contains,
915
+ limit: topK ?? 5
916
+ });
917
+ if (hits.length === 0) {
918
+ return "No memories found for this user on this topic.";
919
+ }
920
+ const lines = hits.map((hit) => {
921
+ const who = hit.role === "user" ? "the user" : "you (assistant)";
922
+ const when = hit.createdAt ? ` on ${new Date(hit.createdAt).toISOString().slice(0, 10)}` : "";
923
+ return `- "${hit.text}" \u2014 stated by ${who}${when}`;
924
+ });
925
+ return `YOUR MEMORY OF THIS USER (trusted statements they said or asked you to remember \u2014 answer from them and attribute them to the user; never present them as public web knowledge):
926
+ ${lines.join("\n")}`;
927
+ }
928
+ });
929
+ }
930
+ var memoryRememberSchema = z.object({
931
+ text: z.string().min(1).max(2e3).describe('The fact to remember, as a self-contained statement, e.g. "Sams phone number is 555-1234".'),
932
+ tags: z.array(z.string().min(1).max(40)).max(8).optional().describe(
933
+ 'Optional topic labels (lowercase, reusable) so future recall can filter by topic, e.g. ["contacts", "sam"].'
934
+ )
935
+ });
936
+ function createMemoryRememberTool(deps) {
937
+ return tool({
938
+ description: 'Store into YOUR long-term memory of this user: notable facts about subjects they care about (favorites, interests, projects, followed stocks, people, past topics), preferences and durable details they state, and anything they explicitly ask you to remember. Storing gathered knowledge and noticed preferences is expected \u2014 do not wait for an explicit "remember" instruction. This memory outlives the conversation and is recalled later with memoryRecall. STORAGE MECHANICS: each record is embedded as a whole and matched sentence-by-sentence at recall time \u2014 write ONE self-contained statement per call (a single dense sentence is fine, subject up front), restating a record verbatim updates it in place, and tags are the recall filter vocabulary (lowercase, reusable).',
939
+ inputSchema: memoryRememberSchema,
940
+ execute: async ({ text, tags }) => {
941
+ try {
942
+ const id = await deps.storeRecord({
943
+ memoryPartition: deps.scope.memoryPartition,
944
+ sessionId: deps.scope.sessionId,
945
+ conversationId: deps.scope.conversationId,
946
+ requestId: deps.scope.requestId,
947
+ text,
948
+ tags
949
+ });
950
+ return { stored: true, id };
951
+ } catch (error) {
952
+ return {
953
+ stored: false,
954
+ error: error instanceof Error ? error.message : "memory store failed"
955
+ };
956
+ }
957
+ }
958
+ });
959
+ }
960
+ var serperBusinessReviewsSearchSchema = z.object({
961
+ query: z.string().optional().describe(
962
+ "The exact business or place name, ideally with its location, named explicitly and resolved from the conversation. Used when neither placeId nor cid is known."
963
+ ),
964
+ placeId: z.string().optional().describe("Google Place ID of the business. Most precise identifier \u2014 prefer it when available."),
965
+ cid: z.string().optional().describe("Google CID of the business, e.g. from a places search result."),
966
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
967
+ });
968
+
969
+ // src/tools/serper/serper.constants.ts
970
+ var HEADERS = (apiKey) => ({
971
+ "X-API-KEY": apiKey,
972
+ "Content-Type": "application/json"
973
+ });
974
+
975
+ // src/tools/serper/business-reviews-search.tool.ts
976
+ function createSerperBusinessReviewsSearch(deps) {
977
+ return tool({
978
+ description: `Fetch Google Maps reviews for a specific business or place using Serper.dev.
979
+ Returns individual reviewer snippets with author names, star ratings, dates, and likes.
980
+ This endpoint reviews BUSINESSES (shops, restaurants, hotels, services) \u2014 it does not search editorial product reviews.
981
+ Identify the business by its exact name plus location via query (e.g. "MediaMarkt Berlin Alexanderplatz"),
982
+ or pass placeId/cid when a previous places search returned them.
983
+ Use it to judge seller/business reputation; for product quality opinions use a *WebSearch tool with a "<product> review" query instead.`,
984
+ inputSchema: serperBusinessReviewsSearchSchema,
985
+ execute: async ({ query, placeId, cid, lang }) => {
986
+ const cfg = deps.getLiveConfig().serper;
987
+ if (!cfg.enabled || !cfg.apiKey || !cfg.reviews.enabled) {
988
+ return {
989
+ results: [],
990
+ error: "Serper.dev reviews is not enabled"
991
+ };
992
+ }
993
+ if (!placeId && !cid && !query?.trim()) {
994
+ return {
995
+ results: [],
996
+ error: "Provide a placeId, cid, or business name query"
997
+ };
998
+ }
999
+ const lookup = placeId ?? cid ?? query?.trim();
1000
+ deps.logger.log(`Serper.dev Reviews search for "${lookup}"`);
1001
+ const body = {};
1002
+ if (placeId) body.placeId = placeId;
1003
+ else if (cid) body.cid = cid;
1004
+ else body.q = query.trim();
1005
+ applyLocaleParams(body, lang ?? deps.defaultLang);
1006
+ const res = await fetchWithTimeout(
1007
+ "https://google.serper.dev/reviews",
1008
+ {
1009
+ method: "POST",
1010
+ headers: HEADERS(cfg.apiKey),
1011
+ body: JSON.stringify(body)
1012
+ },
1013
+ { timeoutMs: SEARCH_TIMEOUT_MS }
1014
+ );
1015
+ if (!res.ok) return { results: [], error: `HTTP ${res.status}` };
1016
+ const data = await res.json();
1017
+ if (!data.reviews?.length) {
1018
+ deps.logger.warn(`Serper.dev Reviews returned 0 results for "${lookup}"`);
1019
+ return { results: [] };
1020
+ }
1021
+ const placeName = data.placeInfo?.title || "";
1022
+ const results = data.reviews.map((r) => ({
1023
+ author: r.user?.name || "",
1024
+ snippet: r.snippet || "",
1025
+ rating: r.rating,
1026
+ date: r.isoDate || r.date || "",
1027
+ likes: r.likes ?? 0,
1028
+ place: placeName
1029
+ }));
1030
+ return {
1031
+ results,
1032
+ place: data.placeInfo ? {
1033
+ title: data.placeInfo.title || "",
1034
+ address: data.placeInfo.address || "",
1035
+ rating: data.placeInfo.rating,
1036
+ ratingCount: data.placeInfo.ratingCount
1037
+ } : void 0
1038
+ };
1039
+ }
1040
+ });
1041
+ }
1042
+ var serperImageSearchSchema = z.object({
1043
+ query: z.string().describe(`${STANDALONE_QUERY_DESCRIPTION} Add short visual qualifiers describing the subject.`),
1044
+ count: z.number().optional().describe("Number of results (max 100)"),
1045
+ minWidth: z.number().optional().describe(
1046
+ "Minimum image width in pixels. Use 1920 when the user wants 1080p-quality images, 2560 for 1440p, 3840 for 4K. The tool always enforces a floor of 1280 (720p)."
1047
+ ),
1048
+ minHeight: z.number().optional().describe(
1049
+ "Minimum image height in pixels. Use 1080 when the user wants 1080p-quality images, 1440 for 1440p, 2160 for 4K. The tool always enforces a floor of 720 (720p)."
1050
+ ),
1051
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)"),
1052
+ recency: z.enum(["day", "week", "month", "year"]).optional().describe(RECENCY_DESCRIPTION)
1053
+ });
1054
+ function createSerperImageSearch(deps) {
1055
+ return tool({
1056
+ description: 'Search for images using Serper.dev (Google Images). Returns image URLs, thumbnails, source pages, and dimensions. The tool prefers 2560\xD71440 (1440p) images and always enforces a minimum of 1280\xD7720 (720p). It passes the appropriate Google Images `tbs=isz:lt,islt:<bucket>` size filter server-side, drops any returned images whose dimensions are below 1280\xD7720, and rejects untrusted domains such as Google thumbnail proxies (encrypted-tbn*.gstatic.com, t*.gstatic.com), data URIs, localhost, and private IPs. You do not need to pass minWidth/minHeight for the default 720p floor. If the user asks for a higher resolution, pass minWidth/minHeight and the tool will pick the smallest Google bucket that can satisfy the requested area. Common reference: 1280\xD7720 (720p) ~0.9 MP, 1920\xD71080 (1080p) ~2 MP, 2560\xD71440 (1440p) ~3.7 MP, 3840\xD72160 (4K) ~8.3 MP. Pass recency ("day"|"week"|"month"|"year") to restrict to recently published images. ' + STANDALONE_QUERY_TOOL_CLAUSE,
1057
+ inputSchema: serperImageSearchSchema,
1058
+ execute: async ({
1059
+ query,
1060
+ count: reqCount,
1061
+ minWidth: requestedMinWidth,
1062
+ minHeight: requestedMinHeight,
1063
+ lang,
1064
+ recency
1065
+ }) => {
1066
+ const cfg = deps.getLiveConfig().serper;
1067
+ if (!cfg.enabled || !cfg.apiKey || !cfg.images.enabled) {
1068
+ return {
1069
+ results: [],
1070
+ error: "Serper.dev image search is not enabled"
1071
+ };
1072
+ }
1073
+ const minWidth = Math.max(requestedMinWidth ?? 0, MIN_IMAGE_WIDTH);
1074
+ const minHeight = Math.max(requestedMinHeight ?? 0, MIN_IMAGE_HEIGHT);
1075
+ deps.logger.log(`Serper.dev Image Search for "${query}" min ${minWidth}x${minHeight}`);
1076
+ const num = Math.min(reqCount ?? cfg.images.results, cfg.images.results);
1077
+ const body = { q: query, num };
1078
+ applyLocaleParams(body, lang);
1079
+ const targetPixels = minWidth * minHeight;
1080
+ body.tbs = `isz:lt,islt:${tbsSizeLabelForPixels(targetPixels)}`;
1081
+ applyRecencyParam(body, recency);
1082
+ const res = await fetchWithTimeout(
1083
+ "https://google.serper.dev/images",
1084
+ {
1085
+ method: "POST",
1086
+ headers: HEADERS(cfg.apiKey),
1087
+ body: JSON.stringify(body)
1088
+ },
1089
+ { timeoutMs: SEARCH_TIMEOUT_MS }
1090
+ );
1091
+ if (!res.ok) {
1092
+ deps.logger.warn(`Serper.dev Image Search returned ${res.status} for "${query}"`);
1093
+ return { results: [] };
1094
+ }
1095
+ const data = await res.json();
1096
+ if (!data.images?.length) {
1097
+ deps.logger.warn(`Serper.dev Image Search returned 0 results for "${query}"`);
1098
+ return { results: [] };
1099
+ }
1100
+ const results = data.images.map((r) => ({
1101
+ title: r.title || "",
1102
+ imageUrl: r.imageUrl || r.image || "",
1103
+ sourcePageUrl: r.link || "",
1104
+ width: r.imageWidth ?? r.width,
1105
+ height: r.imageHeight ?? r.height,
1106
+ source: r.source || "",
1107
+ domain: r.domain || ""
1108
+ })).filter((r) => {
1109
+ if (!isTrustedImageUrl(r.imageUrl)) return false;
1110
+ const w = r.width ?? 0;
1111
+ const h = r.height ?? 0;
1112
+ if (!w || !h) return true;
1113
+ return meetsMinimumImageDimensions(w, h, minWidth, minHeight);
1114
+ });
1115
+ deps.logger.log(`Serper.dev Image Search returned ${results.length} results for "${query}"`);
1116
+ return { results };
1117
+ }
1118
+ });
1119
+ }
1120
+ var serperNewsSearchSchema = z.object({
1121
+ query: z.string().describe(`${STANDALONE_QUERY_DESCRIPTION} Include the newsworthy angle (announcement, release, event, update).`),
1122
+ count: z.number().optional().describe("Number of results (max 100)"),
1123
+ recency: z.enum(["day", "week", "month", "year"]).optional().describe(RECENCY_DESCRIPTION),
1124
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
1125
+ });
1126
+ function createSerperNewsSearch(deps) {
1127
+ return tool({
1128
+ description: 'Search the latest news using Serper.dev. Returns headlines, sources, dates, and snippets. Pass recency ("day"|"week"|"month"|"year") to restrict to a recent period. ' + STANDALONE_QUERY_TOOL_CLAUSE,
1129
+ inputSchema: serperNewsSearchSchema,
1130
+ execute: async ({ query, count: reqCount, recency, lang }) => {
1131
+ const cfg = deps.getLiveConfig().serper;
1132
+ if (!cfg.enabled || !cfg.apiKey || !cfg.news.enabled) {
1133
+ return { results: [], error: "Serper.dev news is not enabled" };
1134
+ }
1135
+ deps.logger.log(`Serper.dev News search for "${query}"`);
1136
+ const num = Math.min(reqCount ?? cfg.news.results, cfg.news.results);
1137
+ const newsBody = {
1138
+ q: query,
1139
+ num
1140
+ };
1141
+ applyLocaleParams(newsBody, lang ?? deps.defaultLang);
1142
+ applyRecencyParam(newsBody, recency);
1143
+ const res = await fetchWithTimeout(
1144
+ "https://google.serper.dev/news",
1145
+ {
1146
+ method: "POST",
1147
+ headers: HEADERS(cfg.apiKey),
1148
+ body: JSON.stringify(newsBody)
1149
+ },
1150
+ { timeoutMs: SEARCH_TIMEOUT_MS }
1151
+ );
1152
+ if (!res.ok) return { results: [] };
1153
+ const data = await res.json();
1154
+ if (!data.news?.length) return { results: [] };
1155
+ const results = data.news.map((r) => ({
1156
+ title: r.title,
1157
+ snippet: r.snippet || "",
1158
+ url: r.link,
1159
+ source: r.source || "",
1160
+ date: r.date || "",
1161
+ imageUrl: r.imageUrl || ""
1162
+ }));
1163
+ return { results };
1164
+ }
1165
+ });
1166
+ }
1167
+ var serperPlacesSearchSchema = z.object({
1168
+ query: z.string().describe(
1169
+ 'A standalone places search query that explicitly names the business or business type plus location (e.g. "MediaMarkt Berlin", "coffee shops in Munich") \u2014 resolve the subject from the conversation; never copy the user message verbatim.'
1170
+ ),
1171
+ count: z.number().optional().describe("Number of results (max 100)"),
1172
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
1173
+ });
1174
+ function createSerperPlacesSearch(deps) {
1175
+ return tool({
1176
+ description: 'Search for places and businesses using Serper.dev (Google Maps). Returns addresses, phone numbers, ratings, review counts, and coordinates. Phrase the query like a Google Maps search: a business name, a business type, or a business type plus location (e.g. "MediaMarkt Berlin", "coffee shops in Munich").',
1177
+ inputSchema: serperPlacesSearchSchema,
1178
+ execute: async ({ query, count: reqCount, lang }) => {
1179
+ const cfg = deps.getLiveConfig().serper;
1180
+ if (!cfg.enabled || !cfg.apiKey || !cfg.places.enabled) {
1181
+ return { results: [], error: "Serper.dev places is not enabled" };
1182
+ }
1183
+ deps.logger.log(`Serper.dev Places search for "${query}"`);
1184
+ const num = Math.min(reqCount ?? cfg.places.results, cfg.places.results);
1185
+ const body = { q: query, num };
1186
+ applyLocaleParams(body, lang ?? deps.defaultLang);
1187
+ const res = await fetchWithTimeout(
1188
+ "https://google.serper.dev/places",
1189
+ {
1190
+ method: "POST",
1191
+ headers: HEADERS(cfg.apiKey),
1192
+ body: JSON.stringify(body)
1193
+ },
1194
+ { timeoutMs: SEARCH_TIMEOUT_MS }
1195
+ );
1196
+ if (!res.ok) {
1197
+ return { results: [], error: `HTTP ${res.status}` };
1198
+ }
1199
+ const data = await res.json();
1200
+ if (!data.places?.length) {
1201
+ deps.logger.warn(`Serper.dev Places returned 0 results for "${query}"`);
1202
+ return { results: [] };
1203
+ }
1204
+ const results = data.places.map((r) => ({
1205
+ title: r.title,
1206
+ address: r.address || "",
1207
+ phoneNumber: r.phoneNumber || "",
1208
+ latitude: r.latitude,
1209
+ longitude: r.longitude,
1210
+ rating: r.rating,
1211
+ ratingCount: r.ratingCount,
1212
+ type: r.type || "",
1213
+ website: r.website || "",
1214
+ cid: r.cid || ""
1215
+ }));
1216
+ return { results };
1217
+ }
1218
+ });
1219
+ }
1220
+ var serperShoppingSearchSchema = z.object({
1221
+ query: z.string().describe(
1222
+ 'The exact product name with model number, kept short and standalone \u2014 resolve product references from the conversation (e.g. "the headphones we discussed" becomes "Sony WH-1000XM5"). No extra words like "buy", "price", or "review".'
1223
+ ),
1224
+ count: z.number().optional().describe("Number of results (max 100)"),
1225
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
1226
+ });
1227
+ function createSerperShoppingSearch(deps) {
1228
+ return tool({
1229
+ description: 'Search for products using Serper.dev (Google Shopping). Returns prices, sellers, delivery info, images, and per-offer ratings. Phrase the query as the bare product name with model number (e.g. "Sony WH-1000XM5") \u2014 do NOT add words like "review", "test", or long descriptive sentences, they hurt shopping result quality.',
1230
+ inputSchema: serperShoppingSearchSchema,
1231
+ execute: async ({ query, count: reqCount, lang }) => {
1232
+ const cfg = deps.getLiveConfig().serper;
1233
+ if (!cfg.enabled || !cfg.apiKey || !cfg.shopping.enabled) {
1234
+ return {
1235
+ results: [],
1236
+ error: "Serper.dev shopping is not enabled"
1237
+ };
1238
+ }
1239
+ deps.logger.log(`Serper.dev Shopping search for "${query}"`);
1240
+ const num = Math.min(reqCount ?? cfg.shopping.results, cfg.shopping.results);
1241
+ const body = { q: query, num };
1242
+ const effectiveLang = lang ?? deps.defaultLang;
1243
+ applyLocaleParams(body, effectiveLang);
1244
+ const res = await fetchWithTimeout(
1245
+ "https://google.serper.dev/shopping",
1246
+ {
1247
+ method: "POST",
1248
+ headers: HEADERS(cfg.apiKey),
1249
+ body: JSON.stringify(body)
1250
+ },
1251
+ { timeoutMs: SEARCH_TIMEOUT_MS }
1252
+ );
1253
+ if (!res.ok) return { results: [], error: `HTTP ${res.status}` };
1254
+ const data = await res.json();
1255
+ if (!data.shopping?.length) {
1256
+ deps.logger.warn(`Serper.dev Shopping returned 0 results for "${query}"`);
1257
+ return { results: [] };
1258
+ }
1259
+ const results = data.shopping.map((r) => ({
1260
+ title: r.title,
1261
+ price: r.price || "",
1262
+ link: r.link || "",
1263
+ source: r.source || "",
1264
+ imageUrl: r.imageUrl || "",
1265
+ delivery: r.delivery || "",
1266
+ rating: r.rating,
1267
+ ratingCount: r.ratingCount
1268
+ }));
1269
+ return {
1270
+ results: await resolveSerperShopOfferLinks(results, {
1271
+ apiKey: cfg.apiKey,
1272
+ lang: effectiveLang,
1273
+ logger: deps.logger
1274
+ })
1275
+ };
1276
+ }
1277
+ });
1278
+ }
1279
+ var serperVideoSearchSchema = z.object({
1280
+ query: z.string().describe(
1281
+ `${STANDALONE_QUERY_DESCRIPTION} Add the video type (e.g. review, trailer, tutorial, gameplay). When the conversation language is not English, phrase the descriptive words in that language and append the language's own name (e.g. "Review Deutsch") to pull localized results.`
1282
+ ),
1283
+ count: z.number().optional().describe("Number of results (max 100)"),
1284
+ recency: z.enum(["day", "week", "month", "year"]).optional().describe(RECENCY_DESCRIPTION),
1285
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
1286
+ });
1287
+ function createSerperVideoSearch(deps) {
1288
+ return tool({
1289
+ description: 'Search for videos using Serper.dev. Returns titles, links, channel names, duration, and publish dates. Only return URLs from supported embeddable providers: YouTube, Vimeo, Dailymotion, Loom, Wistia, or direct video files. Reject Instagram, Facebook, TikTok, Twitch, X/Twitter, and other unreliable platforms. Pass recency ("day"|"week"|"month"|"year") to restrict to recently uploaded videos. ' + STANDALONE_QUERY_TOOL_CLAUSE,
1290
+ inputSchema: serperVideoSearchSchema,
1291
+ execute: async ({ query, count: reqCount, recency, lang }) => {
1292
+ const cfg = deps.getLiveConfig().serper;
1293
+ if (!cfg.enabled || !cfg.apiKey || !cfg.videos.enabled) {
1294
+ return { results: [], error: "Serper.dev videos is not enabled" };
1295
+ }
1296
+ const langSuffix = localizedQuerySuffix(lang ?? deps.defaultLang);
1297
+ const searchQuery = langSuffix && !query.toLowerCase().includes(langSuffix.toLowerCase()) ? `${query} ${langSuffix}` : query;
1298
+ deps.logger.log(`Serper.dev Video search for "${searchQuery}"`);
1299
+ const num = Math.min(reqCount ?? cfg.videos.results, cfg.videos.results);
1300
+ const videoBody = {
1301
+ q: searchQuery,
1302
+ num
1303
+ };
1304
+ applyLocaleParams(videoBody, lang ?? deps.defaultLang);
1305
+ applyRecencyParam(videoBody, recency);
1306
+ const res = await fetchWithTimeout(
1307
+ "https://google.serper.dev/videos",
1308
+ {
1309
+ method: "POST",
1310
+ headers: HEADERS(cfg.apiKey),
1311
+ body: JSON.stringify(videoBody)
1312
+ },
1313
+ { timeoutMs: SEARCH_TIMEOUT_MS }
1314
+ );
1315
+ if (!res.ok) return { results: [] };
1316
+ const data = await res.json();
1317
+ if (!data.videos?.length) return { results: [] };
1318
+ const results = data.videos.slice(0, num).map((r) => ({
1319
+ title: r.title,
1320
+ link: r.link,
1321
+ snippet: r.snippet || "",
1322
+ channel: r.channel || "",
1323
+ duration: r.duration || "",
1324
+ date: r.date || "",
1325
+ // Serper thumbnails are Google proxy images (blocked by our image
1326
+ // trust rules) — derive a direct thumbnail for YouTube instead.
1327
+ thumbnailUrl: buildYoutubeThumbnailUrl(r.link) ?? "",
1328
+ source: r.source || "",
1329
+ views: r.views ?? 0
1330
+ }));
1331
+ deps.logger.log(`Serper.dev Video search returned ${results.length} results for "${searchQuery}"`);
1332
+ return { results };
1333
+ }
1334
+ });
1335
+ }
1336
+ var serperWebSearchSchema = z.object({
1337
+ query: z.string().describe(STANDALONE_QUERY_DESCRIPTION),
1338
+ recency: z.enum(["day", "week", "month", "year"]).optional().describe(RECENCY_DESCRIPTION),
1339
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
1340
+ });
1341
+ function createSerperWebSearch(deps) {
1342
+ return tool({
1343
+ description: 'Search the web using Serper.dev (Google results). Returns organic results with titles, snippets, and links. Pass recency ("day"|"week"|"month"|"year") to restrict to fresh results. ' + STANDALONE_QUERY_TOOL_CLAUSE,
1344
+ inputSchema: serperWebSearchSchema,
1345
+ execute: async ({ query, recency, lang }) => {
1346
+ const cfg = deps.getLiveConfig().serper;
1347
+ if (!cfg.enabled || !cfg.apiKey || !cfg.web.enabled) {
1348
+ return { results: [], error: "Serper.dev web search is not enabled" };
1349
+ }
1350
+ deps.logger.log(`Serper.dev search for "${query}"`);
1351
+ const body = {
1352
+ q: query,
1353
+ num: cfg.web.results
1354
+ };
1355
+ applyLocaleParams(body, lang ?? deps.defaultLang);
1356
+ applyRecencyParam(body, recency);
1357
+ const res = await fetchWithTimeout(
1358
+ "https://google.serper.dev/search",
1359
+ {
1360
+ method: "POST",
1361
+ headers: HEADERS(cfg.apiKey),
1362
+ body: JSON.stringify(body)
1363
+ },
1364
+ { timeoutMs: SEARCH_TIMEOUT_MS }
1365
+ );
1366
+ if (!res.ok) {
1367
+ deps.logger.warn(`Serper.dev returned ${res.status} for "${query}"`);
1368
+ return { results: [] };
1369
+ }
1370
+ const data = await res.json();
1371
+ if (!data.organic?.length) {
1372
+ deps.logger.warn(`Serper.dev returned 0 results for "${query}"`);
1373
+ return { results: [] };
1374
+ }
1375
+ const results = data.organic.map((r) => ({
1376
+ title: r.title,
1377
+ snippet: r.snippet || "",
1378
+ url: r.link,
1379
+ source: "serper"
1380
+ }));
1381
+ deps.logger.log(`Serper.dev returned ${results.length} results for "${query}"`);
1382
+ return { results };
1383
+ }
1384
+ });
1385
+ }
1386
+ var serperWebpageScrapeSchema = z.object({
1387
+ url: z.string().describe("The URL to fetch and render")
1388
+ });
1389
+ function createSerperWebpageScrape(deps) {
1390
+ return tool({
1391
+ description: "Fetch and render a full webpage using Serper.dev scrape API. Returns clean rendered text with its title.",
1392
+ inputSchema: serperWebpageScrapeSchema,
1393
+ execute: async ({ url }) => {
1394
+ const cfg = deps.getLiveConfig().serper;
1395
+ if (!cfg.apiKey || !cfg.scrape.enabled) {
1396
+ return {
1397
+ content: "",
1398
+ error: "Serper.dev webpage scrape is not enabled"
1399
+ };
1400
+ }
1401
+ deps.logger.log(`Serper.dev Webpage scrape for "${url}"`);
1402
+ const res = await fetchWithTimeout(
1403
+ "https://scrape.serper.dev",
1404
+ {
1405
+ method: "POST",
1406
+ headers: HEADERS(cfg.apiKey),
1407
+ body: JSON.stringify({ url })
1408
+ },
1409
+ { timeoutMs: SEARCH_TIMEOUT_MS }
1410
+ );
1411
+ if (!res.ok) {
1412
+ deps.logger.warn(`Serper.dev Webpage scrape returned ${res.status} for "${url}"`);
1413
+ return { content: "", error: `HTTP ${res.status}` };
1414
+ }
1415
+ const data = await res.json();
1416
+ const content = data.text || "";
1417
+ const title = data.title || "";
1418
+ deps.logger.log(`Serper.dev Webpage scraped ${content.length} chars from "${url}"`);
1419
+ return { content, title };
1420
+ }
1421
+ });
1422
+ }
1423
+
1424
+ // src/tools/tool-factory.ts
1425
+ function extractSources(results) {
1426
+ const sources = /* @__PURE__ */ new Set();
1427
+ for (const r of results) {
1428
+ const src = r.source;
1429
+ if (!src) continue;
1430
+ sources.add(src);
1431
+ }
1432
+ return [...sources];
1433
+ }
1434
+ var summarizeResults = (data) => {
1435
+ const results = data.results;
1436
+ if (!Array.isArray(results)) return {};
1437
+ return {
1438
+ resultCount: results.length,
1439
+ sources: extractSources(results),
1440
+ sampleImageUrls: results.slice(0, 5).map((r) => r.imageUrl).filter((url) => typeof url === "string" && url.length > 0)
1441
+ };
1442
+ };
1443
+ var summarizeContent = (data) => ({
1444
+ contentLength: typeof data.content === "string" ? data.content.length : 0
1445
+ });
1446
+ var summarizeFound = (data) => ({
1447
+ found: !!data.title || !!data.id
1448
+ });
1449
+ var defaultSummarize = (data) => {
1450
+ if (Array.isArray(data.results)) return summarizeResults(data);
1451
+ if (typeof data.content === "string") return summarizeContent(data);
1452
+ return {};
1453
+ };
1454
+ function withSummary(tool22, summarize = defaultSummarize) {
1455
+ const t = tool22;
1456
+ t.summarize = summarize;
1457
+ return t;
1458
+ }
1459
+ var webFetchSchema = z.object({
1460
+ url: z.string().describe("The URL to fetch content from")
1461
+ });
1462
+ function extractArticleText(html) {
1463
+ if (!html.trim()) return "";
1464
+ const { document } = parseHTML(html);
1465
+ const turndown = new TurndownService();
1466
+ const article = document.documentElement ? new Readability(document).parse() : null;
1467
+ const content = article?.content;
1468
+ if (typeof content === "string" && content.trim()) {
1469
+ return turndown.turndown(content);
1470
+ }
1471
+ return turndown.turndown(document.body?.innerHTML ?? "");
1472
+ }
1473
+
1474
+ // src/tools/web-fetch/web-fetch.tool.ts
1475
+ function createWebFetchTool() {
1476
+ return tool({
1477
+ description: "Fetch the full content of a specific URL. Returns the main article text as Markdown (boilerplate removed). Use only when search snippets are insufficient.",
1478
+ inputSchema: webFetchSchema,
1479
+ execute: async ({ url }) => {
1480
+ const response = await fetchWithTimeout(
1481
+ url,
1482
+ {
1483
+ headers: {
1484
+ "User-Agent": "Mozilla/5.0 (compatible; TriplefBot/1.0)",
1485
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1486
+ }
1487
+ },
1488
+ { timeoutMs: SEARCH_TIMEOUT_MS }
1489
+ );
1490
+ const html = await response.text();
1491
+ return { content: extractArticleText(html) };
1492
+ }
1493
+ });
1494
+ }
1495
+ var youtubeVideoSearchSchema = z.object({
1496
+ query: z.string().describe(`${STANDALONE_QUERY_DESCRIPTION} Add the video type (e.g. review, trailer, tutorial, gameplay).`),
1497
+ count: z.number().optional().describe("Number of results (max 50)"),
1498
+ recency: z.enum(["day", "week", "month", "year"]).optional().describe(
1499
+ "Restrict results to the given past period (day=24 hours, week=7 days, month=1 month, year=1 year). Use for fresh content such as news, recent releases, or trending topics; leave unset for evergreen, historical, or general queries."
1500
+ ),
1501
+ lang: z.string().optional().describe("Two-letter ISO language code for result preference (e.g. en, de, ja)")
1502
+ });
1503
+
1504
+ // src/tools/youtube/youtube.ts
1505
+ var SEARCH_URL = "https://www.googleapis.com/youtube/v3/search";
1506
+ var VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos";
1507
+ var RECENCY_DAYS = {
1508
+ day: 1,
1509
+ week: 7,
1510
+ month: 30,
1511
+ year: 365
1512
+ };
1513
+ function publishedAfterFor(recency) {
1514
+ if (!recency) return void 0;
1515
+ return new Date(Date.now() - RECENCY_DAYS[recency] * 864e5).toISOString();
1516
+ }
1517
+ async function readErrorReason(res) {
1518
+ try {
1519
+ const body = await res.json();
1520
+ return body.error?.errors?.[0]?.reason;
1521
+ } catch {
1522
+ return void 0;
1523
+ }
1524
+ }
1525
+ function formatIsoDuration(iso) {
1526
+ const match = iso?.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/);
1527
+ if (!match) return "";
1528
+ const hours = Number(match[1] ?? 0);
1529
+ const minutes = Number(match[2] ?? 0);
1530
+ const seconds = Number(match[3] ?? 0);
1531
+ const pad = (n) => String(n).padStart(2, "0");
1532
+ return hours ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
1533
+ }
1534
+ async function fetchYoutubeVideoStats(videoIds, apiKey, logger) {
1535
+ const params = new URLSearchParams({
1536
+ part: "statistics,contentDetails,snippet",
1537
+ id: videoIds.join(","),
1538
+ key: apiKey
1539
+ });
1540
+ const res = await fetchWithTimeout(`${VIDEOS_URL}?${params}`, {}, { timeoutMs: SEARCH_TIMEOUT_MS }).catch(
1541
+ (err) => {
1542
+ logger.warn(`YouTube stats fetch failed: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
1543
+ throw err;
1544
+ }
1545
+ );
1546
+ const map = /* @__PURE__ */ new Map();
1547
+ if (!res.ok) {
1548
+ const reason = await readErrorReason(res);
1549
+ logger.warn(`YouTube stats fetch failed \u2014 HTTP ${res.status}${reason ? ` (${reason})` : ""}`);
1550
+ return map;
1551
+ }
1552
+ const data = await res.json();
1553
+ for (const item of data.items ?? []) {
1554
+ if (!item.id) continue;
1555
+ map.set(item.id, {
1556
+ viewCount: Number(item.statistics?.viewCount ?? 0),
1557
+ duration: item.contentDetails?.duration,
1558
+ lang: item.snippet?.defaultAudioLanguage ?? item.snippet?.defaultLanguage
1559
+ });
1560
+ }
1561
+ return map;
1562
+ }
1563
+ function createYoutubeVideoSearch(deps) {
1564
+ return tool({
1565
+ description: 'Search YouTube for videos using the official YouTube Data API. Returns titles, links, channel names, durations, view counts, and upload dates with direct thumbnails. Every result is an embeddable YouTube video. Pass recency ("day"|"week"|"month"|"year") to restrict to recently uploaded videos. ' + STANDALONE_QUERY_TOOL_CLAUSE,
1566
+ inputSchema: youtubeVideoSearchSchema,
1567
+ execute: async ({ query, count: reqCount, recency, lang }) => {
1568
+ const cfg = deps.getLiveConfig().youtube;
1569
+ if (!cfg.enabled || !cfg.apiKey || !cfg.videos.enabled) {
1570
+ return { results: [], error: "YouTube video search is not enabled" };
1571
+ }
1572
+ const num = Math.min(reqCount ?? cfg.videos.results, cfg.videos.results, 50);
1573
+ const params = new URLSearchParams({
1574
+ part: "snippet",
1575
+ type: "video",
1576
+ videoEmbeddable: "true",
1577
+ q: query,
1578
+ maxResults: String(num),
1579
+ order: recency ? "date" : "relevance",
1580
+ key: cfg.apiKey
1581
+ });
1582
+ const langParam = lang ?? deps.defaultLang;
1583
+ if (langParam) params.set("relevanceLanguage", langParam);
1584
+ const publishedAfter = publishedAfterFor(recency);
1585
+ if (publishedAfter) params.set("publishedAfter", publishedAfter);
1586
+ deps.logger.log(`YouTube video search for "${query}"`);
1587
+ const res = await fetchWithTimeout(`${SEARCH_URL}?${params}`, {}, { timeoutMs: SEARCH_TIMEOUT_MS }).catch(
1588
+ (err) => {
1589
+ deps.logger.warn(
1590
+ `YouTube video search failed for "${query}": ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`
1591
+ );
1592
+ throw err;
1593
+ }
1594
+ );
1595
+ if (!res.ok) {
1596
+ const reason = await readErrorReason(res);
1597
+ deps.logger.warn(
1598
+ `YouTube video search failed for "${query}" \u2014 HTTP ${res.status}${reason ? ` (${reason})` : ""}`
1599
+ );
1600
+ return { results: [], error: `HTTP ${res.status}` };
1601
+ }
1602
+ const data = await res.json();
1603
+ const items = (data.items ?? []).filter((item) => item.id?.videoId);
1604
+ if (!items.length) {
1605
+ deps.logger.warn(`YouTube returned 0 results for "${query}"`);
1606
+ return { results: [] };
1607
+ }
1608
+ const stats = await fetchYoutubeVideoStats(
1609
+ items.map((item) => item.id.videoId),
1610
+ cfg.apiKey,
1611
+ deps.logger
1612
+ );
1613
+ const results = items.map((item) => {
1614
+ const snippet = item.snippet ?? {};
1615
+ const id = item.id.videoId;
1616
+ const detail = stats.get(id);
1617
+ const thumbs = snippet.thumbnails ?? {};
1618
+ return {
1619
+ title: snippet.title ?? "",
1620
+ link: `https://www.youtube.com/watch?v=${id}`,
1621
+ snippet: snippet.description ?? "",
1622
+ channel: snippet.channelTitle ?? "",
1623
+ duration: formatIsoDuration(detail?.duration),
1624
+ date: snippet.publishedAt ?? "",
1625
+ thumbnailUrl: thumbs.maxres?.url ?? thumbs.high?.url ?? thumbs.medium?.url ?? "",
1626
+ source: "youtube",
1627
+ views: detail?.viewCount ?? 0,
1628
+ lang: detail?.lang
1629
+ };
1630
+ });
1631
+ deps.logger.log(`YouTube video search returned ${results.length} results for "${query}"`);
1632
+ return { results };
1633
+ }
1634
+ });
1635
+ }
1636
+
1637
+ export { BRIGHT_DATA_TIMEOUT_MS, HEADERS, MIN_IMAGE_HEIGHT, MIN_IMAGE_WIDTH, RECENCY_DESCRIPTION, SEARCH_RETRIES, SEARCH_TIMEOUT_MS, SOURCE, STANDALONE_QUERY_DESCRIPTION, STANDALONE_QUERY_TOOL_CLAUSE, applyLocaleParams, applyRecencyParam, brightDataImageSearchSchema, brightDataNewsSearchSchema, brightDataPlacesSearchSchema, brightDataShoppingSearchSchema, brightDataVideoSearchSchema, brightDataWebSearchSchema, brightDataWebpageScrapeSchema, buildGoogleUrl, buildYoutubeThumbnailUrl, createBrightDataImageSearch, createBrightDataNewsSearch, createBrightDataPlacesSearch, createBrightDataShoppingSearch, createBrightDataVideoSearch, createBrightDataWebSearch, createBrightDataWebpageScrape, createMemoryDeleteTool, createMemoryRecallTool, createMemoryRememberTool, createSerperBusinessReviewsSearch, createSerperImageSearch, createSerperNewsSearch, createSerperPlacesSearch, createSerperShoppingSearch, createSerperVideoSearch, createSerperWebSearch, createSerperWebpageScrape, createVariantRequestTool, createWebFetchTool, createYoutubeVideoSearch, defaultSummarize, engineEnabled, fetchWithTimeout, isGoogleHostUrl, localizedQuerySuffix, meetsMinimumImageDimensions, memoryDeleteSchema, memoryRecallSchema, memoryRememberSchema, pickMerchantResult, requestBrightData, resolveSerperShopOfferLinks, serperBusinessReviewsSearchSchema, serperImageSearchSchema, serperNewsSearchSchema, serperPlacesSearchSchema, serperShoppingSearchSchema, serperVideoSearchSchema, serperWebSearchSchema, serperWebpageScrapeSchema, storeHostToken, summarizeContent, summarizeFound, summarizeResults, tbsSizeLabelForPixels, variantRequestSchema, webFetchSchema, withSummary, youtubeVideoSearchSchema };